Merge "Add a dedicated servlet for checking a user's authorization"
diff --git a/.bazelproject b/.bazelproject
index 8a726eb..4194604 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -22,3 +22,6 @@
 
 build_flags:
   --javacopt=-g
+  # Temporarily add an option to work around an error in the Bazel IntelliJ plugin.
+  # TODO(aliceks): Remove when issue is fixed.
+  --incompatible_depset_is_not_iterable=false
diff --git a/.bazelrc b/.bazelrc
new file mode 100644
index 0000000..d6d4ce6
--- /dev/null
+++ b/.bazelrc
@@ -0,0 +1,11 @@
+build --workspace_status_command=./tools/workspace-status.sh --strategy=Closure=worker
+build --repository_cache=~/.gerritcodereview/bazel-cache/repository
+build --experimental_strict_action_env
+build --action_env=PATH
+build --disk_cache=~/.gerritcodereview/bazel-cache/cas
+build --java_toolchain //tools:error_prone_warnings_toolchain
+
+test --build_tests_only
+test --test_output=errors
+
+import tools/remote-bazelrc
diff --git a/.bazelversion b/.bazelversion
new file mode 100644
index 0000000..1b58cc1
--- /dev/null
+++ b/.bazelversion
@@ -0,0 +1 @@
+0.27.0
diff --git a/.gitignore b/.gitignore
index 5fdc85b..75cdfb5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,27 +6,43 @@
 *.swp
 *~
 .DS_Store
-.gwt_work_dir
 /.apt_generated
+/.apt_generated_tests
 /.bazel_path
 /.buckd
 /.classpath
 /.factorypath
 /.idea
+/.ijwb
 /.metadata
 /.project
 /.settings/org.eclipse.ltk.core.refactoring.prefs
 /.settings/org.eclipse.m2e.core.prefs
 /.settings/org.maven.ide.eclipse.prefs
+/.vscode
 /bazel-*
 /bin/
+/bower_components/
 /eclipse-out
 /extras
 /gerrit-package-plugins
-/gwt-unitCache
 /infer-out
 /local.properties
-/plugins/cookbook-plugin/
+/node_modules/
+/package-lock.json
+/plugins/*
+!/plugins/BUILD
+!/plugins/codemirror-editor
+!/plugins/commit-message-length-validator
+!/plugins/delete-project
+!/plugins/download-commands
+!/plugins/external_plugin_deps.bzl
+!/plugins/gitiles
+!/plugins/hooks
+!/plugins/plugin-manager
+!/plugins/replication
+!/plugins/reviewnotes
+!/plugins/singleusergroup
+!/plugins/webhooks
 /test_site
 /tools/format
-/.vscode
diff --git a/.gitmodules b/.gitmodules
index 8d75bcc..6844f6a 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -8,16 +8,31 @@
 	url = ../plugins/commit-message-length-validator
 	branch = .
 
+[submodule "plugins/delete-project"]
+	path = plugins/delete-project
+	url = ../plugins/delete-project
+	branch = .
+
 [submodule "plugins/download-commands"]
 	path = plugins/download-commands
 	url = ../plugins/download-commands
 	branch = .
 
+[submodule "plugins/gitiles"]
+	path = plugins/gitiles
+	url = ../plugins/gitiles
+	branch = .
+
 [submodule "plugins/hooks"]
 	path = plugins/hooks
 	url = ../plugins/hooks
 	branch = .
 
+[submodule "plugins/plugin-manager"]
+	path = plugins/plugin-manager
+	url = ../plugins/plugin-manager
+	branch = .
+
 [submodule "plugins/replication"]
 	path = plugins/replication
 	url = ../plugins/replication
@@ -32,3 +47,8 @@
 	path = plugins/singleusergroup
 	url = ../plugins/singleusergroup
 	branch = .
+
+[submodule "plugins/webhooks"]
+	path = plugins/webhooks
+	url = ../plugins/webhooks
+	branch = .
diff --git a/.mailmap b/.mailmap
index 4c71059..b5c119c 100644
--- a/.mailmap
+++ b/.mailmap
@@ -6,12 +6,14 @@
 Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex.ryazantsev <alex.ryazantsev@gmail.com>
 Andrew Bonventre <andybons@chromium.org>                                                    <andybons@google.com>
 Becky Siegel <beckysiegel@google.com>                                                       beckysiegel <beckysiegel@google.com>
+Ben Rohlfs <brohlfs@google.com>                                                             brohlfs <brohlfs@google.com>
 Brad Larson <bklarson@gmail.com>                                                            <brad.larson@garmin.com>
 Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonyericsson.com>
 Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonymobile.com>
 Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
 Changcheng Xiao <xchangcheng@google.com>                                                    xchangcheng
 Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
+Darrien Glasser <darrien@arista.com>                                                        darrien <darrien@arista.com>
 Dave Borowitz <dborowitz@google.com>                                                        <dborowitz@google.com>
 David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
 David Ostrovsky <david@ostrovsky.org>                                                       <david.ostrovsky@gmail.com>
@@ -37,6 +39,8 @@
 Joel Dodge <dodgejoel@gmail.com>                                                            dodgejoel <dodgejoel@gmail.com>
 Johan Björk <jbjoerk@gmail.com>                                                             Johan Bjork <phb@spotify.com>
 JT Olds <hello@jtolds.com>                                                                  <jtolds@gmail.com>
+Kasper Nilsson <kaspern@google.com>                                                         <kaspern@google.com>
+Lawrence Dubé <ldube@audiokinetic.com>                                                      <ldube@audiokinetic.com>
 Lei Sun <lei.sun01@sap.com>                                                                 LeiSun <lei.sun01@sap.com>
 Lincoln Oliveira Campos Do Nascimento <lincoln.oliveiracamposdonascimento@sonyericsson.com> lincoln <lincoln.oliveiracamposdonascimento@sonyericsson.com>
 Luca Milanesio <luca.milanesio@gmail.com>                                                   <luca@gitent-scm.com>
diff --git a/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch b/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch
deleted file mode 100644
index 3ccf5cd..0000000
--- a/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch
+++ /dev/null
@@ -1,133 +0,0 @@
-Date: Wed, 30 May 2018 21:22:18 +0200
-Subject: [PATCH] Replace native {http,git}_archive with Skylark rules
-
-See [1] for more details.
-
-Test Plan:
-
-* Apply this CL on Bazel master: [2] and build bazel
-* Run with this custom built bazel version:
-
-  $ bazel test //javatests/...
-  $ bazel test //closure/...
-
-[1] https://groups.google.com/d/topic/bazel-discuss/dO2MHQLwJF0/discussion
-[2] https://bazel-review.googlesource.com/#/c/bazel/+/55932/
----
- closure/repositories.bzl | 23 ++++++++++++-----------
- 1 file changed, 12 insertions(+), 11 deletions(-)
-
-diff --git a/closure/repositories.bzl b/closure/repositories.bzl
-index 9b84a72..2816fb6 100644
---- closure/repositories.bzl
-+++ closure/repositories.bzl
-@@ -14,6 +14,7 @@
- 
- """External dependencies for Closure Rules."""
- 
-+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
- load("//closure/private:java_import_external.bzl", "java_import_external")
- load("//closure/private:platform_http_file.bzl", "platform_http_file")
- load("//closure:filegroup_external.bzl", "filegroup_external")
-@@ -405,7 +406,7 @@ def com_google_common_html_types():
-   )
- 
- def com_google_common_html_types_html_proto():
--  native.http_file(
-+  http_file(
-       name = "com_google_common_html_types_html_proto",
-       sha256 = "6ece202f11574e37d0c31d9cf2e9e11a0dbc9218766d50d211059ebd495b49c3",
-       urls = [
-@@ -633,7 +634,7 @@ def com_google_javascript_closure_compiler():
- 
- def com_google_javascript_closure_library():
-   # After updating: bazel run //closure/library:regenerate -- "$PWD"
--  native.new_http_archive(
-+  http_archive(
-       name = "com_google_javascript_closure_library",
-       urls = [
-           "https://mirror.bazel.build/github.com/google/closure-library/archive/v20180405.tar.gz",
-@@ -658,7 +659,7 @@ def com_google_jsinterop_annotations():
- 
- def com_google_protobuf():
-   # Note: Protobuf 3.6.0+ is going to use C++11
--  native.http_archive(
-+  http_archive(
-       name = "com_google_protobuf",
-       strip_prefix = "protobuf-3.5.1",
-       sha256 = "826425182ee43990731217b917c5c3ea7190cfda141af4869e6d4ad9085a740f",
-@@ -669,7 +670,7 @@ def com_google_protobuf():
-   )
- 
- def com_google_protobuf_js():
--  native.new_http_archive(
-+  http_archive(
-       name = "com_google_protobuf_js",
-       urls = [
-           "https://mirror.bazel.build/github.com/google/protobuf/archive/v3.5.1.tar.gz",
-@@ -722,7 +723,7 @@ def com_google_template_soy():
-   )
- 
- def com_google_template_soy_jssrc():
--  native.new_http_archive(
-+  http_archive(
-       name = "com_google_template_soy_jssrc",
-       sha256 = "c76ab4cb6e46a7c76336640b3c40d6897b420209a6c0905cdcd32533dda8126a",
-       urls = [
-@@ -757,7 +758,7 @@ def com_squareup_javapoet():
-   )
- 
- def fonts_noto_hinted_deb():
--  native.http_file(
-+  http_file(
-       name = "fonts_noto_hinted_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fonts-noto/fonts-noto-hinted_20161116-1_all.deb",
-@@ -767,7 +768,7 @@ def fonts_noto_hinted_deb():
-   )
- 
- def fonts_noto_mono_deb():
--  native.http_file(
-+  http_file(
-       name = "fonts_noto_mono_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fonts-noto/fonts-noto-mono_20161116-1_all.deb",
-@@ -801,7 +802,7 @@ def javax_inject():
-   )
- 
- def libexpat_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libexpat_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/e/expat/libexpat1_2.1.0-6+deb8u3_amd64.deb",
-@@ -811,7 +812,7 @@ def libexpat_amd64_deb():
-   )
- 
- def libfontconfig_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libfontconfig_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fontconfig/libfontconfig1_2.11.0-6.3+deb8u1_amd64.deb",
-@@ -821,7 +822,7 @@ def libfontconfig_amd64_deb():
-   )
- 
- def libfreetype_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libfreetype_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/freetype/libfreetype6_2.5.2-3+deb8u1_amd64.deb",
-@@ -831,7 +832,7 @@ def libfreetype_amd64_deb():
-   )
- 
- def libpng_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libpng_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/libp/libpng/libpng12-0_1.2.50-2+deb8u2_amd64.deb",
--- 
-2.16.3
-
diff --git a/BUILD b/BUILD
index 91e2dec..3989a75 100644
--- a/BUILD
+++ b/BUILD
@@ -1,15 +1,28 @@
-package(default_visibility = ["//visibility:public"])
-
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:pkg_war.bzl", "pkg_war")
 
+package(default_visibility = ["//visibility:public"])
+
+config_setting(
+    name = "java9",
+    values = {
+        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_java9",
+    },
+)
+
+config_setting(
+    name = "java_next",
+    values = {
+        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_vanilla",
+    },
+)
+
 genrule(
     name = "gen_version",
     outs = ["version.txt"],
     cmd = ("cat bazel-out/volatile-status.txt bazel-out/stable-status.txt | " +
            "grep STABLE_BUILD_GERRIT_LABEL | cut -d ' ' -f 2 > $@"),
     stamp = 1,
-    visibility = ["//visibility:public"],
 )
 
 genrule(
@@ -17,7 +30,6 @@
     srcs = ["//Documentation:licenses.txt"],
     outs = ["LICENSES.txt"],
     cmd = "cp $< $@",
-    visibility = ["//visibility:public"],
 )
 
 pkg_war(
@@ -31,15 +43,9 @@
 )
 
 pkg_war(
-    name = "polygerrit",
-    ui = "polygerrit",
-)
-
-pkg_war(
     name = "release",
     context = ["//plugins:core"],
     doc = True,
-    ui = "ui_optdbg_r",
 )
 
 pkg_war(
@@ -57,14 +63,11 @@
     "//plugins:plugin-api_deploy.jar",
     "//plugins:plugin-api-sources_deploy.jar",
     "//plugins:plugin-api-javadoc",
-    "//gerrit-plugin-gwtui:gwtui-api_deploy.jar",
-    "//gerrit-plugin-gwtui:gwtui-api-source_deploy.jar",
-    "//gerrit-plugin-gwtui:gwtui-api-javadoc",
 ]
 
 genrule2(
     name = "api",
-    testonly = 1,
+    testonly = True,
     srcs = API_DEPS,
     outs = ["api.zip"],
     cmd = " && ".join([
diff --git a/Documentation/BUILD b/Documentation/BUILD
index 4177f51..52ab7a8 100644
--- a/Documentation/BUILD
+++ b/Documentation/BUILD
@@ -1,10 +1,8 @@
-package(default_visibility = ["//visibility:public"])
-
-load("//tools/bzl:asciidoc.bzl", "documentation_attributes")
-load("//tools/bzl:asciidoc.bzl", "genasciidoc")
-load("//tools/bzl:asciidoc.bzl", "genasciidoc_zip")
+load("//tools/bzl:asciidoc.bzl", "documentation_attributes", "genasciidoc", "genasciidoc_zip")
 load("//tools/bzl:license.bzl", "license_map")
 
+package(default_visibility = ["//visibility:public"])
+
 exports_files([
     "replace_macros.py",
 ])
@@ -40,32 +38,38 @@
         ":prettify_files",
         "//:LICENSES.txt",
     ],
-    visibility = ["//visibility:public"],
 )
 
 license_map(
     name = "licenses",
     opts = ["--asciidoctor"],
     targets = [
-        "//gerrit-gwtui:ui_module",
         "//polygerrit-ui/app:polygerrit_ui",
         "//java/com/google/gerrit/pgm",
     ],
-    visibility = ["//visibility:public"],
 )
 
 license_map(
     name = "js_licenses",
     targets = [
-        "//gerrit-gwtui:ui_module",
         "//polygerrit-ui/app:polygerrit_ui",
     ],
-    visibility = ["//visibility:public"],
+)
+
+sh_test(
+    name = "check_licenses",
+    srcs = ["check_licenses_test.sh"],
+    data = [
+        "js_licenses.gen.txt",
+        "js_licenses.txt",
+        "licenses.gen.txt",
+        "licenses.txt",
+    ],
 )
 
 DOC_DIR = "Documentation"
 
-SRCS = glob(["*.txt"]) + [":licenses.txt"]
+SRCS = glob(["*.txt"])
 
 genrule(
     name = "index",
@@ -88,7 +92,6 @@
     srcs = SRCS,
     attributes = documentation_attributes(),
     backend = "html5",
-    visibility = ["//visibility:public"],
 )
 
 genasciidoc_zip(
@@ -97,7 +100,6 @@
     attributes = documentation_attributes(),
     backend = "html5",
     directory = DOC_DIR,
-    visibility = ["//visibility:public"],
 )
 
 genasciidoc_zip(
@@ -107,5 +109,4 @@
     backend = "html5",
     directory = DOC_DIR,
     searchbox = False,
-    visibility = ["//visibility:public"],
 )
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 397b99a..d333347 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -5,6 +5,12 @@
 to those groups.  Access rights cannot be granted to individual
 users.
 
+To view/edit the access controls for a specific project, first
+navigate to the projects page: for example,
+https://gerrit-review.googlesource.com/admin/repos/ . Then click on
+the individual project, and then click Access. This will bring you
+to a url that looks like
+https://gerrit-review.googlesource.com/admin/repos/gerrit,access
 
 [[system_groups]]
 == System Groups
@@ -721,13 +727,29 @@
 A user must have this access granted in order to see a project, its
 changes, or any of its data.
 
-This category has a special behavior, where the per-project ACL is
-evaluated before the global all projects ACL.  If the per-project
-ACL has granted `Read` with 'DENY', and does not otherwise grant
-`Read` with 'ALLOW', then a `Read` in the all projects ACL
-is ignored.  This behavior is useful to hide a handful of projects
+[[read_special_behaviors]]
+==== Special behaviors
+
+This category has multiple special behaviors:
+
+The per-project ACL is evaluated before the global all projects ACL.
+If the per-project ACL has granted `Read` with 'DENY', and does not
+otherwise grant `Read` with 'ALLOW', then a `Read` in the all projects
+ACL is ignored.  This behavior is useful to hide a handful of projects
 on an otherwise public server.
 
+You cannot grant `Read` on the `refs/tags/` namespace.  Visibility to
+`refs/tags/` is derived from `Read` grants on refs namespaces other than
+`refs/tags/`, `refs/changes/`, and `refs/cache-automerge/` by finding
+tags reachable from those refs.  For example, if a tag `refs/tags/test`
+points to a commit on the branch `refs/heads/master`, then allowing
+`Read` access to `refs/heads/master` would also allow access to
+`refs/tags/test`.  If a tag is reachable from multiple refs, allowing
+access to any of those refs allows access to the tag.
+
+[[read_typical_usage]]
+==== Typical usage
+
 For an open source, public Gerrit installation it is common to grant
 `Read` to `Anonymous Users` in the `All-Projects` ACL, enabling
 casual browsing of any project's changes, as well as fetching any
@@ -828,7 +850,7 @@
 [[category_view_private_changes]]
 === View Private Changes
 
-This category permits users to view all private changes.
+This category permits users to view all private changes and all change edit refs.
 
 The change owner and any explicitly added reviewers can always see
 private changes (even without having the `View Private Changes` access
@@ -841,6 +863,17 @@
 This category permits users to delete their own changes if they are not merged
 yet. This means only own changes that are open or abandoned can be deleted.
 
+[[category_delete_changes]]
+=== Delete Changes
+
+This category permits users to delete other users' changes if they are not merged
+yet. This means only changes that are open or abandoned can be deleted.
+
+Having this permission implies having the link:#category_delete_own_changes[
+Delete Own Changes] permission.
+
+Administrators may always delete changes without having this permission.
+
 [[category_edit_topic_name]]
 === Edit Topic Name
 
@@ -894,7 +927,7 @@
 
 Suggested access rights to grant:
 
-* xref:category_read[`Read`] on 'refs/heads/\*' and 'refs/tags/*'
+* xref:category_read[`Read`] on 'refs/heads/\*'
 * xref:category_push[`Push`] to 'refs/for/refs/heads/*'
 * link:config-labels.html#label_Code-Review[`Code-Review`] with range '-1' to '+1' for 'refs/heads/*'
 
@@ -922,7 +955,7 @@
 
 Suggested access rights to grant:
 
-* xref:category_read[`Read`] on 'refs/heads/\*' and 'refs/tags/*'
+* xref:category_read[`Read`] on 'refs/heads/\*'
 * xref:category_push[`Push`] to 'refs/for/refs/heads/*'
 * xref:category_push_merge[`Push merge commit`] to 'refs/for/refs/heads/*'
 * xref:category_forge_author[`Forge Author Identity`] to 'refs/heads/*'
@@ -977,7 +1010,7 @@
 
 Suggested access rights to grant, that won't block changes:
 
-* xref:category_read[`Read`] on 'refs/heads/\*' and 'refs/tags/*'
+* xref:category_read[`Read`] on 'refs/heads/\*'
 * link:config-labels.html#label_Code-Review[`Label: Code-Review`] with range '-1' to '0' for 'refs/heads/*'
 * link:config-labels.html#label_Verified[`Label: Verified`] with range '0' to '+1' for 'refs/heads/*'
 
@@ -1072,21 +1105,43 @@
 [[block]]
 === 'BLOCK' access rule
 
-The 'BLOCK' rule blocks a permission globally. An inherited 'BLOCK'
-rule cannot be overridden in the inheriting project. Any 'ALLOW' rule
-from an inheriting project, which conflicts with an inherited 'BLOCK'
-rule will not be honored. Searching for 'BLOCK' rules, in the chain
-of parent projects, ignores the Exclusive flag, unless the rule with
-the Exclusive flag is defined on the same project as the 'BLOCK'
-rule. This means within the same project a 'BLOCK' rule can be
-overruled by 'ALLOW' rules on the same access section and 'ALLOW'
-rules with Exclusive flag on access section for more specific refs.
+The 'BLOCK' rule can be used to take away rights from users. The BLOCK rule
+works across project inheritance, from the top down, so an administrator can
+use 'BLOCK' rules to enforce site-wide restrictions.
+
+For example, if a user in the 'Foo Users' group tries to push to
+'refs/heads/mater' with the permissions below, that user will be blocked
+
+[options="header"]
+|=========================================================================
+|Project      | Inherits From    |Reference Name |Permissions            |
+|All-Projects | -                |refs/*         |push = block Foo Users |
+|Foo          | All-Projects     |refs/heads/*   |push = Foo Users       |
+|=========================================================================
+
+'BLOCK' rules are evaluated starting from the parent project, and after a 'BLOCK'
+rule is found to apply, further rules are ignored. Hence, in this example, the
+permissions on child-project is ignored.
+
+----
+All-Projects: project.config
+  [access "refs/heads/*"]
+    push = block group X
+
+child-project: project.config
+  [access "refs/heads/*"]
+    exclusiveGroupPermissions = push
+    push = group X
+----
+
+In this case push for group 'X' will be blocked, even though the Exclusive
+flag was set for the child-project.
 
 A 'BLOCK' rule that blocks the 'push' permission blocks any type of push,
 force or not. A blocking force push rule blocks only force pushes, but
 allows non-forced pushes if an 'ALLOW' rule would have permitted it.
 
-It is also possible to block label ranges.  To block a group 'X' from voting
+It is also possible to block label ranges. To block a group 'X' from voting
 '-2' and '+2', but keep their existing voting permissions for the '-1..+1'
 range intact we would define:
 
@@ -1113,6 +1168,24 @@
 In this case a user which is a member of the group 'Y' will still be allowed to
 push to 'refs/heads/*' even if it is a member of the group 'X'.
 
+=== 'BLOCK' and 'ALLOW' rules in the same project with the Exclusive flag
+
+When a project contains a 'BLOCK' and 'ALLOW' that uses the Exclusive flag in a
+more specific reference, the 'ALLOW' rule with the Exclusive flag will override
+the 'BLOCK' rule:
+
+----
+  [access "refs/*"]
+    read = block group X
+
+  [access "refs/heads/*"]
+    exclusiveGroupPermissions = read
+    read = group X
+----
+
+In this case a user which is a member of the group 'X' will still be allowed to
+read 'refs/heads/*'.
+
 [NOTE]
 An 'ALLOW' rule overrides a 'BLOCK' rule only when both of them are
 inside the same access section of the same project. An 'ALLOW' rule in a
@@ -1187,8 +1260,7 @@
 [[capability_accessDatabase]]
 === Access Database
 
-Allow users to access the database using the `gsql` command, and view code
-review metadata refs in repositories.
+Allow users to view code review metadata refs in repositories.
 
 
 [[capability_administrateServer]]
@@ -1342,7 +1414,14 @@
 any of their groups is used.
 
 This limit applies not only to the link:cmd-query.html[`gerrit query`]
-command, but also to the web UI results pagination size.
+command, but also to the web UI results pagination size in the new
+PolyGerrit UI and, limited to the full project list, in the old GWT UI.
+
+
+[[capability_readAs]]
+=== Read As
+
+Allow users to impersonate any user to see which refs they can read.
 
 
 [[capability_runAs]]
@@ -1572,15 +1651,18 @@
 * The 'force' setting has no effect on label ranges.
 
 * BLOCK specifies the values that a group cannot vote, eg.
++
 ----
   label-Code-Review = block -2..+2 group Anonymous Users
 ----
++
 prevents all users from voting -2 or +2.
 
 * DENY works for votes too, with the same caveats
 
 * The blocked vote range is the union of the all the blocked vote
 ranges across projects, so in
++
 ----
 All-Projects: project.config
      label-Code-Review = block -2..+1 group A
@@ -1588,15 +1670,18 @@
 Child-Project: project-config
      label-Code-Review = block -1..+2 group A
 ----
++
 members of group A cannot vote at all in the Child-Project.
 
 
 * The allowed vote range is the union of vote ranges allowed by all of
 the ALLOW rules. For example, in
++
 ----
      label-Code-Review = -2..+1 group A
      label-Code-Review = -1..+2 group B
 ----
++
 a user that is both in A and B can vote -2..2.
 
 
diff --git a/Documentation/check_licenses_test.sh b/Documentation/check_licenses_test.sh
new file mode 100755
index 0000000..a65a827
--- /dev/null
+++ b/Documentation/check_licenses_test.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+hook=$(pwd)/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+
+for f in  js_licenses licenses ; do
+  if ! diff -u Documentation/${f}.txt Documentation/${f}.gen.txt  ; then
+     echo ""
+     echo "FAIL: ${f}.txt out of date"
+     echo "to fix: "
+     echo ""
+     echo "  cp bazel-genfiles/Documentation/${f}.gen.txt Documentation/${f}.txt"
+     echo ""
+     exit 1
+  fi
+done
diff --git a/Documentation/cmd-close-connection.txt b/Documentation/cmd-close-connection.txt
index 973441e..c161541 100644
--- a/Documentation/cmd-close-connection.txt
+++ b/Documentation/cmd-close-connection.txt
@@ -1,7 +1,7 @@
 = gerrit close-connection
 
 == NAME
-gerrit close-connection - Close the specified SSH connection
+gerrit close-connection - Close the specified SSH connection.
 
 == SYNOPSIS
 [verse]
diff --git a/Documentation/cmd-create-account.txt b/Documentation/cmd-create-account.txt
index 62bd0aa..617191f 100644
--- a/Documentation/cmd-create-account.txt
+++ b/Documentation/cmd-create-account.txt
@@ -69,7 +69,7 @@
 the 'Non-Interactive Users' group.
 
 ----
-	$ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit create-account --group "'Non-Interactive Users'" --ssh-key - watcher
+$ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit create-account --group "'Non-Interactive Users'" --ssh-key - watcher
 ----
 
 GERRIT
diff --git a/Documentation/cmd-create-branch.txt b/Documentation/cmd-create-branch.txt
index 336af56d..2060917 100644
--- a/Documentation/cmd-create-branch.txt
+++ b/Documentation/cmd-create-branch.txt
@@ -1,7 +1,7 @@
 = gerrit create-branch
 
 == NAME
-gerrit create-branch - Create a new branch
+gerrit create-branch - Create a new branch.
 
 == SYNOPSIS
 [verse]
@@ -40,7 +40,7 @@
 the project 'myproject'.
 
 ----
-    $ ssh -p 29418 review.example.com gerrit create-branch myproject newbranch master
+$ ssh -p 29418 review.example.com gerrit create-branch myproject newbranch master
 ----
 
 GERRIT
diff --git a/Documentation/cmd-create-group.txt b/Documentation/cmd-create-group.txt
index 7f1f463..2ba611c 100644
--- a/Documentation/cmd-create-group.txt
+++ b/Documentation/cmd-create-group.txt
@@ -68,14 +68,14 @@
 `developer1` and `developer2`.  The group should be owned by itself:
 
 ----
-	$ ssh -p 29418 user@review.example.com gerrit create-group --member developer1 --member developer2 gerritdev
+$ ssh -p 29418 user@review.example.com gerrit create-group --member developer1 --member developer2 gerritdev
 ----
 
 Create a new account group called `Foo` owned by the `Foo-admin` group.
 Put `developer1` as the initial member and include group description:
 
 ----
-	$ ssh -p 29418 user@review.example.com gerrit create-group --owner Foo-admin --member developer1 --description "'Foo description'" Foo
+$ ssh -p 29418 user@review.example.com gerrit create-group --owner Foo-admin --member developer1 --description "'Foo description'" Foo
 ----
 
 Note that it is necessary to quote the description twice.  The local
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index e8c3857..9e3d70b 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -1,7 +1,7 @@
 = gerrit create-project
 
 == NAME
-gerrit create-project - Create a new hosted project
+gerrit create-project - Create a new hosted project.
 
 == SYNOPSIS
 [verse]
@@ -28,9 +28,8 @@
 == DESCRIPTION
 Creates a new bare Git repository under `gerrit.basePath`, using
 the project name supplied.  The newly created repository is empty
-(has no commits), but is registered in the Gerrit database so that
-the initial commit may be uploaded for review, or initial content
-can be pushed directly into a branch.
+(has no commits), and the initial content may either be uploaded for
+review, or pushed directly to a branch.
 
 If replication is enabled, this command also connects to each of
 the configured remote systems over SSH and uses command line git
@@ -61,15 +60,17 @@
 --owner::
 -o::
 	Identifier of the group(s) which will initially own this repository.
++
+--
+This can be:
 
-        This can be:
-
-        * the UUID of the group
-        * the legacy numeric ID of the group
-        * the name of the group if it is unique
-
-	The specified group(s) must already be defined within Gerrit.
-	Several groups can be specified on the command line.
+* the UUID of the group
+* the legacy numeric ID of the group
+* the name of the group if it is unique
+--
++
+The specified group(s) must already be defined within Gerrit.
+Several groups can be specified on the command line.
 +
 Defaults to what is specified by `repository.*.ownerGroup`
 in gerrit.config.
@@ -117,7 +118,7 @@
 Defaults to MERGE_IF_NECESSARY unless
 link:config-gerrit.html#repository.name.defaultSubmitType[
 repository.<name>.defaultSubmitType] is set to a different value.
-For more details see link:project-configuration.html#submit_type[
+For more details see link:config-project-config.html#submit-type[
 Submit Types].
 
 --use-content-merge::
@@ -180,13 +181,13 @@
 Create a new project called `tools/gerrit`:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit create-project tools/gerrit.git
+$ ssh -p 29418 review.example.com gerrit create-project tools/gerrit.git
 ----
 
 Create a new project with a description:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit create-project tool.git --description "'Tools used by build system'"
+$ ssh -p 29418 review.example.com gerrit create-project tool.git --description "'Tools used by build system'"
 ----
 
 Note that it is necessary to quote the description twice.  The local
@@ -199,7 +200,7 @@
 perform remote repository creation by a Bourne shell script:
 
 ----
-  mkdir -p '/base/project.git' && cd '/base/project.git' && git init --bare && git update-ref HEAD refs/heads/master
+mkdir -p '/base/project.git' && cd '/base/project.git' && git init --bare && git update-ref HEAD refs/heads/master
 ----
 
 For this to work successfully the remote system must be able to run
diff --git a/Documentation/cmd-flush-caches.txt b/Documentation/cmd-flush-caches.txt
index 9ba4808..5a84b9d 100644
--- a/Documentation/cmd-flush-caches.txt
+++ b/Documentation/cmd-flush-caches.txt
@@ -1,7 +1,7 @@
 = gerrit flush-caches
 
 == NAME
-gerrit flush-caches - Flush some/all server caches from memory
+gerrit flush-caches - Flush some/all server caches from memory.
 
 == SYNOPSIS
 [verse]
@@ -16,7 +16,7 @@
 truth when it needs the information again.
 
 Flushing a cache may be necessary if an administrator modifies
-database records directly in the database, rather than going through
+NoteDb metadata directly in a repository, rather than going through
 the Gerrit web interface.
 
 If no options are supplied, defaults to `--all`.
@@ -58,40 +58,40 @@
 List caches available for flushing:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit flush-caches --list
-	accounts
-	diff
-	groups
-	ldap_groups
-	openid
-	projects
-	sshkeys
-	web_sessions
+$ ssh -p 29418 review.example.com gerrit flush-caches --list
+accounts
+diff
+groups
+ldap_groups
+openid
+projects
+sshkeys
+web_sessions
 ----
 
 Flush all caches known to the server, forcing them to recompute:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit flush-caches --all
+$ ssh -p 29418 review.example.com gerrit flush-caches --all
 ----
 
 or
 
 ----
-	$ ssh -p 29418 review.example.com gerrit flush-caches
+$ ssh -p 29418 review.example.com gerrit flush-caches
 ----
 
 Flush only the "sshkeys" cache, after manually editing an SSH key
 for a user:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit flush-caches --cache sshkeys
+$ ssh -p 29418 review.example.com gerrit flush-caches --cache sshkeys
 ----
 
 Flush "web_sessions", forcing all users to sign-in again:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit flush-caches --cache web_sessions
+$ ssh -p 29418 review.example.com gerrit flush-caches --cache web_sessions
 ----
 
 == SEE ALSO
diff --git a/Documentation/cmd-gc.txt b/Documentation/cmd-gc.txt
index 1d1cc00..390dce1 100644
--- a/Documentation/cmd-gc.txt
+++ b/Documentation/cmd-gc.txt
@@ -1,7 +1,7 @@
 = gerrit gc
 
 == NAME
-gerrit gc - Run the Git garbage collection
+gerrit gc - Run the Git garbage collection.
 
 == SYNOPSIS
 [verse]
@@ -54,19 +54,19 @@
 Run the Git garbage collection for the projects 'myProject' and
 'yourProject':
 ----
-	$ ssh -p 29418 review.example.com gerrit gc myProject yourProject
-	collecting garbage for "myProject":
-	...
-	done.
+$ ssh -p 29418 review.example.com gerrit gc myProject yourProject
+collecting garbage for "myProject":
+...
+done.
 
-	collecting garbage for "yourProject":
-	...
-	done.
+collecting garbage for "yourProject":
+...
+done.
 ----
 
 Run the Git garbage collection for all projects:
 ----
-	$ ssh -p 29418 review.example.com gerrit gc --all
+$ ssh -p 29418 review.example.com gerrit gc --all
 ----
 
 GERRIT
diff --git a/Documentation/cmd-gsql.txt b/Documentation/cmd-gsql.txt
deleted file mode 100644
index d2eb783..0000000
--- a/Documentation/cmd-gsql.txt
+++ /dev/null
@@ -1,64 +0,0 @@
-= gerrit gsql
-
-== NAME
-gerrit gsql - Administrative interface to active database
-
-== SYNOPSIS
-[verse]
---
-_ssh_ -p <port> <host> _gerrit gsql_
-  [--format {PRETTY | JSON | JSON_SINGLE}]
-  [-c QUERY]
---
-
-== DESCRIPTION
-Provides interactive query support directly against the underlying
-SQL database used by the host Gerrit server.  All SQL statements
-are supported, including SELECT, UPDATE, INSERT, DELETE and ALTER.
-
-== OPTIONS
---format::
-	Set the format records are output in.  In PRETTY (the
-	default) records are displayed in a tabular output suitable
-	for reading by a human on a sufficiently wide terminal.
-	In JSON mode records are output as JSON objects using the
-	column names as the property names, one object per line.
-	In JSON_SINGLE mode the whole result set is output as a
-	single JSON object.
-
--c::
-	Execute the single query statement supplied, and then exit.
-
-== ACCESS
-Caller must have been granted the
-link:access-control.html#capability_accessDatabase[Access Database]
-global capability.
-
-== SCRIPTING
-Intended for interactive use only, unless format is JSON, or
-JSON_SINGLE.
-
-== EXAMPLES
-To manually correct a user's SSH user name:
-
-----
-	$ ssh -p 29418 review.example.com gerrit gsql
-	Welcome to Gerrit Code Review v2.0.25
-	(PostgreSQL 8.3.8)
-
-	Type '\h' for help.  Type '\r' to clear the buffer.
-
-	gerrit> update accounts set ssh_user_name = 'alice' where account_id=1;
-	UPDATE 1; 1 ms
-	gerrit> \q
-	Bye
-
-	$ ssh -p 29418 review.example.com gerrit flush-caches --cache sshkeys --cache accounts
-----
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/cmd-hook-commit-msg.txt b/Documentation/cmd-hook-commit-msg.txt
index ffdd5da..49d5c17 100644
--- a/Documentation/cmd-hook-commit-msg.txt
+++ b/Documentation/cmd-hook-commit-msg.txt
@@ -20,24 +20,24 @@
 for a project, the hook will modify a commit message such as:
 
 ----
-  Improve foo widget by attaching a bar.
+Improve foo widget by attaching a bar.
 
-  We want a bar, because it improves the foo by providing more
-  wizbangery to the dowhatimeanery.
+We want a bar, because it improves the foo by providing more
+wizbangery to the dowhatimeanery.
 
-  Signed-off-by: A. U. Thor <author@example.com>
+Signed-off-by: A. U. Thor <author@example.com>
 ----
 
 by inserting a new `Change-Id: ` line in the footer:
 
 ----
-  Improve foo widget by attaching a bar.
+Improve foo widget by attaching a bar.
 
-  We want a bar, because it improves the foo by providing more
-  wizbangery to the dowhatimeanery.
+We want a bar, because it improves the foo by providing more
+wizbangery to the dowhatimeanery.
 
-  Change-Id: Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
-  Signed-off-by: A. U. Thor <author@example.com>
+Change-Id: Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
+Signed-off-by: A. U. Thor <author@example.com>
 ----
 
 The hook implementation is reasonably intelligent at inserting the
@@ -57,31 +57,29 @@
 
 == OBTAINING
 
-
 To obtain the `commit-msg` script use `scp`, `wget` or `curl` to download
 it to your local system from your Gerrit server.
 
 You can use either of the below commands:
 
 ----
-  $ scp -p -P 29418 <your username>@<your Gerrit review server>:hooks/commit-msg <local path to your git>/.git/hooks/
+$ scp -p -P 29418 <your username>@<your Gerrit review server>:hooks/commit-msg <local path to your git>/.git/hooks/
 
-  $ curl -Lo <local path to your git>/.git/hooks/commit-msg <your Gerrit http URL>/tools/hooks/commit-msg
+$ curl -Lo <local path to your git>/.git/hooks/commit-msg <your Gerrit http URL>/tools/hooks/commit-msg
 ----
 
 A specific example of this might look something like this:
 
-.Example
 ----
-  $ scp -p -P 29418 john.doe@review.example.com:hooks/commit-msg ~/duhproject/.git/hooks/
+$ scp -p -P 29418 john.doe@review.example.com:hooks/commit-msg ~/duhproject/.git/hooks/
 
-  $ curl -Lo ~/duhproject/.git/hooks/commit-msg http://review.example.com/tools/hooks/commit-msg
+$ curl -Lo ~/duhproject/.git/hooks/commit-msg http://review.example.com/tools/hooks/commit-msg
 ----
 
 Make sure the hook file is executable:
 
 ----
-  $ chmod u+x ~/duhproject/.git/hooks/commit-msg
+$ chmod u+x ~/duhproject/.git/hooks/commit-msg
 ----
 
 == SEE ALSO
diff --git a/Documentation/cmd-index-activate.txt b/Documentation/cmd-index-activate.txt
index 4428d12..0bbf308 100644
--- a/Documentation/cmd-index-activate.txt
+++ b/Documentation/cmd-index-activate.txt
@@ -1,7 +1,7 @@
 = gerrit index activate
 
 == NAME
-gerrit index activate - Activate the latest index version available
+gerrit index activate - Activate the latest index version available.
 
 == SYNOPSIS
 [verse]
@@ -37,7 +37,7 @@
 Activate the latest change index:
 
 ----
-  $ ssh -p 29418 review.example.com gerrit index activate changes
+$ ssh -p 29418 review.example.com gerrit index activate changes
 ----
 
 GERRIT
diff --git a/Documentation/cmd-index-changes-in-project.txt b/Documentation/cmd-index-changes-in-project.txt
new file mode 100644
index 0000000..b2282fc
--- /dev/null
+++ b/Documentation/cmd-index-changes-in-project.txt
@@ -0,0 +1,37 @@
+= gerrit index changes in project
+
+== NAME
+gerrit index changes in project - Index all the changes in one or more projects.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit index changes-in-project_ <PROJECT> [<PROJECT> ...]
+--
+
+== DESCRIPTION
+Index all the changes in one or more projects.
+
+== ACCESS
+Caller must have the 'Maintain Server' capability.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== OPTIONS
+<PROJECT>::
+    Required; name of the project to be indexed.
+
+== EXAMPLES
+Index all changes in projects MyProject and NiceProject.
+
+----
+$ ssh -p 29418 user@review.example.com gerrit index changes-in-project MyProject NiceProject
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-index-changes.txt b/Documentation/cmd-index-changes.txt
index d38c51a..0ee7aab 100644
--- a/Documentation/cmd-index-changes.txt
+++ b/Documentation/cmd-index-changes.txt
@@ -30,7 +30,7 @@
 Index changes with legacy ID numbers 1 and 2.
 
 ----
-    $ ssh -p 29418 user@review.example.com gerrit index changes 1 2
+$ ssh -p 29418 user@review.example.com gerrit index changes 1 2
 ----
 
 GERRIT
diff --git a/Documentation/cmd-index-project.txt b/Documentation/cmd-index-project.txt
deleted file mode 100644
index 2196a26..0000000
--- a/Documentation/cmd-index-project.txt
+++ /dev/null
@@ -1,37 +0,0 @@
-= gerrit index project
-
-== NAME
-gerrit index project - Index all the changes in one or more projects.
-
-== SYNOPSIS
-[verse]
---
-_ssh_ -p <port> <host> _gerrit index project_ <PROJECT> [<PROJECT> ...]
---
-
-== DESCRIPTION
-Index all the changes in one or more projects.
-
-== ACCESS
-Caller must have the 'Maintain Server' capability.
-
-== SCRIPTING
-This command is intended to be used in scripts.
-
-== OPTIONS
-<PROJECT>::
-    Required; name of the project to be indexed.
-
-== EXAMPLES
-Index all changes in projects MyProject and NiceProject.
-
-----
-    $ ssh -p 29418 user@review.example.com gerrit index project MyProject NiceProject
-----
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/cmd-index-start.txt b/Documentation/cmd-index-start.txt
index 5a002f3..d1a02b3 100644
--- a/Documentation/cmd-index-start.txt
+++ b/Documentation/cmd-index-start.txt
@@ -1,7 +1,7 @@
 = gerrit index start
 
 == NAME
-gerrit index start - Start the online indexer
+gerrit index start - Start the online indexer.
 
 == SYNOPSIS
 [verse]
@@ -44,7 +44,7 @@
 Start the online indexer for the 'changes' index:
 
 ----
-  $ ssh -p 29418 review.example.com gerrit index start changes
+$ ssh -p 29418 review.example.com gerrit index start changes
 ----
 
 GERRIT
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index f535281..edb54b5 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -8,29 +8,31 @@
 To download a client command or hook, use scp or an http client:
 
 ----
-  $ scp -p -P 29418 john.doe@review.example.com:bin/gerrit-cherry-pick ~/bin/
-  $ scp -p -P 29418 john.doe@review.example.com:hooks/commit-msg .git/hooks/
+$ scp -p -P 29418 john.doe@review.example.com:bin/gerrit-cherry-pick ~/bin/
+$ scp -p -P 29418 john.doe@review.example.com:hooks/commit-msg .git/hooks/
 
-  $ curl -Lo ~/bin/gerrit-cherry-pick http://review.example.com/tools/bin/gerrit-cherry-pick
-  $ curl -Lo .git/hooks/commit-msg http://review.example.com/tools/hooks/commit-msg
+$ curl -Lo ~/bin/gerrit-cherry-pick http://review.example.com/tools/bin/gerrit-cherry-pick
+$ curl -Lo .git/hooks/commit-msg http://review.example.com/tools/hooks/commit-msg
 ----
 
 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
 server.
 
 link:cmd-hook-commit-msg.html[commit-msg]::
-	Automatically generate `Change-Id: ` tags in commit messages.
+	Automatically generate `Change-Id:` tags in commit messages.
 
 
 == Server
@@ -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.
@@ -68,7 +71,7 @@
 	List projects visible to the caller.
 
 link:cmd-query.html[gerrit query]::
-	Query the change database.
+	Query the change search index.
 
 'gerrit receive-pack'::
 	'Deprecated alias for `git receive-pack`.'
@@ -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.
@@ -124,9 +130,6 @@
 link:cmd-gc.html[gerrit gc]::
 	Run the Git garbage collection.
 
-link:cmd-gsql.html[gerrit gsql]::
-	Administrative interface to active database.
-
 link:cmd-index-activate.html[gerrit index activate]::
 	Activate the latest index version available.
 
@@ -136,7 +139,7 @@
 link:cmd-index-changes.html[gerrit index changes]::
 	Index one or more changes.
 
-link:cmd-index-project.html[gerrit index project]::
+link:cmd-index-changes-in-project.html[gerrit index changes-in-project]::
 	Index all the changes in one or more projects.
 
 link:cmd-logging-ls-level.html[gerrit logging ls-level]::
@@ -178,9 +181,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 +205,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-kill.txt b/Documentation/cmd-kill.txt
index ac8e802..db5093d 100644
--- a/Documentation/cmd-kill.txt
+++ b/Documentation/cmd-kill.txt
@@ -1,7 +1,7 @@
 = kill
 
 == NAME
-kill - Cancel or abort a background task
+kill - Cancel or abort a background task.
 
 == SYNOPSIS
 [verse]
diff --git a/Documentation/cmd-logging-ls-level.txt b/Documentation/cmd-logging-ls-level.txt
index ee015bb..fb1fb33 100644
--- a/Documentation/cmd-logging-ls-level.txt
+++ b/Documentation/cmd-logging-ls-level.txt
@@ -1,9 +1,9 @@
 = gerrit logging ls-level
 
 == NAME
-gerrit logging ls-level - view the logging level
+gerrit logging ls-level - view the logging level.
 
-gerrit logging ls - view the logging level
+gerrit logging ls - view the logging level.
 
 == SYNOPSIS
 [verse]
@@ -27,13 +27,12 @@
 
 View the logging level of the loggers in the package com.google:
 ----
-    $ssh -p 29418 review.example.com gerrit logging ls-level \
-     com.google.
+$ssh -p 29418 review.example.com gerrit logging ls-level com.google.
 ----
 
 View the logging level of every logger
 ----
-    $ssh -p 29418 review.example.com gerrit logging ls-level
+$ssh -p 29418 review.example.com gerrit logging ls-level
 ----
 
 GERRIT
diff --git a/Documentation/cmd-logging-set-level.txt b/Documentation/cmd-logging-set-level.txt
index 5baa968..d7fc69e 100644
--- a/Documentation/cmd-logging-set-level.txt
+++ b/Documentation/cmd-logging-set-level.txt
@@ -1,9 +1,9 @@
 = gerrit logging set-level
 
 == NAME
-gerrit logging set-level - set the logging level
+gerrit logging set-level - set the logging level.
 
-gerrit logging set - set the logging level
+gerrit logging set - set the logging level.
 
 == SYNOPSIS
 [verse]
@@ -34,14 +34,12 @@
 
 Change the logging level of the loggers in the package com.google to DEBUG.
 ----
-    $ssh -p 29418 review.example.com gerrit logging set-level \
-     debug com.google.
+$ssh -p 29418 review.example.com gerrit logging set-level debug com.google.
 ----
 
 Reset the logging level of every logger to what they were at deployment time.
 ----
-    $ssh -p 29418 review.example.com gerrit logging set-level \
-     reset
+$ssh -p 29418 review.example.com gerrit logging set-level reset
 ----
 
 GERRIT
diff --git a/Documentation/cmd-ls-groups.txt b/Documentation/cmd-ls-groups.txt
index 6d4bdc5..8a4845c 100644
--- a/Documentation/cmd-ls-groups.txt
+++ b/Documentation/cmd-ls-groups.txt
@@ -1,7 +1,7 @@
 = gerrit ls-groups
 
 == NAME
-gerrit ls-groups - List groups visible to caller
+gerrit ls-groups - List groups visible to caller.
 
 == SYNOPSIS
 [verse]
@@ -88,53 +88,53 @@
 
 List visible groups:
 ----
-	$ ssh -p 29418 review.example.com gerrit ls-groups
-	Administrators
-	Anonymous Users
-	MyProject_Committers
-	Project Owners
-	Registered Users
+$ ssh -p 29418 review.example.com gerrit ls-groups
+Administrators
+Anonymous Users
+MyProject_Committers
+Project Owners
+Registered Users
 ----
 
 List all groups for which any permission is set for the project
 "MyProject":
 ----
-	$ ssh -p 29418 review.example.com gerrit ls-groups --project MyProject
-	MyProject_Committers
-	Project Owners
-	Registered Users
+$ ssh -p 29418 review.example.com gerrit ls-groups --project MyProject
+MyProject_Committers
+Project Owners
+Registered Users
 ----
 
 List all groups which are owned by the calling user:
 ----
-	$ ssh -p 29418 review.example.com gerrit ls-groups --owned
-	MyProject_Committers
-	MyProject_Verifiers
+$ ssh -p 29418 review.example.com gerrit ls-groups --owned
+MyProject_Committers
+MyProject_Verifiers
 ----
 
 Check if the calling user owns the group `MyProject_Committers`. If
 `MyProject_Committers` is returned the calling user owns this group.
 If the result is empty, the calling user doesn't own the group.
 ----
-	$ ssh -p 29418 review.example.com gerrit ls-groups --owned -q MyProject_Committers
-	MyProject_Committers
+$ ssh -p 29418 review.example.com gerrit ls-groups --owned -q MyProject_Committers
+MyProject_Committers
 ----
 
 Extract the UUID of the 'Administrators' group:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit ls-groups -v | awk '-F\t' '$1 == "Administrators" {print $2}'
-	ad463411db3eec4e1efb0d73f55183c1db2fd82a
+$ ssh -p 29418 review.example.com gerrit ls-groups -v | awk '-F\t' '$1 == "Administrators" {print $2}'
+ad463411db3eec4e1efb0d73f55183c1db2fd82a
 ----
 
 Extract and expand the multi-line description of the 'Administrators'
 group:
 
 ----
-	$ printf "$(ssh -p 29418 review.example.com gerrit ls-groups -v | awk '-F\t' '$1 == "Administrators" {print $3}')\n"
-	This is a
-	multi-line
-	description.
+$ printf "$(ssh -p 29418 review.example.com gerrit ls-groups -v | awk '-F\t' '$1 == "Administrators" {print $3}')\n"
+This is a
+multi-line
+description.
 ----
 
 GERRIT
diff --git a/Documentation/cmd-ls-members.txt b/Documentation/cmd-ls-members.txt
index 273451b..8f8857c 100644
--- a/Documentation/cmd-ls-members.txt
+++ b/Documentation/cmd-ls-members.txt
@@ -1,7 +1,7 @@
 = gerrit ls-members
 
 == NAME
-gerrit ls-members - Show members of a given group
+gerrit ls-members - Show members of a given group.
 
 == SYNOPSIS
 [verse]
@@ -40,17 +40,17 @@
 
 List members of the Administrators group:
 ----
-	$ ssh -p 29418 review.example.com gerrit ls-members Administrators
-	id      username  full name    email
-	100000  jim     Jim Bob somebody@example.com
-	100001  johnny  John Smith      n/a
-	100002  mrnoname        n/a     someoneelse@example.com
+$ ssh -p 29418 review.example.com gerrit ls-members Administrators
+id      username  full name    email
+100000  jim     Jim Bob somebody@example.com
+100001  johnny  John Smith      n/a
+100002  mrnoname        n/a     someoneelse@example.com
 ----
 
 List members of a non-existent group:
 ----
-	$ ssh -p 29418 review.example.com gerrit ls-members BadlySpelledGroup
-	Group not found or not visible
+$ ssh -p 29418 review.example.com gerrit ls-members BadlySpelledGroup
+Group not found or not visible
 ----
 
 GERRIT
diff --git a/Documentation/cmd-ls-projects.txt b/Documentation/cmd-ls-projects.txt
index 486ca44..1dd6720 100644
--- a/Documentation/cmd-ls-projects.txt
+++ b/Documentation/cmd-ls-projects.txt
@@ -1,7 +1,7 @@
 = gerrit ls-projects
 
 == NAME
-gerrit ls-projects - List projects visible to caller
+gerrit ls-projects - List projects visible to caller.
 
 == SYNOPSIS
 [verse]
@@ -14,6 +14,7 @@
   [--format {text | json | json_compact}]
   [--all]
   [--limit <N>]
+  [--prefix | -p <prefix>]
   [--has-acl-for GROUP]
 --
 
@@ -87,6 +88,9 @@
 --limit::
 	Cap the number of results to the first N matches.
 
+--prefix::
+	Limit the results to those projects that start with the specified prefix.
+
 --has-acl-for::
 	Display only projects on which access rights for this group are
 	directly assigned. Projects which only inherit access rights for
@@ -115,28 +119,28 @@
 
 List visible projects:
 ----
-	$ ssh -p 29418 review.example.com gerrit ls-projects
-	platform/manifest
-	tools/gerrit
-	tools/gwtorm
+$ ssh -p 29418 review.example.com gerrit ls-projects
+platform/manifest
+tools/gerrit
+tools/gitiles
 
-	$ curl http://review.example.com/projects/
-	platform/manifest
-	tools/gerrit
-	tools/gwtorm
+$ curl http://review.example.com/projects/
+platform/manifest
+tools/gerrit
+tools/gitiles
 
-	$ curl http://review.example.com/projects/tools/
-	tools/gerrit
-	tools/gwtorm
+$ curl http://review.example.com/projects/tools/
+tools/gerrit
+tools/gitiles
 ----
 
 Clone any project visible to the user:
 ----
-	for p in `ssh -p 29418 review.example.com gerrit ls-projects`
-	do
-	  mkdir -p `dirname "$p"`
-	  git clone --bare "ssh://review.example.com:29418/$p.git" "$p.git"
-	done
+for p in `ssh -p 29418 review.example.com gerrit ls-projects`
+do
+  mkdir -p `dirname "$p"`
+  git clone --bare "ssh://review.example.com:29418/$p.git" "$p.git"
+done
 ----
 
 == SEE ALSO
diff --git a/Documentation/cmd-ls-user-refs.txt b/Documentation/cmd-ls-user-refs.txt
index 1a87fc9..0363f60 100644
--- a/Documentation/cmd-ls-user-refs.txt
+++ b/Documentation/cmd-ls-user-refs.txt
@@ -1,7 +1,7 @@
 = gerrit ls-user-refs
 
 == NAME
-gerrit ls-user-refs - List refs visible to a specific user
+gerrit ls-user-refs - List refs visible to a specific user.
 
 == SYNOPSIS
 [verse]
@@ -32,8 +32,8 @@
 --user::
 -u::
 	Required; User for which the visible refs should be listed. Gerrit
-	will query the database to find matching users, so the
-	full identity/name does not need to be specified.
+	will query the index to find matching users, so the full
+	identity/name does not need to be specified.
 
 --only-refs-heads::
 	Only list the refs found under refs/heads/*
@@ -42,7 +42,7 @@
 
 List visible refs for the user "mr.developer" in project "gerrit"
 ----
-	$ ssh -p 29418 review.example.com gerrit ls-user-refs -p gerrit -u mr.developer
+$ ssh -p 29418 review.example.com gerrit ls-user-refs -p gerrit -u mr.developer
 ----
 
 GERRIT
diff --git a/Documentation/cmd-plugin-enable.txt b/Documentation/cmd-plugin-enable.txt
index 9b52736..955267e 100644
--- a/Documentation/cmd-plugin-enable.txt
+++ b/Documentation/cmd-plugin-enable.txt
@@ -32,7 +32,7 @@
 Enable a plugin:
 
 ----
-	ssh -p 29418 localhost gerrit plugin enable my-plugin
+ssh -p 29418 localhost gerrit plugin enable my-plugin
 ----
 
 GERRIT
diff --git a/Documentation/cmd-plugin-install.txt b/Documentation/cmd-plugin-install.txt
index 5443613..ef68b40 100644
--- a/Documentation/cmd-plugin-install.txt
+++ b/Documentation/cmd-plugin-install.txt
@@ -46,29 +46,25 @@
 Install a plugin from an absolute file path on the server's host:
 
 ----
-	ssh -p 29418 localhost gerrit plugin install -n name.jar \
-	  $(pwd)/my-plugin.jar
+ssh -p 29418 localhost gerrit plugin install -n name.jar $(pwd)/my-plugin.jar
 ----
 
 Install a WebUI plugin from an absolute file path on the server's host:
 
 ----
-  ssh -p 29418 localhost gerrit plugin install -n name.js \
-    $(pwd)/my-webui-plugin.js
+ssh -p 29418 localhost gerrit plugin install -n name.js $(pwd)/my-webui-plugin.js
 ----
 
 Install a plugin from an HTTP site:
 
 ----
-	ssh -p 29418 localhost gerrit plugin install -n name.jar \
-	  http://build-server/output/our-plugin
+ssh -p 29418 localhost gerrit plugin install -n name.jar http://build-server/output/our-plugin
 ----
 
 Install a plugin from piped input:
 
 ----
-	ssh -p 29418 localhost gerrit plugin install -n name.jar \
-	  - <target/name-0.1.jar
+ssh -p 29418 localhost gerrit plugin install -n name.jar - <target/name-0.1.jar
 ----
 
 GERRIT
diff --git a/Documentation/cmd-plugin-reload.txt b/Documentation/cmd-plugin-reload.txt
index ad1e5e7..5cfb6cc 100644
--- a/Documentation/cmd-plugin-reload.txt
+++ b/Documentation/cmd-plugin-reload.txt
@@ -36,7 +36,7 @@
 Reload a plugin:
 
 ----
-	ssh -p 29418 localhost gerrit plugin reload my-plugin
+ssh -p 29418 localhost gerrit plugin reload my-plugin
 ----
 
 GERRIT
diff --git a/Documentation/cmd-plugin-remove.txt b/Documentation/cmd-plugin-remove.txt
index 805c7b4..012bf7b 100644
--- a/Documentation/cmd-plugin-remove.txt
+++ b/Documentation/cmd-plugin-remove.txt
@@ -20,6 +20,7 @@
 * Caller must be a member of the privileged 'Administrators' group.
 * link:config-gerrit.html#plugins.allowRemoteAdmin[plugins.allowRemoteAdmin]
 must be enabled in `$site_path/etc/gerrit.config`.
+* Mandatory plugin cannot be disabled
 
 == SCRIPTING
 This command is intended to be used in scripts.
@@ -33,7 +34,7 @@
 Disable a plugin:
 
 ----
-	ssh -p 29418 localhost gerrit plugin remove my-plugin
+ssh -p 29418 localhost gerrit plugin remove my-plugin
 ----
 
 GERRIT
diff --git a/Documentation/cmd-query.txt b/Documentation/cmd-query.txt
index 90e5cdd..d0419d7 100644
--- a/Documentation/cmd-query.txt
+++ b/Documentation/cmd-query.txt
@@ -1,7 +1,7 @@
 = gerrit query
 
 == NAME
-gerrit query - Query the change database
+gerrit query - Query the change search index
 
 == SYNOPSIS
 [verse]
@@ -17,6 +17,7 @@
   [--submit-records]
   [--all-reviewers]
   [--start <n> | -S <n>]
+  [--no-limit]
   [--]
   <query>
   [limit:<n>]
@@ -24,7 +25,7 @@
 
 == DESCRIPTION
 
-Queries the change database and returns results describing changes
+Queries the change search index and returns results describing changes
 that match the input query.  More recently updated changes appear
 before older changes, which is the same order presented in the
 web interface.  For each matching change, the result contains data
@@ -101,6 +102,9 @@
 -S::
 	Number of changes to skip.
 
+--no-limit::
+	Return all results, overriding the default limit.
+
 limit:<n>::
 	Maximum number of results to return.  This is actually a
 	query operator, and not a command line option.	If more
@@ -117,18 +121,18 @@
 
 Find the 2 most recent open changes in the tools/gerrit project:
 ----
-  $ ssh -p 29418 review.example.com gerrit query --format=JSON status:open project:tools/gerrit limit:2
-  {"project":"tools/gerrit", ...}
-  {"project":"tools/gerrit", ...}
-  {"type":"stats","rowCount":2,"runningTimeMilliseconds:15}
+$ ssh -p 29418 review.example.com gerrit query --format=JSON status:open project:tools/gerrit limit:2
+{"project":"tools/gerrit", ...}
+{"project":"tools/gerrit", ...}
+{"type":"stats","rowCount":2,"runningTimeMilliseconds:15}
 ----
 
 Skip number of changes:
 ----
-  $ ssh -p 29418 review.example.com gerrit query --format=JSON --start 42 status:open project:tools/gerrit limit:2
-  {"project":"tools/gerrit", ...}
-  {"project":"tools/gerrit", ...}
-  {"type":"stats","rowCount":1,"runningTimeMilliseconds:15}
+$ ssh -p 29418 review.example.com gerrit query --format=JSON --start 42 status:open project:tools/gerrit limit:2
+{"project":"tools/gerrit", ...}
+{"project":"tools/gerrit", ...}
+{"type":"stats","rowCount":1,"runningTimeMilliseconds:15}
 ----
 
 
diff --git a/Documentation/cmd-receive-pack.txt b/Documentation/cmd-receive-pack.txt
index b62b9a9..9c6d9fa 100644
--- a/Documentation/cmd-receive-pack.txt
+++ b/Documentation/cmd-receive-pack.txt
@@ -1,7 +1,7 @@
 = git-receive-pack
 
 == NAME
-git-receive-pack - Receive what is pushed into the repository
+git-receive-pack - Receive what is pushed into the repository.
 
 == SYNOPSIS
 [verse]
@@ -43,36 +43,36 @@
 
 Send a review for a change on the master branch to charlie@example.com:
 ----
-	git push ssh://review.example.com:29418/project HEAD:refs/for/master%r=charlie@example.com
+git push ssh://review.example.com:29418/project HEAD:refs/for/master%r=charlie@example.com
 ----
 
 Send reviews, but tagging them with the topic name 'bug42':
 ----
-	git push ssh://review.example.com:29418/project HEAD:refs/for/master%r=charlie@example.com,topic=bug42
+git push ssh://review.example.com:29418/project HEAD:refs/for/master%r=charlie@example.com,topic=bug42
 ----
 
 Also CC two other parties:
 ----
-	git push ssh://review.example.com:29418/project HEAD:refs/for/master%r=charlie@example.com,cc=alice@example.com,cc=bob@example.com
+git push ssh://review.example.com:29418/project HEAD:refs/for/master%r=charlie@example.com,cc=alice@example.com,cc=bob@example.com
 ----
 
 Configure a push macro to perform the last action:
 ----
-	git config remote.charlie.url ssh://review.example.com:29418/project
-	git config remote.charlie.push HEAD:refs/for/master%r=charlie@example.com,cc=alice@example.com,cc=bob@example.com
+git config remote.charlie.url ssh://review.example.com:29418/project
+git config remote.charlie.push HEAD:refs/for/master%r=charlie@example.com,cc=alice@example.com,cc=bob@example.com
 ----
 
 afterwards `.git/config` contains the following:
 ----
 [remote "charlie"]
-  url = ssh://review.example.com:29418/project
-  push = HEAD:refs/for/master%r=charlie@example.com,cc=alice@example.com,cc=bob@example.com
+ url = ssh://review.example.com:29418/project
+ push = HEAD:refs/for/master%r=charlie@example.com,cc=alice@example.com,cc=bob@example.com
 ----
 
 and now sending a new change for review to charlie, CC'ing both
 alice and bob is much easier:
 ----
-	git push charlie
+git push charlie
 ----
 
 == SEE ALSO
diff --git a/Documentation/cmd-reload-config.txt b/Documentation/cmd-reload-config.txt
index 7a25130..6d652f5 100644
--- a/Documentation/cmd-reload-config.txt
+++ b/Documentation/cmd-reload-config.txt
@@ -33,7 +33,7 @@
 Reload the gerrit configuration:
 
 ----
-	ssh -p 29418 localhost gerrit reload-config
+ssh -p 29418 localhost gerrit reload-config
 ----
 
 GERRIT
diff --git a/Documentation/cmd-rename-group.txt b/Documentation/cmd-rename-group.txt
index a48014c..c946e88 100644
--- a/Documentation/cmd-rename-group.txt
+++ b/Documentation/cmd-rename-group.txt
@@ -32,7 +32,7 @@
 Rename the group "MyGroup" to "MyCommitters".
 
 ----
-	$ ssh -p 29418 user@review.example.com gerrit rename-group MyGroup MyCommitters
+$ ssh -p 29418 user@review.example.com gerrit rename-group MyGroup MyCommitters
 ----
 
 GERRIT
diff --git a/Documentation/cmd-review.txt b/Documentation/cmd-review.txt
index 59ed6ff..eef47fc 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -1,7 +1,7 @@
 = gerrit review
 
 == NAME
-gerrit review - Apply reviews to one or more patch sets
+gerrit review - Apply reviews to one or more patch sets.
 
 == SYNOPSIS
 [verse]
@@ -15,19 +15,17 @@
   [--abandon | --restore]
   [--rebase]
   [--move <BRANCH>]
-  [--publish]
   [--json | -j]
-  [--delete]
   [--verified <N>] [--code-review <N>]
   [--label Label-Name=<N>]
   [--tag TAG]
-  {COMMIT | CHANGEID,PATCHSET}...
+  {COMMIT | CHANGENUMBER,PATCHSET}...
 --
 
 == DESCRIPTION
 Updates the current user's approval status of the specified patch
 sets and/or submits them for merging, sending out email
-notifications and updating the database.
+notifications and updating code review metadata.
 
 Patch sets may be specified in 'CHANGEID,PATCHSET' format, such as
 '8242,2', or 'COMMIT' format.
@@ -66,7 +64,7 @@
 	Read review input json from stdin. See
 	link:rest-api-changes.html#review-input[ReviewInput] entity for the
 	format.
-	(option is mutually exclusive with --submit, --restore, --publish, --delete,
+	(option is mutually exclusive with --submit, --restore,
 	--abandon, --message, --rebase and --move)
 
 --notify::
@@ -88,7 +86,7 @@
 
 --abandon::
 	Abandon the specified change(s).
-	(option is mutually exclusive with --submit, --restore, --publish, --delete,
+	(option is mutually exclusive with --submit, --restore,
 	--rebase, --move and --json)
 
 --restore::
@@ -97,7 +95,7 @@
 
 --rebase::
 	Rebase the specified change(s).
-	(option is mutually exclusive with --abandon, --submit, --delete and --json)
+	(option is mutually exclusive with --abandon, --submit and --json)
 
 --move::
 	Move the specified change(s).
@@ -106,7 +104,7 @@
 --submit::
 -s::
 	Submit the specified patch set(s) for merging.
-	(option is mutually exclusive with --abandon, --publish --delete, --rebase
+	(option is mutually exclusive with --abandon, --rebase
 	and --json)
 
 --code-review::
@@ -127,12 +125,12 @@
 
 --tag::
 -t::
-  Apply a 'TAG' to the change message, votes, and inline comments. The 'TAG'
-  can represent an external system like CI that does automated verification
-  of the change. Comments with specific 'TAG' values can be filtered out in
-  the web UI.
-  Note that to apply different tags on on different votes/comments, multiple
-  invocations of the SSH command are required.
+	Apply a 'TAG' to the change message, votes, and inline comments. The 'TAG'
+	can represent an external system like CI that does automated verification
+	of the change. Comments that contain TAG values with 'autogenerated:' prefix
+	can be filtered out in the web UI.
+	Note that to apply different tags on different votes/comments, multiple
+	invocations of the SSH command are required.
 
 == ACCESS
 Any user who has SSH access to Gerrit.
@@ -144,35 +142,40 @@
 
 Approve the change with commit c0ff33 as "Verified +1"
 ----
-	$ ssh -p 29418 review.example.com gerrit review --verified +1 c0ff33
+$ ssh -p 29418 review.example.com gerrit review --verified +1 8242,2
+----
+
+Approve the change with change number 8242 and patch set 2 as "Code-Review +2"
+----
+$ ssh -p 29418 review.example.com gerrit review --code-review +2 8242,2
 ----
 
 Vote on the project specific label "mylabel":
 ----
-	$ ssh -p 29418 review.example.com gerrit review --label mylabel=+1 c0ff33
+$ ssh -p 29418 review.example.com gerrit review --label mylabel=+1 8242,2
 ----
 
 Append the message "Build Successful". Notice two levels of quoting is
 required, one for the local shell, and another for the argument parser
 inside the Gerrit server:
 ----
-	$ ssh -p 29418 review.example.com gerrit review -m '"Build Successful"' c0ff33
+$ ssh -p 29418 review.example.com gerrit review -m '"Build Successful"' 8242,2
 ----
 
 Mark the unmerged commits both "Verified +1" and "Code-Review +2" and
 submit them for merging:
 ----
-  $ ssh -p 29418 review.example.com gerrit review \
-    --verified +1 \
-    --code-review +2 \
-    --submit \
-    --project this/project \
-    $(git rev-list origin/master..HEAD)
+$ ssh -p 29418 review.example.com gerrit review \
+  --verified +1 \
+  --code-review +2 \
+  --submit \
+  --project this/project \
+  $(git rev-list origin/master..HEAD)
 ----
 
 Abandon an active change:
 ----
-  $ ssh -p 29418 review.example.com gerrit review --abandon c0ff33
+$ ssh -p 29418 review.example.com gerrit review --abandon 8242,2
 ----
 
 == SEE ALSO
diff --git a/Documentation/cmd-set-account.txt b/Documentation/cmd-set-account.txt
index 884c8cc..6808e017 100644
--- a/Documentation/cmd-set-account.txt
+++ b/Documentation/cmd-set-account.txt
@@ -12,6 +12,7 @@
   [--preferred-email <EMAIL>]
   [--add-ssh-key - | <KEY>]
   [--delete-ssh-key - | <KEY> | ALL]
+  [--generate-http-password]
   [--http-password <PASSWORD>]
   [--clear-http-password] <USER>
 --
@@ -25,8 +26,9 @@
 verification step we force within the UI.
 
 == ACCESS
-Caller must be a member of the privileged 'Administrators' group,
-or have been granted
+Users can call this to update their own accounts. To update a different
+account, a caller must be a member of the privileged 'Administrators'
+group, or have been granted
 link:access-control.html#capability_modifyAccount[the 'Modify Account' global capability].
 For security reasons only the members of the privileged 'Administrators'
 group can add or delete SSH keys for a user.
@@ -93,6 +95,11 @@
     May be supplied more than once to delete multiple SSH
     keys in a single command execution.
 
+--generate-http-password::
+    Generate a new random HTTP password for the user account
+    similar to the web ui. The password will be output to the
+    user on success with a line: `New password: <PASSWORD>`.
+
 --http-password::
     Set the HTTP password for the user account.
 
@@ -103,7 +110,7 @@
 Add an email and SSH key to `watcher`'s account:
 
 ----
-    $ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit set-account --add-ssh-key - --add-email mail@example.com watcher
+$ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit set-account --add-ssh-key - --add-email mail@example.com watcher
 ----
 
 GERRIT
diff --git a/Documentation/cmd-set-head.txt b/Documentation/cmd-set-head.txt
index f444173..83bdf20 100644
--- a/Documentation/cmd-set-head.txt
+++ b/Documentation/cmd-set-head.txt
@@ -35,7 +35,7 @@
 Change HEAD of project `example` to `stable-2.11` branch:
 
 ----
-    $ ssh -p 29418 review.example.com gerrit set-head example --new-head stable-2.11
+$ ssh -p 29418 review.example.com gerrit set-head example --new-head stable-2.11
 ----
 
 GERRIT
diff --git a/Documentation/cmd-set-members.txt b/Documentation/cmd-set-members.txt
index 5fb2bb9..141cb33 100644
--- a/Documentation/cmd-set-members.txt
+++ b/Documentation/cmd-set-members.txt
@@ -1,7 +1,7 @@
 = gerrit set-members
 
 == NAME
-gerrit set-members - Set group members
+gerrit set-members - Set group members.
 
 == SYNOPSIS
 [verse]
@@ -59,16 +59,16 @@
 Add alice and bob, but remove eve from the groups my-committers and
 my-verifiers.
 ----
-	$ ssh -p 29418 review.example.com gerrit set-members \
-	  -a alice@example.com -a bob@example.com \
-	  -r eve@example.com my-committers my-verifiers
+$ ssh -p 29418 review.example.com gerrit set-members \
+  -a alice@example.com -a bob@example.com \
+  -r eve@example.com my-committers my-verifiers
 ----
 
 Include the group my-friends into the group my-committers, but
 exclude the included group my-testers from the group my-committers.
 ----
-	$ ssh -p 29418 review.example.com gerrit set-members \
-	  -i my-friends -e my-testers my-committers
+$ ssh -p 29418 review.example.com gerrit set-members \
+  -i my-friends -e my-testers my-committers
 ----
 
 GERRIT
diff --git a/Documentation/cmd-set-project-parent.txt b/Documentation/cmd-set-project-parent.txt
index 6e2328c..801f15a 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.
@@ -47,14 +51,14 @@
 Configure `kernel/omap` to inherit permissions from `kernel/common`:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit set-project-parent --parent kernel/common kernel/omap
+$ ssh -p 29418 review.example.com gerrit set-project-parent --parent kernel/common kernel/omap
 ----
 
 Reparent all children of `myParent` to `myOtherParent`:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit set-project-parent \
-	  --children-of myParent --parent myOtherParent
+$ ssh -p 29418 review.example.com gerrit set-project-parent \
+  --children-of myParent --parent myOtherParent
 ----
 
 == SEE ALSO
diff --git a/Documentation/cmd-set-project.txt b/Documentation/cmd-set-project.txt
index 7282e28..9686230 100644
--- a/Documentation/cmd-set-project.txt
+++ b/Documentation/cmd-set-project.txt
@@ -59,7 +59,7 @@
 
 +
 For more details see
-link:project-configuration.html#submit_type[Submit Types].
+link:config-project-config.html#submit-type[Submit Types].
 
 --content-merge::
     If enabled, Gerrit will try to perform a 3-way merge of text
@@ -105,8 +105,8 @@
 and use 'merge if necessary' as merge strategy:
 
 ----
-    $ ssh -p 29418 review.example.com gerrit set-project example --submit-type MERGE_IF_NECESSARY\
-    --change-id true --content-merge false --project-state HIDDEN
+$ ssh -p 29418 review.example.com gerrit set-project example --submit-type MERGE_IF_NECESSARY \
+  --change-id true --content-merge false --project-state HIDDEN
 ----
 
 GERRIT
diff --git a/Documentation/cmd-set-reviewers.txt b/Documentation/cmd-set-reviewers.txt
index eb4335b..f6248c6 100644
--- a/Documentation/cmd-set-reviewers.txt
+++ b/Documentation/cmd-set-reviewers.txt
@@ -1,7 +1,7 @@
 = gerrit set-reviewers
 
 == NAME
-gerrit set-reviewers - Add or remove reviewers to a change
+gerrit set-reviewers - Add or remove reviewers to a change.
 
 == SYNOPSIS
 [verse]
@@ -28,6 +28,10 @@
 -p::
 	Name of the project the intended change is contained within.  This
 	option must be supplied before Change-Id in order to take effect.
+	Please note that the project specified must be active.
+
+	If omitted, the impacted changes can be from different projects and
+	the current user needs to be authorized to set reviewers to all of them.
 
 --add::
 -a::
@@ -56,32 +60,32 @@
 
 Add reviewers alice and bob, but remove eve from change Iac6b2ac2.
 ----
-	$ ssh -p 29418 review.example.com gerrit set-reviewers \
-	  -a alice@example.com -a bob@example.com \
-	  -r eve@example.com \
-	  Iac6b2ac2
+$ ssh -p 29418 review.example.com gerrit set-reviewers \
+  -a alice@example.com -a bob@example.com \
+  -r eve@example.com \
+  Iac6b2ac2
 ----
 
 Add reviewer elvis to old-style change id 1935 specifying that the change is in project "graceland"
 ----
-	$ ssh -p 29418 review.example.com gerrit set-reviewers \
-	  --project graceland \
-	  -a elvis@example.com \
-	  1935
+$ ssh -p 29418 review.example.com gerrit set-reviewers \
+  --project graceland \
+  -a elvis@example.com \
+  1935
 ----
 
 Add all project owners as reviewers to change Iac6b2ac2.
 ----
-	$ ssh -p 29418 review.example.com gerrit set-reviewers \
-	  -a "'Project Owners'" \
-	  Iac6b2ac2
+$ ssh -p 29418 review.example.com gerrit set-reviewers \
+  -a "'Project Owners'" \
+  Iac6b2ac2
 ----
 
 Add all project owners as reviewers to commit 13dff08acca571b22542ebd2e31acf4572ea0b86.
 ----
-	$ ssh -p 29418 review.example.com gerrit set-reviewers \
-	  -a "'Project Owners'" \
-	  13dff08acca571b22542ebd2e31acf4572ea0b86
+$ ssh -p 29418 review.example.com gerrit set-reviewers \
+  -a "'Project Owners'" \
+  13dff08acca571b22542ebd2e31acf4572ea0b86
 ----
 
 GERRIT
diff --git a/Documentation/cmd-show-caches.txt b/Documentation/cmd-show-caches.txt
index 215463b..050118b 100644
--- a/Documentation/cmd-show-caches.txt
+++ b/Documentation/cmd-show-caches.txt
@@ -1,7 +1,7 @@
 = gerrit show-caches
 
 == NAME
-gerrit show-caches - Display current cache statistics
+gerrit show-caches - Display current cache statistics.
 
 == SYNOPSIS
 [verse]
@@ -51,42 +51,42 @@
 == EXAMPLES
 
 ----
-  $ ssh -p 29418 review.example.com gerrit show-caches
-  Gerrit Code Review        2.9                       now   11:14:13   CEST
-                                                   uptime    6 days 20 hrs
-    Name                          |Entries              |  AvgGet |Hit Ratio|
-                                  |   Mem   Disk   Space|         |Mem  Disk|
-  --------------------------------+---------------------+---------+---------+
-    accounts                      |  4096               |   3.4ms | 99%     |
-    adv_bases                     |                     |         |         |
-    changes                       |                     |  27.1ms |  0%     |
-    groups                        |  5646               |  11.8ms | 97%     |
-    groups_bymember               |                     |         |         |
-    groups_byname                 |                     |         |         |
-    groups_bysubgroup             |   230               |   2.4ms | 62%     |
-    groups_byuuid                 |  5612               |  29.2ms | 99%     |
-    groups_external               |     1               |   1.5s  | 98%     |
-    ldap_group_existence          |                     |         |         |
-    ldap_groups                   |   650               | 680.5ms | 99%     |
-    ldap_groups_byinclude         |  1024               |         | 83%     |
-    ldap_usernames                |   390               |   3.8ms | 81%     |
-    permission_sort               | 16384               |         | 99%     |
-    plugin_resources              |                     |         |         |
-    project_list                  |     1               |   3.8s  | 99%     |
-    projects                      |  6477               |   2.9ms | 99%     |
-    sshkeys                       |  2048               |  12.5ms | 99%     |
-  D diff                          |  1299  62033 132.36m|  22.0ms | 85%  99%|
-  D diff_intraline                | 12777 218651 128.45m| 171.1ms | 31%  96%|
-  D git_tags                      |     3      6  11.85k|         |  0% 100%|
-  D web_sessions                  |  1024 151714  59.10m|         | 99%  57%|
+$ ssh -p 29418 review.example.com gerrit show-caches
+Gerrit Code Review        2.9                       now   11:14:13   CEST
+                                                 uptime    6 days 20 hrs
+  Name                          |Entries              |  AvgGet |Hit Ratio|
+                                |   Mem   Disk   Space|         |Mem  Disk|
+--------------------------------+---------------------+---------+---------+
+  accounts                      |  4096               |   3.4ms | 99%     |
+  adv_bases                     |                     |         |         |
+  changes                       |                     |  27.1ms |  0%     |
+  groups                        |  5646               |  11.8ms | 97%     |
+  groups_bymember               |                     |         |         |
+  groups_byname                 |                     |         |         |
+  groups_bysubgroup             |   230               |   2.4ms | 62%     |
+  groups_byuuid                 |  5612               |  29.2ms | 99%     |
+  groups_external               |     1               |   1.5s  | 98%     |
+  ldap_group_existence          |                     |         |         |
+  ldap_groups                   |   650               | 680.5ms | 99%     |
+  ldap_groups_byinclude         |  1024               |         | 83%     |
+  ldap_usernames                |   390               |   3.8ms | 81%     |
+  permission_sort               | 16384               |         | 99%     |
+  plugin_resources              |                     |         |         |
+  project_list                  |     1               |   3.8s  | 99%     |
+  projects                      |  6477               |   2.9ms | 99%     |
+  sshkeys                       |  2048               |  12.5ms | 99%     |
+D diff                          |  1299  62033 132.36m|  22.0ms | 85%  99%|
+D diff_intraline                | 12777 218651 128.45m| 171.1ms | 31%  96%|
+D git_tags                      |     3      6  11.85k|         |  0% 100%|
+D web_sessions                  |  1024 151714  59.10m|         | 99%  57%|
 
-  SSH:    385  users, oldest session started    6 days 20 hrs ago
-  Tasks:   10  total =    6 running +      0 ready +    4 sleeping
-  Mem:  14.94g total =   3.04g used +  11.89g free +  10.00m buffers
-        28.44g max
-           107 open files
+SSH:    385  users, oldest session started    6 days 20 hrs ago
+Tasks:   10  total =    6 running +      0 ready +    4 sleeping
+Mem:  14.94g total =   3.04g used +  11.89g free +  10.00m buffers
+      28.44g max
+         107 open files
 
-  Threads: 4 CPUs available, 371 threads
+Threads: 4 CPUs available, 371 threads
 ----
 
 == SEE ALSO
diff --git a/Documentation/cmd-show-connections.txt b/Documentation/cmd-show-connections.txt
index 2f70e3c..c5274e1 100644
--- a/Documentation/cmd-show-connections.txt
+++ b/Documentation/cmd-show-connections.txt
@@ -1,7 +1,7 @@
 = gerrit show-connections
 
 == NAME
-gerrit show-connections - Display active client SSH connections
+gerrit show-connections - Display active client SSH connections.
 
 == SYNOPSIS
 [verse]
@@ -65,20 +65,20 @@
 
 With reverse DNS lookup (default):
 ----
-	$ ssh -p 29418 review.example.com gerrit show-connections
-	Session     Start     Idle   User            Remote Host
-	--------------------------------------------------------------
-	3abf31e6 20:09:02 00:00:00  jdoe            jdoe-desktop.example.com
-	--
+$ ssh -p 29418 review.example.com gerrit show-connections
+Session     Start     Idle   User            Remote Host
+--------------------------------------------------------------
+3abf31e6 20:09:02 00:00:00  jdoe            jdoe-desktop.example.com
+--
 ----
 
 Without reverse DNS lookup:
 ----
-	$ ssh -p 29418 review.example.com gerrit show-connections -n
-	Session     Start     Idle   User            Remote Host
-	--------------------------------------------------------------
-	3abf31e6 20:09:02 00:00:00  a/1001240       10.0.0.1
-	--
+$ ssh -p 29418 review.example.com gerrit show-connections -n
+Session     Start     Idle   User            Remote Host
+--------------------------------------------------------------
+3abf31e6 20:09:02 00:00:00  a/1001240       10.0.0.1
+--
 ----
 
 GERRIT
diff --git a/Documentation/cmd-show-queue.txt b/Documentation/cmd-show-queue.txt
index 141f7e2..005ffe0 100644
--- a/Documentation/cmd-show-queue.txt
+++ b/Documentation/cmd-show-queue.txt
@@ -1,7 +1,8 @@
 = gerrit show-queue
 
 == NAME
-gerrit show-queue - Display the background work queues, including replication and indexing
+gerrit show-queue - Display the background work queues, including replication
+and indexing.
 
 == SYNOPSIS
 [verse]
@@ -75,13 +76,13 @@
 and `dst2`:
 
 ----
-	$ ssh -p 29418 review.example.com gerrit show-queue
-	Task     State                 Command
-	------------------------------------------------------------------------------
-	7aae09b2 14:31:15.435          mirror dst1:/home/git/tools/gerrit.git
-	9ad09d27 14:31:25.434          mirror dst2:/var/cache/tools/gerrit.git
-	------------------------------------------------------------------------------
-	  2 tasks
+$ ssh -p 29418 review.example.com gerrit show-queue
+Task     State                 Command
+------------------------------------------------------------------------------
+7aae09b2 14:31:15.435          mirror dst1:/home/git/tools/gerrit.git
+9ad09d27 14:31:25.434          mirror dst2:/var/cache/tools/gerrit.git
+------------------------------------------------------------------------------
+2 tasks
 ----
 
 GERRIT
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 72a9c21..1ab67d8 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -1,6 +1,6 @@
 = gerrit stream-events
 == NAME
-gerrit stream-events - Monitor events occurring in real time
+gerrit stream-events - Monitor events occurring in real time.
 
 == SYNOPSIS
 [verse]
@@ -37,16 +37,16 @@
 == EXAMPLES
 
 ----
-  $ ssh -p 29418 review.example.com gerrit stream-events
-  {"type":"comment-added",change:{"project":"tools/gerrit", ...}, ...}
-  {"type":"comment-added",change:{"project":"tools/gerrit", ...}, ...}
+$ ssh -p 29418 review.example.com gerrit stream-events
+{"type":"comment-added",change:{"project":"tools/gerrit", ...}, ...}
+{"type":"comment-added",change:{"project":"tools/gerrit", ...}, ...}
 ----
 
 Only subscribe to specific event types:
 
 ----
-  $ ssh -p 29418 review.example.com gerrit stream-events \
-      -s patchset-created -s ref-replicated
+$ ssh -p 29418 review.example.com gerrit stream-events \
+   -s patchset-created -s ref-replicated
 ----
 
 == SCHEMA
@@ -90,6 +90,16 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+== Change Deleted
+
+Sent when a change has been deleted.
+
+type:: "change-deleted"
+
+change:: link:json.html#change[change attribute]
+
+deleter:: link:json.html#account[account attribute]
+
 === Change Merged
 
 Sent when a change has been merged into the git repository.
@@ -102,7 +112,8 @@
 
 submitter:: link:json.html#account[account attribute]
 
-newRev:: The resulting revision of the merge.
+newRev:: The state (revision) of the target branch after the operation that
+closed the change was completed.
 
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
@@ -270,6 +281,8 @@
 
 change:: link:json.html#change[change attribute]
 
+patchSet:: link:json.html#patchSet[patchSet attribute]
+
 changer:: link:json.html#account[account attribute]
 
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
@@ -284,6 +297,8 @@
 
 change:: link:json.html#change[change attribute]
 
+patchSet:: link:json.html#patchSet[patchSet attribute]
+
 changer:: link:json.html#account[account attribute]
 
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
diff --git a/Documentation/cmd-suexec.txt b/Documentation/cmd-suexec.txt
index 16338ba..9808edc 100644
--- a/Documentation/cmd-suexec.txt
+++ b/Documentation/cmd-suexec.txt
@@ -1,7 +1,7 @@
 = suexec
 
 == NAME
-suexec - Execute a command as any registered user account
+suexec - Execute a command as any registered user account.
 
 == SYNOPSIS
 [verse]
@@ -49,13 +49,13 @@
 
 Approve the change with commit c0ff33 as "Verified +1" as user bob@example.com
 ----
-  $ sudo -u gerrit ssh -p 29418 \
-    -i site_path/etc/ssh_host_rsa_key \
-    "Gerrit Code Review@localhost" \
-    suexec \
-    --as bob@example.com \
-    -- \
-    gerrit approve --verified +1 c0ff33
+$ sudo -u gerrit ssh -p 29418 \
+  -i site_path/etc/ssh_host_rsa_key \
+  "Gerrit Code Review@localhost" \
+  suexec \
+  --as bob@example.com \
+  -- \
+  gerrit approve --verified +1 c0ff33
 ----
 
 GERRIT
diff --git a/Documentation/cmd-test-submit-rule.txt b/Documentation/cmd-test-submit-rule.txt
index b8c4380..33cf2ea 100644
--- a/Documentation/cmd-test-submit-rule.txt
+++ b/Documentation/cmd-test-submit-rule.txt
@@ -29,29 +29,29 @@
 
 Test submit_rule from stdin and return the results as JSON.
 ----
- cat rules.pl | ssh -p 29418 review.example.com gerrit test-submit rule -s I78f2c6673db24e4e92ed32f604c960dc952437d9
- [
-   {
-     "status": "NOT_READY",
-     "reject": {
-       "Any-Label-Name": {}
-     }
-   }
- ]
+cat rules.pl | ssh -p 29418 review.example.com gerrit test-submit rule -s I78f2c6673db24e4e92ed32f604c960dc952437d9
+[
+  {
+    "status": "NOT_READY",
+    "reject": {
+      "Any-Label-Name": {}
+    }
+  }
+]
 ----
 
 Test the active submit_rule from the refs/meta/config branch, ignoring filters in the project parents.
 ----
- $ ssh -p 29418 review.example.com gerrit test-submit rule I78f2c6673db24e4e92ed32f604c960dc952437d9 --no-filters
- [
-   {
-     "status": "NOT_READY",
-     "need": {
-       "Code-Review": {}
-       "Verified": {}
-     }
-   }
- ]
+$ ssh -p 29418 review.example.com gerrit test-submit rule I78f2c6673db24e4e92ed32f604c960dc952437d9 --no-filters
+[
+  {
+    "status": "NOT_READY",
+    "need": {
+      "Code-Review": {}
+      "Verified": {}
+    }
+  }
+]
 ----
 
 == SCRIPTING
diff --git a/Documentation/cmd-test-submit-type.txt b/Documentation/cmd-test-submit-type.txt
index 508684f..48e5e75 100644
--- a/Documentation/cmd-test-submit-type.txt
+++ b/Documentation/cmd-test-submit-type.txt
@@ -29,14 +29,14 @@
 
 Test submit_type from stdin and return the submit type.
 ----
- cat rules.pl | ssh -p 29418 review.example.com gerrit test-submit type -s I78f2c6673db24e4e92ed32f604c960dc952437d9
- "MERGE_IF_NECESSARY"
+cat rules.pl | ssh -p 29418 review.example.com gerrit test-submit type -s I78f2c6673db24e4e92ed32f604c960dc952437d9
+"MERGE_IF_NECESSARY"
 ----
 
 Test the active submit_type from the refs/meta/config branch, ignoring filters in the project parents.
 ----
- $ ssh -p 29418 review.example.com gerrit test-submit type I78f2c6673db24e4e92ed32f604c960dc952437d9 --no-filters
- "MERGE_IF_NECESSARY"
+$ ssh -p 29418 review.example.com gerrit test-submit type I78f2c6673db24e4e92ed32f604c960dc952437d9 --no-filters
+"MERGE_IF_NECESSARY"
 ----
 
 == SCRIPTING
diff --git a/Documentation/cmd-version.txt b/Documentation/cmd-version.txt
index 85b0491..cdfc779 100644
--- a/Documentation/cmd-version.txt
+++ b/Documentation/cmd-version.txt
@@ -1,7 +1,7 @@
 = gerrit version
 
 == NAME
-gerrit version - Show the version of the currently executing Gerrit server
+gerrit version - Show the version of the currently executing Gerrit server.
 
 == SYNOPSIS
 [verse]
@@ -34,8 +34,8 @@
 == EXAMPLES
 
 ----
-	$ ssh -p 29418 review.example.com gerrit version
-	gerrit version 2.4.2
+$ ssh -p 29418 review.example.com gerrit version
+gerrit version 2.4.2
 ----
 
 GERRIT
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 7320a50..1d275b4 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -55,7 +55,7 @@
 |An optional topic.
 
 |Strategy
-|The <<submit-strategy>> for the change.
+|The <<submit-strategies,submit strategy>> for the change.
 
 |Code Review
 |Displays the Code Review status for the change.
@@ -84,10 +84,10 @@
 several categories, including:
 
 * Relation Chain. These changes are related by parent-child relationships,
-  regardless of <<topics>>.
+  regardless of <<topic,topic>>.
 * Merge Conflicts. These are changes in which there is a merge conflict with
   the current change.
-* Submitted Together. These are changes that share the same <<topics>>.
+* Submitted Together. These are changes that share the same <<topic,topic>>.
 
 An arrow indicates the change you are currently viewing.
 
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 6378632..e642425 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -200,7 +200,7 @@
 * link:rest-api-accounts.html#edit-preferences-info[Edit Preferences]
 
 If the value for a preference is the same as the default value for this
-preference, it can be omitted in the `preference.config` file.
+preference, it can be omitted in the `preferences.config` file.
 
 Defaults for preferences that apply for all accounts can be configured
 in the `refs/users/default` branch in the `All-Users` repository.
@@ -275,7 +275,7 @@
 To identify SSH keys in the REST API Gerrit uses
 link:rest-api-accounts.html#ssh-key-id[sequence numbers per account].
 This is why the order of the keys in the `authorized_keys` file is
-used to determines the sequence numbers of the keys (the sequence
+used to determine the sequence numbers of the keys (the sequence
 numbers start at 1).
 
 To keep the sequence numbers intact when a key is deleted, a
@@ -286,15 +286,22 @@
 [[external-ids]]
 == External IDs
 
-External IDs are used to link external identities, such as an LDAP
-account or an OAUTH identity, to an account in Gerrit.
+External IDs are used to link identities, such as the username and email
+addresses, and external identies such as an LDAP account or an OAUTH
+identity, to an account in Gerrit.
 
 External IDs are stored as Git Notes in the `All-Users` repository. The
 name of the notes branch is `refs/meta/external-ids`.
 
-As note key the SHA1 of the external ID key is used. This ensures that
-an external ID is used only once (e.g. an external ID can never be
-assigned to multiple accounts at a point in time).
+As note key the SHA1 of the external ID key is used, for example the key
+for the external ID `username:jdoe` is `e0b751ae90ef039f320e097d7d212f490e933706`.
+This ensures that an external ID is used only once (e.g. an external ID can
+never be assigned to multiple accounts at a point in time).
+
+[IMPORTANT]
+If the external ID key is changed manually you must adapt the note key
+to the new SHA1, otherwise the external ID becomes inconsistent and is
+ignored by Gerrit.
 
 The note content is a Git config file:
 
@@ -305,14 +312,14 @@
   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
 ----
 
-The config file has one `externalId` section. The external ID key which
-consists of scheme and ID in the format '<scheme>:<id>' is used as
+The config file has one `externalId` section. The external ID key, which
+consists of scheme and ID in the format '<scheme>:<id>', is used as
 subsection name.
 
-The `accountId` field is mandatory, the `email` and `password` fields
+The `accountId` field is mandatory. The `email` and `password` fields
 are optional.
 
-The external IDs are maintained by Gerrit, this means users are not
+The external IDs are maintained by Gerrit. This means users are not
 allowed to manually edit their external IDs. Only users with the
 link:access-control.html#capability_accessDatabase[Access Database]
 global capability can push updates to the `refs/meta/external-ids`
diff --git a/Documentation/config-auto-site-initialization.txt b/Documentation/config-auto-site-initialization.txt
index acd03c9..2253ed0 100644
--- a/Documentation/config-auto-site-initialization.txt
+++ b/Documentation/config-auto-site-initialization.txt
@@ -2,75 +2,41 @@
 
 == Description
 
-Gerrit supports automatic site initialization on server startup
-when Gerrit runs in a servlet container. Both creation of a new site
-and upgrade of an existing site are supported. By default, all packaged
-plugins will be installed when Gerrit is deployed in a servlet container
-and the location of the Gerrit distribution can be determined at
-runtime. It is also possible to install only a subset of packaged
-plugins or not install any plugins.
+Gerrit supports automatic site initialization on server startup when Gerrit runs
+in a servlet container. Both creation of a new site and upgrade of an existing
+site are supported. By default, all packaged plugins will be installed when
+Gerrit is deployed in a servlet container and the location of the Gerrit
+distribution can be determined at runtime. It is also possible to install only a
+subset of packaged plugins or not install any plugins.
 
-This feature may be useful for such setups where Gerrit administrators
-don't have direct access to the database and the file system of the
-server where Gerrit should be deployed and, therefore, cannot perform
-the init from their local machine prior to deploying Gerrit on such a
-server. It may also make deployment and testing in a local servlet
-container faster to set up as the init step could be skipped.
+This feature may be useful for such setups where Gerrit administrators don't
+have direct access to the file system of the server where Gerrit should be
+deployed and, therefore, cannot perform the init from their local machine prior
+to deploying Gerrit on such a server. It may also make deployment and testing in
+a local servlet container faster to set up as the init step could be skipped.
 
 == Gerrit Configuration
 
-The site initialization will be performed only if the `gerrit.init`
-system property exists. The value of the property is not used; only the
-existence of the property matters.
+In order to perform site initialization, define `gerrit.site_path` with the path
+to your site. If the site already exists, this is the only required property.
+If your site does not yet exist, set the `gerrit.init` system property to
+automatically initialize the site.
 
-If the `gerrit.site_path` system property is defined then the init is
-run for that site. The database connectivity, in that case, is defined
-in the `etc/gerrit.config`.
+During initialization, if the `gerrit.install_plugins` property is not defined,
+then all packaged plugins will be installed. If it is defined, then it is parsed
+as a comma-separated list of plugin names to install. If the value is an empty
+string then no plugins will be installed.
 
-If `gerrit.site_path` is not defined then Gerrit will try to find the
-`gerrit.init_path` system property. If defined this property will be
-used to determine the site path. The database connectivity, also for
-this case, is defined by the `jdbc/ReviewDb` JNDI property.
+=== Example
 
-[WARNING]
-Defining the `jdbc/ReviewDb` JNDI property for an H2 database under the
-path defined by either `gerrit.site_path` or `gerrit.init_path` will
-cause an incomplete auto initialization and Gerrit will fail to start.
-Opening a connection to such a database will create a subfolder under the
-site path folder (in order to create the H2 database) and Gerrit will
-no longer consider that site path to be new and, because of that,
-skip some required initialization steps (for example, Lucene index
-creation). In order to auto initialize Gerrit with an embedded H2
-database use the `gerrit.site_path` to define the location of the review
-site and don't define a JNDI resource with a URL under that path.
-
-If the `gerrit.install_plugins` property is not defined then all packaged
-plugins will be installed. If it is defined then it is parsed as a
-comma-separated list of plugin names to install. If the value is an
-empty string then no plugin will be installed.
-
-=== Example 1
-
-Prepare Tomcat so that a site is initialized at a given path using
-the H2 database (if the site doesn't exist yet) or using whatever
-database is defined in `etc/gerrit.config` of that site:
+Prepare Tomcat so that a site is initialized at a given path (if the site
+doesn't exist yet), installing all packaged plugins.
 
 ----
   $ export CATALINA_OPTS='-Dgerrit.init -Dgerrit.site_path=/path/to/site'
   $ catalina.sh start
 ----
 
-=== Example 2
-
-Assuming the database schema doesn't exist in the database defined
-via the `jdbc/ReviewDb` JNDI property, initialize a new site using that
-database and a given path:
-
-----
-  $ export CATALINA_OPTS='-Dgerrit.init -Dgerrit.init_path=/path/to/site'
-  $ catalina.sh start
-----
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-cla.txt b/Documentation/config-cla.txt
index 2234808..2c7b194 100644
--- a/Documentation/config-cla.txt
+++ b/Documentation/config-cla.txt
@@ -25,13 +25,15 @@
 ----
 
 Contributor agreements are defined as contributor-agreement sections in
-`project.config`:
+`project.config` of `All-Projects`:
 ----
   [contributor-agreement "Individual"]
     description = If you are going to be contributing code on your own, this is the one you want. You can sign this one online.
     agreementUrl = static/cla_individual.html
     autoVerify = group CLA Accepted - Individual
     accepted = group CLA Accepted - Individual
+    matchProjects = ^/.*$
+    excludeProjects = ^/not/my/project/
 ----
 
 Each `contributor-agreement` section within the `project.config` file must
@@ -75,6 +77,16 @@
 contributor agreement has been accepted. The groups' UUID must also
 appear in the `groups` file.
 
+[[contributor-agreement.name.matchProjects]]contributor-agreement.<name>.matchProjects::
++
+List of project regular expressions identifying projects where the
+agreement is required. Defaults to every project when omitted.
+
+[[contributor-agreement.name.excludeProjects]]contributor-agreement.<name>.excludeProjects::
++
+List of project regular expressions identifying projects where the
+agreement does not apply. Defaults to empty. i.e. no projects excluded.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 62c5400..16dd9b2 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -32,7 +32,7 @@
 === Section accountPatchReviewDb
 
 The AccountPatchReviewDb is a database used to store the user file reviewed
-flags. It co-exists with <<database,ReviewDb>> and link:note-db.html[NoteDb].
+flags.
 
 [[accountPatchReviewDb.url]]accountPatchReviewDb.url::
 +
@@ -46,8 +46,8 @@
 link:pgm-MigrateAccountPatchReviewDb.html[MigrateAccountPatchReviewDb] program.
 Migration cannot be done while the server is running.
 +
-Also note that the db_name has to be a new db and not reusing gerrit's own review database,
-otherwise gerrit's init will remove the table.
+Also note that the db_name has to be a new db and not reusing an old ReviewDb
+database from a former 2.x site, otherwise gerrit's init will remove the table.
 
 ----
 [accountPatchReviewDb]
@@ -273,10 +273,7 @@
 `Become` appears in the top right corner of the page, taking the
 user to a form where they can enter the username of any existing
 user account, and immediately login as that account, without any
-authentication taking place.  This form of authentication is only
-useful for the GWT hosted mode shell, where OpenID authentication
-redirects might be risky to the developer's host computer, and HTTP
-authentication is not possible.
+authentication taking place.
 
 +
 By default, OpenID.
@@ -485,13 +482,12 @@
 +
 When `auth.type` does not normally enable this URL administrators may
 set this to `login/`, allowing users to begin a new web session. This value
-is used as an href in PolyGerrit and the GWT UI, so absolute URLs like
+is used as an href in PolyGerrit, so absolute URLs like
 `https://someotherhost/login` work as well.
 +
 If a ${path} parameter is included, then PolyGerrit will substitute the
 currently viewed path in the link. Be aware that this path will include
 a leading slash, so a value like this might be appropriate: `/login${path}`.
-Note: in the GWT UI this substitution for ${path} is *always* `/`.
 
 [[auth.cookiePath]]auth.cookiePath::
 +
@@ -616,7 +612,10 @@
 existing accounts this username is already in lower case. It is not
 possible to convert the usernames of the existing accounts to lower
 case because this would break the access to existing per-user
-branches.
+branches and Gerrit provides no tool to do such a conversion.
++
+Setting this parameter to `true` will prevent all users from login that
+have a non-lower-case username.
 +
 This parameter only affects git over http and git over SSH traffic.
 +
@@ -661,6 +660,13 @@
 +
 By default, false.
 
+[[auth.skipFullRefEvaluationIfAllRefsAreVisible]]auth.skipFullRefEvaluationIfAllRefsAreVisible::
++
+Whether to skip the full ref visibility checks as a performance shortcut when all refs are
+visible to a user. Full ref filtering would filter out things like pending edits.
++
+By default, true.
+
 [[cache]]
 === Section cache
 
@@ -687,7 +693,9 @@
 allows to limit the memory used by H2 and thus prevent out-of-memory
 caused by the H2 database using too much memory.
 +
-See <<database.h2.cachesize,database.h2.cachesize>> for a detailed discussion.
+Technically the H2 cache size is configured using the CACHE_SIZE parameter in
+the H2 JDBC connection URL, as described
+link:http://www.h2database.com/html/features.html#cache_settings[here]
 +
 Default is unset, using up to half of the available memory.
 +
@@ -786,6 +794,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.
@@ -796,11 +805,10 @@
 +
 Cache entries contain important details of an active user, including
 their display name, preferences, and known email addresses. Entry
-information is obtained from the `accounts` database table.
+information is obtained from NoteDb data in the `All-Users` repo.
 
 +
-If direct updates are made to any of these database tables, this
-cache should be flushed.
+If direct updates are made to `All-Users`, this cache should be flushed.
 
 cache `"adv_bases"`::
 +
@@ -862,8 +870,16 @@
 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.
++
+`external_ids_map` supports computing the new cache value based on a
+previously cached state. This applies modifications based on the Git
+diff and is almost always faster.
+`cache.external_ids_map.enablePartialReloads` turns this behavior on
+or off. The default is `false`.
 
 cache `"git_tags"`::
 +
@@ -969,6 +985,11 @@
 Caches parsed `rules.pl` contents for each project. This cache uses the same
 size as the `projects` cache, and cannot be configured independently.
 
+cache `"pure_revert"`::
++
+Result of checking if one change or commit is a pure/clean revert of
+another.
+
 cache `"sshkeys"`::
 +
 Caches unpacked versions of user SSH keys, so the internal SSH daemon
@@ -1130,43 +1151,23 @@
 [[change]]
 === Section change
 
-[[change.largeChange]]change.largeChange::
-+
-Number of changed lines from which on a change is considered as a large
-change. The number of changed lines of a change is the sum of the lines
-that were inserted and deleted in the change.
-+
-The specified value is used to visualize the change sizes in the Web UI
-in change tables and user dashboards.
-+
-By default 500.
-
-[[change.updateDelay]]change.updateDelay::
-+
-How often in seconds the web interface should poll for updates to the
-currently open change.  The poller relies on the client's browser
-cache to use If-Modified-Since and respect `304 Not Modified` HTTP
-responses.  This allows for fast polls, often under 8 milliseconds.
-+
-With a configured 30 second delay a server with 4900 active users will
-typically need to dedicate 1 CPU to the update check.  4900 users
-divided by an average delay of 30 seconds is 163 requests arriving per
-second.  If requests are served at \~6 ms response time, 1 CPU is
-necessary to keep up with the update request traffic.  On a smaller
-user base of 500 active users, the default 30 second delay is only 17
-requests per second and requires ~10% CPU.
-+
-If 0 the update polling is disabled.
-+
-Default is 5 minutes.
-
 [[change.allowBlame]]change.allowBlame::
 +
-Allow blame on side by side diff in the GWT UI. If set to false, blame cannot be
-used.
+Allow blame on side by side diff. If set to false, blame cannot be used.
 +
 Default is true.
 
+[[change.allowDrafts]]change.allowDrafts::
++
+Legacy support for drafts workflow. If set to true, pushing a new change
+with draft option will create a private change. Pushing with draft option
+to an existing change will create change edit.
++
+Enabling this option allows to push to the `refs/drafts/branch`. When
+disabled any push to `refs/drafts/branch` will be rejected.
++
+Default is false.
+
 [[change.api.allowedIdentifier]]change.api.allowedIdentifier::
 +
 Change identifier(s) that are allowed on the API. See
@@ -1179,14 +1180,12 @@
 +
 Default is `ALL`.
 
-[[change.allowDrafts]]change.allowDrafts::
+[[change.api.excludeMergeableInChangeInfo]]change.api.excludeMergeableInChangeInfo::
 +
-Legacy support for drafts workflow. If set to true, pushing a new change
-with draft option will create a private change. Pushing with draft option
-to an existing change will create change edit.
-+
-Enabling this option allows to push to the `refs/drafts/branch`. When
-disabled any push to `refs/drafts/branch` will be rejected.
+If true, the mergeability bit in
+link:rest-api-changes.html#change-info[ChangeInfo] will never be set. It can
+be requested separately through the
+link:rest-api-changes.html#get-mergeable[get-mergeable] endpoint.
 +
 Default is false.
 
@@ -1205,14 +1204,66 @@
 +
 Default is true.
 
-[[change.enableParallelFormatting]]change.enableParallelFormatting::
+[[change.disablePrivateChanges]]change.disablePrivateChanges::
 +
-Whether or not changes can be formatted in parallel when requesting
-multiple changes at once. An example for this is Dashboards.
+If set to true, users are not allowed to create private changes.
 +
-This setting is experimental.
+The default is false.
+
+[[change.largeChange]]change.largeChange::
 +
-Default is `false`.
+Number of changed lines from which on a change is considered as a large
+change. The number of changed lines of a change is the sum of the lines
+that were inserted and deleted in the change.
++
+The specified value is used to visualize the change sizes in the Web UI
+in change tables and user dashboards.
++
+By default 500.
+
+[[change.maxUpdates]]change.maxUpdates::
++
+Maximum number of updates to a change. Counts only updates to the main NoteDb
+meta ref; draft comments, robot comments, stars, etc. do not count towards the
+total.
++
+Many NoteDb operations require walking the entire change meta ref and loading
+its contents into memory, so changes with arbitrarily many updates may cause
+high CPU usage, memory pressure, persistent cache bloat, and other problems.
++
+The following operations are allowed even when a change is at the limit:
+* Abandon
+* Submit
+* Submit by push with `%submit`
+* Auto-close by pushing directly to the branch
+* Fix with link:rest-api-changes.html#fix-input[`expect_merged_as`]
++
+By default 1000.
+
+[[change.replyLabel]]change.replyLabel::
++
+Label name for the reply button. In the user interface an ellipsis (…)
+is appended.
++
+Default is "Reply". In the user interface it becomes "Reply…".
+
+[[change.replyTooltip]]change.replyTooltip::
++
+Tooltip for the reply button. In the user interface a note about the
+keyboard shortcut is appended.
++
+Default is "Reply and score". In the user interface it becomes "Reply
+and score (Shortcut: a)".
+
+[[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
++
+Maximum allowed size of a robot comment that will be accepted. Robot comments
+which exceed the indicated size will be rejected on addition. The specified
+value is interpreted as the maximum size in bytes of the JSON representation of
+the robot comment. Common unit suffixes of 'k', 'm', or 'g' are supported.
+Zero or negative values allow robot comments of unlimited size.
++
+The default limit is 1024kB.
 
 [[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
 +
@@ -1221,6 +1272,14 @@
 +
 Default is false.
 
+[[change.strictLabels]]change.strictLabels::
++
+Reject invalid label votes: invalid labels or invalid values. This
+configuration option is provided for backwards compaitbility and may
+be removed in future gerrit versions.
++
+Default is false.
+
 [[change.submitLabel]]change.submitLabel::
 +
 Label name for the submit button.
@@ -1254,13 +1313,6 @@
 Default is "Submit all ${topicSize} changes of the same topic (${submitSize}
 changes including ancestors and other changes related by topic)".
 
-[[change.submitWholeTopic]]change.submitWholeTopic::
-+
-Determines if the submit button submits the whole topic instead of
-just the current change.
-+
-Default is false.
-
 [[change.submitTopicLabel]]change.submitTopicLabel::
 +
 If `change.submitWholeTopic` is set and a change has a topic,
@@ -1281,44 +1333,31 @@
 (${submitSize} changes including ancestors and other
 changes related by topic)".
 
-[[change.replyLabel]]change.replyLabel::
+[[change.submitWholeTopic]]change.submitWholeTopic::
 +
-Label name for the reply button. In the user interface an ellipsis (…)
-is appended.
-+
-Default is "Reply". In the user interface it becomes "Reply…".
-
-[[change.replyTooltip]]change.replyTooltip::
-+
-Tooltip for the reply button. In the user interface a note about the
-keyboard shortcut is appended.
-+
-Default is "Reply and score". In the user interface it becomes "Reply
-and score (Shortcut: a)".
-
-[[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
-+
-Maximum allowed size of a robot comment that will be accepted. Robot comments
-which exceed the indicated size will be rejected on addition. The specified
-value is interpreted as the maximum size in bytes of the JSON representation of
-the robot comment. Common unit suffixes of 'k', 'm', or 'g' are supported.
-Zero or negative values allow robot comments of unlimited size.
-+
-The default limit is 1024kB.
-
-[[change.strictLabels]]change.strictLabels::
-+
-Reject invalid label votes: invalid labels or invalid values. This
-configuration option is provided for backwards compaitbility and may
-be removed in future gerrit versions.
+Determines if the submit button submits the whole topic instead of
+just the current change.
 +
 Default is false.
 
-[[change.disablePrivateChanges]]change.disablePrivateChanges::
+[[change.updateDelay]]change.updateDelay::
 +
-If set to true, users are not allowed to create private changes.
+How often in seconds the web interface should poll for updates to the
+currently open change.  The poller relies on the client's browser
+cache to use If-Modified-Since and respect `304 Not Modified` HTTP
+responses.  This allows for fast polls, often under 8 milliseconds.
 +
-The default is false.
+With a configured 30 second delay a server with 4900 active users will
+typically need to dedicate 1 CPU to the update check.  4900 users
+divided by an average delay of 30 seconds is 163 requests arriving per
+second.  If requests are served at \~6 ms response time, 1 CPU is
+necessary to keep up with the update request traffic.  On a smaller
+user base of 500 active users, the default 30 second delay is only 17
+requests per second and requires ~10% CPU.
++
+If 0 the update polling is disabled.
++
+Default is 5 minutes.
 
 [[changeCleanup]]
 === Section changeCleanup
@@ -1429,13 +1468,10 @@
 example, to match the string `bug` in a case insensitive way the match
 pattern `[bB][uU][gG]` needs to be used.
 +
-Between the GWT UI and PolyGerrit, the commentlink.name.match regular
-expressions are applied differently. Whereas in the GWT UI the
-expressions are applied to the formatted and escaped HTML result, the
-PolyGerrit UI applies them only to the raw, unformatted and unescaped
-text form. PolyGerrit does not support regex matching against HTML.
-Comment link patterns that are written in this style should be updated
-to match text formats.
+The commentlink.name.match regular expressions are applied to the raw,
+unformatted and unescaped text form. Regex matching against HTML is not
+supported. Comment link patterns that are written in this style should
+be updated to match text formats.
 +
 A common pattern to match is `bug\\s+(\\d+)`.
 
@@ -1547,6 +1583,10 @@
 [[core]]
 === Section core
 
+[NOTE]
+The link:#jgitConfig[etc/jgit.config] file supports configuration of all JGit
+options.
+
 [[core.packedGitWindowSize]]core.packedGitWindowSize::
 +
 Number of bytes of a pack file to load into memory in a single
@@ -1681,215 +1721,6 @@
 +
 Default is 1 hour.
 
-[[database]]
-=== Section database
-
-The database section configures ReviewDb, where Gerrit stores its metadata
-records about account groups and change reviews. Starting from 2.15, accounts
-are always stored in NoteDb and, optionally, changes too. See the
-link:note-db.html[NoteDb documentation] for more information.
-
-Note that user file reviewed flags are stored in a separate database. See the
-<<accountPatchReviewDb,accountPatchReviewDb>> section for more information.
-
-----
-[database]
-  type = POSTGRESQL
-  hostname = localhost
-  database = reviewdb
-  username = gerrit
-  password = s3kr3t
-----
-
-[[database.type]]database.type::
-+
-Type of database server to connect to.  If set this value will be
-used to automatically create correct database.driver and database.url
-values to open the connection.
-+
-* `DB2`
-+
-Connect to a DB2 database server.
-+
-* `DERBY`
-+
-Connect to an Apache Derby database server.
-+
-* `H2`
-+
-Connect to a local embedded H2 database.
-+
-* `JDBC`
-+
-Connect using a JDBC driver class name and URL.
-+
-* `MAXDB`
-+
-Connect to an SAP MaxDB database server.
-+
-* `MYSQL`
-+
-Connect to a MySQL database server.
-+
-* `MARIADB`
-+
-Connect to a MariaDB database server.
-+
-* `ORACLE`
-+
-Connect to an Oracle database server.
-+
-* `POSTGRESQL`
-+
-Connect to a PostgreSQL database server.
-
-+
-If not specified, database.driver and database.url are used as-is,
-and if they are also not specified, defaults to H2.
-
-[[database.hostname]]database.hostname::
-+
-Hostname of the database server.  Defaults to 'localhost'.
-
-[[database.port]]database.port::
-+
-Port number of the database server.  Defaults to the default port
-of the server named by database.type.
-
-[[database.database]]database.database::
-+
-For POSTGRESQL or MYSQL, the name of the database on the server.
-+
-For H2, this is the path to the database, and if not absolute is
-relative to `'$site_path'`.
-
-[[database.username]]database.username::
-+
-Username to connect to the database server as.
-
-[[database.password]]database.password::
-+
-Password to authenticate to the database server with.
-
-[[database.driver]]database.driver::
-+
-Name of the JDBC driver class to connect to the database with.
-Setting this usually isn't necessary as it can be derived from
-database.type or database.url for any supported database.
-
-[[database.url]]database.url::
-+
-'jdbc:' URL for the database.  Setting this variable usually
-isn't necessary as it can be constructed from the all of the
-above properties.
-
-[[database.connectionPool]]database.connectionPool::
-+
-If true, use connection pooling for database connections. Otherwise, a
-new database connection is opened for each request.
-+
-Default is false for MySQL, and true for other database backends.
-
-[[database.poolLimit]]database.poolLimit::
-+
-Maximum number of open database connections.  If the server needs
-more than this number, request processing threads will wait up
-to <<database.poolMaxWait, poolMaxWait>> seconds for a
-connection to be released before they abort with an exception.
-This limit must be several units higher than the total number of
-httpd and sshd threads as some request processing code paths may
-need multiple connections.
-+
-Default is <<sshd.threads, sshd.threads>>
- + <<httpd.maxThreads, httpd.maxThreads>> + 2.
-+
-This setting only applies if
-<<database.connectionPool,database.connectionPool>> is true.
-
-[[database.poolMinIdle]]database.poolMinIdle::
-+
-Minimum number of connections to keep idle in the pool.
-Default is 4.
-+
-This setting only applies if
-<<database.connectionPool,database.connectionPool>> is true.
-
-[[database.poolMaxIdle]]database.poolMaxIdle::
-+
-Maximum number of connections to keep idle in the pool.  If there
-are more idle connections, connections will be closed instead of
-being returned back to the pool.
-Default is min(<<database.poolLimit, database.poolLimit>>, 16).
-+
-This setting only applies if
-<<database.connectionPool,database.connectionPool>> is true.
-
-[[database.poolMaxWait]]database.poolMaxWait::
-+
-Maximum amount of time a request processing thread will wait to
-acquire a database connection from the pool.  If no connection is
-released within this time period, the processing thread will abort
-its current operations and return an error to the client.
-Values should use common unit suffixes to express their setting:
-+
-* ms, milliseconds
-* s, sec, second, seconds
-* m, min, minute, minutes
-* h, hr, hour, hours
-
-+
---
-If a unit suffix is not specified, `milliseconds` is assumed.
-
-Default is `30 seconds`.
-
-This setting only applies if
-<<database.connectionPool,database.connectionPool>> is true.
---
-
-[[database.dataSourceInterceptorClass]]database.dataSourceInterceptorClass::
-
-Class that implements DataSourceInterceptor interface to monitor SQL activity.
-This class must have default constructor and be available on Gerrit's bootstrap
-classpath, e. g. in `$gerrit_site/lib` directory. Example implementation of
-SQL monitoring can be found in javamelody-plugin.
-
-[[database.h2]]database.h2::
-+
-The settings in this section are used for the reviewdb if the
-<<database.type,database.type>> is H2.
-+
-Additionally gerrit uses H2 for storing reviewed flags on changes.
-
-[[database.h2.cacheSize]]database.h2.cacheSize::
-+
-The size of the H2 internal database cache, in bytes. The H2 internal cache for
-persistent H2-backed caches is controlled by
-<<cache.h2CacheSize,cache.h2CacheSize>>.
-+
-H2 uses memory to cache its database content. The parameter `cacheSize`
-allows to limit the memory used by H2 and thus prevent out-of-memory
-caused by the H2 database using too much memory.
-+
-Technically the H2 cache size is configured using the CACHE_SIZE parameter in
-the H2 JDBC connection URL, as described
-link:http://www.h2database.com/html/features.html#cache_settings[here]
-+
-Default is unset, using up to half of the available memory.
-+
-H2 will persist this value in the database, so to unset explicitly specify 0.
-+
-Common unit suffixes of 'k', 'm', or 'g' are supported.
-
-[[database.h2.autoServer]]database.h2.autoServer::
-+
-If `true` enable the automatic mixed mode
-(see link:http://www.h2database.com/html/features.html#auto_mixed_mode[Automatic Mixed Mode]).
-This enables concurrent access to the embedded H2 database from command line
-utils (e.g. MigrateToNoteDb).
-+
-Default is `false`.
-
 [[download]]
 === Section download
 
@@ -2153,10 +1984,20 @@
 by the system administrator, and might not even be running on the
 same host as Gerrit.
 
+[[gerrit.installDbModule]]gerrit.installDbModule::
++
+Repeatable list of class name of additional Guice modules to load at
+Gerrit startup as part of the dbInjector and during the init phases.
+Classes are resolved using the primary Gerrit class loader, hence the
+class needs to be either declared in Gerrit or an additional JAR
+located under the `/lib` directory.
++
+By default unset.
+
 [[gerrit.installModule]]gerrit.installModule::
 +
 Repeatable list of class name of additional Guice modules to load at
-Gerrit startup and init phases.
+Gerrit startup as part of the sysInjector and during the init phases.
 Classes are resolved using the primary Gerrit class loader, hence the
 class needs to be either declared in Gerrit or an additional JAR
 located under the `/lib` directory.
@@ -2168,6 +2009,40 @@
 [gerrit]
   installModule = com.googlesource.gerrit.libmodule.MyModule
   installModule = com.example.abc.OurSpecialSauceModule
+  installDbModule = com.example.def.OurCustomProvider
+----
+
+[[gerrit.listProjectsFromIndex]]gerrit.listProjectsFromIndex::
++
+Enable rendering of project list from the secondary index instead
+of purely relying on the in-memory cache.
++
+By default false.
++
+[NOTE]
+The in-memory cache (set to false) rendering provides an **unlimited list** as a result
+of the list project API, causing the full list of projects to be
+returned as a result of the link:rest-api-projects.html[/projects/] REST API
+or the link:cmd-ls-projects.html[gerrit ls-projects] SSH command.
+When the rendering from the secondary index (set to true),
+the **list is limited** by the global capability
+link:access-control.html#capability_queryLimit[queryLimit]
+which is defaulted to 500 entries.
+
+[[gerrit.primaryWeblinkName]]gerrit.primaryWeblinkName::
++
+Name of the link:dev-plugins.html#links-to-external-tools[Weblink] that should
+be chosen in cases where only one Weblink can be used in the UI, for example in
+inline links.
++
+By default unset, meaning that the UI is responsible for trying to identify
+a weblink to be used in these cases, most likely weblinks that links to code
+browsers with known integrations with Gerrit (like Gitiles and Gitweb).
++
+Example:
+----
+[gerrit]
+  primaryWeblinkName = gitiles
 ----
 
 [[gerrit.reportBugUrl]]gerrit.reportBugUrl::
@@ -2177,19 +2052,16 @@
 By default unset, meaning no bug report URL will be displayed. Administrators
 should set this to the URL of their issue tracker, if necessary.
 
-[[gerrit.reportBugText]]gerrit.reportBugText::
+[[gerrit.enableReverseDnsLookup]]gerrit.enableReverseDnsLookup::
 +
-Text to be displayed in the link to the bug report URL.
+Enable reverse DNS lookup during computing ref log entry for identified user,
+to record the actual hostname of the user's host in the ref log.
 +
-Only used when `gerrit.reportBugUrl` is set.
+Enabling reverse DNS lookup can cause performance issues on git push when
+the reverse DNS lookup is slow.
 +
-Defaults to "Report Bug".
-
-[[gerrit.disableReverseDnsLookup]]gerrit.disableReverseDnsLookup::
-+
-Disables reverse DNS lookup during computing ref log entry for identified user.
-+
-Defaults to false.
+Defaults to false, reverse DNS lookup is disabled. The user's IP address
+will be recorded in the ref log rather than their hostname.
 
 [[gerrit.secureStoreClass]]gerrit.secureStoreClass::
 +
@@ -2779,16 +2651,16 @@
 it to 0 disables the dedicated thread pool and indexing will be done in the same
 thread as the operation.
 +
-If not set or set to a negative value, defaults to 1 plus half of the number of
-logical CPUs as returned by the JVM.
+If not set or set to a zero, defaults to the number of logical CPUs as returned
+by the JVM. If set to a negative value, defaults to a direct executor.
 
 [[index.batchThreads]]index.batchThreads::
 +
 Number of threads to use for indexing in background operations, such as
 online schema upgrades.
 +
-If not set or set to a negative value, defaults to the number of logical
-CPUs as returned by the JVM.
+If not set or set to a zero, defaults to the number of logical CPUs as returned
+by the JVM. If set to a negative value, defaults to a direct executor.
 
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
@@ -2829,8 +2701,7 @@
 +
 Maximum number of leaf terms to allow in a query. Too-large queries may
 perform poorly, so setting this option causes query parsing to fail fast
-before attempting to send them to the secondary index. Should this limit
-be reached, database is used instead of index as applicable.
+before attempting to send them to the secondary index.
 +
 When the index type is `LUCENE`, also sets the maximum number of clauses
 permitted per BooleanQuery. This is so that all enforced query limits
@@ -2979,14 +2850,18 @@
 [[elasticsearch]]
 === Section elasticsearch
 
-WARNING: The Elasticsearch support has only been tested with Elasticsearch
-versions 2.4, 5.6 and 6.2. Support for other versions is not guaranteed.
+WARNING: Support for Elasticsearch is still experimental and is not recommended
+for production use. For compatibility information, please refer to the
+link:https://www.gerritcodereview.com/elasticsearch.html[project homepage].
 
-Open and closed changes are indexed in a single index, separated into types
-`open_changes` and `closed_changes` respectively, if using Elasticsearch
-versions 2.4 or 5.6. Open and closed changes are merged into the default `_doc`
-type otherwise. The latter is also used for accounts and groups indices starting
-with Elasticsearch 6.2.
+When using Elasticsearch version 5.6, the open and closed changes are
+indexed in a single index, separated into types `open_changes` and `closed_changes`
+respectively. When using version 6.2 or later, the open and closed changes are
+merged into the default `_doc` type. The latter is also used for the accounts and
+groups indices starting with Elasticsearch 6.2.
+
+Note that when Gerrit is configured to use Elasticsearch, the Elasticsearch
+server(s) must be reachable during the site initialization.
 
 [[elasticsearch.prefix]]elasticsearch.prefix::
 +
@@ -2996,11 +2871,53 @@
 +
 Not set by default.
 
+[[elasticsearch.server]]elasticsearch.server::
++
+Elasticsearch server URI in the form `http[s]://hostname:port`. The `port` is
+optional and defaults to `9200` if not specified.
++
+At least one server must be specified. May be specified multiple times to
+configure multiple Elasticsearch servers.
++
+Note that the site initialization program only allows to configure a single
+server. To configure multiple servers the `gerrit.config` file must be edited
+manually.
+
+[[elasticsearch.numberOfShards]]elasticsearch.numberOfShards::
++
+Sets the number of shards to use per index. Refer to the
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-concepts.html#getting-started-shards-and-replicas[
+Elasticsearch documentation] for details.
++
+Defaults to 5 for Elasticsearch versions 5 and 6, and to 1 starting with Elasticsearch 7.
+
+[[elasticsearch.numberOfReplicas]]elasticsearch.numberOfReplicas::
++
+Sets the number of replicas to use per index. Refer to the
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-concepts.html#getting-started-shards-and-replicas[
+Elasticsearch documentation] for details.
++
+Defaults to 1.
+
+==== Elasticsearch Security
+
+When security is enabled in Elasticsearch, the username and password must be provided.
+Note that the same username and password are used for all servers.
+
+For further information about Elasticsearch security, please refer to the documentation:
+
+* link:https://www.elastic.co/guide/en/x-pack/5.6/security-getting-started.html[Elasticsearch 5.6]
+* link:https://www.elastic.co/guide/en/x-pack/6.2/security-getting-started.html[Elasticsearch 6.2]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.3/security-getting-started.html[Elasticsearch 6.3]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.4/security-getting-started.html[Elasticsearch 6.4]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.5/security-getting-started.html[Elasticsearch 6.5]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.6/security-getting-started.html[Elasticsearch 6.6]
+
 [[elasticsearch.username]]elasticsearch.username::
 +
 Username used to connect to Elasticsearch.
 +
-Not set by default.
+If a password is set, defaults to `elastic`, otherwise not set by default.
 
 [[elasticsearch.password]]elasticsearch.password::
 +
@@ -3008,65 +2925,6 @@
 +
 Not set by default.
 
-[[elasticsearch.requestCompression]]elasticsearch.requestCompression::
-+
-Enable request compression.
-+
-Defaults to `false`.
-
-[[elasticsearch.connectionTimeout]]elasticsearch.connectionTimeout::
-+
-How long should Gerrit waits for connection.
-+
-The value is in the usual time-unit format like `1 m`, `5 m`.
-+
-Defaults to `5 m`
-
-[[elasticsearch.maxConnectionIdleTime]]elasticsearch.maxConnectionIdleTime::
-+
-How long connection can stay in idle.
-+
-The value is in the usual time-unit format like `1 m`, `5 m`.
-+
-Defaults to `5 m`
-
-[[elasticsearch.maxTotalConnection]]elasticsearch.maxTotalConnection::
-+
-How many connections can be spawned simultaneously.
-+
-Defaults to `1`
-
-[[elasticsearch.maxReadTimeout]]elasticsearch.maxReadTimeout::
-+
-Timeout for read operations.
-+
-The value is in the usual time-unit format like `1 m`, `5 m`.
-+
-Defaults to `5 m`
-
-==== Elasticsearch server(s) configuration
-
-Each section corresponds to one Elasticsearch server.
-
-[[elasticsearch.name.protocol]]elasticsearch.name.protocol::
-+
-Elasticsearch server protocol. Can be `http` or `https`.
-+
-Defaults to `http`.
-
-[[elasticsearch.name.hostname]]elasticsearch.name.hostname::
-+
-Elasticsearch server hostname.
-
-Defaults to `localhost`.
-
-[[elasticsearch.name.port]]elasticsearch.name.port::
-+
-Elasticsearch server port.
-+
-Defaults to `9200`.
-
-
 [[ldap]]
 === Section ldap
 
@@ -3095,6 +2953,19 @@
   groupMemberPattern = (&(objectClass=group)(member=${dn}))
 ----
 
+[[ldap.guessRelevantGroups]]ldap.guessRelevantGroups::
++
+Filter the groups found in LDAP by guessing the ones relevant to
+Gerrit and removing the others from list completions and ACL evaluations.
+The guess is based on two elements: the projects most recently
+accessed in the cache and the list of LDAP groups included in their ACLs.
++
+Please note that projects rarely used and thus not cached may be
+temporarily inaccessible by users even with LDAP membership and grants
+referenced in the ACLs.
++
+By default, true.
+
 [[ldap.server]]ldap.server::
 +
 URL of the organization's LDAP server to query for user information
@@ -3111,6 +2982,14 @@
 +
 By default, false, StartTLS will not be enabled.
 
+[[ldap.supportAnonymous]]ldap.supportAnonymous::
++
+If false, Gerrit will provide credentials only at connection open, this is
+required for some `LDAP` implementations that do not allow anonymous bind
+for StartTLS or for reauthentication.
++
+By default, true.
+
 [[ldap.sslVerify]]ldap.sslVerify::
 +
 If false and ldap.server is an `ldaps://` style URL or `ldap.startTls`
@@ -3517,9 +3396,9 @@
 [[note-db]]
 === Section noteDb
 
-NoteDb is the next generation of Gerrit storage backend, currently powering
-`googlesource.com`. For more information, including how to migrate your data,
-see the link:note-db.html[documentation].
+NoteDb is the Git-based database storage backend for Gerrit. For more
+information, including how to migrate data from an older Gerrit version, see the
+link:note-db.html[documentation].
 
 [[notedb.accounts.sequenceBatchSize]]notedb.accounts.sequenceBatchSize::
 +
@@ -3608,6 +3487,11 @@
 and SSH.  If set to true Administrators can install new plugins
 remotely, or disable existing plugins.  Defaults to false.
 
+[[plugins.mandatory]]plugins.mandatory::
++
+List of mandatory plugins. If a plugin from this list does not load,
+Gerrit start will fail.
+
 [[plugins.jsLoadTimeout]]plugins.jsLoadTimeout::
 +
 Set the timeout value for loading JavaScript plugins in Gerrit UI.
@@ -3630,17 +3514,6 @@
 If no groups are added, any user will be allowed to execute
 'receive-pack' on the server.
 
-[[receive.allowPushToRefsChanges]]receive.allowPushToRefsChanges::
-+
-If true, it is possible to push directly to a change using `refs/changes/`.
-The possibility to push to `refs/changes/` is deprecated and it might be
-removed in future releases.
-See link:user-upload.html#manual_replacement_mapping[Manual Replacement Mapping].
-+
-False means pushing to `refs/changes/` is prohibited.
-+
-Defaults to false.
-
 [[receive.certNonceSeed]]receive.certNonceSeed::
 +
 If set to a non-empty value and server-side signed push validation is
@@ -3690,6 +3563,20 @@
 +
 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.
++
+This value supports configuration reloads:
+link:cmd-reload-config.html[reload-config]
+
 [[receive.checkReferencedObjectsAreReachable]]receive.checkReferencedObjectsAreReachable::
 +
 If set to true, Gerrit will validate that all referenced objects that
@@ -3749,7 +3636,7 @@
 from pushing objects which are too large to Gerrit.
 +
 This setting can also be set in the `project.config`
-link:config-project-config.html[receive.maxObjectSizeLimit] in order
+(link:config-project-config.html[receive.maxObjectSizeLimit]) in order
 to further reduce the global setting. The project specific setting is
 only honored when it further reduces the global limit.
 +
@@ -3757,6 +3644,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],
@@ -3802,7 +3697,6 @@
 If no keys are specified, web-of-trust checks are disabled. This is the
 default behavior.
 
-
 [[repository]]
 === Section repository
 
@@ -3851,9 +3745,9 @@
 are `INHERIT`, `MERGE_IF_NECESSARY`, `FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`,
 `REBASE_ALWAYS`, `MERGE_ALWAYS` and `CHERRY_PICK`.
 +
-For more details see link:project-configuration.html#submit_type[Submit Types].
+For more details see link:config-project-config.html#submit-type[Submit Types].
 +
-Default is link:project-configuration.html#submit_type_inherit[`INHERIT`].
+Default is link:config-project-config.html#submit_type_inherit[`INHERIT`].
 +
 This submit type is only applied at project creation time if a submit type is
 omitted from the link:rest-api-projects.html#project-input[ProjectInput]. If the
@@ -3902,6 +3796,14 @@
 Defaults to link:#retry.timeout[`retry.timeout`]; unit suffixes are supported,
 and assumes milliseconds if not specified.
 
+[[retry.retryWithTraceOnFailure]]retry.retryWithTraceOnFailure::
++
+Whether Gerrit should automatically retry operations on failure with tracing
+enabled. The automatically generated traces can help with debugging. Please
+note that only some of the REST endpoints support automatic retry.
++
+By default this is set to false.
+
 [[rules]]
 === Section rules
 
@@ -4375,7 +4277,8 @@
 If additional requests are received while all threads are busy they
 are queued and serviced in a first-come-first-served order.
 +
-By default, 2x the number of CPUs available to the JVM.
+By default, 2x the number of CPUs available to the JVM (but at least 4
+threads).
 +
 [NOTE]
 When SSH daemon is enabled then this setting also defines the max number of
@@ -4644,103 +4547,71 @@
 +
 By default 0.
 
-[[theme]]
-=== Section theme
+[[tracing]]
+=== Section tracing
 
-[[theme.backgroundColor]]theme.backgroundColor::
+[[tracing.performanceLogging]]tracing.performanceLogging::
 +
-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.
+Whether performance logging is enabled.
 +
-By default white, `FFFFFF`.
+When performance logging is enabled, performance events for some
+operations are collected in memory while a request is running. At the
+end of the request the performance events are handed over to the
+link:dev-plugins.html#performance-logger[PerformanceLogger] plugins.
+This means if performance logging is enabled, the memory footprint of
+requests is slightly increased.
++
+This setting has no effect if no
+link:dev-plugins.html#performance-logger[PerformanceLogger] plugins are
+installed, because then performance logging is always disabled.
++
+By default, true.
 
-[[theme.topMenuColor]]theme.topMenuColor::
-+
-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.
-+
-By default white, `FFFFFF`.
+[[tracing.traceid]]
+==== Subsection tracing.<trace-id>
 
-[[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.
-+
-By default dark grey, `353535`.
+There can be multiple `tracing.<trace-id>` subsections to configure
+automatic tracing of requests. To be traced a request must match all
+conditions of one `tracing.<trace-id>` subsection. The subsection name
+is used as trace ID. Using this trace ID administrators can find
+matching log entries.
 
-[[theme.trimColor]]theme.trimColor::
+[[tracing.traceid.requestType]]tracing.<trace-id>.requestType::
 +
-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.
+Type of request for which request tracing should be always enabled (can
+be `GIT_RECEIVE`, `GIT_UPLOAD`, `REST` and `SSH`).
 +
-By default a light grey, `EEEEEE`.
+May be specified multiple times.
++
+By default, unset (all request types are matched).
 
-[[theme.selectionColor]]theme.selectionColor::
+[[tracing.traceid.requestUriPattern]]tracing.<trace-id>.requestUriPattern::
 +
-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.
+Regular expression to match request URIs for which request tracing
+should be always enabled. Request URIs are only available for REST
+requests. Request URIs never include the '/a' prefix.
 +
-By default a pale blue, `D8EDF9`.
+May be specified multiple times.
++
+By default, unset (all request URIs are matched).
 
-[[theme.changeTableOutdatedColor]]theme.changeTableOutdatedColor::
+[[tracing.traceid.account]]tracing.<trace-id>.account::
 +
-Background color used for patch outdated messages.  The value must be
-a valid HTML hex color code, or standard color name.
+Account ID of an account for which request tracing should be always
+enabled.
 +
-By default a shade of red, `F08080`.
-
-[[theme.tableOddRowColor]]theme.tableOddRowColor::
+May be specified multiple times.
 +
-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, unset (all accounts are matched).
+
+[[tracing.traceid.projectPattern]]tracing.<trace-id>.projectPattern::
 +
-By default transparent.
-
-[[theme.tableEvenRowColor]]theme.tableEvenRowColor::
+Regular expression to match project names for which request tracing
+should be always enabled.
 +
-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.
+May be specified multiple times.
 +
-By default transparent.
-
-A different theme may be used for signed-in vs. signed-out user status
-by using the "signed-in" and "signed-out" theme sections. Variables
-not specified in a section are inherited from the default theme.
-
-----
-[theme]
-  backgroundColor = FFFFFF
-[theme "signed-in"]
-  backgroundColor = C0C0C0
-[theme "signed-out"]
-  backgroundColor = 00FFFF
-----
-
-As example, here is the theme configuration to have the old green look:
-
-----
-[theme]
-  backgroundColor = FCFEEF
-  textColor = 000000
-  trimColor = D4E9A9
-  selectionColor = FFFFCC
-  topMenuColor = D4E9A9
-  changeTableOutdatedColor = F08080
-[theme "signed-in"]
-  backgroundColor = FFFFFF
-----
+By default, unset (all projects are matched).
 
 [[trackingid]]
 === Section trackingid
@@ -5007,7 +4878,7 @@
 
 +
 The time zone cannot be specified but is always the system default
-time zone.
+time zone. Hours must be zero-padded, i.e. `06:00` rather than `6:00`.
 
 The section (and optionally the subsection) in which the `interval` and
 `startTime` keys must be set depends on the background job for which a
@@ -5047,6 +4918,38 @@
 Assuming that the server is started on `Mon 07:00` then this yields the
 first run on Tuesday at 06:00 and a repetition interval of 1 day.
 
+[[All-Projects-project.config]]
+== File `etc/All-Projects/project.config`
+
+The optional file `'$site_path'/etc/All-Projects/project.config` provides
+defaults for configuration read from
+link:config-project-config.html[`project.config`] in the
+`All-Projects` repo. Unlike `gerrit.config`, this file contains project-type
+configuration rather than server-type configuration.
+
+Most administrators will not need this file, and should instead make commits to
+`All-Projects` to modify global config. However, a separate file can be useful
+when managing multiple Gerrit servers, since pushing changes to defaults using
+Puppet or a similar tool can be easier than scripting git updates to
+`All-Projects`.
+
+The contents of the file are loaded each time the `All-Projects` project is
+reloaded. Updating the file requires either evicting the project cache or
+restarting the server.
+
+Caveats:
+
+* The path from which the file is read corresponds to the name of the repo,
+  which is link:#gerrit.allProjects[configurable].
+* Although the file lives in a directory that shares a name with a repository,
+  this directory is not a Git repository.
+* Only the file `project.config` is read from this directory to provide
+  defaults; any other files in this directory, such as `rules.pl`, are ignored.
+  (This behavior may change in the future.)
+* Group names listed in the access config in this file are resolved to UUIDs
+  using the `groups` file in the repository, not in the config directory. As a
+  result, setting ACLs in this file is not recommended.
+
 [[secure.config]]
 == File `etc/secure.config`
 
@@ -5086,21 +4989,6 @@
 
 The format is one Base-64 encoded public key per line.
 
-
-== Database system_config
-
-Several columns in the `system_config` table within the metadata
-database may be set to control how Gerrit behaves.
-
-[NOTE]
-The contents of the `system_config` table are cached at startup
-by Gerrit.  If you modify any columns in this table, Gerrit needs
-to be restarted before it will use the new values.
-
-== Configuring the Polygerrit UI
-
-Please see link:dev-polygerrit.html[UI] on configuring the Polygerrit UI.
-
 === Configurable Parameters
 
 site_path::
@@ -5115,6 +5003,19 @@
 +
 * link:config-themes.html[Themes]
 
+[[jgitConfig]]
+== File `etc/jgit.config`
+
+Gerrit uses the `$site_path/etc/jgit.config` file instead of the
+system-wide and user-global Git configuration for its runtime JGit
+configuration.
+
+Sample `etc/jgit.config` file:
+----
+[core]
+  trustFolderStat = false
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-hooks.txt b/Documentation/config-hooks.txt
index a71595f..835ec11 100644
--- a/Documentation/config-hooks.txt
+++ b/Documentation/config-hooks.txt
@@ -2,7 +2,7 @@
 
 Gerrit does not run any of the standard git hooks in the repositories
 it works with, but it does have its own hook mechanism included via
-the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/hooks[
+the link:https://gerrit-review.googlesource.com/admin/repos/plugins/hooks[
 hooks plugin].
 
 GERRIT
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index cf78c6d..9c90ba7 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -238,7 +238,9 @@
 The `PatchSetLock` function provides a locking mechanism for patch
 sets.  This function's values are not considered when determining
 whether a change is submittable. When set, no new patchsets can be
-created and rebase and abandon are blocked.
+created and rebase and abandon are blocked. This is useful to prevent
+updates to a change while (potentially expensive) CI
+validation is running.
 +
 This function is designed to allow overlapping locks, so several lock
 accounts could lock the same change.
@@ -295,12 +297,13 @@
 [[label_copyAllScoresOnTrivialRebase]]
 === `label.Label-Name.copyAllScoresOnTrivialRebase`
 
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that is a trivial rebase. A new patch set is considered
-as trivial rebase if the commit message is the same as in the previous
-patch set and if it has the same code delta as the previous patch set.
-This is the case if the change was rebased onto a different parent, or
-if the parent did not change at all.
+If true, all scores for the label are copied forward when a new patch set is
+uploaded that is a trivial rebase. A new patch set is considered to be trivial
+rebase if the commit message is the same as in the previous patch set and if it
+has the same diff (including context lines) as the previous patch set. This is
+the case if the change was rebased onto a different parent and that rebase did
+not require git to perform any conflict resolution, or if the parent did not
+change at all.
 
 This can be used to enable sticky approvals, reducing turn-around for
 trivial rebases prior to submitting a change.
@@ -311,13 +314,13 @@
 [[label_copyAllScoresIfNoCodeChange]]
 === `label.Label-Name.copyAllScoresIfNoCodeChange`
 
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that has the same parent tree as the previous patch
-set and the same code delta as the previous patch set. This means only
-the commit message is different. This can be used to enable sticky
-approvals on labels that only depend on the code, reducing turn-around
-if only the commit message is changed prior to submitting a change.
-For the Verified label that is optionally installed by the
+If true, all scores for the label are copied forward when a new patch set is
+uploaded that has the same parent tree as the previous patch set and the same
+code diff (including context lines) as the previous patch set. This means only
+the commit message is different; the change hasn't even been rebased. This can
+be used to enable sticky approvals on labels that only depend on the code,
+reducing turn-around if only the commit message is changed prior to submitting a
+change. For the Verified label that is optionally installed by the
 link:pgm-init.html[init] site program this is enabled by default.
 
 Defaults to false.
@@ -368,6 +371,18 @@
 ignored if the label doesn't apply for that branch.
 Additionally, the `branch` modifier has no effect when the submit rule
 is customized in the rules.pl of the project or inherited from parent projects.
+Branch can be a ref pattern similar to what is documented
+link:access-control.html#reference[here], but must not contain `${username}` or
+`${shardeduserid}`.
+
+[[label_ignoreSelfApproval]]
+=== `label.Label-Name.ignoreSelfApproval`
+
+If true, the label may be voted on by the uploader of the latest patch set,
+but their approval does not make a change submittable. Instead, a
+non-uploader who has the right to vote has to approve the change.
+
+Defaults to false.
 
 [[label_example]]
 === Example
diff --git a/Documentation/config-login-register.txt b/Documentation/config-login-register.txt
index 3dcef0a..cc2185b 100644
--- a/Documentation/config-login-register.txt
+++ b/Documentation/config-login-register.txt
@@ -57,7 +57,7 @@
 find the url in the settings file.
 
 ----
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config gerrit.canonicalWebUrl
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config gerrit.canonicalWebUrl
   http://localhost:8080/
   gerrit@host:~$
 ----
@@ -70,9 +70,9 @@
 proxy settings in the configuration file.
 
 ----
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxy http://proxy:8080
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyUsername username
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyPassword password
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxy http://proxy:8080
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxyUsername username
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxyPassword password
 ----
 
 Refer to the Gerrit configuration guide for more detailed information about
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index ef6a488..7d46e26 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -66,6 +66,12 @@
 will be appended to emails related to a user submitting comments on changes.
 See `ChangeSubject.soy`, Comment and ChangeFooter.
 
+=== DeleteKey.soy and DeleteKeyHtml.soy
+
+DeleteKey templates will determine the contents of the email related to SSH or GPG keys
+being deleted from a user account. This notification is not sent when the key is
+administratively deleted from another user account.
+
 === DeleteVote.soy and DeleteVoteHtml.soy
 
 The DeleteVote templates will determine the contents of the email related to
@@ -83,6 +89,11 @@
 The Footer templates will determine the contents of the footer text appended to
 the end of all outgoing emails after the ChangeFooter and CommentFooter.
 
+=== HttpPasswordUpdate.soy and HttpPasswordUpdateHtml.soy
+
+HttpPasswordUpdate templates will determine the contents of the email related to adding,
+changing or deleting the HTTP password on a user account.
+
 === Merged.soy and MergedHtml.soy
 
 The Merged templates will determine the contents of the email related to a
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index 29f3273..f86c17a 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -9,6 +9,10 @@
 link:config-gerrit.html#plugins.checkFrequency[a few minutes] until
 the server picks up new and updated plugins.
 
+Due to caching, you might need to flush your browser cache after
+installing a plugin. Users will usually see the result within
+several minutes.
+
 Plugins can also be installed via
 link:rest-api-plugins.html#install-plugin[REST] and
 link:cmd-plugin-install.html[SSH].
@@ -44,7 +48,7 @@
 
 CodeMirror plugin for polygerrit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/codemirror-editor[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/codemirror-editor[
 Project] |
 
 [[commit-message-length-validator]]
@@ -54,38 +58,72 @@
 message body, and reports warnings or errors to the git client if the
 lengths are exceeded.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/commit-message-length-validator[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/commit-message-length-validator[
 Project] |
 link:https://gerrit.googlesource.com/plugins/commit-message-length-validator/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
 link:https://gerrit.googlesource.com/plugins/commit-message-length-validator/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[delete-project]]
+=== delete-project
+
+Provides the ability to delete a project.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/delete-project[
+Project] |
+link:https://gerrit.googlesource.com/plugins/delete-project/+doc/master/src/main/resources/Documentation/about.md[
+Documentation] |
+link:https://gerrit.googlesource.com/plugins/delete-project/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[download-commands]]
 === download-commands
 
 This plugin defines commands for downloading changes in different
 download schemes (for downloading via different network protocols).
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/download-commands[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/download-commands[
 Project] |
 link:https://gerrit.googlesource.com/plugins/download-commands/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
 link:https://gerrit.googlesource.com/plugins/download-commands/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[gitiles]]
+=== gitiles
+
+Plugin running Gitiles alongside a Gerrit server.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/gitiles[
+Project]
+
 [[hooks]]
 === hooks
 
 This plugin runs server-side hooks on events.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/hooks[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/hooks[
 Project] |
 link:https://gerrit.googlesource.com/plugins/hooks/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
 link:https://gerrit.googlesource.com/plugins/hooks/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[plugin-manager]]
+=== plugin-manager
+
+This plugins provides an initial wizard to discover and install Gerrit plugins.
+Per default GerritForge CI is used to download the plugin artifacts from, but
+this can be changed per plugin configuration.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/plugin-manager[
+Project]
+link:https://gerrit.googlesource.com/plugins/plugin-manager/+doc/master/src/main/resources/Documentation/about.md[
+Documentation]
+link:https://gerrit.googlesource.com/plugins/plugin-manager/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[replication]]
 === replication
 
@@ -94,7 +132,7 @@
 be configured to provide mirroring of changes, for warm-standby
 backups, or a load-balanced public mirror farm.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/replication[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/replication[
 Project] |
 link:https://gerrit.googlesource.com/plugins/replication/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -107,21 +145,11 @@
 Stores review information for Gerrit changes in the `refs/notes/review`
 branch.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewnotes[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewnotes[
 Project] |
 link:https://gerrit.googlesource.com/plugins/reviewnotes/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 
-[[review-strategy]]
-=== review-strategy
-
-This plugin allows users to configure different review strategies.
-
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/review-strategy[
-Project] |
-link:https://gerrit.googlesource.com/plugins/review-strategy/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
-
 [[singleusergroup]]
 === singleusergroup
 
@@ -129,6 +157,18 @@
 rights directly to a single user, since in Gerrit access rights can
 only be assigned to groups.
 
+[[webhooks]]
+=== webhooks
+
+This plugin allows to propagate Gerrit events to remote http endpoints.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/webhooks[
+Project] |
+link:https://gerrit.googlesource.com/plugins/webhooks/+doc/master/src/main/resources/Documentation/about.md[
+Documentation] |
+link:https://gerrit.googlesource.com/plugins/webhooks/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[other-plugins]]
 == Other Plugins
 
@@ -145,7 +185,7 @@
 
 The following list gives an overview of available plugins, but the
 list may not be complete. You may discover more plugins on
-link:https://gerrit-review.googlesource.com/#/admin/projects/?filter=plugins%252F[
+link:https://gerrit-review.googlesource.com/admin/repos/?filter=plugins%252F[
 gerrit-review].
 
 [[admin-console]]
@@ -156,7 +196,7 @@
 information. Also providing access control information by project or
 project/account.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/admin-console[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/admin-console[
 Project] |
 link:https://gerrit.googlesource.com/plugins/admin-console/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -170,7 +210,7 @@
 archived and processed with popular BigData transformation tools such
 Apache Spark or published and visualized in dashboards.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/analytics[Project] |
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/analytics[Project] |
 link:https://gerrit.googlesource.com/plugins/analytics/+doc/master/README.md[Documentation]
 
 [[avatars-external]]
@@ -179,7 +219,7 @@
 This plugin allows to use an external url to load the avatar images
 from.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars-external[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/avatars-external[
 Project] |
 link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -191,7 +231,7 @@
 
 Plugin to display user icons from Gravatar.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars-gravatar[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/avatars-gravatar[
 Project]
 
 [[branch-network]]
@@ -199,10 +239,9 @@
 
 This plugin allows the rendering of Git repository branch network in a
 graphical HTML5 Canvas. It is mainly intended to be used as a
-"project link" in a gitweb configuration or by other Gerrit GWT UI
-plugins to be plugged elsewhere in Gerrit.
+"project link" in a gitweb configuration.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/branch-network[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/branch-network[
 Project] |
 link:https://gerrit.googlesource.com/plugins/branch-network/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -214,22 +253,23 @@
 
 This plugin allows to display a static info message on the change screen.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/changemessage[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/changemessage[
 Project] |
 link:https://gerrit.googlesource.com/plugins/changemessage/+doc/master/src/main/resources/Documentation/about.md[
 Plugin Documentation] |
 link:https://gerrit.googlesource.com/plugins/changemessage/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
-[[delete-project]]
-=== delete-project
+[[checks]]
+=== checks
 
-Provides the ability to delete a project.
+The checks plugin provides a REST API and UI extensions for integrating
+CI systems with Gerrit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/delete-project[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/checks[
 Project] |
-link:https://gerrit.googlesource.com/plugins/delete-project/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+link:https://gerrit.googlesource.com/plugins/checks/+doc/master/src/main/resources/Documentation/about.md[
+Plugin Documentation]]
 
 [[egit]]
 === egit
@@ -240,7 +280,7 @@
 the change ref into the clipboard. The change ref is needed for
 downloading a Gerrit change from within EGit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/egit[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/egit[
 Project] |
 link:https://gerrit.googlesource.com/plugins/egit/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -250,19 +290,30 @@
 
 This plugin allows users to see emoticons in comments as images.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/emoticons[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/emoticons[
 Project] |
 link:https://gerrit.googlesource.com/plugins/emoticons/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
 link:https://gerrit.googlesource.com/plugins/emoticons/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[find-owners]]
+=== find-owners
+This plugin provides (1) a change review action button `[FIND OWNERS]`
+that shows owners of changed files to be included as code reviewers, and
+(2) Prolog predicates to make sure that a CL is submittable
+only with owner Code-Review +1 votes.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/find-owners[Project] |
+link:https://gerrit.googlesource.com/plugins/find-owners/+doc/master/src/main/resources/Documentation/about.md[Documentation] |
+link:https://gerrit.googlesource.com/plugins/find-owners/+doc/master/src/main/resources/Documentation/config.md[Configuration]
+
 [[gitblit]]
 === gitblit
 
 GitBlit code-viewer plugin with SSO and Security Access Control.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/gitblit[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/gitblit[
 Project]
 
 [[github]]
@@ -270,23 +321,35 @@
 
 Plugin to integrate with GitHub: replication, pull-request to Change-Sets
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/github[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/github[
 Project]
 
-[[gitiles]]
-=== gitiles
+[[healthcheck]]
+=== healthcheck
 
-Plugin running Gitiles alongside a Gerrit server.
+Plugin for monitoring and alerting when Gerrit does not behave properrly.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/gitiles[
-Project]
+When Gerrit Server needs to be available 24x7, it is important to know
+*beforehand* if something isn't working correctly: this plugin exposes a
+REST-API that provides the real-time status of the Gerrit internals and can
+be integrated with real-time monitoring systems and paging platforms.
+
+Healthcheck metrics (latency and subsystem healthiness) are published as
+Gerrit internal metrics and can be published to dashboards.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/healthcheck[
+Project] |
+link:https://gerrit.googlesource.com/plugins/healthcheck/+doc/master/src/main/resources/Documentation/about.md[
+Documentation] |
+link:https://gerrit.googlesource.com/plugins/healthcheck/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
 
 [[imagare]]
 === imagare
 
 The imagare plugin allows Gerrit users to upload and share images.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/imagare[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/imagare[
 Project] |
 link:https://gerrit.googlesource.com/plugins/imagare/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -314,7 +377,7 @@
 server, and in combination with the link:#delete-project[delete-project]
 plugin it can be used to rename a project.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/importer[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/importer[
 Project] |
 link:https://gerrit.googlesource.com/plugins/importer/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -330,7 +393,7 @@
 the `its-base` project. `its-base` is not a plugin, but just a
 framework for the ITS plugins which is packaged within each ITS plugin.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-base[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-base[
 its-base Project] |
 link:https://gerrit.googlesource.com/plugins/its-base/+doc/master/src/main/resources/Documentation/about.md[
 its-base Documentation] |
@@ -342,7 +405,7 @@
 
 Plugin to integrate with Bugzilla.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-bugzilla[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-bugzilla[
 Project] |
 link:https://gerrit.googlesource.com/plugins/its-bugzilla/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -352,7 +415,7 @@
 
 Plugin to integrate with Jira.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-jira[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-jira[
 Project] |
 link:https://gerrit.googlesource.com/plugins/its-jira/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
@@ -362,7 +425,7 @@
 
 Plugin to integrate with Phabricator.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-phabricator[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-phabricator[
 Project] |
 link:https://gerrit.googlesource.com/plugins/its-phabricator/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
@@ -372,7 +435,7 @@
 
 Plugin to integrate with IBM Rational Team Concert (RTC).
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-rtc[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-rtc[
 Project] |
 link:https://gerrit.googlesource.com/plugins/its-rtc/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
@@ -382,7 +445,7 @@
 
 Plugin to integrate with Storyboard task tracking system.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-storyboard[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-storyboard[
 Project] |
 link:https://gerrit.googlesource.com/plugins/its-storyboard/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -395,7 +458,7 @@
 This plugin integrates JavaMelody in Gerrit in order to retrieve live
 instrumentation data from Gerrit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/javamelody[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/javamelody[
 Project] |
 link:https://gerrit.googlesource.com/plugins/javamelody/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -410,7 +473,7 @@
 (similar to how labels/approvals were rendered on the old change
 screen).
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/labelui[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/labelui[
 Project] |
 link:https://gerrit.googlesource.com/plugins/labelui/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -421,7 +484,7 @@
 The menuextender plugin allows Gerrit administrators to configure
 additional menu entries from the WebUI.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/menuextender[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/menuextender[
 Project] |
 link:https://gerrit.googlesource.com/plugins/menuextender/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -433,7 +496,7 @@
 
 This plugin reports Gerrit metrics to Elasticsearch.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-elasticsearch[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-elasticsearch[
 Project].
 
 [[metrics-reporter-graphite]]
@@ -441,7 +504,7 @@
 
 This plugin reports Gerrit metrics to Graphite.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-graphite[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-graphite[
 Project].
 
 [[metrics-reporter-jmx]]
@@ -449,7 +512,7 @@
 
 This plugin reports Gerrit metrics to JMX.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-jmx[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-jmx[
 Project].
 
 [[metrics-reporter-prometheus]]
@@ -457,7 +520,7 @@
 
 This plugin exposes Gerrit metrics for consumption by Prometheus.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-prometheus[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-prometheus[
 Project].
 
 [[motd]]
@@ -469,7 +532,7 @@
 the user (usually prefixed by “remote: ”), but will be silently
 discarded otherwise.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/motd[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/motd[
 Project] |
 link:https://gerrit.googlesource.com/plugins/motd/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -479,20 +542,28 @@
 [[oauth-authentication-provider]]
 === OAuth authentication provider
 This plugin enables Gerrit to use OAuth2 protocol for authentication.
-Two different OAuth providers are supported:
+Several OAuth2 providers are supported:
 
+* AirVantage
+* Bitbucket
+* CAS
+* CoreOS Dex
+* Facebook
 * GitHub
+* GitLab
 * Google
+* Keycloak
+* Office365
 
-https://github.com/davido/gerrit-oauth-provider[Project] |
-https://github.com/davido/gerrit-oauth-provider/wiki/Getting-Started[Configuration]
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/oauth[Project] |
+link:https://gerrit.googlesource.com/plugins/oauth/+doc/master/src/main/resources/Documentation/config.md[Configuration]
 
 [[owners]]
 === owners
 This plugin provides a Prolog predicate `add_owner_approval/3` that
 appends `label('Owner-Approval', need(_))` to a provided list.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/owners[Project] |
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/owners[Project] |
 link:https://gerrit.googlesource.com/plugins/owners/+doc/master/README.md[Documentation]
 
 [[project-download-commands]]
@@ -504,7 +575,7 @@
 are inherited by the child projects. Child projects can overwrite the
 inherited download command or remove it by assigning no value to it.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/project-download-commands[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/project-download-commands[
 Project] |
 link:https://gerrit.googlesource.com/plugins/project-download-commands/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -520,20 +591,30 @@
 that a project or group can consume. To do this a Gerrit administrator
 can use this plugin to define quotas on project namespaces.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/quota[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/quota[
 Project] |
 link:https://gerrit.googlesource.com/plugins/quota/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 link:https://gerrit.googlesource.com/plugins/quota/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[rabbitmq]]
+=== rabbitmq
+
+A plugin that publishes Gerrit events to a
+link:https://www.rabbitmq.com/[RabbitMQ] exchange.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/rabbitmq[Project]
+link:https://gerrit.googlesource.com/plugins/rabbitmq/+/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[readonly]]
 === readonly
 
 A plugin that makes the Gerrit server read-only by rejecting git pushes,
 blocking HTTP PUT/POST/DELETE requests, and disabling SSH commands.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/readonly[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/readonly[
 Project] |
 link:https://gerrit.googlesource.com/plugins/readonly/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -548,7 +629,7 @@
 Backups of deleted or non-fast-forward updated refs are created under the
 `refs/backups/` namespace.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/ref-protection[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/ref-protection[
 Project] |
 link:https://gerrit.googlesource.com/plugins/ref-protection/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -558,19 +639,29 @@
 
 A plugin that provides project reparenting as a self-service for project owners.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reparent[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/reparent[
 Project] |
 link:https://gerrit.googlesource.com/plugins/reparent/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
 link:https://gerrit.googlesource.com/plugins/reparent/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[review-strategy]]
+=== review-strategy
+
+This plugin allows users to configure different review strategies.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/review-strategy[
+Project] |
+link:https://gerrit.googlesource.com/plugins/review-strategy/+doc/master/src/main/resources/Documentation/about.md[
+Documentation]
+
 [[reviewers]]
 === reviewers
 
 A plugin that allows adding default reviewers to a change.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewers[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers[
 Project] |
 link:https://gerrit.googlesource.com/plugins/reviewers/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -586,7 +677,7 @@
 users should be familiar with the code and can mostly review the
 change.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewers-by-blame[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers-by-blame[
 Project] |
 link:https://gerrit.googlesource.com/plugins/reviewers-by-blame/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -598,17 +689,24 @@
 
 This plugin provides a Groovy runtime environment for Gerrit plugins in Groovy.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/groovy-provider[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/scripting/groovy-provider[
 Project] |
 link:https://gerrit.googlesource.com/plugins/scripting/groovy-provider/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 
+[[saml-authentication-provider]]
+=== SAML2 authentication provider
+
+This plugin enables Gerrit to use SAML2 protocol for authentication.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/saml[Project]
+
 [[scala-provider]]
 === scripting/scala-provider
 
 This plugin provides a Scala runtime environment for Gerrit plugins in Scala.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/scala-provider[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/scripting/scala-provider[
 Project] |
 link:https://gerrit.googlesource.com/plugins/scripting/scala-provider/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -622,7 +720,7 @@
 Groovy and Scala scripts require the installation of the corresponding
 scripting/*-provider plugin in order to be loaded into Gerrit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripts[Project]
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/scripts[Project]
 link:https://gerrit.googlesource.com/plugins/scripts/+doc/master/README.md[Documentation]
 
 [[server-config]]
@@ -634,7 +732,7 @@
 where Gerrit's config files are stored is difficult or impossible to
 get.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/server-config[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/server-config[
 Project]
 
 [[serviceuser]]
@@ -647,7 +745,7 @@
 Plugin in Jenkins. A service user is not able to login into the Gerrit
 WebUI and it cannot push commits or tags.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/serviceuser[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/serviceuser[
 Project] |
 link:https://gerrit.googlesource.com/plugins/serviceuser/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -663,7 +761,7 @@
 and a maximum allowed path length. Pushes of commits that violate these
 settings are rejected by Gerrit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/uploadvalidator[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/uploadvalidator[
 Project] |
 link:https://gerrit.googlesource.com/plugins/uploadvalidator/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -677,7 +775,7 @@
 view them on the Gerrit UI.  The metadata can be stored in the Gerrit database
 or in a completely separate datastore.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/verify-status[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/verify-status[
 Project] |
 link:https://gerrit.googlesource.com/plugins/verify-status/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -692,7 +790,7 @@
 among multiple Gerrit servers, making it useful for multi-master
 Gerrit installations.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/websession-flatfile[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/websession-flatfile[
 Project] |
 link:https://gerrit.googlesource.com/plugins/websession-flatfile/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -704,7 +802,7 @@
 
 This plugin serves project documentation as HTML pages.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/x-docs[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/x-docs[
 Project] |
 link:https://gerrit.googlesource.com/plugins/x-docs/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 3b2b65f..71af331 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -96,7 +96,41 @@
 
 These are the keys:
 
-- Description
+[[description]]description::
++
+A description for the project.
+
+[[state]]state::
++
+This setting defines the state of the project. A project can have the
+following states:
+
+- `Active`:
++
+The project is active and users can see and modify the project according
+to their access rights on the project.
+
+- `Read Only`:
++
+The project is read only and all modifying operations on it are
+disabled. E.g. this means that pushing to this project fails for all
+users even if they have push permissions assigned on it.
++
+Setting a project to this state is an easy way to temporary close a
+project, as you can keep all write access rights in place and they will
+become active again as soon as the project state is set back to
+`Active`.
++
+This state also makes sense if a project was moved to another location.
+In this case all new development should happen in the new project and
+you want to prevent that somebody accidentally works on the old
+project, while keeping the old project around for old references.
+
+- `Hidden`:
++
+The project is hidden and only visible to project owners. Other users
+are not able to see the project even if they have read permissions
+granted on the project.
 
 
 [[receive-section]]
@@ -125,9 +159,29 @@
 
 [[receive.requireChangeId]]receive.requireChangeId::
 +
-Controls whether or not the Change-Id must be included in the commit message
-in the last paragraph. Default is `INHERIT`, which means that this property
-is inherited from the parent project.
+The `Require Change-Id in commit message` option defines whether a
+link:user-changeid.html[Change-Id] in the commit message is required
+for pushing a commit for review. If this option is set, trying to push
+a commit for review that doesn't contain a Change-Id in the commit
+message fails with link:error-missing-changeid.html[missing Change-Id
+in commit message footer].
+
+It is recommended to set this option and use a
+link:user-changeid.html#create[commit-msg hook] (or other client side
+tooling like EGit) to automatically generate Change-Id's for new
+commits. This way the Change-Id is automatically in place when changes
+are reworked or rebased and uploading new patch sets gets easy.
+
+If this option is not set, commits can be uploaded without a Change-Id,
+but then users have to remember to copy the assigned Change-Id from the
+change screen and insert it manually into the commit message when they
+want to upload a second patch set.
+
+Default is `INHERIT`, which means that this property is inherited from
+the parent project. The global default for new hosts is `true`
+
+This option is deprecated and future releases will behave as if this
+is always `true`.
 
 [[receive.maxObjectSizeLimit]]receive.maxObjectSizeLimit::
 +
@@ -136,12 +190,18 @@
 operation will fail. If set to zero then there is no limit.
 +
 Project owners can use this setting to prevent developers from pushing
-objects which are too large to Gerrit. This setting can also be set it
-`gerrit.config` globally link:config-gerrit.html#receive.maxObjectSizeLimit[
-receive.maxObjectSizeLimit].
+objects which are too large to Gerrit. This setting can also be set in
+`gerrit.config` globally (link:config-gerrit.html#receive.maxObjectSizeLimit[
+receive.maxObjectSizeLimit]).
 +
-The project specific setting in `project.config` is only honored when it
-further reduces the global limit.
+The project specific setting in `project.config` may not set a value higher
+than the global limit (if configured). In other words, it is only honored when
+it further reduces the global limit.
++
+When link:config-gerrit.html#receive.inheritProjectMaxObjectSizeLimit[
+`receive.inheritProjectmaxObjectSizeLimit`] is enabled in the global config,
+the value is inherited from the parent project. Otherwise, it is not inherited
+and must be explicitly set per project.
 +
 Default is zero.
 +
@@ -176,9 +236,10 @@
 +
 Controls whether server-side signed push validation is required on the
 project. Only has an effect if signed push validation is enabled on the
-server, and link:#receive.enableSignedPush is set on the project. See
-the link:config-gerrit.html#receive.enableSignedPush[global
-configuration] for details.
+server, and link:#receive.enableSignedPush[`receive.enableSignedPush`] is
+set on the project. See the
+link:config-gerrit.html#receive.enableSignedPush[global configuration]
+for details.
 +
 Default is `INHERIT`, which means that this property is inherited from
 the parent project.
@@ -199,6 +260,25 @@
 Default is `INHERIT`, which means that this property is inherited from
 the parent project.
 
+[[receive.createNewChangeForAllNotInTarget]]receive.createNewChangeForAllNotInTarget::
++
+The `create-new-change-for-all-not-in-target` option provides a
+convenience for selecting link:user-upload.html#base[the merge base]
+by setting it automatically to the target branch's tip so you can
+create new changes for all commits not in the target branch.
+
+This option is disabled if the tip of the push is a merge commit.
+
+This option also only works if there are no merge commits in the
+commit chain, in such cases it fails warning the user that such
+pushes can only be performed by manually specifying
+link:user-upload.html#base[bases]
+
+This option is useful if you want to push a change to your personal
+branch first and for review to another branch for example. Or in cases
+where a commit is already merged into a branch and you want to create
+a new open change for that commit on another branch.
+
 [[change-section]]
 === Change section
 
@@ -217,6 +297,19 @@
 Default is `INHERIT`, which means that this property is inherited from
 the parent project.
 
+[[change.workInProgressByDefault]]change.workInProgressByDefault::
++
+Controls whether all new changes in the project are set as WIP by default.
++
+Note that a new change will be ready if the `workInProgress` field in
+link:rest-api-changes.html#change-input[ChangeInput] is set to `false` explicitly
+when calling the link:rest-api-changes.html#create-change[CreateChange] REST API
+or the `ready` link:user-upload.html#wip[PushOption] is used during
+the Git push.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project.
+
 [[submit-section]]
 === Submit section
 
@@ -226,9 +319,9 @@
 - 'mergeContent': Defines whether to automatically merge changes.  Valid values
 are 'true', 'false', or 'INHERIT'.  Default is 'INHERIT'.
 
-- 'action': defines the link:project-configuration.html#submit_type[submit type].  Valid
+- 'action': defines the link:#submit-type[submit type].  Valid
 values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
-'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
+'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
 
 - 'matchAuthorToCommitterDate': Defines whether to the author date will be changed to match the
 submitter date upon submit, so that git log shows when the change was submitted instead of when the
@@ -238,7 +331,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
 
@@ -379,6 +473,100 @@
 You can read more about the +rules.pl+ file and the prolog rules on
 link:prolog-cookbook.html[the Prolog cookbook page].
 
+[[submit-type]]
+=== Submit Type
+
+The method Gerrit uses to submit a change to a project can be
+modified by any project owner through the project console, `Projects` >
+`List` > my/project. In general, a submitted change is only merged if all
+its dependencies are also submitted, with exceptions documented below.
+The following submit types are supported:
+
+[[submit_type_inherit]]
+* Inherit
++
+This is the default for new projects, unless overridden by a global
+link:config-gerrit.html#repository.name.defaultSubmitType[`defaultSubmitType` option].
++
+Inherit the submit type from the parent project. In `All-Projects`, this
+is equivalent to link:#merge_if_necessary[Merge If Necessary].
+
+[[fast_forward_only]]
+* Fast Forward Only
++
+With this method Gerrit does not create merge commits on submitting a
+change. Merge commits may still be submitted, but they must be created
+on the client prior to uploading to Gerrit for review.
++
+To submit a change, the change must be a strict superset of the
+destination branch.  That is, the change must already contain the
+tip of the destination branch at submit time.
+
+[[merge_if_necessary]]
+* Merge If Necessary
++
+If the change being submitted is a strict superset of the destination
+branch, then the branch is fast-forwarded to the change.  If not,
+then a merge commit is automatically created.  This is identical
+to the classical `git merge` behavior, or `git merge --ff`.
+
+[[always_merge]]
+* Always Merge
++
+Always produce a merge commit, even if the change is a strict
+superset of the destination branch.  This is identical to the
+behavior of `git merge --no-ff`, and may be useful if the
+project needs to follow submits with `git log --first-parent`.
+
+[[cherry_pick]]
+* Cherry Pick
++
+Always cherry pick the patch set, ignoring the parent lineage
+and instead creating a brand new commit on top of the current
+branch head.
++
+When cherry picking a change, Gerrit automatically appends onto the
+end of the commit message a short summary of the change's approvals,
+and a URL link back to the change on the web.  The committer header
+is also set to the submitter, while the author header retains the
+original patch set author.
++
+Note that Gerrit ignores dependencies between changes when using this
+submit type unless
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+is enabled and depending changes share the same topic. So generally
+submitters must remember to submit changes in the right order when using this
+submit type. If all you want is extra information in the commit message,
+consider using the Rebase Always submit strategy.
+
+[[rebase_if_necessary]]
+* Rebase If Necessary
++
+If the change being submitted is a strict superset of the destination
+branch, then the branch is fast-forwarded to the change.  If not,
+then the change is automatically rebased and then the branch is
+fast-forwarded to the change.
++
+When Gerrit tries to do a merge, by default the merge will only
+succeed if there is no path conflict.  A path conflict occurs when
+the same file has also been changed on the other side of the merge.
+
+[[rebase_always]]
+* Rebase Always
++
+Basically, the same as Rebase If Necessary, but it creates a new patchset even
+if fast forward is possible AND like Cherry Pick it ensures footers such as
+Change-Id, Reviewed-On, and others are present in resulting commit that is
+merged.
++
+Thus, Rebase Always can be considered similar to Cherry Pick, but with
+the important distinction that Rebase Always does not ignore dependencies.
+
+[[content_merge]]
+=== Allow content merges
+If `Allow content merges` is enabled, Gerrit will try
+to do a content merge when a path conflict occurs.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-robot-comments.txt b/Documentation/config-robot-comments.txt
index cf5de10..f5185a4 100644
--- a/Documentation/config-robot-comments.txt
+++ b/Documentation/config-robot-comments.txt
@@ -36,8 +36,6 @@
 
 == Limitations
 
-* Robot comments are only supported with NoteDb, but not with ReviewDb.
-* Robot comments are not displayed in the web UI yet.
 * There is no support for draft robot comments, but robot comments are
   always published and visible to everyone who can see the change.
 
diff --git a/Documentation/config-themes.txt b/Documentation/config-themes.txt
index dcfd711..a83c747 100644
--- a/Documentation/config-themes.txt
+++ b/Documentation/config-themes.txt
@@ -4,34 +4,28 @@
 the browser, allowing organizations to alter the look and
 feel of the application to fit with their general scheme.
 
-Configuration can either be sitewide or per-project. Projects without a
-specified theme inherit from their parents, or from the sitewide theme
-for `All-Projects`.
+== HTML Header/Footer and CSS
 
-Sitewide themes are stored in `'$site_path'/etc`, and per-project
-themes are stored in `'$site_path'/themes/{project-name}`. Files are
-only served from a single theme directory; if you want to modify or
-extend an inherited theme, you must copy it into the appropriate
-per-project directory.
-
-== HTML Header/Footer
+The HTML header, footer and CSS may be customized for login
+screens (LDAP, OAuth, OpenId) and the internally managed
+Gitweb servlet.
 
 At startup Gerrit reads the following files (if they exist) and
 uses them to customize the HTML page it sends to clients:
 
-* `<theme-dir>/GerritSiteHeader.html`
+* `etc/GerritSiteHeader.html`
 +
 HTML is inserted below the menu bar, but above any page content.
 This is a good location for an organizational logo, or links to
 other systems like bug tracking.
 
-* `<theme-dir>/GerritSiteFooter.html`
+* `etc/GerritSiteFooter.html`
 +
 HTML is inserted at the bottom of the page, below all other content,
 but just above the footer rule and the "Powered by Gerrit Code
 Review (v....)" message shown at the extreme bottom.
 
-* `<theme-dir>/GerritSite.css`
+* `etc/GerritSite.css`
 +
 The CSS rules are inlined into the top of the HTML page, inside
 of a `<style>` tag.  These rules can be used to support styling
@@ -129,9 +123,7 @@
 
 The `window.onload` callback is necessary to ensure that the
 `Gerrit.on()` function has actually been defined by the
-page.  Because GWT loads the module asynchronously any `<script>`
-block in the header or footer will execute before Gerrit has defined
-the function and is ready to register the hook callback.
+page.
 
 GERRIT
 ------
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index 24932a8..cb953c1 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -122,6 +122,15 @@
 perform validation when an account is activated or deactivated via the Gerrit
 REST API or the Java extension API.
 
+[[review-comment-validation]]
+== Review comment validation
+
+
+The `CommentValidator` interface allows plugins to validate all review comments,
+i.e. inline comments, file comments and the review message. This works for the
+REST API, for `git push` when `--publish-comments` is used and for comments sent
+via email.
+
 
 GERRIT
 ------
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
deleted file mode 100644
index d35772e..0000000
--- a/Documentation/database-setup.txt
+++ /dev/null
@@ -1,279 +0,0 @@
-[[createdb]]
-== Database Setup
-
-During the init phase of Gerrit you will need to specify which database to use.
-
-[[createdb_h2]]
-=== H2
-
-If you choose H2, Gerrit will automatically set up the embedded H2 database as
-backend so no set up or configuration is necessary.
-
-Using the embedded H2 database is the easiest way to get a Gerrit
-site up and running, making it ideal for proof of concepts or small team
-servers.  On the flip side, H2 is not the recommended option for large
-corporate installations. This is because there is no easy way to interact
-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].
-
-[[createdb_derby]]
-=== Apache Derby
-
-If Derby is selected, Gerrit will automatically set up the embedded Derby
-database as backend so no set up or configuration is necessary.
-
-Currently only support for embedded mode is added. There are two other
-deployment options for Apache Derby that can be added later:
-
-* link:http://db.apache.org/derby/papers/DerbyTut/ns_intro.html#Network+Server+Options[
-Derby Network Server (standalone mode)]
-
-* link:http://db.apache.org/derby/papers/DerbyTut/ns_intro.html#Embedded+Server[
-Embedded Server (hybrid mode)]
-
-[[createdb_postgres]]
-=== PostgreSQL
-
-This option is more complicated than the H2 option but is recommended
-for larger installations. It's the database backend with the largest userbase
-in the Gerrit community.
-
-Create a user for the web application within PostgreSQL, assign it a
-password, create a database to store the metadata, and grant the user
-full rights on the newly created database:
-
-----
-  $ createuser --username=postgres -RDIElPS gerrit
-  $ createdb --username=postgres -E UTF-8 -O gerrit reviewdb
-----
-
-Visit PostgreSQL's link:http://www.postgresql.org/docs/9.1/interactive/index.html[documentation] for further information regarding
-using PostgreSQL.
-
-[[createdb_mysql]]
-=== MySQL
-
-Requirements: MySQL version 5.1 or later.
-
-This option is also more complicated than the H2 option. Just as with
-PostgreSQL it's also recommended for larger installations.
-
-Create a user for the web application within the database, assign it a
-password, create a database, and give the newly created user full
-rights on it:
-
-----
-  mysql
-
-  CREATE USER 'gerrit'@'localhost' IDENTIFIED BY 'secret';
-  CREATE DATABASE reviewdb DEFAULT CHARACTER SET 'utf8';
-  GRANT ALL ON reviewdb.* TO 'gerrit'@'localhost';
-  FLUSH PRIVILEGES;
-----
-
-Visit MySQL's link:http://dev.mysql.com/doc/[documentation] for further
-information regarding using MySQL.
-
-[[createdb_mariadb]]
-=== MariaDB
-
-Requirements: MariaDB version 5.5 or later.
-
-Refer to MySQL section above how to create MariaDB database.
-
-Visit MariaDB's link:https://mariadb.com/kb/en/mariadb/[documentation] for further
-information regarding using MariaDB.
-
-[[createdb_oracle]]
-=== Oracle
-
-PostgreSQL or H2 is the recommended database for Gerrit Code Review.
-Oracle is supported for environments where running on an existing Oracle
-installation simplifies administrative overheads, such as database backups.
-
-Create a user for the web application within sqlplus, assign it a
-password, and grant the user full rights on the newly created database:
-
-----
-  SQL> create user gerrit identified by secret_password default tablespace users;
-  SQL> grant connect, resources to gerrit;
-----
-
-JDBC driver ojdbc6.jar must be obtained from your Oracle distribution. Gerrit
-initialization process tries to copy it from a known location:
-
-----
-/u01/app/oracle/product/11.2.0/xe/jdbc/lib/ojdbc6.jar
-----
-
-If this file can not be located at this place, then the alternative location
-can be provided.
-
-Instance name is the Oracle SID. Sample database section in
-$site_path/etc/gerrit.config:
-
-----
-[database]
-        type = oracle
-        instance = xe
-        hostname = localhost
-        username = gerrit
-        port = 1521
-----
-
-Sample database section in $site_path/etc/secure.config:
-
-----
-[database]
-        password = secret_password
-----
-
-[[createdb_maxdb]]
-=== SAP MaxDB
-
-SAP MaxDB is a supported database for running Gerrit Code Review. However it is
-recommended only for environments where you intend to run Gerrit on an existing
-MaxDB installation to reduce administrative overhead.
-
-In the MaxDB studio or using the SQLCLI command line interface create a user
-'gerrit' with the user class 'RESOURCE' and a password <secret password>. This
-will also create an associated schema on the database.
-
-To run Gerrit on MaxDB, you need to obtain the MaxDB JDBC driver. It can be
-found in your MaxDB installation at the following location:
-
-- on Windows 64bit at "C:\Program Files\sdb\MaxDB\runtime\jar\sapdbc.jar"
-- on Linux at "/opt/sdb/MaxDB/runtime/jar/sapdbc.jar"
-
-It needs to be stored in the 'lib' folder of the review site.
-
-In the following sample database section it is assumed that the database name is
-'reviewdb' and the database is installed on localhost:
-
-In $site_path/etc/gerrit.config:
-
-----
-[database]
-        type = maxdb
-        database = reviewdb
-        hostname = localhost
-        username = gerrit
-
-----
-
-In $site_path/etc/secure.config:
-
-----
-[database]
-        password = <secret password>
-----
-
-Visit SAP MaxDB's link:http://maxdb.sap.com/documentation/[documentation] for further
-information regarding using SAP MaxDB.
-
-[[createdb_db2]]
-=== DB2
-
-IBM DB2 is a supported database for running Gerrit Code Review. However it is
-recommended only for environments where you intend to run Gerrit on an existing
-DB2 installation to reduce administrative overhead.
-
-Create a system wide user for the Gerrit application, and grant the user
-full rights on the newly created database:
-
-----
-  db2 => create database gerrit
-  db2 => connect to gerrit
-  db2 => grant connect,accessctrl,dataaccess,dbadm,secadm on database to gerrit;
-----
-
-JDBC driver db2jcc4.jar and db2jcc_license_cu.jar must be obtained
-from your DB2 distribution. Gerrit initialization process tries to copy
-it from a known location:
-
-----
-/opt/ibm/db2/V10.5/java/db2jcc4.jar
-/opt/ibm/db2/V10.5/java/db2jcc_license_cu.jar
-----
-
-If these files cannot be located at this place, then an alternative location
-can be provided during init step execution.
-
-Sample database section in $site_path/etc/gerrit.config:
-
-----
-[database]
-        type = db2
-        database = gerrit
-        hostname = localhost
-        username = gerrit
-        port = 50001
-----
-
-Sample database section in $site_path/etc/secure.config:
-
-----
-[database]
-        password = secret_password
-----
-
-[[createdb_hana]]
-=== SAP HANA
-
-SAP HANA is a supported database for running Gerrit Code Review. However it is
-recommended only for environments where you intend to run Gerrit on an existing
-HANA installation to reduce administrative overhead.
-
-In the HANA studio or the SAP HANA Web-based Development Workbench create a user
-'GERRIT2' with the role 'RESTRICTED_USER_JDBC_ACCESS' and a password
-<secret password>. This will also create an associated schema on the database.
-As this user would be required to change the password upon first login you might
-want to to disable the password lifetime check by executing
-'ALTER USER GERRIT2 DISABLE PASSWORD LIFETIME'.
-
-To run Gerrit on HANA, you need to obtain the HANA JDBC driver. It can be found
-as described
-link:http://help.sap.com/saphelp_hanaplatform/helpdata/en/ff/15928cf5594d78b841fbbe649f04b4/frameset.htm[here].
-It needs to be stored in the 'lib' folder of the review site.
-
-In the following sample database section it is assumed that HANA is running on
-the host 'hana.host' and listening on port '4242' where a schema/user GERRIT2
-was created:
-
-In $site_path/etc/gerrit.config:
-
-----
-[database]
-        type = hana
-        hostname = hana.host
-        port = 4242
-        username = GERRIT2
-
-----
-
-In order to configure a specific database in a multi-database environment (MDC)
-the database name has to be specified additionally:
-
-In $site_path/etc/gerrit.config:
-
-----
-[database]
-        type = hana
-        hostname = hana.host
-        database = tdb1
-        port = 4242
-        username = GERRIT2
-
-----
-
-In $site_path/etc/secure.config:
-
-----
-[database]
-        password = <secret password>
-----
-
-Visit SAP HANA's link:http://help.sap.com/hana_appliance/[documentation] for
-further information regarding using SAP HANA.
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 055ebcb..0b6a218 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -6,30 +6,121 @@
 To build Gerrit from source, you need:
 
 * A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 8
+* A JDK for Java 8|9|10|11|...
 * Python 2 or 3
-* Node.js
+* Node.js (including npm)
 * link:https://www.bazel.io/versions/master/docs/install.html[Bazel]
 * Maven
 * zip, unzip
 * gcc
 
+[[java]]
+=== Java
+
+==== MacOS
+
+On MacOS, ensure that "Java for MacOS X 10.5 Update 4" (or higher) is installed
+and that `JAVA_HOME` is set to the
+link:install.html#Requirements[required Java version].
+
+Java installations can typically be found in
+"/System/Library/Frameworks/JavaVM.framework/Versions".
+
+To check the installed version of Java, open a terminal window and run:
+
+`java -version`
+
+[[java-10]]
+==== Java 10 support
+
+Java 10 (and newer) is supported through vanilla java toolchain
+link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
+To build Gerrit with Java 10 and newer, specify vanilla java toolchain and
+provide the path to JDK home:
+
+```
+  $ bazel build \
+    --define=ABSOLUTE_JAVABASE=<path-to-java-10> \
+    --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
+    --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    :release
+```
+
+To run the tests, `--javabase` option must be passed as well, because
+bazel test runs the test using the target javabase:
+
+```
+  $ bazel test \
+    --define=ABSOLUTE_JAVABASE=<path-to-java-10> \
+    --javabase=@bazel_tools//tools/jdk:absolute_javabase \
+    --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
+    --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    //...
+```
+
+To avoid passing all those options on every Bazel build invocation,
+they could be added to ~/.bazelrc resource file:
+
+```
+$ cat << EOF > ~/.bazelrc
+> build --define=ABSOLUTE_JAVABASE=<path-to-java-10>
+> build --javabase=@bazel_tools//tools/jdk:absolute_javabase
+> build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
+> build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+> build --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+> EOF
+```
+
+Now, invoking Bazel with just `bazel build :release` would include
+all those options.
+
+Note that the follow option must be added to `container.javaOptions`
+in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 10|11|...:
+
+```
+[container]
+  javaOptions = --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
+```
+
+[[java-9]]
+==== Java 9 support
+
+Java 9 is supported through alternative java toolchain
+link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
+The Java 9 support is backwards compatible. Java 8 is still the default.
+To build Gerrit with Java 9, specify JDK 9 java toolchain:
+
+```
+  $ bazel build \
+      --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java9 \
+      --java_toolchain=@bazel_tools//tools/jdk:toolchain_java9 \
+      :release
+```
+
+Note that the follow option must be added to `container.javaOptions`
+in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 9:
+
+```
+[container]
+  javaOptions = --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
+```
+
+=== Node.js and npm packages
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md#installing-node_js-and-npm-packages[Installing Node.js and npm packages].
+
 [[build]]
 == Building on the Command Line
 
 === Gerrit Development WAR File
 
-To build the Gerrit web application that includes the GWT UI and the
-PolyGerrit UI:
+To build the Gerrit web application:
 
 ----
   bazel build gerrit
 ----
 
-[NOTE]
-PolyGerrit UI may require additional tools (such as npm). Please read
-the polygerrit-ui/README.md for more info.
-
 The output executable WAR will be placed in:
 
 ----
@@ -39,8 +130,8 @@
 [[release]]
 === Gerrit Release WAR File
 
-To build the Gerrit web application that includes the GWT UI, the
-PolyGerrit UI, core plugins and documentation:
+To build the Gerrit web application that includes the PolyGerrit UI,
+core plugins and documentation:
 
 ----
   bazel build release
@@ -54,7 +145,8 @@
 
 === Headless Mode
 
-To build Gerrit in headless mode, i.e. without the GWT Web UI:
+To build Gerrit in headless mode, i.e. without the PolyGerrit UI:
+Web UI:
 
 ----
   bazel build headless
@@ -68,7 +160,7 @@
 
 === Extension and Plugin API JAR Files
 
-To build the extension, plugin and GWT API JAR files:
+To build the extension, plugin and acceptance-framework JAR files:
 
 ----
   bazel build api
@@ -78,10 +170,11 @@
 Java docs will be placed in:
 
 ----
-  bazel-genfiles/api.zip
+  bazel-bin/api.zip
 ----
 
-Install {extension,plugin,gwt}-api to the local maven repository:
+Install {extension,plugin,acceptance-framework}-api to the local
+maven repository:
 
 ----
   tools/maven/api.sh install
@@ -102,13 +195,13 @@
 The output JAR files for individual plugins will be placed in:
 
 ----
-  bazel-genfiles/plugins/<name>/<name>.jar
+  bazel-bin/plugins/<name>/<name>.jar
 ----
 
 The JAR files will also be packaged in:
 
 ----
-  bazel-genfiles/plugins/core.zip
+  bazel-bin/plugins/core.zip
 ----
 
 To build a specific plugin:
@@ -120,14 +213,12 @@
 The output JAR file will be be placed in:
 
 ----
-  bazel-genfiles/plugins/<name>/<name>.jar
+  bazel-bin/plugins/<name>/<name>.jar
 ----
 
 Note that when building an individual plugin, the `core.zip` package
 is not regenerated.
 
-
-
 [[IDEs]]
 == Using an IDE.
 
@@ -207,31 +298,6 @@
   bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
 ----
 
-To run the tests against NoteDb backend with write
-to NoteDb, but not read from it:
-
-----
-  bazel test --test_env=GERRIT_NOTEDB=WRITE //...
-----
-
-Write and read from NoteDb:
-
-----
-  bazel test --test_env=GERRIT_NOTEDB=READ_WRITE //...
-----
-
-Primary storage NoteDb:
-
-----
-  bazel test --test_env=GERRIT_NOTEDB=PRIMARY //...
-----
-
-Primary storage NoteDb and ReviewDb disabled:
-
-----
-  bazel test --test_env=GERRIT_NOTEDB=ON //...
-----
-
 To run only tests that do not use SSH:
 
 ----
@@ -266,7 +332,9 @@
 
 * annotation
 * api
+* docker
 * edit
+* elastic
 * git
 * notedb
 * pgm
@@ -277,11 +345,13 @@
 [[elasticsearch]]
 === Elasticsearch
 
-Successfully running the elasticsearch tests may require setting the local
+Successfully running the Elasticsearch tests requires Docker, and
+may require setting the local
 link:https://www.elastic.co/guide/en/elasticsearch/reference/current/vm-max-map-count.html[virtual memory].
 
-Bazel link:https://github.com/bazelbuild/bazel/issues/3476[does not currently make container failures visible],
-if any.
+If Docker is not available, the Elasticsearch tests will be skipped.
+Note that Bazel currently does not show
+link:https://github.com/bazelbuild/bazel/issues/3476[the skipped tests].
 
 == Dependencies
 
@@ -316,16 +386,16 @@
 
 == Building against unpublished Maven JARs
 
-To build against unpublished Maven JARs, like gwtorm or PrologCafe, the custom
-JARs must be installed in the local Maven repository (`mvn clean install`) and
+To build against unpublished Maven JARs, like PrologCafe, the custom JARs must
+be installed in the local Maven repository (`mvn clean install`) and
 `maven_jar()` must be updated to point to the `MAVEN_LOCAL` Maven repository for
 that artifact:
 
 [source,python]
 ----
  maven_jar(
-   name = 'gwtorm',
-   artifact = 'gwtorm:gwtorm:42',
+   name = 'prolog-runtime',
+   artifact = 'com.googlecode.prolog-cafe:prolog-runtime:42',
    repository = MAVEN_LOCAL,
  )
 ----
@@ -372,6 +442,19 @@
  )
 ----
 
+== Building against SNAPSHOT Maven JARs
+
+To build against SNAPSHOT Maven JARs, the complete SNAPSHOT version must be used:
+
+[source,python]
+----
+ maven_jar(
+   name = "pac4j-core",
+   artifact = "org.pac4j:pac4j-core:3.5.0-SNAPSHOT-20190112.120241-16",
+   sha1 = "da2b1cb68a8f87bfd40813179abd368de9f3a746",
+ )
+----
+
 [[consume-jgit-from-development-tree]]
 
 To consume the JGit dependency from the development tree, edit
@@ -391,6 +474,129 @@
 details. Users should watch the cache sizes and clean them manually if
 necessary.
 
+[[npm-binary]]
+== NPM Binaries
+
+Parts of the PolyGerrit build require running NPM-based JavaScript programs as
+"binaries". We don't attempt to resolve and download NPM dependencies at build
+time, but instead use pre-built bundles of the NPM binary along with all its
+dependencies. Some packages on
+link:https://docs.npmjs.com/misc/registry[registry.npmjs.org] come with their
+dependencies bundled, but this is the exception rather than the rule. More
+commonly, to add a new binary to this list, you will need to bundle the binary
+yourself.
+
+[NOTE]
+We can only use binaries that meet certain licensing requirements, and that do
+not include any native code.
+
+Start by checking that the license and file types of the bundle are acceptable:
+[source,bash]
+----
+  gerrit_repo=/path/to/gerrit
+  package=some-npm-package
+  version=1.2.3
+
+  npm install -g license-checker && \
+  rm -rf /tmp/$package-$version && mkdir -p /tmp/$package-$version && \
+  cd /tmp/$package-$version && \
+  npm install $package@$version && \
+  license-checker | grep licenses: | sort -u
+----
+
+This will output a list of the different licenses used by the package and all
+its transitive dependencies. We can only legally distribute a bundle via our
+storage bucket if the licenses allow us to do so. As long as all of the listed
+license are allowed by
+link:https://opensource.google.com/docs/thirdparty/licenses/[Google's
+standards]. Any `by_exception_only`, commercial, prohibited, or unlisted
+licenses are not allowed; otherwise, it is ok to distribute the source. If in
+doubt, contact a maintainer who is a Googler.
+
+Next, check the file types:
+[source,bash]
+----
+  cd /tmp/$package-$version
+  find . -type f | xargs file | grep -v 'ASCII\|UTF-8\|empty$'
+----
+
+If you see anything that looks like a native library or binary, then we can't
+use the bundle.
+
+If everything looks good, create the bundle, and note the SHA-1:
+[source,bash]
+----
+  $gerrit_repo/tools/js/npm_pack.py $package $version && \
+  sha1sum $package-$version.tgz
+----
+
+This creates a file named `$package-$version.tgz` in your working directory.
+
+Any project maintainer can upload this file to the
+link:https://console.cloud.google.com/storage/browser/gerrit-maven/npm-packages[storage
+bucket].
+
+Finally, add the new binary to the build process:
+----
+  # WORKSPACE
+  npm_binary(
+      name = "some-npm-package",
+      repository = GERRIT,
+  )
+
+  # lib/js/npm.bzl
+  NPM_VERSIONS = {
+    ...
+    "some-npm-package": "1.2.3",
+  }
+
+  NPM_SHA1S = {
+    ...
+    "some-npm-package": "<sha1>",
+  }
+----
+
+To use the binary from the Bazel build, you need to use the `run_npm_binary.py`
+wrapper script. For an example, see the use of `crisper` in `tools/bzl/js.bzl`.
+
+
+
+[[RBE]]
+== Google Remote Build Support
+
+The Bazel build can be used with Google's Remote Build Execution.
+
+
+This needs the following setup steps:
+
+```
+gcloud auth application-default login
+gcloud services enable remotebuildexecution.googleapis.com  --project=${PROJECT}
+```
+
+Create a worker pool. The instances should have at least 4 CPUs each
+for adequate performance.
+
+```
+gcloud alpha remote-build-execution worker-pools create default \
+    --project=${PROJECT} \
+    --instance=default_instance \
+    --worker-count=50 \
+    --machine-type=n1-highcpu-4 \
+    --disk-size=200
+```
+
+To use RBE, execute
+
+```
+bazel test --config=remote \
+    --remote_instance_name=projects/${PROJECT}/instances/default_instance \
+    javatests/...
+```
+
+
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt
index 072c22c..47ace5b 100644
--- a/Documentation/dev-build-plugins.txt
+++ b/Documentation/dev-build-plugins.txt
@@ -75,6 +75,12 @@
 Some plugins describe their build process in `src/main/resources/Documentation/build.md`
 file. It may worth checking.
 
+=== Error Prone checks
+
+Error Prone checks are enabled by default for core Gerrit and all core plugins. To
+enable the checks for custom plugins, add it in the `error_prone_packages` group
+in `tools/BUILD`.
+
 === Plugins with external dependencies ===
 
 If the plugin has external dependencies, then they must be included from Gerrit's
diff --git a/Documentation/dev-cla.txt b/Documentation/dev-cla.txt
new file mode 100644
index 0000000..267351f
--- /dev/null
+++ b/Documentation/dev-cla.txt
@@ -0,0 +1,26 @@
+= Gerrit Code Review - Contributor License Agreement
+
+In order to link::dev-community.html#how-to-contribute[contribute] to
+Gerrit a Contributor License Agreement must be completed before
+contributions are accepted. To view and accept the agreements do the
+following:
+
+. Click 'Sign In' at the top right corner of
+  https://gerrit-review.googlesource.com/
+. Sign In with your Google account
+. After signing in, go to the
+  link:https://gerrit-review.googlesource.com/#/settings/agreements[Agreements]
+  tab on the settings page
+. Click on 'New Contributor Agreement' and follow the instructions
+
+For reference, the actual agreements are linked below:
+
+* link:https://cla.developers.google.com/about/google-individual[Individual Agreement]
+* link:https://cla.developers.google.com/about/google-corporate[Corporate Agreement]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
new file mode 100644
index 0000000..0656090
--- /dev/null
+++ b/Documentation/dev-community.txt
@@ -0,0 +1,70 @@
+= Gerrit Community
+
+Gerrit is developed as a
+link:https://gerrit-review.googlesource.com/[self-hosting open source project]
+and very much welcomes contributions from anyone with a
+link:dev-cla.html[contributor's agreement] on file with the project.
+
+[[project-information]]
+== Project Information
+
+* link:https://www.gerritcodereview.com/[Project Homepage]
+* link:https://www.gerritcodereview.com/releases-readme.html[Release Versions]
+* link:https://gerrit.googlesource.com/gerrit[Source]
+* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
+* link:https://gerrit-review.googlesource.com/q/status:open+project:gerrit[Change Review]
+* link:dev-design.html[System Design]
+* Processes
+** link:dev-processes.html#project-governance[Project Governance / Engineering Steering Committee]
+** link:dev-contributing.html#contribution-processes[Contribution Processes]
+*** link:dev-contributing.html#lightweight-contribution-process[Lightweight Contribution Process]
+*** link:dev-contributing.html#design-driven-contribution-process[Design-Driven Contribution Process]
+*** link:dev-contributing.html#mentorship[Mentorship]
+** link:dev-design-docs.html#review[Design doc reviews]
+** link:dev-processes.html#dev-in-stable-branches[Development in stable branches]
+** link:dev-processes.html#backporting[Backporting to stable branches]
+** link:dev-processes.html#upgrading-libraries[Upgrading Libraries]
+** link:dev-processes.html#deprecating-features[Deprecating features]
+* Roles
+** link:dev-roles.html#supporter[Supporter]
+** link:dev-roles.html#contributor[Contributor]
+** link:dev-roles.html#maintainer[Maintainer]
+** link:dev-roles.html#mentor[Mentor]
+** link:dev-roles.html#steering-committee-member[Engineering Steering Committee Member]
+** link:dev-roles.html#community-manager[Community Manager]
+** link:dev-roles.html#release-manager[Release Manager]
+
+[[how-to-contribute]]
+== How to contribute?
+* link:dev-cla.html[Contributor License Agreement]
+* link:dev-contributing.html#contribution-processes[Contribution Processes]
+** link:dev-contributing.html#lightweight-contribution-process[Lightweight Contribution Process]
+** link:dev-contributing.html#design-driven-contribution-process[Design-Driven Contribution Process]
+** link:dev-contributing.html#mentorship[Mentorship]
+* link:dev-design-docs.html[Design Docs]
+* link:dev-readme.html[Developer Setup]
+* link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui[Polymer Frontend Developer Setup]
+* link:dev-crafting-changes.html[Crafting Changes]
+* link:dev-starter-projects.html[Starter Projects]
+
+[[plugin-development]]
+== Plugin Development
+* link:dev-plugins.html[Developing Plugins]
+* link:dev-build-plugins.html[Building Gerrit plugins]
+* link:js-api.html[JavaScript Plugin API]
+* link:config-validation.html[Validation Interfaces]
+* link:dev-stars.html[Starring Changes]
+* link:quota.html[Quota Enforcement]
+
+[[maintainer]]
+== Maintainer
+* link:dev-release.html[Making a Gerrit Release]
+* link:dev-release-subproject.html[Making a Release of a Gerrit Subproject]
+* link:https://www.gerritcodereview.com/publishing.html[Publish Gerrit Homepage]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index e065e57..0bac643 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -1,372 +1,248 @@
 = Gerrit Code Review - Contributing
 
-== Introduction
-Gerrit is developed as a
-link:https://gerrit-review.googlesource.com/[self-hosting open source project]
-and very much welcomes contributions from anyone with a contributor's
-agreement on file with the project.
-
+[[cla]]
 == Contributor License Agreement
-A Contributor License Agreement must be completed before contributions
-are accepted.  To view and accept the agreements do the following:
 
-* Click 'Sign In' at the top right corner of https://gerrit-review.googlesource.com/
-* Sign In with your Google account
-* After signing in, go to the
-link:https://gerrit-review.googlesource.com/#/settings/agreements[Agreements]
-tab on the settings page
-* Click 'New Contributor Agreement' and follow the instructions
+In order to contribute to Gerrit a link:dev-cla.html[Contributor
+License Agreement] must be completed before contributions are accepted.
 
-For reference, the actual agreements are linked below
+[[contribution-processes]]
+== Contribution Processes
 
-* link:https://cla.developers.google.com/about/android-individual[Individual Agreement]
-* link:https://source.android.com/source/cla-corporate.pdf[Corporate Agreement]
+The Gerrit project offers two contribution processes:
 
-== Code Review
+* link:#lightweight-contribution-process[Lightweight Contribution
+  Process]
+* link:#design-driven-contribution-process[Design-Driven Contribution
+  Process]
+
+The lightweight contribution process has little overhead and is best
+suited for small contributions (documentation updates, bug fixes, small
+features). Contributions are pushed as changes and
+link:dev-roles.html#maintainer[maintainers] review them adhoc.
+
+For large/complex features, it is required to follow the
+link:#design-driven-contribution-process[design-driven contribution
+process] and specify the feature in a link:dev-design-docs.html[design
+doc] before starting with the implementation.
+
+If link:dev-roles.html#contributor[contributors] choose the
+lightweight contribution process and during the review it turns out
+that the feature is too large or complex,
+link:dev-roles.html#maintainer[maintainers] can require to follow the
+design-driven contribution process instead.
+
+If you are in doubt which process is right for you, consult the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list.
+
+These contribution processes apply to everyone who contributes code to
+the Gerrit project, including link:dev-roles.html#maintainer[
+maintainers]. When reading this document, keep in mind that maintainers
+are also contributors when they contribute code.
+
+If a new feature is large or complex, it is often difficult to find a
+maintainer who can take the time that is needed for a thorough review,
+and who can help with getting the changes submitted. To avoid that this
+results in unpredictable long waiting times during code review,
+contributors can ask for link:#mentorship[mentor support]. A mentor
+helps with timely code reviews and technical guidance. Doing the
+implementation is still the responsibility of the contributor.
+
+[[comparison]]
+=== Quick Comparison
+
+[options="header"]
+|======================
+|        |Lightweight Contribution Process|Design-Driven Contribution Process
+|Overhead|low (write good commit message, address review comments)|
+high (write link:dev-design-docs.html[design doc] and get it approved)
+|Technical Guidance|by reviewer|during the design review and by
+reviewer/mentor
+|Review  |adhoc (when reviewer is available)|by a dedicated mentor (if
+a link:#mentorship[mentor] was assigned)
+|Caveats |features may get vetoed after the implementation was already
+done, maintainers may make the design-driven contribution process
+required if a change gets too complex/large|design doc must stay open
+for a minimum of 10 calendar days, a mentor may not be available
+immediately
+|Applicable to|documentation updates, bug fixes, small features|
+large/complex features
+|======================
+
+[[lightweight-contribution-process]]
+=== Lightweight Contribution Process
+
+The lightweight contribution process has little overhead and is best
+suited for small contributions (documentation updates, bug fixes, small
+features). For large/complex features the
+link:#design-driven-contribution-process[design-driven contribution
+process] is required.
+
 As Gerrit is a code review tool, naturally contributions will
 be reviewed before they will get submitted to the code base.  To
 start your contribution, please make a git commit and upload it
-for review to the main Gerrit review server.  To help speed up the
-review of your change, review these guidelines before submitting
-your change.  You can view the pending Gerrit contributions and
-their statuses
+for review to the link:https://gerrit-review.googlesource.com/[
+gerrit-review.googlesource.com] Gerrit server.  To help speed up the
+review of your change, review these link:dev-crafting-changes.html[
+guidelines] before submitting your change.  You can view the pending
+Gerrit contributions and their statuses
 link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here].
 
 Depending on the size of that list it might take a while for
 your change to get reviewed.  Naturally there are fewer
-approvers than contributors; so anything that you can do to
-ensure that your contribution will undergo fewer revisions
-will speed up the contribution process.  This includes helping
-out reviewing other people's changes to relieve the load from
-the approvers.  Even if you are not familiar with Gerrit's
-internals, it would be of great help if you can download, try
-out, and comment on new features.  If it works as advertised,
-say so, and if you have the privileges to do so, go ahead
-and give it a +1 Verified.  If you would find the feature
-useful, say so and give it a +1 code review.
+link:dev-roles.html#maintainer[maintainers], that can approve changes,
+than link:dev-roles.html#contributor[contributors]; so anything that
+you can do to ensure that your contribution will undergo fewer
+revisions will speed up the contribution process.  This includes
+helping out reviewing other people's changes to relieve the load from
+the maintainers.  Even if you are not familiar with Gerrit's internals,
+it would be of great help if you can download, try out, and comment on
+new features.  If it works as advertised, say so, and if you have the
+privileges to do so, go ahead and give it a `+1 Verified`.  If you
+would find the feature useful, say so and give it a `+1 Code Review`.
 
-And finally, the quicker you respond to the comments of your
-reviewers, the quicker your change might get merged!  Try to
-reply to every comment after submitting your new patch,
-particularly if you decided against making the suggested change.
-Reviewers don't want to seem like nags and pester you if you
-haven't replied or made a fix, so it helps them know if you
-missed it or decided against it.
+And finally, the quicker you respond to the comments of your reviewers,
+the quicker your change might get merged!  Try to reply to every
+comment after submitting your new patch, particularly if you decided
+against making the suggested change. Reviewers don't want to seem like
+nags and pester you if you haven't replied or made a fix, so it helps
+them know if you missed it or decided against it.
 
+[[design-driven-contribution-process]]
+=== Design-driven Contribution Process
 
-== Review Criteria
+The design-driven contribution process applies to large/complex
+features.
 
-Here are some hints as to what approvers may be looking for
-before approving or submitting changes to the Gerrit project.
-Let's start with the simple nit picky stuff.  You are likely
-excited that your code works; help us share your excitement
-by not distracting us with the simple stuff.  Thanks to Gerrit,
-problems are often highlighted and we find it hard to look
-beyond simple spacing issues.  Blame it on our short attention
-spans, we really do want your code.
+For large/complex features it is important to:
 
+* agree on the functionality and scope before spending too much time
+  on the implementation
+* ensure that they are in line with Gerrit's project scope and vision
+* ensure that they are well aligned with other features
+* think about possibilities how the feature could be evolved over time
 
-[[commit-message]]
-=== Commit Message
+This is why for large/complex features it is required to describe the
+feature in a link:dev-design-docs.html[design doc] and get it approved
+by the link:dev-processes.html#steering-committee[steering committee],
+before starting the implementation.
 
-It is essential to have a good commit message if you want your
-change to be reviewed.
+The design-driven contribution process has the following steps:
 
-  * Keep lines no longer than 72 chars
-  * Start with a short one line summary
-  * Followed by a blank line
-  * Followed by one or more explanatory paragraphs
-  * Use the present tense (fix instead of fixed)
-  * Use the past tense when describing the status before this commit
-  * Include a `Bug: Issue <#>` line if fixing a Gerrit issue, or a
-    `Feature: Issue <#>` line if implementing a feature request.
-  * Include a `Change-Id` line
+* A link:dev-roles.html#contributor[contributor]
+  link:dev-design-docs.html#propose[proposes] a new feature by
+  uploading a change with a link:dev-design-docs.html[design doc].
+* The design doc is link:dev-design-docs.html#review[reviewed] by
+  interested parties from the community. The design review is public
+  and everyone can comment and raise concerns.
+* Design docs should stay open for a minimum of 10 calendar days so
+  that everyone has a fair chance to join the review.
+* Within 14 calendar days the contributor should hear back from the
+  link:dev-processes.html#steering-committee[steering committee]
+  whether the proposed feature is in scope of the project and if it can
+  be accepted.
+* To be submitted, the design doc needs to be approved by the
+  link:dev-processes.html#steering-committee[steering committee].
+* After the design was approved, the implementation is done by pushing
+  changes for review, see link:#lightweight-contribution-process[
+  lightweight contribution process]. Changes that are associated with
+  a design should all share a common hashtag. The contributor is the
+  main driver of the implementation and responsible that it is done.
+  Others from the Gerrit community are usually much welcome to help
+  with the implementation.
 
-=== Setting up Vim for Git commit message
+In order to be accepted/submitted, it is not necessary that the design
+doc fully specifies all the details, but the idea of the feature and
+how it fits into Gerrit should be sufficiently clear (judged by the
+steering committee). Contributors are expected to keep the design doc
+updated and fill in gaps while they go forward with the implementation.
+We expect that implementing the feature and updating the design doc
+will be an iterative process.
 
-Git uses Vim as the default commit message editor. Put this into your
-`$HOME/.vimrc` file to configure Vim for Git commit message formatting
-and writing:
+While the design doc is still in review, contributors may already start
+with the implementation (e.g. do some prototyping to demonstrate parts
+of the proposed design), but those changes should not be submitted
+while the design wasn't approved yet.
 
-====
-  " Enable spell checking, which is not on by default for commit messages.
-  au FileType gitcommit setlocal spell
+By approving a design, the steering committee commits to:
 
-  " Reset textwidth if you've previously overridden it.
-  au FileType gitcommit setlocal textwidth=72
-====
+* Accepting the feature when it is implemented.
+* Supporting the feature by assigning a link:dev-roles.html#mentor[
+  mentor] (if requested, see link:#mentorship[mentorship]).
 
+If the implementation of a feature gets stuck and it's unclear whether
+the feature gets fully done, it should be discussed with the steering
+committee how to proceed. If the contributor cannot commit to finish
+the implementation and no other contributor can take over, changes that
+have already been submitted for the feature might get reverted so that
+there is no unused or half-finished code in the code base.
 
-[[git_commit_settings]]
-=== A sample good Gerrit commit message:
-====
-  Add sample commit message to guidelines doc
+For contributors, the design-driven contribution process has the
+following advantages:
 
-  The original patch set for the contributing guidelines doc did not
-  include a sample commit message, this new patchset does.  Hopefully this
-  makes things a bit clearer since examples can sometimes help when
-  explanations don't.
+* By writing a design doc, the feature gets more attention. During the
+  design review, feedback from various sides can be collected, which
+  likely leads to improvements of the feature.
+* Once a design was approved by the
+  link:dev-processes.html#steering-committee[steering committee], the
+  contributor can be almost certain that the feature will be accepted.
+  Hence, there is only a low risk to invest into implementing a feature
+  and see it being rejected later during the code review, as it can
+  happen with the lightweight contribution process.
+* The contributor can link:#mentorship[get a dedicated mentor assigned]
+  who provides timely reviews and serves as a contact person for
+  technical questions and discussing details of the design.
 
-  Note that the body of this commit message can be several paragraphs, and
-  that I word wrap it at 72 characters.  Also note that I keep the summary
-  line under 50 characters since it is often truncated by tools which
-  display just the git summary.
+[[mentorship]]
+== Mentorship
 
-  Bug: Issue 98765605
-  Change-Id: Ic4a7c07eeb98cdeaf44e9d231a65a51f3fceae52
-====
+For features for which a link:dev-design-docs.html[design] has been
+approved (see link:#design-driven-contribution-process[design-driven
+contribution process]), contributors can gain the support of a mentor
+if they are committed to implement the feature.
 
-The `Change-Id` line is, as usual, created by a local git hook.  To install it,
-simply copy it from the checkout and make it executable:
+A link:dev-roles.html#mentor[mentor] helps with:
 
-====
-  cp ./gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg .git/hooks/
-  chmod +x .git/hooks/commit-msg
-====
+* doing timely reviews
+* providing technical guidance during code reviews
+* discussing details of the design
+* ensuring that the quality standards are met (well documented,
+  sufficient test coverage, backwards compatible etc.)
 
-If you are working on core plugins, you will also need to install the
-same hook in the submodules:
+A feature can have more than one mentor. To be able to deliver the
+promised support, at least one of the mentors must be a
+link:dev-roles.html#maintainer[maintainer].
 
-====
-  export hook=$(pwd)/.git/hooks/commit-msg
-  git submodule foreach 'cp -p "$hook" "$(git rev-parse --git-dir)/hooks/"'
-====
+Mentors are assigned by the link:dev-processes.html#steering-committee[
+steering committee]. To gain a mentor, ask for a mentor in the
+link:dev-design-doc-template.html#implementation-plan[Implementation
+Plan] section of the design doc or ask the steering committee after the
+design has been approved.
 
+Mentors may not be available immediately. In this case, the steering
+committee should include the approved feature into the roadmap or
+prioritize it in the backlog. This way, it is transparent for the
+contributor when they can expect to be able to work on the feature with
+mentor support.
 
-To set up git's remote for easy pushing, run the following:
+Once the implementation phase starts, the contributor is expected to do
+the implementation in a timely manner.
 
-====
-  git remote add gerrit https://gerrit.googlesource.com/gerrit
-====
+For every mentorship, the end must be clearly defined. The design doc
+must specify:
 
-The HTTPS access requires proper username and password; this can be obtained
-by clicking the 'Obtain Password' link on the
-link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
-Password tab of the user settings page].
+* a maximum time frame for the mentorship, after which the mentorship
+  automatically ends, even if the feature is not done yet
+* done criteria that define when the feature is done and the mentorship
+  ends
 
-[[style]]
-=== Style
-
-This project has a policy of Eclipse's warning free code. Eclipse
-configuration is added to git and we expect the changes to be
-warnings free.
-
-We do not ask you to use Eclipse for editing, obviously.  We do ask you
-to provide Eclipse's warning free patches only. If for some reasons, you
-are not able to set up Eclipse and verify, that your patch hasn't
-introduced any new Eclipse warnings, mention this in a comment to your
-change, so that reviewers will do it for you. Yes, the way to go is to
-extend gerrit CI to take care of this, but it's not yet implemented.
-
-Gerrit generally follows the
-link:https://google.github.io/styleguide/javaguide.html[Google Java Style
-Guide].
-
-To format Java source code, Gerrit uses the
-link:https://github.com/google/google-java-format[`google-java-format`]
-tool (version 1.5), and to format Bazel BUILD and WORKSPACE files the
-link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`]
-tool (version 0.12.0).
-These tools automatically apply format according to the style guides; this
-streamlines code review by reducing the need for time-consuming, tedious,
-and contentious discussions about trivial issues like whitespace.
-
-You may download and run `google-java-format` on your own, or you may
-run `./tools/setup_gjf.sh` to download a local copy and set up a
-wrapper script. If you run your own copy, please use the same version,
-as there may be slight differences between versions.
-
-When considering the style beyond just formatting rules, it is often
-more important to match the style of the nearby code which you are
-modifying than it is to match the style guide exactly. This is
-especially true within the same file.
-
-Additionally, you will notice that most of the newline spacing
-is fairly consistent throughout the code in Gerrit, it helps to
-stick to the blank line conventions.  Here are some specific
-examples:
-
-  * Keep a blank line between all class and method declarations.
-  * Do not add blank lines at the beginning or end of class/methods.
-
-When to use `final` modifier and when not (in new code):
-
-Always:
-
-  * final fields: marking fields as final forces them to be
-  initialized in the constructor or at declaration
-  * final static fields: clearly communicates the intent
-  * to use final variables in inner anonymous classes
-
-Optional:
-
-  * final classes: use when appropriate, e.g. API restriction
-  * final methods: similar to final classes
-
-Never:
-
-  * local variables: it clutters the code, and makes the code less
-  readable. When copying old code to new location, finals should
-  be removed
-  * method parameters: similar to local variables
-
-=== Code Organization
-
-Do your best to organize classes and methods in a logical way.
-Here are some guidelines that Gerrit uses:
-
-  * Ensure a standard copyright header is included at the top
-    of any new files (copy it from another file, update the year).
-  * Always place loggers first in your class!
-  * Define any static interfaces next in your class.
-  * Define non static interfaces after static interfaces in your
-    class.
-  * Next you should define static types, static members, and
-    static methods, in decreasing order of visibility (public to private).
-  * Finally instance types, instance members, then constructors,
-    and then instance methods.
-  * Some common exceptions are private helper static methods, which
-    might appear near the instance methods which they help (but may
-    also appear at the top).
-  * Getters and setters for the same instance field should usually
-    be near each other barring a good reason not to.
-  * If you are using assisted injection, the factory for your class
-    should be before the instance members.
-  * Annotations should go before language keywords (`final`, `private`, etc) +
-    Example: `@Assisted @Nullable final type varName`
-  * Prefer to open multiple AutoCloseable resources in the same
-    try-with-resources block instead of nesting the try-with-resources
-    blocks and increasing the indentation level more than necessary.
-
-Wow that's a lot!  But don't worry, you'll get the habit and most
-of the code is organized this way already; so if you pay attention
-to the class you are editing you will likely pick up on it.
-Naturally new classes are a little harder; you may want to come
-back and consult this section when creating them.
-
-
-=== Design
-
-Here are some design level objectives that you should keep in mind
-when coding:
-
-  * ORM entity objects should match exactly one row in the database.
-  * Most client pages should perform only one RPC to load so as to
-    keep latencies down.  Exceptions would apply to RPCs which need
-    to load large data sets if splitting them out will help the
-    page load faster.  Generally page loads are expected to complete
-    in under 100ms.  This will be the case for most operations,
-    unless the data being fetched is not using Gerrit's caching
-    infrastructure.  In these slower cases, it is worth considering
-    mitigating this longer load by using a second RPC to fill in
-    this data after the page is displayed (or alternatively it might
-    be worth proposing caching this data).
-  * `@Inject` should be used on constructors, not on fields.  The
-    current exceptions are the ssh commands, these were implemented
-    earlier in Gerrit's development.  To stay consistent, new ssh
-    commands should follow this older pattern; but eventually these
-    should get converted to eliminate this exception.
-  * Don't leave repository objects (git or schema) open.  A .close()
-    after every open should be placed in a finally{} block.
-  * Don't leave UI components, which can cause new actions to occur,
-    enabled during RPCs which update the DB.  This is to prevent
-    people from submitting actions more than once when operating
-    on slow links.  If the action buttons are disabled, they cannot
-    be resubmitted and the user can see that Gerrit is still busy.
-  * GWT EventBus is the new way forward.
-  * ...and so is Guava (previously known as Google Collections).
-
-
-=== Tests
-
-  * Tests for new code will greatly help your change get approved.
-
-
-=== Change Size/Number of Files Touched
-
-And finally, I probably cannot say enough about change sizes.
-Generally, smaller is better, hopefully within reason.  Do try to
-keep things which will be confusing on their own together,
-especially if changing one without the other will break something!
-
-  * If a new feature is implemented and it is a larger one, try to
-    identify if it can be split into smaller logical features; when
-    in doubt, err on the smaller side.
-  * Separate bug fixes from feature improvements.  The bug fix may
-    be an easy candidate for approval and should not need to wait
-    for new features to be approved.  Also, combining the two makes
-    reviewing harder since then there is no clear line between the
-    fix and the feature.
-  * Separate supporting refactoring from feature changes.  If your
-    new feature requires some refactoring, it helps to make the
-    refactoring a separate change which your feature change
-    depends on.  This way, reviewers can easily review the refactor
-    change as a something that should not alter the current
-    functionality, and feel more confident they can more easily
-    spot errors this way.  Of course, it also makes it easier to
-    test and locate later on if an unfortunate error does slip in.
-    Lastly, by not having to see refactoring changes at the same
-    time, it helps reviewers understand how your feature changes
-    the current functionality.
-  * Separate logical features into separate changes.  This
-    is often the hardest part.  Here is an example:  when adding a
-    new ability, make separate changes for the UI and the ssh
-    commands if possible.
-  * Do only what the commit message describes.  In other words, things which
-    are not strictly related to the commit message shouldn't be part of
-    a change, even trivial things like externalizing a string somewhere
-    or fixing a typo.  This helps keep `git blame` more useful in the future
-    and it also makes `git revert` more useful.
-  * Use topics to link your separate changes together.
-
-[[process]]
-== Process
-
-=== Backporting to stable branches
-
-From time to time bug fix releases are made for existing stable branches.
-
-Developers concerned with stable branches are encouraged to backport or push
-patchsets to these branches, even if no new release is planned.
-
-Fixes that are known to be needed for a particular release should be pushed
-for review on that release's stable branch.  It will then be included in
-the master branch when the stable branch is merged back.
-
-=== Updating to new version of GWT
-
-When updating to a new version of GWT, there are several things that also need
-to be updated or at least checked.
-
-* Update common and plugin dependencies in `tools/gwt-constants.defs`.
-* Update to the same GWT version in the cookbook plugin and optionally in other
-plugins that have a dependency on GWT.
-* Update the GWT version in the archetype metadata in the
-`gerrit-plugin-gwt-archetype`.
-* Update the version of `gwt-maven-plugin` in the example pom.xml file in
-link:dev-plugins.html[dev-plugins].
-* Update to the same GWT version in the `gwtjsonrpc` project, and release a
-new version.
-
-=== Finding starter projects to work on
-
-We have created a
-link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject]
-category in the issue tracker and try to assign easy hack projects to it. If in
-doubt, do not hesitate to ask on the developer
-link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list].
-
-=== Upgrading Libraries
-
-Gerrit's library dependencies should only be upgraded if the new version contains
-something we need in Gerrit. This includes new features, API changes as well as bug
-or security fixes.
-An exception to this rule is that right after a new Gerrit release was branched
-off, all libraries should be upgraded to the latest version to prevent Gerrit
-from falling behind. Doing those upgrades should conclude at the latest two
-months after the branch was cut. This should happen on the master branch to ensure
-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.
+If a feature is not finished in time, it should be discussed with the
+steering committee how to proceed. If the contributor cannot commit to
+finish the implementation in time and no other contributor can take
+over, changes that have already been submitted for the feature might
+get reverted so that there is no unused or half-finished code in the
+code base.
 
 GERRIT
 ------
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
new file mode 100644
index 0000000..a22bea9
--- /dev/null
+++ b/Documentation/dev-crafting-changes.txt
@@ -0,0 +1,271 @@
+= Gerrit Code Review - Crafting Changes
+
+Here are some hints as to what approvers may be looking for
+before approving or submitting changes to the Gerrit project.
+Let's start with the simple nit picky stuff.  You are likely
+excited that your code works; help us share your excitement
+by not distracting us with the simple stuff.  Thanks to Gerrit,
+problems are often highlighted and we find it hard to look
+beyond simple spacing issues.  Blame it on our short attention
+spans, we really do want your code.
+
+[[commit-message]]
+== Commit Message
+
+It is essential to have a good commit message if you want your
+change to be reviewed.
+
+  * Keep lines no longer than 72 chars
+  * Start with a short one line summary
+  * Followed by a blank line
+  * Followed by one or more explanatory paragraphs
+  * Use the present tense (fix instead of fixed)
+  * Use the past tense when describing the status before this commit
+  * Include a `Bug: Issue <#>` line if fixing a Gerrit issue, or a
+    `Feature: Issue <#>` line if implementing a feature request.
+  * Include a `Change-Id` line
+
+[[vim-setup]]
+=== Setting up Vim for Git commit message
+
+Git uses Vim as the default commit message editor. Put this into your
+`$HOME/.vimrc` file to configure Vim for Git commit message formatting
+and writing:
+
+====
+  " Enable spell checking, which is not on by default for commit messages.
+  au FileType gitcommit setlocal spell
+
+  " Reset textwidth if you've previously overridden it.
+  au FileType gitcommit setlocal textwidth=72
+====
+
+
+[[git-commit-settings]]
+=== A sample good Gerrit commit message:
+====
+  Add sample commit message to guidelines doc
+
+  The original patch set for the contributing guidelines doc did not
+  include a sample commit message, this new patchset does.  Hopefully this
+  makes things a bit clearer since examples can sometimes help when
+  explanations don't.
+
+  Note that the body of this commit message can be several paragraphs, and
+  that I word wrap it at 72 characters.  Also note that I keep the summary
+  line under 50 characters since it is often truncated by tools which
+  display just the git summary.
+
+  Bug: Issue 98765605
+  Change-Id: Ic4a7c07eeb98cdeaf44e9d231a65a51f3fceae52
+====
+
+The `Change-Id` line is, as usual, created by a local git hook.  To install it,
+simply copy it from the checkout and make it executable:
+
+====
+  cp ./gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg .git/hooks/
+  chmod +x .git/hooks/commit-msg
+====
+
+If you are working on core plugins, you will also need to install the
+same hook in the submodules:
+
+====
+  export hook=$(pwd)/.git/hooks/commit-msg
+  git submodule foreach 'cp -p "$hook" "$(git rev-parse --git-dir)/hooks/"'
+====
+
+
+To set up git's remote for easy pushing, run the following:
+
+====
+  git remote add gerrit https://gerrit.googlesource.com/gerrit
+====
+
+The HTTPS access requires proper username and password; this can be obtained
+by clicking the 'Obtain Password' link on the
+link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
+Password tab of the user settings page].
+
+[[style]]
+== Style
+
+This project has a policy of Eclipse's warning free code. Eclipse
+configuration is added to git and we expect the changes to be
+warnings free.
+
+We do not ask you to use Eclipse for editing, obviously.  We do ask you
+to provide Eclipse's warning free patches only. If for some reasons, you
+are not able to set up Eclipse and verify, that your patch hasn't
+introduced any new Eclipse warnings, mention this in a comment to your
+change, so that reviewers will do it for you. Yes, the way to go is to
+extend gerrit CI to take care of this, but it's not yet implemented.
+
+Gerrit generally follows the
+link:https://google.github.io/styleguide/javaguide.html[Google Java Style
+Guide].
+
+To format Java source code, Gerrit uses the
+link:https://github.com/google/google-java-format[`google-java-format`]
+tool (version 1.7), and to format Bazel BUILD, WORKSPACE and .bzl files the
+link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`]
+tool (version 0.26.0).
+These tools automatically apply format according to the style guides; this
+streamlines code review by reducing the need for time-consuming, tedious,
+and contentious discussions about trivial issues like whitespace.
+
+You may download and run `google-java-format` on your own, or you may
+run `./tools/setup_gjf.sh` to download a local copy and set up a
+wrapper script. If you run your own copy, please use the same version,
+as there may be slight differences between versions.
+
+When considering the style beyond just formatting rules, it is often
+more important to match the style of the nearby code which you are
+modifying than it is to match the style guide exactly. This is
+especially true within the same file.
+
+Additionally, you will notice that most of the newline spacing
+is fairly consistent throughout the code in Gerrit, it helps to
+stick to the blank line conventions.  Here are some specific
+examples:
+
+  * Keep a blank line between all class and method declarations.
+  * Do not add blank lines at the beginning or end of class/methods.
+
+When to use `final` modifier and when not (in new code):
+
+Always:
+
+  * final fields: marking fields as final forces them to be
+  initialized in the constructor or at declaration
+  * final static fields: clearly communicates the intent
+  * to use final variables in inner anonymous classes
+
+Optional:
+
+  * final classes: use when appropriate, e.g. API restriction
+  * final methods: similar to final classes
+
+Never:
+
+  * local variables: it clutters the code, and makes the code less
+  readable. When copying old code to new location, finals should
+  be removed
+  * method parameters: similar to local variables
+
+[[code-organization]]
+== Code Organization
+
+Do your best to organize classes and methods in a logical way.
+Here are some guidelines that Gerrit uses:
+
+  * Ensure a standard copyright header is included at the top
+    of any new files (copy it from another file, update the year).
+  * Always place loggers first in your class!
+  * Define any static interfaces next in your class.
+  * Define non static interfaces after static interfaces in your
+    class.
+  * Next you should define static types, static members, and
+    static methods, in decreasing order of visibility (public to private).
+  * Finally instance types, instance members, then constructors,
+    and then instance methods.
+  * Some common exceptions are private helper static methods, which
+    might appear near the instance methods which they help (but may
+    also appear at the top).
+  * Getters and setters for the same instance field should usually
+    be near each other barring a good reason not to.
+  * If you are using assisted injection, the factory for your class
+    should be before the instance members.
+  * Annotations should go before language keywords (`final`, `private`, etc) +
+    Example: `@Assisted @Nullable final type varName`
+  * Prefer to open multiple AutoCloseable resources in the same
+    try-with-resources block instead of nesting the try-with-resources
+    blocks and increasing the indentation level more than necessary.
+
+Wow that's a lot!  But don't worry, you'll get the habit and most
+of the code is organized this way already; so if you pay attention
+to the class you are editing you will likely pick up on it.
+Naturally new classes are a little harder; you may want to come
+back and consult this section when creating them.
+
+[[design]]
+== Design
+
+Here are some design level objectives that you should keep in mind
+when coding:
+
+  * Most client pages should perform only one RPC to load so as to
+    keep latencies down.  Exceptions would apply to RPCs which need
+    to load large data sets if splitting them out will help the
+    page load faster.  Generally page loads are expected to complete
+    in under 100ms.  This will be the case for most operations,
+    unless the data being fetched is not using Gerrit's caching
+    infrastructure.  In these slower cases, it is worth considering
+    mitigating this longer load by using a second RPC to fill in
+    this data after the page is displayed (or alternatively it might
+    be worth proposing caching this data).
+  * `@Inject` should be used on constructors, not on fields.  The
+    current exceptions are the ssh commands, these were implemented
+    earlier in Gerrit's development.  To stay consistent, new ssh
+    commands should follow this older pattern; but eventually these
+    should get converted to eliminate this exception.
+  * Don't leave repository objects (git or schema) open.  Use a
+    try-with-resources statement to ensure that repository objects get
+    closed after use.
+  * Don't leave UI components, which can cause new actions to occur,
+    enabled during RPCs which update Git repositories, including NoteDb.
+    This is to prevent people from submitting actions more than once
+    when operating on slow links.  If the action buttons are disabled,
+    they cannot be resubmitted and the user can see that Gerrit is still
+    busy.
+
+[[tests]]
+== Tests
+
+  * Tests for new code will greatly help your change get approved.
+
+[[change-size]]
+== Change Size/Number of Files Touched
+
+And finally, I probably cannot say enough about change sizes.
+Generally, smaller is better, hopefully within reason.  Do try to
+keep things which will be confusing on their own together,
+especially if changing one without the other will break something!
+
+  * If a new feature is implemented and it is a larger one, try to
+    identify if it can be split into smaller logical features; when
+    in doubt, err on the smaller side.
+  * Separate bug fixes from feature improvements.  The bug fix may
+    be an easy candidate for approval and should not need to wait
+    for new features to be approved.  Also, combining the two makes
+    reviewing harder since then there is no clear line between the
+    fix and the feature.
+  * Separate supporting refactoring from feature changes.  If your
+    new feature requires some refactoring, it helps to make the
+    refactoring a separate change which your feature change
+    depends on.  This way, reviewers can easily review the refactor
+    change as a something that should not alter the current
+    functionality, and feel more confident they can more easily
+    spot errors this way.  Of course, it also makes it easier to
+    test and locate later on if an unfortunate error does slip in.
+    Lastly, by not having to see refactoring changes at the same
+    time, it helps reviewers understand how your feature changes
+    the current functionality.
+  * Separate logical features into separate changes.  This
+    is often the hardest part.  Here is an example:  when adding a
+    new ability, make separate changes for the UI and the ssh
+    commands if possible.
+  * Do only what the commit message describes.  In other words, things which
+    are not strictly related to the commit message shouldn't be part of
+    a change, even trivial things like externalizing a string somewhere
+    or fixing a typo.  This helps keep `git blame` more useful in the future
+    and it also makes `git revert` more useful.
+  * Use topics to link your separate changes together.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-design-doc-template.txt b/Documentation/dev-design-doc-template.txt
new file mode 100644
index 0000000..9480d97
--- /dev/null
+++ b/Documentation/dev-design-doc-template.txt
@@ -0,0 +1,79 @@
+= Gerrit Code Review - ${title}
+
+[[objective]]
+== Objective
+
+In a few sentences, describe the key system objectives. Define the
+goals and non-goals.
+
+[[background]]
+== Background
+
+Stuff one needs to know to understand this doc (e.g. motivating
+examples, previous versions and problems, links to related
+changes/design docs, etc.
+
+Note: this is background; do not write about your design or ideas to
+solve problems here.
+
+[[overview]]
+== Overview
+
+High-level overview; put details in the next section and background in
+the previous section. Should be understandable by engineers that are
+not working on Gerrit.
+
+[[detailed-design]]
+== Detailed Design
+
+How does the overall design work? Details about the algorithms,
+storage format, APIs, etc., should be included here.
+
+It is ok for this to lack in detail at first for initial review.
+
+[[alternatives-considered]]
+== Alternatives Considered
+
+You may need to describe what you did not do or why simpler approaches
+don't work. Mention other things to watch out for (if any).
+
+[[implemenation-plan]]
+== Implementation Plan
+
+If known, say who is driving the implementation, for when the
+implementation is planned and which priority it has for you.
+
+It is possible to contribute designs without having resources to do the
+implementation. In this case, say so here.
+
+If mentor support is desired, say so here. Also briefly describe any
+circumstances that can help with finding a suitable mentor.
+
+[[time-estimation]]
+=== Time Estimation
+
+A rough itemized estimation of how much time it takes to implement this
+feature. Break down the feature into work items and estimate each item
+separately.
+
+If a mentor is assigned, this section must define a maximum time frame
+after which the mentorship automatically ends even if the feature isn't
+fully done yet.
+
+[[done-criteria]]
+== Done Criteria
+
+Describe the conditions that must be satisfied to consider this feature
+as done.
+
+If a mentor is assigned, the mentorship ends when this state is reached.
+Please note that a mentorship can also end earlier if the maximum time
+frame for the mentorship has exceeded (see section 'Time Estimation'
+above).
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
new file mode 100644
index 0000000..621fd70
--- /dev/null
+++ b/Documentation/dev-design-docs.txt
@@ -0,0 +1,62 @@
+= Gerrit Code Review - Design Docs
+
+For the link:dev-contributing.html#design-driven-contribution-process[
+design-driven contribution process] it is required to specify features
+upfront in a design doc.
+
+[[propose]]
+== How to propose a new design?
+
+To propose a new design, add a `design-${title}.txt` file to this
+folder and push it as change for review. The design doc should follow
+the structure of the link:dev-design-doc-template.html[design doc
+template] and the change should be marked with the hashtag
+`design-doc`.
+
+Pushing a design doc for review requires to be a
+link:dev-roles.html#contributor[contributor].
+
+When contributing design docs, contributors should make clear whether
+they are committed to do the implementation. It is possible to
+contribute designs without having resources to do the implementation,
+but in this case the implementation is only done if someone volunteers
+to do it (which is not guaranteed to happen).
+
+[[review]]
+== Design doc review
+
+Everyone in the link:dev-roles.html[Gerrit community] is welcome to
+take part in the design review and comment on the design.
+
+Changes with new design docs should stay open for a minimum of 10
+calendar days so that everyone has a fair chance to see them. It is
+important that concerns regarding a feature are raised during this time
+frame since once a design is approved and submitted the implementation
+may start immediately.
+
+Within the 10 calendar days time frame, the contributor should hear back
+from the link:dev-processes.html#steering-committee[engineering steering committee]
+whether the proposed feature is in scope of the project and if it can
+be accepted.
+
+In order to be accepted/submitted, it is not necessary that the design
+doc fully specifies all the details, but the idea of the feature and
+how it fits into Gerrit should be sufficiently clear (judged by the
+engineering steering committee). Contributors are expected to keep the design doc
+updated and fill in gaps while they go forward with the implementation.
+
+[[watch-designs]]
+== How to get notified for new design docs?
+
+. Go to the
+  link:https://gerrit-review.googlesource.com/settings/#Notifications[
+  notification settings]
+. Add a project watch for the `gerrit` repository with the following
+  query: `hashtag:design-doc`
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-design.txt b/Documentation/dev-design.txt
index bdd2a68..1285404 100644
--- a/Documentation/dev-design.txt
+++ b/Documentation/dev-design.txt
@@ -65,6 +65,9 @@
 changing the implementation from Python on Google App Engine, to Java
 on a J2EE servlet container and an SQL database.
 
+Since Gerrit 3.x link:note-db.html[NoteDb] replaced the SQL database
+and all metadata is now stored in Git.
+
 * link:http://video.google.com/videoplay?docid=-8502904076440714866[Mondrian Code Review On The Web]
 * link:https://github.com/rietveld-codereview/rietveld[Rietveld - Code Review for Subversion]
 * link:http://eagain.net/gitweb/?p=gitosis.git;a=blob;f=README.rst;hb=HEAD[Gitosis README]
@@ -83,9 +86,7 @@
 
 Each Git commit created on the client desktop system is converted
 into a unique change record which can be reviewed independently.
-Change records are stored in a database: PostgreSQL, MySQL, or the
-built-in H2, where they can be queried to present customized user
-dashboards, enumerating any pending changes.
+Change records are stored in NoteDb.
 
 A summary of each newly uploaded change is automatically emailed
 to reviewers, so they receive a direct hyperlink to review the
@@ -121,9 +122,9 @@
 
 End-user web browsers make HTTP requests directly to Gerrit's
 HTTP server.  As nearly all of the user interface is implemented
-through Google Web Toolkit (GWT), the majority of these requests
-are transmitting compressed JSON payloads, with all HTML being
-generated within the browser.  Most responses are under 1 KB.
+through PolyGerrit, the majority of these requests are transmitting
+compressed JSON payloads, with all HTML being generated within the
+browser.  Most responses are under 1 KB.
 
 Gerrit's HTTP server side component is implemented as a standard
 Java servlet, and thus runs within any J2EE servlet container.
@@ -166,9 +167,7 @@
 requires that the OpenID provider selected by a user must be
 online and operating in order to authenticate that user.
 
-* link:http://www.gwtproject.org/[Google Web Toolkit (GWT)]
 * link:http://www.kernel.org/pub/software/scm/git/docs/gitrepository-layout.html[Git Repository Format]
-* link:http://www.postgresql.org/about/[About PostgreSQL]
 * link:http://openid.net/developers/specs/[OpenID Specifications]
 
 *1  Although an effort is underway to eliminate the use of the
@@ -179,17 +178,6 @@
 repositories for each project.
 
 
-== Project Information
-
-Gerrit is developed as a self-hosting open source project:
-
-* link:https://www.gerritcodereview.com/[Project Homepage]
-* link:https://www.gerritcodereview.com/download/index.html[Release Versions]
-* link:https://gerrit.googlesource.com/gerrit[Source]
-* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
-* link:https://review.source.android.com/[Change Review]
-
-
 == Internationalization and Localization
 
 As a source code review system for open source projects, where the
@@ -200,18 +188,11 @@
 and comments in English, and therefore an English user interface
 is usable by the target user base.
 
-Gerrit uses GWT's i18n support to externalize all constant strings
-and messages shown to the user, so that in the future someone who
-really needed a translated version of the UI could contribute new
-string files for their locale(s).
-
 Right-to-left (RTL) support is only barely considered within the
 Gerrit code base.  Some portions of the code have tried to take
 RTL into consideration, while others probably need to be modified
 before translating the UI to an RTL language.
 
-* link:i18n-readme.html[Gerrit's i18n Support]
-
 
 == Accessibility Considerations
 
@@ -235,20 +216,11 @@
 
 Supporting non-JavaScript enabled browsers is a non-goal for Gerrit.
 
-As Gerrit is a pure-GWT application with no server side rendering
-fallbacks, the browser must support modern JavaScript semantics in
-order to access the Gerrit web application.  Dumb clients such as
-`lynx`, `wget`, `curl`, or even many search engine spiders are not
-able to access Gerrit content.
-
-As Google Web Toolkit (GWT) is used to generate the browser
-specific versions of the client-side JavaScript code, Gerrit works
-on any JavaScript enabled browser which GWT can produce code for.
-This covers the majority of the popular browsers.
-
-The Gerrit project does not have the development resources necessary
-to support two parallel UI implementations (GWT based JavaScript
-and server-side rendering).  Consequently only one is implemented.
+As Gerrit is a pure JavaScript application on the client side, with
+no server side rendering fallbacks, the browser must support modern
+JavaScript semantics in order to access the Gerrit web application.
+Dumb clients such as `lynx`, `wget`, `curl`, or even many search engine
+spiders are not able to access Gerrit content.
 
 There are number of web browsers available with full JavaScript
 support, and nearly every operating system (including any PDA-like
@@ -317,34 +289,6 @@
 Gerrit does not integrate with any Google service, or any other
 services other than those listed above.
 
-
-== Standards / Developer APIs
-
-Gerrit uses an XSRF protected variant of JSON-RPC 1.1 to communicate
-between the browser client and the server.
-
-As the protocol is not the GWT-RPC protocol, but is instead a
-self-describing standard JSON format it is easily implemented by
-any 3rd party client application, provided the client has a JSON
-parser and HTTP client library available.
-
-As the entire command set necessary for the standard web browser
-based UI is exposed through JSON-RPC over HTTP, there are no other
-data feeds or command interfaces to the server.
-
-Commands requiring user authentication may require the user agent to
-complete a sign-in cycle through the user's OpenID provider in order
-to establish the HTTP cookie Gerrit uses to track user identity.
-Automating this sign-in process for non-web browser agents is
-outside of the scope of Gerrit, as each OpenID provider uses its own
-sign-in sequence.  Use of OpenID providers which have difficult to
-automate interfaces may make it impossible for non-browser agents
-to be used with the JSON-RPC interface.
-
-* link:http://json-rpc.org/wd/JSON-RPC-1-1-WD-20060807.html[JSON-RPC 1.1]
-* link:https://gerrit.googlesource.com/gwtjsonrpc/+/master/README[XSRF JSON-RPC]
-
-
 == Privacy Considerations
 
 Gerrit stores the following information per user account:
@@ -378,7 +322,7 @@
 project's mailing list archives.
 
 The user's name and email address is stored unencrypted in the
-Gerrit metadata store, typically a PostgreSQL database.
+link:config-accounts.html#all-users[All-Users] repository.
 
 The snail-mail mailing address, country, and phone and fax numbers
 are gathered to help project leads contact the user should there
@@ -648,12 +592,6 @@
 
 === Backups
 
-PostgreSQL and MySQL can be configured to replicate their data to
-other systems, where they are applied to a warm-standby backup in
-real time.  Gerrit instances which care about redundancy will setup
-this feature of PostgreSQL or MySQL to ensure the warm-standby is
-reasonably current should the master go offline.
-
 Using the standard replication plugin, Gerrit can be configured
 to replicate changes made to the local Git repositories over any
 standard Git transports. After the plugin is installed, remote
@@ -689,29 +627,6 @@
 scope of Gerrit.
 
 
-== Testing Plan
-
-Gerrit is currently manually tested through its web UI.
-
-JGit has a fairly extensive automated unit test suite.  Most new
-changes to JGit are rejected unless corresponding automated unit
-tests are included.
-
-
-== Caveats
-
-Rietveld can't be used as it does not provide the "submit over the
-web" feature that Gerrit provides for Git.
-
-Gitosis can't be used as it does not provide any code review
-features, but it does provide basic access controls.
-
-Email based code review does not scale to a project as large and
-complex as Android.  Most contributors at least need some sort of
-dashboard to keep track of any pending reviews, and some way to
-correlate updated revisions back to the comments written on prior
-revisions of the same logical change.
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 6e39502..67ced54 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -1,11 +1,9 @@
 = Gerrit Code Review - Eclipse Setup
 
 This document is about configuring Gerrit Code Review into an
-Eclipse workspace for development and debugging with GWT.
+Eclipse workspace for development.
 
-Java 6 or later SDK is also required to run GWT's compiler and
-runtime debugging environment.
-
+Java 8 or later SDK is require
 
 [[setup]]
 == Project Setup
@@ -49,13 +47,26 @@
 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
 
 To format source code, Gerrit uses the
 link:https://github.com/google/google-java-format[`google-java-format`]
-tool (version 1.3), which automatically formats code to follow the
+tool (version 1.7), which automatically formats code to follow the
 style guide. See link:dev-contributing.html#style[Code Style] for the
 instruction how to set up command line tool that uses this formatter.
 The Eclipse plugin is provided that allows to format with the same
@@ -91,61 +102,6 @@
 * Change Save as to be Local file.
 * Close the Debug Configurations dialog and save the changes when prompted.
 
-
-=== Running GWT Debug Mode
-
-The `gerrit_gwt_debug` launch configuration uses GWT's
-link:http://www.gwtproject.org/articles/superdevmode.html[Super Dev Mode].
-
-* Make a local copy of the `gerrit_gwt_debug` configuration, using the
-process described for `gerrit_daemon` above.
-* Launch the local copy of `gerrit_gwt_debug` from the Eclipse debug menu.
-* If debugging GWT for the first time:
-
-** Open the link:http://localhost:9876/[codeserver URL] and add the `Dev Mode On`
-and `Dev Mode Off` bookmarklet to your bookmark bar.
-
-** Activate the source maps feature in your browser. Refer to the
-link:https://developer.chrome.com/devtools/docs/javascript-debugging#source-maps[
-Chrome] and
-link:https://developer.mozilla.org/en-US/docs/Tools/Debugger#Use_a_source_map[
-Firefox] developer documentation.
-
-* Load the link:http://localhost:8080[Gerrit page].
-* Open the source tab in developer tools.
-* Click the `Dev Mode On` bookmark to incrementally recompile changed files.
-* Select the `gerrit_ui` module to compile (the `Compile` button can also be used
-as a bookmarklet).
-* In the developer tools source tab, open a file and set a breakpoint.
-* Navigate to the UI and confirm that the breakpoint is hit.
-* To end the debugging session, click the `Dev Mode Off` bookmark.
-
-.After changing the client side code:
-
-* Hitting `F5` in the browser only reloads the last compile output, without
-recompiling.
-* To reflect your changes in the debug session, click `Dev Mode On` then `Compile`.
-
-
-=== Running GWT Debug Mode for Gerrit plugins
-
-A Gerrit plugin can expose GWT module and its implementation can be inspected
-in the SDM debug session.
-
-`codeserver` needs two additional inputs to expose the plugin module in the SDM
-debug session: the module name and the source folder location. For example the
-module name and source folder of `cookbook-plugin` should be added in the local
-copy of the `gerrit_gwt_debug` configuration:
-
-----
-  com.googlesource.gerrit.plugins.cookbook.HelloForm \
-  -src ${resource_loc:/gerrit}/plugins/cookbook-plugin/src/main/java \
-  -- --console-log [...]
-----
-
-After doing that, both the Gerrit core and plugin GWT modules can be activated
-during SDM (debug session)[http://imgur.com/HFXZ5No].
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-inspector.txt b/Documentation/dev-inspector.txt
index 2134f2f..b1559ca 100644
--- a/Documentation/dev-inspector.txt
+++ b/Documentation/dev-inspector.txt
@@ -54,8 +54,6 @@
 ----
 "Shell" is "com.google.gerrit.pgm.shell.JythonShell@61644f2d"
 "m" is "com.google.gerrit.lifecycle.LifecycleManager@6f03b248"
-"ds" is "com.google.gerrit.server.schema.DataSourceProvider@6b3592c"
-"schk" is "com.google.gerrit.server.schema.SchemaVersionCheck@5e8cb9bd"
 
 Welcome to the Gerrit Inspector
 Enter help() to see the above again, EOF to quit and stop Gerrit
@@ -109,71 +107,11 @@
 'registerNatives', 'toString', 'wait']
 ----
 
-Startup script provides some convenient variables to access some global Gerrit components,
-for example a connection to the review database is kept open:
-
-----
->>> ds
-org.apache.commons.dbcp.BasicDataSource@61db2215
->>> ds.driverClassName
-u'org.postgresql.Driver'
->>> ds.dataSource
-org.apache.commons.dbcp.PoolingDataSource@23226fe1
->>> ds.dataSource.connection
-jdbc:postgresql://localhost/reviewdb, UserName=rv, PostgreSQL Native Driver
-----
-
-It is also possible to interact with the ORM layer:
-
-----
->>> db = schk.schema.open()
->>> db
-com.google.gerrit.reviewdb.server.ReviewDb_Schema_GwtOrm$$28@24cbbdf3
->>> db.getDialect()
-com.google.gwtorm.schema.sql.DialectPostgreSQL@4de07d3e
->>> for x in db.patchSets().iterateAllEntities():
-...     print x
-...
-[PatchSet 1,1]
-[PatchSet 2,1]
-[PatchSet 3,1]
-[PatchSet 4,1]
-[PatchSet 5,1]
-[PatchSet 6,1]
-[PatchSet 7,1]
-[PatchSet 8,1]
-[PatchSet 6,2]
->>> for x in db.patchComments().iterateAllEntities():
-...     print x
-com.google.gerrit.reviewdb.client.PatchLineComment@5381298a
-com.google.gerrit.reviewdb.client.PatchLineComment@44ce4dda
-com.google.gerrit.reviewdb.client.PatchLineComment@44594680
->>> dir(com.google.gerrit.reviewdb.client.PatchLineComment)
-['Key', 'STATUS_DRAFT', 'STATUS_PUBLISHED', 'Status', '__class__',
-'__copy__', '__deepcopy__', '__delattr__', '__doc__', '__eq__',
-'__getattribute__', '__hash__', '__init__', '__ne__', '__new__',
-'__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__str__',
-'__unicode__', 'author', 'class', 'clone', 'equals', 'finalize',
-'getAuthor', 'getClass', 'getKey', 'getLine', 'getMessage',
-'getParentUuid', 'getSide', 'getStatus', 'getWrittenOn', 'hashCode',
-'key', 'line', 'lineNbr', 'message', 'notify', 'notifyAll',
-'parentUuid', 'registerNatives', 'setMessage', 'setSide', 'setStatus',
-'side', 'status', 'toString', 'updated', 'wait', 'writtenOn']
->>> for x in db.patchComments().iterateAllEntities():
-...     print x.status, x.line, x.message
-...
-P 2 I like it!
-P 2 more
-P 1 better
-----
-
 A built-in *help()* function provides values of global variables
 defined in the interpreter:
 
 ----
 >>> help()
-"schk" is "com.google.gerrit.server.schema.SchemaVersionCheck@5e8cb9bd"
-"ds" is "com.google.gerrit.server.schema.DataSourceProvider@6b3592c"
 "m" is "com.google.gerrit.lifecycle.LifecycleManager@6f03b248"
 "Shell" is "com.google.gerrit.pgm.shell.JythonShell@61644f2d"
 "d" is "com.google.gerrit.pgm.Daemon@28a3f689"
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index 8bedd08..b87cbf4 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -24,6 +24,13 @@
 . Install it.
 . Restart IntelliJ.
 
+TIP: If your project's Bazel build fails with **Cannot run program "bazel": No
+such file or directory**, then you may have to set the binary location in the
+Bazel plugin settings:
+
+. Go to Preferences -> Other Settings -> Bazel Settings.
+. Set the Bazel binary location.
+
 == Creation of IntelliJ project
 
 . Go to *File -> Import Bazel Project*.
@@ -100,7 +107,9 @@
 === Copyright
 Copy the folder `$(gerrit_source_code)/tools/intellij/copyright` (not just the
 contents) to `$(project_data_directory)/.idea`. If it already exists, replace
-it.
+it. Then go to *File -> Settings -> Editor -> Copyright -> Copyright Profiles*,
+and import `Gerrit_Copyright.xml` to IntelliJ in case it doesn't pick the
+copyright up automatically.
 
 === File header
 By default, IntelliJ adds a file header containing the name of the author and
@@ -177,10 +186,6 @@
 plugin manages a project, it intercepts the creation and creates a Bazel test
 run configuration instead, which can be used just like the standard ones.
 
-TIP: If you would like to execute a test in NoteDb mode, add
-`--test_env=GERRIT_NOTEDB=READ_WRITE` to the *Bazel flags* of your run
-configuration.
-
 [[remote-debug]]
 === Debugging a remote Gerrit server
 If a remote Gerrit server is running and has opened a debug port, you can attach
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 1b28424..e292549 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -1,7 +1,8 @@
 = Gerrit Code Review - Plugin Development
 
 The Gerrit server functionality can be extended by installing plugins.
-This page describes how plugins for Gerrit can be developed.
+This page describes how plugins for Gerrit can be developed and hosted
+on gerrit-review.googlesource.com.
 
 For PolyGerrit-specific plugin development, consult with
 link:pg-plugin-dev.html[PolyGerrit Plugin Development] guide.
@@ -33,12 +34,9 @@
 [[getting-started]]
 == Getting started
 
-To get started with the development of a plugin clone the sample
-plugin:
-
-----
-$ git clone https://gerrit.googlesource.com/plugins/cookbook-plugin
-----
+To get started with the development of a plugin, take a look at
+the samples in the
+link:https://gerrit.googlesource.com/plugins/examples[examples plugin project].
 
 This is a project that demonstrates the various features of the
 plugin API. It can be taken as an example to develop an own plugin.
@@ -325,10 +323,10 @@
 
 Plugins' InitSteps are executed during the "Gerrit Plugin init" phase, after
 the extraction of the plugins embedded in the distribution .war file into
-`$GERRIT_SITE/plugins` and before the DB Schema initialization or upgrade.
+`$GERRIT_SITE/plugins` and before the site initialization or upgrade.
 
-A plugin's InitStep cannot refer to Gerrit's DB Schema or any other Gerrit
-runtime objects injected at startup.
+A plugin's InitStep cannot refer to any Gerrit runtime objects injected at
+startup.
 
 [source,java]
 ----
@@ -455,7 +453,7 @@
 ----
 import com.google.gerrit.common.EventDispatcher;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gwtorm.server.OrmException;
+import com.google.exceptions.StorageException;
 import com.google.inject.Inject;
 
 class MyPlugin {
@@ -469,7 +467,7 @@
   private void postEvent(MyPluginEvent event) {
     try {
       eventDispatcher.get().postEvent(event);
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       // error handling
     }
   }
@@ -718,10 +716,9 @@
 
 [source,java]
 ----
-@Singleton
 public class SampleOperator
     implements ChangeQueryBuilder.ChangeOperatorFactory {
-  public static class MyPredicate extends OperatorChangePredicate<ChangeData> {
+  public static class MyPredicate extends PostFilterPredicate<ChangeData> {
     ...
   }
 
@@ -751,7 +748,6 @@
 new `has:sample_pluginName` operand is shown below:
 
 ====
-  @Singleton
   public class SampleHasOperand implements ChangeHasOperandFactory {
     public static class Module extends AbstractModule {
       @Override
@@ -801,30 +797,112 @@
   }
 ----
 
+=== Calling Command Options ===
+
+Within an OptionHandler, during the processing of an option, plugins can
+provide and call extra parameters on the current command during parsing
+simulating as if they had been passed from the command line originally.
+
+To call additional parameters from within an option handler, instantiate
+the com.google.gerrit.util.cli.CmdLineParser.Parameters class with the
+existing parameters, and then call callParameters() with the additional
+parameters to be parsed. OptionHandlers may optionally pass this class to
+other methods which may then both parse/consume more parameters and call
+additional parameters.
+
+When calling command options not provided by your plugin, there is always
+a risk that the options may not exist, perhaps because the options being
+called are to be provided by another plugin, and said plugin is not
+currently installed. To protect againt this situation, it is possible to
+define an option as being dependent on other options using the
+@RequiresOptions() annotation. If the required options are not all not
+currently present, then the dependent option will not be available or
+visible in the help.
+
+The example below shows a plugin that adds a "--special" option (perhaps
+for use with the Query command) that calls (and requires) the
+"--format json" option.
+
+[source, java]
+----
+public class JsonOutputOptionHandler<T> extends OptionHandler<T> {
+  protected com.google.gerrit.util.cli.CmdLineParser.MyParser myParser;
+
+  public JsonOutputOptionHandler(CmdLineParser parser, OptionDef option, Setter<? super T> setter) {
+    super(parser, option, setter);
+    myParser = (com.google.gerrit.util.cli.CmdLineParser.MyParser) owner;
+  }
+
+  @Override
+  public int parseArguments(org.kohsuke.args4j.spi.Parameters params) throws CmdLineException {
+    new Parameters(params, myParser).callParameters("--format", "json");
+    setter.addValue(true);
+    return 0; // we didn't consume any additional args
+  }
+
+  @Override
+  public String getDefaultMetaVariable() {
+   ...
+  }
+}
+
+@RequiresOptions("--format")
+@Option(
+  name = "--special",
+  usage = "ouptut results using json",
+  handler = JsonOutputOptionHandler.class
+)
+boolean json;
+----
+
 [[query_attributes]]
-=== Query Attributes ===
+=== Change Attributes ===
 
-Plugins can provide additional attributes to be returned in Gerrit queries by
-implementing the ChangeAttributeFactory interface and registering it to the
-ChangeQueryProcessor.ChangeAttributeFactory class in the plugin module's
-'configure()' method. The new attribute(s) will be output under a "plugin"
-attribute in the change query output.
+Plugins can provide additional attributes to be returned from the Get Change and
+Query Change APIs by implementing implementing the `ChangeAttributeFactory`
+interface and adding it to the `DynamicSet` in the plugin module's `configure()`
+method. The new attribute(s) will be output under a `plugin` attribute in the
+change output. This can be further controlled by registering a class containing
+@Option declarations as a `DynamicBean`, annotated with the with HTTP/SSH
+commands on which the options should be available.
 
-The example below shows a plugin that adds two attributes ('exampleName' and
-'changeValue'), to the change query output.
+The example below shows a plugin that adds two attributes (`exampleName` and
+`changeValue`), to the change query output, when the query command is provided
+the `--myplugin-name--all` option.
 
 [source, java]
 ----
 public class Module extends AbstractModule {
   @Override
   protected void configure() {
-    bind(ChangeAttributeFactory.class)
-        .annotatedWith(Exports.named("example"))
+    // Register attribute factory.
+    DynamicSet.bind(binder(), ChangeAttributeFactory.class)
         .to(AttributeFactory.class);
+
+    // Register options for GET /changes/X/change and /changes/X/detail.
+    bind(DynamicBean.class)
+        .annotatedWith(Exports.named(GetChange.class))
+        .to(MyChangeOptions.class);
+
+    // Register options for GET /changes/?q=...
+    bind(DynamicBean.class)
+        .annotatedWith(Exports.named(QueryChanges.class))
+        .to(MyChangeOptions.class);
+
+    // Register options for ssh gerrit query.
+    bind(DynamicBean.class)
+        .annotatedWith(Exports.named(Query.class))
+        .to(MyChangeOptions.class);
   }
 }
 
+public class MyChangeOptions implements DynamicBean {
+  @Option(name = "--all", usage = "Include plugin output")
+  public boolean all = false;
+}
+
 public class AttributeFactory implements ChangeAttributeFactory {
+  protected MyChangeOptions options;
 
   public class PluginAttribute extends PluginDefinedInfo {
     public String exampleName;
@@ -837,8 +915,14 @@
   }
 
   @Override
-  public PluginDefinedInfo create(ChangeData c, ChangeQueryProcessor qp, String plugin) {
-    return new PluginAttribute(c);
+  public PluginDefinedInfo create(ChangeData c, BeanProvider bp, String plugin) {
+    if (options == null) {
+      options = (MyChangeOptions) bp.getDynamicBean(plugin);
+    }
+    if (options.all) {
+      return new PluginAttribute(c);
+    }
+    return null;
   }
 }
 ----
@@ -846,7 +930,7 @@
 Example
 ----
 
-ssh -p 29418 localhost gerrit query "change:1" --format json
+ssh -p 29418 localhost gerrit query --myplugin-name--all "change:1" --format json
 
 Output:
 
@@ -861,8 +945,30 @@
    ],
     ...
 }
+
+curl http://localhost:8080/changes/1?myplugin-name--all
+
+Output:
+
+{
+  "_number": 1,
+  ...
+  "plugins": [
+    {
+      "name": "myplugin-name",
+      "example_name": "Attribute Example",
+      "change_value": "1"
+    }
+  ],
+  ...
+}
 ----
 
+Implementors of the `ChangeAttributeFactory` interface should check whether
+they need to contribute to the link:#change-etag-computation[change ETag
+computation] to prevent callers using ETags from potentially seeing outdated
+plugin attributes.
+
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
 
@@ -1270,7 +1376,7 @@
 [[panels]]
 === Panels
 
-GWT plugins can contribute panels to Gerrit screens.
+UI plugins can contribute panels to Gerrit screens.
 
 Gerrit screens define extension points where plugins can add GWT
 panels with custom controls:
@@ -1483,9 +1589,8 @@
   // schedule a build
   [...]
   // update change
-  ReviewDb db = dbProvider.get();
   try (BatchUpdate bu = batchUpdateFactory.create(
-      db, project.getNameKey(), user, TimeUtil.nowTs())) {
+      project.getNameKey(), user, TimeUtil.nowTs())) {
     bu.addOp(change.getId(), new BatchUpdate.Op() {
       @Override
       public boolean updateChange(ChangeContext ctx) {
@@ -1572,7 +1677,7 @@
 
 [source,java]
 ----
-public class HttpModule extends HttpPluginModule {
+public class HttpModule extends ServletModule {
   @Override
   protected void configureServlets() {
     DynamicSet.bind(binder(), WebUiPlugin.class)
@@ -1846,317 +1951,6 @@
 ----
 
 
-[[gwt_ui_extension]]
-== GWT UI Extension
-Plugins can extend the Gerrit UI with own GWT code.
-
-A GWT plugin must contain a GWT module file, e.g. `HelloPlugin.gwt.xml`,
-that bundles together all the configuration settings of the GWT plugin:
-
-[source,xml]
-----
-<?xml version="1.0" encoding="UTF-8"?>
-<module rename-to="hello_gwt_plugin">
-  <!-- Inherit the core Web Toolkit stuff. -->
-  <inherits name="com.google.gwt.user.User"/>
-  <!-- Other module inherits -->
-  <inherits name="com.google.gerrit.Plugin"/>
-  <inherits name="com.google.gwt.http.HTTP"/>
-  <!-- Using GWT built-in themes adds a number of static -->
-  <!-- resources to the plugin. No theme inherits lines were -->
-  <!-- added in order to make this plugin as simple as possible -->
-  <!-- Specify the app entry point class. -->
-  <entry-point class="${package}.client.HelloPlugin"/>
-  <stylesheet src="hello.css"/>
-</module>
-----
-
-The GWT module must inherit `com.google.gerrit.Plugin` and
-`com.google.gwt.http.HTTP`.
-
-To register the GWT module a `GwtPlugin` needs to be bound.
-
-If no Guice modules are declared in the manifest, the GWT plugin may
-use auto-registration by using the `@Listen` annotation:
-
-[source,java]
-----
-@Listen
-public class MyExtension extends GwtPlugin {
-  public MyExtension() {
-    super("hello_gwt_plugin");
-  }
-}
-----
-
-Otherwise the binding must be done in an `HttpModule`:
-
-[source,java]
-----
-public class HttpModule extends HttpPluginModule {
-
-  @Override
-  protected void configureServlets() {
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new GwtPlugin("hello_gwt_plugin"));
-  }
-}
-----
-
-The HTTP module above must be declared in the `pom.xml` for Maven
-driven plugins:
-
-[source,xml]
-----
-<manifestEntries>
-  <Gerrit-HttpModule>com.googlesource.gerrit.plugins.myplugin.HttpModule</Gerrit-HttpModule>
-</manifestEntries>
-----
-
-The name that is provided to the `GwtPlugin` must match the GWT
-module name compiled into the plugin. The name of the GWT module
-can be explicitly set in the GWT module XML file by specifying
-the `rename-to` attribute on the module. It is important that the
-module name be unique across all plugins installed on the server,
-as the module name determines the JavaScript namespace used by the
-compiled plugin code.
-
-[source,xml]
-----
-<module rename-to="hello_gwt_plugin">
-----
-
-The actual GWT code must be implemented in a class that extends
-`com.google.gerrit.plugin.client.PluginEntryPoint`:
-
-[source,java]
-----
-public class HelloPlugin extends PluginEntryPoint {
-
-  @Override
-  public void onPluginLoad() {
-    // Create the dialog box
-    final DialogBox dialogBox = new DialogBox();
-
-    // The content of the dialog comes from a User specified Preference
-    dialogBox.setText("Hello from GWT Gerrit UI plugin");
-    dialogBox.setAnimationEnabled(true);
-    Button closeButton = new Button("Close");
-    VerticalPanel dialogVPanel = new VerticalPanel();
-    dialogVPanel.setWidth("100%");
-    dialogVPanel.setHorizontalAlignment(VerticalPanel.ALIGN_CENTER);
-    dialogVPanel.add(closeButton);
-
-    closeButton.addClickHandler(new ClickHandler() {
-      public void onClick(ClickEvent event) {
-        dialogBox.hide();
-      }
-    });
-
-    // Set the contents of the Widget
-    dialogBox.setWidget(dialogVPanel);
-
-    RootPanel rootPanel = RootPanel.get(HelloMenu.MENU_ID);
-    rootPanel.getElement().removeAttribute("href");
-    rootPanel.addDomHandler(new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          dialogBox.center();
-          dialogBox.show();
-        }
-    }, ClickEvent.getType());
-  }
-}
-----
-
-This class must be set as entry point in the GWT module:
-
-[source,xml]
-----
-<entry-point class="${package}.client.HelloPlugin"/>
-----
-
-In addition this class must be defined as module in the `pom.xml` for the
-`gwt-maven-plugin` and the `webappDirectory` option of `gwt-maven-plugin`
-must be set to `${project.build.directory}/classes/static`:
-
-[source,xml]
-----
-<plugin>
-  <groupId>org.codehaus.mojo</groupId>
-  <artifactId>gwt-maven-plugin</artifactId>
-  <version>2.7.0</version>
-  <configuration>
-    <module>com.googlesource.gerrit.plugins.myplugin.HelloPlugin</module>
-    <disableClassMetadata>true</disableClassMetadata>
-    <disableCastChecking>true</disableCastChecking>
-    <webappDirectory>${project.build.directory}/classes/static</webappDirectory>
-  </configuration>
-  <executions>
-    <execution>
-      <goals>
-        <goal>compile</goal>
-      </goals>
-    </execution>
-  </executions>
-</plugin>
-----
-
-To attach a GWT widget defined by the plugin to the Gerrit core UI
-`com.google.gwt.user.client.ui.RootPanel` can be used to manipulate the
-Gerrit core widgets:
-
-[source,java]
-----
-RootPanel rootPanel = RootPanel.get(HelloMenu.MENU_ID);
-rootPanel.getElement().removeAttribute("href");
-rootPanel.addDomHandler(new ClickHandler() {
-  @Override
-  public void onClick(ClickEvent event) {
-    dialogBox.center();
-    dialogBox.show();
-  }
-}, ClickEvent.getType());
-----
-
-GWT plugins can come with their own css file. This css file must have a
-unique name and must be registered in the GWT module:
-
-[source,xml]
-----
-<stylesheet src="hello.css"/>
-----
-
-If a GWT plugin wants to invoke the Gerrit REST API it can use
-`com.google.gerrit.plugin.client.rpc.RestApi` to construct the URL
-path and to trigger the REST calls.
-
-Example for invoking a Gerrit core REST endpoint:
-
-[source,java]
-----
-new RestApi("projects").id(projectName).view("description")
-    .put("new description", new AsyncCallback<JavaScriptObject>() {
-
-  @Override
-  public void onSuccess(JavaScriptObject result) {
-    // TODO
-  }
-
-  @Override
-  public void onFailure(Throwable caught) {
-    // never invoked
-  }
-});
-----
-
-Example for invoking a REST endpoint defined by a plugin:
-
-[source,java]
-----
-new RestApi("projects").id(projectName).view("myplugin", "myview")
-    .get(new AsyncCallback<JavaScriptObject>() {
-
-  @Override
-  public void onSuccess(JavaScriptObject result) {
-    // TODO
-  }
-
-  @Override
-  public void onFailure(Throwable caught) {
-    // never invoked
-  }
-});
-----
-
-The `onFailure(Throwable)` of the provided callback is never invoked.
-If an error occurs, it is shown in an error dialog.
-
-In order to be able to do REST calls the GWT module must inherit
-`com.google.gwt.json.JSON`:
-
-[source,xml]
-----
-<inherits name="com.google.gwt.json.JSON"/>
-----
-
-[[screen]]
-== Add Screen
-A link:#gwt_ui_extension[GWT plugin] can link:#top-menu-extensions[add
-a menu item] that opens a screen that is implemented by the plugin.
-This way plugin screens can be fully integrated into the Gerrit UI.
-
-Example menu item:
-[source,java]
-----
-public class MyMenu implements TopMenu {
-  private final List<MenuEntry> menuEntries;
-
-  @Inject
-  public MyMenu(@PluginName String name) {
-    menuEntries = new ArrayList<>();
-    menuEntries.add(new MenuEntry("My Menu", Collections.singletonList(
-      new MenuItem("My Screen", "#/x/" + name + "/my-screen", ""))));
-  }
-
-  @Override
-  public List<MenuEntry> getEntries() {
-    return menuEntries;
-  }
-}
-----
-
-Example screen:
-[source,java]
-----
-public class MyPlugin extends PluginEntryPoint {
-  @Override
-  public void onPluginLoad() {
-    Plugin.get().screen("my-screen", new Screen.EntryPoint() {
-      @Override
-      public void onLoad(Screen screen) {
-        screen.add(new InlineLabel("My Screen");
-        screen.show();
-      }
-    });
-  }
-}
-----
-
-[[user-settings-screen]]
-== Add User Settings Screen
-
-A link:#gwt_ui_extension[GWT plugin] can implement a user settings
-screen that is integrated into the Gerrit user settings menu.
-
-Example settings screen:
-[source,java]
-----
-public class MyPlugin extends PluginEntryPoint {
-  @Override
-  public void onPluginLoad() {
-    Plugin.get().settingsScreen("my-preferences", "My Preferences",
-        new Screen.EntryPoint() {
-          @Override
-          public void onLoad(Screen screen) {
-            screen.setPageTitle("Settings");
-            screen.add(new InlineLabel("My Preferences"));
-            screen.show();
-          }
-    });
-  }
-}
-----
-
-By defining an link:config-gerrit.html#urlAlias[urlAlias] Gerrit
-administrators can map plugin screens into the Gerrit URL namespace or
-even replace Gerrit screens by plugin screens.
-
-Plugins may also programatically add URL aliases in the preferences of
-of a user. This way certain screens can be replaced for certain users.
-E.g. the plugin may offer a user preferences setting for choosing a
-screen that then sets/unsets a URL alias for the user.
-
 [[settings-screen]]
 == Plugin Settings Screen
 
@@ -2313,6 +2107,26 @@
 e.g. a plugin can provide a list of servers on which the change was
 deployed.
 
+[[change-report-formatting]]
+== Change Report Formatting
+
+When a change is pushed for review from the command line, Gerrit reports
+the change(s) received with their URL and subject.
+
+By implementing the
+`com.google.gerrit.server.git.ChangeReportFormatter` interface, a plugin
+may change the formatting of the report.
+
+[[url-formatting]]
+== URL Formatting
+
+URLs to various parts of Gerrit are usually formed by adding suffixes to
+the canonical web URL.
+
+By implementing the
+`com.google.gerrit.server.config.UrlFormatter` interface, a plugin may
+change the format of the URL.
+
 [[links-to-external-tools]]
 == Links To External Tools
 
@@ -2407,9 +2221,9 @@
 /** Register the LfsApiServlet to listen on the default LFS protocol endpoint */
 import static com.google.gerrit.httpd.plugins.LfsPluginServlet.URL_REGEX;
 
-import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.inject.servlet.ServletModule;
 
-public class HttpModule extends HttpPluginModule {
+public class HttpModule extends ServletModule {
 
   @Override
   protected void configureServlets() {
@@ -2524,7 +2338,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`.
 
@@ -2637,40 +2451,6 @@
 Disabled plugins can be re-enabled using the
 link:cmd-plugin-enable.html[plugin enable] command.
 
-== Known issues and bugs
-
-=== Error handling in UI when using the REST API
-
-When a plugin invokes a REST endpoint in the UI, it provides an
-`AsyncCallback` to handle the result. At the moment the
-`onFailure(Throwable)` of the callback is never invoked, even if there
-is an error. Errors are always handled by the Gerrit core UI which
-shows the error dialog. This means currently plugins cannot do any
-error handling and e.g. ignore expected errors.
-
-In the following example the REST endpoint would return '404 Not
-Found' if the user has no username and the Gerrit core UI would
-display an error dialog for this. However having no username is
-not an error and the plugin may like to handle this case.
-
-[source,java]
-----
-new RestApi("accounts").id("self").view("username")
-    .get(new AsyncCallback<NativeString>() {
-
-  @Override
-  public void onSuccess(NativeString username) {
-    // TODO
-  }
-
-  @Override
-  public void onFailure(Throwable caught) {
-    // never invoked
-  }
-});
-----
-
-
 [[reviewer-suggestion]]
 == Reviewer Suggestion Plugins
 
@@ -2724,7 +2504,7 @@
 [source, java]
 ----
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
 
 public class MyPlugin implements MailFilter {
   public boolean shouldProcessMessage(MailMessage message) {
@@ -2734,8 +2514,8 @@
 }
 ----
 
-[[ssh-command-interception]]
-== SSH Command Interception
+[[ssh-command-creation-interception]]
+== SSH Command Creation Interception
 
 Gerrit provides an extension point that allows a plugin to intercept
 creation of SSH commands and override the functionality with its own
@@ -2751,6 +2531,39 @@
     return pluginName + " mycommand";
 ----
 
+[[ssh-command-execution-interception]]
+== SSH Command Execution Interception
+Gerrit provides an extension point that enables plugins to check and
+prevent an SSH command from being run.
+
+[source, java]
+----
+import com.google.gerrit.sshd.SshExecuteCommandInterceptor;
+
+@Singleton
+public class SshExecuteCommandInterceptorImpl implements SshExecuteCommandInterceptor {
+  private final Provider<SshSession> sessionProvider;
+
+  @Inject
+  SshExecuteCommandInterceptorImpl(Provider<SshSession> sessionProvider) {
+    this.sessionProvider = sessionProvider;
+  }
+
+  @Override
+  public boolean accept(String command, List<String> arguments) {
+    if (command.startsWith("gerrit") && !"10.1.2.3".equals(sessionProvider.get().getRemoteAddressAsString())) {
+      return false;
+    }
+    return true;
+  }
+}
+----
+
+And then declare it in your SSH module:
+[source, java]
+----
+  DynamicSet.bind(binder(), SshExecuteCommandInterceptor.class).to(SshExecuteCommandInterceptorImpl.class);
+----
 
 [[pre-submit-evaluator]]
 == Pre-submit Validation Plugins
@@ -2836,7 +2649,7 @@
     // Implement your submitability logic here
 
     // Assuming we want to prevent this change from being submitted:
-    SubmitRecord record;
+    SubmitRecord record = new SubmitRecord();
     record.status = Status.NOT_READY;
     return record;
   }
@@ -2861,6 +2674,371 @@
 Plugin authors should also consider binding their SubmitRule using a `Gerrit-BatchModule`.
 See link:dev-plugins.html[Batch runtime] for more informations.
 
+
+The SubmitRule extension point allows you to write complex rules, but writing
+small self-contained rules should be preferred: doing so allows end users to
+compose several rules to form more complex submit checks.
+
+The `SubmitRequirement` class allows rules to communicate what the user needs
+to change in order to be compliant. These requirements should be kept once they
+are met, but marked as `OK`. If the requirements were not displayed, reviewers
+would need to use their precious time to manually check that they were met.
+
+Implementors of the `SubmitRule` interface should check whether they need to
+contribute to the link:#change-etag-computation[change ETag computation] to
+prevent callers using ETags from potentially seeing outdated submittability
+information.
+
+[[change-etag-computation]]
+== Change ETag Computation
+
+By implementing the `com.google.gerrit.server.change.ChangeETagComputation`
+interface plugins can contribute a value to the change ETag computation.
+
+Plugins can affect the result of the get change / get change details REST
+endpoints by:
+
+* providing link:#query_attributes[plugin defined attributes] in
+  link:rest-api-changes.html#change-info[ChangeInfo]
+* implementing a link:#pre-submit-evaluator[pre-submit evaluator] which affects
+  the computation of `submittable` field in
+  link:rest-api-changes.html#change-info[ChangeInfo]
+
+If the plugin defined part of link:rest-api-changes.html#change-info[
+ChangeInfo] depends on plugin specific data, callers that use change ETags to
+avoid unneeded recomputations of ChangeInfos may see outdated plugin attributes
+and/or outdated submittable information, because a ChangeInfo is only reloaded
+if the change ETag changes.
+
+By implementating the `com.google.gerrit.server.change.ChangeETagComputation`
+interface plugins can contribute to the ETag computation and thus ensure that
+the change ETag changes when the plugin data was changed. This way it can be
+ensured that callers do not see outdated ChangeInfos.
+
+IMPORTANT: Change ETags are computed very frequently and the computation must
+be cheap. Take good care to not perform any expensive computations when
+implementing this.
+
+[source, java]
+----
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.hash.Hasher;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.ChangeETagComputation;
+
+public class MyPluginChangeETagComputation implements ChangeETagComputation {
+  public String getETag(Project.NameKey projectName, Change.Id changeId) {
+    Hasher hasher = Hashing.murmur3_128().newHasher();
+
+    // Add hashes for all plugin-specific data that affects change infos.
+    hasher.putString(sha1OfPluginSpecificChangeRef, UTF_8);
+
+    return hasher.hash().toString();
+  }
+}
+----
+
+[[quota-enforcer]]
+== Quota Enforcer
+
+Gerrit provides an extension point that allows a plugin to enforce quota.
+link:quota.html[This documentation page] has a list of all quota requests that
+Gerrit core issues. Plugins can choose to respond to all or just a subset of
+requests. Some implementations might want to keep track of user quota in buckets,
+others might just check against instance or project state to enforce limits on how
+many projects can be created or how large a repository can become.
+
+Checking against instance state can be racy for concurrent requests as the server does not
+refill tokens if the action fails in a later stage (e.g. database failure). If
+plugins want to guarantee an absolute maximum on a resource, they have to do their own
+book-keeping.
+
+[source, java]
+----
+import com.google.server.quota.QuotaEnforcer;
+
+class ProjectLimiter implements QuotaEnforcer {
+  private final long maxNumberOfProjects = 100;
+  @Override
+  QuotaResponse requestTokens(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    if (!"/projects/create".equals(quotaGroup)) {
+      return QuotaResponse.noOp();
+    }
+    // No deduction because we always check against the instance state (racy but fine for
+    // this plugin)
+    if (currentNumberOfProjects() + numTokens > maxNumberOfProjects) {
+      return QuotaResponse.error("too many projects");
+    }
+    return QuotaResponse.ok();
+  }
+
+  @Override
+  QuotaResponse dryRun(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    // Since we are not keeping any state in this enforcer, we can simply call requestTokens().
+    return requestTokens(quotaGroup, ctx, numTokens);
+  }
+
+  void refill(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    // No-op
+  }
+}
+----
+
+[source, java]
+----
+import com.google.server.quota.QuotaEnforcer;
+
+class ApiQpsEnforcer implements QuotaEnforcer {
+  // AutoRefillingPerUserBuckets is a imaginary bucket implementation that could be based on
+  // a loading cache or a commonly used bucketing algorithm.
+  private final AutoRefillingPerUserBuckets<CurrentUser, Long> buckets;
+  @Override
+  QuotaResponse requestTokens(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    if (!quotaGroup.startsWith("/restapi/")) {
+      return QuotaResponse.noOp();
+    }
+    boolean success = buckets.deduct(ctx.user(), numTokens);
+    if (!success) {
+      return QuotaResponse.error("user sent too many qps, please wait for 5 minutes");
+    }
+    return QuotaResponse.ok();
+  }
+
+  @Override
+  QuotaResponse dryRun(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    if (!quotaGroup.startsWith("/restapi/")) {
+      return QuotaResponse.noOp();
+    }
+    boolean success = buckets.checkOnly(ctx.user(), numTokens);
+    if (!success) {
+      return QuotaResponse.error("user sent too many qps, please wait for 5 minutes");
+    }
+    return QuotaResponse.ok();
+  }
+
+  @Override
+  void refill(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    if (!quotaGroup.startsWith("/restapi/")) {
+      return;
+    }
+    buckets.add(ctx.user(), numTokens);
+  }
+}
+----
+
+[[performance-logger]]
+== Performance Logger
+
+`com.google.gerrit.server.logging.PerformanceLogger` is an extension point that
+is invoked for all operations for which the execution time is measured. The
+invocation of the extension point does not happen immediately, but only at the
+end of a request (REST call, SSH call, git push). Implementors can write the
+execution times into a performance log for further analysis.
+
+[[request-listener]]
+== Request Listener
+
+`com.google.gerrit.server.RequestListener` is an extension point that is
+invoked each time the server executes a request from a user.
+
+[[plugins_hosting]]
+== Plugins source code hosting
+
+Most of the plugins are hosted on the same instance as the
+link:https://gerrit-review.googlesource.com[Gerrit project itself] to make them
+more discoverable and have more chances to be reviewed by the whole community.
+
+[[hosting_lifecycle]]
+=== Hosting lifecycle
+
+The process of writing a new plugin goes through different phases:
+
+- Ideation and discussion
+  The idea of creating a new plugin is posted and discussed on the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list.
+- Prototyping (optional)
+  The author of the plugin creates a working prototype on a public repository
+  accessible to the community.
+- Proposal and Hosting
+  The author proposes to release the plugin under the
+  link:https://www.apache.org/licenses/LICENSE-2.0.html[Apache 2.0 OpenSource license]
+  and request to be hosted on
+  link:https://gerrit-review.googlesource.com[the Gerrit project site]. The proposal must be
+  accepted by at least one Gerrit maintainer. In case of disagreement between maintainers, the
+  issue can be escalated to the Engineering Steering Committee. If the plugin is accepted,
+  the Gerrit maintainer creates the project under the plugins path on
+  link:https://gerrit-review.googlesource.com[the Gerrit project site].
+- Development and contribution
+  The author develops a production-ready code base of the plugin, with contributions, reviews,
+  and help from the Gerrit community.
+- Release
+  The author releases the plugin by tagging and announcing on the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list.
+- Maintenance
+  The author maintains their plugins as new Gerrit versions are released, updates them when necessary,
+  develops further existing or new features and reviews incoming new contributions.
+  A plugin should declare and build on
+  link:https://gerrit-ci.gerritforge.com[the GerritForge CI] for the Gerrit versions it supports.
+- Deprecation
+  The author declares that the plugin is not maintained anymore or is deprecated and should
+  not be used anymore.
+
+[[ideation_discussion]]
+=== Ideation and discussion
+
+Starting a new plugin project is a community effort: it starts with the identification of a gap
+in the Gerrit Code Review product but evolves with the contribution of ideas and suggestions
+by the whole community.
+
+The ideator of the plugin starts with an RFC (Request For Comments) post on the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list with a description
+of the main reasons for starting a new plugin.
+
+Example of a post:
+
+  [RFC] Code-Formatter plugin
+
+  Hello, community,
+  I am proposing to create a new plugin for Gerrit called 'Code-Formatter', see the
+  details below.
+
+  *The gap*
+  Often, when I post a new change to Gerrit, I forget to run the common code formatting
+  tool (e.g. Google-Java-Format for the Gerrit project). I would like Gerrit to be in charge
+  of highlighting these issues to me and save many people's time.
+
+  *The proposal*
+  The Code-Formatter plugin reads the formatting rules in the project config and applies
+  them automatically to every patch-set. Any issue is reported as a regular review comment
+  to the patchset, highlighting the part of the code to be changed.
+
+  What do you think? Did anyone have the same idea or need?
+
+The idea is discussed on the mailing list and can evolve based on the needs and inputs from
+the entire community.
+
+After the discussion, the ideator of the plugin can decide to start prototyping on it or park the
+proposal, if the feedback provided an alternative solution to the problem.
+The prototype phase can be optionally skipped if the idea is clear enough and receives a general
+agreement from the Gerrit maintainers. The author can be given a "leap of faith" and can go
+directly to the format plugin proposal (see below) and the creation of the plugin repository.
+
+[[plugin_prototyping]]
+=== Plugin Prototyping
+
+The initial idea is translated to code by the plugin author. The development can happen on any
+public or private source code repository and can involve one or more contributors.
+The purpose of prototyping is to verify that the idea can be implemented and provides the expected
+benefits.
+
+Once a working prototype is ready, it can be announced as a follow-up to the initial RFC proposal
+so that other members of the community can see the code and try the plugin themselves.
+
+[[plugin_proposal]]
+==== Plugin Proposal
+
+The author decides that the plugin prototype makes sense as a general purpose plugin and decides
+to release the code with the same
+link:https://www.apache.org/licenses/LICENSE-2.0.html[Apache 2.0 license]
+as the Gerrit Code Review project and have it hosted on
+link:https://gerrit-review.googlesource.com[the Gerrit project site].
+
+The plugin author formalizes the proposal with a follow-up of the initial RFC post and asks
+for public opinion on it.
+
+Example:
+
+----
+  Re - [RFC] Code-Formatter plugin
+
+  Hello, community,
+  thanks for your feedback on the prototype. I have now decided to donate the project to the
+  Gerrit Code Review project and make it a plugin:
+
+  Plugin name:
+  /plugins/code-formatter
+
+  Plugin description:
+    Plugin to allow automatic posting review based on code-formatting rules
+----
+
+The community discusses the proposal and the value of the plugin for the whole project; the result
+of the discussion can end up in one of the following cases:
+
+- The plugin's project request is widely appreciated and formally accepted by at least one
+  Gerrit maintainer who creates the repository as child project of 'Public-Projects' on
+  link:https://gerrit-review.googlesource.com[the Gerrit project site], creates an associated
+  plugin owners group with "Owner" permissions for the plugin and adds the plugin's
+  author as member of it.
+- The plugin's project is widely appreciated; however, another existing plugin already
+  partially covers the same use-case and thus it would make more sense to have the features
+  integrated into the existing plugin. The new plugin's author contributes his prototype commits
+  refactored to be included as change into the existing plugin.
+- The plugin's project is found useful; however, it is too specific to the author's use-case
+  and would not make sense outside of it. The plugin remains in a public repository, widely
+  accessible and OpenSource, but not hosted on link:https://gerrit-review.googlesource.com[the Gerrit project site].
+
+[[development_contribution]]
+== Development and contribution
+
+The plugin's maintainer creates a job on the link:https://gerrit-ci.gerritforge.com[GerritForge CI]
+by creating a new YAML definition in the
+link:https://gerrit.googlesource.com/gerrit-ci-scripts[Gerrit CI Scripts] repository.
+
+Example of a YAML CI job for plugins:
+
+----
+  - project:
+    name: code-formatter
+    jobs:
+      - 'plugin-{name}-bazel-{branch}':
+          branch:
+            - master
+----
+
+The plugin follows the same lifecycle as Gerrit Code Review and needs to be kept up-to-date with
+the current active branches, according to the
+link:https://www.gerritcodereview.com/#support[current support policy].
+During the development, the plugin's maintainer can reward contributors requesting to be more
+involved and making them maintainers of his plugin, adding them to the list of the project owners.
+
+[[plugin_release]]
+== Plugin release
+
+The plugin's maintainer is the only person responsible for making and announcing the official
+releases, typically, but not limited to, in conjunction with the major releases of Gerrit Code Review.
+The plugin's maintainer may tag his plugin and follow the notation and semantics of the
+Gerrit Code Review project; however it is not mandatory and many of the plugins do not have any
+tags or releases.
+
+Example of a YAML CI job for a plugin compatible with multiple Gerrit versions:
+
+----
+  - project:
+    name: code-formatter
+    jobs:
+      - 'plugin-{name}-bazel-{branch}-{gerrit-branch}':
+          branch:
+            - master
+          gerrit-branch:
+            - master
+            - stable-3.0
+            - stable-2.16
+----
+
+[[plugin_deprecation]]
+=== Plugin deprecation
+
+The plugin's maintainer and the community have agreed that the plugin is not useful anymore or there
+isn't anyone willing to contribute to bringing it forward and keeping it up-to-date with the recent
+versions of Gerrit Code Review.
+
+The plugin's maintainer puts a deprecation notice in the README.md of the plugin and pushes it for
+review. If nobody is willing to bring the code forward, the change gets merged, and the master branch is
+removed from the list of branches to be built on the GerritFoge CI.
+
 == SEE ALSO
 
 * link:js-api.html[JavaScript API]
diff --git a/Documentation/dev-polygerrit.txt b/Documentation/dev-polygerrit.txt
deleted file mode 100644
index 79049fc..0000000
--- a/Documentation/dev-polygerrit.txt
+++ /dev/null
@@ -1,31 +0,0 @@
-= 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 disable GWT but not PolyGerrit:
-----
-[gerrit]
-        enableGwtUi = false
-        enablePolyGerrit = true
-----
-
-To enable GWT but not PolyGerrit:
-----
-[gerrit]
-        enableGwtUi = true
-        enablePolyGerrit = false
-----
-
-To switch to the PolyGerrit UI you have to add `?polygerrit=1` in the URL.
-
-for example https://gerrit.example.org/?polygerrit=1
-
-To disable PolyGerrit UI, change 1 to 0, which will take you back to GWT UI.
-
-
-More information can be found in the link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/[README]
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
new file mode 100644
index 0000000..b3c147f
--- /dev/null
+++ b/Documentation/dev-processes.txt
@@ -0,0 +1,181 @@
+= Gerrit Code Review - Development Processes
+
+[[project-governance]]
+[[steering-committee]]
+== Project Governance / Engineering Steering Committee
+
+The Gerrit project has an engineering steering committee (ESC) that is
+in charge of:
+
+* Gerrit core (the `gerrit` project) and the core plugins
+* defining the project vision and the project scope
+* maintaining a roadmap, a release plan and a prioritized backlog
+* ensuring timely design reviews
+* ensuring that new features are compatible with the project vision and
+  are well aligned with other features (give feedback on new
+  link:dev-design-docs.html[design docs] within 14 calendar days)
+* approving/rejecting link:dev-design-docs.html[designs], vetoing new
+  features
+* assigning link:dev-roles.html#mentor[mentors] for approved features
+* accepting new plugins as core plugins
+* making changes to the project governance process and the
+  link:dev-contributing.html#contribution-processes[contribution
+  processes]
+
+The steering committee has 5 members:
+
+* 3 Googlers that are appointed by Google
+* 2 non-Google maintainers, elected by non-Google maintainers for the
+  period of 1 year (see link:#steering-committee-election[below])
+
+Refer to the project homepage for the link:https://www.gerritcodereview.com/esc.html[
+list of current committee members].
+
+The steering committee should act in the interest of the Gerrit project
+and the whole Gerrit community.
+
+For decisions, consensus between steering committee members and all
+other maintainers is desired. If consensus cannot be reached, decisions
+can also be made by simple majority in the steering committee (should
+be applied only in exceptional situations).
+
+The steering committee is empowered to overrule positive/negative votes
+from individual maintainers, but should do so only in exceptional
+situations after attempts to reach consensus have failed.
+
+As an integral part of the Gerrit community, the steering committee is
+committed to transparency and to answering incoming requests in a
+timely manner.
+
+[[steering-committee-election]]
+=== Election of non-Google steering committee members
+
+The election of the non-Google steering committee members happens once
+a year in May. Non-Google link:dev-roles.html#maintainer[maintainers]
+can nominate themselves by posting an informal application on the
+non-public maintainers mailing list by end of April (deadline for 2019
+is Mon 13th of May). By applying to be steering committee member, the
+candidate confirms to be able to dedicate the time that is needed to
+fulfill this role (also see
+link:dev-roles.html#steering-committee-member[steering committee
+member]).
+
+Each non-Google maintainer can vote for 2 candidates. The voting
+happens by posting on the maintainer mailing list. The voting period is
+14 calendar days from the nomination deadline (except for 2019, where
+the initial steering committee should be confirmed during the Munich
+hackathon, the voting period goes from 14th May to 16th May).
+
+Google maintainers do not take part in this vote, because Google
+already has dedicated seats in the steering committee (see section
+link:steering-committee[steering committee]).
+
+[[contribution-process]]
+== Contribution Process
+
+See link:dev-contributing.html[here].
+
+[[design-doc-review]]
+== Design Doc Review
+
+See link:dev-design-docs.html#review[here].
+
+[[dev-in-stable-branches]]
+== Development in stable branches
+
+As their name suggests stable branches are intended to be stable. This means that generally
+only bug-fixes should be done on stable branches, however this is not strictly enforced and
+exceptions may apply:
+
+  * When a stable branch is initially created to prepare a new release the Gerrit community
+    discusses on the mailing list if there are pending features which should still make it into the
+    release. Those features are blocking the release and should be implemented on the stable
+    branch before the first release candidate is created.
+  * To stabilize the code before doing a major release several release candidates are created. Once
+    the first release candidate was done no more features should be accepted on the stable branch.
+    If more features are found to be required they should be discussed with the steering committee
+    and should only be allowed if the risk of breaking things is considered to be low.
+  * Once a major release is done only bug-fixes and documentation updates should be done on the
+    stable branch. These updates will be included in the next minor release.
+  * For minor releases new features are only acceptable if they are important to the Gerrit
+    community, if they are backwards compatible and the risk of breaking things is low and if there
+    are no objections from the steering committee.
+  * In cases of doubt it's the responsibility of the steering committee to evaluate the risk of new
+    features and make a decision based on these rules and opinions from the Gerrit community.
+  * The older a stable branch is the more stable it should be. This means old stable branches
+    should only receive bug-fixes that are either important or low risk. Security fixes, including
+    security updates for third party dependencies, are always considered as important and hence can
+    always be done on stable branches.
+
+[[backporting]]
+== Backporting to stable branches
+
+From time to time bug fix releases are made for existing stable branches.
+
+Developers concerned with stable branches are encouraged to backport or push fixes to these
+branches, even if no new release is planned. Backporting features is only possible in compliance
+with the rules link:#dev-in-stable-branches[above].
+
+Fixes that are known to be needed for a particular release should be pushed for review on that
+release's stable branch. They will then be included into the master branch when the stable branch
+is merged back.
+
+[[upgrading-libraries]]
+== Upgrading Libraries
+
+Changes that add new libraries or upgrade existing libraries require an approval on the
+`Library-Compliance` label. For an approval the following things are checked:
+
+* The library has a license that is suitable for use within Gerrit.
+* If the library is used within Google, the version of the library must be compatible with the
+  version that is used at Google.
+
+Only maintainers from Google can vote on the `Library-Compliance` label.
+
+Gerrit's library dependencies should only be upgraded if the new version contains
+something we need in Gerrit. This includes new features, API changes as well as bug
+or security fixes.
+An exception to this rule is that right after a new Gerrit release was branched
+off, all libraries should be upgraded to the latest version to prevent Gerrit
+from falling behind. Doing those upgrades should conclude at the latest two
+months after the branch was cut. This should happen on the master branch to ensure
+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]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index a170e07..02b1891 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -3,110 +3,47 @@
 To build a developer instance, you'll need link:https://bazel.build/[Bazel] to
 compile the code.
 
-== Getting the Source
+== Git Setup
+
+=== Getting the Source
 
 Create a new client workspace:
 
 ----
-  git clone --recursive https://gerrit.googlesource.com/gerrit
+  git clone --recurse-submodules https://gerrit.googlesource.com/gerrit
   cd gerrit
 ----
 
-The `--recursive` option is needed on `git clone` to ensure that
-the core plugins, which are included as git submodules, are also
-cloned.
+The `--recurse-submodules` option is needed on `git clone` to ensure that the
+core plugins, which are included as git submodules, are also cloned.
+
+=== Switching between branches
+
+When using `git checkout` without `--recurse-submodules` to switch between
+branches, submodule revisions are not altered, which can result in:
+
+*  Incorrect or unneeded plugin revisions.
+*  Missing plugins.
+
+After you switch branches, ensure that you have the correct versions of
+the submodules.
+
+CAUTION: If you store Eclipse or IntelliJ project files in the Gerrit source
+directories, do *_not_* run `git clean -fdx`. Doing so may remove untracked files and damage your project. For more information, see
+link:https://git-scm.com/docs/git-clean[git-clean].
+
+Run the following:
+
+----
+  git submodule update
+  git clean -ffd
+----
 
 [[compile_project]]
 == Compiling
 
 For details, see <<dev-bazel#,Building with Bazel>>.
 
-== Configuring Eclipse
-
-To use the Eclipse IDE for development, see
-link:dev-eclipse.html[Eclipse Setup].
-
-To configure the Eclipse workspace with Bazel, see
-link:dev-bazel.html#eclipse[Eclipse integration with Bazel].
-
-== Configuring IntelliJ IDEA
-
-See <<dev-intellij#,IntelliJ Setup>> for details.
-
-== MacOS
-
-On MacOS, ensure that "Java for MacOS X 10.5 Update 4" (or higher) is installed
-and that `JAVA_HOME` is set to the
-link:install.html#Requirements[required Java version].
-
-Java installations can typically be found in
-"/System/Library/Frameworks/JavaVM.framework/Versions".
-
-To check the installed version of Java, open a terminal window and run:
-
-`java -version`
-
-[[init]]
-== Site Initialization
-
-After you compile the project <<compile_project,(above)>>, run the Gerrit
-`init`
-command to create a test site:
-
-----
-  $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war init -d ../gerrit_testsite
-----
-
-[[special_bazel_java_version]]
-NOTE: You must use the same Java version that Bazel used for the build, which
-is available at `$(bazel info output_base)/external/local_jdk/bin/java`.
-
-During initialization, change two settings from the defaults:
-
-*  To ensure the development instance is not externally accessible, change the
-listen addresses from '*' to 'localhost'.
-*  To allow yourself to create and act as arbitrary test accounts on your
-development instance, change the auth type from 'OPENID' to 'DEVELOPMENT_BECOME_ANY_ACCOUNT'.
-
-After initializing the test site, Gerrit starts serving in the background. A
-web browser displays the Start page.
-
-On the Start page, you can:
-
-.  Log in as the account you created during the initialization process.
-.  Register additional accounts.
-.  Create projects.
-
-To shut down the daemon, run:
-
-----
-  ../gerrit_testsite/bin/gerrit.sh stop
-----
-
-
-[[localdev]]
-== Working with the Local Server
-
-To create more accounts on your development instance:
-
-.  Click 'become' in the upper right corner.
-.  Select 'Switch User'.
-.  Register a new account.
-
-Use the `ssh` protocol to clone from and push to the local server. For
-example, to clone a repository that you've created through the admin
-interface, run:
-
-----
-git clone ssh://username@localhost:29418/projectname
-----
-
-To create changes as users of Gerrit would, run:
-
-----
-git push origin HEAD:refs/for/master
-----
 
 == Testing
 
@@ -123,6 +60,92 @@
 For instructions on running the acceptance tests with Bazel,
 see <<dev-bazel#tests,Running Unit Tests with Bazel>>.
 
+
+== Local server
+
+[[init]]
+=== Site Initialization
+
+After you compile the project <<compile_project,(above)>>, run the Gerrit
+`init`
+command to create a test site:
+
+----
+  export GERRIT_SITE=~/gerrit_testsite
+  $(bazel info output_base)/external/local_jdk/bin/java \
+      -jar bazel-bin/gerrit.war init --batch --dev -d $GERRIT_SITE
+----
+
+[[special_bazel_java_version]]
+NOTE: You must use the same Java version that Bazel used for the build, which
+is available at `$(bazel info output_base)/external/local_jdk/bin/java`.
+
+This command takes two parameters:
+
+* `--batch` assigns default values to several Gerrit configuration
+    options. To learn more about these options, see
+    link:config-gerrit.html[Configuration].
+* `--dev` configures the Gerrit server to use the authentication
+  option, `DEVELOPMENT_BECOME_ANY_ACCOUNT`, which enables you to
+  switch between different users to explore how Gerrit works. To learn more
+  about setting up Gerrit for development, see
+  link:dev-readme.html[Gerrit Code Review: Developer Setup].
+
+After initializing the test site, Gerrit starts serving in the background. A
+web browser displays the Start page.
+
+On the Start page, you can:
+
+.  Log in as the account you created during the initialization process.
+.  Register additional accounts.
+.  Create projects.
+
+To shut down the daemon, run:
+
+----
+  $GERRIT_SITE/bin/gerrit.sh stop
+----
+
+
+[[localdev]]
+=== Working with the Local Server
+
+To create more accounts on your development instance:
+
+.  Click 'become' in the upper right corner.
+.  Select 'Switch User'.
+.  Register a new account.
+.  link:user-upload.html#ssh[Configure your SSH key].
+
+Use the `ssh` protocol to clone from and push to the local server. For
+example, to clone a repository that you've created through the admin
+interface, run:
+
+----
+git clone ssh://username@localhost:29418/projectname
+----
+
+To use the `HTTP` protocol, run:
+
+----
+git clone http://username@localhost:8080/projectname
+----
+
+The default password for user `admin` is `secret`. You can regenerate a
+password in the UI under User Settings -- HTTP credentials. The password can be
+stored locally to avoid retyping it:
+
+----
+git config --global credential.helper store
+git pull
+----
+
+To create changes as users of Gerrit would, run:
+
+----
+git push origin HEAD:refs/for/master
+----
+
 [[run_daemon]]
 === Running the Daemon
 
@@ -131,7 +154,7 @@
 
 ----
   $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite \
+     -jar bazel-bin/gerrit.war daemon -d $GERRIT_SITE \
      --console-log
 ----
 
@@ -163,7 +186,7 @@
 
 ----
   $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite -s
+     -jar bazel-bin/gerrit.war daemon -d $GERRIT_SITE -s
 ----
 
 NOTE: To learn why using `java -jar` isn't sufficient, see
@@ -187,48 +210,24 @@
 CAUTION: When using the Inspector, be careful not to modify the internal state
 of the system.
 
-=== Querying the database
 
-The embedded H2 database can be queried and updated from the command line. If
-the daemon is not running, run:
+== Setup for backend developers
 
-----
-  $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war gsql -d ../gerrit_testsite -s
-----
+=== Configuring Eclipse
 
-NOTE: To learn why using `java -jar` isn't sufficient, see
-<<special_bazel_java_version,this explanation>>.
+To use the Eclipse IDE for development, see
+link:dev-eclipse.html[Eclipse Setup].
 
-Alternatively, if the daemon is running and the database is in use, use an
-administrator user account to connect over SSH:
+To configure the Eclipse workspace with Bazel, see
+link:dev-bazel.html#eclipse[Eclipse integration with Bazel].
 
-----
-  ssh -p 29418 user@localhost gerrit gsql
-----
+=== Configuring IntelliJ IDEA
 
+See <<dev-intellij#,IntelliJ Setup>> for details.
 
-== Switching between branches
+== Setup for frontend developers
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md[Frontend Developer Setup].
 
-When using `git checkout` without `--recurse-submodules` to switch between
-branches, submodule revisions are not altered, which can result in:
-
-*  Incorrect or unneeded plugin revisions.
-*  Missing plugins.
-
-After you switch branches, ensure that you have the correct versions of
-the submodules.
-
-CAUTION: If you store Eclipse or IntelliJ project files in the Gerrit source
-directories, do *_not_* run `git clean -fdx`. Doing so may remove untracked files and damage your project. For more information, see
-link:https://git-scm.com/docs/git-clean[git-clean].
-
-Run the following:
-
-----
-  git submodule update
-  git clean -ffd
-----
 
 GERRIT
 ------
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index 5f95cb3..98a3df5 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -91,7 +91,7 @@
 
 * `gerrit-maven`:
 +
-Bucket to store Gerrit Subproject Artifacts (e.g. `gwtjsonrpc` etc.).
+Bucket to store Gerrit Subproject Artifacts (e.g. Prolog Cafe).
 
 To upload artifacts to a bucket the user must authenticate with a
 username and password. The username and password need to be retrieved
diff --git a/Documentation/dev-release-subproject.txt b/Documentation/dev-release-subproject.txt
index 4886849..c9369b9 100644
--- a/Documentation/dev-release-subproject.txt
+++ b/Documentation/dev-release-subproject.txt
@@ -73,7 +73,7 @@
 * Deploy the new release:
 +
 ----
-  mvn deploy
+  mvn deploy -DperformRelease=true
 ----
 
 * Push the pom change(s) to the project's repository
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 53ded48..cda7a49 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -247,7 +247,7 @@
 ==== Push the Stable Branch
 
 * Create the stable branch `stable-$version` in the `gerrit` project via the
-link:https://gerrit-review.googlesource.com/#/admin/projects/gerrit,branches[
+link:https://gerrit-review.googlesource.com/admin/repos/gerrit,branches[
 Gerrit Web UI] or by push.
 
 * Push the commits done on `stable-$version` to `refs/for/stable-$version` and
@@ -294,7 +294,7 @@
 [[update-links]]
 ==== Update homepage links
 
-Upload a change on the link:https://gerrit-review.googlesource.com/#/admin/projects/homepage[
+Upload a change on the link:https://gerrit-review.googlesource.com/admin/repos/homepage[
 homepage project] to change the version numbers to the new version.
 
 [[update-issues]]
@@ -316,15 +316,15 @@
 ==== Announce on Mailing List
 
 Send an email to the mailing list to announce the release. The content of the
-announcement email is generated with the `release-announcement.py` which
-automatically includes all the necessary links, hash values, and wraps the
-text in a PGP signature.
+announcement email is generated with the `release-announcement.py` script from
+the gerrit-release-tools repository, which automatically includes all the
+necessary links, hash values, and wraps the text in a PGP signature.
 
 For details refer to the documentation in the script's header, and/or the
 help text:
 
 ----
- ./tools/release-announcement.py --help
+ ~/gerrit-release-tools/release-announcement.py --help
 ----
 
 [[increase-version]]
@@ -365,6 +365,17 @@
 must reference the new version. Upload a change to bazlets repository with
 api version upgrade.
 
+[[clean-up-on-master]]
+=== Clean up on master
+
+Once you are done with the release, check if there are any code changes in the
+master branch that were gated on the next release. Mostly, these are
+feature-deprecations that we were holding off on to have a stable release where
+the feature is still contained, but marked as deprecated.
+
+See link:dev-contributing.html#deprecating-features[Deprecating features] for
+details.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
new file mode 100644
index 0000000..93a58c6
--- /dev/null
+++ b/Documentation/dev-roles.txt
@@ -0,0 +1,365 @@
+= Gerrit Code Review - Supporting Roles
+
+As an open source project Gerrit has a large community of people
+driving the project forward. There are many ways to engage with
+the project and get involved.
+
+[[supporter]]
+== Supporter
+
+Supporters are individuals who help the Gerrit project and the Gerrit
+community in any way. This includes users that provide feedback to the
+Gerrit community or get in touch by other means.
+
+There are many possibilities to support the project, e.g.:
+
+* get involved in discussions on the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  mailing list (post your questions, provide feedback, share your
+  experiences, help other users)
+* attend community events like user summits (see
+  link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
+  community calendar])
+* report link:https://bugs.chromium.org/p/gerrit/issues/list[issues]
+  and help to clarify existing issues
+* provide feedback on
+  link:https://www.gerritcodereview.com/releases-readme.html[new
+  releases and release candidates]
+* review
+  link:https://gerrit-review.googlesource.com/q/status:open[changes]
+  and help to verify that they work as advertised, comment if you like
+  or dislike a feature
+* serve as contact person for a proprietary Gerrit installation and
+  channel feedback from users back to the Gerrit community
+
+Supporters can:
+
+* post on the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  mailing list (Please note that the `repo-discuss` mailing list is
+  managed to prevent spam posts. This means posts from new participants
+  must be approved manually before they appear on the mailing list.
+  Approvals normally happen within 1 work day. Posts of people who
+  participate in mailing list discussions frequently are approved
+  automatically)
+* comment on
+  link:https://gerrit-review.googlesource.com/q/status:open[changes]
+  and vote from `-1` to `+1` on the `Code-Review` label (these votes
+  are important to understand the interest in a change and to address
+  concerns early, however link:#maintainer[maintainers] can
+  overrule/ignore these votes)
+* download changes to try them out, feedback can be provided as
+  comments and by voting (preferably on the `Verified` label,
+  permissions to vote on the `Verified` label are granted by request,
+  see below)
+* file issues in the link:https://bugs.chromium.org/p/gerrit/issues/list[
+  issue tracker] and comment on existing issues
+* support the
+  link:dev-processes.html#design-driven-contribution-process[
+  design-driven contribution process] by reviewing incoming
+  link:dev-design-docs.html[design docs] and raising concerns during
+  the design review
+
+Supporters who want to engage further can get additional privileges
+on request (ask for it on the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list):
+
+* become member of the `gerrit-verifiers` group, which allows to:
+** vote on the `Verified` and `Code-Style` labels
+** edit hashtags on all changes
+** edit topics on all open changes
+** abandon changes
+* approve posts to the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  mailing list
+* administrate issues in the
+  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker]
+
+Supporters can become link:#contributor[contributors] by signing a
+contributor license agreement and contributing code to the Gerrit
+project.
+
+[[contributor]]
+== Contributor
+
+Everyone who has a valid link:dev-cla.html[contributor license
+agreement] and who has link:dev-contributing.html[contributed] at least
+one change to any project on
+link:https://gerrit-review.googlesource.com/[
+gerrit-review.googlesource.com] is a contributor.
+
+Contributions can be:
+
+* new features
+* bug fixes
+* code cleanups
+* documentation updates
+* release notes updates
+* propose link:#dev-design-docs[design docs] as part of the
+  link:dev-contributing.html#design-driven-contribution-process[
+  design-driven contribution process]
+* scripts which are of interest to the community
+
+Contributors have all the permissions that link:#supporter[supporters]
+have. In addition they have signed a link:dev-cla.html[contributor
+license agreement] which enables them to push changes.
+
+Regular contributors can ask to be added to the `gerrit-verifiers`
+group, which allows to:
+
+* add patch sets to changes of other users
+* propose project config changes (push changes for the
+  `refs/meta/config` branch
+
+Being member of the `gerrit-verifiers` group includes further
+permissions (see link:#supporter[supporter] section above).
+
+It's highly appreciated if contributors engage in code reviews,
+link:dev-design-docs.html#review[design reviews] and mailing list
+discussions. If wanted, contributors can also serve as link#mentor[
+mentors] to support other contributors with getting their features
+done.
+
+Contributors may also be invited to join the Gerrit hackathons which
+happen regularly (e.g. twice a year). Hackathons are announced on the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list (also see
+link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
+community calendar]).
+
+Outstanding contributors that are actively engaged in the community, in
+activities outlined above, may be nominated as link:#maintainer[
+maintainers].
+
+[[maintainer]]
+== Maintainer
+
+Maintainers are the gatekeepers of the project and are in charge of
+approving and submitting changes. Refer to the project homepage for
+the link:https://www.gerritcodereview.com/members.html#maintainers[
+list of current maintainers].
+
+Maintainers should only approve changes that:
+
+* they fully understand
+* are in line with the project vision and project scope that are
+  defined by the link:dev-processes.html#steering-committee[engineering steering
+  committee], and should consult them, when in doubt
+* meet the quality expectations of the project (well-tested, properly
+  documented, scalable, backwards-compatible)
+* implement usable features or bug fixes (no incomplete/unusable
+  things)
+* are not authored by themselves (exceptions are changes which are
+  trivial according to the judgment of the maintainer and changes that
+  are required by the release process and branch management)
+
+Maintainers are trusted to assess changes, but are also expected to
+align with the other maintainers, especially if large new features are
+being added.
+
+Maintainers are highly encouraged to dedicate some of their time to the
+following tasks (but are not required to do so):
+
+* reviewing changes
+* mailing list discussions and support
+* bug fixing and bug triaging
+* supporting the
+  link:dev-processes.html#design-driven-contribution-process[
+  design-driven contribution process] by reviewing incoming
+  link:dev-design-docs.html[design docs] and raising concerns during
+  the design review
+* serving as link:#mentor[mentor]
+* doing releases (see link#release-manager[release manager])
+
+Maintainers can:
+
+* approve changes (vote `+2` on the `Code-Review` label); when
+  approving changes, `-1` votes on the `Code-Review` label can be
+  ignored if there is a good reason, in this case the reason should be
+  clearly communicated on the change
+* submit changes
+* block submission of changes if they disagree with how a feature is
+  being implemented (vote `-2` on the `Code-Review` label), but their
+  vote can be overruled by the steering committee, see
+  link:dev-processes.html#project-governance[Project Governance]
+* nominate new maintainers and vote on nominations (see below)
+* administrate the link:https://groups.google.com/d/forum/repo-discuss[
+  mailing list], the
+  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker]
+  and the link:https://www.gerritcodereview.com/[homepage]
+* gain permissions to do Gerrit releases and publish release artifacts
+* create new projects and groups on
+  link:https://gerrit-review.googlesource.com/[
+  gerrit-review.googlesource.com]
+* administrate the Gerrit projects on
+  link:https://gerrit-review.googlesource.com/[
+  gerrit-review.googlesource.com] (e.g. edit ACLs, update project
+  configuration)
+* create events in the
+  link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
+  community calendar]
+* discuss with other maintainers on the private maintainers mailing
+  list and Slack channel
+
+In addition, maintainers from Google can:
+
+* approve/reject changes that update project dependencies (vote `-1` to
+  `+1` on the `Library-Compliance` label), see
+  link:dev-processes.html#upgrading-libraries[Upgrading Libraries]
+* edit permissions on the Gerrit core projects
+
+[[maintainer-election]]
+Maintainers can nominate new maintainers by posting a nomination on the
+non-public maintainers mailing list. Nominations should stay open for
+at least 14 calendar days so that all maintainers have a chance to
+vote. To be approved as maintainer a minimum of 5 positive votes and no
+negative votes is required. This means if 5 positive votes without
+negative votes have been reached and 14 calendar days have passed, any
+maintainer can close the vote and welcome the new maintainer. Extending
+the voting period during holiday season or if there are not enough
+votes is possible, but the voting period should not exceed 1 month. If
+there are negative votes that are considered unjustified, the
+link:dev-processes.html#steering-committee[engineering steering
+committee] may get involved to decide whether the new maintainer can be
+accepted anyway.
+
+To become a maintainer, a link:#contributor[contributor] should have a
+history of deep technical contributions across different parts of the
+core Gerrit codebase. However, it is not required to be an expert on
+everything. Things that we want to see from potential maintainers
+include:
+
+* high quality code contributions
+* high quality code reviews
+* activity on the mailing list
+
+[[steering-committee-member]]
+== Engineering Steering Committee Member
+
+The Gerrit project has an Engineering Steering Committee (ESC) that
+governs the project, see link:dev-processes.html#project-governance[Project Governance].
+
+Members of the steering committee are expected to act in the interest
+of the Gerrit project and the whole Gerrit community. Refer to the project
+homepage for the link:https://www.gerritcodereview.com/members.html#engineering-steering-committee[
+list of current committee members].
+
+For those that are familiar with scrum, the steering committee member
+role is similar to the role of an agile product owner.
+
+Steering committee members must be able to dedicate sufficient time to
+their role so that the steering committee can satisfy its
+responsibilities and live up to the promise of answering incoming
+requests in a timely manner.
+
+link:#maintainer[Maintainers] can become steering committee member by
+election, or by being appointed by Google (only for the seats that
+belong to Google).
+
+[[mentor]]
+== Mentor
+
+A mentor is a link:#maintainer[maintainer] or link:#contributor[
+contributor] who is assigned to support the development of a feature
+that was specified in a link:dev-design-docs.html[design doc] and was
+approved by the link:dev-processes.html#steering-committee[steering
+committee].
+
+The goal of the mentor is to make the feature successful by:
+
+* doing timely reviews
+* providing technical guidance during code reviews
+* discussing details of the design
+* ensuring that the quality standards are met (well documented,
+  sufficient test coverage, backwards compatible etc.)
+
+The implementation is fully done by the contributor, but optionally
+mentors can help out with contributing some changes.
+
+link:#maintainer[Maintainers] and link:#contributor[contributors] can
+volunteer to generally serve as mentors, or to mentor specific features
+(e.g. if they see an upcoming feature on the roadmap that they are
+interested in). To volunteer as mentor, contact the
+link:dev-processes.html#steering-committee[steering committee] or
+comment on a change that adds a link:dev-design-docs.html#propose[
+design doc].
+
+[[community-manager]]
+== Community Manager
+
+Community managers should act as stakeholders for the Gerrit community
+and focus on the health of the community. Refer to the project homepage
+for the link:https://www.gerritcodereview.com/members.html#community-managers[
+list of current community managers].
+
+Tasks:
+
+* act as stakeholder for the Gerrit community towards the
+  link:dev-processes.html#steering-committee[steering committee]
+* ensure that the link:dev-contributing.html#mentorship[mentorship
+  process] works
+* deescalate conflicts in the Gerrit community
+* constantly improve community processes (e.g. contribution process)
+* watch out for community issues and address them proactively
+* serve as contact person for community issues
+
+Community members may submit new items under the
+link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:Community[Community component]
+backlog, for community managers to refine. Only public topics should be
+issued through that backlog.
+
+Sensitive topics are to be privately discussed using
+mailto:gerritcodereview-community-managers@googlegroups.com[this mailing list].
+This is a group that remains private between the individual community
+member and community managers.
+
+The community managers should be a pair or trio that shares the work:
+
+* One Googler that is appointed by Google.
+* One or two non-Googlers, elected by the community if there are more
+  than two candidates. If there is no candidate, we only have the one
+  community manager from Google.
+
+Community managers must not be link:#steering-committee-member[
+steering committee members] at the same time so that they can represent
+the community without conflict of interest.
+
+Nomination process, election process and election period for the
+non-Google community manager are the same as for
+link:dev-processes.html#steering-committee-election[steering committee
+members].
+
+[[release-manager]]
+== Release Manager
+
+Each major Gerrit release is driven by a Gerrit link:#maintainer[
+maintainer], the so called release manager.
+
+The release manager is responsible for:
+
+* identifying release blockers and informing about them
+* creating stable branches and updating version numbers
+* creating release candidates, the final major release and minor
+  releases
+* announcing releases on the mailing list and collecting feedback
+* ensuring that releases meet minimal quality expectations (Gerrit
+  starts, upgrade from previous version works)
+* publishing release artifacts
+* ensuring quality and completeness of the release notes
+* cherry-picking bug fixes, see link:dev-processes.html#backporting[
+  Backporting to stable branches]
+* estimating the risk of new features that are added on stable
+  branches, see link:dev-processes.html#dev-in-stable-branches[
+  Development in stable branches]
+
+Before each release, the release manager is appointed by consensus among
+the maintainers. Volunteers are welcome, but it's also a goal to fairly
+share this work between maintainers and contributing companies.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-starter-projects.txt b/Documentation/dev-starter-projects.txt
new file mode 100644
index 0000000..ae40ea6
--- /dev/null
+++ b/Documentation/dev-starter-projects.txt
@@ -0,0 +1,14 @@
+= Gerrit Code Review - Starter Projects
+
+We have created a
+link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject]
+category in the issue tracker and try to assign easy hack projects to it. If in
+doubt, do not hesitate to ask on the developer
+link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list].
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/error-changeid-above-footer.txt b/Documentation/error-changeid-above-footer.txt
new file mode 100644
index 0000000..abc0186
--- /dev/null
+++ b/Documentation/error-changeid-above-footer.txt
@@ -0,0 +1,31 @@
+= commit xxxxxxx: Change-Id must be in message footer
+
+With this error message, Gerrit rejects a push of a commit to a project
+if the commit message of the pushed commit contains a Change-Id line that
+is not in the footer (the last paragraph).
+
+To be picked up by Gerrit, a Change-Id must be in the last paragraph
+of a commit message. For details, see link:user-changeid.html[Change-Id Lines].
+
+You can see the commit messages for existing commits in the history
+by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log].
+
+
+== Change-Id is contained in the commit message but not in the last paragraph
+
+If the Change-Id is contained in the commit message but not in its
+last paragraph, you have to update the commit message and move the
+Change-Id into the last paragraph. How to update the commit message
+is explained link:error-push-fails-due-to-commit-message.html[here].
+
+To avoid confusion due to a Change-Id that was meant to be picked up by
+Gerrit not being picked up, this is an error whether or not the project
+is configured to always require a Change-Id in the commit message.
+
+
+GERRIT
+------
+Part of link:error-messages.html[Gerrit Error Messages]
+
+SEARCHBOX
+---------
diff --git a/Documentation/error-messages.txt b/Documentation/error-messages.txt
index 37eb1f6..b523663 100644
--- a/Documentation/error-messages.txt
+++ b/Documentation/error-messages.txt
@@ -18,6 +18,7 @@
 * link:error-invalid-changeid-line.html[invalid Change-Id line format in commit message footer]
 * link:error-invalid-committer.html[invalid committer]
 * link:error-missing-changeid.html[missing Change-Id in commit message footer]
+* link:error-changeid-above-footer.html[Change-Id must be in commit message footer]
 * link:error-missing-subject.html[missing subject; Change-Id must be in commit message footer]
 * link:error-multiple-changeid-lines.html[multiple Change-Id lines in commit message footer]
 * link:error-no-common-ancestry.html[no common ancestry]
diff --git a/Documentation/error-missing-changeid.txt b/Documentation/error-missing-changeid.txt
index 9cddd85..27bfea5 100644
--- a/Documentation/error-missing-changeid.txt
+++ b/Documentation/error-missing-changeid.txt
@@ -1,15 +1,9 @@
-= 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
 message if the commit message of the pushed commit does not contain
-a Change-Id in the footer (the last paragraph).
-
-This error may happen for different reasons:
-
-. missing Change-Id in the commit message
-. Change-Id is contained in the commit message but not in the last
-  paragraph
+a Change-Id.
 
 You can see the commit messages for existing commits in the history
 by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log].
@@ -38,17 +32,6 @@
 is explained link:error-push-fails-due-to-commit-message.html[here].
 
 
-== Change-Id is contained in the commit message but not in the last paragraph
-
-To be picked up by Gerrit, a Change-Id must be in the last paragraph
-of a commit message, for details, see link:user-changeid.html[Change-Id Lines].
-
-If the Change-Id is contained in the commit message but not in its
-last paragraph you have to update the commit message and move the
-Change-Id into the last paragraph. How to update the commit message
-is explained link:error-push-fails-due-to-commit-message.html[here].
-
-
 GERRIT
 ------
 Part of link:error-messages.html[Gerrit Error Messages]
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/i18n-readme.txt b/Documentation/i18n-readme.txt
deleted file mode 100644
index 180fc53..0000000
--- a/Documentation/i18n-readme.txt
+++ /dev/null
@@ -1,24 +0,0 @@
-= Gerrit Code Review - i18n
-
-Aside from actually writing translations, there are some issues with
-the way the code produces output.  Most of the UI should support
-right-to-left (RTL) languages.
-
-== Labels
-
-Labels and their values are defined in project.config by the Gerrit
-administrator or project owners.  Only a single translation of these
-strings is supported.
-
-== /Gerrit Gerrit.html
-
-* The title of the host page is not translated.
-
-* The <noscript> tag is not translated.
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
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 51ce9d6..d02570c 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -5,9 +5,11 @@
 . link:linux-quickstart.html[Quickstart for Installing Gerrit on Linux]
 
 == About Gerrit
+. link:intro-rockstar.html[Why Code Review?]
 . link:intro-quick.html[Product Overview]
 . link:intro-how-gerrit-works.html[How Gerrit Works]
 . link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
+. link:dev-community.html[Gerrit Community]
 
 == Guides
 . link:intro-user.html[User Guide]
@@ -66,31 +68,11 @@
 . 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]
 
-== Developer
-. Getting Started
-.. link:dev-readme.html[Developer Setup]
-.. link:dev-bazel.html[Building with Bazel]
-.. link:dev-eclipse.html[Eclipse Setup]
-.. link:dev-intellij.html[IntelliJ Setup]
-.. link:dev-contributing.html[Contributing to Gerrit]
-. Plugin Development
-.. link:dev-plugins.html[Developing Plugins]
-.. link:dev-build-plugins.html[Building Gerrit plugins]
-.. link:js-api.html[JavaScript Plugin API]
-.. link:config-validation.html[Validation Interfaces]
-.. link:dev-stars.html[Starring Changes]
-. link:dev-design.html[System Design]
-. link:i18n-readme.html[i18n Support]
-
-== Maintainer
-. link:dev-release.html[Making a Gerrit Release]
-. link:dev-release-subproject.html[Making a Release of a Gerrit Subproject]
-. link:dev-release-jgit.html[Making a Release of JGit]
-
 == Concepts
 . link:config-labels.html[Review Labels]
 . link:access-control.html[Access Controls]
@@ -101,7 +83,7 @@
 == Resources
 * link:licenses.html[Licenses and Notices]
 * link:https://www.gerritcodereview.com/[Homepage]
-* link:https://www.gerritcodereview.com/download/index.html[Downloads]
+* link:https://gerrit-releases.storage.googleapis.com/index.html[Downloads]
 * link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
 * link:https://gerrit.googlesource.com/gerrit[Source Code]
 * link:https://www.gerritcodereview.com/about.md[A History of Gerrit Code Review]
diff --git a/Documentation/install-j2ee.txt b/Documentation/install-j2ee.txt
index f7252e0..48751b7 100644
--- a/Documentation/install-j2ee.txt
+++ b/Documentation/install-j2ee.txt
@@ -14,9 +14,8 @@
 
 == Installation
 
-* Complete the link:install.html#createdb[database setup] and
-  link:install.html#init[site initialization] tasks described
-  in the standard installation documentation.
+* Complete the link:install.html#init[site initialization] task
+  described in the standard installation documentation.
 
 * Stop the embedded daemon that was automatically started by 'init':
 +
@@ -24,13 +23,6 @@
   review_site/bin/gerrit.sh stop
 ----
 
-* Configure JNDI DataSource 'jdbc/ReviewDb'.
-+
-This DataSource must point to the database you created above.
-Don't forget to ensure your JNDI configuration can load the
-necessary JDBC drivers.  You may wish to ensure connection pooling
-is configured and enabled within the DataSource.
-
 * Deploy the 'gerrit.war' file to your application server.
 +
 The deployment process differs between servers, but typically this
@@ -70,20 +62,8 @@
   java -jar webapps/gerrit.war cat extra/jetty7/gerrit.xml >contexts/gerrit.xml
 ----
 
-Install the required additional libraries by copying them into the
-`'$JETTY_HOME'/lib/ext` directory:
-
-----
-  cp ../review_db/lib/* lib/ext/
-  java -jar webapps/gerrit.war cat lib/commons-dbcp-1.2.2.jar >lib/ext/commons-dbcp-1.2.2.jar
-  java -jar webapps/gerrit.war cat lib/commons-pool-1.5.4.jar >lib/ext/commons-pool-1.5.4.jar
-  java -jar webapps/gerrit.war cat lib/h2-1.2.128.jar >lib/ext/h2-1.2.128.jar
-  java -jar webapps/gerrit.war cat lib/postgresql-8.4-701.jdbc4.jar >lib/ext/postgresql-8.4-701.jdbc4.jar
-----
-
 Edit `'$JETTY_HOME'/contexts/gerrit.xml` to correctly configure
-the database and outgoing SMTP connections, especially the user
-and password fields.
+outgoing SMTP connections.
 
 If OpenID authentication (or certain enterprise single-sign-on
 solutions) is being used, you may need to increase the
@@ -105,9 +85,8 @@
 ----
 
 [TIP]
-Under Jetty, restarting the web application (e.g. after modifying
-`system_config`) is as simple as touching the context config file:
-`'$JETTY_HOME'/contexts/gerrit.xml`
+Under Jetty, restarting the web application is as simple as
+touching the context config file: `'$JETTY_HOME'/contexts/gerrit.xml`
 
 [[tomcat]]
 == Tomcat 7.x
diff --git a/Documentation/install-quick.txt b/Documentation/install-quick.txt
deleted file mode 100644
index 7b80229..0000000
--- a/Documentation/install-quick.txt
+++ /dev/null
@@ -1,227 +0,0 @@
-= Gerrit Code Review - Quick get started guide
-
-****
-This guide was made with the impatient in mind, ready to try out Gerrit on their
-own server but not prepared to make the full installation procedure yet.
-
-Explanation is sparse and you should not use a server installed this way in a
-live setup, this is made with proof of concept activities in mind.
-
-It is presumed you install it on a Unix based server such as any of the Linux
-flavors or BSD.
-
-It's also presumed that you have access to an OpenID enabled email address.
-Examples of OpenID enable email providers are Gmail, Yahoo! Mail and Hotmail.
-It's also possible to register a custom email address with OpenID, but that is
-outside the scope of this quick installation guide. For testing purposes one of
-the above providers should be fine. Please note that network access to the
-OpenID provider you choose is necessary for both you and your Gerrit instance.
-****
-
-
-[[requirements]]
-== Requirements
-
-Most distributions come with Java today. Do you already have Java installed?
-
-----
-  $ java -version
-  openjdk version "1.8.0_72"
-  OpenJDK Runtime Environment (build 1.8.0_72-b15)
-  OpenJDK 64-Bit Server VM (build 25.72-b15, mixed mode)
-----
-
-If Java isn't installed, get it:
-
-* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
-
-
-[[user]]
-== Create a user to host the Gerrit service
-
-We will run the service as a non-privileged user on your system.
-First create the user and then become the user:
-
-----
-  $ sudo adduser gerrit
-  $ sudo su gerrit
-----
-
-If you don't have root privileges you could skip this step and run Gerrit
-as your own user as well.
-
-
-[[download]]
-== Download Gerrit
-
-It's time to download the archive that contains the Gerrit web and ssh service.
-
-You can choose from different versions to download from here:
-
-* https://www.gerritcodereview.com/download/index.html[A list of releases available]
-
-This tutorial is based on version 2.2.2, and you can download that from this link
-
-* https://www.gerritcodereview.com/download/gerrit-2.2.2.war[Link to the 2.2.2 war archive]
-
-
-[[initialization]]
-== Initialize the Site
-
-It's time to run the initialization, and with the batch switch enabled, we don't have to answer any questions at all:
-
-----
-  gerrit@host:~$ java -jar gerrit.war init --batch -d ~/gerrit_testsite
-  Generating SSH host key ... rsa(simple)... done
-  Initialized /home/gerrit/gerrit_testsite
-  Executing /home/gerrit/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-When the init is complete, you can review your settings in the
-file `'$site_path/etc/gerrit.config'`.
-
-Note that initialization also starts the server.  If any settings changes are
-made, the server must be restarted before they will take effect.
-
-----
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh restart
-  Stopping Gerrit Code Review: OK
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-The server can be also stopped and started by passing the `stop` and `start`
-commands to gerrit.sh.
-
-----
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh stop
-  Stopping Gerrit Code Review: OK
-  gerrit@host:~$
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-include::config-login-register.txt[]
-
-== Project creation
-
-Your base Gerrit server is now running and you have a user that's ready
-to interact with it.  You now have two options, either you create a new
-test project to work with or you already have a git with history that
-you would like to import into Gerrit and try out code review on.
-
-=== New project from scratch
-If you choose to create a new repository from scratch, it's easier for
-you to create a project with an initial commit in it. That way first
-time setup between client and server is easier.
-
-This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project --empty-commit
-  user@host:~$
-----
-
-This will create a repository that you can clone to work with.
-
-=== Already existing project
-
-The other alternative is if you already have a git project that you
-want to try out Gerrit on.
-First you have to create the project.  This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project
-  user@host:~$
-----
-
-You need to make sure that at least initially your account is granted
-"Create Reference" privileges for the refs/heads/* reference.
-This is done via the web interface in the Admin/Projects/Access page
-that correspond to your project.
-
-After that it's time to upload the previous history to the server:
-
-----
-  user@host:~/my-project$ git push ssh://user@localhost:29418/demo-project *:*
-  Counting objects: 2011, done.
-  Writing objects: 100% (2011/2011), 456293 bytes, done.
-  Total 2011 (delta 0), reused 0 (delta 0)
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      master -> master
-  user@host:~/my-project$
-----
-
-This will create a repository that you can clone to work with.
-
-
-== My first change
-
-Download a local clone of the repository and move into it
-
-----
-  user@host:~$ git clone ssh://user@localhost:29418/demo-project
-  Cloning into demo-project...
-  remote: Counting objects: 2, done
-  remote: Finding sources: 100% (2/2)
-  remote: Total 2 (delta 0), reused 0 (delta 0)
-  user@host:~$ cd demo-project
-  user@host:~/demo-project$
-----
-
-Then make a change to it and upload it as a reviewable change in Gerrit.
-
-----
-  user@host:~/demo-project$ date > testfile.txt
-  user@host:~/demo-project$ git add testfile.txt
-  user@host:~/demo-project$ git commit -m "My pretty test commit"
-  [master ff643a5] My pretty test commit
-   1 files changed, 1 insertions(+), 0 deletions(-)
-   create mode 100644 testfile.txt
-  user@host:~/demo-project$
-----
-
-Usually when you push to a remote git, you push to the reference
-`'/refs/heads/branch'`, but when working with Gerrit you have to push to a
-virtual branch representing "code review before submission to branch".
-This virtual name space is known as /refs/for/<branch>
-
-----
-  user@host:~/demo-project$ git push origin HEAD:refs/for/master
-  Counting objects: 4, done.
-  Writing objects: 100% (3/3), 293 bytes, done.
-  Total 3 (delta 0), reused 0 (delta 0)
-  remote:
-  remote: New Changes:
-  remote:   http://localhost:8080/1
-  remote:
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      HEAD -> refs/for/master
-  user@host:~/demo-project$
-----
-
-You should now be able to access your change by browsing to the http URL
-suggested above, http://localhost:8080/1
-
-
-== Quick Installation Complete
-
-This covers the scope of getting Gerrit started and your first change uploaded.
-It doesn't give any clue as to how the review workflow works, please read
-link:http://source.android.com/source/life-of-a-patch[Default Workflow] to
-learn more about the workflow of Gerrit.
-
-To read more on the installation of Gerrit please see link:install.html[the detailed
-installation page].
-
-
-GERRIT
-------
-
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/install.txt b/Documentation/install.txt
index cc19b3f..2b6cc6e 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -5,11 +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]
-
-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
-PostgreSQL.)
+* 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.
 
 [[cryptography]]
 == Configure Java for Strong Cryptography
@@ -48,7 +46,7 @@
 == Download Gerrit
 
 Current and past binary releases of Gerrit can be obtained from
-the link:https://www.gerritcodereview.com/download/index.html[
+the link:https://gerrit-releases.storage.googleapis.com/index.html[
 Gerrit Releases site].
 
 Download any current `*.war` package. The war will be referred to as
@@ -58,15 +56,12 @@
 If you would prefer to build Gerrit directly from source, review
 the notes under link:dev-readme.html[developer setup].
 
-include::database-setup.txt[]
-
 [[init]]
 == Initialize the Site
 
 Gerrit stores configuration files, the server's SSH keys, and the
 managed Git repositories under a local directory, typically referred
-to as `'$site_path'`.  If the embedded H2 database is being used,
-its data files will also be stored under this directory.
+to as `'$site_path'`.
 
 You also have to decide where to store your server side git repositories. This
 can either be a relative path under `'$site_path'` or an absolute path
@@ -91,11 +86,10 @@
 then give ownership of that location to the `'gerrit'` user.
 
 If run from an interactive terminal, the init command will prompt through a
-series of configuration questions, including gathering information
-about the database created above.  If the terminal is not interactive,
-running the init command will choose some reasonable default selections,
-and will use the embedded H2 database. Once the init phase is complete,
-you can review your settings in the file `'$site_path/etc/gerrit.config'`.
+series of configuration questions.  If the terminal is not interactive,
+running the init command will choose some reasonable default selections.
+Once the init phase is complete, you can review your settings in the file
+`'$site_path/etc/gerrit.config'`.
 
 When running the init command, additional JARs might be downloaded to
 support optional selected functionality.  If a download fails a URL will
@@ -215,8 +209,7 @@
         --StartPath=C:\MY\GERRIT\SITE ^
         --StartMode=jvm --StopMode=jvm ^
         --StartClass=com.google.gerrit.launcher.GerritLauncher --StartMethod=daemonStart ^
-        --StopClass=com.google.gerrit.launcher.GerritLauncher --StopMethod=daemonStop ^
-        ++DependsOn=postgresql-x64-9.4
+        --StopClass=com.google.gerrit.launcher.GerritLauncher --StopMethod=daemonStop
 ====
 
 [[customize]]
@@ -253,8 +246,6 @@
 
 == External Documentation Links
 
-* http://www.postgresql.org/docs/[PostgreSQL Documentation]
-* http://dev.mysql.com/doc/[MySQL Documentation]
 * http://www.kernel.org/pub/software/scm/git/docs/git-daemon.html[git-daemon]
 
 
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index 1fba1dc..b4f799c2 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -28,7 +28,7 @@
 modify. To get this code, he runs the following `git clone` command:
 
 ----
-clone ssh://gerrithost:29418/RecipeBook.git RecipeBook
+git clone ssh://gerrithost:29418/RecipeBook.git RecipeBook
 ----
 
 After he clones the repository, he runs a couple of commands to add a
diff --git a/Documentation/intro-how-gerrit-works.txt b/Documentation/intro-how-gerrit-works.txt
index f903849..5f5deed 100644
--- a/Documentation/intro-how-gerrit-works.txt
+++ b/Documentation/intro-how-gerrit-works.txt
@@ -1,32 +1,32 @@
 = How Gerrit Works
 
-To understand how Gerrit fits into and enhances the developer workflow, consider
-a typical project. This project has a central source repository, which serves as
-the authoritative copy of the project's contents.
+To learn how Gerrit fits into and complements the developer workflow, consider
+a typical project. The following project contains a central source repository
+(_Authoritative Repository_) that serves as the authoritative version of the
+project's contents.
 
 .Central Source Repository
 image::images/intro-quick-central-repo.png[Authoritative Source Repository]
 
-Gerrit takes the place of this central repository and adds an additional
-concept: a _store of pending changes_.
+When implemented, Gerrit becomes the central source repository and introduces
+an additional concept: a store of _Pending Changes_.
 
-.Gerrit in place of Central Repository
-image::images/intro-quick-central-gerrit.png[Gerrit in place of Central Repository]
+.Gerrit as the Central Repository
+image::images/intro-quick-central-gerrit.png[Gerrit as the Central Repository]
 
-With Gerrit, when a developer makes a change, it is sent to this store of
-pending changes, where other developers can review, discuss and approve the
-change. After enough reviewers grant their approval, the change becomes an
-official part of the codebase.
+When Gerrit is configured as the central source repository, all code changes
+are sent to Pending Changes for others to review and discuss. When enough
+reviewers have approved a code change, you can submit the change to the code
+base.
 
-In addition to this store of pending changes, Gerrit captures notes
-and comments about each change. These features allow developers to review
-changes at their convenience, or when conversations about a change can't
-happen face to face. They also help to create a record of the conversation
-around a given change, which can provide a history of when a change was made and
-why.
+In addition to the store of Pending Changes, Gerrit captures notes and comments
+made about each change. This enables you to review changes at your convenience
+or when a conversation about a change can't happen in person. In addition,
+notes and comments provide a history of each change (what was changed and why and
+who reviewed the change).
 
-Like any repository hosting solution, Gerrit has a powerful
-link:access-control.html[access control model]. This model allows you to
+Like any repository hosting product, Gerrit provides a powerful
+link:access-control.html[access control model], which enables you to
 fine-tune access to your repository.
 
 GERRIT
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index de5171c..1f98291 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -123,6 +123,13 @@
 are a number of link:access-control.html#references_special[special refs]
 and link:access-control.html#references_magic[magic refs].
 
+Gerrit only supports tags that are reachable by any ref not owned by
+Gerrit. This includes branches (refs/heads/*) or custom ref namespaces
+(refs/my-company/*). Tagging a change ref is not supported.
+When filtering tags by visibility, Gerrit performs a reachability check
+and will present the user ony with tags that are reachable by any ref
+they can see.
+
 Access rights can be assigned on a concrete ref, e.g.
 `refs/heads/master` but also on ref patterns and regular expressions
 for ref names.
@@ -173,7 +180,7 @@
 them, e.g. link:access-control.html#ldap_groups[LDAP group names] need
 to be prefixed with `ldap/`.
 
-If the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/singleusergroup[
+If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/singleusergroup[
 singleusergroup] plugin is installed you can also directly assign
 access rights to users, by prefixing the username with `user/` or the
 user's account ID by `userid/`.
@@ -209,7 +216,7 @@
 addition you can benefit from Gerrit's merge strategies that can
 automatically merge/rebase commits on server side if necessary. You can
 control the merge strategy by configuring the
-link:project-configuration.html#submit_type[submit type] on the project. If you
+link:config-project-config.html#submit-type[submit type] on the project. If you
 bypass code review you always need to merge/rebase manually if the tip
 of the destination branch has moved. Please keep this in mind if you
 choose to not work with code review because you think it's easier to
@@ -239,7 +246,7 @@
 
 An important decision for a project is the choice of the submit type
 and the content merge setting (see the `Allow content merges` option).
-The link:project-configuration.html#submit_type[submit type] is the method
+The link:config-project-config.html#submit-type[submit type] is the method
 Gerrit uses to submit a change to the project. The submit type defines
 what Gerrit should do on submit of a change if the destination branch
 has moved while the change was in review. The
@@ -281,7 +288,7 @@
 types for different branches.
 
 Please note that there are other submit types available; they are
-described in the link:project-configuration.html#submit_type[Submit Type]
+described in the link:config-project-config.html#submit-type[Submit Type]
 section.
 
 [[labels]]
@@ -379,7 +386,7 @@
 Create Account] global capability is granted. If not, you need to ask
 a Gerrit administrator to create the service user.
 
-If the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/serviceuser[
+If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/serviceuser[
 serviceuser] plugin is installed you can also create new service users
 in the Gerrit Web UI under `People` > `Create Service User`. For this
 the `Create Service User` global capability must be assigned.
@@ -407,13 +414,13 @@
 
 There are some plugins available that provide commit validation:
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/uploadvalidator[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/uploadvalidator[
   uploadvalidator]:
 +
 The `uploadvalidator` plugin allows project owners to configure blocked
 file extensions, required footers and a maximum allowed path length.
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/commit-message-length-validator[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/commit-message-length-validator[
   commit-message-length-validator]
 +
 The `commit-message-length-validator` core plugin validates that commit
@@ -494,9 +501,9 @@
 - Issue Tracker System Plugins
 +
 There are Gerrit plugins for a tight integration with
-link:https://gerrit-review.googlesource.com/\#/admin/projects/plugins/its-jira[Jira],
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-bugzilla[Bugzilla] and
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-rtc[IBM Rational Team Concert].
+link:https://gerrit-review.googlesource.com//admin/repos/plugins/its-jira[Jira],
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-bugzilla[Bugzilla] and
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-rtc[IBM Rational Team Concert].
 If installed, these plugins can e.g. be used to automatically add links
 to Gerrit changes to the issues in the issue tracker system or to
 automatically close an issue if the corresponding change is merged.
@@ -543,13 +550,13 @@
 by adding this person in the Gerrit Web UI as a reviewer on the change.
 Gerrit will then notify this person by email about the review request.
 
-With the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewers[
+With the link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers[
 reviewers] plugin it is possible to configure default reviewers who
 will be automatically added to each change. The default reviewers can
 be configured in the Gerrit Web UI under `Projects` > `List` >
 <your project> > `General` in the `reviewers Plugin` section.
 
-The link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewers-by-blame[
+The link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers-by-blame[
 reviewers-by-blame] plugin can automatically add reviewers to changes
 based on the link:https://www.kernel.org/pub/software/scm/git/docs/git-blame.html[
 git blame] computation on the changed files. This means that the plugin
@@ -570,7 +577,7 @@
 that the available download commands depend on the installed Gerrit
 plugins:
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/download-commands[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/download-commands[
   download-commands] plugin:
 +
 The `download-commands` plugin provides the default download commands
@@ -579,14 +586,14 @@
 Gerrit administrators may configure which of the commands are shown on
 the change screen.
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/egit[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/egit[
   egit] plugin:
 +
 The `egit` plugin provides the change ref as a download command, which is
 needed for downloading a change from within
 link:https://www.eclipse.org/egit/[EGit].
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/project-download-commands[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/project-download-commands[
   project-download-commands] plugin:
 +
 The `project-download-commands` plugin enables project owners to
@@ -607,18 +614,6 @@
 are inherited by the child projects. A child project can overwrite an
 inherited download command, or remove it by assigning no value to it.
 
-[[theme]]
-== Theme
-
-Gerrit supports project-specific themes for customizing the appearance
-of the change screen and the diff screens. It is possible to define an
-HTML header and footer and to adapt Gerrit's CSS. Details about themes
-are explained in the link:config-themes.html[Themes] section.
-
-Project-specific themes can only be installed by Gerrit administrators
-since the theme files must be copied into the Gerrit installation
-folder.
-
 [[tool-integration]]
 == Integration with other tools
 
@@ -745,7 +740,7 @@
 
 Gerrit core does not support the deletion of projects.
 
-If the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/delete-project[
+If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/delete-project[
 delete-project] plugin is installed, projects can be deleted from the
 Gerrit Web UI under `Projects` > `List` > <project> > `General` by
 clicking on the `Delete` command under `Project Commands`. The `Delete`
diff --git a/Documentation/intro-quick.txt b/Documentation/intro-quick.txt
index e6b1e43..11d5052 100644
--- a/Documentation/intro-quick.txt
+++ b/Documentation/intro-quick.txt
@@ -1,31 +1,47 @@
-= Gerrit Product Overview
+= Gerrit Code Review Product Overview
 
-Gerrit is a web-based code review tool built on top of the
-https://git-scm.com/[Git version control system]. This introduction provides
-an overview of Gerrit and describes how Gerrit integrates into a typical
-development workflow. It also provides a brief tutorial that shows how to manage
-a change using Gerrit.
+Gerrit Code Review is a web-based code review tool built on
+https://git-scm.com/[Git version control].
 
-== What is Gerrit?
+== What is Gerrit Code Review?
 
-Gerrit makes code review easy by providing a lightweight framework for reviewing
-commits before they are accepted by the codebase. Gerrit works equally well for
-projects where approving changes is restricted to selected users, as is typical
-for Open Source software development, as well as projects where all contributors
-are trusted.
+Gerrit provides a framework you and your teams can use to review code before it
+becomes part of the code base. Gerrit works equally well in open source projects
+that limit the number of users who can approve changes (typical in open source
+software development) and in projects in which all contributors are trusted.
 
-== Learn About Gerrit
+== What is Code Review?
 
-If you're new to Gerrit and want to know more about how it can improve your
-developer workflow, see the following topics:
+Code reviews can identify mistakes before they're found by customers. In a world
+of continuous integration, code must be tested before it's submitted to the
+master branch to become part of the code base. Tests confirm that a product
+works (and continues to work) as intended by the developers.
+
+When code is reviewed, developers:
+
+. Work carefully and consistently
+. Learn best practices and new techniques from other developers
+. Implement consistency and quality across the code base
+
+Code reviews typically turn up issues related to:
+
+. Design: Is code well-designed and suited to the code base?
+. Functionality: Does code perform as intended and in a way that is good for users?
+. Complexity: Can other developers understand and use the code?
+. Naming: Does the code contain clear names for elements such as variables, classes, and methods?
+. Comments: Are comments specific and complete?
+
+== Learn Gerrit Code Review
+
+If you're new to Gerrit and want to learn how Gerrit can improve your workflow,
+see:
 
 . link:intro-how-gerrit-works.html[How Gerrit Works]
 . link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
 
 == Getting Started
 
-This documentation contains several guides to help you learn about the Gerrit
-features most relevant to you:
+To learn more, see:
 
 . link:intro-user.html[User Guide]
 . link:intro-project-owner.html[Project Owner Guide]
diff --git a/Documentation/intro-rockstar.txt b/Documentation/intro-rockstar.txt
new file mode 100644
index 0000000..0b67950
--- /dev/null
+++ b/Documentation/intro-rockstar.txt
@@ -0,0 +1,129 @@
+= Use Gerrit to Be a Rockstar Programmer
+
+== Overview
+
+The term _rockstar_ is often used to describe those talented programmers who
+seem to work faster and better than everyone else, much like a composer who
+seems to effortlessly churn out fantastic music. However, just as the
+spontaneity of masterful music is a fantasy, so is the development of
+exceptional code.
+
+The process of composing and then recording music is painstaking — the artist
+records portions of a composition over and over, changing each take until one
+song is completed by combining those many takes into a cohesive whole. The end
+result is the recording of the best performance of the best version of the
+song.
+
+Consider Queen’s six-minute long Bohemian Rhapsody, which took three weeks to
+record. Some segments were overdubbed 180 times!
+
+Software engineering is much the same. Changes that seem logical and
+straightforward in retrospect actually required many revisions and many hours
+of work before they were ready to be merged into a code base. A single
+conceptual code change (_fix bug 123_) often requires numerous iterations
+before it can be finalized. Programmers typically:
+
+* Fix compilation errors
+* Factor out a method, to avoid duplicate code
+* Use a better algorithm, to make it faster
+* Handle error conditions, to make it more robust
+* Add tests, to prevent a bug from regressing
+* Adapt tests, to reflect changed behavior
+* Polish code, to make it easier to read
+* Improve the commit message, to explain why a change was made
+
+In fact, first drafts of code changes are best kept out of project history. Not
+just because rockstar programmers want to hide sloppy first attempts at making
+something work. It's more that keeping intermediate states hampers effective
+use of version control. Git works best when one commit corresponds to one
+functional change, as is required for:
+
+* git revert
+
+* git cherry-pick
+
+* link:https://www.kernel.org/pub/software/scm/git/docs/git-bisect-lk2009.html[git bisect]
+
+
+[[amending]]
+== Amending commits
+
+Git provides a mechanism to continually update a commit until it’s perfect: use
+`git commit --amend` to remake (re-record) a code change. After you update a
+commit in this way, your branch then points to the new commit. However, the
+older (imperfect) revision is not lost. It can be found via the `git reflog`.
+
+
+[[review]]
+== Code review
+
+At least two well-known open source projects insist on these practices:
+
+* link:http://git-scm.com/[Git]
+* link:http://www.kernel.org/category/about.html[Linux Kernel]
+
+However, contributors to these projects don’t refine and polish their changes
+in private until they’re perfect. Instead, polishing code is part of a review
+process — the contributor offers their change to the project for other
+developers to evaluate and critique. This process is called _code review_ and
+results in numerous benefits:
+
+* Code reviews mean that every change effectively has shared authorship
+
+* Developers share knowledge in two directions: Reviewers learn from the patch
+author how the new code they will have to maintain works, and the patch
+author learns from reviewers about best practices used in the project.
+
+* Code review encourages more people to read the code contained in a given
+change. As a result, there are more opportunities to find bugs and suggest
+improvements.
+
+* The more people who read the code, the more bugs can be identified. Since
+code review occurs before code is submitted, bugs are squashed during the
+earliest stage of the software development lifecycle.
+
+* The review process provides a mechanism to enforce team and company policies.
+For example, _all tests shall pass on all platforms_ or _at least two people
+shall sign off on code in production_.
+
+Many successful software companies, including Google, use code review as a
+standard, integral stage in the software development process.
+
+
+[[web]]
+== Web-based code review
+
+To review work, the Git and Linux Kernel projects send patches via email.
+
+Code Review (Gerrit) adds a modern web interface to this workflow. Rather than
+send patches and comments via email, Gerrit users push commits to Gerrit where
+diffs are displayed on a web page. Reviewers can post comments directly on the
+diff. If a change must be reworked, users can push a new, amended revision of
+the same change. Reviewers can then check if the new revision addresses the
+original concerns. If not, the process is repeated.
+
+
+[[magic]]
+== Gerrit’s magic
+
+When you push a change to Gerrit, how does Gerrit detect that the commit amends
+a previous change? Gerrit can’t use the SHA-1, since that value changes when
+`git commit --amend` is called. Fortunately, upon amending a commit, the commit
+message is retained by default.
+
+This is where Gerrit's solution lies: Gerrit identifies a conceptual change
+with a footer in the commit message. Each commit message footer contains a
+Change-Id message hook, which uniquely identifies a change across all its
+drafts. For example:
+
+  `Change-Id: I9e29f5469142cc7fce9e90b0b09f5d2186ff0990`
+
+Thus, if the Change-Id remains the same as commits are amended, Gerrit detects
+that each new version refers to the same conceptual change. The Gerrit web
+interface groups versions so that reviewers can see how your change evolves
+during the code review.
+
+To Gerrit, the identifier can be random.
+
+Gerrit provides a client-side link:cmd-hook-commit-msg.html[message hook] to
+automatically add to commit messages when necessary.
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 436408d..17c9a61 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -383,7 +383,7 @@
 
 How the code modification is applied to the target branch when a change
 is submitted is controlled by the
-link:project-configuration.html#submit_type[submit type] which can be
+link:config-project-config.html#submit-type[submit type] which can be
 link:intro-project-owner.html#submit-type[configured on project-level].
 
 Submitting a change may fail with conflicts. In this case you need to
@@ -548,7 +548,8 @@
 ----
 Alternatively, click *Ready* from the Change screen.
 
-Only change owners, project owners and site administrators can mark changes as
+Change owners, project owners, site administrators and members of a group that
+was granted "Toggle Work In Progress state" permission can mark changes as
 `work-in-progress` and `ready`.
 
 [[wip-polygerrit]]
@@ -565,6 +566,11 @@
 View Private Changes] global capability. Private changes are useful in a number
 of cases:
 
+* You want a set of collaborators to review the change before formal review
+  starts. By creating a Private change and adding only a selected few as
+  reviewers you can control who can see the change and get a first opinion
+  before opening up for all reviewers.
+
 * You want to check what the change looks like before formal review starts.
   By marking the change private without reviewers, nobody can
   prematurely comment on your changes.
@@ -601,7 +607,7 @@
 exposing secret details.
 
 [[ignore]]
-== Ignoring and Muting Changes
+== Ignoring Or Marking Changes As 'Reviewed'
 
 Changes can be ignored, which means they will not appear in the 'Incoming
 Reviews' dashboard and any related email notifications will be suppressed.
@@ -609,9 +615,9 @@
 you do not actively participate in the review, but do not want to completely
 remove yourself.
 
-Alternatively, rather than completely ignoring the change, it can be muted.
-Muting a change means it will always be marked as "reviewed" in dashboards,
-until a new patch set is uploaded.
+Alternatively, rather than completely ignoring the change, it can be marked
+as 'Reviewed'. Marking a change as 'Reviewed' means it will not be highlighted
+in the dashboard, until a new patch set is uploaded.
 
 [[inline-edit]]
 == Inline Edit
@@ -827,6 +833,12 @@
 and download commands. Note that this option is only shown if the Flash plugin
 is available and the JavaScript Clipboard API is unavailable.
 
+- [[work-in-progress-by-default]]`Set new changes work-in-progress`:
++
+Whether new changes are uploaded as work-in-progress per default. This
+preference just sets the default; the behavior can still be overridden using a
+link:user-upload.html#wip[push option].
+
 [[my-menu]]
 In addition it is possible to customize the menu entries of the `My`
 menu. This can be used to make the navigation to frequently used
diff --git a/Documentation/js-api.txt b/Documentation/js-api.txt
index 2df5971..4ef2a6c 100644
--- a/Documentation/js-api.txt
+++ b/Documentation/js-api.txt
@@ -175,7 +175,11 @@
   and link:rest-api-changes.html#revision-info[RevisionInfo] are
   passed as arguments. Similar to a form submit validation, the
   function must return true to allow the operation to continue, or
-  false to prevent it.
+  false to prevent it. The function may be called multiple times, for
+  example, if submitting a change shows a confirmation dialog, this
+  event may be called to validate that the check whether dialog can be
+  shown, and called again when the submit is confirmed to check whether
+  the actual submission action can proceed.
 
 * `comment`: Invoked when a DOM element that represents a comment is
   created. This DOM element is passed as argument. This DOM element
@@ -184,6 +188,12 @@
   comments, file-level comments and summary comments, and it may change
   with new Gerrit versions.
 
+* `highlightjs-loaded`: Invoked when the highlight.js library has
+  finished loading. The global `hljs` object (also now accessible via
+  `window.hljs`) is passed as an argument to the callback function.
+  This event can be used to register a new language highlighter with
+  the highlight.js library before syntax highlighting begins.
+
 [[self_onAction]]
 === self.onAction()
 Register a JavaScript callback to be invoked when the user clicks
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
new file mode 100644
index 0000000..6a83980
--- /dev/null
+++ b/Documentation/js_licenses.txt
@@ -0,0 +1,509 @@
+
+[[Apache2_0]]
+Apache2.0
+
+* fonts:robotofonts
+* js:web-animations-js
+* polymer_externs:polymer_closure
+
+[[Apache2_0_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
+[[ba-linkify]]
+ba-linkify
+
+* js:ba-linkify
+
+[[ba-linkify_license]]
+----
+Copyright (c) 2009 "Cowboy" Ben Alman
+
+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.
+
+----
+
+
+[[es6-promise]]
+es6-promise
+
+* js:es6-promise
+
+[[es6-promise_license]]
+----
+Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
+
+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.
+
+----
+
+
+[[fetch]]
+fetch
+
+* js:fetch
+
+[[fetch_license]]
+----
+Copyright (c) 2014-2016 GitHub, Inc.
+
+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.
+
+----
+
+
+[[highlightjs]]
+highlightjs
+
+* js:highlightjs
+* js:highlightjs_files
+
+[[highlightjs_license]]
+----
+Copyright (c) 2006, Ivan Sagalaev
+All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above copyright
+      notice, this list of conditions and the following disclaimer in the
+      documentation and/or other materials provided with the distribution.
+    * Neither the name of highlight.js nor the names of its contributors
+      may be used to endorse or promote products derived from this software
+      without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE REGENTS 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 REGENTS AND 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.
+----
+
+
+[[moment]]
+moment
+
+* js:moment
+
+[[moment_license]]
+----
+Copyright (c) 2011-2016 Tim Wood, Iskren Chernev, Moment.js contributors
+
+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.
+
+----
+
+
+[[page_js]]
+page.js
+
+* js:page
+
+[[page_js_license]]
+----
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+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.
+
+----
+
+
+[[polymer]]
+polymer
+
+* js:font-roboto
+* js:iron-a11y-announcer
+* js:iron-a11y-keys-behavior
+* js:iron-autogrow-textarea
+* js:iron-behaviors
+* js:iron-checked-element-behavior
+* js:iron-dropdown
+* js:iron-fit-behavior
+* js:iron-flex-layout
+* js:iron-form-element-behavior
+* js:iron-icon
+* js:iron-iconset-svg
+* js:iron-input
+* js:iron-menu-behavior
+* js:iron-meta
+* js:iron-overlay-behavior
+* js:iron-resizable-behavior
+* js:iron-selector
+* js:iron-validatable-behavior
+* js:neon-animation
+* js:paper-behaviors
+* js:paper-button
+* js:paper-icon-button
+* js:paper-input
+* js:paper-item
+* js:paper-listbox
+* js:paper-ripple
+* js:paper-styles
+* js:paper-tabs
+* js:paper-toggle-button
+* js:polymer
+* js:polymer-resin
+* js:webcomponentsjs
+
+[[polymer_license]]
+----
+Copyright (c) 2014 The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[promise-polyfill]]
+promise-polyfill
+
+* js:promise-polyfill
+
+[[promise-polyfill_license]]
+----
+Copyright (c) 2014 Taylor Hakes
+Copyright (c) 2014 Forbes Lindesay
+
+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/Documentation/json.txt b/Documentation/json.txt
index 3b8a8cb..dc82ad1 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -29,6 +29,8 @@
 commitMessage:: The full commit message for the change's current patch
 set.
 
+hashtags:: List of hashtags associated with this change.
+
 createdOn:: Time in seconds since the UNIX epoch when this change
 was created.
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
new file mode 100644
index 0000000..17c1184
--- /dev/null
+++ b/Documentation/licenses.txt
@@ -0,0 +1,3469 @@
+= Gerrit Code Review - Licenses
+
+// DO NOT EDIT - GENERATED AUTOMATICALLY.
+
+Gerrit open source software is licensed under the <<Apache2_0,Apache
+License 2.0>>.  Executable distributions also include other software
+components that are provided under additional licenses.
+
+[[cryptography]]
+== Cryptography Notice
+
+This distribution includes cryptographic software.  The country
+in which you currently reside may have restrictions on the import,
+possession, use, and/or re-export to another country, of encryption
+software.  BEFORE using any encryption software, please check
+your country's laws, regulations and policies concerning the
+import, possession, or use, and re-export of encryption software,
+to see if this is permitted.  See the
+link:http://www.wassenaar.org/[Wassenaar Arrangement]
+for more information.
+
+The U.S. Government Department of Commerce, Bureau of Industry
+and Security (BIS), has classified this software as Export
+Commodity Control Number (ECCN) 5D002.C.1, which includes
+information security software using or performing cryptographic
+functions with asymmetric algorithms.  The form and manner of
+this distribution makes it eligible for export under the License
+Exception ENC Technology Software Unrestricted (TSU) exception
+(see the BIS Export Administration Regulations, Section 740.13)
+for both object code and source code.
+
+Gerrit includes an SSH daemon (Apache SSHD), to support authenticated
+uploads of changes directly from `git push` command line clients.
+
+Gerrit includes an SSH client (JSch), to support authenticated
+replication of changes to remote systems, such as for automatic
+updates of mirror servers, or realtime backups.
+
+== Licenses
+
+
+[[Apache2_0]]
+Apache2.0
+
+* auto:auto-value
+* auto:auto-value-annotations
+* commons:codec
+* commons:compress
+* commons:dbcp
+* commons:lang
+* commons:net
+* commons:pool
+* commons:validator
+* dropwizard:dropwizard-core
+* errorprone:annotations
+* flogger:api
+* fonts:robotofonts
+* guice:guice
+* guice:guice-assistedinject
+* guice:guice-library
+* guice:guice-servlet
+* guice:javax_inject
+* httpcomponents:httpasyncclient
+* httpcomponents:httpclient
+* httpcomponents:httpcore
+* httpcomponents:httpcore-nio
+* jackson:jackson-core
+* jetty:continuation
+* jetty:http
+* jetty:io
+* jetty:jmx
+* jetty:security
+* jetty:server
+* jetty:servlet
+* jetty:util
+* jgit/org.eclipse.jgit:javaewah
+* js:web-animations-js
+* log:json-smart
+* log:jsonevent-layout
+* log:log4j
+* lucene:lucene-analyzers-common
+* lucene:lucene-core-and-backward-codecs
+* lucene:lucene-misc
+* lucene:lucene-queryparser
+* mime4j:core
+* mime4j:dom
+* mina:core
+* mina:sshd
+* openid:consumer
+* openid:nekohtml
+* openid:xerces
+* polymer_externs:polymer_closure
+* blame-cache
+* gson
+* guava
+* guava-failureaccess
+* guava-retrying
+* html-types
+* j2objc
+* jsr305
+* mime-util
+* servlet-api-3_1
+* servlet-api-3_1-without-neverlink
+* soy
+
+[[Apache2_0_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
+[[CC0-1_0]]
+CC0-1.0
+
+* mina:eddsa
+
+[[CC0-1_0_license]]
+----
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
+
+For more information, please see https://creativecommons.org/publicdomain/zero/1.0/
+
+----
+
+
+[[MPL1_1]]
+MPL1.1
+
+* juniversalchardet
+
+[[MPL1_1_license]]
+----
+                          MOZILLA PUBLIC LICENSE
+                                Version 1.1
+
+                              ---------------
+
+1. Definitions.
+
+     1.0.1. "Commercial Use" means distribution or otherwise making the
+     Covered Code available to a third party.
+
+     1.1. "Contributor" means each entity that creates or contributes to
+     the creation of Modifications.
+
+     1.2. "Contributor Version" means the combination of the Original
+     Code, prior Modifications used by a Contributor, and the Modifications
+     made by that particular Contributor.
+
+     1.3. "Covered Code" means the Original Code or Modifications or the
+     combination of the Original Code and Modifications, in each case
+     including portions thereof.
+
+     1.4. "Electronic Distribution Mechanism" means a mechanism generally
+     accepted in the software development community for the electronic
+     transfer of data.
+
+     1.5. "Executable" means Covered Code in any form other than Source
+     Code.
+
+     1.6. "Initial Developer" means the individual or entity identified
+     as the Initial Developer in the Source Code notice required by Exhibit
+     A.
+
+     1.7. "Larger Work" means a work which combines Covered Code or
+     portions thereof with code not governed by the terms of this License.
+
+     1.8. "License" means this document.
+
+     1.8.1. "Licensable" means having the right to grant, to the maximum
+     extent possible, whether at the time of the initial grant or
+     subsequently acquired, any and all of the rights conveyed herein.
+
+     1.9. "Modifications" means any addition to or deletion from the
+     substance or structure of either the Original Code or any previous
+     Modifications. When Covered Code is released as a series of files, a
+     Modification is:
+          A. Any addition to or deletion from the contents of a file
+          containing Original Code or previous Modifications.
+
+          B. Any new file that contains any part of the Original Code or
+          previous Modifications.
+
+     1.10. "Original Code" means Source Code of computer software code
+     which is described in the Source Code notice required by Exhibit A as
+     Original Code, and which, at the time of its release under this
+     License is not already Covered Code governed by this License.
+
+     1.10.1. "Patent Claims" means any patent claim(s), now owned or
+     hereafter acquired, including without limitation,  method, process,
+     and apparatus claims, in any patent Licensable by grantor.
+
+     1.11. "Source Code" means the preferred form of the Covered Code for
+     making modifications to it, including all modules it contains, plus
+     any associated interface definition files, scripts used to control
+     compilation and installation of an Executable, or source code
+     differential comparisons against either the Original Code or another
+     well known, available Covered Code of the Contributor's choice. The
+     Source Code can be in a compressed or archival form, provided the
+     appropriate decompression or de-archiving software is widely available
+     for no charge.
+
+     1.12. "You" (or "Your")  means an individual or a legal entity
+     exercising rights under, and complying with all of the terms of, this
+     License or a future version of this License issued under Section 6.1.
+     For legal entities, "You" includes any entity which controls, is
+     controlled by, or is under common control with You. For purposes of
+     this definition, "control" means (a) the power, direct or indirect,
+     to cause the direction or management of such entity, whether by
+     contract or otherwise, or (b) ownership of more than fifty percent
+     (50%) of the outstanding shares or beneficial ownership of such
+     entity.
+
+2. Source Code License.
+
+     2.1. The Initial Developer Grant.
+     The Initial Developer hereby grants You a world-wide, royalty-free,
+     non-exclusive license, subject to third party intellectual property
+     claims:
+          (a)  under intellectual property rights (other than patent or
+          trademark) Licensable by Initial Developer to use, reproduce,
+          modify, display, perform, sublicense and distribute the Original
+          Code (or portions thereof) with or without Modifications, and/or
+          as part of a Larger Work; and
+
+          (b) under Patents Claims infringed by the making, using or
+          selling of Original Code, to make, have made, use, practice,
+          sell, and offer for sale, and/or otherwise dispose of the
+          Original Code (or portions thereof).
+
+          (c) the licenses granted in this Section 2.1(a) and (b) are
+          effective on the date Initial Developer first distributes
+          Original Code under the terms of this License.
+
+          (d) Notwithstanding Section 2.1(b) above, no patent license is
+          granted: 1) for code that You delete from the Original Code; 2)
+          separate from the Original Code;  or 3) for infringements caused
+          by: i) the modification of the Original Code or ii) the
+          combination of the Original Code with other software or devices.
+
+     2.2. Contributor Grant.
+     Subject to third party intellectual property claims, each Contributor
+     hereby grants You a world-wide, royalty-free, non-exclusive license
+
+          (a)  under intellectual property rights (other than patent or
+          trademark) Licensable by Contributor, to use, reproduce, modify,
+          display, perform, sublicense and distribute the Modifications
+          created by such Contributor (or portions thereof) either on an
+          unmodified basis, with other Modifications, as Covered Code
+          and/or as part of a Larger Work; and
+
+          (b) under Patent Claims infringed by the making, using, or
+          selling of  Modifications made by that Contributor either alone
+          and/or in combination with its Contributor Version (or portions
+          of such combination), to make, use, sell, offer for sale, have
+          made, and/or otherwise dispose of: 1) Modifications made by that
+          Contributor (or portions thereof); and 2) the combination of
+          Modifications made by that Contributor with its Contributor
+          Version (or portions of such combination).
+
+          (c) the licenses granted in Sections 2.2(a) and 2.2(b) are
+          effective on the date Contributor first makes Commercial Use of
+          the Covered Code.
+
+          (d)    Notwithstanding Section 2.2(b) above, no patent license is
+          granted: 1) for any code that Contributor has deleted from the
+          Contributor Version; 2)  separate from the Contributor Version;
+          3)  for infringements caused by: i) third party modifications of
+          Contributor Version or ii)  the combination of Modifications made
+          by that Contributor with other software  (except as part of the
+          Contributor Version) or other devices; or 4) under Patent Claims
+          infringed by Covered Code in the absence of Modifications made by
+          that Contributor.
+
+3. Distribution Obligations.
+
+     3.1. Application of License.
+     The Modifications which You create or to which You contribute are
+     governed by the terms of this License, including without limitation
+     Section 2.2. The Source Code version of Covered Code may be
+     distributed only under the terms of this License or a future version
+     of this License released under Section 6.1, and You must include a
+     copy of this License with every copy of the Source Code You
+     distribute. You may not offer or impose any terms on any Source Code
+     version that alters or restricts the applicable version of this
+     License or the recipients' rights hereunder. However, You may include
+     an additional document offering the additional rights described in
+     Section 3.5.
+
+     3.2. Availability of Source Code.
+     Any Modification which You create or to which You contribute must be
+     made available in Source Code form under the terms of this License
+     either on the same media as an Executable version or via an accepted
+     Electronic Distribution Mechanism to anyone to whom you made an
+     Executable version available; and if made available via Electronic
+     Distribution Mechanism, must remain available for at least twelve (12)
+     months after the date it initially became available, or at least six
+     (6) months after a subsequent version of that particular Modification
+     has been made available to such recipients. You are responsible for
+     ensuring that the Source Code version remains available even if the
+     Electronic Distribution Mechanism is maintained by a third party.
+
+     3.3. Description of Modifications.
+     You must cause all Covered Code to which You contribute to contain a
+     file documenting the changes You made to create that Covered Code and
+     the date of any change. You must include a prominent statement that
+     the Modification is derived, directly or indirectly, from Original
+     Code provided by the Initial Developer and including the name of the
+     Initial Developer in (a) the Source Code, and (b) in any notice in an
+     Executable version or related documentation in which You describe the
+     origin or ownership of the Covered Code.
+
+     3.4. Intellectual Property Matters
+          (a) Third Party Claims.
+          If Contributor has knowledge that a license under a third party's
+          intellectual property rights is required to exercise the rights
+          granted by such Contributor under Sections 2.1 or 2.2,
+          Contributor must include a text file with the Source Code
+          distribution titled "LEGAL" which describes the claim and the
+          party making the claim in sufficient detail that a recipient will
+          know whom to contact. If Contributor obtains such knowledge after
+          the Modification is made available as described in Section 3.2,
+          Contributor shall promptly modify the LEGAL file in all copies
+          Contributor makes available thereafter and shall take other steps
+          (such as notifying appropriate mailing lists or newsgroups)
+          reasonably calculated to inform those who received the Covered
+          Code that new knowledge has been obtained.
+
+          (b) Contributor APIs.
+          If Contributor's Modifications include an application programming
+          interface and Contributor has knowledge of patent licenses which
+          are reasonably necessary to implement that API, Contributor must
+          also include this information in the LEGAL file.
+
+               (c)    Representations.
+          Contributor represents that, except as disclosed pursuant to
+          Section 3.4(a) above, Contributor believes that Contributor's
+          Modifications are Contributor's original creation(s) and/or
+          Contributor has sufficient rights to grant the rights conveyed by
+          this License.
+
+     3.5. Required Notices.
+     You must duplicate the notice in Exhibit A in each file of the Source
+     Code.  If it is not possible to put such notice in a particular Source
+     Code file due to its structure, then You must include such notice in a
+     location (such as a relevant directory) where a user would be likely
+     to look for such a notice.  If You created one or more Modification(s)
+     You may add your name as a Contributor to the notice described in
+     Exhibit A.  You must also duplicate this License in any documentation
+     for the Source Code where You describe recipients' rights or ownership
+     rights relating to Covered Code.  You may choose to offer, and to
+     charge a fee for, warranty, support, indemnity or liability
+     obligations to one or more recipients of Covered Code. However, You
+     may do so only on Your own behalf, and not on behalf of the Initial
+     Developer or any Contributor. You must make it absolutely clear than
+     any such warranty, support, indemnity or liability obligation is
+     offered by You alone, and You hereby agree to indemnify the Initial
+     Developer and every Contributor for any liability incurred by the
+     Initial Developer or such Contributor as a result of warranty,
+     support, indemnity or liability terms You offer.
+
+     3.6. Distribution of Executable Versions.
+     You may distribute Covered Code in Executable form only if the
+     requirements of Section 3.1-3.5 have been met for that Covered Code,
+     and if You include a notice stating that the Source Code version of
+     the Covered Code is available under the terms of this License,
+     including a description of how and where You have fulfilled the
+     obligations of Section 3.2. The notice must be conspicuously included
+     in any notice in an Executable version, related documentation or
+     collateral in which You describe recipients' rights relating to the
+     Covered Code. You may distribute the Executable version of Covered
+     Code or ownership rights under a license of Your choice, which may
+     contain terms different from this License, provided that You are in
+     compliance with the terms of this License and that the license for the
+     Executable version does not attempt to limit or alter the recipient's
+     rights in the Source Code version from the rights set forth in this
+     License. If You distribute the Executable version under a different
+     license You must make it absolutely clear that any terms which differ
+     from this License are offered by You alone, not by the Initial
+     Developer or any Contributor. You hereby agree to indemnify the
+     Initial Developer and every Contributor for any liability incurred by
+     the Initial Developer or such Contributor as a result of any such
+     terms You offer.
+
+     3.7. Larger Works.
+     You may create a Larger Work by combining Covered Code with other code
+     not governed by the terms of this License and distribute the Larger
+     Work as a single product. In such a case, You must make sure the
+     requirements of this License are fulfilled for the Covered Code.
+
+4. Inability to Comply Due to Statute or Regulation.
+
+     If it is impossible for You to comply with any of the terms of this
+     License with respect to some or all of the Covered Code due to
+     statute, judicial order, or regulation then You must: (a) comply with
+     the terms of this License to the maximum extent possible; and (b)
+     describe the limitations and the code they affect. Such description
+     must be included in the LEGAL file described in Section 3.4 and must
+     be included with all distributions of the Source Code. Except to the
+     extent prohibited by statute or regulation, such description must be
+     sufficiently detailed for a recipient of ordinary skill to be able to
+     understand it.
+
+5. Application of this License.
+
+     This License applies to code to which the Initial Developer has
+     attached the notice in Exhibit A and to related Covered Code.
+
+6. Versions of the License.
+
+     6.1. New Versions.
+     Netscape Communications Corporation ("Netscape") may publish revised
+     and/or new versions of the License from time to time. Each version
+     will be given a distinguishing version number.
+
+     6.2. Effect of New Versions.
+     Once Covered Code has been published under a particular version of the
+     License, You may always continue to use it under the terms of that
+     version. You may also choose to use such Covered Code under the terms
+     of any subsequent version of the License published by Netscape. No one
+     other than Netscape has the right to modify the terms applicable to
+     Covered Code created under this License.
+
+     6.3. Derivative Works.
+     If You create or use a modified version of this License (which you may
+     only do in order to apply it to code which is not already Covered Code
+     governed by this License), You must (a) rename Your license so that
+     the phrases "Mozilla", "MOZILLAPL", "MOZPL", "Netscape",
+     "MPL", "NPL" or any confusingly similar phrase do not appear in your
+     license (except to note that your license differs from this License)
+     and (b) otherwise make it clear that Your version of the license
+     contains terms which differ from the Mozilla Public License and
+     Netscape Public License. (Filling in the name of the Initial
+     Developer, Original Code or Contributor in the notice described in
+     Exhibit A shall not of themselves be deemed to be modifications of
+     this License.)
+
+7. DISCLAIMER OF WARRANTY.
+
+     COVERED CODE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" BASIS,
+     WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+     WITHOUT LIMITATION, WARRANTIES THAT THE COVERED CODE IS FREE OF
+     DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING.
+     THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE COVERED CODE
+     IS WITH YOU. SHOULD ANY COVERED CODE PROVE DEFECTIVE IN ANY RESPECT,
+     YOU (NOT THE INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE
+     COST OF ANY NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER
+     OF WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF
+     ANY COVERED CODE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER.
+
+8. TERMINATION.
+
+     8.1.  This License and the rights granted hereunder will terminate
+     automatically if You fail to comply with terms herein and fail to cure
+     such breach within 30 days of becoming aware of the breach. All
+     sublicenses to the Covered Code which are properly granted shall
+     survive any termination of this License. Provisions which, by their
+     nature, must remain in effect beyond the termination of this License
+     shall survive.
+
+     8.2.  If You initiate litigation by asserting a patent infringement
+     claim (excluding declatory judgment actions) against Initial Developer
+     or a Contributor (the Initial Developer or Contributor against whom
+     You file such action is referred to as "Participant")  alleging that:
+
+     (a)  such Participant's Contributor Version directly or indirectly
+     infringes any patent, then any and all rights granted by such
+     Participant to You under Sections 2.1 and/or 2.2 of this License
+     shall, upon 60 days notice from Participant terminate prospectively,
+     unless if within 60 days after receipt of notice You either: (i)
+     agree in writing to pay Participant a mutually agreeable reasonable
+     royalty for Your past and future use of Modifications made by such
+     Participant, or (ii) withdraw Your litigation claim with respect to
+     the Contributor Version against such Participant.  If within 60 days
+     of notice, a reasonable royalty and payment arrangement are not
+     mutually agreed upon in writing by the parties or the litigation claim
+     is not withdrawn, the rights granted by Participant to You under
+     Sections 2.1 and/or 2.2 automatically terminate at the expiration of
+     the 60 day notice period specified above.
+
+     (b)  any software, hardware, or device, other than such Participant's
+     Contributor Version, directly or indirectly infringes any patent, then
+     any rights granted to You by such Participant under Sections 2.1(b)
+     and 2.2(b) are revoked effective as of the date You first made, used,
+     sold, distributed, or had made, Modifications made by that
+     Participant.
+
+     8.3.  If You assert a patent infringement claim against Participant
+     alleging that such Participant's Contributor Version directly or
+     indirectly infringes any patent where such claim is resolved (such as
+     by license or settlement) prior to the initiation of patent
+     infringement litigation, then the reasonable value of the licenses
+     granted by such Participant under Sections 2.1 or 2.2 shall be taken
+     into account in determining the amount or value of any payment or
+     license.
+
+     8.4.  In the event of termination under Sections 8.1 or 8.2 above,
+     all end user license agreements (excluding distributors and resellers)
+     which have been validly granted by You or any distributor hereunder
+     prior to termination shall survive termination.
+
+9. LIMITATION OF LIABILITY.
+
+     UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT
+     (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE INITIAL
+     DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF COVERED CODE,
+     OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE LIABLE TO ANY PERSON FOR
+     ANY INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY
+     CHARACTER INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF GOODWILL,
+     WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER
+     COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN
+     INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF
+     LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL INJURY
+     RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT APPLICABLE LAW
+     PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE
+     EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO
+     THIS EXCLUSION AND LIMITATION MAY NOT APPLY TO YOU.
+
+10. U.S. GOVERNMENT END USERS.
+
+     The Covered Code is a "commercial item," as that term is defined in
+     48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial computer
+     software" and "commercial computer software documentation," as such
+     terms are used in 48 C.F.R. 12.212 (Sept. 1995). Consistent with 48
+     C.F.R. 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4 (June 1995),
+     all U.S. Government End Users acquire Covered Code with only those
+     rights set forth herein.
+
+11. MISCELLANEOUS.
+
+     This License represents the complete agreement concerning subject
+     matter hereof. If any provision of this License is held to be
+     unenforceable, such provision shall be reformed only to the extent
+     necessary to make it enforceable. This License shall be governed by
+     California law provisions (except to the extent applicable law, if
+     any, provides otherwise), excluding its conflict-of-law provisions.
+     With respect to disputes in which at least one party is a citizen of,
+     or an entity chartered or registered to do business in the United
+     States of America, any litigation relating to this License shall be
+     subject to the jurisdiction of the Federal Courts of the Northern
+     District of California, with venue lying in Santa Clara County,
+     California, with the losing party responsible for costs, including
+     without limitation, court costs and reasonable attorneys' fees and
+     expenses. The application of the United Nations Convention on
+     Contracts for the International Sale of Goods is expressly excluded.
+     Any law or regulation which provides that the language of a contract
+     shall be construed against the drafter shall not apply to this
+     License.
+
+12. RESPONSIBILITY FOR CLAIMS.
+
+     As between Initial Developer and the Contributors, each party is
+     responsible for claims and damages arising, directly or indirectly,
+     out of its utilization of rights under this License and You agree to
+     work with Initial Developer and Contributors to distribute such
+     responsibility on an equitable basis. Nothing herein is intended or
+     shall be deemed to constitute any admission of liability.
+
+13. MULTIPLE-LICENSED CODE.
+
+     Initial Developer may designate portions of the Covered Code as
+     "Multiple-Licensed".  "Multiple-Licensed" means that the Initial
+     Developer permits you to utilize portions of the Covered Code under
+     Your choice of the NPL or the alternative licenses, if any, specified
+     by the Initial Developer in the file described in Exhibit A.
+
+EXHIBIT A -Mozilla Public License.
+
+     ``The contents of this file are subject to the Mozilla Public License
+     Version 1.1 (the "License"); you may not use this file except in
+     compliance with the License. You may obtain a copy of the License at
+     http://www.mozilla.org/MPL/
+
+     Software distributed under the License is distributed on an "AS IS"
+     basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
+     License for the specific language governing rights and limitations
+     under the License.
+
+     The Original Code is ______________________________________.
+
+     The Initial Developer of the Original Code is ________________________.
+     Portions created by ______________________ are Copyright (C) ______
+     _______________________. All Rights Reserved.
+
+     Contributor(s): ______________________________________.
+
+     Alternatively, the contents of this file may be used under the terms
+     of the _____ license (the  "[___] License"), in which case the
+     provisions of [______] License are applicable instead of those
+     above.  If you wish to allow use of your version of this file only
+     under the terms of the [____] License and not to allow others to use
+     your version of this file under the MPL, indicate your decision by
+     deleting  the provisions above and replace  them with the notice and
+     other provisions required by the [___] License.  If you do not delete
+     the provisions above, a recipient may use your version of this file
+     under either the MPL or the [___] License."
+
+     [NOTE: The text of this Exhibit A may differ slightly from the text of
+     the notices in the Source Code files of the Original Code. You should
+     use the text of this Exhibit A rather than the text found in the
+     Original Code Source Code for Your Modifications.]
+
+----
+
+
+[[PublicDomain]]
+PublicDomain
+
+* guice:aopalliance
+
+[[PublicDomain_license]]
+----
+This software has been placed in the public domain by its author(s).
+
+----
+
+
+[[antlr]]
+antlr
+
+* antlr:antlr27
+* antlr:java-runtime
+* antlr:stringtemplate
+* antlr:tool
+
+[[antlr_license]]
+----
+Copyright (c) 2003-2008, Terence Parr
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of the author nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[args4j]]
+args4j
+
+* args4j
+
+[[args4j_license]]
+----
+Copyright (c) 2013 Kohsuke Kawaguchi and other contributors
+
+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.
+
+----
+
+
+[[autolink]]
+autolink
+
+* autolink
+
+[[autolink_license]]
+----
+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.
+
+----
+
+
+[[automaton]]
+automaton
+
+* automaton
+
+[[automaton_license]]
+----
+Copyright (c) 2001-2011 Anders Moeller
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright notice,
+      this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above copyright notice,
+      this list of conditions and the following disclaimer in the documentation
+      and/or other materials provided with the distribution.
+    * Neither the name of the JSR305 expert group nor the names of its
+      contributors may be used to endorse or promote products derived from
+      this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[ba-linkify]]
+ba-linkify
+
+* js:ba-linkify
+
+[[ba-linkify_license]]
+----
+Copyright (c) 2009 "Cowboy" Ben Alman
+
+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.
+
+----
+
+
+[[bouncycastle]]
+bouncycastle
+
+* bouncycastle:bcpg-neverlink
+* bouncycastle:bcpkix-neverlink
+* bouncycastle:bcprov-neverlink
+
+[[bouncycastle_license]]
+----
+Copyright (c) 2000 - 2015 The Legion of the Bouncy Castle Inc.
+(http://www.bouncycastle.org)
+
+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.
+
+----
+
+
+[[elasticsearch]]
+elasticsearch
+
+* elasticsearch-rest-client:elasticsearch-rest-client
+
+[[elasticsearch_license]]
+----
+Elasticsearch
+Copyright 2009-2015 Elasticsearch
+
+This product includes software developed by The Apache Software
+Foundation (http://www.apache.org/).
+
+----
+
+
+[[es6-promise]]
+es6-promise
+
+* js:es6-promise
+
+[[es6-promise_license]]
+----
+Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
+
+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.
+
+----
+
+
+[[fetch]]
+fetch
+
+* js:fetch
+
+[[fetch_license]]
+----
+Copyright (c) 2014-2016 GitHub, Inc.
+
+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.
+
+----
+
+
+[[flexmark]]
+flexmark
+
+* flexmark
+* flexmark-ext-abbreviation
+* flexmark-ext-anchorlink
+* flexmark-ext-autolink
+* flexmark-ext-definition
+* flexmark-ext-emoji
+* flexmark-ext-escaped-character
+* flexmark-ext-footnotes
+* flexmark-ext-gfm-issues
+* flexmark-ext-gfm-strikethrough
+* flexmark-ext-gfm-tables
+* flexmark-ext-gfm-tasklist
+* flexmark-ext-gfm-users
+* flexmark-ext-ins
+* flexmark-ext-jekyll-front-matter
+* flexmark-ext-superscript
+* flexmark-ext-tables
+* flexmark-ext-toc
+* flexmark-ext-typographic
+* flexmark-ext-wikilink
+* flexmark-ext-yaml-front-matter
+* flexmark-formatter
+* flexmark-html-parser
+* flexmark-profile-pegdown
+* flexmark-util
+
+[[flexmark_license]]
+----
+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.
+
+----
+
+
+[[h2]]
+h2
+
+* h2
+
+[[h2_license]]
+----
+H2 is dual licensed and available under a modified version of the
+MPL 1.1 (Mozilla Public License) or under the (unmodified) EPL 1.0.
+----
+
+link:http://www.h2database.com/html/license.html[H2 License]
+
+----
+H2 License - Version 1.0
+1. Definitions
+
+1.0.1. "Commercial Use" means distribution or otherwise making the
+       Covered Code available to a third party.
+
+1.1. "Contributor" means each entity that creates or contributes
+     to the creation of Modifications.
+
+1.2. "Contributor Version" means the combination of the Original
+     Code, prior Modifications used by a Contributor, and the
+     Modifications made by that particular Contributor.
+
+1.3. "Covered Code" means the Original Code or Modifications or
+     the combination of the Original Code and Modifications, in each
+     case including portions thereof.
+
+1.4. "Electronic Distribution Mechanism" means a mechanism generally
+     accepted in the software development community for the electronic
+     transfer of data.
+
+1.5. "Executable" means Covered Code in any form other than Source Code.
+
+1.6. "Initial Developer" means the individual or entity identified
+     as the Initial Developer in the Source Code notice required
+     by Exhibit A.
+
+1.7. "Larger Work" means a work which combines Covered Code or
+     portions thereof with code not governed by the terms of this
+     License.
+
+1.8. "License" means this document.
+
+1.8.1. "Licensable" means having the right to grant, to the maximum
+       extent possible, whether at the time of the initial grant
+       or subsequently acquired, any and all of the rights conveyed
+       herein.
+
+1.9. "Modifications" means any addition to or deletion from the
+     substance or structure of either the Original Code or any
+     previous Modifications. When Covered Code is released as a
+     series of files, a Modification is:
+
+1.9.a. Any addition to or deletion from the contents of a file
+       containing Original Code or previous Modifications.
+
+1.9.b. Any new file that contains any part of the Original Code or
+       previous Modifications.
+
+1.10. "Original Code" means Source Code of computer software
+      code which is described in the Source Code notice required
+      by Exhibit A as Original Code, and which, at the time of
+      its release under this License is not already Covered Code
+      governed by this License.
+
+1.10.1. "Patent Claims" means any patent claim(s), now owned or
+        hereafter acquired, including without limitation, method,
+        process, and apparatus claims, in any patent Licensable
+        by grantor.
+
+1.11. "Source Code" means the preferred form of the Covered Code
+      for making modifications to it, including all modules it
+      contains, plus any associated interface definition files,
+      scripts used to control compilation and installation of an
+      Executable, or source code differential comparisons against
+      either the Original Code or another well known, available
+      Covered Code of the Contributor's choice. The Source Code can
+      be in a compressed or archival form, provided the appropriate
+      decompression or de-archiving software is widely available
+      for no charge.
+
+1.12. "You" (or "Your") means an individual or a legal entity
+      exercising rights under, and complying with all of the terms
+      of, this License or a future version of this License issued
+      under Section 6.1. For legal entities, "You" includes any
+      entity which controls, is controlled by, or is under common
+      control with You. For purposes of this definition, "control"
+      means (a) the power, direct or indirect, to cause the direction
+      or management of such entity, whether by contract or otherwise,
+      or (b) ownership of more than fifty percent (50%) of the
+      outstanding shares or beneficial ownership of such entity.
+
+2. Source Code License
+
+2.1. The Initial Developer Grant
+
+The Initial Developer hereby grants You a world-wide, royalty-free,
+non-exclusive license, subject to third party intellectual property
+claims:
+
+2.1.a. under intellectual property rights (other than patent
+       or trademark) Licensable by Initial Developer to use,
+       reproduce, modify, display, perform, sublicense and distribute
+       the Original Code (or portions thereof) with or without
+       Modifications, and/or as part of a Larger Work; and
+
+2.1.b. under Patents Claims infringed by the making, using or selling
+       of Original Code, to make, have made, use, practice, sell,
+       and offer for sale, and/or otherwise dispose of the Original
+       Code (or portions thereof).
+
+2.1.c. the licenses granted in this Section 2.1 (a) and (b) are
+       effective on the date Initial Developer first distributes
+       Original Code under the terms of this License.
+
+2.1.d. Notwithstanding Section 2.1 (b) above, no patent license is
+       granted: 1) for code that You delete from the Original Code;
+       2) separate from the Original Code; or 3) for infringements
+       caused by: i) the modification of the Original Code or ii)
+       the combination of the Original Code with other software
+       or devices.
+
+2.2. Contributor Grant
+
+Subject to third party intellectual property claims, each Contributor
+hereby grants You a world-wide, royalty-free, non-exclusive license
+
+2.2.a. under intellectual property rights (other than patent or
+       trademark) Licensable by Contributor, to use, reproduce,
+       modify, display, perform, sublicense and distribute the
+       Modifications created by such Contributor (or portions
+       thereof) either on an unmodified basis, with other
+       Modifications, as Covered Code and/or as part of a Larger
+       Work; and
+
+2.2.b. under Patent Claims infringed by the making, using, or selling
+       of Modifications made by that Contributor either alone and/or
+       in combination with its Contributor Version (or portions
+       of such combination), to make, use, sell, offer for sale,
+       have made, and/or otherwise dispose of: 1) Modifications
+       made by that Contributor (or portions thereof); and 2) the
+       combination of Modifications made by that Contributor with
+       its Contributor Version (or portions of such combination).
+
+2.2.c. the licenses granted in Sections 2.2 (a) and 2.2 (b) are
+       effective on the date Contributor first makes Commercial
+       Use of the Covered Code.
+
+2.2.c. Notwithstanding Section 2.2 (b) above, no patent license is
+       granted: 1) for any code that Contributor has deleted from
+       the Contributor Version; 2) separate from the Contributor
+       Version; 3) for infringements caused by: i) third party
+       modifications of Contributor Version or ii) the combination
+       of Modifications made by that Contributor with other software
+       (except as part of the Contributor Version) or other devices;
+       or 4) under Patent Claims infringed by Covered Code in the
+       absence of Modifications made by that Contributor.
+
+3. Distribution Obligations
+
+3.1. Application of License
+
+The Modifications which You create or to which You contribute
+are governed by the terms of this License, including without
+limitation Section 2.2. The Source Code version of Covered Code may
+be distributed only under the terms of this License or a future
+version of this License released under Section 6.1, and You must
+include a copy of this License with every copy of the Source Code
+You distribute. You may not offer or impose any terms on any Source
+Code version that alters or restricts the applicable version of
+this License or the recipients' rights hereunder. However, You
+may include an additional document offering the additional rights
+described in Section 3.5.
+
+3.2. Availability of Source Code
+
+Any Modification which You create or to which You contribute must
+be made available in Source Code form under the terms of this
+License either on the same media as an Executable version or via
+an accepted Electronic Distribution Mechanism to anyone to whom
+you made an Executable version available; and if made available
+via Electronic Distribution Mechanism, must remain available for
+at least twelve (12) months after the date it initially became
+available, or at least six (6) months after a subsequent version
+of that particular Modification has been made available to such
+recipients. You are responsible for ensuring that the Source Code
+version remains available even if the Electronic Distribution
+Mechanism is maintained by a third party.
+
+3.3. Description of Modifications
+
+You must cause all Covered Code to which You contribute to contain
+a file documenting the changes You made to create that Covered
+Code and the date of any change. You must include a prominent
+statement that the Modification is derived, directly or indirectly,
+from Original Code provided by the Initial Developer and including
+the name of the Initial Developer in (a) the Source Code, and (b)
+in any notice in an Executable version or related documentation in
+which You describe the origin or ownership of the Covered Code.
+
+3.4. Intellectual Property Matters
+
+3.4.a. Third Party Claims: If Contributor has knowledge that
+       a license under a third party's intellectual property
+       rights is required to exercise the rights granted by such
+       Contributor under Sections 2.1 or 2.2, Contributor must
+       include a text file with the Source Code distribution titled
+       "LEGAL" which describes the claim and the party making the
+       claim in sufficient detail that a recipient will know whom
+       to contact. If Contributor obtains such knowledge after the
+       Modification is made available as described in Section 3.2,
+       Contributor shall promptly modify the LEGAL file in all
+       copies Contributor makes available thereafter and shall take
+       other steps (such as notifying appropriate mailing lists or
+       newsgroups) reasonably calculated to inform those who received
+       the Covered Code that new knowledge has been obtained.
+
+3.4.b. Contributor APIs: If Contributor's Modifications include
+       an application programming interface and Contributor has
+       knowledge of patent licenses which are reasonably necessary
+       to implement that API, Contributor must also include this
+       information in the legal file.
+
+3.4.c. Representations: Contributor represents that, except as
+       disclosed pursuant to Section 3.4 (a) above, Contributor
+       believes that Contributor's Modifications are Contributor's
+       original creation(s) and/or Contributor has sufficient rights
+       to grant the rights conveyed by this License.
+
+3.5. Required Notices
+
+You must duplicate the notice in Exhibit A in each file of
+the Source Code. If it is not possible to put such notice in a
+particular Source Code file due to its structure, then You must
+include such notice in a location (such as a relevant directory)
+where a user would be likely to look for such a notice. If You
+created one or more Modification(s) You may add your name as a
+Contributor to the notice described in Exhibit A. You must also
+duplicate this License in any documentation for the Source Code
+where You describe recipients' rights or ownership rights relating
+to Covered Code. You may choose to offer, and to charge a fee for,
+warranty, support, indemnity or liability obligations to one or
+more recipients of Covered Code. However, You may do so only on
+Your own behalf, and not on behalf of the Initial Developer or
+any Contributor. You must make it absolutely clear than any such
+warranty, support, indemnity or liability obligation is offered by
+You alone, and You hereby agree to indemnify the Initial Developer
+and every Contributor for any liability incurred by the Initial
+Developer or such Contributor as a result of warranty, support,
+indemnity or liability terms You offer.
+
+3.6. Distribution of Executable Versions
+
+You may distribute Covered Code in Executable form only if the
+requirements of Sections 3.1, 3.2, 3.3, 3.4 and 3.5 have been met
+for that Covered Code, and if You include a notice stating that
+the Source Code version of the Covered Code is available under the
+terms of this License, including a description of how and where
+You have fulfilled the obligations of Section 3.2. The notice
+must be conspicuously included in any notice in an Executable
+version, related documentation or collateral in which You describe
+recipients' rights relating to the Covered Code. You may distribute
+the Executable version of Covered Code or ownership rights under
+a license of Your choice, which may contain terms different from
+this License, provided that You are in compliance with the terms
+of this License and that the license for the Executable version
+does not attempt to limit or alter the recipient's rights in the
+Source Code version from the rights set forth in this License. If
+You distribute the Executable version under a different license You
+must make it absolutely clear that any terms which differ from this
+License are offered by You alone, not by the Initial Developer or any
+Contributor. You hereby agree to indemnify the Initial Developer and
+every Contributor for any liability incurred by the Initial Developer
+or such Contributor as a result of any such terms You offer.
+
+3.7. Larger Works
+
+You may create a Larger Work by combining Covered Code with other
+code not governed by the terms of this License and distribute the
+Larger Work as a single product. In such a case, You must make sure
+the requirements of this License are fulfilled for the Covered Code.
+
+4. Inability to Comply Due to Statute or Regulation.
+
+If it is impossible for You to comply with any of the terms of
+this License with respect to some or all of the Covered Code due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description
+must be included in the legal file described in Section 3.4 and
+must be included with all distributions of the Source Code. Except
+to the extent prohibited by statute or regulation, such description
+must be sufficiently detailed for a recipient of ordinary skill to
+be able to understand it.
+
+5. Application of this License.
+
+This License applies to code to which the Initial Developer has
+attached the notice in Exhibit A and to related Covered Code.
+
+6. Versions of the License.
+
+6.1. New Versions
+
+The H2 Group may publish revised and/or new versions of the License
+from time to time. Each version will be given a distinguishing
+version number.
+
+6.2. Effect of New Versions
+
+Once Covered Code has been published under a particular version of
+the License, You may always continue to use it under the terms of
+that version. You may also choose to use such Covered Code under the
+terms of any subsequent version of the License published by the H2
+Group. No one other than the H2 Group has the right to modify the
+terms applicable to Covered Code created under this License.
+
+6.3. Derivative Works
+
+If You create or use a modified version of this License (which you
+may only do in order to apply it to code which is not already Covered
+Code governed by this License), You must (a) rename Your license so
+that the phrases "H2 Group", "H2" or any confusingly similar phrase
+do not appear in your license (except to note that your license
+differs from this License) and (b) otherwise make it clear that
+Your version of the license contains terms which differ from the
+H2 License. (Filling in the name of the Initial Developer, Original
+Code or Contributor in the notice described in Exhibit A shall not
+of themselves be deemed to be modifications of this License.)
+
+7. Disclaimer of Warranty
+
+Covered code is provided under this license on an "as is" basis,
+without warranty of any kind, either expressed or implied,
+including, without limitation, warranties that the covered code
+is free of defects, merchantable, fit for a particular purpose or
+non-infringing. The entire risk as to the quality and performance
+of the covered code is with you. Should any covered code prove
+defective in any respect, you (not the initial developer or any
+other contributor) assume the cost of any necessary servicing,
+repair or correction. This disclaimer of warranty constitutes
+an essential part of this license. No use of any covered code is
+authorized hereunder except under this disclaimer.
+
+8. Termination
+
+8.1. This License and the rights granted hereunder will terminate
+     automatically if You fail to comply with terms herein and
+     fail to cure such breach within 30 days of becoming aware
+     of the breach. All sublicenses to the Covered Code which
+     are properly granted shall survive any termination of this
+     License. Provisions which, by their nature, must remain in
+     effect beyond the termination of this License shall survive.
+
+8.2. If You initiate litigation by asserting a patent infringement
+     claim (excluding declaratory judgment actions) against
+     Initial Developer or a Contributor (the Initial Developer or
+     Contributor against whom You file such action is referred to as
+     "Participant") alleging that:
+
+8.2.a. such Participant's Contributor Version directly or indirectly
+       infringes any patent, then any and all rights granted by
+       such Participant to You under Sections 2.1 and/or 2.2 of this
+       License shall, upon 60 days notice from Participant terminate
+       prospectively, unless if within 60 days after receipt of
+       notice You either: (i) agree in writing to pay Participant
+       a mutually agreeable reasonable royalty for Your past and
+       future use of Modifications made by such Participant, or (ii)
+       withdraw Your litigation claim with respect to the Contributor
+       Version against such Participant. If within 60 days of notice,
+       a reasonable royalty and payment arrangement are not mutually
+       agreed upon in writing by the parties or the litigation claim
+       is not withdrawn, the rights granted by Participant to You
+       under Sections 2.1 and/or 2.2 automatically terminate at
+       the expiration of the 60 day notice period specified above.
+
+8.2.b. any software, hardware, or device, other than such
+       Participant's Contributor Version, directly or indirectly
+       infringes any patent, then any rights granted to You by
+       such Participant under Sections 2.1(b) and 2.2(b) are
+       revoked effective as of the date You first made, used,
+       sold, distributed, or had made, Modifications made by that
+       Participant.
+
+8.3. If You assert a patent infringement claim against Participant
+     alleging that such Participant's Contributor Version directly
+     or indirectly infringes any patent where such claim is resolved
+     (such as by license or settlement) prior to the initiation of
+     patent infringement litigation, then the reasonable value of
+     the licenses granted by such Participant under Sections 2.1
+     or 2.2 shall be taken into account in determining the amount
+     or value of any payment or license.
+
+8.4. In the event of termination under Sections 8.1 or 8.2 above,
+     all end user license agreements (excluding distributors and
+     resellers) which have been validly granted by You or any
+     distributor hereunder prior to termination shall survive
+     termination.
+
+9. Limitation of Liability
+
+Under no circumstances and under no legal theory, whether tort
+(including negligence), contract, or otherwise, shall you, the
+initial developer, any other contributor, or any distributor of
+covered code, or any supplier of any of such parties, be liable to
+any person for any indirect, special, incidental, or consequential
+damages of any character including, without limitation, damages for
+loss of goodwill, work stoppage, computer failure or malfunction, or
+any and all other commercial damages or losses, even if such party
+shall have been informed of the possibility of such damages. This
+limitation of liability shall not apply to liability for death or
+personal injury resulting from such party's negligence to the extent
+applicable law prohibits such limitation. Some jurisdictions do not
+allow the exclusion or limitation of incidental or consequential
+damages, so this exclusion and limitation may not apply to you.
+
+10. United States Government End Users
+
+The Covered Code is a "commercial item", as that term is defined in
+48 C.F.R. 2.101 (October 1995), consisting of "commercial computer
+software" and "commercial computer software documentation", as such
+terms are used in 48 C.F.R. 12.212 (September 1995). Consistent
+with 48 C.F.R. 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4
+(June 1995), all U.S. Government End Users acquire Covered Code
+with only those rights set forth herein.
+
+11. Miscellaneous
+
+This License represents the complete agreement concerning subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. This License shall be governed
+by California law provisions (except to the extent applicable
+law, if any, provides otherwise), excluding its conflict-of-law
+provisions. With respect to disputes in which at least one party is
+a citizen of, or an entity chartered or registered to do business in
+United States of America, any litigation relating to this License
+shall be subject to the jurisdiction of the Federal Courts of the
+Northern District of California, with venue lying in Santa Clara
+County, California, with the losing party responsible for costs,
+including without limitation, court costs and reasonable attorneys'
+fees and expenses. The application of the United Nations Convention
+on Contracts for the International Sale of Goods is expressly
+excluded. Any law or regulation which provides that the language of
+a contract shall be construed against the drafter shall not apply
+to this License.
+
+12. Responsibility for Claims
+
+As between Initial Developer and the Contributors, each party is
+responsible for claims and damages arising, directly or indirectly,
+out of its utilization of rights under this License and You agree
+to work with Initial Developer and Contributors to distribute such
+responsibility on an equitable basis. Nothing herein is intended
+or shall be deemed to constitute any admission of liability.
+
+13. Multiple-Licensed Code
+
+Initial Developer may designate portions of the Covered Code as
+"Multiple-Licensed". "Multiple-Licensed" means that the Initial
+Developer permits you to utilize portions of the Covered Code under
+Your choice of this or the alternative licenses, if any, specified
+by the Initial Developer in the file described in Exhibit A.
+
+Exhibit A
+
+Multiple-Licensed under the H2 License, Version 1.0,
+and under the Eclipse Public License, Version 1.0
+(http://h2database.com/html/license.html).
+Initial Developer: H2 Group
+----
+
+----
+Eclipse Public License - v 1.0
+
+THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
+PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
+OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+a) in the case of the initial Contributor, the initial code and
+   documentation distributed under this Agreement, and
+b) in the case of each subsequent Contributor:
+
+i) changes to the Program, and
+
+ii) additions to the Program;
+
+where such changes and/or additions to the Program originate from
+and are distributed by that particular Contributor. A Contribution
+'originates' from a Contributor if it was added to the Program
+by such Contributor itself or anyone acting on such Contributor's
+behalf. Contributions do not include additions to the Program which:
+(i) are separate modules of software distributed in conjunction
+with the Program under their own license agreement, and (ii) are
+not derivative works of the Program.
+
+"Contributor" means any person or entity that distributes the Program.
+
+"Licensed Patents " mean patent claims licensable by a Contributor
+which are necessarily infringed by the use or sale of its
+Contribution alone or when combined with the Program.
+
+"Program" means the Contributions distributed in accordance with
+this Agreement.
+
+"Recipient" means anyone who receives the Program under this
+Agreement, including all Contributors.
+
+2. GRANT OF RIGHTS
+
+a) Subject to the terms of this Agreement, each Contributor hereby
+   grants Recipient a non-exclusive, worldwide, royalty-free copyright
+   license to reproduce, prepare derivative works of, publicly display,
+   publicly perform, distribute and sublicense the Contribution of such
+   Contributor, if any, and such derivative works, in source code and
+   object code form.
+
+b) Subject to the terms of this Agreement, each Contributor hereby
+   grants Recipient a non-exclusive, worldwide, royalty-free patent
+   license under Licensed Patents to make, use, sell, offer to sell,
+   import and otherwise transfer the Contribution of such Contributor,
+   if any, in source code and object code form. This patent license
+   shall apply to the combination of the Contribution and the Program
+   if, at the time the Contribution is added by the Contributor, such
+   addition of the Contribution causes such combination to be covered
+   by the Licensed Patents. The patent license shall not apply to any
+   other combinations which include the Contribution. No hardware per
+   se is licensed hereunder.
+
+c) Recipient understands that although each Contributor grants the
+   licenses to its Contributions set forth herein, no assurances are
+   provided by any Contributor that the Program does not infringe
+   the patent or other intellectual property rights of any other
+   entity. Each Contributor disclaims any liability to Recipient
+   for claims brought by any other entity based on infringement
+   of intellectual property rights or otherwise. As a condition to
+   exercising the rights and licenses granted hereunder, each Recipient
+   hereby assumes sole responsibility to secure any other intellectual
+   property rights needed, if any. For example, if a third party patent
+   license is required to allow Recipient to distribute the Program,
+   it is Recipient's responsibility to acquire that license before
+   distributing the Program.
+
+d) Each Contributor represents that to its knowledge it has
+   sufficient copyright rights in its Contribution, if any, to grant
+   the copyright license set forth in this Agreement.
+
+3. REQUIREMENTS
+
+A Contributor may choose to distribute the Program in object code
+  form under its own license agreement, provided that:
+
+a) it complies with the terms and conditions of this Agreement; and
+
+b) its license agreement:
+
+i) effectively disclaims on behalf of all Contributors all warranties
+   and conditions, express and implied, including warranties or
+   conditions of title and non-infringement, and implied warranties or
+   conditions of merchantability and fitness for a particular purpose;
+
+ii) effectively excludes on behalf of all Contributors all liability
+    for damages, including direct, indirect, special, incidental and
+    consequential damages, such as lost profits;
+
+iii) states that any provisions which differ from this Agreement
+     are offered by that Contributor alone and not by any other
+     party; and
+
+iv) states that source code for the Program is available from such
+    Contributor, and informs licensees how to obtain it in a reasonable
+    manner on or through a medium customarily used for software exchange.
+
+When the Program is made available in source code form:
+
+a) it must be made available under this Agreement; and
+
+b) a copy of this Agreement must be included with each copy of the Program.
+
+Contributors may not remove or alter any copyright notices contained
+within the Program.
+
+Each Contributor must identify itself as the originator of its
+Contribution, if any, in a manner that reasonably allows subsequent
+Recipients to identify the originator of the Contribution.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain
+responsibilities with respect to end users, business partners and the
+like. While this license is intended to facilitate the commercial
+use of the Program, the Contributor who includes the Program in a
+commercial product offering should do so in a manner which does not
+create potential liability for other Contributors. Therefore, if a
+Contributor includes the Program in a commercial product offering,
+such Contributor ("Commercial Contributor") hereby agrees to defend
+and indemnify every other Contributor ("Indemnified Contributor")
+against any losses, damages and costs (collectively "Losses") arising
+from claims, lawsuits and other legal actions brought by a third
+party against the Indemnified Contributor to the extent caused by
+the acts or omissions of such Commercial Contributor in connection
+with its distribution of the Program in a commercial product
+offering. The obligations in this section do not apply to any claims
+or Losses relating to any actual or alleged intellectual property
+infringement. In order to qualify, an Indemnified Contributor must:
+a) promptly notify the Commercial Contributor in writing of such
+claim, and b) allow the Commercial Contributor to control, and
+cooperate with the Commercial Contributor in, the defense and any
+related settlement negotiations. The Indemnified Contributor may
+participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a
+commercial product offering, Product X. That Contributor is then a
+Commercial Contributor. If that Commercial Contributor then makes
+performance claims, or offers warranties related to Product X, those
+performance claims and warranties are such Commercial Contributor's
+responsibility alone. Under this section, the Commercial Contributor
+would have to defend claims against the other Contributors related
+to those performance claims and warranties, and if a court requires
+any other Contributor to pay any damages as a result, the Commercial
+Contributor must pay those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS
+PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY
+WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY
+OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely
+responsible for determining the appropriateness of using and
+distributing the Program and assumes all risks associated with
+its exercise of rights under this Agreement , including but not
+limited to the risks and costs of program errors, compliance with
+applicable laws, damage to or loss of data, programs or equipment,
+and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT
+NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT,
+INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING WITHOUT LIMITATION LOST PROFITS), 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 OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY
+RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under
+applicable law, it shall not affect the validity or enforceability of
+the remainder of the terms of this Agreement, and without further
+action by the parties hereto, such provision shall be reformed
+to the minimum extent necessary to make such provision valid and
+enforceable.
+
+If Recipient institutes patent litigation against any entity
+(including a cross-claim or counterclaim in a lawsuit) alleging
+that the Program itself (excluding combinations of the Program with
+other software or hardware) infringes such Recipient's patent(s),
+then such Recipient's rights granted under Section 2(b) shall
+terminate as of the date such litigation is filed.
+
+All Recipient's rights under this Agreement shall terminate if
+it fails to comply with any of the material terms or conditions
+of this Agreement and does not cure such failure in a reasonable
+period of time after becoming aware of such noncompliance. If all
+Recipient's rights under this Agreement terminate, Recipient agrees
+to cease use and distribution of the Program as soon as reasonably
+practicable. However, Recipient's obligations under this Agreement
+and any licenses granted by Recipient relating to the Program shall
+continue and survive.
+
+Everyone is permitted to copy and distribute copies of this
+Agreement, but in order to avoid inconsistency the Agreement is
+copyrighted and may only be modified in the following manner. The
+Agreement Steward reserves the right to publish new versions
+(including revisions) of this Agreement from time to time. No
+one other than the Agreement Steward has the right to modify
+this Agreement. The Eclipse Foundation is the initial Agreement
+Steward. The Eclipse Foundation may assign the responsibility to
+serve as the Agreement Steward to a suitable separate entity. Each
+new version of the Agreement will be given a distinguishing
+version number. The Program (including Contributions) may always be
+distributed subject to the version of the Agreement under which it
+was received. In addition, after a new version of the Agreement is
+published, Contributor may elect to distribute the Program (including
+its Contributions) under the new version. Except as expressly stated
+in Sections 2(a) and 2(b) above, Recipient receives no rights or
+licenses to the intellectual property of any Contributor under
+this Agreement, whether expressly, by implication, estoppel or
+otherwise. All rights in the Program not expressly granted under
+this Agreement are reserved.
+
+This Agreement is governed by the laws of the State of New York and
+the intellectual property laws of the United States of America. No
+party to this Agreement will bring a legal action under this
+Agreement more than one year after the cause of action arose. Each
+party waives its rights to a jury trial in any resulting litigation.
+----
+
+----
+Export Control Classification Number (ECCN)
+
+As far as we know, the U.S. Export Control Classification Number
+(ECCN) for this software is 5D002. However, for legal reasons, we
+can make no warranty that this information is correct. For details,
+see also the Apache Software Foundation Export Classifications page.
+
+----
+
+
+[[highlightjs]]
+highlightjs
+
+* js:highlightjs
+* js:highlightjs_files
+
+[[highlightjs_license]]
+----
+Copyright (c) 2006, Ivan Sagalaev
+All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above copyright
+      notice, this list of conditions and the following disclaimer in the
+      documentation and/or other materials provided with the distribution.
+    * Neither the name of highlight.js nor the names of its contributors
+      may be used to endorse or promote products derived from this software
+      without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE REGENTS 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 REGENTS AND 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.
+----
+
+
+[[icu4j]]
+icu4j
+
+* icu4j
+
+[[icu4j_license]]
+----
+COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later)
+
+Copyright © 1991-2016 Unicode, Inc. All rights reserved.
+Distributed under the Terms of Use in http://www.unicode.org/copyright.html
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Unicode data files and any associated documentation
+(the "Data Files") or Unicode software and any associated documentation
+(the "Software") to deal in the Data Files or Software
+without restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, and/or sell copies of
+the Data Files or Software, and to permit persons to whom the Data Files
+or Software are furnished to do so, provided that either
+(a) this copyright and permission notice appear with all copies
+of the Data Files or Software, or
+(b) this copyright and permission notice appear in associated
+Documentation.
+
+THE DATA FILES AND SOFTWARE ARE 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 OF THIRD PARTY RIGHTS.
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS
+NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL
+DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
+DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THE DATA FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder
+shall not be used in advertising or otherwise to promote the sale,
+use or other dealings in these Data Files or Software without prior
+written authorization of the copyright holder.
+
+---------------------
+
+Third-Party Software Licenses
+
+This section contains third-party software notices and/or additional
+terms for licensed third-party software components included within ICU
+libraries.
+
+1. ICU License - ICU 1.8.1 to ICU 57.1
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright (c) 1995-2016 International Business Machines Corporation and others
+All rights reserved.
+
+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, and/or sell copies of the Software, and to permit persons
+to whom the Software is furnished to do so, provided that the above
+copyright notice(s) and this permission notice appear in all copies of
+the Software and that both the above copyright notice(s) and this
+permission notice appear in supporting documentation.
+
+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
+OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY
+SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER
+RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
+CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder
+shall not be used in advertising or otherwise to promote the sale, use
+or other dealings in this Software without prior written authorization
+of the copyright holder.
+
+All trademarks and registered trademarks mentioned herein are the
+property of their respective owners.
+
+2. Chinese/Japanese Word Break Dictionary Data (cjdict.txt)
+
+ #     The Google Chrome software developed by Google is licensed under
+ # the BSD license. Other software included in this distribution is
+ # provided under other licenses, as set forth below.
+ #
+ #  The BSD License
+ #  http://opensource.org/licenses/bsd-license.php
+ #  Copyright (C) 2006-2008, Google Inc.
+ #
+ #  All rights reserved.
+ #
+ #  Redistribution and use in source and binary forms, with or without
+ # modification, are permitted provided that the following conditions are met:
+ #
+ #  Redistributions of source code must retain the above copyright notice,
+ # this list of conditions and the following disclaimer.
+ #  Redistributions in binary form must reproduce the above
+ # copyright notice, this list of conditions and the following
+ # disclaimer in the documentation and/or other materials provided with
+ # the distribution.
+ #  Neither the name of  Google Inc. nor the names of its
+ # contributors may be used to endorse or promote products derived from
+ # this software without specific prior written permission.
+ #
+ #
+ #  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+ # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ #
+ #
+ #  The word list in cjdict.txt are generated by combining three word lists
+ # listed below with further processing for compound word breaking. The
+ # frequency is generated with an iterative training against Google web
+ # corpora.
+ #
+ #  * Libtabe (Chinese)
+ #    - https://sourceforge.net/project/?group_id=1519
+ #    - Its license terms and conditions are shown below.
+ #
+ #  * IPADIC (Japanese)
+ #    - http://chasen.aist-nara.ac.jp/chasen/distribution.html
+ #    - Its license terms and conditions are shown below.
+ #
+ #  ---------COPYING.libtabe ---- BEGIN--------------------
+ #
+ #  /*
+ #   * Copyrighy (c) 1999 TaBE Project.
+ #   * Copyright (c) 1999 Pai-Hsiang Hsiao.
+ #   * All rights reserved.
+ #   *
+ #   * Redistribution and use in source and binary forms, with or without
+ #   * modification, are permitted provided that the following conditions
+ #   * are met:
+ #   *
+ #   * . Redistributions of source code must retain the above copyright
+ #   *   notice, this list of conditions and the following disclaimer.
+ #   * . Redistributions in binary form must reproduce the above copyright
+ #   *   notice, this list of conditions and the following disclaimer in
+ #   *   the documentation and/or other materials provided with the
+ #   *   distribution.
+ #   * . Neither the name of the TaBE Project nor the names of its
+ #   *   contributors may be used to endorse or promote products derived
+ #   *   from this software without specific prior written permission.
+ #   *
+ #   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ #   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ #   * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ #   * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ #   * REGENTS 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.
+ #   */
+ #
+ #  /*
+ #   * Copyright (c) 1999 Computer Systems and Communication Lab,
+ #   *                    Institute of Information Science, Academia
+ #       *                    Sinica. All rights reserved.
+ #   *
+ #   * Redistribution and use in source and binary forms, with or without
+ #   * modification, are permitted provided that the following conditions
+ #   * are met:
+ #   *
+ #   * . Redistributions of source code must retain the above copyright
+ #   *   notice, this list of conditions and the following disclaimer.
+ #   * . Redistributions in binary form must reproduce the above copyright
+ #   *   notice, this list of conditions and the following disclaimer in
+ #   *   the documentation and/or other materials provided with the
+ #   *   distribution.
+ #   * . Neither the name of the Computer Systems and Communication Lab
+ #   *   nor the names of its contributors may be used to endorse or
+ #   *   promote products derived from this software without specific
+ #   *   prior written permission.
+ #   *
+ #   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ #   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ #   * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ #   * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ #   * REGENTS 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.
+ #   */
+ #
+ #  Copyright 1996 Chih-Hao Tsai @ Beckman Institute,
+ #      University of Illinois
+ #  c-tsai4@uiuc.edu  http://casper.beckman.uiuc.edu/~c-tsai4
+ #
+ #  ---------------COPYING.libtabe-----END--------------------------------
+ #
+ #
+ #  ---------------COPYING.ipadic-----BEGIN-------------------------------
+ #
+ #  Copyright 2000, 2001, 2002, 2003 Nara Institute of Science
+ #  and Technology.  All Rights Reserved.
+ #
+ #  Use, reproduction, and distribution of this software is permitted.
+ #  Any copy of this software, whether in its original form or modified,
+ #  must include both the above copyright notice and the following
+ #  paragraphs.
+ #
+ #  Nara Institute of Science and Technology (NAIST),
+ #  the copyright holders, disclaims all warranties with regard to this
+ #  software, including all implied warranties of merchantability and
+ #  fitness, in no event shall NAIST be liable for
+ #  any special, indirect or consequential damages or any damages
+ #  whatsoever resulting from loss of use, data or profits, whether in an
+ #  action of contract, negligence or other tortuous action, arising out
+ #  of or in connection with the use or performance of this software.
+ #
+ #  A large portion of the dictionary entries
+ #  originate from ICOT Free Software.  The following conditions for ICOT
+ #  Free Software applies to the current dictionary as well.
+ #
+ #  Each User may also freely distribute the Program, whether in its
+ #  original form or modified, to any third party or parties, PROVIDED
+ #  that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear
+ #  on, or be attached to, the Program, which is distributed substantially
+ #  in the same form as set out herein and that such intended
+ #  distribution, if actually made, will neither violate or otherwise
+ #  contravene any of the laws and regulations of the countries having
+ #  jurisdiction over the User or the intended distribution itself.
+ #
+ #  NO WARRANTY
+ #
+ #  The program was produced on an experimental basis in the course of the
+ #  research and development conducted during the project and is provided
+ #  to users as so produced on an experimental basis.  Accordingly, the
+ #  program is provided without any warranty whatsoever, whether express,
+ #  implied, statutory or otherwise.  The term "warranty" used herein
+ #  includes, but is not limited to, any warranty of the quality,
+ #  performance, merchantability and fitness for a particular purpose of
+ #  the program and the nonexistence of any infringement or violation of
+ #  any right of any third party.
+ #
+ #  Each user of the program will agree and understand, and be deemed to
+ #  have agreed and understood, that there is no warranty whatsoever for
+ #  the program and, accordingly, the entire risk arising from or
+ #  otherwise connected with the program is assumed by the user.
+ #
+ #  Therefore, neither ICOT, the copyright holder, or any other
+ #  organization that participated in or was otherwise related to the
+ #  development of the program and their respective officials, directors,
+ #  officers and other employees shall be held liable for any and all
+ #  damages, including, without limitation, general, special, incidental
+ #  and consequential damages, arising out of or otherwise in connection
+ #  with the use or inability to use the program or any product, material
+ #  or result produced or otherwise obtained by using the program,
+ #  regardless of whether they have been advised of, or otherwise had
+ #  knowledge of, the possibility of such damages at any time during the
+ #  project or thereafter.  Each user will be deemed to have agreed to the
+ #  foregoing by his or her commencement of use of the program.  The term
+ #  "use" as used herein includes, but is not limited to, the use,
+ #  modification, copying and distribution of the program and the
+ #  production of secondary products from the program.
+ #
+ #  In the case where the program, whether in its original form or
+ #  modified, was distributed or delivered to or received by a user from
+ #  any person, organization or entity other than ICOT, unless it makes or
+ #  grants independently of ICOT any specific warranty to the user in
+ #  writing, such person, organization or entity, will also be exempted
+ #  from and not be held liable to the user for any such damages as noted
+ #  above as far as the program is concerned.
+ #
+ #  ---------------COPYING.ipadic-----END----------------------------------
+
+3. Lao Word Break Dictionary Data (laodict.txt)
+
+ #  Copyright (c) 2013 International Business Machines Corporation
+ #  and others. All Rights Reserved.
+ #
+ # Project: http://code.google.com/p/lao-dictionary/
+ # Dictionary: http://lao-dictionary.googlecode.com/git/Lao-Dictionary.txt
+ # License: http://lao-dictionary.googlecode.com/git/Lao-Dictionary-LICENSE.txt
+ #              (copied below)
+ #
+ #  This file is derived from the above dictionary, with slight
+ #  modifications.
+ #  ----------------------------------------------------------------------
+ #  Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell.
+ #  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.
+ #  --------------------------------------------------------------------------
+
+4. Burmese Word Break Dictionary Data (burmesedict.txt)
+
+ #  Copyright (c) 2014 International Business Machines Corporation
+ #  and others. All Rights Reserved.
+ #
+ #  This list is part of a project hosted at:
+ #    github.com/kanyawtech/myanmar-karen-word-lists
+ #
+ #  --------------------------------------------------------------------------
+ #  Copyright (c) 2013, LeRoy Benjamin Sharon
+ #  All rights reserved.
+ #
+ #  Redistribution and use in source and binary forms, with or without
+ #  modification, are permitted provided that the following conditions
+ #  are met: Redistributions of source code must retain the above
+ #  copyright notice, this list of conditions and the following
+ #  disclaimer.  Redistributions in binary form must reproduce the
+ #  above copyright notice, this list of conditions and the following
+ #  disclaimer in the documentation and/or other materials provided
+ #  with the distribution.
+ #
+ #    Neither the name Myanmar Karen Word Lists, nor the names of its
+ #    contributors may be used to endorse or promote products derived
+ #    from this software without specific prior written permission.
+ #
+ #  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ #  CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ #  INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ #  MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ #  DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 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.
+ #  --------------------------------------------------------------------------
+
+5. Time Zone Database
+
+  ICU uses the public domain data and code derived from Time Zone
+Database for its time zone support. The ownership of the TZ database
+is explained in BCP 175: Procedure for Maintaining the Time Zone
+Database section 7.
+
+ # 7.  Database Ownership
+ #
+ #    The TZ database itself is not an IETF Contribution or an IETF
+ #    document.  Rather it is a pre-existing and regularly updated work
+ #    that is in the public domain, and is intended to remain in the
+ #    public domain.  Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do
+ #    not apply to the TZ Database or contributions that individuals make
+ #    to it.  Should any claims be made and substantiated against the TZ
+ #    Database, the organization that is providing the IANA
+ #    Considerations defined in this RFC, under the memorandum of
+ #    understanding with the IETF, currently ICANN, may act in accordance
+ #    with all competent court orders.  No ownership claims will be made
+ #    by ICANN or the IETF Trust on the database or the code.  Any person
+ #    making a contribution to the database or code waives all rights to
+ #    future claims in that contribution or in the TZ Database.
+
+----
+
+
+[[jgit]]
+jgit
+
+* jgit/org.eclipse.jgit.archive:jgit-archive
+* jgit/org.eclipse.jgit.http.server:jgit-servlet
+* jgit/org.eclipse.jgit:jgit
+
+[[jgit_license]]
+----
+This program and the accompanying materials are made available
+under the terms of the Eclipse Distribution License v1.0 which
+accompanies this distribution, is reproduced below, and is
+available at http://www.eclipse.org/org/documents/edl-v10.php
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or
+without modification, are permitted provided that the following
+conditions are met:
+
+- Redistributions of source code must retain the above copyright
+  notice, this list of conditions and the following disclaimer.
+
+- Redistributions in binary form must reproduce the above
+  copyright notice, this list of conditions and the following
+  disclaimer in the documentation and/or other materials provided
+  with the distribution.
+
+- Neither the name of the Eclipse Foundation, Inc. nor the
+  names of its contributors may be used to endorse or promote
+  products derived from this software without specific prior
+  written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[jsch]]
+jsch
+
+* jsch
+
+[[jsch_license]]
+----
+Copyright (c) 2002-2012 Atsuhiko Yamanaka, JCraft,Inc.
+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 names of the authors may not be used to endorse or promote products
+     derived from this software without specific prior written permission.
+
+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 JCRAFT,
+INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE 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.
+
+----
+
+
+[[jsoup]]
+jsoup
+
+* jsoup:jsoup
+
+[[jsoup_license]]
+----
+The MIT License
+
+© 2009-2016, Jonathan Hedley <jonathan@hedley.net>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+----
+
+
+[[moment]]
+moment
+
+* js:moment
+
+[[moment_license]]
+----
+Copyright (c) 2011-2016 Tim Wood, Iskren Chernev, Moment.js contributors
+
+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.
+
+----
+
+
+[[ow2]]
+ow2
+
+* ow2:ow2-asm
+* ow2:ow2-asm-analysis
+* ow2:ow2-asm-commons
+* ow2:ow2-asm-tree
+* ow2:ow2-asm-util
+
+[[ow2_license]]
+----
+Copyright (c) 2000-2011 INRIA, France Telecom
+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. Neither the name of the copyright holders nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[page_js]]
+page.js
+
+* js:page
+
+[[page_js_license]]
+----
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+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.
+
+----
+
+
+[[polymer]]
+polymer
+
+* js:font-roboto
+* js:iron-a11y-announcer
+* js:iron-a11y-keys-behavior
+* js:iron-autogrow-textarea
+* js:iron-behaviors
+* js:iron-checked-element-behavior
+* js:iron-dropdown
+* js:iron-fit-behavior
+* js:iron-flex-layout
+* js:iron-form-element-behavior
+* js:iron-icon
+* js:iron-iconset-svg
+* js:iron-input
+* js:iron-menu-behavior
+* js:iron-meta
+* js:iron-overlay-behavior
+* js:iron-resizable-behavior
+* js:iron-selector
+* js:iron-validatable-behavior
+* js:neon-animation
+* js:paper-behaviors
+* js:paper-button
+* js:paper-icon-button
+* js:paper-input
+* js:paper-item
+* js:paper-listbox
+* js:paper-ripple
+* js:paper-styles
+* js:paper-tabs
+* js:paper-toggle-button
+* js:polymer
+* js:polymer-resin
+* js:webcomponentsjs
+
+[[polymer_license]]
+----
+Copyright (c) 2014 The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[prologcafe]]
+prologcafe
+
+* prolog:cafeteria
+* prolog:compiler
+* prolog:io
+* prolog:runtime
+
+[[prologcafe_license]]
+----
+Prolog Cafe (A Prolog to Java Translator System)
+Copyright (C) 1997-2009 by Mutsunori Banbara and Naoyuki Tamura
+
+Prolog Cafe is free software; you can redistribute it and/or modify
+it under the terms of either:
+
+  * the GNU General Public License as published by the Free Software
+    Foundation; either version 2 of the License, or (at your option)
+    any later version, or
+
+  * the Eclipse Public License
+----
+
+In the context of Gerrit Code Review, Prolog Cafe is consumed under
+the <<prologcafe_EPL,EPL>>. Gerrit Code Review uses a fork derived
+from the 1.2.5 release and offers the corresponding source code at
+link:https://gerrit.googlesource.com/prolog-cafe[].
+
+----
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                            NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
+----
+
+[[prologcafe_EPL]]
+----
+Eclipse Public License - v 1.0
+
+THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
+PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
+OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+a) in the case of the initial Contributor, the initial code and
+   documentation distributed under this Agreement, and
+b) in the case of each subsequent Contributor:
+
+i) changes to the Program, and
+
+ii) additions to the Program;
+
+where such changes and/or additions to the Program originate from
+and are distributed by that particular Contributor. A Contribution
+'originates' from a Contributor if it was added to the Program
+by such Contributor itself or anyone acting on such Contributor's
+behalf. Contributions do not include additions to the Program which:
+(i) are separate modules of software distributed in conjunction
+with the Program under their own license agreement, and (ii) are
+not derivative works of the Program.
+
+"Contributor" means any person or entity that distributes the Program.
+
+"Licensed Patents " mean patent claims licensable by a Contributor
+which are necessarily infringed by the use or sale of its
+Contribution alone or when combined with the Program.
+
+"Program" means the Contributions distributed in accordance with
+this Agreement.
+
+"Recipient" means anyone who receives the Program under this
+Agreement, including all Contributors.
+
+2. GRANT OF RIGHTS
+
+a) Subject to the terms of this Agreement, each Contributor hereby
+   grants Recipient a non-exclusive, worldwide, royalty-free copyright
+   license to reproduce, prepare derivative works of, publicly display,
+   publicly perform, distribute and sublicense the Contribution of such
+   Contributor, if any, and such derivative works, in source code and
+   object code form.
+
+b) Subject to the terms of this Agreement, each Contributor hereby
+   grants Recipient a non-exclusive, worldwide, royalty-free patent
+   license under Licensed Patents to make, use, sell, offer to sell,
+   import and otherwise transfer the Contribution of such Contributor,
+   if any, in source code and object code form. This patent license
+   shall apply to the combination of the Contribution and the Program
+   if, at the time the Contribution is added by the Contributor, such
+   addition of the Contribution causes such combination to be covered
+   by the Licensed Patents. The patent license shall not apply to any
+   other combinations which include the Contribution. No hardware per
+   se is licensed hereunder.
+
+c) Recipient understands that although each Contributor grants the
+   licenses to its Contributions set forth herein, no assurances are
+   provided by any Contributor that the Program does not infringe
+   the patent or other intellectual property rights of any other
+   entity. Each Contributor disclaims any liability to Recipient
+   for claims brought by any other entity based on infringement
+   of intellectual property rights or otherwise. As a condition to
+   exercising the rights and licenses granted hereunder, each Recipient
+   hereby assumes sole responsibility to secure any other intellectual
+   property rights needed, if any. For example, if a third party patent
+   license is required to allow Recipient to distribute the Program,
+   it is Recipient's responsibility to acquire that license before
+   distributing the Program.
+
+d) Each Contributor represents that to its knowledge it has
+   sufficient copyright rights in its Contribution, if any, to grant
+   the copyright license set forth in this Agreement.
+
+3. REQUIREMENTS
+
+A Contributor may choose to distribute the Program in object code
+  form under its own license agreement, provided that:
+
+a) it complies with the terms and conditions of this Agreement; and
+
+b) its license agreement:
+
+i) effectively disclaims on behalf of all Contributors all warranties
+   and conditions, express and implied, including warranties or
+   conditions of title and non-infringement, and implied warranties or
+   conditions of merchantability and fitness for a particular purpose;
+
+ii) effectively excludes on behalf of all Contributors all liability
+    for damages, including direct, indirect, special, incidental and
+    consequential damages, such as lost profits;
+
+iii) states that any provisions which differ from this Agreement
+     are offered by that Contributor alone and not by any other
+     party; and
+
+iv) states that source code for the Program is available from such
+    Contributor, and informs licensees how to obtain it in a reasonable
+    manner on or through a medium customarily used for software exchange.
+
+When the Program is made available in source code form:
+
+a) it must be made available under this Agreement; and
+
+b) a copy of this Agreement must be included with each copy of the Program.
+
+Contributors may not remove or alter any copyright notices contained
+within the Program.
+
+Each Contributor must identify itself as the originator of its
+Contribution, if any, in a manner that reasonably allows subsequent
+Recipients to identify the originator of the Contribution.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain
+responsibilities with respect to end users, business partners and the
+like. While this license is intended to facilitate the commercial
+use of the Program, the Contributor who includes the Program in a
+commercial product offering should do so in a manner which does not
+create potential liability for other Contributors. Therefore, if a
+Contributor includes the Program in a commercial product offering,
+such Contributor ("Commercial Contributor") hereby agrees to defend
+and indemnify every other Contributor ("Indemnified Contributor")
+against any losses, damages and costs (collectively "Losses") arising
+from claims, lawsuits and other legal actions brought by a third
+party against the Indemnified Contributor to the extent caused by
+the acts or omissions of such Commercial Contributor in connection
+with its distribution of the Program in a commercial product
+offering. The obligations in this section do not apply to any claims
+or Losses relating to any actual or alleged intellectual property
+infringement. In order to qualify, an Indemnified Contributor must:
+a) promptly notify the Commercial Contributor in writing of such
+claim, and b) allow the Commercial Contributor to control, and
+cooperate with the Commercial Contributor in, the defense and any
+related settlement negotiations. The Indemnified Contributor may
+participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a
+commercial product offering, Product X. That Contributor is then a
+Commercial Contributor. If that Commercial Contributor then makes
+performance claims, or offers warranties related to Product X, those
+performance claims and warranties are such Commercial Contributor's
+responsibility alone. Under this section, the Commercial Contributor
+would have to defend claims against the other Contributors related
+to those performance claims and warranties, and if a court requires
+any other Contributor to pay any damages as a result, the Commercial
+Contributor must pay those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS
+PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY
+WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY
+OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely
+responsible for determining the appropriateness of using and
+distributing the Program and assumes all risks associated with
+its exercise of rights under this Agreement , including but not
+limited to the risks and costs of program errors, compliance with
+applicable laws, damage to or loss of data, programs or equipment,
+and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT
+NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT,
+INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING WITHOUT LIMITATION LOST PROFITS), 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 OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY
+RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under
+applicable law, it shall not affect the validity or enforceability of
+the remainder of the terms of this Agreement, and without further
+action by the parties hereto, such provision shall be reformed
+to the minimum extent necessary to make such provision valid and
+enforceable.
+
+If Recipient institutes patent litigation against any entity
+(including a cross-claim or counterclaim in a lawsuit) alleging
+that the Program itself (excluding combinations of the Program with
+other software or hardware) infringes such Recipient's patent(s),
+then such Recipient's rights granted under Section 2(b) shall
+terminate as of the date such litigation is filed.
+
+All Recipient's rights under this Agreement shall terminate if
+it fails to comply with any of the material terms or conditions
+of this Agreement and does not cure such failure in a reasonable
+period of time after becoming aware of such noncompliance. If all
+Recipient's rights under this Agreement terminate, Recipient agrees
+to cease use and distribution of the Program as soon as reasonably
+practicable. However, Recipient's obligations under this Agreement
+and any licenses granted by Recipient relating to the Program shall
+continue and survive.
+
+Everyone is permitted to copy and distribute copies of this
+Agreement, but in order to avoid inconsistency the Agreement is
+copyrighted and may only be modified in the following manner. The
+Agreement Steward reserves the right to publish new versions
+(including revisions) of this Agreement from time to time. No
+one other than the Agreement Steward has the right to modify
+this Agreement. The Eclipse Foundation is the initial Agreement
+Steward. The Eclipse Foundation may assign the responsibility to
+serve as the Agreement Steward to a suitable separate entity. Each
+new version of the Agreement will be given a distinguishing
+version number. The Program (including Contributions) may always be
+distributed subject to the version of the Agreement under which it
+was received. In addition, after a new version of the Agreement is
+published, Contributor may elect to distribute the Program (including
+its Contributions) under the new version. Except as expressly stated
+in Sections 2(a) and 2(b) above, Recipient receives no rights or
+licenses to the intellectual property of any Contributor under
+this Agreement, whether expressly, by implication, estoppel or
+otherwise. All rights in the Program not expressly granted under
+this Agreement are reserved.
+
+This Agreement is governed by the laws of the State of New York and
+the intellectual property laws of the United States of America. No
+party to this Agreement will bring a legal action under this
+Agreement more than one year after the cause of action arose. Each
+party waives its rights to a jury trial in any resulting litigation.
+
+----
+
+
+[[promise-polyfill]]
+promise-polyfill
+
+* js:promise-polyfill
+
+[[promise-polyfill_license]]
+----
+Copyright (c) 2014 Taylor Hakes
+Copyright (c) 2014 Forbes Lindesay
+
+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.
+
+----
+
+
+[[protobuf]]
+protobuf
+
+* protobuf
+
+[[protobuf_license]]
+----
+Copyright 2008, Google Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+    * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+Code generated by the Protocol Buffer compiler is owned by the owner
+of the input file used when generating it.  This code is not
+standalone and requires a support library to be linked with it.  This
+support library is itself covered by the above license.
+
+----
+
+
+[[slf4j]]
+slf4j
+
+* log:api
+* log:jcl-over-slf4j
+
+[[slf4j_license]]
+----
+Copyright (c) 2004-2008 QOS.ch
+All rights reserved.
+
+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.
+
+----
+
+
+[[xz]]
+xz
+
+* tukaani-xz
+
+[[xz_license]]
+----
+All the files in this package have been written by Lasse Collin
+and/or Igor Pavlov. All these files have been put into the
+public domain. You can do whatever you want with these files.
+This software is provided "as is", without any warranty.
+
+----
+
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 2464c3a..c2dcedb 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
 
@@ -42,7 +43,8 @@
 From the command line, enter:
 
 ....
-java -jar gerrit*.war init --batch --dev -d ~/gerrit_testsite
+export GERRIT_SITE=~/gerrit_testsite
+java -jar gerrit*.war init --batch --dev -d $GERRIT_SITE
 ....
 
 This command takes two parameters:
@@ -77,7 +79,7 @@
 `localhost`. For example:
 
 ....
-git config --file ~/gerrit_testsite/etc/gerrit.config httpd.listenUrl 'http://localhost:8080'
+git config --file $GERRIT_SITE/etc/gerrit.config httpd.listenUrl 'http://localhost:8080'
 ....
 
 == Restart the Gerrit service
@@ -86,7 +88,7 @@
 changes to take effect:
 
 ....
-~/gerrit_testsite/bin/gerrit.sh restart
+$GERRIT_SITE/bin/gerrit.sh restart
 ....
 
 == Viewing Gerrit
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 19d3b41..63cbd7c 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -15,11 +15,22 @@
 
 === Actions
 
-* `action/retry_attempt_counts`: Distribution of number of attempts made
-by RetryHelper to execute an action (1 == single attempt, no retry)
+* `action/retry_attempt_count`: Number of retry attempts made
+by RetryHelper to execute an action (0 == single attempt, no retry)
 * `action/retry_timeout_count`: Number of action executions of RetryHelper
 that ultimately timed out
 
+=== Pushes
+
+* `receivecommits/changes`: histogram of number of changes processed
+in a single upload, split up by update type (change created/updated,
+change autoclosed).
+* `receivecommits/latency`: latency per change for processing a push,
+split up by update type (create+replace, and autoclose)
+* `receivecommits/push_latency`: total latency for processing a push,
+split up by update type (create+replace, autoclose, normal)
+* `receivecommits/timeout`: number of timeouts during push processing.
+
 === Process
 
 * `proc/birth_timestamp`: Time at which the Gerrit process started.
@@ -97,10 +108,6 @@
 * `sshd/sessions/created`: Rate of new SSH sessions.
 * `sshd/sessions/authentication_failures`: Rate of SSH authentication failures.
 
-=== SQL connections
-
-* `sql/connection_pool/connections`: SQL database connections.
-
 === Topics
 
 * `topic/cross_project_submit`: number of cross-project topic submissions.
@@ -131,13 +138,24 @@
 * `notedb/stage_update_latency`: Latency for staging updates to NoteDb by table.
 * `notedb/read_latency`: NoteDb read latency by table.
 * `notedb/parse_latency`: NoteDb parse latency by table.
-* `notedb/auto_rebuild_latency`: NoteDb auto-rebuilding latency by table.
-* `notedb/auto_rebuild_failure_count`: NoteDb auto-rebuilding attempts that
-failed by table.
+* `notedb/external_id_cache_load_count`: Total number of times the external ID
+  cache loader was called.
+* `notedb/external_id_partial_read_latency`: Latency for generating a new external ID
+  cache state from a prior state.
 * `notedb/external_id_update_count`: Total number of external ID updates.
 * `notedb/read_all_external_ids_latency`: Latency for reading all
 external ID's from NoteDb.
 
+=== Permissions
+
+* `permissions/project_state/computation_latency`: Latency to compute current access
+sections on a project by traversing it's parents.
+* `permissions/permission_collection/filter_latency`: Latency to filter access sections
+by user and ref.
+* `permissions/ref_filter/full_filter_count`: Rate of full ref filter operations
+* `permissions/ref_filter/skip_filter_count`: Rate of ref filter operations where
+we skip full evaluation because the user can read all refs
+
 === Reviewer Suggestion
 
 * `reviewer_suggestion/query_accounts`: Latency for querying accounts for
@@ -153,6 +171,15 @@
 
 * `sequence/next_id_latency`: Latency of requesting IDs from repo sequences.
 
+=== Plugin
+
+* `plugin/latency`: Latency for plugin invocation.
+* `plugin/error_count`: Number of plugin errors.
+
+=== Group
+
+* `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+
 === Replication Plugin
 
 * `plugins/replication/replication_latency`: Time spent pushing to remote
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index fd2bef37..308e045 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -1,8 +1,8 @@
 = Gerrit Code Review - NoteDb Backend
 
 NoteDb is the next generation of Gerrit storage backend, which replaces the
-traditional SQL backend for change and account metadata with storing data in the
-same repository as code changes.
+traditional SQL backend for change, account and group metadata with storing
+data in the same repository as code changes.
 
 .Advantages
 - *Simplicity*: All data is stored in one location in the site directory, rather
@@ -32,12 +32,15 @@
   2.15 release. Account data is migrated automatically during the upgrade
   process by running `gerrit.war init`.
 - Storing link:config-groups.html[group metadata] is fully implemented
-  for the 2.16 release. Group data is migrated automatically during
+  in the 2.16 release. Group data is migrated automatically during
   the upgrade process by running `gerrit.war init`
 - Account, group and change metadata on the servers behind `googlesource.com` is fully
   migrated to NoteDb. In other words, if you use
   link:https://gerrit-review.googlesource.com/[gerrit-review], you're already
   using NoteDb.
+- NoteDb is the only database format supported by Gerrit 3.0. The change data
+  migration tools are only included in Gerrit 2.15 and 2.16; they are not
+  available in 3.0.
 
 For an example NoteDb change, poke around at this one:
 ----
@@ -45,12 +48,6 @@
       && git log -p FETCH_HEAD
 ----
 
-== Future Work ("Gerrit 3.0")
-
-- NoteDb will be the only database format supported by Gerrit 3.0. The offline
-  change data migration tool will be included in Gerrit 3.0, but online
-  migration will only be available in the 2.x line.
-
 [[migration]]
 == Migration
 
@@ -64,6 +61,9 @@
 [[online-migration]]
 === Online
 
+Note that online migration is only available in 2.x. To do the online migration
+from 2.14.x or 2.15.x to 3.0, it is necessary to first upgrade to 2.16.x.
+
 To start the online migration, set the `noteDb.changes.autoMigrate` option in
 `gerrit.config` and restart Gerrit:
 ----
@@ -87,7 +87,7 @@
 
 *Disadvantages*
 
-* Only available in 2.x; will not be available in Gerrit 3.0.
+* Only available in 2.x; not available in Gerrit 3.0.
 * Much slower than offline; uses only a single thread, to leave resources
   available for serving traffic.
 * Performance may be degraded, particularly of updates; data needs to be written
@@ -114,10 +114,10 @@
 * Much faster than online; can use all available CPUs, since no live traffic
   needs to be served.
 * No degraded performance of live servers due to writing data to 2 locations.
-* Available in both Gerrit 2.x and 3.0.
 
 *Disadvantages*
 
+* Available in Gerrit 2.15 and 2.16 only.
 * May require substantial downtime; takes about twice as long as an
   link:pgm-reindex.html[offline reindex]. (In fact, one of the migration steps is a
   full reindex, so it can't possibly take less time.)
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index c7aa57c..8fb5655 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -177,6 +177,14 @@
 
 Note: TODO
 
+=== registerDynamicCustomComponent
+`plugin.registerDynamicCustomComponent(dynamicEndpointName, opt_moduleName,
+opt_options)`
+
+See list of supported link:pg-plugin-endpoints.html[endpoints].
+
+Note: TODO
+
 === registerStyleModule
 `plugin.registerStyleModule(endpointName, moduleName)`
 
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index b77a66b..ff62da1 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -35,6 +35,11 @@
 
 The following endpoints are available to plugins.
 
+=== banner
+The `banner` extension point is located at the top of all pages. The purpose
+is to allow plugins to show outage information and important announcements to
+all users.
+
 === change-view-integration
 The `change-view-integration` extension point is located between `Files` and
 `Messages` section on the change view page, and it may take full page's
@@ -124,3 +129,69 @@
 
 === header-title
 This endpoint wraps the title-text in the application header.
+
+=== confirm-submit-change
+This endpoint is inside the confirm submit dialog. By default it displays a
+generic confirmation message regarding submission of the change. Plugins may add
+content to this message or replace it entirely.
+
+In addition to default parameters, the following are available:
+
+* `change`
++
+The change beinng potentially submitted, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `action`
++
+The submit action, including the title and label, an instance of
+link:rest-api-changes.html#action-info[ActionInfo]
+
+== Dynamic Plugin endpoints
+
+The following endpoints are available to plugins.
+
+=== change-list-header
+The `change-list-header` extension point adds a header to the change list view.
+
+=== change-list-item-cell
+The `change-list-item-cell` extension point adds a cell to the change list item.
+
+In addition to default parameters, the following are available:
+
+* `change`
++
+current change of the row, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+=== change-view-tab-header
+The `change-view-tab-header` extension point adds a primary tab to the change
+view. This must be used in conjunction with `change-view-tab-content`.
+
+In addition to default parameters, the following are available:
+
+* `change`
++
+current change displayed, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `revision`
++
+current revision displayed, an instance of
+link:rest-api-changes.html#revision-info[RevisionInfo]
+
+=== change-view-tab-content
+The `change-view-tab-content` extension point adds primary tab content to
+the change view. This must be used in conjunction with `change-view-tab-header`.
+
+In addition to default parameters, the following are available:
+
+* `change`
++
+current change displayed, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `revision`
++
+current revision displayed, an instance of
+link:rest-api-changes.html#revision-info[RevisionInfo]
diff --git a/Documentation/pg-plugin-styling.txt b/Documentation/pg-plugin-styling.txt
index 301da51..2453bad 100644
--- a/Documentation/pg-plugin-styling.txt
+++ b/Documentation/pg-plugin-styling.txt
@@ -23,13 +23,15 @@
 
 ``` html
   <dom-module id="some-style">
-    <style>
-      :root {
-        --css-mixin-name: {
-          property: value;
+    <template>
+      <style>
+        html {
+          --css-mixin-name: {
+            property: value;
+          }
         }
-      }
-    </style>
+      </style>
+    </template>
   </dom-module>
 ```
 
diff --git a/Documentation/pgm-LocalUsernamesToLowerCase.txt b/Documentation/pgm-LocalUsernamesToLowerCase.txt
index 03aaabf..53081a1 100644
--- a/Documentation/pgm-LocalUsernamesToLowerCase.txt
+++ b/Documentation/pgm-LocalUsernamesToLowerCase.txt
@@ -14,7 +14,11 @@
 == DESCRIPTION
 Converts the local username for every account to lower case. The
 local username is the username that is used to login into the Gerrit
-Web UI.
+Web UI. The local username is stored a external ID with scheme
+`gerrit`.
+
+[IMPORTANT]
+This program does not modify usernames in the `username` scheme.
 
 This task is only intended to be run if the configuration parameter
 link:config-gerrit.html#ldap.localUsernameToLowerCase[ldap.localUsernameToLowerCase]
@@ -43,7 +47,7 @@
 
 == CONTEXT
 This command can only be run on a server which has direct
-connectivity to the metadata database.
+connectivity to the managed Git repositories.
 
 == EXAMPLES
 To convert the local username of every account to lower case:
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index 0b1a3e5..ad07cfa 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -19,14 +19,8 @@
 
 == DESCRIPTION
 Runs the Gerrit network daemon on the local system, configured as
-per the local copy of link:config-gerrit.html[gerrit.config].
-
-The path to gerrit.config is read from the metadata database,
-which requires that all slaves (and master) reading from the same
-database must place gerrit.config at the same location on the local
-filesystem.  However, any option within gerrit.config, including
-link:config-gerrit.html#gerrit.basePath[gerrit.basePath] may be set
-to different values.
+per the local copy of link:config-gerrit.html[gerrit.config] located under
+`<SITE_PATH>/etc`.
 
 == OPTIONS
 
diff --git a/Documentation/pgm-gsql.txt b/Documentation/pgm-gsql.txt
deleted file mode 100644
index 4986522..0000000
--- a/Documentation/pgm-gsql.txt
+++ /dev/null
@@ -1,55 +0,0 @@
-= gsql
-
-== NAME
-gsql - Administrative interface to idle database
-
-== SYNOPSIS
-[verse]
---
-_java_ -jar gerrit.war _gsql_
-  -d <SITE_PATH>
---
-
-== DESCRIPTION
-Interactive query support against the configured SQL database.
-All SQL statements are supported, including SELECT, UPDATE, INSERT,
-DELETE and ALTER.
-
-This command is primarily intended to access a local H2 database
-which is not currently open by a Gerrit daemon.  To access an open
-database use link:cmd-gsql.html[gerrit gsql] over SSH.
-
-== OPTIONS
-
--d::
---site-path::
-	Location of the gerrit.config file, and all other per-site
-	configuration data, supporting libraries and log files.
-
-== CONTEXT
-This command can only be run on a server which has direct
-connectivity to the metadata database, and local access to the
-managed Git repositories.
-
-== EXAMPLES
-To manually correct a user's SSH user name:
-
-----
-	$ java -jar gerrit.war gsql
-	Welcome to Gerrit Code Review v2.0.25
-	(PostgreSQL 8.3.8)
-
-	Type '\h' for help.  Type '\r' to clear the buffer.
-
-	gerrit> update accounts set ssh_user_name = 'alice' where account_id=1;
-	UPDATE 1; 1 ms
-	gerrit> \q
-	Bye
-----
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/pgm-index.txt b/Documentation/pgm-index.txt
index d61cc0b..dde0231 100644
--- a/Documentation/pgm-index.txt
+++ b/Documentation/pgm-index.txt
@@ -15,9 +15,6 @@
 link:pgm-daemon.html[daemon]::
 	Gerrit HTTP, SSH network server.
 
-link:pgm-gsql.html[gsql]::
-	Administrative interface to idle database.
-
 link:pgm-prolog-shell.html[prolog-shell]::
 	Simple interactive Prolog interpreter.
 
diff --git a/Documentation/pgm-init.txt b/Documentation/pgm-init.txt
index 9a16cdf..f6c3c85 100644
--- a/Documentation/pgm-init.txt
+++ b/Documentation/pgm-init.txt
@@ -28,7 +28,7 @@
 into a newly created `$site_path`.
 
 If run in an existing `$site_path`, init upgrades existing resources
-(e.g. DB schema, plugins) as necessary.
+(e.g. NoteDb schema, plugins) as necessary.
 
 == OPTIONS
 -b::
@@ -100,8 +100,7 @@
 	folder.
 
 == CONTEXT
-This command can only be run on a server which has direct
-connectivity to the metadata database, and local access to the
+This command can only be run on a server which has direct local access to the
 managed Git repositories.
 
 GERRIT
diff --git a/Documentation/pgm-prolog-shell.txt b/Documentation/pgm-prolog-shell.txt
index a669aa7..3566b8f 100644
--- a/Documentation/pgm-prolog-shell.txt
+++ b/Documentation/pgm-prolog-shell.txt
@@ -7,7 +7,7 @@
 [verse]
 --
 _java_ -jar gerrit.war _prolog-shell_
-  [-s FILE.pl ...]
+  [-q] [-s FILE.pl ...]
 --
 
 == DESCRIPTION
@@ -15,6 +15,8 @@
 and testing.
 
 == OPTIONS
+-q::
+	Do not display banner.
 -s::
 	Dynamically load the Prolog source code at startup,
 	as though the user had entered `['FILE.pl'].` into
diff --git a/Documentation/pgm-rulec.txt b/Documentation/pgm-rulec.txt
index 1b50812..2a987205 100644
--- a/Documentation/pgm-rulec.txt
+++ b/Documentation/pgm-rulec.txt
@@ -33,8 +33,7 @@
 	Compile rules for the specified project.
 
 == CONTEXT
-This command can only be run on a server which has direct
-connectivity to the metadata database, and local access to the
+This command can only be run on a server which has local access to the
 managed Git repositories.
 
 Caching needs to be enabled. See
diff --git a/Documentation/project-configuration.txt b/Documentation/project-configuration.txt
index f76b5e4..23030a4 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -48,198 +48,7 @@
 [[project_options]]
 == Project Options
 
-[[submit_type]]
-=== Submit Type
-
-The method Gerrit uses to submit a change to a project can be
-modified by any project owner through the project console, `Projects` >
-`List` > my/project. In general, a submitted change is only merged if all
-its dependencies are also submitted, with exceptions documented below.
-The following submit types are supported:
-
-[[submit_type_inherit]]
-* Inherit
-+
-This is the default for new projects, unless overridden by a global
-link:config-gerrit.html#repository.name.defaultSubmitType[`defaultSubmitType` option].
-+
-Inherit the submit type from the parent project. In `All-Projects`, this
-is equivalent to link:#merge_if_necessary[Merge If Necessary].
-
-[[fast_forward_only]]
-* Fast Forward Only
-+
-With this method no merge commits are produced. All merges must
-be handled on the client, prior to uploading to Gerrit for review.
-+
-To submit a change, the change must be a strict superset of the
-destination branch.  That is, the change must already contain the
-tip of the destination branch at submit time.
-
-[[merge_if_necessary]]
-* Merge If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then a merge commit is automatically created.  This is identical
-to the classical `git merge` behavior, or `git merge --ff`.
-
-[[always_merge]]
-* Always Merge
-+
-Always produce a merge commit, even if the change is a strict
-superset of the destination branch.  This is identical to the
-behavior of `git merge --no-ff`, and may be useful if the
-project needs to follow submits with `git log --first-parent`.
-
-[[cherry_pick]]
-* Cherry Pick
-+
-Always cherry pick the patch set, ignoring the parent lineage
-and instead creating a brand new commit on top of the current
-branch head.
-+
-When cherry picking a change, Gerrit automatically appends onto the
-end of the commit message a short summary of the change's approvals,
-and a URL link back to the change on the web.  The committer header
-is also set to the submitter, while the author header retains the
-original patch set author.
-+
-Note that Gerrit ignores dependencies between changes when using this
-submit type unless
-link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-is enabled and depending changes share the same topic. So generally
-submitters must remember to submit changes in the right order when using this
-submit type. If all you want is extra information in the commit message,
-consider using the Rebase Always submit strategy.
-
-[[rebase_if_necessary]]
-* Rebase If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then the change is automatically rebased and then the branch is
-fast-forwarded to the change.
-
-When Gerrit tries to do a merge, by default the merge will only
-succeed if there is no path conflict.  A path conflict occurs when
-the same file has also been changed on the other side of the merge.
-
-[[rebase_always]]
-* Rebase Always
-+
-Basically, the same as Rebase If Necessary, but it creates a new patchset even
-if fast forward is possible AND like Cherry Pick it ensures footers such as
-Change-Id, Reviewed-On, and others are present in resulting commit that is
-merged.
-
-Thus, Rebase Always can be considered similar to Cherry Pick, but with
-the important distinction that Rebase Always does not ignore dependencies.
-
-[[content_merge]]
-If `Allow content merges` is enabled, Gerrit will try
-to do a content merge when a path conflict occurs.
-
-[[project-state]]
-=== State
-
-This setting defines the state of the project. A project can have the
-following states:
-
-- `Active`:
-+
-The project is active and users can see and modify the project according
-to their access rights on the project.
-
-- `Read Only`:
-+
-The project is read only and all modifying operations on it are
-disabled. E.g. this means that pushing to this project fails for all
-users even if they have push permissions assigned on it.
-+
-Setting a project to this state is an easy way to temporary close a
-project, as you can keep all write access rights in place and they will
-become active again as soon as the project state is set back to
-`Active`.
-+
-This state also makes sense if a project was moved to another location.
-In this case all new development should happen in the new project and
-you want to prevent that somebody accidentally works on the old
-project, while keeping the old project around for old references.
-
-- `Hidden`:
-+
-The project is hidden and only visible to project owners. Other users
-are not able to see the project even if they have read permissions
-granted on the project.
-
-=== Use target branch when determining new changes to open
-
-The `create-new-change-for-all-not-in-target` option provides a
-convenience for selecting link:user-upload.html#base[the merge base]
-by setting it automatically to the target branch's tip so you can
-create new changes for all commits not in the target branch.
-
-This option is disabled if the tip of the push is a merge commit.
-
-This option also only works if there are no merge commits in the
-commit chain, in such cases it fails warning the user that such
-pushes can only be performed by manually specifying
-link:user-upload.html#base[bases]
-
-This option is useful if you want to push a change to your personal
-branch first and for review to another branch for example. Or in cases
-where a commit is already merged into a branch and you want to create
-a new open change for that commit on another branch.
-
-[[require-change-id]]
-=== Require Change-Id
-
-The `Require Change-Id in commit message` option defines whether a
-link:user-changeid.html[Change-Id] in the commit message is required
-for pushing a commit for review. If this option is set, trying to push
-a commit for review that doesn't contain a Change-Id in the commit
-message fails with link:error-missing-changeid.html[missing Change-Id
-in commit message footer].
-
-It is recommended to set this option and use a
-link:user-changeid.html#create[commit-msg hook] (or other client side
-tooling like EGit) to automatically generate Change-Id's for new
-commits. This way the Change-Id is automatically in place when changes
-are reworked or rebased and uploading new patch sets gets easy.
-
-If this option is not set, commits can be uploaded without a Change-Id,
-but then users have to remember to copy the assigned Change-Id from the
-change screen and insert it manually into the commit message when they
-want to upload a second patch set.
-
-=== Maximum Git Object Size Limit
-
-This option defines the maximum allowed Git object size that
-receive-pack will accept. If an object is larger than the given size
-the pack-parsing will abort and the push operation will fail.
-
-With this option users can be prevented from uploading commits that
-contain files which are too large.
-
-Normally the link:config-gerrit.html#receive.maxObjectSizeLimit[maximum
-Git object size limit] is configured globally for a Gerrit server. At
-the project level, the maximum Git object size limit can be further
-reduced, but not extended. The displayed effective limit shows the
-maximum Git object size limit that is actually used on the project.
-
-The defined maximum Git object size limit is inherited by any child
-project.
-
-[[require-signed-off-by]]
-=== Require Signed-off-by
-
-The `Require Signed-off-by in commit message` option defines whether a
-link:user-signedoffby.html[Signed-off-by] line in the commit message is
-required for pushing a commit. If this option is set, trying to push a
-commit that doesn't contain a Signed-off-by line in the commit message
-fails with link:error-not-signed-off-by.html[not Signed-off-by
-author/committer/uploader in commit message footer].
+See details at link:config-project-config.html#project-section[project section].
 
 [[branch-admin]]
 == Branch Administration
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 19ed98a..f291920 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -74,6 +74,9 @@
 link:pgm-prolog-shell.html[prolog-shell] program which opens an interactive
 Prolog interpreter shell.
 
+For batch or unit tests, see the examples in Gerrit source directory
+link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/prologtests/examples/[prologtests/examples].
+
 [NOTE]
 The interactive shell is just a prolog shell, it does not load
 a gerrit server environment and thus is not intended for
@@ -725,6 +728,9 @@
 if the `cut` in the first rule is not reached and it only happens if a
 predicate before the `cut` fails.
 
+This fact can be bypassed by users who have
+link:access-control.html#category_forge_author[Forge Author] permission.
+
 ==== Don't use `gerrit:default_submit`
 Let's implement the same submit rule the other way, without reusing the
 `gerrit:default_submit`:
@@ -1044,10 +1050,7 @@
     gerrit:uploader(U),
     R = label('Is-Pure-Revert', ok(U)).
 
-submit_rule(submit(R)) :-
-    gerrit:pure_revert(U),
-    U /= 1,
-    R = label('Is-Pure-Revert', need(_)).
+submit_rule(submit(label('Is-Pure-Revert', need(_)))).
 ----
 
 Suppose currently a change is submittable if it gets `+2` for `Code-Review`
@@ -1058,21 +1061,20 @@
 [source,prolog]
 ----
 submit_rule(submit(CR, V, R)) :-
-    base(CR, V),
-    gerrit:pure_revert(1),
-    !,
-    gerrit:uploader(U),
-    R = label('Is-Pure-Revert', ok(U)).
-
-submit_rule(submit(CR, V, R)) :-
-    base(CR, V),
-    gerrit:pure_revert(U),
-    U /= 1,
-    R = label('Is-Pure-Revert', need(_)).
+  base(CR, V),
+  set_pure_revert_label(R).
 
 base(CR, V) :-
-    gerrit:max_with_block(-2, 2, 'Code-Review', CR),
-    gerrit:max_with_block(-1, 1, 'Verified', V).
+  gerrit:max_with_block(-2, 2, 'Code-Review', CR),
+  gerrit:max_with_block(-1, 1, 'Verified', V).
+
+set_pure_revert_label(R) :-
+  gerrit:pure_revert(1),
+  !,
+  gerrit:uploader(U),
+  R = label('Is-Pure-Revert', ok(U)).
+
+set_pure_revert_label(label('Is-Pure-Revert', need(_))).
 ----
 
 Note that a new label as `Is-Pure-Revert` should not be configured.
diff --git a/Documentation/quota.txt b/Documentation/quota.txt
new file mode 100644
index 0000000..a647e33
--- /dev/null
+++ b/Documentation/quota.txt
@@ -0,0 +1,50 @@
+= Gerrit Code Review - Quota
+
+Gerrit does not provide out of the box quota enforcement. However, it does
+support an extension mechanism for plugins to hook into to provide this
+functionality. The most prominent plugin is the
+link:https://gerrit.googlesource.com/plugins/quota/[Quota Plugin].
+
+This documentation is intended to be read by plugin developers. It contains all
+quota requests implemented in Gerrit-core as well as the metadata that they have
+associated.
+
+== Quota Groups
+
+The following quota groups are defined in core Gerrit:
+
+=== REST API
+[[rest-api]]
+
+The REST API enforces quota after the resource was parsed (if applicable) and before the
+endpoint's logic is executed. This enables quota enforcer implementations to throttle calls
+to specific endpoints while knowing the general context (user and top-level entity such as
+change, project or account).
+
+If the quota enforcer wants to throttle HTTP requests, they should use
+link:quota.html#http-requests[HTTP Requests] instead.
+
+The quota groups used for checking follow the exact definition of the endoint in the REST
+API, but remove all IDs. The schema is:
+
+/restapi/<ENDPOINT>:<HTTP-METHOD>
+
+Examples:
+
+[options="header",cols="1,6"]
+|=======================
+|HTTP call                                 |Quota Group                    |Metadata
+|GET /a/changes/1/revisions/current/detail |/changes/revisions/detail:GET  |CurrentUser, Change.Id, Project.NameKey
+|POST /a/changes/                          |/changes/:POST                 |CurrentUser
+|GET /a/accounts/self/detail               |/accounts/detail:GET           |CurrentUser, Account.Id
+|=======================
+
+The user provided in the check's metadata is always the calling user (having the
+impersonation bit and real user set in case the user is impersonating another user).
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/replace_macros.py b/Documentation/replace_macros.py
index 6f90697..7d8ea23 100755
--- a/Documentation/replace_macros.py
+++ b/Documentation/replace_macros.py
@@ -87,9 +87,15 @@
   id="searchBox">
   Search
 </button>
+  %s
+</div>
+++++
+"""
+
+BUILTIN_SEARCH = """
 <script type="text/javascript">
 var f = function() {
-  window.location = '../#/Documentation/' +
+  window.location = '../#/Documentation/q/' +
     encodeURIComponent(document.getElementById("docSearch").value);
 }
 document.getElementById("searchBox").onclick = f;
@@ -99,11 +105,25 @@
   }
 }
 </script>
-</div>
-++++
-
 """
 
+GOOGLE_SITE_SEARCH = """
+<script type="text/javascript">
+var f = function() {
+  window.location = 'https://www.google.com/search?q=' +
+     encodeURIComponent(document.getElementById("docSearch").value +
+     ' site:@SITE@');
+}
+document.getElementById("searchBox").onclick = f;
+document.getElementById("docSearch").onkeypress = function(e) {
+  if (13 == (e.keyCode ? e.keyCode : e.which)) {
+    f();
+  }
+}
+</script>
+"""
+
+
 LINK_SCRIPT = """
 
 ++++
@@ -227,8 +247,19 @@
                 help="generate the search boxes")
 opts.add_option('--no-searchbox', action="store_false", dest='searchbox',
                 help="don't generate the search boxes")
+opts.add_option('--site-search', action="store", metavar="SITE",
+                help=("generate the search box using google. SITE should " +
+                      "point to the domain/path of the site, eg. " +
+                      "gerrit-review.googlesource.com/Documentation"))
 options, _ = opts.parse_args()
 
+if options.site_search:
+  SEARCH_BOX = (SEARCH_BOX %
+                GOOGLE_SITE_SEARCH.replace("@SITE@", options.site_search))
+else:
+  SEARCH_BOX = SEARCH_BOX % BUILTIN_SEARCH
+
+
 try:
     try:
         out_file = open(options.out, 'w', errors='ignore')
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 65a15ca..c2a7d21 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -263,6 +263,7 @@
       ],
       "can_upload": true,
       "can_add": true,
+      "can_add_tags": true,
       "config_visible": true,
       "groups": {
          "53a4f647a89ea57992571187d8025f830625192a": {
@@ -313,6 +314,7 @@
       ],
       "can_upload": true,
       "can_add": true,
+      "can_add_tags": true,
       "config_visible": true
     }
   }
@@ -399,6 +401,8 @@
 Whether the calling user can upload to any ref.
 |`can_add`            |not set if `false`|
 Whether the calling user can add any ref.
+|`can_add_tags`       |not set if `false`|
+Whether the calling user can add any tag ref.
 |`config_visible`     |not set if `false`|
 Whether the calling user can see the `refs/meta/config` branch of the
 project.
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 025b29d..5d22659 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
 ----
 
@@ -476,6 +479,8 @@
 in the request body inside a link:#http-password-input[
 HttpPasswordInput] entity.
 
+The account must have a username.
+
 .Request
 ----
   PUT /accounts/self/password.http HTTP/1.0
@@ -1255,6 +1260,7 @@
     "review_category_strategy": "ABBREV",
     "mute_common_path_prefixes": true,
     "publish_comments_on_push": true,
+    "work_in_progress_by_default": true,
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1361,6 +1367,7 @@
     "review_category_strategy": "NAME",
     "diff_view": "SIDE_BY_SIDE",
     "publish_comments_on_push": true,
+    "work_in_progress_by_default": true,
     "mute_common_path_prefixes": true,
     "my": [
       {
@@ -1797,6 +1804,69 @@
   ]
 ----
 
+[delete-draft-comments]
+=== Delete Draft Comments
+--
+'POST /accounts/link:#account-id[\{account-id\}]/drafts:delete'
+--
+
+Deletes some or all of a user's draft comments. The set of comments to delete is
+specified as a link:#delete-draft-comments-input[DeleteDraftCommentsInput]
+entity. An empty input entity deletes all comments.
+
+Only drafts belonging to the caller may be deleted.
+
+.Request
+----
+  POST /accounts/self/drafts.delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "query": "is:abandoned"
+  }
+----
+
+As a response, a list of
+link:#deleted-draft-comment-info[DeletedDraftCommentInfo] entities is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+   )]}'
+   [
+     {
+       "change": {
+         "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+         "project": "myProject",
+         "branch": "master",
+         "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+         "subject": "Implementing Feature X",
+         "status": "ABANDONED",
+         "created": "2013-02-01 09:59:32.126000000",
+         "updated": "2013-02-21 11:16:36.775000000",
+         "insertions": 34,
+         "deletions": 101,
+         "_number": 3965,
+         "owner": {
+           "name": "John Doe"
+         }
+       },
+       "deleted": [
+         {
+           "id": "TvcXrmjM",
+           "path": "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java",
+           "line": 23,
+           "message": "[nit] trailing whitespace",
+           "updated": "2013-02-26 15:40:43.986000000"
+         }
+       ]
+     }
+   ]
+----
+
 [[sign-contributor-agreement]]
 === Sign Contributor Agreement
 --
@@ -2073,12 +2143,37 @@
 
 This can be:
 
+* `self` or `me` for the calling user
+* a bare account ID ("18419")
+* an account ID following a name in parentheses ("Full Name (18419)")
 * a string of the format "Full Name <email@example.com>"
 * just the email address ("email@example")
-* a full name if it is unique ("Full Name")
-* an account ID ("18419")
+* a full name ("Full Name")
 * a user name ("username")
-* `self` for the calling user
+
+In all cases, accounts that are not
+link:config-gerrit.txt#accounts.visibility[visible] to the calling user are not
+considered.
+
+In all cases _except_ a bare account ID and `self`/`me`, inactive accounts are
+not considered. Inactive accounts should only be referenced by bare ID.
+
+If the input is a bare account ID, this will always resolve to exactly
+one account if there is a visible account with that ID, and zero accounts
+otherwise. (This is true even in corner cases like a user having a full name
+which is exactly a numeric account ID belonging to a different user; such a user
+cannot be identified by this number.)
+
+If the identifier is ambiguous or only refers to inactive accounts, the error
+message from the API should contain a human-readable description of how to
+disambiguate the request.
+
+*Note*: Except as noted above, callers should not rely on the particular
+priorities of any of the identifiers in the account resolution algorithm. Any
+other formats may be subject to future deprecation. If callers require specific
+searching semantics, they should use the link:#query-account[Query Account]
+endpoint to resolve a string to one or more accounts, then access the API using
+the account ID.
 
 [[capability-id]]
 === \{capability-id\}
@@ -2308,6 +2403,37 @@
 |`name`                     |The name of the agreement.
 |=================================
 
+[[delete-draft-comments-input]]
+=== DeleteDraftCommentsInput
+The `DeleteDraftCommentsInput` entity contains information specifying a set of
+draft comments that should be deleted.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name                 ||Description
+|`query`                    |optional|
+A link:user-search.html[change query] limiting results to changes matching this
+query; `has:draft` is implied and not necessary to list explicitly. If not set,
+matches all changes with drafts.
+|=================================
+
+[[deleted-draft-comment-info]]
+=== DeletedDraftCommentInfo
+The `DeletedDraftCommentInfo` entity contains information about draft comments
+that were deleted.
+
+[options="header",cols="1,6"]
+|=================================
+|Field Name                 |Description
+|`change`                   |
+link:rest-api-changes.html#change-info[ChangeInfo] entity describing the change
+on which one or more comments was deleted. Populated with only the
+link:rest-api-changes.html#skip_mergeable[SKIP_MERGEABLE] option.
+|`deleted`                  |
+List of link:rest-api-changes.html#comment-info[CommentInfo] entities for each
+comment that was deleted.
+|=================================
+
 [[diff-preferences-info]]
 === DiffPreferencesInfo
 The `DiffPreferencesInfo` entity contains information about the diff
@@ -2654,6 +2780,9 @@
 |`publish_comments_on_push`     |not set if `false`|
 Whether to link:user-upload.html#publish-comments[publish draft comments] on
 push by default.
+|`work_in_progress_by_default`  |not set if `false`|
+Whether to link:user-upload.html#wip[set work-in-progress] on
+push or on create changes online by default.
 |============================================
 
 [[preferences-input]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index fb0a0df..56376fa 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -309,6 +309,19 @@
 * `SKIP_MERGEABLE`: skip the `mergeable` field in
 link:#change-info[ChangeInfo]. For fast moving projects, this field must
 be recomputed often, which is slow for projects with big trees.
++
+When link:config-gerrit.html#change.api.excludeMergeableInChangeInfo[
+`change.api.excludeMergeableInChangeInfo`] is set in the `gerrit.config`,
+the `mergeable` field will always be omitted and `SKIP_MERGEABLE` has no
+effect.
++
+A change's mergeability can be requested separately by calling the
+link:#get-mergeable[get-mergeable] endpoint.
+--
+[[skip_diffstat]]
+--
+* `SKIP_DIFFSTAT`: skip the 'insertions' and 'deletions' field in link:#change-info[ChangeInfo].
+ For large trees, their computation may be expensive.
 --
 
 [[submittable]]
@@ -350,6 +363,11 @@
   as link:#tracking-id-info[TrackingIdInfo].
 --
 
+[[no-limit]]
+--
+* `NO-LIMIT`: Return all results
+--
+
 .Request
 ----
   GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES&o=DOWNLOAD_COMMANDS HTTP/1.0
@@ -831,8 +849,10 @@
 Creates a new patch set with a new commit message.
 
 The new commit message must be provided in the request body inside a
-link:#commit-message-input[CommitMessageInput] entity and contain the change ID footer if
-link:project-configuration.html#require-change-id[Require Change-Id] was specified.
+link:#commit-message-input[CommitMessageInput] entity. If a Change-Id
+footer is specified, it must match the current Change-Id footer. If
+the Change-Id footer is absent, the current Change-Id is added to the
+message.
 
 .Request
 ----
@@ -924,10 +944,6 @@
 
 Deletes the topic of a change.
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify a commit message, use
-link:#set-topic[PUT] to delete the topic.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/topic HTTP/1.0
@@ -2204,8 +2220,8 @@
 'POST /changes/link:#change-id[\{change-id\}]/private'
 --
 
-Marks the change to be private. Changes may only be marked private by the
-owner or site administrators.
+Marks the change to be private. Only open changes can be marked private.
+Changes may only be marked private by the owner or site administrators.
 
 A message can be specified in the request body inside a
 link:#private-input[PrivateInput] entity.
@@ -2236,17 +2252,9 @@
 Marks the change to be non-private. Note users can only unmark own private
 changes.
 
-A message can be specified in the request body inside a
-link:#private-input[PrivateInput] entity.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "message": "This is a security fix that must not be public."
-  }
 ----
 
 .Response
@@ -2256,9 +2264,11 @@
 
 If the change was already not private, the response is "`409 Conflict`".
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to set a message options, use a
-POST request:
+A message can be specified in the request body inside a
+link:#private-input[PrivateInput] entity. Historically, this method allowed
+a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -2277,7 +2287,9 @@
 --
 
 Marks a change as ignored. The change will not be shown in the incoming
-reviews dashboard, and email notifications will be suppressed.
+reviews dashboard, and email notifications will be suppressed. Ignoring
+a change does not cause the change's "updated" timestamp to be modified,
+and the owner is not notified.
 
 .Request
 ----
@@ -2462,7 +2474,7 @@
 Retrieves a change message including link:#detailed-accounts[detailed account information].
 
 --
-'GET /changes/link:#change-id[\{change-id\}]/message/link:#change-message-id[\{change-message-id\}'
+'GET /changes/link:#change-id[\{change-id\}]/messages/link:#change-message-id[\{change-message-id\}]'
 --
 
 As response a link:#change-message-info[ChangeMessageInfo] entity is returned.
@@ -2483,7 +2495,66 @@
       "username": "jdoe"
      },
      "date": "2013-03-23 21:34:02.419000000",
-     "message": "Change message removed by: Administrator; Reason: spam",
+     "message": "a change message",
+     "_revision_number": 1
+  }
+----
+
+[[delete-change-message]]
+=== Delete Change Message
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/messages/link:#change-message-id[\{change-message-id\}]' +
+'POST /changes/link:#change-id[\{change-id\}]/messages/link:#change-message-id[\{change-message-id\}]/delete'
+--
+
+Deletes a change message by replacing the change message with a new message,
+which contains the name of the user who deleted the change message and the
+reason why it was deleted. The reason can be provided in the request body as a
+link:#delete-change-message-input[DeleteChangeMessageInput] entity.
+
+Note that only users with the
+link:access-control.html#capability_administrateServer[Administrate Server]
+global capability are permitted to delete a change message.
+
+To delete a change message, send a DELETE request:
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/messages/aaee04dcb46bafc8be24d8aa70b3b1beb7df5780 HTTP/1.0
+----
+
+To provide a reason for the deletion, use a POST request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/messages/aaee04dcb46bafc8be24d8aa70b3b1beb7df5780/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reason": "spam"
+  }
+----
+
+As response a link:#change-message-info[ChangeMessageInfo] entity is returned that
+describes the updated change message.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "aaee04dcb46bafc8be24d8aa70b3b1beb7df5780",
+    "author": {
+      "_account_id": 1000096,
+      "name": "John Doe",
+      "email": "john.doe@example.com",
+      "username": "jdoe"
+     },
+     "date": "2013-03-23 21:34:02.419000000",
+     "message": "Change message removed by: Administrator\nReason: spam",
      "_revision_number": 1
   }
 ----
@@ -2521,28 +2592,30 @@
 
   )]}'
   {
-    "commit":{
-      "parents":[
+    "commit": {
+      "parents": [
         {
-          "commit":"1eee2c9d8f352483781e772f35dc586a69ff5646",
+          "commit": "1eee2c9d8f352483781e772f35dc586a69ff5646",
         }
       ],
-      "author":{
-        "name":"Shawn O. Pearce",
-        "email":"sop@google.com",
-        "date":"2012-04-24 18:08:08.000000000",
-        "tz":-420
+      "author": {
+        "name": "Shawn O. Pearce",
+        "email": "sop@google.com",
+        "date": "2012-04-24 18:08:08.000000000",
+        "tz": -420
        },
-       "committer":{
-         "name":"Shawn O. Pearce",
-         "email":"sop@google.com",
-         "date":"2012-04-24 18:08:08.000000000",
-         "tz":-420
+       "committer": {
+         "name": "Shawn O. Pearce",
+         "email": "sop@google.com",
+         "date": "2012-04-24 18:08:08.000000000",
+         "tz": -420
        },
-       "subject":"Use an EventBus to manage star icons",
-       "message":"Use an EventBus to manage star icons\n\nImage widgets that need to ..."
+       "subject": "Use an EventBus to manage star icons",
+       "message": "Use an EventBus to manage star icons\n\nImage widgets that need to ..."
     },
-    "base_revision":"c35558e0925e6985c91f3a16921537d5e572b7a3"
+    "base_patch_set_number": 1,
+    "base_revision": "c35558e0925e6985c91f3a16921537d5e572b7a3",
+    "ref": "refs/users/01/1000001/edit-76482/1"
   }
 ----
 
@@ -2730,7 +2803,7 @@
 
   )]}'
   {
-  "web_links":[
+  "web_links": [
     {
       "show_on_side_by_side_diff_view": true,
       "name": "side-by-side preview diff",
@@ -3130,18 +3203,17 @@
 
 Deletes a reviewer from a change.
 
-Options can be provided in the request body as a
-link:#delete-reviewer-input[DeleteReviewerInput] entity.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe HTTP/1.0
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/delete HTTP/1.0
 ----
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify options, use a POST
-request:
+Options can be provided in the request body as a
+link:#delete-reviewer-input[DeleteReviewerInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -3211,18 +3283,17 @@
 Deletes a single vote from a change. Note, that even when the last vote of
 a reviewer is removed the reviewer itself is still listed on the change.
 
-Options can be provided in the request body as a
-link:#delete-vote-input[DeleteVoteInput] entity.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
 ----
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify options, use a POST
-request:
+Options can be provided in the request body as a
+link:#delete-vote-input[DeleteVoteInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -4585,17 +4656,17 @@
 Deletes a published comment of a revision. Instead of deleting the
 whole comment, this endpoint just replaces the comment's message
 with a new message, which contains the name of the user who deletes
-the comment and the reason why it's deleted. The reason can be
-provided in the request body as a
-link:#delete-comment-input[DeleteCommentInput] entity.
+the comment and the reason why it's deleted.
 
 Note that only users with the
 link:access-control.html#capability_administrateServer[Administrate Server]
 global capability are permitted to delete a comment.
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify options, use a
-POST request:
+Deletion reason can be provided in the request body as a
+link:#delete-comment-input[DeleteCommentInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -4755,28 +4826,30 @@
 
     )]}'
     {
-      "commit":{
-        "parents":[
+      "commit": {
+        "parents": [
           {
-            "commit":"1eee2c9d8f352483781e772f35dc586a69ff5646",
+            "commit": "1eee2c9d8f352483781e772f35dc586a69ff5646",
           }
         ],
-        "author":{
-          "name":"John Doe",
-          "email":"john.doe@example.com",
-          "date":"2013-05-07 15:21:27.000000000",
-          "tz":120
+        "author": {
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "date": "2013-05-07 15:21:27.000000000",
+          "tz": 120
          },
-         "committer":{
-           "name":"Jane Doe",
-           "email":"jane.doe@example.com",
-           "date":"2013-05-07 15:35:43.000000000",
-           "tz":120
+         "committer": {
+           "name": "Jane Doe",
+           "email": "jane.doe@example.com",
+           "date": "2013-05-07 15:35:43.000000000",
+           "tz": 120
          },
-         "subject":"Implement feature X",
-         "message":"Implement feature X\n\nWith this feature ..."
+         "subject": "Implement feature X",
+         "message": "Implement feature X\n\nWith this feature ..."
       },
-      "base_revision":"674ac754f91e64a0efb8087e59a176484bd534d1"
+      "base_patch_set_number": 1,
+      "base_revision": "674ac754f91e64a0efb8087e59a176484bd534d1"
+      "ref": "refs/users/01/1000001/edit-42622/1"
     }
 ----
 
@@ -4806,8 +4879,8 @@
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/ HTTP/1.0
 ----
 
-As result a map is returned that maps the link:#file-id[file path] to a list of
-link:#file-info[FileInfo] entries. The entries in the map are
+As result a map is returned that maps the link:#file-id[file path] to a
+link:#file-info[FileInfo] entry. The entries in the map are
 sorted by file path.
 
 .Response
@@ -5269,8 +5342,8 @@
   }
 ----
 
-As response a link:#change-info[ChangeInfo] entity is returned that
-describes the resulting cherry picked change.
+As response a link:#cherry-pick-change-info[CherryPickChangeInfo]
+entity is returned that describes the resulting cherry-pick change.
 
 .Response
 ----
@@ -5395,18 +5468,17 @@
 Note, that even when the last vote of a reviewer is removed the reviewer itself
 is still listed on the change.
 
-Options can be provided in the request body as a
-link:#delete-vote-input[DeleteVoteInput] entity.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
 ----
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify options, use a POST
-request:
+Options can be provided in the request body as a
+link:#delete-vote-input[DeleteVoteInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -5600,8 +5672,9 @@
 The time and date describing when the approval was made.
 |`tag`                    |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
-while posting the review.
-NOTE: To apply different tags on on different votes/comments multiple
+while posting the review. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
+NOTE: To apply different tags on different votes/comments multiple
 invocations of the REST call are required.
 |`post_submit` |not set if `false`|
 If true, this vote was made after the change was submitted.
@@ -5707,12 +5780,14 @@
 Whether the change was reviewed by the calling user.
 Only set if link:#reviewed[reviewed] is requested.
 |`submit_type`        |optional|
-The link:project-configuration.html#submit_type[submit type] of the change. +
+The link:config-project-config.html#submit-type[submit type] of the change. +
 Not set for merged changes.
 |`mergeable`          |optional|
 Whether the change is mergeable. +
 Not set for merged changes, if the change has not yet been tested, or
-if the link:#skip_mergeable[skip_mergeable] option is set.
+if the link:#skip_mergeable[skip_mergeable] option is set or when
+link:config-gerrit.html#change.api.excludeMergeableInChangeInfo[change.api.excludeMergeableInChangeInfo]
+is set.
 |`submittable`        |optional|
 Whether the change has been approved by the project submit rules. +
 Only set if link:#submittable[requested].
@@ -5720,8 +5795,12 @@
 Number of inserted lines.
 |`deletions`          ||
 Number of deleted lines.
+|`total_comment_count`  |optional|
+Total number of inline comments across all patch sets. Not set if the current
+change index doesn't have the data.
 |`unresolved_comment_count`  |optional|
-Number of unresolved comments. Not set if the current change index doesn't have the data.
+Number of unresolved inline comment threads across all patch sets. Not set if
+the current change index doesn't have the data.
 |`_number`            ||The legacy numeric ID of the change.
 |`owner`              ||
 The owner of the change as an link:rest-api-accounts.html#account-info[
@@ -5864,13 +5943,31 @@
 |`message`            ||The text left by the user.
 |`tag`                 |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
-while posting the review.
-NOTE: To apply different tags on on different votes/comments multiple
+while posting the review. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
+NOTE: To apply different tags on different votes/comments multiple
 invocations of the REST call are required.
 |`_revision_number`    |optional|
 Which patchset (if any) generated this message.
 |==================================
 
+[[cherry-pick-change-info]]
+=== CherryPickChangeInfo
+The `CherryPickChangeInfo` entity contains information about a
+cherry-pick change.
+
+`CherryPickChangeInfo` has the same fields as link:#change-info[
+ChangeInfo]. In addition `CherryPickChangeInfo` has the following
+fields:
+
+[options="header",cols="1,^1,5"]
+|======================================
+|Field Name               ||Description
+|`contains_git_conflicts` |optional, not set if `false`|
+Whether any file in the change contains Git conflict markers.
+|======================================
+
+
 [[cherrypick-input]]
 === CherryPickInput
 The `CherryPickInput` entity contains information for cherry-picking a change to a new branch.
@@ -5878,7 +5975,7 @@
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name         ||Description
-|`message`          ||Commit message for the cherry-picked change
+|`message`          ||Commit message for the cherry-pick change
 |`destination`      ||Destination branch
 |`base`             |optional|
 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created change.
@@ -5894,7 +5991,16 @@
 Additional information about whom to notify about the update as a map
 of recipient type to link:#notify-info[NotifyInfo] entity.
 |`keep_reviewers`   |optional, defaults to false|
-If true, carries reviewers and ccs over from original change to newly created one.
+If `true`, carries reviewers and ccs over from original change to newly created one.
+|`allow_conflicts`  |optional, defaults to false|
+If `true`, the cherry-pick uses content merge and succeeds also if
+there are conflicts. If there are conflicts the file contents of the
+created change contain git conflict markers to indicate the conflicts.
+Callers can find out if there were conflicts by checking the
+`contains_git_conflicts` field in the link:#cherry-pick-change-info[
+CherryPickChangeInfo] that is returned by the cherry-pick REST
+endpoints. If there are conflicts the cherry-pick change is marked as
+work-in-progress.
 |===========================
 
 [[comment-info]]
@@ -5938,7 +6044,7 @@
 |`tag`                 |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
 while posting the review.
-NOTE: To apply different tags on on different votes/comments multiple
+NOTE: To apply different tags on different votes/comments multiple
 invocations of the REST call are required.
 |`unresolved`        |optional|
 Whether or not the comment must be addressed by the user. The state of
@@ -5985,7 +6091,8 @@
 |`tag`         |optional, drafts only|
 Value of the `tag` field. Only allowed on link:#create-draft[draft comment] +
 inputs; for published comments, use the `tag` field in +
-link#review-input[ReviewInput]
+link#review-input[ReviewInput]. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
 |`unresolved`        |optional|
 Whether or not the comment must be addressed by the user. This value will
 default to false if the comment is an orphan, or the value of the `in_reply_to`
@@ -5996,13 +6103,22 @@
 === CommentRange
 The `CommentRange` entity describes the range of an inline comment.
 
+The comment range is a range from the start position, specified by `start_line`
+and `start_character`, to the end position, specified by `end_line` and
+`end_character`. The start position is *inclusive* and the end position is
+*exclusive*.
+
+So, a range over part of a line will have `start_line` equal to
+`end_line`; however a range with `end_line` set to 5 and `end_character` equal
+to 0 will not include any characters on line 5,
+
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`start_line`        ||The start line number of the range. (1-based, inclusive)
-|`start_character`   ||The character position in the start line. (0-based, inclusive)
-|`end_line`          ||The end line number of the range. (1-based, exclusive)
-|`end_character`     ||The character position in the end line. (0-based, exclusive)
+|Field Name          ||Description
+|`start_line`        ||The start line number of the range. (1-based)
+|`start_character`   ||The character position in the start line. (0-based)
+|`end_line`          ||The end line number of the range. (1-based)
+|`end_character`     ||The character position in the end line. (0-based)
 |===========================
 
 [[commit-info]]
@@ -6051,6 +6167,20 @@
 of recipient type to link:#notify-info[NotifyInfo] entity.
 |=============================
 
+[[delete-change-message-input]]
+=== DeleteChangeMessageInput
+The `DeleteChangeMessageInput` entity contains the options for deleting a change message.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name               ||Description
+|`reason`                 |optional|
+The reason why the change message should be deleted. +
+If set, the change message will be replaced with
+"Change message removed by: `name`\nReason: `reason`",
+or just "Change message removed by: `name`." if not set.
+|=============================
+
 [[delete-comment-input]]
 === DeleteCommentInput
 The `DeleteCommentInput` entity contains the option for deleting a comment.
@@ -6125,10 +6255,12 @@
 |`a`            |optional|Content only in the file on side A (deleted in B).
 |`b`            |optional|Content only in the file on side B (added in B).
 |`ab`           |optional|Content in the file on both sides (unchanged).
-|`edit_a`       |only present during a replace, i.e. both `a` and `b` are present|
+|`edit_a`       |only present when the `intraline` parameter is set and the
+DiffContent is a replace, i.e. both `a` and `b` are present|
 Text sections deleted from side A as a
 link:#diff-intraline-info[DiffIntralineInfo] entity.
-|`edit_b`       |only present during a replace, i.e. both `a` and `b` are present|
+|`edit_b`       |only present when the `intraline` parameter is set and the
+DiffContent is a replace, i.e. both `a` and `b` are present|
 Text sections inserted in side B as a
 link:#diff-intraline-info[DiffIntralineInfo] entity.
 |`due_to_rebase`|not set if `false`|Indicates whether this entry was introduced by a
@@ -6191,11 +6323,12 @@
 The `DiffIntralineInfo` entity contains information about intraline edits in a
 file.
 
-The information consists of a list of `<skip length, mark length>` pairs, where
+The information consists of a list of `<skip length, edit length>` pairs, where
 the skip length is the number of characters between the end of the previous edit
-and the start of this edit, and the mark length is the number of edited characters
+and the start of this edit, and the edit length is the number of edited characters
 following the skip. The start of the edits is from the beginning of the related
-diff content lines.
+diff content lines. If the list is empty, the entire DiffContent should be
+considered edited.
 
 Note that the implied newline character at the end of each line is included in
 the length calculation, and thus it is possible for the edits to span newlines.
@@ -6236,15 +6369,17 @@
 
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name     ||Description
-|`commit`       ||The commit of change edit as
+|Field Name             ||Description
+|`commit`               ||The commit of change edit as
 link:#commit-info[CommitInfo] entity.
-|`base_revision`||The revision of the patch set the change edit is based on.
-|`fetch`        |optional|
+|`base_patch_set_number`||The patch set number of the patch set the change edit is based on.
+|`base_revision`        ||The revision of the patch set the change edit is based on.
+|`ref`                  ||The ref of the change edit.
+|`fetch`                |optional|
 Information about how to fetch this patch set. The fetch information is
 provided as a map that maps the protocol name ("`git`", "`http`",
 "`ssh`") to link:#fetch-info[FetchInfo] entities.
-|`files`        |optional|
+|`files`                |optional|
 The files of the change edit as a map that maps the file names to
 link:#file-info[FileInfo] entities.
 |===========================
@@ -6282,10 +6417,14 @@
 Only set if the file was renamed or copied.
 |`lines_inserted`|optional|
 Number of inserted lines. +
-Not set for binary files or if no lines were inserted.
+Not set for binary files or if no lines were inserted. +
+An empty last line is not included in the count and hence this number can
+differ by one from details provided in <<#diff-info,DiffInfo>>.
 |`lines_deleted` |optional|
 Number of deleted lines. +
-Not set for binary files or if no lines were deleted.
+Not set for binary files or if no lines were deleted. +
+An empty last line is not included in the count and hence this number can
+differ by one from details provided in <<#diff-info,DiffInfo>>.
 |`size_delta`    ||
 Number of bytes by which the file size increased/decreased.
 |`size`          ||
@@ -6790,8 +6929,8 @@
 |`tag`                    |optional|
 Apply this tag to the review comment message, votes, and inline
 comments. Tags may be used by CI or other automated systems to
-distinguish them from human reviews. Comments with specific tag
-values can be filtered out in the web UI.
+distinguish them from human reviews. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
 |`labels`                 |optional|
 The votes that should be added to the revision as a map that maps the
 label names to the voting values.
@@ -6951,7 +7090,7 @@
 |`reviewed`     |optional|
 Indicates whether the caller is authenticated and has commented on the
 current revision. Only set if link:#reviewed[REVIEWED] option is requested.
-|`messageWithFooter` |optional|
+|`commit_with_footers` |optional|
 If the link:#commit-footers[COMMIT_FOOTERS] option is requested and
 this is the current patch set, contains the full commit message with
 Gerrit-specific commit footers, as if this revision were submitted
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 4b8922a..96b376d 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -126,10 +126,7 @@
     "gerrit": {
       "all_projects": "All-Projects",
       "all_users": "All-Users"
-      "doc_search": true,
-      "web_uis": [
-        "gwt"
-      ]
+      "doc_search": true
     },
     "sshd": {},
     "suggest": {
@@ -1802,12 +1799,6 @@
 Whether to enable the web UI for editing GPG keys.
 |`report_bug_url`    |optional|
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
-|`report_bug_text`   |optional, not set if default|
-link:config-gerrit.html#gerrit.reportBugText[Display text for report
-bugs link].
-|`web_uis`           ||
-List of web UIs supported by the HTTP server. Possible values are `GWT`
-and `POLYGERRIT`.
 |=================================
 
 [[hit-ration-info]]
@@ -1927,7 +1918,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-plugins.txt b/Documentation/rest-api-plugins.txt
index 938d101..c34fe77 100644
--- a/Documentation/rest-api-plugins.txt
+++ b/Documentation/rest-api-plugins.txt
@@ -387,6 +387,24 @@
   }
 ----
 
+Disabling of a link:config-gerrit.html#plugins.mandatory[mandatory plugin]
+is rejected:
+
+.Request
+----
+  DELETE /plugins/replication HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 405 Method Not Allowed
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  Plugin replication is mandatory
+----
+
 [[reload-plugin]]
 === Reload Plugin
 --
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index bc5a3c6..c1349aa 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -288,11 +288,11 @@
     },
     "child-project": {
       "id": "child-project",
-      "parent":"parent-project"
+      "parent": "parent-project"
     },
     "parent-project": {
       "id": "parent-project",
-      "parent":"All-Projects"
+      "parent": "All-Projects"
     }
   }
 ----
@@ -597,14 +597,6 @@
 
 Deletes the description of a project.
 
-The request body does not need to include a
-link:#project-description-input[ProjectDescriptionInput] entity if no
-commit message is specified.
-
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify a commit message, use
-link:#set-project-description[PUT] to delete the description.
-
 .Request
 ----
   DELETE /projects/plugins%2Freplication/description HTTP/1.0
@@ -615,6 +607,27 @@
   HTTP/1.1 204 No Content
 ----
 
+A commit message can be provided in the request body as a
+link:#project-description-input[ProjectDescriptionInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use link:#set-project-description[PUT] instead.
+
+.Request
+----
+  PUT /projects/plugins%2Freplication/description HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update the project description"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[get-project-parent]]
 === Get Project Parent
 --
@@ -1128,6 +1141,7 @@
     ],
     "can_upload": true,
     "can_add": true,
+    "can_add_tags": true,
     "config_visible": true,
     "groups": {
       "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
@@ -1178,21 +1192,19 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "remove": [
-      {
-        "refs/*": {
-          "permissions": {
-            "read": {
-              "rules": {
-                "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
-                  "action": "ALLOW"
-                }
+    "remove": {
+      "refs/*": {
+        "permissions": {
+          "read": {
+            "rules": {
+              "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
+                "action": "ALLOW"
               }
             }
           }
         }
       }
-    ]
+    }
   }
 ----
 
@@ -1229,6 +1241,7 @@
     ],
     "can_upload": true,
     "can_add": true,
+    "can_add_tags": true,
     "config_visible": true,
     "groups": {
       "global:Anonymous-Users": {
@@ -1258,14 +1271,14 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "add":{
-      "refs/heads/*":{
-        "permissions":{
-          "read":{
-            "rules":{
+    "add": {
+      "refs/heads/*": {
+        "permissions": {
+          "read": {
+            "rules": {
               "global:Anonymous-Users": {
-                "action":"DENY",
-                "force":false
+                "action": "DENY",
+                "force": false
               }
             }
           }
@@ -1307,27 +1320,12 @@
 [[check-access]]
 === Check Access
 --
-'POST /projects/MyProject/check.access'
+'GET /projects/MyProject/check.access?account=1000098&ref=refs%2Fheads%2Fsecret%2Fbla'
 --
 
-Runs access checks for other users. This requires the
-link:access-control.html#capability_viewAccess[View Access]
-global capability.
-
-Input for the access checks that should be run must be provided in
-the request body inside a
-link:#access-check-input[AccessCheckInput] entity.
-
-.Request
-----
-  POST /projects/MyProject/check.access HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "account": "Kristen.Burns@gerritcodereview.com",
-    "ref": "refs/heads/secret/bla"
-  }
-----
+This command runs access checks for other users. This requires the
+link:access-control.html#capability_viewAccess[View Access] global
+capability.
 
 The result is a link:#access-check-info[AccessCheckInfo] entity
 detailing the access of the given user for the given project,
@@ -1345,15 +1343,46 @@
   }
 ----
 
-This endpoint can also be accessed as a GET request, using the query
-parameters `perm`, `account` and `ref`, for example:
+[[check-access-options]]
+==== Check Access Options
 
-----
-  GET /projects/MyProject/check.access?account=10024&ref=refs/heads/secret/bla
-----
+Account(account)::
+The account for which to check access. Mandatory.
 
+Permission(perm)::
+The ref permission for which to check access. If not specified, read
+access to at least branch is checked.
+
+Ref(ref)::
+The branch for which to check access. This must be given if `perm` is specified.
 
 [[index]]
+=== Index project
+
+Adds or updates the current project (and children, if specified) in the secondary index.
+The indexing task is executed asynchronously in background and this command returns
+immediately if `async` is specified in the input.
+
+As an input, a link:#index-project-input[IndexProjectInput] entity can be provided.
+
+.Request
+----
+  POST /projects/MyProject/index HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "index_children": "true"
+    "async": "true"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Disposition: attachment
+----
+
+[[index.changes]]
 === Index all changes in a project
 
 Adds or updates all the changes belonging to a project in the secondary index.
@@ -1362,7 +1391,7 @@
 
 .Request
 ----
-  POST /projects/MyProject/index HTTP/1.0
+  POST /projects/MyProject/index.changes HTTP/1.0
 ----
 
 .Response
@@ -1371,6 +1400,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
 
@@ -2457,8 +2575,9 @@
   }
 ----
 
-As response a link:rest-api-changes.html#change-info[ChangeInfo] entity is returned that
-describes the resulting cherry-picked change.
+As response a link:rest-api-changes.html#cherry-pick-change-info[
+CherryPickChangeInfo] entity is returned that describes the resulting
+cherry-picked change.
 
 .Response
 ----
@@ -2486,6 +2605,51 @@
   }
 ----
 
+[[list-files]]
+=== List Files
+--
+'GET /projects/link:#project-name[\{project-name\}]/commits/link:#commit-id[\{commit-id\}]/files/'
+--
+
+Lists the files that were modified, added or deleted in a commit.
+
+.Request
+----
+  GET /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/files/ HTTP/1.0
+----
+
+As result a map is returned that maps the link:rest-api-changes.html#file-id[file path] to a
+link:rest-api-changes.html#file-info[FileInfo] entry. The entries in the map are
+sorted by file path.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "/COMMIT_MSG": {
+      "status": "A",
+      "lines_inserted": 7,
+      "size_delta": 551,
+      "size": 551
+    },
+    "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": {
+      "lines_inserted": 5,
+      "lines_deleted": 3,
+      "size_delta": 98,
+      "size": 23348
+    }
+  }
+----
+
+The integer-valued request parameter `parent` changes the response to return a
+list of the files which are different in this commit compared to the given
+parent commit. This is useful for supporting review of merge commits. The value
+is the 1-based index of the parent's position in the commit object.
+
 [[dashboard-endpoints]]
 == Dashboard Endpoints
 
@@ -2623,17 +2787,18 @@
   }
 ----
 
-[[set-dashboard]]
-=== Set Dashboard
+[[create-dashboard]]
+=== Create Dashboard
 --
 'PUT /projects/link:#project-name[\{project-name\}]/dashboards/link:#dashboard-id[\{dashboard-id\}]'
 --
 
-Updates/Creates a project dashboard.
+Creates a project dashboard, if a project dashboard with the given
+dashboard ID doesn't exist yet.
 
 Currently only supported for the `default` dashboard.
 
-The creation/update information for the dashboard must be provided in
+The creation information for the dashboard must be provided in
 the request body as a link:#dashboard-input[DashboardInput] entity.
 
 .Request
@@ -2647,7 +2812,63 @@
   }
 ----
 
-As response the new/updated dashboard is returned as a
+As response the new dashboard is returned as a link:#dashboard-info[
+DashboardInfo] entity.
+
+.Response
+----
+  HTTP/1.1 201 Created
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "main:closed",
+    "ref": "main",
+    "path": "closed",
+    "description": "Merged and abandoned changes in last 7 weeks",
+    "url": "/dashboard/?title\u003dClosed+changes\u0026Merged\u003dstatus:merged+age:7w\u0026Abandoned\u003dstatus:abandoned+age:7w",
+    "is_default": true,
+    "title": "Closed changes",
+    "sections": [
+      {
+        "name": "Merged",
+        "query": "status:merged age:7w"
+      },
+      {
+        "name": "Abandoned",
+        "query": "status:abandoned age:7w"
+      }
+    ]
+  }
+----
+
+[[update-dashboard]]
+=== Update Dashboard
+--
+'PUT /projects/link:#project-name[\{project-name\}]/dashboards/link:#dashboard-id[\{dashboard-id\}]'
+--
+
+Updates a project dashboard, if a project dashboard with the given
+dashboard ID already exists.
+
+Currently only supported for the `default` dashboard.
+
+The update information for the dashboard must be provided in
+the request body as a link:#dashboard-input[DashboardInput] entity.
+
+.Request
+----
+  PUT /projects/work%2Fmy-project/dashboards/default HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "id": "main:closed",
+    "commit_message": "Update the default dashboard"
+  }
+----
+
+As response the updated dashboard is returned as a
 link:#dashboard-info[DashboardInfo] entity.
 
 .Response
@@ -2747,24 +2968,55 @@
 |=========================================
 |Field Name                  ||Description
 |`status`                    ||The HTTP status code for the access.
-200 means success, 403 means denied and 404 means the project does not exist.
+200 means success and 403 means denied.
 |`message`                   |optional|A clarifying message if `status` is not 200.
 |=========================================
 
-[[access-check-input]]
-=== AccessCheckInput
-The `AccessCheckInput` entity is either an account or
-(account, ref) tuple for which we want to check access.
+[[auto_closeable_changes_check_input]]
+=== AutoCloseableChangesCheckInput
+The `AutoCloseableChangesCheckInput` entity contains options for running
+the link:#auto-closeable-changes-check[AutoCloseableChangesCheck].
 
-[options="header",cols="1,^1,5"]
-|=========================================
-|Field Name                  ||Description
-|`account`                   ||The account for which to check access
-|`ref`                       |optional|The refname for which to check
-access
-|`permission`                |optional|The ref permission for which to
-check. This defaults to `read`. If given, it `ref` must be given too.
-|=========================================
+[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
@@ -2823,6 +3075,55 @@
 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.
+|==================================================
+
+[[commentlink-info]]
+=== CommentLinkInfo
+The `CommentLinkInfo` entity describes a
+link:config-gerrit.html#commentlink[commentlink].
+
+[options="header",cols="1,^2,4"]
+|==================================================
+|Field Name |        |Description
+|`match`    |        |A JavaScript regular expression to match
+positions to be replaced with a hyperlink, as documented in
+link:config-gerrit.html#commentlink.name.match[commentlink.name.match].
+|`link`     |        |The URL to direct the user to whenever the
+regular expression is matched, as documented in
+link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`enabled`  |optional|Whether the commentlink is enabled, as documented
+in link:config-gerrit.html#commentlink.name.enabled[
+commentlink.name.enabled]. If not set the commentlink is enabled.
+|==================================================
+
 [[config-info]]
 === ConfigInfo
 The `ConfigInfo` entity contains information about the effective project
@@ -2854,7 +3155,8 @@
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether a
 valid link:user-changeid.html[Change-Id] footer in any commit uploaded
 for review is required. This does not apply to commits pushed directly
-to a branch or tag.
+to a branch or tag. This property is deprecated and will be removed in
+a future release.
 |`enable_signed_push`|optional, not set if signed push is disabled|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 signed push validation is enabled on the project.
@@ -2864,10 +3166,13 @@
 |`reject_implicit_merges`|optional|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 implicit merges should be rejected on changes pushed to the project.
-|`private_by_default`     ||
+|`private_by_default`         ||
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 all new changes are set as private by default.
-|`max_object_size_limit`     ||
+|`work_in_progress_by_default`||
+link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
+all new changes are set as work-in-progress by default.
+|`max_object_size_limit`      ||
 The link:config-gerrit.html#receive.maxObjectSizeLimit[max object size
 limit] of this project as a link:#max-object-size-limit-info[
 MaxObjectSizeLimitInfo] entity.
@@ -2885,13 +3190,8 @@
 Not set if the project state is `ACTIVE`.
 |`commentlinks`              ||
 Map with the comment link configurations of the project. The name of
-the comment link configuration is mapped to the comment link
-configuration, which has the same format as the
-link:config-gerrit.html#_a_id_commentlink_a_section_commentlink[
-commentlink section] of `gerrit.config`.
-|`theme`                                   |optional|
-The theme that is configured for the project as a link:#theme-info[
-ThemeInfo] entity.
+the comment link configuration is mapped to a link:#commentlink-info[
+CommentlinkInfo] entity.
 |`plugin_config`                           |optional|
 Plugin configuration as map which maps the plugin name to a map of
 parameter names to link:#config-parameter-info[ConfigParameterInfo]
@@ -2943,6 +3243,8 @@
 directly to a branch or tag. +
 Can be `TRUE`, `FALSE` or `INHERIT`. +
 If not set, this setting is not updated.
+This property is deprecated and will be removed in
+a future release.
 |`reject_implicit_merges`                  |optional|
 Whether a check for implicit merges will be performed when changes
 are pushed for review. +
@@ -3128,6 +3430,19 @@
 omitted.
 |============================
 
+[[index-project-input]]
+=== IndexProjectInput
+The `IndexProjectInput` contains parameters for indexing a project.
+
+[options="header",cols="1,^2,4"]
+|================================
+|Field Name         ||Description
+|`index_children`   ||
+If children should be indexed recursively.
+|`async`            ||
+If projects should be indexed asynchronously.
+|================================
+
 [[inherited-boolean-info]]
 === InheritedBooleanInfo
 A boolean value that can also be inherited.
@@ -3167,16 +3482,17 @@
 |===============================
 |Field Name        ||Description
 |`value`           |optional|
-The effective value of the max object size limit as a formatted string. +
+The effective value in bytes of the max object size limit. +
 Not set if there is no limit for the object size.
 |`configured_value`|optional|
 The max object size limit that is configured on the project as a
 formatted string. +
 Not set if there is no limit for the object size configured on project
 level.
-|`inherited_value` |optional|
-The max object size limit that is inherited as a formatted string. +
-Not set if there is no global limit for the object size.
+|`summary`         |optional|
+A string describing whether the value was inherited or overridden from
+the parent project or global config. +
+Not set if not inherited or overridden.
 |===============================
 
 [[project-access-input]]
@@ -3297,6 +3613,14 @@
 |`require_change_id`                           |`INHERIT` if not set|
 Whether the usage of Change-Ids is required for the project (`TRUE`,
 `FALSE`, `INHERIT`).
+This property is deprecated and will be removed in
+a future release.
+|`enable_signed_push`                           |`INHERIT` if not set|
+Whether signed push validation is enabled on the project  (`TRUE`,
+`FALSE`, `INHERIT`).
+|`require_signed_push`                          |`INHERIT` if not set|
+Whether signed push validation is required on the project  (`TRUE`,
+`FALSE`, `INHERIT`).
 |`max_object_size_limit`     |optional|
 Max allowed Git object size for this project.
 Common unit suffixes of 'k', 'm', or 'g' are supported.
@@ -3356,7 +3680,7 @@
 
 [[submit-type-info]]
 === SubmitTypeInfo
-Information about the link:project-configuration.html#submit_type[default submit
+Information about the link:config-project-config.html#submit-type[default submit
 type of a project], taking into account project inheritance.
 
 Valid values for each field are `MERGE_IF_NECESSARY`, `FAST_FORWARD_ONLY`,
@@ -3419,23 +3743,6 @@
 |=========================
 
 
-[[theme-info]]
-=== ThemeInfo
-The `ThemeInfo` entity describes a theme.
-
-[options="header",cols="1,^2,4"]
-|=============================
-|Field Name      ||Description
-|`css`           |optional|
-The path to the `GerritSite.css` file.
-|`header`        |optional|
-The path to the `GerritSiteHeader.html` file.
-|`footer`        |optional|
-The path to the `GerritSiteFooter.html` file.
-|=============================
-
-----
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 0957d32..a8ab353 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -191,6 +191,58 @@
 "`422 Unprocessable Entity`" is returned if the ID of a resource that is
 specified in the request body cannot be resolved.
 
+==== 429 Too Many Requests
+"`429 Too Many Requests`" is returned if the request exhausted any set
+quota limits. Depending on the exhausted quota, the request may be retried
+with exponential backoff.
+
+[[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..1f5e195 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 right 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:
+
+*  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.
+
+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].
+
 
 [[change-edit-actions]]
-== Change Edit Actions
+== Modifying Changes
 
-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.
-
-[[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.
-
-[[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
----------
diff --git a/Documentation/user-request-tracing.txt b/Documentation/user-request-tracing.txt
new file mode 100644
index 0000000..8430e97
--- /dev/null
+++ b/Documentation/user-request-tracing.txt
@@ -0,0 +1,72 @@
+= Request Tracing
+
+Gerrit supports on-demand tracing of single requests that results in
+additional logs with debug information that are written to the
+`error_log`. The logs that correspond to a traced request are
+associated with a unique trace ID. This trace ID is returned with the
+response and can be used by an administrator to find the matching log
+entries.
+
+How tracing is enabled and how the trace ID is returned depends on the
+request type:
+
+* REST API: For REST calls tracing can be enabled by setting the
+  `trace` request parameter or the `X-Gerrit-Trace` header, the trace
+  ID is returned as `X-Gerrit-Trace` header. More information about
+  this can be found in the link:rest-api.html#tracing[Request Tracing]
+  section of the link:rest-api.html[REST API documentation].
+* SSH API: For SSH calls tracing can be enabled by setting the
+  `--trace` option. More information about this can be found in
+  the link:cmd-index.html#trace[Trace] section of the
+  link:cmd-index.html[SSH command documentation].
+* Git: For Git pushes tracing can be enabled by setting the
+  `trace` push option, the trace ID is returned in the command output.
+  More information about this can be found in
+  the link:user-upload.html#trace[Trace] section of the
+  link:user-upload.html[upload documentation]. Tracing for Git requests
+  other than Git push is not supported.
+
+When request tracing is enabled it is possible to provide an ID that
+should be used as trace ID. If a trace ID is not provided a trace ID is
+automatically generated. The trace ID must be provided to the support
+team so that they can find the trace.
+
+When doing traces it is recommended to specify the ID of the issue
+that is being investigated as trace ID so that the traces of the issue
+can be found more easily. When the issue ID is used as trace ID there
+is no need to find the generated trace ID and report it in the issue.
+
+Since tracing consumes additional server resources tracing should only
+be enabled for single requests if there is a concrete need for
+debugging. In particular bots should never enable tracing for all their
+requests by default.
+
+== Find log entries for a trace ID
+
+If tracing is enabled all log messages that correspond to the traced
+request have a `TRACE_ID` tag set, e.g.:
+
+----
+[2018-08-13 15:28:08,913] [HTTP-76] TRACE com.google.gerrit.httpd.restapi.RestApiServlet : Received REST request: GET /a/accounts/self (parameters: [trace]) [CONTEXT forced=true TRACE_ID="1534166888910-3985dfba" ]
+[2018-08-13 15:28:08,914] [HTTP-76] TRACE com.google.gerrit.httpd.restapi.RestApiServlet : Calling user: admin [CONTEXT forced=true TRACE_ID="1534166888910-3985dfba" ]
+[2018-08-13 15:28:08,942] [HTTP-76] TRACE com.google.gerrit.httpd.restapi.RestApiServlet : REST call succeeded: 200 [CONTEXT forced=true TRACE_ID="1534166888910-3985dfba" ]
+----
+
+By doing a grep with the trace ID over the error log the log entries
+that correspond to the request can be found.
+
+== Which information is captured in a trace?
+
+* request details
+** REST API: request URL, request parameter names, calling user,
+   response code, response body on errors
+** SSH API: parameter names
+** Git API: push options, magic branch parameter names
+* cache misses, cache evictions
+* reads from NoteDb, writes to NoteDb
+* reads of meta data files, writes of meta data files
+* index queries (with parameters and matches)
+* reindex events
+* permission checks (e.g. which rule is responsible for a deny)
+* timer metrics
+* all other logs
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 3804734d..de17c00 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -258,7 +258,9 @@
 For open or abandoned changes, the `Delete Change` button will be available
 and if the user is the change owner and is granted the
 link:access-control.html#category_delete_own_changes[Delete Own Changes]
-permission or if they are an administrator.
+permission, if they are granted the
+link:access-control.html#category_delete_changes[Delete Changes] permission,
+or if they are an administrator.
 
 ** [[plugin-actions]]Further actions may be available if plugins are installed.
 
@@ -305,7 +307,7 @@
 The type of a file modification is indicated by the character in front
 of the file name:
 
-- 'no character' (Modified):
+- `M` or 'no character' (Modified):
 +
 The file existed before this change and is modified.
 
@@ -325,6 +327,13 @@
 +
 The file is new and is copied from an existing file.
 
+- `U` (Unchanged):
++
+The file is unchanged and has the same content. Unchanged files only
+appear in the file list if 2 patch sets are compared and the file has
+comments on at least one of the sides. Otherwise unchanged files are
+filtered out.
+
 image::images/user-review-ui-change-screen-file-list-modification-type.png[width=800, link="images/user-review-ui-change-screen-file-list-modification-type.png"]
 
 [[rename-or-copy]]
@@ -422,7 +431,7 @@
 
 The available download commands depend on the installed Gerrit plugins.
 The most popular plugin for download commands, the
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/download-commands[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/download-commands[
 download-commands] plugin, provides commands to checkout, pull and
 cherry-pick a patch set.
 
diff --git a/Documentation/user-search-projects.txt b/Documentation/user-search-projects.txt
index ba20adb..8ebbf3e 100644
--- a/Documentation/user-search-projects.txt
+++ b/Documentation/user-search-projects.txt
@@ -12,6 +12,11 @@
 +
 Matches projects that have exactly the name 'NAME'.
 
+[[parent]]
+parent:'PARENT'::
++
+Matches projects that have 'PARENT' as parent project.
+
 [[inname]]
 inname:'NAME'::
 +
@@ -24,6 +29,11 @@
 Matches projects whose description contains 'DESCRIPTION', using a
 full-text search.
 
+[[state]]
+state:'STATE'::
++
+Matches project's state. Can be either 'active' or 'read-only'.
+
 == Magical Operators
 
 [[is-visible]]
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index bebe81b..bee723e 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -53,7 +53,7 @@
 +
 Amount of time that has expired since the change was last updated
 with a review comment or new patch set.  The age must be specified
-to include a unit suffix, for example `age:2d`:
+to include a unit suffix, for example `-age:2d`:
 +
 * s, sec, second, seconds
 * m, min, minute, minutes
@@ -63,6 +63,10 @@
 * mon, month, months (`1 month` is treated as `30 days`)
 * y, year, years (`1 year` is treated as `365 days`)
 
+`age` can be used both forward and backward looking: `age:2d`
+means 'everything older than 2 days' while `-age:2d` means
+'everything with an age of at most 2 days'.
+
 [[assignee]]
 assignee:'USER'::
 +
@@ -165,6 +169,25 @@
 Changes occurring in 'PROJECT' or in one of the child projects of
 'PROJECT'.
 
+[[repository]]
+repository:'REPOSITORY', repo:'REPOSITORY'::
++
+Changes occurring in 'REPOSITORY'. If 'REPOSITORY' starts with `^` it
+matches repository names by regular expression.  The
+link:http://www.brics.dk/automaton/[dk.brics.automaton
+library] is used for evaluation of such patterns.
+
+[[repositories]]
+repositories:'PREFIX', repos:'PREFIX'::
++
+Changes occurring in repositories starting with 'PREFIX'.
+
+[[parentrepository]]
+parentrepository:'REPOSITORY', parentrepo:'REPOSITORY'::
++
+Changes occurring in 'REPOSITORY' or in one of the child repositories of
+'REPOSITORY'.
+
 [[branch]]
 branch:'BRANCH'::
 +
@@ -197,7 +220,8 @@
 [[hashtag]]
 hashtag:'HASHTAG'::
 +
-Changes whose link:intro-user.html#hashtags[hashtag] matches 'HASHTAG' exactly.
+Changes whose link:intro-user.html#hashtags[hashtag] matches 'HASHTAG'.
+The match is case-insensitive.
 
 [[ref]]
 ref:'REF'::
@@ -257,6 +281,8 @@
 ones using a bracket expression). For example, to match all XML
 files named like 'name1.xml', 'name2.xml', and 'name3.xml' use
 `file:"^name[1-3].xml"`.
++
+Slash ('/') is used path separator.
 
 [[file]]
 file:'NAME', f:'NAME'::
@@ -270,6 +296,46 @@
 Regular expression matching can be enabled by starting the string
 with `^`. In this mode `file:` is an alias of `path:` (see above).
 
+[[extension]]
+extension:'EXT', ext:'EXT'::
++
+Matches any change touching a file with extension 'EXT', case-insensitive. The
+extension is defined as the portion of the filename following the final `.`.
+Files with no `.` in their name have no extension and can be matched by an
+empty string.
+
+[[onlyextensions]]
+onlyextensions:'EXT_LIST', onlyexts:'EXT_LIST'::
++
+Matches any change touching only files with extensions that are listed in
+'EXT_LIST' (comma-separated list). The matching is done case-insensitive.
+An extension is defined as the portion of the filename following the final `.`.
+Files with no `.` in their name have no extension and can be matched by an
+empty string.
+
+[[directory]]
+directory:'DIR', dir:'DIR'::
++
+Matches any change where the current patch set touches a file in the directory
+'DIR'. The matching is done case-insensitive. 'DIR' can be a full directory
+name, a directory prefix or any combination of intermediate directory segments.
+E.g. a change that touches a file in the directory 'a/b/c' matches for 'a/b/c',
+'a', 'a/b', 'b', 'b/c' and 'c'.
++
+Slash ('/') is used path separator. Leading and trailing slashes are allowed
+but are not mandatory.
++
+If 'DIR' starts with `^` it matches directories and directory segments by
+regular expression. The link:http://www.brics.dk/automaton/[dk.brics.automaton
+library] is used for evaluation of such patterns.
+
+[[footer-operator]]
+footer:'FOOTER'::
++
+Matches any change that has 'FOOTER' as footer in the commit message of the
+current patch set. 'FOOTER' can be specified verbatim ('<key>: <value>', must
+be quoted) or as '<key>=<value>'. The matching is done case-insensitive.
+
 [[star]]
 star:'LABEL'::
 +
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index ce62b93..5bf49cd 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -309,6 +309,11 @@
 Only change owners, project owners and site administrators can specify
 `work-in-progress` and `ready` options on push.
 
+The default for this option can be set as a
+link:intro-user.html#work-in-progress-by-default[user preference]. If the
+preference is set so the default behavior is to create `work-in-progress`
+changes, this can be overridden with the `ready` option.
+
 [[message]]
 ==== Message
 
@@ -416,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
@@ -435,69 +470,6 @@
 
 For more about Change-Ids, see link:user-changeid.html[Change-Id Lines].
 
-[[manual_replacement_mapping]]
-==== Manual Replacement Mapping
-
-[NOTE]
---
-The remainder of this section describes a manual method of replacing
-changes by matching each commit name to an existing change number.
-End-users should instead prefer to use Change-Id lines in their
-commit messages, as the process is then fully automated by Gerrit
-during normal uploads.
-
-See above for the preferred technique of replacing changes.
-
-Pushing directly to `refs/changes/` is deprecated. If you see the error
-message 'upload to refs/changes not allowed', it means that pushing directly
-to `refs/changes` is disabled on the Gerrit server and the below section does
-not apply to you.
---
-
-To add an additional patch set to a change, replacing it with an
-updated version of the same logical modification, send the new
-commit to the change's ref.  For example, to add the commit whose
-SHA-1 starts with `c0ffee` as a new patch set for change number
-`1979`, use the push refspec `c0ffee:refs/changes/1979` as below:
-
-----
-  git push ssh://sshusername@hostname:29418/projectname c0ffee:refs/changes/1979
-----
-
-This form can be combined together with `refs/for/'branchname'`
-(above) to simultaneously create new changes and replace changes
-during one network transaction.
-
-For example, consider the following sequence of events:
-
-----
-  $ git commit -m A                    ; # create 3 commits
-  $ git commit -m B
-  $ git commit -m C
-
-  $ git push ... HEAD:refs/for/master  ; # upload for review
-  ... A is 1500 ...
-  ... B is 1501 ...
-  ... C is 1502 ...
-
-  $ git rebase -i HEAD~3               ; # edit "A", insert D before B
-                                       ; # now series is A'-D-B'-C'
-  $ git push ...
-      HEAD:refs/for/master
-      HEAD~3:refs/changes/1500
-      HEAD~1:refs/changes/1501
-      HEAD~0:refs/changes/1502         ; # upload replacements
-----
-
-At the final step during the push Gerrit will attach A' as a new
-patch set on change 1500; B' as a new patch set on change 1501; C'
-as a new patch set on 1502; and D will be created as a new change.
-
-Ensuring D is created as a new change requires passing the refspec
-`HEAD:refs/for/branchname`, otherwise Gerrit will ignore D and
-won't do anything with it.  For this reason it is a good idea to
-always include the create change refspec when uploading replacements.
-
 
 [[bypass_review]]
 === Bypass Review
@@ -697,7 +669,7 @@
 Gerrit to provide magical refs, such as `+refs/for/*+` for new
 change submission and `+refs/changes/*+` for change replacement.
 When a push request is received to create a ref in one of these
-namespaces Gerrit performs its own logic to update the database,
+namespaces Gerrit performs its own logic to update the review metadata,
 and then lies to the client about the result of the operation.
 A successful result causes the client to believe that Gerrit has
 created the ref, but in reality Gerrit hasn't created the ref at all.
diff --git a/README.md b/README.md
index 844ee48..a76dac6 100644
--- a/README.md
+++ b/README.md
@@ -39,9 +39,6 @@
 
 ## Getting in contact
 
-The IRC channel on freenode is #gerrit. An archive is available at:
-[echelog.com](https://echelog.com/logs/browse/gerrit).
-
 The Developer Mailing list is [repo-discuss on Google Groups](https://groups.google.com/forum/#!forum/repo-discuss).
 
 ## License
@@ -52,7 +49,7 @@
 
 Install [Bazel](https://bazel.build/versions/master/docs/install.html) and run the following:
 
-        git clone --recursive https://gerrit.googlesource.com/gerrit
+        git clone --recurse-submodules https://gerrit.googlesource.com/gerrit
         cd gerrit && bazel build release
 
 ## Install binary packages (Deb/Rpm)
diff --git a/WORKSPACE b/WORKSPACE
index e320fb1..350dba1 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,24 +1,57 @@
 workspace(name = "gerrit")
 
+load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "maven_jar")
-load("//lib/codemirror:cm.bzl", "CM_VERSION", "DIFF_MATCH_PATCH_VERSION")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
+load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
+
+http_archive(
+    name = "bazel_toolchains",
+    sha256 = "88e818f9f03628eef609c8429c210ecf265ffe46c2af095f36c7ef8b1855fef5",
+    strip_prefix = "bazel-toolchains-92dd8a7a518a2fb7ba992d47c8b38299fe0be825",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
+        "https://github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
+    ],
+)
+
+load("@bazel_toolchains//rules:rbe_repo.bzl", "rbe_autoconfig")
+
+# Creates a default toolchain config for RBE.
+# Use this as is if you are using the rbe_ubuntu16_04 container,
+# otherwise refer to RBE docs.
+rbe_autoconfig(name = "rbe_default")
+
+http_archive(
+    name = "bazel_toolchains",
+    sha256 = "88e818f9f03628eef609c8429c210ecf265ffe46c2af095f36c7ef8b1855fef5",
+    strip_prefix = "bazel-toolchains-92dd8a7a518a2fb7ba992d47c8b38299fe0be825",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
+        "https://github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
+    ],
+)
+
+load("@bazel_toolchains//rules:rbe_repo.bzl", "rbe_autoconfig")
+
+# Creates a default toolchain config for RBE.
+# Use this as is if you are using the rbe_ubuntu16_04 container,
+# otherwise refer to RBE docs.
+rbe_autoconfig(name = "rbe_default")
 
 http_archive(
     name = "bazel_skylib",
-    sha256 = "bbccf674aa441c266df9894182d80de104cabd19be98be002f6d478aaa31574d",
-    strip_prefix = "bazel-skylib-2169ae1c374aab4a09aa90e65efe1a3aad4e279b",
-    urls = ["https://github.com/bazelbuild/bazel-skylib/archive/2169ae1c374aab4a09aa90e65efe1a3aad4e279b.tar.gz"],
+    sha256 = "2ea8a5ed2b448baf4a6855d3ce049c4c452a6470b1efd1504fdb7c1c134d220a",
+    strip_prefix = "bazel-skylib-0.8.0",
+    urls = ["https://github.com/bazelbuild/bazel-skylib/archive/0.8.0.tar.gz"],
 )
 
 http_archive(
     name = "io_bazel_rules_closure",
-    build_file_content = "exports_files([\"0001-Replace-native-http-git-_archive-with-Skylark-rules.patch\"])",
-    patches = ["//:0001-Replace-native-http-git-_archive-with-Skylark-rules.patch"],
-    sha256 = "a80acb69c63d5f6437b099c111480a4493bad4592015af2127a2f49fb7512d8d",
-    strip_prefix = "rules_closure-0.7.0",
-    url = "https://github.com/bazelbuild/rules_closure/archive/0.7.0.tar.gz",
+    sha256 = "d075b084e6f4109d1b1ab877495ac72c1a6c4dbc593980967e0b7359f4254d7e",
+    strip_prefix = "rules_closure-78f1192664acf66ca1de24116cbcc98e1698f26b",
+    urls = ["https://github.com/bazelbuild/rules_closure/archive/78f1192664acf66ca1de24116cbcc98e1698f26b.tar.gz"],
 )
 
 # File is specific to Polymer and copied from the Closure Github -- should be
@@ -26,23 +59,60 @@
 # https://github.com/google/closure-compiler/blob/master/contrib/externs/polymer-1.0.js
 http_file(
     name = "polymer_closure",
-    sha256 = "5a589bdba674e1fec7188e9251c8624ebf2d4d969beb6635f9148f420d1e08b1",
-    urls = ["https://raw.githubusercontent.com/google/closure-compiler/775609aad61e14aef289ebec4bfc09ad88877f9e/contrib/externs/polymer-1.0.js"],
+    downloaded_file_path = "polymer_closure.js",
+    sha256 = "4d63a36dcca040475bd6deb815b9a600bd686e1413ac1ebd4b04516edd675020",
+    urls = ["https://raw.githubusercontent.com/google/closure-compiler/35d2b3340ff23a69441f10fa3bc820691c2942f2/contrib/externs/polymer-1.0.js"],
 )
 
-load("@bazel_skylib//:lib.bzl", "versions")
+load("@bazel_skylib//lib:versions.bzl", "versions")
 
-versions.check(minimum_bazel_version = "0.14.0")
+versions.check(minimum_bazel_version = "0.26.1")
 
 load("@io_bazel_rules_closure//closure:defs.bzl", "closure_repositories")
 
 # Prevent redundant loading of dependencies.
+# TODO(davido): Omit re-fetching ancient args4j version when these PRs are merged:
+# https://github.com/bazelbuild/rules_closure/pull/262
+# https://github.com/google/closure-templates/pull/155
 closure_repositories(
     omit_aopalliance = True,
-    omit_args4j = True,
+    omit_bazel_skylib = True,
     omit_javax_inject = True,
 )
 
+# Golang support for PolyGerrit local dev server.
+http_archive(
+    name = "io_bazel_rules_go",
+    sha256 = "f04d2373bcaf8aa09bccb08a98a57e721306c8f6043a2a0ee610fd6853dcde3d",
+    urls = [
+        "https://storage.googleapis.com/bazel-mirror/github.com/bazelbuild/rules_go/releases/download/0.18.6/rules_go-0.18.6.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/0.18.6/rules_go-0.18.6.tar.gz",
+    ],
+)
+
+load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
+
+go_rules_dependencies()
+
+go_register_toolchains()
+
+http_archive(
+    name = "bazel_gazelle",
+    sha256 = "3c681998538231a2d24d0c07ed5a7658cb72bfb5fd4bf9911157c0e9ac6a2687",
+    urls = ["https://github.com/bazelbuild/bazel-gazelle/releases/download/0.17.0/bazel-gazelle-0.17.0.tar.gz"],
+)
+
+load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies", "go_repository")
+
+gazelle_dependencies()
+
+# Dependencies for PolyGerrit local dev server.
+go_repository(
+    name = "com_github_howeyc_fsnotify",
+    commit = "441bbc86b167f3c1f4786afae9931403b99fdacf",
+    importpath = "github.com/howeyc/fsnotify",
+)
+
 ANTLR_VERS = "3.5.2"
 
 maven_jar(
@@ -70,24 +140,24 @@
     sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
 )
 
-GUICE_VERS = "4.2.0"
+GUICE_VERS = "4.2.2"
 
 maven_jar(
     name = "guice-library",
     artifact = "com.google.inject:guice:" + GUICE_VERS,
-    sha1 = "25e1f4c1d528a1cffabcca0d432f634f3132f6c8",
+    sha1 = "6dacbe18e5eaa7f6c9c36db33b42e7985e94ce77",
 )
 
 maven_jar(
     name = "guice-assistedinject",
     artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
-    sha1 = "e7270305960ad7db56f7e30cb9df6be9ff1cfb45",
+    sha1 = "c33fb10080d58446f752b4fcfff8a5fabb80a449",
 )
 
 maven_jar(
     name = "guice-servlet",
     artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
-    sha1 = "f57581625c36c148f088d9f52a568d5bdf12c61d",
+    sha1 = "0d0054bdd812224078357a9b11409e43d182a046",
 )
 
 maven_jar(
@@ -108,61 +178,6 @@
     sha1 = "021a212688ec94fe77aff74ab34cc74f6f940e60",
 )
 
-GWT_VERS = "2.8.2"
-
-maven_jar(
-    name = "user",
-    artifact = "com.google.gwt:gwt-user:" + GWT_VERS,
-    sha1 = "a2b9be2c996a658c4e009ba652a9c6a81c88a797",
-)
-
-maven_jar(
-    name = "dev",
-    artifact = "com.google.gwt:gwt-dev:" + GWT_VERS,
-    sha1 = "7a87e060bbf129386b7ae772459fb9f87297c332",
-)
-
-maven_jar(
-    name = "javax-validation",
-    artifact = "javax.validation:validation-api:1.0.0.GA",
-    sha1 = "b6bd7f9d78f6fdaa3c37dae18a4bd298915f328e",
-    src_sha1 = "7a561191db2203550fbfa40d534d4997624cd369",
-)
-
-maven_jar(
-    name = "jsinterop-annotations",
-    artifact = "com.google.jsinterop:jsinterop-annotations:1.0.2",
-    sha1 = "abd7319f53d018e11108a88f599bd16492448dd2",
-    src_sha1 = "33716f8aef043f2f02b78ab4a1acda6cd90a7602",
-)
-
-maven_jar(
-    name = "ant",
-    artifact = "ant:ant:1.6.5",
-    attach_source = False,
-    sha1 = "7d18faf23df1a5c3a43613952e0e8a182664564b",
-)
-
-maven_jar(
-    name = "colt",
-    artifact = "colt:colt:1.2.0",
-    attach_source = False,
-    sha1 = "0abc984f3adc760684d49e0f11ddf167ba516d4f",
-)
-
-maven_jar(
-    name = "tapestry",
-    artifact = "tapestry:tapestry:4.0.2",
-    attach_source = False,
-    sha1 = "e855a807425d522e958cbce8697f21e9d679b1f7",
-)
-
-maven_jar(
-    name = "w3c-css-sac",
-    artifact = "org.w3c.css:sac:1.3",
-    sha1 = "cdb2dcb4e22b83d6b32b93095f644c3462739e82",
-)
-
 load("//lib/jgit:jgit.bzl", "jgit_repos")
 
 jgit_repos()
@@ -174,50 +189,36 @@
     sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
 )
 
-FLOGGER_VERS = "0.2"
+maven_jar(
+    name = "error-prone-annotations",
+    artifact = "com.google.errorprone:error_prone_annotations:2.3.3",
+    sha1 = "42aa5155a54a87d70af32d4b0d06bf43779de0e2",
+)
+
+FLOGGER_VERS = "0.4"
 
 maven_jar(
     name = "flogger",
     artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-    sha1 = "a22d04ed3b84bae8ecf8aa6d4430ad000bcdf7b4",
+    sha1 = "9c8863dcc913b56291c0c88e6d4ca9715b43df98",
 )
 
 maven_jar(
     name = "flogger-log4j-backend",
     artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS,
-    sha1 = "d5085e3996bddc4b105d53b886190cc9a8811a9e",
+    sha1 = "17aa5e31daa1354187e14b6978597d630391c028",
 )
 
 maven_jar(
     name = "flogger-system-backend",
     artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
-    sha1 = "b995c84b8443d6cfbd011a55719b63494b974c3a",
-)
-
-maven_jar(
-    name = "gwtjsonrpc",
-    artifact = "com.google.gerrit:gwtjsonrpc:1.11",
-    sha1 = "0990e7eec9eec3a15661edcf9232acbac4aeacec",
-    src_sha1 = "a682afc46284fb58197a173cb5818770a1e7834a",
+    sha1 = "287b569d76abcd82f9de87fe41829fbc7ebd8ac9",
 )
 
 maven_jar(
     name = "gson",
-    artifact = "com.google.code.gson:gson:2.8.4",
-    sha1 = "d0de1ca9b69e69d1d497ee3c6009d015f64dad57",
-)
-
-maven_jar(
-    name = "gwtorm-client",
-    artifact = "com.google.gerrit:gwtorm:1.18",
-    sha1 = "f326dec463439a92ccb32f05b38345e21d0b5ecf",
-    src_sha1 = "e0b973d5cafef3d145fa80cdf032fcead1186d29",
-)
-
-maven_jar(
-    name = "protobuf",
-    artifact = "com.google.protobuf:protobuf-java:3.5.1",
-    sha1 = "8c3492f7662fa1cbf8ca76a0f5eb1146f7725acd",
+    artifact = "com.google.code.gson:gson:2.8.5",
+    sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
 )
 
 load("//lib:guava.bzl", "GUAVA_BIN_SHA1", "GUAVA_VERSION")
@@ -229,6 +230,12 @@
 )
 
 maven_jar(
+    name = "guava-failureaccess",
+    artifact = "com.google.guava:failureaccess:1.0.1",
+    sha1 = "1dcf1de382a0bf95a3d8b0849546c88bac1292c9",
+)
+
+maven_jar(
     name = "j2objc",
     artifact = "com.google.j2objc:j2objc-annotations:1.1",
     sha1 = "ed28ded51a8b1c6b112568def5f4b455e6809019",
@@ -246,30 +253,30 @@
     sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
 )
 
-SLF4J_VERS = "1.7.7"
+SLF4J_VERS = "1.7.26"
 
 maven_jar(
     name = "log-api",
     artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
-    sha1 = "2b8019b6249bb05d81d3a3094e468753e2b21311",
+    sha1 = "77100a62c2e6f04b53977b9f541044d7d722693d",
 )
 
 maven_jar(
     name = "log-ext",
     artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
-    sha1 = "09a8f58c784c37525d2624062414358acf296717",
+    sha1 = "31cdf122e000322e9efcb38913e9ab07825b17ef",
 )
 
 maven_jar(
     name = "impl-log4j",
     artifact = "org.slf4j:slf4j-log4j12:" + SLF4J_VERS,
-    sha1 = "58f588119ffd1702c77ccab6acb54bfb41bed8bd",
+    sha1 = "12f5c685b71c3027fd28bcf90528ec4ec74bf818",
 )
 
 maven_jar(
     name = "jcl-over-slf4j",
     artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
-    sha1 = "56003dcd0a31deea6391b9e2ef2f2dc90b205a92",
+    sha1 = "33fbc2d93de829fa5e263c5ce97f5eab8f57d53e",
 )
 
 maven_jar(
@@ -291,9 +298,9 @@
 )
 
 maven_jar(
-    name = "args4j",
-    artifact = "args4j:args4j:2.0.26",
-    sha1 = "01ebb18ebb3b379a74207d5af4ea7c8338ebd78b",
+    name = "args4j-intern",
+    artifact = "args4j:args4j:2.33",
+    sha1 = "bd87a75374a6d6523de82fef51fc3cfe9baf9fc9",
 )
 
 maven_jar(
@@ -305,8 +312,8 @@
 # When upgrading commons-compress, also upgrade tukaani-xz
 maven_jar(
     name = "commons-compress",
-    artifact = "org.apache.commons:commons-compress:1.15",
-    sha1 = "b686cd04abaef1ea7bc5e143c080563668eec17e",
+    artifact = "org.apache.commons:commons-compress:1.18",
+    sha1 = "1191f9f2bc0c47a8cce69193feb1ff0a8bcb37d5",
 )
 
 maven_jar(
@@ -317,8 +324,14 @@
 
 maven_jar(
     name = "commons-lang3",
-    artifact = "org.apache.commons:commons-lang3:3.6",
-    sha1 = "9d28a6b23650e8a7e9063c04588ace6cf7012c17",
+    artifact = "org.apache.commons:commons-lang3:3.8.1",
+    sha1 = "6505a72a097d9270f7a9e7bf42c4238283247755",
+)
+
+maven_jar(
+    name = "commons-text",
+    artifact = "org.apache.commons:commons-text:1.2",
+    sha1 = "74acdec7237f576c4803fff0c1008ab8a3808b2b",
 )
 
 maven_jar(
@@ -327,6 +340,8 @@
     sha1 = "30be73c965cc990b153a100aaaaafcf239f82d39",
 )
 
+# Transitive dependency of commons-dbcp, do not update without
+# also updating commons-dbcp
 maven_jar(
     name = "commons-pool",
     artifact = "commons-pool:commons-pool:1.5.5",
@@ -351,22 +366,190 @@
     sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241",
 )
 
+COMMONMARK_VERS = "0.10.0"
+
+# commonmark must match the version used in Gitiles
 maven_jar(
-    name = "pegdown",
-    artifact = "org.pegdown:pegdown:1.6.0",
-    sha1 = "231ae49d913467deb2027d0b8a0b68b231deef4f",
+    name = "commonmark",
+    artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
+    sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
 )
 
 maven_jar(
-    name = "grappa",
-    artifact = "com.github.parboiled1:grappa:1.0.4",
-    sha1 = "ad4b44b9c305dad7aa1e680d4b5c8eec9c4fd6f5",
+    name = "cm-autolink",
+    artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
+    sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
 )
 
 maven_jar(
-    name = "jitescript",
-    artifact = "me.qmx.jitescript:jitescript:0.4.0",
-    sha1 = "2e35862b0435c1b027a21f3d6eecbe50e6e08d54",
+    name = "gfm-strikethrough",
+    artifact = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERS,
+    sha1 = "40837da951b421b545edddac57012e15fcc9e63c",
+)
+
+maven_jar(
+    name = "gfm-tables",
+    artifact = "com.atlassian.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERS,
+    sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
+)
+
+FLEXMARK_VERS = "0.34.18"
+
+maven_jar(
+    name = "flexmark",
+    artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
+    sha1 = "65cc1489ef8902023140900a3a7fcce89fba678d",
+)
+
+maven_jar(
+    name = "flexmark-ext-abbreviation",
+    artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
+    sha1 = "a0384932801e51f16499358dec69a730739aca3f",
+)
+
+maven_jar(
+    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 and gitiles
+maven_jar(
+    name = "autolink",
+    artifact = "org.nibor.autolink:autolink:0.7.0",
+    sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
 )
 
 GREENMAIL_VERS = "1.5.5"
@@ -405,89 +588,84 @@
     sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
-OW2_VERS = "6.0"
+OW2_VERS = "7.0"
 
 maven_jar(
     name = "ow2-asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "bc6fa6b19424bb9592fe43bbc20178f92d403105",
+    sha1 = "d74d4ba0dee443f68fb2dcb7fcdb945a2cd89912",
 )
 
 maven_jar(
     name = "ow2-asm-analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "dd1cc1381a970800268160203aae2d3784da779b",
+    sha1 = "4b310d20d6f1c6b7197a75f1b5d69f169bc8ac1f",
 )
 
 maven_jar(
     name = "ow2-asm-commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "f256fd215d8dd5a4fa2ab3201bf653de266ed4ec",
+    sha1 = "478006d07b7c561ae3a92ddc1829bca81ae0cdd1",
 )
 
 maven_jar(
     name = "ow2-asm-tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "a624f1a6e4e428dcd680a01bab2d4c56b35b18f0",
+    sha1 = "29bc62dcb85573af6e62e5b2d735ef65966c4180",
 )
 
 maven_jar(
     name = "ow2-asm-util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "430b2fc839b5de1f3643b528853d5cf26096c1de",
+    sha1 = "18d4d07010c24405129a6dbb0e92057f8779fb9d",
 )
 
-AUTO_VALUE_VERSION = "1.6"
+AUTO_VALUE_VERSION = "1.6.5"
 
 maven_jar(
     name = "auto-value",
     artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-    sha1 = "a3b1b1404f8acaa88594a017185e013cd342c9a8",
+    sha1 = "816872c85048f36a67a276ef7a49cc2e4595711c",
 )
 
 maven_jar(
     name = "auto-value-annotations",
     artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-    sha1 = "da725083ee79fdcd86d9f3d8a76e38174a01892a",
+    sha1 = "c3dad10377f0e2242c9a4b88e9704eaf79103679",
 )
 
-# Transitive dependency of commons-compress
-maven_jar(
-    name = "tukaani-xz",
-    artifact = "org.tukaani:xz:1.6",
-    sha1 = "05b6f921f1810bdf90e25471968f741f87168b64",
-)
+declare_nongoogle_deps()
 
-LUCENE_VERS = "5.5.4"
+LUCENE_VERS = "6.6.5"
 
 maven_jar(
     name = "lucene-core",
     artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-    sha1 = "ab9c77e75cf142aa6e284b310c8395617bd9b19b",
+    sha1 = "2983f80b1037e098209657b0ca9176827892d0c0",
 )
 
 maven_jar(
     name = "lucene-analyzers-common",
     artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-    sha1 = "08ce9d34c8124c80e176e8332ee947480bbb9576",
+    sha1 = "6094f91071d90570b7f5f8ce481d5de7d2d2e9d5",
 )
 
 maven_jar(
     name = "backward-codecs",
     artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-    sha1 = "a933f42e758c54c43083398127ea7342b54d8212",
+    sha1 = "460a19e8d1aa7d31e9614cf528a6cb508c9e823d",
 )
 
 maven_jar(
     name = "lucene-misc",
     artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-    sha1 = "a74388857f73614e528ae44d742c60187cb55a5a",
+    sha1 = "ce3a1b7b6a92b9af30791356a4bd46d1cea6cc1e",
 )
 
 maven_jar(
     name = "lucene-queryparser",
     artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-    sha1 = "8a06fad4675473d98d93b61fea529e3f464bf69e",
+    sha1 = "2db9ca0086a4b8e0b9bc9f08a9b420303168e37c",
 )
 
 maven_jar(
@@ -497,7 +675,7 @@
     sha1 = "0c9cfae15c74f62491d4f28def0dff1dabe52a47",
 )
 
-PROLOG_VERS = "1.4.3"
+PROLOG_VERS = "1.4.4"
 
 PROLOG_REPO = GERRIT
 
@@ -506,7 +684,7 @@
     artifact = "com.googlecode.prolog-cafe:prolog-runtime:" + PROLOG_VERS,
     attach_source = False,
     repository = PROLOG_REPO,
-    sha1 = "d5206556cbc76ffeab21313ffc47b586a1efbcbb",
+    sha1 = "e9a364f4233481cce63239e8e68a6190c8f58acd",
 )
 
 maven_jar(
@@ -514,7 +692,7 @@
     artifact = "com.googlecode.prolog-cafe:prolog-compiler:" + PROLOG_VERS,
     attach_source = False,
     repository = PROLOG_REPO,
-    sha1 = "f37032cf1dec3e064427745bc59da5a12757a3b2",
+    sha1 = "570295026f6aa7b905e423d107cb2e081eecdc04",
 )
 
 maven_jar(
@@ -522,7 +700,7 @@
     artifact = "com.googlecode.prolog-cafe:prolog-io:" + PROLOG_VERS,
     attach_source = False,
     repository = PROLOG_REPO,
-    sha1 = "d02b2640b26f64036b6ba2b45e4acc79281cea17",
+    sha1 = "1f25c4e27d22bdbc31481ee0c962a2a2853e4428",
 )
 
 maven_jar(
@@ -530,7 +708,7 @@
     artifact = "com.googlecode.prolog-cafe:prolog-cafeteria:" + PROLOG_VERS,
     attach_source = False,
     repository = PROLOG_REPO,
-    sha1 = "e3b1860c63e57265e5435f890263ad82dafa724f",
+    sha1 = "0e6c2deeaf5054815a561cbd663566fd59b56c6c",
 )
 
 maven_jar(
@@ -545,25 +723,43 @@
     sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
 )
 
+GITILES_VERS = "0.3-2"
+
+GITILES_REPO = GERRIT
+
 maven_jar(
     name = "blame-cache",
-    artifact = "com/google/gitiles:blame-cache:0.2-6",
+    artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
     attach_source = False,
-    repository = GERRIT,
-    sha1 = "64827f1bc2cbdbb6515f1d29ce115db94c03bb6a",
+    repository = GITILES_REPO,
+    sha1 = "f19d4ccddad1e39165ff4c60a723f5e543a02f80",
+)
+
+maven_jar(
+    name = "gitiles-servlet",
+    artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
+    repository = GITILES_REPO,
+    sha1 = "1d4fd7358d6798cc4f4ecf5d6336523c566d7618",
+)
+
+# prettify must match the version used in Gitiles
+maven_jar(
+    name = "prettify",
+    artifact = "com.github.twalcari:java-prettify:1.2.2",
+    sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
 )
 
 # Keep this version of Soy synchronized with the version used in Gitiles.
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2018-03-14",
-    sha1 = "76a1322705ba5a6d6329ee26e7387417725ce4b3",
+    artifact = "com.google.template:soy:2019-07-14",
+    sha1 = "547dee679bac6011126f3a54619d3aec336216d0",
 )
 
 maven_jar(
     name = "html-types",
-    artifact = "com.google.common.html.types:types:1.0.4",
-    sha1 = "2adf4c8bfccc0ff7346f9186ac5aa57d829ad065",
+    artifact = "com.google.common.html.types:types:1.0.8",
+    sha1 = "9e9cf7bc4b2a60efeb5f5581fe46d17c068e0777",
 )
 
 maven_jar(
@@ -574,38 +770,35 @@
 
 maven_jar(
     name = "dropwizard-core",
-    artifact = "io.dropwizard.metrics:metrics-core:4.0.2",
-    sha1 = "ec9878842d510cabd6bd6a9da1bebae1ae0cd199",
+    artifact = "io.dropwizard.metrics:metrics-core:4.0.5",
+    sha1 = "b81ef162970cdb9f4512ee2da09715a856ff4c4c",
 )
 
-# When updading Bouncy Castle, also update it in bazlets.
-BC_VERS = "1.59"
+# When updating Bouncy Castle, also update it in bazlets.
+BC_VERS = "1.61"
 
 maven_jar(
     name = "bcprov",
     artifact = "org.bouncycastle:bcprov-jdk15on:" + BC_VERS,
-    sha1 = "2507204241ab450456bdb8e8c0a8f986e418bd99",
+    sha1 = "00df4b474e71be02c1349c3292d98886f888d1f7",
 )
 
 maven_jar(
     name = "bcpg",
     artifact = "org.bouncycastle:bcpg-jdk15on:" + BC_VERS,
-    sha1 = "ee93e5376bb6cf0a15c027b5f5e4393f2738e709",
+    sha1 = "422656435514ab8a28752b117d5d2646660a0ace",
 )
 
 maven_jar(
     name = "bcpkix",
     artifact = "org.bouncycastle:bcpkix-jdk15on:" + BC_VERS,
-    sha1 = "9cef0aab8a4bb849a8476c058ce3ff302aba3fff",
+    sha1 = "89bb3aa5b98b48e584eee2a7401b7682a46779b4",
 )
 
-# TODO(davido): Remove exlusion of file system provider, when this issue is fixed:
-# https://issues.apache.org/jira/browse/SSHD-736
 maven_jar(
     name = "sshd",
-    artifact = "org.apache.sshd:sshd-core:1.7.0",
-    exclude = ["META-INF/services/java.nio.file.spi.FileSystemProvider"],
-    sha1 = "2e8b14f6d841b098e46bf407b6fdccab4c19fa41",
+    artifact = "org.apache.sshd:sshd-core:2.0.0",
+    sha1 = "f4275079a2463cfd2bf1548a80e1683288a8e86b",
 )
 
 maven_jar(
@@ -616,8 +809,14 @@
 
 maven_jar(
     name = "mina-core",
-    artifact = "org.apache.mina:mina-core:2.0.16",
-    sha1 = "f720f17643eaa7b0fec07c1d7f6272972c02bba4",
+    artifact = "org.apache.mina:mina-core:2.0.17",
+    sha1 = "7e10ec974760436d931f3e58be507d1957bcc8db",
+)
+
+maven_jar(
+    name = "sshd-mina",
+    artifact = "org.apache.sshd:sshd-mina:2.0.0",
+    sha1 = "50f2669312494f6c1996d8bd0d266c1fca7be6f6",
 )
 
 maven_jar(
@@ -649,10 +848,18 @@
     sha1 = "f5aa318bda4c6c8d688c9d00b90681dcd82ce636",
 )
 
+# elasticsearch-rest-client explicitly depends on this version
 maven_jar(
-    name = "httpmime",
-    artifact = "org.apache.httpcomponents:httpmime:" + HTTPCOMP_VERS,
-    sha1 = "2f8757f5ac5e38f46c794e5229d1f3c522e9b1df",
+    name = "httpasyncclient",
+    artifact = "org.apache.httpcomponents:httpasyncclient:4.1.4",
+    sha1 = "f3a3240681faae3fa46b573a4c7e50cec9db0d86",
+)
+
+# elasticsearch-rest-client explicitly depends on this version
+maven_jar(
+    name = "httpcore-nio",
+    artifact = "org.apache.httpcomponents:httpcore-nio:4.4.11",
+    sha1 = "7d0a97d01d39cff9aa3e6db81f21fddb2435f4e6",
 )
 
 # Test-only dependencies below.
@@ -675,37 +882,36 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-# Only needed when jgit is built from the development tree
-maven_jar(
-    name = "hamcrest-library",
-    artifact = "org.hamcrest:hamcrest-library:1.3",
-    sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
-)
-
-TRUTH_VERS = "0.40"
+TRUTH_VERS = "1.0"
 
 maven_jar(
     name = "truth",
     artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "0d74e716afec045cc4a178dbbfde2a8314ae5574",
+    sha1 = "998e5fb3fa31df716574b4c9e8d374855e800451",
 )
 
 maven_jar(
     name = "truth-java8-extension",
     artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "636e49d675bc28e0b3ae0edd077d6acbbb159166",
+    sha1 = "d85fbc1daf0510821f552f2aa71d9605e97aa438",
 )
 
 maven_jar(
     name = "truth-liteproto-extension",
     artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-    sha1 = "21210ac07e5cfbe83f04733f806224a6c0ae4d2d",
+    sha1 = "7a279c50a0f93da15533cef4993b45606cf67d72",
 )
 
 maven_jar(
     name = "truth-proto-extension",
     artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-    sha1 = "5a2b504143a5fec2b6be8bce292b3b7577a81789",
+    sha1 = "8c0c2ea61750f02d0d5ce9c653106b6a5dc82d12",
+)
+
+maven_jar(
+    name = "diffutils",
+    artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
+    sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
 # When bumping the easymock version number, make sure to also move powermock to a compatible version
@@ -717,14 +923,8 @@
 
 maven_jar(
     name = "cglib-3_2",
-    artifact = "cglib:cglib-nodep:3.2.0",
-    sha1 = "cf1ca207c15b04ace918270b6cb3f5601160cdfd",
-)
-
-maven_jar(
-    name = "objenesis",
-    artifact = "org.objenesis:objenesis:1.3",
-    sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
+    artifact = "cglib:cglib-nodep:3.2.6",
+    sha1 = "92bf48723d277d6efd1150b2f7e9e1e92cb56caf",
 )
 
 POWERM_VERS = "1.6.1"
@@ -771,73 +971,60 @@
     sha1 = "3e83394258ae2089be7219b971ec21a8288528ad",
 )
 
-maven_jar(
-    name = "derby",
-    artifact = "org.apache.derby:derby:10.11.1.1",
-    attach_source = False,
-    sha1 = "df4b50061e8e4c348ce243b921f53ee63ba9bbe1",
-)
-
-JETTY_VERS = "9.3.18.v20170406"
+JETTY_VERS = "9.4.18.v20190429"
 
 maven_jar(
     name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "534e7fa0e4fb6e08f89eb3f6a8c48b4f81ff5738",
+    sha1 = "290f7a88f351950d51ebc9fb4a794752c62d7de5",
 )
 
 maven_jar(
     name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "16b900e91b04511f42b706c925c8af6023d2c05e",
-)
-
-maven_jar(
-    name = "jetty-servlets",
-    artifact = "org.eclipse.jetty:jetty-servlets:" + JETTY_VERS,
-    sha1 = "f9311d1d8e6124d2792f4db5b29514d0ecf46812",
+    sha1 = "01aceff3608ca1b223bfd275a497797cfe675ef4",
 )
 
 maven_jar(
     name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "0a32feea88cba2d43951d22b60861c643454bb3f",
+    sha1 = "b76ef50e04635f11d4d43bc6ccb7c4482a8384f0",
 )
 
 maven_jar(
     name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "f988136dc5aa634afed6c5a35d910ee9599c6c23",
+    sha1 = "f4c2654db1a55f0780acdfcee8bb98550f56ca70",
 )
 
 maven_jar(
     name = "jetty-continuation",
     artifact = "org.eclipse.jetty:jetty-continuation:" + JETTY_VERS,
-    sha1 = "3c5d89c8204d4a48a360087f95e4cbd4520b5de0",
+    sha1 = "3c421a3be5be5805e32b1a7f9c6046526524181d",
 )
 
 maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "30ece6d732d276442d513b94d914de6fa1075fae",
+    sha1 = "c2e73db2db5c369326b717da71b6587b3da11e0e",
 )
 
 maven_jar(
     name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "36cb411ee89be1b527b0c10747aa3153267fc3ec",
+    sha1 = "844af5efe58ab23fd0166a796efef123f4cb06b0",
 )
 
 maven_jar(
     name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "8600b7d028a38cb462eff338de91390b3ff5040e",
+    sha1 = "13e6148bfda7ae511f69ae7e5e3ea898bc9b0e33",
 )
 
 maven_jar(
     name = "openid-consumer",
-    artifact = "org.openid4java:openid4java:0.9.8",
-    sha1 = "de4f1b33d3b0f0b2ab1d32834ec1190b39db4160",
+    artifact = "org.openid4java:openid4java:1.0.0",
+    sha1 = "541091bb49f2c0d583544c5bb1e6df7612d31e3e",
 )
 
 maven_jar(
@@ -854,78 +1041,49 @@
 )
 
 maven_jar(
-    name = "postgresql",
-    artifact = "org.postgresql:postgresql:9.4.1211",
-    sha1 = "721e3017fab68db9f0b08537ec91b8d757973ca8",
-)
-
-maven_jar(
-    name = "codemirror-minified-gwt",
-    artifact = "org.webjars.npm:codemirror-minified:" + CM_VERSION,
-    sha1 = "36558ea3b8e30782e1e09c0e7bd781e09614f139",
-)
-
-maven_jar(
-    name = "codemirror-original-gwt",
-    artifact = "org.webjars.npm:codemirror:" + CM_VERSION,
-    sha1 = "f1f8fbbc3e2d224fdccc43d2f4180658a92320f9",
-)
-
-maven_jar(
-    name = "diff-match-patch",
-    artifact = "org.webjars:google-diff-match-patch:" + DIFF_MATCH_PATCH_VERSION,
-    attach_source = False,
-    sha1 = "0cf1782dbcb8359d95070da9176059a5a9d37709",
-)
-
-maven_jar(
     name = "commons-io",
-    artifact = "commons-io:commons-io:1.4",
-    sha1 = "a8762d07e76cfde2395257a5da47ba7c1dbd3dce",
+    artifact = "commons-io:commons-io:2.2",
+    sha1 = "83b5b8a7ba1c08f9e8c8ff2373724e33d3c1e22a",
 )
 
 maven_jar(
     name = "asciidoctor",
-    artifact = "org.asciidoctor:asciidoctorj:1.5.6",
-    sha1 = "bb757d4b8b0f8438ce2ed781f6688cc6c01d9237",
+    artifact = "org.asciidoctor:asciidoctorj:1.5.7",
+    sha1 = "8e8c1d8fc6144405700dd8df3b177f2801ac5987",
 )
 
 maven_jar(
     name = "jruby",
-    artifact = "org.jruby:jruby-complete:9.1.13.0",
-    sha1 = "8903bf42272062e87a7cbc1d98919e0729a9939f",
+    artifact = "org.jruby:jruby-complete:9.1.17.0",
+    sha1 = "76716d529710fc03d1d429b43e3cedd4419f78d4",
 )
 
+# When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
+# and httpasyncclient as necessary.
 maven_jar(
     name = "elasticsearch-rest-client",
-    artifact = "org.elasticsearch.client:elasticsearch-rest-client:5.6.9",
-    sha1 = "895706412e2fba3f842fca82ec3dece1cb4ee7d1",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.2.0",
+    sha1 = "39cf34068b0af284eaa9b8bd86a131cb24b322d5",
 )
 
-JACKSON_VERSION = "2.8.9"
-
 maven_jar(
     name = "jackson-core",
-    artifact = "com.fasterxml.jackson.core:jackson-core:" + JACKSON_VERSION,
-    sha1 = "569b1752705da98f49aabe2911cc956ff7d8ed9d",
+    artifact = "com.fasterxml.jackson.core:jackson-core:2.9.8",
+    sha1 = "0f5a654e4675769c716e5b387830d19b501ca191",
 )
 
-maven_jar(
-    name = "httpasyncclient",
-    artifact = "org.apache.httpcomponents:httpasyncclient:4.1.2",
-    sha1 = "95aa3e6fb520191a0970a73cf09f62948ee614be",
-)
-
-maven_jar(
-    name = "httpcore-nio",
-    artifact = "org.apache.httpcomponents:httpcore-nio:" + HTTPCOMP_VERS,
-    sha1 = "a8c5e3c3bfea5ce23fb647c335897e415eb442e3",
-)
+TESTCONTAINERS_VERSION = "1.12.0"
 
 maven_jar(
     name = "testcontainers",
-    artifact = "org.testcontainers:testcontainers:1.8.0",
-    sha1 = "bc413912f7044f9f12aa0782853aef0a067ee52a",
+    artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
+    sha1 = "ac89643ce1ddde504da09172086aba0c7df10bff",
+)
+
+maven_jar(
+    name = "testcontainers-elasticsearch",
+    artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
+    sha1 = "cd9020f1803396c45ef935312bf232f9b17332b0",
 )
 
 maven_jar(
@@ -936,24 +1094,60 @@
 
 maven_jar(
     name = "visible-assertions",
-    artifact = "org.rnorth.visible-assertions:visible-assertions:2.1.0",
-    sha1 = "f2fcff2862860828ac38a5e1f14d941787c06b13",
+    artifact = "org.rnorth.visible-assertions:visible-assertions:2.1.2",
+    sha1 = "20d31a578030ec8e941888537267d3123c2ad1c1",
 )
 
 maven_jar(
     name = "jna",
-    artifact = "net.java.dev.jna:jna:4.5.1",
-    sha1 = "65bd0cacc9c79a21c6ed8e9f588577cd3c2f85b9",
+    artifact = "net.java.dev.jna:jna:5.2.0",
+    sha1 = "ed8b772eb077a9cb50e44e90899c66a9a6c00e67",
+)
+
+maven_jar(
+    name = "javax-activation",
+    artifact = "javax.activation:activation:1.1.1",
+    sha1 = "485de3a253e23f645037828c07f1d7f1af40763a",
+)
+
+maven_jar(
+    name = "mockito",
+    artifact = "org.mockito:mockito-core:2.24.0",
+    sha1 = "969a7bcb6f16e076904336ebc7ca171d412cc1f9",
+)
+
+BYTE_BUDDY_VERSION = "1.9.7"
+
+maven_jar(
+    name = "byte-buddy",
+    artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
+    sha1 = "8fea78fea6449e1738b675cb155ce8422661e237",
+)
+
+maven_jar(
+    name = "byte-buddy-agent",
+    artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
+    sha1 = "8e7d1b599f4943851ffea125fd9780e572727fc0",
+)
+
+maven_jar(
+    name = "objenesis",
+    artifact = "org.objenesis:objenesis:2.6",
+    sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
 )
 
 load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
 
+# NPM binaries bundled along with their dependencies.
+#
+# For full instructions on adding new binaries to the build, see
+# http://gerrit-review.googlesource.com/Documentation/dev-bazel.html#npm-binary
 npm_binary(
     name = "bower",
 )
 
 npm_binary(
-    name = "vulcanize",
+    name = "polymer-bundler",
     repository = GERRIT,
 )
 
@@ -1092,8 +1286,8 @@
 bower_archive(
     name = "polymer-resin",
     package = "polymer/polymer-resin",
-    sha1 = "5cb65081d461e710252a1ba1e671fe4c290356ef",
-    version = "1.2.8",
+    sha1 = "94c29926c20ea3a9b636f26b3e0d689ead8137e5",
+    version = "2.0.1",
 )
 
 bower_archive(
@@ -1104,10 +1298,17 @@
 )
 
 bower_archive(
+    name = "resemblejs",
+    package = "rsmbl/Resemble.js",
+    sha1 = "49d5f022417c389b630d6f7ee667aa9540075c42",
+    version = "2.10.1",
+)
+
+bower_archive(
     name = "codemirror-minified",
     package = "Dominator008/codemirror-minified",
-    sha1 = "1524e19087d8223edfe4a5b1ccf04c1e3707235d",
-    version = "5.37.0",
+    sha1 = "e6bda82afc7cf3493f4282c6f17265d40e1485e5",
+    version = "5.43.0",
 )
 
 # bower test stuff
diff --git a/antlr3/BUILD b/antlr3/BUILD
index 6d7102a..2d3050e 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -15,3 +15,18 @@
     ],
     visibility = ["//visibility:public"],
 )
+
+java_library(
+    name = "query_parser",
+    srcs = [":query"],
+    visibility = [
+        "//java/com/google/gerrit/index:__subpackages__",
+        "//javatests/com/google/gerrit:__subpackages__",
+        "//javatests/com/google/gerrit/index:__pkg__",
+        "//plugins:__pkg__",
+    ],
+    deps = [
+        "//java/com/google/gerrit/index:query_exception",
+        "//lib/antlr:java-runtime",
+    ],
+)
diff --git a/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
index 953a473..1bf20aa 100644
--- a/antlr3/com/google/gerrit/index/query/Query.g
+++ b/antlr3/com/google/gerrit/index/query/Query.g
@@ -120,12 +120,24 @@
   ;
 conditionBase
   : '('! conditionOr ')'!
-  | (FIELD_NAME ':') => FIELD_NAME^ ':'! fieldValue
+  | (FIELD_NAME COLON) => FIELD_NAME^ COLON! fieldValue
   | fieldValue -> ^(DEFAULT_FIELD fieldValue)
   ;
 
 fieldValue
-  : n=FIELD_NAME   -> SINGLE_WORD[n]
+  // Rewrite by invoking SINGLE_WORD fragment lexer rule, passing the field name as an argument.
+  : n=FIELD_NAME -> SINGLE_WORD[n]
+
+  // Allow field values to contain a colon. We can't do this at the lexer level, because we need to
+  // emit a separate token for the field name. If we were to allow ':' in SINGLE_WORD, then
+  // everything would just lex as DEFAULT_FIELD.
+  //
+  // Field values with a colon may be lexed either as <field>:<rest> or <word>:<rest>, depending on
+  // whether the part before the colon looks like a field name.
+  // TODO(dborowitz): Field values ending in colon still don't work.
+  | (FIELD_NAME COLON) => n=FIELD_NAME COLON fieldValue -> SINGLE_WORD[n] COLON fieldValue
+  | (SINGLE_WORD COLON) => SINGLE_WORD COLON fieldValue
+
   | SINGLE_WORD
   | EXACT_PHRASE
   ;
@@ -134,6 +146,8 @@
 OR:  'OR'  ;
 NOT: 'NOT' ;
 
+COLON: ':' ;
+
 WS
   :  ( ' ' | '\r' | '\t' | '\n' ) { $channel=HIDDEN; }
   ;
@@ -172,7 +186,7 @@
      // '-'  permit
      // '.'  permit
      // '/'  permit
-     | ':'
+     | COLON
      | ';'
      // '<' permit
      // '=' permit
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
deleted file mode 100755
index 3501b8b..0000000
--- a/contrib/abandon_stale.py
+++ /dev/null
@@ -1,220 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# The MIT License
-#
-# Copyright 2014 Sony Mobile Communications. All rights reserved.
-#
-# 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.
-
-""" Script to abandon stale changes from the review server.
-
-Fetches a list of open changes that have not been updated since a given age in
-days, months or years (default 6 months), and then abandons them.
-
-Requires the user's credentials for the Gerrit server to be declared in the
-.netrc file. Supports either basic or digest authentication.
-
-Example to abandon changes that have not been updated for 3 months:
-
-  ./abandon_stale --gerrit-url http://review.example.com/ --age 3months
-
-Supports dry-run mode to only list the stale changes, but not actually
-abandon them.
-
-See the --help output for more information about options.
-
-Requires pygerrit2 (https://github.com/dpursehouse/pygerrit2) to be installed
-and available for import.
-
-"""
-
-import logging
-import optparse
-import re
-import sys
-
-from pygerrit2.rest import GerritRestAPI
-from pygerrit2.rest.auth import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc
-
-
-def _main():
-    parser = optparse.OptionParser()
-    parser.add_option('-g', '--gerrit-url', dest='gerrit_url',
-                      metavar='URL',
-                      default=None,
-                      help='gerrit server URL')
-    parser.add_option('-b', '--basic-auth', dest='basic_auth',
-                      action='store_true',
-                      help='(deprecated) use HTTP basic authentication instead'
-                      ' of digest')
-    parser.add_option('-d', '--digest-auth', dest='digest_auth',
-                      action='store_true',
-                      help='use HTTP digest authentication instead of basic')
-    parser.add_option('-n', '--dry-run', dest='dry_run',
-                      action='store_true',
-                      help='enable dry-run mode: show stale changes but do '
-                           'not abandon them')
-    parser.add_option('-t', '--test', dest='testmode', action='store_true',
-                      help='test mode: query changes with the `test-abandon` '
-                           'topic and ignore age option')
-    parser.add_option('-a', '--age', dest='age',
-                      metavar='AGE',
-                      default="6months",
-                      help='age of change since last update in days, months'
-                           ' or years (default: %default)')
-    parser.add_option('-m', '--message', dest='message',
-                      metavar='STRING', default=None,
-                      help='custom message to append to abandon message')
-    parser.add_option('--branch', dest='branches', metavar='BRANCH_NAME',
-                      default=[], action='append',
-                      help='abandon changes only on the given branch')
-    parser.add_option('--exclude-branch', dest='exclude_branches',
-                      metavar='BRANCH_NAME',
-                      default=[],
-                      action='append',
-                      help='do not abandon changes on given branch')
-    parser.add_option('--project', dest='projects', metavar='PROJECT_NAME',
-                      default=[], action='append',
-                      help='abandon changes only on the given project')
-    parser.add_option('--exclude-project', dest='exclude_projects',
-                      metavar='PROJECT_NAME',
-                      default=[],
-                      action='append',
-                      help='do not abandon changes on given project')
-    parser.add_option('--owner', dest='owner',
-                      metavar='USERNAME',
-                      default=None,
-                      action='store',
-                      help='only abandon changes owned by the given user')
-    parser.add_option('-v', '--verbose', dest='verbose',
-                      action='store_true',
-                      help='enable verbose (debug) logging')
-
-    (options, _args) = parser.parse_args()
-
-    level = logging.DEBUG if options.verbose else logging.INFO
-    logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
-                        level=level)
-
-    if not options.gerrit_url:
-        logging.error("Gerrit URL is required")
-        return 1
-
-    if options.testmode:
-        message = "Abandoning in test mode"
-    else:
-        pattern = re.compile(r"^([\d]+)(month[s]?|year[s]?|week[s]?)")
-        match = pattern.match(options.age)
-        if not match:
-            logging.error("Invalid age: %s", options.age)
-            return 1
-        message = "Abandoning after %s %s or more of inactivity." % \
-            (match.group(1), match.group(2))
-
-    if options.digest_auth:
-        auth_type = HTTPDigestAuthFromNetrc
-    else:
-        auth_type = HTTPBasicAuthFromNetrc
-
-    try:
-        auth = auth_type(url=options.gerrit_url)
-        gerrit = GerritRestAPI(url=options.gerrit_url, auth=auth)
-    except Exception as e:
-        logging.error(e)
-        return 1
-
-    logging.info(message)
-    try:
-        stale_changes = []
-        offset = 0
-        step = 500
-        if options.testmode:
-            query_terms = ["status:new", "owner:self", "topic:test-abandon"]
-        else:
-            query_terms = ["status:new", "-is:wip", "age:%s" % options.age]
-        if options.branches:
-            query_terms += ["branch:%s" % b for b in options.branches]
-        elif options.exclude_branches:
-            query_terms += ["-branch:%s" % b for b in options.exclude_branches]
-        if options.projects:
-            query_terms += ["project:%s" % p for p in options.projects]
-        elif options.exclude_projects:
-            query_terms = ["-project:%s" % p for p in options.exclude_projects]
-        if options.owner and not options.testmode:
-            query_terms += ["owner:%s" % options.owner]
-        query = "%20".join(query_terms)
-        while True:
-            q = query + "&o=DETAILED_ACCOUNTS&n=%d&S=%d" % (step, offset)
-            logging.debug("Query: %s", q)
-            url = "/changes/?q=" + q
-            result = gerrit.get(url)
-            logging.debug("%d changes", len(result))
-            if not result:
-                break
-            stale_changes += result
-            last = result[-1]
-            if "_more_changes" in last:
-                logging.debug("More...")
-                offset += step
-            else:
-                break
-    except Exception as e:
-        logging.error(e)
-        return 1
-
-    abandoned = 0
-    errors = 0
-    abandon_message = message
-    if options.message:
-        abandon_message += "\n\n" + options.message
-    for change in stale_changes:
-        number = change["_number"]
-        project = ""
-        if len(options.projects) != 1:
-            project = "%s: " % change["project"]
-        owner = ""
-        if options.verbose:
-            try:
-                o = change["owner"]["name"]
-            except KeyError:
-                o = "Unknown"
-            owner = " (%s)" % o
-        subject = change["subject"]
-        if len(subject) > 70:
-            subject = subject[:65] + " [...]"
-        change_id = change["id"]
-        logging.info("%s%s: %s%s", number, owner, project, subject)
-        if options.dry_run:
-            continue
-
-        try:
-            gerrit.post("/changes/" + change_id + "/abandon",
-                        json={"message": "%s" % abandon_message})
-            abandoned += 1
-        except Exception as e:
-            errors += 1
-            logging.error(e)
-    logging.info("Total %d stale open changes", len(stale_changes))
-    if not options.dry_run:
-        logging.info("Abandoned %d changes. %d errors.", abandoned, errors)
-
-
-if __name__ == "__main__":
-    sys.exit(_main())
diff --git a/contrib/hooks/post-receive-move-tmp-refs b/contrib/hooks/post-receive-move-tmp-refs
new file mode 100755
index 0000000..c4a53db
--- /dev/null
+++ b/contrib/hooks/post-receive-move-tmp-refs
@@ -0,0 +1,79 @@
+#!/bin/sh
+#
+# 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.
+# --------------------------------------------------------
+# Install this hook script as post-receive hook in replicated repositories
+# hosted by a gerrit slave which are updated by push replication from the
+# corresponding gerrit master.
+#
+# In the gerrit master configure the replication plugin to push changes from
+# refs/changes/ to refs/tmp/changes/
+#   remote.NAME.push = +refs/changes/*:refs/tmp/changes/*
+#   remote.NAME.push = +refs/heads/*:refs/heads/*
+#   remote.NAME.push = +refs/tags/*:refs/tags/*
+#
+# In the replicated repository in the gerrit slave configure
+#    receive.hideRefs = refs/changes/
+# in order to not advertise the big number of refs in this namespace when
+# the gerrit master's replication plugin is pushing a change
+#
+# Whenever a ref under refs/tmp/changes/ is arriving this hook will move it
+# to refs/changes/. This helps to avoid the large overhead of advertising all
+# refs/changes/ refs to the gerrit master when it replicates changes to the
+# slave..
+#
+# Make this script executable then link to it in the repository you would like
+# to use it in.
+#   cd /path/to/your/repository.git
+#   ln -sf <shared hooks directory>/post-receive-move-tmp-refs hooks/post-receive
+#
+# If you want to use this by default for repositories on the Gerrit slave you
+# can set up a git template directory $TEMPLATE_DIR/hooks/post-receive and
+# configure init.templateDir in the ~/.gitconfig of the user that receives the
+# replication on the mirror host. That way when a new repository is created on
+# the master and hence on the mirror (if configured that way) it will
+# automatically have the "tmp-refs" commit hook installed.
+# See https://git-scm.com/docs/git-init#_template_directory for details.
+
+readonly NULL_SHA1=0000000000000000000000000000000000000000
+
+# move new changes arriving under refs/tmp/changes/ to refs/changes/
+mv_tmp_refs()
+{
+	oldrev=$1
+	newrev=$2
+	refname=$3
+	case "$refname","$oldrev" in
+		refs/tmp/changes/*,$NULL_SHA1)
+			short_refname=${refname##refs/tmp/changes/}
+			$(git update-ref refs/changes/$short_refname $newrev $NULL_SHA1 2>/dev/null)
+			$(git update-ref -d $refname $newrev 2>/dev/null)
+			echo "moved \"$refname\" to \"refs/changes/$short_refname\""
+			;;
+	esac
+	return 0
+}
+
+GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
+if [ -z "$GIT_DIR" ]; then
+	echo >&2 "fatal: post-receive: GIT_DIR not set"
+	exit 1
+fi
+
+# read ref updates passed to post-receive hook
+while read oldrev newrev refname
+do
+	mv_tmp_refs $oldrev $newrev $refname || continue
+done
diff --git a/contrib/mitm-ui/README.md b/contrib/mitm-ui/README.md
new file mode 100644
index 0000000..1ec8dd4
--- /dev/null
+++ b/contrib/mitm-ui/README.md
@@ -0,0 +1,61 @@
+# Scripts for PolyGerrit local development against prod using MitmProxy.
+
+## Installation (OSX)
+
+1. Install Docker from http://docker.com
+2. Start the proxy and create a new proxied browser instance
+   ```
+   cd ~/gerrit
+   ~/mitm-gerrit/mitm-serve-app-dev.sh
+   ```
+3. Make sure that the browser uses the proxy provided by the command line,
+   e.g. if you are a Googler check that the BeyondCorp extension uses the
+   "System/Alternative" proxy.
+4. Install MITM certificates
+   - Open http://mitm.it in the proxied browser window
+   - Follow the instructions to install MITM certs
+
+## Usage
+
+### Add or replace a single plugin containing static content
+
+To develop unminified plugin that loads multiple files, use this.
+
+1. Create a new proxied browser window and start mitmproxy via Docker:
+   ```
+   ~/mitm-gerrit/mitm-single-plugin.sh ./path/to/static/plugin.html
+   ```
+2. Open any *.googlesource.com domain in proxied window
+3. plugin.html and ./path/to/static/* will be served
+
+### Add or replace a minified plugin for *.googlesource.com
+
+This flow assumes no additional .html/.js are needed, i.e. the plugin is a single file.
+
+1. Create a new proxied browser window and start mitmproxy via Docker:
+   ```
+   ~/mitm-gerrit/mitm-plugins.sh ./path/to/plugin.html,./maybe/one/more.js
+   ```
+2. Open any *.googlesource.com domain in proxied window
+3. plugin.html and more.js are served
+
+### Force or replace default site theme for *.googlesource.com
+
+1. Create a new proxied browser window and start mitmproxy via Docker:
+   ```
+   ~/mitm-gerrit/mitm-theme.sh ./path/to/theme.html
+   ```
+2. Open any *.googlesource.com domain in proxied window
+3. Default site themes are enabled.
+4. Local `theme.html` content replaces `/static/gerrit-theme.html`
+5. `/static/*` URLs are served from local theme directory, i.e. `./path/to/`
+
+### Serve uncompiled PolyGerrit
+
+1. Create a new proxied browser window and start mitmproxy via Docker:
+   ```
+   cd ~/gerrit
+   ~/mitm-gerrit/mitm-serve-app-dev.sh
+   ```
+2. Open any *.googlesource.com domain in proxied window
+3. Instead of prod UI (gr-app.html, gr-app.js), local source files will be served
diff --git a/contrib/mitm-ui/add-header.py b/contrib/mitm-ui/add-header.py
new file mode 100644
index 0000000..f9b2b12
--- /dev/null
+++ b/contrib/mitm-ui/add-header.py
@@ -0,0 +1,5 @@
+# mitmdump -s add-header.py
+def response(flow):
+    if flow.request.host == 'gerrit-review.googlesource.com' and flow.request.path == "/c/92000?1":
+        #flow.response.headers['any'] = '<meta.rdf>; rel=meta'
+        flow.response.headers['Link'] = '</changes/98000/detail?O=11640c>;rel="preload";crossorigin;'
diff --git a/contrib/mitm-ui/dev-chrome.sh b/contrib/mitm-ui/dev-chrome.sh
new file mode 100755
index 0000000..adcb296
--- /dev/null
+++ b/contrib/mitm-ui/dev-chrome.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+if [[ "$OSTYPE" != "darwin"* ]]; then
+    echo Only works on OSX.
+    exit 1
+fi
+
+/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=${HOME}/devchrome --proxy-server="127.0.0.1:8888"
diff --git a/contrib/mitm-ui/force-version.py b/contrib/mitm-ui/force-version.py
new file mode 100644
index 0000000..a69c885
--- /dev/null
+++ b/contrib/mitm-ui/force-version.py
@@ -0,0 +1,22 @@
+# mitmdump -q -p 8888 -s "force-version.py --version $1"
+# Request URL is not changed, only the response context
+from mitmproxy import http
+import argparse
+import re
+
+class Server:
+    def __init__(self, version):
+        self.version = version
+
+    def request(self, flow: http.HTTPFlow) -> None:
+        if "gr-app." in flow.request.pretty_url:
+            flow.request.url = re.sub(
+                r"polygerrit_ui/([\d.]+)/elements",
+                "polygerrit_ui/" + self.version + "/elements",
+                flow.request.url)
+
+def start():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--version", type=str, help="Rapid release version, e.g. 432.0")
+    args = parser.parse_args()
+    return Server(args.version)
diff --git a/contrib/mitm-ui/mitm-docker.sh b/contrib/mitm-ui/mitm-docker.sh
new file mode 100755
index 0000000..a1206f7
--- /dev/null
+++ b/contrib/mitm-ui/mitm-docker.sh
@@ -0,0 +1,43 @@
+#!/bin/sh
+
+extra_volume='/tmp:/tmp'
+
+POSITIONAL=()
+while [[ $# -gt 0 ]]
+do
+key="$1"
+
+case $key in
+    -v|--volume)
+    extra_volume="$2"
+    shift # past argument
+    shift # past value
+    ;;
+    *)    # unknown option
+    POSITIONAL+=("$1") # save it in an array for later
+    shift # past argument
+    ;;
+esac
+done
+set -- "${POSITIONAL[@]}" # restore positional parameters
+
+if [[ -z "$1" ]]; then
+    echo This is a runner for higher-level scripts, e.g. mitm-serve-app-dev.sh
+    echo Alternatively, pass mitmproxy script from the same dir as a parameter, e.g. serve-app-dev.py
+    exit 1
+fi
+
+gerrit_dir=$(pwd)
+mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
+
+CMD="${mitm_dir}/$1"
+
+docker run --rm -it \
+       -v ~/.mitmproxy:/home/mitmproxy/.mitmproxy \
+       -v ${mitm_dir}:${mitm_dir} \
+       -v ${gerrit_dir}:${gerrit_dir} \
+       -v ${gerrit_dir}/bazel-out:${gerrit_dir}/bazel-out \
+       -v ${extra_volume} \
+       -p 8888:8888 \
+       mitmproxy/mitmproxy:2.0.2 \
+       mitmdump -q -p 8888 -s "${CMD}"
diff --git a/contrib/mitm-ui/mitm-plugins.sh b/contrib/mitm-ui/mitm-plugins.sh
new file mode 100755
index 0000000..fc542bb
--- /dev/null
+++ b/contrib/mitm-ui/mitm-plugins.sh
@@ -0,0 +1,39 @@
+#!/bin/sh
+
+if [[ -z "$1" ]]; then
+    echo This script injects plugins for *.googlesource.com.
+    echo Provide plugin paths, comma-separated, as a parameter.
+    echo This script assumes files do not have dependencies, i.e. minified.
+    exit 1
+fi
+
+realpath() {
+    [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"
+}
+
+join () {
+  local IFS="$1"
+  shift
+  echo "$*"
+}
+
+plugins=$1
+plugin_paths=()
+for plugin in $(echo ${plugins} | sed "s/,/ /g")
+do
+    plugin_paths+=($(realpath ${plugin}))
+done
+
+absolute_plugin_paths=$(join , "${plugin_paths[@]}")
+
+mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
+
+${mitm_dir}/dev-chrome.sh &
+
+bazel build //polygerrit-ui/app:test_components &
+
+${mitm_dir}/mitm-docker.sh \
+           "serve-app-dev.py \
+           --plugins ${absolute_plugin_paths} \
+           --strip_assets \
+           --components $(pwd)/bazel-bin/polygerrit-ui/app/"
diff --git a/contrib/mitm-ui/mitm-serve-app-dev.sh b/contrib/mitm-ui/mitm-serve-app-dev.sh
new file mode 100755
index 0000000..d4c72cc
--- /dev/null
+++ b/contrib/mitm-ui/mitm-serve-app-dev.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+workspace="./WORKSPACE"
+if [[ ! -f ${workspace} ]] || [[ ! $(head -n 1 ${workspace}) == *"gerrit"* ]]; then
+    echo Please change to cloned Gerrit repo from https://gerrit.googlesource.com/gerrit/
+    exit 1
+fi
+
+mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
+
+bazel build //polygerrit-ui/app:test_components &
+
+${mitm_dir}/dev-chrome.sh &
+
+${mitm_dir}/mitm-docker.sh "serve-app-dev.py --app $(pwd)/polygerrit-ui/app/ --components $(pwd)/bazel-bin/polygerrit-ui/app/"
diff --git a/contrib/mitm-ui/mitm-single-plugin.sh b/contrib/mitm-ui/mitm-single-plugin.sh
new file mode 100755
index 0000000..8958229
--- /dev/null
+++ b/contrib/mitm-ui/mitm-single-plugin.sh
@@ -0,0 +1,38 @@
+#!/bin/sh
+
+if [[ -z "$1" ]]; then
+    echo This script serves one plugin with the rest of static content.
+    echo Provide path to index plugin file, e.g. buildbucket.html for buildbucket plugin
+    exit 1
+fi
+
+realpath() {
+  OURPWD=$PWD
+  cd "$(dirname "$1")"
+  LINK=$(basename "$1")
+  while [ -L "$LINK" ]; do
+      LINK=$(readlink "$LINK")
+      cd "$(dirname "$LINK")"
+      LINK="$(basename "$1")"
+  done
+  REAL_DIR=`pwd -P`
+  RESULT=$REAL_DIR/$LINK
+  cd "$OURPWD"
+  echo "$RESULT"
+}
+
+plugin=$(realpath $1)
+plugin_root=$(dirname ${plugin})
+
+mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
+
+${mitm_dir}/dev-chrome.sh &
+
+bazel build //polygerrit-ui/app:test_components &
+
+${mitm_dir}/mitm-docker.sh -v ${plugin_root}:${plugin_root} \
+           "serve-app-dev.py \
+           --plugins ${plugin} \
+           --strip_assets \
+           --plugin_root ${plugin_root}  \
+           --components $(pwd)/bazel-bin/polygerrit-ui/app/"
diff --git a/contrib/mitm-ui/mitm-theme.sh b/contrib/mitm-ui/mitm-theme.sh
new file mode 100755
index 0000000..9290235
--- /dev/null
+++ b/contrib/mitm-ui/mitm-theme.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+
+if [[ -z "$1" ]]; then
+    echo This script forces or replaces default site theme on *.googlesource.com
+    echo Provide path to the theme.html as a parameter.
+    exit 1
+fi
+
+realpath() {
+  OURPWD=$PWD
+  cd "$(dirname "$1")"
+  LINK=$(basename "$1")
+  while [ -L "$LINK" ]; do
+      LINK=$(readlink "$LINK")
+      cd "$(dirname "$LINK")"
+      LINK="$(basename "$1")"
+  done
+  REAL_DIR=`pwd -P`
+  RESULT=$REAL_DIR/$LINK
+  cd "$OURPWD"
+  echo "$RESULT"
+}
+
+theme=$(realpath "$1")
+theme_dir=$(dirname "${theme}")
+
+mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
+
+"${mitm_dir}"/dev-chrome.sh &
+
+"${mitm_dir}"/mitm-docker.sh -v "${theme_dir}":"${theme_dir}" "serve-app-dev.py --strip_assets --theme \"${theme}\""
diff --git a/contrib/mitm-ui/serve-app-dev.py b/contrib/mitm-ui/serve-app-dev.py
new file mode 100644
index 0000000..cdf7bfc
--- /dev/null
+++ b/contrib/mitm-ui/serve-app-dev.py
@@ -0,0 +1,169 @@
+# 1. install and setup mitmproxy v2.0.2: https://mitmproxy.readthedocs.io/en/v2.0.2/install.html
+#   (In case of python versions trouble, use https://www.anaconda.com/)
+# 2. mitmdump -q -s -p 8888 \
+#   "serve-app-dev.py --app /path/to/polygerrit-ui/app/"
+# 3. start Chrome with --proxy-server="127.0.0.1:8888" --user-data-dir=/tmp/devchrome
+# 4. open, say, gerrit-review.googlesource.com. Or chromium-review.googlesource.com. Any.
+# 5. uncompiled source files are served and you can log in, too.
+# 6. enjoy!
+#
+# P.S. For replacing plugins, use --plugins or --plugin_root
+#
+# --plugin takes comma-separated list of plugins to add or replace.
+#
+# Example: Adding a new plugin to the server response:
+# --plugins ~/gerrit-testsite/plugins/myplugin.html
+#
+# Example: Replace all matching plugins with local versions:
+# --plugins ~/gerrit-testsite/plugins/
+# Following files will be served if they exist for /plugins/tricium/static/tricium.html:
+#  ~/gerrit-testsite/plugins/tricium.html
+#  ~/gerrit-testsite/plugins/tricium/static/tricium.html
+#
+# --assets takes assets bundle.html, expecting rest of the assets files to be in the same folder
+#
+# Example:
+#  --assets ~/gerrit-testsite/assets/a3be19f.html
+#
+
+from mitmproxy import http
+from mitmproxy.script import concurrent
+import argparse
+import json
+import mimetypes
+import os.path
+import re
+import zipfile
+
+class Server:
+    def __init__(self, devpath, components, plugins, pluginroot, assets, strip_assets, theme):
+        if devpath:
+            print("Serving app from " + devpath)
+        if components:
+            print("Serving components from " + components)
+        if pluginroot:
+            print("Serving plugins from " + pluginroot)
+        if assets:
+            self.assets_root, self.assets_file = os.path.split(assets)
+            print("Assets: using " + self.assets_file + " from " + self.assets_root)
+        else:
+            self.assets_root = None
+        if plugins:
+            self.plugins = {path.split("/")[-1:][0]: path for path in map(expandpath, plugins.split(","))}
+            for filename, path in self.plugins.items():
+                print("Serving " + filename + " from " + path)
+        else:
+            self.plugins = {}
+        self.devpath = devpath
+        self.components = components
+        self.pluginroot = pluginroot
+        self.strip_assets = strip_assets
+        self.theme = theme
+
+    def readfile(self, path):
+        with open(path, 'rb') as contentfile:
+            return contentfile.read()
+
+@concurrent
+def response(flow: http.HTTPFlow) -> None:
+    if server.strip_assets:
+        assets_bundle = 'googlesource.com/polygerrit_assets'
+        assets_pos = flow.response.text.find(assets_bundle)
+        if assets_pos != -1:
+            t = flow.response.text
+            flow.response.text = t[:t.rfind('<', 0, assets_pos)] + t[t.find('>', assets_pos) + 1:]
+            return
+
+    if server.assets_root:
+        marker = 'webcomponents-lite.js"></script>'
+        pos = flow.response.text.find(marker)
+        if pos != -1:
+            pos += len(marker)
+            flow.response.text = ''.join([
+                flow.response.text[:pos],
+                '<link rel="import" href="/gerrit_assets/123.0/' + server.assets_file + '">',
+                flow.response.text[pos:]
+            ])
+
+        assets_prefix = "/gerrit_assets/123.0/"
+        if flow.request.path.startswith(assets_prefix):
+            assets_file = flow.request.path[len(assets_prefix):]
+            flow.response.content = server.readfile(server.assets_root + '/' + assets_file)
+            flow.response.status_code = 200
+            if assets_file.endswith('.js'):
+                flow.response.headers['Content-type'] = 'text/javascript'
+            return
+    m = re.match(".+polygerrit_ui/\d+\.\d+/(.+)", flow.request.path)
+    pluginmatch = re.match("^/plugins/(.+)", flow.request.path)
+    localfile = ""
+    content = ""
+    if flow.request.path == "/config/server/info":
+        config = json.loads(flow.response.content[5:].decode('utf8'))
+        if server.theme:
+            config['default_theme'] = '/static/gerrit-theme.html'
+        for filename, path in server.plugins.items():
+            pluginname = filename.split(".")[0]
+            payload = config["plugin"]["js_resource_paths" if filename.endswith(".js") else "html_resource_paths"]
+            if list(filter(lambda url: filename in url, payload)):
+                continue
+            payload.append("plugins/" + pluginname + "/static/" + filename)
+        flow.response.content = str.encode(")]}'\n" + json.dumps(config))
+    if m is not None:
+        filepath = m.groups()[0]
+        if (filepath.startswith("bower_components/")):
+            with zipfile.ZipFile(server.components + "test_components.zip") as bower_zip:
+                content = bower_zip.read(filepath)
+        localfile = server.devpath + filepath
+    elif pluginmatch is not None:
+        pluginfile = flow.request.path_components[-1]
+        if server.plugins and pluginfile in server.plugins:
+            if os.path.isfile(server.plugins[pluginfile]):
+                localfile = server.plugins[pluginfile]
+            else:
+                print("Can't find file " + server.plugins[pluginfile] + " for " + flow.request.path)
+        elif server.pluginroot:
+            pluginurl = pluginmatch.groups()[0]
+            if os.path.isfile(server.pluginroot + pluginfile):
+                localfile = server.pluginroot + pluginfile
+            elif os.path.isfile(server.pluginroot + pluginurl):
+                localfile = server.pluginroot + pluginurl
+
+    if server.theme:
+        if flow.request.path.endswith('/gerrit-theme.html'):
+            localfile = server.theme
+        else:
+            match = re.match("^/static(/[\w\.]+)$", flow.request.path)
+            if match is not None:
+                localfile = os.path.dirname(server.theme) + match.group(1)
+
+    if localfile and os.path.isfile(localfile):
+        if pluginmatch is not None:
+            print("Serving " + flow.request.path + " from " + localfile)
+        content = server.readfile(localfile)
+
+    if content:
+        flow.response.content = content
+        flow.response.status_code = 200
+        localtype = mimetypes.guess_type(localfile)
+        if localtype and localtype[0]:
+            flow.response.headers['Content-type'] = localtype[0]
+
+def expandpath(path):
+    return os.path.realpath(os.path.expanduser(path))
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--app", type=str, default="", help="Path to /polygerrit-ui/app/")
+parser.add_argument("--components", type=str, default="", help="Path to test_components.zip")
+parser.add_argument("--plugins", type=str, default="", help="Comma-separated list of plugin files to add/replace")
+parser.add_argument("--plugin_root", type=str, default="", help="Path containing individual plugin files to replace")
+parser.add_argument("--assets", type=str, default="", help="Path containing assets file to import.")
+parser.add_argument("--strip_assets", action="store_true", help="Strip plugin bundles from the response.")
+parser.add_argument("--theme", default="", type=str, help="Path to the default site theme to be used.")
+args = parser.parse_args()
+server = Server(expandpath(args.app) + '/',
+                expandpath(args.components) + '/',
+                args.plugins,
+                expandpath(args.plugin_root) + '/',
+                args.assets and expandpath(args.assets),
+                args.strip_assets,
+                expandpath(args.theme))
diff --git a/contrib/mitm-ui/serve-app-locally.py b/contrib/mitm-ui/serve-app-locally.py
new file mode 100644
index 0000000..636c684
--- /dev/null
+++ b/contrib/mitm-ui/serve-app-locally.py
@@ -0,0 +1,46 @@
+# bazel build polygerrit-ui/app:gr-app
+# mitmdump -s "serve-app-locally.py ~/gerrit/bazel-bin/polygerrit-ui/app"
+from mitmproxy import http
+import argparse
+import os
+import zipfile
+
+class Server:
+    def __init__(self, bundle):
+        self.bundle = bundle
+        self.bundlemtime = 0
+        self.files = {
+            'polygerrit_ui/elements/gr-app.js': '',
+            'polygerrit_ui/elements/gr-app.html': '',
+            'polygerrit_ui/styles/main.css': '',
+        }
+        self.read_files()
+
+    def read_files(self):
+        if not os.path.isfile(self.bundle):
+            print("bundle not found!")
+            return
+        mtime = os.stat(self.bundle).st_mtime
+        if mtime <= self.bundlemtime:
+            return
+        self.bundlemtime = mtime
+        with zipfile.ZipFile(self.bundle) as z:
+            for fname in self.files:
+                print('Reading new content for ' + fname)
+                with z.open(fname, 'r') as content_file:
+                    self.files[fname] = content_file.read()
+
+    def response(self, flow: http.HTTPFlow) -> None:
+        self.read_files()
+        for name in self.files:
+            if name.rsplit('/', 1)[1] in flow.request.pretty_url:
+                flow.response.content = self.files[name]
+
+def expandpath(path):
+    return os.path.expanduser(path)
+
+def start():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("bundle", type=str)
+    args = parser.parse_args()
+    return Server(expandpath(args.bundle))
diff --git a/gerrit-gwtdebug/BUILD b/gerrit-gwtdebug/BUILD
deleted file mode 100644
index f564745..0000000
--- a/gerrit-gwtdebug/BUILD
+++ /dev/null
@@ -1,16 +0,0 @@
-java_library(
-    name = "gwtdebug",
-    srcs = glob(["src/main/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/pgm",
-        "//java/com/google/gerrit/pgm/util",
-        "//java/com/google/gerrit/util/cli",
-        "//lib/flogger:api",
-        "//lib/gwt:dev",
-        "//lib/jetty:server",
-        "//lib/jetty:servlet",
-        "//lib/jetty:servlets",
-        "//lib/log:log4j",
-    ],
-)
diff --git a/gerrit-gwtdebug/src/main/java/com/google/gerrit/gwtdebug/GerritGwtDebugLauncher.java b/gerrit-gwtdebug/src/main/java/com/google/gerrit/gwtdebug/GerritGwtDebugLauncher.java
deleted file mode 100644
index cf84919..0000000
--- a/gerrit-gwtdebug/src/main/java/com/google/gerrit/gwtdebug/GerritGwtDebugLauncher.java
+++ /dev/null
@@ -1,77 +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.gwtdebug;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.pgm.Daemon;
-import com.google.gwt.dev.codeserver.CodeServer;
-import com.google.gwt.dev.codeserver.Options;
-import java.util.ArrayList;
-import java.util.List;
-
-class GerritGwtDebugLauncher {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static void main(String[] argv) throws Exception {
-    GerritGwtDebugLauncher launcher = new GerritGwtDebugLauncher();
-    launcher.mainImpl(argv);
-  }
-
-  private int mainImpl(String[] argv) {
-    List<String> sdmLauncherOptions = new ArrayList<>();
-    List<String> daemonLauncherOptions = new ArrayList<>();
-
-    // Separator between Daemon and Codeserver parameters is "--"
-    boolean daemonArgumentSeparator = false;
-    int i = 0;
-    for (; i < argv.length; i++) {
-      if (!argv[i].equals("--")) {
-        sdmLauncherOptions.add(argv[i]);
-      } else {
-        daemonArgumentSeparator = true;
-        break;
-      }
-    }
-    if (daemonArgumentSeparator) {
-      ++i;
-      for (; i < argv.length; i++) {
-        daemonLauncherOptions.add(argv[i]);
-      }
-    }
-
-    Options options = new Options();
-    if (!options.parseArgs(sdmLauncherOptions.toArray(new String[sdmLauncherOptions.size()]))) {
-      logger.atSevere().log("Failed to parse codeserver arguments");
-      return 1;
-    }
-
-    CodeServer.main(options);
-
-    try {
-      int r =
-          new Daemon()
-              .main(daemonLauncherOptions.toArray(new String[daemonLauncherOptions.size()]));
-      if (r != 0) {
-        logger.atSevere().log("Daemon exited with return code: %d", r);
-        return 1;
-      }
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot start daemon");
-      return 1;
-    }
-
-    return 0;
-  }
-}
diff --git a/gerrit-gwtui-common/BUILD b/gerrit-gwtui-common/BUILD
deleted file mode 100644
index 46019ab..0000000
--- a/gerrit-gwtui-common/BUILD
+++ /dev/null
@@ -1,62 +0,0 @@
-load("//tools/bzl:java.bzl", "java_library2")
-load("//tools/bzl:junit.bzl", "junit_tests")
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-EXPORTED_DEPS = [
-    "//java/com/google/gerrit/common:client",
-    "//java/com/google/gwtexpui/clippy",
-    "//java/com/google/gwtexpui/globalkey",
-    "//java/com/google/gwtexpui/progress",
-    "//java/com/google/gwtexpui/safehtml",
-    "//java/com/google/gwtexpui/user:agent",
-]
-
-DEPS = ["//lib/gwt:user-neverlink"]
-
-SRC = "src/main/java/com/google/gerrit/"
-
-gwt_module(
-    name = "client",
-    srcs = glob(["src/main/**/*.java"]),
-    exported_deps = EXPORTED_DEPS,
-    gwt_xml = SRC + "GerritGwtUICommon.gwt.xml",
-    resources = glob(
-        ["src/main/**/*"],
-        exclude = [SRC + "client/**/*.java"] + [
-            SRC + "GerritGwtUICommon.gwt.xml",
-        ],
-    ),
-    visibility = ["//visibility:public"],
-    deps = DEPS,
-)
-
-java_library2(
-    name = "client-lib",
-    srcs = glob(["src/main/**/*.java"]),
-    exported_deps = EXPORTED_DEPS,
-    resources = glob(["src/main/**/*"]),
-    visibility = ["//visibility:public"],
-    deps = DEPS,
-)
-
-java_library(
-    name = "diffy_logo",
-    data = [
-        "//lib:LICENSE-CC-BY3.0-unported",
-        "//lib:LICENSE-diffy",
-    ],
-    resources = glob(["src/main/resources/com/google/gerrit/client/diffy*.png"]),
-    visibility = ["//visibility:public"],
-)
-
-junit_tests(
-    name = "client_tests",
-    srcs = glob(["src/test/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        ":client",
-        "//lib:junit",
-        "//lib/gwt:dev",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml b/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml
deleted file mode 100644
index dc478fc..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml
+++ /dev/null
@@ -1,43 +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.
--->
-<module>
-  <inherits name='org.eclipse.jgit.JGit'/>
-  <inherits name='com.google.gerrit.common.Common'/>
-  <inherits name='com.google.gerrit.extensions.Extensions'/>
-  <inherits name='com.google.gerrit.prettify.PrettyFormatter'/>
-  <inherits name='com.google.gwtexpui.clippy.Clippy'/>
-  <inherits name='com.google.gwtexpui.globalkey.GlobalKey'/>
-  <inherits name='com.google.gwtexpui.progress.Progress'/>
-  <inherits name='com.google.gwtexpui.safehtml.SafeHtml'/>
-  <source path='client'>
-    <include name='AccountFormatter.java'/>
-    <include name='CommonConstants.java'/>
-    <include name='CommonMessages.java'/>
-    <include name='DateFormatter.java'/>
-    <include name='GerritUiExtensionPoint.java'/>
-    <include name='RelativeDateFormatter.java'/>
-    <include name='Resources.java'/>
-    <include name='CommonConstants.properties'/>
-    <include name='CommonMessages.properties'/>
-    <include name='info/*.java'/>
-    <include name='rpc/NativeMap.java'/>
-    <include name='rpc/Natives.java'/>
-    <include name='rpc/NativeString.java'/>
-    <include name='rpc/TransformCallback.java'/>
-    <include name='ui/HighlightSuggestion.java'/>
-    <include name='ui/RemoteSuggestOracle.java'/>
-  </source>
-</module>
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/AccountFormatter.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/AccountFormatter.java
deleted file mode 100644
index 3058971..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/AccountFormatter.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gerrit.client.info.AccountInfo;
-
-public class AccountFormatter {
-  private final String anonymousCowardName;
-
-  public AccountFormatter(String anonymousCowardName) {
-    this.anonymousCowardName = anonymousCowardName;
-  }
-
-  /**
-   * Formats an account as a name and an email address.
-   *
-   * <p>Example output:
-   *
-   * <ul>
-   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
-   *   <li>{@code A U. Thor (12)}: missing email address
-   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
-   *   <li>{@code Anonymous Coward (12)}: missing name and email address
-   * </ul>
-   */
-  public String nameEmail(AccountInfo info) {
-    String name = info.name();
-    if (name == null || name.trim().isEmpty()) {
-      name = anonymousCowardName;
-    }
-
-    StringBuilder b = new StringBuilder().append(name);
-    if (info.email() != null) {
-      b.append(" <").append(info.email()).append(">");
-    } else if (info._accountId() > 0) {
-      b.append(" (").append(info._accountId()).append(")");
-    }
-    return b.toString();
-  }
-
-  /**
-   * Formats an account name.
-   *
-   * <p>If the account has a full name, it returns only the full name. Otherwise it returns a longer
-   * form that includes the email address.
-   */
-  public String name(AccountInfo ai) {
-    if (ai.name() != null && !ai.name().trim().isEmpty()) {
-      return ai.name();
-    }
-    String email = ai.email();
-    if (email != null) {
-      int at = email.indexOf('@');
-      return 0 < at ? email.substring(0, at) : email;
-    }
-    return nameEmail(ai);
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java
deleted file mode 100644
index e769730..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.i18n.client.Constants;
-
-public interface CommonConstants extends Constants {
-  CommonConstants C = GWT.create(CommonConstants.class);
-
-  String inTheFuture();
-
-  String month();
-
-  String months();
-
-  String year();
-
-  String years();
-
-  String oneSecondAgo();
-
-  String oneMinuteAgo();
-
-  String oneHourAgo();
-
-  String oneDayAgo();
-
-  String oneWeekAgo();
-
-  String oneMonthAgo();
-
-  String oneYearAgo();
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.properties b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.properties
deleted file mode 100644
index 3202bfc..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.properties
+++ /dev/null
@@ -1,13 +0,0 @@
-inTheFuture = in the future
-month = month
-months = months
-years = years
-year = year
-
-oneSecondAgo = 1 second ago
-oneMinuteAgo = 1 minute ago
-oneHourAgo = 1 hour ago
-oneDayAgo = 1 day ago
-oneWeekAgo = 1 week ago
-oneMonthAgo = 1 month ago
-oneYearAgo = 1 year ago
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java
deleted file mode 100644
index 5314254..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.i18n.client.Messages;
-
-public interface CommonMessages extends Messages {
-  CommonMessages M = GWT.create(CommonMessages.class);
-
-  String secondsAgo(long seconds);
-
-  String minutesAgo(long minutes);
-
-  String hoursAgo(long hours);
-
-  String daysAgo(long days);
-
-  String weeksAgo(long weeks);
-
-  String monthsAgo(long months);
-
-  String yearsAgo(long years);
-
-  String years0MonthsAgo(long years, String yearLabel);
-
-  String yearsMonthsAgo(long years, String yearLabel, long months, String monthLabel);
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.properties b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.properties
deleted file mode 100644
index 738602e..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.properties
+++ /dev/null
@@ -1,9 +0,0 @@
-secondsAgo = {0} seconds ago
-minutesAgo = {0} minutes ago
-hoursAgo = {0} hours ago
-daysAgo = {0} days ago
-weeksAgo = {0} weeks ago
-monthsAgo = {0} months ago
-years0MonthsAgo = {0} {1} ago
-yearsMonthsAgo = {0} {1}, {2} {3} ago
-yearsAgo = {0} years ago
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java
deleted file mode 100644
index 4df2f5f..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gwt.i18n.client.DateTimeFormat;
-import java.util.Date;
-
-public class DateFormatter {
-  private static final long ONE_YEAR = 182L * 24 * 60 * 60 * 1000;
-
-  private final DateTimeFormat sTime;
-  private final DateTimeFormat sDate;
-  private final DateTimeFormat sdtFmt;
-  private final DateTimeFormat mDate;
-  private final DateTimeFormat dtfmt;
-
-  public DateFormatter(GeneralPreferences prefs) {
-    String fmt_sTime = prefs.timeFormat().getFormat();
-    String fmt_sDate = prefs.dateFormat().getShortFormat();
-    String fmt_mDate = prefs.dateFormat().getLongFormat();
-
-    sTime = DateTimeFormat.getFormat(fmt_sTime);
-    sDate = DateTimeFormat.getFormat(fmt_sDate);
-    sdtFmt = DateTimeFormat.getFormat(fmt_sDate + " " + fmt_sTime);
-    mDate = DateTimeFormat.getFormat(fmt_mDate);
-    dtfmt = DateTimeFormat.getFormat(fmt_mDate + " " + fmt_sTime);
-  }
-
-  /** Format a date using a really short format. */
-  public String shortFormat(Date dt) {
-    if (dt == null) {
-      return "";
-    }
-
-    Date now = new Date();
-    dt = new Date(dt.getTime());
-    if (mDate.format(now).equals(mDate.format(dt))) {
-      // Same day as today, report only the time.
-      //
-      return sTime.format(dt);
-
-    } else if (Math.abs(now.getTime() - dt.getTime()) < ONE_YEAR) {
-      // Within the last year, show a shorter date.
-      //
-      return sDate.format(dt);
-
-    } else {
-      // Report only date and year, its far away from now.
-      //
-      return mDate.format(dt);
-    }
-  }
-
-  /** Format a date using a really short format. */
-  public String shortFormatDayTime(Date dt) {
-    if (dt == null) {
-      return "";
-    }
-
-    Date now = new Date();
-    dt = new Date(dt.getTime());
-    if (mDate.format(now).equals(mDate.format(dt))) {
-      // Same day as today, report only the time.
-      //
-      return sTime.format(dt);
-
-    } else if (Math.abs(now.getTime() - dt.getTime()) < ONE_YEAR) {
-      // Within the last year, show a shorter date.
-      //
-      return sdtFmt.format(dt);
-
-    } else {
-      // Report only date and year, its far away from now.
-      //
-      return mDate.format(dt);
-    }
-  }
-
-  /** Format a date using the locale's medium length format. */
-  public String mediumFormat(Date dt) {
-    if (dt == null) {
-      return "";
-    }
-    return dtfmt.format(new Date(dt.getTime()));
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
deleted file mode 100644
index 66a3b6b..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-public enum GerritUiExtensionPoint {
-  /* ChangeScreen */
-  CHANGE_SCREEN_HEADER,
-  CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS,
-  CHANGE_SCREEN_HEADER_RIGHT_OF_POP_DOWNS,
-  CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
-  CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
-  CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK,
-  CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS,
-
-  /* MyPasswordScreen */
-  PASSWORD_SCREEN_BOTTOM,
-
-  /* MyPreferencesScreen */
-  PREFERENCES_SCREEN_BOTTOM,
-
-  /* MyProfileScreen */
-  PROFILE_SCREEN_BOTTOM,
-
-  /* ProjectInfoScreen */
-  PROJECT_INFO_SCREEN_TOP,
-  PROJECT_INFO_SCREEN_BOTTOM;
-
-  public enum Key {
-    ACCOUNT_INFO,
-    CHANGE_INFO,
-    PROJECT_NAME,
-    REVISION_INFO
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
deleted file mode 100644
index e0cc9ca..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
+++ /dev/null
@@ -1,142 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import java.util.Date;
-
-/**
- * Formatter to format timestamps relative to the current time using time units in the format
- * defined by {@code git log --relative-date}.
- */
-public class RelativeDateFormatter {
-  private static CommonConstants constants;
-  private static CommonMessages messages;
-
-  static final long SECOND_IN_MILLIS = 1000;
-  static final long MINUTE_IN_MILLIS = 60 * SECOND_IN_MILLIS;
-  static final long HOUR_IN_MILLIS = 60 * MINUTE_IN_MILLIS;
-  static final long DAY_IN_MILLIS = 24 * HOUR_IN_MILLIS;
-  static final long WEEK_IN_MILLIS = 7 * DAY_IN_MILLIS;
-  static final long MONTH_IN_MILLIS = 30 * DAY_IN_MILLIS;
-  static final long YEAR_IN_MILLIS = 365 * DAY_IN_MILLIS;
-
-  static void setConstants(CommonConstants c, CommonMessages m) {
-    constants = c;
-    messages = m;
-  }
-
-  private static CommonConstants c() {
-    return constants != null ? constants : CommonConstants.C;
-  }
-
-  private static CommonMessages m() {
-    return messages != null ? messages : CommonMessages.M;
-  }
-
-  /**
-   * @param when {@link Date} to format
-   * @return age of given {@link Date} compared to now formatted in the same relative format as
-   *     returned by {@code git log --relative-date}
-   */
-  public static String format(Date when) {
-    long ageMillis = (new Date()).getTime() - when.getTime();
-
-    // shouldn't happen in a perfect world
-    if (ageMillis < 0) {
-      return c().inTheFuture();
-    }
-
-    // seconds
-    if (ageMillis < upperLimit(MINUTE_IN_MILLIS)) {
-      long seconds = round(ageMillis, SECOND_IN_MILLIS);
-      if (seconds == 1) {
-        return c().oneSecondAgo();
-      }
-      return m().secondsAgo(seconds);
-    }
-
-    // minutes
-    if (ageMillis < upperLimit(HOUR_IN_MILLIS)) {
-      long minutes = round(ageMillis, MINUTE_IN_MILLIS);
-      if (minutes == 1) {
-        return c().oneMinuteAgo();
-      }
-      return m().minutesAgo(minutes);
-    }
-
-    // hours
-    if (ageMillis < upperLimit(DAY_IN_MILLIS)) {
-      long hours = round(ageMillis, HOUR_IN_MILLIS);
-      if (hours == 1) {
-        return c().oneHourAgo();
-      }
-      return m().hoursAgo(hours);
-    }
-
-    // up to 14 days use days
-    if (ageMillis < 14 * DAY_IN_MILLIS) {
-      long days = round(ageMillis, DAY_IN_MILLIS);
-      if (days == 1) {
-        return c().oneDayAgo();
-      }
-      return m().daysAgo(days);
-    }
-
-    // up to 10 weeks use weeks
-    if (ageMillis < 10 * WEEK_IN_MILLIS) {
-      long weeks = round(ageMillis, WEEK_IN_MILLIS);
-      if (weeks == 1) {
-        return c().oneWeekAgo();
-      }
-      return m().weeksAgo(weeks);
-    }
-
-    // months
-    if (ageMillis < YEAR_IN_MILLIS) {
-      long months = round(ageMillis, MONTH_IN_MILLIS);
-      if (months == 1) {
-        return c().oneMonthAgo();
-      }
-      return m().monthsAgo(months);
-    }
-
-    // up to 5 years use "year, months" rounded to months
-    if (ageMillis < 5 * YEAR_IN_MILLIS) {
-      long years = round(ageMillis, MONTH_IN_MILLIS) / 12;
-      String yearLabel = (years > 1) ? c().years() : c().year();
-      long months = round(ageMillis - years * YEAR_IN_MILLIS, MONTH_IN_MILLIS);
-      String monthLabel = (months > 1) ? c().months() : (months == 1 ? c().month() : "");
-      if (months == 0) {
-        return m().years0MonthsAgo(years, yearLabel);
-      }
-      return m().yearsMonthsAgo(years, yearLabel, months, monthLabel);
-    }
-
-    // years
-    long years = round(ageMillis, YEAR_IN_MILLIS);
-    if (years == 1) {
-      return c().oneYearAgo();
-    }
-    return m().yearsAgo(years);
-  }
-
-  private static long upperLimit(long unit) {
-    return unit + unit / 2;
-  }
-
-  private static long round(long n, long unit) {
-    return (n + unit / 2) / unit;
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
deleted file mode 100644
index 67627c3..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.ImageResource;
-
-public interface Resources extends ClientBundle {
-  /** silk icons (CC-BY3.0): http://famfamfam.com/lab/icons/silk/ */
-  @Source("note_add.png")
-  ImageResource addFileComment();
-
-  @Source("tag_blue_add.png")
-  ImageResource addHashtag();
-
-  @Source("user_add.png")
-  ImageResource addUser();
-
-  @Source("user_edit.png")
-  ImageResource editUser();
-
-  // derived from resultset_next.png
-  @Source("resultset_down_gray.png")
-  ImageResource arrowDown();
-
-  // derived from resultset_next.png
-  @Source("resultset_next_gray.png")
-  ImageResource arrowRight();
-
-  // derived from resultset_next.png
-  @Source("resultset_up_gray.png")
-  ImageResource arrowUp();
-
-  @Source("lightbulb.png")
-  ImageResource blame();
-
-  @Source("page_white_put.png")
-  ImageResource downloadIcon();
-
-  // derived from comment.png
-  @Source("comment_draft.png")
-  ImageResource draftComments();
-
-  @Source("page_edit.png")
-  ImageResource edit();
-
-  @Source("arrow_undo.png")
-  ImageResource editUndo();
-
-  @Source("cog.png")
-  ImageResource gear();
-
-  @Source("tick.png")
-  ImageResource greenCheck();
-
-  @Source("tag_blue.png")
-  ImageResource hashtag();
-
-  @Source("lightbulb.png")
-  ImageResource info();
-
-  @Source("find.png")
-  ImageResource queryIcon();
-
-  @Source("lock.png")
-  ImageResource readOnly();
-
-  @Source("cross.png")
-  ImageResource redNot();
-
-  @Source("disk.png")
-  ImageResource save();
-
-  @Source("star.png")
-  ImageResource starFilled();
-
-  // derived from star.png
-  @Source("star-open.png")
-  ImageResource starOpen();
-
-  @Source("exclamation.png")
-  ImageResource warning();
-
-  @Source("help.png")
-  ImageResource question();
-
-  /** tango icon library (public domain): http://tango.freedesktop.org/Tango_Icon_Library */
-  @Source("goNext.png")
-  ImageResource goNext();
-
-  @Source("goPrev.png")
-  ImageResource goPrev();
-
-  @Source("goUp.png")
-  ImageResource goUp();
-
-  @Source("listAdd.png")
-  ImageResource listAdd();
-
-  // derived from important.png
-  @Source("merge.png")
-  ImageResource merge();
-
-  /** contributed by the artist under Apache2.0 */
-  @Source("sideBySideDiff.png")
-  ImageResource sideBySideDiff();
-
-  @Source("unifiedDiff.png")
-  ImageResource unifiedDiff();
-
-  /** contributed by the artist under CC-BY3.0 */
-  @Source("diffy26.png")
-  ImageResource gerritAvatar26();
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java
deleted file mode 100644
index e4c008c..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
-import java.sql.Timestamp;
-
-public class AccountInfo extends JavaScriptObject {
-  public final native int _accountId() /*-{ return this._account_id || 0; }-*/;
-
-  public final native String name() /*-{ return this.name; }-*/;
-
-  public final native String email() /*-{ return this.email; }-*/;
-
-  public final native JsArrayString secondaryEmails() /*-{ return this.secondary_emails; }-*/;
-
-  public final native String username() /*-{ return this.username; }-*/;
-
-  public final Timestamp registeredOn() {
-    Timestamp ts = _getRegisteredOn();
-    if (ts == null) {
-      ts = JavaSqlTimestamp_JsonSerializer.parseTimestamp(registeredOnRaw());
-      _setRegisteredOn(ts);
-    }
-    return ts;
-  }
-
-  private native String registeredOnRaw() /*-{ return this.registered_on; }-*/;
-
-  private native Timestamp _getRegisteredOn() /*-{ return this._cts; }-*/;
-
-  private native void _setRegisteredOn(Timestamp ts) /*-{ this._cts = ts; }-*/;
-
-  /**
-   * @return true if the server supplied avatar information about this account. The information may
-   *     be an empty list, indicating no avatars are available, such as when no plugin is installed.
-   *     This method returns false if the server did not check on avatars for the account.
-   */
-  public final native boolean hasAvatarInfo() /*-{ return this.hasOwnProperty('avatars') }-*/;
-
-  public final AvatarInfo avatar(int sz) {
-    JsArray<AvatarInfo> a = avatars();
-    for (int i = 0; a != null && i < a.length(); i++) {
-      AvatarInfo r = a.get(i);
-      if (r.height() == sz) {
-        return r;
-      }
-    }
-    return null;
-  }
-
-  private native JsArray<AvatarInfo> avatars() /*-{ return this.avatars }-*/;
-
-  public final native void name(String n) /*-{ this.name = n }-*/;
-
-  public final native void email(String e) /*-{ this.email = e }-*/;
-
-  public final native void username(String n) /*-{ this.username = n }-*/;
-
-  public static native AccountInfo create(int id, String name, String email, String username) /*-{
-    return {'_account_id': id, 'name': name, 'email': email,
-        'username': username};
-  }-*/;
-
-  protected AccountInfo() {}
-
-  public static class AvatarInfo extends JavaScriptObject {
-    public static final int DEFAULT_SIZE = 26;
-
-    public final native String url() /*-{ return this.url }-*/;
-
-    public final native int height() /*-{ return this.height || 0 }-*/;
-
-    public final native int width() /*-{ return this.width || 0 }-*/;
-
-    protected AvatarInfo() {}
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ActionInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ActionInfo.java
deleted file mode 100644
index d09d5b7..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ActionInfo.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class ActionInfo extends JavaScriptObject {
-
-  public final native String id() /*-{ return this.id; }-*/;
-
-  public final native String method() /*-{ return this.method; }-*/;
-
-  public final native String label() /*-{ return this.label; }-*/;
-
-  public final native String title() /*-{ return this.title; }-*/;
-
-  public final native boolean enabled() /*-{ return this.enabled || false; }-*/;
-
-  protected ActionInfo() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AgreementInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AgreementInfo.java
deleted file mode 100644
index 04fba4f..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AgreementInfo.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class AgreementInfo extends JavaScriptObject {
-  public final native String name() /*-{ return this.name; }-*/;
-
-  public final native String description() /*-{ return this.description; }-*/;
-
-  public final native String url() /*-{ return this.url; }-*/;
-
-  public final native GroupInfo autoVerifyGroup() /*-{ return this.auto_verify_group; }-*/;
-
-  protected AgreementInfo() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
deleted file mode 100644
index 43281bd..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import java.util.ArrayList;
-import java.util.List;
-
-public class AuthInfo extends JavaScriptObject {
-  public final AuthType authType() {
-    return AuthType.valueOf(authTypeRaw());
-  }
-
-  public final boolean isLdap() {
-    return authType() == AuthType.LDAP || authType() == AuthType.LDAP_BIND;
-  }
-
-  public final boolean isOpenId() {
-    return authType() == AuthType.OPENID;
-  }
-
-  public final boolean isOAuth() {
-    return authType() == AuthType.OAUTH;
-  }
-
-  public final boolean isDev() {
-    return authType() == AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT;
-  }
-
-  public final boolean isClientSslCertLdap() {
-    return authType() == AuthType.CLIENT_SSL_CERT_LDAP;
-  }
-
-  public final boolean isCustomExtension() {
-    return authType() == AuthType.CUSTOM_EXTENSION;
-  }
-
-  public final boolean canEdit(AccountFieldName f) {
-    return editableAccountFields().contains(f);
-  }
-
-  public final List<AccountFieldName> editableAccountFields() {
-    List<AccountFieldName> fields = new ArrayList<>();
-    for (String f : Natives.asList(_editableAccountFields())) {
-      fields.add(AccountFieldName.valueOf(f));
-    }
-    return fields;
-  }
-
-  public final List<AgreementInfo> contributorAgreements() {
-    List<AgreementInfo> agreements = new ArrayList<>();
-    JsArray<AgreementInfo> contributorAgreements = _contributorAgreements();
-    if (contributorAgreements != null) {
-      agreements.addAll(Natives.asList(contributorAgreements));
-    }
-    return agreements;
-  }
-
-  public final boolean siteHasUsernames() {
-    if (isCustomExtension() && httpPasswordUrl() != null && !canEdit(AccountFieldName.USER_NAME)) {
-      return false;
-    }
-    return true;
-  }
-
-  public final boolean isHttpPasswordSettingsEnabled() {
-    return gitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP
-        || gitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP_LDAP;
-  }
-
-  public final GitBasicAuthPolicy gitBasicAuthPolicy() {
-    return GitBasicAuthPolicy.valueOf(gitBasicAuthPolicyRaw());
-  }
-
-  public final native boolean useContributorAgreements()
-      /*-{ return this.use_contributor_agreements || false; }-*/ ;
-
-  public final native String loginUrl() /*-{ return this.login_url; }-*/;
-
-  public final native String loginText() /*-{ return this.login_text; }-*/;
-
-  public final native String switchAccountUrl() /*-{ return this.switch_account_url; }-*/;
-
-  public final native String registerUrl() /*-{ return this.register_url; }-*/;
-
-  public final native String registerText() /*-{ return this.register_text; }-*/;
-
-  public final native String editFullNameUrl() /*-{ return this.edit_full_name_url; }-*/;
-
-  public final native String httpPasswordUrl() /*-{ return this.http_password_url; }-*/;
-
-  private native String gitBasicAuthPolicyRaw() /*-{ return this.git_basic_auth_policy; }-*/;
-
-  private native String authTypeRaw() /*-{ return this.auth_type; }-*/;
-
-  private native JsArrayString _editableAccountFields()
-      /*-{ return this.editable_account_fields; }-*/ ;
-
-  private native JsArray<AgreementInfo> _contributorAgreements()
-      /*-{ return this.contributor_agreements; }-*/ ;
-
-  protected AuthInfo() {}
-}
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
deleted file mode 100644
index 866d74f..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ /dev/null
@@ -1,585 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-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;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-public class ChangeInfo extends JavaScriptObject {
-  public final void init() {
-    if (allLabels() != null) {
-      allLabels().copyKeysIntoChildren("_name");
-    }
-  }
-
-  public final Project.NameKey projectNameKey() {
-    return new Project.NameKey(project());
-  }
-
-  public final Change.Id legacyId() {
-    return new Change.Id(_number());
-  }
-
-  public final Timestamp created() {
-    Timestamp ts = _getCts();
-    if (ts == null) {
-      ts = JavaSqlTimestamp_JsonSerializer.parseTimestamp(createdRaw());
-      _setCts(ts);
-    }
-    return ts;
-  }
-
-  public final boolean hasEditBasedOnCurrentPatchSet() {
-    JsArray<RevisionInfo> revList = revisions().values();
-    RevisionInfo.sortRevisionInfoByNumber(revList);
-    return revList.get(revList.length() - 1).isEdit();
-  }
-
-  private native Timestamp _getCts() /*-{ return this._cts; }-*/;
-
-  private native void _setCts(Timestamp ts) /*-{ this._cts = ts; }-*/;
-
-  public final Timestamp updated() {
-    return JavaSqlTimestamp_JsonSerializer.parseTimestamp(updatedRaw());
-  }
-
-  public final Timestamp submitted() {
-    return JavaSqlTimestamp_JsonSerializer.parseTimestamp(submittedRaw());
-  }
-
-  public final String idAbbreviated() {
-    return new Change.Key(changeId()).abbreviate();
-  }
-
-  public final Change.Status status() {
-    return Change.Status.valueOf(statusRaw());
-  }
-
-  public final Set<String> labels() {
-    return allLabels().keySet();
-  }
-
-  public final Set<Integer> removableReviewerIds() {
-    Set<Integer> removable = new HashSet<>();
-    if (removableReviewers() != null) {
-      for (AccountInfo a : Natives.asList(removableReviewers())) {
-        removable.add(a._accountId());
-      }
-    }
-    return removable;
-  }
-
-  public final native String id() /*-{ return this.id; }-*/;
-
-  public final native String project() /*-{ return this.project; }-*/;
-
-  public final native String branch() /*-{ return this.branch; }-*/;
-
-  public final native String topic() /*-{ return this.topic; }-*/;
-
-  public final native String changeId() /*-{ return this.change_id; }-*/;
-
-  public final native boolean mergeable() /*-{ return this.mergeable ? true : false; }-*/;
-
-  public final native int insertions() /*-{ return this.insertions; }-*/;
-
-  public final native int deletions() /*-{ return this.deletions; }-*/;
-
-  private native String statusRaw() /*-{ return this.status; }-*/;
-
-  public final native String subject() /*-{ return this.subject; }-*/;
-
-  public final native AccountInfo owner() /*-{ return this.owner; }-*/;
-
-  public final native AccountInfo assignee() /*-{ return this.assignee; }-*/;
-
-  private native String createdRaw() /*-{ return this.created; }-*/;
-
-  private native String updatedRaw() /*-{ return this.updated; }-*/;
-
-  private native String submittedRaw() /*-{ return this.submitted; }-*/;
-
-  public final native AccountInfo submitter() /*-{ return this.submitter; }-*/;
-
-  public final native boolean starred() /*-{ return this.starred ? true : false; }-*/;
-
-  public final native boolean reviewed() /*-{ return this.reviewed ? true : false; }-*/;
-
-  public final native boolean isPrivate() /*-{ return this.is_private ? true : false; }-*/;
-
-  public final native boolean
-      isWorkInProgress() /*-{ return this.work_in_progress ? true : false; }-*/;
-
-  public final native NativeMap<LabelInfo> allLabels() /*-{ return this.labels; }-*/;
-
-  public final native LabelInfo label(String n) /*-{ return this.labels[n]; }-*/;
-
-  public final native String currentRevision() /*-{ return this.current_revision; }-*/;
-
-  public final native void setCurrentRevision(String r) /*-{ this.current_revision = r; }-*/;
-
-  public final native NativeMap<RevisionInfo> revisions() /*-{ return this.revisions; }-*/;
-
-  public final native RevisionInfo revision(String n) /*-{ return this.revisions[n]; }-*/;
-
-  public final native JsArray<MessageInfo> messages() /*-{ return this.messages; }-*/;
-
-  public final native void setEdit(EditInfo edit) /*-{ this.edit = edit; }-*/;
-
-  public final native EditInfo edit() /*-{ return this.edit; }-*/;
-
-  public final native boolean hasEdit() /*-{ return this.hasOwnProperty('edit') }-*/;
-
-  public final native JsArrayString hashtags() /*-{ return this.hashtags; }-*/;
-
-  public final native boolean hasPermittedLabels()
-      /*-{ return this.hasOwnProperty('permitted_labels') }-*/ ;
-
-  public final native NativeMap<JsArrayString> permittedLabels()
-      /*-{ return this.permitted_labels; }-*/ ;
-
-  public final native JsArrayString permittedValues(String n)
-      /*-{ return this.permitted_labels[n]; }-*/ ;
-
-  public final native JsArray<AccountInfo> removableReviewers()
-      /*-{ return this.removable_reviewers; }-*/ ;
-
-  private native NativeMap<JsArray<AccountInfo>> _reviewers() /*-{ return this.reviewers; }-*/;
-
-  public final Map<ReviewerState, List<AccountInfo>> reviewers() {
-    NativeMap<JsArray<AccountInfo>> reviewers = _reviewers();
-    Map<ReviewerState, List<AccountInfo>> result = new HashMap<>();
-    for (String k : reviewers.keySet()) {
-      ReviewerState state = ReviewerState.valueOf(k.toUpperCase());
-      List<AccountInfo> accounts = result.get(state);
-      if (accounts == null) {
-        accounts = new ArrayList<>();
-        result.put(state, accounts);
-      }
-      accounts.addAll(Natives.asList(reviewers.get(k)));
-    }
-    return result;
-  }
-
-  public final native boolean hasActions() /*-{ return this.hasOwnProperty('actions') }-*/;
-
-  public final native NativeMap<ActionInfo> actions() /*-{ return this.actions; }-*/;
-
-  public final native int _number() /*-{ return this._number; }-*/;
-
-  public final native boolean _more_changes() /*-{ return this._more_changes ? true : false; }-*/;
-
-  public final SubmitType submitType() {
-    String submitType = _submitType();
-    if (submitType == null) {
-      return null;
-    }
-    return SubmitType.valueOf(submitType);
-  }
-
-  private native String _submitType() /*-{ return this.submit_type; }-*/;
-
-  public final boolean submittable() {
-    init();
-    return _submittable();
-  }
-
-  private native boolean _submittable() /*-{ return this.submittable ? true : false; }-*/;
-
-  /**
-   * @return the index of the missing label or -1 if no label is missing, or if more than one label
-   *     is missing.
-   */
-  public final int getMissingLabelIndex() {
-    int i = -1;
-    int ret = -1;
-    List<LabelInfo> labels = Natives.asList(allLabels().values());
-    for (LabelInfo label : labels) {
-      i++;
-      if (!permittedLabels().containsKey(label.name())) {
-        continue;
-      }
-
-      JsArrayString values = permittedValues(label.name());
-      if (values.length() == 0) {
-        continue;
-      }
-
-      switch (label.status()) {
-        case NEED: // Label is required for submit.
-          if (ret != -1) {
-            // more than one label is missing, so it's unclear which to quick
-            // approve, return -1
-            return -1;
-          }
-          ret = i;
-          continue;
-
-        case OK: // Label already applied.
-        case MAY: // Label is not required.
-          continue;
-
-        case REJECT: // Submit cannot happen, do not quick approve.
-        case IMPOSSIBLE:
-          return -1;
-      }
-    }
-    return ret;
-  }
-
-  protected ChangeInfo() {}
-
-  public static class LabelInfo extends JavaScriptObject {
-    public final SubmitRecord.Label.Status status() {
-      if (approved() != null) {
-        return SubmitRecord.Label.Status.OK;
-      } else if (rejected() != null) {
-        return SubmitRecord.Label.Status.REJECT;
-      } else if (optional()) {
-        return SubmitRecord.Label.Status.MAY;
-      } else {
-        return SubmitRecord.Label.Status.NEED;
-      }
-    }
-
-    public final native String name() /*-{ return this._name; }-*/;
-
-    public final native AccountInfo approved() /*-{ return this.approved; }-*/;
-
-    public final native AccountInfo rejected() /*-{ return this.rejected; }-*/;
-
-    public final native AccountInfo recommended() /*-{ return this.recommended; }-*/;
-
-    public final native AccountInfo disliked() /*-{ return this.disliked; }-*/;
-
-    public final native JsArray<ApprovalInfo> all() /*-{ return this.all; }-*/;
-
-    public final ApprovalInfo forUser(int user) {
-      JsArray<ApprovalInfo> all = all();
-      for (int i = 0; all != null && i < all.length(); i++) {
-        if (all.get(i)._accountId() == user) {
-          return all.get(i);
-        }
-      }
-      return null;
-    }
-
-    private native NativeMap<NativeString> _values() /*-{ return this.values; }-*/;
-
-    public final Set<String> values() {
-      return Natives.keys(_values());
-    }
-
-    public final native String valueText(String n) /*-{ return this.values[n]; }-*/;
-
-    public final native boolean optional() /*-{ return this.optional ? true : false; }-*/;
-
-    public final native boolean blocking() /*-{ return this.blocking ? true : false; }-*/;
-
-    public final native short defaultValue() /*-{ return this.default_value; }-*/;
-
-    public final native short _value() /*-{
-      if (this.value) return this.value;
-      if (this.disliked) return -1;
-      if (this.recommended) return 1;
-      return 0;
-    }-*/;
-
-    public final String maxValue() {
-      return LabelValue.formatValue(valueSet().last());
-    }
-
-    public final SortedSet<Short> valueSet() {
-      SortedSet<Short> values = new TreeSet<>();
-      for (String v : values()) {
-        values.add(parseValue(v));
-      }
-      return values;
-    }
-
-    public static final short parseValue(String formatted) {
-      if (formatted.startsWith("+")) {
-        formatted = formatted.substring(1);
-      } else if (formatted.startsWith(" ")) {
-        formatted = formatted.trim();
-      }
-      return Short.parseShort(formatted);
-    }
-
-    protected LabelInfo() {}
-  }
-
-  public static class ApprovalInfo extends AccountInfo {
-    public final native boolean hasValue() /*-{ return this.hasOwnProperty('value'); }-*/;
-
-    public final native short value() /*-{ return this.value || 0; }-*/;
-
-    public final native VotingRangeInfo
-        permittedVotingRange() /*-{ return this.permitted_voting_range; }-*/;
-
-    protected ApprovalInfo() {}
-  }
-
-  public static class VotingRangeInfo extends AccountInfo {
-    public final native short min() /*-{ return this.min || 0; }-*/;
-
-    public final native short max() /*-{ return this.max || 0; }-*/;
-
-    protected VotingRangeInfo() {}
-  }
-
-  public static class EditInfo extends JavaScriptObject {
-    public final native String name() /*-{ return this.name; }-*/;
-
-    public final native String setName(String n) /*-{ this.name = n; }-*/;
-
-    public final native String baseRevision() /*-{ return this.base_revision; }-*/;
-
-    public final native CommitInfo commit() /*-{ return this.commit; }-*/;
-
-    public final native boolean hasActions() /*-{ return this.hasOwnProperty('actions') }-*/;
-
-    public final native NativeMap<ActionInfo> actions() /*-{ return this.actions; }-*/;
-
-    public final native boolean hasFetch() /*-{ return this.hasOwnProperty('fetch') }-*/;
-
-    public final native NativeMap<FetchInfo> fetch() /*-{ return this.fetch; }-*/;
-
-    public final native boolean hasFiles() /*-{ return this.hasOwnProperty('files') }-*/;
-
-    public final native NativeMap<FileInfo> files() /*-{ return this.files; }-*/;
-
-    protected EditInfo() {}
-  }
-
-  public static class RevisionInfo extends JavaScriptObject {
-    public static RevisionInfo fromEdit(EditInfo edit) {
-      RevisionInfo revisionInfo = createObject().cast();
-      revisionInfo.takeFromEdit(edit);
-      return revisionInfo;
-    }
-
-    public static RevisionInfo forParent(int number, CommitInfo commit) {
-      RevisionInfo revisionInfo = createObject().cast();
-      revisionInfo.takeFromParent(number, commit);
-      return revisionInfo;
-    }
-
-    private native void takeFromEdit(EditInfo edit) /*-{
-      this._number = 0;
-      this.name = edit.name;
-      this.commit = edit.commit;
-      this.edit_base = edit.base_revision;
-    }-*/;
-
-    private native void takeFromParent(int number, CommitInfo commit) /*-{
-      this._number = number;
-      this.commit = commit;
-      this.name = this._number;
-    }-*/;
-
-    public final native int _number() /*-{ return this._number; }-*/;
-
-    public final native String name() /*-{ return this.name; }-*/;
-
-    public final native AccountInfo uploader() /*-{ return this.uploader; }-*/;
-
-    public final native boolean isEdit() /*-{ return this._number == 0; }-*/;
-
-    public final native CommitInfo commit() /*-{ return this.commit; }-*/;
-
-    public final native void setCommit(CommitInfo c) /*-{ this.commit = c; }-*/;
-
-    public final native String editBase() /*-{ return this.edit_base; }-*/;
-
-    public final native boolean hasFiles() /*-{ return this.hasOwnProperty('files') }-*/;
-
-    public final native NativeMap<FileInfo> files() /*-{ return this.files; }-*/;
-
-    public final native boolean hasActions() /*-{ return this.hasOwnProperty('actions') }-*/;
-
-    public final native NativeMap<ActionInfo> actions() /*-{ return this.actions; }-*/;
-
-    public final native boolean hasFetch() /*-{ return this.hasOwnProperty('fetch') }-*/;
-
-    public final native NativeMap<FetchInfo> fetch() /*-{ return this.fetch; }-*/;
-
-    public final native boolean
-        hasPushCertificate() /*-{ return this.hasOwnProperty('push_certificate'); }-*/;
-
-    public final native PushCertificateInfo
-        pushCertificate() /*-{ return this.push_certificate; }-*/;
-
-    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;
-            }
-          });
-    }
-
-    public static int findEditParent(JsArray<RevisionInfo> list) {
-      RevisionInfo r = findEditParentRevision(list);
-      return r == null ? -1 : r._number();
-    }
-
-    public static RevisionInfo findEditParentRevision(JsArray<RevisionInfo> list) {
-      for (int i = 0; i < list.length(); i++) {
-        // edit under revisions?
-        RevisionInfo editInfo = list.get(i);
-        if (editInfo.isEdit()) {
-          String parentRevision = editInfo.editBase();
-          // find parent
-          for (int j = 0; j < list.length(); j++) {
-            RevisionInfo parentInfo = list.get(j);
-            String name = parentInfo.name();
-            if (name.equals(parentRevision)) {
-              // found parent pacth set number
-              return parentInfo;
-            }
-          }
-        }
-      }
-      return null;
-    }
-
-    public final String id() {
-      return PatchSet.Id.toId(_number());
-    }
-
-    public final boolean isMerge() {
-      return commit().parents().length() > 1;
-    }
-
-    protected RevisionInfo() {}
-  }
-
-  public static class FetchInfo extends JavaScriptObject {
-    public final native String url() /*-{ return this.url }-*/;
-
-    public final native String ref() /*-{ return this.ref }-*/;
-
-    public final native NativeMap<NativeString> commands() /*-{ return this.commands }-*/;
-
-    public final native String command(String n) /*-{ return this.commands[n]; }-*/;
-
-    protected FetchInfo() {}
-  }
-
-  public static class CommitInfo extends JavaScriptObject {
-    public final native String commit() /*-{ return this.commit; }-*/;
-
-    public final native JsArray<CommitInfo> parents() /*-{ return this.parents; }-*/;
-
-    public final native GitPerson author() /*-{ return this.author; }-*/;
-
-    public final native GitPerson committer() /*-{ return this.committer; }-*/;
-
-    public final native String subject() /*-{ return this.subject; }-*/;
-
-    public final native String message() /*-{ return this.message; }-*/;
-
-    public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
-
-    protected CommitInfo() {}
-  }
-
-  public static class GitPerson extends JavaScriptObject {
-    public final native String name() /*-{ return this.name; }-*/;
-
-    public final native String email() /*-{ return this.email; }-*/;
-
-    private native String dateRaw() /*-{ return this.date; }-*/;
-
-    public final Timestamp date() {
-      return JavaSqlTimestamp_JsonSerializer.parseTimestamp(dateRaw());
-    }
-
-    protected GitPerson() {}
-  }
-
-  public static class MessageInfo extends JavaScriptObject {
-    public final native AccountInfo author() /*-{ return this.author; }-*/;
-
-    public final native String message() /*-{ return this.message; }-*/;
-
-    public final native int _revisionNumber() /*-{ return this._revision_number || 0; }-*/;
-
-    public final native String tag() /*-{ return this.tag; }-*/;
-
-    private native String dateRaw() /*-{ return this.date; }-*/;
-
-    public final Timestamp date() {
-      return JavaSqlTimestamp_JsonSerializer.parseTimestamp(dateRaw());
-    }
-
-    protected MessageInfo() {}
-  }
-
-  public static class MergeableInfo extends JavaScriptObject {
-    public final native String submitType() /*-{ return this.submit_type }-*/;
-
-    public final native boolean mergeable() /*-{ return this.mergeable }-*/;
-
-    protected MergeableInfo() {}
-  }
-
-  public static class IncludedInInfo extends JavaScriptObject {
-    public final Set<String> externalNames() {
-      return Natives.keys(external());
-    }
-
-    public final native JsArrayString branches() /*-{ return this.branches; }-*/;
-
-    public final native JsArrayString tags() /*-{ return this.tags; }-*/;
-
-    public final native JsArrayString external(String n) /*-{ return this.external[n]; }-*/;
-
-    private native NativeMap<JsArrayString> external() /*-{ return this.external; }-*/;
-
-    protected IncludedInInfo() {}
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
deleted file mode 100644
index a22a1e8..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArrayString;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-public class DownloadInfo extends JavaScriptObject {
-  public final List<String> schemes() {
-    return _schemes().sortedKeys();
-  }
-
-  public final List<String> archives() {
-    List<String> archives = new ArrayList<>();
-    archives.addAll(Natives.asList(_archives()));
-    return archives;
-  }
-
-  public final native DownloadSchemeInfo scheme(String n) /*-{ return this.schemes[n]; }-*/;
-
-  private native NativeMap<DownloadSchemeInfo> _schemes() /*-{ return this.schemes; }-*/;
-
-  private native JsArrayString _archives() /*-{ return this.archives; }-*/;
-
-  protected DownloadInfo() {}
-
-  public static class DownloadSchemeInfo extends JavaScriptObject {
-    public final List<String> commandNames() {
-      return _commands().sortedKeys();
-    }
-
-    public final Set<DownloadCommandInfo> commands(String project) {
-      Set<DownloadCommandInfo> commands = new HashSet<>();
-      for (String commandName : commandNames()) {
-        commands.add(new DownloadCommandInfo(commandName, command(commandName, project)));
-      }
-      return commands;
-    }
-
-    public final String command(String commandName, String project) {
-      return command(commandName).replaceAll("\\$\\{project\\}", project);
-    }
-
-    private static String projectBaseName(String project) {
-      return project.substring(project.lastIndexOf('/') + 1);
-    }
-
-    public final List<String> cloneCommandNames() {
-      return _cloneCommands().sortedKeys();
-    }
-
-    public final List<DownloadCommandInfo> cloneCommands(String project) {
-      List<String> commandNames = cloneCommandNames();
-      List<DownloadCommandInfo> commands = new ArrayList<>(commandNames.size());
-      for (String commandName : commandNames) {
-        commands.add(new DownloadCommandInfo(commandName, cloneCommand(commandName, project)));
-      }
-      return commands;
-    }
-
-    public final String cloneCommand(String commandName, String project) {
-      return cloneCommand(commandName)
-          .replaceAll("\\$\\{project\\}", project)
-          .replaceAll("\\$\\{project-base-name\\}", projectBaseName(project));
-    }
-
-    public final String getUrl(String project) {
-      return url().replaceAll("\\$\\{project\\}", project);
-    }
-
-    public final native String name() /*-{ return this.name; }-*/;
-
-    public final native String url() /*-{ return this.url; }-*/;
-
-    public final native boolean isAuthRequired() /*-{ return this.is_auth_required || false; }-*/;
-
-    public final native boolean isAuthSupported() /*-{ return this.is_auth_supported || false; }-*/;
-
-    public final native String command(String n) /*-{ return this.commands[n]; }-*/;
-
-    public final native String cloneCommand(String n) /*-{ return this.clone_commands[n]; }-*/;
-
-    private native NativeMap<NativeString> _commands() /*-{ return this.commands; }-*/;
-
-    private native NativeMap<NativeString> _cloneCommands() /*-{ return this.clone_commands; }-*/;
-
-    protected DownloadSchemeInfo() {}
-  }
-
-  public static class DownloadCommandInfo {
-    private final String name;
-    private final String command;
-
-    DownloadCommandInfo(String name, String command) {
-      this.name = name;
-      this.command = command;
-    }
-
-    public String name() {
-      return name;
-    }
-
-    public String command() {
-      return command;
-    }
-  }
-}
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
deleted file mode 100644
index 345a260..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.info;
-
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.common.data.FilenameComparator;
-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 {
-  public final native String path() /*-{ return this.path; }-*/;
-
-  public final native String oldPath() /*-{ return this.old_path; }-*/;
-
-  public final native int linesInserted() /*-{ return this.lines_inserted || 0; }-*/;
-
-  public final native int linesDeleted() /*-{ return this.lines_deleted || 0; }-*/;
-
-  public final native boolean binary() /*-{ return this.binary || false; }-*/;
-
-  public final native String status() /*-{ return this.status; }-*/;
-
-  // JSNI methods cannot have 'long' as a parameter type or a return type and
-  // it's suggested to use double in this case:
-  // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html#important
-  public final long size() {
-    return (long) _size();
-  }
-
-  private native double _size() /*-{ return this.size || 0; }-*/;
-
-  public final long sizeDelta() {
-    return (long) _sizeDelta();
-  }
-
-  private native double _sizeDelta() /*-{ return this.size_delta || 0; }-*/;
-
-  public final native int _row() /*-{ return this._row }-*/;
-
-  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));
-  }
-
-  public static String getFileName(String path) {
-    String fileName;
-    if (Patch.COMMIT_MSG.equals(path)) {
-      fileName = "Commit Message";
-    } else if (Patch.MERGE_LIST.equals(path)) {
-      fileName = "Merge List";
-    } else {
-      fileName = path;
-    }
-
-    int s = fileName.lastIndexOf('/');
-    return s >= 0 ? fileName.substring(s + 1) : fileName;
-  }
-
-  protected FileInfo() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
deleted file mode 100644
index 1dcb284..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
+++ /dev/null
@@ -1,268 +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.client.info;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class GeneralPreferences extends JavaScriptObject {
-  public static GeneralPreferences create() {
-    return createObject().cast();
-  }
-
-  public static GeneralPreferences createDefault() {
-    GeneralPreferencesInfo d = GeneralPreferencesInfo.defaults();
-    GeneralPreferences p = createObject().cast();
-    p.changesPerPage(d.changesPerPage);
-    p.showSiteHeader(d.showSiteHeader);
-    p.useFlashClipboard(d.useFlashClipboard);
-    p.downloadScheme(d.downloadScheme);
-    p.downloadCommand(d.downloadCommand);
-    p.dateFormat(d.getDateFormat());
-    p.timeFormat(d.getTimeFormat());
-    p.highlightAssigneeInChangeTable(d.highlightAssigneeInChangeTable);
-    p.relativeDateInChangeTable(d.relativeDateInChangeTable);
-    p.sizeBarInChangeTable(d.sizeBarInChangeTable);
-    p.legacycidInChangeTable(d.legacycidInChangeTable);
-    p.muteCommonPathPrefixes(d.muteCommonPathPrefixes);
-    p.signedOffBy(d.signedOffBy);
-    p.emailFormat(d.emailFormat);
-    p.reviewCategoryStrategy(d.getReviewCategoryStrategy());
-    p.diffView(d.getDiffView());
-    p.emailStrategy(d.emailStrategy);
-    p.defaultBaseForMerges(d.defaultBaseForMerges);
-    return p;
-  }
-
-  public final int changesPerPage() {
-    int changesPerPage = get("changes_per_page", GeneralPreferencesInfo.DEFAULT_PAGESIZE);
-    return 0 < changesPerPage ? changesPerPage : GeneralPreferencesInfo.DEFAULT_PAGESIZE;
-  }
-
-  private native short get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
-
-  public final native boolean showSiteHeader() /*-{ return this.show_site_header || false }-*/;
-
-  public final native boolean useFlashClipboard()
-      /*-{ return this.use_flash_clipboard || false }-*/ ;
-
-  public final native String downloadScheme() /*-{ return this.download_scheme }-*/;
-
-  public final DownloadCommand downloadCommand() {
-    String s = downloadCommandRaw();
-    return s != null ? DownloadCommand.valueOf(s) : null;
-  }
-
-  private native String downloadCommandRaw() /*-{ return this.download_command }-*/;
-
-  public final DateFormat dateFormat() {
-    String s = dateFormatRaw();
-    return s != null ? DateFormat.valueOf(s) : null;
-  }
-
-  private native String dateFormatRaw() /*-{ return this.date_format }-*/;
-
-  public final TimeFormat timeFormat() {
-    String s = timeFormatRaw();
-    return s != null ? TimeFormat.valueOf(s) : null;
-  }
-
-  private native String timeFormatRaw() /*-{ return this.time_format }-*/;
-
-  public final native boolean highlightAssigneeInChangeTable()
-      /*-{ return this.highlight_assignee_in_change_table || false }-*/ ;
-
-  public final native boolean relativeDateInChangeTable()
-      /*-{ return this.relative_date_in_change_table || false }-*/ ;
-
-  public final native boolean sizeBarInChangeTable()
-      /*-{ return this.size_bar_in_change_table || false }-*/ ;
-
-  public final native boolean legacycidInChangeTable()
-      /*-{ return this.legacycid_in_change_table || false }-*/ ;
-
-  public final native boolean muteCommonPathPrefixes()
-      /*-{ return this.mute_common_path_prefixes || false }-*/ ;
-
-  public final native boolean signedOffBy() /*-{ return this.signed_off_by || false }-*/;
-
-  public final ReviewCategoryStrategy reviewCategoryStrategy() {
-    String s = reviewCategeoryStrategyRaw();
-    return s != null ? ReviewCategoryStrategy.valueOf(s) : ReviewCategoryStrategy.NONE;
-  }
-
-  private native String reviewCategeoryStrategyRaw() /*-{ return this.review_category_strategy }-*/;
-
-  public final DiffView diffView() {
-    String s = diffViewRaw();
-    return s != null ? DiffView.valueOf(s) : null;
-  }
-
-  private native String diffViewRaw() /*-{ return this.diff_view }-*/;
-
-  public final EmailStrategy emailStrategy() {
-    String s = emailStrategyRaw();
-    return s != null ? EmailStrategy.valueOf(s) : null;
-  }
-
-  private native String emailStrategyRaw() /*-{ return this.email_strategy }-*/;
-
-  public final EmailFormat emailFormat() {
-    String s = emailFormatRaw();
-    return s != null ? EmailFormat.valueOf(s) : null;
-  }
-
-  private native String emailFormatRaw() /*-{ return this.email_format }-*/;
-
-  public final DefaultBase defaultBaseForMerges() {
-    String s = defaultBaseForMergesRaw();
-    return s != null ? DefaultBase.valueOf(s) : null;
-  }
-
-  private native String defaultBaseForMergesRaw() /*-{ return this.default_base_for_merges }-*/;
-
-  public final native boolean
-      publishCommentsOnPush() /*-{ return this.publish_comments_on_push || false }-*/;
-
-  public final native JsArray<TopMenuItem> my() /*-{ return this.my; }-*/;
-
-  public final native void changesPerPage(int n) /*-{ this.changes_per_page = n }-*/;
-
-  public final native void showSiteHeader(boolean s) /*-{ this.show_site_header = s }-*/;
-
-  public final native void useFlashClipboard(boolean u) /*-{ this.use_flash_clipboard = u }-*/;
-
-  public final native void downloadScheme(String d) /*-{ this.download_scheme = d }-*/;
-
-  public final void downloadCommand(DownloadCommand d) {
-    downloadCommandRaw(d != null ? d.toString() : null);
-  }
-
-  public final native void downloadCommandRaw(String d) /*-{ this.download_command = d }-*/;
-
-  public final void dateFormat(DateFormat f) {
-    dateFormatRaw(f != null ? f.toString() : null);
-  }
-
-  private native void dateFormatRaw(String f) /*-{ this.date_format = f }-*/;
-
-  public final void timeFormat(TimeFormat f) {
-    timeFormatRaw(f != null ? f.toString() : null);
-  }
-
-  private native void timeFormatRaw(String f) /*-{ this.time_format = f }-*/;
-
-  public final native void highlightAssigneeInChangeTable(boolean d)
-      /*-{ this.highlight_assignee_in_change_table = d }-*/ ;
-
-  public final native void relativeDateInChangeTable(boolean d)
-      /*-{ this.relative_date_in_change_table = d }-*/ ;
-
-  public final native void sizeBarInChangeTable(boolean s)
-      /*-{ this.size_bar_in_change_table = s }-*/ ;
-
-  public final native void legacycidInChangeTable(boolean s)
-      /*-{ this.legacycid_in_change_table = s }-*/ ;
-
-  public final native void muteCommonPathPrefixes(boolean s)
-      /*-{ this.mute_common_path_prefixes = s }-*/ ;
-
-  public final native void signedOffBy(boolean s) /*-{ this.signed_off_by = s }-*/;
-
-  public final void reviewCategoryStrategy(ReviewCategoryStrategy s) {
-    reviewCategoryStrategyRaw(s != null ? s.toString() : null);
-  }
-
-  private native void reviewCategoryStrategyRaw(String s)
-      /*-{ this.review_category_strategy = s }-*/ ;
-
-  public final void diffView(DiffView d) {
-    diffViewRaw(d != null ? d.toString() : null);
-  }
-
-  private native void diffViewRaw(String d) /*-{ this.diff_view = d }-*/;
-
-  public final void emailStrategy(EmailStrategy s) {
-    emailStrategyRaw(s != null ? s.toString() : null);
-  }
-
-  private native void emailStrategyRaw(String s) /*-{ this.email_strategy = s }-*/;
-
-  public final void emailFormat(EmailFormat f) {
-    emailFormatRaw(f != null ? f.toString() : null);
-  }
-
-  private native void emailFormatRaw(String s) /*-{ this.email_format = s }-*/;
-
-  public final void defaultBaseForMerges(DefaultBase b) {
-    defaultBaseForMergesRaw(b != null ? b.toString() : null);
-  }
-
-  private native void defaultBaseForMergesRaw(String b) /*-{ this.default_base_for_merges = b }-*/;
-
-  public final native void publishCommentsOnPush(
-      boolean p) /*-{ this.publish_comments_on_push = p }-*/;
-
-  public final void setMyMenus(List<TopMenuItem> myMenus) {
-    initMy();
-    for (TopMenuItem n : myMenus) {
-      addMy(n);
-    }
-  }
-
-  final native void initMy() /*-{ this.my = []; }-*/;
-
-  final native void addMy(TopMenuItem m) /*-{ this.my.push(m); }-*/;
-
-  public final Map<String, String> urlAliases() {
-    Map<String, String> urlAliases = new HashMap<>();
-    for (String k : Natives.keys(_urlAliases())) {
-      urlAliases.put(k, urlAliasToken(k));
-    }
-    return urlAliases;
-  }
-
-  private native String urlAliasToken(String m) /*-{ return this.url_aliases[m]; }-*/;
-
-  private native NativeMap<NativeString> _urlAliases() /*-{ return this.url_aliases; }-*/;
-
-  public final void setUrlAliases(Map<String, String> urlAliases) {
-    initUrlAliases();
-    for (Map.Entry<String, String> e : urlAliases.entrySet()) {
-      putUrlAlias(e.getKey(), e.getValue());
-    }
-  }
-
-  private native void putUrlAlias(String m, String t) /*-{ this.url_aliases[m] = t; }-*/;
-
-  private native void initUrlAliases() /*-{ this.url_aliases = {}; }-*/;
-
-  protected GeneralPreferences() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
deleted file mode 100644
index 78ca417..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gerrit.extensions.client.UiType;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArrayString;
-import java.util.ArrayList;
-import java.util.List;
-
-public class GerritInfo extends JavaScriptObject {
-  public final Project.NameKey allProjectsNameKey() {
-    return new Project.NameKey(allProjects());
-  }
-
-  public final boolean isAllProjects(Project.NameKey p) {
-    return allProjectsNameKey().equals(p);
-  }
-
-  public final Project.NameKey allUsersNameKey() {
-    return new Project.NameKey(allUsers());
-  }
-
-  public final boolean isAllUsers(Project.NameKey p) {
-    return allUsersNameKey().equals(p);
-  }
-
-  public final native String allProjects() /*-{ return this.all_projects; }-*/;
-
-  public final native String allUsers() /*-{ return this.all_users; }-*/;
-
-  public final native boolean docSearch() /*-{ return this.doc_search; }-*/;
-
-  public final native String docUrl() /*-{ return this.doc_url; }-*/;
-
-  public final native boolean editGpgKeys() /*-{ return this.edit_gpg_keys || false; }-*/;
-
-  public final native String reportBugUrl() /*-{ return this.report_bug_url; }-*/;
-
-  public final native String reportBugText() /*-{ return this.report_bug_text; }-*/;
-
-  private native JsArrayString _webUis() /*-{ return this.web_uis; }-*/;
-
-  public final List<UiType> webUis() {
-    JsArrayString webUis = _webUis();
-    List<UiType> result = new ArrayList<>(webUis.length());
-    for (int i = 0; i < webUis.length(); i++) {
-      UiType t = UiType.parse(webUis.get(i));
-      if (t != null) {
-        result.add(t);
-      }
-    }
-    return result;
-  }
-
-  protected GerritInfo() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java
deleted file mode 100644
index fd4fde7..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArrayString;
-
-public class GpgKeyInfo extends JavaScriptObject {
-  public enum Status {
-    BAD,
-    OK,
-    TRUSTED;
-  }
-
-  public final native String id() /*-{ return this.id; }-*/;
-
-  public final native String fingerprint() /*-{ return this.fingerprint; }-*/;
-
-  public final native JsArrayString userIds() /*-{ return this.user_ids; }-*/;
-
-  public final native String key() /*-{ return this.key; }-*/;
-
-  private native String statusRaw() /*-{ return this.status; }-*/;
-
-  public final Status status() {
-    String s = statusRaw();
-    if (s == null) {
-      return null;
-    }
-    return Status.valueOf(s);
-  }
-
-  public final native boolean hasProblems() /*-{ return this.hasOwnProperty('problems'); }-*/;
-
-  public final native JsArrayString problems() /*-{ return this.problems; }-*/;
-
-  protected GpgKeyInfo() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupBaseInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupBaseInfo.java
deleted file mode 100644
index 94905c0..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupBaseInfo.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.info;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.http.client.URL;
-
-public class GroupBaseInfo extends JavaScriptObject {
-  public final AccountGroup.UUID getGroupUUID() {
-    return new AccountGroup.UUID(URL.decodeQueryString(id()));
-  }
-
-  public final native String id() /*-{ return this.id; }-*/;
-
-  public final native String name() /*-{ return this.name; }-*/;
-
-  protected GroupBaseInfo() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupInfo.java
deleted file mode 100644
index 9bf3411a..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupInfo.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.info;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.http.client.URL;
-
-public class GroupInfo extends GroupBaseInfo {
-  public final AccountGroup.Id getGroupId() {
-    return new AccountGroup.Id(group_id());
-  }
-
-  public final native GroupOptionsInfo options() /*-{ return this.options; }-*/;
-
-  public final native String description() /*-{ return this.description; }-*/;
-
-  public final native String url() /*-{ return this.url; }-*/;
-
-  public final native String owner() /*-{ return this.owner; }-*/;
-
-  public final native void owner(String o) /*-{ if(o)this.owner=o; }-*/;
-
-  public final native JsArray<AccountInfo> members() /*-{ return this.members; }-*/;
-
-  public final native JsArray<GroupInfo> includes() /*-{ return this.includes; }-*/;
-
-  private native int group_id() /*-{ return this.group_id; }-*/;
-
-  private native String owner_id() /*-{ return this.owner_id; }-*/;
-
-  private native void owner_id(String o) /*-{ if(o)this.owner_id=o; }-*/;
-
-  public final AccountGroup.UUID getOwnerUUID() {
-    String owner = owner_id();
-    if (owner != null) {
-      return new AccountGroup.UUID(URL.decodeQueryString(owner));
-    }
-    return null;
-  }
-
-  public final void setOwnerUUID(AccountGroup.UUID uuid) {
-    owner_id(URL.encodeQueryString(uuid.get()));
-  }
-
-  protected GroupInfo() {}
-
-  public static class GroupOptionsInfo extends JavaScriptObject {
-    public final native boolean
-        isVisibleToAll() /*-{ return this['visible_to_all'] ? true : false; }-*/;
-
-    protected GroupOptionsInfo() {}
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java
deleted file mode 100644
index d96adaa..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class OAuthTokenInfo extends JavaScriptObject {
-
-  protected OAuthTokenInfo() {}
-
-  public final native String username() /*-{ return this.username; }-*/;
-
-  public final native String resourceHost() /*-{ return this.resource_host; }-*/;
-
-  public final native String accessToken() /*-{ return this.access_token; }-*/;
-
-  public final native String providerId() /*-{ return this.provider_id; }-*/;
-
-  public final native String expiresAt() /*-{ return this.expires_at; }-*/;
-
-  public final native String type() /*-{ return this.type; }-*/;
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/PushCertificateInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/PushCertificateInfo.java
deleted file mode 100644
index fb5d932..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/PushCertificateInfo.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class PushCertificateInfo extends JavaScriptObject {
-  public final native String certificate() /*-{ return this.certificate; }-*/;
-
-  public final native GpgKeyInfo key() /*-{ return this.key; }-*/;
-
-  protected PushCertificateInfo() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
deleted file mode 100644
index d3274e6..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
+++ /dev/null
@@ -1,113 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArrayString;
-import java.util.HashMap;
-import java.util.Map;
-
-public class ServerInfo extends JavaScriptObject {
-  public final native AuthInfo auth() /*-{ return this.auth; }-*/;
-
-  public final native ChangeConfigInfo change() /*-{ return this.change; }-*/;
-
-  public final native DownloadInfo download() /*-{ return this.download; }-*/;
-
-  public final native GerritInfo gerrit() /*-{ return this.gerrit; }-*/;
-
-  public final native PluginConfigInfo plugin() /*-{ return this.plugin; }-*/;
-
-  public final native SshdInfo sshd() /*-{ return this.sshd; }-*/;
-
-  public final native SuggestInfo suggest() /*-{ return this.suggest; }-*/;
-
-  public final native UserConfigInfo user() /*-{ return this.user; }-*/;
-
-  public final native ReceiveInfo receive() /*-{ return this.receive; }-*/;
-
-  public final Map<String, String> urlAliases() {
-    Map<String, String> urlAliases = new HashMap<>();
-    for (String k : Natives.keys(_urlAliases())) {
-      urlAliases.put(k, urlAliasToken(k));
-    }
-    return urlAliases;
-  }
-
-  public final native String urlAliasToken(String n) /*-{ return this.url_aliases[n]; }-*/;
-
-  private native NativeMap<NativeString> _urlAliases() /*-{ return this.url_aliases; }-*/;
-
-  public final boolean hasSshd() {
-    return sshd() != null;
-  }
-
-  protected ServerInfo() {}
-
-  public static class ChangeConfigInfo extends JavaScriptObject {
-    public final native boolean allowBlame() /*-{ return this.allow_blame || false; }-*/;
-
-    public final native int largeChange() /*-{ return this.large_change || 0; }-*/;
-
-    public final native String replyLabel() /*-{ return this.reply_label; }-*/;
-
-    public final native String replyTooltip() /*-{ return this.reply_tooltip; }-*/;
-
-    public final native boolean
-        showAssigneeInChangesTable() /*-{ return this.show_assignee_in_changes_table || false; }-*/;
-
-    public final native int updateDelay() /*-{ return this.update_delay || 0; }-*/;
-
-    public final native boolean isSubmitWholeTopicEnabled() /*-{
-        return this.submit_whole_topic; }-*/;
-
-    protected ChangeConfigInfo() {}
-  }
-
-  public static class PluginConfigInfo extends JavaScriptObject {
-    public final native boolean hasAvatars() /*-{ return this.has_avatars || false; }-*/;
-
-    public final native JsArrayString jsResourcePaths() /*-{
-        return this.js_resource_paths || []; }-*/;
-
-    protected PluginConfigInfo() {}
-  }
-
-  public static class SshdInfo extends JavaScriptObject {
-    protected SshdInfo() {}
-  }
-
-  public static class SuggestInfo extends JavaScriptObject {
-    public final native int from() /*-{ return this.from || 0; }-*/;
-
-    protected SuggestInfo() {}
-  }
-
-  public static class UserConfigInfo extends JavaScriptObject {
-    public final native String anonymousCowardName() /*-{ return this.anonymous_coward_name; }-*/;
-
-    protected UserConfigInfo() {}
-  }
-
-  public static class ReceiveInfo extends JavaScriptObject {
-    public final native boolean enableSignedPush()
-        /*-{ return this.enable_signed_push || false; }-*/ ;
-
-    protected ReceiveInfo() {}
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenu.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenu.java
deleted file mode 100644
index 7e25af2..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenu.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-
-public class TopMenu extends JavaScriptObject {
-
-  protected TopMenu() {}
-
-  public final native String getName() /*-{ return this.name; }-*/;
-
-  public final native JsArray<TopMenuItem> getItems() /*-{ return this.items; }-*/;
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuItem.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuItem.java
deleted file mode 100644
index 3a286a2..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuItem.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class TopMenuItem extends JavaScriptObject {
-  public static TopMenuItem create(String name, String url) {
-    TopMenuItem i = createObject().cast();
-    i.name(name);
-    i.url(url);
-    return i;
-  }
-
-  public final native String getName() /*-{ return this.name; }-*/;
-
-  public final native String getUrl() /*-{ return this.url; }-*/;
-
-  public final native String getTarget() /*-{ return this.target; }-*/;
-
-  public final native String getId() /*-{ return this.id; }-*/;
-
-  public final native void name(String n) /*-{ this.name = n }-*/;
-
-  public final native void url(String u) /*-{ this.url = u }-*/;
-
-  protected TopMenuItem() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuList.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuList.java
deleted file mode 100644
index d7df1f7..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuList.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.info;
-
-import com.google.gwt.core.client.JsArray;
-
-public class TopMenuList extends JsArray<TopMenu> {
-
-  protected TopMenuList() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/WebLinkInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/WebLinkInfo.java
deleted file mode 100644
index bcf3dde..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/WebLinkInfo.java
+++ /dev/null
@@ -1,50 +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.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Image;
-
-public class WebLinkInfo extends JavaScriptObject {
-
-  public final native String name() /*-{ return this.name; }-*/;
-
-  public final native String imageUrl() /*-{ return this.image_url; }-*/;
-
-  public final native String url() /*-{ return this.url; }-*/;
-
-  public final native String target() /*-{ return this.target; }-*/;
-
-  protected WebLinkInfo() {}
-
-  public final Anchor toAnchor() {
-    Anchor a = new Anchor();
-    a.setHref(url());
-    if (target() != null && !target().isEmpty()) {
-      a.setTarget(target());
-    }
-    if (imageUrl() != null && !imageUrl().isEmpty()) {
-      Image img = new Image();
-      img.setAltText(name());
-      img.setUrl(imageUrl());
-      img.setTitle(name());
-      a.getElement().appendChild(img.getElement());
-    } else {
-      a.setText("(" + name() + ")");
-    }
-    return a;
-  }
-}
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
deleted file mode 100644
index 4b17068..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.rpc;
-
-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;
-
-/** A map of native JSON objects, keyed by a string. */
-public class NativeMap<T extends JavaScriptObject> extends JavaScriptObject {
-  public static <T extends JavaScriptObject> NativeMap<T> create() {
-    return createObject().cast();
-  }
-
-  /**
-   * Loop through the result map's entries and copy the key strings into the "name" property of the
-   * corresponding child object. This only runs on the top level map of the result, and requires the
-   * children to be JSON objects and not a JSON primitive (e.g. boolean or string).
-   */
-  public static <T extends JavaScriptObject, M extends NativeMap<T>>
-      AsyncCallback<M> copyKeysIntoChildren(AsyncCallback<M> callback) {
-    return copyKeysIntoChildren("name", callback);
-  }
-
-  /** Loop through the result map and set asProperty on the children. */
-  public static <T extends JavaScriptObject, M extends NativeMap<T>>
-      AsyncCallback<M> copyKeysIntoChildren(String asProperty, AsyncCallback<M> callback) {
-    return new TransformCallback<M, M>(callback) {
-      @Override
-      protected M transform(M result) {
-        result.copyKeysIntoChildren(asProperty);
-        return result;
-      }
-    };
-  }
-
-  protected NativeMap() {}
-
-  public final Set<String> keySet() {
-    return Natives.keys(this);
-  }
-
-  public final List<String> sortedKeys() {
-    Set<String> keys = keySet();
-    List<String> sorted = new ArrayList<>(keys);
-    Collections.sort(sorted);
-    return sorted;
-  }
-
-  public final native JsArray<T> values() /*-{
-    var s = this;
-    var v = [];
-    var i = 0;
-    for (var k in s) {
-      if (s.hasOwnProperty(k)) {
-        v[i++] = s[k];
-      }
-    }
-    return v;
-  }-*/;
-
-  public final int size() {
-    return keySet().size();
-  }
-
-  public final boolean isEmpty() {
-    return size() == 0;
-  }
-
-  public final boolean containsKey(String n) {
-    return get(n) != null;
-  }
-
-  public final native T get(String n) /*-{ return this[n]; }-*/;
-
-  public final native void put(String n, T v) /*-{ this[n] = v; }-*/;
-
-  public final native void copyKeysIntoChildren(String p) /*-{
-    var s = this;
-    for (var k in s) {
-      if (s.hasOwnProperty(k)) {
-        var c = s[k];
-        c[p] = k;
-      }
-    }
-  }-*/;
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java
deleted file mode 100644
index e0bca0e..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.rpc;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** Wraps a String that was returned from a JSON API. */
-public final class NativeString extends JavaScriptObject {
-  public static final JavaScriptObject TYPE = init();
-
-  // Used from core and plugins
-  private static native JavaScriptObject init() /*-{
-    if ($wnd.Gerrit === undefined || $wnd.Gerrit.JsonString === undefined) {
-      return function(s){this.s=s};
-    } else {
-      return $wnd.Gerrit.JsonString;
-    }
-  }-*/;
-
-  static NativeString wrap(String s) {
-    return wrap0(TYPE, s);
-  }
-
-  private static native NativeString wrap0(JavaScriptObject T, String s) /*-{ return new T(s) }-*/;
-
-  public native String asString() /*-{ return this.s; }-*/;
-
-  public static AsyncCallback<NativeString> unwrap(AsyncCallback<String> cb) {
-    return new AsyncCallback<NativeString>() {
-      @Override
-      public void onSuccess(NativeString result) {
-        cb.onSuccess(result != null ? result.asString() : null);
-      }
-
-      @Override
-      public void onFailure(Throwable caught) {
-        cb.onFailure(caught);
-      }
-    };
-  }
-
-  public static boolean is(JavaScriptObject o) {
-    return is(TYPE, o);
-  }
-
-  private static native boolean is(JavaScriptObject T, JavaScriptObject o)
-      /*-{ return o instanceof T }-*/ ;
-
-  protected NativeString() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
deleted file mode 100644
index 1421386..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.rpc;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.json.client.JSONObject;
-import java.util.AbstractList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-
-public class Natives {
-  /**
-   * Get the names of defined properties on the object. The returned set iterates in the native
-   * iteration order, which may match the source order.
-   */
-  public static Set<String> keys(JavaScriptObject obj) {
-    if (obj != null) {
-      return new JSONObject(obj).keySet();
-    }
-    return Collections.emptySet();
-  }
-
-  public static List<String> asList(JsArrayString arr) {
-    if (arr == null) {
-      return null;
-    }
-    return new AbstractList<String>() {
-      @Override
-      public String set(int index, String element) {
-        String old = arr.get(index);
-        arr.set(index, element);
-        return old;
-      }
-
-      @Override
-      public String get(int index) {
-        return arr.get(index);
-      }
-
-      @Override
-      public int size() {
-        return arr.length();
-      }
-    };
-  }
-
-  public static <T extends JavaScriptObject> List<T> asList(JsArray<T> arr) {
-    if (arr == null) {
-      return null;
-    }
-    return new AbstractList<T>() {
-      @Override
-      public T set(int index, T element) {
-        T old = arr.get(index);
-        arr.set(index, element);
-        return old;
-      }
-
-      @Override
-      public T get(int index) {
-        return arr.get(index);
-      }
-
-      @Override
-      public int size() {
-        return arr.length();
-      }
-    };
-  }
-
-  public static <T extends JavaScriptObject> JsArray<T> arrayOf(T element) {
-    JsArray<T> arr = JavaScriptObject.createArray().cast();
-    arr.push(element);
-    return arr;
-  }
-
-  public static JsArrayString arrayOf(Iterable<String> elements) {
-    JsArrayString arr = JavaScriptObject.createArray().cast();
-    for (String elem : elements) {
-      arr.push(elem);
-    }
-    return arr;
-  }
-
-  public static JsArrayString arrayOf(String element) {
-    JsArrayString arr = JavaScriptObject.createArray().cast();
-    arr.push(element);
-    return arr;
-  }
-
-  private Natives() {}
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/TransformCallback.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/TransformCallback.java
deleted file mode 100644
index 00e6bb1..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/TransformCallback.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.rpc;
-
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** Transforms a value and passes it on to another callback. */
-public abstract class TransformCallback<I, O> implements AsyncCallback<I> {
-  private final AsyncCallback<O> callback;
-
-  protected TransformCallback(AsyncCallback<O> callback) {
-    this.callback = callback;
-  }
-
-  @Override
-  public void onSuccess(I result) {
-    callback.onSuccess(transform(result));
-  }
-
-  @Override
-  public void onFailure(Throwable caught) {
-    callback.onFailure(caught);
-  }
-
-  protected abstract O transform(I result);
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/HighlightSuggestion.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/HighlightSuggestion.java
deleted file mode 100644
index 513f570..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/HighlightSuggestion.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.safehtml.shared.SafeHtmlBuilder;
-import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
-
-/** A {@code Suggestion} with highlights. */
-public class HighlightSuggestion implements Suggestion {
-
-  private final String keyword;
-  private final String value;
-
-  public HighlightSuggestion(String keyword, String value) {
-    this.keyword = keyword;
-    this.value = value;
-  }
-
-  @Override
-  public String getDisplayString() {
-    int start = 0;
-    int keyLen = keyword.length();
-    SafeHtmlBuilder builder = new SafeHtmlBuilder();
-    for (; ; ) {
-      int index = value.indexOf(keyword, start);
-      if (index == -1) {
-        builder.appendEscaped(value.substring(start));
-        break;
-      }
-      builder.appendEscaped(value.substring(start, index));
-      builder.appendHtmlConstant("<strong>");
-      start = index + keyLen;
-      builder.appendEscaped(value.substring(index, start));
-      builder.appendHtmlConstant("</strong>");
-    }
-    return builder.toSafeHtml().asString();
-  }
-
-  @Override
-  public String getReplacementString() {
-    return value;
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
deleted file mode 100644
index d66d6a6..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gwt.user.client.Timer;
-import com.google.gwt.user.client.ui.SuggestOracle;
-
-/**
- * Delegates to a slow SuggestOracle, such as a remote server API.
- *
- * <p>A response is only supplied to the UI if no requests were made after the oracle begin that
- * request.
- *
- * <p>When a request is made while the delegate is still processing a prior request all intermediate
- * requests are discarded and the most recent request is queued. The pending request's response is
- * discarded and the most recent request is started.
- */
-public class RemoteSuggestOracle extends SuggestOracle {
-  private final SuggestOracle oracle;
-  private Query query;
-  private String last;
-  private Timer requestRetentionTimer;
-  private boolean cancelOutstandingRequest;
-
-  private boolean serveSuggestions;
-
-  public RemoteSuggestOracle(SuggestOracle src) {
-    oracle = src;
-  }
-
-  public String getLast() {
-    return last;
-  }
-
-  @Override
-  public void requestSuggestions(Request req, Callback cb) {
-    if (!serveSuggestions) {
-      return;
-    }
-
-    // Use a timer for key stroke retention, such that we don't query the
-    // backend for each and every keystroke we receive.
-    if (requestRetentionTimer != null) {
-      requestRetentionTimer.cancel();
-    }
-    requestRetentionTimer =
-        new Timer() {
-          @Override
-          public void run() {
-            Query q = new Query(req, cb);
-            if (query == null) {
-              query = q;
-              q.start();
-            } else {
-              query = q;
-            }
-          }
-        };
-    requestRetentionTimer.schedule(200);
-  }
-
-  @Override
-  public void requestDefaultSuggestions(Request req, Callback cb) {
-    requestSuggestions(req, cb);
-  }
-
-  @Override
-  public boolean isDisplayStringHTML() {
-    return oracle.isDisplayStringHTML();
-  }
-
-  public void cancelOutstandingRequest() {
-    if (requestRetentionTimer != null) {
-      requestRetentionTimer.cancel();
-    }
-    if (query != null) {
-      cancelOutstandingRequest = true;
-    }
-  }
-
-  public void setServeSuggestions(boolean serveSuggestions) {
-    this.serveSuggestions = serveSuggestions;
-  }
-
-  private class Query implements Callback {
-    final Request request;
-    final Callback callback;
-
-    Query(Request req, Callback cb) {
-      request = req;
-      callback = cb;
-    }
-
-    void start() {
-      oracle.requestSuggestions(request, this);
-    }
-
-    @Override
-    public void onSuggestionsReady(Request req, Response res) {
-      if (cancelOutstandingRequest || !serveSuggestions) {
-        // If cancelOutstandingRequest() was called, we ignore this response
-        cancelOutstandingRequest = false;
-        query = null;
-      } else if (query == this) {
-        // No new request was started while this query was running.
-        // Propose this request's response as the suggestions.
-        query = null;
-        last = request.getQuery();
-        callback.onSuggestionsReady(req, res);
-      } else {
-        // Another query came in while this one was running. Skip
-        // this response and start the most recent query.
-        query.start();
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrow_undo.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrow_undo.png
deleted file mode 100644
index 6972c5e..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrow_undo.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cog.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cog.png
deleted file mode 100644
index 67de2c6..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cog.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/comment_draft.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/comment_draft.png
deleted file mode 100644
index 3408ddf..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/comment_draft.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cross.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cross.png
deleted file mode 100644
index 1514d51..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cross.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/diffy26.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/diffy26.png
deleted file mode 100644
index 88b59d8..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/diffy26.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/disk.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/disk.png
deleted file mode 100644
index 99d532e..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/disk.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/exclamation.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/exclamation.png
deleted file mode 100644
index c37bd06..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/exclamation.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/find.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/find.png
deleted file mode 100644
index 1547479..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/find.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goDown.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goDown.png
deleted file mode 100644
index 5d87e45..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goDown.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goNext.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goNext.png
deleted file mode 100644
index 872c197..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goNext.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goPrev.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goPrev.png
deleted file mode 100644
index d68f29b..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goPrev.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goUp.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goUp.png
deleted file mode 100644
index f75bed4..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goUp.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/help.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/help.png
deleted file mode 100644
index 5c87017..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/help.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lightbulb.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lightbulb.png
deleted file mode 100644
index d22fde8..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lightbulb.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/listAdd.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/listAdd.png
deleted file mode 100644
index 1aa7f09..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/listAdd.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lock.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lock.png
deleted file mode 100644
index 2ebc4f6..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lock.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/merge.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/merge.png
deleted file mode 100644
index 9c892db..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/merge.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/note_add.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/note_add.png
deleted file mode 100644
index abdad91..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/note_add.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_edit.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_edit.png
deleted file mode 100644
index 046811e..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_edit.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_white_put.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_white_put.png
deleted file mode 100644
index 884ffd6..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_white_put.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_down_gray.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_down_gray.png
deleted file mode 100644
index 7bdd8ea..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_down_gray.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_next_gray.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_next_gray.png
deleted file mode 100644
index 3049ef4..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_next_gray.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_up_gray.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_up_gray.png
deleted file mode 100644
index 966a25e..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_up_gray.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/sideBySideDiff.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/sideBySideDiff.png
deleted file mode 100755
index ee70080..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/sideBySideDiff.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star-open.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star-open.png
deleted file mode 100644
index edd577c..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star-open.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star.png
deleted file mode 100644
index b88c857..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue.png
deleted file mode 100644
index 9757fc6..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue_add.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue_add.png
deleted file mode 100644
index f135248..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue_add.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tick.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tick.png
deleted file mode 100644
index a9925a0..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tick.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/unifiedDiff.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/unifiedDiff.png
deleted file mode 100755
index ec5f97a..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/unifiedDiff.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_add.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_add.png
deleted file mode 100644
index deae99b..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_add.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_edit.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_edit.png
deleted file mode 100644
index c1974cd..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_edit.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java b/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
deleted file mode 100644
index 6915ba7..0000000
--- a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
+++ /dev/null
@@ -1,216 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import static com.google.gerrit.client.RelativeDateFormatter.DAY_IN_MILLIS;
-import static com.google.gerrit.client.RelativeDateFormatter.HOUR_IN_MILLIS;
-import static com.google.gerrit.client.RelativeDateFormatter.MINUTE_IN_MILLIS;
-import static com.google.gerrit.client.RelativeDateFormatter.SECOND_IN_MILLIS;
-import static com.google.gerrit.client.RelativeDateFormatter.YEAR_IN_MILLIS;
-import static org.junit.Assert.assertEquals;
-
-import java.util.Date;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-public class RelativeDateFormatterTest {
-
-  @BeforeClass
-  public static void setConstants() {
-    Constants c = new Constants();
-    RelativeDateFormatter.setConstants(c, c);
-  }
-
-  @AfterClass
-  public static void unsetConstants() {
-    RelativeDateFormatter.setConstants(null, null);
-  }
-
-  private static void assertFormat(long ageFromNow, long timeUnit, String expectedFormat) {
-    Date d = new Date(System.currentTimeMillis() - ageFromNow * timeUnit);
-    String s = RelativeDateFormatter.format(d);
-    assertEquals(expectedFormat, s);
-  }
-
-  @Test
-  public void future() {
-    assertFormat(-100, YEAR_IN_MILLIS, "in the future");
-    assertFormat(-1, SECOND_IN_MILLIS, "in the future");
-  }
-
-  @Test
-  public void formatSeconds() {
-    assertFormat(1, SECOND_IN_MILLIS, "1 second ago");
-    assertFormat(89, SECOND_IN_MILLIS, "89 seconds ago");
-  }
-
-  @Test
-  public void formatMinutes() {
-    assertFormat(90, SECOND_IN_MILLIS, "2 minutes ago");
-    assertFormat(3, MINUTE_IN_MILLIS, "3 minutes ago");
-    assertFormat(60, MINUTE_IN_MILLIS, "60 minutes ago");
-    assertFormat(89, MINUTE_IN_MILLIS, "89 minutes ago");
-  }
-
-  @Test
-  public void formatHours() {
-    assertFormat(90, MINUTE_IN_MILLIS, "2 hours ago");
-    assertFormat(149, MINUTE_IN_MILLIS, "2 hours ago");
-    assertFormat(35, HOUR_IN_MILLIS, "35 hours ago");
-  }
-
-  @Test
-  public void formatDays() {
-    assertFormat(36, HOUR_IN_MILLIS, "2 days ago");
-    assertFormat(13, DAY_IN_MILLIS, "13 days ago");
-  }
-
-  @Test
-  public void formatWeeks() {
-    assertFormat(14, DAY_IN_MILLIS, "2 weeks ago");
-    assertFormat(69, DAY_IN_MILLIS, "10 weeks ago");
-  }
-
-  @Test
-  public void formatMonths() {
-    assertFormat(70, DAY_IN_MILLIS, "2 months ago");
-    assertFormat(75, DAY_IN_MILLIS, "3 months ago");
-    assertFormat(364, DAY_IN_MILLIS, "12 months ago");
-  }
-
-  @Test
-  public void formatYearsMonths() {
-    assertFormat(366, DAY_IN_MILLIS, "1 year ago");
-    assertFormat(380, DAY_IN_MILLIS, "1 year, 1 month ago");
-    assertFormat(410, DAY_IN_MILLIS, "1 year, 2 months ago");
-    assertFormat(2, YEAR_IN_MILLIS, "2 years ago");
-    assertFormat(1824, DAY_IN_MILLIS, "5 years ago");
-    assertFormat(2 * 365 - 10, DAY_IN_MILLIS, "2 years ago");
-  }
-
-  @Test
-  public void formatYears() {
-    assertFormat(5, YEAR_IN_MILLIS, "5 years ago");
-    assertFormat(60, YEAR_IN_MILLIS, "60 years ago");
-  }
-
-  private static class Constants implements CommonConstants, CommonMessages {
-    @Override
-    public String inTheFuture() {
-      return "in the future";
-    }
-
-    @Override
-    public String month() {
-      return "month";
-    }
-
-    @Override
-    public String months() {
-      return "months";
-    }
-
-    @Override
-    public String year() {
-      return "year";
-    }
-
-    @Override
-    public String years() {
-      return "years";
-    }
-
-    @Override
-    public String oneSecondAgo() {
-      return "1 second ago";
-    }
-
-    @Override
-    public String oneMinuteAgo() {
-      return "1 minute ago";
-    }
-
-    @Override
-    public String oneHourAgo() {
-      return "1 hour ago";
-    }
-
-    @Override
-    public String oneDayAgo() {
-      return "1 day ago";
-    }
-
-    @Override
-    public String oneWeekAgo() {
-      return "1 week ago";
-    }
-
-    @Override
-    public String oneMonthAgo() {
-      return "1 month ago";
-    }
-
-    @Override
-    public String oneYearAgo() {
-      return "1 year ago";
-    }
-
-    @Override
-    public String secondsAgo(long seconds) {
-      return seconds + " seconds ago";
-    }
-
-    @Override
-    public String minutesAgo(long minutes) {
-      return minutes + " minutes ago";
-    }
-
-    @Override
-    public String hoursAgo(long hours) {
-      return hours + " hours ago";
-    }
-
-    @Override
-    public String daysAgo(long days) {
-      return days + " days ago";
-    }
-
-    @Override
-    public String weeksAgo(long weeks) {
-      return weeks + " weeks ago";
-    }
-
-    @Override
-    public String monthsAgo(long months) {
-      return months + " months ago";
-    }
-
-    @Override
-    public String yearsAgo(long years) {
-      return years + " years ago";
-    }
-
-    @Override
-    public String years0MonthsAgo(long years, String yearLabel) {
-      return years + " " + yearLabel + " ago";
-    }
-
-    @Override
-    public String yearsMonthsAgo(long years, String yearLabel, long months, String monthLabel) {
-      return years + " " + yearLabel + ", " + months + " " + monthLabel + " ago";
-    }
-  }
-}
diff --git a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/ui/HighlightSuggestionTest.java b/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/ui/HighlightSuggestionTest.java
deleted file mode 100644
index 9bf2d95..0000000
--- a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/ui/HighlightSuggestionTest.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import static org.junit.Assert.assertEquals;
-
-import org.junit.Test;
-
-public class HighlightSuggestionTest {
-
-  @Test
-  public void singleHighlight() throws Exception {
-    String keyword = "key";
-    String value = "somethingkeysomething";
-    HighlightSuggestion suggestion = new HighlightSuggestion(keyword, value);
-    assertEquals("something<strong>key</strong>something", suggestion.getDisplayString());
-    assertEquals(value, suggestion.getReplacementString());
-  }
-
-  @Test
-  public void noHighlight() throws Exception {
-    String keyword = "key";
-    String value = "something";
-    HighlightSuggestion suggestion = new HighlightSuggestion(keyword, value);
-    assertEquals(value, suggestion.getDisplayString());
-    assertEquals(value, suggestion.getReplacementString());
-  }
-
-  @Test
-  public void doubleHighlight() throws Exception {
-    String keyword = "key";
-    String value = "somethingkeysomethingkeysomething";
-    HighlightSuggestion suggestion = new HighlightSuggestion(keyword, value);
-    assertEquals(
-        "something<strong>key</strong>something<strong>key</strong>something",
-        suggestion.getDisplayString());
-    assertEquals(value, suggestion.getReplacementString());
-  }
-}
diff --git a/gerrit-gwtui/BUILD b/gerrit-gwtui/BUILD
deleted file mode 100644
index 1a772f1..0000000
--- a/gerrit-gwtui/BUILD
+++ /dev/null
@@ -1,41 +0,0 @@
-load(
-    "//tools/bzl:gwt.bzl",
-    "gen_ui_module",
-    "gwt_genrule",
-    "gwt_user_agent_permutations",
-)
-load("//tools/bzl:license.bzl", "license_test")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-gwt_genrule()
-
-gwt_genrule("_r")
-
-gen_ui_module(name = "ui_module")
-
-gen_ui_module(
-    name = "ui_module",
-    suffix = "_r",
-)
-
-gwt_user_agent_permutations()
-
-license_test(
-    name = "ui_module_license_test",
-    target = ":ui_module",
-)
-
-junit_tests(
-    name = "ui_tests",
-    srcs = glob(["src/test/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        ":ui_module",
-        "//java/com/google/gerrit/common:client",
-        "//java/com/google/gerrit/extensions:client",
-        "//lib:junit",
-        "//lib/gwt:dev",
-        "//lib/gwt:user",
-        "//lib/truth",
-    ],
-)
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/GerritGwtUI.gwt.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/GerritGwtUI.gwt.xml
deleted file mode 100644
index 9ac919b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/GerritGwtUI.gwt.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-<!--
- Copyright (C) 2008 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module rename-to="gerrit_ui">
-  <inherits name='com.google.gwt.editor.Editor'/>
-  <inherits name='com.google.gwt.user.User'/>
-  <inherits name='com.google.gwt.resources.Resources'/>
-  <inherits name='com.google.gwt.user.theme.chrome.Chrome'/>
-  <inherits name='com.google.gwtexpui.css.CSS'/>
-  <inherits name='com.google.gerrit.GerritGwtUICommon'/>
-  <inherits name='com.google.gerrit.UserAgent'/>
-  <inherits name='net.codemirror.CodeMirror'/>
-
-  <extend-property name='locale' values='en'/>
-  <set-property-fallback name='locale' value='en'/>
-  <set-property name='locale' value='en'/>
-  <set-configuration-property name='UiBinder.useSafeHtmlTemplates' value='true'/>
-  <set-configuration-property name='CssResource.style' value='stable'/>
-  <add-linker name='xsiframe'/>
-
-  <set-property name='gwt.logging.logLevel' value='SEVERE'/>
-
-  <!-- Disable GSS -->
-  <set-configuration-property name='CssResource.enableGss' value='false'/>
-
-  <entry-point class='com.google.gerrit.client.Gerrit'/>
-</module>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml
deleted file mode 100644
index 9644093..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml
+++ /dev/null
@@ -1,41 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <replace-with class="com.google.gerrit.client.api.PluginName.PluginNameMoz">
-    <when-type-is class="com.google.gerrit.client.api.PluginName" />
-    <when-property-is name="compiler.stackMode" value="native" />
-    <when-property-is name="user.agent" value="safari" />
-    <when-property-is name="user.agent" value="gecko1_8" />
-  </replace-with>
-
-  <replace-with class="com.google.gerrit.client.ui.FancyFlexTableImplIE8">
-    <when-type-is class="com.google.gerrit.client.ui.FancyFlexTableImpl" />
-    <any>
-      <when-property-is name="user.agent" value="ie8"/>
-    </any>
-  </replace-with>
-
-  <replace-with class="com.google.gerrit.client.Themer.ThemerIE">
-    <when-type-is class="com.google.gerrit.client.Themer" />
-    <any>
-      <when-property-is name="user.agent" value="ie8"/>
-      <when-property-is name="user.agent" value="ie9"/>
-      <when-property-is name="user.agent" value="ie10"/>
-      <when-property-is name="user.agent" value="ie11"/>
-      <when-property-is name="user.agent" value="edge"/>
-    </any>
-  </replace-with>
-</module>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
deleted file mode 100644
index 107d663..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.AccountInfo.AvatarInfo;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.event.dom.client.LoadEvent;
-import com.google.gwt.event.dom.client.LoadHandler;
-import com.google.gwt.event.dom.client.MouseOutEvent;
-import com.google.gwt.event.dom.client.MouseOutHandler;
-import com.google.gwt.event.dom.client.MouseOverEvent;
-import com.google.gwt.event.dom.client.MouseOverHandler;
-import com.google.gwt.user.client.Timer;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.UIObject;
-
-public class AvatarImage extends Image implements LoadHandler {
-  public AvatarImage() {
-    setVisible(false);
-    addLoadHandler(this);
-  }
-
-  /** A default sized avatar image. */
-  public AvatarImage(AccountInfo account) {
-    this(account, AccountInfo.AvatarInfo.DEFAULT_SIZE, true);
-  }
-
-  /**
-   * An avatar image for the given account using the requested size.
-   *
-   * @param account The account in which we are interested
-   * @param size A requested size. Note that the size can be ignored depending on the avatar
-   *     provider. A size <= 0 indicates to let the provider decide a default size.
-   * @param addPopup show avatar popup with user info on hovering over the avatar image
-   */
-  public AvatarImage(AccountInfo account, int size, boolean addPopup) {
-    addLoadHandler(this);
-    setAccount(account, size, addPopup);
-  }
-
-  public void setAccount(AccountInfo account, int size, boolean addPopup) {
-    if (account == null) {
-      setVisible(false);
-    } else if (isGerritServer(account)) {
-      setVisible(true);
-      setResource(Gerrit.RESOURCES.gerritAvatar26());
-    } else if (account.hasAvatarInfo()) {
-      setVisible(false);
-      AvatarInfo info = account.avatar(size);
-      if (info != null) {
-        setWidth(info.width() > 0 ? info.width() + "px" : "");
-        setHeight(info.height() > 0 ? info.height() + "px" : "");
-        setUrl(info.url());
-        popup(account, addPopup);
-      } else if (account.email() != null) {
-        loadAvatar(account, size, addPopup);
-      }
-    } else if (account.email() != null) {
-      loadAvatar(account, size, addPopup);
-    } else {
-      setVisible(false);
-    }
-  }
-
-  private void loadAvatar(AccountInfo account, int size, boolean addPopup) {
-    if (!Gerrit.info().plugin().hasAvatars()) {
-      setVisible(false);
-      return;
-    }
-
-    // TODO Kill /accounts/*/avatar URL.
-    String u = account.email();
-    if (Gerrit.isSignedIn() && u.equals(Gerrit.getUserAccount().email())) {
-      u = "self";
-    }
-    RestApi api = new RestApi("/accounts/").id(u).view("avatar");
-    if (size > 0) {
-      api.addParameter("s", size);
-      setSize("", size + "px");
-    }
-    setVisible(false);
-    setUrl(api.url());
-    popup(account, addPopup);
-  }
-
-  private void popup(AccountInfo account, boolean addPopup) {
-    if (addPopup) {
-      PopupHandler popupHandler = new PopupHandler(account, this);
-      addMouseOverHandler(popupHandler);
-      addMouseOutHandler(popupHandler);
-    }
-  }
-
-  @Override
-  public void onLoad(LoadEvent event) {
-    setVisible(true);
-  }
-
-  private static boolean isGerritServer(AccountInfo account) {
-    return account._accountId() == 0 && Util.C.messageNoAuthor().equals(account.name());
-  }
-
-  private static class PopupHandler implements MouseOverHandler, MouseOutHandler {
-    private final AccountInfo account;
-    private final UIObject target;
-
-    private UserPopupPanel popup;
-    private Timer showTimer;
-    private Timer hideTimer;
-
-    PopupHandler(AccountInfo account, UIObject target) {
-      this.account = account;
-      this.target = target;
-    }
-
-    private UserPopupPanel createPopupPanel(AccountInfo account) {
-      UserPopupPanel popup = new UserPopupPanel(account, false, false);
-      popup.addDomHandler(
-          new MouseOverHandler() {
-            @Override
-            public void onMouseOver(MouseOverEvent event) {
-              scheduleShow();
-            }
-          },
-          MouseOverEvent.getType());
-      popup.addDomHandler(
-          new MouseOutHandler() {
-            @Override
-            public void onMouseOut(MouseOutEvent event) {
-              scheduleHide();
-            }
-          },
-          MouseOutEvent.getType());
-      return popup;
-    }
-
-    @Override
-    public void onMouseOver(MouseOverEvent event) {
-      scheduleShow();
-    }
-
-    @Override
-    public void onMouseOut(MouseOutEvent event) {
-      scheduleHide();
-    }
-
-    private void scheduleShow() {
-      if (hideTimer != null) {
-        hideTimer.cancel();
-        hideTimer = null;
-      }
-      if ((popup != null && popup.isShowing() && popup.isVisible()) || showTimer != null) {
-        return;
-      }
-      showTimer =
-          new Timer() {
-            @Override
-            public void run() {
-              if (popup == null) {
-                popup = createPopupPanel(account);
-              }
-              if (!popup.isShowing() || !popup.isVisible()) {
-                popup.showRelativeTo(target);
-              }
-            }
-          };
-      showTimer.schedule(600);
-    }
-
-    private void scheduleHide() {
-      if (showTimer != null) {
-        showTimer.cancel();
-        showTimer = null;
-      }
-      if (popup == null || !popup.isShowing() || !popup.isVisible() || hideTimer != null) {
-        return;
-      }
-      hideTimer =
-          new Timer() {
-            @Override
-            public void run() {
-              popup.hide();
-            }
-          };
-      hideTimer.schedule(50);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationCallback.java
deleted file mode 100644
index cc30873..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationCallback.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-/**
- * Interface that a caller must implement to react on the result of a {@link ConfirmationDialog}.
- */
-public abstract class ConfirmationCallback {
-
-  /**
-   * Called when the {@link ConfirmationDialog} is finished with OK. To be overwritten by
-   * subclasses.
-   */
-  public abstract void onOk();
-
-  /**
-   * Called when the {@link ConfirmationDialog} is finished with Cancel. To be overwritten by
-   * subclasses.
-   */
-  public void onCancel() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java
deleted file mode 100644
index 438df34..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.user.client.AutoCenterDialogBox;
-
-public class ConfirmationDialog extends AutoCenterDialogBox {
-
-  private Button cancelButton;
-  private Button okButton;
-
-  public ConfirmationDialog(
-      final String dialogTitle, SafeHtml message, ConfirmationCallback callback) {
-    super(/* auto hide */ false, /* modal */ true);
-    setGlassEnabled(true);
-    setText(dialogTitle);
-
-    final FlowPanel buttons = new FlowPanel();
-
-    okButton = new Button();
-    okButton.setText(Gerrit.C.confirmationDialogOk());
-    okButton.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            hide();
-            callback.onOk();
-          }
-        });
-    buttons.add(okButton);
-
-    cancelButton = new Button();
-    cancelButton.getElement().getStyle().setProperty("marginLeft", "300px");
-    cancelButton.setText(Gerrit.C.confirmationDialogCancel());
-    cancelButton.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            hide();
-            callback.onCancel();
-          }
-        });
-    buttons.add(cancelButton);
-
-    final FlowPanel center = new FlowPanel();
-    final Widget msgWidget = message.toBlockWidget();
-    center.add(msgWidget);
-    center.add(buttons);
-    add(center);
-
-    msgWidget.setWidth("400px");
-
-    setWidget(center);
-  }
-
-  @Override
-  public void center() {
-    super.center();
-    GlobalKey.dialog(this);
-    cancelButton.setFocus(true);
-  }
-
-  public void setCancelVisible(boolean visible) {
-    cancelButton.setVisible(visible);
-    if (!visible) {
-      okButton.setFocus(true);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffObject.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffObject.java
deleted file mode 100644
index 21ced4c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffObject.java
+++ /dev/null
@@ -1,183 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-
-/**
- * Represent an object that can be diffed. This can be either a regular patch set, the base of a
- * patch set, the parent of a merge, the auto-merge of a merge or an edit patch set.
- */
-public class DiffObject {
-  public static final String AUTO_MERGE = "AutoMerge";
-
-  /**
-   * Parses a string that represents a diff object.
-   *
-   * <p>The following string representations are supported:
-   *
-   * <ul>
-   *   <li>a positive integer: represents a patch set
-   *   <li>a negative integer: represents a parent of a merge patch set
-   *   <li>'0': represents the edit patch set
-   *   <li>empty string or null: represents the parent of a 1-parent patch set, also called base
-   *   <li>'AutoMerge': represents the auto-merge of a merge patch set
-   * </ul>
-   *
-   * @param changeId the ID of the change to which the diff object belongs
-   * @param str the string representation of the diff object
-   * @return the parsed diff object, {@code null} if str cannot be parsed as diff object
-   */
-  public static DiffObject parse(Change.Id changeId, String str) {
-    if (str == null || str.isEmpty()) {
-      return new DiffObject(false);
-    }
-
-    if (AUTO_MERGE.equals(str)) {
-      return new DiffObject(true);
-    }
-
-    return new DiffObject(Dispatcher.toPsId(changeId, str));
-  }
-
-  /** Create a DiffObject that represents the parent of a 1-parent patch set. */
-  public static DiffObject base() {
-    return new DiffObject(false);
-  }
-
-  /** Create a DiffObject that represents the auto-merge for a merge patch set. */
-  public static DiffObject autoMerge() {
-    return new DiffObject(true);
-  }
-
-  /** Create a DiffObject that represents a patch set. */
-  public static DiffObject patchSet(PatchSet.Id psId) {
-    return new DiffObject(psId);
-  }
-
-  private final PatchSet.Id psId;
-  private final boolean autoMerge;
-
-  private DiffObject(PatchSet.Id psId) {
-    this.psId = psId;
-    this.autoMerge = false;
-  }
-
-  private DiffObject(boolean autoMerge) {
-    this.psId = null;
-    this.autoMerge = autoMerge;
-  }
-
-  public boolean isBase() {
-    return psId == null && !autoMerge;
-  }
-
-  public boolean isAutoMerge() {
-    return psId == null && autoMerge;
-  }
-
-  public boolean isBaseOrAutoMerge() {
-    return psId == null;
-  }
-
-  public boolean isPatchSet() {
-    return psId != null && psId.get() > 0;
-  }
-
-  public boolean isParent() {
-    return psId != null && psId.get() < 0;
-  }
-
-  public boolean isEdit() {
-    return psId != null && psId.get() == 0;
-  }
-
-  /**
-   * Returns the DiffObject as PatchSet.Id.
-   *
-   * @return PatchSet.Id with an id > 0 for a regular patch set; PatchSet.Id with an id < 0 for a
-   *     parent of a merge; PatchSet.Id with id == 0 for an edit patch set; {@code null} for the
-   *     base of a 1-parent patch set and for the auto-merge of a merge patch set
-   */
-  public PatchSet.Id asPatchSetId() {
-    return psId;
-  }
-
-  /**
-   * Returns the parent number for a parent of a merge.
-   *
-   * @return 1-based parent number, 0 if this DiffObject is not a parent of a merge
-   */
-  public int getParentNum() {
-    if (!isParent()) {
-      return 0;
-    }
-
-    return -psId.get();
-  }
-
-  /**
-   * Returns a string representation of this DiffObject that can be used in URLs.
-   *
-   * <p>The following string representations are returned:
-   *
-   * <ul>
-   *   <li>a positive integer for a patch set
-   *   <li>a negative integer for a parent of a merge patch set
-   *   <li>'0' for the edit patch set
-   *   <li>{@code null} for the parent of a 1-parent patch set, also called base
-   *   <li>'AutoMerge' for the auto-merge of a merge patch set
-   * </ul>
-   *
-   * @return string representation of this DiffObject
-   */
-  public String asString() {
-    if (autoMerge) {
-      if (Gerrit.getUserPreferences().defaultBaseForMerges() != DefaultBase.AUTO_MERGE) {
-        return AUTO_MERGE;
-      }
-      return null;
-    }
-
-    if (psId != null) {
-      return psId.getId();
-    }
-
-    return null;
-  }
-
-  @Override
-  public String toString() {
-    if (isPatchSet()) {
-      return "Patch Set " + psId.getId();
-    }
-
-    if (isParent()) {
-      return "Parent " + psId.getId();
-    }
-
-    if (isEdit()) {
-      return "Edit Patch Set";
-    }
-
-    if (isAutoMerge()) {
-      return "Auto Merge";
-    }
-
-    return "Base";
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffWebLinkInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffWebLinkInfo.java
deleted file mode 100644
index fe7016f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffWebLinkInfo.java
+++ /dev/null
@@ -1,27 +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.client;
-
-import com.google.gerrit.client.info.WebLinkInfo;
-
-public class DiffWebLinkInfo extends WebLinkInfo {
-  public final native boolean showOnSideBySideDiffView()
-      /*-{ return this.show_on_side_by_side_diff_view || false; }-*/ ;
-
-  public final native boolean showOnUnifiedDiffView()
-      /*-{ return this.show_on_unified_diff_view || false; }-*/ ;
-
-  protected DiffWebLinkInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
deleted file mode 100644
index c9346f4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
+++ /dev/null
@@ -1,879 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import static com.google.gerrit.common.PageLinks.ADMIN_CREATE_GROUP;
-import static com.google.gerrit.common.PageLinks.ADMIN_CREATE_PROJECT;
-import static com.google.gerrit.common.PageLinks.ADMIN_GROUPS;
-import static com.google.gerrit.common.PageLinks.ADMIN_PLUGINS;
-import static com.google.gerrit.common.PageLinks.ADMIN_PROJECTS;
-import static com.google.gerrit.common.PageLinks.DASHBOARDS;
-import static com.google.gerrit.common.PageLinks.MINE;
-import static com.google.gerrit.common.PageLinks.MY_GROUPS;
-import static com.google.gerrit.common.PageLinks.PROJECTS;
-import static com.google.gerrit.common.PageLinks.QUERY;
-import static com.google.gerrit.common.PageLinks.REGISTER;
-import static com.google.gerrit.common.PageLinks.SETTINGS;
-import static com.google.gerrit.common.PageLinks.SETTINGS_AGREEMENTS;
-import static com.google.gerrit.common.PageLinks.SETTINGS_CONTACT;
-import static com.google.gerrit.common.PageLinks.SETTINGS_DIFF_PREFERENCES;
-import static com.google.gerrit.common.PageLinks.SETTINGS_EDIT_PREFERENCES;
-import static com.google.gerrit.common.PageLinks.SETTINGS_EXTENSION;
-import static com.google.gerrit.common.PageLinks.SETTINGS_GPGKEYS;
-import static com.google.gerrit.common.PageLinks.SETTINGS_HTTP_PASSWORD;
-import static com.google.gerrit.common.PageLinks.SETTINGS_MYGROUPS;
-import static com.google.gerrit.common.PageLinks.SETTINGS_NEW_AGREEMENT;
-import static com.google.gerrit.common.PageLinks.SETTINGS_OAUTH_TOKEN;
-import static com.google.gerrit.common.PageLinks.SETTINGS_PREFERENCES;
-import static com.google.gerrit.common.PageLinks.SETTINGS_PROJECTS;
-import static com.google.gerrit.common.PageLinks.SETTINGS_SSHKEYS;
-import static com.google.gerrit.common.PageLinks.SETTINGS_WEBIDENT;
-
-import com.google.gerrit.client.account.MyAgreementsScreen;
-import com.google.gerrit.client.account.MyContactInformationScreen;
-import com.google.gerrit.client.account.MyDiffPreferencesScreen;
-import com.google.gerrit.client.account.MyEditPreferencesScreen;
-import com.google.gerrit.client.account.MyGpgKeysScreen;
-import com.google.gerrit.client.account.MyGroupsScreen;
-import com.google.gerrit.client.account.MyIdentitiesScreen;
-import com.google.gerrit.client.account.MyOAuthTokenScreen;
-import com.google.gerrit.client.account.MyPasswordScreen;
-import com.google.gerrit.client.account.MyPreferencesScreen;
-import com.google.gerrit.client.account.MyProfileScreen;
-import com.google.gerrit.client.account.MySshKeysScreen;
-import com.google.gerrit.client.account.MyWatchedProjectsScreen;
-import com.google.gerrit.client.account.NewAgreementScreen;
-import com.google.gerrit.client.account.RegisterScreen;
-import com.google.gerrit.client.account.ValidateEmailScreen;
-import com.google.gerrit.client.admin.AccountGroupAuditLogScreen;
-import com.google.gerrit.client.admin.AccountGroupInfoScreen;
-import com.google.gerrit.client.admin.AccountGroupMembersScreen;
-import com.google.gerrit.client.admin.AccountGroupScreen;
-import com.google.gerrit.client.admin.CreateGroupScreen;
-import com.google.gerrit.client.admin.CreateProjectScreen;
-import com.google.gerrit.client.admin.GroupListScreen;
-import com.google.gerrit.client.admin.PluginListScreen;
-import com.google.gerrit.client.admin.ProjectAccessScreen;
-import com.google.gerrit.client.admin.ProjectBranchesScreen;
-import com.google.gerrit.client.admin.ProjectDashboardsScreen;
-import com.google.gerrit.client.admin.ProjectInfoScreen;
-import com.google.gerrit.client.admin.ProjectListScreen;
-import com.google.gerrit.client.admin.ProjectScreen;
-import com.google.gerrit.client.admin.ProjectTagsScreen;
-import com.google.gerrit.client.api.ExtensionScreen;
-import com.google.gerrit.client.api.ExtensionSettingsScreen;
-import com.google.gerrit.client.change.ChangeScreen;
-import com.google.gerrit.client.change.FileTable;
-import com.google.gerrit.client.change.ProjectChangeId;
-import com.google.gerrit.client.changes.AccountDashboardScreen;
-import com.google.gerrit.client.changes.CustomDashboardScreen;
-import com.google.gerrit.client.changes.ProjectDashboardScreen;
-import com.google.gerrit.client.changes.QueryScreen;
-import com.google.gerrit.client.dashboards.DashboardInfo;
-import com.google.gerrit.client.dashboards.DashboardList;
-import com.google.gerrit.client.diff.DisplaySide;
-import com.google.gerrit.client.diff.SideBySide;
-import com.google.gerrit.client.diff.Unified;
-import com.google.gerrit.client.documentation.DocScreen;
-import com.google.gerrit.client.editor.EditScreen;
-import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.RunAsyncCallback;
-import com.google.gwt.http.client.URL;
-import com.google.gwtexpui.user.client.UserAgent;
-import com.google.gwtorm.client.KeyUtil;
-
-public class Dispatcher {
-  public static String toPatch(
-      @Nullable Project.NameKey project,
-      DiffObject diffBase,
-      PatchSet.Id revision,
-      String fileName) {
-    return toPatch("", project, diffBase, revision, fileName, null, 0);
-  }
-
-  public static String toPatch(
-      @Nullable Project.NameKey project,
-      DiffObject diffBase,
-      PatchSet.Id revision,
-      String fileName,
-      DisplaySide side,
-      int line) {
-    return toPatch("", project, diffBase, revision, fileName, side, line);
-  }
-
-  public static String toSideBySide(
-      @Nullable Project.NameKey project,
-      DiffObject diffBase,
-      PatchSet.Id revision,
-      String fileName) {
-    return toPatch("sidebyside", project, diffBase, revision, fileName, null, 0);
-  }
-
-  public static String toUnified(
-      @Nullable Project.NameKey project,
-      DiffObject diffBase,
-      PatchSet.Id revision,
-      String fileName) {
-    return toPatch("unified", project, diffBase, revision, fileName, null, 0);
-  }
-
-  public static String toPatch(
-      @Nullable Project.NameKey project, String type, DiffObject diffBase, Patch.Key id) {
-    return toPatch(type, project, diffBase, id.getParentKey(), id.get(), null, 0);
-  }
-
-  public static String toEditScreen(
-      @Nullable Project.NameKey project, PatchSet.Id revision, String fileName) {
-    return toEditScreen(project, revision, fileName, 0);
-  }
-
-  public static String toEditScreen(
-      @Nullable Project.NameKey project, PatchSet.Id revision, String fileName, int line) {
-    return toPatch("edit", project, DiffObject.base(), revision, fileName, null, line);
-  }
-
-  private static String toPatch(
-      String type,
-      @Nullable Project.NameKey project,
-      DiffObject diffBase,
-      PatchSet.Id revision,
-      String fileName,
-      DisplaySide side,
-      int line) {
-    Change.Id c = revision.getParentKey();
-    StringBuilder p = new StringBuilder(PageLinks.toChange(project, c));
-    if (diffBase != null && diffBase.asString() != null) {
-      p.append(diffBase.asString()).append("..");
-    }
-    p.append(revision.getId()).append("/").append(KeyUtil.encode(fileName));
-    if (type != null && !type.isEmpty() && (!"sidebyside".equals(type) || preferUnified())) {
-      p.append(",").append(type);
-    }
-    if (side == DisplaySide.A && line > 0) {
-      p.append("@a").append(line);
-    } else if (line > 0) {
-      p.append("@").append(line);
-    }
-    return p.toString();
-  }
-
-  public static String toGroup(AccountGroup.Id id) {
-    return ADMIN_GROUPS + id.toString();
-  }
-
-  public static String toGroup(AccountGroup.Id id, String panel) {
-    return ADMIN_GROUPS + id.toString() + "," + panel;
-  }
-
-  public static String toGroup(AccountGroup.UUID uuid) {
-    return PageLinks.toGroup(uuid);
-  }
-
-  public static String toGroup(AccountGroup.UUID uuid, String panel) {
-    return toGroup(uuid) + "," + panel;
-  }
-
-  public static String toProject(Project.NameKey n) {
-    return toProjectAdmin(n, ProjectScreen.getSavedPanel());
-  }
-
-  public static String toProjectAdmin(Project.NameKey n, String panel) {
-    if (panel == null || ProjectScreen.INFO.equals(panel)) {
-      return ADMIN_PROJECTS + n.toString();
-    }
-    return ADMIN_PROJECTS + n.toString() + "," + panel;
-  }
-
-  static final String RELOAD_UI = "/reload-ui/";
-  private static boolean wasStartedByReloadUI;
-
-  void display(String token) {
-    assert token != null;
-    try {
-      try {
-        if (matchPrefix(RELOAD_UI, token)) {
-          wasStartedByReloadUI = true;
-          token = skip(token);
-        }
-        select(token);
-      } finally {
-        wasStartedByReloadUI = false;
-      }
-    } catch (RuntimeException err) {
-      GWT.log("Error parsing history token: " + token, err);
-      Gerrit.display(token, new NotFoundScreen());
-    }
-  }
-
-  private static void select(String token) {
-    token = Gerrit.getUrlAliasMatcher().replace(token);
-
-    if (matchPrefix(QUERY, token)) {
-      query(token);
-
-    } else if (matchPrefix("/Documentation/", token)) {
-      docSearch(token);
-
-    } else if (matchPrefix("/c/", token)) {
-      change(token);
-
-    } else if (matchPrefix("/x/", token)) {
-      extension(token);
-
-    } else if (matchExact(MINE, token)) {
-      String defaultScreenToken = Gerrit.getDefaultScreenToken();
-      if (defaultScreenToken != null && !MINE.equals(defaultScreenToken)) {
-        select(defaultScreenToken);
-      } else {
-        Gerrit.display(token, mine());
-      }
-
-    } else if (matchPrefix("/dashboard/", token)) {
-      dashboard(token);
-
-    } else if (matchPrefix(PROJECTS, token)) {
-      projects(token);
-
-    } else if (matchExact(SETTINGS, token)
-        || matchPrefix("/settings/", token)
-        || matchExact(MY_GROUPS, token)
-        || matchExact("register", token)
-        || matchExact(REGISTER, token)
-        || matchPrefix("/register/", token)
-        || matchPrefix("/VE/", token)
-        || matchPrefix("VE,", token)
-        || matchPrefix("/SignInFailure,", token)) {
-      settings(token);
-
-    } else if (matchPrefix("/admin/", token)) {
-      admin(token);
-
-    } else {
-      Gerrit.display(token, new NotFoundScreen());
-    }
-  }
-
-  private static void query(String token) {
-    String s = skip(token);
-    int c = s.indexOf(',');
-    Screen screen;
-    if (c >= 0) {
-      String prefix = s.substring(0, c);
-      if (s.substring(c).equals(",n,z")) {
-        // Respect legacy token with max sortkey.
-        screen = new QueryScreen(prefix, 0);
-      } else {
-        screen = new QueryScreen(prefix, Integer.parseInt(s.substring(c + 1)));
-      }
-    } else {
-      screen = new QueryScreen(s, 0);
-    }
-    Gerrit.display(token, screen);
-  }
-
-  private static Screen mine() {
-    if (Gerrit.isSignedIn()) {
-      return new AccountDashboardScreen(Gerrit.getUserAccount()._accountId());
-    }
-    Screen r = new AccountDashboardScreen(null);
-    r.setRequiresSignIn(true);
-    return r;
-  }
-
-  private static void dashboard(String token) {
-    String rest = skip(token);
-    if (rest.matches("[0-9]+")) {
-      int accountId = Integer.parseInt(rest);
-      Gerrit.display(token, new AccountDashboardScreen(accountId));
-      return;
-    }
-
-    if (rest.equals("self")) {
-      if (Gerrit.isSignedIn()) {
-        Gerrit.display(token, new AccountDashboardScreen(Gerrit.getUserAccount()._accountId()));
-      } else {
-        Screen s = new AccountDashboardScreen(null);
-        s.setRequiresSignIn(true);
-        Gerrit.display(token, s);
-      }
-      return;
-    }
-
-    if (rest.startsWith("?")) {
-      Gerrit.display(token, new CustomDashboardScreen(rest.substring(1)));
-      return;
-    }
-
-    Gerrit.display(token, new NotFoundScreen());
-  }
-
-  private static void projects(String token) {
-    String rest = skip(token);
-    int c = rest.indexOf(DASHBOARDS);
-    if (0 <= c) {
-      final String project = URL.decodePathSegment(rest.substring(0, c));
-      rest = rest.substring(c);
-      if (matchPrefix(DASHBOARDS, rest)) {
-        final String dashboardId = skip(rest);
-        GerritCallback<DashboardInfo> cb =
-            new GerritCallback<DashboardInfo>() {
-              @Override
-              public void onSuccess(DashboardInfo result) {
-                if (matchPrefix("/dashboard/", result.url())) {
-                  String params = skip(result.url()).substring(1);
-                  ProjectDashboardScreen dash =
-                      new ProjectDashboardScreen(new Project.NameKey(project), params);
-                  Gerrit.display(token, dash);
-                }
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                if ("default".equals(dashboardId) && RestApi.isNotFound(caught)) {
-                  Gerrit.display(
-                      PageLinks.toChangeQuery(
-                          PageLinks.projectQuery(new Project.NameKey(project))));
-                } else {
-                  super.onFailure(caught);
-                }
-              }
-            };
-        if ("default".equals(dashboardId)) {
-          DashboardList.getDefault(new Project.NameKey(project), cb);
-          return;
-        }
-        c = dashboardId.indexOf(":");
-        if (0 <= c) {
-          final String ref = URL.decodeQueryString(dashboardId.substring(0, c));
-          final String path = URL.decodeQueryString(dashboardId.substring(c + 1));
-          DashboardList.get(new Project.NameKey(project), ref + ":" + path, cb);
-          return;
-        }
-      }
-    }
-
-    Gerrit.display(token, new NotFoundScreen());
-  }
-
-  private static void change(String token) {
-    String rest = skip(token);
-    int c = rest.lastIndexOf(',');
-    String panel = null;
-    if (0 <= c) {
-      panel = rest.substring(c + 1);
-      rest = rest.substring(0, c);
-      int at = panel.lastIndexOf('@');
-      if (at > 0) {
-        rest += panel.substring(at);
-        panel = panel.substring(0, at);
-      }
-    }
-
-    ProjectChangeId id = ProjectChangeId.create(rest);
-    rest = rest.length() > id.identifierLength() ? rest.substring(id.identifierLength() + 1) : "";
-
-    if (rest.isEmpty()) {
-      FileTable.Mode mode = FileTable.Mode.REVIEW;
-      if (panel != null && (panel.equals("edit") || panel.startsWith("edit/"))) {
-        mode = FileTable.Mode.EDIT;
-        panel = null;
-      }
-      Gerrit.display(
-          token,
-          panel == null
-              ? new ChangeScreen(
-                  id.getProject(), id.getChangeId(), DiffObject.base(), null, false, mode)
-              : new NotFoundScreen());
-      return;
-    }
-
-    String psIdStr;
-    int s = rest.indexOf('/');
-    if (0 <= s) {
-      psIdStr = rest.substring(0, s);
-      rest = rest.substring(s + 1);
-    } else {
-      psIdStr = rest;
-      rest = "";
-    }
-
-    DiffObject base = DiffObject.base();
-    PatchSet.Id ps;
-    int dotdot = psIdStr.indexOf("..");
-    if (1 <= dotdot) {
-      base = DiffObject.parse(id.getChangeId(), psIdStr.substring(0, dotdot));
-      if (base == null) {
-        Gerrit.display(token, new NotFoundScreen());
-      }
-      psIdStr = psIdStr.substring(dotdot + 2);
-    }
-    ps = toPsId(id.getChangeId(), psIdStr);
-
-    if (!rest.isEmpty()) {
-      DisplaySide side = DisplaySide.B;
-      int line = 0;
-      int at = rest.lastIndexOf('@');
-      if (at > 0) {
-        String l = rest.substring(at + 1);
-        if (l.startsWith("a")) {
-          side = DisplaySide.A;
-          l = l.substring(1);
-        }
-        line = Integer.parseInt(l);
-        rest = rest.substring(0, at);
-      }
-      Patch.Key p = new Patch.Key(ps, KeyUtil.decode(rest));
-      patch(token, id.getProject(), base, p, side, line, panel);
-    } else {
-      if (panel == null) {
-        Gerrit.display(
-            token,
-            new ChangeScreen(
-                id.getProject(),
-                id.getChangeId(),
-                base,
-                String.valueOf(ps.get()),
-                false,
-                FileTable.Mode.REVIEW));
-      } else {
-        Gerrit.display(token, new NotFoundScreen());
-      }
-    }
-  }
-
-  public static PatchSet.Id toPsId(Change.Id id, String psIdStr) {
-    return new PatchSet.Id(id, psIdStr.equals("edit") ? 0 : Integer.parseInt(psIdStr));
-  }
-
-  private static void extension(String token) {
-    ExtensionScreen view = new ExtensionScreen(skip(token));
-    if (view.isFound()) {
-      Gerrit.display(token, view);
-    } else {
-      Gerrit.display(token, new NotFoundScreen());
-    }
-  }
-
-  private static void patch(
-      String token,
-      @Nullable Project.NameKey project,
-      DiffObject base,
-      Patch.Key id,
-      DisplaySide side,
-      int line,
-      String panelType) {
-    String panel = panelType;
-    if (panel == null) {
-      int c = token.lastIndexOf(',');
-      panel = 0 <= c ? token.substring(c + 1) : "";
-    }
-
-    if ("".equals(panel) || /* DEPRECATED URL */ "cm".equals(panel)) {
-      if (preferUnified()) {
-        unified(token, project, base, id, side, line);
-      } else {
-        codemirror(token, base, project, id, side, line);
-      }
-    } else if ("sidebyside".equals(panel)) {
-      codemirror(token, base, project, id, side, line);
-    } else if ("unified".equals(panel)) {
-      unified(token, project, base, id, side, line);
-    } else if ("edit".equals(panel)) {
-      if (!Patch.isMagic(id.get()) || Patch.COMMIT_MSG.equals(id.get())) {
-        codemirrorForEdit(token, project, id, line);
-      } else {
-        Gerrit.display(token, new NotFoundScreen());
-      }
-    } else {
-      Gerrit.display(token, new NotFoundScreen());
-    }
-  }
-
-  private static boolean preferUnified() {
-    return DiffView.UNIFIED_DIFF.equals(Gerrit.getUserPreferences().diffView())
-        || (UserAgent.isPortrait() && UserAgent.isMobile());
-  }
-
-  private static void unified(
-      final String token,
-      final Project.NameKey project,
-      final DiffObject base,
-      final Patch.Key id,
-      final DisplaySide side,
-      final int line) {
-    GWT.runAsync(
-        new AsyncSplit(token) {
-          @Override
-          public void onSuccess() {
-            Gerrit.display(
-                token,
-                new Unified(
-                    project, base, DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
-          }
-        });
-  }
-
-  private static void codemirror(
-      final String token,
-      final DiffObject base,
-      @Nullable final Project.NameKey project,
-      final Patch.Key id,
-      final DisplaySide side,
-      final int line) {
-    GWT.runAsync(
-        new AsyncSplit(token) {
-          @Override
-          public void onSuccess() {
-            Gerrit.display(
-                token,
-                new SideBySide(
-                    project, base, DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
-          }
-        });
-  }
-
-  private static void codemirrorForEdit(
-      final String token,
-      @Nullable final Project.NameKey project,
-      final Patch.Key id,
-      final int line) {
-    GWT.runAsync(
-        new AsyncSplit(token) {
-          @Override
-          public void onSuccess() {
-            Gerrit.display(token, new EditScreen(project, id, line));
-          }
-        });
-  }
-
-  private static void settings(String token) {
-    GWT.runAsync(
-        new AsyncSplit(token) {
-          @Override
-          public void onSuccess() {
-            Gerrit.display(token, select());
-          }
-
-          private Screen select() {
-            if (matchExact(SETTINGS, token)) {
-              return new MyProfileScreen();
-            }
-
-            if (matchExact(SETTINGS_PREFERENCES, token)) {
-              return new MyPreferencesScreen();
-            }
-
-            if (matchExact(SETTINGS_DIFF_PREFERENCES, token)) {
-              return new MyDiffPreferencesScreen();
-            }
-
-            if (matchExact(SETTINGS_EDIT_PREFERENCES, token)) {
-              return new MyEditPreferencesScreen();
-            }
-
-            if (matchExact(SETTINGS_PROJECTS, token)) {
-              return new MyWatchedProjectsScreen();
-            }
-
-            if (matchExact(SETTINGS_CONTACT, token)) {
-              return new MyContactInformationScreen();
-            }
-
-            if (matchExact(SETTINGS_SSHKEYS, token)) {
-              return new MySshKeysScreen();
-            }
-
-            if (matchExact(SETTINGS_GPGKEYS, token) && Gerrit.info().gerrit().editGpgKeys()) {
-              return new MyGpgKeysScreen();
-            }
-
-            if (matchExact(SETTINGS_WEBIDENT, token)) {
-              return new MyIdentitiesScreen();
-            }
-
-            if (matchExact(SETTINGS_HTTP_PASSWORD, token)) {
-              return new MyPasswordScreen();
-            }
-
-            if (matchExact(SETTINGS_OAUTH_TOKEN, token) && Gerrit.info().auth().isOAuth()) {
-              return new MyOAuthTokenScreen();
-            }
-
-            if (matchExact(MY_GROUPS, token) || matchExact(SETTINGS_MYGROUPS, token)) {
-              return new MyGroupsScreen();
-            }
-
-            if (matchExact(SETTINGS_AGREEMENTS, token)
-                && Gerrit.info().auth().useContributorAgreements()) {
-              return new MyAgreementsScreen();
-            }
-
-            if (matchExact(REGISTER, token)
-                || matchExact("/register/", token)
-                || matchExact("register", token)) {
-              return new RegisterScreen(MINE);
-            } else if (matchPrefix("/register/", token)) {
-              return new RegisterScreen("/" + skip(token));
-            }
-
-            if (matchPrefix("/VE/", token) || matchPrefix("VE,", token)) {
-              return new ValidateEmailScreen(skip(token));
-            }
-
-            if (matchExact(SETTINGS_NEW_AGREEMENT, token)) {
-              return new NewAgreementScreen();
-            }
-
-            if (matchPrefix(SETTINGS_NEW_AGREEMENT + "/", token)) {
-              return new NewAgreementScreen(skip(token));
-            }
-
-            if (matchPrefix(SETTINGS_EXTENSION, token)) {
-              ExtensionSettingsScreen view = new ExtensionSettingsScreen(skip(token));
-              if (view.isFound()) {
-                return view;
-              }
-              return new NotFoundScreen();
-            }
-
-            return new NotFoundScreen();
-          }
-        });
-  }
-
-  private static void admin(String token) {
-    GWT.runAsync(
-        new AsyncSplit(token) {
-          @Override
-          public void onSuccess() {
-            if (matchExact(ADMIN_GROUPS, token) || matchExact("/admin/groups", token)) {
-              Gerrit.display(token, new GroupListScreen());
-
-            } else if (matchPrefix(ADMIN_GROUPS, token)) {
-              String rest = skip(token);
-              if (rest.startsWith("?")) {
-                Gerrit.display(token, new GroupListScreen(rest.substring(1)));
-              } else {
-                group();
-              }
-
-            } else if (matchPrefix("/admin/groups", token)) {
-              String rest = skip(token);
-              if (rest.startsWith("?")) {
-                Gerrit.display(token, new GroupListScreen(rest.substring(1)));
-              }
-
-            } else if (matchExact(ADMIN_PROJECTS, token) || matchExact("/admin/projects", token)) {
-              Gerrit.display(token, new ProjectListScreen());
-
-            } else if (matchPrefix(ADMIN_PROJECTS, token)) {
-              String rest = skip(token);
-              if (rest.startsWith("?")) {
-                Gerrit.display(token, new ProjectListScreen(rest.substring(1)));
-              } else {
-                Gerrit.display(token, selectProject());
-              }
-
-            } else if (matchPrefix("/admin/projects", token)) {
-              String rest = skip(token);
-              if (rest.startsWith("?")) {
-                Gerrit.display(token, new ProjectListScreen(rest.substring(1)));
-              }
-
-            } else if (matchPrefix(ADMIN_PLUGINS, token) || matchExact("/admin/plugins", token)) {
-              Gerrit.display(token, new PluginListScreen());
-
-            } else if (matchExact(ADMIN_CREATE_PROJECT, token)
-                || matchExact("/admin/create-project", token)) {
-              Gerrit.display(token, new CreateProjectScreen());
-
-            } else if (matchExact(ADMIN_CREATE_GROUP, token)
-                || matchExact("/admin/create-group", token)) {
-              Gerrit.display(token, new CreateGroupScreen());
-
-            } else {
-              Gerrit.display(token, new NotFoundScreen());
-            }
-          }
-
-          private void group() {
-            final String panel;
-            final String group;
-
-            if (matchPrefix("/admin/groups/uuid-", token)) {
-              String p = skip(token);
-              int c = p.indexOf(',');
-              if (c < 0) {
-                group = p;
-                panel = null;
-              } else {
-                group = p.substring(0, c);
-                panel = p.substring(c + 1);
-              }
-            } else if (matchPrefix(ADMIN_GROUPS, token)) {
-              String p = skip(token);
-              int c = p.indexOf(',');
-              if (c < 0) {
-                group = p;
-                panel = null;
-              } else {
-                group = p.substring(0, c);
-                panel = p.substring(c + 1);
-              }
-            } else {
-              Gerrit.display(token, new NotFoundScreen());
-              return;
-            }
-
-            GroupApi.getGroupDetail(
-                group,
-                new GerritCallback<GroupInfo>() {
-                  @Override
-                  public void onSuccess(GroupInfo group) {
-                    if (panel == null || panel.isEmpty()) {
-                      // The token does not say which group screen should be shown,
-                      // as default for internal groups show the members, as default
-                      // for external and system groups show the info screen (since
-                      // for external and system groups the members cannot be
-                      // shown in the web UI).
-                      //
-                      if (AccountGroup.isInternalGroup(group.getGroupUUID())) {
-                        String newToken = toGroup(group.getGroupId(), AccountGroupScreen.MEMBERS);
-                        Gerrit.display(newToken, new AccountGroupMembersScreen(group, newToken));
-                      } else {
-                        String newToken = toGroup(group.getGroupId(), AccountGroupScreen.INFO);
-                        Gerrit.display(newToken, new AccountGroupInfoScreen(group, newToken));
-                      }
-                    } else if (AccountGroupScreen.INFO.equals(panel)) {
-                      Gerrit.display(token, new AccountGroupInfoScreen(group, token));
-                    } else if (AccountGroupScreen.MEMBERS.equals(panel)) {
-                      Gerrit.display(token, new AccountGroupMembersScreen(group, token));
-                    } else if (AccountGroupScreen.AUDIT_LOG.equals(panel)) {
-                      Gerrit.display(token, new AccountGroupAuditLogScreen(group, token));
-                    } else {
-                      Gerrit.display(token, new NotFoundScreen());
-                    }
-                  }
-                });
-          }
-
-          private Screen selectProject() {
-            if (matchPrefix(ADMIN_PROJECTS, token)) {
-              String rest = skip(token);
-              int c = rest.lastIndexOf(',');
-              if (c < 0) {
-                return new ProjectInfoScreen(Project.NameKey.parse(rest));
-              } else if (c == 0) {
-                return new NotFoundScreen();
-              }
-
-              int q = rest.lastIndexOf('?');
-              if (q > 0 && rest.lastIndexOf(',', q) > 0) {
-                c = rest.substring(0, q - 1).lastIndexOf(',');
-              }
-
-              Project.NameKey k = Project.NameKey.parse(rest.substring(0, c));
-              String panel = rest.substring(c + 1);
-
-              if (ProjectScreen.INFO.equals(panel)) {
-                return new ProjectInfoScreen(k);
-              }
-
-              if (ProjectScreen.BRANCHES.equals(panel)
-                  || matchPrefix(ProjectScreen.BRANCHES, panel)) {
-                return new ProjectBranchesScreen(k);
-              }
-
-              if (ProjectScreen.TAGS.equals(panel) || matchPrefix(ProjectScreen.TAGS, panel)) {
-                return new ProjectTagsScreen(k);
-              }
-
-              if (ProjectScreen.ACCESS.equals(panel)) {
-                return new ProjectAccessScreen(k);
-              }
-
-              if (ProjectScreen.DASHBOARDS.equals(panel)) {
-                return new ProjectDashboardsScreen(k);
-              }
-            }
-            return new NotFoundScreen();
-          }
-        });
-  }
-
-  private static boolean matchExact(String want, String token) {
-    return token.equals(want);
-  }
-
-  private static int prefixlen;
-
-  private static boolean matchPrefix(String want, String token) {
-    if (token.startsWith(want)) {
-      prefixlen = want.length();
-      return true;
-    }
-    return false;
-  }
-
-  private static String skip(String token) {
-    return token.substring(prefixlen);
-  }
-
-  private abstract static class AsyncSplit implements RunAsyncCallback {
-    private final boolean isReloadUi;
-    protected final String token;
-
-    protected AsyncSplit(String token) {
-      this.isReloadUi = wasStartedByReloadUI;
-      this.token = token;
-    }
-
-    @Override
-    public final void onFailure(Throwable reason) {
-      if (!isReloadUi && "HTTP download failed with status 404".equals(reason.getMessage())) {
-        // The server was upgraded since we last download the main script,
-        // so the pointers to the splits aren't valid anymore.  Force the
-        // page to reload itself and pick up the new code.
-        //
-        Gerrit.upgradeUI(token);
-      } else {
-        new ErrorDialog(reason).center();
-      }
-    }
-  }
-
-  private static void docSearch(String token) {
-    GWT.runAsync(
-        new AsyncSplit(token) {
-          @Override
-          public void onSuccess() {
-            Gerrit.display(token, new DocScreen(skip(token)));
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
deleted file mode 100644
index c116d76..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
+++ /dev/null
@@ -1,168 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.rpc.RpcConstants;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.http.client.Response;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.rpc.StatusCodeException;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtjsonrpc.client.RemoteJsonException;
-
-/** A dialog box showing an error message, when bad things happen. */
-public class ErrorDialog extends PopupPanel {
-  private final Label text;
-  private final FlowPanel body;
-  private final Button closey;
-
-  protected ErrorDialog() {
-    super(/* auto hide */ false, /* modal */ true);
-    setGlassEnabled(true);
-    getGlassElement().addClassName(Gerrit.RESOURCES.css().errorDialogGlass());
-
-    text = new Label();
-    text.setStyleName(Gerrit.RESOURCES.css().errorDialogTitle());
-
-    body = new FlowPanel();
-
-    final FlowPanel buttons = new FlowPanel();
-    buttons.setStyleName(Gerrit.RESOURCES.css().errorDialogButtons());
-
-    closey = new Button();
-    closey.setText(Gerrit.C.errorDialogContinue());
-    closey.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            hide();
-          }
-        });
-    closey.addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            // if the close button is triggered by a key we need to consume the key
-            // event, otherwise the key event would be propagated to the parent
-            // screen and eventually trigger some unwanted action there after the
-            // error dialog was closed
-            event.stopPropagation();
-          }
-        });
-    buttons.add(closey);
-
-    final FlowPanel center = new FlowPanel();
-    center.add(text);
-    center.add(body);
-    center.add(buttons);
-
-    setText(Gerrit.C.errorTitle());
-    addStyleName(Gerrit.RESOURCES.css().errorDialog());
-    add(center);
-
-    int l = Window.getScrollLeft() + 20;
-    int t = Window.getScrollTop() + 20;
-    setPopupPosition(l, t);
-  }
-
-  /** Create a dialog box to show a single message string. */
-  public ErrorDialog(String message) {
-    this();
-    body.add(createErrorMsgLabel(message));
-  }
-
-  /** Create a dialog box to show a single message string. */
-  public ErrorDialog(SafeHtml message) {
-    this();
-    body.add(message.toBlockWidget());
-  }
-
-  /** Create a dialog box to nicely format an exception. */
-  public ErrorDialog(Throwable what) {
-    this();
-
-    String hdr;
-    String msg;
-
-    if (what instanceof StatusCodeException) {
-      StatusCodeException sc = (StatusCodeException) what;
-      if (RestApi.isExpected(sc.getStatusCode())) {
-        hdr = null;
-        msg = sc.getEncodedResponse();
-      } else if (sc.getStatusCode() == Response.SC_INTERNAL_SERVER_ERROR) {
-        hdr = null;
-        msg = what.getMessage();
-      } else {
-        hdr = RpcConstants.C.errorServerUnavailable();
-        msg = what.getMessage();
-      }
-
-    } else if (what instanceof RemoteJsonException) {
-      // TODO Remove RemoteJsonException from Gerrit sources.
-      hdr = RpcConstants.C.errorRemoteJsonException();
-      msg = what.getMessage();
-
-    } else {
-      // TODO Fix callers of ErrorDialog to stop passing random types.
-      hdr = what.getClass().getName();
-      if (hdr.startsWith("java.lang.")) {
-        hdr = hdr.substring("java.lang.".length());
-      } else if (hdr.startsWith("com.google.gerrit.")) {
-        hdr = hdr.substring(hdr.lastIndexOf('.') + 1);
-      }
-      if (hdr.endsWith("Exception")) {
-        hdr = hdr.substring(0, hdr.length() - "Exception".length());
-      } else if (hdr.endsWith("Error")) {
-        hdr = hdr.substring(0, hdr.length() - "Error".length());
-      }
-      msg = what.getMessage();
-    }
-
-    if (hdr != null) {
-      final Label r = new Label(hdr);
-      r.setStyleName(Gerrit.RESOURCES.css().errorDialogErrorType());
-      body.add(r);
-    }
-
-    if (msg != null) {
-      body.add(createErrorMsgLabel(msg));
-    }
-  }
-
-  private Label createErrorMsgLabel(String message) {
-    Label m = new Label(message);
-    m.getElement().getStyle().setProperty("white-space", "pre");
-    return m;
-  }
-
-  public ErrorDialog setText(String t) {
-    text.setText(t);
-    return this;
-  }
-
-  @Override
-  public void center() {
-    show();
-    closey.setFocus(true);
-  }
-}
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
deleted file mode 100644
index 74fcdc2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
+++ /dev/null
@@ -1,132 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gerrit.client.change.Resources;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gwt.i18n.client.NumberFormat;
-import java.util.Date;
-
-/** Misc. formatting functions. */
-public class FormatUtil {
-  private static DateFormatter dateFormatter;
-
-  public static void setPreferences(GeneralPreferences prefs) {
-    dateFormatter = new DateFormatter(prefs);
-  }
-
-  /** Format a date using a really short format. */
-  public static String shortFormat(Date dt) {
-    ensureInited();
-    return dateFormatter.shortFormat(dt);
-  }
-
-  /** Format a date using a really short format. */
-  public static String shortFormatDayTime(Date dt) {
-    ensureInited();
-    return dateFormatter.shortFormatDayTime(dt);
-  }
-
-  /** Format a date using the locale's medium length format. */
-  public static String mediumFormat(Date dt) {
-    ensureInited();
-    return dateFormatter.mediumFormat(dt);
-  }
-
-  private static void ensureInited() {
-    if (dateFormatter == null) {
-      setPreferences(Gerrit.getUserPreferences());
-    }
-  }
-
-  /** Format a date using git log's relative date format. */
-  public static String relativeFormat(Date dt) {
-    return RelativeDateFormatter.format(dt);
-  }
-
-  /**
-   * Formats an account as a name and an email address.
-   *
-   * <p>Example output:
-   *
-   * <ul>
-   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
-   *   <li>{@code A U. Thor (12)}: missing email address
-   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
-   *   <li>{@code Anonymous Coward (12)}: missing name and email address
-   * </ul>
-   */
-  public static String nameEmail(AccountInfo info) {
-    return createAccountFormatter().nameEmail(info);
-  }
-
-  /**
-   * Formats an account name.
-   *
-   * <p>If the account has a full name, it returns only the full name. Otherwise it returns a longer
-   * form that includes the email address.
-   */
-  public static String name(AccountInfo info) {
-    return createAccountFormatter().name(info);
-  }
-
-  private static AccountFormatter createAccountFormatter() {
-    return new AccountFormatter(Gerrit.info().user().anonymousCowardName());
-  }
-
-  /** The returned format string doesn't contain any +/- sign. */
-  public static String formatAbsBytes(long bytes) {
-    return formatBytes(bytes, true);
-  }
-
-  public static String formatBytes(long bytes) {
-    return formatBytes(bytes, false);
-  }
-
-  private static String formatBytes(long bytes, boolean abs) {
-    bytes = abs ? Math.abs(bytes) : bytes;
-
-    if (bytes == 0) {
-      return abs ? "0 B" : "+/- 0 B";
-    }
-
-    if (Math.abs(bytes) < 1024) {
-      return (bytes > 0 && !abs ? "+" : "") + bytes + " B";
-    }
-
-    int exp = (int) (Math.log(Math.abs(bytes)) / Math.log(1024));
-    return (bytes > 0 && !abs ? "+" : "")
-        + NumberFormat.getFormat("#.0").format(bytes / Math.pow(1024, exp))
-        + " "
-        + "KMGTPE".charAt(exp - 1)
-        + "iB";
-  }
-
-  public static String formatPercentage(long size, long delta) {
-    if (size == 0) {
-      return Resources.C.notAvailable();
-    }
-    return (delta > 0 ? "+" : "-") + formatAbsPercentage(size, delta);
-  }
-
-  public static String formatAbsPercentage(long size, long delta) {
-    if (size == 0) {
-      return Resources.C.notAvailable();
-    }
-    int p = Math.abs(Math.round(delta * 100 / size));
-    return p + "%";
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
deleted file mode 100644
index afc65c9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
+++ /dev/null
@@ -1,1134 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_GROUP;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_PROJECT;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_PLUGINS;
-import static com.google.gerrit.common.data.HostPageData.XSRF_COOKIE_NAME;
-
-import com.google.gerrit.client.account.AccountApi;
-import com.google.gerrit.client.account.AccountCapabilities;
-import com.google.gerrit.client.account.EditPreferences;
-import com.google.gerrit.client.admin.ProjectScreen;
-import com.google.gerrit.client.api.ApiGlue;
-import com.google.gerrit.client.api.PluginLoader;
-import com.google.gerrit.client.change.LocalComments;
-import com.google.gerrit.client.changes.ChangeListScreen;
-import com.google.gerrit.client.config.ConfigServerApi;
-import com.google.gerrit.client.documentation.DocInfo;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.AuthInfo;
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gerrit.client.info.ServerInfo;
-import com.google.gerrit.client.info.TopMenu;
-import com.google.gerrit.client.info.TopMenuItem;
-import com.google.gerrit.client.info.TopMenuList;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.LinkMenuBar;
-import com.google.gerrit.client.ui.LinkMenuItem;
-import com.google.gerrit.client.ui.MorphingTabPanel;
-import com.google.gerrit.client.ui.ProjectLinkMenuItem;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.HostPageData;
-import com.google.gerrit.common.data.SystemInfoService;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.client.GerritTopMenu;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.aria.client.Roles;
-import com.google.gwt.core.client.EntryPoint;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Document;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.event.shared.EventBus;
-import com.google.gwt.event.shared.SimpleEventBus;
-import com.google.gwt.http.client.Request;
-import com.google.gwt.http.client.RequestBuilder;
-import com.google.gwt.http.client.RequestCallback;
-import com.google.gwt.http.client.RequestException;
-import com.google.gwt.http.client.Response;
-import com.google.gwt.http.client.URL;
-import com.google.gwt.http.client.UrlBuilder;
-import com.google.gwt.user.client.Command;
-import com.google.gwt.user.client.Cookies;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.Window.Location;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.FocusPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.InlineHTML;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.RootPanel;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-import com.google.gwtexpui.user.client.UserAgent;
-import com.google.gwtexpui.user.client.ViewSite;
-import com.google.gwtjsonrpc.client.JsonDefTarget;
-import com.google.gwtjsonrpc.client.JsonUtil;
-import com.google.gwtjsonrpc.client.XsrfManager;
-import com.google.gwtorm.client.KeyUtil;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class Gerrit implements EntryPoint {
-  public static final GerritConstants C = GWT.create(GerritConstants.class);
-  public static final GerritMessages M = GWT.create(GerritMessages.class);
-  public static final GerritResources RESOURCES = GWT.create(GerritResources.class);
-  public static final SystemInfoService SYSTEM_SVC;
-  public static final EventBus EVENT_BUS = GWT.create(SimpleEventBus.class);
-  public static final Themer THEMER = GWT.create(Themer.class);
-  public static final String PROJECT_NAME_MENU_VAR = "${projectName}";
-  public static final String INDEX = "Documentation/index.html";
-
-  private static String myHost;
-  private static ServerInfo myServerInfo;
-  private static AccountInfo myAccount;
-  private static GeneralPreferences myPrefs;
-  private static UrlAliasMatcher urlAliasMatcher;
-  private static boolean hasDocumentation;
-  private static boolean docSearch;
-  private static String docUrl;
-  private static HostPageData.Theme myTheme;
-  private static String defaultScreenToken;
-  private static DiffPreferencesInfo myAccountDiffPref;
-  private static EditPreferencesInfo editPrefs;
-  private static String xGerritAuth;
-  private static boolean isNoteDbEnabled;
-
-  private static Map<String, LinkMenuBar> menuBars;
-
-  private static MorphingTabPanel menuLeft;
-  private static LinkMenuBar menuRight;
-  private static RootPanel topMenu;
-  private static RootPanel siteHeader;
-  private static RootPanel siteFooter;
-  private static RootPanel bottomMenu;
-  private static SearchPanel searchPanel;
-  private static final Dispatcher dispatcher = new Dispatcher();
-  private static ViewSite<Screen> body;
-  private static String lastChangeListToken;
-  private static String lastViewToken;
-  private static Anchor uiSwitcherLink;
-
-  static {
-    SYSTEM_SVC = GWT.create(SystemInfoService.class);
-    JsonUtil.bind(SYSTEM_SVC, "rpc/SystemInfoService");
-  }
-
-  static void upgradeUI(String token) {
-    History.newItem(Dispatcher.RELOAD_UI + token, false);
-    Window.Location.reload();
-  }
-
-  public static void displayLastChangeList() {
-    if (lastChangeListToken != null) {
-      display(lastChangeListToken);
-    } else if (isSignedIn()) {
-      display(PageLinks.MINE);
-    } else {
-      display(PageLinks.toChangeQuery("status:open"));
-    }
-  }
-
-  public static String getPriorView() {
-    return lastViewToken;
-  }
-
-  /**
-   * Load the screen at the given location, displaying when ready.
-   *
-   * <p>If the URL is not already pointing at this location, a new item will be added to the
-   * browser's history when the screen is fully loaded and displayed on the UI.
-   *
-   * @param token location to parse, load, and render.
-   */
-  public static void display(String token) {
-    if (body.getView() == null || !body.getView().displayToken(token)) {
-      dispatcher.display(token);
-      updateUiLink(token);
-    }
-  }
-
-  /**
-   * Load the screen passed, assuming token can be used to locate it.
-   *
-   * <p>The screen is loaded in the background. When it is ready to be visible a new item will be
-   * added to the browser's history, the screen will be made visible, and the window title may be
-   * updated.
-   *
-   * <p>If {@link Screen#isRequiresSignIn()} is true and the user is not signed in yet the screen
-   * instance will be discarded, sign-in will take place, and will redirect to this location upon
-   * success.
-   *
-   * @param token location that refers to {@code view}.
-   * @param view the view to load.
-   */
-  public static void display(String token, Screen view) {
-    if (view.isRequiresSignIn() && !isSignedIn()) {
-      doSignIn(token);
-    } else {
-      view.setToken(token);
-      if (isSignedIn()) {
-        LocalComments.saveInlineComments();
-      }
-      body.setView(view);
-      updateUiLink(token);
-    }
-  }
-
-  public static void selectMenu(LinkMenuBar bar) {
-    menuLeft.selectTab(menuLeft.getWidgetIndex(bar));
-  }
-
-  /**
-   * Update the current history token after a screen change.
-   *
-   * <p>The caller has already updated the UI, but wants to publish a different history token for
-   * the current browser state. This really only makes sense if the caller is a {@code TabPanel} and
-   * is firing an event when the tab changed to a different part.
-   *
-   * @param token new location that is already visible.
-   */
-  public static void updateImpl(String token) {
-    History.newItem(token, false);
-    dispatchHistoryHooks(token);
-  }
-
-  public static void setQueryString(String query) {
-    searchPanel.setText(query);
-  }
-
-  public static void setWindowTitle(Screen screen, String text) {
-    if (screen == body.getView()) {
-      if (text == null || text.length() == 0) {
-        Window.setTitle(M.windowTitle1(myHost));
-      } else {
-        Window.setTitle(M.windowTitle2(text, myHost));
-      }
-    }
-  }
-
-  public static int getHeaderFooterHeight() {
-    int h = bottomMenu.getOffsetHeight();
-    if (topMenu.isVisible()) {
-      h += topMenu.getOffsetHeight();
-    }
-    if (siteHeader.isVisible()) {
-      h += siteHeader.getOffsetHeight();
-    }
-    if (siteFooter.isVisible()) {
-      h += siteFooter.getOffsetHeight();
-    }
-    return h;
-  }
-
-  public static void setHeaderVisible(boolean visible) {
-    topMenu.setVisible(visible);
-    siteHeader.setVisible(visible && getUserPreferences().showSiteHeader());
-  }
-
-  public static boolean isHeaderVisible() {
-    return topMenu.isVisible();
-  }
-
-  public static String getDefaultScreenToken() {
-    return defaultScreenToken;
-  }
-
-  public static RootPanel getBottomMenu() {
-    return bottomMenu;
-  }
-
-  /** Get the public configuration data used by this Gerrit instance. */
-  public static ServerInfo info() {
-    return myServerInfo;
-  }
-
-  public static UrlAliasMatcher getUrlAliasMatcher() {
-    return urlAliasMatcher;
-  }
-
-  /** Site theme information (site specific colors)/ */
-  public static HostPageData.Theme getTheme() {
-    return myTheme;
-  }
-
-  /** @return the currently signed in user's account data; empty account data if no account */
-  public static AccountInfo getUserAccount() {
-    return myAccount;
-  }
-
-  /** @return access token to prove user identity during REST API calls. */
-  @Nullable
-  public static String getXGerritAuth() {
-    return xGerritAuth;
-  }
-
-  /**
-   * @return the preferences of the currently signed in user, the default preferences if not signed
-   *     in
-   */
-  public static GeneralPreferences getUserPreferences() {
-    return myPrefs;
-  }
-
-  /** @return the currently signed in users's diff preferences, or default values */
-  public static DiffPreferencesInfo getDiffPreferences() {
-    return myAccountDiffPref;
-  }
-
-  public static void setDiffPreferences(DiffPreferencesInfo accountDiffPref) {
-    myAccountDiffPref = accountDiffPref;
-  }
-
-  /** @return the edit preferences of the current user, null if not signed-in */
-  public static EditPreferencesInfo getEditPreferences() {
-    return editPrefs;
-  }
-
-  public static void setEditPreferences(EditPreferencesInfo p) {
-    editPrefs = p;
-  }
-
-  /** @return true if the user is currently authenticated */
-  public static boolean isSignedIn() {
-    return xGerritAuth != null;
-  }
-
-  /** Sign the user into the application. */
-  public static void doSignIn(String token) {
-    Location.assign(loginRedirect(token));
-  }
-
-  public static boolean isNoteDbEnabled() {
-    return isNoteDbEnabled;
-  }
-
-  public static String loginRedirect(String token) {
-    if (token == null) {
-      token = "";
-    } else if (token.startsWith("/")) {
-      token = token.substring(1);
-    }
-
-    return selfRedirect("login/") + URL.encodePathSegment("#/" + token);
-  }
-
-  public static String selfRedirect(String suffix) {
-    // Clean up the path. Users seem to like putting extra slashes into the URL
-    // which can break redirections by misinterpreting at either client or server.
-    String path = Location.getPath();
-    if (path == null || path.isEmpty()) {
-      path = "/";
-    } else {
-      while (path.startsWith("//")) {
-        path = path.substring(1);
-      }
-      while (path.endsWith("//")) {
-        path = path.substring(0, path.length() - 1);
-      }
-      if (!path.endsWith("/")) {
-        path = path + "/";
-      }
-    }
-
-    if (suffix != null) {
-      while (suffix.startsWith("/")) {
-        suffix = suffix.substring(1);
-      }
-      path += suffix;
-    }
-
-    UrlBuilder builder = new UrlBuilder();
-    builder.setProtocol(Location.getProtocol());
-    builder.setHost(Location.getHost());
-    String port = Location.getPort();
-    if (port != null && !port.isEmpty()) {
-      builder.setPort(Integer.parseInt(port));
-    }
-    builder.setPath(path);
-    return builder.buildString();
-  }
-
-  static void deleteSessionCookie() {
-    myAccount = AccountInfo.create(0, null, null, null);
-    myAccountDiffPref = null;
-    editPrefs = null;
-    myPrefs = GeneralPreferences.createDefault();
-    urlAliasMatcher.clearUserAliases();
-    xGerritAuth = null;
-    refreshMenuBar();
-
-    // If the cookie was HttpOnly, this request to delete it will
-    // most likely not be successful.  We can try anyway though.
-    //
-    Cookies.removeCookie("GerritAccount");
-  }
-
-  private void setXsrfToken() {
-    xGerritAuth = Cookies.getCookie(XSRF_COOKIE_NAME);
-    JsonUtil.setDefaultXsrfManager(
-        new XsrfManager() {
-          @Override
-          public String getToken(JsonDefTarget proxy) {
-            return xGerritAuth;
-          }
-
-          @Override
-          public void setToken(JsonDefTarget proxy, String token) {
-            // Ignore the request, we always rely upon the cookie.
-          }
-        });
-  }
-
-  @Override
-  public void onModuleLoad() {
-    if (!canLoadInIFrame()) {
-      UserAgent.assertNotInIFrame();
-    }
-    setXsrfToken();
-
-    KeyUtil.setEncoderImpl(
-        new KeyUtil.Encoder() {
-          @Override
-          public String encode(String e) {
-            e = URL.encodeQueryString(e);
-            e = fixPathImpl(e);
-            e = fixColonImpl(e);
-            e = fixDoubleQuote(e);
-            return e;
-          }
-
-          @Override
-          public String decode(String e) {
-            return URL.decodeQueryString(e);
-          }
-
-          private native String fixPathImpl(String path)
-              /*-{ return path.replace(/%2F/g, "/"); }-*/ ;
-
-          private native String fixColonImpl(String path)
-              /*-{ return path.replace(/%3A/g, ":"); }-*/ ;
-
-          private native String fixDoubleQuote(String path)
-              /*-{ return path.replace(/%22/g, '"'); }-*/ ;
-        });
-
-    initHostname();
-    Window.setTitle(M.windowTitle1(myHost));
-
-    RpcStatus.INSTANCE = new RpcStatus();
-    CallbackGroup cbg = new CallbackGroup();
-    getDocIndex(
-        cbg.add(
-            new GerritCallback<DocInfo>() {
-              @Override
-              public void onSuccess(DocInfo indexInfo) {
-                hasDocumentation = indexInfo != null;
-                docUrl = selfRedirect("/Documentation/");
-              }
-            }));
-    ConfigServerApi.serverInfo(
-        cbg.add(
-            new GerritCallback<ServerInfo>() {
-              @Override
-              public void onSuccess(ServerInfo info) {
-                myServerInfo = info;
-                urlAliasMatcher = new UrlAliasMatcher(info.urlAliases());
-                String du = info.gerrit().docUrl();
-                if (du != null && !du.isEmpty()) {
-                  hasDocumentation = true;
-                  docUrl = du;
-                }
-                docSearch = info.gerrit().docSearch();
-              }
-            }));
-    HostPageDataService hpd = GWT.create(HostPageDataService.class);
-    hpd.load(
-        cbg.addFinal(
-            new GerritCallback<HostPageData>() {
-              @Override
-              public void onSuccess(HostPageData result) {
-                Document.get().getElementById("gerrit_hostpagedata").removeFromParent();
-                myTheme = result.theme;
-                isNoteDbEnabled = result.isNoteDbEnabled;
-                if (result.accountDiffPref != null) {
-                  myAccountDiffPref = result.accountDiffPref;
-                }
-                if (result.accountDiffPref != null) {
-                  // TODO: Support options on the GetDetail REST endpoint so that it can
-                  // also return the preferences. Then we can fetch everything with a
-                  // single request and we don't need the callback group anymore.
-                  CallbackGroup cbg = new CallbackGroup();
-                  AccountApi.self()
-                      .view("detail")
-                      .get(
-                          cbg.add(
-                              new GerritCallback<AccountInfo>() {
-                                @Override
-                                public void onSuccess(AccountInfo result) {
-                                  myAccount = result;
-                                }
-                              }));
-                  AccountApi.self()
-                      .view("preferences")
-                      .get(
-                          cbg.add(
-                              new GerritCallback<GeneralPreferences>() {
-                                @Override
-                                public void onSuccess(GeneralPreferences prefs) {
-                                  myPrefs = prefs;
-                                  onModuleLoad2(result);
-                                }
-                              }));
-                  AccountApi.getEditPreferences(
-                      cbg.addFinal(
-                          new GerritCallback<EditPreferences>() {
-                            @Override
-                            public void onSuccess(EditPreferences prefs) {
-                              EditPreferencesInfo prefsInfo = new EditPreferencesInfo();
-                              prefs.copyTo(prefsInfo);
-                              editPrefs = prefsInfo;
-                            }
-                          }));
-                } else {
-                  myAccount = AccountInfo.create(0, null, null, null);
-                  myPrefs = GeneralPreferences.createDefault();
-                  editPrefs = null;
-                  onModuleLoad2(result);
-                }
-              }
-            }));
-  }
-
-  private native boolean canLoadInIFrame() /*-{
-    return $wnd.gerrit_hostpagedata.canLoadInIFrame || false;
-  }-*/;
-
-  private static void initHostname() {
-    myHost = Location.getHostName();
-    final int d1 = myHost.indexOf('.');
-    if (d1 < 0) {
-      return;
-    }
-    final int d2 = myHost.indexOf('.', d1 + 1);
-    if (d2 >= 0) {
-      myHost = myHost.substring(0, d2);
-    }
-  }
-
-  private static void dispatchHistoryHooks(String token) {
-    ApiGlue.fireEvent("history", token);
-  }
-
-  private static String getUiSwitcherUrl(String token) {
-    UrlBuilder builder = new UrlBuilder();
-    builder.setProtocol(Location.getProtocol());
-    builder.setHost(Location.getHost());
-    String port = Location.getPort();
-    if (port != null && !port.isEmpty()) {
-      builder.setPort(Integer.parseInt(port));
-    }
-    String[] tokens = token.split("@", 2);
-    if (Location.getPath().endsWith("/") && tokens[0].startsWith("/")) {
-      tokens[0] = tokens[0].substring(1);
-    }
-    if (tokens[0].startsWith("projects/") && tokens[0].contains(",dashboards/")) {
-      // Rewrite project dashboard URIs to a new format, because otherwise
-      // "/projects/..." would be served as an API request.
-      tokens[0] = "p/" + tokens[0].substring("projects/".length());
-      tokens[0] = tokens[0].replace(",dashboards/", "/+/dashboard/");
-    }
-    builder.setPath(Location.getPath() + tokens[0]);
-    if (tokens.length == 2) {
-      builder.setHash(tokens[1]);
-    }
-    builder.setParameter("polygerrit", "1");
-    return builder.buildString();
-  }
-
-  private static void populateBottomMenu(RootPanel btmmenu, HostPageData hpd) {
-    String vs = hpd.version;
-    if (vs == null || vs.isEmpty()) {
-      vs = "dev";
-    }
-
-    btmmenu.add(new InlineHTML(M.poweredBy(vs)));
-
-    btmmenu.add(new InlineLabel(" | "));
-    uiSwitcherLink = new Anchor(C.newUi(), getUiSwitcherUrl(History.getToken()));
-    uiSwitcherLink.setStyleName("");
-    btmmenu.add(uiSwitcherLink);
-
-    String reportBugUrl = info().gerrit().reportBugUrl();
-    if (reportBugUrl != null) {
-      String reportBugText = info().gerrit().reportBugText();
-      Anchor a = new Anchor(reportBugText == null ? C.reportBug() : reportBugText, reportBugUrl);
-      a.setTarget("_blank");
-      a.setStyleName("");
-      btmmenu.add(new InlineLabel(" | "));
-      btmmenu.add(a);
-    }
-    btmmenu.add(new InlineLabel(" | "));
-    btmmenu.add(new InlineLabel(C.keyHelp()));
-  }
-
-  private static void updateUiLink(String token) {
-    if (uiSwitcherLink != null) {
-      uiSwitcherLink.setHref(getUiSwitcherUrl(token));
-    }
-  }
-
-  private void onModuleLoad2(HostPageData hpd) {
-    RESOURCES.gwt_override().ensureInjected();
-    RESOURCES.css().ensureInjected();
-
-    topMenu = RootPanel.get("gerrit_topmenu");
-    final RootPanel gStarting = RootPanel.get("gerrit_startinggerrit");
-    final RootPanel gBody = RootPanel.get("gerrit_body");
-    bottomMenu = RootPanel.get("gerrit_btmmenu");
-
-    topMenu.setStyleName(RESOURCES.css().gerritTopMenu());
-    gBody.setStyleName(RESOURCES.css().gerritBody());
-
-    final Grid menuLine = new Grid(1, 3);
-    menuLeft = new MorphingTabPanel();
-    menuRight = new LinkMenuBar();
-    searchPanel = new SearchPanel();
-    menuLeft.setStyleName(RESOURCES.css().topmenuMenuLeft());
-    menuLine.setStyleName(RESOURCES.css().topmenu());
-    topMenu.add(menuLine);
-    final FlowPanel menuRightPanel = new FlowPanel();
-    menuRightPanel.setStyleName(RESOURCES.css().topmenuMenuRight());
-    menuRightPanel.add(searchPanel);
-    menuRightPanel.add(menuRight);
-    menuLine.setWidget(0, 0, menuLeft);
-    menuLine.setWidget(0, 1, new FlowPanel());
-    menuLine.setWidget(0, 2, menuRightPanel);
-    final CellFormatter fmt = menuLine.getCellFormatter();
-    fmt.setStyleName(0, 0, RESOURCES.css().topmenuTDmenu());
-    fmt.setStyleName(0, 1, RESOURCES.css().topmenuTDglue());
-    fmt.setStyleName(0, 2, RESOURCES.css().topmenuTDmenu());
-
-    siteHeader = RootPanel.get("gerrit_header");
-    siteFooter = RootPanel.get("gerrit_footer");
-
-    body =
-        new ViewSite<Screen>() {
-          @Override
-          protected void onShowView(Screen view) {
-            String token = view.getToken();
-            History.newItem(token, false);
-            dispatchHistoryHooks(token);
-
-            if (view instanceof ChangeListScreen) {
-              lastChangeListToken = token;
-            }
-
-            super.onShowView(view);
-            view.onShowView();
-            lastViewToken = token;
-          }
-        };
-    gBody.add(body);
-
-    JsonUtil.addRpcStartHandler(RpcStatus.INSTANCE);
-    JsonUtil.addRpcCompleteHandler(RpcStatus.INSTANCE);
-
-    gStarting.getElement().getParentElement().removeChild(gStarting.getElement());
-    RootPanel.detachNow(gStarting);
-    ApiGlue.init();
-
-    applyUserPreferences();
-    populateBottomMenu(bottomMenu, hpd);
-    refreshMenuBar();
-
-    History.addValueChangeHandler(
-        new ValueChangeHandler<String>() {
-          @Override
-          public void onValueChange(ValueChangeEvent<String> event) {
-            display(event.getValue());
-          }
-        });
-    JumpKeys.register(body);
-
-    saveDefaultTheme();
-    if (hpd.messages != null) {
-      new MessageOfTheDayBar(hpd.messages).show();
-    }
-    PluginLoader.load(
-        hpd.plugins,
-        hpd.pluginsLoadTimeout,
-        new GerritCallback<VoidResult>() {
-          @Override
-          public void onSuccess(VoidResult result) {
-            String token = History.getToken();
-            if (token.isEmpty()) {
-              token = isSignedIn() ? PageLinks.MINE : PageLinks.toChangeQuery("status:open");
-            }
-            display(token);
-          }
-        });
-  }
-
-  private void saveDefaultTheme() {
-    THEMER.init(
-        Document.get().getElementById("gerrit_sitecss"),
-        Document.get().getElementById("gerrit_header"),
-        Document.get().getElementById("gerrit_footer"));
-  }
-
-  public static void refreshMenuBar() {
-    menuLeft.clear();
-    menuRight.clear();
-
-    menuBars = new HashMap<>();
-
-    boolean signedIn = isSignedIn();
-    AuthInfo authInfo = info().auth();
-    LinkMenuBar m;
-
-    m = new LinkMenuBar();
-    menuBars.put(GerritTopMenu.ALL.menuName, m);
-    addLink(m, C.menuAllOpen(), PageLinks.toChangeQuery("status:open"));
-    addLink(m, C.menuAllMerged(), PageLinks.toChangeQuery("status:merged"));
-    addLink(m, C.menuAllAbandoned(), PageLinks.toChangeQuery("status:abandoned"));
-    menuLeft.add(m, C.menuAll());
-
-    if (signedIn) {
-      LinkMenuBar myBar = new LinkMenuBar();
-      menuBars.put(GerritTopMenu.MY.menuName, myBar);
-
-      if (myPrefs.my() != null) {
-        myBar.clear();
-        String url = null;
-        List<TopMenuItem> myMenuItems = Natives.asList(myPrefs.my());
-        if (!myMenuItems.isEmpty()) {
-          if (myMenuItems.get(0).getUrl().startsWith("#")) {
-            url = myMenuItems.get(0).getUrl().substring(1);
-          }
-          for (TopMenuItem item : myMenuItems) {
-            addExtensionLink(myBar, item);
-          }
-        }
-        defaultScreenToken = url;
-      }
-
-      menuLeft.add(myBar, C.menuMine());
-      menuLeft.selectTab(1);
-    } else {
-      menuLeft.selectTab(0);
-    }
-
-    final LinkMenuBar projectsBar = new LinkMenuBar();
-    menuBars.put(GerritTopMenu.PROJECTS.menuName, projectsBar);
-    addLink(projectsBar, C.menuProjectsList(), PageLinks.ADMIN_PROJECTS);
-    projectsBar.addItem(new ProjectLinkMenuItem(C.menuProjectsInfo(), ProjectScreen.INFO));
-    projectsBar.addItem(new ProjectLinkMenuItem(C.menuProjectsBranches(), ProjectScreen.BRANCHES));
-    projectsBar.addItem(new ProjectLinkMenuItem(C.menuProjectsTags(), ProjectScreen.TAGS));
-    projectsBar.addItem(new ProjectLinkMenuItem(C.menuProjectsAccess(), ProjectScreen.ACCESS));
-    final LinkMenuItem dashboardsMenuItem =
-        new ProjectLinkMenuItem(C.menuProjectsDashboards(), ProjectScreen.DASHBOARDS) {
-          @Override
-          protected boolean match(String token) {
-            return super.match(token)
-                || (!getTargetHistoryToken().isEmpty()
-                    && ("/admin" + token).startsWith(getTargetHistoryToken()));
-          }
-        };
-    projectsBar.addItem(dashboardsMenuItem);
-    menuLeft.add(projectsBar, C.menuProjects());
-
-    if (signedIn) {
-      final LinkMenuBar peopleBar = new LinkMenuBar();
-      menuBars.put(GerritTopMenu.PEOPLE.menuName, peopleBar);
-      final LinkMenuItem groupsListMenuItem =
-          addLink(peopleBar, C.menuPeopleGroupsList(), PageLinks.ADMIN_GROUPS);
-      menuLeft.add(peopleBar, C.menuPeople());
-
-      final LinkMenuBar pluginsBar = new LinkMenuBar();
-      menuBars.put(GerritTopMenu.PLUGINS.menuName, pluginsBar);
-      AccountCapabilities.all(
-          new GerritCallback<AccountCapabilities>() {
-            @Override
-            public void onSuccess(AccountCapabilities result) {
-              if (result.canPerform(CREATE_PROJECT)) {
-                insertLink(
-                    projectsBar,
-                    C.menuProjectsCreate(),
-                    PageLinks.ADMIN_CREATE_PROJECT,
-                    projectsBar.getWidgetIndex(dashboardsMenuItem) + 1);
-              }
-              if (result.canPerform(CREATE_GROUP)) {
-                insertLink(
-                    peopleBar,
-                    C.menuPeopleGroupsCreate(),
-                    PageLinks.ADMIN_CREATE_GROUP,
-                    peopleBar.getWidgetIndex(groupsListMenuItem) + 1);
-              }
-              if (result.canPerform(VIEW_PLUGINS)) {
-                insertLink(pluginsBar, C.menuPluginsInstalled(), PageLinks.ADMIN_PLUGINS, 0);
-                menuLeft.insert(
-                    pluginsBar, C.menuPlugins(), menuLeft.getWidgetIndex(peopleBar) + 1);
-              }
-            }
-          },
-          CREATE_PROJECT,
-          CREATE_GROUP,
-          VIEW_PLUGINS);
-    }
-
-    if (hasDocumentation) {
-      m = new LinkMenuBar();
-      menuBars.put(GerritTopMenu.DOCUMENTATION.menuName, m);
-      addDocLink(m, C.menuDocumentationTOC(), "index.html");
-      addDocLink(m, C.menuDocumentationSearch(), "user-search.html");
-      addDocLink(m, C.menuDocumentationUpload(), "user-upload.html");
-      addDocLink(m, C.menuDocumentationAccess(), "access-control.html");
-      addDocLink(m, C.menuDocumentationAPI(), "rest-api.html");
-      addDocLink(m, C.menuDocumentationProjectOwnerGuide(), "intro-project-owner.html");
-      menuLeft.add(m, C.menuDocumentation());
-    }
-
-    if (signedIn) {
-      whoAmI(!authInfo.isClientSslCertLdap());
-    } else {
-      switch (authInfo.authType()) {
-        case CLIENT_SSL_CERT_LDAP:
-          break;
-
-        case OPENID:
-          menuRight.addItem(
-              C.menuRegister(),
-              new Command() {
-                @Override
-                public void execute() {
-                  String t = History.getToken();
-                  if (t == null) {
-                    t = "";
-                  }
-                  doSignIn(PageLinks.REGISTER + t);
-                }
-              });
-          menuRight.addItem(
-              C.menuSignIn(),
-              new Command() {
-                @Override
-                public void execute() {
-                  doSignIn(History.getToken());
-                }
-              });
-          break;
-
-        case OAUTH:
-          menuRight.addItem(
-              C.menuSignIn(),
-              new Command() {
-                @Override
-                public void execute() {
-                  doSignIn(History.getToken());
-                }
-              });
-          break;
-
-        case OPENID_SSO:
-          menuRight.addItem(
-              C.menuSignIn(),
-              new Command() {
-                @Override
-                public void execute() {
-                  doSignIn(History.getToken());
-                }
-              });
-          break;
-
-        case HTTP:
-        case HTTP_LDAP:
-          if (authInfo.loginUrl() != null) {
-            String signinText =
-                authInfo.loginText() == null ? C.menuSignIn() : authInfo.loginText();
-            menuRight.add(anchor(signinText, authInfo.loginUrl()));
-          }
-          break;
-
-        case LDAP:
-        case LDAP_BIND:
-        case CUSTOM_EXTENSION:
-          if (authInfo.registerUrl() != null) {
-            String registerText =
-                authInfo.registerText() == null ? C.menuRegister() : authInfo.registerText();
-            menuRight.add(anchor(registerText, authInfo.registerUrl()));
-          }
-          menuRight.addItem(
-              C.menuSignIn(),
-              new Command() {
-                @Override
-                public void execute() {
-                  doSignIn(History.getToken());
-                }
-              });
-          break;
-
-        case DEVELOPMENT_BECOME_ANY_ACCOUNT:
-          menuRight.add(anchor("Become", loginRedirect("")));
-          break;
-      }
-    }
-    ConfigServerApi.topMenus(
-        new GerritCallback<TopMenuList>() {
-          @Override
-          public void onSuccess(TopMenuList result) {
-            List<TopMenu> topMenuExtensions = Natives.asList(result);
-            for (TopMenu menu : topMenuExtensions) {
-              String name = menu.getName();
-              LinkMenuBar existingBar = menuBars.get(name);
-              LinkMenuBar bar = existingBar != null ? existingBar : new LinkMenuBar();
-              for (TopMenuItem item : Natives.asList(menu.getItems())) {
-                addMenuLink(bar, item);
-              }
-              if (existingBar == null) {
-                menuBars.put(name, bar);
-                menuLeft.add(bar, name);
-              }
-            }
-          }
-        });
-  }
-
-  public static void refreshUserPreferences() {
-    if (isSignedIn()) {
-      AccountApi.self()
-          .view("preferences")
-          .get(
-              new GerritCallback<GeneralPreferences>() {
-                @Override
-                public void onSuccess(GeneralPreferences prefs) {
-                  setUserPreferences(prefs);
-                }
-              });
-    } else {
-      setUserPreferences(GeneralPreferences.createDefault());
-    }
-  }
-
-  public static void setUserPreferences(GeneralPreferences prefs) {
-    myPrefs = prefs;
-    applyUserPreferences();
-    refreshMenuBar();
-  }
-
-  private static void applyUserPreferences() {
-    CopyableLabel.setFlashEnabled(myPrefs.useFlashClipboard());
-    if (siteHeader != null) {
-      siteHeader.setVisible(myPrefs.showSiteHeader());
-    }
-    if (siteFooter != null) {
-      siteFooter.setVisible(myPrefs.showSiteHeader());
-    }
-    FormatUtil.setPreferences(myPrefs);
-    urlAliasMatcher.updateUserAliases(myPrefs.urlAliases());
-  }
-
-  public static boolean hasDocSearch() {
-    return docSearch;
-  }
-
-  private static void getDocIndex(AsyncCallback<DocInfo> cb) {
-    RequestBuilder req = new RequestBuilder(RequestBuilder.HEAD, GWT.getHostPageBaseURL() + INDEX);
-    req.setCallback(
-        new RequestCallback() {
-          @Override
-          public void onResponseReceived(Request req, Response resp) {
-            switch (resp.getStatusCode()) {
-              case Response.SC_OK:
-              case Response.SC_MOVED_PERMANENTLY:
-              case Response.SC_MOVED_TEMPORARILY:
-                cb.onSuccess(DocInfo.create());
-                break;
-              default:
-                cb.onSuccess(null);
-                break;
-            }
-          }
-
-          @Override
-          public void onError(Request request, Throwable e) {
-            cb.onFailure(e);
-          }
-        });
-    try {
-      req.send();
-    } catch (RequestException e) {
-      cb.onFailure(e);
-    }
-  }
-
-  private static void whoAmI(boolean canLogOut) {
-    AccountInfo account = getUserAccount();
-    final UserPopupPanel userPopup = new UserPopupPanel(account, canLogOut, true);
-    final FlowPanel userSummaryPanel = new FlowPanel();
-    class PopupHandler implements KeyDownHandler, ClickHandler {
-      private void showHidePopup() {
-        if (userPopup.isShowing() && userPopup.isVisible()) {
-          userPopup.hide();
-        } else {
-          userPopup.showRelativeTo(userSummaryPanel);
-        }
-      }
-
-      @Override
-      public void onClick(ClickEvent event) {
-        showHidePopup();
-      }
-
-      @Override
-      public void onKeyDown(KeyDownEvent event) {
-        if (event.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
-          showHidePopup();
-          event.preventDefault();
-        }
-      }
-    }
-    final PopupHandler popupHandler = new PopupHandler();
-    final InlineLabel l = new InlineLabel(FormatUtil.name(account));
-    l.setStyleName(RESOURCES.css().menuBarUserName());
-    final AvatarImage avatar = new AvatarImage(account, 26, false);
-    avatar.setStyleName(RESOURCES.css().menuBarUserNameAvatar());
-    userSummaryPanel.setStyleName(RESOURCES.css().menuBarUserNamePanel());
-    userSummaryPanel.add(l);
-    userSummaryPanel.add(avatar);
-    // "BLACK DOWN-POINTING SMALL TRIANGLE"
-    userSummaryPanel.add(new InlineLabel(" \u25be"));
-    userPopup.addAutoHidePartner(userSummaryPanel.getElement());
-    FocusPanel fp = new FocusPanel(userSummaryPanel);
-    fp.setStyleName(RESOURCES.css().menuBarUserNameFocusPanel());
-    fp.addKeyDownHandler(popupHandler);
-    fp.addClickHandler(popupHandler);
-    menuRight.add(fp);
-  }
-
-  private static Anchor anchor(String text, String to) {
-    final Anchor a = new Anchor(text, to);
-    a.setStyleName(RESOURCES.css().menuItem());
-    Roles.getMenuitemRole().set(a.getElement());
-    return a;
-  }
-
-  private static LinkMenuItem addLink(final LinkMenuBar m, String text, String historyToken) {
-    LinkMenuItem i = new LinkMenuItem(text, historyToken);
-    m.addItem(i);
-    return i;
-  }
-
-  private static void insertLink(
-      final LinkMenuBar m, String text, String historyToken, int beforeIndex) {
-    m.insertItem(new LinkMenuItem(text, historyToken), beforeIndex);
-  }
-
-  private static LinkMenuItem addProjectLink(LinkMenuBar m, TopMenuItem item) {
-    LinkMenuItem i =
-        new ProjectLinkMenuItem(item.getName(), item.getUrl()) {
-          @Override
-          protected void onScreenLoad(Project.NameKey project) {
-            String p = panel.replace(PROJECT_NAME_MENU_VAR, URL.encodeQueryString(project.get()));
-            if (!panel.startsWith("/x/") && !isAbsolute(panel)) {
-              UrlBuilder builder = new UrlBuilder();
-              builder.setProtocol(Location.getProtocol());
-              builder.setHost(Location.getHost());
-              String port = Location.getPort();
-              if (port != null && !port.isEmpty()) {
-                builder.setPort(Integer.parseInt(port));
-              }
-              builder.setPath(Location.getPath());
-              p = builder.buildString() + p;
-            }
-            getElement().setPropertyString("href", p);
-          }
-
-          @Override
-          public void go() {
-            String href = getElement().getPropertyString("href");
-            if (href.startsWith("#")) {
-              super.go();
-            } else {
-              Window.open(href, getElement().getPropertyString("target"), "");
-            }
-          }
-        };
-    if (item.getTarget() != null && !item.getTarget().isEmpty()) {
-      i.getElement().setAttribute("target", item.getTarget());
-    }
-    if (item.getId() != null) {
-      i.getElement().setAttribute("id", item.getId());
-    }
-    m.addItem(i);
-    return i;
-  }
-
-  private static void addDocLink(LinkMenuBar m, String text, String href) {
-    final Anchor atag = anchor(text, docUrl + href);
-    atag.setTarget("_blank");
-    m.add(atag);
-  }
-
-  private static void addMenuLink(LinkMenuBar m, TopMenuItem item) {
-    if (item.getUrl().contains(PROJECT_NAME_MENU_VAR)) {
-      addProjectLink(m, item);
-    } else {
-      addExtensionLink(m, item);
-    }
-  }
-
-  private static void addExtensionLink(LinkMenuBar m, TopMenuItem item) {
-    if (item.getUrl().startsWith("#") && (item.getTarget() == null || item.getTarget().isEmpty())) {
-      LinkMenuItem a = new LinkMenuItem(item.getName(), item.getUrl().substring(1));
-      if (item.getId() != null) {
-        a.getElement().setAttribute("id", item.getId());
-      }
-      m.addItem(a);
-    } else {
-      Anchor atag =
-          anchor(
-              item.getName(),
-              isAbsolute(item.getUrl()) ? item.getUrl() : selfRedirect(item.getUrl()));
-      if (item.getTarget() != null && !item.getTarget().isEmpty()) {
-        atag.setTarget(item.getTarget());
-      }
-      if (item.getId() != null) {
-        atag.getElement().setAttribute("id", item.getId());
-      }
-      m.add(atag);
-    }
-  }
-
-  private static boolean isAbsolute(String url) {
-    return url.matches("^https?://.*");
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
deleted file mode 100644
index eae3431..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
+++ /dev/null
@@ -1,199 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gwt.i18n.client.Constants;
-
-public interface GerritConstants extends Constants {
-  String menuSignIn();
-
-  String menuRegister();
-
-  String reportBug();
-
-  String loadingPlugins();
-
-  String signInDialogTitle();
-
-  String signInDialogGoAnonymous();
-
-  String linkIdentityDialogTitle();
-
-  String registerDialogTitle();
-
-  String loginTypeUnsupported();
-
-  String errorTitle();
-
-  String errorDialogContinue();
-
-  String warnTitle();
-
-  String confirmationDialogOk();
-
-  String confirmationDialogCancel();
-
-  String branchCreationDialogTitle();
-
-  String branchCreationConfirmationMessage();
-
-  String tagCreationDialogTitle();
-
-  String tagCreationConfirmationMessage();
-
-  String branchDeletionDialogTitle();
-
-  String branchDeletionConfirmationMessage();
-
-  String tagDeletionDialogTitle();
-
-  String tagDeletionConfirmationMessage();
-
-  String newUi();
-
-  String notSignedInTitle();
-
-  String notSignedInBody();
-
-  String notFoundTitle();
-
-  String notFoundBody();
-
-  String noSuchAccountTitle();
-
-  String noSuchGroupTitle();
-
-  String inactiveAccountBody();
-
-  String labelNotApplicable();
-
-  String menuAll();
-
-  String menuAllOpen();
-
-  String menuAllMerged();
-
-  String menuAllAbandoned();
-
-  String menuMine();
-
-  String menuMyChanges();
-
-  String menuMyWatchedChanges();
-
-  String menuMyStarredChanges();
-
-  String menuMyDraftComments();
-
-  String menuDiff();
-
-  String menuDiffCommit();
-
-  String menuDiffPreferences();
-
-  String menuDiffPatchSets();
-
-  String menuDiffFiles();
-
-  String menuProjects();
-
-  String menuProjectsList();
-
-  String menuProjectsInfo();
-
-  String menuProjectsBranches();
-
-  String menuProjectsTags();
-
-  String menuProjectsAccess();
-
-  String menuProjectsDashboards();
-
-  String menuProjectsCreate();
-
-  String menuPeople();
-
-  String menuPeopleGroupsList();
-
-  String menuPeopleGroupsCreate();
-
-  String menuPlugins();
-
-  String menuPluginsInstalled();
-
-  String menuDocumentation();
-
-  String menuDocumentationTOC();
-
-  String menuDocumentationSearch();
-
-  String menuDocumentationUpload();
-
-  String menuDocumentationAccess();
-
-  String menuDocumentationAPI();
-
-  String menuDocumentationProjectOwnerGuide();
-
-  String searchHint();
-
-  String searchButton();
-
-  String rpcStatusWorking();
-
-  String sectionNavigation();
-
-  String sectionActions();
-
-  String keySearch();
-
-  String keyEditor();
-
-  String keyHelp();
-
-  String sectionJumping();
-
-  String jumpAllOpen();
-
-  String jumpAllMerged();
-
-  String jumpAllAbandoned();
-
-  String jumpMine();
-
-  String jumpMineWatched();
-
-  String jumpMineStarred();
-
-  String jumpMineDraftComments();
-
-  String projectAccessError();
-
-  String projectAccessProposeForReviewHint();
-
-  String userCannotVoteToolTip();
-
-  String stringListPanelAdd();
-
-  String stringListPanelDelete();
-
-  String stringListPanelUp();
-
-  String stringListPanelDown();
-
-  String searchDropdownChanges();
-
-  String searchDropdownDoc();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
deleted file mode 100644
index 2819d22..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
+++ /dev/null
@@ -1,121 +0,0 @@
-menuSignIn = Sign In
-menuRegister = Register
-reportBug = Report Bug
-loadingPlugins = Loading plugins ...
-
-signInDialogTitle = Code Review - Sign In
-signInDialogGoAnonymous = Go Anonymous
-
-linkIdentityDialogTitle = Code Review - Link Identity
-registerDialogTitle = Code Review - Register New Account
-loginTypeUnsupported = Sign in is not available.
-
-errorTitle = Code Review - Error
-errorDialogContinue = Continue
-warnTitle = Code Review - Warning
-
-confirmationDialogOk = OK
-confirmationDialogCancel = Cancel
-
-branchCreationDialogTitle = Branch Creation
-branchCreationConfirmationMessage = The following branch was successfully created:
-
-tagCreationDialogTitle = Tag Creation
-tagCreationConfirmationMessage = The following tag was successfully created:
-
-branchDeletionDialogTitle = Branch Deletion
-branchDeletionConfirmationMessage = Do you really want to delete the following branches?
-
-tagDeletionDialogTitle = Tag Deletion
-tagDeletionConfirmationMessage = Do you really want to delete the following tags?
-
-newUi = Switch to New UI
-
-notSignedInTitle = Code Review - Session Expired
-notSignedInBody = <b>Session Expired</b>\
-<p>You are no longer signed in to Gerrit Code Review.</p>\
-<p>To continue, please sign-in again.</p>
-
-notFoundTitle = Not Found
-notFoundBody = The page you requested was not found, or you do not have permission to view this page.
-noSuchAccountTitle = Code Review - Unknown User
-
-noSuchGroupTitle = Code Review - Unknown Group
-
-inactiveAccountBody = This user is currently inactive.
-
-labelNotApplicable = Label not applicable
-
-menuAll = All
-menuAllOpen = Open
-menuAllMerged = Merged
-menuAllAbandoned = Abandoned
-
-menuMine = My
-menuMyChanges = Changes
-menuMyStarredChanges = Starred Changes
-menuMyWatchedChanges = Watched Changes
-menuMyDraftComments = Draft Comments
-
-menuDiff = Differences
-menuDiffCommit = Commit Message
-menuDiffPreferences = Preferences
-menuDiffPatchSets = Patch Sets
-menuDiffFiles = Files
-
-menuProjects = Projects
-menuProjectsList = List
-menuProjectsInfo = General
-menuProjectsBranches = Branches
-menuProjectsTags = Tags
-menuProjectsAccess = Access
-menuProjectsDashboards = Dashboards
-menuProjectsCreate = Create New Project
-
-menuPeople = People
-menuPeopleGroupsList = List Groups
-menuPeopleGroupsCreate = Create New Group
-
-menuPlugins = Plugins
-menuPluginsInstalled = Installed
-
-menuDocumentation = Documentation
-menuDocumentationTOC = Table of Contents
-menuDocumentationSearch = Searching
-menuDocumentationUpload = Uploading
-menuDocumentationAccess = Access Controls
-menuDocumentationAPI = REST API
-menuDocumentationProjectOwnerGuide = Project Owner Guide
-
-searchHint = Search term
-searchButton = Search
-
-rpcStatusWorking = Working ...
-
-sectionNavigation = Navigation
-sectionActions = Actions
-keySearch = Search
-keyEditor = Open Inline Editor
-keyHelp = Press '?' to view keyboard shortcuts
-
-sectionJumping = Jumping
-jumpAllOpen = Go to all open changes
-jumpAllMerged = Go to all merged changes
-jumpAllAbandoned = Go to all abandoned changes
-jumpMine = Go to my dashboard
-jumpMineWatched = Go to watched changes
-jumpMineStarred = Go to starred changes
-jumpMineDraftComments = Go to draft comments
-
-projectAccessError = You don't have permissions to modify the access rights for the following refs:
-projectAccessProposeForReviewHint = You may propose these modifications to the project owners by clicking on 'Save for Review'.
-
-userCannotVoteToolTip = User cannot vote in this category
-
-stringListPanelAdd = Add
-stringListPanelDelete = Delete
-stringListPanelUp = Up
-stringListPanelDown = Down
-
-searchDropdownChanges = Changes
-searchDropdownDoc = Docs
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
deleted file mode 100644
index 968104c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
+++ /dev/null
@@ -1,299 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gwt.resources.client.CssResource;
-
-public interface GerritCss extends CssResource {
-  String accountDashboard();
-
-  String accountInfoBlock();
-
-  String accountLinkPanel();
-
-  String accountPassword();
-
-  String accountUsername();
-
-  String activeRow();
-
-  String addBranch();
-
-  String addMemberTextBox();
-
-  String addSshKeyPanel();
-
-  String addWatchPanel();
-
-  String avatarInfoPanel();
-
-  String bottomheader();
-
-  String branchTableDeleteButton();
-
-  String branchTablePrevNextLinks();
-
-  String cAPPROVAL();
-
-  String cASSIGNEE();
-
-  String cASSIGNEDTOME();
-
-  String cLastUpdate();
-
-  String cOWNER();
-
-  String cSIZE();
-
-  String cSUBJECT();
-
-  String cSTATUS();
-
-  String changeSize();
-
-  String changeTable();
-
-  String changeTablePrevNextLinks();
-
-  String commentedActionDialog();
-
-  String commentedActionMessage();
-
-  String contributorAgreementAlreadySubmitted();
-
-  String contributorAgreementButton();
-
-  String contributorAgreementLegal();
-
-  String contributorAgreementShortDescription();
-
-  String createProjectPanel();
-
-  String dataCell();
-
-  String dataCellHidden();
-
-  String dataHeader();
-
-  String dataHeaderHidden();
-
-  String downloadBox();
-
-  String downloadBoxTable();
-
-  String downloadBoxTableCommandColumn();
-
-  String downloadBoxSpacer();
-
-  String downloadBoxScheme();
-
-  String downloadBoxCopyLabel();
-
-  String downloadLink();
-
-  String downloadLinkCopyLabel();
-
-  String downloadLinkHeader();
-
-  String downloadLinkHeaderGap();
-
-  String downloadLinkList();
-
-  String downloadLink_Active();
-
-  String editHeadButton();
-
-  String emptySection();
-
-  String errorDialog();
-
-  String errorDialogButtons();
-
-  String errorDialogErrorType();
-
-  String errorDialogGlass();
-
-  String errorDialogTitle();
-
-  String extensionPanel();
-
-  String loadingPluginsDialog();
-
-  String gerritBody();
-
-  String gerritTopMenu();
-
-  String greenCheckClass();
-
-  String groupDescriptionPanel();
-
-  String groupIncludesTable();
-
-  String groupMembersTable();
-
-  String groupName();
-
-  String groupNamePanel();
-
-  String groupNameTextBox();
-
-  String groupOptionsPanel();
-
-  String groupOwnerPanel();
-
-  String groupOwnerTextBox();
-
-  String groupUUIDPanel();
-
-  String header();
-
-  String iconCell();
-
-  String iconHeader();
-
-  String identityUntrustedExternalId();
-
-  String infoBlock();
-
-  String inputFieldTypeHint();
-
-  String labelNotApplicable();
-
-  String leftMostCell();
-
-  String link();
-
-  String linkMenuBar();
-
-  String linkMenuItemNotLast();
-
-  String maxObjectSizeLimitEffectiveLabel();
-
-  String menuBarUserName();
-
-  String menuBarUserNameAvatar();
-
-  String menuBarUserNameFocusPanel();
-
-  String menuBarUserNamePanel();
-
-  String menuItem();
-
-  String menuScreenMenuBar();
-
-  String needsReview();
-
-  String negscore();
-
-  String oauthExpires();
-
-  String oauthInfoBlock();
-
-  String oauthPanel();
-
-  String oauthPanelCookieEntry();
-
-  String oauthPanelCookieHeading();
-
-  String oauthPanelNetRCEntry();
-
-  String oauthPanelNetRCHeading();
-
-  String oauthToken();
-
-  String pagingLink();
-
-  String patchSetActions();
-
-  String pluginProjectConfigInheritedValue();
-
-  String pluginsTable();
-
-  String posscore();
-
-  String projectActions();
-
-  String projectFilterLabel();
-
-  String projectFilterPanel();
-
-  String projectNameColumn();
-
-  String queryIcon();
-
-  String rebaseContentPanel();
-
-  String rebaseSuggestBox();
-
-  String registerScreenExplain();
-
-  String registerScreenNextLinks();
-
-  String registerScreenSection();
-
-  String rpcStatus();
-
-  String screen();
-
-  String screenHeader();
-
-  String searchPanel();
-
-  String suggestBoxPopup();
-
-  String sectionHeader();
-
-  String singleLine();
-
-  String smallHeading();
-
-  String specialBranchDataCell();
-
-  String specialBranchIconCell();
-
-  String sshHostKeyPanel();
-
-  String sshHostKeyPanelFingerprintData();
-
-  String sshHostKeyPanelHeading();
-
-  String sshHostKeyPanelKnownHostEntry();
-
-  String sshKeyPanelEncodedKey();
-
-  String sshKeyPanelInvalid();
-
-  String sshKeyTable();
-
-  String stringListPanelButtons();
-
-  String topmenu();
-
-  String topmenuMenuLeft();
-
-  String topmenuMenuRight();
-
-  String topmenuTDglue();
-
-  String topmenuTDmenu();
-
-  String topmost();
-
-  String userInfoPopup();
-
-  String usernameField();
-
-  String watchedProjectFilter();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
deleted file mode 100644
index 980529a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gwt.i18n.client.Messages;
-
-public interface GerritMessages extends Messages {
-  String windowTitle1(String hostname);
-
-  String windowTitle2(String section, String hostname);
-
-  String poweredBy(String version);
-
-  String noSuchAccountMessage(String who);
-
-  String noSuchGroupMessage(String who);
-
-  String nameAlreadyUsedBody(String alreadyUsedName);
-
-  String branchCreationFailed(String branchName, String error);
-
-  String invalidBranchName(String branchName);
-
-  String invalidRevision(String revision);
-
-  String branchCreationNotAllowedUnderRefnamePrefix(String refnamePrefix);
-
-  String branchAlreadyExists(String branchName);
-
-  String branchCreationConflict(String branchName, String existingBranchName);
-
-  String pluginFailed(String scriptPath);
-
-  String cannotDownloadPlugin(String scriptPath);
-
-  String parentUpdateFailed(String message);
-
-  String fileCount(int fileNumber, int fileCount);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties
deleted file mode 100644
index b2d67b8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties
+++ /dev/null
@@ -1,21 +0,0 @@
-windowTitle1 = {0} Code Review
-windowTitle2 = {0} | {1} Code Review
-poweredBy = Powered by <a href="https://www.gerritcodereview.com/" target="_blank">Gerrit Code Review</a> ({0})
-
-noSuchAccountMessage = {0} is not a registered user.
-noSuchGroupMessage = Group {0} does not exist or is not visible to you.
-nameAlreadyUsedBody = The name {0} is already in use.
-
-branchCreationFailed = Creating branch {0} failed. Error: {1}
-invalidBranchName = The branch name {0} is not valid.
-invalidRevision = The revision {0} is not valid.
-branchCreationNotAllowedUnderRefnamePrefix = Branch creation is not allowed under {0}.
-branchAlreadyExists = A branch with the name {0} already exists.
-branchCreationConflict = Cannot create branch {0} since it conflicts with branch {1}.
-
-pluginFailed = Plugin "{0}" failed to load
-cannotDownloadPlugin = Cannot load plugin from {0}
-
-parentUpdateFailed = Setting parent project failed: {0}
-
-fileCount = File {0} of {1}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritResources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritResources.java
deleted file mode 100644
index 01be2f2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritResources.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gwt.resources.client.CssResource;
-
-public interface GerritResources extends Resources {
-  @Source("gerrit.css")
-  GerritCss css();
-
-  @Source("gwt_override.css")
-  CssResource gwt_override();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/HostPageDataService.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/HostPageDataService.java
deleted file mode 100644
index bd3d483..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/HostPageDataService.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gerrit.common.data.HostPageData;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.HostPageCache;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-
-@RpcImpl(version = Version.V2_0)
-interface HostPageDataService extends RemoteJsonService {
-  @HostPageCache(name = "gerrit_hostpagedata", once = true)
-  void load(AsyncCallback<HostPageData> callback);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java
deleted file mode 100644
index a4879ca..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.CompoundKeyCommand;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-
-public class JumpKeys {
-  private static HandlerRegistration activeHandler;
-  private static KeyCommandSet keys;
-  private static Widget bodyWidget;
-
-  public static void enable(boolean enable) {
-    if (enable && activeHandler == null) {
-      activeHandler = GlobalKey.add(bodyWidget, keys);
-    } else if (!enable && activeHandler != null) {
-      activeHandler.removeHandler();
-      activeHandler = null;
-    }
-  }
-
-  static void register(Widget body) {
-    final KeyCommandSet jumps = new KeyCommandSet();
-
-    jumps.add(
-        new KeyCommand(0, 'o', Gerrit.C.jumpAllOpen()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            Gerrit.display(PageLinks.toChangeQuery("status:open"));
-          }
-        });
-    jumps.add(
-        new KeyCommand(0, 'm', Gerrit.C.jumpAllMerged()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            Gerrit.display(PageLinks.toChangeQuery("status:merged"));
-          }
-        });
-    jumps.add(
-        new KeyCommand(0, 'a', Gerrit.C.jumpAllAbandoned()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
-          }
-        });
-
-    if (Gerrit.isSignedIn()) {
-      jumps.add(
-          new KeyCommand(0, 'i', Gerrit.C.jumpMine()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              Gerrit.display(PageLinks.MINE);
-            }
-          });
-      jumps.add(
-          new KeyCommand(0, 'c', Gerrit.C.jumpMineDraftComments()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              Gerrit.display(PageLinks.toChangeQuery("has:draft"));
-            }
-          });
-      jumps.add(
-          new KeyCommand(0, 'w', Gerrit.C.jumpMineWatched()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              Gerrit.display(PageLinks.toChangeQuery("is:watched status:open"));
-            }
-          });
-      jumps.add(
-          new KeyCommand(0, 's', Gerrit.C.jumpMineStarred()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              Gerrit.display(PageLinks.toChangeQuery("is:starred"));
-            }
-          });
-    }
-
-    keys = new KeyCommandSet(Gerrit.C.sectionJumping());
-    keys.add(new CompoundKeyCommand(0, 'g', "", jumps));
-    bodyWidget = body;
-    activeHandler = GlobalKey.add(body, keys);
-  }
-
-  private JumpKeys() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java
deleted file mode 100644
index 36fa6e8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java
+++ /dev/null
@@ -1,91 +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.client;
-
-import com.google.gerrit.common.data.HostPageData;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.Cookies;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.RootPanel;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.ArrayList;
-import java.util.List;
-
-/** Displays pending messages from the server. */
-class MessageOfTheDayBar extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, MessageOfTheDayBar> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  private final List<HostPageData.Message> motd;
-  @UiField HTML message;
-  @UiField Anchor dismiss;
-
-  MessageOfTheDayBar(List<HostPageData.Message> motd) {
-    this.motd = filter(motd);
-    initWidget(uiBinder.createAndBindUi(this));
-
-    SafeHtmlBuilder b = new SafeHtmlBuilder();
-    if (this.motd.size() == 1) {
-      b.append(SafeHtml.asis(this.motd.get(0).html));
-    } else {
-      for (HostPageData.Message m : this.motd) {
-        b.openDiv();
-        b.append(SafeHtml.asis(m.html));
-        b.openElement("hr");
-        b.closeSelf();
-        b.closeDiv();
-      }
-    }
-    message.setHTML(b);
-  }
-
-  void show() {
-    if (!motd.isEmpty()) {
-      RootPanel.get().add(this);
-    }
-  }
-
-  @UiHandler("dismiss")
-  void onDismiss(@SuppressWarnings("unused") ClickEvent e) {
-    removeFromParent();
-
-    for (HostPageData.Message m : motd) {
-      Cookies.setCookie(cookieName(m), "1", m.redisplay);
-    }
-  }
-
-  private static List<HostPageData.Message> filter(List<HostPageData.Message> in) {
-    List<HostPageData.Message> show = new ArrayList<>();
-    for (HostPageData.Message m : in) {
-      if (Cookies.getCookie(cookieName(m)) == null) {
-        show.add(m);
-      }
-    }
-    return show;
-  }
-
-  private static String cookieName(HostPageData.Message m) {
-    return "msg-" + m.id;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.ui.xml
deleted file mode 100644
index 36d08b1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.ui.xml
+++ /dev/null
@@ -1,71 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-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.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:style gss='false'>
-    .popup {
-      position: fixed;
-      top: 5px;
-      left: 50%;
-      margin-left: -200px;
-      z-index: 201;
-      padding-top: 5px;
-      padding-bottom: 5px;
-      padding-left: 12px;
-      padding-right: 12px;
-      background: #FFF1A8;
-      border-radius: 10px;
-    }
-
-    @if user.agent safari {
-      .popup {
-        \-webkit-border-radius: 10px;
-      }
-    }
-    @if user.agent gecko1_8 {
-      .popup {
-        \-moz-border-radius: 10px;
-      }
-    }
-
-    .message {
-      display: inline;
-    }
-    .message a {
-      color: #222;
-      text-decoration: underline;
-    }
-    a.action {
-      color: #222;
-      text-decoration: underline;
-      display: inline-block;
-      margin-left: 0.5em;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.popup}'>
-    <g:HTML ui:field='message' styleName='{style.message}'/>
-    <g:Anchor ui:field='dismiss'
-        styleName='{style.action}'
-        href='javascript:;'
-        title='Hide this message'>
-      <ui:attribute name='title'/>
-      Dismiss
-    </g:Anchor>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotFoundScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotFoundScreen.java
deleted file mode 100644
index 29bbf85..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotFoundScreen.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gerrit.client.ui.Screen;
-import com.google.gwt.user.client.ui.Label;
-
-/** Displays an error message letting the user know the page doesn't exist. */
-public class NotFoundScreen extends Screen {
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setPageTitle(Gerrit.C.notFoundTitle());
-    add(new Label(Gerrit.C.notFoundBody()));
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    display();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotSignedInDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotSignedInDialog.java
deleted file mode 100644
index cd5197a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotSignedInDialog.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-
-/** A dialog box telling the user they are not signed in. */
-public class NotSignedInDialog extends PopupPanel implements CloseHandler<PopupPanel> {
-  private Button signin;
-  private boolean buttonClicked;
-
-  public NotSignedInDialog() {
-    super(/* auto hide */ false, /* modal */ true);
-    setGlassEnabled(true);
-    getGlassElement().addClassName(Gerrit.RESOURCES.css().errorDialogGlass());
-    addStyleName(Gerrit.RESOURCES.css().errorDialog());
-
-    final FlowPanel buttons = new FlowPanel();
-    signin = new Button();
-    signin.setText(Gerrit.C.menuSignIn());
-    signin.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            buttonClicked = true;
-            hide();
-            Gerrit.doSignIn(History.getToken());
-          }
-        });
-    buttons.add(signin);
-
-    final Button close = new Button();
-    close.getElement().getStyle().setProperty("marginLeft", "200px");
-    close.setText(Gerrit.C.signInDialogGoAnonymous());
-    close.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            buttonClicked = true;
-            Gerrit.deleteSessionCookie();
-            hide();
-          }
-        });
-    buttons.add(close);
-
-    Label title = new Label(Gerrit.C.notSignedInTitle());
-    title.setStyleName(Gerrit.RESOURCES.css().errorDialogTitle());
-
-    FlowPanel center = new FlowPanel();
-    center.add(title);
-    center.add(new HTML(Gerrit.C.notSignedInBody()));
-    center.add(buttons);
-    add(center);
-
-    int l = Window.getScrollLeft() + 20;
-    int t = Window.getScrollTop() + 20;
-    setPopupPosition(l, t);
-    addCloseHandler(this);
-  }
-
-  @Override
-  public void onClose(CloseEvent<PopupPanel> event) {
-    if (!buttonClicked) {
-      // the dialog was closed without one of the buttons being pressed
-      // e.g. the user pressed ESC to close the dialog
-      Gerrit.deleteSessionCookie();
-    }
-  }
-
-  @Override
-  public void center() {
-    show();
-    GlobalKey.dialog(this);
-    signin.setFocus(true);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java
deleted file mode 100644
index 42454a3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class RangeInfo extends JavaScriptObject {
-  public final native int start() /*-{ return this.start; }-*/;
-
-  public final native int end() /*-{ return this.end; }-*/;
-
-  protected RangeInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RpcStatus.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RpcStatus.java
deleted file mode 100644
index 4153439..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RpcStatus.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.RootPanel;
-import com.google.gwtjsonrpc.client.event.RpcCompleteEvent;
-import com.google.gwtjsonrpc.client.event.RpcCompleteHandler;
-import com.google.gwtjsonrpc.client.event.RpcStartEvent;
-import com.google.gwtjsonrpc.client.event.RpcStartHandler;
-
-public class RpcStatus implements RpcStartHandler, RpcCompleteHandler {
-  public static RpcStatus INSTANCE;
-
-  private static int hideDepth;
-
-  /** Execute code, hiding the RPCs they execute from being shown visually. */
-  public static void hide(Runnable run) {
-    try {
-      hideDepth++;
-      run.run();
-    } finally {
-      hideDepth--;
-    }
-  }
-
-  private final Label loading;
-  private int activeCalls;
-
-  RpcStatus() {
-    loading = new InlineLabel();
-    loading.setText(Gerrit.C.rpcStatusWorking());
-    loading.setStyleName(Gerrit.RESOURCES.css().rpcStatus());
-    loading.setVisible(false);
-    RootPanel.get().add(loading);
-  }
-
-  @Override
-  public void onRpcStart(RpcStartEvent event) {
-    onRpcStart();
-  }
-
-  public void onRpcStart() {
-    if (++activeCalls == 1) {
-      if (hideDepth == 0) {
-        loading.setVisible(true);
-      }
-    }
-  }
-
-  @Override
-  public void onRpcComplete(RpcCompleteEvent event) {
-    onRpcComplete();
-  }
-
-  public void onRpcComplete() {
-    if (--activeCalls == 0) {
-      loading.setVisible(false);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
deleted file mode 100644
index 406ab4e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
+++ /dev/null
@@ -1,163 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gerrit.client.changes.QueryScreen;
-import com.google.gerrit.client.ui.HintTextBox;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.SuggestBox;
-import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-
-class SearchPanel extends Composite {
-  private final HintTextBox searchBox;
-  private final ListBox dropdown;
-  private HandlerRegistration regFocus;
-
-  SearchPanel() {
-    final FlowPanel body = new FlowPanel();
-    initWidget(body);
-    setStyleName(Gerrit.RESOURCES.css().searchPanel());
-
-    searchBox = new HintTextBox();
-    final MySuggestionDisplay suggestionDisplay = new MySuggestionDisplay();
-    searchBox.addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-              if (!suggestionDisplay.isSuggestionSelected) {
-                doSearch();
-              }
-            }
-          }
-        });
-
-    if (Gerrit.hasDocSearch()) {
-      dropdown = new ListBox();
-      dropdown.setStyleName("searchDropdown");
-      dropdown.addItem(Gerrit.C.searchDropdownChanges());
-      dropdown.addItem(Gerrit.C.searchDropdownDoc());
-      dropdown.setVisibleItemCount(1);
-      dropdown.setSelectedIndex(0);
-    } else {
-      // Doc search is NOT available.
-      dropdown = null;
-    }
-
-    final SuggestBox suggestBox =
-        new SuggestBox(new SearchSuggestOracle(), searchBox, suggestionDisplay);
-    searchBox.setStyleName("searchTextBox");
-    searchBox.setVisibleLength(70);
-    searchBox.setHintText(Gerrit.C.searchHint());
-
-    final Button searchButton = new Button(Gerrit.C.searchButton());
-    searchButton.setStyleName("searchButton");
-    searchButton.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doSearch();
-          }
-        });
-
-    body.add(suggestBox);
-    if (dropdown != null) {
-      body.add(dropdown);
-    }
-    body.add(searchButton);
-  }
-
-  void setText(String query) {
-    searchBox.setText(query);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    if (regFocus == null) {
-      regFocus =
-          GlobalKey.addApplication(
-              this,
-              new KeyCommand(0, '/', Gerrit.C.keySearch()) {
-                @Override
-                public void onKeyPress(KeyPressEvent event) {
-                  event.preventDefault();
-                  searchBox.setFocus(true);
-                  searchBox.selectAll();
-                }
-              });
-    }
-  }
-
-  @Override
-  protected void onUnload() {
-    if (regFocus != null) {
-      regFocus.removeHandler();
-      regFocus = null;
-    }
-  }
-
-  private void doSearch() {
-    final String query = searchBox.getText().trim();
-    if ("".equals(query)) {
-      return;
-    }
-
-    searchBox.setFocus(false);
-
-    if (dropdown != null && dropdown.getSelectedValue().equals(Gerrit.C.searchDropdownDoc())) {
-      // doc
-      Gerrit.display(PageLinks.toDocumentationQuery(query));
-    } else {
-      // changes
-      if (query.matches("^[1-9][0-9]*$")) {
-        // Query is a change number. Project can't be supplied.
-        Gerrit.display(PageLinks.toChange(null, Change.Id.parse(query)));
-      } else {
-        Gerrit.display(PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
-      }
-    }
-  }
-
-  private static class MySuggestionDisplay extends SuggestBox.DefaultSuggestionDisplay {
-    private boolean isSuggestionSelected;
-
-    private MySuggestionDisplay() {
-      super();
-
-      getPopupPanel().addStyleName(Gerrit.RESOURCES.css().suggestBoxPopup());
-    }
-
-    @Override
-    protected Suggestion getCurrentSelection() {
-      Suggestion currentSelection = super.getCurrentSelection();
-      isSuggestionSelected = currentSelection != null;
-      return currentSelection;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
deleted file mode 100644
index cb3e9f0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
+++ /dev/null
@@ -1,311 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gerrit.client.ui.AccountGroupSuggestOracle;
-import com.google.gerrit.client.ui.AccountSuggestOracle;
-import com.google.gerrit.client.ui.ProjectNameSuggestOracle;
-import com.google.gwt.user.client.ui.SuggestOracle;
-import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.TreeSet;
-
-public class SearchSuggestOracle extends HighlightSuggestOracle {
-  private static final List<ParamSuggester> paramSuggester =
-      Arrays.asList(
-          new ParamSuggester(
-              Arrays.asList("project:", "p:", "parentproject:"), new ProjectNameSuggestOracle()),
-          new ParamSuggester(
-              Arrays.asList(
-                  "owner:",
-                  "o:",
-                  "reviewer:",
-                  "r:",
-                  "commentby:",
-                  "reviewedby:",
-                  "author:",
-                  "committer:",
-                  "from:",
-                  "assignee:",
-                  "cc:"),
-              new AccountSuggestOracle() {
-                @Override
-                public void onRequestSuggestions(Request request, Callback done) {
-                  super.onRequestSuggestions(
-                      request,
-                      new Callback() {
-                        @Override
-                        public void onSuggestionsReady(final Request request, Response response) {
-                          if ("self".startsWith(request.getQuery())) {
-                            final ArrayList<SuggestOracle.Suggestion> r =
-                                new ArrayList<>(response.getSuggestions().size() + 1);
-                            r.add(
-                                new SuggestOracle.Suggestion() {
-                                  @Override
-                                  public String getDisplayString() {
-                                    return getReplacementString();
-                                  }
-
-                                  @Override
-                                  public String getReplacementString() {
-                                    return "self";
-                                  }
-                                });
-                            r.addAll(response.getSuggestions());
-                            response.setSuggestions(r);
-                          }
-                          done.onSuggestionsReady(request, response);
-                        }
-                      });
-                }
-              }),
-          new ParamSuggester(
-              Arrays.asList("ownerin:", "reviewerin:"), new AccountGroupSuggestOracle()));
-
-  private static final TreeSet<String> suggestions = new TreeSet<>();
-
-  static {
-    suggestions.add("age:");
-    suggestions.add("age:1week"); // Give an example age
-
-    suggestions.add("change:");
-
-    suggestions.add("owner:");
-    suggestions.add("owner:self");
-    suggestions.add("ownerin:");
-    suggestions.add("author:");
-    suggestions.add("committer:");
-    suggestions.add("assignee:");
-
-    suggestions.add("reviewer:");
-    suggestions.add("reviewer:self");
-    suggestions.add("reviewerin:");
-    suggestions.add("reviewedby:");
-
-    suggestions.add("commit:");
-    suggestions.add("comment:");
-    suggestions.add("message:");
-    suggestions.add("commentby:");
-    suggestions.add("from:");
-    suggestions.add("file:");
-    suggestions.add("conflicts:");
-    suggestions.add("project:");
-    suggestions.add("projects:");
-    suggestions.add("parentproject:");
-    suggestions.add("branch:");
-    suggestions.add("topic:");
-    suggestions.add("intopic:");
-    suggestions.add("ref:");
-    suggestions.add("tr:");
-    suggestions.add("bug:");
-    suggestions.add("label:");
-    suggestions.add("query:");
-    suggestions.add("has:");
-    suggestions.add("has:draft");
-    suggestions.add("has:edit");
-    suggestions.add("has:star");
-    suggestions.add("has:stars");
-    suggestions.add("has:unresolved");
-    suggestions.add("star:");
-
-    suggestions.add("is:");
-    suggestions.add("is:starred");
-    suggestions.add("is:watched");
-    suggestions.add("is:reviewed");
-    suggestions.add("is:owner");
-    suggestions.add("is:reviewer");
-    suggestions.add("is:open");
-    suggestions.add("is:pending");
-    suggestions.add("is:private");
-    suggestions.add("is:closed");
-    suggestions.add("is:merged");
-    suggestions.add("is:abandoned");
-    suggestions.add("is:mergeable");
-    suggestions.add("is:ignored");
-    suggestions.add("is:wip");
-    suggestions.add("is:assigned");
-
-    suggestions.add("status:");
-    suggestions.add("status:open");
-    suggestions.add("status:pending");
-    suggestions.add("status:reviewed");
-    suggestions.add("status:closed");
-    suggestions.add("status:merged");
-    suggestions.add("status:abandoned");
-
-    suggestions.add("added:");
-    suggestions.add("deleted:");
-    suggestions.add("delta:");
-    suggestions.add("size:");
-
-    suggestions.add("unresolved:");
-
-    suggestions.add("revertof:");
-
-    if (Gerrit.isNoteDbEnabled()) {
-      suggestions.add("cc:");
-      suggestions.add("hashtag:");
-    }
-
-    suggestions.add("is:assigned");
-    suggestions.add("is:unassigned");
-    suggestions.add("assignee:");
-
-    suggestions.add("AND");
-    suggestions.add("OR");
-    suggestions.add("NOT");
-  }
-
-  @Override
-  public void requestDefaultSuggestions(Request request, Callback done) {
-    final ArrayList<SearchSuggestion> r = new ArrayList<>();
-    // No text - show some default suggestions.
-    r.add(new SearchSuggestion("status:open", "status:open"));
-    r.add(new SearchSuggestion("age:1week", "age:1week"));
-    if (Gerrit.isSignedIn()) {
-      r.add(new SearchSuggestion("owner:self", "owner:self"));
-    }
-    done.onSuggestionsReady(request, new Response(r));
-  }
-
-  @Override
-  protected void onRequestSuggestions(Request request, Callback done) {
-    final String query = request.getQuery();
-
-    final String lastWord = getLastWord(query);
-    if (lastWord == null) {
-      // Starting a new word - don't show suggestions yet.
-      done.onSuggestionsReady(request, null);
-      return;
-    }
-
-    for (ParamSuggester ps : paramSuggester) {
-      if (ps.applicable(lastWord)) {
-        ps.suggest(lastWord, request, done);
-        return;
-      }
-    }
-
-    final ArrayList<SearchSuggestion> r = new ArrayList<>();
-    for (String suggestion : suggestions.tailSet(lastWord)) {
-      if ((lastWord.length() < suggestion.length()) && suggestion.startsWith(lastWord)) {
-        if (suggestion.contains("self") && !Gerrit.isSignedIn()) {
-          continue;
-        }
-        r.add(new SearchSuggestion(suggestion, query + suggestion.substring(lastWord.length())));
-      }
-    }
-    done.onSuggestionsReady(request, new Response(r));
-  }
-
-  private String getLastWord(String query) {
-    final int lastSpace = query.lastIndexOf(' ');
-    if (lastSpace == query.length() - 1) {
-      return null;
-    }
-    if (lastSpace == -1) {
-      return query;
-    }
-    return query.substring(lastSpace + 1);
-  }
-
-  @Override
-  protected String getQueryPattern(String query) {
-    return super.getQueryPattern(getLastWord(query));
-  }
-
-  @Override
-  protected boolean isHTML() {
-    return true;
-  }
-
-  private static class SearchSuggestion implements SuggestOracle.Suggestion {
-    private final String suggestion;
-    private final String fullQuery;
-
-    SearchSuggestion(String suggestion, String fullQuery) {
-      this.suggestion = suggestion;
-      // Add a space to the query if it is a complete operation (e.g.
-      // "status:open") so the user can keep on typing.
-      this.fullQuery = fullQuery.endsWith(":") ? fullQuery : fullQuery + " ";
-    }
-
-    @Override
-    public String getDisplayString() {
-      return suggestion;
-    }
-
-    @Override
-    public String getReplacementString() {
-      return fullQuery;
-    }
-  }
-
-  private static class ParamSuggester {
-    private final List<String> operators;
-    private final SuggestOracle parameterSuggestionOracle;
-
-    ParamSuggester(List<String> operators, SuggestOracle parameterSuggestionOracle) {
-      this.operators = operators;
-      this.parameterSuggestionOracle = parameterSuggestionOracle;
-    }
-
-    boolean applicable(String query) {
-      final String operator = getApplicableOperator(query, operators);
-      return operator != null && query.length() > operator.length();
-    }
-
-    private String getApplicableOperator(String lastWord, List<String> operators) {
-      for (String operator : operators) {
-        if (lastWord.startsWith(operator)) {
-          return operator;
-        }
-      }
-      return null;
-    }
-
-    void suggest(String lastWord, Request request, Callback done) {
-      final String operator = getApplicableOperator(lastWord, operators);
-      parameterSuggestionOracle.requestSuggestions(
-          new Request(lastWord.substring(operator.length()), request.getLimit()),
-          new Callback() {
-            @Override
-            public void onSuggestionsReady(Request req, Response response) {
-              final String query = request.getQuery();
-              final List<SearchSuggestOracle.Suggestion> r =
-                  new ArrayList<>(response.getSuggestions().size());
-              for (SearchSuggestOracle.Suggestion s : response.getSuggestions()) {
-                r.add(
-                    new SearchSuggestion(
-                        s.getDisplayString(),
-                        query.substring(0, query.length() - lastWord.length())
-                            + operator
-                            + quoteIfNeeded(s.getReplacementString())));
-              }
-              done.onSuggestionsReady(request, new Response(r));
-            }
-
-            private String quoteIfNeeded(String s) {
-              if (!s.matches("^\\S*$")) {
-                return "\"" + s + "\"";
-              }
-              return s;
-            }
-          });
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java
deleted file mode 100644
index f771fee..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java
+++ /dev/null
@@ -1,359 +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.client;
-
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.user.client.Event;
-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 com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.FocusWidget;
-import com.google.gwt.user.client.ui.HasEnabled;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.ImageResourceRenderer;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-import java.util.ArrayList;
-import java.util.List;
-
-public class StringListPanel extends FlowPanel implements HasEnabled {
-  private final StringListTable t;
-  private HorizontalPanel titlePanel;
-  protected final HorizontalPanel buttonPanel;
-  private final Button deleteButton;
-  private Image info;
-  protected FocusWidget widget;
-
-  public StringListPanel(String title, List<String> fieldNames, FocusWidget w, boolean autoSort) {
-    widget = w;
-    if (title != null) {
-      titlePanel = new HorizontalPanel();
-      SmallHeading titleLabel = new SmallHeading(title);
-      titlePanel.add(titleLabel);
-      add(titlePanel);
-    }
-
-    t = new StringListTable(fieldNames, autoSort);
-    add(t);
-
-    buttonPanel = new HorizontalPanel();
-    buttonPanel.setStyleName(Gerrit.RESOURCES.css().stringListPanelButtons());
-    deleteButton = new Button(Gerrit.C.stringListPanelDelete());
-    deleteButton.setEnabled(false);
-    deleteButton.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            widget.setEnabled(true);
-            t.deleteChecked();
-          }
-        });
-    buttonPanel.add(deleteButton);
-    add(buttonPanel);
-  }
-
-  public void display(List<List<String>> values) {
-    t.display(values);
-  }
-
-  public void setInfo(String msg) {
-    if (info == null && titlePanel != null) {
-      info = new Image(Gerrit.RESOURCES.info());
-      titlePanel.add(info);
-    }
-    if (info != null) {
-      info.setTitle(msg);
-    }
-  }
-
-  public List<List<String>> getValues() {
-    return t.getValues();
-  }
-
-  public List<String> getValues(int i) {
-    List<List<String>> allValuesList = getValues();
-    List<String> singleValueList = new ArrayList<>(allValuesList.size());
-    for (List<String> values : allValuesList) {
-      singleValueList.add(values.get(i));
-    }
-    return singleValueList;
-  }
-
-  private class StringListTable extends NavigationTable<List<String>> {
-    private final Button addButton;
-    private final List<NpTextBox> inputs;
-    private final boolean autoSort;
-
-    StringListTable(List<String> names, boolean autoSort) {
-      this.autoSort = autoSort;
-
-      addButton = new Button(new ImageResourceRenderer().render(Gerrit.RESOURCES.listAdd()));
-      addButton.setTitle(Gerrit.C.stringListPanelAdd());
-      OnEditEnabler e = new OnEditEnabler(addButton);
-      inputs = new ArrayList<>();
-
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().iconHeader());
-      fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().leftMostCell());
-      for (int i = 0; i < names.size(); i++) {
-        fmt.addStyleName(0, i + 1, Gerrit.RESOURCES.css().dataHeader());
-        table.setText(0, i + 1, names.get(i));
-
-        NpTextBox input = new NpTextBox();
-        input.setVisibleLength(35);
-        input.addKeyPressHandler(
-            new KeyPressHandler() {
-              @Override
-              public void onKeyPress(KeyPressEvent event) {
-                if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-                  widget.setEnabled(true);
-                  add();
-                }
-              }
-            });
-        inputs.add(input);
-        fmt.addStyleName(1, i + 1, Gerrit.RESOURCES.css().dataHeader());
-        table.setWidget(1, i + 1, input);
-        e.listenTo(input);
-      }
-      addButton.setEnabled(false);
-
-      addButton.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              widget.setEnabled(true);
-              add();
-            }
-          });
-      fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().iconHeader());
-      fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().leftMostCell());
-      table.setWidget(1, 0, addButton);
-
-      if (!autoSort) {
-        fmt.addStyleName(0, names.size() + 1, Gerrit.RESOURCES.css().iconHeader());
-        fmt.addStyleName(0, names.size() + 2, Gerrit.RESOURCES.css().iconHeader());
-        fmt.addStyleName(1, names.size() + 1, Gerrit.RESOURCES.css().iconHeader());
-        fmt.addStyleName(1, names.size() + 2, Gerrit.RESOURCES.css().iconHeader());
-      }
-    }
-
-    void display(List<List<String>> values) {
-      for (int row = 2; row < table.getRowCount(); row++) {
-        table.removeRow(row--);
-      }
-      int row = 2;
-      for (List<String> v : values) {
-        populate(row, v);
-        row++;
-      }
-      updateNavigationLinks();
-    }
-
-    List<List<String>> getValues() {
-      List<List<String>> values = new ArrayList<>();
-      for (int row = 2; row < table.getRowCount(); row++) {
-        values.add(getRowItem(row));
-      }
-      return values;
-    }
-
-    @Override
-    protected List<String> getRowItem(int row) {
-      List<String> v = new ArrayList<>();
-      for (int i = 0; i < inputs.size(); i++) {
-        v.add(table.getText(row, i + 1));
-      }
-      return v;
-    }
-
-    private void populate(int row, List<String> values) {
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().iconCell());
-      fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().leftMostCell());
-      CheckBox checkBox = new CheckBox();
-      checkBox.addValueChangeHandler(
-          new ValueChangeHandler<Boolean>() {
-            @Override
-            public void onValueChange(ValueChangeEvent<Boolean> event) {
-              enableDelete();
-            }
-          });
-      table.setWidget(row, 0, checkBox);
-      for (int i = 0; i < values.size(); i++) {
-        fmt.addStyleName(row, i + 1, Gerrit.RESOURCES.css().dataCell());
-        table.setText(row, i + 1, values.get(i));
-      }
-      if (!autoSort) {
-        fmt.addStyleName(row, values.size() + 1, Gerrit.RESOURCES.css().iconCell());
-        fmt.addStyleName(row, values.size() + 2, Gerrit.RESOURCES.css().dataCell());
-
-        Image down = new Image(Gerrit.RESOURCES.arrowDown());
-        down.setTitle(Gerrit.C.stringListPanelDown());
-        down.addClickHandler(
-            new ClickHandler() {
-              @Override
-              public void onClick(ClickEvent event) {
-                moveDown(row);
-              }
-            });
-        table.setWidget(row, values.size() + 1, down);
-
-        Image up = new Image(Gerrit.RESOURCES.arrowUp());
-        up.setTitle(Gerrit.C.stringListPanelUp());
-        up.addClickHandler(
-            new ClickHandler() {
-              @Override
-              public void onClick(ClickEvent event) {
-                moveUp(row);
-              }
-            });
-        table.setWidget(row, values.size() + 2, up);
-      }
-    }
-
-    @Override
-    protected void onCellSingleClick(Event event, int row, int column) {
-      if (column == inputs.size() + 1 && row >= 2 && row < table.getRowCount() - 2) {
-        moveDown(row);
-      } else if (column == inputs.size() + 2 && row > 2) {
-        moveUp(row);
-      }
-    }
-
-    void moveDown(int row) {
-      if (row < table.getRowCount() - 1) {
-        swap(row, row + 1);
-      }
-    }
-
-    void moveUp(int row) {
-      if (row > 2) {
-        swap(row - 1, row);
-      }
-    }
-
-    void swap(int row1, int row2) {
-      List<String> value = getRowItem(row1);
-      List<String> nextValue = getRowItem(row2);
-      populate(row1, nextValue);
-      populate(row2, value);
-      updateNavigationLinks();
-      widget.setEnabled(true);
-    }
-
-    private void updateNavigationLinks() {
-      if (!autoSort) {
-        for (int row = 2; row < table.getRowCount(); row++) {
-          table.getWidget(row, inputs.size() + 1).setVisible(row < table.getRowCount() - 1);
-          table.getWidget(row, inputs.size() + 2).setVisible(row > 2);
-        }
-      }
-    }
-
-    void add() {
-      List<String> values = new ArrayList<>();
-      for (NpTextBox input : inputs) {
-        values.add(input.getValue().trim());
-        input.setValue("");
-      }
-      insert(values);
-    }
-
-    void insert(List<String> v) {
-      int insertPos = table.getRowCount();
-      if (autoSort) {
-        for (int row = 1; row < table.getRowCount(); row++) {
-          int compareResult = v.get(0).compareTo(table.getText(row, 1));
-          if (compareResult < 0) {
-            insertPos = row;
-            break;
-          } else if (compareResult == 0) {
-            return;
-          }
-        }
-      }
-      table.insertRow(insertPos);
-      populate(insertPos, v);
-      updateNavigationLinks();
-    }
-
-    void enableDelete() {
-      for (int row = 2; row < table.getRowCount(); row++) {
-        if (((CheckBox) table.getWidget(row, 0)).getValue()) {
-          deleteButton.setEnabled(true);
-          return;
-        }
-      }
-      deleteButton.setEnabled(false);
-    }
-
-    void deleteChecked() {
-      deleteButton.setEnabled(false);
-      for (int row = 2; row < table.getRowCount(); row++) {
-        if (((CheckBox) table.getWidget(row, 0)).getValue()) {
-          table.removeRow(row--);
-        }
-      }
-      updateNavigationLinks();
-    }
-
-    @Override
-    protected void onOpenRow(int row) {}
-
-    @Override
-    protected Object getRowItemKey(List<String> item) {
-      return item.get(0);
-    }
-
-    void setEnabled(boolean enabled) {
-      addButton.setVisible(enabled);
-      for (NpTextBox input : inputs) {
-        input.setEnabled(enabled);
-      }
-      for (int row = 2; row < table.getRowCount(); row++) {
-        table.getWidget(row, 0).setVisible(enabled);
-        if (!autoSort) {
-          table.getWidget(row, inputs.size() + 1).setVisible(enabled);
-          table.getWidget(row, inputs.size() + 2).setVisible(enabled);
-        }
-      }
-      if (enabled) {
-        updateNavigationLinks();
-      }
-    }
-  }
-
-  @Override
-  public boolean isEnabled() {
-    return deleteButton.isVisible();
-  }
-
-  @Override
-  public void setEnabled(boolean enabled) {
-    t.setEnabled(enabled);
-    deleteButton.setVisible(enabled);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Themer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Themer.java
deleted file mode 100644
index d87c0b8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Themer.java
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gerrit.client.projects.ThemeInfo;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.StyleElement;
-
-public class Themer {
-  public static class ThemerIE extends Themer {
-    protected ThemerIE() {}
-
-    @Override
-    protected String getCssText(StyleElement el) {
-      return el.getCssText();
-    }
-
-    @Override
-    protected void setCssText(StyleElement el, String css) {
-      el.setCssText(css);
-    }
-  }
-
-  protected StyleElement cssElement;
-  protected Element headerElement;
-  protected Element footerElement;
-  protected String cssText;
-  protected String headerHtml;
-  protected String footerHtml;
-
-  protected Themer() {}
-
-  public void set(ThemeInfo theme) {
-    if (theme != null) {
-      set(
-          theme.css() != null ? theme.css() : cssText,
-          theme.header() != null ? theme.header() : headerHtml,
-          theme.footer() != null ? theme.footer() : footerHtml);
-    } else {
-      set(cssText, headerHtml, footerHtml);
-    }
-  }
-
-  public void clear() {
-    set(null);
-  }
-
-  void init(Element css, Element header, Element footer) {
-    cssElement = StyleElement.as(css);
-    headerElement = header;
-    footerElement = footer;
-
-    cssText = getCssText(this.cssElement);
-    headerHtml = header.getInnerHTML();
-    footerHtml = footer.getInnerHTML();
-  }
-
-  protected String getCssText(StyleElement el) {
-    return el.getInnerHTML();
-  }
-
-  protected void setCssText(StyleElement el, String css) {
-    el.setInnerHTML(css);
-  }
-
-  private void set(String css, String header, String footer) {
-    setCssText(cssElement, css);
-    headerElement.setInnerHTML(header);
-    footerElement.setInnerHTML(footer);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UrlAliasMatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UrlAliasMatcher.java
deleted file mode 100644
index acaaf46..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UrlAliasMatcher.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gwt.regexp.shared.RegExp;
-import java.util.HashMap;
-import java.util.Map;
-
-public class UrlAliasMatcher {
-  private final Map<RegExp, String> userUrlAliases;
-  private final Map<RegExp, String> globalUrlAliases;
-
-  UrlAliasMatcher(Map<String, String> globalUrlAliases) {
-    this.globalUrlAliases = compile(globalUrlAliases);
-    this.userUrlAliases = new HashMap<>();
-  }
-
-  private static Map<RegExp, String> compile(Map<String, String> urlAliases) {
-    Map<RegExp, String> compiledUrlAliases = new HashMap<>();
-    if (urlAliases != null) {
-      for (Map.Entry<String, String> e : urlAliases.entrySet()) {
-        compiledUrlAliases.put(RegExp.compile(e.getKey()), e.getValue());
-      }
-    }
-    return compiledUrlAliases;
-  }
-
-  void clearUserAliases() {
-    this.userUrlAliases.clear();
-  }
-
-  void updateUserAliases(Map<String, String> userUrlAliases) {
-    clearUserAliases();
-    this.userUrlAliases.putAll(compile(userUrlAliases));
-  }
-
-  public String replace(String token) {
-    for (Map.Entry<RegExp, String> e : userUrlAliases.entrySet()) {
-      RegExp pat = e.getKey();
-      if (pat.exec(token) != null) {
-        return pat.replace(token, e.getValue());
-      }
-    }
-
-    for (Map.Entry<RegExp, String> e : globalUrlAliases.entrySet()) {
-      RegExp pat = e.getKey();
-      if (pat.exec(token) != null) {
-        return pat.replace(token, e.getValue());
-      }
-    }
-    return token;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
deleted file mode 100644
index cb529f4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.AnchorElement;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-
-public class UserPopupPanel extends PopupPanel {
-  interface Binder extends UiBinder<Widget, UserPopupPanel> {}
-
-  private static final Binder binder = GWT.create(Binder.class);
-
-  @UiField(provided = true)
-  AvatarImage avatar;
-
-  @UiField Label userName;
-  @UiField Label userEmail;
-  @UiField Element userLinks;
-  @UiField AnchorElement switchAccount;
-  @UiField AnchorElement logout;
-  @UiField InlineHyperlink settings;
-
-  public UserPopupPanel(AccountInfo account, boolean canLogOut, boolean showSettingsLink) {
-    super(/* auto hide */ true, /* modal */ false);
-    avatar = new AvatarImage(account, 100, false);
-    setWidget(binder.createAndBindUi(this));
-    setStyleName(Gerrit.RESOURCES.css().userInfoPopup());
-    if (account.name() != null) {
-      userName.setText(account.name());
-    }
-    if (account.email() != null) {
-      userEmail.setText(account.email());
-    }
-    if (showSettingsLink) {
-      String switchAccountUrl = Gerrit.info().auth().switchAccountUrl();
-      if (switchAccountUrl != null) {
-        switchAccount.setHref(switchAccountUrl.replace("${path}", "/"));
-      } else if (Gerrit.info().auth().isDev() || Gerrit.info().auth().isOpenId()) {
-        switchAccount.setHref(Gerrit.selfRedirect("/login"));
-      } else {
-        switchAccount.removeFromParent();
-        switchAccount = null;
-      }
-      if (canLogOut) {
-        logout.setHref(Gerrit.selfRedirect("/logout"));
-      } else {
-        logout.removeFromParent();
-        logout = null;
-      }
-
-    } else {
-      settings.removeFromParent();
-      settings = null;
-      userLinks.removeFromParent();
-      userLinks = null;
-      logout = null;
-    }
-
-    // We must show and then hide this popup so that it is part of the DOM.
-    // Otherwise the image does not get any events.  Calling hide() would
-    // remove it from the DOM so we use setVisible(false) instead.
-    show();
-    setVisible(false);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.ui.xml
deleted file mode 100644
index 8f5073d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.ui.xml
+++ /dev/null
@@ -1,70 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2012 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<!DOCTYPE ui:UiBinder SYSTEM "http://dl.google.com/gwt/DTD/xhtml.ent">
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-  xmlns:g='urn:import:com.google.gwt.user.client.ui'
-  xmlns:gerrit='urn:import:com.google.gerrit.client'
-  xmlns:u='urn:import:com.google.gerrit.client.ui'>
-  <ui:style gss='false'>
-    .panel {
-      padding: 8px;
-    }
-    .avatar {
-      padding-right: 4px;
-      width: 100px;
-      height: 100px;
-    }
-    .infoCell {
-      vertical-align: top;
-    }
-    .userName {
-      font-weight: bold;
-    }
-    .email {
-      padding-bottom: 6px;
-    }
-    .userLinks {
-      min-width: 250px;
-    }
-    .userLinksRight {
-      float: right;
-    }
-    .switchAccount {
-      border-right: 1px solid black;
-      padding-right: 0.5em;
-      margin-right: 0.5em;
-    }
-  </ui:style>
-
-  <g:HTMLPanel styleName='{style.panel}'>
-    <table><tr><td>
-      <gerrit:AvatarImage ui:field='avatar' styleName='{style.avatar}' />
-    </td><td class='{style.infoCell}'>
-      <g:Label ui:field='userName' styleName="{style.userName}" />
-      <g:Label ui:field='userEmail' styleName="{style.email}" />
-    </td></tr></table>
-    <div ui:field='userLinks' class='{style.userLinks}'>
-      <u:InlineHyperlink ui:field='settings' targetHistoryToken='/settings/'>
-        <ui:msg>Settings</ui:msg>
-      </u:InlineHyperlink>
-      <span class='{style.userLinksRight}'>
-        <a ui:field='switchAccount' class='{style.switchAccount}'><ui:msg>Switch Account</ui:msg></a
-        ><a ui:field='logout'><ui:msg>Sign Out</ui:msg></a>
-      </span>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/VoidResult.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/VoidResult.java
deleted file mode 100644
index 810ebe7..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/VoidResult.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public final class VoidResult extends JavaScriptObject {
-  protected VoidResult() {}
-
-  public static VoidResult create() {
-    return createObject().cast();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java
deleted file mode 100644
index a0060d5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.access;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import java.util.Collections;
-import java.util.Set;
-
-/** Access rights available from {@code /access/}. */
-public class AccessMap extends NativeMap<ProjectAccessInfo> {
-  public static void get(Set<Project.NameKey> projects, AsyncCallback<AccessMap> callback) {
-    RestApi api = new RestApi("/access/");
-    for (Project.NameKey p : projects) {
-      api.addParameter("project", p.get());
-    }
-    api.get(NativeMap.copyKeysIntoChildren(callback));
-  }
-
-  public static void get(Project.NameKey project, AsyncCallback<ProjectAccessInfo> cb) {
-    get(
-        Collections.singleton(project),
-        new AsyncCallback<AccessMap>() {
-          @Override
-          public void onSuccess(AccessMap result) {
-            cb.onSuccess(result.get(project.get()));
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            cb.onFailure(caught);
-          }
-        });
-  }
-
-  protected AccessMap() {}
-}
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
deleted file mode 100644
index b115c7d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.access;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class ProjectAccessInfo extends JavaScriptObject {
-  public final native boolean canAddRefs() /*-{ return this.can_add ? true : false; }-*/;
-
-  public final native boolean isOwner() /*-{ return this.is_owner ? true : false; }-*/;
-
-  public final native boolean configVisible() /*-{ return this.config_visible ? true : false; }-*/;
-
-  protected ProjectAccessInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
deleted file mode 100644
index 7f4522f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
+++ /dev/null
@@ -1,287 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.AgreementInfo;
-import com.google.gerrit.client.info.GpgKeyInfo;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwtorm.client.KeyUtil;
-import java.util.HashSet;
-import java.util.Set;
-
-/** A collection of static methods which work on the Gerrit REST API for specific accounts. */
-public class AccountApi {
-  public static RestApi self() {
-    return new RestApi("/accounts/").view("self");
-  }
-
-  /** Retrieve the account edit preferences */
-  public static void getEditPreferences(AsyncCallback<EditPreferences> cb) {
-    self().view("preferences.edit").get(cb);
-  }
-
-  /** Put the account edit preferences */
-  public static void putEditPreferences(EditPreferences in, AsyncCallback<EditPreferences> cb) {
-    self().view("preferences.edit").put(in, cb);
-  }
-
-  public static void suggest(String query, int limit, AsyncCallback<JsArray<AccountInfo>> cb) {
-    new RestApi("/accounts/")
-        .addParameterTrue("suggest")
-        .addParameterRaw("q", KeyUtil.encode(query))
-        .addParameter("n", limit)
-        .background()
-        .get(cb);
-  }
-
-  public static void putDiffPreferences(DiffPreferences in, AsyncCallback<DiffPreferences> cb) {
-    self().view("preferences.diff").put(in, cb);
-  }
-
-  /** Retrieve the username */
-  public static void getUsername(String account, AsyncCallback<NativeString> cb) {
-    new RestApi("/accounts/").id(account).view("username").get(cb);
-  }
-
-  /** Set the username */
-  public static void setUsername(String account, String username, AsyncCallback<NativeString> cb) {
-    UsernameInput input = UsernameInput.create();
-    input.username(username);
-    new RestApi("/accounts/").id(account).view("username").put(input, cb);
-  }
-
-  /** Retrieve the account name */
-  public static void getName(String account, AsyncCallback<NativeString> cb) {
-    new RestApi("/accounts/").id(account).view("name").get(cb);
-  }
-
-  /** Set the account name */
-  public static void setName(String account, String name, AsyncCallback<NativeString> cb) {
-    AccountNameInput input = AccountNameInput.create();
-    input.name(name);
-    new RestApi("/accounts/").id(account).view("name").put(input, cb);
-  }
-
-  /** Retrieve email addresses */
-  public static void getEmails(String account, AsyncCallback<JsArray<EmailInfo>> cb) {
-    new RestApi("/accounts/").id(account).view("emails").get(cb);
-  }
-
-  /** Register a new email address */
-  public static void registerEmail(String account, String email, AsyncCallback<EmailInfo> cb) {
-    JavaScriptObject in = JavaScriptObject.createObject();
-    new RestApi("/accounts/").id(account).view("emails").id(email).ifNoneMatch().put(in, cb);
-  }
-
-  /** Set preferred email address */
-  public static void setPreferredEmail(
-      String account, String email, AsyncCallback<NativeString> cb) {
-    new RestApi("/accounts/").id(account).view("emails").id(email).view("preferred").put(cb);
-  }
-
-  /** Retrieve SSH keys */
-  public static void getSshKeys(String account, AsyncCallback<JsArray<SshKeyInfo>> cb) {
-    new RestApi("/accounts/").id(account).view("sshkeys").get(cb);
-  }
-
-  /** Add a new SSH keys */
-  public static void addSshKey(String account, String sshPublicKey, AsyncCallback<SshKeyInfo> cb) {
-    new RestApi("/accounts/").id(account).view("sshkeys").post(sshPublicKey, cb);
-  }
-
-  /** Retrieve Watched Projects */
-  public static void getWatchedProjects(
-      String account, AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
-    new RestApi("/accounts/").id(account).view("watched.projects").get(cb);
-  }
-
-  /** Create/Update Watched Project */
-  public static void updateWatchedProject(
-      String account,
-      ProjectWatchInfo watchedProjectInfo,
-      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
-    Set<ProjectWatchInfo> watchedProjectInfos = new HashSet<>();
-    watchedProjectInfos.add(watchedProjectInfo);
-    updateWatchedProjects(account, watchedProjectInfos, cb);
-  }
-
-  /** Create/Update Watched Projects */
-  public static void updateWatchedProjects(
-      String account,
-      Set<ProjectWatchInfo> watchedProjectInfos,
-      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
-    new RestApi("/accounts/")
-        .id(account)
-        .view("watched.projects")
-        .post(projectWatchArrayFromSet(watchedProjectInfos), cb);
-  }
-
-  /** Delete Watched Project */
-  public static void deleteWatchedProject(
-      String account,
-      ProjectWatchInfo watchedProjectInfo,
-      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
-    Set<ProjectWatchInfo> watchedProjectInfos = new HashSet<>();
-    watchedProjectInfos.add(watchedProjectInfo);
-    deleteWatchedProjects(account, watchedProjectInfos, cb);
-  }
-
-  /** Delete Watched Projects */
-  public static void deleteWatchedProjects(
-      String account,
-      Set<ProjectWatchInfo> watchedProjectInfos,
-      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
-    new RestApi("/accounts/")
-        .id(account)
-        .view("watched.projects:delete")
-        .post(projectWatchArrayFromSet(watchedProjectInfos), cb);
-  }
-
-  /**
-   * Delete SSH keys. For each key to be deleted a separate DELETE request is fired to the server.
-   * The {@code onSuccess} method of the provided callback is invoked once after all requests
-   * succeeded. If any request fails the callbacks' {@code onFailure} method is invoked. In a
-   * failure case it can be that still some of the keys were successfully deleted.
-   */
-  public static void deleteSshKeys(
-      String account, Set<Integer> sequenceNumbers, AsyncCallback<VoidResult> cb) {
-    CallbackGroup group = new CallbackGroup();
-    for (int seq : sequenceNumbers) {
-      new RestApi("/accounts/").id(account).view("sshkeys").id(seq).delete(group.add(cb));
-      cb = CallbackGroup.emptyCallback();
-    }
-    group.done();
-  }
-
-  /** Generate a new HTTP password */
-  public static void generateHttpPassword(String account, AsyncCallback<NativeString> cb) {
-    HttpPasswordInput in = HttpPasswordInput.create();
-    in.generate(true);
-    new RestApi("/accounts/").id(account).view("password.http").put(in, cb);
-  }
-
-  /** Retrieve account external ids */
-  public static void getExternalIds(AsyncCallback<JsArray<ExternalIdInfo>> cb) {
-    self().view("external.ids").get(cb);
-  }
-
-  /** Delete account external ids */
-  public static void deleteExternalIds(Set<String> ids, AsyncCallback<VoidResult> cb) {
-    self().view("external.ids:delete").post(Natives.arrayOf(ids), cb);
-  }
-
-  /** Enter a contributor agreement */
-  public static void enterAgreement(String account, String name, AsyncCallback<NativeString> cb) {
-    AgreementInput in = AgreementInput.create();
-    in.name(name);
-    new RestApi("/accounts/").id(account).view("agreements").put(in, cb);
-  }
-
-  private static JsArray<ProjectWatchInfo> projectWatchArrayFromSet(Set<ProjectWatchInfo> set) {
-    JsArray<ProjectWatchInfo> jsArray = JsArray.createArray().cast();
-    for (ProjectWatchInfo p : set) {
-      jsArray.push(p);
-    }
-    return jsArray;
-  }
-
-  private static class AgreementInput extends JavaScriptObject {
-    final native void name(String n) /*-{ if(n)this.name=n; }-*/;
-
-    static AgreementInput create() {
-      return createObject().cast();
-    }
-
-    protected AgreementInput() {}
-  }
-
-  private static class HttpPasswordInput extends JavaScriptObject {
-    final native void generate(boolean g) /*-{ if(g)this.generate=g; }-*/;
-
-    static HttpPasswordInput create() {
-      return createObject().cast();
-    }
-
-    protected HttpPasswordInput() {}
-  }
-
-  private static class UsernameInput extends JavaScriptObject {
-    final native void username(String u) /*-{ if(u)this.username=u; }-*/;
-
-    static UsernameInput create() {
-      return createObject().cast();
-    }
-
-    protected UsernameInput() {}
-  }
-
-  private static class AccountNameInput extends JavaScriptObject {
-    final native void name(String n) /*-{ if(n)this.name=n; }-*/;
-
-    static AccountNameInput create() {
-      return createObject().cast();
-    }
-
-    protected AccountNameInput() {}
-  }
-
-  public static void addGpgKey(
-      String account, String armored, AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
-    new RestApi("/accounts/").id(account).view("gpgkeys").post(GpgKeysInput.add(armored), cb);
-  }
-
-  public static void deleteGpgKeys(
-      String account, Iterable<String> fingerprints, AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
-    new RestApi("/accounts/")
-        .id(account)
-        .view("gpgkeys")
-        .post(GpgKeysInput.delete(fingerprints), cb);
-  }
-
-  /** List contributor agreements */
-  public static void getAgreements(String account, AsyncCallback<JsArray<AgreementInfo>> cb) {
-    new RestApi("/accounts/").id(account).view("agreements").get(cb);
-  }
-
-  private static class GpgKeysInput extends JavaScriptObject {
-    static GpgKeysInput add(String key) {
-      return createWithAdd(Natives.arrayOf(key));
-    }
-
-    static GpgKeysInput delete(Iterable<String> fingerprints) {
-      return createWithDelete(Natives.arrayOf(fingerprints));
-    }
-
-    private static native GpgKeysInput createWithAdd(JsArrayString keys) /*-{
-      return {'add': keys};
-    }-*/;
-
-    private static native GpgKeysInput createWithDelete(JsArrayString fingerprints) /*-{
-      return {'delete': fingerprints};
-    }-*/;
-
-    protected GpgKeysInput() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountCapabilities.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountCapabilities.java
deleted file mode 100644
index d317881..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountCapabilities.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.account;
-
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** Capabilities the caller has from {@code /accounts/self/capabilities}. */
-public class AccountCapabilities extends JavaScriptObject {
-  public static void all(AsyncCallback<AccountCapabilities> cb, String... filter) {
-    new RestApi("/accounts/self/capabilities").addParameter("q", filter).get(cb);
-  }
-
-  protected AccountCapabilities() {}
-
-  public final native boolean canPerform(String name) /*-{ return this[name] ? true : false; }-*/;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
deleted file mode 100644
index 0b32cd5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
+++ /dev/null
@@ -1,309 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gwt.i18n.client.Constants;
-
-public interface AccountConstants extends Constants {
-  String settingsHeading();
-
-  String changeAvatar();
-
-  String fullName();
-
-  String preferredEmail();
-
-  String registeredOn();
-
-  String accountId();
-
-  String diffViewLabel();
-
-  String maximumPageSizeFieldLabel();
-
-  String dateFormatLabel();
-
-  String contextWholeFile();
-
-  String showSiteHeader();
-
-  String useFlashClipboard();
-
-  String reviewCategoryLabel();
-
-  String messageShowInReviewCategoryNone();
-
-  String messageShowInReviewCategoryName();
-
-  String messageShowInReviewCategoryEmail();
-
-  String messageShowInReviewCategoryUsername();
-
-  String messageShowInReviewCategoryAbbrev();
-
-  String buttonSaveChanges();
-
-  String highlightAssigneeInChangeTable();
-
-  String showRelativeDateInChangeTable();
-
-  String showSizeBarInChangeTable();
-
-  String showLegacycidInChangeTable();
-
-  String muteCommonPathPrefixes();
-
-  String signedOffBy();
-
-  String publishCommentsOnPush();
-
-  String myMenu();
-
-  String myMenuInfo();
-
-  String myMenuName();
-
-  String myMenuUrl();
-
-  String myMenuReset();
-
-  String tabAccountSummary();
-
-  String tabAgreements();
-
-  String tabContactInformation();
-
-  String tabDiffPreferences();
-
-  String tabEditPreferences();
-
-  String tabGpgKeys();
-
-  String tabHttpAccess();
-
-  String tabMyGroups();
-
-  String tabOAuthToken();
-
-  String tabPreferences();
-
-  String tabSshKeys();
-
-  String tabWatchedProjects();
-
-  String tabWebIdentities();
-
-  String buttonShowAddSshKey();
-
-  String buttonCloseAddSshKey();
-
-  String buttonDeleteSshKey();
-
-  String buttonClearSshKeyInput();
-
-  String buttonAddSshKey();
-
-  String userName();
-
-  String password();
-
-  String buttonSetUserName();
-
-  String confirmSetUserNameTitle();
-
-  String confirmSetUserName();
-
-  String buttonClearPassword();
-
-  String buttonGeneratePassword();
-
-  String revokePassword();
-
-  String linkObtainPassword();
-
-  String linkEditFullName();
-
-  String linkReloadContact();
-
-  String invalidUserName();
-
-  String invalidUserEmail();
-
-  String labelOAuthToken();
-
-  String labelOAuthExpires();
-
-  String labelOAuthNetRCEntry();
-
-  String labelOAuthGitCookie();
-
-  String labelOAuthExpired();
-
-  String sshKeyInvalid();
-
-  String sshKeyAlgorithm();
-
-  String sshKeyKey();
-
-  String sshKeyComment();
-
-  String sshKeyStatus();
-
-  String addSshKeyPanelHeader();
-
-  String addSshKeyHelpTitle();
-
-  String addSshKeyHelp();
-
-  String sshJavaAppletNotAvailable();
-
-  String invalidSshKeyError();
-
-  String sshHostKeyTitle();
-
-  String sshHostKeyFingerprint();
-
-  String sshHostKeyKnownHostEntry();
-
-  String gpgKeyId();
-
-  String gpgKeyFingerprint();
-
-  String gpgKeyUserIds();
-
-  String webIdStatus();
-
-  String webIdEmail();
-
-  String webIdIdentity();
-
-  String untrustedProvider();
-
-  String buttonDeleteIdentity();
-
-  String buttonLinkIdentity();
-
-  String buttonWatchProject();
-
-  String defaultProjectName();
-
-  String defaultFilter();
-
-  String buttonBrowseProjects();
-
-  String projects();
-
-  String projectsClose();
-
-  String projectListOpen();
-
-  String watchedProjectName();
-
-  String watchedProjectFilter();
-
-  String watchedProjectColumnEmailNotifications();
-
-  String watchedProjectColumnNewChanges();
-
-  String watchedProjectColumnNewPatchSets();
-
-  String watchedProjectColumnAllComments();
-
-  String watchedProjectColumnSubmittedChanges();
-
-  String watchedProjectColumnAbandonedChanges();
-
-  String contactFieldFullName();
-
-  String contactFieldEmail();
-
-  String buttonOpenRegisterNewEmail();
-
-  String buttonSendRegisterNewEmail();
-
-  String buttonCancel();
-
-  String titleRegisterNewEmail();
-
-  String descRegisterNewEmail();
-
-  String errorDialogTitleRegisterNewEmail();
-
-  String emailFilterHelpTitle();
-
-  String emailFilterHelp();
-
-  String newAgreement();
-
-  String agreementName();
-
-  String agreementDescription();
-
-  String newAgreementSelectTypeHeading();
-
-  String newAgreementNoneAvailable();
-
-  String newAgreementReviewLegalHeading();
-
-  String newAgreementReviewContactHeading();
-
-  String newAgreementCompleteHeading();
-
-  String newAgreementIAGREE();
-
-  String newAgreementAlreadySubmitted();
-
-  String buttonSubmitNewAgreement();
-
-  String welcomeToGerritCodeReview();
-
-  String welcomeReviewContact();
-
-  String welcomeContactFrom();
-
-  String welcomeUsernameHeading();
-
-  String welcomeSshKeyHeading();
-
-  String welcomeSshKeyText();
-
-  String welcomeAgreementHeading();
-
-  String welcomeAgreementText();
-
-  String welcomeAgreementLater();
-
-  String welcomeContinue();
-
-  String messageEnabled();
-
-  String messageCCMeOnMyComments();
-
-  String messageDisabled();
-
-  String emailFieldLabel();
-
-  String emailFormatFieldLabel();
-
-  String messagePlaintextOnly();
-
-  String messageHtmlPlaintext();
-
-  String defaultBaseForMerges();
-
-  String autoMerge();
-
-  String firstParent();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
deleted file mode 100644
index 4b01513..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
+++ /dev/null
@@ -1,273 +0,0 @@
-settingsHeading = Settings
-
-changeAvatar = Change Avatar
-fullName = Full Name
-preferredEmail = Email Address
-registeredOn = Registered
-accountId = Account ID
-showSiteHeader = Show Site Header / Footer
-useFlashClipboard = Use Flash Clipboard Widget
-reviewCategoryLabel = Display In Review Category
-messageShowInReviewCategoryNone = None (default)
-messageShowInReviewCategoryName = Show Name
-messageShowInReviewCategoryEmail = Show Email
-messageShowInReviewCategoryUsername = Show Username
-messageShowInReviewCategoryAbbrev = Show Abbreviated Name
-
-emailFieldLabel = Email Notifications:
-messageCCMeOnMyComments = Every Comment
-messageEnabled = Only Comments Left By Others
-messageDisabled = None
-
-emailFormatFieldLabel = Email Format:
-messagePlaintextOnly = Plaintext Only
-messageHtmlPlaintext = HTML and Plaintext
-
-defaultBaseForMerges = Default Base For Merges:
-autoMerge = Auto Merge
-firstParent = First Parent
-
-maximumPageSizeFieldLabel = Maximum Page Size:
-diffViewLabel = Diff View:
-dateFormatLabel = Date/Time Format:
-contextWholeFile = Whole File
-buttonSaveChanges = Save Changes
-highlightAssigneeInChangeTable = Highlight Changes Assigned To Me In Changes Table
-showRelativeDateInChangeTable = Show Relative Dates In Changes Table
-showSizeBarInChangeTable = Show Change Sizes As Colored Bars
-showLegacycidInChangeTable = Show Change Number In Changes Table
-muteCommonPathPrefixes = Mute Common Path Prefixes In File List
-signedOffBy = Insert Signed-off-by Footer For Inline Edit Changes
-publishCommentsOnPush = Publish Comments On Push
-myMenu = My Menu
-myMenuInfo = \
-  Menu items for the 'My' top level menu. \
-  The first menu item defines the default screen.
-myMenuName = Name
-myMenuUrl = URL
-myMenuReset = Reset
-
-tabAccountSummary = Profile
-tabAgreements = Agreements
-tabContactInformation = Contact Information
-tabDiffPreferences = Diff Preferences
-tabEditPreferences = Edit Preferences
-tabGpgKeys = GPG Public Keys
-tabHttpAccess = HTTP Password
-tabOAuthToken = OAuth Token
-tabMyGroups = Groups
-tabPreferences = Preferences
-tabSshKeys = SSH Public Keys
-tabWatchedProjects = Watched Projects
-tabWebIdentities = Identities
-
-buttonShowAddSshKey = Add Key ...
-buttonCloseAddSshKey = Close
-buttonDeleteSshKey = Delete
-buttonClearSshKeyInput = Clear
-buttonAddSshKey = Add
-
-userName = Username
-password = Password
-buttonSetUserName = Select Username
-confirmSetUserNameTitle = Confirm Setting the Username
-confirmSetUserName = Setting the Username is permanent.  Are you sure?
-buttonClearPassword = Clear Password
-buttonGeneratePassword = Generate Password
-revokePassword = (click 'Generate Password' to revoke an old password)
-linkObtainPassword = Obtain Password
-linkEditFullName = Edit
-linkReloadContact = Reload
-invalidUserName = Username must contain only letters, numbers, _, - or .
-invalidUserEmail = Email format is wrong.
-
-labelOAuthToken = Access Token
-labelOAuthExpires = Expires
-labelOAuthNetRCEntry = Entry for ~/.netrc
-labelOAuthGitCookie = Entry for ~/.gitcookies
-labelOAuthExpired = To obtain an access token please sign out and sign in again.
-
-sshKeyInvalid = Invalid Key
-sshKeyAlgorithm = Algorithm
-sshKeyKey = Key
-sshKeyComment = Comment
-sshKeyStatus = Status
-
-sshHostKeyTitle = Server Host Key
-sshHostKeyFingerprint = Fingerprint:
-sshHostKeyKnownHostEntry = Entry for <code>~/.ssh/known_hosts</code>:
-
-gpgKeyId = ID
-gpgKeyFingerprint = Fingerprint
-gpgKeyUserIds = User IDs
-
-webIdStatus = Status
-webIdEmail = Email Address
-webIdIdentity = Identity
-untrustedProvider = Untrusted
-buttonDeleteIdentity = Delete
-buttonLinkIdentity = Link Another Identity
-
-addSshKeyPanelHeader = Add SSH Public Key
-addSshKeyHelpTitle = How to Generate an SSH Key
-addSshKeyHelp = \
-  <ol>\
-    <li>\
-      From the Terminal or Git Bash, run <em>ssh-keygen</em>\
-    </li>\
-    <li>\
-      Confirm the default path <em>.ssh/id_rsa</em>\
-    </li>\
-    <li>\
-      Enter a passphrase (recommended) or leave it blank.<br />\
-      Remember this passphrase, as you will need it to unlock the<br />\
-      key whenever you use it.\
-    </li>\
-    <li>\
-      Open <em>~/.ssh/id_rsa.pub</em> and copy & paste the contents into<br />\
-      the box below, then click on "Add".<br />\
-      Note that <em>id_rsa.pub</em> is your public key and can be shared,<br />\
-      while <em>id_rsa</em> is your private key and should be kept secret.\
-    </li>\
-  <\ol>
-invalidSshKeyError = Invalid SSH Key
-sshJavaAppletNotAvailable = Open Key Unavailable: Java not enabled
-
-buttonWatchProject = Watch
-defaultProjectName = Project Name
-defaultFilter = branch:name, or other search expression
-projects = All Watchable Projects
-projectsClose = Close
-buttonBrowseProjects = Browse
-projectListOpen = Watch Selected project
-watchedProjectName = Project Name
-watchedProjectFilter = Only If
-watchedProjectColumnEmailNotifications = Email Notifications
-watchedProjectColumnNewChanges = New Changes
-watchedProjectColumnNewPatchSets = New Patch Sets
-watchedProjectColumnAllComments = All Comments
-watchedProjectColumnSubmittedChanges = Submitted Changes
-watchedProjectColumnAbandonedChanges = Abandoned Changes
-
-contactFieldFullName = Full Name
-contactFieldEmail = Preferred Email
-buttonOpenRegisterNewEmail = Register New Email ...
-buttonSendRegisterNewEmail = Register
-buttonCancel = Cancel
-titleRegisterNewEmail = Register Email Address
-descRegisterNewEmail = \
-  <p>A confirmation link will be sent by email to this address.</p>\
-  <p>You must click on the link to complete the registration and make the address available for selection.</p>
-errorDialogTitleRegisterNewEmail = Email Registration Failed
-emailFilterHelpTitle = Mail Filters
-emailFilterHelp = \
-  <p>\
-    Gerrit emails include metadata about the change to support \
-    writing mail filters.\
-  </p>\
-  <p>\
-    Here are some example Gmail queries that can be used for filters or \
-    for searching through archived messages. View the \
-    <a href="https://gerrit-review.googlesource.com/Documentation/user-notify.html"\
-        target="_blank" rel="nofollow">Gerrit documentation</a> for \
-    the complete set of footers.\
-  </p>\
-  <table>\
-    <tbody>\
-      <tr><th>Name</th><th>Query</th></tr>\
-      <tr>\
-        <td>Changes requesting my review</td>\
-        <td>\
-          <code>\
-            "Gerrit-Reviewer: <em>Your Name</em>\
-            &lt;<em>your.email@example.com</em>&gt;"\
-          </code>\
-        </td>\
-      </tr>\
-      <tr>\
-        <td>Changes from a specific owner</td>\
-        <td>\
-          <code>\
-            "Gerrit-Owner: <em>Owner name</em>\
-            &lt;<em>owner.email@example.com</em>&gt;"\
-          </code>\
-        </td>\
-      </tr>\
-      <tr>\
-        <td>Changes targeting a specific branch</td>\
-        <td>\
-          <code>\
-            "Gerrit-Branch: <em>branch-name</em>"\
-          </code>\
-        </td>\
-      </tr>\
-      <tr>\
-        <td>Changes in a specific project</td>\
-        <td>\
-          <code>\
-            "Gerrit-Project: <em>project-name</em>"\
-          </code>\
-        </td>\
-      </tr>\
-      <tr>\
-        <td>Messages related to a specific Change ID</td>\
-        <td>\
-          <code>\
-            "Gerrit-Change-Id: <em>Change ID</em>"\
-          </code>\
-        </td>\
-      </tr>\
-      <tr>\
-        <td>Messages related to a specific change number</td>\
-        <td>\
-          <code>\
-            "Gerrit-Change-Number: <em>change number</em>"\
-          </code>\
-        </td>\
-      </tr>\
-    </tbody>\
-  </table>
-
-newAgreement = New Contributor Agreement
-agreementName = Name
-agreementDescription = Description
-
-newAgreementSelectTypeHeading = Select an agreement type:
-newAgreementNoneAvailable = No contributor agreements are configured.
-newAgreementReviewLegalHeading = Review the agreement:
-newAgreementReviewContactHeading = Review your contact information:
-newAgreementCompleteHeading = Complete the agreement:
-newAgreementIAGREE = I AGREE
-newAgreementAlreadySubmitted = Agreement already submitted.
-buttonSubmitNewAgreement = Submit Agreement
-
-welcomeToGerritCodeReview = Welcome to Gerrit Code Review
-welcomeReviewContact = Please review your contact information:
-welcomeContactFrom = \
-  <p>The following contact information was automatically obtained when \
-  you signed-in to the site.  This information is used to display who \
-  you are to others, and to send updates to code reviews you have either \
-  started or subscribed to.</p>
-
-welcomeUsernameHeading = Select a unique username:
-
-welcomeSshKeyHeading = Register an SSH public key:
-welcomeSshKeyText = \
-  <p>Gerrit Code Review uses \
-  <a href="http://en.wikipedia.org/wiki/Public-key_cryptography" target="_blank">public-key cryptography</a> \
-  and \
-  <a href="http://en.wikipedia.org/wiki/Secure_Shell" target="_blank">SSH</a> \
-  to authenticate \
-  you during git's push and pull commands to hosted projects.  Registering \
-  your public key allows Gerrit to identify you whenever you connect through \
-  SSH.</p>\
-  <p>This step can also be completed at a later time.</p>
-
-welcomeAgreementHeading = Complete a contributor agreement:
-welcomeAgreementText = \
-  <p>If you will be contributing code or documentation changes to projects \
-  hosted here, please consider taking a minute to review and complete \
-  a contributor agreement.</p>\
-  <p>This step can also be completed at a later time.</p>
-welcomeAgreementLater = Continue Without Agreement
-welcomeContinue = Continue
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java
deleted file mode 100644
index 4cff1e2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gwt.i18n.client.Messages;
-
-public interface AccountMessages extends Messages {
-  String lines(short cnt);
-
-  String rowsPerPage(int cnt);
-
-  String changeScreenServerDefault(String d);
-
-  String enterIAGREE(String iagree);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.properties
deleted file mode 100644
index a8d61cf..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-lines = {0} lines
-rowsPerPage = {0} rows per page
-changeScreenServerDefault = Server Default ({0})
-enterIAGREE = (enter {0} in the box to the left)
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
deleted file mode 100644
index a537063..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
+++ /dev/null
@@ -1,484 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.ComplexDisclosurePanel;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.ChangeEvent;
-import com.google.gwt.event.dom.client.ChangeHandler;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.i18n.client.LocaleInfo;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.FormPanel;
-import com.google.gwt.user.client.ui.FormPanel.SubmitEvent;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.VerticalPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-import com.google.gwtexpui.user.client.AutoCenterDialogBox;
-
-class ContactPanelShort extends Composite {
-  protected final FlowPanel body;
-  protected int labelIdx;
-  protected int fieldIdx;
-  protected Button save;
-
-  private String currentEmail;
-  protected boolean haveAccount;
-  private boolean haveEmails;
-
-  NpTextBox nameTxt;
-  private ListBox emailPick;
-  private Button registerNewEmail;
-  private OnEditEnabler onEditEnabler;
-
-  ContactPanelShort() {
-    body = new FlowPanel();
-    initWidget(body);
-  }
-
-  protected void onInitUI() {
-    if (LocaleInfo.getCurrentLocale().isRTL()) {
-      labelIdx = 1;
-      fieldIdx = 0;
-    } else {
-      labelIdx = 0;
-      fieldIdx = 1;
-    }
-
-    nameTxt = new NpTextBox();
-    nameTxt.setVisibleLength(60);
-    nameTxt.setReadOnly(!canEditFullName());
-
-    emailPick = new ListBox();
-
-    final Grid infoPlainText = new Grid(2, 2);
-    infoPlainText.setStyleName(Gerrit.RESOURCES.css().infoBlock());
-    infoPlainText.addStyleName(Gerrit.RESOURCES.css().accountInfoBlock());
-
-    body.add(infoPlainText);
-
-    registerNewEmail = new Button(Util.C.buttonOpenRegisterNewEmail());
-    registerNewEmail.setEnabled(false);
-    registerNewEmail.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doRegisterNewEmail();
-          }
-        });
-    final FlowPanel emailLine = new FlowPanel();
-    emailLine.add(emailPick);
-    if (canRegisterNewEmail()) {
-      emailLine.add(registerNewEmail);
-    }
-
-    int row = 0;
-    if (!Gerrit.info().auth().canEdit(AccountFieldName.USER_NAME)
-        && Gerrit.info().auth().siteHasUsernames()) {
-      infoPlainText.resizeRows(infoPlainText.getRowCount() + 1);
-      row(infoPlainText, row++, Util.C.userName(), new UsernameField());
-    }
-
-    if (!canEditFullName()) {
-      FlowPanel nameLine = new FlowPanel();
-      nameLine.add(nameTxt);
-      if (Gerrit.info().auth().editFullNameUrl() != null) {
-        Button edit = new Button(Util.C.linkEditFullName());
-        edit.addClickHandler(
-            new ClickHandler() {
-              @Override
-              public void onClick(ClickEvent event) {
-                Window.open(Gerrit.info().auth().editFullNameUrl(), "_blank", null);
-              }
-            });
-        nameLine.add(edit);
-      }
-      Button reload = new Button(Util.C.linkReloadContact());
-      reload.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              Window.Location.replace(Gerrit.loginRedirect(PageLinks.SETTINGS_CONTACT));
-            }
-          });
-      nameLine.add(reload);
-      row(infoPlainText, row++, Util.C.contactFieldFullName(), nameLine);
-    } else {
-      row(infoPlainText, row++, Util.C.contactFieldFullName(), nameTxt);
-    }
-    row(infoPlainText, row++, Util.C.contactFieldEmail(), emailLine);
-
-    infoPlainText.getCellFormatter().addStyleName(0, 0, Gerrit.RESOURCES.css().topmost());
-    infoPlainText.getCellFormatter().addStyleName(0, 1, Gerrit.RESOURCES.css().topmost());
-    infoPlainText
-        .getCellFormatter()
-        .addStyleName(row - 1, 0, Gerrit.RESOURCES.css().bottomheader());
-
-    save = new Button(Util.C.buttonSaveChanges());
-    save.setEnabled(false);
-    save.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doSave();
-          }
-        });
-
-    final ComplexDisclosurePanel mailFilterHelp =
-        new ComplexDisclosurePanel(Util.C.emailFilterHelpTitle(), false);
-    mailFilterHelp.setContent(new HTML(Util.C.emailFilterHelp()));
-    body.add(mailFilterHelp);
-
-    emailPick.addChangeHandler(
-        new ChangeHandler() {
-          @Override
-          public void onChange(ChangeEvent event) {
-            final int idx = emailPick.getSelectedIndex();
-            final String v = 0 <= idx ? emailPick.getValue(idx) : null;
-            if (Util.C.buttonOpenRegisterNewEmail().equals(v)) {
-              for (int i = 0; i < emailPick.getItemCount(); i++) {
-                if (currentEmail.equals(emailPick.getValue(i))) {
-                  emailPick.setSelectedIndex(i);
-                  break;
-                }
-              }
-              doRegisterNewEmail();
-            } else {
-              save.setEnabled(true);
-            }
-          }
-        });
-
-    onEditEnabler = new OnEditEnabler(save, nameTxt);
-  }
-
-  private boolean canEditFullName() {
-    return Gerrit.info().auth().canEdit(AccountFieldName.FULL_NAME);
-  }
-
-  private boolean canRegisterNewEmail() {
-    return Gerrit.info().auth().canEdit(AccountFieldName.REGISTER_NEW_EMAIL);
-  }
-
-  void hideSaveButton() {
-    save.setVisible(false);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-
-    onInitUI();
-    body.add(save);
-    display(Gerrit.getUserAccount());
-
-    emailPick.clear();
-    emailPick.setEnabled(false);
-    registerNewEmail.setEnabled(false);
-
-    haveAccount = false;
-    haveEmails = false;
-
-    CallbackGroup group = new CallbackGroup();
-    AccountApi.getName(
-        "self",
-        group.add(
-            new GerritCallback<NativeString>() {
-
-              @Override
-              public void onSuccess(NativeString result) {
-                nameTxt.setText(result.asString());
-                haveAccount = true;
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {}
-            }));
-
-    AccountApi.getEmails(
-        "self",
-        group.addFinal(
-            new GerritCallback<JsArray<EmailInfo>>() {
-              @Override
-              public void onSuccess(JsArray<EmailInfo> result) {
-                for (EmailInfo i : Natives.asList(result)) {
-                  emailPick.addItem(i.email());
-                  if (i.isPreferred()) {
-                    currentEmail = i.email();
-                  }
-                }
-                haveEmails = true;
-                postLoad();
-              }
-            }));
-  }
-
-  private void postLoad() {
-    if (haveAccount && haveEmails) {
-      updateEmailList();
-      registerNewEmail.setEnabled(true);
-      save.setEnabled(false);
-      onEditEnabler.updateOriginalValue(nameTxt);
-    }
-    display();
-  }
-
-  void display() {}
-
-  protected void row(Grid info, int row, String name, Widget field) {
-    info.setText(row, labelIdx, name);
-    info.setWidget(row, fieldIdx, field);
-    info.getCellFormatter().addStyleName(row, 0, Gerrit.RESOURCES.css().header());
-  }
-
-  protected void display(AccountInfo account) {
-    currentEmail = account.email();
-    nameTxt.setText(account.name());
-    save.setEnabled(false);
-    onEditEnabler.updateOriginalValue(nameTxt);
-  }
-
-  private void doRegisterNewEmail() {
-    if (!canRegisterNewEmail()) {
-      return;
-    }
-
-    final AutoCenterDialogBox box = new AutoCenterDialogBox(true, true);
-    final VerticalPanel body = new VerticalPanel();
-
-    final NpTextBox inEmail = new NpTextBox();
-    inEmail.setVisibleLength(60);
-
-    final Button register = new Button(Util.C.buttonSendRegisterNewEmail());
-    final Button cancel = new Button(Util.C.buttonCancel());
-    final FormPanel form = new FormPanel();
-    form.addSubmitHandler(
-        new FormPanel.SubmitHandler() {
-          @Override
-          public void onSubmit(SubmitEvent event) {
-            event.cancel();
-            final String addr = inEmail.getText().trim();
-            if (!addr.contains("@")) {
-              new ErrorDialog(Util.C.invalidUserEmail()).center();
-              return;
-            }
-
-            inEmail.setEnabled(false);
-            register.setEnabled(false);
-            AccountApi.registerEmail(
-                "self",
-                addr,
-                new GerritCallback<EmailInfo>() {
-                  @Override
-                  public void onSuccess(EmailInfo result) {
-                    box.hide();
-                    if (Gerrit.info().auth().isDev()) {
-                      currentEmail = addr;
-                      if (emailPick.getItemCount() == 0) {
-                        AccountInfo me = Gerrit.getUserAccount();
-                        me.email(addr);
-                        onSaveSuccess(me);
-                      } else {
-                        save.setEnabled(true);
-                      }
-                      updateEmailList();
-                    }
-                  }
-
-                  @Override
-                  public void onFailure(Throwable caught) {
-                    inEmail.setEnabled(true);
-                    register.setEnabled(true);
-                    if (caught.getMessage().startsWith(EmailException.MESSAGE)) {
-                      final ErrorDialog d =
-                          new ErrorDialog(
-                              caught.getMessage().substring(EmailException.MESSAGE.length()));
-                      d.setText(Util.C.errorDialogTitleRegisterNewEmail());
-                      d.center();
-                    } else {
-                      super.onFailure(caught);
-                    }
-                  }
-                });
-          }
-        });
-    form.setWidget(body);
-
-    register.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            form.submit();
-          }
-        });
-    cancel.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            box.hide();
-          }
-        });
-
-    final FlowPanel buttons = new FlowPanel();
-    buttons.setStyleName(Gerrit.RESOURCES.css().patchSetActions());
-    buttons.add(register);
-    buttons.add(cancel);
-
-    if (!Gerrit.info().auth().isDev()) {
-      body.add(new HTML(Util.C.descRegisterNewEmail()));
-    }
-    body.add(inEmail);
-    body.add(buttons);
-
-    box.setText(Util.C.titleRegisterNewEmail());
-    box.setWidget(form);
-    box.center();
-    inEmail.setFocus(true);
-  }
-
-  void doSave() {
-    final String newName;
-    String name = canEditFullName() ? nameTxt.getText() : null;
-    if (name != null && name.trim().isEmpty()) {
-      newName = null;
-    } else {
-      newName = name;
-    }
-
-    final String newEmail;
-    if (emailPick.isEnabled() && emailPick.getSelectedIndex() >= 0) {
-      final String v = emailPick.getValue(emailPick.getSelectedIndex());
-      if (Util.C.buttonOpenRegisterNewEmail().equals(v)) {
-        newEmail = currentEmail;
-      } else {
-        newEmail = v;
-      }
-    } else {
-      newEmail = currentEmail;
-    }
-
-    save.setEnabled(false);
-    registerNewEmail.setEnabled(false);
-
-    CallbackGroup group = new CallbackGroup();
-    if (currentEmail != null && !newEmail.equals(currentEmail)) {
-      AccountApi.setPreferredEmail(
-          "self",
-          newEmail,
-          group.add(
-              new GerritCallback<NativeString>() {
-                @Override
-                public void onSuccess(NativeString result) {}
-              }));
-    }
-    AccountApi.setName(
-        "self",
-        newName,
-        group.add(
-            new GerritCallback<NativeString>() {
-              @Override
-              public void onSuccess(NativeString result) {}
-
-              @Override
-              public void onFailure(Throwable caught) {
-                save.setEnabled(true);
-                registerNewEmail.setEnabled(true);
-                super.onFailure(caught);
-              }
-            }));
-    group.done();
-    group.addListener(
-        new GerritCallback<Void>() {
-          @Override
-          public void onSuccess(Void result) {
-            currentEmail = newEmail;
-            AccountInfo me = Gerrit.getUserAccount();
-            me.email(currentEmail);
-            me.name(newName);
-            onSaveSuccess(me);
-            registerNewEmail.setEnabled(true);
-          }
-        });
-  }
-
-  void onSaveSuccess(AccountInfo result) {
-    AccountInfo me = Gerrit.getUserAccount();
-    me.name(result.name());
-    me.email(result.email());
-    Gerrit.refreshMenuBar();
-    display(me);
-  }
-
-  private int emailListIndexOf(String value) {
-    for (int i = 0; i < emailPick.getItemCount(); i++) {
-      if (value.equalsIgnoreCase(emailPick.getValue(i))) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  private void updateEmailList() {
-    if (currentEmail != null) {
-      int index = emailListIndexOf(currentEmail);
-      if (index == -1) {
-        emailPick.addItem(currentEmail);
-        emailPick.setSelectedIndex(emailPick.getItemCount() - 1);
-      } else {
-        emailPick.setSelectedIndex(index);
-      }
-    }
-    if (emailPick.getItemCount() > 0) {
-      if (currentEmail == null) {
-        int index = emailListIndexOf("");
-        if (index != -1) {
-          emailPick.removeItem(index);
-        }
-        emailPick.insertItem("", 0);
-        emailPick.setSelectedIndex(0);
-      }
-      emailPick.setVisible(true);
-      emailPick.setEnabled(true);
-      if (canRegisterNewEmail()) {
-        final String t = Util.C.buttonOpenRegisterNewEmail();
-        int index = emailListIndexOf(t);
-        if (index != -1) {
-          emailPick.removeItem(index);
-        }
-        emailPick.addItem("... " + t + "  ", t);
-      }
-    } else {
-      emailPick.setVisible(false);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
deleted file mode 100644
index 286d29a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
+++ /dev/null
@@ -1,226 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.client.Theme;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class DiffPreferences extends JavaScriptObject {
-  public static DiffPreferences create(DiffPreferencesInfo in) {
-    if (in == null) {
-      in = DiffPreferencesInfo.defaults();
-    }
-    DiffPreferences p = createObject().cast();
-    p.ignoreWhitespace(in.ignoreWhitespace);
-    p.tabSize(in.tabSize);
-    p.lineLength(in.lineLength);
-    p.cursorBlinkRate(in.cursorBlinkRate);
-    p.context(in.context);
-    p.intralineDifference(in.intralineDifference);
-    p.showLineEndings(in.showLineEndings);
-    p.showTabs(in.showTabs);
-    p.showWhitespaceErrors(in.showWhitespaceErrors);
-    p.syntaxHighlighting(in.syntaxHighlighting);
-    p.hideTopMenu(in.hideTopMenu);
-    p.autoHideDiffTableHeader(in.autoHideDiffTableHeader);
-    p.hideLineNumbers(in.hideLineNumbers);
-    p.expandAllComments(in.expandAllComments);
-    p.manualReview(in.manualReview);
-    p.renderEntireFile(in.renderEntireFile);
-    p.theme(in.theme);
-    p.hideEmptyPane(in.hideEmptyPane);
-    p.retainHeader(in.retainHeader);
-    p.skipUnchanged(in.skipUnchanged);
-    p.skipUncommented(in.skipUncommented);
-    p.skipDeleted(in.skipDeleted);
-    p.matchBrackets(in.matchBrackets);
-    p.lineWrapping(in.lineWrapping);
-    return p;
-  }
-
-  public final void copyTo(DiffPreferencesInfo p) {
-    p.context = context();
-    p.tabSize = tabSize();
-    p.lineLength = lineLength();
-    p.cursorBlinkRate = cursorBlinkRate();
-    p.expandAllComments = expandAllComments();
-    p.intralineDifference = intralineDifference();
-    p.manualReview = manualReview();
-    p.retainHeader = retainHeader();
-    p.showLineEndings = showLineEndings();
-    p.showTabs = showTabs();
-    p.showWhitespaceErrors = showWhitespaceErrors();
-    p.skipDeleted = skipDeleted();
-    p.skipUnchanged = skipUnchanged();
-    p.skipUncommented = skipUncommented();
-    p.syntaxHighlighting = syntaxHighlighting();
-    p.hideTopMenu = hideTopMenu();
-    p.autoHideDiffTableHeader = autoHideDiffTableHeader();
-    p.hideLineNumbers = hideLineNumbers();
-    p.renderEntireFile = renderEntireFile();
-    p.hideEmptyPane = hideEmptyPane();
-    p.matchBrackets = matchBrackets();
-    p.lineWrapping = lineWrapping();
-    p.theme = theme();
-    p.ignoreWhitespace = ignoreWhitespace();
-  }
-
-  public final void ignoreWhitespace(Whitespace i) {
-    setIgnoreWhitespaceRaw(i.toString());
-  }
-
-  public final void theme(Theme i) {
-    setThemeRaw(i != null ? i.toString() : Theme.DEFAULT.toString());
-  }
-
-  public final void showLineNumbers(boolean s) {
-    hideLineNumbers(!s);
-  }
-
-  public final Whitespace ignoreWhitespace() {
-    String s = ignoreWhitespaceRaw();
-    return s != null ? Whitespace.valueOf(s) : Whitespace.IGNORE_NONE;
-  }
-
-  public final Theme theme() {
-    String s = themeRaw();
-    return s != null ? Theme.valueOf(s) : Theme.DEFAULT;
-  }
-
-  public final int tabSize() {
-    return get("tab_size", 8);
-  }
-
-  public final int context() {
-    return get("context", 10);
-  }
-
-  public final int lineLength() {
-    return get("line_length", 100);
-  }
-
-  public final int cursorBlinkRate() {
-    return get("cursor_blink_rate", 0);
-  }
-
-  public final boolean showLineNumbers() {
-    return !hideLineNumbers();
-  }
-
-  public final boolean autoReview() {
-    return !manualReview();
-  }
-
-  public final native void tabSize(int t) /*-{ this.tab_size = t }-*/;
-
-  public final native void lineLength(int c) /*-{ this.line_length = c }-*/;
-
-  public final native void context(int c) /*-{ this.context = c }-*/;
-
-  public final native void cursorBlinkRate(int r) /*-{ this.cursor_blink_rate = r }-*/;
-
-  public final native void intralineDifference(Boolean i) /*-{ this.intraline_difference = i }-*/;
-
-  public final native void showLineEndings(Boolean s) /*-{ this.show_line_endings = s }-*/;
-
-  public final native void showTabs(Boolean s) /*-{ this.show_tabs = s }-*/;
-
-  public final native void showWhitespaceErrors(
-      Boolean s) /*-{ this.show_whitespace_errors = s }-*/;
-
-  public final native void syntaxHighlighting(Boolean s) /*-{ this.syntax_highlighting = s }-*/;
-
-  public final native void hideTopMenu(Boolean s) /*-{ this.hide_top_menu = s }-*/;
-
-  public final native void autoHideDiffTableHeader(
-      Boolean s) /*-{ this.auto_hide_diff_table_header = s }-*/;
-
-  public final native void hideLineNumbers(Boolean s) /*-{ this.hide_line_numbers = s }-*/;
-
-  public final native void expandAllComments(Boolean e) /*-{ this.expand_all_comments = e }-*/;
-
-  public final native void manualReview(Boolean r) /*-{ this.manual_review = r }-*/;
-
-  public final native void renderEntireFile(Boolean r) /*-{ this.render_entire_file = r }-*/;
-
-  public final native void retainHeader(Boolean r) /*-{ this.retain_header = r }-*/;
-
-  public final native void hideEmptyPane(Boolean s) /*-{ this.hide_empty_pane = s }-*/;
-
-  public final native void skipUnchanged(Boolean s) /*-{ this.skip_unchanged = s }-*/;
-
-  public final native void skipUncommented(Boolean s) /*-{ this.skip_uncommented = s }-*/;
-
-  public final native void skipDeleted(Boolean s) /*-{ this.skip_deleted = s }-*/;
-
-  public final native void matchBrackets(Boolean m) /*-{ this.match_brackets = m }-*/;
-
-  public final native void lineWrapping(Boolean w) /*-{ this.line_wrapping = w }-*/;
-
-  public final native boolean
-      intralineDifference() /*-{ return this.intraline_difference || false }-*/;
-
-  public final native boolean showLineEndings() /*-{ return this.show_line_endings || false }-*/;
-
-  public final native boolean showTabs() /*-{ return this.show_tabs || false }-*/;
-
-  public final native boolean
-      showWhitespaceErrors() /*-{ return this.show_whitespace_errors || false }-*/;
-
-  public final native boolean
-      syntaxHighlighting() /*-{ return this.syntax_highlighting || false }-*/;
-
-  public final native boolean hideTopMenu() /*-{ return this.hide_top_menu || false }-*/;
-
-  public final native boolean
-      autoHideDiffTableHeader() /*-{ return this.auto_hide_diff_table_header || false }-*/;
-
-  public final native boolean hideLineNumbers() /*-{ return this.hide_line_numbers || false }-*/;
-
-  public final native boolean
-      expandAllComments() /*-{ return this.expand_all_comments || false }-*/;
-
-  public final native boolean manualReview() /*-{ return this.manual_review || false }-*/;
-
-  public final native boolean renderEntireFile() /*-{ return this.render_entire_file || false }-*/;
-
-  public final native boolean hideEmptyPane() /*-{ return this.hide_empty_pane || false }-*/;
-
-  public final native boolean retainHeader() /*-{ return this.retain_header || false }-*/;
-
-  public final native boolean skipUnchanged() /*-{ return this.skip_unchanged || false }-*/;
-
-  public final native boolean skipUncommented() /*-{ return this.skip_uncommented || false }-*/;
-
-  public final native boolean skipDeleted() /*-{ return this.skip_deleted || false }-*/;
-
-  public final native boolean matchBrackets() /*-{ return this.match_brackets || false }-*/;
-
-  public final native boolean lineWrapping() /*-{ return this.line_wrapping || false }-*/;
-
-  private native void setThemeRaw(String i) /*-{ this.theme = i }-*/;
-
-  private native void setIgnoreWhitespaceRaw(String i) /*-{ this.ignore_whitespace = i }-*/;
-
-  private native String ignoreWhitespaceRaw() /*-{ return this.ignore_whitespace }-*/;
-
-  private native String themeRaw() /*-{ return this.theme }-*/;
-
-  private native int get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
-
-  protected DiffPreferences() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java
deleted file mode 100644
index 9cd2f17..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java
+++ /dev/null
@@ -1,161 +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.client.account;
-
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.client.KeyMapType;
-import com.google.gerrit.extensions.client.Theme;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class EditPreferences extends JavaScriptObject {
-  public static EditPreferences create(EditPreferencesInfo in) {
-    EditPreferences p = createObject().cast();
-    p.tabSize(in.tabSize);
-    p.lineLength(in.lineLength);
-    p.indentUnit(in.indentUnit);
-    p.cursorBlinkRate(in.cursorBlinkRate);
-    p.hideTopMenu(in.hideTopMenu);
-    p.showTabs(in.showTabs);
-    p.showWhitespaceErrors(in.showWhitespaceErrors);
-    p.syntaxHighlighting(in.syntaxHighlighting);
-    p.hideLineNumbers(in.hideLineNumbers);
-    p.matchBrackets(in.matchBrackets);
-    p.lineWrapping(in.lineWrapping);
-    p.indentWithTabs(in.indentWithTabs);
-    p.autoCloseBrackets(in.autoCloseBrackets);
-    p.showBase(in.showBase);
-    p.theme(in.theme);
-    p.keyMapType(in.keyMapType);
-    return p;
-  }
-
-  public final EditPreferencesInfo copyTo(EditPreferencesInfo p) {
-    p.tabSize = tabSize();
-    p.lineLength = lineLength();
-    p.indentUnit = indentUnit();
-    p.cursorBlinkRate = cursorBlinkRate();
-    p.hideTopMenu = hideTopMenu();
-    p.showTabs = showTabs();
-    p.showWhitespaceErrors = showWhitespaceErrors();
-    p.syntaxHighlighting = syntaxHighlighting();
-    p.hideLineNumbers = hideLineNumbers();
-    p.matchBrackets = matchBrackets();
-    p.lineWrapping = lineWrapping();
-    p.indentWithTabs = indentWithTabs();
-    p.autoCloseBrackets = autoCloseBrackets();
-    p.showBase = showBase();
-    p.theme = theme();
-    p.keyMapType = keyMapType();
-    return p;
-  }
-
-  public final void theme(Theme i) {
-    setThemeRaw(i != null ? i.toString() : Theme.DEFAULT.toString());
-  }
-
-  private native void setThemeRaw(String i) /*-{ this.theme = i }-*/;
-
-  public final void keyMapType(KeyMapType i) {
-    setkeyMapTypeRaw(i != null ? i.toString() : KeyMapType.DEFAULT.toString());
-  }
-
-  private native void setkeyMapTypeRaw(String i) /*-{ this.key_map_type = i }-*/;
-
-  public final native void tabSize(int t) /*-{ this.tab_size = t }-*/;
-
-  public final native void lineLength(int c) /*-{ this.line_length = c }-*/;
-
-  public final native void indentUnit(int c) /*-{ this.indent_unit = c }-*/;
-
-  public final native void cursorBlinkRate(int r) /*-{ this.cursor_blink_rate = r }-*/;
-
-  public final native void hideTopMenu(boolean s) /*-{ this.hide_top_menu = s }-*/;
-
-  public final native void showTabs(boolean s) /*-{ this.show_tabs = s }-*/;
-
-  public final native void showWhitespaceErrors(
-      boolean s) /*-{ this.show_whitespace_errors = s }-*/;
-
-  public final native void syntaxHighlighting(boolean s) /*-{ this.syntax_highlighting = s }-*/;
-
-  public final native void hideLineNumbers(boolean s) /*-{ this.hide_line_numbers = s }-*/;
-
-  public final native void matchBrackets(boolean m) /*-{ this.match_brackets = m }-*/;
-
-  public final native void lineWrapping(boolean w) /*-{ this.line_wrapping = w }-*/;
-
-  public final native void indentWithTabs(boolean w) /*-{ this.indent_with_tabs = w }-*/;
-
-  public final native void autoCloseBrackets(boolean c) /*-{ this.auto_close_brackets = c }-*/;
-
-  public final native void showBase(boolean s) /*-{ this.show_base = s }-*/;
-
-  public final Theme theme() {
-    String s = themeRaw();
-    return s != null ? Theme.valueOf(s) : Theme.DEFAULT;
-  }
-
-  private native String themeRaw() /*-{ return this.theme }-*/;
-
-  public final KeyMapType keyMapType() {
-    String s = keyMapTypeRaw();
-    return s != null ? KeyMapType.valueOf(s) : KeyMapType.DEFAULT;
-  }
-
-  private native String keyMapTypeRaw() /*-{ return this.key_map_type }-*/;
-
-  public final int tabSize() {
-    return get("tab_size", 8);
-  }
-
-  public final int lineLength() {
-    return get("line_length", 100);
-  }
-
-  public final int indentUnit() {
-    return get("indent_unit", 2);
-  }
-
-  public final int cursorBlinkRate() {
-    return get("cursor_blink_rate", 0);
-  }
-
-  public final native boolean hideTopMenu() /*-{ return this.hide_top_menu || false }-*/;
-
-  public final native boolean showTabs() /*-{ return this.show_tabs || false }-*/;
-
-  public final native boolean
-      showWhitespaceErrors() /*-{ return this.show_whitespace_errors || false }-*/;
-
-  public final native boolean
-      syntaxHighlighting() /*-{ return this.syntax_highlighting || false }-*/;
-
-  public final native boolean hideLineNumbers() /*-{ return this.hide_line_numbers || false }-*/;
-
-  public final native boolean matchBrackets() /*-{ return this.match_brackets || false }-*/;
-
-  public final native boolean lineWrapping() /*-{ return this.line_wrapping || false }-*/;
-
-  public final native boolean indentWithTabs() /*-{ return this.indent_with_tabs || false }-*/;
-
-  public final native boolean
-      autoCloseBrackets() /*-{ return this.auto_close_brackets || false }-*/;
-
-  public final native boolean showBase() /*-{ return this.show_base || false }-*/;
-
-  private native int get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
-
-  protected EditPreferences() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EmailInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EmailInfo.java
deleted file mode 100644
index 9c324be..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EmailInfo.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class EmailInfo extends JavaScriptObject {
-  public final native String email() /*-{ return this.email; }-*/;
-
-  public final native boolean isPreferred() /*-{ return this['preferred'] ? true : false; }-*/;
-
-  public final native boolean
-      isConfirmationPending() /*-{ return this['pending_confirmation'] ? true : false; }-*/;
-
-  protected EmailInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ExternalIdInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ExternalIdInfo.java
deleted file mode 100644
index 4ac0716..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ExternalIdInfo.java
+++ /dev/null
@@ -1,89 +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.client.account;
-
-import com.google.gerrit.client.auth.openid.OpenIdUtil;
-import com.google.gerrit.common.auth.openid.OpenIdUrls;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class ExternalIdInfo extends JavaScriptObject implements Comparable<ExternalIdInfo> {
-  /**
-   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
-   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
-   *
-   * <p>The name {@code gerrit:} was a very poor choice.
-   */
-  private static final String SCHEME_GERRIT = "gerrit:";
-
-  /** Scheme used to represent only an email address. */
-  private static final String SCHEME_MAILTO = "mailto:";
-
-  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
-  private static final String SCHEME_USERNAME = "username:";
-
-  public final native String identity() /*-{ return this.identity; }-*/;
-
-  public final native String emailAddress() /*-{ return this.email_address; }-*/;
-
-  public final native boolean isTrusted() /*-{ return this['trusted'] ? true : false; }-*/;
-
-  public final native boolean canDelete() /*-{ return this['can_delete'] ? true : false; }-*/;
-
-  public final boolean isUsername() {
-    return isScheme(SCHEME_USERNAME);
-  }
-
-  public final String describe() {
-    if (isScheme(SCHEME_GERRIT)) {
-      // A local user identity should just be itself.
-      return getSchemeRest();
-    } else if (isScheme(SCHEME_USERNAME)) {
-      // A local user identity should just be itself.
-      return getSchemeRest();
-    } else if (isScheme(SCHEME_MAILTO)) {
-      // Describe a mailto address as just its email address,
-      // which is already shown in the email address field.
-      return "";
-    } else if (isScheme(OpenIdUrls.URL_LAUNCHPAD)) {
-      return OpenIdUtil.C.nameLaunchpad();
-    } else if (isScheme(OpenIdUrls.URL_YAHOO)) {
-      return OpenIdUtil.C.nameYahoo();
-    }
-
-    return identity();
-  }
-
-  @Override
-  public final int compareTo(ExternalIdInfo a) {
-    return emailOf(this).compareTo(emailOf(a));
-  }
-
-  private boolean isScheme(String scheme) {
-    return identity() != null && identity().startsWith(scheme);
-  }
-
-  private String getSchemeRest() {
-    int colonIdx = identity().indexOf(':');
-    String scheme = (colonIdx > 0) ? identity().substring(0, colonIdx) : null;
-    return scheme != null ? identity().substring(scheme.length() + 1) : null;
-  }
-
-  private String emailOf(ExternalIdInfo a) {
-    return a.emailAddress() != null ? a.emailAddress() : "";
-  }
-
-  protected ExternalIdInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java
deleted file mode 100644
index 4592b62..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.AgreementInfo;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.FancyFlexTable;
-import com.google.gerrit.client.ui.Hyperlink;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-import java.util.List;
-
-public class MyAgreementsScreen extends SettingsScreen {
-  private AgreementTable agreements;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    agreements = new AgreementTable();
-    add(agreements);
-    add(new Hyperlink(Util.C.newAgreement(), PageLinks.SETTINGS_NEW_AGREEMENT));
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    AccountApi.getAgreements(
-        "self",
-        new ScreenLoadCallback<JsArray<AgreementInfo>>(this) {
-          @Override
-          public void preDisplay(JsArray<AgreementInfo> result) {
-            agreements.display(Natives.asList(result));
-          }
-        });
-  }
-
-  private static class AgreementTable extends FancyFlexTable<ContributorAgreement> {
-    AgreementTable() {
-      table.setWidth("");
-      table.setText(0, 1, Util.C.agreementName());
-      table.setText(0, 2, Util.C.agreementDescription());
-
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      for (int c = 1; c < 3; c++) {
-        fmt.addStyleName(0, c, Gerrit.RESOURCES.css().dataHeader());
-      }
-    }
-
-    void display(List<AgreementInfo> result) {
-      while (1 < table.getRowCount()) {
-        table.removeRow(table.getRowCount() - 1);
-      }
-
-      for (AgreementInfo info : result) {
-        addOne(info);
-      }
-    }
-
-    void addOne(AgreementInfo info) {
-      int row = table.getRowCount();
-      table.insertRow(row);
-      applyDataRowStyle(row);
-
-      String url = info.url();
-      if (url != null && url.length() > 0) {
-        Anchor a = new Anchor(info.name(), url);
-        a.setTarget("_blank");
-        table.setWidget(row, 1, a);
-      } else {
-        table.setText(row, 1, info.name());
-      }
-      table.setText(row, 2, info.description());
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      for (int c = 1; c < 3; c++) {
-        fmt.addStyleName(row, c, Gerrit.RESOURCES.css().dataCell());
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyContactInformationScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyContactInformationScreen.java
deleted file mode 100644
index d5884f4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyContactInformationScreen.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-public class MyContactInformationScreen extends SettingsScreen {
-  private ContactPanelShort panel;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    panel =
-        new ContactPanelShort() {
-          @Override
-          void display() {
-            MyContactInformationScreen.this.display();
-          }
-        };
-    add(panel);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyDiffPreferencesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyDiffPreferencesScreen.java
deleted file mode 100644
index a721441..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyDiffPreferencesScreen.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.diff.PreferencesBox;
-import com.google.gwt.user.client.ui.FlowPanel;
-
-public class MyDiffPreferencesScreen extends SettingsScreen {
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    PreferencesBox pb = new PreferencesBox(null);
-    pb.set(DiffPreferences.create(Gerrit.getDiffPreferences()));
-    FlowPanel p = new FlowPanel();
-    p.setStyleName(pb.getStyle().dialog());
-    p.add(pb);
-    add(p);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    display();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyEditPreferencesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyEditPreferencesScreen.java
deleted file mode 100644
index 424b5d5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyEditPreferencesScreen.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.editor.EditPreferencesBox;
-import com.google.gwt.user.client.ui.FlowPanel;
-
-public class MyEditPreferencesScreen extends SettingsScreen {
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    EditPreferencesBox pb = new EditPreferencesBox(null);
-    pb.set(EditPreferences.create(Gerrit.getEditPreferences()));
-    FlowPanel p = new FlowPanel();
-    p.setStyleName(pb.getStyle().dialog());
-    p.add(pb);
-    add(p);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    display();
-  }
-}
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
deleted file mode 100644
index 0dc1dab..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
+++ /dev/null
@@ -1,296 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.GpgKeyInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.FancyFlexTable;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.http.client.Response;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.rpc.StatusCodeException;
-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 com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.VerticalPanel;
-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 {
-  interface Binder extends UiBinder<HTMLPanel, MyGpgKeysScreen> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField(provided = true)
-  GpgKeyTable keys;
-
-  @UiField Button deleteKey;
-  @UiField Button addKey;
-
-  @UiField VerticalPanel addKeyBlock;
-  @UiField NpTextArea keyText;
-
-  @UiField VerticalPanel errorPanel;
-  @UiField Label errorText;
-
-  @UiField Button clearButton;
-  @UiField Button addButton;
-  @UiField Button closeButton;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    keys = new GpgKeyTable();
-    add(uiBinder.createAndBindUi(this));
-    keys.updateDeleteButton();
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    refreshKeys();
-  }
-
-  @UiHandler("deleteKey")
-  void onDeleteKey(@SuppressWarnings("unused") ClickEvent e) {
-    keys.deleteChecked();
-  }
-
-  @UiHandler("addKey")
-  void onAddKey(@SuppressWarnings("unused") ClickEvent e) {
-    showAddKeyBlock(true);
-  }
-
-  @UiHandler("clearButton")
-  void onClearButton(@SuppressWarnings("unused") ClickEvent e) {
-    keyText.setText("");
-    keyText.setFocus(true);
-    errorPanel.setVisible(false);
-  }
-
-  @UiHandler("closeButton")
-  void onCloseButton(@SuppressWarnings("unused") ClickEvent e) {
-    showAddKeyBlock(false);
-  }
-
-  @UiHandler("addButton")
-  void onAddButton(@SuppressWarnings("unused") ClickEvent e) {
-    doAddKey();
-  }
-
-  private void refreshKeys() {
-    AccountApi.self()
-        .view("gpgkeys")
-        .get(
-            NativeMap.copyKeysIntoChildren(
-                "id",
-                new GerritCallback<NativeMap<GpgKeyInfo>>() {
-                  @Override
-                  public void onSuccess(NativeMap<GpgKeyInfo> result) {
-                    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());
-                          }
-                        });
-                    keys.clear();
-                    keyText.setText("");
-                    errorPanel.setVisible(false);
-                    addButton.setEnabled(true);
-                    if (!list.isEmpty()) {
-                      keys.setVisible(true);
-                      for (GpgKeyInfo k : list) {
-                        keys.addOneKey(k);
-                      }
-                      showKeyTable(true);
-                      showAddKeyBlock(false);
-                    } else {
-                      keys.setVisible(false);
-                      showAddKeyBlock(true);
-                      showKeyTable(false);
-                    }
-
-                    display();
-                  }
-                }));
-  }
-
-  private void showAddKeyBlock(boolean show) {
-    addKey.setVisible(!show);
-    addKeyBlock.setVisible(show);
-  }
-
-  private void showKeyTable(boolean show) {
-    keys.setVisible(show);
-    deleteKey.setVisible(show);
-    addKey.setVisible(show);
-  }
-
-  private void doAddKey() {
-    if (keyText.getText().isEmpty()) {
-      return;
-    }
-    addButton.setEnabled(false);
-    keyText.setEnabled(false);
-    AccountApi.addGpgKey(
-        "self",
-        keyText.getText(),
-        new AsyncCallback<NativeMap<GpgKeyInfo>>() {
-          @Override
-          public void onSuccess(NativeMap<GpgKeyInfo> result) {
-            keyText.setEnabled(true);
-            refreshKeys();
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            keyText.setEnabled(true);
-            addButton.setEnabled(true);
-            if (caught instanceof StatusCodeException) {
-              StatusCodeException sce = (StatusCodeException) caught;
-              if (sce.getStatusCode() == Response.SC_CONFLICT
-                  || sce.getStatusCode() == Response.SC_BAD_REQUEST) {
-                errorText.setText(sce.getEncodedResponse());
-              } else {
-                errorText.setText(sce.getMessage());
-              }
-            } else {
-              errorText.setText("Unexpected error saving key: " + caught.getMessage());
-            }
-            errorPanel.setVisible(true);
-          }
-        });
-  }
-
-  private class GpgKeyTable extends FancyFlexTable<GpgKeyInfo> {
-    private final ValueChangeHandler<Boolean> updateDeleteHandler;
-
-    GpgKeyTable() {
-      table.setWidth("");
-      table.setText(0, 1, Util.C.gpgKeyId());
-      table.setText(0, 2, Util.C.gpgKeyFingerprint());
-      table.setText(0, 3, Util.C.gpgKeyUserIds());
-
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().iconHeader());
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-
-      updateDeleteHandler =
-          new ValueChangeHandler<Boolean>() {
-            @Override
-            public void onValueChange(ValueChangeEvent<Boolean> event) {
-              updateDeleteButton();
-            }
-          };
-    }
-
-    private void addOneKey(GpgKeyInfo k) {
-      int row = table.getRowCount();
-      table.insertRow(row);
-      applyDataRowStyle(row);
-
-      CheckBox sel = new CheckBox();
-      sel.addValueChangeHandler(updateDeleteHandler);
-      table.setWidget(row, 0, sel);
-      table.setWidget(row, 1, new CopyableLabel(k.id()));
-      table.setText(row, 2, k.fingerprint());
-
-      VerticalPanel userIds = new VerticalPanel();
-      for (int i = 0; i < k.userIds().length(); i++) {
-        userIds.add(new InlineLabel(k.userIds().get(i)));
-      }
-      table.setWidget(row, 3, userIds);
-
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().iconCell());
-      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
-
-      setRowItem(row, k);
-    }
-
-    private void updateDeleteButton() {
-      for (int row = 1; row < table.getRowCount(); row++) {
-        if (isChecked(row)) {
-          deleteKey.setEnabled(true);
-          return;
-        }
-      }
-      deleteKey.setEnabled(false);
-    }
-
-    private void deleteChecked() {
-      deleteKey.setEnabled(false);
-      List<String> toDelete = new ArrayList<>(table.getRowCount());
-      for (int row = 1; row < table.getRowCount(); row++) {
-        if (isChecked(row)) {
-          toDelete.add(getRowItem(row).fingerprint());
-        }
-      }
-      AccountApi.deleteGpgKeys(
-          "self",
-          toDelete,
-          new GerritCallback<NativeMap<GpgKeyInfo>>() {
-            @Override
-            public void onSuccess(NativeMap<GpgKeyInfo> result) {
-              refreshKeys();
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              deleteKey.setEnabled(true);
-              super.onFailure(caught);
-            }
-          });
-    }
-
-    private boolean isChecked(int row) {
-      return ((CheckBox) table.getWidget(row, 0)).getValue();
-    }
-
-    private void clear() {
-      while (table.getRowCount() > 1) {
-        table.removeRow(1);
-      }
-      for (int i = table.getRowCount() - 1; i >= 1; i++) {
-        table.removeRow(i);
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.ui.xml
deleted file mode 100644
index dc73736..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.ui.xml
+++ /dev/null
@@ -1,94 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:expui='urn:import:com.google.gwtexpui.globalkey.client'>
-  <ui:with field='res' type='com.google.gerrit.client.GerritResources'/>
-
-  <ui:style gss='false'>
-    .errorHeader {
-      font-weight: bold;
-    }
-    .errorText {
-      white-space: pre-wrap;
-      padding-bottom: 6px;
-    }
-  </ui:style>
-
-  <g:HTMLPanel>
-    <g:Widget ui:field='keys' addStyleNames='{res.css.sshKeyTable}'/>
-    <g:FlowPanel>
-      <g:Button ui:field='deleteKey'>
-        <div><ui:msg>Delete</ui:msg></div>
-      </g:Button>
-      <g:Button ui:field='addKey'>
-        <div><ui:msg>Add Key ...</ui:msg></div>
-      </g:Button>
-    </g:FlowPanel>
-    <g:VerticalPanel ui:field='addKeyBlock'
-        styleName='{res.css.addSshKeyPanel}'
-        visible='false'>
-      <g:Label>Add GPG Public Key</g:Label>
-      <g:DisclosurePanel>
-        <g:header>How to generate a GPG key</g:header>
-        <g:HTMLPanel>
-          <ol>
-            <li>
-              From the Terminal or Git Bash, run <em>gpg --gen-key</em> and
-              follow the prompts to create the key.
-            </li>
-            <li>
-              Use the default kind. Use the default (or higher) keysize. Choose
-              any value for your expiration.
-            </li>
-            <li>
-              The user ID should contain one of your registered email addresses.
-            </li>
-            <li>Setting a passphrase is strongly recommended.</li>
-            <li>Note the ID of your new key.</li>
-            <li>
-              To export your key, run the following and paste the full output
-              into the text box:
-              <br/>
-              <code>gpg --export -a &lt;key ID&gt;</code>
-            </li>
-          </ol>
-        </g:HTMLPanel>
-      </g:DisclosurePanel>
-      <expui:NpTextArea
-          visibleLines='12'
-          characterWidth='80'
-          spellCheck='false'
-          ui:field='keyText'/>
-      <g:VerticalPanel ui:field='errorPanel' visible='false'>
-        <g:Label styleName='{style.errorHeader}'>Error adding GPG key:</g:Label>
-        <g:Label styleName='{style.errorText}' ui:field='errorText'/>
-      </g:VerticalPanel>
-      <g:FlowPanel>
-        <g:Button ui:field='clearButton'>
-          <div><ui:msg>Clear</ui:msg></div>
-        </g:Button>
-        <g:Button ui:field='addButton'>
-          <div><ui:msg>Add</ui:msg></div>
-        </g:Button>
-        <g:Button ui:field='closeButton'>
-          <div><ui:msg>Close</ui:msg></div>
-        </g:Button>
-      </g:FlowPanel>
-    </g:VerticalPanel>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java
deleted file mode 100644
index e9112de..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.account;
-
-import com.google.gerrit.client.admin.GroupTable;
-import com.google.gerrit.client.groups.GroupList;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-
-public class MyGroupsScreen extends SettingsScreen {
-  private GroupTable groups;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    groups = new GroupTable();
-    add(groups);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    GroupList.my(
-        new ScreenLoadCallback<GroupList>(this) {
-          @Override
-          protected void preDisplay(GroupList result) {
-            groups.display(result);
-            groups.finishDisplay();
-          }
-        });
-  }
-}
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
deleted file mode 100644
index 5c6d40f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
+++ /dev/null
@@ -1,223 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.FancyFlexTable;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.Window.Location;
-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;
-
-public class MyIdentitiesScreen extends SettingsScreen {
-  private IdTable identites;
-  private Button deleteIdentity;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    identites = new IdTable();
-    add(identites);
-
-    deleteIdentity = new Button(Util.C.buttonDeleteIdentity());
-    deleteIdentity.setEnabled(false);
-    deleteIdentity.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            identites.deleteChecked();
-          }
-        });
-    add(deleteIdentity);
-
-    if (Gerrit.info().auth().isOpenId() || Gerrit.info().auth().isOAuth()) {
-      Button linkIdentity = new Button(Util.C.buttonLinkIdentity());
-      linkIdentity.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              Location.assign(Gerrit.loginRedirect(History.getToken()) + "?link");
-            }
-          });
-      add(linkIdentity);
-    }
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    AccountApi.getExternalIds(
-        new GerritCallback<JsArray<ExternalIdInfo>>() {
-          @Override
-          public void onSuccess(JsArray<ExternalIdInfo> results) {
-            identites.display(results);
-            display();
-          }
-        });
-  }
-
-  private class IdTable extends FancyFlexTable<ExternalIdInfo> {
-    private ValueChangeHandler<Boolean> updateDeleteHandler;
-
-    IdTable() {
-      table.setWidth("");
-      table.setText(0, 2, Util.C.webIdStatus());
-      table.setText(0, 3, Util.C.webIdEmail());
-      table.setText(0, 4, Util.C.webIdIdentity());
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
-      fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
-
-      updateDeleteHandler =
-          new ValueChangeHandler<Boolean>() {
-            @Override
-            public void onValueChange(ValueChangeEvent<Boolean> event) {
-              updateDeleteButton();
-            }
-          };
-    }
-
-    void deleteChecked() {
-      final HashSet<String> keys = new HashSet<>();
-      for (int row = 1; row < table.getRowCount(); row++) {
-        final ExternalIdInfo k = getRowItem(row);
-        if (k == null) {
-          continue;
-        }
-        final CheckBox cb = (CheckBox) table.getWidget(row, 1);
-        if (cb == null) {
-          continue;
-        }
-        if (cb.getValue()) {
-          keys.add(k.identity());
-        }
-      }
-      if (keys.isEmpty()) {
-        updateDeleteButton();
-      } else {
-        deleteIdentity.setEnabled(false);
-        AccountApi.deleteExternalIds(
-            keys,
-            new GerritCallback<VoidResult>() {
-              @Override
-              public void onSuccess(VoidResult result) {
-                for (int row = 1; row < table.getRowCount(); ) {
-                  final ExternalIdInfo k = getRowItem(row);
-                  if (k != null && keys.contains(k.identity())) {
-                    table.removeRow(row);
-                  } else {
-                    row++;
-                  }
-                }
-                updateDeleteButton();
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                updateDeleteButton();
-                super.onFailure(caught);
-              }
-            });
-      }
-    }
-
-    void updateDeleteButton() {
-      int off = 0;
-      boolean on = false;
-      for (int row = 1; row < table.getRowCount(); row++) {
-        if (table.getWidget(row, 1) == null) {
-          off++;
-        } else {
-          CheckBox sel = (CheckBox) table.getWidget(row, 1);
-          if (sel.getValue()) {
-            on = true;
-            break;
-          }
-        }
-      }
-      deleteIdentity.setVisible(off < table.getRowCount() - 1);
-      deleteIdentity.setEnabled(on);
-    }
-
-    void display(JsArray<ExternalIdInfo> results) {
-      List<ExternalIdInfo> idList = Natives.asList(results);
-      Collections.sort(idList);
-
-      while (1 < table.getRowCount()) {
-        table.removeRow(table.getRowCount() - 1);
-      }
-
-      for (ExternalIdInfo k : idList) {
-        addOneId(k);
-      }
-      updateDeleteButton();
-    }
-
-    void addOneId(ExternalIdInfo k) {
-      if (k.isUsername()) {
-        // Don't display the username as an identity here.
-        return;
-      }
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      final int row = table.getRowCount();
-      table.insertRow(row);
-      applyDataRowStyle(row);
-
-      if (k.canDelete()) {
-        final CheckBox sel = new CheckBox();
-        sel.addValueChangeHandler(updateDeleteHandler);
-        table.setWidget(row, 1, sel);
-      } else {
-        table.setText(row, 1, "");
-      }
-      if (k.isTrusted()) {
-        table.setText(row, 2, "");
-      } else {
-        table.setText(row, 2, Util.C.untrustedProvider());
-        fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().identityUntrustedExternalId());
-      }
-      if (k.emailAddress() != null && k.emailAddress().length() > 0) {
-        table.setText(row, 3, k.emailAddress());
-      } else {
-        table.setText(row, 3, "");
-      }
-      table.setText(row, 4, k.describe());
-
-      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().iconCell());
-      fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 4, Gerrit.RESOURCES.css().dataCell());
-
-      setRowItem(row, k);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java
deleted file mode 100644
index 173dba6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java
+++ /dev/null
@@ -1,198 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gerrit.client.info.OAuthTokenInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gwt.i18n.client.DateTimeFormat;
-import com.google.gwt.i18n.client.LocaleInfo;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-import java.util.Date;
-
-public class MyOAuthTokenScreen extends SettingsScreen {
-  private CopyableLabel tokenLabel;
-  private Label expiresLabel;
-  private Label expiredNote;
-  private CopyableLabel netrcValue;
-  private CopyableLabel cookieValue;
-  private FlowPanel flow;
-  private Grid grid;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    tokenLabel = new CopyableLabel("");
-    tokenLabel.addStyleName(Gerrit.RESOURCES.css().oauthToken());
-
-    expiresLabel = new Label("");
-    expiresLabel.addStyleName(Gerrit.RESOURCES.css().oauthExpires());
-
-    grid = new Grid(2, 2);
-    grid.setStyleName(Gerrit.RESOURCES.css().infoBlock());
-    grid.addStyleName(Gerrit.RESOURCES.css().oauthInfoBlock());
-    add(grid);
-
-    expiredNote = new Label(Util.C.labelOAuthExpired());
-    expiredNote.setVisible(false);
-    add(expiredNote);
-
-    row(grid, 0, Util.C.labelOAuthToken(), tokenLabel);
-    row(grid, 1, Util.C.labelOAuthExpires(), expiresLabel);
-
-    CellFormatter fmt = grid.getCellFormatter();
-    fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().topmost());
-    fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().topmost());
-    fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().bottomheader());
-
-    flow = new FlowPanel();
-    flow.setStyleName(Gerrit.RESOURCES.css().oauthPanel());
-    add(flow);
-
-    Label netrcLabel = new Label(Util.C.labelOAuthNetRCEntry());
-    netrcLabel.setStyleName(Gerrit.RESOURCES.css().oauthPanelNetRCHeading());
-    flow.add(netrcLabel);
-    netrcValue = new CopyableLabel("");
-    netrcValue.setStyleName(Gerrit.RESOURCES.css().oauthPanelNetRCEntry());
-    flow.add(netrcValue);
-
-    Label cookieLabel = new Label(Util.C.labelOAuthGitCookie());
-    cookieLabel.setStyleName(Gerrit.RESOURCES.css().oauthPanelCookieHeading());
-    flow.add(cookieLabel);
-    cookieValue = new CopyableLabel("");
-    cookieValue.setStyleName(Gerrit.RESOURCES.css().oauthPanelCookieEntry());
-    flow.add(cookieValue);
-  }
-
-  private void row(Grid grid, int row, String name, Widget field) {
-    final CellFormatter fmt = grid.getCellFormatter();
-    if (LocaleInfo.getCurrentLocale().isRTL()) {
-      grid.setText(row, 1, name);
-      grid.setWidget(row, 0, field);
-      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().header());
-    } else {
-      grid.setText(row, 0, name);
-      grid.setWidget(row, 1, field);
-      fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().header());
-    }
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    AccountApi.self()
-        .view("preferences")
-        .get(
-            new ScreenLoadCallback<GeneralPreferences>(this) {
-              @Override
-              protected void preDisplay(GeneralPreferences prefs) {
-                display(prefs);
-              }
-            });
-  }
-
-  private void display(GeneralPreferences prefs) {
-    AccountApi.self()
-        .view("oauthtoken")
-        .get(
-            new GerritCallback<OAuthTokenInfo>() {
-              @Override
-              public void onSuccess(OAuthTokenInfo tokenInfo) {
-                tokenLabel.setText(tokenInfo.accessToken());
-                expiresLabel.setText(getExpiresAt(tokenInfo, prefs));
-                netrcValue.setText(getNetRC(tokenInfo));
-                cookieValue.setText(getCookie(tokenInfo));
-                flow.setVisible(true);
-                expiredNote.setVisible(false);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                if (isNoSuchEntity(caught) || isSigninFailure(caught)) {
-                  tokenLabel.setText("");
-                  expiresLabel.setText("");
-                  netrcValue.setText("");
-                  cookieValue.setText("");
-                  flow.setVisible(false);
-                  expiredNote.setVisible(true);
-                } else {
-                  showFailure(caught);
-                }
-              }
-            });
-  }
-
-  private static long getExpiresAt(OAuthTokenInfo tokenInfo) {
-    if (tokenInfo.expiresAt() == null) {
-      return Long.MAX_VALUE;
-    }
-    long expiresAt;
-    try {
-      expiresAt = Long.parseLong(tokenInfo.expiresAt());
-    } catch (NumberFormatException e) {
-      return Long.MAX_VALUE;
-    }
-    return expiresAt;
-  }
-
-  private static long getExpiresAtSeconds(OAuthTokenInfo tokenInfo) {
-    return getExpiresAt(tokenInfo) / 1000L;
-  }
-
-  private static String getExpiresAt(OAuthTokenInfo tokenInfo, GeneralPreferences prefs) {
-    long expiresAt = getExpiresAt(tokenInfo);
-    if (expiresAt == Long.MAX_VALUE) {
-      return "";
-    }
-    String dateFormat = prefs.dateFormat().getLongFormat();
-    String timeFormat = prefs.timeFormat().getFormat();
-    DateTimeFormat formatter = DateTimeFormat.getFormat(dateFormat + " " + timeFormat);
-    return formatter.format(new Date(expiresAt));
-  }
-
-  private static String getNetRC(OAuthTokenInfo accessTokenInfo) {
-    StringBuilder sb = new StringBuilder();
-    sb.append("machine ");
-    sb.append(accessTokenInfo.resourceHost());
-    sb.append(" login ");
-    sb.append(accessTokenInfo.username());
-    sb.append(" password ");
-    sb.append(accessTokenInfo.accessToken());
-    return sb.toString();
-  }
-
-  private static String getCookie(OAuthTokenInfo accessTokenInfo) {
-    StringBuilder sb = new StringBuilder();
-    sb.append(accessTokenInfo.resourceHost());
-    sb.append("\tFALSE\t/\tTRUE\t");
-    sb.append(getExpiresAtSeconds(accessTokenInfo));
-    sb.append("\tgit-");
-    sb.append(accessTokenInfo.username());
-    sb.append('\t');
-    sb.append(accessTokenInfo.accessToken());
-    if (accessTokenInfo.providerId() != null) {
-      sb.append('@').append(accessTokenInfo.providerId());
-    }
-    return sb.toString();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
deleted file mode 100644
index 5dd7530..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gerrit.client.api.ExtensionPanel;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.i18n.client.LocaleInfo;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-
-public class MyPasswordScreen extends SettingsScreen {
-  private CopyableLabel password;
-  private Button generatePassword;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    String url = Gerrit.info().auth().httpPasswordUrl();
-    if (url != null) {
-      Anchor link = new Anchor();
-      link.setText(Util.C.linkObtainPassword());
-      link.setHref(url);
-      link.setTarget("_blank");
-      add(link);
-      return;
-    }
-
-    password = new CopyableLabel(Util.C.revokePassword());
-    password.addStyleName(Gerrit.RESOURCES.css().accountPassword());
-
-    generatePassword = new Button(Util.C.buttonGeneratePassword());
-    generatePassword.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doGeneratePassword();
-          }
-        });
-
-    final Grid userInfo = new Grid(2, 2);
-    final CellFormatter fmt = userInfo.getCellFormatter();
-    userInfo.setStyleName(Gerrit.RESOURCES.css().infoBlock());
-    userInfo.addStyleName(Gerrit.RESOURCES.css().accountInfoBlock());
-    add(userInfo);
-
-    row(userInfo, 0, Util.C.userName(), new UsernameField());
-    row(userInfo, 1, Util.C.password(), password);
-
-    fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().topmost());
-    fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().topmost());
-    fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().bottomheader());
-
-    final FlowPanel buttons = new FlowPanel();
-    buttons.add(generatePassword);
-    add(buttons);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    ExtensionPanel extensionPanel =
-        createExtensionPoint(GerritUiExtensionPoint.PASSWORD_SCREEN_BOTTOM);
-    extensionPanel.addStyleName(Gerrit.RESOURCES.css().extensionPanel());
-    add(extensionPanel);
-
-    if (password == null) {
-      display();
-      return;
-    }
-
-    enableUI(false);
-    AccountApi.getUsername(
-        "self",
-        new GerritCallback<NativeString>() {
-          @Override
-          public void onSuccess(NativeString user) {
-            Gerrit.getUserAccount().username(user.asString());
-            enableUI(true);
-            display();
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            if (RestApi.isNotFound(caught)) {
-              Gerrit.getUserAccount().username(null);
-              display();
-            } else {
-              super.onFailure(caught);
-            }
-          }
-        });
-  }
-
-  private void display(String pass) {
-    password.setText(pass != null ? pass : "");
-    password.setVisible(pass != null);
-    enableUI(true);
-  }
-
-  private void row(Grid info, int row, String name, Widget field) {
-    final CellFormatter fmt = info.getCellFormatter();
-    if (LocaleInfo.getCurrentLocale().isRTL()) {
-      info.setText(row, 1, name);
-      info.setWidget(row, 0, field);
-      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().header());
-    } else {
-      info.setText(row, 0, name);
-      info.setWidget(row, 1, field);
-      fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().header());
-    }
-  }
-
-  private void doGeneratePassword() {
-    if (Gerrit.getUserAccount().username() != null) {
-      enableUI(false);
-      AccountApi.generateHttpPassword(
-          "self",
-          new GerritCallback<NativeString>() {
-            @Override
-            public void onSuccess(NativeString newPassword) {
-              display(newPassword.asString());
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              enableUI(true);
-            }
-          });
-    }
-  }
-
-  private void enableUI(boolean on) {
-    on &= Gerrit.getUserAccount().username() != null;
-
-    generatePassword.setEnabled(on);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
deleted file mode 100644
index f349065..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
+++ /dev/null
@@ -1,505 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.DEFAULT_PAGESIZE;
-import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.PAGESIZE_CHOICES;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gerrit.client.StringListPanel;
-import com.google.gerrit.client.api.ExtensionPanel;
-import com.google.gerrit.client.config.ConfigServerApi;
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gerrit.client.info.TopMenuItem;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.i18n.client.DateTimeFormat;
-import com.google.gwt.i18n.client.LocaleInfo;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwtexpui.user.client.UserAgent;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.List;
-
-public class MyPreferencesScreen extends SettingsScreen {
-  private CheckBox showSiteHeader;
-  private CheckBox useFlashClipboard;
-  private CheckBox highlightAssigneeInChangeTable;
-  private CheckBox relativeDateInChangeTable;
-  private CheckBox sizeBarInChangeTable;
-  private CheckBox legacycidInChangeTable;
-  private CheckBox muteCommonPathPrefixes;
-  private CheckBox signedOffBy;
-  private CheckBox publishCommentsOnPush;
-  private ListBox maximumPageSize;
-  private ListBox dateFormat;
-  private ListBox timeFormat;
-  private ListBox reviewCategoryStrategy;
-  private ListBox diffView;
-  private ListBox emailStrategy;
-  private ListBox emailFormat;
-  private ListBox defaultBaseForMerges;
-  private StringListPanel myMenus;
-  private Button save;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    showSiteHeader = new CheckBox(Util.C.showSiteHeader());
-    useFlashClipboard = new CheckBox(Util.C.useFlashClipboard());
-    maximumPageSize = new ListBox();
-    for (int v : PAGESIZE_CHOICES) {
-      maximumPageSize.addItem(Util.M.rowsPerPage(v), String.valueOf(v));
-    }
-
-    reviewCategoryStrategy = new ListBox();
-    reviewCategoryStrategy.addItem(
-        Util.C.messageShowInReviewCategoryNone(),
-        GeneralPreferencesInfo.ReviewCategoryStrategy.NONE.name());
-    reviewCategoryStrategy.addItem(
-        Util.C.messageShowInReviewCategoryName(),
-        GeneralPreferencesInfo.ReviewCategoryStrategy.NAME.name());
-    reviewCategoryStrategy.addItem(
-        Util.C.messageShowInReviewCategoryEmail(),
-        GeneralPreferencesInfo.ReviewCategoryStrategy.EMAIL.name());
-    reviewCategoryStrategy.addItem(
-        Util.C.messageShowInReviewCategoryUsername(),
-        GeneralPreferencesInfo.ReviewCategoryStrategy.USERNAME.name());
-    reviewCategoryStrategy.addItem(
-        Util.C.messageShowInReviewCategoryAbbrev(),
-        GeneralPreferencesInfo.ReviewCategoryStrategy.ABBREV.name());
-
-    emailStrategy = new ListBox();
-    emailStrategy.addItem(
-        Util.C.messageCCMeOnMyComments(),
-        GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS.name());
-    emailStrategy.addItem(
-        Util.C.messageEnabled(), GeneralPreferencesInfo.EmailStrategy.ENABLED.name());
-    emailStrategy.addItem(
-        Util.C.messageDisabled(), GeneralPreferencesInfo.EmailStrategy.DISABLED.name());
-
-    emailFormat = new ListBox();
-    emailFormat.addItem(
-        Util.C.messagePlaintextOnly(), GeneralPreferencesInfo.EmailFormat.PLAINTEXT.name());
-    emailFormat.addItem(
-        Util.C.messageHtmlPlaintext(), GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT.name());
-
-    defaultBaseForMerges = new ListBox();
-    defaultBaseForMerges.addItem(
-        Util.C.autoMerge(), GeneralPreferencesInfo.DefaultBase.AUTO_MERGE.name());
-    defaultBaseForMerges.addItem(
-        Util.C.firstParent(), GeneralPreferencesInfo.DefaultBase.FIRST_PARENT.name());
-
-    diffView = new ListBox();
-    diffView.addItem(
-        com.google.gerrit.client.changes.Util.C.sideBySide(),
-        GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE.name());
-    diffView.addItem(
-        com.google.gerrit.client.changes.Util.C.unifiedDiff(),
-        GeneralPreferencesInfo.DiffView.UNIFIED_DIFF.name());
-
-    Date now = new Date();
-    dateFormat = new ListBox();
-    for (GeneralPreferencesInfo.DateFormat fmt : GeneralPreferencesInfo.DateFormat.values()) {
-      StringBuilder r = new StringBuilder();
-      r.append(DateTimeFormat.getFormat(fmt.getShortFormat()).format(now));
-      r.append(" ; ");
-      r.append(DateTimeFormat.getFormat(fmt.getLongFormat()).format(now));
-      dateFormat.addItem(r.toString(), fmt.name());
-    }
-
-    timeFormat = new ListBox();
-    for (GeneralPreferencesInfo.TimeFormat fmt : GeneralPreferencesInfo.TimeFormat.values()) {
-      StringBuilder r = new StringBuilder();
-      r.append(DateTimeFormat.getFormat(fmt.getFormat()).format(now));
-      timeFormat.addItem(r.toString(), fmt.name());
-    }
-
-    FlowPanel dateTimePanel = new FlowPanel();
-
-    final int labelIdx;
-    final int fieldIdx;
-    if (LocaleInfo.getCurrentLocale().isRTL()) {
-      labelIdx = 1;
-      fieldIdx = 0;
-      dateTimePanel.add(timeFormat);
-      dateTimePanel.add(dateFormat);
-    } else {
-      labelIdx = 0;
-      fieldIdx = 1;
-      dateTimePanel.add(dateFormat);
-      dateTimePanel.add(timeFormat);
-    }
-    highlightAssigneeInChangeTable = new CheckBox(Util.C.highlightAssigneeInChangeTable());
-    relativeDateInChangeTable = new CheckBox(Util.C.showRelativeDateInChangeTable());
-    sizeBarInChangeTable = new CheckBox(Util.C.showSizeBarInChangeTable());
-    legacycidInChangeTable = new CheckBox(Util.C.showLegacycidInChangeTable());
-    muteCommonPathPrefixes = new CheckBox(Util.C.muteCommonPathPrefixes());
-    signedOffBy = new CheckBox(Util.C.signedOffBy());
-    publishCommentsOnPush = new CheckBox(Util.C.publishCommentsOnPush());
-
-    boolean flashClippy = !UserAgent.hasJavaScriptClipboard() && UserAgent.Flash.isInstalled();
-    final Grid formGrid = new Grid(15 + (flashClippy ? 1 : 0), 2);
-
-    int row = 0;
-
-    formGrid.setText(row, labelIdx, Util.C.reviewCategoryLabel());
-    formGrid.setWidget(row, fieldIdx, reviewCategoryStrategy);
-    row++;
-
-    formGrid.setText(row, labelIdx, Util.C.maximumPageSizeFieldLabel());
-    formGrid.setWidget(row, fieldIdx, maximumPageSize);
-    row++;
-
-    formGrid.setText(row, labelIdx, Util.C.dateFormatLabel());
-    formGrid.setWidget(row, fieldIdx, dateTimePanel);
-    row++;
-
-    formGrid.setText(row, labelIdx, Util.C.emailFieldLabel());
-    formGrid.setWidget(row, fieldIdx, emailStrategy);
-    row++;
-
-    formGrid.setText(row, labelIdx, Util.C.emailFormatFieldLabel());
-    formGrid.setWidget(row, fieldIdx, emailFormat);
-    row++;
-
-    formGrid.setText(row, labelIdx, Util.C.defaultBaseForMerges());
-    formGrid.setWidget(row, fieldIdx, defaultBaseForMerges);
-    row++;
-
-    formGrid.setText(row, labelIdx, Util.C.diffViewLabel());
-    formGrid.setWidget(row, fieldIdx, diffView);
-    row++;
-
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, showSiteHeader);
-    row++;
-
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, highlightAssigneeInChangeTable);
-    row++;
-
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, relativeDateInChangeTable);
-    row++;
-
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, sizeBarInChangeTable);
-    row++;
-
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, legacycidInChangeTable);
-    row++;
-
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, muteCommonPathPrefixes);
-    row++;
-
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, signedOffBy);
-    row++;
-
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, publishCommentsOnPush);
-    row++;
-
-    if (flashClippy) {
-      formGrid.setText(row, labelIdx, "");
-      formGrid.setWidget(row, fieldIdx, useFlashClipboard);
-    }
-
-    add(formGrid);
-
-    save = new Button(Util.C.buttonSaveChanges());
-    save.setEnabled(false);
-    save.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doSave();
-          }
-        });
-
-    myMenus = new MyMenuPanel(save);
-    add(myMenus);
-
-    add(save);
-
-    final OnEditEnabler e = new OnEditEnabler(save);
-    e.listenTo(showSiteHeader);
-    e.listenTo(useFlashClipboard);
-    e.listenTo(maximumPageSize);
-    e.listenTo(dateFormat);
-    e.listenTo(timeFormat);
-    e.listenTo(highlightAssigneeInChangeTable);
-    e.listenTo(relativeDateInChangeTable);
-    e.listenTo(sizeBarInChangeTable);
-    e.listenTo(legacycidInChangeTable);
-    e.listenTo(muteCommonPathPrefixes);
-    e.listenTo(signedOffBy);
-    e.listenTo(publishCommentsOnPush);
-    e.listenTo(diffView);
-    e.listenTo(reviewCategoryStrategy);
-    e.listenTo(emailStrategy);
-    e.listenTo(emailFormat);
-    e.listenTo(defaultBaseForMerges);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    ExtensionPanel extensionPanel =
-        createExtensionPoint(GerritUiExtensionPoint.PREFERENCES_SCREEN_BOTTOM);
-    extensionPanel.addStyleName(Gerrit.RESOURCES.css().extensionPanel());
-    add(extensionPanel);
-
-    AccountApi.self()
-        .view("preferences")
-        .get(
-            new ScreenLoadCallback<GeneralPreferences>(this) {
-              @Override
-              public void preDisplay(GeneralPreferences prefs) {
-                display(prefs);
-              }
-            });
-  }
-
-  private void enable(boolean on) {
-    showSiteHeader.setEnabled(on);
-    useFlashClipboard.setEnabled(on);
-    maximumPageSize.setEnabled(on);
-    dateFormat.setEnabled(on);
-    timeFormat.setEnabled(on);
-    highlightAssigneeInChangeTable.setEnabled(on);
-    relativeDateInChangeTable.setEnabled(on);
-    sizeBarInChangeTable.setEnabled(on);
-    legacycidInChangeTable.setEnabled(on);
-    muteCommonPathPrefixes.setEnabled(on);
-    signedOffBy.setEnabled(on);
-    publishCommentsOnPush.setEnabled(on);
-    reviewCategoryStrategy.setEnabled(on);
-    diffView.setEnabled(on);
-    emailStrategy.setEnabled(on);
-    emailFormat.setEnabled(on);
-    defaultBaseForMerges.setEnabled(on);
-  }
-
-  private void display(GeneralPreferences p) {
-    showSiteHeader.setValue(p.showSiteHeader());
-    useFlashClipboard.setValue(p.useFlashClipboard());
-    setListBox(maximumPageSize, DEFAULT_PAGESIZE, p.changesPerPage());
-    setListBox(
-        dateFormat,
-        GeneralPreferencesInfo.DateFormat.STD, //
-        p.dateFormat());
-    setListBox(
-        timeFormat,
-        GeneralPreferencesInfo.TimeFormat.HHMM_12, //
-        p.timeFormat());
-    highlightAssigneeInChangeTable.setValue(p.highlightAssigneeInChangeTable());
-    relativeDateInChangeTable.setValue(p.relativeDateInChangeTable());
-    sizeBarInChangeTable.setValue(p.sizeBarInChangeTable());
-    legacycidInChangeTable.setValue(p.legacycidInChangeTable());
-    muteCommonPathPrefixes.setValue(p.muteCommonPathPrefixes());
-    signedOffBy.setValue(p.signedOffBy());
-    publishCommentsOnPush.setValue(p.publishCommentsOnPush());
-    setListBox(
-        reviewCategoryStrategy,
-        GeneralPreferencesInfo.ReviewCategoryStrategy.NONE,
-        p.reviewCategoryStrategy());
-    setListBox(diffView, GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE, p.diffView());
-    setListBox(emailStrategy, GeneralPreferencesInfo.EmailStrategy.ENABLED, p.emailStrategy());
-    setListBox(emailFormat, GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT, p.emailFormat());
-    setListBox(
-        defaultBaseForMerges,
-        GeneralPreferencesInfo.DefaultBase.FIRST_PARENT,
-        p.defaultBaseForMerges());
-    display(p.my());
-  }
-
-  private void display(JsArray<TopMenuItem> items) {
-    List<List<String>> values = new ArrayList<>();
-    for (TopMenuItem item : Natives.asList(items)) {
-      values.add(Arrays.asList(item.getName(), item.getUrl()));
-    }
-    myMenus.display(values);
-  }
-
-  private void setListBox(ListBox f, int defaultValue, int currentValue) {
-    setListBox(f, String.valueOf(defaultValue), String.valueOf(currentValue));
-  }
-
-  private <T extends Enum<?>> void setListBox(final ListBox f, T defaultValue, T currentValue) {
-    setListBox(
-        f,
-        defaultValue != null ? defaultValue.name() : "",
-        currentValue != null ? currentValue.name() : "");
-  }
-
-  private void setListBox(ListBox f, String defaultValue, String currentValue) {
-    final int n = f.getItemCount();
-    for (int i = 0; i < n; i++) {
-      if (f.getValue(i).equals(currentValue)) {
-        f.setSelectedIndex(i);
-        return;
-      }
-    }
-    if (!currentValue.equals(defaultValue)) {
-      setListBox(f, defaultValue, defaultValue);
-    }
-  }
-
-  private int getListBox(ListBox f, int defaultValue) {
-    final int idx = f.getSelectedIndex();
-    if (0 <= idx) {
-      return Short.parseShort(f.getValue(idx));
-    }
-    return defaultValue;
-  }
-
-  private <T extends Enum<?>> T getListBox(ListBox f, T defaultValue, T[] all) {
-    final int idx = f.getSelectedIndex();
-    if (0 <= idx) {
-      String v = f.getValue(idx);
-      if ("".equals(v)) {
-        return defaultValue;
-      }
-      for (T t : all) {
-        if (t.name().equals(v)) {
-          return t;
-        }
-      }
-    }
-    return defaultValue;
-  }
-
-  private void doSave() {
-    GeneralPreferences p = GeneralPreferences.create();
-    p.showSiteHeader(showSiteHeader.getValue());
-    p.useFlashClipboard(useFlashClipboard.getValue());
-    p.changesPerPage(getListBox(maximumPageSize, DEFAULT_PAGESIZE));
-    p.dateFormat(
-        getListBox(
-            dateFormat,
-            GeneralPreferencesInfo.DateFormat.STD,
-            GeneralPreferencesInfo.DateFormat.values()));
-    p.timeFormat(
-        getListBox(
-            timeFormat,
-            GeneralPreferencesInfo.TimeFormat.HHMM_12,
-            GeneralPreferencesInfo.TimeFormat.values()));
-    p.highlightAssigneeInChangeTable(highlightAssigneeInChangeTable.getValue());
-    p.relativeDateInChangeTable(relativeDateInChangeTable.getValue());
-    p.sizeBarInChangeTable(sizeBarInChangeTable.getValue());
-    p.legacycidInChangeTable(legacycidInChangeTable.getValue());
-    p.muteCommonPathPrefixes(muteCommonPathPrefixes.getValue());
-    p.signedOffBy(signedOffBy.getValue());
-    p.publishCommentsOnPush(publishCommentsOnPush.getValue());
-    p.reviewCategoryStrategy(
-        getListBox(
-            reviewCategoryStrategy, ReviewCategoryStrategy.NONE, ReviewCategoryStrategy.values()));
-    p.diffView(
-        getListBox(
-            diffView,
-            GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE,
-            GeneralPreferencesInfo.DiffView.values()));
-
-    p.emailStrategy(
-        getListBox(
-            emailStrategy,
-            GeneralPreferencesInfo.EmailStrategy.ENABLED,
-            GeneralPreferencesInfo.EmailStrategy.values()));
-
-    p.emailFormat(
-        getListBox(
-            emailFormat,
-            GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT,
-            GeneralPreferencesInfo.EmailFormat.values()));
-
-    p.defaultBaseForMerges(
-        getListBox(
-            defaultBaseForMerges,
-            GeneralPreferencesInfo.DefaultBase.FIRST_PARENT,
-            GeneralPreferencesInfo.DefaultBase.values()));
-
-    List<TopMenuItem> items = new ArrayList<>();
-    for (List<String> v : myMenus.getValues()) {
-      items.add(TopMenuItem.create(v.get(0), v.get(1)));
-    }
-    p.setMyMenus(items);
-
-    enable(false);
-    save.setEnabled(false);
-
-    AccountApi.self()
-        .view("preferences")
-        .put(
-            p,
-            new GerritCallback<GeneralPreferences>() {
-              @Override
-              public void onSuccess(GeneralPreferences prefs) {
-                Gerrit.setUserPreferences(prefs);
-                enable(true);
-                display(prefs);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                enable(true);
-                save.setEnabled(true);
-                super.onFailure(caught);
-              }
-            });
-  }
-
-  private class MyMenuPanel extends StringListPanel {
-    MyMenuPanel(Button save) {
-      super(Util.C.myMenu(), Arrays.asList(Util.C.myMenuName(), Util.C.myMenuUrl()), save, false);
-
-      setInfo(Util.C.myMenuInfo());
-
-      Button resetButton = new Button(Util.C.myMenuReset());
-      resetButton.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              ConfigServerApi.defaultPreferences(
-                  new GerritCallback<GeneralPreferences>() {
-                    @Override
-                    public void onSuccess(GeneralPreferences p) {
-                      MyPreferencesScreen.this.display(p.my());
-                      widget.setEnabled(true);
-                    }
-                  });
-            }
-          });
-      buttonPanel.add(resetButton);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java
deleted file mode 100644
index 0275948..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import static com.google.gerrit.client.FormatUtil.mediumFormat;
-
-import com.google.gerrit.client.AvatarImage;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.i18n.client.LocaleInfo;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.VerticalPanel;
-
-public class MyProfileScreen extends SettingsScreen {
-  private AvatarImage avatar;
-  private Anchor changeAvatar;
-  private int labelIdx;
-  private int fieldIdx;
-  private Grid info;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    HorizontalPanel h = new HorizontalPanel();
-    add(h);
-
-    if (Gerrit.info().plugin().hasAvatars()) {
-      VerticalPanel v = new VerticalPanel();
-      v.addStyleName(Gerrit.RESOURCES.css().avatarInfoPanel());
-      h.add(v);
-      avatar = new AvatarImage();
-      v.add(avatar);
-      changeAvatar = new Anchor(Util.C.changeAvatar(), "", "_blank");
-      changeAvatar.setVisible(false);
-      v.add(changeAvatar);
-    }
-
-    if (LocaleInfo.getCurrentLocale().isRTL()) {
-      labelIdx = 1;
-      fieldIdx = 0;
-    } else {
-      labelIdx = 0;
-      fieldIdx = 1;
-    }
-
-    info = new Grid((Gerrit.info().auth().siteHasUsernames() ? 1 : 0) + 4, 2);
-    info.setStyleName(Gerrit.RESOURCES.css().infoBlock());
-    info.addStyleName(Gerrit.RESOURCES.css().accountInfoBlock());
-    h.add(info);
-
-    int row = 0;
-    if (Gerrit.info().auth().siteHasUsernames()) {
-      infoRow(row++, Util.C.userName());
-    }
-    infoRow(row++, Util.C.fullName());
-    infoRow(row++, Util.C.preferredEmail());
-    infoRow(row++, Util.C.registeredOn());
-    infoRow(row++, Util.C.accountId());
-
-    final CellFormatter fmt = info.getCellFormatter();
-    fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().topmost());
-    fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().topmost());
-    fmt.addStyleName(row - 1, 0, Gerrit.RESOURCES.css().bottomheader());
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    add(createExtensionPoint(GerritUiExtensionPoint.PROFILE_SCREEN_BOTTOM));
-    display(Gerrit.getUserAccount());
-    display();
-  }
-
-  private void infoRow(int row, String name) {
-    info.setText(row, labelIdx, name);
-    info.getCellFormatter().addStyleName(row, 0, Gerrit.RESOURCES.css().header());
-  }
-
-  void display(AccountInfo account) {
-    if (Gerrit.info().plugin().hasAvatars()) {
-      avatar.setAccount(account, 93, false);
-      new RestApi("/accounts/")
-          .id("self")
-          .view("avatar.change.url")
-          .get(
-              new AsyncCallback<NativeString>() {
-                @Override
-                public void onSuccess(NativeString changeUrl) {
-                  changeAvatar.setHref(changeUrl.asString());
-                  changeAvatar.setVisible(true);
-                }
-
-                @Override
-                public void onFailure(Throwable caught) {}
-              });
-    }
-
-    int row = 0;
-    if (Gerrit.info().auth().siteHasUsernames()) {
-      info.setWidget(row++, fieldIdx, new UsernameField());
-    }
-    info.setText(row++, fieldIdx, account.name());
-    info.setText(row++, fieldIdx, account.email());
-    info.setText(row++, fieldIdx, mediumFormat(account.registeredOn()));
-    info.setText(row, fieldIdx, Integer.toString(account._accountId()));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MySshKeysScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MySshKeysScreen.java
deleted file mode 100644
index 6ba63aa..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MySshKeysScreen.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-public class MySshKeysScreen extends SettingsScreen {
-  private SshPanel panel;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    panel =
-        new SshPanel() {
-          @Override
-          void display() {
-            MySshKeysScreen.this.display();
-          }
-        };
-    add(panel);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java
deleted file mode 100644
index c99cd1a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java
+++ /dev/null
@@ -1,233 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.HintTextBox;
-import com.google.gerrit.client.ui.ProjectListPopup;
-import com.google.gerrit.client.ui.ProjectNameSuggestOracle;
-import com.google.gerrit.client.ui.RemoteSuggestBox;
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-
-public class MyWatchedProjectsScreen extends SettingsScreen {
-  private Button addNew;
-  private RemoteSuggestBox nameBox;
-  private HintTextBox filterTxt;
-  private MyWatchesTable watchesTab;
-  private Button browse;
-  private Button delSel;
-  private Grid grid;
-  private ProjectListPopup projectsPopup;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    createWidgets();
-
-    /* top table */
-    grid = new Grid(2, 2);
-    grid.setStyleName(Gerrit.RESOURCES.css().infoBlock());
-    grid.setText(0, 0, Util.C.watchedProjectName());
-    final HorizontalPanel hp = new HorizontalPanel();
-    hp.add(nameBox);
-    hp.add(browse);
-    grid.setWidget(0, 1, hp);
-
-    grid.setText(1, 0, Util.C.watchedProjectFilter());
-    grid.setWidget(1, 1, filterTxt);
-
-    final CellFormatter fmt = grid.getCellFormatter();
-    fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().topmost());
-    fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().topmost());
-    fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().header());
-    fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().header());
-    fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().bottomheader());
-
-    final FlowPanel fp = new FlowPanel();
-    fp.setStyleName(Gerrit.RESOURCES.css().addWatchPanel());
-    fp.add(grid);
-    fp.add(addNew);
-    add(fp);
-
-    /* bottom table */
-    add(watchesTab);
-    add(delSel);
-
-    /* popup */
-    projectsPopup =
-        new ProjectListPopup() {
-          @Override
-          protected void onMovePointerTo(String projectName) {
-            // prevent user input from being overwritten by simply poping up
-            if (!projectsPopup.isPoppingUp() || "".equals(nameBox.getText())) {
-              nameBox.setText(projectName);
-            }
-          }
-
-          @Override
-          protected void openRow(String projectName) {
-            nameBox.setText(projectName);
-            doAddNew();
-          }
-        };
-    projectsPopup.initPopup(Util.C.projects(), PageLinks.SETTINGS_PROJECTS);
-  }
-
-  protected void createWidgets() {
-    nameBox = new RemoteSuggestBox(new ProjectNameSuggestOracle());
-    nameBox.setVisibleLength(50);
-    nameBox.setHintText(Util.C.defaultProjectName());
-    nameBox.addSelectionHandler(
-        new SelectionHandler<String>() {
-          @Override
-          public void onSelection(SelectionEvent<String> event) {
-            doAddNew();
-          }
-        });
-
-    filterTxt = new HintTextBox();
-    filterTxt.setVisibleLength(50);
-    filterTxt.setHintText(Util.C.defaultFilter());
-    filterTxt.addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-              doAddNew();
-            }
-          }
-        });
-
-    addNew = new Button(Util.C.buttonWatchProject());
-    addNew.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doAddNew();
-          }
-        });
-
-    browse = new Button(Util.C.buttonBrowseProjects());
-    browse.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            int top = grid.getAbsoluteTop() - 50; // under page header
-            // Try to place it to the right of everything else, but not
-            // right justified
-            int left =
-                5
-                    + Math.max(
-                        grid.getAbsoluteLeft() + grid.getOffsetWidth(),
-                        watchesTab.getAbsoluteLeft() + watchesTab.getOffsetWidth());
-            projectsPopup.setPreferredCoordinates(top, left);
-            projectsPopup.displayPopup();
-          }
-        });
-
-    watchesTab = new MyWatchesTable();
-
-    delSel = new Button(Util.C.buttonDeleteSshKey());
-    delSel.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            watchesTab.deleteChecked();
-          }
-        });
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    populateWatches();
-  }
-
-  @Override
-  protected void onUnload() {
-    super.onUnload();
-    projectsPopup.closePopup();
-  }
-
-  protected void doAddNew() {
-    final String projectName = nameBox.getText().trim();
-    if ("".equals(projectName)) {
-      return;
-    }
-
-    String filter = filterTxt.getText();
-    if (filter == null || filter.isEmpty() || filter.equals(Util.C.defaultFilter())) {
-      filter = null;
-    }
-
-    addNew.setEnabled(false);
-    nameBox.setEnabled(false);
-    filterTxt.setEnabled(false);
-
-    final ProjectWatchInfo projectWatchInfo = JavaScriptObject.createObject().cast();
-    projectWatchInfo.project(projectName);
-    projectWatchInfo.filter(filterTxt.getText());
-
-    AccountApi.updateWatchedProject(
-        "self",
-        projectWatchInfo,
-        new GerritCallback<JsArray<ProjectWatchInfo>>() {
-          @Override
-          public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
-            addNew.setEnabled(true);
-            nameBox.setEnabled(true);
-            filterTxt.setEnabled(true);
-
-            nameBox.setText("");
-            watchesTab.insertWatch(projectWatchInfo);
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            addNew.setEnabled(true);
-            nameBox.setEnabled(true);
-            filterTxt.setEnabled(true);
-            super.onFailure(caught);
-          }
-        });
-  }
-
-  protected void populateWatches() {
-    AccountApi.getWatchedProjects(
-        "self",
-        new GerritCallback<JsArray<ProjectWatchInfo>>() {
-          @Override
-          public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
-            watchesTab.display(watchedProjects);
-            display();
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java
deleted file mode 100644
index 0a61b2d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java
+++ /dev/null
@@ -1,193 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.FancyFlexTable;
-import com.google.gerrit.client.ui.ProjectLink;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Label;
-import java.util.HashSet;
-import java.util.Set;
-
-public class MyWatchesTable extends FancyFlexTable<ProjectWatchInfo> {
-
-  public MyWatchesTable() {
-    table.setWidth("");
-    table.insertRow(1);
-    table.setText(0, 2, Util.C.watchedProjectName());
-    table.setText(0, 3, Util.C.watchedProjectColumnEmailNotifications());
-
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
-    fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-    fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-    fmt.setRowSpan(0, 0, 2);
-    fmt.setRowSpan(0, 1, 2);
-    fmt.setRowSpan(0, 2, 2);
-    fmt.getElement(0, 3).setPropertyString("align", "center");
-
-    fmt.setColSpan(0, 3, 5);
-    table.setText(1, 0, Util.C.watchedProjectColumnNewChanges());
-    table.setText(1, 1, Util.C.watchedProjectColumnNewPatchSets());
-    table.setText(1, 2, Util.C.watchedProjectColumnAllComments());
-    table.setText(1, 3, Util.C.watchedProjectColumnSubmittedChanges());
-    table.setText(1, 4, Util.C.watchedProjectColumnAbandonedChanges());
-    fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().dataHeader());
-    fmt.addStyleName(1, 1, Gerrit.RESOURCES.css().dataHeader());
-    fmt.addStyleName(1, 2, Gerrit.RESOURCES.css().dataHeader());
-    fmt.addStyleName(1, 3, Gerrit.RESOURCES.css().dataHeader());
-    fmt.addStyleName(1, 4, Gerrit.RESOURCES.css().dataHeader());
-  }
-
-  public void deleteChecked() {
-    final Set<ProjectWatchInfo> infos = getCheckedProjectWatchInfos();
-    if (!infos.isEmpty()) {
-      AccountApi.deleteWatchedProjects(
-          "self",
-          infos,
-          new GerritCallback<JsArray<ProjectWatchInfo>>() {
-            @Override
-            public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
-              remove(infos);
-            }
-          });
-    }
-  }
-
-  protected void remove(Set<ProjectWatchInfo> infos) {
-    for (int row = 1; row < table.getRowCount(); ) {
-      final ProjectWatchInfo k = getRowItem(row);
-      if (k != null && infos.contains(k)) {
-        table.removeRow(row);
-      } else {
-        row++;
-      }
-    }
-  }
-
-  protected Set<ProjectWatchInfo> getCheckedProjectWatchInfos() {
-    final Set<ProjectWatchInfo> infos = new HashSet<>();
-    for (int row = 1; row < table.getRowCount(); row++) {
-      final ProjectWatchInfo k = getRowItem(row);
-      if (k != null && ((CheckBox) table.getWidget(row, 1)).getValue()) {
-        infos.add(k);
-      }
-    }
-    return infos;
-  }
-
-  public void insertWatch(ProjectWatchInfo k) {
-    final String newName = k.project();
-    int row = 1;
-    for (; row < table.getRowCount(); row++) {
-      final ProjectWatchInfo i = getRowItem(row);
-      if (i != null && i.project().compareTo(newName) >= 0) {
-        break;
-      }
-    }
-
-    table.insertRow(row);
-    applyDataRowStyle(row);
-    populate(row, k);
-  }
-
-  public void display(JsArray<ProjectWatchInfo> result) {
-    while (2 < table.getRowCount()) {
-      table.removeRow(table.getRowCount() - 1);
-    }
-
-    for (ProjectWatchInfo info : Natives.asList(result)) {
-      final int row = table.getRowCount();
-      table.insertRow(row);
-      applyDataRowStyle(row);
-      populate(row, info);
-    }
-  }
-
-  protected void populate(int row, ProjectWatchInfo info) {
-    final FlowPanel fp = new FlowPanel();
-    fp.add(new ProjectLink(info.project(), new Project.NameKey(info.project())));
-    if (info.filter() != null) {
-      Label filter = new Label(info.filter());
-      filter.setStyleName(Gerrit.RESOURCES.css().watchedProjectFilter());
-      fp.add(filter);
-    }
-
-    table.setWidget(row, 1, new CheckBox());
-    table.setWidget(row, 2, fp);
-
-    addNotifyButton(ProjectWatchInfo.Type.NEW_CHANGES, info, row, 3);
-    addNotifyButton(ProjectWatchInfo.Type.NEW_PATCHSETS, info, row, 4);
-    addNotifyButton(ProjectWatchInfo.Type.ALL_COMMENTS, info, row, 5);
-    addNotifyButton(ProjectWatchInfo.Type.SUBMITTED_CHANGES, info, row, 6);
-    addNotifyButton(ProjectWatchInfo.Type.ABANDONED_CHANGES, info, row, 7);
-
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().iconCell());
-    fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, 4, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, 5, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, 6, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, 7, Gerrit.RESOURCES.css().dataCell());
-
-    setRowItem(row, info);
-  }
-
-  protected void addNotifyButton(
-      final ProjectWatchInfo.Type type, ProjectWatchInfo info, int row, int col) {
-    final CheckBox cbox = new CheckBox();
-
-    cbox.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            final Boolean oldVal = info.notify(type);
-            info.notify(type, cbox.getValue());
-            cbox.setEnabled(false);
-
-            AccountApi.updateWatchedProject(
-                "self",
-                info,
-                new GerritCallback<JsArray<ProjectWatchInfo>>() {
-                  @Override
-                  public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
-                    cbox.setEnabled(true);
-                  }
-
-                  @Override
-                  public void onFailure(Throwable caught) {
-                    cbox.setEnabled(true);
-                    info.notify(type, oldVal);
-                    cbox.setValue(oldVal);
-                    super.onFailure(caught);
-                  }
-                });
-          }
-        });
-
-    cbox.setValue(info.notify(type));
-    table.setWidget(row, col, cbox);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
deleted file mode 100644
index 7c90884..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
+++ /dev/null
@@ -1,258 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.AgreementInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.AccountScreen;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.http.client.Request;
-import com.google.gwt.http.client.RequestBuilder;
-import com.google.gwt.http.client.RequestCallback;
-import com.google.gwt.http.client.RequestException;
-import com.google.gwt.http.client.Response;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.FormPanel;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.Panel;
-import com.google.gwt.user.client.ui.RadioButton;
-import com.google.gwt.user.client.ui.VerticalPanel;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-public class NewAgreementScreen extends AccountScreen {
-  private final String nextToken;
-  private Set<String> mySigned;
-  private List<AgreementInfo> available;
-  private AgreementInfo current;
-
-  private VerticalPanel radios;
-
-  private Panel agreementGroup;
-  private HTML agreementHtml;
-
-  private Panel finalGroup;
-  private NpTextBox yesIAgreeBox;
-  private Button submit;
-
-  public NewAgreementScreen() {
-    this(null);
-  }
-
-  public NewAgreementScreen(String token) {
-    nextToken = token != null ? token : PageLinks.SETTINGS_AGREEMENTS;
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    AccountApi.getAgreements(
-        "self",
-        new GerritCallback<JsArray<AgreementInfo>>() {
-          @Override
-          public void onSuccess(JsArray<AgreementInfo> result) {
-            if (isAttached()) {
-              mySigned = new HashSet<>();
-              for (AgreementInfo info : Natives.asList(result)) {
-                mySigned.add(info.name());
-              }
-              postRPC();
-            }
-          }
-        });
-
-    available = Gerrit.info().auth().contributorAgreements();
-    postRPC();
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setPageTitle(Util.C.newAgreement());
-
-    final FlowPanel formBody = new FlowPanel();
-    radios = new VerticalPanel();
-    formBody.add(radios);
-
-    agreementGroup = new FlowPanel();
-    agreementGroup.add(new SmallHeading(Util.C.newAgreementReviewLegalHeading()));
-
-    agreementHtml = new HTML();
-    agreementHtml.setStyleName(Gerrit.RESOURCES.css().contributorAgreementLegal());
-    agreementGroup.add(agreementHtml);
-    formBody.add(agreementGroup);
-
-    finalGroup = new VerticalPanel();
-    finalGroup.add(new SmallHeading(Util.C.newAgreementCompleteHeading()));
-    final FlowPanel fp = new FlowPanel();
-    yesIAgreeBox = new NpTextBox();
-    yesIAgreeBox.setVisibleLength(Util.C.newAgreementIAGREE().length() + 8);
-    yesIAgreeBox.setMaxLength(Util.C.newAgreementIAGREE().length());
-    fp.add(yesIAgreeBox);
-    fp.add(new InlineLabel(Util.M.enterIAGREE(Util.C.newAgreementIAGREE())));
-    finalGroup.add(fp);
-    submit = new Button(Util.C.buttonSubmitNewAgreement());
-    submit.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doSign();
-          }
-        });
-    finalGroup.add(submit);
-    formBody.add(finalGroup);
-    new OnEditEnabler(submit, yesIAgreeBox);
-
-    final FormPanel form = new FormPanel();
-    form.add(formBody);
-    add(form);
-  }
-
-  private void postRPC() {
-    if (mySigned != null && available != null) {
-      renderSelf();
-      display();
-    }
-  }
-
-  private void renderSelf() {
-    current = null;
-    agreementGroup.setVisible(false);
-    finalGroup.setVisible(false);
-    radios.clear();
-
-    final SmallHeading hdr = new SmallHeading();
-    if (available.isEmpty()) {
-      hdr.setText(Util.C.newAgreementNoneAvailable());
-    } else {
-      hdr.setText(Util.C.newAgreementSelectTypeHeading());
-    }
-    radios.add(hdr);
-
-    for (AgreementInfo cla : available) {
-      final RadioButton r = new RadioButton("cla_id", cla.name());
-      r.addStyleName(Gerrit.RESOURCES.css().contributorAgreementButton());
-      radios.add(r);
-
-      if (mySigned.contains(cla.name())) {
-        r.setEnabled(false);
-        final Label l = new Label(Util.C.newAgreementAlreadySubmitted());
-        l.setStyleName(Gerrit.RESOURCES.css().contributorAgreementAlreadySubmitted());
-        radios.add(l);
-      } else {
-        r.addClickHandler(
-            new ClickHandler() {
-              @Override
-              public void onClick(ClickEvent event) {
-                showCLA(cla);
-              }
-            });
-      }
-
-      if (cla.description() != null && !cla.description().equals("")) {
-        final Label l = new Label(cla.description());
-        l.setStyleName(Gerrit.RESOURCES.css().contributorAgreementShortDescription());
-        radios.add(l);
-      }
-    }
-  }
-
-  private void doSign() {
-    submit.setEnabled(false);
-
-    if (current == null || !Util.C.newAgreementIAGREE().equalsIgnoreCase(yesIAgreeBox.getText())) {
-      yesIAgreeBox.setText("");
-      yesIAgreeBox.setFocus(true);
-      return;
-    }
-    doEnterAgreement();
-  }
-
-  private void doEnterAgreement() {
-    AccountApi.enterAgreement(
-        "self",
-        current.name(),
-        new GerritCallback<NativeString>() {
-          @Override
-          public void onSuccess(NativeString result) {
-            Gerrit.display(nextToken);
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            yesIAgreeBox.setText("");
-            super.onFailure(caught);
-          }
-        });
-  }
-
-  private void showCLA(AgreementInfo cla) {
-    current = cla;
-    String url = cla.url();
-    if (url != null && url.length() > 0) {
-      agreementGroup.setVisible(true);
-      agreementHtml.setText(Gerrit.C.rpcStatusWorking());
-      if (!url.startsWith("http:") && !url.startsWith("https:")) {
-        url = GWT.getHostPageBaseURL() + url;
-      }
-      final RequestBuilder rb = new RequestBuilder(RequestBuilder.GET, url);
-      rb.setCallback(
-          new RequestCallback() {
-            @Override
-            public void onError(Request request, Throwable exception) {
-              new ErrorDialog(exception).center();
-            }
-
-            @Override
-            public void onResponseReceived(Request request, Response response) {
-              final String ct = response.getHeader("Content-Type");
-              if (response.getStatusCode() == 200
-                  && ct != null
-                  && (ct.equals("text/html") || ct.startsWith("text/html;"))) {
-                agreementHtml.setHTML(response.getText());
-              } else {
-                new ErrorDialog(response.getStatusText()).center();
-              }
-            }
-          });
-      try {
-        rb.send();
-      } catch (RequestException e) {
-        new ErrorDialog(e).show();
-      }
-    } else {
-      agreementGroup.setVisible(false);
-    }
-
-    finalGroup.setVisible(cla.autoVerifyGroup() != null);
-    yesIAgreeBox.setText("");
-    submit.setEnabled(false);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java
deleted file mode 100644
index fab25ae..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.client.account;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class ProjectWatchInfo extends JavaScriptObject {
-
-  public enum Type {
-    NEW_CHANGES,
-    NEW_PATCHSETS,
-    ALL_COMMENTS,
-    SUBMITTED_CHANGES,
-    ABANDONED_CHANGES
-  }
-
-  public final native String project() /*-{ return this.project; }-*/;
-
-  public final native String filter() /*-{ return this.filter; }-*/;
-
-  public final native void project(String s) /*-{ this.project = s; }-*/;
-
-  public final native void filter(String s) /*-{ this.filter = s; }-*/;
-
-  public final void notify(ProjectWatchInfo.Type t, Boolean b) {
-    if (t == ProjectWatchInfo.Type.NEW_CHANGES) {
-      notifyNewChanges(b.booleanValue());
-    } else if (t == Type.NEW_PATCHSETS) {
-      notifyNewPatchSets(b.booleanValue());
-    } else if (t == Type.ALL_COMMENTS) {
-      notifyAllComments(b.booleanValue());
-    } else if (t == Type.SUBMITTED_CHANGES) {
-      notifySubmittedChanges(b.booleanValue());
-    } else if (t == Type.ABANDONED_CHANGES) {
-      notifyAbandonedChanges(b.booleanValue());
-    }
-  }
-
-  public final Boolean notify(ProjectWatchInfo.Type t) {
-    boolean b = false;
-    if (t == ProjectWatchInfo.Type.NEW_CHANGES) {
-      b = notifyNewChanges();
-    } else if (t == Type.NEW_PATCHSETS) {
-      b = notifyNewPatchSets();
-    } else if (t == Type.ALL_COMMENTS) {
-      b = notifyAllComments();
-    } else if (t == Type.SUBMITTED_CHANGES) {
-      b = notifySubmittedChanges();
-    } else if (t == Type.ABANDONED_CHANGES) {
-      b = notifyAbandonedChanges();
-    }
-    return Boolean.valueOf(b);
-  }
-
-  private native boolean
-      notifyNewChanges() /*-{ return this['notify_new_changes'] ? true : false; }-*/;
-
-  private native boolean
-      notifyNewPatchSets() /*-{ return this['notify_new_patch_sets'] ? true : false; }-*/;
-
-  private native boolean
-      notifyAllComments() /*-{ return this['notify_all_comments'] ? true : false; }-*/;
-
-  private native boolean
-      notifySubmittedChanges() /*-{ return this['notify_submitted_changes'] ? true : false; }-*/;
-
-  private native boolean
-      notifyAbandonedChanges() /*-{ return this['notify_abandoned_changes'] ? true : false; }-*/;
-
-  private native void notifyNewChanges(
-      boolean b) /*-{ this['notify_new_changes'] = b ? true : null; }-*/;
-
-  private native void notifyNewPatchSets(
-      boolean b) /*-{ this['notify_new_patch_sets'] = b ? true : null; }-*/;
-
-  private native void notifyAllComments(
-      boolean b) /*-{ this['notify_all_comments'] = b ? true : null; }-*/;
-
-  private native void notifySubmittedChanges(
-      boolean b) /*-{ this['notify_submitted_changes'] = b ? true : null; }-*/;
-
-  private native void notifyAbandonedChanges(
-      boolean b) /*-{ this['notify_abandoned_changes'] = b ? true : null; }-*/;
-
-  protected ProjectWatchInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
deleted file mode 100644
index 29de14a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.ui.AccountScreen;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gwt.i18n.client.LocaleInfo;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.FormPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-
-public class RegisterScreen extends AccountScreen {
-  private final String nextToken;
-
-  public RegisterScreen(String next) {
-    nextToken = next;
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    display();
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setPageTitle(Util.C.welcomeToGerritCodeReview());
-
-    final FlowPanel formBody = new FlowPanel();
-
-    final FlowPanel contactGroup = new FlowPanel();
-    contactGroup.setStyleName(Gerrit.RESOURCES.css().registerScreenSection());
-    contactGroup.add(new SmallHeading(Util.C.welcomeReviewContact()));
-    final HTML whereFrom = new HTML(Util.C.welcomeContactFrom());
-    whereFrom.setStyleName(Gerrit.RESOURCES.css().registerScreenExplain());
-    contactGroup.add(whereFrom);
-    contactGroup.add(
-        new ContactPanelShort() {
-          @Override
-          protected void display(AccountInfo account) {
-            super.display(account);
-
-            if ("".equals(nameTxt.getText())) {
-              // No name? Encourage the user to provide us something.
-              //
-              nameTxt.setFocus(true);
-              save.setEnabled(true);
-            }
-          }
-        });
-    formBody.add(contactGroup);
-
-    if (Gerrit.getUserAccount().username() == null
-        && Gerrit.info().auth().canEdit(AccountFieldName.USER_NAME)) {
-      final FlowPanel fp = new FlowPanel();
-      fp.setStyleName(Gerrit.RESOURCES.css().registerScreenSection());
-      fp.add(new SmallHeading(Util.C.welcomeUsernameHeading()));
-
-      final Grid userInfo = new Grid(1, 2);
-      final CellFormatter fmt = userInfo.getCellFormatter();
-      userInfo.setStyleName(Gerrit.RESOURCES.css().infoBlock());
-      userInfo.addStyleName(Gerrit.RESOURCES.css().accountInfoBlock());
-      fp.add(userInfo);
-
-      fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().topmost());
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().topmost());
-      fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().bottomheader());
-
-      UsernameField field = new UsernameField();
-      if (LocaleInfo.getCurrentLocale().isRTL()) {
-        userInfo.setText(0, 1, Util.C.userName());
-        userInfo.setWidget(0, 0, field);
-        fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().header());
-      } else {
-        userInfo.setText(0, 0, Util.C.userName());
-        userInfo.setWidget(0, 1, field);
-        fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().header());
-      }
-
-      formBody.add(fp);
-    }
-
-    if (Gerrit.info().hasSshd()) {
-      final FlowPanel sshKeyGroup = new FlowPanel();
-      sshKeyGroup.setStyleName(Gerrit.RESOURCES.css().registerScreenSection());
-      sshKeyGroup.add(new SmallHeading(Util.C.welcomeSshKeyHeading()));
-      final HTML whySshKey = new HTML(Util.C.welcomeSshKeyText());
-      whySshKey.setStyleName(Gerrit.RESOURCES.css().registerScreenExplain());
-      sshKeyGroup.add(whySshKey);
-      sshKeyGroup.add(
-          new SshPanel() {
-            {
-              setKeyTableVisible(false);
-            }
-          });
-      formBody.add(sshKeyGroup);
-    }
-
-    final FlowPanel choices = new FlowPanel();
-    choices.setStyleName(Gerrit.RESOURCES.css().registerScreenNextLinks());
-    if (Gerrit.info().auth().useContributorAgreements()) {
-      final FlowPanel agreementGroup = new FlowPanel();
-      agreementGroup.setStyleName(Gerrit.RESOURCES.css().registerScreenSection());
-      agreementGroup.add(new SmallHeading(Util.C.welcomeAgreementHeading()));
-      final HTML whyAgreement = new HTML(Util.C.welcomeAgreementText());
-      whyAgreement.setStyleName(Gerrit.RESOURCES.css().registerScreenExplain());
-      agreementGroup.add(whyAgreement);
-
-      choices.add(new InlineHyperlink(Util.C.newAgreement(), PageLinks.SETTINGS_NEW_AGREEMENT));
-      choices.add(new InlineHyperlink(Util.C.welcomeAgreementLater(), nextToken));
-      formBody.add(agreementGroup);
-    } else {
-      choices.add(new InlineHyperlink(Util.C.welcomeContinue(), nextToken));
-    }
-    formBody.add(choices);
-
-    final FormPanel form = new FormPanel();
-    form.add(formBody);
-    add(form);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
deleted file mode 100644
index a948595..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gerrit.client.api.ExtensionPanel;
-import com.google.gerrit.client.api.ExtensionSettingsScreen;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.MenuScreen;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import java.util.HashSet;
-import java.util.Set;
-
-public abstract class SettingsScreen extends MenuScreen {
-  private final Set<String> allMenuNames;
-  private final Set<String> ambiguousMenuNames;
-
-  public SettingsScreen() {
-    setRequiresSignIn(true);
-
-    allMenuNames = new HashSet<>();
-    ambiguousMenuNames = new HashSet<>();
-
-    linkByGerrit(Util.C.tabAccountSummary(), PageLinks.SETTINGS);
-    linkByGerrit(Util.C.tabPreferences(), PageLinks.SETTINGS_PREFERENCES);
-    linkByGerrit(Util.C.tabDiffPreferences(), PageLinks.SETTINGS_DIFF_PREFERENCES);
-    linkByGerrit(Util.C.tabEditPreferences(), PageLinks.SETTINGS_EDIT_PREFERENCES);
-    linkByGerrit(Util.C.tabWatchedProjects(), PageLinks.SETTINGS_PROJECTS);
-    linkByGerrit(Util.C.tabContactInformation(), PageLinks.SETTINGS_CONTACT);
-    if (Gerrit.info().hasSshd()) {
-      linkByGerrit(Util.C.tabSshKeys(), PageLinks.SETTINGS_SSHKEYS);
-    }
-    if (Gerrit.info().auth().isHttpPasswordSettingsEnabled()) {
-      linkByGerrit(Util.C.tabHttpAccess(), PageLinks.SETTINGS_HTTP_PASSWORD);
-    }
-    if (Gerrit.info().auth().isOAuth()
-        && Gerrit.info().auth().gitBasicAuthPolicy() == GitBasicAuthPolicy.OAUTH) {
-      linkByGerrit(Util.C.tabOAuthToken(), PageLinks.SETTINGS_OAUTH_TOKEN);
-    }
-    if (Gerrit.info().gerrit().editGpgKeys()) {
-      linkByGerrit(Util.C.tabGpgKeys(), PageLinks.SETTINGS_GPGKEYS);
-    }
-    linkByGerrit(Util.C.tabWebIdentities(), PageLinks.SETTINGS_WEBIDENT);
-    linkByGerrit(Util.C.tabMyGroups(), PageLinks.SETTINGS_MYGROUPS);
-    if (Gerrit.info().auth().useContributorAgreements()) {
-      linkByGerrit(Util.C.tabAgreements(), PageLinks.SETTINGS_AGREEMENTS);
-    }
-
-    for (String pluginName : ExtensionSettingsScreen.Definition.plugins()) {
-      for (ExtensionSettingsScreen.Definition def :
-          Natives.asList(ExtensionSettingsScreen.Definition.get(pluginName))) {
-        if (!allMenuNames.add(def.getMenu())) {
-          ambiguousMenuNames.add(def.getMenu());
-        }
-      }
-    }
-
-    for (String pluginName : ExtensionSettingsScreen.Definition.plugins()) {
-      for (ExtensionSettingsScreen.Definition def :
-          Natives.asList(ExtensionSettingsScreen.Definition.get(pluginName))) {
-        linkByPlugin(pluginName, def.getMenu(), PageLinks.toSettings(pluginName, def.getPath()));
-      }
-    }
-  }
-
-  private void linkByGerrit(String text, String target) {
-    allMenuNames.add(text);
-    link(text, target);
-  }
-
-  private void linkByPlugin(String pluginName, String text, String target) {
-    if (ambiguousMenuNames.contains(text)) {
-      text += " (" + pluginName + ")";
-    }
-    link(text, target);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setPageTitle(Util.C.settingsHeading());
-  }
-
-  protected ExtensionPanel createExtensionPoint(GerritUiExtensionPoint extensionPoint) {
-    ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint);
-    extensionPanel.putObject(GerritUiExtensionPoint.Key.ACCOUNT_INFO, Gerrit.getUserAccount());
-    return extensionPanel;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshHostKeyPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshHostKeyPanel.java
deleted file mode 100644
index 2dfc2ed..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshHostKeyPanel.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gerrit.common.data.SshHostKey;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-
-class SshHostKeyPanel extends Composite {
-  SshHostKeyPanel(SshHostKey info) {
-    final FlowPanel body = new FlowPanel();
-    body.setStyleName(Gerrit.RESOURCES.css().sshHostKeyPanel());
-    body.add(new SmallHeading(Util.C.sshHostKeyTitle()));
-    {
-      final Label fpLbl = new Label(Util.C.sshHostKeyFingerprint());
-      fpLbl.setStyleName(Gerrit.RESOURCES.css().sshHostKeyPanelHeading());
-      body.add(fpLbl);
-      final Label fpVal = new Label(info.getFingerprint());
-      fpVal.setStyleName(Gerrit.RESOURCES.css().sshHostKeyPanelFingerprintData());
-      body.add(fpVal);
-    }
-    {
-      final HTML hdr = new HTML(Util.C.sshHostKeyKnownHostEntry());
-      hdr.setStyleName(Gerrit.RESOURCES.css().sshHostKeyPanelHeading());
-      body.add(hdr);
-
-      final CopyableLabel lbl;
-      lbl = new CopyableLabel(info.getHostIdent() + " " + info.getHostKey());
-      lbl.setPreviewText(SshPanel.elide(lbl.getText(), 80));
-      lbl.addStyleName(Gerrit.RESOURCES.css().sshHostKeyPanelKnownHostEntry());
-      body.add(lbl);
-    }
-    initWidget(body);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshKeyInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshKeyInfo.java
deleted file mode 100644
index 23b3d2d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshKeyInfo.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class SshKeyInfo extends JavaScriptObject {
-  public final native int seq() /*-{ return this.seq || 0; }-*/;
-
-  public final native String sshPublicKey() /*-{ return this.ssh_public_key; }-*/;
-
-  public final native String encodedKey() /*-{ return this.encoded_key; }-*/;
-
-  public final native String algorithm() /*-{ return this.algorithm; }-*/;
-
-  public final native String comment() /*-{ return this.comment; }-*/;
-
-  public final native boolean isValid() /*-{ return this['valid'] ? true : false; }-*/;
-
-  protected SshKeyInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java
deleted file mode 100644
index 6a8b44d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java
+++ /dev/null
@@ -1,387 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.ComplexDisclosurePanel;
-import com.google.gerrit.client.ui.FancyFlexTable;
-import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gerrit.common.data.SshHostKey;
-import com.google.gerrit.common.errors.InvalidSshKeyException;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.HasHorizontalAlignment;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.Panel;
-import com.google.gwt.user.client.ui.VerticalPanel;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-import com.google.gwtexpui.globalkey.client.NpTextArea;
-import com.google.gwtjsonrpc.client.RemoteJsonException;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-
-class SshPanel extends Composite {
-  private SshKeyTable keys;
-
-  private Button showAddKeyBlock;
-  private Panel addKeyBlock;
-  private Button closeAddKeyBlock;
-  private Button clearNew;
-  private Button addNew;
-  private NpTextArea addTxt;
-  private Button deleteKey;
-
-  private Panel serverKeys;
-
-  private int loadCount;
-
-  SshPanel() {
-    final FlowPanel body = new FlowPanel();
-
-    showAddKeyBlock = new Button(Util.C.buttonShowAddSshKey());
-    showAddKeyBlock.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            showAddKeyBlock(true);
-          }
-        });
-
-    keys = new SshKeyTable();
-    body.add(keys);
-    {
-      final FlowPanel fp = new FlowPanel();
-      deleteKey = new Button(Util.C.buttonDeleteSshKey());
-      deleteKey.setEnabled(false);
-      deleteKey.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              keys.deleteChecked();
-            }
-          });
-      fp.add(deleteKey);
-      fp.add(showAddKeyBlock);
-      body.add(fp);
-    }
-
-    addKeyBlock = new VerticalPanel();
-    addKeyBlock.setVisible(false);
-    addKeyBlock.setStyleName(Gerrit.RESOURCES.css().addSshKeyPanel());
-    addKeyBlock.add(new SmallHeading(Util.C.addSshKeyPanelHeader()));
-
-    final ComplexDisclosurePanel addSshKeyHelp =
-        new ComplexDisclosurePanel(Util.C.addSshKeyHelpTitle(), false);
-    addSshKeyHelp.setContent(new HTML(Util.C.addSshKeyHelp()));
-    addKeyBlock.add(addSshKeyHelp);
-
-    addTxt = new NpTextArea();
-    addTxt.setVisibleLines(12);
-    addTxt.setCharacterWidth(80);
-    addTxt.setSpellCheck(false);
-    addKeyBlock.add(addTxt);
-
-    final HorizontalPanel buttons = new HorizontalPanel();
-    addKeyBlock.add(buttons);
-
-    clearNew = new Button(Util.C.buttonClearSshKeyInput());
-    clearNew.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            addTxt.setText("");
-            addTxt.setFocus(true);
-          }
-        });
-    buttons.add(clearNew);
-
-    addNew = new Button(Util.C.buttonAddSshKey());
-    addNew.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doAddNew();
-          }
-        });
-    buttons.add(addNew);
-
-    closeAddKeyBlock = new Button(Util.C.buttonCloseAddSshKey());
-    closeAddKeyBlock.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            showAddKeyBlock(false);
-          }
-        });
-    buttons.add(closeAddKeyBlock);
-    buttons.setCellWidth(closeAddKeyBlock, "100%");
-    buttons.setCellHorizontalAlignment(closeAddKeyBlock, HasHorizontalAlignment.ALIGN_RIGHT);
-
-    body.add(addKeyBlock);
-
-    serverKeys = new FlowPanel();
-    body.add(serverKeys);
-
-    initWidget(body);
-  }
-
-  void setKeyTableVisible(boolean on) {
-    keys.setVisible(on);
-    deleteKey.setVisible(on);
-    closeAddKeyBlock.setVisible(on);
-  }
-
-  void doAddNew() {
-    final String txt = addTxt.getText();
-    if (txt != null && txt.length() > 0) {
-      addNew.setEnabled(false);
-      AccountApi.addSshKey(
-          "self",
-          txt,
-          new GerritCallback<SshKeyInfo>() {
-            @Override
-            public void onSuccess(SshKeyInfo k) {
-              addNew.setEnabled(true);
-              addTxt.setText("");
-              keys.addOneKey(k);
-              if (!keys.isVisible()) {
-                showAddKeyBlock(false);
-                setKeyTableVisible(true);
-                keys.updateDeleteButton();
-              }
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              addNew.setEnabled(true);
-
-              if (isInvalidSshKey(caught)) {
-                new ErrorDialog(Util.C.invalidSshKeyError()).center();
-
-              } else {
-                super.onFailure(caught);
-              }
-            }
-
-            private boolean isInvalidSshKey(Throwable caught) {
-              if (caught instanceof InvalidSshKeyException) {
-                return true;
-              }
-              return caught instanceof RemoteJsonException
-                  && InvalidSshKeyException.MESSAGE.equals(caught.getMessage());
-            }
-          });
-    }
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    refreshSshKeys();
-    Gerrit.SYSTEM_SVC.daemonHostKeys(
-        new GerritCallback<List<SshHostKey>>() {
-          @Override
-          public void onSuccess(List<SshHostKey> result) {
-            serverKeys.clear();
-            for (SshHostKey keyInfo : result) {
-              serverKeys.add(new SshHostKeyPanel(keyInfo));
-            }
-            if (++loadCount == 2) {
-              display();
-            }
-          }
-        });
-  }
-
-  private void refreshSshKeys() {
-    AccountApi.getSshKeys(
-        "self",
-        new GerritCallback<JsArray<SshKeyInfo>>() {
-          @Override
-          public void onSuccess(JsArray<SshKeyInfo> result) {
-            keys.display(Natives.asList(result));
-            if (result.length() == 0 && keys.isVisible()) {
-              showAddKeyBlock(true);
-            }
-            if (++loadCount == 2) {
-              display();
-            }
-          }
-        });
-  }
-
-  void display() {}
-
-  private void showAddKeyBlock(boolean show) {
-    showAddKeyBlock.setVisible(!show);
-    addKeyBlock.setVisible(show);
-  }
-
-  private class SshKeyTable extends FancyFlexTable<SshKeyInfo> {
-    private ValueChangeHandler<Boolean> updateDeleteHandler;
-
-    SshKeyTable() {
-      table.setWidth("");
-      table.setText(0, 2, Util.C.sshKeyStatus());
-      table.setText(0, 3, Util.C.sshKeyAlgorithm());
-      table.setText(0, 4, Util.C.sshKeyKey());
-      table.setText(0, 5, Util.C.sshKeyComment());
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
-      fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 5, Gerrit.RESOURCES.css().dataHeader());
-
-      updateDeleteHandler =
-          new ValueChangeHandler<Boolean>() {
-            @Override
-            public void onValueChange(ValueChangeEvent<Boolean> event) {
-              updateDeleteButton();
-            }
-          };
-    }
-
-    void deleteChecked() {
-      final HashSet<Integer> sequenceNumbers = new HashSet<>();
-      for (int row = 1; row < table.getRowCount(); row++) {
-        final SshKeyInfo k = getRowItem(row);
-        if (k != null && ((CheckBox) table.getWidget(row, 1)).getValue()) {
-          sequenceNumbers.add(k.seq());
-        }
-      }
-      if (sequenceNumbers.isEmpty()) {
-        updateDeleteButton();
-      } else {
-        deleteKey.setEnabled(false);
-        AccountApi.deleteSshKeys(
-            "self",
-            sequenceNumbers,
-            new GerritCallback<VoidResult>() {
-              @Override
-              public void onSuccess(VoidResult result) {
-                for (int row = 1; row < table.getRowCount(); ) {
-                  final SshKeyInfo k = getRowItem(row);
-                  if (k != null && sequenceNumbers.contains(k.seq())) {
-                    table.removeRow(row);
-                  } else {
-                    row++;
-                  }
-                }
-                if (table.getRowCount() == 1) {
-                  display(Collections.<SshKeyInfo>emptyList());
-                } else {
-                  updateDeleteButton();
-                }
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                refreshSshKeys();
-                updateDeleteButton();
-                super.onFailure(caught);
-              }
-            });
-      }
-    }
-
-    void display(List<SshKeyInfo> result) {
-      if (result.isEmpty()) {
-        setKeyTableVisible(false);
-        showAddKeyBlock(true);
-      } else {
-        while (1 < table.getRowCount()) {
-          table.removeRow(table.getRowCount() - 1);
-        }
-        for (SshKeyInfo k : result) {
-          addOneKey(k);
-        }
-        setKeyTableVisible(true);
-        deleteKey.setEnabled(false);
-      }
-    }
-
-    void addOneKey(SshKeyInfo k) {
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      final int row = table.getRowCount();
-      table.insertRow(row);
-      applyDataRowStyle(row);
-
-      final CheckBox sel = new CheckBox();
-      sel.addValueChangeHandler(updateDeleteHandler);
-
-      table.setWidget(row, 1, sel);
-      if (k.isValid()) {
-        table.setText(row, 2, "");
-        fmt.removeStyleName(
-            row,
-            2, //
-            Gerrit.RESOURCES.css().sshKeyPanelInvalid());
-      } else {
-        table.setText(row, 2, Util.C.sshKeyInvalid());
-        fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().sshKeyPanelInvalid());
-      }
-      table.setText(row, 3, k.algorithm());
-
-      CopyableLabel keyLabel = new CopyableLabel(k.sshPublicKey());
-      keyLabel.setPreviewText(elide(k.encodedKey(), 40));
-      table.setWidget(row, 4, keyLabel);
-
-      table.setText(row, 5, k.comment());
-
-      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().iconCell());
-      fmt.addStyleName(row, 4, Gerrit.RESOURCES.css().sshKeyPanelEncodedKey());
-      for (int c = 2; c <= 5; c++) {
-        fmt.addStyleName(row, c, Gerrit.RESOURCES.css().dataCell());
-      }
-
-      setRowItem(row, k);
-    }
-
-    void updateDeleteButton() {
-      boolean on = false;
-      for (int row = 1; row < table.getRowCount(); row++) {
-        CheckBox sel = (CheckBox) table.getWidget(row, 1);
-        if (sel.getValue()) {
-          on = true;
-          break;
-        }
-      }
-      deleteKey.setEnabled(on);
-    }
-  }
-
-  static String elide(String s, int len) {
-    if (s == null || s.length() < len || len <= 10) {
-      return s;
-    }
-    return s.substring(0, len - 10) + "..." + s.substring(s.length() - 10);
-  }
-}
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
deleted file mode 100644
index 80c6d1a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
+++ /dev/null
@@ -1,204 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.client.ConfirmationCallback;
-import com.google.gerrit.client.ConfirmationDialog;
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.TextBox;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-class UsernameField extends Composite {
-  // If these regular expressions are modified the same modifications should be done to the
-  // 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 CopyableLabel userNameLbl;
-  private NpTextBox userNameTxt;
-  private Button setUserName;
-
-  UsernameField() {
-    String user = Gerrit.getUserAccount().username();
-    userNameLbl = new CopyableLabel(user != null ? user : "");
-    userNameLbl.setStyleName(Gerrit.RESOURCES.css().accountUsername());
-
-    if (user != null || !canEditUserName()) {
-      initWidget(userNameLbl);
-
-    } else {
-      final FlowPanel body = new FlowPanel();
-      initWidget(body);
-      setStyleName(Gerrit.RESOURCES.css().usernameField());
-
-      userNameTxt = new NpTextBox();
-      userNameTxt.addKeyPressHandler(new UserNameValidator());
-      userNameTxt.addStyleName(Gerrit.RESOURCES.css().accountUsername());
-      userNameTxt.setVisibleLength(16);
-      userNameTxt.addKeyPressHandler(
-          new KeyPressHandler() {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-                confirmSetUserName();
-              }
-            }
-          });
-
-      setUserName = new Button(Util.C.buttonSetUserName());
-      setUserName.setVisible(canEditUserName());
-      setUserName.setEnabled(false);
-      setUserName.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              confirmSetUserName();
-            }
-          });
-      new OnEditEnabler(setUserName, userNameTxt);
-
-      userNameLbl.setVisible(false);
-      body.add(userNameLbl);
-      body.add(userNameTxt);
-      body.add(setUserName);
-    }
-  }
-
-  private boolean canEditUserName() {
-    return Gerrit.info().auth().canEdit(AccountFieldName.USER_NAME);
-  }
-
-  private void confirmSetUserName() {
-    new ConfirmationDialog(
-            Util.C.confirmSetUserNameTitle(),
-            new SafeHtmlBuilder().append(Util.C.confirmSetUserName()),
-            new ConfirmationCallback() {
-              @Override
-              public void onOk() {
-                doSetUserName();
-              }
-            })
-        .center();
-  }
-
-  private void doSetUserName() {
-    if (!canEditUserName()) {
-      return;
-    }
-
-    enableUI(false);
-
-    String newName = userNameTxt.getText();
-    if ("".equals(newName)) {
-      newName = null;
-    }
-    final String newUserName = newName;
-
-    AccountApi.setUsername(
-        "self",
-        newUserName,
-        new GerritCallback<NativeString>() {
-          @Override
-          public void onSuccess(NativeString result) {
-            Gerrit.getUserAccount().username(newUserName);
-            userNameLbl.setText(newUserName);
-            userNameLbl.setVisible(true);
-            userNameTxt.setVisible(false);
-            setUserName.setVisible(false);
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            enableUI(true);
-            if (RestApi.isExpected(422 /* Unprocessable Entity */)) {
-              new ErrorDialog(Util.C.invalidUserName()).center();
-            } else {
-              super.onFailure(caught);
-            }
-          }
-        });
-  }
-
-  private void enableUI(boolean on) {
-    userNameTxt.setEnabled(on);
-    setUserName.setEnabled(on);
-  }
-
-  private static final class UserNameValidator implements KeyPressHandler {
-    @Override
-    public void onKeyPress(KeyPressEvent event) {
-      final char code = event.getCharCode();
-      final int nativeCode = event.getNativeEvent().getKeyCode();
-      switch (nativeCode) {
-        case KeyCodes.KEY_ALT:
-        case KeyCodes.KEY_BACKSPACE:
-        case KeyCodes.KEY_CTRL:
-        case KeyCodes.KEY_DELETE:
-        case KeyCodes.KEY_DOWN:
-        case KeyCodes.KEY_END:
-        case KeyCodes.KEY_ENTER:
-        case KeyCodes.KEY_ESCAPE:
-        case KeyCodes.KEY_HOME:
-        case KeyCodes.KEY_LEFT:
-        case KeyCodes.KEY_PAGEDOWN:
-        case KeyCodes.KEY_PAGEUP:
-        case KeyCodes.KEY_RIGHT:
-        case KeyCodes.KEY_SHIFT:
-        case KeyCodes.KEY_TAB:
-        case KeyCodes.KEY_UP:
-          // Allow these, even if one of their assigned codes is
-          // identical to an ASCII character we do not want to
-          // allow in the box.
-          //
-          // We still want to let the user move around the input box
-          // with their arrow keys, or to move between fields using tab.
-          // Invalid characters introduced will be caught through the
-          // server's own validation of the input data.
-          //
-          break;
-
-        default:
-          final TextBox box = (TextBox) event.getSource();
-          final String re;
-          if (box.getCursorPos() == 0) {
-            re = USER_NAME_PATTERN_FIRST_REGEX;
-          } else {
-            re = USER_NAME_PATTERN_REST_REGEX;
-          }
-          if (!String.valueOf(code).matches("^" + re + "$")) {
-            event.preventDefault();
-            event.stopPropagation();
-          }
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java
deleted file mode 100644
index 1c4870c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.account;
-
-import com.google.gerrit.common.data.ProjectAdminService;
-import com.google.gwt.core.client.GWT;
-import com.google.gwtjsonrpc.client.JsonUtil;
-
-public class Util {
-  public static final AccountConstants C = GWT.create(AccountConstants.class);
-  public static final AccountMessages M = GWT.create(AccountMessages.class);
-  public static final ProjectAdminService PROJECT_SVC;
-
-  static {
-    PROJECT_SVC = GWT.create(ProjectAdminService.class);
-    JsonUtil.bind(PROJECT_SVC, "rpc/ProjectAdminService");
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java
deleted file mode 100644
index b66f108..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.account;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.config.ConfigServerApi;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.AccountScreen;
-import com.google.gerrit.common.PageLinks;
-
-public class ValidateEmailScreen extends AccountScreen {
-  private final String magicToken;
-
-  public ValidateEmailScreen(String magicToken) {
-    this.magicToken = magicToken;
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setPageTitle(Util.C.settingsHeading());
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    ConfigServerApi.confirmEmail(
-        magicToken,
-        new ScreenLoadCallback<VoidResult>(this) {
-          @Override
-          protected void preDisplay(VoidResult result) {}
-
-          @Override
-          protected void postDisplay() {
-            Gerrit.display(PageLinks.SETTINGS_CONTACT);
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java
deleted file mode 100644
index 85937db..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.actions;
-
-import com.google.gerrit.client.api.ActionContext;
-import com.google.gerrit.client.api.ChangeGlue;
-import com.google.gerrit.client.api.EditGlue;
-import com.google.gerrit.client.api.ProjectGlue;
-import com.google.gerrit.client.api.RevisionGlue;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.projects.BranchInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-public class ActionButton extends Button implements ClickHandler {
-  private final Project.NameKey project;
-  private final BranchInfo branch;
-  private final ChangeInfo change;
-  private final EditInfo edit;
-  private final RevisionInfo revision;
-  private final ActionInfo action;
-  private ActionContext ctx;
-
-  public ActionButton(Project.NameKey project, ActionInfo action) {
-    this(project, null, null, null, null, action);
-  }
-
-  public ActionButton(Project.NameKey project, BranchInfo branch, ActionInfo action) {
-    this(project, branch, null, null, null, action);
-  }
-
-  public ActionButton(ChangeInfo change, ActionInfo action) {
-    this(null, null, change, null, null, action);
-  }
-
-  public ActionButton(ChangeInfo change, RevisionInfo revision, ActionInfo action) {
-    this(null, null, change, null, revision, action);
-  }
-
-  private ActionButton(
-      Project.NameKey project,
-      BranchInfo branch,
-      ChangeInfo change,
-      EditInfo edit,
-      RevisionInfo revision,
-      ActionInfo action) {
-    super(new SafeHtmlBuilder().openDiv().append(action.label()).closeDiv());
-    setStyleName("");
-    setTitle(action.title());
-    setEnabled(action.enabled());
-    addClickHandler(this);
-
-    this.project = project;
-    this.branch = branch;
-    this.change = change;
-    this.edit = edit;
-    this.revision = revision;
-    this.action = action;
-  }
-
-  @Override
-  public void onClick(ClickEvent event) {
-    if (ctx != null && ctx.has_popup()) {
-      ctx.hide();
-      ctx = null;
-      return;
-    }
-
-    if (revision != null) {
-      RevisionGlue.onAction(change, revision, action, this);
-    } else if (edit != null) {
-      EditGlue.onAction(change, edit, action, this);
-    } else if (change != null) {
-      ChangeGlue.onAction(change, action, this);
-    } else if (branch != null) {
-      ProjectGlue.onAction(project, branch, action, this);
-    } else if (project != null) {
-      ProjectGlue.onAction(project, action, this);
-    }
-  }
-
-  @Override
-  public void onUnload() {
-    if (ctx != null) {
-      if (ctx.has_popup()) {
-        ctx.hide();
-      }
-      ctx = null;
-    }
-    super.onUnload();
-  }
-
-  public void link(ActionContext ctx) {
-    this.ctx = ctx;
-  }
-
-  public void unlink() {
-    ctx = null;
-  }
-}
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
deleted file mode 100644
index e518d26..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
+++ /dev/null
@@ -1,289 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.dom.client.DivElement;
-import com.google.gwt.dom.client.SpanElement;
-import com.google.gwt.dom.client.Style.Display;
-import com.google.gwt.editor.client.Editor;
-import com.google.gwt.editor.client.EditorDelegate;
-import com.google.gwt.editor.client.ValueAwareEditor;
-import com.google.gwt.editor.client.adapters.EditorSource;
-import com.google.gwt.editor.client.adapters.ListEditor;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.MouseOutEvent;
-import com.google.gwt.event.dom.client.MouseOverEvent;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-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
-    implements Editor<AccessSection>, ValueAwareEditor<AccessSection> {
-  interface Binder extends UiBinder<HTMLPanel, AccessSectionEditor> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField ValueEditor<String> name;
-
-  @UiField FlowPanel permissionContainer;
-  ListEditor<Permission, PermissionEditor> permissions;
-
-  @UiField DivElement addContainer;
-
-  @UiField(provided = true)
-  @Editor.Ignore
-  ValueListBox<String> permissionSelector;
-
-  @UiField SpanElement deletedName;
-
-  @UiField Anchor deleteSection;
-
-  @UiField DivElement normal;
-  @UiField DivElement deleted;
-
-  @UiField SpanElement sectionType;
-  @UiField SpanElement sectionName;
-
-  private final ProjectAccess projectAccess;
-  private AccessSection value;
-  private boolean editing;
-  private boolean readOnly;
-  private boolean isDeleted;
-
-  public AccessSectionEditor(ProjectAccess access) {
-    projectAccess = access;
-    permissionSelector = new ValueListBox<>(new PermissionNameRenderer(access.getCapabilities()));
-    permissionSelector.addValueChangeHandler(
-        new ValueChangeHandler<String>() {
-          @Override
-          public void onValueChange(ValueChangeEvent<String> event) {
-            if (!AdminConstants.I.addPermission().equals(event.getValue())) {
-              onAddPermission(event.getValue());
-            }
-          }
-        });
-
-    initWidget(uiBinder.createAndBindUi(this));
-    permissions = ListEditor.of(new PermissionEditorSource());
-  }
-
-  @UiHandler("deleteSection")
-  void onDeleteHover(@SuppressWarnings("unused") MouseOverEvent event) {
-    normal.addClassName(AdminResources.I.css().deleteSectionHover());
-  }
-
-  @UiHandler("deleteSection")
-  void onDeleteNonHover(@SuppressWarnings("unused") MouseOutEvent event) {
-    normal.removeClassName(AdminResources.I.css().deleteSectionHover());
-  }
-
-  @UiHandler("deleteSection")
-  void onDeleteSection(@SuppressWarnings("unused") ClickEvent event) {
-    isDeleted = true;
-
-    if (name.isVisible() && RefConfigSection.isValid(name.getValue())) {
-      deletedName.setInnerText(AdminMessages.I.deletedReference(name.getValue()));
-    } else {
-      String name = AdminConstants.I.sectionNames().get(value.getName());
-      if (name == null) {
-        name = value.getName();
-      }
-      deletedName.setInnerText(AdminMessages.I.deletedSection(name));
-    }
-
-    normal.getStyle().setDisplay(Display.NONE);
-    deleted.getStyle().setDisplay(Display.BLOCK);
-  }
-
-  @UiHandler("undoDelete")
-  void onUndoDelete(@SuppressWarnings("unused") ClickEvent event) {
-    isDeleted = false;
-    deleted.getStyle().setDisplay(Display.NONE);
-    normal.getStyle().setDisplay(Display.BLOCK);
-  }
-
-  void onAddPermission(String varName) {
-    int idx = permissions.getList().size();
-
-    Permission p = value.getPermission(varName, true);
-    permissions.getList().add(p);
-
-    PermissionEditor e = permissions.getEditors().get(idx);
-    e.beginAddRule();
-
-    rebuildPermissionSelector();
-  }
-
-  void editRefPattern() {
-    name.edit();
-    Scheduler.get()
-        .scheduleDeferred(
-            new ScheduledCommand() {
-              @Override
-              public void execute() {
-                name.setFocus(true);
-              }
-            });
-  }
-
-  void enableEditing() {
-    readOnly = false;
-    addContainer.getStyle().setDisplay(Display.BLOCK);
-    rebuildPermissionSelector();
-  }
-
-  boolean isDeleted() {
-    return isDeleted;
-  }
-
-  @Override
-  public void setValue(AccessSection value) {
-    Collections.sort(value.getPermissions());
-
-    this.value = value;
-    this.readOnly = !editing || !(projectAccess.isOwnerOf(value) || projectAccess.canUpload());
-
-    name.setEnabled(!readOnly);
-    deleteSection.setVisible(!readOnly);
-
-    if (RefConfigSection.isValid(value.getName())) {
-      name.setVisible(true);
-      name.setIgnoreEditorValue(false);
-      sectionType.setInnerText(AdminConstants.I.sectionTypeReference());
-
-    } else {
-      name.setVisible(false);
-      name.setIgnoreEditorValue(true);
-
-      String name = AdminConstants.I.sectionNames().get(value.getName());
-      if (name != null) {
-        sectionType.setInnerText(name);
-        sectionName.getStyle().setDisplay(Display.NONE);
-      } else {
-        sectionType.setInnerText(AdminConstants.I.sectionTypeSection());
-        sectionName.setInnerText(value.getName());
-        sectionName.getStyle().clearDisplay();
-      }
-    }
-
-    if (readOnly) {
-      addContainer.getStyle().setDisplay(Display.NONE);
-    } else {
-      enableEditing();
-    }
-  }
-
-  void setEditing(boolean editing) {
-    this.editing = editing;
-  }
-
-  private void rebuildPermissionSelector() {
-    List<String> perms = new ArrayList<>();
-
-    if (AccessSection.GLOBAL_CAPABILITIES.equals(value.getName())) {
-      for (String varName : projectAccess.getCapabilities().keySet()) {
-        addPermission(varName, perms);
-      }
-    } else if (RefConfigSection.isValid(value.getName())) {
-      for (LabelType t : projectAccess.getLabelTypes().getLabelTypes()) {
-        addPermission(Permission.forLabel(t.getName()), perms);
-      }
-      for (LabelType t : projectAccess.getLabelTypes().getLabelTypes()) {
-        addPermission(Permission.forLabelAs(t.getName()), perms);
-      }
-      for (String varName : AdminConstants.I.permissionNames().keySet()) {
-        addPermission(varName, perms);
-      }
-    }
-    if (perms.isEmpty()) {
-      addContainer.getStyle().setDisplay(Display.NONE);
-    } else {
-      addContainer.getStyle().setDisplay(Display.BLOCK);
-      perms.add(0, AdminConstants.I.addPermission());
-      permissionSelector.setValue(AdminConstants.I.addPermission());
-      permissionSelector.setAcceptableValues(perms);
-    }
-  }
-
-  private void addPermission(String permissionName, List<String> permissionList) {
-    if (value.getPermission(permissionName) != null) {
-      return;
-    }
-    if (Gerrit.info().gerrit().isAllProjects(projectAccess.getProjectName())
-        && !Permission.canBeOnAllProjects(value.getName(), permissionName)) {
-      return;
-    }
-    permissionList.add(permissionName);
-  }
-
-  @Override
-  public void flush() {
-    List<Permission> src = permissions.getList();
-    List<Permission> keep = new ArrayList<>(src.size());
-
-    for (int i = 0; i < src.size(); i++) {
-      PermissionEditor e = (PermissionEditor) permissionContainer.getWidget(i);
-      if (!e.isDeleted()) {
-        keep.add(src.get(i));
-      }
-    }
-    value.setPermissions(keep);
-  }
-
-  @Override
-  public void onPropertyChange(String... paths) {}
-
-  @Override
-  public void setDelegate(EditorDelegate<AccessSection> delegate) {}
-
-  private class PermissionEditorSource extends EditorSource<PermissionEditor> {
-    @Override
-    public PermissionEditor create(int index) {
-      PermissionEditor subEditor =
-          new PermissionEditor(projectAccess, readOnly, value, projectAccess.getLabelTypes());
-      permissionContainer.insert(subEditor, index);
-      return subEditor;
-    }
-
-    @Override
-    public void dispose(PermissionEditor subEditor) {
-      subEditor.removeFromParent();
-    }
-
-    @Override
-    public void setIndex(PermissionEditor subEditor, int index) {
-      permissionContainer.insert(subEditor, index);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.ui.xml
deleted file mode 100644
index 3710265..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.ui.xml
+++ /dev/null
@@ -1,157 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2011 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-  xmlns:ui='urn:ui:com.google.gwt.uibinder'
-  xmlns:g='urn:import:com.google.gwt.user.client.ui'
-  xmlns:e='urn:import:com.google.gwt.editor.ui.client'
-  xmlns:my='urn:import:com.google.gerrit.client.admin'
-  ui:generateFormat='com.google.gwt.i18n.rebind.format.PropertiesFormat'
-  ui:generateKeys='com.google.gwt.i18n.rebind.keygen.MD5KeyGenerator'
-  ui:generateLocales='default,en'
-  >
-<ui:with field='res' type='com.google.gerrit.client.admin.AdminResources'/>
-<ui:style gss='false'>
-  @eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-  @eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
-
-  .panel {
-    width: 50em;
-    position: relative;
-  }
-
-  .content {
-    margin-top: 4px;
-    margin-bottom: 4px;
-    padding-bottom: 2px;
-  }
-
-  .normal {
-    background-color: trimColor;
-  }
-
-  .deleted {
-    padding-left: 7px;
-    padding-bottom: 2px;
-  }
-
-  .header {
-    padding-left: 5px;
-    padding-right: 5px;
-  }
-  .headerText {
-    vertical-align: top;
-    white-space: nowrap;
-    font-weight: bold;
-  }
-  .headerTable {
-    border: 0;
-    width: 100%;
-    padding-right: 40px;
-  }
-
-  .header:hover {
-    background-color: selectionColor;
-  }
-
-  .name {
-    width: 100%;
-  }
-  .nameEdit {
-    width: 100%;
-  }
-
-  .permissionList {
-    margin-left: 5px;
-    margin-right: 5px;
-    margin-bottom: 5px;
-  }
-
-  .addContainer {
-    padding-left: 16px;
-    padding-right: 16px;
-    font-size: 80%;
-  }
-  .addContainer:hover {
-    background-color: selectionColor;
-  }
-
-  .deleteIcon {
-    position: absolute;
-    top: 5px;
-    right: 17px;
-  }
-
-  .undoIcon {
-    position: absolute;
-    top: 2px;
-    right: 17px;
-  }
-</ui:style>
-
-<g:HTMLPanel styleName='{style.panel}'>
-<div ui:field='normal' class='{style.normal} {style.content}'>
-  <div class='{style.header}'>
-    <table class='{style.headerTable}'><tr>
-      <td class='{style.headerText}'>
-        <span ui:field='sectionType'/>
-      </td>
-      <td width='100%'>
-        <my:ValueEditor
-            ui:field='name'
-            addStyleNames='{style.name}'
-            editTitle='Edit reference pattern'>
-          <ui:attribute name='editTitle'/>
-          <my:editor>
-            <my:RefPatternBox styleName='{style.nameEdit}'/>
-          </my:editor>
-        </my:ValueEditor>
-        <span ui:field='sectionName' class='{style.name}'/>
-      </td>
-    </tr></table>
-
-    <g:Anchor
-        ui:field='deleteSection'
-        href='javascript:void'
-        styleName='{style.deleteIcon} {res.css.deleteIcon}'
-        title='Delete this section (and nested rules)'>
-      <ui:attribute name='title'/>
-    </g:Anchor>
-  </div>
-
-  <g:FlowPanel
-      ui:field='permissionContainer'
-      styleName='{style.permissionList}'/>
-  <div ui:field='addContainer' class='{style.addContainer}'>
-    <g:ValueListBox ui:field='permissionSelector'/>
-  </div>
-</div>
-
-<div
-    ui:field='deleted'
-    class='{style.deleted} {res.css.deleted}'
-    style='display: none'>
-  <span ui:field='deletedName'/>
-  <g:Anchor
-      ui:field='undoDelete'
-      href='javascript:void'
-      styleName='{style.undoIcon} {res.css.undoIcon}'
-      title='Undo deletion'>
-    <ui:attribute name='title'/>
-  </g:Anchor>
-</div>
-</g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java
deleted file mode 100644
index 5e38a14..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java
+++ /dev/null
@@ -1,159 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.admin;
-
-import static com.google.gerrit.client.FormatUtil.mediumFormat;
-import static com.google.gerrit.client.FormatUtil.name;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupAuditEventInfo;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-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.reviewdb.client.AccountGroup;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-import java.util.List;
-
-public class AccountGroupAuditLogScreen extends AccountGroupScreen {
-  private AuditEventTable auditEventTable;
-
-  public AccountGroupAuditLogScreen(GroupInfo toShow, String token) {
-    super(toShow, token);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    add(new SmallHeading(AdminConstants.I.headingAuditLog()));
-    auditEventTable = new AuditEventTable();
-    add(auditEventTable);
-  }
-
-  @Override
-  protected void display(GroupInfo group, boolean canModify) {
-    GroupApi.getAuditLog(
-        group.getGroupUUID(),
-        new GerritCallback<JsArray<GroupAuditEventInfo>>() {
-          @Override
-          public void onSuccess(JsArray<GroupAuditEventInfo> result) {
-            auditEventTable.display(Natives.asList(result));
-          }
-        });
-  }
-
-  private static class AuditEventTable extends FancyFlexTable<GroupAuditEventInfo> {
-    AuditEventTable() {
-      table.setText(0, 1, AdminConstants.I.columnDate());
-      table.setText(0, 2, AdminConstants.I.columnType());
-      table.setText(0, 3, AdminConstants.I.columnMember());
-      table.setText(0, 4, AdminConstants.I.columnByUser());
-
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
-    }
-
-    void display(List<GroupAuditEventInfo> auditEvents) {
-      while (1 < table.getRowCount()) {
-        table.removeRow(table.getRowCount() - 1);
-      }
-
-      for (GroupAuditEventInfo auditEvent : auditEvents) {
-        int row = table.getRowCount();
-        table.insertRow(row);
-        applyDataRowStyle(row);
-        populate(row, auditEvent);
-      }
-    }
-
-    void populate(int row, GroupAuditEventInfo auditEvent) {
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      table.setText(row, 1, mediumFormat(auditEvent.date()));
-
-      switch (auditEvent.type()) {
-        case ADD_USER:
-        case ADD_GROUP:
-          table.setText(row, 2, AdminConstants.I.typeAdded());
-          break;
-        case REMOVE_USER:
-        case REMOVE_GROUP:
-          table.setText(row, 2, AdminConstants.I.typeRemoved());
-          break;
-      }
-
-      switch (auditEvent.type()) {
-        case ADD_USER:
-        case REMOVE_USER:
-          table.setText(row, 3, formatAccount(auditEvent.memberAsUser()));
-          break;
-        case ADD_GROUP:
-        case REMOVE_GROUP:
-          GroupInfo member = auditEvent.memberAsGroup();
-          if (AccountGroup.isInternalGroup(member.getGroupUUID())) {
-            table.setWidget(
-                row,
-                3,
-                new Hyperlink(formatGroup(member), Dispatcher.toGroup(member.getGroupUUID())));
-            fmt.getElement(row, 3).setTitle(null);
-          } else if (member.url() != null) {
-            Anchor a = new Anchor();
-            a.setText(formatGroup(member));
-            a.setHref(member.url());
-            a.setTitle("UUID " + member.getGroupUUID().get());
-            table.setWidget(row, 3, a);
-            fmt.getElement(row, 3).setTitle(null);
-          } else {
-            table.setText(row, 3, formatGroup(member));
-            fmt.getElement(row, 3).setTitle("UUID " + member.getGroupUUID().get());
-          }
-          break;
-      }
-
-      table.setText(row, 4, formatAccount(auditEvent.user()));
-
-      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 4, Gerrit.RESOURCES.css().dataCell());
-
-      setRowItem(row, auditEvent);
-    }
-  }
-
-  private static String formatAccount(AccountInfo account) {
-    StringBuilder b = new StringBuilder();
-    b.append(name(account));
-    b.append(" (");
-    b.append(account._accountId());
-    b.append(")");
-    return b.toString();
-  }
-
-  private static String formatGroup(GroupInfo group) {
-    return group.name() != null && !group.name().isEmpty()
-        ? group.name()
-        : group.getGroupUUID().get();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
deleted file mode 100644
index 34a1ac9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
+++ /dev/null
@@ -1,243 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.AccountGroupSuggestOracle;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.client.ui.RemoteSuggestBox;
-import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.VerticalPanel;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-import com.google.gwtexpui.globalkey.client.NpTextArea;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-
-public class AccountGroupInfoScreen extends AccountGroupScreen {
-  private CopyableLabel groupUUIDLabel;
-
-  private NpTextBox groupNameTxt;
-  private Button saveName;
-
-  private RemoteSuggestBox ownerTxt;
-  private Button saveOwner;
-
-  private NpTextArea descTxt;
-  private Button saveDesc;
-
-  private CheckBox visibleToAllCheckBox;
-  private Button saveGroupOptions;
-
-  public AccountGroupInfoScreen(GroupInfo toShow, String token) {
-    super(toShow, token);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    initUUID();
-    initName();
-    initOwner();
-    initDescription();
-    initGroupOptions();
-  }
-
-  private void enableForm(boolean canModify) {
-    groupNameTxt.setEnabled(canModify);
-    ownerTxt.setEnabled(canModify);
-    descTxt.setEnabled(canModify);
-    visibleToAllCheckBox.setEnabled(canModify);
-  }
-
-  private void initUUID() {
-    final VerticalPanel groupUUIDPanel = new VerticalPanel();
-    groupUUIDPanel.setStyleName(Gerrit.RESOURCES.css().groupUUIDPanel());
-    groupUUIDPanel.add(new SmallHeading(AdminConstants.I.headingGroupUUID()));
-    groupUUIDLabel = new CopyableLabel("");
-    groupUUIDPanel.add(groupUUIDLabel);
-    add(groupUUIDPanel);
-  }
-
-  private void initName() {
-    final VerticalPanel groupNamePanel = new VerticalPanel();
-    groupNamePanel.setStyleName(Gerrit.RESOURCES.css().groupNamePanel());
-    groupNameTxt = new NpTextBox();
-    groupNameTxt.setStyleName(Gerrit.RESOURCES.css().groupNameTextBox());
-    groupNameTxt.setVisibleLength(60);
-    groupNamePanel.add(groupNameTxt);
-
-    saveName = new Button(AdminConstants.I.buttonRenameGroup());
-    saveName.setEnabled(false);
-    saveName.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            final String newName = groupNameTxt.getText().trim();
-            GroupApi.renameGroup(
-                getGroupUUID(),
-                newName,
-                new GerritCallback<com.google.gerrit.client.VoidResult>() {
-                  @Override
-                  public void onSuccess(com.google.gerrit.client.VoidResult result) {
-                    saveName.setEnabled(false);
-                    setPageTitle(AdminMessages.I.group(newName));
-                    groupNameTxt.setText(newName);
-                    if (getGroupUUID().equals(getOwnerGroupUUID())) {
-                      ownerTxt.setText(newName);
-                    }
-                  }
-                });
-          }
-        });
-    groupNamePanel.add(saveName);
-    add(groupNamePanel);
-  }
-
-  private void initOwner() {
-    final VerticalPanel ownerPanel = new VerticalPanel();
-    ownerPanel.setStyleName(Gerrit.RESOURCES.css().groupOwnerPanel());
-    ownerPanel.add(new SmallHeading(AdminConstants.I.headingOwner()));
-
-    final AccountGroupSuggestOracle accountGroupOracle = new AccountGroupSuggestOracle();
-    ownerTxt = new RemoteSuggestBox(accountGroupOracle);
-    ownerTxt.setStyleName(Gerrit.RESOURCES.css().groupOwnerTextBox());
-    ownerTxt.setVisibleLength(60);
-    ownerPanel.add(ownerTxt);
-
-    saveOwner = new Button(AdminConstants.I.buttonChangeGroupOwner());
-    saveOwner.setEnabled(false);
-    saveOwner.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            final String newOwner = ownerTxt.getText().trim();
-            if (newOwner.length() > 0) {
-              AccountGroup.UUID ownerUuid = accountGroupOracle.getUUID(newOwner);
-              String ownerId = ownerUuid != null ? ownerUuid.get() : newOwner;
-              GroupApi.setGroupOwner(
-                  getGroupUUID(),
-                  ownerId,
-                  new GerritCallback<GroupInfo>() {
-                    @Override
-                    public void onSuccess(GroupInfo result) {
-                      updateOwnerGroup(result);
-                      saveOwner.setEnabled(false);
-                    }
-                  });
-            }
-          }
-        });
-    ownerPanel.add(saveOwner);
-    add(ownerPanel);
-  }
-
-  private void initDescription() {
-    final VerticalPanel vp = new VerticalPanel();
-    vp.setStyleName(Gerrit.RESOURCES.css().groupDescriptionPanel());
-    vp.add(new SmallHeading(AdminConstants.I.headingDescription()));
-
-    descTxt = new NpTextArea();
-    descTxt.setVisibleLines(6);
-    descTxt.setCharacterWidth(60);
-    vp.add(descTxt);
-
-    saveDesc = new Button(AdminConstants.I.buttonSaveDescription());
-    saveDesc.setEnabled(false);
-    saveDesc.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            final String txt = descTxt.getText().trim();
-            GroupApi.setGroupDescription(
-                getGroupUUID(),
-                txt,
-                new GerritCallback<VoidResult>() {
-                  @Override
-                  public void onSuccess(VoidResult result) {
-                    saveDesc.setEnabled(false);
-                  }
-                });
-          }
-        });
-    vp.add(saveDesc);
-    add(vp);
-  }
-
-  private void initGroupOptions() {
-    final VerticalPanel groupOptionsPanel = new VerticalPanel();
-
-    final VerticalPanel vp = new VerticalPanel();
-    vp.setStyleName(Gerrit.RESOURCES.css().groupOptionsPanel());
-    vp.add(new SmallHeading(AdminConstants.I.headingGroupOptions()));
-
-    visibleToAllCheckBox = new CheckBox(AdminConstants.I.isVisibleToAll());
-    vp.add(visibleToAllCheckBox);
-    groupOptionsPanel.add(vp);
-
-    saveGroupOptions = new Button(AdminConstants.I.buttonSaveGroupOptions());
-    saveGroupOptions.setEnabled(false);
-    saveGroupOptions.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            GroupApi.setGroupOptions(
-                getGroupUUID(),
-                visibleToAllCheckBox.getValue(),
-                new GerritCallback<VoidResult>() {
-                  @Override
-                  public void onSuccess(VoidResult result) {
-                    saveGroupOptions.setEnabled(false);
-                  }
-                });
-          }
-        });
-    groupOptionsPanel.add(saveGroupOptions);
-
-    add(groupOptionsPanel);
-
-    final OnEditEnabler enabler = new OnEditEnabler(saveGroupOptions);
-    enabler.listenTo(visibleToAllCheckBox);
-  }
-
-  @Override
-  protected void display(GroupInfo group, boolean canModify) {
-    groupUUIDLabel.setText(group.getGroupUUID().get());
-    groupNameTxt.setText(group.name());
-    ownerTxt.setText(
-        group.owner() != null
-            ? group.owner()
-            : AdminMessages.I.deletedReference(group.getOwnerUUID().get()));
-    descTxt.setText(group.description());
-    visibleToAllCheckBox.setValue(group.options().isVisibleToAll());
-    setMembersTabVisible(AccountGroup.isInternalGroup(group.getGroupUUID()));
-
-    enableForm(canModify);
-    saveName.setVisible(canModify);
-    saveOwner.setVisible(canModify);
-    saveDesc.setVisible(canModify);
-    saveGroupOptions.setVisible(canModify);
-    new OnEditEnabler(saveDesc, descTxt);
-    new OnEditEnabler(saveName, groupNameTxt);
-    new OnEditEnabler(saveOwner, ownerTxt.getTextBox());
-  }
-}
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
deleted file mode 100644
index 2614224..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
+++ /dev/null
@@ -1,460 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.AccountGroupSuggestOracle;
-import com.google.gerrit.client.ui.AccountLinkPanel;
-import com.google.gerrit.client.ui.AccountSuggestOracle;
-import com.google.gerrit.client.ui.AddMemberBox;
-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;
-import com.google.gwt.user.client.ui.Anchor;
-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 com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Panel;
-import java.util.Comparator;
-import java.util.HashSet;
-import java.util.List;
-
-public class AccountGroupMembersScreen extends AccountGroupScreen {
-
-  private MemberTable members;
-  private IncludeTable includes;
-
-  private Panel memberPanel;
-  private AddMemberBox addMemberBox;
-  private Button delMember;
-
-  private Panel includePanel;
-  private AddMemberBox addIncludeBox;
-  private Button delInclude;
-
-  private FlowPanel noMembersInfo;
-  private AccountGroupSuggestOracle accountGroupSuggestOracle;
-
-  public AccountGroupMembersScreen(GroupInfo toShow, String token) {
-    super(toShow, token);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    initMemberList();
-    initIncludeList();
-    initNoMembersInfo();
-  }
-
-  private void enableForm(boolean canModify) {
-    addMemberBox.setEnabled(canModify);
-    members.setEnabled(canModify);
-    addIncludeBox.setEnabled(canModify);
-    includes.setEnabled(canModify);
-  }
-
-  private void initMemberList() {
-    addMemberBox =
-        new AddMemberBox(
-            AdminConstants.I.buttonAddGroupMember(),
-            AdminConstants.I.defaultAccountName(),
-            new AccountSuggestOracle());
-
-    addMemberBox.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doAddNewMember();
-          }
-        });
-
-    members = new MemberTable();
-    members.addStyleName(Gerrit.RESOURCES.css().groupMembersTable());
-
-    delMember = new Button(AdminConstants.I.buttonDeleteGroupMembers());
-    delMember.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            members.deleteChecked();
-          }
-        });
-
-    memberPanel = new FlowPanel();
-    memberPanel.add(new SmallHeading(AdminConstants.I.headingMembers()));
-    memberPanel.add(addMemberBox);
-    memberPanel.add(members);
-    memberPanel.add(delMember);
-    add(memberPanel);
-  }
-
-  private void initIncludeList() {
-    accountGroupSuggestOracle = new AccountGroupSuggestOracle();
-    addIncludeBox =
-        new AddMemberBox(
-            AdminConstants.I.buttonAddIncludedGroup(),
-            AdminConstants.I.defaultAccountGroupName(),
-            accountGroupSuggestOracle);
-
-    addIncludeBox.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doAddNewInclude();
-          }
-        });
-
-    includes = new IncludeTable();
-    includes.addStyleName(Gerrit.RESOURCES.css().groupIncludesTable());
-
-    delInclude = new Button(AdminConstants.I.buttonDeleteIncludedGroup());
-    delInclude.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            includes.deleteChecked();
-          }
-        });
-
-    includePanel = new FlowPanel();
-    includePanel.add(new SmallHeading(AdminConstants.I.headingIncludedGroups()));
-    includePanel.add(addIncludeBox);
-    includePanel.add(includes);
-    includePanel.add(delInclude);
-    add(includePanel);
-  }
-
-  private void initNoMembersInfo() {
-    noMembersInfo = new FlowPanel();
-    noMembersInfo.setVisible(false);
-    noMembersInfo.add(new SmallHeading(AdminConstants.I.noMembersInfo()));
-    add(noMembersInfo);
-  }
-
-  @Override
-  protected void display(GroupInfo group, boolean canModify) {
-    if (AccountGroup.isInternalGroup(group.getGroupUUID())) {
-      members.display(Natives.asList(group.members()));
-      includes.display(Natives.asList(group.includes()));
-    } else {
-      memberPanel.setVisible(false);
-      includePanel.setVisible(false);
-      noMembersInfo.setVisible(true);
-    }
-
-    enableForm(canModify);
-    delMember.setVisible(canModify);
-    delInclude.setVisible(canModify);
-  }
-
-  void doAddNewMember() {
-    final String nameEmail = addMemberBox.getText();
-    if (nameEmail.length() == 0) {
-      return;
-    }
-
-    addMemberBox.setEnabled(false);
-    GroupApi.addMember(
-        getGroupUUID(),
-        nameEmail,
-        new GerritCallback<AccountInfo>() {
-          @Override
-          public void onSuccess(AccountInfo memberInfo) {
-            addMemberBox.setEnabled(true);
-            addMemberBox.setText("");
-            members.insert(memberInfo);
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            addMemberBox.setEnabled(true);
-            super.onFailure(caught);
-          }
-        });
-  }
-
-  void doAddNewInclude() {
-    String groupName = addIncludeBox.getText();
-    if (groupName.length() == 0) {
-      return;
-    }
-
-    AccountGroup.UUID uuid = accountGroupSuggestOracle.getUUID(groupName);
-    if (uuid == null) {
-      return;
-    }
-
-    addIncludeBox.setEnabled(false);
-    GroupApi.addIncludedGroup(
-        getGroupUUID(),
-        uuid.get(),
-        new GerritCallback<GroupInfo>() {
-          @Override
-          public void onSuccess(GroupInfo result) {
-            addIncludeBox.setEnabled(true);
-            addIncludeBox.setText("");
-            includes.insert(result);
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            addIncludeBox.setEnabled(true);
-            super.onFailure(caught);
-          }
-        });
-  }
-
-  private class MemberTable extends FancyFlexTable<AccountInfo> {
-    private boolean enabled = true;
-
-    MemberTable() {
-      table.setText(0, 2, AdminConstants.I.columnMember());
-      table.setText(0, 3, AdminConstants.I.columnEmailAddress());
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
-      fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-    }
-
-    void setEnabled(boolean enabled) {
-      this.enabled = enabled;
-      for (int row = 1; row < table.getRowCount(); row++) {
-        final AccountInfo i = getRowItem(row);
-        if (i != null) {
-          ((CheckBox) table.getWidget(row, 1)).setEnabled(enabled);
-        }
-      }
-    }
-
-    void deleteChecked() {
-      final HashSet<Integer> ids = new HashSet<>();
-      for (int row = 1; row < table.getRowCount(); row++) {
-        final AccountInfo i = getRowItem(row);
-        if (i != null && ((CheckBox) table.getWidget(row, 1)).getValue()) {
-          ids.add(i._accountId());
-        }
-      }
-      if (!ids.isEmpty()) {
-        GroupApi.removeMembers(
-            getGroupUUID(),
-            ids,
-            new GerritCallback<VoidResult>() {
-              @Override
-              public void onSuccess(VoidResult result) {
-                for (int row = 1; row < table.getRowCount(); ) {
-                  final AccountInfo i = getRowItem(row);
-                  if (i != null && ids.contains(i._accountId())) {
-                    table.removeRow(row);
-                  } else {
-                    row++;
-                  }
-                }
-              }
-            });
-      }
-    }
-
-    void display(List<AccountInfo> result) {
-      while (1 < table.getRowCount()) {
-        table.removeRow(table.getRowCount() - 1);
-      }
-
-      for (AccountInfo i : result) {
-        final int row = table.getRowCount();
-        table.insertRow(row);
-        applyDataRowStyle(row);
-        populate(row, i);
-      }
-    }
-
-    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;
-            }
-          };
-      int insertPos = getInsertRow(c, info);
-      if (insertPos >= 0) {
-        table.insertRow(insertPos);
-        applyDataRowStyle(insertPos);
-        populate(insertPos, info);
-      }
-    }
-
-    void populate(int row, AccountInfo i) {
-      CheckBox checkBox = new CheckBox();
-      table.setWidget(row, 1, checkBox);
-      checkBox.setEnabled(enabled);
-      table.setWidget(row, 2, AccountLinkPanel.create(i));
-      table.setText(row, 3, i.email());
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().iconCell());
-      fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
-
-      setRowItem(row, i);
-    }
-  }
-
-  private class IncludeTable extends FancyFlexTable<GroupInfo> {
-    private boolean enabled = true;
-
-    IncludeTable() {
-      table.setText(0, 2, AdminConstants.I.columnGroupName());
-      table.setText(0, 3, AdminConstants.I.columnGroupDescription());
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
-      fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-    }
-
-    void setEnabled(boolean enabled) {
-      this.enabled = enabled;
-      for (int row = 1; row < table.getRowCount(); row++) {
-        final GroupInfo i = getRowItem(row);
-        if (i != null) {
-          ((CheckBox) table.getWidget(row, 1)).setEnabled(enabled);
-        }
-      }
-    }
-
-    void deleteChecked() {
-      final HashSet<AccountGroup.UUID> ids = new HashSet<>();
-      for (int row = 1; row < table.getRowCount(); row++) {
-        final GroupInfo i = getRowItem(row);
-        if (i != null && ((CheckBox) table.getWidget(row, 1)).getValue()) {
-          ids.add(i.getGroupUUID());
-        }
-      }
-      if (!ids.isEmpty()) {
-        GroupApi.removeIncludedGroups(
-            getGroupUUID(),
-            ids,
-            new GerritCallback<VoidResult>() {
-              @Override
-              public void onSuccess(VoidResult result) {
-                for (int row = 1; row < table.getRowCount(); ) {
-                  final GroupInfo i = getRowItem(row);
-                  if (i != null && ids.contains(i.getGroupUUID())) {
-                    table.removeRow(row);
-                  } else {
-                    row++;
-                  }
-                }
-              }
-            });
-      }
-    }
-
-    void display(List<GroupInfo> list) {
-      while (1 < table.getRowCount()) {
-        table.removeRow(table.getRowCount() - 1);
-      }
-
-      for (GroupInfo i : list) {
-        final int row = table.getRowCount();
-        table.insertRow(row);
-        applyDataRowStyle(row);
-        populate(row, i);
-      }
-    }
-
-    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;
-            }
-          };
-      int insertPos = getInsertRow(c, info);
-      if (insertPos >= 0) {
-        table.insertRow(insertPos);
-        applyDataRowStyle(insertPos);
-        populate(insertPos, info);
-      }
-    }
-
-    void populate(int row, GroupInfo i) {
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-
-      AccountGroup.UUID uuid = i.getGroupUUID();
-      CheckBox checkBox = new CheckBox();
-      table.setWidget(row, 1, checkBox);
-      checkBox.setEnabled(enabled);
-      if (AccountGroup.isInternalGroup(uuid)) {
-        table.setWidget(row, 2, new Hyperlink(i.name(), Dispatcher.toGroup(uuid)));
-        fmt.getElement(row, 2).setTitle(null);
-        table.setText(row, 3, i.description());
-      } else if (i.url() != null) {
-        Anchor a = new Anchor();
-        a.setText(i.name());
-        a.setHref(i.url());
-        a.setTitle("UUID " + uuid.get());
-        table.setWidget(row, 2, a);
-        fmt.getElement(row, 2).setTitle(null);
-      } else {
-        table.setText(row, 2, i.name());
-        fmt.getElement(row, 2).setTitle("UUID " + uuid.get());
-      }
-
-      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().iconCell());
-      fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
-
-      setRowItem(row, i);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
deleted file mode 100644
index b67213b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import static com.google.gerrit.client.Dispatcher.toGroup;
-
-import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.MenuScreen;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-
-public abstract class AccountGroupScreen extends MenuScreen {
-  public static final String INFO = "info";
-  public static final String MEMBERS = "members";
-  public static final String AUDIT_LOG = "audit-log";
-
-  private final GroupInfo group;
-  private final String token;
-  private final String membersTabToken;
-  private final String auditLogTabToken;
-
-  public AccountGroupScreen(GroupInfo toShow, String token) {
-    setRequiresSignIn(true);
-
-    this.group = toShow;
-    this.token = token;
-    this.membersTabToken = getTabToken(token, MEMBERS);
-    this.auditLogTabToken = getTabToken(token, AUDIT_LOG);
-
-    link(AdminConstants.I.groupTabGeneral(), getTabToken(token, INFO));
-    link(
-        AdminConstants.I.groupTabMembers(),
-        membersTabToken,
-        AccountGroup.isInternalGroup(group.getGroupUUID()));
-  }
-
-  private String getTabToken(String token, String tab) {
-    if (token.startsWith("/admin/groups/uuid-")) {
-      return toGroup(group.getGroupUUID(), tab);
-    }
-    return toGroup(group.getGroupId(), tab);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    setPageTitle(AdminMessages.I.group(group.name()));
-    display();
-    GroupApi.isGroupOwner(
-        group.name(),
-        new GerritCallback<Boolean>() {
-          @Override
-          public void onSuccess(Boolean result) {
-            if (result) {
-              link(
-                  AdminConstants.I.groupTabAuditLog(),
-                  auditLogTabToken,
-                  AccountGroup.isInternalGroup(group.getGroupUUID()));
-              setToken(token);
-            }
-            display(group, result);
-          }
-        });
-  }
-
-  protected abstract void display(GroupInfo group, boolean canModify);
-
-  protected AccountGroup.UUID getGroupUUID() {
-    return group.getGroupUUID();
-  }
-
-  protected void updateOwnerGroup(GroupInfo ownerGroup) {
-    group.setOwnerUUID(ownerGroup.getGroupUUID());
-    group.owner(ownerGroup.name());
-  }
-
-  protected AccountGroup.UUID getOwnerGroupUUID() {
-    return group.getOwnerUUID();
-  }
-
-  protected void setMembersTabVisible(boolean visible) {
-    setLinkVisible(membersTabToken, visible);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
deleted file mode 100644
index c0947a8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
+++ /dev/null
@@ -1,291 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.i18n.client.Constants;
-import java.util.Map;
-
-public interface AdminConstants extends Constants {
-  AdminConstants I = GWT.create(AdminConstants.class);
-
-  String defaultAccountName();
-
-  String defaultAccountGroupName();
-
-  String defaultBranchName();
-
-  String defaultTagName();
-
-  String defaultRevisionSpec();
-
-  String annotation();
-
-  String buttonDeleteIncludedGroup();
-
-  String buttonAddIncludedGroup();
-
-  String buttonDeleteGroupMembers();
-
-  String buttonAddGroupMember();
-
-  String buttonSaveDescription();
-
-  String buttonRenameGroup();
-
-  String buttonCreateGroup();
-
-  String buttonCreateProject();
-
-  String buttonChangeGroupOwner();
-
-  String buttonChangeGroupType();
-
-  String buttonSelectGroup();
-
-  String buttonSaveChanges();
-
-  String checkBoxEmptyCommit();
-
-  String checkBoxPermissionsOnly();
-
-  String useContentMerge();
-
-  String useContributorAgreements();
-
-  String useSignedOffBy();
-
-  String createNewChangeForAllNotInTarget();
-
-  String enableSignedPush();
-
-  String requireSignedPush();
-
-  String requireChangeID();
-
-  String rejectImplicitMerges();
-
-  String privateByDefault();
-
-  String enableReviewerByEmail();
-
-  String matchAuthorToCommitterDate();
-
-  String headingMaxObjectSizeLimit();
-
-  String headingGroupOptions();
-
-  String isVisibleToAll();
-
-  String buttonSaveGroupOptions();
-
-  String suggestedGroupLabel();
-
-  String parentSuggestions();
-
-  String buttonBrowseProjects();
-
-  String projects();
-
-  String projectRepoBrowser();
-
-  String headingGroupUUID();
-
-  String headingOwner();
-
-  String headingDescription();
-
-  String headingProjectOptions();
-
-  String headingProjectCommands();
-
-  String headingCommands();
-
-  String headingMembers();
-
-  String headingIncludedGroups();
-
-  String noMembersInfo();
-
-  String headingExternalGroup();
-
-  String headingCreateGroup();
-
-  String headingParentProjectName();
-
-  String columnProjectName();
-
-  String headingAgreements();
-
-  String headingAuditLog();
-
-  String headingProjectSubmitType();
-
-  String projectSubmitType_INHERIT();
-
-  String projectSubmitType_FAST_FORWARD_ONLY();
-
-  String projectSubmitType_MERGE_ALWAYS();
-
-  String projectSubmitType_MERGE_IF_NECESSARY();
-
-  String projectSubmitType_REBASE_IF_NECESSARY();
-
-  String projectSubmitType_REBASE_ALWAYS();
-
-  String projectSubmitType_CHERRY_PICK();
-
-  String headingProjectState();
-
-  String projectState_ACTIVE();
-
-  String projectState_READ_ONLY();
-
-  String projectState_HIDDEN();
-
-  String columnMember();
-
-  String columnEmailAddress();
-
-  String columnGroupName();
-
-  String columnGroupDescription();
-
-  String columnGroupType();
-
-  String columnGroupNotifications();
-
-  String columnGroupVisibleToAll();
-
-  String columnDate();
-
-  String columnType();
-
-  String columnByUser();
-
-  String typeAdded();
-
-  String typeRemoved();
-
-  String columnBranchName();
-
-  String columnBranchRevision();
-
-  String columnTagName();
-
-  String columnTagRevision();
-
-  String columnTagAnnotation();
-
-  String initialRevision();
-
-  String revision();
-
-  String buttonAddBranch();
-
-  String buttonDeleteBranch();
-
-  String buttonAddTag();
-
-  String buttonDeleteTag();
-
-  String saveHeadButton();
-
-  String cancelHeadButton();
-
-  String groupItemHelp();
-
-  String groupListTitle();
-
-  String groupFilter();
-
-  String createGroupTitle();
-
-  String groupTabGeneral();
-
-  String groupTabMembers();
-
-  String groupTabAuditLog();
-
-  String projectListTitle();
-
-  String projectFilter();
-
-  String createProjectTitle();
-
-  String projectListQueryLink();
-
-  String plugins();
-
-  String pluginEnabled();
-
-  String pluginDisabled();
-
-  String pluginSettingsToolTip();
-
-  String columnPluginName();
-
-  String columnPluginSettings();
-
-  String columnPluginVersion();
-
-  String columnPluginStatus();
-
-  String noGroupSelected();
-
-  String errorNoMatchingGroups();
-
-  String errorNoGitRepository();
-
-  String addPermission();
-
-  Map<String, String> permissionNames();
-
-  String refErrorEmpty();
-
-  String refErrorBeginSlash();
-
-  String refErrorDoubleSlash();
-
-  String refErrorNoSpace();
-
-  String refErrorPrintable();
-
-  String errorsMustBeFixed();
-
-  String sectionTypeReference();
-
-  String sectionTypeSection();
-
-  Map<String, String> sectionNames();
-
-  String pagedListPrev();
-
-  String pagedListNext();
-
-  String buttonCreate();
-
-  String buttonCreateDescription();
-
-  String buttonCreateChange();
-
-  String buttonCreateChangeDescription();
-
-  String buttonEditConfig();
-
-  String buttonEditConfigDescription();
-
-  String editConfigMessage();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
deleted file mode 100644
index 8d6878f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ /dev/null
@@ -1,201 +0,0 @@
-defaultAccountName = Name or Email
-defaultAccountGroupName = Group Name
-defaultBranchName = Branch Name
-defaultTagName = Tag Name
-defaultRevisionSpec = Revision (Branch or SHA-1)
-annotation = Annotation (optional)
-
-buttonDeleteIncludedGroup = Delete
-buttonAddIncludedGroup = Add
-buttonDeleteGroupMembers = Delete
-buttonAddGroupMember = Add
-buttonRenameGroup = Rename Group
-buttonSaveDescription = Save Description
-buttonCreateGroup = Create Group
-buttonCreateProject = Create Project
-buttonChangeGroupOwner = Change Owner
-buttonChangeGroupType = Change Type
-buttonSelectGroup = Select
-buttonSaveChanges = Save Changes
-checkBoxEmptyCommit = Create initial empty commit
-checkBoxPermissionsOnly = Only serve as parent for other projects
-buttonBrowseProjects = Browse
-projects = All projects
-projectRepoBrowser = Repository Browser
-useContentMerge = Allow content merges
-useContributorAgreements = Require a valid contributor agreement to upload
-useSignedOffBy = Require <code>Signed-off-by</code> in commit message
-createNewChangeForAllNotInTarget = Create a new change for every commit not in the target branch
-enableSignedPush = Enable signed push
-requireSignedPush = Require signed push
-requireChangeID = Require <code>Change-Id</code> in commit message
-rejectImplicitMerges = Reject implicit merges when changes are pushed for review
-privateByDefault = Set all new changes private by default
-headingMaxObjectSizeLimit = Maximum Git object size limit
-headingGroupOptions = Group Options
-isVisibleToAll = Make group visible to all registered users.
-buttonSaveGroupOptions = Save Group Options
-suggestedGroupLabel = group
-headingParentProjectName = Rights Inherit From
-parentSuggestions = Parent Suggestion
-columnProjectName = Project Name
-enableReviewerByEmail = Enable adding unregistered users as reviewers and CCs on changes
-matchAuthorToCommitterDate = Match authored date with committer date upon submit
-
-headingGroupUUID = Group UUID
-headingOwner = Owners
-headingDescription = Description
-headingProjectOptions = Project Options
-headingProjectCommands = Project Commands
-headingCommands = Commands
-headingMembers = Members
-headingIncludedGroups = Included Groups
-noMembersInfo = Group Members can only be viewed for Gerrit internal groups. For external groups and Gerrit system groups the members cannot be displayed.
-headingExternalGroup = Selected External Group
-headingCreateGroup = Create New Group
-headingAgreements = Contributor Agreements
-headingAuditLog = Audit Log
-
-headingProjectSubmitType = Submit Type
-projectSubmitType_INHERIT = Inherit
-projectSubmitType_FAST_FORWARD_ONLY = Fast Forward Only
-projectSubmitType_MERGE_IF_NECESSARY = Merge if Necessary
-projectSubmitType_REBASE_ALWAYS = Rebase Always
-projectSubmitType_REBASE_IF_NECESSARY = Rebase if Necessary
-projectSubmitType_MERGE_ALWAYS = Always Merge
-projectSubmitType_CHERRY_PICK = Cherry Pick
-
-headingProjectState = State
-projectState_ACTIVE = Active
-projectState_READ_ONLY = Read Only
-projectState_HIDDEN = Hidden
-
-columnMember = Member
-columnEmailAddress = Email Address
-columnGroupName = Group Name
-columnGroupDescription = Description
-columnGroupType = Group Type
-columnGroupNotifications = Email Only Authors
-columnGroupVisibleToAll = Visible To All
-
-columnDate = Date
-columnType = Type
-columnByUser = By User
-
-typeAdded = Added
-typeRemoved = Removed
-
-columnBranchName = Branch Name
-columnBranchRevision = Revision
-columnTagName = Tag Name
-columnTagRevision = Revision
-columnTagAnnotation = Annotation
-initialRevision = Initial Revision
-revision = Revision
-buttonAddBranch = Create Branch
-buttonAddTag = Create Tag
-buttonDeleteBranch = Delete
-buttonDeleteTag = Delete
-saveHeadButton = Save
-cancelHeadButton = Cancel
-
-groupItemHelp = group
-
-groupListTitle = Groups
-groupFilter = Filter
-createGroupTitle = Create Group
-groupTabGeneral = General
-groupTabMembers = Members
-groupTabAuditLog = Audit Log
-projectListTitle = Projects
-projectFilter = Filter
-createProjectTitle = Create Project
-projectListQueryLink = Search for changes on this project
-
-plugins = Plugins
-pluginEnabled = Enabled
-pluginDisabled = Disabled
-pluginSettingsToolTip = Plugin Settings
-columnPluginName = Plugin Name
-columnPluginSettings = Settings
-columnPluginVersion = Version
-columnPluginStatus = Status
-
-noGroupSelected = (No group selected)
-errorNoMatchingGroups = No Matching Groups
-errorNoGitRepository = No Git Repository
-
-pagedListPrev = &#x21e6;Prev
-pagedListNext = Next&#x21e8;
-
-addPermission = Add Permission ...
-
-# Permission Names
-permissionNames = \
-	abandon, \
-	addPatchSet, \
-	create, \
-	createTag, \
-	createSignedTag, \
-	delete, \
-	deleteOwnChanges, \
-	editAssignee, \
-	editHashtags, \
-	editTopicName, \
-	forgeAuthor, \
-	forgeCommitter, \
-	forgeServerAsCommitter, \
-	owner, \
-	push, \
-	pushMerge, \
-	read, \
-	rebase, \
-	removeReviewer, \
-	submit, \
-	submitAs, \
-	viewPrivateChanges
-
-abandon = Abandon
-addPatchSet = Add Patch Set
-create = Create Reference
-createTag = Create Annotated Tag
-createSignedTag = Create Signed Tag
-delete = Delete Reference
-deleteOwnChanges = Delete Own Changes
-editAssignee = Edit Assignee
-editHashtags = Edit Hashtags
-editTopicName = Edit Topic Name
-forgeAuthor = Forge Author Identity
-forgeCommitter = Forge Committer Identity
-forgeServerAsCommitter = Forge Server Identity
-owner = Owner
-push = Push
-pushMerge = Push Merge Commit
-read = Read
-rebase = Rebase
-removeReviewer = Remove Reviewer
-submit = Submit
-submitAs = Submit (On Behalf Of)
-viewPrivateChanges = View Private Changes
-
-refErrorEmpty = Reference must be supplied
-refErrorBeginSlash = Reference must not start with '/'
-refErrorDoubleSlash = References cannot contain '//'
-refErrorNoSpace = References cannot contain spaces
-refErrorPrintable = References may contain only printable characters
-errorsMustBeFixed = Errors must be fixed before committing changes.
-
-# Section Names
-sectionTypeReference = Reference:
-sectionTypeSection = Section:
-sectionNames = \
-  GLOBAL_CAPABILITIES
-GLOBAL_CAPABILITIES = Global Capabilities
-
-buttonCreate = Create
-buttonCreateDescription = Insert the description of the change.
-buttonCreateChange = Create Change
-buttonCreateChangeDescription = Create change directly in the browser.
-buttonEditConfig = Edit Config
-buttonEditConfigDescription = Creates a change to edit the project configuration in the browser.
-editConfigMessage = Edit Project Config
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminCss.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminCss.java
deleted file mode 100644
index 53b2e72..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminCss.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gwt.resources.client.CssResource;
-
-public interface AdminCss extends CssResource {
-  String deleteIcon();
-
-  String undoIcon();
-
-  String deleted();
-
-  String deletedBorder();
-
-  String deleteSectionHover();
-}
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
deleted file mode 100644
index 0c2f6fa..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.i18n.client.Messages;
-
-public interface AdminMessages extends Messages {
-  AdminMessages I = GWT.create(AdminMessages.class);
-
-  String group(String name);
-
-  String label(String name);
-
-  String labelAs(String name);
-
-  String project(String name);
-
-  String deletedGroup(int id);
-
-  String deletedReference(String name);
-
-  String deletedSection(String name);
-
-  String effectiveMaxObjectSizeLimit(String effectiveMaxObjectSizeLimit);
-
-  String globalMaxObjectSizeLimit(String globalMaxObjectSizeLimit);
-
-  String pluginProjectOptionsTitle(String pluginName);
-
-  String pluginProjectInheritedValue(String value);
-
-  String pluginProjectInheritedListValue(String value);
-}
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
deleted file mode 100644
index 6338920..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
+++ /dev/null
@@ -1,13 +0,0 @@
-group = Group {0}
-label = Label {0}
-labelAs = Label {0} (On Behalf Of)
-project = Project {0}
-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.
-pluginProjectOptionsTitle = {0} Plugin Options
-pluginProjectOptionsTitle = {0} Plugin
-pluginProjectInheritedValue = inherited: {0}
-pluginProjectInheritedListValue = INHERIT ({0})
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
deleted file mode 100644
index dfbfbb6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.ImageResource;
-
-public interface AdminResources extends ClientBundle {
-  AdminResources I = GWT.create(AdminResources.class);
-
-  @Source("admin.css")
-  AdminCss css();
-
-  /** unknown origin TODO replace icons */
-  @Source("deleteNormal.png")
-  ImageResource deleteNormal();
-
-  @Source("deleteHover.png")
-  ImageResource deleteHover();
-
-  /** silk icons (CC-BY3.0): http://famfamfam.com/lab/icons/silk/ */
-  @Source("arrow_undo.png")
-  ImageResource undoNormal();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java
deleted file mode 100644
index 611db85..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java
+++ /dev/null
@@ -1,69 +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.client.admin;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.CreateChangeDialog;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.PopupPanel;
-
-class CreateChangeAction {
-  static void call(Button b, String project) {
-    // TODO Replace CreateChangeDialog with a nicer looking display.
-    b.setEnabled(false);
-    new CreateChangeDialog(new Project.NameKey(project)) {
-      {
-        sendButton.setText(AdminConstants.I.buttonCreate());
-        message.setText(AdminConstants.I.buttonCreateDescription());
-      }
-
-      @Override
-      public void onSend() {
-        ChangeApi.createChange(
-            project,
-            getDestinationBranch(),
-            getDestinationTopic(),
-            message.getText(),
-            null,
-            new GerritCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo result) {
-                sent = true;
-                hide();
-                Gerrit.display(PageLinks.toChange(result.projectNameKey(), result.legacyId()));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                enableButtons(true);
-                super.onFailure(caught);
-              }
-            });
-      }
-
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        super.onClose(event);
-        b.setEnabled(true);
-      }
-    }.center();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
deleted file mode 100644
index 6914ee9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
+++ /dev/null
@@ -1,160 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.admin;
-
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_GROUP;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.NotFoundScreen;
-import com.google.gerrit.client.account.AccountCapabilities;
-import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.VerticalPanel;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-
-public class CreateGroupScreen extends Screen {
-
-  private NpTextBox addTxt;
-  private Button addNew;
-
-  public CreateGroupScreen() {
-    super();
-    setRequiresSignIn(true);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    AccountCapabilities.all(
-        new GerritCallback<AccountCapabilities>() {
-          @Override
-          public void onSuccess(AccountCapabilities ac) {
-            if (ac.canPerform(CREATE_GROUP)) {
-              display();
-            } else {
-              Gerrit.display(PageLinks.ADMIN_CREATE_GROUP, new NotFoundScreen());
-            }
-          }
-        },
-        CREATE_GROUP);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setPageTitle(AdminConstants.I.createGroupTitle());
-    addCreateGroupPanel();
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    if (addTxt != null) {
-      addTxt.setFocus(true);
-    }
-  }
-
-  private void addCreateGroupPanel() {
-    VerticalPanel addPanel = new VerticalPanel();
-    addPanel.setStyleName(Gerrit.RESOURCES.css().addSshKeyPanel());
-    addPanel.add(new SmallHeading(AdminConstants.I.headingCreateGroup()));
-
-    addTxt =
-        new NpTextBox() {
-          @Override
-          public void onBrowserEvent(Event event) {
-            super.onBrowserEvent(event);
-            if (event.getTypeInt() == Event.ONPASTE) {
-              Scheduler.get()
-                  .scheduleDeferred(
-                      new ScheduledCommand() {
-                        @Override
-                        public void execute() {
-                          if (addTxt.getValue().trim().length() != 0) {
-                            addNew.setEnabled(true);
-                          }
-                        }
-                      });
-            }
-          }
-        };
-    addTxt.sinkEvents(Event.ONPASTE);
-
-    addTxt.setVisibleLength(60);
-    addTxt.addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-              doCreateGroup();
-            }
-          }
-        });
-    addPanel.add(addTxt);
-
-    addNew = new Button(AdminConstants.I.buttonCreateGroup());
-    addNew.setEnabled(false);
-    addNew.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doCreateGroup();
-          }
-        });
-    addPanel.add(addNew);
-    add(addPanel);
-
-    new OnEditEnabler(addNew, addTxt);
-  }
-
-  private void doCreateGroup() {
-    final String newName = addTxt.getText();
-    if (newName == null || newName.length() == 0) {
-      return;
-    }
-
-    addNew.setEnabled(false);
-    GroupApi.createGroup(
-        newName,
-        new GerritCallback<GroupInfo>() {
-          @Override
-          public void onSuccess(GroupInfo result) {
-            History.newItem(Dispatcher.toGroup(result.getGroupId(), AccountGroupScreen.MEMBERS));
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            super.onFailure(caught);
-            addNew.setEnabled(true);
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
deleted file mode 100644
index 02b9169..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
+++ /dev/null
@@ -1,306 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_PROJECT;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.NotFoundScreen;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.account.AccountCapabilities;
-import com.google.gerrit.client.projects.ProjectApi;
-import com.google.gerrit.client.projects.ProjectInfo;
-import com.google.gerrit.client.projects.ProjectMap;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.client.ui.ProjectListPopup;
-import com.google.gerrit.client.ui.ProjectNameSuggestOracle;
-import com.google.gerrit.client.ui.ProjectsTable;
-import com.google.gerrit.client.ui.RemoteSuggestBox;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.ProjectUtil;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.VerticalPanel;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-
-public class CreateProjectScreen extends Screen {
-  private Grid grid;
-  private NpTextBox project;
-  private Button create;
-  private Button browse;
-  private RemoteSuggestBox parent;
-  private CheckBox emptyCommit;
-  private CheckBox permissionsOnly;
-  private ProjectsTable suggestedParentsTab;
-  private ProjectListPopup projectsPopup;
-
-  public CreateProjectScreen() {
-    super();
-    setRequiresSignIn(true);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    AccountCapabilities.all(
-        new GerritCallback<AccountCapabilities>() {
-          @Override
-          public void onSuccess(AccountCapabilities ac) {
-            if (ac.canPerform(CREATE_PROJECT)) {
-              display();
-            } else {
-              Gerrit.display(PageLinks.ADMIN_CREATE_PROJECT, new NotFoundScreen());
-            }
-          }
-        },
-        CREATE_PROJECT);
-  }
-
-  @Override
-  protected void onUnload() {
-    super.onUnload();
-    projectsPopup.closePopup();
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setPageTitle(AdminConstants.I.createProjectTitle());
-    addCreateProjectPanel();
-
-    /* popup */
-    projectsPopup =
-        new ProjectListPopup() {
-          @Override
-          protected void onMovePointerTo(String projectName) {
-            // prevent user input from being overwritten by simply poping up
-            if (!projectsPopup.isPoppingUp() || "".equals(parent.getText())) {
-              parent.setText(projectName);
-            }
-          }
-        };
-    projectsPopup.initPopup(AdminConstants.I.projects(), PageLinks.ADMIN_PROJECTS);
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    if (project != null) {
-      project.setFocus(true);
-    }
-  }
-
-  private void addCreateProjectPanel() {
-    final VerticalPanel fp = new VerticalPanel();
-    fp.setStyleName(Gerrit.RESOURCES.css().createProjectPanel());
-
-    initCreateButton();
-    initCreateTxt();
-    initParentBox();
-
-    addGrid(fp);
-
-    emptyCommit = new CheckBox(AdminConstants.I.checkBoxEmptyCommit());
-    emptyCommit.setValue(true);
-    permissionsOnly = new CheckBox(AdminConstants.I.checkBoxPermissionsOnly());
-    fp.add(emptyCommit);
-    fp.add(permissionsOnly);
-    fp.add(create);
-    VerticalPanel vp = new VerticalPanel();
-    vp.add(fp);
-    initSuggestedParents();
-    vp.add(suggestedParentsTab);
-    add(vp);
-  }
-
-  private void initCreateTxt() {
-    project =
-        new NpTextBox() {
-          @Override
-          public void onBrowserEvent(Event event) {
-            super.onBrowserEvent(event);
-            if (event.getTypeInt() == Event.ONPASTE) {
-              Scheduler.get()
-                  .scheduleDeferred(
-                      new ScheduledCommand() {
-                        @Override
-                        public void execute() {
-                          if (project.getValue().trim().length() != 0) {
-                            create.setEnabled(true);
-                          }
-                        }
-                      });
-            }
-          }
-        };
-    project.sinkEvents(Event.ONPASTE);
-    project.setVisibleLength(50);
-    project.addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-              doCreateProject();
-            }
-          }
-        });
-    new OnEditEnabler(create, project);
-  }
-
-  private void initCreateButton() {
-    create = new Button(AdminConstants.I.buttonCreateProject());
-    create.setEnabled(false);
-    create.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doCreateProject();
-          }
-        });
-
-    browse = new Button(AdminConstants.I.buttonBrowseProjects());
-    browse.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            int top = grid.getAbsoluteTop() - 50; // under page header
-            // Try to place it to the right of everything else, but not
-            // right justified
-            int left =
-                5
-                    + Math.max(
-                        grid.getAbsoluteLeft() + grid.getOffsetWidth(),
-                        suggestedParentsTab.getAbsoluteLeft()
-                            + suggestedParentsTab.getOffsetWidth());
-            projectsPopup.setPreferredCoordinates(top, left);
-            projectsPopup.displayPopup();
-          }
-        });
-  }
-
-  private void initParentBox() {
-    parent = new RemoteSuggestBox(new ProjectNameSuggestOracle());
-    parent.setVisibleLength(50);
-  }
-
-  private void initSuggestedParents() {
-    suggestedParentsTab =
-        new ProjectsTable() {
-          {
-            table.setText(0, 1, AdminConstants.I.parentSuggestions());
-          }
-
-          @Override
-          protected void populate(int row, ProjectInfo k) {
-            populateState(row, k);
-            final Anchor projectLink = new Anchor(k.name());
-            projectLink.addClickHandler(
-                new ClickHandler() {
-
-                  @Override
-                  public void onClick(ClickEvent event) {
-                    parent.setText(getRowItem(row).name());
-                  }
-                });
-
-            table.setWidget(row, ProjectsTable.C_NAME, projectLink);
-            table.setText(row, ProjectsTable.C_DESCRIPTION, k.description());
-
-            setRowItem(row, k);
-          }
-        };
-    suggestedParentsTab.setVisible(false);
-
-    ProjectMap.parentCandidates(
-        new GerritCallback<ProjectMap>() {
-          @Override
-          public void onSuccess(ProjectMap list) {
-            if (!list.isEmpty()) {
-              suggestedParentsTab.setVisible(true);
-              suggestedParentsTab.display(list);
-              suggestedParentsTab.finishDisplay();
-            }
-          }
-        });
-  }
-
-  private void addGrid(VerticalPanel fp) {
-    grid = new Grid(2, 3);
-    grid.setStyleName(Gerrit.RESOURCES.css().infoBlock());
-    grid.setText(0, 0, AdminConstants.I.columnProjectName() + ":");
-    grid.setWidget(0, 1, project);
-    grid.setText(1, 0, AdminConstants.I.headingParentProjectName() + ":");
-    grid.setWidget(1, 1, parent);
-    grid.setWidget(1, 2, browse);
-    fp.add(grid);
-  }
-
-  private void doCreateProject() {
-    final String projectName = project.getText().trim();
-    final String parentName = parent.getText().trim();
-
-    if ("".equals(projectName)) {
-      project.setFocus(true);
-      return;
-    }
-
-    enableForm(false);
-    ProjectApi.createProject(
-        projectName,
-        parentName,
-        emptyCommit.getValue(),
-        permissionsOnly.getValue(),
-        new AsyncCallback<VoidResult>() {
-          @Override
-          public void onSuccess(VoidResult result) {
-            String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
-            History.newItem(
-                Dispatcher.toProjectAdmin(
-                    new Project.NameKey(nameWithoutSuffix), ProjectScreen.INFO));
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            new ErrorDialog(caught.getMessage()).center();
-            enableForm(true);
-          }
-        });
-  }
-
-  private void enableForm(boolean enabled) {
-    project.setEnabled(enabled);
-    create.setEnabled(enabled);
-    parent.setEnabled(enabled);
-    emptyCommit.setEnabled(enabled);
-    permissionsOnly.setEnabled(enabled);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java
deleted file mode 100644
index cb2ca0f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.admin;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gwt.user.client.ui.Button;
-
-public class EditConfigAction {
-
-  static void call(Button b, Project.NameKey project) {
-    b.setEnabled(false);
-
-    ChangeApi.createChange(
-        project.get(),
-        RefNames.REFS_CONFIG,
-        null,
-        AdminConstants.I.editConfigMessage(),
-        null,
-        new GerritCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo result) {
-            Gerrit.display(
-                Dispatcher.toEditScreen(
-                    project, new PatchSet.Id(result.legacyId(), 1), "project.config"));
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            b.setEnabled(true);
-            super.onFailure(caught);
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
deleted file mode 100644
index b37a680..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
+++ /dev/null
@@ -1,232 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import static com.google.gerrit.common.PageLinks.ADMIN_GROUPS;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.groups.GroupMap;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.Hyperlink;
-import com.google.gerrit.client.ui.PagingHyperlink;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyUpEvent;
-import com.google.gwt.event.dom.client.KeyUpHandler;
-import com.google.gwt.http.client.URL;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-
-public class GroupListScreen extends Screen {
-  private Hyperlink prev;
-  private Hyperlink next;
-  private GroupTable groups;
-  private NpTextBox filterTxt;
-  private int pageSize;
-
-  private String match = "";
-  private int start;
-  private Query query;
-
-  public GroupListScreen() {
-    setRequiresSignIn(true);
-    pageSize = Gerrit.getUserPreferences().changesPerPage();
-  }
-
-  public GroupListScreen(String params) {
-    this();
-    for (String kvPair : params.split("[,;&]")) {
-      String[] kv = kvPair.split("=", 2);
-      if (kv.length != 2 || kv[0].isEmpty()) {
-        continue;
-      }
-
-      if ("filter".equals(kv[0])) {
-        match = URL.decodeQueryString(kv[1]);
-      }
-
-      if ("skip".equals(kv[0]) && URL.decodeQueryString(kv[1]).matches("^[\\d]+")) {
-        start = Integer.parseInt(URL.decodeQueryString(kv[1]));
-      }
-    }
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    query = new Query(match).start(start).run();
-  }
-
-  private void setupNavigationLink(Hyperlink link, String filter, int skip) {
-    link.setTargetHistoryToken(getTokenForScreen(filter, skip));
-    link.setVisible(true);
-  }
-
-  private String getTokenForScreen(String filter, int skip) {
-    String token = ADMIN_GROUPS;
-    if (filter != null && !filter.isEmpty()) {
-      token += "?filter=" + URL.encodeQueryString(filter);
-    }
-    if (skip > 0) {
-      if (token.contains("?filter=")) {
-        token += ",";
-      } else {
-        token += "?";
-      }
-      token += "skip=" + skip;
-    }
-    return token;
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setPageTitle(AdminConstants.I.groupListTitle());
-    initPageHeader();
-
-    prev = PagingHyperlink.createPrev();
-    prev.setVisible(false);
-
-    next = PagingHyperlink.createNext();
-    next.setVisible(false);
-
-    groups = new GroupTable(PageLinks.ADMIN_GROUPS);
-    add(groups);
-
-    final HorizontalPanel buttons = new HorizontalPanel();
-    buttons.setStyleName(Gerrit.RESOURCES.css().changeTablePrevNextLinks());
-    buttons.add(prev);
-    buttons.add(next);
-    add(buttons);
-  }
-
-  private void initPageHeader() {
-    final HorizontalPanel hp = new HorizontalPanel();
-    hp.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
-    final Label filterLabel = new Label(AdminConstants.I.projectFilter());
-    filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
-    hp.add(filterLabel);
-    filterTxt = new NpTextBox();
-    filterTxt.setValue(match);
-    filterTxt.addKeyUpHandler(
-        new KeyUpHandler() {
-          @Override
-          public void onKeyUp(KeyUpEvent event) {
-            Query q =
-                new Query(filterTxt.getValue())
-                    .open(event.getNativeKeyCode() == KeyCodes.KEY_ENTER);
-            if (match.equals(q.qMatch)) {
-              q.start(start);
-            }
-            if (q.open || !match.equals(q.qMatch)) {
-              if (query == null) {
-                q.run();
-              }
-              query = q;
-            }
-          }
-        });
-    hp.add(filterTxt);
-    add(hp);
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    if (match != null) {
-      filterTxt.setCursorPos(match.length());
-    }
-    filterTxt.setFocus(true);
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    groups.setRegisterKeys(true);
-  }
-
-  private class Query {
-    private final String qMatch;
-    private int qStart;
-    private boolean open;
-
-    Query(String match) {
-      this.qMatch = match;
-    }
-
-    Query start(int start) {
-      this.qStart = start;
-      return this;
-    }
-
-    Query open(boolean open) {
-      this.open = open;
-      return this;
-    }
-
-    Query run() {
-      int limit = open ? 1 : pageSize + 1;
-      GroupMap.match(
-          qMatch,
-          limit,
-          qStart,
-          new GerritCallback<GroupMap>() {
-            @Override
-            public void onSuccess(GroupMap result) {
-              if (!isAttached()) {
-                // View has been disposed.
-              } else if (query == Query.this) {
-                query = null;
-                showMap(result);
-              } else {
-                query.run();
-              }
-            }
-          });
-      return this;
-    }
-
-    private void showMap(GroupMap result) {
-      if (open && !result.isEmpty()) {
-        Gerrit.display(PageLinks.toGroup(result.values().get(0).getGroupUUID()));
-        return;
-      }
-
-      setToken(getTokenForScreen(qMatch, qStart));
-      GroupListScreen.this.match = qMatch;
-      GroupListScreen.this.start = qStart;
-
-      if (result.size() <= pageSize) {
-        groups.display(result, qMatch);
-        next.setVisible(false);
-      } else {
-        groups.displaySubset(result, 0, result.size() - 1, qMatch);
-        setupNavigationLink(next, qMatch, qStart + pageSize);
-      }
-
-      if (qStart > 0) {
-        setupNavigationLink(prev, qMatch, qStart - pageSize);
-      } else {
-        prev.setVisible(false);
-      }
-
-      if (!isCurrentView()) {
-        display();
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java
deleted file mode 100644
index db138a9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.client.ui.AccountGroupSuggestOracle;
-import com.google.gerrit.client.ui.RemoteSuggestBox;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.editor.client.LeafValueEditor;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.event.logical.shared.HasCloseHandlers;
-import com.google.gwt.event.logical.shared.HasSelectionHandlers;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.Focusable;
-
-public class GroupReferenceBox extends Composite
-    implements LeafValueEditor<GroupReference>,
-        HasSelectionHandlers<GroupReference>,
-        HasCloseHandlers<GroupReferenceBox>,
-        Focusable {
-  private final AccountGroupSuggestOracle oracle;
-  private final RemoteSuggestBox suggestBox;
-
-  public GroupReferenceBox() {
-    oracle = new AccountGroupSuggestOracle();
-    suggestBox = new RemoteSuggestBox(oracle);
-    initWidget(suggestBox);
-
-    suggestBox.addSelectionHandler(
-        new SelectionHandler<String>() {
-          @Override
-          public void onSelection(SelectionEvent<String> event) {
-            SelectionEvent.fire(GroupReferenceBox.this, toValue(event.getSelectedItem()));
-          }
-        });
-    suggestBox.addCloseHandler(
-        new CloseHandler<RemoteSuggestBox>() {
-          @Override
-          public void onClose(CloseEvent<RemoteSuggestBox> event) {
-            suggestBox.setText("");
-            CloseEvent.fire(GroupReferenceBox.this, GroupReferenceBox.this);
-          }
-        });
-  }
-
-  public void setVisibleLength(int len) {
-    suggestBox.setVisibleLength(len);
-  }
-
-  @Override
-  public HandlerRegistration addSelectionHandler(SelectionHandler<GroupReference> handler) {
-    return addHandler(handler, SelectionEvent.getType());
-  }
-
-  @Override
-  public HandlerRegistration addCloseHandler(CloseHandler<GroupReferenceBox> handler) {
-    return addHandler(handler, CloseEvent.getType());
-  }
-
-  @Override
-  public GroupReference getValue() {
-    return toValue(suggestBox.getText());
-  }
-
-  private GroupReference toValue(String name) {
-    if (name != null && !name.isEmpty()) {
-      return new GroupReference(oracle.getUUID(name), name);
-    }
-    return null;
-  }
-
-  @Override
-  public void setValue(GroupReference value) {
-    suggestBox.setText(value != null ? value.getName() : "");
-  }
-
-  @Override
-  public int getTabIndex() {
-    return suggestBox.getTabIndex();
-  }
-
-  @Override
-  public void setTabIndex(int index) {
-    suggestBox.setTabIndex(index);
-  }
-
-  @Override
-  public void setFocus(boolean focused) {
-    suggestBox.setFocus(focused);
-  }
-
-  @Override
-  public void setAccessKey(char key) {
-    suggestBox.setAccessKey(key);
-  }
-
-  public void setProject(Project.NameKey projectName) {
-    oracle.setProject(projectName);
-  }
-}
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
deleted file mode 100644
index 259847e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
+++ /dev/null
@@ -1,158 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.groups.GroupList;
-import com.google.gerrit.client.groups.GroupMap;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.HighlightingInlineHyperlink;
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gerrit.client.ui.Util;
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.Anchor;
-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> {
-  private static final int NUM_COLS = 3;
-
-  public GroupTable() {
-    this(null);
-  }
-
-  public GroupTable(String pointerId) {
-    super(AdminConstants.I.groupItemHelp());
-    setSavePointerId(pointerId);
-
-    table.setText(0, 1, AdminConstants.I.columnGroupName());
-    table.setText(0, 2, AdminConstants.I.columnGroupDescription());
-    table.setText(0, 3, AdminConstants.I.columnGroupVisibleToAll());
-    table.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            final Cell cell = table.getCellForEvent(event);
-            if (cell != null
-                && cell.getCellIndex() != 1
-                && getRowItem(cell.getRowIndex()) != null) {
-              movePointerTo(cell.getRowIndex());
-            }
-          }
-        });
-
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    for (int i = 1; i <= NUM_COLS; i++) {
-      fmt.addStyleName(0, i, Gerrit.RESOURCES.css().dataHeader());
-    }
-  }
-
-  @Override
-  protected Object getRowItemKey(GroupInfo item) {
-    return item.getGroupId();
-  }
-
-  @Override
-  protected void onOpenRow(int row) {
-    GroupInfo groupInfo = getRowItem(row);
-    if (isInteralGroup(groupInfo)) {
-      History.newItem(Dispatcher.toGroup(groupInfo.getGroupId()));
-    } else if (groupInfo.url() != null) {
-      Window.open(groupInfo.url(), "_self", null);
-    }
-  }
-
-  public void display(GroupMap groups, String toHighlight) {
-    display(Natives.asList(groups.values()), toHighlight);
-  }
-
-  public void display(GroupList groups) {
-    display(Natives.asList(groups), null);
-  }
-
-  public void display(List<GroupInfo> list, String toHighlight) {
-    displaySubset(list, toHighlight, 0, list.size());
-  }
-
-  public void displaySubset(GroupMap groups, int fromIndex, int toIndex, String toHighlight) {
-    displaySubset(Natives.asList(groups.values()), toHighlight, fromIndex, toIndex);
-  }
-
-  public void displaySubset(List<GroupInfo> list, String toHighlight, int fromIndex, int toIndex) {
-    while (1 < table.getRowCount()) {
-      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());
-          }
-        });
-    for (GroupInfo group : list.subList(fromIndex, toIndex)) {
-      final int row = table.getRowCount();
-      table.insertRow(row);
-      applyDataRowStyle(row);
-      populate(row, group, toHighlight);
-    }
-  }
-
-  void populate(int row, GroupInfo k, String toHighlight) {
-    if (k.url() != null) {
-      if (isInteralGroup(k)) {
-        table.setWidget(
-            row,
-            1,
-            new HighlightingInlineHyperlink(
-                k.name(), Dispatcher.toGroup(k.getGroupId()), toHighlight));
-      } else {
-        Anchor link = new Anchor();
-        link.setHTML(Util.highlight(k.name(), toHighlight));
-        link.setHref(k.url());
-        table.setWidget(row, 1, link);
-      }
-    } else {
-      table.setHTML(row, 1, Util.highlight(k.name(), toHighlight));
-    }
-    table.setText(row, 2, k.description());
-    if (k.options().isVisibleToAll()) {
-      table.setWidget(row, 3, new Image(Gerrit.RESOURCES.greenCheck()));
-    }
-
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().groupName());
-    for (int i = 1; i <= NUM_COLS; i++) {
-      fmt.addStyleName(row, i, Gerrit.RESOURCES.css().dataCell());
-    }
-
-    setRowItem(row, k);
-  }
-
-  private boolean isInteralGroup(GroupInfo groupInfo) {
-    return groupInfo != null && groupInfo.url().startsWith("#" + PageLinks.ADMIN_GROUPS);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java
deleted file mode 100644
index 75c3cb6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.admin;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.ui.Hyperlink;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.http.client.URL;
-
-abstract class PaginatedProjectScreen extends ProjectScreen {
-  protected int pageSize;
-  protected String match = "";
-  protected int start;
-
-  PaginatedProjectScreen(Project.NameKey toShow) {
-    super(toShow);
-    pageSize = Gerrit.getUserPreferences().changesPerPage();
-  }
-
-  protected void parseToken(String token) {
-    for (String kvPair : token.split("[,;&/?]")) {
-      String[] kv = kvPair.split("=", 2);
-      if (kv.length != 2 || kv[0].isEmpty()) {
-        continue;
-      }
-
-      if ("filter".equals(kv[0])) {
-        match = URL.decodeQueryString(kv[1]);
-      }
-
-      if ("skip".equals(kv[0]) && URL.decodeQueryString(kv[1]).matches("^[\\d]+")) {
-        start = Integer.parseInt(URL.decodeQueryString(kv[1]));
-      }
-    }
-  }
-
-  protected void parseToken() {
-    parseToken(getToken());
-  }
-
-  protected String getTokenForScreen(String filter, int skip) {
-    String token = getScreenToken();
-    if (filter != null && !filter.isEmpty()) {
-      token += "?filter=" + URL.encodeQueryString(filter);
-    }
-    if (skip > 0) {
-      if (token.contains("?filter=")) {
-        token += ",";
-      } else {
-        token += "?";
-      }
-      token += "skip=" + skip;
-    }
-    return token;
-  }
-
-  protected abstract String getScreenToken();
-
-  protected void setupNavigationLink(Hyperlink link, String filter, int skip) {
-    link.setTargetHistoryToken(getTokenForScreen(filter, skip));
-    link.setVisible(true);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
deleted file mode 100644
index 39dadcc..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
+++ /dev/null
@@ -1,329 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.groups.GroupMap;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupInfo;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.dom.client.DivElement;
-import com.google.gwt.dom.client.Style.Display;
-import com.google.gwt.editor.client.Editor;
-import com.google.gwt.editor.client.EditorDelegate;
-import com.google.gwt.editor.client.ValueAwareEditor;
-import com.google.gwt.editor.client.adapters.EditorSource;
-import com.google.gwt.editor.client.adapters.ListEditor;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.MouseOutEvent;
-import com.google.gwt.event.dom.client.MouseOverEvent;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.ValueLabel;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-
-public class PermissionEditor extends Composite
-    implements Editor<Permission>, ValueAwareEditor<Permission> {
-  interface Binder extends UiBinder<HTMLPanel, PermissionEditor> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField(provided = true)
-  @Path("name")
-  ValueLabel<String> normalName;
-
-  @UiField(provided = true)
-  @Path("name")
-  ValueLabel<String> deletedName;
-
-  @UiField CheckBox exclusiveGroup;
-
-  @UiField FlowPanel ruleContainer;
-  ListEditor<PermissionRule, PermissionRuleEditor> rules;
-
-  @UiField DivElement addContainer;
-  @UiField DivElement addStage1;
-  @UiField DivElement addStage2;
-  @UiField Anchor beginAddRule;
-  @UiField @Editor.Ignore GroupReferenceBox groupToAdd;
-  @UiField Button addRule;
-
-  @UiField Anchor deletePermission;
-
-  @UiField DivElement normal;
-  @UiField DivElement deleted;
-
-  private final Project.NameKey projectName;
-  private final Map<AccountGroup.UUID, GroupInfo> groupInfo;
-  private final boolean readOnly;
-  private final AccessSection section;
-  private final LabelTypes labelTypes;
-  private Permission value;
-  private PermissionRange.WithDefaults validRange;
-  private boolean isDeleted;
-
-  public PermissionEditor(
-      ProjectAccess projectAccess, boolean readOnly, AccessSection section, LabelTypes labelTypes) {
-    this.readOnly = readOnly;
-    this.section = section;
-    this.projectName = projectAccess.getProjectName();
-    this.groupInfo = projectAccess.getGroupInfo();
-    this.labelTypes = labelTypes;
-
-    PermissionNameRenderer nameRenderer =
-        new PermissionNameRenderer(projectAccess.getCapabilities());
-    normalName = new ValueLabel<>(nameRenderer);
-    deletedName = new ValueLabel<>(nameRenderer);
-
-    initWidget(uiBinder.createAndBindUi(this));
-    groupToAdd.setProject(projectName);
-    rules = ListEditor.of(new RuleEditorSource());
-
-    exclusiveGroup.setEnabled(!readOnly);
-    exclusiveGroup.setVisible(RefConfigSection.isValid(section.getName()));
-
-    if (readOnly) {
-      addContainer.removeFromParent();
-      addContainer = null;
-
-      deletePermission.removeFromParent();
-      deletePermission = null;
-    }
-  }
-
-  @UiHandler("deletePermission")
-  void onDeleteHover(@SuppressWarnings("unused") MouseOverEvent event) {
-    addStyleName(AdminResources.I.css().deleteSectionHover());
-  }
-
-  @UiHandler("deletePermission")
-  void onDeleteNonHover(@SuppressWarnings("unused") MouseOutEvent event) {
-    removeStyleName(AdminResources.I.css().deleteSectionHover());
-  }
-
-  @UiHandler("deletePermission")
-  void onDeletePermission(@SuppressWarnings("unused") ClickEvent event) {
-    isDeleted = true;
-    normal.getStyle().setDisplay(Display.NONE);
-    deleted.getStyle().setDisplay(Display.BLOCK);
-  }
-
-  @UiHandler("undoDelete")
-  void onUndoDelete(@SuppressWarnings("unused") ClickEvent event) {
-    isDeleted = false;
-    deleted.getStyle().setDisplay(Display.NONE);
-    normal.getStyle().setDisplay(Display.BLOCK);
-  }
-
-  @UiHandler("beginAddRule")
-  void onBeginAddRule(@SuppressWarnings("unused") ClickEvent event) {
-    beginAddRule();
-  }
-
-  void beginAddRule() {
-    addStage1.getStyle().setDisplay(Display.NONE);
-    addStage2.getStyle().setDisplay(Display.BLOCK);
-
-    Scheduler.get()
-        .scheduleDeferred(
-            new ScheduledCommand() {
-              @Override
-              public void execute() {
-                groupToAdd.setFocus(true);
-              }
-            });
-  }
-
-  @UiHandler("addRule")
-  void onAddGroupByClick(@SuppressWarnings("unused") ClickEvent event) {
-    GroupReference ref = groupToAdd.getValue();
-    if (ref != null) {
-      addGroup(ref);
-    } else {
-      groupToAdd.setFocus(true);
-    }
-  }
-
-  @UiHandler("groupToAdd")
-  void onAddGroupByEnter(SelectionEvent<GroupReference> event) {
-    GroupReference ref = event.getSelectedItem();
-    if (ref != null) {
-      addGroup(ref);
-    }
-  }
-
-  @UiHandler("groupToAdd")
-  void onAbortAddGroup(@SuppressWarnings("unused") CloseEvent<GroupReferenceBox> event) {
-    hideAddGroup();
-  }
-
-  @UiHandler("hideAddGroup")
-  void hideAddGroup(@SuppressWarnings("unused") ClickEvent event) {
-    hideAddGroup();
-  }
-
-  private void hideAddGroup() {
-    addStage1.getStyle().setDisplay(Display.BLOCK);
-    addStage2.getStyle().setDisplay(Display.NONE);
-  }
-
-  private void addGroup(GroupReference ref) {
-    if (ref.getUUID() != null) {
-      if (value.getRule(ref) == null) {
-        PermissionRule newRule = value.getRule(ref, true);
-        if (validRange != null) {
-          int min = validRange.getDefaultMin();
-          int max = validRange.getDefaultMax();
-          newRule.setRange(min, max);
-
-        } else if (GlobalCapability.PRIORITY.equals(value.getName())) {
-          newRule.setAction(PermissionRule.Action.BATCH);
-        }
-
-        rules.getList().add(newRule);
-      }
-      groupToAdd.setValue(null);
-      groupToAdd.setFocus(true);
-
-    } else {
-      // If the oracle didn't get to complete a UUID, resolve it now.
-      //
-      addRule.setEnabled(false);
-      GroupMap.suggestAccountGroupForProject(
-          projectName.get(),
-          ref.getName(),
-          1,
-          new GerritCallback<GroupMap>() {
-            @Override
-            public void onSuccess(GroupMap result) {
-              addRule.setEnabled(true);
-              if (result.values().length() == 1) {
-                addGroup(
-                    new GroupReference(
-                        result.values().get(0).getGroupUUID(), result.values().get(0).name()));
-              } else {
-                groupToAdd.setFocus(true);
-                new ErrorDialog(Gerrit.M.noSuchGroupMessage(ref.getName())).center();
-              }
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              addRule.setEnabled(true);
-              super.onFailure(caught);
-            }
-          });
-    }
-  }
-
-  boolean isDeleted() {
-    return isDeleted;
-  }
-
-  @Override
-  public void setValue(Permission value) {
-    this.value = value;
-
-    if (Permission.hasRange(value.getName())) {
-      LabelType lt = labelTypes.byLabel(value.getLabel());
-      if (lt != null) {
-        validRange =
-            new PermissionRange.WithDefaults(
-                value.getName(),
-                lt.getMin().getValue(),
-                lt.getMax().getValue(),
-                lt.getMin().getValue(),
-                lt.getMax().getValue());
-      }
-    } else if (GlobalCapability.isGlobalCapability(value.getName())) {
-      validRange = GlobalCapability.getRange(value.getName());
-
-    } else {
-      validRange = null;
-    }
-
-    if (Permission.OWNER.equals(value.getName())) {
-      exclusiveGroup.setEnabled(false);
-    } else {
-      exclusiveGroup.setEnabled(!readOnly);
-    }
-  }
-
-  @Override
-  public void flush() {
-    List<PermissionRule> src = rules.getList();
-    List<PermissionRule> keep = new ArrayList<>(src.size());
-
-    for (int i = 0; i < src.size(); i++) {
-      PermissionRuleEditor e = (PermissionRuleEditor) ruleContainer.getWidget(i);
-      if (!e.isDeleted()) {
-        keep.add(src.get(i));
-      }
-    }
-    value.setRules(keep);
-  }
-
-  @Override
-  public void onPropertyChange(String... paths) {}
-
-  @Override
-  public void setDelegate(EditorDelegate<Permission> delegate) {}
-
-  private class RuleEditorSource extends EditorSource<PermissionRuleEditor> {
-    @Override
-    public PermissionRuleEditor create(int index) {
-      PermissionRuleEditor subEditor =
-          new PermissionRuleEditor(readOnly, groupInfo, section, value, validRange);
-      ruleContainer.insert(subEditor, index);
-      return subEditor;
-    }
-
-    @Override
-    public void dispose(PermissionRuleEditor subEditor) {
-      subEditor.removeFromParent();
-    }
-
-    @Override
-    public void setIndex(PermissionRuleEditor subEditor, int index) {
-      ruleContainer.insert(subEditor, index);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.ui.xml
deleted file mode 100644
index 00c41dc..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.ui.xml
+++ /dev/null
@@ -1,146 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2011 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-  xmlns:ui='urn:ui:com.google.gwt.uibinder'
-  xmlns:g='urn:import:com.google.gwt.user.client.ui'
-  xmlns:e='urn:import:com.google.gwt.editor.ui.client'
-  xmlns:my='urn:import:com.google.gerrit.client.admin'
-  ui:generateFormat='com.google.gwt.i18n.rebind.format.PropertiesFormat'
-  ui:generateKeys='com.google.gwt.i18n.rebind.keygen.MD5KeyGenerator'
-  ui:generateLocales='default,en'
-  >
-<ui:with field='res' type='com.google.gerrit.client.admin.AdminResources'/>
-<ui:style gss='false'>
-  @eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-  @eval backgroundColor com.google.gerrit.client.Gerrit.getTheme().backgroundColor;
-
-  .panel {
-    position: relative;
-  }
-
-  .normal {
-    border: 1px solid backgroundColor;
-    margin-top: -1px;
-    margin-bottom: -1px;
-  }
-
-  .header {
-    position: relative;
-    padding-left: 5px;
-    padding-right: 5px;
-    padding-bottom: 1px;
-    white-space: nowrap;
-  }
-
-  .header:hover {
-    background-color: selectionColor;
-  }
-
-  .name {
-    font-style: italic;
-  }
-
-  .exclusiveGroup {
-    position: absolute;
-    top: 0;
-    right: 36px;
-    width: 7em;
-    font-size: 80%;
-  }
-
-  .addContainer {
-    padding-left: 10px;
-    position: relative;
-  }
-  .addContainer:hover {
-    background-color: selectionColor;
-  }
-  .addLink {
-    font-size: 80%;
-  }
-
-  .deleteIcon {
-    position: absolute;
-    top: 1px;
-    right: 12px;
-  }
-</ui:style>
-
-<g:HTMLPanel stylePrimaryName='{style.panel}'>
-<div ui:field='normal' class='{style.normal}'>
-  <div class='{style.header}'>
-    <g:ValueLabel styleName='{style.name}' ui:field='normalName'/>
-    <g:CheckBox
-        ui:field='exclusiveGroup'
-        addStyleNames='{style.exclusiveGroup}'
-        text='Exclusive'>
-      <ui:attribute name='text'/>
-    </g:CheckBox>
-  <g:Anchor
-      ui:field='deletePermission'
-      href='javascript:void'
-      styleName='{style.deleteIcon} {res.css.deleteIcon}'
-      title='Delete this permission (and nested rules)'>
-    <ui:attribute name='title'/>
-  </g:Anchor>
-  </div>
-  <g:FlowPanel ui:field='ruleContainer'/>
-  <div ui:field='addContainer' class='{style.addContainer}'>
-    <div ui:field='addStage1'>
-      <g:Anchor
-          ui:field='beginAddRule'
-          styleName='{style.addLink}'
-          href='javascript:void'
-          text='Add Group'>
-        <ui:attribute name='text'/>
-      </g:Anchor>
-    </div>
-    <div ui:field='addStage2' style='display: none'>
-      <ui:msg>Group Name: <my:GroupReferenceBox
-                                            ui:field='groupToAdd'
-                                            visibleLength='45'/></ui:msg>
-      <g:Button
-          ui:field='addRule'
-          text='Add'>
-        <ui:attribute name='text'/>
-      </g:Button>
-      <g:Anchor
-          ui:field='hideAddGroup'
-          href='javascript:void'
-          styleName='{style.deleteIcon} {res.css.deleteIcon}'
-          title='Cancel additional group'>
-        <ui:attribute name='title'/>
-      </g:Anchor>
-    </div>
-  </div>
-</div>
-
-<div
-    ui:field='deleted'
-    class='{res.css.deleted} {res.css.deletedBorder}'
-    style='display: none'>
-  <ui:msg>Permission <g:ValueLabel styleName='{style.name}' ui:field='deletedName'/> was deleted</ui:msg>
-  <g:Anchor
-      ui:field='undoDelete'
-      href='javascript:void'
-      styleName='{style.deleteIcon} {res.css.undoIcon}'
-      title='Undo deletion'>
-    <ui:attribute name='title'/>
-  </g:Anchor>
-</div>
-</g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionNameRenderer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionNameRenderer.java
deleted file mode 100644
index ef02bd0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionNameRenderer.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.common.data.Permission;
-import com.google.gwt.text.shared.Renderer;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-
-class PermissionNameRenderer implements Renderer<String> {
-  private static final Map<String, String> permissions;
-
-  static {
-    permissions = new HashMap<>();
-    for (Map.Entry<String, String> e : AdminConstants.I.permissionNames().entrySet()) {
-      permissions.put(e.getKey(), e.getValue());
-      permissions.put(e.getKey().toLowerCase(), e.getValue());
-    }
-  }
-
-  private final Map<String, String> fromServer;
-
-  PermissionNameRenderer(Map<String, String> allFromOutside) {
-    fromServer = allFromOutside;
-  }
-
-  @Override
-  public String render(String varName) {
-    if (Permission.isLabelAs(varName)) {
-      return AdminMessages.I.labelAs(Permission.extractLabel(varName));
-    } else if (Permission.isLabel(varName)) {
-      return AdminMessages.I.label(Permission.extractLabel(varName));
-    }
-
-    String desc = permissions.get(varName);
-    if (desc != null) {
-      return desc;
-    }
-
-    desc = fromServer.get(varName);
-    if (desc != null) {
-      return desc;
-    }
-
-    desc = permissions.get(varName.toLowerCase());
-    if (desc != null) {
-      return desc;
-    }
-
-    desc = fromServer.get(varName.toLowerCase());
-    if (desc != null) {
-      return desc;
-    }
-    return varName;
-  }
-
-  @Override
-  public void render(String object, Appendable appendable) throws IOException {
-    appendable.append(render(object));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java
deleted file mode 100644
index 16dd167..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java
+++ /dev/null
@@ -1,258 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
-import static com.google.gerrit.common.data.Permission.PUSH;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupInfo;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.DivElement;
-import com.google.gwt.dom.client.SpanElement;
-import com.google.gwt.dom.client.Style.Display;
-import com.google.gwt.editor.client.Editor;
-import com.google.gwt.editor.client.EditorDelegate;
-import com.google.gwt.editor.client.ValueAwareEditor;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.text.shared.Renderer;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.ValueListBox;
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-
-public class PermissionRuleEditor extends Composite
-    implements Editor<PermissionRule>, ValueAwareEditor<PermissionRule> {
-  interface Binder extends UiBinder<HTMLPanel, PermissionRuleEditor> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField(provided = true)
-  ValueListBox<PermissionRule.Action> action;
-
-  @UiField(provided = true)
-  RangeBox min;
-
-  @UiField(provided = true)
-  RangeBox max;
-
-  @UiField CheckBox force;
-
-  @UiField Anchor groupNameLink;
-  @UiField SpanElement groupNameSpan;
-  @UiField SpanElement deletedGroupName;
-
-  @UiField Anchor deleteRule;
-
-  @UiField DivElement normal;
-  @UiField DivElement deleted;
-
-  @UiField SpanElement rangeEditor;
-
-  private Map<AccountGroup.UUID, GroupInfo> groupInfo;
-  private boolean isDeleted;
-  private HandlerRegistration clickHandler;
-
-  public PermissionRuleEditor(
-      boolean readOnly,
-      Map<AccountGroup.UUID, GroupInfo> groupInfo,
-      AccessSection section,
-      Permission permission,
-      PermissionRange.WithDefaults validRange) {
-    this.groupInfo = groupInfo;
-    action = new ValueListBox<>(actionRenderer);
-
-    if (validRange != null && 10 < validRange.getRangeSize()) {
-      min = new RangeBox.Box();
-      max = new RangeBox.Box();
-
-    } else if (validRange != null) {
-      RangeBox.List minList = new RangeBox.List();
-      RangeBox.List maxList = new RangeBox.List();
-      List<Integer> valueList = validRange.getValuesAsList();
-
-      minList.list.setValue(validRange.getMin());
-      maxList.list.setValue(validRange.getMax());
-
-      minList.list.setAcceptableValues(valueList);
-      maxList.list.setAcceptableValues(valueList);
-
-      min = minList;
-      max = maxList;
-
-      action.setAcceptableValues(
-          Arrays.asList(
-              PermissionRule.Action.ALLOW,
-              PermissionRule.Action.DENY,
-              PermissionRule.Action.BLOCK));
-
-    } else {
-      min = new RangeBox.Box();
-      max = new RangeBox.Box();
-
-      if (GlobalCapability.PRIORITY.equals(permission.getName())) {
-        action.setValue(PermissionRule.Action.INTERACTIVE);
-        action.setAcceptableValues(
-            Arrays.asList(PermissionRule.Action.INTERACTIVE, PermissionRule.Action.BATCH));
-
-      } else {
-        action.setValue(PermissionRule.Action.ALLOW);
-        action.setAcceptableValues(
-            Arrays.asList(
-                PermissionRule.Action.ALLOW,
-                PermissionRule.Action.DENY,
-                PermissionRule.Action.BLOCK));
-      }
-    }
-
-    initWidget(uiBinder.createAndBindUi(this));
-
-    String name = permission.getName();
-    boolean canForce = PUSH.equals(name);
-    if (canForce) {
-      String ref = section.getName();
-      canForce = !ref.startsWith("refs/for/") && !ref.startsWith("^refs/for/");
-      force.setText(PermissionRule.FORCE_PUSH);
-    } else {
-      canForce = EDIT_TOPIC_NAME.equals(name);
-      force.setText(PermissionRule.FORCE_EDIT);
-    }
-    force.setVisible(canForce);
-    force.setEnabled(!readOnly);
-    action.getElement().setPropertyBoolean("disabled", readOnly);
-
-    if (validRange != null) {
-      min.setEnabled(!readOnly);
-      max.setEnabled(!readOnly);
-    } else {
-      rangeEditor.getStyle().setDisplay(Display.NONE);
-    }
-
-    if (readOnly) {
-      deleteRule.removeFromParent();
-      deleteRule = null;
-    }
-
-    if (name.equals(GlobalCapability.BATCH_CHANGES_LIMIT)) {
-      min.setEnabled(false);
-    }
-  }
-
-  boolean isDeleted() {
-    return isDeleted;
-  }
-
-  @UiHandler("deleteRule")
-  void onDeleteRule(@SuppressWarnings("unused") ClickEvent event) {
-    isDeleted = true;
-    normal.getStyle().setDisplay(Display.NONE);
-    deleted.getStyle().setDisplay(Display.BLOCK);
-  }
-
-  @UiHandler("undoDelete")
-  void onUndoDelete(@SuppressWarnings("unused") ClickEvent event) {
-    isDeleted = false;
-    deleted.getStyle().setDisplay(Display.NONE);
-    normal.getStyle().setDisplay(Display.BLOCK);
-  }
-
-  @Override
-  public void setValue(PermissionRule value) {
-    if (clickHandler != null) {
-      clickHandler.removeHandler();
-      clickHandler = null;
-    }
-
-    GroupReference ref = value.getGroup();
-    GroupInfo info =
-        groupInfo != null && ref.getUUID() != null ? groupInfo.get(ref.getUUID()) : null;
-
-    boolean link;
-    if (ref.getUUID() != null && AccountGroup.isInternalGroup(ref.getUUID())) {
-      final String token = Dispatcher.toGroup(ref.getUUID());
-      groupNameLink.setText(ref.getName());
-      groupNameLink.setHref("#" + token);
-      groupNameLink.setTitle(info != null ? info.getDescription() : null);
-      groupNameLink.setTarget(null);
-      clickHandler =
-          groupNameLink.addClickHandler(
-              new ClickHandler() {
-                @Override
-                public void onClick(ClickEvent event) {
-                  event.preventDefault();
-                  event.stopPropagation();
-                  Gerrit.display(token);
-                }
-              });
-      link = true;
-    } else if (info != null && info.getUrl() != null) {
-      groupNameLink.setText(ref.getName());
-      groupNameLink.setHref(info.getUrl());
-      groupNameLink.setTitle(info.getDescription());
-      groupNameLink.setTarget("_blank");
-      link = true;
-    } else {
-      groupNameSpan.setInnerText(ref.getName());
-      groupNameSpan.setTitle(ref.getUUID() != null ? ref.getUUID().get() : "");
-      link = false;
-    }
-
-    deletedGroupName.setInnerText(ref.getName());
-    groupNameLink.setVisible(link);
-    UIObject.setVisible(groupNameSpan, !link);
-  }
-
-  @Override
-  public void setDelegate(EditorDelegate<PermissionRule> delegate) {}
-
-  @Override
-  public void flush() {}
-
-  @Override
-  public void onPropertyChange(String... paths) {}
-
-  private static class ActionRenderer implements Renderer<PermissionRule.Action> {
-    @Override
-    public String render(PermissionRule.Action object) {
-      return object != null ? object.toString() : "";
-    }
-
-    @Override
-    public void render(PermissionRule.Action object, Appendable appendable) throws IOException {
-      appendable.append(render(object));
-    }
-  }
-
-  private static final ActionRenderer actionRenderer = new ActionRenderer();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.ui.xml
deleted file mode 100644
index 644fef4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.ui.xml
+++ /dev/null
@@ -1,107 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2011 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-  xmlns:ui='urn:ui:com.google.gwt.uibinder'
-  xmlns:g='urn:import:com.google.gwt.user.client.ui'
-  xmlns:e='urn:import:com.google.gwt.editor.ui.client'
-  xmlns:my='urn:import:com.google.gerrit.client.admin'
-  xmlns:q='urn:import:com.google.gerrit.client.ui'
-  ui:generateFormat='com.google.gwt.i18n.rebind.format.PropertiesFormat'
-  ui:generateKeys='com.google.gwt.i18n.rebind.keygen.MD5KeyGenerator'
-  ui:generateLocales='default,en'
-  >
-<ui:with field='res' type='com.google.gerrit.client.admin.AdminResources'/>
-<ui:style gss='false'>
-  @eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-
-  .panel {
-    position: relative;
-    height: 1.5em;
-  }
-
-  .panel:hover {
-    background-color: selectionColor;
-  }
-
-  .normal {
-    padding-left: 10px;
-    white-space: nowrap;
-    height: 100%;
-  }
-
-  .deleted {
-    height: 100%;
-  }
-
-  .actionList, .minmax {
-    font-size: 80%;
-  }
-
-  .forcePush {
-    position: absolute;
-    top: 0;
-    right: 36px;
-    width: 7em;
-    font-size: 80%;
-  }
-
-  .deleteIcon {
-    position: absolute;
-    top: 2px;
-    right: 11px;
-  }
-
-  .groupName {
-    display: inline;
-  }
-</ui:style>
-
-<g:HTMLPanel styleName='{style.panel}'>
-<div ui:field='normal' class='{style.normal}'>
-  <g:ValueListBox ui:field='action' styleName='{style.actionList}'/>
-  <span ui:field='rangeEditor'>
-    <g:Widget ui:field='min' styleName='{style.minmax}'/>
-    <g:Widget ui:field='max' styleName='{style.minmax}'/>
-  </span>
-
-  <g:Anchor ui:field='groupNameLink' styleName='{style.groupName}'/>
-  <span ui:field='groupNameSpan' styleName='{style.groupName}'/>
-  <g:CheckBox ui:field='force' addStyleNames='{style.forcePush}'/>
-  <g:Anchor
-      ui:field='deleteRule'
-      href='javascript:void'
-      styleName='{style.deleteIcon} {res.css.deleteIcon}'
-      title='Delete this rule'>
-    <ui:attribute name='title'/>
-  </g:Anchor>
-</div>
-
-<div
-    ui:field='deleted'
-    class='{res.css.deleted} {style.deleted}'
-    style='display: none'>
-  <ui:msg>Group <span ui:field='deletedGroupName'/> was deleted</ui:msg>
-  <g:Anchor
-      ui:field='undoDelete'
-      href='javascript:void'
-      styleName='{style.deleteIcon} {res.css.undoIcon}'
-      title='Undo deletion'>
-    <ui:attribute name='title'/>
-  </g:Anchor>
-</div>
-</g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java
deleted file mode 100644
index 381c644..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java
+++ /dev/null
@@ -1,122 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.admin;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.api.ExtensionScreen;
-import com.google.gerrit.client.plugins.PluginInfo;
-import com.google.gerrit.client.plugins.PluginMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.FancyFlexTable;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.ImageResourceRenderer;
-import com.google.gwt.user.client.ui.Panel;
-
-public class PluginListScreen extends PluginScreen {
-
-  private Panel pluginPanel;
-  private PluginTable pluginTable;
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    initPluginList();
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    PluginMap.all(
-        new ScreenLoadCallback<PluginMap>(this) {
-          @Override
-          protected void preDisplay(PluginMap result) {
-            pluginTable.display(result);
-          }
-        });
-  }
-
-  private void initPluginList() {
-    pluginTable = new PluginTable();
-    pluginTable.addStyleName(Gerrit.RESOURCES.css().pluginsTable());
-
-    pluginPanel = new FlowPanel();
-    pluginPanel.setWidth("500px");
-    pluginPanel.add(pluginTable);
-    add(pluginPanel);
-  }
-
-  private static class PluginTable extends FancyFlexTable<PluginInfo> {
-    PluginTable() {
-      table.setText(0, 1, AdminConstants.I.columnPluginName());
-      table.setText(0, 2, AdminConstants.I.columnPluginSettings());
-      table.setText(0, 3, AdminConstants.I.columnPluginVersion());
-      table.setText(0, 4, AdminConstants.I.columnPluginStatus());
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
-    }
-
-    void display(PluginMap plugins) {
-      while (1 < table.getRowCount()) {
-        table.removeRow(table.getRowCount() - 1);
-      }
-
-      for (PluginInfo p : Natives.asList(plugins.values())) {
-        final int row = table.getRowCount();
-        table.insertRow(row);
-        applyDataRowStyle(row);
-        populate(row, p);
-      }
-    }
-
-    void populate(int row, PluginInfo plugin) {
-      if (plugin.disabled() || plugin.indexUrl() == null) {
-        table.setText(row, 1, plugin.name());
-      } else {
-        table.setWidget(
-            row, 1, new Anchor(plugin.name(), Gerrit.selfRedirect(plugin.indexUrl()), "_blank"));
-
-        if (new ExtensionScreen(plugin.name() + "/settings").isFound()) {
-          InlineHyperlink adminScreenLink = new InlineHyperlink();
-          adminScreenLink.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.gear()));
-          adminScreenLink.setTargetHistoryToken("/x/" + plugin.name() + "/settings");
-          adminScreenLink.setTitle(AdminConstants.I.pluginSettingsToolTip());
-          table.setWidget(row, 2, adminScreenLink);
-        }
-      }
-
-      table.setText(row, 3, plugin.version());
-      table.setText(
-          row,
-          4,
-          plugin.disabled() ? AdminConstants.I.pluginDisabled() : AdminConstants.I.pluginEnabled());
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(row, 4, Gerrit.RESOURCES.css().dataCell());
-
-      setRowItem(row, plugin);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginScreen.java
deleted file mode 100644
index 0909fe1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginScreen.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.admin;
-
-import com.google.gerrit.client.ui.Screen;
-
-public abstract class PluginScreen extends Screen {
-
-  public PluginScreen() {
-    setRequiresSignIn(true);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    setPageTitle(AdminConstants.I.plugins());
-    display();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
deleted file mode 100644
index a52ea60..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
+++ /dev/null
@@ -1,196 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.ui.Hyperlink;
-import com.google.gerrit.client.ui.ParentProjectBox;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.common.data.WebLinkInfoCommon;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.DivElement;
-import com.google.gwt.dom.client.Style.Display;
-import com.google.gwt.editor.client.Editor;
-import com.google.gwt.editor.client.EditorDelegate;
-import com.google.gwt.editor.client.ValueAwareEditor;
-import com.google.gwt.editor.client.adapters.EditorSource;
-import com.google.gwt.editor.client.adapters.ListEditor;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import java.util.ArrayList;
-import java.util.List;
-
-public class ProjectAccessEditor extends Composite
-    implements Editor<ProjectAccess>, ValueAwareEditor<ProjectAccess> {
-  interface Binder extends UiBinder<HTMLPanel, ProjectAccessEditor> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField DivElement inheritsFrom;
-
-  @UiField Hyperlink parentProject;
-
-  @UiField @Editor.Ignore ParentProjectBox parentProjectBox;
-
-  @UiField DivElement history;
-
-  @UiField FlowPanel webLinkPanel;
-
-  @UiField FlowPanel localContainer;
-  ListEditor<AccessSection, AccessSectionEditor> local;
-
-  @UiField Anchor addSection;
-
-  private ProjectAccess value;
-
-  private boolean editing;
-
-  public ProjectAccessEditor() {
-    initWidget(uiBinder.createAndBindUi(this));
-    local = ListEditor.of(new Source(localContainer));
-  }
-
-  @UiHandler("addSection")
-  void onAddSection(@SuppressWarnings("unused") ClickEvent event) {
-    int index = local.getList().size();
-    local.getList().add(new AccessSection("refs/heads/*"));
-
-    AccessSectionEditor editor = local.getEditors().get(index);
-    editor.enableEditing();
-    editor.editRefPattern();
-  }
-
-  @Override
-  public void setValue(ProjectAccess value) {
-    // If the owner can edit the Global Capabilities but they don't exist in this
-    // project, create an empty one at the beginning of the list making it
-    // possible to add permissions to it.
-    if (editing
-        && value.isOwnerOf(AccessSection.GLOBAL_CAPABILITIES)
-        && value.getLocal(AccessSection.GLOBAL_CAPABILITIES) == null) {
-      value.getLocal().add(0, new AccessSection(AccessSection.GLOBAL_CAPABILITIES));
-    }
-
-    this.value = value;
-
-    Project.NameKey parent = value.getInheritsFrom();
-    if (parent != null) {
-      inheritsFrom.getStyle().setDisplay(Display.BLOCK);
-      parentProject.setText(parent.get());
-      parentProject.setTargetHistoryToken( //
-          Dispatcher.toProjectAdmin(parent, ProjectScreen.ACCESS));
-
-      parentProjectBox.setVisible(editing);
-      parentProjectBox.setProject(value.getProjectName());
-      parentProjectBox.setParentProject(value.getInheritsFrom());
-      parentProject.setVisible(!parentProjectBox.isVisible());
-    } else {
-      inheritsFrom.getStyle().setDisplay(Display.NONE);
-    }
-    setUpWebLinks();
-
-    addSection.setVisible(editing && (!value.getOwnerOf().isEmpty() || value.canUpload()));
-  }
-
-  @Override
-  public void flush() {
-    List<AccessSection> src = local.getList();
-    List<AccessSection> keep = new ArrayList<>(src.size());
-
-    for (int i = 0; i < src.size(); i++) {
-      AccessSectionEditor e = (AccessSectionEditor) localContainer.getWidget(i);
-      if (!e.isDeleted() && !src.get(i).getPermissions().isEmpty()) {
-        keep.add(src.get(i));
-      }
-    }
-    value.setLocal(keep);
-    value.setInheritsFrom(parentProjectBox.getParentProjectName());
-  }
-
-  @Override
-  public void onPropertyChange(String... paths) {}
-
-  @Override
-  public void setDelegate(EditorDelegate<ProjectAccess> delegate) {}
-
-  void setEditing(boolean editing) {
-    this.editing = editing;
-    addSection.setVisible(editing);
-  }
-
-  private void setUpWebLinks() {
-    List<WebLinkInfoCommon> links = value.getFileHistoryLinks();
-    if (!value.isConfigVisible() || links == null || links.isEmpty()) {
-      history.getStyle().setDisplay(Display.NONE);
-      return;
-    }
-    for (WebLinkInfoCommon link : links) {
-      webLinkPanel.add(toAnchor(link));
-    }
-  }
-
-  private static Anchor toAnchor(WebLinkInfoCommon info) {
-    Anchor a = new Anchor();
-    a.setHref(info.url);
-    if (info.target != null && !info.target.isEmpty()) {
-      a.setTarget(info.target);
-    }
-    if (info.imageUrl != null && !info.imageUrl.isEmpty()) {
-      Image img = new Image();
-      img.setAltText(info.name);
-      img.setUrl(info.imageUrl);
-      img.setTitle(info.name);
-      a.getElement().appendChild(img.getElement());
-    } else {
-      a.setText("(" + info.name + ")");
-    }
-    return a;
-  }
-
-  private class Source extends EditorSource<AccessSectionEditor> {
-    private final FlowPanel container;
-
-    Source(FlowPanel container) {
-      this.container = container;
-    }
-
-    @Override
-    public AccessSectionEditor create(int index) {
-      AccessSectionEditor subEditor = new AccessSectionEditor(value);
-      subEditor.setEditing(editing);
-      container.insert(subEditor, index);
-      return subEditor;
-    }
-
-    @Override
-    public void dispose(AccessSectionEditor subEditor) {
-      subEditor.removeFromParent();
-    }
-
-    @Override
-    public void setIndex(AccessSectionEditor subEditor, int index) {
-      container.insert(subEditor, index);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml
deleted file mode 100644
index 7fbf70d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml
+++ /dev/null
@@ -1,82 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2011 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-  xmlns:ui='urn:ui:com.google.gwt.uibinder'
-  xmlns:g='urn:import:com.google.gwt.user.client.ui'
-  xmlns:q='urn:import:com.google.gerrit.client.ui'
-  ui:generateFormat='com.google.gwt.i18n.rebind.format.PropertiesFormat'
-  ui:generateKeys='com.google.gwt.i18n.rebind.keygen.MD5KeyGenerator'
-  ui:generateLocales='default,en'
-  >
-<ui:style gss='false'>
-  .inheritsFrom {
-    margin-bottom: 0.5em;
-  }
-  .parentTitle {
-    font-weight: bold;
-  }
-  .parentLink {
-    display: inline;
-  }
-
-  .history {
-    margin-bottom: 0.5em;
-  }
-  .historyTitle {
-    font-weight: bold;
-  }
-  .webLinkPanel a {
-    display: inline;
-  }
-  .webLinkPanel>a {
-    margin-left:2px;
-  }
-
-  .addContainer {
-    margin-top: 5px;
-  }
-  .addContainer:hover {
-    background-color: selectionColor;
-  }
-</ui:style>
-
-<g:HTMLPanel>
-  <div ui:field='inheritsFrom' class='{style.inheritsFrom}'>
-    <span class='{style.parentTitle}'><ui:msg>Rights Inherit From:</ui:msg></span>
-    <q:Hyperlink ui:field='parentProject' styleName='{style.parentLink}'/>
-    <q:ParentProjectBox
-      ui:field='parentProjectBox'
-      visible='false'/>
-  </div>
-  <div ui:field='history' class='{style.history}'>
-    <span class='{style.historyTitle}'><ui:msg>History:</ui:msg></span>
-    <td>
-      <g:FlowPanel ui:field="webLinkPanel" styleName='{style.webLinkPanel}'/>
-    </td>
-  </div>
-
-  <g:FlowPanel ui:field='localContainer'/>
-  <div class='{style.addContainer}'>
-    <g:Anchor
-        ui:field='addSection'
-        href='javascript:void'
-        text='Add Reference'>
-      <ui:attribute name='text'/>
-    </g:Anchor>
-  </div>
-</g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
deleted file mode 100644
index eb44bda..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
+++ /dev/null
@@ -1,312 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import static com.google.gerrit.common.ProjectAccessUtil.mergeSections;
-import static com.google.gerrit.common.ProjectAccessUtil.removeEmptyPermissionsAndSections;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.config.CapabilityInfo;
-import com.google.gerrit.client.config.ConfigServerApi;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.common.errors.UpdateParentFailedException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.DivElement;
-import com.google.gwt.editor.client.SimpleBeanEditorDriver;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.VerticalPanel;
-import com.google.gwtexpui.globalkey.client.NpTextArea;
-import com.google.gwtjsonrpc.client.RemoteJsonException;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-public class ProjectAccessScreen extends ProjectScreen {
-  interface Binder extends UiBinder<HTMLPanel, ProjectAccessScreen> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface Driver
-      extends SimpleBeanEditorDriver< //
-          ProjectAccess, //
-          ProjectAccessEditor> {}
-
-  @UiField DivElement editTools;
-
-  @UiField Button edit;
-
-  @UiField Button cancel1;
-
-  @UiField Button cancel2;
-
-  @UiField VerticalPanel error;
-
-  @UiField ProjectAccessEditor accessEditor;
-
-  @UiField DivElement commitTools;
-
-  @UiField NpTextArea commitMessage;
-
-  @UiField Button commit;
-
-  @UiField Button review;
-
-  private Driver driver;
-
-  private ProjectAccess access;
-
-  private NativeMap<CapabilityInfo> capabilityMap;
-
-  public ProjectAccessScreen(Project.NameKey toShow) {
-    super(toShow);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    add(uiBinder.createAndBindUi(this));
-
-    driver = GWT.create(Driver.class);
-    accessEditor.setEditing(false);
-    driver.initialize(accessEditor);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    CallbackGroup cbs = new CallbackGroup();
-    ConfigServerApi.capabilities(
-        cbs.add(
-            new AsyncCallback<NativeMap<CapabilityInfo>>() {
-              @Override
-              public void onSuccess(NativeMap<CapabilityInfo> result) {
-                capabilityMap = result;
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                // Handled by ScreenLoadCallback.onFailure().
-              }
-            }));
-    Util.PROJECT_SVC.projectAccess(
-        getProjectKey(),
-        cbs.addFinal(
-            new ScreenLoadCallback<ProjectAccess>(this) {
-              @Override
-              public void preDisplay(ProjectAccess access) {
-                displayReadOnly(access);
-              }
-            }));
-    savedPanel = ACCESS;
-  }
-
-  private void displayReadOnly(ProjectAccess access) {
-    this.access = access;
-    Map<String, String> allCapabilities = new HashMap<>();
-    for (CapabilityInfo c : Natives.asList(capabilityMap.values())) {
-      allCapabilities.put(c.id(), c.name());
-    }
-    this.access.setCapabilities(allCapabilities);
-    accessEditor.setEditing(false);
-    UIObject.setVisible(editTools, !access.getOwnerOf().isEmpty() || access.canUpload());
-    edit.setEnabled(!access.getOwnerOf().isEmpty() || access.canUpload());
-    cancel1.setVisible(false);
-    UIObject.setVisible(commitTools, false);
-    driver.edit(access);
-  }
-
-  @UiHandler("edit")
-  void onEdit(@SuppressWarnings("unused") ClickEvent event) {
-    resetEditors();
-
-    edit.setEnabled(false);
-    cancel1.setVisible(true);
-    UIObject.setVisible(commitTools, true);
-    commit.setVisible(!access.getOwnerOf().isEmpty());
-    review.setVisible(access.canUpload());
-    accessEditor.setEditing(true);
-    driver.edit(access);
-  }
-
-  private void resetEditors() {
-    // Push an empty instance through the driver before pushing the real
-    // data. This will force GWT to delete and recreate the editors, which
-    // is required to build initialize them as editable vs. read-only.
-    ProjectAccess mock = new ProjectAccess();
-    mock.setProjectName(access.getProjectName());
-    mock.setRevision(access.getRevision());
-    mock.setLocal(Collections.<AccessSection>emptyList());
-    mock.setOwnerOf(Collections.<String>emptySet());
-    driver.edit(mock);
-  }
-
-  @UiHandler(value = {"cancel1", "cancel2"})
-  void onCancel(@SuppressWarnings("unused") ClickEvent event) {
-    Gerrit.display(PageLinks.toProjectAcceess(getProjectKey()));
-  }
-
-  @UiHandler("commit")
-  void onCommit(@SuppressWarnings("unused") ClickEvent event) {
-    final ProjectAccess access = driver.flush();
-
-    if (driver.hasErrors()) {
-      Window.alert(AdminConstants.I.errorsMustBeFixed());
-      return;
-    }
-
-    String message = commitMessage.getText().trim();
-    if ("".equals(message)) {
-      message = null;
-    }
-
-    enable(false);
-    Util.PROJECT_SVC.changeProjectAccess( //
-        getProjectKey(), //
-        access.getRevision(), //
-        message, //
-        access.getLocal(), //
-        access.getInheritsFrom(), //
-        new GerritCallback<ProjectAccess>() {
-          @Override
-          public void onSuccess(ProjectAccess newAccess) {
-            enable(true);
-            commitMessage.setText("");
-            error.clear();
-            final Set<String> diffs = getDiffs(access, newAccess);
-            if (diffs.isEmpty()) {
-              displayReadOnly(newAccess);
-            } else {
-              error.add(new Label(Gerrit.C.projectAccessError()));
-              for (String diff : diffs) {
-                error.add(new Label(diff));
-              }
-              if (access.canUpload()) {
-                error.add(new Label(Gerrit.C.projectAccessProposeForReviewHint()));
-              }
-            }
-          }
-
-          private Set<String> getDiffs(ProjectAccess wantedAccess, ProjectAccess newAccess) {
-            List<AccessSection> wantedSections =
-                mergeSections(removeEmptyPermissionsAndSections(wantedAccess.getLocal()));
-            List<AccessSection> newSections =
-                removeEmptyPermissionsAndSections(newAccess.getLocal());
-            HashSet<AccessSection> same = new HashSet<>(wantedSections);
-            HashSet<AccessSection> different =
-                new HashSet<>(wantedSections.size() + newSections.size());
-            different.addAll(wantedSections);
-            different.addAll(newSections);
-            same.retainAll(newSections);
-            different.removeAll(same);
-
-            Set<String> differentNames = new HashSet<>();
-            for (AccessSection s : different) {
-              differentNames.add(s.getName());
-            }
-            return differentNames;
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            error.clear();
-            enable(true);
-            if (caught instanceof RemoteJsonException
-                && caught.getMessage().startsWith(UpdateParentFailedException.MESSAGE)) {
-              new ErrorDialog(
-                      Gerrit.M.parentUpdateFailed(
-                          caught
-                              .getMessage()
-                              .substring(UpdateParentFailedException.MESSAGE.length() + 1)))
-                  .center();
-            } else {
-              super.onFailure(caught);
-            }
-          }
-        });
-  }
-
-  @UiHandler("review")
-  void onReview(@SuppressWarnings("unused") ClickEvent event) {
-    final ProjectAccess access = driver.flush();
-
-    if (driver.hasErrors()) {
-      Window.alert(AdminConstants.I.errorsMustBeFixed());
-      return;
-    }
-
-    String message = commitMessage.getText().trim();
-    if ("".equals(message)) {
-      message = null;
-    }
-
-    enable(false);
-    Util.PROJECT_SVC.reviewProjectAccess( //
-        getProjectKey(), //
-        access.getRevision(), //
-        message, //
-        access.getLocal(), //
-        access.getInheritsFrom(), //
-        new GerritCallback<Change.Id>() {
-          @Override
-          public void onSuccess(Change.Id changeId) {
-            enable(true);
-            commitMessage.setText("");
-            error.clear();
-            if (changeId != null) {
-              Gerrit.display(PageLinks.toChange(getProjectKey(), changeId));
-            } else {
-              displayReadOnly(access);
-            }
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            error.clear();
-            enable(true);
-            super.onFailure(caught);
-          }
-        });
-  }
-
-  private void enable(boolean enabled) {
-    commitMessage.setEnabled(enabled);
-    commit.setEnabled(enabled && !access.getOwnerOf().isEmpty());
-    review.setEnabled(enabled && access.canUpload());
-    cancel1.setEnabled(enabled);
-    cancel2.setEnabled(enabled);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.ui.xml
deleted file mode 100644
index 724c7a1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.ui.xml
+++ /dev/null
@@ -1,87 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2011 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-  xmlns:ui='urn:ui:com.google.gwt.uibinder'
-  xmlns:g='urn:import:com.google.gwt.user.client.ui'
-  xmlns:my='urn:import:com.google.gerrit.client.admin'
-  xmlns:expui='urn:import:com.google.gwtexpui.globalkey.client'
-  ui:generateFormat='com.google.gwt.i18n.rebind.format.PropertiesFormat'
-  ui:generateKeys='com.google.gwt.i18n.rebind.keygen.MD5KeyGenerator'
-  ui:generateLocales='default,en'
-  >
-<ui:style gss='false'>
-  @external .gwt-TextArea;
-
-  .commitMessage {
-    margin-top: 2em;
-  }
-  .commitMessage .gwt-TextArea {
-    margin: 5px 5px 5px 5px;
-  }
-  .errorMessage {
-    margin-top: 5px;
-    margin-bottom: 5px;
-    color: red;
-  }
-</ui:style>
-
-<g:HTMLPanel>
-  <div ui:field='editTools'>
-    <g:Button
-        ui:field='edit'
-        text='Edit'>
-      <ui:attribute name='text'/>
-    </g:Button>
-    <g:Button
-        ui:field='cancel1'
-        text='Cancel'>
-      <ui:attribute name='text'/>
-    </g:Button>
-  </div>
-  <my:ProjectAccessEditor ui:field='accessEditor'/>
-  <div ui:field='commitTools'>
-    <div class='{style.commitMessage}'>
-      <ui:msg>Commit Message (optional):</ui:msg><br/>
-      <expui:NpTextArea
-          ui:field='commitMessage'
-          visibleLines='4'
-          characterWidth='60'
-          spellCheck='true'
-          />
-    </div>
-    <g:VerticalPanel
-      styleName='{style.errorMessage}'
-      ui:field='error'>
-    </g:VerticalPanel>
-    <g:Button
-        ui:field='commit'
-        text='Save Changes'>
-      <ui:attribute name='text'/>
-    </g:Button>
-    <g:Button
-        ui:field='review'
-        text='Save for Review'>
-      <ui:attribute name='text'/>
-    </g:Button>
-    <g:Button
-        ui:field='cancel2'
-        text='Cancel'>
-      <ui:attribute name='text'/>
-    </g:Button>
-  </div>
-</g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java
deleted file mode 100644
index c6a391b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java
+++ /dev/null
@@ -1,673 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.admin;
-
-import static com.google.gerrit.client.ui.Util.highlight;
-
-import com.google.gerrit.client.ConfirmationCallback;
-import com.google.gerrit.client.ConfirmationDialog;
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.access.AccessMap;
-import com.google.gerrit.client.access.ProjectAccessInfo;
-import com.google.gerrit.client.actions.ActionButton;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.projects.BranchInfo;
-import com.google.gerrit.client.projects.ProjectApi;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.HintTextBox;
-import com.google.gerrit.client.ui.Hyperlink;
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.client.ui.PagingHyperlink;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.dom.client.KeyUpEvent;
-import com.google.gwt.event.dom.client.KeyUpHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-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 com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.InlineHTML;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.TextBox;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-public class ProjectBranchesScreen extends PaginatedProjectScreen {
-  private Hyperlink prev;
-  private Hyperlink next;
-  private BranchesTable branchTable;
-  private Button delBranch;
-  private Button addBranch;
-  private HintTextBox nameTxtBox;
-  private HintTextBox irevTxtBox;
-  private FlowPanel addPanel;
-  private NpTextBox filterTxt;
-  private Query query;
-
-  public ProjectBranchesScreen(Project.NameKey toShow) {
-    super(toShow);
-  }
-
-  @Override
-  public String getScreenToken() {
-    return PageLinks.toProjectBranches(getProjectKey());
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    addPanel.setVisible(false);
-    AccessMap.get(
-        getProjectKey(),
-        new GerritCallback<ProjectAccessInfo>() {
-          @Override
-          public void onSuccess(ProjectAccessInfo result) {
-            addPanel.setVisible(result.canAddRefs());
-          }
-        });
-    query = new Query(match).start(start).run();
-    savedPanel = BRANCHES;
-  }
-
-  private void updateForm() {
-    branchTable.updateDeleteButton();
-    addBranch.setEnabled(true);
-    nameTxtBox.setEnabled(true);
-    irevTxtBox.setEnabled(true);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    initPageHeader();
-
-    prev = PagingHyperlink.createPrev();
-    prev.setVisible(false);
-
-    next = PagingHyperlink.createNext();
-    next.setVisible(false);
-
-    addPanel = new FlowPanel();
-
-    final Grid addGrid = new Grid(2, 2);
-    addGrid.setStyleName(Gerrit.RESOURCES.css().addBranch());
-    final int texBoxLength = 50;
-
-    nameTxtBox = new HintTextBox();
-    nameTxtBox.setVisibleLength(texBoxLength);
-    nameTxtBox.setHintText(AdminConstants.I.defaultBranchName());
-    nameTxtBox.addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-              doAddNewBranch();
-            }
-          }
-        });
-    addGrid.setText(0, 0, AdminConstants.I.columnBranchName() + ":");
-    addGrid.setWidget(0, 1, nameTxtBox);
-
-    irevTxtBox = new HintTextBox();
-    irevTxtBox.setVisibleLength(texBoxLength);
-    irevTxtBox.setHintText(AdminConstants.I.defaultRevisionSpec());
-    irevTxtBox.addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-              doAddNewBranch();
-            }
-          }
-        });
-    addGrid.setText(1, 0, AdminConstants.I.initialRevision() + ":");
-    addGrid.setWidget(1, 1, irevTxtBox);
-
-    addBranch = new Button(AdminConstants.I.buttonAddBranch());
-    addBranch.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doAddNewBranch();
-          }
-        });
-    addPanel.add(addGrid);
-    addPanel.add(addBranch);
-
-    branchTable = new BranchesTable();
-
-    delBranch = new Button(AdminConstants.I.buttonDeleteBranch());
-    delBranch.setStyleName(Gerrit.RESOURCES.css().branchTableDeleteButton());
-    delBranch.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            branchTable.deleteChecked();
-          }
-        });
-    HorizontalPanel buttons = new HorizontalPanel();
-    buttons.setStyleName(Gerrit.RESOURCES.css().branchTablePrevNextLinks());
-    buttons.add(delBranch);
-    buttons.add(prev);
-    buttons.add(next);
-    add(branchTable);
-    add(buttons);
-    add(addPanel);
-  }
-
-  private void initPageHeader() {
-    parseToken();
-    HorizontalPanel hp = new HorizontalPanel();
-    hp.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
-    Label filterLabel = new Label(AdminConstants.I.projectFilter());
-    filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
-    hp.add(filterLabel);
-    filterTxt = new NpTextBox();
-    filterTxt.setValue(match);
-    filterTxt.addKeyUpHandler(
-        new KeyUpHandler() {
-          @Override
-          public void onKeyUp(KeyUpEvent event) {
-            Query q = new Query(filterTxt.getValue());
-            if (match.equals(q.qMatch)) {
-              q.start(start);
-            } else {
-              if (query == null) {
-                q.run();
-              }
-              query = q;
-            }
-          }
-        });
-    hp.add(filterTxt);
-    add(hp);
-  }
-
-  private void doAddNewBranch() {
-    final String branchName = nameTxtBox.getText().trim();
-    if ("".equals(branchName)) {
-      nameTxtBox.setFocus(true);
-      return;
-    }
-
-    final String rev = irevTxtBox.getText().trim();
-    if ("".equals(rev)) {
-      irevTxtBox.setText("HEAD");
-      Scheduler.get()
-          .scheduleDeferred(
-              new ScheduledCommand() {
-                @Override
-                public void execute() {
-                  irevTxtBox.selectAll();
-                  irevTxtBox.setFocus(true);
-                }
-              });
-      return;
-    }
-
-    addBranch.setEnabled(false);
-    ProjectApi.createBranch(
-        getProjectKey(),
-        branchName,
-        rev,
-        new GerritCallback<BranchInfo>() {
-          @Override
-          public void onSuccess(BranchInfo branch) {
-            showAddedBranch(branch);
-            nameTxtBox.setText("");
-            irevTxtBox.setText("");
-            query = new Query(match).start(start).run();
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            addBranch.setEnabled(true);
-            selectAllAndFocus(nameTxtBox);
-            new ErrorDialog(caught.getMessage()).center();
-          }
-        });
-  }
-
-  void showAddedBranch(BranchInfo branch) {
-    SafeHtmlBuilder b = new SafeHtmlBuilder();
-    b.openElement("b");
-    b.append(Gerrit.C.branchCreationConfirmationMessage());
-    b.closeElement("b");
-
-    b.openElement("p");
-    b.append(branch.ref());
-    b.closeElement("p");
-
-    ConfirmationDialog confirmationDialog =
-        new ConfirmationDialog(
-            Gerrit.C.branchCreationDialogTitle(),
-            b.toSafeHtml(),
-            new ConfirmationCallback() {
-              @Override
-              public void onOk() {
-                // do nothing
-              }
-            });
-    confirmationDialog.center();
-    confirmationDialog.setCancelVisible(false);
-  }
-
-  private static void selectAllAndFocus(TextBox textBox) {
-    textBox.selectAll();
-    textBox.setFocus(true);
-  }
-
-  private class BranchesTable extends NavigationTable<BranchInfo> {
-    private ValueChangeHandler<Boolean> updateDeleteHandler;
-    boolean canDelete;
-
-    BranchesTable() {
-      table.setWidth("");
-      table.setText(0, 2, AdminConstants.I.columnBranchName());
-      table.setText(0, 3, AdminConstants.I.columnBranchRevision());
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
-      fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
-
-      updateDeleteHandler =
-          new ValueChangeHandler<Boolean>() {
-            @Override
-            public void onValueChange(ValueChangeEvent<Boolean> event) {
-              updateDeleteButton();
-            }
-          };
-    }
-
-    Set<String> getCheckedRefs() {
-      Set<String> refs = new HashSet<>();
-      for (int row = 1; row < table.getRowCount(); row++) {
-        final BranchInfo k = getRowItem(row);
-        if (k != null
-            && table.getWidget(row, 1) instanceof CheckBox
-            && ((CheckBox) table.getWidget(row, 1)).getValue()) {
-          refs.add(k.ref());
-        }
-      }
-      return refs;
-    }
-
-    void setChecked(Set<String> refs) {
-      for (int row = 1; row < table.getRowCount(); row++) {
-        final BranchInfo k = getRowItem(row);
-        if (k != null && refs.contains(k.ref()) && table.getWidget(row, 1) instanceof CheckBox) {
-          ((CheckBox) table.getWidget(row, 1)).setValue(true);
-        }
-      }
-    }
-
-    void deleteChecked() {
-      final Set<String> refs = getCheckedRefs();
-
-      SafeHtmlBuilder b = new SafeHtmlBuilder();
-      b.openElement("b");
-      b.append(Gerrit.C.branchDeletionConfirmationMessage());
-      b.closeElement("b");
-
-      b.openElement("p");
-      boolean first = true;
-      for (String ref : refs) {
-        if (!first) {
-          b.append(",").br();
-        }
-        b.append(ref);
-        first = false;
-      }
-      b.closeElement("p");
-
-      if (refs.isEmpty()) {
-        updateDeleteButton();
-        return;
-      }
-
-      delBranch.setEnabled(false);
-      ConfirmationDialog confirmationDialog =
-          new ConfirmationDialog(
-              Gerrit.C.branchDeletionDialogTitle(),
-              b.toSafeHtml(),
-              new ConfirmationCallback() {
-                @Override
-                public void onOk() {
-                  deleteBranches(refs);
-                }
-
-                @Override
-                public void onCancel() {
-                  branchTable.updateDeleteButton();
-                }
-              });
-      confirmationDialog.center();
-    }
-
-    private void deleteBranches(Set<String> branches) {
-      ProjectApi.deleteBranches(
-          getProjectKey(),
-          branches,
-          new GerritCallback<VoidResult>() {
-            @Override
-            public void onSuccess(VoidResult result) {
-              query = new Query(match).start(start).run();
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              query = new Query(match).start(start).run();
-              super.onFailure(caught);
-            }
-          });
-    }
-
-    void display(List<BranchInfo> branches) {
-      displaySubset(branches, 0, branches.size());
-    }
-
-    void displaySubset(List<BranchInfo> branches, int fromIndex, int toIndex) {
-      canDelete = false;
-
-      while (1 < table.getRowCount()) {
-        table.removeRow(table.getRowCount() - 1);
-      }
-
-      for (BranchInfo k : branches.subList(fromIndex, toIndex)) {
-        final int row = table.getRowCount();
-        table.insertRow(row);
-        applyDataRowStyle(row);
-        populate(row, k);
-      }
-    }
-
-    void populate(int row, BranchInfo k) {
-      if (k.canDelete()) {
-        CheckBox sel = new CheckBox();
-        sel.addValueChangeHandler(updateDeleteHandler);
-        table.setWidget(row, 1, sel);
-        canDelete = true;
-      } else {
-        table.setText(row, 1, "");
-      }
-
-      table.setWidget(row, 2, new InlineHTML(highlight(k.getShortName(), match)));
-
-      if (k.revision() != null) {
-        if ("HEAD".equals(k.getShortName())) {
-          setHeadRevision(row, 3, k.revision());
-        } else {
-          table.setText(row, 3, k.revision());
-        }
-      } else {
-        table.setText(row, 3, "");
-      }
-
-      FlowPanel actionsPanel = new FlowPanel();
-      if (k.webLinks() != null) {
-        for (WebLinkInfo webLink : Natives.asList(k.webLinks())) {
-          actionsPanel.add(webLink.toAnchor());
-        }
-      }
-      if (k.actions() != null) {
-        k.actions().copyKeysIntoChildren("id");
-        for (ActionInfo a : Natives.asList(k.actions().values())) {
-          actionsPanel.add(new ActionButton(getProjectKey(), k, a));
-        }
-      }
-      table.setWidget(row, 4, actionsPanel);
-
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      String iconCellStyle = Gerrit.RESOURCES.css().iconCell();
-      String dataCellStyle = Gerrit.RESOURCES.css().dataCell();
-      if (RefNames.REFS_CONFIG.equals(k.getShortName()) || "HEAD".equals(k.getShortName())) {
-        iconCellStyle = Gerrit.RESOURCES.css().specialBranchIconCell();
-        dataCellStyle = Gerrit.RESOURCES.css().specialBranchDataCell();
-        fmt.setStyleName(row, 0, iconCellStyle);
-      }
-      fmt.addStyleName(row, 1, iconCellStyle);
-      fmt.addStyleName(row, 2, dataCellStyle);
-      fmt.addStyleName(row, 3, dataCellStyle);
-      fmt.addStyleName(row, 4, dataCellStyle);
-
-      setRowItem(row, k);
-    }
-
-    private void setHeadRevision(int row, int column, String rev) {
-      AccessMap.get(
-          getProjectKey(),
-          new GerritCallback<ProjectAccessInfo>() {
-            @Override
-            public void onSuccess(ProjectAccessInfo result) {
-              if (result.isOwner()) {
-                table.setWidget(row, column, getHeadRevisionWidget(rev));
-              } else {
-                table.setText(row, 3, rev);
-              }
-            }
-          });
-    }
-
-    private Widget getHeadRevisionWidget(String headRevision) {
-      FlowPanel p = new FlowPanel();
-      final InlineLabel l = new InlineLabel(headRevision);
-      final Image edit = new Image(Gerrit.RESOURCES.edit());
-      edit.addStyleName(Gerrit.RESOURCES.css().editHeadButton());
-
-      final NpTextBox input = new NpTextBox();
-      input.setVisibleLength(35);
-      input.setValue(headRevision);
-      input.setVisible(false);
-      final Button save = new Button();
-      save.setText(AdminConstants.I.saveHeadButton());
-      save.setVisible(false);
-      save.setEnabled(false);
-      final Button cancel = new Button();
-      cancel.setText(AdminConstants.I.cancelHeadButton());
-      cancel.setVisible(false);
-
-      OnEditEnabler e = new OnEditEnabler(save);
-      e.listenTo(input);
-
-      edit.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              l.setVisible(false);
-              edit.setVisible(false);
-              input.setVisible(true);
-              save.setVisible(true);
-              cancel.setVisible(true);
-            }
-          });
-      save.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              save.setEnabled(false);
-              ProjectApi.setHead(
-                  getProjectKey(),
-                  input.getValue().trim(),
-                  new GerritCallback<NativeString>() {
-                    @Override
-                    public void onSuccess(NativeString result) {
-                      Gerrit.display(PageLinks.toProjectBranches(getProjectKey()));
-                    }
-
-                    @Override
-                    public void onFailure(Throwable caught) {
-                      super.onFailure(caught);
-                      save.setEnabled(true);
-                    }
-                  });
-            }
-          });
-      cancel.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              l.setVisible(true);
-              edit.setVisible(true);
-              input.setVisible(false);
-              input.setValue(headRevision);
-              save.setVisible(false);
-              save.setEnabled(false);
-              cancel.setVisible(false);
-            }
-          });
-
-      p.add(l);
-      p.add(edit);
-      p.add(input);
-      p.add(save);
-      p.add(cancel);
-      return p;
-    }
-
-    boolean hasBranchCanDelete() {
-      return canDelete;
-    }
-
-    void updateDeleteButton() {
-      boolean on = false;
-      for (int row = 1; row < table.getRowCount(); row++) {
-        Widget w = table.getWidget(row, 1);
-        if (w != null && w instanceof CheckBox) {
-          CheckBox sel = (CheckBox) w;
-          if (sel.getValue()) {
-            on = true;
-            break;
-          }
-        }
-      }
-      delBranch.setEnabled(on);
-    }
-
-    @Override
-    protected void onOpenRow(int row) {
-      if (row > 0) {
-        movePointerTo(row);
-      }
-    }
-
-    @Override
-    protected Object getRowItemKey(BranchInfo item) {
-      return item.ref();
-    }
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    if (match != null) {
-      filterTxt.setCursorPos(match.length());
-    }
-    filterTxt.setFocus(true);
-  }
-
-  private class Query {
-    private String qMatch;
-    private int qStart;
-
-    Query(String match) {
-      this.qMatch = match;
-    }
-
-    Query start(int start) {
-      this.qStart = start;
-      return this;
-    }
-
-    Query run() {
-      // Retrieve one more branch than page size to determine if there are more
-      // branches to display
-      ProjectApi.getBranches(
-          getProjectKey(),
-          pageSize + 1,
-          qStart,
-          qMatch,
-          new ScreenLoadCallback<JsArray<BranchInfo>>(ProjectBranchesScreen.this) {
-            @Override
-            public void preDisplay(JsArray<BranchInfo> result) {
-              if (!isAttached()) {
-                // View has been disposed.
-              } else if (query == Query.this) {
-                query = null;
-                showList(result);
-              } else {
-                query.run();
-              }
-            }
-          });
-      return this;
-    }
-
-    void showList(JsArray<BranchInfo> result) {
-      setToken(getTokenForScreen(qMatch, qStart));
-      ProjectBranchesScreen.this.match = qMatch;
-      ProjectBranchesScreen.this.start = qStart;
-
-      if (result.length() <= pageSize) {
-        branchTable.display(Natives.asList(result));
-        next.setVisible(false);
-      } else {
-        branchTable.displaySubset(Natives.asList(result), 0, result.length() - 1);
-        setupNavigationLink(next, qMatch, qStart + pageSize);
-      }
-      if (qStart > 0) {
-        setupNavigationLink(prev, qMatch, qStart - pageSize);
-      } else {
-        prev.setVisible(false);
-      }
-
-      delBranch.setVisible(branchTable.hasBranchCanDelete());
-      Set<String> checkedRefs = branchTable.getCheckedRefs();
-      branchTable.setChecked(checkedRefs);
-      updateForm();
-
-      if (!isCurrentView()) {
-        display();
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java
deleted file mode 100644
index 7b5d04d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.admin;
-
-import com.google.gerrit.client.dashboards.DashboardList;
-import com.google.gerrit.client.dashboards.DashboardsTable;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.ui.FlowPanel;
-
-public class ProjectDashboardsScreen extends ProjectScreen {
-  private DashboardsTable dashes;
-  Project.NameKey project;
-
-  public ProjectDashboardsScreen(Project.NameKey project) {
-    super(project);
-    this.project = project;
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    DashboardList.all(
-        getProjectKey(),
-        new ScreenLoadCallback<JsArray<DashboardList>>(this) {
-          @Override
-          protected void preDisplay(JsArray<DashboardList> result) {
-            dashes.display(result);
-          }
-        });
-    savedPanel = DASHBOARDS;
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    dashes = new DashboardsTable(project);
-    FlowPanel fp = new FlowPanel();
-    fp.add(dashes);
-    add(fp);
-    dashes.setSavePointerId("dashboards/project/" + getProjectKey().get());
-    display();
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    dashes.setRegisterKeys(true);
-  }
-}
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
deleted file mode 100644
index 64e147d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ /dev/null
@@ -1,815 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gerrit.client.StringListPanel;
-import com.google.gerrit.client.access.AccessMap;
-import com.google.gerrit.client.access.ProjectAccessInfo;
-import com.google.gerrit.client.actions.ActionButton;
-import com.google.gerrit.client.api.ExtensionPanel;
-import com.google.gerrit.client.change.Resources;
-import com.google.gerrit.client.download.DownloadPanel;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.DownloadInfo.DownloadCommandInfo;
-import com.google.gerrit.client.info.DownloadInfo.DownloadSchemeInfo;
-import com.google.gerrit.client.projects.ConfigInfo;
-import com.google.gerrit.client.projects.ConfigInfo.ConfigParameterInfo;
-import com.google.gerrit.client.projects.ConfigInfo.ConfigParameterValue;
-import com.google.gerrit.client.projects.ConfigInfo.InheritedBooleanInfo;
-import com.google.gerrit.client.projects.ConfigInfo.SubmitTypeInfo;
-import com.google.gerrit.client.projects.ProjectApi;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.NpIntTextBox;
-import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.dom.client.ChangeEvent;
-import com.google.gwt.event.dom.client.ChangeHandler;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.FlexTable;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HasEnabled;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.Panel;
-import com.google.gwt.user.client.ui.TextBox;
-import com.google.gwt.user.client.ui.VerticalPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.NpTextArea;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-
-public class ProjectInfoScreen extends ProjectScreen {
-  private boolean isOwner;
-  private boolean configVisible;
-
-  private LabeledWidgetsGrid grid;
-  private Panel pluginOptionsPanel;
-  private LabeledWidgetsGrid actionsGrid;
-
-  // Section: Project Options
-  private ListBox requireChangeID;
-  private ListBox submitType;
-  private ListBox state;
-  private ListBox contentMerge;
-  private ListBox newChangeForAllNotInTarget;
-  private ListBox enableSignedPush;
-  private ListBox requireSignedPush;
-  private ListBox rejectImplicitMerges;
-  private ListBox privateByDefault;
-  private ListBox enableReviewerByEmail;
-  private ListBox matchAuthorToCommitterDate;
-  private NpTextBox maxObjectSizeLimit;
-  private Label effectiveMaxObjectSizeLimit;
-  private Map<String, Map<String, HasEnabled>> pluginConfigWidgets;
-
-  // Section: Contributor Agreements
-  private ListBox contributorAgreements;
-  private ListBox signedOffBy;
-
-  private NpTextArea descTxt;
-  private Button saveProject;
-
-  private OnEditEnabler saveEnabler;
-
-  public ProjectInfoScreen(Project.NameKey toShow) {
-    super(toShow);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    Resources.I.style().ensureInjected();
-    saveProject = new Button(AdminConstants.I.buttonSaveChanges());
-    saveProject.setStyleName("");
-    saveProject.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doSave();
-          }
-        });
-
-    ExtensionPanel extensionPanelTop =
-        new ExtensionPanel(GerritUiExtensionPoint.PROJECT_INFO_SCREEN_TOP);
-    extensionPanelTop.put(GerritUiExtensionPoint.Key.PROJECT_NAME, getProjectKey().get());
-    add(extensionPanelTop);
-
-    add(new ProjectDownloadPanel(getProjectKey().get(), true));
-
-    initDescription();
-    grid = new LabeledWidgetsGrid();
-    pluginOptionsPanel = new FlowPanel();
-    actionsGrid = new LabeledWidgetsGrid();
-    initProjectOptions();
-    initAgreements();
-    add(grid);
-    add(pluginOptionsPanel);
-    add(saveProject);
-    add(actionsGrid);
-
-    ExtensionPanel extensionPanelBottom =
-        new ExtensionPanel(GerritUiExtensionPoint.PROJECT_INFO_SCREEN_BOTTOM);
-    extensionPanelBottom.put(GerritUiExtensionPoint.Key.PROJECT_NAME, getProjectKey().get());
-    add(extensionPanelBottom);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-
-    Project.NameKey project = getProjectKey();
-    CallbackGroup cbg = new CallbackGroup();
-    AccessMap.get(
-        project,
-        cbg.add(
-            new GerritCallback<ProjectAccessInfo>() {
-              @Override
-              public void onSuccess(ProjectAccessInfo result) {
-                isOwner = result.isOwner();
-                configVisible = result.configVisible();
-                enableForm();
-                saveProject.setVisible(isOwner);
-              }
-            }));
-    ProjectApi.getConfig(
-        project,
-        cbg.addFinal(
-            new ScreenLoadCallback<ConfigInfo>(this) {
-              @Override
-              public void preDisplay(ConfigInfo result) {
-                display(result);
-              }
-            }));
-
-    savedPanel = INFO;
-  }
-
-  private void enableForm() {
-    enableForm(isOwner);
-  }
-
-  private void enableForm(boolean isOwner) {
-    state.setEnabled(isOwner);
-    submitType.setEnabled(isOwner);
-    setEnabledForUseContentMerge();
-    newChangeForAllNotInTarget.setEnabled(isOwner);
-    if (enableSignedPush != null) {
-      enableSignedPush.setEnabled(isOwner);
-    }
-    if (requireSignedPush != null) {
-      requireSignedPush.setEnabled(isOwner);
-    }
-    descTxt.setEnabled(isOwner);
-    contributorAgreements.setEnabled(isOwner);
-    signedOffBy.setEnabled(isOwner);
-    requireChangeID.setEnabled(isOwner);
-    rejectImplicitMerges.setEnabled(isOwner);
-    privateByDefault.setEnabled(isOwner);
-    maxObjectSizeLimit.setEnabled(isOwner);
-    enableReviewerByEmail.setEnabled(isOwner);
-    matchAuthorToCommitterDate.setEnabled(isOwner);
-
-    if (pluginConfigWidgets != null) {
-      for (Map<String, HasEnabled> widgetMap : pluginConfigWidgets.values()) {
-        for (HasEnabled widget : widgetMap.values()) {
-          widget.setEnabled(isOwner);
-        }
-      }
-    }
-  }
-
-  private void initDescription() {
-    final VerticalPanel vp = new VerticalPanel();
-    vp.add(new SmallHeading(AdminConstants.I.headingDescription()));
-
-    descTxt = new NpTextArea();
-    descTxt.setVisibleLines(6);
-    descTxt.setCharacterWidth(60);
-    vp.add(descTxt);
-
-    add(vp);
-    saveEnabler = new OnEditEnabler(saveProject);
-    saveEnabler.listenTo(descTxt);
-  }
-
-  private void initProjectOptions() {
-    grid.addHeader(new SmallHeading(AdminConstants.I.headingProjectOptions()));
-
-    state = new ListBox();
-    for (ProjectState stateValue : ProjectState.values()) {
-      state.addItem(Util.toLongString(stateValue), stateValue.name());
-    }
-    saveEnabler.listenTo(state);
-    grid.add(AdminConstants.I.headingProjectState(), state);
-
-    submitType = new ListBox();
-    for (SubmitType type : SubmitType.values()) {
-      submitType.addItem(Util.toLongString(type), type.name());
-    }
-    submitType.addChangeHandler(
-        new ChangeHandler() {
-          @Override
-          public void onChange(ChangeEvent event) {
-            setEnabledForUseContentMerge();
-          }
-        });
-    saveEnabler.listenTo(submitType);
-    grid.add(AdminConstants.I.headingProjectSubmitType(), submitType);
-
-    contentMerge = newInheritedBooleanBox();
-    saveEnabler.listenTo(contentMerge);
-    grid.add(AdminConstants.I.useContentMerge(), contentMerge);
-
-    newChangeForAllNotInTarget = newInheritedBooleanBox();
-    saveEnabler.listenTo(newChangeForAllNotInTarget);
-    grid.add(AdminConstants.I.createNewChangeForAllNotInTarget(), newChangeForAllNotInTarget);
-
-    requireChangeID = newInheritedBooleanBox();
-    saveEnabler.listenTo(requireChangeID);
-    grid.addHtml(AdminConstants.I.requireChangeID(), requireChangeID);
-
-    if (Gerrit.info().receive().enableSignedPush()) {
-      enableSignedPush = newInheritedBooleanBox();
-      saveEnabler.listenTo(enableSignedPush);
-      grid.add(AdminConstants.I.enableSignedPush(), enableSignedPush);
-      requireSignedPush = newInheritedBooleanBox();
-      saveEnabler.listenTo(requireSignedPush);
-      grid.add(AdminConstants.I.requireSignedPush(), requireSignedPush);
-    }
-
-    rejectImplicitMerges = newInheritedBooleanBox();
-    saveEnabler.listenTo(rejectImplicitMerges);
-    grid.addHtml(AdminConstants.I.rejectImplicitMerges(), rejectImplicitMerges);
-
-    privateByDefault = newInheritedBooleanBox();
-    saveEnabler.listenTo(privateByDefault);
-    grid.addHtml(AdminConstants.I.privateByDefault(), privateByDefault);
-
-    enableReviewerByEmail = newInheritedBooleanBox();
-    saveEnabler.listenTo(enableReviewerByEmail);
-    grid.addHtml(AdminConstants.I.enableReviewerByEmail(), enableReviewerByEmail);
-
-    matchAuthorToCommitterDate = newInheritedBooleanBox();
-    saveEnabler.listenTo(matchAuthorToCommitterDate);
-    grid.addHtml(AdminConstants.I.matchAuthorToCommitterDate(), matchAuthorToCommitterDate);
-
-    maxObjectSizeLimit = new NpTextBox();
-    saveEnabler.listenTo(maxObjectSizeLimit);
-    effectiveMaxObjectSizeLimit = new Label();
-    effectiveMaxObjectSizeLimit.setStyleName(
-        Gerrit.RESOURCES.css().maxObjectSizeLimitEffectiveLabel());
-    HorizontalPanel p = new HorizontalPanel();
-    p.add(maxObjectSizeLimit);
-    p.add(effectiveMaxObjectSizeLimit);
-    grid.addHtml(AdminConstants.I.headingMaxObjectSizeLimit(), p);
-  }
-
-  private static ListBox newInheritedBooleanBox() {
-    ListBox box = new ListBox();
-    for (InheritableBoolean b : InheritableBoolean.values()) {
-      box.addItem(b.name(), b.name());
-    }
-    return box;
-  }
-
-  /**
-   * Enables the {@link #contentMerge} checkbox if the selected submit type allows the usage of
-   * content merge. If the submit type (currently only 'Fast Forward Only') does not allow content
-   * merge the useContentMerge checkbox gets disabled.
-   */
-  private void setEnabledForUseContentMerge() {
-    if (SubmitType.FAST_FORWARD_ONLY.equals(
-        SubmitType.valueOf(submitType.getValue(submitType.getSelectedIndex())))) {
-      contentMerge.setEnabled(false);
-      InheritedBooleanInfo b = InheritedBooleanInfo.create();
-      b.setConfiguredValue(InheritableBoolean.FALSE);
-      setBool(contentMerge, b);
-    } else {
-      contentMerge.setEnabled(submitType.isEnabled());
-    }
-  }
-
-  private void initAgreements() {
-    grid.addHeader(new SmallHeading(AdminConstants.I.headingAgreements()));
-
-    contributorAgreements = newInheritedBooleanBox();
-    if (Gerrit.info().auth().useContributorAgreements()) {
-      saveEnabler.listenTo(contributorAgreements);
-      grid.add(AdminConstants.I.useContributorAgreements(), contributorAgreements);
-    }
-
-    signedOffBy = newInheritedBooleanBox();
-    saveEnabler.listenTo(signedOffBy);
-    grid.addHtml(AdminConstants.I.useSignedOffBy(), signedOffBy);
-  }
-
-  private void setSubmitType(SubmitTypeInfo newSubmitType) {
-    int index = -1;
-    if (newSubmitType != null) {
-      for (int i = 0; i < submitType.getItemCount(); i++) {
-        if (submitType.getValue(i).equals(SubmitType.INHERIT.name())) {
-          submitType.setItemText(i, getInheritString(newSubmitType));
-        }
-        if (newSubmitType.configuredValue().name().equals(submitType.getValue(i))) {
-          index = i;
-        }
-      }
-      submitType.setSelectedIndex(index);
-      setEnabledForUseContentMerge();
-    }
-  }
-
-  private static String getInheritString(SubmitTypeInfo submitType) {
-    return Util.toLongString(SubmitType.INHERIT)
-        + " ("
-        + Util.toLongString(submitType.inheritedValue())
-        + ")";
-  }
-
-  private void setState(ProjectState newState) {
-    if (state != null) {
-      for (int i = 0; i < state.getItemCount(); i++) {
-        if (newState.name().equals(state.getValue(i))) {
-          state.setSelectedIndex(i);
-          break;
-        }
-      }
-    }
-  }
-
-  private void setBool(ListBox box, InheritedBooleanInfo inheritedBoolean) {
-    if (box == null) {
-      return;
-    }
-    int inheritedIndex = -1;
-    for (int i = 0; i < box.getItemCount(); i++) {
-      if (box.getValue(i).startsWith(InheritableBoolean.INHERIT.name())) {
-        inheritedIndex = i;
-      }
-      if (box.getValue(i).startsWith(inheritedBoolean.configuredValue().name())) {
-        box.setSelectedIndex(i);
-      }
-    }
-    if (inheritedIndex >= 0) {
-      if (Gerrit.info().gerrit().isAllProjects(getProjectKey())) {
-        if (box.getSelectedIndex() == inheritedIndex) {
-          for (int i = 0; i < box.getItemCount(); i++) {
-            if (box.getValue(i).equals(InheritableBoolean.FALSE.name())) {
-              box.setSelectedIndex(i);
-              break;
-            }
-          }
-        }
-        box.removeItem(inheritedIndex);
-      } else {
-        box.setItemText(
-            inheritedIndex,
-            InheritableBoolean.INHERIT.name() + " (" + inheritedBoolean.inheritedValue() + ")");
-      }
-    }
-  }
-
-  private static InheritableBoolean getBool(ListBox box) {
-    int i = box.getSelectedIndex();
-    if (i >= 0) {
-      final String selectedValue = box.getValue(i);
-      if (selectedValue.startsWith(InheritableBoolean.INHERIT.name())) {
-        return InheritableBoolean.INHERIT;
-      }
-      return InheritableBoolean.valueOf(selectedValue);
-    }
-    return InheritableBoolean.INHERIT;
-  }
-
-  void display(ConfigInfo result) {
-    descTxt.setText(result.description());
-    setBool(contributorAgreements, result.useContributorAgreements());
-    setBool(signedOffBy, result.useSignedOffBy());
-    setBool(contentMerge, result.useContentMerge());
-    setBool(newChangeForAllNotInTarget, result.createNewChangeForAllNotInTarget());
-    setBool(requireChangeID, result.requireChangeId());
-    if (Gerrit.info().receive().enableSignedPush()) {
-      setBool(enableSignedPush, result.enableSignedPush());
-      setBool(requireSignedPush, result.requireSignedPush());
-    }
-    setBool(rejectImplicitMerges, result.rejectImplicitMerges());
-    setBool(privateByDefault, result.privateByDefault());
-    setBool(enableReviewerByEmail, result.enableReviewerByEmail());
-    setBool(matchAuthorToCommitterDate, result.matchAuthorToCommitterDate());
-    setSubmitType(result.defaultSubmitType());
-    setState(result.state());
-    maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
-    if (result.maxObjectSizeLimit().inheritedValue() != null) {
-      effectiveMaxObjectSizeLimit.setVisible(true);
-      effectiveMaxObjectSizeLimit.setText(
-          AdminMessages.I.effectiveMaxObjectSizeLimit(result.maxObjectSizeLimit().value()));
-      effectiveMaxObjectSizeLimit.setTitle(
-          AdminMessages.I.globalMaxObjectSizeLimit(result.maxObjectSizeLimit().inheritedValue()));
-    } else {
-      effectiveMaxObjectSizeLimit.setVisible(false);
-    }
-
-    saveProject.setEnabled(false);
-    initPluginOptions(result);
-    initProjectActions(result);
-  }
-
-  private void initPluginOptions(ConfigInfo info) {
-    pluginOptionsPanel.clear();
-    pluginConfigWidgets = new HashMap<>();
-
-    for (String pluginName : info.pluginConfig().keySet()) {
-      Map<String, HasEnabled> widgetMap = new HashMap<>();
-      pluginConfigWidgets.put(pluginName, widgetMap);
-      LabeledWidgetsGrid g = new LabeledWidgetsGrid();
-      g.addHeader(new SmallHeading(AdminMessages.I.pluginProjectOptionsTitle(pluginName)));
-      pluginOptionsPanel.add(g);
-      NativeMap<ConfigParameterInfo> pluginConfig = info.pluginConfig(pluginName);
-      pluginConfig.copyKeysIntoChildren("name");
-      for (ConfigParameterInfo param : Natives.asList(pluginConfig.values())) {
-        HasEnabled w;
-        switch (param.type()) {
-          case "STRING":
-          case "INT":
-          case "LONG":
-            w = renderTextBox(g, param);
-            break;
-          case "BOOLEAN":
-            w = renderCheckBox(g, param);
-            break;
-          case "LIST":
-            w = renderListBox(g, param);
-            break;
-          case "ARRAY":
-            w = renderStringListPanel(g, param);
-            break;
-          default:
-            throw new UnsupportedOperationException("unsupported widget type");
-        }
-        if (param.editable()) {
-          widgetMap.put(param.name(), w);
-        } else {
-          w.setEnabled(false);
-        }
-      }
-    }
-
-    enableForm();
-  }
-
-  private TextBox renderTextBox(LabeledWidgetsGrid g, ConfigParameterInfo param) {
-    NpTextBox textBox = param.type().equals("STRING") ? new NpTextBox() : new NpIntTextBox();
-    if (param.inheritable()) {
-      textBox.setValue(param.configuredValue());
-      Label inheritedLabel =
-          new Label(AdminMessages.I.pluginProjectInheritedValue(param.inheritedValue()));
-      inheritedLabel.setStyleName(Gerrit.RESOURCES.css().pluginProjectConfigInheritedValue());
-      HorizontalPanel p = new HorizontalPanel();
-      p.add(textBox);
-      p.add(inheritedLabel);
-      addWidget(g, p, param);
-    } else {
-      textBox.setValue(param.value());
-      addWidget(g, textBox, param);
-    }
-    saveEnabler.listenTo(textBox);
-    return textBox;
-  }
-
-  private CheckBox renderCheckBox(LabeledWidgetsGrid g, ConfigParameterInfo param) {
-    CheckBox checkBox = new CheckBox(getDisplayName(param));
-    checkBox.setValue(Boolean.parseBoolean(param.value()));
-    HorizontalPanel p = new HorizontalPanel();
-    p.add(checkBox);
-    if (param.description() != null) {
-      Image infoImg = new Image(Gerrit.RESOURCES.info());
-      infoImg.setTitle(param.description());
-      p.add(infoImg);
-    }
-    if (param.warning() != null) {
-      Image warningImg = new Image(Gerrit.RESOURCES.warning());
-      warningImg.setTitle(param.warning());
-      p.add(warningImg);
-    }
-    g.add((String) null, p);
-    saveEnabler.listenTo(checkBox);
-    return checkBox;
-  }
-
-  private ListBox renderListBox(LabeledWidgetsGrid g, ConfigParameterInfo param) {
-    if (param.permittedValues() == null) {
-      return null;
-    }
-    ListBox listBox = new ListBox();
-    if (param.inheritable()) {
-      listBox.addItem(AdminMessages.I.pluginProjectInheritedListValue(param.inheritedValue()));
-      if (param.configuredValue() == null) {
-        listBox.setSelectedIndex(0);
-      }
-      for (int i = 0; i < param.permittedValues().length(); i++) {
-        String pv = param.permittedValues().get(i);
-        listBox.addItem(pv);
-        if (pv.equals(param.configuredValue())) {
-          listBox.setSelectedIndex(i + 1);
-        }
-      }
-    } else {
-      for (int i = 0; i < param.permittedValues().length(); i++) {
-        String pv = param.permittedValues().get(i);
-        listBox.addItem(pv);
-        if (pv.equals(param.value())) {
-          listBox.setSelectedIndex(i);
-        }
-      }
-    }
-
-    if (param.editable()) {
-      saveEnabler.listenTo(listBox);
-      addWidget(g, listBox, param);
-    } else {
-      listBox.setEnabled(false);
-
-      if (param.inheritable() && listBox.getSelectedIndex() != 0) {
-        // the inherited value is not selected,
-        // since the listBox is disabled the inherited value cannot be
-        // seen and we have to display it explicitly
-        Label inheritedLabel =
-            new Label(AdminMessages.I.pluginProjectInheritedValue(param.inheritedValue()));
-        inheritedLabel.setStyleName(Gerrit.RESOURCES.css().pluginProjectConfigInheritedValue());
-        HorizontalPanel p = new HorizontalPanel();
-        p.add(listBox);
-        p.add(inheritedLabel);
-        addWidget(g, p, param);
-      } else {
-        addWidget(g, listBox, param);
-      }
-    }
-
-    return listBox;
-  }
-
-  private StringListPanel renderStringListPanel(LabeledWidgetsGrid g, ConfigParameterInfo param) {
-    StringListPanel p =
-        new StringListPanel(null, Arrays.asList(getDisplayName(param)), saveProject, false);
-    List<List<String>> values = new ArrayList<>();
-    for (String v : Natives.asList(param.values())) {
-      values.add(Arrays.asList(v));
-    }
-    p.display(values);
-    if (!param.editable()) {
-      p.setEnabled(false);
-    }
-    addWidget(g, p, param);
-    return p;
-  }
-
-  private void addWidget(LabeledWidgetsGrid g, Widget w, ConfigParameterInfo param) {
-    if (param.description() != null || param.warning() != null) {
-      HorizontalPanel p = new HorizontalPanel();
-      p.add(new Label(getDisplayName(param)));
-      if (param.description() != null) {
-        Image infoImg = new Image(Gerrit.RESOURCES.info());
-        infoImg.setTitle(param.description());
-        p.add(infoImg);
-      }
-      if (param.warning() != null) {
-        Image warningImg = new Image(Gerrit.RESOURCES.warning());
-        warningImg.setTitle(param.warning());
-        p.add(warningImg);
-      }
-      p.add(new Label(":"));
-      g.add(p, w);
-    } else {
-      g.add(getDisplayName(param), w);
-    }
-  }
-
-  private String getDisplayName(ConfigParameterInfo param) {
-    return param.displayName() != null ? param.displayName() : param.name();
-  }
-
-  private void initProjectActions(ConfigInfo info) {
-    actionsGrid.clear(true);
-    actionsGrid.removeAllRows();
-    boolean showCreateChange = Gerrit.isSignedIn();
-
-    NativeMap<ActionInfo> actions = info.actions();
-    if (actions == null) {
-      actions = NativeMap.create().cast();
-    }
-    if (actions.isEmpty() && !showCreateChange) {
-      return;
-    }
-    actions.copyKeysIntoChildren("id");
-    actionsGrid.addHeader(new SmallHeading(AdminConstants.I.headingProjectCommands()));
-    FlowPanel actionsPanel = new FlowPanel();
-    actionsPanel.setStyleName(Gerrit.RESOURCES.css().projectActions());
-    actionsPanel.setVisible(true);
-    actionsGrid.add(AdminConstants.I.headingCommands(), actionsPanel);
-
-    for (String id : actions.keySet()) {
-      actionsPanel.add(new ActionButton(getProjectKey(), actions.get(id)));
-    }
-
-    // TODO: The user should have create permission on the branch referred to by
-    // HEAD. This would have to happen on the server side.
-    if (showCreateChange) {
-      actionsPanel.add(createChangeAction());
-    }
-
-    if (isOwner && configVisible) {
-      actionsPanel.add(createEditConfigAction());
-    }
-  }
-
-  private Button createChangeAction() {
-    final Button createChange = new Button(AdminConstants.I.buttonCreateChange());
-    createChange.setStyleName("");
-    createChange.setTitle(AdminConstants.I.buttonCreateChangeDescription());
-    createChange.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            CreateChangeAction.call(createChange, getProjectKey().get());
-          }
-        });
-    return createChange;
-  }
-
-  private Button createEditConfigAction() {
-    final Button editConfig = new Button(AdminConstants.I.buttonEditConfig());
-    editConfig.setStyleName("");
-    editConfig.setTitle(AdminConstants.I.buttonEditConfigDescription());
-    editConfig.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            EditConfigAction.call(editConfig, getProjectKey());
-          }
-        });
-    return editConfig;
-  }
-
-  private void doSave() {
-    enableForm(false);
-    saveProject.setEnabled(false);
-    InheritableBoolean esp = enableSignedPush != null ? getBool(enableSignedPush) : null;
-    InheritableBoolean rsp = requireSignedPush != null ? getBool(requireSignedPush) : null;
-    ProjectApi.setConfig(
-        getProjectKey(),
-        descTxt.getText().trim(),
-        getBool(contributorAgreements),
-        getBool(contentMerge),
-        getBool(signedOffBy),
-        getBool(newChangeForAllNotInTarget),
-        getBool(requireChangeID),
-        esp,
-        rsp,
-        getBool(rejectImplicitMerges),
-        getBool(privateByDefault),
-        getBool(enableReviewerByEmail),
-        getBool(matchAuthorToCommitterDate),
-        maxObjectSizeLimit.getText().trim(),
-        SubmitType.valueOf(submitType.getValue(submitType.getSelectedIndex())),
-        ProjectState.valueOf(state.getValue(state.getSelectedIndex())),
-        getPluginConfigValues(),
-        new GerritCallback<ConfigInfo>() {
-          @Override
-          public void onSuccess(ConfigInfo result) {
-            enableForm();
-            display(result);
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            enableForm();
-            super.onFailure(caught);
-          }
-        });
-  }
-
-  private Map<String, Map<String, ConfigParameterValue>> getPluginConfigValues() {
-    Map<String, Map<String, ConfigParameterValue>> pluginConfigValues =
-        new HashMap<>(pluginConfigWidgets.size());
-    for (Entry<String, Map<String, HasEnabled>> e : pluginConfigWidgets.entrySet()) {
-      Map<String, ConfigParameterValue> values = new HashMap<>(e.getValue().size());
-      pluginConfigValues.put(e.getKey(), values);
-      for (Entry<String, HasEnabled> e2 : e.getValue().entrySet()) {
-        HasEnabled widget = e2.getValue();
-        if (widget instanceof TextBox) {
-          values.put(
-              e2.getKey(),
-              ConfigParameterValue.create().value(((TextBox) widget).getValue().trim()));
-        } else if (widget instanceof CheckBox) {
-          values.put(
-              e2.getKey(),
-              ConfigParameterValue.create()
-                  .value(Boolean.toString(((CheckBox) widget).getValue())));
-        } else if (widget instanceof ListBox) {
-          ListBox listBox = (ListBox) widget;
-          // the inherited value is at index 0,
-          // if it is selected no value should be set on this project
-          String value =
-              listBox.getSelectedIndex() > 0 ? listBox.getValue(listBox.getSelectedIndex()) : null;
-          values.put(e2.getKey(), ConfigParameterValue.create().value(value));
-        } else if (widget instanceof StringListPanel) {
-          values.put(
-              e2.getKey(),
-              ConfigParameterValue.create()
-                  .values(((StringListPanel) widget).getValues(0).toArray(new String[] {})));
-        } else {
-          throw new UnsupportedOperationException("unsupported widget type");
-        }
-      }
-    }
-    return pluginConfigValues;
-  }
-
-  public static class ProjectDownloadPanel extends DownloadPanel {
-    public ProjectDownloadPanel(String project, boolean isAllowsAnonymous) {
-      super(project, isAllowsAnonymous);
-    }
-
-    @Override
-    protected List<DownloadCommandInfo> getCommands(DownloadSchemeInfo schemeInfo) {
-      return schemeInfo.cloneCommands(project);
-    }
-  }
-
-  private static class LabeledWidgetsGrid extends FlexTable {
-    private String labelSuffix;
-
-    LabeledWidgetsGrid() {
-      super();
-      labelSuffix = ":";
-    }
-
-    private void addHeader(Widget widget) {
-      int row = getRowCount();
-      insertRow(row);
-      setWidget(row, 0, widget);
-      getCellFormatter().getElement(row, 0).setAttribute("colSpan", "2");
-    }
-
-    private void add(String label, boolean labelIsHtml, Widget widget) {
-      int row = getRowCount();
-      insertRow(row);
-      if (label != null) {
-        if (labelIsHtml) {
-          setHTML(row, 0, label + labelSuffix);
-        } else {
-          setText(row, 0, label + labelSuffix);
-        }
-      }
-      setWidget(row, 1, widget);
-    }
-
-    public void add(String label, Widget widget) {
-      add(label, false, widget);
-    }
-
-    public void addHtml(String label, Widget widget) {
-      add(label, true, widget);
-    }
-
-    public void add(Widget label, Widget widget) {
-      int row = getRowCount();
-      insertRow(row);
-      setWidget(row, 0, label);
-      setWidget(row, 1, widget);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java
deleted file mode 100644
index 2a03136..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java
+++ /dev/null
@@ -1,259 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import static com.google.gerrit.common.PageLinks.ADMIN_PROJECTS;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.projects.ProjectInfo;
-import com.google.gerrit.client.projects.ProjectMap;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.HighlightingInlineHyperlink;
-import com.google.gerrit.client.ui.Hyperlink;
-import com.google.gerrit.client.ui.PagingHyperlink;
-import com.google.gerrit.client.ui.ProjectSearchLink;
-import com.google.gerrit.client.ui.ProjectsTable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyUpEvent;
-import com.google.gwt.event.dom.client.KeyUpHandler;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-import java.util.List;
-
-public class ProjectListScreen extends PaginatedProjectScreen {
-  private Hyperlink prev;
-  private Hyperlink next;
-  private ProjectsTable projects;
-  private NpTextBox filterTxt;
-
-  private Query query;
-
-  public ProjectListScreen() {
-    super(null);
-  }
-
-  public ProjectListScreen(String params) {
-    this();
-    parseToken(params);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    query = new Query(match).start(start).run();
-  }
-
-  @Override
-  public String getScreenToken() {
-    return ADMIN_PROJECTS;
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setPageTitle(AdminConstants.I.projectListTitle());
-    initPageHeader();
-
-    prev = PagingHyperlink.createPrev();
-    prev.setVisible(false);
-
-    next = PagingHyperlink.createNext();
-    next.setVisible(false);
-
-    projects =
-        new ProjectsTable() {
-          @Override
-          protected void initColumnHeaders() {
-            super.initColumnHeaders();
-            table.setText(0, ProjectsTable.C_REPO_BROWSER, AdminConstants.I.projectRepoBrowser());
-            table
-                .getFlexCellFormatter()
-                .addStyleName(0, ProjectsTable.C_REPO_BROWSER, Gerrit.RESOURCES.css().dataHeader());
-          }
-
-          @Override
-          protected void onOpenRow(int row) {
-            History.newItem(link(getRowItem(row)));
-          }
-
-          private String link(ProjectInfo item) {
-            return Dispatcher.toProject(item.name_key());
-          }
-
-          @Override
-          protected void insert(int row, ProjectInfo k) {
-            super.insert(row, k);
-            table
-                .getFlexCellFormatter()
-                .addStyleName(row, ProjectsTable.C_REPO_BROWSER, Gerrit.RESOURCES.css().dataCell());
-          }
-
-          @Override
-          protected void populate(int row, ProjectInfo k) {
-            populateState(row, k);
-            FlowPanel fp = new FlowPanel();
-            fp.add(new ProjectSearchLink(k.name_key()));
-            fp.add(new HighlightingInlineHyperlink(k.name(), link(k), match));
-            table.setWidget(row, ProjectsTable.C_NAME, fp);
-            table.setText(row, ProjectsTable.C_DESCRIPTION, k.description());
-            addWebLinks(row, k);
-
-            setRowItem(row, k);
-          }
-
-          private void addWebLinks(int row, ProjectInfo k) {
-            List<WebLinkInfo> webLinks = Natives.asList(k.webLinks());
-            if (webLinks != null && !webLinks.isEmpty()) {
-              FlowPanel p = new FlowPanel();
-              table.setWidget(row, ProjectsTable.C_REPO_BROWSER, p);
-              for (WebLinkInfo weblink : webLinks) {
-                p.add(weblink.toAnchor());
-              }
-            }
-          }
-        };
-    projects.setSavePointerId(PageLinks.ADMIN_PROJECTS);
-
-    add(projects);
-    final HorizontalPanel buttons = new HorizontalPanel();
-    buttons.setStyleName(Gerrit.RESOURCES.css().changeTablePrevNextLinks());
-    buttons.add(prev);
-    buttons.add(next);
-    add(buttons);
-  }
-
-  private void initPageHeader() {
-    final HorizontalPanel hp = new HorizontalPanel();
-    hp.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
-    final Label filterLabel = new Label(AdminConstants.I.projectFilter());
-    filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
-    hp.add(filterLabel);
-    filterTxt = new NpTextBox();
-    filterTxt.setValue(match);
-    filterTxt.addKeyUpHandler(
-        new KeyUpHandler() {
-          @Override
-          public void onKeyUp(KeyUpEvent event) {
-            Query q =
-                new Query(filterTxt.getValue())
-                    .open(event.getNativeKeyCode() == KeyCodes.KEY_ENTER);
-            if (match.equals(q.qMatch)) {
-              q.start(start);
-            }
-            if (q.open || !match.equals(q.qMatch)) {
-              if (query == null) {
-                q.run();
-              }
-              query = q;
-            }
-          }
-        });
-    hp.add(filterTxt);
-    add(hp);
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    if (match != null) {
-      filterTxt.setCursorPos(match.length());
-    }
-    filterTxt.setFocus(true);
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    projects.setRegisterKeys(true);
-  }
-
-  private class Query {
-    private final String qMatch;
-    private int qStart;
-    private boolean open;
-
-    Query(String match) {
-      this.qMatch = match;
-    }
-
-    Query start(int start) {
-      this.qStart = start;
-      return this;
-    }
-
-    Query open(boolean open) {
-      this.open = open;
-      return this;
-    }
-
-    Query run() {
-      int limit = open ? 1 : pageSize + 1;
-      ProjectMap.match(
-          qMatch,
-          limit,
-          qStart,
-          new GerritCallback<ProjectMap>() {
-            @Override
-            public void onSuccess(ProjectMap result) {
-              if (!isAttached()) {
-                // View has been disposed.
-              } else if (query == Query.this) {
-                query = null;
-                showMap(result);
-              } else {
-                query.run();
-              }
-            }
-          });
-      return this;
-    }
-
-    private void showMap(ProjectMap result) {
-      if (open && !result.isEmpty()) {
-        Gerrit.display(PageLinks.toProject(result.values().get(0).name_key()));
-        return;
-      }
-
-      setToken(getTokenForScreen(qMatch, qStart));
-      ProjectListScreen.this.match = qMatch;
-      ProjectListScreen.this.start = qStart;
-
-      if (result.size() <= pageSize) {
-        projects.display(result);
-        next.setVisible(false);
-      } else {
-        projects.displaySubset(result, 0, result.size() - 1);
-        setupNavigationLink(next, qMatch, qStart + pageSize);
-      }
-
-      if (qStart > 0) {
-        setupNavigationLink(prev, qMatch, qStart - pageSize);
-      } else {
-        prev.setVisible(false);
-      }
-
-      if (!isCurrentView()) {
-        display();
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java
deleted file mode 100644
index dc964b8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.reviewdb.client.Project;
-
-public abstract class ProjectScreen extends Screen {
-  public static final String INFO = "info";
-  public static final String BRANCHES = "branches";
-  public static final String ACCESS = "access";
-  public static final String DASHBOARDS = "dashboards";
-  public static final String TAGS = "tags";
-
-  protected static String savedPanel;
-  protected static Project.NameKey savedKey;
-
-  public static String getSavedPanel() {
-    return savedPanel;
-  }
-
-  public static Project.NameKey getSavedKey() {
-    return savedKey;
-  }
-
-  private final Project.NameKey name;
-
-  public ProjectScreen(Project.NameKey toShow) {
-    name = toShow;
-  }
-
-  public Project.NameKey getProjectKey() {
-    return name;
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    if (name != null) {
-      setPageTitle(AdminMessages.I.project(name.get()));
-    }
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    savedKey = name;
-  }
-}
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
deleted file mode 100644
index 18e4176..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
+++ /dev/null
@@ -1,571 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.admin;
-
-import static com.google.gerrit.client.ui.Util.highlight;
-
-import com.google.gerrit.client.ConfirmationCallback;
-import com.google.gerrit.client.ConfirmationDialog;
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.access.AccessMap;
-import com.google.gerrit.client.access.ProjectAccessInfo;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.projects.ProjectApi;
-import com.google.gerrit.client.projects.TagInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.HintTextBox;
-import com.google.gerrit.client.ui.Hyperlink;
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gerrit.client.ui.PagingHyperlink;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.dom.client.KeyUpEvent;
-import com.google.gwt.event.dom.client.KeyUpHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-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 com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.InlineHTML;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.TextBox;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-public class ProjectTagsScreen extends PaginatedProjectScreen {
-  private Hyperlink prev;
-  private Hyperlink next;
-  private TagsTable tagTable;
-  private Button delTag;
-  private Button addTag;
-  private HintTextBox nameTxtBox;
-  private HintTextBox irevTxtBox;
-  private HintTextBox annotationTxtBox;
-  private FlowPanel addPanel;
-  private NpTextBox filterTxt;
-  private Query query;
-
-  public ProjectTagsScreen(Project.NameKey toShow) {
-    super(toShow);
-  }
-
-  @Override
-  public String getScreenToken() {
-    return PageLinks.toProjectTags(getProjectKey());
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    addPanel.setVisible(false);
-    AccessMap.get(
-        getProjectKey(),
-        new GerritCallback<ProjectAccessInfo>() {
-          @Override
-          public void onSuccess(ProjectAccessInfo result) {
-            addPanel.setVisible(result.canAddRefs());
-          }
-        });
-    query = new Query(match).start(start).run();
-    savedPanel = TAGS;
-  }
-
-  private void updateForm() {
-    tagTable.updateDeleteButton();
-    addTag.setEnabled(true);
-    nameTxtBox.setEnabled(true);
-    irevTxtBox.setEnabled(true);
-    annotationTxtBox.setEnabled(true);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    initPageHeader();
-
-    prev = PagingHyperlink.createPrev();
-    prev.setVisible(false);
-
-    next = PagingHyperlink.createNext();
-    next.setVisible(false);
-
-    addPanel = new FlowPanel();
-
-    Grid addGrid = new Grid(3, 2);
-    addGrid.setStyleName(Gerrit.RESOURCES.css().addBranch());
-    int texBoxLength = 50;
-
-    KeyPressHandler onKeyPress =
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-              doAddNewTag();
-            }
-          }
-        };
-
-    nameTxtBox = new HintTextBox();
-    nameTxtBox.setVisibleLength(texBoxLength);
-    nameTxtBox.setHintText(AdminConstants.I.defaultTagName());
-    nameTxtBox.addKeyPressHandler(onKeyPress);
-    addGrid.setText(0, 0, AdminConstants.I.columnTagName() + ":");
-    addGrid.setWidget(0, 1, nameTxtBox);
-
-    irevTxtBox = new HintTextBox();
-    irevTxtBox.setVisibleLength(texBoxLength);
-    irevTxtBox.setHintText(AdminConstants.I.defaultRevisionSpec());
-    irevTxtBox.addKeyPressHandler(onKeyPress);
-    addGrid.setText(1, 0, AdminConstants.I.revision() + ":");
-    addGrid.setWidget(1, 1, irevTxtBox);
-
-    annotationTxtBox = new HintTextBox();
-    annotationTxtBox.setVisibleLength(texBoxLength);
-    annotationTxtBox.setHintText(AdminConstants.I.annotation());
-    annotationTxtBox.addKeyPressHandler(onKeyPress);
-    addGrid.setText(2, 0, AdminConstants.I.columnTagAnnotation() + ":");
-    addGrid.setWidget(2, 1, annotationTxtBox);
-
-    addTag = new Button(AdminConstants.I.buttonAddTag());
-    addTag.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            doAddNewTag();
-          }
-        });
-    addPanel.add(addGrid);
-    addPanel.add(addTag);
-
-    tagTable = new TagsTable();
-
-    delTag = new Button(AdminConstants.I.buttonDeleteTag());
-    delTag.setStyleName(Gerrit.RESOURCES.css().branchTableDeleteButton());
-    delTag.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            tagTable.deleteChecked();
-          }
-        });
-
-    HorizontalPanel buttons = new HorizontalPanel();
-    buttons.setStyleName(Gerrit.RESOURCES.css().branchTablePrevNextLinks());
-    buttons.add(delTag);
-    buttons.add(prev);
-    buttons.add(next);
-    add(tagTable);
-    add(buttons);
-    add(addPanel);
-  }
-
-  private void initPageHeader() {
-    parseToken();
-    HorizontalPanel hp = new HorizontalPanel();
-    hp.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
-    Label filterLabel = new Label(AdminConstants.I.projectFilter());
-    filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
-    hp.add(filterLabel);
-    filterTxt = new NpTextBox();
-    filterTxt.setValue(match);
-    filterTxt.addKeyUpHandler(
-        new KeyUpHandler() {
-          @Override
-          public void onKeyUp(KeyUpEvent event) {
-            Query q = new Query(filterTxt.getValue());
-            if (match.equals(q.qMatch)) {
-              q.start(start);
-            } else {
-              if (query == null) {
-                q.run();
-              }
-              query = q;
-            }
-          }
-        });
-    hp.add(filterTxt);
-    add(hp);
-  }
-
-  private void doAddNewTag() {
-    String tagName = nameTxtBox.getText().trim();
-    if (tagName.isEmpty()) {
-      nameTxtBox.setFocus(true);
-      return;
-    }
-
-    String rev = irevTxtBox.getText().trim();
-    if (rev.isEmpty()) {
-      irevTxtBox.setText("HEAD");
-      Scheduler.get()
-          .scheduleDeferred(
-              new ScheduledCommand() {
-                @Override
-                public void execute() {
-                  irevTxtBox.selectAll();
-                  irevTxtBox.setFocus(true);
-                }
-              });
-      return;
-    }
-
-    String annotation = annotationTxtBox.getText().trim();
-    if (annotation.isEmpty()) {
-      annotation = null;
-    }
-
-    addTag.setEnabled(false);
-    ProjectApi.createTag(
-        getProjectKey(),
-        tagName,
-        rev,
-        annotation,
-        new GerritCallback<TagInfo>() {
-          @Override
-          public void onSuccess(TagInfo tag) {
-            showAddedTag(tag);
-            nameTxtBox.setText("");
-            irevTxtBox.setText("");
-            annotationTxtBox.setText("");
-            query = new Query(match).start(start).run();
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            addTag.setEnabled(true);
-            selectAllAndFocus(nameTxtBox);
-            new ErrorDialog(caught.getMessage()).center();
-          }
-        });
-  }
-
-  void showAddedTag(TagInfo tag) {
-    SafeHtmlBuilder b = new SafeHtmlBuilder();
-    b.openElement("b");
-    b.append(Gerrit.C.tagCreationConfirmationMessage());
-    b.closeElement("b");
-
-    b.openElement("p");
-    b.append(tag.ref());
-    b.closeElement("p");
-
-    ConfirmationDialog confirmationDialog =
-        new ConfirmationDialog(
-            Gerrit.C.tagCreationDialogTitle(),
-            b.toSafeHtml(),
-            new ConfirmationCallback() {
-              @Override
-              public void onOk() {
-                // do nothing
-              }
-            });
-    confirmationDialog.center();
-    confirmationDialog.setCancelVisible(false);
-  }
-
-  private static void selectAllAndFocus(TextBox textBox) {
-    textBox.selectAll();
-    textBox.setFocus(true);
-  }
-
-  private class TagsTable extends NavigationTable<TagInfo> {
-    private ValueChangeHandler<Boolean> updateDeleteHandler;
-    boolean canDelete;
-
-    TagsTable() {
-      table.setWidth("");
-      table.setText(0, 2, AdminConstants.I.columnTagName());
-      table.setText(0, 3, AdminConstants.I.columnTagRevision());
-
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
-      fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-      fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
-
-      updateDeleteHandler =
-          new ValueChangeHandler<Boolean>() {
-            @Override
-            public void onValueChange(ValueChangeEvent<Boolean> event) {
-              updateDeleteButton();
-            }
-          };
-    }
-
-    Set<String> getCheckedRefs() {
-      Set<String> refs = new HashSet<>();
-      for (int row = 1; row < table.getRowCount(); row++) {
-        TagInfo k = getRowItem(row);
-        if (k != null
-            && table.getWidget(row, 1) instanceof CheckBox
-            && ((CheckBox) table.getWidget(row, 1)).getValue()) {
-          refs.add(k.ref());
-        }
-      }
-      return refs;
-    }
-
-    void setChecked(Set<String> refs) {
-      for (int row = 1; row < table.getRowCount(); row++) {
-        TagInfo k = getRowItem(row);
-        if (k != null && refs.contains(k.ref()) && table.getWidget(row, 1) instanceof CheckBox) {
-          ((CheckBox) table.getWidget(row, 1)).setValue(true);
-        }
-      }
-    }
-
-    void deleteChecked() {
-      final Set<String> refs = getCheckedRefs();
-
-      SafeHtmlBuilder b = new SafeHtmlBuilder();
-      b.openElement("b");
-      b.append(Gerrit.C.tagDeletionConfirmationMessage());
-      b.closeElement("b");
-
-      b.openElement("p");
-      boolean first = true;
-      for (String ref : refs) {
-        if (!first) {
-          b.append(",").br();
-        }
-        b.append(ref);
-        first = false;
-      }
-      b.closeElement("p");
-
-      if (refs.isEmpty()) {
-        updateDeleteButton();
-        return;
-      }
-
-      delTag.setEnabled(false);
-      ConfirmationDialog confirmationDialog =
-          new ConfirmationDialog(
-              Gerrit.C.tagDeletionDialogTitle(),
-              b.toSafeHtml(),
-              new ConfirmationCallback() {
-                @Override
-                public void onOk() {
-                  deleteTags(refs);
-                }
-
-                @Override
-                public void onCancel() {
-                  tagTable.updateDeleteButton();
-                }
-              });
-      confirmationDialog.center();
-    }
-
-    private void deleteTags(Set<String> tags) {
-      ProjectApi.deleteTags(
-          getProjectKey(),
-          tags,
-          new GerritCallback<VoidResult>() {
-            @Override
-            public void onSuccess(VoidResult result) {
-              query = new Query(match).start(start).run();
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              query = new Query(match).start(start).run();
-              super.onFailure(caught);
-            }
-          });
-    }
-
-    void display(List<TagInfo> tags) {
-      displaySubset(tags, 0, tags.size());
-    }
-
-    void displaySubset(List<TagInfo> tags, int fromIndex, int toIndex) {
-      canDelete = false;
-
-      while (1 < table.getRowCount()) {
-        table.removeRow(table.getRowCount() - 1);
-      }
-
-      for (TagInfo k : tags.subList(fromIndex, toIndex)) {
-        int row = table.getRowCount();
-        table.insertRow(row);
-        applyDataRowStyle(row);
-        populate(row, k);
-      }
-    }
-
-    void populate(int row, TagInfo k) {
-      if (k.canDelete()) {
-        CheckBox sel = new CheckBox();
-        sel.addValueChangeHandler(updateDeleteHandler);
-        table.setWidget(row, 1, sel);
-        canDelete = true;
-      } else {
-        table.setText(row, 1, "");
-      }
-
-      table.setWidget(row, 2, new InlineHTML(highlight(k.getShortName(), match)));
-
-      if (k.revision() != null) {
-        table.setText(row, 3, k.revision());
-      } else {
-        table.setText(row, 3, "");
-      }
-
-      FlowPanel actionsPanel = new FlowPanel();
-      if (k.webLinks() != null) {
-        for (WebLinkInfo webLink : Natives.asList(k.webLinks())) {
-          actionsPanel.add(webLink.toAnchor());
-        }
-      }
-      table.setWidget(row, 4, actionsPanel);
-
-      FlexCellFormatter fmt = table.getFlexCellFormatter();
-      String iconCellStyle = Gerrit.RESOURCES.css().iconCell();
-      String dataCellStyle = Gerrit.RESOURCES.css().dataCell();
-      fmt.addStyleName(row, 1, iconCellStyle);
-      fmt.addStyleName(row, 2, dataCellStyle);
-      fmt.addStyleName(row, 3, dataCellStyle);
-      fmt.addStyleName(row, 4, dataCellStyle);
-
-      setRowItem(row, k);
-    }
-
-    boolean hasTagCanDelete() {
-      return canDelete;
-    }
-
-    void updateDeleteButton() {
-      boolean on = false;
-      for (int row = 1; row < table.getRowCount(); row++) {
-        Widget w = table.getWidget(row, 1);
-        if (w != null && w instanceof CheckBox) {
-          CheckBox sel = (CheckBox) w;
-          if (sel.getValue()) {
-            on = true;
-            break;
-          }
-        }
-      }
-      delTag.setEnabled(on);
-    }
-
-    @Override
-    protected void onOpenRow(int row) {
-      if (row > 0) {
-        movePointerTo(row);
-      }
-    }
-
-    @Override
-    protected Object getRowItemKey(TagInfo item) {
-      return item.ref();
-    }
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    if (match != null) {
-      filterTxt.setCursorPos(match.length());
-    }
-    filterTxt.setFocus(true);
-  }
-
-  private class Query {
-    private String qMatch;
-    private int qStart;
-
-    Query(String match) {
-      this.qMatch = match;
-    }
-
-    Query start(int start) {
-      this.qStart = start;
-      return this;
-    }
-
-    Query run() {
-      // Retrieve one more tag than page size to determine if there are more
-      // tags to display
-      ProjectApi.getTags(
-          getProjectKey(),
-          pageSize + 1,
-          qStart,
-          qMatch,
-          new ScreenLoadCallback<JsArray<TagInfo>>(ProjectTagsScreen.this) {
-            @Override
-            public void preDisplay(JsArray<TagInfo> result) {
-              if (!isAttached()) {
-                // View has been disposed.
-              } else if (query == Query.this) {
-                query = null;
-                showList(result);
-              } else {
-                query.run();
-              }
-            }
-          });
-      return this;
-    }
-
-    void showList(JsArray<TagInfo> result) {
-      setToken(getTokenForScreen(qMatch, qStart));
-      ProjectTagsScreen.this.match = qMatch;
-      ProjectTagsScreen.this.start = qStart;
-
-      if (result.length() <= pageSize) {
-        tagTable.display(Natives.asList(result));
-        next.setVisible(false);
-      } else {
-        tagTable.displaySubset(Natives.asList(result), 0, result.length() - 1);
-        setupNavigationLink(next, qMatch, qStart + pageSize);
-      }
-      if (qStart > 0) {
-        setupNavigationLink(prev, qMatch, qStart - pageSize);
-      } else {
-        prev.setVisible(false);
-      }
-
-      delTag.setVisible(tagTable.hasTagCanDelete());
-      Set<String> checkedRefs = tagTable.getCheckedRefs();
-      tagTable.setChecked(checkedRefs);
-      updateForm();
-
-      if (!isCurrentView()) {
-        display();
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java
deleted file mode 100644
index 063a60c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gwt.editor.client.IsEditor;
-import com.google.gwt.editor.client.adapters.TakesValueEditor;
-import com.google.gwt.text.shared.Renderer;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.IntegerBox;
-import com.google.gwt.user.client.ui.ValueBoxBase.TextAlignment;
-import com.google.gwt.user.client.ui.ValueListBox;
-import java.io.IOException;
-
-abstract class RangeBox extends Composite implements IsEditor<TakesValueEditor<Integer>> {
-  static final RangeRenderer rangeRenderer = new RangeRenderer();
-
-  private static class RangeRenderer implements Renderer<Integer> {
-    @Override
-    public String render(Integer object) {
-      if (0 <= object) {
-        return "+" + object;
-      }
-      return String.valueOf(object);
-    }
-
-    @Override
-    public void render(Integer object, Appendable appendable) throws IOException {
-      appendable.append(render(object));
-    }
-  }
-
-  static class List extends RangeBox {
-    final ValueListBox<Integer> list;
-
-    List() {
-      list = new ValueListBox<>(rangeRenderer);
-      initWidget(list);
-    }
-
-    @Override
-    void setEnabled(boolean on) {
-      list.getElement().setPropertyBoolean("disabled", !on);
-    }
-
-    @Override
-    public TakesValueEditor<Integer> asEditor() {
-      return list.asEditor();
-    }
-  }
-
-  static class Box extends RangeBox {
-    private final IntegerBox box;
-
-    Box() {
-      box = new IntegerBox();
-      box.setVisibleLength(10);
-      box.setAlignment(TextAlignment.RIGHT);
-      initWidget(box);
-    }
-
-    @Override
-    void setEnabled(boolean on) {
-      box.getElement().setPropertyBoolean("disabled", !on);
-    }
-
-    @Override
-    public TakesValueEditor<Integer> asEditor() {
-      return box.asEditor();
-    }
-  }
-
-  abstract void setEnabled(boolean on);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RefPatternBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RefPatternBox.java
deleted file mode 100644
index f1180cc..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RefPatternBox.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gwt.dom.client.Document;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.text.shared.Parser;
-import com.google.gwt.text.shared.Renderer;
-import com.google.gwt.user.client.ui.ValueBox;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import java.io.IOException;
-import java.text.ParseException;
-
-public class RefPatternBox extends ValueBox<String> {
-  private static final Renderer<String> RENDERER =
-      new Renderer<String>() {
-        @Override
-        public String render(String ref) {
-          return ref;
-        }
-
-        @Override
-        public void render(String ref, Appendable dst) throws IOException {
-          dst.append(render(ref));
-        }
-      };
-
-  private static final Parser<String> PARSER =
-      new Parser<String>() {
-        @Override
-        public String parse(CharSequence text) throws ParseException {
-          String ref = text.toString();
-
-          if (ref.isEmpty()) {
-            throw new ParseException(AdminConstants.I.refErrorEmpty(), 0);
-          }
-
-          if (ref.charAt(0) == '/') {
-            throw new ParseException(AdminConstants.I.refErrorBeginSlash(), 0);
-          }
-
-          if (ref.charAt(0) == '^') {
-            if (!ref.startsWith("^refs/")) {
-              ref = "^refs/heads/" + ref.substring(1);
-            }
-          } else if (!ref.startsWith("refs/")) {
-            ref = "refs/heads/" + ref;
-          }
-
-          for (int i = 0; i < ref.length(); i++) {
-            final char c = ref.charAt(i);
-
-            if (c == '/' && 0 < i && ref.charAt(i - 1) == '/') {
-              throw new ParseException(AdminConstants.I.refErrorDoubleSlash(), i);
-            }
-
-            if (c == ' ') {
-              throw new ParseException(AdminConstants.I.refErrorNoSpace(), i);
-            }
-
-            if (c < ' ') {
-              throw new ParseException(AdminConstants.I.refErrorPrintable(), i);
-            }
-          }
-          return ref;
-        }
-      };
-
-  public RefPatternBox() {
-    super(Document.get().createTextInputElement(), RENDERER, PARSER);
-    addKeyPressHandler(GlobalKey.STOP_PROPAGATION);
-    addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getCharCode() == ' ') {
-              event.preventDefault();
-            }
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
deleted file mode 100644
index bbc8a1d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gerrit.common.data.ProjectAdminService;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gwt.core.client.GWT;
-import com.google.gwtjsonrpc.client.JsonUtil;
-
-public class Util {
-  public static final ProjectAdminService PROJECT_SVC;
-
-  static {
-    PROJECT_SVC = GWT.create(ProjectAdminService.class);
-    JsonUtil.bind(PROJECT_SVC, "rpc/ProjectAdminService");
-
-    AdminResources.I.css().ensureInjected();
-  }
-
-  public static String toLongString(SubmitType type) {
-    if (type == null) {
-      return "";
-    }
-    switch (type) {
-      case INHERIT:
-        return AdminConstants.I.projectSubmitType_INHERIT();
-      case FAST_FORWARD_ONLY:
-        return AdminConstants.I.projectSubmitType_FAST_FORWARD_ONLY();
-      case MERGE_IF_NECESSARY:
-        return AdminConstants.I.projectSubmitType_MERGE_IF_NECESSARY();
-      case REBASE_IF_NECESSARY:
-        return AdminConstants.I.projectSubmitType_REBASE_IF_NECESSARY();
-      case REBASE_ALWAYS:
-        return AdminConstants.I.projectSubmitType_REBASE_ALWAYS();
-      case MERGE_ALWAYS:
-        return AdminConstants.I.projectSubmitType_MERGE_ALWAYS();
-      case CHERRY_PICK:
-        return AdminConstants.I.projectSubmitType_CHERRY_PICK();
-      default:
-        return type.name();
-    }
-  }
-
-  public static String toLongString(ProjectState type) {
-    if (type == null) {
-      return "";
-    }
-    switch (type) {
-      case ACTIVE:
-        return AdminConstants.I.projectState_ACTIVE();
-      case READ_ONLY:
-        return AdminConstants.I.projectState_READ_ONLY();
-      case HIDDEN:
-        return AdminConstants.I.projectState_HIDDEN();
-      default:
-        return type.name();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.java
deleted file mode 100644
index ad614e5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.java
+++ /dev/null
@@ -1,209 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.admin;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.DivElement;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.dom.client.Style.Display;
-import com.google.gwt.editor.client.EditorError;
-import com.google.gwt.editor.client.HasEditorErrors;
-import com.google.gwt.editor.client.IsEditor;
-import com.google.gwt.editor.client.LeafValueEditor;
-import com.google.gwt.editor.ui.client.adapters.ValueBoxEditor;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.DoubleClickEvent;
-import com.google.gwt.event.dom.client.DoubleClickHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiChild;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.Focusable;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.SimplePanel;
-import com.google.gwt.user.client.ui.ValueBoxBase;
-import com.google.gwt.user.client.ui.Widget;
-import java.text.ParseException;
-import java.util.List;
-
-public class ValueEditor<T> extends Composite
-    implements HasEditorErrors<T>, IsEditor<ValueBoxEditor<T>>, LeafValueEditor<T>, Focusable {
-  interface Binder extends UiBinder<Widget, ValueEditor<?>> {}
-
-  static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField SimplePanel textPanel;
-  private Label textLabel;
-  private StartEditHandlers startHandlers;
-
-  @UiField Image editIcon;
-
-  @UiField SimplePanel editPanel;
-
-  @UiField DivElement errorLabel;
-
-  private ValueBoxBase<T> editChild;
-  private ValueBoxEditor<T> editProxy;
-  private boolean ignoreEditorValue;
-  private T value;
-
-  public ValueEditor() {
-    startHandlers = new StartEditHandlers();
-    initWidget(uiBinder.createAndBindUi(this));
-    editPanel.setVisible(false);
-    editIcon.addClickHandler(startHandlers);
-  }
-
-  public void edit() {
-    textPanel.removeFromParent();
-    textPanel = null;
-    textLabel = null;
-
-    editIcon.removeFromParent();
-    editIcon = null;
-    startHandlers = null;
-
-    editPanel.setVisible(true);
-  }
-
-  @Override
-  public ValueBoxEditor<T> asEditor() {
-    if (editProxy == null) {
-      editProxy = new EditorProxy();
-    }
-    return editProxy;
-  }
-
-  @Override
-  public T getValue() {
-    return ignoreEditorValue ? value : asEditor().getValue();
-  }
-
-  @Override
-  public void setValue(T value) {
-    this.value = value;
-    asEditor().setValue(value);
-  }
-
-  void setIgnoreEditorValue(boolean off) {
-    ignoreEditorValue = off;
-  }
-
-  public void setEditTitle(String title) {
-    editIcon.setTitle(title);
-  }
-
-  @UiChild(limit = 1, tagname = "display")
-  public void setDisplay(Label widget) {
-    textLabel = widget;
-    textPanel.add(textLabel);
-
-    textLabel.addClickHandler(startHandlers);
-    textLabel.addDoubleClickHandler(startHandlers);
-  }
-
-  @UiChild(limit = 1, tagname = "editor")
-  public void setEditor(ValueBoxBase<T> widget) {
-    editChild = widget;
-    editPanel.add(editChild);
-    editProxy = null;
-  }
-
-  public void setEnabled(boolean enabled) {
-    editIcon.setVisible(enabled);
-    startHandlers.enabled = enabled;
-  }
-
-  @Override
-  public void showErrors(List<EditorError> errors) {
-    StringBuilder buf = new StringBuilder();
-    for (EditorError error : errors) {
-      if (error.getEditor().equals(editProxy)) {
-        buf.append("\n");
-        if (error.getUserData() instanceof ParseException) {
-          buf.append(((ParseException) error.getUserData()).getMessage());
-        } else {
-          buf.append(error.getMessage());
-        }
-      }
-    }
-
-    if (0 < buf.length()) {
-      errorLabel.setInnerText(buf.substring(1));
-      errorLabel.getStyle().setDisplay(Display.BLOCK);
-    } else {
-      errorLabel.setInnerText("");
-      errorLabel.getStyle().setDisplay(Display.NONE);
-    }
-  }
-
-  @Override
-  public void setAccessKey(char key) {
-    editChild.setAccessKey(key);
-  }
-
-  @Override
-  public void setFocus(boolean focused) {
-    editChild.setFocus(focused);
-    if (focused) {
-      editChild.setCursorPos(editChild.getText().length());
-    }
-  }
-
-  @Override
-  public int getTabIndex() {
-    return editChild.getTabIndex();
-  }
-
-  @Override
-  public void setTabIndex(int index) {
-    editChild.setTabIndex(index);
-  }
-
-  private class StartEditHandlers implements ClickHandler, DoubleClickHandler {
-    boolean enabled;
-
-    @Override
-    public void onClick(ClickEvent event) {
-      if (enabled && event.getNativeButton() == NativeEvent.BUTTON_LEFT) {
-        edit();
-      }
-    }
-
-    @Override
-    public void onDoubleClick(DoubleClickEvent event) {
-      if (enabled && event.getNativeButton() == NativeEvent.BUTTON_LEFT) {
-        edit();
-      }
-    }
-  }
-
-  private class EditorProxy extends ValueBoxEditor<T> {
-    EditorProxy() {
-      super(editChild);
-    }
-
-    @Override
-    public void setValue(T value) {
-      super.setValue(value);
-      if (textLabel == null) {
-        setDisplay(new Label());
-      }
-      textLabel.setText(editChild.getText());
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.ui.xml
deleted file mode 100644
index 137ad2b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.ui.xml
+++ /dev/null
@@ -1,64 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2011 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-  xmlns:ui='urn:ui:com.google.gwt.uibinder'
-  xmlns:g='urn:import:com.google.gwt.user.client.ui'
-  >
-<ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-<ui:style gss='false'>
-  .panel {
-    position: relative;
-    white-space: nowrap;
-  }
-
-  .textPanel {
-    width: 100%;
-    padding-right: 21px;
-  }
-
-  .editIcon {
-    position: absolute;
-    top: 0;
-    right: 5px;
-  }
-
-  .editPanel {
-    width: 100%;
-  }
-
-  .errorLabel {
-    display: none;
-    color: red;
-    white-space: pre;
-  }
-</ui:style>
-<g:HTMLPanel stylePrimaryName='{style.panel}'>
-  <g:Image
-      ui:field='editIcon'
-      resource='{ico.edit}'
-      stylePrimaryName='{style.editIcon}'
-      title='Edit'>
-    <ui:attribute name='title'/>
-  </g:Image>
-  <g:SimplePanel ui:field='textPanel' stylePrimaryName='{style.textPanel}'/>
-
-  <g:SimplePanel ui:field='editPanel' stylePrimaryName='{style.editPanel}'/>
-  <div
-      ui:field='errorLabel'
-      class='{style.errorLabel}'/>
-</g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/admin.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/admin.css
deleted file mode 100644
index eca4823..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/admin.css
+++ /dev/null
@@ -1,53 +0,0 @@
-/* Copyright (C) 2009 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the 'License');
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an 'AS IS' BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-@eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-@eval textColor com.google.gerrit.client.Gerrit.getTheme().textColor;
-@def deletedBackground #a9a9a9;
-
-@sprite .deleteIcon {
-  gwt-image: 'deleteNormal';
-  border: none;
-}
-
-@sprite .deleteIcon:hover {
-  gwt-image: 'deleteHover';
-  border: none;
-}
-
-@sprite .undoIcon {
-  gwt-image: 'undoNormal';
-  border: none;
-}
-
-.deleted {
-  background-color: deletedBackground;
-  color: #ffffff;
-  white-space: nowrap;
-  padding-left: 50px;
-}
-
-.deleted:hover {
-  background-color: selectionColor;
-  color: textColor;
- }
-
-.deletedBorder {
-  background: 1px solid deletedBackground;
-}
-
-.deleteSectionHover {
-  background-color: selectionColor !important;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/arrow_undo.png b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/arrow_undo.png
deleted file mode 100644
index 6972c5e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/arrow_undo.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/deleteHover.png b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/deleteHover.png
deleted file mode 100644
index 9fde3fa..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/deleteHover.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/deleteNormal.png b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/deleteNormal.png
deleted file mode 100644
index 47a1195..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/deleteNormal.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java
deleted file mode 100644
index cf8de54..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java
+++ /dev/null
@@ -1,293 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gerrit.client.actions.ActionButton;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.projects.BranchInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class ActionContext extends JavaScriptObject {
-  static final native void init() /*-{
-    var Gerrit = $wnd.Gerrit;
-    var doc = $wnd.document;
-    var stopPropagation = function (e) {
-      if (e && e.stopPropagation) e.stopPropagation();
-      else $wnd.event.cancelBubble = true;
-    };
-
-    Gerrit.ActionContext = function(u){this._u=u};
-    Gerrit.ActionContext.prototype = {
-      go: Gerrit.go,
-      refresh: Gerrit.refresh,
-      refreshMenuBar: Gerrit.refreshMenuBar,
-      isSignedIn: Gerrit.isSignedIn,
-      showError: Gerrit.showError,
-
-      br: function(){return doc.createElement('br')},
-      hr: function(){return doc.createElement('hr')},
-      button: function(label, o) {
-        var e = doc.createElement('button');
-        e.appendChild(this.div(doc.createTextNode(label)));
-        if (o && o.onclick) e.onclick = o.onclick;
-        return e;
-      },
-      checkbox: function() {
-        var e = doc.createElement('input');
-        e.type = 'checkbox';
-        return e;
-      },
-      div: function() {
-        var e = doc.createElement('div');
-        for (var i = 0; i < arguments.length; i++)
-          e.appendChild(arguments[i]);
-        return e;
-      },
-      label: function(c,label) {
-        var e = doc.createElement('label');
-        e.appendChild(c);
-        e.appendChild(doc.createTextNode(label));
-        return e;
-      },
-      prependLabel: function(label,c) {
-        var e = doc.createElement('label');
-        e.appendChild(doc.createTextNode(label));
-        e.appendChild(c);
-        return e;
-      },
-      span: function() {
-        var e = doc.createElement('span');
-        for (var i = 0; i < arguments.length; i++)
-          e.appendChild(arguments[i]);
-        return e;
-      },
-      msg: function(label) {
-        var e = doc.createElement('span');
-        e.appendChild(doc.createTextNode(label));
-        return e;
-      },
-      textarea: function(o) {
-        var e = doc.createElement('textarea');
-        e.onkeypress = stopPropagation;
-        if (o && o.rows) e.rows = o.rows;
-        if (o && o.cols) e.cols = o.cols;
-        return e;
-      },
-      textfield: function() {
-        var e = doc.createElement('input');
-        e.type = 'text';
-        e.onkeypress = stopPropagation;
-        return e;
-      },
-      select: function(a,s) {
-        var e = doc.createElement('select');
-        for (var i = 0; i < a.length; i++) {
-          var o = doc.createElement('option');
-          if (i==s) {
-            o.setAttributeNode(doc.createAttribute("selected"));
-          }
-          o.appendChild(doc.createTextNode(a[i]));
-          e.appendChild(o);
-        }
-        return e;
-      },
-      selected: function(e) {
-        return e.options[e.selectedIndex].text;
-      },
-
-      popup: function(e){
-        this._p=@com.google.gerrit.client.api.PopupHelper::popup(
-          Lcom/google/gerrit/client/api/ActionContext;Lcom/google/gwt/dom/client/Element;)(this,e)},
-      hide: function() {
-        this._p.@com.google.gerrit.client.api.PopupHelper::hide()();
-        delete this['_p'];
-      },
-
-      call: function(i,b) {
-        var m = this.action.method.toLowerCase();
-        if (m == 'get' || m == 'delete' || i==null) this[m](b);
-        else this[m](i,b);
-      },
-      get: function(b){@com.google.gerrit.client.api.ActionContext::get(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._u,b)},
-      post: function(i,b){@com.google.gerrit.client.api.ActionContext::post(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(
-        this._u,i,b)},
-      put: function(i,b){@com.google.gerrit.client.api.ActionContext::put(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(
-        this._u,i,b)},
-      'delete': function(b){@com.google.gerrit.client.api.ActionContext::delete(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._u,b)},
-      del: function(b){@com.google.gerrit.client.api.ActionContext::delete(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._u,b)},
-    };
-  }-*/;
-
-  static final native ActionContext create(RestApi f) /*-{
-    return new $wnd.Gerrit.ActionContext(f);
-  }-*/;
-
-  final native void set(ActionInfo a) /*-{ this.action=a; }-*/;
-
-  final native void set(ChangeInfo c) /*-{ this.change=c; }-*/;
-
-  final native void set(EditInfo e) /*-{ this.edit=e; }-*/;
-
-  final native void set(Project.NameKey p) /*-{ this.project=p; }-*/;
-
-  final native void set(BranchInfo b) /*-{ this.branch=b }-*/;
-
-  final native void set(RevisionInfo r) /*-{ this.revision=r; }-*/;
-
-  final native void button(ActionButton b) /*-{ this._b=b; }-*/;
-
-  final native ActionButton button() /*-{ return this._b; }-*/;
-
-  public final native boolean has_popup() /*-{ return this.hasOwnProperty('_p') }-*/;
-
-  public final native void hide() /*-{ this.hide(); }-*/;
-
-  protected ActionContext() {}
-
-  static final void get(RestApi api, JavaScriptObject cb) {
-    api.get(wrap(cb));
-  }
-
-  /**
-   * The same as {@link #get(RestApi, JavaScriptObject)} but without converting a {@link
-   * NativeString} result to String.
-   */
-  static final void getRaw(RestApi api, JavaScriptObject cb) {
-    api.get(wrapRaw(cb));
-  }
-
-  static final void post(RestApi api, JavaScriptObject in, JavaScriptObject cb) {
-    if (NativeString.is(in)) {
-      post(api, ((NativeString) in).asString(), cb);
-    } else {
-      api.post(in, wrap(cb));
-    }
-  }
-
-  /**
-   * The same as {@link #post(RestApi, JavaScriptObject, JavaScriptObject)} but without converting a
-   * {@link NativeString} result to String.
-   */
-  static final void postRaw(RestApi api, JavaScriptObject in, JavaScriptObject cb) {
-    if (NativeString.is(in)) {
-      postRaw(api, ((NativeString) in).asString(), cb);
-    } else {
-      api.post(in, wrapRaw(cb));
-    }
-  }
-
-  static final void post(RestApi api, String in, JavaScriptObject cb) {
-    api.post(in, wrap(cb));
-  }
-
-  /**
-   * The same as {@link #post(RestApi, String, JavaScriptObject)} but without converting a {@link
-   * NativeString} result to String.
-   */
-  static final void postRaw(RestApi api, String in, JavaScriptObject cb) {
-    api.post(in, wrapRaw(cb));
-  }
-
-  static final void put(RestApi api, JavaScriptObject cb) {
-    api.put(wrap(cb));
-  }
-
-  /**
-   * The same as {@link #put(RestApi, JavaScriptObject)} but without converting a {@link
-   * NativeString} result to String.
-   */
-  static final void putRaw(RestApi api, JavaScriptObject cb) {
-    api.put(wrapRaw(cb));
-  }
-
-  static final void put(RestApi api, JavaScriptObject in, JavaScriptObject cb) {
-    if (NativeString.is(in)) {
-      put(api, ((NativeString) in).asString(), cb);
-    } else {
-      api.put(in, wrap(cb));
-    }
-  }
-
-  /**
-   * The same as {@link #put(RestApi, JavaScriptObject, JavaScriptObject)} but without converting a
-   * {@link NativeString} result to String.
-   */
-  static final void putRaw(RestApi api, JavaScriptObject in, JavaScriptObject cb) {
-    if (NativeString.is(in)) {
-      putRaw(api, ((NativeString) in).asString(), cb);
-    } else {
-      api.put(in, wrapRaw(cb));
-    }
-  }
-
-  static final void put(RestApi api, String in, JavaScriptObject cb) {
-    api.put(in, wrap(cb));
-  }
-
-  /**
-   * The same as {@link #put(RestApi, String, JavaScriptObject)} but without converting a {@link
-   * NativeString} result to String.
-   */
-  static final void putRaw(RestApi api, String in, JavaScriptObject cb) {
-    api.put(in, wrapRaw(cb));
-  }
-
-  static final void delete(RestApi api, JavaScriptObject cb) {
-    api.delete(wrap(cb));
-  }
-
-  /**
-   * The same as {@link #delete(RestApi, JavaScriptObject)} but without converting a {@link
-   * NativeString} result to String.
-   */
-  static final void deleteRaw(RestApi api, JavaScriptObject cb) {
-    api.delete(wrapRaw(cb));
-  }
-
-  private static GerritCallback<JavaScriptObject> wrap(JavaScriptObject cb) {
-    return new GerritCallback<JavaScriptObject>() {
-      @Override
-      public void onSuccess(JavaScriptObject result) {
-        if (NativeString.is(result)) {
-          NativeString s = result.cast();
-          ApiGlue.invoke(cb, s.asString());
-        } else {
-          ApiGlue.invoke(cb, result);
-        }
-      }
-    };
-  }
-
-  private static GerritCallback<JavaScriptObject> wrapRaw(JavaScriptObject cb) {
-    return new GerritCallback<JavaScriptObject>() {
-      @Override
-      public void onSuccess(JavaScriptObject result) {
-        ApiGlue.invoke(cb, result);
-      }
-    };
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
deleted file mode 100644
index 294fa9b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
+++ /dev/null
@@ -1,322 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gerrit.client.info.ServerInfo;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.Window;
-
-public class ApiGlue {
-  private static String pluginName;
-
-  public static void init() {
-    init0();
-    ActionContext.init();
-    HtmlTemplate.init();
-    Plugin.init();
-  }
-
-  private static native void init0() /*-{
-    var serverUrl = @com.google.gwt.core.client.GWT::getHostPageBaseURL()();
-    var ScreenDefinition = @com.google.gerrit.client.api.ExtensionScreen.Definition::TYPE;
-    var SettingsScreenDefinition = @com.google.gerrit.client.api.ExtensionSettingsScreen.Definition::TYPE;
-    var PanelDefinition = @com.google.gerrit.client.api.ExtensionPanel.Definition::TYPE;
-    $wnd.Gerrit = {
-      JsonString: @com.google.gerrit.client.rpc.NativeString::TYPE,
-      events: {},
-      plugins: {},
-      screens: {},
-      settingsScreens: {},
-      panels: {},
-      change_actions: {},
-      edit_actions: {},
-      revision_actions: {},
-      project_actions: {},
-      branch_actions: {},
-
-      getPluginName: @com.google.gerrit.client.api.ApiGlue::getPluginName(),
-      injectCss: @com.google.gwt.dom.client.StyleInjector::inject(Ljava/lang/String;),
-      install: function (f) {
-        var p = this._getPluginByUrl(@com.google.gerrit.client.api.PluginName::getCallerUrl()());
-        @com.google.gerrit.client.api.ApiGlue::install(
-            Lcom/google/gwt/core/client/JavaScriptObject;
-            Lcom/google/gerrit/client/api/Plugin;)
-          (f,p);
-      },
-      installGwt: function(u){return this._getPluginByUrl(u)},
-      _getPluginByUrl: function(u) {
-        return u.indexOf(serverUrl) == 0
-          ? this.plugins[u.substring(serverUrl.length)]
-          : this.plugins[u]
-      },
-
-      go: @com.google.gerrit.client.api.ApiGlue::go(Ljava/lang/String;),
-      refresh: @com.google.gerrit.client.api.ApiGlue::refresh(),
-      refreshMenuBar: @com.google.gerrit.client.api.ApiGlue::refreshMenuBar(),
-      isSignedIn: @com.google.gerrit.client.api.ApiGlue::isSignedIn(),
-      showError: @com.google.gerrit.client.api.ApiGlue::showError(Ljava/lang/String;),
-      getServerInfo: @com.google.gerrit.client.api.ApiGlue::getServerInfo(),
-      getCurrentUser: @com.google.gerrit.client.api.ApiGlue::getCurrentUser(),
-      getUserPreferences: @com.google.gerrit.client.api.ApiGlue::getUserPreferences(),
-      refreshUserPreferences: @com.google.gerrit.client.api.ApiGlue::refreshUserPreferences(),
-
-      on: function (e,f){(this.events[e] || (this.events[e]=[])).push(f)},
-      onAction: function (t,n,c){this._onAction(this.getPluginName(),t,n,c)},
-      _onAction: function (p,t,n,c) {
-        var i = p+'~'+n;
-        if ('change' == t) this.change_actions[i]=c;
-        else if ('edit' == t) this.edit_actions[i]=c;
-        else if ('revision' == t) this.revision_actions[i]=c;
-        else if ('project' == t) this.project_actions[i]=c;
-        else if ('branch' == t) this.branch_actions[i]=c;
-        else if ('screen' == t) _screen(p,t,c);
-      },
-      screen: function(r,c){this._screen(this.getPluginName(),r,c)},
-      _screen: function(p,r,c){
-        var s = new ScreenDefinition(r,c);
-        (this.screens[p] || (this.screens[p]=[])).push(s);
-      },
-      settingsScreen: function(p,m,c){this._settingsScreen(this.getPluginName(),p,m,c)},
-      _settingsScreen: function(n,p,m,c){
-        var s = new SettingsScreenDefinition(p,m,c);
-        (this.settingsScreens[n] || (this.settingsScreens[n]=[])).push(s);
-      },
-      panel: function(i,c,n){this._panel(this.getPluginName(),i,c,n)},
-      _panel: function(n,i,c,x){
-        var p = new PanelDefinition(n,c,x);
-        (this.panels[i] || (this.panels[i]=[])).push(p);
-      },
-
-      url: function (d) {
-        if (d && d.length > 0)
-          return serverUrl + (d.charAt(0)=='/' ? d.substring(1) : d);
-        return serverUrl;
-      },
-
-      _api: function(u) {
-        return @com.google.gerrit.client.rpc.RestApi::new(Ljava/lang/String;)(u);
-      },
-      get: function(u,b) {
-        @com.google.gerrit.client.api.ActionContext::get(
-            Lcom/google/gerrit/client/rpc/RestApi;
-            Lcom/google/gwt/core/client/JavaScriptObject;)
-          (this._api(u), b);
-      },
-      get_raw: function(u,b) {
-        @com.google.gerrit.client.api.ActionContext::getRaw(
-            Lcom/google/gerrit/client/rpc/RestApi;
-            Lcom/google/gwt/core/client/JavaScriptObject;)
-          (this._api(u), b);
-      },
-      post: function(u,i,b) {
-        if (typeof i == 'string') {
-          @com.google.gerrit.client.api.ActionContext::post(
-              Lcom/google/gerrit/client/rpc/RestApi;
-              Ljava/lang/String;
-              Lcom/google/gwt/core/client/JavaScriptObject;)
-            (this._api(u), i, b);
-        } else {
-          @com.google.gerrit.client.api.ActionContext::post(
-              Lcom/google/gerrit/client/rpc/RestApi;
-              Lcom/google/gwt/core/client/JavaScriptObject;
-              Lcom/google/gwt/core/client/JavaScriptObject;)
-            (this._api(u), i, b);
-        }
-      },
-      post_raw: function(u,i,b) {
-        if (typeof i == 'string') {
-          @com.google.gerrit.client.api.ActionContext::postRaw(
-              Lcom/google/gerrit/client/rpc/RestApi;
-              Ljava/lang/String;
-              Lcom/google/gwt/core/client/JavaScriptObject;)
-            (this._api(u), i, b);
-        } else {
-          @com.google.gerrit.client.api.ActionContext::postRaw(
-              Lcom/google/gerrit/client/rpc/RestApi;
-              Lcom/google/gwt/core/client/JavaScriptObject;
-              Lcom/google/gwt/core/client/JavaScriptObject;)
-            (this._api(u), i, b);
-        }
-      },
-      put: function(u,i,b) {
-        if (b) {
-          if (typeof i == 'string') {
-            @com.google.gerrit.client.api.ActionContext::put(
-                Lcom/google/gerrit/client/rpc/RestApi;
-                Ljava/lang/String;
-                Lcom/google/gwt/core/client/JavaScriptObject;)
-              (this._api(u), i, b);
-          } else {
-            @com.google.gerrit.client.api.ActionContext::put(
-                Lcom/google/gerrit/client/rpc/RestApi;
-                Lcom/google/gwt/core/client/JavaScriptObject;
-                Lcom/google/gwt/core/client/JavaScriptObject;)
-              (this._api(u), i, b);
-          }
-        } else {
-          @com.google.gerrit.client.api.ActionContext::put(
-              Lcom/google/gerrit/client/rpc/RestApi;
-              Lcom/google/gwt/core/client/JavaScriptObject;)
-            (this._api(u), i);
-        }
-      },
-      put_raw: function(u,i,b) {
-        if (b) {
-          if (typeof i == 'string') {
-            @com.google.gerrit.client.api.ActionContext::putRaw(
-                Lcom/google/gerrit/client/rpc/RestApi;
-                Ljava/lang/String;
-                Lcom/google/gwt/core/client/JavaScriptObject;)
-              (this._api(u), i, b);
-          } else {
-            @com.google.gerrit.client.api.ActionContext::putRaw(
-                Lcom/google/gerrit/client/rpc/RestApi;
-                Lcom/google/gwt/core/client/JavaScriptObject;
-                Lcom/google/gwt/core/client/JavaScriptObject;)
-              (this._api(u), i, b);
-          }
-        } else {
-          @com.google.gerrit.client.api.ActionContext::putRaw(
-              Lcom/google/gerrit/client/rpc/RestApi;
-              Lcom/google/gwt/core/client/JavaScriptObject;)
-            (this._api(u), i);
-        }
-      },
-      'delete': function(u,b) {
-        @com.google.gerrit.client.api.ActionContext::delete(
-            Lcom/google/gerrit/client/rpc/RestApi;
-            Lcom/google/gwt/core/client/JavaScriptObject;)
-          (this._api(u), b);
-      },
-      del: function(u,b) {
-        @com.google.gerrit.client.api.ActionContext::delete(
-            Lcom/google/gerrit/client/rpc/RestApi;
-            Lcom/google/gwt/core/client/JavaScriptObject;)
-          (this._api(u), b);
-      },
-      del_raw: function(u,b) {
-        @com.google.gerrit.client.api.ActionContext::deleteRaw(
-            Lcom/google/gerrit/client/rpc/RestApi;
-            Lcom/google/gwt/core/client/JavaScriptObject;)
-          (this._api(u), b);
-      },
-    };
-  }-*/;
-
-  private static void install(JavaScriptObject cb, Plugin p) throws Exception {
-    try {
-      pluginName = p.name();
-      invoke(cb, p);
-      p._initialized();
-    } catch (Exception e) {
-      p.failure(e);
-      throw e;
-    } finally {
-      pluginName = null;
-      PluginLoader.loaded();
-    }
-  }
-
-  private static String getPluginName() {
-    if (pluginName != null) {
-      return pluginName;
-    }
-    return PluginName.fromUrl(PluginName.getCallerUrl());
-  }
-
-  private static void go(String urlOrToken) {
-    if (urlOrToken.startsWith("http:")
-        || urlOrToken.startsWith("https:")
-        || urlOrToken.startsWith("//")) {
-      Window.Location.assign(urlOrToken);
-    } else {
-      Gerrit.display(urlOrToken);
-    }
-  }
-
-  private static void refresh() {
-    Gerrit.display(History.getToken());
-  }
-
-  private static ServerInfo getServerInfo() {
-    return Gerrit.info();
-  }
-
-  private static AccountInfo getCurrentUser() {
-    return Gerrit.getUserAccount();
-  }
-
-  private static GeneralPreferences getUserPreferences() {
-    return Gerrit.getUserPreferences();
-  }
-
-  private static void refreshUserPreferences() {
-    Gerrit.refreshUserPreferences();
-  }
-
-  private static void refreshMenuBar() {
-    Gerrit.refreshMenuBar();
-  }
-
-  private static boolean isSignedIn() {
-    return Gerrit.isSignedIn();
-  }
-
-  private static void showError(String message) {
-    new ErrorDialog(message).center();
-  }
-
-  static final native void invoke(JavaScriptObject f) /*-{ f(); }-*/;
-
-  static final native void invoke(JavaScriptObject f, JavaScriptObject a) /*-{ f(a); }-*/;
-
-  static final native void invoke(
-      JavaScriptObject f, JavaScriptObject a, JavaScriptObject b) /*-{ f(a,b) }-*/;
-
-  static final native void invoke(JavaScriptObject f, String a) /*-{ f(a); }-*/;
-
-  public static final void fireEvent(String event, String a) {
-    JsArray<JavaScriptObject> h = getEventHandlers(event);
-    for (int i = 0; i < h.length(); i++) {
-      invoke(h.get(i), a);
-    }
-  }
-
-  public static final void fireEvent(String event, Element e) {
-    JsArray<JavaScriptObject> h = getEventHandlers(event);
-    for (int i = 0; i < h.length(); i++) {
-      invoke(h.get(i), e);
-    }
-  }
-
-  static final void fireEvent(String event, JavaScriptObject a, JavaScriptObject b) {
-    JsArray<JavaScriptObject> h = getEventHandlers(event);
-    for (int i = 0; i < h.length(); i++) {
-      invoke(h.get(i), a, b);
-    }
-  }
-
-  static final native JsArray<JavaScriptObject> getEventHandlers(String e)
-      /*-{ return $wnd.Gerrit.events[e] || [] }-*/ ;
-
-  private ApiGlue() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java
deleted file mode 100644
index c7f0051..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gerrit.client.actions.ActionButton;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-
-public class ChangeGlue {
-  public static void fireShowChange(ChangeInfo change, RevisionInfo rev) {
-    ApiGlue.fireEvent("showchange", change, rev);
-  }
-
-  public static boolean onSubmitChange(ChangeInfo change, RevisionInfo rev) {
-    JsArray<JavaScriptObject> h = ApiGlue.getEventHandlers("submitchange");
-    for (int i = 0; i < h.length(); i++) {
-      if (!invoke(h.get(i), change, rev)) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  public static void onAction(ChangeInfo change, ActionInfo action, ActionButton button) {
-    RestApi api = ChangeApi.change(change.project(), change.legacyId().get()).view(action.id());
-    JavaScriptObject f = get(action.id());
-    if (f != null) {
-      ActionContext c = ActionContext.create(api);
-      c.set(action);
-      c.set(change);
-      c.button(button);
-      ApiGlue.invoke(f, c);
-    } else {
-      DefaultActions.invoke(change, action, api);
-    }
-  }
-
-  private static native JavaScriptObject get(String id) /*-{
-    return $wnd.Gerrit.change_actions[id];
-  }-*/;
-
-  private static native boolean invoke(JavaScriptObject h, ChangeInfo a, RevisionInfo r)
-      /*-{ return h(a,r) }-*/ ;
-
-  private ChangeGlue() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
deleted file mode 100644
index 0c4aacd..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.Window.Location;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-class DefaultActions {
-  static void invoke(ChangeInfo change, ActionInfo action, RestApi api) {
-    invoke(action, api, callback(PageLinks.toChange(change.projectNameKey(), change.legacyId())));
-  }
-
-  static void invoke(Project.NameKey project, ActionInfo action, RestApi api) {
-    invoke(action, api, callback(PageLinks.toProject(project)));
-  }
-
-  private static AsyncCallback<JavaScriptObject> callback(String target) {
-    return new GerritCallback<JavaScriptObject>() {
-      @Override
-      public void onSuccess(JavaScriptObject in) {
-        UiResult result = asUiResult(in);
-        if (result == null) {
-          Gerrit.display(target);
-          return;
-        }
-
-        if (result.alert() != null) {
-          Window.alert(result.alert());
-        }
-
-        if (result.redirectUrl() != null && result.openWindow()) {
-          Window.open(result.redirectUrl(), "_blank", null);
-        } else if (result.redirectUrl() != null) {
-          Location.assign(result.redirectUrl());
-        } else {
-          Gerrit.display(target);
-        }
-      }
-
-      private UiResult asUiResult(JavaScriptObject in) {
-        if (NativeString.is(in)) {
-          String str = ((NativeString) in).asString();
-          return str.isEmpty() ? UiResult.none() : UiResult.alert(str);
-        }
-        return in.cast();
-      }
-    };
-  }
-
-  private static void invoke(ActionInfo action, RestApi api, AsyncCallback<JavaScriptObject> cb) {
-    if ("GET".equalsIgnoreCase(action.method())) {
-      api.get(cb);
-    } else if ("PUT".equalsIgnoreCase(action.method())) {
-      api.put(JavaScriptObject.createObject(), cb);
-    } else if ("DELETE".equalsIgnoreCase(action.method())) {
-      api.delete(cb);
-    } else {
-      api.post(JavaScriptObject.createObject(), cb);
-    }
-  }
-
-  private DefaultActions() {}
-
-  private static class UiResult extends JavaScriptObject {
-    static native UiResult alert(String m) /*-{ return {'alert':m} }-*/;
-
-    static native UiResult none() /*-{ return {} }-*/;
-
-    final native String alert() /*-{ return this.alert }-*/;
-
-    final native String redirectUrl() /*-{ return this.url }-*/;
-
-    final native boolean openWindow() /*-{ return this.open_window || false }-*/;
-
-    protected UiResult() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java
deleted file mode 100644
index 85cfde6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java
+++ /dev/null
@@ -1,48 +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.client.api;
-
-import com.google.gerrit.client.actions.ActionButton;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class EditGlue {
-  public static void onAction(
-      ChangeInfo change, EditInfo edit, ActionInfo action, ActionButton button) {
-    RestApi api = ChangeApi.edit(change.project(), change.legacyId().get()).view(action.id());
-
-    JavaScriptObject f = get(action.id());
-    if (f != null) {
-      ActionContext c = ActionContext.create(api);
-      c.set(action);
-      c.set(change);
-      c.set(edit);
-      c.button(button);
-      ApiGlue.invoke(f, c);
-    } else {
-      DefaultActions.invoke(change, action, api);
-    }
-  }
-
-  private static native JavaScriptObject get(String id) /*-{
-    return $wnd.Gerrit.edit_actions[id];
-  }-*/;
-
-  private EditGlue() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
deleted file mode 100644
index 6d3dd60..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
+++ /dev/null
@@ -1,205 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.api;
-
-import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.SimplePanel;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-public class ExtensionPanel extends FlowPanel {
-  private static final Logger logger = Logger.getLogger(ExtensionPanel.class.getName());
-  private final GerritUiExtensionPoint extensionPoint;
-  private final List<Context> contexts;
-
-  public ExtensionPanel(GerritUiExtensionPoint extensionPoint) {
-    this(extensionPoint, new ArrayList<String>());
-  }
-
-  public ExtensionPanel(GerritUiExtensionPoint extensionPoint, List<String> panelNames) {
-    this.extensionPoint = extensionPoint;
-    this.contexts = create(panelNames);
-  }
-
-  private List<Context> create(List<String> panelNames) {
-    List<Context> contexts = new ArrayList<>();
-    for (Definition def : getOrderedDefs(panelNames)) {
-      SimplePanel p = new SimplePanel();
-      add(p);
-      contexts.add(Context.create(def, p));
-    }
-    return contexts;
-  }
-
-  private List<Definition> getOrderedDefs(List<String> panelNames) {
-    if (panelNames == null) {
-      panelNames = Collections.emptyList();
-    }
-    Map<String, List<Definition>> defsOrderedByName = new LinkedHashMap<>();
-    for (String name : panelNames) {
-      defsOrderedByName.put(name, new ArrayList<Definition>());
-    }
-    for (Definition def : Natives.asList(Definition.get(extensionPoint.name()))) {
-      addDef(def, defsOrderedByName);
-    }
-    List<Definition> orderedDefs = new ArrayList<>();
-    for (List<Definition> defList : defsOrderedByName.values()) {
-      orderedDefs.addAll(defList);
-    }
-    return orderedDefs;
-  }
-
-  private static void addDef(Definition def, Map<String, List<Definition>> defsOrderedByName) {
-    String panelName = def.getPanelName();
-    if (panelName.equals(def.getPluginName() + ".undefined")) {
-      /* Handle a partially undefined panel name from the
-      javascript layer by generating a random panel name.
-      This maintains support for panels that do not provide a name. */
-      panelName =
-          def.getPluginName() + "." + Long.toHexString(Double.doubleToLongBits(Math.random()));
-    }
-    if (defsOrderedByName.containsKey(panelName)) {
-      defsOrderedByName.get(panelName).add(def);
-    } else if (defsOrderedByName.containsKey(def.getPluginName())) {
-      defsOrderedByName.get(def.getPluginName()).add(def);
-    } else {
-      defsOrderedByName.put(panelName, Collections.singletonList(def));
-    }
-  }
-
-  public void put(GerritUiExtensionPoint.Key key, String value) {
-    for (Context ctx : contexts) {
-      ctx.put(key.name(), value);
-    }
-  }
-
-  public void putInt(GerritUiExtensionPoint.Key key, int value) {
-    for (Context ctx : contexts) {
-      ctx.putInt(key.name(), value);
-    }
-  }
-
-  public void putBoolean(GerritUiExtensionPoint.Key key, boolean value) {
-    for (Context ctx : contexts) {
-      ctx.putBoolean(key.name(), value);
-    }
-  }
-
-  public void putObject(GerritUiExtensionPoint.Key key, JavaScriptObject value) {
-    for (Context ctx : contexts) {
-      ctx.putObject(key.name(), value);
-    }
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    for (Context ctx : contexts) {
-      try {
-        ctx.onLoad();
-      } catch (RuntimeException e) {
-        logger.log(
-            Level.SEVERE,
-            "Failed to load extension panel for extension point "
-                + extensionPoint.name()
-                + " from plugin "
-                + ctx.getPluginName()
-                + ": "
-                + e.getMessage());
-      }
-    }
-  }
-
-  @Override
-  protected void onUnload() {
-    super.onUnload();
-    for (Context ctx : contexts) {
-      for (JavaScriptObject u : Natives.asList(ctx.unload())) {
-        ApiGlue.invoke(u);
-      }
-    }
-  }
-
-  static class Definition extends JavaScriptObject {
-    static final JavaScriptObject TYPE = init();
-
-    private static native JavaScriptObject init() /*-{
-      function PanelDefinition(n, c, x) {
-        this.pluginName = n;
-        this.onLoad = c;
-        this.name = x;
-      };
-      return PanelDefinition;
-    }-*/;
-
-    static native JsArray<Definition> get(String i) /*-{ return $wnd.Gerrit.panels[i] || [] }-*/;
-
-    protected Definition() {}
-
-    public final native String getPanelName() /*-{ return this.pluginName + "." + this.name; }-*/;
-
-    public final native String getPluginName() /*-{ return this.pluginName; }-*/;
-  }
-
-  static class Context extends JavaScriptObject {
-    static final Context create(Definition def, SimplePanel panel) {
-      return create(TYPE, def, panel.getElement());
-    }
-
-    final native void onLoad() /*-{ this._d.onLoad(this) }-*/;
-
-    final native JsArray<JavaScriptObject> unload() /*-{ return this._u }-*/;
-
-    final native String getPluginName() /*-{ return this._d.pluginName; }-*/;
-
-    final native void put(String k, String v) /*-{ this.p[k] = v; }-*/;
-
-    final native void putInt(String k, int v) /*-{ this.p[k] = v; }-*/;
-
-    final native void putBoolean(String k, boolean v) /*-{ this.p[k] = v; }-*/;
-
-    final native void putObject(String k, JavaScriptObject v) /*-{ this.p[k] = v; }-*/;
-
-    private static native Context create(JavaScriptObject T, Definition d, Element e)
-        /*-{ return new T(d,e) }-*/ ;
-
-    private static final JavaScriptObject TYPE = init();
-
-    private static native JavaScriptObject init() /*-{
-      var T = function(d,e) {
-        this._d = d;
-        this._u = [];
-        this.body = e;
-        this.p = {};
-      };
-      T.prototype = {
-        onUnload: function(f){this._u.push(f)},
-      };
-      return T;
-    }-*/;
-
-    protected Context() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java
deleted file mode 100644
index ff495b9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.Element;
-
-/** Screen contributed by a plugin. */
-public class ExtensionScreen extends Screen {
-  private Context ctx;
-
-  public ExtensionScreen(String token) {
-    if (token.contains("?")) {
-      token = token.substring(0, token.indexOf('?'));
-    }
-    String name;
-    String rest;
-    int s = token.indexOf('/');
-    if (0 < s) {
-      name = token.substring(0, s);
-      rest = token.substring(s + 1);
-    } else {
-      name = token;
-      rest = "";
-    }
-    ctx = create(name, rest);
-  }
-
-  private Context create(String name, String rest) {
-    for (Definition def : Natives.asList(Definition.get(name))) {
-      JsArrayString m = def.match(rest);
-      if (m != null) {
-        return Context.create(def, this, m);
-      }
-    }
-    return null;
-  }
-
-  public boolean isFound() {
-    return ctx != null;
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    setHeaderVisible(false);
-    ctx.onLoad();
-  }
-
-  @Override
-  protected void onUnload() {
-    super.onUnload();
-    for (JavaScriptObject u : Natives.asList(ctx.unload())) {
-      ApiGlue.invoke(u);
-    }
-  }
-
-  static class Definition extends JavaScriptObject {
-    static final JavaScriptObject TYPE = init();
-
-    private static native JavaScriptObject init() /*-{
-      function ScreenDefinition(r, c) {
-        this.pattern = r;
-        this.onLoad = c;
-      };
-      return ScreenDefinition;
-    }-*/;
-
-    static native JsArray<Definition> get(String n) /*-{ return $wnd.Gerrit.screens[n] || [] }-*/;
-
-    final native JsArrayString match(String t) /*-{
-      var p = this.pattern;
-      if (p instanceof $wnd.RegExp) {
-        var m = p.exec(t);
-        return m && m[0] == t ? m : null;
-      }
-      return p == t ? [t] : null;
-    }-*/;
-
-    protected Definition() {}
-  }
-
-  static class Context extends JavaScriptObject {
-    static final Context create(Definition def, ExtensionScreen view, JsArrayString match) {
-      return create(TYPE, def, view, view.getBody().getElement(), match);
-    }
-
-    final native void onLoad() /*-{ this._d.onLoad(this) }-*/;
-
-    final native JsArray<JavaScriptObject> unload() /*-{ return this._u }-*/;
-
-    private static native Context create(
-        JavaScriptObject T, Definition d, ExtensionScreen s, Element e, JsArrayString m)
-        /*-{ return new T(d,s,e,m) }-*/ ;
-
-    private static final JavaScriptObject TYPE = init();
-
-    private static native JavaScriptObject init() /*-{
-      var T = function(d,s,e,m) {
-        this._d = d;
-        this._s = s;
-        this._u = [];
-        this.body = e;
-        this.token = m[0];
-        this.token_match = m;
-      };
-      T.prototype = {
-        setTitle: function(t){this._s.@com.google.gerrit.client.ui.Screen::setPageTitle(Ljava/lang/String;)(t)},
-        setWindowTitle: function(t){this._s.@com.google.gerrit.client.ui.Screen::setWindowTitle(Ljava/lang/String;)(t)},
-        show: function(){$entry(this._s.@com.google.gwtexpui.user.client.View::display()())},
-        onUnload: function(f){this._u.push(f)},
-      };
-      return T;
-    }-*/;
-
-    protected Context() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java
deleted file mode 100644
index e7d1ed1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java
+++ /dev/null
@@ -1,139 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.api;
-
-import com.google.gerrit.client.account.SettingsScreen;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.dom.client.Element;
-import java.util.Set;
-
-/** SettingsScreen contributed by a plugin. */
-public class ExtensionSettingsScreen extends SettingsScreen {
-  private Context ctx;
-
-  public ExtensionSettingsScreen(String token) {
-    if (token.contains("?")) {
-      token = token.substring(0, token.indexOf('?'));
-    }
-    String name;
-    String rest;
-    int s = token.indexOf('/');
-    if (0 < s) {
-      name = token.substring(0, s);
-      rest = token.substring(s + 1);
-    } else {
-      name = token;
-      rest = "";
-    }
-    ctx = create(name, rest);
-  }
-
-  private Context create(String name, String rest) {
-    for (Definition def : Natives.asList(Definition.get(name))) {
-      if (def.matches(rest)) {
-        return Context.create(def, this);
-      }
-    }
-    return null;
-  }
-
-  public boolean isFound() {
-    return ctx != null;
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    setHeaderVisible(false);
-    ctx.onLoad();
-  }
-
-  @Override
-  protected void onUnload() {
-    super.onUnload();
-    for (JavaScriptObject u : Natives.asList(ctx.unload())) {
-      ApiGlue.invoke(u);
-    }
-  }
-
-  public static class Definition extends JavaScriptObject {
-    static final JavaScriptObject TYPE = init();
-
-    private static native JavaScriptObject init() /*-{
-      function SettingsScreenDefinition(p, m, c) {
-        this.path = p;
-        this.menu = m;
-        this.onLoad = c;
-      };
-      return SettingsScreenDefinition;
-    }-*/;
-
-    public static native JsArray<Definition> get(String n)
-        /*-{ return $wnd.Gerrit.settingsScreens[n] || [] }-*/ ;
-
-    public static final Set<String> plugins() {
-      return Natives.keys(settingsScreens());
-    }
-
-    private static native NativeMap<NativeString> settingsScreens()
-        /*-{ return $wnd.Gerrit.settingsScreens; }-*/ ;
-
-    public final native String getPath() /*-{ return this.path; }-*/;
-
-    public final native String getMenu() /*-{ return this.menu; }-*/;
-
-    final native boolean matches(String t) /*-{ return this.path == t; }-*/;
-
-    protected Definition() {}
-  }
-
-  static class Context extends JavaScriptObject {
-    static final Context create(Definition def, ExtensionSettingsScreen view) {
-      return create(TYPE, def, view, view.getBody().getElement());
-    }
-
-    final native void onLoad() /*-{ this._d.onLoad(this) }-*/;
-
-    final native JsArray<JavaScriptObject> unload() /*-{ return this._u }-*/;
-
-    private static native Context create(
-        JavaScriptObject T, Definition d, ExtensionSettingsScreen s, Element e)
-        /*-{ return new T(d,s,e) }-*/ ;
-
-    private static final JavaScriptObject TYPE = init();
-
-    private static native JavaScriptObject init() /*-{
-      var T = function(d,s,e) {
-        this._d = d;
-        this._s = s;
-        this._u = [];
-        this.body = e;
-      };
-      T.prototype = {
-        setTitle: function(t){this._s.@com.google.gerrit.client.ui.Screen::setPageTitle(Ljava/lang/String;)(t)},
-        setWindowTitle: function(t){this._s.@com.google.gerrit.client.ui.Screen::setWindowTitle(Ljava/lang/String;)(t)},
-        show: function(){$entry(this._s.@com.google.gwtexpui.user.client.View::display()())},
-        onUnload: function(f){this._u.push(f)},
-      };
-      return T;
-    }-*/;
-
-    protected Context() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java
deleted file mode 100644
index ba9c659..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.dom.client.Document;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Node;
-import com.google.gwt.dom.client.StyleInjector;
-import com.google.gwt.user.client.DOM;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-final class HtmlTemplate {
-  static native void init() /*-{
-    var ElementSet = function(r,e) {
-      this.root = r;
-      this.elements = e;
-    };
-    ElementSet.prototype = {
-      clear: function() {
-        this.root = null;
-        this.elements = null;
-      },
-    };
-
-    $wnd.Gerrit.css = @com.google.gerrit.client.api.HtmlTemplate::css(Ljava/lang/String;);
-    $wnd.Gerrit.html = function(h,r,w) {
-      var i = {};
-      if (r) {
-        h = h.replace(
-          /\sid=['"]\{([a-z_][a-z0-9_]*)\}['"]|\{([a-z0-9._-]+)\}/gi,
-          function(m,a,b) {
-            if (a)
-              return @com.google.gerrit.client.api.HtmlTemplate::id(
-                  Lcom/google/gerrit/client/api/HtmlTemplate$IdMap;
-                  Ljava/lang/String;)
-                (i,a);
-            return @com.google.gerrit.client.api.HtmlTemplate::html(
-                Lcom/google/gerrit/client/api/HtmlTemplate$ReplacementMap;
-                Ljava/lang/String;)
-              (r,b);
-          });
-      }
-      var e = @com.google.gerrit.client.api.HtmlTemplate::parseHtml(
-          Ljava/lang/String;Lcom/google/gerrit/client/api/HtmlTemplate$IdMap;
-          Lcom/google/gerrit/client/api/HtmlTemplate$ReplacementMap;
-          Z)
-        (h,i,r,!!w);
-      return w ? new ElementSet(e,i) : e;
-    };
-  }-*/;
-
-  private static String css(String css) {
-    String name = DOM.createUniqueId();
-    StyleInjector.inject("." + name + "{" + css + "}");
-    return name;
-  }
-
-  private static String id(IdMap idMap, String key) {
-    String id = DOM.createUniqueId();
-    idMap.put(id, key);
-    return " id='" + id + "'";
-  }
-
-  private static String html(ReplacementMap opts, String id) {
-    int d = id.indexOf('.');
-    if (0 < d) {
-      String name = id.substring(0, d);
-      String rest = id.substring(d + 1);
-      return html(opts.map(name), rest);
-    }
-    return new SafeHtmlBuilder().append(opts.str(id)).asString();
-  }
-
-  private static Node parseHtml(String html, IdMap ids, ReplacementMap opts, boolean wantElements) {
-    Element div = Document.get().createDivElement();
-    div.setInnerHTML(html);
-    if (!ids.isEmpty()) {
-      attachHandlers(div, ids, opts, wantElements);
-    }
-    if (div.getChildCount() == 1) {
-      return div.getFirstChild();
-    }
-    return div;
-  }
-
-  private static void attachHandlers(
-      Element e, IdMap ids, ReplacementMap opts, boolean wantElements) {
-    if (e.getId() != null) {
-      String key = ids.get(e.getId());
-      if (key != null) {
-        ids.remove(e.getId());
-        if (wantElements) {
-          ids.put(key, e);
-        }
-        e.setId(null);
-        opts.map(key).attachHandlers(e);
-      }
-    }
-    for (Element c = e.getFirstChildElement(); c != null; ) {
-      attachHandlers(c, ids, opts, wantElements);
-      c = c.getNextSiblingElement();
-    }
-  }
-
-  private static class ReplacementMap extends JavaScriptObject {
-    final native ReplacementMap map(String n) /*-{ return this[n] }-*/;
-
-    final native String str(String n) /*-{ return ''+this[n] }-*/;
-
-    final native void attachHandlers(Element e) /*-{
-      for (var k in this) {
-        var f = this[k];
-        if (k.substring(0, 2) == 'on' && typeof f == 'function')
-          e[k] = f;
-      }
-    }-*/;
-
-    protected ReplacementMap() {}
-  }
-
-  private static class IdMap extends JavaScriptObject {
-    final native String get(String i) /*-{ return this[i] }-*/;
-
-    final native void remove(String i) /*-{ delete this[i] }-*/;
-
-    final native void put(String i, String k) /*-{ this[i] = k }-*/;
-
-    final native void put(String k, Element e) /*-{ this[k] = e }-*/;
-
-    final native boolean isEmpty() /*-{
-      for (var i in this)
-        return false;
-      return true;
-    }-*/;
-
-    protected IdMap() {}
-  }
-
-  private HtmlTemplate() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
deleted file mode 100644
index 48a812c1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-final class Plugin extends JavaScriptObject {
-  private static final JavaScriptObject TYPE = createType();
-
-  static Plugin create(String url) {
-    int s = "plugins/".length();
-    int e = url.indexOf('/', s);
-    String name = url.substring(s, e);
-    return create(TYPE, url, name);
-  }
-
-  native String url() /*-{ return this._scriptUrl }-*/;
-
-  native String name() /*-{ return this.name }-*/;
-
-  native boolean loaded() /*-{ return this._success || this._failure != null }-*/;
-
-  native Exception failure() /*-{ return this._failure }-*/;
-
-  native void failure(Exception e) /*-{ this._failure = e }-*/;
-
-  native boolean success() /*-{ return this._success || false }-*/;
-
-  native void _initialized() /*-{ this._success = true }-*/;
-
-  private static native Plugin create(JavaScriptObject T, String u, String n)
-      /*-{ return new T(u,n) }-*/ ;
-
-  private static native JavaScriptObject createType() /*-{
-    function Plugin(u, n) {
-      this._scriptUrl = u;
-      this.name = n;
-    }
-    return Plugin;
-  }-*/;
-
-  static native void init() /*-{
-    var G = $wnd.Gerrit;
-    @com.google.gerrit.client.api.Plugin::TYPE.prototype = {
-      getPluginName: function(){return this.name},
-      getServerInfo: @com.google.gerrit.client.api.ApiGlue::getServerInfo(),
-      getCurrentUser: @com.google.gerrit.client.api.ApiGlue::getCurrentUser(),
-      getUserPreferences: @com.google.gerrit.client.api.ApiGlue::getUserPreferences(),
-      refreshUserPreferences: @com.google.gerrit.client.api.ApiGlue::refreshUserPreferences(),
-      go: @com.google.gerrit.client.api.ApiGlue::go(Ljava/lang/String;),
-      refresh: @com.google.gerrit.client.api.ApiGlue::refresh(),
-      refreshMenuBar: @com.google.gerrit.client.api.ApiGlue::refreshMenuBar(),
-      isSignedIn: @com.google.gerrit.client.api.ApiGlue::isSignedIn(),
-      showError: @com.google.gerrit.client.api.ApiGlue::showError(Ljava/lang/String;),
-      on: function(e,f){G.on(e,f)},
-      onAction: function(t,n,c){G._onAction(this.name,t,n,c)},
-      screen: function(p,c){G._screen(this.name,p,c)},
-      settingsScreen: function(p,m,c){G._settingsScreen(this.name,p,m,c)},
-      panel: function(i,c,n){G._panel(this.name,i,c,n)},
-
-      url: function (u){return G.url(this._url(u))},
-      get: function(u,b){@com.google.gerrit.client.api.ActionContext::get(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),b)},
-      post: function(u,i,b){@com.google.gerrit.client.api.ActionContext::post(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(
-        this._api(u),i,b)},
-      put: function(u,i,b){@com.google.gerrit.client.api.ActionContext::put(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;)(
-        this._api(u),i,b)},
-      'delete': function(u,b){@com.google.gerrit.client.api.ActionContext::delete(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),b)},
-      del: function(u,b){@com.google.gerrit.client.api.ActionContext::delete(
-        Lcom/google/gerrit/client/rpc/RestApi;Lcom/google/gwt/core/client/JavaScriptObject;)(this._api(u),b)},
-
-      _loadedGwt: function(){@com.google.gerrit.client.api.PluginLoader::loaded()()},
-      _api: function(u){return @com.google.gerrit.client.rpc.RestApi::new(Ljava/lang/String;)(this._url(u))},
-      _url: function (d) {
-        var u = 'plugins/' + this.name + '/';
-        if (d && d.length > 0)
-          return u + (d.charAt(0)=='/' ? d.substring(1) : d);
-        return u;
-      },
-    };
-  }-*/;
-
-  protected Plugin() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
deleted file mode 100644
index de25ef0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
+++ /dev/null
@@ -1,196 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.Callback;
-import com.google.gwt.core.client.CodeDownloadException;
-import com.google.gwt.core.client.ScriptInjector;
-import com.google.gwt.user.client.Timer;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.DialogBox;
-import com.google.gwtexpui.progress.client.ProgressBar;
-import java.util.List;
-
-/** Loads JavaScript plugins with a progress meter visible. */
-public class PluginLoader extends DialogBox {
-  private static PluginLoader self;
-
-  public static void load(
-      List<String> plugins, int loadTimeout, AsyncCallback<VoidResult> callback) {
-    if (plugins == null || plugins.isEmpty()) {
-      callback.onSuccess(VoidResult.create());
-    } else {
-      plugins = plugins.stream().filter(p -> p.endsWith(".js")).collect(toList());
-      if (plugins.isEmpty()) {
-        callback.onSuccess(VoidResult.create());
-      } else {
-        self = new PluginLoader(loadTimeout, callback);
-        self.load(plugins);
-        self.startTimers();
-        self.center();
-      }
-    }
-  }
-
-  static void loaded() {
-    self.loadedOne();
-  }
-
-  private final int loadTimeout;
-  private final AsyncCallback<VoidResult> callback;
-  private ProgressBar progress;
-  private Timer show;
-  private Timer update;
-  private Timer timeout;
-  private boolean visible;
-
-  private PluginLoader(int loadTimeout, AsyncCallback<VoidResult> cb) {
-    super(/* auto hide */ false, /* modal */ true);
-    callback = cb;
-    this.loadTimeout = loadTimeout;
-    progress = new ProgressBar(Gerrit.C.loadingPlugins());
-
-    setStyleName(Gerrit.RESOURCES.css().errorDialog());
-    addStyleName(Gerrit.RESOURCES.css().loadingPluginsDialog());
-  }
-
-  private void load(List<String> pluginUrls) {
-    for (String url : pluginUrls) {
-      Plugin plugin = Plugin.create(url);
-      plugins().put(url, plugin);
-      ScriptInjector.fromUrl(url)
-          .setWindow(ScriptInjector.TOP_WINDOW)
-          .setCallback(new LoadCallback(plugin))
-          .inject();
-    }
-  }
-
-  private void startTimers() {
-    show =
-        new Timer() {
-          @Override
-          public void run() {
-            setText(Window.getTitle());
-            setWidget(progress);
-            setGlassEnabled(true);
-            getGlassElement().addClassName(Gerrit.RESOURCES.css().errorDialogGlass());
-            hide(true);
-            center();
-            visible = true;
-          }
-        };
-    show.schedule(500);
-
-    update =
-        new Timer() {
-          private int cycle;
-
-          @Override
-          public void run() {
-            progress.setValue(100 * ++cycle * 250 / loadTimeout);
-          }
-        };
-    update.scheduleRepeating(250);
-
-    timeout =
-        new Timer() {
-          @Override
-          public void run() {
-            finish();
-          }
-        };
-    timeout.schedule(loadTimeout);
-  }
-
-  private void loadedOne() {
-    boolean done = true;
-    for (Plugin plugin : Natives.asList(plugins().values())) {
-      done &= plugin.loaded();
-    }
-    if (done) {
-      finish();
-    }
-  }
-
-  private void finish() {
-    show.cancel();
-    update.cancel();
-    timeout.cancel();
-    self = null;
-
-    if (!hadFailures()) {
-      if (visible) {
-        progress.setValue(100);
-        new Timer() {
-          @Override
-          public void run() {
-            hide(true);
-          }
-        }.schedule(250);
-      } else {
-        hide(true);
-      }
-    }
-
-    callback.onSuccess(VoidResult.create());
-  }
-
-  private boolean hadFailures() {
-    boolean failed = false;
-    for (Plugin plugin : Natives.asList(plugins().values())) {
-      if (!plugin.success()) {
-        failed = true;
-
-        Exception e = plugin.failure();
-        String msg;
-        if (e != null && e instanceof CodeDownloadException) {
-          msg = Gerrit.M.cannotDownloadPlugin(plugin.url());
-        } else {
-          msg = Gerrit.M.pluginFailed(plugin.name());
-        }
-        hide(true);
-        new ErrorDialog(msg).center();
-      }
-    }
-    return failed;
-  }
-
-  private static native NativeMap<Plugin> plugins() /*-{ return $wnd.Gerrit.plugins }-*/;
-
-  private class LoadCallback implements Callback<Void, Exception> {
-    private final Plugin plugin;
-
-    LoadCallback(Plugin plugin) {
-      this.plugin = plugin;
-    }
-
-    @Override
-    public void onSuccess(Void result) {}
-
-    @Override
-    public void onFailure(Exception reason) {
-      plugin.failure(reason);
-      loadedOne();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java
deleted file mode 100644
index 7cf4fbb..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptException;
-import com.google.gwt.core.client.JsArrayString;
-
-/**
- * Determines the name a plugin has been installed under.
- *
- * <p>This implementation guesses the name a plugin runs under by looking at the JavaScript call
- * stack and identifying the URL of the script file calling {@code Gerrit.install()}. The simple
- * approach applied here is looking at the source URLs and extracting the name out of the string,
- * e.g.: {@code "http://localhost:8080/plugins/[name]/static/foo.js"}.
- */
-class PluginName {
-  private static final String UNKNOWN = "<unknown>";
-
-  private static String baseUrl() {
-    return GWT.getHostPageBaseURL() + "plugins/";
-  }
-
-  static String getCallerUrl() {
-    return GWT.<PluginName>create(PluginName.class).findCallerUrl();
-  }
-
-  static String fromUrl(String url) {
-    String baseUrl = baseUrl();
-    if (url != null && url.startsWith(baseUrl)) {
-      int s = url.indexOf('/', baseUrl.length());
-      if (s > 0) {
-        return url.substring(baseUrl.length(), s);
-      }
-    }
-    return UNKNOWN;
-  }
-
-  String findCallerUrl() {
-    JavaScriptException err = makeException();
-    if (hasStack(err)) {
-      return PluginNameMoz.getUrl(err);
-    }
-
-    String baseUrl = baseUrl();
-    StackTraceElement[] trace = getTrace(err);
-    for (int i = trace.length - 1; i >= 0; i--) {
-      String u = trace[i].getFileName();
-      if (u != null && u.startsWith(baseUrl)) {
-        return u;
-      }
-    }
-    return UNKNOWN;
-  }
-
-  private static StackTraceElement[] getTrace(JavaScriptException err) {
-    if (err.getStackTrace().length == 0) {
-      err.fillInStackTrace();
-    }
-    return err.getStackTrace();
-  }
-
-  protected static final native JavaScriptException makeException()
-      /*-{ try { null.a() } catch (e) { return e } }-*/ ;
-
-  private static native boolean hasStack(JavaScriptException e) /*-{ return !!e.stack }-*/;
-
-  /** Extracts URL from the stack frame. */
-  static class PluginNameMoz extends PluginName {
-    @Override
-    String findCallerUrl() {
-      return getUrl(makeException());
-    }
-
-    private static String getUrl(JavaScriptException e) {
-      String baseUrl = baseUrl();
-      JsArrayString stack = getStack(e);
-      for (int i = stack.length() - 1; i >= 0; i--) {
-        String frame = stack.get(i);
-        int at = frame.indexOf(baseUrl);
-        if (at >= 0) {
-          int end = frame.indexOf(':', at + baseUrl.length());
-          if (end < 0) {
-            end = frame.length();
-          }
-          return frame.substring(at, end);
-        }
-      }
-      return UNKNOWN;
-    }
-
-    private static native JsArrayString getStack(JavaScriptException e)
-        /*-{ return e.stack ? e.stack.split('\n') : [] }-*/ ;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PopupHelper.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PopupHelper.java
deleted file mode 100644
index 173b369..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PopupHelper.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gerrit.client.actions.ActionButton;
-import com.google.gerrit.client.change.Resources;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-
-class PopupHelper {
-  static PopupHelper popup(ActionContext ctx, Element panel) {
-    PopupHelper helper = new PopupHelper(ctx.button(), panel);
-    helper.show();
-    ctx.button().link(ctx);
-    return helper;
-  }
-
-  private final ActionButton activatingButton;
-  private final FlowPanel panel;
-  private PopupPanel popup;
-
-  PopupHelper(ActionButton button, Element child) {
-    activatingButton = button;
-    panel = new FlowPanel();
-    panel.setStyleName(Resources.I.style().popupContent());
-    panel.getElement().appendChild(child);
-  }
-
-  void show() {
-    final PopupPanel p = new PopupPanel(true);
-    p.setStyleName(Resources.I.style().popup());
-    p.addAutoHidePartner(activatingButton.getElement());
-    p.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            activatingButton.unlink();
-            if (popup == p) {
-              popup = null;
-            }
-          }
-        });
-    p.add(panel);
-    p.showRelativeTo(activatingButton);
-    GlobalKey.dialog(p);
-    popup = p;
-  }
-
-  void hide() {
-    if (popup != null) {
-      activatingButton.unlink();
-      popup.hide();
-      popup = null;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java
deleted file mode 100644
index 92070f8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gerrit.client.actions.ActionButton;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.projects.BranchInfo;
-import com.google.gerrit.client.projects.ProjectApi;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class ProjectGlue {
-  public static void onAction(
-      Project.NameKey project, BranchInfo branch, ActionInfo action, ActionButton button) {
-    RestApi api = ProjectApi.project(project).view("branches").id(branch.ref()).view(action.id());
-    JavaScriptObject f = branchAction(action.id());
-    if (f != null) {
-      ActionContext c = ActionContext.create(api);
-      c.set(action);
-      c.set(project);
-      c.set(branch);
-      c.button(button);
-      ApiGlue.invoke(f, c);
-    } else {
-      DefaultActions.invoke(project, action, api);
-    }
-  }
-
-  public static void onAction(Project.NameKey project, ActionInfo action, ActionButton button) {
-    RestApi api = ProjectApi.project(project).view(action.id());
-    JavaScriptObject f = projectAction(action.id());
-    if (f != null) {
-      ActionContext c = ActionContext.create(api);
-      c.set(action);
-      c.set(project);
-      c.button(button);
-      ApiGlue.invoke(f, c);
-    } else {
-      DefaultActions.invoke(project, action, api);
-    }
-  }
-
-  private static native JavaScriptObject projectAction(String id) /*-{
-    return $wnd.Gerrit.project_actions[id];
-  }-*/;
-
-  private static native JavaScriptObject branchAction(String id) /*-{
-    return $wnd.Gerrit.branch_actions[id];
-  }-*/;
-
-  private ProjectGlue() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java
deleted file mode 100644
index d1029b2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.api;
-
-import com.google.gerrit.client.actions.ActionButton;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class RevisionGlue {
-  public static void onAction(
-      ChangeInfo change, RevisionInfo revision, ActionInfo action, ActionButton button) {
-    RestApi api =
-        ChangeApi.revision(change.project(), change.legacyId().get(), revision.name())
-            .view(action.id());
-
-    JavaScriptObject f = get(action.id());
-    if (f != null) {
-      ActionContext c = ActionContext.create(api);
-      c.set(action);
-      c.set(change);
-      c.set(revision);
-      c.button(button);
-      ApiGlue.invoke(f, c);
-    } else {
-      DefaultActions.invoke(change, action, api);
-    }
-  }
-
-  private static native JavaScriptObject get(String id) /*-{
-    return $wnd.Gerrit.revision_actions[id];
-  }-*/;
-
-  private RevisionGlue() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.java
deleted file mode 100644
index a0eaef7..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.auth.openid;
-
-import com.google.gwt.i18n.client.Constants;
-
-public interface OpenIdConstants extends Constants {
-  String nameLaunchpad();
-
-  String nameYahoo();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.properties
deleted file mode 100644
index d6e8de6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.properties
+++ /dev/null
@@ -1,2 +0,0 @@
-nameLaunchpad = Launchpad ID
-nameYahoo = Yahoo! ID
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdUtil.java
deleted file mode 100644
index d5b7684..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdUtil.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.auth.openid;
-
-import com.google.gwt.core.client.GWT;
-
-public class OpenIdUtil {
-  public static final OpenIdConstants C;
-
-  static {
-    C = GWT.create(OpenIdConstants.class);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java
deleted file mode 100644
index 77fddeb..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.blame;
-
-import com.google.gerrit.client.RangeInfo;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-
-public class BlameInfo extends JavaScriptObject {
-  public final native String author() /*-{ return this.author; }-*/;
-
-  public final native String id() /*-{ return this.id; }-*/;
-
-  public final native String commitMsg() /*-{ return this.commit_msg; }-*/;
-
-  public final native int time() /*-{ return this.time; }-*/;
-
-  public final native JsArray<RangeInfo> ranges() /*-{ return this.ranges; }-*/;
-
-  protected BlameInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java
deleted file mode 100644
index fd58959..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.user.client.ui.Button;
-
-class AbandonAction extends ActionMessageBox {
-  private final Project.NameKey project;
-  private final Change.Id id;
-
-  AbandonAction(Button b, Project.NameKey project, Change.Id id) {
-    super(b);
-    this.project = project;
-    this.id = id;
-  }
-
-  @Override
-  void send(String message) {
-    ChangeApi.abandon(
-        project.get(),
-        id.get(),
-        message,
-        new GerritCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo result) {
-            Gerrit.display(PageLinks.toChange(project, id));
-            hide();
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java
deleted file mode 100644
index 5b3ee29..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.NpTextArea;
-
-abstract class ActionMessageBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, ActionMessageBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface Style extends CssResource {
-    String popup();
-  }
-
-  private final Button activatingButton;
-  private PopupPanel popup;
-
-  @UiField Style style;
-  @UiField NpTextArea message;
-  @UiField Button send;
-
-  ActionMessageBox(Button button) {
-    this.activatingButton = button;
-    initWidget(uiBinder.createAndBindUi(this));
-    send.setText(button.getText());
-  }
-
-  abstract void send(String message);
-
-  void show() {
-    if (popup != null) {
-      popup.hide();
-      popup = null;
-      return;
-    }
-
-    final PopupPanel p = new PopupPanel(true);
-    p.setStyleName(style.popup());
-    p.addAutoHidePartner(activatingButton.getElement());
-    p.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            if (popup == p) {
-              popup = null;
-            }
-          }
-        });
-    p.add(this);
-    p.showRelativeTo(activatingButton);
-    GlobalKey.dialog(p);
-    message.setFocus(true);
-    popup = p;
-  }
-
-  void hide() {
-    if (popup != null) {
-      popup.hide();
-      popup = null;
-    }
-  }
-
-  @UiHandler("message")
-  void onMessageKey(KeyPressEvent event) {
-    if ((event.getCharCode() == '\n' || event.getCharCode() == KeyCodes.KEY_ENTER)
-        && event.isControlKeyDown()) {
-      event.preventDefault();
-      event.stopPropagation();
-      onSend(null);
-    }
-  }
-
-  @UiHandler("send")
-  void onSend(@SuppressWarnings("unused") ClickEvent e) {
-    send(message.getValue().trim());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.ui.xml
deleted file mode 100644
index a9e1bb3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.ui.xml
+++ /dev/null
@@ -1,47 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false' type='com.google.gerrit.client.change.ActionMessageBox.Style'>
-    @eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
-
-    .popup { background-color: trimColor; }
-    .section {
-      padding: 5px 5px;
-      border-bottom: 1px solid #b8b8b8;
-    }
-  </ui:style>
-  <g:HTMLPanel>
-    <div class='{style.section}'>
-      <c:NpTextArea
-         visibleLines='3'
-         characterWidth='40'
-         ui:field='message'/>
-    </div>
-    <div class='{style.section}'>
-      <g:Button ui:field='send'
-          title='(Shortcut: Ctrl-Enter)'
-          styleName='{res.style.button}'>
-        <ui:attribute name='title'/>
-        <div><ui:msg>Send</ui:msg></div>
-      </g:Button>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
deleted file mode 100644
index e4f5e576..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
+++ /dev/null
@@ -1,260 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.actions.ActionButton;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.TreeSet;
-
-class Actions extends Composite {
-  private static final String[] CORE = {
-    "abandon",
-    "assignee",
-    "cherrypick",
-    "description",
-    "followup",
-    "hashtags",
-    "move",
-    "publish",
-    "rebase",
-    "restore",
-    "revert",
-    "submit",
-    "topic",
-    "private",
-    "/",
-  };
-
-  interface Binder extends UiBinder<FlowPanel, Actions> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField Button cherrypick;
-  @UiField Button move;
-  @UiField Button rebase;
-  @UiField Button revert;
-  @UiField Button submit;
-
-  @UiField Button abandon;
-  private AbandonAction abandonAction;
-
-  @UiField Button deleteChange;
-
-  @UiField Button markPrivate;
-  @UiField Button unmarkPrivate;
-
-  @UiField Button restore;
-  private RestoreAction restoreAction;
-
-  @UiField Button followUp;
-  private FollowUpAction followUpAction;
-
-  private Change.Id changeId;
-  private ChangeInfo changeInfo;
-  private String revision;
-  private Project.NameKey project;
-  private String topic;
-  private String subject;
-  private String message;
-  private String branch;
-  private String key;
-
-  private boolean rebaseParentNotCurrent = true;
-
-  Actions() {
-    initWidget(uiBinder.createAndBindUi(this));
-    getElement().setId("change_actions");
-  }
-
-  void display(ChangeInfo info, String revision) {
-    this.revision = revision;
-
-    boolean hasUser = Gerrit.isSignedIn();
-    RevisionInfo revInfo = info.revision(revision);
-    CommitInfo commit = revInfo.commit();
-    changeId = info.legacyId();
-    project = info.projectNameKey();
-    topic = info.topic();
-    subject = commit.subject();
-    message = commit.message();
-    branch = info.branch();
-    key = info.changeId();
-    changeInfo = info;
-
-    initChangeActions(info, hasUser);
-
-    NativeMap<ActionInfo> actionMap =
-        revInfo.hasActions() ? revInfo.actions() : NativeMap.<ActionInfo>create();
-    actionMap.copyKeysIntoChildren("id");
-    reloadRevisionActions(actionMap);
-  }
-
-  private void initChangeActions(ChangeInfo info, boolean hasUser) {
-    NativeMap<ActionInfo> actions =
-        info.hasActions() ? info.actions() : NativeMap.<ActionInfo>create();
-    actions.copyKeysIntoChildren("id");
-
-    if (hasUser) {
-      a2b(actions, "abandon", abandon);
-      a2b(actions, "/", deleteChange);
-      a2b(actions, "move", move);
-      a2b(actions, "restore", restore);
-      a2b(actions, "revert", revert);
-      a2b(actions, "followup", followUp);
-      if (info.isPrivate()) {
-        a2b(actions, "private", unmarkPrivate);
-      } else {
-        a2b(actions, "private", markPrivate);
-      }
-      for (String id : filterNonCore(actions)) {
-        add(new ActionButton(info, actions.get(id)));
-      }
-    }
-  }
-
-  void reloadRevisionActions(NativeMap<ActionInfo> actions) {
-    if (!Gerrit.isSignedIn()) {
-      return;
-    }
-    boolean canSubmit = actions.containsKey("submit");
-    if (canSubmit) {
-      ActionInfo action = actions.get("submit");
-      submit.setTitle(action.title());
-      submit.setEnabled(action.enabled());
-      submit.setHTML(new SafeHtmlBuilder().openDiv().append(action.label()).closeDiv());
-      submit.setEnabled(action.enabled());
-    }
-    submit.setVisible(canSubmit);
-
-    a2b(actions, "cherrypick", cherrypick);
-    a2b(actions, "rebase", rebase);
-
-    // The rebase button on change screen is always enabled.
-    // It is the "Rebase" button in the RebaseDialog that might be disabled.
-    rebaseParentNotCurrent = rebase.isEnabled();
-    if (rebase.isVisible()) {
-      rebase.setEnabled(true);
-    }
-    RevisionInfo revInfo = changeInfo.revision(revision);
-    for (String id : filterNonCore(actions)) {
-      add(new ActionButton(changeInfo, revInfo, actions.get(id)));
-    }
-  }
-
-  private void add(ActionButton b) {
-    ((FlowPanel) getWidget()).add(b);
-  }
-
-  private static TreeSet<String> filterNonCore(NativeMap<ActionInfo> m) {
-    TreeSet<String> ids = new TreeSet<>(m.keySet());
-    for (String id : CORE) {
-      ids.remove(id);
-    }
-    return ids;
-  }
-
-  @UiHandler("followUp")
-  void onFollowUp(@SuppressWarnings("unused") ClickEvent e) {
-    if (followUpAction == null) {
-      followUpAction = new FollowUpAction(followUp, project.get(), branch, topic, key);
-    }
-    followUpAction.show();
-  }
-
-  @UiHandler("abandon")
-  void onAbandon(@SuppressWarnings("unused") ClickEvent e) {
-    if (abandonAction == null) {
-      abandonAction = new AbandonAction(abandon, project, changeId);
-    }
-    abandonAction.show();
-  }
-
-  @UiHandler("deleteChange")
-  void onDeleteChange(@SuppressWarnings("unused") ClickEvent e) {
-    if (Window.confirm(Resources.C.deleteChange())) {
-      ChangeActions.delete(project, changeId, deleteChange);
-    }
-  }
-
-  @UiHandler("markPrivate")
-  void onMarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
-    ChangeActions.markPrivate(project, changeId, markPrivate);
-  }
-
-  @UiHandler("unmarkPrivate")
-  void onUnmarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
-    ChangeActions.unmarkPrivate(project, changeId, unmarkPrivate);
-  }
-
-  @UiHandler("restore")
-  void onRestore(@SuppressWarnings("unused") ClickEvent e) {
-    if (restoreAction == null) {
-      restoreAction = new RestoreAction(restore, project, changeId);
-    }
-    restoreAction.show();
-  }
-
-  @UiHandler("rebase")
-  void onRebase(@SuppressWarnings("unused") ClickEvent e) {
-    RebaseAction.call(
-        rebase, project, changeInfo.branch(), changeId, revision, rebaseParentNotCurrent);
-  }
-
-  @UiHandler("submit")
-  void onSubmit(@SuppressWarnings("unused") ClickEvent e) {
-    SubmitAction.call(changeInfo, changeInfo.revision(revision));
-  }
-
-  @UiHandler("cherrypick")
-  void onCherryPick(@SuppressWarnings("unused") ClickEvent e) {
-    CherryPickAction.call(cherrypick, changeInfo, revision, project, message);
-  }
-
-  @UiHandler("move")
-  void onMove(@SuppressWarnings("unused") ClickEvent e) {
-    MoveAction.call(move, changeInfo, project);
-  }
-
-  @UiHandler("revert")
-  void onRevert(@SuppressWarnings("unused") ClickEvent e) {
-    RevertAction.call(revert, changeId, project, revision, subject);
-  }
-
-  private static void a2b(NativeMap<ActionInfo> actions, String a, Button b) {
-    if (actions.containsKey(a)) {
-      b.setVisible(true);
-      ActionInfo actionInfo = actions.get(a);
-      b.setTitle(actionInfo.title());
-      b.setEnabled(actionInfo.enabled());
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
deleted file mode 100644
index 8aeba90..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
+++ /dev/null
@@ -1,96 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:style gss='false'>
-    @def BUTTON_HEIGHT 14px;
-
-    #change_actions {
-      padding-top: 2px;
-      padding-bottom: 20px;
-    }
-
-    #change_actions button {
-      margin: 6px 3px 0 0;
-      text-align: center;
-      font-size: 8pt;
-      font-weight: bold;
-      border: 2px solid;
-      cursor: pointer;
-      color: rgba(0, 0, 0, 0.15);
-      background-color: #f5f5f5;
-      -webkit-border-radius: 2px;
-      -webkit-box-sizing: content-box;
-    }
-    #change_actions button div {
-      color: #444;
-      min-width: 54px;
-      white-space: nowrap;
-      height: BUTTON_HEIGHT;
-      line-height: BUTTON_HEIGHT;
-    }
-
-    #change_actions button.submit {
-      float: right;
-      background-color: #4d90fe;
-      background-image: -webkit-linear-gradient(top, #4d90fe, #4d90fe);
-    }
-    #change_actions button.submit div {color: #fff;}
-
-    #change_actions button:disabled {
-      font-weight: normal;
-      background-color: #999;
-      background-image: -webkit-linear-gradient(top, #999, #999);
-    }
-  </ui:style>
-
-  <g:FlowPanel>
-    <g:Button ui:field='cherrypick' styleName='' visible='false'>
-      <div><ui:msg>Cherry Pick</ui:msg></div>
-    </g:Button>
-    <g:Button ui:field='move' styleName='' visible='false'>
-      <div><ui:msg>Move Change</ui:msg></div>
-    </g:Button>
-    <g:Button ui:field='rebase' styleName='' visible='false'>
-      <div><ui:msg>Rebase</ui:msg></div>
-    </g:Button>
-    <g:Button ui:field='revert' styleName='' visible='false'>
-      <div><ui:msg>Revert</ui:msg></div>
-    </g:Button>
-    <g:Button ui:field='abandon' styleName='' visible='false'>
-      <div><ui:msg>Abandon</ui:msg></div>
-    </g:Button>
-    <g:Button ui:field='deleteChange' styleName='' visible='false'>
-      <div><ui:msg>Delete Change</ui:msg></div>
-    </g:Button>
-    <g:Button ui:field='restore' styleName='' visible='false'>
-      <div><ui:msg>Restore</ui:msg></div>
-    </g:Button>
-    <g:Button ui:field='followUp' styleName='' visible='false'>
-      <div><ui:msg>Follow-Up</ui:msg></div>
-    </g:Button>
-    <g:Button ui:field='markPrivate' styleName='' visible='false'>
-      <div><ui:msg>Mark Private</ui:msg></div>
-    </g:Button>
-    <g:Button ui:field='unmarkPrivate' styleName='' visible='false'>
-      <div><ui:msg>Unmark Private</ui:msg></div>
-    </g:Button>
-
-    <g:Button ui:field='submit' styleName='{style.submit}' visible='false'/>
-  </g:FlowPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java
deleted file mode 100644
index 2080a0e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-
-class AddFileAction {
-  private final Project.NameKey project;
-  private final Change.Id changeId;
-  private final RevisionInfo revision;
-  private final ChangeScreen.Style style;
-  private final Widget addButton;
-  private final FileTable files;
-
-  private AddFileBox addBox;
-  private PopupPanel popup;
-
-  AddFileAction(
-      Project.NameKey project,
-      Change.Id changeId,
-      RevisionInfo revision,
-      ChangeScreen.Style style,
-      Widget addButton,
-      FileTable files) {
-    this.project = project;
-    this.changeId = changeId;
-    this.revision = revision;
-    this.style = style;
-    this.addButton = addButton;
-    this.files = files;
-  }
-
-  public void onEdit() {
-    if (popup != null) {
-      popup.hide();
-      return;
-    }
-
-    files.unregisterKeys();
-    if (addBox == null) {
-      addBox = new AddFileBox(project, changeId, revision, files);
-    }
-    addBox.clearPath();
-
-    final PopupPanel p = new PopupPanel(true);
-    p.setStyleName(style.replyBox());
-    p.addAutoHidePartner(addButton.getElement());
-    p.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            if (popup == p) {
-              popup = null;
-            }
-          }
-        });
-    p.add(addBox);
-    p.showRelativeTo(addButton);
-    GlobalKey.dialog(p);
-    addBox.setFocus(true);
-    popup = p;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java
deleted file mode 100644
index cd862d2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java
+++ /dev/null
@@ -1,114 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.ui.RemoteSuggestBox;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-
-class AddFileBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, AddFileBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  private final Project.NameKey project;
-  private final Change.Id changeId;
-  private final RevisionInfo revision;
-  private final FileTable fileTable;
-
-  @UiField Button open;
-  @UiField Button cancel;
-
-  @UiField(provided = true)
-  RemoteSuggestBox path;
-
-  AddFileBox(Project.NameKey project, Change.Id changeId, RevisionInfo revision, FileTable files) {
-    this.project = project;
-    this.changeId = changeId;
-    this.revision = revision;
-    this.fileTable = files;
-
-    path = new RemoteSuggestBox(new PathSuggestOracle(project, changeId, revision));
-    path.addSelectionHandler(
-        new SelectionHandler<String>() {
-          @Override
-          public void onSelection(SelectionEvent<String> event) {
-            open(event.getSelectedItem());
-          }
-        });
-    path.addCloseHandler(
-        new CloseHandler<RemoteSuggestBox>() {
-          @Override
-          public void onClose(CloseEvent<RemoteSuggestBox> event) {
-            hide();
-            fileTable.registerKeys();
-          }
-        });
-
-    initWidget(uiBinder.createAndBindUi(this));
-  }
-
-  void setFocus(boolean focus) {
-    path.setFocus(focus);
-  }
-
-  void clearPath() {
-    path.setText("");
-  }
-
-  @UiHandler("open")
-  void onOpen(@SuppressWarnings("unused") ClickEvent e) {
-    open(path.getText());
-  }
-
-  private void open(String path) {
-    hide();
-    Gerrit.display(
-        Dispatcher.toEditScreen(project, new PatchSet.Id(changeId, revision._number()), path));
-  }
-
-  @UiHandler("cancel")
-  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    hide();
-    fileTable.registerKeys();
-  }
-
-  private void hide() {
-    for (Widget w = getParent(); w != null; w = w.getParent()) {
-      if (w instanceof PopupPanel) {
-        ((PopupPanel) w).hide();
-        break;
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.ui.xml
deleted file mode 100644
index c3539bc..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.ui.xml
+++ /dev/null
@@ -1,43 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:u='urn:import:com.google.gerrit.client.ui'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false'>
-    .cancel { float: right; }
-  </ui:style>
-  <g:HTMLPanel>
-    <div class='{res.style.section}'>
-      <ui:msg>Path: <u:RemoteSuggestBox ui:field='path' visibleLength='86'/></ui:msg>
-    </div>
-    <div class='{res.style.section}'>
-      <g:Button ui:field='open'
-          title='Open file in editor'
-          styleName='{res.style.button}'>
-        <ui:attribute name='title'/>
-        <div><ui:msg>Open</ui:msg></div>
-      </g:Button>
-      <g:Button ui:field='cancel'
-          styleName='{res.style.button}'
-          addStyleNames='{style.cancel}'>
-          <div>Cancel</div>
-      </g:Button>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
deleted file mode 100644
index a376782..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
+++ /dev/null
@@ -1,239 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.NotSignedInDialog;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.client.ui.RemoteSuggestBox;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.EventListener;
-import com.google.gwt.user.client.rpc.StatusCodeException;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.UIObject;
-
-/** Edit assignee using auto-completion. */
-public class Assignee extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, Assignee> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField Element show;
-  @UiField InlineHyperlink assigneeLink;
-  @UiField Image editAssigneeIcon;
-  @UiField Element form;
-  @UiField Element error;
-
-  @UiField(provided = true)
-  RemoteSuggestBox suggestBox;
-
-  private AssigneeSuggestOracle assigneeSuggestOracle;
-  private Change.Id changeId;
-  private Project.NameKey project;
-  private boolean canEdit;
-  private AccountInfo currentAssignee;
-
-  Assignee() {
-    assigneeSuggestOracle = new AssigneeSuggestOracle();
-    suggestBox = new RemoteSuggestBox(assigneeSuggestOracle);
-    suggestBox.setVisibleLength(55);
-    suggestBox.setHintText(Util.C.approvalTableEditAssigneeHint());
-    suggestBox.addCloseHandler(
-        new CloseHandler<RemoteSuggestBox>() {
-          @Override
-          public void onClose(CloseEvent<RemoteSuggestBox> event) {
-            Assignee.this.onCancel(null);
-          }
-        });
-    suggestBox.addSelectionHandler(
-        new SelectionHandler<String>() {
-          @Override
-          public void onSelection(SelectionEvent<String> event) {
-            editAssignee(event.getSelectedItem());
-          }
-        });
-
-    initWidget(uiBinder.createAndBindUi(this));
-    editAssigneeIcon.addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            onOpenForm();
-          }
-        },
-        ClickEvent.getType());
-  }
-
-  void set(ChangeInfo info) {
-    this.changeId = info.legacyId();
-    this.project = info.projectNameKey();
-    this.canEdit = info.hasActions() && info.actions().containsKey("assignee");
-    assigneeSuggestOracle.setChange(info);
-    setAssignee(info.assignee());
-    editAssigneeIcon.setVisible(canEdit);
-    if (!canEdit) {
-      show.setTitle(null);
-    }
-  }
-
-  void onOpenForm() {
-    UIObject.setVisible(form, true);
-    UIObject.setVisible(show, false);
-    UIObject.setVisible(error, false);
-    editAssigneeIcon.setVisible(false);
-    suggestBox.setFocus(true);
-    if (currentAssignee != null) {
-      suggestBox.setText(FormatUtil.nameEmail(currentAssignee));
-      suggestBox.selectAll();
-    } else {
-      suggestBox.setText("");
-    }
-  }
-
-  void onCloseForm() {
-    UIObject.setVisible(form, false);
-    UIObject.setVisible(show, true);
-    UIObject.setVisible(error, false);
-    editAssigneeIcon.setVisible(true);
-    suggestBox.setFocus(false);
-  }
-
-  @UiHandler("assign")
-  void onEditAssignee(@SuppressWarnings("unused") ClickEvent e) {
-    if (canEdit) {
-      editAssignee(suggestBox.getText());
-    }
-  }
-
-  @UiHandler("cancel")
-  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    onCloseForm();
-  }
-
-  private void editAssignee(String assignee) {
-    if (assignee.trim().isEmpty()) {
-      ChangeApi.deleteAssignee(
-          project.get(),
-          changeId.get(),
-          new GerritCallback<AccountInfo>() {
-            @Override
-            public void onSuccess(AccountInfo result) {
-              onCloseForm();
-              setAssignee(null);
-            }
-
-            @Override
-            public void onFailure(Throwable err) {
-              if (isSigninFailure(err)) {
-                new NotSignedInDialog().center();
-              } else {
-                UIObject.setVisible(error, true);
-                error.setInnerText(
-                    err instanceof StatusCodeException
-                        ? ((StatusCodeException) err).getEncodedResponse()
-                        : err.getMessage());
-              }
-            }
-          });
-    } else {
-      ChangeApi.setAssignee(
-          project.get(),
-          changeId.get(),
-          assignee,
-          new GerritCallback<AccountInfo>() {
-            @Override
-            public void onSuccess(AccountInfo result) {
-              onCloseForm();
-              setAssignee(result);
-              Reviewers reviewers = getReviewers();
-              if (reviewers != null) {
-                reviewers.updateReviewerList();
-              }
-            }
-
-            @Override
-            public void onFailure(Throwable err) {
-              if (isSigninFailure(err)) {
-                new NotSignedInDialog().center();
-              } else {
-                UIObject.setVisible(error, true);
-                error.setInnerText(
-                    err instanceof StatusCodeException
-                        ? ((StatusCodeException) err).getEncodedResponse()
-                        : err.getMessage());
-              }
-            }
-          });
-    }
-  }
-
-  private void setAssignee(AccountInfo assignee) {
-    currentAssignee = assignee;
-    assigneeLink.setText(assignee != null ? getName(assignee) : null);
-    assigneeLink.setTargetHistoryToken(
-        assignee != null
-            ? PageLinks.toAssigneeQuery(
-                assignee.name() != null
-                    ? assignee.name()
-                    : assignee.email() != null
-                        ? assignee.email()
-                        : String.valueOf(assignee._accountId()))
-            : "");
-  }
-
-  private Reviewers getReviewers() {
-    Element e = DOM.getParent(getElement());
-    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
-      EventListener l = DOM.getEventListener(e);
-      if (l instanceof ChangeScreen) {
-        ChangeScreen screen = (ChangeScreen) l;
-        return screen.reviewers;
-      }
-    }
-    return null;
-  }
-
-  private String getName(AccountInfo info) {
-    if (info.name() != null) {
-      return info.name();
-    }
-    if (info.email() != null) {
-      return info.email();
-    }
-    return Gerrit.info().user().anonymousCowardName();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.ui.xml
deleted file mode 100644
index d5a7239..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.ui.xml
+++ /dev/null
@@ -1,66 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:u='urn:import:com.google.gerrit.client.ui'>
-  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false'>
-    .suggestBox {
-      margin-bottom: 2px;
-    }
-
-    .error {
-      color: #D33D3D;
-      font-weight: bold;
-    }
-
-    .editAssignee,
-    .cancel {
-      cursor: pointer;
-      float: right;
-    }
-  </ui:style>
-  <g:HTMLPanel>
-    <div ui:field='show'>
-      <u:InlineHyperlink ui:field='assigneeLink'
-          title='Search for changes assigned to this user'/>
-      <g:Image ui:field='editAssigneeIcon'
-          resource='{ico.editUser}'
-          styleName='{style.editAssignee}'
-          title='Assign User to Change'/>
-    </div>
-    <div ui:field='form' style='display: none' aria-hidden='true'>
-      <u:RemoteSuggestBox ui:field='suggestBox' styleName='{style.suggestBox}'/>
-      <div ui:field='error'
-           class='{style.error}'
-           style='display: none' aria-hidden='true'/>
-      <div>
-        <g:Button ui:field='assign' styleName='{res.style.button}'>
-          <div>Assign</div>
-        </g:Button>
-        <g:Button ui:field='cancel'
-            styleName='{res.style.button}'
-            addStyleNames='{style.cancel}'>
-          <div>Cancel</div>
-        </g:Button>
-      </div>
-    </div>
-   </g:HTMLPanel>
-  </ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AssigneeSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AssigneeSuggestOracle.java
deleted file mode 100644
index c8bbfc3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AssigneeSuggestOracle.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.account.AccountApi;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.AccountSuggestOracle.AccountSuggestion;
-import com.google.gerrit.client.ui.SuggestAfterTypingNCharsOracle;
-import com.google.gwt.core.client.JsArray;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-/** REST API based suggestion Oracle for assignee */
-public class AssigneeSuggestOracle extends SuggestAfterTypingNCharsOracle {
-
-  private ChangeInfo change;
-
-  public void setChange(ChangeInfo change) {
-    this.change = change;
-  }
-
-  @Override
-  protected void _onRequestSuggestions(Request req, Callback cb) {
-    AccountApi.suggest(
-        getQuery(req),
-        req.getLimit(),
-        new GerritCallback<JsArray<AccountInfo>>() {
-          @Override
-          public void onSuccess(JsArray<AccountInfo> result) {
-            List<AccountSuggestion> r = new ArrayList<>(result.length());
-            for (AccountInfo reviewer : Natives.asList(result)) {
-              r.add(new AccountSuggestion(reviewer, req.getQuery()));
-            }
-            cb.onSuggestionsReady(req, new Response(r));
-          }
-
-          @Override
-          public void onFailure(Throwable err) {
-            List<Suggestion> r = Collections.emptyList();
-            cb.onSuggestionsReady(req, new Response(r));
-          }
-        });
-  }
-
-  private String getQuery(Request req) {
-    StringBuilder query = new StringBuilder();
-    query.append(req.getQuery());
-    if (change != null) {
-      query.append(" cansee:").append(change._number());
-    }
-    return query.toString();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
deleted file mode 100644
index 0bc74e4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Button;
-
-public class ChangeActions {
-
-  static void delete(Project.NameKey project, Change.Id id, Button... draftButtons) {
-    ChangeApi.deleteChange(project.get(), id.get(), mine(draftButtons));
-  }
-
-  static void markPrivate(Project.NameKey project, Change.Id id, Button... draftButtons) {
-    ChangeApi.markPrivate(project.get(), id.get(), cs(project, id, draftButtons));
-  }
-
-  static void unmarkPrivate(Project.NameKey project, Change.Id id, Button... draftButtons) {
-    ChangeApi.unmarkPrivate(project.get(), id.get(), cs(project, id, draftButtons));
-  }
-
-  public static GerritCallback<JavaScriptObject> cs(
-      Project.NameKey project, final Change.Id id, Button... draftButtons) {
-    setEnabled(false, draftButtons);
-    return new GerritCallback<JavaScriptObject>() {
-      @Override
-      public void onSuccess(JavaScriptObject result) {
-        Gerrit.display(PageLinks.toChange(project, id));
-      }
-
-      @Override
-      public void onFailure(Throwable err) {
-        setEnabled(true, draftButtons);
-        if (SubmitFailureDialog.isConflict(err)) {
-          new SubmitFailureDialog(err.getMessage()).center();
-          Gerrit.display(PageLinks.toChange(project, id));
-        } else {
-          super.onFailure(err);
-        }
-      }
-    };
-  }
-
-  private static AsyncCallback<JavaScriptObject> mine(Button... draftButtons) {
-    setEnabled(false, draftButtons);
-    return new GerritCallback<JavaScriptObject>() {
-      @Override
-      public void onSuccess(JavaScriptObject result) {
-        Gerrit.display(PageLinks.MINE);
-      }
-
-      @Override
-      public void onFailure(Throwable err) {
-        setEnabled(true, draftButtons);
-        if (SubmitFailureDialog.isConflict(err)) {
-          new SubmitFailureDialog(err.getMessage()).center();
-          Gerrit.display(PageLinks.MINE);
-        } else {
-          super.onFailure(err);
-        }
-      }
-    };
-  }
-
-  private static void setEnabled(boolean enabled, Button... draftButtons) {
-    if (draftButtons != null) {
-      for (Button b : draftButtons) {
-        b.setEnabled(enabled);
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
deleted file mode 100644
index ed67846..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gwt.i18n.client.Constants;
-
-public interface ChangeConstants extends Constants {
-  String previousChange();
-
-  String nextChange();
-
-  String openChange();
-
-  String reviewedFileTitle();
-
-  String editFileInline();
-
-  String removeFileInline();
-
-  String restoreFileInline();
-
-  String openLastFile();
-
-  String openCommitMessage();
-
-  String patchSet();
-
-  String commit();
-
-  String date();
-
-  String author();
-
-  String notAvailable();
-
-  String relatedChanges();
-
-  String relatedChangesTooltip();
-
-  String conflictingChanges();
-
-  String conflictingChangesTooltip();
-
-  String cherryPicks();
-
-  String cherryPicksTooltip();
-
-  String sameTopic();
-
-  String sameTopicTooltip();
-
-  String submittedTogether();
-
-  String submittedTogetherTooltip();
-
-  String noChanges();
-
-  String indirectAncestor();
-
-  String merged();
-
-  String abandoned();
-
-  String deleteChangeEdit();
-
-  String deleteChange();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
deleted file mode 100644
index 9000149..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
+++ /dev/null
@@ -1,36 +0,0 @@
-previousChange = Previous related change
-nextChange = Next related change
-openChange = Open related change
-reviewedFileTitle = Mark file as reviewed (Shortcut: r)
-editFileInline = Edit file inline
-removeFileInline = Remove file inline
-restoreFileInline = Restore file inline
-
-openLastFile = Open last file
-openCommitMessage = Open commit message
-
-patchSet = Patch Set
-commit = Commit
-date = Date
-author = Author / Committer
-
-notAvailable = N/A
-relatedChanges = Related Changes
-relatedChangesTooltip = Same branch changes connected by Git history
-conflictingChanges = Conflicts With
-conflictingChangesTooltip = Open changes that conflict with this change
-cherryPicks = Cherry-Picks
-cherryPicksTooltip = Changes with the same Change-Id
-sameTopic = Same Topic
-sameTopicTooltip = Changes with the same topic
-submittedTogether = Submitted Together
-submittedTogetherTooltip = Changes submitted together with this change
-noChanges = No Changes
-indirectAncestor = Indirect ancestor
-merged = Merged
-abandoned = Abandoned
-
-deleteChangeEdit = Delete Change Edit?\n\
-  \n\
-  All changes made in the edit revision will be lost.
-deleteChange = Delete Change?
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java
deleted file mode 100644
index 4eead56..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gwt.i18n.client.Messages;
-
-public interface ChangeMessages extends Messages {
-  String patchSets(String currentlyViewedPatchSet, int currentPatchSet);
-
-  String changeWithNoRevisions(int changeId);
-
-  String relatedChanges(int count);
-
-  String relatedChanges(String count);
-
-  String conflictingChanges(int count);
-
-  String conflictingChanges(String count);
-
-  String cherryPicks(int count);
-
-  String cherryPicks(String count);
-
-  String sameTopic(int count);
-
-  String sameTopic(String count);
-
-  String submittedTogether(int count);
-
-  String submittedTogether(String count);
-
-  String editPatchSet(int patchSet);
-
-  String failedToLoadFileList(String error);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties
deleted file mode 100644
index 743945d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties
+++ /dev/null
@@ -1,9 +0,0 @@
-patchSets = Patch Sets ({0}/{1})
-changeWithNoRevisions = Cannot display change {0} because it has no revisions.
-relatedChanges = Related Changes ({0})
-conflictingChanges = Conflicts With ({0})
-cherryPicks = Cherry-Picks ({0})
-sameTopic = Same Topic ({0})
-submittedTogether = Submitted Together ({0})
-editPatchSet = edit:{0}
-failedToLoadFileList = Failed to load file list: {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
deleted file mode 100644
index cb6fe28..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ /dev/null
@@ -1,1651 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.AvatarImage;
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gerrit.client.NotFoundScreen;
-import com.google.gerrit.client.api.ChangeGlue;
-import com.google.gerrit.client.api.ExtensionPanel;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ChangeList;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.changes.QueryScreen;
-import com.google.gerrit.client.changes.RevisionInfoCache;
-import com.google.gerrit.client.changes.StarredChanges;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.diff.DiffApi;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.AccountInfo.AvatarInfo;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.client.info.ChangeInfo.LabelInfo;
-import com.google.gerrit.client.info.ChangeInfo.MessageInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.info.FileInfo;
-import com.google.gerrit.client.info.GpgKeyInfo;
-import com.google.gerrit.client.info.PushCertificateInfo;
-import com.google.gerrit.client.projects.ConfigInfoCache;
-import com.google.gerrit.client.projects.ConfigInfoCache.Entry;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.BranchLink;
-import com.google.gerrit.client.ui.ChangeLink;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.client.ui.Hyperlink;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.client.ui.UserActivityMonitor;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.AnchorElement;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.dom.client.SelectElement;
-import com.google.gwt.dom.client.Style.Display;
-import com.google.gwt.event.dom.client.ChangeEvent;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.EventListener;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.Panel;
-import com.google.gwt.user.client.ui.SimplePanel;
-import com.google.gwt.user.client.ui.ToggleButton;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import com.google.gwtorm.client.KeyUtil;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import net.codemirror.lib.CodeMirror;
-
-public class ChangeScreen extends Screen {
-  private static final Logger logger = Logger.getLogger(ChangeScreen.class.getName());
-
-  interface Binder extends UiBinder<HTMLPanel, ChangeScreen> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface Style extends CssResource {
-    String avatar();
-
-    String hashtagName();
-
-    String hashtagIcon();
-
-    String highlight();
-
-    String labelName();
-
-    String label_may();
-
-    String label_need();
-
-    String label_ok();
-
-    String label_reject();
-
-    String label_user();
-
-    String pushCertStatus();
-
-    String replyBox();
-
-    String selected();
-
-    String notCurrentPatchSet();
-  }
-
-  static ChangeScreen get(NativeEvent in) {
-    Element e = in.getEventTarget().cast();
-    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
-      EventListener l = DOM.getEventListener(e);
-      if (l instanceof ChangeScreen) {
-        return (ChangeScreen) l;
-      }
-    }
-    return null;
-  }
-
-  private final Change.Id changeId;
-  @Nullable private Project.NameKey project;
-  private DiffObject base;
-  private String revision;
-  private ChangeInfo changeInfo;
-  private boolean hasDraftComments;
-  private CommentLinkProcessor commentLinkProcessor;
-  private EditInfo edit;
-  private LocalComments lc;
-
-  private List<HandlerRegistration> handlers = new ArrayList<>(4);
-  private UpdateCheckTimer updateCheck;
-  private Timestamp lastDisplayedUpdate;
-  private UpdateAvailableBar updateAvailable;
-  private boolean openReplyBox;
-  private boolean loaded;
-  private FileTable.Mode fileTableMode;
-
-  @UiField HTMLPanel headerLine;
-  @UiField SimplePanel headerExtension;
-  @UiField SimplePanel headerExtensionMiddle;
-  @UiField SimplePanel headerExtensionRight;
-  @UiField Style style;
-  @UiField ToggleButton star;
-  @UiField Anchor permalink;
-
-  @UiField Assignee assignee;
-  @UiField Element assigneeRow;
-  @UiField Element ccText;
-  @UiField Reviewers reviewers;
-  @UiField Hashtags hashtags;
-  @UiField Element hashtagTableRow;
-
-  @UiField FlowPanel ownerPanel;
-  @UiField InlineHyperlink ownerLink;
-
-  @UiField Element uploaderRow;
-  @UiField FlowPanel uploaderPanel;
-  @UiField InlineLabel uploaderName;
-
-  @UiField Element statusText;
-  @UiField Element privateText;
-  @UiField Element wipText;
-  @UiField Image projectSettings;
-  @UiField AnchorElement projectSettingsLink;
-  @UiField InlineHyperlink projectDashboard;
-  @UiField InlineHyperlink branchLink;
-  @UiField Element strategy;
-  @UiField Element submitActionText;
-  @UiField Element notMergeable;
-  @UiField Topic topic;
-  @UiField Element actionText;
-  @UiField Element actionDate;
-  @UiField SimplePanel changeExtension;
-  @UiField SimplePanel relatedExtension;
-  @UiField SimplePanel commitExtension;
-
-  @UiField Actions actions;
-  @UiField Labels labels;
-  @UiField CommitBox commit;
-  @UiField RelatedChanges related;
-  @UiField FileTable files;
-  @UiField ListBox diffBase;
-  @UiField History history;
-  @UiField SimplePanel historyExtensionRight;
-
-  @UiField Button includedIn;
-  @UiField Button patchSets;
-  @UiField Element patchSetsText;
-  @UiField Button download;
-  @UiField Button reply;
-  @UiField Button publishEdit;
-  @UiField Button rebaseEdit;
-  @UiField Button deleteEdit;
-  @UiField Button openAll;
-  @UiField Button editMode;
-  @UiField Button reviewMode;
-  @UiField Button addFile;
-  @UiField Button deleteFile;
-  @UiField Button renameFile;
-  @UiField Button expandAll;
-  @UiField Button collapseAll;
-  @UiField Button hideTaggedComments;
-  @UiField Button showTaggedComments;
-  @UiField QuickApprove quickApprove;
-
-  private ReplyAction replyAction;
-  private IncludedInAction includedInAction;
-  private PatchSetsAction patchSetsAction;
-  private DownloadAction downloadAction;
-  private AddFileAction addFileAction;
-  private DeleteFileAction deleteFileAction;
-  private RenameFileAction renameFileAction;
-
-  public ChangeScreen(
-      @Nullable Project.NameKey project,
-      Change.Id changeId,
-      DiffObject base,
-      String revision,
-      boolean openReplyBox,
-      FileTable.Mode mode) {
-    this.project = project;
-    this.changeId = changeId;
-    this.base = base;
-    this.revision = normalize(revision);
-    this.openReplyBox = openReplyBox;
-    this.fileTableMode = mode;
-    this.lc = new LocalComments(project, changeId);
-    add(uiBinder.createAndBindUi(this));
-  }
-
-  public Project.NameKey getProject() {
-    return project;
-  }
-
-  PatchSet.Id getPatchSetId() {
-    return new PatchSet.Id(changeInfo.legacyId(), changeInfo.revisions().get(revision)._number());
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    loadChangeScreen();
-  }
-
-  private void loadChangeScreen() {
-    if (project == null) {
-      // Load the project if it is not already present. This is the case when the user used a URL
-      // that doesn't include the project. Setting it here will rewrite the URL token to include the
-      // project (visible to the user) and all future API calls made from the change screen will use
-      // project/+/changeId to identify the change.
-      String query = "change:" + changeId.get();
-      ChangeList.query(
-          query,
-          Collections.emptySet(),
-          new AsyncCallback<ChangeList>() {
-            @Override
-            public void onSuccess(ChangeList result) {
-              if (result.length() == 0) {
-                Gerrit.display(getToken(), new NotFoundScreen());
-              } else if (result.length() > 1) {
-                Gerrit.display(PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
-              } else {
-                // Initialize current screen with newly obtained project
-                project = result.get(0).projectNameKey();
-                loadChangeScreen();
-              }
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              GerritCallback.showFailure(caught);
-            }
-          });
-
-      return;
-    }
-    CallbackGroup group = new CallbackGroup();
-    if (Gerrit.isSignedIn()) {
-      ChangeList.query(
-          "change:" + changeId.get() + " has:draft",
-          Collections.emptySet(),
-          group.add(
-              new AsyncCallback<ChangeList>() {
-                @Override
-                public void onSuccess(ChangeList result) {
-                  hasDraftComments = result.length() > 0;
-                }
-
-                @Override
-                public void onFailure(Throwable caught) {}
-              }));
-      ChangeApi.editWithFiles(
-          Project.NameKey.asStringOrNull(project),
-          changeId.get(),
-          group.add(
-              new AsyncCallback<EditInfo>() {
-                @Override
-                public void onSuccess(EditInfo result) {
-                  edit = result;
-                }
-
-                @Override
-                public void onFailure(Throwable caught) {}
-              }));
-    }
-    loadChangeInfo(
-        true,
-        group.addFinal(
-            new GerritCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo info) {
-                info.init();
-                initCurrentRevision(info);
-                final RevisionInfo rev = info.revision(revision);
-                CallbackGroup group = new CallbackGroup();
-                loadCommit(rev, group);
-
-                group.addListener(
-                    new GerritCallback<Void>() {
-                      @Override
-                      public void onSuccess(Void result) {
-                        if (base.isBase() && rev.isMerge()) {
-                          base =
-                              DiffObject.parse(
-                                  info.legacyId(),
-                                  Gerrit.getUserPreferences().defaultBaseForMerges().getBase());
-                        }
-                        loadConfigInfo(info, base);
-                        JsArray<MessageInfo> mAr = info.messages();
-                        for (int i = 0; i < mAr.length(); i++) {
-                          if (mAr.get(i).tag() != null) {
-                            hideTaggedComments.setVisible(true);
-                            break;
-                          }
-                        }
-                      }
-                    });
-                group.done();
-              }
-            }));
-  }
-
-  private RevisionInfo initCurrentRevision(ChangeInfo info) {
-    info.revisions().copyKeysIntoChildren("name");
-    if (edit != null) {
-      edit.setName(edit.commit().commit());
-      info.setEdit(edit);
-      if (edit.hasFiles()) {
-        edit.files().copyKeysIntoChildren("path");
-      }
-      info.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
-      JsArray<RevisionInfo> list = info.revisions().values();
-
-      // Edit is converted to a regular revision (with number = 0) and
-      // added to the list of revisions. Additionally under certain
-      // circumstances change edit is assigned to be the current revision
-      // and is selected to be shown on the change screen.
-      // We have two different strategies to assign edit to the current ps:
-      // 1. revision == null: no revision is selected, so use the edit only
-      //    if it is based on the latest patch set
-      // 2. edit was selected explicitly from ps drop down:
-      //    use the edit regardless of which patch set it is based on
-      if (revision == null) {
-        RevisionInfo.sortRevisionInfoByNumber(list);
-        RevisionInfo rev = list.get(list.length() - 1);
-        if (rev.isEdit()) {
-          info.setCurrentRevision(rev.name());
-        }
-      } else if (revision.equals("edit") || revision.equals("0")) {
-        for (int i = 0; i < list.length(); i++) {
-          RevisionInfo r = list.get(i);
-          if (r.isEdit()) {
-            info.setCurrentRevision(r.name());
-            break;
-          }
-        }
-      }
-    }
-    return resolveRevisionToDisplay(info);
-  }
-
-  private void addExtensionPoints(ChangeInfo change, RevisionInfo rev, Entry result) {
-    addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER, headerExtension, change, rev);
-    addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS,
-        headerExtensionMiddle,
-        change,
-        rev);
-    addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_POP_DOWNS,
-        headerExtensionRight,
-        change,
-        rev);
-    addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
-        changeExtension,
-        change,
-        rev,
-        result.getExtensionPanelNames(
-            GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK.toString()));
-    addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
-        relatedExtension,
-        change,
-        rev);
-    addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK, commitExtension, change, rev);
-    addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS,
-        historyExtensionRight,
-        change,
-        rev);
-  }
-
-  private void addExtensionPoint(
-      GerritUiExtensionPoint extensionPoint,
-      Panel p,
-      ChangeInfo change,
-      RevisionInfo rev,
-      List<String> panelNames) {
-    ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint, panelNames);
-    extensionPanel.putObject(GerritUiExtensionPoint.Key.CHANGE_INFO, change);
-    extensionPanel.putObject(GerritUiExtensionPoint.Key.REVISION_INFO, rev);
-    p.add(extensionPanel);
-  }
-
-  private void addExtensionPoint(
-      GerritUiExtensionPoint extensionPoint, Panel p, ChangeInfo change, RevisionInfo rev) {
-    addExtensionPoint(extensionPoint, p, change, rev, Collections.emptyList());
-  }
-
-  private boolean enableSignedPush() {
-    return Gerrit.info().receive().enableSignedPush();
-  }
-
-  void loadChangeInfo(boolean fg, AsyncCallback<ChangeInfo> cb) {
-    RestApi call = ChangeApi.detail(Project.NameKey.asStringOrNull(project), changeId.get());
-    EnumSet<ListChangesOption> opts =
-        EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.CHANGE_ACTIONS);
-    if (enableSignedPush()) {
-      opts.add(ListChangesOption.PUSH_CERTIFICATES);
-    }
-    ChangeList.addOptions(call, opts);
-    if (!fg) {
-      call.background();
-    }
-    call.get(cb);
-  }
-
-  void loadRevisionInfo() {
-    RestApi call = ChangeApi.actions(getProject().get(), changeId.get(), revision);
-    call.background();
-    call.get(
-        new GerritCallback<NativeMap<ActionInfo>>() {
-          @Override
-          public void onSuccess(NativeMap<ActionInfo> actionMap) {
-            actionMap.copyKeysIntoChildren("id");
-            renderRevisionInfo(changeInfo, actionMap);
-          }
-        });
-  }
-
-  @Override
-  protected void onUnload() {
-    if (replyAction != null) {
-      replyAction.hide();
-    }
-    if (updateCheck != null) {
-      updateCheck.cancel();
-      updateCheck = null;
-    }
-    for (HandlerRegistration h : handlers) {
-      h.removeHandler();
-    }
-    handlers.clear();
-    super.onUnload();
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setHeaderVisible(false);
-    Resources.I.style().ensureInjected();
-    star.setVisible(Gerrit.isSignedIn());
-    labels.init(style);
-    reviewers.init(style, ccText);
-    hashtags.init(style);
-  }
-
-  private void initReplyButton(ChangeInfo info, String revision) {
-    if (!info.revision(revision).isEdit()) {
-      reply.setTitle(Gerrit.info().change().replyLabel());
-      reply.setHTML(
-          new SafeHtmlBuilder().openDiv().append(Gerrit.info().change().replyLabel()).closeDiv());
-      if (hasDraftComments || lc.hasReplyComment()) {
-        reply.setStyleName(style.highlight());
-      }
-      reply.setVisible(true);
-    }
-  }
-
-  private void gotoSibling(int offset) {
-    if (offset > 0
-        && changeInfo.currentRevision() != null
-        && changeInfo.currentRevision().equals(revision)) {
-      return;
-    }
-
-    if (offset < 0 && changeInfo.revision(revision)._number() == 1) {
-      return;
-    }
-
-    JsArray<RevisionInfo> revisions = changeInfo.revisions().values();
-    RevisionInfo.sortRevisionInfoByNumber(revisions);
-    for (int i = 0; i < revisions.length(); i++) {
-      if (revision.equals(revisions.get(i).name())) {
-        if (0 <= i + offset && i + offset < revisions.length()) {
-          Gerrit.display(
-              PageLinks.toChange(
-                  project,
-                  new PatchSet.Id(changeInfo.legacyId(), revisions.get(i + offset)._number())));
-          return;
-        }
-        return;
-      }
-    }
-  }
-
-  private void initIncludedInAction(ChangeInfo info) {
-    if (info.status() == Status.MERGED) {
-      includedInAction =
-          new IncludedInAction(
-              info.projectNameKey(), info.legacyId(), style, headerLine, includedIn);
-      includedIn.setVisible(true);
-    }
-  }
-
-  private void updatePatchSetsTextStyle(boolean isPatchSetCurrent) {
-    if (isPatchSetCurrent) {
-      patchSetsText.removeClassName(style.notCurrentPatchSet());
-    } else {
-      patchSetsText.addClassName(style.notCurrentPatchSet());
-    }
-  }
-
-  private void initRevisionsAction(ChangeInfo info, String revision) {
-    int currentPatchSet;
-    if (info.currentRevision() != null && info.revisions().containsKey(info.currentRevision())) {
-      currentPatchSet = info.revision(info.currentRevision())._number();
-    } else {
-      JsArray<RevisionInfo> revList = info.revisions().values();
-      RevisionInfo.sortRevisionInfoByNumber(revList);
-      currentPatchSet = revList.get(revList.length() - 1)._number();
-    }
-
-    String currentlyViewedPatchSet;
-    boolean isPatchSetCurrent = true;
-    String revisionId = info.revision(revision).id();
-    if (revisionId.equals("edit")) {
-      currentlyViewedPatchSet =
-          Resources.M.editPatchSet(RevisionInfo.findEditParent(info.revisions().values()));
-      currentPatchSet = info.revisions().values().length() - 1;
-    } else {
-      currentlyViewedPatchSet = revisionId;
-      if (!currentlyViewedPatchSet.equals(Integer.toString(currentPatchSet))) {
-        isPatchSetCurrent = false;
-      }
-    }
-    patchSetsText.setInnerText(Resources.M.patchSets(currentlyViewedPatchSet, currentPatchSet));
-    updatePatchSetsTextStyle(isPatchSetCurrent);
-    patchSetsAction =
-        new PatchSetsAction(
-            info.projectNameKey(), info.legacyId(), revision, edit, style, headerLine, patchSets);
-  }
-
-  private void initDownloadAction(ChangeInfo info, String revision) {
-    downloadAction = new DownloadAction(info, revision, style, headerLine, download);
-  }
-
-  private void initProjectLinks(ChangeInfo info) {
-    projectSettingsLink.setHref("#" + PageLinks.toProject(info.projectNameKey()));
-    projectSettings.addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            if (Hyperlink.impl.handleAsClick((Event) event.getNativeEvent())) {
-              event.stopPropagation();
-              event.preventDefault();
-              Gerrit.display(PageLinks.toProject(info.projectNameKey()));
-            }
-          }
-        },
-        ClickEvent.getType());
-    projectDashboard.setText(info.project());
-    projectDashboard.setTargetHistoryToken(
-        PageLinks.toProjectDefaultDashboard(info.projectNameKey()));
-  }
-
-  private void initBranchLink(ChangeInfo info) {
-    branchLink.setText(info.branch());
-    branchLink.setTargetHistoryToken(
-        PageLinks.toChangeQuery(
-            BranchLink.query(info.projectNameKey(), info.status(), info.branch(), null)));
-  }
-
-  private void initEditMode(ChangeInfo info, String revision) {
-    if (Gerrit.isSignedIn()) {
-      RevisionInfo rev = info.revision(revision);
-      if (info.status().isOpen()) {
-        if (isEditModeEnabled(info, rev)) {
-          editMode.setVisible(fileTableMode == FileTable.Mode.REVIEW);
-          addFile.setVisible(!editMode.isVisible());
-          deleteFile.setVisible(!editMode.isVisible());
-          renameFile.setVisible(!editMode.isVisible());
-          reviewMode.setVisible(!editMode.isVisible());
-          addFileAction =
-              new AddFileAction(
-                  info.projectNameKey(), changeId, info.revision(revision), style, addFile, files);
-          deleteFileAction =
-              new DeleteFileAction(
-                  info.projectNameKey(), changeId, info.revision(revision), style, addFile);
-          renameFileAction =
-              new RenameFileAction(
-                  info.projectNameKey(), changeId, info.revision(revision), style, addFile);
-        } else {
-          editMode.setVisible(false);
-          addFile.setVisible(false);
-          reviewMode.setVisible(false);
-        }
-
-        if (rev.isEdit()) {
-          if (info.hasEditBasedOnCurrentPatchSet()) {
-            publishEdit.setVisible(true);
-          } else {
-            rebaseEdit.setVisible(true);
-          }
-          deleteEdit.setVisible(true);
-        }
-      } else if (rev.isEdit()) {
-        deleteEdit.setStyleName(style.highlight());
-        deleteEdit.setVisible(true);
-      }
-    }
-  }
-
-  private boolean isEditModeEnabled(ChangeInfo info, RevisionInfo rev) {
-    if (rev.isEdit()) {
-      return true;
-    }
-    if (edit == null) {
-      return revision.equals(info.currentRevision());
-    }
-    return rev._number() == RevisionInfo.findEditParent(info.revisions().values());
-  }
-
-  @UiHandler("publishEdit")
-  void onPublishEdit(@SuppressWarnings("unused") ClickEvent e) {
-    EditActions.publishEdit(getProject(), changeId, publishEdit, rebaseEdit, deleteEdit);
-  }
-
-  @UiHandler("rebaseEdit")
-  void onRebaseEdit(@SuppressWarnings("unused") ClickEvent e) {
-    EditActions.rebaseEdit(getProject(), changeId, publishEdit, rebaseEdit, deleteEdit);
-  }
-
-  @UiHandler("deleteEdit")
-  void onDeleteEdit(@SuppressWarnings("unused") ClickEvent e) {
-    if (Window.confirm(Resources.C.deleteChangeEdit())) {
-      EditActions.deleteEdit(getProject(), changeId, publishEdit, rebaseEdit, deleteEdit);
-    }
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-
-    KeyCommandSet keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
-    keysNavigation.add(
-        new KeyCommand(0, 'u', Util.C.upToChangeList()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            Gerrit.displayLastChangeList();
-          }
-        });
-    keysNavigation.add(
-        new KeyCommand(0, 'R', Util.C.keyReloadChange()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            Gerrit.display(PageLinks.toChange(project, changeId));
-          }
-        });
-    keysNavigation.add(
-        new KeyCommand(0, 'n', Util.C.keyNextPatchSet()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            gotoSibling(1);
-          }
-        },
-        new KeyCommand(0, 'p', Util.C.keyPreviousPatchSet()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            gotoSibling(-1);
-          }
-        });
-    handlers.add(GlobalKey.add(this, keysNavigation));
-
-    KeyCommandSet keysAction = new KeyCommandSet(Gerrit.C.sectionActions());
-    keysAction.add(
-        new KeyCommand(0, 'a', Util.C.keyPublishComments()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (Gerrit.isSignedIn()) {
-              onReply(null);
-            } else {
-              Gerrit.doSignIn(getToken());
-            }
-          }
-        });
-    keysAction.add(
-        new KeyCommand(0, 'x', Util.C.keyExpandAllMessages()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            onExpandAll(null);
-          }
-        });
-    keysAction.add(
-        new KeyCommand(0, 'z', Util.C.keyCollapseAllMessages()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            onCollapseAll(null);
-          }
-        });
-    keysAction.add(
-        new KeyCommand(0, 's', Util.C.changeTableStar()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (Gerrit.isSignedIn()) {
-              star.setValue(!star.getValue(), true);
-            } else {
-              Gerrit.doSignIn(getToken());
-            }
-          }
-        });
-    keysAction.add(
-        new KeyCommand(0, 'c', Util.C.keyAddReviewers()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (Gerrit.isSignedIn()) {
-              reviewers.onOpenForm();
-            } else {
-              Gerrit.doSignIn(getToken());
-            }
-          }
-        });
-    keysAction.add(
-        new KeyCommand(0, 't', Util.C.keyEditTopic()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (Gerrit.isSignedIn()) {
-              // In Firefox this event is mistakenly called when F5 is pressed so
-              // differentiate F5 from 't' by checking the charCode(F5=0, t=116).
-              if (event.getNativeEvent().getCharCode() == 0) {
-                Window.Location.reload();
-                return;
-              }
-              if (topic.canEdit()) {
-                topic.onEdit();
-              }
-            } else {
-              Gerrit.doSignIn(getToken());
-            }
-          }
-        });
-    handlers.add(GlobalKey.add(this, keysAction));
-    files.registerKeys();
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    commit.onShowView();
-    related.setMaxHeight(commit.getElement().getParentElement().getOffsetHeight());
-
-    if (openReplyBox) {
-      onReply();
-    } else {
-      String prior = Gerrit.getPriorView();
-      if (prior != null && prior.startsWith("/c/")) {
-        scrollToPath(prior.substring(3));
-      }
-    }
-
-    ChangeGlue.fireShowChange(changeInfo, changeInfo.revision(revision));
-    CodeMirror.preload();
-    startPoller();
-  }
-
-  private void scrollToPath(String token) {
-    ProjectChangeId cId;
-    try {
-      cId = ProjectChangeId.create(token);
-    } catch (IllegalArgumentException e) {
-      // Scrolling is best-effort.
-      return;
-    }
-    if (!changeId.equals(cId.getChangeId())) {
-      return; // Unrelated URL, do not scroll.
-    }
-
-    // Extract the start of a file path. The patch set is always contained in the URL and separated
-    // by from the changeId by a forward slash. Example: /c/project/+/123/1/folder/file.txt
-    int s = token.indexOf('/', cId.identifierLength() + 1);
-    if (s < 0) {
-      return; // URL does not name a file.
-    }
-
-    int c = token.lastIndexOf(',');
-    if (0 <= c) {
-      token = token.substring(s + 1, c);
-    } else {
-      token = token.substring(s + 1);
-    }
-
-    if (!token.isEmpty()) {
-      files.scrollToPath(KeyUtil.decode(token));
-    }
-  }
-
-  @UiHandler("star")
-  void onToggleStar(ValueChangeEvent<Boolean> e) {
-    StarredChanges.toggleStar(changeId, e.getValue());
-  }
-
-  @UiHandler("includedIn")
-  void onIncludedIn(@SuppressWarnings("unused") ClickEvent e) {
-    includedInAction.show();
-  }
-
-  @UiHandler("download")
-  void onDownload(@SuppressWarnings("unused") ClickEvent e) {
-    downloadAction.show();
-  }
-
-  @UiHandler("patchSets")
-  void onPatchSets(@SuppressWarnings("unused") ClickEvent e) {
-    patchSetsAction.show();
-  }
-
-  @UiHandler("reply")
-  void onReply(@SuppressWarnings("unused") ClickEvent e) {
-    onReply();
-  }
-
-  @UiHandler("permalink")
-  void onReload(ClickEvent e) {
-    e.preventDefault();
-    Gerrit.display(PageLinks.toChange(project, changeId));
-  }
-
-  private void onReply() {
-    if (Gerrit.isSignedIn()) {
-      replyAction.onReply(null);
-    } else {
-      Gerrit.doSignIn(getToken());
-    }
-  }
-
-  @UiHandler("openAll")
-  void onOpenAll(@SuppressWarnings("unused") ClickEvent e) {
-    files.openAll();
-  }
-
-  @UiHandler("editMode")
-  void onEditMode(@SuppressWarnings("unused") ClickEvent e) {
-    fileTableMode = FileTable.Mode.EDIT;
-    refreshFileTable();
-    editMode.setVisible(false);
-    addFile.setVisible(true);
-    deleteFile.setVisible(true);
-    renameFile.setVisible(true);
-    reviewMode.setVisible(true);
-  }
-
-  @UiHandler("reviewMode")
-  void onReviewMode(@SuppressWarnings("unused") ClickEvent e) {
-    fileTableMode = FileTable.Mode.REVIEW;
-    refreshFileTable();
-    editMode.setVisible(true);
-    addFile.setVisible(false);
-    deleteFile.setVisible(false);
-    renameFile.setVisible(false);
-    reviewMode.setVisible(false);
-  }
-
-  @UiHandler("addFile")
-  void onAddFile(@SuppressWarnings("unused") ClickEvent e) {
-    addFileAction.onEdit();
-  }
-
-  @UiHandler("deleteFile")
-  void onDeleteFile(@SuppressWarnings("unused") ClickEvent e) {
-    deleteFileAction.onDelete();
-  }
-
-  @UiHandler("renameFile")
-  void onRenameFile(@SuppressWarnings("unused") ClickEvent e) {
-    renameFileAction.onRename();
-  }
-
-  private void refreshFileTable() {
-    int idx = diffBase.getSelectedIndex();
-    if (0 <= idx) {
-      String n = diffBase.getValue(idx);
-      loadConfigInfo(changeInfo, DiffObject.parse(changeInfo.legacyId(), n));
-    }
-  }
-
-  @UiHandler("showTaggedComments")
-  void onShowTaggedComments(@SuppressWarnings("unused") ClickEvent e) {
-    showTaggedComments.setVisible(false);
-    hideTaggedComments.setVisible(true);
-    int n = history.getWidgetCount();
-    for (int i = 0; i < n; i++) {
-      Message m = ((Message) history.getWidget(i));
-      m.setVisible(true);
-    }
-  }
-
-  @UiHandler("hideTaggedComments")
-  void onHideTaggedComments(@SuppressWarnings("unused") ClickEvent e) {
-    hideTaggedComments.setVisible(false);
-    showTaggedComments.setVisible(true);
-    int n = history.getWidgetCount();
-    for (int i = 0; i < n; i++) {
-      Message m = ((Message) history.getWidget(i));
-      if (m.getMessageInfo().tag() != null) {
-        m.setVisible(false);
-      }
-    }
-  }
-
-  @UiHandler("expandAll")
-  void onExpandAll(@SuppressWarnings("unused") ClickEvent e) {
-    int n = history.getWidgetCount();
-    for (int i = 0; i < n; i++) {
-      ((Message) history.getWidget(i)).setOpen(true);
-    }
-    expandAll.setVisible(false);
-    collapseAll.setVisible(true);
-  }
-
-  @UiHandler("collapseAll")
-  void onCollapseAll(@SuppressWarnings("unused") ClickEvent e) {
-    int n = history.getWidgetCount();
-    for (int i = 0; i < n; i++) {
-      ((Message) history.getWidget(i)).setOpen(false);
-    }
-    expandAll.setVisible(true);
-    collapseAll.setVisible(false);
-  }
-
-  @UiHandler("diffBase")
-  void onChangeRevision(@SuppressWarnings("unused") ChangeEvent e) {
-    int idx = diffBase.getSelectedIndex();
-    if (0 <= idx) {
-      String n = diffBase.getValue(idx);
-      loadConfigInfo(changeInfo, DiffObject.parse(changeInfo.legacyId(), n));
-    }
-  }
-
-  private void loadConfigInfo(ChangeInfo info, DiffObject base) {
-    final RevisionInfo rev = info.revision(revision);
-    if (base.isAutoMerge() && !initCurrentRevision(info).isMerge()) {
-      Gerrit.display(getToken(), new NotFoundScreen());
-    }
-
-    updateToken(info, base, rev);
-
-    RevisionInfo baseRev = resolveRevisionOrPatchSetId(info, base.asString(), null);
-
-    CallbackGroup group = new CallbackGroup();
-    Timestamp lastReply = myLastReply(info);
-    if (rev.isEdit()) {
-      // Comments are filtered for the current revision. Use parent
-      // patch set for edits, as edits themself can never have comments.
-      RevisionInfo p = RevisionInfo.findEditParentRevision(info.revisions().values());
-      List<NativeMap<JsArray<CommentInfo>>> comments = loadComments(p, group);
-      loadFileList(base, baseRev, rev, lastReply, group, comments, null);
-    } else {
-      loadDiff(base, baseRev, rev, lastReply, group);
-    }
-    group.addListener(
-        new AsyncCallback<Void>() {
-          @Override
-          public void onSuccess(Void result) {
-            loadConfigInfo(info, rev);
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            logger.log(
-                Level.SEVERE,
-                "Loading file list and inline comments failed: " + caught.getMessage());
-            loadConfigInfo(info, rev);
-          }
-        });
-    group.done();
-  }
-
-  private void loadConfigInfo(ChangeInfo info, RevisionInfo rev) {
-    if (loaded) {
-      return;
-    }
-
-    RevisionInfoCache.add(changeId, rev);
-    ConfigInfoCache.add(info);
-    ConfigInfoCache.get(
-        info.projectNameKey(),
-        new ScreenLoadCallback<ConfigInfoCache.Entry>(this) {
-          @Override
-          protected void preDisplay(Entry result) {
-            loaded = true;
-            commentLinkProcessor = result.getCommentLinkProcessor();
-            setTheme(result.getTheme());
-            renderChangeInfo(info);
-            loadRevisionInfo();
-          }
-        });
-    ConfigInfoCache.get(
-        info.projectNameKey(),
-        new GerritCallback<Entry>() {
-          @Override
-          public void onSuccess(Entry entry) {
-            addExtensionPoints(info, rev, entry);
-          }
-        });
-  }
-
-  private void updateToken(ChangeInfo info, DiffObject base, RevisionInfo rev) {
-    StringBuilder token =
-        new StringBuilder("/c/")
-            .append(PageLinks.toChangeId(info.projectNameKey(), info.legacyId()))
-            .append("/");
-    if (base.asString() != null) {
-      token.append(base.asString()).append("..");
-    }
-    if (base.asString() != null || !rev.name().equals(info.currentRevision())) {
-      token.append(rev._number());
-    }
-    setToken(token.toString());
-  }
-
-  static Timestamp myLastReply(ChangeInfo info) {
-    if (Gerrit.isSignedIn() && info.messages() != null) {
-      int self = Gerrit.getUserAccount()._accountId();
-      for (int i = info.messages().length() - 1; i >= 0; i--) {
-        MessageInfo m = info.messages().get(i);
-        if (m.author() != null && m.author()._accountId() == self) {
-          return m.date();
-        }
-      }
-    }
-    return null;
-  }
-
-  private void loadDiff(
-      DiffObject base,
-      RevisionInfo baseRev,
-      RevisionInfo rev,
-      Timestamp myLastReply,
-      CallbackGroup group) {
-    List<NativeMap<JsArray<CommentInfo>>> comments = loadComments(rev, group);
-    List<NativeMap<JsArray<CommentInfo>>> drafts = loadDrafts(rev, group);
-    loadFileList(base, baseRev, rev, myLastReply, group, comments, drafts);
-
-    if (Gerrit.isSignedIn() && fileTableMode == FileTable.Mode.REVIEW) {
-      ChangeApi.revision(getProject().get(), changeId.get(), rev.name())
-          .view("files")
-          .addParameterTrue("reviewed")
-          .get(
-              group.add(
-                  new AsyncCallback<JsArrayString>() {
-                    @Override
-                    public void onSuccess(JsArrayString result) {
-                      files.markReviewed(result);
-                    }
-
-                    @Override
-                    public void onFailure(Throwable caught) {}
-                  }));
-    }
-  }
-
-  private void loadFileList(
-      final DiffObject base,
-      final RevisionInfo baseRev,
-      final RevisionInfo rev,
-      final Timestamp myLastReply,
-      CallbackGroup group,
-      final List<NativeMap<JsArray<CommentInfo>>> comments,
-      final List<NativeMap<JsArray<CommentInfo>>> drafts) {
-    DiffApi.list(
-        getProject().get(),
-        changeId.get(),
-        rev.name(),
-        baseRev,
-        group.add(
-            new AsyncCallback<NativeMap<FileInfo>>() {
-              @Override
-              public void onSuccess(NativeMap<FileInfo> m) {
-                files.set(
-                    base,
-                    new PatchSet.Id(changeId, rev._number()),
-                    getProject(),
-                    style,
-                    reply,
-                    fileTableMode,
-                    edit != null);
-                files.setValue(
-                    m,
-                    myLastReply,
-                    comments != null ? comments.get(0) : null,
-                    drafts != null ? drafts.get(0) : null);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                files.showError(caught);
-              }
-            }));
-  }
-
-  private List<NativeMap<JsArray<CommentInfo>>> loadComments(
-      final RevisionInfo rev, CallbackGroup group) {
-    final List<NativeMap<JsArray<CommentInfo>>> r = new ArrayList<>(1);
-    // TODO(dborowitz): Could eliminate this call by adding an option to include
-    // inline comments in the change detail.
-    ChangeApi.comments(getProject().get(), changeId.get())
-        .get(
-            group.add(
-                new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-                  @Override
-                  public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-                    // Return value is used for populating the file table, so only count
-                    // comments for the current revision. Still include all comments in
-                    // the history table.
-                    r.add(filterForRevision(result, rev._number()));
-                    history.addComments(result);
-                  }
-
-                  @Override
-                  public void onFailure(Throwable caught) {}
-                }));
-    return r;
-  }
-
-  private static NativeMap<JsArray<CommentInfo>> filterForRevision(
-      NativeMap<JsArray<CommentInfo>> comments, int id) {
-    NativeMap<JsArray<CommentInfo>> filtered = NativeMap.create();
-    for (String k : comments.keySet()) {
-      JsArray<CommentInfo> allRevisions = comments.get(k);
-      JsArray<CommentInfo> thisRevision = JsArray.createArray().cast();
-      for (int i = 0; i < allRevisions.length(); i++) {
-        CommentInfo c = allRevisions.get(i);
-        if (c.patchSet() == id) {
-          thisRevision.push(c);
-        }
-      }
-      filtered.put(k, thisRevision);
-    }
-    return filtered;
-  }
-
-  private List<NativeMap<JsArray<CommentInfo>>> loadDrafts(RevisionInfo rev, CallbackGroup group) {
-    final List<NativeMap<JsArray<CommentInfo>>> r = new ArrayList<>(1);
-    if (Gerrit.isSignedIn()) {
-      ChangeApi.revision(getProject().get(), changeId.get(), rev.name())
-          .view("drafts")
-          .get(
-              group.add(
-                  new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-                    @Override
-                    public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-                      r.add(result);
-                    }
-
-                    @Override
-                    public void onFailure(Throwable caught) {}
-                  }));
-    } else {
-      r.add(NativeMap.<JsArray<CommentInfo>>create());
-    }
-    return r;
-  }
-
-  private void loadCommit(RevisionInfo rev, CallbackGroup group) {
-    if (rev.isEdit() || rev.commit() != null) {
-      return;
-    }
-
-    ChangeApi.commitWithLinks(
-        getProject().get(),
-        changeId.get(),
-        rev.name(),
-        group.add(
-            new AsyncCallback<CommitInfo>() {
-              @Override
-              public void onSuccess(CommitInfo info) {
-                rev.setCommit(info);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {}
-            }));
-  }
-
-  private void renderSubmitType(Change.Status status, boolean canSubmit, SubmitType submitType) {
-    if (canSubmit && status == Change.Status.NEW && !changeInfo.isWorkInProgress()) {
-      statusText.setInnerText(
-          changeInfo.mergeable() ? Util.C.readyToSubmit() : Util.C.mergeConflict());
-    }
-    setVisible(notMergeable, !changeInfo.mergeable());
-    submitActionText.setInnerText(com.google.gerrit.client.admin.Util.toLongString(submitType));
-  }
-
-  private RevisionInfo resolveRevisionToDisplay(ChangeInfo info) {
-    RevisionInfo rev = resolveRevisionOrPatchSetId(info, revision, info.currentRevision());
-    if (rev != null) {
-      revision = rev.name();
-      return rev;
-    }
-
-    // the revision is not visible to the calling user (maybe it is a draft?)
-    // or the change is corrupt, take the last revision that was returned,
-    // if no revision was returned display an error
-    JsArray<RevisionInfo> revisions = info.revisions().values();
-    if (revisions.length() > 0) {
-      RevisionInfo.sortRevisionInfoByNumber(revisions);
-      rev = revisions.get(revisions.length() - 1);
-      revision = rev.name();
-      return rev;
-    }
-    new ErrorDialog(Resources.M.changeWithNoRevisions(info.legacyId().get())).center();
-    throw new IllegalStateException("no revision, cannot proceed");
-  }
-
-  /**
-   * Resolve a revision or patch set id string to RevisionInfo. When this view is created from the
-   * changes table, revision is passed as a real revision. When this view is created from side by
-   * side (by closing it with 'u') patch set id is passed.
-   *
-   * @param info change info
-   * @param revOrId revision or patch set id
-   * @param defaultValue value returned when revOrId is null
-   * @return resolved revision or default value
-   */
-  private RevisionInfo resolveRevisionOrPatchSetId(
-      ChangeInfo info, String revOrId, String defaultValue) {
-    int parentNum;
-    if (revOrId == null) {
-      revOrId = defaultValue;
-    } else if ((parentNum = toParentNum(revOrId)) > 0) {
-      CommitInfo commitInfo = info.revision(revision).commit();
-      JsArray<CommitInfo> parents = commitInfo.parents();
-      if (parents.length() >= parentNum) {
-        return RevisionInfo.forParent(-parentNum, parents.get(parentNum - 1));
-      }
-    } else if (!info.revisions().containsKey(revOrId)) {
-      JsArray<RevisionInfo> list = info.revisions().values();
-      for (int i = 0; i < list.length(); i++) {
-        RevisionInfo r = list.get(i);
-        if (revOrId.equals(String.valueOf(r._number()))) {
-          revOrId = r.name();
-          break;
-        }
-      }
-    }
-    return revOrId != null ? info.revision(revOrId) : null;
-  }
-
-  private boolean isSubmittable(ChangeInfo info) {
-    boolean canSubmit = info.status().isOpen() && revision.equals(info.currentRevision());
-    if (canSubmit && info.status() == Change.Status.NEW) {
-      for (String name : info.labels()) {
-        LabelInfo label = info.label(name);
-        switch (label.status()) {
-          case NEED:
-            statusText.setInnerText(Util.M.needs(name));
-            canSubmit = false;
-            break;
-          case REJECT:
-          case IMPOSSIBLE:
-            if (label.blocking()) {
-              statusText.setInnerText(Util.M.blockedOn(name));
-              canSubmit = false;
-            }
-            break;
-          case MAY:
-          case OK:
-          default:
-            break;
-        }
-      }
-    }
-    return canSubmit;
-  }
-
-  private void renderChangeInfo(ChangeInfo info) {
-    RevisionInfo revisionInfo = info.revision(revision);
-    changeInfo = info;
-    lastDisplayedUpdate = info.updated();
-
-    labels.set(info);
-
-    renderOwner(info);
-    renderUploader(info, revisionInfo);
-    renderActionTextDate(info);
-    renderDiffBaseListBox(info);
-    initReplyButton(info, revision);
-    initIncludedInAction(info);
-    initDownloadAction(info, revision);
-    initProjectLinks(info);
-    initBranchLink(info);
-    initEditMode(info, revision);
-    actions.display(info, revision);
-
-    star.setValue(info.starred());
-    permalink.setHref(ChangeLink.permalink(changeId));
-    permalink.setText(String.valueOf(info.legacyId()));
-    topic.set(info, revision);
-    commit.set(commentLinkProcessor, info, revision);
-    related.set(info, revision);
-    reviewers.set(info);
-    assignee.set(info);
-    if (Gerrit.isNoteDbEnabled()) {
-      hashtags.set(info, revision);
-    } else {
-      setVisible(hashtagTableRow, false);
-    }
-
-    StringBuilder sb = new StringBuilder();
-    sb.append(Util.M.changeScreenTitleId(info.idAbbreviated()));
-    if (info.subject() != null) {
-      sb.append(": ");
-      sb.append(info.subject());
-    }
-    setWindowTitle(sb.toString());
-
-    // Although this is related to the revision, we can process it early to
-    // render it faster.
-    if (!info.status().isOpen()
-        || !revision.equals(info.currentRevision())
-        || revisionInfo.isEdit()) {
-      setVisible(strategy, false);
-    }
-
-    // Properly render revision actions initially while waiting for
-    // the callback to populate them correctly.
-    NativeMap<ActionInfo> emptyMap = NativeMap.<ActionInfo>create();
-    initRevisionsAction(info, revision);
-    quickApprove.setVisible(false);
-    actions.reloadRevisionActions(emptyMap);
-
-    boolean current = revision.equals(info.currentRevision()) && !revisionInfo.isEdit();
-
-    if (revisionInfo.isEdit()) {
-      statusText.setInnerText(Util.C.changeEdit());
-    } else if (!current) {
-      statusText.setInnerText(Util.C.notCurrent());
-      labels.setVisible(false);
-    } else {
-      statusText.setInnerText(Util.toLongString(info.status()));
-    }
-
-    if (info.isPrivate()) {
-      privateText.setInnerText(Util.C.isPrivate());
-    }
-
-    if (info.isWorkInProgress()) {
-      wipText.setInnerText(Util.C.isWorkInProgress());
-    }
-
-    if (Gerrit.isSignedIn()) {
-      replyAction =
-          new ReplyAction(
-              info, revision, hasDraftComments, style, commentLinkProcessor, reply, quickApprove);
-    }
-    history.set(commentLinkProcessor, replyAction, changeId, info);
-
-    if (current && info.status().isOpen()) {
-      quickApprove.set(info, revision, replyAction);
-      renderSubmitType(info.status(), isSubmittable(info), info.submitType());
-    } else {
-      quickApprove.setVisible(false);
-    }
-  }
-
-  private void renderRevisionInfo(ChangeInfo info, NativeMap<ActionInfo> actionMap) {
-    initRevisionsAction(info, revision);
-    commit.setParentNotCurrent(
-        actionMap.containsKey("rebase") && actionMap.get("rebase").enabled());
-    actions.reloadRevisionActions(actionMap);
-  }
-
-  private void renderOwner(ChangeInfo info) {
-    // TODO info card hover
-    String name = name(info.owner());
-    if (info.owner().avatar(AvatarInfo.DEFAULT_SIZE) != null) {
-      ownerPanel.insert(new AvatarImage(info.owner()), 0);
-    }
-    ownerLink.setText(name);
-    ownerLink.setTitle(email(info.owner(), name));
-    ownerLink.setTargetHistoryToken(
-        PageLinks.toAccountQuery(
-            info.owner().name() != null
-                ? info.owner().name()
-                : info.owner().email() != null
-                    ? info.owner().email()
-                    : String.valueOf(info.owner()._accountId()),
-            Change.Status.NEW));
-  }
-
-  private void renderUploader(ChangeInfo changeInfo, RevisionInfo revInfo) {
-    AccountInfo uploader = revInfo.uploader();
-    boolean isOwner = uploader == null || uploader._accountId() == changeInfo.owner()._accountId();
-    renderPushCertificate(revInfo, isOwner ? ownerPanel : uploaderPanel);
-    if (isOwner) {
-      uploaderRow.getStyle().setDisplay(Display.NONE);
-      return;
-    }
-    uploaderRow.getStyle().setDisplay(Display.TABLE_ROW);
-
-    if (uploader.avatar(AvatarInfo.DEFAULT_SIZE) != null) {
-      uploaderPanel.insert(new AvatarImage(uploader), 0);
-    }
-    String name = name(uploader);
-    uploaderName.setText(name);
-    uploaderName.setTitle(email(uploader, name));
-  }
-
-  private void renderPushCertificate(RevisionInfo revInfo, FlowPanel panel) {
-    if (!enableSignedPush()) {
-      return;
-    }
-    Image status = new Image();
-    panel.add(status);
-    status.setStyleName(style.pushCertStatus());
-    if (!revInfo.hasPushCertificate() || revInfo.pushCertificate().key() == null) {
-      status.setResource(Gerrit.RESOURCES.question());
-      status.setTitle(Util.C.pushCertMissing());
-      return;
-    }
-    PushCertificateInfo certInfo = revInfo.pushCertificate();
-    GpgKeyInfo.Status s = certInfo.key().status();
-    switch (s) {
-      case BAD:
-        status.setResource(Gerrit.RESOURCES.redNot());
-        status.setTitle(problems(Util.C.pushCertBad(), certInfo));
-        break;
-      case OK:
-        status.setResource(Gerrit.RESOURCES.warning());
-        status.setTitle(problems(Util.C.pushCertOk(), certInfo));
-        break;
-      case TRUSTED:
-        status.setResource(Gerrit.RESOURCES.greenCheck());
-        status.setTitle(Util.C.pushCertTrusted());
-        break;
-    }
-  }
-
-  private static String name(AccountInfo info) {
-    return info.name() != null ? info.name() : Gerrit.info().user().anonymousCowardName();
-  }
-
-  private static String email(AccountInfo info, String name) {
-    return info.email() != null ? info.email() : name;
-  }
-
-  private static String problems(String msg, PushCertificateInfo info) {
-    if (info.key() == null || !info.key().hasProblems() || info.key().problems().length() == 0) {
-      return msg;
-    }
-
-    StringBuilder sb = new StringBuilder();
-    sb.append(msg).append(':');
-    for (String problem : Natives.asList(info.key().problems())) {
-      sb.append('\n').append(problem);
-    }
-    return sb.toString();
-  }
-
-  private void renderActionTextDate(ChangeInfo info) {
-    String action;
-    if (info.created().equals(info.updated())) {
-      action = Util.C.changeInfoBlockUploaded();
-    } else {
-      action = Util.C.changeInfoBlockUpdated();
-    }
-    actionText.setInnerText(action);
-    actionDate.setInnerText(FormatUtil.relativeFormat(info.updated()));
-  }
-
-  private void renderDiffBaseListBox(ChangeInfo info) {
-    JsArray<RevisionInfo> list = info.revisions().values();
-    RevisionInfo.sortRevisionInfoByNumber(list);
-    int selectedIdx = list.length();
-    for (int i = list.length() - 1; i >= 0; i--) {
-      RevisionInfo r = list.get(i);
-      diffBase.addItem(r.id() + ": " + r.name().substring(0, 6), r.id());
-      if (r.name().equals(revision)) {
-        SelectElement.as(diffBase.getElement())
-            .getOptions()
-            .getItem(diffBase.getItemCount() - 1)
-            .setDisabled(true);
-      }
-      if (base.isPatchSet() && base.asPatchSetId().get() == r._number()) {
-        selectedIdx = diffBase.getItemCount() - 1;
-      }
-    }
-
-    RevisionInfo rev = info.revisions().get(revision);
-    JsArray<CommitInfo> parents = rev.commit().parents();
-    if (parents.length() > 1) {
-      diffBase.addItem(Util.C.autoMerge(), DiffObject.AUTO_MERGE);
-      for (int i = 0; i < parents.length(); i++) {
-        int parentNum = i + 1;
-        diffBase.addItem(Util.M.diffBaseParent(parentNum), String.valueOf(-parentNum));
-      }
-
-      if (base.isParent()) {
-        selectedIdx = list.length() + base.getParentNum();
-      }
-    } else {
-      diffBase.addItem(Util.C.baseDiffItem(), "");
-    }
-
-    diffBase.setSelectedIndex(selectedIdx);
-  }
-
-  void showUpdates(ChangeInfo newInfo) {
-    if (!isAttached() || newInfo.updated().equals(lastDisplayedUpdate)) {
-      return;
-    }
-
-    JsArray<MessageInfo> om = changeInfo.messages();
-    JsArray<MessageInfo> nm = newInfo.messages();
-
-    if (om == null) {
-      om = JsArray.createArray().cast();
-    }
-    if (nm == null) {
-      nm = JsArray.createArray().cast();
-    }
-
-    if (om.length() == nm.length()) {
-      return;
-    }
-
-    if (updateAvailable == null) {
-      updateAvailable =
-          new UpdateAvailableBar() {
-            @Override
-            void onShow() {
-              Gerrit.display(PageLinks.toChange(project, changeId));
-            }
-
-            @Override
-            void onIgnore(Timestamp newTime) {
-              lastDisplayedUpdate = newTime;
-            }
-          };
-    }
-    updateAvailable.set(Natives.asList(nm).subList(om.length(), nm.length()), newInfo.updated());
-    if (!updateAvailable.isAttached()) {
-      add(updateAvailable);
-    }
-  }
-
-  private void startPoller() {
-    if (Gerrit.isSignedIn() && 0 < Gerrit.info().change().updateDelay()) {
-      updateCheck = new UpdateCheckTimer(this);
-      updateCheck.schedule();
-      handlers.add(UserActivityMonitor.addValueChangeHandler(updateCheck));
-    }
-  }
-
-  private static String normalize(String r) {
-    return r != null && !r.isEmpty() ? r : null;
-  }
-
-  /**
-   * @param parentToken
-   * @return 1-based parentNum if parentToken is a String which can be parsed as a negative integer
-   *     i.e. "-1", "-2", etc. If parentToken cannot be parsed as a negative integer, return zero.
-   */
-  private static int toParentNum(String parentToken) {
-    try {
-      int n = Integer.parseInt(parentToken);
-      if (n < 0) {
-        return -n;
-      }
-      return 0;
-    } catch (NumberFormatException e) {
-      return 0;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
deleted file mode 100644
index d629fc2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
+++ /dev/null
@@ -1,634 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gerrit.client.change'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:x='urn:import:com.google.gerrit.client.ui'>
-  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false' type='com.google.gerrit.client.change.ChangeScreen.Style'>
-    @eval textColor com.google.gerrit.client.Gerrit.getTheme().textColor;
-    @eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
-
-    @def COMMIT_WIDTH 560px;
-    @def HEADER_HEIGHT 30px;
-
-    @def BUTTON_HEIGHT 14px;
-
-    .cs2 {
-      margin-bottom: 1em;
-    }
-
-    .headerLine {
-      position: relative;
-      background-color: trimColor;
-      height: HEADER_HEIGHT;
-      margin: 0 -5px;
-      padding: 0 5px;
-    }
-
-    .subjectLine {
-      position: relative;
-      width: COMMIT_WIDTH;
-      height: HEADER_HEIGHT;
-      background-color: trimColor;
-      color: textColor;
-      font-family: sans-serif;
-    }
-    .subjectText {
-      width: 460px;
-      height: HEADER_HEIGHT;
-      line-height: HEADER_HEIGHT;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-
-    .infoLine {
-      position: absolute;
-      top: 0;
-      left: COMMIT_WIDTH;
-      height: HEADER_HEIGHT;
-      padding-left: 25px;
-    }
-
-    .infoLineHeaderButtons {
-      display: inline-block;
-      height: HEADER_HEIGHT;
-    }
-    .statusRight {
-      position: absolute;
-      top: 0;
-      right: 0;
-      height: HEADER_HEIGHT;
-    }
-    .idAndStatus {
-      display: inline-block;
-      position: relative;
-      height: HEADER_HEIGHT;
-    }
-    .star {
-      position: absolute;
-      top: 5px;
-      right: 2px;
-      cursor: pointer;
-      outline: none;
-    }
-    .changeId {
-      width: 300px;
-      white-space: nowrap;
-      line-height: HEADER_HEIGHT;
-      overflow: hidden;
-      text-overflow: ellipsis;
-    }
-    .statusText {
-      font-weight: bold;
-    }
-    .privateText {
-      font-weight: bold;
-    }
-
-    .wipText {
-      font-weight: bold;
-    }
-
-    div.popdown {
-      display: inline-block;
-      margin-top: 2px;
-      margin-left: 5px;
-      margin-right: 25px;
-    }
-
-    .popdown button {
-      cursor: pointer;
-      height: 25px;
-      border: none;
-      background-color: trimColor;
-      margin: 0 0 0 -2px;
-      padding-left: 2px;
-      padding-right: 2px;
-      min-width: 100px;
-    }
-    .popdown button div {
-      padding-left: 6px;
-      padding-right: 6px;
-    }
-    .popdown button div:after {
-      content: " \25bc";
-    }
-    .popdown button.selected {
-      font-weight: bold;
-    }
-    .popdown button:focus {
-      outline: none;
-    }
-
-    .headerButtons button:disabled,
-    #change_infoTable button:disabled,
-    .popdown button:disabled {
-      background-color: #999;
-      background-image: -webkit-linear-gradient(top, #999, #999);
-    }
-
-    .infoTable {
-      border-spacing: 0;
-    }
-
-    .infoTable th {
-      width: 60px;
-      color: #444;
-      font-weight: normal;
-      vertical-align: top;
-      text-align: left;
-      padding: 0 5px 0 0;
-    }
-
-    .projectSettings {
-      float: right;
-      cursor: pointer;
-    }
-
-    .infoColumn {
-      width: 440px;
-      padding-left: 17px;
-      padding-right: 17px;
-      vertical-align: top;
-    }
-
-    #change_infoTable {
-      border-spacing: 0;
-      width: 100%;
-      margin-left: 2px;
-      margin-right: 5px;
-    }
-
-    .notMergeable {
-      float: right;
-      font-weight: bold;
-      color: #d00;
-    }
-
-    .commitColumn, .relatedColumn {
-      padding: 0;
-      vertical-align: top;
-    }
-    .commitColumn { width: COMMIT_WIDTH; }
-    .relatedColumn { width: 375px; }
-
-    .labels {
-      border-spacing: 0;
-      padding: 0;
-    }
-    .labelName {
-      color: #444;
-      vertical-align: top;
-      text-align: left;
-      padding-top: 3px;
-      padding-right: 5px;
-      white-space: nowrap;
-    }
-
-    .label_user {
-      display: inline-block;
-      margin-bottom: 2px;
-      padding: 1px 3px 0px 3px;
-      border-radius: 5px;
-      -webkit-border-radius: 5px;
-      background: trimColor;
-      border: 1px solid trimColor;
-      white-space: nowrap;
-    }
-    .label_user img.avatar {
-      margin: 0 2px 0 0;
-      width: 16px;
-      height: 16px;
-      vertical-align: bottom;
-    }
-    .label_user button {
-      cursor: pointer;
-      padding: 0;
-      margin: 0 0 0 5px;
-      border: 0;
-      background-color: transparent;
-      white-space: nowrap;
-    }
-
-    .label_ok {color: #060;}
-    .label_reject {color: #d14836;}
-    .label_need {color: #000;}
-    .label_may {color: #777;}
-
-    .hashtagName {
-      display: inline-block;
-      height: 15px;
-      margin-bottom: 2px;
-      padding: 1px 3px 1px 3px;
-      border-radius: 5px;
-      -webkit-border-radius: 5px;
-      background: #E2F5FF;
-      border: 1px solid #579FDA;
-      white-space: nowrap;
-    }
-
-    .hashtagName a,
-    .hashtagName button {
-      position: relative;
-      top: -4px;
-    }
-
-    .hashtagName button {
-      cursor: pointer;
-      padding: 0;
-      margin: 0 0 0 5px;
-      border: 0;
-      background-color: transparent;
-      white-space: nowrap;
-    }
-
-    .hashtagIcon img {
-      position: relative;
-      top: 4px;
-    }
-
-    .headerButtons button {
-      margin: 5.286px 3px 0 0;
-      text-align: center;
-      font-size: 8pt;
-      font-weight: bold;
-      cursor: pointer;
-      border: 2px solid;
-      color: rgba(0, 0, 0, 0.15);
-      background-color: #f5f5f5;
-      -webkit-border-radius: 2px;
-      -webkit-box-sizing: content-box;
-    }
-    .headerButtons button div {
-      color: #444;
-      min-width: 54px;
-      white-space: nowrap;
-      height: BUTTON_HEIGHT;
-      line-height: BUTTON_HEIGHT;
-    }
-    button.highlight {
-      background-color: #4d90fe;
-    }
-    button.highlight div { color: #fff; }
-
-    .sectionHeader {
-      position: relative;
-      background-color: trimColor;
-      font-weight: bold;
-      color: textColor;
-      height: 20px;
-      line-height: 20px;
-      margin: 0 -5px;
-      padding: 5px 5px;
-    }
-    .sectionHeader .headerButtons {
-      position: absolute;
-      left: 300px;
-      top: 2px;
-      height: 18px;
-      line-height: 18px;
-      border-left: 1px inset #fff;
-      padding-left: 5px;
-      padding-top: 3px;
-      padding-bottom: 3px;
-    }
-    .sectionHeader button { margin-top: 0; }
-
-    .diffBase {
-      display: inline-block;
-      height: 18px;
-      line-height: 18px;
-      font-size: smaller;
-      font-weight: normal;
-      vertical-align: top;
-    }
-    .diffBase select {
-      margin: 0;
-      border: 2px solid rgba(0, 0, 0, 0.15);
-      height: 20px;
-      font-size: 8pt;
-      font-weight: bold;
-      border-radius: 2px;
-      background-color: #f5f5f5
-    }
-
-    .replyBox {
-      background-color: trimColor;
-    }
-
-    .ownerPanel img, .uploaderPanel img {
-      margin: 0 2px 0 0;
-      width: 16px;
-      height: 16px !important;
-      vertical-align: bottom;
-    }
-
-    .headerExtension {
-      display: inline-block;
-      float: right;
-    }
-
-    .headerExtension>div>div {
-      float: left;
-    }
-
-    .changeExtension {
-      padding-top: 5px;
-    }
-
-    .relatedExtension {
-      padding-top: 5px;
-    }
-
-    .commitExtension {
-      padding-top: 5px;
-    }
-
-    .historyExtension {
-      display: inline-block;
-      float: right;
-    }
-
-    .pushCertStatus {
-      padding-left: 5px;
-    }
-
-    .notCurrentPatchSet {
-      background-color: #FFA62F;
-    }
-  </ui:style>
-
-  <g:HTMLPanel styleName='{style.cs2}'>
-    <g:HTMLPanel styleName='{style.headerLine}' ui:field='headerLine'>
-      <div class='{style.subjectLine}'>
-        <div class='{style.idAndStatus}'>
-          <span class='{style.changeId}'>
-            <ui:msg>Change <g:Anchor ui:field='permalink' title='Reload the change (Shortcut: R)'>
-              <ui:attribute name='title'/>
-            </g:Anchor> - <span ui:field='statusText' class='{style.statusText}'/>
-              <span ui:field='privateText' class='{style.privateText}'/>
-              <span ui:field='wipText' class='{style.wipText}'/></ui:msg>
-          </span>
-          <g:SimplePanel ui:field='headerExtension' styleName='{style.headerExtension}'/>
-        </div>
-      </div>
-
-      <div class='{style.infoLine}'>
-        <div class='{style.headerButtons} {style.infoLineHeaderButtons}'>
-          <g:Button ui:field='reply'
-              styleName=''
-              title=''
-              visible='false'>
-            <ui:attribute name='title'/>
-          </g:Button>
-          <c:QuickApprove ui:field='quickApprove'
-              styleName='{style.highlight}'
-              title='Apply score with one click'>
-            <ui:attribute name='title'/>
-          </c:QuickApprove>
-          <g:Button ui:field='publishEdit'
-              styleName='{style.highlight}' visible='false'>
-            <div><ui:msg>Publish Edit</ui:msg></div>
-          </g:Button>
-          <g:Button ui:field='rebaseEdit'
-              styleName='{style.highlight}' visible='false'>
-            <div><ui:msg>Rebase Edit</ui:msg></div>
-          </g:Button>
-          <g:Button ui:field='deleteEdit' styleName='' visible='false'>
-            <div><ui:msg>Delete Edit</ui:msg></div>
-          </g:Button>
-          <g:SimplePanel ui:field='headerExtensionMiddle' styleName='{style.headerExtension}'/>
-        </div>
-      </div>
-
-      <div class='{style.statusRight}'>
-        <g:FlowPanel styleName='{style.popdown}'>
-          <g:Button ui:field='includedIn' styleName='' visible="false">
-            <div><ui:msg>Included in</ui:msg></div>
-          </g:Button>
-          <g:Button ui:field='patchSets' styleName=''>
-            <div ui:field='patchSetsText'/>
-          </g:Button>
-          <g:Button ui:field='download' styleName=''>
-            <div><ui:msg>Download</ui:msg></div>
-          </g:Button>
-          <g:SimplePanel ui:field='headerExtensionRight' styleName='{style.headerExtension}'/>
-        </g:FlowPanel>
-        <c:StarIcon ui:field='star' styleName='{style.star}' title='Star the change (Shortcut: s)'>
-          <ui:attribute name='title'/>
-        </c:StarIcon>
-      </div>
-    </g:HTMLPanel>
-
-    <table class='{style.infoTable}'>
-      <tr>
-        <td class='{style.commitColumn}'>
-          <c:CommitBox ui:field='commit'/>
-          <g:SimplePanel ui:field='commitExtension' styleName='{style.commitExtension}'/>
-        </td>
-        <td class='{style.infoColumn}'>
-          <table id='change_infoTable'>
-            <tr>
-              <th><ui:msg>Owner</ui:msg></th>
-              <td>
-                <g:FlowPanel ui:field='ownerPanel' styleName='{style.ownerPanel}'>
-                  <x:InlineHyperlink ui:field='ownerLink'/>
-                </g:FlowPanel>
-              </td>
-            </tr>
-            <tr ui:field='uploaderRow'>
-              <th><ui:msg>Uploader</ui:msg></th>
-              <td>
-                <g:FlowPanel ui:field='uploaderPanel' styleName='{style.uploaderPanel}'>
-                  <g:InlineLabel ui:field='uploaderName'/>
-                </g:FlowPanel>
-              </td>
-            </tr>
-            <tr ui:field='assigneeRow'>
-              <th><ui:msg>Assignee</ui:msg></th>
-              <td>
-                <c:Assignee ui:field='assignee'/>
-              </td>
-            </tr>
-            <tr>
-              <th><ui:msg>Reviewers</ui:msg></th>
-              <td>
-                <c:Reviewers ui:field='reviewers'/>
-              </td>
-            </tr>
-            <tr>
-              <th/>
-              <td ui:field='ccText'/>
-            </tr>
-            <tr>
-              <th><ui:msg>Project</ui:msg></th>
-              <td><x:InlineHyperlink ui:field='projectDashboard'
-                     title='Go to project dashboard'>
-                     <ui:attribute name='title'/>
-                  </x:InlineHyperlink>
-                  <a ui:field='projectSettingsLink'
-                     class='{style.projectSettings}'>
-                    <g:Image
-                       ui:field='projectSettings'
-                       resource='{ico.gear}'
-                       title='Go to project settings'>
-                      <ui:attribute name='title'/>
-                    </g:Image>
-                  </a>
-              </td>
-            </tr>
-            <tr>
-              <th><ui:msg>Branch</ui:msg></th>
-              <td><x:InlineHyperlink ui:field='branchLink'
-                     title='Search for changes on this branch'>
-                     <ui:attribute name='title'/>
-                  </x:InlineHyperlink>
-              </td>
-            </tr>
-            <tr>
-              <th><ui:msg>Topic</ui:msg></th>
-              <td><c:Topic ui:field='topic'/></td>
-            </tr>
-            <tr ui:field='strategy'>
-              <th><ui:msg>Strategy</ui:msg></th>
-              <td>
-                <span ui:field='submitActionText'/>
-                <div ui:field='notMergeable'
-                     class='{style.notMergeable}'
-                     style='display: none'
-                     aria-hidden='true'
-                     title='The change cannot be merged due to a path conflict. Rebase the change and upload the rebased commit for review.'>
-                  <ui:attribute name='title'/>
-                  <ui:msg>Cannot Merge</ui:msg>
-                </div>
-              </td>
-            </tr>
-            <tr>
-              <th ui:field='actionText'/>
-              <td ui:field='actionDate'/>
-            </tr>
-            <tr ui:field='hashtagTableRow'>
-              <th><ui:msg>Hashtags</ui:msg></th>
-              <td colspan='2'>
-                <c:Hashtags ui:field='hashtags'/>
-              </td>
-            </tr>
-            <tr><td colspan='2'><c:Actions ui:field='actions'/></td></tr>
-          </table>
-          <hr/>
-          <c:Labels ui:field='labels' styleName='{style.labels}'/>
-          <g:SimplePanel ui:field='changeExtension' styleName='{style.changeExtension}'/>
-          <div id='change_plugins'/>
-        </td>
-        <td class='{style.relatedColumn}'>
-          <c:RelatedChanges ui:field='related'/>
-          <g:SimplePanel ui:field='relatedExtension' styleName='{style.relatedExtension}'/>
-        </td>
-      </tr>
-    </table>
-
-    <div class='{style.sectionHeader} {style.headerButtons}'>
-      <ui:msg>Files</ui:msg>
-      <g:Button ui:field='addFile'
-         title='Add file to this change'
-         styleName=''
-         visible='false'>
-        <ui:attribute name='title'/>
-        <div><ui:msg>Add&#8230;</ui:msg></div>
-      </g:Button>
-      <g:Button ui:field='deleteFile'
-         title='Delete file from the repository'
-         styleName=''
-         visible='false'>
-        <ui:attribute name='title'/>
-        <div><ui:msg>Delete&#8230;</ui:msg></div>
-      </g:Button>
-      <g:Button ui:field='renameFile'
-         title='Rename file in the repository'
-         styleName=''
-         visible='false'>
-        <ui:attribute name='title'/>
-        <div><ui:msg>Rename&#8230;</ui:msg></div>
-      </g:Button>
-      <div class='{style.headerButtons}'>
-        <g:Button ui:field='openAll'
-            styleName=''
-            title='Open each file in a new tab'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Open All</ui:msg></div>
-        </g:Button>
-        <div class='{style.diffBase}'>
-          <ui:msg>Diff against: <g:ListBox ui:field='diffBase' styleName=''/></ui:msg>
-        </div>
-        <g:Button ui:field='editMode'
-            styleName=''
-            visible='false'
-            title='Switch file table to edit mode'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Edit</ui:msg></div>
-        </g:Button>
-        <g:Button ui:field='reviewMode'
-            styleName=''
-            visible='false'
-            title='Done with edit mode'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Done Editing</ui:msg></div>
-        </g:Button>
-      </div>
-    </div>
-    <c:FileTable ui:field='files'/>
-
-    <div class='{style.sectionHeader}'>
-      <ui:msg>History</ui:msg>
-      <div class='{style.headerButtons}'>
-        <g:Button ui:field='expandAll'
-            styleName=''
-            title='Expand all messages in the change history'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Expand All</ui:msg></div>
-        </g:Button>
-        <g:Button ui:field='collapseAll'
-            styleName=''
-            visible='false'
-            title='Collapse all messages in the change history'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Collapse All</ui:msg></div>
-        </g:Button>
-        <g:Button ui:field='hideTaggedComments'
-            styleName=''
-            visible='false'
-            title='Hide tagged comments'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Hide tagged comments</ui:msg></div>
-        </g:Button>
-        <g:Button ui:field='showTaggedComments'
-            styleName=''
-            visible='false'
-            title='Show tagged comments'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Show tagged comments</ui:msg></div>
-        </g:Button>
-        <g:SimplePanel ui:field='historyExtensionRight' styleName='{style.historyExtension}'/>
-      </div>
-    </div>
-    <c:History ui:field='history'/>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java
deleted file mode 100644
index be011d2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.CherryPickDialog;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.PopupPanel;
-
-class CherryPickAction {
-  static void call(
-      final Button b,
-      final ChangeInfo info,
-      final String revision,
-      final Project.NameKey project,
-      final String commitMessage) {
-    // TODO Replace CherryPickDialog with a nicer looking display.
-    b.setEnabled(false);
-    new CherryPickDialog(project) {
-      {
-        sendButton.setText(Util.C.buttonCherryPickChangeSend());
-        if (info.status() == Change.Status.MERGED) {
-          message.setText(Util.M.cherryPickedChangeDefaultMessage(commitMessage.trim(), revision));
-        } else {
-          message.setText(commitMessage.trim());
-        }
-      }
-
-      @Override
-      public void onSend() {
-        ChangeApi.cherrypick(
-            info.project(),
-            info.legacyId().get(),
-            revision,
-            getDestinationBranch(),
-            getMessageText(),
-            new GerritCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo result) {
-                sent = true;
-                hide();
-                Gerrit.display(PageLinks.toChange(project, result.legacyId()));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                enableButtons(true);
-                super.onFailure(caught);
-              }
-            });
-      }
-
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        super.onClose(event);
-        b.setEnabled(true);
-      }
-    }.center();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
deleted file mode 100644
index 0112579..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
+++ /dev/null
@@ -1,219 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.AvatarImage;
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
-import com.google.gerrit.client.info.ChangeInfo.GitPerson;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.TableRowElement;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.ScrollPanel;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-class CommitBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, CommitBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface Style extends CssResource {
-    String collapsed();
-
-    String expanded();
-
-    String clippy();
-
-    String parentWebLink();
-  }
-
-  @UiField Style style;
-  @UiField FlowPanel authorPanel;
-  @UiField FlowPanel committerPanel;
-  @UiField Image mergeCommit;
-  @UiField CopyableLabel commitName;
-  @UiField FlowPanel webLinkPanel;
-  @UiField TableRowElement firstParent;
-  @UiField FlowPanel parentCommits;
-  @UiField FlowPanel parentWebLinks;
-  @UiField InlineHyperlink authorNameEmail;
-  @UiField Element authorDate;
-  @UiField InlineHyperlink committerNameEmail;
-  @UiField Element committerDate;
-  @UiField CopyableLabel idText;
-  @UiField HTML text;
-  @UiField ScrollPanel scroll;
-  @UiField Button more;
-  @UiField Element parentNotCurrentText;
-  private boolean expanded;
-
-  CommitBox() {
-    initWidget(uiBinder.createAndBindUi(this));
-    addStyleName(style.collapsed());
-  }
-
-  void onShowView() {
-    more.setVisible(scroll.getMaximumVerticalScrollPosition() > 0);
-  }
-
-  @UiHandler("more")
-  void onMore(@SuppressWarnings("unused") ClickEvent e) {
-    if (expanded) {
-      removeStyleName(style.expanded());
-      addStyleName(style.collapsed());
-    } else {
-      removeStyleName(style.collapsed());
-      addStyleName(style.expanded());
-    }
-    expanded = !expanded;
-  }
-
-  void set(CommentLinkProcessor commentLinkProcessor, ChangeInfo change, String revision) {
-    RevisionInfo revInfo = change.revision(revision);
-    CommitInfo commit = revInfo.commit();
-
-    commitName.setText(revision);
-    idText.setText("Change-Id: " + change.changeId());
-    idText.setPreviewText(change.changeId());
-
-    formatLink(commit.author(), authorPanel, authorNameEmail, authorDate, change);
-    formatLink(commit.committer(), committerPanel, committerNameEmail, committerDate, change);
-    text.setHTML(
-        commentLinkProcessor.apply(new SafeHtmlBuilder().append(commit.message()).linkify()));
-    setWebLinks(webLinkPanel, revInfo.commit());
-
-    if (revInfo.commit().parents().length() > 1) {
-      mergeCommit.setVisible(true);
-    }
-
-    setParents(revInfo.commit().parents());
-  }
-
-  void setParentNotCurrent(boolean parentNotCurrent) {
-    // display the orange ball if parent has moved on (not current)
-    UIObject.setVisible(parentNotCurrentText, parentNotCurrent);
-    parentNotCurrentText.setInnerText(parentNotCurrent ? "\u25CF" : "");
-  }
-
-  private void setWebLinks(FlowPanel panel, CommitInfo commit) {
-    JsArray<WebLinkInfo> links = commit.webLinks();
-    if (links != null) {
-      for (WebLinkInfo link : Natives.asList(links)) {
-        panel.add(link.toAnchor());
-      }
-    }
-  }
-
-  private void setParents(JsArray<CommitInfo> commits) {
-    setVisible(firstParent, true);
-    TableRowElement next = firstParent;
-    TableRowElement previous = null;
-    for (CommitInfo c : Natives.asList(commits)) {
-      if (next == firstParent) {
-        CopyableLabel copyLabel = getCommitLabel(c);
-        parentCommits.add(copyLabel);
-        setWebLinks(parentWebLinks, c);
-      } else {
-        next.appendChild(DOM.createTD());
-        Element td1 = DOM.createTD();
-        td1.appendChild(getCommitLabel(c).getElement());
-        next.appendChild(td1);
-        FlowPanel linksPanel = new FlowPanel();
-        linksPanel.addStyleName(style.parentWebLink());
-        setWebLinks(linksPanel, c);
-        Element td2 = DOM.createTD();
-        td2.appendChild(linksPanel.getElement());
-        next.appendChild(td2);
-        previous.getParentElement().insertAfter(next, previous);
-      }
-      previous = next;
-      next = DOM.createTR().cast();
-    }
-  }
-
-  private CopyableLabel getCommitLabel(CommitInfo c) {
-    CopyableLabel copyLabel;
-    copyLabel = new CopyableLabel(c.commit());
-    copyLabel.setTitle(c.subject());
-    copyLabel.setStyleName(style.clippy());
-    return copyLabel;
-  }
-
-  private static void formatLink(
-      GitPerson person, FlowPanel p, InlineHyperlink name, Element date, ChangeInfo change) {
-    // only try to fetch the avatar image for author and committer if an avatar
-    // plugin is installed, if the change owner has no avatar info assume that
-    // no avatar plugin is installed
-    if (change.owner().hasAvatarInfo()) {
-      AvatarImage avatar;
-      if (sameEmail(change.owner(), person)) {
-        avatar = new AvatarImage(change.owner());
-      } else {
-        avatar = new AvatarImage(AccountInfo.create(0, person.name(), person.email(), null));
-      }
-      p.insert(avatar, 0);
-    }
-
-    name.setText(renderName(person));
-    name.setTargetHistoryToken(PageLinks.toAccountQuery(owner(person), change.status()));
-    date.setInnerText(FormatUtil.mediumFormat(person.date()));
-  }
-
-  private static String renderName(GitPerson person) {
-    return person.name() + " <" + person.email() + ">";
-  }
-
-  private static String owner(GitPerson person) {
-    if (person.email() != null) {
-      return person.email();
-    } else if (person.name() != null) {
-      return person.name();
-    } else {
-      return "";
-    }
-  }
-
-  private static boolean sameEmail(@Nullable AccountInfo p1, @Nullable GitPerson p2) {
-    return p1 != null
-        && p2 != null
-        && p1.email() != null
-        && p2.email() != null
-        && p1.email().equals(p2.email());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.ui.xml
deleted file mode 100644
index 5f476be..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.ui.xml
+++ /dev/null
@@ -1,198 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:x='urn:import:com.google.gerrit.client.ui'
-    xmlns:clippy='urn:import:com.google.gwtexpui.clippy.client'>
-  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-  <ui:image field="toggle" src="moreLess.png"/>
-  <ui:style gss='false' type='com.google.gerrit.client.change.CommitBox.Style'>
-    @eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
-
-    .collapsed .scroll { height: 250px }
-    .scroll, .more { width: 560px }
-    .scroll {
-      border-right: 1px solid trimColor;
-      border-bottom: 1px solid trimColor;
-    }
-
-    .text {
-      font-family: monospace;
-      white-space: pre;
-    }
-
-    .more {
-      height: 8px;
-      line-height: 8px;
-      text-align: center;
-    }
-    .moreButton {
-      padding: 0 5px 0 5px;
-      margin: 0;
-      border: none;
-      height: 8px;
-      background-color: #F7F7F7;
-    }
-    .moreButton:focus {
-      outline: none;
-    }
-
-    @sprite .toggle {
-      gwt-image: "toggle";
-      width: 13px;
-      height: 8px;
-      padding: 0;
-    }
-    .collapsed .toggle { background-position: -13px -8px }
-    .expanded .toggle { background-position: 0px -8px }
-    .collapsed button:hover .toggle { background-position: -13px 0px }
-    .expanded button:hover .toggle { background-position: 0px 0px }
-
-    .header {
-      border-spacing: 0;
-      padding: 0;
-      width: 560px;
-    }
-    .header th { width: 72px; }
-    .header td { white-space: nowrap; }
-    .date { width: 132px; }
-
-    .clippy {
-      position: relative;
-    }
-    .clippy div {
-      position: absolute;
-      top: 0px;
-      right: -16px;
-    }
-    <!-- To make room for the copyableLabel from the adjacent column -->
-    .webLinkPanel a:first-child {
-      margin-left:16px;
-    }
-    .webLinkPanel>a {
-      margin-left:2px;
-    }
-
-    .parentWebLink a:first-child {
-      margin-left:16px;
-    }
-    .parentWebLink>a {
-      margin-left:2px;
-    }
-
-    .commit {
-      margin-right: 3px;
-      float: left;
-    }
-
-    .userPanel img {
-      margin: 0 2px 0 0;
-      width: 16px;
-      height: 16px !important;
-      vertical-align: bottom;
-    }
-
-    .parent {
-      margin-right: 3px;
-      float: left;
-    }
-    .parentNotCurrent {
-      color: #FFA62F;   <!-- orange -->
-      font-weight: bold;
-    }
-
-  </ui:style>
-  <g:HTMLPanel>
-    <g:ScrollPanel styleName='{style.scroll}' ui:field='scroll'>
-      <g:HTML styleName='{style.text}' ui:field='text'/>
-    </g:ScrollPanel>
-    <div class='{style.more}'>
-      <g:Button ui:field='more'
-          styleName='{style.moreButton}'
-          title='Expand/Collapse'>
-        <ui:attribute name='title'/>
-        <div class='{style.toggle}'/>
-      </g:Button>
-    </div>
-    <table class='{style.header}'>
-      <tr>
-        <th><ui:msg>Author</ui:msg></th>
-        <td>
-          <g:FlowPanel ui:field='authorPanel' styleName='{style.userPanel}'>
-            <x:InlineHyperlink ui:field='authorNameEmail'
-              title='Search for changes by this user'>
-              <ui:attribute name='title'/>
-            </x:InlineHyperlink>
-          </g:FlowPanel>
-        </td>
-        <td ui:field='authorDate' class='{style.date}' colspan="2"/>
-      </tr>
-      <tr>
-        <th><ui:msg>Committer</ui:msg></th>
-        <td>
-          <g:FlowPanel ui:field='committerPanel' styleName='{style.userPanel}'>
-            <x:InlineHyperlink ui:field='committerNameEmail'
-              title='Search for changes by this user'>
-              <ui:attribute name='title'/>
-            </x:InlineHyperlink>
-          </g:FlowPanel>
-        </td>
-        <td ui:field='committerDate' class='{style.date}' colspan="2"/>
-      </tr>
-      <tr>
-        <th>
-          <div class='{style.commit}'>
-            <ui:msg>Commit</ui:msg>
-          </div>
-          <g:Image
-              ui:field='mergeCommit'
-              resource='{ico.merge}'
-              visible='false'
-              title='Merge Commit'>
-            <ui:attribute name='title'/>
-          </g:Image>
-        </th>
-        <td><clippy:CopyableLabel styleName='{style.clippy}' ui:field='commitName'/></td>
-        <td>
-            <g:FlowPanel ui:field='webLinkPanel' styleName='{style.webLinkPanel}'/>
-        </td>
-      </tr>
-      <tr ui:field='firstParent' style='display: none'>
-        <th>
-          <div class='{style.parent}'>
-            <ui:msg>Parent(s)</ui:msg>
-          </div>
-          <div ui:field='parentNotCurrentText'
-              title='Not current - rebase possible'
-              class='{style.parentNotCurrent}'
-              style='display: none' aria-hidden='true'/>
-        </th>
-        <td>
-          <g:FlowPanel ui:field='parentCommits'/>
-        </td>
-        <td>
-          <g:FlowPanel ui:field='parentWebLinks' styleName='{style.parentWebLink}'/>
-        </td>
-      </tr>
-      <tr>
-        <th><ui:msg>Change-Id</ui:msg></th>
-        <td><clippy:CopyableLabel styleName='{style.clippy}' ui:field='idText'/></td>
-      </tr>
-    </table>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java
deleted file mode 100644
index 9369c18..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-
-class DeleteFileAction {
-  private final Project.NameKey project;
-  private final Change.Id changeId;
-  private final RevisionInfo revision;
-  private final ChangeScreen.Style style;
-  private final Widget deleteButton;
-
-  private DeleteFileBox deleteBox;
-  private PopupPanel popup;
-
-  DeleteFileAction(
-      Project.NameKey project,
-      Change.Id changeId,
-      RevisionInfo revision,
-      ChangeScreen.Style style,
-      Widget deleteButton) {
-    this.project = project;
-    this.changeId = changeId;
-    this.revision = revision;
-    this.style = style;
-    this.deleteButton = deleteButton;
-  }
-
-  void onDelete() {
-    if (popup != null) {
-      popup.hide();
-      return;
-    }
-
-    if (deleteBox == null) {
-      deleteBox = new DeleteFileBox(project, changeId, revision);
-    }
-    deleteBox.clearPath();
-
-    final PopupPanel p = new PopupPanel(true);
-    p.setStyleName(style.replyBox());
-    p.addAutoHidePartner(deleteButton.getElement());
-    p.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            if (popup == p) {
-              popup = null;
-            }
-          }
-        });
-    p.add(deleteBox);
-    p.showRelativeTo(deleteButton);
-    GlobalKey.dialog(p);
-    deleteBox.setFocus(true);
-    popup = p;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java
deleted file mode 100644
index 1885293..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.changes.ChangeEditApi;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.ui.RemoteSuggestBox;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-
-class DeleteFileBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, DeleteFileBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  private final Project.NameKey project;
-  private final Change.Id changeId;
-
-  @UiField Button delete;
-  @UiField Button cancel;
-
-  @UiField(provided = true)
-  RemoteSuggestBox path;
-
-  DeleteFileBox(Project.NameKey project, Change.Id changeId, RevisionInfo revision) {
-    this.project = project;
-    this.changeId = changeId;
-
-    path = new RemoteSuggestBox(new PathSuggestOracle(project, changeId, revision));
-    path.addSelectionHandler(
-        new SelectionHandler<String>() {
-          @Override
-          public void onSelection(SelectionEvent<String> event) {
-            delete(event.getSelectedItem());
-          }
-        });
-    path.addCloseHandler(
-        new CloseHandler<RemoteSuggestBox>() {
-          @Override
-          public void onClose(CloseEvent<RemoteSuggestBox> event) {
-            hide();
-          }
-        });
-
-    initWidget(uiBinder.createAndBindUi(this));
-  }
-
-  void setFocus(boolean focus) {
-    path.setFocus(focus);
-  }
-
-  void clearPath() {
-    path.setText("");
-  }
-
-  @UiHandler("delete")
-  void onDelete(@SuppressWarnings("unused") ClickEvent e) {
-    delete(path.getText());
-  }
-
-  private void delete(String path) {
-    hide();
-    ChangeEditApi.delete(
-        project.get(),
-        changeId.get(),
-        path,
-        new AsyncCallback<VoidResult>() {
-          @Override
-          public void onSuccess(VoidResult result) {
-            Gerrit.display(PageLinks.toChangeInEditMode(project, changeId));
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {}
-        });
-  }
-
-  @UiHandler("cancel")
-  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    hide();
-  }
-
-  private void hide() {
-    for (Widget w = getParent(); w != null; w = w.getParent()) {
-      if (w instanceof PopupPanel) {
-        ((PopupPanel) w).hide();
-        break;
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.ui.xml
deleted file mode 100644
index 9e79f752..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.ui.xml
+++ /dev/null
@@ -1,43 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:u='urn:import:com.google.gerrit.client.ui'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false'>
-    .cancel { float: right; }
-  </ui:style>
-  <g:HTMLPanel>
-    <div class='{res.style.section}'>
-      <ui:msg>Path: <u:RemoteSuggestBox ui:field='path' visibleLength='86'/></ui:msg>
-    </div>
-    <div class='{res.style.section}'>
-      <g:Button ui:field='delete'
-          title='Delete file from the repository'
-          styleName='{res.style.button}'>
-        <ui:attribute name='title'/>
-        <div><ui:msg>Delete</ui:msg></div>
-      </g:Button>
-      <g:Button ui:field='cancel'
-          styleName='{res.style.button}'
-          addStyleNames='{style.cancel}'>
-          <div>Cancel</div>
-      </g:Button>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java
deleted file mode 100644
index 8e4ea84..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-
-class DownloadAction extends RightSidePopdownAction {
-  private final DownloadBox downloadBox;
-
-  DownloadAction(
-      ChangeInfo info,
-      String revision,
-      ChangeScreen.Style style,
-      UIObject relativeTo,
-      Widget downloadButton) {
-    super(style, relativeTo, downloadButton);
-    this.downloadBox =
-        new DownloadBox(
-            info, revision, new PatchSet.Id(info.legacyId(), info.revision(revision)._number()));
-  }
-
-  @Override
-  Widget getWidget() {
-    return downloadBox;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
deleted file mode 100644
index 547f3d5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
+++ /dev/null
@@ -1,261 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.AccountApi;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ChangeList;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.client.info.ChangeInfo.FetchInfo;
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.event.dom.client.ChangeEvent;
-import com.google.gwt.event.dom.client.ChangeHandler;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.FlexTable;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.VerticalPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.Iterator;
-import java.util.List;
-
-class DownloadBox extends VerticalPanel {
-  private final ChangeInfo change;
-  private final String revision;
-  private final PatchSet.Id psId;
-  private final FlexTable commandTable;
-  private final ListBox scheme;
-  private NativeMap<FetchInfo> fetch;
-
-  DownloadBox(ChangeInfo change, String revision, PatchSet.Id psId) {
-    this.change = change;
-    this.revision = revision;
-    this.psId = psId;
-    this.commandTable = new FlexTable();
-    this.scheme = new ListBox();
-    this.scheme.addChangeHandler(
-        new ChangeHandler() {
-          @Override
-          public void onChange(ChangeEvent event) {
-            renderCommands();
-            if (Gerrit.isSignedIn()) {
-              saveScheme();
-            }
-          }
-        });
-
-    setStyleName(Gerrit.RESOURCES.css().downloadBox());
-    commandTable.setStyleName(Gerrit.RESOURCES.css().downloadBoxTable());
-    scheme.setStyleName(Gerrit.RESOURCES.css().downloadBoxScheme());
-    add(commandTable);
-  }
-
-  @Override
-  protected void onLoad() {
-    if (fetch == null) {
-      if (psId.get() == 0) {
-        ChangeApi.editWithCommands(change.project(), change.legacyId().get())
-            .get(
-                new AsyncCallback<EditInfo>() {
-                  @Override
-                  public void onSuccess(EditInfo result) {
-                    fetch = result.fetch();
-                    renderScheme();
-                  }
-
-                  @Override
-                  public void onFailure(Throwable caught) {}
-                });
-      } else {
-        RestApi call = ChangeApi.detail(change.project(), change.legacyId().get());
-        ChangeList.addOptions(
-            call,
-            EnumSet.of(
-                revision.equals(change.currentRevision())
-                    ? ListChangesOption.CURRENT_REVISION
-                    : ListChangesOption.ALL_REVISIONS,
-                ListChangesOption.DOWNLOAD_COMMANDS));
-        call.get(
-            new AsyncCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo result) {
-                fetch = result.revision(revision).fetch();
-                renderScheme();
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {}
-            });
-      }
-    }
-  }
-
-  private void renderCommands() {
-    commandTable.removeAllRows();
-
-    if (scheme.getItemCount() > 0) {
-      FetchInfo fetchInfo = fetch.get(scheme.getValue(scheme.getSelectedIndex()));
-      for (String commandName : fetchInfo.commands().sortedKeys()) {
-        CopyableLabel copyLabel = new CopyableLabel(fetchInfo.command(commandName));
-        copyLabel.setStyleName(Gerrit.RESOURCES.css().downloadBoxCopyLabel());
-        insertCommand(commandName, copyLabel);
-      }
-    }
-    if (change.revision(revision).commit().parents().length() == 1) {
-      insertPatch();
-    }
-    insertArchive();
-    insertCommand(null, scheme);
-  }
-
-  private void insertPatch() {
-    String id = revision.substring(0, 7);
-    Anchor patchBase64 = new Anchor(id + ".diff.base64");
-    patchBase64.setHref(
-        new RestApi("/changes/")
-            .id(psId.getParentKey().get())
-            .view("revisions")
-            .id(revision)
-            .view("patch")
-            .addParameterTrue("download")
-            .url());
-
-    Anchor patchZip = new Anchor(id + ".diff.zip");
-    patchZip.setHref(
-        new RestApi("/changes/")
-            .id(psId.getParentKey().get())
-            .view("revisions")
-            .id(revision)
-            .view("patch")
-            .addParameterTrue("zip")
-            .url());
-
-    HorizontalPanel p = new HorizontalPanel();
-    p.add(patchBase64);
-    InlineLabel spacer = new InlineLabel("|");
-    spacer.setStyleName(Gerrit.RESOURCES.css().downloadBoxSpacer());
-    p.add(spacer);
-    p.add(patchZip);
-    insertCommand("Patch-File", p);
-  }
-
-  private void insertArchive() {
-    List<String> activated = Gerrit.info().download().archives();
-    if (activated.isEmpty()) {
-      return;
-    }
-
-    List<Anchor> anchors = new ArrayList<>(activated.size());
-    for (String f : activated) {
-      Anchor archive = new Anchor(f);
-      archive.setHref(
-          new RestApi("/changes/")
-              .id(psId.getParentKey().get())
-              .view("revisions")
-              .id(revision)
-              .view("archive")
-              .addParameter("format", f)
-              .url());
-      anchors.add(archive);
-    }
-
-    HorizontalPanel p = new HorizontalPanel();
-    Iterator<Anchor> it = anchors.iterator();
-    while (it.hasNext()) {
-      Anchor a = it.next();
-      p.add(a);
-      if (it.hasNext()) {
-        InlineLabel spacer = new InlineLabel("|");
-        spacer.setStyleName(Gerrit.RESOURCES.css().downloadBoxSpacer());
-        p.add(spacer);
-      }
-    }
-    insertCommand("Archive", p);
-  }
-
-  private void insertCommand(String commandName, Widget w) {
-    int row = commandTable.getRowCount();
-    commandTable.insertRow(row);
-    commandTable
-        .getCellFormatter()
-        .addStyleName(row, 0, Gerrit.RESOURCES.css().downloadBoxTableCommandColumn());
-    if (commandName != null) {
-      commandTable.setText(row, 0, commandName);
-    }
-    if (w != null) {
-      commandTable.setWidget(row, 1, w);
-    }
-  }
-
-  private void renderScheme() {
-    for (String id : fetch.sortedKeys()) {
-      scheme.addItem(id);
-    }
-    if (scheme.getItemCount() == 0) {
-      scheme.setVisible(false);
-    } else {
-      if (scheme.getItemCount() == 1) {
-        scheme.setSelectedIndex(0);
-        scheme.setVisible(false);
-      } else {
-        int select = 0;
-        String find = Gerrit.getUserPreferences().downloadScheme();
-        if (find != null) {
-          for (int i = 0; i < scheme.getItemCount(); i++) {
-            if (find.equals(scheme.getValue(i))) {
-              select = i;
-              break;
-            }
-          }
-        }
-        scheme.setSelectedIndex(select);
-      }
-    }
-    renderCommands();
-  }
-
-  private void saveScheme() {
-    String schemeStr = scheme.getValue(scheme.getSelectedIndex());
-    GeneralPreferences prefs = Gerrit.getUserPreferences();
-    if (Gerrit.isSignedIn() && !schemeStr.equals(prefs.downloadScheme())) {
-      prefs.downloadScheme(schemeStr);
-      GeneralPreferences in = GeneralPreferences.create();
-      in.downloadScheme(schemeStr);
-      AccountApi.self()
-          .view("preferences")
-          .put(
-              in,
-              new AsyncCallback<JavaScriptObject>() {
-                @Override
-                public void onSuccess(JavaScriptObject result) {}
-
-                @Override
-                public void onFailure(Throwable caught) {}
-              });
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
deleted file mode 100644
index f075c16..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
+++ /dev/null
@@ -1,69 +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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.ui.Button;
-
-public class EditActions {
-
-  static void deleteEdit(Project.NameKey project, Change.Id id, Button... editButtons) {
-    ChangeApi.deleteEdit(project.get(), id.get(), cs(project, id, editButtons));
-  }
-
-  static void publishEdit(Project.NameKey project, Change.Id id, Button... editButtons) {
-    ChangeApi.publishEdit(project.get(), id.get(), cs(project, id, editButtons));
-  }
-
-  static void rebaseEdit(Project.NameKey project, Change.Id id, Button... editButtons) {
-    ChangeApi.rebaseEdit(project.get(), id.get(), cs(project, id, editButtons));
-  }
-
-  public static GerritCallback<JavaScriptObject> cs(
-      Project.NameKey project, final Change.Id id, Button... editButtons) {
-    setEnabled(false, editButtons);
-    return new GerritCallback<JavaScriptObject>() {
-      @Override
-      public void onSuccess(JavaScriptObject result) {
-        Gerrit.display(PageLinks.toChange(project, id));
-      }
-
-      @Override
-      public void onFailure(Throwable err) {
-        setEnabled(true, editButtons);
-        if (SubmitFailureDialog.isConflict(err)) {
-          new SubmitFailureDialog(err.getMessage()).center();
-          Gerrit.display(PageLinks.toChange(project, id));
-        } else {
-          super.onFailure(err);
-        }
-      }
-    };
-  }
-
-  private static void setEnabled(boolean enabled, Button... editButtons) {
-    if (editButtons != null) {
-      for (Button b : editButtons) {
-        b.setEnabled(enabled);
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java
deleted file mode 100644
index 083c824..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import java.util.List;
-
-class FileComments extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, FileComments> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField InlineHyperlink path;
-  @UiField FlowPanel comments;
-
-  FileComments(
-      CommentLinkProcessor clp,
-      Project.NameKey project,
-      PatchSet.Id defaultPs,
-      String title,
-      List<CommentInfo> list) {
-    initWidget(uiBinder.createAndBindUi(this));
-
-    path.setTargetHistoryToken(url(project, defaultPs, list.get(0)));
-    path.setText(title);
-    for (CommentInfo c : list) {
-      comments.add(new LineComment(clp, project, defaultPs, c));
-    }
-  }
-
-  private static String url(Project.NameKey project, PatchSet.Id ps, CommentInfo info) {
-    return Dispatcher.toPatch(project, null, ps, info.path());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.ui.xml
deleted file mode 100644
index e463e95..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.ui.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gerrit.client.ui'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:style gss='false'>
-    .box {
-    }
-    .path {
-      display: block;
-      white-space: nowrap;
-    }
-    .comments {
-      margin-left: 1em;
-    }
-  </ui:style>
-
-  <g:HTMLPanel styleName='{style.box}'>
-    <c:InlineHyperlink styleName='{style.path}' ui:field='path'/>
-    <g:FlowPanel styleName='{style.comments}' ui:field='comments'/>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
deleted file mode 100644
index 30554b6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
+++ /dev/null
@@ -1,940 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import static com.google.gerrit.client.FormatUtil.formatAbsBytes;
-import static com.google.gerrit.client.FormatUtil.formatAbsPercentage;
-import static com.google.gerrit.client.FormatUtil.formatBytes;
-import static com.google.gerrit.client.FormatUtil.formatPercentage;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ChangeEditApi;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.changes.ReviewInfo;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.FileInfo;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.RepeatingCommand;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.InputElement;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.EventListener;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.ImageResourceRenderer;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwt.user.client.ui.impl.HyperlinkImpl;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.progress.client.ProgressBar;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.sql.Timestamp;
-
-public class FileTable extends FlowPanel {
-  private static final FileTableResources R = GWT.create(FileTableResources.class);
-
-  interface FileTableResources extends ClientBundle {
-    @Source("file_table.css")
-    FileTableCss css();
-  }
-
-  interface FileTableCss extends CssResource {
-    String table();
-
-    String nohover();
-
-    String pointer();
-
-    String reviewed();
-
-    String status();
-
-    String pathColumn();
-
-    String commonPrefix();
-
-    String renameCopySource();
-
-    String draftColumn();
-
-    String newColumn();
-
-    String commentColumn();
-
-    String deltaColumn1();
-
-    String deltaColumn2();
-
-    String inserted();
-
-    String deleted();
-
-    String restoreDelete();
-
-    String error();
-  }
-
-  public enum Mode {
-    REVIEW,
-    EDIT
-  }
-
-  private static final String DELETE;
-  private static final String RESTORE;
-  private static final String REVIEWED;
-  private static final String OPEN;
-  private static final int C_PATH = 3;
-  private static final HyperlinkImpl link = GWT.create(HyperlinkImpl.class);
-
-  static {
-    DELETE = DOM.createUniqueId().replace('-', '_');
-    RESTORE = DOM.createUniqueId().replace('-', '_');
-    REVIEWED = DOM.createUniqueId().replace('-', '_');
-    OPEN = DOM.createUniqueId().replace('-', '_');
-    init(DELETE, RESTORE, REVIEWED, OPEN);
-  }
-
-  private static native void init(String d, String t, String r, String o) /*-{
-    $wnd[d] = $entry(function(e,i) {
-      @com.google.gerrit.client.change.FileTable::onDelete(Lcom/google/gwt/dom/client/NativeEvent;I)(e,i)
-    });
-    $wnd[t] = $entry(function(e,i) {
-      @com.google.gerrit.client.change.FileTable::onRestore(Lcom/google/gwt/dom/client/NativeEvent;I)(e,i)
-    });
-    $wnd[r] = $entry(function(e,i) {
-      @com.google.gerrit.client.change.FileTable::onReviewed(Lcom/google/gwt/dom/client/NativeEvent;I)(e,i)
-    });
-    $wnd[o] = $entry(function(e,i) {
-      return @com.google.gerrit.client.change.FileTable::onOpen(Lcom/google/gwt/dom/client/NativeEvent;I)(e,i);
-    });
-  }-*/;
-
-  private static void onDelete(NativeEvent e, int idx) {
-    MyTable t = getMyTable(e);
-    if (t != null) {
-      t.onDelete(idx);
-    }
-  }
-
-  private static boolean onRestore(NativeEvent e, int idx) {
-    MyTable t = getMyTable(e);
-    if (t != null) {
-      t.onRestore(idx);
-      e.preventDefault();
-      e.stopPropagation();
-      return false;
-    }
-    return true;
-  }
-
-  private static void onReviewed(NativeEvent e, int idx) {
-    MyTable t = getMyTable(e);
-    if (t != null) {
-      t.onReviewed(InputElement.as(Element.as(e.getEventTarget())), idx);
-    }
-  }
-
-  private static boolean onOpen(NativeEvent e, int idx) {
-    if (link.handleAsClick(e.<Event>cast())) {
-      MyTable t = getMyTable(e);
-      if (t != null) {
-        t.onOpenRow(1 + idx);
-        e.preventDefault();
-        e.stopPropagation();
-        return false;
-      }
-    }
-    return true;
-  }
-
-  private static MyTable getMyTable(NativeEvent event) {
-    Element e = event.getEventTarget().cast();
-    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
-      EventListener l = DOM.getEventListener(e);
-      if (l instanceof MyTable) {
-        return (MyTable) l;
-      }
-    }
-    return null;
-  }
-
-  private DiffObject base;
-  private PatchSet.Id curr;
-  private Project.NameKey project;
-  private MyTable table;
-  private boolean register;
-  private JsArrayString reviewed;
-  private String scrollToPath;
-  private ChangeScreen.Style style;
-  private Widget replyButton;
-  private boolean editExists;
-  private Mode mode;
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    R.css().ensureInjected();
-  }
-
-  public void set(
-      DiffObject base,
-      PatchSet.Id curr,
-      Project.NameKey project,
-      ChangeScreen.Style style,
-      Widget replyButton,
-      Mode mode,
-      boolean editExists) {
-    this.base = base;
-    this.curr = curr;
-    this.project = project;
-    this.style = style;
-    this.replyButton = replyButton;
-    this.mode = mode;
-    this.editExists = editExists;
-  }
-
-  void setValue(
-      NativeMap<FileInfo> fileMap,
-      Timestamp myLastReply,
-      @Nullable NativeMap<JsArray<CommentInfo>> comments,
-      @Nullable NativeMap<JsArray<CommentInfo>> drafts) {
-    JsArray<FileInfo> list = fileMap.values();
-    FileInfo.sortFileInfoByPath(list);
-
-    DisplayCommand cmd = new DisplayCommand(fileMap, list, myLastReply, comments, drafts);
-    if (cmd.execute()) {
-      cmd.showProgressBar();
-      Scheduler.get().scheduleIncremental(cmd);
-    }
-  }
-
-  void showError(Throwable t) {
-    clear();
-    Label l = new Label(Resources.M.failedToLoadFileList(t.getMessage()));
-    add(l);
-    l.setStyleName(R.css().error());
-  }
-
-  void markReviewed(JsArrayString reviewed) {
-    if (table != null) {
-      table.markReviewed(reviewed);
-    } else {
-      this.reviewed = reviewed;
-    }
-  }
-
-  void unregisterKeys() {
-    register = false;
-
-    if (table != null) {
-      table.setRegisterKeys(false);
-    }
-  }
-
-  void registerKeys() {
-    register = true;
-
-    if (table != null) {
-      table.setRegisterKeys(true);
-    }
-  }
-
-  void scrollToPath(String path) {
-    if (table != null) {
-      table.scrollToPath(path);
-    } else {
-      scrollToPath = path;
-    }
-  }
-
-  void openAll() {
-    if (table != null) {
-      String self = Gerrit.selfRedirect(null);
-      for (FileInfo info : Natives.asList(table.list)) {
-        if (canOpen(info.path())) {
-          Window.open(self + "#" + url(info), "_blank", null);
-        }
-      }
-    }
-  }
-
-  private boolean canOpen(String path) {
-    return mode != Mode.EDIT || !Patch.isMagic(path) || Patch.COMMIT_MSG.equals(path);
-  }
-
-  private void setTable(MyTable table) {
-    clear();
-    add(table);
-    this.table = table;
-
-    if (register) {
-      table.setRegisterKeys(true);
-    }
-    if (reviewed != null) {
-      table.markReviewed(reviewed);
-      reviewed = null;
-    }
-    if (scrollToPath != null) {
-      table.scrollToPath(scrollToPath);
-      scrollToPath = null;
-    }
-  }
-
-  private String url(FileInfo info) {
-    return info.binary()
-        ? Dispatcher.toUnified(project, base, curr, info.path())
-        : mode == Mode.REVIEW
-            ? Dispatcher.toPatch(project, base, curr, info.path())
-            : Dispatcher.toEditScreen(project, curr, info.path());
-  }
-
-  private final class MyTable extends NavigationTable<FileInfo> {
-    private final NativeMap<FileInfo> map;
-    private final JsArray<FileInfo> list;
-
-    MyTable(NativeMap<FileInfo> map, JsArray<FileInfo> list) {
-      this.map = map;
-      this.list = list;
-      table.setWidth("");
-
-      keysNavigation.add(
-          new PrevKeyCommand(0, 'k', Util.C.patchTablePrev()),
-          new NextKeyCommand(0, 'j', Util.C.patchTableNext()));
-      keysNavigation.add(new OpenKeyCommand(0, 'o', Util.C.patchTableOpenDiff()));
-      keysNavigation.add(new OpenKeyCommand(0, KeyCodes.KEY_ENTER, Util.C.patchTableOpenDiff()));
-
-      keysNavigation.add(
-          new OpenFileCommand(list.length() - 1, 0, '[', Resources.C.openLastFile()),
-          new OpenFileCommand(0, 0, ']', Resources.C.openCommitMessage()));
-
-      keysAction.add(
-          new KeyCommand(0, 'r', PatchUtil.C.toggleReviewed()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              int row = getCurrentRow();
-              if (1 <= row && row <= MyTable.this.list.length()) {
-                FileInfo info = MyTable.this.list.get(row - 1);
-                InputElement b = getReviewed(info);
-                boolean c = !b.isChecked();
-                setReviewed(info, c);
-                b.setChecked(c);
-              }
-            }
-          });
-
-      setSavePointerId((!base.isBase() ? base.asString() + ".." : "") + curr.toString());
-    }
-
-    void onDelete(int idx) {
-      String path = list.get(idx).path();
-      ChangeEditApi.delete(
-          project.get(),
-          curr.getParentKey().get(),
-          path,
-          new AsyncCallback<VoidResult>() {
-            @Override
-            public void onSuccess(VoidResult result) {
-              Gerrit.display(PageLinks.toChangeInEditMode(project, curr.getParentKey()));
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {}
-          });
-    }
-
-    void onRestore(int idx) {
-      String path = list.get(idx).path();
-      ChangeEditApi.restore(
-          project.get(),
-          curr.getParentKey().get(),
-          path,
-          new AsyncCallback<VoidResult>() {
-            @Override
-            public void onSuccess(VoidResult result) {
-              Gerrit.display(PageLinks.toChangeInEditMode(project, curr.getParentKey()));
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {}
-          });
-    }
-
-    void onReviewed(InputElement checkbox, int idx) {
-      setReviewed(list.get(idx), checkbox.isChecked());
-    }
-
-    private void setReviewed(FileInfo info, boolean r) {
-      RestApi api =
-          ChangeApi.revision(project.get(), curr).view("files").id(info.path()).view("reviewed");
-      if (r) {
-        api.put(CallbackGroup.<ReviewInfo>emptyCallback());
-      } else {
-        api.delete(CallbackGroup.<ReviewInfo>emptyCallback());
-      }
-    }
-
-    void markReviewed(JsArrayString reviewed) {
-      for (int i = 0; i < reviewed.length(); i++) {
-        FileInfo info = map.get(reviewed.get(i));
-        if (info != null) {
-          getReviewed(info).setChecked(true);
-        }
-      }
-    }
-
-    private InputElement getReviewed(FileInfo info) {
-      CellFormatter fmt = table.getCellFormatter();
-      Element e = fmt.getElement(1 + info._row(), 1);
-      return InputElement.as(e.getFirstChildElement());
-    }
-
-    void scrollToPath(String path) {
-      FileInfo info = map.get(path);
-      if (info != null) {
-        movePointerTo(1 + info._row(), true);
-      }
-    }
-
-    @Override
-    protected Object getRowItemKey(FileInfo item) {
-      return item.path();
-    }
-
-    @Override
-    protected int findRow(Object id) {
-      FileInfo info = map.get((String) id);
-      return info != null ? 1 + info._row() : -1;
-    }
-
-    @Override
-    protected FileInfo getRowItem(int row) {
-      if (1 <= row && row <= list.length()) {
-        return list.get(row - 1);
-      }
-      return null;
-    }
-
-    @Override
-    protected void onOpenRow(int row) {
-      if (1 <= row && row <= list.length()) {
-        FileInfo info = list.get(row - 1);
-        if (canOpen(info.path())) {
-          Gerrit.display(url(info));
-        }
-      }
-    }
-
-    @Override
-    protected void onCellSingleClick(Event event, int row, int column) {
-      if (column == C_PATH && link.handleAsClick(event)) {
-        onOpenRow(row);
-      } else {
-        super.onCellSingleClick(event, row, column);
-      }
-    }
-
-    private class OpenFileCommand extends KeyCommand {
-      private final int index;
-
-      OpenFileCommand(int index, int modifiers, char c, String helpText) {
-        super(modifiers, c, helpText);
-        this.index = index;
-      }
-
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        FileInfo info = list.get(index);
-        if (canOpen(info.path())) {
-          Gerrit.display(url(info));
-        }
-      }
-    }
-  }
-
-  private final class DisplayCommand implements RepeatingCommand {
-    private final SafeHtmlBuilder sb = new SafeHtmlBuilder();
-    private final MyTable myTable;
-    private final JsArray<FileInfo> list;
-    private final Timestamp myLastReply;
-    private final NativeMap<JsArray<CommentInfo>> comments;
-    private final NativeMap<JsArray<CommentInfo>> drafts;
-    private final boolean hasUser;
-    private final boolean showChangeSizeBars;
-    private boolean attached;
-    private int row;
-    private double start;
-    private ProgressBar meter;
-    private String lastPath = "";
-
-    private boolean hasBinaryFile;
-    private boolean hasNonBinaryFile;
-    private int inserted;
-    private int deleted;
-    private long binOldSize;
-    private long bytesInserted;
-    private long bytesDeleted;
-
-    private DisplayCommand(
-        NativeMap<FileInfo> map,
-        JsArray<FileInfo> list,
-        Timestamp myLastReply,
-        @Nullable NativeMap<JsArray<CommentInfo>> comments,
-        @Nullable NativeMap<JsArray<CommentInfo>> drafts) {
-      this.myTable = new MyTable(map, list);
-      this.list = list;
-      this.myLastReply = myLastReply;
-      this.comments = comments;
-      this.drafts = drafts;
-      this.hasUser = Gerrit.isSignedIn();
-      this.showChangeSizeBars = Gerrit.getUserPreferences().sizeBarInChangeTable();
-      myTable.addStyleName(R.css().table());
-    }
-
-    @Override
-    public boolean execute() {
-      boolean attachedNow = isAttached();
-      if (!attached && attachedNow) {
-        // Remember that we have been attached at least once. If
-        // later we find we aren't attached we should stop running.
-        attached = true;
-      } else if (attached && !attachedNow) {
-        // If the user navigated away, we aren't in the DOM anymore.
-        // Don't continue to render.
-        return false;
-      }
-
-      start = System.currentTimeMillis();
-      if (row == 0) {
-        header(sb);
-        computeInsertedDeleted();
-      }
-      while (row < list.length()) {
-        FileInfo info = list.get(row);
-        info._row(row);
-        render(sb, info);
-        if ((++row % 10) == 0 && longRunning()) {
-          updateMeter();
-          return true;
-        }
-      }
-      footer(sb);
-      myTable.resetHtml(sb);
-      myTable.finishDisplay();
-      setTable(myTable);
-      return false;
-    }
-
-    private void computeInsertedDeleted() {
-      inserted = 0;
-      deleted = 0;
-      binOldSize = 0;
-      bytesInserted = 0;
-      bytesDeleted = 0;
-      for (int i = 0; i < list.length(); i++) {
-        FileInfo info = list.get(i);
-        if (!Patch.isMagic(info.path())) {
-          if (!info.binary()) {
-            hasNonBinaryFile = true;
-            inserted += info.linesInserted();
-            deleted += info.linesDeleted();
-          } else {
-            hasBinaryFile = true;
-            binOldSize += info.size() - info.sizeDelta();
-            if (info.sizeDelta() >= 0) {
-              bytesInserted += info.sizeDelta();
-            } else {
-              bytesDeleted += info.sizeDelta();
-            }
-          }
-        }
-      }
-    }
-
-    void showProgressBar() {
-      if (meter == null) {
-        meter = new ProgressBar(Util.M.loadingPatchSet(curr.get()));
-        FileTable.this.clear();
-        FileTable.this.add(meter);
-      }
-      updateMeter();
-    }
-
-    void updateMeter() {
-      if (meter != null) {
-        int n = list.length();
-        meter.setValue((100 * row) / n);
-      }
-    }
-
-    private boolean longRunning() {
-      return System.currentTimeMillis() - start > 200;
-    }
-
-    private void header(SafeHtmlBuilder sb) {
-      sb.openTr().setStyleName(R.css().nohover());
-      sb.openTh().setStyleName(R.css().pointer()).closeTh();
-      if (mode == Mode.REVIEW) {
-        sb.openTh().setStyleName(R.css().reviewed()).closeTh();
-      } else {
-        sb.openTh().setStyleName(R.css().restoreDelete()).closeTh();
-      }
-      sb.openTh().setStyleName(R.css().status()).closeTh();
-      sb.openTh().append(Util.C.patchTableColumnName()).closeTh();
-      sb.openTh().setAttribute("colspan", 3).append(Util.C.patchTableColumnComments()).closeTh();
-      sb.openTh().setAttribute("colspan", 2).append(Util.C.patchTableColumnSize()).closeTh();
-      sb.closeTr();
-    }
-
-    private void render(SafeHtmlBuilder sb, FileInfo info) {
-      sb.openTr();
-      sb.openTd().setStyleName(R.css().pointer()).closeTd();
-      if (mode == Mode.REVIEW) {
-        columnReviewed(sb, info);
-      } else {
-        columnDeleteRestore(sb, info);
-      }
-      columnStatus(sb, info);
-      columnPath(sb, info);
-      columnComments(sb, info);
-      columnDelta1(sb, info);
-      columnDelta2(sb, info);
-      sb.closeTr();
-    }
-
-    private void columnReviewed(SafeHtmlBuilder sb, FileInfo info) {
-      sb.openTd().setStyleName(R.css().reviewed());
-      if (hasUser) {
-        sb.openElement("input")
-            .setAttribute("title", Resources.C.reviewedFileTitle())
-            .setAttribute("type", "checkbox")
-            .setAttribute("onclick", REVIEWED + "(event," + info._row() + ")")
-            .closeSelf();
-      }
-      sb.closeTd();
-    }
-
-    private void columnDeleteRestore(SafeHtmlBuilder sb, FileInfo info) {
-      sb.openTd().setStyleName(R.css().restoreDelete());
-      if (hasUser) {
-        if (!Patch.isMagic(info.path())) {
-          boolean editable = isEditable(info);
-          sb.openDiv()
-              .openElement("button")
-              .setAttribute("title", Resources.C.restoreFileInline())
-              .setAttribute("onclick", RESTORE + "(event," + info._row() + ")")
-              .append(new ImageResourceRenderer().render(Gerrit.RESOURCES.editUndo()))
-              .closeElement("button");
-          if (editable) {
-            sb.openElement("button")
-                .setAttribute("title", Resources.C.removeFileInline())
-                .setAttribute("onclick", DELETE + "(event," + info._row() + ")")
-                .append(new ImageResourceRenderer().render(Gerrit.RESOURCES.redNot()))
-                .closeElement("button");
-          }
-          sb.closeDiv();
-        }
-      }
-      sb.closeTd();
-    }
-
-    private boolean isEditable(FileInfo info) {
-      String status = info.status();
-      return status == null || !ChangeType.DELETED.matches(status);
-    }
-
-    private void columnStatus(SafeHtmlBuilder sb, FileInfo info) {
-      sb.openTd().setStyleName(R.css().status());
-      if (!Patch.isMagic(info.path())
-          && info.status() != null
-          && !ChangeType.MODIFIED.matches(info.status())) {
-        sb.append(info.status());
-      }
-      sb.closeTd();
-    }
-
-    private void columnPath(SafeHtmlBuilder sb, FileInfo info) {
-      String path = info.path();
-
-      sb.openTd().setStyleName(R.css().pathColumn());
-
-      if (!canOpen(path)) {
-        sb.openDiv();
-        appendPath(path);
-        sb.closeDiv();
-        sb.closeTd();
-        return;
-      }
-
-      sb.openAnchor();
-
-      if (mode == Mode.EDIT && !isEditable(info)) {
-        sb.setAttribute("onclick", RESTORE + "(event," + info._row() + ")");
-      } else {
-        sb.setAttribute("href", "#" + url(info))
-            .setAttribute("onclick", OPEN + "(event," + info._row() + ")");
-      }
-      appendPath(path);
-      sb.closeAnchor();
-      if (info.oldPath() != null) {
-        sb.br();
-        sb.openSpan().setStyleName(R.css().renameCopySource()).append(info.oldPath()).closeSpan();
-      }
-      sb.closeTd();
-    }
-
-    private void appendPath(String path) {
-      if (Patch.COMMIT_MSG.equals(path)) {
-        sb.append(Util.C.commitMessage());
-      } else if (Patch.MERGE_LIST.equals(path)) {
-        sb.append(Util.C.mergeList());
-      } else if (Gerrit.getUserPreferences().muteCommonPathPrefixes()) {
-        int commonPrefixLen = commonPrefix(path);
-        if (commonPrefixLen > 0) {
-          sb.openSpan()
-              .setStyleName(R.css().commonPrefix())
-              .append(path.substring(0, commonPrefixLen))
-              .closeSpan();
-        }
-        sb.append(path.substring(commonPrefixLen));
-        lastPath = path;
-      } else {
-        sb.append(path);
-      }
-    }
-
-    private int commonPrefix(String path) {
-      for (int n = path.length(); n > 0; ) {
-        int s = path.lastIndexOf('/', n);
-        if (s < 0) {
-          return 0;
-        }
-
-        String p = path.substring(0, s + 1);
-        if (lastPath.startsWith(p)) {
-          return s + 1;
-        }
-        n = s - 1;
-      }
-      return 0;
-    }
-
-    private void columnComments(SafeHtmlBuilder sb, FileInfo info) {
-      JsArray<CommentInfo> cList = filterForParent(get(info.path(), comments));
-      JsArray<CommentInfo> dList = filterForParent(get(info.path(), drafts));
-
-      sb.openTd().setStyleName(R.css().draftColumn());
-      if (dList.length() > 0) {
-        sb.append("drafts: ").append(dList.length());
-      }
-      sb.closeTd();
-
-      int cntAll = cList.length();
-      int cntNew = 0;
-      if (myLastReply != null) {
-        for (int i = cntAll - 1; i >= 0; i--) {
-          CommentInfo m = cList.get(i);
-          if (m.updated().compareTo(myLastReply) > 0) {
-            cntNew++;
-          } else {
-            break;
-          }
-        }
-      }
-
-      sb.openTd().setStyleName(R.css().newColumn());
-      if (cntNew > 0) {
-        sb.append("new: ").append(cntNew);
-      }
-      sb.closeTd();
-
-      sb.openTd().setStyleName(R.css().commentColumn());
-      if (cntAll - cntNew > 0) {
-        sb.append("comments: ").append(cntAll - cntNew);
-      }
-      sb.closeTd();
-    }
-
-    private JsArray<CommentInfo> filterForParent(JsArray<CommentInfo> list) {
-      JsArray<CommentInfo> result = JsArray.createArray().cast();
-      for (CommentInfo c : Natives.asList(list)) {
-        if (c.side() == Side.REVISION) {
-          result.push(c);
-        } else if (base.isBaseOrAutoMerge() && !c.hasParent()) {
-          result.push(c);
-        } else if (base.isParent() && c.parent() == base.getParentNum()) {
-          result.push(c);
-        }
-      }
-      return result;
-    }
-
-    private JsArray<CommentInfo> get(String p, NativeMap<JsArray<CommentInfo>> m) {
-      JsArray<CommentInfo> r = null;
-      if (m != null) {
-        r = m.get(p);
-      }
-      if (r == null) {
-        r = JsArray.createArray().cast();
-      }
-      return r;
-    }
-
-    private void columnDelta1(SafeHtmlBuilder sb, FileInfo info) {
-      sb.openTd().setStyleName(R.css().deltaColumn1());
-      if (!Patch.isMagic(info.path()) && !info.binary()) {
-        if (showChangeSizeBars) {
-          sb.append(info.linesInserted() + info.linesDeleted());
-        } else if (!ChangeType.DELETED.matches(info.status())) {
-          if (ChangeType.ADDED.matches(info.status())) {
-            sb.append(info.linesInserted()).append(" lines");
-          } else {
-            sb.append("+").append(info.linesInserted()).append(", -").append(info.linesDeleted());
-          }
-        }
-      } else if (info.binary()) {
-        sb.append(formatBytes(info.sizeDelta()));
-        long oldSize = info.size() - info.sizeDelta();
-        if (oldSize != 0) {
-          sb.append(" (").append(formatPercentage(oldSize, info.sizeDelta())).append(")");
-        }
-      }
-      sb.closeTd();
-    }
-
-    private void columnDelta2(SafeHtmlBuilder sb, FileInfo info) {
-      sb.openTd().setStyleName(R.css().deltaColumn2());
-      if (showChangeSizeBars
-          && !Patch.isMagic(info.path())
-          && !info.binary()
-          && (info.linesInserted() != 0 || info.linesDeleted() != 0)) {
-        int w = 80;
-        int t = inserted + deleted;
-        int i = Math.max(5, (int) (((double) w) * info.linesInserted() / t));
-        int d = Math.max(5, (int) (((double) w) * info.linesDeleted() / t));
-
-        sb.setAttribute(
-            "title", Util.M.patchTableSize_LongModify(info.linesInserted(), info.linesDeleted()));
-
-        if (0 < info.linesInserted()) {
-          sb.openDiv()
-              .setStyleName(R.css().inserted())
-              .setAttribute("style", "width:" + i + "px")
-              .closeDiv();
-        }
-        if (0 < info.linesDeleted()) {
-          sb.openDiv()
-              .setStyleName(R.css().deleted())
-              .setAttribute("style", "width:" + d + "px")
-              .closeDiv();
-        }
-      }
-      sb.closeTd();
-    }
-
-    private void footer(SafeHtmlBuilder sb) {
-      sb.openTr().setStyleName(R.css().nohover());
-      sb.openTh().setStyleName(R.css().pointer()).closeTh();
-      if (mode == Mode.REVIEW) {
-        sb.openTh().setStyleName(R.css().reviewed()).closeTh();
-      } else {
-        sb.openTh().setStyleName(R.css().restoreDelete()).closeTh();
-      }
-      sb.openTh().setStyleName(R.css().status()).closeTh();
-      sb.openTd().closeTd(); // path
-      sb.openTd().setAttribute("colspan", 3).closeTd(); // comments
-
-      // delta1
-      sb.openTh().setStyleName(R.css().deltaColumn1());
-      if (hasNonBinaryFile) {
-        sb.append(Util.M.patchTableSize_Modify(inserted, deleted));
-      }
-      if (hasBinaryFile) {
-        if (hasNonBinaryFile) {
-          sb.br();
-        }
-        if (binOldSize != 0) {
-          sb.append(
-              Util.M.patchTableSize_ModifyBinaryFilesWithPercentages(
-                  formatAbsBytes(bytesInserted),
-                  formatAbsPercentage(binOldSize, bytesInserted),
-                  formatAbsBytes(bytesDeleted),
-                  formatAbsPercentage(binOldSize, bytesDeleted)));
-        } else {
-          sb.append(
-              Util.M.patchTableSize_ModifyBinaryFiles(
-                  formatAbsBytes(bytesInserted), formatAbsBytes(bytesDeleted)));
-        }
-      }
-      sb.closeTh();
-
-      // delta2
-      sb.openTh().setStyleName(R.css().deltaColumn2());
-      if (showChangeSizeBars) {
-        int w = 80;
-        int t = inserted + deleted;
-        int i = Math.max(1, (int) (((double) w) * inserted / t));
-        int d = Math.max(1, (int) (((double) w) * deleted / t));
-        if (i + d > w && i > d) {
-          i = w - d;
-        } else if (i + d > w && d > i) {
-          d = w - i;
-        }
-        if (0 < inserted) {
-          sb.openDiv()
-              .setStyleName(R.css().inserted())
-              .setAttribute("style", "width:" + i + "px")
-              .closeDiv();
-        }
-        if (0 < deleted) {
-          sb.openDiv()
-              .setStyleName(R.css().deleted())
-              .setAttribute("style", "width:" + d + "px")
-              .closeDiv();
-        }
-      }
-      sb.closeTh();
-
-      sb.closeTr();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java
deleted file mode 100644
index a4c90b8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.user.client.ui.Button;
-
-class FollowUpAction extends ActionMessageBox {
-  private final String project;
-  private final String branch;
-  private final String topic;
-  private final String base;
-
-  FollowUpAction(Button b, String project, String branch, String topic, String key) {
-    super(b);
-    this.project = project;
-    this.branch = branch;
-    this.topic = topic;
-    this.base = project + "~" + branch + "~" + key;
-  }
-
-  @Override
-  void send(String message) {
-    ChangeApi.createChange(
-        project,
-        branch,
-        topic,
-        message,
-        base,
-        new GerritCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo result) {
-            Gerrit.display(PageLinks.toChange(result.projectNameKey(), result.legacyId()));
-            hide();
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java
deleted file mode 100644
index 1044828..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java
+++ /dev/null
@@ -1,272 +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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.rpc.StatusCodeException;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.ImageResourceRenderer;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.Iterator;
-
-public class Hashtags extends Composite {
-
-  interface Binder extends UiBinder<HTMLPanel, Hashtags> {}
-
-  private static final int VISIBLE_LENGTH = 55;
-  private static final Binder uiBinder = GWT.create(Binder.class);
-  private static final String REMOVE;
-  private static final String DATA_ID = "data-id";
-
-  private PatchSet.Id psId;
-  private boolean canEdit;
-
-  static {
-    REMOVE = DOM.createUniqueId().replace('-', '_');
-    init(REMOVE);
-  }
-
-  private static native void init(String r) /*-{
-    $wnd[r] = $entry(function(e) {
-      @com.google.gerrit.client.change.Hashtags::onRemove(Lcom/google/gwt/dom/client/NativeEvent;)(e)
-    });
-  }-*/;
-
-  private static void onRemove(NativeEvent event) {
-    String hashtags = getDataId(event);
-    if (hashtags != null) {
-      final ChangeScreen screen = ChangeScreen.get(event);
-      final PatchSet.Id psId = screen.getPatchSetId();
-      ChangeApi.hashtags(screen.getProject().get(), psId.getParentKey().get())
-          .post(
-              PostInput.create(null, hashtags),
-              new GerritCallback<JavaScriptObject>() {
-                @Override
-                public void onSuccess(JavaScriptObject result) {
-                  if (screen.isCurrentView()) {
-                    Gerrit.display(PageLinks.toChange(screen.getProject(), psId));
-                  }
-                }
-              });
-    }
-  }
-
-  private static String getDataId(NativeEvent event) {
-    Element e = event.getEventTarget().cast();
-    while (e != null) {
-      String v = e.getAttribute(DATA_ID);
-      if (!v.isEmpty()) {
-        return v;
-      }
-      e = e.getParentElement();
-    }
-    return null;
-  }
-
-  @UiField Element hashtagsText;
-  @UiField Image addHashtagIcon;
-  @UiField Element form;
-  @UiField Element error;
-  @UiField NpTextBox hashtagTextBox;
-
-  private ChangeScreen.Style style;
-  private Change.Id changeId;
-  private Project.NameKey project;
-
-  public Hashtags() {
-
-    initWidget(uiBinder.createAndBindUi(this));
-
-    hashtagTextBox.setVisibleLength(VISIBLE_LENGTH);
-    hashtagTextBox.addKeyDownHandler(
-        new KeyDownHandler() {
-          @Override
-          public void onKeyDown(KeyDownEvent e) {
-            if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
-              onCancel(null);
-            } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
-              onAdd(null);
-            }
-          }
-        });
-
-    addHashtagIcon.addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            onOpenForm();
-          }
-        },
-        ClickEvent.getType());
-  }
-
-  void init(ChangeScreen.Style style) {
-    this.style = style;
-  }
-
-  void set(ChangeInfo info, String revision) {
-    psId = new PatchSet.Id(info.legacyId(), info.revisions().get(revision)._number());
-    project = info.projectNameKey();
-
-    canEdit = info.hasActions() && info.actions().containsKey("hashtags");
-    this.changeId = info.legacyId();
-    display(info);
-    addHashtagIcon.setVisible(canEdit);
-  }
-
-  void onOpenForm() {
-    UIObject.setVisible(form, true);
-    UIObject.setVisible(error, false);
-    addHashtagIcon.setVisible(false);
-    hashtagTextBox.setFocus(true);
-  }
-
-  private void display(ChangeInfo info) {
-    hashtagsText.setInnerSafeHtml(formatHashtags(info));
-  }
-
-  private void display(JsArrayString hashtags) {
-    hashtagsText.setInnerSafeHtml(formatHashtags(hashtags));
-  }
-
-  private SafeHtmlBuilder formatHashtags(ChangeInfo info) {
-    if (info.hashtags() != null) {
-      return formatHashtags(info.hashtags());
-    }
-    return new SafeHtmlBuilder();
-  }
-
-  private SafeHtmlBuilder formatHashtags(JsArrayString hashtags) {
-    SafeHtmlBuilder html = new SafeHtmlBuilder();
-    Iterator<String> itr = Natives.asList(hashtags).iterator();
-    while (itr.hasNext()) {
-      String hashtagName = itr.next();
-      html.openSpan()
-          .setAttribute(DATA_ID, hashtagName)
-          .setStyleName(style.hashtagName())
-          .openAnchor()
-          .setAttribute("href", "#" + PageLinks.toChangeQuery("hashtag:\"" + hashtagName + "\""))
-          .setAttribute("role", "listitem")
-          .openSpan()
-          .setStyleName(style.hashtagIcon())
-          .append(new ImageResourceRenderer().render(Gerrit.RESOURCES.hashtag()))
-          .closeSpan()
-          .append(" ")
-          .append(hashtagName)
-          .closeAnchor();
-      if (canEdit) {
-        html.openElement("button")
-            .setAttribute("title", "Remove hashtag")
-            .setAttribute("onclick", REMOVE + "(event)")
-            .append("×")
-            .closeElement("button");
-      }
-      html.closeSpan();
-      if (itr.hasNext()) {
-        html.append(' ');
-      }
-    }
-    return html;
-  }
-
-  @UiHandler("cancel")
-  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    addHashtagIcon.setVisible(true);
-    UIObject.setVisible(form, false);
-    hashtagTextBox.setFocus(false);
-  }
-
-  @UiHandler("add")
-  void onAdd(@SuppressWarnings("unused") ClickEvent e) {
-    String hashtag = hashtagTextBox.getText();
-    if (!hashtag.isEmpty()) {
-      addHashtag(hashtag);
-    }
-  }
-
-  private void addHashtag(String hashtags) {
-    ChangeApi.hashtags(project.get(), changeId.get())
-        .post(
-            PostInput.create(hashtags, null),
-            new GerritCallback<JsArrayString>() {
-              @Override
-              public void onSuccess(JsArrayString result) {
-                Gerrit.display(
-                    PageLinks.toChange(project, psId.getParentKey(), String.valueOf(psId.get())));
-              }
-
-              @Override
-              public void onFailure(Throwable err) {
-                UIObject.setVisible(error, true);
-                error.setInnerText(
-                    err instanceof StatusCodeException
-                        ? ((StatusCodeException) err).getEncodedResponse()
-                        : err.getMessage());
-                hashtagTextBox.setEnabled(true);
-              }
-            });
-  }
-
-  public static class PostInput extends JavaScriptObject {
-    public static PostInput create(String add, String remove) {
-      PostInput input = createObject().cast();
-      input.init(toJsArrayString(add), toJsArrayString(remove));
-      return input;
-    }
-
-    private static JsArrayString toJsArrayString(String commaSeparated) {
-      if (commaSeparated == null || commaSeparated.equals("")) {
-        return null;
-      }
-      JsArrayString array = JsArrayString.createArray().cast();
-      for (String hashtag : commaSeparated.split(",")) {
-        array.push(hashtag.trim());
-      }
-      return array;
-    }
-
-    private native void init(JsArrayString add, JsArrayString remove) /*-{
-      this.add = add;
-      this.remove = remove;
-    }-*/;
-
-    protected PostInput() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.ui.xml
deleted file mode 100644
index c0bfd1c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.ui.xml
+++ /dev/null
@@ -1,82 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-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.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false'>
-    button.openAdd {
-      margin: 3px 3px 0 0;
-      float: right;
-      color: rgba(0, 0, 0, 0.15);
-      background-color: #f5f5f5;
-      background-image: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1);
-      -webkit-border-radius: 2px;
-      -moz-border-radius: 2px;
-      border-radius: 2px;
-      -webkit-box-sizing: content-box;
-      -moz-box-sizing: content-box;
-      box-sizing: content-box;
-    }
-    button.openAdd div {
-      width: auto;
-      color: #444;
-    }
-
-    .hashtagTextBox {
-      margin-bottom: 2px;
-    }
-
-    .error {
-      color: #D33D3D;
-      font-weight: bold;
-    }
-
-    .addHashtag,
-    .cancel {
-      cursor: pointer;
-      float: right;
-    }
-  </ui:style>
-  <g:HTMLPanel>
-    <div>
-      <span ui:field='hashtagsText'/>
-      <g:Image ui:field='addHashtagIcon'
-        resource='{ico.addHashtag}'
-        styleName='{style.addHashtag}'
-        title='Add Hashtag'/>
-    </div>
-    <div ui:field='form' style='display: none' aria-hidden='true'>
-      <c:NpTextBox ui:field='hashtagTextBox' styleName='{style.hashtagTextBox}'/>
-      <div ui:field='error'
-           class='{style.error}'
-           style='display: none' aria-hidden='true'/>
-      <div>
-        <g:Button ui:field='add' styleName='{res.style.button}'>
-          <div>Add</div>
-        </g:Button>
-        <g:Button ui:field='cancel'
-            styleName='{res.style.button}'
-            addStyleNames='{style.cancel}'>
-          <div>Cancel</div>
-        </g:Button>
-      </div>
-    </div>
-   </g:HTMLPanel>
-  </ui:UiBinder>
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java
deleted file mode 100644
index 55e021f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.MessageInfo;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Widget;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-class History extends FlowPanel {
-  private CommentLinkProcessor clp;
-  private ReplyAction replyAction;
-  private Change.Id changeId;
-  private Project.NameKey project;
-
-  private final Map<Integer, List<CommentInfo>> byAuthor = new HashMap<>();
-
-  void set(CommentLinkProcessor clp, ReplyAction ra, Change.Id id, ChangeInfo info) {
-    this.clp = clp;
-    this.replyAction = ra;
-    this.changeId = id;
-    this.project = info.projectNameKey();
-
-    JsArray<MessageInfo> messages = info.messages();
-    if (messages != null) {
-      for (MessageInfo msg : Natives.asList(messages)) {
-        Message ui = new Message(this, msg);
-        ui.addComments(comments(msg));
-        add(ui);
-      }
-      autoOpen(ChangeScreen.myLastReply(info));
-    }
-  }
-
-  private void autoOpen(Timestamp lastReply) {
-    if (lastReply == null) {
-      for (Widget child : getChildren()) {
-        ((Message) child).autoOpen();
-      }
-    } else {
-      for (int i = getChildren().size() - 1; i >= 0; i--) {
-        Message ui = (Message) getChildren().get(i);
-        MessageInfo msg = ui.getMessageInfo();
-        if (lastReply.compareTo(msg.date()) < 0) {
-          ui.autoOpen();
-        } else {
-          break;
-        }
-      }
-    }
-  }
-
-  CommentLinkProcessor getCommentLinkProcessor() {
-    return clp;
-  }
-
-  Change.Id getChangeId() {
-    return changeId;
-  }
-
-  Project.NameKey getProject() {
-    return project;
-  }
-
-  void replyTo(MessageInfo info) {
-    replyAction.onReply(info);
-  }
-
-  void addComments(NativeMap<JsArray<CommentInfo>> map) {
-    for (String path : map.keySet()) {
-      for (CommentInfo c : Natives.asList(map.get(path))) {
-        c.path(path);
-        if (c.author() != null) {
-          int authorId = c.author()._accountId();
-          List<CommentInfo> l = byAuthor.get(authorId);
-          if (l == null) {
-            l = new ArrayList<>();
-            byAuthor.put(authorId, l);
-          }
-          l.add(c);
-        }
-      }
-    }
-  }
-
-  private List<CommentInfo> comments(MessageInfo msg) {
-    if (msg.author() == null) {
-      return Collections.emptyList();
-    }
-
-    int authorId = msg.author()._accountId();
-    List<CommentInfo> list = byAuthor.get(authorId);
-    if (list == null) {
-      return Collections.emptyList();
-    }
-
-    Timestamp when = msg.date();
-    List<CommentInfo> match = new ArrayList<>();
-    List<CommentInfo> other = new ArrayList<>();
-    for (CommentInfo c : list) {
-      if (c.updated().compareTo(when) <= 0) {
-        match.add(c);
-      } else {
-        other.add(c);
-      }
-    }
-    if (match.isEmpty()) {
-      return Collections.emptyList();
-    } else if (other.isEmpty()) {
-      byAuthor.remove(authorId);
-    } else {
-      byAuthor.put(authorId, other);
-    }
-    return match;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java
deleted file mode 100644
index 5557f90..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-
-class IncludedInAction extends RightSidePopdownAction {
-  private final IncludedInBox includedInBox;
-
-  IncludedInAction(
-      Project.NameKey project,
-      Change.Id changeId,
-      ChangeScreen.Style style,
-      UIObject relativeTo,
-      Widget includedInButton) {
-    super(style, relativeTo, includedInButton);
-    this.includedInBox = new IncludedInBox(project, changeId);
-  }
-
-  @Override
-  Widget getWidget() {
-    return includedInBox;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java
deleted file mode 100644
index 9751f54..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java
+++ /dev/null
@@ -1,106 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo.IncludedInInfo;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.Document;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.TableCellElement;
-import com.google.gwt.dom.client.TableElement;
-import com.google.gwt.dom.client.TableRowElement;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.safehtml.shared.SafeHtml;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-class IncludedInBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, IncludedInBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface Style extends CssResource {
-    String includedInElement();
-  }
-
-  private final Project.NameKey project;
-  private final Change.Id changeId;
-  private boolean loaded;
-
-  @UiField Style style;
-  @UiField TableElement table;
-  @UiField Element branches;
-  @UiField Element tags;
-
-  IncludedInBox(Project.NameKey project, Change.Id changeId) {
-    this.project = project;
-    this.changeId = changeId;
-    initWidget(uiBinder.createAndBindUi(this));
-  }
-
-  @Override
-  protected void onLoad() {
-    if (!loaded) {
-      ChangeApi.includedIn(
-          project.get(),
-          changeId.get(),
-          new AsyncCallback<IncludedInInfo>() {
-            @Override
-            public void onSuccess(IncludedInInfo r) {
-              branches.setInnerSafeHtml(formatList(r.branches()));
-              tags.setInnerSafeHtml(formatList(r.tags()));
-              for (String n : r.externalNames()) {
-                JsArrayString external = r.external(n);
-                if (external.length() > 0) {
-                  appendRow(n, external);
-                }
-              }
-              loaded = true;
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {}
-          });
-    }
-  }
-
-  private SafeHtml formatList(JsArrayString l) {
-    SafeHtmlBuilder html = new SafeHtmlBuilder();
-    int size = l.length();
-    for (int i = 0; i < size; i++) {
-      html.openSpan().addStyleName(style.includedInElement()).append(l.get(i)).closeSpan();
-      if (i < size - 1) {
-        html.append(", ");
-      }
-    }
-    return html;
-  }
-
-  private void appendRow(String title, JsArrayString l) {
-    TableRowElement row = table.insertRow(-1);
-    TableCellElement th = Document.get().createTHElement();
-    th.setInnerText(title);
-    row.appendChild(th);
-    row.insertCell(-1).setInnerSafeHtml(formatList(l));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.ui.xml
deleted file mode 100644
index 36ac734..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.ui.xml
+++ /dev/null
@@ -1,77 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:style gss='false' type='com.google.gerrit.client.change.IncludedInBox.Style'>
-    .includedInBox {
-      min-width: 300px;
-      max-width: 580px;
-      margin: 5px;
-    }
-
-    .includedInTable {
-      border-spacing: 0;
-    }
-
-    .includedInTable th {
-      width: 60px;
-      color: #444;
-      font-weight: normal;
-      vertical-align: top;
-      text-align: left;
-      padding-right: 5px;
-    }
-
-    .includedInElement {
-      font-size: smaller;
-      font-family: monospace;
-    }
-
-    .includedInElement span {
-      width: 500px;
-      white-space: nowrap;
-      display: inline-block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-    }
-
-    .includedInElement .gwt-TextBox {
-      padding: 0;
-      margin: 0;
-      border: 0;
-      max-height: 18px;
-      width: 500px;
-    }
-
-    .includedInElement div {
-      float: right;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.includedInBox}'>
-    <table class='{style.includedInTable}' ui:field='table'>
-      <tr>
-        <th><ui:msg>Branches</ui:msg></th>
-          <td ui:field='branches'/>
-      </tr>
-      <tr>
-        <th><ui:msg>Tags</ui:msg></th>
-          <td ui:field='tags'/>
-      </tr>
-    </table>
-  </g:HTMLPanel>
-</ui:UiBinder>
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
deleted file mode 100644
index 1f4820f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
+++ /dev/null
@@ -1,347 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.AccountInfo.AvatarInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.ApprovalInfo;
-import com.google.gerrit.client.info.ChangeInfo.LabelInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.Widget;
-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.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-/** Displays a table of label and reviewer scores. */
-class Labels extends Grid {
-  private static final String DATA_ID = "data-id";
-  private static final String DATA_VOTE = "data-vote";
-  private static final String REMOVE_REVIEWER;
-  private static final String REMOVE_VOTE;
-
-  static {
-    REMOVE_REVIEWER = DOM.createUniqueId().replace('-', '_');
-    REMOVE_VOTE = DOM.createUniqueId().replace('-', '_');
-    init(REMOVE_REVIEWER, REMOVE_VOTE);
-  }
-
-  private static native void init(String r, String v) /*-{
-    $wnd[r] = $entry(function(e) {
-      @com.google.gerrit.client.change.Labels::onRemoveReviewer(Lcom/google/gwt/dom/client/NativeEvent;)(e)
-    });
-    $wnd[v] = $entry(function(e) {
-      @com.google.gerrit.client.change.Labels::onRemoveVote(Lcom/google/gwt/dom/client/NativeEvent;)(e)
-    });
-  }-*/;
-
-  private static void onRemoveReviewer(NativeEvent event) {
-    Integer user = getDataId(event);
-    if (user != null) {
-      final ChangeScreen screen = ChangeScreen.get(event);
-      final Change.Id changeId = screen.getPatchSetId().getParentKey();
-      ChangeApi.reviewer(screen.getProject().get(), changeId.get(), user)
-          .delete(
-              new GerritCallback<JavaScriptObject>() {
-                @Override
-                public void onSuccess(JavaScriptObject result) {
-                  if (screen.isCurrentView()) {
-                    Gerrit.display(PageLinks.toChange(screen.getProject(), changeId));
-                  }
-                }
-              });
-    }
-  }
-
-  private static void onRemoveVote(NativeEvent event) {
-    Integer user = getDataId(event);
-    String vote = getVoteId(event);
-    if (user != null && vote != null) {
-      final ChangeScreen screen = ChangeScreen.get(event);
-      final Change.Id changeId = screen.getPatchSetId().getParentKey();
-      ChangeApi.vote(screen.getProject().get(), changeId.get(), user, vote)
-          .delete(
-              new GerritCallback<JavaScriptObject>() {
-                @Override
-                public void onSuccess(JavaScriptObject result) {
-                  if (screen.isCurrentView()) {
-                    Gerrit.display(PageLinks.toChange(screen.getProject(), changeId));
-                  }
-                }
-              });
-    }
-  }
-
-  private static Integer getDataId(NativeEvent event) {
-    Element e = event.getEventTarget().cast();
-    while (e != null) {
-      String v = e.getAttribute(DATA_ID);
-      if (!v.isEmpty()) {
-        return Integer.parseInt(v);
-      }
-      e = e.getParentElement();
-    }
-    return null;
-  }
-
-  private static String getVoteId(NativeEvent event) {
-    Element e = event.getEventTarget().cast();
-    while (e != null) {
-      String v = e.getAttribute(DATA_VOTE);
-      if (!v.isEmpty()) {
-        return v;
-      }
-      e = e.getParentElement();
-    }
-    return null;
-  }
-
-  private ChangeScreen.Style style;
-
-  void init(ChangeScreen.Style style) {
-    this.style = style;
-  }
-
-  void set(ChangeInfo info) {
-    List<String> names = new ArrayList<>(info.labels());
-    Set<Integer> removable = info.removableReviewerIds();
-    Collections.sort(names);
-
-    resize(names.size(), 2);
-
-    for (int row = 0; row < names.size(); row++) {
-      String name = names.get(row);
-      LabelInfo label = info.label(name);
-      setText(row, 0, name);
-      if (label.all() != null) {
-        setWidget(row, 1, renderUsers(label, removable));
-      }
-      getCellFormatter().setStyleName(row, 0, style.labelName());
-      getCellFormatter().addStyleName(row, 0, getStyleForLabel(label));
-    }
-  }
-
-  private Widget renderUsers(LabelInfo label, Set<Integer> removable) {
-    Map<Integer, List<ApprovalInfo>> m = new HashMap<>(4);
-    int approved = 0;
-    int rejected = 0;
-
-    for (ApprovalInfo ai : Natives.asList(label.all())) {
-      if (ai.value() != 0) {
-        List<ApprovalInfo> l = m.get(Integer.valueOf(ai.value()));
-        if (l == null) {
-          l = new ArrayList<>(label.all().length());
-          m.put(Integer.valueOf(ai.value()), l);
-        }
-        l.add(ai);
-
-        if (isRejected(label, ai)) {
-          rejected = ai.value();
-        } else if (isApproved(label, ai)) {
-          approved = ai.value();
-        }
-      }
-    }
-
-    SafeHtmlBuilder html = new SafeHtmlBuilder();
-    for (Integer v : sort(m.keySet(), approved, rejected)) {
-      if (!html.isEmpty()) {
-        html.br();
-      }
-
-      String val = LabelValue.formatValue(v.shortValue());
-      html.openSpan();
-      html.setAttribute("title", label.valueText(val));
-      if (v.intValue() == approved) {
-        html.setStyleName(style.label_ok());
-      } else if (v.intValue() == rejected) {
-        html.setStyleName(style.label_reject());
-      }
-      html.append(val).append(" ");
-      html.append(formatUserList(style, m.get(v), removable, label.name(), null));
-      html.closeSpan();
-    }
-    return html.toBlockWidget();
-  }
-
-  private static List<Integer> sort(Set<Integer> keySet, int a, int b) {
-    List<Integer> r = new ArrayList<>(keySet);
-    Collections.sort(r);
-    if (keySet.contains(a)) {
-      r.remove(Integer.valueOf(a));
-      r.add(0, a);
-    } else if (keySet.contains(b)) {
-      r.remove(Integer.valueOf(b));
-      r.add(0, b);
-    }
-    return r;
-  }
-
-  private static boolean isApproved(LabelInfo label, ApprovalInfo ai) {
-    return label.approved() != null && label.approved()._accountId() == ai._accountId();
-  }
-
-  private static boolean isRejected(LabelInfo label, ApprovalInfo ai) {
-    return label.rejected() != null && label.rejected()._accountId() == ai._accountId();
-  }
-
-  private String getStyleForLabel(LabelInfo label) {
-    switch (label.status()) {
-      case OK:
-        return style.label_ok();
-      case NEED:
-        return style.label_need();
-      case REJECT:
-      case IMPOSSIBLE:
-        return style.label_reject();
-      default:
-      case MAY:
-        return style.label_may();
-    }
-  }
-
-  static SafeHtml formatUserList(
-      ChangeScreen.Style style,
-      Collection<? extends AccountInfo> in,
-      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);
-          }
-
-          private String name(AccountInfo a) {
-            if (a.name() != null) {
-              return a.name();
-            } else if (a.email() != null) {
-              return a.email();
-            }
-            return "";
-          }
-        });
-
-    SafeHtmlBuilder html = new SafeHtmlBuilder();
-    Iterator<? extends AccountInfo> itr = users.iterator();
-    while (itr.hasNext()) {
-      AccountInfo ai = itr.next();
-      AvatarInfo img = ai.avatar(AvatarInfo.DEFAULT_SIZE);
-      String name;
-      if (ai.name() != null) {
-        name = ai.name();
-      } else if (ai.email() != null) {
-        name = ai.email();
-      } else {
-        name = Integer.toString(ai._accountId());
-      }
-
-      String votableCategories = "";
-      if (votable != null) {
-        VotableInfo vi = votable.get(ai._accountId());
-        if (vi != null) {
-          Set<String> s = vi.votableLabels();
-          if (!s.isEmpty()) {
-            StringBuilder sb = new StringBuilder(Util.C.votable());
-            sb.append(" ");
-            for (Iterator<String> it = vi.votableLabels().iterator(); it.hasNext(); ) {
-              sb.append(it.next());
-              if (it.hasNext()) {
-                sb.append(", ");
-              }
-            }
-            votableCategories = sb.toString();
-          }
-        }
-      }
-      html.openSpan()
-          .setAttribute("role", "listitem")
-          .setAttribute(DATA_ID, ai._accountId())
-          .setAttribute("title", getTitle(ai, votableCategories))
-          .setStyleName(style.label_user());
-      if (label != null) {
-        html.setAttribute(DATA_VOTE, label);
-      }
-      if (img != null) {
-        html.openElement("img").setStyleName(style.avatar()).setAttribute("src", img.url());
-        if (img.width() > 0) {
-          html.setAttribute("width", img.width());
-        }
-        if (img.height() > 0) {
-          html.setAttribute("height", img.height());
-        }
-        html.closeSelf();
-      }
-      html.append(name);
-      if (removable.contains(ai._accountId())) {
-        html.openElement("button");
-        if (label != null) {
-          html.setAttribute("title", Util.M.removeVote(label))
-              .setAttribute("onclick", REMOVE_VOTE + "(event)");
-        } else {
-          html.setAttribute("title", Util.M.removeReviewer(name))
-              .setAttribute("onclick", REMOVE_REVIEWER + "(event)");
-        }
-        html.append("×").closeElement("button");
-      }
-      html.closeSpan();
-      if (itr.hasNext()) {
-        html.append(' ');
-      }
-    }
-    return html;
-  }
-
-  private static String getTitle(AccountInfo ai, String votableCategories) {
-    String title = ai.email() != null ? ai.email() : "";
-    if (!votableCategories.isEmpty()) {
-      if (!title.isEmpty()) {
-        title += " ";
-      }
-      title += votableCategories;
-    }
-    return title;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java
deleted file mode 100644
index 5a0cc59..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.api.ApiGlue;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.diff.DisplaySide;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-class LineComment extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, LineComment> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField Element sideLoc;
-  @UiField Element psLoc;
-  @UiField Element psNum;
-  @UiField Element fileLoc;
-  @UiField Element lineLoc;
-  @UiField InlineHyperlink line;
-  @UiField Element message;
-
-  LineComment(
-      CommentLinkProcessor clp, Project.NameKey project, PatchSet.Id defaultPs, CommentInfo info) {
-    initWidget(uiBinder.createAndBindUi(this));
-
-    PatchSet.Id ps;
-    if (info.patchSet() != defaultPs.get()) {
-      ps = new PatchSet.Id(defaultPs.getParentKey(), info.patchSet());
-      psNum.setInnerText(Integer.toString(info.patchSet()));
-      sideLoc.removeFromParent();
-      sideLoc = null;
-    } else if (info.side() == Side.PARENT) {
-      ps = defaultPs;
-      psLoc.removeFromParent();
-      psLoc = null;
-      psNum = null;
-    } else {
-      ps = defaultPs;
-      sideLoc.removeFromParent();
-      sideLoc = null;
-      psLoc.removeFromParent();
-      psLoc = null;
-      psNum = null;
-    }
-
-    if (info.hasLine()) {
-      fileLoc.removeFromParent();
-      fileLoc = null;
-
-      line.setTargetHistoryToken(url(project, ps, info));
-      line.setText(Integer.toString(info.line()));
-
-    } else {
-      lineLoc.removeFromParent();
-      lineLoc = null;
-      line = null;
-    }
-
-    if (info.message() != null) {
-      message.setInnerSafeHtml(
-          clp.apply(new SafeHtmlBuilder().append(info.message().trim()).wikify()));
-      ApiGlue.fireEvent("comment", message);
-    }
-  }
-
-  private static String url(Project.NameKey project, PatchSet.Id ps, CommentInfo info) {
-    return Dispatcher.toPatch(
-        project,
-        null,
-        ps,
-        info.path(),
-        info.side() == Side.PARENT ? DisplaySide.A : DisplaySide.B,
-        info.line());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.ui.xml
deleted file mode 100644
index f33ba51..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.ui.xml
+++ /dev/null
@@ -1,45 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gerrit.client.ui'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:style gss='false'>
-    .box {
-      position: relative;
-    }
-    .location {
-      position: absolute;
-      top: 0;
-      left: 0;
-      font-weight: bold;
-    }
-    .message {
-      margin-left: 135px;
-    }
-  </ui:style>
-
-  <g:HTMLPanel styleName='{style.box}'>
-    <div class='{style.location}'>
-      <span ui:field='sideLoc'><ui:msg>Base, </ui:msg></span>
-      <span ui:field='psLoc'><ui:msg>PS<span ui:field='psNum'/>, </ui:msg></span>
-      <span ui:field='fileLoc'><ui:msg>File Comment</ui:msg></span>
-      <span ui:field='lineLoc'><ui:msg>Line <c:InlineHyperlink ui:field='line'/>:</ui:msg></span>
-    </div>
-    <div class='{style.message}' ui:field='message'/>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java
deleted file mode 100644
index 44652cf..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java
+++ /dev/null
@@ -1,276 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.changes.CommentApi;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.diff.CommentRange;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.storage.client.Storage;
-import com.google.gwt.user.client.Cookies;
-import java.util.ArrayList;
-import java.util.Collection;
-
-public class LocalComments {
-  @Nullable private final Project.NameKey project;
-  private final Change.Id changeId;
-  private final PatchSet.Id psId;
-  private final StorageBackend storage;
-
-  private static class InlineComment {
-    @Nullable final Project.NameKey project;
-    final PatchSet.Id psId;
-    final CommentInfo commentInfo;
-
-    InlineComment(@Nullable Project.NameKey project, PatchSet.Id psId, CommentInfo commentInfo) {
-      this.project = project;
-      this.psId = psId;
-      this.commentInfo = commentInfo;
-    }
-  }
-
-  private static class StorageBackend {
-    private final Storage storageBackend;
-
-    StorageBackend() {
-      storageBackend =
-          (Storage.isLocalStorageSupported())
-              ? Storage.getLocalStorageIfSupported()
-              : Storage.getSessionStorageIfSupported();
-    }
-
-    String getItem(String key) {
-      if (storageBackend == null) {
-        return Cookies.getCookie(key);
-      }
-      return storageBackend.getItem(key);
-    }
-
-    void setItem(String key, String value) {
-      if (storageBackend == null) {
-        Cookies.setCookie(key, value);
-        return;
-      }
-      storageBackend.setItem(key, value);
-    }
-
-    void removeItem(String key) {
-      if (storageBackend == null) {
-        Cookies.removeCookie(key);
-        return;
-      }
-      storageBackend.removeItem(key);
-    }
-
-    Collection<String> getKeys() {
-      if (storageBackend == null) {
-        return Cookies.getCookieNames();
-      }
-      ArrayList<String> result = new ArrayList<>(storageBackend.getLength());
-      for (int i = 0; i < storageBackend.getLength(); i++) {
-        result.add(storageBackend.key(i));
-      }
-      return result;
-    }
-  }
-
-  public LocalComments(@Nullable Project.NameKey project, Change.Id changeId) {
-    this.project = project;
-    this.changeId = changeId;
-    this.psId = null;
-    this.storage = new StorageBackend();
-  }
-
-  public LocalComments(@Nullable Project.NameKey project, PatchSet.Id psId) {
-    this.project = project;
-    this.changeId = psId.getParentKey();
-    this.psId = psId;
-    this.storage = new StorageBackend();
-  }
-
-  public String getReplyComment() {
-    String comment = storage.getItem(getReplyCommentName());
-    storage.removeItem(getReplyCommentName());
-    return comment;
-  }
-
-  public void setReplyComment(String comment) {
-    storage.setItem(getReplyCommentName(), comment.trim());
-  }
-
-  public boolean hasReplyComment() {
-    return storage.getKeys().contains(getReplyCommentName());
-  }
-
-  public void removeReplyComment() {
-    if (hasReplyComment()) {
-      storage.removeItem(getReplyCommentName());
-    }
-  }
-
-  private String getReplyCommentName() {
-    return "savedReplyComment~" + PageLinks.toChangeId(project, changeId);
-  }
-
-  public static void saveInlineComments() {
-    final StorageBackend storage = new StorageBackend();
-    for (String cookie : storage.getKeys()) {
-      if (isInlineComment(cookie)) {
-        InlineComment input = getInlineComment(cookie);
-        if (input.commentInfo.id() == null) {
-          CommentApi.createDraft(
-              Project.NameKey.asStringOrNull(input.project),
-              input.psId,
-              input.commentInfo,
-              new GerritCallback<CommentInfo>() {
-                @Override
-                public void onSuccess(CommentInfo result) {
-                  storage.removeItem(cookie);
-                }
-              });
-        } else {
-          CommentApi.updateDraft(
-              Project.NameKey.asStringOrNull(input.project),
-              input.psId,
-              input.commentInfo.id(),
-              input.commentInfo,
-              new GerritCallback<CommentInfo>() {
-                @Override
-                public void onSuccess(CommentInfo result) {
-                  storage.removeItem(cookie);
-                }
-
-                @Override
-                public void onFailure(Throwable caught) {
-                  if (RestApi.isNotFound(caught)) {
-                    // the draft comment, that was supposed to be updated,
-                    // was deleted in the meantime
-                    storage.removeItem(cookie);
-                  } else {
-                    super.onFailure(caught);
-                  }
-                }
-              });
-        }
-      }
-    }
-  }
-
-  public void setInlineComment(CommentInfo comment) {
-    String name = getInlineCommentName(comment);
-    if (name == null) {
-      // Failed to get the store key -- so we can't continue.
-      return;
-    }
-    storage.setItem(name, comment.message().trim());
-  }
-
-  public boolean hasInlineComments() {
-    for (String cookie : storage.getKeys()) {
-      if (isInlineComment(cookie)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private static boolean isInlineComment(String key) {
-    return key.startsWith("patchCommentEdit~")
-        || key.startsWith("patchReply~")
-        || key.startsWith("patchComment~");
-  }
-
-  private static InlineComment getInlineComment(String key) {
-    String path;
-    Side side = Side.PARENT;
-    int line = 0;
-    CommentRange range;
-    StorageBackend storage = new StorageBackend();
-
-    String[] elements = key.split("~");
-    int offset = 1;
-    if (key.startsWith("patchReply~") || key.startsWith("patchCommentEdit~")) {
-      offset = 2;
-    }
-    ProjectChangeId id = ProjectChangeId.create(elements[offset + 0]);
-    PatchSet.Id psId = new PatchSet.Id(id.getChangeId(), Integer.parseInt(elements[offset + 1]));
-    path = atob(elements[offset + 2]);
-    side = (Side.PARENT.toString().equals(elements[offset + 3])) ? Side.PARENT : Side.REVISION;
-    range = null;
-    if (elements[offset + 4].startsWith("R")) {
-      String rangeStart = elements[offset + 4].substring(1);
-      String rangeEnd = elements[offset + 5];
-      String[] split = rangeStart.split(",");
-      int sl = Integer.parseInt(split[0]);
-      int sc = Integer.parseInt(split[1]);
-      split = rangeEnd.split(",");
-      int el = Integer.parseInt(split[0]);
-      int ec = Integer.parseInt(split[1]);
-      range = CommentRange.create(sl, sc, el, ec);
-      line = sl;
-    } else {
-      line = Integer.parseInt(elements[offset + 4]);
-    }
-    CommentInfo info = CommentInfo.create(path, side, line, range, false);
-    info.message(storage.getItem(key));
-    if (key.startsWith("patchReply~")) {
-      info.inReplyTo(elements[1]);
-    } else if (key.startsWith("patchCommentEdit~")) {
-      info.id(elements[1]);
-    }
-    InlineComment inlineComment = new InlineComment(id.getProject(), psId, info);
-    return inlineComment;
-  }
-
-  private String getInlineCommentName(CommentInfo comment) {
-    if (psId == null) {
-      return null;
-    }
-    String result = "patchComment~";
-    if (comment.id() != null) {
-      result = "patchCommentEdit~" + comment.id() + "~";
-    } else if (comment.inReplyTo() != null) {
-      result = "patchReply~" + comment.inReplyTo() + "~";
-    }
-
-    result += PageLinks.toChangeId(project, changeId);
-    result += "~" + psId.getId() + "~" + btoa(comment.path()) + "~" + comment.side() + "~";
-    if (comment.hasRange()) {
-      result +=
-          "R"
-              + comment.range().startLine()
-              + ","
-              + comment.range().startCharacter()
-              + "~"
-              + comment.range().endLine()
-              + ","
-              + comment.range().endCharacter();
-    } else {
-      result += comment.line();
-    }
-    return result;
-  }
-
-  private static native String btoa(String a) /*-{ return btoa(a); }-*/;
-
-  private static native String atob(String b) /*-{ return atob(b); }-*/;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
deleted file mode 100644
index cadaf97..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
+++ /dev/null
@@ -1,209 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.AvatarImage;
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.api.ApiGlue;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.ChangeInfo.MessageInfo;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Visibility;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-
-class Message extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, Message> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface Style extends CssResource {
-    String closed();
-  }
-
-  @UiField Style style;
-  @UiField HTMLPanel header;
-  @UiField Element name;
-  @UiField Element summary;
-  @UiField Element date;
-  @UiField Button reply;
-  @UiField Element message;
-  @UiField FlowPanel comments;
-
-  private final History history;
-  private final MessageInfo info;
-  private List<CommentInfo> commentList;
-  private boolean autoOpen;
-
-  @UiField(provided = true)
-  AvatarImage avatar;
-
-  Message(History parent, MessageInfo info) {
-    if (info.author() != null) {
-      avatar = new AvatarImage(info.author());
-      avatar.setSize("", "");
-    } else {
-      avatar = new AvatarImage();
-    }
-
-    initWidget(uiBinder.createAndBindUi(this));
-    header.addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            setOpen(!isOpen());
-          }
-        },
-        ClickEvent.getType());
-
-    this.history = parent;
-    this.info = info;
-
-    setName(false);
-    date.setInnerText(FormatUtil.shortFormatDayTime(info.date()));
-    if (info.message() != null) {
-      String msg = info.message().trim();
-      summary.setInnerText(msg);
-      message.setInnerSafeHtml(
-          history.getCommentLinkProcessor().apply(new SafeHtmlBuilder().append(msg).wikify()));
-      ApiGlue.fireEvent("comment", message);
-    } else {
-      reply.getElement().getStyle().setVisibility(Visibility.HIDDEN);
-    }
-  }
-
-  @UiHandler("reply")
-  void onReply(ClickEvent e) {
-    e.stopPropagation();
-
-    if (Gerrit.isSignedIn()) {
-      history.replyTo(info);
-    } else {
-      Gerrit.doSignIn(com.google.gwt.user.client.History.getToken());
-    }
-  }
-
-  MessageInfo getMessageInfo() {
-    return info;
-  }
-
-  private boolean isOpen() {
-    return UIObject.isVisible(message);
-  }
-
-  void setOpen(boolean open) {
-    if (open && info._revisionNumber() > 0 && !commentList.isEmpty()) {
-      renderComments(commentList);
-      commentList = Collections.emptyList();
-    }
-    setName(open);
-
-    UIObject.setVisible(summary, !open);
-    UIObject.setVisible(message, open);
-    comments.setVisible(open && comments.getWidgetCount() > 0);
-    if (open) {
-      removeStyleName(style.closed());
-    } else {
-      addStyleName(style.closed());
-    }
-  }
-
-  private void setName(boolean open) {
-    name.setInnerText(
-        open ? authorName(info) : com.google.gerrit.common.FormatUtil.elide(authorName(info), 20));
-  }
-
-  void autoOpen() {
-    if (commentList == null) {
-      autoOpen = true;
-    } else if (!commentList.isEmpty()) {
-      setOpen(true);
-    }
-  }
-
-  void addComments(List<CommentInfo> list) {
-    if (isOpen()) {
-      renderComments(list);
-      comments.setVisible(comments.getWidgetCount() > 0);
-      commentList = Collections.emptyList();
-    } else {
-      commentList = list;
-      if (autoOpen && !commentList.isEmpty()) {
-        setOpen(true);
-      }
-    }
-  }
-
-  private void renderComments(List<CommentInfo> list) {
-    CommentLinkProcessor clp = history.getCommentLinkProcessor();
-    PatchSet.Id ps = new PatchSet.Id(history.getChangeId(), info._revisionNumber());
-    TreeMap<String, List<CommentInfo>> m = byPath(list);
-    List<CommentInfo> l = m.remove(Patch.COMMIT_MSG);
-    if (l != null) {
-      comments.add(new FileComments(clp, history.getProject(), ps, Util.C.commitMessage(), l));
-    }
-    l = m.remove(Patch.MERGE_LIST);
-    if (l != null) {
-      comments.add(new FileComments(clp, history.getProject(), ps, Util.C.mergeList(), l));
-    }
-    for (Map.Entry<String, List<CommentInfo>> e : m.entrySet()) {
-      comments.add(new FileComments(clp, history.getProject(), ps, e.getKey(), e.getValue()));
-    }
-  }
-
-  private static TreeMap<String, List<CommentInfo>> byPath(List<CommentInfo> list) {
-    TreeMap<String, List<CommentInfo>> m = new TreeMap<>();
-    for (CommentInfo c : list) {
-      List<CommentInfo> l = m.get(c.path());
-      if (l == null) {
-        l = new ArrayList<>();
-        m.put(c.path(), l);
-      }
-      l.add(c);
-    }
-    return m;
-  }
-
-  static String authorName(MessageInfo info) {
-    if (info.author() != null) {
-      if (info.author().name() != null) {
-        return info.author().name();
-      }
-      return Gerrit.info().user().anonymousCowardName();
-    }
-    return Util.C.messageNoAuthor();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.ui.xml
deleted file mode 100644
index e362c07..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.ui.xml
+++ /dev/null
@@ -1,136 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gerrit.client'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:style gss='false' type='com.google.gerrit.client.change.Message.Style'>
-    .messageBox {
-      position: relative;
-      width: 1168px;
-      padding: 2px 0 2px 0;
-      border-left: 1px solid #e3e9ff;
-      border-right: 1px solid #e3e9ff;
-      border-bottom: 1px solid #e3e9ff;
-      -webkit-border-bottom-left-radius: 8px;
-      -webkit-border-bottom-right-radius: 8px;
-    }
-
-    .header {
-      cursor: pointer;
-    }
-
-    .avatar {
-      position: absolute;
-      width: 26px;
-      height: 26px;
-    }
-    .closed .avatar {
-      position: absolute;
-      top: 0;
-      left: -1px;
-      width: 20px;
-      height: 20px;
-    }
-
-    .contents {
-      margin-left: 28px;
-      position: relative;
-    }
-
-    .contents p,
-    .contents blockquote {
-      -webkit-margin-before: 0;
-      -webkit-margin-after: 0.3em;
-       white-space: pre-wrap;
-    }
-
-    .name {
-      white-space: nowrap;
-      font-weight: bold;
-    }
-    .closed .name {
-      width: 150px;
-      overflow: hidden;
-      font-weight: normal;
-    }
-
-    .summary {
-      color: #777;
-      position: absolute;
-      top: 0;
-      left: 150px;
-      width: 880px;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-
-    .date {
-      white-space: nowrap;
-      position: absolute;
-      top: 0;
-      right: 18px;
-    }
-
-    .reply {
-      position: absolute;
-      top: 0;
-      right: 1px;
-      cursor: pointer;
-      outline: none;
-      border: none;
-      background: transparent;
-      margin: 0;
-      padding: 0;
-      line-height: 15px;
-      font-family: Arial Unicode MS, sans-serif;
-      font-size: 18px;
-    }
-    .closed .reply {
-      visibility: hidden;
-    }
-    .comment {
-    }
-  </ui:style>
-
-  <g:HTMLPanel
-      styleName='{style.messageBox}'
-      addStyleNames='{style.closed}'>
-    <c:AvatarImage ui:field='avatar' styleName='{style.avatar}'/>
-    <div class='{style.contents}'>
-      <g:HTMLPanel ui:field='header' styleName='{style.header}'>
-        <div class='{style.name}' ui:field='name'/>
-        <div ui:field='summary' class='{style.summary}'/>
-        <div class='{style.date}' ui:field='date'/>
-        <g:Button styleName='{style.reply}'
-            ui:field='reply'
-            title='Reply to this message'>
-          <ui:attribute name='title'/>
-          <div>&#x21a9;</div>
-        </g:Button>
-      </g:HTMLPanel>
-      <div style='overflow: auto'>
-        <div ui:field='message'
-            aria-hidden='true'
-            style='display: NONE'
-            styleName='{style.comment}'/>
-        <g:FlowPanel ui:field='comments' visible='false'/>
-      </div>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/MoveAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/MoveAction.java
deleted file mode 100644
index e3e9525..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/MoveAction.java
+++ /dev/null
@@ -1,67 +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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.MoveDialog;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.PopupPanel;
-
-class MoveAction {
-  static void call(Button b, ChangeInfo info, Project.NameKey project) {
-    b.setEnabled(false);
-    new MoveDialog(project) {
-      {
-        sendButton.setText(Util.C.moveChangeSend());
-      }
-
-      @Override
-      public void onSend() {
-        ChangeApi.move(
-            info.project(),
-            info.legacyId().get(),
-            getDestinationBranch(),
-            getMessageText(),
-            new GerritCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo result) {
-                sent = true;
-                hide();
-                Gerrit.display(PageLinks.toChange(project, result.legacyId()));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                enableButtons(true);
-                super.onFailure(caught);
-              }
-            });
-      }
-
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        super.onClose(event);
-        b.setEnabled(true);
-      }
-    }.center();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsAction.java
deleted file mode 100644
index faf2516..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsAction.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-
-class PatchSetsAction extends RightSidePopdownAction {
-  private final PatchSetsBox revisionBox;
-
-  PatchSetsAction(
-      Project.NameKey project,
-      Change.Id changeId,
-      String revision,
-      EditInfo edit,
-      ChangeScreen.Style style,
-      UIObject relativeTo,
-      Widget downloadButton) {
-    super(style, relativeTo, downloadButton);
-    this.revisionBox = new PatchSetsBox(project, changeId, revision, edit);
-  }
-
-  @Override
-  Widget getWidget() {
-    return revisionBox;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java
deleted file mode 100644
index 35cab4e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java
+++ /dev/null
@@ -1,233 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ChangeList;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.ui.FancyFlexTableImpl;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.EventListener;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlexTable;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwt.user.client.ui.impl.HyperlinkImpl;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.Collections;
-import java.util.EnumSet;
-
-class PatchSetsBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, PatchSetsBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  private static final String OPEN;
-  private static final HyperlinkImpl link = GWT.create(HyperlinkImpl.class);
-
-  static {
-    OPEN = DOM.createUniqueId().replace('-', '_');
-    init(OPEN);
-  }
-
-  private static native void init(String o) /*-{
-    $wnd[o] = $entry(function(e,i) {
-      return @com.google.gerrit.client.change.PatchSetsBox::onOpen(Lcom/google/gwt/dom/client/NativeEvent;I)(e,i);
-    });
-  }-*/;
-
-  private static boolean onOpen(NativeEvent e, int idx) {
-    if (link.handleAsClick(e.<Event>cast())) {
-      PatchSetsBox t = getRevisionBox(e);
-      if (t != null) {
-        t.onOpenRow(idx);
-        e.preventDefault();
-        return false;
-      }
-    }
-    return true;
-  }
-
-  private static PatchSetsBox getRevisionBox(NativeEvent event) {
-    Element e = event.getEventTarget().cast();
-    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
-      EventListener l = DOM.getEventListener(e);
-      if (l instanceof PatchSetsBox) {
-        return (PatchSetsBox) l;
-      }
-    }
-    return null;
-  }
-
-  interface Style extends CssResource {
-    String current();
-
-    String legacy_id();
-
-    String commit();
-
-    String draft_comment();
-  }
-
-  private final Change.Id changeId;
-  private final Project.NameKey project;
-  private final String revision;
-  private final EditInfo edit;
-  private boolean loaded;
-  private JsArray<RevisionInfo> revisions;
-
-  @UiField FlexTable table;
-  @UiField Style style;
-
-  PatchSetsBox(Project.NameKey project, Change.Id changeId, String revision, EditInfo edit) {
-    this.project = project;
-    this.changeId = changeId;
-    this.revision = revision;
-    this.edit = edit;
-    initWidget(uiBinder.createAndBindUi(this));
-  }
-
-  @Override
-  protected void onLoad() {
-    if (!loaded) {
-      RestApi call = ChangeApi.detail(project.get(), changeId.get());
-      ChangeList.addOptions(
-          call, EnumSet.of(ListChangesOption.ALL_COMMITS, ListChangesOption.ALL_REVISIONS));
-      call.get(
-          new AsyncCallback<ChangeInfo>() {
-            @Override
-            public void onSuccess(ChangeInfo result) {
-              if (edit != null) {
-                edit.setName(edit.commit().commit());
-                result.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
-              }
-              render(result.revisions());
-              loaded = true;
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {}
-          });
-    }
-  }
-
-  private void onOpenRow(int idx) {
-    closeParent();
-    Gerrit.display(url(revisions.get(idx)));
-  }
-
-  private void render(NativeMap<RevisionInfo> map) {
-    map.copyKeysIntoChildren("name");
-
-    revisions = map.values();
-    RevisionInfo.sortRevisionInfoByNumber(revisions);
-    Collections.reverse(Natives.asList(revisions));
-
-    SafeHtmlBuilder sb = new SafeHtmlBuilder();
-    header(sb);
-    for (int i = 0; i < revisions.length(); i++) {
-      revision(sb, i, revisions.get(i));
-    }
-
-    GWT.<FancyFlexTableImpl>create(FancyFlexTableImpl.class).resetHtml(table, sb);
-  }
-
-  private void header(SafeHtmlBuilder sb) {
-    sb.openTr()
-        .openTh()
-        .setStyleName(style.legacy_id())
-        .append(Resources.C.patchSet())
-        .closeTh()
-        .openTh()
-        .append(Resources.C.commit())
-        .closeTh()
-        .openTh()
-        .append(Resources.C.date())
-        .closeTh()
-        .openTh()
-        .append(Resources.C.author())
-        .closeTh()
-        .closeTr();
-  }
-
-  private void revision(SafeHtmlBuilder sb, int index, RevisionInfo r) {
-    CommitInfo c = r.commit();
-    sb.openTr();
-    if (revision.equals(r.name())) {
-      sb.setStyleName(style.current());
-    }
-
-    sb.openTd().setStyleName(style.legacy_id());
-    sb.append(r.id());
-    sb.closeTd();
-
-    sb.openTd()
-        .setStyleName(style.commit())
-        .openAnchor()
-        .setAttribute("href", "#" + url(r))
-        .setAttribute("onclick", OPEN + "(event," + index + ")")
-        .append(r.name().substring(0, 10))
-        .closeAnchor()
-        .closeTd();
-
-    sb.openTd().append(FormatUtil.shortFormatDayTime(c.committer().date())).closeTd();
-
-    String an = c.author() != null ? c.author().name() : "";
-    String cn = c.committer() != null ? c.committer().name() : "";
-    sb.openTd();
-    sb.append(an);
-    if (!"".equals(an) && !"".equals(cn) && !an.equals(cn)) {
-      sb.append(" / ").append(cn);
-    }
-    sb.closeTd();
-
-    sb.closeTr();
-  }
-
-  private String url(RevisionInfo r) {
-    return PageLinks.toChange(project, changeId, r.id());
-  }
-
-  private void closeParent() {
-    for (Widget w = getParent(); w != null; w = w.getParent()) {
-      if (w instanceof PopupPanel) {
-        ((PopupPanel) w).hide(true);
-        break;
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.ui.xml
deleted file mode 100644
index 7537aa4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.ui.xml
+++ /dev/null
@@ -1,76 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false' type='com.google.gerrit.client.change.PatchSetsBox.Style'>
-    @eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-
-    .revisionBox {
-      min-width: 300px;
-      margin: 10px 0px 5px 5px;
-    }
-
-    .scroll {
-      min-width: 300px;
-      height: 200px;
-    }
-
-    .table {
-      border-spacing: 0;
-      width: 100%;
-    }
-
-    .table td, .table th {
-      padding-left: 5px;
-      padding-right: 5px;
-      border-right: 2px solid #ddd;
-      white-space: nowrap;
-    }
-
-    .table tr.current {
-      background-color: selectionColor;
-    }
-    .table tr.current a {
-      pointer-events: none;
-      color: #000;
-    }
-
-    .legacy_id {
-      min-width: 50px;
-      text-align: right;
-      font-weight: bold;
-    }
-
-    .commit {
-      font-family: monospace;
-    }
-
-    .draft_comment {
-      margin: 0 2px 0 0;
-      width: 16px;
-      height: 16px;
-      vertical-align: bottom;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.revisionBox}'>
-    <g:ScrollPanel styleName='{style.scroll}'>
-      <g:FlexTable ui:field='table' styleName='{style.table}'/>
-    </g:ScrollPanel>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java
deleted file mode 100644
index 7668f0f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-class PathSuggestOracle extends HighlightSuggestOracle {
-
-  private final Project.NameKey project;
-  private final Change.Id changeId;
-  private final RevisionInfo revision;
-
-  PathSuggestOracle(Project.NameKey project, Change.Id changeId, RevisionInfo revision) {
-    this.project = project;
-    this.changeId = changeId;
-    this.revision = revision;
-  }
-
-  @Override
-  protected void onRequestSuggestions(Request req, Callback cb) {
-    RestApi api = ChangeApi.revision(project.get(), changeId.get(), revision.name()).view("files");
-    if (req.getQuery() != null) {
-      api.addParameter("q", req.getQuery() == null ? "" : req.getQuery());
-    }
-    api.background()
-        .get(
-            new AsyncCallback<JsArrayString>() {
-              @Override
-              public void onSuccess(JsArrayString result) {
-                List<Suggestion> r = new ArrayList<>();
-                for (String path : Natives.asList(result)) {
-                  r.add(new PathSuggestion(path));
-                }
-                cb.onSuggestionsReady(req, new Response(r));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                List<Suggestion> none = Collections.emptyList();
-                cb.onSuggestionsReady(req, new Response(none));
-              }
-            });
-  }
-
-  private static class PathSuggestion implements Suggestion {
-    private final String path;
-
-    PathSuggestion(String path) {
-      this.path = path;
-    }
-
-    @Override
-    public String getDisplayString() {
-      return path;
-    }
-
-    @Override
-    public String getReplacementString() {
-      return path;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ProjectChangeId.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ProjectChangeId.java
deleted file mode 100644
index 684867b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ProjectChangeId.java
+++ /dev/null
@@ -1,117 +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.client.change;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import java.util.Objects;
-
-/** Provides logic for parsing a numeric change id and project from a URL. */
-public class ProjectChangeId {
-
-  /** Parses a {@link ProjectChangeId} from it's string representation. */
-  public static ProjectChangeId create(String token) {
-    String mutableToken = token;
-    // Try parsing /c/project/+/numericChangeId where token is project/+/numericChangeId
-    int delimiter = mutableToken.indexOf(PageLinks.PROJECT_CHANGE_DELIMITER);
-    Project.NameKey project = null;
-    if (delimiter > 0) {
-      project = new Project.NameKey(token.substring(0, delimiter));
-      mutableToken =
-          mutableToken.substring(delimiter + PageLinks.PROJECT_CHANGE_DELIMITER.length());
-    }
-
-    // Try parsing /c/numericChangeId where token is numericChangeId
-    int s = mutableToken.indexOf('/');
-    if (s > 0) {
-      mutableToken = mutableToken.substring(0, s);
-    }
-    // Special case: project/+/1233,edit/
-    s = mutableToken.indexOf(",edit");
-    if (s > 0) {
-      mutableToken = mutableToken.substring(0, s);
-    }
-    Integer cId = tryParse(mutableToken);
-    if (cId != null) {
-      return new ProjectChangeId(project, new Change.Id(cId));
-    }
-
-    throw new IllegalArgumentException(token + " is not a valid change identifier");
-  }
-
-  @Nullable private final Project.NameKey project;
-  private final Change.Id changeId;
-
-  @VisibleForTesting
-  ProjectChangeId(@Nullable Project.NameKey project, Change.Id changeId) {
-    this.project = project;
-    this.changeId = changeId;
-  }
-
-  @Nullable
-  public Project.NameKey getProject() {
-    return project;
-  }
-
-  public Change.Id getChangeId() {
-    return changeId;
-  }
-
-  /**
-   * Calculate the length of the string representation of the change ID that was parsed from the
-   * token.
-   *
-   * @return the length of the {@link com.google.gerrit.reviewdb.client.Change.Id} if no project was
-   *     parsed from the token. The length of {@link
-   *     com.google.gerrit.reviewdb.client.Project.NameKey} + the delimiter + the length of {@link
-   *     com.google.gerrit.reviewdb.client.Change.Id} otherwise.
-   */
-  public int identifierLength() {
-    if (project == null) {
-      return String.valueOf(changeId).length();
-    }
-    return PageLinks.toChangeId(project, changeId).length();
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (obj instanceof ProjectChangeId) {
-      ProjectChangeId other = (ProjectChangeId) obj;
-      return Objects.equals(changeId, other.changeId) && Objects.equals(project, other.project);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(changeId, project);
-  }
-
-  @Override
-  public String toString() {
-    return "ProjectChangeId.Result{changeId: " + changeId + ", project: " + project + "}";
-  }
-
-  private static Integer tryParse(String s) {
-    try {
-      return Integer.parseInt(s);
-    } catch (NumberFormatException e) {
-      return null;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java
deleted file mode 100644
index 56cc7a7..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ReviewInput;
-import com.google.gerrit.client.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.LabelInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-/** Applies a label with one mouse click. */
-class QuickApprove extends Button implements ClickHandler {
-  private Change.Id changeId;
-  private Project.NameKey project;
-  private String revision;
-  private ReviewInput input;
-  private ReplyAction replyAction;
-
-  QuickApprove() {
-    addClickHandler(this);
-  }
-
-  void set(ChangeInfo info, String commit, ReplyAction action) {
-    if (!info.hasPermittedLabels() || !info.status().isOpen()) {
-      // Quick approve needs at least one label on an open change.
-      setVisible(false);
-      return;
-    }
-    if (info.revision(commit).isEdit()) {
-      setVisible(false);
-      return;
-    }
-
-    String qName = null;
-    String qValueStr = null;
-    short qValue = 0;
-
-    int index = info.getMissingLabelIndex();
-    if (index != -1) {
-      LabelInfo label = Natives.asList(info.allLabels().values()).get(index);
-      JsArrayString values = info.permittedValues(label.name());
-      String s = values.get(values.length() - 1);
-      short v = LabelInfo.parseValue(s);
-      if (v > 0 && s.equals(label.maxValue())) {
-        qName = label.name();
-        qValueStr = s;
-        qValue = v;
-      }
-    }
-
-    if (qName != null) {
-      changeId = info.legacyId();
-      project = info.projectNameKey();
-      revision = commit;
-      input = ReviewInput.create();
-      input.drafts(DraftHandling.PUBLISH_ALL_REVISIONS);
-      input.label(qName, qValue);
-      replyAction = action;
-      setText(qName + qValueStr);
-      setVisible(true);
-    } else {
-      setVisible(false);
-    }
-  }
-
-  @Override
-  public void setText(String text) {
-    setHTML(new SafeHtmlBuilder().openDiv().append(text).closeDiv());
-  }
-
-  @Override
-  public void onClick(ClickEvent event) {
-    if (replyAction != null && replyAction.isVisible()) {
-      replyAction.quickApprove(input);
-    } else {
-      ChangeApi.revision(project.get(), changeId.get(), revision)
-          .view("review")
-          .post(
-              input,
-              new GerritCallback<ReviewInput>() {
-                @Override
-                public void onSuccess(ReviewInput result) {
-                  Gerrit.display(PageLinks.toChange(project, changeId));
-                }
-              });
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java
deleted file mode 100644
index 0e3e835..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.RebaseDialog;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.PopupPanel;
-
-class RebaseAction {
-  static void call(
-      final Button b,
-      final Project.NameKey project,
-      final String branch,
-      final Change.Id id,
-      final String revision,
-      final boolean enabled) {
-    b.setEnabled(false);
-
-    new RebaseDialog(project, branch, id, enabled) {
-      @Override
-      public void onSend() {
-        ChangeApi.rebase(
-            project.get(),
-            id.get(),
-            revision,
-            getBase(),
-            new GerritCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo result) {
-                sent = true;
-                hide();
-                Gerrit.display(PageLinks.toChange(project, id));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                enableButtons(true);
-                super.onFailure(caught);
-              }
-            });
-      }
-
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        super.onClose(event);
-        b.setEnabled(true);
-      }
-    }.center();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
deleted file mode 100644
index 96bbe61..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
+++ /dev/null
@@ -1,455 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import static com.google.gerrit.common.PageLinks.op;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ChangeList;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.TabBar;
-import com.google.gwt.user.client.ui.TabPanel;
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.List;
-
-public class RelatedChanges extends TabPanel {
-  static final RelatedChangesResources R = GWT.create(RelatedChangesResources.class);
-
-  interface RelatedChangesResources extends ClientBundle {
-    @Source("related_changes.css")
-    RelatedChangesCss css();
-  }
-
-  interface RelatedChangesCss extends CssResource {
-    String activeRow();
-
-    String current();
-
-    String gitweb();
-
-    String indirect();
-
-    String notCurrent();
-
-    String pointer();
-
-    String row();
-
-    String subject();
-
-    String strikedSubject();
-
-    String submittable();
-
-    String tabPanel();
-  }
-
-  enum Tab {
-    RELATED_CHANGES(Resources.C.relatedChanges(), Resources.C.relatedChangesTooltip()) {
-      @Override
-      String getTitle(int count) {
-        return Resources.M.relatedChanges(count);
-      }
-
-      @Override
-      String getTitle(String count) {
-        return Resources.M.relatedChanges(count);
-      }
-    },
-
-    SUBMITTED_TOGETHER(Resources.C.submittedTogether(), Resources.C.submittedTogether()) {
-      @Override
-      String getTitle(int count) {
-        return Resources.M.submittedTogether(count);
-      }
-
-      @Override
-      String getTitle(String count) {
-        return Resources.M.submittedTogether(count);
-      }
-    },
-
-    SAME_TOPIC(Resources.C.sameTopic(), Resources.C.sameTopicTooltip()) {
-      @Override
-      String getTitle(int count) {
-        return Resources.M.sameTopic(count);
-      }
-
-      @Override
-      String getTitle(String count) {
-        return Resources.M.sameTopic(count);
-      }
-    },
-
-    CONFLICTING_CHANGES(Resources.C.conflictingChanges(), Resources.C.conflictingChangesTooltip()) {
-      @Override
-      String getTitle(int count) {
-        return Resources.M.conflictingChanges(count);
-      }
-
-      @Override
-      String getTitle(String count) {
-        return Resources.M.conflictingChanges(count);
-      }
-    },
-
-    CHERRY_PICKS(Resources.C.cherryPicks(), Resources.C.cherryPicksTooltip()) {
-      @Override
-      String getTitle(int count) {
-        return Resources.M.cherryPicks(count);
-      }
-
-      @Override
-      String getTitle(String count) {
-        return Resources.M.cherryPicks(count);
-      }
-    };
-
-    final String defaultTitle;
-    final String tooltip;
-
-    abstract String getTitle(int count);
-
-    abstract String getTitle(String count);
-
-    Tab(String defaultTitle, String tooltip) {
-      this.defaultTitle = defaultTitle;
-      this.tooltip = tooltip;
-    }
-  }
-
-  private static Tab savedTab;
-
-  private final List<RelatedChangesTab> tabs;
-  private int maxHeightWithHeader;
-  private int selectedTab;
-  private int outstandingCallbacks;
-
-  RelatedChanges() {
-    tabs = new ArrayList<>(Tab.values().length);
-    selectedTab = -1;
-
-    setVisible(false);
-    addStyleName(R.css().tabPanel());
-    initTabBar();
-  }
-
-  private void initTabBar() {
-    TabBar tabBar = getTabBar();
-    tabBar.addSelectionHandler(
-        new SelectionHandler<Integer>() {
-          @Override
-          public void onSelection(SelectionEvent<Integer> event) {
-            if (selectedTab >= 0) {
-              tabs.get(selectedTab).registerKeys(false);
-            }
-            selectedTab = event.getSelectedItem();
-            tabs.get(selectedTab).registerKeys(true);
-          }
-        });
-
-    for (Tab tabInfo : Tab.values()) {
-      RelatedChangesTab panel = new RelatedChangesTab(tabInfo);
-      add(panel, tabInfo.defaultTitle);
-      tabs.add(panel);
-
-      TabBar.Tab tab = tabBar.getTab(tabInfo.ordinal());
-      tab.setWordWrap(false);
-      ((Composite) tab).setTitle(tabInfo.tooltip);
-
-      setTabEnabled(tabInfo, false);
-    }
-    getTab(Tab.RELATED_CHANGES).setShowIndirectAncestors(true);
-    getTab(Tab.CHERRY_PICKS).setShowBranches(true);
-    getTab(Tab.SAME_TOPIC).setShowBranches(true);
-    getTab(Tab.SAME_TOPIC).setShowProjects(true);
-    getTab(Tab.SAME_TOPIC).setShowSubmittable(true);
-    getTab(Tab.SUBMITTED_TOGETHER).setShowBranches(true);
-    getTab(Tab.SUBMITTED_TOGETHER).setShowProjects(true);
-    getTab(Tab.SUBMITTED_TOGETHER).setShowSubmittable(true);
-  }
-
-  void set(ChangeInfo info, String revision) {
-    if (info.status().isOpen()) {
-      setForOpenChange(info, revision);
-    }
-
-    ChangeApi.revision(info.project(), info.legacyId().get(), revision)
-        .view("related")
-        .get(
-            new TabCallback<RelatedInfo>(Tab.RELATED_CHANGES, info.project(), revision) {
-              @Override
-              public JsArray<ChangeAndCommit> convert(RelatedInfo result) {
-                return result.changes();
-              }
-            });
-
-    StringBuilder cherryPicksQuery = new StringBuilder();
-    cherryPicksQuery.append(op("project", info.project()));
-    cherryPicksQuery.append(" ").append(op("change", info.changeId()));
-    cherryPicksQuery.append(" ").append(op("-change", info.legacyId().get()));
-    cherryPicksQuery.append(" -is:abandoned");
-    ChangeList.query(
-        cherryPicksQuery.toString(),
-        EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT),
-        new TabChangeListCallback(Tab.CHERRY_PICKS, info.project(), revision));
-
-    if (info.currentRevision() != null && info.currentRevision().equals(revision)) {
-      ChangeApi.change(info.project(), info.legacyId().get())
-          .view("submitted_together")
-          .addParameter("o", "CURRENT_COMMIT")
-          .get(new TabChangeListCallback(Tab.SUBMITTED_TOGETHER, info.project(), revision));
-    }
-
-    if (!Gerrit.info().change().isSubmitWholeTopicEnabled()
-        && info.topic() != null
-        && !"".equals(info.topic())) {
-      StringBuilder topicQuery = new StringBuilder();
-      topicQuery.append("status:open");
-      topicQuery.append(" ").append(op("topic", info.topic()));
-      ChangeList.query(
-          topicQuery.toString(),
-          EnumSet.of(
-              ListChangesOption.CURRENT_REVISION,
-              ListChangesOption.CURRENT_COMMIT,
-              ListChangesOption.DETAILED_LABELS,
-              ListChangesOption.LABELS),
-          new TabChangeListCallback(Tab.SAME_TOPIC, info.project(), revision));
-    }
-  }
-
-  private void setForOpenChange(ChangeInfo info, String revision) {
-    if (info.mergeable()) {
-      StringBuilder conflictsQuery = new StringBuilder();
-      conflictsQuery.append("status:open");
-      conflictsQuery.append(" is:mergeable");
-      conflictsQuery.append(" ").append(op("conflicts", info.legacyId().get()));
-      ChangeList.query(
-          conflictsQuery.toString(),
-          EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT),
-          new TabChangeListCallback(Tab.CONFLICTING_CHANGES, info.project(), revision));
-    }
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    R.css().ensureInjected();
-  }
-
-  static void setSavedTab(Tab subject) {
-    savedTab = subject;
-  }
-
-  private RelatedChangesTab getTab(Tab tabInfo) {
-    return tabs.get(tabInfo.ordinal());
-  }
-
-  private void setTabTitle(Tab tabInfo, String title) {
-    getTabBar().setTabText(tabInfo.ordinal(), title);
-  }
-
-  private void setTabEnabled(Tab tabInfo, boolean enabled) {
-    getTabBar().setTabEnabled(tabInfo.ordinal(), enabled);
-  }
-
-  void setMaxHeight(int height) {
-    maxHeightWithHeader = height;
-    if (isVisible()) {
-      applyMaxHeight();
-    }
-  }
-
-  private void applyMaxHeight() {
-    int header = getTabBar().getOffsetHeight() + 2 /* padding */;
-    for (int i = 0; i < getTabBar().getTabCount(); i++) {
-      tabs.get(i).setMaxHeight(maxHeightWithHeader - header);
-    }
-  }
-
-  private abstract class TabCallback<T> implements AsyncCallback<T> {
-    private final Tab tabInfo;
-    private final String project;
-    private final String revision;
-
-    TabCallback(Tab tabInfo, String project, String revision) {
-      this.tabInfo = tabInfo;
-      this.project = project;
-      this.revision = revision;
-      outstandingCallbacks++;
-    }
-
-    protected abstract JsArray<ChangeAndCommit> convert(T result);
-
-    @Override
-    public void onSuccess(T result) {
-      if (isAttached()) {
-        JsArray<ChangeAndCommit> changes = convert(result);
-        if (changes.length() > 0) {
-          setTabTitle(tabInfo, tabInfo.getTitle(changes.length()));
-          getTab(tabInfo).setChanges(project, revision, changes);
-        }
-        onDone(changes.length() > 0);
-      }
-    }
-
-    @Override
-    public void onFailure(Throwable err) {
-      if (isAttached()) {
-        setTabTitle(tabInfo, tabInfo.getTitle(Resources.C.notAvailable()));
-        getTab(tabInfo).setError(err.getMessage());
-        onDone(true);
-      }
-    }
-
-    private void onDone(boolean enabled) {
-      setTabEnabled(tabInfo, enabled);
-      outstandingCallbacks--;
-      if (outstandingCallbacks == 0 || (enabled && tabInfo == Tab.RELATED_CHANGES)) {
-        outstandingCallbacks = 0; // Only execute this block once
-        for (int i = 0; i < getTabBar().getTabCount(); i++) {
-          if (getTabBar().isTabEnabled(i)) {
-            selectTab(i);
-            setVisible(true);
-            applyMaxHeight();
-            break;
-          }
-        }
-      }
-
-      if (tabInfo == savedTab && enabled) {
-        selectTab(savedTab.ordinal());
-      }
-    }
-  }
-
-  private class TabChangeListCallback extends TabCallback<ChangeList> {
-    TabChangeListCallback(Tab tabInfo, String project, String revision) {
-      super(tabInfo, project, revision);
-    }
-
-    @Override
-    protected JsArray<ChangeAndCommit> convert(ChangeList l) {
-      JsArray<ChangeAndCommit> arr = JavaScriptObject.createArray().cast();
-      for (ChangeInfo i : Natives.asList(l)) {
-        if (i.currentRevision() != null && i.revisions().containsKey(i.currentRevision())) {
-          RevisionInfo currentRevision = i.revision(i.currentRevision());
-          ChangeAndCommit c = ChangeAndCommit.create();
-          c.setId(i.id());
-          c.setCommit(currentRevision.commit());
-          c.setChangeNumber(i.legacyId().get());
-          c.setRevisionNumber(currentRevision._number());
-          c.setBranch(i.branch());
-          c.setProject(i.project());
-          c.setSubmittable(i.submittable() && i.mergeable());
-          c.setStatus(i.status().asChangeStatus().toString());
-          arr.push(c);
-        }
-      }
-      return arr;
-    }
-  }
-
-  public static class RelatedInfo extends JavaScriptObject {
-    public final native JsArray<ChangeAndCommit> changes() /*-{ return this.changes }-*/;
-
-    protected RelatedInfo() {}
-  }
-
-  public static class ChangeAndCommit extends JavaScriptObject {
-    static ChangeAndCommit create() {
-      return (ChangeAndCommit) createObject();
-    }
-
-    public final native String id() /*-{ return this.change_id }-*/;
-
-    public final native CommitInfo commit() /*-{ return this.commit }-*/;
-
-    final native String branch() /*-{ return this.branch }-*/;
-
-    final native String project() /*-{ return this.project }-*/;
-
-    final native boolean submittable() /*-{ return this._submittable ? true : false; }-*/;
-
-    final Change.Status status() {
-      String s = statusRaw();
-      return s != null ? Change.Status.valueOf(s) : null;
-    }
-
-    private native String statusRaw() /*-{ return this.status; }-*/;
-
-    final native void setId(String i) /*-{ if(i)this.change_id=i; }-*/;
-
-    final native void setCommit(CommitInfo c) /*-{ if(c)this.commit=c; }-*/;
-
-    final native void setBranch(String b) /*-{ if(b)this.branch=b; }-*/;
-
-    final native void setProject(String b) /*-{ if(b)this.project=b; }-*/;
-
-    public final Change.Id legacyId() {
-      return hasChangeNumber() ? new Change.Id(_changeNumber()) : null;
-    }
-
-    public final PatchSet.Id patchSetId() {
-      return hasChangeNumber() && hasRevisionNumber()
-          ? new PatchSet.Id(legacyId(), _revisionNumber())
-          : null;
-    }
-
-    public final native boolean hasChangeNumber()
-        /*-{ return this.hasOwnProperty('_change_number') }-*/ ;
-
-    final native boolean hasRevisionNumber()
-        /*-{ return this.hasOwnProperty('_revision_number') }-*/ ;
-
-    final native boolean hasCurrentRevisionNumber()
-        /*-{ return this.hasOwnProperty('_current_revision_number') }-*/ ;
-
-    final native int _changeNumber() /*-{ return this._change_number }-*/;
-
-    final native int _revisionNumber() /*-{ return this._revision_number }-*/;
-
-    final native int _currentRevisionNumber() /*-{ return this._current_revision_number }-*/;
-
-    final native void setChangeNumber(int n) /*-{ this._change_number=n; }-*/;
-
-    final native void setRevisionNumber(int n) /*-{ this._revision_number=n; }-*/;
-
-    final native void setCurrentRevisionNumber(int n) /*-{ this._current_revision_number=n; }-*/;
-
-    final native void setSubmittable(boolean s) /*-{ this._submittable=s; }-*/;
-
-    final native void setStatus(String s) /*-{ if(s)this.status=s; }-*/;
-
-    protected ChangeAndCommit() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
deleted file mode 100644
index aa049ca..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
+++ /dev/null
@@ -1,593 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.change.RelatedChanges.ChangeAndCommit;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.RepeatingCommand;
-import com.google.gwt.dom.client.AnchorElement;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.dom.client.Node;
-import com.google.gwt.dom.client.NodeList;
-import com.google.gwt.dom.client.Style;
-import com.google.gwt.dom.client.Style.Visibility;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.DoubleClickEvent;
-import com.google.gwt.event.dom.client.DoubleClickHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.ScrollEvent;
-import com.google.gwt.event.dom.client.ScrollHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.safehtml.shared.SafeHtml;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.AbstractImagePrototype;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.IsWidget;
-import com.google.gwt.user.client.ui.ScrollPanel;
-import com.google.gwt.user.client.ui.SimplePanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwt.user.client.ui.impl.HyperlinkImpl;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-class RelatedChangesTab implements IsWidget {
-  private static final String OPEN = init(DOM.createUniqueId().replace('-', '_'));
-  private static final HyperlinkImpl LINK = GWT.create(HyperlinkImpl.class);
-  private static final SafeHtml POINTER_HTML =
-      AbstractImagePrototype.create(Gerrit.RESOURCES.arrowRight()).getSafeHtml();
-
-  private static native String init(String o) /*-{
-    $wnd[o] = $entry(@com.google.gerrit.client.change.RelatedChangesTab::onOpen(
-      Lcom/google/gwt/dom/client/NativeEvent;Lcom/google/gwt/dom/client/Element;));
-    return o + '(event,this)';
-  }-*/;
-
-  private static boolean onOpen(NativeEvent evt, Element e) {
-    if (LINK.handleAsClick(evt.<Event>cast())) {
-      Gerrit.display(e.getAttribute("href").substring(1));
-      evt.preventDefault();
-      return false;
-    }
-    return true;
-  }
-
-  private final SimplePanel panel;
-  private final RelatedChanges.Tab subject;
-
-  private boolean showBranches;
-  private boolean showProjects;
-  private boolean showSubmittable;
-  private boolean showIndirectAncestors;
-  private boolean registerKeys;
-  private int maxHeight;
-
-  private String project;
-  private NavigationList view;
-
-  RelatedChangesTab(RelatedChanges.Tab subject) {
-    panel = new SimplePanel();
-    this.subject = subject;
-  }
-
-  @Override
-  public Widget asWidget() {
-    return panel;
-  }
-
-  void setShowBranches(boolean showBranches) {
-    this.showBranches = showBranches;
-  }
-
-  void setShowProjects(boolean showProjects) {
-    this.showProjects = showProjects;
-  }
-
-  void setShowSubmittable(boolean submittable) {
-    this.showSubmittable = submittable;
-  }
-
-  void setShowIndirectAncestors(boolean showIndirectAncestors) {
-    this.showIndirectAncestors = showIndirectAncestors;
-  }
-
-  void setMaxHeight(int height) {
-    maxHeight = height;
-    if (view != null) {
-      view.setHeight(height + "px");
-      view.ensureRowMeasurements();
-      view.movePointerTo(view.selectedRow, true);
-    }
-  }
-
-  void registerKeys(boolean on) {
-    registerKeys = on;
-    if (view != null) {
-      view.setRegisterKeys(on);
-    }
-  }
-
-  void setError(String message) {
-    panel.setWidget(new InlineLabel(message));
-    view = null;
-    project = null;
-  }
-
-  void setChanges(String project, String revision, JsArray<ChangeAndCommit> changes) {
-    if (0 == changes.length()) {
-      setError(Resources.C.noChanges());
-      return;
-    }
-
-    this.project = project;
-    view = new NavigationList();
-    panel.setWidget(view);
-
-    DisplayCommand display = new DisplayCommand(revision, changes, view);
-    if (display.execute()) {
-      Scheduler.get().scheduleIncremental(display);
-    }
-  }
-
-  private final class DisplayCommand implements RepeatingCommand {
-    private final String revision;
-    private final JsArray<ChangeAndCommit> changes;
-    private final List<SafeHtml> rows;
-    private final Set<String> connected;
-    private final NavigationList navList;
-
-    private double start;
-    private int row;
-    private int connectedPos;
-    private int selected;
-
-    private DisplayCommand(
-        String revision, JsArray<ChangeAndCommit> changes, NavigationList navList) {
-      this.revision = revision;
-      this.changes = changes;
-      this.navList = navList;
-      rows = new ArrayList<>(changes.length());
-      connectedPos = changes.length() - 1;
-      connected =
-          showIndirectAncestors ? new HashSet<>(Math.max(changes.length() * 4 / 3, 16)) : null;
-    }
-
-    private boolean computeConnected() {
-      // Since TOPO sorted, when can walk the list in reverse and find all
-      // the connections.
-      if (!connected.contains(revision)) {
-        while (connectedPos >= 0) {
-          CommitInfo c = changes.get(connectedPos).commit();
-          connected.add(c.commit());
-          if (longRunning(--connectedPos)) {
-            return true;
-          }
-          if (c.commit().equals(revision)) {
-            break;
-          }
-        }
-      }
-      while (connectedPos >= 0) {
-        CommitInfo c = changes.get(connectedPos).commit();
-        for (int j = 0; j < c.parents().length(); j++) {
-          if (connected.contains(c.parents().get(j).commit())) {
-            connected.add(c.commit());
-            break;
-          }
-        }
-        if (longRunning(--connectedPos)) {
-          return true;
-        }
-      }
-      return false;
-    }
-
-    @Override
-    public boolean execute() {
-      if (navList != view || !panel.isAttached()) {
-        // If the user navigated away, we aren't in the DOM anymore.
-        // Don't continue to render.
-        return false;
-      }
-
-      start = System.currentTimeMillis();
-
-      if (connected != null && computeConnected()) {
-        return true;
-      }
-
-      while (row < changes.length()) {
-        ChangeAndCommit info = changes.get(row);
-        String commit = info.commit().commit();
-        rows.add(new RowSafeHtml(info, connected != null && !connected.contains(commit)));
-        if (revision.equals(commit)) {
-          selected = row;
-        }
-        if (longRunning(++row)) {
-          return true;
-        }
-      }
-
-      navList.rows = rows;
-      navList.ensureRowMeasurements();
-      navList.movePointerTo(selected, true);
-      return false;
-    }
-
-    private boolean longRunning(int i) {
-      return (i % 10) == 0 && System.currentTimeMillis() - start > 50;
-    }
-  }
-
-  @SuppressWarnings("serial")
-  private class RowSafeHtml implements SafeHtml {
-    private String html;
-    private ChangeAndCommit info;
-    private final boolean notConnected;
-
-    RowSafeHtml(ChangeAndCommit info, boolean notConnected) {
-      this.info = info;
-      this.notConnected = notConnected;
-    }
-
-    @Override
-    public String asString() {
-      if (html == null) {
-        SafeHtmlBuilder sb = new SafeHtmlBuilder();
-        renderRow(sb);
-        html = sb.asString();
-        info = null;
-      }
-      return html;
-    }
-
-    private void renderRow(SafeHtmlBuilder sb) {
-      sb.openDiv().setStyleName(RelatedChanges.R.css().row());
-
-      sb.openSpan().setStyleName(RelatedChanges.R.css().pointer());
-      sb.append(POINTER_HTML);
-      sb.closeSpan();
-
-      if (info.status() == Change.Status.ABANDONED) {
-        sb.openSpan().setStyleName(RelatedChanges.R.css().strikedSubject());
-      } else {
-        sb.openSpan().setStyleName(RelatedChanges.R.css().subject());
-      }
-      sb.setAttribute("data-branch", info.branch());
-      sb.setAttribute("data-project", info.project());
-      String url = url();
-      if (url != null) {
-        sb.openAnchor().setAttribute("href", url);
-        if (url.startsWith("#")) {
-          sb.setAttribute("onclick", OPEN);
-        }
-        sb.setAttribute("title", info.commit().subject());
-        if (showProjects) {
-          sb.append(info.project()).append(": ");
-        }
-        if (showBranches) {
-          sb.append(info.branch()).append(": ");
-        }
-        sb.append(info.commit().subject());
-        sb.closeAnchor();
-      } else {
-        sb.append(info.commit().subject());
-      }
-      sb.closeSpan();
-
-      sb.openSpan();
-      if (info.status() != null && !info.status().isOpen()) {
-        sb.setStyleName(RelatedChanges.R.css().gitweb());
-        sb.setAttribute("title", Util.toLongString(info.status()));
-        sb.append('\u25CF'); // Unicode 'BLACK CIRCLE'
-      } else if (notConnected) {
-        sb.setStyleName(RelatedChanges.R.css().indirect());
-        sb.setAttribute("title", Resources.C.indirectAncestor());
-        sb.append('~');
-      } else if (info.hasCurrentRevisionNumber()
-          && info.hasRevisionNumber()
-          && info._currentRevisionNumber() != info._revisionNumber()) {
-        sb.setStyleName(RelatedChanges.R.css().notCurrent());
-        sb.setAttribute("title", Util.C.notCurrent());
-        sb.append('\u25CF'); // Unicode 'BLACK CIRCLE'
-      } else if (showSubmittable && info.submittable()) {
-        sb.setStyleName(RelatedChanges.R.css().submittable());
-        sb.setAttribute("title", Util.C.submittable());
-        sb.append('\u2713'); // Unicode 'CHECK MARK'
-      } else {
-        sb.setStyleName(RelatedChanges.R.css().current());
-      }
-      sb.closeSpan();
-
-      sb.closeDiv();
-    }
-
-    private String url() {
-      if (info.hasChangeNumber() && info.hasRevisionNumber()) {
-        return "#" + PageLinks.toChange(new Project.NameKey(info.project()), info.patchSetId());
-      }
-      return null;
-    }
-  }
-
-  private class NavigationList extends ScrollPanel
-      implements ClickHandler, DoubleClickHandler, ScrollHandler {
-    private final KeyCommandSet keysNavigation;
-    private final Element body;
-    private final Element surrogate;
-    private final Node fragment = createDocumentFragment();
-
-    List<SafeHtml> rows;
-    private HandlerRegistration regNavigation;
-    private int selectedRow;
-    private int startRow;
-    private int rowHeight;
-    private int rowWidth;
-
-    NavigationList() {
-      addDomHandler(this, ClickEvent.getType());
-      addDomHandler(this, DoubleClickEvent.getType());
-      addScrollHandler(this);
-
-      keysNavigation = new KeyCommandSet(Resources.C.relatedChanges());
-      keysNavigation.add(
-          new KeyCommand(0, 'K', Resources.C.previousChange()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              movePointerTo(selectedRow - 1, true);
-            }
-          },
-          new KeyCommand(0, 'J', Resources.C.nextChange()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              movePointerTo(selectedRow + 1, true);
-            }
-          });
-      keysNavigation.add(
-          new KeyCommand(0, 'O', Resources.C.openChange()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              onOpenRow(getRow(selectedRow));
-            }
-          });
-
-      if (maxHeight > 0) {
-        setHeight(maxHeight + "px");
-      }
-
-      body = DOM.createDiv();
-      body.getStyle().setPosition(Style.Position.RELATIVE);
-      body.getStyle().setVisibility(Visibility.HIDDEN);
-      getContainerElement().appendChild(body);
-
-      surrogate = DOM.createDiv();
-      surrogate.getStyle().setVisibility(Visibility.HIDDEN);
-    }
-
-    private boolean ensureRowMeasurements() {
-      if (rowHeight == 0 && rows != null) {
-        surrogate.setInnerSafeHtml(rows.get(0));
-        getContainerElement().appendChild(surrogate);
-        rowHeight = surrogate.getOffsetHeight();
-        rowWidth = surrogate.getOffsetWidth();
-        getContainerElement().removeChild(surrogate);
-        getContainerElement().getStyle().setHeight(rowHeight * rows.size(), Style.Unit.PX);
-        return true;
-      }
-      return false;
-    }
-
-    public void movePointerTo(int row, boolean scroll) {
-      if (rows != null && 0 <= row && row < rows.size()) {
-        renderSelected(selectedRow, false);
-        selectedRow = row;
-
-        if (scroll && rowHeight != 0) {
-          // Position the selected row in the middle.
-          setVerticalScrollPosition(Math.max(rowHeight * selectedRow - maxHeight / 2, 0));
-          render();
-        }
-        renderSelected(selectedRow, true);
-      }
-    }
-
-    private void renderSelected(int row, boolean selected) {
-      Element e = getRow(row);
-      if (e != null) {
-        if (selected) {
-          e.addClassName(RelatedChanges.R.css().activeRow());
-        } else {
-          e.removeClassName(RelatedChanges.R.css().activeRow());
-        }
-      }
-    }
-
-    private void render() {
-      if (rows == null || rowHeight == 0) {
-        return;
-      }
-
-      int currStart = startRow;
-      int currEnd = startRow + body.getChildCount();
-
-      int vpos = getVerticalScrollPosition();
-      int start = Math.max(vpos / rowHeight - 5, 0);
-      int end = Math.min((vpos + maxHeight) / rowHeight + 5, rows.size());
-      if (currStart <= start && end <= currEnd) {
-        return; // All of the required nodes are already in the DOM.
-      }
-
-      if (end <= currStart) {
-        renderRange(start, end, true, true);
-      } else if (start < currStart) {
-        renderRange(start, currStart, false, true);
-      } else if (start >= currEnd) {
-        renderRange(start, end, true, false);
-      } else if (end > currEnd) {
-        renderRange(currEnd, end, false, false);
-      }
-
-      renderSelected(selectedRow, true);
-
-      if (currEnd == 0) {
-        // Account for the scroll bars
-        int width = body.getOffsetWidth();
-        if (rowWidth > width) {
-          int w = 2 * rowWidth - width;
-          setWidth(w + "px");
-        }
-        body.getStyle().clearVisibility();
-      }
-    }
-
-    private void renderRange(int start, int end, boolean removeAll, boolean insertFirst) {
-      SafeHtmlBuilder sb = new SafeHtmlBuilder();
-      for (int i = start; i < end; i++) {
-        sb.append(rows.get(i));
-      }
-
-      if (removeAll) {
-        body.setInnerSafeHtml(sb);
-      } else {
-        surrogate.setInnerSafeHtml(sb);
-        for (int cnt = surrogate.getChildCount(); cnt > 0; cnt--) {
-          fragment.appendChild(surrogate.getFirstChild());
-        }
-        if (insertFirst) {
-          body.insertFirst(fragment);
-        } else {
-          body.appendChild(fragment);
-        }
-      }
-
-      if (insertFirst || removeAll) {
-        startRow = start;
-        body.getStyle().setTop(start * rowHeight, Style.Unit.PX);
-      }
-    }
-
-    @Override
-    public void onClick(ClickEvent event) {
-      Element row = getRow(event.getNativeEvent().getEventTarget().<Element>cast());
-      if (row != null) {
-        movePointerTo(startRow + DOM.getChildIndex(body, row), false);
-        event.stopPropagation();
-      }
-      saveSelectedTab();
-    }
-
-    @Override
-    public void onDoubleClick(DoubleClickEvent event) {
-      Element row = getRow(event.getNativeEvent().getEventTarget().<Element>cast());
-      if (row != null) {
-        movePointerTo(startRow + DOM.getChildIndex(body, row), false);
-        onOpenRow(row);
-        event.stopPropagation();
-      }
-    }
-
-    @Override
-    public void onScroll(ScrollEvent event) {
-      render();
-    }
-
-    private Element getRow(Element e) {
-      for (Element prev = e; e != null; prev = e) {
-        if ((e = DOM.getParent(e)) == body) {
-          return prev;
-        }
-      }
-      return null;
-    }
-
-    private Element getRow(int row) {
-      if (startRow <= row && row < startRow + body.getChildCount()) {
-        return body.getChild(row - startRow).cast();
-      }
-      return null;
-    }
-
-    private void onOpenRow(Element row) {
-      // Find the first HREF of the anchor of the select row (if any)
-      if (row != null) {
-        NodeList<Element> nodes = row.getElementsByTagName(AnchorElement.TAG);
-        for (int i = 0; i < nodes.getLength(); i++) {
-          String url = nodes.getItem(i).getAttribute("href");
-          if (!url.isEmpty()) {
-            if (url.startsWith("#")) {
-              Gerrit.display(url.substring(1));
-            } else {
-              Window.Location.assign(url);
-            }
-            break;
-          }
-        }
-      }
-
-      saveSelectedTab();
-    }
-
-    private void saveSelectedTab() {
-      RelatedChanges.setSavedTab(subject);
-    }
-
-    @Override
-    protected void onLoad() {
-      super.onLoad();
-      setRegisterKeys(registerKeys);
-    }
-
-    @Override
-    protected void onUnload() {
-      setRegisterKeys(false);
-      super.onUnload();
-    }
-
-    public void setRegisterKeys(boolean on) {
-      if (on && isAttached()) {
-        if (regNavigation == null) {
-          regNavigation = GlobalKey.add(this, keysNavigation);
-        }
-        if (view.ensureRowMeasurements()) {
-          view.movePointerTo(view.selectedRow, true);
-        }
-      } else if (regNavigation != null) {
-        regNavigation.removeHandler();
-        regNavigation = null;
-      }
-    }
-  }
-
-  private static native Node createDocumentFragment() /*-{
-    return $doc.createDocumentFragment();
-  }-*/;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java
deleted file mode 100644
index 1e7063a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-
-class RenameFileAction {
-  private final Project.NameKey project;
-  private final Change.Id changeId;
-  private final RevisionInfo revision;
-  private final ChangeScreen.Style style;
-  private final Widget renameButton;
-
-  private RenameFileBox renameBox;
-  private PopupPanel popup;
-
-  RenameFileAction(
-      Project.NameKey project,
-      Change.Id changeId,
-      RevisionInfo revision,
-      ChangeScreen.Style style,
-      Widget renameButton) {
-    this.project = project;
-    this.changeId = changeId;
-    this.revision = revision;
-    this.style = style;
-    this.renameButton = renameButton;
-  }
-
-  void onRename() {
-    if (popup != null) {
-      popup.hide();
-      return;
-    }
-
-    if (renameBox == null) {
-      renameBox = new RenameFileBox(project, changeId, revision);
-    }
-    renameBox.clearPath();
-
-    final PopupPanel p = new PopupPanel(true);
-    p.setStyleName(style.replyBox());
-    p.addAutoHidePartner(renameButton.getElement());
-    p.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            if (popup == p) {
-              popup = null;
-            }
-          }
-        });
-    p.add(renameBox);
-    p.showRelativeTo(renameButton);
-    GlobalKey.dialog(p);
-    renameBox.setFocus(true);
-    popup = p;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java
deleted file mode 100644
index f288dbe..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.changes.ChangeEditApi;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.ui.RemoteSuggestBox;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-
-class RenameFileBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, RenameFileBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  private final Project.NameKey project;
-  private final Change.Id changeId;
-
-  @UiField Button rename;
-  @UiField Button cancel;
-
-  @UiField(provided = true)
-  RemoteSuggestBox path;
-
-  @UiField NpTextBox newPath;
-
-  RenameFileBox(Project.NameKey project, Change.Id changeId, RevisionInfo revision) {
-    this.project = project;
-    this.changeId = changeId;
-
-    path = new RemoteSuggestBox(new PathSuggestOracle(project, changeId, revision));
-    path.addCloseHandler(
-        new CloseHandler<RemoteSuggestBox>() {
-          @Override
-          public void onClose(CloseEvent<RemoteSuggestBox> event) {
-            hide();
-          }
-        });
-
-    initWidget(uiBinder.createAndBindUi(this));
-  }
-
-  void setFocus(boolean focus) {
-    path.setFocus(focus);
-  }
-
-  void clearPath() {
-    path.setText("");
-  }
-
-  @UiHandler("rename")
-  void onRename(@SuppressWarnings("unused") ClickEvent e) {
-    rename(path.getText(), newPath.getText());
-  }
-
-  private void rename(String path, String newPath) {
-    hide();
-    ChangeEditApi.rename(
-        project.get(),
-        changeId.get(),
-        path,
-        newPath,
-        new AsyncCallback<VoidResult>() {
-          @Override
-          public void onSuccess(VoidResult result) {
-            Gerrit.display(PageLinks.toChangeInEditMode(project, changeId));
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {}
-        });
-  }
-
-  @UiHandler("cancel")
-  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    hide();
-  }
-
-  private void hide() {
-    for (Widget w = getParent(); w != null; w = w.getParent()) {
-      if (w instanceof PopupPanel) {
-        ((PopupPanel) w).hide();
-        break;
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.ui.xml
deleted file mode 100644
index 17e8797..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.ui.xml
+++ /dev/null
@@ -1,47 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
-    xmlns:u='urn:import:com.google.gerrit.client.ui'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false'>
-    .cancel { float: right; }
-  </ui:style>
-  <g:HTMLPanel>
-    <div class='{res.style.section}'>
-      <ui:msg>Old: <u:RemoteSuggestBox ui:field='path' visibleLength='86'/></ui:msg>
-    </div>
-    <div class='{res.style.section}'>
-      <ui:msg>New: <c:NpTextBox ui:field='newPath' visibleLength='86'/></ui:msg>
-    </div>
-    <div class='{res.style.section}'>
-      <g:Button ui:field='rename'
-          title='Rename file in the repository'
-          styleName='{res.style.button}'>
-        <ui:attribute name='title'/>
-        <div><ui:msg>Rename</ui:msg></div>
-      </g:Button>
-      <g:Button ui:field='cancel'
-          styleName='{res.style.button}'
-          addStyleNames='{style.cancel}'>
-          <div>Cancel</div>
-      </g:Button>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java
deleted file mode 100644
index ff09ff5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.changes.ReviewInput;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.LabelInfo;
-import com.google.gerrit.client.info.ChangeInfo.MessageInfo;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-
-class ReplyAction {
-  private final PatchSet.Id psId;
-  private final Project.NameKey project;
-  private final String revision;
-  private final boolean hasDraftComments;
-  private final ChangeScreen.Style style;
-  private final CommentLinkProcessor clp;
-  private final Widget replyButton;
-  private final Widget quickApproveButton;
-
-  private NativeMap<LabelInfo> allLabels;
-  private NativeMap<JsArrayString> permittedLabels;
-
-  private ReplyBox replyBox;
-  private PopupPanel popup;
-
-  ReplyAction(
-      ChangeInfo info,
-      String revision,
-      boolean hasDraftComments,
-      ChangeScreen.Style style,
-      CommentLinkProcessor clp,
-      Widget replyButton,
-      Widget quickApproveButton) {
-    this.psId = new PatchSet.Id(info.legacyId(), info.revisions().get(revision)._number());
-    this.project = info.projectNameKey();
-    this.revision = revision;
-    this.hasDraftComments = hasDraftComments;
-    this.style = style;
-    this.clp = clp;
-    this.replyButton = replyButton;
-    this.quickApproveButton = quickApproveButton;
-
-    boolean current = revision.equals(info.currentRevision());
-    allLabels = info.allLabels();
-    permittedLabels =
-        current && info.hasPermittedLabels()
-            ? info.permittedLabels()
-            : NativeMap.<JsArrayString>create();
-  }
-
-  boolean isVisible() {
-    return popup != null;
-  }
-
-  void quickApprove(ReviewInput input) {
-    replyBox.quickApprove(input);
-  }
-
-  void hide() {
-    if (popup != null) {
-      popup.hide();
-    }
-    return;
-  }
-
-  void onReply(MessageInfo msg) {
-    if (popup != null) {
-      popup.hide();
-      return;
-    }
-
-    if (replyBox == null) {
-      replyBox = new ReplyBox(clp, project, psId, revision, allLabels, permittedLabels);
-      allLabels = null;
-      permittedLabels = null;
-    }
-    if (msg != null) {
-      replyBox.replyTo(msg);
-    }
-
-    final PopupPanel p = new PopupPanel(true, false);
-    p.setStyleName(style.replyBox());
-    p.addAutoHidePartner(replyButton.getElement());
-    p.addAutoHidePartner(quickApproveButton.getElement());
-    p.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            if (popup == p) {
-              popup = null;
-              if (hasDraftComments || replyBox.hasMessage()) {
-                replyButton.setStyleName(style.highlight());
-              }
-            }
-          }
-        });
-    p.add(replyBox);
-    Window.scrollTo(0, 0);
-    replyButton.removeStyleName(style.highlight());
-    p.showRelativeTo(replyButton);
-    GlobalKey.dialog(p);
-    popup = p;
-  }
-}
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
deleted file mode 100644
index 0bbd614..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
+++ /dev/null
@@ -1,535 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import static com.google.gwt.event.dom.client.KeyCodes.KEY_ENTER;
-import static com.google.gwt.event.dom.client.KeyCodes.KEY_MAC_ENTER;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.changes.ReviewInput;
-import com.google.gerrit.client.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.ChangeInfo.ApprovalInfo;
-import com.google.gerrit.client.info.ChangeInfo.LabelInfo;
-import com.google.gerrit.client.info.ChangeInfo.MessageInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.RepeatingCommand;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.dom.client.MouseOutEvent;
-import com.google.gwt.event.dom.client.MouseOutHandler;
-import com.google.gwt.event.dom.client.MouseOverEvent;
-import com.google.gwt.event.dom.client.MouseOverHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.RadioButton;
-import com.google.gwt.user.client.ui.ScrollPanel;
-import com.google.gwt.user.client.ui.TextArea;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import java.util.TreeSet;
-
-public class ReplyBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, ReplyBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface Styles extends CssResource {
-    String label_name();
-
-    String label_value();
-
-    String label_help();
-  }
-
-  private final CommentLinkProcessor clp;
-  private final Project.NameKey project;
-  private final PatchSet.Id psId;
-  private final String revision;
-  private ReviewInput in = ReviewInput.create();
-  private int labelHelpColumn;
-  private LocalComments lc;
-
-  @UiField Styles style;
-  @UiField TextArea message;
-  @UiField Element labelsParent;
-  @UiField Grid labelsTable;
-  @UiField Button post;
-  @UiField Button cancel;
-  @UiField ScrollPanel commentsPanel;
-  @UiField FlowPanel comments;
-
-  ReplyBox(
-      CommentLinkProcessor clp,
-      Project.NameKey project,
-      PatchSet.Id psId,
-      String revision,
-      NativeMap<LabelInfo> all,
-      NativeMap<JsArrayString> permitted) {
-    this.clp = clp;
-    this.project = project;
-    this.psId = psId;
-    this.revision = revision;
-    this.lc = new LocalComments(project, psId.getParentKey());
-    initWidget(uiBinder.createAndBindUi(this));
-
-    List<String> names = new ArrayList<>(permitted.keySet());
-    if (names.isEmpty()) {
-      UIObject.setVisible(labelsParent, false);
-    } else {
-      Collections.sort(names);
-      renderLabels(names, all, permitted);
-    }
-
-    addDomHandler(
-        new KeyDownHandler() {
-          @Override
-          public void onKeyDown(KeyDownEvent e) {
-            e.stopPropagation();
-            if ((e.getNativeKeyCode() == KEY_ENTER || e.getNativeKeyCode() == KEY_MAC_ENTER)
-                && (e.isControlKeyDown() || e.isMetaKeyDown())) {
-              e.preventDefault();
-              if (post.isEnabled()) {
-                onPost(null);
-              }
-            }
-          }
-        },
-        KeyDownEvent.getType());
-    addDomHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent e) {
-            e.stopPropagation();
-          }
-        },
-        KeyPressEvent.getType());
-  }
-
-  @Override
-  protected void onLoad() {
-    commentsPanel.setVisible(false);
-    post.setEnabled(false);
-    if (lc.hasReplyComment()) {
-      message.setText(lc.getReplyComment());
-      lc.removeReplyComment();
-    }
-    ChangeApi.drafts(project.get(), psId.getParentKey().get())
-        .get(
-            new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-              @Override
-              public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-                displayComments(result);
-                post.setEnabled(true);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                post.setEnabled(true);
-              }
-            });
-
-    Scheduler.get()
-        .scheduleDeferred(
-            new ScheduledCommand() {
-              @Override
-              public void execute() {
-                message.setFocus(true);
-              }
-            });
-    Scheduler.get()
-        .scheduleFixedDelay(
-            new RepeatingCommand() {
-              @Override
-              public boolean execute() {
-                String t = message.getText();
-                if (t != null) {
-                  message.setCursorPos(t.length());
-                }
-                return false;
-              }
-            },
-            0);
-  }
-
-  @UiHandler("post")
-  void onPost(@SuppressWarnings("unused") ClickEvent e) {
-    postReview();
-  }
-
-  void quickApprove(ReviewInput quickApproveInput) {
-    in.mergeLabels(quickApproveInput);
-    postReview();
-  }
-
-  boolean hasMessage() {
-    return !message.getText().trim().isEmpty();
-  }
-
-  private void postReview() {
-    in.message(message.getText().trim());
-    // Don't send any comments in the request; just publish everything, even if
-    // e.g. a draft was modified in another tab since we last looked it up.
-    in.drafts(DraftHandling.PUBLISH_ALL_REVISIONS);
-    in.prePost();
-    ChangeApi.revision(project.get(), psId.getParentKey().get(), revision)
-        .view("review")
-        .post(
-            in,
-            new GerritCallback<ReviewInput>() {
-              @Override
-              public void onSuccess(ReviewInput result) {
-                Gerrit.display(PageLinks.toChange(project, psId));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                if (RestApi.isNotSignedIn(caught)) {
-                  lc.setReplyComment(message.getText());
-                }
-                super.onFailure(caught);
-              }
-            });
-    hide();
-  }
-
-  @UiHandler("cancel")
-  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    message.setText("");
-    hide();
-  }
-
-  void replyTo(MessageInfo msg) {
-    if (msg.message() != null) {
-      String t = message.getText();
-      String m = quote(removePatchSetHeaderLine(msg.message()));
-      if (t == null || t.isEmpty()) {
-        t = m;
-      } else if (t.endsWith("\n\n")) {
-        t += m;
-      } else if (t.endsWith("\n")) {
-        t += "\n" + m;
-      } else {
-        t += "\n\n" + m;
-      }
-      message.setText(t);
-    }
-  }
-
-  private static String removePatchSetHeaderLine(String msg) {
-    msg = msg.trim();
-    if (msg.startsWith("Patch Set ")) {
-      int i = msg.indexOf('\n');
-      if (i > 0) {
-        msg = msg.substring(i + 1).trim();
-      }
-    }
-    return msg;
-  }
-
-  public static String quote(String msg) {
-    msg = msg.trim();
-    StringBuilder quotedMsg = new StringBuilder();
-    for (String line : msg.split("\\n")) {
-      line = line.trim();
-      while (line.length() > 67) {
-        int i = line.lastIndexOf(' ', 67);
-        if (i < 50) {
-          i = line.indexOf(' ', 67);
-        }
-        if (i > 0) {
-          quotedMsg.append(" > ").append(line.substring(0, i)).append("\n");
-          line = line.substring(i + 1);
-        } else {
-          break;
-        }
-      }
-      quotedMsg.append(" > ").append(line).append("\n");
-    }
-    quotedMsg.append("\n");
-    return quotedMsg.toString();
-  }
-
-  private void hide() {
-    for (Widget w = getParent(); w != null; w = w.getParent()) {
-      if (w instanceof PopupPanel) {
-        ((PopupPanel) w).hide();
-        break;
-      }
-    }
-  }
-
-  private void renderLabels(
-      List<String> names, NativeMap<LabelInfo> all, NativeMap<JsArrayString> permitted) {
-    TreeSet<Short> values = new TreeSet<>();
-    List<LabelAndValues> labels = new ArrayList<>(permitted.size());
-    for (String id : names) {
-      JsArrayString p = permitted.get(id);
-      if (p != null) {
-        if (!all.containsKey(id)) {
-          continue;
-        }
-        Set<Short> a = new TreeSet<>();
-        for (int i = 0; i < p.length(); i++) {
-          a.add(LabelInfo.parseValue(p.get(i)));
-        }
-        labels.add(new LabelAndValues(all.get(id), a));
-        values.addAll(a);
-      }
-    }
-    List<Short> columns = new ArrayList<>(values);
-
-    labelsTable.resize(1 + labels.size(), 2 + values.size());
-    for (int c = 0; c < columns.size(); c++) {
-      labelsTable.setText(0, 1 + c, LabelValue.formatValue(columns.get(c)));
-      labelsTable.getCellFormatter().setStyleName(0, 1 + c, style.label_value());
-    }
-
-    List<LabelAndValues> checkboxes = new ArrayList<>(labels.size());
-    int row = 1;
-    for (LabelAndValues lv : labels) {
-      if (isCheckBox(lv.info.valueSet())) {
-        checkboxes.add(lv);
-      } else {
-        renderRadio(row++, columns, lv);
-      }
-    }
-    for (LabelAndValues lv : checkboxes) {
-      renderCheckBox(row++, lv);
-    }
-  }
-
-  private Short normalizeDefaultValue(Short defaultValue, Set<Short> permittedValues) {
-    Short pmin = Collections.min(permittedValues);
-    Short pmax = Collections.max(permittedValues);
-    Short dv = defaultValue;
-    if (dv > pmax) {
-      dv = pmax;
-    } else if (dv < pmin) {
-      dv = pmin;
-    }
-    return dv;
-  }
-
-  private void renderRadio(int row, List<Short> columns, LabelAndValues lv) {
-    String id = lv.info.name();
-    Short dv = normalizeDefaultValue(lv.info.defaultValue(), lv.permitted);
-
-    labelHelpColumn = 1 + columns.size();
-    labelsTable.setText(row, 0, id);
-
-    CellFormatter fmt = labelsTable.getCellFormatter();
-    fmt.setStyleName(row, 0, style.label_name());
-    fmt.setStyleName(row, labelHelpColumn, style.label_help());
-
-    ApprovalInfo self =
-        Gerrit.isSignedIn() ? lv.info.forUser(Gerrit.getUserAccount()._accountId()) : null;
-
-    final LabelRadioGroup group = new LabelRadioGroup(row, id, lv.permitted.size());
-    for (int i = 0; i < columns.size(); i++) {
-      Short v = columns.get(i);
-      if (lv.permitted.contains(v)) {
-        String text = lv.info.valueText(LabelValue.formatValue(v));
-        LabelRadioButton b = new LabelRadioButton(group, text, v);
-        if ((self != null && v == self.value()) || (self == null && v.equals(dv))) {
-          b.setValue(true);
-          group.select(b);
-          in.label(group.label, v);
-          labelsTable.setText(row, labelHelpColumn, b.text);
-        }
-        group.buttons.add(b);
-        labelsTable.setWidget(row, 1 + i, b);
-      }
-    }
-  }
-
-  private void renderCheckBox(int row, LabelAndValues lv) {
-    ApprovalInfo self =
-        Gerrit.isSignedIn() ? lv.info.forUser(Gerrit.getUserAccount()._accountId()) : null;
-
-    final String id = lv.info.name();
-    final CheckBox b = new CheckBox();
-    b.setText(id);
-    b.setEnabled(lv.permitted.contains((short) 1));
-    if (self != null && self.value() == 1) {
-      b.setValue(true);
-    }
-    b.addValueChangeHandler(
-        new ValueChangeHandler<Boolean>() {
-          @Override
-          public void onValueChange(ValueChangeEvent<Boolean> event) {
-            in.label(id, event.getValue() ? (short) 1 : (short) 0);
-          }
-        });
-    b.setStyleName(style.label_name());
-    labelsTable.setWidget(row, 0, b);
-
-    CellFormatter fmt = labelsTable.getCellFormatter();
-    fmt.setStyleName(row, labelHelpColumn, style.label_help());
-    labelsTable.setText(row, labelHelpColumn, lv.info.valueText("+1"));
-  }
-
-  private static boolean isCheckBox(Set<Short> values) {
-    return values.size() == 2 && values.contains((short) 0) && values.contains((short) 1);
-  }
-
-  private void displayComments(NativeMap<JsArray<CommentInfo>> m) {
-    comments.clear();
-
-    JsArray<CommentInfo> l = m.get(Patch.COMMIT_MSG);
-    if (l != null) {
-      comments.add(
-          new FileComments(
-              clp, project, psId, Util.C.commitMessage(), copyPath(Patch.COMMIT_MSG, l)));
-    }
-    l = m.get(Patch.MERGE_LIST);
-    if (l != null) {
-      comments.add(
-          new FileComments(
-              clp, project, psId, Util.C.commitMessage(), copyPath(Patch.MERGE_LIST, l)));
-    }
-
-    List<String> paths = new ArrayList<>(m.keySet());
-    Collections.sort(paths);
-
-    for (String path : paths) {
-      if (!Patch.isMagic(path)) {
-        comments.add(new FileComments(clp, project, psId, path, copyPath(path, m.get(path))));
-      }
-    }
-
-    commentsPanel.setVisible(comments.getWidgetCount() > 0);
-  }
-
-  private static List<CommentInfo> copyPath(String path, JsArray<CommentInfo> l) {
-    for (int i = 0; i < l.length(); i++) {
-      l.get(i).path(path);
-    }
-    return Natives.asList(l);
-  }
-
-  private static class LabelAndValues {
-    final LabelInfo info;
-    final Set<Short> permitted;
-
-    LabelAndValues(LabelInfo info, Set<Short> permitted) {
-      this.info = info;
-      this.permitted = permitted;
-    }
-  }
-
-  private class LabelRadioGroup {
-    final int row;
-    final String label;
-    final List<LabelRadioButton> buttons;
-    LabelRadioButton selected;
-
-    LabelRadioGroup(int row, String label, int cnt) {
-      this.row = row;
-      this.label = label;
-      this.buttons = new ArrayList<>(cnt);
-    }
-
-    void select(LabelRadioButton b) {
-      selected = b;
-      labelsTable.setText(row, labelHelpColumn, b.text);
-    }
-  }
-
-  private class LabelRadioButton extends RadioButton
-      implements ValueChangeHandler<Boolean>, ClickHandler, MouseOverHandler, MouseOutHandler {
-    private final LabelRadioGroup group;
-    private final String text;
-    private final short value;
-
-    LabelRadioButton(LabelRadioGroup group, String text, short value) {
-      super(group.label);
-      this.group = group;
-      this.text = text;
-      this.value = value;
-      addValueChangeHandler(this);
-      addClickHandler(this);
-      addMouseOverHandler(this);
-      addMouseOutHandler(this);
-    }
-
-    @Override
-    public void onValueChange(ValueChangeEvent<Boolean> event) {
-      if (event.getValue()) {
-        select();
-      }
-    }
-
-    @Override
-    public void onClick(ClickEvent event) {
-      select();
-    }
-
-    void select() {
-      group.select(this);
-      in.label(group.label, value);
-    }
-
-    @Override
-    public void onMouseOver(MouseOverEvent event) {
-      labelsTable.setText(group.row, labelHelpColumn, text);
-    }
-
-    @Override
-    public void onMouseOut(MouseOutEvent event) {
-      LabelRadioButton b = group.selected;
-      String s = b != null ? b.text : "";
-      labelsTable.setText(group.row, labelHelpColumn, s);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml
deleted file mode 100644
index 6903b91..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml
+++ /dev/null
@@ -1,85 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false' type='com.google.gerrit.client.change.ReplyBox.Styles'>
-    .replyBox {
-    }
-    .label_name {
-      font-weight: bold;
-      text-align: left;
-      white-space: nowrap;
-    }
-    .label_name input { margin-left: 0; }
-    .label_help {
-      padding-left: 5px;
-      white-space: nowrap;
-    }
-    .label_value {
-      text-align: center;
-    }
-    .cancel {
-      position: absolute;
-      bottom: 5px;
-      right: 5px;
-      background-color: #eee;
-      background-image: -webkit-linear-gradient(top, #eee, #eee);
-    }
-    .cancel div { color: #444; }
-    .comments {
-      max-height: 275px;
-      width: 526px;
-    }
-    .comments p {
-      margin: 5px 0 5px 0;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.replyBox}'>
-    <div class='{res.style.section}'>
-      <g:TextArea
-         visibleLines='5'
-         characterWidth='70'
-         ui:field='message'/>
-    </div>
-    <div class='{res.style.section}' ui:field='labelsParent'>
-      <g:Grid ui:field='labelsTable'/>
-    </div>
-    <g:ScrollPanel ui:field='commentsPanel'
-        styleName='{style.comments}'
-        addStyleNames='{res.style.section}'>
-      <g:FlowPanel ui:field='comments'/>
-    </g:ScrollPanel>
-    <div class='{res.style.section}' style='position: relative'>
-      <g:Button ui:field='post'
-          title='Post reply (Shortcut: Ctrl-Enter)'
-          styleName='{res.style.button}'>
-        <ui:attribute name='title'/>
-        <div><ui:msg>Post</ui:msg></div>
-      </g:Button>
-
-      <g:Button ui:field='cancel'
-          title='Close reply form (Shortcut: Esc)'
-          styleName='{res.style.button}'
-          addStyleNames='{style.cancel}'>
-        <ui:attribute name='title'/>
-        <div><ui:msg>Cancel</ui:msg></div>
-      </g:Button>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
deleted file mode 100644
index cfc4e23..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.CssResource;
-
-public interface Resources extends ClientBundle {
-  Resources I = GWT.create(Resources.class);
-  ChangeConstants C = GWT.create(ChangeConstants.class);
-  ChangeMessages M = GWT.create(ChangeMessages.class);
-
-  @Source("common.css")
-  Style style();
-
-  public interface Style extends CssResource {
-    String button();
-
-    String popup();
-
-    String popupContent();
-
-    String section();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java
deleted file mode 100644
index aa3a9ef..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.user.client.ui.Button;
-
-class RestoreAction extends ActionMessageBox {
-  private final Project.NameKey project;
-  private final Change.Id id;
-
-  RestoreAction(Button b, Project.NameKey project, Change.Id id) {
-    super(b);
-    this.project = project;
-    this.id = id;
-  }
-
-  @Override
-  void send(String message) {
-    ChangeApi.restore(
-        project.get(),
-        id.get(),
-        message,
-        new GerritCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo result) {
-            Gerrit.display(PageLinks.toChange(project, id));
-            hide();
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java
deleted file mode 100644
index 3fba125..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.TextAreaActionDialog;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.PopupPanel;
-
-class RevertAction {
-
-  static void call(
-      final Button b,
-      Change.Id id,
-      Project.NameKey project,
-      String revision,
-      String commitSubject) {
-    // TODO Replace ActionDialog with a nicer looking display.
-    b.setEnabled(false);
-    new TextAreaActionDialog(Util.C.revertChangeTitle(), Util.C.headingRevertMessage()) {
-      {
-        sendButton.setText(Util.C.buttonRevertChangeSend());
-        message.setText(Util.M.revertChangeDefaultMessage(commitSubject, revision));
-      }
-
-      @Override
-      public void onSend() {
-        ChangeApi.revert(
-            project.get(),
-            id.get(),
-            getMessageText(),
-            new GerritCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo result) {
-                sent = true;
-                hide();
-                Gerrit.display(PageLinks.toChange(result.projectNameKey(), result.legacyId()));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                enableButtons(true);
-                super.onFailure(caught);
-              }
-            });
-      }
-
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        super.onClose(event);
-        b.setEnabled(true);
-      }
-    }.center();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
deleted file mode 100644
index 4e464df..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.admin.AdminConstants;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.GroupBaseInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.AccountSuggestOracle;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-/** REST API based suggestion Oracle for reviewers. */
-public class ReviewerSuggestOracle extends HighlightSuggestOracle {
-  private Project.NameKey project;
-  private Change.Id changeId;
-
-  @Override
-  protected void onRequestSuggestions(Request req, Callback cb) {
-    ChangeApi.suggestReviewers(project.get(), changeId.get(), req.getQuery(), req.getLimit(), false)
-        .get(
-            new GerritCallback<JsArray<SuggestReviewerInfo>>() {
-              @Override
-              public void onSuccess(JsArray<SuggestReviewerInfo> result) {
-                List<RestReviewerSuggestion> r = new ArrayList<>(result.length());
-                for (SuggestReviewerInfo reviewer : Natives.asList(result)) {
-                  r.add(new RestReviewerSuggestion(reviewer, req.getQuery()));
-                }
-                cb.onSuggestionsReady(req, new Response(r));
-              }
-
-              @Override
-              public void onFailure(Throwable err) {
-                List<Suggestion> r = Collections.emptyList();
-                cb.onSuggestionsReady(req, new Response(r));
-              }
-            });
-  }
-
-  @Override
-  public void requestDefaultSuggestions(Request req, Callback cb) {
-    requestSuggestions(req, cb);
-  }
-
-  public void setChange(Project.NameKey project, Change.Id changeId) {
-    this.project = project;
-    this.changeId = changeId;
-  }
-
-  public static class RestReviewerSuggestion implements Suggestion {
-    private final String displayString;
-    private final String replacementString;
-
-    RestReviewerSuggestion(SuggestReviewerInfo reviewer, String query) {
-      if (reviewer.account() != null) {
-        this.replacementString =
-            AccountSuggestOracle.AccountSuggestion.format(reviewer.account(), query);
-        this.displayString = replacementString;
-      } else {
-        this.replacementString = reviewer.group().name();
-        this.displayString =
-            replacementString + " (" + AdminConstants.I.suggestedGroupLabel() + ")";
-      }
-    }
-
-    @Override
-    public String getDisplayString() {
-      return displayString;
-    }
-
-    @Override
-    public String getReplacementString() {
-      return replacementString;
-    }
-  }
-
-  public static class SuggestReviewerInfo extends JavaScriptObject {
-    public final native AccountInfo account() /*-{ return this.account; }-*/;
-
-    public final native GroupBaseInfo group() /*-{ return this.group; }-*/;
-
-    protected SuggestReviewerInfo() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
deleted file mode 100644
index 859af19..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
+++ /dev/null
@@ -1,328 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.ConfirmationCallback;
-import com.google.gerrit.client.ConfirmationDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.NotSignedInDialog;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.ApprovalInfo;
-import com.google.gerrit.client.info.ChangeInfo.LabelInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.RemoteSuggestBox;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.rpc.StatusCodeException;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-/** Add reviewers. */
-public class Reviewers extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, Reviewers> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField Element reviewersText;
-  @UiField Image addReviewerIcon;
-  @UiField Button addMe;
-  @UiField Element form;
-  @UiField Element error;
-
-  @UiField(provided = true)
-  RemoteSuggestBox suggestBox;
-
-  private ChangeScreen.Style style;
-  private Element ccText;
-
-  private ReviewerSuggestOracle reviewerSuggestOracle;
-  private Change.Id changeId;
-  private Project.NameKey project;
-
-  Reviewers() {
-    reviewerSuggestOracle = new ReviewerSuggestOracle();
-    suggestBox = new RemoteSuggestBox(reviewerSuggestOracle);
-    suggestBox.enableDefaultSuggestions();
-    suggestBox.setVisibleLength(55);
-    suggestBox.setHintText(Util.C.approvalTableAddReviewerHint());
-    suggestBox.addCloseHandler(
-        new CloseHandler<RemoteSuggestBox>() {
-          @Override
-          public void onClose(CloseEvent<RemoteSuggestBox> event) {
-            Reviewers.this.onCancel(null);
-          }
-        });
-    suggestBox.addSelectionHandler(
-        new SelectionHandler<String>() {
-          @Override
-          public void onSelection(SelectionEvent<String> event) {
-            addReviewer(event.getSelectedItem(), false);
-          }
-        });
-
-    initWidget(uiBinder.createAndBindUi(this));
-    addReviewerIcon.addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            onOpenForm();
-          }
-        },
-        ClickEvent.getType());
-  }
-
-  void init(ChangeScreen.Style style, Element ccText) {
-    this.style = style;
-    this.ccText = ccText;
-  }
-
-  void set(ChangeInfo info) {
-    this.changeId = info.legacyId();
-    this.project = info.projectNameKey();
-    display(info);
-    reviewerSuggestOracle.setChange(project, changeId);
-    addReviewerIcon.setVisible(Gerrit.isSignedIn());
-  }
-
-  void onOpenForm() {
-    UIObject.setVisible(form, true);
-    UIObject.setVisible(error, false);
-    addReviewerIcon.setVisible(false);
-    suggestBox.setServeSuggestionsOnOracle(true);
-    suggestBox.setFocus(true);
-  }
-
-  @UiHandler("add")
-  void onAdd(@SuppressWarnings("unused") ClickEvent e) {
-    addReviewer(suggestBox.getText(), false);
-  }
-
-  @UiHandler("addMe")
-  void onAddMe(@SuppressWarnings("unused") ClickEvent e) {
-    String accountId = String.valueOf(Gerrit.getUserAccount()._accountId());
-    addReviewer(accountId, false);
-  }
-
-  @UiHandler("cancel")
-  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    addReviewerIcon.setVisible(true);
-    UIObject.setVisible(form, false);
-    suggestBox.setFocus(false);
-    suggestBox.setText("");
-    suggestBox.setServeSuggestionsOnOracle(false);
-  }
-
-  private void addReviewer(String reviewer, boolean confirmed) {
-    if (reviewer.isEmpty()) {
-      return;
-    }
-
-    ChangeApi.reviewers(project.get(), changeId.get())
-        .post(
-            PostInput.create(reviewer, confirmed),
-            new GerritCallback<PostResult>() {
-              @Override
-              public void onSuccess(PostResult result) {
-                if (result.confirm()) {
-                  askForConfirmation(result.error());
-                } else if (result.error() != null) {
-                  UIObject.setVisible(error, true);
-                  error.setInnerText(result.error());
-                } else {
-                  UIObject.setVisible(error, false);
-                  error.setInnerText("");
-                  suggestBox.setText("");
-
-                  if (result.reviewers() != null && result.reviewers().length() > 0) {
-                    updateReviewerList();
-                  }
-                }
-              }
-
-              private void askForConfirmation(String text) {
-                new ConfirmationDialog(
-                        Util.C.approvalTableAddManyReviewersConfirmationDialogTitle(),
-                        new SafeHtmlBuilder().append(text),
-                        new ConfirmationCallback() {
-                          @Override
-                          public void onOk() {
-                            addReviewer(reviewer, true);
-                          }
-                        })
-                    .center();
-              }
-
-              @Override
-              public void onFailure(Throwable err) {
-                if (isSigninFailure(err)) {
-                  new NotSignedInDialog().center();
-                } else {
-                  UIObject.setVisible(error, true);
-                  error.setInnerText(
-                      err instanceof StatusCodeException
-                          ? ((StatusCodeException) err).getEncodedResponse()
-                          : err.getMessage());
-                }
-              }
-            });
-  }
-
-  void updateReviewerList() {
-    ChangeApi.detail(
-        project.get(),
-        changeId.get(),
-        new GerritCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo result) {
-            display(result);
-          }
-        });
-  }
-
-  private void display(ChangeInfo info) {
-    Map<ReviewerState, List<AccountInfo>> reviewers = info.reviewers();
-    Map<Integer, AccountInfo> r = byAccount(reviewers, ReviewerState.REVIEWER);
-    Map<Integer, AccountInfo> cc = byAccount(reviewers, ReviewerState.CC);
-    for (Integer i : r.keySet()) {
-      cc.remove(i);
-    }
-    cc.remove(info.owner()._accountId());
-    Set<Integer> removable = info.removableReviewerIds();
-    Map<Integer, VotableInfo> votable = votable(info);
-
-    SafeHtml rHtml = Labels.formatUserList(style, r.values(), removable, null, votable);
-    SafeHtml ccHtml = Labels.formatUserList(style, cc.values(), removable, null, votable);
-
-    reviewersText.setInnerSafeHtml(rHtml);
-    ccText.setInnerSafeHtml(ccHtml);
-    if (Gerrit.isSignedIn()) {
-      int currentUser = Gerrit.getUserAccount()._accountId();
-      boolean showAddMeButton =
-          info.owner()._accountId() != currentUser
-              && !cc.containsKey(currentUser)
-              && !r.containsKey(currentUser);
-      addMe.setVisible(showAddMeButton);
-    }
-  }
-
-  private static Map<Integer, AccountInfo> byAccount(
-      Map<ReviewerState, List<AccountInfo>> reviewers, ReviewerState state) {
-    List<AccountInfo> accounts = reviewers.get(state);
-    if (accounts == null) {
-      return Collections.emptyMap();
-    }
-    Map<Integer, AccountInfo> result = new HashMap<>();
-    for (AccountInfo a : accounts) {
-      result.put(a._accountId(), a);
-    }
-    return result;
-  }
-
-  private static Map<Integer, VotableInfo> votable(ChangeInfo change) {
-    Map<Integer, VotableInfo> d = new HashMap<>();
-    for (String name : change.labels()) {
-      LabelInfo label = change.label(name);
-      Short labelMaxValue =
-          label.valueSet().isEmpty() ? null : LabelInfo.parseValue(label.maxValue());
-      if (label.all() != null) {
-        for (ApprovalInfo ai : Natives.asList(label.all())) {
-          int id = ai._accountId();
-          VotableInfo ad = d.get(id);
-          if (ad == null) {
-            ad = new VotableInfo();
-            d.put(id, ad);
-          }
-          if (labelMaxValue != null
-              && ai.permittedVotingRange() != null
-              && ai.permittedVotingRange().max() == labelMaxValue) {
-            ad.votable(name + " (" + label.maxValue() + ") ");
-          } else if (ai.hasValue()) {
-            ad.votable(name);
-          }
-        }
-      }
-    }
-    return d;
-  }
-
-  public static class PostInput extends JavaScriptObject {
-    public static PostInput create(String reviewer, boolean confirmed) {
-      PostInput input = createObject().cast();
-      input.init(reviewer, confirmed);
-      return input;
-    }
-
-    private native void init(String reviewer, boolean confirmed) /*-{
-      this.reviewer = reviewer;
-      if (confirmed) {
-        this.confirmed = true;
-      }
-    }-*/;
-
-    protected PostInput() {}
-  }
-
-  public static class ReviewerInfo extends AccountInfo {
-    final Set<String> approvals() {
-      return Natives.keys(_approvals());
-    }
-
-    final native String approval(String l) /*-{ return this.approvals[l]; }-*/;
-
-    private native NativeMap<NativeString> _approvals() /*-{ return this.approvals; }-*/;
-
-    protected ReviewerInfo() {}
-  }
-
-  public static class PostResult extends JavaScriptObject {
-    public final native JsArray<ReviewerInfo> reviewers() /*-{ return this.reviewers; }-*/;
-
-    public final native boolean confirm() /*-{ return this.confirm || false; }-*/;
-
-    public final native String error() /*-{ return this.error; }-*/;
-
-    protected PostResult() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml
deleted file mode 100644
index cf506e5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml
+++ /dev/null
@@ -1,69 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:u='urn:import:com.google.gerrit.client.ui'>
-  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false'>
-    .suggestBox {
-      margin-bottom: 2px;
-    }
-
-    .error {
-      color: #D33D3D;
-      font-weight: bold;
-    }
-
-    .addReviewer,
-    .cancel {
-      cursor: pointer;
-      float: right;
-    }
-  </ui:style>
-  <g:HTMLPanel>
-    <div>
-      <span ui:field='reviewersText'/>
-      <g:Image ui:field='addReviewerIcon'
-          resource='{ico.addUser}'
-          styleName='{style.addReviewer}'
-          title='Add Reviewer'/>
-    </div>
-    <div ui:field='form' style='display: none' aria-hidden='true'>
-      <u:RemoteSuggestBox ui:field='suggestBox' styleName='{style.suggestBox}'/>
-      <div ui:field='error'
-           class='{style.error}'
-           style='display: none' aria-hidden='true'/>
-      <div>
-        <g:Button ui:field='add' styleName='{res.style.button}'>
-          <div>Add</div>
-        </g:Button>
-        <g:Button ui:field='addMe'
-            styleName='{res.style.button}' visible='false'>
-          <div>Add Me</div>
-        </g:Button>
-        <g:Button ui:field='cancel'
-            styleName='{res.style.button}'
-            addStyleNames='{style.cancel}'>
-          <div>Cancel</div>
-        </g:Button>
-      </div>
-    </div>
-   </g:HTMLPanel>
-  </ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RightSidePopdownAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RightSidePopdownAction.java
deleted file mode 100644
index 1383c5d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RightSidePopdownAction.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gwt.dom.client.Document;
-import com.google.gwt.dom.client.Style;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-
-abstract class RightSidePopdownAction {
-  private final ChangeScreen.Style style;
-  private final Widget button;
-  private final UIObject relativeTo;
-  private PopupPanel popup;
-
-  RightSidePopdownAction(ChangeScreen.Style style, UIObject relativeTo, Widget button) {
-    this.style = style;
-    this.relativeTo = relativeTo;
-    this.button = button;
-  }
-
-  abstract Widget getWidget();
-
-  void show() {
-    if (popup != null) {
-      button.removeStyleName(style.selected());
-      popup.hide();
-      return;
-    }
-
-    final PopupPanel p =
-        new PopupPanel(true) {
-          @Override
-          public void setPopupPosition(int left, int top) {
-            top -= Document.get().getBodyOffsetTop();
-
-            int w = Window.getScrollLeft() + Window.getClientWidth();
-            int r = relativeTo.getAbsoluteLeft() + relativeTo.getOffsetWidth();
-            int right = w - r;
-            Style style = getElement().getStyle();
-            style.clearProperty("left");
-            style.setPropertyPx("right", right);
-            style.setPropertyPx("top", top);
-          }
-        };
-    p.setStyleName(style.replyBox());
-    p.addAutoHidePartner(button.getElement());
-    p.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            if (popup == p) {
-              button.removeStyleName(style.selected());
-              popup = null;
-            }
-          }
-        });
-    p.add(getWidget());
-    p.showRelativeTo(relativeTo);
-    GlobalKey.dialog(p);
-    button.addStyleName(style.selected());
-    popup = p;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/StarIcon.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/StarIcon.java
deleted file mode 100644
index b7bf8de..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/StarIcon.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.ToggleButton;
-
-class StarIcon extends ToggleButton {
-  StarIcon() {
-    super(new Image(Gerrit.RESOURCES.starOpen()), new Image(Gerrit.RESOURCES.starFilled()));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java
deleted file mode 100644
index 4446e65..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.api.ChangeGlue;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.SubmitInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-
-class SubmitAction {
-  static void call(ChangeInfo changeInfo, RevisionInfo revisionInfo) {
-    if (ChangeGlue.onSubmitChange(changeInfo, revisionInfo)) {
-      final Change.Id changeId = changeInfo.legacyId();
-      ChangeApi.submit(
-          changeInfo.project(),
-          changeId.get(),
-          revisionInfo.name(),
-          new GerritCallback<SubmitInfo>() {
-            @Override
-            public void onSuccess(SubmitInfo result) {
-              redisplay();
-            }
-
-            @Override
-            public void onFailure(Throwable err) {
-              if (SubmitFailureDialog.isConflict(err)) {
-                new SubmitFailureDialog(err.getMessage()).center();
-              } else {
-                super.onFailure(err);
-              }
-              redisplay();
-            }
-
-            private void redisplay() {
-              Gerrit.display(PageLinks.toChange(changeInfo.projectNameKey(), changeId));
-            }
-          });
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitFailureDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitFailureDialog.java
deleted file mode 100644
index 77bf217..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitFailureDialog.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.changes.Util;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import com.google.gwtjsonrpc.client.RemoteJsonException;
-
-class SubmitFailureDialog extends ErrorDialog {
-  static boolean isConflict(Throwable err) {
-    return err instanceof RemoteJsonException && 409 == ((RemoteJsonException) err).getCode();
-  }
-
-  SubmitFailureDialog(String msg) {
-    super(new SafeHtmlBuilder().append(msg.trim()).wikify());
-    setText(Util.C.submitFailed());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
deleted file mode 100644
index f5c921b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
+++ /dev/null
@@ -1,148 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-
-/** Displays (and edits) the change topic string. */
-class Topic extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, Topic> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  private PatchSet.Id psId;
-  private Project.NameKey project;
-  private boolean canEdit;
-
-  @UiField Element show;
-  @UiField InlineHyperlink text;
-  @UiField Image editIcon;
-
-  @UiField Element form;
-  @UiField NpTextBox input;
-  @UiField Button save;
-  @UiField Button cancel;
-
-  Topic() {
-    initWidget(uiBinder.createAndBindUi(this));
-    editIcon.addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            onEdit();
-          }
-        },
-        ClickEvent.getType());
-  }
-
-  void set(ChangeInfo info, String revision) {
-    canEdit = info.hasActions() && info.actions().containsKey("topic");
-
-    psId = new PatchSet.Id(info.legacyId(), info.revisions().get(revision)._number());
-    project = info.projectNameKey();
-
-    initTopicLink(info);
-    editIcon.setVisible(canEdit);
-    if (!canEdit) {
-      show.setTitle(null);
-    }
-  }
-
-  private void initTopicLink(ChangeInfo info) {
-    if (info.topic() != null && !info.topic().isEmpty()) {
-      String topic = info.topic();
-      text.setText(topic);
-      text.setTargetHistoryToken(PageLinks.topicQuery(info.status(), topic));
-    }
-  }
-
-  boolean canEdit() {
-    return canEdit;
-  }
-
-  void onEdit() {
-    if (canEdit) {
-      UIObject.setVisible(show, false);
-      UIObject.setVisible(form, true);
-
-      input.setText(text.getText());
-      input.setFocus(true);
-      input.selectAll();
-    }
-  }
-
-  @UiHandler("cancel")
-  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    input.setFocus(false);
-    UIObject.setVisible(form, false);
-    UIObject.setVisible(show, true);
-  }
-
-  @UiHandler("input")
-  void onKeyDownInput(KeyDownEvent e) {
-    if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
-      onCancel(null);
-    } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
-      e.stopPropagation();
-      e.preventDefault();
-      onSave(null);
-    }
-  }
-
-  @UiHandler("save")
-  void onSave(@SuppressWarnings("unused") ClickEvent e) {
-    ChangeApi.topic(
-        project.get(),
-        psId.getParentKey().get(),
-        input.getValue().trim(),
-        new GerritCallback<String>() {
-          @Override
-          public void onSuccess(String result) {
-            Gerrit.display(PageLinks.toChange(project, psId));
-          }
-        });
-    onCancel(null);
-  }
-
-  @UiHandler("save")
-  void onSaveKeyPress(KeyPressEvent e) {
-    if (e.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-      e.stopPropagation();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.ui.xml
deleted file mode 100644
index c2a6fbd..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.ui.xml
+++ /dev/null
@@ -1,54 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:x='urn:import:com.google.gerrit.client.ui'>
-  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style gss='false'>
-    .edit { cursor: pointer; }
-    .edit, .cancel { float: right; }
-  </ui:style>
-  <g:HTMLPanel>
-    <div ui:field='show'>
-      <x:InlineHyperlink ui:field='text'
-          title='Search for changes on this topic'/>
-      <g:Image ui:field='editIcon'
-          resource='{ico.edit}'
-          styleName='{style.edit}'
-          title='Edit topic (Shortcut: t)'/>
-    </div>
-
-    <div ui:field='form' style='display: none' aria-hidden='true'>
-      <div>
-        <c:NpTextBox ui:field='input' visibleLength='55'/>
-      </div>
-      <div>
-        <g:Button ui:field='save' styleName='{res.style.button}'>
-          <div>Update</div>
-        </g:Button>
-        <g:Button ui:field='cancel'
-            styleName='{res.style.button}'
-            addStyleNames='{style.cancel}'>
-          <div>Cancel</div>
-        </g:Button>
-      </div>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.java
deleted file mode 100644
index 520dc69..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.info.ChangeInfo.MessageInfo;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import java.sql.Timestamp;
-import java.util.HashSet;
-import java.util.List;
-
-/** Displays the "New Message From ..." panel in bottom right on updates. */
-abstract class UpdateAvailableBar extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, UpdateAvailableBar> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  private Timestamp updated;
-
-  @UiField Element author;
-  @UiField Anchor show;
-  @UiField Anchor ignore;
-
-  UpdateAvailableBar() {
-    initWidget(uiBinder.createAndBindUi(this));
-  }
-
-  void set(List<MessageInfo> newMessages, Timestamp newTime) {
-    HashSet<Integer> seen = new HashSet<>();
-    StringBuilder r = new StringBuilder();
-    for (MessageInfo m : newMessages) {
-      int a = m.author() != null ? m.author()._accountId() : 0;
-      if (seen.add(a)) {
-        if (r.length() > 0) {
-          r.append(", ");
-        }
-        r.append(Message.authorName(m));
-      }
-    }
-    author.setInnerText(r.toString());
-    updated = newTime;
-  }
-
-  @UiHandler("show")
-  void onShow(@SuppressWarnings("unused") ClickEvent e) {
-    onShow();
-  }
-
-  @UiHandler("ignore")
-  void onIgnore(@SuppressWarnings("unused") ClickEvent e) {
-    onIgnore(updated);
-    removeFromParent();
-  }
-
-  abstract void onShow();
-
-  abstract void onIgnore(Timestamp newTime);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.ui.xml
deleted file mode 100644
index 1d5592b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.ui.xml
+++ /dev/null
@@ -1,65 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:style gss='false'>
-    .popup {
-      position: fixed;
-      bottom: 0;
-      right: 0;
-      z-index: 10;
-      padding: 5px;
-    }
-    .bar {
-      background-color: #fff1a8;
-      border: 1px solid #ccc;
-      padding: 5px 10px;
-      font-size: 80%;
-      color: #222;
-      white-space: nowrap;
-      width: auto;
-      height: auto;
-    }
-    a.action {
-      color: #222;
-      text-decoration: underline;
-      display: inline-block;
-      margin-left: 0.5em;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.popup}'>
-    <div class='{style.bar}'>
-      <ui:msg>Update from <span ui:field='author'/></ui:msg>
-      <g:Anchor ui:field='show'
-          styleName='{style.action}'
-          href='javascript:;'
-          title='Refresh screen and display updates'>
-        <ui:attribute name='title'/>
-        <ui:msg>Show</ui:msg>
-      </g:Anchor>
-      <g:Anchor ui:field='ignore'
-          styleName='{style.action}'
-          href='javascript:;'
-          title='Ignore this update'>
-        <ui:attribute name='title'/>
-        <ui:msg>Ignore</ui:msg>
-      </g:Anchor>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateCheckTimer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateCheckTimer.java
deleted file mode 100644
index 4a5af0551..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateCheckTimer.java
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.ui.UserActivityMonitor;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.user.client.Timer;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-class UpdateCheckTimer extends Timer implements ValueChangeHandler<Boolean> {
-  private static final int MAX_PERIOD = 3 * 60 * 1000;
-  private static final int IDLE_PERIOD = 2 * 3600 * 1000;
-  private static final int POLL_PERIOD = Gerrit.info().change().updateDelay() * 1000;
-
-  private final ChangeScreen screen;
-  private int delay;
-  private boolean running;
-
-  UpdateCheckTimer(ChangeScreen screen) {
-    this.screen = screen;
-    this.delay = POLL_PERIOD;
-  }
-
-  void schedule() {
-    scheduleRepeating(delay);
-  }
-
-  @Override
-  public void run() {
-    if (!screen.isAttached()) {
-      // screen should have cancelled this timer.
-      cancel();
-      return;
-    } else if (running) {
-      return;
-    }
-
-    running = true;
-    screen.loadChangeInfo(
-        false,
-        new AsyncCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo info) {
-            running = false;
-            screen.showUpdates(info);
-
-            int d = UserActivityMonitor.isActive() ? POLL_PERIOD : IDLE_PERIOD;
-            if (d != delay) {
-              delay = d;
-              schedule();
-            }
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            // On failures increase the delay time and try again,
-            // but place an upper bound on the delay.
-            running = false;
-            delay =
-                (int)
-                    Math.max(
-                        delay * (1.5 + Math.random()),
-                        UserActivityMonitor.isActive() ? MAX_PERIOD : IDLE_PERIOD + MAX_PERIOD);
-            schedule();
-          }
-        });
-  }
-
-  @Override
-  public void onValueChange(ValueChangeEvent<Boolean> event) {
-    if (event.getValue()) {
-      delay = POLL_PERIOD;
-      run();
-    } else {
-      delay = IDLE_PERIOD;
-    }
-    schedule();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/VotableInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/VotableInfo.java
deleted file mode 100644
index 33d8d12..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/VotableInfo.java
+++ /dev/null
@@ -1,37 +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.client.change;
-
-import java.util.HashSet;
-import java.util.Set;
-
-class VotableInfo {
-  private Set<String> votable;
-
-  void votable(String label) {
-    if (votable == null) {
-      votable = new HashSet<>();
-    }
-    votable.add(label);
-  }
-
-  Set<String> votableLabels() {
-    Set<String> s = new HashSet<>();
-    if (votable != null) {
-      s.addAll(votable);
-    }
-    return s;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/common.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/common.css
deleted file mode 100644
index bb7cb27..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/common.css
+++ /dev/null
@@ -1,63 +0,0 @@
-/* Copyright (C) 2013 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-@eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
-
-.popup {
-  background-color: trimColor;
-  min-width: 300px;
-  min-height: 90px;
-}
-
-.popupContent {
-  padding: 5px;
-}
-
-.button,
-.popup button,
-.popup input[type='button'] {
-  margin: 0 3px 0 0;
-  border-color: rgba(0, 0, 0, 0.15) !important;
-  text-align: center;
-  font-size: 11px;
-  font-weight: bold;
-  border: 2px solid;
-  cursor: pointer;
-  color: #fff;
-  background-color: #4d90fe;
-  background-image: -webkit-linear-gradient(top, #4d90fe, #4d90fe);
-  -webkit-border-radius: 2px;
-  -webkit-box-sizing: content-box;
-}
-
-.button:disabled,
-.popup button:disabled,
-.popup input[type='button']:disabled {
-  background-color: #999;
-  background-image: -webkit-linear-gradient(top, #999, #999);
-}
-
-.button div, .popup button div {
-  width: 54px;
-  white-space: nowrap;
-  color: #fff;
-  height: 14px;
-  line-height: 14px;
-}
-
-.section {
-  padding: 5px 5px;
-  border-bottom: 1px solid #b8b8b8;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css
deleted file mode 100644
index 6f514df..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css
+++ /dev/null
@@ -1,115 +0,0 @@
-/* Copyright (C) 2013 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-.pointer, .reviewed, .restoreDelete {
-  padding: 0px;
-  vertical-align: top;
-}
-.pointer {
-  width: 12px;
-}
-.reviewed {
-  height: 19px;
-  width: 20px;
-}
-
-.table th {
-  vertical-align: top;
-  text-align: left;
-}
-.table tr {
-  vertical-align: top;
-}
-.table tr:hover {
-  background: rgba(209, 245, 248, 0.32);
-}
-.table tr.nohover:hover {
-  background: transparent;
-}
-
-.status {
-  padding-right: 4px;
-  color: #888;
-}
-
-.pathColumn {
-  white-space: nowrap;
-  min-width: 600px;
-}
-.pathColumn a {
-  color: #000;
-  cursor: pointer;
-}
-.commonPrefix {
-  color: #888;
-}
-.renameCopySource {
-  color: #888;
-  font-size: smaller;
-}
-
-.draftColumn,
-.newColumn,
-.commentColumn {
-  white-space: nowrap;
-}
-.draftColumn {
-  color: #d44;
-  font-weight: bold;
-}
-.newColumn {
-  font-weight: bold;
-}
-
-.deltaColumn1 {
-  white-space: nowrap;
-  text-align: right !important;
-}
-
-.deltaColumn2 {
-  padding-left: 5px;
-  white-space: nowrap;
-  text-align: right;
-}
-
-.inserted {
-  height: 10px;
-  display: inline-block;
-  background-color: #4d4;
-}
-
-.deleted {
-  height: 10px;
-  display: inline-block;
-  background-color: #d44;
-}
-
-.restoreDelete div {
-  white-space: nowrap;
-}
-
-.restoreDelete button {
-  cursor: pointer;
-  padding: 0;
-  margin: 0 0 0 5px;
-  border: 0;
-  background-color: transparent;
-  white-space: nowrap;
-}
-
-.error {
-  color: #D33D3D;
-  font-weight: bold;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/moreLess.png b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/moreLess.png
deleted file mode 100644
index 298514f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/moreLess.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/related_changes.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/related_changes.css
deleted file mode 100644
index 5e0e402..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/related_changes.css
+++ /dev/null
@@ -1,98 +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.
- */
-
-@external .gwt-TabBarItem;
-@external .gwt-TabBarItem-disabled;
-@external .gwt-TabBarItem-selected;
-@external .gwt-TabBarRest;
-@external .gwt-TabPanelBottom;
-@eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-
-.row {
-  white-space: nowrap;
-}
-
-.activeRow {
-  background-color: selectionColor !important;
-}
-
-.activeRow .pointer {
-  visibility: visible;
-}
-
-.pointer {
-  display: inline-block;
-  vertical-align: top;
-  visibility: hidden;
-}
-
-.subject, .strikedSubject {
-  display: inline-block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  width: 355px;
-}
-.strikedSubject {
-  text-decoration: line-through;
-}
-
-.tabPanel .gwt-TabBarItem,
-.tabPanel .gwt-TabBarItem-selected,
-.tabPanel .gwt-TabBarRest {
-  background: none repeat scroll 0 0 #FFF !important;
-}
-
-.tabPanel .gwt-TabPanelBottom {
-  padding: 2px 0 0 0;
-}
-
-.tabPanel .gwt-TabBarItem-selected {
-  border-bottom-color: #000 !important;
-  color: #000 !important;
-}
-
-.tabPanel .gwt-TabBarItem-disabled {
-  display: none;
-}
-
-.current,
-.gitweb,
-.indirect,
-.notCurrent,
-.submittable {
-  display: inline-block;
-  text-align: center;
-  vertical-align: top;
-  width: 12px;
-}
-
-.gitweb {
-  color: #000;
-}
-
-.indirect {
-  color: #090;      /* green */
-  font-weight: bold;
-}
-
-.notCurrent {
-  color: #FFA62F;   /* orange */
-}
-
-.submittable {
-  color: #090;      /* green */
-  font-weight: bold;
-}
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
deleted file mode 100644
index c6e4e2f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
+++ /dev/null
@@ -1,219 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.NotFoundScreen;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.EnumSet;
-import java.util.Set;
-
-public class AccountDashboardScreen extends Screen implements ChangeListScreen {
-  // If changing default options, also update in
-  // ChangeIT#defaultSearchDoesNotTouchDatabase().
-  private static final Set<ListChangesOption> MY_DASHBOARD_OPTIONS;
-
-  static {
-    EnumSet<ListChangesOption> options = EnumSet.copyOf(ChangeTable.OPTIONS);
-    options.add(ListChangesOption.REVIEWED);
-    MY_DASHBOARD_OPTIONS = Collections.unmodifiableSet(options);
-  }
-
-  private final Integer ownerId;
-  private final boolean mine;
-  private ChangeTable table;
-  private ChangeTable.Section workInProgress;
-  private ChangeTable.Section outgoing;
-  private ChangeTable.Section incoming;
-  private ChangeTable.Section closed;
-
-  public AccountDashboardScreen(Integer accountId) {
-    ownerId = accountId;
-    mine = Gerrit.isSignedIn() && ownerId == Gerrit.getUserAccount()._accountId();
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    table =
-        new ChangeTable() {
-          {
-            keysNavigation.add(
-                new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
-                  @Override
-                  public void onKeyPress(KeyPressEvent event) {
-                    Gerrit.display(getToken());
-                  }
-                });
-          }
-        };
-    table.addStyleName(Gerrit.RESOURCES.css().accountDashboard());
-
-    workInProgress = new ChangeTable.Section();
-    outgoing = new ChangeTable.Section();
-    incoming = new ChangeTable.Section();
-    closed = new ChangeTable.Section();
-
-    String who = mine ? "self" : ownerId.toString();
-    workInProgress.setTitleWidget(
-        new InlineHyperlink(
-            Util.C.workInProgress(), PageLinks.toChangeQuery(queryWorkInProgress(who))));
-    outgoing.setTitleWidget(
-        new InlineHyperlink(Util.C.outgoingReviews(), PageLinks.toChangeQuery(queryOutgoing(who))));
-    incoming.setTitleWidget(
-        new InlineHyperlink(Util.C.incomingReviews(), PageLinks.toChangeQuery(queryIncoming(who))));
-    incoming.setHighlightUnreviewed(mine);
-    closed.setTitleWidget(
-        new InlineHyperlink(Util.C.recentlyClosed(), PageLinks.toChangeQuery(queryClosed(who))));
-
-    table.addSection(workInProgress);
-    table.addSection(outgoing);
-    table.addSection(incoming);
-    table.addSection(closed);
-    add(table);
-    table.setSavePointerId("owner:" + ownerId);
-  }
-
-  private static String queryWorkInProgress(String who) {
-    return "is:open is:wip owner:" + who;
-  }
-
-  private static String queryOutgoing(String who) {
-    return "is:open -is:wip owner:" + who;
-  }
-
-  private static String queryIncoming(String who) {
-    return "is:open ((reviewer:"
-        + who
-        + " -owner:"
-        + who
-        + " -is:ignored) OR assignee:"
-        + who
-        + ")";
-  }
-
-  private static String queryClosed(String who) {
-    return "is:closed (owner:" + who + " OR reviewer:" + who + " OR assignee:" + who + ")";
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-
-    String who = mine ? "self" : ownerId.toString();
-    ChangeList.queryMultiple(
-        new ScreenLoadCallback<JsArray<ChangeList>>(this) {
-          @Override
-          protected void preDisplay(JsArray<ChangeList> result) {
-            display(result);
-          }
-        },
-        mine ? MY_DASHBOARD_OPTIONS : DashboardTable.OPTIONS,
-        queryWorkInProgress(who),
-        queryOutgoing(who),
-        queryIncoming(who),
-        queryClosed(who) + " -age:4w limit:10");
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    table.setRegisterKeys(true);
-  }
-
-  private void display(JsArray<ChangeList> result) {
-    if (!mine && !hasChanges(result)) {
-      // When no results are returned and the data is not for the
-      // current user, the target user is presumed to not exist.
-      Gerrit.display(getToken(), new NotFoundScreen());
-      return;
-    }
-
-    ChangeList wip = result.get(0);
-    ChangeList out = result.get(1);
-    ChangeList in = result.get(2);
-    ChangeList done = result.get(3);
-
-    if (mine) {
-      setWindowTitle(Util.C.myDashboardTitle());
-      setPageTitle(Util.C.myDashboardTitle());
-    } else {
-      // The server doesn't tell us who the dashboard is for. Try to guess
-      // by looking at a change started by the owner and extract the name.
-      String name = guessName(out);
-      if (name == null) {
-        name = guessName(done);
-      }
-      if (name != null) {
-        setWindowTitle(name);
-        setPageTitle(Util.M.accountDashboardTitle(name));
-      } else {
-        setWindowTitle(Util.C.unknownDashboardTitle());
-        setWindowTitle(Util.C.unknownDashboardTitle());
-      }
-    }
-
-    Collections.sort(Natives.asList(out), outComparator());
-
-    table.updateColumnsForLabels(wip, out, in, done);
-    workInProgress.display(wip);
-    outgoing.display(out);
-    incoming.display(in);
-    closed.display(done);
-    table.finishDisplay();
-  }
-
-  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();
-      }
-    };
-  }
-
-  private boolean hasChanges(JsArray<ChangeList> result) {
-    for (ChangeList list : Natives.asList(result)) {
-      if (list.length() != 0) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private static String guessName(ChangeList list) {
-    for (ChangeInfo change : Natives.asList(list)) {
-      if (change.owner() != null && change.owner().name() != null) {
-        return change.owner().name();
-      }
-    }
-    return null;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
deleted file mode 100644
index 02be8c7..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ /dev/null
@@ -1,401 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.changes;
-
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.client.info.ChangeInfo.IncludedInInfo;
-import com.google.gerrit.client.rpc.CallbackGroup.Callback;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** A collection of static methods which work on the Gerrit REST API for specific changes. */
-public class ChangeApi {
-  /** Abandon the change, ending its review. */
-  public static void abandon(
-      @Nullable String project, int id, String msg, AsyncCallback<ChangeInfo> cb) {
-    MessageInput input = MessageInput.create();
-    input.message(emptyToNull(msg));
-    call(project, id, "abandon").post(input, cb);
-  }
-
-  /** Create a new work-in-progress change. */
-  public static void createChange(
-      String project,
-      String branch,
-      String topic,
-      String subject,
-      String base,
-      AsyncCallback<ChangeInfo> cb) {
-    CreateChangeInput input = CreateChangeInput.create();
-    input.project(emptyToNull(project));
-    input.branch(emptyToNull(branch));
-    input.topic(emptyToNull(topic));
-    input.subject(emptyToNull(subject));
-    input.baseChange(emptyToNull(base));
-    input.workInProgress(true);
-
-    new RestApi("/changes/").post(input, cb);
-  }
-
-  /** Restore a previously abandoned change to be open again. */
-  public static void restore(
-      @Nullable String project, int id, String msg, AsyncCallback<ChangeInfo> cb) {
-    MessageInput input = MessageInput.create();
-    input.message(emptyToNull(msg));
-    call(project, id, "restore").post(input, cb);
-  }
-
-  /** Create a new change that reverts the delta caused by this change. */
-  public static void revert(
-      @Nullable String project, int id, String msg, AsyncCallback<ChangeInfo> cb) {
-    MessageInput input = MessageInput.create();
-    input.message(emptyToNull(msg));
-    call(project, id, "revert").post(input, cb);
-  }
-
-  /** Update the topic of a change. */
-  public static void topic(
-      @Nullable String project, int id, String topic, AsyncCallback<String> cb) {
-    RestApi call = call(project, id, "topic");
-    topic = emptyToNull(topic);
-    if (topic != null) {
-      TopicInput input = TopicInput.create();
-      input.topic(topic);
-      call.put(input, NativeString.unwrap(cb));
-    } else {
-      call.delete(NativeString.unwrap(cb));
-    }
-  }
-
-  public static void detail(@Nullable String project, int id, AsyncCallback<ChangeInfo> cb) {
-    detail(project, id).get(cb);
-  }
-
-  public static RestApi detail(@Nullable String project, int id) {
-    return call(project, id, "detail");
-  }
-
-  public static RestApi blame(@Nullable String project, PatchSet.Id id, String path, boolean base) {
-    return revision(project, id).view("files").id(path).view("blame").addParameter("base", base);
-  }
-
-  public static RestApi actions(@Nullable String project, int id, String revision) {
-    if (revision == null || revision.equals("")) {
-      revision = "current";
-    }
-    return call(project, id, revision, "actions");
-  }
-
-  public static void deleteAssignee(
-      @Nullable String project, int id, AsyncCallback<AccountInfo> cb) {
-    change(project, id).view("assignee").delete(cb);
-  }
-
-  public static void setAssignee(
-      @Nullable String project, int id, String user, AsyncCallback<AccountInfo> cb) {
-    AssigneeInput input = AssigneeInput.create();
-    input.assignee(user);
-    change(project, id).view("assignee").put(input, cb);
-  }
-
-  public static void markPrivate(
-      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
-    change(project, id).view("private").post(PrivateInput.create(), cb);
-  }
-
-  public static void unmarkPrivate(
-      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
-    change(project, id).view("private.delete").post(PrivateInput.create(), cb);
-  }
-
-  public static RestApi comments(@Nullable String project, int id) {
-    return call(project, id, "comments");
-  }
-
-  public static RestApi drafts(@Nullable String project, int id) {
-    return call(project, id, "drafts");
-  }
-
-  public static void edit(@Nullable String project, int id, AsyncCallback<EditInfo> cb) {
-    edit(project, id).get(cb);
-  }
-
-  public static void editWithFiles(@Nullable String project, int id, AsyncCallback<EditInfo> cb) {
-    edit(project, id).addParameterTrue("list").get(cb);
-  }
-
-  public static RestApi edit(@Nullable String project, int id) {
-    return change(project, id).view("edit");
-  }
-
-  public static RestApi editWithCommands(@Nullable String project, int id) {
-    return edit(project, id).addParameterTrue("download-commands");
-  }
-
-  public static void includedIn(
-      @Nullable String project, int id, AsyncCallback<IncludedInInfo> cb) {
-    call(project, id, "in").get(cb);
-  }
-
-  public static RestApi revision(@Nullable String project, int id, String revision) {
-    return change(project, id).view("revisions").id(revision);
-  }
-
-  public static RestApi revision(@Nullable String project, PatchSet.Id id) {
-    int cn = id.getParentKey().get();
-    String revision = RevisionInfoCache.get(id);
-    if (revision != null) {
-      return revision(project, cn, revision);
-    }
-    return change(project, cn).view("revisions").id(id.get());
-  }
-
-  public static RestApi reviewers(@Nullable String project, int id) {
-    return change(project, id).view("reviewers");
-  }
-
-  public static RestApi suggestReviewers(
-      @Nullable String project, int id, String q, int n, boolean e) {
-    RestApi api =
-        change(project, id).view("suggest_reviewers").addParameter("n", n).addParameter("e", e);
-    if (q != null) {
-      api.addParameter("q", q);
-    }
-    return api;
-  }
-
-  public static RestApi vote(@Nullable String project, int id, int reviewer, String vote) {
-    return reviewer(project, id, reviewer).view("votes").id(vote);
-  }
-
-  public static RestApi reviewer(@Nullable String project, int id, int reviewer) {
-    return change(project, id).view("reviewers").id(reviewer);
-  }
-
-  public static RestApi reviewer(@Nullable String project, int id, String reviewer) {
-    return change(project, id).view("reviewers").id(reviewer);
-  }
-
-  public static RestApi hashtags(@Nullable String project, int changeId) {
-    return change(project, changeId).view("hashtags");
-  }
-
-  public static RestApi hashtag(@Nullable String project, int changeId, String hashtag) {
-    return change(project, changeId).view("hashtags").id(hashtag);
-  }
-
-  /** Submit a specific revision of a change. */
-  public static void cherrypick(
-      String project,
-      int id,
-      String commit,
-      String destination,
-      String message,
-      AsyncCallback<ChangeInfo> cb) {
-    CherryPickInput cherryPickInput = CherryPickInput.create();
-    cherryPickInput.setMessage(message);
-    cherryPickInput.setDestination(destination);
-    call(project, id, commit, "cherrypick").post(cherryPickInput, cb);
-  }
-
-  /** Move change to another branch. */
-  public static void move(
-      String project, int id, String destination, String message, AsyncCallback<ChangeInfo> cb) {
-    MoveInput moveInput = MoveInput.create();
-    moveInput.setMessage(message);
-    moveInput.setDestinationBranch(destination);
-    change(project, id).view("move").post(moveInput, cb);
-  }
-
-  /** Edit commit message for specific revision of a change. */
-  public static void message(
-      @Nullable String project,
-      int id,
-      String commit,
-      String message,
-      AsyncCallback<JavaScriptObject> cb) {
-    CherryPickInput input = CherryPickInput.create();
-    input.setMessage(message);
-    call(project, id, commit, "message").post(input, cb);
-  }
-
-  /** Submit a specific revision of a change. */
-  public static void submit(
-      @Nullable String project, int id, String commit, AsyncCallback<SubmitInfo> cb) {
-    JavaScriptObject in = JavaScriptObject.createObject();
-    call(project, id, commit, "submit").post(in, cb);
-  }
-
-  /** Delete a specific draft change. */
-  public static void deleteChange(
-      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
-    change(project, id).delete(cb);
-  }
-
-  /** Delete change edit. */
-  public static void deleteEdit(
-      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
-    edit(project, id).delete(cb);
-  }
-
-  /** Publish change edit. */
-  public static void publishEdit(
-      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
-    JavaScriptObject in = JavaScriptObject.createObject();
-    change(project, id).view("edit:publish").post(in, cb);
-  }
-
-  /** Rebase change edit on latest patch set. */
-  public static void rebaseEdit(
-      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
-    JavaScriptObject in = JavaScriptObject.createObject();
-    change(project, id).view("edit:rebase").post(in, cb);
-  }
-
-  /** Rebase a revision onto the branch tip or another change. */
-  public static void rebase(
-      @Nullable String project, int id, String commit, String base, AsyncCallback<ChangeInfo> cb) {
-    RebaseInput rebaseInput = RebaseInput.create();
-    rebaseInput.setBase(base);
-    call(project, id, commit, "rebase").post(rebaseInput, cb);
-  }
-
-  private static class MessageInput extends JavaScriptObject {
-    final native void message(String m) /*-{ if(m)this.message=m; }-*/;
-
-    static MessageInput create() {
-      return (MessageInput) createObject();
-    }
-
-    protected MessageInput() {}
-  }
-
-  private static class AssigneeInput extends JavaScriptObject {
-    final native void assignee(String a) /*-{ if(a)this.assignee=a; }-*/;
-
-    static AssigneeInput create() {
-      return (AssigneeInput) createObject();
-    }
-
-    protected AssigneeInput() {}
-  }
-
-  private static class TopicInput extends JavaScriptObject {
-    final native void topic(String t) /*-{ if(t)this.topic=t; }-*/;
-
-    static TopicInput create() {
-      return (TopicInput) createObject();
-    }
-
-    protected TopicInput() {}
-  }
-
-  private static class CreateChangeInput extends JavaScriptObject {
-    static CreateChangeInput create() {
-      return (CreateChangeInput) createObject();
-    }
-
-    public final native void branch(String b) /*-{ if(b)this.branch=b; }-*/;
-
-    public final native void topic(String t) /*-{ if(t)this.topic=t; }-*/;
-
-    public final native void project(String p) /*-{ if(p)this.project=p; }-*/;
-
-    public final native void subject(String s) /*-{ if(s)this.subject=s; }-*/;
-
-    public final native void status(String s) /*-{ if(s)this.status=s; }-*/;
-
-    public final native void baseChange(String b) /*-{ if(b)this.base_change=b; }-*/;
-
-    public final native void workInProgress(Boolean b) /*-{ if(b)this.work_in_progress=b; }-*/;
-
-    protected CreateChangeInput() {}
-  }
-
-  private static class CherryPickInput extends JavaScriptObject {
-    static CherryPickInput create() {
-      return (CherryPickInput) createObject();
-    }
-
-    final native void setDestination(String d) /*-{ this.destination = d; }-*/;
-
-    final native void setMessage(String m) /*-{ this.message = m; }-*/;
-
-    protected CherryPickInput() {}
-  }
-
-  private static class MoveInput extends JavaScriptObject {
-    static MoveInput create() {
-      return (MoveInput) createObject();
-    }
-
-    final native void setDestinationBranch(String d) /*-{ this.destination_branch = d; }-*/;
-
-    final native void setMessage(String m) /*-{ this.message = m; }-*/;
-
-    protected MoveInput() {}
-  }
-
-  private static class PrivateInput extends JavaScriptObject {
-    static PrivateInput create() {
-      return (PrivateInput) createObject();
-    }
-
-    final native void setMessage(String m) /*-{ this.message = m; }-*/;
-
-    protected PrivateInput() {}
-  }
-
-  private static class RebaseInput extends JavaScriptObject {
-    final native void setBase(String b) /*-{ this.base = b; }-*/;
-
-    static RebaseInput create() {
-      return (RebaseInput) createObject();
-    }
-
-    protected RebaseInput() {}
-  }
-
-  private static RestApi call(@Nullable String project, int id, String action) {
-    return change(project, id).view(action);
-  }
-
-  private static RestApi call(@Nullable String project, int id, String commit, String action) {
-    return change(project, id).view("revisions").id(commit).view(action);
-  }
-
-  public static RestApi change(@Nullable String project, int id) {
-    if (project == null) {
-      return new RestApi("/changes/").id(String.valueOf(id));
-    }
-    return new RestApi("/changes/").id(project, id);
-  }
-
-  public static String emptyToNull(String str) {
-    return str == null || str.isEmpty() ? null : str;
-  }
-
-  public static void commitWithLinks(
-      @Nullable String project, int changeId, String revision, Callback<CommitInfo> callback) {
-    revision(project, changeId, revision).view("commit").addParameterTrue("links").get(callback);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
deleted file mode 100644
index aa6c4ec..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ /dev/null
@@ -1,187 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gwt.i18n.client.Constants;
-
-public interface ChangeConstants extends Constants {
-  String statusLongNew();
-
-  String statusLongMerged();
-
-  String statusLongAbandoned();
-
-  String statusLongDraft();
-
-  String submittable();
-
-  String readyToSubmit();
-
-  String mergeConflict();
-
-  String notCurrent();
-
-  String isPrivate();
-
-  String isWorkInProgress();
-
-  String changeEdit();
-
-  String myDashboardTitle();
-
-  String unknownDashboardTitle();
-
-  String workInProgress();
-
-  String incomingReviews();
-
-  String outgoingReviews();
-
-  String recentlyClosed();
-
-  String changeTableColumnSubject();
-
-  String changeTableColumnSize();
-
-  String changeTableColumnStatus();
-
-  String changeTableColumnOwner();
-
-  String changeTableColumnAssignee();
-
-  String changeTableColumnProject();
-
-  String changeTableColumnBranch();
-
-  String changeTableColumnLastUpdate();
-
-  String changeTableColumnID();
-
-  String changeTableNone();
-
-  String changeTableNotMergeable();
-
-  String changeItemHelp();
-
-  String changeTableStar();
-
-  String changeTablePagePrev();
-
-  String changeTablePageNext();
-
-  String upToChangeList();
-
-  String keyReloadChange();
-
-  String keyNextPatchSet();
-
-  String keyPreviousPatchSet();
-
-  String keyReloadSearch();
-
-  String keyPublishComments();
-
-  String keyEditTopic();
-
-  String keyAddReviewers();
-
-  String keyExpandAllMessages();
-
-  String keyCollapseAllMessages();
-
-  String patchTableColumnName();
-
-  String patchTableColumnComments();
-
-  String patchTableColumnSize();
-
-  String commitMessage();
-
-  String mergeList();
-
-  String patchTablePrev();
-
-  String patchTableNext();
-
-  String patchTableOpenDiff();
-
-  String approvalTableEditAssigneeHint();
-
-  String approvalTableAddReviewerHint();
-
-  String approvalTableAddManyReviewersConfirmationDialogTitle();
-
-  String changeInfoBlockUploaded();
-
-  String changeInfoBlockUpdated();
-
-  String messageNoAuthor();
-
-  String sideBySide();
-
-  String unifiedDiff();
-
-  String buttonRevertChangeSend();
-
-  String headingRevertMessage();
-
-  String revertChangeTitle();
-
-  String buttonCherryPickChangeSend();
-
-  String headingCherryPickBranch();
-
-  String cherryPickCommitMessage();
-
-  String cherryPickTitle();
-
-  String moveChangeSend();
-
-  String headingMoveBranch();
-
-  String moveChangeMessage();
-
-  String moveTitle();
-
-  String buttonRebaseChangeSend();
-
-  String rebaseConfirmMessage();
-
-  String rebaseNotPossibleMessage();
-
-  String rebasePlaceholderMessage();
-
-  String rebaseTitle();
-
-  String baseDiffItem();
-
-  String autoMerge();
-
-  String pagedChangeListPrev();
-
-  String pagedChangeListNext();
-
-  String submitFailed();
-
-  String votable();
-
-  String pushCertMissing();
-
-  String pushCertBad();
-
-  String pushCertOk();
-
-  String pushCertTrusted();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
deleted file mode 100644
index 2d5a9f9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ /dev/null
@@ -1,103 +0,0 @@
-statusLongNew = Review in Progress
-statusLongMerged = Merged
-statusLongAbandoned = Abandoned
-statusLongDraft = Draft
-submittable = Submittable
-readyToSubmit = Ready to Submit
-mergeConflict = Merge Conflict
-notCurrent = Not Current
-changeEdit = Change Edit
-isPrivate = (Private)
-isWorkInProgress = (Work in Progress)
-
-myDashboardTitle = My Reviews
-unknownDashboardTitle = Code Review Dashboard
-workInProgress Work in progress
-incomingReviews = Incoming reviews
-outgoingReviews = Outgoing reviews
-recentlyClosed = Recently closed
-
-changeTableColumnSubject = Subject
-changeTableColumnSize = Size
-changeTableColumnStatus = Status
-changeTableColumnOwner = Owner
-changeTableColumnAssignee = Assignee
-changeTableColumnProject = Project
-changeTableColumnBranch = Branch
-changeTableColumnLastUpdate = Updated
-changeTableColumnID = ID
-changeTableNone = (None)
-changeTableNotMergeable = Merge Conflict
-
-changeItemHelp = change
-changeTableStar = Star (or unstar) change
-changeTablePagePrev = Previous page of changes
-changeTablePageNext = Next page of changes
-upToChangeList = Up to change list
-keyReloadChange = Reload change
-keyNextPatchSet = Next patch set
-keyPreviousPatchSet = Previous patch set
-keyReloadSearch = Reload change list
-keyPublishComments = Review and publish comments
-keyEditTopic = Edit change topic
-keyAddReviewers = Add reviewers
-keyExpandAllMessages = Expand all messages
-keyCollapseAllMessages = Collapse all messages
-
-patchTableColumnName = File Path
-patchTableColumnComments = Comments
-patchTableColumnSize = Size
-commitMessage = Commit Message
-mergeList = Merge List
-
-patchTablePrev = Previous file
-patchTableNext = Next file
-patchTableOpenDiff = Open diff
-
-approvalTableEditAssigneeHint = Name or Email
-
-approvalTableAddReviewerHint = Name or Email or Group
-approvalTableAddManyReviewersConfirmationDialogTitle = Adding Group Members as Reviewers
-
-changeInfoBlockUploaded = Uploaded
-changeInfoBlockUpdated = Updated
-
-messageNoAuthor = Gerrit Code Review
-
-sideBySide = Side by Side
-unifiedDiff = Unified Diff
-
-baseDiffItem = Base
-autoMerge = Auto Merge
-
-buttonRevertChangeSend = Revert Change
-headingRevertMessage = Revert Commit Message:
-revertChangeTitle = Code Review - Revert Merged Change
-
-buttonCherryPickChangeSend = Cherry Pick Change
-headingCherryPickBranch = Cherry Pick to Branch:
-cherryPickCommitMessage = Cherry Pick Commit Message:
-cherryPickTitle = Code Review - Cherry Pick Change to Another Branch
-
-headingMoveBranch = Move Change to Branch:
-moveChangeSend = Move Change
-moveChangeMessage = Move Change Message:
-moveTitle = Code Review - Move Change to Another Branch
-
-buttonRebaseChangeSend = Rebase
-rebaseConfirmMessage = Change parent revision
-rebaseNotPossibleMessage = Change is already up to date
-rebasePlaceholderMessage = (subject, change number, or leave empty)
-rebaseTitle = Code Review - Rebase Change
-
-pagedChangeListPrev = &#x21e6;Prev
-pagedChangeListNext = Next&#x21e8;
-
-submitFailed = Submit Failed
-
-votable = Votable:
-
-pushCertMissing = This patch set was created without a push certificate
-pushCertBad = Push certificate is invalid
-pushCertOk = Push certificate is valid, but key is not trusted
-pushCertTrusted = Push certificate is valid and key is trusted
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java
deleted file mode 100644
index 71b54f7d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java
+++ /dev/null
@@ -1,142 +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.client.changes;
-
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.editor.EditFileInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.HttpCallback;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** REST API helpers to remotely edit a change. */
-public class ChangeEditApi {
-  /** Get file (or commit message) contents. */
-  public static void get(
-      @Nullable Project.NameKey project,
-      PatchSet.Id id,
-      String path,
-      boolean base,
-      HttpCallback<NativeString> cb) {
-    RestApi api;
-    if (id.get() != 0) {
-      // Read from a published revision, when change edit doesn't
-      // exist for the caller, or is not currently active.
-      api =
-          ChangeApi.revision(Project.NameKey.asStringOrNull(project), id)
-              .view("files")
-              .id(path)
-              .view("content");
-    } else if (Patch.COMMIT_MSG.equals(path)) {
-      api =
-          editMessage(Project.NameKey.asStringOrNull(project), id.getParentKey().get())
-              .addParameter("base", base);
-    } else {
-      api =
-          editFile(Project.NameKey.asStringOrNull(project), id.getParentKey().get(), path)
-              .addParameter("base", base);
-    }
-    api.get(cb);
-  }
-
-  /** Get file (or commit message) contents of the edit. */
-  public static void get(
-      @Nullable Project.NameKey project,
-      PatchSet.Id id,
-      String path,
-      HttpCallback<NativeString> cb) {
-    get(project, id, path, false, cb);
-  }
-
-  /** Get meta info for change edit. */
-  public static void getMeta(
-      @Nullable String project, PatchSet.Id id, String path, AsyncCallback<EditFileInfo> cb) {
-    if (id.get() != 0) {
-      throw new IllegalStateException("only supported for edits");
-    }
-    editFile(project, id.getParentKey().get(), path).view("meta").get(cb);
-  }
-
-  /** Put message into a change edit. */
-  public static void putMessage(
-      @Nullable String project, int id, String m, GerritCallback<VoidResult> cb) {
-    editMessage(project, id).put(m, cb);
-  }
-
-  /** Put contents into a file or commit message in a change edit. */
-  public static void put(
-      @Nullable String project,
-      int id,
-      String path,
-      String content,
-      GerritCallback<VoidResult> cb) {
-    if (Patch.COMMIT_MSG.equals(path)) {
-      putMessage(project, id, content, cb);
-    } else {
-      editFile(project, id, path).put(content, cb);
-    }
-  }
-
-  /** Delete a file in the pending edit. */
-  public static void delete(
-      @Nullable String project, int id, String path, AsyncCallback<VoidResult> cb) {
-    editFile(project, id, path).delete(cb);
-  }
-
-  /** Rename a file in the pending edit. */
-  public static void rename(
-      @Nullable String project, int id, String path, String newPath, AsyncCallback<VoidResult> cb) {
-    Input in = Input.create();
-    in.oldPath(path);
-    in.newPath(newPath);
-    ChangeApi.edit(project, id).post(in, cb);
-  }
-
-  /** Restore (undo delete/modify) a file in the pending edit. */
-  public static void restore(
-      @Nullable String project, int id, String path, AsyncCallback<VoidResult> cb) {
-    Input in = Input.create();
-    in.restorePath(path);
-    ChangeApi.edit(project, id).post(in, cb);
-  }
-
-  private static RestApi editMessage(@Nullable String project, int id) {
-    return ChangeApi.change(project, id).view("edit:message");
-  }
-
-  private static RestApi editFile(@Nullable String project, int id, String path) {
-    return ChangeApi.edit(project, id).id(path);
-  }
-
-  private static class Input extends JavaScriptObject {
-    static Input create() {
-      return createObject().cast();
-    }
-
-    final native void restorePath(String p) /*-{ this.restore_path=p }-*/;
-
-    final native void oldPath(String p) /*-{ this.old_path=p }-*/;
-
-    final native void newPath(String p) /*-{ this.new_path=p }-*/;
-
-    protected Input() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java
deleted file mode 100644
index 5d525b6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.changes;
-
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwtorm.client.KeyUtil;
-import java.util.Set;
-
-/** List of changes available from {@code /changes/}. */
-public class ChangeList extends JsArray<ChangeInfo> {
-  private static final String URI = "/changes/";
-
-  /** Run multiple queries in a single remote invocation. */
-  public static void queryMultiple(
-      final AsyncCallback<JsArray<ChangeList>> callback,
-      Set<ListChangesOption> options,
-      String... queries) {
-    if (queries.length == 0) {
-      return;
-    }
-    RestApi call = new RestApi(URI);
-    for (String q : queries) {
-      call.addParameterRaw("q", KeyUtil.encode(q));
-    }
-    addOptions(call, options);
-    if (queries.length == 1) {
-      // Server unwraps a single query, so wrap it back in an array for the
-      // callback.
-      call.get(
-          new AsyncCallback<ChangeList>() {
-            @Override
-            public void onSuccess(ChangeList result) {
-              JsArray<ChangeList> wrapped = JsArray.createArray(1).cast();
-              wrapped.set(0, result);
-              callback.onSuccess(wrapped);
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              callback.onFailure(caught);
-            }
-          });
-    } else {
-      call.get(callback);
-    }
-  }
-
-  public static void query(
-      String query, Set<ListChangesOption> options, AsyncCallback<ChangeList> callback) {
-    query(query, options, callback, 0, 0);
-  }
-
-  public static void query(
-      String query,
-      Set<ListChangesOption> options,
-      AsyncCallback<ChangeList> callback,
-      int start,
-      int limit) {
-    RestApi call = newQuery(query);
-    if (limit > 0) {
-      call.addParameter("n", limit);
-    }
-    addOptions(call, options);
-    if (start != 0) {
-      call.addParameter("S", start);
-    }
-    call.get(callback);
-  }
-
-  public static void addOptions(RestApi call, Set<ListChangesOption> s) {
-    call.addParameterRaw("O", Integer.toHexString(ListChangesOption.toBits(s)));
-  }
-
-  private static RestApi newQuery(String query) {
-    RestApi call = new RestApi(URI);
-    // The server default is ?q=status:open so don't repeat it.
-    if (!"status:open".equals(query) && !"is:open".equals(query)) {
-      call.addParameterRaw("q", KeyUtil.encode(query));
-    }
-    return call;
-  }
-
-  protected ChangeList() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeListScreen.java
deleted file mode 100644
index ed5a6f2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeListScreen.java
+++ /dev/null
@@ -1,17 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-public interface ChangeListScreen {}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
deleted file mode 100644
index c64fe91..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gwt.i18n.client.Messages;
-
-public interface ChangeMessages extends Messages {
-  String accountDashboardTitle(String fullName);
-
-  String revertChangeDefaultMessage(String commitMsg, String commitId);
-
-  String cherryPickedChangeDefaultMessage(String commitMsg, String commitId);
-
-  String changeScreenTitleId(String changeId);
-
-  String loadingPatchSet(int id);
-
-  String patchTableSize_Modify(int insertions, int deletions);
-
-  String patchTableSize_ModifyBinaryFiles(String bytesInserted, String bytesDeleted);
-
-  String patchTableSize_ModifyBinaryFilesWithPercentages(
-      String bytesInserted,
-      String percentageInserted,
-      String bytesDeleted,
-      String percentageDeleted);
-
-  String patchTableSize_LongModify(int insertions, int deletions);
-
-  String removeReviewer(String fullName);
-
-  String removeVote(String label);
-
-  String blockedOn(String labelName);
-
-  String needs(String labelName);
-
-  String changeQueryWindowTitle(String query);
-
-  String changeQueryPageTitle(String query);
-
-  String insertionsAndDeletions(int insertions, int deletions);
-
-  String diffBaseParent(int parentNum);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
deleted file mode 100644
index 2b68492..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
+++ /dev/null
@@ -1,27 +0,0 @@
-# Changes to this file should also be made in
-# gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
-accountDashboardTitle = Code Review Dashboard for {0}
-
-revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
-cherryPickedChangeDefaultMessage = {0}\n(cherry picked from commit {1})
-
-changeScreenTitleId = Change {0}
-loadingPatchSet = Loading Patch Set {0} ...
-
-patchTableSize_Modify = +{0}, -{1}
-patchTableSize_ModifyBinaryFiles = +{0}, -{1}
-patchTableSize_ModifyBinaryFilesWithPercentages = +{0} (+{1}), -{2} (-{3})
-patchTableSize_LongModify = {0} inserted, {1} deleted
-
-removeReviewer = Remove reviewer {0}
-removeVote = Remove vote {0}
-
-blockedOn = Blocked on {0} Label
-needs = Needs {0} Label
-
-changeQueryWindowTitle = {0}
-changeQueryPageTitle = Search for {0}
-
-insertionsAndDeletions = +{0}, -{1}
-
-diffBaseParent = Parent {0}
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
deleted file mode 100644
index caea87e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
+++ /dev/null
@@ -1,559 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import static com.google.gerrit.client.FormatUtil.relativeFormat;
-import static com.google.gerrit.client.FormatUtil.shortFormat;
-
-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.ui.AccountLinkPanel;
-import com.google.gerrit.client.ui.BranchLink;
-import com.google.gerrit.client.ui.ChangeLink;
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gerrit.client.ui.NeedsSignInKeyCommand;
-import com.google.gerrit.client.ui.ProjectLink;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLTable.Cell;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.SimplePanel;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-
-public class ChangeTable extends NavigationTable<ChangeInfo> {
-  // If changing default options, also update in
-  // ChangeIT#defaultSearchDoesNotTouchDatabase().
-  static final Set<ListChangesOption> OPTIONS =
-      Collections.unmodifiableSet(
-          EnumSet.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS));
-
-  private static final int C_STAR = 1;
-  private static final int C_ID = 2;
-  private static final int C_SUBJECT = 3;
-  private static final int C_STATUS = 4;
-  private static final int C_OWNER = 5;
-  private static final int C_ASSIGNEE = 6;
-  private static final int C_PROJECT = 7;
-  private static final int C_BRANCH = 8;
-  private static final int C_LAST_UPDATE = 9;
-  private static final int C_SIZE = 10;
-  private static final int BASE_COLUMNS = 11;
-
-  private final List<Section> sections;
-  private int columns;
-  private final boolean showAssignee;
-  private final boolean showLegacyId;
-  private List<String> labelNames;
-
-  public ChangeTable() {
-    super(Util.C.changeItemHelp());
-    columns = BASE_COLUMNS;
-    labelNames = Collections.emptyList();
-    showAssignee = Gerrit.info().change().showAssigneeInChangesTable();
-    showLegacyId = Gerrit.getUserPreferences().legacycidInChangeTable();
-
-    if (Gerrit.isSignedIn()) {
-      keysAction.add(new StarKeyCommand(0, 's', Util.C.changeTableStar()));
-    }
-
-    sections = new ArrayList<>();
-    table.setText(0, C_STAR, "");
-    table.setText(0, C_ID, Util.C.changeTableColumnID());
-    table.setText(0, C_SUBJECT, Util.C.changeTableColumnSubject());
-    table.setText(0, C_STATUS, Util.C.changeTableColumnStatus());
-    table.setText(0, C_OWNER, Util.C.changeTableColumnOwner());
-    table.setText(0, C_ASSIGNEE, Util.C.changeTableColumnAssignee());
-    table.setText(0, C_PROJECT, Util.C.changeTableColumnProject());
-    table.setText(0, C_BRANCH, Util.C.changeTableColumnBranch());
-    table.setText(0, C_LAST_UPDATE, Util.C.changeTableColumnLastUpdate());
-    table.setText(0, C_SIZE, Util.C.changeTableColumnSize());
-
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.addStyleName(0, C_STAR, Gerrit.RESOURCES.css().iconHeader());
-    for (int i = C_ID; i < columns; i++) {
-      fmt.addStyleName(0, i, Gerrit.RESOURCES.css().dataHeader());
-    }
-    if (!showLegacyId) {
-      fmt.addStyleName(0, C_ID, Gerrit.RESOURCES.css().dataHeaderHidden());
-    }
-    if (!showAssignee) {
-      fmt.addStyleName(0, C_ASSIGNEE, Gerrit.RESOURCES.css().dataHeaderHidden());
-    }
-
-    table.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            final Cell cell = table.getCellForEvent(event);
-            if (cell == null) {
-              return;
-            }
-            if (cell.getCellIndex() == C_STAR) {
-              // Don't do anything (handled by star itself).
-            } else if (cell.getCellIndex() == C_STATUS) {
-              // Don't do anything.
-            } else if (cell.getCellIndex() == C_OWNER) {
-              // Don't do anything.
-            } else if (getRowItem(cell.getRowIndex()) != null) {
-              movePointerTo(cell.getRowIndex());
-            }
-          }
-        });
-  }
-
-  @Override
-  protected Object getRowItemKey(ChangeInfo item) {
-    return item.legacyId();
-  }
-
-  @Override
-  protected void onOpenRow(int row) {
-    final ChangeInfo c = getRowItem(row);
-    Gerrit.display(PageLinks.toChange(c.projectNameKey(), c.legacyId()));
-  }
-
-  private void insertNoneRow(int row) {
-    insertRow(row);
-    table.setText(row, 0, Util.C.changeTableNone());
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.setColSpan(row, 0, columns);
-    fmt.setStyleName(row, 0, Gerrit.RESOURCES.css().emptySection());
-  }
-
-  private void insertChangeRow(int row) {
-    insertRow(row);
-    applyDataRowStyle(row);
-  }
-
-  @Override
-  protected void applyDataRowStyle(int row) {
-    super.applyDataRowStyle(row);
-    final CellFormatter fmt = table.getCellFormatter();
-    fmt.addStyleName(row, C_STAR, Gerrit.RESOURCES.css().iconCell());
-    for (int i = C_ID; i < columns; i++) {
-      fmt.addStyleName(row, i, Gerrit.RESOURCES.css().dataCell());
-    }
-    if (!showLegacyId) {
-      fmt.addStyleName(row, C_ID, Gerrit.RESOURCES.css().dataCellHidden());
-    }
-    fmt.addStyleName(row, C_SUBJECT, Gerrit.RESOURCES.css().cSUBJECT());
-    fmt.addStyleName(row, C_STATUS, Gerrit.RESOURCES.css().cSTATUS());
-    fmt.addStyleName(row, C_OWNER, Gerrit.RESOURCES.css().cOWNER());
-    fmt.addStyleName(
-        row,
-        C_ASSIGNEE,
-        showAssignee
-            ? Gerrit.RESOURCES.css().cASSIGNEE()
-            : Gerrit.RESOURCES.css().dataCellHidden());
-    fmt.addStyleName(row, C_LAST_UPDATE, Gerrit.RESOURCES.css().cLastUpdate());
-    fmt.addStyleName(row, C_SIZE, Gerrit.RESOURCES.css().cSIZE());
-
-    for (int i = C_SIZE + 1; i < columns; i++) {
-      fmt.addStyleName(row, i, Gerrit.RESOURCES.css().cAPPROVAL());
-    }
-  }
-
-  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);
-
-    int baseColumns = BASE_COLUMNS;
-    if (baseColumns + labelNames.size() < columns) {
-      int n = columns - (baseColumns + labelNames.size());
-      for (int row = 0; row < table.getRowCount(); row++) {
-        table.removeCells(row, columns, n);
-      }
-    }
-    columns = baseColumns + labelNames.size();
-
-    FlexCellFormatter fmt = table.getFlexCellFormatter();
-    for (int i = 0; i < labelNames.size(); i++) {
-      String name = labelNames.get(i);
-      int col = baseColumns + i;
-
-      String abbrev = getAbbreviation(name, "-");
-      table.setText(0, col, abbrev);
-      table.getCellFormatter().getElement(0, col).setTitle(name);
-      fmt.addStyleName(0, col, Gerrit.RESOURCES.css().dataHeader());
-    }
-
-    for (Section s : sections) {
-      if (s.titleRow >= 0) {
-        fmt.setColSpan(s.titleRow, 0, columns);
-      }
-    }
-  }
-
-  private void populateChangeRow(int row, ChangeInfo c, boolean highlightUnreviewed) {
-    CellFormatter fmt = table.getCellFormatter();
-    if (Gerrit.isSignedIn()) {
-      table.setWidget(row, C_STAR, StarredChanges.createIcon(c.legacyId(), c.starred()));
-    }
-    table.setWidget(row, C_ID, new TableChangeLink(String.valueOf(c.legacyId()), c));
-
-    String subject = Util.cropSubject(c.subject());
-    table.setWidget(row, C_SUBJECT, new TableChangeLink(subject, c));
-
-    Change.Status status = c.status();
-    if (status != Change.Status.NEW) {
-      table.setText(
-          row,
-          C_STATUS,
-          Util.toLongString(status) + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
-    } else if (c.isWorkInProgress()) {
-      table.setText(
-          row,
-          C_STATUS,
-          Util.C.workInProgress() + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
-    } else if (!c.mergeable()) {
-      table.setText(
-          row,
-          C_STATUS,
-          Util.C.changeTableNotMergeable() + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
-    } else if (c.isPrivate()) {
-      table.setText(row, C_STATUS, Util.C.isPrivate());
-    }
-
-    if (c.owner() != null) {
-      table.setWidget(row, C_OWNER, AccountLinkPanel.withStatus(c.owner(), status));
-    } else {
-      table.setText(row, C_OWNER, "");
-    }
-
-    if (showAssignee) {
-      if (c.assignee() != null) {
-        table.setWidget(row, C_ASSIGNEE, AccountLinkPanel.forAssignee(c.assignee()));
-        if (Gerrit.getUserPreferences().highlightAssigneeInChangeTable()
-            && Objects.equals(c.assignee()._accountId(), Gerrit.getUserAccount()._accountId())) {
-          table.getRowFormatter().addStyleName(row, Gerrit.RESOURCES.css().cASSIGNEDTOME());
-        }
-      } else {
-        table.setText(row, C_ASSIGNEE, "");
-      }
-    }
-
-    table.setWidget(row, C_PROJECT, new ProjectLink(c.projectNameKey()));
-    table.setWidget(
-        row, C_BRANCH, new BranchLink(c.projectNameKey(), c.status(), c.branch(), c.topic()));
-    if (Gerrit.getUserPreferences().relativeDateInChangeTable()) {
-      table.setText(row, C_LAST_UPDATE, relativeFormat(c.updated()));
-    } else {
-      table.setText(row, C_LAST_UPDATE, shortFormat(c.updated()));
-    }
-
-    int col = C_SIZE;
-    if (!Gerrit.getUserPreferences().sizeBarInChangeTable()) {
-      table.setText(row, col, Util.M.insertionsAndDeletions(c.insertions(), c.deletions()));
-    } else {
-      table.setWidget(row, col, getSizeWidget(c));
-      fmt.getElement(row, col)
-          .setTitle(Util.M.insertionsAndDeletions(c.insertions(), c.deletions()));
-    }
-    col++;
-
-    for (int idx = 0; idx < labelNames.size(); idx++, col++) {
-      String name = labelNames.get(idx);
-
-      LabelInfo label = c.label(name);
-      if (label == null) {
-        fmt.getElement(row, col).setTitle(Gerrit.C.labelNotApplicable());
-        fmt.addStyleName(row, col, Gerrit.RESOURCES.css().labelNotApplicable());
-        continue;
-      }
-
-      String user;
-      String info;
-      ReviewCategoryStrategy reviewCategoryStrategy =
-          Gerrit.getUserPreferences().reviewCategoryStrategy();
-      if (label.rejected() != null) {
-        user = label.rejected().name();
-        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy, label.rejected());
-        if (info != null) {
-          FlowPanel panel = new FlowPanel();
-          panel.add(new Image(Gerrit.RESOURCES.redNot()));
-          panel.add(new InlineLabel(info));
-          table.setWidget(row, col, panel);
-        } else {
-          table.setWidget(row, col, new Image(Gerrit.RESOURCES.redNot()));
-        }
-      } else if (label.approved() != null) {
-        user = label.approved().name();
-        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy, label.approved());
-        if (info != null) {
-          FlowPanel panel = new FlowPanel();
-          panel.add(new Image(Gerrit.RESOURCES.greenCheck()));
-          panel.add(new InlineLabel(info));
-          table.setWidget(row, col, panel);
-        } else {
-          table.setWidget(row, col, new Image(Gerrit.RESOURCES.greenCheck()));
-        }
-      } else if (label.disliked() != null) {
-        user = label.disliked().name();
-        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy, label.disliked());
-        String vstr = String.valueOf(label._value());
-        if (info != null) {
-          vstr = vstr + " " + info;
-        }
-        fmt.addStyleName(row, col, Gerrit.RESOURCES.css().negscore());
-        table.setText(row, col, vstr);
-      } else if (label.recommended() != null) {
-        user = label.recommended().name();
-        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy, label.recommended());
-        String vstr = "+" + label._value();
-        if (info != null) {
-          vstr = vstr + " " + info;
-        }
-        fmt.addStyleName(row, col, Gerrit.RESOURCES.css().posscore());
-        table.setText(row, col, vstr);
-      } else {
-        table.clearCell(row, col);
-        continue;
-      }
-      fmt.addStyleName(row, col, Gerrit.RESOURCES.css().singleLine());
-
-      if (user != null) {
-        // Some web browsers ignore the embedded newline; some like it;
-        // so we include a space before the newline to accommodate both.
-        fmt.getElement(row, col).setTitle(name + " \nby " + user);
-      }
-    }
-
-    boolean needHighlight = false;
-    if (highlightUnreviewed && !c.reviewed()) {
-      needHighlight = true;
-    }
-    final Element tr = fmt.getElement(row, 0).getParentElement();
-    UIObject.setStyleName(tr, Gerrit.RESOURCES.css().needsReview(), needHighlight);
-
-    setRowItem(row, c);
-  }
-
-  private static String getReviewCategoryDisplayInfo(
-      ReviewCategoryStrategy reviewCategoryStrategy, AccountInfo accountInfo) {
-    switch (reviewCategoryStrategy) {
-      case NAME:
-        return accountInfo.name();
-      case EMAIL:
-        return accountInfo.email();
-      case USERNAME:
-        return accountInfo.username();
-      case ABBREV:
-        return getAbbreviation(accountInfo.name(), " ");
-      case NONE:
-      default:
-        return null;
-    }
-  }
-
-  private static String getAbbreviation(String name, String token) {
-    StringBuilder abbrev = new StringBuilder();
-    if (name != null) {
-      for (String t : name.split(token)) {
-        abbrev.append(t.substring(0, 1).toUpperCase());
-      }
-    }
-    return abbrev.toString();
-  }
-
-  private static Widget getSizeWidget(ChangeInfo c) {
-    int largeChangeSize = Gerrit.info().change().largeChange();
-    int changedLines = c.insertions() + c.deletions();
-    int p = 100;
-    if (changedLines < largeChangeSize) {
-      p = changedLines * 100 / largeChangeSize;
-    }
-
-    int width = Math.max(2, 70 * p / 100);
-    int red = p >= 50 ? 255 : (int) Math.round((p) * 5.12);
-    int green = p <= 50 ? 255 : (int) Math.round(256 - (p - 50) * 5.12);
-    String bg = "#" + toHex(red) + toHex(green) + "00";
-
-    SimplePanel panel = new SimplePanel();
-    panel.setStyleName(Gerrit.RESOURCES.css().changeSize());
-    panel.setWidth(width + "px");
-    panel.getElement().getStyle().setBackgroundColor(bg);
-    return panel;
-  }
-
-  private static String toHex(int i) {
-    String hex = Integer.toHexString(i);
-    return hex.length() == 1 ? "0" + hex : hex;
-  }
-
-  public void addSection(Section s) {
-    assert s.parent == null;
-
-    s.parent = this;
-    s.titleRow = table.getRowCount();
-    if (s.displayTitle()) {
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.setColSpan(s.titleRow, 0, columns);
-      fmt.addStyleName(s.titleRow, 0, Gerrit.RESOURCES.css().sectionHeader());
-    } else {
-      s.titleRow = -1;
-    }
-
-    s.dataBegin = table.getRowCount();
-    insertNoneRow(s.dataBegin);
-    sections.add(s);
-  }
-
-  private int insertRow(int beforeRow) {
-    for (Section s : sections) {
-      if (beforeRow <= s.titleRow) {
-        s.titleRow++;
-      }
-      if (beforeRow < s.dataBegin) {
-        s.dataBegin++;
-      }
-    }
-    return table.insertRow(beforeRow);
-  }
-
-  private void removeRow(int row) {
-    for (Section s : sections) {
-      if (row < s.titleRow) {
-        s.titleRow--;
-      }
-      if (row < s.dataBegin) {
-        s.dataBegin--;
-      }
-    }
-    table.removeRow(row);
-  }
-
-  public class StarKeyCommand extends NeedsSignInKeyCommand {
-    public StarKeyCommand(int mask, char key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(KeyPressEvent event) {
-      int row = getCurrentRow();
-      ChangeInfo c = getRowItem(row);
-      if (c != null && Gerrit.isSignedIn()) {
-        ((StarredChanges.Icon) table.getWidget(row, C_STAR)).toggleStar();
-      }
-    }
-  }
-
-  private final class TableChangeLink extends ChangeLink {
-    private TableChangeLink(String text, ChangeInfo c) {
-      super(c.projectNameKey(), c.legacyId(), text);
-    }
-
-    @Override
-    public void go() {
-      movePointerTo(cid);
-      super.go();
-    }
-  }
-
-  public static class Section {
-    ChangeTable parent;
-    String titleText;
-    Widget titleWidget;
-    int titleRow = -1;
-    int dataBegin;
-    int rows;
-    private boolean highlightUnreviewed;
-
-    public void setHighlightUnreviewed(boolean value) {
-      this.highlightUnreviewed = value;
-    }
-
-    public void setTitleText(String text) {
-      titleText = text;
-      titleWidget = null;
-      if (titleRow >= 0) {
-        parent.table.setText(titleRow, 0, titleText);
-      }
-    }
-
-    public void setTitleWidget(Widget title) {
-      titleWidget = title;
-      titleText = null;
-      if (titleRow >= 0) {
-        parent.table.setWidget(titleRow, 0, title);
-      }
-    }
-
-    public boolean displayTitle() {
-      if (titleText != null) {
-        setTitleText(titleText);
-        return true;
-      } else if (titleWidget != null) {
-        setTitleWidget(titleWidget);
-        return true;
-      }
-      return false;
-    }
-
-    public void display(ChangeList changeList) {
-      final int sz = changeList != null ? changeList.length() : 0;
-      final boolean hadData = rows > 0;
-
-      if (hadData) {
-        while (sz < rows) {
-          parent.removeRow(dataBegin);
-          rows--;
-        }
-      } else {
-        parent.removeRow(dataBegin);
-      }
-
-      if (sz == 0) {
-        parent.insertNoneRow(dataBegin);
-        return;
-      }
-
-      while (rows < sz) {
-        parent.insertChangeRow(dataBegin + rows);
-        rows++;
-      }
-      for (int i = 0; i < sz; i++) {
-        parent.populateChangeRow(dataBegin + i, changeList.get(i), highlightUnreviewed);
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
deleted file mode 100644
index 987b382..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-public class CommentApi {
-
-  public static void comments(
-      @Nullable String project, PatchSet.Id id, AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
-    revision(project, id, "comments").get(cb);
-  }
-
-  public static void comment(
-      @Nullable String project, PatchSet.Id id, String commentId, AsyncCallback<CommentInfo> cb) {
-    revision(project, id, "comments").id(commentId).get(cb);
-  }
-
-  public static void drafts(
-      @Nullable String project, PatchSet.Id id, AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
-    revision(project, id, "drafts").get(cb);
-  }
-
-  public static void draft(
-      @Nullable String project, PatchSet.Id id, String draftId, AsyncCallback<CommentInfo> cb) {
-    revision(project, id, "drafts").id(draftId).get(cb);
-  }
-
-  public static void createDraft(
-      @Nullable String project,
-      PatchSet.Id id,
-      CommentInfo content,
-      AsyncCallback<CommentInfo> cb) {
-    revision(project, id, "drafts").put(content, cb);
-  }
-
-  public static void updateDraft(
-      @Nullable String project,
-      PatchSet.Id id,
-      String draftId,
-      CommentInfo content,
-      AsyncCallback<CommentInfo> cb) {
-    revision(project, id, "drafts").id(draftId).put(content, cb);
-  }
-
-  public static void deleteDraft(
-      @Nullable String project,
-      PatchSet.Id id,
-      String draftId,
-      AsyncCallback<JavaScriptObject> cb) {
-    revision(project, id, "drafts").id(draftId).delete(cb);
-  }
-
-  private static RestApi revision(@Nullable String project, PatchSet.Id id, String type) {
-    return ChangeApi.revision(project, id).view(type);
-  }
-
-  private CommentApi() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
deleted file mode 100644
index a111860..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gerrit.client.diff.CommentRange;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
-import java.sql.Timestamp;
-
-public class CommentInfo extends JavaScriptObject {
-  public static CommentInfo create(
-      String path, Side side, int line, CommentRange range, Boolean unresolved) {
-    return create(path, side, 0, line, range, unresolved);
-  }
-
-  public static CommentInfo create(
-      String path, Side side, int parent, int line, CommentRange range, boolean unresolved) {
-    CommentInfo n = createObject().cast();
-    n.path(path);
-    n.side(side);
-    n.parent(parent);
-    if (range != null) {
-      n.line(range.endLine());
-      n.range(range);
-    } else if (line > 0) {
-      n.line(line);
-    }
-    n.unresolved(unresolved);
-    return n;
-  }
-
-  public static CommentInfo createReply(CommentInfo r) {
-    CommentInfo n = createObject().cast();
-    n.path(r.path());
-    n.side(r.side());
-    n.parent(r.parent());
-    n.inReplyTo(r.id());
-    if (r.hasRange()) {
-      n.line(r.range().endLine());
-      n.range(r.range());
-    } else if (r.hasLine()) {
-      n.line(r.line());
-    }
-    n.unresolved(r.unresolved());
-    return n;
-  }
-
-  public static CommentInfo copy(CommentInfo s) {
-    CommentInfo n = createObject().cast();
-    n.path(s.path());
-    n.side(s.side());
-    n.parent(s.parent());
-    n.id(s.id());
-    n.inReplyTo(s.inReplyTo());
-    n.message(s.message());
-    if (s.hasRange()) {
-      n.line(s.range().endLine());
-      n.range(s.range());
-    } else if (s.hasLine()) {
-      n.line(s.line());
-    }
-    n.unresolved(s.unresolved());
-    return n;
-  }
-
-  public final native void path(String p) /*-{ this.path = p }-*/;
-
-  public final native void id(String i) /*-{ this.id = i }-*/;
-
-  public final native void line(int n) /*-{ this.line = n }-*/;
-
-  public final native void range(CommentRange r) /*-{ this.range = r }-*/;
-
-  public final native void inReplyTo(String i) /*-{ this.in_reply_to = i }-*/;
-
-  public final native void message(String m) /*-{ this.message = m }-*/;
-
-  public final native void unresolved(boolean b) /*-{ this.unresolved = b }-*/;
-
-  public final void side(Side side) {
-    sideRaw(side.toString());
-  }
-
-  private native void sideRaw(String s) /*-{ this.side = s }-*/;
-
-  public final native void parent(int n) /*-{ this.parent = n }-*/;
-
-  public final native boolean hasParent() /*-{ return this.hasOwnProperty('parent') }-*/;
-
-  public final native String path() /*-{ return this.path }-*/;
-
-  public final native String id() /*-{ return this.id }-*/;
-
-  public final native String inReplyTo() /*-{ return this.in_reply_to }-*/;
-
-  public final native int patchSet() /*-{ return this.patch_set }-*/;
-
-  public final native boolean unresolved() /*-{ return this.unresolved }-*/;
-
-  public final Side side() {
-    String s = sideRaw();
-    return s != null ? Side.valueOf(s) : Side.REVISION;
-  }
-
-  private native String sideRaw() /*-{ return this.side }-*/;
-
-  public final native int parent() /*-{ return this.parent }-*/;
-
-  public final Timestamp updated() {
-    Timestamp r = updatedTimestamp();
-    if (r == null) {
-      String s = updatedRaw();
-      if (s != null) {
-        r = JavaSqlTimestamp_JsonSerializer.parseTimestamp(s);
-        updatedTimestamp(r);
-      }
-    }
-    return r;
-  }
-
-  private native String updatedRaw() /*-{ return this.updated }-*/;
-
-  private native Timestamp updatedTimestamp() /*-{ return this._ts }-*/;
-
-  private native void updatedTimestamp(Timestamp t) /*-{ this._ts = t }-*/;
-
-  public final native AccountInfo author() /*-{ return this.author }-*/;
-
-  public final native int line() /*-{ return this.line || 0 }-*/;
-
-  public final native boolean hasLine() /*-{ return this.hasOwnProperty('line') }-*/;
-
-  public final native boolean hasRange() /*-{ return this.hasOwnProperty('range') }-*/;
-
-  public final native CommentRange range() /*-{ return this.range }-*/;
-
-  public final native String message() /*-{ return this.message }-*/;
-
-  protected CommentInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CustomDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CustomDashboardScreen.java
deleted file mode 100644
index 802e56c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CustomDashboardScreen.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.changes;
-
-import com.google.gerrit.client.ui.Screen;
-
-public class CustomDashboardScreen extends Screen implements ChangeListScreen {
-  private DashboardTable table;
-  private String params;
-
-  public CustomDashboardScreen(String params) {
-    this.params = params;
-  }
-
-  @Override
-  protected void onInitUI() {
-    table =
-        new DashboardTable(this, params) {
-          @Override
-          public void finishDisplay() {
-            super.finishDisplay();
-            display();
-          }
-        };
-
-    super.onInitUI();
-
-    String title = table.getTitle();
-    if (title != null) {
-      setWindowTitle(title);
-      setPageTitle(title);
-    }
-
-    add(table);
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    table.setRegisterKeys(true);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java
deleted file mode 100644
index aba4ee0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java
+++ /dev/null
@@ -1,119 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.changes;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.PageLinks;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.http.client.URL;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.ListIterator;
-
-public class DashboardTable extends ChangeTable {
-  private List<Section> sections;
-  private String title;
-  private List<String> titles;
-  private List<String> queries;
-
-  public DashboardTable(Screen screen, String params) {
-    titles = new ArrayList<>();
-    queries = new ArrayList<>();
-    String foreach = null;
-    for (String kvPair : params.split("[,;&]")) {
-      String[] kv = kvPair.split("=", 2);
-      if (kv.length != 2 || kv[0].isEmpty()) {
-        continue;
-      }
-
-      if ("title".equals(kv[0])) {
-        title = URL.decodeQueryString(kv[1]);
-      } else if ("foreach".equals(kv[0])) {
-        foreach = URL.decodeQueryString(kv[1]);
-      } else {
-        titles.add(URL.decodeQueryString(kv[0]));
-        queries.add(URL.decodeQueryString(kv[1]));
-      }
-    }
-
-    if (foreach != null) {
-      ListIterator<String> it = queries.listIterator();
-      while (it.hasNext()) {
-        it.set(it.next() + " " + foreach);
-      }
-    }
-
-    addStyleName(Gerrit.RESOURCES.css().accountDashboard());
-
-    sections = new ArrayList<>();
-    int i = 0;
-    for (String title : titles) {
-      Section s = new Section();
-      String query = removeLimitAndAge(queries.get(i++));
-      s.setTitleWidget(new InlineHyperlink(title, PageLinks.toChangeQuery(query)));
-      addSection(s);
-      sections.add(s);
-    }
-
-    keysNavigation.add(
-        new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            Gerrit.display(screen.getToken());
-          }
-        });
-  }
-
-  private String removeLimitAndAge(String query) {
-    StringBuilder unlimitedQuery = new StringBuilder();
-    String[] operators = query.split(" ");
-    for (String o : operators) {
-      if (!o.startsWith("limit:") && !o.startsWith("age:") && !o.startsWith("-age:")) {
-        unlimitedQuery.append(o).append(" ");
-      }
-    }
-    return unlimitedQuery.toString().trim();
-  }
-
-  @Override
-  public String getTitle() {
-    return title;
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    ChangeList.queryMultiple(
-        new GerritCallback<JsArray<ChangeList>>() {
-          @Override
-          public void onSuccess(JsArray<ChangeList> result) {
-            List<ChangeList> cls = Natives.asList(result);
-            updateColumnsForLabels(cls.toArray(new ChangeList[cls.size()]));
-            for (int i = 0; i < cls.size(); i++) {
-              sections.get(i).display(cls.get(i));
-            }
-            finishDisplay();
-          }
-        },
-        OPTIONS,
-        queries.toArray(new String[queries.size()]));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java
deleted file mode 100644
index 1695eb9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java
+++ /dev/null
@@ -1,135 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.Hyperlink;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-
-public abstract class PagedSingleListScreen extends Screen {
-  protected final int pageSize;
-  protected final int start;
-  private final String anchorPrefix;
-
-  protected ChangeList changes;
-  private ChangeTable table;
-  private ChangeTable.Section section;
-  private Hyperlink prev;
-  private Hyperlink next;
-
-  protected PagedSingleListScreen(String anchorToken, int start) {
-    anchorPrefix = anchorToken;
-    this.start = start;
-    pageSize = Gerrit.getUserPreferences().changesPerPage();
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    prev = new Hyperlink(Util.C.pagedChangeListPrev(), true, "");
-    prev.setVisible(false);
-
-    next = new Hyperlink(Util.C.pagedChangeListNext(), true, "");
-    next.setVisible(false);
-
-    table =
-        new ChangeTable() {
-          {
-            keysNavigation.add(
-                new DoLinkCommand(0, 'p', Util.C.changeTablePagePrev(), prev),
-                new DoLinkCommand(0, 'n', Util.C.changeTablePageNext(), next));
-
-            keysNavigation.add(
-                new DoLinkCommand(0, '[', Util.C.changeTablePagePrev(), prev),
-                new DoLinkCommand(0, ']', Util.C.changeTablePageNext(), next));
-
-            keysNavigation.add(
-                new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
-                  @Override
-                  public void onKeyPress(KeyPressEvent event) {
-                    Gerrit.display(getToken());
-                  }
-                });
-          }
-        };
-    section = new ChangeTable.Section();
-    table.addSection(section);
-    table.setSavePointerId(anchorPrefix);
-    add(table);
-
-    final HorizontalPanel buttons = new HorizontalPanel();
-    buttons.setStyleName(Gerrit.RESOURCES.css().changeTablePrevNextLinks());
-    buttons.add(prev);
-    buttons.add(next);
-    add(buttons);
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    table.setRegisterKeys(true);
-  }
-
-  protected AsyncCallback<ChangeList> loadCallback() {
-    return new ScreenLoadCallback<ChangeList>(this) {
-      @Override
-      protected void preDisplay(ChangeList result) {
-        display(result);
-      }
-    };
-  }
-
-  protected void display(ChangeList result) {
-    changes = result;
-    if (changes.length() != 0) {
-      if (start > 0) {
-        int p = start - pageSize;
-        prev.setTargetHistoryToken(anchorPrefix + (p > 0 ? "," + p : ""));
-        prev.setVisible(true);
-      } else {
-        prev.setVisible(false);
-      }
-
-      int n = start + changes.length();
-      next.setTargetHistoryToken(anchorPrefix + "," + n);
-      next.setVisible(changes.get(changes.length() - 1)._more_changes());
-    }
-    table.updateColumnsForLabels(result);
-    section.display(result);
-    table.finishDisplay();
-  }
-
-  private static final class DoLinkCommand extends KeyCommand {
-    private final Hyperlink link;
-
-    private DoLinkCommand(int mask, char key, String help, Hyperlink l) {
-      super(mask, key, help);
-      link = l;
-    }
-
-    @Override
-    public void onKeyPress(KeyPressEvent event) {
-      if (link.isVisible()) {
-        History.newItem(link.getTargetHistoryToken());
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java
deleted file mode 100644
index f511308..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.changes;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.admin.ProjectScreen;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.user.client.ui.FlowPanel;
-
-public class ProjectDashboardScreen extends ProjectScreen implements ChangeListScreen {
-  private DashboardTable table;
-  private String params;
-
-  public ProjectDashboardScreen(Project.NameKey toShow, String params) {
-    super(toShow);
-    this.params = params;
-  }
-
-  @Override
-  protected void onInitUI() {
-    table =
-        new DashboardTable(this, params) {
-          @Override
-          public void finishDisplay() {
-            super.finishDisplay();
-            display();
-          }
-        };
-
-    super.onInitUI();
-
-    String title = table.getTitle();
-    if (title != null) {
-      FlowPanel fp = new FlowPanel();
-      fp.setStyleName(Gerrit.RESOURCES.css().screenHeader());
-      fp.add(new InlineHyperlink(title, PageLinks.toCustomDashboard(params)));
-      add(fp);
-    }
-
-    add(table);
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    table.setRegisterKeys(true);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java
deleted file mode 100644
index 8d580a3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gwt.regexp.shared.RegExp;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwtorm.client.KeyUtil;
-
-public class QueryScreen extends PagedSingleListScreen implements ChangeListScreen {
-  // Legacy numeric identifier.
-  private static final RegExp NUMERIC_ID = RegExp.compile("^[1-9][0-9]*$");
-  // Commit SHA1 hash
-  private static final RegExp COMMIT_SHA1 = RegExp.compile("^([0-9a-fA-F]{4," + RevId.LEN + "})$");
-  // Change-Id
-  private static final String ID_PATTERN = "[iI][0-9a-f]{4,}$";
-  private static final RegExp CHANGE_ID = RegExp.compile("^" + ID_PATTERN);
-  private static final RegExp CHANGE_ID_TRIPLET = RegExp.compile("^(.)+~(.)+~" + ID_PATTERN);
-
-  public static QueryScreen forQuery(String query) {
-    return forQuery(query, 0);
-  }
-
-  public static QueryScreen forQuery(String query, int start) {
-    return new QueryScreen(KeyUtil.encode(query), start);
-  }
-
-  private final String query;
-
-  public QueryScreen(String encQuery, int start) {
-    super(PageLinks.QUERY + encQuery, start);
-    query = KeyUtil.decode(encQuery);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setWindowTitle(Util.M.changeQueryWindowTitle(query));
-    setPageTitle(Util.M.changeQueryPageTitle(query));
-  }
-
-  @Override
-  protected AsyncCallback<ChangeList> loadCallback() {
-    return new GerritCallback<ChangeList>() {
-      @Override
-      public void onSuccess(ChangeList result) {
-        if (isAttached()) {
-          if (result.length() == 1 && isSingleQuery(query)) {
-            ChangeInfo c = result.get(0);
-            Change.Id id = c.legacyId();
-            Gerrit.display(PageLinks.toChange(c.projectNameKey(), id));
-          } else {
-            display(result);
-            QueryScreen.this.display();
-          }
-        }
-      }
-    };
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    Gerrit.setQueryString(query);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    ChangeList.query(query, ChangeTable.OPTIONS, loadCallback(), start, pageSize);
-  }
-
-  private static boolean isSingleQuery(String query) {
-    return NUMERIC_ID.test(query)
-        || CHANGE_ID.test(query)
-        || CHANGE_ID_TRIPLET.test(query)
-        || COMMIT_SHA1.test(query);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInfo.java
deleted file mode 100644
index 06d2484..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInfo.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class ReviewInfo extends JavaScriptObject {
-
-  public final native NativeMap<?> labels() /*-{ return this.labels }-*/;
-
-  protected ReviewInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
deleted file mode 100644
index f851d5e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-
-public class ReviewInput extends JavaScriptObject {
-  public enum NotifyHandling {
-    NONE,
-    OWNER,
-    OWNER_REVIEWERS,
-    ALL
-  }
-
-  public enum DraftHandling {
-    DELETE,
-    PUBLISH,
-    KEEP,
-    PUBLISH_ALL_REVISIONS
-  }
-
-  public static ReviewInput create() {
-    ReviewInput r = createObject().cast();
-    r.init();
-    r.drafts(DraftHandling.PUBLISH);
-    return r;
-  }
-
-  public final native void message(String m) /*-{ if(m)this.message=m; }-*/;
-
-  public final native void label(String n, short v) /*-{ this.labels[n]=v; }-*/;
-
-  public final native void comments(NativeMap<JsArray<CommentInfo>> m) /*-{ this.comments=m }-*/;
-
-  public final void notify(NotifyHandling e) {
-    _notify(e.name());
-  }
-
-  private native void _notify(String n) /*-{ this.notify=n; }-*/;
-
-  public final void drafts(DraftHandling e) {
-    _drafts(e.name());
-  }
-
-  private native void _drafts(String n) /*-{ this.drafts=n; }-*/;
-
-  private native void init() /*-{
-    this.labels = {};
-  }-*/;
-
-  public final native void prePost() /*-{
-    var m=this.comments;
-    if (m) {
-      for (var p in m) {
-        var l=m[p];
-        for (var i=0;i<l.length;i++) {
-          var c=l[i];
-          delete c['path'];
-          delete c['updated'];
-        }
-      }
-    }
-  }-*/;
-
-  public final native void mergeLabels(ReviewInput o) /*-{
-    var l=o.labels;
-    if (l) {
-      for (var n in l)
-        this.labels[n]=l[n];
-    }
-  }-*/;
-
-  protected ReviewInput() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java
deleted file mode 100644
index 0b83119..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import java.util.LinkedHashMap;
-import java.util.Map;
-
-/** Cache of PatchSet.Id to revision SHA-1 strings. */
-public class RevisionInfoCache {
-  private static final int LIMIT = 10;
-  private static final RevisionInfoCache IMPL = new RevisionInfoCache();
-
-  public static void add(Change.Id change, RevisionInfo info) {
-    IMPL.psToCommit.put(new PatchSet.Id(change, info._number()), info.name());
-  }
-
-  static String get(PatchSet.Id id) {
-    return IMPL.psToCommit.get(id);
-  }
-
-  private final LinkedHashMap<PatchSet.Id, String> psToCommit;
-
-  @SuppressWarnings("serial")
-  private RevisionInfoCache() {
-    psToCommit =
-        new LinkedHashMap<PatchSet.Id, String>(LIMIT) {
-          @Override
-          protected boolean removeEldestEntry(Map.Entry<PatchSet.Id, String> e) {
-            return size() > LIMIT;
-          }
-        };
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
deleted file mode 100644
index b1028420..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
+++ /dev/null
@@ -1,201 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.changes;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.AccountApi;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.resources.client.ImageResource;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.web.bindery.event.shared.Event;
-import com.google.web.bindery.event.shared.HandlerRegistration;
-import java.util.LinkedHashMap;
-import java.util.Map;
-
-/** Supports the star icon displayed on changes and tracking the status. */
-public class StarredChanges {
-  private static final Event.Type<ChangeStarHandler> TYPE = new Event.Type<>();
-
-  /** Handler that can receive notifications of a change's starred status. */
-  public interface ChangeStarHandler {
-    void onChangeStar(ChangeStarEvent event);
-  }
-
-  /** Event fired when a star changes status. The new status is reported. */
-  public static class ChangeStarEvent extends Event<ChangeStarHandler> {
-    private boolean starred;
-
-    public ChangeStarEvent(Change.Id source, boolean starred) {
-      setSource(source);
-      this.starred = starred;
-    }
-
-    public boolean isStarred() {
-      return starred;
-    }
-
-    @Override
-    public Type<ChangeStarHandler> getAssociatedType() {
-      return TYPE;
-    }
-
-    @Override
-    protected void dispatch(ChangeStarHandler handler) {
-      handler.onChangeStar(this);
-    }
-  }
-
-  /**
-   * Create a star icon for the given change, and current status. Returns null if the user is not
-   * signed in and cannot support starred changes.
-   */
-  public static Icon createIcon(Change.Id source, boolean starred) {
-    return Gerrit.isSignedIn() ? new Icon(source, starred) : null;
-  }
-
-  /** Make a key command that toggles the star for a change. */
-  public static KeyCommand newKeyCommand(Icon icon) {
-    return new KeyCommand(0, 's', Util.C.changeTableStar()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        icon.toggleStar();
-      }
-    };
-  }
-
-  /** Add a handler to listen for starred status to change. */
-  public static HandlerRegistration addHandler(Change.Id source, ChangeStarHandler handler) {
-    return Gerrit.EVENT_BUS.addHandlerToSource(TYPE, source, handler);
-  }
-
-  /**
-   * Broadcast the current starred value of a change to UI widgets. This does not RPC to the server
-   * and does not alter the starred status of a change.
-   */
-  public static void fireChangeStarEvent(Change.Id id, boolean starred) {
-    Gerrit.EVENT_BUS.fireEventFromSource(new ChangeStarEvent(id, starred), id);
-  }
-
-  /**
-   * Set the starred status of a change. This method broadcasts to all interested UI widgets and
-   * sends an RPC to the server to record the updated status.
-   */
-  public static void toggleStar(Change.Id changeId, boolean newValue) {
-    pending.put(changeId, newValue);
-    fireChangeStarEvent(changeId, newValue);
-    if (!busy) {
-      startRequest();
-    }
-  }
-
-  private static boolean busy;
-  private static final Map<Change.Id, Boolean> pending = new LinkedHashMap<>(4);
-
-  private static void startRequest() {
-    busy = true;
-
-    final Change.Id id = pending.keySet().iterator().next();
-    final boolean starred = pending.remove(id);
-    RestApi call = AccountApi.self().view("starred.changes").id(id.get());
-    AsyncCallback<JavaScriptObject> cb =
-        new AsyncCallback<JavaScriptObject>() {
-          @Override
-          public void onSuccess(JavaScriptObject none) {
-            if (pending.isEmpty()) {
-              busy = false;
-            } else {
-              startRequest();
-            }
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            if (!starred && RestApi.isStatus(caught, 404)) {
-              onSuccess(null);
-              return;
-            }
-
-            fireChangeStarEvent(id, !starred);
-            for (Map.Entry<Change.Id, Boolean> e : pending.entrySet()) {
-              fireChangeStarEvent(e.getKey(), !e.getValue());
-            }
-            pending.clear();
-            busy = false;
-          }
-        };
-    if (starred) {
-      call.put(cb);
-    } else {
-      call.delete(cb);
-    }
-  }
-
-  public static class Icon extends Image implements ChangeStarHandler, ClickHandler {
-    private final Change.Id changeId;
-    private boolean starred;
-    private HandlerRegistration handler;
-
-    Icon(Change.Id changeId, boolean starred) {
-      super(resource(starred));
-      this.changeId = changeId;
-      this.starred = starred;
-      addClickHandler(this);
-    }
-
-    /**
-     * Toggles the state of the star, as if the user clicked on the image. This will broadcast the
-     * new star status to all interested UI widgets, and RPC to the server to store the changed
-     * value.
-     */
-    public void toggleStar() {
-      StarredChanges.toggleStar(changeId, !starred);
-    }
-
-    @Override
-    protected void onLoad() {
-      handler = StarredChanges.addHandler(changeId, this);
-    }
-
-    @Override
-    protected void onUnload() {
-      handler.removeHandler();
-      handler = null;
-    }
-
-    @Override
-    public void onChangeStar(ChangeStarEvent event) {
-      setResource(resource(event.isStarred()));
-      starred = event.isStarred();
-    }
-
-    @Override
-    public void onClick(ClickEvent event) {
-      toggleStar();
-    }
-
-    private static ImageResource resource(boolean starred) {
-      return starred ? Gerrit.RESOURCES.starFilled() : Gerrit.RESOURCES.starOpen();
-    }
-  }
-
-  private StarredChanges() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java
deleted file mode 100644
index 9027c5b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.changes;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class SubmitInfo extends JavaScriptObject {
-  final Change.Status status() {
-    return Change.Status.valueOf(statusRaw());
-  }
-
-  private native String statusRaw() /*-{ return this.status; }-*/;
-
-  protected SubmitInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
deleted file mode 100644
index 8d949d1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.changes;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwt.core.client.GWT;
-
-public class Util {
-  public static final ChangeConstants C = GWT.create(ChangeConstants.class);
-  public static final ChangeMessages M = GWT.create(ChangeMessages.class);
-
-  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 String toLongString(Change.Status status) {
-    if (status == null) {
-      return "";
-    }
-    switch (status) {
-      case NEW:
-        return C.statusLongNew();
-      case MERGED:
-        return C.statusLongMerged();
-      case ABANDONED:
-        return C.statusLongAbandoned();
-      default:
-        return status.name();
-    }
-  }
-
-  /**
-   * Crops the given change subject if needed so that it has at most {@link #SUBJECT_MAX_LENGTH}
-   * characters.
-   *
-   * <p>If the given subject is not longer than {@link #SUBJECT_MAX_LENGTH} characters it is
-   * returned unchanged.
-   *
-   * <p>If the length of the given subject exceeds {@link #SUBJECT_MAX_LENGTH} characters it is
-   * cropped. In this case {@link #SUBJECT_CROP_APPENDIX} is appended to the cropped subject, the
-   * cropped subject including the appendix has at most {@link #SUBJECT_MAX_LENGTH} characters.
-   *
-   * <p>If cropping is needed, the subject will be cropped after the last space character that is
-   * found within the last {@link #SUBJECT_CROP_RANGE} characters of the potentially visible
-   * characters. If no such space is found, the subject will be cropped so that the cropped subject
-   * including the appendix has exactly {@link #SUBJECT_MAX_LENGTH} characters.
-   *
-   * @return the subject, cropped if needed
-   */
-  @SuppressWarnings("deprecation")
-  public static String cropSubject(String subject) {
-    if (subject.length() > SUBJECT_MAX_LENGTH) {
-      final int maxLength = SUBJECT_MAX_LENGTH - SUBJECT_CROP_APPENDIX.length();
-      for (int cropPosition = maxLength;
-          cropPosition > maxLength - SUBJECT_CROP_RANGE;
-          cropPosition--) {
-        // Character.isWhitespace(char) can't be used because this method is not supported by GWT,
-        // see https://developers.google.com/web-toolkit/doc/1.6/RefJreEmulation#Package_java_lang
-        if (Character.isSpace(subject.charAt(cropPosition - 1))) {
-          return subject.substring(0, cropPosition) + SUBJECT_CROP_APPENDIX;
-        }
-      }
-      return subject.substring(0, maxLength) + SUBJECT_CROP_APPENDIX;
-    }
-    return subject;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/CapabilityInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/CapabilityInfo.java
deleted file mode 100644
index 1d7f4ab..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/CapabilityInfo.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.config;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class CapabilityInfo extends JavaScriptObject {
-  public final native String id() /*-{ return this.id; }-*/;
-
-  public final native String name() /*-{ return this.name; }-*/;
-
-  protected CapabilityInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java
deleted file mode 100644
index e71929c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.config;
-
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gerrit.client.info.ServerInfo;
-import com.google.gerrit.client.info.TopMenuList;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** A collection of static methods which work on the Gerrit REST API for server configuration. */
-public class ConfigServerApi {
-  /** map of the server wide capabilities (core & plugins). */
-  public static void capabilities(AsyncCallback<NativeMap<CapabilityInfo>> cb) {
-    new RestApi("/config/server/capabilities/").get(cb);
-  }
-
-  public static void topMenus(AsyncCallback<TopMenuList> cb) {
-    new RestApi("/config/server/top-menus").get(cb);
-  }
-
-  public static void defaultPreferences(AsyncCallback<GeneralPreferences> cb) {
-    new RestApi("/config/server/preferences").get(cb);
-  }
-
-  public static void serverInfo(AsyncCallback<ServerInfo> cb) {
-    new RestApi("/config/server/info").get(cb);
-  }
-
-  public static void confirmEmail(String token, AsyncCallback<VoidResult> cb) {
-    EmailConfirmationInput input = EmailConfirmationInput.create();
-    input.setToken(token);
-    new RestApi("/config/server/email.confirm").put(input, cb);
-  }
-
-  private static class EmailConfirmationInput extends JavaScriptObject {
-    final native void setToken(String token) /*-{ this.token = token; }-*/;
-
-    static EmailConfirmationInput create() {
-      return createObject().cast();
-    }
-
-    protected EmailConfirmationInput() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.java
deleted file mode 100644
index ecb2938..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.dashboards;
-
-import com.google.gwt.i18n.client.Constants;
-
-public interface DashboardConstants extends Constants {
-  String dashboardName();
-
-  String dashboardTitle();
-
-  String dashboardDescription();
-
-  String dashboardInherited();
-
-  String dashboardItem();
-
-  String dashboardDefaultToolTip();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.properties
deleted file mode 100644
index ac4de7c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.properties
+++ /dev/null
@@ -1,6 +0,0 @@
-dashboardName = Dashboard Name
-dashboardTitle = Dashboard Title
-dashboardDescription = Dashboard Description
-dashboardInherited = Inherited From
-dashboardItem = dashboard
-dashboardDefaultToolTip = Project Default Dashboard
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java
deleted file mode 100644
index 5c6b51a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.dashboards;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class DashboardInfo extends JavaScriptObject {
-  public final native String id() /*-{ return this.id; }-*/;
-
-  public final native String title() /*-{ return this.title; }-*/;
-
-  public final native String project() /*-{ return this.project; }-*/;
-
-  public final native String definingProject() /*-{ return this.defining_project; }-*/;
-
-  public final native String ref() /*-{ return this.ref; }-*/;
-
-  public final native String path() /*-{ return this.path; }-*/;
-
-  public final native String description() /*-{ return this.description; }-*/;
-
-  public final native String foreach() /*-{ return this.foreach; }-*/;
-
-  public final native String url() /*-{ return this.url; }-*/;
-
-  private final native boolean isDefaultLegacy() /*-{ return this['default'] ? true : false; }-*/;
-
-  private final native boolean isDefaultNew() /*-{ return this.is_default ? true : false; }-*/;
-
-  public final boolean isDefault() {
-    return isDefaultLegacy() || isDefaultNew();
-  }
-
-  protected DashboardInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardList.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardList.java
deleted file mode 100644
index 7ba3580..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardList.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.dashboards;
-
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.http.client.URL;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** Project dashboards from {@code /projects/<name>/dashboards/}. */
-public class DashboardList extends JsArray<DashboardInfo> {
-  public static void all(Project.NameKey project, AsyncCallback<JsArray<DashboardList>> callback) {
-    base(project).addParameterTrue("inherited").get(callback);
-  }
-
-  public static void getDefault(Project.NameKey project, AsyncCallback<DashboardInfo> callback) {
-    base(project).view("default").addParameterTrue("inherited").get(callback);
-  }
-
-  public static void get(
-      Project.NameKey project, String id, AsyncCallback<DashboardInfo> callback) {
-    base(project).idRaw(encodeDashboardId(id)).get(callback);
-  }
-
-  private static RestApi base(Project.NameKey project) {
-    return new RestApi("/projects/").id(project.get()).view("dashboards");
-  }
-
-  private static String encodeDashboardId(String id) {
-    int c = id.indexOf(':');
-    if (0 <= c) {
-      String ref = URL.encodeQueryString(id.substring(0, c));
-      String path = URL.encodeQueryString(id.substring(c + 1));
-      return ref + ':' + path;
-    }
-    return URL.encodeQueryString(id);
-  }
-
-  protected DashboardList() {}
-}
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
deleted file mode 100644
index 0e4ef4e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.dashboards;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.ui.Anchor;
-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;
-
-public class DashboardsTable extends NavigationTable<DashboardInfo> {
-  Project.NameKey project;
-
-  public DashboardsTable(Project.NameKey project) {
-    super(Util.C.dashboardItem());
-    this.project = project;
-    initColumnHeaders();
-  }
-
-  protected void initColumnHeaders() {
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.setColSpan(0, 0, 2);
-    fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().dataHeader());
-    fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
-    fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
-    fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
-
-    table.setText(0, 1, Util.C.dashboardName());
-    table.setText(0, 2, Util.C.dashboardTitle());
-    table.setText(0, 3, Util.C.dashboardDescription());
-    table.setText(0, 4, Util.C.dashboardInherited());
-  }
-
-  public void display(DashboardList dashes) {
-    display(Natives.asList(dashes));
-  }
-
-  public void display(JsArray<DashboardList> in) {
-    Map<String, DashboardInfo> map = new HashMap<>();
-    for (DashboardList list : Natives.asList(in)) {
-      for (DashboardInfo d : Natives.asList(list)) {
-        if (!map.containsKey(d.id())) {
-          map.put(d.id(), d);
-        }
-      }
-    }
-    display(new ArrayList<>(map.values()));
-  }
-
-  public void display(List<DashboardInfo> list) {
-    while (1 < table.getRowCount()) {
-      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());
-          }
-        });
-
-    String ref = null;
-    for (DashboardInfo d : list) {
-      if (!d.ref().equals(ref)) {
-        ref = d.ref();
-        insertTitleRow(table.getRowCount(), ref);
-      }
-      insert(table.getRowCount(), d);
-    }
-
-    finishDisplay();
-  }
-
-  protected void insertTitleRow(int row, String section) {
-    table.insertRow(row);
-
-    table.setText(row, 0, section);
-
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.setColSpan(row, 0, 6);
-    fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().sectionHeader());
-  }
-
-  protected void insert(int row, DashboardInfo k) {
-    table.insertRow(row);
-
-    applyDataRowStyle(row);
-
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, 4, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, 5, Gerrit.RESOURCES.css().dataCell());
-
-    populate(row, k);
-  }
-
-  protected void populate(int row, DashboardInfo k) {
-    if (k.isDefault()) {
-      table.setWidget(row, 1, new Image(Gerrit.RESOURCES.greenCheck()));
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.getElement(row, 1).setTitle(Util.C.dashboardDefaultToolTip());
-    }
-    table.setWidget(
-        row,
-        2,
-        new Anchor(
-            k.path(),
-            "#" + PageLinks.toProjectDashboard(new Project.NameKey(k.project()), k.id())));
-    table.setText(row, 3, k.title() != null ? k.title() : k.path());
-    table.setText(row, 4, k.description());
-    if (k.definingProject() != null && !k.definingProject().equals(k.project())) {
-      table.setWidget(
-          row,
-          5,
-          new Anchor(
-              k.definingProject(),
-              "#" + PageLinks.toProjectDashboards(new Project.NameKey(k.definingProject()))));
-    }
-    setRowItem(row, k);
-  }
-
-  @Override
-  protected Object getRowItemKey(DashboardInfo item) {
-    return item.id();
-  }
-
-  @Override
-  protected void onOpenRow(int row) {
-    if (row > 0) {
-      movePointerTo(row);
-    }
-    History.newItem(getRowItem(row).url());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/Util.java
deleted file mode 100644
index b15bf73..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/Util.java
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.dashboards;
-
-import com.google.gwt.core.client.GWT;
-
-public class Util {
-  public static final DashboardConstants C = GWT.create(DashboardConstants.class);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
deleted file mode 100644
index 0091f53..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
+++ /dev/null
@@ -1,137 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import static com.google.gerrit.client.diff.DisplaySide.A;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.dom.client.Element;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.List;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.LineClassWhere;
-import net.codemirror.lib.Pos;
-import net.codemirror.lib.TextMarker;
-
-/** Colors modified regions for {@link SideBySide} and {@link Unified}. */
-abstract class ChunkManager {
-  static final native void onClick(Element e, JavaScriptObject f) /*-{ e.onclick = f }-*/;
-
-  final Scrollbar scrollbar;
-  final LineMapper lineMapper;
-
-  private List<TextMarker> markers;
-  private List<Runnable> undo;
-
-  ChunkManager(Scrollbar scrollbar) {
-    this.scrollbar = scrollbar;
-    this.lineMapper = new LineMapper();
-  }
-
-  abstract DiffChunkInfo getFirst();
-
-  List<TextMarker> getMarkers() {
-    return markers;
-  }
-
-  void reset() {
-    lineMapper.reset();
-    for (TextMarker m : markers) {
-      m.clear();
-    }
-    for (Runnable r : undo) {
-      r.run();
-    }
-  }
-
-  abstract void render(DiffInfo diff);
-
-  void render() {
-    markers = new ArrayList<>();
-    undo = new ArrayList<>();
-  }
-
-  void colorLines(CodeMirror cm, String color, int line, int cnt) {
-    colorLines(cm, LineClassWhere.WRAP, color, line, line + cnt);
-  }
-
-  void colorLines(CodeMirror cm, LineClassWhere where, String className, int start, int end) {
-    if (start < end) {
-      for (int line = start; line < end; line++) {
-        cm.addLineClass(line, where, className);
-      }
-      undo.add(
-          () -> {
-            for (int line = start; line < end; line++) {
-              cm.removeLineClass(line, where, className);
-            }
-          });
-    }
-  }
-
-  abstract Runnable diffChunkNav(CodeMirror cm, Direction dir);
-
-  void diffChunkNavHelper(
-      List<? extends DiffChunkInfo> chunks, DiffScreen host, int res, Direction dir) {
-    if (res < 0) {
-      res = -res - (dir == Direction.PREV ? 1 : 2);
-    }
-    res = res + (dir == Direction.PREV ? -1 : 1);
-    if (res < 0 || chunks.size() <= res) {
-      return;
-    }
-
-    DiffChunkInfo lookUp = chunks.get(res);
-    // If edit, skip the deletion chunk and set focus on the insertion one.
-    if (lookUp.isEdit() && lookUp.getSide() == A) {
-      res = res + (dir == Direction.PREV ? -1 : 1);
-      if (res < 0 || chunks.size() <= res) {
-        return;
-      }
-    }
-
-    DiffChunkInfo target = chunks.get(res);
-    CodeMirror targetCm = host.getCmFromSide(target.getSide());
-    int cmLine = getCmLine(target.getStart(), target.getSide());
-    targetCm.setCursor(Pos.create(cmLine));
-    targetCm.focus();
-    targetCm.scrollToY(
-        targetCm.heightAtLine(cmLine, "local") - 0.5 * targetCm.scrollbarV().getClientHeight());
-  }
-
-  Comparator<DiffChunkInfo> getDiffChunkComparator() {
-    // Chunks are ordered by their starting line. If it's a deletion,
-    // use its corresponding line on the revision side for comparison.
-    // In the edit case, put the deletion chunk right before the
-    // insertion chunk. This placement guarantees well-ordering.
-    return new Comparator<DiffChunkInfo>() {
-      @Override
-      public int compare(DiffChunkInfo a, DiffChunkInfo b) {
-        if (a.getSide() == b.getSide()) {
-          return a.getStart() - b.getStart();
-        } else if (a.getSide() == A) {
-          int comp = lineMapper.lineOnOther(a.getSide(), a.getStart()).getLine() - b.getStart();
-          return comp == 0 ? -1 : comp;
-        } else {
-          int comp = a.getStart() - lineMapper.lineOnOther(b.getSide(), b.getStart()).getLine();
-          return comp == 0 ? 1 : comp;
-        }
-      }
-    };
-  }
-
-  abstract int getCmLine(int line, DisplaySide side);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css
deleted file mode 100644
index b4216eb..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css
+++ /dev/null
@@ -1,147 +0,0 @@
-/* Copyright (C) 2013 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-@external .cm-s-midnight;
-@external .cm-s-night;
-@external .cm-s-twilight;
-@external .com-google-gerrit-client-diff-PublishedBox_BinderImpl_GenCss_style-name;
-@external .com-google-gerrit-client-diff-CommentBox-Style-message;
-@external .com-google-gerrit-client-diff-CommentBox-Style-date;
-@external .com-google-gerrit-client-diff-DiffTable_BinderImpl_GenCss_style-range;
-@external .com-google-gerrit-client-diff-DiffTable_BinderImpl_GenCss_style-rangeHighlight;
-@external .net-codemirror-lib-CodeMirror-Style-activeLine;
-@external .CodeMirror-linenumber;
-
-.cm-s-midnight .com-google-gerrit-client-diff-PublishedBox_BinderImpl_GenCss_style-name { color: black }
-.cm-s-midnight .com-google-gerrit-client-diff-CommentBox-Style-message { color: black }
-.cm-s-midnight .com-google-gerrit-client-diff-CommentBox-Style-date { color: black }
-.cm-s-midnight .com-google-gerrit-client-diff-DiffTable_BinderImpl_GenCss_style-range { color: #777 }
-.cm-s-midnight .com-google-gerrit-client-diff-DiffTable_BinderImpl_GenCss_style-rangeHighlight { color: #777 }
-.cm-s-midnight .net-codemirror-lib-CodeMirror-Style-activeLine .CodeMirror-linenumber { color: black }
-
-.cm-s-night .com-google-gerrit-client-diff-PublishedBox_BinderImpl_GenCss_style-name { color: black }
-.cm-s-night .com-google-gerrit-client-diff-CommentBox-Style-message { color: black }
-.cm-s-night .com-google-gerrit-client-diff-CommentBox-Style-date { color: black }
-.cm-s-night .com-google-gerrit-client-diff-DiffTable_BinderImpl_GenCss_style-range { color: #777 }
-.cm-s-night .com-google-gerrit-client-diff-DiffTable_BinderImpl_GenCss_style-rangeHighlight { color: #777 }
-.cm-s-night .net-codemirror-lib-CodeMirror-Style-activeLine .CodeMirror-linenumber { color: black }
-
-.cm-s-twilight .com-google-gerrit-client-diff-PublishedBox_BinderImpl_GenCss_style-name { color: black }
-.cm-s-twilight .com-google-gerrit-client-diff-CommentBox-Style-message { color: black }
-.cm-s-twilight .com-google-gerrit-client-diff-CommentBox-Style-date { color: black }
-.cm-s-twilight .com-google-gerrit-client-diff-DiffTable_BinderImpl_GenCss_style-range { color: #777 }
-.cm-s-twilight .com-google-gerrit-client-diff-DiffTable_BinderImpl_GenCss_style-rangeHighlight { color: #777 }
-.cm-s-twilight .net-codemirror-lib-CodeMirror-Style-activeLine .CodeMirror-linenumber { color: black }
-
-.commentWidgets {
-  max-width: 650px;
-
-  font-family: sans-serif;
-  background-color: #fcfa96;
-  border: 1px solid black;
-  -webkit-box-shadow: 3px 3px 3px #888888;
-  -moz-box-shadow: 3px 3px 3px #888888;
-  box-shadow: 3px 3px 3px #888888;
-
-  /* margin-bottom is fixed in CommentGroup.computeHeight() */
-  margin-bottom: 5px;
-  margin-right: 5px;
-
-  -webkit-touch-callout: initial;
-  -webkit-user-select: text;
-  -khtml-user-select: text;
-  -moz-user-select: text;
-  -ms-user-select: text;
-  user-select: text;
-}
-
-.commentBox {
-  position: relative;
-  min-height: 16px;
-}
-
-.header {
-  cursor: pointer;
-}
-
-.summary {
-  color: #777;
-  position: absolute;
-  top: 1px;
-  left: 120px;
-  width: 408px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  padding-bottom: 0.1em;
-}
-
-.date {
-  white-space: nowrap;
-  position: absolute;
-  top: 2px;
-  right: 5px;
-}
-
-.contents {
-  margin-left: 28px;
-  padding-top: 2px;
-  position: relative;
-}
-.message {
-  overflow-x: auto;
-}
-.message p,
-.message ul,
-.message blockquote {
-  -webkit-margin-before: 0.2em;
-  -webkit-margin-after: 0.3em;
-}
-.message {
-  white-space: pre-wrap;
-}
-.commentBox button {
-  margin-right: 3px;
-  margin-bottom: 1px;
-  padding: 1px;
-  text-align: center;
-  font-size: 8px;
-  font-weight: bold;
-  border: 1px solid black;
-  cursor: pointer;
-  color: #fff;
-  background-color: #4d90fe;
-  background-image: -webkit-linear-gradient(top, #4d90fe, #4d90fe);
-  -webkit-border-radius: 2px;
-  -webkit-box-sizing: content-box;
-}
-.commentBox button div {
-  width: 25px;
-  white-space: nowrap;
-  color: #fff;
-}
-
-@sprite .goPrev {
-  gwt-image: "goPrev";
-  display: inline-block;
-}
-@sprite .goNext {
-  gwt-image: "goNext";
-  display: inline-block;
-}
-@sprite .goUp {
-  gwt-image: "goUp";
-  display: inline-block;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
deleted file mode 100644
index 6f9e694..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
+++ /dev/null
@@ -1,158 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gwt.event.dom.client.MouseOutEvent;
-import com.google.gwt.event.dom.client.MouseOutHandler;
-import com.google.gwt.event.dom.client.MouseOverEvent;
-import com.google.gwt.event.dom.client.MouseOverHandler;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.user.client.ui.Composite;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Configuration;
-import net.codemirror.lib.Pos;
-import net.codemirror.lib.TextMarker;
-import net.codemirror.lib.TextMarker.FromTo;
-
-/** An HtmlPanel for displaying a comment */
-abstract class CommentBox extends Composite {
-  static {
-    Resources.I.style().ensureInjected();
-  }
-
-  interface Style extends CssResource {
-    String commentWidgets();
-
-    String commentBox();
-
-    String contents();
-
-    String message();
-
-    String header();
-
-    String summary();
-
-    String date();
-
-    String goPrev();
-
-    String goNext();
-
-    String goUp();
-  }
-
-  private final CommentGroup group;
-  private ScrollbarAnnotation annotation;
-  private FromTo fromTo;
-  private TextMarker rangeMarker;
-  private TextMarker rangeHighlightMarker;
-
-  CommentBox(CommentGroup group, CommentRange range) {
-    this.group = group;
-    if (range != null) {
-      DiffScreen screen = group.getManager().host;
-      int startCmLine = screen.getCmLine(range.startLine() - 1, group.getSide());
-      int endCmLine = screen.getCmLine(range.endLine() - 1, group.getSide());
-      fromTo =
-          FromTo.create(
-              Pos.create(startCmLine, range.startCharacter()),
-              Pos.create(endCmLine, range.endCharacter()));
-      rangeMarker =
-          group
-              .getCm()
-              .markText(
-                  fromTo.from(),
-                  fromTo.to(),
-                  Configuration.create().set("className", Resources.I.diffTableStyle().range()));
-    }
-    addDomHandler(
-        new MouseOverHandler() {
-          @Override
-          public void onMouseOver(MouseOverEvent event) {
-            setRangeHighlight(true);
-          }
-        },
-        MouseOverEvent.getType());
-    addDomHandler(
-        new MouseOutHandler() {
-          @Override
-          public void onMouseOut(MouseOutEvent event) {
-            setRangeHighlight(isOpen());
-          }
-        },
-        MouseOutEvent.getType());
-  }
-
-  abstract CommentInfo getCommentInfo();
-
-  abstract boolean isOpen();
-
-  void setOpen(boolean open) {
-    group.resize();
-    setRangeHighlight(open);
-    getCm().focus();
-  }
-
-  CommentGroup getCommentGroup() {
-    return group;
-  }
-
-  CommentManager getCommentManager() {
-    return group.getCommentManager();
-  }
-
-  ScrollbarAnnotation getAnnotation() {
-    return annotation;
-  }
-
-  void setAnnotation(ScrollbarAnnotation mh) {
-    annotation = mh;
-  }
-
-  void setRangeHighlight(boolean highlight) {
-    if (fromTo != null) {
-      if (highlight && rangeHighlightMarker == null) {
-        rangeHighlightMarker =
-            group
-                .getCm()
-                .markText(
-                    fromTo.from(),
-                    fromTo.to(),
-                    Configuration.create()
-                        .set("className", Resources.I.diffTableStyle().rangeHighlight()));
-      } else if (!highlight && rangeHighlightMarker != null) {
-        rangeHighlightMarker.clear();
-        rangeHighlightMarker = null;
-      }
-    }
-  }
-
-  void clearRange() {
-    if (rangeMarker != null) {
-      rangeMarker.clear();
-      rangeMarker = null;
-    }
-  }
-
-  CodeMirror getCm() {
-    return group.getCm();
-  }
-
-  FromTo getFromTo() {
-    return fromTo;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
deleted file mode 100644
index 414e82e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gwt.user.client.Timer;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.SimplePanel;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Configuration;
-import net.codemirror.lib.LineWidget;
-import net.codemirror.lib.TextMarker.FromTo;
-
-/**
- * LineWidget attached to a CodeMirror container.
- *
- * <p>When a comment is placed on a line a CommentWidget is created.
- */
-abstract class CommentGroup extends Composite {
-
-  final DisplaySide side;
-  final int line;
-
-  private final CommentManager manager;
-  private final CodeMirror cm;
-  private final FlowPanel comments;
-  private LineWidget lineWidget;
-  private Timer resizeTimer;
-
-  CommentGroup(CommentManager manager, CodeMirror cm, DisplaySide side, int line) {
-    this.manager = manager;
-    this.cm = cm;
-    this.side = side;
-    this.line = line;
-
-    comments = new FlowPanel();
-    comments.setStyleName(Resources.I.style().commentWidgets());
-    comments.setVisible(false);
-    initWidget(new SimplePanel(comments));
-  }
-
-  CommentManager getCommentManager() {
-    return manager;
-  }
-
-  CodeMirror getCm() {
-    return cm;
-  }
-
-  int getLine() {
-    return line;
-  }
-
-  DisplaySide getSide() {
-    return side;
-  }
-
-  void add(PublishedBox box) {
-    comments.add(box);
-    comments.setVisible(true);
-  }
-
-  void add(DraftBox box) {
-    PublishedBox p = box.getReplyToBox();
-    if (p != null) {
-      for (int i = 0; i < getBoxCount(); i++) {
-        if (p == getCommentBox(i)) {
-          comments.insert(box, i + 1);
-          comments.setVisible(true);
-          resize();
-          return;
-        }
-      }
-    }
-    comments.add(box);
-    comments.setVisible(true);
-    resize();
-  }
-
-  CommentBox getCommentBox(int i) {
-    return (CommentBox) comments.getWidget(i);
-  }
-
-  int getBoxCount() {
-    return comments.getWidgetCount();
-  }
-
-  void openCloseLast() {
-    if (0 < getBoxCount()) {
-      CommentBox box = getCommentBox(getBoxCount() - 1);
-      box.setOpen(!box.isOpen());
-    }
-  }
-
-  void openCloseAll() {
-    boolean open = false;
-    for (int i = 0; i < getBoxCount(); i++) {
-      if (!getCommentBox(i).isOpen()) {
-        open = true;
-        break;
-      }
-    }
-    setOpenAll(open);
-  }
-
-  void setOpenAll(boolean open) {
-    for (int i = 0; i < getBoxCount(); i++) {
-      getCommentBox(i).setOpen(open);
-    }
-  }
-
-  void remove(DraftBox box) {
-    comments.remove(box);
-    comments.setVisible(0 < getBoxCount());
-  }
-
-  void detach() {
-    if (lineWidget != null) {
-      lineWidget.clear();
-      lineWidget = null;
-      updateSelection();
-    }
-    manager.clearLine(side, line, this);
-    removeFromParent();
-  }
-
-  void attach(DiffTable parent) {
-    parent.add(this);
-    lineWidget =
-        cm.addLineWidget(
-            Math.max(0, line - 1),
-            getElement(),
-            Configuration.create()
-                .set("coverGutter", true)
-                .set("noHScroll", true)
-                .set("above", line <= 0)
-                .set("insertAt", 0));
-  }
-
-  @Override
-  protected void onUnload() {
-    super.onUnload();
-    if (resizeTimer != null) {
-      resizeTimer.cancel();
-    }
-  }
-
-  void updateSelection() {
-    if (cm.somethingSelected()) {
-      FromTo r = cm.getSelectedRange();
-      if (r.to().line() >= line) {
-        cm.setSelection(r.from(), r.to());
-      }
-    }
-  }
-
-  boolean canComputeHeight() {
-    return !comments.isVisible() || comments.getOffsetHeight() > 0;
-  }
-
-  LineWidget getLineWidget() {
-    return lineWidget;
-  }
-
-  void setLineWidget(LineWidget widget) {
-    lineWidget = widget;
-  }
-
-  Timer getResizeTimer() {
-    return resizeTimer;
-  }
-
-  void setResizeTimer(Timer timer) {
-    resizeTimer = timer;
-  }
-
-  FlowPanel getComments() {
-    return comments;
-  }
-
-  CommentManager getManager() {
-    return manager;
-  }
-
-  abstract void init(DiffTable parent);
-
-  abstract void handleRedraw();
-
-  abstract void resize();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
deleted file mode 100644
index ef1ec1e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
+++ /dev/null
@@ -1,451 +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.client.diff;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.patches.SkippedLine;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.TreeMap;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Pos;
-import net.codemirror.lib.TextMarker.FromTo;
-
-/** Tracks comment widgets for {@link DiffScreen}. */
-abstract class CommentManager {
-  @Nullable private final Project.NameKey project;
-  private final DiffObject base;
-  private final PatchSet.Id revision;
-  private final String path;
-  private final CommentLinkProcessor commentLinkProcessor;
-  final SortedMap<Integer, CommentGroup> sideA;
-  final SortedMap<Integer, CommentGroup> sideB;
-  private final Map<String, PublishedBox> published;
-  private final Set<DraftBox> unsavedDrafts;
-  final DiffScreen host;
-  private boolean attached;
-  private boolean expandAll;
-  private boolean open;
-
-  CommentManager(
-      DiffScreen host,
-      @Nullable Project.NameKey project,
-      DiffObject base,
-      PatchSet.Id revision,
-      String path,
-      CommentLinkProcessor clp,
-      boolean open) {
-    this.host = host;
-    this.project = project;
-    this.base = base;
-    this.revision = revision;
-    this.path = path;
-    this.commentLinkProcessor = clp;
-    this.open = open;
-
-    published = new HashMap<>();
-    unsavedDrafts = new HashSet<>();
-    sideA = new TreeMap<>();
-    sideB = new TreeMap<>();
-  }
-
-  void setAttached(boolean attached) {
-    this.attached = attached;
-  }
-
-  boolean isAttached() {
-    return attached;
-  }
-
-  void setExpandAll(boolean expandAll) {
-    this.expandAll = expandAll;
-  }
-
-  boolean isExpandAll() {
-    return expandAll;
-  }
-
-  boolean isOpen() {
-    return open;
-  }
-
-  String getPath() {
-    return path;
-  }
-
-  Map<String, PublishedBox> getPublished() {
-    return published;
-  }
-
-  CommentLinkProcessor getCommentLinkProcessor() {
-    return commentLinkProcessor;
-  }
-
-  void renderDrafts(DisplaySide forSide, JsArray<CommentInfo> in) {
-    for (CommentInfo info : Natives.asList(in)) {
-      DisplaySide side = displaySide(info, forSide);
-      if (side != null) {
-        addDraftBox(side, info);
-      }
-    }
-  }
-
-  void setUnsaved(DraftBox box, boolean isUnsaved) {
-    if (isUnsaved) {
-      unsavedDrafts.add(box);
-    } else {
-      unsavedDrafts.remove(box);
-    }
-  }
-
-  void saveAllDrafts(CallbackGroup cb) {
-    for (DraftBox box : unsavedDrafts) {
-      box.save(cb);
-    }
-  }
-
-  Side getStoredSideFromDisplaySide(DisplaySide side) {
-    if (side == DisplaySide.A && (base.isBaseOrAutoMerge() || base.isParent())) {
-      return Side.PARENT;
-    }
-    return Side.REVISION;
-  }
-
-  int getParentNumFromDisplaySide(DisplaySide side) {
-    if (side == DisplaySide.A) {
-      return base.getParentNum();
-    }
-    return 0;
-  }
-
-  PatchSet.Id getPatchSetIdFromSide(DisplaySide side) {
-    if (side == DisplaySide.A && (base.isPatchSet() || base.isEdit())) {
-      return base.asPatchSetId();
-    }
-    return revision;
-  }
-
-  DisplaySide displaySide(CommentInfo info, DisplaySide forSide) {
-    if (info.side() == Side.PARENT) {
-      return (base.isBaseOrAutoMerge() || base.isParent()) ? DisplaySide.A : null;
-    }
-    return forSide;
-  }
-
-  static FromTo adjustSelection(CodeMirror cm) {
-    FromTo fromTo = cm.getSelectedRange();
-    Pos to = fromTo.to();
-    if (to.ch() == 0) {
-      to.line(to.line() - 1);
-      to.ch(cm.getLine(to.line()).length());
-    }
-    return fromTo;
-  }
-
-  abstract CommentGroup group(DisplaySide side, int cmLinePlusOne);
-
-  /**
-   * Create a new {@link DraftBox} at the specified line and focus it.
-   *
-   * @param side which side the draft will appear on.
-   * @param line the line the draft will be at. Lines are 1-based. Line 0 is a special case creating
-   *     a file level comment.
-   */
-  void insertNewDraft(DisplaySide side, int line) {
-    if (line == 0) {
-      host.skipManager.ensureFirstLineIsVisible();
-    }
-
-    CommentGroup group = group(side, line);
-    if (0 < group.getBoxCount()) {
-      CommentBox last = group.getCommentBox(group.getBoxCount() - 1);
-      if (last instanceof DraftBox) {
-        ((DraftBox) last).setEdit(true);
-      } else {
-        ((PublishedBox) last).doReply();
-      }
-    } else {
-      addDraftBox(
-              side,
-              CommentInfo.create(
-                  getPath(),
-                  getStoredSideFromDisplaySide(side),
-                  getParentNumFromDisplaySide(side),
-                  line,
-                  null,
-                  false))
-          .setEdit(true);
-    }
-  }
-
-  abstract String getTokenSuffixForActiveLine(CodeMirror cm);
-
-  Runnable signInCallback(CodeMirror cm) {
-    return () -> {
-      String token = host.getToken();
-      if (cm.extras().hasActiveLine()) {
-        token += "@" + getTokenSuffixForActiveLine(cm);
-      }
-      Gerrit.doSignIn(token);
-    };
-  }
-
-  abstract void newDraft(CodeMirror cm);
-
-  Runnable newDraftCallback(CodeMirror cm) {
-    if (!Gerrit.isSignedIn()) {
-      return signInCallback(cm);
-    }
-
-    return () -> {
-      if (cm.extras().hasActiveLine()) {
-        newDraft(cm);
-      }
-    };
-  }
-
-  DraftBox addDraftBox(DisplaySide side, CommentInfo info) {
-    int cmLinePlusOne = host.getCmLine(info.line() - 1, side) + 1;
-    CommentGroup group = group(side, cmLinePlusOne);
-    DraftBox box =
-        new DraftBox(
-            group,
-            getCommentLinkProcessor(),
-            project,
-            getPatchSetIdFromSide(side),
-            info,
-            isExpandAll());
-
-    if (info.inReplyTo() != null) {
-      PublishedBox r = getPublished().get(info.inReplyTo());
-      if (r != null) {
-        r.setReplyBox(box);
-      }
-    }
-
-    group.add(box);
-    box.setAnnotation(
-        host.getDiffTable()
-            .scrollbar
-            .draft(host.getCmFromSide(side), Math.max(0, cmLinePlusOne - 1)));
-    return box;
-  }
-
-  void setExpandAllComments(boolean b) {
-    setExpandAll(b);
-    for (CommentGroup g : sideA.values()) {
-      g.setOpenAll(b);
-    }
-    for (CommentGroup g : sideB.values()) {
-      g.setOpenAll(b);
-    }
-  }
-
-  abstract SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side);
-
-  Runnable commentNav(CodeMirror src, Direction dir) {
-    return () -> {
-      // Every comment appears in both side maps as a linked pair.
-      // It is only necessary to search one side to find a comment
-      // on either side of the editor pair.
-      SortedMap<Integer, CommentGroup> map = getMapForNav(src.side());
-      int line =
-          src.extras().hasActiveLine() ? src.getLineNumber(src.extras().activeLine()) + 1 : 0;
-
-      CommentGroup g;
-      if (dir == Direction.NEXT) {
-        map = map.tailMap(line + 1);
-        if (map.isEmpty()) {
-          return;
-        }
-        g = map.get(map.firstKey());
-        while (g.getBoxCount() == 0) {
-          map = map.tailMap(map.firstKey() + 1);
-          if (map.isEmpty()) {
-            return;
-          }
-          g = map.get(map.firstKey());
-        }
-      } else {
-        map = map.headMap(line);
-        if (map.isEmpty()) {
-          return;
-        }
-        g = map.get(map.lastKey());
-        while (g.getBoxCount() == 0) {
-          map = map.headMap(map.lastKey());
-          if (map.isEmpty()) {
-            return;
-          }
-          g = map.get(map.lastKey());
-        }
-      }
-
-      CodeMirror cm = g.getCm();
-      double y = cm.heightAtLine(g.getLine() - 1, "local");
-      cm.setCursor(Pos.create(g.getLine() - 1));
-      cm.scrollToY(y - 0.5 * cm.scrollbarV().getClientHeight());
-      cm.focus();
-    };
-  }
-
-  void clearLine(DisplaySide side, int line, CommentGroup group) {
-    SortedMap<Integer, CommentGroup> map = map(side);
-    if (map.get(line) == group) {
-      map.remove(line);
-    }
-  }
-
-  void render(CommentsCollections in, boolean expandAll) {
-    if (in.publishedBase != null) {
-      renderPublished(DisplaySide.A, in.publishedBase);
-    }
-    if (in.publishedRevision != null) {
-      renderPublished(DisplaySide.B, in.publishedRevision);
-    }
-    if (in.draftsBase != null) {
-      renderDrafts(DisplaySide.A, in.draftsBase);
-    }
-    if (in.draftsRevision != null) {
-      renderDrafts(DisplaySide.B, in.draftsRevision);
-    }
-    if (expandAll) {
-      setExpandAllComments(true);
-    }
-    for (CommentGroup g : sideA.values()) {
-      g.init(host.getDiffTable());
-    }
-    for (CommentGroup g : sideB.values()) {
-      g.init(host.getDiffTable());
-      g.handleRedraw();
-    }
-    setAttached(true);
-  }
-
-  void renderPublished(DisplaySide forSide, JsArray<CommentInfo> in) {
-    for (CommentInfo info : Natives.asList(in)) {
-      DisplaySide side = displaySide(info, forSide);
-      if (side != null) {
-        int cmLinePlusOne = host.getCmLine(info.line() - 1, side) + 1;
-        CommentGroup group = group(side, cmLinePlusOne);
-        PublishedBox box =
-            new PublishedBox(
-                group,
-                getCommentLinkProcessor(),
-                project,
-                getPatchSetIdFromSide(side),
-                info,
-                side,
-                isOpen());
-        group.add(box);
-        box.setAnnotation(
-            host.getDiffTable().scrollbar.comment(host.getCmFromSide(side), cmLinePlusOne - 1));
-        getPublished().put(info.id(), box);
-      }
-    }
-  }
-
-  abstract Collection<Integer> getLinesWithCommentGroups();
-
-  private static void checkAndAddSkip(List<SkippedLine> out, SkippedLine s) {
-    if (s.getSize() > 1) {
-      out.add(s);
-    }
-  }
-
-  List<SkippedLine> splitSkips(int context, List<SkippedLine> skips) {
-    if (sideA.containsKey(0) || sideB.containsKey(0)) {
-      // Special case of file comment; cannot skip first line.
-      for (SkippedLine skip : skips) {
-        if (skip.getStartA() == 0) {
-          skip.incrementStart(1);
-          break;
-        }
-      }
-    }
-
-    for (int boxLine : getLinesWithCommentGroups()) {
-      List<SkippedLine> temp = new ArrayList<>(skips.size() + 2);
-      for (SkippedLine skip : skips) {
-        int startLine = host.getCmLine(skip.getStartB(), DisplaySide.B);
-        int deltaBefore = boxLine - startLine;
-        int deltaAfter = startLine + skip.getSize() - boxLine;
-        if (deltaBefore < -context || deltaAfter < -context) {
-          temp.add(skip); // Size guaranteed to be greater than 1
-        } else if (deltaBefore > context && deltaAfter > context) {
-          SkippedLine before =
-              new SkippedLine(
-                  skip.getStartA(), skip.getStartB(), skip.getSize() - deltaAfter - context);
-          skip.incrementStart(deltaBefore + context);
-          checkAndAddSkip(temp, before);
-          checkAndAddSkip(temp, skip);
-        } else if (deltaAfter > context) {
-          skip.incrementStart(deltaBefore + context);
-          checkAndAddSkip(temp, skip);
-        } else if (deltaBefore > context) {
-          skip.reduceSize(deltaAfter + context);
-          checkAndAddSkip(temp, skip);
-        }
-      }
-      if (temp.isEmpty()) {
-        return temp;
-      }
-      skips = temp;
-    }
-    return skips;
-  }
-
-  abstract void newDraftOnGutterClick(CodeMirror cm, String gutterClass, int line);
-
-  abstract CommentGroup getCommentGroupOnActiveLine(CodeMirror cm);
-
-  Runnable toggleOpenBox(CodeMirror cm) {
-    return () -> {
-      CommentGroup group = getCommentGroupOnActiveLine(cm);
-      if (group != null) {
-        group.openCloseLast();
-      }
-    };
-  }
-
-  Runnable openCloseAll(CodeMirror cm) {
-    return () -> {
-      CommentGroup group = getCommentGroupOnActiveLine(cm);
-      if (group != null) {
-        group.openCloseAll();
-      }
-    };
-  }
-
-  SortedMap<Integer, CommentGroup> map(DisplaySide side) {
-    return side == DisplaySide.A ? sideA : sideB;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java
deleted file mode 100644
index 0f357d5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import net.codemirror.lib.Pos;
-import net.codemirror.lib.TextMarker.FromTo;
-
-public class CommentRange extends JavaScriptObject {
-  public static CommentRange create(int sl, int sc, int el, int ec) {
-    CommentRange r = createObject().cast();
-    r.set(sl, sc, el, ec);
-    return r;
-  }
-
-  public static CommentRange create(FromTo fromTo) {
-    if (fromTo == null) {
-      return null;
-    }
-
-    Pos from = fromTo.from();
-    Pos to = fromTo.to();
-    return create(
-        from.line() + 1, from.ch(),
-        to.line() + 1, to.ch());
-  }
-
-  public final native int startLine() /*-{ return this.start_line; }-*/;
-
-  public final native int startCharacter() /*-{ return this.start_character; }-*/;
-
-  public final native int endLine() /*-{ return this.end_line; }-*/;
-
-  public final native int endCharacter() /*-{ return this.end_character; }-*/;
-
-  private native void set(int sl, int sc, int el, int ec) /*-{
-    this.start_line = sl;
-    this.start_character = sc;
-    this.end_line = el;
-    this.end_character = ec;
-  }-*/;
-
-  protected CommentRange() {}
-}
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
deleted file mode 100644
index 533b745..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
+++ /dev/null
@@ -1,172 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.CommentApi;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.reviewdb.client.PatchSet;
-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 {
-  @Nullable private final Project.NameKey project;
-  private final String path;
-  private final DiffObject base;
-  private final PatchSet.Id revision;
-  private NativeMap<JsArray<CommentInfo>> publishedBaseAll;
-  private NativeMap<JsArray<CommentInfo>> publishedRevisionAll;
-  JsArray<CommentInfo> publishedBase;
-  JsArray<CommentInfo> publishedRevision;
-  JsArray<CommentInfo> draftsBase;
-  JsArray<CommentInfo> draftsRevision;
-
-  CommentsCollections(
-      @Nullable Project.NameKey project, DiffObject base, PatchSet.Id revision, String path) {
-    this.project = project;
-    this.path = path;
-    this.base = base;
-    this.revision = revision;
-  }
-
-  void load(CallbackGroup group) {
-    if (base.isPatchSet()) {
-      CommentApi.comments(
-          Project.NameKey.asStringOrNull(project), base.asPatchSetId(), group.add(publishedBase()));
-    }
-    CommentApi.comments(
-        Project.NameKey.asStringOrNull(project), revision, group.add(publishedRevision()));
-
-    if (Gerrit.isSignedIn()) {
-      if (base.isPatchSet()) {
-        CommentApi.drafts(
-            Project.NameKey.asStringOrNull(project), base.asPatchSetId(), group.add(draftsBase()));
-      }
-      CommentApi.drafts(
-          Project.NameKey.asStringOrNull(project), revision, group.add(draftsRevision()));
-    }
-  }
-
-  boolean hasCommentForPath(String filePath) {
-    if (base.isPatchSet()) {
-      JsArray<CommentInfo> forBase = publishedBaseAll.get(filePath);
-      if (forBase != null && forBase.length() > 0) {
-        return true;
-      }
-    }
-    JsArray<CommentInfo> forRevision = publishedRevisionAll.get(filePath);
-    if (forRevision != null && forRevision.length() > 0) {
-      return true;
-    }
-    return false;
-  }
-
-  private AsyncCallback<NativeMap<JsArray<CommentInfo>>> publishedBase() {
-    return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-      @Override
-      public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-        publishedBaseAll = result;
-        publishedBase = sort(result.get(path));
-      }
-
-      @Override
-      public void onFailure(Throwable caught) {}
-    };
-  }
-
-  private AsyncCallback<NativeMap<JsArray<CommentInfo>>> publishedRevision() {
-    return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-      @Override
-      public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-        for (String k : result.keySet()) {
-          result.put(k, filterForParent(result.get(k)));
-        }
-        publishedRevisionAll = result;
-        publishedRevision = sort(result.get(path));
-      }
-
-      @Override
-      public void onFailure(Throwable caught) {}
-    };
-  }
-
-  private JsArray<CommentInfo> filterForParent(JsArray<CommentInfo> list) {
-    JsArray<CommentInfo> result = JsArray.createArray().cast();
-    for (CommentInfo c : Natives.asList(list)) {
-      if (c.side() == Side.REVISION) {
-        result.push(c);
-      } else if (base.isBaseOrAutoMerge() && !c.hasParent()) {
-        result.push(c);
-      } else if (base.isParent() && c.parent() == base.getParentNum()) {
-        result.push(c);
-      }
-    }
-    return result;
-  }
-
-  private AsyncCallback<NativeMap<JsArray<CommentInfo>>> draftsBase() {
-    return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-      @Override
-      public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-        draftsBase = sort(result.get(path));
-      }
-
-      @Override
-      public void onFailure(Throwable caught) {}
-    };
-  }
-
-  private AsyncCallback<NativeMap<JsArray<CommentInfo>>> draftsRevision() {
-    return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-      @Override
-      public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-        for (String k : result.keySet()) {
-          result.put(k, filterForParent(result.get(k)));
-        }
-        draftsRevision = sort(result.get(path));
-      }
-
-      @Override
-      public void onFailure(Throwable caught) {}
-    };
-  }
-
-  private JsArray<CommentInfo> sort(JsArray<CommentInfo> in) {
-    if (in != null) {
-      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());
-            }
-          });
-    }
-    return in;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
deleted file mode 100644
index 1815920..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_ALL;
-
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.info.FileInfo;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-public class DiffApi {
-  public static void list(
-      @Nullable String project,
-      int id,
-      String revision,
-      RevisionInfo base,
-      AsyncCallback<NativeMap<FileInfo>> cb) {
-    RestApi api = ChangeApi.revision(project, id, revision).view("files");
-    if (base != null) {
-      if (base._number() < 0) {
-        api.addParameter("parent", -base._number());
-      } else {
-        api.addParameter("base", base.name());
-      }
-    }
-    api.get(NativeMap.copyKeysIntoChildren("path", cb));
-  }
-
-  public static void list(
-      @Nullable String project,
-      PatchSet.Id id,
-      PatchSet.Id base,
-      AsyncCallback<NativeMap<FileInfo>> cb) {
-    RestApi api = ChangeApi.revision(project, id).view("files");
-    if (base != null) {
-      if (base.get() < 0) {
-        api.addParameter("parent", -base.get());
-      } else {
-        api.addParameter("base", base.get());
-      }
-    }
-    api.get(NativeMap.copyKeysIntoChildren("path", cb));
-  }
-
-  public static DiffApi diff(@Nullable String project, PatchSet.Id id, String path) {
-    return new DiffApi(ChangeApi.revision(project, id).view("files").id(path).view("diff"));
-  }
-
-  private final RestApi call;
-
-  private DiffApi(RestApi call) {
-    this.call = call;
-  }
-
-  public DiffApi base(PatchSet.Id id) {
-    if (id != null) {
-      if (id.get() < 0) {
-        call.addParameter("parent", -id.get());
-      } else {
-        call.addParameter("base", id.get());
-      }
-    }
-    return this;
-  }
-
-  public DiffApi webLinksOnly() {
-    call.addParameterTrue("weblinks-only");
-    return this;
-  }
-
-  public DiffApi ignoreWhitespace(DiffPreferencesInfo.Whitespace w) {
-    if (w != null && w != IGNORE_ALL) {
-      call.addParameter("whitespace", w);
-    }
-    return this;
-  }
-
-  public DiffApi intraline(boolean intraline) {
-    if (intraline) {
-      call.addParameterTrue("intraline");
-    }
-    return this;
-  }
-
-  public DiffApi wholeFile() {
-    call.addParameter("context", "ALL");
-    return this;
-  }
-
-  public DiffApi context(int lines) {
-    call.addParameter("context", lines);
-    return this;
-  }
-
-  public void get(AsyncCallback<DiffInfo> cb) {
-    call.get(cb);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffChunkInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffChunkInfo.java
deleted file mode 100644
index 3b1b346..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffChunkInfo.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-/** Object recording the position of a diff chunk and whether it's an edit */
-class DiffChunkInfo {
-  private DisplaySide side;
-  private int start;
-  private int end;
-  private boolean edit;
-
-  DiffChunkInfo(DisplaySide side, int start, int end, boolean edit) {
-    this.side = side;
-    this.start = start;
-    this.end = end;
-    this.edit = edit;
-  }
-
-  DisplaySide getSide() {
-    return side;
-  }
-
-  int getStart() {
-    return start;
-  }
-
-  int getEnd() {
-    return end;
-  }
-
-  boolean isEdit() {
-    return edit;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
deleted file mode 100644
index cf40762..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
+++ /dev/null
@@ -1,190 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.DiffWebLinkInfo;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import java.util.ArrayList;
-import java.util.List;
-
-public class DiffInfo extends JavaScriptObject {
-  public final native FileMeta metaA() /*-{ return this.meta_a; }-*/;
-
-  public final native FileMeta metaB() /*-{ return this.meta_b; }-*/;
-
-  public final native JsArrayString diffHeader() /*-{ return this.diff_header; }-*/;
-
-  public final native JsArray<Region> content() /*-{ return this.content; }-*/;
-
-  public final native JsArray<DiffWebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
-
-  public final native boolean binary() /*-{ return this.binary || false; }-*/;
-
-  public final List<WebLinkInfo> sideBySideWebLinks() {
-    return filterWebLinks(DiffView.SIDE_BY_SIDE);
-  }
-
-  public final List<WebLinkInfo> unifiedWebLinks() {
-    return filterWebLinks(DiffView.UNIFIED_DIFF);
-  }
-
-  private List<WebLinkInfo> filterWebLinks(DiffView diffView) {
-    List<WebLinkInfo> filteredDiffWebLinks = new ArrayList<>();
-    List<DiffWebLinkInfo> allDiffWebLinks = Natives.asList(webLinks());
-    if (allDiffWebLinks != null) {
-      for (DiffWebLinkInfo webLink : allDiffWebLinks) {
-        if (diffView == DiffView.SIDE_BY_SIDE && webLink.showOnSideBySideDiffView()) {
-          filteredDiffWebLinks.add(webLink);
-        }
-        if (diffView == DiffView.UNIFIED_DIFF && webLink.showOnUnifiedDiffView()) {
-          filteredDiffWebLinks.add(webLink);
-        }
-      }
-    }
-    return filteredDiffWebLinks;
-  }
-
-  public final ChangeType changeType() {
-    return ChangeType.valueOf(changeTypeRaw());
-  }
-
-  private native String changeTypeRaw() /*-{ return this.change_type }-*/;
-
-  public final IntraLineStatus intralineStatus() {
-    String s = intralineStatusRaw();
-    return s != null ? IntraLineStatus.valueOf(s) : IntraLineStatus.OFF;
-  }
-
-  private native String intralineStatusRaw() /*-{ return this.intraline_status }-*/;
-
-  public final boolean hasSkip() {
-    JsArray<Region> c = content();
-    for (int i = 0; i < c.length(); i++) {
-      if (c.get(i).skip() != 0) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  public final String textA() {
-    StringBuilder s = new StringBuilder();
-    JsArray<Region> c = content();
-    for (int i = 0; i < c.length(); i++) {
-      Region r = c.get(i);
-      if (r.ab() != null) {
-        append(s, r.ab());
-      } else if (r.a() != null) {
-        append(s, r.a());
-      }
-      // TODO skip may need to be handled
-    }
-    return s.toString();
-  }
-
-  public final String textB() {
-    StringBuilder s = new StringBuilder();
-    JsArray<Region> c = content();
-    for (int i = 0; i < c.length(); i++) {
-      Region r = c.get(i);
-      if (r.ab() != null) {
-        append(s, r.ab());
-      } else if (r.b() != null) {
-        append(s, r.b());
-      }
-      // TODO skip may need to be handled
-    }
-    return s.toString();
-  }
-
-  public final String textUnified() {
-    StringBuilder s = new StringBuilder();
-    JsArray<Region> c = content();
-    for (int i = 0; i < c.length(); i++) {
-      Region r = c.get(i);
-      if (r.ab() != null) {
-        append(s, r.ab());
-      } else {
-        if (r.a() != null) {
-          append(s, r.a());
-        }
-        if (r.b() != null) {
-          append(s, r.b());
-        }
-      }
-      // TODO skip may need to be handled
-    }
-    return s.toString();
-  }
-
-  private static void append(StringBuilder s, JsArrayString lines) {
-    for (int i = 0; i < lines.length(); i++) {
-      s.append(lines.get(i)).append('\n');
-    }
-  }
-
-  protected DiffInfo() {}
-
-  public enum IntraLineStatus {
-    OFF,
-    OK,
-    TIMEOUT,
-    FAILURE
-  }
-
-  public static class FileMeta extends JavaScriptObject {
-    public final native String name() /*-{ return this.name; }-*/;
-
-    public final native String contentType() /*-{ return this.content_type; }-*/;
-
-    public final native int lines() /*-{ return this.lines || 0 }-*/;
-
-    public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
-
-    protected FileMeta() {}
-  }
-
-  public static class Region extends JavaScriptObject {
-    public final native JsArrayString ab() /*-{ return this.ab; }-*/;
-
-    public final native JsArrayString a() /*-{ return this.a; }-*/;
-
-    public final native JsArrayString b() /*-{ return this.b; }-*/;
-
-    public final native int skip() /*-{ return this.skip || 0; }-*/;
-
-    public final native boolean common() /*-{ return this.common || false; }-*/;
-
-    public final native JsArray<Span> editA() /*-{ return this.edit_a }-*/;
-
-    public final native JsArray<Span> editB() /*-{ return this.edit_b }-*/;
-
-    protected Region() {}
-  }
-
-  public static class Span extends JavaScriptObject {
-    public final native int skip() /*-{ return this[0]; }-*/;
-
-    public final native int mark() /*-{ return this[1]; }-*/;
-
-    protected Span() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
deleted file mode 100644
index b4221ca..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
+++ /dev/null
@@ -1,931 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or impl ied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.diff;
-
-import static com.google.gerrit.extensions.client.DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
-import static java.lang.Double.POSITIVE_INFINITY;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.DiffPreferences;
-import com.google.gerrit.client.change.ChangeScreen;
-import com.google.gerrit.client.change.FileTable;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ChangeList;
-import com.google.gerrit.client.diff.DiffInfo.FileMeta;
-import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.info.FileInfo;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.client.projects.ConfigInfoCache;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.RepeatingCommand;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.logical.shared.ResizeEvent;
-import com.google.gwt.event.logical.shared.ResizeHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-import com.google.gwtexpui.globalkey.client.ShowHelpCommand;
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.List;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.BeforeSelectionChangeHandler;
-import net.codemirror.lib.CodeMirror.GutterClickHandler;
-import net.codemirror.lib.CodeMirror.LineHandle;
-import net.codemirror.lib.KeyMap;
-import net.codemirror.lib.Pos;
-import net.codemirror.mode.ModeInfo;
-import net.codemirror.mode.ModeInjector;
-import net.codemirror.theme.ThemeLoader;
-
-/** Base class for SideBySide and Unified */
-abstract class DiffScreen extends Screen {
-  private static final KeyMap RENDER_ENTIRE_FILE_KEYMAP =
-      KeyMap.create().propagate("Ctrl-F").propagate("Ctrl-G").propagate("Shift-Ctrl-G");
-
-  enum FileSize {
-    SMALL(0),
-    LARGE(500),
-    HUGE(4000);
-
-    final int lines;
-
-    FileSize(int n) {
-      this.lines = n;
-    }
-  }
-
-  @Nullable private Project.NameKey project;
-  private final Change.Id changeId;
-  final DiffObject base;
-  final PatchSet.Id revision;
-  final String path;
-  final DiffPreferences prefs;
-  final SkipManager skipManager;
-
-  private DisplaySide startSide;
-  private int startLine;
-  private Change.Status changeStatus;
-
-  private HandlerRegistration resizeHandler;
-  private DiffInfo diff;
-  private FileSize fileSize;
-  private EditInfo edit;
-
-  private KeyCommandSet keysNavigation;
-  private KeyCommandSet keysAction;
-  private KeyCommandSet keysComment;
-  private List<HandlerRegistration> handlers;
-  private PreferencesAction prefsAction;
-  private int reloadVersionId;
-  private int parents;
-
-  @UiField(provided = true)
-  Header header;
-
-  DiffScreen(
-      @Nullable Project.NameKey project,
-      DiffObject base,
-      DiffObject revision,
-      String path,
-      DisplaySide startSide,
-      int startLine,
-      DiffView diffScreenType) {
-    this.project = project;
-    this.base = base;
-    this.revision = revision.asPatchSetId();
-    this.changeId = revision.asPatchSetId().getParentKey();
-    this.path = path;
-    this.startSide = startSide;
-    this.startLine = startLine;
-
-    prefs = DiffPreferences.create(Gerrit.getDiffPreferences());
-    handlers = new ArrayList<>(6);
-    keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
-    header = new Header(keysNavigation, project, base, revision, path, diffScreenType, prefs);
-    skipManager = new SkipManager(this);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setHeaderVisible(false);
-    setWindowTitle(FileInfo.getFileName(path));
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-
-    CallbackGroup group1 = new CallbackGroup();
-    final CallbackGroup group2 = new CallbackGroup();
-
-    CodeMirror.initLibrary(
-        group1.add(
-            new AsyncCallback<Void>() {
-              final AsyncCallback<Void> themeCallback = group2.addEmpty();
-
-              @Override
-              public void onSuccess(Void result) {
-                // Load theme after CM library to ensure theme can override CSS.
-                ThemeLoader.loadTheme(prefs.theme(), themeCallback);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {}
-            }));
-
-    DiffApi.diff(Project.NameKey.asStringOrNull(project), revision, path)
-        .base(base.asPatchSetId())
-        .wholeFile()
-        .intraline(prefs.intralineDifference())
-        .ignoreWhitespace(prefs.ignoreWhitespace())
-        .get(
-            group1.addFinal(
-                new GerritCallback<DiffInfo>() {
-                  final AsyncCallback<Void> modeInjectorCb = group2.addEmpty();
-
-                  @Override
-                  public void onSuccess(DiffInfo diffInfo) {
-                    diff = diffInfo;
-                    fileSize = bucketFileSize(diffInfo);
-
-                    if (prefs.syntaxHighlighting()) {
-                      if (fileSize.compareTo(FileSize.SMALL) > 0) {
-                        modeInjectorCb.onSuccess(null);
-                      } else {
-                        injectMode(diffInfo, modeInjectorCb);
-                      }
-                    } else {
-                      modeInjectorCb.onSuccess(null);
-                    }
-                  }
-                }));
-
-    if (Gerrit.isSignedIn()) {
-      ChangeApi.edit(
-          Project.NameKey.asStringOrNull(project),
-          changeId.get(),
-          group2.add(
-              new AsyncCallback<EditInfo>() {
-                @Override
-                public void onSuccess(EditInfo result) {
-                  edit = result;
-                }
-
-                @Override
-                public void onFailure(Throwable caught) {}
-              }));
-    }
-
-    final CommentsCollections comments = new CommentsCollections(project, base, revision, path);
-    comments.load(group2);
-
-    countParents(group2);
-
-    RestApi call = ChangeApi.detail(Project.NameKey.asStringOrNull(project), changeId.get());
-    ChangeList.addOptions(call, EnumSet.of(ListChangesOption.ALL_REVISIONS));
-    call.get(
-        group2.add(
-            new AsyncCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo info) {
-                changeStatus = info.status();
-                project = info.projectNameKey();
-                info.revisions().copyKeysIntoChildren("name");
-                if (edit != null) {
-                  edit.setName(edit.commit().commit());
-                  info.setEdit(edit);
-                  info.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
-                }
-                String currentRevision = info.currentRevision();
-                boolean current =
-                    currentRevision != null
-                        && revision.get() == info.revision(currentRevision)._number();
-                JsArray<RevisionInfo> list = info.revisions().values();
-                RevisionInfo.sortRevisionInfoByNumber(list);
-                getDiffTable()
-                    .set(
-                        prefs,
-                        list,
-                        parents,
-                        diff,
-                        edit != null,
-                        current,
-                        changeStatus.isOpen(),
-                        diff.binary());
-                header.setChangeInfo(info);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {}
-            }));
-
-    ConfigInfoCache.get(changeId, group2.addFinal(getScreenLoadCallback(comments)));
-  }
-
-  private void countParents(CallbackGroup cbg) {
-    ChangeApi.revision(Project.NameKey.asStringOrNull(project), changeId.get(), revision.getId())
-        .view("commit")
-        .get(
-            cbg.add(
-                new AsyncCallback<CommitInfo>() {
-                  @Override
-                  public void onSuccess(CommitInfo info) {
-                    parents = info.parents().length();
-                  }
-
-                  @Override
-                  public void onFailure(Throwable caught) {
-                    parents = 0;
-                  }
-                }));
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-
-    Window.enableScrolling(false);
-    if (prefs.hideTopMenu()) {
-      Gerrit.setHeaderVisible(false);
-    }
-    resizeHandler =
-        Window.addResizeHandler(
-            new ResizeHandler() {
-              @Override
-              public void onResize(ResizeEvent event) {
-                resizeCodeMirror();
-              }
-            });
-  }
-
-  KeyCommandSet getKeysNavigation() {
-    return keysNavigation;
-  }
-
-  KeyCommandSet getKeysAction() {
-    return keysAction;
-  }
-
-  @Override
-  protected void onUnload() {
-    super.onUnload();
-
-    removeKeyHandlerRegistrations();
-    if (getCommentManager() != null) {
-      CallbackGroup group = new CallbackGroup();
-      getCommentManager().saveAllDrafts(group);
-      group.done();
-    }
-    if (resizeHandler != null) {
-      resizeHandler.removeHandler();
-      resizeHandler = null;
-    }
-    for (CodeMirror cm : getCms()) {
-      if (cm != null) {
-        cm.getWrapperElement().removeFromParent();
-      }
-    }
-    if (prefsAction != null) {
-      prefsAction.hide();
-    }
-
-    Window.enableScrolling(true);
-    Gerrit.setHeaderVisible(true);
-  }
-
-  private void removeKeyHandlerRegistrations() {
-    for (HandlerRegistration h : handlers) {
-      h.removeHandler();
-    }
-    handlers.clear();
-  }
-
-  void registerCmEvents(CodeMirror cm) {
-    cm.on("cursorActivity", updateActiveLine(cm));
-    cm.on("focus", updateActiveLine(cm));
-    KeyMap keyMap =
-        KeyMap.create()
-            .on("A", upToChange(true))
-            .on("U", upToChange(false))
-            .on("'['", header.navigate(Direction.PREV))
-            .on("']'", header.navigate(Direction.NEXT))
-            .on("R", header.toggleReviewed())
-            .on("O", getCommentManager().toggleOpenBox(cm))
-            .on("N", maybeNextVimSearch(cm))
-            .on("Ctrl-Alt-E", openEditScreen(cm))
-            .on("P", getChunkManager().diffChunkNav(cm, Direction.PREV))
-            .on("Shift-M", header.reviewedAndNext())
-            .on("Shift-N", maybePrevVimSearch(cm))
-            .on("Shift-P", getCommentManager().commentNav(cm, Direction.PREV))
-            .on("Shift-O", getCommentManager().openCloseAll(cm))
-            .on(
-                "I",
-                () -> {
-                  switch (getIntraLineStatus()) {
-                    case OFF:
-                    case OK:
-                      toggleShowIntraline();
-                      break;
-                    case FAILURE:
-                    case TIMEOUT:
-                    default:
-                      break;
-                  }
-                })
-            .on("','", prefsAction::show)
-            .on("Shift-/", () -> new ShowHelpCommand().onKeyPress(null))
-            .on("Space", () -> cm.vim().handleKey("<C-d>"))
-            .on("Shift-Space", () -> cm.vim().handleKey("<C-u>"))
-            .on("Ctrl-F", () -> cm.execCommand("find"))
-            .on("Ctrl-G", () -> cm.execCommand("findNext"))
-            .on("Enter", maybeNextCmSearch(cm))
-            .on("Shift-Ctrl-G", () -> cm.execCommand("findPrev"))
-            .on("Shift-Enter", () -> cm.execCommand("findPrev"))
-            .on(
-                "Esc",
-                () -> {
-                  cm.setCursor(cm.getCursor());
-                  cm.execCommand("clearSearch");
-                  cm.vim().handleEx("nohlsearch");
-                })
-            .on("Ctrl-A", () -> cm.execCommand("selectAll"))
-            .on("G O", () -> Gerrit.display(PageLinks.toChangeQuery("status:open")))
-            .on("G M", () -> Gerrit.display(PageLinks.toChangeQuery("status:merged")))
-            .on("G A", () -> Gerrit.display(PageLinks.toChangeQuery("status:abandoned")));
-    if (Gerrit.isSignedIn()) {
-      keyMap
-          .on("G I", () -> Gerrit.display(PageLinks.MINE))
-          .on("G C", () -> Gerrit.display(PageLinks.toChangeQuery("has:draft")))
-          .on("G W", () -> Gerrit.display(PageLinks.toChangeQuery("is:watched status:open")))
-          .on("G S", () -> Gerrit.display(PageLinks.toChangeQuery("is:starred")));
-    }
-
-    if (revision.get() != 0) {
-      cm.on("beforeSelectionChange", onSelectionChange(cm));
-      cm.on("gutterClick", onGutterClick(cm));
-      keyMap.on("C", getCommentManager().newDraftCallback(cm));
-    }
-    CodeMirror.normalizeKeyMap(keyMap); // Needed to for multi-stroke keymaps
-    cm.addKeyMap(keyMap);
-  }
-
-  void maybeRegisterRenderEntireFileKeyMap(CodeMirror cm) {
-    if (renderEntireFile()) {
-      cm.addKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
-    }
-  }
-
-  private BeforeSelectionChangeHandler onSelectionChange(CodeMirror cm) {
-    return new BeforeSelectionChangeHandler() {
-      private InsertCommentBubble bubble;
-
-      @Override
-      public void handle(CodeMirror cm, Pos anchor, Pos head) {
-        if (anchor.equals(head)) {
-          if (bubble != null) {
-            bubble.setVisible(false);
-          }
-          return;
-        } else if (bubble == null) {
-          init(anchor);
-        } else {
-          bubble.setVisible(true);
-        }
-        bubble.position(cm.charCoords(head, "local"));
-      }
-
-      private void init(Pos anchor) {
-        bubble = new InsertCommentBubble(getCommentManager(), cm);
-        add(bubble);
-        cm.addWidget(anchor, bubble.getElement());
-      }
-    };
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-
-    keysNavigation.add(new UpToChangeCommand(project, revision, 0, 'u'));
-    keysNavigation.add(
-        new NoOpKeyCommand(0, 'j', PatchUtil.C.lineNext()),
-        new NoOpKeyCommand(0, 'k', PatchUtil.C.linePrev()));
-    keysNavigation.add(
-        new NoOpKeyCommand(0, 'n', PatchUtil.C.chunkNext()),
-        new NoOpKeyCommand(0, 'p', PatchUtil.C.chunkPrev()));
-    keysNavigation.add(
-        new NoOpKeyCommand(KeyCommand.M_SHIFT, 'n', PatchUtil.C.commentNext()),
-        new NoOpKeyCommand(KeyCommand.M_SHIFT, 'p', PatchUtil.C.commentPrev()));
-    keysNavigation.add(new NoOpKeyCommand(KeyCommand.M_CTRL, 'f', Gerrit.C.keySearch()));
-
-    keysAction = new KeyCommandSet(Gerrit.C.sectionActions());
-    keysAction.add(new NoOpKeyCommand(0, KeyCodes.KEY_ENTER, PatchUtil.C.expandComment()));
-    keysAction.add(new NoOpKeyCommand(0, 'o', PatchUtil.C.expandComment()));
-    keysAction.add(
-        new NoOpKeyCommand(KeyCommand.M_SHIFT, 'o', PatchUtil.C.expandAllCommentsOnCurrentLine()));
-    if (Gerrit.isSignedIn()) {
-      keysAction.add(
-          new KeyCommand(0, 'r', PatchUtil.C.toggleReviewed()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              header.toggleReviewed().run();
-            }
-          });
-      keysAction.add(
-          new NoOpKeyCommand(KeyCommand.M_CTRL | KeyCommand.M_ALT, 'e', Gerrit.C.keyEditor()));
-    }
-    keysAction.add(
-        new KeyCommand(KeyCommand.M_SHIFT, 'm', PatchUtil.C.markAsReviewedAndGoToNext()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            header.reviewedAndNext().run();
-          }
-        });
-    keysAction.add(
-        new KeyCommand(0, 'a', PatchUtil.C.openReply()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            upToChange(true).run();
-          }
-        });
-    keysAction.add(
-        new KeyCommand(0, ',', PatchUtil.C.showPreferences()) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            prefsAction.show();
-          }
-        });
-    if (getIntraLineStatus() == DiffInfo.IntraLineStatus.OFF
-        || getIntraLineStatus() == DiffInfo.IntraLineStatus.OK) {
-      keysAction.add(
-          new KeyCommand(0, 'i', PatchUtil.C.toggleIntraline()) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              toggleShowIntraline();
-            }
-          });
-    }
-
-    if (Gerrit.isSignedIn()) {
-      keysAction.add(new NoOpKeyCommand(0, 'c', PatchUtil.C.commentInsert()));
-      keysComment = new KeyCommandSet(PatchUtil.C.commentEditorSet());
-      keysComment.add(new NoOpKeyCommand(KeyCommand.M_CTRL, 's', PatchUtil.C.commentSaveDraft()));
-      keysComment.add(new NoOpKeyCommand(0, KeyCodes.KEY_ESCAPE, PatchUtil.C.commentCancelEdit()));
-    } else {
-      keysComment = null;
-    }
-  }
-
-  @Nullable
-  public Project.NameKey getProject() {
-    return project;
-  }
-
-  void registerHandlers() {
-    removeKeyHandlerRegistrations();
-    handlers.add(GlobalKey.add(this, keysAction));
-    handlers.add(GlobalKey.add(this, keysNavigation));
-    if (keysComment != null) {
-      handlers.add(GlobalKey.add(this, keysComment));
-    }
-    handlers.add(ShowHelpCommand.addFocusHandler(getFocusHandler()));
-  }
-
-  void setupSyntaxHighlighting() {
-    if (prefs.syntaxHighlighting() && fileSize.compareTo(FileSize.SMALL) > 0) {
-      Scheduler.get()
-          .scheduleFixedDelay(
-              new RepeatingCommand() {
-                @Override
-                public boolean execute() {
-                  if (prefs.syntaxHighlighting() && isAttached()) {
-                    setSyntaxHighlighting(prefs.syntaxHighlighting());
-                  }
-                  return false;
-                }
-              },
-              250);
-    }
-  }
-
-  abstract CodeMirror newCm(DiffInfo.FileMeta meta, String contents, Element parent);
-
-  void render(DiffInfo diff) {
-    header.setNoDiff(diff);
-    getChunkManager().render(diff);
-  }
-
-  void setShowLineNumbers(boolean b) {
-    if (b) {
-      getDiffTable().addStyleName(Resources.I.diffTableStyle().showLineNumbers());
-    } else {
-      getDiffTable().removeStyleName(Resources.I.diffTableStyle().showLineNumbers());
-    }
-  }
-
-  void setShowIntraline(boolean b) {
-    if (b && getIntraLineStatus() == DiffInfo.IntraLineStatus.OFF) {
-      reloadDiffInfo();
-    } else if (b) {
-      getDiffTable().removeStyleName(Resources.I.diffTableStyle().noIntraline());
-    } else {
-      getDiffTable().addStyleName(Resources.I.diffTableStyle().noIntraline());
-    }
-  }
-
-  private void toggleShowIntraline() {
-    prefs.intralineDifference(!Boolean.valueOf(prefs.intralineDifference()));
-    setShowIntraline(prefs.intralineDifference());
-    prefsAction.update();
-  }
-
-  abstract void setSyntaxHighlighting(boolean b);
-
-  void setContext(int context) {
-    operation(
-        () -> {
-          skipManager.removeAll();
-          skipManager.render(context, diff);
-          updateRenderEntireFile();
-        });
-  }
-
-  private int adjustCommitMessageLine(int line) {
-    /* When commit messages are shown in the diff screen they include
-      a header block that looks like this:
-
-      1 Parent:     deadbeef (Parent commit title)
-      2 Author:     A. U. Thor <author@example.com>
-      3 AuthorDate: 2015-02-27 19:20:52 +0900
-      4 Commit:     A. U. Thor <author@example.com>
-      5 CommitDate: 2015-02-27 19:20:52 +0900
-      6 [blank line]
-      7 Commit message title
-      8
-      9 Commit message body
-     10 ...
-     11 ...
-
-    If the commit is a merge commit, both parent commits are listed in the
-    first two lines instead of a 'Parent' line:
-
-      1 Merge Of:   deadbeef (Parent 1 commit title)
-      2             beefdead (Parent 2 commit title)
-
-    */
-
-    // Offset to compensate for header lines until the blank line
-    // after 'CommitDate'
-    int offset = 6;
-
-    // Adjust for merge commits, which have two parent lines
-    if (diff.textB().startsWith("Merge")) {
-      offset += 1;
-    }
-
-    // If the cursor is inside the header line, reset to the first line of the
-    // commit message. Otherwise if the cursor is on an actual line of the commit
-    // message, adjust the line number to compensate for the header lines, so the
-    // focus is on the correct line.
-    if (line <= offset) {
-      return 1;
-    }
-    return line - offset;
-  }
-
-  private Runnable openEditScreen(CodeMirror cm) {
-    return () -> {
-      LineHandle handle = cm.extras().activeLine();
-      int line = cm.getLineNumber(handle) + 1;
-      if (Patch.COMMIT_MSG.equals(path)) {
-        line = adjustCommitMessageLine(line);
-      }
-      String token = Dispatcher.toEditScreen(project, revision, path, line);
-      if (!Gerrit.isSignedIn()) {
-        Gerrit.doSignIn(token);
-      } else {
-        Gerrit.display(token);
-      }
-    };
-  }
-
-  void updateRenderEntireFile() {
-    boolean entireFile = renderEntireFile();
-    for (CodeMirror cm : getCms()) {
-      cm.removeKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
-      if (entireFile) {
-        cm.addKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
-      }
-      cm.setOption("viewportMargin", entireFile ? POSITIVE_INFINITY : 10);
-    }
-  }
-
-  void resizeCodeMirror() {
-    int height = header.getOffsetHeight() + getDiffTable().getHeaderHeight();
-    for (CodeMirror cm : getCms()) {
-      cm.adjustHeight(height);
-    }
-  }
-
-  abstract ChunkManager getChunkManager();
-
-  abstract CommentManager getCommentManager();
-
-  Change.Status getChangeStatus() {
-    return changeStatus;
-  }
-
-  int getStartLine() {
-    return startLine;
-  }
-
-  void setStartLine(int startLine) {
-    this.startLine = startLine;
-  }
-
-  DisplaySide getStartSide() {
-    return startSide;
-  }
-
-  void setStartSide(DisplaySide startSide) {
-    this.startSide = startSide;
-  }
-
-  DiffInfo getDiff() {
-    return diff;
-  }
-
-  FileSize getFileSize() {
-    return fileSize;
-  }
-
-  PreferencesAction getPrefsAction() {
-    return prefsAction;
-  }
-
-  void setPrefsAction(PreferencesAction prefsAction) {
-    this.prefsAction = prefsAction;
-  }
-
-  abstract void operation(Runnable apply);
-
-  private Runnable upToChange(boolean openReplyBox) {
-    return () -> {
-      CallbackGroup group = new CallbackGroup();
-      getCommentManager().saveAllDrafts(group);
-      group.done();
-      group.addListener(
-          new GerritCallback<Void>() {
-            @Override
-            public void onSuccess(Void result) {
-              String rev = String.valueOf(revision.get());
-              Gerrit.display(
-                  PageLinks.toChange(project, changeId, base.asString(), rev),
-                  new ChangeScreen(
-                      project, changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
-            }
-          });
-    };
-  }
-
-  private Runnable maybePrevVimSearch(CodeMirror cm) {
-    return () -> {
-      if (cm.vim().hasSearchHighlight()) {
-        cm.vim().handleKey("N");
-      } else {
-        getCommentManager().commentNav(cm, Direction.NEXT).run();
-      }
-    };
-  }
-
-  private Runnable maybeNextVimSearch(CodeMirror cm) {
-    return () -> {
-      if (cm.vim().hasSearchHighlight()) {
-        cm.vim().handleKey("n");
-      } else {
-        getChunkManager().diffChunkNav(cm, Direction.NEXT).run();
-      }
-    };
-  }
-
-  Runnable maybeNextCmSearch(CodeMirror cm) {
-    return () -> {
-      if (cm.hasSearchHighlight()) {
-        cm.execCommand("findNext");
-      } else {
-        cm.execCommand("clearSearch");
-        getCommentManager().toggleOpenBox(cm).run();
-      }
-    };
-  }
-
-  boolean renderEntireFile() {
-    return prefs.renderEntireFile() && canRenderEntireFile(prefs);
-  }
-
-  boolean canRenderEntireFile(DiffPreferences prefs) {
-    // CodeMirror is too slow to layout an entire huge file.
-    return fileSize.compareTo(FileSize.HUGE) < 0
-        || (prefs.context() != WHOLE_FILE_CONTEXT && prefs.context() < 100);
-  }
-
-  DiffInfo.IntraLineStatus getIntraLineStatus() {
-    return diff.intralineStatus();
-  }
-
-  void setThemeStyles(boolean d) {
-    if (d) {
-      getDiffTable().addStyleName(Resources.I.diffTableStyle().dark());
-    } else {
-      getDiffTable().removeStyleName(Resources.I.diffTableStyle().dark());
-    }
-  }
-
-  void setShowTabs(boolean show) {
-    for (CodeMirror cm : getCms()) {
-      cm.extras().showTabs(show);
-    }
-  }
-
-  void setLineLength(int columns) {
-    for (CodeMirror cm : getCms()) {
-      cm.extras().lineLength(columns);
-    }
-  }
-
-  String getContentType(DiffInfo.FileMeta meta) {
-    if (prefs.syntaxHighlighting() && meta != null && meta.contentType() != null) {
-      ModeInfo m = ModeInfo.findMode(meta.contentType(), path);
-      return m != null ? m.mime() : null;
-    }
-    return null;
-  }
-
-  String getContentType() {
-    return getContentType(diff.metaB());
-  }
-
-  void injectMode(DiffInfo diffInfo, AsyncCallback<Void> cb) {
-    new ModeInjector()
-        .add(getContentType(diffInfo.metaA()))
-        .add(getContentType(diffInfo.metaB()))
-        .inject(cb);
-  }
-
-  abstract void setAutoHideDiffHeader(boolean hide);
-
-  void prefetchNextFile() {
-    String nextPath = header.getNextPath();
-    if (nextPath != null) {
-      DiffApi.diff(Project.NameKey.asStringOrNull(project), revision, nextPath)
-          .base(base.asPatchSetId())
-          .wholeFile()
-          .intraline(prefs.intralineDifference())
-          .ignoreWhitespace(prefs.ignoreWhitespace())
-          .get(
-              new AsyncCallback<DiffInfo>() {
-                @Override
-                public void onSuccess(DiffInfo info) {
-                  new ModeInjector()
-                      .add(getContentType(info.metaA()))
-                      .add(getContentType(info.metaB()))
-                      .inject(CallbackGroup.<Void>emptyCallback());
-                }
-
-                @Override
-                public void onFailure(Throwable caught) {}
-              });
-    }
-  }
-
-  void reloadDiffInfo() {
-    int id = ++reloadVersionId;
-    DiffApi.diff(Project.NameKey.asStringOrNull(project), revision, path)
-        .base(base.asPatchSetId())
-        .wholeFile()
-        .intraline(prefs.intralineDifference())
-        .ignoreWhitespace(prefs.ignoreWhitespace())
-        .get(
-            new GerritCallback<DiffInfo>() {
-              @Override
-              public void onSuccess(DiffInfo diffInfo) {
-                if (id == reloadVersionId && isAttached()) {
-                  diff = diffInfo;
-                  operation(
-                      () -> {
-                        skipManager.removeAll();
-                        getChunkManager().reset();
-                        getDiffTable().scrollbar.removeDiffAnnotations();
-                        setShowIntraline(prefs.intralineDifference());
-                        render(diff);
-                        skipManager.render(prefs.context(), diff);
-                      });
-                }
-              }
-            });
-  }
-
-  private static FileSize bucketFileSize(DiffInfo diff) {
-    FileMeta a = diff.metaA();
-    FileMeta b = diff.metaB();
-    FileSize[] sizes = FileSize.values();
-    for (int i = sizes.length - 1; 0 <= i; i--) {
-      FileSize s = sizes[i];
-      if ((a != null && s.lines <= a.lines()) || (b != null && s.lines <= b.lines())) {
-        return s;
-      }
-    }
-    return FileSize.SMALL;
-  }
-
-  abstract Runnable updateActiveLine(CodeMirror cm);
-
-  private GutterClickHandler onGutterClick(CodeMirror cm) {
-    return new GutterClickHandler() {
-      @Override
-      public void handle(
-          CodeMirror instance, int line, String gutterClass, NativeEvent clickEvent) {
-        if (Element.as(clickEvent.getEventTarget()).hasClassName(getLineNumberClassName())
-            && clickEvent.getButton() == NativeEvent.BUTTON_LEFT
-            && !clickEvent.getMetaKey()
-            && !clickEvent.getAltKey()
-            && !clickEvent.getCtrlKey()
-            && !clickEvent.getShiftKey()) {
-          cm.setCursor(Pos.create(line));
-          Scheduler.get()
-              .scheduleDeferred(
-                  new ScheduledCommand() {
-                    @Override
-                    public void execute() {
-                      getCommentManager().newDraftOnGutterClick(cm, gutterClass, line + 1);
-                    }
-                  });
-        }
-      }
-    };
-  }
-
-  abstract FocusHandler getFocusHandler();
-
-  abstract CodeMirror[] getCms();
-
-  abstract CodeMirror getCmFromSide(DisplaySide side);
-
-  abstract DiffTable getDiffTable();
-
-  abstract int getCmLine(int line, DisplaySide side);
-
-  abstract String getLineNumberClassName();
-
-  LineOnOtherInfo lineOnOther(DisplaySide side, int line) {
-    return getChunkManager().lineMapper.lineOnOther(side, line);
-  }
-
-  abstract ScreenLoadCallback<ConfigInfoCache.Entry> getScreenLoadCallback(
-      CommentsCollections comments);
-
-  abstract boolean isSideBySide();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.css
deleted file mode 100644
index 7569cf5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.css
+++ /dev/null
@@ -1,41 +0,0 @@
-/* Copyright (C) 2013 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-.range {
-  background-color: #ffd500 !important;
-}
-.rangeHighlight {
-  background-color: #ffff00 !important;
-}
-
-.fullscreen {
-  background-color: #f7f7f7;
-  border-bottom: 1px solid #ddd;
-}
-
-@external .diffHeader;
-.diffHeader {
-  font-size: 12px;
-  font-weight: bold;
-  color: #5252ad;
-}
-
-.diffHeader pre {
-  margin: 0 0 3px 0;
-}
-
-@external .dark, .noIntraline, .showLineNumbers;
-.dark {}
-.noIntraline {}
-.showLineNumbers {}
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
deleted file mode 100644
index a91f8e6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
+++ /dev/null
@@ -1,184 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.account.DiffPreferences;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Unit;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-import net.codemirror.lib.CodeMirror;
-
-/** Base class for SideBySideTable2 and UnifiedTable2 */
-abstract class DiffTable extends Composite {
-  static {
-    Resources.I.diffTableStyle().ensureInjected();
-  }
-
-  interface Style extends CssResource {
-    String fullscreen();
-
-    String dark();
-
-    String noIntraline();
-
-    String range();
-
-    String rangeHighlight();
-
-    String diffHeader();
-
-    String showLineNumbers();
-  }
-
-  @UiField Element patchSetNavRow;
-  @UiField Element patchSetNavCellA;
-  @UiField Element patchSetNavCellB;
-  @UiField Element diffHeaderRow;
-  @UiField Element diffHeaderText;
-  @UiField FlowPanel widgets;
-
-  @UiField(provided = true)
-  PatchSetSelectBox patchSetSelectBoxA;
-
-  @UiField(provided = true)
-  PatchSetSelectBox patchSetSelectBoxB;
-
-  private boolean header;
-  private ChangeType changeType;
-  Scrollbar scrollbar;
-
-  DiffTable(DiffScreen parent, DiffObject base, DiffObject revision, String path) {
-    patchSetSelectBoxA =
-        new PatchSetSelectBox(
-            parent,
-            DisplaySide.A,
-            parent.getProject(),
-            revision.asPatchSetId().getParentKey(),
-            base,
-            path);
-    patchSetSelectBoxB =
-        new PatchSetSelectBox(
-            parent,
-            DisplaySide.B,
-            parent.getProject(),
-            revision.asPatchSetId().getParentKey(),
-            revision,
-            path);
-    PatchSetSelectBox.link(patchSetSelectBoxA, patchSetSelectBoxB);
-
-    this.scrollbar = new Scrollbar(this);
-  }
-
-  abstract boolean isVisibleA();
-
-  void setHeaderVisible(boolean show) {
-    DiffScreen parent = getDiffScreen();
-    if (show != UIObject.isVisible(patchSetNavRow)) {
-      UIObject.setVisible(patchSetNavRow, show);
-      UIObject.setVisible(diffHeaderRow, show && header);
-      if (show) {
-        parent.header.removeStyleName(Resources.I.diffTableStyle().fullscreen());
-      } else {
-        parent.header.addStyleName(Resources.I.diffTableStyle().fullscreen());
-      }
-      parent.resizeCodeMirror();
-    }
-  }
-
-  abstract int getHeaderHeight();
-
-  ChangeType getChangeType() {
-    return changeType;
-  }
-
-  void setUpBlameIconA(CodeMirror cm, boolean isBase, PatchSet.Id rev, String path) {
-    patchSetSelectBoxA.setUpBlame(cm, isBase, rev, path);
-  }
-
-  void setUpBlameIconB(CodeMirror cm, PatchSet.Id rev, String path) {
-    patchSetSelectBoxB.setUpBlame(cm, false, rev, path);
-  }
-
-  void set(
-      DiffPreferences prefs,
-      JsArray<RevisionInfo> list,
-      int parents,
-      DiffInfo info,
-      boolean editExists,
-      boolean current,
-      boolean open,
-      boolean binary) {
-    this.changeType = info.changeType();
-    patchSetSelectBoxA.setUpPatchSetNav(
-        list, parents, info.metaA(), editExists, current, open, binary);
-    patchSetSelectBoxB.setUpPatchSetNav(
-        list, parents, info.metaB(), editExists, current, open, binary);
-
-    JsArrayString hdr = info.diffHeader();
-    if (hdr != null) {
-      StringBuilder b = new StringBuilder();
-      for (int i = 1; i < hdr.length(); i++) {
-        String s = hdr.get(i);
-        if (!info.binary()
-            && (s.startsWith("diff --git ")
-                || s.startsWith("index ")
-                || s.startsWith("+++ ")
-                || s.startsWith("--- "))) {
-          continue;
-        }
-        b.append(s).append('\n');
-      }
-
-      String hdrTxt = b.toString().trim();
-      header = !hdrTxt.isEmpty();
-      diffHeaderText.setInnerText(hdrTxt);
-      UIObject.setVisible(diffHeaderRow, header);
-    } else {
-      header = false;
-      UIObject.setVisible(diffHeaderRow, false);
-    }
-    setHideEmptyPane(prefs.hideEmptyPane());
-  }
-
-  abstract void setHideEmptyPane(boolean hide);
-
-  void refresh() {
-    if (header) {
-      CodeMirror cm = getDiffScreen().getCmFromSide(DisplaySide.A);
-      diffHeaderText.getStyle().setMarginLeft(cm.getGutterElement().getOffsetWidth(), Unit.PX);
-    }
-  }
-
-  void add(Widget widget) {
-    widgets.add(widget);
-  }
-
-  abstract DiffScreen getDiffScreen();
-
-  boolean hasHeader() {
-    return header;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Direction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Direction.java
deleted file mode 100644
index b1dd87e1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Direction.java
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-/** Direction of traversal in an ordered list. */
-public enum Direction {
-  PREV,
-  NEXT
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java
deleted file mode 100644
index 6cee174..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-/** Enum representing the side on a side-by-side view */
-public enum DisplaySide {
-  A,
-  B;
-
-  DisplaySide otherSide() {
-    return this == A ? B : A;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
deleted file mode 100644
index 33d1ac4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
+++ /dev/null
@@ -1,466 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.change.LocalComments;
-import com.google.gerrit.client.changes.CommentApi;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.BlurEvent;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.DoubleClickEvent;
-import com.google.gwt.event.dom.client.DoubleClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.MouseMoveEvent;
-import com.google.gwt.event.dom.client.MouseMoveHandler;
-import com.google.gwt.event.dom.client.MouseUpEvent;
-import com.google.gwt.event.dom.client.MouseUpHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.Timer;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.NpTextArea;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import net.codemirror.lib.CodeMirror;
-
-/** An HtmlPanel for displaying and editing a draft */
-class DraftBox extends CommentBox {
-  interface Binder extends UiBinder<HTMLPanel, DraftBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  private static final int INITIAL_LINES = 5;
-  private static final int MAX_LINES = 30;
-
-  private final CommentLinkProcessor linkProcessor;
-  private final PatchSet.Id psId;
-  @Nullable private final Project.NameKey project;
-  private final boolean expandAll;
-  private CommentInfo comment;
-  private PublishedBox replyToBox;
-  private Timer expandTimer;
-  private Timer resizeTimer;
-  private int editAreaHeight;
-  private boolean autoClosed;
-  private CallbackGroup pendingGroup;
-
-  @UiField Widget header;
-  @UiField Element summary;
-  @UiField Element date;
-
-  @UiField Element p_view;
-  @UiField HTML message;
-  @UiField Button edit;
-  @UiField Button discard1;
-
-  @UiField Element p_edit;
-  @UiField NpTextArea editArea;
-  @UiField Button save;
-  @UiField Button cancel;
-  @UiField Button discard2;
-
-  DraftBox(
-      CommentGroup group,
-      CommentLinkProcessor clp,
-      @Nullable Project.NameKey pj,
-      PatchSet.Id id,
-      CommentInfo info,
-      boolean expandAllComments) {
-    super(group, info.range());
-
-    linkProcessor = clp;
-    psId = id;
-    project = pj;
-    expandAll = expandAllComments;
-    initWidget(uiBinder.createAndBindUi(this));
-
-    expandTimer =
-        new Timer() {
-          @Override
-          public void run() {
-            expandText();
-          }
-        };
-    set(info);
-
-    header.addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            if (!isEdit()) {
-              if (autoClosed && !isOpen()) {
-                setOpen(true);
-                setEdit(true);
-              } else {
-                setOpen(!isOpen());
-              }
-            }
-          }
-        },
-        ClickEvent.getType());
-
-    addDomHandler(
-        new DoubleClickHandler() {
-          @Override
-          public void onDoubleClick(DoubleClickEvent event) {
-            if (isEdit()) {
-              editArea.setFocus(true);
-            } else {
-              setOpen(true);
-              setEdit(true);
-            }
-          }
-        },
-        DoubleClickEvent.getType());
-
-    initResizeHandler();
-  }
-
-  private void set(CommentInfo info) {
-    autoClosed = !expandAll && info.message() != null && info.message().length() < 70;
-    date.setInnerText(FormatUtil.shortFormatDayTime(info.updated()));
-    if (info.message() != null) {
-      String msg = info.message().trim();
-      summary.setInnerText(msg);
-      message.setHTML(linkProcessor.apply(new SafeHtmlBuilder().append(msg).wikify()));
-    }
-    comment = info;
-  }
-
-  @Override
-  CommentInfo getCommentInfo() {
-    return comment;
-  }
-
-  @Override
-  boolean isOpen() {
-    return UIObject.isVisible(p_view);
-  }
-
-  @Override
-  void setOpen(boolean open) {
-    UIObject.setVisible(summary, !open);
-    UIObject.setVisible(p_view, open);
-    super.setOpen(open);
-  }
-
-  private void expandText() {
-    double cols = editArea.getCharacterWidth();
-    int rows = 2;
-    for (String line : editArea.getValue().split("\n")) {
-      rows += Math.ceil((1.0 + line.length()) / cols);
-    }
-    rows = Math.max(INITIAL_LINES, Math.min(rows, MAX_LINES));
-    if (editArea.getVisibleLines() != rows) {
-      editArea.setVisibleLines(rows);
-    }
-    editAreaHeight = editArea.getOffsetHeight();
-    getCommentGroup().resize();
-  }
-
-  boolean isEdit() {
-    return UIObject.isVisible(p_edit);
-  }
-
-  void setEdit(boolean edit) {
-    UIObject.setVisible(summary, false);
-    UIObject.setVisible(p_view, !edit);
-    UIObject.setVisible(p_edit, edit);
-
-    setRangeHighlight(edit);
-    if (edit) {
-      String msg = comment.message() != null ? comment.message() : "";
-      editArea.setValue(msg);
-      cancel.setVisible(!isNew());
-      expandText();
-      editAreaHeight = editArea.getOffsetHeight();
-
-      final int len = msg.length();
-      Scheduler.get()
-          .scheduleDeferred(
-              new ScheduledCommand() {
-                @Override
-                public void execute() {
-                  editArea.setFocus(true);
-                  if (len > 0) {
-                    editArea.setCursorPos(len);
-                  }
-                }
-              });
-    } else {
-      expandTimer.cancel();
-      resizeTimer.cancel();
-    }
-    getCommentManager().setUnsaved(this, edit);
-    getCommentGroup().resize();
-  }
-
-  PublishedBox getReplyToBox() {
-    return replyToBox;
-  }
-
-  void setReplyToBox(PublishedBox box) {
-    replyToBox = box;
-  }
-
-  @Override
-  protected void onUnload() {
-    expandTimer.cancel();
-    resizeTimer.cancel();
-    super.onUnload();
-  }
-
-  private void removeUI() {
-    if (replyToBox != null) {
-      replyToBox.unregisterReplyBox();
-    }
-
-    getCommentManager().setUnsaved(this, false);
-    setRangeHighlight(false);
-    clearRange();
-    getAnnotation().remove();
-    getCommentGroup().remove(this);
-    getCm().focus();
-  }
-
-  private void restoreSelection() {
-    if (getFromTo() != null && comment.inReplyTo() == null) {
-      getCm().setSelection(getFromTo().from(), getFromTo().to());
-    }
-  }
-
-  @UiHandler("message")
-  void onMessageClick(ClickEvent e) {
-    e.stopPropagation();
-  }
-
-  @UiHandler("message")
-  void onMessageDoubleClick(@SuppressWarnings("unused") DoubleClickEvent e) {
-    setEdit(true);
-  }
-
-  @UiHandler("edit")
-  void onEdit(ClickEvent e) {
-    e.stopPropagation();
-    setEdit(true);
-  }
-
-  @UiHandler("save")
-  void onSave(ClickEvent e) {
-    e.stopPropagation();
-    CallbackGroup group = new CallbackGroup();
-    save(group);
-    group.done();
-  }
-
-  void save(CallbackGroup group) {
-    if (pendingGroup != null) {
-      pendingGroup.addListener(group);
-      return;
-    }
-
-    String message = editArea.getValue().trim();
-    if (message.length() == 0) {
-      return;
-    }
-
-    CommentInfo input = CommentInfo.copy(comment);
-    input.message(message);
-    enableEdit(false);
-
-    pendingGroup = group;
-    final LocalComments lc = new LocalComments(project, psId);
-    GerritCallback<CommentInfo> cb =
-        new GerritCallback<CommentInfo>() {
-          @Override
-          public void onSuccess(CommentInfo result) {
-            enableEdit(true);
-            pendingGroup = null;
-            set(result);
-            setEdit(false);
-            if (autoClosed) {
-              setOpen(false);
-            }
-            getCommentManager().setUnsaved(DraftBox.this, false);
-          }
-
-          @Override
-          public void onFailure(Throwable e) {
-            enableEdit(true);
-            pendingGroup = null;
-            if (RestApi.isNotSignedIn(e)) {
-              CommentInfo saved = CommentInfo.copy(comment);
-              saved.message(editArea.getValue().trim());
-              lc.setInlineComment(saved);
-            }
-            super.onFailure(e);
-          }
-        };
-    if (input.id() == null) {
-      CommentApi.createDraft(Project.NameKey.asStringOrNull(project), psId, input, group.add(cb));
-    } else {
-      CommentApi.updateDraft(
-          Project.NameKey.asStringOrNull(project), psId, input.id(), input, group.add(cb));
-    }
-    CodeMirror cm = getCm();
-    cm.vim().handleKey("<Esc>");
-    cm.focus();
-  }
-
-  private void enableEdit(boolean on) {
-    editArea.setEnabled(on);
-    save.setEnabled(on);
-    cancel.setEnabled(on);
-    discard2.setEnabled(on);
-  }
-
-  @UiHandler("cancel")
-  void onCancel(ClickEvent e) {
-    e.stopPropagation();
-    if (isNew() && !isDirty()) {
-      removeUI();
-      restoreSelection();
-    } else {
-      setEdit(false);
-      if (autoClosed) {
-        setOpen(false);
-      }
-      getCm().focus();
-    }
-  }
-
-  @UiHandler({"discard1", "discard2"})
-  void onDiscard(ClickEvent e) {
-    e.stopPropagation();
-    if (isNew()) {
-      removeUI();
-      restoreSelection();
-    } else {
-      setEdit(false);
-      pendingGroup = new CallbackGroup();
-      CommentApi.deleteDraft(
-          Project.NameKey.asStringOrNull(project),
-          psId,
-          comment.id(),
-          pendingGroup.addFinal(
-              new GerritCallback<JavaScriptObject>() {
-                @Override
-                public void onSuccess(JavaScriptObject result) {
-                  pendingGroup = null;
-                  removeUI();
-                }
-              }));
-    }
-  }
-
-  @UiHandler("editArea")
-  void onKeyDown(KeyDownEvent e) {
-    resizeTimer.cancel();
-    if ((e.isControlKeyDown() || e.isMetaKeyDown()) && !e.isAltKeyDown() && !e.isShiftKeyDown()) {
-      switch (e.getNativeKeyCode()) {
-        case 's':
-        case 'S':
-          e.preventDefault();
-          CallbackGroup group = new CallbackGroup();
-          save(group);
-          group.done();
-          return;
-      }
-    } else if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE && !isDirty()) {
-      if (isNew()) {
-        removeUI();
-        restoreSelection();
-        return;
-      }
-      setEdit(false);
-      if (autoClosed) {
-        setOpen(false);
-      }
-      getCm().focus();
-      return;
-    }
-    expandTimer.schedule(250);
-  }
-
-  @UiHandler("editArea")
-  void onBlur(@SuppressWarnings("unused") BlurEvent e) {
-    resizeTimer.cancel();
-  }
-
-  private void initResizeHandler() {
-    resizeTimer =
-        new Timer() {
-          @Override
-          public void run() {
-            getCommentGroup().resize();
-          }
-        };
-
-    addDomHandler(
-        new MouseMoveHandler() {
-          @Override
-          public void onMouseMove(MouseMoveEvent event) {
-            int h = editArea.getOffsetHeight();
-            if (isEdit() && h != editAreaHeight) {
-              getCommentGroup().resize();
-              resizeTimer.scheduleRepeating(50);
-              editAreaHeight = h;
-            }
-          }
-        },
-        MouseMoveEvent.getType());
-
-    addDomHandler(
-        new MouseUpHandler() {
-          @Override
-          public void onMouseUp(MouseUpEvent event) {
-            resizeTimer.cancel();
-            getCommentGroup().resize();
-          }
-        },
-        MouseUpEvent.getType());
-  }
-
-  private boolean isNew() {
-    return comment.id() == null;
-  }
-
-  private boolean isDirty() {
-    String msg = editArea.getValue().trim();
-    if (isNew()) {
-      return msg.length() > 0;
-    }
-    return !msg.equals(comment.message() != null ? comment.message().trim() : "");
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.ui.xml
deleted file mode 100644
index a363c06..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.ui.xml
+++ /dev/null
@@ -1,96 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gerrit.client'
-    xmlns:e='urn:import:com.google.gwtexpui.globalkey.client'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.diff.Resources'/>
-  <ui:style gss='false'>
-    .draft {
-      width: 45px;
-      text-align: center;
-      color: #fff;
-      background-color: #aaa;
-      -webkit-border-radius: 2px;
-    }
-    .editArea { max-width: 637px; }
-    button.button div {
-      width: 35px;
-    }
-    button.discard {
-      color: #d14836;
-      background-color: #d14836;
-      background-image: -webkit-linear-gradient(top, #d14836, #d14836);
-      position: absolute;
-      left: 150px;
-    }
-  </ui:style>
-
-  <g:HTMLPanel styleName='{res.style.commentBox}'>
-    <div class='{res.style.contents}'>
-      <g:HTMLPanel ui:field='header' styleName='{res.style.header}'>
-        <div class='{style.draft}'>Draft</div>
-        <div ui:field='summary' class='{res.style.summary}'/>
-        <div ui:field='date' class='{res.style.date}'/>
-      </g:HTMLPanel>
-      <div ui:field='p_view' aria-hidden='true' style='display: NONE'>
-        <g:HTML ui:field='message' styleName='{res.style.message}'/>
-        <div style='position: relative'>
-          <g:Button ui:field='edit'
-              title='Edit this draft comment'
-              styleName='{style.button}'>
-            <ui:attribute name='title'/>
-            <div><ui:msg>Edit</ui:msg></div>
-          </g:Button>
-          <g:Button ui:field='discard1'
-              title='Discard this draft comment'
-              styleName='{style.button}'
-              addStyleNames='{style.discard}'>
-            <ui:attribute name='title'/>
-            <div><ui:msg>Discard</ui:msg></div>
-          </g:Button>
-        </div>
-      </div>
-      <div ui:field='p_edit' aria-hidden='true' style='display: NONE'>
-        <e:NpTextArea ui:field='editArea'
-            characterWidth='60'
-            visibleLines='5'
-            spellCheck='true'
-            styleName='{style.editArea}'/>
-        <div style='position: relative'>
-          <g:Button ui:field='save'
-              title='Save this draft comment'
-              styleName='{style.button}'>
-            <ui:attribute name='title'/>
-            <div><ui:msg>Save</ui:msg></div>
-          </g:Button>
-          <g:Button ui:field='cancel' styleName='{style.button}'>
-            <div><ui:msg>Cancel</ui:msg></div>
-          </g:Button>
-          <g:Button ui:field='discard2'
-              title='Discard this draft comment'
-              styleName='{style.button}'
-              addStyleNames='{style.discard}'>
-            <ui:attribute name='title'/>
-            <div><ui:msg>Discard</ui:msg></div>
-          </g:Button>
-        </div>
-      </div>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/EditIterator.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/EditIterator.java
deleted file mode 100644
index 4cf78c7..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/EditIterator.java
+++ /dev/null
@@ -1,71 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gwt.core.client.JsArrayString;
-import net.codemirror.lib.Pos;
-
-/** An iterator for intraline edits */
-class EditIterator {
-  private final JsArrayString lines;
-  private final int startLine;
-  private int line;
-  private int pos;
-
-  EditIterator(JsArrayString lineArray, int start) {
-    lines = lineArray;
-    startLine = start;
-  }
-
-  Pos advance(int numOfChar) {
-    numOfChar = adjustForNegativeDelta(numOfChar);
-
-    while (line < lines.length()) {
-      int len = lines.get(line).length() - pos + 1; // + 1 for LF
-      if (numOfChar < len) {
-        Pos at = Pos.create(startLine + line, numOfChar + pos);
-        pos += numOfChar;
-        return at;
-      }
-
-      numOfChar -= len;
-      line++;
-      pos = 0;
-
-      if (numOfChar == 0) {
-        return Pos.create(startLine + line, 0);
-      }
-    }
-
-    throw new IllegalStateException("EditIterator index out of bounds");
-  }
-
-  private int adjustForNegativeDelta(int n) {
-    while (n < 0) {
-      if (-n <= pos) {
-        pos += n;
-        return 0;
-      }
-
-      n += pos;
-      line--;
-      if (line < 0) {
-        throw new IllegalStateException("EditIterator index out of bounds");
-      }
-      pos = lines.get(line).length() + 1;
-    }
-    return n;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
deleted file mode 100644
index 7a97df1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
+++ /dev/null
@@ -1,367 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.DiffPreferences;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ReviewInfo;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.diff.DiffInfo.Region;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.FileInfo;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Visibility;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.List;
-
-public class Header extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, Header> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  static {
-    Resources.I.style().ensureInjected();
-  }
-
-  private enum ReviewedState {
-    AUTO_REVIEW,
-    LOADED
-  }
-
-  @UiField CheckBox reviewed;
-  @UiField Element project;
-  @UiField Element filePath;
-  @UiField Element fileNumber;
-  @UiField Element fileCount;
-
-  @UiField Element noDiff;
-  @UiField FlowPanel linkPanel;
-
-  @UiField InlineHyperlink prev;
-  @UiField InlineHyperlink up;
-  @UiField InlineHyperlink next;
-  @UiField Image preferences;
-
-  private final KeyCommandSet keys;
-  @Nullable private final Project.NameKey projectKey;
-  private final DiffObject base;
-  private final PatchSet.Id patchSetId;
-  private final String path;
-  private final DiffView diffScreenType;
-  private final DiffPreferences prefs;
-  private boolean hasPrev;
-  private boolean hasNext;
-  private String nextPath;
-  private JsArray<FileInfo> files;
-  private PreferencesAction prefsAction;
-  private ReviewedState reviewedState;
-
-  Header(
-      KeyCommandSet keys,
-      @Nullable Project.NameKey project,
-      DiffObject base,
-      DiffObject patchSetId,
-      String path,
-      DiffView diffSreenType,
-      DiffPreferences prefs) {
-    initWidget(uiBinder.createAndBindUi(this));
-    this.keys = keys;
-    this.projectKey = project;
-    this.base = base;
-    this.patchSetId = patchSetId.asPatchSetId();
-    this.path = path;
-    this.diffScreenType = diffSreenType;
-    this.prefs = prefs;
-
-    if (!Gerrit.isSignedIn()) {
-      reviewed.getElement().getStyle().setVisibility(Visibility.HIDDEN);
-    }
-    SafeHtml.setInnerHTML(filePath, formatPath(path));
-    up.setTargetHistoryToken(
-        PageLinks.toChange(
-            project,
-            patchSetId.asPatchSetId().getParentKey(),
-            base.asString(),
-            patchSetId.asPatchSetId().getId()));
-  }
-
-  public static SafeHtml formatPath(String path) {
-    SafeHtmlBuilder b = new SafeHtmlBuilder();
-    if (Patch.COMMIT_MSG.equals(path)) {
-      return b.append(Util.C.commitMessage());
-    } else if (Patch.MERGE_LIST.equals(path)) {
-      return b.append(Util.C.mergeList());
-    }
-
-    int s = path.lastIndexOf('/') + 1;
-    b.append(path.substring(0, s));
-    b.openElement("b");
-    b.append(path.substring(s));
-    b.closeElement("b");
-    return b;
-  }
-
-  private int findCurrentFileIndex(JsArray<FileInfo> files) {
-    int currIndex = 0;
-    for (int i = 0; i < files.length(); i++) {
-      if (path.equals(files.get(i).path())) {
-        currIndex = i;
-        break;
-      }
-    }
-    return currIndex;
-  }
-
-  @Override
-  protected void onLoad() {
-    DiffApi.list(
-        Project.NameKey.asStringOrNull(projectKey),
-        patchSetId,
-        base.asPatchSetId(),
-        new GerritCallback<NativeMap<FileInfo>>() {
-          @Override
-          public void onSuccess(NativeMap<FileInfo> result) {
-            files = result.values();
-            FileInfo.sortFileInfoByPath(files);
-            fileNumber.setInnerText(
-                Integer.toString(Natives.asList(files).indexOf(result.get(path)) + 1));
-            fileCount.setInnerText(Integer.toString(files.length()));
-          }
-        });
-
-    if (Gerrit.isSignedIn()) {
-      ChangeApi.revision(Project.NameKey.asStringOrNull(projectKey), patchSetId)
-          .view("files")
-          .addParameterTrue("reviewed")
-          .get(
-              new AsyncCallback<JsArrayString>() {
-                @Override
-                public void onSuccess(JsArrayString result) {
-                  boolean b = Natives.asList(result).contains(path);
-                  reviewed.setValue(b, false);
-                  if (!b && reviewedState == ReviewedState.AUTO_REVIEW) {
-                    postAutoReviewed();
-                  }
-                  reviewedState = ReviewedState.LOADED;
-                }
-
-                @Override
-                public void onFailure(Throwable caught) {}
-              });
-    }
-  }
-
-  void autoReview() {
-    if (reviewedState == ReviewedState.LOADED && !reviewed.getValue()) {
-      postAutoReviewed();
-    } else {
-      reviewedState = ReviewedState.AUTO_REVIEW;
-    }
-  }
-
-  void setChangeInfo(ChangeInfo info) {
-    project.setInnerText(info.project());
-  }
-
-  void init(PreferencesAction pa, List<InlineHyperlink> links, List<WebLinkInfo> webLinks) {
-    prefsAction = pa;
-    prefsAction.setPartner(preferences);
-
-    for (InlineHyperlink link : links) {
-      linkPanel.add(link);
-    }
-    for (WebLinkInfo webLink : webLinks) {
-      linkPanel.add(webLink.toAnchor());
-    }
-  }
-
-  @UiHandler("reviewed")
-  void onValueChange(ValueChangeEvent<Boolean> event) {
-    if (event.getValue()) {
-      reviewed().put(CallbackGroup.<ReviewInfo>emptyCallback());
-    } else {
-      reviewed().delete(CallbackGroup.<ReviewInfo>emptyCallback());
-    }
-  }
-
-  private void postAutoReviewed() {
-    reviewed()
-        .background()
-        .put(
-            new AsyncCallback<ReviewInfo>() {
-              @Override
-              public void onSuccess(ReviewInfo result) {
-                reviewed.setValue(true, false);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {}
-            });
-  }
-
-  private RestApi reviewed() {
-    return ChangeApi.revision(Project.NameKey.asStringOrNull(projectKey), patchSetId)
-        .view("files")
-        .id(path)
-        .view("reviewed");
-  }
-
-  @UiHandler("preferences")
-  void onPreferences(@SuppressWarnings("unused") ClickEvent e) {
-    prefsAction.show();
-  }
-
-  private String url(FileInfo info) {
-    return diffScreenType == DiffView.UNIFIED_DIFF
-        ? Dispatcher.toUnified(projectKey, base, patchSetId, info.path())
-        : Dispatcher.toSideBySide(projectKey, base, patchSetId, info.path());
-  }
-
-  private KeyCommand setupNav(InlineHyperlink link, char key, String help, FileInfo info) {
-    if (info != null) {
-      final String url = url(info);
-      link.setTargetHistoryToken(url);
-      link.setTitle(
-          PatchUtil.M.fileNameWithShortcutKey(
-              FileInfo.getFileName(info.path()), Character.toString(key)));
-      KeyCommand k =
-          new KeyCommand(0, key, help) {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              Gerrit.display(url);
-            }
-          };
-      keys.add(k);
-      if (link == prev) {
-        hasPrev = true;
-      } else {
-        hasNext = true;
-      }
-      return k;
-    }
-    link.getElement().getStyle().setVisibility(Visibility.HIDDEN);
-    keys.add(new UpToChangeCommand(projectKey, patchSetId, 0, key));
-    return null;
-  }
-
-  private boolean shouldSkipFile(FileInfo curr, CommentsCollections comments) {
-    return prefs.skipDeleted() && ChangeType.DELETED.matches(curr.status())
-        || prefs.skipUnchanged() && ChangeType.RENAMED.matches(curr.status())
-        || prefs.skipUncommented() && !comments.hasCommentForPath(curr.path());
-  }
-
-  void setupPrevNextFiles(CommentsCollections comments) {
-    FileInfo prevInfo = null;
-    FileInfo nextInfo = null;
-    int currIndex = findCurrentFileIndex(files);
-    for (int i = currIndex - 1; i >= 0; i--) {
-      FileInfo curr = files.get(i);
-      if (shouldSkipFile(curr, comments)) {
-        continue;
-      }
-      prevInfo = curr;
-      break;
-    }
-    for (int i = currIndex + 1; i < files.length(); i++) {
-      FileInfo curr = files.get(i);
-      if (shouldSkipFile(curr, comments)) {
-        continue;
-      }
-      nextInfo = curr;
-      break;
-    }
-    KeyCommand p = setupNav(prev, '[', PatchUtil.C.previousFileHelp(), prevInfo);
-    KeyCommand n = setupNav(next, ']', PatchUtil.C.nextFileHelp(), nextInfo);
-    if (p != null && n != null) {
-      keys.pair(p, n);
-    }
-    nextPath = nextInfo != null ? nextInfo.path() : null;
-  }
-
-  Runnable toggleReviewed() {
-    return () -> reviewed.setValue(!reviewed.getValue(), true);
-  }
-
-  Runnable navigate(Direction dir) {
-    switch (dir) {
-      case PREV:
-        return () -> (hasPrev ? prev : up).go();
-      case NEXT:
-        return () -> (hasNext ? next : up).go();
-      default:
-        return () -> {};
-    }
-  }
-
-  Runnable reviewedAndNext() {
-    return () -> {
-      if (Gerrit.isSignedIn()) {
-        reviewed.setValue(true, true);
-      }
-      navigate(Direction.NEXT).run();
-    };
-  }
-
-  String getNextPath() {
-    return nextPath;
-  }
-
-  void setNoDiff(DiffInfo diff) {
-    if (diff.binary()) {
-      UIObject.setVisible(noDiff, false); // Don't bother showing "No Differences"
-    } else {
-      JsArray<Region> regions = diff.content();
-      boolean b = regions.length() == 0 || (regions.length() == 1 && regions.get(0).ab() != null);
-      UIObject.setVisible(noDiff, b);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml
deleted file mode 100644
index 39eb6cb..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml
+++ /dev/null
@@ -1,95 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:x='urn:import:com.google.gerrit.client.ui'>
-  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-  <ui:with field='res' type='com.google.gerrit.client.diff.Resources'/>
-  <ui:style gss='false'>
-  .header {
-    position: relative;
-    height: 16px;
-    line-height: 16px;
-  }
-  .reviewed input {
-    margin: 0;
-    padding: 0;
-    vertical-align: middle;
-  }
-  .path {
-    white-space: nowrap;
-  }
-  .fileCount {
-    white-space: nowrap;
-    position: relative;
-    bottom: 4px;
-  }
-  .navigation {
-    position: absolute;
-    top: 0;
-    right: 10px;
-    height: 16px;
-    line-height: 16px;
-  }
-  .nodiff {
-    white-space: nowrap;
-    color: #B00000;
-    vertical-align: top;
-    font-weight: bold;
-    margin-right: 1em;
-    float: left;
-  }
-  .linkPanel {
-    float: left;
-  }
-  .linkPanel img {
-    padding-right: 3px;
-  }
-  .preferences {
-    cursor: pointer;
-  }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.header}'>
-    <g:CheckBox ui:field='reviewed'
-        styleName='{style.reviewed}'
-        title='Mark file as reviewed (Shortcut: r)'>
-      <ui:attribute name='title'/>
-    </g:CheckBox>
-    <span class='{style.path}'><span ui:field='project'/> / <span ui:field='filePath'/></span>
-    <div class='{style.navigation}'>
-      <span ui:field='noDiff' class='{style.nodiff}'><ui:msg>No Differences</ui:msg></span>
-      <g:FlowPanel ui:field='linkPanel' styleName='{style.linkPanel}'/>
-      <span class='{style.fileCount}'>
-        <ui:msg>File <span ui:field='fileNumber'/> of <span ui:field='fileCount'/></ui:msg>
-      </span>
-      <x:InlineHyperlink ui:field='prev' styleName='{res.style.goPrev}'/>
-      <x:InlineHyperlink ui:field='up'
-          styleName='{res.style.goUp}'
-          title='Up to change (Shortcut: u)'>
-        <ui:attribute name='title'/>
-      </x:InlineHyperlink>
-      <x:InlineHyperlink ui:field='next' styleName='{res.style.goNext}'/>
-      <g:Image ui:field='preferences'
-           styleName='{style.preferences}'
-           resource='{ico.gear}'
-           title='Diff preferences (Shortcut: ,)'>
-         <ui:attribute name='title'/>
-      </g:Image>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java
deleted file mode 100644
index f8eab91..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java
+++ /dev/null
@@ -1,62 +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.client.diff;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Style;
-import com.google.gwt.dom.client.Style.Unit;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Rect;
-
-/** Bubble displayed near a selected region to create a comment. */
-class InsertCommentBubble extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, InsertCommentBubble> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField Image icon;
-
-  InsertCommentBubble(CommentManager commentManager, CodeMirror cm) {
-    initWidget(uiBinder.createAndBindUi(this));
-    addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            setVisible(false);
-            commentManager.newDraftCallback(cm).run();
-          }
-        },
-        ClickEvent.getType());
-  }
-
-  void position(Rect r) {
-    Style s = getElement().getStyle();
-    int top = (int) (r.top() - (getOffsetHeight() - 8));
-    if (top < 0) {
-      s.setTop(-3, Unit.PX);
-      s.setLeft(r.right() + 2, Unit.PX);
-    } else {
-      s.setTop(top, Unit.PX);
-      s.setLeft((int) (r.right() - 14), Unit.PX);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.ui.xml
deleted file mode 100644
index 6a18c4d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.ui.xml
+++ /dev/null
@@ -1,51 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-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.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.GerritResources'/>
-  <ui:style gss='false'>
-    .bubble {
-      z-index: 150;
-      white-space: nowrap;
-      line-height: 16px;
-      cursor: pointer;
-    }
-    .message {
-      background: #fff1a8;
-      padding-left: 5px;
-      padding-right: 5px;
-      border-radius: 5px;
-      border: 1px solid #aaa;
-      font-family: sans-serif;
-      font-size: smaller;
-      font-style: italic;
-      vertical-align: top;
-    }
-    .message b {
-      vertical-align: top;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.bubble}'>
-    <g:Image ui:field='icon'
-        styleName=''
-        resource='{res.draftComments}'
-        title='Create a new inline comment'>
-      <ui:attribute name='title'/>
-    </g:Image><span class='{style.message}'><ui:msg>press <b>c</b> to comment</ui:msg></span>
-  </g:HTMLPanel>
-</ui:UiBinder>
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
deleted file mode 100644
index fc83a14..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
+++ /dev/null
@@ -1,225 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Objects;
-
-/** Helper class to handle calculations involving line gaps. */
-class LineMapper {
-  private int lineA;
-  private int lineB;
-  private List<LineGap> lineMapAtoB;
-  private List<LineGap> lineMapBtoA;
-
-  LineMapper() {
-    reset();
-  }
-
-  void reset() {
-    lineA = 0;
-    lineB = 0;
-    lineMapAtoB = new ArrayList<>();
-    lineMapBtoA = new ArrayList<>();
-  }
-
-  int getLineA() {
-    return lineA;
-  }
-
-  int getLineB() {
-    return lineB;
-  }
-
-  void appendCommon(int numLines) {
-    lineA += numLines;
-    lineB += numLines;
-  }
-
-  void appendReplace(int aLen, int bLen) {
-    appendCommon(Math.min(aLen, bLen));
-    if (aLen < bLen) { // Edit with insertion
-      appendInsert(bLen - aLen);
-    } else if (aLen > bLen) { // Edit with deletion
-      appendDelete(aLen - bLen);
-    }
-  }
-
-  void appendInsert(int numLines) {
-    int origLineB = lineB;
-    lineB += numLines;
-    int bAheadOfA = lineB - lineA;
-    lineMapAtoB.add(new LineGap(lineA, -1, bAheadOfA));
-    lineMapBtoA.add(new LineGap(origLineB, lineB - 1, -bAheadOfA));
-  }
-
-  void appendDelete(int numLines) {
-    int origLineA = lineA;
-    lineA += numLines;
-    int aAheadOfB = lineA - lineB;
-    lineMapAtoB.add(new LineGap(origLineA, lineA - 1, -aAheadOfB));
-    lineMapBtoA.add(new LineGap(lineB, -1, aAheadOfB));
-  }
-
-  /**
-   * Helper method to retrieve the line number on the other side.
-   *
-   * <p>Given a line number on one side, performs a binary search in the lineMap to find the
-   * corresponding LineGap record.
-   *
-   * <p>A LineGap records gap information from the start of an actual gap up to the start of the
-   * next gap. In the following example, lineMapAtoB will have LineGap: {start: 1, end: -1, delta:
-   * 3} (end set to -1 to represent a dummy gap of length zero. The binary search only looks at
-   * start so setting it to -1 has no effect here.) lineMapBtoA will have LineGap: {start: 1, end:
-   * 3, delta: -3} These LineGaps control lines between 1 and 5.
-   *
-   * <p>The "delta" is computed as the number to add on our side to get the line number on the other
-   * side given a line after the actual gap, so the result will be (line + delta). All lines within
-   * the actual gap (1 to 3) are considered corresponding to the last line above the region on the
-   * other side, which is 0 in this case. For these lines, we do (end + delta).
-   *
-   * <p>For example, to get the line number on the left corresponding to 1 on the right
-   * (lineOnOther(REVISION, 1)), the method looks up in lineMapBtoA, finds the "delta" to be -3, and
-   * returns 3 + (-3) = 0 since 1 falls in the actual gap. On the other hand, the line corresponding
-   * to 5 on the right will be 5 + (-3) = 2, since 5 is in the region after the gap (but still
-   * controlled by the current LineGap).
-   *
-   * <p>PARENT REVISION 0 | 0 - | 1 \ \ - | 2 | Actual insertion gap | - | 3 / | Region controlled
-   * by one LineGap 1 | 4 <- delta = 4 - 1 = 3 | 2 | 5 / - | 6 ...
-   */
-  LineOnOtherInfo lineOnOther(DisplaySide mySide, int line) {
-    List<LineGap> lineGaps = gapList(mySide);
-    // Create a dummy LineGap for the search.
-    int ret = Collections.binarySearch(lineGaps, new LineGap(line));
-    if (ret == -1) {
-      return new LineOnOtherInfo(line, true);
-    }
-    LineGap lookup = lineGaps.get(0 <= ret ? ret : -ret - 2);
-    int start = lookup.start;
-    int end = lookup.end;
-    int delta = lookup.delta;
-    if (start <= line && line <= end && end != -1) { // Line falls within gap
-      return new LineOnOtherInfo(end + delta, false);
-    }
-    // Line after gap
-    return new LineOnOtherInfo(line + delta, true);
-  }
-
-  AlignedPair align(DisplaySide mySide, int line) {
-    List<LineGap> gaps = gapList(mySide);
-    int idx = Collections.binarySearch(gaps, new LineGap(line));
-    if (idx == -1) {
-      return new AlignedPair(line, line);
-    }
-
-    LineGap g = gaps.get(0 <= idx ? idx : -idx - 2);
-    if (g.start <= line && line <= g.end && g.end != -1) {
-      if (0 < g.start) {
-        // Line falls within this gap, use alignment before.
-        return new AlignedPair(g.start - 1, g.end + g.delta);
-      }
-      return new AlignedPair(g.end, g.end + g.delta + 1);
-    }
-    return new AlignedPair(line, line + g.delta);
-  }
-
-  private List<LineGap> gapList(DisplaySide mySide) {
-    return mySide == DisplaySide.A ? lineMapAtoB : lineMapBtoA;
-  }
-
-  static class AlignedPair {
-    final int src;
-    final int dst;
-
-    AlignedPair(int s, int d) {
-      src = s;
-      dst = d;
-    }
-  }
-
-  /**
-   * @field line The line number on the other side.
-   * @field aligned Whether the two lines are at the same height when displayed.
-   */
-  static class LineOnOtherInfo {
-    private int line;
-    private boolean aligned;
-
-    LineOnOtherInfo(int line, boolean aligned) {
-      this.line = line;
-      this.aligned = aligned;
-    }
-
-    int getLine() {
-      return line;
-    }
-
-    boolean isAligned() {
-      return aligned;
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (obj instanceof LineOnOtherInfo) {
-        LineOnOtherInfo other = (LineOnOtherInfo) obj;
-        return aligned == other.aligned && line == other.line;
-      }
-      return false;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(line, aligned);
-    }
-
-    @Override
-    public String toString() {
-      return line + " " + aligned;
-    }
-  }
-
-  /**
-   * Helper class to record line gap info and assist in calculation of line number on the other
-   * side.
-   *
-   * <p>For a mapping from A to B, where A is the side with an insertion:
-   *
-   * @field start The start line of the insertion in A.
-   * @field end The exclusive end line of the insertion in A.
-   * @field delta The offset added to A to get the line number in B calculated from end.
-   */
-  private static class LineGap implements Comparable<LineGap> {
-    private final int start;
-    private final int end;
-    private final int delta;
-
-    private LineGap(int start, int end, int delta) {
-      this.start = start;
-      this.end = end;
-      this.delta = delta;
-    }
-
-    private LineGap(int line) {
-      this(line, 0, 0);
-    }
-
-    @Override
-    public int compareTo(LineGap o) {
-      return start - o.start;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java
deleted file mode 100644
index 584232d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-
-/** A KeyCommand that does nothing, used to display a help message */
-class NoOpKeyCommand extends KeyCommand {
-  NoOpKeyCommand(int mask, int key, String help) {
-    super(mask, key, help);
-  }
-
-  @Override
-  public void onKeyPress(KeyPressEvent event) {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
deleted file mode 100644
index 292773c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
+++ /dev/null
@@ -1,240 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.blame.BlameInfo;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.ImageResourceRenderer;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtorm.client.KeyUtil;
-import java.util.List;
-import net.codemirror.lib.CodeMirror;
-
-/** HTMLPanel to select among patch sets */
-class PatchSetSelectBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, PatchSetSelectBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface BoxStyle extends CssResource {
-    String selected();
-  }
-
-  @UiField Image icon;
-  @UiField HTMLPanel linkPanel;
-  @UiField BoxStyle style;
-
-  @Nullable private final Project.NameKey project;
-  private final Change.Id changeId;
-
-  private DiffScreen parent;
-  private DisplaySide side;
-  private boolean sideA;
-  private String path;
-  private PatchSet.Id revision;
-  private DiffObject idActive;
-  private PatchSetSelectBox other;
-
-  PatchSetSelectBox(
-      DiffScreen parent,
-      DisplaySide side,
-      @Nullable Project.NameKey project,
-      Change.Id changeId,
-      DiffObject diffObject,
-      String path) {
-    initWidget(uiBinder.createAndBindUi(this));
-    icon.setTitle(PatchUtil.C.addFileCommentToolTip());
-    icon.addStyleName(Gerrit.RESOURCES.css().link());
-
-    this.parent = parent;
-    this.side = side;
-    this.sideA = side == DisplaySide.A;
-    this.project = project;
-    this.changeId = changeId;
-    this.revision = diffObject.asPatchSetId();
-    this.idActive = diffObject;
-    this.path = path;
-  }
-
-  void setUpPatchSetNav(
-      JsArray<RevisionInfo> list,
-      int parents,
-      DiffInfo.FileMeta meta,
-      boolean editExists,
-      boolean current,
-      boolean open,
-      boolean binary) {
-    InlineHyperlink selectedLink = null;
-    if (sideA) {
-      if (parents <= 1) {
-        InlineHyperlink link = createLink(PatchUtil.C.patchBase(), DiffObject.base());
-        linkPanel.add(link);
-        selectedLink = link;
-      } else {
-        for (int i = parents; i > 0; i--) {
-          PatchSet.Id id = new PatchSet.Id(changeId, -i);
-          InlineHyperlink link = createLink(Util.M.diffBaseParent(i), DiffObject.patchSet(id));
-          linkPanel.add(link);
-          if (revision != null && id.equals(revision)) {
-            selectedLink = link;
-          }
-        }
-        InlineHyperlink link = createLink(Util.C.autoMerge(), DiffObject.autoMerge());
-        linkPanel.add(link);
-        if (selectedLink == null) {
-          selectedLink = link;
-        }
-      }
-    }
-    for (int i = 0; i < list.length(); i++) {
-      RevisionInfo r = list.get(i);
-      InlineHyperlink link =
-          createLink(r.id(), DiffObject.patchSet(new PatchSet.Id(changeId, r._number())));
-      linkPanel.add(link);
-      if (revision != null && r.id().equals(revision.getId())) {
-        selectedLink = link;
-      }
-    }
-    if (selectedLink != null) {
-      selectedLink.setStyleName(style.selected());
-    }
-
-    if (meta == null) {
-      return;
-    }
-    if (!Patch.isMagic(path)) {
-      linkPanel.add(createDownloadLink());
-    }
-    if (!binary && open && !idActive.isBaseOrAutoMerge() && Gerrit.isSignedIn()) {
-      if ((editExists && idActive.isEdit()) || (!editExists && current)) {
-        linkPanel.add(createEditIcon());
-      }
-    }
-    List<WebLinkInfo> webLinks = Natives.asList(meta.webLinks());
-    if (webLinks != null) {
-      for (WebLinkInfo webLink : webLinks) {
-        linkPanel.add(webLink.toAnchor());
-      }
-    }
-  }
-
-  void setUpBlame(final CodeMirror cm, boolean isBase, PatchSet.Id rev, String path) {
-    if (!Patch.isMagic(path) && Gerrit.isSignedIn() && Gerrit.info().change().allowBlame()) {
-      Anchor blameIcon = createBlameIcon();
-      blameIcon.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent clickEvent) {
-              if (cm.extras().getBlameInfo() != null) {
-                cm.extras().toggleAnnotation();
-              } else {
-                ChangeApi.blame(Project.NameKey.asStringOrNull(project), rev, path, isBase)
-                    .get(
-                        new GerritCallback<JsArray<BlameInfo>>() {
-
-                          @Override
-                          public void onSuccess(JsArray<BlameInfo> lines) {
-                            cm.extras().toggleAnnotation(lines);
-                          }
-                        });
-              }
-            }
-          });
-      linkPanel.add(blameIcon);
-    }
-  }
-
-  private Widget createEditIcon() {
-    PatchSet.Id id =
-        idActive.isBaseOrAutoMerge() ? other.idActive.asPatchSetId() : idActive.asPatchSetId();
-    Anchor anchor =
-        new Anchor(
-            new ImageResourceRenderer().render(Gerrit.RESOURCES.edit()),
-            "#" + Dispatcher.toEditScreen(project, id, path));
-    anchor.setTitle(PatchUtil.C.edit());
-    return anchor;
-  }
-
-  private Anchor createBlameIcon() {
-    Anchor anchor = new Anchor(new ImageResourceRenderer().render(Gerrit.RESOURCES.blame()));
-    anchor.setTitle(PatchUtil.C.blame());
-    return anchor;
-  }
-
-  static void link(PatchSetSelectBox a, PatchSetSelectBox b) {
-    a.other = b;
-    b.other = a;
-  }
-
-  private InlineHyperlink createLink(String label, DiffObject id) {
-    assert other != null;
-    if (sideA) {
-      assert !other.idActive.isBaseOrAutoMerge();
-    }
-    DiffObject diffBase = sideA ? id : other.idActive;
-    DiffObject revision = sideA ? other.idActive : id;
-
-    return new InlineHyperlink(
-        label,
-        parent.isSideBySide()
-            ? Dispatcher.toSideBySide(project, diffBase, revision.asPatchSetId(), path)
-            : Dispatcher.toUnified(project, diffBase, revision.asPatchSetId(), path));
-  }
-
-  private Anchor createDownloadLink() {
-    DiffObject diffObject = idActive.isBaseOrAutoMerge() ? other.idActive : idActive;
-    String sideURL = idActive.isBaseOrAutoMerge() ? "1" : "0";
-    String base = GWT.getHostPageBaseURL() + "cat/";
-    Anchor anchor =
-        new Anchor(
-            new ImageResourceRenderer().render(Gerrit.RESOURCES.downloadIcon()),
-            base + KeyUtil.encode(diffObject.asPatchSetId() + "," + path) + "^" + sideURL);
-    anchor.setTitle(PatchUtil.C.download());
-    return anchor;
-  }
-
-  @UiHandler("icon")
-  void onIconClick(@SuppressWarnings("unused") ClickEvent e) {
-    parent.getCmFromSide(side).scrollToY(0);
-    parent.getCommentManager().insertNewDraft(side, 0);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.ui.xml
deleted file mode 100644
index 6e526ec..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.ui.xml
+++ /dev/null
@@ -1,74 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.GerritResources'/>
-  <ui:with field='patchConstants'
-      type='com.google.gerrit.client.patches.PatchConstants'/>
-  <ui:style gss='false' type='com.google.gerrit.client.diff.PatchSetSelectBox.BoxStyle'>
-    @eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-    @eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
-    .table {
-      width: 100%;
-    }
-    .linkCell {
-      text-align: center;
-      font-size: 12px;
-      white-space: normal;
-      font-family: sans-serif;
-      font-weight: bold;
-    }
-    .linkCell div {
-      padding-left: 3px;
-      padding-right: 3px;
-      vertical-align: middle;
-      display: inline-block;
-    }
-    .linkCell a {
-      padding-left: 3px;
-      padding-right: 3px;
-      text-decoration: none;
-      vertical-align: middle;
-      display: inline-block;
-    }
-    .selected {
-      font-weight: bold;
-      background-color: selectionColor;
-    }
-    .hidden {
-      visibility: hidden;
-    }
-    .iconCell {
-      width: 16px;
-    }
-  </ui:style>
-  <g:HTMLPanel>
-    <table class='{style.table}'>
-      <td class='{style.iconCell}'>
-        <g:Image ui:field='icon' resource='{res.addFileComment}'/>
-      </td>
-      <td class='{style.linkCell}'>
-        <g:HTMLPanel ui:field='linkPanel'>
-          <g:Label>
-            <ui:text from='{patchConstants.patchSet}'/>
-          </g:Label>
-        </g:HTMLPanel>
-      </td>
-    </table>
-  </g:HTMLPanel>
-</ui:UiBinder>
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java
deleted file mode 100644
index 2d4a4c4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.account.DiffPreferences;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.PopupPanel.PositionCallback;
-import com.google.gwt.user.client.ui.Widget;
-
-class PreferencesAction {
-  private final DiffScreen view;
-  private final DiffPreferences prefs;
-  private PopupPanel popup;
-  private PreferencesBox current;
-  private Widget partner;
-
-  PreferencesAction(DiffScreen view, DiffPreferences prefs) {
-    this.view = view;
-    this.prefs = prefs;
-  }
-
-  void update() {
-    if (current != null) {
-      current.set(prefs);
-    }
-  }
-
-  void show() {
-    if (popup != null) {
-      // Already open? Close the dialog.
-      hide();
-      return;
-    }
-
-    current = new PreferencesBox(view);
-    current.set(prefs);
-
-    popup = new PopupPanel(true, false);
-    popup.setStyleName(current.style.dialog());
-    popup.add(current);
-    popup.addAutoHidePartner(partner.getElement());
-    popup.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            view.getCmFromSide(DisplaySide.B).focus();
-            popup = null;
-            current = null;
-          }
-        });
-    popup.setPopupPositionAndShow(
-        new PositionCallback() {
-          @Override
-          public void setPosition(int offsetWidth, int offsetHeight) {
-            popup.setPopupPosition(300, 120);
-          }
-        });
-    current.setFocus(true);
-  }
-
-  void hide() {
-    if (popup != null) {
-      popup.hide();
-      popup = null;
-      current = null;
-    }
-  }
-
-  void setPartner(Widget w) {
-    partner = w;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
deleted file mode 100644
index ed4ac25..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
+++ /dev/null
@@ -1,644 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import static com.google.gerrit.extensions.client.DiffPreferencesInfo.DEFAULT_CONTEXT;
-import static com.google.gerrit.extensions.client.DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
-import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_ALL;
-import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_LEADING_AND_TRAILING;
-import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_NONE;
-import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_TRAILING;
-import static com.google.gwt.event.dom.client.KeyCodes.KEY_ESCAPE;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.AccountApi;
-import com.google.gerrit.client.account.DiffPreferences;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.NpIntTextBox;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.client.Theme;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Visibility;
-import com.google.gwt.event.dom.client.ChangeEvent;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.Timer;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.ToggleButton;
-import com.google.gwt.user.client.ui.UIObject;
-import java.util.Objects;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.mode.ModeInfo;
-import net.codemirror.mode.ModeInjector;
-import net.codemirror.theme.ThemeLoader;
-
-/** Displays current diff preferences. */
-public class PreferencesBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, PreferencesBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  public interface Style extends CssResource {
-    String dialog();
-  }
-
-  private final DiffScreen view;
-  private DiffPreferences prefs;
-  private int contextLastValue;
-  private Timer updateContextTimer;
-
-  @UiField Style style;
-  @UiField Element header;
-  @UiField Anchor close;
-  @UiField ListBox ignoreWhitespace;
-  @UiField NpIntTextBox tabWidth;
-  @UiField NpIntTextBox lineLength;
-  @UiField NpIntTextBox context;
-  @UiField NpIntTextBox cursorBlinkRate;
-  @UiField CheckBox contextEntireFile;
-  @UiField ToggleButton intralineDifference;
-  @UiField ToggleButton syntaxHighlighting;
-  @UiField ToggleButton whitespaceErrors;
-  @UiField ToggleButton showTabs;
-  @UiField ToggleButton lineNumbers;
-  @UiField Element leftSideLabel;
-  @UiField ToggleButton leftSide;
-  @UiField ToggleButton emptyPane;
-  @UiField ToggleButton topMenu;
-  @UiField ToggleButton autoHideDiffTableHeader;
-  @UiField ToggleButton manualReview;
-  @UiField ToggleButton expandAllComments;
-  @UiField ToggleButton renderEntireFile;
-  @UiField ToggleButton matchBrackets;
-  @UiField ToggleButton lineWrapping;
-  @UiField ToggleButton skipDeleted;
-  @UiField ToggleButton skipUnchanged;
-  @UiField ToggleButton skipUncommented;
-  @UiField ListBox theme;
-  @UiField Element modeLabel;
-  @UiField ListBox mode;
-  @UiField Button apply;
-  @UiField Button save;
-
-  public PreferencesBox(DiffScreen view) {
-    this.view = view;
-
-    initWidget(uiBinder.createAndBindUi(this));
-    initIgnoreWhitespace();
-    initTheme();
-
-    if (view != null) {
-      initMode();
-    } else {
-      UIObject.setVisible(header, false);
-      apply.getElement().getStyle().setVisibility(Visibility.HIDDEN);
-    }
-  }
-
-  @Override
-  public void onLoad() {
-    super.onLoad();
-
-    save.setVisible(Gerrit.isSignedIn());
-
-    if (view != null) {
-      addDomHandler(
-          new KeyDownHandler() {
-            @Override
-            public void onKeyDown(KeyDownEvent event) {
-              if (event.getNativeKeyCode() == KEY_ESCAPE || event.getNativeKeyCode() == ',') {
-                close();
-              }
-            }
-          },
-          KeyDownEvent.getType());
-
-      updateContextTimer =
-          new Timer() {
-            @Override
-            public void run() {
-              if (prefs.context() == WHOLE_FILE_CONTEXT) {
-                contextEntireFile.setValue(true);
-              }
-              if (view.canRenderEntireFile(prefs)) {
-                renderEntireFile.setEnabled(true);
-                renderEntireFile.setValue(prefs.renderEntireFile());
-              } else {
-                renderEntireFile.setValue(false);
-                renderEntireFile.setEnabled(false);
-              }
-              view.setContext(prefs.context());
-            }
-          };
-    }
-  }
-
-  public Style getStyle() {
-    return style;
-  }
-
-  public void set(DiffPreferences prefs) {
-    this.prefs = prefs;
-
-    setIgnoreWhitespace(prefs.ignoreWhitespace());
-    tabWidth.setIntValue(prefs.tabSize());
-    if (view != null && Patch.COMMIT_MSG.equals(view.path)) {
-      lineLength.setEnabled(false);
-      lineLength.setIntValue(72);
-    } else {
-      lineLength.setEnabled(true);
-      lineLength.setIntValue(prefs.lineLength());
-    }
-    cursorBlinkRate.setIntValue(prefs.cursorBlinkRate());
-    syntaxHighlighting.setValue(prefs.syntaxHighlighting());
-    whitespaceErrors.setValue(prefs.showWhitespaceErrors());
-    showTabs.setValue(prefs.showTabs());
-    lineNumbers.setValue(prefs.showLineNumbers());
-    emptyPane.setValue(!prefs.hideEmptyPane());
-    if (view != null) {
-      leftSide.setValue(view.getDiffTable().isVisibleA());
-      leftSide.setEnabled(
-          !(prefs.hideEmptyPane() && view.getDiffTable().getChangeType() == ChangeType.ADDED));
-    } else {
-      UIObject.setVisible(leftSideLabel, false);
-      leftSide.setVisible(false);
-    }
-    topMenu.setValue(!prefs.hideTopMenu());
-    autoHideDiffTableHeader.setValue(!prefs.autoHideDiffTableHeader());
-    manualReview.setValue(prefs.manualReview());
-    expandAllComments.setValue(prefs.expandAllComments());
-    matchBrackets.setValue(prefs.matchBrackets());
-    lineWrapping.setValue(prefs.lineWrapping());
-    skipDeleted.setValue(!prefs.skipDeleted());
-    skipUnchanged.setValue(!prefs.skipUnchanged());
-    skipUncommented.setValue(!prefs.skipUncommented());
-    setTheme(prefs.theme());
-
-    if (view == null || view.canRenderEntireFile(prefs)) {
-      renderEntireFile.setValue(prefs.renderEntireFile());
-      renderEntireFile.setEnabled(true);
-    } else {
-      renderEntireFile.setValue(false);
-      renderEntireFile.setEnabled(false);
-    }
-
-    if (view != null) {
-      mode.setEnabled(prefs.syntaxHighlighting());
-      if (prefs.syntaxHighlighting()) {
-        setMode(view.getCmFromSide(DisplaySide.B).getStringOption("mode"));
-      }
-    } else {
-      UIObject.setVisible(modeLabel, false);
-      mode.setVisible(false);
-    }
-
-    if (view != null) {
-      switch (view.getIntraLineStatus()) {
-        case OFF:
-        case OK:
-          intralineDifference.setValue(prefs.intralineDifference());
-          break;
-
-        case TIMEOUT:
-        case FAILURE:
-          intralineDifference.setValue(false);
-          intralineDifference.setEnabled(false);
-          break;
-      }
-    } else {
-      intralineDifference.setValue(prefs.intralineDifference());
-    }
-
-    if (prefs.context() == WHOLE_FILE_CONTEXT) {
-      contextLastValue = DEFAULT_CONTEXT;
-      context.setText("");
-      contextEntireFile.setValue(true);
-    } else {
-      context.setIntValue(prefs.context());
-      contextEntireFile.setValue(false);
-    }
-  }
-
-  @UiHandler("ignoreWhitespace")
-  void onIgnoreWhitespace(@SuppressWarnings("unused") ChangeEvent e) {
-    prefs.ignoreWhitespace(
-        Whitespace.valueOf(ignoreWhitespace.getValue(ignoreWhitespace.getSelectedIndex())));
-    if (view != null) {
-      view.reloadDiffInfo();
-    }
-  }
-
-  @UiHandler("intralineDifference")
-  void onIntralineDifference(ValueChangeEvent<Boolean> e) {
-    prefs.intralineDifference(Boolean.valueOf(e.getValue()));
-    if (view != null) {
-      view.setShowIntraline(prefs.intralineDifference());
-    }
-  }
-
-  @UiHandler("context")
-  void onContextKey(KeyPressEvent e) {
-    if (contextEntireFile.getValue()) {
-      char c = e.getCharCode();
-      if ('0' <= c && c <= '9') {
-        contextEntireFile.setValue(false);
-      }
-    }
-  }
-
-  @UiHandler("context")
-  void onContext(ValueChangeEvent<String> e) {
-    String v = e.getValue();
-    int c;
-    if (v != null && v.length() > 0) {
-      c = Math.min(Math.max(0, Integer.parseInt(v)), 32767);
-      contextEntireFile.setValue(false);
-    } else if (v == null || v.isEmpty()) {
-      c = WHOLE_FILE_CONTEXT;
-    } else {
-      return;
-    }
-    prefs.context(c);
-    if (view != null) {
-      updateContextTimer.schedule(200);
-    }
-  }
-
-  @UiHandler("contextEntireFile")
-  void onContextEntireFile(ValueChangeEvent<Boolean> e) {
-    // If a click arrives too fast after onContext applied an update
-    // the user committed the context line update by clicking on the
-    // whole file checkmark. Drop this event, but transfer focus.
-    if (e.getValue()) {
-      contextLastValue = context.getIntValue();
-      context.setText("");
-      prefs.context(WHOLE_FILE_CONTEXT);
-    } else {
-      prefs.context(contextLastValue > 0 ? contextLastValue : DEFAULT_CONTEXT);
-      context.setIntValue(prefs.context());
-      context.setFocus(true);
-      context.setSelectionRange(0, context.getText().length());
-    }
-    if (view != null) {
-      updateContextTimer.schedule(200);
-    }
-  }
-
-  @UiHandler("tabWidth")
-  void onTabWidth(ValueChangeEvent<String> e) {
-    String v = e.getValue();
-    if (v != null && v.length() > 0) {
-      prefs.tabSize(Math.max(1, Integer.parseInt(v)));
-      if (view != null) {
-        view.operation(
-            () -> {
-              int size = prefs.tabSize();
-              for (CodeMirror cm : view.getCms()) {
-                cm.setOption("tabSize", size);
-              }
-            });
-      }
-    }
-  }
-
-  @UiHandler("lineLength")
-  void onLineLength(ValueChangeEvent<String> e) {
-    String v = e.getValue();
-    if (v != null && v.length() > 0) {
-      prefs.lineLength(Math.max(1, Integer.parseInt(v)));
-      if (view != null) {
-        view.operation(() -> view.setLineLength(prefs.lineLength()));
-      }
-    }
-  }
-
-  @UiHandler("expandAllComments")
-  void onExpandAllComments(ValueChangeEvent<Boolean> e) {
-    prefs.expandAllComments(e.getValue());
-    if (view != null) {
-      view.getCommentManager().setExpandAllComments(prefs.expandAllComments());
-    }
-  }
-
-  @UiHandler("cursorBlinkRate")
-  void onCursoBlinkRate(ValueChangeEvent<String> e) {
-    String v = e.getValue();
-    if (v != null && v.length() > 0) {
-      // A negative value hides the cursor entirely:
-      // don't let user shoot himself in the foot.
-      prefs.cursorBlinkRate(Math.max(0, Integer.parseInt(v)));
-      view.getCmFromSide(DisplaySide.A).setOption("cursorBlinkRate", prefs.cursorBlinkRate());
-      view.getCmFromSide(DisplaySide.B).setOption("cursorBlinkRate", prefs.cursorBlinkRate());
-    }
-  }
-
-  @UiHandler("showTabs")
-  void onShowTabs(ValueChangeEvent<Boolean> e) {
-    prefs.showTabs(e.getValue());
-    if (view != null) {
-      view.setShowTabs(prefs.showTabs());
-    }
-  }
-
-  @UiHandler("lineNumbers")
-  void onLineNumbers(ValueChangeEvent<Boolean> e) {
-    prefs.showLineNumbers(e.getValue());
-    if (view != null) {
-      view.setShowLineNumbers(prefs.showLineNumbers());
-    }
-  }
-
-  @UiHandler("leftSide")
-  void onLeftSide(ValueChangeEvent<Boolean> e) {
-    if (view.getDiffTable() instanceof SideBySideTable) {
-      ((SideBySideTable) view.getDiffTable()).setVisibleA(e.getValue());
-    }
-  }
-
-  @UiHandler("emptyPane")
-  void onHideEmptyPane(ValueChangeEvent<Boolean> e) {
-    prefs.hideEmptyPane(!e.getValue());
-    if (view != null) {
-      view.getDiffTable().setHideEmptyPane(prefs.hideEmptyPane());
-      if (prefs.hideEmptyPane()) {
-        if (view.getDiffTable().getChangeType() == ChangeType.ADDED) {
-          leftSide.setValue(false);
-          leftSide.setEnabled(false);
-        }
-      } else {
-        leftSide.setValue(view.getDiffTable().isVisibleA());
-        leftSide.setEnabled(true);
-      }
-    }
-  }
-
-  @UiHandler("topMenu")
-  void onTopMenu(ValueChangeEvent<Boolean> e) {
-    prefs.hideTopMenu(!e.getValue());
-    if (view != null) {
-      Gerrit.setHeaderVisible(!prefs.hideTopMenu());
-      view.resizeCodeMirror();
-    }
-  }
-
-  @UiHandler("autoHideDiffTableHeader")
-  void onAutoHideDiffTableHeader(ValueChangeEvent<Boolean> e) {
-    prefs.autoHideDiffTableHeader(!e.getValue());
-    if (view != null) {
-      view.setAutoHideDiffHeader(!e.getValue());
-    }
-  }
-
-  @UiHandler("manualReview")
-  void onManualReview(ValueChangeEvent<Boolean> e) {
-    prefs.manualReview(e.getValue());
-  }
-
-  @UiHandler("syntaxHighlighting")
-  void onSyntaxHighlighting(ValueChangeEvent<Boolean> e) {
-    prefs.syntaxHighlighting(e.getValue());
-    if (view != null) {
-      mode.setEnabled(prefs.syntaxHighlighting());
-      if (prefs.syntaxHighlighting()) {
-        setMode(view.getContentType());
-      }
-      view.setSyntaxHighlighting(prefs.syntaxHighlighting());
-    }
-  }
-
-  @UiHandler("mode")
-  void onMode(@SuppressWarnings("unused") ChangeEvent e) {
-    String mode = getSelectedMode();
-    prefs.syntaxHighlighting(true);
-    syntaxHighlighting.setValue(true, false);
-    new ModeInjector()
-        .add(mode)
-        .inject(
-            new GerritCallback<Void>() {
-              @Override
-              public void onSuccess(Void result) {
-                if (prefs.syntaxHighlighting()
-                    && Objects.equals(mode, getSelectedMode())
-                    && view.isAttached()) {
-                  view.operation(
-                      () -> {
-                        view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
-                        view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
-                      });
-                }
-              }
-            });
-  }
-
-  private String getSelectedMode() {
-    String m = mode.getValue(mode.getSelectedIndex());
-    return m != null && !m.isEmpty() ? m : null;
-  }
-
-  @UiHandler("whitespaceErrors")
-  void onWhitespaceErrors(ValueChangeEvent<Boolean> e) {
-    prefs.showWhitespaceErrors(e.getValue());
-    if (view != null) {
-      view.operation(
-          () -> {
-            boolean s = prefs.showWhitespaceErrors();
-            for (CodeMirror cm : view.getCms()) {
-              cm.setOption("showTrailingSpace", s);
-            }
-          });
-    }
-  }
-
-  @UiHandler("renderEntireFile")
-  void onRenderEntireFile(ValueChangeEvent<Boolean> e) {
-    prefs.renderEntireFile(e.getValue());
-    if (view != null) {
-      view.updateRenderEntireFile();
-    }
-  }
-
-  @UiHandler("matchBrackets")
-  void onMatchBrackets(ValueChangeEvent<Boolean> e) {
-    prefs.matchBrackets(e.getValue());
-    view.getCmFromSide(DisplaySide.A).setOption("matchBrackets", prefs.matchBrackets());
-    view.getCmFromSide(DisplaySide.B).setOption("matchBrackets", prefs.matchBrackets());
-  }
-
-  @UiHandler("lineWrapping")
-  void onLineWrapping(ValueChangeEvent<Boolean> e) {
-    prefs.lineWrapping(e.getValue());
-    view.getCmFromSide(DisplaySide.A).setOption("lineWrapping", prefs.lineWrapping());
-    view.getCmFromSide(DisplaySide.B).setOption("lineWrapping", prefs.lineWrapping());
-  }
-
-  @UiHandler("skipDeleted")
-  void onSkipDeleted(ValueChangeEvent<Boolean> e) {
-    prefs.skipDeleted(!e.getValue());
-    // TODO: Update the navigation links on the current DiffScreen
-  }
-
-  @UiHandler("skipUnchanged")
-  void onSkipUnchanged(ValueChangeEvent<Boolean> e) {
-    prefs.skipUnchanged(!e.getValue());
-    // TODO: Update the navigation links on the current DiffScreen
-  }
-
-  @UiHandler("skipUncommented")
-  void onSkipUncommented(ValueChangeEvent<Boolean> e) {
-    prefs.skipUncommented(!e.getValue());
-    // TODO: Update the navigation links on the current DiffScreen
-  }
-
-  @UiHandler("theme")
-  void onTheme(@SuppressWarnings("unused") ChangeEvent e) {
-    Theme newTheme = getSelectedTheme();
-    prefs.theme(newTheme);
-    if (view != null) {
-      ThemeLoader.loadTheme(
-          newTheme,
-          new GerritCallback<Void>() {
-            @Override
-            public void onSuccess(Void result) {
-              view.operation(
-                  () -> {
-                    if (getSelectedTheme() == newTheme && isAttached()) {
-                      String t = newTheme.name().toLowerCase();
-                      view.getCmFromSide(DisplaySide.A).setOption("theme", t);
-                      view.getCmFromSide(DisplaySide.B).setOption("theme", t);
-                      view.setThemeStyles(newTheme.isDark());
-                    }
-                  });
-            }
-          });
-    }
-  }
-
-  private Theme getSelectedTheme() {
-    return Theme.valueOf(theme.getValue(theme.getSelectedIndex()));
-  }
-
-  @UiHandler("apply")
-  void onApply(@SuppressWarnings("unused") ClickEvent e) {
-    close();
-  }
-
-  @UiHandler("save")
-  void onSave(@SuppressWarnings("unused") ClickEvent e) {
-    AccountApi.putDiffPreferences(
-        prefs,
-        new GerritCallback<DiffPreferences>() {
-          @Override
-          public void onSuccess(DiffPreferences result) {
-            DiffPreferencesInfo p = new DiffPreferencesInfo();
-            result.copyTo(p);
-            Gerrit.setDiffPreferences(p);
-          }
-        });
-    if (view != null) {
-      close();
-    }
-  }
-
-  @UiHandler("close")
-  void onClose(ClickEvent e) {
-    e.preventDefault();
-    close();
-  }
-
-  void setFocus(boolean focus) {
-    ignoreWhitespace.setFocus(focus);
-  }
-
-  private void close() {
-    ((PopupPanel) getParent()).hide();
-  }
-
-  private void setIgnoreWhitespace(Whitespace v) {
-    String name = v != null ? v.name() : IGNORE_NONE.name();
-    for (int i = 0; i < ignoreWhitespace.getItemCount(); i++) {
-      if (ignoreWhitespace.getValue(i).equals(name)) {
-        ignoreWhitespace.setSelectedIndex(i);
-        return;
-      }
-    }
-    ignoreWhitespace.setSelectedIndex(0);
-  }
-
-  private void initIgnoreWhitespace() {
-    ignoreWhitespace.addItem(PatchUtil.C.whitespaceIGNORE_NONE(), IGNORE_NONE.name());
-    ignoreWhitespace.addItem(PatchUtil.C.whitespaceIGNORE_TRAILING(), IGNORE_TRAILING.name());
-    ignoreWhitespace.addItem(
-        PatchUtil.C.whitespaceIGNORE_LEADING_AND_TRAILING(), IGNORE_LEADING_AND_TRAILING.name());
-    ignoreWhitespace.addItem(PatchUtil.C.whitespaceIGNORE_ALL(), IGNORE_ALL.name());
-  }
-
-  private void initMode() {
-    mode.addItem("", "");
-    for (ModeInfo m : Natives.asList(ModeInfo.all())) {
-      mode.addItem(m.name(), m.mime());
-    }
-  }
-
-  private void setMode(String modeType) {
-    if (modeType != null && !modeType.isEmpty()) {
-      ModeInfo m = ModeInfo.findModeByMIME(modeType);
-      if (m != null) {
-        for (int i = 0; i < mode.getItemCount(); i++) {
-          if (mode.getValue(i).equals(m.mime())) {
-            mode.setSelectedIndex(i);
-            return;
-          }
-        }
-      }
-    }
-    mode.setSelectedIndex(0);
-  }
-
-  private void setTheme(Theme v) {
-    String name = v != null ? v.name() : Theme.DEFAULT.name();
-    for (int i = 0; i < theme.getItemCount(); i++) {
-      if (theme.getValue(i).equals(name)) {
-        theme.setSelectedIndex(i);
-        return;
-      }
-    }
-    theme.setSelectedIndex(0);
-  }
-
-  private void initTheme() {
-    for (Theme t : Theme.values()) {
-      theme.addItem(t.name().toLowerCase(), t.name());
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.ui.xml
deleted file mode 100644
index 4465d63..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.ui.xml
+++ /dev/null
@@ -1,341 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:x='urn:import:com.google.gerrit.client.ui'>
-  <ui:style gss='false' type='com.google.gerrit.client.diff.PreferencesBox.Style'>
-    @external .gwt-TextBox;
-    @external .gwt-ToggleButton .html-face;
-    @external .gwt-ToggleButton-up;
-    @external .gwt-ToggleButton-up-hovering;
-    @external .gwt-ToggleButton-up-disabled;
-    @external .gwt-ToggleButton-down;
-    @external .gwt-ToggleButton-down-hovering;
-    @external .gwt-ToggleButton-down-disabled;
-
-    .dialog {
-      background: rgba(0, 0, 0, 0.85) none repeat scroll 0 50%;
-      color: #ffffff;
-      font-family: arial,sans-serif;
-      font-weight: bold;
-      overflow: auto !important;
-      bottom: 0;
-      text-align: left;
-      text-shadow: 1px 1px 7px #000000;
-      min-width: 300px;
-      z-index: 200;
-      border-radius: 10px;
-    }
-
-    @if user.agent safari {
-      .dialog {
-        \-webkit-border-radius: 10px;
-      }
-    }
-
-    @if user.agent gecko1_8 {
-      .dialog {
-        \-moz-border-radius: 10px;
-      }
-    }
-
-    .box { margin: 10px; }
-    .box .gwt-TextBox { padding: 0; }
-    .context { vertical-align: bottom; }
-
-    .table tr { min-height: 23px; }
-    .table th,
-    .table td {
-      white-space: nowrap;
-      color: #ffffff;
-    }
-    .table th {
-      padding-right: 8px;
-      text-align: right;
-    }
-
-    .box a,
-    .box a:visited,
-    .box a:hover {
-      color: #dddd00;
-    }
-
-    .box input.gwt-TextBox:disabled {
-      background-color: #cacaca;
-    }
-
-    .box .gwt-ToggleButton {
-      position: relative;
-      height: 19px;
-      width: 140px;
-      background: #fff;
-      color: #000;
-      text-shadow: none;
-    }
-    .box .gwt-ToggleButton .html-face {
-      position: absolute;
-      top: 0;
-      width: 68px;
-      height: 17px;
-      line-height: 17px;
-      text-align: center;
-      border-width: 1px;
-    }
-
-    .box .gwt-ToggleButton-up,
-    .box .gwt-ToggleButton-up-hovering,
-    .box .gwt-ToggleButton-up-disabled,
-    .box .gwt-ToggleButton-down,
-    .box .gwt-ToggleButton-down-hovering,
-    .box .gwt-ToggleButton-down-disabled {
-      padding: 0;
-      border: 0;
-    }
-    .box .gwt-ToggleButton-up .html-face,
-    .box .gwt-ToggleButton-up-hovering .html-face {
-      left: 0;
-      background: #cacaca;
-      border-style: outset;
-    }
-    .box .gwt-ToggleButton-down .html-face,
-    .box .gwt-ToggleButton-down-hovering .html-face {
-      right: 0;
-      background: #bcf;
-      border-style: inset;
-    }
-
-    .box button {
-      margin: 6px 3px 0 0;
-      border-color: rgba(0, 0, 0, 0.1);
-      text-align: center;
-      font-size: 8pt;
-      font-weight: bold;
-      border: 1px solid;
-      cursor: pointer;
-      color: #444;
-      background-color: #f5f5f5;
-      background-image: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1);
-      -webkit-border-radius: 2px;
-      -webkit-box-sizing: content-box;
-    }
-    .box button div {
-      color: #444;
-      height: 10px;
-      min-width: 54px;
-      line-height: 10px;
-      white-space: nowrap;
-    }
-
-    button.apply {
-      background-color: #4d90fe;
-      background-image: -webkit-linear-gradient(top, #4d90fe, #4d90fe);
-    }
-    button.apply div { color: #fff; }
-
-    button.save {
-      margin-left: 10px;
-      color: #d14836;
-      background-color: #d14836;
-      background-image: -webkit-linear-gradient(top, #d14836, #d14836);
-    }
-    button.save div { color: #fff; }
-  </ui:style>
-
-  <g:HTMLPanel styleName='{style.box}'>
-    <div ui:field='header'>
-      <table style='width: 100%'>
-        <tr>
-          <td><ui:msg>Diff Preferences</ui:msg></td>
-          <td style='text-align: right'>
-            <g:Anchor ui:field='close' href='javascript:;'><ui:msg>Close</ui:msg></g:Anchor>
-          </td>
-        </tr>
-      </table>
-      <hr/>
-    </div>
-    <table class='{style.table}'>
-      <tr>
-        <th><ui:msg>Theme</ui:msg></th>
-        <td><g:ListBox ui:field='theme'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Ignore Whitespace</ui:msg></th>
-        <td><g:ListBox ui:field='ignoreWhitespace'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Tab Width</ui:msg></th>
-        <td><x:NpIntTextBox ui:field='tabWidth'
-            visibleLength='4'
-            alignment='RIGHT'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Columns</ui:msg></th>
-        <td><x:NpIntTextBox ui:field='lineLength'
-            visibleLength='4'
-            alignment='RIGHT'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Lines of Context</ui:msg></th>
-        <td><ui:msg><x:NpIntTextBox ui:field='context'
-            addStyleNames='{style.context}'
-            visibleLength='4'
-            alignment='RIGHT'/>
-          or <g:CheckBox ui:field='contextEntireFile'>entire file</g:CheckBox></ui:msg></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Cursor Blink Rate</ui:msg></th>
-        <td><x:NpIntTextBox ui:field='cursorBlinkRate'
-            visibleLength='4'
-            alignment='RIGHT'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Intraline Difference</ui:msg></th>
-        <td><g:ToggleButton ui:field='intralineDifference'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Syntax Highlighting</ui:msg></th>
-        <td><g:ToggleButton ui:field='syntaxHighlighting'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><div ui:field='modeLabel'><ui:msg>Language</ui:msg></div></th>
-        <td><g:ListBox ui:field='mode'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Whitespace Errors</ui:msg></th>
-        <td><g:ToggleButton ui:field='whitespaceErrors'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Show Tabs</ui:msg></th>
-        <td><g:ToggleButton ui:field='showTabs'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Line Numbers</ui:msg></th>
-        <td><g:ToggleButton ui:field='lineNumbers'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Empty Pane</ui:msg></th>
-        <td><g:ToggleButton ui:field='emptyPane'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><div ui:field='leftSideLabel'><ui:msg>Left Side</ui:msg></div></th>
-        <td><g:ToggleButton ui:field='leftSide'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Top Menu</ui:msg></th>
-        <td><g:ToggleButton ui:field='topMenu'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Auto Hide Diff Table Header</ui:msg></th>
-        <td><g:ToggleButton ui:field='autoHideDiffTableHeader'>
-          <g:upFace><ui:msg>Yes</ui:msg></g:upFace>
-          <g:downFace><ui:msg>No</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Mark Reviewed</ui:msg></th>
-        <td><g:ToggleButton ui:field='manualReview'>
-          <g:upFace><ui:msg>Automatic</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Manual</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Expand All Comments</ui:msg></th>
-        <td><g:ToggleButton ui:field='expandAllComments'>
-          <g:upFace><ui:msg>Collapse</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Expand</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Render</ui:msg></th>
-        <td><g:ToggleButton ui:field='renderEntireFile'>
-          <g:upFace><ui:msg>Fast</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Slow</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Match Brackets</ui:msg></th>
-        <td><g:ToggleButton ui:field='matchBrackets'>
-          <g:upFace><ui:msg>Off</ui:msg></g:upFace>
-          <g:downFace><ui:msg>On</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Line Wrapping</ui:msg></th>
-        <td><g:ToggleButton ui:field='lineWrapping'>
-          <g:upFace><ui:msg>Off</ui:msg></g:upFace>
-          <g:downFace><ui:msg>On</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Skip Deleted Files</ui:msg></th>
-        <td><g:ToggleButton ui:field='skipDeleted'>
-          <g:upFace><ui:msg>Yes</ui:msg></g:upFace>
-          <g:downFace><ui:msg>No</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Skip Unchanged Files</ui:msg></th>
-        <td><g:ToggleButton ui:field='skipUnchanged'>
-          <g:upFace><ui:msg>Yes</ui:msg></g:upFace>
-          <g:downFace><ui:msg>No</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Skip Uncommented Files</ui:msg></th>
-        <td><g:ToggleButton ui:field='skipUncommented'>
-          <g:upFace><ui:msg>Yes</ui:msg></g:upFace>
-          <g:downFace><ui:msg>No</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <td></td>
-        <td>
-          <g:Button ui:field='apply' styleName='{style.apply}'>
-            <div><ui:msg>Apply</ui:msg></div>
-          </g:Button>
-          <g:Button ui:field='save' styleName='{style.save}'>
-            <div><ui:msg>Save</ui:msg></div>
-          </g:Button>
-        </td>
-      </tr>
-    </table>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
deleted file mode 100644
index 1ddf895..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
+++ /dev/null
@@ -1,239 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.AvatarImage;
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.api.ApiGlue;
-import com.google.gerrit.client.change.ReplyBox;
-import com.google.gerrit.client.changes.CommentApi;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-/** An HtmlPanel for displaying a published comment */
-class PublishedBox extends CommentBox {
-  interface Binder extends UiBinder<HTMLPanel, PublishedBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface Style extends CssResource {
-    String closed();
-  }
-
-  private final PatchSet.Id psId;
-  @Nullable private final Project.NameKey project;
-  private final CommentInfo comment;
-  private final DisplaySide displaySide;
-  private DraftBox replyBox;
-
-  @UiField Style style;
-  @UiField Widget header;
-  @UiField Element name;
-  @UiField Element summary;
-  @UiField Element date;
-  @UiField Element message;
-  @UiField Element buttons;
-  @UiField Button reply;
-  @UiField Button done;
-  @UiField Button fix;
-
-  @UiField(provided = true)
-  AvatarImage avatar;
-
-  PublishedBox(
-      CommentGroup group,
-      CommentLinkProcessor clp,
-      @Nullable Project.NameKey project,
-      PatchSet.Id psId,
-      CommentInfo info,
-      DisplaySide displaySide,
-      boolean open) {
-    super(group, info.range());
-
-    this.psId = psId;
-    this.project = project;
-    this.comment = info;
-    this.displaySide = displaySide;
-
-    if (info.author() != null) {
-      avatar = new AvatarImage(info.author());
-      avatar.setSize("", "");
-    } else {
-      avatar = new AvatarImage();
-    }
-
-    initWidget(uiBinder.createAndBindUi(this));
-    header.addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            setOpen(!isOpen());
-          }
-        },
-        ClickEvent.getType());
-
-    name.setInnerText(authorName(info));
-    date.setInnerText(FormatUtil.shortFormatDayTime(info.updated()));
-    if (info.message() != null) {
-      String msg = info.message().trim();
-      summary.setInnerText(msg);
-      message.setInnerSafeHtml(clp.apply(new SafeHtmlBuilder().append(msg).wikify()));
-      ApiGlue.fireEvent("comment", message);
-    }
-
-    fix.setVisible(open);
-  }
-
-  @Override
-  CommentInfo getCommentInfo() {
-    return comment;
-  }
-
-  @Override
-  boolean isOpen() {
-    return UIObject.isVisible(message);
-  }
-
-  @Override
-  void setOpen(boolean open) {
-    UIObject.setVisible(summary, !open);
-    UIObject.setVisible(message, open);
-    UIObject.setVisible(buttons, open && replyBox == null);
-    if (open) {
-      removeStyleName(style.closed());
-    } else {
-      addStyleName(style.closed());
-    }
-    super.setOpen(open);
-  }
-
-  void setReplyBox(DraftBox box) {
-    replyBox = box;
-    UIObject.setVisible(buttons, false);
-    box.setReplyToBox(this);
-  }
-
-  void unregisterReplyBox() {
-    replyBox = null;
-    UIObject.setVisible(buttons, isOpen());
-  }
-
-  private void openReplyBox() {
-    replyBox.setOpen(true);
-    replyBox.setEdit(true);
-  }
-
-  void addReplyBox(boolean quote) {
-    CommentInfo commentReply = CommentInfo.createReply(comment);
-    if (quote) {
-      commentReply.message(ReplyBox.quote(comment.message()));
-    }
-    getCommentManager().addDraftBox(displaySide, commentReply).setEdit(true);
-  }
-
-  void doReply() {
-    if (!Gerrit.isSignedIn()) {
-      Gerrit.doSignIn(getCommentManager().host.getToken());
-    } else if (replyBox == null) {
-      addReplyBox(false);
-    } else {
-      openReplyBox();
-    }
-  }
-
-  @UiHandler("reply")
-  void onReply(ClickEvent e) {
-    e.stopPropagation();
-    doReply();
-  }
-
-  @UiHandler("quote")
-  void onQuote(ClickEvent e) {
-    e.stopPropagation();
-    if (!Gerrit.isSignedIn()) {
-      Gerrit.doSignIn(getCommentManager().host.getToken());
-    }
-    addReplyBox(true);
-  }
-
-  @UiHandler("done")
-  void onReplyDone(ClickEvent e) {
-    e.stopPropagation();
-    if (!Gerrit.isSignedIn()) {
-      Gerrit.doSignIn(getCommentManager().host.getToken());
-    } else if (replyBox == null) {
-      done.setEnabled(false);
-      CommentInfo input = CommentInfo.createReply(comment);
-      input.message(PatchUtil.C.cannedReplyDone());
-      CommentApi.createDraft(
-          Project.NameKey.asStringOrNull(project),
-          psId,
-          input,
-          new GerritCallback<CommentInfo>() {
-            @Override
-            public void onSuccess(CommentInfo result) {
-              done.setEnabled(true);
-              setOpen(false);
-              getCommentManager().addDraftBox(displaySide, result);
-            }
-          });
-    } else {
-      openReplyBox();
-      setOpen(false);
-    }
-  }
-
-  @UiHandler("fix")
-  void onFix(ClickEvent e) {
-    e.stopPropagation();
-    String t = Dispatcher.toEditScreen(project, psId, comment.path(), comment.line());
-    if (!Gerrit.isSignedIn()) {
-      Gerrit.doSignIn(t);
-    } else {
-      Gerrit.display(t);
-    }
-  }
-
-  private static String authorName(CommentInfo info) {
-    if (info.author() != null) {
-      if (info.author().name() != null) {
-        return info.author().name();
-      }
-      return Gerrit.info().user().anonymousCowardName();
-    }
-    return Util.C.messageNoAuthor();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
deleted file mode 100644
index cbea847..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
+++ /dev/null
@@ -1,82 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:c='urn:import:com.google.gerrit.client'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.diff.Resources'/>
-  <ui:style gss='false' type='com.google.gerrit.client.diff.PublishedBox.Style'>
-    .avatar {
-      position: absolute;
-      width: 26px;
-      height: 26px;
-    }
-    .closed .avatar {
-      position: absolute;
-      width: 16px;
-      height: 16px;
-    }
-
-    .name {
-      white-space: nowrap;
-      font-weight: bold;
-    }
-    .closed .name {
-      width: 120px;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      font-weight: normal;
-    }
-  </ui:style>
-
-  <g:HTMLPanel
-      styleName='{res.style.commentBox}'
-      addStyleNames='{style.closed}'>
-    <c:AvatarImage ui:field='avatar' styleName='{style.avatar}'/>
-    <div class='{res.style.contents}'>
-      <g:HTMLPanel ui:field='header' styleName='{res.style.header}'>
-        <div ui:field='name' class='{style.name}'/>
-        <div ui:field='summary' class='{res.style.summary}'/>
-        <div ui:field='date' class='{res.style.date}'/>
-      </g:HTMLPanel>
-      <div ui:field='message' class='{res.style.message}'
-           aria-hidden='true' style='display: NONE'/>
-      <div ui:field='buttons' aria-hidden='true' style='display: NONE'>
-        <g:Button ui:field='reply' styleName=''
-            title='Reply to this comment'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Reply</ui:msg></div>
-        </g:Button>
-        <g:Button ui:field='quote' styleName=''
-            title='Reply to this comment with quoting it'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Quote</ui:msg></div>
-        </g:Button>
-        <g:Button ui:field='done' styleName=''
-            title='Reply "Done" to this comment'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Done</ui:msg></div>
-        </g:Button>
-        <g:Button ui:field='fix' styleName='' visible='false'
-            title='Fix this comment in the inline editor'>
-          <ui:attribute name='title'/>
-          <div><ui:msg>Fix</ui:msg></div>
-        </g:Button>
-      </div>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java
deleted file mode 100644
index e590333..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.ImageResource;
-
-/** Resources used by diff. */
-interface Resources extends ClientBundle {
-  Resources I = GWT.create(Resources.class);
-
-  @Source("CommentBox.css")
-  CommentBox.Style style();
-
-  @Source("Scrollbar.css")
-  Scrollbar.Style scrollbarStyle();
-
-  @Source("DiffTable.css")
-  DiffTable.Style diffTableStyle();
-
-  /** tango icon library (public domain): http://tango.freedesktop.org/Tango_Icon_Library */
-  @Source("goPrev.png")
-  ImageResource goPrev();
-
-  @Source("goNext.png")
-  ImageResource goNext();
-
-  @Source("goUp.png")
-  ImageResource goUp();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java
deleted file mode 100644
index 35e3e7d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gwt.user.client.Timer;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.ScrollInfo;
-
-class ScrollSynchronizer {
-  private SideBySideTable diffTable;
-  private LineMapper mapper;
-  private ScrollCallback active;
-  private ScrollCallback callbackA;
-  private ScrollCallback callbackB;
-  private CodeMirror cmB;
-  private boolean autoHideDiffTableHeader;
-
-  ScrollSynchronizer(SideBySideTable diffTable, CodeMirror cmA, CodeMirror cmB, LineMapper mapper) {
-    this.diffTable = diffTable;
-    this.mapper = mapper;
-    this.cmB = cmB;
-
-    callbackA = new ScrollCallback(cmA, cmB, DisplaySide.A);
-    callbackB = new ScrollCallback(cmB, cmA, DisplaySide.B);
-    cmA.on("scroll", callbackA);
-    cmB.on("scroll", callbackB);
-  }
-
-  void setAutoHideDiffTableHeader(boolean autoHide) {
-    if (autoHide) {
-      updateDiffTableHeader(cmB.getScrollInfo());
-    } else {
-      diffTable.setHeaderVisible(true);
-    }
-    autoHideDiffTableHeader = autoHide;
-  }
-
-  void syncScroll(DisplaySide masterSide) {
-    (masterSide == DisplaySide.A ? callbackA : callbackB).sync();
-  }
-
-  private void updateDiffTableHeader(ScrollInfo si) {
-    if (si.top() == 0) {
-      diffTable.setHeaderVisible(true);
-    } else if (si.top() > 0.5 * si.clientHeight()) {
-      diffTable.setHeaderVisible(false);
-    }
-  }
-
-  class ScrollCallback implements Runnable {
-    private final CodeMirror src;
-    private final CodeMirror dst;
-    private final DisplaySide srcSide;
-    private final Timer fixup;
-    private int state;
-
-    ScrollCallback(CodeMirror src, CodeMirror dst, DisplaySide srcSide) {
-      this.src = src;
-      this.dst = dst;
-      this.srcSide = srcSide;
-      this.fixup =
-          new Timer() {
-            @Override
-            public void run() {
-              if (active == ScrollCallback.this) {
-                fixup();
-              }
-            }
-          };
-    }
-
-    void sync() {
-      dst.scrollToY(align(src.getScrollInfo().top()));
-    }
-
-    @Override
-    public void run() {
-      if (active == null) {
-        active = this;
-        fixup.scheduleRepeating(20);
-      }
-      if (active == this) {
-        ScrollInfo si = src.getScrollInfo();
-        if (autoHideDiffTableHeader) {
-          updateDiffTableHeader(si);
-        }
-        dst.scrollTo(si.left(), align(si.top()));
-        state = 0;
-      }
-    }
-
-    private void fixup() {
-      switch (state) {
-        case 0:
-          state = 1;
-          dst.scrollToY(align(src.getScrollInfo().top()));
-          break;
-        case 1:
-          state = 2;
-          break;
-        case 2:
-          active = null;
-          fixup.cancel();
-          break;
-      }
-    }
-
-    private double align(double srcTop) {
-      // Since CM doesn't always take the height of line widgets into
-      // account when calculating scrollInfo when scrolling too fast (e.g.
-      // throw scrolling), simply setting scrollTop to be the same doesn't
-      // guarantee alignment.
-
-      int line = src.lineAtHeight(srcTop, "local");
-      if (line == 0) {
-        // Padding for insert at start of file occurs above line 0,
-        // and CM3 doesn't always compute heightAtLine correctly.
-        return srcTop;
-      }
-
-      // Find a pair of lines that are aligned and near the top of
-      // the viewport. Use that distance to correct the Y coordinate.
-      LineMapper.AlignedPair p = mapper.align(srcSide, line);
-      double sy = src.heightAtLine(p.src, "local");
-      double dy = dst.heightAtLine(p.dst, "local");
-      return Math.max(0, dy + (srcTop - sy));
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css
deleted file mode 100644
index 26f8ff5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css
+++ /dev/null
@@ -1,40 +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.
- */
-
-.comment, .draft, .insert, .delete, .edit {
-  min-height: 5px;
-  position: absolute;
-  right: 0;
-  z-index: 7;
-}
-
-.comment, .draft {
-  color: #0d0d0d;
-  font-size: 9px;
-}
-
-.delete {
-  background-color: #faa;
-  min-width: 12px;
-}
-.insert {
-  background-color: #9f9;
-  min-width: 12px;
-}
-.edit {
-  border-left: 6px solid #faa;
-  width: 6px !important;
-  background-color: #9f9;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.java
deleted file mode 100644
index 83ada90..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.java
+++ /dev/null
@@ -1,101 +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.client.diff;
-
-import com.google.gwt.resources.client.CssResource;
-import java.util.ArrayList;
-import java.util.List;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Pos;
-
-/** Displays overview of all edits and comments in this file. */
-class Scrollbar {
-  static {
-    Resources.I.scrollbarStyle().ensureInjected();
-  }
-
-  interface Style extends CssResource {
-    String comment();
-
-    String draft();
-
-    String insert();
-
-    String delete();
-
-    String edit();
-  }
-
-  private final List<ScrollbarAnnotation> diff = new ArrayList<>();
-  private final DiffTable parent;
-
-  Scrollbar(DiffTable d) {
-    parent = d;
-  }
-
-  ScrollbarAnnotation comment(CodeMirror cm, int line) {
-    ScrollbarAnnotation a = new ScrollbarAnnotation(cm);
-    a.setStyleName(Resources.I.scrollbarStyle().comment());
-    a.at(line);
-    a.getElement().setInnerText("\u2736"); // Six pointed black star
-    parent.add(a);
-    return a;
-  }
-
-  ScrollbarAnnotation draft(CodeMirror cm, int line) {
-    ScrollbarAnnotation a = new ScrollbarAnnotation(cm);
-    a.setStyleName(Resources.I.scrollbarStyle().draft());
-    a.at(line);
-    a.getElement().setInnerText("\u270D"); // Writing hand
-    parent.add(a);
-    return a;
-  }
-
-  ScrollbarAnnotation insert(CodeMirror cm, int line, int len) {
-    ScrollbarAnnotation a = diff(cm, line, len);
-    a.setStyleName(Resources.I.scrollbarStyle().insert());
-    parent.add(a);
-    return a;
-  }
-
-  ScrollbarAnnotation delete(CodeMirror cmA, CodeMirror cmB, int line, int len) {
-    ScrollbarAnnotation a = diff(cmA, line, len);
-    a.setStyleName(Resources.I.scrollbarStyle().delete());
-    a.renderOn(cmB);
-    parent.add(a);
-    return a;
-  }
-
-  ScrollbarAnnotation edit(CodeMirror cm, int line, int len) {
-    ScrollbarAnnotation a = diff(cm, line, len);
-    a.setStyleName(Resources.I.scrollbarStyle().edit());
-    parent.add(a);
-    return a;
-  }
-
-  private ScrollbarAnnotation diff(CodeMirror cm, int s, int n) {
-    ScrollbarAnnotation a = new ScrollbarAnnotation(cm);
-    a.at(Pos.create(s), Pos.create(s + n));
-    diff.add(a);
-    return a;
-  }
-
-  void removeDiffAnnotations() {
-    for (ScrollbarAnnotation a : diff) {
-      a.remove();
-    }
-    diff.clear();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
deleted file mode 100644
index ecdac46..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
+++ /dev/null
@@ -1,118 +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.client.diff;
-
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Unit;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.Widget;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.RegisteredHandler;
-import net.codemirror.lib.Pos;
-
-/** Displayed on the vertical scrollbar to place a chunk or comment. */
-class ScrollbarAnnotation extends Widget implements ClickHandler {
-  private final CodeMirror cm;
-  private CodeMirror cmB;
-  private RegisteredHandler refresh;
-  private Pos from;
-  private Pos to;
-  private double scale;
-
-  ScrollbarAnnotation(CodeMirror cm) {
-    setElement((Element) DOM.createDiv());
-    getElement().setAttribute("not-content", "true");
-    addDomHandler(this, ClickEvent.getType());
-    this.cm = cm;
-    this.cmB = cm;
-  }
-
-  void remove() {
-    removeFromParent();
-  }
-
-  void at(int line) {
-    at(Pos.create(line), Pos.create(line + 1));
-  }
-
-  void at(Pos from, Pos to) {
-    this.from = from;
-    this.to = to;
-  }
-
-  void renderOn(CodeMirror cm) {
-    this.cmB = cm;
-  }
-
-  @Override
-  protected void onLoad() {
-    cmB.getWrapperElement().appendChild(getElement());
-    refresh =
-        cmB.on(
-            "refresh",
-            () -> {
-              if (updateScale()) {
-                updatePosition();
-              }
-            });
-    updateScale();
-    updatePosition();
-  }
-
-  @Override
-  protected void onUnload() {
-    cmB.off("refresh", refresh);
-  }
-
-  private boolean updateScale() {
-    double old = scale;
-    double docHeight = cmB.getWrapperElement().getClientHeight();
-    double lineHeight = cmB.heightAtLine(cmB.lastLine() + 1, "local");
-    scale = (docHeight - cmB.barHeight()) / lineHeight;
-    return old != scale;
-  }
-
-  private void updatePosition() {
-    double top = cm.charCoords(from, "local").top() * scale;
-    double bottom = cm.charCoords(to, "local").bottom() * scale;
-
-    Element e = getElement();
-    e.getStyle().setTop(top, Unit.PX);
-    e.getStyle().setWidth(Math.max(2, cm.barWidth() - 1), Unit.PX);
-    e.getStyle().setHeight(Math.max(3, bottom - top), Unit.PX);
-  }
-
-  @Override
-  public void onClick(ClickEvent event) {
-    event.stopPropagation();
-
-    int line = from.line();
-    int h = to.line() - line;
-    if (h > 5) {
-      // Map click inside of the annotation to the relative position
-      // within the region covered by the annotation.
-      double s = ((double) event.getY()) / getElement().getOffsetHeight();
-      line += (int) (s * h);
-    }
-
-    double y = cm.heightAtLine(line, "local");
-    double viewport = cm.getScrollInfo().clientHeight();
-    cm.setCursor(from);
-    cm.scrollTo(0, y - 0.5 * viewport);
-    cm.focus();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
deleted file mode 100644
index d052323..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
+++ /dev/null
@@ -1,414 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import static java.lang.Double.POSITIVE_INFINITY;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.client.projects.ConfigInfoCache;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.FocusEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.ImageResourceRenderer;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import java.util.Collections;
-import java.util.List;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.LineHandle;
-import net.codemirror.lib.Configuration;
-import net.codemirror.lib.KeyMap;
-import net.codemirror.lib.Pos;
-
-public class SideBySide extends DiffScreen {
-  interface Binder extends UiBinder<FlowPanel, SideBySide> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-  private static final String LINE_NUMBER_CLASSNAME = "CodeMirror-linenumber";
-
-  @UiField(provided = true)
-  SideBySideTable diffTable;
-
-  private CodeMirror cmA;
-  private CodeMirror cmB;
-
-  private ScrollSynchronizer scrollSynchronizer;
-
-  private SideBySideChunkManager chunkManager;
-  private SideBySideCommentManager commentManager;
-
-  public SideBySide(
-      @Nullable Project.NameKey project,
-      DiffObject base,
-      DiffObject revision,
-      String path,
-      DisplaySide startSide,
-      int startLine) {
-    super(project, base, revision, path, startSide, startLine, DiffView.SIDE_BY_SIDE);
-
-    diffTable = new SideBySideTable(this, base, revision, path);
-    add(uiBinder.createAndBindUi(this));
-    addDomHandler(GlobalKey.STOP_PROPAGATION, KeyPressEvent.getType());
-  }
-
-  @Override
-  ScreenLoadCallback<ConfigInfoCache.Entry> getScreenLoadCallback(
-      final CommentsCollections comments) {
-    return new ScreenLoadCallback<ConfigInfoCache.Entry>(SideBySide.this) {
-      @Override
-      protected void preDisplay(ConfigInfoCache.Entry result) {
-        commentManager =
-            new SideBySideCommentManager(
-                SideBySide.this,
-                getProject(),
-                base,
-                revision,
-                path,
-                result.getCommentLinkProcessor(),
-                getChangeStatus().isOpen());
-        setTheme(result.getTheme());
-        display(comments);
-        header.setupPrevNextFiles(comments);
-      }
-    };
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-
-    operation(
-        () -> {
-          resizeCodeMirror();
-          chunkManager.adjustPadding();
-          cmA.refresh();
-          cmB.refresh();
-        });
-    setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
-    diffTable.refresh();
-
-    if (getStartLine() == 0) {
-      DiffChunkInfo d = chunkManager.getFirst();
-      if (d != null) {
-        if (d.isEdit() && d.getSide() == DisplaySide.A) {
-          setStartSide(DisplaySide.B);
-          setStartLine(lineOnOther(d.getSide(), d.getStart()).getLine() + 1);
-        } else {
-          setStartSide(d.getSide());
-          setStartLine(d.getStart() + 1);
-        }
-      }
-    }
-    if (getStartSide() != null && getStartLine() > 0) {
-      CodeMirror cm = getCmFromSide(getStartSide());
-      cm.scrollToLine(getStartLine() - 1);
-      cm.focus();
-    } else {
-      cmA.setCursor(Pos.create(0));
-      cmA.focus();
-    }
-    if (Gerrit.isSignedIn() && prefs.autoReview()) {
-      header.autoReview();
-    }
-    prefetchNextFile();
-  }
-
-  @Override
-  void registerCmEvents(CodeMirror cm) {
-    super.registerCmEvents(cm);
-
-    KeyMap keyMap =
-        KeyMap.create()
-            .on("Shift-A", diffTable.toggleA())
-            .on("Shift-Left", moveCursorToSide(cm, DisplaySide.A))
-            .on("Shift-Right", moveCursorToSide(cm, DisplaySide.B));
-    cm.addKeyMap(keyMap);
-    maybeRegisterRenderEntireFileKeyMap(cm);
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-
-    getKeysNavigation()
-        .add(
-            new NoOpKeyCommand(KeyCommand.M_SHIFT, KeyCodes.KEY_LEFT, PatchUtil.C.focusSideA()),
-            new NoOpKeyCommand(KeyCommand.M_SHIFT, KeyCodes.KEY_RIGHT, PatchUtil.C.focusSideB()));
-    getKeysAction()
-        .add(
-            new KeyCommand(KeyCommand.M_SHIFT, 'a', PatchUtil.C.toggleSideA()) {
-              @Override
-              public void onKeyPress(KeyPressEvent event) {
-                diffTable.toggleA().run();
-              }
-            });
-
-    registerHandlers();
-  }
-
-  @Override
-  FocusHandler getFocusHandler() {
-    return new FocusHandler() {
-      @Override
-      public void onFocus(FocusEvent event) {
-        cmB.focus();
-      }
-    };
-  }
-
-  private void display(CommentsCollections comments) {
-    DiffInfo diff = getDiff();
-    setThemeStyles(prefs.theme().isDark());
-    setShowIntraline(prefs.intralineDifference());
-    if (prefs.showLineNumbers()) {
-      diffTable.addStyleName(Resources.I.diffTableStyle().showLineNumbers());
-    }
-
-    cmA = newCm(diff.metaA(), diff.textA(), diffTable.cmA);
-    cmB = newCm(diff.metaB(), diff.textB(), diffTable.cmB);
-
-    getDiffTable()
-        .setUpBlameIconA(
-            cmA,
-            base.isBaseOrAutoMerge(),
-            base.isBaseOrAutoMerge() ? revision : base.asPatchSetId(),
-            path);
-    getDiffTable().setUpBlameIconB(cmB, revision, path);
-
-    cmA.extras().side(DisplaySide.A);
-    cmB.extras().side(DisplaySide.B);
-    setShowTabs(prefs.showTabs());
-
-    chunkManager = new SideBySideChunkManager(this, cmA, cmB, diffTable.scrollbar);
-
-    operation(
-        () -> {
-          // Estimate initial CodeMirror height, fixed up in onShowView.
-          int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
-          cmA.setHeight(height);
-          cmB.setHeight(height);
-
-          render(diff);
-          commentManager.render(comments, prefs.expandAllComments());
-          skipManager.render(prefs.context(), diff);
-        });
-
-    registerCmEvents(cmA);
-    registerCmEvents(cmB);
-    scrollSynchronizer = new ScrollSynchronizer(diffTable, cmA, cmB, chunkManager.lineMapper);
-
-    setPrefsAction(new PreferencesAction(this, prefs));
-    header.init(getPrefsAction(), getUnifiedDiffLink(), diff.sideBySideWebLinks());
-    scrollSynchronizer.setAutoHideDiffTableHeader(prefs.autoHideDiffTableHeader());
-
-    setupSyntaxHighlighting();
-  }
-
-  private List<InlineHyperlink> getUnifiedDiffLink() {
-    InlineHyperlink toUnifiedDiffLink = new InlineHyperlink();
-    toUnifiedDiffLink.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.unifiedDiff()));
-    toUnifiedDiffLink.setTargetHistoryToken(
-        Dispatcher.toUnified(getProject(), base, revision, path));
-    toUnifiedDiffLink.setTitle(PatchUtil.C.unifiedDiff());
-    return Collections.singletonList(toUnifiedDiffLink);
-  }
-
-  @Override
-  CodeMirror newCm(DiffInfo.FileMeta meta, String contents, Element parent) {
-    return CodeMirror.create(
-        parent,
-        Configuration.create()
-            .set("cursorBlinkRate", prefs.cursorBlinkRate())
-            .set("cursorHeight", 0.85)
-            .set("inputStyle", "textarea")
-            .set("keyMap", "vim_ro")
-            .set("lineNumbers", prefs.showLineNumbers())
-            .set("matchBrackets", prefs.matchBrackets())
-            .set("lineWrapping", prefs.lineWrapping())
-            .set("mode", getFileSize() == FileSize.SMALL ? getContentType(meta) : null)
-            .set("readOnly", true)
-            .set("scrollbarStyle", "overlay")
-            .set("showTrailingSpace", prefs.showWhitespaceErrors())
-            .set("styleSelectedText", true)
-            .set("tabSize", prefs.tabSize())
-            .set("theme", prefs.theme().name().toLowerCase())
-            .set("value", meta != null ? contents : "")
-            .set("viewportMargin", renderEntireFile() ? POSITIVE_INFINITY : 10));
-  }
-
-  @Override
-  void setShowLineNumbers(boolean b) {
-    super.setShowLineNumbers(b);
-
-    cmA.setOption("lineNumbers", b);
-    cmB.setOption("lineNumbers", b);
-  }
-
-  @Override
-  void setSyntaxHighlighting(boolean b) {
-    final DiffInfo diff = getDiff();
-    if (b) {
-      injectMode(
-          diff,
-          new AsyncCallback<Void>() {
-            @Override
-            public void onSuccess(Void result) {
-              if (prefs.syntaxHighlighting()) {
-                cmA.setOption("mode", getContentType(diff.metaA()));
-                cmB.setOption("mode", getContentType(diff.metaB()));
-              }
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              prefs.syntaxHighlighting(false);
-            }
-          });
-    } else {
-      cmA.setOption("mode", (String) null);
-      cmB.setOption("mode", (String) null);
-    }
-  }
-
-  @Override
-  void setAutoHideDiffHeader(boolean hide) {
-    scrollSynchronizer.setAutoHideDiffTableHeader(hide);
-  }
-
-  CodeMirror otherCm(CodeMirror me) {
-    return me == cmA ? cmB : cmA;
-  }
-
-  @Override
-  CodeMirror getCmFromSide(DisplaySide side) {
-    return side == DisplaySide.A ? cmA : cmB;
-  }
-
-  @Override
-  int getCmLine(int line, DisplaySide side) {
-    return line;
-  }
-
-  @Override
-  Runnable updateActiveLine(CodeMirror cm) {
-    CodeMirror other = otherCm(cm);
-    return () -> {
-      // The rendering of active lines has to be deferred. Reflow
-      // caused by adding and removing styles chokes Firefox when arrow
-      // key (or j/k) is held down. Performance on Chrome is fine
-      // without the deferral.
-      //
-      Scheduler.get()
-          .scheduleDeferred(
-              new ScheduledCommand() {
-                @Override
-                public void execute() {
-                  operation(
-                      () -> {
-                        LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                        if (!cm.extras().activeLine(handle)) {
-                          return;
-                        }
-
-                        LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
-                        if (info.isAligned()) {
-                          other.extras().activeLine(other.getLineHandle(info.getLine()));
-                        } else {
-                          other.extras().clearActiveLine();
-                        }
-                      });
-                }
-              });
-    };
-  }
-
-  private Runnable moveCursorToSide(CodeMirror cmSrc, DisplaySide sideDst) {
-    CodeMirror cmDst = getCmFromSide(sideDst);
-    if (cmDst == cmSrc) {
-      return () -> {};
-    }
-
-    DisplaySide sideSrc = cmSrc.side();
-    return () -> {
-      if (cmSrc.extras().hasActiveLine()) {
-        cmDst.setCursor(
-            Pos.create(
-                lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine())).getLine()));
-      }
-      cmDst.focus();
-    };
-  }
-
-  void syncScroll(DisplaySide masterSide) {
-    if (scrollSynchronizer != null) {
-      scrollSynchronizer.syncScroll(masterSide);
-    }
-  }
-
-  @Override
-  void operation(Runnable apply) {
-    cmA.operation(() -> cmB.operation(apply::run));
-  }
-
-  @Override
-  CodeMirror[] getCms() {
-    return new CodeMirror[] {cmA, cmB};
-  }
-
-  @Override
-  SideBySideTable getDiffTable() {
-    return diffTable;
-  }
-
-  @Override
-  SideBySideChunkManager getChunkManager() {
-    return chunkManager;
-  }
-
-  @Override
-  SideBySideCommentManager getCommentManager() {
-    return commentManager;
-  }
-
-  @Override
-  boolean isSideBySide() {
-    return true;
-  }
-
-  @Override
-  String getLineNumberClassName() {
-    return LINE_NUMBER_CLASSNAME;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.ui.xml
deleted file mode 100644
index 55c9de0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.ui.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:d='urn:import:com.google.gerrit.client.diff'>
-  <ui:style gss='false'>
-    .sbs {
-      margin-left: -5px;
-      margin-right: -5px;
-    }
-  </ui:style>
-  <g:FlowPanel styleName='{style.sbs}'>
-    <d:Header ui:field='header'/>
-    <d:SideBySideTable ui:field='diffTable'/>
-  </g:FlowPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
deleted file mode 100644
index 2877794..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
+++ /dev/null
@@ -1,262 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import static com.google.gerrit.client.diff.DisplaySide.A;
-import static com.google.gerrit.client.diff.DisplaySide.B;
-
-import com.google.gerrit.client.diff.DiffInfo.Region;
-import com.google.gerrit.client.diff.DiffInfo.Span;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.dom.client.Style.Unit;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.EventListener;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.LineClassWhere;
-import net.codemirror.lib.Configuration;
-import net.codemirror.lib.LineWidget;
-import net.codemirror.lib.Pos;
-
-/** Colors modified regions for {@link SideBySide}. */
-class SideBySideChunkManager extends ChunkManager {
-  private static final String DATA_LINES = "_cs2h";
-  private static double guessedLineHeightPx = 15;
-  private static final JavaScriptObject focusA = initOnClick(A);
-  private static final JavaScriptObject focusB = initOnClick(B);
-
-  private static native JavaScriptObject initOnClick(DisplaySide s) /*-{
-    return $entry(function(e){
-      @com.google.gerrit.client.diff.SideBySideChunkManager::focus(
-        Lcom/google/gwt/dom/client/NativeEvent;
-        Lcom/google/gerrit/client/diff/DisplaySide;)(e,s)
-    });
-  }-*/;
-
-  private static void focus(NativeEvent event, DisplaySide side) {
-    Element e = Element.as(event.getEventTarget());
-    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
-      EventListener l = DOM.getEventListener(e);
-      if (l instanceof SideBySide) {
-        ((SideBySide) l).getCmFromSide(side).focus();
-        event.stopPropagation();
-      }
-    }
-  }
-
-  static void focusOnClick(Element e, DisplaySide side) {
-    onClick(e, side == A ? focusA : focusB);
-  }
-
-  private final SideBySide host;
-  private final CodeMirror cmA;
-  private final CodeMirror cmB;
-
-  private List<DiffChunkInfo> chunks;
-  private List<LineWidget> padding;
-  private List<Element> paddingDivs;
-
-  SideBySideChunkManager(SideBySide host, CodeMirror cmA, CodeMirror cmB, Scrollbar scrollbar) {
-    super(scrollbar);
-
-    this.host = host;
-    this.cmA = cmA;
-    this.cmB = cmB;
-  }
-
-  @Override
-  DiffChunkInfo getFirst() {
-    return !chunks.isEmpty() ? chunks.get(0) : null;
-  }
-
-  @Override
-  void reset() {
-    super.reset();
-
-    for (LineWidget w : padding) {
-      w.clear();
-    }
-  }
-
-  @Override
-  void render(DiffInfo diff) {
-    super.render();
-
-    chunks = new ArrayList<>();
-    padding = new ArrayList<>();
-    paddingDivs = new ArrayList<>();
-
-    String diffColor =
-        diff.metaA() == null || diff.metaB() == null
-            ? SideBySideTable.style.intralineBg()
-            : SideBySideTable.style.diff();
-
-    for (Region current : Natives.asList(diff.content())) {
-      if (current.ab() != null) {
-        lineMapper.appendCommon(current.ab().length());
-      } else if (current.skip() > 0) {
-        lineMapper.appendCommon(current.skip());
-      } else if (current.common()) {
-        lineMapper.appendCommon(current.b().length());
-      } else {
-        render(current, diffColor);
-      }
-    }
-
-    if (paddingDivs.isEmpty()) {
-      paddingDivs = null;
-    }
-  }
-
-  void adjustPadding() {
-    if (paddingDivs != null) {
-      double h = cmB.extras().lineHeightPx();
-      for (Element div : paddingDivs) {
-        int lines = div.getPropertyInt(DATA_LINES);
-        div.getStyle().setHeight(lines * h, Unit.PX);
-      }
-      for (LineWidget w : padding) {
-        w.changed();
-      }
-      paddingDivs = null;
-      guessedLineHeightPx = h;
-    }
-  }
-
-  private void render(Region region, String diffColor) {
-    int startA = lineMapper.getLineA();
-    int startB = lineMapper.getLineB();
-
-    JsArrayString a = region.a();
-    JsArrayString b = region.b();
-    int aLen = a != null ? a.length() : 0;
-    int bLen = b != null ? b.length() : 0;
-
-    String color = a == null || b == null ? diffColor : SideBySideTable.style.intralineBg();
-
-    colorLines(cmA, color, startA, aLen);
-    colorLines(cmB, color, startB, bLen);
-    markEdit(cmA, startA, a, region.editA());
-    markEdit(cmB, startB, b, region.editB());
-    addPadding(cmA, startA + aLen - 1, bLen - aLen);
-    addPadding(cmB, startB + bLen - 1, aLen - bLen);
-    addGutterTag(region, startA, startB);
-    lineMapper.appendReplace(aLen, bLen);
-
-    int endA = lineMapper.getLineA() - 1;
-    int endB = lineMapper.getLineB() - 1;
-    if (aLen > 0) {
-      addDiffChunk(cmB, endA, aLen, bLen > 0);
-    }
-    if (bLen > 0) {
-      addDiffChunk(cmA, endB, bLen, aLen > 0);
-    }
-  }
-
-  private void addGutterTag(Region region, int startA, int startB) {
-    if (region.a() == null) {
-      scrollbar.insert(cmB, startB, region.b().length());
-    } else if (region.b() == null) {
-      scrollbar.delete(cmA, cmB, startA, region.a().length());
-    } else {
-      scrollbar.edit(cmB, startB, region.b().length());
-    }
-  }
-
-  private void markEdit(CodeMirror cm, int startLine, JsArrayString lines, JsArray<Span> edits) {
-    if (lines == null || edits == null) {
-      return;
-    }
-
-    EditIterator iter = new EditIterator(lines, startLine);
-    Configuration bg =
-        Configuration.create()
-            .set("className", SideBySideTable.style.intralineBg())
-            .set("readOnly", true);
-
-    Configuration diff =
-        Configuration.create().set("className", SideBySideTable.style.diff()).set("readOnly", true);
-
-    Pos last = Pos.create(0, 0);
-    for (Span span : Natives.asList(edits)) {
-      Pos from = iter.advance(span.skip());
-      Pos to = iter.advance(span.mark());
-      if (from.line() == last.line()) {
-        getMarkers().add(cm.markText(last, from, bg));
-      } else {
-        getMarkers().add(cm.markText(Pos.create(from.line(), 0), from, bg));
-      }
-      getMarkers().add(cm.markText(from, to, diff));
-      last = to;
-      colorLines(
-          cm, LineClassWhere.BACKGROUND, SideBySideTable.style.diff(), from.line(), to.line());
-    }
-  }
-
-  /**
-   * Insert a new padding div below the given line.
-   *
-   * @param cm parent CodeMirror to add extra space into.
-   * @param line line to put the padding below.
-   * @param len number of lines to pad. Padding is inserted only if {@code len >= 1}.
-   */
-  private void addPadding(CodeMirror cm, int line, int len) {
-    if (0 < len) {
-      Element pad = DOM.createDiv();
-      pad.setClassName(SideBySideTable.style.padding());
-      pad.setPropertyInt(DATA_LINES, len);
-      pad.getStyle().setHeight(guessedLineHeightPx * len, Unit.PX);
-      focusOnClick(pad, cm.side());
-      paddingDivs.add(pad);
-      padding.add(
-          cm.addLineWidget(
-              line == -1 ? 0 : line,
-              pad,
-              Configuration.create()
-                  .set("coverGutter", true)
-                  .set("noHScroll", true)
-                  .set("above", line == -1)));
-    }
-  }
-
-  private void addDiffChunk(CodeMirror cmToPad, int lineOnOther, int chunkSize, boolean edit) {
-    chunks.add(
-        new DiffChunkInfo(
-            host.otherCm(cmToPad).side(), lineOnOther - chunkSize + 1, lineOnOther, edit));
-  }
-
-  @Override
-  Runnable diffChunkNav(CodeMirror cm, Direction dir) {
-    return () -> {
-      int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
-      int res =
-          Collections.binarySearch(
-              chunks, new DiffChunkInfo(cm.side(), line, 0, false), getDiffChunkComparator());
-      diffChunkNavHelper(chunks, host, res, dir);
-    };
-  }
-
-  @Override
-  int getCmLine(int line, DisplaySide side) {
-    return line;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
deleted file mode 100644
index c728f6f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
+++ /dev/null
@@ -1,159 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Unit;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Timer;
-import java.util.PriorityQueue;
-import net.codemirror.lib.CodeMirror;
-
-/**
- * LineWidget attached to a CodeMirror container.
- *
- * <p>When a comment is placed on a line a CommentWidget is created on both sides. The group tracks
- * all comment boxes on that same line, and also includes an empty padding element to keep
- * subsequent lines vertically aligned.
- */
-class SideBySideCommentGroup extends CommentGroup implements Comparable<SideBySideCommentGroup> {
-  static void pair(SideBySideCommentGroup a, SideBySideCommentGroup b) {
-    a.peers.add(b);
-    b.peers.add(a);
-  }
-
-  private final Element padding;
-  private final PriorityQueue<SideBySideCommentGroup> peers;
-
-  SideBySideCommentGroup(
-      SideBySideCommentManager manager, CodeMirror cm, DisplaySide side, int line) {
-    super(manager, cm, side, line);
-
-    padding = DOM.createDiv();
-    padding.setClassName(SideBySideTable.style.padding());
-    SideBySideChunkManager.focusOnClick(padding, cm.side());
-    getElement().appendChild(padding);
-    peers = new PriorityQueue<>();
-  }
-
-  SideBySideCommentGroup getPeer() {
-    return peers.peek();
-  }
-
-  @Override
-  void remove(DraftBox box) {
-    super.remove(box);
-
-    if (getBoxCount() == 0 && peers.size() == 1 && peers.peek().peers.size() > 1) {
-      SideBySideCommentGroup peer = peers.peek();
-      peer.peers.remove(this);
-      detach();
-      if (peer.getBoxCount() == 0
-          && peer.peers.size() == 1
-          && peer.peers.peek().getBoxCount() == 0) {
-        peer.detach();
-      } else {
-        peer.resize();
-      }
-    } else {
-      resize();
-    }
-  }
-
-  @Override
-  void init(DiffTable parent) {
-    if (getLineWidget() == null) {
-      attach(parent);
-    }
-    for (CommentGroup peer : peers) {
-      if (peer.getLineWidget() == null) {
-        peer.attach(parent);
-      }
-    }
-  }
-
-  @Override
-  void handleRedraw() {
-    getLineWidget()
-        .onRedraw(
-            () -> {
-              if (canComputeHeight() && peers.peek().canComputeHeight()) {
-                if (getResizeTimer() != null) {
-                  getResizeTimer().cancel();
-                  setResizeTimer(null);
-                }
-                adjustPadding(SideBySideCommentGroup.this, peers.peek());
-              } else if (getResizeTimer() == null) {
-                setResizeTimer(
-                    new Timer() {
-                      @Override
-                      public void run() {
-                        if (canComputeHeight() && peers.peek().canComputeHeight()) {
-                          cancel();
-                          setResizeTimer(null);
-                          adjustPadding(SideBySideCommentGroup.this, peers.peek());
-                        }
-                      }
-                    });
-                getResizeTimer().scheduleRepeating(5);
-              }
-            });
-  }
-
-  @Override
-  void resize() {
-    if (getLineWidget() != null) {
-      adjustPadding(this, peers.peek());
-    }
-  }
-
-  private int computeHeight() {
-    if (getComments().isVisible()) {
-      // Include margin-bottom: 5px from CSS class.
-      return getComments().getOffsetHeight() + 5;
-    }
-    return 0;
-  }
-
-  private static void adjustPadding(SideBySideCommentGroup a, SideBySideCommentGroup b) {
-    int apx = a.computeHeight();
-    int bpx = b.computeHeight();
-    for (SideBySideCommentGroup otherPeer : a.peers) {
-      if (otherPeer != b) {
-        bpx += otherPeer.computeHeight();
-      }
-    }
-    for (SideBySideCommentGroup otherPeer : b.peers) {
-      if (otherPeer != a) {
-        apx += otherPeer.computeHeight();
-      }
-    }
-    int h = Math.max(apx, bpx);
-    a.padding.getStyle().setHeight(Math.max(0, h - apx), Unit.PX);
-    b.padding.getStyle().setHeight(Math.max(0, h - bpx), Unit.PX);
-    a.getLineWidget().changed();
-    b.getLineWidget().changed();
-    a.updateSelection();
-    b.updateSelection();
-  }
-
-  @Override
-  public int compareTo(SideBySideCommentGroup o) {
-    if (side == o.side) {
-      return line - o.line;
-    }
-    throw new IllegalStateException("Cannot compare SideBySideCommentGroup with different sides");
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
deleted file mode 100644
index 09c5b07..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import java.util.Collection;
-import java.util.Map;
-import java.util.SortedMap;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.TextMarker.FromTo;
-
-/** Tracks comment widgets for {@link SideBySide}. */
-class SideBySideCommentManager extends CommentManager {
-  SideBySideCommentManager(
-      SideBySide host,
-      @Nullable Project.NameKey project,
-      DiffObject base,
-      PatchSet.Id revision,
-      String path,
-      CommentLinkProcessor clp,
-      boolean open) {
-    super(host, project, base, revision, path, clp, open);
-  }
-
-  @Override
-  SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side) {
-    return map(side);
-  }
-
-  @Override
-  void clearLine(DisplaySide side, int line, CommentGroup group) {
-    super.clearLine(side, line, group);
-  }
-
-  @Override
-  void newDraftOnGutterClick(CodeMirror cm, String gutterClass, int line) {
-    if (!Gerrit.isSignedIn()) {
-      signInCallback(cm).run();
-    } else {
-      insertNewDraft(cm.side(), line);
-    }
-  }
-
-  @Override
-  CommentGroup getCommentGroupOnActiveLine(CodeMirror cm) {
-    CommentGroup group = null;
-    if (cm.extras().hasActiveLine()) {
-      group = map(cm.side()).get(cm.getLineNumber(cm.extras().activeLine()) + 1);
-    }
-    return group;
-  }
-
-  @Override
-  Collection<Integer> getLinesWithCommentGroups() {
-    return sideB.tailMap(1).keySet();
-  }
-
-  @Override
-  String getTokenSuffixForActiveLine(CodeMirror cm) {
-    return (cm.side() == DisplaySide.A ? "a" : "")
-        + (cm.getLineNumber(cm.extras().activeLine()) + 1);
-  }
-
-  @Override
-  void newDraft(CodeMirror cm) {
-    int line = cm.getLineNumber(cm.extras().activeLine()) + 1;
-    if (cm.somethingSelected()) {
-      FromTo fromTo = adjustSelection(cm);
-      addDraftBox(
-              cm.side(),
-              CommentInfo.create(
-                  getPath(),
-                  getStoredSideFromDisplaySide(cm.side()),
-                  getParentNumFromDisplaySide(cm.side()),
-                  line,
-                  CommentRange.create(fromTo),
-                  false))
-          .setEdit(true);
-      cm.setCursor(fromTo.to());
-      cm.setSelection(cm.getCursor());
-    } else {
-      insertNewDraft(cm.side(), line);
-    }
-  }
-
-  @Override
-  CommentGroup group(DisplaySide side, int line) {
-    CommentGroup existing = map(side).get(line);
-    if (existing != null) {
-      return existing;
-    }
-
-    SideBySideCommentGroup newGroup = newGroup(side, line);
-    Map<Integer, CommentGroup> map = side == DisplaySide.A ? sideA : sideB;
-    Map<Integer, CommentGroup> otherMap = side == DisplaySide.A ? sideB : sideA;
-    map.put(line, newGroup);
-    int otherLine = host.lineOnOther(side, line - 1).getLine() + 1;
-    existing = map(side.otherSide()).get(otherLine);
-    CommentGroup otherGroup;
-    if (existing != null) {
-      otherGroup = existing;
-    } else {
-      otherGroup = newGroup(side.otherSide(), otherLine);
-      otherMap.put(otherLine, otherGroup);
-    }
-    SideBySideCommentGroup.pair(newGroup, (SideBySideCommentGroup) otherGroup);
-
-    if (isAttached()) {
-      newGroup.init(host.getDiffTable());
-      otherGroup.handleRedraw();
-    }
-    return newGroup;
-  }
-
-  private SideBySideCommentGroup newGroup(DisplaySide side, int line) {
-    return new SideBySideCommentGroup(this, host.getCmFromSide(side), side, line);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
deleted file mode 100644
index c65dcf0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.ui.HTMLPanel;
-
-/**
- * A table with one row and two columns to hold the two CodeMirrors displaying the files to be
- * compared.
- */
-class SideBySideTable extends DiffTable {
-  interface Binder extends UiBinder<HTMLPanel, SideBySideTable> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface DiffTableStyle extends CssResource {
-    String intralineBg();
-
-    String diff();
-
-    String hideA();
-
-    String hideB();
-
-    String padding();
-  }
-
-  private SideBySide parent;
-  @UiField Element cmA;
-  @UiField Element cmB;
-  @UiField static DiffTableStyle style;
-
-  private boolean visibleA;
-
-  SideBySideTable(SideBySide parent, DiffObject base, DiffObject revision, String path) {
-    super(parent, base, revision, path);
-
-    initWidget(uiBinder.createAndBindUi(this));
-    this.visibleA = true;
-    this.parent = parent;
-  }
-
-  @Override
-  boolean isVisibleA() {
-    return visibleA;
-  }
-
-  void setVisibleA(boolean show) {
-    visibleA = show;
-    if (show) {
-      removeStyleName(style.hideA());
-      parent.syncScroll(DisplaySide.B); // match B's viewport
-    } else {
-      addStyleName(style.hideA());
-    }
-  }
-
-  Runnable toggleA() {
-    return () -> setVisibleA(!isVisibleA());
-  }
-
-  void setVisibleB(boolean show) {
-    if (show) {
-      removeStyleName(style.hideB());
-      parent.syncScroll(DisplaySide.A); // match A's viewport
-    } else {
-      addStyleName(style.hideB());
-    }
-  }
-
-  @Override
-  void setHideEmptyPane(boolean hide) {
-    if (getChangeType() == ChangeType.ADDED) {
-      setVisibleA(!hide);
-    } else if (getChangeType() == ChangeType.DELETED) {
-      setVisibleB(!hide);
-    }
-  }
-
-  @Override
-  SideBySide getDiffScreen() {
-    return parent;
-  }
-
-  @Override
-  int getHeaderHeight() {
-    int h = patchSetSelectBoxA.getOffsetHeight();
-    if (hasHeader()) {
-      h += diffHeaderRow.getOffsetHeight();
-    }
-    return h;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.ui.xml
deleted file mode 100644
index b2e3f43..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.ui.xml
+++ /dev/null
@@ -1,147 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:d='urn:import:com.google.gerrit.client.diff'>
-  <ui:with field='res' type='com.google.gerrit.client.diff.Resources'/>
-  <ui:style gss='false' type='com.google.gerrit.client.diff.SideBySideTable.DiffTableStyle'>
-    @external .CodeMirror, .CodeMirror-selectedtext;
-    @external .CodeMirror-linenumber;
-    @external .CodeMirror-overlayscroll-vertical, .CodeMirror-scroll;
-    @external .CodeMirror-dialog-bottom;
-    @external .CodeMirror-cursor;
-
-    @external .dark, .noIntraline, .showLineNumbers;
-
-    .difftable .patchSetNav,
-    .difftable .CodeMirror {
-      -webkit-touch-callout: none;
-      -webkit-user-select: none;
-      -khtml-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-    }
-
-    .difftable .CodeMirror pre {
-      overflow: visible;
-      border-right: 0;
-      width: auto;
-    }
-
-    /* Preserve space for underscores. If this changes
-     * see ChunkManager.addPadding() and adjust there.
-     */
-    .difftable .CodeMirror pre,
-    .difftable .CodeMirror pre span {
-      padding-bottom: 1px;
-    }
-
-    .hideA .psNavA,
-    .hideA .a {
-      display: none;
-    }
-
-    .hideB .psNavB,
-    .hideB .b {
-      display: none;
-    }
-
-    .table {
-      width: 100%;
-      table-layout: fixed;
-      border-spacing: 0;
-    }
-    .table td { padding: 0 }
-    .a, .b { width: 50% }
-    .hideA .psNavB, .hideA .b { width: 100% }
-    .hideB .psNavA, .hideB .a { width: 100% }
-
-    /* Hide scrollbars on A, B controls both views. */
-    .a .CodeMirror-scroll { margin-right: -36px; }
-    .a .CodeMirror-overlayscroll-vertical { display: none !important; }
-
-    .showLineNumbers .b { border-left: none; }
-    .b { border-left: 1px solid #ddd; }
-
-    .a .diff { background-color: #faa; }
-    /* Set min-width for lineWrapping to make sure it gets enough width
-       before lineWrapping and to make sure it dosent do a ugly line wrap */
-    .b .diff { background-color: #9f9; min-width: 60em; }
-    .a .intralineBg { background-color: #fee; }
-    .b .intralineBg { background-color: #dfd; }
-    .noIntraline .a .intralineBg { background-color: #faa; }
-    .noIntraline .b .intralineBg { background-color: #9f9; }
-
-    .dark .a .diff { background-color: #400; }
-    .dark .b .diff { background-color: #444; }
-
-    .dark .a .intralineBg { background-color: #888; }
-    .dark .b .intralineBg { background-color: #bbb; }
-    .dark .noIntraline .a .intralineBg { background-color: #400; }
-    .dark .noIntraline .b .intralineBg { background-color: #444; }
-
-    .patchSetNav, .diff_header {
-      background-color: #f7f7f7;
-      line-height: 1;
-    }
-
-    .difftable .CodeMirror-selectedtext {
-      background-color: inherit !important;
-    }
-    .difftable .CodeMirror-linenumber {
-      height: 1.11em;
-      cursor: pointer;
-    }
-    .difftable .CodeMirror div.CodeMirror-cursor {
-      border-left: 2px solid black;
-    }
-    .difftable .CodeMirror-dialog-bottom {
-      border-top: 0;
-      border-left: 1px solid #000;
-      border-bottom: 1px solid #000;
-      background-color: #f7f7f7;
-      top: 0;
-      right: 0;
-      bottom: auto;
-      left: auto;
-    }
-    .showLineNumbers .padding {
-      margin-left: 21px;
-      border-left: 2px solid #d64040;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.difftable}'>
-    <table class='{style.table}'>
-      <tr ui:field='patchSetNavRow' class='{style.patchSetNav}'>
-        <td ui:field='patchSetNavCellA' class='{style.psNavA}'>
-          <d:PatchSetSelectBox ui:field='patchSetSelectBoxA' />
-        </td>
-        <td ui:field='patchSetNavCellB' class='{style.psNavB}'>
-          <d:PatchSetSelectBox ui:field='patchSetSelectBoxB' />
-        </td>
-      </tr>
-      <tr ui:field='diffHeaderRow' class='{res.diffTableStyle.diffHeader}'>
-        <td colspan='2'><pre ui:field='diffHeaderText' /></td>
-      </tr>
-      <tr>
-        <td ui:field='cmA' class='{style.a}' />
-        <td ui:field='cmB' class='{style.b}' />
-      </tr>
-    </table>
-    <g:FlowPanel ui:field='widgets' visible='false'/>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
deleted file mode 100644
index c138f37..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
+++ /dev/null
@@ -1,217 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Style.Unit;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Configuration;
-import net.codemirror.lib.LineWidget;
-import net.codemirror.lib.Pos;
-import net.codemirror.lib.TextMarker;
-import net.codemirror.lib.TextMarker.FromTo;
-
-class SkipBar extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, SkipBar> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-  private static final int NUM_ROWS_TO_EXPAND = 10;
-  private static final int UP_DOWN_THRESHOLD = 30;
-
-  interface SkipBarStyle extends CssResource {
-    String noExpand();
-  }
-
-  @UiField(provided = true)
-  Anchor skipNum;
-
-  @UiField(provided = true)
-  Anchor upArrow;
-
-  @UiField(provided = true)
-  Anchor downArrow;
-
-  @UiField SkipBarStyle style;
-
-  private final SkipManager manager;
-  private final CodeMirror cm;
-
-  private LineWidget lineWidget;
-  private TextMarker textMarker;
-  private SkipBar otherBar;
-
-  SkipBar(SkipManager manager, CodeMirror cm) {
-    this.manager = manager;
-    this.cm = cm;
-
-    skipNum = new Anchor(true);
-    upArrow = new Anchor(true);
-    downArrow = new Anchor(true);
-    initWidget(uiBinder.createAndBindUi(this));
-    addDomHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            cm.focus();
-          }
-        },
-        ClickEvent.getType());
-  }
-
-  void collapse(int start, int end, boolean attach) {
-    if (attach) {
-      boolean isNew = lineWidget == null;
-      Configuration cfg = Configuration.create().set("coverGutter", true).set("noHScroll", true);
-      if (start == 0) { // First line workaround
-        lineWidget = cm.addLineWidget(end + 1, getElement(), cfg.set("above", true));
-      } else {
-        lineWidget = cm.addLineWidget(start - 1, getElement(), cfg);
-      }
-      if (isNew) {
-        lineWidget.onFirstRedraw(
-            () -> {
-              int w = cm.getGutterElement().getOffsetWidth();
-              getElement().getStyle().setPaddingLeft(w, Unit.PX);
-            });
-      }
-    }
-
-    textMarker =
-        cm.markText(
-            Pos.create(start, 0),
-            Pos.create(end),
-            Configuration.create()
-                .set("collapsed", true)
-                .set("inclusiveLeft", true)
-                .set("inclusiveRight", true));
-
-    textMarker.on("beforeCursorEnter", this::expandAll);
-
-    int skipped = end - start + 1;
-    if (skipped <= UP_DOWN_THRESHOLD) {
-      addStyleName(style.noExpand());
-    } else {
-      upArrow.setHTML(PatchUtil.M.expandBefore(NUM_ROWS_TO_EXPAND));
-      downArrow.setHTML(PatchUtil.M.expandAfter(NUM_ROWS_TO_EXPAND));
-    }
-    skipNum.setText(PatchUtil.M.patchSkipRegion(Integer.toString(skipped)));
-  }
-
-  static void link(SkipBar barA, SkipBar barB) {
-    barA.otherBar = barB;
-    barB.otherBar = barA;
-  }
-
-  private void clearMarkerAndWidget() {
-    textMarker.clear();
-    lineWidget.clear();
-  }
-
-  void expandBefore(int cnt) {
-    expandSideBefore(cnt);
-
-    if (otherBar != null) {
-      otherBar.expandSideBefore(cnt);
-    }
-  }
-
-  private void expandSideBefore(int cnt) {
-    FromTo range = textMarker.find();
-    int oldStart = range.from().line();
-    int newStart = oldStart + cnt;
-    int end = range.to().line();
-    clearMarkerAndWidget();
-    collapse(newStart, end, true);
-    updateSelection();
-  }
-
-  void expandSideAll() {
-    clearMarkerAndWidget();
-    removeFromParent();
-  }
-
-  private void expandAfter() {
-    FromTo range = textMarker.find();
-    int start = range.from().line();
-    int oldEnd = range.to().line();
-    int newEnd = oldEnd - NUM_ROWS_TO_EXPAND;
-    boolean attach = start == 0;
-    if (attach) {
-      clearMarkerAndWidget();
-    } else {
-      textMarker.clear();
-    }
-    collapse(start, newEnd, attach);
-    updateSelection();
-  }
-
-  private void updateSelection() {
-    if (cm.somethingSelected()) {
-      FromTo sel = cm.getSelectedRange();
-      cm.setSelection(sel.from(), sel.to());
-    }
-  }
-
-  @UiHandler("skipNum")
-  void onExpandAll(@SuppressWarnings("unused") ClickEvent e) {
-    expandAll();
-    updateSelection();
-    if (otherBar != null) {
-      otherBar.expandAll();
-      otherBar.updateSelection();
-    }
-    cm.refresh();
-    cm.focus();
-  }
-
-  private void expandAll() {
-    expandSideAll();
-    if (otherBar != null) {
-      otherBar.expandSideAll();
-    }
-    manager.remove(this, otherBar);
-  }
-
-  @UiHandler("upArrow")
-  void onExpandBefore(@SuppressWarnings("unused") ClickEvent e) {
-    expandBefore(NUM_ROWS_TO_EXPAND);
-    if (otherBar != null) {
-      otherBar.expandBefore(NUM_ROWS_TO_EXPAND);
-    }
-    cm.refresh();
-    cm.focus();
-  }
-
-  @UiHandler("downArrow")
-  void onExpandAfter(@SuppressWarnings("unused") ClickEvent e) {
-    expandAfter();
-
-    if (otherBar != null) {
-      otherBar.expandAfter();
-    }
-    cm.refresh();
-    cm.focus();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.ui.xml
deleted file mode 100644
index bf3c425..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.ui.xml
+++ /dev/null
@@ -1,52 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:style gss='false' type='com.google.gerrit.client.diff.SkipBar.SkipBarStyle'>
-    .skipBar {
-      background-color: #def;
-      height: 1.3em;
-      overflow: hidden;
-    }
-    .text {
-      display: table;
-      margin: 0 auto;
-      color: #777;
-      font-style: italic;
-      overflow: hidden;
-    }
-    .anchor {
-      color: inherit;
-      text-decoration: none;
-    }
-    .noExpand .arrow {
-      display: none;
-    }
-    .arrow {
-      font-family: Arial Unicode MS, sans-serif;
-    }
-  </ui:style>
-  <g:HTMLPanel addStyleNames='{style.skipBar}'>
-  <div class='{style.text}'>
-    <ui:msg>
-      <g:Anchor ui:field='upArrow' addStyleNames='{style.arrow} {style.anchor}' />
-      <g:Anchor ui:field='skipNum' addStyleNames='{style.anchor}' />
-      <g:Anchor ui:field='downArrow' addStyleNames=' {style.arrow} {style.anchor}' />
-    </ui:msg>
-  </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java
deleted file mode 100644
index 533ba1f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java
+++ /dev/null
@@ -1,149 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.diff.DiffInfo.Region;
-import com.google.gerrit.client.patches.SkippedLine;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gwt.core.client.JsArray;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import net.codemirror.lib.CodeMirror;
-
-/** Collapses common regions with {@link SkipBar} for {@link SideBySide} and {@link Unified}. */
-class SkipManager {
-  private final Set<SkipBar> skipBars;
-  private final DiffScreen host;
-  private SkipBar line0;
-
-  SkipManager(DiffScreen host) {
-    this.host = host;
-    this.skipBars = new HashSet<>();
-  }
-
-  void render(int context, DiffInfo diff) {
-    if (context == DiffPreferencesInfo.WHOLE_FILE_CONTEXT) {
-      return;
-    }
-
-    List<SkippedLine> skips = new ArrayList<>();
-    int lineA = 0;
-    int lineB = 0;
-    JsArray<Region> regions = diff.content();
-    for (int i = 0; i < regions.length(); i++) {
-      Region current = regions.get(i);
-      if (current.ab() != null || current.common() || current.skip() > 0) {
-        int len =
-            current.skip() > 0
-                ? current.skip()
-                : (current.ab() != null ? current.ab() : current.b()).length();
-        if (i == 0 && len > context + 1) {
-          skips.add(new SkippedLine(0, 0, len - context));
-        } else if (i == regions.length() - 1 && len > context + 1) {
-          skips.add(new SkippedLine(lineA + context, lineB + context, len - context));
-        } else if (len > 2 * context + 1) {
-          skips.add(new SkippedLine(lineA + context, lineB + context, len - 2 * context));
-        }
-        lineA += len;
-        lineB += len;
-      } else {
-        lineA += current.a() != null ? current.a().length() : 0;
-        lineB += current.b() != null ? current.b().length() : 0;
-      }
-    }
-    skips = host.getCommentManager().splitSkips(context, skips);
-    renderSkips(skips, lineA, lineB);
-  }
-
-  private void renderSkips(List<SkippedLine> skips, int lineA, int lineB) {
-    if (!skips.isEmpty()) {
-      boolean isSideBySide = host.isSideBySide();
-      CodeMirror cmA = null;
-      if (isSideBySide) {
-        cmA = host.getCmFromSide(DisplaySide.A);
-      }
-      CodeMirror cmB = host.getCmFromSide(DisplaySide.B);
-
-      for (SkippedLine skip : skips) {
-        SkipBar barA = null;
-        SkipBar barB = newSkipBar(cmB, DisplaySide.B, skip);
-        skipBars.add(barB);
-        if (isSideBySide) {
-          barA = newSkipBar(cmA, DisplaySide.A, skip);
-          SkipBar.link(barA, barB);
-          skipBars.add(barA);
-        }
-
-        if (skip.getStartA() == 0 || skip.getStartB() == 0) {
-          if (isSideBySide) {
-            barA.upArrow.setVisible(false);
-          }
-          barB.upArrow.setVisible(false);
-          setLine0(barB);
-        } else if (skip.getStartA() + skip.getSize() == lineA
-            || skip.getStartB() + skip.getSize() == lineB) {
-          if (isSideBySide) {
-            barA.downArrow.setVisible(false);
-          }
-          barB.downArrow.setVisible(false);
-        }
-      }
-    }
-  }
-
-  private SkipBar newSkipBar(CodeMirror cm, DisplaySide side, SkippedLine skip) {
-    int start = host.getCmLine(side == DisplaySide.A ? skip.getStartA() : skip.getStartB(), side);
-    int end = start + skip.getSize() - 1;
-
-    SkipBar bar = new SkipBar(this, cm);
-    host.getDiffTable().add(bar);
-    bar.collapse(start, end, true);
-    return bar;
-  }
-
-  void ensureFirstLineIsVisible() {
-    if (line0 != null) {
-      line0.expandBefore(1);
-      line0 = null;
-    }
-  }
-
-  void removeAll() {
-    if (!skipBars.isEmpty()) {
-      for (SkipBar bar : skipBars) {
-        bar.expandSideAll();
-      }
-      line0 = null;
-    }
-  }
-
-  void remove(SkipBar a, SkipBar b) {
-    skipBars.remove(a);
-    skipBars.remove(b);
-    if (getLine0() == a || getLine0() == b) {
-      setLine0(null);
-    }
-  }
-
-  SkipBar getLine0() {
-    return line0;
-  }
-
-  void setLine0(SkipBar bar) {
-    line0 = bar;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
deleted file mode 100644
index 7bd9804..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
+++ /dev/null
@@ -1,383 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import static java.lang.Double.POSITIVE_INFINITY;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.diff.UnifiedChunkManager.LineRegionInfo;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.client.projects.ConfigInfoCache;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.FocusEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.ImageResourceRenderer;
-import com.google.gwt.user.client.ui.InlineHTML;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import java.util.Collections;
-import java.util.List;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.LineHandle;
-import net.codemirror.lib.Configuration;
-import net.codemirror.lib.Pos;
-import net.codemirror.lib.ScrollInfo;
-
-public class Unified extends DiffScreen {
-  interface Binder extends UiBinder<FlowPanel, Unified> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  @UiField(provided = true)
-  UnifiedTable diffTable;
-
-  private CodeMirror cm;
-
-  private UnifiedChunkManager chunkManager;
-  private UnifiedCommentManager commentManager;
-
-  private boolean autoHideDiffTableHeader;
-
-  public Unified(
-      @Nullable Project.NameKey project,
-      DiffObject base,
-      DiffObject revision,
-      String path,
-      DisplaySide startSide,
-      int startLine) {
-    super(project, base, revision, path, startSide, startLine, DiffView.UNIFIED_DIFF);
-
-    diffTable = new UnifiedTable(this, base, revision, path);
-    add(uiBinder.createAndBindUi(this));
-    addDomHandler(GlobalKey.STOP_PROPAGATION, KeyPressEvent.getType());
-  }
-
-  @Override
-  ScreenLoadCallback<ConfigInfoCache.Entry> getScreenLoadCallback(
-      final CommentsCollections comments) {
-    return new ScreenLoadCallback<ConfigInfoCache.Entry>(Unified.this) {
-      @Override
-      protected void preDisplay(ConfigInfoCache.Entry result) {
-        commentManager =
-            new UnifiedCommentManager(
-                Unified.this,
-                getProject(),
-                base,
-                revision,
-                path,
-                result.getCommentLinkProcessor(),
-                getChangeStatus().isOpen());
-        setTheme(result.getTheme());
-        display(comments);
-        header.setupPrevNextFiles(comments);
-      }
-    };
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-
-    operation(
-        () -> {
-          resizeCodeMirror();
-          cm.refresh();
-        });
-    setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
-    diffTable.refresh();
-
-    if (getStartLine() == 0) {
-      DiffChunkInfo d = chunkManager.getFirst();
-      if (d != null) {
-        if (d.isEdit() && d.getSide() == DisplaySide.A) {
-          setStartSide(DisplaySide.B);
-        } else {
-          setStartSide(d.getSide());
-        }
-        setStartLine(chunkManager.getCmLine(d.getStart(), d.getSide()) + 1);
-      }
-    }
-    if (getStartSide() != null && getStartLine() > 0) {
-      cm.scrollToLine(chunkManager.getCmLine(getStartLine() - 1, getStartSide()));
-      cm.focus();
-    } else {
-      cm.setCursor(Pos.create(0));
-      cm.focus();
-    }
-    if (Gerrit.isSignedIn() && prefs.autoReview()) {
-      header.autoReview();
-    }
-    prefetchNextFile();
-  }
-
-  @Override
-  void registerCmEvents(CodeMirror cm) {
-    super.registerCmEvents(cm);
-
-    cm.on(
-        "scroll",
-        () -> {
-          ScrollInfo si = cm.getScrollInfo();
-          if (autoHideDiffTableHeader) {
-            updateDiffTableHeader(si);
-          }
-        });
-    maybeRegisterRenderEntireFileKeyMap(cm);
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-
-    registerHandlers();
-  }
-
-  @Override
-  FocusHandler getFocusHandler() {
-    return new FocusHandler() {
-      @Override
-      public void onFocus(FocusEvent event) {
-        cm.focus();
-      }
-    };
-  }
-
-  private void display(CommentsCollections comments) {
-    DiffInfo diff = getDiff();
-    setThemeStyles(prefs.theme().isDark());
-    setShowIntraline(prefs.intralineDifference());
-    if (prefs.showLineNumbers()) {
-      diffTable.addStyleName(Resources.I.diffTableStyle().showLineNumbers());
-    }
-
-    cm =
-        newCm(diff.metaA() == null ? diff.metaB() : diff.metaA(), diff.textUnified(), diffTable.cm);
-    setShowTabs(prefs.showTabs());
-
-    chunkManager = new UnifiedChunkManager(this, cm, diffTable.scrollbar);
-
-    operation(
-        () -> {
-          // Estimate initial CodeMirror height, fixed up in onShowView.
-          int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
-          cm.setHeight(height);
-
-          render(diff);
-          commentManager.render(comments, prefs.expandAllComments());
-          skipManager.render(prefs.context(), diff);
-        });
-
-    registerCmEvents(cm);
-
-    setPrefsAction(new PreferencesAction(this, prefs));
-    header.init(getPrefsAction(), getSideBySideDiffLink(), diff.unifiedWebLinks());
-    setAutoHideDiffHeader(prefs.autoHideDiffTableHeader());
-
-    setupSyntaxHighlighting();
-  }
-
-  private List<InlineHyperlink> getSideBySideDiffLink() {
-    InlineHyperlink toSideBySideDiffLink = new InlineHyperlink();
-    toSideBySideDiffLink.setHTML(
-        new ImageResourceRenderer().render(Gerrit.RESOURCES.sideBySideDiff()));
-    toSideBySideDiffLink.setTargetHistoryToken(
-        Dispatcher.toSideBySide(getProject(), base, revision, path));
-    toSideBySideDiffLink.setTitle(PatchUtil.C.sideBySideDiff());
-    return Collections.singletonList(toSideBySideDiffLink);
-  }
-
-  @Override
-  CodeMirror newCm(DiffInfo.FileMeta meta, String contents, Element parent) {
-    JsArrayString gutters = JavaScriptObject.createArray().cast();
-    gutters.push(UnifiedTable.style.lineNumbersLeft());
-    gutters.push(UnifiedTable.style.lineNumbersRight());
-
-    return CodeMirror.create(
-        parent,
-        Configuration.create()
-            .set("cursorBlinkRate", prefs.cursorBlinkRate())
-            .set("cursorHeight", 0.85)
-            .set("gutters", gutters)
-            .set("inputStyle", "textarea")
-            .set("keyMap", "vim_ro")
-            .set("lineNumbers", false)
-            .set("lineWrapping", prefs.lineWrapping())
-            .set("matchBrackets", prefs.matchBrackets())
-            .set("mode", getFileSize() == FileSize.SMALL ? getContentType(meta) : null)
-            .set("readOnly", true)
-            .set("scrollbarStyle", "overlay")
-            .set("styleSelectedText", true)
-            .set("showTrailingSpace", prefs.showWhitespaceErrors())
-            .set("tabSize", prefs.tabSize())
-            .set("theme", prefs.theme().name().toLowerCase())
-            .set("value", meta != null ? contents : "")
-            .set("viewportMargin", renderEntireFile() ? POSITIVE_INFINITY : 10));
-  }
-
-  @Override
-  void setShowLineNumbers(boolean b) {
-    super.setShowLineNumbers(b);
-
-    cm.refresh();
-  }
-
-  private void setLineNumber(DisplaySide side, int cmLine, Integer line, String styleName) {
-    SafeHtml html = SafeHtml.asis(line != null ? line.toString() : "&nbsp;");
-    InlineHTML gutter = new InlineHTML(html);
-    diffTable.add(gutter);
-    gutter.setStyleName(styleName);
-    cm.setGutterMarker(
-        cmLine,
-        side == DisplaySide.A
-            ? UnifiedTable.style.lineNumbersLeft()
-            : UnifiedTable.style.lineNumbersRight(),
-        gutter.getElement());
-  }
-
-  void setLineNumber(DisplaySide side, int cmLine, int line) {
-    setLineNumber(side, cmLine, line, UnifiedTable.style.unifiedLineNumber());
-  }
-
-  void setLineNumberEmpty(DisplaySide side, int cmLine) {
-    setLineNumber(side, cmLine, null, UnifiedTable.style.unifiedLineNumberEmpty());
-  }
-
-  @Override
-  void setSyntaxHighlighting(boolean b) {
-    final DiffInfo diff = getDiff();
-    if (b) {
-      injectMode(
-          diff,
-          new AsyncCallback<Void>() {
-            @Override
-            public void onSuccess(Void result) {
-              if (prefs.syntaxHighlighting()) {
-                cm.setOption(
-                    "mode", getContentType(diff.metaA() == null ? diff.metaB() : diff.metaA()));
-              }
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              prefs.syntaxHighlighting(false);
-            }
-          });
-    } else {
-      cm.setOption("mode", (String) null);
-    }
-  }
-
-  @Override
-  void setAutoHideDiffHeader(boolean autoHide) {
-    if (autoHide) {
-      updateDiffTableHeader(cm.getScrollInfo());
-    } else {
-      diffTable.setHeaderVisible(true);
-    }
-    autoHideDiffTableHeader = autoHide;
-  }
-
-  private void updateDiffTableHeader(ScrollInfo si) {
-    if (si.top() == 0) {
-      diffTable.setHeaderVisible(true);
-    } else if (si.top() > 0.5 * si.clientHeight()) {
-      diffTable.setHeaderVisible(false);
-    }
-  }
-
-  @Override
-  Runnable updateActiveLine(CodeMirror cm) {
-    return () -> {
-      // The rendering of active lines has to be deferred. Reflow
-      // caused by adding and removing styles chokes Firefox when arrow
-      // key (or j/k) is held down. Performance on Chrome is fine
-      // without the deferral.
-      //
-      Scheduler.get()
-          .scheduleDeferred(
-              () -> {
-                LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                cm.extras().activeLine(handle);
-              });
-    };
-  }
-
-  @Override
-  CodeMirror getCmFromSide(DisplaySide side) {
-    return cm;
-  }
-
-  @Override
-  int getCmLine(int line, DisplaySide side) {
-    return chunkManager.getCmLine(line, side);
-  }
-
-  LineRegionInfo getLineRegionInfoFromCmLine(int cmLine) {
-    return chunkManager.getLineRegionInfoFromCmLine(cmLine);
-  }
-
-  @Override
-  void operation(Runnable apply) {
-    cm.operation(apply::run);
-  }
-
-  @Override
-  CodeMirror[] getCms() {
-    return new CodeMirror[] {cm};
-  }
-
-  @Override
-  UnifiedTable getDiffTable() {
-    return diffTable;
-  }
-
-  @Override
-  UnifiedChunkManager getChunkManager() {
-    return chunkManager;
-  }
-
-  @Override
-  UnifiedCommentManager getCommentManager() {
-    return commentManager;
-  }
-
-  @Override
-  boolean isSideBySide() {
-    return false;
-  }
-
-  @Override
-  String getLineNumberClassName() {
-    return UnifiedTable.style.unifiedLineNumber();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.ui.xml
deleted file mode 100644
index 85f46a6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.ui.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:d='urn:import:com.google.gerrit.client.diff'>
-  <ui:style>
-    .unified {
-      margin-left: -5px;
-      margin-right: -5px;
-    }
-  </ui:style>
-  <g:FlowPanel styleName='{style.unified}'>
-    <d:Header ui:field='header'/>
-    <d:UnifiedTable ui:field='diffTable'/>
-  </g:FlowPanel>
-</ui:UiBinder>
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
deleted file mode 100644
index 1a662e2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
+++ /dev/null
@@ -1,333 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.diff.DiffInfo.Region;
-import com.google.gerrit.client.diff.DiffInfo.Span;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.EventListener;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.LineClassWhere;
-import net.codemirror.lib.Configuration;
-import net.codemirror.lib.Pos;
-
-/** Colors modified regions for {@link Unified}. */
-class UnifiedChunkManager extends ChunkManager {
-  private static final JavaScriptObject focus = initOnClick();
-
-  private static native JavaScriptObject initOnClick() /*-{
-    return $entry(function(e){
-      @com.google.gerrit.client.diff.UnifiedChunkManager::focus(
-        Lcom/google/gwt/dom/client/NativeEvent;)(e)
-    });
-  }-*/;
-
-  private List<UnifiedDiffChunkInfo> chunks;
-
-  @Override
-  DiffChunkInfo getFirst() {
-    return !chunks.isEmpty() ? chunks.get(0) : null;
-  }
-
-  private static void focus(NativeEvent event) {
-    Element e = Element.as(event.getEventTarget());
-    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
-      EventListener l = DOM.getEventListener(e);
-      if (l instanceof Unified) {
-        ((Unified) l).getCmFromSide(DisplaySide.A).focus();
-        event.stopPropagation();
-      }
-    }
-  }
-
-  static void focusOnClick(Element e) {
-    onClick(e, focus);
-  }
-
-  private final Unified host;
-  private final CodeMirror cm;
-
-  UnifiedChunkManager(Unified host, CodeMirror cm, Scrollbar scrollbar) {
-    super(scrollbar);
-
-    this.host = host;
-    this.cm = cm;
-  }
-
-  @Override
-  void render(DiffInfo diff) {
-    super.render();
-
-    chunks = new ArrayList<>();
-
-    int cmLine = 0;
-    boolean useIntralineBg = diff.metaA() == null || diff.metaB() == null;
-
-    for (Region current : Natives.asList(diff.content())) {
-      int origLineA = lineMapper.getLineA();
-      int origLineB = lineMapper.getLineB();
-      if (current.ab() != null) {
-        int length = current.ab().length();
-        lineMapper.appendCommon(length);
-        for (int i = 0; i < length; i++) {
-          host.setLineNumber(DisplaySide.A, cmLine + i, origLineA + i + 1);
-          host.setLineNumber(DisplaySide.B, cmLine + i, origLineB + i + 1);
-        }
-        cmLine += length;
-      } else if (current.skip() > 0) {
-        lineMapper.appendCommon(current.skip());
-        cmLine += current.skip(); // Maybe current.ab().length();
-      } else if (current.common()) {
-        lineMapper.appendCommon(current.b().length());
-        cmLine += current.b().length();
-      } else {
-        cmLine += render(current, cmLine, useIntralineBg);
-      }
-    }
-    host.setLineNumber(DisplaySide.A, cmLine, lineMapper.getLineA() + 1);
-    host.setLineNumber(DisplaySide.B, cmLine, lineMapper.getLineB() + 1);
-  }
-
-  private int render(Region region, int cmLine, boolean useIntralineBg) {
-    int startA = lineMapper.getLineA();
-    int startB = lineMapper.getLineB();
-
-    JsArrayString a = region.a();
-    JsArrayString b = region.b();
-    int aLen = a != null ? a.length() : 0;
-    int bLen = b != null ? b.length() : 0;
-    boolean insertOrDelete = a == null || b == null;
-
-    colorLines(
-        cm,
-        insertOrDelete && !useIntralineBg
-            ? UnifiedTable.style.diffDelete()
-            : UnifiedTable.style.intralineDelete(),
-        cmLine,
-        aLen);
-    colorLines(
-        cm,
-        insertOrDelete && !useIntralineBg
-            ? UnifiedTable.style.diffInsert()
-            : UnifiedTable.style.intralineInsert(),
-        cmLine + aLen,
-        bLen);
-    markEdit(DisplaySide.A, cmLine, a, region.editA());
-    markEdit(DisplaySide.B, cmLine + aLen, b, region.editB());
-    addGutterTag(region, cmLine); // TODO: verify addGutterTag
-    lineMapper.appendReplace(aLen, bLen);
-
-    int endA = lineMapper.getLineA() - 1;
-    int endB = lineMapper.getLineB() - 1;
-    if (aLen > 0) {
-      addDiffChunk(DisplaySide.A, endA, aLen, cmLine, bLen > 0);
-      for (int j = 0; j < aLen; j++) {
-        host.setLineNumber(DisplaySide.A, cmLine + j, startA + j + 1);
-        host.setLineNumberEmpty(DisplaySide.B, cmLine + j);
-      }
-    }
-    if (bLen > 0) {
-      addDiffChunk(DisplaySide.B, endB, bLen, cmLine + aLen, aLen > 0);
-      for (int j = 0; j < bLen; j++) {
-        host.setLineNumberEmpty(DisplaySide.A, cmLine + aLen + j);
-        host.setLineNumber(DisplaySide.B, cmLine + aLen + j, startB + j + 1);
-      }
-    }
-    return aLen + bLen;
-  }
-
-  private void addGutterTag(Region region, int cmLine) {
-    if (region.a() == null) {
-      scrollbar.insert(cm, cmLine, region.b().length());
-    } else if (region.b() == null) {
-      scrollbar.delete(cm, cm, cmLine, region.a().length());
-    } else {
-      scrollbar.edit(cm, cmLine, region.b().length());
-    }
-  }
-
-  private void markEdit(DisplaySide side, int startLine, JsArrayString lines, JsArray<Span> edits) {
-    if (lines == null || edits == null) {
-      return;
-    }
-
-    EditIterator iter = new EditIterator(lines, startLine);
-    Configuration bg =
-        Configuration.create().set("className", getIntralineBgFromSide(side)).set("readOnly", true);
-
-    Configuration diff =
-        Configuration.create().set("className", getDiffColorFromSide(side)).set("readOnly", true);
-
-    Pos last = Pos.create(0, 0);
-    for (Span span : Natives.asList(edits)) {
-      Pos from = iter.advance(span.skip());
-      Pos to = iter.advance(span.mark());
-      if (from.line() == last.line()) {
-        getMarkers().add(cm.markText(last, from, bg));
-      } else {
-        getMarkers().add(cm.markText(Pos.create(from.line(), 0), from, bg));
-      }
-      getMarkers().add(cm.markText(from, to, diff));
-      last = to;
-      colorLines(cm, LineClassWhere.BACKGROUND, getDiffColorFromSide(side), from.line(), to.line());
-    }
-  }
-
-  private String getIntralineBgFromSide(DisplaySide side) {
-    return side == DisplaySide.A
-        ? UnifiedTable.style.intralineDelete()
-        : UnifiedTable.style.intralineInsert();
-  }
-
-  private String getDiffColorFromSide(DisplaySide side) {
-    return side == DisplaySide.A
-        ? UnifiedTable.style.diffDelete()
-        : UnifiedTable.style.diffInsert();
-  }
-
-  private void addDiffChunk(
-      DisplaySide side, int chunkEnd, int chunkSize, int cmLine, boolean edit) {
-    chunks.add(new UnifiedDiffChunkInfo(side, chunkEnd - chunkSize + 1, chunkEnd, cmLine, edit));
-  }
-
-  @Override
-  Runnable diffChunkNav(CodeMirror cm, Direction dir) {
-    return () -> {
-      int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
-      int res =
-          Collections.binarySearch(
-              chunks,
-              new UnifiedDiffChunkInfo(cm.side(), 0, 0, line, false),
-              getDiffChunkComparatorCmLine());
-      diffChunkNavHelper(chunks, host, res, dir);
-    };
-  }
-
-  /** 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();
-      }
-    };
-  }
-
-  @Override
-  int getCmLine(int line, DisplaySide side) {
-    int res =
-        Collections.binarySearch(
-            chunks,
-            new UnifiedDiffChunkInfo(side, line, 0, 0, false), // Dummy DiffChunkInfo
-            getDiffChunkComparator());
-    if (res >= 0) {
-      return chunks.get(res).getCmLine();
-    }
-    // The line might be within a DiffChunk
-    res = -res - 1;
-    if (res > 0) {
-      UnifiedDiffChunkInfo info = chunks.get(res - 1);
-      if (side == DisplaySide.A && info.isEdit() && info.getSide() == DisplaySide.B) {
-        // Need to use the start and cmLine of the deletion chunk
-        UnifiedDiffChunkInfo delete = chunks.get(res - 2);
-        if (line <= delete.getEnd()) {
-          return delete.getCmLine() + line - delete.getStart();
-        }
-        // Need to add the length of the insertion chunk
-        return delete.getCmLine() + line - delete.getStart() + info.getEnd() - info.getStart() + 1;
-      } else if (side == info.getSide()) {
-        return info.getCmLine() + line - info.getStart();
-      } else {
-        return info.getCmLine() + lineMapper.lineOnOther(side, line).getLine() - info.getStart();
-      }
-    }
-    return line;
-  }
-
-  LineRegionInfo getLineRegionInfoFromCmLine(int cmLine) {
-    int res =
-        Collections.binarySearch(
-            chunks,
-            new UnifiedDiffChunkInfo(DisplaySide.A, 0, 0, cmLine, false), // Dummy DiffChunkInfo
-            getDiffChunkComparatorCmLine());
-    if (res >= 0) { // The line is right at the start of a diff chunk.
-      UnifiedDiffChunkInfo info = chunks.get(res);
-      return new LineRegionInfo(info.getStart(), displaySideToRegionType(info.getSide()));
-    }
-    // The line might be within or after a diff chunk.
-    res = -res - 1;
-    if (res > 0) {
-      UnifiedDiffChunkInfo info = chunks.get(res - 1);
-      int lineOnInfoSide = info.getStart() + cmLine - info.getCmLine();
-      if (lineOnInfoSide > info.getEnd()) { // After a diff chunk
-        if (info.getSide() == DisplaySide.A) {
-          // For the common region after a deletion chunk, associate the line
-          // on side B with a common region.
-          return new LineRegionInfo(
-              lineMapper.lineOnOther(DisplaySide.A, lineOnInfoSide).getLine(), RegionType.COMMON);
-        }
-        return new LineRegionInfo(lineOnInfoSide, RegionType.COMMON);
-      }
-      // Within a diff chunk
-      return new LineRegionInfo(lineOnInfoSide, displaySideToRegionType(info.getSide()));
-    }
-    // The line is before any diff chunk, so it always equals cmLine and
-    // belongs to a common region.
-    return new LineRegionInfo(cmLine, RegionType.COMMON);
-  }
-
-  enum RegionType {
-    INSERT,
-    DELETE,
-    COMMON,
-  }
-
-  private static RegionType displaySideToRegionType(DisplaySide side) {
-    return side == DisplaySide.A ? RegionType.DELETE : RegionType.INSERT;
-  }
-
-  /**
-   * Helper class to associate a line in the original file with the type of the region it belongs
-   * to.
-   *
-   * @field line The 0-based line number in the original file. Note that this might be different
-   *     from the line number shown in CodeMirror.
-   * @field type The type of the region the line belongs to. Can be INSERT, DELETE or COMMON.
-   */
-  static class LineRegionInfo {
-    final int line;
-    final RegionType type;
-
-    LineRegionInfo(int line, RegionType type) {
-      this.line = line;
-      this.type = type;
-    }
-
-    DisplaySide getSide() {
-      // Always return DisplaySide.B for INSERT or COMMON
-      return type == RegionType.DELETE ? DisplaySide.A : DisplaySide.B;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
deleted file mode 100644
index 6d5fba3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
+++ /dev/null
@@ -1,88 +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.client.diff;
-
-import com.google.gwt.user.client.Timer;
-import net.codemirror.lib.CodeMirror;
-
-/**
- * LineWidget attached to a CodeMirror container.
- *
- * <p>When a comment is placed on a line a CommentWidget is created. The group tracks all comment
- * boxes on a line in unified diff view.
- */
-class UnifiedCommentGroup extends CommentGroup {
-  UnifiedCommentGroup(UnifiedCommentManager manager, CodeMirror cm, DisplaySide side, int line) {
-    super(manager, cm, side, line);
-  }
-
-  @Override
-  void remove(DraftBox box) {
-    super.remove(box);
-
-    if (0 < getBoxCount()) {
-      resize();
-    } else {
-      detach();
-    }
-  }
-
-  @Override
-  void init(DiffTable parent) {
-    if (getLineWidget() == null) {
-      attach(parent);
-    }
-  }
-
-  @Override
-  void handleRedraw() {
-    getLineWidget()
-        .onRedraw(
-            () -> {
-              if (canComputeHeight()) {
-                if (getResizeTimer() != null) {
-                  getResizeTimer().cancel();
-                  setResizeTimer(null);
-                }
-                reportHeightChange();
-              } else if (getResizeTimer() == null) {
-                setResizeTimer(
-                    new Timer() {
-                      @Override
-                      public void run() {
-                        if (canComputeHeight()) {
-                          cancel();
-                          setResizeTimer(null);
-                          reportHeightChange();
-                        }
-                      }
-                    });
-                getResizeTimer().scheduleRepeating(5);
-              }
-            });
-  }
-
-  @Override
-  void resize() {
-    if (getLineWidget() != null) {
-      reportHeightChange();
-    }
-  }
-
-  private void reportHeightChange() {
-    getLineWidget().changed();
-    updateSelection();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
deleted file mode 100644
index c92075f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
+++ /dev/null
@@ -1,203 +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.client.diff;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
-import com.google.gerrit.client.diff.UnifiedChunkManager.LineRegionInfo;
-import com.google.gerrit.client.diff.UnifiedChunkManager.RegionType;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.TreeMap;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Pos;
-import net.codemirror.lib.TextMarker.FromTo;
-
-/** Tracks comment widgets for {@link Unified}. */
-class UnifiedCommentManager extends CommentManager {
-
-  private final SortedMap<Integer, CommentGroup> mergedMap;
-
-  // In Unified, a CodeMirror line can have up to two CommentGroups - one for
-  // the base side and one for the revision, so we need to keep track of the
-  // duplicates and replace the entries in mergedMap on draft removal.
-  private final Map<Integer, CommentGroup> duplicates;
-
-  UnifiedCommentManager(
-      Unified host,
-      @Nullable Project.NameKey project,
-      DiffObject base,
-      PatchSet.Id revision,
-      String path,
-      CommentLinkProcessor clp,
-      boolean open) {
-    super(host, project, base, revision, path, clp, open);
-    mergedMap = new TreeMap<>();
-    duplicates = new HashMap<>();
-  }
-
-  @Override
-  SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side) {
-    return mergedMap;
-  }
-
-  @Override
-  void clearLine(DisplaySide side, int line, CommentGroup group) {
-    super.clearLine(side, line, group);
-
-    if (mergedMap.get(line) == group) {
-      mergedMap.remove(line);
-      if (duplicates.containsKey(line)) {
-        mergedMap.put(line, duplicates.remove(line));
-      }
-    }
-  }
-
-  @Override
-  void newDraftOnGutterClick(CodeMirror cm, String gutterClass, int cmLinePlusOne) {
-    if (!Gerrit.isSignedIn()) {
-      signInCallback(cm).run();
-    } else {
-      LineRegionInfo info = ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
-      DisplaySide side =
-          gutterClass.equals(UnifiedTable.style.lineNumbersLeft()) ? DisplaySide.A : DisplaySide.B;
-      int line = info.line;
-      if (info.getSide() != side) {
-        line = host.lineOnOther(info.getSide(), line).getLine();
-      }
-      insertNewDraft(side, line + 1);
-    }
-  }
-
-  @Override
-  CommentGroup getCommentGroupOnActiveLine(CodeMirror cm) {
-    CommentGroup group = null;
-    if (cm.extras().hasActiveLine()) {
-      int cmLinePlusOne = cm.getLineNumber(cm.extras().activeLine()) + 1;
-      LineRegionInfo info = ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
-      CommentGroup forSide = map(info.getSide()).get(cmLinePlusOne);
-      group = forSide == null ? map(info.getSide().otherSide()).get(cmLinePlusOne) : forSide;
-    }
-    return group;
-  }
-
-  @Override
-  Collection<Integer> getLinesWithCommentGroups() {
-    return mergedMap.tailMap(1).keySet();
-  }
-
-  @Override
-  String getTokenSuffixForActiveLine(CodeMirror cm) {
-    int cmLinePlusOne = cm.getLineNumber(cm.extras().activeLine()) + 1;
-    LineRegionInfo info = ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
-    return (info.getSide() == DisplaySide.A ? "a" : "") + cmLinePlusOne;
-  }
-
-  @Override
-  void newDraft(CodeMirror cm) {
-    if (cm.somethingSelected()) {
-      FromTo fromTo = adjustSelection(cm);
-      Pos from = fromTo.from();
-      Pos to = fromTo.to();
-      Unified unified = (Unified) host;
-      UnifiedChunkManager manager = unified.getChunkManager();
-      LineRegionInfo fromInfo = unified.getLineRegionInfoFromCmLine(from.line());
-      LineRegionInfo toInfo = unified.getLineRegionInfoFromCmLine(to.line());
-      DisplaySide side = toInfo.getSide();
-
-      // Handle special cases in selections that span multiple regions. Force
-      // start line to be on the same side as the end line.
-      if ((fromInfo.type == RegionType.INSERT || fromInfo.type == RegionType.COMMON)
-          && toInfo.type == RegionType.DELETE) {
-        LineOnOtherInfo infoOnSideA = manager.lineMapper.lineOnOther(DisplaySide.B, fromInfo.line);
-        int startLineOnSideA = infoOnSideA.getLine();
-        if (infoOnSideA.isAligned()) {
-          from.line(startLineOnSideA);
-        } else {
-          from.line(startLineOnSideA + 1);
-        }
-        from.ch(0);
-        to.line(toInfo.line);
-      } else if (fromInfo.type == RegionType.DELETE && toInfo.type == RegionType.INSERT) {
-        LineOnOtherInfo infoOnSideB = manager.lineMapper.lineOnOther(DisplaySide.A, fromInfo.line);
-        int startLineOnSideB = infoOnSideB.getLine();
-        if (infoOnSideB.isAligned()) {
-          from.line(startLineOnSideB);
-        } else {
-          from.line(startLineOnSideB + 1);
-        }
-        from.ch(0);
-        to.line(toInfo.line);
-      } else if (fromInfo.type == RegionType.DELETE && toInfo.type == RegionType.COMMON) {
-        int toLineOnSideA = manager.lineMapper.lineOnOther(DisplaySide.B, toInfo.line).getLine();
-        from.line(fromInfo.line);
-        // Force the end line to be on the same side as the start line.
-        to.line(toLineOnSideA);
-        side = DisplaySide.A;
-      } else { // Common case
-        from.line(fromInfo.line);
-        to.line(toInfo.line);
-      }
-
-      addDraftBox(
-              side,
-              CommentInfo.create(
-                  getPath(),
-                  getStoredSideFromDisplaySide(side),
-                  to.line() + 1,
-                  CommentRange.create(fromTo),
-                  false))
-          .setEdit(true);
-      cm.setCursor(Pos.create(host.getCmLine(to.line(), side), to.ch()));
-      cm.setSelection(cm.getCursor());
-    } else {
-      int cmLine = cm.getLineNumber(cm.extras().activeLine());
-      LineRegionInfo info = ((Unified) host).getLineRegionInfoFromCmLine(cmLine);
-      insertNewDraft(info.getSide(), cmLine + 1);
-    }
-  }
-
-  @Override
-  CommentGroup group(DisplaySide side, int cmLinePlusOne) {
-    Map<Integer, CommentGroup> map = map(side);
-    CommentGroup existing = map.get(cmLinePlusOne);
-    if (existing != null) {
-      return existing;
-    }
-
-    UnifiedCommentGroup g =
-        new UnifiedCommentGroup(this, host.getCmFromSide(side), side, cmLinePlusOne);
-    map.put(cmLinePlusOne, g);
-    if (mergedMap.containsKey(cmLinePlusOne)) {
-      duplicates.put(cmLinePlusOne, mergedMap.remove(cmLinePlusOne));
-    }
-    mergedMap.put(cmLinePlusOne, g);
-
-    if (isAttached()) {
-      g.init(host.getDiffTable());
-      g.handleRedraw();
-    }
-
-    return g;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java
deleted file mode 100644
index dc827cb..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-public class UnifiedDiffChunkInfo extends DiffChunkInfo {
-
-  private int cmLine;
-
-  UnifiedDiffChunkInfo(DisplaySide side, int start, int end, int cmLine, boolean edit) {
-    super(side, start, end, edit);
-    this.cmLine = cmLine;
-  }
-
-  int getCmLine() {
-    return cmLine;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
deleted file mode 100644
index 2d5df63..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
+++ /dev/null
@@ -1,84 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.DiffObject;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.ui.HTMLPanel;
-
-/**
- * A table with one row and one column to hold a unified CodeMirror displaying the files to be
- * compared.
- */
-class UnifiedTable extends DiffTable {
-  interface Binder extends UiBinder<HTMLPanel, UnifiedTable> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface DiffTableStyle extends CssResource {
-    String intralineInsert();
-
-    String intralineDelete();
-
-    String diffInsert();
-
-    String diffDelete();
-
-    String unifiedLineNumber();
-
-    String unifiedLineNumberEmpty();
-
-    String lineNumbersLeft();
-
-    String lineNumbersRight();
-  }
-
-  private Unified parent;
-  @UiField Element cm;
-  @UiField static DiffTableStyle style;
-
-  UnifiedTable(Unified parent, DiffObject base, DiffObject revision, String path) {
-    super(parent, base, revision, path);
-
-    initWidget(uiBinder.createAndBindUi(this));
-    this.parent = parent;
-  }
-
-  @Override
-  void setHideEmptyPane(boolean hide) {}
-
-  @Override
-  boolean isVisibleA() {
-    return true;
-  }
-
-  @Override
-  Unified getDiffScreen() {
-    return parent;
-  }
-
-  @Override
-  int getHeaderHeight() {
-    int h = patchSetSelectBoxA.getOffsetHeight() + patchSetSelectBoxB.getOffsetHeight();
-    if (hasHeader()) {
-      h += diffHeaderRow.getOffsetHeight();
-    }
-    return h;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.ui.xml
deleted file mode 100644
index c2cefe4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.ui.xml
+++ /dev/null
@@ -1,152 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:d='urn:import:com.google.gerrit.client.diff'>
-  <ui:with field='res' type='com.google.gerrit.client.diff.Resources'/>
-  <ui:style gss='false' type='com.google.gerrit.client.diff.UnifiedTable.DiffTableStyle'>
-    @external .CodeMirror, .CodeMirror-selectedtext;
-    @external .CodeMirror-vscrollbar .CodeMirror-scroll;
-    @external .CodeMirror-dialog-bottom;
-    @external .CodeMirror-cursor;
-
-    @external .dark, .unifiedLineNumber, .noIntraline, .showLineNumbers;
-
-    .difftable .patchSetNav,
-    .difftable .CodeMirror {
-      -webkit-touch-callout: none;
-      -webkit-user-select: none;
-      -khtml-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-    }
-
-    .difftable .CodeMirror pre {
-      overflow: visible;
-      border-right: 0;
-      width: auto;
-    }
-
-    /* Preserve space for underscores. If this changes
-     * see ChunkManager.addPadding() and adjust there.
-     */
-    .difftable .CodeMirror pre,
-    .difftable .CodeMirror pre span {
-      padding-bottom: 1px;
-    }
-    .table {
-      width: 100%;
-      table-layout: fixed;
-      border-spacing: 0;
-    }
-    .table td { padding: 0 }
-
-    /* Hide scrollbars. */
-    .difftable .CodeMirror-scroll { padding-right: 0; }
-    .difftable .CodeMirror-vscrollbar { display: none !important; }
-
-    .diffDelete { background-color: #faa; }
-    .diffInsert { background-color: #9f9; }
-    .intralineDelete { background-color: #fee; }
-    .intralineInsert { background-color: #dfd; }
-    .noIntraline .intralineDelete { background-color: #faa; }
-    .noIntraline .intralineInsert { background-color: #9f9; }
-
-    .dark .diffDelete { background-color: #400; }
-    .dark .diffInsert { background-color: #444; }
-    .dark .intralineDelete { background-color: #888; }
-    .dark .intralineInsert { background-color: #bbb; }
-    .dark .noIntraline .intralineDelete { background-color: #400; }
-    .dark .noIntraline .intralineInsert { background-color: #444; }
-
-    .patchSetNav, .diff_header {
-      background-color: #f7f7f7;
-      line-height: 1;
-    }
-
-    .difftable .CodeMirror-selectedtext {
-      background-color: inherit !important;
-    }
-    .difftable .CodeMirror div.CodeMirror-cursor {
-      border-left: 2px solid black;
-    }
-    .difftable .CodeMirror-dialog-bottom {
-      border-top: 0;
-      border-left: 1px solid #000;
-      border-bottom: 1px solid #000;
-      background-color: #f7f7f7;
-      top: 0;
-      right: 0;
-      bottom: auto;
-      left: auto;
-    }
-    .showLineNumbers .lineNumbersLeft, .showLineNumbers .lineNumbersRight {
-      min-width: 20px;
-      width: 3em; /* TODO: This needs to be set based on number of lines */
-    }
-    .showLineNumbers .lineNumbersLeft {
-      border-right: 1px solid #ddd;
-    }
-    .unifiedLineNumber {
-      display: none;
-    }
-    .showLineNumbers .unifiedLineNumber {
-      display: block;
-      cursor: pointer;
-      padding: 0 3px 0 5px;
-      min-width: 20px;
-      text-align: right;
-      color: #999;
-    }
-    .unifiedLineNumberEmpty {
-      display: none;
-    }
-    .showLineNumbers .unifiedLineNumberEmpty {
-      display: block;
-      margin-left: 28px;
-      border-left: 2px solid #d64040;
-      padding-bottom: 1px;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.difftable}'>
-    <table class='{style.table}'>
-      <tr ui:field='patchSetNavRow' class='{style.patchSetNav}'>
-        <td>
-          <table class='{style.table}'>
-            <tr>
-              <td ui:field='patchSetNavCellA'>
-                <d:PatchSetSelectBox ui:field='patchSetSelectBoxA' />
-              </td>
-            </tr>
-            <tr>
-              <td ui:field='patchSetNavCellB'>
-                <d:PatchSetSelectBox ui:field='patchSetSelectBoxB' />
-              </td>
-            </tr>
-          </table>
-        </td>
-      </tr>
-      <tr ui:field='diffHeaderRow' class='{res.diffTableStyle.diffHeader}'>
-        <td><pre ui:field='diffHeaderText' /></td>
-      </tr>
-      <tr>
-        <td ui:field='cm'/>
-      </tr>
-    </table>
-    <g:FlowPanel ui:field='widgets' visible='false'/>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java
deleted file mode 100644
index 50ef0d7..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-
-class UpToChangeCommand extends KeyCommand {
-  private final PatchSet.Id revision;
-  @Nullable private final Project.NameKey project;
-
-  UpToChangeCommand(@Nullable Project.NameKey project, PatchSet.Id revision, int mask, int key) {
-    super(mask, key, PatchUtil.C.upToChange());
-    this.revision = revision;
-    this.project = project;
-  }
-
-  @Override
-  public void onKeyPress(KeyPressEvent event) {
-    Gerrit.display(PageLinks.toChange(project, revision.getParentKey(), revision.getId()));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/goNext.png b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/goNext.png
deleted file mode 100644
index 872c197..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/goNext.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/goPrev.png b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/goPrev.png
deleted file mode 100644
index d68f29b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/goPrev.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/goUp.png b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/goUp.png
deleted file mode 100644
index f75bed4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/goUp.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.java
deleted file mode 100644
index 2958783..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.documentation;
-
-import com.google.gwt.i18n.client.Constants;
-
-public interface DocConstants extends Constants {
-  String keyReloadSearch();
-
-  String docItemHelp();
-
-  String docTableColumnTitle();
-
-  String docTableNone();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.properties
deleted file mode 100644
index b48c507..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.properties
+++ /dev/null
@@ -1,5 +0,0 @@
-keyReloadSearch = Reload documentation list
-
-docItemHelp = documentation
-docTableColumnTitle = Title
-docTableNone = (None)
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocInfo.java
deleted file mode 100644
index 5fcb6b0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocInfo.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.documentation;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class DocInfo extends JavaScriptObject {
-
-  public final native String title() /*-{ return this.title; }-*/;
-
-  public final native String url() /*-{ return this.url; }-*/;
-
-  public static DocInfo create() {
-    return (DocInfo) createObject();
-  }
-
-  protected DocInfo() {}
-
-  public final String getFullUrl() {
-    return GWT.getHostPageBaseURL() + url();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.java
deleted file mode 100644
index 7d76f7b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.documentation;
-
-import com.google.gwt.i18n.client.Messages;
-
-public interface DocMessages extends Messages {
-  String docQueryWindowTitle(String query);
-
-  String docQueryPageTitle(String query);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.properties
deleted file mode 100644
index 8810a4a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.properties
+++ /dev/null
@@ -1,2 +0,0 @@
-docQueryWindowTitle = {0}
-docQueryPageTitle = Search for {0} in documentation
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocScreen.java
deleted file mode 100644
index 0a87d29..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocScreen.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.documentation;
-
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwtorm.client.KeyUtil;
-
-public class DocScreen extends Screen {
-  private static final String URI = "/Documentation/";
-
-  private DocTable table;
-  private final String query;
-
-  public DocScreen(String query) {
-    this.query = KeyUtil.decode(query);
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    table = new DocTable();
-    table.setSavePointerId(query);
-    add(table);
-
-    setWindowTitle(Util.M.docQueryWindowTitle(query));
-    setPageTitle(Util.M.docQueryPageTitle(query));
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    doQuery();
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    table.setRegisterKeys(true);
-  }
-
-  private AsyncCallback<JsArray<DocInfo>> loadCallback() {
-    return new GerritCallback<JsArray<DocInfo>>() {
-      @Override
-      public void onSuccess(JsArray<DocInfo> result) {
-        displayResults(result);
-        display();
-      }
-    };
-  }
-
-  private void displayResults(JsArray<DocInfo> result) {
-    table.display(result);
-    table.finishDisplay();
-  }
-
-  private void doQuery() {
-    RestApi call = new RestApi(URI);
-    call.addParameterRaw("q", KeyUtil.encode(query));
-    call.get(loadCallback());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java
deleted file mode 100644
index 677c2bf..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.documentation;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.Anchor;
-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.HTMLTable.CellFormatter;
-
-class DocTable extends NavigationTable<DocInfo> {
-  private static final int C_TITLE = 1;
-
-  private int rows;
-  private int dataBeginRow;
-
-  DocTable() {
-    super(Util.C.docItemHelp());
-
-    table.setText(0, C_TITLE, Util.C.docTableColumnTitle());
-
-    FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.addStyleName(0, C_TITLE, Gerrit.RESOURCES.css().dataHeader());
-
-    table.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            Cell cell = table.getCellForEvent(event);
-            if (cell == null) {
-              return;
-            }
-            if (getRowItem(cell.getRowIndex()) != null) {
-              movePointerTo(cell.getRowIndex());
-            }
-          }
-        });
-  }
-
-  @Override
-  protected Object getRowItemKey(DocInfo item) {
-    return item.url();
-  }
-
-  @Override
-  protected void onOpenRow(int row) {
-    DocInfo d = getRowItem(row);
-    Window.Location.assign(d.getFullUrl());
-  }
-
-  private void insertNoneRow(int row) {
-    table.insertRow(row);
-    table.setText(row, 0, Util.C.docTableNone());
-    FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.setStyleName(row, 0, Gerrit.RESOURCES.css().emptySection());
-  }
-
-  private void insertDocRow(int row) {
-    table.insertRow(row);
-    applyDataRowStyle(row);
-  }
-
-  @Override
-  protected void applyDataRowStyle(int row) {
-    super.applyDataRowStyle(row);
-    CellFormatter fmt = table.getCellFormatter();
-    fmt.addStyleName(row, C_TITLE, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, C_TITLE, Gerrit.RESOURCES.css().cSUBJECT());
-  }
-
-  private void populateDocRow(int row, DocInfo d) {
-    table.setWidget(row, C_TITLE, new DocLink(d));
-    setRowItem(row, d);
-  }
-
-  public void display(JsArray<DocInfo> docList) {
-    int sz = docList != null ? docList.length() : 0;
-    boolean hadData = rows > 0;
-
-    if (hadData) {
-      while (sz < rows) {
-        table.removeRow(dataBeginRow);
-        rows--;
-      }
-    } else {
-      table.removeRow(dataBeginRow);
-    }
-
-    if (sz == 0) {
-      insertNoneRow(dataBeginRow);
-      return;
-    }
-
-    while (rows < sz) {
-      insertDocRow(dataBeginRow + rows);
-      rows++;
-    }
-    for (int i = 0; i < sz; i++) {
-      populateDocRow(dataBeginRow + i, docList.get(i));
-    }
-  }
-
-  public static class DocLink extends Anchor {
-    DocLink(DocInfo d) {
-      super(com.google.gerrit.client.changes.Util.cropSubject(d.title()));
-      setHref(d.getFullUrl());
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/Util.java
deleted file mode 100644
index 273ead8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/Util.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.documentation;
-
-import com.google.gwt.core.client.GWT;
-
-public class Util {
-  public static final DocConstants C = GWT.create(DocConstants.class);
-  public static final DocMessages M = GWT.create(DocMessages.class);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandLink.java
deleted file mode 100644
index 2b09175..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandLink.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.download;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.DownloadInfo.DownloadCommandInfo;
-import com.google.gwt.aria.client.Roles;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-
-public class DownloadCommandLink extends Anchor implements ClickHandler {
-  private final CopyableLabel copyLabel;
-  private final String command;
-
-  public DownloadCommandLink(CopyableLabel copyLabel, DownloadCommandInfo commandInfo) {
-    super(commandInfo.name());
-    this.copyLabel = copyLabel;
-    this.command = commandInfo.command();
-
-    setStyleName(Gerrit.RESOURCES.css().downloadLink());
-    Roles.getTabRole().set(getElement());
-    addClickHandler(this);
-  }
-
-  @Override
-  public void onClick(ClickEvent event) {
-    event.preventDefault();
-    event.stopPropagation();
-
-    select();
-  }
-
-  void select() {
-    copyLabel.setText(command);
-
-    DownloadCommandPanel parent = (DownloadCommandPanel) getParent();
-    for (Widget w : parent) {
-      if (w != this && w instanceof DownloadCommandLink) {
-        w.removeStyleName(Gerrit.RESOURCES.css().downloadLink_Active());
-      }
-    }
-    parent.setCurrentCommand(this);
-    addStyleName(Gerrit.RESOURCES.css().downloadLink_Active());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandPanel.java
deleted file mode 100644
index 20cf3f3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandPanel.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.download;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.aria.client.Roles;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Widget;
-
-public class DownloadCommandPanel extends FlowPanel {
-  private DownloadCommandLink currentCommand;
-
-  public DownloadCommandPanel() {
-    setStyleName(Gerrit.RESOURCES.css().downloadLinkList());
-    Roles.getTablistRole().set(getElement());
-  }
-
-  public boolean isEmpty() {
-    return getWidgetCount() == 0;
-  }
-
-  public void select() {
-    DownloadCommandLink first = null;
-
-    for (Widget w : this) {
-      if (w instanceof DownloadCommandLink) {
-        DownloadCommandLink d = (DownloadCommandLink) w;
-        if (currentCommand != null && d.getText().equals(currentCommand.getText())) {
-          d.select();
-          return;
-        }
-        if (first == null) {
-          first = d;
-        }
-      }
-    }
-
-    // If none matched the requested type, select the first in the
-    // group as that will at least give us an initial baseline.
-    if (first != null) {
-      first.select();
-    }
-  }
-
-  void setCurrentCommand(DownloadCommandLink cmd) {
-    currentCommand = cmd;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadPanel.java
deleted file mode 100644
index b881505..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadPanel.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.download;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.DownloadInfo.DownloadCommandInfo;
-import com.google.gerrit.client.info.DownloadInfo.DownloadSchemeInfo;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-import java.util.List;
-
-public abstract class DownloadPanel extends FlowPanel {
-  protected final String project;
-
-  private final DownloadCommandPanel commands = new DownloadCommandPanel();
-  private final DownloadUrlPanel urls = new DownloadUrlPanel();
-  private final CopyableLabel copyLabel = new CopyableLabel("");
-
-  public DownloadPanel(String project, boolean allowAnonymous) {
-    this.project = project;
-    copyLabel.setStyleName(Gerrit.RESOURCES.css().downloadLinkCopyLabel());
-    urls.add(DownloadUrlLink.createDownloadUrlLinks(allowAnonymous, this));
-
-    setupWidgets();
-  }
-
-  private void setupWidgets() {
-    if (!urls.isEmpty()) {
-      urls.select(Gerrit.getUserPreferences().downloadScheme());
-
-      FlowPanel p = new FlowPanel();
-      p.setStyleName(Gerrit.RESOURCES.css().downloadLinkHeader());
-      p.add(commands);
-      final InlineLabel glue = new InlineLabel();
-      glue.setStyleName(Gerrit.RESOURCES.css().downloadLinkHeaderGap());
-      p.add(glue);
-      p.add(urls);
-
-      add(p);
-      add(copyLabel);
-    }
-  }
-
-  void populateDownloadCommandLinks(DownloadSchemeInfo schemeInfo) {
-    commands.clear();
-    for (DownloadCommandInfo cmd : getCommands(schemeInfo)) {
-      commands.add(new DownloadCommandLink(copyLabel, cmd));
-    }
-    commands.select();
-  }
-
-  protected abstract List<DownloadCommandInfo> getCommands(DownloadSchemeInfo schemeInfo);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java
deleted file mode 100644
index 76e7d7c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.download;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.AccountApi;
-import com.google.gerrit.client.info.DownloadInfo.DownloadSchemeInfo;
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gwt.aria.client.Roles;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Widget;
-import java.util.ArrayList;
-import java.util.List;
-
-public class DownloadUrlLink extends Anchor implements ClickHandler {
-  public static List<DownloadUrlLink> createDownloadUrlLinks(
-      boolean allowAnonymous, DownloadPanel downloadPanel) {
-    List<DownloadUrlLink> urls = new ArrayList<>();
-    for (String s : Gerrit.info().download().schemes()) {
-      DownloadSchemeInfo scheme = Gerrit.info().download().scheme(s);
-      if (scheme.isAuthRequired() && !allowAnonymous) {
-        continue;
-      }
-      urls.add(new DownloadUrlLink(downloadPanel, scheme, s));
-    }
-    return urls;
-  }
-
-  private final DownloadPanel downloadPanel;
-  private final DownloadSchemeInfo schemeInfo;
-  private final String schemeName;
-
-  public DownloadUrlLink(
-      DownloadPanel downloadPanel, DownloadSchemeInfo schemeInfo, String schemeName) {
-    super(schemeName);
-    setStyleName(Gerrit.RESOURCES.css().downloadLink());
-    Roles.getTabRole().set(getElement());
-    addClickHandler(this);
-
-    this.downloadPanel = downloadPanel;
-    this.schemeInfo = schemeInfo;
-    this.schemeName = schemeName;
-  }
-
-  public String getSchemeName() {
-    return schemeName;
-  }
-
-  @Override
-  public void onClick(ClickEvent event) {
-    event.preventDefault();
-    event.stopPropagation();
-
-    select();
-
-    GeneralPreferences prefs = Gerrit.getUserPreferences();
-    if (Gerrit.isSignedIn() && !schemeName.equals(prefs.downloadScheme())) {
-      prefs.downloadScheme(schemeName);
-      GeneralPreferences in = GeneralPreferences.create();
-      in.downloadScheme(schemeName);
-      AccountApi.self()
-          .view("preferences")
-          .put(
-              in,
-              new AsyncCallback<JavaScriptObject>() {
-                @Override
-                public void onSuccess(JavaScriptObject result) {}
-
-                @Override
-                public void onFailure(Throwable caught) {}
-              });
-    }
-  }
-
-  void select() {
-    downloadPanel.populateDownloadCommandLinks(schemeInfo);
-
-    DownloadUrlPanel parent = (DownloadUrlPanel) getParent();
-    for (Widget w : parent) {
-      if (w != this && w instanceof DownloadUrlLink) {
-        w.removeStyleName(Gerrit.RESOURCES.css().downloadLink_Active());
-      }
-    }
-    addStyleName(Gerrit.RESOURCES.css().downloadLink_Active());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlPanel.java
deleted file mode 100644
index 6a5fbe4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlPanel.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.download;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.aria.client.Roles;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Widget;
-import java.util.Collection;
-
-public class DownloadUrlPanel extends FlowPanel {
-
-  public DownloadUrlPanel() {
-    setStyleName(Gerrit.RESOURCES.css().downloadLinkList());
-    Roles.getTablistRole().set(getElement());
-  }
-
-  public boolean isEmpty() {
-    return getWidgetCount() == 0;
-  }
-
-  public void select(String schemeName) {
-    DownloadUrlLink first = null;
-
-    for (Widget w : this) {
-      if (w instanceof DownloadUrlLink) {
-        final DownloadUrlLink d = (DownloadUrlLink) w;
-        if (first == null) {
-          first = d;
-        }
-        if (d.getSchemeName().equals(schemeName)) {
-          d.select();
-          return;
-        }
-      }
-    }
-
-    // If none matched the requested type, select the first in the
-    // group as that will at least give us an initial baseline.
-    if (first != null) {
-      first.select();
-    }
-  }
-
-  public void add(Collection<DownloadUrlLink> links) {
-    for (Widget link : links) {
-      add(link);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java
deleted file mode 100644
index b70c209..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.editor;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.i18n.client.Constants;
-
-interface EditConstants extends Constants {
-  EditConstants I = GWT.create(EditConstants.class);
-
-  String closeUnsavedChanges();
-
-  String cancelUnsavedChanges();
-
-  String gotoLineNumber();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.properties
deleted file mode 100644
index 2e8a087..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-closeUnsavedChanges = Unsaved changes were made to this file.
-
-cancelUnsavedChanges = Unsaved changes were made to this file.\n\
-  \n\
-  Discard unsaved changes?
-
-gotoLineNumber = Go to Line:
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditFileInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditFileInfo.java
deleted file mode 100644
index 3f9d732..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditFileInfo.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.editor;
-
-import com.google.gerrit.client.DiffWebLinkInfo;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-
-public class EditFileInfo extends JavaScriptObject {
-  public final native JsArray<DiffWebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
-
-  protected EditFileInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesAction.java
deleted file mode 100644
index e11ded0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesAction.java
+++ /dev/null
@@ -1,71 +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.client.editor;
-
-import com.google.gerrit.client.account.EditPreferences;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.PopupPanel.PositionCallback;
-
-class EditPreferencesAction {
-  private final EditScreen view;
-  private final EditPreferences prefs;
-  private PopupPanel popup;
-  private EditPreferencesBox current;
-
-  EditPreferencesAction(EditScreen view, EditPreferences prefs) {
-    this.view = view;
-    this.prefs = prefs;
-  }
-
-  void show() {
-    if (popup != null) {
-      hide();
-      return;
-    }
-
-    current = new EditPreferencesBox(view);
-    current.set(prefs);
-
-    popup = new PopupPanel(true, false);
-    popup.setStyleName(current.style.dialog());
-    popup.add(current);
-    popup.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            view.getEditor().focus();
-            popup = null;
-            current = null;
-          }
-        });
-    popup.setPopupPositionAndShow(
-        new PositionCallback() {
-          @Override
-          public void setPosition(int offsetWidth, int offsetHeight) {
-            popup.setPopupPosition(300, 120);
-          }
-        });
-  }
-
-  void hide() {
-    if (popup != null) {
-      popup.hide();
-      popup = null;
-      current = null;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java
deleted file mode 100644
index 5157123..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java
+++ /dev/null
@@ -1,333 +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.client.editor;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.AccountApi;
-import com.google.gerrit.client.account.EditPreferences;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.NpIntTextBox;
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.client.KeyMapType;
-import com.google.gerrit.extensions.client.Theme;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Visibility;
-import com.google.gwt.event.dom.client.ChangeEvent;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.ToggleButton;
-import com.google.gwt.user.client.ui.UIObject;
-import net.codemirror.theme.ThemeLoader;
-
-/** Displays current edit preferences. */
-public class EditPreferencesBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, EditPreferencesBox> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  public interface Style extends CssResource {
-    String dialog();
-  }
-
-  private final EditScreen view;
-  private EditPreferences prefs;
-
-  @UiField Style style;
-  @UiField Element header;
-  @UiField Anchor close;
-  @UiField NpIntTextBox tabWidth;
-  @UiField NpIntTextBox lineLength;
-  @UiField NpIntTextBox indentUnit;
-  @UiField NpIntTextBox cursorBlinkRate;
-  @UiField ToggleButton topMenu;
-  @UiField ToggleButton syntaxHighlighting;
-  @UiField ToggleButton showTabs;
-  @UiField ToggleButton whitespaceErrors;
-  @UiField ToggleButton lineNumbers;
-  @UiField ToggleButton matchBrackets;
-  @UiField ToggleButton lineWrapping;
-  @UiField ToggleButton indentWithTabs;
-  @UiField ToggleButton autoCloseBrackets;
-  @UiField ToggleButton showBase;
-  @UiField ListBox theme;
-  @UiField ListBox keyMap;
-  @UiField Button apply;
-  @UiField Button save;
-
-  public EditPreferencesBox(EditScreen view) {
-    this.view = view;
-    initWidget(uiBinder.createAndBindUi(this));
-    initTheme();
-    initKeyMapType();
-
-    if (view == null) {
-      UIObject.setVisible(header, false);
-      apply.getElement().getStyle().setVisibility(Visibility.HIDDEN);
-    }
-  }
-
-  public Style getStyle() {
-    return style;
-  }
-
-  public void set(EditPreferences prefs) {
-    this.prefs = prefs;
-
-    tabWidth.setIntValue(prefs.tabSize());
-    lineLength.setIntValue(prefs.lineLength());
-    indentUnit.setIntValue(prefs.indentUnit());
-    cursorBlinkRate.setIntValue(prefs.cursorBlinkRate());
-    topMenu.setValue(!prefs.hideTopMenu());
-    syntaxHighlighting.setValue(prefs.syntaxHighlighting());
-    showTabs.setValue(prefs.showTabs());
-    whitespaceErrors.setValue(prefs.showWhitespaceErrors());
-    lineNumbers.setValue(prefs.hideLineNumbers());
-    matchBrackets.setValue(prefs.matchBrackets());
-    lineWrapping.setValue(prefs.lineWrapping());
-    indentWithTabs.setValue(prefs.indentWithTabs());
-    autoCloseBrackets.setValue(prefs.autoCloseBrackets());
-    showBase.setValue(prefs.showBase());
-    setTheme(prefs.theme());
-    setKeyMapType(prefs.keyMapType());
-  }
-
-  @UiHandler("tabWidth")
-  void onTabWidth(ValueChangeEvent<String> e) {
-    String v = e.getValue();
-    if (v != null && v.length() > 0) {
-      prefs.tabSize(Math.max(1, Integer.parseInt(v)));
-      if (view != null) {
-        view.setOption("tabSize", v);
-      }
-    }
-  }
-
-  @UiHandler("lineLength")
-  void onLineLength(ValueChangeEvent<String> e) {
-    String v = e.getValue();
-    if (v != null && v.length() > 0) {
-      prefs.lineLength(Math.max(1, Integer.parseInt(v)));
-      if (view != null) {
-        view.setLineLength(prefs.lineLength());
-      }
-    }
-  }
-
-  @UiHandler("indentUnit")
-  void onIndentUnit(ValueChangeEvent<String> e) {
-    String v = e.getValue();
-    if (v != null && v.length() > 0) {
-      prefs.indentUnit(Math.max(0, Integer.parseInt(v)));
-      if (view != null) {
-        view.setIndentUnit(prefs.indentUnit());
-      }
-    }
-  }
-
-  @UiHandler("cursorBlinkRate")
-  void onCursoBlinkRate(ValueChangeEvent<String> e) {
-    String v = e.getValue();
-    if (v != null && v.length() > 0) {
-      // A negative value hides the cursor entirely:
-      // don't let user shoot himself in the foot.
-      prefs.cursorBlinkRate(Math.max(0, Integer.parseInt(v)));
-      if (view != null) {
-        view.setOption("cursorBlinkRate", prefs.cursorBlinkRate());
-      }
-    }
-  }
-
-  @UiHandler("topMenu")
-  void onTopMenu(ValueChangeEvent<Boolean> e) {
-    prefs.hideTopMenu(!e.getValue());
-    if (view != null) {
-      Gerrit.setHeaderVisible(!prefs.hideTopMenu());
-      view.adjustHeight();
-    }
-  }
-
-  @UiHandler("showTabs")
-  void onShowTabs(ValueChangeEvent<Boolean> e) {
-    prefs.showTabs(e.getValue());
-    if (view != null) {
-      view.setShowTabs(prefs.showTabs());
-    }
-  }
-
-  @UiHandler("whitespaceErrors")
-  void onshowTrailingSpace(ValueChangeEvent<Boolean> e) {
-    prefs.showWhitespaceErrors(e.getValue());
-    if (view != null) {
-      view.setShowWhitespaceErrors(prefs.showWhitespaceErrors());
-    }
-  }
-
-  @UiHandler("lineNumbers")
-  void onLineNumbers(ValueChangeEvent<Boolean> e) {
-    prefs.hideLineNumbers(e.getValue());
-    if (view != null) {
-      view.setShowLineNumbers(prefs.hideLineNumbers());
-    }
-  }
-
-  @UiHandler("syntaxHighlighting")
-  void onSyntaxHighlighting(ValueChangeEvent<Boolean> e) {
-    prefs.syntaxHighlighting(e.getValue());
-    if (view != null) {
-      view.setSyntaxHighlighting(prefs.syntaxHighlighting());
-    }
-  }
-
-  @UiHandler("matchBrackets")
-  void onMatchBrackets(ValueChangeEvent<Boolean> e) {
-    prefs.matchBrackets(e.getValue());
-    if (view != null) {
-      view.setOption("matchBrackets", prefs.matchBrackets());
-    }
-  }
-
-  @UiHandler("lineWrapping")
-  void onLineWrapping(ValueChangeEvent<Boolean> e) {
-    prefs.lineWrapping(e.getValue());
-    if (view != null) {
-      view.getEditor().setOption("lineWrapping", prefs.lineWrapping());
-    }
-  }
-
-  @UiHandler("indentWithTabs")
-  void onIndentWithTabs(ValueChangeEvent<Boolean> e) {
-    prefs.indentWithTabs(e.getValue());
-    if (view != null) {
-      view.getEditor().setOption("indentWithTabs", prefs.indentWithTabs());
-    }
-  }
-
-  @UiHandler("autoCloseBrackets")
-  void onCloseBrackets(ValueChangeEvent<Boolean> e) {
-    prefs.autoCloseBrackets(e.getValue());
-    if (view != null) {
-      view.getEditor().setOption("autoCloseBrackets", prefs.autoCloseBrackets());
-    }
-  }
-
-  @UiHandler("showBase")
-  void onShowBase(ValueChangeEvent<Boolean> e) {
-    Boolean value = e.getValue();
-    prefs.showBase(value);
-    if (view != null) {
-      view.showBase.setValue(value, true);
-    }
-  }
-
-  @UiHandler("theme")
-  void onTheme(@SuppressWarnings("unused") ChangeEvent e) {
-    final Theme newTheme = Theme.valueOf(theme.getValue(theme.getSelectedIndex()));
-    prefs.theme(newTheme);
-    if (view != null) {
-      ThemeLoader.loadTheme(
-          newTheme,
-          new GerritCallback<Void>() {
-            @Override
-            public void onSuccess(Void result) {
-              view.setTheme(newTheme);
-            }
-          });
-    }
-  }
-
-  @UiHandler("keyMap")
-  void onKeyMap(@SuppressWarnings("unused") ChangeEvent e) {
-    KeyMapType keyMapType = KeyMapType.valueOf(keyMap.getValue(keyMap.getSelectedIndex()));
-    prefs.keyMapType(keyMapType);
-    if (view != null) {
-      view.setOption("keyMap", keyMapType.name().toLowerCase());
-    }
-  }
-
-  @UiHandler("apply")
-  void onApply(@SuppressWarnings("unused") ClickEvent e) {
-    close();
-  }
-
-  @UiHandler("save")
-  void onSave(@SuppressWarnings("unused") ClickEvent e) {
-    AccountApi.putEditPreferences(
-        prefs,
-        new GerritCallback<EditPreferences>() {
-          @Override
-          public void onSuccess(EditPreferences p) {
-            Gerrit.setEditPreferences(p.copyTo(new EditPreferencesInfo()));
-          }
-        });
-    if (view != null) {
-      close();
-    }
-  }
-
-  @UiHandler("close")
-  void onClose(ClickEvent e) {
-    e.preventDefault();
-    close();
-  }
-
-  private void close() {
-    ((PopupPanel) getParent()).hide();
-  }
-
-  private void setTheme(Theme v) {
-    String name = v != null ? v.name() : Theme.DEFAULT.name();
-    for (int i = 0; i < theme.getItemCount(); i++) {
-      if (theme.getValue(i).equals(name)) {
-        theme.setSelectedIndex(i);
-        return;
-      }
-    }
-    theme.setSelectedIndex(0);
-  }
-
-  private void initTheme() {
-    for (Theme t : Theme.values()) {
-      theme.addItem(t.name().toLowerCase(), t.name());
-    }
-  }
-
-  private void setKeyMapType(KeyMapType v) {
-    String name = v != null ? v.name() : KeyMapType.DEFAULT.name();
-    for (int i = 0; i < keyMap.getItemCount(); i++) {
-      if (keyMap.getValue(i).equals(name)) {
-        keyMap.setSelectedIndex(i);
-        return;
-      }
-    }
-    keyMap.setSelectedIndex(0);
-  }
-
-  private void initKeyMapType() {
-    for (KeyMapType t : KeyMapType.values()) {
-      keyMap.addItem(t.name().toLowerCase(), t.name());
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml
deleted file mode 100644
index f5ec71e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml
+++ /dev/null
@@ -1,282 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-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.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:x='urn:import:com.google.gerrit.client.ui'>
-  <ui:style type='com.google.gerrit.client.editor.EditPreferencesBox.Style'>
-    @external .gwt-TextBox;
-    @external .gwt-ToggleButton .html-face;
-    @external .gwt-ToggleButton-up;
-    @external .gwt-ToggleButton-up-hovering;
-    @external .gwt-ToggleButton-up-disabled;
-    @external .gwt-ToggleButton-down;
-    @external .gwt-ToggleButton-down-hovering;
-    @external .gwt-ToggleButton-down-disabled;
-
-    .dialog {
-      background: rgba(0, 0, 0, 0.85) none repeat scroll 0 50%;
-      color: #ffffff;
-      font-family: arial,sans-serif;
-      font-weight: bold;
-      overflow: auto !important;
-      bottom: 0;
-      text-align: left;
-      text-shadow: 1px 1px 7px #000000;
-      min-width: 300px;
-      z-index: 200;
-      border-radius: 10px;
-    }
-
-    @if user.agent safari {
-      .dialog {
-        \-webkit-border-radius: 10px;
-      }
-    }
-
-    @if user.agent gecko1_8 {
-      .dialog {
-        \-moz-border-radius: 10px;
-      }
-    }
-
-    .box { margin: 10px; }
-    .box .gwt-TextBox { padding: 0; }
-    .context { vertical-align: bottom; }
-
-    .table tr { min-height: 23px; }
-    .table th,
-    .table td {
-      white-space: nowrap;
-      color: #ffffff;
-    }
-    .table th {
-      padding-right: 8px;
-      text-align: right;
-    }
-
-    .box a,
-    .box a:visited,
-    .box a:hover {
-      color: #dddd00;
-    }
-
-    .box .gwt-ToggleButton {
-      position: relative;
-      height: 19px;
-      width: 140px;
-      background: #fff;
-      color: #000;
-      text-shadow: none;
-    }
-    .box .gwt-ToggleButton .html-face {
-      position: absolute;
-      top: 0;
-      width: 68px;
-      height: 17px;
-      line-height: 17px;
-      text-align: center;
-      border-width: 1px;
-    }
-
-    .box .gwt-ToggleButton-up,
-    .box .gwt-ToggleButton-up-hovering,
-    .box .gwt-ToggleButton-up-disabled,
-    .box .gwt-ToggleButton-down,
-    .box .gwt-ToggleButton-down-hovering,
-    .box .gwt-ToggleButton-down-disabled {
-      padding: 0;
-      border: 0;
-    }
-    .box .gwt-ToggleButton-up .html-face,
-    .box .gwt-ToggleButton-up-hovering .html-face {
-      left: 0;
-      background: #cacaca;
-      border-style: outset;
-    }
-    .box .gwt-ToggleButton-down .html-face,
-    .box .gwt-ToggleButton-down-hovering .html-face {
-      right: 0;
-      background: #bcf;
-      border-style: inset;
-    }
-
-    .box button {
-      margin: 6px 3px 0 0;
-      border-color: rgba(0, 0, 0, 0.1);
-      text-align: center;
-      font-size: 8pt;
-      font-weight: bold;
-      border: 1px solid;
-      cursor: pointer;
-      color: #444;
-      background-color: #f5f5f5;
-      background-image: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1);
-      -webkit-border-radius: 2px;
-      -webkit-box-sizing: content-box;
-    }
-    .box button div {
-      color: #444;
-      height: 10px;
-      min-width: 54px;
-      line-height: 10px;
-      white-space: nowrap;
-    }
-
-    button.apply {
-      background-color: #4d90fe;
-      background-image: -webkit-linear-gradient(top, #4d90fe, #4d90fe);
-    }
-    button.apply div { color: #fff; }
-
-    button.save {
-      margin-left: 10px;
-      color: #d14836;
-      background-color: #d14836;
-      background-image: -webkit-linear-gradient(top, #d14836, #d14836);
-    }
-    button.save div { color: #fff; }
-  </ui:style>
-
-  <g:HTMLPanel styleName='{style.box}'>
-    <div ui:field='header'>
-      <table style='width: 100%'>
-        <tr>
-          <td><ui:msg>Edit Preferences</ui:msg></td>
-          <td style='text-align: right'>
-            <g:Anchor ui:field='close' href='javascript:;'><ui:msg>Close</ui:msg></g:Anchor>
-          </td>
-        </tr>
-      </table>
-      <hr/>
-    </div>
-    <table class='{style.table}'>
-      <tr>
-        <th><ui:msg>Theme</ui:msg></th>
-        <td><g:ListBox ui:field='theme'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Key Map</ui:msg></th>
-        <td><g:ListBox ui:field='keyMap'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Tab Width</ui:msg></th>
-        <td><x:NpIntTextBox ui:field='tabWidth'
-            visibleLength='4'
-            alignment='RIGHT'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Columns</ui:msg></th>
-        <td><x:NpIntTextBox ui:field='lineLength'
-            visibleLength='4'
-            alignment='RIGHT'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Indent Unit</ui:msg></th>
-        <td><x:NpIntTextBox ui:field='indentUnit'
-            visibleLength='4'
-            alignment='RIGHT'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Cursor Blink Rate</ui:msg></th>
-        <td><x:NpIntTextBox ui:field='cursorBlinkRate'
-            visibleLength='4'
-            alignment='RIGHT'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Top Menu</ui:msg></th>
-        <td><g:ToggleButton ui:field='topMenu'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Syntax Highlighting</ui:msg></th>
-        <td><g:ToggleButton ui:field='syntaxHighlighting'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Show Tabs</ui:msg></th>
-        <td><g:ToggleButton ui:field='showTabs'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-      <th><ui:msg>Whitespace Errors</ui:msg></th>
-        <td><g:ToggleButton ui:field='whitespaceErrors'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Line Numbers</ui:msg></th>
-        <td><g:ToggleButton ui:field='lineNumbers'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Match Brackets</ui:msg></th>
-        <td><g:ToggleButton ui:field='matchBrackets'>
-          <g:upFace><ui:msg>Off</ui:msg></g:upFace>
-          <g:downFace><ui:msg>On</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Line Wrapping</ui:msg></th>
-        <td><g:ToggleButton ui:field='lineWrapping'>
-          <g:upFace><ui:msg>Off</ui:msg></g:upFace>
-          <g:downFace><ui:msg>On</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Indent With Tabs</ui:msg></th>
-        <td><g:ToggleButton ui:field='indentWithTabs'>
-          <g:upFace><ui:msg>Off</ui:msg></g:upFace>
-          <g:downFace><ui:msg>On</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Auto Close Brackets</ui:msg></th>
-        <td><g:ToggleButton ui:field='autoCloseBrackets'>
-          <g:upFace><ui:msg>Off</ui:msg></g:upFace>
-          <g:downFace><ui:msg>On</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Show Base Version</ui:msg></th>
-        <td><g:ToggleButton ui:field='showBase'>
-          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
-          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
-        </g:ToggleButton></td>
-      </tr>
-      <tr>
-        <td></td>
-        <td>
-          <g:Button ui:field='apply' styleName='{style.apply}'>
-            <div><ui:msg>Apply</ui:msg></div>
-          </g:Button>
-          <g:Button ui:field='save' styleName='{style.save}'>
-            <div><ui:msg>Save</ui:msg></div>
-          </g:Button>
-        </td>
-      </tr>
-    </table>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
deleted file mode 100644
index cbf12a3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
+++ /dev/null
@@ -1,699 +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.client.editor;
-
-import static com.google.gwt.dom.client.Style.Visibility.HIDDEN;
-import static com.google.gwt.dom.client.Style.Visibility.VISIBLE;
-
-import com.google.gerrit.client.DiffWebLinkInfo;
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.JumpKeys;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.account.EditPreferences;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ChangeEditApi;
-import com.google.gerrit.client.diff.DiffApi;
-import com.google.gerrit.client.diff.DiffInfo;
-import com.google.gerrit.client.diff.Header;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.FileInfo;
-import com.google.gerrit.client.patches.PatchUtil;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.HttpCallback;
-import com.google.gerrit.client.rpc.HttpResponse;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.KeyMapType;
-import com.google.gerrit.extensions.client.Theme;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Unit;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.logical.shared.ResizeEvent;
-import com.google.gwt.event.logical.shared.ResizeHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.Window.ClosingEvent;
-import com.google.gwt.user.client.Window.ClosingHandler;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.ImageResourceRenderer;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import java.util.List;
-import net.codemirror.addon.AddonInjector;
-import net.codemirror.addon.Addons;
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.ChangesHandler;
-import net.codemirror.lib.CodeMirror.CommandRunner;
-import net.codemirror.lib.Configuration;
-import net.codemirror.lib.KeyMap;
-import net.codemirror.lib.MergeView;
-import net.codemirror.lib.Pos;
-import net.codemirror.mode.ModeInfo;
-import net.codemirror.mode.ModeInjector;
-import net.codemirror.theme.ThemeLoader;
-
-public class EditScreen extends Screen {
-  interface Binder extends UiBinder<HTMLPanel, EditScreen> {}
-
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface Style extends CssResource {
-    String fullWidth();
-
-    String base();
-
-    String hideBase();
-  }
-
-  @Nullable private Project.NameKey projectKey;
-  private final PatchSet.Id revision;
-  private final String path;
-  private final int startLine;
-  private EditPreferences prefs;
-  private EditPreferencesAction editPrefsAction;
-  private MergeView mv;
-  private CodeMirror cmBase;
-  private CodeMirror cmEdit;
-  private HttpResponse<NativeString> content;
-  private HttpResponse<NativeString> baseContent;
-  private EditFileInfo editFileInfo;
-  private JsArray<DiffWebLinkInfo> diffLinks;
-
-  @UiField Element header;
-  @UiField Element project;
-  @UiField Element filePath;
-  @UiField FlowPanel linkPanel;
-  @UiField Element cursLine;
-  @UiField Element cursCol;
-  @UiField Element dirty;
-  @UiField CheckBox showBase;
-  @UiField Button close;
-  @UiField Button save;
-  @UiField Element editor;
-  @UiField Style style;
-
-  private HandlerRegistration resizeHandler;
-  private HandlerRegistration closeHandler;
-  private int generation;
-
-  public EditScreen(@Nullable Project.NameKey projectKey, Patch.Key patch, int startLine) {
-    this.projectKey = projectKey;
-    this.revision = patch.getParentKey();
-    this.path = patch.get();
-    this.startLine = startLine - 1;
-    setRequiresSignIn(true);
-    add(uiBinder.createAndBindUi(this));
-    addDomHandler(GlobalKey.STOP_PROPAGATION, KeyPressEvent.getType());
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setHeaderVisible(false);
-    setWindowTitle(FileInfo.getFileName(path));
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-
-    prefs = EditPreferences.create(Gerrit.getEditPreferences());
-
-    CallbackGroup group1 = new CallbackGroup();
-    final CallbackGroup group2 = new CallbackGroup();
-    final CallbackGroup group3 = new CallbackGroup();
-
-    CodeMirror.initLibrary(
-        group1.add(
-            new AsyncCallback<Void>() {
-              final AsyncCallback<Void> themeCallback = group3.addEmpty();
-
-              @Override
-              public void onSuccess(Void result) {
-                // Load theme after CM library to ensure theme can override CSS.
-                ThemeLoader.loadTheme(prefs.theme(), themeCallback);
-                group2.done();
-
-                new AddonInjector()
-                    .add(Addons.I.merge_bundled().getName())
-                    .inject(
-                        new AsyncCallback<Void>() {
-                          @Override
-                          public void onFailure(Throwable caught) {}
-
-                          @Override
-                          public void onSuccess(Void result) {
-                            if (!prefs.showBase() || revision.get() > 0) {
-                              group3.done();
-                            }
-                          }
-                        });
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {}
-            }));
-
-    ChangeApi.detail(
-        Project.NameKey.asStringOrNull(projectKey),
-        revision.getParentKey().get(),
-        group1.add(
-            new AsyncCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo c) {
-                projectKey = c.projectNameKey();
-                project.setInnerText(c.project());
-                SafeHtml.setInnerHTML(filePath, Header.formatPath(path));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {}
-            }));
-
-    if (revision.get() == 0) {
-      ChangeEditApi.getMeta(
-          Project.NameKey.asStringOrNull(projectKey),
-          revision,
-          path,
-          group1.add(
-              new AsyncCallback<EditFileInfo>() {
-                @Override
-                public void onSuccess(EditFileInfo editInfo) {
-                  editFileInfo = editInfo;
-                }
-
-                @Override
-                public void onFailure(Throwable e) {}
-              }));
-
-      if (prefs.showBase()) {
-        ChangeEditApi.get(
-            projectKey,
-            revision,
-            path,
-            true /* base */,
-            group1.addFinal(
-                new HttpCallback<NativeString>() {
-                  @Override
-                  public void onSuccess(HttpResponse<NativeString> fc) {
-                    baseContent = fc;
-                    group3.done();
-                  }
-
-                  @Override
-                  public void onFailure(Throwable e) {}
-                }));
-      } else {
-        group1.done();
-      }
-    } else {
-      // TODO(davido): We probably want to create dedicated GET EditScreenMeta
-      // REST endpoint. Abuse GET diff for now, as it retrieves links we need.
-      DiffApi.diff(Project.NameKey.asStringOrNull(projectKey), revision, path)
-          .webLinksOnly()
-          .get(
-              group1.addFinal(
-                  new AsyncCallback<DiffInfo>() {
-                    @Override
-                    public void onSuccess(DiffInfo diffInfo) {
-                      diffLinks = diffInfo.webLinks();
-                    }
-
-                    @Override
-                    public void onFailure(Throwable e) {}
-                  }));
-    }
-
-    ChangeEditApi.get(
-        projectKey,
-        revision,
-        path,
-        group2.add(
-            new HttpCallback<NativeString>() {
-              final AsyncCallback<Void> modeCallback = group3.addEmpty();
-
-              @Override
-              public void onSuccess(HttpResponse<NativeString> fc) {
-                content = fc;
-                if (revision.get() > 0) {
-                  baseContent = fc;
-                }
-
-                if (prefs.syntaxHighlighting()) {
-                  injectMode(fc.getContentType(), modeCallback);
-                } else {
-                  modeCallback.onSuccess(null);
-                }
-              }
-
-              @Override
-              public void onFailure(Throwable e) {
-                // "Not Found" means it's a new file.
-                if (RestApi.isNotFound(e)) {
-                  content = null;
-                  modeCallback.onSuccess(null);
-                } else {
-                  GerritCallback.showFailure(e);
-                }
-              }
-            }));
-
-    group3.addListener(
-        new ScreenLoadCallback<Void>(this) {
-          @Override
-          protected void preDisplay(Void result) {
-            initEditor();
-
-            renderLinks(editFileInfo, diffLinks);
-            editFileInfo = null;
-            diffLinks = null;
-
-            showBase.setValue(prefs.showBase(), true);
-            cmBase.refresh();
-          }
-        });
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    KeyMap localKeyMap = KeyMap.create();
-    localKeyMap.on("Ctrl-L", gotoLine()).on("Cmd-L", gotoLine()).on("Cmd-S", save());
-
-    // TODO(davido): Find a better way to prevent key maps collisions
-    if (prefs.keyMapType() != KeyMapType.EMACS) {
-      localKeyMap.on("Ctrl-S", save());
-    }
-
-    cmBase.addKeyMap(localKeyMap);
-    cmEdit.addKeyMap(localKeyMap);
-  }
-
-  private Runnable gotoLine() {
-    return () -> cmEdit.execCommand("jumpToLine");
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    Window.enableScrolling(false);
-    JumpKeys.enable(false);
-    if (prefs.hideTopMenu()) {
-      Gerrit.setHeaderVisible(false);
-    }
-    resizeHandler =
-        Window.addResizeHandler(
-            new ResizeHandler() {
-              @Override
-              public void onResize(ResizeEvent event) {
-                adjustHeight();
-              }
-            });
-    closeHandler =
-        Window.addWindowClosingHandler(
-            new ClosingHandler() {
-              @Override
-              public void onWindowClosing(ClosingEvent event) {
-                if (!cmEdit.isClean(generation)) {
-                  event.setMessage(EditConstants.I.closeUnsavedChanges());
-                }
-              }
-            });
-
-    generation = cmEdit.changeGeneration(true);
-    setClean(true);
-    cmEdit.on(
-        new ChangesHandler() {
-          @Override
-          public void handle(CodeMirror cm) {
-            setClean(cm.isClean(generation));
-          }
-        });
-
-    adjustHeight();
-    cmEdit.on("cursorActivity", updateCursorPosition());
-    setShowTabs(prefs.showTabs());
-    setLineLength(prefs.lineLength());
-    cmEdit.refresh();
-    cmEdit.focus();
-
-    if (startLine > 0) {
-      cmEdit.scrollToLine(startLine);
-    }
-    updateActiveLine();
-    editPrefsAction = new EditPreferencesAction(this, prefs);
-  }
-
-  @Override
-  protected void onUnload() {
-    super.onUnload();
-    if (cmBase != null) {
-      cmBase.getWrapperElement().removeFromParent();
-    }
-    if (cmEdit != null) {
-      cmEdit.getWrapperElement().removeFromParent();
-    }
-    if (resizeHandler != null) {
-      resizeHandler.removeHandler();
-    }
-    if (closeHandler != null) {
-      closeHandler.removeHandler();
-    }
-    Window.enableScrolling(true);
-    Gerrit.setHeaderVisible(true);
-    JumpKeys.enable(true);
-  }
-
-  CodeMirror getEditor() {
-    return cmEdit;
-  }
-
-  @UiHandler("editSettings")
-  void onEditSetting(@SuppressWarnings("unused") ClickEvent e) {
-    editPrefsAction.show();
-  }
-
-  @UiHandler("save")
-  void onSave(@SuppressWarnings("unused") ClickEvent e) {
-    save().run();
-  }
-
-  @UiHandler("close")
-  void onClose(@SuppressWarnings("unused") ClickEvent e) {
-    if (cmEdit.isClean(generation) || Window.confirm(EditConstants.I.cancelUnsavedChanges())) {
-      upToChange();
-    }
-  }
-
-  private void displayBase() {
-    cmBase.getWrapperElement().getParentElement().removeClassName(style.hideBase());
-    cmEdit.getWrapperElement().getParentElement().removeClassName(style.fullWidth());
-    mv.getGapElement().removeClassName(style.hideBase());
-    setCmBaseValue();
-    setLineLength(prefs.lineLength());
-    cmBase.refresh();
-  }
-
-  @UiHandler("showBase")
-  void onShowBase(ValueChangeEvent<Boolean> e) {
-    boolean shouldShow = e.getValue();
-    if (shouldShow) {
-      if (baseContent == null) {
-        ChangeEditApi.get(
-            projectKey,
-            revision,
-            path,
-            true /* base */,
-            new HttpCallback<NativeString>() {
-              @Override
-              public void onSuccess(HttpResponse<NativeString> fc) {
-                baseContent = fc;
-                displayBase();
-              }
-
-              @Override
-              public void onFailure(Throwable e) {}
-            });
-      } else {
-        displayBase();
-      }
-    } else {
-      cmBase.getWrapperElement().getParentElement().addClassName(style.hideBase());
-      cmEdit.getWrapperElement().getParentElement().addClassName(style.fullWidth());
-      mv.getGapElement().addClassName(style.hideBase());
-    }
-    mv.setShowDifferences(shouldShow);
-  }
-
-  void setOption(String option, String value) {
-    cmBase.setOption(option, value);
-    cmEdit.setOption(option, value);
-  }
-
-  void setOption(String option, boolean value) {
-    cmBase.setOption(option, value);
-    cmEdit.setOption(option, value);
-  }
-
-  void setOption(String option, double value) {
-    cmBase.setOption(option, value);
-    cmEdit.setOption(option, value);
-  }
-
-  void setTheme(Theme newTheme) {
-    cmBase.operation(() -> cmBase.setOption("theme", newTheme.name().toLowerCase()));
-    cmEdit.operation(() -> cmEdit.setOption("theme", newTheme.name().toLowerCase()));
-  }
-
-  void setLineLength(int length) {
-    int adjustedLength = Patch.COMMIT_MSG.equals(path) ? 72 : length;
-    cmBase.extras().lineLength(adjustedLength);
-    cmEdit.extras().lineLength(adjustedLength);
-  }
-
-  void setIndentUnit(int indent) {
-    cmEdit.setOption("indentUnit", Patch.COMMIT_MSG.equals(path) ? 2 : indent);
-  }
-
-  void setShowLineNumbers(boolean show) {
-    cmBase.setOption("lineNumbers", show);
-    cmEdit.setOption("lineNumbers", show);
-  }
-
-  void setShowWhitespaceErrors(boolean show) {
-    cmBase.operation(() -> cmBase.setOption("showTrailingSpace", show));
-    cmEdit.operation(() -> cmEdit.setOption("showTrailingSpace", show));
-  }
-
-  void setShowTabs(boolean show) {
-    cmBase.extras().showTabs(show);
-    cmEdit.extras().showTabs(show);
-  }
-
-  void adjustHeight() {
-    int height = header.getOffsetHeight();
-    int rest = Gerrit.getHeaderFooterHeight() + height + 5; // Estimate
-    mv.getGapElement().getStyle().setHeight(Window.getClientHeight() - rest, Unit.PX);
-    cmBase.adjustHeight(height);
-    cmEdit.adjustHeight(height);
-  }
-
-  void setSyntaxHighlighting(boolean b) {
-    ModeInfo modeInfo = ModeInfo.findMode(content.getContentType(), path);
-    final String mode = modeInfo != null ? modeInfo.mime() : null;
-    if (b && mode != null && !mode.isEmpty()) {
-      injectMode(
-          mode,
-          new AsyncCallback<Void>() {
-            @Override
-            public void onSuccess(Void result) {
-              cmBase.setOption("mode", mode);
-              cmEdit.setOption("mode", mode);
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              prefs.syntaxHighlighting(false);
-            }
-          });
-    } else {
-      cmBase.setOption("mode", (String) null);
-      cmEdit.setOption("mode", (String) null);
-    }
-  }
-
-  private void upToChange() {
-    Gerrit.display(PageLinks.toChangeInEditMode(projectKey, revision.getParentKey()));
-  }
-
-  private void initEditor() {
-    ModeInfo mode = null;
-    String editContent = "";
-    if (content != null && content.getResult() != null) {
-      editContent = content.getResult().asString();
-      if (prefs.syntaxHighlighting()) {
-        mode = ModeInfo.findMode(content.getContentType(), path);
-      }
-    }
-
-    Configuration cfg =
-        Configuration.create()
-            .set("autoCloseBrackets", prefs.autoCloseBrackets())
-            .set("cursorBlinkRate", prefs.cursorBlinkRate())
-            .set("cursorHeight", 0.85)
-            .set("indentUnit", prefs.indentUnit())
-            .set("keyMap", prefs.keyMapType().name().toLowerCase())
-            .set("lineNumbers", prefs.hideLineNumbers())
-            .set("lineWrapping", prefs.lineWrapping())
-            .set("indentWithTabs", prefs.indentWithTabs())
-            .set("matchBrackets", prefs.matchBrackets())
-            .set("mode", mode != null ? mode.mime() : null)
-            .set("origLeft", editContent)
-            .set("scrollbarStyle", "overlay")
-            .set("showTrailingSpace", prefs.showWhitespaceErrors())
-            .set("styleSelectedText", true)
-            .set("tabSize", prefs.tabSize())
-            .set("theme", prefs.theme().name().toLowerCase())
-            .set("value", "");
-
-    if (editContent.contains("\r\n")) {
-      cfg.set("lineSeparator", "\r\n");
-    }
-
-    mv = MergeView.create(editor, cfg);
-
-    cmBase = mv.leftOriginal();
-    cmBase.getWrapperElement().addClassName(style.base());
-    cmEdit = mv.editor();
-    setCmBaseValue();
-    cmEdit.setValue(editContent);
-
-    CodeMirror.addCommand(
-        "save",
-        new CommandRunner() {
-          @Override
-          public void run(CodeMirror instance) {
-            save().run();
-          }
-        });
-  }
-
-  private void renderLinks(EditFileInfo editInfo, JsArray<DiffWebLinkInfo> diffLinks) {
-    renderLinksToDiff();
-
-    if (editInfo != null) {
-      renderLinks(Natives.asList(editInfo.webLinks()));
-    } else if (diffLinks != null) {
-      renderLinks(Natives.asList(diffLinks));
-    }
-  }
-
-  private void renderLinks(List<DiffWebLinkInfo> links) {
-    if (links != null) {
-      for (DiffWebLinkInfo webLink : links) {
-        linkPanel.add(webLink.toAnchor());
-      }
-    }
-  }
-
-  private void renderLinksToDiff() {
-    InlineHyperlink sbs = new InlineHyperlink();
-    sbs.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.sideBySideDiff()));
-    sbs.setTargetHistoryToken(
-        Dispatcher.toPatch(projectKey, "sidebyside", null, new Patch.Key(revision, path)));
-    sbs.setTitle(PatchUtil.C.sideBySideDiff());
-    linkPanel.add(sbs);
-
-    InlineHyperlink unified = new InlineHyperlink();
-    unified.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.unifiedDiff()));
-    unified.setTargetHistoryToken(
-        Dispatcher.toPatch(projectKey, "unified", null, new Patch.Key(revision, path)));
-    unified.setTitle(PatchUtil.C.unifiedDiff());
-    linkPanel.add(unified);
-  }
-
-  private Runnable updateCursorPosition() {
-    return () -> {
-      // The rendering of active lines has to be deferred. Reflow
-      // caused by adding and removing styles chokes Firefox when arrow
-      // key (or j/k) is held down. Performance on Chrome is fine
-      // without the deferral.
-      //
-      Scheduler.get().scheduleDeferred(() -> cmEdit.operation(this::updateActiveLine));
-    };
-  }
-
-  private void updateActiveLine() {
-    Pos p = cmEdit.getCursor("end");
-    cursLine.setInnerText(Integer.toString(p.line() + 1));
-    cursCol.setInnerText(Integer.toString(p.ch() + 1));
-    cmEdit.extras().activeLine(cmEdit.getLineHandleVisualStart(p.line()));
-  }
-
-  private void setClean(boolean clean) {
-    save.setEnabled(!clean);
-    close.setEnabled(true);
-    dirty.getStyle().setVisibility(!clean ? VISIBLE : HIDDEN);
-  }
-
-  private Runnable save() {
-    return () -> {
-      if (!cmEdit.isClean(generation)) {
-        close.setEnabled(false);
-        String text = cmEdit.getValue();
-        if (Patch.COMMIT_MSG.equals(path)) {
-          String trimmed = text.trim() + "\r";
-          if (!trimmed.equals(text)) {
-            text = trimmed;
-            cmEdit.setValue(text);
-          }
-        }
-        final int g = cmEdit.changeGeneration(false);
-        ChangeEditApi.put(
-            Project.NameKey.asStringOrNull(projectKey),
-            revision.getParentKey().get(),
-            path,
-            text,
-            new GerritCallback<VoidResult>() {
-              @Override
-              public void onSuccess(VoidResult result) {
-                generation = g;
-                setClean(cmEdit.isClean(g));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                close.setEnabled(true);
-              }
-            });
-      }
-    };
-  }
-
-  private void injectMode(String type, AsyncCallback<Void> cb) {
-    new ModeInjector().add(type).inject(cb);
-  }
-
-  private void setCmBaseValue() {
-    cmBase.setValue(
-        baseContent != null && baseContent.getResult() != null
-            ? baseContent.getResult().asString()
-            : "");
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.ui.xml
deleted file mode 100644
index 34282c8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.ui.xml
+++ /dev/null
@@ -1,185 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-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.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-  <ui:style gss='false' type='com.google.gerrit.client.editor.EditScreen.Style'>
-    @external .CodeMirror, .CodeMirror-cursor;
-    @external .CodeMirror-merge-2pane, .CodeMirror-merge-pane;
-    @external .CodeMirror-merge-gap;
-    @external .CodeMirror-scroll, .CodeMirror-overlayscroll-vertical;
-
-    .header {
-      position: relative;
-      height: 16px;
-      line-height: 16px;
-    }
-
-    .header .CodeMirror div.CodeMirror-cursor {
-      border-left: 2px solid black;
-    }
-
-    .headerLine {
-      background-color: #f7f7f7;
-      border-bottom: 1px solid #ddd;
-      padding-left: 30px;
-    }
-
-    .headerButtons {
-      display: inline-block;
-      padding-right: 5px;
-      border-right: 1px inset #ddd;
-      margin-right: 5px;
-    }
-
-    .headerButtons button:disabled {
-      background-color: #ddd;
-      font-weight: normal;
-      cursor: default;
-    }
-
-    .headerButtons button {
-      margin: 2px 0 2px 0;
-      text-align: center;
-      font-size: 8pt;
-      cursor: pointer;
-      border: 1px solid;
-      color: rgba(0, 0, 0, 0.15);
-      background-color: #f5f5f5;
-      -webkit-border-radius: 1px;
-      -webkit-box-sizing: content-box;
-    }
-
-    .headerButtons button div {
-      color: #444;
-      min-width: 54px;
-      white-space: nowrap;
-      line-height: 8pt;
-    }
-
-    .save {
-      font-weight: bold;
-    }
-
-    .path {
-      white-space: nowrap;
-    }
-
-    .statusLine {
-      position: fixed;
-      bottom: 0;
-      left: 0;
-      width: 175px;
-      height: 19px;
-      background-color: #f7f7f7;
-      border-top: 1px solid #ddd;
-      border-right: 1px solid #ddd;
-    }
-    .statusLine div {
-      height: inherit;
-    }
-
-    .cursorPosition {
-      display: inline-block;
-      margin: 0 5px 0 35px;
-      white-space: nowrap;
-    }
-
-    .dirty {
-      display: inline-block;
-      margin: 0 5px 0 5px;
-      padding: 0 0 0 5px;
-      border-left: 1px solid #ddd;
-      font-weight: bold;
-    }
-
-    .navigation {
-      position: absolute;
-      top: 0;
-      right: 10px;
-    }
-    .linkPanel {
-      float: left;
-    }
-    .linkPanel img {
-      padding-top: 2px;
-      padding-right: 3px;
-    }
-
-    .preferences {
-      position: relative;
-      top: 2px;
-      cursor: pointer;
-      outline: none;
-    }
-
-    .hideBase.CodeMirror-merge-pane {
-      display: none;
-    }
-
-    .hideBase.CodeMirror-merge-gap {
-      display: none;
-    }
-
-    .CodeMirror-merge-2pane .fullWidth.CodeMirror-merge-pane {
-      width: 100%;
-    }
-
-    /* Hide the vertical scrollbar on the base side. The edit side controls
-       both views */
-    .base .CodeMirror-scroll { margin-right: -42px; }
-    .base .CodeMirror-overlayscroll-vertical { display: none !important; }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.header}'>
-    <div class='{style.headerLine}' ui:field='header'>
-       <div class='{style.headerButtons}'>
-         <g:Button ui:field='close'
-             title='Close file and return to change'>
-           <ui:attribute name='title'/>
-           <div><ui:msg>Close</ui:msg></div>
-         </g:Button>
-         <g:Button ui:field='save'
-             styleName='{style.save}'
-             title='Save'>
-           <ui:attribute name='title'/>
-           <div><ui:msg>Save</ui:msg></div>
-         </g:Button>
-       </div>
-       <span class='{style.path}'><span ui:field='project'/> / <span ui:field='filePath'/></span>
-       <div class='{style.navigation}'>
-         <g:Label text='Show Base' styleName='{style.linkPanel}'></g:Label>
-         <g:CheckBox ui:field='showBase' checked='true' styleName='{style.linkPanel}'
-             title='Show Base Version'>
-           <ui:attribute name='title'/>
-         </g:CheckBox>
-         <g:FlowPanel ui:field='linkPanel' styleName='{style.linkPanel}'/>
-         <g:Image
-             ui:field='editSettings'
-             styleName='{style.preferences}'
-             resource='{ico.gear}'
-             title='Edit screen preferences'>
-            <ui:attribute name='title'/>
-         </g:Image>
-       </div>
-    </div>
-    <div ui:field='editor' />
-    <div class='{style.statusLine}'>
-      <div class='{style.cursorPosition}'><span ui:field='cursLine'/> : <span ui:field='cursCol'/></div>
-      <div class='{style.dirty}' ui:field='dirty'>Unsaved</div>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
deleted file mode 100644
index 4076296..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
+++ /dev/null
@@ -1,1060 +0,0 @@
-/* Copyright (C) 2009 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Make every single class external so users can rely on their names
- */
-@external .*;
-
-@def black #000000;
-@def white #ffffff;
-@def norm-font  sans-serif;
-@def mono-font  monospace;
-
-@eval backgroundColor com.google.gerrit.client.Gerrit.getTheme().backgroundColor;
-@eval topMenuColor com.google.gerrit.client.Gerrit.getTheme().topMenuColor;
-@eval textColor com.google.gerrit.client.Gerrit.getTheme().textColor;
-@eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
-@eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-@eval changeTableOutdatedColor com.google.gerrit.client.Gerrit.getTheme().changeTableOutdatedColor;
-@eval tableOddRowColor com.google.gerrit.client.Gerrit.getTheme().tableOddRowColor;
-@eval tableEvenRowColor com.google.gerrit.client.Gerrit.getTheme().tableEvenRowColor;
-
-@sprite .greenCheckClass {
-  gwt-image: "greenCheck";
-}
-
-/** Override various GWT defaults */
-.gerritTopMenu {
-  font-size: 9pt;
-  padding-left: 5px;
-  padding-right: 5px;
-  background: transparent;
-}
-
-body, table td, select {
-  font-family: norm-font;
-}
-
-button {
-  padding: 1px 6px;
-}
-
-.gerritBody {
-  font-size: small;
-  padding-left: 5px;
-  padding-right: 5px;
-}
-
-a,
-a:visited {
-  color: #0654ac;
-  text-decoration: none;
-}
-
-a:hover {
-  color: #0654ac;
-  text-decoration: underline;
-}
-
-#gerrit_btmmenu {
-  clear: both;
-  color: #a0adcc;
-  text-align: right;
-  padding-right: 10px;
-}
-
-.version a,
-.version a:visited,
-.version a:hover {
-  color: #2a5db0;
-}
-
-
-/** Widgets **/
-.gwt-Button {
-  color: black;
-}
-
-.accountLinkPanel {
-  display: inline;
-}
-
-.accountLinkPanel img {
-  margin-right: 0.2em;
-  position: relative;
-  top: 2px;
-  height: 16px !important;
-  width: 16px;
-}
-
-.accountLinkPanel a {
-  position: relative;
-  top: -1px;
-}
-
-.inputFieldTypeHint {
-  color: grey;
-}
-
-.smallHeading {
-  margin-top: 5px;
-  font-weight: bold;
-}
-
-.link {
-  cursor: pointer;
-}
-
-.extensionPanel {
-  padding-top: 10px;
-}
-
-/** MenuScreen **/
-.menuScreenMenuBar {
-  background: topMenuColor;
-  padding-top: 0.5em;
-  padding-bottom: 10em;
-  padding-left: 0.5em;
-  padding-right: 0.5em;
-  border-right: 1px solid black;
-  margin-right: 0.5em;
-}
-
-.menuScreenMenuBar .menuItem {
-  white-space: nowrap;
-  display: block;
-  border-right: none;
-  padding: 0.2em;
-}
-
-.menuScreenMenuBar .menuItem.activeRow {
-  background: selectionColor;
-}
-
-.menuItem.activeRow {
-  background: selectionColor;
-}
-
-/** Menu **/
-.linkMenuBar {
-  font-size: 9pt;
-  display: inline;
-  white-space: nowrap;
-  padding-left: 6px;
-}
-.menuItem {
-  padding-left: 5px;
-  padding-right: 5px;
-}
-.linkMenuItemNotLast {
-  border-right: 1px solid black;
-}
-
-.topmenu {
-  width: 100%;
-}
-.topmenuTDmenu {
-  vertical-align: top;
-}
-.topmenuTDglue {
-  width: 100%;
-}
-
-.topmenuMenuLeft {
-  width: 300px;
-  font-size: 9pt;
-  padding-top: 5px;
-  padding-left: 5px;
-  padding-right: 5px;
-  background: none;
-  position: relative;
-  top: 0;
-}
-.topmenuMenuLeft tbody tr td table {
-  border: 0;
-}
-.topmenuMenuLeft tbody tr td table.gwt-TabBar {
-  border-bottom: 1px solid #DDD;
-}
-.topmenuMenuLeft .gwt-TextBox {
-  width: 250px;
-}
-.topmenuMenuLeft .gwt-Button {
-  padding: 3px 6px;
-}
-.topmenuMenuLeft .gwt-TabBarFirst {
-  display: none;
-}
-.topmenuMenuLeft .gwt-TabBarItem {
-  margin: 0px;
-  background: transparent;
-  padding-top: 0px;
-  padding-bottom: 1px;
-  padding-left: 1em;
-  padding-right: 1em;
-}
-.topmenuMenuLeft .gwt-TabBarRest {
-  background: transparent;
-  padding-top: 0px;
-}
-.topmenuMenuLeft .gwt-TabPanelBottom {
-  background: transparent;
-  border-top: none;
-  border-left: none;
-  border-right: none;
-  border-bottom: none;
-  padding: 1px;
-}
-.topmenuMenuLeft .menuItem {
-  padding-left: 1em;
-  padding-right: 1em;
-  border-right: none;
-}
-
-.topmenuMenuRight {
-  float: right;
-  text-align: right;
-}
-.menuBarUserName {
-  padding-left: 5px;
-  padding-right: 5px;
-  white-space: nowrap;
-}
-.menuBarUserNameAvatar {
-  vertical-align: middle;
-}
-.menuBarUserNameFocusPanel {
-  display: inline;
-}
-.menuBarUserNamePanel {
-  display: inline;
-  cursor: pointer;
-  font-weight: bold;
-}
-.userInfoPopup {
-  border: 1px solid black;
-  background: white;
-  box-shadow: 3px 3px 5px #888;
-  z-index: 200;
-}
-.searchPanel {
-  white-space: nowrap;
-  display: inline;
-}
-.searchPanel .searchTextBox {
-  font-size: 9pt;
-  margin: 8.286px 3px 0 0;
-}
-.searchPanel .searchDropdown {
-  font-size: 8pt;
-  border: 2px solid;
-  border-color: rgba(0, 0, 0, 0.15);
-  height: 16px;
-  border-radius: 2px;
-  box-sizing: content-box;
-}
-.searchPanel .searchButton {
-  text-align: center;
-  font-size: 8pt;
-  font-weight: bold;
-  cursor: pointer;
-  border: 2px solid;
-  color: #FFF;
-  border-color: rgba(0, 0, 0, 0.15);
-  height: 14px;
-  background-color: #53A93F;
-  border-radius: 2px;
-  box-sizing: content-box;
-}
-.suggestBoxPopup {
-  z-index: 200;
-}
-
-/** RPC Status **/
-.rpcStatus {
-  position: fixed;
-  top: 6px;
-  left: 50%;
-  padding-top: 4px;
-  padding-bottom: 4px;
-  padding-left: 10px;
-  padding-right: 10px;
-  text-align: center;
-  font-weight: bold;
-  background: #FFF1A8;
-  z-index: 200;
-}
-
-
-/** Error Dialog **/
-.errorDialog {
-  background: none;
-  border: none;
-  padding: 10px;
-  width: 600px;
-  color: backgroundColor;
-  font-size: 15px;
-  font-family: verdana;
-  z-index: 200;
-}
-.errorDialogGlass {
-  opacity: 0.75;
-  z-index: 200;
-}
-@if user.agent safari {
-  .errorDialogGlass {
-    opacity: 0.80;
-  }
-}
-@if user.agent ie8 {
-  /* IE just doesn't do opacity the way we want, make our dialog
-   * stand out in a way that it can't be missed against the page
-   */
-  .errorDialog {
-    color: black;
-    background: darkgray;
-    border: 10px groove lightgrey;
-  }
-}
-.errorDialogTitle {
-  font-size: 30px;
-  font-weight: bold;
-  margin-bottom: 15px;
-}
-.errorDialogErrorType {
-  font-weight: bold;
-  white-space: nowrap;
-  margin-bottom: 15px;
-}
-.errorDialogButtons {
-  width: 100%;
-  margin-top: 15px;
-}
-.errorDialog a,
-.errorDialog a:visited,
-.errorDialog a:hover {
-  color: white;
-  font-weight: bold;
-  font-size: 15px;
-  font-family: verdana;
-}
-.loadingPluginsDialog {
-  background: #fff;
-  color: #000;
-  width: auto;
-}
-
-
-/** Screen **/
-.screen {
-}
-
-.screenHeader {
-  white-space: nowrap;
-  font-size: 16pt;
-  margin: 3px 0 8px;
-  text-overflow: ellipsis;
-  overflow: hidden;
-}
-
-/** ChangeTable **/
-.changeTable {
-  border-collapse: separate;
-  border-spacing: 0;
-}
-
-.changeTable tr:nth-child\(even\) {
-  background: tableEvenRowColor;
-}
-
-.changeTable tr:nth-child\(odd\) {
-  background: tableOddRowColor;
-}
-
-.changeTable .iconCell {
-  width: 1px;
-  padding: 0px;
-  vertical-align: middle;
-  border-bottom: 1px solid trimColor;
-}
-
-.changeTable .leftMostCell {
-  border-left: 1px solid trimColor;
-}
-
-.changeTable .dataCell {
-  padding-left: 5px;
-  padding-right: 5px;
-  border-right: 1px solid trimColor;
-  border-bottom: 1px solid trimColor;
-  vertical-align: middle;
-  height: 20px;
-}
-
-.changeTable .dataCellHidden {
-  display: none;
-}
-
-.changeTable a.gwt-InlineHyperlink,
-.changeTable a.gwt-Anchor {
-  color: #222 !important;
-}
-
-.changeTable .changeSize {
-  height: 10px;
-  display: inline-block;
-  opacity: 0.6;
-}
-
-.accountDashboard.changeTable tr {
-  color: #444444;
-}
-.accountDashboard.changeTable tr a {
-  color: #444444;
-  text-decoration: none;
-}
-.accountDashboard.changeTable .needsReview,
-.accountDashboard.changeTable .needsReview a {
-  font-weight: bold;
-  color: textColor;
-}
-
-.changeTable .activeRow,
-.accountDashboard.changeTable .activeRow,
-.accountDashboard.changeTable .activeRow a {
-  background: selectionColor !important;
-}
-
-.changeTable .cSIZE {
-  width: 70px;
-  text-align: right;
-}
-
-.changeTable .cSUBJECT div {
-  text-overflow: ellipsis;
-  overflow: hidden;
-  white-space: nowrap;
-}
-
-.changeTable .cASSIGNEDTOME {
-  background: #ffe9d6 !important;
-}
-
-.changeTable .cASSIGNEE,
-.changeTable .cOWNER,
-.changeTable .cSTATUS {
-  white-space: nowrap;
-}
-
-.changeTable .cLastUpdate {
-  white-space: nowrap;
-  text-align: right;
-  width: 1em;
-}
-
-.changeTable .groupName {
-  white-space: nowrap;
-}
-
-.changeTable .cAPPROVAL {
-  width: 0.5em;
-  text-align: center;
-}
-.changeTable .dataCell.negscore {
-  color: red;
-}
-.changeTable .dataCell.posscore {
-  color: #08a400;
-}
-.changeTable .dataCell.singleLine {
-  white-space: nowrap;
-}
-.changeTable .dataCell.labelNotApplicable {
- background: #F5F5F5;
-}
-.changeTable .iconHeader {
-  border-top: 1px solid backgroundColor;
-  border-bottom: 1px solid backgroundColor;
-  background-color: trimColor;
-}
-
-.changeTable .dataHeader {
-  border: 1px solid backgroundColor;
-  padding: 2px 6px 1px;
-  background-color: trimColor;
-  font-style: italic;
-  white-space: nowrap;
-  color: textColor;
-}
-
-.changeTable .dataHeaderHidden {
-  display: none;
-}
-
-.changeTable .sectionHeader {
-  border-top: 8px solid backgroundColor;
-  padding: 2px 6px 1px;
-  background-color: trimColor;
-  white-space: nowrap;
-  font-weight: bold;
-  color: textColor;
-}
-
-.changeTable .emptySection {
-  border-left: 1px solid trimColor;
-  border-right: 1px solid trimColor;
-  border-bottom: 1px solid trimColor;
-  font-style: italic;
-  padding-left: 25px;
-}
-
-.changeTablePrevNextLinks {
-  float: right;
-  padding-right: 5px;
-}
-.changeTablePrevNextLinks td {
-  width: 5em;
-  text-align: right;
-}
-.changeTablePrevNextLinks .gwt-Hyperlink {
-  font-size: 9pt;
-  color: #2a5db0;
-}
-
-/** Change **/
-.avatarInfoPanel {
-  margin-right: 10px;
-}
-.avatarInfoPanel td {
-  text-align: center;
-}
-
-.infoBlock {
-  border-collapse: collapse;
-  border-spacing: 0;
-}
-
-.infoBlock td {
-  padding: 2px 4px 2px 6px;
-  border-right: 1px solid trimColor;
-  border-bottom: 1px solid trimColor;
-  text-align: left;
-  white-space: nowrap;
-}
-
-.infoBlock td td {
-  padding-left: 0px;
-  border-right: 0px;
-}
-
-.infoBlock td.topmost {
-  border-top: 1px solid trimColor;
-}
-
-.infoBlock td.header {
-  background-color: trimColor;
-  font-style: italic;
-  text-align: right;
-}
-
-.infoBlock td.bottomheader {
-  border-bottom: 1px solid trimColor;
-}
-
-
-.patchSetActions {
-  margin-bottom: 10px;
-}
-.patchSetActions .gwt-Button {
-  margin-right: 30px;
-  font-size: 8pt;
-}
-
-.downloadBox {
-  min-width: 580px;
-  margin: 5px;
-  margin-right: 15px;
-}
-.downloadBoxTable {
-  border-spacing: 0;
-  width: 100%;
-}
-.downloadBoxTableCommandColumn {
-  text-align: left;
-  font-weight: normal;
-  white-space: nowrap;
-  max-height: 18px;
-  width: 80px;
-  padding-right: 5px;
-}
-.downloadBoxSpacer {
-  margin-left: 5px;
-  margin-right: 5px;
-}
-.downloadBoxScheme {
-  float: right;
-}
-.downloadBoxCopyLabel {
-  font-size: smaller;
-  font-family: monospace;
-}
-.downloadBoxCopyLabel span {
-  width: 500px;
-  white-space: nowrap;
-  display: inline-block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-.downloadBoxCopyLabel .gwt-TextBox {
-  padding: 0;
-  margin: 0;
-  border: 0;
-  max-height: 18px;
-  width: 500px;
-}
-.downloadBoxCopyLabel div {
-  float: right;
-}
-.downloadLinkHeader {
-  background: trimColor;
-  white-space: nowrap;
-  border-bottom: 1px solid black;
-}
-.downloadLinkHeaderGap {
-  margin-left: 5em;
-}
-.downloadLinkList {
-  display: inline;
-  white-space: nowrap;
-}
-.downloadLink {
-  color: black;
-  text-decoration: none;
-  white-space: nowrap;
-  background: trimColor;
-  border-right: 1px solid black;
-  padding-left: 0.5em;
-  padding-right: 0.5em;
-}
-a:hover.downloadLink {
-  color: black;
-}
-.downloadLink_Active {
-  background: selectionColor;
-}
-.downloadLinkCopyLabel {
-  white-space: pre;
-  font-family: mono-font;
-  font-size: 12px;
-  margin-left: 0.5em;
-  margin-right: 0.5em;
-}
-.downloadLinkCopyLabel .gwt-TextBox {
-  width: 40em;
-}
-.downloadLinkCopyLabel span {
-  width: 40em;
-  white-space: nowrap;
-  display: inline-block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-/** AccountSettings  **/
-.usernameField {
-  white-space: nowrap;
-}
-.accountUsername {
-  font-family: mono-font;
-  font-size: small;
-}
-.accountPassword {
-  font-family: mono-font;
-  font-size: small;
-}
-.sshKeyPanelEncodedKey {
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  overflow: hidden;
-  font-family: mono-font;
-  font-size: small;
-}
-.sshKeyPanelInvalid {
-  white-space: nowrap;
-  color: red;
-  font-weight: bold;
-}
-.identityUntrustedExternalId {
-  white-space: nowrap;
-  color: red;
-  font-weight: bold;
-}
-
-.accountInfoBlock {
-  margin-bottom: 10px;
-}
-.accountInfoBlock .gwt-Button {
-  margin-left: 10px;
-}
-
-.addWatchPanel {
-  margin-top: 10px;
-  padding: 5px 5px 5px 5px;
-}
-.watchedProjectFilter {
-  margin-left: 1em;
-  color: grey;
-}
-
-.addBranch {
-  margin-top: 10px;
-  background-color: trimColor;
-  padding: 5px 5px 5px 5px;
-}
-
-.addSshKeyPanel {
-  margin-top: 10px;
-  background-color: trimColor;
-  padding: 5px 5px 5px 5px;
-}
-
-.addSshKeyPanel ol {
-  margin-top: 0px;
-  margin-bottom: 5px;
-}
-
-.addSshKeyPanel td {
-  width: 100%;
-}
-
-.sshKeyTable td.dataCell, .sshKeyTable td.iconCell {
-  vertical-align: top;
-}
-
-.createProjectPanel {
-  margin-bottom: 10px;
-  background-color: trimColor;
-  padding: 5px 5px 5px 5px;
-}
-
-.sshHostKeyPanel {
-  margin-top: 10px;
-  border: 1px solid trimColor;
-  padding: 5px 5px 5px 5px;
-}
-.sshHostKeyPanelHeading {
-  white-space: nowrap;
-  margin-top: 5px;
-  margin-left: 1em;
-}
-.sshHostKeyPanelFingerprintData {
-  margin-left: 2em;
-  white-space: nowrap;
-  font-family: mono-font;
-  font-size: small;
-}
-.sshHostKeyPanelKnownHostEntry {
-  margin-left: 2em;
-  white-space: nowrap;
-  font-family: mono-font;
-  font-size: small;
-  width: 80em;
-}
-
-.contributorAgreementButton {
-  font-weight: bold;
-}
-
-.contributorAgreementShortDescription {
-  margin-left: 20px;
-  margin-right: 20px;
-  margin-bottom: 10px;
-  padding: 5px 5px 5px 5px;
-  border: 1px solid #b0bdcc;
-}
-
-.contributorAgreementAlreadySubmitted {
-  margin-left: 20px;
-  margin-right: 20px;
-  padding: 5px 5px 5px 5px;
-  color: red;
-}
-
-.contributorAgreementLegal {
-  margin-left: 20px;
-  margin-right: 20px;
-  padding: 5px 5px 5px 5px;
-  border: 1px solid #b0bdcc;
-}
-
-.registerScreenSection {
-  margin-top: 2em;
-}
-.registerScreenExplain {
-  margin-left: 10px;
-  margin-top: 5px;
-  margin-bottom: 5px;
-  width: 45em;
-}
-.registerScreenNextLinks {
-  margin-top: 2em;
-}
-.registerScreenNextLinks .gwt-InlineHyperlink {
-  margin-left: 2em;
-  white-space: nowrap;
-}
-.registerScreenSection .changeTable {
-  width: 45em;
-}
-.registerScreenSection .addSshKeyPanel {
-  background: none;
-}
-.registerScreenSection .sshHostKeyPanel {
-  border: none;
-}
-.registerScreenSection .sshHostKeyPanel .sshHostKeyPanelKnownHostEntry {
-  width: 45em;
-}
-
-.projectActions {
-  margin-bottom: 10px;
-}
-
-.oauthInfoBlock {
-  margin-bottom: 10px;
-}
-.oauthToken {
-  font-family: monospace;
-  font-size: small;
-  width: 40em;
-}
-.oauthToken span {
-  white-space: nowrap;
-  display: inline-block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  width: 38em;
-}
-.oauthExpires {
-  font-family: monospace;
-  font-size: small;
-  width: 40em;
-}
-.oauthPanel {
-  margin-top: 10px;
-  border: 1px solid trimColor;
-  padding: 5px 5px 5px 5px;
-}
-.oauthPanelNetRCHeading {
-  margin-top: 5px;
-  margin-left: 1em;
-  white-space: nowrap;
-}
-.oauthPanelNetRCEntry {
-  margin-top: 5px;
-  margin-left: 2em;
-  font-family: monospace;
-  font-size: small;
-  width: 80em;
-}
-.oauthPanelNetRCEntry span {
-  white-space: nowrap;
-  display: inline-block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  width: 78em;
-}
-.oauthPanelCookieHeading {
-  margin-top: 15px;
-  margin-left: 1em;
-  white-space: nowrap;
-}
-.oauthPanelCookieEntry {
-  margin-top: 5px;
-  margin-left: 2em;
-  font-family: monospace;
-  font-size: small;
-  width: 80em;
-}
-.oauthPanelCookieEntry span {
-  white-space: nowrap;
-  display: inline-block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  width: 78em;
-}
-
-
-/** CommentedActionDialog **/
-.commentedActionDialog .gwt-DisclosurePanel .header td {
-  font-weight: bold;
-  white-space: nowrap;
-}
-.commentedActionDialog .smallHeading {
-  font-size: small;
-  font-weight: bold;
-  white-space: nowrap;
-}
-.commentedActionDialog .commentedActionMessage {
-  margin-left: 10px;
-  background: trimColor;
-  padding: 5px 5px 5px 5px;
-}
-.commentedActionDialog .commentedActionMessage textarea {
-  font-size: small;
-}
-.commentedActionDialog .gwt-Hyperlink {
-  white-space: nowrap;
-  font-size: small;
-}
-.commentedActionDialog .rebaseContentPanel {
-  margin-left: 10px;
-  background: trimColor;
-  padding: 5px 5px 5px 5px;
-  width: 300px;
-}
-.commentedActionDialog .rebaseContentPanel .rebaseSuggestBox {
-  font-size: small;
-  width: 100%;
-}
-
-/** AccountGroupInfoScreen **/
-.groupUUIDPanel {
-  margin-bottom: 10px;
-}
-.groupDescriptionPanel {
-  margin-bottom: 3px;
-}
-.groupNamePanel {
-  margin-bottom: 3px;
-}
-.groupNameTextBox {
-  margin-bottom: 2px;
-}
-.groupOptionsPanel {
-  margin-bottom: 5px;
-}
-.groupOwnerPanel {
-  margin-bottom: 3px;
-}
-.groupOwnerTextBox {
-  margin-bottom: 2px;
-}
-
-
-/** AccountGroupMembersScreen **/
-.groupMembersTable {
-  margin-bottom: 2px;
-}
-.groupIncludesTable {
-  margin-bottom: 2px;
-}
-
-
-/** AddMemberBox **/
-.addMemberTextBox {
-  margin-right: 2px;
-  margin-bottom: 2px;
-}
-
-
-/** ProjectBranchesScreen **/
-.specialBranchIconCell {
-  background: #ECECEC;
-  border-bottom: 1px solid #FFFFFF;
-  border-top: 1px solid #FFFFFF;
-}
-.specialBranchDataCell {
-  background: #ECECEC;
-  border: 1px solid white;
-  font-style: italic;
-  padding: 2px 6px 1px;
-}
-
-.editHeadButton {
-  float: right;
-  cursor: pointer;
-}
-
-.branchTableDeleteButton {
-  margin-top: 5px;
-}
-
-.branchTablePrevNextLinks {
-  position: relative;
-}
-.branchTablePrevNextLinks td {
-  float: left;
-  width: 5em;
-  text-align: left;
-  padding-right: 10px;
-}
-.branchTablePrevNextLinks .gwt-Hyperlink {
-  font-size: 9pt;
-  color: #2a5db0;
-}
-
-/** PluginListScreen **/
-.pluginsTable {
-}
-
-/** ProjectListScreen **/
-.projectFilterPanel {
-  margin-bottom: 10px;
-}
-.projectFilterPanel input {
-  width: 200px;
-}
-.projectFilterLabel {
-  margin-right: 5px;
-}
-.projectNameColumn {
-  min-width: 300px;
-}
-
-.queryIcon {
-  position: relative;
-  top: 2px;
-  margin-right: 3px;
-}
-
-/** ProjectSettings */
-.maxObjectSizeLimitEffectiveLabel {
-  padding-top: 5px;
-  padding-left: 5px;
-}
-
-.pluginProjectConfigInheritedValue {
-  padding-top: 5px;
-  padding-left: 5px;
-}
-
-/* StringListPanel */
-.stringListPanelButtons {
-  margin-left: 0.5em;
-}
-.stringListPanelButtons .gwt-Button {
-  margin-right: 2em;
-  font-size: 7pt;
-  padding: 1px;
-}
-
-/* List Screens */
-.pagingLink {
-  font-size: 18px;
-  margin-top: 5px;
-  margin-bottom: 15px;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
deleted file mode 100644
index 01c4d26..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
+++ /dev/null
@@ -1,270 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.groups;
-
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-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.Set;
-
-/** A collection of static methods which work on the Gerrit REST API for specific groups. */
-public class GroupApi {
-  /** Create a new group */
-  public static void createGroup(String groupName, AsyncCallback<GroupInfo> cb) {
-    JavaScriptObject in = JavaScriptObject.createObject();
-    new RestApi("/groups/").id(groupName).ifNoneMatch().put(in, cb);
-  }
-
-  public static void getGroupDetail(String group, AsyncCallback<GroupInfo> cb) {
-    group(group).view("detail").get(cb);
-  }
-
-  /** Get the name of a group */
-  public static void getGroupName(AccountGroup.UUID group, AsyncCallback<NativeString> cb) {
-    group(group).view("name").get(cb);
-  }
-
-  /** Check if the current user is owner of a group */
-  public static void isGroupOwner(String groupName, AsyncCallback<Boolean> cb) {
-    GroupMap.myOwned(
-        groupName,
-        new AsyncCallback<GroupMap>() {
-          @Override
-          public void onSuccess(GroupMap result) {
-            cb.onSuccess(!result.isEmpty());
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            cb.onFailure(caught);
-          }
-        });
-  }
-
-  /** Rename a group */
-  public static void renameGroup(
-      AccountGroup.UUID group, String newName, AsyncCallback<VoidResult> cb) {
-    GroupInput in = GroupInput.create();
-    in.name(newName);
-    group(group).view("name").put(in, cb);
-  }
-
-  /** Set description for a group */
-  public static void setGroupDescription(
-      AccountGroup.UUID group, String description, AsyncCallback<VoidResult> cb) {
-    RestApi call = group(group).view("description");
-    if (description != null && !description.isEmpty()) {
-      GroupInput in = GroupInput.create();
-      in.description(description);
-      call.put(in, cb);
-    } else {
-      call.delete(cb);
-    }
-  }
-
-  /** Set owner for a group */
-  public static void setGroupOwner(
-      AccountGroup.UUID group, String owner, AsyncCallback<GroupInfo> cb) {
-    GroupInput in = GroupInput.create();
-    in.owner(owner);
-    group(group).view("owner").put(in, cb);
-  }
-
-  /** Set the options for a group */
-  public static void setGroupOptions(
-      AccountGroup.UUID group, boolean isVisibleToAll, AsyncCallback<VoidResult> cb) {
-    GroupOptionsInput in = GroupOptionsInput.create();
-    in.visibleToAll(isVisibleToAll);
-    group(group).view("options").put(in, cb);
-  }
-
-  /** Add member to a group. */
-  public static void addMember(
-      AccountGroup.UUID group, String member, AsyncCallback<AccountInfo> cb) {
-    members(group).id(member).put(cb);
-  }
-
-  /** Add members to a group. */
-  public static void addMembers(
-      AccountGroup.UUID group, Set<String> members, AsyncCallback<JsArray<AccountInfo>> cb) {
-    if (members.size() == 1) {
-      addMember(
-          group,
-          members.iterator().next(),
-          new AsyncCallback<AccountInfo>() {
-            @Override
-            public void onSuccess(AccountInfo result) {
-              cb.onSuccess(Natives.arrayOf(result));
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              cb.onFailure(caught);
-            }
-          });
-    } else {
-      MemberInput input = MemberInput.create();
-      for (String member : members) {
-        input.addMember(member);
-      }
-      members(group).post(input, cb);
-    }
-  }
-
-  /** Remove members from a group. */
-  public static void removeMembers(
-      AccountGroup.UUID group, Set<Integer> ids, AsyncCallback<VoidResult> cb) {
-    if (ids.size() == 1) {
-      members(group).id(ids.iterator().next().toString()).delete(cb);
-    } else {
-      MemberInput in = MemberInput.create();
-      for (Integer id : ids) {
-        in.addMember(id.toString());
-      }
-      group(group).view("members.delete").post(in, cb);
-    }
-  }
-
-  /** Include a group into a group. */
-  public static void addIncludedGroup(
-      AccountGroup.UUID group, String include, AsyncCallback<GroupInfo> cb) {
-    groups(group).id(include).put(cb);
-  }
-
-  /** Include groups into a group. */
-  public static void addIncludedGroups(
-      AccountGroup.UUID group,
-      Set<String> includedGroups,
-      final AsyncCallback<JsArray<GroupInfo>> cb) {
-    if (includedGroups.size() == 1) {
-      addIncludedGroup(
-          group,
-          includedGroups.iterator().next(),
-          new AsyncCallback<GroupInfo>() {
-            @Override
-            public void onSuccess(GroupInfo result) {
-              cb.onSuccess(Natives.arrayOf(result));
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              cb.onFailure(caught);
-            }
-          });
-    } else {
-      IncludedGroupInput input = IncludedGroupInput.create();
-      for (String includedGroup : includedGroups) {
-        input.addGroup(includedGroup);
-      }
-      groups(group).post(input, cb);
-    }
-  }
-
-  /** Remove included groups from a group. */
-  public static void removeIncludedGroups(
-      AccountGroup.UUID group, Set<AccountGroup.UUID> ids, AsyncCallback<VoidResult> cb) {
-    if (ids.size() == 1) {
-      AccountGroup.UUID g = ids.iterator().next();
-      groups(group).id(g.get()).delete(cb);
-    } else {
-      IncludedGroupInput in = IncludedGroupInput.create();
-      for (AccountGroup.UUID g : ids) {
-        in.addGroup(g.get());
-      }
-      group(group).view("groups.delete").post(in, cb);
-    }
-  }
-
-  /** Get audit log of a group. */
-  public static void getAuditLog(
-      AccountGroup.UUID group, AsyncCallback<JsArray<GroupAuditEventInfo>> cb) {
-    group(group).view("log.audit").get(cb);
-  }
-
-  private static RestApi members(AccountGroup.UUID group) {
-    return group(group).view("members");
-  }
-
-  private static RestApi groups(AccountGroup.UUID group) {
-    return group(group).view("groups");
-  }
-
-  private static RestApi group(AccountGroup.UUID group) {
-    return group(group.get());
-  }
-
-  private static RestApi group(String group) {
-    return new RestApi("/groups/").id(group);
-  }
-
-  private static class GroupInput extends JavaScriptObject {
-    final native void description(String d) /*-{ if(d)this.description=d; }-*/;
-
-    final native void name(String n) /*-{ if(n)this.name=n; }-*/;
-
-    final native void owner(String o) /*-{ if(o)this.owner=o; }-*/;
-
-    static GroupInput create() {
-      return (GroupInput) createObject();
-    }
-
-    protected GroupInput() {}
-  }
-
-  private static class GroupOptionsInput extends JavaScriptObject {
-    final native void visibleToAll(boolean v) /*-{ if(v)this.visible_to_all=v; }-*/;
-
-    static GroupOptionsInput create() {
-      return (GroupOptionsInput) createObject();
-    }
-
-    protected GroupOptionsInput() {}
-  }
-
-  private static class MemberInput extends JavaScriptObject {
-    final native void init() /*-{ this.members = []; }-*/;
-
-    final native void addMember(String n) /*-{ this.members.push(n); }-*/;
-
-    static MemberInput create() {
-      MemberInput m = (MemberInput) createObject();
-      m.init();
-      return m;
-    }
-
-    protected MemberInput() {}
-  }
-
-  private static class IncludedGroupInput extends JavaScriptObject {
-    final native void init() /*-{ this.groups = []; }-*/;
-
-    final native void addGroup(String n) /*-{ this.groups.push(n); }-*/;
-
-    static IncludedGroupInput create() {
-      IncludedGroupInput g = (IncludedGroupInput) createObject();
-      g.init();
-      return g;
-    }
-
-    protected IncludedGroupInput() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
deleted file mode 100644
index 255c6e8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.groups;
-
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
-import java.sql.Timestamp;
-
-public class GroupAuditEventInfo extends JavaScriptObject {
-  public enum Type {
-    ADD_USER,
-    REMOVE_USER,
-    ADD_GROUP,
-    REMOVE_GROUP
-  }
-
-  public final Timestamp date() {
-    return JavaSqlTimestamp_JsonSerializer.parseTimestamp(dateRaw());
-  }
-
-  public final Type type() {
-    return Type.valueOf(typeRaw());
-  }
-
-  public final native AccountInfo user() /*-{ return this.user; }-*/;
-
-  public final native AccountInfo memberAsUser() /*-{ return this.member; }-*/;
-
-  public final native GroupInfo memberAsGroup() /*-{ return this.member; }-*/;
-
-  private native String dateRaw() /*-{ return this.date; }-*/;
-
-  private native String typeRaw() /*-{ return this.type; }-*/;
-
-  protected GroupAuditEventInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java
deleted file mode 100644
index db966b1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.groups;
-
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** Groups available from {@code /groups/} or {@code /accounts/[id]/groups}. */
-public class GroupList extends JsArray<GroupInfo> {
-  public static void my(AsyncCallback<GroupList> callback) {
-    new RestApi("/accounts/self/groups").get(callback);
-  }
-
-  public static void included(AccountGroup.UUID group, AsyncCallback<GroupList> callback) {
-    new RestApi("/groups/").id(group.get()).view("groups").get(callback);
-  }
-
-  protected GroupList() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
deleted file mode 100644
index 73ac183..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.groups;
-
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** Groups available from {@code /groups/}. */
-public class GroupMap extends NativeMap<GroupInfo> {
-  public static void all(AsyncCallback<GroupMap> callback) {
-    groups().get(NativeMap.copyKeysIntoChildren(callback));
-  }
-
-  public static void match(String match, int limit, int start, AsyncCallback<GroupMap> cb) {
-    RestApi call = groups();
-    if (match != null) {
-      if (match.startsWith("^")) {
-        call.addParameter("r", match);
-      } else {
-        call.addParameter("m", match);
-      }
-    }
-    if (limit > 0) {
-      call.addParameter("n", limit);
-    }
-    if (start > 0) {
-      call.addParameter("S", start);
-    }
-    call.get(NativeMap.copyKeysIntoChildren(cb));
-  }
-
-  public static void suggestAccountGroupForProject(
-      String project, String query, int limit, AsyncCallback<GroupMap> cb) {
-    RestApi call = groups();
-    if (project != null) {
-      call.addParameter("p", project);
-    }
-    if (query != null) {
-      call.addParameter("s", query);
-    }
-    if (limit > 0) {
-      call.addParameter("n", limit);
-    }
-    call.get(NativeMap.copyKeysIntoChildren(cb));
-  }
-
-  public static void myOwned(AsyncCallback<GroupMap> cb) {
-    myOwnedGroups().get(NativeMap.copyKeysIntoChildren(cb));
-  }
-
-  public static void myOwned(String groupName, AsyncCallback<GroupMap> cb) {
-    myOwnedGroups().addParameter("g", groupName).get(NativeMap.copyKeysIntoChildren(cb));
-  }
-
-  private static RestApi myOwnedGroups() {
-    return groups().addParameterTrue("owned");
-  }
-
-  private static RestApi groups() {
-    return new RestApi("/groups/");
-  }
-
-  protected GroupMap() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gwt_override.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gwt_override.css
deleted file mode 100644
index c92117c..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gwt_override.css
+++ /dev/null
@@ -1,88 +0,0 @@
-/* Copyright (C) 2009 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-@external .gwt-Button;
-@external .gwt-DialogBox .dialogMiddleCenter;
-@external .gwt-TabBar;
-@external .gwt-TabBarFirst;
-@external .gwt-TabBarItem;
-@external .gwt-TabBarItem-selected;
-@external .gwt-TabBarRest;
-@external .gwt-TabPanel;
-@external .gwt-TabPanelBottom;
-
-@eval backgroundColor com.google.gerrit.client.Gerrit.getTheme().backgroundColor;
-@eval textColor com.google.gerrit.client.Gerrit.getTheme().textColor;
-@eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
-@eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-
-body {
-  background: backgroundColor;
-  color: textColor;
-}
-
-.gwt-DialogBox .dialogMiddleCenter {
-  background: backgroundColor;
-  color: textColor;
-}
-
-.gwt-Button {
-  white-space: nowrap;
-}
-
-.gwt-TabBar .gwt-TabBarItem,
-.gwt-TabBar .gwt-TabBarRest,
-.gwt-TabPanelBottom {
-  background: transparent;
-}
-
-.gwt-TabBar {
-  border-bottom: 1px solid black;
-}
-.gwt-TabBar .gwt-TabBarFirst {
-  display: none;
-}
-.gwt-TabBar .gwt-TabBarItem {
-  color: #353535;
-  margin: 0;
-  background: trimColor;
-  padding-top: 0.5em;
-  padding-bottom: 1px;
-  padding-left: 1em;
-  padding-right: 1em;
-  border-bottom: 3px solid transparent;
-  border-right: 0;
-}
-.gwt-TabBar .gwt-TabBarItem-selected {
-  color: #990000;
-  background: selectionColor;
-  border-bottom-color: #990000;
-}
-.gwt-TabBar .gwt-TabBarRest {
-  background: trimColor;
-  padding-top: 0.5em;
-  padding-bottom: 1px;
-}
-.gwt-TabBar .gwt-TabPanelBottom {
-  background: trimColor;
-  border-top: 1px solid black;
-  border-left: none;
-  border-right: none;
-  border-bottom: none;
-  padding: 1px;
-}
-.gwt-TabPanel .gwt-TabPanelBottom {
-  border: none;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java
deleted file mode 100644
index 4c4c8da..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.patches;
-
-import com.google.gwt.i18n.client.Constants;
-
-public interface PatchConstants extends Constants {
-  String patchBase();
-
-  String patchSet();
-
-  String upToChange();
-
-  String openReply();
-
-  String linePrev();
-
-  String lineNext();
-
-  String chunkPrev();
-
-  String chunkNext();
-
-  String commentPrev();
-
-  String commentNext();
-
-  String focusSideA();
-
-  String focusSideB();
-
-  String expandComment();
-
-  String expandAllCommentsOnCurrentLine();
-
-  String toggleSideA();
-
-  String toggleIntraline();
-
-  String showPreferences();
-
-  String toggleReviewed();
-
-  String markAsReviewedAndGoToNext();
-
-  String commentEditorSet();
-
-  String commentInsert();
-
-  String commentSaveDraft();
-
-  String commentCancelEdit();
-
-  String whitespaceIGNORE_NONE();
-
-  String whitespaceIGNORE_TRAILING();
-
-  String whitespaceIGNORE_LEADING_AND_TRAILING();
-
-  String whitespaceIGNORE_ALL();
-
-  String previousFileHelp();
-
-  String nextFileHelp();
-
-  String download();
-
-  String edit();
-
-  String blame();
-
-  String addFileCommentToolTip();
-
-  String cannedReplyDone();
-
-  String sideBySideDiff();
-
-  String unifiedDiff();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.properties
deleted file mode 100644
index 13f0afa..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.properties
+++ /dev/null
@@ -1,44 +0,0 @@
-cannedReplyDone = Done
-
-patchBase = Base
-patchSet = Patch Set
-
-upToChange = Up to change
-openReply = Reply and score
-linePrev = Previous line
-lineNext = Next line
-chunkPrev = Previous diff chunk
-chunkNext = Next diff chunk or search result
-commentPrev = Previous comment
-commentNext = Next comment
-focusSideA = Focus left side
-focusSideB = Focus right side
-expandComment = Expand or collapse comment
-expandAllCommentsOnCurrentLine = Expand or collapse all comments on current line
-toggleSideA = Toggle left side
-toggleIntraline = Toggle intraline difference
-showPreferences = Show diff preferences
-
-toggleReviewed = Toggle the reviewed flag
-markAsReviewedAndGoToNext = Mark patch as reviewed and go to next unreviewed patch
-
-commentEditorSet = Comment Editing
-commentInsert = Create a new inline comment
-commentSaveDraft = Save draft comment
-commentCancelEdit = Cancel comment edit
-
-whitespaceIGNORE_NONE=None
-whitespaceIGNORE_TRAILING=At Line End
-whitespaceIGNORE_LEADING_AND_TRAILING=Leading, At Line End
-whitespaceIGNORE_ALL=All
-
-previousFileHelp = Previous file
-nextFileHelp = Next file
-
-download = Download
-edit = Edit
-blame = Blame
-addFileCommentToolTip = Click to add file comment
-
-sideBySideDiff = Side-by-side diff
-unifiedDiff = Unified diff
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java
deleted file mode 100644
index 358ccd3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.patches;
-
-import com.google.gwt.i18n.client.Messages;
-
-public interface PatchMessages extends Messages {
-  String expandBefore(int cnt);
-
-  String expandAfter(int cnt);
-
-  String patchSkipRegion(String lineNumber);
-
-  String fileNameWithShortcutKey(String file, String key);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.properties
deleted file mode 100644
index 8dcebdc..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-expandBefore = +{0}&#x21e7;
-expandAfter = +{0}&#x21e9;
-patchSkipRegion = ... skipped {0} common lines ...
-fileNameWithShortcutKey = {0} (Shortcut: {1})
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages_en.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages_en.properties
deleted file mode 100644
index 8dcebdc..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages_en.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-expandBefore = +{0}&#x21e7;
-expandAfter = +{0}&#x21e9;
-patchSkipRegion = ... skipped {0} common lines ...
-fileNameWithShortcutKey = {0} (Shortcut: {1})
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchUtil.java
deleted file mode 100644
index d599756..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchUtil.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.patches;
-
-import com.google.gwt.core.client.GWT;
-
-public class PatchUtil {
-  public static final PatchConstants C = GWT.create(PatchConstants.class);
-  public static final PatchMessages M = GWT.create(PatchMessages.class);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/SkippedLine.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/SkippedLine.java
deleted file mode 100644
index 486e7b8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/SkippedLine.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.patches;
-
-public class SkippedLine {
-
-  private int a;
-  private int b;
-  private int sz;
-
-  public SkippedLine(int startA, int startB, int size) {
-    a = startA;
-    b = startB;
-    sz = size;
-  }
-
-  public int getStartA() {
-    return a;
-  }
-
-  public int getStartB() {
-    return b;
-  }
-
-  public int getSize() {
-    return sz;
-  }
-
-  public void incrementStart(int n) {
-    a += n;
-    b += n;
-    reduceSize(n);
-  }
-
-  public void reduceSize(int n) {
-    sz -= n;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginInfo.java
deleted file mode 100644
index ac073de..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginInfo.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.plugins;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class PluginInfo extends JavaScriptObject {
-  public final native String name() /*-{ return this.name }-*/;
-
-  public final native String version() /*-{ return this.version }-*/;
-
-  public final native String indexUrl() /*-{ return this.index_url }-*/;
-
-  public final native boolean disabled() /*-{ return this.disabled || false }-*/;
-
-  protected PluginInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginMap.java
deleted file mode 100644
index cea27b9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginMap.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.plugins;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** Plugins available from {@code /plugins/}. */
-public class PluginMap extends NativeMap<PluginInfo> {
-  public static void all(AsyncCallback<PluginMap> callback) {
-    new RestApi("/plugins/").addParameterTrue("all").get(NativeMap.copyKeysIntoChildren(callback));
-  }
-
-  protected PluginMap() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/BranchInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/BranchInfo.java
deleted file mode 100644
index 097f26a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/BranchInfo.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.projects;
-
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gwt.core.client.JsArray;
-
-public class BranchInfo extends RefInfo {
-  public final native boolean canDelete() /*-{ return this['can_delete'] ? true : false; }-*/;
-
-  public final native NativeMap<ActionInfo> actions() /*-{ return this.actions }-*/;
-
-  public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
-
-  protected BranchInfo() {}
-}
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
deleted file mode 100644
index f670ac7..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
+++ /dev/null
@@ -1,259 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.projects;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwtexpui.safehtml.client.FindReplace;
-import com.google.gwtexpui.safehtml.client.LinkFindReplace;
-import com.google.gwtexpui.safehtml.client.RawFindReplace;
-import java.util.ArrayList;
-import java.util.List;
-
-public class ConfigInfo extends JavaScriptObject {
-
-  public final native String description() /*-{ return this.description }-*/;
-
-  public final native InheritedBooleanInfo requireChangeId()
-      /*-{ return this.require_change_id; }-*/ ;
-
-  public final native InheritedBooleanInfo useContentMerge()
-      /*-{ return this.use_content_merge; }-*/ ;
-
-  public final native InheritedBooleanInfo useContributorAgreements()
-      /*-{ return this.use_contributor_agreements; }-*/ ;
-
-  public final native InheritedBooleanInfo createNewChangeForAllNotInTarget()
-      /*-{ return this.create_new_change_for_all_not_in_target; }-*/ ;
-
-  public final native InheritedBooleanInfo useSignedOffBy()
-      /*-{ return this.use_signed_off_by; }-*/ ;
-
-  public final native InheritedBooleanInfo enableSignedPush()
-      /*-{ return this.enable_signed_push; }-*/ ;
-
-  public final native InheritedBooleanInfo requireSignedPush()
-      /*-{ return this.require_signed_push; }-*/ ;
-
-  public final native InheritedBooleanInfo rejectImplicitMerges()
-      /*-{ return this.reject_implicit_merges; }-*/ ;
-
-  public final native InheritedBooleanInfo privateByDefault()
-      /*-{ return this.private_by_default; }-*/ ;
-
-  public final native InheritedBooleanInfo enableReviewerByEmail()
-      /*-{ return this.enable_reviewer_by_email; }-*/ ;
-
-  public final native InheritedBooleanInfo matchAuthorToCommitterDate()
-      /*-{ return this.match_author_to_committer_date; }-*/ ;
-
-  public final SubmitType submitType() {
-    return SubmitType.valueOf(submitTypeRaw());
-  }
-
-  public final native SubmitTypeInfo defaultSubmitType() /*-{ return this.default_submit_type; }-*/;
-
-  public final native NativeMap<NativeMap<ConfigParameterInfo>> pluginConfig()
-      /*-{ return this.plugin_config || {}; }-*/ ;
-
-  public final native NativeMap<ConfigParameterInfo> pluginConfig(String p)
-      /*-{ return this.plugin_config[p]; }-*/ ;
-
-  public final native NativeMap<ActionInfo> actions() /*-{ return this.actions; }-*/;
-
-  private native String submitTypeRaw() /*-{ return this.submit_type }-*/;
-
-  public final ProjectState state() {
-    if (stateRaw() == null) {
-      return ProjectState.ACTIVE;
-    }
-    return ProjectState.valueOf(stateRaw());
-  }
-
-  private native String stateRaw() /*-{ return this.state }-*/;
-
-  public final native MaxObjectSizeLimitInfo maxObjectSizeLimit()
-      /*-{ return this.max_object_size_limit; }-*/ ;
-
-  private native NativeMap<CommentLinkInfo> commentlinks0() /*-{ return this.commentlinks; }-*/;
-
-  final List<FindReplace> commentlinks() {
-    JsArray<CommentLinkInfo> cls = commentlinks0().values();
-    List<FindReplace> commentLinks = new ArrayList<>(cls.length());
-    for (int i = 0; i < cls.length(); i++) {
-      CommentLinkInfo cl = cls.get(i);
-      if (!cl.enabled()) {
-        continue;
-      }
-      if (cl.link() != null) {
-        commentLinks.add(new LinkFindReplace(cl.match(), cl.link()));
-      } else {
-        try {
-          FindReplace fr = new RawFindReplace(cl.match(), cl.html());
-          commentLinks.add(fr);
-        } catch (RuntimeException e) {
-          int index = e.getMessage().indexOf("at Object");
-          new ErrorDialog(
-                  "Invalid commentlink configuration: "
-                      + (index == -1 ? e.getMessage() : e.getMessage().substring(0, index)))
-              .center();
-        }
-      }
-    }
-    return commentLinks;
-  }
-
-  final native ThemeInfo theme() /*-{ return this.theme; }-*/;
-
-  final native NativeMap<JsArrayString>
-      extensionPanelNames() /*-{ return this.extension_panel_names; }-*/;
-
-  protected ConfigInfo() {}
-
-  static class CommentLinkInfo extends JavaScriptObject {
-    final native String match() /*-{ return this.match; }-*/;
-
-    final native String link() /*-{ return this.link; }-*/;
-
-    final native String html() /*-{ return this.html; }-*/;
-
-    final native boolean enabled() /*-{
-      return !this.hasOwnProperty('enabled') || this.enabled;
-    }-*/;
-
-    protected CommentLinkInfo() {}
-  }
-
-  public static class InheritedBooleanInfo extends JavaScriptObject {
-    public static InheritedBooleanInfo create() {
-      return (InheritedBooleanInfo) createObject();
-    }
-
-    public final native boolean value() /*-{ return this.value ? true : false; }-*/;
-
-    public final native boolean inheritedValue()
-        /*-{ return this.inherited_value ? true : false; }-*/ ;
-
-    public final InheritableBoolean configuredValue() {
-      return InheritableBoolean.valueOf(configuredValueRaw());
-    }
-
-    private native String configuredValueRaw() /*-{ return this.configured_value }-*/;
-
-    public final void setConfiguredValue(InheritableBoolean v) {
-      setConfiguredValueRaw(v.name());
-    }
-
-    public final native void setConfiguredValueRaw(String v)
-        /*-{ if(v)this.configured_value=v; }-*/ ;
-
-    protected InheritedBooleanInfo() {}
-  }
-
-  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 }-*/;
-
-    protected MaxObjectSizeLimitInfo() {}
-  }
-
-  public static class ConfigParameterInfo extends JavaScriptObject {
-    public final native String name() /*-{ return this.name; }-*/;
-
-    public final native String displayName() /*-{ return this.display_name; }-*/;
-
-    public final native String description() /*-{ return this.description; }-*/;
-
-    public final native String warning() /*-{ return this.warning; }-*/;
-
-    public final native String type() /*-{ return this.type; }-*/;
-
-    public final native String value() /*-{ return this.value; }-*/;
-
-    public final native boolean editable() /*-{ return this.editable ? true : false; }-*/;
-
-    public final native boolean inheritable() /*-{ return this.inheritable ? true : false; }-*/;
-
-    public final native String configuredValue() /*-{ return this.configured_value; }-*/;
-
-    public final native String inheritedValue() /*-{ return this.inherited_value; }-*/;
-
-    public final native JsArrayString permittedValues() /*-{ return this.permitted_values; }-*/;
-
-    public final native JsArrayString values() /*-{ return this.values; }-*/;
-
-    protected ConfigParameterInfo() {}
-  }
-
-  public static class ConfigParameterValue extends JavaScriptObject {
-    final native void init() /*-{ this.values = []; }-*/;
-
-    final native void addValue(String v) /*-{ this.values.push(v); }-*/;
-
-    final native void setValue(String v) /*-{ if(v)this.value = v; }-*/;
-
-    public static ConfigParameterValue create() {
-      ConfigParameterValue v = createObject().cast();
-      return v;
-    }
-
-    public final ConfigParameterValue values(String[] values) {
-      init();
-      for (String v : values) {
-        addValue(v);
-      }
-      return this;
-    }
-
-    public final ConfigParameterValue value(String v) {
-      setValue(v);
-      return this;
-    }
-
-    protected ConfigParameterValue() {}
-  }
-
-  public static class SubmitTypeInfo extends JavaScriptObject {
-    public final SubmitType value() {
-      return SubmitType.valueOf(valueRaw());
-    }
-
-    public final SubmitType configuredValue() {
-      return SubmitType.valueOf(configuredValueRaw());
-    }
-
-    public final SubmitType inheritedValue() {
-      return SubmitType.valueOf(inheritedValueRaw());
-    }
-
-    private final native String valueRaw() /*-{ return this.value; }-*/;
-
-    private final native String configuredValueRaw() /*-{ return this.configured_value; }-*/;
-
-    private final native String inheritedValueRaw() /*-{ return this.inherited_value; }-*/;
-
-    protected SubmitTypeInfo() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
deleted file mode 100644
index 7262b3a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.projects;
-
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-/** Cache of {@link ConfigInfo} objects by project name. */
-public class ConfigInfoCache {
-  private static final int PROJECT_LIMIT = 25;
-  private static final int CHANGE_LIMIT = 100;
-  private static final ConfigInfoCache instance = GWT.create(ConfigInfoCache.class);
-
-  public static class Entry {
-    private final ConfigInfo info;
-    private CommentLinkProcessor commentLinkProcessor;
-
-    private Entry(ConfigInfo info) {
-      this.info = info;
-    }
-
-    public CommentLinkProcessor getCommentLinkProcessor() {
-      if (commentLinkProcessor == null) {
-        commentLinkProcessor = new CommentLinkProcessor(info.commentlinks());
-      }
-      return commentLinkProcessor;
-    }
-
-    public ThemeInfo getTheme() {
-      return info.theme();
-    }
-
-    public List<String> getExtensionPanelNames(String extensionPoint) {
-      return Natives.asList(info.extensionPanelNames().get(extensionPoint));
-    }
-  }
-
-  public static void get(Project.NameKey name, AsyncCallback<Entry> cb) {
-    instance.getImpl(name.get(), cb);
-  }
-
-  public static void get(Change.Id changeId, AsyncCallback<Entry> cb) {
-    instance.getImpl(changeId.get(), cb);
-  }
-
-  public static void add(ChangeInfo info) {
-    instance.changeToProject.put(info.legacyId().get(), info.project());
-  }
-
-  private final LinkedHashMap<String, Entry> cache;
-  private final LinkedHashMap<Integer, String> changeToProject;
-
-  protected ConfigInfoCache() {
-    cache =
-        new LinkedHashMap<String, Entry>(PROJECT_LIMIT) {
-          private static final long serialVersionUID = 1L;
-
-          @Override
-          protected boolean removeEldestEntry(Map.Entry<String, ConfigInfoCache.Entry> e) {
-            return size() > PROJECT_LIMIT;
-          }
-        };
-
-    changeToProject =
-        new LinkedHashMap<Integer, String>(CHANGE_LIMIT) {
-          private static final long serialVersionUID = 1L;
-
-          @Override
-          protected boolean removeEldestEntry(Map.Entry<Integer, String> e) {
-            return size() > CHANGE_LIMIT;
-          }
-        };
-  }
-
-  private void getImpl(String name, AsyncCallback<Entry> cb) {
-    Entry e = cache.get(name);
-    if (e != null) {
-      cb.onSuccess(e);
-      return;
-    }
-    ProjectApi.getConfig(
-        new Project.NameKey(name),
-        new AsyncCallback<ConfigInfo>() {
-          @Override
-          public void onSuccess(ConfigInfo result) {
-            Entry e = new Entry(result);
-            cache.put(name, e);
-            cb.onSuccess(e);
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            cb.onFailure(caught);
-          }
-        });
-  }
-
-  private void getImpl(Integer id, AsyncCallback<Entry> cb) {
-    String name = changeToProject.get(id);
-    if (name != null) {
-      getImpl(name, cb);
-      return;
-    }
-    // TODO(hiesel) Make a preflight request to get project before we deprecate the numeric changeId
-    ChangeApi.change(null, id)
-        .get(
-            new AsyncCallback<ChangeInfo>() {
-              @Override
-              public void onSuccess(ChangeInfo result) {
-                changeToProject.put(id, result.project());
-                getImpl(result.project(), cb);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                cb.onFailure(caught);
-              }
-            });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
deleted file mode 100644
index 66afdb2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
+++ /dev/null
@@ -1,452 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.projects;
-
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.projects.ConfigInfo.ConfigParameterValue;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project;
-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.Map;
-import java.util.Map.Entry;
-import java.util.Set;
-
-public class ProjectApi {
-  /** Create a new project */
-  public static void createProject(
-      String projectName,
-      String parent,
-      Boolean createEmptyCcommit,
-      Boolean permissionsOnly,
-      AsyncCallback<VoidResult> cb) {
-    ProjectInput input = ProjectInput.create();
-    input.setName(projectName);
-    input.setParent(parent);
-    input.setPermissionsOnly(permissionsOnly);
-    input.setCreateEmptyCommit(createEmptyCcommit);
-    new RestApi("/projects/").id(projectName).ifNoneMatch().put(input, cb);
-  }
-
-  private static RestApi getRestApi(
-      Project.NameKey name, String viewName, int limit, int start, String match) {
-    RestApi call = project(name).view(viewName);
-    call.addParameter("n", limit);
-    call.addParameter("S", start);
-    if (match != null) {
-      if (match.startsWith("^")) {
-        call.addParameter("r", match);
-      } else {
-        call.addParameter("m", match);
-      }
-    }
-    return call;
-  }
-
-  /** Create a new tag */
-  public static void createTag(
-      Project.NameKey name,
-      String ref,
-      String revision,
-      String annotation,
-      AsyncCallback<TagInfo> cb) {
-    TagInput input = TagInput.create();
-    input.setRevision(revision);
-    input.setMessage(annotation);
-    project(name).view("tags").id(ref).ifNoneMatch().put(input, cb);
-  }
-
-  /** Retrieve all visible tags of the project */
-  public static void getTags(Project.NameKey name, AsyncCallback<JsArray<TagInfo>> cb) {
-    project(name).view("tags").get(cb);
-  }
-
-  public static void getTags(
-      Project.NameKey name,
-      int limit,
-      int start,
-      String match,
-      AsyncCallback<JsArray<TagInfo>> cb) {
-    getRestApi(name, "tags", limit, start, match).get(cb);
-  }
-
-  /** Delete tags. One call is fired to the server to delete all the tags. */
-  public static void deleteTags(
-      Project.NameKey name, Set<String> refs, AsyncCallback<VoidResult> cb) {
-    if (refs.size() == 1) {
-      project(name).view("tags").id(refs.iterator().next()).delete(cb);
-    } else {
-      DeleteTagsInput d = DeleteTagsInput.create();
-      for (String ref : refs) {
-        d.addTag(ref);
-      }
-      project(name).view("tags:delete").post(d, cb);
-    }
-  }
-
-  /** Create a new branch */
-  public static void createBranch(
-      Project.NameKey name, String ref, String revision, AsyncCallback<BranchInfo> cb) {
-    BranchInput input = BranchInput.create();
-    input.setRevision(revision);
-    project(name).view("branches").id(ref).ifNoneMatch().put(input, cb);
-  }
-
-  /** Retrieve all visible branches of the project */
-  public static void getBranches(Project.NameKey name, AsyncCallback<JsArray<BranchInfo>> cb) {
-    project(name).view("branches").get(cb);
-  }
-
-  public static void getBranches(
-      Project.NameKey name,
-      int limit,
-      int start,
-      String match,
-      AsyncCallback<JsArray<BranchInfo>> cb) {
-    getRestApi(name, "branches", limit, start, match).get(cb);
-  }
-
-  /** Delete branches. One call is fired to the server to delete all the branches. */
-  public static void deleteBranches(
-      Project.NameKey name, Set<String> refs, AsyncCallback<VoidResult> cb) {
-    if (refs.size() == 1) {
-      project(name).view("branches").id(refs.iterator().next()).delete(cb);
-    } else {
-      DeleteBranchesInput d = DeleteBranchesInput.create();
-      for (String ref : refs) {
-        d.addBranch(ref);
-      }
-      project(name).view("branches:delete").post(d, cb);
-    }
-  }
-
-  public static void getConfig(Project.NameKey name, AsyncCallback<ConfigInfo> cb) {
-    project(name).view("config").get(cb);
-  }
-
-  public static void setConfig(
-      Project.NameKey name,
-      String description,
-      InheritableBoolean useContributorAgreements,
-      InheritableBoolean useContentMerge,
-      InheritableBoolean useSignedOffBy,
-      InheritableBoolean createNewChangeForAllNotInTarget,
-      InheritableBoolean requireChangeId,
-      InheritableBoolean enableSignedPush,
-      InheritableBoolean requireSignedPush,
-      InheritableBoolean rejectImplicitMerges,
-      InheritableBoolean privateByDefault,
-      InheritableBoolean enableReviewerByEmail,
-      InheritableBoolean matchAuthorToCommitterDate,
-      String maxObjectSizeLimit,
-      SubmitType submitType,
-      ProjectState state,
-      Map<String, Map<String, ConfigParameterValue>> pluginConfigValues,
-      AsyncCallback<ConfigInfo> cb) {
-    ConfigInput in = ConfigInput.create();
-    in.setDescription(description);
-    in.setUseContributorAgreements(useContributorAgreements);
-    in.setUseContentMerge(useContentMerge);
-    in.setUseSignedOffBy(useSignedOffBy);
-    in.setRequireChangeId(requireChangeId);
-    in.setCreateNewChangeForAllNotInTarget(createNewChangeForAllNotInTarget);
-    if (enableSignedPush != null) {
-      in.setEnableSignedPush(enableSignedPush);
-    }
-    if (requireSignedPush != null) {
-      in.setRequireSignedPush(requireSignedPush);
-    }
-    in.setRejectImplicitMerges(rejectImplicitMerges);
-    in.setPrivateByDefault(privateByDefault);
-    in.setMaxObjectSizeLimit(maxObjectSizeLimit);
-    if (submitType != null) {
-      in.setSubmitType(submitType);
-    }
-    in.setState(state);
-    in.setPluginConfigValues(pluginConfigValues);
-    in.setEnableReviewerByEmail(enableReviewerByEmail);
-    in.setMatchAuthorToCommitterDate(matchAuthorToCommitterDate);
-
-    project(name).view("config").put(in, cb);
-  }
-
-  public static void getParent(Project.NameKey name, AsyncCallback<Project.NameKey> cb) {
-    project(name)
-        .view("parent")
-        .get(
-            new AsyncCallback<NativeString>() {
-              @Override
-              public void onSuccess(NativeString result) {
-                cb.onSuccess(new Project.NameKey(result.asString()));
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                cb.onFailure(caught);
-              }
-            });
-  }
-
-  public static void getChildren(
-      Project.NameKey name, boolean recursive, AsyncCallback<JsArray<ProjectInfo>> cb) {
-    RestApi view = project(name).view("children");
-    if (recursive) {
-      view.addParameterTrue("recursive");
-    }
-    view.get(cb);
-  }
-
-  public static void getDescription(Project.NameKey name, AsyncCallback<NativeString> cb) {
-    project(name).view("description").get(cb);
-  }
-
-  public static void setDescription(
-      Project.NameKey name, String description, AsyncCallback<NativeString> cb) {
-    RestApi call = project(name).view("description");
-    if (description != null && !description.isEmpty()) {
-      DescriptionInput input = DescriptionInput.create();
-      input.setDescription(description);
-      call.put(input, cb);
-    } else {
-      call.delete(cb);
-    }
-  }
-
-  public static void setHead(Project.NameKey name, String ref, AsyncCallback<NativeString> cb) {
-    RestApi call = project(name).view("HEAD");
-    HeadInput input = HeadInput.create();
-    input.setRef(ref);
-    call.put(input, cb);
-  }
-
-  public static RestApi project(Project.NameKey name) {
-    return new RestApi("/projects/").id(name.get());
-  }
-
-  private static class ProjectInput extends JavaScriptObject {
-    static ProjectInput create() {
-      return (ProjectInput) createObject();
-    }
-
-    protected ProjectInput() {}
-
-    final native void setName(String n) /*-{ if(n)this.name=n; }-*/;
-
-    final native void setParent(String p) /*-{ if(p)this.parent=p; }-*/;
-
-    final native void setPermissionsOnly(boolean po) /*-{ if(po)this.permissions_only=po; }-*/;
-
-    final native void setCreateEmptyCommit(boolean cc) /*-{ if(cc)this.create_empty_commit=cc; }-*/;
-  }
-
-  private static class ConfigInput extends JavaScriptObject {
-    static ConfigInput create() {
-      return (ConfigInput) createObject();
-    }
-
-    protected ConfigInput() {}
-
-    final native void setDescription(String d) /*-{ if(d)this.description=d; }-*/;
-
-    final void setUseContributorAgreements(InheritableBoolean v) {
-      setUseContributorAgreementsRaw(v.name());
-    }
-
-    private native void setUseContributorAgreementsRaw(String v)
-        /*-{ if(v)this.use_contributor_agreements=v; }-*/ ;
-
-    final void setUseContentMerge(InheritableBoolean v) {
-      setUseContentMergeRaw(v.name());
-    }
-
-    private native void setUseContentMergeRaw(String v) /*-{ if(v)this.use_content_merge=v; }-*/;
-
-    final void setUseSignedOffBy(InheritableBoolean v) {
-      setUseSignedOffByRaw(v.name());
-    }
-
-    private native void setUseSignedOffByRaw(String v) /*-{ if(v)this.use_signed_off_by=v; }-*/;
-
-    final void setRequireChangeId(InheritableBoolean v) {
-      setRequireChangeIdRaw(v.name());
-    }
-
-    private native void setRequireChangeIdRaw(String v) /*-{ if(v)this.require_change_id=v; }-*/;
-
-    final void setCreateNewChangeForAllNotInTarget(InheritableBoolean v) {
-      setCreateNewChangeForAllNotInTargetRaw(v.name());
-    }
-
-    private native void setCreateNewChangeForAllNotInTargetRaw(String v)
-        /*-{ if(v)this.create_new_change_for_all_not_in_target=v; }-*/ ;
-
-    final void setEnableSignedPush(InheritableBoolean v) {
-      setEnableSignedPushRaw(v.name());
-    }
-
-    private native void setEnableSignedPushRaw(String v) /*-{ if(v)this.enable_signed_push=v; }-*/;
-
-    final void setRequireSignedPush(InheritableBoolean v) {
-      setRequireSignedPushRaw(v.name());
-    }
-
-    final void setPrivateByDefault(InheritableBoolean v) {
-      setPrivateByDefault(v.name());
-    }
-
-    private native void setPrivateByDefault(String v) /*-{ if(v)this.private_by_default=v; }-*/;
-
-    final void setEnableReviewerByEmail(InheritableBoolean v) {
-      setEnableReviewerByEmailRaw(v.name());
-    }
-
-    final void setMatchAuthorToCommitterDate(InheritableBoolean v) {
-      setMatchAuthorToCommitterDateRaw(v.name());
-    }
-
-    private native void setMatchAuthorToCommitterDateRaw(String v)
-        /*-{ if(v)this.match_author_to_committer_date=v; }-*/ ;
-
-    private native void setEnableReviewerByEmailRaw(String v)
-        /*-{ if(v)this.enable_reviewer_by_email=v; }-*/ ;
-
-    private native void setRequireSignedPushRaw(String v)
-        /*-{ if(v)this.require_signed_push=v; }-*/ ;
-
-    final void setRejectImplicitMerges(InheritableBoolean v) {
-      setRejectImplicitMergesRaw(v.name());
-    }
-
-    private native void setRejectImplicitMergesRaw(String v)
-        /*-{ if(v)this.reject_implicit_merges=v; }-*/ ;
-
-    final native void setMaxObjectSizeLimit(String l) /*-{ if(l)this.max_object_size_limit=l; }-*/;
-
-    final void setSubmitType(SubmitType t) {
-      setSubmitTypeRaw(t.name());
-    }
-
-    private native void setSubmitTypeRaw(String t) /*-{ if(t)this.submit_type=t; }-*/;
-
-    final void setState(ProjectState s) {
-      setStateRaw(s.name());
-    }
-
-    private native void setStateRaw(String s) /*-{ if(s)this.state=s; }-*/;
-
-    final void setPluginConfigValues(
-        Map<String, Map<String, ConfigParameterValue>> pluginConfigValues) {
-      if (!pluginConfigValues.isEmpty()) {
-        NativeMap<ConfigParameterValueMap> configValues = NativeMap.create().cast();
-        for (Entry<String, Map<String, ConfigParameterValue>> e : pluginConfigValues.entrySet()) {
-          ConfigParameterValueMap values = ConfigParameterValueMap.create();
-          configValues.put(e.getKey(), values);
-          for (Entry<String, ConfigParameterValue> e2 : e.getValue().entrySet()) {
-            values.put(e2.getKey(), e2.getValue());
-          }
-        }
-        setPluginConfigValuesRaw(configValues);
-      }
-    }
-
-    private native void setPluginConfigValuesRaw(NativeMap<ConfigParameterValueMap> v)
-        /*-{ this.plugin_config_values=v; }-*/ ;
-  }
-
-  private static class ConfigParameterValueMap extends JavaScriptObject {
-    static ConfigParameterValueMap create() {
-      return createObject().cast();
-    }
-
-    protected ConfigParameterValueMap() {}
-
-    public final native void put(String n, ConfigParameterValue v) /*-{ this[n] = v; }-*/;
-  }
-
-  private static class TagInput extends JavaScriptObject {
-    static TagInput create() {
-      return (TagInput) createObject();
-    }
-
-    protected TagInput() {}
-
-    final native void setRevision(String r) /*-{ if(r)this.revision=r; }-*/;
-
-    final native void setMessage(String m) /*-{ if(m)this.message=m; }-*/;
-  }
-
-  private static class BranchInput extends JavaScriptObject {
-    static BranchInput create() {
-      return (BranchInput) createObject();
-    }
-
-    protected BranchInput() {}
-
-    final native void setRevision(String r) /*-{ if(r)this.revision=r; }-*/;
-  }
-
-  private static class DescriptionInput extends JavaScriptObject {
-    static DescriptionInput create() {
-      return (DescriptionInput) createObject();
-    }
-
-    protected DescriptionInput() {}
-
-    final native void setDescription(String d) /*-{ if(d)this.description=d; }-*/;
-  }
-
-  private static class HeadInput extends JavaScriptObject {
-    static HeadInput create() {
-      return createObject().cast();
-    }
-
-    protected HeadInput() {}
-
-    final native void setRef(String r) /*-{ if(r)this.ref=r; }-*/;
-  }
-
-  private static class DeleteTagsInput extends JavaScriptObject {
-    static DeleteTagsInput create() {
-      DeleteTagsInput d = createObject().cast();
-      d.init();
-      return d;
-    }
-
-    protected DeleteTagsInput() {}
-
-    final native void init() /*-{ this.tags = []; }-*/;
-
-    final native void addTag(String b) /*-{ this.tags.push(b); }-*/;
-  }
-
-  private static class DeleteBranchesInput extends JavaScriptObject {
-    static DeleteBranchesInput create() {
-      DeleteBranchesInput d = createObject().cast();
-      d.init();
-      return d;
-    }
-
-    protected DeleteBranchesInput() {}
-
-    final native void init() /*-{ this.branches = []; }-*/;
-
-    final native void addBranch(String b) /*-{ this.branches.push(b); }-*/;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java
deleted file mode 100644
index 1ff568f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.projects;
-
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.ui.SuggestOracle;
-
-public class ProjectInfo extends JavaScriptObject implements SuggestOracle.Suggestion {
-  public final Project.NameKey name_key() {
-    return new Project.NameKey(name());
-  }
-
-  public final native String name() /*-{ return this.name; }-*/;
-
-  public final native String description() /*-{ return this.description; }-*/;
-
-  public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
-
-  public final ProjectState state() {
-    return ProjectState.valueOf(getStringState());
-  }
-
-  private native String getStringState() /*-{ return this.state; }-*/;
-
-  @Override
-  public final String getDisplayString() {
-    if (description() != null) {
-      return name() + " (" + description() + ")";
-    }
-    return name();
-  }
-
-  @Override
-  public final String getReplacementString() {
-    return name();
-  }
-
-  protected ProjectInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
deleted file mode 100644
index 5ff300d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.projects;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-/** Projects available from {@code /projects/}. */
-public class ProjectMap extends NativeMap<ProjectInfo> {
-  public static void all(AsyncCallback<ProjectMap> callback) {
-    new RestApi("/projects/")
-        .addParameterRaw("type", "ALL")
-        .addParameterTrue("all")
-        .addParameterTrue("d") // description
-        .get(NativeMap.copyKeysIntoChildren(callback));
-  }
-
-  public static void permissions(AsyncCallback<ProjectMap> callback) {
-    new RestApi("/projects/")
-        .addParameterRaw("type", "PERMISSIONS")
-        .addParameterTrue("all")
-        .addParameterTrue("d") // description
-        .get(NativeMap.copyKeysIntoChildren(callback));
-  }
-
-  public static void parentCandidates(AsyncCallback<ProjectMap> callback) {
-    new RestApi("/projects/")
-        .addParameterRaw("type", "PARENT_CANDIDATES")
-        .addParameterTrue("all")
-        .addParameterTrue("d") // description
-        .get(NativeMap.copyKeysIntoChildren(callback));
-  }
-
-  public static void suggest(String match, int limit, AsyncCallback<ProjectMap> cb) {
-    new RestApi("/projects/")
-        .addParameter("m", match)
-        .addParameter("n", limit)
-        .addParameterRaw("type", "ALL")
-        .addParameterTrue("d") // description
-        .background()
-        .get(NativeMap.copyKeysIntoChildren(cb));
-  }
-
-  public static void match(String match, int limit, int start, AsyncCallback<ProjectMap> cb) {
-    RestApi call = new RestApi("/projects/");
-    if (match != null) {
-      if (match.startsWith("^")) {
-        call.addParameter("r", match);
-      } else {
-        call.addParameter("m", match);
-      }
-    }
-    if (limit > 0) {
-      call.addParameter("n", limit);
-    }
-    if (start > 0) {
-      call.addParameter("S", start);
-    }
-    call.addParameterRaw("type", "ALL");
-    call.addParameterTrue("d"); // description
-    call.get(NativeMap.copyKeysIntoChildren(cb));
-  }
-
-  public static void match(String match, AsyncCallback<ProjectMap> cb) {
-    match(match, 0, 0, cb);
-  }
-
-  protected ProjectMap() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java
deleted file mode 100644
index 90c862f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.projects;
-
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class RefInfo extends JavaScriptObject {
-  public final String getShortName() {
-    return RefNames.shortName(ref());
-  }
-
-  public final native String ref() /*-{ return this.ref; }-*/;
-
-  public final native String revision() /*-{ return this.revision; }-*/;
-
-  protected RefInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/TagInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/TagInfo.java
deleted file mode 100644
index fc13fe1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/TagInfo.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.projects;
-
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gwt.core.client.JsArray;
-
-public class TagInfo extends RefInfo {
-  public final native boolean canDelete() /*-{ return this['can_delete'] ? true : false; }-*/;
-
-  public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
-
-  // TODO(dpursehouse) add extra tag-related fields (message, tagger, etc)
-  protected TagInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ThemeInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ThemeInfo.java
deleted file mode 100644
index 7584e14..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ThemeInfo.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.projects;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class ThemeInfo extends JavaScriptObject {
-  public final native String css() /*-{ return this.css; }-*/;
-
-  public final native String header() /*-{ return this.header; }-*/;
-
-  public final native String footer() /*-{ return this.footer; }-*/;
-
-  protected ThemeInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
deleted file mode 100644
index af32d01..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
+++ /dev/null
@@ -1,260 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.rpc;
-
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/**
- * Class for grouping together callbacks and calling them in order.
- *
- * <p>Callbacks are added to the group with {@link #add(AsyncCallback)}, which returns a wrapped
- * callback suitable for passing to an asynchronous RPC call. The last callback must be added using
- * {@link #addFinal(AsyncCallback)} or {@link #done()} must be invoked.
- *
- * <p>The enclosing group buffers returned results and ensures that {@code onSuccess} is called
- * exactly once for each callback in the group, in the same order that callbacks were added. This
- * allows callers to, for example, use a {@link ScreenLoadCallback} as the last callback in the list
- * and only display the screen once all callbacks have succeeded.
- *
- * <p>In the event of a failure, the <em>first</em> caught exception is sent to <em>all</em>
- * callbacks' {@code onFailure} methods, in order; subsequent successes or failures are all ignored.
- * Note that this means {@code onFailure} may be called with an exception unrelated to the callback
- * processing it.
- */
-public class CallbackGroup {
-  private final List<CallbackGlue> callbacks;
-  private final Set<CallbackGlue> remaining;
-  private boolean finalAdded;
-
-  private boolean failed;
-  private Throwable failedThrowable;
-
-  public static <T> Callback<T> emptyCallback() {
-    return new Callback<T>() {
-      @Override
-      public void onSuccess(T result) {}
-
-      @Override
-      public void onFailure(Throwable err) {}
-    };
-  }
-
-  public CallbackGroup() {
-    callbacks = new ArrayList<>();
-    remaining = new HashSet<>();
-  }
-
-  public <T> Callback<T> addEmpty() {
-    Callback<T> cb = emptyCallback();
-    return add(cb);
-  }
-
-  public <T> Callback<T> add(AsyncCallback<T> cb) {
-    checkFinalAdded();
-    return handleAdd(cb);
-  }
-
-  public <T> HttpCallback<T> add(HttpCallback<T> cb) {
-    checkFinalAdded();
-    return handleAdd(cb);
-  }
-
-  public <T> Callback<T> addFinal(AsyncCallback<T> cb) {
-    checkFinalAdded();
-    finalAdded = true;
-    return handleAdd(cb);
-  }
-
-  public <T> HttpCallback<T> addFinal(HttpCallback<T> cb) {
-    checkFinalAdded();
-    finalAdded = true;
-    return handleAdd(cb);
-  }
-
-  public void done() {
-    finalAdded = true;
-    apply();
-  }
-
-  public void addListener(AsyncCallback<Void> cb) {
-    if (!failed && finalAdded && remaining.isEmpty()) {
-      cb.onSuccess(null);
-    } else {
-      handleAdd(cb).onSuccess(null);
-    }
-  }
-
-  public void addListener(CallbackGroup group) {
-    addListener(group.<Void>addEmpty());
-  }
-
-  private void success(CallbackGlue cb) {
-    remaining.remove(cb);
-    apply();
-  }
-
-  private <T> void failure(CallbackGlue w, Throwable caught) {
-    if (!failed) {
-      failed = true;
-      failedThrowable = caught;
-    }
-    remaining.remove(w);
-    apply();
-  }
-
-  private void apply() {
-    if (finalAdded && remaining.isEmpty()) {
-      if (failed) {
-        for (CallbackGlue cb : callbacks) {
-          cb.applyFailed();
-        }
-      } else {
-        for (CallbackGlue cb : callbacks) {
-          cb.applySuccess();
-        }
-      }
-      callbacks.clear();
-    }
-  }
-
-  private <T> Callback<T> handleAdd(AsyncCallback<T> cb) {
-    if (failed) {
-      cb.onFailure(failedThrowable);
-      return emptyCallback();
-    }
-
-    CallbackImpl<T> wrapper = new CallbackImpl<>(cb);
-    callbacks.add(wrapper);
-    remaining.add(wrapper);
-    return wrapper;
-  }
-
-  private <T> HttpCallback<T> handleAdd(HttpCallback<T> cb) {
-    if (failed) {
-      cb.onFailure(failedThrowable);
-      return new HttpCallback<T>() {
-        @Override
-        public void onSuccess(HttpResponse<T> result) {}
-
-        @Override
-        public void onFailure(Throwable caught) {}
-      };
-    }
-
-    HttpCallbackImpl<T> w = new HttpCallbackImpl<>(cb);
-    callbacks.add(w);
-    remaining.add(w);
-    return w;
-  }
-
-  private void checkFinalAdded() {
-    if (finalAdded) {
-      throw new IllegalStateException("final callback already added");
-    }
-  }
-
-  public interface Callback<T>
-      extends AsyncCallback<T>, com.google.gwtjsonrpc.common.AsyncCallback<T> {}
-
-  private interface CallbackGlue {
-    void applySuccess();
-
-    void applyFailed();
-  }
-
-  private class CallbackImpl<T> implements Callback<T>, CallbackGlue {
-    AsyncCallback<T> delegate;
-    T result;
-
-    CallbackImpl(AsyncCallback<T> delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public void onSuccess(T value) {
-      this.result = value;
-      success(this);
-    }
-
-    @Override
-    public void onFailure(Throwable caught) {
-      failure(this, caught);
-    }
-
-    @Override
-    public void applySuccess() {
-      AsyncCallback<T> cb = delegate;
-      if (cb != null) {
-        delegate = null;
-        cb.onSuccess(result);
-        result = null;
-      }
-    }
-
-    @Override
-    public void applyFailed() {
-      AsyncCallback<T> cb = delegate;
-      if (cb != null) {
-        delegate = null;
-        result = null;
-        cb.onFailure(failedThrowable);
-      }
-    }
-  }
-
-  private class HttpCallbackImpl<T> implements HttpCallback<T>, CallbackGlue {
-    private HttpCallback<T> delegate;
-    private HttpResponse<T> result;
-
-    HttpCallbackImpl(HttpCallback<T> delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public void onSuccess(HttpResponse<T> result) {
-      this.result = result;
-      success(this);
-    }
-
-    @Override
-    public void onFailure(Throwable caught) {
-      failure(this, caught);
-    }
-
-    @Override
-    public void applySuccess() {
-      HttpCallback<T> cb = delegate;
-      if (cb != null) {
-        delegate = null;
-        cb.onSuccess(result);
-        result = null;
-      }
-    }
-
-    @Override
-    public void applyFailed() {
-      HttpCallback<T> cb = delegate;
-      if (cb != null) {
-        delegate = null;
-        result = null;
-        cb.onFailure(failedThrowable);
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
deleted file mode 100644
index 2d6723a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.rpc;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.NotSignedInDialog;
-import com.google.gerrit.common.errors.NameAlreadyUsedException;
-import com.google.gerrit.common.errors.NoSuchAccountException;
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.common.errors.NotSignedInException;
-import com.google.gwt.user.client.rpc.InvocationException;
-import com.google.gwtjsonrpc.client.RemoteJsonException;
-import com.google.gwtjsonrpc.client.ServerUnavailableException;
-import com.google.gwtjsonrpc.common.JsonConstants;
-
-/** Abstract callback handling generic error conditions automatically */
-public abstract class GerritCallback<T>
-    implements com.google.gwtjsonrpc.common.AsyncCallback<T>,
-        com.google.gwt.user.client.rpc.AsyncCallback<T> {
-  @Override
-  public void onFailure(Throwable caught) {
-    showFailure(caught);
-  }
-
-  public static void showFailure(Throwable caught) {
-    if (isSigninFailure(caught)) {
-      new NotSignedInDialog().center();
-    } else if (isNoSuchEntity(caught)) {
-      new ErrorDialog(Gerrit.C.notFoundBody()).center();
-    } else if (isNoSuchAccount(caught)) {
-      final String msg = caught.getMessage();
-      final String who = msg.substring(NoSuchAccountException.MESSAGE.length());
-      final ErrorDialog d = new ErrorDialog(Gerrit.M.noSuchAccountMessage(who));
-      d.setText(Gerrit.C.noSuchAccountTitle());
-      d.center();
-
-    } else if (isNameAlreadyUsed(caught)) {
-      final String msg = caught.getMessage();
-      final String alreadyUsedName = msg.substring(NameAlreadyUsedException.MESSAGE.length());
-      new ErrorDialog(Gerrit.M.nameAlreadyUsedBody(alreadyUsedName)).center();
-
-    } else if (isNoSuchGroup(caught)) {
-      final String msg = caught.getMessage();
-      final String group = msg.substring(NoSuchGroupException.MESSAGE.length());
-      final ErrorDialog d = new ErrorDialog(Gerrit.M.noSuchGroupMessage(group));
-      d.setText(Gerrit.C.noSuchGroupTitle());
-      d.center();
-
-    } else if (caught instanceof ServerUnavailableException) {
-      new ErrorDialog(RpcConstants.C.errorServerUnavailable()).center();
-
-    } else {
-      new ErrorDialog(caught).center();
-    }
-  }
-
-  public static boolean isSigninFailure(Throwable caught) {
-    if (isNotSignedIn(caught)
-        || isInvalidXSRF(caught)
-        || (isNoSuchEntity(caught) && !Gerrit.isSignedIn())) {
-      return true;
-    }
-    return false;
-  }
-
-  protected static boolean isInvalidXSRF(Throwable caught) {
-    return caught instanceof InvocationException
-        && caught.getMessage().equals(JsonConstants.ERROR_INVALID_XSRF);
-  }
-
-  protected static boolean isNotSignedIn(Throwable caught) {
-    return RestApi.isNotSignedIn(caught)
-        || (caught instanceof RemoteJsonException
-            && caught.getMessage().equals(NotSignedInException.MESSAGE));
-  }
-
-  protected static boolean isNoSuchEntity(Throwable caught) {
-    return RestApi.isNotFound(caught)
-        || (caught instanceof RemoteJsonException
-            && caught.getMessage().equals(NoSuchEntityException.MESSAGE));
-  }
-
-  protected static boolean isNoSuchAccount(Throwable caught) {
-    return caught instanceof RemoteJsonException
-        && caught.getMessage().startsWith(NoSuchAccountException.MESSAGE);
-  }
-
-  protected static boolean isNameAlreadyUsed(Throwable caught) {
-    return caught instanceof RemoteJsonException
-        && caught.getMessage().startsWith(NameAlreadyUsedException.MESSAGE);
-  }
-
-  protected static boolean isNoSuchGroup(Throwable caught) {
-    return caught instanceof RemoteJsonException
-        && caught.getMessage().startsWith(NoSuchGroupException.MESSAGE);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpCallback.java
deleted file mode 100644
index 2de2980..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpCallback.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.rpc;
-
-/** AsyncCallback supplied with HTTP response headers. */
-public interface HttpCallback<T> {
-  void onSuccess(HttpResponse<T> result);
-
-  void onFailure(Throwable caught);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpResponse.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpResponse.java
deleted file mode 100644
index 22d62fb..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpResponse.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.rpc;
-
-import com.google.gwt.http.client.Response;
-
-/** Wraps decoded server reply with HTTP headers. */
-public class HttpResponse<T> {
-  private final Response httpResponse;
-  private final String contentType;
-  private final T result;
-
-  HttpResponse(Response httpResponse, String contentType, T result) {
-    this.httpResponse = httpResponse;
-    this.contentType = contentType;
-    this.result = result;
-  }
-
-  /** HTTP status code, always in the 2xx family. */
-  public int getStatusCode() {
-    return httpResponse.getStatusCode();
-  }
-
-  /**
-   * Content type supplied by the server.
-   *
-   * <p>This helper simplifies the common {@code getHeader("Content-Type")} case.
-   */
-  public String getContentType() {
-    return contentType;
-  }
-
-  /** Lookup an arbitrary reply header. */
-  public String getHeader(String header) {
-    if ("Content-Type".equals(header)) {
-      return contentType;
-    }
-    return httpResponse.getHeader(header);
-  }
-
-  public T getResult() {
-    return result;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
deleted file mode 100644
index e2a9ffb..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
+++ /dev/null
@@ -1,519 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.rpc;
-
-import static com.google.gwt.http.client.RequestBuilder.DELETE;
-import static com.google.gwt.http.client.RequestBuilder.GET;
-import static com.google.gwt.http.client.RequestBuilder.POST;
-import static com.google.gwt.http.client.RequestBuilder.PUT;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.RpcStatus;
-import com.google.gerrit.common.data.HostPageData;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.http.client.Request;
-import com.google.gwt.http.client.RequestBuilder;
-import com.google.gwt.http.client.RequestBuilder.Method;
-import com.google.gwt.http.client.RequestCallback;
-import com.google.gwt.http.client.RequestException;
-import com.google.gwt.http.client.Response;
-import com.google.gwt.http.client.URL;
-import com.google.gwt.json.client.JSONException;
-import com.google.gwt.json.client.JSONParser;
-import com.google.gwt.json.client.JSONValue;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.rpc.StatusCodeException;
-
-/** Makes a REST API call to the server. */
-public class RestApi {
-  private static final int SC_UNAVAILABLE = 2;
-  private static final int SC_BAD_TRANSPORT = 3;
-  private static final int SC_BAD_RESPONSE = 4;
-  private static final String JSON_TYPE = "application/json";
-  private static final String JSON_UTF8 = JSON_TYPE + "; charset=utf-8";
-  private static final String TEXT_TYPE = "text/plain";
-  private static final String TEXT_UTF8 = TEXT_TYPE + "; charset=utf-8";
-
-  /**
-   * Expected JSON content body prefix that prevents XSSI.
-   *
-   * <p>The server always includes this line as the first line of the response content body when the
-   * response body is formatted as JSON. It gets inserted by the server to prevent the resource from
-   * being imported into another domain's page using a &lt;script&gt; tag. This line must be removed
-   * before the JSON can be parsed.
-   */
-  private static final String JSON_MAGIC = ")]}'\n";
-
-  /** True if err is a StatusCodeException reporting Not Found. */
-  public static boolean isNotFound(Throwable err) {
-    return isStatus(err, Response.SC_NOT_FOUND);
-  }
-
-  /** True if err is describing a user that is currently anonymous. */
-  public static boolean isNotSignedIn(Throwable err) {
-    if (err instanceof StatusCodeException) {
-      StatusCodeException sce = (StatusCodeException) err;
-      if (sce.getStatusCode() == Response.SC_UNAUTHORIZED) {
-        return true;
-      }
-      return sce.getStatusCode() == Response.SC_FORBIDDEN
-          && (sce.getEncodedResponse().equals("Authentication required")
-              || sce.getEncodedResponse().startsWith("Must be signed-in")
-              || sce.getEncodedResponse().startsWith("Invalid authentication"));
-    }
-    return false;
-  }
-
-  /** True if err is a StatusCodeException with a specific HTTP code. */
-  public static boolean isStatus(Throwable err, int status) {
-    return err instanceof StatusCodeException
-        && ((StatusCodeException) err).getStatusCode() == status;
-  }
-
-  /** Is the Gerrit Code Review server likely to return this status? */
-  public static boolean isExpected(int statusCode) {
-    switch (statusCode) {
-      case SC_UNAVAILABLE:
-      case Response.SC_BAD_REQUEST:
-      case Response.SC_UNAUTHORIZED:
-      case Response.SC_FORBIDDEN:
-      case Response.SC_NOT_FOUND:
-      case Response.SC_METHOD_NOT_ALLOWED:
-      case Response.SC_CONFLICT:
-      case Response.SC_PRECONDITION_FAILED:
-      case 422: // Unprocessable Entity
-      case 429: // Too Many Requests (RFC 6585)
-        return true;
-
-      default:
-        // Assume any other code is not expected. These may be
-        // local proxy server errors outside of our control.
-        return false;
-    }
-  }
-
-  private static class HttpImpl<T extends JavaScriptObject> implements RequestCallback {
-    private final boolean background;
-    private final HttpCallback<T> cb;
-
-    HttpImpl(boolean bg, HttpCallback<T> cb) {
-      this.background = bg;
-      this.cb = cb;
-    }
-
-    @Override
-    public void onResponseReceived(Request req, Response res) {
-      int status = res.getStatusCode();
-      if (status == Response.SC_NO_CONTENT) {
-        cb.onSuccess(new HttpResponse<T>(res, null, null));
-        if (!background) {
-          RpcStatus.INSTANCE.onRpcComplete();
-        }
-
-      } else if (200 <= status && status < 300) {
-        long start = System.currentTimeMillis();
-        final T data;
-        final String type;
-        if (isJsonBody(res)) {
-          try {
-            JSONValue val = parseJson(res);
-            if (isJsonEncoded(res) && val.isString() != null) {
-              data = NativeString.wrap(val.isString().stringValue()).cast();
-              type = simpleType(res.getHeader("X-FYI-Content-Type"));
-            } else {
-              data = RestApi.<T>cast(val);
-              type = JSON_TYPE;
-            }
-          } catch (JSONException e) {
-            if (!background) {
-              RpcStatus.INSTANCE.onRpcComplete();
-            }
-            cb.onFailure(
-                new StatusCodeException(SC_BAD_RESPONSE, "Invalid JSON: " + e.getMessage()));
-            return;
-          }
-        } else if (isTextBody(res)) {
-          data = NativeString.wrap(res.getText()).cast();
-          type = TEXT_TYPE;
-        } else {
-          if (!background) {
-            RpcStatus.INSTANCE.onRpcComplete();
-          }
-          cb.onFailure(
-              new StatusCodeException(
-                  SC_BAD_RESPONSE,
-                  "Expected "
-                      + JSON_TYPE
-                      + " or "
-                      + TEXT_TYPE
-                      + "; received Content-Type: "
-                      + res.getHeader("Content-Type")));
-          return;
-        }
-
-        Scheduler.ScheduledCommand cmd =
-            new Scheduler.ScheduledCommand() {
-              @Override
-              public void execute() {
-                try {
-                  cb.onSuccess(new HttpResponse<>(res, type, data));
-                } finally {
-                  if (!background) {
-                    RpcStatus.INSTANCE.onRpcComplete();
-                  }
-                }
-              }
-            };
-
-        // Defer handling the response if the create took a while.
-        if ((System.currentTimeMillis() - start) > 75) {
-          Scheduler.get().scheduleDeferred(cmd);
-        } else {
-          cmd.execute();
-        }
-      } else {
-        String msg;
-        if (isTextBody(res)) {
-          msg = res.getText().trim();
-        } else if (isJsonBody(res)) {
-          JSONValue v;
-          try {
-            v = parseJson(res);
-          } catch (JSONException e) {
-            v = null;
-          }
-          if (v != null && v.isString() != null) {
-            msg = v.isString().stringValue();
-          } else {
-            msg = trimJsonMagic(res.getText()).trim();
-          }
-        } else {
-          msg = res.getStatusText();
-        }
-
-        if (!background) {
-          RpcStatus.INSTANCE.onRpcComplete();
-        }
-        cb.onFailure(new StatusCodeException(status, msg));
-      }
-    }
-
-    @Override
-    public void onError(Request req, Throwable err) {
-      if (!background) {
-        RpcStatus.INSTANCE.onRpcComplete();
-      }
-      if (err.getMessage().contains("XmlHttpRequest.status")) {
-        cb.onFailure(
-            new StatusCodeException(SC_UNAVAILABLE, RpcConstants.C.errorServerUnavailable()));
-      } else {
-        cb.onFailure(new StatusCodeException(SC_BAD_TRANSPORT, err.getMessage()));
-      }
-    }
-  }
-
-  private StringBuilder url;
-  private boolean hasQueryParams;
-  private boolean background;
-  private String ifNoneMatch;
-
-  /**
-   * Initialize a new API call.
-   *
-   * <p>By default the JSON format will be selected by including an HTTP Accept header in the
-   * request.
-   *
-   * @param name URL of the REST resource to access, e.g. {@code "/projects/"} to list accessible
-   *     projects from the server.
-   */
-  public RestApi(String name) {
-    if (name.startsWith("/")) {
-      name = name.substring(1);
-    }
-
-    url = new StringBuilder();
-    url.append(GWT.getHostPageBaseURL());
-    url.append(name);
-  }
-
-  public RestApi view(String name) {
-    return idRaw(name);
-  }
-
-  public RestApi id(String id) {
-    return idRaw(URL.encodePathSegment(id));
-  }
-
-  public RestApi id(String project, int id) {
-    return idRaw(URL.encodePathSegment(project) + "~" + id);
-  }
-
-  public RestApi id(int id) {
-    return idRaw(Integer.toString(id));
-  }
-
-  public RestApi idRaw(String name) {
-    if (hasQueryParams) {
-      throw new IllegalStateException();
-    }
-    if (url.charAt(url.length() - 1) != '/') {
-      url.append('/');
-    }
-    url.append(name);
-    return this;
-  }
-
-  public RestApi addParameter(String name, String value) {
-    return addParameterRaw(name, URL.encodeQueryString(value));
-  }
-
-  public RestApi addParameter(String name, String... value) {
-    for (String val : value) {
-      addParameter(name, val);
-    }
-    return this;
-  }
-
-  public RestApi addParameterTrue(String name) {
-    return addParameterRaw(name, null);
-  }
-
-  public RestApi addParameter(String name, boolean value) {
-    return addParameterRaw(name, value ? "t" : "f");
-  }
-
-  public RestApi addParameter(String name, int value) {
-    return addParameterRaw(name, String.valueOf(value));
-  }
-
-  public RestApi addParameter(String name, Enum<?> value) {
-    return addParameterRaw(name, value.name());
-  }
-
-  public RestApi addParameterRaw(String name, String value) {
-    if (hasQueryParams) {
-      url.append("&");
-    } else {
-      url.append("?");
-      hasQueryParams = true;
-    }
-    url.append(name);
-    if (value != null) {
-      url.append("=").append(value);
-    }
-    return this;
-  }
-
-  public RestApi ifNoneMatch() {
-    return ifNoneMatch("*");
-  }
-
-  public RestApi ifNoneMatch(String etag) {
-    ifNoneMatch = etag;
-    return this;
-  }
-
-  public RestApi background() {
-    background = true;
-    return this;
-  }
-
-  public String url() {
-    return url.toString();
-  }
-
-  public <T extends JavaScriptObject> void get(AsyncCallback<T> cb) {
-    get(wrap(cb));
-  }
-
-  public <T extends JavaScriptObject> void get(HttpCallback<T> cb) {
-    send(GET, cb);
-  }
-
-  public <T extends JavaScriptObject> void delete(AsyncCallback<T> cb) {
-    delete(wrap(cb));
-  }
-
-  public <T extends JavaScriptObject> void delete(HttpCallback<T> cb) {
-    send(DELETE, cb);
-  }
-
-  private <T extends JavaScriptObject> void send(Method method, HttpCallback<T> cb) {
-    HttpImpl<T> httpCallback = new HttpImpl<>(background, cb);
-    try {
-      if (!background) {
-        RpcStatus.INSTANCE.onRpcStart();
-      }
-      request(method).sendRequest(null, httpCallback);
-    } catch (RequestException e) {
-      httpCallback.onError(null, e);
-    }
-  }
-
-  public <T extends JavaScriptObject> void post(JavaScriptObject content, AsyncCallback<T> cb) {
-    post(content, wrap(cb));
-  }
-
-  public <T extends JavaScriptObject> void post(JavaScriptObject content, HttpCallback<T> cb) {
-    sendJSON(POST, content, cb);
-  }
-
-  public <T extends JavaScriptObject> void post(String content, AsyncCallback<T> cb) {
-    post(content, wrap(cb));
-  }
-
-  public <T extends JavaScriptObject> void post(String content, HttpCallback<T> cb) {
-    sendText(POST, content, cb);
-  }
-
-  public <T extends JavaScriptObject> void put(AsyncCallback<T> cb) {
-    put(wrap(cb));
-  }
-
-  public <T extends JavaScriptObject> void put(HttpCallback<T> cb) {
-    send(PUT, cb);
-  }
-
-  public <T extends JavaScriptObject> void put(String content, AsyncCallback<T> cb) {
-    put(content, wrap(cb));
-  }
-
-  public <T extends JavaScriptObject> void put(String content, HttpCallback<T> cb) {
-    sendText(PUT, content, cb);
-  }
-
-  public <T extends JavaScriptObject> void put(JavaScriptObject content, AsyncCallback<T> cb) {
-    put(content, wrap(cb));
-  }
-
-  public <T extends JavaScriptObject> void put(JavaScriptObject content, HttpCallback<T> cb) {
-    sendJSON(PUT, content, cb);
-  }
-
-  private <T extends JavaScriptObject> void sendJSON(
-      Method method, JavaScriptObject content, HttpCallback<T> cb) {
-    HttpImpl<T> httpCallback = new HttpImpl<>(background, cb);
-    try {
-      if (!background) {
-        RpcStatus.INSTANCE.onRpcStart();
-      }
-      RequestBuilder req = request(method);
-      req.setHeader("Content-Type", JSON_UTF8);
-      req.sendRequest(str(content), httpCallback);
-    } catch (RequestException e) {
-      httpCallback.onError(null, e);
-    }
-  }
-
-  private static native String str(JavaScriptObject jso) /*-{ return JSON.stringify(jso) }-*/;
-
-  private <T extends JavaScriptObject> void sendText(
-      Method method, String body, HttpCallback<T> cb) {
-    HttpImpl<T> httpCallback = new HttpImpl<>(background, cb);
-    try {
-      if (!background) {
-        RpcStatus.INSTANCE.onRpcStart();
-      }
-      RequestBuilder req = request(method);
-      req.setHeader("Content-Type", TEXT_UTF8);
-      req.sendRequest(body, httpCallback);
-    } catch (RequestException e) {
-      httpCallback.onError(null, e);
-    }
-  }
-
-  private RequestBuilder request(Method method) {
-    RequestBuilder req = new RequestBuilder(method, url());
-    if (ifNoneMatch != null) {
-      req.setHeader("If-None-Match", ifNoneMatch);
-    }
-    req.setHeader("Accept", JSON_TYPE);
-    if (Gerrit.getXGerritAuth() != null) {
-      req.setHeader(HostPageData.XSRF_HEADER_NAME, Gerrit.getXGerritAuth());
-    }
-    return req;
-  }
-
-  private static boolean isJsonBody(Response res) {
-    return isContentType(res, JSON_TYPE);
-  }
-
-  private static boolean isTextBody(Response res) {
-    return isContentType(res, TEXT_TYPE);
-  }
-
-  private static boolean isJsonEncoded(Response res) {
-    return "json".equals(res.getHeader("X-FYI-Content-Encoding"));
-  }
-
-  private static boolean isContentType(Response res, String want) {
-    String type = res.getHeader("Content-Type");
-    return type != null && want.equals(simpleType(type));
-  }
-
-  private static String simpleType(String type) {
-    int semi = type.indexOf(';');
-    if (semi >= 0) {
-      return type.substring(0, semi).trim();
-    }
-    return type;
-  }
-
-  private static JSONValue parseJson(Response res) throws JSONException {
-    String json = trimJsonMagic(res.getText());
-    if (json.isEmpty()) {
-      throw new JSONException("response was empty");
-    }
-    return JSONParser.parseStrict(json);
-  }
-
-  private static String trimJsonMagic(String json) {
-    if (json.startsWith(JSON_MAGIC)) {
-      json = json.substring(JSON_MAGIC.length());
-    }
-    return json;
-  }
-
-  @SuppressWarnings("unchecked")
-  private static <T extends JavaScriptObject> T cast(JSONValue val) {
-    if (val.isObject() != null) {
-      return (T) val.isObject().getJavaScriptObject();
-    } else if (val.isArray() != null) {
-      return (T) val.isArray().getJavaScriptObject();
-    } else if (val.isString() != null) {
-      return (T) NativeString.wrap(val.isString().stringValue());
-    } else if (val.isNull() != null) {
-      return null;
-    } else {
-      throw new JSONException("unsupported JSON type");
-    }
-  }
-
-  private static <T extends JavaScriptObject> HttpCallback<T> wrap(AsyncCallback<T> cb) {
-    return new HttpCallback<T>() {
-      @Override
-      public void onSuccess(HttpResponse<T> r) {
-        cb.onSuccess(r.getResult());
-      }
-
-      @Override
-      public void onFailure(Throwable e) {
-        cb.onFailure(e);
-      }
-    };
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java
deleted file mode 100644
index 56d536d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.rpc;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.i18n.client.Constants;
-
-public interface RpcConstants extends Constants {
-  RpcConstants C = GWT.create(RpcConstants.class);
-
-  String errorServerUnavailable();
-
-  String errorRemoteJsonException();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.properties
deleted file mode 100644
index e8695b1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.properties
+++ /dev/null
@@ -1,2 +0,0 @@
-errorServerUnavailable = Server Unavailable
-errorRemoteJsonException = Server Error
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
deleted file mode 100644
index 3aae04a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.rpc;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.NotFoundScreen;
-import com.google.gerrit.client.NotSignedInDialog;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.errors.NoSuchEntityException;
-
-/** Callback switching {@link NoSuchEntityException} to {@link NotFoundScreen} */
-public abstract class ScreenLoadCallback<T> extends GerritCallback<T> {
-  private final Screen screen;
-
-  public ScreenLoadCallback(Screen s) {
-    screen = s;
-  }
-
-  @Override
-  public final void onSuccess(T result) {
-    if (screen.isAttached()) {
-      preDisplay(result);
-      screen.display();
-      postDisplay();
-    }
-  }
-
-  protected abstract void preDisplay(T result);
-
-  protected void postDisplay() {}
-
-  @Override
-  public void onFailure(Throwable caught) {
-    if (isSigninFailure(caught)) {
-      new NotSignedInDialog().center();
-    } else if (isNoSuchEntity(caught)) {
-      Gerrit.display(screen.getToken(), new NotFoundScreen());
-    } else {
-      super.onFailure(caught);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
deleted file mode 100644
index bdebd68..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.groups.GroupMap;
-import com.google.gerrit.client.info.GroupInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.user.client.ui.SuggestOracle;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.Map;
-
-/** Suggestion Oracle for AccountGroup entities. */
-public class AccountGroupSuggestOracle extends SuggestAfterTypingNCharsOracle {
-  private Map<String, AccountGroup.UUID> priorResults = new HashMap<>();
-
-  private Project.NameKey projectName;
-
-  @Override
-  public void _onRequestSuggestions(Request req, Callback callback) {
-    GroupMap.suggestAccountGroupForProject(
-        projectName == null ? null : projectName.get(),
-        req.getQuery(),
-        req.getLimit(),
-        new GerritCallback<GroupMap>() {
-          @Override
-          public void onSuccess(GroupMap result) {
-            priorResults.clear();
-            ArrayList<AccountGroupSuggestion> r = new ArrayList<>(result.size());
-            for (GroupInfo group : Natives.asList(result.values())) {
-              r.add(new AccountGroupSuggestion(group));
-              priorResults.put(group.name(), group.getGroupUUID());
-            }
-            callback.onSuggestionsReady(req, new Response(r));
-          }
-        });
-  }
-
-  public void setProject(Project.NameKey projectName) {
-    this.projectName = projectName;
-  }
-
-  private static class AccountGroupSuggestion implements SuggestOracle.Suggestion {
-    private final GroupInfo info;
-
-    AccountGroupSuggestion(GroupInfo k) {
-      info = k;
-    }
-
-    @Override
-    public String getDisplayString() {
-      return info.name();
-    }
-
-    @Override
-    public String getReplacementString() {
-      return info.name();
-    }
-  }
-
-  /** @return the group UUID, or null if it cannot be found. */
-  public AccountGroup.UUID getUUID(String name) {
-    return priorResults.get(name);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
deleted file mode 100644
index c44f357..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.AvatarImage;
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwt.user.client.ui.FlowPanel;
-import java.util.function.Function;
-
-/** Link to any user's account dashboard. */
-public class AccountLinkPanel extends FlowPanel {
-  public static AccountLinkPanel create(AccountInfo ai) {
-    return withStatus(ai, Change.Status.NEW);
-  }
-
-  public static AccountLinkPanel withStatus(AccountInfo ai, Change.Status status) {
-    return new AccountLinkPanel(ai, name -> PageLinks.toAccountQuery(name, status));
-  }
-
-  public static AccountLinkPanel forAssignee(AccountInfo ai) {
-    return new AccountLinkPanel(ai, PageLinks::toAssigneeQuery);
-  }
-
-  private AccountLinkPanel(AccountInfo ai, Function<String, String> nameToQuery) {
-    addStyleName(Gerrit.RESOURCES.css().accountLinkPanel());
-
-    InlineHyperlink l =
-        new InlineHyperlink(FormatUtil.name(ai), nameToQuery.apply(name(ai))) {
-          @Override
-          public void go() {
-            Gerrit.display(getTargetHistoryToken());
-          }
-        };
-    l.setTitle(FormatUtil.nameEmail(ai));
-
-    add(new AvatarImage(ai));
-    add(l);
-  }
-
-  private static String name(AccountInfo ai) {
-    if (ai.email() != null) {
-      return ai.email();
-    } else if (ai.name() != null) {
-      return ai.name();
-    } else if (ai._accountId() != 0) {
-      return "" + ai._accountId();
-    } else {
-      return "";
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountScreen.java
deleted file mode 100644
index 59ff9fe3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountScreen.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-/** A screen that requires the user to be signed-into their account. */
-public abstract class AccountScreen extends Screen {
-  protected AccountScreen() {
-    setRequiresSignIn(true);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
deleted file mode 100644
index 5038ad9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.account.AccountApi;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.ui.SuggestOracle;
-import java.util.ArrayList;
-import java.util.List;
-
-/** Suggestion Oracle for Account entities. */
-public class AccountSuggestOracle extends SuggestAfterTypingNCharsOracle {
-  @Override
-  public void _onRequestSuggestions(Request req, Callback cb) {
-    AccountApi.suggest(
-        req.getQuery(),
-        req.getLimit(),
-        new GerritCallback<JsArray<AccountInfo>>() {
-          @Override
-          public void onSuccess(JsArray<AccountInfo> in) {
-            List<AccountSuggestion> r = new ArrayList<>(in.length());
-            for (AccountInfo p : Natives.asList(in)) {
-              r.add(new AccountSuggestion(p, req.getQuery()));
-            }
-            cb.onSuggestionsReady(req, new Response(r));
-          }
-        });
-  }
-
-  public static class AccountSuggestion implements SuggestOracle.Suggestion {
-    private final String suggestion;
-
-    public AccountSuggestion(AccountInfo info, String query) {
-      this.suggestion = format(info, query);
-    }
-
-    @Override
-    public String getDisplayString() {
-      return suggestion;
-    }
-
-    @Override
-    public String getReplacementString() {
-      return suggestion;
-    }
-
-    public static String format(AccountInfo info, String query) {
-      String s = FormatUtil.nameEmail(info);
-      if (query != null && !containsQuery(s, query) && info.secondaryEmails() != null) {
-        for (String email : Natives.asList(info.secondaryEmails())) {
-          AccountInfo info2 =
-              AccountInfo.create(info._accountId(), info.name(), email, info.username());
-          String s2 = FormatUtil.nameEmail(info2);
-          if (containsQuery(s2, query)) {
-            s = s2;
-            break;
-          }
-        }
-      }
-      return s;
-    }
-
-    private static boolean containsQuery(String s, String query) {
-      for (String qterm : query.split("\\s+")) {
-        if (!s.toLowerCase().contains(qterm.toLowerCase())) {
-          return false;
-        }
-      }
-      return true;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
deleted file mode 100644
index a1d2229..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.SuggestOracle;
-
-public class AddMemberBox extends Composite {
-  private final FlowPanel addPanel;
-  private final Button addMember;
-  private final RemoteSuggestBox suggestBox;
-
-  public AddMemberBox(final String buttonLabel, String hint, SuggestOracle suggestOracle) {
-    addPanel = new FlowPanel();
-    addMember = new Button(buttonLabel);
-
-    suggestBox = new RemoteSuggestBox(suggestOracle);
-    suggestBox.setStyleName(Gerrit.RESOURCES.css().addMemberTextBox());
-    suggestBox.setVisibleLength(50);
-    suggestBox.setHintText(hint);
-    suggestBox.addSelectionHandler(
-        new SelectionHandler<String>() {
-          @Override
-          public void onSelection(SelectionEvent<String> event) {
-            addMember.fireEvent(new ClickEvent() {});
-          }
-        });
-
-    addPanel.add(suggestBox);
-    addPanel.add(addMember);
-
-    initWidget(addPanel);
-  }
-
-  public void addClickHandler(ClickHandler handler) {
-    addMember.addClickHandler(handler);
-  }
-
-  public String getText() {
-    return suggestBox.getText();
-  }
-
-  public void setEnabled(boolean enabled) {
-    addMember.setEnabled(enabled);
-    suggestBox.setEnabled(enabled);
-  }
-
-  public void setText(String text) {
-    suggestBox.setText(text);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java
deleted file mode 100644
index 6e05f83..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.QueryScreen;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-
-/** Link to the open changes of a project. */
-public class BranchLink extends InlineHyperlink {
-  private final String query;
-
-  public BranchLink(Project.NameKey project, Change.Status status, String branch, String topic) {
-    this(text(branch, topic), query(project, status, branch, topic));
-  }
-
-  public BranchLink(
-      String text, Project.NameKey project, Change.Status status, String branch, String topic) {
-    this(text, query(project, status, branch, topic));
-  }
-
-  private BranchLink(String text, String query) {
-    super(text, PageLinks.toChangeQuery(query));
-    this.query = query;
-  }
-
-  @Override
-  public void go() {
-    Gerrit.display(getTargetHistoryToken(), createScreen());
-  }
-
-  private Screen createScreen() {
-    return QueryScreen.forQuery(query);
-  }
-
-  private static String text(String branch, String topic) {
-    if (topic != null && !topic.isEmpty()) {
-      return branch + " (" + topic + ")";
-    }
-    return branch;
-  }
-
-  public static String query(
-      Project.NameKey project, Change.Status status, String branch, String topic) {
-    String query = PageLinks.projectQuery(project, status);
-
-    if (branch.startsWith(RefNames.REFS)) {
-      if (branch.startsWith(RefNames.REFS_HEADS)) {
-        query +=
-            " "
-                + PageLinks.op(
-                    "branch", //
-                    branch.substring(RefNames.REFS_HEADS.length()));
-      } else {
-        query += " " + PageLinks.op("ref", branch);
-      }
-    } else {
-      // Assume it was clipped already by the caller.  This
-      // happens for example inside of the ChangeInfo object.
-      //
-      query += " " + PageLinks.op("branch", branch);
-    }
-
-    if (topic != null && !topic.isEmpty()) {
-      query += " " + PageLinks.op("topic", topic);
-    }
-
-    return query;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ChangeLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ChangeLink.java
deleted file mode 100644
index b54d752..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ChangeLink.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.GWT;
-
-public class ChangeLink extends InlineHyperlink {
-  public static String permalink(Change.Id c) {
-    return GWT.getHostPageBaseURL() + c.get();
-  }
-
-  protected Change.Id cid;
-
-  public ChangeLink(Project.NameKey project, Change.Id c, String text) {
-    super(text, PageLinks.toChange(project, c));
-    getElement().setPropertyString("href", permalink(c));
-    cid = c;
-  }
-
-  @Override
-  public void go() {
-    Gerrit.display(getTargetHistoryToken());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java
deleted file mode 100644
index 0a0c14a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.projects.BranchInfo;
-import com.google.gerrit.client.projects.ProjectApi;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.SuggestBox;
-import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-import java.util.ArrayList;
-import java.util.List;
-
-public abstract class CherryPickDialog extends TextAreaActionDialog {
-  private SuggestBox newBranch;
-  private List<BranchInfo> branches;
-
-  public CherryPickDialog(Project.NameKey project) {
-    super(Util.C.cherryPickTitle(), Util.C.cherryPickCommitMessage());
-    ProjectApi.getBranches(
-        project,
-        new GerritCallback<JsArray<BranchInfo>>() {
-          @Override
-          public void onSuccess(JsArray<BranchInfo> result) {
-            branches = Natives.asList(result);
-          }
-        });
-
-    newBranch =
-        new SuggestBox(
-            new HighlightSuggestOracle() {
-              @Override
-              protected void onRequestSuggestions(Request request, Callback done) {
-                List<BranchSuggestion> suggestions = new ArrayList<>();
-                for (BranchInfo b : branches) {
-                  if (b.ref().contains(request.getQuery())) {
-                    suggestions.add(new BranchSuggestion(b));
-                  }
-                }
-                done.onSuggestionsReady(request, new Response(suggestions));
-              }
-            });
-
-    newBranch.setWidth("100%");
-    newBranch.getElement().getStyle().setProperty("boxSizing", "border-box");
-    message.setCharacterWidth(70);
-
-    final FlowPanel mwrap = new FlowPanel();
-    mwrap.setStyleName(Gerrit.RESOURCES.css().commentedActionMessage());
-    mwrap.add(newBranch);
-
-    panel.insert(mwrap, 0);
-    panel.insert(new SmallHeading(Util.C.headingCherryPickBranch()), 0);
-  }
-
-  @Override
-  public void center() {
-    super.center();
-    GlobalKey.dialog(this);
-    newBranch.setFocus(true);
-  }
-
-  public String getDestinationBranch() {
-    return newBranch.getText();
-  }
-
-  static class BranchSuggestion implements Suggestion {
-    private BranchInfo branch;
-
-    BranchSuggestion(BranchInfo branch) {
-      this.branch = branch;
-    }
-
-    @Override
-    public String getDisplayString() {
-      final String refsHeads = "refs/heads/";
-      if (branch.ref().startsWith(refsHeads)) {
-        return branch.ref().substring(refsHeads.length());
-      }
-      return branch.ref();
-    }
-
-    @Override
-    public String getReplacementString() {
-      return branch.getShortName();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommandMenuItem.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommandMenuItem.java
deleted file mode 100644
index c5ee34f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommandMenuItem.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.aria.client.Roles;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.Command;
-import com.google.gwt.user.client.ui.Anchor;
-
-public class CommandMenuItem extends Anchor implements ClickHandler {
-  private final Command command;
-
-  public CommandMenuItem(String text, Command cmd) {
-    super(text);
-    setStyleName(Gerrit.RESOURCES.css().menuItem());
-    Roles.getMenuitemRole().set(getElement());
-    addClickHandler(this);
-    command = cmd;
-  }
-
-  @Override
-  public void onClick(ClickEvent event) {
-    setFocus(false);
-    command.execute();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java
deleted file mode 100644
index 1753ade..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwtexpui.safehtml.client.FindReplace;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.VoidResult;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-public class CommentLinkProcessor {
-  private List<FindReplace> commentLinks;
-
-  public CommentLinkProcessor(List<FindReplace> commentLinks) {
-    this.commentLinks = commentLinks;
-  }
-
-  public SafeHtml apply(SafeHtml buf) {
-    try {
-      return buf.replaceAll(commentLinks);
-    } catch (RuntimeException err) {
-      // One or more of the patterns isn't valid on this browser.
-      // Try to filter the list down and remove the invalid ones.
-
-      List<FindReplace> safe = new ArrayList<>(commentLinks.size());
-
-      List<PatternError> bad = new ArrayList<>();
-      for (FindReplace r : commentLinks) {
-        try {
-          buf.replaceAll(Collections.singletonList(r));
-          safe.add(r);
-        } catch (RuntimeException why) {
-          bad.add(new PatternError(r, why.getMessage()));
-        }
-      }
-
-      if (!bad.isEmpty()) {
-        StringBuilder msg = new StringBuilder();
-        msg.append("Invalid commentlink pattern(s):");
-        for (PatternError e : bad) {
-          msg.append("\n");
-          msg.append("\"");
-          msg.append(e.pattern.pattern().getSource());
-          msg.append("\": ");
-          msg.append(e.errorMessage);
-        }
-        Gerrit.SYSTEM_SVC.clientError(
-            msg.toString(),
-            new AsyncCallback<VoidResult>() {
-              @Override
-              public void onFailure(Throwable caught) {}
-
-              @Override
-              public void onSuccess(VoidResult result) {}
-            });
-      }
-
-      try {
-        commentLinks = safe;
-        return buf.replaceAll(safe);
-      } catch (RuntimeException err2) {
-        // To heck with it. The patterns passed individually above but
-        // failed as a group? Just render without.
-        //
-        commentLinks = null;
-        return buf;
-      }
-    }
-  }
-
-  private static class PatternError {
-    FindReplace pattern;
-    String errorMessage;
-
-    PatternError(FindReplace r, String w) {
-      pattern = r;
-      errorMessage = w;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java
deleted file mode 100644
index b68f329..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.FocusWidget;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.user.client.AutoCenterDialogBox;
-
-public abstract class CommentedActionDialog extends AutoCenterDialogBox
-    implements CloseHandler<PopupPanel> {
-  protected final FlowPanel panel;
-  protected final Button sendButton;
-  protected final Button cancelButton;
-  protected final FlowPanel buttonPanel;
-  protected final FlowPanel contentPanel;
-  protected FocusWidget focusOn;
-
-  protected boolean sent;
-
-  public CommentedActionDialog(String title, String heading) {
-    super(/* auto hide */ false, /* modal */ true);
-    setGlassEnabled(true);
-    setText(title);
-
-    addStyleName(Gerrit.RESOURCES.css().commentedActionDialog());
-
-    sendButton = new Button(Util.C.commentedActionButtonSend());
-    sendButton.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            enableButtons(false);
-            onSend();
-          }
-        });
-
-    cancelButton = new Button(Util.C.commentedActionButtonCancel());
-    cancelButton.getElement().getStyle().setProperty("float", "right");
-    cancelButton.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            hide();
-          }
-        });
-
-    contentPanel = new FlowPanel();
-    contentPanel.setStyleName(Gerrit.RESOURCES.css().commentedActionMessage());
-
-    buttonPanel = new FlowPanel();
-    buttonPanel.add(sendButton);
-    buttonPanel.add(cancelButton);
-    buttonPanel.getElement().getStyle().setProperty("marginTop", "4px");
-
-    panel = new FlowPanel();
-    if (heading != null) {
-      panel.add(new SmallHeading(heading));
-    }
-    panel.add(contentPanel);
-    panel.add(buttonPanel);
-    add(panel);
-
-    addCloseHandler(this);
-  }
-
-  public void setFocusOn(FocusWidget focusWidget) {
-    focusOn = focusWidget;
-  }
-
-  public void enableButtons(boolean enable) {
-    sendButton.setEnabled(enable);
-    cancelButton.setEnabled(enable);
-  }
-
-  @Override
-  public void center() {
-    super.center();
-    GlobalKey.dialog(this);
-    if (focusOn != null) {
-      focusOn.setFocus(true);
-    }
-  }
-
-  @Override
-  public void onClose(CloseEvent<PopupPanel> event) {
-    sent = false;
-  }
-
-  public abstract void onSend();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java
deleted file mode 100644
index c0b662a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.event.logical.shared.HasCloseHandlers;
-import com.google.gwt.event.logical.shared.HasOpenHandlers;
-import com.google.gwt.event.logical.shared.OpenHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.ComplexPanel;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.DisclosurePanel;
-import com.google.gwt.user.client.ui.Panel;
-import com.google.gwt.user.client.ui.Widget;
-
-public class ComplexDisclosurePanel extends Composite
-    implements HasOpenHandlers<DisclosurePanel>, HasCloseHandlers<DisclosurePanel> {
-  private final DisclosurePanel main;
-  private final Panel header;
-
-  public ComplexDisclosurePanel(String text, boolean isOpen) {
-    // Ick. GWT's DisclosurePanel won't let us subclass it, or do any
-    // other modification of its header. We're stuck with injecting
-    // into the DOM directly.
-    //
-    main = new DisclosurePanel(text);
-    main.setOpen(isOpen);
-    final Element headerParent;
-    {
-      final Element table = main.getElement();
-      final Element tbody = DOM.getFirstChild(table);
-      final Element tr1 = DOM.getChild(tbody, 0);
-      final Element tr2 = DOM.getChild(tbody, 1);
-
-      DOM.getChild(tr1, 0).setPropertyString("width", "20px");
-      DOM.getChild(tr2, 0).setPropertyInt("colSpan", 2);
-      headerParent = tr1;
-    }
-
-    header =
-        new ComplexPanel() {
-          {
-            setElement((Element) (DOM.createTD()));
-            getElement().setInnerHTML("&nbsp;");
-          }
-
-          @Override
-          public void add(Widget w) {
-            add(w, (Element) getElement());
-          }
-        };
-
-    initWidget(
-        new ComplexPanel() {
-          {
-            final DisclosurePanel main = ComplexDisclosurePanel.this.main;
-            setElement((Element) (main.getElement()));
-            getChildren().add(main);
-            adopt(main);
-
-            add(ComplexDisclosurePanel.this.header, headerParent);
-          }
-        });
-  }
-
-  public Panel getHeader() {
-    return header;
-  }
-
-  public void setContent(Widget w) {
-    main.setContent(w);
-  }
-
-  public Widget getContent() {
-    return main.getContent();
-  }
-
-  @Override
-  public HandlerRegistration addOpenHandler(OpenHandler<DisclosurePanel> h) {
-    return main.addOpenHandler(h);
-  }
-
-  @Override
-  public HandlerRegistration addCloseHandler(CloseHandler<DisclosurePanel> h) {
-    return main.addCloseHandler(h);
-  }
-
-  /** @return true if the panel's content is visible. */
-  public boolean isOpen() {
-    return main.isOpen();
-  }
-
-  /**
-   * Changes the visible state of this panel's content.
-   *
-   * @param isOpen {@code true} to open, {@code false} to close
-   */
-  public void setOpen(boolean isOpen) {
-    main.setOpen(isOpen);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java
deleted file mode 100644
index 2d00281..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java
+++ /dev/null
@@ -1,116 +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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.projects.BranchInfo;
-import com.google.gerrit.client.projects.ProjectApi;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.SuggestBox;
-import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
-import com.google.gwt.user.client.ui.TextBox;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-import java.util.ArrayList;
-import java.util.List;
-
-public abstract class CreateChangeDialog extends TextAreaActionDialog {
-  private SuggestBox newChange;
-  private List<BranchInfo> branches;
-  private TextBox topic;
-
-  public CreateChangeDialog(Project.NameKey project) {
-    super(Util.C.dialogCreateChangeTitle(), Util.C.dialogCreateChangeHeading());
-    ProjectApi.getBranches(
-        project,
-        new GerritCallback<JsArray<BranchInfo>>() {
-          @Override
-          public void onSuccess(JsArray<BranchInfo> result) {
-            branches = Natives.asList(result);
-          }
-        });
-
-    topic = new TextBox();
-    topic.setWidth("100%");
-    topic.getElement().getStyle().setProperty("boxSizing", "border-box");
-    FlowPanel newTopicPanel = new FlowPanel();
-    newTopicPanel.setStyleName(Gerrit.RESOURCES.css().commentedActionMessage());
-    newTopicPanel.add(topic);
-    panel.insert(newTopicPanel, 0);
-    panel.insert(new SmallHeading(Util.C.newChangeTopicSuggestion()), 0);
-
-    newChange =
-        new SuggestBox(
-            new HighlightSuggestOracle() {
-              @Override
-              protected void onRequestSuggestions(Request request, Callback done) {
-                List<BranchSuggestion> suggestions = new ArrayList<>();
-                for (BranchInfo b : branches) {
-                  if (b.ref().contains(request.getQuery())) {
-                    suggestions.add(new BranchSuggestion(b));
-                  }
-                }
-                done.onSuggestionsReady(request, new Response(suggestions));
-              }
-            });
-
-    newChange.setWidth("100%");
-    newChange.getElement().getStyle().setProperty("boxSizing", "border-box");
-    FlowPanel newChangePanel = new FlowPanel();
-    newChangePanel.setStyleName(Gerrit.RESOURCES.css().commentedActionMessage());
-    newChangePanel.add(newChange);
-    panel.insert(newChangePanel, 0);
-    panel.insert(new SmallHeading(Util.C.newChangeBranchSuggestion()), 0);
-
-    message.setCharacterWidth(70);
-  }
-
-  @Override
-  public void center() {
-    super.center();
-    GlobalKey.dialog(this);
-    newChange.setFocus(true);
-  }
-
-  public String getDestinationBranch() {
-    return newChange.getText();
-  }
-
-  public String getDestinationTopic() {
-    return topic.getText();
-  }
-
-  static class BranchSuggestion implements Suggestion {
-    private BranchInfo branch;
-
-    BranchSuggestion(BranchInfo branch) {
-      this.branch = branch;
-    }
-
-    @Override
-    public String getDisplayString() {
-      return branch.getShortName();
-    }
-
-    @Override
-    public String getReplacementString() {
-      return branch.getShortName();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java
deleted file mode 100644
index 045e0ae..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java
+++ /dev/null
@@ -1,222 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Document;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlexTable;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import java.util.Comparator;
-import java.util.Iterator;
-
-public abstract class FancyFlexTable<RowItem> extends Composite {
-  private static final FancyFlexTableImpl impl = GWT.create(FancyFlexTableImpl.class);
-
-  protected static final int C_ARROW = 0;
-
-  protected final MyFlexTable table;
-
-  protected FancyFlexTable() {
-    table = createFlexTable();
-    table.addStyleName(Gerrit.RESOURCES.css().changeTable());
-    table.setWidth("100%");
-    initWidget(table);
-
-    table.setText(0, C_ARROW, "");
-    table.getCellFormatter().addStyleName(0, C_ARROW, Gerrit.RESOURCES.css().iconHeader());
-  }
-
-  protected MyFlexTable createFlexTable() {
-    return new MyFlexTable();
-  }
-
-  protected RowItem getRowItem(int row) {
-    return FancyFlexTable.<RowItem>getRowItem(table.getCellFormatter().getElement(row, 0));
-  }
-
-  protected void setRowItem(int row, RowItem item) {
-    setRowItem(table.getCellFormatter().getElement(row, 0), item);
-  }
-
-  /**
-   * Finds an item in the table.
-   *
-   * @param comparator comparator by which the items in the table are sorted
-   * @param item the item that should be found
-   * @return if the item is found the number of the row that contains the item; if the item is not
-   *     found {@code -1}
-   */
-  protected int findRowItem(Comparator<RowItem> comparator, RowItem item) {
-    int row = lookupRowItem(comparator, item);
-    if (row < table.getRowCount() && comparator.compare(item, getRowItem(row)) == 0) {
-      return row;
-    }
-    return -1;
-  }
-
-  /**
-   * Finds the number of the row where a new item should be inserted into the table.
-   *
-   * @param comparator comparator by which the items in the table are sorted
-   * @param item the new item that should be inserted
-   * @return if the item is not yet contained in the table, the number of the row where the new item
-   *     should be inserted; if the item is already contained in the table {@code -1}
-   */
-  protected int getInsertRow(Comparator<RowItem> comparator, RowItem item) {
-    int row = lookupRowItem(comparator, item);
-    if (row >= table.getRowCount() || comparator.compare(item, getRowItem(row)) != 0) {
-      return row;
-    }
-    return -1;
-  }
-
-  /**
-   * Makes a binary search for the given row item over the table.
-   *
-   * @param comparator comparator by which the items in the table are sorted
-   * @param item the item that should be looked up
-   * @return if the item is found the number of the row that contains the item; if the item is not
-   *     found the number of the row where the item should be inserted according to the given
-   *     comparator.
-   */
-  private int lookupRowItem(Comparator<RowItem> comparator, RowItem item) {
-    int left = 1;
-    int right = table.getRowCount() - 1;
-    while (left <= right) {
-      int middle = (left + right) >>> 1; // (left+right)/2
-      RowItem i = getRowItem(middle);
-      int cmp = comparator.compare(i, item);
-
-      if (cmp < 0) {
-        left = middle + 1;
-      } else if (cmp > 0) {
-        right = middle - 1;
-      } else {
-        // item is already contained in the table
-        return middle;
-      }
-    }
-    return left;
-  }
-
-  protected void resetHtml(SafeHtml body) {
-    for (Iterator<Widget> i = table.iterator(); i.hasNext(); ) {
-      i.next();
-      i.remove();
-    }
-    impl.resetHtml(table, body);
-  }
-
-  protected void scrollIntoView(int topRow, int endRow) {
-    final CellFormatter fmt = table.getCellFormatter();
-    final Element top = fmt.getElement(topRow, C_ARROW).getParentElement();
-    final Element end = fmt.getElement(endRow, C_ARROW).getParentElement();
-
-    final int rTop = top.getAbsoluteTop();
-    final int rEnd = end.getAbsoluteTop() + end.getOffsetHeight();
-    final int rHeight = rEnd - rTop;
-
-    final int sTop = Document.get().getScrollTop();
-    final int sHeight = Document.get().getClientHeight();
-    final int sEnd = sTop + sHeight;
-
-    final int nTop;
-    if (sHeight <= rHeight) {
-      // The region is larger than the visible area, make the top
-      // exactly the top of the region, its the most visible area.
-      //
-      nTop = rTop;
-    } else if (sTop <= rTop && rTop <= sEnd) {
-      // At least part of the region is already visible.
-      //
-      if (rEnd <= sEnd) {
-        // ... actually its all visible. Don't scroll.
-        //
-        return;
-      }
-
-      // Move only enough to make the end visible.
-      //
-      nTop = sTop + (rHeight - (sEnd - rTop));
-    } else {
-      // None of the region is visible. Make it visible.
-      //
-      nTop = rTop;
-    }
-    Document.get().setScrollTop(nTop);
-  }
-
-  protected void applyDataRowStyle(int newRow) {
-    table.getCellFormatter().addStyleName(newRow, C_ARROW, Gerrit.RESOURCES.css().iconCell());
-    table.getCellFormatter().addStyleName(newRow, C_ARROW, Gerrit.RESOURCES.css().leftMostCell());
-  }
-
-  /**
-   * Get the td element that contains another element.
-   *
-   * @param target the child element whose parent td is required.
-   * @return the td containing element {@code target}; null if {@code target} is not a member of
-   *     this table.
-   */
-  protected Element getParentCell(Element target) {
-    final Element body = FancyFlexTableImpl.getBodyElement(table);
-    for (Element td = target; td != null && td != body; td = DOM.getParent(td)) {
-      // If it's a TD, it might be the one we're looking for.
-      if ("td".equalsIgnoreCase(td.getTagName())) {
-        // Make sure it's directly a part of this table.
-        Element tr = DOM.getParent(td);
-        if (DOM.getParent(tr) == body) {
-          return td;
-        }
-      }
-    }
-    return null;
-  }
-
-  /** @return the row of the child element; -1 if the child is not in the table. */
-  protected int rowOf(Element target) {
-    final Element td = getParentCell(target);
-    if (td == null) {
-      return -1;
-    }
-    final Element tr = DOM.getParent(td);
-    final Element body = DOM.getParent(tr);
-    return DOM.getChildIndex(body, tr);
-  }
-
-  /** @return the cell of the child element; -1 if the child is not in the table. */
-  protected int columnOf(Element target) {
-    final Element td = getParentCell(target);
-    if (td == null) {
-      return -1;
-    }
-    final Element tr = DOM.getParent(td);
-    return DOM.getChildIndex(tr, td);
-  }
-
-  protected static class MyFlexTable extends FlexTable {}
-
-  private static native <ItemType> void setRowItem(Element td, ItemType c)
-      /*-{ td['__gerritRowItem'] = c; }-*/ ;
-
-  private static native <ItemType> ItemType getRowItem(Element td)
-      /*-{ return td['__gerritRowItem']; }-*/ ;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java
deleted file mode 100644
index a3a2a7a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.user.client.ui.FlexTable;
-import com.google.gwt.user.client.ui.HTMLTable;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-
-public class FancyFlexTableImpl {
-  public void resetHtml(FlexTable myTable, SafeHtml body) {
-    SafeHtml.setInnerHTML(getBodyElement(myTable), body);
-  }
-
-  protected static native Element getBodyElement(HTMLTable myTable)
-      /*-{ return myTable.@com.google.gwt.user.client.ui.HTMLTable::bodyElem; }-*/ ;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java
deleted file mode 100644
index 3eae0f8..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.FlexTable;
-import com.google.gwt.user.client.ui.HTMLTable;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-public class FancyFlexTableImplIE8 extends FancyFlexTableImpl {
-  @Override
-  public void resetHtml(FlexTable myTable, SafeHtml bodyHtml) {
-    final Element oldBody = getBodyElement(myTable);
-    final Element newBody = parseBody(bodyHtml);
-    assert newBody != null;
-
-    final Element tableElem = DOM.getParent(oldBody);
-    tableElem.removeChild(oldBody);
-    setBodyElement(myTable, newBody);
-    DOM.appendChild(tableElem, newBody);
-  }
-
-  private static Element parseBody(SafeHtml body) {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    b.openElement("table");
-    b.append(body);
-    b.closeElement("table");
-
-    final Element newTable = SafeHtml.parse(b);
-    for (Element e = DOM.getFirstChild(newTable); e != null; e = DOM.getNextSibling(e)) {
-      if ("tbody".equals(e.getTagName().toLowerCase())) {
-        return e;
-      }
-    }
-    return null;
-  }
-
-  private static native void setBodyElement(HTMLTable myTable, Element newBody)
-      /*-{ myTable.@com.google.gwt.user.client.ui.HTMLTable::bodyElem = newBody; }-*/ ;
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java
deleted file mode 100644
index f8e382a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-public class HighlightingInlineHyperlink extends InlineHyperlink {
-
-  private String toHighlight;
-
-  public HighlightingInlineHyperlink(final String text, String token, String toHighlight) {
-    super(text, token);
-    this.toHighlight = toHighlight;
-    highlight(text, toHighlight);
-  }
-
-  @Override
-  public void setText(String text) {
-    super.setText(text);
-    highlight(text, toHighlight);
-  }
-
-  private void highlight(String text, String toHighlight) {
-    setHTML(Util.highlight(text, toHighlight));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java
deleted file mode 100644
index 1e3be3f..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.projects.ProjectInfo;
-import com.google.gerrit.client.projects.ProjectMap;
-import com.google.gwt.user.client.ui.InlineHTML;
-
-public class HighlightingProjectsTable extends ProjectsTable {
-  private String toHighlight;
-
-  public void display(ProjectMap projects, String toHighlight) {
-    this.toHighlight = toHighlight;
-    super.display(projects);
-  }
-
-  @Override
-  protected void populate(int row, ProjectInfo k) {
-    populateState(row, k);
-    table.setWidget(
-        row, ProjectsTable.C_NAME, new InlineHTML(Util.highlight(k.name(), toHighlight)));
-    table.setText(row, ProjectsTable.C_DESCRIPTION, k.description());
-
-    setRowItem(row, k);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java
deleted file mode 100644
index 4ccfe9d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java
+++ /dev/null
@@ -1,218 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.event.dom.client.BlurEvent;
-import com.google.gwt.event.dom.client.BlurHandler;
-import com.google.gwt.event.dom.client.FocusEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.ui.SuggestBox;
-import com.google.gwt.user.client.ui.SuggestBox.DefaultSuggestionDisplay;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-
-public class HintTextBox extends NpTextBox {
-  private HandlerRegistration hintFocusHandler;
-  private HandlerRegistration hintBlurHandler;
-  private HandlerRegistration keyDownHandler;
-
-  private String hintText;
-  private String hintStyleName = Gerrit.RESOURCES.css().inputFieldTypeHint();
-
-  private String prevText;
-
-  private boolean hintOn;
-  private boolean isFocused;
-
-  @Override
-  public String getText() {
-    if (hintOn) {
-      return "";
-    }
-    return super.getText();
-  }
-
-  @Override
-  public void setText(String text) {
-    focusHint();
-
-    super.setText(text);
-    prevText = text;
-
-    if (!isFocused) {
-      blurHint();
-    }
-  }
-
-  public String getHintText() {
-    return hintText;
-  }
-
-  public void setHintText(String text) {
-    if (text == null) {
-      if (hintText == null) { // was not set, still not set, no change.
-        return;
-      }
-
-      // Clearing a previously set Hint
-      hintFocusHandler.removeHandler();
-      hintFocusHandler = null;
-      hintBlurHandler.removeHandler();
-      hintBlurHandler = null;
-      keyDownHandler.removeHandler();
-      keyDownHandler = null;
-      hintText = null;
-      focusHint();
-
-      return;
-    }
-
-    // Setting Hints
-
-    if (hintText == null) { // first time (was not already set)
-      hintText = text;
-
-      hintFocusHandler =
-          addFocusHandler(
-              new FocusHandler() {
-                @Override
-                public void onFocus(FocusEvent event) {
-                  focusHint();
-                  prevText = getText();
-                  isFocused = true;
-                }
-              });
-
-      hintBlurHandler =
-          addBlurHandler(
-              new BlurHandler() {
-                @Override
-                public void onBlur(BlurEvent event) {
-                  blurHint();
-                  isFocused = false;
-                }
-              });
-
-      /*
-       * There seems to be a strange bug (at least on firefox 3.5.9 ubuntu) with
-       * the textbox under the following circumstances:
-       *  1) The field is not focused with BText in it.
-       *  2) The field receives focus and a focus listener changes the text to FText
-       *  3) The ESC key is pressed and the value of the field has not changed
-       *     (ever) from FText
-       *  4) BUG: The text value gets reset to BText!
-       *
-       *  A counter to this bug seems to be to force setFocus(false) on ESC.
-       */
-
-      /* Chrome does not create a KeyPressEvent on ESC, so use KeyDownEvents */
-      keyDownHandler =
-          addKeyDownHandler(
-              new KeyDownHandler() {
-                @Override
-                public void onKeyDown(KeyDownEvent event) {
-                  onKey(event.getNativeKeyCode());
-                }
-              });
-
-    } else { // Changing an already set Hint
-
-      focusHint();
-      hintText = text;
-    }
-
-    if (!isFocused) {
-      blurHint();
-    }
-  }
-
-  private void onKey(int key) {
-    if (key == KeyCodes.KEY_ESCAPE) {
-      setText(prevText);
-
-      Widget p = getParent();
-      if (p instanceof SuggestBox) {
-        // Since the text was changed, ensure that the SuggestBox is
-        // aware of this change so that it will refresh properly on
-        // the next keystroke.  Without this, if the first keystroke
-        // recreates the same string as before ESC was pressed, the
-        // SuggestBox will think that the string has not changed, and
-        // it will not yet provide any Suggestions.
-        ((SuggestBox) p).showSuggestionList();
-
-        // The suggestion list lingers if we don't hide it.
-        ((DefaultSuggestionDisplay) ((SuggestBox) p).getSuggestionDisplay()).hideSuggestions();
-      }
-
-      setFocus(false);
-    }
-  }
-
-  public void setHintStyleName(String styleName) {
-    if (hintStyleName != null && hintOn) {
-      removeStyleName(hintStyleName);
-    }
-
-    hintStyleName = styleName;
-
-    if (styleName != null && hintOn) {
-      addStyleName(styleName);
-    }
-  }
-
-  public String getHintStyleName() {
-    return hintStyleName;
-  }
-
-  protected void blurHint() {
-    if (!hintOn && getHintText() != null && "".equals(super.getText())) {
-      hintOn = true;
-      super.setText(getHintText());
-      if (getHintStyleName() != null) {
-        addStyleName(getHintStyleName());
-      }
-    }
-  }
-
-  protected void focusHint() {
-    if (hintOn) {
-      super.setText("");
-      if (getHintStyleName() != null) {
-        removeStyleName(getHintStyleName());
-      }
-      hintOn = false;
-    }
-  }
-
-  @Override
-  public void setFocus(boolean focus) {
-    super.setFocus(focus);
-
-    if (focus != isFocused) {
-      if (focus) {
-        focusHint();
-      } else {
-        blurHint();
-      }
-    }
-
-    isFocused = focus;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java
deleted file mode 100644
index c35d097..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.impl.HyperlinkImpl;
-
-/** Standard GWT hyperlink with late updating of the token. */
-public class Hyperlink extends com.google.gwt.user.client.ui.Hyperlink {
-  public static final HyperlinkImpl impl = GWT.create(HyperlinkImpl.class);
-
-  /** Initialize a default hyperlink with no target and no text. */
-  public Hyperlink() {}
-
-  /**
-   * Creates a hyperlink with its text and target history token specified.
-   *
-   * @param text the hyperlink's text
-   * @param token the history token to which it will link, which may not be null (use {@link Anchor}
-   *     instead if you don't need history processing)
-   */
-  public Hyperlink(String text, String token) {
-    super(text, token);
-  }
-
-  /**
-   * Creates a hyperlink with its text and target history token specified.
-   *
-   * @param text the hyperlink's text
-   * @param asHTML {@code true} to treat the specified text as html
-   * @param token the history token to which it will link
-   * @see #setTargetHistoryToken
-   */
-  public Hyperlink(String text, boolean asHTML, String token) {
-    super(text, asHTML, token);
-  }
-
-  @Override
-  public void onBrowserEvent(Event event) {
-    if (DOM.eventGetType(event) == Event.ONCLICK && impl.handleAsClick(event)) {
-      event.preventDefault();
-      go();
-    } else {
-      super.onBrowserEvent(event);
-    }
-  }
-
-  /** Create the screen and start rendering, updating the browser history. */
-  public void go() {
-    Gerrit.display(getTargetHistoryToken());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java
deleted file mode 100644
index a4edb5b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import static com.google.gerrit.client.ui.Hyperlink.impl;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-
-/** Standard GWT hyperlink with late updating of the token. */
-public class InlineHyperlink extends com.google.gwt.user.client.ui.InlineHyperlink {
-  /**
-   * Creates a link with its text and target history token specified.
-   *
-   * @param text the hyperlink's text
-   * @param token the history token to which it will link
-   */
-  public InlineHyperlink(String text, String token) {
-    super(text, token);
-  }
-
-  /** Creates an empty link. */
-  public InlineHyperlink() {}
-
-  @Override
-  public void onBrowserEvent(Event event) {
-    if (DOM.eventGetType(event) == Event.ONCLICK && impl.handleAsClick(event)) {
-      event.preventDefault();
-      go();
-    } else {
-      super.onBrowserEvent(event);
-    }
-  }
-
-  /** Create the screen and start rendering, updating the browser history. */
-  public void go() {
-    Gerrit.display(getTargetHistoryToken());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java
deleted file mode 100644
index d3db098..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.aria.client.Roles;
-import com.google.gwt.user.client.Command;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Widget;
-
-public class LinkMenuBar extends Composite implements ScreenLoadHandler {
-  private final FlowPanel body;
-
-  public LinkMenuBar() {
-    body = new FlowPanel();
-    initWidget(body);
-    setStyleName(Gerrit.RESOURCES.css().linkMenuBar());
-    Roles.getMenubarRole().set(getElement());
-    Gerrit.EVENT_BUS.addHandler(ScreenLoadEvent.TYPE, this);
-  }
-
-  public void addItem(String text, Command imp) {
-    add(new CommandMenuItem(text, imp));
-  }
-
-  public void addItem(CommandMenuItem i) {
-    add(i);
-  }
-
-  public void addItem(LinkMenuItem i) {
-    i.setMenuBar(this);
-    add(i);
-  }
-
-  public void insertItem(LinkMenuItem i, int beforeIndex) {
-    i.setMenuBar(this);
-    insert(i, beforeIndex);
-  }
-
-  public void clear() {
-    body.clear();
-  }
-
-  public LinkMenuItem find(String targetToken) {
-    for (Widget w : body) {
-      if (w instanceof LinkMenuItem) {
-        LinkMenuItem m = (LinkMenuItem) w;
-        if (targetToken.equals(m.getTargetHistoryToken())) {
-          return m;
-        }
-      }
-    }
-    return null;
-  }
-
-  public void add(Widget i) {
-    if (body.getWidgetCount() > 0) {
-      final Widget p = body.getWidget(body.getWidgetCount() - 1);
-      p.addStyleName(Gerrit.RESOURCES.css().linkMenuItemNotLast());
-    }
-    body.add(i);
-  }
-
-  public void insert(Widget i, int beforeIndex) {
-    if (body.getWidgetCount() == 0 || body.getWidgetCount() <= beforeIndex) {
-      add(i);
-      return;
-    }
-    body.insert(i, beforeIndex);
-  }
-
-  public int getWidgetIndex(Widget i) {
-    return body.getWidgetIndex(i);
-  }
-
-  @Override
-  public void onScreenLoad(ScreenLoadEvent event) {}
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuItem.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuItem.java
deleted file mode 100644
index 8a8ab25..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuItem.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.aria.client.Roles;
-import com.google.gwt.dom.client.AnchorElement;
-
-public class LinkMenuItem extends InlineHyperlink implements ScreenLoadHandler {
-  private LinkMenuBar bar;
-
-  public LinkMenuItem(String text, String targetHistoryToken) {
-    super(text, targetHistoryToken);
-    setStyleName(Gerrit.RESOURCES.css().menuItem());
-    Roles.getMenuitemRole().set(getElement());
-    Gerrit.EVENT_BUS.addHandler(ScreenLoadEvent.TYPE, this);
-  }
-
-  @Override
-  public void go() {
-    super.go();
-    AnchorElement.as(getElement()).blur();
-  }
-
-  public void setMenuBar(LinkMenuBar bar) {
-    this.bar = bar;
-  }
-
-  @Override
-  public void onScreenLoad(ScreenLoadEvent event) {
-    if (match(event.getScreen().getToken())) {
-      Gerrit.selectMenu(bar);
-      addStyleName(Gerrit.RESOURCES.css().activeRow());
-    } else {
-      removeStyleName(Gerrit.RESOURCES.css().activeRow());
-    }
-  }
-
-  protected boolean match(String token) {
-    return token.equals(getTargetHistoryToken());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java
deleted file mode 100644
index 0f28ddc..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.Widget;
-
-public abstract class MenuScreen extends Screen {
-  private final LinkMenuBar menu;
-  private final FlowPanel body;
-
-  public MenuScreen() {
-    menu = new LinkMenuBar();
-    menu.setStyleName(Gerrit.RESOURCES.css().menuScreenMenuBar());
-    body = new FlowPanel();
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    HorizontalPanel hp = new HorizontalPanel();
-    hp.add(menu);
-    hp.add(body);
-    super.add(hp);
-  }
-
-  @Override
-  public void setToken(String token) {
-    LinkMenuItem self = menu.find(token);
-    if (self != null) {
-      self.addStyleName(Gerrit.RESOURCES.css().activeRow());
-    }
-    super.setToken(token);
-  }
-
-  @Override
-  protected FlowPanel getBody() {
-    return body;
-  }
-
-  @Override
-  protected void add(Widget w) {
-    body.add(w);
-  }
-
-  protected void link(String text, String target) {
-    link(text, target, true);
-  }
-
-  protected void link(String text, String target, boolean visible) {
-    final LinkMenuItem item = new LinkMenuItem(text, target);
-    item.setStyleName(Gerrit.RESOURCES.css().menuItem());
-    item.setVisible(visible);
-    menu.add(item);
-  }
-
-  protected void setLinkVisible(String token, boolean visible) {
-    final LinkMenuItem item = menu.find(token);
-    item.setVisible(visible);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java
deleted file mode 100644
index 7fd6432..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java
+++ /dev/null
@@ -1,101 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.user.client.ui.TabPanel;
-import com.google.gwt.user.client.ui.Widget;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * A TabPanel which allows entries to be hidden. This class is not yet designed to handle removes or
- * any other add methods than the one overridden here. It is also not designed to handle anything
- * other than text for the tab.
- */
-public class MorphingTabPanel extends TabPanel {
-  // Keep track of the order the widgets/texts should be in when not hidden.
-  private List<Widget> widgets = new ArrayList<>();
-  private List<String> texts = new ArrayList<>();
-
-  // currently visible widgets
-  private List<Widget> visibles = new ArrayList<>();
-
-  private int selection;
-
-  public MorphingTabPanel() {
-    addSelectionHandler(
-        new SelectionHandler<Integer>() {
-          @Override
-          public void onSelection(SelectionEvent<Integer> ev) {
-            selection = ev.getSelectedItem();
-          }
-        });
-  }
-
-  public int getSelectedIndex() {
-    return selection;
-  }
-
-  public Widget getSelectedWidget() {
-    return getWidget(getSelectedIndex());
-  }
-
-  @Override
-  public void clear() {
-    super.clear();
-    widgets.clear();
-    texts.clear();
-    visibles.clear();
-  }
-
-  @Override
-  public void add(Widget w, String tabText) {
-    addInvisible(w, tabText);
-    visibles.add(w);
-    super.add(w, tabText);
-  }
-
-  public void addInvisible(Widget w, String tabText) {
-    widgets.add(w);
-    texts.add(tabText);
-  }
-
-  public void setVisible(Widget w, boolean visible) {
-    if (visible) {
-      if (!visibles.contains(w)) {
-        int origPos = widgets.indexOf(w);
-
-        /* Re-insert the widget right after the first visible widget found
-        when scanning backwards from the current widget */
-        for (int pos = origPos - 1; pos >= 0; pos--) {
-          int visiblePos = visibles.indexOf(widgets.get(pos));
-          if (visiblePos != -1) {
-            visibles.add(visiblePos + 1, w);
-            insert(w, texts.get(origPos), visiblePos + 1);
-            break;
-          }
-        }
-      }
-    } else {
-      int i = visibles.indexOf(w);
-      if (i != -1) {
-        visibles.remove(i);
-        remove(i);
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MoveDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MoveDialog.java
deleted file mode 100644
index 3821e93..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MoveDialog.java
+++ /dev/null
@@ -1,107 +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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.projects.BranchInfo;
-import com.google.gerrit.client.projects.ProjectApi;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.SuggestBox;
-import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-import java.util.ArrayList;
-import java.util.List;
-
-public abstract class MoveDialog extends TextAreaActionDialog {
-  private SuggestBox newBranch;
-  private List<BranchInfo> branches;
-
-  public MoveDialog(Project.NameKey project) {
-    super(Util.C.moveTitle(), Util.C.moveChangeMessage());
-    ProjectApi.getBranches(
-        project,
-        new GerritCallback<JsArray<BranchInfo>>() {
-          @Override
-          public void onSuccess(JsArray<BranchInfo> result) {
-            branches = Natives.asList(result);
-          }
-        });
-
-    newBranch =
-        new SuggestBox(
-            new HighlightSuggestOracle() {
-              @Override
-              protected void onRequestSuggestions(Request request, Callback done) {
-                List<BranchSuggestion> suggestions = new ArrayList<>();
-                for (BranchInfo b : branches) {
-                  if (b.ref().contains(request.getQuery())) {
-                    suggestions.add(new BranchSuggestion(b));
-                  }
-                }
-                done.onSuggestionsReady(request, new Response(suggestions));
-              }
-            });
-
-    newBranch.setWidth("100%");
-    newBranch.getElement().getStyle().setProperty("boxSizing", "border-box");
-    message.setCharacterWidth(70);
-
-    FlowPanel mwrap = new FlowPanel();
-    mwrap.setStyleName(Gerrit.RESOURCES.css().commentedActionMessage());
-    mwrap.add(newBranch);
-
-    panel.insert(mwrap, 0);
-    panel.insert(new SmallHeading(Util.C.headingMoveBranch()), 0);
-  }
-
-  @Override
-  public void center() {
-    super.center();
-    GlobalKey.dialog(this);
-    newBranch.setFocus(true);
-  }
-
-  public String getDestinationBranch() {
-    return newBranch.getText();
-  }
-
-  static class BranchSuggestion implements Suggestion {
-    private BranchInfo branch;
-
-    BranchSuggestion(BranchInfo branch) {
-      this.branch = branch;
-    }
-
-    @Override
-    public String getDisplayString() {
-      String refsHeads = "refs/heads/";
-      if (branch.ref().startsWith(refsHeads)) {
-        return branch.ref().substring(refsHeads.length());
-      }
-      return branch.ref();
-    }
-
-    @Override
-    public String getReplacementString() {
-      return branch.getShortName();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
deleted file mode 100644
index 7e34730..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
+++ /dev/null
@@ -1,407 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.dom.client.Document;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.ScrollPanel;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import java.util.LinkedHashMap;
-import java.util.Map.Entry;
-
-public abstract class NavigationTable<RowItem> extends FancyFlexTable<RowItem> {
-  protected class MyFlexTable extends FancyFlexTable.MyFlexTable {
-    public MyFlexTable() {
-      sinkEvents(Event.ONDBLCLICK | Event.ONCLICK);
-    }
-
-    @Override
-    public void onBrowserEvent(Event event) {
-      switch (DOM.eventGetType(event)) {
-        case Event.ONCLICK:
-          {
-            // Find out which cell was actually clicked.
-            final Element td = getEventTargetCell(event);
-            if (td == null) {
-              break;
-            }
-            final int row = rowOf(td);
-            if (getRowItem(row) != null) {
-              onCellSingleClick(event, rowOf(td), columnOf(td));
-              return;
-            }
-            break;
-          }
-        case Event.ONDBLCLICK:
-          {
-            // Find out which cell was actually clicked.
-            Element td = getEventTargetCell(event);
-            if (td == null) {
-              return;
-            }
-            onCellDoubleClick(rowOf(td), columnOf(td));
-            return;
-          }
-      }
-      super.onBrowserEvent(event);
-    }
-  }
-
-  @SuppressWarnings("serial")
-  private static final LinkedHashMap<String, Object> savedPositions =
-      new LinkedHashMap<String, Object>(10, 0.75f, true) {
-        @Override
-        protected boolean removeEldestEntry(Entry<String, Object> eldest) {
-          return size() >= 20;
-        }
-      };
-
-  private final Image pointer;
-  protected final KeyCommandSet keysNavigation;
-  protected final KeyCommandSet keysAction;
-  private HandlerRegistration regNavigation;
-  private HandlerRegistration regAction;
-  private int currentRow = -1;
-  private String saveId;
-
-  private boolean computedScrollType;
-  private ScrollPanel parentScrollPanel;
-
-  protected NavigationTable(String itemHelpName) {
-    this();
-    keysNavigation.add(
-        new PrevKeyCommand(0, 'k', Util.M.helpListPrev(itemHelpName)),
-        new NextKeyCommand(0, 'j', Util.M.helpListNext(itemHelpName)));
-    keysNavigation.add(new OpenKeyCommand(0, 'o', Util.M.helpListOpen(itemHelpName)));
-    keysNavigation.add(
-        new OpenKeyCommand(0, KeyCodes.KEY_ENTER, Util.M.helpListOpen(itemHelpName)));
-  }
-
-  protected NavigationTable() {
-    pointer = new Image(Gerrit.RESOURCES.arrowRight());
-    keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
-    keysAction = new KeyCommandSet(Gerrit.C.sectionActions());
-  }
-
-  protected abstract void onOpenRow(int row);
-
-  protected abstract Object getRowItemKey(RowItem item);
-
-  private void onUp() {
-    for (int row = currentRow - 1; row >= 0; row--) {
-      if (getRowItem(row) != null) {
-        movePointerTo(row);
-        break;
-      }
-    }
-  }
-
-  private void onDown() {
-    final int max = table.getRowCount();
-    for (int row = currentRow + 1; row < max; row++) {
-      if (getRowItem(row) != null) {
-        movePointerTo(row);
-        break;
-      }
-    }
-  }
-
-  private void onOpen() {
-    if (0 <= currentRow && currentRow < table.getRowCount()) {
-      if (getRowItem(currentRow) != null) {
-        onOpenRow(currentRow);
-      }
-    }
-  }
-
-  /**
-   * Invoked when the user double clicks on a table cell.
-   *
-   * @param row row number.
-   * @param column column number.
-   */
-  protected void onCellDoubleClick(int row, int column) {
-    onOpenRow(row);
-  }
-
-  /**
-   * Invoked when the user clicks on a table cell.
-   *
-   * @param event click event.
-   * @param row row number.
-   * @param column column number.
-   */
-  protected void onCellSingleClick(Event event, int row, int column) {
-    movePointerTo(row);
-  }
-
-  protected int getCurrentRow() {
-    return currentRow;
-  }
-
-  protected void ensurePointerVisible() {
-    final int max = table.getRowCount();
-    int row = currentRow;
-    final int init = row;
-    if (row < 0) {
-      row = 0;
-    } else if (max <= row) {
-      row = max - 1;
-    }
-
-    final CellFormatter fmt = table.getCellFormatter();
-    final int sTop = Document.get().getScrollTop();
-    final int sEnd = sTop + Document.get().getClientHeight();
-
-    while (0 <= row && row < max) {
-      final Element cur = fmt.getElement(row, C_ARROW).getParentElement();
-      final int cTop = cur.getAbsoluteTop();
-      final int cEnd = cTop + cur.getOffsetHeight();
-
-      if (cEnd < sTop) {
-        row++;
-      } else if (sEnd < cTop) {
-        row--;
-      } else {
-        break;
-      }
-    }
-
-    if (init != row) {
-      movePointerTo(row, false);
-    }
-  }
-
-  protected void movePointerTo(int newRow) {
-    movePointerTo(newRow, true);
-  }
-
-  protected void movePointerTo(int newRow, boolean scroll) {
-    final CellFormatter fmt = table.getCellFormatter();
-    final boolean clear = 0 <= currentRow && currentRow < table.getRowCount();
-    if (clear) {
-      final Element tr = fmt.getElement(currentRow, C_ARROW).getParentElement();
-      UIObject.setStyleName(tr, Gerrit.RESOURCES.css().activeRow(), false);
-    }
-    if (0 <= newRow && newRow < table.getRowCount() && getRowItem(newRow) != null) {
-      table.setWidget(newRow, C_ARROW, pointer);
-      final Element tr = fmt.getElement(newRow, C_ARROW).getParentElement();
-      UIObject.setStyleName(tr, Gerrit.RESOURCES.css().activeRow(), true);
-      if (scroll && isAttached()) {
-        scrollIntoView(tr);
-      }
-    } else if (clear) {
-      table.setWidget(currentRow, C_ARROW, null);
-      pointer.removeFromParent();
-    }
-    currentRow = newRow;
-  }
-
-  protected void scrollIntoView(Element tr) {
-    if (!computedScrollType) {
-      parentScrollPanel = null;
-      Widget w = getParent();
-      while (w != null) {
-        if (w instanceof ScrollPanel) {
-          parentScrollPanel = (ScrollPanel) w;
-          break;
-        }
-        w = w.getParent();
-      }
-      computedScrollType = true;
-    }
-
-    if (parentScrollPanel != null) {
-      parentScrollPanel.ensureVisible(
-          new UIObject() {
-            {
-              setElement(tr);
-            }
-          });
-    } else {
-      int rt = tr.getAbsoluteTop();
-      int rl = tr.getAbsoluteLeft();
-      int rb = tr.getAbsoluteBottom();
-
-      int wt = Window.getScrollTop();
-      int wl = Window.getScrollLeft();
-
-      int wh = Window.getClientHeight();
-      int ww = Window.getClientWidth();
-      int wb = wt + wh;
-
-      // If the row is partially or fully obscured, scroll:
-      //
-      // rl < wl: Row left edge is off screen to left.
-      // rt < wt: Row top is above top of window.
-      // wb < rt: Row top is below bottom of window.
-      // wb < rb: Row bottom is below bottom of window.
-      if (rl < wl || rt < wt || wb < rt || wb < rb) {
-        if (rl < wl) {
-          // Left edge needs to move to make it visible.
-          // If the row fully fits in the window, set 0.
-          if (tr.getAbsoluteRight() < ww) {
-            wl = 0;
-          } else {
-            wl = Math.max(tr.getAbsoluteLeft() - 5, 0);
-          }
-        }
-
-        // Vertically center the row in the window.
-        int h = (wh - (rb - rt)) / 2;
-        Window.scrollTo(wl, Math.max(rt - h, 0));
-      }
-    }
-  }
-
-  protected void movePointerTo(Object oldId) {
-    final int row = findRow(oldId);
-    if (0 <= row) {
-      movePointerTo(row);
-    }
-  }
-
-  protected int findRow(Object oldId) {
-    if (oldId != null) {
-      final int max = table.getRowCount();
-      for (int row = 0; row < max; row++) {
-        final RowItem c = getRowItem(row);
-        if (c != null && oldId.equals(getRowItemKey(c))) {
-          return row;
-        }
-      }
-    }
-    return -1;
-  }
-
-  @Override
-  public void resetHtml(SafeHtml body) {
-    currentRow = -1;
-    super.resetHtml(body);
-  }
-
-  public void finishDisplay() {
-    if (currentRow >= table.getRowCount()) {
-      currentRow = -1;
-    }
-    if (saveId != null) {
-      movePointerTo(savedPositions.get(saveId));
-    }
-    if (currentRow < 0) {
-      onDown();
-    }
-  }
-
-  public void setSavePointerId(String id) {
-    saveId = id;
-  }
-
-  public void setRegisterKeys(boolean on) {
-    if (on && isAttached()) {
-      if (regNavigation == null) {
-        regNavigation = GlobalKey.add(this, keysNavigation);
-      }
-      if (regAction == null) {
-        regAction = GlobalKey.add(this, keysAction);
-      }
-    } else {
-      if (regNavigation != null) {
-        regNavigation.removeHandler();
-        regNavigation = null;
-      }
-      if (regAction != null) {
-        regAction.removeHandler();
-        regAction = null;
-      }
-    }
-  }
-
-  @Override
-  protected void onLoad() {
-    computedScrollType = false;
-    parentScrollPanel = null;
-  }
-
-  @Override
-  protected void onUnload() {
-    setRegisterKeys(false);
-
-    if (saveId != null && currentRow >= 0) {
-      final RowItem c = getRowItem(currentRow);
-      if (c != null) {
-        savedPositions.put(saveId, getRowItemKey(c));
-      }
-    }
-
-    computedScrollType = false;
-    parentScrollPanel = null;
-    super.onUnload();
-  }
-
-  @Override
-  protected MyFlexTable createFlexTable() {
-    return new MyFlexTable();
-  }
-
-  public class PrevKeyCommand extends KeyCommand {
-    public PrevKeyCommand(int mask, char key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(KeyPressEvent event) {
-      ensurePointerVisible();
-      onUp();
-    }
-  }
-
-  public class NextKeyCommand extends KeyCommand {
-    public NextKeyCommand(int mask, char key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(KeyPressEvent event) {
-      ensurePointerVisible();
-      onDown();
-    }
-  }
-
-  public class OpenKeyCommand extends KeyCommand {
-    public OpenKeyCommand(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(KeyPressEvent event) {
-      ensurePointerVisible();
-      onOpen();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NeedsSignInKeyCommand.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NeedsSignInKeyCommand.java
deleted file mode 100644
index 4420762..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NeedsSignInKeyCommand.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-
-public abstract class NeedsSignInKeyCommand extends KeyCommand {
-  public NeedsSignInKeyCommand(int mask, int key, String help) {
-    super(mask, key, help);
-  }
-
-  public NeedsSignInKeyCommand(int mask, char key, String help) {
-    super(mask, key, help);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java
deleted file mode 100644
index 8be6647..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.dom.client.KeyEvent;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-
-/** Text box that accepts only integer values. */
-public class NpIntTextBox extends NpTextBox {
-  private int intValue;
-
-  public NpIntTextBox() {
-    init();
-  }
-
-  private void init() {
-    addKeyDownHandler(
-        new KeyDownHandler() {
-          @Override
-          public void onKeyDown(KeyDownEvent event) {
-            int code = event.getNativeKeyCode();
-            onKey(event, code, code);
-          }
-        });
-    addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            int charCode = event.getCharCode();
-            int keyCode = event.getNativeEvent().getKeyCode();
-            onKey(event, charCode, keyCode);
-          }
-        });
-  }
-
-  private void onKey(KeyEvent<?> event, int charCode, int keyCode) {
-    if ('0' <= charCode && charCode <= '9') {
-      if (event.isAnyModifierKeyDown()) {
-        event.preventDefault();
-      }
-    } else {
-      switch (keyCode) {
-        case KeyCodes.KEY_BACKSPACE:
-        case KeyCodes.KEY_LEFT:
-        case KeyCodes.KEY_RIGHT:
-        case KeyCodes.KEY_HOME:
-        case KeyCodes.KEY_END:
-        case KeyCodes.KEY_TAB:
-        case KeyCodes.KEY_DELETE:
-          break;
-
-        default:
-          // Allow copy and paste using ctl-c/ctrl-v,
-          // or whatever the platform's convention is.
-          if (!(event.isControlKeyDown() || event.isMetaKeyDown() || event.isAltKeyDown())) {
-            event.preventDefault();
-          }
-          break;
-      }
-    }
-  }
-
-  public int getIntValue() {
-    String txt = getText().trim();
-    if (!txt.isEmpty()) {
-      try {
-        intValue = Integer.parseInt(getText());
-      } catch (NumberFormatException e) {
-        // Ignored
-      }
-    }
-    return intValue;
-  }
-
-  public void setIntValue(int v) {
-    intValue = v;
-    setText(Integer.toString(v));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java
deleted file mode 100644
index 2c7fcd4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java
+++ /dev/null
@@ -1,192 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.event.dom.client.ChangeEvent;
-import com.google.gwt.event.dom.client.ChangeHandler;
-import com.google.gwt.event.dom.client.FocusEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.dom.client.MouseUpEvent;
-import com.google.gwt.event.dom.client.MouseUpHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.event.shared.GwtEvent;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.FocusWidget;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.TextBoxBase;
-import com.google.gwt.user.client.ui.ValueBoxBase;
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * Enables a FocusWidget (e.g. a Button) if an edit is detected from any registered input widget.
- */
-public class OnEditEnabler
-    implements KeyPressHandler,
-        KeyDownHandler,
-        MouseUpHandler,
-        ChangeHandler,
-        ValueChangeHandler<Object> {
-
-  private final FocusWidget widget;
-  private Map<TextBoxBase, String> strings = new HashMap<>();
-  private String originalValue;
-
-  // The first parameter to the contructors must be the FocusWidget to enable,
-  // subsequent parameters are widgets to listenTo.
-
-  public OnEditEnabler(FocusWidget w, TextBoxBase tb) {
-    this(w);
-    originalValue = tb.getValue().trim();
-    listenTo(tb);
-  }
-
-  public OnEditEnabler(FocusWidget w, ListBox lb) {
-    this(w);
-    listenTo(lb);
-  }
-
-  public OnEditEnabler(FocusWidget w, CheckBox cb) {
-    this(w);
-    listenTo(cb);
-  }
-
-  public OnEditEnabler(FocusWidget w) {
-    widget = w;
-  }
-
-  public void updateOriginalValue(TextBoxBase tb) {
-    originalValue = tb.getValue().trim();
-  }
-
-  // Register input widgets to be listened to
-
-  public void listenTo(TextBoxBase tb) {
-    strings.put(tb, tb.getText().trim());
-    tb.addKeyPressHandler(this);
-
-    // Is there another way to capture middle button X11 pastes in browsers
-    // which do not yet support ONPASTE events (Firefox)?
-    tb.addMouseUpHandler(this);
-
-    // Resetting the "original text" on focus ensures that we are
-    // up to date with non-user updates of the text (calls to
-    // setText()...) and also up to date with user changes which
-    // occurred after enabling "widget".
-    tb.addFocusHandler(
-        new FocusHandler() {
-          @Override
-          public void onFocus(FocusEvent event) {
-            strings.put(tb, tb.getText().trim());
-          }
-        });
-
-    // CTRL-V Pastes in Chrome seem only detectable via BrowserEvents or
-    // KeyDownEvents, the latter is better.
-    tb.addKeyDownHandler(this);
-  }
-
-  public void listenTo(ListBox lb) {
-    lb.addChangeHandler(this);
-  }
-
-  @SuppressWarnings({"unchecked", "rawtypes"})
-  public void listenTo(CheckBox cb) {
-    cb.addValueChangeHandler((ValueChangeHandler) this);
-  }
-
-  // Handlers
-
-  @Override
-  public void onKeyPress(KeyPressEvent e) {
-    on(e);
-  }
-
-  @Override
-  public void onKeyDown(KeyDownEvent e) {
-    on(e);
-  }
-
-  @Override
-  public void onMouseUp(MouseUpEvent e) {
-    on(e);
-  }
-
-  @Override
-  public void onChange(ChangeEvent e) {
-    on(e);
-  }
-
-  @SuppressWarnings("rawtypes")
-  @Override
-  public void onValueChange(ValueChangeEvent e) {
-    on(e);
-  }
-
-  private void on(GwtEvent<?> e) {
-    if (widget.isEnabled()
-        || !(e.getSource() instanceof FocusWidget)
-        || !((FocusWidget) e.getSource()).isEnabled()) {
-      if (e.getSource() instanceof ValueBoxBase) {
-        final TextBoxBase box = ((TextBoxBase) e.getSource());
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    if (box.getValue().trim().equals(originalValue)) {
-                      widget.setEnabled(false);
-                    }
-                  }
-                });
-      }
-      return;
-    }
-
-    if (e.getSource() instanceof TextBoxBase) {
-      onTextBoxBase((TextBoxBase) e.getSource());
-    } else {
-      // For many widgets, we can assume that a change is an edit. If
-      // a widget does not work that way, it should be special cased
-      // above.
-      widget.setEnabled(true);
-    }
-  }
-
-  private void onTextBoxBase(TextBoxBase tb) {
-    // The text appears to not get updated until the handlers complete.
-    Scheduler.get()
-        .scheduleDeferred(
-            new ScheduledCommand() {
-              @Override
-              public void execute() {
-                String orig = strings.get(tb);
-                if (orig == null) {
-                  orig = "";
-                }
-                if (!orig.equals(tb.getText().trim())) {
-                  widget.setEnabled(true);
-                }
-              }
-            });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PagingHyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PagingHyperlink.java
deleted file mode 100644
index 7f0ef68..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PagingHyperlink.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.admin.AdminConstants;
-
-public class PagingHyperlink extends Hyperlink {
-
-  public static PagingHyperlink createPrev() {
-    return new PagingHyperlink(AdminConstants.I.pagedListPrev());
-  }
-
-  public static PagingHyperlink createNext() {
-    return new PagingHyperlink(AdminConstants.I.pagedListNext());
-  }
-
-  private PagingHyperlink(String text) {
-    super(text, true, "");
-    setStyleName(Gerrit.RESOURCES.css().pagingLink());
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java
deleted file mode 100644
index 7c45a20..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.projects.ProjectApi;
-import com.google.gerrit.client.projects.ProjectInfo;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Composite;
-import java.util.HashSet;
-import java.util.Set;
-
-public class ParentProjectBox extends Composite {
-  private final RemoteSuggestBox suggestBox;
-  private final ParentProjectNameSuggestOracle suggestOracle;
-
-  public ParentProjectBox() {
-    suggestOracle = new ParentProjectNameSuggestOracle();
-    suggestBox = new RemoteSuggestBox(suggestOracle);
-    initWidget(suggestBox);
-  }
-
-  public void setVisibleLength(int len) {
-    suggestBox.setVisibleLength(len);
-  }
-
-  public void setProject(Project.NameKey project) {
-    suggestOracle.setProject(project);
-  }
-
-  public void setParentProject(Project.NameKey parent) {
-    suggestBox.setText(parent != null ? parent.get() : "");
-  }
-
-  public Project.NameKey getParentProjectName() {
-    final String projectName = suggestBox.getText().trim();
-    if (projectName.isEmpty()) {
-      return null;
-    }
-    return new Project.NameKey(projectName);
-  }
-
-  private static class ParentProjectNameSuggestOracle extends ProjectNameSuggestOracle {
-    private Set<String> exclude = new HashSet<>();
-
-    public void setProject(Project.NameKey project) {
-      exclude.clear();
-      exclude.add(project.get());
-      ProjectApi.getChildren(
-          project,
-          true,
-          new AsyncCallback<JsArray<ProjectInfo>>() {
-            @Override
-            public void onSuccess(JsArray<ProjectInfo> result) {
-              for (ProjectInfo p : Natives.asList(result)) {
-                exclude.add(p.name());
-              }
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {}
-          });
-    }
-
-    @Override
-    public void _onRequestSuggestions(Request req, Callback callback) {
-      super._onRequestSuggestions(
-          req,
-          new Callback() {
-            @Override
-            public void onSuggestionsReady(Request request, Response response) {
-              if (exclude.size() > 0) {
-                Set<Suggestion> filteredSuggestions = new HashSet<>(response.getSuggestions());
-                for (Suggestion s : response.getSuggestions()) {
-                  if (exclude.contains(s.getReplacementString())) {
-                    filteredSuggestions.remove(s);
-                  }
-                }
-                response.setSuggestions(filteredSuggestions);
-              }
-              callback.onSuggestionsReady(request, response);
-            }
-          });
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLink.java
deleted file mode 100644
index c5b609a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLink.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Project;
-
-/** Link to the default dashboard of a project. */
-public class ProjectLink extends InlineHyperlink {
-  public ProjectLink(Project.NameKey proj) {
-    this(proj.get(), proj);
-  }
-
-  public ProjectLink(String text, Project.NameKey proj) {
-    super(text, PageLinks.toProjectDefaultDashboard(proj));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLinkMenuItem.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLinkMenuItem.java
deleted file mode 100644
index 114f794..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLinkMenuItem.java
+++ /dev/null
@@ -1,51 +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.client.ui;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.admin.ProjectScreen;
-import com.google.gerrit.reviewdb.client.Project;
-
-public class ProjectLinkMenuItem extends LinkMenuItem {
-  protected final String panel;
-
-  public ProjectLinkMenuItem(String text, String panel) {
-    super(text, "");
-    this.panel = panel;
-  }
-
-  @Override
-  public void onScreenLoad(ScreenLoadEvent event) {
-    Screen screen = event.getScreen();
-    Project.NameKey projectKey;
-    if (screen instanceof ProjectScreen) {
-      projectKey = ((ProjectScreen) screen).getProjectKey();
-    } else {
-      projectKey = ProjectScreen.getSavedKey();
-    }
-
-    if (projectKey != null) {
-      setVisible(true);
-      onScreenLoad(projectKey);
-    } else {
-      setVisible(false);
-    }
-    super.onScreenLoad(event);
-  }
-
-  protected void onScreenLoad(Project.NameKey project) {
-    setTargetHistoryToken(Dispatcher.toProjectAdmin(project, panel));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
deleted file mode 100644
index 89bff71..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
+++ /dev/null
@@ -1,233 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.Util;
-import com.google.gerrit.client.projects.ProjectMap;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyUpEvent;
-import com.google.gwt.event.dom.client.KeyUpHandler;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.DialogBox;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.ScrollPanel;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.HidePopupPanelCommand;
-import com.google.gwtexpui.globalkey.client.NpTextBox;
-
-/** A popup containing all projects. */
-public class ProjectListPopup {
-  private HighlightingProjectsTable projectsTab;
-  private DialogBox popup;
-  private NpTextBox filterTxt;
-  private HorizontalPanel filterPanel;
-  private String match;
-  private Query query;
-  private Button closeTop;
-  private Button closeBottom;
-  private ScrollPanel sp;
-  private PopupPanel.PositionCallback popupPosition;
-  private int preferredTop;
-  private int preferredLeft;
-  private boolean poppingUp;
-  private boolean firstPopupLoad = true;
-
-  public void initPopup(String popupText, String currentPageLink) {
-    createWidgets(popupText, currentPageLink);
-    final FlowPanel pfp = new FlowPanel();
-    pfp.add(filterPanel);
-    pfp.add(closeTop);
-    sp = new ScrollPanel(projectsTab);
-    sp.setSize("100%", "100%");
-    pfp.add(sp);
-    pfp.add(closeBottom);
-    popup.setWidget(pfp);
-    popup.setHeight("100%");
-    popupPosition = getPositionCallback();
-  }
-
-  protected PopupPanel.PositionCallback getPositionCallback() {
-    return new PopupPanel.PositionCallback() {
-      @Override
-      public void setPosition(int offsetWidth, int offsetHeight) {
-        if (preferredTop + offsetHeight > Window.getClientWidth()) {
-          preferredTop = Window.getClientWidth() - offsetHeight;
-        }
-        if (preferredLeft + offsetWidth > Window.getClientWidth()) {
-          preferredLeft = Window.getClientWidth() - offsetWidth;
-        }
-
-        if (preferredTop < 0) {
-          sp.setHeight((sp.getOffsetHeight() + preferredTop) + "px");
-          preferredTop = 0;
-        }
-        if (preferredLeft < 0) {
-          sp.setWidth((sp.getOffsetWidth() + preferredLeft) + "px");
-          preferredLeft = 0;
-        }
-
-        popup.setPopupPosition(preferredLeft, preferredTop);
-      }
-    };
-  }
-
-  /**
-   * Invoked after moving pointer to a project.
-   *
-   * @param projectName project name.
-   */
-  protected void onMovePointerTo(String projectName) {}
-
-  /**
-   * Invoked after opening a project row.
-   *
-   * @param projectName project name.
-   */
-  protected void openRow(String projectName) {}
-
-  public boolean isPoppingUp() {
-    return poppingUp;
-  }
-
-  private void createWidgets(String popupText, String currentPageLink) {
-    filterPanel = new HorizontalPanel();
-    filterPanel.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
-    final Label filterLabel =
-        new Label(com.google.gerrit.client.admin.AdminConstants.I.projectFilter());
-    filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
-    filterPanel.add(filterLabel);
-    filterTxt = new NpTextBox();
-    filterTxt.addKeyUpHandler(
-        new KeyUpHandler() {
-          @Override
-          public void onKeyUp(KeyUpEvent event) {
-            Query q = new Query(filterTxt.getValue());
-            if (!match.equals(q.qMatch)) {
-              if (query == null) {
-                q.run();
-              }
-              query = q;
-            }
-          }
-        });
-    filterPanel.add(filterTxt);
-
-    projectsTab =
-        new HighlightingProjectsTable() {
-          @Override
-          protected void movePointerTo(int row, boolean scroll) {
-            super.movePointerTo(row, scroll);
-            onMovePointerTo(getRowItem(row).name());
-          }
-
-          @Override
-          protected void onOpenRow(int row) {
-            super.onOpenRow(row);
-            openRow(getRowItem(row).name());
-          }
-        };
-    projectsTab.setSavePointerId(currentPageLink);
-
-    closeTop = createCloseButton();
-    closeBottom = createCloseButton();
-
-    popup = new DialogBox();
-    popup.setModal(false);
-    popup.setText(popupText);
-  }
-
-  private Button createCloseButton() {
-    Button close = new Button(Util.C.projectsClose());
-    close.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            closePopup();
-          }
-        });
-    return close;
-  }
-
-  public void displayPopup() {
-    poppingUp = true;
-    if (firstPopupLoad) { // For sizing/positioning, delay display until loaded
-      match = "";
-      query = new Query(match).run();
-    } else {
-      popup.setPopupPositionAndShow(popupPosition);
-      GlobalKey.dialog(popup);
-      GlobalKey.addApplication(popup, new HidePopupPanelCommand(0, KeyCodes.KEY_ESCAPE, popup));
-      projectsTab.setRegisterKeys(true);
-      projectsTab.finishDisplay();
-      filterTxt.setFocus(true);
-      poppingUp = false;
-    }
-  }
-
-  public void closePopup() {
-    popup.hide();
-  }
-
-  public void setPreferredCoordinates(int top, int left) {
-    this.preferredTop = top;
-    this.preferredLeft = left;
-  }
-
-  private class Query {
-    private final String qMatch;
-
-    Query(String match) {
-      this.qMatch = match;
-    }
-
-    Query run() {
-      ProjectMap.match(
-          qMatch,
-          new GerritCallback<ProjectMap>() {
-            @Override
-            public void onSuccess(ProjectMap result) {
-              if (!firstPopupLoad && !popup.isShowing()) {
-                query = null;
-              } else if (query == Query.this) {
-                query = null;
-                showMap(result);
-              } else {
-                query.run();
-              }
-            }
-          });
-      return this;
-    }
-
-    private void showMap(ProjectMap result) {
-      ProjectListPopup.this.match = qMatch;
-      projectsTab.display(result, qMatch);
-
-      if (firstPopupLoad) {
-        // Display was delayed until table was loaded
-        firstPopupLoad = false;
-        displayPopup();
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java
deleted file mode 100644
index f2ebf81..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.projects.ProjectMap;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-
-/** Suggestion Oracle for Project.NameKey entities. */
-public class ProjectNameSuggestOracle extends SuggestAfterTypingNCharsOracle {
-  @Override
-  public void _onRequestSuggestions(Request req, Callback callback) {
-    ProjectMap.suggest(
-        req.getQuery(),
-        req.getLimit(),
-        new GerritCallback<ProjectMap>() {
-          @Override
-          public void onSuccess(ProjectMap map) {
-            callback.onSuggestionsReady(req, new Response(Natives.asList(map.values())));
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java
deleted file mode 100644
index f0e06a0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.admin.AdminConstants;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.Image;
-
-public class ProjectSearchLink extends InlineHyperlink {
-
-  public ProjectSearchLink(Project.NameKey projectName) {
-    super(" ", PageLinks.toProjectDefaultDashboard(projectName));
-    setTitle(AdminConstants.I.projectListQueryLink());
-    final Image image = new Image(Gerrit.RESOURCES.queryIcon());
-    image.setStyleName(Gerrit.RESOURCES.css().queryIcon());
-    DOM.insertBefore(getElement(), image.getElement(), DOM.getFirstChild(getElement()));
-  }
-}
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
deleted file mode 100644
index ac89180..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
+++ /dev/null
@@ -1,128 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-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> {
-  public static final int C_STATE = 1;
-  public static final int C_NAME = 2;
-  public static final int C_DESCRIPTION = 3;
-  public static final int C_REPO_BROWSER = 4;
-
-  public ProjectsTable() {
-    super(Util.C.projectItemHelp());
-    initColumnHeaders();
-  }
-
-  protected void initColumnHeaders() {
-    table.setText(0, C_STATE, Util.C.projectStateAbbrev());
-    table.getCellFormatter().getElement(0, C_STATE).setTitle(Util.C.projectStateHelp());
-    table.setText(0, C_NAME, Util.C.projectName());
-    table.setText(0, C_DESCRIPTION, Util.C.projectDescription());
-
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.addStyleName(0, C_STATE, Gerrit.RESOURCES.css().iconHeader());
-    fmt.addStyleName(0, C_NAME, Gerrit.RESOURCES.css().dataHeader());
-    fmt.addStyleName(0, C_DESCRIPTION, Gerrit.RESOURCES.css().dataHeader());
-  }
-
-  @Override
-  protected Object getRowItemKey(ProjectInfo item) {
-    return item.name();
-  }
-
-  @Override
-  protected void onOpenRow(int row) {
-    if (row > 0) {
-      movePointerTo(row);
-    }
-  }
-
-  public void display(ProjectMap projects) {
-    displaySubset(projects, 0, projects.size());
-  }
-
-  public void displaySubset(ProjectMap projects, int fromIndex, int toIndex) {
-    while (1 < table.getRowCount()) {
-      table.removeRow(table.getRowCount() - 1);
-    }
-
-    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());
-          }
-        });
-    for (ProjectInfo p : list.subList(fromIndex, toIndex)) {
-      insert(table.getRowCount(), p);
-    }
-
-    finishDisplay();
-  }
-
-  protected void insert(int row, ProjectInfo k) {
-    table.insertRow(row);
-
-    applyDataRowStyle(row);
-
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.addStyleName(row, C_STATE, Gerrit.RESOURCES.css().iconCell());
-    fmt.addStyleName(row, C_NAME, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(row, C_NAME, Gerrit.RESOURCES.css().projectNameColumn());
-    fmt.addStyleName(row, C_DESCRIPTION, Gerrit.RESOURCES.css().dataCell());
-
-    populate(row, k);
-  }
-
-  protected void populate(int row, ProjectInfo k) {
-    populateState(row, k);
-    table.setText(row, C_NAME, k.name());
-    table.setText(row, C_DESCRIPTION, k.description());
-
-    setRowItem(row, k);
-  }
-
-  protected void populateState(int row, ProjectInfo k) {
-    Image state = new Image();
-    switch (k.state()) {
-      case HIDDEN:
-        state.setResource(Gerrit.RESOURCES.redNot());
-        state.setTitle(com.google.gerrit.client.admin.Util.toLongString(k.state()));
-        table.setWidget(row, ProjectsTable.C_STATE, state);
-        break;
-      case READ_ONLY:
-        state.setResource(Gerrit.RESOURCES.readOnly());
-        state.setTitle(com.google.gerrit.client.admin.Util.toLongString(k.state()));
-        table.setWidget(row, ProjectsTable.C_STATE, state);
-        break;
-      case ACTIVE:
-      default:
-        // Intentionally left blank, do not show an icon when active.
-        break;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java
deleted file mode 100644
index e03ac46..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java
+++ /dev/null
@@ -1,169 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeList;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.SuggestBox;
-import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-public abstract class RebaseDialog extends CommentedActionDialog {
-  private final SuggestBox base;
-  private final CheckBox changeParent;
-  private List<ChangeInfo> candidateChanges;
-  private final boolean sendEnabled;
-
-  public RebaseDialog(
-      final Project.NameKey project,
-      final String branch,
-      final Change.Id changeId,
-      final boolean sendEnabled) {
-    super(Util.C.rebaseTitle(), null);
-    this.sendEnabled = sendEnabled;
-    sendButton.setText(Util.C.buttonRebaseChangeSend());
-
-    // Create the suggestion box to filter over a list of recent changes
-    // open on the same branch. The list of candidates is primed by the
-    // changeParent CheckBox (below) getting enabled by the user.
-    base =
-        new SuggestBox(
-            new HighlightSuggestOracle() {
-              @Override
-              protected void onRequestSuggestions(Request request, Callback done) {
-                String query = request.getQuery().toLowerCase();
-                List<ChangeSuggestion> suggestions = new ArrayList<>();
-                for (ChangeInfo ci : candidateChanges) {
-                  if (changeId.equals(ci.legacyId())) {
-                    continue; // do not suggest current change
-                  }
-                  String id = String.valueOf(ci.legacyId().get());
-                  if (id.contains(query) || ci.subject().toLowerCase().contains(query)) {
-                    suggestions.add(new ChangeSuggestion(ci));
-                    if (suggestions.size() >= 50) { // limit to 50 suggestions
-                      break;
-                    }
-                  }
-                }
-                done.onSuggestionsReady(request, new Response(suggestions));
-              }
-            });
-    base.getElement().setAttribute("placeholder", Util.C.rebasePlaceholderMessage());
-    base.setStyleName(Gerrit.RESOURCES.css().rebaseSuggestBox());
-
-    // The changeParent checkbox must be clicked to load into browser memory
-    // a list of open changes from the same project and same branch that this
-    // change may rebase onto.
-    changeParent = new CheckBox(Util.C.rebaseConfirmMessage());
-    changeParent.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            if (changeParent.getValue()) {
-              ChangeList.query(
-                  PageLinks.projectQuery(project)
-                      + " "
-                      + PageLinks.op("branch", branch)
-                      + " is:open -age:90d",
-                  Collections.<ListChangesOption>emptySet(),
-                  new GerritCallback<ChangeList>() {
-                    @Override
-                    public void onSuccess(ChangeList result) {
-                      candidateChanges = Natives.asList(result);
-                      updateControls(true);
-                    }
-
-                    @Override
-                    public void onFailure(Throwable err) {
-                      updateControls(false);
-                      changeParent.setValue(false);
-                      super.onFailure(err);
-                    }
-                  });
-            } else {
-              updateControls(false);
-            }
-          }
-        });
-
-    // add the checkbox and suggestbox widgets to the content panel
-    contentPanel.add(changeParent);
-    contentPanel.add(base);
-    contentPanel.setStyleName(Gerrit.RESOURCES.css().rebaseContentPanel());
-  }
-
-  @Override
-  public void center() {
-    super.center();
-    GlobalKey.dialog(this);
-    updateControls(false);
-  }
-
-  private void updateControls(boolean changeParentEnabled) {
-    if (changeParentEnabled) {
-      sendButton.setTitle(null);
-      sendButton.setEnabled(true);
-      base.setEnabled(true);
-      base.setFocus(true);
-    } else {
-      base.setEnabled(false);
-      sendButton.setEnabled(sendEnabled);
-      if (sendEnabled) {
-        sendButton.setTitle(null);
-        sendButton.setFocus(true);
-      } else {
-        sendButton.setTitle(Util.C.rebaseNotPossibleMessage());
-        cancelButton.setFocus(true);
-      }
-    }
-  }
-
-  public String getBase() {
-    return changeParent.getValue() ? base.getText() : null;
-  }
-
-  private static class ChangeSuggestion implements Suggestion {
-    private ChangeInfo change;
-
-    ChangeSuggestion(ChangeInfo change) {
-      this.change = change;
-    }
-
-    @Override
-    public String getDisplayString() {
-      return String.valueOf(change.legacyId().get()) + ": " + change.subject();
-    }
-
-    @Override
-    public String getReplacementString() {
-      return String.valueOf(change.legacyId().get());
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
deleted file mode 100644
index 5d741cf..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
+++ /dev/null
@@ -1,165 +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.client.ui;
-
-import com.google.gwt.event.dom.client.FocusEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.event.logical.shared.HasCloseHandlers;
-import com.google.gwt.event.logical.shared.HasSelectionHandlers;
-import com.google.gwt.event.logical.shared.SelectionEvent;
-import com.google.gwt.event.logical.shared.SelectionHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.Focusable;
-import com.google.gwt.user.client.ui.HasText;
-import com.google.gwt.user.client.ui.SuggestBox;
-import com.google.gwt.user.client.ui.SuggestBox.DefaultSuggestionDisplay;
-import com.google.gwt.user.client.ui.SuggestOracle;
-import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
-import com.google.gwt.user.client.ui.TextBoxBase;
-
-public class RemoteSuggestBox extends Composite
-    implements Focusable,
-        HasText,
-        HasSelectionHandlers<String>,
-        HasCloseHandlers<RemoteSuggestBox> {
-  private final RemoteSuggestOracle remoteSuggestOracle;
-  private final DefaultSuggestionDisplay display;
-  private final HintTextBox textBox;
-  private final SuggestBox suggestBox;
-  private boolean submitOnSelection;
-
-  public RemoteSuggestBox(SuggestOracle oracle) {
-    remoteSuggestOracle = new RemoteSuggestOracle(oracle);
-    remoteSuggestOracle.setServeSuggestions(true);
-    display = new DefaultSuggestionDisplay();
-
-    textBox = new HintTextBox();
-    textBox.addKeyDownHandler(
-        new KeyDownHandler() {
-          @Override
-          public void onKeyDown(KeyDownEvent e) {
-            submitOnSelection = false;
-            if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
-              CloseEvent.fire(RemoteSuggestBox.this, RemoteSuggestBox.this);
-            } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
-              if (display.isSuggestionListShowing()) {
-                if (textBox.getValue().equals(remoteSuggestOracle.getLast())) {
-                  submitOnSelection = true;
-                } else {
-                  display.hideSuggestions();
-                }
-              } else {
-                SelectionEvent.fire(RemoteSuggestBox.this, getText());
-              }
-            }
-          }
-        });
-
-    suggestBox = new SuggestBox(remoteSuggestOracle, textBox, display);
-    suggestBox.addSelectionHandler(
-        new SelectionHandler<Suggestion>() {
-          @Override
-          public void onSelection(SelectionEvent<Suggestion> event) {
-            if (submitOnSelection) {
-              SelectionEvent.fire(RemoteSuggestBox.this, getText());
-            }
-            remoteSuggestOracle.cancelOutstandingRequest();
-            display.hideSuggestions();
-          }
-        });
-    initWidget(suggestBox);
-  }
-
-  public void setHintText(String hint) {
-    textBox.setHintText(hint);
-  }
-
-  public void setVisibleLength(int len) {
-    textBox.setVisibleLength(len);
-  }
-
-  public void setEnabled(boolean enabled) {
-    suggestBox.setEnabled(enabled);
-  }
-
-  public TextBoxBase getTextBox() {
-    return textBox;
-  }
-
-  @Override
-  public String getText() {
-    return suggestBox.getText();
-  }
-
-  @Override
-  public void setText(String value) {
-    suggestBox.setText(value);
-  }
-
-  @Override
-  public void setFocus(boolean focus) {
-    suggestBox.setFocus(focus);
-  }
-
-  @Override
-  public int getTabIndex() {
-    return suggestBox.getTabIndex();
-  }
-
-  @Override
-  public void setAccessKey(char key) {
-    suggestBox.setAccessKey(key);
-  }
-
-  @Override
-  public void setTabIndex(int index) {
-    suggestBox.setTabIndex(index);
-  }
-
-  @Override
-  public HandlerRegistration addSelectionHandler(SelectionHandler<String> h) {
-    return addHandler(h, SelectionEvent.getType());
-  }
-
-  @Override
-  public HandlerRegistration addCloseHandler(CloseHandler<RemoteSuggestBox> h) {
-    return addHandler(h, CloseEvent.getType());
-  }
-
-  public void selectAll() {
-    suggestBox.getValueBox().selectAll();
-  }
-
-  public void enableDefaultSuggestions() {
-    textBox.addFocusHandler(
-        new FocusHandler() {
-          @Override
-          public void onFocus(FocusEvent focusEvent) {
-            if (textBox.getText().equals("")) {
-              suggestBox.showSuggestionList();
-            }
-          }
-        });
-  }
-
-  public void setServeSuggestionsOnOracle(boolean serveSuggestions) {
-    remoteSuggestOracle.setServeSuggestions(serveSuggestions);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
deleted file mode 100644
index 03ed899..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
+++ /dev/null
@@ -1,199 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.projects.ThemeInfo;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HasHorizontalAlignment;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.user.client.View;
-
-/**
- * A Screen layout with a header and a body.
- *
- * <p>The header is mainly a text title, but it can be decorated in the West, the East, and the
- * FarEast by any Widget. The West and East decorations will surround the text on the left and right
- * respectively, and the FarEast will be right justified to the right edge of the screen. The East
- * decoration will expand to take up any extra space.
- */
-public abstract class Screen extends View {
-  private Grid header;
-  private InlineLabel headerText;
-  private FlowPanel body;
-  private String token;
-  private boolean requiresSignIn;
-  private String windowTitle;
-  private Widget titleWidget;
-
-  private ThemeInfo theme;
-  private boolean setTheme;
-
-  protected Screen() {
-    initWidget(new FlowPanel());
-    setStyleName(Gerrit.RESOURCES.css().screen());
-    body = new FlowPanel();
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    if (header == null) {
-      onInitUI();
-    }
-  }
-
-  @Override
-  protected void onUnload() {
-    super.onUnload();
-    if (setTheme) {
-      Gerrit.THEMER.clear();
-    }
-  }
-
-  public void registerKeys() {}
-
-  private enum Cols {
-    West,
-    Title,
-    East,
-    FarEast
-  }
-
-  protected void onInitUI() {
-    final FlowPanel me = (FlowPanel) getWidget();
-    me.add(header = new Grid(1, Cols.values().length));
-    me.add(body);
-
-    headerText = new InlineLabel();
-    if (titleWidget == null) {
-      titleWidget = headerText;
-    }
-    FlowPanel title = new FlowPanel();
-    title.add(titleWidget);
-    title.setStyleName(Gerrit.RESOURCES.css().screenHeader());
-    header.setWidget(0, Cols.Title.ordinal(), title);
-
-    header.setStyleName(Gerrit.RESOURCES.css().screenHeader());
-    header
-        .getCellFormatter()
-        .setHorizontalAlignment(0, Cols.FarEast.ordinal(), HasHorizontalAlignment.ALIGN_RIGHT);
-    // force FarEast all the way to the right
-    header.getCellFormatter().setWidth(0, Cols.FarEast.ordinal(), "100%");
-  }
-
-  protected void setWindowTitle(String text) {
-    windowTitle = text;
-    Gerrit.setWindowTitle(this, text);
-  }
-
-  protected void setPageTitle(String text) {
-    final String old = headerText.getText();
-    if (text.isEmpty()) {
-      header.setVisible(false);
-    } else {
-      headerText.setText(text);
-      header.setVisible(true);
-    }
-    if (windowTitle == null || windowTitle.equals(old)) {
-      setWindowTitle(text);
-    }
-  }
-
-  protected void setHeaderVisible(boolean value) {
-    header.setVisible(value);
-  }
-
-  public void setTitle(Widget w) {
-    titleWidget = w;
-  }
-
-  protected void setTitleEast(Widget w) {
-    header.setWidget(0, Cols.East.ordinal(), w);
-  }
-
-  protected void setTitleFarEast(Widget w) {
-    header.setWidget(0, Cols.FarEast.ordinal(), w);
-  }
-
-  protected void setTitleWest(Widget w) {
-    header.setWidget(0, Cols.West.ordinal(), w);
-  }
-
-  protected void add(Widget w) {
-    body.add(w);
-  }
-
-  protected FlowPanel getBody() {
-    return body;
-  }
-
-  protected void setTheme(ThemeInfo t) {
-    theme = t;
-  }
-
-  /** Get the history token for this screen. */
-  public String getToken() {
-    return token;
-  }
-
-  /** Set the history token for this screen. */
-  public void setToken(String t) {
-    assert t != null && !t.isEmpty();
-    token = t;
-
-    if (isCurrentView()) {
-      Gerrit.updateImpl(token);
-    }
-  }
-
-  /**
-   * If this view can display the given token, update it.
-   *
-   * @param newToken token the UI wants to show.
-   * @return true if this view can show the token immediately, false if not.
-   */
-  public boolean displayToken(String newToken) {
-    return false;
-  }
-
-  /** Set whether or not {@link Gerrit#isSignedIn()} must be true. */
-  public final void setRequiresSignIn(boolean b) {
-    requiresSignIn = b;
-  }
-
-  /** Does {@link Gerrit#isSignedIn()} have to be true to be on this screen? */
-  public final boolean isRequiresSignIn() {
-    return requiresSignIn;
-  }
-
-  public void onShowView() {
-    if (windowTitle != null) {
-      Gerrit.setWindowTitle(this, windowTitle);
-    }
-    Gerrit.EVENT_BUS.fireEvent(new ScreenLoadEvent(this));
-    Gerrit.setQueryString(null);
-    registerKeys();
-
-    if (theme != null) {
-      Gerrit.THEMER.set(theme);
-      setTheme = true;
-    } else {
-      Gerrit.THEMER.clear();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadEvent.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadEvent.java
deleted file mode 100644
index debd9a6..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadEvent.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.event.shared.GwtEvent;
-
-public class ScreenLoadEvent extends GwtEvent<ScreenLoadHandler> {
-  private final Screen screen;
-
-  public ScreenLoadEvent(Screen screen) {
-    super();
-    this.screen = screen;
-  }
-
-  public static final Type<ScreenLoadHandler> TYPE = new Type<>();
-
-  @Override
-  protected void dispatch(ScreenLoadHandler handler) {
-    handler.onScreenLoad(this);
-  }
-
-  @Override
-  public GwtEvent.Type<ScreenLoadHandler> getAssociatedType() {
-    return TYPE;
-  }
-
-  public Screen getScreen() {
-    return screen;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java
deleted file mode 100644
index 9a5eb03..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.event.shared.EventHandler;
-
-public interface ScreenLoadHandler extends EventHandler {
-  void onScreenLoad(ScreenLoadEvent event);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SmallHeading.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SmallHeading.java
deleted file mode 100644
index ea18d62..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SmallHeading.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwt.user.client.ui.Label;
-
-public class SmallHeading extends Label {
-  public SmallHeading() {
-    setStyleName(Gerrit.RESOURCES.css().smallHeading());
-  }
-
-  public SmallHeading(String text) {
-    this();
-    setText(text);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestAfterTypingNCharsOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestAfterTypingNCharsOracle.java
deleted file mode 100644
index e24f347..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestAfterTypingNCharsOracle.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Suggest oracle that only provides suggestions if the user has typed at least as many characters
- * as configured by 'suggest.from'. If 'suggest.from' is set to 0, suggestions will always be
- * provided.
- */
-public abstract class SuggestAfterTypingNCharsOracle extends HighlightSuggestOracle {
-
-  @Override
-  protected void onRequestSuggestions(Request req, Callback cb) {
-    if (req.getQuery() != null && req.getQuery().length() >= Gerrit.info().suggest().from()) {
-      _onRequestSuggestions(req, cb);
-    } else {
-      List<Suggestion> none = Collections.emptyList();
-      cb.onSuggestionsReady(req, new Response(none));
-    }
-  }
-
-  protected abstract void _onRequestSuggestions(Request request, Callback done);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/TextAreaActionDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/TextAreaActionDialog.java
deleted file mode 100644
index d7d5d6a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/TextAreaActionDialog.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwtexpui.globalkey.client.NpTextArea;
-
-public abstract class TextAreaActionDialog extends CommentedActionDialog
-    implements CloseHandler<PopupPanel> {
-  protected final NpTextArea message;
-
-  public TextAreaActionDialog(String title, String heading) {
-    super(title, heading);
-
-    message = new NpTextArea();
-    message.setCharacterWidth(60);
-    message.setVisibleLines(10);
-    message.getElement().setPropertyBoolean("spellcheck", true);
-    setFocusOn(message);
-
-    contentPanel.add(message);
-  }
-
-  public String getMessageText() {
-    return message.getText().trim();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.java
deleted file mode 100644
index 32bb796..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gwt.i18n.client.Constants;
-
-public interface UIConstants extends Constants {
-  String commentedActionButtonSend();
-
-  String commentedActionButtonCancel();
-
-  String projectName();
-
-  String projectDescription();
-
-  String projectItemHelp();
-
-  String projectStateAbbrev();
-
-  String projectStateHelp();
-
-  String dialogCreateChangeTitle();
-
-  String dialogCreateChangeHeading();
-
-  String newChangeBranchSuggestion();
-
-  String newChangeTopicSuggestion();
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.properties
deleted file mode 100644
index 736e210..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.properties
+++ /dev/null
@@ -1,13 +0,0 @@
-commentedActionButtonSend = Submit
-commentedActionButtonCancel = Cancel
-
-projectName = Project Name
-projectDescription = Project Description
-projectItemHelp = project
-projectStateAbbrev = S
-projectStateHelp = State
-
-dialogCreateChangeTitle = Create Change
-dialogCreateChangeHeading = Description
-newChangeBranchSuggestion = Select branch for new change
-newChangeTopicSuggestion = Enter topic for new change
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.java
deleted file mode 100644
index af17390..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.i18n.client.Messages;
-
-public interface UIMessages extends Messages {
-  String helpListOpen(String item);
-
-  String helpListPrev(String item);
-
-  String helpListNext(String item);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.properties
deleted file mode 100644
index 1439245..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.properties
+++ /dev/null
@@ -1,3 +0,0 @@
-helpListOpen = Select {0}
-helpListPrev = Previous {0}
-helpListNext = Next {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UserActivityMonitor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UserActivityMonitor.java
deleted file mode 100644
index 2b76b9b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UserActivityMonitor.java
+++ /dev/null
@@ -1,113 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.RepeatingCommand;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.dom.client.MouseMoveEvent;
-import com.google.gwt.event.dom.client.MouseMoveHandler;
-import com.google.gwt.event.logical.shared.HasValueChangeHandlers;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.event.shared.EventBus;
-import com.google.gwt.event.shared.GwtEvent;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.event.shared.SimpleEventBus;
-import com.google.gwt.user.client.History;
-import com.google.gwtexpui.globalkey.client.DocWidget;
-
-/** Checks for user keyboard and mouse activity. */
-public class UserActivityMonitor {
-  private static final long TIMEOUT = 10 * 60 * 1000;
-  private static final MonitorImpl impl;
-
-  /**
-   * @return true if there has been keyboard and/or mouse activity in recent enough history to
-   *     believe a user is still controlling this session.
-   */
-  public static boolean isActive() {
-    return impl.active || impl.recent;
-  }
-
-  public static HandlerRegistration addValueChangeHandler(ValueChangeHandler<Boolean> handler) {
-    return impl.addValueChangeHandler(handler);
-  }
-
-  static {
-    impl = new MonitorImpl();
-    DocWidget.get().addKeyPressHandler(impl);
-    DocWidget.get().addMouseMoveHandler(impl);
-    History.addValueChangeHandler(impl);
-    Scheduler.get().scheduleFixedDelay(impl, 60 * 1000);
-  }
-
-  private UserActivityMonitor() {}
-
-  private static class MonitorImpl
-      implements RepeatingCommand,
-          KeyPressHandler,
-          MouseMoveHandler,
-          ValueChangeHandler<String>,
-          HasValueChangeHandlers<Boolean> {
-    private final EventBus bus = new SimpleEventBus();
-    private boolean recent = true;
-    private boolean active = true;
-    private long last = System.currentTimeMillis();
-
-    @Override
-    public void onKeyPress(KeyPressEvent event) {
-      recent = true;
-    }
-
-    @Override
-    public void onMouseMove(MouseMoveEvent event) {
-      recent = true;
-    }
-
-    @Override
-    public void onValueChange(ValueChangeEvent<String> event) {
-      recent = true;
-    }
-
-    @Override
-    public boolean execute() {
-      long now = System.currentTimeMillis();
-      if (recent) {
-        if (!active) {
-          ValueChangeEvent.fire(this, active);
-        }
-        recent = false;
-        active = true;
-        last = now;
-      } else if (active && (now - last) > TIMEOUT) {
-        active = false;
-        ValueChangeEvent.fire(this, false);
-      }
-      return true;
-    }
-
-    @Override
-    public HandlerRegistration addValueChangeHandler(ValueChangeHandler<Boolean> handler) {
-      return bus.addHandler(ValueChangeEvent.getType(), handler);
-    }
-
-    @Override
-    public void fireEvent(GwtEvent<?> event) {
-      bus.fireEvent(event);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java
deleted file mode 100644
index 41e3573..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.ui;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-public class Util {
-  public static final UIConstants C = GWT.create(UIConstants.class);
-  public static final UIMessages M = GWT.create(UIMessages.class);
-
-  public static String highlight(String text, String toHighlight) {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    if (toHighlight == null || "".equals(toHighlight)) {
-      b.append(text);
-      return b.toSafeHtml().asString();
-    }
-
-    int pos = 0;
-    int endPos = 0;
-    while ((pos = text.toLowerCase().indexOf(toHighlight.toLowerCase(), pos)) > -1) {
-      if (pos > endPos) {
-        b.append(text.substring(endPos, pos));
-      }
-      endPos = pos + toHighlight.length();
-      b.openElement("b");
-      b.append(text.substring(pos, endPos));
-      b.closeElement("b");
-      pos = endPos;
-    }
-    if (endPos < text.length()) {
-      b.append(text.substring(endPos));
-    }
-    return b.toSafeHtml().asString();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/CodeMirror.gwt.xml b/gerrit-gwtui/src/main/java/net/codemirror/CodeMirror.gwt.xml
deleted file mode 100644
index add033f..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/CodeMirror.gwt.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
- Copyright (C) 2013 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name='com.google.gwt.logging.Logging'/>
-  <inherits name='com.google.gwt.resources.Resources'/>
-  <source path='addon'/>
-  <source path='lib'/>
-  <source path='keymap'/>
-  <source path='mode'/>
-  <source path='theme'/>
-</module>
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java b/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
deleted file mode 100644
index cb1891e..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package net.codemirror.addon;
-
-import com.google.gwt.safehtml.shared.SafeUri;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import net.codemirror.lib.Loader;
-
-public class AddonInjector {
-  private static final Map<String, SafeUri> addonUris = new HashMap<>();
-
-  static {
-    addonUris.put(Addons.I.merge_bundled().getName(), Addons.I.merge_bundled().getSafeUri());
-  }
-
-  public static SafeUri getAddonScriptUri(String addon) {
-    return addonUris.get(addon);
-  }
-
-  private static boolean canLoad(String addon) {
-    return getAddonScriptUri(addon) != null;
-  }
-
-  private final Set<String> loading = new HashSet<>();
-  private int pending;
-  private AsyncCallback<Void> appCallback;
-
-  public AddonInjector add(String name) {
-    if (name == null) {
-      return this;
-    }
-
-    if (!canLoad(name)) {
-      Logger.getLogger("net.codemirror")
-          .log(Level.WARNING, "CodeMirror addon " + name + " not configured.");
-      return this;
-    }
-
-    loading.add(name);
-    return this;
-  }
-
-  public void inject(AsyncCallback<Void> appCallback) {
-    this.appCallback = appCallback;
-    for (String addon : loading) {
-      beginLoading(addon);
-    }
-    if (pending == 0) {
-      appCallback.onSuccess(null);
-    }
-  }
-
-  private void beginLoading(String addon) {
-    pending++;
-    Loader.injectScript(
-        getAddonScriptUri(addon),
-        new AsyncCallback<Void>() {
-          @Override
-          public void onSuccess(Void result) {
-            pending--;
-            if (pending == 0) {
-              appCallback.onSuccess(null);
-            }
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            if (--pending == 0) {
-              appCallback.onFailure(caught);
-            }
-          }
-        });
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java b/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java
deleted file mode 100644
index 19a681c..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package net.codemirror.addon;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.DataResource;
-import com.google.gwt.resources.client.DataResource.DoNotEmbed;
-
-public interface Addons extends ClientBundle {
-  Addons I = GWT.create(Addons.class);
-
-  @Source("merge_bundled.js")
-  @DoNotEmbed
-  DataResource merge_bundled();
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java
deleted file mode 100644
index 1e81f83a..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package net.codemirror.lib;
-
-import com.google.gwt.i18n.client.Messages;
-
-public interface BlameConfig extends Messages {
-  String shortBlameMsg(String commitId, String date, String author);
-
-  String detailedBlameMsg(String commitId, String author, String time, String msg);
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.properties b/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.properties
deleted file mode 100644
index 658b50f..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.properties
+++ /dev/null
@@ -1,2 +0,0 @@
-shortBlameMsg={0} {1} {2}
-detailedBlameMsg=commit {0}\nAuthor: {1}\nDate: {2}\n\n{3}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
deleted file mode 100644
index a84c464..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
+++ /dev/null
@@ -1,448 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.diff.DisplaySide;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import net.codemirror.lib.TextMarker.FromTo;
-
-/**
- * Glue to connect CodeMirror to be callable from GWT.
- *
- * @see <a href="http://codemirror.net/doc/manual.html#api">CodeMirror API</a>
- */
-public class CodeMirror extends JavaScriptObject {
-  public static void preload() {
-    initLibrary(CallbackGroup.<Void>emptyCallback());
-  }
-
-  public static void initLibrary(AsyncCallback<Void> cb) {
-    Loader.initLibrary(cb);
-  }
-
-  interface Style extends CssResource {
-    String activeLine();
-
-    String showTabs();
-
-    String margin();
-  }
-
-  static Style style() {
-    return Lib.I.style();
-  }
-
-  public static CodeMirror create(Element p, Configuration cfg) {
-    CodeMirror cm = newCM(p, cfg);
-    Extras.attach(cm);
-    return cm;
-  }
-
-  private static native CodeMirror newCM(Element p, Configuration cfg) /*-{
-    return $wnd.CodeMirror(p, cfg);
-  }-*/;
-
-  public final native void setOption(String option, boolean value) /*-{
-    this.setOption(option, value)
-  }-*/;
-
-  public final native void setOption(String option, double value) /*-{
-    this.setOption(option, value)
-  }-*/;
-
-  public final native void setOption(String option, String value) /*-{
-    this.setOption(option, value)
-  }-*/;
-
-  public final native void setOption(String option, JavaScriptObject val) /*-{
-    this.setOption(option, val)
-  }-*/;
-
-  public final native String getStringOption(String o) /*-{ return this.getOption(o) }-*/;
-
-  public final native String getValue() /*-{ return this.getValue() }-*/;
-
-  public final native void setValue(String v) /*-{ this.setValue(v) }-*/;
-
-  public final native int changeGeneration(boolean closeEvent)
-      /*-{ return this.changeGeneration(closeEvent) }-*/ ;
-
-  public final native boolean isClean(int generation) /*-{ return this.isClean(generation) }-*/;
-
-  public final native void setWidth(double w) /*-{ this.setSize(w, null) }-*/;
-
-  public final native void setHeight(double h) /*-{ this.setSize(null, h) }-*/;
-
-  public final int getHeight() {
-    return getWrapperElement().getClientHeight();
-  }
-
-  public final void adjustHeight(int localHeader) {
-    int rest = Gerrit.getHeaderFooterHeight() + localHeader + 5; // Estimate
-    setHeight(Window.getClientHeight() - rest);
-  }
-
-  public final native String getLine(int n) /*-{ return this.getLine(n) }-*/;
-
-  public final native double barHeight() /*-{ return this.display.barHeight }-*/;
-
-  public final native double barWidth() /*-{ return this.display.barWidth }-*/;
-
-  public final native int lastLine() /*-{ return this.lastLine() }-*/;
-
-  public final native void refresh() /*-{ this.refresh() }-*/;
-
-  public final native TextMarker markText(Pos from, Pos to, Configuration options) /*-{
-    return this.markText(from, to, options)
-  }-*/;
-
-  public enum LineClassWhere {
-    TEXT {
-      @Override
-      String value() {
-        return "text";
-      }
-    },
-    BACKGROUND {
-      @Override
-      String value() {
-        return "background";
-      }
-    },
-    WRAP {
-      @Override
-      String value() {
-        return "wrap";
-      }
-    };
-
-    abstract String value();
-  }
-
-  public final void addLineClass(int line, LineClassWhere where, String className) {
-    addLineClassNative(line, where.value(), className);
-  }
-
-  private native void addLineClassNative(int line, String where, String lineClass) /*-{
-    this.addLineClass(line, where, lineClass)
-  }-*/;
-
-  public final void addLineClass(LineHandle line, LineClassWhere where, String className) {
-    addLineClassNative(line, where.value(), className);
-  }
-
-  private native void addLineClassNative(LineHandle line, String where, String lineClass) /*-{
-    this.addLineClass(line, where, lineClass)
-  }-*/;
-
-  public final void removeLineClass(int line, LineClassWhere where, String className) {
-    removeLineClassNative(line, where.value(), className);
-  }
-
-  private native void removeLineClassNative(int line, String where, String lineClass) /*-{
-    this.removeLineClass(line, where, lineClass)
-  }-*/;
-
-  public final void removeLineClass(LineHandle line, LineClassWhere where, String className) {
-    removeLineClassNative(line, where.value(), className);
-  }
-
-  private native void removeLineClassNative(LineHandle line, String where, String lineClass) /*-{
-    this.removeLineClass(line, where, lineClass)
-  }-*/;
-
-  public final native void addWidget(Pos pos, Element node) /*-{
-    this.addWidget(pos, node, false)
-  }-*/;
-
-  public final native LineWidget addLineWidget(int line, Element node, Configuration options) /*-{
-    return this.addLineWidget(line, node, options)
-  }-*/;
-
-  public final native int lineAtHeight(double height) /*-{
-    return this.lineAtHeight(height)
-  }-*/;
-
-  public final native int lineAtHeight(double height, String mode) /*-{
-    return this.lineAtHeight(height, mode)
-  }-*/;
-
-  public final native double heightAtLine(int line) /*-{
-    return this.heightAtLine(line)
-  }-*/;
-
-  public final native double heightAtLine(int line, String mode) /*-{
-    return this.heightAtLine(line, mode)
-  }-*/;
-
-  public final native Rect charCoords(Pos pos, String mode) /*-{
-    return this.charCoords(pos, mode)
-  }-*/;
-
-  public final native CodeMirrorDoc getDoc() /*-{
-    return this.getDoc()
-  }-*/;
-
-  public final native void scrollTo(double x, double y) /*-{
-    this.scrollTo(x, y)
-  }-*/;
-
-  public final native void scrollToY(double y) /*-{
-    this.scrollTo(null, y)
-  }-*/;
-
-  public final void scrollToLine(int line) {
-    int height = getHeight();
-    if (lineAtHeight(height - 20) < line) {
-      scrollToY(heightAtLine(line, "local") - 0.5 * height);
-    }
-    setCursor(Pos.create(line, 0));
-  }
-
-  public final native ScrollInfo getScrollInfo() /*-{
-    return this.getScrollInfo()
-  }-*/;
-
-  public final native Viewport getViewport() /*-{
-    return this.getViewport()
-  }-*/;
-
-  public final native void operation(Runnable thunk) /*-{
-    this.operation(function() {
-      thunk.@java.lang.Runnable::run()();
-    })
-  }-*/;
-
-  public final native void off(String event, RegisteredHandler h) /*-{
-    this.off(event, h)
-  }-*/;
-
-  public final native RegisteredHandler on(String event, Runnable thunk) /*-{
-    var h = $entry(function() { thunk.@java.lang.Runnable::run()() });
-    this.on(event, h);
-    return h;
-  }-*/;
-
-  public final native void on(String event, EventHandler handler) /*-{
-    this.on(event, $entry(function(cm, e) {
-      handler.@net.codemirror.lib.CodeMirror.EventHandler::handle(
-        Lnet/codemirror/lib/CodeMirror;
-        Lcom/google/gwt/dom/client/NativeEvent;)(cm, e);
-    }))
-  }-*/;
-
-  public final native void on(String event, RenderLineHandler handler) /*-{
-    this.on(event, $entry(function(cm, h, e) {
-      handler.@net.codemirror.lib.CodeMirror.RenderLineHandler::handle(
-        Lnet/codemirror/lib/CodeMirror;
-        Lnet/codemirror/lib/CodeMirror$LineHandle;
-        Lcom/google/gwt/dom/client/Element;)(cm, h, e);
-    }))
-  }-*/;
-
-  public final native void on(String event, GutterClickHandler handler) /*-{
-    this.on(event, $entry(function(cm, l, g, e) {
-      handler.@net.codemirror.lib.CodeMirror.GutterClickHandler::handle(
-        Lnet/codemirror/lib/CodeMirror;
-        I
-        Ljava/lang/String;
-        Lcom/google/gwt/dom/client/NativeEvent;)(cm, l, g, e);
-    }))
-  }-*/;
-
-  public final native void on(String event, BeforeSelectionChangeHandler handler) /*-{
-    this.on(event, $entry(function(cm, o) {
-      var e = o.ranges[o.ranges.length-1];
-      handler.@net.codemirror.lib.CodeMirror.BeforeSelectionChangeHandler::handle(
-        Lnet/codemirror/lib/CodeMirror;
-        Lnet/codemirror/lib/Pos;
-        Lnet/codemirror/lib/Pos;)(cm, e.anchor, e.head);
-    }))
-  }-*/;
-
-  public final native void on(ChangesHandler handler) /*-{
-    this.on('changes', $entry(function(cm, o) {
-      handler.@net.codemirror.lib.CodeMirror.ChangesHandler::handle(
-        Lnet/codemirror/lib/CodeMirror;)(cm);
-    }))
-  }-*/;
-
-  public final native void setCursor(Pos p) /*-{ this.setCursor(p) }-*/;
-
-  public final native Pos getCursor() /*-{ return this.getCursor() }-*/;
-
-  public final native Pos getCursor(String start) /*-{
-    return this.getCursor(start)
-  }-*/;
-
-  public final FromTo getSelectedRange() {
-    return FromTo.create(getCursor("start"), getCursor("end"));
-  }
-
-  public final native void setSelection(Pos p) /*-{ this.setSelection(p) }-*/;
-
-  public final native void setSelection(Pos anchor, Pos head) /*-{
-    this.setSelection(anchor, head)
-  }-*/;
-
-  public final native boolean somethingSelected() /*-{
-    return this.somethingSelected()
-  }-*/;
-
-  public final native void addKeyMap(KeyMap map) /*-{ this.addKeyMap(map) }-*/;
-
-  public final native void removeKeyMap(KeyMap map) /*-{ this.removeKeyMap(map) }-*/;
-
-  public final native LineHandle getLineHandle(int line) /*-{
-    return this.getLineHandle(line)
-  }-*/;
-
-  public final native LineHandle getLineHandleVisualStart(int line) /*-{
-    return this.getLineHandleVisualStart(line)
-  }-*/;
-
-  public final native int getLineNumber(LineHandle handle) /*-{
-    return this.getLineNumber(handle)
-  }-*/;
-
-  public final native void focus() /*-{
-    this.focus()
-  }-*/;
-
-  public final native Element getWrapperElement() /*-{
-    return this.getWrapperElement()
-  }-*/;
-
-  public final native Element getGutterElement() /*-{
-    return this.getGutterElement()
-  }-*/;
-
-  public final native Element sizer() /*-{
-    return this.display.sizer
-  }-*/;
-
-  public final native Element mover() /*-{
-    return this.display.mover
-  }-*/;
-
-  public final native Element measure() /*-{
-    return this.display.measure
-  }-*/;
-
-  public final native Element scrollbarV() /*-{
-    return this.display.scrollbars.vert.node;
-  }-*/;
-
-  public final native void execCommand(String cmd) /*-{
-    this.execCommand(cmd)
-  }-*/;
-
-  public static final native KeyMap getKeyMap(String name) /*-{
-    return $wnd.CodeMirror.keyMap[name];
-  }-*/;
-
-  public static final native void addKeyMap(String name, KeyMap km) /*-{
-    $wnd.CodeMirror.keyMap[name] = km
-  }-*/;
-
-  public static final native void normalizeKeyMap(KeyMap km) /*-{
-    $wnd.CodeMirror.normalizeKeyMap(km);
-  }-*/;
-
-  public static final native void addCommand(String name, CommandRunner runner) /*-{
-    $wnd.CodeMirror.commands[name] = function(cm) {
-      runner.@net.codemirror.lib.CodeMirror.CommandRunner::run(
-        Lnet/codemirror/lib/CodeMirror;)(cm);
-    };
-  }-*/;
-
-  public final native Vim vim() /*-{
-    return this;
-  }-*/;
-
-  public final DisplaySide side() {
-    return extras().side();
-  }
-
-  public final Extras extras() {
-    return Extras.get(this);
-  }
-
-  public final native LineHandle setGutterMarker(int line, String gutterId, Element value) /*-{
-    return this.setGutterMarker(line, gutterId, value);
-  }-*/;
-
-  public final native LineHandle setGutterMarker(
-      LineHandle line, String gutterId, Element value) /*-{
-    return this.setGutterMarker(line, gutterId, value);
-  }-*/;
-
-  public final native boolean hasSearchHighlight() /*-{
-    return this.state.search && !!this.state.search.query;
-  }-*/;
-
-  protected CodeMirror() {}
-
-  public static class Viewport extends JavaScriptObject {
-    public final native int from() /*-{ return this.from }-*/;
-
-    public final native int to() /*-{ return this.to }-*/;
-
-    public final boolean contains(int line) {
-      return from() <= line && line < to();
-    }
-
-    protected Viewport() {}
-  }
-
-  public static class LineHandle extends JavaScriptObject {
-    protected LineHandle() {}
-  }
-
-  public static class RegisteredHandler extends JavaScriptObject {
-    protected RegisteredHandler() {}
-  }
-
-  public interface EventHandler {
-    void handle(CodeMirror instance, NativeEvent event);
-  }
-
-  public interface RenderLineHandler {
-    void handle(CodeMirror instance, LineHandle handle, Element element);
-  }
-
-  public interface GutterClickHandler {
-    void handle(CodeMirror instance, int line, String gutter, NativeEvent clickEvent);
-  }
-
-  public interface BeforeSelectionChangeHandler {
-    void handle(CodeMirror instance, Pos anchor, Pos head);
-  }
-
-  public interface ChangesHandler {
-    void handle(CodeMirror instance);
-  }
-
-  public interface CommandRunner {
-    void run(CodeMirror instance);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirrorDoc.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirrorDoc.java
deleted file mode 100644
index be1af05..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirrorDoc.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-/** The Doc object representing the content in a CodeMirror */
-public class CodeMirrorDoc extends JavaScriptObject {
-
-  public final native void replaceRange(String replacement, Pos from, Pos to) /*-{
-    this.replaceRange(replacement, from, to);
-  }-*/;
-
-  public final native void insertText(String insertion, Pos at) /*-{
-    this.replaceRange(insertion, at);
-  }-*/;
-
-  protected CodeMirrorDoc() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Configuration.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Configuration.java
deleted file mode 100644
index d37b70b..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Configuration.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-/**
- * Simple map-like structure to pass configuration to CodeMirror.
- *
- * @see <a href="http://codemirror.net/doc/manual.html#config">CodeMirror config</a>
- * @see CodeMirror#create(com.google.gwt.dom.client.Element, Configuration)
- */
-public class Configuration extends JavaScriptObject {
-  public static Configuration create() {
-    return createObject().cast();
-  }
-
-  public final native Configuration set(String name, String val)
-      /*-{ this[name] = val; return this; }-*/ ;
-
-  public final native Configuration set(String name, int val)
-      /*-{ this[name] = val; return this; }-*/ ;
-
-  public final native Configuration set(String name, double val)
-      /*-{ this[name] = val; return this; }-*/ ;
-
-  public final native Configuration set(String name, boolean val)
-      /*-{ this[name] = val; return this; }-*/ ;
-
-  public final native Configuration set(String name, JavaScriptObject val)
-      /*-{ this[name] = val; return this; }-*/ ;
-
-  protected Configuration() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java
deleted file mode 100644
index a5af703..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java
+++ /dev/null
@@ -1,221 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package net.codemirror.lib;
-
-import static com.google.gwt.dom.client.Style.Display.INLINE_BLOCK;
-import static com.google.gwt.dom.client.Style.Unit.PX;
-import static net.codemirror.lib.CodeMirror.LineClassWhere.WRAP;
-import static net.codemirror.lib.CodeMirror.style;
-
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.RangeInfo;
-import com.google.gerrit.client.blame.BlameInfo;
-import com.google.gerrit.client.diff.DisplaySide;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.i18n.client.DateTimeFormat;
-import com.google.gwt.user.client.DOM;
-import java.util.Date;
-import java.util.Objects;
-import net.codemirror.lib.CodeMirror.LineHandle;
-
-/** Additional features added to CodeMirror by Gerrit Code Review. */
-public class Extras {
-  private static final String ANNOTATION_GUTTER_ID = "CodeMirror-lint-markers";
-  private static final BlameConfig C = GWT.create(BlameConfig.class);
-
-  static final native Extras get(CodeMirror c) /*-{ return c.gerritExtras }-*/;
-
-  private static native void set(CodeMirror c, Extras e) /*-{ c.gerritExtras = e }-*/;
-
-  static void attach(CodeMirror c) {
-    set(c, new Extras(c));
-  }
-
-  private final CodeMirror cm;
-  private Element margin;
-  private DisplaySide side;
-  private double charWidthPx;
-  private double lineHeightPx;
-  private LineHandle activeLine;
-  private boolean annotated;
-
-  private Extras(CodeMirror cm) {
-    this.cm = cm;
-  }
-
-  public DisplaySide side() {
-    return side;
-  }
-
-  public void side(DisplaySide s) {
-    side = s;
-  }
-
-  public double charWidthPx() {
-    if (charWidthPx <= 1) {
-      int len = 100;
-      StringBuilder s = new StringBuilder();
-      for (int i = 0; i < len; i++) {
-        s.append('m');
-      }
-
-      Element e = DOM.createSpan();
-      e.getStyle().setDisplay(INLINE_BLOCK);
-      e.setInnerText(s.toString());
-
-      cm.measure().appendChild(e);
-      charWidthPx = ((double) e.getOffsetWidth()) / len;
-      e.removeFromParent();
-    }
-    return charWidthPx;
-  }
-
-  public double lineHeightPx() {
-    if (lineHeightPx <= 1) {
-      Element p = DOM.createDiv();
-      int lines = 1;
-      for (int i = 0; i < lines; i++) {
-        Element e = DOM.createDiv();
-        p.appendChild(e);
-
-        Element pre = DOM.createElement("pre");
-        pre.setInnerText("gqyŚŻŹŃ");
-        e.appendChild(pre);
-      }
-
-      cm.measure().appendChild(p);
-      lineHeightPx = ((double) p.getOffsetHeight()) / lines;
-      p.removeFromParent();
-    }
-    return lineHeightPx;
-  }
-
-  public void lineLength(int columns) {
-    if (margin == null) {
-      margin = DOM.createDiv();
-      margin.setClassName(style().margin());
-      cm.mover().appendChild(margin);
-    }
-    margin.getStyle().setMarginLeft(columns * charWidthPx(), PX);
-  }
-
-  public void showTabs(boolean show) {
-    Element e = cm.getWrapperElement();
-    if (show) {
-      e.addClassName(style().showTabs());
-    } else {
-      e.removeClassName(style().showTabs());
-    }
-  }
-
-  public final boolean hasActiveLine() {
-    return activeLine != null;
-  }
-
-  public final LineHandle activeLine() {
-    return activeLine;
-  }
-
-  public final boolean activeLine(LineHandle line) {
-    if (Objects.equals(activeLine, line)) {
-      return false;
-    }
-
-    if (activeLine != null) {
-      cm.removeLineClass(activeLine, WRAP, style().activeLine());
-    }
-    activeLine = line;
-    cm.addLineClass(activeLine, WRAP, style().activeLine());
-    return true;
-  }
-
-  public final void clearActiveLine() {
-    if (activeLine != null) {
-      cm.removeLineClass(activeLine, WRAP, style().activeLine());
-      activeLine = null;
-    }
-  }
-
-  public boolean isAnnotated() {
-    return annotated;
-  }
-
-  public final void clearAnnotations() {
-    JsArrayString gutters = ((JsArrayString) JsArrayString.createArray());
-    cm.setOption("gutters", gutters);
-    annotated = false;
-  }
-
-  public final void setAnnotations(JsArray<BlameInfo> blameInfos) {
-    if (blameInfos.length() > 0) {
-      setBlameInfo(blameInfos);
-      JsArrayString gutters = ((JsArrayString) JsArrayString.createArray());
-      gutters.push(ANNOTATION_GUTTER_ID);
-      cm.setOption("gutters", gutters);
-      annotated = true;
-      DateTimeFormat format = DateTimeFormat.getFormat(DateTimeFormat.PredefinedFormat.DATE_SHORT);
-      JsArray<LintLine> annotations = JsArray.createArray().cast();
-      for (BlameInfo blameInfo : Natives.asList(blameInfos)) {
-        for (RangeInfo range : Natives.asList(blameInfo.ranges())) {
-          Date commitTime = new Date(blameInfo.time() * 1000L);
-          String shortId = blameInfo.id().substring(0, 8);
-          String shortBlame =
-              C.shortBlameMsg(shortId, format.format(commitTime), blameInfo.author());
-          String detailedBlame =
-              C.detailedBlameMsg(
-                  blameInfo.id(),
-                  blameInfo.author(),
-                  FormatUtil.mediumFormat(commitTime),
-                  blameInfo.commitMsg());
-
-          annotations.push(
-              LintLine.create(shortBlame, detailedBlame, shortId, Pos.create(range.start() - 1)));
-        }
-      }
-      cm.setOption("lint", getAnnotation(annotations));
-    }
-  }
-
-  private native JavaScriptObject getAnnotation(JsArray<LintLine> annotations) /*-{
-     return {
-        getAnnotations: function(text, options, cm) { return annotations; }
-     };
-  }-*/;
-
-  public final native JsArray<BlameInfo> getBlameInfo() /*-{
-    return this.blameInfos;
-  }-*/;
-
-  public final native void setBlameInfo(JsArray<BlameInfo> blameInfos) /*-{
-    this['blameInfos'] = blameInfos;
-  }-*/;
-
-  public final void toggleAnnotation() {
-    toggleAnnotation(getBlameInfo());
-  }
-
-  public final void toggleAnnotation(JsArray<BlameInfo> blameInfos) {
-    if (isAnnotated()) {
-      clearAnnotations();
-    } else {
-      setAnnotations(blameInfos);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java
deleted file mode 100644
index be1852f..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-/** Object that associates a key or key combination with a handler. */
-public class KeyMap extends JavaScriptObject {
-  public static KeyMap create() {
-    return createObject().cast();
-  }
-
-  public final native KeyMap on(String key, Runnable thunk) /*-{
-    this[key] = function() { $entry(thunk.@java.lang.Runnable::run()()); };
-    return this;
-  }-*/;
-
-  /** Do not handle inside of CodeMirror; instead push up the DOM tree. */
-  public final native KeyMap propagate(String key) /*-{
-    this[key] = false;
-    return this;
-  }-*/;
-
-  /** Delegate undefined keys to another KeyMap implementation. */
-  public final native KeyMap fallthrough(KeyMap m) /*-{
-    this.fallthrough = m;
-    return this;
-  }-*/;
-
-  protected KeyMap() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Lib.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Lib.java
deleted file mode 100644
index f205ef9..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Lib.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.DataResource;
-import com.google.gwt.resources.client.DataResource.DoNotEmbed;
-import com.google.gwt.resources.client.ExternalTextResource;
-
-interface Lib extends ClientBundle {
-  Lib I = GWT.create(Lib.class);
-
-  @Source("cm.css")
-  ExternalTextResource css();
-
-  @Source("cm.js")
-  @DoNotEmbed
-  DataResource js();
-
-  @Source("style.css")
-  CodeMirror.Style style();
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/LineWidget.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/LineWidget.java
deleted file mode 100644
index 4a15fd3..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/LineWidget.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-/** LineWidget objects used within CodeMirror. */
-public class LineWidget extends JavaScriptObject {
-  public final native void clear() /*-{ this.clear() }-*/;
-
-  public final native void changed() /*-{ this.changed() }-*/;
-
-  public final native void onRedraw(Runnable thunk) /*-{
-    this.on("redraw", $entry(function() {
-      thunk.@java.lang.Runnable::run()();
-    }))
-  }-*/;
-
-  public final native void onFirstRedraw(Runnable thunk) /*-{
-    var w = this;
-    var h = $entry(function() {
-      thunk.@java.lang.Runnable::run()();
-      w.off("redraw", h);
-    });
-    w.on("redraw", h);
-  }-*/;
-
-  protected LineWidget() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java
deleted file mode 100644
index b0b2ae0..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.dom.client.StyleInjector;
-
-public class LintLine extends JavaScriptObject {
-  public static LintLine create(String shortMsg, String msg, String sev, Pos line) {
-    StyleInjector.inject(
-        ".CodeMirror-lint-marker-"
-            + sev
-            + " {\n"
-            + "  visibility: hidden;\n"
-            + "  text-overflow: ellipsis;\n"
-            + "  white-space: nowrap;\n"
-            + "  overflow: hidden;\n"
-            + "  position: relative;\n"
-            + "}\n"
-            + ".CodeMirror-lint-marker-"
-            + sev
-            + ":after {\n"
-            + "  content:'"
-            + shortMsg
-            + "';\n"
-            + "  visibility: visible;\n"
-            + "}");
-    return create(msg, sev, line, null);
-  }
-
-  public static native LintLine create(String msg, String sev, Pos f, Pos t) /*-{
-    return {
-      message : msg,
-      severity : sev,
-      from : f,
-      to : t
-    };
-  }-*/;
-
-  public final native String message() /*-{ return this.message; }-*/;
-
-  public final native String detailedMessage() /*-{ return this.message; }-*/;
-
-  public final native String severity() /*-{ return this.severity; }-*/;
-
-  public final native Pos from() /*-{ return this.from; }-*/;
-
-  public final native Pos to() /*-{ return this.to; }-*/;
-
-  protected LintLine() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java
deleted file mode 100644
index 01bc7e2..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gwt.core.client.Callback;
-import com.google.gwt.core.client.ScriptInjector;
-import com.google.gwt.dom.client.ScriptElement;
-import com.google.gwt.dom.client.StyleInjector;
-import com.google.gwt.resources.client.ExternalTextResource;
-import com.google.gwt.resources.client.ResourceCallback;
-import com.google.gwt.resources.client.ResourceException;
-import com.google.gwt.resources.client.TextResource;
-import com.google.gwt.safehtml.shared.SafeUri;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-public class Loader {
-  private static native boolean isLibLoaded() /*-{ return $wnd.hasOwnProperty('CodeMirror'); }-*/;
-
-  static void initLibrary(AsyncCallback<Void> cb) {
-    if (isLibLoaded()) {
-      cb.onSuccess(null);
-      return;
-    }
-
-    CallbackGroup group = new CallbackGroup();
-    injectCss(Lib.I.css(), group.<Void>addEmpty());
-    injectScript(
-        Lib.I.js().getSafeUri(),
-        group.add(
-            new AsyncCallback<Void>() {
-              @Override
-              public void onSuccess(Void result) {
-                Vim.initKeyMap();
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {}
-            }));
-    group.addListener(cb);
-    group.done();
-  }
-
-  private static void injectCss(ExternalTextResource css, AsyncCallback<Void> cb) {
-    try {
-      css.getText(
-          new ResourceCallback<TextResource>() {
-            @Override
-            public void onSuccess(TextResource resource) {
-              StyleInjector.inject(resource.getText());
-              Lib.I.style().ensureInjected();
-              cb.onSuccess(null);
-            }
-
-            @Override
-            public void onError(ResourceException e) {
-              cb.onFailure(e);
-            }
-          });
-    } catch (ResourceException e) {
-      cb.onFailure(e);
-    }
-  }
-
-  public static void injectScript(SafeUri js, AsyncCallback<Void> callback) {
-    final ScriptElement[] script = new ScriptElement[1];
-    script[0] =
-        ScriptInjector.fromUrl(js.asString())
-            .setWindow(ScriptInjector.TOP_WINDOW)
-            .setCallback(
-                new Callback<Void, Exception>() {
-                  @Override
-                  public void onSuccess(Void result) {
-                    script[0].removeFromParent();
-                    callback.onSuccess(result);
-                  }
-
-                  @Override
-                  public void onFailure(Exception reason) {
-                    callback.onFailure(reason);
-                  }
-                })
-            .inject()
-            .cast();
-  }
-
-  private Loader() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java
deleted file mode 100644
index 38c3906..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.dom.client.Element;
-
-/** Object that represents a text marker within CodeMirror */
-public class MergeView extends JavaScriptObject {
-  public static MergeView create(Element p, Configuration cfg) {
-    MergeView mv = newMergeView(p, cfg);
-    Extras.attach(mv.leftOriginal());
-    Extras.attach(mv.editor());
-    return mv;
-  }
-
-  private static native MergeView newMergeView(Element p, Configuration cfg) /*-{
-    return $wnd.CodeMirror.MergeView(p, cfg);
-  }-*/;
-
-  public final native CodeMirror leftOriginal() /*-{
-    return this.leftOriginal();
-  }-*/;
-
-  public final native CodeMirror editor() /*-{
-    return this.editor();
-  }-*/;
-
-  public final native void setShowDifferences(boolean b) /*-{
-    this.setShowDifferences(b);
-  }-*/;
-
-  public final native Element getGapElement() /*-{
-    return $doc.getElementsByClassName("CodeMirror-merge-gap")[0];
-  }-*/;
-
-  protected MergeView() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Pos.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Pos.java
deleted file mode 100644
index 7f83b75..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Pos.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-/** Pos (or {line, ch}) objects used within CodeMirror. */
-public class Pos extends JavaScriptObject {
-  public static final native Pos create(int line) /*-{
-    return $wnd.CodeMirror.Pos(line)
-  }-*/;
-
-  public static final native Pos create(int line, int ch) /*-{
-    return $wnd.CodeMirror.Pos(line, ch)
-  }-*/;
-
-  public final native void line(int l) /*-{ this.line = l }-*/;
-
-  public final native void ch(int c) /*-{ this.ch = c }-*/;
-
-  public final native int line() /*-{ return this.line }-*/;
-
-  public final native int ch() /*-{ return this.ch || 0 }-*/;
-
-  public final boolean equals(Pos o) {
-    return this == o || (line() == o.line() && ch() == o.ch());
-  }
-
-  protected Pos() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Rect.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Rect.java
deleted file mode 100644
index 1114403..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Rect.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-/** {left, right, top, bottom} objects used within CodeMirror. */
-public class Rect extends JavaScriptObject {
-  public final native double left() /*-{ return this.left }-*/;
-
-  public final native double right() /*-{ return this.right }-*/;
-
-  public final native double top() /*-{ return this.top }-*/;
-
-  public final native double bottom() /*-{ return this.bottom }-*/;
-
-  protected Rect() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/ScrollInfo.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/ScrollInfo.java
deleted file mode 100644
index 432a60f..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/ScrollInfo.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-/** Returned by {@link CodeMirror#getScrollInfo()}. */
-public class ScrollInfo extends JavaScriptObject {
-  public final native double left() /*-{ return this.left }-*/;
-
-  public final native double top() /*-{ return this.top }-*/;
-
-  /**
-   * Pixel height of the full content being scrolled. This may only be an estimate given by
-   * CodeMirror. Line widgets further down in the document may not be measured, so line heights can
-   * be incorrect until drawn.
-   */
-  public final native double height() /*-{ return this.height }-*/;
-
-  public final native double width() /*-{ return this.width }-*/;
-
-  /** Visible height of the viewport, excluding scrollbars. */
-  public final native double clientHeight() /*-{ return this.clientHeight }-*/;
-
-  public final native double clientWidth() /*-{ return this.clientWidth }-*/;
-
-  protected ScrollInfo() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/TextMarker.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/TextMarker.java
deleted file mode 100644
index c3d248a..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/TextMarker.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.lib;
-
-import com.google.gerrit.client.diff.CommentRange;
-import com.google.gwt.core.client.JavaScriptObject;
-
-/** Object that represents a text marker within CodeMirror */
-public class TextMarker extends JavaScriptObject {
-  public final native void clear() /*-{ this.clear(); }-*/;
-
-  public final native void changed() /*-{ this.changed(); }-*/;
-
-  public final native FromTo find() /*-{ return this.find(); }-*/;
-
-  public final native void on(String event, Runnable thunk)
-      /*-{ this.on(event, function(){$entry(thunk.@java.lang.Runnable::run()())}) }-*/ ;
-
-  protected TextMarker() {}
-
-  public static class FromTo extends JavaScriptObject {
-    public static final native FromTo create(Pos f, Pos t) /*-{
-      return {from: f, to: t}
-    }-*/;
-
-    public static FromTo create(CommentRange range) {
-      return create(
-          Pos.create(range.startLine() - 1, range.startCharacter()),
-          Pos.create(range.endLine() - 1, range.endCharacter()));
-    }
-
-    public final native Pos from() /*-{ return this.from }-*/;
-
-    public final native Pos to() /*-{ return this.to }-*/;
-
-    public final native void from(Pos f) /*-{ this.from = f }-*/;
-
-    public final native void to(Pos t) /*-{ this.to = t }-*/;
-
-    protected FromTo() {}
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java
deleted file mode 100644
index 06016ef..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java
+++ /dev/null
@@ -1,70 +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 net.codemirror.lib;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-/**
- * Glue around the Vim emulation for {@link CodeMirror}.
- *
- * <p>As an instance {@code this} is actually the {@link CodeMirror} object. Class Vim is providing
- * a new namespace for Vim related methods that are associated with an editor.
- */
-public class Vim extends JavaScriptObject {
-  static void initKeyMap() {
-    KeyMap km = KeyMap.create();
-    for (String key : new String[] {"A", "C", "I", "O", "R", "U"}) {
-      km.propagate(key);
-      km.propagate("'" + key.toLowerCase() + "'");
-    }
-    for (String key :
-        new String[] {
-          "Ctrl-C", "Ctrl-O", "Ctrl-P", "Ctrl-S", "Ctrl-F", "Ctrl-B", "Ctrl-R",
-        }) {
-      km.propagate(key);
-    }
-    for (int i = 0; i <= 9; i++) {
-      km.propagate("Ctrl-" + i);
-    }
-    km.fallthrough(CodeMirror.getKeyMap("vim"));
-    CodeMirror.addKeyMap("vim_ro", km);
-
-    mapKey("j", "gj");
-    mapKey("k", "gk");
-    mapKey("Down", "gj");
-    mapKey("Up", "gk");
-    mapKey("<PageUp>", "<C-u>");
-    mapKey("<PageDown>", "<C-d>");
-  }
-
-  public static final native void mapKey(String alias, String actual) /*-{
-    $wnd.CodeMirror.Vim.map(alias, actual)
-  }-*/;
-
-  public final native void handleKey(String key) /*-{
-    $wnd.CodeMirror.Vim.handleKey(this, key)
-  }-*/;
-
-  public final native void handleEx(String exCommand) /*-{
-    $wnd.CodeMirror.Vim.handleEx(this, exCommand);
-  }-*/;
-
-  public final native boolean hasSearchHighlight() /*-{
-    var v = this.state.vim;
-    return v && v.searchState_ && !!v.searchState_.getOverlay();
-  }-*/;
-
-  protected Vim() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css b/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css
deleted file mode 100644
index 022a800..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css
+++ /dev/null
@@ -1,124 +0,0 @@
-/* Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-@external .CodeMirror;
-@external .CodeMirror-lines;
-@external .CodeMirror-linenumber;
-@external .CodeMirror-lint-markers;
-@external .CodeMirror-lint-tooltip;
-@external .CodeMirror-overlayscroll-horizontal;
-@external .CodeMirror-overlayscroll-vertical;
-@external .CodeMirror-scrollbar-filler;
-@external .cm-tab;
-@external .cm-searching;
-@external .cm-trailingspace;
-@external .unifiedLineNumber;
-
-/* Reduce margins around CodeMirror to save space. */
-.CodeMirror-lines {
-  padding: 0;
-}
-.CodeMirror pre {
-  padding: 0;
-  line-height: normal;
-}
-
-/* Minimum scrollbar bubble size even on large files. */
-.CodeMirror-overlayscroll-horizontal div {
-  min-width: 25px;
-}
-.CodeMirror-overlayscroll-vertical div {
-  min-height: 25px;
-}
-/* Ensure the scrollbars are not too narrow */
-.CodeMirror-overlayscroll-horizontal {
-  min-height: 12px;
-}
-.CodeMirror-overlayscroll-vertical {
-  min-width: 12px;
-}
-.CodeMirror-scrollbar-filler {
-  min-height: 12px;
-  min-width: 12px;
-}
-/* Stack the scrollbar so annotations can receive clicks. */
-.CodeMirror-overlayscroll-vertical {
-  z-index: inherit;
-}
-.CodeMirror-overlayscroll-horizontal div,
-.CodeMirror-overlayscroll-vertical div {
-  background-color: rgba(128, 128, 128, 0.50);
-  z-index: 8;
-}
-
-/* Highlight current line number in the line gutter. */
-.activeLine .CodeMirror-linenumber,
-.activeLine .unifiedLineNumber {
-  background-color: #bcf !important;
-  color: #000;
-}
-
-.showTabs .cm-tab:before {
-  position: absolute;
-  content: "\00bb";
-  color: #f00;
-}
-
-.cm-searching {
-  background-color: #ffa !important;
-}
-
-.cm-trailingspace {
-  background-color: red !important;
-}
-
-/* Line length margin displayed at NN columns to provide
- * a visual guide for length of any single line of code.
- */
-.margin {
-  position: absolute;
-  top: 0;
-  bottom: 0;
-  width: 0;
-  border-right: 1px dashed #ffa500;
-  z-index: 2;
-  cursor: text;
-}
-
-.CodeMirror-lint-markers {
-  width: 250px;
-}
-
-.CodeMirror-lint-tooltip {
-  background-color: infobackground;
-  border: 1px solid black;
-  border-radius: 4px 4px 4px 4px;
-  color: infotext;
-  font-family: monospace;
-  font-size: 10pt;
-  overflow: hidden;
-  padding: 2px 5px;
-  position: fixed;
-  white-space: pre;
-  white-space: pre-wrap;
-  z-index: 100;
-  max-width: 600px;
-  opacity: 0;
-  transition: opacity .4s;
-  -moz-transition: opacity .4s;
-  -webkit-transition: opacity .4s;
-  -o-transition: opacity .4s;
-  -ms-transition: opacity .4s;
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
deleted file mode 100644
index c6f113e..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
+++ /dev/null
@@ -1,277 +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 net.codemirror.mode;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-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;
-
-/** Description of a CodeMirror language mode. */
-public class ModeInfo extends JavaScriptObject {
-  private static NativeMap<ModeInfo> byMime;
-  private static NativeMap<ModeInfo> byExt;
-
-  /** Map of names such as "clike" to URI for code download. */
-  private static final Map<String, SafeUri> modeUris = new HashMap<>();
-
-  static {
-    indexModes(
-        new DataResource[] {
-          Modes.I.apl(),
-          Modes.I.asciiarmor(),
-          Modes.I.asn_1(),
-          Modes.I.asterisk(),
-          Modes.I.brainfuck(),
-          Modes.I.clike(),
-          Modes.I.clojure(),
-          Modes.I.cmake(),
-          Modes.I.cobol(),
-          Modes.I.coffeescript(),
-          Modes.I.commonlisp(),
-          Modes.I.crystal(),
-          Modes.I.css(),
-          Modes.I.cypher(),
-          Modes.I.d(),
-          Modes.I.dart(),
-          Modes.I.diff(),
-          Modes.I.django(),
-          Modes.I.dockerfile(),
-          Modes.I.dtd(),
-          Modes.I.dylan(),
-          Modes.I.ebnf(),
-          Modes.I.ecl(),
-          Modes.I.eiffel(),
-          Modes.I.elm(),
-          Modes.I.erlang(),
-          Modes.I.factor(),
-          Modes.I.fcl(),
-          Modes.I.forth(),
-          Modes.I.fortran(),
-          Modes.I.gas(),
-          Modes.I.gerrit_commit(),
-          Modes.I.gfm(),
-          Modes.I.gherkin(),
-          Modes.I.go(),
-          Modes.I.groovy(),
-          Modes.I.haml(),
-          Modes.I.handlebars(),
-          Modes.I.haskell_literate(),
-          Modes.I.haskell(),
-          Modes.I.haxe(),
-          Modes.I.htmlembedded(),
-          Modes.I.htmlmixed(),
-          Modes.I.http(),
-          Modes.I.idl(),
-          Modes.I.javascript(),
-          Modes.I.jinja2(),
-          Modes.I.jsx(),
-          Modes.I.julia(),
-          Modes.I.livescript(),
-          Modes.I.lua(),
-          Modes.I.markdown(),
-          Modes.I.mathematica(),
-          Modes.I.mbox(),
-          Modes.I.mirc(),
-          Modes.I.mllike(),
-          Modes.I.modelica(),
-          Modes.I.mscgen(),
-          Modes.I.mumps(),
-          Modes.I.nginx(),
-          Modes.I.nsis(),
-          Modes.I.ntriples(),
-          Modes.I.octave(),
-          Modes.I.oz(),
-          Modes.I.pascal(),
-          Modes.I.pegjs(),
-          Modes.I.perl(),
-          Modes.I.php(),
-          Modes.I.pig(),
-          Modes.I.powershell(),
-          Modes.I.properties(),
-          Modes.I.protobuf(),
-          Modes.I.pug(),
-          Modes.I.puppet(),
-          Modes.I.python(),
-          Modes.I.q(),
-          Modes.I.r(),
-          Modes.I.rpm(),
-          Modes.I.rst(),
-          Modes.I.ruby(),
-          Modes.I.rust(),
-          Modes.I.sas(),
-          Modes.I.sass(),
-          Modes.I.scheme(),
-          Modes.I.shell(),
-          Modes.I.smalltalk(),
-          Modes.I.smarty(),
-          Modes.I.solr(),
-          Modes.I.soy(),
-          Modes.I.sparql(),
-          Modes.I.spreadsheet(),
-          Modes.I.sql(),
-          Modes.I.stex(),
-          Modes.I.stylus(),
-          Modes.I.swift(),
-          Modes.I.tcl(),
-          Modes.I.textile(),
-          Modes.I.tiddlywiki(),
-          Modes.I.tiki(),
-          Modes.I.toml(),
-          Modes.I.tornado(),
-          Modes.I.troff(),
-          Modes.I.ttcn_cfg(),
-          Modes.I.ttcn(),
-          Modes.I.turtle(),
-          Modes.I.twig(),
-          Modes.I.vb(),
-          Modes.I.vbscript(),
-          Modes.I.velocity(),
-          Modes.I.verilog(),
-          Modes.I.vhdl(),
-          Modes.I.vue(),
-          Modes.I.webidl(),
-          Modes.I.xml(),
-          Modes.I.xquery(),
-          Modes.I.yacas(),
-          Modes.I.yaml_frontmatter(),
-          Modes.I.yaml(),
-          Modes.I.z80(),
-        });
-
-    alias("application/x-httpd-php-open", "application/x-httpd-php");
-    alias("application/x-javascript", "application/javascript");
-    alias("application/x-shellscript", "text/x-sh");
-    alias("application/x-tcl", "text/x-tcl");
-    alias("text/typescript", "application/typescript");
-    alias("text/x-c", "text/x-csrc");
-    alias("text/x-c++hdr", "text/x-c++src");
-    alias("text/x-chdr", "text/x-csrc");
-    alias("text/x-h", "text/x-csrc");
-    alias("text/x-ini", "text/x-properties");
-    alias("text/x-java-source", "text/x-java");
-    alias("text/x-php", "application/x-httpd-php");
-    alias("text/x-scripttcl", "text/x-tcl");
-  }
-
-  /** All supported modes. */
-  public static native JsArray<ModeInfo> all() /*-{
-    return $wnd.CodeMirror.modeInfo
-  }-*/;
-
-  private static native void setAll(JsArray<ModeInfo> m) /*-{
-    $wnd.CodeMirror.modeInfo = m
-  }-*/;
-
-  /** Look up mode by primary or alternate MIME types. */
-  public static ModeInfo findModeByMIME(String mime) {
-    return byMime.get(mime);
-  }
-
-  public static SafeUri getModeScriptUri(String mode) {
-    return modeUris.get(mode);
-  }
-
-  /** Look up mode by MIME type or file extension from a path. */
-  public static ModeInfo findMode(String mime, String path) {
-    ModeInfo m = byMime.get(mime);
-    if (m != null) {
-      return m;
-    }
-
-    int s = path.lastIndexOf('/');
-    int d = path.lastIndexOf('.');
-    if (d == -1 || s > d) {
-      return null; // punt on "foo.src/bar" type paths.
-    }
-
-    if (byExt == null) {
-      byExt = NativeMap.create();
-      for (ModeInfo mode : Natives.asList(all())) {
-        for (String ext : Natives.asList(mode.ext())) {
-          byExt.put(ext, mode);
-        }
-      }
-    }
-    return byExt.get(path.substring(d + 1));
-  }
-
-  private static void alias(String serverMime, String toMime) {
-    ModeInfo mode = byMime.get(toMime);
-    if (mode != null) {
-      byMime.put(serverMime, mode);
-    }
-  }
-
-  private static void indexModes(DataResource[] all) {
-    for (DataResource r : all) {
-      modeUris.put(r.getName(), r.getSafeUri());
-    }
-
-    JsArray<ModeInfo> modeList = all();
-    modeList.push(gerrit_commit());
-
-    byMime = NativeMap.create();
-    JsArray<ModeInfo> filtered = JsArray.createArray().cast();
-    for (ModeInfo m : Natives.asList(modeList)) {
-      if (modeUris.containsKey(m.mode())) {
-        filtered.push(m);
-
-        for (String mimeType : Natives.asList(m.mimes())) {
-          byMime.put(mimeType, m);
-        }
-        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());
-          }
-        });
-    setAll(filtered);
-  }
-
-  /** Human readable name of the mode, such as "C++". */
-  public final native String name() /*-{ return this.name }-*/;
-
-  /** Internal CodeMirror name for {@code mode.js} file to load. */
-  public final native String mode() /*-{ return this.mode }-*/;
-
-  /** Primary MIME type to activate this mode. */
-  public final native String mime() /*-{ return this.mime }-*/;
-
-  /** Primary and additional MIME types that activate this mode. */
-  public final native JsArrayString mimes() /*-{ return this.mimes || [this.mime] }-*/;
-
-  private native JsArrayString ext() /*-{ return this.ext || [] }-*/;
-
-  protected ModeInfo() {}
-
-  private static native ModeInfo gerrit_commit() /*-{
-    return {name: "Git Commit Message",
-            mime: "text/x-gerrit-commit-message",
-            mode: "gerrit_commit"}
-  }-*/;
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java
deleted file mode 100644
index 5fda608..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java
+++ /dev/null
@@ -1,114 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.mode;
-
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import net.codemirror.lib.Loader;
-
-public class ModeInjector {
-  private static boolean canLoad(String mode) {
-    return ModeInfo.getModeScriptUri(mode) != null;
-  }
-
-  private static native boolean isModeLoaded(String n)
-      /*-{ return $wnd.CodeMirror.modes.hasOwnProperty(n); }-*/ ;
-
-  private static native boolean isMimeLoaded(String n)
-      /*-{ return $wnd.CodeMirror.mimeModes.hasOwnProperty(n); }-*/ ;
-
-  private static native JsArrayString getDependencies(String n)
-      /*-{ return $wnd.CodeMirror.modes[n].dependencies || []; }-*/ ;
-
-  private final Set<String> loading = new HashSet<>(4);
-  private int pending;
-  private AsyncCallback<Void> appCallback;
-
-  public ModeInjector add(String name) {
-    if (name == null || isModeLoaded(name) || isMimeLoaded(name)) {
-      return this;
-    }
-
-    ModeInfo m = ModeInfo.findModeByMIME(name);
-    if (m != null) {
-      name = m.mode();
-    }
-
-    if (!canLoad(name)) {
-      Logger.getLogger("net.codemirror")
-          .log(Level.WARNING, "CodeMirror mode " + name + " not configured.");
-      return this;
-    }
-
-    loading.add(name);
-    return this;
-  }
-
-  public void inject(AsyncCallback<Void> appCallback) {
-    this.appCallback = appCallback;
-    for (String mode : loading) {
-      beginLoading(mode);
-    }
-    if (pending == 0) {
-      appCallback.onSuccess(null);
-    }
-  }
-
-  private void beginLoading(String mode) {
-    pending++;
-    Loader.injectScript(
-        ModeInfo.getModeScriptUri(mode),
-        new AsyncCallback<Void>() {
-          @Override
-          public void onSuccess(Void result) {
-            pending--;
-            ensureDependenciesAreLoaded(mode);
-            if (pending == 0) {
-              appCallback.onSuccess(null);
-            }
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            if (--pending == 0) {
-              appCallback.onFailure(caught);
-            }
-          }
-        });
-  }
-
-  private void ensureDependenciesAreLoaded(String mode) {
-    JsArrayString deps = getDependencies(mode);
-    for (int i = 0; i < deps.length(); i++) {
-      String d = deps.get(i);
-      if (loading.contains(d) || isModeLoaded(d)) {
-        continue;
-      }
-
-      if (!canLoad(d)) {
-        Logger.getLogger("net.codemirror")
-            .log(Level.SEVERE, "CodeMirror mode " + d + " needs " + d);
-        continue;
-      }
-
-      loading.add(d);
-      beginLoading(d);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
deleted file mode 100644
index 2b87a34..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
+++ /dev/null
@@ -1,510 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 net.codemirror.mode;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.DataResource;
-import com.google.gwt.resources.client.DataResource.DoNotEmbed;
-
-public interface Modes extends ClientBundle {
-  Modes I = GWT.create(Modes.class);
-
-  @Source("apl.js")
-  @DoNotEmbed
-  DataResource apl();
-
-  @Source("asciiarmor.js")
-  @DoNotEmbed
-  DataResource asciiarmor();
-
-  @Source("asn.1.js")
-  @DoNotEmbed
-  DataResource asn_1();
-
-  @Source("asterisk.js")
-  @DoNotEmbed
-  DataResource asterisk();
-
-  @Source("brainfuck.js")
-  @DoNotEmbed
-  DataResource brainfuck();
-
-  @Source("clike.js")
-  @DoNotEmbed
-  DataResource clike();
-
-  @Source("clojure.js")
-  @DoNotEmbed
-  DataResource clojure();
-
-  @Source("cmake.js")
-  @DoNotEmbed
-  DataResource cmake();
-
-  @Source("cobol.js")
-  @DoNotEmbed
-  DataResource cobol();
-
-  @Source("coffeescript.js")
-  @DoNotEmbed
-  DataResource coffeescript();
-
-  @Source("commonlisp.js")
-  @DoNotEmbed
-  DataResource commonlisp();
-
-  @Source("crystal.js")
-  @DoNotEmbed
-  DataResource crystal();
-
-  @Source("css.js")
-  @DoNotEmbed
-  DataResource css();
-
-  @Source("cypher.js")
-  @DoNotEmbed
-  DataResource cypher();
-
-  @Source("d.js")
-  @DoNotEmbed
-  DataResource d();
-
-  @Source("dart.js")
-  @DoNotEmbed
-  DataResource dart();
-
-  @Source("diff.js")
-  @DoNotEmbed
-  DataResource diff();
-
-  @Source("django.js")
-  @DoNotEmbed
-  DataResource django();
-
-  @Source("dockerfile.js")
-  @DoNotEmbed
-  DataResource dockerfile();
-
-  @Source("dtd.js")
-  @DoNotEmbed
-  DataResource dtd();
-
-  @Source("dylan.js")
-  @DoNotEmbed
-  DataResource dylan();
-
-  @Source("ebnf.js")
-  @DoNotEmbed
-  DataResource ebnf();
-
-  @Source("ecl.js")
-  @DoNotEmbed
-  DataResource ecl();
-
-  @Source("eiffel.js")
-  @DoNotEmbed
-  DataResource eiffel();
-
-  @Source("elm.js")
-  @DoNotEmbed
-  DataResource elm();
-
-  @Source("erlang.js")
-  @DoNotEmbed
-  DataResource erlang();
-
-  @Source("factor.js")
-  @DoNotEmbed
-  DataResource factor();
-
-  @Source("fcl.js")
-  @DoNotEmbed
-  DataResource fcl();
-
-  @Source("forth.js")
-  @DoNotEmbed
-  DataResource forth();
-
-  @Source("fortran.js")
-  @DoNotEmbed
-  DataResource fortran();
-
-  @Source("gas.js")
-  @DoNotEmbed
-  DataResource gas();
-
-  @Source("gerrit/commit.js")
-  @DoNotEmbed
-  DataResource gerrit_commit();
-
-  @Source("gfm.js")
-  @DoNotEmbed
-  DataResource gfm();
-
-  @Source("gherkin.js")
-  @DoNotEmbed
-  DataResource gherkin();
-
-  @Source("go.js")
-  @DoNotEmbed
-  DataResource go();
-
-  @Source("groovy.js")
-  @DoNotEmbed
-  DataResource groovy();
-
-  @Source("haml.js")
-  @DoNotEmbed
-  DataResource haml();
-
-  @Source("handlebars.js")
-  @DoNotEmbed
-  DataResource handlebars();
-
-  @Source("haskell-literate.js")
-  @DoNotEmbed
-  DataResource haskell_literate();
-
-  @Source("haskell.js")
-  @DoNotEmbed
-  DataResource haskell();
-
-  @Source("haxe.js")
-  @DoNotEmbed
-  DataResource haxe();
-
-  @Source("htmlembedded.js")
-  @DoNotEmbed
-  DataResource htmlembedded();
-
-  @Source("htmlmixed.js")
-  @DoNotEmbed
-  DataResource htmlmixed();
-
-  @Source("http.js")
-  @DoNotEmbed
-  DataResource http();
-
-  @Source("idl.js")
-  @DoNotEmbed
-  DataResource idl();
-
-  @Source("javascript.js")
-  @DoNotEmbed
-  DataResource javascript();
-
-  @Source("jinja2.js")
-  @DoNotEmbed
-  DataResource jinja2();
-
-  @Source("jsx.js")
-  @DoNotEmbed
-  DataResource jsx();
-
-  @Source("julia.js")
-  @DoNotEmbed
-  DataResource julia();
-
-  @Source("livescript.js")
-  @DoNotEmbed
-  DataResource livescript();
-
-  @Source("lua.js")
-  @DoNotEmbed
-  DataResource lua();
-
-  @Source("markdown.js")
-  @DoNotEmbed
-  DataResource markdown();
-
-  @Source("mathematica.js")
-  @DoNotEmbed
-  DataResource mathematica();
-
-  @Source("mbox.js")
-  @DoNotEmbed
-  DataResource mbox();
-
-  @Source("mirc.js")
-  @DoNotEmbed
-  DataResource mirc();
-
-  @Source("mllike.js")
-  @DoNotEmbed
-  DataResource mllike();
-
-  @Source("modelica.js")
-  @DoNotEmbed
-  DataResource modelica();
-
-  @Source("mscgen.js")
-  @DoNotEmbed
-  DataResource mscgen();
-
-  @Source("mumps.js")
-  @DoNotEmbed
-  DataResource mumps();
-
-  @Source("nginx.js")
-  @DoNotEmbed
-  DataResource nginx();
-
-  @Source("nsis.js")
-  @DoNotEmbed
-  DataResource nsis();
-
-  @Source("ntriples.js")
-  @DoNotEmbed
-  DataResource ntriples();
-
-  @Source("octave.js")
-  @DoNotEmbed
-  DataResource octave();
-
-  @Source("oz.js")
-  @DoNotEmbed
-  DataResource oz();
-
-  @Source("pascal.js")
-  @DoNotEmbed
-  DataResource pascal();
-
-  @Source("pegjs.js")
-  @DoNotEmbed
-  DataResource pegjs();
-
-  @Source("perl.js")
-  @DoNotEmbed
-  DataResource perl();
-
-  @Source("php.js")
-  @DoNotEmbed
-  DataResource php();
-
-  @Source("pig.js")
-  @DoNotEmbed
-  DataResource pig();
-
-  @Source("powershell.js")
-  @DoNotEmbed
-  DataResource powershell();
-
-  @Source("properties.js")
-  @DoNotEmbed
-  DataResource properties();
-
-  @Source("protobuf.js")
-  @DoNotEmbed
-  DataResource protobuf();
-
-  @Source("pug.js")
-  @DoNotEmbed
-  DataResource pug();
-
-  @Source("puppet.js")
-  @DoNotEmbed
-  DataResource puppet();
-
-  @Source("python.js")
-  @DoNotEmbed
-  DataResource python();
-
-  @Source("q.js")
-  @DoNotEmbed
-  DataResource q();
-
-  @Source("r.js")
-  @DoNotEmbed
-  DataResource r();
-
-  @Source("rpm.js")
-  @DoNotEmbed
-  DataResource rpm();
-
-  @Source("rst.js")
-  @DoNotEmbed
-  DataResource rst();
-
-  @Source("ruby.js")
-  @DoNotEmbed
-  DataResource ruby();
-
-  @Source("rust.js")
-  @DoNotEmbed
-  DataResource rust();
-
-  @Source("sas.js")
-  @DoNotEmbed
-  DataResource sas();
-
-  @Source("sass.js")
-  @DoNotEmbed
-  DataResource sass();
-
-  @Source("scheme.js")
-  @DoNotEmbed
-  DataResource scheme();
-
-  @Source("shell.js")
-  @DoNotEmbed
-  DataResource shell();
-
-  @Source("sieve.js")
-  @DoNotEmbed
-  DataResource sieve();
-
-  @Source("slim.js")
-  @DoNotEmbed
-  DataResource slim();
-
-  @Source("smalltalk.js")
-  @DoNotEmbed
-  DataResource smalltalk();
-
-  @Source("smarty.js")
-  @DoNotEmbed
-  DataResource smarty();
-
-  @Source("solr.js")
-  @DoNotEmbed
-  DataResource solr();
-
-  @Source("soy.js")
-  @DoNotEmbed
-  DataResource soy();
-
-  @Source("sparql.js")
-  @DoNotEmbed
-  DataResource sparql();
-
-  @Source("spreadsheet.js")
-  @DoNotEmbed
-  DataResource spreadsheet();
-
-  @Source("sql.js")
-  @DoNotEmbed
-  DataResource sql();
-
-  @Source("stex.js")
-  @DoNotEmbed
-  DataResource stex();
-
-  @Source("stylus.js")
-  @DoNotEmbed
-  DataResource stylus();
-
-  @Source("swift.js")
-  @DoNotEmbed
-  DataResource swift();
-
-  @Source("tcl.js")
-  @DoNotEmbed
-  DataResource tcl();
-
-  @Source("textile.js")
-  @DoNotEmbed
-  DataResource textile();
-
-  @Source("tiddlywiki.js")
-  @DoNotEmbed
-  DataResource tiddlywiki();
-
-  @Source("tiki.js")
-  @DoNotEmbed
-  DataResource tiki();
-
-  @Source("toml.js")
-  @DoNotEmbed
-  DataResource toml();
-
-  @Source("tornado.js")
-  @DoNotEmbed
-  DataResource tornado();
-
-  @Source("troff.js")
-  @DoNotEmbed
-  DataResource troff();
-
-  @Source("ttcn-cfg.js")
-  @DoNotEmbed
-  DataResource ttcn_cfg();
-
-  @Source("ttcn.js")
-  @DoNotEmbed
-  DataResource ttcn();
-
-  @Source("turtle.js")
-  @DoNotEmbed
-  DataResource turtle();
-
-  @Source("twig.js")
-  @DoNotEmbed
-  DataResource twig();
-
-  @Source("vb.js")
-  @DoNotEmbed
-  DataResource vb();
-
-  @Source("vbscript.js")
-  @DoNotEmbed
-  DataResource vbscript();
-
-  @Source("velocity.js")
-  @DoNotEmbed
-  DataResource velocity();
-
-  @Source("verilog.js")
-  @DoNotEmbed
-  DataResource verilog();
-
-  @Source("vhdl.js")
-  @DoNotEmbed
-  DataResource vhdl();
-
-  @Source("vue.js")
-  @DoNotEmbed
-  DataResource vue();
-
-  @Source("webidl.js")
-  @DoNotEmbed
-  DataResource webidl();
-
-  @Source("xml.js")
-  @DoNotEmbed
-  DataResource xml();
-
-  @Source("xquery.js")
-  @DoNotEmbed
-  DataResource xquery();
-
-  @Source("yacas.js")
-  @DoNotEmbed
-  DataResource yacas();
-
-  @Source("yaml-frontmatter.js")
-  @DoNotEmbed
-  DataResource yaml_frontmatter();
-
-  @Source("yaml.js")
-  @DoNotEmbed
-  DataResource yaml();
-
-  @Source("z80.js")
-  @DoNotEmbed
-  DataResource z80();
-
-  // When adding a resource, update static initializer in ModeInfo.
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/gerrit/commit.js b/gerrit-gwtui/src/main/java/net/codemirror/mode/gerrit/commit.js
deleted file mode 100644
index e1fe898..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/gerrit/commit.js
+++ /dev/null
@@ -1,26 +0,0 @@
-CodeMirror.defineMode('gerrit_commit', function() {
-  var header = /^(Parent|Author|AuthorDate|Commit|CommitDate):/;
-  var id = /^Change-Id: I[0-9a-f]{40}/;
-  var footer = /^[A-Z][A-Za-z0-9-]+:/;
-  var sha1 = /\b[0-9a-f]{6,40}/;
-
-  return {
-    token: function(stream) {
-      if (stream.sol()) {
-        if (stream.match(header))
-          return 'keyword';
-        if (stream.match(id) || stream.match(footer))
-          return 'builtin';
-      }
-
-      stream.eatSpace();
-      if (stream.match(sha1))
-        return 'variable-2';
-      if (stream.match(/".*"/))
-        return 'string';
-      stream.next();
-      return null;
-    }
-  };
-});
-CodeMirror.defineMIME('text/x-gerrit-commit-message', 'gerrit_commit');
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java b/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
deleted file mode 100644
index 23039d4..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
+++ /dev/null
@@ -1,119 +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 net.codemirror.theme;
-
-import com.google.gerrit.extensions.client.Theme;
-import com.google.gwt.dom.client.StyleInjector;
-import com.google.gwt.resources.client.ExternalTextResource;
-import com.google.gwt.resources.client.ResourceCallback;
-import com.google.gwt.resources.client.ResourceException;
-import com.google.gwt.resources.client.TextResource;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import java.util.EnumSet;
-
-/** Dynamically loads a known CodeMirror theme's CSS */
-public class ThemeLoader {
-  private static final ExternalTextResource[] THEMES = {
-    Themes.I.day_3024(),
-    Themes.I.night_3024(),
-    Themes.I.abcdef(),
-    Themes.I.ambiance(),
-    Themes.I.base16_dark(),
-    Themes.I.base16_light(),
-    Themes.I.bespin(),
-    Themes.I.blackboard(),
-    Themes.I.cobalt(),
-    Themes.I.colorforth(),
-    Themes.I.dracula(),
-    Themes.I.eclipse(),
-    Themes.I.elegant(),
-    Themes.I.erlang_dark(),
-    Themes.I.hopscotch(),
-    Themes.I.icecoder(),
-    Themes.I.isotope(),
-    Themes.I.lesser_dark(),
-    Themes.I.liquibyte(),
-    Themes.I.material(),
-    Themes.I.mbo(),
-    Themes.I.mdn_like(),
-    Themes.I.midnight(),
-    Themes.I.monokai(),
-    Themes.I.neat(),
-    Themes.I.neo(),
-    Themes.I.night(),
-    Themes.I.paraiso_dark(),
-    Themes.I.paraiso_light(),
-    Themes.I.pastel_on_dark(),
-    Themes.I.railscasts(),
-    Themes.I.rubyblue(),
-    Themes.I.seti(),
-    Themes.I.solarized(),
-    Themes.I.the_matrix(),
-    Themes.I.tomorrow_night_bright(),
-    Themes.I.tomorrow_night_eighties(),
-    Themes.I.ttcn(),
-    Themes.I.twilight(),
-    Themes.I.vibrant_ink(),
-    Themes.I.xq_dark(),
-    Themes.I.xq_light(),
-    Themes.I.yeti(),
-    Themes.I.zenburn(),
-  };
-
-  private static final EnumSet<Theme> loaded = EnumSet.of(Theme.DEFAULT);
-
-  public static final void loadTheme(Theme theme, AsyncCallback<Void> cb) {
-    if (loaded.contains(theme)) {
-      cb.onSuccess(null);
-      return;
-    }
-
-    ExternalTextResource resource = findTheme(theme);
-    if (resource == null) {
-      cb.onFailure(new Exception("unknown theme " + theme));
-      return;
-    }
-
-    try {
-      resource.getText(
-          new ResourceCallback<TextResource>() {
-            @Override
-            public void onSuccess(TextResource resource) {
-              StyleInjector.inject(resource.getText());
-              loaded.add(theme);
-              cb.onSuccess(null);
-            }
-
-            @Override
-            public void onError(ResourceException e) {
-              cb.onFailure(e);
-            }
-          });
-    } catch (ResourceException e) {
-      cb.onFailure(e);
-    }
-  }
-
-  private static ExternalTextResource findTheme(Theme theme) {
-    for (ExternalTextResource r : THEMES) {
-      if (theme.name().toLowerCase().equals(r.getName())) {
-        return r;
-      }
-    }
-    return null;
-  }
-
-  private ThemeLoader() {}
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java b/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
deleted file mode 100644
index cfe853f..0000000
--- a/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
+++ /dev/null
@@ -1,165 +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 net.codemirror.theme;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.ExternalTextResource;
-
-public interface Themes extends ClientBundle {
-  Themes I = GWT.create(Themes.class);
-
-  @Source("3024-day.css")
-  ExternalTextResource day_3024();
-
-  @Source("3024-night.css")
-  ExternalTextResource night_3024();
-
-  @Source("abcdef.css")
-  ExternalTextResource abcdef();
-
-  @Source("ambiance.css")
-  ExternalTextResource ambiance();
-
-  @Source("base16-dark.css")
-  ExternalTextResource base16_dark();
-
-  @Source("base16-light.css")
-  ExternalTextResource base16_light();
-
-  @Source("bespin.css")
-  ExternalTextResource bespin();
-
-  @Source("blackboard.css")
-  ExternalTextResource blackboard();
-
-  @Source("cobalt.css")
-  ExternalTextResource cobalt();
-
-  @Source("colorforth.css")
-  ExternalTextResource colorforth();
-
-  @Source("dracula.css")
-  ExternalTextResource dracula();
-
-  @Source("duotone-dark.css")
-  ExternalTextResource duotone_dark();
-
-  @Source("duotone-light.css")
-  ExternalTextResource duotone_light();
-
-  @Source("eclipse.css")
-  ExternalTextResource eclipse();
-
-  @Source("elegant.css")
-  ExternalTextResource elegant();
-
-  @Source("erlang-dark.css")
-  ExternalTextResource erlang_dark();
-
-  @Source("hopscotch.css")
-  ExternalTextResource hopscotch();
-
-  @Source("icecoder.css")
-  ExternalTextResource icecoder();
-
-  @Source("isotope.css")
-  ExternalTextResource isotope();
-
-  @Source("lesser-dark.css")
-  ExternalTextResource lesser_dark();
-
-  @Source("liquibyte.css")
-  ExternalTextResource liquibyte();
-
-  @Source("material.css")
-  ExternalTextResource material();
-
-  @Source("mbo.css")
-  ExternalTextResource mbo();
-
-  @Source("mdn-like.css")
-  ExternalTextResource mdn_like();
-
-  @Source("midnight.css")
-  ExternalTextResource midnight();
-
-  @Source("monokai.css")
-  ExternalTextResource monokai();
-
-  @Source("neat.css")
-  ExternalTextResource neat();
-
-  @Source("neo.css")
-  ExternalTextResource neo();
-
-  @Source("night.css")
-  ExternalTextResource night();
-
-  @Source("paraiso-dark.css")
-  ExternalTextResource paraiso_dark();
-
-  @Source("paraiso-light.css")
-  ExternalTextResource paraiso_light();
-
-  @Source("pastel-on-dark.css")
-  ExternalTextResource pastel_on_dark();
-
-  @Source("railscasts.css")
-  ExternalTextResource railscasts();
-
-  @Source("rubyblue.css")
-  ExternalTextResource rubyblue();
-
-  @Source("seti.css")
-  ExternalTextResource seti();
-
-  @Source("solarized.css")
-  ExternalTextResource solarized();
-
-  @Source("the-matrix.css")
-  ExternalTextResource the_matrix();
-
-  @Source("tomorrow-night-bright.css")
-  ExternalTextResource tomorrow_night_bright();
-
-  @Source("tomorrow-night-eighties.css")
-  ExternalTextResource tomorrow_night_eighties();
-
-  @Source("ttcn.css")
-  ExternalTextResource ttcn();
-
-  @Source("twilight.css")
-  ExternalTextResource twilight();
-
-  @Source("vibrant-ink.css")
-  ExternalTextResource vibrant_ink();
-
-  @Source("xq-dark.css")
-  ExternalTextResource xq_dark();
-
-  @Source("xq-light.css")
-  ExternalTextResource xq_light();
-
-  @Source("yeti.css")
-  ExternalTextResource yeti();
-
-  @Source("zenburn.css")
-  ExternalTextResource zenburn();
-
-  // When adding a resource, update:
-  // - static initializer in ThemeLoader
-  // - enum value in com.google.gerrit.extensions.common.Theme
-}
diff --git a/gerrit-gwtui/src/test/java/com/google/gerrit/client/change/ProjectChangeIdTest.java b/gerrit-gwtui/src/test/java/com/google/gerrit/client/change/ProjectChangeIdTest.java
deleted file mode 100644
index 1d47a82..0000000
--- a/gerrit-gwtui/src/test/java/com/google/gerrit/client/change/ProjectChangeIdTest.java
+++ /dev/null
@@ -1,86 +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.client.change;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-
-public class ProjectChangeIdTest {
-
-  @Rule public ExpectedException exception = ExpectedException.none();
-
-  @Test
-  public void emptyStringThrowsException() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage(" is not a valid change identifier");
-    ProjectChangeId.create("");
-  }
-
-  @Test
-  public void noChangeIdThrowsException() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("some/path is not a valid change identifier");
-    ProjectChangeId.create("some/path");
-  }
-
-  @Test
-  public void noChangeButProjectIdThrowsException() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("some/+/path is not a valid change identifier");
-    ProjectChangeId.create("some/+/path");
-  }
-
-  @Test
-  public void project() {
-    assertThat(ProjectChangeId.create("test/+/123/some/path")).isEqualTo(result("test", 123));
-    assertThat(ProjectChangeId.create("test/+/123/some/path/")).isEqualTo(result("test", 123));
-    assertThat(ProjectChangeId.create("test/+/123/")).isEqualTo(result("test", 123));
-    assertThat(ProjectChangeId.create("test/+/123")).isEqualTo(result("test", 123));
-    // Numeric Project.NameKey
-    assertThat(ProjectChangeId.create("123/+/123")).isEqualTo(result("123", 123));
-    // Numeric Project.NameKey with ,edit as part of the name
-    assertThat(ProjectChangeId.create("123,edit/+/123")).isEqualTo(result("123,edit", 123));
-  }
-
-  @Test
-  public void noProject() {
-    assertThat(ProjectChangeId.create("123/some/path")).isEqualTo(result(null, 123));
-    assertThat(ProjectChangeId.create("123/some/path/")).isEqualTo(result(null, 123));
-    assertThat(ProjectChangeId.create("123/")).isEqualTo(result(null, 123));
-    assertThat(ProjectChangeId.create("123")).isEqualTo(result(null, 123));
-  }
-
-  @Test
-  public void editSuffix() {
-    assertThat(ProjectChangeId.create("123,edit/some/path")).isEqualTo(result(null, 123));
-    assertThat(ProjectChangeId.create("123,edit/")).isEqualTo(result(null, 123));
-    assertThat(ProjectChangeId.create("123,edit")).isEqualTo(result(null, 123));
-
-    assertThat(ProjectChangeId.create("test/+/123,edit/some/path")).isEqualTo(result("test", 123));
-    assertThat(ProjectChangeId.create("test/+/123,edit/")).isEqualTo(result("test", 123));
-    assertThat(ProjectChangeId.create("test/+/123,edit")).isEqualTo(result("test", 123));
-  }
-
-  private static ProjectChangeId result(@Nullable String project, int id) {
-    return new ProjectChangeId(
-        project == null ? null : new Project.NameKey(project), new Change.Id(id));
-  }
-}
diff --git a/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java b/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java
deleted file mode 100644
index fcc214e..0000000
--- a/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.client.diff;
-
-import static org.junit.Assert.assertEquals;
-
-import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
-import org.junit.Test;
-
-/** Unit tests for LineMapper */
-public class LineMapperTest {
-
-  @Test
-  public void appendCommon() {
-    LineMapper mapper = new LineMapper();
-    mapper.appendCommon(10);
-    assertEquals(10, mapper.getLineA());
-    assertEquals(10, mapper.getLineB());
-  }
-
-  @Test
-  public void appendInsert() {
-    LineMapper mapper = new LineMapper();
-    mapper.appendInsert(10);
-    assertEquals(0, mapper.getLineA());
-    assertEquals(10, mapper.getLineB());
-  }
-
-  @Test
-  public void appendDelete() {
-    LineMapper mapper = new LineMapper();
-    mapper.appendDelete(10);
-    assertEquals(10, mapper.getLineA());
-    assertEquals(0, mapper.getLineB());
-  }
-
-  @Test
-  public void findInCommon() {
-    LineMapper mapper = new LineMapper();
-    mapper.appendCommon(10);
-    assertEquals(new LineOnOtherInfo(9, true), mapper.lineOnOther(DisplaySide.A, 9));
-    assertEquals(new LineOnOtherInfo(9, true), mapper.lineOnOther(DisplaySide.B, 9));
-  }
-
-  @Test
-  public void findAfterCommon() {
-    LineMapper mapper = new LineMapper();
-    mapper.appendCommon(10);
-    assertEquals(new LineOnOtherInfo(10, true), mapper.lineOnOther(DisplaySide.A, 10));
-    assertEquals(new LineOnOtherInfo(10, true), mapper.lineOnOther(DisplaySide.B, 10));
-  }
-
-  @Test
-  public void findInInsertGap() {
-    LineMapper mapper = new LineMapper();
-    mapper.appendInsert(10);
-    assertEquals(new LineOnOtherInfo(-1, false), mapper.lineOnOther(DisplaySide.B, 9));
-  }
-
-  @Test
-  public void findAfterInsertGap() {
-    LineMapper mapper = new LineMapper();
-    mapper.appendInsert(10);
-    assertEquals(new LineOnOtherInfo(0, true), mapper.lineOnOther(DisplaySide.B, 10));
-    assertEquals(new LineOnOtherInfo(10, true), mapper.lineOnOther(DisplaySide.A, 0));
-  }
-
-  @Test
-  public void findInDeleteGap() {
-    LineMapper mapper = new LineMapper();
-    mapper.appendDelete(10);
-    assertEquals(new LineOnOtherInfo(-1, false), mapper.lineOnOther(DisplaySide.A, 9));
-  }
-
-  @Test
-  public void findAfterDeleteGap() {
-    LineMapper mapper = new LineMapper();
-    mapper.appendDelete(10);
-    assertEquals(new LineOnOtherInfo(0, true), mapper.lineOnOther(DisplaySide.A, 10));
-    assertEquals(new LineOnOtherInfo(10, true), mapper.lineOnOther(DisplaySide.B, 0));
-  }
-
-  @Test
-  public void replaceWithInsertInB() {
-    // 0 c c
-    // 1 a b
-    // 2 a b
-    // 3 - b
-    // 4 - b
-    // 5 c c
-    LineMapper mapper = new LineMapper();
-    mapper.appendCommon(1);
-    mapper.appendReplace(2, 4);
-    mapper.appendCommon(1);
-
-    assertEquals(4, mapper.getLineA());
-    assertEquals(6, mapper.getLineB());
-
-    assertEquals(new LineOnOtherInfo(1, true), mapper.lineOnOther(DisplaySide.B, 1));
-    assertEquals(new LineOnOtherInfo(3, true), mapper.lineOnOther(DisplaySide.B, 5));
-
-    assertEquals(new LineOnOtherInfo(2, true), mapper.lineOnOther(DisplaySide.B, 2));
-    assertEquals(new LineOnOtherInfo(2, false), mapper.lineOnOther(DisplaySide.B, 3));
-  }
-}
diff --git a/gerrit-plugin-gwtui/BUILD b/gerrit-plugin-gwtui/BUILD
deleted file mode 100644
index bfd977d..0000000
--- a/gerrit-plugin-gwtui/BUILD
+++ /dev/null
@@ -1,84 +0,0 @@
-package(default_visibility = ["//visibility:public"])
-
-load("//tools/bzl:java.bzl", "java_library2")
-
-SRCS = glob(["src/main/java/com/google/gerrit/**/*.java"])
-
-DEPS = ["//lib/gwt:user-neverlink"]
-
-java_binary(
-    name = "gwtui-api",
-    main_class = "Dummy",
-    runtime_deps = [
-        ":gwtui-api-lib",
-        "//gerrit-gwtui-common:client-lib",
-    ],
-)
-
-java_library2(
-    name = "gwtui-api-lib",
-    srcs = SRCS,
-    exported_deps = ["//gerrit-gwtui-common:client-lib"],
-    resources = glob(["src/main/**/*"]),
-    deps = DEPS + [
-        "//java/org/eclipse/jgit:libclient-src.jar",
-        "//java/org/eclipse/jgit:libEdit-src.jar",
-        "//java/com/google/gerrit/common:libclient-src.jar",
-        "//java/com/google/gerrit/extensions:libapi-src.jar",
-        "//java/com/google/gwtexpui/clippy:libclippy-src.jar",
-        "//java/com/google/gwtexpui/globalkey:libglobalkey-src.jar",
-        "//java/com/google/gwtexpui/progress:libprogress-src.jar",
-        "//java/com/google/gwtexpui/safehtml:libsafehtml-src.jar",
-        "//java/com/google/gwtexpui/user:libagent-src.jar",
-        "//gerrit-gwtui-common:libclient-src.jar",
-        "//java/com/google/gerrit/prettify:libclient-src.jar",
-        "//java/com/google/gerrit/reviewdb:libclient-src.jar",
-        "//lib/gwt:dev-neverlink",
-    ],
-)
-
-java_library2(
-    name = "gwtui-api-lib-neverlink",
-    srcs = SRCS,
-    exported_deps = ["//gerrit-gwtui-common:client-lib"],
-    neverlink = 1,  # we want this to be exported deps
-    resources = glob(["src/main/**/*"]),
-    deps = DEPS + ["//lib/gwt:dev"],
-)
-
-java_binary(
-    name = "gwtui-api-source",
-    main_class = "Dummy",
-    runtime_deps = [
-        ":libgwtui-api-lib-src.jar",
-        "//gerrit-gwtui-common:libclient-lib-src.jar",
-        "//java/com/google/gwtexpui/clippy:libclippy-src.jar",
-        "//java/com/google/gwtexpui/globalkey:libglobalkey-src.jar",
-        "//java/com/google/gwtexpui/progress:libprogress-src.jar",
-        "//java/com/google/gwtexpui/safehtml:libsafehtml-src.jar",
-        "//java/com/google/gwtexpui/user:libagent-src.jar",
-    ],
-)
-
-load("//tools/bzl:javadoc.bzl", "java_doc")
-
-java_doc(
-    name = "gwtui-api-javadoc",
-    libs = DEPS + [
-        ":gwtui-api-lib",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm-client",
-        "//lib/gwt:dev",
-        "//gerrit-gwtui-common:client-lib",
-        "//java/com/google/gerrit/common:client",
-        "//java/com/google/gerrit/reviewdb:client",
-    ],
-    pkgs = [
-        "com.google.gerrit.plugin",
-        "com.google.gwtexpui.clippy",
-        "com.google.gwtexpui.globalkey",
-        "com.google.gwtexpui.safehtml",
-        "com.google.gwtexpui.user",
-    ],
-    title = "Gerrit Review GWT Extension API Documentation",
-)
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/Plugin.gwt.xml b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/Plugin.gwt.xml
deleted file mode 100644
index e0b0833..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/Plugin.gwt.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-<!--
- Copyright (C) 2012 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name="com.google.gwt.json.JSON"/>
-  <inherits name='com.google.gerrit.GerritGwtUICommon'/>
-
-  <define-linker name="gerrit_plugin" class="com.google.gerrit.plugin.linker.GerritPluginLinker"/>
-  <add-linker name="gerrit_plugin"/>
-  <generate-with class="com.google.gerrit.plugin.rebind.PluginGenerator">
-    <when-type-assignable class="com.google.gerrit.plugin.client.Plugin"/>
-  </generate-with>
-  <source path="plugin/client"/>
-</module>
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/FormatUtil.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/FormatUtil.java
deleted file mode 100644
index 85bc9c3..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/FormatUtil.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.plugin.client;
-
-import com.google.gerrit.client.AccountFormatter;
-import com.google.gerrit.client.DateFormatter;
-import com.google.gerrit.client.RelativeDateFormatter;
-import com.google.gerrit.client.info.AccountInfo;
-import java.util.Date;
-
-public class FormatUtil {
-  private static final AccountFormatter accountFormatter =
-      new AccountFormatter(Plugin.get().getServerInfo().user().anonymousCowardName());
-
-  /** Format a date using a really short format. */
-  public static String shortFormat(Date dt) {
-    return createDateFormatter().shortFormat(dt);
-  }
-
-  /** Format a date using a really short format. */
-  public static String shortFormatDayTime(Date dt) {
-    return createDateFormatter().shortFormatDayTime(dt);
-  }
-
-  /** Format a date using the locale's medium length format. */
-  public static String mediumFormat(Date dt) {
-    return createDateFormatter().mediumFormat(dt);
-  }
-
-  private static DateFormatter createDateFormatter() {
-    return new DateFormatter(Plugin.get().getUserPreferences());
-  }
-
-  /** Format a date using git log's relative date format. */
-  public static String relativeFormat(Date dt) {
-    return RelativeDateFormatter.format(dt);
-  }
-
-  /**
-   * Formats an account as a name and an email address.
-   *
-   * <p>Example output:
-   *
-   * <ul>
-   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
-   *   <li>{@code A U. Thor (12)}: missing email address
-   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
-   *   <li>{@code Anonymous Coward (12)}: missing name and email address
-   * </ul>
-   */
-  public static String nameEmail(AccountInfo info) {
-    return accountFormatter.nameEmail(info);
-  }
-
-  /**
-   * Formats an account name.
-   *
-   * <p>If the account has a full name, it returns only the full name. Otherwise it returns a longer
-   * form that includes the email address.
-   */
-  public static String name(AccountInfo info) {
-    return accountFormatter.name(info);
-  }
-}
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
deleted file mode 100644
index bfcb3d6..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
+++ /dev/null
@@ -1,155 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.plugin.client;
-
-import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gerrit.client.info.ServerInfo;
-import com.google.gerrit.plugin.client.extension.Panel;
-import com.google.gerrit.plugin.client.screen.Screen;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-
-/**
- * Wrapper around the plugin instance exposed by Gerrit.
- *
- * <p>Listeners for events generated by the main UI must be registered through this instance.
- */
-public final class Plugin extends JavaScriptObject {
-  private static final Plugin self =
-      install(GWT.getModuleBaseURL() + GWT.getModuleName() + ".nocache.js");
-
-  /** Obtain the plugin instance wrapper. */
-  public static Plugin get() {
-    return self;
-  }
-
-  /** Installed name of the plugin. */
-  public String getName() {
-    return getPluginName();
-  }
-
-  /** Installed name of the plugin. */
-  public native String getPluginName() /*-{ return this.getPluginName() }-*/;
-
-  /** Navigate the UI to the screen identified by the token. */
-  public native void go(String token) /*-{ return this.go(token) }-*/;
-
-  /** Refresh the current UI. */
-  public native void refresh() /*-{ return this.refresh() }-*/;
-
-  /** Refresh Gerrit's menu bar. */
-  public native void refreshMenuBar() /*-{ return this.refreshMenuBar() }-*/;
-
-  /**
-   * @return the preferences of the currently signed in user, the default preferences if not signed
-   *     in
-   */
-  public native GeneralPreferences getUserPreferences() /*-{ return this.getUserPreferences() }-*/;
-
-  /** Refresh the user preferences of the current user. */
-  public native void refreshUserPreferences() /*-{ return this.refreshUserPreferences() }-*/;
-
-  /** @return the server info */
-  public native ServerInfo getServerInfo() /*-{ return this.getServerInfo() }-*/;
-
-  /** @return the current user */
-  public native AccountInfo getCurrentUser() /*-{ return this.getCurrentUser() }-*/;
-
-  /** Check if user is signed in. */
-  public native boolean isSignedIn() /*-{ return this.isSignedIn() }-*/;
-
-  /** Show message in Gerrit's ErrorDialog. */
-  public native void showError(String message) /*-{ return this.showError(message) }-*/;
-
-  /**
-   * Register a screen displayed at {@code /#/x/plugin/token}.
-   *
-   * @param token literal anchor token appearing after the plugin name. For regular expression
-   *     matching use {@code screenRegex()} .
-   * @param entry callback function invoked to create the screen widgets.
-   */
-  public void screen(String token, Screen.EntryPoint entry) {
-    screen(token, wrap(entry));
-  }
-
-  private native void screen(String t, JavaScriptObject e) /*-{ this.screen(t, e) }-*/;
-
-  /**
-   * Register a screen displayed at {@code /#/x/plugin/regex}.
-   *
-   * @param regex JavaScript {@code RegExp} expression to match the anchor token after the plugin
-   *     name. Matching groups are exposed through the {@code Screen} object passed into the {@code
-   *     Screen.EntryPoint}.
-   * @param entry callback function invoked to create the screen widgets.
-   */
-  public void screenRegex(String regex, Screen.EntryPoint entry) {
-    screenRegex(regex, wrap(entry));
-  }
-
-  private native void screenRegex(String p, JavaScriptObject e)
-      /*-{ this.screen(new $wnd.RegExp(p), e) }-*/ ;
-
-  /**
-   * Register a settings screen displayed at {@code /#/settings/x/plugin/token}.
-   *
-   * @param token literal anchor token appearing after the plugin name.
-   * @param entry callback function invoked to create the settings screen widgets.
-   */
-  public void settingsScreen(String token, String menu, Screen.EntryPoint entry) {
-    settingsScreen(token, menu, wrap(entry));
-  }
-
-  private native void settingsScreen(String t, String m, JavaScriptObject e)
-      /*-{ this.settingsScreen(t, m, e) }-*/ ;
-
-  /**
-   * Register a panel for a UI extension point.
-   *
-   * @param extensionPoint the UI extension point for which the panel should be registered.
-   * @param entry callback function invoked to create the panel widgets.
-   * @param name the name of the panel which can be used to specify panel ordering via project
-   *     config
-   */
-  public final void panel(
-      GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry, String name) {
-    panel(extensionPoint.name(), wrap(entry), name);
-  }
-
-  private native void panel(String i, JavaScriptObject e, String n) /*-{ this.panel(i, e, n) }-*/;
-
-  protected Plugin() {}
-
-  native void _initialized() /*-{ this._success = true }-*/;
-
-  native void _loaded() /*-{ this._loadedGwt() }-*/;
-
-  private static native Plugin install(String u) /*-{ return $wnd.Gerrit.installGwt(u) }-*/;
-
-  private static native JavaScriptObject wrap(Screen.EntryPoint b) /*-{
-    return $entry(function(c){
-      b.@com.google.gerrit.plugin.client.screen.Screen.EntryPoint::onLoad(Lcom/google/gerrit/plugin/client/screen/Screen;)(
-        @com.google.gerrit.plugin.client.screen.Screen::new(Lcom/google/gerrit/plugin/client/screen/Screen$Context;)(c));
-    });
-  }-*/;
-
-  private static native JavaScriptObject wrap(Panel.EntryPoint b) /*-{
-    return $entry(function(c){
-      b.@com.google.gerrit.plugin.client.extension.Panel.EntryPoint::onLoad(Lcom/google/gerrit/plugin/client/extension/Panel;)(
-        @com.google.gerrit.plugin.client.extension.Panel::new(Lcom/google/gerrit/plugin/client/extension/Panel$Context;)(c));
-    });
-  }-*/;
-}
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/PluginEntryPoint.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/PluginEntryPoint.java
deleted file mode 100644
index 808cda3..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/PluginEntryPoint.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.plugin.client;
-
-import com.google.gwt.core.client.EntryPoint;
-
-/**
- * Base class for writing Gerrit Web UI plugins
- *
- * <p>Writing a plugin:
- *
- * <ol>
- *   <li>Declare subtype of Plugin
- *   <li>Bind WebUiPlugin to GwtPlugin implementation in Gerrit-Module
- * </ol>
- */
-public abstract class PluginEntryPoint implements EntryPoint {
-  /**
-   * The plugin entry point method, called automatically by loading a module that declares an
-   * implementing class as an entry point.
-   */
-  public abstract void onPluginLoad();
-
-  @Override
-  public final void onModuleLoad() {
-    Plugin self = Plugin.get();
-    try {
-      onPluginLoad();
-      self._initialized();
-    } finally {
-      self._loaded();
-    }
-  }
-}
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
deleted file mode 100644
index 8ee6d0e..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.plugin.client.extension;
-
-import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.user.client.ui.SimplePanel;
-
-/**
- * Panel that extends a Gerrit core screen contributed by this plugin.
- *
- * <p>Panel should be registered early at module load:
- *
- * <pre>
- * &#064;Override
- * public void onModuleLoad() {
- *   Plugin.get().panel(GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
- *       new Panel.EntryPoint() {
- *         &#064;Override
- *         public void onLoad(Panel panel) {
- *           panel.setWidget(new Label(&quot;World&quot;));
- *         }
- *       });
- * }
- * </pre>
- */
-public class Panel extends SimplePanel {
-  /** Initializes a panel for display. */
-  public interface EntryPoint {
-    /**
-     * Invoked when the panel has been created.
-     *
-     * <p>The implementation should create a single widget to define the content of this panel and
-     * add it to the passed panel instance.
-     *
-     * <p>To use multiple widgets, compose them in panels such as {@code FlowPanel} and add only the
-     * top level widget to the panel.
-     *
-     * <p>The panel is already attached to the browser DOM. Any widgets added to the screen will
-     * immediately receive {@code onLoad()}. GWT will fire {@code onUnload()} when the panel is
-     * removed from the UI, generally caused by the user navigating to another screen.
-     *
-     * @param panel panel that will contain the panel widget.
-     */
-    void onLoad(Panel panel);
-  }
-
-  static final class Context extends JavaScriptObject {
-    native Element body() /*-{ return this.body }-*/;
-
-    native String get(String k) /*-{ return this.p[k]; }-*/;
-
-    native int getInt(String k, int d) /*-{
-      return this.p.hasOwnProperty(k) ? this.p[k] : d
-    }-*/;
-
-    native int getBoolean(String k, boolean d) /*-{
-      return this.p.hasOwnProperty(k) ? this.p[k] : d
-    }-*/;
-
-    native JavaScriptObject getObject(String k) /*-{ return this.p[k]; }-*/;
-
-    native void detach(Panel p) /*-{
-      this.onUnload($entry(function(){
-        p.@com.google.gwt.user.client.ui.Widget::onDetach()();
-      }));
-    }-*/;
-
-    protected Context() {}
-  }
-
-  private final Context ctx;
-
-  Panel(Context ctx) {
-    super(ctx.body());
-    this.ctx = ctx;
-    onAttach();
-    ctx.detach(this);
-  }
-
-  public String get(GerritUiExtensionPoint.Key key) {
-    return ctx.get(key.name());
-  }
-
-  public int getInt(GerritUiExtensionPoint.Key key, int defaultValue) {
-    return ctx.getInt(key.name(), defaultValue);
-  }
-
-  public int getBoolean(GerritUiExtensionPoint.Key key, boolean defaultValue) {
-    return ctx.getBoolean(key.name(), defaultValue);
-  }
-
-  public JavaScriptObject getObject(GerritUiExtensionPoint.Key key) {
-    return ctx.getObject(key.name());
-  }
-}
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/NoContent.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/NoContent.java
deleted file mode 100644
index 9ff4fed..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/NoContent.java
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.plugin.client.rpc;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class NoContent extends JavaScriptObject {
-  protected NoContent() {}
-}
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java
deleted file mode 100644
index 86791f8..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java
+++ /dev/null
@@ -1,160 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.plugin.client.rpc;
-
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.http.client.URL;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-public class RestApi {
-  private final StringBuilder path;
-  private boolean hasQueryParams;
-
-  public RestApi(String name) {
-    path = new StringBuilder();
-    path.append(name);
-  }
-
-  public RestApi view(String name) {
-    return idRaw(name);
-  }
-
-  public RestApi view(String pluginName, String name) {
-    return idRaw(pluginName + "~" + name);
-  }
-
-  public RestApi id(String id) {
-    return idRaw(URL.encodePathSegment(id));
-  }
-
-  public RestApi id(int id) {
-    return idRaw(Integer.toString(id));
-  }
-
-  public RestApi idRaw(String name) {
-    if (hasQueryParams) {
-      throw new IllegalStateException();
-    }
-    if (path.charAt(path.length() - 1) != '/') {
-      path.append('/');
-    }
-    path.append(name);
-    return this;
-  }
-
-  public RestApi addParameter(String name, String value) {
-    return addParameterRaw(name, URL.encodeQueryString(value));
-  }
-
-  public RestApi addParameter(String name, String... value) {
-    for (String val : value) {
-      addParameter(name, val);
-    }
-    return this;
-  }
-
-  public RestApi addParameterTrue(String name) {
-    return addParameterRaw(name, null);
-  }
-
-  public RestApi addParameter(String name, boolean value) {
-    return addParameterRaw(name, value ? "t" : "f");
-  }
-
-  public RestApi addParameter(String name, int value) {
-    return addParameterRaw(name, String.valueOf(value));
-  }
-
-  public RestApi addParameter(String name, Enum<?> value) {
-    return addParameterRaw(name, value.name());
-  }
-
-  public RestApi addParameterRaw(String name, String value) {
-    if (hasQueryParams) {
-      path.append("&");
-    } else {
-      path.append("?");
-      hasQueryParams = true;
-    }
-    path.append(name);
-    if (value != null) {
-      path.append("=").append(value);
-    }
-    return this;
-  }
-
-  public String path() {
-    return path.toString();
-  }
-
-  public <T extends JavaScriptObject> void get(AsyncCallback<T> cb) {
-    get(path(), wrap(cb));
-  }
-
-  public void getString(AsyncCallback<String> cb) {
-    get(NativeString.unwrap(cb));
-  }
-
-  private static native void get(String p, JavaScriptObject r) /*-{ $wnd.Gerrit.get_raw(p, r) }-*/;
-
-  public <T extends JavaScriptObject> void put(AsyncCallback<T> cb) {
-    put(path(), wrap(cb));
-  }
-
-  private static native void put(String p, JavaScriptObject r) /*-{ $wnd.Gerrit.put_raw(p, r) }-*/;
-
-  public <T extends JavaScriptObject> void put(String content, AsyncCallback<T> cb) {
-    put(path(), content, wrap(cb));
-  }
-
-  private static native void put(String p, String c, JavaScriptObject r)
-      /*-{ $wnd.Gerrit.put_raw(p, c, r) }-*/ ;
-
-  public <T extends JavaScriptObject> void put(JavaScriptObject content, AsyncCallback<T> cb) {
-    put(path(), content, wrap(cb));
-  }
-
-  private static native void put(String p, JavaScriptObject c, JavaScriptObject r)
-      /*-{ $wnd.Gerrit.put_raw(p, c, r) }-*/ ;
-
-  public <T extends JavaScriptObject> void post(String content, AsyncCallback<T> cb) {
-    post(path(), content, wrap(cb));
-  }
-
-  private static native void post(String p, String c, JavaScriptObject r)
-      /*-{ $wnd.Gerrit.post_raw(p, c, r) }-*/ ;
-
-  public <T extends JavaScriptObject> void post(JavaScriptObject content, AsyncCallback<T> cb) {
-    post(path(), content, wrap(cb));
-  }
-
-  private static native void post(String p, JavaScriptObject c, JavaScriptObject r)
-      /*-{ $wnd.Gerrit.post_raw(p, c, r) }-*/ ;
-
-  public void delete(AsyncCallback<NoContent> cb) {
-    delete(path(), wrap(cb));
-  }
-
-  private static native void delete(String p, JavaScriptObject r)
-      /*-{ $wnd.Gerrit.del_raw(p, r) }-*/ ;
-
-  private static native <T extends JavaScriptObject> JavaScriptObject wrap(
-      AsyncCallback<T> b) /*-{
-    return function(r) {
-      b.@com.google.gwt.user.client.rpc.AsyncCallback::onSuccess(Ljava/lang/Object;)(r)
-    }
-  }-*/;
-}
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
deleted file mode 100644
index 226ac48..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
+++ /dev/null
@@ -1,144 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.plugin.client.screen;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.user.client.ui.SimplePanel;
-import com.google.gwt.user.client.ui.Widget;
-
-/**
- * Screen contributed by this plugin.
- *
- * <p>Screens should be registered early at module load:
- *
- * <pre>
- * &#064;Override
- * public void onModuleLoad() {
- *   Plugin.get().screen(&quot;hi&quot;, new Screen.EntryPoint() {
- *     &#064;Override
- *     public void onLoad(Screen screen) {
- *       screen.setPageTitle(&quot;Hi&quot;);
- *       screen.show(new Label(&quot;World&quot;));
- *     }
- *   });
- * }
- * </pre>
- */
-public final class Screen extends SimplePanel {
-  /** Initializes a screen for display. */
-  public interface EntryPoint {
-    /**
-     * Invoked when the screen has been created, but not yet displayed.
-     *
-     * <p>The implementation should create a single widget to define the content of this screen and
-     * added it to the passed screen instance. When the screen is ready to be displayed, call {@link
-     * Screen#show()}.
-     *
-     * <p>To use multiple widgets, compose them in panels such as {@code FlowPanel} and add only the
-     * top level widget to the screen.
-     *
-     * <p>The screen is already attached to the browser DOM in an invisible area. Any widgets added
-     * to the screen will immediately receive {@code onLoad()}. GWT will fire {@code onUnload()}
-     * when the screen is removed from the UI, generally caused by the user navigating to another
-     * screen.
-     *
-     * @param screen panel that will contain the screen widget.
-     */
-    void onLoad(Screen screen);
-  }
-
-  static final class Context extends JavaScriptObject {
-    native Element body() /*-{ return this.body }-*/;
-
-    native JsArrayString token_match() /*-{ return this.token_match }-*/;
-
-    native void show() /*-{ this.show() }-*/;
-
-    native void setTitle(String t) /*-{ this.setTitle(t) }-*/;
-
-    native void setWindowTitle(String t) /*-{ this.setWindowTitle(t) }-*/;
-
-    native void detach(Screen s) /*-{
-      this.onUnload($entry(function(){
-        s.@com.google.gwt.user.client.ui.Widget::onDetach()();
-      }));
-    }-*/;
-
-    protected Context() {}
-  }
-
-  private final Context ctx;
-
-  Screen(Context ctx) {
-    super(ctx.body());
-    this.ctx = ctx;
-    onAttach();
-    ctx.detach(this);
-  }
-
-  /** @return the token suffix after {@code "/#/x/plugin-name/"}. */
-  public String getToken() {
-    return getToken(0);
-  }
-
-  /**
-   * @param group groups range from 1 to {@code getTokenGroups() - 1}. Token group 0 is the entire
-   *     token, see {@link #getToken()}.
-   * @return the token from the regex match group.
-   */
-  public String getToken(int group) {
-    return ctx.token_match().get(group);
-  }
-
-  /** @return total number of token groups. */
-  public int getTokenGroups() {
-    return ctx.token_match().length();
-  }
-
-  /**
-   * Set the page title text; appears above the widget.
-   *
-   * @param titleText text to display above the widget.
-   */
-  public void setPageTitle(String titleText) {
-    ctx.setTitle(titleText);
-  }
-
-  /**
-   * Set the window title text; appears in the browser window title bar.
-   *
-   * @param titleText text to display in the window title bar.
-   */
-  public void setWindowTitle(String titleText) {
-    ctx.setWindowTitle(titleText);
-  }
-
-  /**
-   * Add the widget and immediately show the screen.
-   *
-   * @param w child containing the content.
-   */
-  public void show(Widget w) {
-    setWidget(w);
-    ctx.show();
-  }
-
-  /** Show this screen in the web interface. */
-  public void show() {
-    ctx.show();
-  }
-}
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java
deleted file mode 100644
index df5be2c..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.plugin.client.ui;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.ui.HighlightSuggestion;
-import com.google.gerrit.plugin.client.rpc.RestApi;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.SuggestOracle;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-/** A {@code SuggestOracle} for groups. */
-public class GroupSuggestOracle extends SuggestOracle {
-
-  private final int chars;
-
-  /** @param chars minimum chars to start suggesting. */
-  public GroupSuggestOracle(int chars) {
-    this.chars = chars;
-  }
-
-  @Override
-  public boolean isDisplayStringHTML() {
-    return true;
-  }
-
-  @Override
-  public void requestSuggestions(Request req, Callback done) {
-    if (req.getQuery().length() < chars) {
-      responseEmptySuggestion(req, done);
-      return;
-    }
-    RestApi rest = new RestApi("/groups/").addParameter("suggest", req.getQuery());
-    if (req.getLimit() > 0) {
-      rest.addParameter("n", req.getLimit());
-    }
-    rest.get(
-        new AsyncCallback<NativeMap<JavaScriptObject>>() {
-          @Override
-          public void onSuccess(NativeMap<JavaScriptObject> result) {
-            List<String> keys = result.sortedKeys();
-            List<Suggestion> suggestions = new ArrayList<>(keys.size());
-            for (String g : keys) {
-              suggestions.add(new HighlightSuggestion(req.getQuery(), g));
-            }
-            done.onSuggestionsReady(req, new Response(suggestions));
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            responseEmptySuggestion(req, done);
-          }
-        });
-  }
-
-  private static void responseEmptySuggestion(Request req, Callback done) {
-    List<Suggestion> empty = Collections.emptyList();
-    done.onSuggestionsReady(req, new Response(empty));
-  }
-}
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/linker/GerritPluginLinker.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/linker/GerritPluginLinker.java
deleted file mode 100644
index 18f6e54..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/linker/GerritPluginLinker.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.plugin.linker;
-
-import com.google.gwt.core.ext.LinkerContext;
-import com.google.gwt.core.linker.CrossSiteIframeLinker;
-
-/** Finalizes the module manifest file with the selection script. */
-public final class GerritPluginLinker extends CrossSiteIframeLinker {
-  @Override
-  public String getDescription() {
-    return "Gerrit GWT UI plugin";
-  }
-
-  @Override
-  protected String getJsComputeUrlForResource(LinkerContext context) {
-    return "com/google/gerrit/linker/computeUrlForPluginResource.js";
-  }
-}
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/rebind/PluginGenerator.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/rebind/PluginGenerator.java
deleted file mode 100644
index ba14556..0000000
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/rebind/PluginGenerator.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-// Copyright 2008 Google Inc.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.plugin.rebind;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.ext.Generator;
-import com.google.gwt.core.ext.GeneratorContext;
-import com.google.gwt.core.ext.TreeLogger;
-import com.google.gwt.core.ext.UnableToCompleteException;
-import com.google.gwt.core.ext.typeinfo.JClassType;
-import com.google.gwt.core.ext.typeinfo.TypeOracle;
-import com.google.gwt.user.rebind.ClassSourceFileComposerFactory;
-import com.google.gwt.user.rebind.SourceWriter;
-import java.io.PrintWriter;
-
-/**
- * Write the top layer in the Gadget bootstrap sandwich and generate a stub manifest that will be
- * completed by the linker.
- *
- * <p>Based on gwt-gadgets GadgetGenerator class
- */
-public class PluginGenerator extends Generator {
-  @Override
-  public String generate(TreeLogger logger, GeneratorContext context, String typeName)
-      throws UnableToCompleteException {
-
-    // The TypeOracle knows about all types in the type system
-    TypeOracle typeOracle = context.getTypeOracle();
-
-    // Get a reference to the type that the generator should implement
-    JClassType sourceType = typeOracle.findType(typeName);
-
-    // Ensure that the requested type exists
-    if (sourceType == null) {
-      logger.log(TreeLogger.ERROR, "Could not find requested typeName", null);
-      throw new UnableToCompleteException();
-    }
-
-    // Make sure the Gadget type is correctly defined
-    validateType(logger, sourceType);
-
-    // Pick a name for the generated class to not conflict.
-    String generatedSimpleSourceName = sourceType.getSimpleSourceName() + "PluginImpl";
-
-    // Begin writing the generated source.
-    ClassSourceFileComposerFactory f =
-        new ClassSourceFileComposerFactory(
-            sourceType.getPackage().getName(), generatedSimpleSourceName);
-    f.addImport(GWT.class.getName());
-    f.setSuperclass(typeName);
-
-    // All source gets written through this Writer
-    PrintWriter out =
-        context.tryCreate(logger, sourceType.getPackage().getName(), generatedSimpleSourceName);
-
-    // If an implementation already exists, we don't need to do any work
-    if (out != null) {
-
-      // We really use a SourceWriter since it's convenient
-      SourceWriter sw = f.createSourceWriter(context, out);
-      sw.commit(logger);
-    }
-
-    return f.getCreatedClassName();
-  }
-
-  protected void validateType(TreeLogger logger, JClassType type) throws UnableToCompleteException {
-    if (!type.isDefaultInstantiable()) {
-      logger.log(TreeLogger.ERROR, "Plugin types must be default instantiable", null);
-      throw new UnableToCompleteException();
-    }
-  }
-}
diff --git a/gerrit-plugin-gwtui/src/main/resources/com/google/gerrit/linker/computeUrlForPluginResource.js b/gerrit-plugin-gwtui/src/main/resources/com/google/gerrit/linker/computeUrlForPluginResource.js
deleted file mode 100644
index 3e20e94..0000000
--- a/gerrit-plugin-gwtui/src/main/resources/com/google/gerrit/linker/computeUrlForPluginResource.js
+++ /dev/null
@@ -1,3 +0,0 @@
-function computeUrlForResource(resource) {
-  return __MODULE_FUNC__.__moduleBase + resource;
-}
diff --git a/java/Main.java b/java/Main.java
index f26b6df..11d8234 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -14,6 +14,7 @@
 
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
 
   // We don't do any real work here because we need to import
   // the archive lookup code and we cannot import a class in
@@ -42,6 +43,9 @@
   }
 
   private static void configureFloggerBackend() {
+    System.setProperty(
+        FLOGGER_LOGGING_CONTEXT, "com.google.gerrit.server.logging.LoggingContext#getInstance");
+
     if (System.getProperty(FLOGGER_BACKEND_PROPERTY) != null) {
       // Flogger backend is already configured
       return;
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index bf01615..197a6a3 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -14,17 +14,19 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
@@ -33,13 +35,14 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 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.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelFunction;
@@ -52,8 +55,6 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
-import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -63,31 +64,36 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.change.BatchAbandon;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -96,26 +102,24 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.index.group.GroupIndexer;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
+import com.google.gerrit.server.plugins.TestServerPlugin;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.Revisions;
@@ -123,12 +127,8 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.gerrit.testing.NoteDbMode;
 import com.google.gerrit.testing.SshMode;
-import com.google.gerrit.testing.TempFileUtil;
 import com.google.gson.Gson;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
@@ -137,6 +137,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.lang.reflect.Modifier;
 import java.nio.file.DirectoryStream;
 import java.nio.file.FileSystem;
 import java.nio.file.FileSystems;
@@ -146,6 +147,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
@@ -153,18 +155,16 @@
 import java.util.Optional;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.FetchResult;
@@ -175,8 +175,9 @@
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
+import org.junit.ClassRule;
 import org.junit.Rule;
-import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
 import org.junit.rules.TestRule;
 import org.junit.runner.Description;
 import org.junit.runner.RunWith;
@@ -187,11 +188,11 @@
   private static GerritServer commonServer;
   private static Description firstTest;
 
+  @ClassRule public static TemporaryFolder temporaryFolder = new TemporaryFolder();
+
   @ConfigSuite.Parameter public Config baseConfig;
   @ConfigSuite.Name private String configName;
 
-  @Rule public ExpectedException exception = ExpectedException.none();
-
   @Rule
   public TestRule testRunner =
       new TestRule() {
@@ -204,12 +205,10 @@
                 firstTest = description;
               }
               beforeTest(description);
-              ProjectResetter.Config input = resetProjects();
-              if (input == null) {
-                input = defaultResetProjects();
-              }
+              ProjectResetter.Config input = requireNonNull(resetProjects());
 
-              try (ProjectResetter resetter = projectResetter.builder().build(input)) {
+              try (ProjectResetter resetter =
+                  projectResetter != null ? projectResetter.builder().build(input) : null) {
                 AbstractDaemonTest.this.resetter = resetter;
                 base.evaluate();
               } finally {
@@ -245,13 +244,13 @@
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected PatchSetUtil psUtil;
   @Inject protected ProjectCache projectCache;
+  @Inject protected ProjectConfig.Factory projectConfigFactory;
   @Inject protected ProjectResetter.Builder.Factory projectResetter;
   @Inject protected Provider<InternalChangeQuery> queryProvider;
   @Inject protected PushOneCommit.Factory pushFactory;
   @Inject protected PluginConfigFactory pluginConfig;
   @Inject protected Revisions revisions;
   @Inject protected SystemGroupBackend systemGroupBackend;
-  @Inject protected MutableNotesMigration notesMigration;
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected BatchAbandon batchAbandon;
   @Inject protected TestSshKeys sshKeys;
@@ -261,7 +260,7 @@
   protected Project.NameKey project;
   protected RestSession adminRestSession;
   protected RestSession userRestSession;
-  protected ReviewDb db;
+  protected RestSession anonymousRestSession;
   protected SshSession adminSshSession;
   protected SshSession userSshSession;
   protected TestAccount admin;
@@ -272,27 +271,33 @@
   protected boolean testRequiresSsh;
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
-  @Inject private ChangeIndexCollection changeIndexes;
+  @Inject private AbstractChangeNotes.Args changeNotesArgs;
   @Inject private AccountIndexCollection accountIndexes;
+  @Inject private AccountIndexer accountIndexer;
+  @Inject private ChangeIndexCollection changeIndexes;
   @Inject private EventRecorder.Factory eventRecorderFactory;
   @Inject private InProcessProtocol inProcessProtocol;
-  @Inject private Provider<AnonymousUser> anonymousUser;
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-  @Inject private AccountIndexer accountIndexer;
-  @Inject private Groups groups;
-  @Inject private GroupIndexer groupIndexer;
+  @Inject private PluginGuiceEnvironment pluginGuiceEnvironment;
+  @Inject private PluginUser.Factory pluginUserFactory;
+  @Inject private ProjectIndexCollection projectIndexes;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private SitePaths sitePaths;
 
   private ProjectResetter resetter;
   private List<Repository> toClose;
 
   @Before
   public void clearSender() {
-    sender.clear();
+    if (sender != null) {
+      sender.clear();
+    }
   }
 
   @Before
   public void startEventRecorder() {
-    eventRecorder = eventRecorderFactory.create(admin);
+    if (eventRecorderFactory != null) {
+      eventRecorder = eventRecorderFactory.create(admin);
+    }
   }
 
   @Before
@@ -307,7 +312,9 @@
 
   @After
   public void closeEventRecorder() {
-    eventRecorder.close();
+    if (eventRecorder != null) {
+      eventRecorder.close();
+    }
   }
 
   @AfterClass
@@ -324,15 +331,10 @@
         commonServer = null;
       }
     }
-    TempFileUtil.cleanup();
   }
 
   /** Controls which project and branches should be reset after each test case. */
   protected ProjectResetter.Config resetProjects() {
-    return null;
-  }
-
-  private ProjectResetter.Config defaultResetProjects() {
     return new ProjectResetter.Config()
         // Don't reset all refs so that refs/sequences/changes is not touched and change IDs are
         // not reused.
@@ -359,18 +361,11 @@
     initSsh();
   }
 
-  protected void evictAndReindexAccount(Account.Id accountId) throws IOException {
+  protected void evictAndReindexAccount(Account.Id accountId) {
     accountCache.evict(accountId);
     accountIndexer.index(accountId);
   }
 
-  private void reindexAllGroups() throws IOException, ConfigInvalidException {
-    Iterable<GroupReference> allGroups = groups.getAllGroupReferences()::iterator;
-    for (GroupReference group : allGroups) {
-      groupIndexer.index(group.getUUID());
-    }
-  }
-
   protected static Config submitWholeTopicEnabledConfig() {
     Config cfg = new Config();
     cfg.setBoolean("change", null, "submitWholeTopic", true);
@@ -397,43 +392,33 @@
       baseConfig.setString("sshd", null, "listenAddress", "off");
     }
 
+    baseConfig.setInt("index", null, "batchThreads", -1);
+
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
       if (commonServer == null) {
-        commonServer = GerritServer.initAndStart(classDesc, baseConfig, module);
+        commonServer = GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module);
       }
       server = commonServer;
     } else {
-      server = GerritServer.initAndStart(methodDesc, baseConfig, module);
+      server = GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module);
     }
 
     server.getTestInjector().injectMembers(this);
     Transport.register(inProcessProtocol);
-    toClose = Collections.synchronizedList(new ArrayList<Repository>());
-
-    db = reviewDbProvider.open();
-
-    // All groups which were added during the server start (e.g. in SchemaCreator) aren't contained
-    // in the instance of the group index which is available here and in tests. There are two
-    // reasons:
-    // 1) No group index is available in SchemaCreator when using an in-memory database. (This could
-    // be fixed by using the IndexManagerOnInit in InMemoryDatabase similar as BaseInit uses it.)
-    // 2) During the on-init part of the server start, we use another instance of the index than
-    // later on. As test indexes are non-permanent, closing an instance and opening another one
-    // removes all indexed data.
-    // As a workaround, we simply reindex all available groups here.
-    reindexAllGroups();
+    toClose = Collections.synchronizedList(new ArrayList<>());
 
     admin = accountCreator.admin();
     user = accountCreator.user();
 
     // Evict and reindex accounts in case tests modify them.
-    evictAndReindexAccount(admin.getId());
-    evictAndReindexAccount(user.getId());
+    evictAndReindexAccount(admin.id());
+    evictAndReindexAccount(user.id());
 
     adminRestSession = new RestSession(server, admin);
     userRestSession = new RestSession(server, user);
+    anonymousRestSession = new RestSession(server, null);
 
     initSsh();
 
@@ -446,8 +431,10 @@
     atrScope.set(ctx);
     ProjectInput in = projectInput(description);
     gApi.projects().create(in);
-    project = new Project.NameKey(in.name);
-    testRepo = cloneProject(project, getCloneAsAccount(description));
+    project = Project.nameKey(in.name);
+    if (!classDesc.skipProjectClone()) {
+      testRepo = cloneProject(project, getCloneAsAccount(description));
+    }
   }
 
   /** Override to bind an additional Guice module */
@@ -492,6 +479,8 @@
       in.useSignedOffBy = ann.useSignedOffBy();
       in.useContentMerge = ann.useContentMerge();
       in.rejectEmptyCommit = ann.rejectEmptyCommit();
+      in.enableSignedPush = ann.enableSignedPush();
+      in.requireSignedPush = ann.requireSignedPush();
     } else {
       // Defaults should match TestProjectConfig, omitting nullable values.
       in.createEmptyCommit = true;
@@ -532,24 +521,7 @@
     return resourcePrefix + name;
   }
 
-  protected Project.NameKey createProject(String nameSuffix) throws RestApiException {
-    return createProject(nameSuffix, null);
-  }
-
-  protected Project.NameKey createProject(String nameSuffix, Project.NameKey parent)
-      throws RestApiException {
-    // Default for createEmptyCommit should match TestProjectConfig.
-    return createProject(nameSuffix, parent, true, null);
-  }
-
-  protected Project.NameKey createProject(
-      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit)
-      throws RestApiException {
-    // Default for createEmptyCommit should match TestProjectConfig.
-    return createProject(nameSuffix, parent, createEmptyCommit, null);
-  }
-
-  protected Project.NameKey createProject(
+  protected Project.NameKey createProjectOverAPI(
       String nameSuffix, Project.NameKey parent, boolean createEmptyCommit, SubmitType submitType)
       throws RestApiException {
     ProjectInput in = new ProjectInput();
@@ -558,7 +530,7 @@
     in.submitType = submitType;
     in.createEmptyCommit = createEmptyCommit;
     gApi.projects().create(in);
-    return new Project.NameKey(in.name);
+    return Project.nameKey(in.name);
   }
 
   protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p) throws Exception {
@@ -578,8 +550,7 @@
   protected String registerRepoConnection(Project.NameKey p, TestAccount testAccount)
       throws Exception {
     InProcessProtocol.Context ctx =
-        new InProcessProtocol.Context(
-            reviewDbProvider, identifiedUserFactory, testAccount.getId(), p);
+        new InProcessProtocol.Context(identifiedUserFactory, testAccount.id(), p);
     Repository repo = repoManager.openRepository(p);
     toClose.add(repo);
     return inProcessProtocol.register(ctx, repo).toString();
@@ -590,13 +561,11 @@
     for (Repository repo : toClose) {
       repo.close();
     }
-    db.close();
     closeSsh();
     if (server != commonServer) {
       server.close();
       server = null;
     }
-    NoteDbMode.resetFromEnv(notesMigration);
   }
 
   protected void closeSsh() {
@@ -633,7 +602,7 @@
   }
 
   protected PushOneCommit.Result createChange(String ref) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result result = push.to(ref);
     result.assertOkStatus();
     return result;
@@ -649,8 +618,7 @@
     PushOneCommit.Result p1 =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 testRepo,
                 "parent 1",
                 ImmutableMap.of(file, "foo-1", "bar", "bar-1"))
@@ -662,8 +630,7 @@
     PushOneCommit.Result p2 =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 testRepo,
                 "parent 2",
                 ImmutableMap.of(file, "foo-2", "bar", "bar-2"))
@@ -671,11 +638,7 @@
 
     PushOneCommit m =
         pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "merge",
-            ImmutableMap.of(file, "foo-1", "bar", "bar-2"));
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of(file, "foo-1", "bar", "bar-2"));
     m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
     PushOneCommit.Result result = m.to(ref);
     result.assertOkStatus();
@@ -690,7 +653,7 @@
       String content)
       throws Exception {
     PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), repo, commitMsg, fileName, content).to(ref);
+        pushFactory.create(admin.newIdent(), repo, commitMsg, fileName, content).to(ref);
     result.assertOkStatus();
     return result;
   }
@@ -704,13 +667,12 @@
       throws Exception {
     assertThat(topic).isNotEmpty();
     return createCommitAndPush(
-        repo, "refs/for/master/" + name(topic), commitMsg, fileName, content);
+        repo, "refs/for/master%topic=" + name(topic), commitMsg, fileName, content);
   }
 
   protected PushOneCommit.Result createChange(String subject, String fileName, String content)
       throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
     return push.to("refs/for/master");
   }
 
@@ -722,26 +684,26 @@
       String content,
       String topic)
       throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
-    return push.to("refs/for/" + branch + "/" + name(topic));
+    PushOneCommit push = pushFactory.create(admin.newIdent(), repo, subject, fileName, content);
+    return push.to("refs/for/" + branch + "%topic=" + name(topic));
   }
 
-  protected BranchApi createBranch(Branch.NameKey branch) throws Exception {
+  protected BranchApi createBranch(BranchNameKey branch) throws Exception {
     return gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get())
+        .name(branch.project().get())
+        .branch(branch.branch())
         .create(new BranchInput());
   }
 
-  protected BranchApi createBranchWithRevision(Branch.NameKey branch, String revision)
+  protected BranchApi createBranchWithRevision(BranchNameKey branch, String revision)
       throws Exception {
     BranchInput in = new BranchInput();
     in.revision = revision;
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get()).create(in);
+    return gApi.projects().name(branch.project().get()).branch(branch.branch()).create(in);
   }
 
   private static final List<Character> RANDOM =
-      Chars.asList(new char[] {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'});
+      Chars.asList('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h');
 
   protected PushOneCommit.Result amendChange(String changeId) throws Exception {
     return amendChange(changeId, "refs/for/master", admin, testRepo);
@@ -776,7 +738,7 @@
       String content)
       throws Exception {
     PushOneCommit push =
-        pushFactory.create(db, testAccount.getIdent(), repo, subject, fileName, content, changeId);
+        pushFactory.create(testAccount.newIdent(), repo, subject, fileName, content, changeId);
     return push.to(ref);
   }
 
@@ -802,28 +764,8 @@
   }
 
   private Context newRequestContext(TestAccount account) {
-    return atrScope.newContext(
-        reviewDbProvider,
-        new SshSession(sshKeys, server, account),
-        identifiedUserFactory.create(account.getId()));
-  }
-
-  /**
-   * Enforce a new request context for the current API user.
-   *
-   * <p>This recreates the IdentifiedUser, hence everything which is cached in the IdentifiedUser is
-   * reloaded (e.g. the email addresses of the user).
-   */
-  protected Context resetCurrentApiUser() {
-    return atrScope.set(newRequestContext(atrScope.get().getSession().getAccount()));
-  }
-
-  protected Context setApiUser(TestAccount account) {
-    return atrScope.set(newRequestContext(account));
-  }
-
-  protected Context setApiUserAnonymous() {
-    return atrScope.set(atrScope.newContext(reviewDbProvider, null, anonymousUser.get()));
+    requestScopeOperations.setApiUser(account.id());
+    return atrScope.get();
   }
 
   protected Account getAccount(Account.Id accountId) {
@@ -832,18 +774,20 @@
 
   protected AccountState getAccountState(Account.Id accountId) {
     Optional<AccountState> accountState = accountCache.get(accountId);
-    assertThat(accountState).named("account %s", accountId.get()).isPresent();
+    assertWithMessage("account %s", accountId.get())
+        .about(optionals())
+        .that(accountState)
+        .isPresent();
     return accountState.get();
   }
 
-  protected Context disableDb() {
-    notesMigration.setFailOnLoadForTest(true);
-    return atrScope.disableDb();
-  }
-
-  protected void enableDb(Context preDisableContext) {
-    notesMigration.setFailOnLoadForTest(false);
-    atrScope.set(preDisableContext);
+  protected AutoCloseable disableNoteDb() {
+    changeNotesArgs.failOnLoadForTest.set(true);
+    Context oldContext = atrScope.disableNoteDb();
+    return () -> {
+      changeNotesArgs.failOnLoadForTest.set(false);
+      atrScope.set(oldContext);
+    };
   }
 
   protected void disableChangeIndexWrites() {
@@ -864,40 +808,69 @@
 
   protected AutoCloseable disableChangeIndex() {
     disableChangeIndexWrites();
-    ChangeIndex searchIndex = changeIndexes.getSearchIndex();
-    if (!(searchIndex instanceof DisabledChangeIndex)) {
-      changeIndexes.setSearchIndex(new DisabledChangeIndex(searchIndex), false);
+    ChangeIndex maybeDisabledSearchIndex = changeIndexes.getSearchIndex();
+    if (!(maybeDisabledSearchIndex instanceof DisabledChangeIndex)) {
+      changeIndexes.setSearchIndex(new DisabledChangeIndex(maybeDisabledSearchIndex), false);
     }
 
-    return new AutoCloseable() {
-      @Override
-      public void close() throws Exception {
-        enableChangeIndexWrites();
-        ChangeIndex searchIndex = changeIndexes.getSearchIndex();
-        if (searchIndex instanceof DisabledChangeIndex) {
-          changeIndexes.setSearchIndex(((DisabledChangeIndex) searchIndex).unwrap(), false);
-        }
+    return () -> {
+      enableChangeIndexWrites();
+      ChangeIndex maybeEnabledSearchIndex = changeIndexes.getSearchIndex();
+      if (maybeEnabledSearchIndex instanceof DisabledChangeIndex) {
+        changeIndexes.setSearchIndex(
+            ((DisabledChangeIndex) maybeEnabledSearchIndex).unwrap(), false);
       }
     };
   }
 
   protected AutoCloseable disableAccountIndex() {
-    AccountIndex searchIndex = accountIndexes.getSearchIndex();
-    if (!(searchIndex instanceof DisabledAccountIndex)) {
-      accountIndexes.setSearchIndex(new DisabledAccountIndex(searchIndex), false);
+    AccountIndex maybeDisabledSearchIndex = accountIndexes.getSearchIndex();
+    if (!(maybeDisabledSearchIndex instanceof DisabledAccountIndex)) {
+      accountIndexes.setSearchIndex(new DisabledAccountIndex(maybeDisabledSearchIndex), false);
     }
 
-    return new AutoCloseable() {
-      @Override
-      public void close() {
-        AccountIndex searchIndex = accountIndexes.getSearchIndex();
-        if (searchIndex instanceof DisabledAccountIndex) {
-          accountIndexes.setSearchIndex(((DisabledAccountIndex) searchIndex).unwrap(), false);
-        }
+    return () -> {
+      AccountIndex maybeEnabledSearchIndex = accountIndexes.getSearchIndex();
+      if (maybeEnabledSearchIndex instanceof DisabledAccountIndex) {
+        accountIndexes.setSearchIndex(
+            ((DisabledAccountIndex) maybeEnabledSearchIndex).unwrap(), false);
       }
     };
   }
 
+  protected AutoCloseable disableProjectIndex() {
+    disableProjectIndexWrites();
+    ProjectIndex maybeDisabledSearchIndex = projectIndexes.getSearchIndex();
+    if (!(maybeDisabledSearchIndex instanceof DisabledProjectIndex)) {
+      projectIndexes.setSearchIndex(new DisabledProjectIndex(maybeDisabledSearchIndex), false);
+    }
+
+    return () -> {
+      enableProjectIndexWrites();
+      ProjectIndex maybeEnabledSearchIndex = projectIndexes.getSearchIndex();
+      if (maybeEnabledSearchIndex instanceof DisabledProjectIndex) {
+        projectIndexes.setSearchIndex(
+            ((DisabledProjectIndex) maybeEnabledSearchIndex).unwrap(), false);
+      }
+    };
+  }
+
+  protected void disableProjectIndexWrites() {
+    for (ProjectIndex i : projectIndexes.getWriteIndexes()) {
+      if (!(i instanceof DisabledProjectIndex)) {
+        projectIndexes.addWriteIndex(new DisabledProjectIndex(i));
+      }
+    }
+  }
+
+  protected void enableProjectIndexWrites() {
+    for (ProjectIndex i : projectIndexes.getWriteIndexes()) {
+      if (i instanceof DisabledProjectIndex) {
+        projectIndexes.addWriteIndex(((DisabledProjectIndex) i).unwrap());
+      }
+    }
+  }
+
   protected static Gson newGson() {
     return OutputFormat.JSON_COMPACT.newGson();
   }
@@ -906,60 +879,9 @@
     return gApi.changes().id(r.getChangeId()).current();
   }
 
-  protected void allow(String ref, String permission, AccountGroup.UUID id) throws Exception {
-    allow(project, ref, permission, id);
-  }
-
-  protected void allow(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), permission, id, ref);
-      u.save();
-    }
-  }
-
-  protected void allowGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
-      throws Exception {
-    allowGlobalCapabilities(id, Arrays.asList(capabilityNames));
-  }
-
-  protected void allowGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (String capabilityName : capabilityNames) {
-        Util.allow(u.getConfig(), capabilityName, id);
-      }
-      u.save();
-    }
-  }
-
-  protected void removeGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
-      throws Exception {
-    removeGlobalCapabilities(id, Arrays.asList(capabilityNames));
-  }
-
-  protected void removeGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (String capabilityName : capabilityNames) {
-        Util.remove(u.getConfig(), capabilityName, id);
-      }
-      u.save();
-    }
-  }
-
-  protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      ProjectConfig config = ProjectConfig.read(md);
-      config.getProject().setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, value);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
   protected void setUseSignedOffBy(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       config.getProject().setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, value);
       config.commit(md);
       projectCache.evict(config.getProject());
@@ -968,125 +890,15 @@
 
   protected void setRequireChangeId(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       config.getProject().setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, value);
       config.commit(md);
       projectCache.evict(config.getProject());
     }
   }
 
-  protected void deny(String ref, String permission, AccountGroup.UUID id) throws Exception {
-    deny(project, ref, permission, id);
-  }
-
-  protected void deny(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.deny(u.getConfig(), permission, id, ref);
-      u.save();
-    }
-  }
-
-  protected PermissionRule block(String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    return block(project, ref, permission, id);
-  }
-
-  protected PermissionRule block(
-      Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      PermissionRule rule = Util.block(u.getConfig(), permission, id, ref);
-      u.save();
-      return rule;
-    }
-  }
-
-  protected void blockLabel(
-      String label, int min, int max, AccountGroup.UUID id, String ref, Project.NameKey project)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.LABEL + label, min, max, id, ref);
-      u.save();
-    }
-  }
-
-  protected void grant(Project.NameKey project, String ref, String permission)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    grant(project, ref, permission, false);
-  }
-
-  protected void grant(Project.NameKey project, String ref, String permission, boolean force)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    grant(project, ref, permission, force, adminGroupUuid());
-  }
-
-  protected void grant(
-      Project.NameKey project,
-      String ref,
-      String permission,
-      boolean force,
-      AccountGroup.UUID groupUUID)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Grant %s on %s", permission, ref));
-      ProjectConfig config = ProjectConfig.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      PermissionRule rule = Util.newRule(config, groupUUID);
-      rule.setForce(force);
-      p.add(rule);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void grantLabel(
-      String label,
-      int min,
-      int max,
-      Project.NameKey project,
-      String ref,
-      boolean force,
-      AccountGroup.UUID groupUUID,
-      boolean exclusive)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    String permission = Permission.LABEL + label;
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Grant %s on %s", permission, ref));
-      ProjectConfig config = ProjectConfig.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      p.setExclusiveGroup(exclusive);
-      PermissionRule rule = Util.newRule(config, groupUUID);
-      rule.setForce(force);
-      rule.setMin(min);
-      rule.setMax(max);
-      p.add(rule);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void removePermission(Project.NameKey project, String ref, String permission)
-      throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Remove %s on %s", permission, ref));
-      ProjectConfig config = ProjectConfig.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      p.getRules().clear();
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void blockRead(String ref) throws Exception {
-    block(ref, Permission.READ, REGISTERED_USERS);
-  }
-
   protected PushOneCommit.Result pushTo(String ref) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     return push.to(ref);
   }
 
@@ -1112,12 +924,12 @@
         .inOrder();
   }
 
-  protected PatchSet getPatchSet(PatchSet.Id psId) throws OrmException {
-    return changeDataFactory.create(db, project, psId.getParentKey()).patchSet(psId);
+  protected PatchSet getPatchSet(PatchSet.Id psId) {
+    return changeDataFactory.create(project, psId.changeId()).patchSet(psId);
   }
 
   protected IdentifiedUser user(TestAccount testAccount) {
-    return identifiedUserFactory.create(testAccount.getId());
+    return identifiedUserFactory.create(testAccount.id());
   }
 
   protected RevisionResource parseCurrentRevisionResource(String changeId) throws Exception {
@@ -1133,7 +945,7 @@
 
   protected RevisionResource parseRevisionResource(PushOneCommit.Result r) throws Exception {
     PatchSet.Id psId = r.getPatchSetId();
-    return parseRevisionResource(psId.getParentKey().toString(), psId.get());
+    return parseRevisionResource(psId.changeId().toString(), psId.get());
   }
 
   protected ChangeResource parseChangeResource(String changeId) throws Exception {
@@ -1142,86 +954,20 @@
     return changeResourceFactory.create(notes.get(0), atrScope.get().getUser());
   }
 
-  protected String createGroup(String name) throws Exception {
-    return createGroup(name, "Administrators");
-  }
-
-  protected String createGroupWithRealName(String name) throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = "Administrators";
-    gApi.groups().create(in);
-    return name;
-  }
-
-  protected String createGroup(String name, String owner) throws Exception {
-    name = name(name);
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = owner;
-    gApi.groups().create(in);
-    return name;
-  }
-
   protected RevCommit getHead(Repository repo, String name) throws Exception {
     try (RevWalk rw = new RevWalk(repo)) {
       Ref r = repo.exactRef(name);
-      return r != null ? rw.parseCommit(r.getObjectId()) : null;
+      return rw.parseCommit(r.getObjectId());
     }
   }
 
-  protected RevCommit getHead(Repository repo) throws Exception {
-    return getHead(repo, "HEAD");
-  }
-
-  protected RevCommit getRemoteHead(Project.NameKey project, String branch) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      return getHead(repo, branch.startsWith(Constants.R_REFS) ? branch : "refs/heads/" + branch);
-    }
-  }
-
-  protected RevCommit getRemoteHead(String project, String branch) throws Exception {
-    return getRemoteHead(new Project.NameKey(project), branch);
-  }
-
-  protected RevCommit getRemoteHead() throws Exception {
-    return getRemoteHead(project, "master");
-  }
-
   protected void assertMailReplyTo(Message message, String email) throws Exception {
     assertThat(message.headers()).containsKey("Reply-To");
     EmailHeader.String replyTo = (EmailHeader.String) message.headers().get("Reply-To");
     assertThat(replyTo.getString()).contains(email);
   }
 
-  protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
-      throws Exception {
-    ContributorAgreement ca;
-    if (autoVerify) {
-      String g = createGroup("cla-test-group");
-      GroupApi groupApi = gApi.groups().id(g);
-      groupApi.description("CLA test group");
-      InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
-      GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
-      PermissionRule rule = new PermissionRule(groupRef);
-      rule.setAction(PermissionRule.Action.ALLOW);
-      ca = new ContributorAgreement("cla-test");
-      ca.setAutoVerify(groupRef);
-      ca.setAccepted(ImmutableList.of(rule));
-    } else {
-      ca = new ContributorAgreement("cla-test-no-auto-verify");
-    }
-    ca.setDescription("description");
-    ca.setAgreementUrl("agreement-url");
-
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().replace(ca);
-      u.save();
-      return ca;
-    }
-  }
-
-  protected Map<Branch.NameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
+  protected Map<BranchNameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
     try (BinaryResult result = gApi.changes().id(changeId).current().submitPreview()) {
       return fetchFromBundles(result);
     }
@@ -1233,7 +979,7 @@
    *
    * <p>Omits NoteDb meta refs.
    */
-  protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
+  protected Map<BranchNameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
     assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
 
     FileSystem fs = Jimfs.newFileSystem();
@@ -1241,7 +987,7 @@
     try (OutputStream out = Files.newOutputStream(previewPath)) {
       bundles.writeTo(out);
     }
-    Map<Branch.NameKey, ObjectId> ret = new HashMap<>();
+    Map<BranchNameKey, ObjectId> ret = new HashMap<>();
     try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, null);
         DirectoryStream<Path> dirStream =
             Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
@@ -1253,7 +999,7 @@
         int len = bundleName.length();
         assertThat(bundleName).endsWith(".git");
         String repoName = bundleName.substring(0, len - 4);
-        Project.NameKey proj = new Project.NameKey(repoName);
+        Project.NameKey proj = Project.nameKey(repoName);
         TestRepository<?> localRepo = cloneProject(proj);
 
         try (InputStream bundleStream = Files.newInputStream(p);
@@ -1270,7 +1016,7 @@
               continue;
             }
             RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(new Branch.NameKey(proj, refName), c.getTree().copy());
+            ret.put(BranchNameKey.create(proj, refName), c.getTree().copy());
           }
         }
       }
@@ -1280,19 +1026,18 @@
   }
 
   /** Assert that the given branches have the given tree ids. */
-  protected void assertTrees(Project.NameKey proj, Map<Branch.NameKey, ObjectId> trees)
+  protected void assertTrees(Project.NameKey proj, Map<BranchNameKey, ObjectId> trees)
       throws Exception {
     TestRepository<?> localRepo = cloneProject(proj);
     GitUtil.fetch(localRepo, "refs/*:refs/*");
-    Map<String, Ref> refs = localRepo.getRepository().getAllRefs();
-    Map<Branch.NameKey, RevTree> refValues = new HashMap<>();
+    Map<BranchNameKey, RevTree> refValues = new HashMap<>();
 
-    for (Branch.NameKey b : trees.keySet()) {
-      if (!b.getParentKey().equals(proj)) {
+    for (BranchNameKey b : trees.keySet()) {
+      if (!b.project().equals(proj)) {
         continue;
       }
 
-      Ref r = refs.get(b.get());
+      Ref r = localRepo.getRepository().exactRef(b.branch());
       assertThat(r).isNotNull();
       RevWalk rw = localRepo.getRevWalk();
       RevCommit c = rw.parseCommit(r.getObjectId());
@@ -1305,8 +1050,7 @@
 
   protected void assertDiffForNewFile(
       DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
-    List<String> expectedLines = new ArrayList<>();
-    Collections.addAll(expectedLines, expectedContentSideB.split("\n"));
+    List<String> expectedLines = ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
 
     assertThat(diff.binary).isNull();
     assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
@@ -1397,7 +1141,7 @@
 
   protected InternalGroup group(AccountGroup.UUID groupUuid) {
     InternalGroup group = groupCache.get(groupUuid).orElse(null);
-    assertThat(group).named(groupUuid.get()).isNotNull();
+    assertWithMessage(groupUuid.get()).that(group).isNotNull();
     return group;
   }
 
@@ -1407,13 +1151,13 @@
   }
 
   protected InternalGroup group(String groupName) {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
-    assertThat(group).named(groupName).isNotNull();
+    InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
+    assertWithMessage(groupName).that(group).isNotNull();
     return group;
   }
 
   protected GroupReference groupRef(String groupName) {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
+    InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
     assertThat(group).isNotNull();
     return new GroupReference(group.getGroupUUID(), group.getName());
   }
@@ -1435,20 +1179,15 @@
   }
 
   protected void assertGroupDoesNotExist(String groupName) {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
-    assertThat(group).named(groupName).isNull();
+    InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
+    assertWithMessage(groupName).that(group).isNull();
   }
 
   protected void assertNotifyTo(TestAccount expected) {
-    assertNotifyTo(expected.email, expected.fullName);
+    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);
@@ -1459,12 +1198,7 @@
   }
 
   protected void assertNotifyCc(TestAccount expected) {
-    assertNotifyCc(expected.emailAddress);
-  }
-
-  protected void assertNotifyCc(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
-    assertNotifyCc(expected.preferredEmail().orElse(null), expected.fullname().orElse(null));
+    assertNotifyCc(expected.getEmailAddress());
   }
 
   protected void assertNotifyCc(String expectedEmail, String expectedFullname) {
@@ -1484,18 +1218,15 @@
   protected void assertNotifyBcc(TestAccount expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.rcpt()).containsExactly(expected.getEmailAddress());
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     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();
   }
@@ -1513,7 +1244,7 @@
   }
 
   protected void watch(PushOneCommit.Result r, ProjectWatchInfoConfiguration config)
-      throws OrmException, RestApiException {
+      throws RestApiException {
     watch(r.getChange().project().get(), config);
   }
 
@@ -1572,25 +1303,43 @@
   }
 
   protected void configLabel(String label, LabelFunction func) throws Exception {
+    configLabel(label, func, ImmutableList.of());
+  }
+
+  protected void configLabel(String label, LabelFunction func, List<String> refPatterns)
+      throws Exception {
     configLabel(
-        project, label, func, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        project,
+        label,
+        func,
+        refPatterns,
+        value(1, "Passes"),
+        value(0, "No score"),
+        value(-1, "Failed"));
   }
 
   protected void configLabel(
       Project.NameKey project, String label, LabelFunction func, LabelValue... value)
       throws Exception {
+    configLabel(project, label, func, ImmutableList.of(), value);
+  }
+
+  private void configLabel(
+      Project.NameKey project,
+      String label,
+      LabelFunction func,
+      List<String> refPatterns,
+      LabelValue... value)
+      throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = category(label, value);
+      LabelType labelType = label(label, value);
       labelType.setFunction(func);
+      labelType.setRefPatterns(refPatterns);
       u.getConfig().getLabelSections().put(labelType.getName(), labelType);
       u.save();
     }
   }
 
-  protected void fail(@Nullable String format, Object... args) {
-    assert_().fail(format, args);
-  }
-
   protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
@@ -1612,7 +1361,7 @@
 
     private ProjectConfigUpdate(Project.NameKey projectName) throws Exception {
       metaDataUpdate = metaDataUpdateFactory.create(projectName);
-      projectConfig = ProjectConfig.read(metaDataUpdate);
+      projectConfig = projectConfigFactory.read(metaDataUpdate);
     }
 
     public ProjectConfig getConfig() {
@@ -1620,7 +1369,7 @@
     }
 
     public void save() throws Exception {
-      metaDataUpdate.setAuthor(identifiedUserFactory.create(admin.getId()));
+      metaDataUpdate.setAuthor(identifiedUserFactory.create(admin.id()));
       projectConfig.commit(metaDataUpdate);
       metaDataUpdate.close();
       metaDataUpdate = null;
@@ -1634,4 +1383,70 @@
       }
     }
   }
+
+  protected List<RevCommit> getChangeMetaCommitsInReverseOrder(Change.Id changeId)
+      throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      revWalk.sort(RevSort.TOPO);
+      revWalk.sort(RevSort.REVERSE);
+      Ref metaRef = repo.exactRef(RefNames.changeMetaRef(changeId));
+      revWalk.markStart(revWalk.parseCommit(metaRef.getObjectId()));
+      return Lists.newArrayList(revWalk);
+    }
+  }
+
+  protected List<CommentInfo> getChangeSortedComments(int changeNum) throws Exception {
+    List<CommentInfo> comments = new ArrayList<>();
+    Map<String, List<CommentInfo>> commentsMap = gApi.changes().id(changeNum).comments();
+    for (Map.Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
+      for (CommentInfo c : e.getValue()) {
+        c.path = e.getKey(); // Set the comment's path field.
+        comments.add(c);
+      }
+    }
+    comments.sort(Comparator.comparing(c -> c.id));
+    return comments;
+  }
+
+  protected AutoCloseable installPlugin(String pluginName, Class<? extends Module> sysModuleClass)
+      throws Exception {
+    return installPlugin(pluginName, sysModuleClass, null, null);
+  }
+
+  protected AutoCloseable installPlugin(
+      String pluginName,
+      @Nullable Class<? extends Module> sysModuleClass,
+      @Nullable Class<? extends Module> httpModuleClass,
+      @Nullable Class<? extends Module> sshModuleClass)
+      throws Exception {
+    checkStatic(sysModuleClass);
+    checkStatic(httpModuleClass);
+    checkStatic(sshModuleClass);
+    TestServerPlugin plugin =
+        new TestServerPlugin(
+            pluginName,
+            "http://example.com/" + pluginName,
+            pluginUserFactory.create(pluginName),
+            getClass().getClassLoader(),
+            sysModuleClass != null ? sysModuleClass.getName() : null,
+            httpModuleClass != null ? httpModuleClass.getName() : null,
+            sshModuleClass != null ? sshModuleClass.getName() : null,
+            sitePaths.data_dir.resolve(pluginName));
+    plugin.start(pluginGuiceEnvironment);
+    pluginGuiceEnvironment.onStartPlugin(plugin);
+    return () -> {
+      plugin.stop(pluginGuiceEnvironment);
+      pluginGuiceEnvironment.onStopPlugin(plugin);
+    };
+  }
+
+  private static void checkStatic(@Nullable Class<? extends Module> moduleClass) {
+    if (moduleClass != null) {
+      checkArgument(
+          (moduleClass.getModifiers() & Modifier.STATIC) != 0,
+          "module must be static: %s",
+          moduleClass.getName());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 2336f2f..f62ccfb 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.gerrit.extensions.api.changes.RecipientType.BCC;
 import static com.google.gerrit.extensions.api.changes.RecipientType.CC;
@@ -25,6 +26,7 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
 import com.google.common.truth.Truth;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -34,13 +36,13 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.mail.EmailHeader.AddressList;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader;
-import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import java.io.IOException;
+import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -53,9 +55,11 @@
 import org.junit.Before;
 
 public abstract class AbstractNotificationTest extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Before
   public void enableReviewerByEmail() throws Exception {
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
@@ -69,7 +73,11 @@
   }
 
   protected static FakeEmailSenderSubject assertThat(FakeEmailSender sender) {
-    return assertAbout(FakeEmailSenderSubject::new).that(sender);
+    return assertAbout(fakeEmailSenders()).that(sender);
+  }
+
+  protected static Subject.Factory<FakeEmailSenderSubject, FakeEmailSender> fakeEmailSenders() {
+    return FakeEmailSenderSubject::new;
   }
 
   protected void setEmailStrategy(TestAccount account, EmailStrategy strategy) throws Exception {
@@ -81,14 +89,14 @@
     if (record) {
       accountsModifyingEmailStrategy.add(account);
     }
-    setApiUser(account);
+    requestScopeOperations.setApiUser(account.id());
     GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
     prefs.emailStrategy = strategy;
     gApi.accounts().self().setPreferences(prefs);
   }
 
-  protected static class FakeEmailSenderSubject
-      extends Subject<FakeEmailSenderSubject, FakeEmailSender> {
+  protected static class FakeEmailSenderSubject extends Subject {
+    private final FakeEmailSender fakeEmailSender;
     private Message message;
     private StagedUsers users;
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
@@ -96,43 +104,48 @@
 
     FakeEmailSenderSubject(FailureMetadata failureMetadata, FakeEmailSender target) {
       super(failureMetadata, target);
+      fakeEmailSender = target;
     }
 
-    public FakeEmailSenderSubject notSent() {
-      if (actual().peekMessage() != null) {
-        fail("a message wasn't sent");
+    public FakeEmailSenderSubject didNotSend() {
+      Message message = fakeEmailSender.peekMessage();
+      if (message != null) {
+        failWithoutActual(fact("expected no message", message));
       }
       return this;
     }
 
     public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
-      message = actual().nextMessage();
+      message = fakeEmailSender.nextMessage();
       if (message == null) {
-        fail("a message was sent");
+        failWithoutActual(fact("expected message", "not sent"));
       }
       recipients = new HashMap<>();
       recipients.put(TO, parseAddresses(message, "To"));
       recipients.put(CC, parseAddresses(message, "Cc"));
       recipients.put(
           BCC,
-          message
-              .rcpt()
-              .stream()
+          message.rcpt().stream()
               .map(Address::getEmail)
               .filter(e -> !recipients.get(TO).contains(e) && !recipients.get(CC).contains(e))
               .collect(toList()));
       this.users = users;
       if (!message.headers().containsKey("X-Gerrit-MessageType")) {
-        fail("a message was sent with X-Gerrit-MessageType header");
+        failWithoutActual(
+            fact("expected to have message sent with", "X-Gerrit-MessageType header"));
       }
       EmailHeader header = message.headers().get("X-Gerrit-MessageType");
       if (!header.equals(new EmailHeader.String(messageType))) {
-        fail("message of type " + messageType + " was sent; X-Gerrit-MessageType is " + header);
+        failWithoutActual(
+            fact("expected message of type", messageType),
+            fact(
+                "actual",
+                header instanceof EmailHeader.String
+                    ? ((EmailHeader.String) header).getString()
+                    : header));
       }
 
-      // Return a named subject that displays a human-readable table of
-      // recipients.
-      return named(recipientMapToString(recipients, users::emailToName));
+      return this;
     }
 
     private static String recipientMapToString(
@@ -191,9 +204,11 @@
 
     private void rcpt(@Nullable RecipientType type, String email, boolean expected) {
       if (recipients.get(type).contains(email) != expected) {
-        fail(
-            expected ? "notifies" : "doesn't notify",
-            "]\n" + type + ": " + users.emailToName(email) + "\n]");
+        failWithoutActual(
+            fact(
+                expected ? "expected to notify" : "expected not to notify",
+                type + ": " + users.emailToName(email)),
+            fact("but notified", recipientMapToString(recipients, users::emailToName)));
       }
       if (expected) {
         accountedFor.add(email);
@@ -202,7 +217,7 @@
 
     public FakeEmailSenderSubject noOneElse() {
       for (Map.Entry<NotifyType, TestAccount> watchEntry : users.watchers.entrySet()) {
-        if (!accountedFor.contains(watchEntry.getValue().email)) {
+        if (!accountedFor.contains(watchEntry.getValue().email())) {
           notTo(watchEntry.getKey());
         }
       }
@@ -219,9 +234,10 @@
         }
       }
       if (!ok) {
-        fail(
-            "was fully tested, missing assertions for: "
-                + recipientMapToString(unaccountedFor, e -> users.emailToName(e)));
+        failWithoutActual(
+            fact(
+                "expected assertions for",
+                recipientMapToString(unaccountedFor, e -> users.emailToName(e))));
       }
       return this;
     }
@@ -254,7 +270,7 @@
     }
 
     private void rcpt(@Nullable RecipientType type, TestAccount account) {
-      rcpt(type, account.email);
+      rcpt(type, account.email());
     }
 
     public FakeEmailSenderSubject to(NotifyType... watches) {
@@ -282,7 +298,7 @@
 
     private void rcpt(@Nullable RecipientType type, NotifyType watch) {
       if (!users.watchers.containsKey(watch)) {
-        fail("configured to watch", watch);
+        failWithoutActual(fact("expected to be configured to watch", watch));
       }
       rcpt(type, users.watchers.get(watch));
     }
@@ -315,14 +331,15 @@
     public final String ccerByEmail = "ccByEmail@example.com";
     private final Map<NotifyType, TestAccount> watchers = new HashMap<>();
     private final Map<String, TestAccount> accountsByEmail = new HashMap<>();
-    boolean supportReviewersByEmail;
+
+    public boolean supportReviewersByEmail;
 
     private String usersCacheKey() {
       return description.getClassName();
     }
 
-    private TestAccount evictAndCopy(TestAccount account) throws IOException {
-      evictAndReindexAccount(account.id);
+    private TestAccount evictAndCopy(TestAccount account) {
+      evictAndReindexAccount(account.id());
       return account;
     }
 
@@ -351,7 +368,7 @@
         assignee = testAccount("assignee");
 
         watchingProjectOwner = testAccount("watchingProjectOwner", "Administrators");
-        setApiUser(watchingProjectOwner);
+        requestScopeOperations.setApiUser(watchingProjectOwner.id());
         watch(allProjects.get(), pwi -> pwi.notifyNewChanges = true);
 
         for (NotifyType watch : NotifyType.values()) {
@@ -359,7 +376,7 @@
             continue;
           }
           TestAccount watcher = testAccount(watch.toString());
-          setApiUser(watcher);
+          requestScopeOperations.setApiUser(watcher.id());
           watch(
               allProjects.get(),
               pwi -> {
@@ -390,20 +407,20 @@
     public TestAccount testAccount(String name) throws Exception {
       String username = name(name);
       TestAccount account = accountCreator.create(username, email(username), name);
-      accountsByEmail.put(account.email, account);
+      accountsByEmail.put(account.email(), account);
       return account;
     }
 
     public TestAccount testAccount(String name, String groupName) throws Exception {
       String username = name(name);
       TestAccount account = accountCreator.create(username, email(username), name, groupName);
-      accountsByEmail.put(account.email, account);
+      accountsByEmail.put(account.email(), account);
       return account;
     }
 
     String emailToName(String email) {
       if (accountsByEmail.containsKey(email)) {
-        return accountsByEmail.get(email).fullName;
+        return accountsByEmail.get(email).fullName();
       }
       return email;
     }
@@ -411,9 +428,9 @@
     protected void addReviewers(PushOneCommit.Result r) throws Exception {
       ReviewInput in =
           ReviewInput.noScore()
-              .reviewer(reviewer.email)
+              .reviewer(reviewer.email())
               .reviewer(reviewerByEmail)
-              .reviewer(ccer.email, ReviewerState.CC, false)
+              .reviewer(ccer.email(), ReviewerState.CC, false)
               .reviewer(ccerByEmail, ReviewerState.CC, false);
       ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
       supportReviewersByEmail = true;
@@ -421,8 +438,8 @@
         supportReviewersByEmail = false;
         in =
             ReviewInput.noScore()
-                .reviewer(reviewer.email)
-                .reviewer(ccer.email, ReviewerState.CC, false);
+                .reviewer(reviewer.email())
+                .reviewer(ccer.email(), ReviewerState.CC, false);
         result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
       }
       Truth.assertThat(result.reviewers.values().stream().allMatch(v -> v.error == null)).isTrue();
@@ -452,9 +469,9 @@
       if (pushOptions != null) {
         ref = ref + '%' + Joiner.on(',').join(pushOptions);
       }
-      setApiUser(owner);
+      requestScopeOperations.setApiUser(owner.id());
       repo = cloneProject(project, owner);
-      PushOneCommit push = pushFactory.create(db, owner.getIdent(), repo);
+      PushOneCommit push = pushFactory.create(owner.newIdent(), repo);
       result = push.to(ref);
       result.assertOkStatus();
       changeId = result.getChangeId();
@@ -474,33 +491,38 @@
     StagedChange(String ref) throws Exception {
       super(ref);
 
-      setApiUser(starrer);
+      requestScopeOperations.setApiUser(starrer.id());
       gApi.accounts().self().starChange(result.getChangeId());
 
-      setApiUser(owner);
+      requestScopeOperations.setApiUser(owner.id());
       addReviewers(result);
       sender.clear();
     }
   }
 
   protected StagedChange stageReviewableChange() throws Exception {
-    return new StagedChange("refs/for/master");
+    StagedChange sc = new StagedChange("refs/for/master");
+    sender.clear();
+    return sc;
   }
 
   protected StagedChange stageWipChange() throws Exception {
-    return new StagedChange("refs/for/master%wip");
+    StagedChange sc = new StagedChange("refs/for/master%wip");
+    sender.clear();
+    return sc;
   }
 
   protected StagedChange stageReviewableWipChange() throws Exception {
     StagedChange sc = stageReviewableChange();
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     gApi.changes().id(sc.changeId).setWorkInProgress();
+    sender.clear();
     return sc;
   }
 
   protected StagedChange stageAbandonedReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChange();
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     gApi.changes().id(sc.changeId).abandon();
     sender.clear();
     return sc;
@@ -508,7 +530,7 @@
 
   protected StagedChange stageAbandonedReviewableWipChange() throws Exception {
     StagedChange sc = stageReviewableWipChange();
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     gApi.changes().id(sc.changeId).abandon();
     sender.clear();
     return sc;
@@ -516,7 +538,7 @@
 
   protected StagedChange stageAbandonedWipChange() throws Exception {
     StagedChange sc = stageWipChange();
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     gApi.changes().id(sc.changeId).abandon();
     sender.clear();
     return sc;
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
new file mode 100644
index 0000000..ccd30ab
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -0,0 +1,201 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.restapi.change.GetChange;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.List;
+import java.util.Objects;
+import org.kohsuke.args4j.Option;
+
+public class AbstractPluginFieldsTest extends AbstractDaemonTest {
+  protected static class MyInfo extends PluginDefinedInfo {
+    @Nullable String theAttribute;
+
+    public MyInfo(@Nullable String theAttribute) {
+      this.theAttribute = theAttribute;
+    }
+
+    MyInfo(String name, @Nullable String theAttribute) {
+      this.name = requireNonNull(name);
+      this.theAttribute = theAttribute;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof MyInfo)) {
+        return false;
+      }
+      MyInfo i = (MyInfo) o;
+      return Objects.equals(name, i.name) && Objects.equals(theAttribute, i.theAttribute);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(name, theAttribute);
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("name", name)
+          .add("theAttribute", theAttribute)
+          .toString();
+    }
+  }
+
+  protected static class NullAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangeAttributeFactory.class).toInstance((cd, bp, p) -> null);
+    }
+  }
+
+  protected static class SimpleAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
+    }
+  }
+
+  private static class MyOptions implements DynamicBean {
+    @Option(name = "--opt")
+    private String opt;
+  }
+
+  protected static class OptionAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+          .toInstance(
+              (cd, bp, p) -> {
+                MyOptions opts = (MyOptions) bp.getDynamicBean(p);
+                return opts != null ? new MyInfo("opt " + opts.opt) : null;
+              });
+      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
+    }
+  }
+
+  protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getter.call(id)).isNull();
+
+    try (AutoCloseable ignored = installPlugin("my-plugin", NullAttributeModule.class)) {
+      assertThat(getter.call(id)).isNull();
+    }
+
+    assertThat(getter.call(id)).isNull();
+  }
+
+  protected void getChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
+    getChangeWithSimpleAttribute(getter, SimpleAttributeModule.class);
+  }
+
+  protected void getChangeWithSimpleAttribute(
+      PluginInfoGetter getter, Class<? extends Module> moduleClass) throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getter.call(id)).isNull();
+
+    try (AutoCloseable ignored = installPlugin("my-plugin", moduleClass)) {
+      assertThat(getter.call(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
+    }
+
+    assertThat(getter.call(id)).isNull();
+  }
+
+  protected void getChangeWithOption(
+      PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
+      throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getterWithoutOptions.call(id)).isNull();
+
+    try (AutoCloseable ignored = installPlugin("my-plugin", OptionAttributeModule.class)) {
+      assertThat(getterWithoutOptions.call(id))
+          .containsExactly(new MyInfo("my-plugin", "opt null"));
+      assertThat(getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")))
+          .containsExactly(new MyInfo("my-plugin", "opt foo"));
+    }
+
+    assertThat(getterWithoutOptions.call(id)).isNull();
+  }
+
+  protected static List<MyInfo> pluginInfoFromSingletonList(List<ChangeInfo> changeInfos) {
+    assertThat(changeInfos).hasSize(1);
+    return pluginInfoFromChangeInfo(changeInfos.get(0));
+  }
+
+  protected static List<MyInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
+    List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
+    if (pluginInfo == null) {
+      return null;
+    }
+    return pluginInfo.stream().map(MyInfo.class::cast).collect(toImmutableList());
+  }
+
+  /**
+   * Decode {@code MyInfo}s from a raw list of maps returned from Gson.
+   *
+   * <p>This method is used instead of decoding {@code ChangeInfo} or {@code ChangAttribute}, since
+   * Gson would decode the {@code plugins} field as a {@code List<PluginDefinedInfo>}, which would
+   * return the base type and silently ignore any fields that are defined only in the subclass.
+   * Instead, decode the enclosing {@code ChangeInfo} or {@code ChangeAttribute} as a raw {@code
+   * Map<String, Object>}, and pass the {@code "plugins"} value to this method.
+   *
+   * @param gson Gson converter.
+   * @param plugins list of {@code MyInfo} objects, each as a raw map returned from Gson.
+   * @return decoded list of {@code MyInfo}s.
+   */
+  protected static List<MyInfo> decodeRawPluginsList(Gson gson, @Nullable Object plugins) {
+    if (plugins == null) {
+      return null;
+    }
+    checkArgument(plugins instanceof List, "not a list: %s", plugins);
+    return gson.fromJson(gson.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
+  }
+
+  @FunctionalInterface
+  protected interface PluginInfoGetter {
+    List<MyInfo> call(Change.Id id) throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface PluginInfoGetterWithOptions {
+    List<MyInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
+        throws Exception;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
index 3acee77..50536d8 100644
--- a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
+++ b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
@@ -14,22 +14,17 @@
 
 package com.google.gerrit.acceptance;
 
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.RequestCleanup;
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestScopePropagator;
-import com.google.gerrit.testing.DisabledReviewDb;
-import com.google.gwtorm.server.SchemaFactory;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.OutOfScopeException;
 import com.google.inject.Provider;
 import com.google.inject.Scope;
-import com.google.inject.util.Providers;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -37,13 +32,9 @@
 public class AcceptanceTestRequestScope {
   private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
 
-  private static final Key<RequestScopedReviewDbProvider> DB_KEY =
-      Key.get(RequestScopedReviewDbProvider.class);
-
   public static class Context implements RequestContext {
     private final RequestCleanup cleanup = new RequestCleanup();
     private final Map<Key<?>, Object> map = new HashMap<>();
-    private final SchemaFactory<ReviewDb> schemaFactory;
     private final SshSession session;
     private final CurrentUser user;
 
@@ -51,22 +42,20 @@
     volatile long started;
     volatile long finished;
 
-    private Context(SchemaFactory<ReviewDb> sf, SshSession s, CurrentUser u, long at) {
-      schemaFactory = sf;
+    private Context(SshSession s, CurrentUser u, long at) {
       session = s;
       user = u;
       created = started = finished = at;
       map.put(RC_KEY, cleanup);
-      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
     }
 
     private Context(Context p, SshSession s, CurrentUser c) {
-      this(p.schemaFactory, s, c, p.created);
+      this(s, c, p.created);
       started = p.started;
       finished = p.finished;
     }
 
-    SshSession getSession() {
+    public SshSession getSession() {
       return session;
     }
 
@@ -78,11 +67,6 @@
       return user;
     }
 
-    @Override
-    public Provider<ReviewDb> getReviewDbProvider() {
-      return (RequestScopedReviewDbProvider) map.get(DB_KEY);
-    }
-
     synchronized <T> T get(Key<T> key, Provider<T> creator) {
       @SuppressWarnings("unchecked")
       T t = (T) map.get(key);
@@ -112,11 +96,8 @@
     private final AcceptanceTestRequestScope atrScope;
 
     @Inject
-    Propagator(
-        AcceptanceTestRequestScope atrScope,
-        ThreadLocalRequestContext local,
-        Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
-      super(REQUEST, current, local, dbProviderProvider);
+    Propagator(AcceptanceTestRequestScope atrScope, ThreadLocalRequestContext local) {
+      super(REQUEST, current, local);
       this.atrScope = atrScope;
     }
 
@@ -145,8 +126,8 @@
     this.local = local;
   }
 
-  public Context newContext(SchemaFactory<ReviewDb> sf, SshSession s, CurrentUser user) {
-    return new Context(sf, s, user, TimeUtil.nowMs());
+  public Context newContext(SshSession s, CurrentUser user) {
+    return new Context(s, user, TimeUtil.nowMs());
   }
 
   private Context newContinuingContext(Context ctx) {
@@ -164,23 +145,18 @@
     return current.get();
   }
 
-  public Context disableDb() {
+  /**
+   * Disables read and write access to NoteDb and returns the context prior to that modification.
+   */
+  public Context disableNoteDb() {
     Context old = current.get();
-    SchemaFactory<ReviewDb> sf = DisabledReviewDb::new;
-    Context ctx = new Context(sf, old.session, old.user, old.created);
+    Context ctx = new Context(old.session, old.user, old.created);
 
     current.set(ctx);
     local.setContext(ctx);
     return old;
   }
 
-  public Context reopenDb() {
-    // Setting a new context with the same fields is enough to get the ReviewDb
-    // provider to reopen the database.
-    Context old = current.get();
-    return set(new Context(old.schemaFactory, old.session, old.user, old.created));
-  }
-
   /** Returns exactly one instance per command executed. */
   static final Scope REQUEST =
       new Scope() {
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 1416797..898c5c7 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.acceptance;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -76,7 +76,7 @@
     if (account != null) {
       return account;
     }
-    Account.Id id = new Account.Id(sequences.nextAccountId());
+    Account.Id id = Account.id(sequences.nextAccountId());
 
     List<ExternalId> extIds = new ArrayList<>(2);
     String httpPass = null;
@@ -98,7 +98,7 @@
 
     if (groupNames != null) {
       for (String n : groupNames) {
-        AccountGroup.NameKey k = new AccountGroup.NameKey(n);
+        AccountGroup.NameKey k = AccountGroup.nameKey(n);
         Optional<InternalGroup> group = groupCache.get(k);
         if (!group.isPresent()) {
           throw new NoSuchGroupException(n);
@@ -107,7 +107,7 @@
       }
     }
 
-    account = new TestAccount(id, username, email, fullName, httpPass);
+    account = TestAccount.create(id, username, email, fullName, httpPass);
     if (username != null) {
       accounts.put(username, account);
     }
@@ -143,15 +143,20 @@
   }
 
   public TestAccount get(String username) {
-    return checkNotNull(accounts.get(username), "No TestAccount created for %s", username);
+    return requireNonNull(
+        accounts.get(username), () -> String.format("No TestAccount created for %s ", username));
   }
 
   public void evict(Collection<Account.Id> ids) {
-    accounts.values().removeIf(a -> ids.contains(a.id));
+    accounts.values().removeIf(a -> ids.contains(a.id()));
+  }
+
+  public ImmutableList<TestAccount> getAll() {
+    return ImmutableList.copyOf(accounts.values());
   }
 
   private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
-      throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
+      throws IOException, NoSuchGroupException, ConfigInvalidException {
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, ImmutableSet.of(accountId)))
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 25e1d7c..ef9f4e6 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -1,8 +1,14 @@
 load("//tools/bzl:java.bzl", "java_library2")
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
+FUNCTION_SRCS = [
+    "testsuite/ThrowingConsumer.java",
+    "testsuite/ThrowingFunction.java",
+]
 
 java_library(
     name = "lib",
-    testonly = 1,
+    testonly = True,
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/acceptance"],
     visibility = ["//visibility:public"],
@@ -13,28 +19,31 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
         "//java/com/google/gerrit/extensions/restapi/testing:restapi-test-util",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/git/testing",
         "//java/com/google/gerrit/gpg/testing:gpg-test-util",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/pgm",
         "//java/com/google/gerrit/pgm/init",
         "//java/com/google/gerrit/pgm/util",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/sshd",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/truth",
         "//lib:args4j",
         "//lib:gson",
         "//lib:guava-retrying",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
         "//lib:h2",
         "//lib:jimfs",
         "//lib:jsch",
@@ -54,7 +63,7 @@
 
 java_binary(
     name = "framework",
-    testonly = 1,
+    testonly = True,
     main_class = "Dummy",
     visibility = ["//visibility:public"],
     runtime_deps = [":framework-lib"],
@@ -62,9 +71,15 @@
 
 java_library2(
     name = "framework-lib",
-    testonly = 1,
-    srcs = glob(["**/*.java"]),
+    testonly = True,
+    srcs = glob(
+        ["**/*.java"],
+        exclude = FUNCTION_SRCS,
+    ),
     exported_deps = [
+        ":function",
+        "//java/com/google/gerrit/acceptance/testsuite/project",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/httpd/auth/openid",
         "//java/com/google/gerrit/index:query_exception",
@@ -87,6 +102,7 @@
         "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/log:impl-log4j",
         "//lib/log:log4j",
+        "//lib/mockito",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
         "//prolog:gerrit-prolog-common",
@@ -98,19 +114,26 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/pgm/init",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/sshd",
+        "//lib:args4j",
         "//lib:gson",
         "//lib:guava-retrying",
-        "//lib:gwtorm",
         "//lib:jsch",
         "//lib:servlet-api-3_1",
+        "//lib/commons:lang",
         "//lib/greenmail",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
@@ -121,11 +144,15 @@
     ],
 )
 
-load("//tools/bzl:javadoc.bzl", "java_doc")
+java_library(
+    name = "function",
+    srcs = FUNCTION_SRCS,
+    visibility = ["//visibility:public"],
+)
 
 java_doc(
     name = "framework-javadoc",
-    testonly = 1,
+    testonly = True,
     libs = [":framework-lib"],
     pkgs = ["com.google.gerrit.acceptance"],
     title = "Gerrit Acceptance Test Framework Documentation",
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
new file mode 100644
index 0000000..e04a643
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.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.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+
+public class ChangeIndexedCounter implements ChangeIndexedListener {
+  private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
+
+  @Override
+  public void onChangeIndexed(String projectName, int id) {
+    countsByChange.incrementAndGet(id);
+  }
+
+  @Override
+  public void onChangeDeleted(int id) {
+    countsByChange.incrementAndGet(id);
+  }
+
+  public void clear() {
+    countsByChange.clear();
+  }
+
+  public void assertReindexOf(ChangeInfo info) {
+    assertReindexOf(info, 1);
+  }
+
+  public void assertReindexOf(ChangeInfo info, long expectedCount) {
+    assertThat(countsByChange.asMap()).containsExactly(info._number, expectedCount);
+    clear();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java b/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
index 0aa56cf..0a1d765 100644
--- a/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
+++ b/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.auto.value.AutoAnnotation;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import java.lang.annotation.Annotation;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -45,32 +45,13 @@
     return cfg;
   }
 
-  static class GlobalPluginConfigToGerritConfig implements GerritConfig {
-    private final GlobalPluginConfig delegate;
+  private static GerritConfig toGerritConfig(GlobalPluginConfig annotation) {
+    return newGerritConfig(annotation.name(), annotation.value(), annotation.values());
+  }
 
-    GlobalPluginConfigToGerritConfig(GlobalPluginConfig delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public Class<? extends Annotation> annotationType() {
-      return delegate.annotationType();
-    }
-
-    @Override
-    public String name() {
-      return delegate.name();
-    }
-
-    @Override
-    public String value() {
-      return delegate.value();
-    }
-
-    @Override
-    public String[] values() {
-      return delegate.values();
-    }
+  @AutoAnnotation
+  private static GerritConfig newGerritConfig(String name, String value, String[] values) {
+    return new AutoAnnotation_ConfigAnnotationParser_newGerritConfig(name, value, values);
   }
 
   static Map<String, Config> parse(GlobalPluginConfig annotation) {
@@ -79,7 +60,7 @@
     }
     Map<String, Config> result = new HashMap<>();
     Config cfg = new Config();
-    parseAnnotation(cfg, new GlobalPluginConfigToGerritConfig(annotation));
+    parseAnnotation(cfg, toGerritConfig(annotation));
     result.put(annotation.pluginName(), cfg);
     return result;
   }
@@ -100,7 +81,7 @@
         config = new Config();
         result.put(pluginName, config);
       }
-      parseAnnotation(config, new GlobalPluginConfigToGerritConfig(c));
+      parseAnnotation(config, toGerritConfig(c));
     }
 
     return result;
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
index 0d473af..a32c6d1 100644
--- a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
@@ -20,10 +20,8 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.query.change.ChangeData;
-import java.io.IOException;
 import java.util.Optional;
 
 /**
@@ -54,17 +52,17 @@
   }
 
   @Override
-  public void replace(ChangeData obj) throws IOException {
+  public void replace(ChangeData obj) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
 
   @Override
-  public void delete(Id key) throws IOException {
+  public void delete(Change.Id key) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
 
   @Override
-  public void deleteAll() throws IOException {
+  public void deleteAll() {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
 
@@ -75,12 +73,12 @@
   }
 
   @Override
-  public void markReady(boolean ready) throws IOException {
+  public void markReady(boolean ready) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
 
   @Override
-  public Optional<ChangeData> get(Change.Id key, QueryOptions opts) throws IOException {
+  public Optional<ChangeData> get(Change.Id key, QueryOptions opts) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
 }
diff --git a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
new file mode 100644
index 0000000..2524a76
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Project;
+
+/**
+ * This class wraps an index and assumes the search index can't handle any queries. However, it does
+ * return the current schema as the assumption is that we need a search index for starting Gerrit in
+ * the first place and only later lose the index connection (making it so that we can't send
+ * requests there anymore).
+ */
+public class DisabledProjectIndex implements ProjectIndex {
+  private final ProjectIndex index;
+
+  public DisabledProjectIndex(ProjectIndex index) {
+    this.index = index;
+  }
+
+  public ProjectIndex unwrap() {
+    return index;
+  }
+
+  @Override
+  public Schema<ProjectData> getSchema() {
+    return index.getSchema();
+  }
+
+  @Override
+  public void close() {
+    index.close();
+  }
+
+  @Override
+  public void replace(ProjectData obj) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
+  public void delete(Project.NameKey key) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
+  public void deleteAll() {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
+  public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
+  public void markReady(boolean ready) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/EventRecorder.java b/java/com/google/gerrit/acceptance/EventRecorder.java
index f9f95b5..cab6b58 100644
--- a/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.LinkedListMultimap;
@@ -25,6 +26,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.ChangeDeletedEvent;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.RefEvent;
@@ -54,7 +56,7 @@
     }
 
     public EventRecorder create(TestAccount user) {
-      return new EventRecorder(eventListeners, userFactory.create(user.id));
+      return new EventRecorder(eventListeners, userFactory.create(user.id()));
     }
   }
 
@@ -63,11 +65,14 @@
 
     eventListenerRegistration =
         eventListeners.add(
+            "gerrit",
             new UserScopedEventListener() {
               @Override
               public void onEvent(Event e) {
                 if (e instanceof ReviewerDeletedEvent) {
                   recordedEvents.put(ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
+                } else if (e instanceof ChangeDeletedEvent) {
+                  recordedEvents.put(ChangeDeletedEvent.TYPE, (ChangeDeletedEvent) e);
                 } else if (e instanceof RefEvent) {
                   RefEvent event = (RefEvent) e;
                   String key =
@@ -105,7 +110,8 @@
     return events;
   }
 
-  private ImmutableList<ChangeMergedEvent> getChangeMergedEvents(
+  @VisibleForTesting
+  public ImmutableList<ChangeMergedEvent> getChangeMergedEvents(
       String project, String branch, int expectedSize) {
     String key = refEventKey(ChangeMergedEvent.TYPE, project, branch);
     if (expectedSize == 0) {
@@ -137,6 +143,25 @@
     return events;
   }
 
+  private ImmutableList<ChangeDeletedEvent> getChangeDeletedEvents(int expectedSize) {
+    String key = ChangeDeletedEvent.TYPE;
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ChangeDeletedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(ChangeDeletedEvent.class::cast)
+            .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  public void assertNoRefUpdatedEvents(String project, String branch) throws Exception {
+    getRefUpdatedEvents(project, branch, 0);
+  }
+
   public void assertRefUpdatedEvents(String project, String branch, String... expected)
       throws Exception {
     ImmutableList<RefUpdatedEvent> events =
@@ -192,6 +217,18 @@
     }
   }
 
+  public void assertChangeDeletedEvents(String... expected) {
+    ImmutableList<ChangeDeletedEvent> events = getChangeDeletedEvents(expected.length / 2);
+    int i = 0;
+    for (ChangeDeletedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      String reviewer = event.deleter.get().email;
+      assertThat(reviewer).isEqualTo(expected[i + 1]);
+      i += 2;
+    }
+  }
+
   public void close() {
     eventListenerRegistration.remove();
   }
diff --git a/java/com/google/gerrit/acceptance/FakeGroupAuditService.java b/java/com/google/gerrit/acceptance/FakeGroupAuditService.java
new file mode 100644
index 0000000..48dc408
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/FakeGroupAuditService.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.util.Comparator.comparing;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.httpd.GitOverHttpServlet;
+import com.google.gerrit.server.AuditEvent;
+import com.google.gerrit.server.audit.AuditListener;
+import com.google.gerrit.server.audit.AuditService;
+import com.google.gerrit.server.audit.HttpAuditEvent;
+import com.google.gerrit.server.audit.group.GroupAuditListener;
+import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Singleton
+public class FakeGroupAuditService extends AuditService {
+  public static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.setOf(binder(), GroupAuditListener.class);
+      DynamicSet.setOf(binder(), AuditListener.class);
+
+      // Use this fake service at the Guice level rather than depending on tests binding their own
+      // audit listeners. If we used per-test listeners, then there would be a race between
+      // dispatching the audit events from HTTP requests performed during test setup in
+      // AbstractDaemonTest, and the later test setup binding the audit listener. Using a separate
+      // audit service implementation ensures all events get recorded.
+      bind(GroupAuditService.class).to(FakeGroupAuditService.class);
+    }
+  }
+
+  private final GitOverHttpServlet.Metrics httpMetrics;
+  private final BlockingQueue<HttpAuditEvent> httpEvents;
+  private final AtomicLong drainedSoFar;
+
+  @Inject
+  FakeGroupAuditService(
+      PluginSetContext<AuditListener> auditListeners,
+      PluginSetContext<GroupAuditListener> groupAuditListeners,
+      GitOverHttpServlet.Metrics httpMetrics) {
+    super(auditListeners, groupAuditListeners);
+    this.httpMetrics = httpMetrics;
+    this.httpEvents = new LinkedBlockingQueue<>();
+    this.drainedSoFar = new AtomicLong();
+  }
+
+  @Override
+  public void dispatch(AuditEvent action) {
+    super.dispatch(action);
+    if (action instanceof HttpAuditEvent) {
+      httpEvents.add((HttpAuditEvent) action);
+    }
+  }
+
+  public ImmutableList<HttpAuditEvent> drainHttpAuditEvents() throws Exception {
+    // Assumes that all HttpAuditEvents are produced by GitOverHttpServlet.
+    int expectedSize = Ints.checkedCast(httpMetrics.getRequestsStarted() - drainedSoFar.get());
+    List<HttpAuditEvent> result = new ArrayList<>();
+    for (int i = 0; i < expectedSize; i++) {
+      HttpAuditEvent e = httpEvents.poll(30, SECONDS);
+      if (e == null) {
+        throw new AssertionError(
+            String.format("Timeout after receiving %d/%d audit events", i, expectedSize));
+      }
+      drainedSoFar.incrementAndGet();
+      result.add(e);
+    }
+    return ImmutableList.sortedCopyOf(comparing(e -> e.when), result);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/GcAssert.java b/java/com/google/gerrit/acceptance/GcAssert.java
index 7f90c3a..b9ef629 100644
--- a/java/com/google/gerrit/acceptance/GcAssert.java
+++ b/java/com/google/gerrit/acceptance/GcAssert.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import java.io.File;
-import java.io.FilenameFilter;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
@@ -53,13 +52,7 @@
   private String[] getPackFiles(Project.NameKey p) throws RepositoryNotFoundException, IOException {
     try (Repository repo = repoManager.openRepository(p)) {
       File packDir = new File(repo.getDirectory(), "objects/pack");
-      return packDir.list(
-          new FilenameFilter() {
-            @Override
-            public boolean accept(File dir, String name) {
-              return name.endsWith(".pack");
-            }
-          });
+      return packDir.list((dir, name) -> name.endsWith(".pack"));
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 6e5424c..a48a278 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -15,15 +15,24 @@
 package com.google.gerrit.acceptance;
 
 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 java.lang.annotation.RetentionPolicy.RUNTIME;
+import static java.util.Objects.requireNonNull;
+import static org.apache.log4j.Logger.getLogger;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 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.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperationsImpl;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -33,23 +42,22 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
+import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.ssh.NoSshModule;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.gerrit.server.util.SystemLog;
 import com.google.gerrit.testing.FakeEmailSender;
-import com.google.gerrit.testing.InMemoryDatabase;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.NoteDbChecker;
-import com.google.gerrit.testing.NoteDbMode;
 import com.google.gerrit.testing.SshMode;
-import com.google.gerrit.testing.TempFileUtil;
 import com.google.inject.AbstractModule;
+import com.google.inject.BindingAnnotation;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
 import java.lang.annotation.Annotation;
+import java.lang.annotation.Retention;
 import java.lang.reflect.Field;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
@@ -66,11 +74,15 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
+import org.apache.log4j.ConsoleAppender;
 import org.apache.log4j.Level;
+import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
+import org.apache.log4j.PatternLayout;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.RepositoryCache;
 import org.eclipse.jgit.util.FS;
+import org.junit.rules.TemporaryFolder;
 
 public class GerritServer implements AutoCloseable {
   public static class StartupException extends Exception {
@@ -81,6 +93,11 @@
     }
   }
 
+  /** Marker on {@link InetSocketAddress} for test SSH server. */
+  @Retention(RUNTIME)
+  @BindingAnnotation
+  public @interface TestSshServerAddress {}
+
   @AutoValue
   public abstract static class Description {
     public static Description forTestClass(
@@ -91,11 +108,13 @@
           !has(UseLocalDisk.class, testDesc.getTestClass()) && !forceLocalDisk(),
           !has(NoHttpd.class, testDesc.getTestClass()),
           has(Sandboxed.class, testDesc.getTestClass()),
+          has(SkipProjectClone.class, testDesc.getTestClass()),
           has(UseSsh.class, testDesc.getTestClass()),
           null, // @GerritConfig is only valid on methods.
           null, // @GerritConfigs is only valid on methods.
           null, // @GlobalPluginConfig is only valid on methods.
-          null); // @GlobalPluginConfigs is only valid on methods.
+          null, // @GlobalPluginConfigs is only valid on methods.
+          getLogLevelThresholdAnnotation(testDesc));
     }
 
     public static Description forTestMethod(
@@ -110,12 +129,15 @@
               && !has(NoHttpd.class, testDesc.getTestClass()),
           testDesc.getAnnotation(Sandboxed.class) != null
               || has(Sandboxed.class, testDesc.getTestClass()),
+          testDesc.getAnnotation(SkipProjectClone.class) != null
+              || has(SkipProjectClone.class, testDesc.getTestClass()),
           testDesc.getAnnotation(UseSsh.class) != null
               || has(UseSsh.class, testDesc.getTestClass()),
           testDesc.getAnnotation(GerritConfig.class),
           testDesc.getAnnotation(GerritConfigs.class),
           testDesc.getAnnotation(GlobalPluginConfig.class),
-          testDesc.getAnnotation(GlobalPluginConfigs.class));
+          testDesc.getAnnotation(GlobalPluginConfigs.class),
+          getLogLevelThresholdAnnotation(testDesc));
     }
 
     private static boolean has(Class<? extends Annotation> annotation, Class<?> clazz) {
@@ -127,6 +149,14 @@
       return false;
     }
 
+    private static Level getLogLevelThresholdAnnotation(org.junit.runner.Description testDesc) {
+      LogThreshold logLevelThreshold = testDesc.getTestClass().getAnnotation(LogThreshold.class);
+      if (logLevelThreshold == null) {
+        return Level.DEBUG;
+      }
+      return Level.toLevel(logLevelThreshold.level());
+    }
+
     abstract org.junit.runner.Description testDescription();
 
     @Nullable
@@ -138,6 +168,8 @@
 
     abstract boolean sandboxed();
 
+    abstract boolean skipProjectClone();
+
     abstract boolean useSshAnnotation();
 
     boolean useSsh() {
@@ -156,6 +188,8 @@
     @Nullable
     abstract GlobalPluginConfigs pluginConfigs();
 
+    abstract Level logLevelThreshold();
+
     private void checkValidAnnotations() {
       if (configs() != null && config() != null) {
         throw new IllegalStateException("Use either @GerritConfigs or @GerritConfig not both");
@@ -189,6 +223,44 @@
     }
   }
 
+  private static final ImmutableMap<String, Level> LOG_LEVELS =
+      ImmutableMap.<String, Level>builder()
+          .put("com.google.gerrit", Level.DEBUG)
+
+          // Silence non-critical messages from MINA SSHD.
+          .put("org.apache.mina", Level.WARN)
+          .put("org.apache.sshd.common", Level.WARN)
+          .put("org.apache.sshd.server", Level.WARN)
+          .put("org.apache.sshd.common.keyprovider.FileKeyPairProvider", Level.INFO)
+          .put("com.google.gerrit.sshd.GerritServerSession", Level.WARN)
+
+          // Silence non-critical messages from mime-util.
+          .put("eu.medsea.mimeutil", Level.WARN)
+
+          // Silence non-critical messages from openid4java.
+          .put("org.apache.xml", Level.WARN)
+          .put("org.openid4java", Level.WARN)
+          .put("org.openid4java.consumer.ConsumerManager", Level.FATAL)
+          .put("org.openid4java.discovery.Discovery", Level.ERROR)
+          .put("org.openid4java.server.RealmVerifier", Level.ERROR)
+          .put("org.openid4java.message.AuthSuccess", Level.ERROR)
+
+          // Silence non-critical messages from c3p0 (if used).
+          .put("com.mchange.v2.c3p0", Level.WARN)
+          .put("com.mchange.v2.resourcepool", Level.WARN)
+          .put("com.mchange.v2.sql", Level.WARN)
+
+          // Silence non-critical messages from apache.http.
+          .put("org.apache.http", Level.WARN)
+
+          // Silence non-critical messages from Jetty.
+          .put("org.eclipse.jetty", Level.WARN)
+
+          // Silence non-critical messages from JGit.
+          .put("org.eclipse.jgit.transport.PacketLineIn", Level.WARN)
+          .put("org.eclipse.jgit.transport.PacketLineOut", Level.WARN)
+          .build();
+
   private static boolean forceLocalDisk() {
     String value = Strings.nullToEmpty(System.getenv("GERRIT_FORCE_LOCAL_DISK"));
     if (value.isEmpty()) {
@@ -249,11 +321,11 @@
   /**
    * Initializes new Gerrit site and returns started server.
    *
-   * <p>A new temporary directory for the site will be created with {@link TempFileUtil}, even in
+   * <p>A new temporary directory for the site will be created with {@code temporaryFolder}, even in
    * the server is otherwise configured in-memory. Closing the server stops the daemon but does not
-   * delete the temporary directory. Callers may either get the directory with {@link
-   * #getSitePath()} and delete it manually, or call {@link TempFileUtil#cleanup()}.
+   * delete the temporary directory..
    *
+   * @param temporaryFolder helper rule for creating site directories.
    * @param desc server description.
    * @param baseConfig default config values; merged with config from {@code desc}.
    * @param testSysModule additional Guice module to use.
@@ -261,18 +333,18 @@
    * @throws Exception
    */
   public static GerritServer initAndStart(
-      Description desc, Config baseConfig, @Nullable Module testSysModule) throws Exception {
-    Path site = TempFileUtil.createTempDirectory().toPath();
-    baseConfig = new Config(baseConfig);
-    baseConfig.setString("gerrit", null, "basePath", site.resolve("git").toString());
-    baseConfig.setString("gerrit", null, "tempSiteDir", site.toString());
+      TemporaryFolder temporaryFolder,
+      Description desc,
+      Config baseConfig,
+      @Nullable Module testSysModule)
+      throws Exception {
+    Path site = temporaryFolder.newFolder().toPath();
     try {
       if (!desc.memory()) {
         init(desc, baseConfig, site);
       }
-      return start(desc, baseConfig, site, testSysModule, null, null);
+      return start(desc, baseConfig, site, testSysModule, null);
     } catch (Exception e) {
-      TempFileUtil.recursivelyDelete(site.toFile());
       throw e;
     }
   }
@@ -289,8 +361,6 @@
    * @param testSysModule optional additional module to add to the system injector.
    * @param inMemoryRepoManager {@link InMemoryRepositoryManager} that should be used if the site is
    *     started in memory
-   * @param inMemoryDatabaseInstance {@link com.google.gerrit.testing.InMemoryDatabase.Instance}
-   *     that should be used if the site is started in memory
    * @param additionalArgs additional command-line arguments for the daemon program; only allowed if
    *     the test is not in-memory.
    * @return started server.
@@ -302,12 +372,11 @@
       Path site,
       @Nullable Module testSysModule,
       @Nullable InMemoryRepositoryManager inMemoryRepoManager,
-      @Nullable InMemoryDatabase.Instance inMemoryDatabaseInstance,
       String... additionalArgs)
       throws Exception {
     checkArgument(site != null, "site is required (even for in-memory server");
     desc.checkValidAnnotations();
-    Logger.getLogger("com.google.gerrit").setLevel(Level.DEBUG);
+    configureLogging(desc.logLevelThreshold());
     CyclicBarrier serverStarted = new CyclicBarrier(2);
     Daemon daemon =
         new Daemon(
@@ -320,14 +389,15 @@
             },
             site);
     daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
-    daemon.setAdditionalSysModuleForTesting(testSysModule);
+    daemon.setAuditEventModuleForTesting(new FakeGroupAuditService.Module());
+    if (testSysModule != null) {
+      daemon.addAdditionalSysModuleForTesting(testSysModule);
+    }
     daemon.setEnableSshd(desc.useSsh());
-    daemon.setSlave(isSlave(baseConfig));
 
     if (desc.memory()) {
       checkArgument(additionalArgs.length == 0, "cannot pass args to in-memory server");
-      return startInMemory(
-          desc, site, baseConfig, daemon, inMemoryRepoManager, inMemoryDatabaseInstance);
+      return startInMemory(desc, site, baseConfig, daemon, inMemoryRepoManager);
     }
     return startOnDisk(desc, site, daemon, serverStarted, additionalArgs);
   }
@@ -337,10 +407,10 @@
       Path site,
       Config baseConfig,
       Daemon daemon,
-      @Nullable InMemoryRepositoryManager inMemoryRepoManager,
-      @Nullable InMemoryDatabase.Instance inMemoryDatabaseInstance)
+      @Nullable InMemoryRepositoryManager inMemoryRepoManager)
       throws Exception {
     Config cfg = desc.buildConfig(baseConfig);
+    daemon.setSlave(isSlave(baseConfig) || cfg.getBoolean("container", "slave", false));
     mergeTestConfig(cfg);
     // Set the log4j configuration to an invalid one to prevent system logs
     // from getting configured and creating log files.
@@ -348,19 +418,23 @@
     cfg.setBoolean("httpd", null, "requestLog", false);
     cfg.setBoolean("sshd", null, "requestLog", false);
     cfg.setBoolean("index", "lucene", "testInmemory", true);
+    cfg.setBoolean("index", null, "onlineUpgrade", false);
     cfg.setString("gitweb", null, "cgi", "");
+    cfg.setString(
+        "accountPatchReviewDb", null, "url", JdbcAccountPatchReviewStore.TEST_IN_MEMORY_URL);
     daemon.setEnableHttpd(desc.httpd());
     daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0, isSlave(baseConfig)));
     daemon.setDatabaseForTesting(
-        ImmutableList.<Module>of(
-            new InMemoryTestingDatabaseModule(
-                cfg, site, inMemoryRepoManager, inMemoryDatabaseInstance),
+        ImmutableList.of(
+            new InMemoryTestingDatabaseModule(cfg, site, inMemoryRepoManager),
             new AbstractModule() {
               @Override
               protected void configure() {
                 bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
               }
             }));
+    daemon.addAdditionalSysModuleForTesting(
+        new ReindexProjectsAtStartup.Module(), new ReindexGroupsAtStartup.Module());
     daemon.start();
     return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
   }
@@ -376,7 +450,7 @@
       CyclicBarrier serverStarted,
       String[] additionalArgs)
       throws Exception {
-    checkNotNull(site);
+    requireNonNull(site);
     ExecutorService daemonService = Executors.newSingleThreadExecutor();
     String[] args =
         Stream.concat(
@@ -406,6 +480,25 @@
     return new GerritServer(desc, site, createTestInjector(daemon), daemon, daemonService);
   }
 
+  private static void configureLogging(Level threshold) {
+    LogManager.resetConfiguration();
+
+    PatternLayout layout = new PatternLayout();
+    layout.setConversionPattern("%-5p %c %x: %m%n");
+
+    ConsoleAppender dst = new ConsoleAppender();
+    dst.setLayout(layout);
+    dst.setTarget("System.err");
+    dst.setThreshold(threshold);
+    dst.activateOptions();
+
+    Logger root = LogManager.getRootLogger();
+    root.removeAllAppenders();
+    root.addAppender(dst);
+
+    LOG_LEVELS.entrySet().stream().forEach(e -> getLogger(e.getKey()).setLevel(e.getValue()));
+  }
+
   private static void mergeTestConfig(Config cfg) {
     String forceEphemeralPort = String.format("%s:0", getLocalHost().getHostName());
     String url = "http://" + forceEphemeralPort + "/";
@@ -428,12 +521,10 @@
     cfg.setInt("receive", null, "threadPoolSize", 1);
     cfg.setInt("index", null, "threads", 1);
     cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
-
-    NoteDbMode.newNotesMigrationFromEnv().setConfigValues(cfg);
   }
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
-    Injector sysInjector = get(daemon, "sysInjector");
+    Injector sysInjector = getInjector(daemon, "sysInjector");
     Module module =
         new FactoryModule() {
           @Override
@@ -441,23 +532,39 @@
             bindConstant().annotatedWith(SshEnabled.class).to(daemon.getEnableSshd());
             bind(AccountCreator.class);
             bind(AccountOperations.class).to(AccountOperationsImpl.class);
+            bind(GroupOperations.class).to(GroupOperationsImpl.class);
+            bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
+            bind(RequestScopeOperations.class).to(RequestScopeOperationsImpl.class);
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
             install(new NoSshModule());
             install(new AsyncReceiveCommits.Module());
             factory(ProjectResetter.Builder.Factory.class);
           }
+
+          @Provides
+          @Singleton
+          @Nullable
+          @TestSshServerAddress
+          InetSocketAddress getSshAddress(@GerritServerConfig Config cfg) {
+            String addr = cfg.getString("sshd", null, "listenAddress");
+            // We do not use InitSshd.isOff to avoid coupling GerritServer to the SSH code.
+            return !"off".equalsIgnoreCase(addr)
+                ? SocketUtil.resolve(cfg.getString("sshd", null, "listenAddress"), 0)
+                : null;
+          }
         };
     return sysInjector.createChildInjector(module);
   }
 
-  @SuppressWarnings("unchecked")
-  private static <T> T get(Object obj, String field)
+  private static Injector getInjector(Object obj, String field)
       throws SecurityException, NoSuchFieldException, IllegalArgumentException,
           IllegalAccessException {
     Field f = obj.getClass().getDeclaredField(field);
     f.setAccessible(true);
-    return (T) f.get(obj);
+    Object v = f.get(obj);
+    checkArgument(v instanceof Injector, "not an Injector: %s", v);
+    return (Injector) f.get(obj);
   }
 
   private static InetAddress getLocalHost() {
@@ -471,7 +578,6 @@
   private ExecutorService daemonService;
   private Injector testInjector;
   private String url;
-  private InetSocketAddress sshdAddress;
   private InetSocketAddress httpAddress;
 
   private GerritServer(
@@ -480,21 +586,15 @@
       Injector testInjector,
       Daemon daemon,
       @Nullable ExecutorService daemonService) {
-    this.desc = checkNotNull(desc);
+    this.desc = requireNonNull(desc);
     this.sitePath = sitePath;
-    this.testInjector = checkNotNull(testInjector);
-    this.daemon = checkNotNull(daemon);
+    this.testInjector = requireNonNull(testInjector);
+    this.daemon = requireNonNull(daemon);
     this.daemonService = daemonService;
 
     Config cfg = testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     url = cfg.getString("gerrit", null, "canonicalWebUrl");
     URI uri = URI.create(url);
-
-    String addr = cfg.getString("sshd", null, "listenAddress");
-    // We do not use InitSshd.isOff to avoid coupling GerritServer to the SSH code.
-    if (!"off".equalsIgnoreCase(addr)) {
-      sshdAddress = SocketUtil.resolve(cfg.getString("sshd", null, "listenAddress"), 0);
-    }
     httpAddress = new InetSocketAddress(uri.getHost(), uri.getPort());
   }
 
@@ -502,10 +602,6 @@
     return url;
   }
 
-  InetSocketAddress getSshdAddress() {
-    return sshdAddress;
-  }
-
   InetSocketAddress getHttpAddress() {
     return httpAddress;
   }
@@ -531,21 +627,9 @@
       inMemoryRepoManager = server.testInjector.getInstance(InMemoryRepositoryManager.class);
     }
 
-    InMemoryDatabase.Instance dbInstance = null;
-    if (hasBinding(server.testInjector, InMemoryDatabase.class)) {
-      InMemoryDatabase inMemoryDatabase = server.testInjector.getInstance(InMemoryDatabase.class);
-      dbInstance = inMemoryDatabase.getDbInstance();
-      dbInstance.setKeepOpen(true);
-    }
-    try {
-      server.close();
-      server.daemon.stop();
-      return start(server.desc, cfg, site, null, inMemoryRepoManager, dbInstance);
-    } finally {
-      if (dbInstance != null) {
-        dbInstance.setKeepOpen(false);
-      }
-    }
+    server.close();
+    server.daemon.stop();
+    return start(server.desc, cfg, site, null, inMemoryRepoManager);
   }
 
   private static boolean hasBinding(Injector injector, Class<?> clazz) {
@@ -554,40 +638,19 @@
 
   @Override
   public void close() throws Exception {
-    try {
-      checkNoteDbState();
-    } finally {
-      daemon.getLifecycleManager().stop();
-      if (daemonService != null) {
-        System.out.println("Gerrit Server Shutdown");
-        daemonService.shutdownNow();
-        daemonService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
-      }
-      RepositoryCache.clear();
+    daemon.getLifecycleManager().stop();
+    if (daemonService != null) {
+      System.out.println("Gerrit Server Shutdown");
+      daemonService.shutdownNow();
+      daemonService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
     }
+    RepositoryCache.clear();
   }
 
   public Path getSitePath() {
     return sitePath;
   }
 
-  private void checkNoteDbState() throws Exception {
-    NoteDbMode mode = NoteDbMode.get();
-    if (mode != NoteDbMode.CHECK && mode != NoteDbMode.PRIMARY) {
-      return;
-    }
-    NoteDbChecker checker = testInjector.getInstance(NoteDbChecker.class);
-    OneOffRequestContext oneOffRequestContext =
-        testInjector.getInstance(OneOffRequestContext.class);
-    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      if (mode == NoteDbMode.CHECK) {
-        checker.rebuildAndCheckAllChanges();
-      } else if (mode == NoteDbMode.PRIMARY) {
-        checker.assertNoReviewDbChanges(desc.testDescription());
-      }
-    }
-  }
-
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this).addValue(desc).toString();
diff --git a/java/com/google/gerrit/acceptance/GitUtil.java b/java/com/google/gerrit/acceptance/GitUtil.java
index cdfdae7..bb1c123 100644
--- a/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/java/com/google/gerrit/acceptance/GitUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
@@ -109,19 +110,14 @@
       throws Exception {
     DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get());
 
-    FS fs = FS.detect();
-
-    // Avoid leaking user state into our tests.
-    fs.setUserHome(null);
-
-    InMemoryRepository dest =
-        new InMemoryRepository.Builder()
-            .setRepositoryDescription(desc)
-            // SshTransport depends on a real FS to read ~/.ssh/config, but
-            // InMemoryRepository by default uses a null FS.
-            // TODO(dborowitz): Remove when we no longer depend on SSH.
-            .setFS(fs)
-            .build();
+    InMemoryRepository.Builder b = new InMemoryRepository.Builder().setRepositoryDescription(desc);
+    if (uri.startsWith("ssh://")) {
+      // SshTransport depends on a real FS to read ~/.ssh/config, but InMemoryRepository by default
+      // uses a null FS.
+      // Avoid leaking user state into our tests.
+      b.setFS(FS.detect().setUserHome(null));
+    }
+    InMemoryRepository dest = b.build();
     Config cfg = dest.getConfig();
     cfg.setString("remote", "origin", "url", uri);
     cfg.setString("remote", "origin", "fetch", "+refs/heads/*:refs/remotes/origin/*");
@@ -134,11 +130,6 @@
     return testRepo;
   }
 
-  public static TestRepository<InMemoryRepository> cloneProject(
-      Project.NameKey project, SshSession sshSession) throws Exception {
-    return cloneProject(project, sshSession.getUrl() + "/" + project.get());
-  }
-
   public static Ref createAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
       throws GitAPIException {
     TagCommand cmd =
@@ -209,13 +200,13 @@
 
   public static void assertPushOk(PushResult result, String ref) {
     RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus()).named(rru.toString()).isEqualTo(RemoteRefUpdate.Status.OK);
+    assertWithMessage(rru.toString()).that(rru.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
   }
 
   public static void assertPushRejected(PushResult result, String ref, String expectedMessage) {
     RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus())
-        .named(rru.toString())
+    assertWithMessage(rru.toString())
+        .that(rru.getStatus())
         .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
     assertThat(rru.getMessage()).isEqualTo(expectedMessage);
   }
diff --git a/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java
index b62e932..88079a4 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 com.google.common.base.Preconditions;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+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,14 +64,20 @@
     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.");
+    requireNonNull(response, "Response is not initialized.");
     return response.getEntity() != null;
   }
 
   public String getEntityContent() throws IOException {
-    Preconditions.checkNotNull(response, "Response is not initialized.");
-    Preconditions.checkNotNull(response.getEntity(), "Response.Entity is not initialized.");
+    requireNonNull(response, "Response is not initialized.");
+    requireNonNull(response.getEntity(), "Response.Entity is not initialized.");
     ByteBuffer buf = IO.readWholeStream(response.getEntity().getContent(), 1024);
     return RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()).trim();
   }
diff --git a/java/com/google/gerrit/acceptance/HttpSession.java b/java/com/google/gerrit/acceptance/HttpSession.java
index fe446f4..833c53b 100644
--- a/java/com/google/gerrit/acceptance/HttpSession.java
+++ b/java/com/google/gerrit/acceptance/HttpSession.java
@@ -19,8 +19,10 @@
 import java.io.IOException;
 import java.net.URI;
 import org.apache.http.HttpHost;
+import org.apache.http.client.HttpClient;
 import org.apache.http.client.fluent.Executor;
 import org.apache.http.client.fluent.Request;
+import org.apache.http.impl.client.HttpClientBuilder;
 
 public class HttpSession {
   protected TestAccount account;
@@ -30,11 +32,12 @@
   public HttpSession(GerritServer server, @Nullable TestAccount account) {
     this.url = CharMatcher.is('/').trimTrailingFrom(server.getUrl());
     URI uri = URI.create(url);
-    this.executor = Executor.newInstance();
+    HttpClient noRedirectClient = HttpClientBuilder.create().disableRedirectHandling().build();
+    this.executor = Executor.newInstance(noRedirectClient);
     this.account = account;
     if (account != null) {
       executor.auth(
-          new HttpHost(uri.getHost(), uri.getPort()), account.username, account.httpPassword);
+          new HttpHost(uri.getHost(), uri.getPort()), account.username(), account.httpPassword());
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index 83a3874..a3207e2 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -17,68 +17,44 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
-import com.google.gerrit.server.schema.ReviewDbFactory;
+import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.schema.SchemaModule;
-import com.google.gerrit.server.schema.SchemaVersion;
-import com.google.gerrit.testing.InMemoryDatabase;
-import com.google.gerrit.testing.InMemoryH2Type;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
-import com.google.inject.Key;
-import com.google.inject.Provides;
 import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import org.apache.sshd.common.keyprovider.KeyPairProvider;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
 class InMemoryTestingDatabaseModule extends LifecycleModule {
   private final Config cfg;
   private final Path sitePath;
   @Nullable private final InMemoryRepositoryManager repoManager;
-  @Nullable private final InMemoryDatabase.Instance inMemoryDatabaseInstance;
 
   InMemoryTestingDatabaseModule(
-      Config cfg,
-      Path sitePath,
-      @Nullable InMemoryRepositoryManager repoManager,
-      @Nullable InMemoryDatabase.Instance inMemoryDatabaseInstance) {
+      Config cfg, Path sitePath, @Nullable InMemoryRepositoryManager repoManager) {
     this.cfg = cfg;
     this.sitePath = sitePath;
     this.repoManager = repoManager;
-    this.inMemoryDatabaseInstance = inMemoryDatabaseInstance;
     makeSiteDirs(sitePath);
   }
 
   @Override
   protected void configure() {
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
-
-    // TODO(dborowitz): Use jimfs.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
 
     if (repoManager != null) {
@@ -89,73 +65,43 @@
     }
 
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
-    bind(DataSourceType.class).to(InMemoryH2Type.class);
 
-    install(new NotesMigration.Module());
-    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
-        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
-    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
-    bind(InMemoryDatabase.Instance.class).toProvider(Providers.of(inMemoryDatabaseInstance));
-    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
-    bind(InMemoryDatabase.class).in(SINGLETON);
-    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
-
-    listener().to(CreateDatabase.class);
+    listener().to(CreateSchema.class);
 
     bind(SitePaths.class);
     bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
 
     install(new SchemaModule());
-    bind(SchemaVersion.class).to(SchemaVersion.C);
+
+    install(new SshdModule());
   }
 
-  @Provides
-  @Singleton
-  KeyPairProvider createHostKey() {
-    return getHostKeys();
-  }
-
-  private static SimpleGeneratorHostKeyProvider keys;
-
-  private static synchronized KeyPairProvider getHostKeys() {
-    if (keys == null) {
-      keys = new SimpleGeneratorHostKeyProvider();
-      keys.setAlgorithm("RSA");
-      keys.loadKeys();
-    }
-    return keys;
-  }
-
-  static class CreateDatabase implements LifecycleListener {
-    private final InMemoryDatabase mem;
+  static class CreateSchema implements LifecycleListener {
+    private final SchemaCreator schemaCreator;
 
     @Inject
-    CreateDatabase(InMemoryDatabase mem) {
-      this.mem = mem;
+    CreateSchema(SchemaCreator schemaCreator) {
+      this.schemaCreator = schemaCreator;
     }
 
     @Override
     public void start() {
       try {
-        mem.create();
-      } catch (OrmException e) {
-        throw new OrmRuntimeException(e);
+        schemaCreator.ensureCreated();
+      } catch (IOException | ConfigInvalidException e) {
+        throw new StorageException(e);
       }
     }
 
     @Override
-    public void stop() {
-      mem.getDbInstance().drop();
-    }
+    public void stop() {}
   }
 
   private static void makeSiteDirs(Path p) {
     try {
       Files.createDirectories(p.resolve("etc"));
     } catch (IOException e) {
-      ProvisionException pe = new ProvisionException(e.getMessage());
-      pe.initCause(e);
-      throw pe;
+      throw new ProvisionException(e.getMessage(), e);
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 7e2796a..d0ed673 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.acceptance;
 
-import com.google.common.collect.ImmutableSetMultimap;
+import static com.google.gerrit.server.git.receive.LazyPostReceiveHookChain.affectsSize;
+import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP;
+
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.InProcessProtocol.Context;
 import com.google.gerrit.common.data.Capable;
@@ -22,14 +25,12 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.RequestCleanup;
 import com.google.gerrit.server.config.GerritRequestModule;
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TransferConfig;
@@ -40,13 +41,16 @@
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.quota.QuotaBackend;
+import com.google.gerrit.server.quota.QuotaException;
+import com.google.gerrit.server.quota.QuotaResponse;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestScopePropagator;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Key;
@@ -56,7 +60,6 @@
 import com.google.inject.Provides;
 import com.google.inject.Scope;
 import com.google.inject.servlet.RequestScoped;
-import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.net.SocketAddress;
 import java.util.HashMap;
@@ -87,8 +90,6 @@
       @Provides
       @RemotePeer
       SocketAddress getSocketAddress() {
-        // TODO(dborowitz): Could potentially fake this with thread ID or
-        // something.
         throw new OutOfScopeException("No remote peer in acceptance tests");
       }
     };
@@ -123,10 +124,8 @@
 
   private static class Propagator extends ThreadLocalRequestScopePropagator<Context> {
     @Inject
-    Propagator(
-        ThreadLocalRequestContext local,
-        Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
-      super(REQUEST, current, local, dbProviderProvider);
+    Propagator(ThreadLocalRequestContext local) {
+      super(REQUEST, current, local);
     }
 
     @Override
@@ -151,12 +150,9 @@
    * request.
    */
   static class Context implements RequestContext {
-    private static final Key<RequestScopedReviewDbProvider> DB_KEY =
-        Key.get(RequestScopedReviewDbProvider.class);
     private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
     private static final Key<CurrentUser> USER_KEY = Key.get(CurrentUser.class);
 
-    private final SchemaFactory<ReviewDb> schemaFactory;
     private final IdentifiedUser.GenericFactory userFactory;
     private final Account.Id accountId;
     private final Project.NameKey project;
@@ -164,17 +160,12 @@
     private final Map<Key<?>, Object> map;
 
     Context(
-        SchemaFactory<ReviewDb> schemaFactory,
-        IdentifiedUser.GenericFactory userFactory,
-        Account.Id accountId,
-        Project.NameKey project) {
-      this.schemaFactory = schemaFactory;
+        IdentifiedUser.GenericFactory userFactory, Account.Id accountId, Project.NameKey project) {
       this.userFactory = userFactory;
       this.accountId = accountId;
       this.project = project;
       map = new HashMap<>();
       cleanup = new RequestCleanup();
-      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
       map.put(RC_KEY, cleanup);
 
       IdentifiedUser user = userFactory.create(accountId);
@@ -183,7 +174,7 @@
     }
 
     private Context newContinuingContext() {
-      return new Context(schemaFactory, userFactory, accountId, project);
+      return new Context(userFactory, accountId, project);
     }
 
     @Override
@@ -191,11 +182,6 @@
       return get(USER_KEY, null);
     }
 
-    @Override
-    public Provider<ReviewDb> getReviewDbProvider() {
-      return get(DB_KEY, null);
-    }
-
     private synchronized <T> T get(Key<T> key, Provider<T> creator) {
       @SuppressWarnings("unchecked")
       T t = (T) map.get(key);
@@ -209,7 +195,7 @@
 
   private static class Upload implements UploadPackFactory<Context> {
     private final TransferConfig transferConfig;
-    private final DynamicSet<UploadPackInitializer> uploadPackInitializers;
+    private final PluginSetContext<UploadPackInitializer> uploadPackInitializers;
     private final DynamicSet<PreUploadHook> preUploadHooks;
     private final UploadValidators.Factory uploadValidatorsFactory;
     private final ThreadLocalRequestContext threadContext;
@@ -219,7 +205,7 @@
     @Inject
     Upload(
         TransferConfig transferConfig,
-        DynamicSet<UploadPackInitializer> uploadPackInitializers,
+        PluginSetContext<UploadPackInitializer> uploadPackInitializers,
         DynamicSet<PreUploadHook> preUploadHooks,
         UploadValidators.Factory uploadValidatorsFactory,
         ThreadLocalRequestContext threadContext,
@@ -268,9 +254,7 @@
       List<PreUploadHook> hooks = Lists.newArrayList(preUploadHooks);
       hooks.add(uploadValidatorsFactory.create(projectState.getProject(), repo, "localhost-test"));
       up.setPreUploadHook(PreUploadHookChain.newChain(hooks));
-      for (UploadPackInitializer initializer : uploadPackInitializers) {
-        initializer.init(req.project, up);
-      }
+      uploadPackInitializers.runEach(initializer -> initializer.init(req.project, up));
       return up;
     }
   }
@@ -280,10 +264,11 @@
     private final ProjectCache projectCache;
     private final AsyncReceiveCommits.Factory factory;
     private final TransferConfig config;
-    private final DynamicSet<ReceivePackInitializer> receivePackInitializers;
+    private final PluginSetContext<ReceivePackInitializer> receivePackInitializers;
     private final DynamicSet<PostReceiveHook> postReceiveHooks;
     private final ThreadLocalRequestContext threadContext;
     private final PermissionBackend permissionBackend;
+    private final QuotaBackend quotaBackend;
 
     @Inject
     Receive(
@@ -291,10 +276,11 @@
         ProjectCache projectCache,
         AsyncReceiveCommits.Factory factory,
         TransferConfig config,
-        DynamicSet<ReceivePackInitializer> receivePackInitializers,
+        PluginSetContext<ReceivePackInitializer> receivePackInitializers,
         DynamicSet<PostReceiveHook> postReceiveHooks,
         ThreadLocalRequestContext threadContext,
-        PermissionBackend permissionBackend) {
+        PermissionBackend permissionBackend,
+        QuotaBackend quotaBackend) {
       this.userProvider = userProvider;
       this.projectCache = projectCache;
       this.factory = factory;
@@ -303,6 +289,7 @@
       this.postReceiveHooks = postReceiveHooks;
       this.threadContext = threadContext;
       this.permissionBackend = permissionBackend;
+      this.quotaBackend = quotaBackend;
     }
 
     @Override
@@ -330,26 +317,47 @@
           throw new RuntimeException(String.format("project %s not found", req.project));
         }
 
-        AsyncReceiveCommits arc =
-            factory.create(projectState, identifiedUser, db, null, ImmutableSetMultimap.of());
-        ReceivePack rp = arc.getReceivePack();
-
-        Capable r = arc.canUpload();
-        if (r != Capable.OK) {
+        AsyncReceiveCommits arc = factory.create(projectState, identifiedUser, db, null);
+        if (arc.canUpload() != Capable.OK) {
           throw new ServiceNotAuthorizedException();
         }
 
+        ReceivePack rp = arc.getReceivePack();
         rp.setRefLogIdent(identifiedUser.newRefLogIdent());
         rp.setTimeout(config.getTimeout());
         rp.setMaxObjectSizeLimit(config.getMaxObjectSizeLimit());
 
-        for (ReceivePackInitializer initializer : receivePackInitializers) {
-          initializer.init(projectState.getNameKey(), rp);
-        }
+        receivePackInitializers.runEach(
+            initializer -> initializer.init(projectState.getNameKey(), rp));
+        QuotaResponse.Aggregated availableTokens =
+            quotaBackend
+                .user(identifiedUser)
+                .project(req.project)
+                .availableTokens(REPOSITORY_SIZE_GROUP);
+        availableTokens.throwOnError();
+        availableTokens.availableTokens().ifPresent(v -> rp.setMaxObjectSizeLimit(v));
 
-        rp.setPostReceiveHook(PostReceiveHookChain.newChain(Lists.newArrayList(postReceiveHooks)));
+        ImmutableList<PostReceiveHook> hooks =
+            ImmutableList.<PostReceiveHook>builder()
+                .add(
+                    (pack, commands) -> {
+                      if (affectsSize(pack, commands)) {
+                        try {
+                          quotaBackend
+                              .user(identifiedUser)
+                              .project(req.project)
+                              .requestTokens(REPOSITORY_SIZE_GROUP, pack.getPackSize())
+                              .throwOnError();
+                        } catch (QuotaException e) {
+                          throw new RuntimeException(e);
+                        }
+                      }
+                    })
+                .addAll(postReceiveHooks)
+                .build();
+        rp.setPostReceiveHook(PostReceiveHookChain.newChain(hooks));
         return rp;
-      } catch (IOException | PermissionBackendException e) {
+      } catch (IOException | PermissionBackendException | QuotaException e) {
         throw new RuntimeException(e);
       }
     }
diff --git a/java/com/google/gerrit/acceptance/LogThreshold.java b/java/com/google/gerrit/acceptance/LogThreshold.java
new file mode 100644
index 0000000..36831f3
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/LogThreshold.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+@Inherited
+public @interface LogThreshold {
+  String level() default "DEBUG";
+}
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index f4a8da3..ae397a9 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.project.ProjectCache;
@@ -46,6 +46,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -214,7 +215,7 @@
     for (Map.Entry<Project.NameKey, Collection<String>> e :
         refsPatternByProject.asMap().entrySet()) {
       try (Repository repo = repoManager.openRepository(e.getKey())) {
-        Collection<Ref> refs = repo.getAllRefs().values();
+        Collection<Ref> refs = repo.getRefDatabase().getRefs();
         for (String refPattern : e.getValue()) {
           RefPatternMatcher matcher = RefPatternMatcher.getMatcher(refPattern);
           for (Ref ref : refs) {
@@ -259,9 +260,7 @@
         refsPatternByProject.asMap().entrySet()) {
       try (Repository repo = repoManager.openRepository(e.getKey())) {
         Collection<Ref> nonRestoredRefs =
-            repo.getAllRefs()
-                .values()
-                .stream()
+            repo.getRefDatabase().getRefs().stream()
                 .filter(
                     r ->
                         !keptRefsByProject.containsEntry(e.getKey(), r.getName())
@@ -314,9 +313,7 @@
 
   private Set<Project.NameKey> projectsWithConfigChanges(
       Multimap<Project.NameKey, String> projects) {
-    return projects
-        .entries()
-        .stream()
+    return projects.entries().stream()
         .filter(e -> e.getValue().equals(RefNames.REFS_CONFIG))
         .map(Map.Entry::getKey)
         .collect(toSet());
@@ -324,21 +321,20 @@
 
   /** Evict accounts that were modified. */
   private void evictAndReindexAccounts() throws IOException {
-    Set<Account.Id> deletedAccounts = accountIds(deletedRefsByProject.get(allUsersName));
+    Set<Account.Id> deletedAccounts = accountIds(deletedRefsByProject.get(allUsersName).stream());
     if (accountCreator != null) {
       accountCreator.evict(deletedAccounts);
     }
     if (accountCache != null || accountIndexer != null) {
       Set<Account.Id> modifiedAccounts =
-          new HashSet<>(accountIds(restoredRefsByProject.get(allUsersName)));
+          new HashSet<>(accountIds(restoredRefsByProject.get(allUsersName).stream()));
 
       if (restoredRefsByProject.get(allUsersName).contains(RefNames.REFS_EXTERNAL_IDS)
           || deletedRefsByProject.get(allUsersName).contains(RefNames.REFS_EXTERNAL_IDS)) {
         // The external IDs have been modified but we don't know which accounts were affected.
         // Make sure all accounts are evicted and reindexed.
         try (Repository repo = repoManager.openRepository(allUsersName)) {
-          for (Account.Id id :
-              accountIds(repo.getAllRefs().values().stream().map(Ref::getName).collect(toSet()))) {
+          for (Account.Id id : accountIds(repo)) {
             evictAndReindexAccount(id);
           }
         }
@@ -357,7 +353,7 @@
   }
 
   /** Evict groups that were modified. */
-  private void evictAndReindexGroups() throws IOException {
+  private void evictAndReindexGroups() {
     if (groupCache != null || groupIndexer != null) {
       Set<AccountGroup.UUID> modifiedGroups =
           new HashSet<>(groupUUIDs(restoredRefsByProject.get(allUsersName)));
@@ -371,7 +367,7 @@
     }
   }
 
-  private void evictAndReindexAccount(Account.Id accountId) throws IOException {
+  private void evictAndReindexAccount(Account.Id accountId) {
     if (accountCache != null) {
       accountCache.evict(accountId);
     }
@@ -383,7 +379,7 @@
     }
   }
 
-  private void evictAndReindexGroup(AccountGroup.UUID uuid) throws IOException {
+  private void evictAndReindexGroup(AccountGroup.UUID uuid) {
     if (groupCache != null) {
       groupCache.evict(uuid);
     }
@@ -397,9 +393,12 @@
     }
   }
 
-  private Set<Account.Id> accountIds(Collection<String> refs) {
-    return refs.stream()
-        .filter(r -> r.startsWith(REFS_USERS))
+  private static Set<Account.Id> accountIds(Repository repo) throws IOException {
+    return accountIds(repo.getRefDatabase().getRefsByPrefix(REFS_USERS).stream().map(Ref::getName));
+  }
+
+  private static Set<Account.Id> accountIds(Stream<String> refs) {
+    return refs.filter(r -> r.startsWith(REFS_USERS))
         .map(Account.Id::fromRef)
         .filter(Objects::nonNull)
         .collect(toSet());
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 5e45df2..3fcf895 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.assertEquals;
 
 import com.google.common.base.Strings;
@@ -28,14 +28,11 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -43,7 +40,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
-import java.util.stream.Stream;
 import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -77,16 +73,12 @@
           + PATCH_FILE_ONLY;
 
   public interface Factory {
-    PushOneCommit create(ReviewDb db, PersonIdent i, TestRepository<?> testRepo);
+    PushOneCommit create(PersonIdent i, TestRepository<?> testRepo);
 
     PushOneCommit create(
-        ReviewDb db,
-        PersonIdent i,
-        TestRepository<?> testRepo,
-        @Assisted("changeId") String changeId);
+        PersonIdent i, TestRepository<?> testRepo, @Assisted("changeId") String changeId);
 
     PushOneCommit create(
-        ReviewDb db,
         PersonIdent i,
         TestRepository<?> testRepo,
         @Assisted("subject") String subject,
@@ -94,14 +86,12 @@
         @Assisted("content") String content);
 
     PushOneCommit create(
-        ReviewDb db,
         PersonIdent i,
         TestRepository<?> testRepo,
         @Assisted String subject,
         @Assisted Map<String, String> files);
 
     PushOneCommit create(
-        ReviewDb db,
         PersonIdent i,
         TestRepository<?> testRepo,
         @Assisted("subject") String subject,
@@ -144,8 +134,6 @@
   private final ChangeNotes.Factory notesFactory;
   private final ApprovalsUtil approvalsUtil;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final NotesMigration notesMigration;
-  private final ReviewDb db;
   private final TestRepository<?> testRepo;
 
   private final String subject;
@@ -162,22 +150,10 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo)
       throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        notesMigration,
-        db,
-        i,
-        testRepo,
-        SUBJECT,
-        FILE_NAME,
-        FILE_CONTENT);
+    this(notesFactory, approvalsUtil, queryProvider, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT);
   }
 
   @AssistedInject
@@ -185,8 +161,6 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("changeId") String changeId)
@@ -195,8 +169,6 @@
         notesFactory,
         approvalsUtil,
         queryProvider,
-        notesMigration,
-        db,
         i,
         testRepo,
         SUBJECT,
@@ -210,26 +182,13 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
       @Assisted("fileName") String fileName,
       @Assisted("content") String content)
       throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        notesMigration,
-        db,
-        i,
-        testRepo,
-        subject,
-        fileName,
-        content,
-        null);
+    this(notesFactory, approvalsUtil, queryProvider, i, testRepo, subject, fileName, content, null);
   }
 
   @AssistedInject
@@ -237,24 +196,12 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted String subject,
       @Assisted Map<String, String> files)
       throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        notesMigration,
-        db,
-        i,
-        testRepo,
-        subject,
-        files,
-        null);
+    this(notesFactory, approvalsUtil, queryProvider, i, testRepo, subject, files, null);
   }
 
   @AssistedInject
@@ -262,8 +209,6 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
@@ -275,8 +220,6 @@
         notesFactory,
         approvalsUtil,
         queryProvider,
-        notesMigration,
-        db,
         i,
         testRepo,
         subject,
@@ -288,20 +231,16 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      ReviewDb db,
       PersonIdent i,
       TestRepository<?> testRepo,
       String subject,
       Map<String, String> files,
       String changeId)
       throws Exception {
-    this.db = db;
     this.testRepo = testRepo;
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.queryProvider = queryProvider;
-    this.notesMigration = notesMigration;
     this.subject = subject;
     this.files = files;
     this.changeId = changeId;
@@ -395,15 +334,15 @@
       this.resSubj = subject;
     }
 
-    public ChangeData getChange() throws OrmException {
+    public ChangeData getChange() {
       return Iterables.getOnlyElement(queryProvider.get().byKeyPrefix(changeId));
     }
 
-    public PatchSet getPatchSet() throws OrmException {
+    public PatchSet getPatchSet() {
       return getChange().currentPatchSet();
     }
 
-    public PatchSet.Id getPatchSetId() throws OrmException {
+    public PatchSet.Id getPatchSetId() {
       return getChange().change().currentPatchSetId();
     }
 
@@ -420,8 +359,7 @@
     }
 
     public void assertChange(
-        Change.Status expectedStatus, String expectedTopic, TestAccount... expectedReviewers)
-        throws OrmException {
+        Change.Status expectedStatus, String expectedTopic, TestAccount... expectedReviewers) {
       assertChange(
           expectedStatus, expectedTopic, Arrays.asList(expectedReviewers), ImmutableList.of());
     }
@@ -430,28 +368,19 @@
         Change.Status expectedStatus,
         String expectedTopic,
         List<TestAccount> expectedReviewers,
-        List<TestAccount> expectedCcs)
-        throws OrmException {
+        List<TestAccount> expectedCcs) {
       Change c = getChange().change();
       assertThat(c.getSubject()).isEqualTo(resSubj);
       assertThat(c.getStatus()).isEqualTo(expectedStatus);
       assertThat(Strings.emptyToNull(c.getTopic())).isEqualTo(expectedTopic);
-      if (notesMigration.readChanges()) {
-        assertReviewers(c, ReviewerStateInternal.REVIEWER, expectedReviewers);
-        assertReviewers(c, ReviewerStateInternal.CC, expectedCcs);
-      } else {
-        assertReviewers(
-            c,
-            ReviewerStateInternal.REVIEWER,
-            Stream.concat(expectedReviewers.stream(), expectedCcs.stream()).collect(toList()));
-      }
+      assertReviewers(c, ReviewerStateInternal.REVIEWER, expectedReviewers);
+      assertReviewers(c, ReviewerStateInternal.CC, expectedCcs);
     }
 
     private void assertReviewers(
-        Change c, ReviewerStateInternal state, List<TestAccount> expectedReviewers)
-        throws OrmException {
+        Change c, ReviewerStateInternal state, List<TestAccount> expectedReviewers) {
       Iterable<Account.Id> actualIds =
-          approvalsUtil.getReviewers(db, notesFactory.createChecked(db, c)).byState(state);
+          approvalsUtil.getReviewers(notesFactory.createChecked(c)).byState(state);
       assertThat(actualIds)
           .containsExactlyElementsIn(Sets.newHashSet(TestAccount.ids(expectedReviewers)));
     }
@@ -467,15 +396,15 @@
     public void assertErrorStatus() {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(refUpdate).isNotNull();
-      assertThat(refUpdate.getStatus())
-          .named(message(refUpdate))
+      assertWithMessage(message(refUpdate))
+          .that(refUpdate.getStatus())
           .isEqualTo(Status.REJECTED_OTHER_REASON);
     }
 
     private void assertStatus(Status expectedStatus, String expectedMessage) {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(refUpdate).isNotNull();
-      assertThat(refUpdate.getStatus()).named(message(refUpdate)).isEqualTo(expectedStatus);
+      assertWithMessage(message(refUpdate)).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
       if (expectedMessage == null) {
         assertThat(refUpdate.getMessage()).isNull();
       } else {
diff --git a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
index 7809ae0..19910db 100644
--- a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
@@ -19,19 +19,18 @@
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.query.change.ChangeData;
-import java.io.IOException;
 
-public class ReadOnlyChangeIndex implements ChangeIndex {
+class ReadOnlyChangeIndex implements ChangeIndex {
   private final ChangeIndex index;
 
-  public ReadOnlyChangeIndex(ChangeIndex index) {
+  ReadOnlyChangeIndex(ChangeIndex index) {
     this.index = index;
   }
 
-  public ChangeIndex unwrap() {
+  ChangeIndex unwrap() {
     return index;
   }
 
@@ -46,17 +45,17 @@
   }
 
   @Override
-  public void replace(ChangeData obj) throws IOException {
+  public void replace(ChangeData obj) {
     // do nothing
   }
 
   @Override
-  public void delete(Id key) throws IOException {
+  public void delete(Change.Id key) {
     // do nothing
   }
 
   @Override
-  public void deleteAll() throws IOException {
+  public void deleteAll() {
     // do nothing
   }
 
@@ -67,7 +66,7 @@
   }
 
   @Override
-  public void markReady(boolean ready) throws IOException {
+  public void markReady(boolean ready) {
     // do nothing
   }
 }
diff --git a/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
new file mode 100644
index 0000000..bd8a926
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.inject.Inject;
+import com.google.inject.Scopes;
+import java.io.IOException;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/** Reindex all groups at Gerrit daemon startup. */
+public class ReindexGroupsAtStartup implements LifecycleListener {
+  private final GroupIndexer groupIndexer;
+  private final Groups groups;
+  private final Config cfg;
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(ReindexGroupsAtStartup.class).in(Scopes.SINGLETON);
+    }
+  }
+
+  @Inject
+  public ReindexGroupsAtStartup(
+      GroupIndexer groupIndexer, Groups groups, @GerritServerConfig Config cfg) {
+    this.groupIndexer = groupIndexer;
+    this.groups = groups;
+    this.cfg = cfg;
+  }
+
+  @Override
+  public void start() {
+    // Gerrit slaves without a reindex
+    if (cfg.getBoolean("container", "slave", false)
+        && !cfg.getBoolean("index", "scheduledIndexer", "runOnStartup", true)) {
+      return;
+    }
+
+    Stream<GroupReference> allGroupReferences;
+    try {
+      allGroupReferences = groups.getAllGroupReferences();
+    } catch (ConfigInvalidException | IOException e) {
+      throw new IllegalStateException("Unable to reindex groups, tests may fail", e);
+    }
+
+    allGroupReferences.forEach(group -> groupIndexer.index(group.getUUID()));
+  }
+
+  @Override
+  public void stop() {}
+}
diff --git a/java/com/google/gerrit/acceptance/ReindexProjectsAtStartup.java b/java/com/google/gerrit/acceptance/ReindexProjectsAtStartup.java
new file mode 100644
index 0000000..2f0ffcb
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ReindexProjectsAtStartup.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.index.project.ProjectIndexer;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Scopes;
+
+/** Reindex all projects at Gerrit daemon startup. */
+public class ReindexProjectsAtStartup implements LifecycleListener {
+  private final ProjectIndexer projectIndexer;
+  private final GitRepositoryManager repoMgr;
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(ReindexProjectsAtStartup.class).in(Scopes.SINGLETON);
+    }
+  }
+
+  @Inject
+  public ReindexProjectsAtStartup(ProjectIndexer projectIndexer, GitRepositoryManager repoMgr) {
+    this.projectIndexer = projectIndexer;
+    this.repoMgr = repoMgr;
+  }
+
+  @Override
+  public void start() {
+    repoMgr.list().stream().forEach(projectIndexer::index);
+  }
+
+  @Override
+  public void stop() {}
+}
diff --git a/java/com/google/gerrit/acceptance/RestResponse.java b/java/com/google/gerrit/acceptance/RestResponse.java
index da08215..e8de5c6 100644
--- a/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/java/com/google/gerrit/acceptance/RestResponse.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.JSON_MAGIC;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -21,6 +22,7 @@
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
+import java.net.URI;
 import org.apache.http.HttpStatus;
 
 public class RestResponse extends HttpResponse {
@@ -83,4 +85,9 @@
   public void assertPreconditionFailed() throws Exception {
     assertStatus(HttpStatus.SC_PRECONDITION_FAILED);
   }
+
+  public void assertTemporaryRedirect(String path) throws Exception {
+    assertStatus(HttpStatus.SC_MOVED_TEMPORARILY);
+    assertThat(URI.create(getHeader("Location")).getPath()).isEqualTo(path);
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 300e75f..0e7ad4b 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -15,15 +15,14 @@
 package com.google.gerrit.acceptance;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
-import com.google.common.base.Preconditions;
 import com.google.common.net.HttpHeaders;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
-import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.json.OutputFormat;
 import java.io.IOException;
 import org.apache.http.Header;
-import org.apache.http.HttpStatus;
 import org.apache.http.client.fluent.Request;
 import org.apache.http.entity.BufferedHttpEntity;
 import org.apache.http.entity.InputStreamEntity;
@@ -36,16 +35,6 @@
     super(server, account);
   }
 
-  public RestResponse getOK(String endPoint) throws Exception {
-    return get(endPoint, HttpStatus.SC_OK);
-  }
-
-  public RestResponse get(String endPoint, int expectedStatus) throws Exception {
-    RestResponse r = get(endPoint);
-    r.assertStatus(expectedStatus);
-    return r;
-  }
-
   public RestResponse get(String endPoint) throws IOException {
     return getWithHeader(endPoint, null);
   }
@@ -92,7 +81,7 @@
   }
 
   public RestResponse putRaw(String endPoint, RawInput stream) throws IOException {
-    Preconditions.checkNotNull(stream);
+    requireNonNull(stream);
     Request put = Request.Put(getUrl(endPoint));
     put.addHeader(new BasicHeader("Content-Type", stream.getContentType()));
     put.body(
@@ -105,21 +94,11 @@
     return post(endPoint, null);
   }
 
-  public RestResponse postOK(String endPoint, Object content) throws Exception {
-    return post(endPoint, content, HttpStatus.SC_OK);
-  }
-
-  public RestResponse post(String endPoint, Object content, int expectedStatus) throws Exception {
-    RestResponse r = post(endPoint, content);
-    r.assertStatus(expectedStatus);
-    return r;
-  }
-
   public RestResponse post(String endPoint, Object content) throws IOException {
-    return postWithHeader(endPoint, content, null);
+    return postWithHeader(endPoint, null, content);
   }
 
-  public RestResponse postWithHeader(String endPoint, Object content, Header header)
+  public RestResponse postWithHeader(String endPoint, Header header, Object content)
       throws IOException {
     Request post = Request.Post(getUrl(endPoint));
     if (header != null) {
diff --git a/java/com/google/gerrit/acceptance/SkipProjectClone.java b/java/com/google/gerrit/acceptance/SkipProjectClone.java
new file mode 100644
index 0000000..9a326d8
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/SkipProjectClone.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+public @interface SkipProjectClone {}
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 9e515ca..fa0bc90 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -15,7 +15,11 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.jcraft.jsch.ChannelExec;
 import com.jcraft.jsch.JSch;
@@ -32,9 +36,9 @@
   private Session session;
   private String error;
 
-  public SshSession(TestSshKeys sshKeys, GerritServer server, TestAccount account) {
+  public SshSession(TestSshKeys sshKeys, InetSocketAddress addr, TestAccount account) {
     this.sshKeys = sshKeys;
-    this.addr = server.getSshdAddress();
+    this.addr = addr;
     this.account = account;
   }
 
@@ -52,10 +56,10 @@
       InputStream err = channel.getErrStream();
       channel.connect();
 
-      Scanner s = new Scanner(err).useDelimiter("\\A");
+      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
       error = s.hasNext() ? s.next() : null;
 
-      s = new Scanner(in).useDelimiter("\\A");
+      s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
       return s.hasNext() ? s.next() : "";
     } finally {
       channel.disconnect();
@@ -75,7 +79,7 @@
     return exec(command, null);
   }
 
-  public boolean hasError() {
+  private boolean hasError() {
     return error != null;
   }
 
@@ -83,6 +87,19 @@
     return error;
   }
 
+  public void assertSuccess() {
+    assertWithMessage(getError()).that(hasError()).isFalse();
+  }
+
+  public void assertFailure() {
+    assertThat(hasError()).isTrue();
+  }
+
+  public void assertFailure(String error) {
+    assertThat(hasError()).isTrue();
+    assertThat(getError()).contains(error);
+  }
+
   public void close() {
     if (session != null) {
       session.disconnect();
@@ -96,8 +113,14 @@
       JSch jsch = new JSch();
       jsch.addIdentity(
           "KeyPair", TestSshKeys.privateKey(keyPair), keyPair.getPublicKeyBlob(), null);
-      session =
-          jsch.getSession(account.username, addr.getAddress().getHostAddress(), addr.getPort());
+      String username =
+          account
+              .username()
+              .orElseThrow(
+                  () ->
+                      new IllegalStateException(
+                          "account " + account.accountId() + " must have a username to use SSH"));
+      session = jsch.getSession(username, addr.getAddress().getHostAddress(), addr.getPort());
       session.setConfig("StrictHostKeyChecking", "no");
       session.connect();
     }
diff --git a/java/com/google/gerrit/acceptance/SshdModule.java b/java/com/google/gerrit/acceptance/SshdModule.java
new file mode 100644
index 0000000..185d6e2
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/SshdModule.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+
+public class SshdModule extends AbstractModule {
+
+  @Provides
+  @Singleton
+  KeyPairProvider createHostKey() {
+    return getHostKeys();
+  }
+
+  private static SimpleGeneratorHostKeyProvider keys;
+
+  private static synchronized KeyPairProvider getHostKeys() {
+    if (keys == null) {
+      keys = new SimpleGeneratorHostKeyProvider();
+      keys.setAlgorithm("RSA");
+      keys.loadKeys();
+    }
+    return keys;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index a50de1a..eac3b0a 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.util.stream.Collectors.joining;
 import static org.junit.Assert.fail;
 
@@ -25,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -34,7 +33,6 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
 import com.google.inject.Module;
-import com.google.inject.Provider;
 import java.io.File;
 import java.util.Arrays;
 import java.util.Collections;
@@ -61,7 +59,7 @@
       this.server = server;
       Injector i = server.getTestInjector();
       if (adminId == null) {
-        adminId = i.getInstance(AccountCreator.class).admin().getId();
+        adminId = i.getInstance(AccountCreator.class).admin().id();
       }
       ctx = i.getInstance(OneOffRequestContext.class).openAs(adminId);
       GerritApi gApi = i.getInstance(GerritApi.class);
@@ -82,11 +80,6 @@
       return ctx.getUser();
     }
 
-    @Override
-    public Provider<ReviewDb> getReviewDbProvider() {
-      return ctx.getReviewDbProvider();
-    }
-
     public Injector getInjector() {
       return server.getTestInjector();
     }
@@ -107,10 +100,8 @@
   private final TemporaryFolder tempSiteDir = new TemporaryFolder();
 
   private final TestRule testRunner =
-      new TestRule() {
-        @Override
-        public Statement apply(Statement base, Description description) {
-          return new Statement() {
+      (base, description) ->
+          new Statement() {
             @Override
             public void evaluate() throws Throwable {
               try {
@@ -121,8 +112,6 @@
               }
             }
           };
-        }
-      };
 
   @Rule public RuleChain ruleChain = RuleChain.outerRule(tempSiteDir).around(testRunner);
 
@@ -209,15 +198,15 @@
   private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
       throws Exception {
     return GerritServer.start(
-        serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, null, additionalArgs);
+        serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, additionalArgs);
   }
 
   protected static void runGerrit(String... args) throws Exception {
     // Use invokeProgram with the current classloader, rather than mainImpl, which would create a
     // new classloader. This is necessary so that static state, particularly the SystemReader, is
     // shared with the test method.
-    assertThat(GerritLauncher.invokeProgram(StandaloneSiteTest.class.getClassLoader(), args))
-        .named("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
+    assertWithMessage("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
+        .that(GerritLauncher.invokeProgram(StandaloneSiteTest.class.getClassLoader(), args))
         .isEqualTo(0);
   }
 
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index 094e8b0..c937aed 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -14,61 +14,80 @@
 
 package com.google.gerrit.acceptance;
 
-import static java.util.stream.Collectors.toList;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
 import com.google.common.net.InetAddresses;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.mail.Address;
 import java.net.InetSocketAddress;
 import java.util.Arrays;
-import java.util.List;
 import org.apache.http.client.utils.URIBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 
-public class TestAccount {
-  public static List<Account.Id> ids(List<TestAccount> accounts) {
-    return accounts.stream().map(a -> a.id).collect(toList());
+@AutoValue
+public abstract class TestAccount {
+  public static ImmutableList<Account.Id> ids(Iterable<TestAccount> accounts) {
+    return Streams.stream(accounts).map(TestAccount::id).collect(toImmutableList());
   }
 
-  public static List<String> names(List<TestAccount> accounts) {
-    return accounts.stream().map(a -> a.fullName).collect(toList());
+  public static ImmutableList<String> names(Iterable<TestAccount> accounts) {
+    return Streams.stream(accounts).map(TestAccount::fullName).collect(toImmutableList());
   }
 
-  public static List<String> names(TestAccount... accounts) {
+  public static ImmutableList<String> names(TestAccount... accounts) {
     return names(Arrays.asList(accounts));
   }
 
-  public final Account.Id id;
-  public final String username;
-  public final String email;
-  public final Address emailAddress;
-  public final String fullName;
-  public final String httpPassword;
-
-  TestAccount(Account.Id id, String username, String email, String fullName, String httpPassword) {
-    this.id = id;
-    this.username = username;
-    this.email = email;
-    this.emailAddress = new Address(fullName, email);
-    this.fullName = fullName;
-    this.httpPassword = httpPassword;
+  static TestAccount create(
+      Account.Id id,
+      @Nullable String username,
+      @Nullable String email,
+      @Nullable String fullName,
+      @Nullable String httpPassword) {
+    return new AutoValue_TestAccount(id, username, email, fullName, httpPassword);
   }
 
-  public PersonIdent getIdent() {
-    return new PersonIdent(fullName, email);
+  public abstract Account.Id id();
+
+  @Nullable
+  public abstract String username();
+
+  @Nullable
+  public abstract String email();
+
+  @Nullable
+  public abstract String fullName();
+
+  @Nullable
+  public abstract String httpPassword();
+
+  public PersonIdent newIdent() {
+    return new PersonIdent(fullName(), email());
   }
 
   public String getHttpUrl(GerritServer server) {
     InetSocketAddress addr = server.getHttpAddress();
     return new URIBuilder()
         .setScheme("http")
-        .setUserInfo(username, httpPassword)
+        .setUserInfo(username(), httpPassword())
         .setHost(InetAddresses.toUriString(addr.getAddress()))
         .setPort(addr.getPort())
         .toString();
   }
 
-  public Account.Id getId() {
-    return id;
+  public Address getEmailAddress() {
+    // Address is weird enough that it's safer and clearer to create a new instance in a
+    // non-abstract method rather than, say, having an abstract emailAddress() as part of this
+    // AutoValue class. Specifically:
+    //  * Email is not specified as @Nullable in Address, but it is nullable in this class. If this
+    //    is a problem, at least it's a problem only for users of TestAccount that actually call
+    //    emailAddress().
+    //  * Address#equals only considers email, not name, whereas TestAccount#equals should include
+    //    name.
+    return new Address(fullName(), email());
   }
 }
diff --git a/java/com/google/gerrit/acceptance/TestProjectInput.java b/java/com/google/gerrit/acceptance/TestProjectInput.java
index eada6434..0a3686b 100644
--- a/java/com/google/gerrit/acceptance/TestProjectInput.java
+++ b/java/com/google/gerrit/acceptance/TestProjectInput.java
@@ -47,6 +47,10 @@
 
   InheritableBoolean rejectEmptyCommit() default InheritableBoolean.INHERIT;
 
+  InheritableBoolean enableSignedPush() default InheritableBoolean.INHERIT;
+
+  InheritableBoolean requireSignedPush() default InheritableBoolean.INHERIT;
+
   // Fields specific to acceptance test behavior.
 
   /** Username to use for initial clone, passed to {@link AccountCreator}. */
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..8efb6ae
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/ThrowingConsumer.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.acceptance.testsuite;
+
+@FunctionalInterface
+public interface ThrowingConsumer<T> {
+  void accept(T t) throws Exception;
+
+  default void acceptAndThrowSilently(T t) {
+    try {
+      accept(t);
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/ThrowingFunction.java b/java/com/google/gerrit/acceptance/testsuite/ThrowingFunction.java
index d41672a..2337331 100644
--- a/java/com/google/gerrit/acceptance/testsuite/ThrowingFunction.java
+++ b/java/com/google/gerrit/acceptance/testsuite/ThrowingFunction.java
@@ -18,4 +18,12 @@
 public interface ThrowingFunction<T, R> {
 
   R apply(T value) throws Exception;
+
+  default R applyAndThrowSilently(T t) {
+    try {
+      return apply(t);
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
index 58a00d0..61b828e 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
@@ -28,11 +28,11 @@
 
   /**
    * Starts the fluent chain for a querying or modifying an account. Please see the methods of
-   * {@link MoreAccountOperations} for details on possible operations.
+   * {@link PerAccountOperations} for details on possible operations.
    *
    * @return an aggregation of operations on a specific account
    */
-  MoreAccountOperations account(Account.Id accountId);
+  PerAccountOperations account(Account.Id accountId);
 
   /**
    * Starts the fluent chain to create an account. The returned builder can be used to specify the
@@ -42,7 +42,7 @@
    * <p>Example:
    *
    * <pre>
-   * TestAccount createdAccount = accountOperations
+   * Account.Id createdAccountId = accountOperations
    *     .newAccount()
    *     .username("janedoe")
    *     .preferredEmail("janedoe@example.com")
@@ -58,14 +58,14 @@
   TestAccountCreation.Builder newAccount();
 
   /** An aggregation of methods on a specific account. */
-  interface MoreAccountOperations {
+  interface PerAccountOperations {
 
     /**
      * Checks whether the account exists.
      *
      * @return {@code true} if the account exists
      */
-    boolean exists() throws Exception;
+    boolean exists();
 
     /**
      * Retrieves the account.
@@ -76,7 +76,7 @@
      *
      * @return the corresponding {@code TestAccount}
      */
-    TestAccount get() throws Exception;
+    TestAccount get();
 
     /**
      * Starts the fluent chain to update an account. The returned builder can be used to specify how
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 3d741b0..ec2d75e 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -17,14 +17,13 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
@@ -50,8 +49,8 @@
   }
 
   @Override
-  public MoreAccountOperations account(Account.Id accountId) {
-    return new MoreAccountOperationsImpl(accountId);
+  public PerAccountOperations account(Account.Id accountId) {
+    return new PerAccountOperationsImpl(accountId);
   }
 
   @Override
@@ -59,17 +58,17 @@
     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());
+            fillBuilder(updateBuilder, accountCreation, account.getAccount().id());
     AccountState createdAccount = createAccount(accountUpdater);
-    return toTestAccount(createdAccount);
+    return createdAccount.getAccount().id();
   }
 
   private AccountState createAccount(AccountsUpdate.AccountUpdater accountUpdater)
-      throws OrmException, IOException, ConfigInvalidException {
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+      throws IOException, ConfigInvalidException {
+    Account.Id accountId = Account.id(seq.nextAccountId());
     return accountsUpdate.insert("Create Test Account", accountId, accountUpdater);
   }
 
@@ -85,17 +84,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
@@ -111,44 +99,61 @@
     return builder.addExternalId(ExternalId.createUsername(username, accountId, httpPassword));
   }
 
-  private class MoreAccountOperationsImpl implements MoreAccountOperations {
+  private class PerAccountOperationsImpl implements PerAccountOperations {
     private final Account.Id accountId;
 
-    MoreAccountOperationsImpl(Account.Id accountId) {
+    PerAccountOperationsImpl(Account.Id accountId) {
       this.accountId = accountId;
     }
 
     @Override
-    public boolean exists() throws Exception {
-      return accounts.get(accountId).isPresent();
+    public boolean exists() {
+      return getAccountState(accountId).isPresent();
     }
 
     @Override
-    public TestAccount get() throws Exception {
+    public TestAccount get() {
       AccountState account =
-          accounts
-              .get(accountId)
+          getAccountState(accountId)
               .orElseThrow(
                   () -> new IllegalStateException("Tried to get non-existing test account"));
       return toTestAccount(account);
     }
 
+    private Optional<AccountState> getAccountState(Account.Id accountId) {
+      try {
+        return accounts.get(accountId);
+      } catch (IOException | ConfigInvalidException e) {
+        throw new IllegalStateException(e);
+      }
+    }
+
+    private TestAccount toTestAccount(AccountState accountState) {
+      Account account = accountState.getAccount();
+      return TestAccount.builder()
+          .accountId(account.id())
+          .preferredEmail(Optional.ofNullable(account.preferredEmail()))
+          .fullname(Optional.ofNullable(account.fullName()))
+          .username(accountState.getUserName())
+          .active(accountState.getAccount().isActive())
+          .build();
+    }
+
     @Override
     public TestAccountUpdate.Builder forUpdate() {
       return TestAccountUpdate.builder(this::updateAccount);
     }
 
-    private TestAccount updateAccount(TestAccountUpdate accountUpdate)
-        throws OrmException, IOException, ConfigInvalidException {
+    private void updateAccount(TestAccountUpdate accountUpdate)
+        throws IOException, ConfigInvalidException {
       AccountsUpdate.AccountUpdater accountUpdater =
           (account, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountId);
       Optional<AccountState> updatedAccount = updateAccount(accountUpdater);
       checkState(updatedAccount.isPresent(), "Tried to update non-existing test account");
-      return toTestAccount(updatedAccount.get());
     }
 
     private Optional<AccountState> updateAccount(AccountsUpdate.AccountUpdater accountUpdater)
-        throws OrmException, IOException, ConfigInvalidException {
+        throws IOException, ConfigInvalidException {
       return accountsUpdate.update("Update Test Account", accountId, 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..f2414e0 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,13 +84,13 @@
     }
 
     abstract Builder accountCreator(
-        ThrowingFunction<TestAccountCreation, TestAccount> accountCreator);
+        ThrowingFunction<TestAccountCreation, Account.Id> accountCreator);
 
     abstract TestAccountCreation autoBuild();
 
-    public TestAccount create() throws Exception {
+    public Account.Id create() {
       TestAccountCreation accountUpdate = autoBuild();
-      return accountUpdate.accountCreator().apply(accountUpdate);
+      return accountUpdate.accountCreator().applyAndThrowSilently(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..da599e7 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() {
       TestAccountUpdate accountUpdate = autoBuild();
-      return accountUpdate.accountUpdater().apply(accountUpdate);
+      accountUpdate.accountUpdater().acceptAndThrowSilently(accountUpdate);
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
index 0cb5cf3..4847fdb 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
@@ -55,14 +55,14 @@
   public KeyPair getKeyPair(com.google.gerrit.acceptance.TestAccount account) throws Exception {
     checkState(sshEnabled, "Requested SSH key pair, but SSH is disabled");
     checkState(
-        account.username != null,
+        account.username() != null,
         "Requested SSH key pair for account %s, but username is not set",
-        account.id);
+        account.id());
 
-    String username = account.username;
+    String username = account.username();
     KeyPair keyPair = sshKeyPairs.get(username);
     if (keyPair == null) {
-      keyPair = createKeyPair(account.id, username, account.email);
+      keyPair = createKeyPair(account.id(), username, account.email());
       sshKeyPairs.put(username, keyPair);
     }
     return keyPair;
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..533d06b
--- /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
+   * PerGroupOperations} for details on possible operations.
+   *
+   * @return an aggregation of operations on a specific group
+   */
+  PerGroupOperations 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 PerGroupOperations {
+
+    /**
+     * Checks whether the group exists.
+     *
+     * @return {@code true} if the group exists
+     */
+    boolean exists();
+
+    /**
+     * 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();
+
+    /**
+     * 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..808f858
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.group;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.GerritPersonIdent;
+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.gerrit.server.notedb.Sequences;
+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 PerGroupOperations group(AccountGroup.UUID groupUuid) {
+    return new PerGroupOperationsImpl(groupUuid);
+  }
+
+  @Override
+  public TestGroupCreation.Builder newGroup() {
+    return TestGroupCreation.builder(this::createNewGroup);
+  }
+
+  private AccountGroup.UUID createNewGroup(TestGroupCreation groupCreation)
+      throws ConfigInvalidException, IOException {
+    InternalGroupCreation internalGroupCreation = toInternalGroupCreation(groupCreation);
+    InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupCreation);
+    InternalGroup internalGroup =
+        groupsUpdate.createGroup(internalGroupCreation, internalGroupUpdate);
+    return internalGroup.getGroupUUID();
+  }
+
+  private InternalGroupCreation toInternalGroupCreation(TestGroupCreation groupCreation) {
+    AccountGroup.Id groupId = AccountGroup.id(seq.nextGroupId());
+    String groupName = groupCreation.name().orElse("group-with-id-" + groupId.get());
+    AccountGroup.UUID groupUuid = GroupUUID.make(groupName, serverIdent);
+    AccountGroup.NameKey nameKey = 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 PerGroupOperationsImpl implements PerGroupOperations {
+    private final AccountGroup.UUID groupUuid;
+
+    PerGroupOperationsImpl(AccountGroup.UUID groupUuid) {
+      this.groupUuid = groupUuid;
+    }
+
+    @Override
+    public boolean exists() {
+      return getGroup(groupUuid).isPresent();
+    }
+
+    @Override
+    public TestGroup get() {
+      Optional<InternalGroup> group = getGroup(groupUuid);
+      checkState(group.isPresent(), "Tried to get non-existing test group");
+      return toTestGroup(group.get());
+    }
+
+    private Optional<InternalGroup> getGroup(AccountGroup.UUID groupUuid) {
+      try {
+        return groups.getGroup(groupUuid);
+      } catch (IOException | ConfigInvalidException e) {
+        throw new IllegalStateException(e);
+      }
+    }
+
+    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 DuplicateKeyException, 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).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..612ce2a
--- /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() {
+      TestGroupCreation groupCreation = autoBuild();
+      return groupCreation.groupCreator().applyAndThrowSilently(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..bc9d569
--- /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() {
+      TestGroupUpdate groupUpdater = autoBuild();
+      groupUpdater.groupUpdater().acceptAndThrowSilently(groupUpdater);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
new file mode 100644
index 0000000..3215a9c
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -0,0 +1,21 @@
+package(default_testonly = 1)
+
+java_library(
+    name = "project",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:function",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//lib:guava",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/commons:lang",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
new file mode 100644
index 0000000..b310393
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
@@ -0,0 +1,77 @@
+// 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.project;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectConfig;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/**
+ * Operations for constructing projects in tests. This does not necessarily use the project REST
+ * API, so don't use it for testing that.
+ */
+public interface ProjectOperations {
+
+  /** Starts a fluent chain for creating a new project. */
+  TestProjectCreation.Builder newProject();
+
+  PerProjectOperations project(Project.NameKey key);
+
+  /** Starts a fluent chain for updating All-Projects. */
+  TestProjectUpdate.Builder allProjectsForUpdate();
+
+  interface PerProjectOperations {
+    /**
+     * Returns the commit for this project. branchName can either be shortened ("HEAD", "master") or
+     * a fully qualified refname ("refs/heads/master"). The branch must exist.
+     */
+    RevCommit getHead(String branchName);
+
+    /**
+     * Returns true if a branch exists. branchName can either be shortened ("HEAD", "master") or a
+     * fully qualified refname ("refs/heads/master").
+     */
+    boolean hasHead(String branchName);
+
+    /** Returns a fresh {@link ProjectConfig} read from the tip of {@code refs/meta/config}. */
+    ProjectConfig getProjectConfig();
+
+    /**
+     * Returns a fresh JGit {@link Config} instance read from {@code project.config} at the tip of
+     * {@code refs/meta/config}. Does not have a base config, i.e. does not respect {@code
+     * $site_path/etc/project.config}.
+     */
+    Config getConfig();
+
+    /**
+     * Starts the fluent chain to update a project. The returned builder can be used to specify how
+     * the attributes of the project should be modified. To update the project for real, the {@link
+     * TestProjectUpdate.Builder#update()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * projectOperations
+     *     .forUpdate()
+     *     .add(allow(ABANDON).ref("refs/*").group(REGISTERED_USERS))
+     *     .update();
+     * </pre>
+     *
+     * @return a builder to update the check.
+     */
+    TestProjectUpdate.Builder forUpdate();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
new file mode 100644
index 0000000..34e57b4
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -0,0 +1,265 @@
+// 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.project;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectCreation.Builder;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+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.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectCreator;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import org.apache.commons.lang.RandomStringUtils;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+public class ProjectOperationsImpl implements ProjectOperations {
+  private final AllProjectsName allProjectsName;
+  private final GitRepositoryManager repoManager;
+  private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final ProjectCache projectCache;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCreator projectCreator;
+
+  @Inject
+  ProjectOperationsImpl(
+      AllProjectsName allProjectsName,
+      GitRepositoryManager repoManager,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      ProjectCache projectCache,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCreator projectCreator) {
+    this.allProjectsName = allProjectsName;
+    this.repoManager = repoManager;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectCache = projectCache;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCreator = projectCreator;
+  }
+
+  @Override
+  public Builder newProject() {
+    return TestProjectCreation.builder(this::createNewProject);
+  }
+
+  private Project.NameKey createNewProject(TestProjectCreation projectCreation) throws Exception {
+    String name = projectCreation.name().orElse(RandomStringUtils.randomAlphabetic(8));
+
+    CreateProjectArgs args = new CreateProjectArgs();
+    args.setProjectName(name);
+    args.branch = Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
+    args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
+    projectCreation.parent().ifPresent(p -> args.newParent = p);
+    // ProjectCreator wants non-null owner IDs.
+    args.ownerIds = new ArrayList<>();
+    projectCreation.submitType().ifPresent(st -> args.submitType = st);
+    projectCreator.createProject(args);
+    return Project.nameKey(name);
+  }
+
+  @Override
+  public ProjectOperations.PerProjectOperations project(Project.NameKey key) {
+    return new PerProjectOperations(key);
+  }
+
+  @Override
+  public TestProjectUpdate.Builder allProjectsForUpdate() {
+    return project(allProjectsName).forUpdate();
+  }
+
+  private class PerProjectOperations implements ProjectOperations.PerProjectOperations {
+    Project.NameKey nameKey;
+
+    PerProjectOperations(Project.NameKey nameKey) {
+      this.nameKey = nameKey;
+    }
+
+    @Override
+    public RevCommit getHead(String branch) {
+      return requireNonNull(headOrNull(branch));
+    }
+
+    @Override
+    public boolean hasHead(String branch) {
+      return headOrNull(branch) != null;
+    }
+
+    @Override
+    public TestProjectUpdate.Builder forUpdate() {
+      return TestProjectUpdate.builder(nameKey, allProjectsName, this::updateProject);
+    }
+
+    private void updateProject(TestProjectUpdate projectUpdate)
+        throws IOException, ConfigInvalidException {
+      try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(nameKey)) {
+        ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+        removePermissions(projectConfig, projectUpdate.removedPermissions());
+        addCapabilities(projectConfig, projectUpdate.addedCapabilities());
+        addPermissions(projectConfig, projectUpdate.addedPermissions());
+        addLabelPermissions(projectConfig, projectUpdate.addedLabelPermissions());
+        setExclusiveGroupPermissions(projectConfig, projectUpdate.exclusiveGroupPermissions());
+        projectConfig.commit(metaDataUpdate);
+      }
+      projectCache.evict(nameKey);
+    }
+
+    private void removePermissions(
+        ProjectConfig projectConfig,
+        ImmutableList<TestProjectUpdate.TestPermissionKey> removedPermissions) {
+      for (TestProjectUpdate.TestPermissionKey p : removedPermissions) {
+        Permission permission =
+            projectConfig.getAccessSection(p.section(), true).getPermission(p.name(), true);
+        if (p.group().isPresent()) {
+          GroupReference group = new GroupReference(p.group().get(), p.group().get().get());
+          group = projectConfig.resolve(group);
+          permission.removeRule(group);
+        } else {
+          permission.clearRules();
+        }
+      }
+    }
+
+    private void addCapabilities(
+        ProjectConfig projectConfig, ImmutableList<TestCapability> addedCapabilities) {
+      for (TestCapability c : addedCapabilities) {
+        PermissionRule rule = newRule(projectConfig, c.group());
+        rule.setRange(c.min(), c.max());
+        projectConfig
+            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+            .getPermission(c.name(), true)
+            .add(rule);
+      }
+    }
+
+    private void addPermissions(
+        ProjectConfig projectConfig, ImmutableList<TestPermission> addedPermissions) {
+      for (TestPermission p : addedPermissions) {
+        PermissionRule rule = newRule(projectConfig, p.group());
+        rule.setAction(p.action());
+        rule.setForce(p.force());
+        projectConfig.getAccessSection(p.ref(), true).getPermission(p.name(), true).add(rule);
+      }
+    }
+
+    private void addLabelPermissions(
+        ProjectConfig projectConfig, ImmutableList<TestLabelPermission> addedLabelPermissions) {
+      for (TestLabelPermission p : addedLabelPermissions) {
+        PermissionRule rule = newRule(projectConfig, p.group());
+        rule.setAction(p.action());
+        rule.setRange(p.min(), p.max());
+        String permissionName =
+            p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+        Permission permission =
+            projectConfig.getAccessSection(p.ref(), true).getPermission(permissionName, true);
+        permission.add(rule);
+      }
+    }
+
+    private void setExclusiveGroupPermissions(
+        ProjectConfig projectConfig,
+        ImmutableMap<TestProjectUpdate.TestPermissionKey, Boolean> exclusiveGroupPermissions) {
+      exclusiveGroupPermissions.forEach(
+          (key, exclusive) ->
+              projectConfig
+                  .getAccessSection(key.section(), true)
+                  .getPermission(key.name(), true)
+                  .setExclusiveGroup(exclusive));
+    }
+
+    private RevCommit headOrNull(String branch) {
+      if (!branch.startsWith(Constants.R_REFS)) {
+        branch = RefNames.REFS_HEADS + branch;
+      }
+
+      try (Repository repo = repoManager.openRepository(nameKey);
+          RevWalk rw = new RevWalk(repo)) {
+        Ref r = repo.exactRef(branch);
+        return r == null ? null : rw.parseCommit(r.getObjectId());
+      } catch (Exception e) {
+        throw new IllegalStateException(e);
+      }
+    }
+
+    @Override
+    public ProjectConfig getProjectConfig() {
+      try (Repository repo = repoManager.openRepository(nameKey)) {
+        ProjectConfig projectConfig = projectConfigFactory.create(nameKey);
+        projectConfig.load(nameKey, repo);
+        return projectConfig;
+      } catch (Exception e) {
+        throw new IllegalStateException(e);
+      }
+    }
+
+    @Override
+    public Config getConfig() {
+      try (Repository repo = repoManager.openRepository(nameKey);
+          RevWalk rw = new RevWalk(repo)) {
+        Ref ref = repo.exactRef(REFS_CONFIG);
+        if (ref == null) {
+          return new Config();
+        }
+        RevTree tree = rw.parseTree(ref.getObjectId());
+        TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), PROJECT_CONFIG, tree);
+        if (tw == null) {
+          return new Config();
+        }
+        ObjectLoader loader = rw.getObjectReader().open(tw.getObjectId(0));
+        String text = new String(loader.getCachedBytes(), UTF_8);
+        Config config = new Config();
+        config.fromText(text);
+        return config;
+      } catch (Exception e) {
+        throw new IllegalStateException(e);
+      }
+    }
+  }
+
+  private static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
+    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
+    group = project.resolve(group);
+    return new PermissionRule(group);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
new file mode 100644
index 0000000..31af1d2
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.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.acceptance.testsuite.project;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestProjectCreation {
+
+  public abstract Optional<String> name();
+
+  public abstract Optional<Project.NameKey> parent();
+
+  public abstract Optional<Boolean> createEmptyCommit();
+
+  public abstract Optional<SubmitType> submitType();
+
+  abstract ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator();
+
+  public static Builder builder(
+      ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator) {
+    return new AutoValue_TestProjectCreation.Builder().projectCreator(projectCreator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract TestProjectCreation.Builder name(String name);
+
+    public abstract TestProjectCreation.Builder parent(Project.NameKey parent);
+
+    public abstract TestProjectCreation.Builder submitType(SubmitType submitType);
+
+    public abstract TestProjectCreation.Builder createEmptyCommit(boolean value);
+
+    /** Skips the empty commit on creation. This means that project's branches will not exist. */
+    public TestProjectCreation.Builder noEmptyCommit() {
+      return createEmptyCommit(false);
+    }
+
+    abstract TestProjectCreation.Builder projectCreator(
+        ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator);
+
+    abstract TestProjectCreation autoBuild();
+
+    /**
+     * Executes the project creation as specified.
+     *
+     * @return the name of the created project
+     */
+    public Project.NameKey create() {
+      TestProjectCreation creation = autoBuild();
+      return creation.projectCreator().applyAndThrowSilently(creation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
new file mode 100644
index 0000000..6cbf40d
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -0,0 +1,436 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.common.data.AccessSection.GLOBAL_CAPABILITIES;
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+
+@AutoValue
+public abstract class TestProjectUpdate {
+  /** Starts a builder for allowing a capability. */
+  public static TestCapability.Builder allowCapability(String name) {
+    return TestCapability.builder().name(name);
+  }
+
+  /** Records a global capability to be updated. */
+  @AutoValue
+  public abstract static class TestCapability {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestCapability.Builder();
+    }
+
+    abstract String name();
+
+    abstract AccountGroup.UUID group();
+
+    abstract int min();
+
+    abstract int max();
+
+    /** Builder for {@link TestCapability}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      /** Sets the name of the capability. */
+      public abstract Builder name(String name);
+
+      abstract String name();
+
+      /** Sets the group to which the capability applies. */
+      public abstract Builder group(AccountGroup.UUID group);
+
+      abstract Builder min(int min);
+
+      abstract Optional<Integer> min();
+
+      abstract Builder max(int max);
+
+      abstract Optional<Integer> max();
+
+      /** Sets the minimum and maximum values for the capability. */
+      public Builder range(int min, int max) {
+        checkNonInvertedRange(min, max);
+        return min(min).max(max);
+      }
+
+      /** Builds the {@link TestCapability}. */
+      abstract TestCapability autoBuild();
+
+      public TestCapability build() {
+        PermissionRange.WithDefaults withDefaults = GlobalCapability.getRange(name());
+        if (withDefaults != null) {
+          int min = min().orElse(withDefaults.getDefaultMin());
+          int max = max().orElse(withDefaults.getDefaultMax());
+          range(min, max);
+          // Don't enforce range is nonempty; this is allowed for e.g. batchChangesLimit.
+        } else {
+          checkArgument(
+              !min().isPresent() && !max().isPresent(),
+              "capability %s does not support ranges",
+              name());
+          range(0, 0);
+        }
+
+        return autoBuild();
+      }
+    }
+  }
+
+  /** Starts a builder for allowing a permission. */
+  public static TestPermission.Builder allow(String name) {
+    return TestPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a permission. */
+  public static TestPermission.Builder deny(String name) {
+    return TestPermission.builder().name(name).action(PermissionRule.Action.DENY);
+  }
+
+  /** Starts a builder for blocking a permission. */
+  public static TestPermission.Builder block(String name) {
+    return TestPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+  }
+
+  /**
+   * Records a permission to be updated.
+   *
+   * <p>Not used for permissions that have ranges (label permissions) or global capabilities.
+   */
+  @AutoValue
+  public abstract static class TestPermission {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestPermission.Builder().force(false);
+    }
+
+    abstract String name();
+
+    abstract String ref();
+
+    abstract AccountGroup.UUID group();
+
+    abstract PermissionRule.Action action();
+
+    abstract boolean force();
+
+    /** Builder for {@link TestPermission}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder name(String name);
+
+      /** Sets the ref pattern used on the permission. */
+      public abstract Builder ref(String ref);
+
+      /** Sets the group to which the permission applies. */
+      public abstract Builder group(AccountGroup.UUID groupUuid);
+
+      abstract Builder action(PermissionRule.Action action);
+
+      /** Sets whether the permission is a force permission. */
+      public abstract Builder force(boolean force);
+
+      /** Builds the {@link TestPermission}. */
+      public abstract TestPermission build();
+    }
+  }
+
+  /** Starts a builder for allowing a label permission. */
+  public static TestLabelPermission.Builder allowLabel(String name) {
+    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a label permission. */
+  public static TestLabelPermission.Builder blockLabel(String name) {
+    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+  }
+
+  /** Records a label permission to be updated. */
+  @AutoValue
+  public abstract static class TestLabelPermission {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestLabelPermission.Builder().impersonation(false);
+    }
+
+    abstract String name();
+
+    abstract String ref();
+
+    abstract AccountGroup.UUID group();
+
+    abstract PermissionRule.Action action();
+
+    abstract int min();
+
+    abstract int max();
+
+    abstract boolean impersonation();
+
+    /** Builder for {@link TestLabelPermission}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder name(String name);
+
+      /** Sets the ref pattern used on the permission. */
+      public abstract Builder ref(String ref);
+
+      /** Sets the group to which the permission applies. */
+      public abstract Builder group(AccountGroup.UUID group);
+
+      abstract Builder action(PermissionRule.Action action);
+
+      abstract Builder min(int min);
+
+      abstract Builder max(int max);
+
+      /** Sets the minimum and maximum values for the permission. */
+      public Builder range(int min, int max) {
+        checkArgument(min != 0 || max != 0, "empty range");
+        checkNonInvertedRange(min, max);
+        return min(min).max(max);
+      }
+
+      /** Sets whether this permission should be for impersonating another user's votes. */
+      public abstract Builder impersonation(boolean impersonation);
+
+      abstract TestLabelPermission autoBuild();
+
+      /** Builds the {@link TestPermission}. */
+      public TestLabelPermission build() {
+        TestLabelPermission result = autoBuild();
+        checkLabelName(result.name());
+        return result;
+      }
+    }
+  }
+
+  /**
+   * Starts a builder for describing a permission key for deletion. Not for label permissions or
+   * global capabilities.
+   */
+  public static TestPermissionKey.Builder permissionKey(String name) {
+    return TestPermissionKey.builder().name(name);
+  }
+
+  /** Starts a builder for describing a label permission key for deletion. */
+  public static TestPermissionKey.Builder labelPermissionKey(String name) {
+    checkLabelName(name);
+    return TestPermissionKey.builder().name(Permission.forLabel(name));
+  }
+
+  /** Starts a builder for describing a capability key for deletion. */
+  public static TestPermissionKey.Builder capabilityKey(String name) {
+    return TestPermissionKey.builder().name(name).section(GLOBAL_CAPABILITIES);
+  }
+
+  /** Records the key of a permission (of any type) for deletion. */
+  @AutoValue
+  public abstract static class TestPermissionKey {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestPermissionKey.Builder();
+    }
+
+    abstract String section();
+
+    abstract String name();
+
+    abstract Optional<AccountGroup.UUID> group();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder section(String section);
+
+      abstract Optional<String> section();
+
+      /** Sets the ref pattern used on the permission. Not for global capabilities. */
+      public Builder ref(String ref) {
+        requireNonNull(ref);
+        checkArgument(ref.startsWith(Constants.R_REFS), "must be a ref: %s", ref);
+        checkArgument(
+            !section().isPresent() || !section().get().equals(GLOBAL_CAPABILITIES),
+            "can't set ref on global capability");
+        return section(ref);
+      }
+
+      abstract Builder name(String name);
+
+      /** Sets the group to which the permission applies. */
+      public abstract Builder group(AccountGroup.UUID group);
+
+      /** Builds the {@link TestPermissionKey}. */
+      public abstract TestPermissionKey build();
+    }
+  }
+
+  static Builder builder(
+      Project.NameKey nameKey,
+      AllProjectsName allProjectsName,
+      ThrowingConsumer<TestProjectUpdate> projectUpdater) {
+    return new AutoValue_TestProjectUpdate.Builder()
+        .nameKey(nameKey)
+        .allProjectsName(allProjectsName)
+        .projectUpdater(projectUpdater);
+  }
+
+  /** Builder for {@link TestProjectUpdate}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    abstract Builder nameKey(Project.NameKey project);
+
+    abstract Builder allProjectsName(AllProjectsName allProjects);
+
+    abstract ImmutableList.Builder<TestPermission> addedPermissionsBuilder();
+
+    abstract ImmutableList.Builder<TestLabelPermission> addedLabelPermissionsBuilder();
+
+    abstract ImmutableList.Builder<TestCapability> addedCapabilitiesBuilder();
+
+    abstract ImmutableList.Builder<TestPermissionKey> removedPermissionsBuilder();
+
+    abstract ImmutableMap.Builder<TestPermissionKey, Boolean> exclusiveGroupPermissionsBuilder();
+
+    /** Adds a permission to be included in this update. */
+    public Builder add(TestPermission testPermission) {
+      addedPermissionsBuilder().add(testPermission);
+      return this;
+    }
+
+    /** Adds a permission to be included in this update. */
+    public Builder add(TestPermission.Builder testPermissionBuilder) {
+      return add(testPermissionBuilder.build());
+    }
+
+    /** Adds a label permission to be included in this update. */
+    public Builder add(TestLabelPermission testLabelPermission) {
+      addedLabelPermissionsBuilder().add(testLabelPermission);
+      return this;
+    }
+
+    /** Adds a label permission to be included in this update. */
+    public Builder add(TestLabelPermission.Builder testLabelPermissionBuilder) {
+      return add(testLabelPermissionBuilder.build());
+    }
+
+    /** Adds a capability to be included in this update. */
+    public Builder add(TestCapability testCapability) {
+      addedCapabilitiesBuilder().add(testCapability);
+      return this;
+    }
+
+    /** Adds a capability to be included in this update. */
+    public Builder add(TestCapability.Builder testCapabilityBuilder) {
+      return add(testCapabilityBuilder.build());
+    }
+
+    /** Removes a permission, label permission, or capability as part of this update. */
+    public Builder remove(TestPermissionKey testPermissionKey) {
+      removedPermissionsBuilder().add(testPermissionKey);
+      return this;
+    }
+
+    /** Removes a permission, label permission, or capability as part of this update. */
+    public Builder remove(TestPermissionKey.Builder testPermissionKeyBuilder) {
+      return remove(testPermissionKeyBuilder.build());
+    }
+
+    /** Sets the exclusive bit bit for the given permission key. */
+    public Builder setExclusiveGroup(
+        TestPermissionKey.Builder testPermissionKeyBuilder, boolean exclusive) {
+      return setExclusiveGroup(testPermissionKeyBuilder.build(), exclusive);
+    }
+
+    /** Sets the exclusive bit bit for the given permission key. */
+    public Builder setExclusiveGroup(TestPermissionKey testPermissionKey, boolean exclusive) {
+      checkArgument(
+          !testPermissionKey.group().isPresent(),
+          "do not specify group for setExclusiveGroup: %s",
+          testPermissionKey);
+      checkArgument(
+          !testPermissionKey.section().equals(GLOBAL_CAPABILITIES),
+          "setExclusiveGroup not valid for global capabilities: %s",
+          testPermissionKey);
+      exclusiveGroupPermissionsBuilder().put(testPermissionKey, exclusive);
+      return this;
+    }
+
+    abstract Builder projectUpdater(ThrowingConsumer<TestProjectUpdate> projectUpdater);
+
+    abstract TestProjectUpdate autoBuild();
+
+    TestProjectUpdate build() {
+      TestProjectUpdate projectUpdate = autoBuild();
+      if (projectUpdate.hasCapabilityUpdates()) {
+        checkArgument(
+            projectUpdate.nameKey().equals(projectUpdate.allProjectsName()),
+            "cannot update global capabilities on %s, only %s: %s",
+            projectUpdate.nameKey(),
+            projectUpdate.allProjectsName(),
+            projectUpdate);
+      }
+      return projectUpdate;
+    }
+
+    /** Executes the update, updating the underlying project. */
+    public void update() {
+      TestProjectUpdate projectUpdate = build();
+      projectUpdate.projectUpdater().acceptAndThrowSilently(projectUpdate);
+    }
+  }
+
+  abstract Project.NameKey nameKey();
+
+  abstract AllProjectsName allProjectsName();
+
+  abstract ImmutableList<TestPermission> addedPermissions();
+
+  abstract ImmutableList<TestLabelPermission> addedLabelPermissions();
+
+  abstract ImmutableList<TestCapability> addedCapabilities();
+
+  abstract ImmutableList<TestPermissionKey> removedPermissions();
+
+  abstract ImmutableMap<TestPermissionKey, Boolean> exclusiveGroupPermissions();
+
+  abstract ThrowingConsumer<TestProjectUpdate> projectUpdater();
+
+  boolean hasCapabilityUpdates() {
+    return !addedCapabilities().isEmpty()
+        || removedPermissions().stream().anyMatch(k -> k.section().equals(GLOBAL_CAPABILITIES));
+  }
+
+  private static void checkLabelName(String name) {
+    // "label-Code-Review" is technically a valid label name, and we don't prevent users from
+    // using it in production, but specifying it in a test is programmer error.
+    checkArgument(!Permission.isLabel(name), "expected label name, got permission name: %s", name);
+    LabelType.checkName(name);
+  }
+
+  private static void checkNonInvertedRange(int min, int max) {
+    checkArgument(min <= max, "inverted range: %s > %s", min, max);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
new file mode 100644
index 0000000..17d9294
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.request;
+
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.reviewdb.client.Account;
+
+/**
+ * An aggregation of operations on Guice request scopes for test purposes.
+ *
+ * <p>To execute the operations, no Gerrit permissions are necessary.
+ */
+public interface RequestScopeOperations {
+  /**
+   * Sets the Guice request scope to the given account.
+   *
+   * <p>The resulting context has an SSH session attached. In order to use the SSH session returned
+   * by {@link com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context#getSession()}, SSH
+   * must be enabled in the test and the account must have a username set. However, these are not
+   * requirements simply to call this method.
+   *
+   * @param accountId account ID. Must exist; throws an unchecked exception otherwise.
+   * @return the previous request scope.
+   */
+  AcceptanceTestRequestScope.Context setApiUser(Account.Id accountId);
+
+  /**
+   * Sets the Guice request scope to the given account.
+   *
+   * <p>The resulting context has an SSH session attached. In order to use the SSH session returned
+   * by {@link com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context#getSession()}, SSH
+   * must be enabled in the test and the account must have a username set. However, these are not
+   * requirements simply to call this method.
+   *
+   * @param testAccount test account from {@code AccountOperations}.
+   * @return the previous request scope.
+   */
+  AcceptanceTestRequestScope.Context setApiUser(TestAccount testAccount);
+
+  /**
+   * Enforces a new request context for the current API user.
+   *
+   * <p>This recreates the {@code IdentifiedUser}, hence everything which is cached in the {@code
+   * IdentifiedUser} is reloaded (e.g. the email addresses of the user).
+   *
+   * <p>The current user must be an identified user.
+   *
+   * @return the previous request scope.
+   */
+  AcceptanceTestRequestScope.Context resetCurrentApiUser();
+
+  /**
+   * Sets the Guice request scope to the anonymous user.
+   *
+   * @return the previous request scope.
+   */
+  AcceptanceTestRequestScope.Context setApiUserAnonymous();
+
+  /**
+   * Sets the Guice request scope to the internal server user.
+   *
+   * @return the previous request scope.
+   */
+  AcceptanceTestRequestScope.Context setApiUserInternal();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
new file mode 100644
index 0000000..5546422
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.request;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
+import com.google.gerrit.acceptance.SshSession;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.net.InetSocketAddress;
+
+/**
+ * The implementation of {@code RequestScopeOperations}.
+ *
+ * <p>There is only one implementation of {@code RequestScopeOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+@Singleton
+public class RequestScopeOperationsImpl implements RequestScopeOperations {
+  private final AcceptanceTestRequestScope atrScope;
+  private final AccountCache accountCache;
+  private final AccountOperations accountOperations;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final Provider<AnonymousUser> anonymousUserProvider;
+  private final InternalUser.Factory internalUserFactory;
+  private final InetSocketAddress sshAddress;
+  private final TestSshKeys testSshKeys;
+
+  @Inject
+  RequestScopeOperationsImpl(
+      AcceptanceTestRequestScope atrScope,
+      AccountCache accountCache,
+      AccountOperations accountOperations,
+      GenericFactory userFactory,
+      Provider<AnonymousUser> anonymousUserProvider,
+      InternalUser.Factory internalUserFactory,
+      @Nullable @TestSshServerAddress InetSocketAddress sshAddress,
+      TestSshKeys testSshKeys) {
+    this.atrScope = atrScope;
+    this.accountCache = accountCache;
+    this.accountOperations = accountOperations;
+    this.userFactory = userFactory;
+    this.anonymousUserProvider = anonymousUserProvider;
+    this.internalUserFactory = internalUserFactory;
+    this.sshAddress = sshAddress;
+    this.testSshKeys = testSshKeys;
+  }
+
+  @Override
+  public AcceptanceTestRequestScope.Context setApiUser(Account.Id accountId) {
+    return setApiUser(accountOperations.account(accountId).get());
+  }
+
+  @Override
+  public AcceptanceTestRequestScope.Context setApiUser(TestAccount testAccount) {
+    return atrScope.set(
+        atrScope.newContext(
+            new SshSession(testSshKeys, sshAddress, testAccount),
+            createIdentifiedUser(testAccount.accountId())));
+  }
+
+  @Override
+  public AcceptanceTestRequestScope.Context resetCurrentApiUser() {
+    CurrentUser user = atrScope.get().getUser();
+    // More special cases for anonymous users etc. can be added as needed.
+    checkState(user.isIdentifiedUser(), "can only reset IdentifiedUser, not %s", user);
+    return setApiUser(user.getAccountId());
+  }
+
+  @Override
+  public AcceptanceTestRequestScope.Context setApiUserAnonymous() {
+    return atrScope.set(atrScope.newContext(null, anonymousUserProvider.get()));
+  }
+
+  @Override
+  public AcceptanceTestRequestScope.Context setApiUserInternal() {
+    return atrScope.set(atrScope.newContext(null, internalUserFactory.create()));
+  }
+
+  private IdentifiedUser createIdentifiedUser(Account.Id accountId) {
+    return userFactory.create(
+        accountCache
+            .get(requireNonNull(accountId))
+            .orElseThrow(
+                () -> new IllegalArgumentException("account does not exist: " + accountId)));
+  }
+}
diff --git a/java/com/google/gerrit/asciidoctor/AsciiDoctor.java b/java/com/google/gerrit/asciidoctor/AsciiDoctor.java
index 8b432ff..9d0a28e 100644
--- a/java/com/google/gerrit/asciidoctor/AsciiDoctor.java
+++ b/java/com/google/gerrit/asciidoctor/AsciiDoctor.java
@@ -19,7 +19,6 @@
 import com.google.common.io.ByteStreams;
 import java.io.BufferedReader;
 import java.io.File;
-import java.io.FilenameFilter;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
@@ -40,6 +39,7 @@
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.ParserProperties;
 
 public class AsciiDoctor {
 
@@ -126,7 +126,7 @@
       int equalsIndex = attribute.indexOf('=');
       if (equalsIndex > -1) {
         String name = attribute.substring(0, equalsIndex);
-        String value = attribute.substring(equalsIndex + 1, attribute.length());
+        String value = attribute.substring(equalsIndex + 1);
 
         attributeValues.put(name, value);
       } else {
@@ -138,13 +138,13 @@
   }
 
   private void invoke(String... parameters) throws IOException {
-    CmdLineParser parser = new CmdLineParser(this);
+    CmdLineParser parser = new CmdLineParser(this, ParserProperties.defaults().withAtSyntax(false));
     try {
       parser.parseArgument(parameters);
       if (inputFiles.isEmpty()) {
-        throw new CmdLineException(parser, "asciidoctor: FAILED: input file missing");
+        throw new IllegalArgumentException("asciidoctor: FAILED: input file missing");
       }
-    } catch (CmdLineException e) {
+    } catch (CmdLineException | IllegalArgumentException e) {
       System.err.println(e.getMessage());
       parser.printUsage(System.err);
       System.exit(1);
@@ -167,14 +167,7 @@
       try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(Paths.get(zipFile)))) {
         renderFiles(inputFiles, zip);
 
-        File[] cssFiles =
-            tmpdir.listFiles(
-                new FilenameFilter() {
-                  @Override
-                  public boolean accept(File dir, String name) {
-                    return name.endsWith(".css");
-                  }
-                });
+        File[] cssFiles = tmpdir.listFiles((dir, name) -> name.endsWith(".css"));
         for (File css : cssFiles) {
           zipFile(css, css.getName(), zip);
         }
diff --git a/java/com/google/gerrit/asciidoctor/DocIndexer.java b/java/com/google/gerrit/asciidoctor/DocIndexer.java
index 5dfde95..513bdd7 100644
--- a/java/com/google/gerrit/asciidoctor/DocIndexer.java
+++ b/java/com/google/gerrit/asciidoctor/DocIndexer.java
@@ -33,8 +33,8 @@
 import java.util.regex.Pattern;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
+import org.apache.lucene.analysis.CharArraySet;
 import org.apache.lucene.analysis.standard.StandardAnalyzer;
-import org.apache.lucene.analysis.util.CharArraySet;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.StringField;
@@ -48,6 +48,7 @@
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.ParserProperties;
 
 public class DocIndexer {
   private static final Pattern SECTION_HEADER = Pattern.compile("^=+ (.*)");
@@ -68,13 +69,13 @@
   private List<String> inputFiles = new ArrayList<>();
 
   private void invoke(String... parameters) throws IOException {
-    CmdLineParser parser = new CmdLineParser(this);
+    CmdLineParser parser = new CmdLineParser(this, ParserProperties.defaults().withAtSyntax(false));
     try {
       parser.parseArgument(parameters);
       if (inputFiles.isEmpty()) {
-        throw new CmdLineException(parser, "FAILED: input file missing");
+        throw new IllegalArgumentException("FAILED: input file missing");
       }
-    } catch (CmdLineException e) {
+    } catch (CmdLineException | IllegalArgumentException e) {
       System.err.println(e.getMessage());
       parser.printUsage(System.err);
       System.exit(1);
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index 2122ebb..b35b8bf 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -1,34 +1,13 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
 ANNOTATIONS = [
     "Nullable.java",
-    "audit/Audit.java",
-    "auth/SignInRequired.java",
+    "UsedAt.java",
 ]
 
 java_library(
     name = "annotations",
     srcs = ANNOTATIONS,
     visibility = ["//visibility:public"],
-)
-
-gwt_module(
-    name = "client",
-    srcs = glob(["**/*.java"]),
-    exported_deps = [
-        "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/prettify:client",
-        "//lib:guava",
-        "//lib:gwtorm-client",
-        "//lib:servlet-api-3_1",
-        "//lib/auto:auto-value",
-        "//lib/auto:auto-value-annotations",
-        "//lib/flogger:api",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-    gwt_xml = "Common.gwt.xml",
-    visibility = ["//visibility:public"],
+    deps = ["//lib:guava"],
 )
 
 java_library(
@@ -43,10 +22,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/reviewdb:server",
-        "//java/org/eclipse/jgit:server",
         "//lib:guava",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/common/Common.gwt.xml b/java/com/google/gerrit/common/Common.gwt.xml
deleted file mode 100644
index 56bbb84..0000000
--- a/java/com/google/gerrit/common/Common.gwt.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name='com.google.gerrit.reviewdb.ReviewDB' />
-  <inherits name='com.google.gwtjsonrpc.GWTJSONRPC'/>
-  <inherits name="com.google.gwt.logging.Logging"/>
-  <source path="">
-    <exclude name='**/testing/**/*.java'/>
-    <include name='**/*.java'/>
-  </source>
-</module>
diff --git a/java/com/google/gerrit/common/FileUtil.java b/java/com/google/gerrit/common/FileUtil.java
index 24e3808..5b0925e 100644
--- a/java/com/google/gerrit/common/FileUtil.java
+++ b/java/com/google/gerrit/common/FileUtil.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.common;
 
-import com.google.common.annotations.GwtIncompatible;
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
@@ -25,7 +24,6 @@
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.IO;
 
-@GwtIncompatible("Unemulated classes in java.io, java.nio and JGit")
 public class FileUtil {
   public static boolean modified(FileBasedConfig cfg) throws IOException {
     byte[] curVers;
@@ -46,7 +44,6 @@
   }
 
   public static void chmod(int mode, Path path) {
-    // TODO(dborowitz): Is there a portable way to do this with NIO?
     chmod(mode, path.toFile());
   }
 
diff --git a/java/com/google/gerrit/common/FooterConstants.java b/java/com/google/gerrit/common/FooterConstants.java
index d76c92b..3ec809c 100644
--- a/java/com/google/gerrit/common/FooterConstants.java
+++ b/java/com/google/gerrit/common/FooterConstants.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.common;
 
-import com.google.common.annotations.GwtIncompatible;
 import org.eclipse.jgit.revwalk.FooterKey;
 
-@GwtIncompatible("Unemulated com.google.gerrit.common.FooterConstants")
 public class FooterConstants {
   /** The change ID as used to track patch sets. */
   public static final FooterKey CHANGE_ID = new FooterKey("Change-Id");
diff --git a/java/com/google/gerrit/common/FormatUtil.java b/java/com/google/gerrit/common/FormatUtil.java
deleted file mode 100644
index 0f6b37a..0000000
--- a/java/com/google/gerrit/common/FormatUtil.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-public class FormatUtil {
-  public static String elide(String s, int max) {
-    if (s == null || s.length() <= max) {
-      return s;
-    }
-    int len = (max - 3) / 2;
-    return s.substring(0, len) + "..." + s.substring(s.length() - len);
-  }
-}
diff --git a/java/com/google/gerrit/common/IoUtil.java b/java/com/google/gerrit/common/IoUtil.java
index 526e88b..37f6c2c 100644
--- a/java/com/google/gerrit/common/IoUtil.java
+++ b/java/com/google/gerrit/common/IoUtil.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.common;
 
-import com.google.common.annotations.GwtIncompatible;
 import com.google.common.collect.Sets;
 import java.io.IOException;
 import java.io.InputStream;
@@ -30,7 +29,6 @@
 import java.util.Collections;
 import java.util.Set;
 
-@GwtIncompatible("Unemulated methods in Class and OutputStream")
 public final class IoUtil {
   public static void copyWithThread(InputStream src, OutputStream dst) {
     new Thread("IoUtil-Copy") {
diff --git a/java/com/google/gerrit/common/PageLinks.java b/java/com/google/gerrit/common/PageLinks.java
index 97e7ff3..701c171 100644
--- a/java/com/google/gerrit/common/PageLinks.java
+++ b/java/com/google/gerrit/common/PageLinks.java
@@ -17,9 +17,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.KeyUtil;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.client.KeyUtil;
 
 public class PageLinks {
   public static final String PROJECT_CHANGE_DELIMITER = "/+/";
@@ -83,7 +83,7 @@
   }
 
   public static String toChange(@Nullable Project.NameKey project, PatchSet.Id ps) {
-    return toChange(project, ps.getParentKey()) + ps.getId();
+    return toChange(project, ps.changeId()) + ps.getId();
   }
 
   public static String toProject(Project.NameKey p) {
diff --git a/java/com/google/gerrit/common/PluginData.java b/java/com/google/gerrit/common/PluginData.java
index b14543d..c440de1 100644
--- a/java/com/google/gerrit/common/PluginData.java
+++ b/java/com/google/gerrit/common/PluginData.java
@@ -14,11 +14,9 @@
 
 package com.google.gerrit.common;
 
-import com.google.common.annotations.GwtIncompatible;
 import java.nio.file.Path;
 import java.util.Objects;
 
-@GwtIncompatible("Unemulated java.nio.file.Path")
 public class PluginData {
   public final String name;
   public final String version;
diff --git a/java/com/google/gerrit/common/ProjectAccessUtil.java b/java/com/google/gerrit/common/ProjectAccessUtil.java
deleted file mode 100644
index 0369bfe..0000000
--- a/java/com/google/gerrit/common/ProjectAccessUtil.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-public class ProjectAccessUtil {
-  public static List<AccessSection> mergeSections(List<AccessSection> src) {
-    Map<String, AccessSection> map = new LinkedHashMap<>();
-    for (AccessSection section : src) {
-      if (section.getPermissions().isEmpty()) {
-        continue;
-      }
-
-      final AccessSection prior = map.get(section.getName());
-      if (prior != null) {
-        prior.mergeFrom(section);
-      } else {
-        map.put(section.getName(), section);
-      }
-    }
-    return new ArrayList<>(map.values());
-  }
-
-  public static List<AccessSection> removeEmptyPermissionsAndSections(
-      final List<AccessSection> src) {
-    final Set<AccessSection> sectionsToRemove = new HashSet<>();
-    for (AccessSection section : src) {
-      final Set<Permission> permissionsToRemove = new HashSet<>();
-      for (Permission permission : section.getPermissions()) {
-        if (permission.getRules().isEmpty()) {
-          permissionsToRemove.add(permission);
-        }
-      }
-      for (Permission permissionToRemove : permissionsToRemove) {
-        section.remove(permissionToRemove);
-      }
-      if (section.getPermissions().isEmpty()) {
-        sectionsToRemove.add(section);
-      }
-    }
-    for (AccessSection sectionToRemove : sectionsToRemove) {
-      src.remove(sectionToRemove);
-    }
-    return src;
-  }
-}
diff --git a/java/com/google/gerrit/common/ProjectUtil.java b/java/com/google/gerrit/common/ProjectUtil.java
deleted file mode 100644
index bfd5ef9..0000000
--- a/java/com/google/gerrit/common/ProjectUtil.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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;
-
-public class ProjectUtil {
-  public static String stripGitSuffix(String name) {
-    if (name.endsWith(".git")) {
-      // Be nice and drop the trailing ".git" suffix, which we never keep
-      // in our database, but clients might mistakenly provide anyway.
-      //
-      name = name.substring(0, name.length() - 4);
-      while (name.endsWith("/")) {
-        name = name.substring(0, name.length() - 1);
-      }
-    }
-    return name;
-  }
-
-  private ProjectUtil() {}
-}
diff --git a/java/com/google/gerrit/common/RawInputUtil.java b/java/com/google/gerrit/common/RawInputUtil.java
index f59d4a9..4a676e6 100644
--- a/java/com/google/gerrit/common/RawInputUtil.java
+++ b/java/com/google/gerrit/common/RawInputUtil.java
@@ -14,25 +14,24 @@
 
 package com.google.gerrit.common;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
-import com.google.common.annotations.GwtIncompatible;
-import com.google.common.base.Preconditions;
 import com.google.gerrit.extensions.restapi.RawInput;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import javax.servlet.http.HttpServletRequest;
 
-@GwtIncompatible("Unemulated classes in java.io and javax.servlet")
 public class RawInputUtil {
   public static RawInput create(String content) {
     return create(content.getBytes(UTF_8));
   }
 
   public static RawInput create(byte[] bytes, String contentType) {
-    Preconditions.checkNotNull(bytes);
-    Preconditions.checkArgument(bytes.length > 0);
+    requireNonNull(bytes);
+    checkArgument(bytes.length > 0);
     return new RawInput() {
       @Override
       public InputStream getInputStream() throws IOException {
diff --git a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
index cf86f74..fa9b139 100644
--- a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
+++ b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
@@ -18,7 +18,6 @@
 import static com.google.gerrit.common.FileUtil.lastModified;
 import static java.util.stream.Collectors.joining;
 
-import com.google.common.annotations.GwtIncompatible;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Ordering;
@@ -30,7 +29,6 @@
 import java.nio.file.Path;
 import java.util.List;
 
-@GwtIncompatible("Unemulated classes in java.nio and Guava")
 public final class SiteLibraryLoaderUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -50,12 +48,9 @@
 
   public static List<Path> listJars(Path dir) throws IOException {
     DirectoryStream.Filter<Path> filter =
-        new DirectoryStream.Filter<Path>() {
-          @Override
-          public boolean accept(Path entry) throws IOException {
-            String name = entry.getFileName().toString();
-            return (name.endsWith(".jar") || name.endsWith(".zip")) && Files.isRegularFile(entry);
-          }
+        entry -> {
+          String name = entry.getFileName().toString();
+          return (name.endsWith(".jar") || name.endsWith(".zip")) && Files.isRegularFile(entry);
         };
     try (DirectoryStream<Path> jars = Files.newDirectoryStream(dir, filter)) {
       return new Ordering<Path>() {
diff --git a/java/com/google/gerrit/common/TimeUtil.java b/java/com/google/gerrit/common/TimeUtil.java
deleted file mode 100644
index e42eb09..0000000
--- a/java/com/google/gerrit/common/TimeUtil.java
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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;
-
-import com.google.common.annotations.GwtIncompatible;
-import com.google.common.annotations.VisibleForTesting;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.util.function.LongSupplier;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.eclipse.jgit.util.SystemReader;
-
-/** Static utility methods for dealing with dates and times. */
-@GwtIncompatible("Unemulated Java 8 functionalities")
-public class TimeUtil {
-  private static final LongSupplier SYSTEM_CURRENT_MILLIS_SUPPLIER = System::currentTimeMillis;
-
-  private static volatile LongSupplier currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
-
-  public static long nowMs() {
-    // We should rather use Instant.now(Clock).toEpochMilli() instead but this would require some
-    // changes in our testing code as we wouldn't have clock steps anymore.
-    return currentMillisSupplier.getAsLong();
-  }
-
-  public static Instant now() {
-    return Instant.ofEpochMilli(nowMs());
-  }
-
-  public static Timestamp nowTs() {
-    return new Timestamp(nowMs());
-  }
-
-  public static Timestamp truncateToSecond(Timestamp t) {
-    return new Timestamp((t.getTime() / 1000) * 1000);
-  }
-
-  @VisibleForTesting
-  public static void setCurrentMillisSupplier(LongSupplier customCurrentMillisSupplier) {
-    currentMillisSupplier = customCurrentMillisSupplier;
-
-    SystemReader oldSystemReader = SystemReader.getInstance();
-    if (!(oldSystemReader instanceof GerritSystemReader)) {
-      SystemReader.setInstance(new GerritSystemReader(oldSystemReader));
-    }
-  }
-
-  @VisibleForTesting
-  public static void resetCurrentMillisSupplier() {
-    currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
-    SystemReader.setInstance(null);
-  }
-
-  private static class GerritSystemReader extends SystemReader {
-    SystemReader delegate;
-
-    GerritSystemReader(SystemReader delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getHostname() {
-      return delegate.getHostname();
-    }
-
-    @Override
-    public String getenv(String variable) {
-      return delegate.getenv(variable);
-    }
-
-    @Override
-    public String getProperty(String key) {
-      return delegate.getProperty(key);
-    }
-
-    @Override
-    public FileBasedConfig openUserConfig(Config parent, FS fs) {
-      return delegate.openUserConfig(parent, fs);
-    }
-
-    @Override
-    public FileBasedConfig openSystemConfig(Config parent, FS fs) {
-      return delegate.openSystemConfig(parent, fs);
-    }
-
-    @Override
-    public long getCurrentTime() {
-      return currentMillisSupplier.getAsLong();
-    }
-
-    @Override
-    public int getTimezone(long when) {
-      return delegate.getTimezone(when);
-    }
-  }
-
-  private TimeUtil() {}
-}
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
new file mode 100644
index 0000000..1816d50
--- /dev/null
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -0,0 +1,43 @@
+// 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;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * A marker to say a method/type/field is added or is increased to public solely because it is
+ * called from inside a project or an organisation using Gerrit.
+ */
+@Target({METHOD, TYPE, FIELD})
+@Retention(RUNTIME)
+public @interface UsedAt {
+  /** Enumeration of projects that call a method/type/field. */
+  enum Project {
+    GOOGLE,
+    PLUGIN_CHECKS,
+    PLUGIN_DELETE_PROJECT,
+    PLUGIN_SERVICEUSER,
+    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
+  }
+
+  /** Reference to the project that uses the method annotated with this annotation. */
+  Project value();
+}
diff --git a/java/com/google/gerrit/common/Version.java b/java/com/google/gerrit/common/Version.java
index b8d3b67..6197be5 100644
--- a/java/com/google/gerrit/common/Version.java
+++ b/java/com/google/gerrit/common/Version.java
@@ -16,7 +16,6 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.annotations.GwtIncompatible;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.flogger.FluentLogger;
 import java.io.BufferedReader;
@@ -24,7 +23,6 @@
 import java.io.InputStream;
 import java.io.InputStreamReader;
 
-@GwtIncompatible("Unemulated com.google.gerrit.common.Version")
 public class Version {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
diff --git a/java/com/google/gerrit/common/audit/Audit.java b/java/com/google/gerrit/common/audit/Audit.java
deleted file mode 100644
index 25e4caf..0000000
--- a/java/com/google/gerrit/common/audit/Audit.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.audit;
-
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-/**
- * Audit annotation for JSON/RPC interfaces.
- *
- * <p>Flag with @Audit all the JSON/RPC methods to be traced in audit-trail and submitted to the
- * AuditService.
- */
-@Retention(RetentionPolicy.RUNTIME)
-@Target({ElementType.METHOD})
-public @interface Audit {
-  String action() default "";
-
-  /** List of positions of parameters to be obfuscated in audit-trail (i.e. passwords) */
-  int[] obfuscate() default {};
-}
diff --git a/java/com/google/gerrit/common/auth/SignInRequired.java b/java/com/google/gerrit/common/auth/SignInRequired.java
deleted file mode 100644
index bcebf5c..0000000
--- a/java/com/google/gerrit/common/auth/SignInRequired.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.auth;
-
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-/**
- * Annotation indicating a service method requires a current user.
- *
- * <p>If there is no current user then {@code com.google.gerrit.common.errors.NotSignedInException}
- * will be given to the callback's onFailure method.
- */
-@Retention(RetentionPolicy.RUNTIME)
-@Target(ElementType.METHOD)
-public @interface SignInRequired {}
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
index 82dc620..3670e96 100644
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ b/java/com/google/gerrit/common/data/AccessSection.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.common.data;
 
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.ArrayList;
@@ -22,26 +25,43 @@
 import java.util.Set;
 
 /** Portion of a {@link Project} describing access rules. */
-public class AccessSection extends RefConfigSection implements Comparable<AccessSection> {
+public final class AccessSection implements Comparable<AccessSection> {
   /** Special name given to the global capabilities; not a valid reference. */
   public static final String GLOBAL_CAPABILITIES = "GLOBAL_CAPABILITIES";
+  /** Pattern that matches all references in a project. */
+  public static final String ALL = "refs/*";
 
-  protected List<Permission> permissions;
+  /** Pattern that matches all branches in a project. */
+  public static final String HEADS = "refs/heads/*";
 
-  protected AccessSection() {}
+  /** Prefix that triggers a regular expression pattern. */
+  public static final String REGEX_PREFIX = "^";
 
-  public AccessSection(String refPattern) {
-    super(refPattern);
+  /** Name of the access section. It could be a ref pattern or something else. */
+  private String name;
+
+  private List<Permission> permissions;
+
+  public AccessSection(String name) {
+    this.name = name;
   }
 
-  public List<Permission> getPermissions() {
-    if (permissions == null) {
-      permissions = new ArrayList<>();
-    }
-    return permissions;
+  /** @return true if the name is likely to be a valid reference section name. */
+  public static boolean isValidRefSectionName(String name) {
+    return name.startsWith("refs/") || name.startsWith("^refs/");
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public ImmutableList<Permission> getPermissions() {
+    return permissions == null ? ImmutableList.of() : ImmutableList.copyOf(permissions);
   }
 
   public void setPermissions(List<Permission> list) {
+    requireNonNull(list);
+
     Set<String> names = new HashSet<>();
     for (Permission p : list) {
       if (!names.add(p.getName().toLowerCase())) {
@@ -49,7 +69,7 @@
       }
     }
 
-    permissions = list;
+    permissions = new ArrayList<>(list);
   }
 
   @Nullable
@@ -59,13 +79,21 @@
 
   @Nullable
   public Permission getPermission(String name, boolean create) {
-    for (Permission p : getPermissions()) {
-      if (p.getName().equalsIgnoreCase(name)) {
-        return p;
+    requireNonNull(name);
+
+    if (permissions != null) {
+      for (Permission p : permissions) {
+        if (p.getName().equalsIgnoreCase(name)) {
+          return p;
+        }
       }
     }
 
     if (create) {
+      if (permissions == null) {
+        permissions = new ArrayList<>();
+      }
+
       Permission p = new Permission(name);
       permissions.add(p);
       return p;
@@ -75,7 +103,12 @@
   }
 
   public void addPermission(Permission permission) {
-    List<Permission> permissions = getPermissions();
+    requireNonNull(permission);
+
+    if (permissions == null) {
+      permissions = new ArrayList<>();
+    }
+
     for (Permission p : permissions) {
       if (p.getName().equalsIgnoreCase(permission.getName())) {
         throw new IllegalArgumentException();
@@ -86,18 +119,21 @@
   }
 
   public void remove(Permission permission) {
-    if (permission != null) {
-      removePermission(permission.getName());
-    }
+    requireNonNull(permission);
+    removePermission(permission.getName());
   }
 
   public void removePermission(String name) {
+    requireNonNull(name);
+
     if (permissions != null) {
       permissions.removeIf(permission -> name.equalsIgnoreCase(permission.getName()));
     }
   }
 
   public void mergeFrom(AccessSection section) {
+    requireNonNull(section);
+
     for (Permission src : section.getPermissions()) {
       Permission dst = getPermission(src.getName());
       if (dst != null) {
@@ -127,10 +163,27 @@
 
   @Override
   public boolean equals(Object obj) {
-    if (!super.equals(obj) || !(obj instanceof AccessSection)) {
+    if (!(obj instanceof AccessSection)) {
+      return false;
+    }
+
+    AccessSection other = (AccessSection) obj;
+    if (!getName().equals(other.getName())) {
       return false;
     }
     return new HashSet<>(getPermissions())
         .equals(new HashSet<>(((AccessSection) obj).getPermissions()));
   }
+
+  @Override
+  public int hashCode() {
+    int hashCode = super.hashCode();
+    if (permissions != null) {
+      for (Permission permission : permissions) {
+        hashCode += permission.hashCode();
+      }
+    }
+    hashCode += getName().hashCode();
+    return hashCode;
+  }
 }
diff --git a/java/com/google/gerrit/common/data/CommentDetail.java b/java/com/google/gerrit/common/data/CommentDetail.java
index ed7c79b..2eb97cf 100644
--- a/java/com/google/gerrit/common/data/CommentDetail.java
+++ b/java/com/google/gerrit/common/data/CommentDetail.java
@@ -42,7 +42,7 @@
   protected CommentDetail() {}
 
   public void include(Change.Id changeId, Comment p) {
-    PatchSet.Id psId = new PatchSet.Id(changeId, p.key.patchSetId);
+    PatchSet.Id psId = PatchSet.id(changeId, p.key.patchSetId);
     if (p.side == 0) {
       if (idA == null && idB.equals(psId)) {
         a.add(p);
@@ -84,7 +84,7 @@
 
   private static List<Comment> get(Map<Integer, List<Comment>> m, int i) {
     List<Comment> r = m.get(i);
-    return r != null ? orderComments(r) : Collections.<Comment>emptyList();
+    return r != null ? orderComments(r) : Collections.emptyList();
   }
 
   /**
diff --git a/java/com/google/gerrit/common/data/ContributorAgreement.java b/java/com/google/gerrit/common/data/ContributorAgreement.java
index 2f8755e..a6e8cdd 100644
--- a/java/com/google/gerrit/common/data/ContributorAgreement.java
+++ b/java/com/google/gerrit/common/data/ContributorAgreement.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 
 /** Portion of a {@link Project} describing a single contributor agreement. */
@@ -26,6 +25,8 @@
   protected List<PermissionRule> accepted;
   protected GroupReference autoVerify;
   protected String agreementUrl;
+  protected List<String> excludeProjectsRegexes;
+  protected List<String> matchProjectsRegexes;
 
   protected ContributorAgreement() {}
 
@@ -76,6 +77,28 @@
     this.agreementUrl = agreementUrl;
   }
 
+  public List<String> getExcludeProjectsRegexes() {
+    if (excludeProjectsRegexes == null) {
+      excludeProjectsRegexes = new ArrayList<>();
+    }
+    return excludeProjectsRegexes;
+  }
+
+  public void setExcludeProjectsRegexes(List<String> excludeProjectsRegexes) {
+    this.excludeProjectsRegexes = excludeProjectsRegexes;
+  }
+
+  public List<String> getMatchProjectsRegexes() {
+    if (matchProjectsRegexes == null) {
+      matchProjectsRegexes = new ArrayList<>();
+    }
+    return matchProjectsRegexes;
+  }
+
+  public void setMatchProjectsRegexes(List<String> matchProjectsRegexes) {
+    this.matchProjectsRegexes = matchProjectsRegexes;
+  }
+
   @Override
   public int compareTo(ContributorAgreement o) {
     return getName().compareTo(o.getName());
@@ -85,15 +108,4 @@
   public String toString() {
     return "ContributorAgreement[" + getName() + "]";
   }
-
-  public ContributorAgreement forUi() {
-    ContributorAgreement ca = new ContributorAgreement(name);
-    ca.description = description;
-    ca.accepted = Collections.emptyList();
-    if (autoVerify != null) {
-      ca.autoVerify = new GroupReference();
-    }
-    ca.agreementUrl = agreementUrl;
-    return ca;
-  }
 }
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index e613d21..fbe1deb 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -22,7 +22,7 @@
 
 /** Server wide capabilities. Represented as {@link Permission} objects. */
 public class GlobalCapability {
-  /** Ability to access the database (with gsql). */
+  /** Ability to view code review metadata refs in repositories. */
   public static final String ACCESS_DATABASE = "accessDatabase";
 
   /**
@@ -90,6 +90,9 @@
   /** Default result limit per executed query. */
   public static final int DEFAULT_MAX_QUERY_LIMIT = 500;
 
+  /** Can impersonate any user to see which refs they can read. */
+  public static final String READ_AS = "readAs";
+
   /** Ability to impersonate another user. */
   public static final String RUN_AS = "runAs";
 
@@ -138,6 +141,7 @@
     NAMES_ALL.add(MODIFY_ACCOUNT);
     NAMES_ALL.add(PRIORITY);
     NAMES_ALL.add(QUERY_LIMIT);
+    NAMES_ALL.add(READ_AS);
     NAMES_ALL.add(RUN_AS);
     NAMES_ALL.add(RUN_GC);
     NAMES_ALL.add(STREAM_EVENTS);
diff --git a/java/com/google/gerrit/common/data/GroupReference.java b/java/com/google/gerrit/common/data/GroupReference.java
index dc22d62..f0ca018 100644
--- a/java/com/google/gerrit/common/data/GroupReference.java
+++ b/java/com/google/gerrit/common/data/GroupReference.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.common.data;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
@@ -22,11 +24,6 @@
 
   private static final String PREFIX = "group ";
 
-  /** @return a new reference to the given group description. */
-  public static GroupReference forGroup(AccountGroup group) {
-    return new GroupReference(group.getGroupUUID(), group.getName());
-  }
-
   public static GroupReference forGroup(GroupDescription.Basic group) {
     return new GroupReference(group.getGroupUUID(), group.getName());
   }
@@ -48,16 +45,33 @@
 
   protected GroupReference() {}
 
+  /**
+   * Create a group reference.
+   *
+   * @param uuid UUID of the group, must not be {@code null}
+   * @param name the group name, must not be {@code null}
+   */
   public GroupReference(AccountGroup.UUID uuid, String name) {
-    setUUID(uuid);
+    setUUID(requireNonNull(uuid));
     setName(name);
   }
 
-  public AccountGroup.UUID getUUID() {
-    return uuid != null ? new AccountGroup.UUID(uuid) : null;
+  /**
+   * Create a group reference where the group's name couldn't be resolved.
+   *
+   * @param name the group name, must not be {@code null}
+   */
+  public GroupReference(String name) {
+    setUUID(null);
+    setName(name);
   }
 
-  public void setUUID(AccountGroup.UUID newUUID) {
+  @Nullable
+  public AccountGroup.UUID getUUID() {
+    return uuid != null ? AccountGroup.uuid(uuid) : null;
+  }
+
+  public void setUUID(@Nullable AccountGroup.UUID newUUID) {
     uuid = newUUID != null ? newUUID.get() : null;
   }
 
@@ -66,6 +80,9 @@
   }
 
   public void setName(String newName) {
+    if (newName == null) {
+      throw new NullPointerException();
+    }
     this.name = newName;
   }
 
@@ -75,7 +92,11 @@
   }
 
   private static String uuid(GroupReference a) {
-    return a.getUUID() != null ? a.getUUID().get() : "?";
+    if (a.getUUID() != null && a.getUUID().get() != null) {
+      return a.getUUID().get();
+    }
+
+    return "?";
   }
 
   @Override
diff --git a/java/com/google/gerrit/common/data/HostPageData.java b/java/com/google/gerrit/common/data/HostPageData.java
deleted file mode 100644
index 517c520..0000000
--- a/java/com/google/gerrit/common/data/HostPageData.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import java.util.Date;
-import java.util.List;
-
-/** Data sent as part of the host page, to bootstrap the UI. */
-public class HostPageData {
-  /**
-   * Name of the cookie in which the XSRF token is sent from the server to the client during host
-   * page bootstrapping.
-   */
-  public static final String XSRF_COOKIE_NAME = "XSRF_TOKEN";
-
-  /**
-   * Name of the HTTP header in which the client must send the XSRF token to the server on each
-   * request.
-   */
-  public static final String XSRF_HEADER_NAME = "X-Gerrit-Auth";
-
-  public String version;
-  public DiffPreferencesInfo accountDiffPref;
-  public Theme theme;
-  public List<String> plugins;
-  public List<Message> messages;
-  public Integer pluginsLoadTimeout;
-  public boolean isNoteDbEnabled;
-  public boolean canLoadInIFrame;
-
-  public static class Theme {
-    public String backgroundColor;
-    public String topMenuColor;
-    public String textColor;
-    public String trimColor;
-    public String selectionColor;
-    public String changeTableOutdatedColor;
-    public String tableOddRowColor;
-    public String tableEvenRowColor;
-  }
-
-  public static class Message {
-    public String id;
-    public Date redisplay;
-    public String html;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/LabelFunction.java b/java/com/google/gerrit/common/data/LabelFunction.java
index 7d13c70..3c00cf5 100644
--- a/java/com/google/gerrit/common/data/LabelFunction.java
+++ b/java/com/google/gerrit/common/data/LabelFunction.java
@@ -98,18 +98,18 @@
     }
 
     for (PatchSetApproval a : approvals) {
-      if (a.getValue() == 0) {
+      if (a.value() == 0) {
         continue;
       }
 
       if (isBlock && labelType.isMaxNegative(a)) {
-        submitRecordLabel.appliedBy = a.getAccountId();
+        submitRecordLabel.appliedBy = a.accountId();
         submitRecordLabel.status = SubmitRecord.Label.Status.REJECT;
         return submitRecordLabel;
       }
 
       if (labelType.isMaxPositive(a) || !requiresMaxValue) {
-        submitRecordLabel.appliedBy = a.getAccountId();
+        submitRecordLabel.appliedBy = a.accountId();
 
         submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
         if (isRequired) {
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index 7bfd22e..25b8d19 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,13 +88,13 @@
       v++;
       result.add(values.get(i++));
     }
+    result.trimToSize();
     return Collections.unmodifiableList(result);
   }
 
   protected String name;
 
-  // String rather than LabelFunction for backwards compatibility with GWT JSON interface.
-  protected String functionName;
+  protected LabelFunction function;
 
   protected boolean copyMinScore;
   protected boolean copyMaxScore;
@@ -109,6 +103,7 @@
   protected boolean copyAllScoresIfNoCodeChange;
   protected boolean copyAllScoresIfNoChange;
   protected boolean allowPostSubmit;
+  protected boolean ignoreSelfApproval;
   protected short defaultValue;
 
   protected List<LabelValue> values;
@@ -117,7 +112,6 @@
 
   private transient boolean canOverride;
   private transient List<String> refPatterns;
-  private transient List<Integer> intList;
   private transient Map<Short, LabelValue> byValue;
 
   protected LabelType() {}
@@ -128,7 +122,7 @@
     values = sortValues(valueList);
     defaultValue = 0;
 
-    functionName = LabelFunction.MAX_WITH_BLOCK.getFunctionName();
+    function = LabelFunction.MAX_WITH_BLOCK;
 
     maxNegative = Short.MIN_VALUE;
     maxPositive = Short.MAX_VALUE;
@@ -148,6 +142,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() {
@@ -155,22 +155,15 @@
   }
 
   public boolean matches(PatchSetApproval psa) {
-    return psa.getLabelId().get().equalsIgnoreCase(name);
+    return psa.labelId().get().equalsIgnoreCase(name);
   }
 
   public LabelFunction getFunction() {
-    if (functionName == null) {
-      return null;
-    }
-    Optional<LabelFunction> f = LabelFunction.parse(functionName);
-    if (!f.isPresent()) {
-      throw new IllegalStateException("Unsupported functionName: " + functionName);
-    }
-    return f.get();
+    return function;
   }
 
   public void setFunction(@Nullable LabelFunction function) {
-    this.functionName = function != null ? function.getFunctionName() : null;
+    this.function = function;
   }
 
   public boolean canOverride() {
@@ -193,8 +186,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() {
@@ -273,46 +279,23 @@
   }
 
   public boolean isMaxNegative(PatchSetApproval ca) {
-    return maxNegative == ca.getValue();
+    return maxNegative == ca.value();
   }
 
   public boolean isMaxPositive(PatchSetApproval ca) {
-    return maxPositive == ca.getValue();
+    return maxPositive == ca.value();
   }
 
   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;
+    return byValue.get(ca.value());
   }
 
   public LabelId getLabelId() {
-    return new LabelId(name);
+    return LabelId.create(name);
   }
 
   @Override
diff --git a/java/com/google/gerrit/common/data/LabelValue.java b/java/com/google/gerrit/common/data/LabelValue.java
index 811e751..c0ba781 100644
--- a/java/com/google/gerrit/common/data/LabelValue.java
+++ b/java/com/google/gerrit/common/data/LabelValue.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.common.data;
 
+import java.util.Objects;
+
 public class LabelValue {
   public static String formatValue(short value) {
     if (value < 0) {
@@ -56,6 +58,20 @@
   }
 
   @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof LabelValue)) {
+      return false;
+    }
+    LabelValue v = (LabelValue) o;
+    return value == v.value && Objects.equals(text, v.text);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(value, text);
+  }
+
+  @Override
   public String toString() {
     return format();
   }
diff --git a/java/com/google/gerrit/common/data/ParameterizedString.java b/java/com/google/gerrit/common/data/ParameterizedString.java
index 28e47ee..84bb535 100644
--- a/java/com/google/gerrit/common/data/ParameterizedString.java
+++ b/java/com/google/gerrit/common/data/ParameterizedString.java
@@ -40,7 +40,7 @@
   private ParameterizedString(Constant c) {
     pattern = c.text;
     rawPattern = c.text;
-    patternOps = Collections.<Format>singletonList(c);
+    patternOps = Collections.singletonList(c);
     parameters = Collections.emptyList();
   }
 
@@ -60,7 +60,7 @@
         break;
       }
 
-      raw.append(pattern.substring(i, b));
+      raw.append(pattern, i, b);
       ops.add(new Constant(pattern.substring(i, b)));
 
       // "${parameter[.functions...]}" -> "parameter[.functions...]"
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
index dff30d7..3ba0ba7 100644
--- a/java/com/google/gerrit/common/data/Permission.java
+++ b/java/com/google/gerrit/common/data/Permission.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.common.collect.ImmutableList;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -27,6 +28,7 @@
   public static final String CREATE_SIGNED_TAG = "createSignedTag";
   public static final String CREATE_TAG = "createTag";
   public static final String DELETE = "delete";
+  public static final String DELETE_CHANGES = "deleteChanges";
   public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
   public static final String EDIT_ASSIGNEE = "editAssignee";
   public static final String EDIT_HASHTAGS = "editHashtags";
@@ -44,6 +46,7 @@
   public static final String REMOVE_REVIEWER = "removeReviewer";
   public static final String SUBMIT = "submit";
   public static final String SUBMIT_AS = "submitAs";
+  public static final String TOGGLE_WORK_IN_PROGRESS_STATE = "toggleWipState";
   public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
 
   private static final List<String> NAMES_LC;
@@ -58,6 +61,7 @@
     NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
     NAMES_LC.add(CREATE_TAG.toLowerCase());
     NAMES_LC.add(DELETE.toLowerCase());
+    NAMES_LC.add(DELETE_CHANGES.toLowerCase());
     NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
     NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
@@ -75,6 +79,7 @@
     NAMES_LC.add(REMOVE_REVIEWER.toLowerCase());
     NAMES_LC.add(SUBMIT.toLowerCase());
     NAMES_LC.add(SUBMIT_AS.toLowerCase());
+    NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase());
     NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
 
     LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
@@ -155,13 +160,12 @@
     exclusiveGroup = newExclusiveGroup;
   }
 
-  public List<PermissionRule> getRules() {
-    initRules();
-    return rules;
+  public ImmutableList<PermissionRule> getRules() {
+    return rules == null ? ImmutableList.of() : ImmutableList.copyOf(rules);
   }
 
   public void setRules(List<PermissionRule> list) {
-    rules = list;
+    rules = new ArrayList<>(list);
   }
 
   public void add(PermissionRule rule) {
@@ -181,6 +185,12 @@
     }
   }
 
+  public void clearRules() {
+    if (rules != null) {
+      rules.clear();
+    }
+  }
+
   public PermissionRule getRule(GroupReference group) {
     return getRule(group, false);
   }
diff --git a/java/com/google/gerrit/common/data/PermissionRange.java b/java/com/google/gerrit/common/data/PermissionRange.java
index 8876c02..97c3731 100644
--- a/java/com/google/gerrit/common/data/PermissionRange.java
+++ b/java/com/google/gerrit/common/data/PermissionRange.java
@@ -17,6 +17,10 @@
 import java.util.ArrayList;
 import java.util.List;
 
+/**
+ * Represents a closed interval [min, max] with a name. The special value [0, 0] is understood to be
+ * the empty range.
+ */
 public class PermissionRange implements Comparable<PermissionRange> {
   public static class WithDefaults extends PermissionRange {
     protected int defaultMin;
@@ -70,8 +74,8 @@
       this.min = min;
       this.max = max;
     } else {
-      this.min = max;
-      this.max = min;
+      this.min = 0;
+      this.max = 0;
     }
   }
 
diff --git a/java/com/google/gerrit/common/data/PermissionRule.java b/java/com/google/gerrit/common/data/PermissionRule.java
index c50af5c..8ab0a55 100644
--- a/java/com/google/gerrit/common/data/PermissionRule.java
+++ b/java/com/google/gerrit/common/data/PermissionRule.java
@@ -66,27 +66,27 @@
     action = Action.BLOCK;
   }
 
-  public Boolean getForce() {
+  public boolean getForce() {
     return force;
   }
 
-  public void setForce(Boolean newForce) {
+  public void setForce(boolean newForce) {
     force = newForce;
   }
 
-  public Integer getMin() {
+  public int getMin() {
     return min;
   }
 
-  public void setMin(Integer min) {
+  public void setMin(int min) {
     this.min = min;
   }
 
-  public void setMax(Integer max) {
+  public void setMax(int max) {
     this.max = max;
   }
 
-  public Integer getMax() {
+  public int getMax() {
     return max;
   }
 
@@ -266,7 +266,7 @@
   }
 
   public boolean hasRange() {
-    return (!(getMin() == null || getMin() == 0)) || (!(getMax() == null || getMax() == 0));
+    return getMin() != 0 || getMax() != 0;
   }
 
   public static int parseInt(String value) {
diff --git a/java/com/google/gerrit/common/data/ProjectAccess.java b/java/com/google/gerrit/common/data/ProjectAccess.java
deleted file mode 100644
index ea17525..0000000
--- a/java/com/google/gerrit/common/data/ProjectAccess.java
+++ /dev/null
@@ -1,142 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-public class ProjectAccess {
-  protected Project.NameKey projectName;
-  protected String revision;
-  protected Project.NameKey inheritsFrom;
-  protected List<AccessSection> local;
-  protected Set<String> ownerOf;
-  protected boolean isConfigVisible;
-  protected boolean canUpload;
-  protected LabelTypes labelTypes;
-  protected Map<String, String> capabilities;
-  protected Map<AccountGroup.UUID, GroupInfo> groupInfo;
-  protected List<WebLinkInfoCommon> fileHistoryLinks;
-
-  public ProjectAccess() {}
-
-  public Project.NameKey getProjectName() {
-    return projectName;
-  }
-
-  public void setProjectName(Project.NameKey projectName) {
-    this.projectName = projectName;
-  }
-
-  public String getRevision() {
-    return revision;
-  }
-
-  public void setRevision(String name) {
-    revision = name;
-  }
-
-  public Project.NameKey getInheritsFrom() {
-    return inheritsFrom;
-  }
-
-  public void setInheritsFrom(Project.NameKey name) {
-    inheritsFrom = name;
-  }
-
-  public List<AccessSection> getLocal() {
-    return local;
-  }
-
-  public void setLocal(List<AccessSection> as) {
-    local = as;
-  }
-
-  public AccessSection getLocal(String name) {
-    for (AccessSection s : local) {
-      if (s.getName().equals(name)) {
-        return s;
-      }
-    }
-    return null;
-  }
-
-  public boolean isOwnerOf(AccessSection section) {
-    return isOwnerOf(section.getName());
-  }
-
-  public boolean isOwnerOf(String name) {
-    return ownerOf.contains(name);
-  }
-
-  public Set<String> getOwnerOf() {
-    return ownerOf;
-  }
-
-  public void setOwnerOf(Set<String> refs) {
-    ownerOf = refs;
-  }
-
-  public boolean isConfigVisible() {
-    return isConfigVisible;
-  }
-
-  public void setConfigVisible(boolean isConfigVisible) {
-    this.isConfigVisible = isConfigVisible;
-  }
-
-  public boolean canUpload() {
-    return canUpload;
-  }
-
-  public void setCanUpload(boolean canUpload) {
-    this.canUpload = canUpload;
-  }
-
-  public LabelTypes getLabelTypes() {
-    return labelTypes;
-  }
-
-  public void setLabelTypes(LabelTypes labelTypes) {
-    this.labelTypes = labelTypes;
-  }
-
-  public Map<String, String> getCapabilities() {
-    return capabilities;
-  }
-
-  public void setCapabilities(Map<String, String> capabilities) {
-    this.capabilities = capabilities;
-  }
-
-  public Map<AccountGroup.UUID, GroupInfo> getGroupInfo() {
-    return groupInfo;
-  }
-
-  public void setGroupInfo(Map<AccountGroup.UUID, GroupInfo> m) {
-    groupInfo = m;
-  }
-
-  public void setFileHistoryLinks(List<WebLinkInfoCommon> links) {
-    fileHistoryLinks = links;
-  }
-
-  public List<WebLinkInfoCommon> getFileHistoryLinks() {
-    return fileHistoryLinks;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/ProjectAdminService.java b/java/com/google/gerrit/common/data/ProjectAdminService.java
deleted file mode 100644
index e9a7c15..0000000
--- a/java/com/google/gerrit/common/data/ProjectAdminService.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 com.google.gerrit.common.audit.Audit;
-import com.google.gerrit.common.auth.SignInRequired;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-import java.util.List;
-
-@RpcImpl(version = Version.V2_0)
-public interface ProjectAdminService extends RemoteJsonService {
-  void projectAccess(Project.NameKey projectName, AsyncCallback<ProjectAccess> callback);
-
-  @Audit
-  @SignInRequired
-  void changeProjectAccess(
-      Project.NameKey projectName,
-      String baseRevision,
-      String message,
-      List<AccessSection> sections,
-      Project.NameKey parentProjectName,
-      AsyncCallback<ProjectAccess> callback);
-
-  @SignInRequired
-  void reviewProjectAccess(
-      Project.NameKey projectName,
-      String baseRevision,
-      String message,
-      List<AccessSection> sections,
-      Project.NameKey parentProjectName,
-      AsyncCallback<Change.Id> callback);
-}
diff --git a/java/com/google/gerrit/common/data/RefConfigSection.java b/java/com/google/gerrit/common/data/RefConfigSection.java
deleted file mode 100644
index 663379a..0000000
--- a/java/com/google/gerrit/common/data/RefConfigSection.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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;
-
-public abstract class RefConfigSection {
-  /** Pattern that matches all references in a project. */
-  public static final String ALL = "refs/*";
-
-  /** Pattern that matches all branches in a project. */
-  public static final String HEADS = "refs/heads/*";
-
-  /** Prefix that triggers a regular expression pattern. */
-  public static final String REGEX_PREFIX = "^";
-
-  /** @return true if the name is likely to be a valid reference section name. */
-  public static boolean isValid(String name) {
-    return name.startsWith("refs/") || name.startsWith("^refs/");
-  }
-
-  protected String name;
-
-  public RefConfigSection() {}
-
-  public RefConfigSection(String name) {
-    setName(name);
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String name) {
-    this.name = name;
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof RefConfigSection)) {
-      return false;
-    }
-    return name.equals(((RefConfigSection) obj).name);
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SshHostKey.java b/java/com/google/gerrit/common/data/SshHostKey.java
deleted file mode 100644
index 05f1611..0000000
--- a/java/com/google/gerrit/common/data/SshHostKey.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-/** Description of the SSH daemon host key used by Gerrit. */
-public class SshHostKey {
-  protected String hostIdent;
-  protected String hostKey;
-  protected String fingerprint;
-
-  protected SshHostKey() {}
-
-  public SshHostKey(String hi, String hk, String fp) {
-    hostIdent = hi;
-    hostKey = hk;
-    fingerprint = fp;
-  }
-
-  /** @return host name string, to appear in a known_hosts file. */
-  public String getHostIdent() {
-    return hostIdent;
-  }
-
-  /** @return base 64 encoded host key string, starting with key type. */
-  public String getHostKey() {
-    return hostKey;
-  }
-
-  /** @return the key fingerprint, as displayed by a connecting client. */
-  public String getFingerprint() {
-    return fingerprint;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitRecord.java b/java/com/google/gerrit/common/data/SubmitRecord.java
index 8638d6d..22861b2 100644
--- a/java/com/google/gerrit/common/data/SubmitRecord.java
+++ b/java/com/google/gerrit/common/data/SubmitRecord.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.common.annotations.GwtIncompatible;
 import com.google.gerrit.reviewdb.client.Account;
 import java.util.Collection;
 import java.util.List;
@@ -65,7 +64,7 @@
 
   public Status status;
   public List<Label> labels;
-  @GwtIncompatible public List<SubmitRequirement> requirements;
+  public List<SubmitRequirement> requirements;
   public String errorMessage;
 
   public static class Label {
@@ -136,7 +135,6 @@
     }
   }
 
-  @GwtIncompatible
   @Override
   public String toString() {
     StringBuilder sb = new StringBuilder();
@@ -164,7 +162,6 @@
     return sb.toString();
   }
 
-  @GwtIncompatible
   @Override
   public boolean equals(Object o) {
     if (o instanceof SubmitRecord) {
@@ -177,7 +174,6 @@
     return false;
   }
 
-  @GwtIncompatible
   @Override
   public int hashCode() {
     return Objects.hash(status, labels, errorMessage, requirements);
diff --git a/java/com/google/gerrit/common/data/SubmitRequirement.java b/java/com/google/gerrit/common/data/SubmitRequirement.java
index 0a8d5ac0..66e647d 100644
--- a/java/com/google/gerrit/common/data/SubmitRequirement.java
+++ b/java/com/google/gerrit/common/data/SubmitRequirement.java
@@ -14,15 +14,14 @@
 
 package com.google.gerrit.common.data;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
-import com.google.common.annotations.GwtIncompatible;
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
 import java.util.Map;
 
 /** Describes a requirement to submit a change. */
-@GwtIncompatible
 @AutoValue
 @AutoValue.CopyAnnotations
 public abstract class SubmitRequirement {
@@ -49,7 +48,7 @@
 
     public SubmitRequirement build() {
       SubmitRequirement requirement = autoBuild();
-      Preconditions.checkState(
+      checkState(
           validateType(requirement.type()),
           "SubmitRequirement's type contains non alphanumerical symbols.");
       return requirement;
diff --git a/java/com/google/gerrit/common/data/SubscribeSection.java b/java/com/google/gerrit/common/data/SubscribeSection.java
index a3468d7..60ac12a 100644
--- a/java/com/google/gerrit/common/data/SubscribeSection.java
+++ b/java/com/google/gerrit/common/data/SubscribeSection.java
@@ -14,8 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.common.annotations.GwtIncompatible;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -24,7 +23,6 @@
 import org.eclipse.jgit.transport.RefSpec;
 
 /** Portion of a {@link Project} describing superproject subscription rules. */
-@GwtIncompatible("Unemulated org.eclipse.jgit.transport.RefSpec")
 public class SubscribeSection {
 
   private final List<RefSpec> multiMatchRefSpecs;
@@ -62,14 +60,14 @@
    * @param branch the branch to check
    * @return if the branch could trigger a superproject update
    */
-  public boolean appliesTo(Branch.NameKey branch) {
+  public boolean appliesTo(BranchNameKey branch) {
     for (RefSpec r : matchingRefSpecs) {
-      if (r.matchSource(branch.get())) {
+      if (r.matchSource(branch.branch())) {
         return true;
       }
     }
     for (RefSpec r : multiMatchRefSpecs) {
-      if (r.matchSource(branch.get())) {
+      if (r.matchSource(branch.branch())) {
         return true;
       }
     }
diff --git a/java/com/google/gerrit/common/data/SystemInfoService.java b/java/com/google/gerrit/common/data/SystemInfoService.java
deleted file mode 100644
index d88b638..0000000
--- a/java/com/google/gerrit/common/data/SystemInfoService.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 com.google.gwtjsonrpc.common.AllowCrossSiteRequest;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-import com.google.gwtjsonrpc.common.VoidResult;
-import java.util.List;
-
-@RpcImpl(version = Version.V2_0)
-public interface SystemInfoService extends RemoteJsonService {
-  @AllowCrossSiteRequest
-  void daemonHostKeys(AsyncCallback<List<SshHostKey>> callback);
-
-  void clientError(String message, AsyncCallback<VoidResult> callback);
-}
diff --git a/java/com/google/gerrit/common/data/WebLinkInfoCommon.java b/java/com/google/gerrit/common/data/WebLinkInfoCommon.java
deleted file mode 100644
index dd0a70a..0000000
--- a/java/com/google/gerrit/common/data/WebLinkInfoCommon.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-public class WebLinkInfoCommon {
-  public WebLinkInfoCommon() {}
-
-  public String name;
-  public String imageUrl;
-  public String url;
-  public String target;
-}
diff --git a/java/com/google/gerrit/common/data/testing/BUILD b/java/com/google/gerrit/common/data/testing/BUILD
index 3899e39..32815d5 100644
--- a/java/com/google/gerrit/common/data/testing/BUILD
+++ b/java/com/google/gerrit/common/data/testing/BUILD
@@ -1,6 +1,6 @@
 java_library(
     name = "common-data-test-util",
-    testonly = 1,
+    testonly = True,
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
diff --git a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
index 1988d66..8ac0de1 100644
--- a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
+++ b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
@@ -20,29 +20,33 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
-public class GroupReferenceSubject extends Subject<GroupReferenceSubject, GroupReference> {
+public class GroupReferenceSubject extends Subject {
 
   public static GroupReferenceSubject assertThat(GroupReference group) {
-    return assertAbout(GroupReferenceSubject::new).that(group);
+    return assertAbout(groupReferences()).that(group);
   }
 
+  public static Subject.Factory<GroupReferenceSubject, GroupReference> groupReferences() {
+    return GroupReferenceSubject::new;
+  }
+
+  private final GroupReference group;
+
   private GroupReferenceSubject(FailureMetadata metadata, GroupReference group) {
     super(metadata, group);
+    this.group = group;
   }
 
-  public ComparableSubject<?, AccountGroup.UUID> groupUuid() {
+  public ComparableSubject<AccountGroup.UUID> groupUuid() {
     isNotNull();
-    GroupReference group = actual();
-    return Truth.assertThat(group.getUUID()).named("groupUuid");
+    return check("getUUID()").that(group.getUUID());
   }
 
   public StringSubject name() {
     isNotNull();
-    GroupReference group = actual();
-    return Truth.assertThat(group.getName()).named("name");
+    return check("getName()").that(group.getName());
   }
 }
diff --git a/java/com/google/gerrit/common/errors/EmailException.java b/java/com/google/gerrit/common/errors/EmailException.java
deleted file mode 100644
index 635335d..0000000
--- a/java/com/google/gerrit/common/errors/EmailException.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-public class EmailException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Mail Error: ";
-
-  public EmailException(String msg) {
-    super(MESSAGE + msg);
-  }
-
-  public EmailException(String msg, Throwable why) {
-    super(MESSAGE + msg, why);
-  }
-}
diff --git a/java/com/google/gerrit/common/errors/InvalidNameException.java b/java/com/google/gerrit/common/errors/InvalidNameException.java
deleted file mode 100644
index d975aef..0000000
--- a/java/com/google/gerrit/common/errors/InvalidNameException.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-/** Error indicating the entity name is invalid as supplied. */
-public class InvalidNameException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Invalid Name";
-
-  public InvalidNameException() {
-    super(MESSAGE);
-  }
-
-  public InvalidNameException(String invalidName) {
-    super(MESSAGE + ": " + invalidName);
-  }
-}
diff --git a/java/com/google/gerrit/common/errors/InvalidSshKeyException.java b/java/com/google/gerrit/common/errors/InvalidSshKeyException.java
deleted file mode 100644
index 3398417..0000000
--- a/java/com/google/gerrit/common/errors/InvalidSshKeyException.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-/** Error indicating the SSH key string is invalid as supplied. */
-public class InvalidSshKeyException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Invalid SSH Key";
-
-  public InvalidSshKeyException() {
-    super(MESSAGE);
-  }
-}
diff --git a/java/com/google/gerrit/common/errors/NameAlreadyUsedException.java b/java/com/google/gerrit/common/errors/NameAlreadyUsedException.java
deleted file mode 100644
index ea20e2e..0000000
--- a/java/com/google/gerrit/common/errors/NameAlreadyUsedException.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.errors;
-
-/** Error indicating entity name is already taken by another entity. */
-public class NameAlreadyUsedException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Name Already Used: ";
-
-  public NameAlreadyUsedException(String name) {
-    super(MESSAGE + name);
-  }
-}
diff --git a/java/com/google/gerrit/common/errors/NoSuchAccountException.java b/java/com/google/gerrit/common/errors/NoSuchAccountException.java
deleted file mode 100644
index 90bf624..0000000
--- a/java/com/google/gerrit/common/errors/NoSuchAccountException.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-/** Error indicating the account requested doesn't exist. */
-public class NoSuchAccountException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Not Found: ";
-
-  public NoSuchAccountException(String who) {
-    super(MESSAGE + who);
-  }
-}
diff --git a/java/com/google/gerrit/common/errors/NoSuchEntityException.java b/java/com/google/gerrit/common/errors/NoSuchEntityException.java
deleted file mode 100644
index 1829c8b..0000000
--- a/java/com/google/gerrit/common/errors/NoSuchEntityException.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.errors;
-
-/** Error indicating the entity requested doesn't exist. */
-public class NoSuchEntityException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Not Found";
-
-  public NoSuchEntityException() {
-    super(MESSAGE);
-  }
-
-  public NoSuchEntityException(String message) {
-    super(message);
-  }
-}
diff --git a/java/com/google/gerrit/common/errors/NoSuchGroupException.java b/java/com/google/gerrit/common/errors/NoSuchGroupException.java
deleted file mode 100644
index 6e3db9e..0000000
--- a/java/com/google/gerrit/common/errors/NoSuchGroupException.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-
-/** Indicates the account group does not exist. */
-public class NoSuchGroupException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Group Not Found: ";
-
-  public NoSuchGroupException(AccountGroup.Id key) {
-    this(key, null);
-  }
-
-  public NoSuchGroupException(AccountGroup.UUID key) {
-    this(key, null);
-  }
-
-  public NoSuchGroupException(AccountGroup.Id key, Throwable why) {
-    super(MESSAGE + key.toString(), why);
-  }
-
-  public NoSuchGroupException(AccountGroup.UUID key, Throwable why) {
-    super(MESSAGE + key.toString(), why);
-  }
-
-  public NoSuchGroupException(AccountGroup.NameKey k, Throwable why) {
-    super(MESSAGE + k.toString(), why);
-  }
-
-  public NoSuchGroupException(String who) {
-    this(who, null);
-  }
-
-  public NoSuchGroupException(String who, Throwable why) {
-    super(MESSAGE + who, why);
-  }
-}
diff --git a/java/com/google/gerrit/common/errors/NotSignedInException.java b/java/com/google/gerrit/common/errors/NotSignedInException.java
deleted file mode 100644
index 65caf02..0000000
--- a/java/com/google/gerrit/common/errors/NotSignedInException.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.errors;
-
-/** Error stating the user must be signed-in in order to perform this action. */
-public class NotSignedInException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Not Signed In";
-
-  public NotSignedInException() {
-    super(MESSAGE);
-  }
-}
diff --git a/java/com/google/gerrit/common/errors/UpdateParentFailedException.java b/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
deleted file mode 100644
index 16d5240..0000000
--- a/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.errors;
-
-/** Error indicating that updating a parent project failed. */
-public class UpdateParentFailedException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Update Parent Project Failed: ";
-
-  public UpdateParentFailedException(String message, Throwable why) {
-    super(MESSAGE + ": " + message, why);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 3755faa..996bbfd 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -15,20 +15,24 @@
 package com.google.gerrit.elasticsearch;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.apache.commons.codec.binary.Base64.decodeBase64;
 
 import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.CharStreams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.builders.QueryBuilder;
 import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
 import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.Index;
@@ -36,8 +40,12 @@
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.ListResultSet;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.ResultSet;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gson.Gson;
@@ -46,9 +54,7 @@
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonParser;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
+import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
@@ -58,7 +64,6 @@
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -70,6 +75,7 @@
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.entity.ContentType;
 import org.apache.http.nio.entity.NStringEntity;
+import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
 
 abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
@@ -79,16 +85,25 @@
   protected static final String MAPPINGS = "mappings";
   protected static final String ORDER = "order";
   protected static final String SEARCH = "_search";
+  protected static final String SETTINGS = "settings";
 
   protected static <T> List<T> decodeProtos(
-      JsonObject doc, String fieldName, ProtobufCodec<T> codec) {
+      JsonObject doc, String fieldName, ProtoConverter<?, T> converter) {
     JsonArray field = doc.getAsJsonArray(fieldName);
     if (field == null) {
       return null;
     }
-    return FluentIterable.from(field)
-        .transform(i -> codec.decode(decodeBase64(i.toString())))
-        .toList();
+    return Streams.stream(field)
+        .map(JsonElement::toString)
+        .map(Base64::decodeBase64)
+        .map(bytes -> parseProtoFrom(bytes, converter))
+        .collect(toImmutableList());
+  }
+
+  protected static <P extends MessageLite, T> T parseProtoFrom(
+      byte[] bytes, ProtoConverter<P, T> converter) {
+    P message = Protos.parseUnchecked(converter.getParser(), bytes);
+    return converter.fromProto(message);
   }
 
   static String getContent(Response response) throws IOException {
@@ -96,13 +111,14 @@
     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);
       }
     }
     return content;
   }
 
+  private final ElasticConfiguration config;
   private final Schema<V> schema;
   private final SitePaths sitePaths;
   private final String indexNameRaw;
@@ -114,17 +130,18 @@
   protected final ElasticQueryBuilder queryBuilder;
 
   AbstractElasticIndex(
-      ElasticConfiguration cfg,
+      ElasticConfiguration config,
       SitePaths sitePaths,
       Schema<V> schema,
       ElasticRestClientProvider client,
       String indexName,
       String indexType) {
+    this.config = config;
     this.sitePaths = sitePaths;
     this.schema = schema;
     this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
     this.queryBuilder = new ElasticQueryBuilder();
-    this.indexName = cfg.getIndexName(indexName, schema.getVersion());
+    this.indexName = config.getIndexName(indexName, schema.getVersion());
     this.indexNameRaw = indexName;
     this.client = client;
     this.type = client.adapter().getType(indexType);
@@ -150,42 +167,45 @@
   }
 
   @Override
-  public void markReady(boolean ready) throws IOException {
+  public void markReady(boolean ready) {
     IndexUtils.setReady(sitePaths, indexNameRaw, schema.getVersion(), ready);
   }
 
   @Override
-  public void delete(K id) throws IOException {
+  public void delete(K id) {
     String uri = getURI(type, BULK);
-    Response response = postRequest(getDeleteActions(id), uri, getRefreshParam());
+    Response response = postRequest(uri, getDeleteActions(id), getRefreshParam());
     int statusCode = response.getStatusLine().getStatusCode();
     if (statusCode != HttpStatus.SC_OK) {
-      throw new IOException(
+      throw new StorageException(
           String.format("Failed to delete %s from index %s: %s", id, indexName, statusCode));
     }
   }
 
   @Override
-  public void deleteAll() throws IOException {
+  public void deleteAll() {
     // Delete the index, if it exists.
-    String endpoint = indexName + client.adapter().indicesExistParam();
-    Response response = client.get().performRequest("HEAD", endpoint);
+    String endpoint = indexName + client.adapter().indicesExistParams();
+    Response response = performRequest("HEAD", endpoint);
     int statusCode = response.getStatusLine().getStatusCode();
     if (statusCode == HttpStatus.SC_OK) {
-      response = client.get().performRequest("DELETE", indexName);
+      response = performRequest("DELETE", indexName);
       statusCode = response.getStatusLine().getStatusCode();
       if (statusCode != HttpStatus.SC_OK) {
-        throw new IOException(
+        throw new StorageException(
             String.format("Failed to delete index %s: %s", indexName, statusCode));
       }
     }
 
     // Recreate the index.
-    response = performRequest("PUT", getMappings(), indexName, Collections.emptyMap());
+    String indexCreationFields = concatJsonString(getSettings(client.adapter()), getMappings());
+    response =
+        performRequest(
+            "PUT", indexName + client.adapter().includeTypeNameParam(), indexCreationFields);
     statusCode = response.getStatusLine().getStatusCode();
     if (statusCode != HttpStatus.SC_OK) {
       String error = String.format("Failed to create index %s: %s", indexName, statusCode);
-      throw new IOException(error);
+      throw new StorageException(error);
     }
   }
 
@@ -193,6 +213,10 @@
 
   protected abstract String getMappings();
 
+  private String getSettings(ElasticQueryAdapter adapter) {
+    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting(config, adapter)));
+  }
+
   protected abstract String getId(V v);
 
   protected String getMappingsForSingleType(String candidateType, MappingProperties properties) {
@@ -200,10 +224,15 @@
   }
 
   protected String getMappingsFor(String type, MappingProperties properties) {
-    JsonObject mappingType = new JsonObject();
-    mappingType.add(type, gson.toJsonTree(properties));
     JsonObject mappings = new JsonObject();
-    mappings.add(MAPPINGS, gson.toJsonTree(mappingType));
+
+    if (client.adapter().omitType()) {
+      mappings.add(MAPPINGS, gson.toJsonTree(properties));
+    } else {
+      JsonObject mappingType = new JsonObject();
+      mappingType.add(type, gson.toJsonTree(properties));
+      mappings.add(MAPPINGS, gson.toJsonTree(mappingType));
+    }
     return gson.toJson(mappings);
   }
 
@@ -276,29 +305,61 @@
   protected JsonArray getSortArray(String idFieldName) {
     JsonObject properties = new JsonObject();
     properties.addProperty(ORDER, "asc");
-    client.adapter().setIgnoreUnmapped(properties);
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(idFieldName, properties, sortArray);
     return sortArray;
   }
 
-  protected String getURI(String type, String request) throws UnsupportedEncodingException {
-    String encodedType = URLEncoder.encode(type, UTF_8.toString());
-    String encodedIndexName = URLEncoder.encode(indexName, UTF_8.toString());
-    return encodedIndexName + "/" + encodedType + "/" + request;
+  protected String getURI(String type, String request) {
+    try {
+      String encodedIndexName = URLEncoder.encode(indexName, UTF_8.toString());
+      if (SEARCH.equals(request) && client.adapter().omitType()) {
+        return encodedIndexName + "/" + request;
+      }
+      String encodedTypeIfAny =
+          client.adapter().omitType() ? "" : "/" + URLEncoder.encode(type, UTF_8.toString());
+      return encodedIndexName + encodedTypeIfAny + "/" + request;
+    } catch (UnsupportedEncodingException e) {
+      throw new StorageException(e);
+    }
   }
 
-  protected Response postRequest(Object payload, String uri, Map<String, String> params)
-      throws IOException {
-    return performRequest("POST", payload, uri, params);
+  protected Response postRequest(String uri, Object payload) {
+    return performRequest("POST", uri, payload);
+  }
+
+  protected Response postRequest(String uri, Object payload, Map<String, String> params) {
+    return performRequest("POST", uri, payload, params);
+  }
+
+  private String concatJsonString(String target, String addition) {
+    return target.substring(0, target.length() - 1) + "," + addition.substring(1);
+  }
+
+  private Response performRequest(String method, String uri) {
+    return performRequest(method, uri, null);
+  }
+
+  private Response performRequest(String method, String uri, @Nullable Object payload) {
+    return performRequest(method, uri, payload, Collections.emptyMap());
   }
 
   private Response performRequest(
-      String method, Object payload, String uri, Map<String, String> params) throws IOException {
-    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);
+      String method, String uri, @Nullable Object payload, Map<String, String> params) {
+    Request request = new Request(method, uri.startsWith("/") ? uri : "/" + uri);
+    if (payload != null) {
+      String payloadStr = payload instanceof String ? (String) payload : payload.toString();
+      request.setEntity(new NStringEntity(payloadStr, ContentType.APPLICATION_JSON));
+    }
+    for (Map.Entry<String, String> entry : params.entrySet()) {
+      request.addParameter(entry.getKey(), entry.getValue());
+    }
+    try {
+      return client.get().performRequest(request);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
   }
 
   protected class ElasticQuerySource implements DataSource<V> {
@@ -326,21 +387,20 @@
     }
 
     @Override
-    public ResultSet<V> read() throws OrmException {
+    public ResultSet<V> read() {
       return readImpl((doc) -> AbstractElasticIndex.this.fromDocument(doc, opts.fields()));
     }
 
     @Override
-    public ResultSet<FieldBundle> readRaw() throws OrmException {
+    public ResultSet<FieldBundle> readRaw() {
       return readImpl(AbstractElasticIndex.this::toFieldBundle);
     }
 
-    private <T> ResultSet<T> readImpl(Function<JsonObject, T> mapper) throws OrmException {
+    private <T> ResultSet<T> readImpl(Function<JsonObject, T> mapper) {
       try {
-        List<T> results = Collections.emptyList();
         String uri = getURI(index, SEARCH);
         Response response =
-            performRequest(HttpPost.METHOD_NAME, search, uri, Collections.emptyMap());
+            performRequest(HttpPost.METHOD_NAME, uri, search, Collections.emptyMap());
         StatusLine statusLine = response.getStatusLine();
         if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
           String content = getContent(response);
@@ -348,36 +408,21 @@
               new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
           if (obj.get("hits") != null) {
             JsonArray json = obj.getAsJsonArray("hits");
-            results = Lists.newArrayListWithCapacity(json.size());
+            ImmutableList.Builder<T> results = ImmutableList.builderWithExpectedSize(json.size());
             for (int i = 0; i < json.size(); i++) {
               T mapperResult = mapper.apply(json.get(i).getAsJsonObject());
               if (mapperResult != null) {
                 results.add(mapperResult);
               }
             }
+            return new ListResultSet<>(results.build());
           }
         } else {
           logger.atSevere().log(statusLine.getReasonPhrase());
         }
-        final List<T> r = Collections.unmodifiableList(results);
-        return new ResultSet<T>() {
-          @Override
-          public Iterator<T> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<T> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
+        return new ListResultSet<>(ImmutableList.of());
       } catch (IOException e) {
-        throw new OrmException(e);
+        throw new StorageException(e);
       }
     }
   }
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
index 31ede79..f919aad 100644
--- a/java/com/google/gerrit/elasticsearch/BUILD
+++ b/java/com/google/gerrit/elasticsearch/BUILD
@@ -3,16 +3,18 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:gson",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:protobuf",
         "//lib/commons:codec",
         "//lib/commons:lang",
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index d18af42..10ecd68 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
@@ -38,7 +39,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Set;
 import org.apache.http.HttpStatus;
 import org.elasticsearch.client.Response;
@@ -73,19 +73,19 @@
   }
 
   @Override
-  public void replace(AccountState as) throws IOException {
+  public void replace(AccountState as) {
     BulkRequest bulk =
         new IndexRequest(getId(as), indexName, type, client.adapter())
             .add(new UpdateRequest<>(schema, as));
 
     String uri = getURI(type, BULK);
-    Response response = postRequest(bulk, uri, getRefreshParam());
+    Response response = postRequest(uri, bulk, getRefreshParam());
     int statusCode = response.getStatusLine().getStatusCode();
     if (statusCode != HttpStatus.SC_OK) {
-      throw new IOException(
+      throw new StorageException(
           String.format(
               "Failed to replace account %s in index %s: %s",
-              as.getAccount().getId(), indexName, statusCode));
+              as.getAccount().id(), indexName, statusCode));
     }
   }
 
@@ -93,8 +93,7 @@
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
     JsonArray sortArray = getSortArray(AccountField.ID.getName());
-    return new ElasticQuerySource(
-        p, opts.filterFields(IndexUtils::accountFields), ACCOUNTS, sortArray);
+    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::accountFields), type, sortArray);
   }
 
   @Override
@@ -109,7 +108,7 @@
 
   @Override
   protected String getId(AccountState as) {
-    return as.getAccount().getId().toString();
+    return as.getAccount().id().toString();
   }
 
   @Override
@@ -119,7 +118,7 @@
       source = json.getAsJsonObject().get("fields");
     }
 
-    Account.Id id = new Account.Id(source.getAsJsonObject().get(ID.getName()).getAsInt());
+    Account.Id id = Account.id(source.getAsJsonObject().get(ID.getName()).getAsInt());
     // Use the AccountCache rather than depending on any stored fields in the document (of which
     // there shouldn't be any). The most expensive part to compute anyway is the effective group
     // IDs, and we don't have a good way to reindex when those change.
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index f6af79f..f595fdc 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static org.apache.commons.codec.binary.Base64.decodeBase64;
 
 import com.google.common.collect.FluentIterable;
@@ -36,6 +33,7 @@
 import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
@@ -43,9 +41,10 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.converter.ChangeProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
@@ -59,11 +58,8 @@
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
@@ -93,55 +89,48 @@
   private static final String CLOSED_CHANGES = "closed_" + CHANGES;
 
   private final ChangeMapping mapping;
-  private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Schema<ChangeData> schema;
 
   @Inject
   ElasticChangeIndex(
       ElasticConfiguration cfg,
-      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       SitePaths sitePaths,
       ElasticRestClientProvider clientBuilder,
       @Assisted Schema<ChangeData> schema) {
     super(cfg, sitePaths, schema, clientBuilder, CHANGES);
-    this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.schema = schema;
     mapping = new ChangeMapping(schema, client.adapter());
   }
 
   @Override
-  public void replace(ChangeData cd) throws IOException {
+  public void replace(ChangeData cd) {
     String deleteIndex;
     String insertIndex;
 
-    try {
-      if (cd.change().getStatus().isOpen()) {
-        insertIndex = OPEN_CHANGES;
-        deleteIndex = CLOSED_CHANGES;
-      } else {
-        insertIndex = CLOSED_CHANGES;
-        deleteIndex = OPEN_CHANGES;
-      }
-    } catch (OrmException e) {
-      throw new IOException(e);
+    if (cd.change().isNew()) {
+      insertIndex = OPEN_CHANGES;
+      deleteIndex = CLOSED_CHANGES;
+    } else {
+      insertIndex = CLOSED_CHANGES;
+      deleteIndex = OPEN_CHANGES;
     }
 
     ElasticQueryAdapter adapter = client.adapter();
     BulkRequest bulk =
         new IndexRequest(getId(cd), indexName, adapter.getType(insertIndex), adapter)
             .add(new UpdateRequest<>(schema, cd));
-    if (!adapter.usePostV5Type()) {
+    if (adapter.deleteToReplace()) {
       bulk.add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex, adapter));
     }
 
     String uri = getURI(type, BULK);
-    Response response = postRequest(bulk, uri, getRefreshParam());
+    Response response = postRequest(uri, bulk, getRefreshParam());
     int statusCode = response.getStatusLine().getStatusCode();
     if (statusCode != HttpStatus.SC_OK) {
-      throw new IOException(
+      throw new StorageException(
           String.format(
               "Failed to replace change %s in index %s: %s", cd.getId(), indexName, statusCode));
     }
@@ -152,17 +141,19 @@
       throws QueryParseException {
     Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
     List<String> indexes = Lists.newArrayListWithCapacity(2);
-    if (client.adapter().usePostV5Type()) {
-      if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()
-          || !Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
-        indexes.add(ElasticQueryAdapter.POST_V5_TYPE);
-      }
-    } else {
-      if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
-        indexes.add(OPEN_CHANGES);
-      }
-      if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
-        indexes.add(CLOSED_CHANGES);
+    if (!client.adapter().omitType()) {
+      if (client.adapter().useV6Type()) {
+        if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()
+            || !Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+          indexes.add(ElasticQueryAdapter.V6_TYPE);
+        }
+      } else {
+        if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
+          indexes.add(OPEN_CHANGES);
+        }
+        if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+          indexes.add(CLOSED_CHANGES);
+        }
       }
     }
 
@@ -173,7 +164,6 @@
   private JsonArray getSortArray() {
     JsonObject properties = new JsonObject();
     properties.addProperty(ORDER, "desc");
-    client.adapter().setIgnoreUnmapped(properties);
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
@@ -186,17 +176,17 @@
   }
 
   @Override
-  protected String getDeleteActions(Id c) {
-    if (client.adapter().usePostV5Type()) {
-      return delete(ElasticQueryAdapter.POST_V5_TYPE, c);
+  protected String getDeleteActions(Change.Id c) {
+    if (!client.adapter().useV5Type()) {
+      return delete(client.adapter().getType(), c);
     }
     return delete(OPEN_CHANGES, c) + delete(CLOSED_CHANGES, c);
   }
 
   @Override
   protected String getMappings() {
-    if (client.adapter().usePostV5Type()) {
-      return getMappingsFor(ElasticQueryAdapter.POST_V5_TYPE, mapping.changes);
+    if (!client.adapter().useV5Type()) {
+      return getMappingsFor(client.adapter().getType(), mapping.changes);
     }
     return gson.toJson(ImmutableMap.of(MAPPINGS, mapping));
   }
@@ -218,23 +208,25 @@
     if (c == null) {
       int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-      String projectName = checkNotNull(source.get(ChangeField.PROJECT.getName()).getAsString());
-      return changeDataFactory.create(
-          db.get(), new Project.NameKey(projectName), new Change.Id(id));
+      String projectName = requireNonNull(source.get(ChangeField.PROJECT.getName()).getAsString());
+      return changeDataFactory.create(Project.nameKey(projectName), Change.id(id));
     }
 
     ChangeData cd =
         changeDataFactory.create(
-            db.get(), CHANGE_CODEC.decode(Base64.decodeBase64(c.getAsString())));
+            parseProtoFrom(Base64.decodeBase64(c.getAsString()), ChangeProtoConverter.INSTANCE));
 
     // Any decoding that is done here must also be done in {@link LuceneChangeIndex}.
 
     // Patch sets.
-    cd.setPatchSets(decodeProtos(source, ChangeField.PATCH_SET.getName(), PATCH_SET_CODEC));
+    cd.setPatchSets(
+        decodeProtos(source, ChangeField.PATCH_SET.getName(), PatchSetProtoConverter.INSTANCE));
 
     // Approvals.
     if (source.get(ChangeField.APPROVAL.getName()) != null) {
-      cd.setCurrentApprovals(decodeProtos(source, ChangeField.APPROVAL.getName(), APPROVAL_CODEC));
+      cd.setCurrentApprovals(
+          decodeProtos(
+              source, ChangeField.APPROVAL.getName(), PatchSetApprovalProtoConverter.INSTANCE));
     } else if (fields.contains(ChangeField.APPROVAL.getName())) {
       cd.setCurrentApprovals(Collections.emptyList());
     }
@@ -287,7 +279,7 @@
           if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
             break;
           }
-          accounts.add(new Account.Id(aId));
+          accounts.add(Account.id(aId));
         }
         cd.setReviewedBy(accounts);
       }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index 4184ec0..cbe9bc7 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -14,81 +14,97 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.common.base.MoreObjects;
+import static com.google.common.base.MoreObjects.firstNonNull;
+
 import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
 import org.apache.http.HttpHost;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
 class ElasticConfiguration {
-  private static final String DEFAULT_HOST = "localhost";
-  private static final String DEFAULT_PORT = "9200";
-  private static final String DEFAULT_PROTOCOL = "http";
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  static final String SECTION_ELASTICSEARCH = "elasticsearch";
+  static final String KEY_PASSWORD = "password";
+  static final String KEY_USERNAME = "username";
+  static final String KEY_PREFIX = "prefix";
+  static final String KEY_SERVER = "server";
+  static final String KEY_NUMBER_OF_SHARDS = "numberOfShards";
+  static final String KEY_NUMBER_OF_REPLICAS = "numberOfReplicas";
+  static final String DEFAULT_PORT = "9200";
+  static final String DEFAULT_USERNAME = "elastic";
+  static final int DEFAULT_NUMBER_OF_SHARDS = 0;
+  static final int DEFAULT_NUMBER_OF_REPLICAS = 1;
 
   private final Config cfg;
+  private final List<HttpHost> hosts;
 
-  final List<HttpHost> urls;
   final String username;
   final String password;
-  final boolean requestCompression;
-  final long connectionTimeout;
-  final long maxConnectionIdleTime;
-  final TimeUnit maxConnectionIdleUnit = TimeUnit.MILLISECONDS;
-  final int maxTotalConnection;
-  final int readTimeout;
+  final int numberOfShards;
+  final int numberOfReplicas;
   final String prefix;
 
   @Inject
   ElasticConfiguration(@GerritServerConfig Config cfg) {
     this.cfg = cfg;
-    this.username = cfg.getString("elasticsearch", null, "username");
-    this.password = cfg.getString("elasticsearch", null, "password");
-    this.requestCompression = cfg.getBoolean("elasticsearch", null, "requestCompression", false);
-    this.connectionTimeout =
-        cfg.getTimeUnit("elasticsearch", null, "connectionTimeout", 3000, TimeUnit.MILLISECONDS);
-    this.maxConnectionIdleTime =
-        cfg.getTimeUnit(
-            "elasticsearch", null, "maxConnectionIdleTime", 3000, TimeUnit.MILLISECONDS);
-    this.maxTotalConnection = cfg.getInt("elasticsearch", null, "maxTotalConnection", 1);
-    this.readTimeout =
-        (int) cfg.getTimeUnit("elasticsearch", null, "readTimeout", 3000, TimeUnit.MICROSECONDS);
-    this.prefix = Strings.nullToEmpty(cfg.getString("elasticsearch", null, "prefix"));
-
-    Set<String> subsections = cfg.getSubsections("elasticsearch");
-    if (subsections.isEmpty()) {
-      HttpHost httpHost =
-          new HttpHost(DEFAULT_HOST, Integer.valueOf(DEFAULT_PORT), DEFAULT_PROTOCOL);
-      this.urls = Collections.singletonList(httpHost);
-    } else {
-      this.urls = new ArrayList<>(subsections.size());
-      for (String subsection : subsections) {
-        String port = getString(cfg, subsection, "port", DEFAULT_PORT);
-        String host = getString(cfg, subsection, "hostname", DEFAULT_HOST);
-        String protocol = getString(cfg, subsection, "protocol", DEFAULT_PROTOCOL);
-
-        HttpHost httpHost = new HttpHost(host, Integer.valueOf(port), protocol);
-        this.urls.add(httpHost);
+    this.password = cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD);
+    this.username =
+        password == null
+            ? null
+            : firstNonNull(
+                cfg.getString(SECTION_ELASTICSEARCH, null, KEY_USERNAME), DEFAULT_USERNAME);
+    this.prefix = Strings.nullToEmpty(cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PREFIX));
+    this.numberOfShards =
+        cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_SHARDS, DEFAULT_NUMBER_OF_SHARDS);
+    this.numberOfReplicas =
+        cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_REPLICAS, DEFAULT_NUMBER_OF_REPLICAS);
+    this.hosts = new ArrayList<>();
+    for (String server : cfg.getStringList(SECTION_ELASTICSEARCH, null, KEY_SERVER)) {
+      try {
+        URI uri = new URI(server);
+        int port = uri.getPort();
+        HttpHost httpHost =
+            new HttpHost(
+                uri.getHost(), port == -1 ? Integer.valueOf(DEFAULT_PORT) : port, uri.getScheme());
+        this.hosts.add(httpHost);
+      } catch (URISyntaxException | IllegalArgumentException e) {
+        logger.atSevere().log("Invalid server URI %s: %s", server, e.getMessage());
       }
     }
+
+    if (hosts.isEmpty()) {
+      throw new ProvisionException("No valid Elasticsearch servers configured");
+    }
+
+    logger.atInfo().log("Elasticsearch servers: %s", hosts);
   }
 
   Config getConfig() {
     return cfg;
   }
 
+  HttpHost[] getHosts() {
+    return hosts.toArray(new HttpHost[hosts.size()]);
+  }
+
   String getIndexName(String name, int schemaVersion) {
     return String.format("%s%s_%04d", prefix, name, schemaVersion);
   }
 
-  private String getString(Config cfg, String subsection, String name, String defaultValue) {
-    return MoreObjects.firstNonNull(cfg.getString("elasticsearch", subsection, name), defaultValue);
+  int getNumberOfShards(ElasticQueryAdapter adapter) {
+    if (numberOfShards == DEFAULT_NUMBER_OF_SHARDS) {
+      return adapter.getDefaultNumberOfShards();
+    }
+    return numberOfShards;
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index bf6b962..471bc4e 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
@@ -36,7 +37,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Set;
 import org.apache.http.HttpStatus;
 import org.elasticsearch.client.Response;
@@ -71,16 +71,16 @@
   }
 
   @Override
-  public void replace(InternalGroup group) throws IOException {
+  public void replace(InternalGroup group) {
     BulkRequest bulk =
         new IndexRequest(getId(group), indexName, type, client.adapter())
             .add(new UpdateRequest<>(schema, group));
 
     String uri = getURI(type, BULK);
-    Response response = postRequest(bulk, uri, getRefreshParam());
+    Response response = postRequest(uri, bulk, getRefreshParam());
     int statusCode = response.getStatusLine().getStatusCode();
     if (statusCode != HttpStatus.SC_OK) {
-      throw new IOException(
+      throw new StorageException(
           String.format(
               "Failed to replace group %s in index %s: %s",
               group.getGroupUUID().get(), indexName, statusCode));
@@ -91,7 +91,7 @@
   public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
       throws QueryParseException {
     JsonArray sortArray = getSortArray(GroupField.UUID.getName());
-    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::groupFields), GROUPS, sortArray);
+    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::groupFields), type, sortArray);
   }
 
   @Override
@@ -117,8 +117,7 @@
     }
 
     AccountGroup.UUID uuid =
-        new AccountGroup.UUID(
-            source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
+        AccountGroup.uuid(source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
     // Use the GroupCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any).
     return groupCache.get().get(uuid).orElse(null);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index 1e41985..15d6126 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -25,20 +25,15 @@
 public class ElasticIndexModule extends AbstractIndexModule {
   public static ElasticIndexModule singleVersionWithExplicitVersions(
       Map<String, Integer> versions, int threads, boolean slave) {
-    return new ElasticIndexModule(versions, threads, false, slave);
+    return new ElasticIndexModule(versions, threads, slave);
   }
 
-  public static ElasticIndexModule latestVersionWithOnlineUpgrade(boolean slave) {
-    return new ElasticIndexModule(null, 0, true, slave);
+  public static ElasticIndexModule latestVersion(boolean slave) {
+    return new ElasticIndexModule(null, 0, slave);
   }
 
-  public static ElasticIndexModule latestVersionWithoutOnlineUpgrade(boolean slave) {
-    return new ElasticIndexModule(null, 0, false, slave);
-  }
-
-  private ElasticIndexModule(
-      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade, boolean slave) {
-    super(singleVersions, threads, onlineUpgrade, slave);
+  private ElasticIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
+    super(singleVersions, threads, slave);
   }
 
   @Override
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
index e36ab2d..100022a 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) {
@@ -56,11 +54,8 @@
     }
 
     return new JsonParser()
-        .parse(AbstractElasticIndex.getContent(response))
-        .getAsJsonObject()
-        .entrySet()
-        .stream()
-        .map(e -> e.getKey().replace(name, ""))
-        .collect(toList());
+        .parse(AbstractElasticIndex.getContent(response)).getAsJsonObject().entrySet().stream()
+            .map(e -> e.getKey().replace(name, ""))
+            .collect(toList());
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
index 8011efa..b9d86d5 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.Schema;
@@ -26,6 +25,7 @@
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.OnlineUpgradeListener;
 import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -45,7 +45,7 @@
   ElasticIndexVersionManager(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
-      DynamicSet<OnlineUpgradeListener> listeners,
+      PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs,
       ElasticIndexVersionDiscovery versionDiscovery) {
     super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
diff --git a/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
index a30e546..f8c4168 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -34,9 +34,9 @@
           || fieldType == FieldType.INTEGER_RANGE
           || fieldType == FieldType.LONG) {
         mapping.addNumber(name);
-      } else if (fieldType == FieldType.PREFIX
-          || fieldType == FieldType.FULL_TEXT
-          || fieldType == FieldType.STORED_ONLY) {
+      } else if (fieldType == FieldType.FULL_TEXT) {
+        mapping.addStringWithAnalyzer(name);
+      } else if (fieldType == FieldType.PREFIX || fieldType == FieldType.STORED_ONLY) {
         mapping.addString(name);
       } else {
         throw new IllegalStateException("Unsupported field type: " + fieldType.getName());
@@ -88,6 +88,13 @@
       return this;
     }
 
+    Builder addStringWithAnalyzer(String name) {
+      FieldProperties key = new FieldProperties(adapter.stringFieldType());
+      key.analyzer = "custom_with_char_filter";
+      fields.put(name, key);
+      return this;
+    }
+
     Builder add(String name, String type) {
       fields.put(name, new FieldProperties(type));
       return this;
@@ -102,6 +109,7 @@
     String type;
     String index;
     String format;
+    String analyzer;
     Map<String, FieldProperties> fields;
 
     FieldProperties(String type) {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
index 623f62c..cb97032 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.project.ProjectData;
@@ -36,7 +37,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Set;
 import org.apache.http.HttpStatus;
 import org.elasticsearch.client.Response;
@@ -71,16 +71,16 @@
   }
 
   @Override
-  public void replace(ProjectData projectState) throws IOException {
+  public void replace(ProjectData projectState) {
     BulkRequest bulk =
         new IndexRequest(projectState.getProject().getName(), indexName, type, client.adapter())
             .add(new UpdateRequest<>(schema, projectState));
 
     String uri = getURI(type, BULK);
-    Response response = postRequest(bulk, uri, getRefreshParam());
+    Response response = postRequest(uri, bulk, getRefreshParam());
     int statusCode = response.getStatusLine().getStatusCode();
     if (statusCode != HttpStatus.SC_OK) {
-      throw new IOException(
+      throw new StorageException(
           String.format(
               "Failed to replace project %s in index %s: %s",
               projectState.getProject().getName(), indexName, statusCode));
@@ -117,8 +117,7 @@
     }
 
     Project.NameKey nameKey =
-        new Project.NameKey(
-            source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
+        Project.nameKey(source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
     return projectCache.get().get(nameKey).toProjectData();
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
index dcf2fc4..72c52b0 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
@@ -14,58 +14,48 @@
 
 package com.google.gerrit.elasticsearch;
 
+import static com.google.gerrit.elasticsearch.ElasticVersion.V6_7;
+
 import com.google.gson.JsonObject;
 
 public class ElasticQueryAdapter {
-  static final String POST_V5_TYPE = "_doc";
+  static final String V6_TYPE = "_doc";
 
-  private final boolean ignoreUnmapped;
-  private final boolean usePostV5Type;
+  private static final String INCLUDE_TYPE = "include_type_name=true";
+  private static final String INDICES = "?allow_no_indices=false";
+
+  private final boolean useV5Type;
+  private final boolean useV6Type;
+  private final boolean omitType;
+  private final int defaultNumberOfShards;
 
   private final String searchFilteringName;
-  private final String indicesExistParam;
+  private final String indicesExistParams;
   private final String exactFieldType;
   private final String stringFieldType;
   private final String indexProperty;
   private final String rawFieldsKey;
   private final String versionDiscoveryUrl;
+  private final String includeTypeNameParam;
 
   ElasticQueryAdapter(ElasticVersion version) {
-    this.ignoreUnmapped = version == ElasticVersion.V2_4;
-    this.usePostV5Type = version == ElasticVersion.V6_2;
-
-    this.versionDiscoveryUrl = version == ElasticVersion.V6_2 ? "%s*" : "%s*/_aliases";
-
-    switch (version) {
-      case V5_6:
-      case V6_2:
-        this.searchFilteringName = "_source";
-        this.indicesExistParam = "?allow_no_indices=false";
-        this.exactFieldType = "keyword";
-        this.stringFieldType = "text";
-        this.indexProperty = "true";
-        this.rawFieldsKey = "_source";
-        break;
-      case V2_4:
-      default:
-        this.searchFilteringName = "fields";
-        this.indicesExistParam = "";
-        this.exactFieldType = "string";
-        this.stringFieldType = "string";
-        this.indexProperty = "not_analyzed";
-        this.rawFieldsKey = "fields";
-        break;
-    }
-  }
-
-  void setIgnoreUnmapped(JsonObject properties) {
-    if (ignoreUnmapped) {
-      properties.addProperty("ignore_unmapped", true);
-    }
+    this.useV5Type = !version.isV6OrLater();
+    this.useV6Type = version.isV6();
+    this.omitType = version.isV7OrLater();
+    this.defaultNumberOfShards = version.isV7OrLater() ? 1 : 5;
+    this.versionDiscoveryUrl = version.isV6OrLater() ? "/%s*" : "/%s*/_aliases";
+    this.searchFilteringName = "_source";
+    this.indicesExistParams =
+        version.isAtLeastMinorVersion(V6_7) ? INDICES + "&" + INCLUDE_TYPE : INDICES;
+    this.exactFieldType = "keyword";
+    this.stringFieldType = "text";
+    this.indexProperty = "true";
+    this.rawFieldsKey = "_source";
+    this.includeTypeNameParam = version.isAtLeastMinorVersion(V6_7) ? "?" + INCLUDE_TYPE : "";
   }
 
   public void setType(JsonObject properties, String type) {
-    if (!usePostV5Type) {
+    if (useV5Type) {
       properties.addProperty("_type", type);
     }
   }
@@ -74,8 +64,8 @@
     return searchFilteringName;
   }
 
-  String indicesExistParam() {
-    return indicesExistParam;
+  String indicesExistParams() {
+    return indicesExistParams;
   }
 
   String exactFieldType() {
@@ -94,15 +84,42 @@
     return rawFieldsKey;
   }
 
-  boolean usePostV5Type() {
-    return usePostV5Type;
+  boolean deleteToReplace() {
+    return useV5Type;
   }
 
-  String getType(String preV6Type) {
-    return usePostV5Type() ? POST_V5_TYPE : preV6Type;
+  boolean useV5Type() {
+    return useV5Type;
+  }
+
+  boolean useV6Type() {
+    return useV6Type;
+  }
+
+  boolean omitType() {
+    return omitType;
+  }
+
+  int getDefaultNumberOfShards() {
+    return defaultNumberOfShards;
+  }
+
+  String getType() {
+    return getType("");
+  }
+
+  String getType(String type) {
+    if (useV6Type()) {
+      return V6_TYPE;
+    }
+    return useV5Type() ? type : "";
   }
 
   String getVersionDiscoveryUrl(String name) {
     return String.format(versionDiscoveryUrl, name);
   }
+
+  String includeTypeNameParam() {
+    return includeTypeNameParam;
+  }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index c2c4548..a67de44 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -22,7 +22,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import org.apache.http.HttpHost;
 import org.apache.http.HttpStatus;
 import org.apache.http.StatusLine;
 import org.apache.http.auth.AuthScope;
@@ -30,6 +29,7 @@
 import org.apache.http.client.CredentialsProvider;
 import org.apache.http.impl.client.BasicCredentialsProvider;
 import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
 import org.elasticsearch.client.RestClient;
 import org.elasticsearch.client.RestClientBuilder;
@@ -38,18 +38,14 @@
 class ElasticRestClientProvider implements Provider<RestClient>, LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final HttpHost[] hosts;
-  private final String username;
-  private final String password;
+  private final ElasticConfiguration cfg;
 
-  private RestClient client;
+  private volatile RestClient client;
   private ElasticQueryAdapter adapter;
 
   @Inject
   ElasticRestClientProvider(ElasticConfiguration cfg) {
-    hosts = cfg.urls.toArray(new HttpHost[cfg.urls.size()]);
-    username = cfg.username;
-    password = cfg.password;
+    this.cfg = cfg;
   }
 
   public static LifecycleModule module() {
@@ -110,7 +106,7 @@
 
   private ElasticVersion getVersion() throws ElasticException {
     try {
-      Response response = client.performRequest("GET", "");
+      Response response = client.performRequest(new Request("GET", "/"));
       StatusLine statusLine = response.getStatusLine();
       if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
         throw new FailedToGetVersion(statusLine);
@@ -131,12 +127,14 @@
   }
 
   private RestClient build() {
-    RestClientBuilder builder = RestClient.builder(hosts);
+    RestClientBuilder builder = RestClient.builder(cfg.getHosts());
     setConfiguredCredentialsIfAny(builder);
     return builder.build();
   }
 
   private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
+    String username = cfg.username;
+    String password = cfg.password;
     if (username != null && password != null) {
       CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
       credentialsProvider.setCredentials(
diff --git a/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
new file mode 100644
index 0000000..14e4623
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+
+class ElasticSetting {
+  /** The custom char mappings of "." to " " and "_" to " " in the form of UTF-8 */
+  private static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
+      ImmutableMap.of("\\u002E", "\\u0020", "\\u005F", "\\u0020");
+
+  static SettingProperties createSetting(ElasticConfiguration config, ElasticQueryAdapter adapter) {
+    return new ElasticSetting.Builder().addCharFilter().addAnalyzer().build(config, adapter);
+  }
+
+  static class Builder {
+    private final ImmutableMap.Builder<String, FieldProperties> fields =
+        new ImmutableMap.Builder<>();
+
+    SettingProperties build(ElasticConfiguration config, ElasticQueryAdapter adapter) {
+      SettingProperties properties = new SettingProperties();
+      properties.analysis = fields.build();
+      properties.numberOfShards = config.getNumberOfShards(adapter);
+      properties.numberOfReplicas = config.numberOfReplicas;
+      return properties;
+    }
+
+    Builder addCharFilter() {
+      FieldProperties charMapping = new FieldProperties("mapping");
+      charMapping.mappings = getCustomCharMappings(CUSTOM_CHAR_MAPPING);
+
+      FieldProperties charFilter = new FieldProperties();
+      charFilter.customMapping = charMapping;
+      fields.put("char_filter", charFilter);
+      return this;
+    }
+
+    Builder addAnalyzer() {
+      FieldProperties customAnalyzer = new FieldProperties("custom");
+      customAnalyzer.tokenizer = "standard";
+      customAnalyzer.charFilter = new String[] {"custom_mapping"};
+      customAnalyzer.filter = new String[] {"lowercase"};
+
+      FieldProperties analyzer = new FieldProperties();
+      analyzer.customWithCharFilter = customAnalyzer;
+      fields.put("analyzer", analyzer);
+      return this;
+    }
+
+    private static String[] getCustomCharMappings(ImmutableMap<String, String> map) {
+      int mappingIndex = 0;
+      int numOfMappings = map.size();
+      String[] mapping = new String[numOfMappings];
+      for (Map.Entry<String, String> e : map.entrySet()) {
+        mapping[mappingIndex++] = e.getKey() + "=>" + e.getValue();
+      }
+      return mapping;
+    }
+  }
+
+  static class SettingProperties {
+    Map<String, FieldProperties> analysis;
+    Integer numberOfShards;
+    Integer numberOfReplicas;
+  }
+
+  static class FieldProperties {
+    String tokenizer;
+    String type;
+    String[] charFilter;
+    String[] filter;
+    String[] mappings;
+    FieldProperties customMapping;
+    FieldProperties customWithCharFilter;
+
+    FieldProperties() {}
+
+    FieldProperties(String type) {
+      this.type = type;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index b65eb31..e608e93 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -18,9 +18,16 @@
 import java.util.regex.Pattern;
 
 public enum ElasticVersion {
-  V2_4("2.4.*"),
   V5_6("5.6.*"),
-  V6_2("6.2.*");
+  V6_2("6.2.*"),
+  V6_3("6.3.*"),
+  V6_4("6.4.*"),
+  V6_5("6.5.*"),
+  V6_6("6.6.*"),
+  V6_7("6.7.*"),
+  V7_0("7.0.*"),
+  V7_1("7.1.*"),
+  V7_2("7.2.*");
 
   private final String version;
   private final Pattern pattern;
@@ -30,29 +37,57 @@
     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 getMajor() == 6;
+  }
+
+  public boolean isV6OrLater() {
+    return isAtLeastVersion(6);
+  }
+
+  public boolean isV7OrLater() {
+    return isAtLeastVersion(7);
+  }
+
+  private boolean isAtLeastVersion(int major) {
+    return getMajor() >= major;
+  }
+
+  public boolean isAtLeastMinorVersion(ElasticVersion version) {
+    return getMajor().equals(version.getMajor()) && getMinor() >= version.getMinor();
+  }
+
+  private Integer getMajor() {
+    return Integer.valueOf(version.split("\\.")[0]);
+  }
+
+  private Integer getMinor() {
+    return Integer.valueOf(version.split("\\.")[1]);
+  }
+
   @Override
   public String toString() {
     return version;
diff --git a/java/com/google/gerrit/exceptions/BUILD b/java/com/google/gerrit/exceptions/BUILD
new file mode 100644
index 0000000..50bf883
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/BUILD
@@ -0,0 +1,6 @@
+java_library(
+    name = "exceptions",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = ["//java/com/google/gerrit/reviewdb:server"],
+)
diff --git a/java/com/google/gerrit/exceptions/DuplicateKeyException.java b/java/com/google/gerrit/exceptions/DuplicateKeyException.java
new file mode 100644
index 0000000..d052450
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/DuplicateKeyException.java
@@ -0,0 +1,28 @@
+// Copyright 2009 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.exceptions;
+
+/** Indicates one or more entities were concurrently inserted with the same key. */
+public class DuplicateKeyException extends StorageException {
+  private static final long serialVersionUID = 1L;
+
+  public DuplicateKeyException(String msg) {
+    super(msg);
+  }
+
+  public DuplicateKeyException(String msg, Throwable why) {
+    super(msg, why);
+  }
+}
diff --git a/java/com/google/gerrit/exceptions/EmailException.java b/java/com/google/gerrit/exceptions/EmailException.java
new file mode 100644
index 0000000..a278eed
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/EmailException.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.exceptions;
+
+public class EmailException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Mail Error: ";
+
+  public EmailException(String msg) {
+    super(MESSAGE + msg);
+  }
+
+  public EmailException(String msg, Throwable why) {
+    super(MESSAGE + msg, why);
+  }
+}
diff --git a/java/com/google/gerrit/exceptions/InvalidNameException.java b/java/com/google/gerrit/exceptions/InvalidNameException.java
new file mode 100644
index 0000000..4539500
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/InvalidNameException.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.exceptions;
+
+/** Error indicating the entity name is invalid as supplied. */
+public class InvalidNameException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Invalid Name";
+
+  public InvalidNameException() {
+    super(MESSAGE);
+  }
+
+  public InvalidNameException(String invalidName) {
+    super(MESSAGE + ": " + invalidName);
+  }
+}
diff --git a/java/com/google/gerrit/exceptions/InvalidSshKeyException.java b/java/com/google/gerrit/exceptions/InvalidSshKeyException.java
new file mode 100644
index 0000000..8baba20
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/InvalidSshKeyException.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.exceptions;
+
+/** Error indicating the SSH key string is invalid as supplied. */
+public class InvalidSshKeyException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Invalid SSH Key";
+
+  public InvalidSshKeyException() {
+    super(MESSAGE);
+  }
+}
diff --git a/java/com/google/gerrit/exceptions/NameAlreadyUsedException.java b/java/com/google/gerrit/exceptions/NameAlreadyUsedException.java
new file mode 100644
index 0000000..df2631b
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/NameAlreadyUsedException.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.exceptions;
+
+/** Error indicating entity name is already taken by another entity. */
+public class NameAlreadyUsedException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Name Already Used: ";
+
+  public NameAlreadyUsedException(String name) {
+    super(MESSAGE + name);
+  }
+}
diff --git a/java/com/google/gerrit/exceptions/NoSuchAccountException.java b/java/com/google/gerrit/exceptions/NoSuchAccountException.java
new file mode 100644
index 0000000..d753128
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/NoSuchAccountException.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.exceptions;
+
+/** Error indicating the account requested doesn't exist. */
+public class NoSuchAccountException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Not Found: ";
+
+  public NoSuchAccountException(String who) {
+    super(MESSAGE + who);
+  }
+}
diff --git a/java/com/google/gerrit/exceptions/NoSuchEntityException.java b/java/com/google/gerrit/exceptions/NoSuchEntityException.java
new file mode 100644
index 0000000..c812a38
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/NoSuchEntityException.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.exceptions;
+
+/** Error indicating the entity requested doesn't exist. */
+public class NoSuchEntityException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Not Found";
+
+  public NoSuchEntityException() {
+    super(MESSAGE);
+  }
+
+  public NoSuchEntityException(String message) {
+    super(message);
+  }
+}
diff --git a/java/com/google/gerrit/exceptions/NoSuchGroupException.java b/java/com/google/gerrit/exceptions/NoSuchGroupException.java
new file mode 100644
index 0000000..dca28cb
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/NoSuchGroupException.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.exceptions;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+
+/** Indicates the account group does not exist. */
+public class NoSuchGroupException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Group Not Found: ";
+
+  public NoSuchGroupException(AccountGroup.Id key) {
+    this(key, null);
+  }
+
+  public NoSuchGroupException(AccountGroup.UUID key) {
+    this(key, null);
+  }
+
+  public NoSuchGroupException(AccountGroup.Id key, Throwable why) {
+    super(MESSAGE + key.toString(), why);
+  }
+
+  public NoSuchGroupException(AccountGroup.UUID key, Throwable why) {
+    super(MESSAGE + key.toString(), why);
+  }
+
+  public NoSuchGroupException(AccountGroup.NameKey k, Throwable why) {
+    super(MESSAGE + k.toString(), why);
+  }
+
+  public NoSuchGroupException(String who) {
+    this(who, null);
+  }
+
+  public NoSuchGroupException(String who, Throwable why) {
+    super(MESSAGE + who, why);
+  }
+}
diff --git a/java/com/google/gerrit/exceptions/NotSignedInException.java b/java/com/google/gerrit/exceptions/NotSignedInException.java
new file mode 100644
index 0000000..1919dc39
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/NotSignedInException.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.exceptions;
+
+/** Error stating the user must be signed-in in order to perform this action. */
+public class NotSignedInException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Not Signed In";
+
+  public NotSignedInException() {
+    super(MESSAGE);
+  }
+}
diff --git a/java/com/google/gerrit/exceptions/StorageException.java b/java/com/google/gerrit/exceptions/StorageException.java
new file mode 100644
index 0000000..a788fff
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/StorageException.java
@@ -0,0 +1,44 @@
+// Copyright 2008 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.exceptions;
+
+/**
+ * Any read/write error in a storage layer.
+ *
+ * <p>This includes but is not limited to:
+ *
+ * <ul>
+ *   <li>NoteDb exceptions
+ *   <li>Secondary index exceptions
+ *   <li>{@code AccountPatchReviewStore} exceptions
+ *   <li>Wrapped JGit exceptions
+ *   <li>Other wrapped {@code IOException}s
+ * </ul>
+ */
+public class StorageException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public StorageException(String message) {
+    super(message);
+  }
+
+  public StorageException(String message, Throwable why) {
+    super(message, why);
+  }
+
+  public StorageException(Throwable why) {
+    super(why);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD
index 1780ce9..b69d2c8 100644
--- a/java/com/google/gerrit/extensions/BUILD
+++ b/java/com/google/gerrit/extensions/BUILD
@@ -1,15 +1,6 @@
-load("//lib:guava.bzl", "GUAVA_DOC_URL")
 load("//lib/jgit:jgit.bzl", "JGIT_DOC_URL")
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-EXT_API_SRCS = glob(["client/*.java"])
-
-gwt_module(
-    name = "client",
-    srcs = EXT_API_SRCS,
-    gwt_xml = "Extensions.gwt.xml",
-    visibility = ["//visibility:public"],
-)
+load("//lib:guava.bzl", "GUAVA_DOC_URL")
+load("//tools/bzl:javadoc.bzl", "java_doc")
 
 java_binary(
     name = "extension-api",
@@ -39,13 +30,12 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
+        "//lib/auto:auto-value-annotations",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
     ],
 )
 
-load("//tools/bzl:javadoc.bzl", "java_doc")
-
 java_doc(
     name = "extension-api-javadoc",
     external_docs = [
diff --git a/java/com/google/gerrit/extensions/Extensions.gwt.xml b/java/com/google/gerrit/extensions/Extensions.gwt.xml
deleted file mode 100644
index c857b60..0000000
--- a/java/com/google/gerrit/extensions/Extensions.gwt.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-<!--
- Copyright (C) 2013 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <source path='client' />
-</module>
diff --git a/java/com/google/gerrit/extensions/annotations/ExportImpl.java b/java/com/google/gerrit/extensions/annotations/ExportImpl.java
deleted file mode 100644
index a3e72bc..0000000
--- a/java/com/google/gerrit/extensions/annotations/ExportImpl.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.annotations;
-
-import java.io.Serializable;
-import java.lang.annotation.Annotation;
-
-final class ExportImpl implements Export, Serializable {
-  private static final long serialVersionUID = 0;
-  private final String value;
-
-  ExportImpl(String value) {
-    this.value = value;
-  }
-
-  @Override
-  public Class<? extends Annotation> annotationType() {
-    return Export.class;
-  }
-
-  @Override
-  public String value() {
-    return value;
-  }
-
-  @Override
-  public int hashCode() {
-    return (127 * "value".hashCode()) ^ value.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return o instanceof Export && value.equals(((Export) o).value());
-  }
-
-  @Override
-  public String toString() {
-    return "@" + Export.class.getName() + "(value=" + value + ")";
-  }
-}
diff --git a/java/com/google/gerrit/extensions/annotations/Exports.java b/java/com/google/gerrit/extensions/annotations/Exports.java
index 1295ea0..9b196b6 100644
--- a/java/com/google/gerrit/extensions/annotations/Exports.java
+++ b/java/com/google/gerrit/extensions/annotations/Exports.java
@@ -14,11 +14,14 @@
 
 package com.google.gerrit.extensions.annotations;
 
+import com.google.auto.value.AutoAnnotation;
+
 /** Static constructors for {@link Export} annotations. */
 public final class Exports {
   /** Create an annotation to export under a specific name. */
-  public static Export named(String name) {
-    return new ExportImpl(name);
+  @AutoAnnotation
+  public static Export named(String value) {
+    return new AutoAnnotation_Exports_named(value);
   }
 
   /** Create an annotation to export based on a cannonical class name. */
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/CoreOrPluginProjectPermission.java b/java/com/google/gerrit/extensions/api/access/CoreOrPluginProjectPermission.java
new file mode 100644
index 0000000..de68987
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/access/CoreOrPluginProjectPermission.java
@@ -0,0 +1,18 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.access;
+
+/** A repository permission either defined in Gerrit core or a plugin. */
+public interface CoreOrPluginProjectPermission extends GerritPermission {}
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/PluginPermission.java b/java/com/google/gerrit/extensions/api/access/PluginPermission.java
index 449135d..1dc5cb6 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginPermission.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.extensions.api.access;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import java.util.Objects;
 
@@ -29,8 +29,8 @@
   }
 
   public PluginPermission(String pluginName, String capability, boolean fallBackToAdmin) {
-    this.pluginName = checkNotNull(pluginName, "pluginName");
-    this.capability = checkNotNull(capability, "capability");
+    this.pluginName = requireNonNull(pluginName, "pluginName");
+    this.capability = requireNonNull(capability, "capability");
     this.fallBackToAdmin = fallBackToAdmin;
   }
 
diff --git a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
new file mode 100644
index 0000000..a62fc63
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.access;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/** Repository permissions defined by plugins. */
+public final class PluginProjectPermission implements CoreOrPluginProjectPermission {
+  public static final String PLUGIN_PERMISSION_NAME_PATTERN_STRING = "[a-zA-Z]+";
+  private static final Pattern PLUGIN_PERMISSION_PATTERN =
+      Pattern.compile("^" + PLUGIN_PERMISSION_NAME_PATTERN_STRING + "$");
+
+  private final String pluginName;
+  private final String permission;
+
+  public PluginProjectPermission(String pluginName, String permission) {
+    requireNonNull(pluginName, "pluginName");
+    requireNonNull(permission, "permission");
+    checkArgument(
+        isValidPluginPermissionName(permission), "invalid plugin permission name: ", permission);
+
+    this.pluginName = pluginName;
+    this.permission = permission;
+  }
+
+  public String pluginName() {
+    return pluginName;
+  }
+
+  public String permission() {
+    return permission;
+  }
+
+  @Override
+  public String describeForException() {
+    return permission + " for plugin " + pluginName;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(pluginName, permission);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other instanceof PluginProjectPermission) {
+      PluginProjectPermission b = (PluginProjectPermission) other;
+      return pluginName.equals(b.pluginName) && permission.equals(b.permission);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("pluginName", pluginName)
+        .add("permission", permission)
+        .toString();
+  }
+
+  /**
+   * Checks if a given name is valid to be used for plugin permissions.
+   *
+   * @param name a name string.
+   * @return whether the name is valid as a plugin permission.
+   */
+  private static boolean isValidPluginPermissionName(String name) {
+    return PLUGIN_PERMISSION_PATTERN.matcher(name).matches();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
index 5d8e950..8273d84 100644
--- a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
+++ b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
@@ -29,6 +29,7 @@
   public Set<String> ownerOf;
   public Boolean canUpload;
   public Boolean canAdd;
+  public Boolean canAddTags;
   public Boolean configVisible;
   public Map<String, GroupInfo> groups;
   public List<WebLinkInfo> configWebLinks;
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 0c3a11f..67d6fd2 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
@@ -36,6 +37,8 @@
 public interface AccountApi {
   AccountInfo get() throws RestApiException;
 
+  AccountDetailInfo detail() throws RestApiException;
+
   boolean getActive() throws RestApiException;
 
   void setActive(boolean active) throws RestApiException;
@@ -106,6 +109,28 @@
 
   void deleteExternalIds(List<String> externalIds) throws RestApiException;
 
+  List<DeletedDraftCommentInfo> deleteDraftComments(DeleteDraftCommentsInput input)
+      throws RestApiException;
+
+  void setName(String name) throws RestApiException;
+
+  /**
+   * Generate a new HTTP password.
+   *
+   * @return the generated password.
+   */
+  String generateHttpPassword() throws RestApiException;
+
+  /**
+   * Set a new HTTP password.
+   *
+   * <p>May only be invoked by administrators.
+   *
+   * @param httpPassword the new password, {@code null} to remove the password.
+   * @return the new password, {@code null} if the password was removed.
+   */
+  String setHttpPassword(String httpPassword) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -117,6 +142,11 @@
     }
 
     @Override
+    public AccountDetailInfo detail() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public boolean getActive() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -293,5 +323,26 @@
     public void deleteExternalIds(List<String> externalIds) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public List<DeletedDraftCommentInfo> deleteDraftComments(DeleteDraftCommentsInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setName(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String generateHttpPassword() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String setHttpPassword(String httpPassword) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/accounts/Accounts.java b/java/com/google/gerrit/extensions/api/accounts/Accounts.java
index 651e786..db7f506 100644
--- a/java/com/google/gerrit/extensions/api/accounts/Accounts.java
+++ b/java/com/google/gerrit/extensions/api/accounts/Accounts.java
@@ -172,16 +172,19 @@
       return this;
     }
 
+    /** Set an option on the request, appending to existing options. */
     public QueryRequest withOption(ListAccountsOption options) {
       this.options.add(options);
       return this;
     }
 
+    /** Set options on the request, appending to existing options. */
     public QueryRequest withOptions(ListAccountsOption... options) {
       this.options.addAll(Arrays.asList(options));
       return this;
     }
 
+    /** Set options on the request, replacing existing options. */
     public QueryRequest withOptions(EnumSet<ListAccountsOption> options) {
       this.options = options;
       return this;
diff --git a/java/com/google/gerrit/extensions/api/accounts/AgreementInput.java b/java/com/google/gerrit/extensions/api/accounts/AgreementInput.java
new file mode 100644
index 0000000..9f35eed
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/accounts/AgreementInput.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.accounts;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+/** This entity contains information for registering a new contributor agreement. */
+public class AgreementInput {
+  /* The agreement name. */
+  @DefaultInput public String name;
+}
diff --git a/java/com/google/gerrit/extensions/api/accounts/DeleteDraftCommentsInput.java b/java/com/google/gerrit/extensions/api/accounts/DeleteDraftCommentsInput.java
new file mode 100644
index 0000000..113f3d4
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/accounts/DeleteDraftCommentsInput.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.accounts;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class DeleteDraftCommentsInput {
+  /**
+   * Delete comments only on changes that match this query.
+   *
+   * <p>If null or empty, delete comments on all changes.
+   */
+  @DefaultInput public String query;
+
+  public DeleteDraftCommentsInput() {
+    this(null);
+  }
+
+  public DeleteDraftCommentsInput(@Nullable String query) {
+    this.query = query;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/accounts/DeletedDraftCommentInfo.java b/java/com/google/gerrit/extensions/api/accounts/DeletedDraftCommentInfo.java
new file mode 100644
index 0000000..c15d5bc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/accounts/DeletedDraftCommentInfo.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.accounts;
+
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import java.util.List;
+
+public class DeletedDraftCommentInfo {
+  public ChangeInfo change;
+  public List<CommentInfo> deleted;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 8b2d29b..010ef6d 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -49,17 +50,21 @@
    * @return API for accessing the revision.
    * @throws RestApiException if an error occurred.
    */
-  RevisionApi current() throws RestApiException;
+  default RevisionApi current() throws RestApiException {
+    return revision("current");
+  }
 
   /**
    * Look up a revision of a change by number.
    *
    * @see #current()
    */
-  RevisionApi revision(int id) throws RestApiException;
+  default RevisionApi revision(int id) throws RestApiException {
+    return revision(Integer.toString(id));
+  }
 
   /**
-   * Look up a revision of a change by commit SHA-1.
+   * Look up a revision of a change by commit SHA-1 or other supported revision string.
    *
    * @see #current()
    */
@@ -79,23 +84,35 @@
    */
   ReviewerApi reviewer(String id) throws RestApiException;
 
-  void abandon() throws RestApiException;
+  default void abandon() throws RestApiException {
+    abandon(new AbandonInput());
+  }
 
   void abandon(AbandonInput in) throws RestApiException;
 
-  void restore() throws RestApiException;
+  default void restore() throws RestApiException {
+    restore(new RestoreInput());
+  }
 
   void restore(RestoreInput in) throws RestApiException;
 
-  void move(String destination) throws RestApiException;
+  default void move(String destination) throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    move(in);
+  }
 
   void move(MoveInput in) throws RestApiException;
 
   void setPrivate(boolean value, @Nullable String message) throws RestApiException;
 
-  void setWorkInProgress(String message) throws RestApiException;
+  default void setPrivate(boolean value) throws RestApiException {
+    setPrivate(value, null);
+  }
 
-  void setReadyForReview(String message) throws RestApiException;
+  void setWorkInProgress(@Nullable String message) throws RestApiException;
+
+  void setReadyForReview(@Nullable String message) throws RestApiException;
 
   default void setWorkInProgress() throws RestApiException {
     setWorkInProgress(null);
@@ -132,7 +149,9 @@
    *
    * @see Changes#id(int)
    */
-  ChangeApi revert() throws RestApiException;
+  default ChangeApi revert() throws RestApiException {
+    return revert(new RevertInput());
+  }
 
   /**
    * Create a new change that reverts this change.
@@ -144,10 +163,17 @@
   /** Create a merge patch set for the change. */
   ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
 
-  List<ChangeInfo> submittedTogether() throws RestApiException;
+  default List<ChangeInfo> submittedTogether() throws RestApiException {
+    SubmittedTogetherInfo info =
+        submittedTogether(
+            EnumSet.noneOf(ListChangesOption.class), EnumSet.noneOf(SubmittedTogetherOption.class));
+    return info.changes;
+  }
 
-  SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
-      throws RestApiException;
+  default SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
+      throws RestApiException {
+    return submittedTogether(EnumSet.noneOf(ListChangesOption.class), options);
+  }
 
   SubmittedTogetherInfo submittedTogether(
       EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
@@ -155,10 +181,14 @@
 
   /** Publishes a draft change. */
   @Deprecated
-  void publish() throws RestApiException;
+  default void publish() {
+    throw new UnsupportedOperationException("draft workflow is discontinued");
+  }
 
   /** Rebase the current revision of a change using default options. */
-  void rebase() throws RestApiException;
+  default void rebase() throws RestApiException {
+    rebase(new RebaseInput());
+  }
 
   /** Rebase the current revision of a change. */
   void rebase(RebaseInput in) throws RestApiException;
@@ -172,15 +202,37 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
-  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
+  default AddReviewerResult addReviewer(String reviewer) throws RestApiException {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = reviewer;
+    return addReviewer(in);
+  }
 
-  AddReviewerResult addReviewer(String in) throws RestApiException;
+  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
 
-  SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException;
+  default SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
+    return suggestReviewers().withQuery(query);
+  }
 
-  ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException;
+  /**
+   * Retrieve reviewers ({@code ReviewerState.REVIEWER} and {@code ReviewerState.CC}) on the change.
+   */
+  List<ReviewerInfo> reviewers() throws RestApiException;
+
+  ChangeInfo get(
+      EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException;
+
+  default ChangeInfo get(ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException {
+    return get(EnumSet.noneOf(ListChangesOption.class), pluginOptions);
+  }
+
+  default ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException {
+    return get(options, ImmutableListMultimap.of());
+  }
 
   default ChangeInfo get(Iterable<ListChangesOption> options) throws RestApiException {
     return get(Sets.newEnumSet(options, ListChangesOption.class));
@@ -190,10 +242,28 @@
     return get(Arrays.asList(options));
   }
 
-  /** {@code get} with {@link ListChangesOption} set to all except CHECK. */
-  ChangeInfo get() throws RestApiException;
-  /** {@code get} with {@link ListChangesOption} set to none. */
-  ChangeInfo info() throws RestApiException;
+  /**
+   * {@link #get(ListChangesOption...)} with all options included, except for the following.
+   *
+   * <ul>
+   *   <li>{@code CHECK} is omitted, to skip consistency checks.
+   *   <li>{@code SKIP_MERGEABLE} is omitted, so the {@code mergeable} bit <em>is</em> set.
+   *   <li>{@code SKIP_DIFFSTAT} is omitted to ensure diffstat calculations.
+   * </ul>
+   */
+  default ChangeInfo get() throws RestApiException {
+    return get(
+        EnumSet.complementOf(
+            EnumSet.of(
+                ListChangesOption.CHECK,
+                ListChangesOption.SKIP_MERGEABLE,
+                ListChangesOption.SKIP_DIFFSTAT)));
+  }
+
+  /** {@link #get(ListChangesOption...)} with no options included. */
+  default ChangeInfo info() throws RestApiException {
+    return get(EnumSet.noneOf(ListChangesOption.class));
+  }
 
   /**
    * Retrieve change edit when exists.
@@ -202,7 +272,9 @@
    *     ChangeEditApi#get()}.
    */
   @Deprecated
-  EditInfo getEdit() throws RestApiException;
+  default EditInfo getEdit() throws RestApiException {
+    return edit().get().orElse(null);
+  }
 
   /**
    * Provides access to an API regarding the change edit of this change.
@@ -213,7 +285,11 @@
   ChangeEditApi edit() throws RestApiException;
 
   /** Create a new patch set with a new commit message. */
-  void setMessage(String message) throws RestApiException;
+  default void setMessage(String message) throws RestApiException {
+    CommitMessageInput in = new CommitMessageInput();
+    in.message = message;
+    setMessage(in);
+  }
 
   /** Create a new patch set with a new commit message. */
   void setMessage(CommitMessageInput in) throws RestApiException;
@@ -295,9 +371,8 @@
   /**
    * Look up a change message of a change by its id.
    *
-   * @param id the id of the change message. Note that in NoteDb, this id is the {@code ObjectId} of
-   *     a commit on the change meta branch. In ReviewDb, it's a UUID generated randomly. That means
-   *     a change message id could be different between NoteDb and ReviewDb.
+   * @param id the id of the change message. In NoteDb, this id is the {@code ObjectId} of a commit
+   *     on the change meta branch.
    * @return API for accessing a change message.
    * @throws RestApiException if the id is invalid.
    */
@@ -339,16 +414,6 @@
     }
 
     @Override
-    public RevisionApi current() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public RevisionApi revision(int id) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public ReviewerApi reviewer(String id) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -359,31 +424,16 @@
     }
 
     @Override
-    public void abandon() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void abandon(AbandonInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void restore() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void restore(RestoreInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void move(String destination) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void move(MoveInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -404,27 +454,11 @@
     }
 
     @Override
-    public ChangeApi revert() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public ChangeApi revert(RevertInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void publish() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Deprecated
-    @Override
-    public void rebase() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void rebase(RebaseInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -455,11 +489,6 @@
     }
 
     @Override
-    public AddReviewerResult addReviewer(String in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public SuggestedReviewersRequest suggestReviewers() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -470,22 +499,14 @@
     }
 
     @Override
-    public ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException {
+    public List<ReviewerInfo> reviewers() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public ChangeInfo get() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ChangeInfo info() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public void setMessage(String message) throws RestApiException {
+    public ChangeInfo get(
+        EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
+        throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -495,11 +516,6 @@
     }
 
     @Override
-    public EditInfo getEdit() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public ChangeEditApi edit() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
index 9d0275a..25eb7a8 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.EnumSet;
 import java.util.Optional;
 
 /**
@@ -29,6 +31,33 @@
  */
 public interface ChangeEditApi {
 
+  abstract class ChangeEditDetailRequest {
+    private String base;
+    private EnumSet<ChangeEditDetailOption> options = EnumSet.noneOf(ChangeEditDetailOption.class);
+
+    public abstract Optional<EditInfo> get() throws RestApiException;
+
+    public ChangeEditDetailRequest withBase(String base) {
+      this.base = base;
+      return this;
+    }
+
+    public ChangeEditDetailRequest withOption(ChangeEditDetailOption option) {
+      this.options.add(option);
+      return this;
+    }
+
+    public String getBase() {
+      return base;
+    }
+
+    public EnumSet<ChangeEditDetailOption> options() {
+      return options;
+    }
+  }
+
+  ChangeEditDetailRequest detail() throws RestApiException;
+
   /**
    * Retrieves details regarding the change edit.
    *
@@ -156,6 +185,11 @@
    */
   class NotImplemented implements ChangeEditApi {
     @Override
+    public ChangeEditDetailRequest detail() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Optional<EditInfo> get() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java
index eb2d2b9..66356f1 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java
@@ -24,6 +24,14 @@
   ChangeMessageInfo get() throws RestApiException;
 
   /**
+   * Deletes a change message by replacing its message. For NoteDb, it's implemented by rewriting
+   * the commit history of change meta branch.
+   *
+   * @return the change message with its message updated.
+   */
+  ChangeMessageInfo delete(DeleteChangeMessageInput input) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -32,5 +40,10 @@
     public ChangeMessageInfo get() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ChangeMessageInfo delete(DeleteChangeMessageInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java
index 0708ef5..bcb49de1 100644
--- a/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -75,7 +77,9 @@
     private String query;
     private int limit;
     private int start;
+    private boolean isNoLimit;
     private EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+    private ListMultimap<String, String> pluginOptions = ArrayListMultimap.create();
 
     public abstract List<ChangeInfo> get() throws RestApiException;
 
@@ -89,26 +93,46 @@
       return this;
     }
 
+    public QueryRequest withNoLimit() {
+      this.isNoLimit = true;
+      return this;
+    }
+
     public QueryRequest withStart(int start) {
       this.start = start;
       return this;
     }
 
+    /** Set an option on the request, appending to existing options. */
     public QueryRequest withOption(ListChangesOption options) {
       this.options.add(options);
       return this;
     }
 
+    /** Set options on the request, appending to existing options. */
     public QueryRequest withOptions(ListChangesOption... options) {
       this.options.addAll(Arrays.asList(options));
       return this;
     }
 
+    /** Set options on the request, replacing existing options. */
     public QueryRequest withOptions(EnumSet<ListChangesOption> options) {
       this.options = options;
       return this;
     }
 
+    /** Set a plugin option on the request, appending to existing options. */
+    public QueryRequest withPluginOption(String name, String value) {
+      this.pluginOptions.put(name, value);
+      return this;
+    }
+
+    /** Set a plugin option on the request, replacing existing options. */
+    public QueryRequest withPluginOptions(ListMultimap<String, String> options) {
+      this.pluginOptions = ArrayListMultimap.create(options);
+      return this;
+    }
+
     public String getQuery() {
       return query;
     }
@@ -117,6 +141,10 @@
       return limit;
     }
 
+    public boolean getNoLimit() {
+      return isNoLimit;
+    }
+
     public int getStart() {
       return start;
     }
@@ -125,6 +153,10 @@
       return options;
     }
 
+    public ListMultimap<String, String> getPluginOptions() {
+      return pluginOptions;
+    }
+
     @Override
     public String toString() {
       StringBuilder sb = new StringBuilder(getClass().getSimpleName()).append('{').append(query);
@@ -137,7 +169,11 @@
       if (!options.isEmpty()) {
         sb.append("options=").append(options);
       }
-      return sb.append('}').toString();
+      sb.append('}');
+      if (isNoLimit == true) {
+        sb.append(" --no-limit");
+      }
+      return sb.toString();
     }
   }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 694e06b..5ac67e7 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -28,4 +28,5 @@
   public Map<RecipientType, NotifyInfo> notifyDetails;
 
   public boolean keepReviewers;
+  public boolean allowConflicts;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/DeleteChangeMessageInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteChangeMessageInput.java
new file mode 100644
index 0000000..ece5a61
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/DeleteChangeMessageInput.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+/** Input for deleting a change message. */
+public class DeleteChangeMessageInput {
+  /** An optional reason for deleting the change message. */
+  @DefaultInput public String reason;
+
+  public DeleteChangeMessageInput() {
+    this.reason = "";
+  }
+
+  public DeleteChangeMessageInput(String reason) {
+    this.reason = reason;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/FileApi.java b/java/com/google/gerrit/extensions/api/changes/FileApi.java
index 89dc269..39cf2b7 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileApi.java
@@ -39,6 +39,9 @@
    */
   DiffRequest diffRequest() throws RestApiException;
 
+  /** Set the file reviewed or not reviewed */
+  void setReviewed(boolean reviewed) throws RestApiException;
+
   abstract class DiffRequest {
     private String base;
     private Integer context;
@@ -123,5 +126,10 @@
     public DiffRequest diffRequest() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void setReviewed(boolean reviewed) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java b/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
index 8f66f12..bbc8a2e 100644
--- a/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
@@ -26,4 +26,9 @@
   public HashtagsInput(Set<String> add) {
     this.add = add;
   }
+
+  public HashtagsInput(Set<String> add, Set<String> remove) {
+    this(add);
+    this.remove = remove;
+  }
 }
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/changes/NotifyInfo.java b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
index ef49651..14e0cdc 100644
--- a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
@@ -20,6 +20,10 @@
 public class NotifyInfo {
   public List<String> accounts;
 
+  /**
+   * @param accounts may be either just a list of: account IDs, Full names, usernames, or emails.
+   *     Also could be a list of those: "Full name <email@example.com>" or "Full name (<ID>)"
+   */
   public NotifyInfo(List<String> accounts) {
     this.accounts = accounts;
   }
diff --git a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
new file mode 100644
index 0000000..5bf22aa
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.extensions.common.CommitInfo;
+
+public class RelatedChangeAndCommitInfo {
+  public String project;
+  public String changeId;
+  public CommitInfo commit;
+  public Integer _changeNumber;
+  public Integer _revisionNumber;
+  public Integer _currentRevisionNumber;
+  public String status;
+
+  public RelatedChangeAndCommitInfo() {}
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("project", project)
+        .add("changeId", changeId)
+        .add("commit", commit)
+        .add("_changeNumber", _changeNumber)
+        .add("_revisionNumber", _revisionNumber)
+        .add("_currentRevisionNumber", _currentRevisionNumber)
+        .add("status", status)
+        .toString();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RelatedChangesInfo.java b/java/com/google/gerrit/extensions/api/changes/RelatedChangesInfo.java
new file mode 100644
index 0000000..e1e70f3
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/RelatedChangesInfo.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import java.util.List;
+
+public class RelatedChangesInfo {
+  public List<RelatedChangeAndCommitInfo> changes;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java b/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
index 3a33de9..3c32d29 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
@@ -38,7 +38,7 @@
 
   @Override
   public String toString() {
-    return username;
+    return username != null ? username : email;
   }
 
   private ReviewerInfo() {}
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 72e762c..7d356bf 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -14,14 +14,19 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -32,7 +37,9 @@
 
 public interface RevisionApi {
   @Deprecated
-  void delete() throws RestApiException;
+  default void delete() {
+    throw new UnsupportedOperationException("draft workflow is discontinued");
+  }
 
   String description() throws RestApiException;
 
@@ -40,20 +47,32 @@
 
   ReviewResult review(ReviewInput in) throws RestApiException;
 
-  void submit() throws RestApiException;
+  default void submit() throws RestApiException {
+    SubmitInput in = new SubmitInput();
+    submit(in);
+  }
 
   void submit(SubmitInput in) throws RestApiException;
 
-  BinaryResult submitPreview() throws RestApiException;
+  default BinaryResult submitPreview() throws RestApiException {
+    return submitPreview("zip");
+  }
 
   BinaryResult submitPreview(String format) throws RestApiException;
 
   @Deprecated
-  void publish() throws RestApiException;
+  default void publish() {
+    throw new UnsupportedOperationException("draft workflow is discontinued");
+  }
 
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
 
-  ChangeApi rebase() throws RestApiException;
+  CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
+
+  default ChangeApi rebase() throws RestApiException {
+    RebaseInput in = new RebaseInput();
+    return rebase(in);
+  }
 
   ChangeApi rebase(RebaseInput in) throws RestApiException;
 
@@ -65,9 +84,11 @@
 
   Set<String> reviewed() throws RestApiException;
 
-  Map<String, FileInfo> files() throws RestApiException;
+  default Map<String, FileInfo> files() throws RestApiException {
+    return files(null);
+  }
 
-  Map<String, FileInfo> files(String base) throws RestApiException;
+  Map<String, FileInfo> files(@Nullable String base) throws RestApiException;
 
   Map<String, FileInfo> files(int parentNum) throws RestApiException;
 
@@ -125,8 +146,15 @@
 
   SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException;
 
+  List<TestSubmitRuleInfo> testSubmitRule(TestSubmitRuleInput in) throws RestApiException;
+
   MergeListRequest getMergeList() throws RestApiException;
 
+  RelatedChangesInfo related() throws RestApiException;
+
+  /** Returns votes on the revision. */
+  ListMultimap<String, ApprovalInfo> votes() throws RestApiException;
+
   abstract class MergeListRequest {
     private boolean addLinks;
     private int uninterestingParent = 1;
@@ -157,40 +185,23 @@
    * interface.
    */
   class NotImplemented implements RevisionApi {
-    @Deprecated
-    @Override
-    public void delete() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
     @Override
     public ReviewResult review(ReviewInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void submit() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void submit(SubmitInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
-    @Deprecated
-    @Override
-    public void publish() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
     @Override
     public ChangeApi cherryPick(CherryPickInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public ChangeApi rebase() throws RestApiException {
+    public CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -240,11 +251,6 @@
     }
 
     @Override
-    public Map<String, FileInfo> files() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public List<String> queryFiles(String query) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -335,11 +341,6 @@
     }
 
     @Override
-    public BinaryResult submitPreview() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public BinaryResult submitPreview(String format) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -350,11 +351,26 @@
     }
 
     @Override
+    public List<TestSubmitRuleInfo> testSubmitRule(TestSubmitRuleInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public MergeListRequest getMergeList() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public RelatedChangesInfo related() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListMultimap<String, ApprovalInfo> votes() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void description(String description) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/config/Server.java b/java/com/google/gerrit/extensions/api/config/Server.java
index 5ec63af..70d1bff 100644
--- a/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/java/com/google/gerrit/extensions/api/config/Server.java
@@ -20,6 +20,8 @@
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.TopMenu;
+import java.util.List;
 
 public interface Server {
   /** @return Version of server. */
@@ -41,6 +43,8 @@
 
   ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException;
 
+  List<TopMenu.MenuEntry> topMenus() throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -93,5 +97,10 @@
     public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public List<TopMenu.MenuEntry> topMenus() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/groups/GroupApi.java b/java/com/google/gerrit/extensions/api/groups/GroupApi.java
index fe85eaa..067f120 100644
--- a/java/com/google/gerrit/extensions/api/groups/GroupApi.java
+++ b/java/com/google/gerrit/extensions/api/groups/GroupApi.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Arrays;
 import java.util.List;
 
 public interface GroupApi {
@@ -97,7 +98,18 @@
    *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
    * @throws RestApiException
    */
-  void addMembers(String... members) throws RestApiException;
+  void addMembers(List<String> members) throws RestApiException;
+
+  /**
+   * Add members to a group.
+   *
+   * @param members list of member identifiers, in any format accepted by {@link
+   *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
+   * @throws RestApiException
+   */
+  default void addMembers(String... members) throws RestApiException {
+    addMembers(Arrays.asList(members));
+  }
 
   /**
    * Remove members from a group.
@@ -106,7 +118,18 @@
    *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
    * @throws RestApiException
    */
-  void removeMembers(String... members) throws RestApiException;
+  void removeMembers(List<String> members) throws RestApiException;
+
+  /**
+   * Remove members from a group.
+   *
+   * @param members list of member identifiers, in any format accepted by {@link
+   *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
+   * @throws RestApiException
+   */
+  default void removeMembers(String... members) throws RestApiException {
+    removeMembers(Arrays.asList(members));
+  }
 
   /**
    * Lists the subgroups of this group.
@@ -122,7 +145,17 @@
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
    * @throws RestApiException
    */
-  void addGroups(String... groups) throws RestApiException;
+  void addGroups(List<String> groups) throws RestApiException;
+
+  /**
+   * Adds subgroups to this group.
+   *
+   * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
+   * @throws RestApiException
+   */
+  default void addGroups(String... groups) throws RestApiException {
+    addGroups(Arrays.asList(groups));
+  }
 
   /**
    * Removes subgroups from this group.
@@ -130,7 +163,17 @@
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
    * @throws RestApiException
    */
-  void removeGroups(String... groups) throws RestApiException;
+  void removeGroups(List<String> groups) throws RestApiException;
+
+  /**
+   * Removes subgroups from this group.
+   *
+   * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
+   * @throws RestApiException
+   */
+  default void removeGroups(String... groups) throws RestApiException {
+    removeGroups(Arrays.asList(groups));
+  }
 
   /**
    * Returns the audit log of the group.
@@ -215,12 +258,12 @@
     }
 
     @Override
-    public void addMembers(String... members) throws RestApiException {
+    public void addMembers(List<String> members) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void removeMembers(String... members) throws RestApiException {
+    public void removeMembers(List<String> members) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -230,12 +273,12 @@
     }
 
     @Override
-    public void addGroups(String... groups) throws RestApiException {
+    public void addGroups(List<String> groups) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void removeGroups(String... groups) throws RestApiException {
+    public void removeGroups(List<String> groups) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/groups/Groups.java b/java/com/google/gerrit/extensions/api/groups/Groups.java
index 0243ba3..86c2d77 100644
--- a/java/com/google/gerrit/extensions/api/groups/Groups.java
+++ b/java/com/google/gerrit/extensions/api/groups/Groups.java
@@ -253,16 +253,19 @@
       return this;
     }
 
+    /** Set an option on the request, appending to existing options. */
     public QueryRequest withOption(ListGroupsOption options) {
       this.options.add(options);
       return this;
     }
 
+    /** Set options on the request, appending to existing options. */
     public QueryRequest withOptions(ListGroupsOption... options) {
       this.options.addAll(Arrays.asList(options));
       return this;
     }
 
+    /** Set options on the request, replacing existing options. */
     public QueryRequest withOptions(EnumSet<ListGroupsOption> options) {
       this.options = options;
       return this;
diff --git a/java/com/google/gerrit/extensions/api/plugins/InstallPluginInput.java b/java/com/google/gerrit/extensions/api/plugins/InstallPluginInput.java
new file mode 100644
index 0000000..f0ef07c
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/plugins/InstallPluginInput.java
@@ -0,0 +1,23 @@
+// 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.plugins;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.RawInput;
+
+public class InstallPluginInput {
+  public @DefaultInput String url;
+  public RawInput raw;
+}
diff --git a/java/com/google/gerrit/extensions/api/plugins/Plugins.java b/java/com/google/gerrit/extensions/api/plugins/Plugins.java
index 2828db5..6c2d6db 100644
--- a/java/com/google/gerrit/extensions/api/plugins/Plugins.java
+++ b/java/com/google/gerrit/extensions/api/plugins/Plugins.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.extensions.api.plugins;
 
-import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -29,6 +28,10 @@
 
   PluginApi name(String name) throws RestApiException;
 
+  @Deprecated
+  PluginApi install(String name, com.google.gerrit.extensions.common.InstallPluginInput input)
+      throws RestApiException;
+
   PluginApi install(String name, InstallPluginInput input) throws RestApiException;
 
   abstract class ListRequest {
@@ -121,7 +124,15 @@
     }
 
     @Override
-    public PluginApi install(String name, InstallPluginInput input) throws RestApiException {
+    @Deprecated
+    public PluginApi install(
+        String name, com.google.gerrit.extensions.common.InstallPluginInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public PluginApi install(String name, InstallPluginInput input) {
       throw new NotImplementedException();
     }
   }
diff --git a/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java b/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java
new file mode 100644
index 0000000..145b200
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+public class CheckProjectInput {
+  public AutoCloseableChangesCheckInput autoCloseableChangesCheck;
+
+  public static class AutoCloseableChangesCheckInput {
+    /** Whether auto-closeable changes should be fixed by setting their status to MERGED. */
+    public Boolean fix;
+
+    /** Branch that should be checked for auto-closeable changes. */
+    public String branch;
+
+    /** Number of commits to skip. */
+    public Integer skipCommits;
+
+    /** Maximum number of commits to walk. */
+    public Integer maxCommits;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java b/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java
new file mode 100644
index 0000000..e685122
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.common.ChangeInfo;
+import java.util.List;
+
+public class CheckProjectResultInfo {
+  public AutoCloseableChangesCheckResult autoCloseableChangesCheckResult;
+
+  public static class AutoCloseableChangesCheckResult {
+    public List<ChangeInfo> autoCloseableChanges;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
index 441022a..23849e4 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.common.base.MoreObjects;
 import java.util.Objects;
 
 public class CommentLinkInfo {
@@ -43,4 +44,15 @@
   public int hashCode() {
     return Objects.hash(match, link, html, enabled);
   }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("name", name)
+        .add("match", match)
+        .add("link", link)
+        .add("html", html)
+        .add("enabled", enabled)
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
index 6084962..6b3c1fb 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommitApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
@@ -23,11 +24,18 @@
 
   ChangeApi cherryPick(CherryPickInput input) throws RestApiException;
 
+  IncludedInInfo includedIn() throws RestApiException;
+
   /** A default implementation for source compatibility when adding new methods to the interface. */
   class NotImplemented implements CommitApi {
     @Override
     public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public IncludedInInfo includedIn() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index 80115aa..fb2a0fe 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -33,6 +34,7 @@
   public InheritedBooleanInfo requireSignedPush;
   public InheritedBooleanInfo rejectImplicitMerges;
   public InheritedBooleanInfo privateByDefault;
+  public InheritedBooleanInfo workInProgressByDefault;
   public InheritedBooleanInfo enableReviewerByEmail;
   public InheritedBooleanInfo matchAuthorToCommitterDate;
   public InheritedBooleanInfo rejectEmptyCommit;
@@ -46,7 +48,6 @@
   public Map<String, ActionInfo> actions;
 
   public Map<String, CommentLinkInfo> commentlinks;
-  public ThemeInfo theme;
 
   public Map<String, List<String>> extensionPanelNames;
 
@@ -57,9 +58,17 @@
   }
 
   public static class MaxObjectSizeLimitInfo {
-    public String value;
-    public String configuredValue;
-    public String inheritedValue;
+    /** The effective value in bytes. Null if not set. */
+    @Nullable public String value;
+
+    /** The value configured explicitly on the project as a formatted string. Null if not set. */
+    @Nullable public String configuredValue;
+
+    /**
+     * Whether the value was inherited or overridden from the project's parent hierarchy or global
+     * config. Null if not inherited or overridden.
+     */
+    @Nullable public String summary;
   }
 
   public static class ConfigParameterInfo {
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 37a2e8b..1a6d77b 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -30,6 +30,7 @@
   public InheritableBoolean requireSignedPush;
   public InheritableBoolean rejectImplicitMerges;
   public InheritableBoolean privateByDefault;
+  public InheritableBoolean workInProgressByDefault;
   public InheritableBoolean enableReviewerByEmail;
   public InheritableBoolean matchAuthorToCommitterDate;
   public InheritableBoolean rejectEmptyCommit;
diff --git a/java/com/google/gerrit/extensions/api/projects/IndexProjectInput.java b/java/com/google/gerrit/extensions/api/projects/IndexProjectInput.java
new file mode 100644
index 0000000..1b962c1
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/IndexProjectInput.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.extensions.api.projects;
+
+public class IndexProjectInput {
+  public Boolean indexChildren;
+  public Boolean async;
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index c9f47c2..3d70996 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;
@@ -104,6 +106,8 @@
 
   List<ProjectInfo> children(boolean recursive) throws RestApiException;
 
+  List<ProjectInfo> children(int limit) throws RestApiException;
+
   ChildProjectApi child(String name) throws RestApiException;
 
   /**
@@ -191,6 +195,13 @@
   void parent(String parent) throws RestApiException;
 
   /**
+   * Reindex the project and children in case {@code indexChildren} is specified.
+   *
+   * @param indexChildren decides if children should be indexed recursively
+   */
+  void index(boolean indexChildren) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -236,6 +247,11 @@
     }
 
     @Override
+    public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ConfigInfo config() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -271,6 +287,11 @@
     }
 
     @Override
+    public List<ProjectInfo> children(int limit) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChildProjectApi child(String name) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -344,5 +365,10 @@
     public void parent(String parent) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void index(boolean indexChildren) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectInput.java b/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
index b7079ae..e61d316 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
@@ -34,6 +34,8 @@
   public InheritableBoolean requireChangeId;
   public InheritableBoolean createNewChangeForAllNotInTarget;
   public InheritableBoolean rejectEmptyCommit;
+  public InheritableBoolean enableSignedPush;
+  public InheritableBoolean requireSignedPush;
   public String maxObjectSizeLimit;
   public Map<String, Map<String, ConfigValue>> pluginConfigValues;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/Projects.java b/java/com/google/gerrit/extensions/api/projects/Projects.java
index 85ec26f..34ca7d4 100644
--- a/java/com/google/gerrit/extensions/api/projects/Projects.java
+++ b/java/com/google/gerrit/extensions/api/projects/Projects.java
@@ -80,7 +80,6 @@
   abstract class ListRequest {
     public enum FilterType {
       CODE,
-      PARENT_CANDIDATES,
       PERMISSIONS,
       ALL
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/RefInfo.java b/java/com/google/gerrit/extensions/api/projects/RefInfo.java
index c573600..dde6e4b 100644
--- a/java/com/google/gerrit/extensions/api/projects/RefInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/RefInfo.java
@@ -14,8 +14,19 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.common.base.MoreObjects;
+
 public class RefInfo {
   public String ref;
   public String revision;
   public Boolean canDelete;
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("ref", ref)
+        .add("revision", revision)
+        .add("canDelete", canDelete)
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java b/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
new file mode 100644
index 0000000..0083c0e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
@@ -0,0 +1,22 @@
+// 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.extensions.api.projects;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class SetDashboardInput {
+  @DefaultInput public String id;
+  public String commitMessage;
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java b/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
deleted file mode 100644
index d5d520f..0000000
--- a/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 ThemeInfo {
-  public static final ThemeInfo INHERIT = new ThemeInfo(null, null, null);
-
-  public final String css;
-  public final String header;
-  public final String footer;
-
-  public ThemeInfo(String css, String header, String footer) {
-    this.css = css;
-    this.header = header;
-    this.footer = footer;
-  }
-}
diff --git a/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
index 84b6a04..8d5e744 100644
--- a/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
+++ b/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.extensions.auth.oauth;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
@@ -55,9 +55,9 @@
 
   public OAuthToken(
       String token, String secret, String raw, long expiresAt, @Nullable String providerId) {
-    this.token = checkNotNull(token, "token");
-    this.secret = checkNotNull(secret, "secret");
-    this.raw = checkNotNull(raw, "raw");
+    this.token = requireNonNull(token, "token");
+    this.secret = requireNonNull(secret, "secret");
+    this.raw = requireNonNull(raw, "raw");
     this.expiresAt = expiresAt;
     this.providerId = Strings.emptyToNull(providerId);
   }
diff --git a/java/com/google/gerrit/extensions/client/ChangeEditDetailOption.java b/java/com/google/gerrit/extensions/client/ChangeEditDetailOption.java
new file mode 100644
index 0000000..156b768
--- /dev/null
+++ b/java/com/google/gerrit/extensions/client/ChangeEditDetailOption.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.client;
+
+public enum ChangeEditDetailOption {
+  LIST_FILES,
+  DOWNLOAD_COMMANDS
+}
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index 3307997..d5fbf89 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -30,7 +30,9 @@
   public String path;
   public Side side;
   public Integer parent;
-  public Integer line; // value 0 or null indicates a file comment, normal lines start at 1
+  /** Value 0 or null indicates a file comment, normal lines start at 1. */
+  public Integer line;
+
   public Range range;
   public String inReplyTo;
   public Timestamp updated;
@@ -44,10 +46,11 @@
             .thenComparingInt(range -> range.endLine)
             .thenComparingInt(range -> range.endCharacter);
 
-    public int startLine; // 1-based, inclusive
-    public int startCharacter; // 0-based, inclusive
-    public int endLine; // 1-based, exclusive
-    public int endCharacter; // 0-based, exclusive
+    // Start position is inclusive; end position is exclusive.
+    public int startLine; // 1-based
+    public int startCharacter; // 0-based
+    public int endLine; // 1-based
+    public int endCharacter; // 0-based
 
     public boolean isValid() {
       return startLine > 0
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 0d5bdfa..522ec88 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -38,7 +38,7 @@
     IGNORE_NONE,
     IGNORE_TRAILING,
     IGNORE_LEADING_AND_TRAILING,
-    IGNORE_ALL;
+    IGNORE_ALL
   }
 
   public Integer context;
@@ -74,18 +74,12 @@
     i.fontSize = DEFAULT_FONT_SIZE;
     i.lineLength = DEFAULT_LINE_LENGTH;
     i.cursorBlinkRate = 0;
-    i.ignoreWhitespace = Whitespace.IGNORE_NONE;
-    i.theme = Theme.DEFAULT;
     i.expandAllComments = false;
     i.intralineDifference = true;
     i.manualReview = false;
-    i.retainHeader = false;
     i.showLineEndings = true;
     i.showTabs = true;
     i.showWhitespaceErrors = true;
-    i.skipDeleted = false;
-    i.skipUnchanged = false;
-    i.skipUncommented = false;
     i.syntaxHighlighting = true;
     i.hideTopMenu = false;
     i.autoHideDiffTableHeader = true;
@@ -94,6 +88,12 @@
     i.hideEmptyPane = false;
     i.matchBrackets = false;
     i.lineWrapping = false;
+    i.theme = Theme.DEFAULT;
+    i.ignoreWhitespace = Whitespace.IGNORE_NONE;
+    i.retainHeader = false;
+    i.skipDeleted = false;
+    i.skipUnchanged = false;
+    i.skipUncommented = false;
     return i;
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 9dcba5e..fa95b8f 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -151,13 +151,14 @@
   public ReviewCategoryStrategy reviewCategoryStrategy;
   public Boolean muteCommonPathPrefixes;
   public Boolean signedOffBy;
-  public List<MenuItem> my;
-  public List<String> changeTable;
-  public Map<String, String> urlAliases;
   public EmailStrategy emailStrategy;
   public EmailFormat emailFormat;
   public DefaultBase defaultBaseForMerges;
   public Boolean publishCommentsOnPush;
+  public Boolean workInProgressByDefault;
+  public List<MenuItem> my;
+  public List<String> changeTable;
+  public Map<String, String> urlAliases;
 
   public boolean isShowInfoInReviewCategory() {
     return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
@@ -210,9 +211,6 @@
     p.changesPerPage = DEFAULT_PAGESIZE;
     p.showSiteHeader = true;
     p.useFlashClipboard = true;
-    p.emailStrategy = EmailStrategy.ENABLED;
-    p.emailFormat = EmailFormat.HTML_PLAINTEXT;
-    p.reviewCategoryStrategy = ReviewCategoryStrategy.NONE;
     p.downloadScheme = null;
     p.downloadCommand = DownloadCommand.CHECKOUT;
     p.dateFormat = DateFormat.STD;
@@ -223,10 +221,14 @@
     p.diffView = DiffView.SIDE_BY_SIDE;
     p.sizeBarInChangeTable = true;
     p.legacycidInChangeTable = false;
+    p.reviewCategoryStrategy = ReviewCategoryStrategy.NONE;
     p.muteCommonPathPrefixes = true;
     p.signedOffBy = false;
+    p.emailStrategy = EmailStrategy.ENABLED;
+    p.emailFormat = EmailFormat.HTML_PLAINTEXT;
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     p.publishCommentsOnPush = false;
+    p.workInProgressByDefault = false;
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/ListAccountsOption.java b/java/com/google/gerrit/extensions/client/ListAccountsOption.java
index b5e9004..2274d5d 100644
--- a/java/com/google/gerrit/extensions/client/ListAccountsOption.java
+++ b/java/com/google/gerrit/extensions/client/ListAccountsOption.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.extensions.client;
 
-import java.util.EnumSet;
-import java.util.Set;
-
 /** Output options available for retrieval of account details. */
-public enum ListAccountsOption {
+public enum ListAccountsOption implements ListOption {
   /** Return detailed account properties. */
   DETAILS(0),
 
@@ -31,32 +28,8 @@
     this.value = v;
   }
 
+  @Override
   public int getValue() {
     return value;
   }
-
-  public static EnumSet<ListAccountsOption> fromBits(int v) {
-    EnumSet<ListAccountsOption> r = EnumSet.noneOf(ListAccountsOption.class);
-    for (ListAccountsOption o : ListAccountsOption.values()) {
-      if ((v & (1 << o.value)) != 0) {
-        r.add(o);
-        v &= ~(1 << o.value);
-      }
-      if (v == 0) {
-        return r;
-      }
-    }
-    if (v != 0) {
-      throw new IllegalArgumentException("unknown " + Integer.toHexString(v));
-    }
-    return r;
-  }
-
-  public static int toBits(Set<ListAccountsOption> set) {
-    int r = 0;
-    for (ListAccountsOption o : set) {
-      r |= 1 << o.value;
-    }
-    return r;
-  }
 }
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index ffc5029..425265b 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.extensions.client;
 
-import java.util.EnumSet;
-import java.util.Set;
-
-/** Output options available for retrieval change details. */
-public enum ListChangesOption {
+/** Output options available for retrieval of change details. */
+public enum ListChangesOption implements ListOption {
   LABELS(0),
   DETAILED_LABELS(8),
 
@@ -78,7 +75,13 @@
   TRACKING_IDS(21),
 
   /** Skip mergeability data */
-  SKIP_MERGEABLE(22);
+  SKIP_MERGEABLE(22),
+
+  /**
+   * Skip diffstat computation that compute the insertions field (number of lines inserted) and
+   * deletions field (number of lines deleted)
+   */
+  SKIP_DIFFSTAT(23);
 
   private final int value;
 
@@ -86,32 +89,8 @@
     this.value = v;
   }
 
+  @Override
   public int getValue() {
     return value;
   }
-
-  public static EnumSet<ListChangesOption> fromBits(int v) {
-    EnumSet<ListChangesOption> r = EnumSet.noneOf(ListChangesOption.class);
-    for (ListChangesOption o : ListChangesOption.values()) {
-      if ((v & (1 << o.value)) != 0) {
-        r.add(o);
-        v &= ~(1 << o.value);
-      }
-      if (v == 0) {
-        return r;
-      }
-    }
-    if (v != 0) {
-      throw new IllegalArgumentException("unknown " + Integer.toHexString(v));
-    }
-    return r;
-  }
-
-  public static int toBits(Set<ListChangesOption> set) {
-    int r = 0;
-    for (ListChangesOption o : set) {
-      r |= 1 << o.value;
-    }
-    return r;
-  }
 }
diff --git a/java/com/google/gerrit/extensions/client/ListGroupsOption.java b/java/com/google/gerrit/extensions/client/ListGroupsOption.java
index e95570f..a971226 100644
--- a/java/com/google/gerrit/extensions/client/ListGroupsOption.java
+++ b/java/com/google/gerrit/extensions/client/ListGroupsOption.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.extensions.client;
 
-import java.util.EnumSet;
-
 /** Output options available when using {@code /groups/} RPCs. */
-public enum ListGroupsOption {
+public enum ListGroupsOption implements ListOption {
   /** Return information on the direct group members. */
   MEMBERS(0),
 
@@ -30,32 +28,8 @@
     this.value = v;
   }
 
+  @Override
   public int getValue() {
     return value;
   }
-
-  public static EnumSet<ListGroupsOption> fromBits(int v) {
-    EnumSet<ListGroupsOption> r = EnumSet.noneOf(ListGroupsOption.class);
-    for (ListGroupsOption o : ListGroupsOption.values()) {
-      if ((v & (1 << o.value)) != 0) {
-        r.add(o);
-        v &= ~(1 << o.value);
-      }
-      if (v == 0) {
-        return r;
-      }
-    }
-    if (v != 0) {
-      throw new IllegalArgumentException("unknown " + Integer.toHexString(v));
-    }
-    return r;
-  }
-
-  public static int toBits(EnumSet<ListGroupsOption> set) {
-    int r = 0;
-    for (ListGroupsOption o : set) {
-      r |= 1 << o.value;
-    }
-    return r;
-  }
 }
diff --git a/java/com/google/gerrit/extensions/client/ListOption.java b/java/com/google/gerrit/extensions/client/ListOption.java
new file mode 100644
index 0000000..e694c0e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/client/ListOption.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.client;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.EnumSet;
+
+/** Enum that can be expressed as a bitset in query parameters. */
+public interface ListOption {
+  int getValue();
+
+  static <T extends Enum<T> & ListOption> EnumSet<T> fromBits(Class<T> clazz, int v) {
+    EnumSet<T> r = EnumSet.noneOf(clazz);
+    T[] values;
+    try {
+      @SuppressWarnings("unchecked")
+      T[] tmp = (T[]) clazz.getMethod("values").invoke(null);
+      values = tmp;
+    } catch (IllegalAccessException | NoSuchMethodException | InvocationTargetException e) {
+      throw new IllegalStateException(e);
+    }
+    for (T o : values) {
+      if ((v & (1 << o.getValue())) != 0) {
+        r.add(o);
+        v &= ~(1 << o.getValue());
+      }
+      if (v == 0) {
+        return r;
+      }
+    }
+    if (v != 0) {
+      throw new IllegalArgumentException(
+          "unknown " + clazz.getName() + ": " + Integer.toHexString(v));
+    }
+    return r;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/client/MenuItem.java b/java/com/google/gerrit/extensions/client/MenuItem.java
index 8375bba..0c7dd88 100644
--- a/java/com/google/gerrit/extensions/client/MenuItem.java
+++ b/java/com/google/gerrit/extensions/client/MenuItem.java
@@ -22,11 +22,6 @@
   public final String target;
   public final String id;
 
-  // Needed for GWT
-  public MenuItem() {
-    this(null, null, null, null);
-  }
-
   public MenuItem(String name, String url) {
     this(name, url, "_blank");
   }
diff --git a/java/com/google/gerrit/extensions/client/UiType.java b/java/com/google/gerrit/extensions/client/UiType.java
deleted file mode 100644
index 0d9df39..0000000
--- a/java/com/google/gerrit/extensions/client/UiType.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.client;
-
-public enum UiType {
-  NONE,
-  GWT,
-  POLYGERRIT;
-
-  public static UiType parse(String str) {
-    if (str != null) {
-      for (UiType type : UiType.values()) {
-        if (type.name().equalsIgnoreCase(str)) {
-          return type;
-        }
-      }
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
new file mode 100644
index 0000000..a498ab0
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AccountDetailInfo.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.common;
+
+import java.sql.Timestamp;
+
+public class AccountDetailInfo extends AccountInfo {
+  public Timestamp registeredOn;
+  public Boolean inactive;
+
+  public AccountDetailInfo(Integer id) {
+    super(id);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
index 9c64fd0..9e6770b 100644
--- a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import static com.google.common.base.MoreObjects.toStringHelper;
+
 import com.google.common.collect.ComparisonChain;
 import java.util.Objects;
 
@@ -47,4 +49,14 @@
   public int hashCode() {
     return Objects.hash(identity, emailAddress, trusted, canDelete);
   }
+
+  @Override
+  public String toString() {
+    return toStringHelper(this)
+        .add("identity", identity)
+        .add("emailAddress", emailAddress)
+        .add("trusted", trusted)
+        .add("canDelete", canDelete)
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AgreementInput.java b/java/com/google/gerrit/extensions/common/AgreementInput.java
deleted file mode 100644
index 0c6cf2d..0000000
--- a/java/com/google/gerrit/extensions/common/AgreementInput.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-/** This entity contains information for registering a new contributor agreement. */
-public class AgreementInput {
-  /* The agreement name. */
-  @DefaultInput public String name;
-}
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index 9125bfd..e40004b 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
 
 public class ApprovalInfo extends AccountInfo {
@@ -26,4 +27,17 @@
   public ApprovalInfo(Integer id) {
     super(id);
   }
+
+  public ApprovalInfo(
+      Integer id,
+      Integer value,
+      @Nullable VotingRangeInfo permittedVotingRange,
+      @Nullable String tag,
+      Timestamp date) {
+    super(id);
+    this.value = value;
+    this.permittedVotingRange = permittedVotingRange;
+    this.date = date;
+    this.tag = tag;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
index 00f1819..de609eb 100644
--- a/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -21,7 +21,7 @@
    * <p>The web UI prefers avatar images to be square, both the height and width of the image should
    * be this size. The height is the more important dimension to match than the width.
    */
-  public static final int DEFAULT_SIZE = 26;
+  public static final int DEFAULT_SIZE = 32;
 
   public String url;
   public Integer height;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index c95dcc3..9a739ef 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -23,6 +23,8 @@
 import java.util.Map;
 
 public class ChangeInfo {
+  // ActionJson#copy(List, ChangeInfo) must be adapted if new fields are added that are not
+  // protected by any ListChangesOption.
   public String id;
   public String project;
   public String branch;
@@ -44,6 +46,7 @@
   public Boolean submittable;
   public Integer insertions;
   public Integer deletions;
+  public Integer totalCommentCount;
   public Integer unresolvedCommentCount;
   public Boolean isPrivate;
   public Boolean workInProgress;
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index a2e61b8..07ad71b 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -45,4 +45,24 @@
   public int hashCode() {
     return Objects.hash(id, tag, author, realAuthor, date, message, _revisionNumber);
   }
+
+  @Override
+  public String toString() {
+    return "ChangeMessageInfo{"
+        + "id="
+        + id
+        + ", tag="
+        + tag
+        + ", author="
+        + author
+        + ", realAuthor="
+        + realAuthor
+        + ", date="
+        + date
+        + ", _revisionNumber"
+        + _revisionNumber
+        + ", message=["
+        + message
+        + "]}";
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/CherryPickChangeInfo.java b/java/com/google/gerrit/extensions/common/CherryPickChangeInfo.java
new file mode 100644
index 0000000..5e2b902
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/CherryPickChangeInfo.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class CherryPickChangeInfo extends ChangeInfo {
+  public Boolean containsGitConflicts;
+}
diff --git a/java/com/google/gerrit/extensions/common/CommitInfo.java b/java/com/google/gerrit/extensions/common/CommitInfo.java
index 213b366..1fd8755 100644
--- a/java/com/google/gerrit/extensions/common/CommitInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommitInfo.java
@@ -16,6 +16,8 @@
 
 import static java.util.stream.Collectors.joining;
 
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
 import java.util.List;
 import java.util.Objects;
 
@@ -50,17 +52,18 @@
 
   @Override
   public String toString() {
-    // Using something like the raw commit format might be nice, but we can't depend on JGit here.
-    StringBuilder sb = new StringBuilder().append(getClass().getSimpleName()).append('{');
-    sb.append(commit);
-    sb.append(", parents=").append(parents.stream().map(p -> p.commit).collect(joining(", ")));
-    sb.append(", author=").append(author);
-    sb.append(", committer=").append(committer);
-    sb.append(", subject=").append(subject);
-    sb.append(", message=").append(message);
-    if (webLinks != null) {
-      sb.append(", webLinks=").append(webLinks);
+    ToStringHelper helper = MoreObjects.toStringHelper(this).addValue(commit);
+    if (parents != null) {
+      helper.add("parents", parents.stream().map(p -> p.commit).collect(joining(", ")));
     }
-    return sb.append('}').toString();
+    helper
+        .add("author", author)
+        .add("committer", committer)
+        .add("subject", subject)
+        .add("message", message);
+    if (webLinks != null) {
+      helper.add("webLinks", webLinks);
+    }
+    return helper.toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/EditInfo.java b/java/com/google/gerrit/extensions/common/EditInfo.java
index 46ef879..0cd5af3 100644
--- a/java/com/google/gerrit/extensions/common/EditInfo.java
+++ b/java/com/google/gerrit/extensions/common/EditInfo.java
@@ -20,6 +20,7 @@
   public CommitInfo commit;
   public int basePatchSetNumber;
   public String baseRevision;
+  public String ref;
   public Map<String, FetchInfo> fetch;
   public Map<String, FileInfo> files;
 }
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index a812908..32c5bd5 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class FileInfo {
   public Character status;
   public Boolean binary;
@@ -22,4 +24,24 @@
   public Integer linesDeleted;
   public long sizeDelta;
   public long size;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof FileInfo) {
+      FileInfo fileInfo = (FileInfo) o;
+      return Objects.equals(status, fileInfo.status)
+          && Objects.equals(binary, fileInfo.binary)
+          && Objects.equals(oldPath, fileInfo.oldPath)
+          && Objects.equals(linesInserted, fileInfo.linesInserted)
+          && Objects.equals(linesDeleted, fileInfo.linesDeleted)
+          && Objects.equals(sizeDelta, fileInfo.sizeDelta)
+          && Objects.equals(size, fileInfo.size);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(status, binary, oldPath, linesInserted, linesDeleted, sizeDelta, size);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index f904b06..5c462d9 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.extensions.client.UiType;
-import java.util.Set;
-
 public class GerritInfo {
   public String allProjects;
   public String allUsers;
@@ -24,6 +21,5 @@
   public String docUrl;
   public Boolean editGpgKeys;
   public String reportBugUrl;
-  public String reportBugText;
-  public Set<UiType> webUis;
+  public String primaryWeblinkName;
 }
diff --git a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
index 3e6f762..711337a 100644
--- a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Optional;
 
 public abstract class GroupAuditEventInfo {
   public enum Type {
@@ -30,35 +31,35 @@
 
   public static UserMemberAuditEventInfo createAddUserEvent(
       AccountInfo user, Timestamp date, AccountInfo member) {
-    return new UserMemberAuditEventInfo(Type.ADD_USER, user, date, member);
+    return new UserMemberAuditEventInfo(Type.ADD_USER, user, Optional.of(date), member);
   }
 
   public static UserMemberAuditEventInfo createRemoveUserEvent(
-      AccountInfo user, Timestamp date, AccountInfo member) {
+      AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
     return new UserMemberAuditEventInfo(Type.REMOVE_USER, user, date, member);
   }
 
   public static GroupMemberAuditEventInfo createAddGroupEvent(
       AccountInfo user, Timestamp date, GroupInfo member) {
-    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date, member);
+    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, Optional.of(date), member);
   }
 
   public static GroupMemberAuditEventInfo createRemoveGroupEvent(
-      AccountInfo user, Timestamp date, GroupInfo member) {
+      AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
     return new GroupMemberAuditEventInfo(Type.REMOVE_GROUP, user, date, member);
   }
 
-  protected GroupAuditEventInfo(Type type, AccountInfo user, Timestamp date) {
+  protected GroupAuditEventInfo(Type type, AccountInfo user, Optional<Timestamp> date) {
     this.type = type;
     this.user = user;
-    this.date = date;
+    this.date = date.orElse(null);
   }
 
   public static class UserMemberAuditEventInfo extends GroupAuditEventInfo {
     public AccountInfo member;
 
-    public UserMemberAuditEventInfo(
-        Type type, AccountInfo user, Timestamp date, AccountInfo member) {
+    private UserMemberAuditEventInfo(
+        Type type, AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
       super(type, user, date);
       this.member = member;
     }
@@ -67,8 +68,8 @@
   public static class GroupMemberAuditEventInfo extends GroupAuditEventInfo {
     public GroupInfo member;
 
-    public GroupMemberAuditEventInfo(
-        Type type, AccountInfo user, Timestamp date, GroupInfo member) {
+    private GroupMemberAuditEventInfo(
+        Type type, AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
       super(type, user, date);
       this.member = member;
     }
diff --git a/java/com/google/gerrit/extensions/common/GroupBaseInfo.java b/java/com/google/gerrit/extensions/common/GroupBaseInfo.java
index 288adb6..4d35b36 100644
--- a/java/com/google/gerrit/extensions/common/GroupBaseInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupBaseInfo.java
@@ -14,7 +14,14 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.base.MoreObjects;
+
 public class GroupBaseInfo {
   public String id;
   public String name;
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("name", name).add("id", id).toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/InstallPluginInput.java b/java/com/google/gerrit/extensions/common/InstallPluginInput.java
index 4774ae7..9cefb0f 100644
--- a/java/com/google/gerrit/extensions/common/InstallPluginInput.java
+++ b/java/com/google/gerrit/extensions/common/InstallPluginInput.java
@@ -17,6 +17,8 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.RawInput;
 
+/** @deprecated use {@link com.google.gerrit.extensions.api.plugins.InstallPluginInput}. */
+@Deprecated
 public class InstallPluginInput {
   public @DefaultInput String url;
   public RawInput raw;
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index fc443dd..f262901 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -19,6 +19,8 @@
 import java.util.Map;
 
 public class RevisionInfo {
+  // ActionJson#copy(List, RevisionInfo) must be adapted if new fields are added that are not
+  // protected by any ListChangesOption.
   public transient boolean isCurrent;
   public ChangeKind kind;
   public int _number;
diff --git a/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/java/com/google/gerrit/extensions/common/SetDashboardInput.java
deleted file mode 100644
index 13d2b9d..0000000
--- a/java/com/google/gerrit/extensions/common/SetDashboardInput.java
+++ /dev/null
@@ -1,22 +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.extensions.common;
-
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-public class SetDashboardInput {
-  @DefaultInput public String id;
-  public String commitMessage;
-}
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/TestSubmitRuleInfo.java b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
new file mode 100644
index 0000000..bd7ebcb
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.common.base.MoreObjects;
+import java.util.Map;
+import java.util.Objects;
+
+public class TestSubmitRuleInfo {
+  /** @see com.google.gerrit.common.data.SubmitRecord.Status */
+  public String status;
+
+  public String errorMessage;
+  public Map<String, AccountInfo> ok;
+  public Map<String, AccountInfo> reject;
+  public Map<String, None> need;
+  public Map<String, AccountInfo> may;
+  public Map<String, None> impossible;
+
+  public static class None {
+    private None() {}
+
+    public static None INSTANCE = new None();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof TestSubmitRuleInfo) {
+      TestSubmitRuleInfo other = (TestSubmitRuleInfo) o;
+      return Objects.equals(status, other.status)
+          && Objects.equals(errorMessage, other.errorMessage)
+          && Objects.equals(ok, other.ok)
+          && Objects.equals(reject, other.reject)
+          && Objects.equals(need, other.need)
+          && Objects.equals(may, other.may)
+          && Objects.equals(impossible, other.impossible);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(status, errorMessage, ok, reject, need, may, impossible);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("status", status)
+        .add("errorMessage", errorMessage)
+        .add("ok", ok)
+        .add("reject", reject)
+        .add("need", need)
+        .add("may", may)
+        .add("impossible", impossible)
+        .toString();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/BUILD b/java/com/google/gerrit/extensions/common/testing/BUILD
index 94fecbf..7092d21 100644
--- a/java/com/google/gerrit/extensions/common/testing/BUILD
+++ b/java/com/google/gerrit/extensions/common/testing/BUILD
@@ -1,11 +1,12 @@
 java_library(
     name = "common-test-util",
-    testonly = 1,
+    testonly = True,
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
+        "//lib:guava",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
     ],
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index 6dd5ce4..d6fcb37 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -15,52 +15,54 @@
 package com.google.gerrit.extensions.common.testing;
 
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.common.testing.GitPersonSubject.gitPersons;
+import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.truth.ListSubject;
 
-public class CommitInfoSubject extends Subject<CommitInfoSubject, CommitInfo> {
+public class CommitInfoSubject extends Subject {
 
   public static CommitInfoSubject assertThat(CommitInfo commitInfo) {
-    return assertAbout(CommitInfoSubject::new).that(commitInfo);
+    return assertAbout(commits()).that(commitInfo);
   }
 
+  public static Subject.Factory<CommitInfoSubject, CommitInfo> commits() {
+    return CommitInfoSubject::new;
+  }
+
+  private final CommitInfo commitInfo;
+
   private CommitInfoSubject(FailureMetadata failureMetadata, CommitInfo commitInfo) {
     super(failureMetadata, commitInfo);
+    this.commitInfo = commitInfo;
   }
 
   public StringSubject commit() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return Truth.assertThat(commitInfo.commit).named("commit");
+    return check("commit").that(commitInfo.commit);
   }
 
   public ListSubject<CommitInfoSubject, CommitInfo> parents() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return ListSubject.assertThat(commitInfo.parents, CommitInfoSubject::assertThat)
-        .named("parents");
+    return check("parents").about(elements()).thatCustom(commitInfo.parents, commits());
   }
 
   public GitPersonSubject committer() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return GitPersonSubject.assertThat(commitInfo.committer).named("committer");
+    return check("committer").about(gitPersons()).that(commitInfo.committer);
   }
 
   public GitPersonSubject author() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return GitPersonSubject.assertThat(commitInfo.author).named("author");
+    return check("author").about(gitPersons()).that(commitInfo.author);
   }
 
   public StringSubject message() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return Truth.assertThat(commitInfo.message).named("message");
+    return check("message").that(commitInfo.message);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
index 9030a1c..b55f7c2 100644
--- a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
@@ -14,71 +14,79 @@
 
 package com.google.gerrit.extensions.common.testing;
 
+import static com.google.common.truth.Fact.simpleFact;
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.IterableSubject;
+import com.google.common.truth.StandardSubjectBuilder;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
 import com.google.gerrit.truth.ListSubject;
 
-public class ContentEntrySubject extends Subject<ContentEntrySubject, ContentEntry> {
+public class ContentEntrySubject extends Subject {
 
   public static ContentEntrySubject assertThat(ContentEntry contentEntry) {
-    return assertAbout(ContentEntrySubject::new).that(contentEntry);
+    return assertAbout(contentEntries()).that(contentEntry);
   }
 
+  public static Subject.Factory<ContentEntrySubject, ContentEntry> contentEntries() {
+    return ContentEntrySubject::new;
+  }
+
+  private final ContentEntry contentEntry;
+
   private ContentEntrySubject(FailureMetadata failureMetadata, ContentEntry contentEntry) {
     super(failureMetadata, contentEntry);
+    this.contentEntry = contentEntry;
   }
 
   public void isDueToRebase() {
     isNotNull();
-    ContentEntry contentEntry = actual();
-    Truth.assertWithMessage("Entry should be marked 'dueToRebase'")
-        .that(contentEntry.dueToRebase)
-        .named("dueToRebase")
-        .isTrue();
+    if (contentEntry.dueToRebase == null || !contentEntry.dueToRebase) {
+      failWithActual(simpleFact("expected entry to be marked 'dueToRebase'"));
+    }
   }
 
   public void isNotDueToRebase() {
     isNotNull();
-    ContentEntry contentEntry = actual();
-    Truth.assertWithMessage("Entry should not be marked 'dueToRebase'")
-        .that(contentEntry.dueToRebase)
-        .named("dueToRebase")
-        .isNull();
+    if (contentEntry.dueToRebase != null && contentEntry.dueToRebase) {
+      failWithActual(simpleFact("expected entry not to be marked 'dueToRebase'"));
+    }
   }
 
   public ListSubject<StringSubject, String> commonLines() {
     isNotNull();
-    ContentEntry contentEntry = actual();
-    return ListSubject.assertThat(contentEntry.ab, Truth::assertThat).named("common lines");
+    return check("commonLines()")
+        .about(elements())
+        .that(contentEntry.ab, StandardSubjectBuilder::that);
   }
 
   public ListSubject<StringSubject, String> linesOfA() {
     isNotNull();
-    ContentEntry contentEntry = actual();
-    return ListSubject.assertThat(contentEntry.a, Truth::assertThat).named("lines of 'a'");
+    return check("linesOfA()").about(elements()).that(contentEntry.a, StandardSubjectBuilder::that);
   }
 
   public ListSubject<StringSubject, String> linesOfB() {
     isNotNull();
-    ContentEntry contentEntry = actual();
-    return ListSubject.assertThat(contentEntry.b, Truth::assertThat).named("lines of 'b'");
+    return check("linesOfB()").about(elements()).that(contentEntry.b, StandardSubjectBuilder::that);
   }
 
   public IterableSubject intralineEditsOfA() {
     isNotNull();
-    ContentEntry contentEntry = actual();
-    return Truth.assertThat(contentEntry.editA).named("intraline edits of 'a'");
+    return check("intralineEditsOfA()").that(contentEntry.editA);
   }
 
   public IterableSubject intralineEditsOfB() {
     isNotNull();
-    ContentEntry contentEntry = actual();
-    return Truth.assertThat(contentEntry.editB).named("intraline edits of 'b'");
+    return check("intralineEditsOfB()").that(contentEntry.editB);
+  }
+
+  public IntegerSubject numberOfSkippedLines() {
+    isNotNull();
+    return check("numberOfSkippedLines()").that(contentEntry.skip);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
index 6918325..8853a30 100644
--- a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
@@ -15,36 +15,49 @@
 package com.google.gerrit.extensions.common.testing;
 
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.common.testing.FileMetaSubject.fileMetas;
+import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
 import com.google.gerrit.truth.ListSubject;
 
-public class DiffInfoSubject extends Subject<DiffInfoSubject, DiffInfo> {
+public class DiffInfoSubject extends Subject {
 
   public static DiffInfoSubject assertThat(DiffInfo diffInfo) {
     return assertAbout(DiffInfoSubject::new).that(diffInfo);
   }
 
+  private final DiffInfo diffInfo;
+
   private DiffInfoSubject(FailureMetadata failureMetadata, DiffInfo diffInfo) {
     super(failureMetadata, diffInfo);
+    this.diffInfo = diffInfo;
   }
 
   public ListSubject<ContentEntrySubject, ContentEntry> content() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return ListSubject.assertThat(diffInfo.content, ContentEntrySubject::assertThat)
-        .named("content");
+    return check("content")
+        .about(elements())
+        .thatCustom(diffInfo.content, ContentEntrySubject.contentEntries());
   }
 
-  public ComparableSubject<?, ChangeType> changeType() {
+  public ComparableSubject<ChangeType> changeType() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return Truth.assertThat(diffInfo.changeType).named("changeType");
+    return check("changeType").that(diffInfo.changeType);
+  }
+
+  public FileMetaSubject metaA() {
+    isNotNull();
+    return check("metaA").about(fileMetas()).that(diffInfo.metaA);
+  }
+
+  public FileMetaSubject metaB() {
+    isNotNull();
+    return check("metaB").about(fileMetas()).that(diffInfo.metaB);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
index 84ad61c..b5622e0 100644
--- a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
@@ -15,39 +15,44 @@
 package com.google.gerrit.extensions.common.testing;
 
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.commits;
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.truth.OptionalSubject;
 import java.util.Optional;
 
-public class EditInfoSubject extends Subject<EditInfoSubject, EditInfo> {
+public class EditInfoSubject extends Subject {
 
   public static EditInfoSubject assertThat(EditInfo editInfo) {
-    return assertAbout(EditInfoSubject::new).that(editInfo);
+    return assertAbout(edits()).that(editInfo);
+  }
+
+  private static Subject.Factory<EditInfoSubject, EditInfo> edits() {
+    return EditInfoSubject::new;
   }
 
   public static OptionalSubject<EditInfoSubject, EditInfo> assertThat(
       Optional<EditInfo> editInfoOptional) {
-    return OptionalSubject.assertThat(editInfoOptional, EditInfoSubject::assertThat);
+    return OptionalSubject.assertThat(editInfoOptional, edits());
   }
 
+  private final EditInfo editInfo;
+
   private EditInfoSubject(FailureMetadata failureMetadata, EditInfo editInfo) {
     super(failureMetadata, editInfo);
+    this.editInfo = editInfo;
   }
 
   public CommitInfoSubject commit() {
     isNotNull();
-    EditInfo editInfo = actual();
-    return CommitInfoSubject.assertThat(editInfo.commit).named("commit");
+    return check("commit").about(commits()).that(editInfo.commit);
   }
 
   public StringSubject baseRevision() {
     isNotNull();
-    EditInfo editInfo = actual();
-    return Truth.assertThat(editInfo.baseRevision).named("baseRevision");
+    return check("baseRevision").that(editInfo.baseRevision);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
index b088016..d011d5d 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
@@ -20,34 +20,33 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.FileInfo;
 
-public class FileInfoSubject extends Subject<FileInfoSubject, FileInfo> {
+public class FileInfoSubject extends Subject {
 
   public static FileInfoSubject assertThat(FileInfo fileInfo) {
     return assertAbout(FileInfoSubject::new).that(fileInfo);
   }
 
+  private final FileInfo fileInfo;
+
   private FileInfoSubject(FailureMetadata failureMetadata, FileInfo fileInfo) {
     super(failureMetadata, fileInfo);
+    this.fileInfo = fileInfo;
   }
 
   public IntegerSubject linesInserted() {
     isNotNull();
-    FileInfo fileInfo = actual();
-    return Truth.assertThat(fileInfo.linesInserted).named("linesInserted");
+    return check("linesInserted").that(fileInfo.linesInserted);
   }
 
   public IntegerSubject linesDeleted() {
     isNotNull();
-    FileInfo fileInfo = actual();
-    return Truth.assertThat(fileInfo.linesDeleted).named("linesDeleted");
+    return check("linesDeleted").that(fileInfo.linesDeleted);
   }
 
-  public ComparableSubject<?, Character> status() {
+  public ComparableSubject<Character> status() {
     isNotNull();
-    FileInfo fileInfo = actual();
-    return Truth.assertThat(fileInfo.status).named("status");
+    return check("status").that(fileInfo.status);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
new file mode 100644
index 0000000..fb09a1f
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.extensions.common.DiffInfo.FileMeta;
+
+public class FileMetaSubject extends Subject {
+
+  public static FileMetaSubject assertThat(FileMeta fileMeta) {
+    return assertAbout(fileMetas()).that(fileMeta);
+  }
+
+  public static Subject.Factory<FileMetaSubject, FileMeta> fileMetas() {
+    return FileMetaSubject::new;
+  }
+
+  private final FileMeta fileMeta;
+
+  private FileMetaSubject(FailureMetadata failureMetadata, FileMeta fileMeta) {
+    super(failureMetadata, fileMeta);
+    this.fileMeta = fileMeta;
+  }
+
+  public IntegerSubject totalLineCount() {
+    isNotNull();
+    return check("totalLineCount()").that(fileMeta.lines);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
index b56d399..9ba69dc 100644
--- a/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
@@ -15,34 +15,43 @@
 package com.google.gerrit.extensions.common.testing;
 
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.common.testing.RangeSubject.ranges;
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 
-public class FixReplacementInfoSubject
-    extends Subject<FixReplacementInfoSubject, FixReplacementInfo> {
+public class FixReplacementInfoSubject extends Subject {
 
   public static FixReplacementInfoSubject assertThat(FixReplacementInfo fixReplacementInfo) {
-    return assertAbout(FixReplacementInfoSubject::new).that(fixReplacementInfo);
+    return assertAbout(fixReplacements()).that(fixReplacementInfo);
   }
 
+  public static Subject.Factory<FixReplacementInfoSubject, FixReplacementInfo> fixReplacements() {
+    return FixReplacementInfoSubject::new;
+  }
+
+  private final FixReplacementInfo fixReplacementInfo;
+
   private FixReplacementInfoSubject(
       FailureMetadata failureMetadata, FixReplacementInfo fixReplacementInfo) {
     super(failureMetadata, fixReplacementInfo);
+    this.fixReplacementInfo = fixReplacementInfo;
   }
 
   public StringSubject path() {
-    return Truth.assertThat(actual().path).named("path");
+    isNotNull();
+    return check("path").that(fixReplacementInfo.path);
   }
 
   public RangeSubject range() {
-    return RangeSubject.assertThat(actual().range).named("range");
+    isNotNull();
+    return check("range").about(ranges()).that(fixReplacementInfo.range);
   }
 
   public StringSubject replacement() {
-    return Truth.assertThat(actual().replacement).named("replacement");
+    isNotNull();
+    return check("replacement").that(fixReplacementInfo.replacement);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
index 7a6da9c..4ac725a 100644
--- a/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
@@ -15,33 +15,43 @@
 package com.google.gerrit.extensions.common.testing;
 
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.common.testing.FixReplacementInfoSubject.fixReplacements;
+import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.truth.ListSubject;
 
-public class FixSuggestionInfoSubject extends Subject<FixSuggestionInfoSubject, FixSuggestionInfo> {
+public class FixSuggestionInfoSubject extends Subject {
 
   public static FixSuggestionInfoSubject assertThat(FixSuggestionInfo fixSuggestionInfo) {
-    return assertAbout(FixSuggestionInfoSubject::new).that(fixSuggestionInfo);
+    return assertAbout(fixSuggestions()).that(fixSuggestionInfo);
   }
 
+  public static Subject.Factory<FixSuggestionInfoSubject, FixSuggestionInfo> fixSuggestions() {
+    return FixSuggestionInfoSubject::new;
+  }
+
+  private final FixSuggestionInfo fixSuggestionInfo;
+
   private FixSuggestionInfoSubject(
       FailureMetadata failureMetadata, FixSuggestionInfo fixSuggestionInfo) {
     super(failureMetadata, fixSuggestionInfo);
+    this.fixSuggestionInfo = fixSuggestionInfo;
   }
 
   public StringSubject fixId() {
-    return Truth.assertThat(actual().fixId).named("fixId");
+    return check("fixId").that(fixSuggestionInfo.fixId);
   }
 
   public ListSubject<FixReplacementInfoSubject, FixReplacementInfo> replacements() {
-    return ListSubject.assertThat(actual().replacements, FixReplacementInfoSubject::assertThat)
-        .named("replacements");
+    isNotNull();
+    return check("replacements")
+        .about(elements())
+        .thatCustom(fixSuggestionInfo.replacements, fixReplacements());
   }
 
   public FixReplacementInfoSubject onlyReplacement() {
@@ -49,6 +59,7 @@
   }
 
   public StringSubject description() {
-    return Truth.assertThat(actual().description).named("description");
+    isNotNull();
+    return check("description").that(fixSuggestionInfo.description);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index cdbef34..d827d5d 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -15,55 +15,58 @@
 package com.google.gerrit.extensions.common.testing;
 
 import static com.google.common.truth.Truth.assertAbout;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.GitPerson;
 import java.sql.Timestamp;
 import java.util.Date;
 import org.eclipse.jgit.lib.PersonIdent;
 
-public class GitPersonSubject extends Subject<GitPersonSubject, GitPerson> {
+public class GitPersonSubject extends Subject {
 
   public static GitPersonSubject assertThat(GitPerson gitPerson) {
-    return assertAbout(GitPersonSubject::new).that(gitPerson);
+    return assertAbout(gitPersons()).that(gitPerson);
   }
 
+  public static Factory<GitPersonSubject, GitPerson> gitPersons() {
+    return GitPersonSubject::new;
+  }
+
+  private final GitPerson gitPerson;
+
   private GitPersonSubject(FailureMetadata failureMetadata, GitPerson gitPerson) {
     super(failureMetadata, gitPerson);
+    this.gitPerson = gitPerson;
   }
 
   public StringSubject name() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return Truth.assertThat(gitPerson.name).named("name");
+    return check("name").that(gitPerson.name);
   }
 
   public StringSubject email() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return Truth.assertThat(gitPerson.email).named("email");
+    return check("email").that(gitPerson.email);
   }
 
-  public ComparableSubject<?, Timestamp> date() {
+  public ComparableSubject<Timestamp> date() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return Truth.assertThat(gitPerson.date).named("date");
+    return check("date").that(gitPerson.date);
   }
 
   public IntegerSubject tz() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return Truth.assertThat(gitPerson.tz).named("tz");
+    return check("tz").that(gitPerson.tz);
   }
 
   public void hasSameDateAs(GitPerson other) {
+    requireNonNull(other, "'other' GitPerson must not be null");
     isNotNull();
-    assertThat(other).named("other").isNotNull();
     date().isEqualTo(other.date);
     tz().isEqualTo(other.tz);
   }
@@ -72,9 +75,7 @@
     isNotNull();
     name().isEqualTo(ident.getName());
     email().isEqualTo(ident.getEmailAddress());
-    Truth.assertThat(new Date(actual().date.getTime()))
-        .named("rounded date")
-        .isEqualTo(ident.getWhen());
+    check("roundedDate()").that(new Date(gitPerson.date.getTime())).isEqualTo(ident.getWhen());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/RangeSubject.java b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
index b478a7e..10abca2 100644
--- a/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
@@ -14,51 +14,58 @@
 
 package com.google.gerrit.extensions.common.testing;
 
+import static com.google.common.truth.Fact.simpleFact;
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.client.Comment;
 
-public class RangeSubject extends Subject<RangeSubject, Comment.Range> {
+public class RangeSubject extends Subject {
 
   public static RangeSubject assertThat(Comment.Range range) {
-    return assertAbout(RangeSubject::new).that(range);
+    return assertAbout(ranges()).that(range);
   }
 
+  public static Subject.Factory<RangeSubject, Comment.Range> ranges() {
+    return RangeSubject::new;
+  }
+
+  private final Comment.Range range;
+
   private RangeSubject(FailureMetadata failureMetadata, Comment.Range range) {
     super(failureMetadata, range);
+    this.range = range;
   }
 
   public IntegerSubject startLine() {
-    return Truth.assertThat(actual().startLine).named("startLine");
+    return check("startLine").that(range.startLine);
   }
 
   public IntegerSubject startCharacter() {
-    return Truth.assertThat(actual().startCharacter).named("startCharacter");
+    return check("startCharacter").that(range.startCharacter);
   }
 
   public IntegerSubject endLine() {
-    return Truth.assertThat(actual().endLine).named("endLine");
+    return check("endLine").that(range.endLine);
   }
 
   public IntegerSubject endCharacter() {
-    return Truth.assertThat(actual().endCharacter).named("endCharacter");
+    return check("endCharacter").that(range.endCharacter);
   }
 
   public void isValid() {
     isNotNull();
-    if (!actual().isValid()) {
-      fail("is valid");
+    if (!range.isValid()) {
+      failWithActual(simpleFact("expected to be valid"));
     }
   }
 
   public void isInvalid() {
     isNotNull();
-    if (actual().isValid()) {
-      fail("is invalid");
+    if (range.isValid()) {
+      failWithActual(simpleFact("expected to be invalid"));
     }
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
index c2bed86..0698735 100644
--- a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common.testing;
 
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
@@ -23,26 +24,33 @@
 import com.google.gerrit.truth.ListSubject;
 import java.util.List;
 
-public class RobotCommentInfoSubject extends Subject<RobotCommentInfoSubject, RobotCommentInfo> {
+public class RobotCommentInfoSubject extends Subject {
 
   public static ListSubject<RobotCommentInfoSubject, RobotCommentInfo> assertThatList(
       List<RobotCommentInfo> robotCommentInfos) {
-    return ListSubject.assertThat(robotCommentInfos, RobotCommentInfoSubject::assertThat)
-        .named("robotCommentInfos");
+    return ListSubject.assertThat(robotCommentInfos, robotComments());
   }
 
   public static RobotCommentInfoSubject assertThat(RobotCommentInfo robotCommentInfo) {
-    return assertAbout(RobotCommentInfoSubject::new).that(robotCommentInfo);
+    return assertAbout(robotComments()).that(robotCommentInfo);
   }
 
+  private static Factory<RobotCommentInfoSubject, RobotCommentInfo> robotComments() {
+    return RobotCommentInfoSubject::new;
+  }
+
+  private final RobotCommentInfo robotCommentInfo;
+
   private RobotCommentInfoSubject(
       FailureMetadata failureMetadata, RobotCommentInfo robotCommentInfo) {
     super(failureMetadata, robotCommentInfo);
+    this.robotCommentInfo = robotCommentInfo;
   }
 
   public ListSubject<FixSuggestionInfoSubject, FixSuggestionInfo> fixSuggestions() {
-    return ListSubject.assertThat(actual().fixSuggestions, FixSuggestionInfoSubject::assertThat)
-        .named("fixSuggestions");
+    return check("fixSuggestions")
+        .about(elements())
+        .thatCustom(robotCommentInfo.fixSuggestions, FixSuggestionInfoSubject.fixSuggestions());
   }
 
   public FixSuggestionInfoSubject onlyFixSuggestion() {
diff --git a/java/com/google/gerrit/extensions/config/CapabilityDefinition.java b/java/com/google/gerrit/extensions/config/CapabilityDefinition.java
index aafb583..c76ec6c 100644
--- a/java/com/google/gerrit/extensions/config/CapabilityDefinition.java
+++ b/java/com/google/gerrit/extensions/config/CapabilityDefinition.java
@@ -18,7 +18,4 @@
 
 /** Specifies a capability declared by a plugin. */
 @ExtensionPoint
-public abstract class CapabilityDefinition {
-  /** @return description of the capability. */
-  public abstract String getDescription();
-}
+public abstract class CapabilityDefinition implements PluginPermissionDefinition {}
diff --git a/java/com/google/gerrit/extensions/config/PluginPermissionDefinition.java b/java/com/google/gerrit/extensions/config/PluginPermissionDefinition.java
new file mode 100644
index 0000000..11b1981
--- /dev/null
+++ b/java/com/google/gerrit/extensions/config/PluginPermissionDefinition.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.config;
+
+/** Specifies a permission declared by a plugin. */
+public interface PluginPermissionDefinition {
+  /**
+   * Gets the description of a permission declared by a plugin.
+   *
+   * @return description of the permission.
+   */
+  String getDescription();
+}
diff --git a/java/com/google/gerrit/extensions/config/PluginProjectPermissionDefinition.java b/java/com/google/gerrit/extensions/config/PluginProjectPermissionDefinition.java
new file mode 100644
index 0000000..d1d9f9e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/config/PluginProjectPermissionDefinition.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.config;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Specifies a repository permission declared by a plugin. */
+@ExtensionPoint
+public abstract class PluginProjectPermissionDefinition implements PluginPermissionDefinition {}
diff --git a/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java b/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java
new file mode 100644
index 0000000..70014f3
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a Change is deleted. */
+@ExtensionPoint
+public interface ChangeDeletedListener {
+  interface Event extends ChangeEvent {}
+
+  void onChangeDeleted(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
index e46ceb8..2da6ec9 100644
--- a/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
+++ b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.extensions.events;
 
 public interface PrivateStateChangedListener {
-  interface Event extends ChangeEvent {}
+  interface Event extends RevisionEvent {}
 
   void onPrivateStateChanged(Event event);
 }
diff --git a/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
index e957421..d0e2bc1 100644
--- a/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
+++ b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.extensions.events;
 
 public interface WorkInProgressStateChangedListener {
-  interface Event extends ChangeEvent {}
+  interface Event extends RevisionEvent {}
 
   void onWorkInProgressStateChanged(Event event);
 }
diff --git a/java/com/google/gerrit/extensions/persistence/DataSourceInterceptor.java b/java/com/google/gerrit/extensions/persistence/DataSourceInterceptor.java
deleted file mode 100644
index 0a83eea..0000000
--- a/java/com/google/gerrit/extensions/persistence/DataSourceInterceptor.java
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.persistence;
-
-import javax.sql.DataSource;
-
-public interface DataSourceInterceptor {
-  DataSource intercept(String name, DataSource dataSource);
-}
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItem.java b/java/com/google/gerrit/extensions/registration/DynamicItem.java
index 477b666..3f848cb 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")
@@ -120,26 +111,45 @@
   }
 
   private final Key<DynamicItem<T>> key;
-  private final AtomicReference<NamedProvider<T>> ref;
+  private final AtomicReference<Extension<T>> ref;
 
   DynamicItem(Key<DynamicItem<T>> key, Provider<T> provider, String pluginName) {
-    NamedProvider<T> in = null;
+    Extension<T> in = null;
     if (provider != null) {
-      in = new NamedProvider<>(provider, pluginName);
+      in = new Extension<>(pluginName, provider);
     }
     this.key = key;
     this.ref = new AtomicReference<>(in);
   }
 
+  @Nullable
+  public Extension<T> getEntry() {
+    return ref.get();
+  }
+
   /**
    * Get the configured item, or null.
    *
    * @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;
+    Extension<T> item = ref.get();
+    return item != null ? item.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() {
+    Extension<T> item = ref.get();
+    return item != null ? item.getPluginName() : null;
   }
 
   /**
@@ -161,25 +171,20 @@
    * @return handle to remove the item at a later point in time.
    */
   public RegistrationHandle set(Provider<T> impl, String pluginName) {
-    final NamedProvider<T> item = new NamedProvider<>(impl, pluginName);
-    NamedProvider<T> old = null;
+    final Extension<T> item = new Extension<>(pluginName, impl);
+    Extension<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.getPluginName())) {
         throw new ProvisionException(
             String.format(
                 "%s already provided by %s, ignoring plugin %s",
-                key.getTypeLiteral(), old.pluginName, pluginName));
+                key.getTypeLiteral(), old.getPluginName(), pluginName));
       }
     }
 
-    final NamedProvider<T> defaultItem = old;
-    return new RegistrationHandle() {
-      @Override
-      public void remove() {
-        ref.compareAndSet(item, defaultItem);
-      }
-    };
+    final Extension<T> defaultItem = old;
+    return () -> ref.compareAndSet(item, defaultItem);
   }
 
   /**
@@ -193,11 +198,13 @@
    * @return a handle that can remove this item later, or hot-swap the item.
    */
   public ReloadableRegistrationHandle<T> set(Key<T> key, Provider<T> impl, String pluginName) {
-    final NamedProvider<T> item = new NamedProvider<>(impl, pluginName);
-    NamedProvider<T> old = null;
+    final Extension<T> item = new Extension<>(pluginName, impl);
+    Extension<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.getPluginName())
+          && !pluginName.equals(old.getPluginName())) {
         // We allow to replace:
         // 1. Gerrit core items, e.g. websession cache
         //    can be replaced by plugin implementation
@@ -205,7 +212,7 @@
         throw new ProvisionException(
             String.format(
                 "%s already provided by %s, ignoring plugin %s",
-                this.key.getTypeLiteral(), old.pluginName, pluginName));
+                this.key.getTypeLiteral(), old.getPluginName(), pluginName));
       }
     }
     return new ReloadableHandle(key, item, old);
@@ -213,10 +220,10 @@
 
   private class ReloadableHandle implements ReloadableRegistrationHandle<T> {
     private final Key<T> handleKey;
-    private final NamedProvider<T> item;
-    private final NamedProvider<T> defaultItem;
+    private final Extension<T> item;
+    private final Extension<T> defaultItem;
 
-    ReloadableHandle(Key<T> handleKey, NamedProvider<T> item, NamedProvider<T> defaultItem) {
+    ReloadableHandle(Key<T> handleKey, Extension<T> item, Extension<T> defaultItem) {
       this.handleKey = handleKey;
       this.item = item;
       this.defaultItem = defaultItem;
@@ -233,8 +240,9 @@
     }
 
     @Override
+    @Nullable
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
-      NamedProvider<T> n = new NamedProvider<>(newItem, item.pluginName);
+      Extension<T> n = new Extension<>(item.getPluginName(), newItem);
       if (ref.compareAndSet(item, n)) {
         return new ReloadableHandle(newKey, n, defaultItem);
       }
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..48b1279 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicMap.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicMap.java
@@ -40,7 +40,7 @@
  * resolve the provider to an instance on demand. This enables registrations to decide between
  * singleton and non-singleton members.
  */
-public abstract class DynamicMap<T> implements Iterable<DynamicMap.Entry<T>> {
+public abstract class DynamicMap<T> implements Iterable<Extension<T>> {
   /**
    * Declare a singleton {@code DynamicMap<T>} with a binder.
    *
@@ -144,33 +144,18 @@
 
   /** Iterate through all entries in an undefined order. */
   @Override
-  public Iterator<Entry<T>> iterator() {
+  public Iterator<Extension<T>> iterator() {
     final Iterator<Map.Entry<NamePair, Provider<T>>> i = items.entrySet().iterator();
-    return new Iterator<Entry<T>>() {
+    return new Iterator<Extension<T>>() {
       @Override
       public boolean hasNext() {
         return i.hasNext();
       }
 
       @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();
-          }
-        };
+      public Extension<T> next() {
+        Map.Entry<NamePair, Provider<T>> e = i.next();
+        return new Extension<>(e.getKey().pluginName, e.getKey().exportName, e.getValue());
       }
 
       @Override
@@ -180,14 +165,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..b2e871e 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;
@@ -30,6 +36,8 @@
 import java.util.NoSuchElementException;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 
 /**
  * A set of members that can be modified as plugins reload.
@@ -129,12 +137,12 @@
   }
 
   public static <T> DynamicSet<T> emptySet() {
-    return new DynamicSet<>(Collections.<AtomicReference<Provider<T>>>emptySet());
+    return new DynamicSet<>(Collections.emptySet());
   }
 
-  private final CopyOnWriteArrayList<AtomicReference<Provider<T>>> items;
+  private final CopyOnWriteArrayList<AtomicReference<Extension<T>>> items;
 
-  DynamicSet(Collection<AtomicReference<Provider<T>>> base) {
+  DynamicSet(Collection<AtomicReference<Extension<T>>> base) {
     items = new CopyOnWriteArrayList<>(base);
   }
 
@@ -144,42 +152,55 @@
 
   @Override
   public Iterator<T> iterator() {
-    final Iterator<AtomicReference<Provider<T>>> itr = items.iterator();
+    Iterator<Extension<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();
-      }
-
-      @Override
-      public void remove() {
-        throw new UnsupportedOperationException();
+        Extension<T> next = entryIterator.next();
+        return next != null ? next.getProvider().get() : null;
       }
     };
   }
 
+  public Iterable<Extension<T>> entries() {
+    final Iterator<AtomicReference<Extension<T>>> itr = items.iterator();
+    return () ->
+        new Iterator<Extension<T>>() {
+          private Extension<T> next;
+
+          @Override
+          public boolean hasNext() {
+            while (next == null && itr.hasNext()) {
+              Extension<T> p = itr.next().get();
+              if (p != null) {
+                next = p;
+              }
+            }
+            return next != null;
+          }
+
+          @Override
+          public Extension<T> next() {
+            if (hasNext()) {
+              Extension<T> result = next;
+              next = null;
+              return result;
+            }
+            throw new NoSuchElementException();
+          }
+
+          @Override
+          public void remove() {
+            throw new UnsupportedOperationException();
+          }
+        };
+  }
+
   /**
    * Returns {@code true} if this set contains the given item.
    *
@@ -198,13 +219,27 @@
   }
 
   /**
-   * 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().getPluginName())
+        .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().getPluginName().equals(pluginName))
+        .map(i -> i.get().getProvider())
+        .collect(toImmutableSet());
   }
 
   /**
@@ -213,15 +248,23 @@
    * @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<Extension<T>> ref =
+        new AtomicReference<>(new Extension<>(pluginName, item));
     items.add(ref);
-    return new RegistrationHandle() {
-      @Override
-      public void remove() {
-        if (ref.compareAndSet(item, null)) {
-          items.remove(ref);
-        }
+    return () -> {
+      if (ref.compareAndSet(ref.get(), null)) {
+        items.remove(ref);
       }
     };
   }
@@ -229,6 +272,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 +280,22 @@
    * @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<Extension<T>> ref = new AtomicReference<>(new Extension<>(pluginName, item));
     items.add(ref);
-    return new ReloadableHandle(ref, key, item);
+    return new ReloadableHandle(ref, key, ref.get());
+  }
+
+  public Stream<T> stream() {
+    return StreamSupport.stream(spliterator(), false);
   }
 
   private class ReloadableHandle implements ReloadableRegistrationHandle<T> {
-    private final AtomicReference<Provider<T>> ref;
+    private final AtomicReference<Extension<T>> ref;
     private final Key<T> key;
-    private final Provider<T> item;
+    private final Extension<T> item;
 
-    ReloadableHandle(AtomicReference<Provider<T>> ref, Key<T> key, Provider<T> item) {
+    ReloadableHandle(AtomicReference<Extension<T>> ref, Key<T> key, Extension<T> item) {
       this.ref = ref;
       this.key = key;
       this.item = item;
@@ -267,8 +315,9 @@
 
     @Override
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
-      if (ref.compareAndSet(item, newItem)) {
-        return new ReloadableHandle(ref, newKey, newItem);
+      Extension<T> n = new Extension<>(item.getPluginName(), newItem);
+      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..832933b 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
@@ -38,16 +38,16 @@
     return new DynamicSet<>(find(injector, type));
   }
 
-  private static <T> List<AtomicReference<Provider<T>>> find(Injector src, TypeLiteral<T> type) {
+  private static <T> List<AtomicReference<Extension<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<Extension<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 Extension<>(PluginName.GERRIT, b.getProvider())));
       }
     }
     return r;
diff --git a/java/com/google/gerrit/extensions/registration/Extension.java b/java/com/google/gerrit/extensions/registration/Extension.java
new file mode 100644
index 0000000..1031bb0
--- /dev/null
+++ b/java/com/google/gerrit/extensions/registration/Extension.java
@@ -0,0 +1,61 @@
+// 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.gerrit.common.Nullable;
+import com.google.inject.Provider;
+
+/**
+ * An extension that is provided by a plugin.
+ *
+ * <p>Contains the name of the plugin that provides the extension, the extension point
+ * implementation and optionally the export name under which the extension was exported.
+ *
+ * <p>An export name is only available if this extension is an entry in a {@link DynamicMap}.
+ *
+ * @param <T> Type of extension point that this extension implements
+ */
+public class Extension<T> {
+  private final String pluginName;
+  private final @Nullable String exportName;
+  private final Provider<T> provider;
+
+  public Extension(String pluginName, Provider<T> provider) {
+    this(pluginName, null, provider);
+  }
+
+  protected Extension(String pluginName, @Nullable String exportName, Provider<T> provider) {
+    this.pluginName = pluginName;
+    this.exportName = exportName;
+    this.provider = provider;
+  }
+
+  public String getPluginName() {
+    return pluginName;
+  }
+
+  @Nullable
+  public String getExportName() {
+    return exportName;
+  }
+
+  public Provider<T> getProvider() {
+    return provider;
+  }
+
+  public T get() {
+    return provider.get();
+  }
+}
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_DynamicMapImpl.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
index 1973f70..fb520b4 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.inject.Key;
@@ -33,15 +33,10 @@
    * @return handle to remove the item at a later point in time.
    */
   public RegistrationHandle put(String pluginName, String exportName, Provider<T> item) {
-    checkNotNull(item);
+    requireNonNull(item);
     final NamePair key = new NamePair(pluginName, exportName);
     items.put(key, item);
-    return new RegistrationHandle() {
-      @Override
-      public void remove() {
-        items.remove(key, item);
-      }
-    };
+    return () -> items.remove(key, item);
   }
 
   /**
@@ -56,7 +51,7 @@
    *     the collection.
    */
   public ReloadableRegistrationHandle<T> put(String pluginName, Key<T> key, Provider<T> item) {
-    checkNotNull(item);
+    requireNonNull(item);
     String exportName = ((Export) key.getAnnotation()).value();
     NamePair np = new NamePair(pluginName, exportName);
     items.put(np, item);
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index e606079..fd31fcd 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -81,7 +81,7 @@
   }
 
   public static List<RegistrationHandle> attachItems(
-      Injector src, Map<TypeLiteral<?>, DynamicItem<?>> items, String pluginName) {
+      Injector src, String pluginName, Map<TypeLiteral<?>, DynamicItem<?>> items) {
     if (src == null || items == null || items.isEmpty()) {
       return Collections.emptyList();
     }
@@ -107,7 +107,7 @@
   }
 
   public static List<RegistrationHandle> attachSets(
-      Injector src, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
+      Injector src, String pluginName, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
     if (src == null || sets == null || sets.isEmpty()) {
       return Collections.emptyList();
     }
@@ -123,7 +123,7 @@
 
         for (Binding<Object> b : bindings(src, type)) {
           if (b.getKey().getAnnotation() != null) {
-            handles.add(set.add(b.getKey(), b.getProvider()));
+            handles.add(set.add(pluginName, b.getKey(), b.getProvider()));
           }
         }
       }
@@ -135,7 +135,7 @@
   }
 
   public static List<RegistrationHandle> attachMaps(
-      Injector src, String groupName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
+      Injector src, String pluginName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
     if (src == null || maps == null || maps.isEmpty()) {
       return Collections.emptyList();
     }
@@ -147,12 +147,12 @@
         TypeLiteral<Object> type = (TypeLiteral<Object>) e.getKey();
 
         @SuppressWarnings("unchecked")
-        PrivateInternals_DynamicMapImpl<Object> set =
+        PrivateInternals_DynamicMapImpl<Object> map =
             (PrivateInternals_DynamicMapImpl<Object>) e.getValue();
 
         for (Binding<Object> b : bindings(src, type)) {
           if (b.getKey().getAnnotation() != null) {
-            handles.add(set.put(groupName, b.getKey(), b.getProvider()));
+            handles.add(map.put(pluginName, b.getKey(), b.getProvider()));
           }
         }
       }
@@ -174,8 +174,8 @@
         handles = new ArrayList<>(4);
         Injector parent = self.getParent();
         while (parent != null) {
-          handles.addAll(attachSets(self, dynamicSetsOf(parent)));
-          handles.addAll(attachMaps(self, "gerrit", dynamicMapsOf(parent)));
+          handles.addAll(attachSets(self, PluginName.GERRIT, dynamicSetsOf(parent)));
+          handles.addAll(attachMaps(self, PluginName.GERRIT, dynamicMapsOf(parent)));
           parent = parent.getParent();
         }
         if (handles.isEmpty()) {
diff --git a/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java b/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
deleted file mode 100644
index 994e7f2..0000000
--- a/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.restapi;
-
-/**
- * Optional interface for {@link RestCollection}.
- *
- * <p>Collections that implement this interface can accept a {@code PUT} or {@code POST} when the
- * parse method throws {@link ResourceNotFoundException}.
- */
-public interface AcceptsCreate<P extends RestResource> {
-  /**
-   * Handle creation of a child resource.
-   *
-   * @param parent parent collection handle.
-   * @param id id of the resource being created.
-   * @return a view to perform the creation. The create method must embed the id into the newly
-   *     returned view object, as it will not be passed.
-   * @throws RestApiException the view cannot be constructed.
-   */
-  RestModifyView<P, ?> create(P parent, IdString id) throws RestApiException;
-}
diff --git a/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java b/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
deleted file mode 100644
index 6b5da7c..0000000
--- a/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.restapi;
-
-/**
- * Optional interface for {@link RestCollection}.
- *
- * <p>Collections that implement this interface can accept a {@code DELETE} directly on the
- * collection itself.
- */
-public interface AcceptsDelete<P extends RestResource> {
-  /**
-   * Handle deletion of a child resource by DELETE on the collection.
-   *
-   * @param parent parent collection handle.
-   * @param id id of the resource being created (optional).
-   * @return a view to perform the deletion.
-   * @throws RestApiException the view cannot be constructed.
-   */
-  RestModifyView<P, ?> delete(P parent, IdString id) throws RestApiException;
-}
diff --git a/java/com/google/gerrit/extensions/restapi/AcceptsPost.java b/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
deleted file mode 100644
index da87d32..0000000
--- a/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.restapi;
-
-/**
- * Optional interface for {@link RestCollection}.
- *
- * <p>Collections that implement this interface can accept a {@code POST} directly on the collection
- * itself when no id was given in the path. This interface is intended to be used with
- * TopLevelResource collections. Nested collections often bind POST on the parent collection to the
- * view implementation handling the insertion of a new member.
- */
-public interface AcceptsPost<P extends RestResource> {
-  /**
-   * Handle creation of a child resource by POST on the collection.
-   *
-   * @param parent parent collection handle.
-   * @return a view to perform the creation. The id of the newly created resource should be
-   *     determined from the input body.
-   * @throws RestApiException the view cannot be constructed.
-   */
-  RestModifyView<P, ?> post(P parent) throws RestApiException;
-}
diff --git a/java/com/google/gerrit/extensions/restapi/AuthException.java b/java/com/google/gerrit/extensions/restapi/AuthException.java
index 0b4f459..fe1744b 100644
--- a/java/com/google/gerrit/extensions/restapi/AuthException.java
+++ b/java/com/google/gerrit/extensions/restapi/AuthException.java
@@ -14,10 +14,17 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+import java.util.Optional;
+
 /** Caller cannot perform the request operation (HTTP 403 Forbidden). */
 public class AuthException extends RestApiException {
   private static final long serialVersionUID = 1L;
 
+  private Optional<String> advice = Optional.empty();
+
   /** @param msg message to return to the client. */
   public AuthException(String msg) {
     super(msg);
@@ -30,4 +37,19 @@
   public AuthException(String msg, Throwable cause) {
     super(msg, cause);
   }
+
+  public void setAdvice(String advice) {
+    checkArgument(!Strings.isNullOrEmpty(advice));
+    this.advice = Optional.of(advice);
+  }
+
+  /**
+   * Advice that the user can follow to acquire authorization to perform the action.
+   *
+   * <p>This may be long-form text with newlines, and may be printed to a terminal, for example in
+   * the message stream in response to a push.
+   */
+  public Optional<String> getAdvice() {
+    return advice;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java b/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
index e676828..75cf713 100644
--- a/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
+++ b/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
@@ -34,9 +34,8 @@
     super("Not found: " + id.get());
   }
 
-  @SuppressWarnings("unchecked")
-  @Override
   public ResourceNotFoundException caching(CacheControl c) {
-    return super.caching(c);
+    setCaching(c);
+    return this;
   }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index 8f2dd5f..6a1020a 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -154,13 +154,38 @@
   }
 
   /** An HTTP redirect to another location. */
-  public static final class Redirect {
+  public static final class Redirect extends Response<Object> {
     private final String location;
 
     private Redirect(String url) {
       this.location = url;
     }
 
+    @Override
+    public boolean isNone() {
+      return false;
+    }
+
+    @Override
+    public int statusCode() {
+      return 302;
+    }
+
+    @Override
+    public Object value() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public CacheControl caching() {
+      return CacheControl.NONE;
+    }
+
+    @Override
+    public Response<Object> caching(CacheControl c) {
+      throw new UnsupportedOperationException();
+    }
+
     public String location() {
       return location;
     }
@@ -182,13 +207,38 @@
   }
 
   /** Accepted as task for asynchronous execution. */
-  public static final class Accepted {
+  public static final class Accepted extends Response<Object> {
     private final String location;
 
     private Accepted(String url) {
       this.location = url;
     }
 
+    @Override
+    public boolean isNone() {
+      return false;
+    }
+
+    @Override
+    public int statusCode() {
+      return 202;
+    }
+
+    @Override
+    public Object value() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public CacheControl caching() {
+      return CacheControl.NONE;
+    }
+
+    @Override
+    public Response<Object> caching(CacheControl c) {
+      throw new UnsupportedOperationException();
+    }
+
     public String location() {
       return location;
     }
diff --git a/java/com/google/gerrit/extensions/restapi/RestApiException.java b/java/com/google/gerrit/extensions/restapi/RestApiException.java
index 28398a4..f3d7dec 100644
--- a/java/com/google/gerrit/extensions/restapi/RestApiException.java
+++ b/java/com/google/gerrit/extensions/restapi/RestApiException.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.restapi;
 
-/** Root exception type for JSON API failures. */
+import static java.util.Objects.requireNonNull;
+
+/** Root exception type for REST API failures. */
 public class RestApiException extends Exception {
   private static final long serialVersionUID = 1L;
   private CacheControl caching = CacheControl.NONE;
@@ -33,9 +35,7 @@
     return caching;
   }
 
-  @SuppressWarnings("unchecked")
-  public <T extends RestApiException> T caching(CacheControl c) {
-    caching = c;
-    return (T) this;
+  protected void setCaching(CacheControl caching) {
+    this.caching = requireNonNull(caching);
   }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestApiModule.java b/java/com/google/gerrit/extensions/restapi/RestApiModule.java
index 0db2891..85bd5a1 100644
--- a/java/com/google/gerrit/extensions/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/extensions/restapi/RestApiModule.java
@@ -28,21 +28,57 @@
   protected static final String PUT = "PUT";
   protected static final String DELETE = "DELETE";
   protected static final String POST = "POST";
+  protected static final String CREATE = "CREATE";
+  protected static final String DELETE_MISSING = "DELETE_MISSING";
+  protected static final String DELETE_ON_COLLECTION = "DELETE_ON_COLLECTION";
+  protected static final String POST_ON_COLLECTION = "POST_ON_COLLECTION";
 
   protected <R extends RestResource> ReadViewBinder<R> get(TypeLiteral<RestView<R>> viewType) {
-    return new ReadViewBinder<>(view(viewType, GET, "/"));
+    return get(viewType, "/");
   }
 
   protected <R extends RestResource> ModifyViewBinder<R> put(TypeLiteral<RestView<R>> viewType) {
-    return new ModifyViewBinder<>(view(viewType, PUT, "/"));
+    return put(viewType, "/");
   }
 
   protected <R extends RestResource> ModifyViewBinder<R> post(TypeLiteral<RestView<R>> viewType) {
-    return new ModifyViewBinder<>(view(viewType, POST, "/"));
+    return post(viewType, "/");
   }
 
   protected <R extends RestResource> ModifyViewBinder<R> delete(TypeLiteral<RestView<R>> viewType) {
-    return new ModifyViewBinder<>(view(viewType, DELETE, "/"));
+    return delete(viewType, "/");
+  }
+
+  protected <R extends RestResource> RestCollectionViewBinder<R> postOnCollection(
+      TypeLiteral<RestView<R>> viewType) {
+    return new RestCollectionViewBinder<>(
+        bind(viewType).annotatedWith(export(POST_ON_COLLECTION, "/")));
+  }
+
+  /**
+   * Creates a binder that allows to bind a REST view to handle {@code DELETE} on the REST
+   * collection of the provided view type.
+   *
+   * <p>This binding is ignored if the provided view type belongs to a root collection.
+   *
+   * @param viewType the type of the resources in the REST collection on which {@code DELETE} should
+   *     be handled
+   * @return binder that allows to bind an implementation for the REST view that should handle
+   *     {@code DELETE} on the REST collection of the provided view type
+   */
+  protected <R extends RestResource> RestCollectionViewBinder<R> deleteOnCollection(
+      TypeLiteral<RestView<R>> viewType) {
+    return new RestCollectionViewBinder<>(
+        bind(viewType).annotatedWith(export(DELETE_ON_COLLECTION, "/")));
+  }
+
+  protected <R extends RestResource> CreateViewBinder<R> create(TypeLiteral<RestView<R>> viewType) {
+    return new CreateViewBinder<>(bind(viewType).annotatedWith(export(CREATE, "/")));
+  }
+
+  protected <R extends RestResource> DeleteViewBinder<R> deleteMissing(
+      TypeLiteral<RestView<R>> viewType) {
+    return new DeleteViewBinder<>(bind(viewType).annotatedWith(export(DELETE_MISSING, "/")));
   }
 
   protected <R extends RestResource> ReadViewBinder<R> get(
@@ -70,7 +106,7 @@
     return new ChildCollectionBinder<>(view(type, GET, name));
   }
 
-  protected <R extends RestResource> LinkedBindingBuilder<RestView<R>> view(
+  private <R extends RestResource> LinkedBindingBuilder<RestView<R>> view(
       TypeLiteral<RestView<R>> viewType, String method, String name) {
     return bind(viewType).annotatedWith(export(method, name));
   }
@@ -137,6 +173,90 @@
     }
   }
 
+  public static class RestCollectionViewBinder<C extends RestResource> {
+    private final LinkedBindingBuilder<RestView<C>> binder;
+
+    private RestCollectionViewBinder(LinkedBindingBuilder<RestView<C>> binder) {
+      this.binder = binder;
+    }
+
+    public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>>
+        ScopedBindingBuilder to(Class<T> impl) {
+      return binder.to(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>> void toInstance(
+        T impl) {
+      binder.toInstance(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
+      return binder.toProvider(providerType);
+    }
+
+    public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
+      return binder.toProvider(provider);
+    }
+  }
+
+  public static class CreateViewBinder<C extends RestResource> {
+    private final LinkedBindingBuilder<RestView<C>> binder;
+
+    private CreateViewBinder(LinkedBindingBuilder<RestView<C>> binder) {
+      this.binder = binder;
+    }
+
+    public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>>
+        ScopedBindingBuilder to(Class<T> impl) {
+      return binder.to(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>> void toInstance(
+        T impl) {
+      binder.toInstance(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
+      return binder.toProvider(providerType);
+    }
+
+    public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
+      return binder.toProvider(provider);
+    }
+  }
+
+  public static class DeleteViewBinder<C extends RestResource> {
+    private final LinkedBindingBuilder<RestView<C>> binder;
+
+    private DeleteViewBinder(LinkedBindingBuilder<RestView<C>> binder) {
+      this.binder = binder;
+    }
+
+    public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
+        ScopedBindingBuilder to(Class<T> impl) {
+      return binder.to(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
+        void toInstance(T impl) {
+      binder.toInstance(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
+      return binder.toProvider(providerType);
+    }
+
+    public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
+      return binder.toProvider(provider);
+    }
+  }
+
   public static class ChildCollectionBinder<P extends RestResource> {
     private final LinkedBindingBuilder<RestView<P>> binder;
 
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.java
new file mode 100644
index 0000000..72ca74b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.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.extensions.restapi;
+
+/**
+ * RestView that supports accepting input and creating a resource.
+ *
+ * <p>The input must be supplied as JSON as the body of the HTTP request. Create views can be
+ * invoked by the HTTP methods {@code PUT} and {@code POST}.
+ *
+ * <p>The RestCreateView is only invoked when the parse method of the {@code RestCollection} throws
+ * {@link ResourceNotFoundException}, and hence the resource doesn't exist yet.
+ *
+ * @param <P> type of the parent resource
+ * @param <C> type of the child resource that is created
+ * @param <I> type of input the JSON parser will parse the input into.
+ */
+public interface RestCollectionCreateView<P extends RestResource, C extends RestResource, I>
+    extends RestCollectionView<P, C, I> {
+
+  /**
+   * Process the view operation by creating the resource.
+   *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestCollectionCreateViews this is usually {@code 201 Created} because a resource is created,
+   * but other 2XX or 3XX status codes are also possible (e.g. {@link Response.Redirect} can be
+   * returned for {@code 302 Found}).
+   *
+   * <p>The value of the returned response is automatically converted to JSON unless it is a {@link
+   * BinaryResult}.
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
+   * @param parentResource parent resource of the resource that should be created
+   * @param id the ID of the child resource that should be created
+   * @param input input after parsing from request.
+   * @return response to return to the client
+   * @throws RestApiException if the resource creation is rejected
+   * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
+   *     500 Internal Server Error will be returned to the client.
+   */
+  Response<?> apply(P parentResource, IdString id, I input) throws Exception;
+}
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java
new file mode 100644
index 0000000..c08d06a
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.restapi;
+
+/**
+ * RestView that supports accepting input and deleting a resource that is missing.
+ *
+ * <p>The RestDeleteMissingView solely exists to support a special case for creating a change edit
+ * by deleting a path in the non-existing change edit. This interface should not be used for new
+ * REST API's.
+ *
+ * <p>The input must be supplied as JSON as the body of the HTTP request. Delete views can be
+ * invoked by the HTTP method {@code DELETE}.
+ *
+ * <p>The RestDeleteMissingView is only invoked when the parse method of the {@code RestCollection}
+ * throws {@link ResourceNotFoundException}, and hence the resource doesn't exist yet.
+ *
+ * @param <P> type of the parent resource
+ * @param <C> type of the child resource that id deleted
+ * @param <I> type of input the JSON parser will parse the input into.
+ */
+public interface RestCollectionDeleteMissingView<P extends RestResource, C extends RestResource, I>
+    extends RestCollectionView<P, C, I> {
+
+  /**
+   * Process the view operation by deleting the resource.
+   *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestCollectionDeleteMissingViews this is usually {@code 204 No Content} because a resource is
+   * deleted, but other 2XX or 3XX status codes are also possible (e.g. {@code 200 OK}, {@code 302
+   * Found} for a redirect).
+   *
+   * <p>The returned response usually does not have any value (status code {@code 204 No Content}).
+   * If a value in the returned response is set it is automatically converted to JSON unless it is a
+   * {@link BinaryResult}.
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
+   * @param parentResource parent resource of the resource that should be deleted
+   * @param id the ID of the child resource that should be deleted
+   * @param input input after parsing from request
+   * @return response to return to the client
+   * @throws RestApiException if the resource creation is rejected
+   * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
+   *     500 Internal Server Error will be returned to the client.
+   */
+  Response<?> apply(P parentResource, IdString id, I input) throws Exception;
+}
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java
new file mode 100644
index 0000000..fcaa15b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.restapi;
+
+/**
+ * RestView on a RestCollection that supports accepting input.
+ *
+ * <p>The input must be supplied as JSON as the body of the HTTP request. RestCollectionModifyViews
+ * can be invoked by the HTTP methods {@code POST} and {@code DELETE} ({@code DELETE} is only
+ * supported on child collections).
+ *
+ * @param <P> type of the parent resource
+ * @param <C> type of the child resource
+ * @param <I> type of input the JSON parser will parse the input into.
+ */
+public interface RestCollectionModifyView<P extends RestResource, C extends RestResource, I>
+    extends RestCollectionView<P, C, I> {
+
+  /**
+   * Process the modification on the collection resource.
+   *
+   * <p>The value of the returned response is automatically converted to JSON unless it is a {@link
+   * BinaryResult}.
+   *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestCollectionModifyViews this is usually {@code 200 OK}, but other 2XX or 3XX status codes are
+   * also possible (e.g. {@code 201 Created} if a resource was created, {@code 202 Accepted} if a
+   * background task was scheduled, {@code 204 No Content} if no content is returned, {@code 302
+   * Found} for a redirect).
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
+   * @param parentResource the collection resource on which the modification is done
+   * @return response to return to the client
+   * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
+   *     500 Internal Server Error will be returned to the client.
+   */
+  Response<?> apply(P parentResource, I input) throws Exception;
+}
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionView.java
new file mode 100644
index 0000000..c29a58f
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionView.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.restapi;
+
+/**
+ * Any type of view on {@link RestCollection}, see {@link RestCollectionModifyView} for updates and
+ * deletes and {@link RestCollectionCreateView} for member creation.
+ */
+public interface RestCollectionView<P extends RestResource, C extends RestResource, I>
+    extends RestView<C> {}
diff --git a/java/com/google/gerrit/extensions/restapi/RestModifyView.java b/java/com/google/gerrit/extensions/restapi/RestModifyView.java
index 79053dd..e397bd0 100644
--- a/java/com/google/gerrit/extensions/restapi/RestModifyView.java
+++ b/java/com/google/gerrit/extensions/restapi/RestModifyView.java
@@ -28,11 +28,21 @@
   /**
    * Process the view operation by altering the resource.
    *
-   * @param resource resource to modify.
-   * @param input input after parsing from request.
-   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
-   *     to JSON.
-   * @throws AuthException the client is not permitted to access this view.
+   * <p>The value of the returned response is automatically converted to JSON unless it is a {@link
+   * BinaryResult}.
+   *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestModifyViews this is usually {@code 200 OK}, but other 2XX or 3XX status codes are also
+   * possible (e.g. {@code 202 Accepted} if a background task was scheduled, {@code 204 No Content}
+   * if no content is returned, {@code 302 Found} for a redirect).
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
+   * @param resource resource to modify
+   * @param input input after parsing from request
+   * @return response to return to the client
+   * @throws AuthException the caller is not permitted to access this view.
    * @throws BadRequestException the request was incorrectly specified and cannot be handled by this
    *     view.
    * @throws ResourceConflictException the resource state does not permit this view to make the
@@ -40,6 +50,6 @@
    * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
    *     500 Internal Server Error will be returned to the client.
    */
-  Object apply(R resource, I input)
+  Response<?> apply(R resource, I input)
       throws AuthException, BadRequestException, ResourceConflictException, Exception;
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestReadView.java b/java/com/google/gerrit/extensions/restapi/RestReadView.java
index a3c31d3..8991f0b 100644
--- a/java/com/google/gerrit/extensions/restapi/RestReadView.java
+++ b/java/com/google/gerrit/extensions/restapi/RestReadView.java
@@ -17,16 +17,27 @@
 /**
  * RestView to read a resource without modification.
  *
+ * <p>RestReadViews are invoked by the HTTP GET method.
+ *
  * @param <R> type of resource the view reads.
  */
 public interface RestReadView<R extends RestResource> extends RestView<R> {
   /**
    * Process the view operation by reading from the resource.
    *
-   * @param resource resource to read.
-   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
-   *     to JSON.
-   * @throws AuthException the client is not permitted to access this view.
+   * <p>The value of the returned response is automatically converted to JSON unless it is a {@link
+   * BinaryResult}.
+   *
+   * <p>The returned response defines the status code that is returned to the client. For
+   * RestReadViews this is usually {@code 200 OK}, but other 2XX or 3XX status codes are also
+   * possible (e.g. {@link Response.Redirect} can be returned for {@code 302 Found}).
+   *
+   * <p>Throwing a subclass of {@link RestApiException} results in a 4XX response to the client. For
+   * any other exception the client will get a {@code 500 Internal Server Error} response.
+   *
+   * @param resource resource to read
+   * @return response to return to the client
+   * @throws AuthException the caller is not permitted to access this view.
    * @throws BadRequestException the request was incorrectly specified and cannot be handled by this
    *     view.
    * @throws ResourceConflictException the resource state does not permit this view to make the
@@ -34,6 +45,6 @@
    * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
    *     500 Internal Server Error will be returned to the client.
    */
-  Object apply(R resource)
+  Response<?> apply(R resource)
       throws AuthException, BadRequestException, ResourceConflictException, Exception;
 }
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BUILD b/java/com/google/gerrit/extensions/restapi/testing/BUILD
index 434591e..3417cae2 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/BUILD
+++ b/java/com/google/gerrit/extensions/restapi/testing/BUILD
@@ -1,6 +1,6 @@
 java_library(
     name = "restapi-test-util",
-    testonly = 1,
+    testonly = True,
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java b/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
index 1867308..c5304e3 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
+++ b/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
@@ -20,26 +20,32 @@
 import com.google.common.truth.PrimitiveByteArraySubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.Optional;
 
-public class BinaryResultSubject extends Subject<BinaryResultSubject, BinaryResult> {
+public class BinaryResultSubject extends Subject {
 
   public static BinaryResultSubject assertThat(BinaryResult binaryResult) {
-    return assertAbout(BinaryResultSubject::new).that(binaryResult);
+    return assertAbout(binaryResults()).that(binaryResult);
+  }
+
+  private static Subject.Factory<BinaryResultSubject, BinaryResult> binaryResults() {
+    return BinaryResultSubject::new;
   }
 
   public static OptionalSubject<BinaryResultSubject, BinaryResult> assertThat(
       Optional<BinaryResult> binaryResultOptional) {
-    return OptionalSubject.assertThat(binaryResultOptional, BinaryResultSubject::assertThat);
+    return OptionalSubject.assertThat(binaryResultOptional, binaryResults());
   }
 
+  private final BinaryResult binaryResult;
+
   private BinaryResultSubject(FailureMetadata failureMetadata, BinaryResult binaryResult) {
     super(failureMetadata, binaryResult);
+    this.binaryResult = binaryResult;
   }
 
   public StringSubject asString() throws IOException {
@@ -47,8 +53,7 @@
     // We shouldn't close the BinaryResult within this method as it might still
     // be used afterwards. Besides, closing it doesn't have an effect for most
     // implementations of a BinaryResult.
-    BinaryResult binaryResult = actual();
-    return Truth.assertThat(binaryResult.asString());
+    return check("asString()").that(binaryResult.asString());
   }
 
   public PrimitiveByteArraySubject bytes() throws IOException {
@@ -56,10 +61,9 @@
     // We shouldn't close the BinaryResult within this method as it might still
     // be used afterwards. Besides, closing it doesn't have an effect for most
     // implementations of a BinaryResult.
-    BinaryResult binaryResult = actual();
     ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
     binaryResult.writeTo(byteArrayOutputStream);
     byte[] bytes = byteArrayOutputStream.toByteArray();
-    return Truth.assertThat(bytes);
+    return check("bytes()").that(bytes);
   }
 }
diff --git a/java/com/google/gerrit/extensions/validators/CommentForValidation.java b/java/com/google/gerrit/extensions/validators/CommentForValidation.java
new file mode 100644
index 0000000..51ae5ae
--- /dev/null
+++ b/java/com/google/gerrit/extensions/validators/CommentForValidation.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.validators;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * Holds a comment's text and {@link CommentType} in order to pass it to a validation plugin.
+ *
+ * @see CommentValidator
+ */
+@AutoValue
+public abstract class CommentForValidation {
+
+  /** The type of comment. */
+  public enum CommentType {
+    /** A regular (inline) comment. */
+    INLINE_COMMENT,
+    /** A file comment. */
+    FILE_COMMENT,
+    /** A change message. */
+    CHANGE_MESSAGE
+  }
+
+  public static CommentForValidation create(CommentType type, String text) {
+    return new AutoValue_CommentForValidation(type, text);
+  }
+
+  public abstract CommentType getType();
+
+  public abstract String getText();
+
+  public CommentValidationFailure failValidation(String message) {
+    return CommentValidationFailure.create(this, message);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/validators/CommentValidationFailure.java b/java/com/google/gerrit/extensions/validators/CommentValidationFailure.java
new file mode 100644
index 0000000..1a832760
--- /dev/null
+++ b/java/com/google/gerrit/extensions/validators/CommentValidationFailure.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.validators;
+
+import com.google.auto.value.AutoValue;
+
+/** A comment or review message was rejected by a {@link CommentValidator}. */
+@AutoValue
+public abstract class CommentValidationFailure {
+  static CommentValidationFailure create(
+      CommentForValidation commentForValidation, String message) {
+    return new AutoValue_CommentValidationFailure(commentForValidation, message);
+  }
+
+  /** Returns the offending comment. */
+  public abstract CommentForValidation getComment();
+
+  /** A friendly message set by the {@link CommentValidator}. */
+  public abstract String getMessage();
+}
diff --git a/java/com/google/gerrit/extensions/validators/CommentValidator.java b/java/com/google/gerrit/extensions/validators/CommentValidator.java
new file mode 100644
index 0000000..cfefdef
--- /dev/null
+++ b/java/com/google/gerrit/extensions/validators/CommentValidator.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.validators;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Validates review comments and messages. Rejecting any comment/message will prevent all comments
+ * from being published.
+ */
+@ExtensionPoint
+public interface CommentValidator {
+
+  /**
+   * Validate the specified comments.
+   *
+   * @return An empty list if all comments are valid, or else a list of validation failures.
+   */
+  ImmutableList<CommentValidationFailure> validateComments(
+      ImmutableList<CommentForValidation> comments);
+}
diff --git a/java/com/google/gerrit/extensions/webui/GwtPlugin.java b/java/com/google/gerrit/extensions/webui/GwtPlugin.java
deleted file mode 100644
index e8041c4..0000000
--- a/java/com/google/gerrit/extensions/webui/GwtPlugin.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.webui;
-
-/** Configures a web UI plugin compiled using GWT. */
-public class GwtPlugin extends WebUiPlugin {
-  private final String moduleName;
-
-  /**
-   * @param moduleName name of GWT module. The resource {@code static/$MODULE/$MODULE.nocache.js}
-   *     will be used.
-   */
-  public GwtPlugin(String moduleName) {
-    this.moduleName = moduleName;
-  }
-
-  @Override
-  public String getJavaScriptResourcePath() {
-    return String.format("static/%s/%s.nocache.js", moduleName, moduleName);
-  }
-}
diff --git a/java/com/google/gerrit/extensions/webui/WebUiPlugin.java b/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
index 051d336..2d49e1c 100644
--- a/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
+++ b/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
@@ -34,15 +34,10 @@
  * }
  * </pre>
  *
- * @see GwtPlugin
  * @see JavaScriptPlugin
  */
 @ExtensionPoint
 public abstract class WebUiPlugin {
-  public static final GwtPlugin gwt(String moduleName) {
-    return new GwtPlugin(moduleName);
-  }
-
   public static final JavaScriptPlugin js(String scriptName) {
     return new JavaScriptPlugin(scriptName);
   }
diff --git a/java/com/google/gerrit/git/BUILD b/java/com/google/gerrit/git/BUILD
new file mode 100644
index 0000000..fc146dc
--- /dev/null
+++ b/java/com/google/gerrit/git/BUILD
@@ -0,0 +1,10 @@
+java_library(
+    name = "git",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/git/LockFailureException.java b/java/com/google/gerrit/git/LockFailureException.java
new file mode 100644
index 0000000..9e67d70
--- /dev/null
+++ b/java/com/google/gerrit/git/LockFailureException.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.git;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Thrown when updating a ref in Git fails with LOCK_FAILURE. */
+public class LockFailureException extends IOException {
+  private static final long serialVersionUID = 1L;
+
+  private final ImmutableList<String> refs;
+
+  public LockFailureException(String message, RefUpdate refUpdate) {
+    super(message);
+    refs = ImmutableList.of(refUpdate.getName());
+  }
+
+  public LockFailureException(String message, BatchRefUpdate batchRefUpdate) {
+    super(message);
+    refs =
+        batchRefUpdate.getCommands().stream()
+            .filter(c -> c.getResult() == ReceiveCommand.Result.LOCK_FAILURE)
+            .map(ReceiveCommand::getRefName)
+            .collect(toImmutableList());
+  }
+
+  /** Subset of ref names that caused the lock failure. */
+  public ImmutableList<String> getFailedRefs() {
+    return refs;
+  }
+}
diff --git a/java/com/google/gerrit/git/ObjectIds.java b/java/com/google/gerrit/git/ObjectIds.java
new file mode 100644
index 0000000..4d83046
--- /dev/null
+++ b/java/com/google/gerrit/git/ObjectIds.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.common.Nullable;
+import java.io.IOException;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+
+/** Static utilities for working with {@code ObjectId}s. */
+public class ObjectIds {
+  /** Length of a binary SHA-1 byte array. */
+  public static final int LEN = Constants.OBJECT_ID_LENGTH;
+
+  /** Length of a hex SHA-1 string. */
+  public static final int STR_LEN = Constants.OBJECT_ID_STRING_LENGTH;
+
+  /** Default abbreviated length of a hex SHA-1 string. */
+  public static final int ABBREV_STR_LEN = 7;
+
+  /**
+   * Abbreviate an ID's hex string representation to 7 chars.
+   *
+   * @param id object ID.
+   * @return abbreviated hex string representation, exactly 7 chars.
+   */
+  public static String abbreviateName(AnyObjectId id) {
+    return abbreviateName(id, ABBREV_STR_LEN);
+  }
+
+  /**
+   * Abbreviate an ID's hex string representation to {@code n} chars.
+   *
+   * @param id object ID.
+   * @param n number of hex chars, 1 to 40.
+   * @return abbreviated hex string representation, exactly {@code n} chars.
+   */
+  public static String abbreviateName(AnyObjectId id, int n) {
+    checkValidLength(n);
+    return requireNonNull(id).abbreviate(n).name();
+  }
+
+  /**
+   * Abbreviate an ID's hex string representation uniquely to at least 7 chars.
+   *
+   * @param id object ID.
+   * @param reader object reader for determining uniqueness.
+   * @return abbreviated hex string representation, unique according to {@code reader} at least 7
+   *     chars.
+   * @throws IOException if an error occurs while looking for ambiguous objects.
+   */
+  public static String abbreviateName(AnyObjectId id, ObjectReader reader) throws IOException {
+    return abbreviateName(id, ABBREV_STR_LEN, reader);
+  }
+
+  /**
+   * Abbreviate an ID's hex string representation uniquely to at least {@code n} chars.
+   *
+   * @param id object ID.
+   * @param n minimum number of hex chars, 1 to 40.
+   * @param reader object reader for determining uniqueness.
+   * @return abbreviated hex string representation, unique according to {@code reader} at least
+   *     {@code n} chars.
+   * @throws IOException if an error occurs while looking for ambiguous objects.
+   */
+  public static String abbreviateName(AnyObjectId id, int n, ObjectReader reader)
+      throws IOException {
+    checkValidLength(n);
+    return reader.abbreviate(id, n).name();
+  }
+
+  /**
+   * Copy a nullable ID, preserving null.
+   *
+   * @param id object ID.
+   * @return {@link AnyObjectId#copy} of {@code id}, or {@code null} if {@code id} is null.
+   */
+  @Nullable
+  public static ObjectId copyOrNull(@Nullable AnyObjectId id) {
+    return id != null ? id.copy() : null;
+  }
+
+  /**
+   * Copy a nullable ID, converting null to {@code zeroId}.
+   *
+   * @param id object ID.
+   * @return {@link AnyObjectId#copy} of {@code id}, or {@link ObjectId#zeroId} if {@code id} is
+   *     null.
+   */
+  public static ObjectId copyOrZero(@Nullable AnyObjectId id) {
+    return id != null ? id.copy() : ObjectId.zeroId();
+  }
+
+  /**
+   * Return whether the given ID matches the given abbreviation.
+   *
+   * @param id object ID.
+   * @param abbreviation abbreviated hex object ID. May not be null, but may be an invalid hex SHA-1
+   *     abbreviation string.
+   * @return true if {@code id} is not null and {@code abbreviation} is a valid hex SHA-1
+   *     abbreviation that matches {@code id}, false otherwise.
+   */
+  public static boolean matchesAbbreviation(@Nullable AnyObjectId id, String abbreviation) {
+    requireNonNull(abbreviation);
+    return id != null && id.name().startsWith(abbreviation);
+  }
+
+  private static void checkValidLength(int n) {
+    checkArgument(n > 0);
+    checkArgument(n <= STR_LEN);
+  }
+
+  private ObjectIds() {}
+}
diff --git a/java/com/google/gerrit/git/RefUpdateUtil.java b/java/com/google/gerrit/git/RefUpdateUtil.java
new file mode 100644
index 0000000..520d0f2
--- /dev/null
+++ b/java/com/google/gerrit/git/RefUpdateUtil.java
@@ -0,0 +1,182 @@
+// 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.git;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.IOException;
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Static utilities for working with JGit's ref update APIs. */
+public class RefUpdateUtil {
+  /**
+   * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
+   *
+   * <p>Creates a new {@link RevWalk} used only for this operation.
+   *
+   * @param bru batch update; should already have been executed.
+   * @param repo repository that created {@code bru}.
+   * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
+   *     #checkResults(BatchRefUpdate)} for details.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  public static void executeChecked(BatchRefUpdate bru, Repository repo) throws IOException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      executeChecked(bru, rw);
+    }
+  }
+
+  /**
+   * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
+   *
+   * @param bru batch update; should already have been executed.
+   * @param rw walk for executing the update.
+   * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
+   *     #checkResults(BatchRefUpdate)} for details.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  public static void executeChecked(BatchRefUpdate bru, RevWalk rw) throws IOException {
+    bru.execute(rw, NullProgressMonitor.INSTANCE);
+    checkResults(bru);
+  }
+
+  /**
+   * Check results of all commands in the update batch, reducing to a single exception if there was
+   * a failure.
+   *
+   * <p>Throws {@link LockFailureException} if at least one command failed with {@code
+   * LOCK_FAILURE}, and the entire transaction was aborted, i.e. any non-{@code LOCK_FAILURE}
+   * results, if there were any, failed with "transaction aborted".
+   *
+   * <p>In particular, if the underlying ref database does not {@link
+   * org.eclipse.jgit.lib.RefDatabase#performsAtomicTransactions() perform atomic transactions},
+   * then a combination of {@code LOCK_FAILURE} on one ref and {@code OK} or another result on other
+   * refs will <em>not</em> throw {@code LockFailureException}.
+   *
+   * @param bru batch update; should already have been executed.
+   * @throws LockFailureException if the transaction was aborted due to lock failure.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  @VisibleForTesting
+  static void checkResults(BatchRefUpdate bru) throws IOException {
+    if (bru.getCommands().isEmpty()) {
+      return;
+    }
+
+    int lockFailure = 0;
+    int aborted = 0;
+    int failure = 0;
+
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        failure++;
+      }
+      if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
+        lockFailure++;
+      } else if (cmd.getResult() == ReceiveCommand.Result.REJECTED_OTHER_REASON
+          && JGitText.get().transactionAborted.equals(cmd.getMessage())) {
+        aborted++;
+      }
+    }
+
+    if (lockFailure + aborted == bru.getCommands().size()) {
+      throw new LockFailureException("Update aborted with one or more lock failures: " + bru, bru);
+    } else if (failure > 0) {
+      throw new IOException("Update failed: " + bru);
+    }
+  }
+
+  /**
+   * Check results of a single ref update, throwing an exception if there was a failure.
+   *
+   * @param ru ref update; must already have been executed.
+   * @throws IllegalArgumentException if the result was {@code NOT_ATTEMPTED}.
+   * @throws LockFailureException if the result was {@code LOCK_FAILURE}.
+   * @throws IOException if the result failed for another reason.
+   */
+  public static void checkResult(RefUpdate ru) throws IOException {
+    RefUpdate.Result result = ru.getResult();
+    switch (result) {
+      case NOT_ATTEMPTED:
+        throw new IllegalArgumentException("Not attempted: " + ru.getName());
+      case NEW:
+      case FORCED:
+      case NO_CHANGE:
+      case FAST_FORWARD:
+      case RENAMED:
+        return;
+      case LOCK_FAILURE:
+        throw new LockFailureException("Failed to update " + ru.getName() + ": " + result, ru);
+      default:
+      case IO_FAILURE:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+        throw new IOException("Failed to update " + ru.getName() + ": " + ru.getResult());
+    }
+  }
+
+  /**
+   * Delete a single ref, throwing a checked exception on failure.
+   *
+   * <p>Does not require that the ref have any particular old value. Succeeds as a no-op if the ref
+   * did not exist.
+   *
+   * @param repo repository.
+   * @param refName ref name to delete.
+   * @throws LockFailureException if a low-level lock failure (e.g. compare-and-swap failure)
+   *     occurs.
+   * @throws IOException if an error occurred.
+   */
+  public static void deleteChecked(Repository repo, String refName) throws IOException {
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setForceUpdate(true);
+    switch (ru.delete()) {
+      case FORCED:
+        // Ref was deleted.
+        return;
+
+      case NEW:
+        // Ref didn't exist (yes, really).
+        return;
+
+      case LOCK_FAILURE:
+        throw new LockFailureException("Failed to delete " + refName + ": " + ru.getResult(), ru);
+
+        // Not really failures, but should not be the result of a deletion, so the best option is to
+        // throw.
+      case NO_CHANGE:
+      case FAST_FORWARD:
+      case RENAMED:
+      case NOT_ATTEMPTED:
+
+      case IO_FAILURE:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        throw new IOException("Failed to delete " + refName + ": " + ru.getResult());
+    }
+  }
+
+  private RefUpdateUtil() {}
+}
diff --git a/java/com/google/gerrit/git/testing/BUILD b/java/com/google/gerrit/git/testing/BUILD
index 4900339..497510d 100644
--- a/java/com/google/gerrit/git/testing/BUILD
+++ b/java/com/google/gerrit/git/testing/BUILD
@@ -1,4 +1,4 @@
-package(default_testonly = 1)
+package(default_testonly = True)
 
 java_library(
     name = "testing",
diff --git a/java/com/google/gerrit/git/testing/CommitSubject.java b/java/com/google/gerrit/git/testing/CommitSubject.java
new file mode 100644
index 0000000..41eb45b
--- /dev/null
+++ b/java/com/google/gerrit/git/testing/CommitSubject.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.git.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** Subject over JGit {@link RevCommit}s. */
+public class CommitSubject extends Subject {
+
+  /**
+   * Constructs a new subject.
+   *
+   * @param commit the commit.
+   * @return a new subject over the commit.
+   */
+  public static CommitSubject assertThat(RevCommit commit) {
+    return assertAbout(CommitSubject::new).that(commit);
+  }
+
+  /**
+   * Performs some common assertions over a single commit.
+   *
+   * @param commit the commit.
+   * @param expectedCommitMessage exact expected commit message.
+   * @param expectedCommitTimestamp expected commit timestamp, to the tolerance specified in {@link
+   *     #hasCommitTimestamp(Timestamp)}.
+   * @param expectedSha1 expected commit SHA-1.
+   */
+  public static void assertCommit(
+      RevCommit commit,
+      String expectedCommitMessage,
+      Timestamp expectedCommitTimestamp,
+      ObjectId expectedSha1) {
+    CommitSubject commitSubject = assertThat(commit);
+    commitSubject.hasCommitMessage(expectedCommitMessage);
+    commitSubject.hasCommitTimestamp(expectedCommitTimestamp);
+    commitSubject.hasSha1(expectedSha1);
+  }
+
+  private final RevCommit commit;
+
+  private CommitSubject(FailureMetadata metadata, RevCommit commit) {
+    super(metadata, commit);
+    this.commit = commit;
+  }
+
+  /**
+   * Asserts that the commit has the given commit message.
+   *
+   * @param expectedCommitMessage exact expected commit message.
+   */
+  public void hasCommitMessage(String expectedCommitMessage) {
+    isNotNull();
+    check("getFullMessage()").that(commit.getFullMessage()).isEqualTo(expectedCommitMessage);
+  }
+
+  /**
+   * Asserts that the commit has the given commit message, up to skew of at most 1 second.
+   *
+   * @param expectedCommitTimestamp expected commit timestamp.
+   */
+  public void hasCommitTimestamp(Timestamp expectedCommitTimestamp) {
+    isNotNull();
+    long timestampDiffMs =
+        Math.abs(commit.getCommitTime() * 1000L - expectedCommitTimestamp.getTime());
+    check("commitTimestampDiff()").that(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
+  }
+
+  /**
+   * Asserts that the commit has the given SHA-1.
+   *
+   * @param expectedSha1 expected commit SHA-1.
+   */
+  public void hasSha1(ObjectId expectedSha1) {
+    isNotNull();
+    check("sha1()").that(commit).isEqualTo(expectedSha1);
+  }
+}
diff --git a/java/com/google/gerrit/git/testing/ObjectIdSubject.java b/java/com/google/gerrit/git/testing/ObjectIdSubject.java
new file mode 100644
index 0000000..0cfc563
--- /dev/null
+++ b/java/com/google/gerrit/git/testing/ObjectIdSubject.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.git.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class ObjectIdSubject extends Subject {
+  public static ObjectIdSubject assertThat(ObjectId objectId) {
+    return assertAbout(objectIds()).that(objectId);
+  }
+
+  public static Factory<ObjectIdSubject, ObjectId> objectIds() {
+    return ObjectIdSubject::new;
+  }
+
+  private final ObjectId objectId;
+
+  private ObjectIdSubject(FailureMetadata metadata, ObjectId objectId) {
+    super(metadata, objectId);
+    this.objectId = objectId;
+  }
+
+  public void hasName(String expectedName) {
+    isNotNull();
+    check("getName()").that(objectId.getName()).isEqualTo(expectedName);
+  }
+}
diff --git a/java/com/google/gerrit/git/testing/PushResultSubject.java b/java/com/google/gerrit/git/testing/PushResultSubject.java
index c5163d1..9a46632 100644
--- a/java/com/google/gerrit/git/testing/PushResultSubject.java
+++ b/java/com/google/gerrit/git/testing/PushResultSubject.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.git.testing;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
-import static java.util.stream.Collectors.joining;
+import static com.google.gerrit.git.testing.PushResultSubject.RemoteRefUpdateSubject.refs;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
@@ -25,37 +26,46 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StreamSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
-import com.google.common.truth.Truth8;
 import com.google.gerrit.common.Nullable;
-import java.util.Arrays;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 
-public class PushResultSubject extends Subject<PushResultSubject, PushResult> {
+public class PushResultSubject extends Subject {
   public static PushResultSubject assertThat(PushResult actual) {
     return assertAbout(PushResultSubject::new).that(actual);
   }
 
-  private PushResultSubject(FailureMetadata metadata, PushResult actual) {
-    super(metadata, actual);
+  private final PushResult pushResult;
+
+  private PushResultSubject(FailureMetadata metadata, PushResult pushResult) {
+    super(metadata, pushResult);
+    this.pushResult = pushResult;
   }
 
   public void hasNoMessages() {
-    Truth.assertWithMessage("expected no messages")
-        .that(Strings.nullToEmpty(trimMessages()))
-        .isEqualTo("");
+    isNotNull();
+    check("hasNoMessages()").that(Strings.nullToEmpty(getTrimmedMessages())).isEqualTo("");
   }
 
   public void hasMessages(String... expectedLines) {
     checkArgument(expectedLines.length > 0, "use hasNoMessages()");
     isNotNull();
-    Truth.assertThat(trimMessages()).isEqualTo(Arrays.stream(expectedLines).collect(joining("\n")));
+    check("getTrimmedMessages()")
+        .that(getTrimmedMessages())
+        .isEqualTo(String.join("\n", expectedLines));
   }
 
-  private String trimMessages() {
-    return trimMessages(actual().getMessages());
+  public void containsMessages(String... expectedLines) {
+    checkArgument(expectedLines.length > 0, "use hasNoMessages()");
+    isNotNull();
+    Iterable<String> got = Splitter.on("\n").split(getTrimmedMessages());
+    check("getTrimmedMessages()").that(got).containsAtLeastElementsIn(expectedLines).inOrder();
+  }
+
+  private String getTrimmedMessages() {
+    return trimMessages(pushResult.getMessages());
   }
 
   @VisibleForTesting
@@ -72,21 +82,19 @@
   }
 
   public void hasProcessed(ImmutableMap<String, Integer> expected) {
+    isNotNull();
     ImmutableMap<String, Integer> actual;
-    String messages = actual().getMessages();
+    String messages = pushResult.getMessages();
     try {
       actual = parseProcessed(messages);
     } catch (RuntimeException e) {
-      Truth.assert_()
-          .fail(
-              "failed to parse \"Processing changes\" line from messages: %s\n%s",
-              messages, Throwables.getStackTraceAsString(e));
+      failWithActual(
+          fact(
+              "failed to parse \"Processing changes\" line from messages, reason:",
+              Throwables.getStackTraceAsString(e)));
       return;
     }
-    Truth.assertThat(actual)
-        .named("processed commands")
-        .containsExactlyEntriesIn(expected)
-        .inOrder();
+    check("processedCommands()").that(actual).containsExactlyEntriesIn(expected).inOrder();
   }
 
   @VisibleForTesting
@@ -116,55 +124,60 @@
   }
 
   public RemoteRefUpdateSubject ref(String refName) {
-    return assertAbout(
-            (FailureMetadata m, RemoteRefUpdate a) -> new RemoteRefUpdateSubject(refName, m, a))
-        .that(actual().getRemoteUpdate(refName));
+    isNotNull();
+    return check("getRemoteUpdate(%s)", refName)
+        .about(refs())
+        .that(pushResult.getRemoteUpdate(refName));
   }
 
   public RemoteRefUpdateSubject onlyRef(String refName) {
-    Truth8.assertThat(actual().getRemoteUpdates().stream().map(RemoteRefUpdate::getRemoteName))
-        .named("set of refs")
+    isNotNull();
+    check("setOfRefs()")
+        .about(StreamSubject.streams())
+        .that(pushResult.getRemoteUpdates().stream().map(RemoteRefUpdate::getRemoteName))
         .containsExactly(refName);
     return ref(refName);
   }
 
-  public static class RemoteRefUpdateSubject
-      extends Subject<RemoteRefUpdateSubject, RemoteRefUpdate> {
-    private final String refName;
+  public static class RemoteRefUpdateSubject extends Subject {
+    private final RemoteRefUpdate remoteRefUpdate;
 
-    private RemoteRefUpdateSubject(
-        String refName, FailureMetadata metadata, RemoteRefUpdate actual) {
-      super(metadata, actual);
-      this.refName = refName;
-      named("ref update for %s", refName).isNotNull();
+    private RemoteRefUpdateSubject(FailureMetadata metadata, RemoteRefUpdate remoteRefUpdate) {
+      super(metadata, remoteRefUpdate);
+      this.remoteRefUpdate = remoteRefUpdate;
+    }
+
+    static Factory<RemoteRefUpdateSubject, RemoteRefUpdate> refs() {
+      return RemoteRefUpdateSubject::new;
     }
 
     public void hasStatus(RemoteRefUpdate.Status status) {
-      RemoteRefUpdate u = actual();
-      Truth.assertThat(u.getStatus())
-          .named(
-              "status of ref update for %s%s",
-              refName, u.getMessage() != null ? ": " + u.getMessage() : "")
+      isNotNull();
+      RemoteRefUpdate u = remoteRefUpdate;
+      check("getStatus()")
+          .withMessage(
+              "status message: %s", u.getMessage() != null ? ": " + u.getMessage() : "<emtpy>")
+          .that(u.getStatus())
           .isEqualTo(status);
     }
 
     public void hasNoMessage() {
-      Truth.assertThat(actual().getMessage())
-          .named("message of ref update for %s", refName)
-          .isNull();
+      isNotNull();
+      check("getMessage()").that(remoteRefUpdate.getMessage()).isNull();
     }
 
     public void hasMessage(String expected) {
-      Truth.assertThat(actual().getMessage())
-          .named("message of ref update for %s", refName)
-          .isEqualTo(expected);
+      isNotNull();
+      check("getMessage()").that(remoteRefUpdate.getMessage()).isEqualTo(expected);
     }
 
     public void isOk() {
+      isNotNull();
       hasStatus(RemoteRefUpdate.Status.OK);
     }
 
     public void isRejected(String expectedMessage) {
+      isNotNull();
       hasStatus(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
       hasMessage(expectedMessage);
     }
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 0aa6ca2..49806cf 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -3,12 +3,14 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/gpg/CheckResult.java b/java/com/google/gerrit/gpg/CheckResult.java
index da891aa..8655b2a 100644
--- a/java/com/google/gerrit/gpg/CheckResult.java
+++ b/java/com/google/gerrit/gpg/CheckResult.java
@@ -31,14 +31,14 @@
   }
 
   static CheckResult trusted() {
-    return new CheckResult(Status.TRUSTED, Collections.<String>emptyList());
+    return new CheckResult(Status.TRUSTED, Collections.emptyList());
   }
 
   static CheckResult create(Status status, String... problems) {
     List<String> problemList =
         problems.length > 0
             ? Collections.unmodifiableList(Arrays.asList(problems))
-            : Collections.<String>emptyList();
+            : Collections.emptyList();
     return new CheckResult(status, problemList);
   }
 
diff --git a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index bc0cd89..9c08857 100644
--- a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -22,14 +22,13 @@
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
-import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -38,6 +37,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
@@ -58,7 +58,7 @@
   @Singleton
   public static class Factory {
     private final Provider<InternalAccountQuery> accountQueryProvider;
-    private final String webUrl;
+    private final DynamicItem<UrlFormatter> urlFormatter;
     private final IdentifiedUser.GenericFactory userFactory;
     private final int maxTrustDepth;
     private final ImmutableMap<Long, Fingerprint> trusted;
@@ -68,9 +68,9 @@
         @GerritServerConfig Config cfg,
         Provider<InternalAccountQuery> accountQueryProvider,
         IdentifiedUser.GenericFactory userFactory,
-        @CanonicalWebUrl String webUrl) {
+        DynamicItem<UrlFormatter> urlFormatter) {
       this.accountQueryProvider = accountQueryProvider;
-      this.webUrl = webUrl;
+      this.urlFormatter = urlFormatter;
       this.userFactory = userFactory;
       this.maxTrustDepth = cfg.getInt("receive", null, "maxTrustDepth", 0);
 
@@ -101,14 +101,14 @@
   }
 
   private final Provider<InternalAccountQuery> accountQueryProvider;
-  private final String webUrl;
+  private final DynamicItem<UrlFormatter> urlFormatter;
   private final IdentifiedUser.GenericFactory userFactory;
 
   private IdentifiedUser expectedUser;
 
   private GerritPublicKeyChecker(Factory factory) {
     this.accountQueryProvider = factory.accountQueryProvider;
-    this.webUrl = factory.webUrl;
+    this.urlFormatter = factory.urlFormatter;
     this.userFactory = factory.userFactory;
     if (factory.trusted != null) {
       enableTrust(factory.maxTrustDepth, factory.trusted);
@@ -134,7 +134,7 @@
         return checkIdsForExpectedUser(key);
       }
       return checkIdsForArbitraryUser(key);
-    } catch (PGPException | OrmException e) {
+    } catch (PGPException | RuntimeException e) {
       String msg = "Error checking user IDs for key";
       logger.atWarning().withCause(e).log("%s %s", msg, keyIdToString(key.getKeyID()));
       return CheckResult.bad(msg);
@@ -144,8 +144,10 @@
   private CheckResult checkIdsForExpectedUser(PGPPublicKey key) throws PGPException {
     Set<String> allowedUserIds = getAllowedUserIds(expectedUser);
     if (allowedUserIds.isEmpty()) {
+      Optional<String> settings = urlFormatter.get().getSettingsUrl("Identities");
       return CheckResult.bad(
-          "No identities found for user; check " + webUrl + "#" + PageLinks.SETTINGS_WEBIDENT);
+          "No identities found for user"
+              + (settings.isPresent() ? "; check " + settings.get() : ""));
     }
     if (hasAllowedUserId(key, allowedUserIds)) {
       return CheckResult.trusted();
@@ -153,7 +155,7 @@
     return CheckResult.bad(missingUserIds(allowedUserIds));
   }
 
-  private CheckResult checkIdsForArbitraryUser(PGPPublicKey key) throws PGPException, OrmException {
+  private CheckResult checkIdsForArbitraryUser(PGPPublicKey key) throws PGPException {
     List<AccountState> accountStates = accountQueryProvider.get().byExternalId(toExtIdKey(key));
     if (accountStates.isEmpty()) {
       return CheckResult.bad("Key is not associated with any users");
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 07b42f1..27530e7 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -130,7 +130,7 @@
     if (store == null) {
       throw new IllegalStateException("PublicKeyStore is required");
     }
-    return check(key, 0, true, trusted != null ? new HashSet<Fingerprint>() : null);
+    return check(key, 0, true, trusted != null ? new HashSet<>() : null);
   }
 
   /**
diff --git a/java/com/google/gerrit/gpg/PublicKeyStore.java b/java/com/google/gerrit/gpg/PublicKeyStore.java
index 19d503f..519c400 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -15,8 +15,14 @@
 package com.google.gerrit.gpg;
 
 import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import com.google.common.base.Preconditions;
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.git.ObjectIds;
+import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -38,8 +44,9 @@
 import org.bouncycastle.openpgp.PGPSignature;
 import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
 import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -63,13 +70,12 @@
  * matching the ID. Multiple keys are supported because forging a key ID is possible, but such a key
  * cannot be used to verify signatures produced with the correct key.
  *
+ * <p>Subkeys are mapped to the master GPG key in the same NoteMap.
+ *
  * <p>No additional checks are performed on the key after reading; callers should only trust keys
  * after checking with a {@link PublicKeyChecker}.
  */
 public class PublicKeyStore implements AutoCloseable {
-  private static final ObjectId EMPTY_TREE =
-      ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904");
-
   /** Ref where GPG public keys are stored. */
   public static final String REFS_GPG_KEYS = "refs/meta/gpg-keys";
 
@@ -88,11 +94,19 @@
   public static PGPPublicKey getSigner(
       Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, byte[] data) throws PGPException {
     for (PGPPublicKeyRing kr : keyRings) {
-      PGPPublicKey k = kr.getPublicKey();
+      // Possibly return a signing subkey in case it differs from the master public key
+      PGPPublicKey k = kr.getPublicKey(sig.getKeyID());
+      if (k == null) {
+        throw new IllegalStateException(
+            "No public key found for ID: " + keyIdToString(sig.getKeyID()));
+      }
       sig.init(new BcPGPContentVerifierBuilderProvider(), k);
       sig.update(data);
       if (sig.verify()) {
-        return k;
+        // If the signature was made using a subkey, return the main public key.
+        // This enables further validity checks, like user ID checks, that can only
+        // be performed using the master public key.
+        return kr.getPublicKey();
       }
     }
     return null;
@@ -207,19 +221,34 @@
     if (notes == null) {
       return Collections.emptyList();
     }
-    Note note = notes.getNote(keyObjectId(keyId));
+
+    return get(keyObjectId(keyId), fp);
+  }
+
+  private List<PGPPublicKeyRing> get(ObjectId keyObjectId, byte[] fp) throws IOException {
+    Note note = notes.getNote(keyObjectId);
     if (note == null) {
       return Collections.emptyList();
     }
 
+    return readKeysFromNote(note, fp);
+  }
+
+  private List<PGPPublicKeyRing> readKeysFromNote(Note note, byte[] fp)
+      throws IOException, MissingObjectException, IncorrectObjectTypeException {
+    boolean foundAtLeastOneKey = false;
     List<PGPPublicKeyRing> keys = new ArrayList<>();
-    try (InputStream in = reader.open(note.getData(), OBJ_BLOB).openStream()) {
+    ObjectId data = note.getData();
+    try (InputStream stream = reader.open(data, OBJ_BLOB).openStream()) {
+      byte[] bytes = ByteStreams.toByteArray(stream);
+      InputStream in = new ByteArrayInputStream(bytes);
       while (true) {
         @SuppressWarnings("unchecked")
         Iterator<Object> it = new BcPGPObjectFactory(new ArmoredInputStream(in)).iterator();
         if (!it.hasNext()) {
           break;
         }
+        foundAtLeastOneKey = true;
         Object obj = it.next();
         if (obj instanceof PGPPublicKeyRing) {
           PGPPublicKeyRing kr = (PGPPublicKeyRing) obj;
@@ -229,7 +258,34 @@
         }
         checkState(!it.hasNext(), "expected one PGP object per ArmoredInputStream");
       }
-      return keys;
+
+      if (foundAtLeastOneKey) {
+        return keys;
+      }
+
+      // Subkey handling
+      String id = new String(bytes, UTF_8);
+      Preconditions.checkArgument(ObjectId.isId(id), "Not valid SHA1: " + id);
+      return get(ObjectId.fromString(id), fp);
+    }
+  }
+
+  public void rebuildSubkeyMasterKeyMap()
+      throws MissingObjectException, IncorrectObjectTypeException, IOException, PGPException {
+    if (reader == null) {
+      load();
+    }
+    if (notes != null) {
+      try (ObjectInserter ins = repo.newObjectInserter()) {
+        for (Note note : notes) {
+          for (PGPPublicKeyRing keyRing :
+              new PGPPublicKeyRingCollection(readKeysFromNote(note, null))) {
+            long masterKeyId = keyRing.getPublicKey().getKeyID();
+            ObjectId masterKeyObjectId = keyObjectId(masterKeyId);
+            saveSubkeyMapping(ins, keyRing, masterKeyId, masterKeyObjectId);
+          }
+        }
+      }
     }
   }
 
@@ -302,7 +358,7 @@
         deleteFromNotes(ins, fp);
       }
       cb.setTreeId(notes.writeTree(ins));
-      if (cb.getTreeId().equals(tip != null ? tip.getTree() : EMPTY_TREE)) {
+      if (cb.getTreeId().equals(tip != null ? tip.getTree() : EMPTY_TREE_ID)) {
         return RefUpdate.Result.NO_CHANGE;
       }
 
@@ -348,8 +404,8 @@
 
   private void saveToNotes(ObjectInserter ins, PGPPublicKeyRing keyRing)
       throws PGPException, IOException {
-    long keyId = keyRing.getPublicKey().getKeyID();
-    PGPPublicKeyRingCollection existing = get(keyId);
+    long masterKeyId = keyRing.getPublicKey().getKeyID();
+    PGPPublicKeyRingCollection existing = get(masterKeyId);
     List<PGPPublicKeyRing> toWrite = new ArrayList<>(existing.size() + 1);
     boolean replaced = false;
     for (PGPPublicKeyRing kr : existing) {
@@ -363,7 +419,37 @@
     if (!replaced) {
       toWrite.add(keyRing);
     }
-    notes.set(keyObjectId(keyId), ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
+
+    ObjectId masterKeyObjectId = keyObjectId(masterKeyId);
+    notes.set(masterKeyObjectId, ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
+
+    saveSubkeyMapping(ins, keyRing, masterKeyId, masterKeyObjectId);
+  }
+
+  private void saveSubkeyMapping(
+      ObjectInserter ins, PGPPublicKeyRing keyRing, long masterKeyId, ObjectId masterKeyObjectId)
+      throws IOException {
+    // Subkey handling
+    byte[] masterKeyBytes = masterKeyObjectId.name().getBytes(UTF_8);
+    ObjectId masterKeyObject = null;
+    for (PGPPublicKey key : keyRing) {
+      long subKeyId = key.getKeyID();
+      // Skip master public key
+      if (masterKeyId == subKeyId) {
+        continue;
+      }
+
+      // Insert master key object only once for all subkeys
+      if (masterKeyObject == null) {
+        masterKeyObject = ins.insert(OBJ_BLOB, masterKeyBytes);
+      }
+
+      ObjectId subkeyObjectId = keyObjectId(subKeyId);
+      Preconditions.checkArgument(
+          notes.get(subkeyObjectId) == null || notes.get(subkeyObjectId).equals(masterKeyObject),
+          "Master key differs for subkey: " + subkeyObjectId.name());
+      notes.set(subkeyObjectId, masterKeyObject);
+    }
   }
 
   private void deleteFromNotes(ObjectInserter ins, Fingerprint fp)
@@ -378,10 +464,24 @@
     }
     if (toWrite.size() == existing.size()) {
       return;
-    } else if (!toWrite.isEmpty()) {
-      notes.set(keyObjectId(keyId), ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
+    }
+
+    ObjectId keyObjectId = keyObjectId(keyId);
+    if (!toWrite.isEmpty()) {
+      notes.set(keyObjectId, ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
     } else {
-      notes.remove(keyObjectId(keyId));
+      PGPPublicKeyRing keyRing = get(fp.get());
+
+      for (PGPPublicKey key : keyRing) {
+        long subKeyId = key.getKeyID();
+        // Skip master public key
+        if (keyId == subKeyId) {
+          continue;
+        }
+        notes.remove(keyObjectId(subKeyId));
+      }
+
+      notes.remove(keyObjectId);
     }
   }
 
@@ -414,7 +514,7 @@
   }
 
   static ObjectId keyObjectId(long keyId) {
-    byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+    byte[] buf = new byte[ObjectIds.LEN];
     NB.encodeInt64(buf, 0, keyId);
     return ObjectId.fromRaw(buf);
   }
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 967259a..62f1d18 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.GpgApiAdapter;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -66,8 +65,8 @@
   public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
       throws RestApiException, GpgException {
     try {
-      return gpgKeys.get().list().apply(account);
-    } catch (OrmException | PGPException | IOException e) {
+      return gpgKeys.get().list().apply(account).value();
+    } catch (PGPException | IOException e) {
       throw new GpgException(e);
     }
   }
@@ -80,8 +79,8 @@
     in.add = add;
     in.delete = delete;
     try {
-      return postGpgKeys.get().apply(account, in);
-    } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
+      return postGpgKeys.get().apply(account, in).value();
+    } catch (PGPException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
     }
   }
@@ -91,7 +90,7 @@
       throws RestApiException, GpgException {
     try {
       return gpgKeyApiFactory.create(gpgKeys.get().parse(account, idStr));
-    } catch (PGPException | OrmException | IOException e) {
+    } catch (PGPException | IOException e) {
       throw new GpgException(e);
     }
   }
diff --git a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 25b472d..311e00a 100644
--- a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.gpg.server.DeleteGpgKey;
 import com.google.gerrit.gpg.server.GpgKey;
 import com.google.gerrit.gpg.server.GpgKeys;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -47,7 +46,7 @@
   @Override
   public GpgKeyInfo get() throws RestApiException {
     try {
-      return get.apply(rsrc);
+      return get.apply(rsrc).value();
     } catch (IOException e) {
       throw new RestApiException("Cannot get GPG key", e);
     }
@@ -57,7 +56,7 @@
   public void delete() throws RestApiException {
     try {
       delete.apply(rsrc, new Input());
-    } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
+    } catch (PGPException | IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot delete GPG key", e);
     }
   }
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index a636a8b..24bfd4f 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -17,7 +17,10 @@
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -30,7 +33,7 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -43,27 +46,31 @@
 import org.eclipse.jgit.lib.RefUpdate;
 
 public class DeleteGpgKey implements RestModifyView<GpgKey, Input> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<PersonIdent> serverIdent;
   private final Provider<PublicKeyStore> storeProvider;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final ExternalIds externalIds;
+  private final DeleteKeySender.Factory deleteKeySenderFactory;
 
   @Inject
   DeleteGpgKey(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       Provider<PublicKeyStore> storeProvider,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      ExternalIds externalIds) {
+      ExternalIds externalIds,
+      DeleteKeySender.Factory deleteKeySenderFactory) {
     this.serverIdent = serverIdent;
     this.storeProvider = storeProvider;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.externalIds = externalIds;
+    this.deleteKeySenderFactory = deleteKeySenderFactory;
   }
 
   @Override
   public Response<?> apply(GpgKey rsrc, Input input)
-      throws RestApiException, PGPException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, PGPException, IOException, ConfigInvalidException {
     PGPPublicKey key = rsrc.getKeyRing().getPublicKey();
     String fingerprint = BaseEncoding.base16().encode(key.getFingerprint());
     Optional<ExternalId> extId = externalIds.get(ExternalId.Key.create(SCHEME_GPGKEY, fingerprint));
@@ -91,6 +98,15 @@
       switch (saveResult) {
         case NO_CHANGE:
         case FAST_FORWARD:
+          try {
+            deleteKeySenderFactory
+                .create(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
+                .send();
+          } catch (EmailException e) {
+            logger.atSevere().withCause(e).log(
+                "Cannot send GPG key deletion message to %s",
+                rsrc.getUser().getAccount().preferredEmail());
+          }
           break;
         case FORCED:
         case IO_FAILURE:
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index a2d901f..b3a2f53 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.gpg.BouncyCastleUtil;
@@ -39,7 +40,6 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -59,8 +59,6 @@
 public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static final String MIME_TYPE = "application/pgp-keys";
-
   private final DynamicMap<RestView<GpgKey>> views;
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
@@ -88,7 +86,7 @@
 
   @Override
   public GpgKey parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, PGPException, OrmException, IOException {
+      throws ResourceNotFoundException, PGPException, IOException {
     checkVisible(self, parent);
 
     ExternalId gpgKeyExtId = findGpgKey(id.get(), getGpgExtIds(parent));
@@ -143,8 +141,8 @@
 
   public class ListGpgKeys implements RestReadView<AccountResource> {
     @Override
-    public Map<String, GpgKeyInfo> apply(AccountResource rsrc)
-        throws OrmException, PGPException, IOException, ResourceNotFoundException {
+    public Response<Map<String, GpgKeyInfo>> apply(AccountResource rsrc)
+        throws PGPException, IOException, ResourceNotFoundException {
       checkVisible(self, rsrc);
       Map<String, GpgKeyInfo> keys = new HashMap<>();
       try (PublicKeyStore store = storeProvider.get()) {
@@ -168,7 +166,7 @@
           }
         }
       }
-      return keys;
+      return Response.ok(keys);
     }
   }
 
@@ -184,12 +182,13 @@
     }
 
     @Override
-    public GpgKeyInfo apply(GpgKey rsrc) throws IOException {
+    public Response<GpgKeyInfo> apply(GpgKey rsrc) throws IOException {
       try (PublicKeyStore store = storeProvider.get()) {
-        return toJson(
-            rsrc.getKeyRing().getPublicKey(),
-            checkerFactory.create().setExpectedUser(rsrc.getUser()),
-            store);
+        return Response.ok(
+            toJson(
+                rsrc.getKeyRing().getPublicKey(),
+                checkerFactory.create().setExpectedUser(rsrc.getUser()),
+                store));
       }
     }
   }
@@ -221,13 +220,14 @@
       Iterator<String> userIds = key.getUserIDs();
       info.userIds = ImmutableList.copyOf(userIds);
 
-      try (ByteArrayOutputStream out = new ByteArrayOutputStream(4096);
-          ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
-        // This is not exactly the key stored in the store, but is equivalent. In
-        // particular, it will have a Bouncy Castle version string. The armored
-        // stream reader in PublicKeyStore doesn't give us an easy way to extract
-        // the original ASCII armor.
-        key.encode(aout);
+      try (ByteArrayOutputStream out = new ByteArrayOutputStream(4096)) {
+        try (ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
+          // This is not exactly the key stored in the store, but is equivalent. In
+          // particular, it will have a Bouncy Castle version string. The armored
+          // stream reader in PublicKeyStore doesn't give us an easy way to extract
+          // the original ASCII armor.
+          key.encode(aout);
+        }
         info.key = new String(out.toByteArray(), UTF_8);
       }
     }
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 7d08fca..bfd7d27 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -18,21 +18,28 @@
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 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.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.gpg.CheckResult;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
@@ -49,8 +56,10 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -80,10 +89,12 @@
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final AddKeySender.Factory addKeyFactory;
+  private final AddKeySender.Factory addKeySenderFactory;
+  private final DeleteKeySender.Factory deleteKeySenderFactory;
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final RetryHelper retryHelper;
 
   @Inject
   PostGpgKeys(
@@ -91,24 +102,27 @@
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
-      AddKeySender.Factory addKeyFactory,
+      AddKeySender.Factory addKeySenderFactory,
+      DeleteKeySender.Factory deleteKeySenderFactory,
       Provider<InternalAccountQuery> accountQueryProvider,
       ExternalIds externalIds,
-      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
+      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+      RetryHelper retryHelper) {
     this.serverIdent = serverIdent;
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.addKeyFactory = addKeyFactory;
+    this.addKeySenderFactory = addKeySenderFactory;
+    this.deleteKeySenderFactory = deleteKeySenderFactory;
     this.accountQueryProvider = accountQueryProvider;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
+    this.retryHelper = retryHelper;
   }
 
   @Override
-  public Map<String, GpgKeyInfo> apply(AccountResource rsrc, GpgKeysInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
-          PGPException, OrmException, IOException, ConfigInvalidException {
+  public Response<Map<String, GpgKeyInfo>> apply(AccountResource rsrc, GpgKeysInput input)
+      throws RestApiException, PGPException, IOException, ConfigInvalidException {
     GpgKeys.checkVisible(self, rsrc);
 
     Collection<ExternalId> existingExtIds =
@@ -124,7 +138,7 @@
         ExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
         Account account = getAccountByExternalId(extIdKey);
         if (account != null) {
-          if (!account.getId().equals(rsrc.getUser().getAccountId())) {
+          if (!account.id().equals(rsrc.getUser().getAccountId())) {
             throw new ResourceConflictException("GPG key already associated with another account");
           }
         } else {
@@ -140,7 +154,7 @@
               "Update GPG Keys via API",
               rsrc.getUser().getAccountId(),
               u -> u.replaceExternalIds(toRemove.keySet(), newExtIds));
-      return toJson(newKeys, fingerprintsToRemove, store, rsrc.getUser());
+      return Response.ok(toJson(newKeys, fingerprintsToRemove, store, rsrc.getUser()));
     }
   }
 
@@ -191,13 +205,31 @@
 
   private void storeKeys(
       AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
-      throws BadRequestException, ResourceConflictException, PGPException, IOException {
+      throws RestApiException, PGPException, IOException {
+    try {
+      retryHelper.execute(
+          ActionType.ACCOUNT_UPDATE,
+          () -> tryStoreKeys(rsrc, keyRings, toRemove),
+          LockFailureException.class::isInstance);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      Throwables.throwIfInstanceOf(e, IOException.class);
+      Throwables.throwIfInstanceOf(e, PGPException.class);
+      throw new StorageException(e);
+    }
+  }
+
+  private Void tryStoreKeys(
+      AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
+      throws RestApiException, PGPException, IOException {
     try (PublicKeyStore store = storeProvider.get()) {
       List<String> addedKeys = new ArrayList<>();
+      IdentifiedUser user = rsrc.getUser();
       for (PGPPublicKeyRing keyRing : keyRings) {
         PGPPublicKey key = keyRing.getPublicKey();
         // Don't check web of trust; admins can fill in certifications later.
-        CheckResult result = checkerFactory.create(rsrc.getUser(), store).disableTrust().check(key);
+        CheckResult result = checkerFactory.create(user, store).disableTrust().check(key);
         if (!result.isOk()) {
           throw new BadRequestException(
               String.format(
@@ -212,7 +244,7 @@
       }
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(user.newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
       cb.setCommitter(committer);
 
       RefUpdate.Result saveResult = store.save(cb);
@@ -220,12 +252,24 @@
         case NEW:
         case FAST_FORWARD:
         case FORCED:
-          try {
-            addKeyFactory.create(rsrc.getUser(), addedKeys).send();
-          } catch (EmailException e) {
-            logger.atSevere().withCause(e).log(
-                "Cannot send GPG key added message to %s",
-                rsrc.getUser().getAccount().getPreferredEmail());
+          if (!addedKeys.isEmpty()) {
+            try {
+              addKeySenderFactory.create(user, addedKeys).send();
+            } catch (EmailException e) {
+              logger.atSevere().withCause(e).log(
+                  "Cannot send GPG key added message to %s",
+                  rsrc.getUser().getAccount().preferredEmail());
+            }
+          }
+          if (!toRemove.isEmpty()) {
+            try {
+              deleteKeySenderFactory
+                  .create(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
+                  .send();
+            } catch (EmailException e) {
+              logger.atSevere().withCause(e).log(
+                  "Cannot send GPG key deleted message to %s", user.getAccount().preferredEmail());
+            }
           }
           break;
         case NO_CHANGE:
@@ -239,17 +283,17 @@
         case REJECTED_MISSING_OBJECT:
         case REJECTED_OTHER_REASON:
         default:
-          // TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
           throw new ResourceConflictException("Failed to save public keys: " + saveResult);
       }
     }
+    return null;
   }
 
   private ExternalId.Key toExtIdKey(byte[] fp) {
     return ExternalId.Key.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
   }
 
-  private Account getAccountByExternalId(ExternalId.Key extIdKey) throws OrmException {
+  private Account getAccountByExternalId(ExternalId.Key extIdKey) {
     List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
 
     if (accountStates.isEmpty()) {
@@ -257,12 +301,12 @@
     }
 
     if (accountStates.size() > 1) {
-      StringBuilder msg = new StringBuilder();
-      msg.append("GPG key ")
-          .append(extIdKey.get())
-          .append(" associated with multiple accounts: ")
-          .append(Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
-      throw new IllegalStateException(msg.toString());
+      String msg = "GPG key " + extIdKey.get() + " associated with multiple accounts: [";
+      msg =
+          accountStates.stream()
+              .map(a -> a.getAccount().id().toString())
+              .collect(joining(", ", msg, "]"));
+      throw new IllegalStateException(msg);
     }
 
     return accountStates.get(0).getAccount();
diff --git a/java/com/google/gerrit/gpg/testing/BUILD b/java/com/google/gerrit/gpg/testing/BUILD
index ff8fecf..0282d3a 100644
--- a/java/com/google/gerrit/gpg/testing/BUILD
+++ b/java/com/google/gerrit/gpg/testing/BUILD
@@ -1,6 +1,6 @@
 java_library(
     name = "gpg-test-util",
-    testonly = 1,
+    testonly = True,
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
diff --git a/java/com/google/gerrit/gpg/testing/TestKeys.java b/java/com/google/gerrit/gpg/testing/TestKeys.java
index 00acedb..de66889 100644
--- a/java/com/google/gerrit/gpg/testing/TestKeys.java
+++ b/java/com/google/gerrit/gpg/testing/TestKeys.java
@@ -1029,4 +1029,81 @@
             + "=JxsF\n"
             + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
+
+  /**
+   * Master Key without expiration with subkey with expiration.
+   *
+   * <pre>
+   * pub   rsa1024 2018-11-17 [C]
+   *       5734 2C37 982A 843B 19C0  622B 6AAF 2D26 B481 02DB
+   * uid            [ultimate] Testuser 10 <testuser10@example.com>
+   * sub   rsa1024 2018-11-17 [S] [expires: 2065-11-05]
+   *       0A4A 9660 1B96 2DFC E898  E686 4305 C92E 626E B485
+   * </pre>
+   */
+  public static TestKey validKeyWithoutExpirationWithSubkeyWithExpiration() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "\n"
+            + "mI0EW+98GgEEALLn87xX++daic3AzKwM7nY50Mx2eTaEZPlnaDAVvFlhbZuPG+n5\n"
+            + "g93vYX3wEfnFxI7IBEe7VMT1AyszLZgpFmbzW8eGQxCGpRd1hYrUUlC0IkGAwG9v\n"
+            + "LQB85GDDZUUH4p+A4oHX0yUm8iCpbO9+D82xzNDe/D8Xbw1foWMWGonLABEBAAG0\n"
+            + "JFRlc3R1c2VyIDEwIDx0ZXN0dXNlcjEwQGV4YW1wbGUuY29tPojOBBMBCAA4FiEE\n"
+            + "VzQsN5gqhDsZwGIraq8tJrSBAtsFAlvvfBoCGwEFCwkIBwIGFQoJCAsCBBYCAwEC\n"
+            + "HgECF4AACgkQaq8tJrSBAtvfFAP/VqV77KQZp9rjSGStDpxxlatr4Y5nrRBZfV5v\n"
+            + "jpAjwusIHRjr0OWXLxX7NDLYd+oIjhLFn26Lux1UXOQT+rGRPwnxoJZWrpDQidP7\n"
+            + "fDgfqnNa5UQGvoBPSIVEK1l0DlYAOUuciwz3HdMkeMuvEVEdyg7nOiVd1bF9V9i/\n"
+            + "8v7ABV24jQRb73xXAQQAssv5gwxWx5J0q4gGcqMIaJKzBaHAjiK3ryH6qnFQpsf1\n"
+            + "ODtU+a4NxFJsXGOd6jHEhBEHPgWAaaKZ7PEJVnwA/XOhPG+q9YimAbbZS0qmC/LH\n"
+            + "DpFtFbsJsMKZbIC69j9OcbmalIowspFQBVeAankGFReZVhh99Z/o81Y+Twm9eisA\n"
+            + "EQEAAYkBcQQYAQgAJhYhBFc0LDeYKoQ7GcBiK2qvLSa0gQLbBQJb73xXAhsCBQlY\n"
+            + "WHSAAL8JEGqvLSa0gQLbtCAEGQEIAB0WIQQKSpZgG5Yt/OiY5oZDBckuYm60hQUC\n"
+            + "W+98VwAKCRBDBckuYm60hafuBACSkvwXAYfxvAf7IOK6+Sp3oWkrq6vsjH5K7oup\n"
+            + "TimR1cVgN1CEAWh82UBCg3zR5Q5BAnvnjeugdQVrAY+sftkaoy8qO5YYHCPtHtQK\n"
+            + "mXGWM7Q33hU1E7IfgU06qkFLhIOL3Vwr8jOuOzHv0M3PbLNr7lOCaIJ7uCjPBZuo\n"
+            + "qRwqjHt2BACuXwA8RHbRGAxC65YgoSjGNu/da3q2J26E57KfSFprQ4TzAg33U4Ws\n"
+            + "qdx2vbbJxy1bfTYxb0AYXe/+k23W7EIdBtMGOYwrX01oTfSIKbM+gDrHswSYXdOy\n"
+            + "ziatLaUBSQfyG656lGGZO/aArLZb4dgGeBHBwhXTufFDIYl3X94zfQ==\n"
+            + "=BG9Z\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "\n"
+            + "lQIGBFvvfBoBBACy5/O8V/vnWonNwMysDO52OdDMdnk2hGT5Z2gwFbxZYW2bjxvp\n"
+            + "+YPd72F98BH5xcSOyARHu1TE9QMrMy2YKRZm81vHhkMQhqUXdYWK1FJQtCJBgMBv\n"
+            + "by0AfORgw2VFB+KfgOKB19MlJvIgqWzvfg/NsczQ3vw/F28NX6FjFhqJywARAQAB\n"
+            + "/gcDAmvJS+mrsxlf7D58lnhHw8gBjP5JkY9anTgVhIdieJEIlitV4PyRQYPAGZZd\n"
+            + "asUC7bC7WnLCygiU59kXS5z63Ue/RM0tVwWy0FsigpseC90Mtwb4wjL2jTeebszD\n"
+            + "UcM33d6Tg9s4eNnsHzpmlC/CReW6MYJj0/06AsvgUgOxgWXf0YapOLRIr60reTUb\n"
+            + "ovVZtH76rsZXyQvR9qJv11F+BmIDDzg4EsipXDGVuEZ0SXJyq6OLAUPkV2ZdELaT\n"
+            + "P4RWp0Zsn22H8jm4MsZ7la2Ux3jD2AMdy2B9dpwhuxOegDSXUMRfXYwxQ1wioqpA\n"
+            + "pOZ1RjjFsID34XNtxGp3wMYcFleOl3CSpXyW/P1PYTVBQta/Y7xniEetzUk9NHVc\n"
+            + "2jMD8a8767+Tk0SChIPmWOhQYrHS1Ce309SjTRSRVexjKF0Mp5WXHxqz582IlPT9\n"
+            + "jdxvLjVIW4xbtZmnQ2JnPHInWbxoa3exaoPg5osvg8h7QlGxRY6H2/20JFRlc3R1\n"
+            + "c2VyIDEwIDx0ZXN0dXNlcjEwQGV4YW1wbGUuY29tPojOBBMBCAA4FiEEVzQsN5gq\n"
+            + "hDsZwGIraq8tJrSBAtsFAlvvfBoCGwEFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AA\n"
+            + "CgkQaq8tJrSBAtvfFAP/VqV77KQZp9rjSGStDpxxlatr4Y5nrRBZfV5vjpAjwusI\n"
+            + "HRjr0OWXLxX7NDLYd+oIjhLFn26Lux1UXOQT+rGRPwnxoJZWrpDQidP7fDgfqnNa\n"
+            + "5UQGvoBPSIVEK1l0DlYAOUuciwz3HdMkeMuvEVEdyg7nOiVd1bF9V9i/8v7ABV2d\n"
+            + "AgYEW+98VwEEALLL+YMMVseSdKuIBnKjCGiSswWhwI4it68h+qpxUKbH9Tg7VPmu\n"
+            + "DcRSbFxjneoxxIQRBz4FgGmimezxCVZ8AP1zoTxvqvWIpgG22UtKpgvyxw6RbRW7\n"
+            + "CbDCmWyAuvY/TnG5mpSKMLKRUAVXgGp5BhUXmVYYffWf6PNWPk8JvXorABEBAAH+\n"
+            + "BwMClgBcY/ItnafslgEWZOSqsxCSJatN72c9zzWZE+zmcx9NbDRuCVxXhTbHJZZs\n"
+            + "Hz44vsKqOKtyhrfr9Oke0mYyzH2CX6tv6ghJyC6znRCGoc/P84uJ1v3ibO/7/p85\n"
+            + "PDHzEEpHmdbef+UymnjZBYKGi45SINy3bLwOa/Vl80Q4wsppPe9oynerq6ig94HR\n"
+            + "e3mNDw/JggtgJA0X2VmmmXG8vHwIB5EziQrH7QGtLyjqdE+w7CLbbvAskL8Uw1qx\n"
+            + "Aowdpb7J8hrUdIDDCr/mlhT17+UM5yOXHKcixyrscqbjlG/nqwPvR10efo7D0rFR\n"
+            + "6tu5OU2y3N2PhGOysDLgupUXBLlpdByF6AYNV9zvU7ipO7QXzrUfYCb/WyAcjl+X\n"
+            + "Yl38sCVTVFGsB2ql9/fzFCxAB3FUNHDlI2sUbkdDPcjgf65SK0GGcckWfntfq9dj\n"
+            + "pQzEVen8X9dT3UhfuvHd98g3n6ju9gh8NucwHM5jITq9ItTY0whb+okBcQQYAQgA\n"
+            + "JhYhBFc0LDeYKoQ7GcBiK2qvLSa0gQLbBQJb73xXAhsCBQlYWHSAAL8JEGqvLSa0\n"
+            + "gQLbtCAEGQEIAB0WIQQKSpZgG5Yt/OiY5oZDBckuYm60hQUCW+98VwAKCRBDBcku\n"
+            + "Ym60hafuBACSkvwXAYfxvAf7IOK6+Sp3oWkrq6vsjH5K7oupTimR1cVgN1CEAWh8\n"
+            + "2UBCg3zR5Q5BAnvnjeugdQVrAY+sftkaoy8qO5YYHCPtHtQKmXGWM7Q33hU1E7If\n"
+            + "gU06qkFLhIOL3Vwr8jOuOzHv0M3PbLNr7lOCaIJ7uCjPBZuoqRwqjHt2BACuXwA8\n"
+            + "RHbRGAxC65YgoSjGNu/da3q2J26E57KfSFprQ4TzAg33U4Wsqdx2vbbJxy1bfTYx\n"
+            + "b0AYXe/+k23W7EIdBtMGOYwrX01oTfSIKbM+gDrHswSYXdOyziatLaUBSQfyG656\n"
+            + "lGGZO/aArLZb4dgGeBHBwhXTufFDIYl3X94zfQ==\n"
+            + "=RbPm\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----");
+  }
 }
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 bbb5b66..f86b35d5 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -2,28 +2,35 @@
     name = "httpd",
     srcs = glob(["**/*.java"]),
     resource_strip_prefix = "resources",
-    resources = ["//resources/com/google/gerrit/httpd"],
+    resources = [
+        "//resources/com/google/gerrit/httpd",
+        "//resources/com/google/gerrit/httpd/raw",
+    ],
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/http",
-        "//java/org/eclipse/jgit:server",
         "//lib:args4j",
         "//lib:gson",
         "//lib:guava",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
         "//lib:jsch",
         "//lib:servlet-api-3_1",
         "//lib:soy",
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 6a19be7..bbe15b5 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.httpd.WebSessionManager.Key;
 import com.google.gerrit.httpd.WebSessionManager.Val;
@@ -96,7 +95,7 @@
   private void authFromCookie(String cookie) {
     key = new Key(cookie);
     val = manager.get(key);
-    String token = request.getHeader(HostPageData.XSRF_HEADER_NAME);
+    String token = request.getHeader(XsrfConstants.XSRF_HEADER_NAME);
     if (val != null && token != null && token.equals(val.getAuth())) {
       okPaths.add(AccessPath.REST_API);
     }
diff --git a/java/com/google/gerrit/httpd/ContainerAuthFilter.java b/java/com/google/gerrit/httpd/ContainerAuthFilter.java
index ac66845..03ed90d 100644
--- a/java/com/google/gerrit/httpd/ContainerAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ContainerAuthFilter.java
@@ -17,9 +17,12 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static com.google.gerrit.extensions.api.lfs.LfsDefinitions.CONTENTTYPE_VND_GIT_LFS_JSON;
+import static com.google.gerrit.httpd.GerritAuthModule.NOT_AUTHORIZED_LFS_URL_REGEX;
 import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
 import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
 import com.google.gerrit.server.AccessPath;
@@ -32,6 +35,7 @@
 import java.io.IOException;
 import java.util.Locale;
 import java.util.Optional;
+import java.util.regex.Pattern;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -55,6 +59,9 @@
  */
 @Singleton
 class ContainerAuthFilter implements Filter {
+  private static final String LFS_AUTH_PREFIX = "Ssh: ";
+  private static final Pattern LFS_ENDPOINT = Pattern.compile(NOT_AUTHORIZED_LFS_URL_REGEX);
+
   private final DynamicItem<WebSession> session;
   private final AccountCache accountCache;
   private final Config config;
@@ -93,6 +100,11 @@
   private boolean verify(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     String username = RemoteUserUtil.getRemoteUser(req, loginHttpHeader);
     if (username == null) {
+      if (isLfsOverSshRequest(req)) {
+        // LFS-over-SSH auth request cannot be authorized by container
+        // therefore let it go through the filter
+        return true;
+      }
       rsp.sendError(SC_FORBIDDEN);
       return false;
     }
@@ -106,9 +118,17 @@
       return false;
     }
     WebSession ws = session.get();
-    ws.setUserAccountId(who.get().getAccount().getId());
+    ws.setUserAccountId(who.get().getAccount().id());
     ws.setAccessPathOk(AccessPath.GIT, true);
     ws.setAccessPathOk(AccessPath.REST_API, true);
     return true;
   }
+
+  private static boolean isLfsOverSshRequest(HttpServletRequest req) {
+    String hdr = req.getHeader(AUTHORIZATION);
+    return CONTENTTYPE_VND_GIT_LFS_JSON.equals(req.getContentType())
+        && !Strings.isNullOrEmpty(hdr)
+        && hdr.startsWith(LFS_AUTH_PREFIX)
+        && LFS_ENDPOINT.matcher(req.getRequestURI()).matches();
+  }
 }
diff --git a/java/com/google/gerrit/httpd/DirectChangeByCommit.java b/java/com/google/gerrit/httpd/DirectChangeByCommit.java
index 152a83d..f86c240 100644
--- a/java/com/google/gerrit/httpd/DirectChangeByCommit.java
+++ b/java/com/google/gerrit/httpd/DirectChangeByCommit.java
@@ -47,7 +47,7 @@
       // If exactly one change matches, link to that change.
       // TODO Link to a specific patch set, if one matched.
       ChangeInfo ci = results.iterator().next();
-      token = PageLinks.toChange(new Project.NameKey(ci.project), new Change.Id(ci._number));
+      token = PageLinks.toChange(Project.nameKey(ci.project), Change.id(ci._number));
     } else {
       // Otherwise, link to the query page.
       token = PageLinks.toChangeQuery(query);
diff --git a/java/com/google/gerrit/httpd/GerritAuthModule.java b/java/com/google/gerrit/httpd/GerritAuthModule.java
new file mode 100644
index 0000000..253c220
--- /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 {
+  static final String NOT_AUTHORIZED_LFS_URL_REGEX = "^(?:(?!/a/))" + LFS_URL_WO_AUTH_REGEX;
+  private final AuthConfig authConfig;
+
+  @Inject
+  GerritAuthModule(AuthConfig authConfig) {
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  protected void configureServlets() {
+    Class<? extends Filter> authFilter = retreiveAuthFilterFromConfig(authConfig);
+
+    filterRegex(NOT_AUTHORIZED_LFS_URL_REGEX).through(authFilter);
+    filter("/a/*").through(authFilter);
+  }
+
+  static Class<? extends Filter> retreiveAuthFilterFromConfig(AuthConfig authConfig) {
+    Class<? extends Filter> authFilter;
+    if (authConfig.isTrustContainerAuth()) {
+      authFilter = ContainerAuthFilter.class;
+    } else {
+      authFilter =
+          authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.OAUTH
+              ? ProjectOAuthFilter.class
+              : ProjectBasicAuthFilter.class;
+    }
+    return authFilter;
+  }
+}
diff --git a/java/com/google/gerrit/httpd/GitOverHttpModule.java b/java/com/google/gerrit/httpd/GitOverHttpModule.java
index 3f3737d..8400d60 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpModule.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpModule.java
@@ -14,20 +14,16 @@
 
 package com.google.gerrit.httpd;
 
-import static com.google.gerrit.extensions.api.lfs.LfsDefinitions.LFS_URL_WO_AUTH_REGEX;
+import static com.google.gerrit.httpd.GitOverHttpServlet.URL_REGEX;
 
-import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.inject.Inject;
 import com.google.inject.servlet.ServletModule;
-import javax.servlet.Filter;
 
 /** Configures Git access over HTTP with authentication. */
 public class GitOverHttpModule extends ServletModule {
-  private static final String NOT_AUTHORIZED_LFS_URL_REGEX = "^(?:(?!/a/))" + LFS_URL_WO_AUTH_REGEX;
-
   private final AuthConfig authConfig;
   private final DownloadConfig downloadConfig;
 
@@ -39,28 +35,10 @@
 
   @Override
   protected void configureServlets() {
-    Class<? extends Filter> authFilter;
-    if (authConfig.isTrustContainerAuth()) {
-      authFilter = ContainerAuthFilter.class;
-    } else {
-      authFilter =
-          authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.OAUTH
-              ? ProjectOAuthFilter.class
-              : ProjectBasicAuthFilter.class;
+    if (downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.ANON_HTTP)
+        || downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.HTTP)) {
+      filterRegex(URL_REGEX).through(GerritAuthModule.retreiveAuthFilterFromConfig(authConfig));
+      serveRegex(URL_REGEX).with(GitOverHttpServlet.class);
     }
-
-    if (isHttpEnabled()) {
-      String git = GitOverHttpServlet.URL_REGEX;
-      filterRegex(git).through(authFilter);
-      serveRegex(git).with(GitOverHttpServlet.class);
-    }
-
-    filterRegex(NOT_AUTHORIZED_LFS_URL_REGEX).through(authFilter);
-    filter("/a/*").through(authFilter);
-  }
-
-  private boolean isHttpEnabled() {
-    return downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.ANON_HTTP)
-        || downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.HTTP);
   }
 }
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index c1a75bb..d66e8ac 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.httpd;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -24,6 +26,9 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.audit.HttpAuditEvent;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -31,12 +36,16 @@
 import com.google.gerrit.server.git.UploadPackInitializer;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -47,7 +56,9 @@
 import java.time.Duration;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -56,6 +67,7 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.http.server.GitServlet;
 import org.eclipse.jgit.http.server.GitSmartHttpTools;
@@ -122,6 +134,74 @@
                   .expireAfterWrite(Duration.ofMinutes(10));
             }
           });
+
+      // Don't bind Metrics, which is bound in a parent injector in tests.
+    }
+  }
+
+  @VisibleForTesting
+  @Singleton
+  public static class Metrics {
+    // Recording requests separately in this class is only necessary because of a bug in the
+    // implementation of the generic RequestMetricsFilter; see
+    // https://gerrit-review.googlesource.com/c/gerrit/+/211692
+    private final AtomicLong requestsStarted = new AtomicLong();
+
+    void requestStarted() {
+      requestsStarted.incrementAndGet();
+    }
+
+    public long getRequestsStarted() {
+      return requestsStarted.get();
+    }
+  }
+
+  static class HttpServletResponseWithStatusWrapper extends HttpServletResponseWrapper {
+    private int responseStatus;
+
+    HttpServletResponseWithStatusWrapper(HttpServletResponse response) {
+      super(response);
+      /* Even if we could read the status from response, we assume that it is all
+       * fine because we entered the filter without any prior issues.
+       * When Google will have upgraded to Servlet 3.0, we could actually
+       * call response.getStatus() and the code will be clearer.
+       */
+      responseStatus = HttpServletResponse.SC_OK;
+    }
+
+    @Override
+    public void setStatus(int sc) {
+      responseStatus = sc;
+      super.setStatus(sc);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public void setStatus(int sc, String sm) {
+      responseStatus = sc;
+      super.setStatus(sc, sm);
+    }
+
+    @Override
+    public void sendError(int sc) throws IOException {
+      this.responseStatus = sc;
+      super.sendError(sc);
+    }
+
+    @Override
+    public void sendError(int sc, String msg) throws IOException {
+      this.responseStatus = sc;
+      super.sendError(sc, msg);
+    }
+
+    @Override
+    public void sendRedirect(String location) throws IOException {
+      this.responseStatus = HttpServletResponse.SC_MOVED_TEMPORARILY;
+      super.sendRedirect(location);
+    }
+
+    public int getResponseStatus() {
+      return responseStatus;
     }
   }
 
@@ -142,6 +222,26 @@
     addReceivePackFilter(receiveFilter);
   }
 
+  private static String extractWhat(HttpServletRequest request) {
+    StringBuilder commandName = new StringBuilder(request.getRequestURL());
+    if (request.getQueryString() != null) {
+      commandName.append("?").append(request.getQueryString());
+    }
+    return commandName.toString();
+  }
+
+  private static ListMultimap<String, String> extractParameters(HttpServletRequest request) {
+    if (request.getQueryString() == null) {
+      return ImmutableListMultimap.of();
+    }
+    // Explicit cast is required to compile under Servlet API 2.5, where the return type is raw Map.
+    @SuppressWarnings("cast")
+    Map<String, String[]> parameterMap = (Map<String, String[]>) request.getParameterMap();
+    ImmutableListMultimap.Builder<String, String> b = ImmutableListMultimap.builder();
+    parameterMap.forEach(b::putAll);
+    return b.build();
+  }
+
   static class Resolver implements RepositoryResolver<HttpServletRequest> {
     private final GitRepositoryManager manager;
     private final PermissionBackend permissionBackend;
@@ -182,7 +282,7 @@
       user.setAccessPath(AccessPath.GIT);
 
       try {
-        Project.NameKey nameKey = new Project.NameKey(projectName);
+        Project.NameKey nameKey = Project.nameKey(projectName);
         ProjectState state = projectCache.checkedGet(nameKey);
         if (state == null || !state.statePermitsRead()) {
           throw new RepositoryNotFoundException(nameKey.get());
@@ -209,14 +309,14 @@
     private final TransferConfig config;
     private final DynamicSet<PreUploadHook> preUploadHooks;
     private final DynamicSet<PostUploadHook> postUploadHooks;
-    private final DynamicSet<UploadPackInitializer> uploadPackInitializers;
+    private final PluginSetContext<UploadPackInitializer> uploadPackInitializers;
 
     @Inject
     UploadFactory(
         TransferConfig tc,
         DynamicSet<PreUploadHook> preUploadHooks,
         DynamicSet<PostUploadHook> postUploadHooks,
-        DynamicSet<UploadPackInitializer> uploadPackInitializers) {
+        PluginSetContext<UploadPackInitializer> uploadPackInitializers) {
       this.config = tc;
       this.preUploadHooks = preUploadHooks;
       this.postUploadHooks = postUploadHooks;
@@ -231,9 +331,7 @@
       up.setPreUploadHook(PreUploadHookChain.newChain(Lists.newArrayList(preUploadHooks)));
       up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
       ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
-      for (UploadPackInitializer initializer : uploadPackInitializers) {
-        initializer.init(state.getNameKey(), up);
-      }
+      uploadPackInitializers.runEach(initializer -> initializer.init(state.getNameKey(), up));
       return up;
     }
   }
@@ -241,43 +339,86 @@
   static class UploadFilter implements Filter {
     private final UploadValidators.Factory uploadValidatorsFactory;
     private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> userProvider;
+    private final GroupAuditService groupAuditService;
+    private final Metrics metrics;
+    private final PluginSetContext<RequestListener> requestListeners;
 
     @Inject
     UploadFilter(
-        UploadValidators.Factory uploadValidatorsFactory, PermissionBackend permissionBackend) {
+        UploadValidators.Factory uploadValidatorsFactory,
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> userProvider,
+        GroupAuditService groupAuditService,
+        Metrics metrics,
+        PluginSetContext<RequestListener> requestListeners) {
       this.uploadValidatorsFactory = uploadValidatorsFactory;
       this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
+      this.groupAuditService = groupAuditService;
+      this.metrics = metrics;
+      this.requestListeners = requestListeners;
     }
 
     @Override
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain next)
         throws IOException, ServletException {
+      metrics.requestStarted();
       // The Resolver above already checked READ access for us.
       Repository repo = ServletUtils.getRepository(request);
       ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
       UploadPack up = (UploadPack) request.getAttribute(ServletUtils.ATTRIBUTE_HANDLER);
       PermissionBackend.ForProject perm =
           permissionBackend.currentUser().project(state.getNameKey());
-      try {
-        perm.check(ProjectPermission.RUN_UPLOAD_PACK);
-      } catch (AuthException e) {
-        GitSmartHttpTools.sendError(
-            (HttpServletRequest) request,
-            (HttpServletResponse) response,
-            HttpServletResponse.SC_FORBIDDEN,
-            "upload-pack not permitted on this server");
-        return;
-      } catch (PermissionBackendException e) {
-        throw new ServletException(e);
+      HttpServletResponseWithStatusWrapper responseWrapper =
+          new HttpServletResponseWithStatusWrapper((HttpServletResponse) response);
+      HttpServletRequest httpRequest = (HttpServletRequest) request;
+      String sessionId = httpRequest.getSession().getId();
+
+      try (TraceContext traceContext = TraceContext.open()) {
+        RequestInfo requestInfo =
+            RequestInfo.builder(
+                    RequestInfo.RequestType.GIT_UPLOAD, userProvider.get(), traceContext)
+                .project(state.getNameKey())
+                .build();
+        requestListeners.runEach(l -> l.onRequest(requestInfo));
+
+        try {
+          perm.check(ProjectPermission.RUN_UPLOAD_PACK);
+        } catch (AuthException e) {
+          GitSmartHttpTools.sendError(
+              (HttpServletRequest) request,
+              responseWrapper,
+              HttpServletResponse.SC_FORBIDDEN,
+              "upload-pack not permitted on this server");
+          return;
+        } catch (PermissionBackendException e) {
+          responseWrapper.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+          throw new ServletException(e);
+        }
+
+        // We use getRemoteHost() here instead of getRemoteAddr() because REMOTE_ADDR
+        // may have been overridden by a proxy server -- we'll try to avoid this.
+        UploadValidators uploadValidators =
+            uploadValidatorsFactory.create(state.getProject(), repo, request.getRemoteHost());
+        up.setPreUploadHook(
+            PreUploadHookChain.newChain(
+                Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
+        up.setAdvertiseRefsHook(new DefaultAdvertiseRefsHook(perm, RefFilterOptions.defaults()));
+        next.doFilter(httpRequest, responseWrapper);
+      } finally {
+        groupAuditService.dispatch(
+            new HttpAuditEvent(
+                sessionId,
+                userProvider.get(),
+                extractWhat(httpRequest),
+                TimeUtil.nowMs(),
+                extractParameters(httpRequest),
+                httpRequest.getMethod(),
+                httpRequest,
+                responseWrapper.getResponseStatus(),
+                responseWrapper));
       }
-      // We use getRemoteHost() here instead of getRemoteAddr() because REMOTE_ADDR
-      // may have been overridden by a proxy server -- we'll try to avoid this.
-      UploadValidators uploadValidators =
-          uploadValidatorsFactory.create(state.getProject(), repo, request.getRemoteHost());
-      up.setPreUploadHook(
-          PreUploadHookChain.newChain(Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
-      up.setAdvertiseRefsHook(new DefaultAdvertiseRefsHook(perm, RefFilterOptions.defaults()));
-      next.doFilter(request, response);
     }
 
     @Override
@@ -308,8 +449,7 @@
       }
 
       AsyncReceiveCommits arc =
-          factory.create(
-              state, userProvider.get().asIdentifiedUser(), db, null, ImmutableSetMultimap.of());
+          factory.create(state, userProvider.get().asIdentifiedUser(), db, null);
       ReceivePack rp = arc.getReceivePack();
       req.setAttribute(ATT_ARC, arc);
       return rp;
@@ -328,61 +468,87 @@
     private final Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache;
     private final PermissionBackend permissionBackend;
     private final Provider<CurrentUser> userProvider;
+    private final GroupAuditService groupAuditService;
+    private final Metrics metrics;
 
     @Inject
     ReceiveFilter(
         @Named(ID_CACHE) Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache,
         PermissionBackend permissionBackend,
-        Provider<CurrentUser> userProvider) {
+        Provider<CurrentUser> userProvider,
+        GroupAuditService groupAuditService,
+        Metrics metrics) {
       this.cache = cache;
       this.permissionBackend = permissionBackend;
       this.userProvider = userProvider;
+      this.groupAuditService = groupAuditService;
+      this.metrics = metrics;
     }
 
     @Override
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
         throws IOException, ServletException {
+      metrics.requestStarted();
       boolean isGet = "GET".equalsIgnoreCase(((HttpServletRequest) request).getMethod());
 
       AsyncReceiveCommits arc = (AsyncReceiveCommits) request.getAttribute(ATT_ARC);
+
+      // Send refs down the wire.
       ReceivePack rp = arc.getReceivePack();
       rp.getAdvertiseRefsHook().advertiseRefs(rp);
-      ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
 
-      Capable s;
+      ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
+      HttpServletResponseWithStatusWrapper responseWrapper =
+          new HttpServletResponseWithStatusWrapper((HttpServletResponse) response);
+      HttpServletRequest httpRequest = (HttpServletRequest) request;
+      Capable canUpload;
       try {
-        permissionBackend
-            .currentUser()
-            .project(state.getNameKey())
-            .check(ProjectPermission.RUN_RECEIVE_PACK);
-        s = arc.canUpload();
-      } catch (AuthException e) {
-        GitSmartHttpTools.sendError(
-            (HttpServletRequest) request,
-            (HttpServletResponse) response,
-            HttpServletResponse.SC_FORBIDDEN,
-            "receive-pack not permitted on this server");
-        return;
-      } catch (PermissionBackendException e) {
-        throw new RuntimeException(e);
+        try {
+          permissionBackend
+              .currentUser()
+              .project(state.getNameKey())
+              .check(ProjectPermission.RUN_RECEIVE_PACK);
+          canUpload = arc.canUpload();
+        } catch (AuthException e) {
+          GitSmartHttpTools.sendError(
+              httpRequest,
+              responseWrapper,
+              HttpServletResponse.SC_FORBIDDEN,
+              "receive-pack not permitted on this server");
+          return;
+        } catch (PermissionBackendException e) {
+          throw new RuntimeException(e);
+        }
+      } finally {
+        groupAuditService.dispatch(
+            new HttpAuditEvent(
+                httpRequest.getSession().getId(),
+                userProvider.get(),
+                extractWhat(httpRequest),
+                TimeUtil.nowMs(),
+                extractParameters(httpRequest),
+                httpRequest.getMethod(),
+                httpRequest,
+                responseWrapper.getResponseStatus(),
+                responseWrapper));
       }
 
-      if (s != Capable.OK) {
+      if (canUpload != Capable.OK) {
         GitSmartHttpTools.sendError(
-            (HttpServletRequest) request,
-            (HttpServletResponse) response,
+            httpRequest,
+            responseWrapper,
             HttpServletResponse.SC_FORBIDDEN,
-            "\n" + s.getMessage());
+            "\n" + canUpload.getMessage());
         return;
       }
 
       if (!rp.isCheckReferencedObjectsAreReachable()) {
-        chain.doFilter(request, response);
+        chain.doFilter(request, responseWrapper);
         return;
       }
 
       if (!(userProvider.get().isIdentifiedUser())) {
-        chain.doFilter(request, response);
+        chain.doFilter(request, responseWrapper);
         return;
       }
 
@@ -399,7 +565,7 @@
         }
       }
 
-      chain.doFilter(request, response);
+      chain.doFilter(request, responseWrapper);
 
       if (isGet) {
         cache.put(cacheKey, Collections.unmodifiableSet(new HashSet<>(rp.getAdvertisedObjects())));
diff --git a/java/com/google/gerrit/httpd/GwtCacheControlFilter.java b/java/com/google/gerrit/httpd/GwtCacheControlFilter.java
deleted file mode 100644
index 5ac3d2f..0000000
--- a/java/com/google/gerrit/httpd/GwtCacheControlFilter.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 com.google.gerrit.util.http.CacheHeaders;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-/**
- * Forces GWT resources to cache for a very long time.
- *
- * <p>GWT compiled JavaScript and ImageBundles can be cached indefinitely by a browser and/or an
- * edge proxy, as they never contain user-specific data and are named by a unique checksum. If their
- * content is ever modified then the URL changes, so user agents would request a different resource.
- * We force these resources to have very long expiration times.
- *
- * <p>To use, add the following block to your {@code web.xml}:
- *
- * <pre>
- * &lt;filter&gt;
- *     &lt;filter-name&gt;CacheControl&lt;/filter-name&gt;
- *     &lt;filter-class&gt;com.google.gwtexpui.server.CacheControlFilter&lt;/filter-class&gt;
- *   &lt;/filter&gt;
- *   &lt;filter-mapping&gt;
- *     &lt;filter-name&gt;CacheControl&lt;/filter-name&gt;
- *     &lt;url-pattern&gt;/*&lt;/url-pattern&gt;
- *   &lt;/filter-mapping&gt;
- * </pre>
- */
-@Singleton
-class GwtCacheControlFilter implements Filter {
-  @Override
-  public void init(FilterConfig config) {}
-
-  @Override
-  public void destroy() {}
-
-  @Override
-  public void doFilter(final ServletRequest sreq, ServletResponse srsp, FilterChain chain)
-      throws IOException, ServletException {
-    final HttpServletRequest req = (HttpServletRequest) sreq;
-    final HttpServletResponse rsp = (HttpServletResponse) srsp;
-    final String pathInfo = pathInfo(req);
-
-    if (cacheForever(pathInfo, req)) {
-      CacheHeaders.setCacheable(req, rsp, 365, TimeUnit.DAYS);
-    } else if (nocache(pathInfo)) {
-      CacheHeaders.setNotCacheable(rsp);
-    }
-
-    chain.doFilter(req, rsp);
-  }
-
-  private static boolean cacheForever(String pathInfo, HttpServletRequest req) {
-    if (pathInfo.endsWith(".cache.html")
-        || pathInfo.endsWith(".cache.gif")
-        || pathInfo.endsWith(".cache.png")
-        || pathInfo.endsWith(".cache.css")
-        || pathInfo.endsWith(".cache.jar")
-        || pathInfo.endsWith(".cache.swf")
-        || pathInfo.endsWith(".cache.txt")
-        || pathInfo.endsWith(".cache.js")) {
-      return true;
-    } else if (pathInfo.endsWith(".nocache.js")) {
-      final String v = req.getParameter("content");
-      return v != null && v.length() > 20;
-    }
-    return false;
-  }
-
-  private static boolean nocache(String pathInfo) {
-    if (pathInfo.endsWith(".nocache.js")) {
-      return true;
-    }
-    return false;
-  }
-
-  private static String pathInfo(HttpServletRequest req) {
-    final String uri = req.getRequestURI();
-    final String ctx = req.getContextPath();
-    return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
-  }
-}
diff --git a/java/com/google/gerrit/httpd/HtmlDomUtil.java b/java/com/google/gerrit/httpd/HtmlDomUtil.java
index 9acc754..25ae71c 100644
--- a/java/com/google/gerrit/httpd/HtmlDomUtil.java
+++ b/java/com/google/gerrit/httpd/HtmlDomUtil.java
@@ -84,9 +84,7 @@
       serializer.transform(domSource, streamResult);
       return out.toString();
     } catch (TransformerException e) {
-      IOException r = new IOException("Error transforming page");
-      r.initCause(e);
-      throw r;
+      throw new IOException("Error transforming page", e);
     }
   }
 
diff --git a/java/com/google/gerrit/httpd/HttpLogoutServlet.java b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
index 39a39c6..1eaaba3 100644
--- a/java/com/google/gerrit/httpd/HttpLogoutServlet.java
+++ b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
@@ -16,13 +16,13 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.AuditEvent;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.audit.AuditEvent;
-import com.google.gerrit.server.audit.AuditService;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -38,14 +38,14 @@
   private final DynamicItem<WebSession> webSession;
   private final Provider<String> urlProvider;
   private final String logoutUrl;
-  private final AuditService audit;
+  private final GroupAuditService audit;
 
   @Inject
   protected HttpLogoutServlet(
       AuthConfig authConfig,
       DynamicItem<WebSession> webSession,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      AuditService audit) {
+      GroupAuditService audit) {
     this.webSession = webSession;
     this.urlProvider = urlProvider;
     this.logoutUrl = authConfig.getLogoutURL();
diff --git a/java/com/google/gerrit/httpd/HttpRequestContext.java b/java/com/google/gerrit/httpd/HttpRequestContext.java
index 6cc7a17..c30c74c 100644
--- a/java/com/google/gerrit/httpd/HttpRequestContext.java
+++ b/java/com/google/gerrit/httpd/HttpRequestContext.java
@@ -15,30 +15,20 @@
 package com.google.gerrit.httpd;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 class HttpRequestContext implements RequestContext {
   private final DynamicItem<WebSession> session;
-  private final RequestScopedReviewDbProvider provider;
 
   @Inject
-  HttpRequestContext(DynamicItem<WebSession> session, RequestScopedReviewDbProvider provider) {
+  HttpRequestContext(DynamicItem<WebSession> session) {
     this.session = session;
-    this.provider = provider;
   }
 
   @Override
   public CurrentUser getUser() {
     return session.get().getUser();
   }
-
-  @Override
-  public Provider<ReviewDb> getReviewDbProvider() {
-    return provider;
-  }
 }
diff --git a/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java b/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
index 397d093..4ae7e5e 100644
--- a/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
+++ b/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
@@ -67,7 +67,7 @@
     headers.put(name, value);
   }
 
-  @SuppressWarnings("all")
+  @SuppressWarnings({"all", "MissingOverride"})
   // @Override is omitted for backwards compatibility with servlet-api 2.5
   // TODO: Remove @SuppressWarnings and add @Override when Google upgrades
   //       to servlet-api 3.1
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
new file mode 100644
index 0000000..e1b983c
--- /dev/null
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Redirects {@code domain.tld/123} to {@code domain.tld/c/project/+/123}. */
+@Singleton
+public class NumericChangeIdRedirectServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final ChangesCollection changesCollection;
+
+  @Inject
+  NumericChangeIdRedirectServlet(ChangesCollection changesCollection) {
+    this.changesCollection = changesCollection;
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    String idString = req.getPathInfo();
+    if (idString.endsWith("/")) {
+      idString = idString.substring(0, idString.length() - 1);
+    }
+    Change.Id id;
+    try {
+      id = Change.Id.parse(idString);
+    } catch (IllegalArgumentException e) {
+      rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+      return;
+    }
+
+    ChangeResource changeResource;
+    try {
+      changeResource = changesCollection.parse(id);
+    } catch (ResourceConflictException | ResourceNotFoundException e) {
+      rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+      return;
+    } catch (PermissionBackendException | RuntimeException e) {
+      throw new IOException("Unable to lookup change " + id.get(), e);
+    }
+    String path =
+        PageLinks.toChange(changeResource.getProject(), changeResource.getChange().getId());
+    UrlModule.toGerrit(path, req, rsp);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index 818827c..e3ab70d 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -177,7 +177,7 @@
   }
 
   private boolean succeedAuthentication(AccountState who) {
-    setUserIdentified(who.getAccount().getId());
+    setUserIdentified(who.getAccount().id());
     return true;
   }
 
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index 589448e..3bb728f 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.account.AccountCache;
@@ -118,7 +118,7 @@
   }
 
   private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
-    AuthInfo authInfo = null;
+    AuthInfo authInfo;
 
     // first check if there is a BASIC authentication header
     String hdr = req.getHeader(AUTHORIZATION);
@@ -163,8 +163,8 @@
 
     Account account = who.get().getAccount();
     AuthRequest authRequest = AuthRequest.forExternalUser(authInfo.username);
-    authRequest.setEmailAddress(account.getPreferredEmail());
-    authRequest.setDisplayName(account.getFullName());
+    authRequest.setEmailAddress(account.preferredEmail());
+    authRequest.setDisplayName(account.fullName());
     authRequest.setPassword(authInfo.tokenOrSecret);
     authRequest.setAuthPlugin(authInfo.pluginName);
     authRequest.setAuthProvider(authInfo.exportName);
@@ -191,7 +191,7 @@
    */
   private void pickOnlyProvider() throws ServletException {
     try {
-      Entry<OAuthLoginProvider> loginProvider = Iterables.getOnlyElement(loginProviders);
+      Extension<OAuthLoginProvider> loginProvider = Iterables.getOnlyElement(loginProviders);
       defaultAuthPlugin = loginProvider.getPluginName();
       defaultAuthProvider = loginProvider.getExportName();
     } catch (NoSuchElementException e) {
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/RequestMetrics.java b/java/com/google/gerrit/httpd/RequestMetrics.java
index cab4a92..e0f9b6a 100644
--- a/java/com/google/gerrit/httpd/RequestMetrics.java
+++ b/java/com/google/gerrit/httpd/RequestMetrics.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -28,15 +29,20 @@
 
   @Inject
   public RequestMetrics(MetricMaker metricMaker) {
+    Field<Integer> statusCodeField =
+        Field.ofInteger("status", Metadata.Builder::httpStatus)
+            .description("HTTP status code")
+            .build();
+
     errors =
         metricMaker.newCounter(
             "http/server/error_count",
             new Description("Rate of REST API error responses").setRate().setUnit("errors"),
-            Field.ofInteger("status", "HTTP status code"));
+            statusCodeField);
     successes =
         metricMaker.newCounter(
             "http/server/success_count",
             new Description("Rate of REST API success responses").setRate().setUnit("successes"),
-            Field.ofInteger("status", "HTTP status code"));
+            statusCodeField);
   }
 }
diff --git a/java/com/google/gerrit/httpd/RunAsFilter.java b/java/com/google/gerrit/httpd/RunAsFilter.java
index f3bf5af..0055fc7 100644
--- a/java/com/google/gerrit/httpd/RunAsFilter.java
+++ b/java/com/google/gerrit/httpd/RunAsFilter.java
@@ -21,6 +21,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver;
@@ -28,7 +29,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.ServletModule;
@@ -103,19 +103,18 @@
         return;
       }
 
-      Account target;
+      Account.Id target;
       try {
-        target = accountResolver.find(runas);
-      } catch (OrmException | IOException | ConfigInvalidException e) {
+        target = accountResolver.resolve(runas).asUnique().getAccount().id();
+      } catch (UnprocessableEntityException e) {
+        replyError(req, res, SC_FORBIDDEN, "no account matches " + RUN_AS, null);
+        return;
+      } catch (IOException | ConfigInvalidException | RuntimeException e) {
         logger.atWarning().withCause(e).log("cannot resolve account for %s", RUN_AS);
         replyError(req, res, SC_INTERNAL_SERVER_ERROR, "cannot resolve " + RUN_AS, e);
         return;
       }
-      if (target == null) {
-        replyError(req, res, SC_FORBIDDEN, "no account matches " + RUN_AS, null);
-        return;
-      }
-      session.get().setUserAccountId(target.getId());
+      session.get().setUserAccountId(target);
     }
 
     chain.doFilter(req, res);
diff --git a/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java b/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
index 4878006..107a07e 100644
--- a/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
+++ b/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.httpd;
 
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -34,8 +35,8 @@
 import javax.servlet.http.HttpServletResponse;
 
 public class UniversalWebLoginFilter implements Filter {
-  private final DynamicItem<WebSession> session;
-  private final DynamicSet<WebLoginListener> webLoginListeners;
+  private final PluginItemContext<WebSession> session;
+  private final PluginSetContext<WebLoginListener> webLoginListeners;
   private final Provider<CurrentUser> userProvider;
 
   public static ServletModule module() {
@@ -52,8 +53,8 @@
 
   @Inject
   public UniversalWebLoginFilter(
-      DynamicItem<WebSession> session,
-      DynamicSet<WebLoginListener> webLoginListeners,
+      PluginItemContext<WebSession> session,
+      PluginSetContext<WebLoginListener> webLoginListeners,
       Provider<CurrentUser> userProvider) {
     this.session = session;
     this.webLoginListeners = webLoginListeners;
@@ -75,20 +76,18 @@
     Optional<IdentifiedUser> loggedInUserAfter = loggedInUser();
 
     if (!loggedInUserBefore.isPresent() && loggedInUserAfter.isPresent()) {
-      for (WebLoginListener loginListener : webLoginListeners) {
-        loginListener.onLogin(loggedInUserAfter.get(), httpRequest, wrappedResponse);
-      }
+      webLoginListeners.runEach(
+          l -> l.onLogin(loggedInUserAfter.get(), httpRequest, wrappedResponse));
     } else if (loggedInUserBefore.isPresent() && !loggedInUserAfter.isPresent()) {
-      for (WebLoginListener loginListener : webLoginListeners) {
-        loginListener.onLogout(loggedInUserBefore.get(), httpRequest, wrappedResponse);
-      }
+      webLoginListeners.runEach(
+          l -> l.onLogout(loggedInUserBefore.get(), httpRequest, wrappedResponse));
     }
 
     wrappedResponse.play();
   }
 
   private Optional<IdentifiedUser> loggedInUser() {
-    return session.get().isSignedIn()
+    return session.call(WebSession::isSignedIn)
         ? Optional.of(userProvider.get().asIdentifiedUser())
         : Optional.empty();
   }
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index c2f4483..3340b14 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -21,8 +21,6 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.httpd.raw.AuthorizationCheckServlet;
 import com.google.gerrit.httpd.raw.CatServlet;
-import com.google.gerrit.httpd.raw.HostPageServlet;
-import com.google.gerrit.httpd.raw.LegacyGerritServlet;
 import com.google.gerrit.httpd.raw.SshInfoServlet;
 import com.google.gerrit.httpd.raw.ToolServlet;
 import com.google.gerrit.httpd.restapi.AccessRestApiServlet;
@@ -34,9 +32,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.config.GerritOptions;
 import com.google.inject.Key;
-import com.google.inject.Provider;
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.servlet.ServletModule;
 import java.io.IOException;
@@ -46,27 +42,14 @@
 import org.eclipse.jgit.lib.Constants;
 
 class UrlModule extends ServletModule {
-  private GerritOptions options;
   private AuthConfig authConfig;
 
-  UrlModule(GerritOptions options, AuthConfig authConfig) {
-    this.options = options;
+  UrlModule(AuthConfig authConfig) {
     this.authConfig = authConfig;
   }
 
   @Override
   protected void configureServlets() {
-    filter("/*").through(GwtCacheControlFilter.class);
-
-    if (options.enableGwtUi()) {
-      filter("/").through(XsrfCookieFilter.class);
-      filter("/accounts/self/detail").through(XsrfCookieFilter.class);
-      serve("/").with(HostPageServlet.class);
-      serve("/Gerrit").with(LegacyGerritServlet.class);
-      serve("/Gerrit/*").with(legacyGerritScreen());
-      // Forward PolyGerrit URLs to their respective GWT equivalents.
-      serveRegex("^/(c|q|x|admin|dashboard|settings)/(.*)").with(gerritUrl());
-    }
     serve("/cat/*").with(CatServlet.class);
 
     if (authConfig.getAuthType() != AuthType.OAUTH && authConfig.getAuthType() != AuthType.OPENID) {
@@ -88,7 +71,7 @@
     serveRegex("^/settings/?$").with(screen(PageLinks.SETTINGS));
     serveRegex("^/register$").with(registerScreen(false));
     serveRegex("^/register/(.+)$").with(registerScreen(true));
-    serveRegex("^/([1-9][0-9]*)/?$").with(directChangeById());
+    serveRegex("^/([1-9][0-9]*)/?$").with(NumericChangeIdRedirectServlet.class);
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
@@ -114,7 +97,9 @@
     serveRegex("^/(?:a/)?groups/(.*)?$").with(GroupsRestApiServlet.class);
     serveRegex("^/(?:a/)?projects/(.*)?$").with(ProjectsRestApiServlet.class);
 
-    filter("/Documentation/").through(QueryDocumentationFilter.class);
+    serveRegex("^/Documentation$").with(redirectDocumentation());
+    serveRegex("^/Documentation/$").with(redirectDocumentation());
+    filter("/Documentation/*").through(QueryDocumentationFilter.class);
   }
 
   private Key<HttpServlet> notFound() {
@@ -129,18 +114,6 @@
         });
   }
 
-  private Key<HttpServlet> gerritUrl() {
-    return key(
-        new HttpServlet() {
-          private static final long serialVersionUID = 1L;
-
-          @Override
-          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-            toGerrit(req.getRequestURI().substring(req.getContextPath().length()), req, rsp);
-          }
-        });
-  }
-
   private Key<HttpServlet> screen(String target) {
     return key(
         new HttpServlet() {
@@ -153,43 +126,6 @@
         });
   }
 
-  private Key<HttpServlet> legacyGerritScreen() {
-    return key(
-        new HttpServlet() {
-          private static final long serialVersionUID = 1L;
-
-          @Override
-          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-            final String token = req.getPathInfo().substring(1);
-            toGerrit(token, req, rsp);
-          }
-        });
-  }
-
-  private Key<HttpServlet> directChangeById() {
-    return key(
-        new HttpServlet() {
-          private static final long serialVersionUID = 1L;
-
-          @Override
-          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-            try {
-              String idString = req.getPathInfo();
-              if (idString.endsWith("/")) {
-                idString = idString.substring(0, idString.length() - 1);
-              }
-              Change.Id id = Change.Id.parse(idString);
-              // User accessed Gerrit with /1234, so we have no project yet.
-              // TODO(hiesel) Replace with a preflight request to obtain project before we deprecate
-              // the numeric change id.
-              toGerrit(PageLinks.toChange(null, id), req, rsp);
-            } catch (IllegalArgumentException err) {
-              rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            }
-          }
-        });
-  }
-
   private Key<HttpServlet> queryProjectNew() {
     return key(
         new HttpServlet() {
@@ -215,7 +151,7 @@
             while (name.endsWith("/")) {
               name = name.substring(0, name.length() - 1);
             }
-            Project.NameKey project = new Project.NameKey(name);
+            Project.NameKey project = Project.nameKey(name);
             toGerrit(
                 PageLinks.toChangeQuery(PageLinks.projectQuery(project, Change.Status.NEW)),
                 req,
@@ -238,15 +174,7 @@
 
   private Key<HttpServlet> key(HttpServlet servlet) {
     final Key<HttpServlet> srv = Key.get(HttpServlet.class, UniqueAnnotations.create());
-    bind(srv)
-        .toProvider(
-            new Provider<HttpServlet>() {
-              @Override
-              public HttpServlet get() {
-                return servlet;
-              }
-            })
-        .in(SINGLETON);
+    bind(srv).toProvider(() -> servlet).in(SINGLETON);
     return srv;
   }
 
@@ -263,6 +191,19 @@
         });
   }
 
+  private Key<HttpServlet> redirectDocumentation() {
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
+
+          @Override
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+            String path = "/Documentation/index.html";
+            toGerrit(path, req, rsp);
+          }
+        });
+  }
+
   static void toGerrit(String target, HttpServletRequest req, HttpServletResponse rsp)
       throws IOException {
     final StringBuilder url = new StringBuilder();
diff --git a/java/com/google/gerrit/httpd/WebModule.java b/java/com/google/gerrit/httpd/WebModule.java
index 538d605..e416075 100644
--- a/java/com/google/gerrit/httpd/WebModule.java
+++ b/java/com/google/gerrit/httpd/WebModule.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.httpd.auth.container.HttpsClientSslCertModule;
 import com.google.gerrit.httpd.auth.ldap.LdapAuthModule;
 import com.google.gerrit.httpd.gitweb.GitwebModule;
-import com.google.gerrit.httpd.rpc.UiRpcModule;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.AuthConfig;
@@ -55,8 +54,7 @@
 
     installAuthModule();
     if (options.enableMasterFeatures()) {
-      install(new UrlModule(options, authConfig));
-      install(new UiRpcModule());
+      install(new UrlModule(authConfig));
     }
     install(new GerritRequestModule());
     install(new GitOverHttpServlet.Module(options.enableMasterFeatures()));
diff --git a/java/com/google/gerrit/httpd/WebSessionManager.java b/java/com/google/gerrit/httpd/WebSessionManager.java
index 457e65f..d7c41bf 100644
--- a/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd;
 
-import static com.google.gerrit.common.TimeUtil.nowMs;
 import static com.google.gerrit.httpd.CacheBasedWebSession.MAX_AGE_MINUTES;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readFixInt64;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readString;
@@ -23,6 +22,7 @@
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeFixInt64;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
+import static com.google.gerrit.server.util.time.TimeUtil.nowMs;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
@@ -215,7 +215,15 @@
       return expiresAt;
     }
 
-    Account.Id getAccountId() {
+    /**
+     * Parse an Account.Id.
+     *
+     * <p>This is public so that plugins that implement a web session, can also implement a way to
+     * clear per user sessions.
+     *
+     * @return account ID.
+     */
+    public Account.Id getAccountId() {
       return accountId;
     }
 
@@ -278,7 +286,7 @@
           case 0:
             break PARSE;
           case 1:
-            accountId = new Account.Id(readVarInt32(in));
+            accountId = Account.id(readVarInt32(in));
             continue;
           case 2:
             refreshCookieAt = readFixInt64(in);
diff --git a/java/com/google/gerrit/httpd/XsrfConstants.java b/java/com/google/gerrit/httpd/XsrfConstants.java
new file mode 100644
index 0000000..0bbfe34
--- /dev/null
+++ b/java/com/google/gerrit/httpd/XsrfConstants.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+/** XSRF Constants. */
+public class XsrfConstants {
+  /**
+   * Name of the cookie in which the XSRF token is sent from the server to the client during host
+   * page bootstrapping.
+   */
+  public static final String XSRF_COOKIE_NAME = "XSRF_TOKEN";
+
+  /**
+   * Name of the HTTP header in which the client must send the XSRF token to the server on each
+   * request.
+   */
+  public static final String XSRF_HEADER_NAME = "X-Gerrit-Auth";
+}
diff --git a/java/com/google/gerrit/httpd/XsrfCookieFilter.java b/java/com/google/gerrit/httpd/XsrfCookieFilter.java
index ff64c84..d15ecac 100644
--- a/java/com/google/gerrit/httpd/XsrfCookieFilter.java
+++ b/java/com/google/gerrit/httpd/XsrfCookieFilter.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Strings.nullToEmpty;
 
-import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AuthConfig;
@@ -59,7 +58,7 @@
   private void setXsrfTokenCookie(
       HttpServletRequest req, HttpServletResponse rsp, WebSession session) {
     String v = session != null ? session.getXGerritAuth() : null;
-    Cookie c = new Cookie(HostPageData.XSRF_COOKIE_NAME, nullToEmpty(v));
+    Cookie c = new Cookie(XsrfConstants.XSRF_COOKIE_NAME, nullToEmpty(v));
     c.setPath("/");
     c.setSecure(authConfig.getCookieSecure() && isSecure(req));
     c.setMaxAge(
diff --git a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index ea01809..3eb4bcc 100644
--- a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.template.SiteHeaderFooter;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -35,8 +34,6 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.util.http.CacheHeaders;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -55,11 +52,11 @@
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 
-@SuppressWarnings("serial")
 @Singleton
 class BecomeAnyAccountLoginServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final DynamicItem<WebSession> webSession;
-  private final SchemaFactory<ReviewDb> schema;
   private final Accounts accounts;
   private final AccountCache accountCache;
   private final AccountManager accountManager;
@@ -69,14 +66,12 @@
   @Inject
   BecomeAnyAccountLoginServlet(
       DynamicItem<WebSession> ws,
-      SchemaFactory<ReviewDb> sf,
       Accounts a,
       AccountCache ac,
       AccountManager am,
       SiteHeaderFooter shf,
       Provider<InternalAccountQuery> qp) {
     webSession = ws;
-    schema = sf;
     accounts = a;
     accountCache = ac;
     accountManager = am;
@@ -91,8 +86,7 @@
   }
 
   @Override
-  protected void doPost(HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException, ServletException {
+  protected void doPost(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     CacheHeaders.setNotCacheable(rsp);
 
     final AuthResult res;
@@ -110,11 +104,7 @@
 
     } else {
       byte[] raw;
-      try {
-        raw = prepareHtmlOutput();
-      } catch (OrmException e) {
-        throw new ServletException(e);
-      }
+      raw = prepareHtmlOutput();
       rsp.setContentType("text/html");
       rsp.setCharacterEncoding(HtmlDomUtil.ENC.name());
       rsp.setContentLength(raw.length);
@@ -150,7 +140,7 @@
     }
   }
 
-  private byte[] prepareHtmlOutput() throws IOException, OrmException {
+  private byte[] prepareHtmlOutput() throws IOException {
     final String pageName = "BecomeAnyAccount.html";
     Document doc = headers.parse(getClass(), pageName);
     if (doc == null) {
@@ -158,37 +148,35 @@
     }
 
     Element userlistElement = HtmlDomUtil.find(doc, "userlist");
-    try (ReviewDb db = schema.open()) {
-      for (Account.Id accountId : accounts.firstNIds(100)) {
-        Optional<AccountState> accountState = accountCache.get(accountId);
-        if (!accountState.isPresent()) {
-          continue;
-        }
-        Account account = accountState.get().getAccount();
-        String displayName;
-        if (accountState.get().getUserName().isPresent()) {
-          displayName = accountState.get().getUserName().get();
-        } else if (account.getFullName() != null && !account.getFullName().isEmpty()) {
-          displayName = account.getFullName();
-        } else if (account.getPreferredEmail() != null) {
-          displayName = account.getPreferredEmail();
-        } else {
-          displayName = accountId.toString();
-        }
-
-        Element linkElement = doc.createElement("a");
-        linkElement.setAttribute("href", "?account_id=" + account.getId().toString());
-        linkElement.setTextContent(displayName);
-        userlistElement.appendChild(linkElement);
-        userlistElement.appendChild(doc.createElement("br"));
+    for (Account.Id accountId : accounts.firstNIds(100)) {
+      Optional<AccountState> accountState = accountCache.get(accountId);
+      if (!accountState.isPresent()) {
+        continue;
       }
+      Account account = accountState.get().getAccount();
+      String displayName;
+      if (accountState.get().getUserName().isPresent()) {
+        displayName = accountState.get().getUserName().get();
+      } else if (account.fullName() != null && !account.fullName().isEmpty()) {
+        displayName = account.fullName();
+      } else if (account.preferredEmail() != null) {
+        displayName = account.preferredEmail();
+      } else {
+        displayName = accountId.toString();
+      }
+
+      Element linkElement = doc.createElement("a");
+      linkElement.setAttribute("href", "?account_id=" + account.id().toString());
+      linkElement.setTextContent(displayName);
+      userlistElement.appendChild(linkElement);
+      userlistElement.appendChild(doc.createElement("br"));
     }
 
     return HtmlDomUtil.toUTF8(doc);
   }
 
   private Optional<AuthResult> auth(Optional<AccountState> account) {
-    return account.map(a -> new AuthResult(a.getAccount().getId(), null, false));
+    return account.map(a -> new AuthResult(a.getAccount().id(), null, false));
   }
 
   private AuthResult auth(Account.Id account) {
@@ -199,33 +187,20 @@
   }
 
   private AuthResult byUserName(String userName) {
-    try {
-      List<AccountState> accountStates =
-          queryProvider.get().byExternalId(SCHEME_USERNAME, userName);
-      if (accountStates.isEmpty()) {
-        getServletContext().log("No accounts with username " + userName + " found");
-        return null;
-      }
-      if (accountStates.size() > 1) {
-        getServletContext().log("Multiple accounts with username " + userName + " found");
-        return null;
-      }
-      return auth(accountStates.get(0).getAccount().getId());
-    } catch (OrmException e) {
-      getServletContext().log("cannot query account index", e);
+    List<AccountState> accountStates = queryProvider.get().byExternalId(SCHEME_USERNAME, userName);
+    if (accountStates.isEmpty()) {
+      getServletContext().log("No accounts with username " + userName + " found");
       return null;
     }
+    if (accountStates.size() > 1) {
+      getServletContext().log("Multiple accounts with username " + userName + " found");
+      return null;
+    }
+    return auth(accountStates.get(0).getAccount().id());
   }
 
   private Optional<AuthResult> byPreferredEmail(String email) {
-    try (ReviewDb db = schema.open()) {
-      Optional<AccountState> match =
-          queryProvider.get().byPreferredEmail(email).stream().findFirst();
-      return auth(match);
-    } catch (OrmException e) {
-      getServletContext().log("cannot query database", e);
-      return Optional.empty();
-    }
+    return auth(queryProvider.get().byPreferredEmail(email).stream().findFirst());
   }
 
   private Optional<AuthResult> byAccountId(String idStr) {
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 0b3c29d..509a9f1 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -25,11 +25,10 @@
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.httpd.raw.HostPageServlet;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.util.http.CacheHeaders;
-import com.google.gwtjsonrpc.server.RPCServletUtils;
+import com.google.gerrit.util.http.RequestUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.FileNotFoundException;
@@ -49,10 +48,10 @@
  * Watches request for the host page and requires login if not yet signed in.
  *
  * <p>If HTTP authentication has been enabled on this server this filter is bound in front of the
- * {@link HostPageServlet} and redirects users who are not yet signed in to visit {@code /login/},
- * so the web container can force login. This redirect is performed with JavaScript, such that any
- * existing anchor token in the URL can be rewritten and preserved through the authentication
- * process of any enterprise single sign-on solutions.
+ * Gerrit and redirects users who are not yet signed in to visit {@code /login/}, so the web
+ * container can force login. This redirect is performed with JavaScript, such that any existing
+ * anchor token in the URL can be rewritten and preserved through the authentication process of any
+ * enterprise single sign-on solutions.
  */
 @Singleton
 class HttpAuthFilter implements Filter {
@@ -98,7 +97,7 @@
       final HttpServletRequest req = (HttpServletRequest) request;
       final HttpServletResponse rsp = (HttpServletResponse) response;
       final byte[] tosend;
-      if (RPCServletUtils.acceptsGzipEncoding(req)) {
+      if (RequestUtil.acceptsGzipEncoding(req)) {
         rsp.setHeader("Content-Encoding", "gzip");
         tosend = signInGzip;
       } else {
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index fd2f628..1b7e477 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.util.http.CacheHeaders;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -128,7 +127,7 @@
         logger.atFine().log(
             "Associating external identity \"%s\" to user \"%s\"", remoteExternalId, user);
         updateRemoteExternalId(arsp, remoteExternalId);
-      } catch (AccountException | OrmException | ConfigInvalidException e) {
+      } catch (AccountException | ConfigInvalidException e) {
         logger.atSevere().withCause(e).log(
             "Unable to associate external identity \"%s\" to user \"%s\"", remoteExternalId, user);
         rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
@@ -152,7 +151,7 @@
   }
 
   private void updateRemoteExternalId(AuthResult arsp, String remoteAuthToken)
-      throws AccountException, OrmException, IOException, ConfigInvalidException {
+      throws AccountException, IOException, ConfigInvalidException {
     accountManager.updateLink(
         arsp.getAccountId(),
         new AuthRequest(ExternalId.Key.create(SCHEME_EXTERNAL, remoteAuthToken)));
diff --git a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
index 116ad6d..a09866e 100644
--- a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
@@ -46,9 +46,10 @@
 import org.w3c.dom.Element;
 
 /** Handles username/password based authentication against the directory. */
-@SuppressWarnings("serial")
 @Singleton
 class LdapLoginServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AccountManager accountManager;
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index 96726ad..dd3e5fc 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -6,13 +6,14 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//lib:gson",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/commons:codec",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
index d25ff60..f468ecb 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
@@ -18,9 +18,9 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.HttpLogoutServlet;
 import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.server.audit.AuditService;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.group.GroupAuditService;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -39,7 +39,7 @@
       AuthConfig authConfig,
       DynamicItem<WebSession> webSession,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      AuditService audit,
+      GroupAuditService audit,
       Provider<OAuthSession> oauthSession) {
     super(authConfig, webSession, urlProvider, audit);
     this.oauthSession = oauthSession;
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index b780fa0..0c8a1a10 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
@@ -188,7 +187,7 @@
       logger.atInfo().log("OAuth2: linking claimed identity to %s", claimedId.get().toString());
       try {
         accountManager.link(claimedId.get(), req);
-      } catch (OrmException | ConfigInvalidException e) {
+      } catch (ConfigInvalidException e) {
         logger.atSevere().log(
             "Cannot link: %s to user identity:\n  Claimed ID: %s is %s",
             user.getExternalId(), claimedId.get(), claimedIdentifier);
@@ -203,7 +202,7 @@
       throws AccountException, IOException {
     try {
       accountManager.link(identifiedUser.get().getAccountId(), areq);
-    } catch (OrmException | ConfigInvalidException e) {
+    } catch (ConfigInvalidException e) {
       logger.atSevere().log(
           "Cannot link: %s to user identity: %s",
           user.getExternalId(), identifiedUser.get().getAccountId());
diff --git a/java/com/google/gerrit/httpd/auth/openid/BUILD b/java/com/google/gerrit/httpd/auth/openid/BUILD
index bfb2551..edd12cc 100644
--- a/java/com/google/gerrit/httpd/auth/openid/BUILD
+++ b/java/com/google/gerrit/httpd/auth/openid/BUILD
@@ -8,13 +8,14 @@
         # We want all these deps to be provided_deps
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/util/http",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/commons:codec",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index adf6458..283cd50 100644
--- a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -52,9 +52,10 @@
 import org.w3c.dom.Element;
 
 /** Handles OpenID based login flow. */
-@SuppressWarnings("serial")
 @Singleton
 class LoginForm extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final ImmutableMap<String, String> ALL_PROVIDERS =
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java b/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
index 8299c16..d75805c 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
@@ -18,9 +18,9 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.HttpLogoutServlet;
 import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.server.audit.AuditService;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.group.GroupAuditService;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -39,7 +39,7 @@
       AuthConfig authConfig,
       DynamicItem<WebSession> webSession,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      AuditService audit,
+      GroupAuditService audit,
       Provider<OAuthSessionOverOpenID> oauthSession) {
     super(authConfig, webSession, urlProvider, audit);
     this.oauthSession = oauthSession;
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index a1a6715..08f2d52 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
@@ -120,7 +119,7 @@
     com.google.gerrit.server.account.AuthRequest areq =
         new com.google.gerrit.server.account.AuthRequest(
             ExternalId.Key.parse(user.getExternalId()));
-    AuthResult arsp = null;
+    AuthResult arsp;
     try {
       String claimedIdentifier = user.getClaimedIdentity();
       Optional<Account.Id> actualId = accountManager.lookup(user.getExternalId());
@@ -164,7 +163,7 @@
           logger.atFine().log("Claimed account already exists: link to it.");
           try {
             accountManager.link(claimedId.get(), areq);
-          } catch (OrmException | ConfigInvalidException e) {
+          } catch (ConfigInvalidException e) {
             logger.atSevere().log(
                 "Cannot link: %s to user identity:\n  Claimed ID: %s is %s",
                 user.getExternalId(), claimedId.get(), claimedIdentifier);
@@ -178,7 +177,7 @@
         try {
           logger.atFine().log("Linking \"%s\" to \"%s\"", user.getExternalId(), accountId);
           accountManager.link(accountId, areq);
-        } catch (OrmException | ConfigInvalidException e) {
+        } catch (ConfigInvalidException e) {
           logger.atSevere().log(
               "Cannot link: %s to user identity: %s", user.getExternalId(), accountId);
           rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
index 23cf468..1536741 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
@@ -23,9 +23,10 @@
 import javax.servlet.http.HttpServletResponse;
 
 /** Handles the {@code /OpenID} URL for web based single-sign-on. */
-@SuppressWarnings("serial")
 @Singleton
 class OpenIdLoginServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final OpenIdServiceImpl impl;
 
   @Inject
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index 28256cf..90a22ac 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.httpd.ProxyProperties;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.KeyUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.client.KeyUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java b/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java
index 2f7f4bd..864b160 100644
--- a/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java
+++ b/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.auth.openid;
 
 import com.google.gerrit.server.config.CanonicalWebUrl;
diff --git a/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java b/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
index d57e629..0344bc7 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
@@ -32,9 +32,10 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-@SuppressWarnings("serial")
 @Singleton
 class GitLogoServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final long modified;
   private final byte[] raw;
 
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
index feee3ba..f7f1bd6 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.util.http.CacheHeaders;
-import com.google.gwtjsonrpc.server.RPCServletUtils;
+import com.google.gerrit.util.http.RequestUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -32,10 +32,13 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-@SuppressWarnings("serial")
 abstract class GitwebCssServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   @Singleton
   static class Site extends GitwebCssServlet {
+    private static final long serialVersionUID = 1L;
+
     @Inject
     Site(SitePaths paths) throws IOException {
       super(paths.site_css);
@@ -44,6 +47,8 @@
 
   @Singleton
   static class Default extends GitwebCssServlet {
+    private static final long serialVersionUID = 1L;
+
     @Inject
     Default(GitwebCgiConfig gwcc) throws IOException {
       super(gwcc.getGitwebCss());
@@ -86,7 +91,7 @@
       rsp.setContentType("text/css");
       rsp.setCharacterEncoding(UTF_8.name());
       final byte[] toSend;
-      if (RPCServletUtils.acceptsGzipEncoding(req)) {
+      if (RequestUtil.acceptsGzipEncoding(req)) {
         rsp.setHeader("Content-Encoding", "gzip");
         toSend = gz_css;
       } else {
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
index 82dd901..d035036 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
@@ -32,9 +32,10 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-@SuppressWarnings("serial")
 @Singleton
 class GitwebJavaScriptServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final long modified;
   private final byte[] raw;
 
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 5e59c9a..b5d9f29 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -80,6 +80,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -88,9 +89,10 @@
 import org.eclipse.jgit.lib.Repository;
 
 /** Invokes {@code gitweb.cgi} for the project given in {@code p}. */
-@SuppressWarnings("serial")
 @Singleton
 class GitwebServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final String PROJECT_LIST_ACTION = "project_list";
@@ -172,10 +174,8 @@
     }
     Path myconf = Files.createTempFile(site.tmp_dir, "gitweb_config", ".perl");
 
-    // To make our configuration file only readable or writable by us;
-    // this reduces the chances of someone tampering with the file.
-    //
-    // TODO(dborowitz): Is there a portable way to do this with NIO?
+    // To make our configuration file only readable or writable by us; this reduces the chances of
+    // someone tampering with the file.
     File myconfFile = myconf.toFile();
     myconfFile.setWritable(false, false /* all */);
     myconfFile.setReadable(false, false /* all */);
@@ -412,7 +412,7 @@
       name = name.substring(0, name.length() - 4);
     }
 
-    Project.NameKey nameKey = new Project.NameKey(name);
+    Project.NameKey nameKey = Project.nameKey(name);
     ProjectState projectState;
     try {
       projectState = projectCache.checkedGet(nameKey);
@@ -668,17 +668,17 @@
   private void copyStderrToLog(InputStream in) {
     new Thread(
             () -> {
-              StringBuilder b = new StringBuilder();
               try (BufferedReader br =
                   new BufferedReader(new InputStreamReader(in, ISO_8859_1.name()))) {
-                String line;
-                while ((line = br.readLine()) != null) {
-                  if (b.length() > 0) {
-                    b.append('\n');
-                  }
-                  b.append("CGI: ").append(line);
+                String err =
+                    br.lines()
+                        .filter(s -> !s.isEmpty())
+                        .map(s -> "CGI: " + s)
+                        .collect(Collectors.joining("\n"))
+                        .trim();
+                if (!err.isEmpty()) {
+                  logger.atSevere().log(err);
                 }
-                logger.atSevere().log(b.toString());
               } catch (IOException e) {
                 logger.atSevere().withCause(e).log("Unexpected error copying stderr from CGI");
               }
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index 292ceff..df072b2 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -18,6 +18,7 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/git/receive",
@@ -25,7 +26,6 @@
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/sshd",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/httpd/init/ReviewDbDataSourceProvider.java b/java/com/google/gerrit/httpd/init/ReviewDbDataSourceProvider.java
deleted file mode 100644
index 6e65780..0000000
--- a/java/com/google/gerrit/httpd/init/ReviewDbDataSourceProvider.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.init;
-
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import javax.naming.InitialContext;
-import javax.naming.NamingException;
-import javax.sql.DataSource;
-
-/** Provides access to the {@code ReviewDb} DataSource. */
-@Singleton
-final class ReviewDbDataSourceProvider implements Provider<DataSource>, LifecycleListener {
-  private DataSource ds;
-
-  @Override
-  public synchronized DataSource get() {
-    if (ds == null) {
-      ds = open();
-    }
-    return ds;
-  }
-
-  @Override
-  public void start() {}
-
-  @Override
-  public synchronized void stop() {
-    if (ds != null) {
-      closeDataSource(ds);
-    }
-  }
-
-  private DataSource open() {
-    final String dsName = "java:comp/env/jdbc/ReviewDb";
-    try {
-      return (DataSource) new InitialContext().lookup(dsName);
-    } catch (NamingException namingErr) {
-      throw new ProvisionException("No DataSource " + dsName, namingErr);
-    }
-  }
-
-  private void closeDataSource(DataSource ds) {
-    try {
-      Class<?> type = Class.forName("org.apache.commons.dbcp.BasicDataSource");
-      if (type.isInstance(ds)) {
-        type.getMethod("close").invoke(ds);
-        return;
-      }
-    } catch (Throwable bad) {
-      // Oh well, its not a Commons DBCP pooled connection.
-    }
-
-    try {
-      Class<?> type = Class.forName("com.mchange.v2.c3p0.DataSources");
-      if (type.isInstance(ds)) {
-        type.getMethod("destroy", DataSource.class).invoke(null, ds);
-      }
-    } catch (Throwable bad) {
-      // Oh well, its not a c3p0 pooled connection.
-    }
-  }
-}
diff --git a/java/com/google/gerrit/httpd/init/SiteInitializer.java b/java/com/google/gerrit/httpd/init/SiteInitializer.java
index de4f284..04a0ddd 100644
--- a/java/com/google/gerrit/httpd/init/SiteInitializer.java
+++ b/java/com/google/gerrit/httpd/init/SiteInitializer.java
@@ -14,19 +14,17 @@
 
 package com.google.gerrit.httpd.init;
 
+import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.pgm.init.BaseInit;
 import com.google.gerrit.pgm.init.PluginsDistribution;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.sql.Connection;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
 import java.util.List;
 
 public final class SiteInitializer {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String GERRIT_SITE_PATH = "gerrit.site_path";
 
   private final String sitePath;
   private final String initPath;
@@ -49,46 +47,26 @@
       if (sitePath != null) {
         Path site = Paths.get(sitePath);
         logger.atInfo().log("Initializing site at %s", site.toRealPath().normalize());
-        new BaseInit(site, false, true, pluginsDistribution, pluginsToInstall).run();
+        new BaseInit(site, false, pluginsDistribution, pluginsToInstall).run();
         return;
       }
 
-      try (Connection conn = connectToDb()) {
-        Path site = getSiteFromReviewDb(conn);
-        if (site == null && initPath != null) {
-          site = Paths.get(initPath);
-        }
-        if (site != null) {
-          logger.atInfo().log("Initializing site at %s", site.toRealPath().normalize());
-          new BaseInit(
-                  site,
-                  new ReviewDbDataSourceProvider(),
-                  false,
-                  false,
-                  pluginsDistribution,
-                  pluginsToInstall)
-              .run();
-        }
+      String path = System.getProperty(GERRIT_SITE_PATH);
+      Path site = null;
+      if (!Strings.isNullOrEmpty(path)) {
+        site = Paths.get(path);
+      }
+
+      if (site == null && initPath != null) {
+        site = Paths.get(initPath);
+      }
+      if (site != null) {
+        logger.atInfo().log("Initializing site at %s", site.toRealPath().normalize());
+        new BaseInit(site, false, pluginsDistribution, pluginsToInstall).run();
       }
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Site init failed");
       throw new RuntimeException(e);
     }
   }
-
-  private Connection connectToDb() throws SQLException {
-    return new ReviewDbDataSourceProvider().get().getConnection();
-  }
-
-  private Path getSiteFromReviewDb(Connection conn) {
-    try (Statement stmt = conn.createStatement();
-        ResultSet rs = stmt.executeQuery("SELECT site_path FROM system_config")) {
-      if (rs.next()) {
-        return Paths.get(rs.getString(1));
-      }
-    } catch (SQLException e) {
-      return null;
-    }
-    return null;
-  }
 }
diff --git a/java/com/google/gerrit/httpd/init/SitePathFromSystemConfigProvider.java b/java/com/google/gerrit/httpd/init/SitePathFromSystemConfigProvider.java
deleted file mode 100644
index 96ba28b..0000000
--- a/java/com/google/gerrit/httpd/init/SitePathFromSystemConfigProvider.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.init;
-
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.List;
-
-/** Provides {@link Path} annotated with {@link SitePath}. */
-class SitePathFromSystemConfigProvider implements Provider<Path> {
-  private final Path path;
-
-  @Inject
-  SitePathFromSystemConfigProvider(@ReviewDbFactory SchemaFactory<ReviewDb> schemaFactory)
-      throws OrmException {
-    path = read(schemaFactory);
-  }
-
-  @Override
-  public Path get() {
-    return path;
-  }
-
-  private static Path read(SchemaFactory<ReviewDb> schemaFactory) throws OrmException {
-    try (ReviewDb db = schemaFactory.open()) {
-      List<SystemConfig> all = db.systemConfig().all().toList();
-      switch (all.size()) {
-        case 1:
-          return Paths.get(all.get(0).sitePath);
-        case 0:
-          throw new OrmException("system_config table is empty");
-        default:
-          throw new OrmException(
-              "system_config must have exactly 1 row; found " + all.size() + " rows instead");
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 9ce7690..b0b843f 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.init;
 
-import static com.google.inject.Scopes.SINGLETON;
 import static com.google.inject.Stage.PRODUCTION;
 
 import com.google.common.base.Splitter;
@@ -23,6 +22,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;
@@ -42,21 +42,26 @@
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor;
 import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks;
 import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.api.GerritApiModule;
+import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
+import com.google.gerrit.server.config.DefaultUrlFormatter;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
 import com.google.gerrit.server.config.GerritOptions;
+import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
@@ -66,27 +71,25 @@
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.OnlineUpgrader;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
 import com.google.gerrit.server.mail.receive.MailReceiver;
 import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginModule;
 import com.google.gerrit.server.project.DefaultProjectNameLockManager;
 import com.google.gerrit.server.restapi.RestApiModule;
-import com.google.gerrit.server.schema.DataSourceModule;
-import com.google.gerrit.server.schema.DataSourceProvider;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.gerrit.server.schema.DatabaseModule;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
+import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.gerrit.server.schema.SchemaModule;
-import com.google.gerrit.server.schema.SchemaVersionCheck;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
@@ -104,7 +107,7 @@
 import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.Provider;
-import com.google.inject.name.Names;
+import com.google.inject.ProvisionException;
 import com.google.inject.servlet.GuiceFilter;
 import com.google.inject.servlet.GuiceServletContextListener;
 import com.google.inject.spi.Message;
@@ -124,13 +127,14 @@
 import javax.servlet.ServletRequest;
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
-import javax.sql.DataSource;
 import org.eclipse.jgit.lib.Config;
 
 /** Configures the web application environment for Gerrit Code Review. */
 public class WebAppInitializer extends GuiceServletContextListener implements Filter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final String GERRIT_SITE_PATH = "gerrit.site_path";
+
   private Path sitePath;
   private Injector dbInjector;
   private Injector cfgInjector;
@@ -152,9 +156,11 @@
 
   private synchronized void init() {
     if (manager == null) {
-      final String path = System.getProperty("gerrit.site_path");
+      String path = System.getProperty(GERRIT_SITE_PATH);
       if (path != null) {
         sitePath = Paths.get(path);
+      } else {
+        throw new ProvisionException(GERRIT_SITE_PATH + " must be defined");
       }
 
       if (System.getProperty("gerrit.init") != null) {
@@ -168,14 +174,14 @@
         }
         new SiteInitializer(
                 path,
-                System.getProperty("gerrit.init_path"),
+                System.getProperty(GERRIT_SITE_PATH),
                 new UnzippedDistribution(servletContext),
                 pluginsToInstall)
             .init();
       }
 
       try {
-        dbInjector = createDbInjector();
+        cfgInjector = createCfgInjector();
       } catch (CreationException ce) {
         final Message first = ce.getErrorMessages().iterator().next();
         final StringBuilder buf = new StringBuilder();
@@ -195,7 +201,7 @@
         throw new CreationException(Collections.singleton(first));
       }
 
-      cfgInjector = createCfgInjector();
+      dbInjector = createDbInjector();
       initIndexType();
       config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
       sysInjector = createSysInjector();
@@ -240,84 +246,40 @@
     return new SshAddressesModule().getListenAddresses(config).isEmpty();
   }
 
-  private Injector createDbInjector() {
+  private Injector createCfgInjector() {
     final List<Module> modules = new ArrayList<>();
     AbstractModule secureStore = createSecureStoreModule();
     modules.add(secureStore);
-    if (sitePath != null) {
-      Module sitePathModule =
-          new AbstractModule() {
-            @Override
-            protected void configure() {
-              bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-            }
-          };
-      modules.add(sitePathModule);
+    Module sitePathModule =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+          }
+        };
+    modules.add(sitePathModule);
 
-      Module configModule = new GerritServerConfigModule();
-      modules.add(configModule);
-
-      Injector cfgInjector = Guice.createInjector(sitePathModule, configModule, secureStore);
-      Config cfg = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-      String dbType = cfg.getString("database", null, "type");
-
-      final DataSourceType dst =
-          Guice.createInjector(new DataSourceModule(), configModule, sitePathModule, secureStore)
-              .getInstance(Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
-      modules.add(
-          new LifecycleModule() {
-            @Override
-            protected void configure() {
-              bind(DataSourceType.class).toInstance(dst);
-              bind(DataSourceProvider.Context.class)
-                  .toInstance(DataSourceProvider.Context.MULTI_USER);
-              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-                  .toProvider(DataSourceProvider.class)
-                  .in(SINGLETON);
-              listener().to(DataSourceProvider.class);
-            }
-          });
-
-    } else {
-      modules.add(
-          new LifecycleModule() {
-            @Override
-            protected void configure() {
-              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-                  .toProvider(ReviewDbDataSourceProvider.class)
-                  .in(SINGLETON);
-              listener().to(ReviewDbDataSourceProvider.class);
-            }
-          });
-
-      // If we didn't get the site path from the system property
-      // we need to get it from the database, as that's our old
-      // method of locating the site path on disk.
-      //
-      modules.add(
-          new AbstractModule() {
-            @Override
-            protected void configure() {
-              bind(Path.class)
-                  .annotatedWith(SitePath.class)
-                  .toProvider(SitePathFromSystemConfigProvider.class)
-                  .in(SINGLETON);
-            }
-          });
-      modules.add(new GerritServerConfigModule());
-    }
-    modules.add(new DatabaseModule());
-    modules.add(new NotesMigration.Module());
+    Module configModule = new GerritServerConfigModule();
+    modules.add(configModule);
+    modules.add(
+        new LifecycleModule() {
+          @Override
+          protected void configure() {
+            listener().to(SystemReaderInstaller.class);
+          }
+        });
     modules.add(new DropWizardMetricMaker.ApiModule());
     return Guice.createInjector(PRODUCTION, modules);
   }
 
-  private Injector createCfgInjector() {
+  private Injector createDbInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(new SchemaModule());
-    modules.add(SchemaVersionCheck.module());
+    modules.add(NoteDbSchemaVersionCheck.module());
     modules.add(new AuthConfigModule());
-    return dbInjector.createChildInjector(modules);
+    return cfgInjector.createChildInjector(
+        ModuleOverloader.override(
+            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE)));
   }
 
   private Injector createSysInjector() {
@@ -333,6 +295,7 @@
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
+    modules.add(new PluginApiModule());
     modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultPermissionBackendModule());
@@ -342,21 +305,7 @@
     modules.add(new SmtpEmailSender.Module());
     modules.add(new SignedTokenEmailTokenVerifier.Module());
     modules.add(new LocalMergeSuperSetComputation.Module());
-
-    // Plugin module needs to be inserted *before* the index module.
-    // There is the concept of LifecycleModule, in Gerrit's own extension
-    // to Guice, which has these:
-    //  listener().to(SomeClassImplementingLifecycleListener.class);
-    // and the start() methods of each such listener are executed in the
-    // order they are declared.
-    // Makes sure that PluginLoader.start() is executed before the
-    // LuceneIndexModule.start() so that plugins get loaded and the respective
-    // Guice modules installed so that the on-line reindexing will happen
-    // with the proper classes (e.g. group backends, custom Prolog
-    // predicates) and the associated rules ready to be evaluated.
-    modules.add(new PluginModule());
-
-    modules.add(new RestApiModule());
+    modules.add(new AuditModule());
     modules.add(new GpgModule(config));
     modules.add(new StartupChecks.Module());
 
@@ -364,6 +313,12 @@
     // work queue can get stuck waiting on index futures that will never return.
     modules.add(createIndexModule());
 
+    modules.add(new PluginModule());
+    if (VersionManager.getOnlineUpgrade(config)) {
+      modules.add(new OnlineUpgrader.Module());
+    }
+
+    modules.add(new RestApiModule());
     modules.add(new WorkQueue.Module());
     modules.add(new GerritInstanceNameModule());
     modules.add(
@@ -373,30 +328,33 @@
             return HttpCanonicalWebUrlProvider.class;
           }
         });
+    modules.add(new DefaultUrlFormatter.Module());
+
     modules.add(SshKeyCacheImpl.module());
     modules.add(
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class).toInstance(new GerritOptions(config, false, false, false));
+            bind(GerritOptions.class).toInstance(new GerritOptions(false, false, false));
+            bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
           }
         });
     modules.add(new GarbageCollectionModule());
     modules.add(new ChangeCleanupRunner.Module());
     modules.add(new AccountDeactivator.Module());
     modules.add(new DefaultProjectNameLockManager.Module());
-    return cfgInjector.createChildInjector(
-        ModuleOverloader.override(modules, LibModuleLoader.loadModules(cfgInjector)));
+    return dbInjector.createChildInjector(
+        ModuleOverloader.override(
+            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE)));
   }
 
   private Module createIndexModule() {
-    switch (indexType) {
-      case LUCENE:
-        return LuceneIndexModule.latestVersionWithOnlineUpgrade(false);
-      case ELASTICSEARCH:
-        return ElasticIndexModule.latestVersionWithOnlineUpgrade(false);
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
+    if (indexType.isLucene()) {
+      return LuceneIndexModule.latestVersion(false);
+    } else if (indexType.isElasticsearch()) {
+      return ElasticIndexModule.latestVersion(false);
+    } else {
+      throw new IllegalStateException("unsupported index.type = " + indexType);
     }
   }
 
@@ -420,9 +378,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) {
@@ -442,7 +401,10 @@
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
-    modules.add(sysInjector.getInstance(StaticModule.class));
+    GerritOptions opts = sysInjector.getInstance(GerritOptions.class);
+    if (opts.enableMasterFeatures()) {
+      modules.add(sysInjector.getInstance(StaticModule.class));
+    }
 
     return sysInjector.createChildInjector(modules);
   }
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java b/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
index 937b24a..279903c 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
@@ -15,11 +15,9 @@
 package com.google.gerrit.httpd.plugins;
 
 import com.google.gerrit.extensions.api.lfs.LfsDefinitions;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.ResourceWeigher;
-import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -65,7 +63,5 @@
                 .weigher(ResourceWeigher.class);
           }
         });
-
-    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
   }
 }
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 9a24e47..43eb3a0 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -35,7 +35,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
 import com.google.common.net.HttpHeaders;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.SmallResource;
@@ -84,8 +83,6 @@
 import javax.servlet.ServletConfig;
 import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -174,13 +171,7 @@
     GuiceFilter filter = load(plugin);
     final String name = plugin.getName();
     final PluginHolder holder = new PluginHolder(plugin, filter);
-    plugin.add(
-        new RegistrationHandle() {
-          @Override
-          public void remove() {
-            plugins.remove(name, holder);
-          }
-        });
+    plugin.add(() -> plugins.remove(name, holder));
     plugins.put(name, holder);
   }
 
@@ -234,12 +225,7 @@
 
     HttpServletRequest wr = wrapper.create(req, name);
     FilterChain chain =
-        new FilterChain() {
-          @Override
-          public void doFilter(ServletRequest req, ServletResponse res) throws IOException {
-            onDefault(holder, (HttpServletRequest) req, (HttpServletResponse) res);
-          }
-        };
+        (sreq, sres) -> onDefault(holder, (HttpServletRequest) sreq, (HttpServletResponse) sres);
     if (holder.filter != null) {
       holder.filter.doFilter(wr, res, chain);
     } else {
@@ -456,8 +442,8 @@
       }
     }
 
-    Collections.sort(cmds, PluginEntry.COMPARATOR_BY_NAME);
-    Collections.sort(docs, PluginEntry.COMPARATOR_BY_NAME);
+    cmds.sort(PluginEntry.COMPARATOR_BY_NAME);
+    docs.sort(PluginEntry.COMPARATOR_BY_NAME);
 
     StringBuilder md = new StringBuilder();
     md.append(String.format("# Plugin %s #\n", pluginName));
diff --git a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
index 67ee3ba..fc0ec39 100644
--- a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
@@ -19,7 +19,6 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.plugins.Plugin;
@@ -40,8 +39,6 @@
 import javax.servlet.ServletConfig;
 import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -66,12 +63,7 @@
   LfsPluginServlet(@GerritServerConfig Config cfg) {
     this.pluginName = cfg.getString("lfs", null, "plugin");
     this.chain =
-        new FilterChain() {
-          @Override
-          public void doFilter(ServletRequest req, ServletResponse res) throws IOException {
-            Resource.NOT_FOUND.send((HttpServletRequest) req, (HttpServletResponse) res);
-          }
-        };
+        (req, res) -> Resource.NOT_FOUND.send((HttpServletRequest) req, (HttpServletResponse) res);
     this.filter = new AtomicReference<>();
   }
 
@@ -123,13 +115,7 @@
       return;
     }
     final GuiceFilter guiceFilter = load(plugin);
-    plugin.add(
-        new RegistrationHandle() {
-          @Override
-          public void remove() {
-            filter.compareAndSet(guiceFilter, null);
-          }
-        });
+    plugin.add(() -> filter.compareAndSet(guiceFilter, null));
     filter.set(guiceFilter);
   }
 
diff --git a/java/com/google/gerrit/httpd/raw/BazelBuild.java b/java/com/google/gerrit/httpd/raw/BazelBuild.java
index 940a51b..a13078d 100644
--- a/java/com/google/gerrit/httpd/raw/BazelBuild.java
+++ b/java/com/google/gerrit/httpd/raw/BazelBuild.java
@@ -22,15 +22,16 @@
 import com.google.common.flogger.FluentLogger;
 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.server.util.time.TimeUtil;
 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"));
     }
@@ -147,11 +147,6 @@
     return sourceRoot.resolve("bazel-bin").resolve(l.pkg).resolve(l.name);
   }
 
-  /** Label for the agent specific GWT zip. */
-  public Label gwtZipLabel(String agent) {
-    return new Label("gerrit-gwtui", "ui_" + agent + ".zip");
-  }
-
   /** Label for the polygerrit component zip. */
   public Label polygerritComponents() {
     return new Label("polygerrit-ui", "polygerrit_components.bower_components.zip");
diff --git a/java/com/google/gerrit/httpd/raw/CatServlet.java b/java/com/google/gerrit/httpd/raw/CatServlet.java
index 4b5c227..a4538cf 100644
--- a/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
@@ -30,9 +29,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
-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.Optional;
@@ -49,10 +46,10 @@
  * this site, and will execute it with the site's own protection domain. This opens a massive
  * security hole so we package the content into a zip file.
  */
-@SuppressWarnings("serial")
 @Singleton
 public class CatServlet extends HttpServlet {
-  private final Provider<ReviewDb> requestDb;
+  private static final long serialVersionUID = 1L;
+
   private final ChangeEditUtil changeEditUtil;
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
@@ -61,13 +58,11 @@
 
   @Inject
   CatServlet(
-      Provider<ReviewDb> sf,
       ChangeEditUtil ceu,
       PatchSetUtil psu,
       ChangeNotes.Factory cnf,
       PermissionBackend pb,
       ProjectCache pc) {
-    requestDb = sf;
     changeEditUtil = ceu;
     psUtil = psu;
     changeNotesFactory = cnf;
@@ -123,17 +118,13 @@
       }
     }
 
-    final Change.Id changeId = patchKey.getParentKey().getParentKey();
+    final Change.Id changeId = patchKey.patchSetId().changeId();
     String revision;
     try {
       ChangeNotes notes = changeNotesFactory.createChecked(changeId);
-      permissionBackend
-          .currentUser()
-          .change(notes)
-          .database(requestDb)
-          .check(ChangePermission.READ);
+      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
       projectCache.checkedGet(notes.getProjectName()).checkStatePermitsRead();
-      if (patchKey.getParentKey().get() == 0) {
+      if (patchKey.patchSetId().get() == 0) {
         // change edit
         Optional<ChangeEdit> edit = changeEditUtil.byChange(notes);
         if (edit.isPresent()) {
@@ -143,23 +134,23 @@
           return;
         }
       } else {
-        PatchSet patchSet = psUtil.get(requestDb.get(), notes, patchKey.getParentKey());
+        PatchSet patchSet = psUtil.get(notes, patchKey.patchSetId());
         if (patchSet == null) {
           rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
           return;
         }
-        revision = patchSet.getRevision().get();
+        revision = patchSet.commitId().name();
       }
     } catch (ResourceConflictException | NoSuchChangeException | AuthException e) {
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
-    } catch (OrmException | PermissionBackendException | IOException e) {
+    } catch (PermissionBackendException | IOException e) {
       getServletContext().log("Cannot query database", e);
       rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
       return;
     }
 
-    String path = patchKey.getFileName();
+    String path = patchKey.fileName();
     String restUrl =
         String.format(
             "%s/changes/%d/revisions/%s/files/%s/download?parent=%d",
diff --git a/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java b/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java
deleted file mode 100644
index 0f3e342..0000000
--- a/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.common.TimeUtil;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.attribute.FileTime;
-
-class DirectoryGwtUiServlet extends ResourceServlet {
-  private static final long serialVersionUID = 1L;
-
-  private static final FileTime NOW = FileTime.fromMillis(TimeUtil.nowMs());
-
-  private final Path ui;
-
-  DirectoryGwtUiServlet(Cache<Path, Resource> cache, Path unpackedWar, boolean dev)
-      throws IOException {
-    super(cache, false);
-    ui = unpackedWar.resolve("gerrit_ui");
-    if (!Files.exists(ui)) {
-      Files.createDirectory(ui);
-    }
-    if (dev) {
-      ui.toFile().deleteOnExit();
-    }
-  }
-
-  @Override
-  protected Path getResourcePath(String pathInfo) {
-    return ui.resolve(pathInfo);
-  }
-
-  @Override
-  protected FileTime getLastModifiedTime(Path p) {
-    // Return initialization time of this class, since the GWT outputs from the
-    // build process all have mtimes of 1980/1/1.
-    return NOW;
-  }
-}
diff --git a/java/com/google/gerrit/httpd/raw/HostPageServlet.java b/java/com/google/gerrit/httpd/raw/HostPageServlet.java
deleted file mode 100644
index 160732e..0000000
--- a/java/com/google/gerrit/httpd/raw/HostPageServlet.java
+++ /dev/null
@@ -1,410 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import static com.google.gerrit.common.FileUtil.lastModified;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.common.primitives.Bytes;
-import com.google.gerrit.common.Version;
-import com.google.gerrit.common.data.HostPageData;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.systemstatus.MessageOfTheDay;
-import com.google.gerrit.extensions.webui.WebUiPlugin;
-import com.google.gerrit.httpd.HtmlDomUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.account.GetDiffPreferences;
-import com.google.gerrit.util.http.CacheHeaders;
-import com.google.gwtjsonrpc.server.JsonServlet;
-import com.google.gwtjsonrpc.server.RPCServletUtils;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.io.StringWriter;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-import org.w3c.dom.Node;
-
-/** Sends the Gerrit host page to clients. */
-@SuppressWarnings("serial")
-@Singleton
-public class HostPageServlet extends HttpServlet {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final String HPD_ID = "gerrit_hostpagedata";
-  private static final int DEFAULT_JS_LOAD_TIMEOUT = 5000;
-
-  private final Provider<CurrentUser> currentUser;
-  private final DynamicSet<WebUiPlugin> plugins;
-  private final DynamicSet<MessageOfTheDay> messages;
-  private final HostPageData.Theme signedOutTheme;
-  private final HostPageData.Theme signedInTheme;
-  private final SitePaths site;
-  private final Document template;
-  private final String noCacheName;
-  private final boolean refreshHeaderFooter;
-  private final SiteStaticDirectoryServlet staticServlet;
-  private final boolean isNoteDbEnabled;
-  private final Integer pluginsLoadTimeout;
-  private final boolean canLoadInIFrame;
-  private final GetDiffPreferences getDiff;
-  private volatile Page page;
-
-  @Inject
-  HostPageServlet(
-      Provider<CurrentUser> cu,
-      SitePaths sp,
-      ThemeFactory themeFactory,
-      ServletContext servletContext,
-      DynamicSet<WebUiPlugin> webUiPlugins,
-      DynamicSet<MessageOfTheDay> motd,
-      @GerritServerConfig Config cfg,
-      SiteStaticDirectoryServlet ss,
-      NotesMigration migration,
-      GetDiffPreferences diffPref)
-      throws IOException, ServletException {
-    currentUser = cu;
-    plugins = webUiPlugins;
-    messages = motd;
-    signedOutTheme = themeFactory.getSignedOutTheme();
-    signedInTheme = themeFactory.getSignedInTheme();
-    site = sp;
-    refreshHeaderFooter = cfg.getBoolean("site", "refreshHeaderFooter", true);
-    staticServlet = ss;
-    isNoteDbEnabled = migration.readChanges();
-    pluginsLoadTimeout = getPluginsLoadTimeout(cfg);
-    canLoadInIFrame = cfg.getBoolean("gerrit", "canLoadInIFrame", false);
-    getDiff = diffPref;
-
-    String pageName = "HostPage.html";
-    template = HtmlDomUtil.parseFile(getClass(), pageName);
-    if (template == null) {
-      throw new FileNotFoundException("No " + pageName + " in webapp");
-    }
-
-    if (HtmlDomUtil.find(template, "gerrit_module") == null) {
-      throw new ServletException("No gerrit_module in " + pageName);
-    }
-    if (HtmlDomUtil.find(template, HPD_ID) == null) {
-      throw new ServletException("No " + HPD_ID + " in " + pageName);
-    }
-
-    String src = "gerrit_ui/gerrit_ui.nocache.js";
-    try (InputStream in = servletContext.getResourceAsStream("/" + src)) {
-      if (in != null) {
-        Hasher md = Hashing.murmur3_128().newHasher();
-        byte[] buf = new byte[1024];
-        int n;
-        while ((n = in.read(buf)) > 0) {
-          md.putBytes(buf, 0, n);
-        }
-        src += "?content=" + md.hash().toString();
-      } else {
-        logger.atFine().log("No %s in webapp root; keeping noncache.js URL", src);
-      }
-    } catch (IOException e) {
-      throw new IOException("Failed reading " + src, e);
-    }
-
-    noCacheName = src;
-    page = new Page();
-  }
-
-  private static int getPluginsLoadTimeout(Config cfg) {
-    long cfgValue =
-        ConfigUtil.getTimeUnit(
-            cfg, "plugins", null, "jsLoadTimeout", DEFAULT_JS_LOAD_TIMEOUT, TimeUnit.MILLISECONDS);
-    if (cfgValue < 0) {
-      return 0;
-    }
-    return (int) cfgValue;
-  }
-
-  private void json(Object data, StringWriter w) {
-    JsonServlet.defaultGsonBuilder().create().toJson(data, w);
-  }
-
-  private Page get() {
-    Page p = page;
-    try {
-      if (refreshHeaderFooter && p.isStale()) {
-        p = new Page();
-        page = p;
-      }
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Cannot refresh site header/footer");
-    }
-    return p;
-  }
-
-  @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    Page.Content page = select(req);
-    StringWriter w = new StringWriter();
-    CurrentUser user = currentUser.get();
-    if (user.isIdentifiedUser()) {
-      w.write(HPD_ID + ".accountDiffPref=");
-      json(getDiffPreferences(user.asIdentifiedUser()), w);
-      w.write(";");
-
-      w.write(HPD_ID + ".theme=");
-      json(signedInTheme, w);
-      w.write(";");
-    } else {
-      w.write(HPD_ID + ".theme=");
-      json(signedOutTheme, w);
-      w.write(";");
-    }
-    plugins(w);
-    messages(w);
-
-    byte[] hpd = w.toString().getBytes(UTF_8);
-    byte[] raw = Bytes.concat(page.part1, hpd, page.part2);
-    byte[] tosend;
-    if (RPCServletUtils.acceptsGzipEncoding(req)) {
-      rsp.setHeader("Content-Encoding", "gzip");
-      tosend = HtmlDomUtil.compress(raw);
-    } else {
-      tosend = raw;
-    }
-
-    CacheHeaders.setNotCacheable(rsp);
-    rsp.setContentType("text/html");
-    rsp.setCharacterEncoding(HtmlDomUtil.ENC.name());
-    rsp.setContentLength(tosend.length);
-    try (OutputStream out = rsp.getOutputStream()) {
-      out.write(tosend);
-    }
-  }
-
-  private DiffPreferencesInfo getDiffPreferences(IdentifiedUser user) {
-    try {
-      return getDiff.apply(new AccountResource(user));
-    } catch (RestApiException
-        | ConfigInvalidException
-        | IOException
-        | PermissionBackendException e) {
-      logger.atWarning().withCause(e).log("Cannot query account diff preferences");
-    }
-    return DiffPreferencesInfo.defaults();
-  }
-
-  private void plugins(StringWriter w) {
-    List<String> urls = new ArrayList<>();
-    for (WebUiPlugin u : plugins) {
-      urls.add(String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath()));
-    }
-    if (!urls.isEmpty()) {
-      w.write(HPD_ID + ".plugins=");
-      json(urls, w);
-      w.write(";");
-    }
-  }
-
-  private void messages(StringWriter w) {
-    List<HostPageData.Message> list = new ArrayList<>(2);
-    for (MessageOfTheDay motd : messages) {
-      String html = motd.getHtmlMessage();
-      if (!Strings.isNullOrEmpty(html)) {
-        HostPageData.Message m = new HostPageData.Message();
-        m.id = motd.getMessageId();
-        m.redisplay = motd.getRedisplay();
-        m.html = html;
-        list.add(m);
-      }
-    }
-    if (!list.isEmpty()) {
-      w.write(HPD_ID + ".messages=");
-      json(list, w);
-      w.write(";");
-    }
-  }
-
-  private Page.Content select(HttpServletRequest req) {
-    Page pg = get();
-    if ("1".equals(req.getParameter("dbg"))) {
-      return pg.debug;
-    }
-    return pg.opt;
-  }
-
-  private void insertETags(Element e) {
-    if ("img".equalsIgnoreCase(e.getTagName()) || "script".equalsIgnoreCase(e.getTagName())) {
-      String src = e.getAttribute("src");
-      if (src != null && src.startsWith("static/")) {
-        String name = src.substring("static/".length());
-        ResourceServlet.Resource r = staticServlet.getResource(name);
-        if (r != null) {
-          e.setAttribute("src", src + "?e=" + r.etag);
-        }
-      }
-    }
-
-    for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling()) {
-      if (n instanceof Element) {
-        insertETags((Element) n);
-      }
-    }
-  }
-
-  private static class FileInfo {
-    private final Path path;
-    private final long time;
-
-    FileInfo(Path p) {
-      path = p;
-      time = lastModified(path);
-    }
-
-    boolean isStale() {
-      return time != lastModified(path);
-    }
-  }
-
-  private class Page {
-    private final FileInfo css;
-    private final FileInfo header;
-    private final FileInfo footer;
-    private final Content opt;
-    private final Content debug;
-
-    Page() throws IOException {
-      Document hostDoc = HtmlDomUtil.clone(template);
-
-      css = injectCssFile(hostDoc, "gerrit_sitecss", site.site_css);
-      header = injectXmlFile(hostDoc, "gerrit_header", site.site_header);
-      footer = injectXmlFile(hostDoc, "gerrit_footer", site.site_footer);
-
-      HostPageData pageData = new HostPageData();
-      pageData.version = Version.getVersion();
-      pageData.isNoteDbEnabled = isNoteDbEnabled;
-      pageData.pluginsLoadTimeout = pluginsLoadTimeout;
-      pageData.canLoadInIFrame = canLoadInIFrame;
-
-      StringWriter w = new StringWriter();
-      w.write("var " + HPD_ID + "=");
-      json(pageData, w);
-      w.write(";");
-
-      Element data = HtmlDomUtil.find(hostDoc, HPD_ID);
-      asScript(data);
-      data.appendChild(hostDoc.createTextNode(w.toString()));
-      data.appendChild(hostDoc.createComment(HPD_ID));
-
-      Element nocache = HtmlDomUtil.find(hostDoc, "gerrit_module");
-      asScript(nocache);
-      nocache.removeAttribute("id");
-      nocache.setAttribute("src", noCacheName);
-      opt = new Content(hostDoc);
-
-      nocache.setAttribute("src", "gerrit_ui/dbg_gerrit_ui.nocache.js");
-      debug = new Content(hostDoc);
-    }
-
-    boolean isStale() {
-      return css.isStale() || header.isStale() || footer.isStale();
-    }
-
-    private void asScript(Element scriptNode) {
-      scriptNode.setAttribute("type", "text/javascript");
-      scriptNode.setAttribute("language", "javascript");
-    }
-
-    class Content {
-      final byte[] part1;
-      final byte[] part2;
-
-      Content(Document hostDoc) throws IOException {
-        String raw = HtmlDomUtil.toString(hostDoc);
-        int p = raw.indexOf("<!--" + HPD_ID);
-        if (p < 0) {
-          throw new IOException("No tag in transformed host page HTML");
-        }
-        part1 = raw.substring(0, p).getBytes(UTF_8);
-        part2 = raw.substring(raw.indexOf('>', p) + 1).getBytes(UTF_8);
-      }
-    }
-
-    private FileInfo injectCssFile(Document hostDoc, String id, Path src) throws IOException {
-      FileInfo info = new FileInfo(src);
-      Element banner = HtmlDomUtil.find(hostDoc, id);
-      if (banner == null) {
-        return info;
-      }
-
-      while (banner.getFirstChild() != null) {
-        banner.removeChild(banner.getFirstChild());
-      }
-
-      String css = HtmlDomUtil.readFile(src.getParent(), src.getFileName().toString());
-      if (css == null) {
-        return info;
-      }
-
-      banner.appendChild(hostDoc.createCDATASection("\n" + css + "\n"));
-      return info;
-    }
-
-    private FileInfo injectXmlFile(Document hostDoc, String id, Path src) throws IOException {
-      FileInfo info = new FileInfo(src);
-      Element banner = HtmlDomUtil.find(hostDoc, id);
-      if (banner == null) {
-        return info;
-      }
-
-      while (banner.getFirstChild() != null) {
-        banner.removeChild(banner.getFirstChild());
-      }
-
-      Document html = HtmlDomUtil.parseFile(src);
-      if (html == null) {
-        return info;
-      }
-
-      Element content = html.getDocumentElement();
-      insertETags(content);
-      banner.appendChild(hostDoc.importNode(content, true));
-      return info;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
new file mode 100644
index 0000000..4c125a7
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.template.soy.data.ordainers.GsonOrdainer.serializeObject;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.AccountApi;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gson.Gson;
+import com.google.template.soy.data.SanitizedContent;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+
+/** Helper for generating parts of {@code index.html}. */
+public class IndexHtmlUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /**
+   * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
+   * rendering the soy template.
+   */
+  public static ImmutableMap<String, Object> templateData(
+      GerritApi gerritApi,
+      String canonicalURL,
+      String cdnPath,
+      String faviconPath,
+      Map<String, String[]> urlParameterMap,
+      Function<String, SanitizedContent> urlInScriptTagOrdainer)
+      throws URISyntaxException, RestApiException {
+    return ImmutableMap.<String, Object>builder()
+        .putAll(
+            staticTemplateData(
+                canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
+        .putAll(dynamicTemplateData(gerritApi))
+        .build();
+  }
+
+  /** Returns dynamic parameters of {@code index.html}. */
+  @UsedAt(Project.GOOGLE)
+  public static Map<String, Map<String, SanitizedContent>> dynamicTemplateData(GerritApi gerritApi)
+      throws RestApiException {
+    Gson gson = OutputFormat.JSON_COMPACT.newGson();
+    Map<String, SanitizedContent> initialData = new HashMap<>();
+    Server serverApi = gerritApi.config().server();
+    initialData.put("\"/config/server/info\"", serializeObject(gson, serverApi.getInfo()));
+    initialData.put("\"/config/server/version\"", serializeObject(gson, serverApi.getVersion()));
+    initialData.put("\"/config/server/top-menus\"", serializeObject(gson, serverApi.topMenus()));
+
+    try {
+      AccountApi accountApi = gerritApi.accounts().self();
+      initialData.put("\"/accounts/self/detail\"", serializeObject(gson, accountApi.get()));
+      initialData.put(
+          "\"/accounts/self/preferences\"", serializeObject(gson, accountApi.getPreferences()));
+      initialData.put(
+          "\"/accounts/self/preferences.diff\"",
+          serializeObject(gson, accountApi.getDiffPreferences()));
+      initialData.put(
+          "\"/accounts/self/preferences.edit\"",
+          serializeObject(gson, accountApi.getEditPreferences()));
+    } catch (AuthException e) {
+      logger.atFine().withCause(e).log(
+          "Can't inline account-related data because user is unauthenticated");
+      // Don't render data
+      // TODO(hiesel): Tell the client that the user is not authenticated so that it doesn't have to
+      // fetch anyway. This requires more client side modifications.
+    }
+    return ImmutableMap.of("gerritInitialData", initialData);
+  }
+
+  /** Returns all static parameters of {@code index.html}. */
+  static Map<String, Object> staticTemplateData(
+      String canonicalURL,
+      String cdnPath,
+      String faviconPath,
+      Map<String, String[]> urlParameterMap,
+      Function<String, SanitizedContent> urlInScriptTagOrdainer)
+      throws URISyntaxException {
+    String canonicalPath = computeCanonicalPath(canonicalURL);
+
+    String staticPath = "";
+    if (cdnPath != null) {
+      staticPath = cdnPath;
+    } else if (canonicalPath != null) {
+      staticPath = canonicalPath;
+    }
+
+    SanitizedContent sanitizedStaticPath = urlInScriptTagOrdainer.apply(staticPath);
+    ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
+    if (canonicalPath != null) {
+      data.put("canonicalPath", canonicalPath);
+    }
+    if (sanitizedStaticPath != null) {
+      data.put("staticResourcePath", sanitizedStaticPath);
+    }
+    if (faviconPath != null) {
+      data.put("faviconPath", faviconPath);
+    }
+    if (urlParameterMap.containsKey("p2")) {
+      data.put("polymer2", "true");
+    }
+    if (urlParameterMap.containsKey("ce")) {
+      data.put("polyfillCE", "true");
+    }
+    if (urlParameterMap.containsKey("sd")) {
+      data.put("polyfillSD", "true");
+    }
+    if (urlParameterMap.containsKey("sc")) {
+      data.put("polyfillSC", "true");
+    }
+    return data.build();
+  }
+
+  private static String computeCanonicalPath(@Nullable String canonicalURL)
+      throws URISyntaxException {
+    if (Strings.isNullOrEmpty(canonicalURL)) {
+      return "";
+    }
+
+    // If we serving from a sub-directory rather than root, determine the path
+    // from the cannonical web URL.
+    URI uri = new URI(canonicalURL);
+    return uri.getPath().replaceAll("/$", "");
+  }
+
+  private IndexHtmlUtil() {}
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index 90b25d9..a0b41b21 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -17,82 +17,73 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
-import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.io.Resources;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.template.soy.SoyFileSet;
 import com.google.template.soy.data.SanitizedContent;
-import com.google.template.soy.data.SoyMapData;
 import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.io.IOException;
 import java.io.OutputStream;
-import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.Map;
+import java.util.function.Function;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
-  protected final byte[] indexSource;
 
-  IndexServlet(String canonicalURL, @Nullable String cdnPath, @Nullable String faviconPath)
-      throws URISyntaxException {
-    String resourcePath = "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
-    SoyFileSet.Builder builder = SoyFileSet.builder();
-    builder.add(Resources.getResource(resourcePath));
-    SoyTofu.Renderer renderer =
-        builder
+  @Nullable private final String canonicalUrl;
+  @Nullable private final String cdnPath;
+  @Nullable private final String faviconPath;
+  private final GerritApi gerritApi;
+  private final SoySauce soySauce;
+  private final Function<String, SanitizedContent> urlOrdainer;
+
+  IndexServlet(
+      @Nullable String canonicalUrl,
+      @Nullable String cdnPath,
+      @Nullable String faviconPath,
+      GerritApi gerritApi) {
+    this.canonicalUrl = canonicalUrl;
+    this.cdnPath = cdnPath;
+    this.faviconPath = faviconPath;
+    this.gerritApi = gerritApi;
+    this.soySauce =
+        SoyFileSet.builder()
+            .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
             .build()
-            .compileToTofu()
-            .newRenderer("com.google.gerrit.httpd.raw.Index")
-            .setContentKind(SanitizedContent.ContentKind.HTML)
-            .setData(getTemplateData(canonicalURL, cdnPath, faviconPath));
-    indexSource = renderer.render().getBytes(UTF_8);
+            .compileTemplates();
+    this.urlOrdainer =
+        (s) ->
+            UnsafeSanitizedContentOrdainer.ordainAsSafe(
+                s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
   }
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    SoySauce.Renderer renderer;
+    try {
+      Map<String, String[]> parameterMap = req.getParameterMap();
+      // TODO(hiesel): Remove URL ordainer as parameter once Soy is consistent
+      ImmutableMap<String, Object> templateData =
+          IndexHtmlUtil.templateData(
+              gerritApi, canonicalUrl, cdnPath, faviconPath, parameterMap, urlOrdainer);
+      renderer = soySauce.renderTemplate("com.google.gerrit.httpd.raw.Index").setData(templateData);
+    } catch (URISyntaxException | RestApiException e) {
+      throw new IOException(e);
+    }
+
     rsp.setCharacterEncoding(UTF_8.name());
     rsp.setContentType("text/html");
     rsp.setStatus(SC_OK);
     try (OutputStream w = rsp.getOutputStream()) {
-      w.write(indexSource);
+      w.write(renderer.renderHtml().get().toString().getBytes(UTF_8));
     }
   }
-
-  static String computeCanonicalPath(String canonicalURL) throws URISyntaxException {
-    if (Strings.isNullOrEmpty(canonicalURL)) {
-      return "";
-    }
-
-    // If we serving from a sub-directory rather than root, determine the path
-    // from the cannonical web URL.
-    URI uri = new URI(canonicalURL);
-    return uri.getPath().replaceAll("/$", "");
-  }
-
-  static SoyMapData getTemplateData(String canonicalURL, String cdnPath, String faviconPath)
-      throws URISyntaxException {
-    String canonicalPath = computeCanonicalPath(canonicalURL);
-
-    String staticPath = "";
-    if (cdnPath != null) {
-      staticPath = cdnPath;
-    } else if (canonicalPath != null) {
-      staticPath = canonicalPath;
-    }
-
-    // The resource path must be typed as safe for use in a script src.
-    // TODO(wyatta): Upgrade this to use an appropriate safe URL type.
-    SanitizedContent sanitizedStaticPath =
-        UnsafeSanitizedContentOrdainer.ordainAsSafe(
-            staticPath, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
-
-    return new SoyMapData(
-        "canonicalPath", canonicalPath,
-        "staticResourcePath", sanitizedStaticPath,
-        "faviconPath", faviconPath);
-  }
 }
diff --git a/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java b/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
index e12f0a5..d4647ae 100644
--- a/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
+++ b/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.util.http.CacheHeaders;
-import com.google.gwtjsonrpc.server.RPCServletUtils;
+import com.google.gerrit.util.http.RequestUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.FileNotFoundException;
@@ -34,9 +34,10 @@
  * as it would lose any history token that appears in the URL. Instead we send an HTML page which
  * instructs the browser to replace the URL, but preserve the history token.
  */
-@SuppressWarnings("serial")
 @Singleton
 public class LegacyGerritServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final byte[] raw;
   private final byte[] compressed;
 
@@ -55,7 +56,7 @@
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     final byte[] tosend;
-    if (RPCServletUtils.acceptsGzipEncoding(req)) {
+    if (RequestUtil.acceptsGzipEncoding(req)) {
       rsp.setHeader("Content-Encoding", "gzip");
       tosend = compressed;
     } else {
diff --git a/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java b/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
index c508b2d..c7d23de 100644
--- a/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
+++ b/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
 import java.nio.file.FileSystems;
 import java.nio.file.Path;
diff --git a/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java b/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
deleted file mode 100644
index c6c3367..0000000
--- a/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Enumeration;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipFile;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-class RecompileGwtUiFilter implements Filter {
-  private final boolean gwtuiRecompile =
-      System.getProperty("gerrit.disable-gwtui-recompile") == null;
-  private final UserAgentRule rule = new UserAgentRule();
-  private final Set<String> uaInitialized = new HashSet<>();
-  private final Path unpackedWar;
-  private final BazelBuild builder;
-
-  private String lastAgent;
-  private long lastTime;
-
-  RecompileGwtUiFilter(BazelBuild builder, Path unpackedWar) {
-    this.builder = builder;
-    this.unpackedWar = unpackedWar;
-  }
-
-  @Override
-  public void doFilter(ServletRequest request, ServletResponse res, FilterChain chain)
-      throws IOException, ServletException {
-    String agent = rule.select((HttpServletRequest) request);
-    if (unpackedWar != null && (gwtuiRecompile || !uaInitialized.contains(agent))) {
-      BazelBuild.Label label = builder.gwtZipLabel(agent);
-      File zip = builder.targetPath(label).toFile();
-
-      synchronized (this) {
-        try {
-          builder.build(label);
-        } catch (BazelBuild.BuildFailureException e) {
-          e.display(label.toString(), (HttpServletResponse) res);
-          return;
-        }
-
-        if (!agent.equals(lastAgent) || lastTime != zip.lastModified()) {
-          lastAgent = agent;
-          lastTime = zip.lastModified();
-          unpack(zip, unpackedWar.toFile());
-        }
-      }
-      uaInitialized.add(agent);
-    }
-    chain.doFilter(request, res);
-  }
-
-  @Override
-  public void init(FilterConfig config) {}
-
-  @Override
-  public void destroy() {}
-
-  private static void unpack(File srcwar, File dstwar) throws IOException {
-    try (ZipFile zf = new ZipFile(srcwar)) {
-      final Enumeration<? extends ZipEntry> e = zf.entries();
-      while (e.hasMoreElements()) {
-        final ZipEntry ze = e.nextElement();
-        final String name = ze.getName();
-
-        if (ze.isDirectory()
-            || name.startsWith("WEB-INF/")
-            || name.startsWith("META-INF/")
-            || name.startsWith("com/google/gerrit/launcher/")
-            || name.equals("Main.class")) {
-          continue;
-        }
-
-        final File rawtmp = new File(dstwar, name);
-        mkdir(rawtmp.getParentFile());
-        rawtmp.deleteOnExit();
-
-        try (OutputStream rawout = Files.newOutputStream(rawtmp.toPath());
-            InputStream in = zf.getInputStream(ze)) {
-          final byte[] buf = new byte[4096];
-          int n;
-          while ((n = in.read(buf, 0, buf.length)) > 0) {
-            rawout.write(buf, 0, n);
-          }
-        }
-      }
-    }
-  }
-
-  private static void mkdir(File dir) throws IOException {
-    if (!dir.isDirectory()) {
-      mkdir(dir.getParentFile());
-      if (!dir.mkdir()) {
-        throw new IOException("Cannot mkdir " + dir.getAbsolutePath());
-      }
-      dir.deleteOnExit();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 64b5bbb..8be4abc 100644
--- a/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.httpd.raw;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.net.HttpHeaders.CONTENT_ENCODING;
 import static com.google.common.net.HttpHeaders.ETAG;
 import static com.google.common.net.HttpHeaders.IF_MODIFIED_SINCE;
 import static com.google.common.net.HttpHeaders.IF_NONE_MATCH;
 import static com.google.common.net.HttpHeaders.LAST_MODIFIED;
+import static java.util.Objects.requireNonNull;
 import static java.util.concurrent.TimeUnit.DAYS;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
@@ -33,9 +33,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.util.http.CacheHeaders;
-import com.google.gwtjsonrpc.server.RPCServletUtils;
+import com.google.gerrit.util.http.RequestUtil;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.nio.file.Files;
@@ -110,7 +111,7 @@
       boolean refresh,
       boolean cacheOnClient,
       int cacheFileSizeLimitBytes) {
-    this.cache = checkNotNull(cache, "cache");
+    this.cache = requireNonNull(cache, "cache");
     this.refresh = refresh;
     this.cacheOnClient = cacheOnClient;
     this.cacheFileSizeLimitBytes = cacheFileSizeLimitBytes;
@@ -181,7 +182,7 @@
     }
 
     byte[] tosend = r.raw;
-    if (!r.contentType.equals(JS) && RPCServletUtils.acceptsGzipEncoding(req)) {
+    if (!r.contentType.equals(JS) && RequestUtil.acceptsGzipEncoding(req)) {
       byte[] gz = HtmlDomUtil.compress(tosend);
       if ((gz.length + 24) < tosend.length) {
         rsp.setHeader(CONTENT_ENCODING, "gzip");
@@ -266,7 +267,7 @@
 
     OutputStream out = rsp.getOutputStream();
     GZIPOutputStream gz = null;
-    if (RPCServletUtils.acceptsGzipEncoding(req)) {
+    if (RequestUtil.acceptsGzipEncoding(req)) {
       rsp.setHeader(CONTENT_ENCODING, "gzip");
       gz = new GZIPOutputStream(out);
       out = gz;
@@ -307,9 +308,9 @@
     final byte[] raw;
 
     Resource(FileTime lastModified, String contentType, byte[] raw) {
-      this.lastModified = checkNotNull(lastModified, "lastModified");
-      this.contentType = checkNotNull(contentType, "contentType");
-      this.raw = checkNotNull(raw, "raw");
+      this.lastModified = requireNonNull(lastModified, "lastModified");
+      this.contentType = requireNonNull(contentType, "contentType");
+      this.raw = requireNonNull(raw, "raw");
       this.etag = Hashing.murmur3_128().hashBytes(raw).toString();
     }
 
@@ -324,6 +325,7 @@
     }
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static class Weigher implements com.google.common.cache.Weigher<Path, Resource> {
     @Override
     public int weigh(Path p, Resource r) {
diff --git a/java/com/google/gerrit/httpd/raw/SshInfoServlet.java b/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
index 1d1fe6cc..1605360 100644
--- a/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
+++ b/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
@@ -46,9 +46,10 @@
  *  Port 8010
  * }</pre>
  */
-@SuppressWarnings("serial")
 @Singleton
 public class SshInfoServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final SshInfo sshd;
 
   @Inject
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index e90e372..0d4c67e 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -22,7 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.UiType;
+import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.httpd.XsrfCookieFilter;
 import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
 import com.google.gerrit.launcher.GerritLauncher;
@@ -42,7 +42,6 @@
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
-import java.net.URISyntaxException;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
 import javax.servlet.Filter;
@@ -51,7 +50,6 @@
 import javax.servlet.ServletException;
 import javax.servlet.ServletRequest;
 import javax.servlet.ServletResponse;
-import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletRequestWrapper;
@@ -62,7 +60,6 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static final String CACHE = "static_content";
-  public static final String GERRIT_UI_COOKIE = "GERRIT_UI";
 
   /**
    * Paths at which we should serve the main PolyGerrit application {@code index.html}.
@@ -71,12 +68,16 @@
    */
   public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
       ImmutableList.of(
-          "/", "/c/*", "/p/*", "/q/*", "/x/*", "/admin/*", "/dashboard/*", "/settings/*");
-  // TODO(dborowitz): These fragments conflict with the REST API
-  // namespace, so they will need to use a different path.
-  // "/groups/*",
-  // "/projects/*");
-  //
+          "/",
+          "/c/*",
+          "/p/*",
+          "/q/*",
+          "/x/*",
+          "/admin/*",
+          "/dashboard/*",
+          "/groups/self",
+          "/settings/*",
+          "/Documentation/q/*");
 
   /**
    * Paths that should be treated as static assets when serving PolyGerrit.
@@ -94,12 +95,9 @@
 
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
-  private static final String GWT_UI_SERVLET = "GwtUiServlet";
   private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet";
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
-  private static final int GERRIT_UI_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
-
   private final GerritOptions options;
   private Paths paths;
 
@@ -119,6 +117,8 @@
 
   @Override
   protected void configureServlets() {
+    serveRegex("^/Documentation$").with(named(DOC_SERVLET));
+    serveRegex("^/Documentation/$").with(named(DOC_SERVLET));
     serveRegex("^/Documentation/(.+)$").with(named(DOC_SERVLET));
     serve("/static/*").with(SiteStaticDirectoryServlet.class);
     install(
@@ -132,12 +132,7 @@
         });
     if (!options.headless()) {
       install(new CoreStaticModule());
-    }
-
-    install(new PolyGerritModule());
-
-    if (options.enableGwtUi()) {
-      install(new GwtUiModule());
+      install(new PolyGerritModule());
     }
   }
 
@@ -211,38 +206,11 @@
     }
   }
 
-  private class GwtUiModule extends ServletModule {
-    @Override
-    public void configureServlets() {
-      serveRegex("^/gerrit_ui/(?!rpc/)(.*)$")
-          .with(Key.get(HttpServlet.class, Names.named(GWT_UI_SERVLET)));
-      Paths p = getPaths();
-      if (p.isDev()) {
-        filter("/").through(new RecompileGwtUiFilter(p.builder, p.unpackedWar));
-      }
-    }
-
-    @Provides
-    @Singleton
-    @Named(GWT_UI_SERVLET)
-    HttpServlet getGwtUiServlet(@Named(CACHE) Cache<Path, Resource> cache) throws IOException {
-      Paths p = getPaths();
-      if (p.warFs != null) {
-        return new WarGwtUiServlet(cache, p.warFs);
-      }
-      return new DirectoryGwtUiServlet(cache, p.unpackedWar, p.isDev());
-    }
-  }
-
   private class PolyGerritModule extends ServletModule {
     @Override
     public void configureServlets() {
       for (String p : POLYGERRIT_INDEX_PATHS) {
-        // Skip XsrfCookieFilter for /, since that is already done in the GWT UI
-        // path (UrlModule).
-        if (!p.equals("/")) {
-          filter(p).through(XsrfCookieFilter.class);
-        }
+        filter(p).through(XsrfCookieFilter.class);
       }
       filter("/*").through(PolyGerritFilter.class);
     }
@@ -251,11 +219,12 @@
     @Singleton
     @Named(POLYGERRIT_INDEX_SERVLET)
     HttpServlet getPolyGerritUiIndexServlet(
-        @CanonicalWebUrl @Nullable String canonicalUrl, @GerritServerConfig Config cfg)
-        throws URISyntaxException {
+        @CanonicalWebUrl @Nullable String canonicalUrl,
+        @GerritServerConfig Config cfg,
+        GerritApi gerritApi) {
       String cdnPath = cfg.getString("gerrit", null, "cdnPath");
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
-      return new IndexServlet(canonicalUrl, cdnPath, faviconPath);
+      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi);
     }
 
     @Provides
@@ -365,9 +334,7 @@
             && GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
           return null;
         }
-        ProvisionException pe = new ProvisionException("Error reading gerrit.war");
-        pe.initCause(e);
-        throw pe;
+        throw new ProvisionException("Error reading gerrit.war", e);
       }
       return war;
     }
@@ -395,9 +362,7 @@
           return dstwar.getAbsoluteFile().toPath();
         }
       } catch (IOException e) {
-        ProvisionException pe = new ProvisionException("Cannot create war tempdir");
-        pe.initCause(e);
-        throw pe;
+        throw new ProvisionException("Cannot create war tempdir", e);
       }
     }
   }
@@ -408,7 +373,6 @@
 
   @Singleton
   private static class PolyGerritFilter implements Filter {
-    private final GerritOptions options;
     private final Paths paths;
     private final HttpServlet polyGerritIndex;
     private final PolyGerritUiServlet polygerritUI;
@@ -417,14 +381,12 @@
 
     @Inject
     PolyGerritFilter(
-        GerritOptions options,
         Paths paths,
         @Named(POLYGERRIT_INDEX_SERVLET) HttpServlet polyGerritIndex,
         PolyGerritUiServlet polygerritUI,
         @Nullable BowerComponentsDevServlet bowerComponentServlet,
         @Nullable FontsDevServlet fontServlet) {
       this.paths = paths;
-      this.options = options;
       this.polyGerritIndex = polyGerritIndex;
       this.polygerritUI = polygerritUI;
       this.bowerComponentServlet = bowerComponentServlet;
@@ -442,13 +404,6 @@
         throws IOException, ServletException {
       HttpServletRequest req = (HttpServletRequest) request;
       HttpServletResponse res = (HttpServletResponse) response;
-      if (handlePolyGerritParam(req, res)) {
-        return;
-      }
-      if (!isPolyGerritEnabled(req)) {
-        chain.doFilter(req, res);
-        return;
-      }
 
       GuiceFilterRequestWrapper reqWrapper = new GuiceFilterRequestWrapper(req);
       String path = pathInfo(req);
@@ -456,7 +411,7 @@
       // Special case assets during development that are built by Bazel and not
       // served out of the source tree.
       //
-      // In the war case, these are either inlined by vulcanize, or live under
+      // In the war case, these are either inlined, or live under
       // /polygerrit_ui in the war file, so we can just treat them as normal
       // assets.
       if (paths.isDev()) {
@@ -487,71 +442,6 @@
       return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
     }
 
-    private boolean handlePolyGerritParam(HttpServletRequest req, HttpServletResponse res)
-        throws IOException {
-      if (!options.enableGwtUi() || !"GET".equals(req.getMethod())) {
-        return false;
-      }
-      boolean redirect = false;
-      String param = req.getParameter("polygerrit");
-      if ("1".equals(param)) {
-        setPolyGerritCookie(req, res, UiType.POLYGERRIT);
-        redirect = true;
-      } else if ("0".equals(param)) {
-        setPolyGerritCookie(req, res, UiType.GWT);
-        redirect = true;
-      }
-      if (redirect) {
-        // Strip polygerrit param from URL. This actually strips all params,
-        // which is a similar behavior to the JS PolyGerrit redirector code.
-        // Stripping just one param is frustratingly difficult without the use
-        // of Apache httpclient, which is a dep we don't want here:
-        // https://gerrit-review.googlesource.com/#/c/57570/57/gerrit-httpd/BUCK@32
-        res.sendRedirect(req.getRequestURL().toString());
-      }
-      return redirect;
-    }
-
-    private boolean isPolyGerritEnabled(HttpServletRequest req) {
-      return !options.enableGwtUi() || isPolyGerritCookie(req);
-    }
-
-    private boolean isPolyGerritCookie(HttpServletRequest req) {
-      UiType type = UiType.POLYGERRIT;
-      Cookie[] all = req.getCookies();
-      if (all != null) {
-        for (Cookie c : all) {
-          if (GERRIT_UI_COOKIE.equals(c.getName())) {
-            UiType t = UiType.parse(c.getValue());
-            if (t != null) {
-              type = t;
-              break;
-            }
-          }
-        }
-      }
-      return type == UiType.POLYGERRIT;
-    }
-
-    private void setPolyGerritCookie(HttpServletRequest req, HttpServletResponse res, UiType pref) {
-      // Only actually set a cookie if GWT UI is enabled in addition to default PG UI;
-      // otherwise clear it.
-      Cookie cookie = new Cookie(GERRIT_UI_COOKIE, pref.name());
-      if (options.enableGwtUi()) {
-        cookie.setPath("/");
-        cookie.setSecure(isSecure(req));
-        cookie.setMaxAge(GERRIT_UI_COOKIE_MAX_AGE);
-      } else {
-        cookie.setValue("");
-        cookie.setMaxAge(0);
-      }
-      res.addCookie(cookie);
-    }
-
-    private static boolean isSecure(HttpServletRequest req) {
-      return req.isSecure() || "https".equals(req.getScheme());
-    }
-
     private static boolean isPolyGerritAsset(String path) {
       return matchPath(POLYGERRIT_ASSET_PATHS, path);
     }
diff --git a/java/com/google/gerrit/httpd/raw/ThemeFactory.java b/java/com/google/gerrit/httpd/raw/ThemeFactory.java
deleted file mode 100644
index 6a75e07..0000000
--- a/java/com/google/gerrit/httpd/raw/ThemeFactory.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import com.google.gerrit.common.data.HostPageData;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.Config;
-
-class ThemeFactory {
-  private final Config cfg;
-
-  @Inject
-  ThemeFactory(@GerritServerConfig Config cfg) {
-    this.cfg = cfg;
-  }
-
-  HostPageData.Theme getSignedOutTheme() {
-    return getTheme("signed-out");
-  }
-
-  HostPageData.Theme getSignedInTheme() {
-    return getTheme("signed-in");
-  }
-
-  private HostPageData.Theme getTheme(String name) {
-    HostPageData.Theme theme = new HostPageData.Theme();
-    theme.backgroundColor = color(name, "backgroundColor", "#FFFFFF");
-    theme.textColor = color(name, "textColor", "#353535");
-    theme.trimColor = color(name, "trimColor", "#EEEEEE");
-    theme.selectionColor = color(name, "selectionColor", "#D8EDF9");
-    theme.topMenuColor = color(name, "topMenuColor", "#FFFFFF");
-    theme.changeTableOutdatedColor = color(name, "changeTableOutdatedColor", "#F08080");
-    theme.tableOddRowColor = color(name, "tableOddRowColor", "transparent");
-    theme.tableEvenRowColor = color(name, "tableEvenRowColor", "transparent");
-    return theme;
-  }
-
-  private String color(String section, String name, String defaultValue) {
-    String v = cfg.getString("theme", section, name);
-    if (v == null || v.isEmpty()) {
-      v = cfg.getString("theme", null, name);
-      if (v == null || v.isEmpty()) {
-        v = defaultValue;
-      }
-    }
-    if (!v.startsWith("#") && v.matches("^[0-9a-fA-F]{2,6}$")) {
-      v = "#" + v;
-    }
-    return v;
-  }
-}
diff --git a/java/com/google/gerrit/httpd/raw/ToolServlet.java b/java/com/google/gerrit/httpd/raw/ToolServlet.java
index fcdd21d..0d707a6 100644
--- a/java/com/google/gerrit/httpd/raw/ToolServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ToolServlet.java
@@ -25,8 +25,7 @@
 
 import com.google.gerrit.common.Version;
 import com.google.gerrit.server.tools.ToolsCatalog;
-import com.google.gerrit.server.tools.ToolsCatalog.Entry;
-import com.google.gwtjsonrpc.server.RPCServletUtils;
+import com.google.gerrit.util.http.RequestUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -50,7 +49,7 @@
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    Entry ent = toc.get(req.getPathInfo());
+    ToolsCatalog.Entry ent = toc.get(req.getPathInfo());
     if (ent == null) {
       rsp.sendError(SC_NOT_FOUND);
       return;
@@ -71,7 +70,7 @@
     }
   }
 
-  private void doGetFile(Entry ent, HttpServletResponse rsp) throws IOException {
+  private void doGetFile(ToolsCatalog.Entry ent, HttpServletResponse rsp) throws IOException {
     byte[] tosend = ent.getBytes();
 
     rsp.setDateHeader(HDR_EXPIRES, 0L);
@@ -84,8 +83,8 @@
     }
   }
 
-  private void doGetDirectory(Entry ent, HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException {
+  private void doGetDirectory(
+      ToolsCatalog.Entry ent, HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     String path = "/tools/" + ent.getPath();
     Document page = newDocument();
 
@@ -108,9 +107,9 @@
     Element ul = page.createElement("ul");
     body.appendChild(ul);
 
-    for (Entry e : ent.getChildren()) {
+    for (ToolsCatalog.Entry e : ent.getChildren()) {
       String name = e.getName();
-      if (e.getType() == Entry.Type.DIR && !name.endsWith("/")) {
+      if (e.getType() == ToolsCatalog.Entry.Type.DIR && !name.endsWith("/")) {
         name += "/";
       }
 
@@ -130,7 +129,7 @@
     body.appendChild(footer);
 
     byte[] tosend = toUTF8(page);
-    if (RPCServletUtils.acceptsGzipEncoding(req)) {
+    if (RequestUtil.acceptsGzipEncoding(req)) {
       rsp.setHeader("Content-Encoding", "gzip");
       tosend = compress(tosend);
     }
diff --git a/java/com/google/gerrit/httpd/raw/UserAgentRule.java b/java/com/google/gerrit/httpd/raw/UserAgentRule.java
deleted file mode 100644
index 4aac243..0000000
--- a/java/com/google/gerrit/httpd/raw/UserAgentRule.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import static java.util.regex.Pattern.compile;
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import javax.servlet.http.HttpServletRequest;
-
-/**
- * Selects the value for the {@code user.agent} property.
- *
- * <p>Examines the {@code User-Agent} HTTP request header, and tries to match it to known {@code
- * user.agent} values.
- *
- * <p>Ported from JavaScript in {@code com.google.gwt.user.UserAgent.gwt.xml}.
- */
-class UserAgentRule {
-  private static final Pattern msie = compile(".*msie ([0-11]+)\\.([0-11]+).*");
-  private static final Pattern gecko = compile(".*rv:([0-9]+)\\.([0-9]+).*");
-
-  String getName() {
-    return "user.agent";
-  }
-
-  String select(HttpServletRequest req) {
-    String ua = req.getHeader("User-Agent");
-    if (ua == null) {
-      return null;
-    }
-
-    ua = ua.toLowerCase();
-
-    if (ua.contains("opera")) {
-      return "opera";
-
-    } else if (ua.contains("webkit")) {
-      return "safari";
-
-    } else if (ua.contains("msie")) {
-      // GWT 2.0 uses document.documentMode here, which we can't do
-      // on the server side.
-
-      Matcher m = msie.matcher(ua);
-      if (m.matches() && m.groupCount() == 2) {
-        int v = makeVersion(m);
-        if (v >= 11000) {
-          return "ie11";
-        }
-        if (v >= 10000) {
-          return "ie10";
-        }
-        if (v >= 9000) {
-          return "ie9";
-        }
-        if (v >= 8000) {
-          return "ie8";
-        }
-      }
-      return null;
-
-    } else if (ua.contains("edge")) {
-      return "edge";
-    } else if (ua.contains("gecko")) {
-      Matcher m = gecko.matcher(ua);
-      if (m.matches() && m.groupCount() == 2) {
-        if (makeVersion(m) >= 1008) {
-          return "gecko1_8";
-        }
-      }
-      return "gecko";
-    }
-
-    return null;
-  }
-
-  private int makeVersion(Matcher result) {
-    return (Integer.parseInt(result.group(1)) * 1000) + Integer.parseInt(result.group(2));
-  }
-}
diff --git a/java/com/google/gerrit/httpd/raw/WarDocServlet.java b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
index 3f6ff25..27520e3 100644
--- a/java/com/google/gerrit/httpd/raw/WarDocServlet.java
+++ b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
 import java.nio.file.attribute.FileTime;
diff --git a/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java b/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java
deleted file mode 100644
index ff27965..0000000
--- a/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.common.TimeUtil;
-import java.nio.file.FileSystem;
-import java.nio.file.Path;
-import java.nio.file.attribute.FileTime;
-
-class WarGwtUiServlet extends ResourceServlet {
-  private static final long serialVersionUID = 1L;
-
-  private static final FileTime NOW = FileTime.fromMillis(TimeUtil.nowMs());
-
-  private final FileSystem warFs;
-
-  WarGwtUiServlet(Cache<Path, Resource> cache, FileSystem warFs) {
-    super(cache, false);
-    this.warFs = warFs;
-  }
-
-  @Override
-  protected Path getResourcePath(String pathInfo) {
-    return warFs.getPath("/gerrit_ui/" + pathInfo);
-  }
-
-  @Override
-  protected FileTime getLastModifiedTime(Path p) {
-    // Return initialization time of this class, since the GWT outputs from the
-    // build process all have mtimes of 1980/1/1.
-    return NOW;
-  }
-}
diff --git a/java/com/google/gerrit/httpd/restapi/LogRedactUtil.java b/java/com/google/gerrit/httpd/restapi/LogRedactUtil.java
new file mode 100644
index 0000000..5a37b7b
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/LogRedactUtil.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.restapi;
+
+import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.restapi.Url;
+import java.util.Iterator;
+
+public class LogRedactUtil {
+  private static final ImmutableSet<String> REDACT_PARAM = ImmutableSet.of(XD_AUTHORIZATION);
+
+  private LogRedactUtil() {}
+
+  /**
+   * Redacts sensitive information such as an access token from the query string to make it suitable
+   * for logging.
+   */
+  @VisibleForTesting
+  public static String redactQueryString(String qs) {
+    StringBuilder b = new StringBuilder();
+    for (String kvPair : Splitter.on('&').split(qs)) {
+      Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
+      String key = i.next();
+      if (b.length() > 0) {
+        b.append('&');
+      }
+      b.append(key);
+      if (i.hasNext()) {
+        b.append('=');
+        if (REDACT_PARAM.contains(Url.decode(key))) {
+          b.append('*');
+        } else {
+          b.append(i.next());
+        }
+      }
+    }
+    return b.toString();
+  }
+}
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..fc099a6 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;
@@ -24,6 +25,7 @@
 import com.google.gerrit.metrics.Histogram1;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -40,19 +42,24 @@
 
   @Inject
   RestApiMetrics(MetricMaker metrics) {
-    Field<String> view = Field.ofString("view", "view implementation class");
+    Field<String> viewField =
+        Field.ofString("view", Metadata.Builder::className)
+            .description("view implementation class")
+            .build();
     count =
         metrics.newCounter(
             "http/server/rest_api/count",
             new Description("REST API calls by view").setRate(),
-            view);
+            viewField);
 
     errorCount =
         metrics.newCounter(
             "http/server/rest_api/error_count",
             new Description("REST API errors by view").setRate(),
-            view,
-            Field.ofInteger("error_code", "HTTP status code"));
+            viewField,
+            Field.ofInteger("error_code", Metadata.Builder::httpStatus)
+                .description("HTTP status code")
+                .build());
 
     serverLatency =
         metrics.newTimer(
@@ -60,7 +67,7 @@
             new Description("REST API call latency by view")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            view);
+            viewField);
 
     responseBytes =
         metrics.newHistogram(
@@ -68,7 +75,7 @@
             new Description("Size of response on network (may be gzip compressed)")
                 .setCumulative()
                 .setUnit(Units.BYTES),
-            view);
+            viewField);
   }
 
   String view(ViewData viewData) {
@@ -79,7 +86,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/RestApiQuotaEnforcer.java b/java/com/google/gerrit/httpd/restapi/RestApiQuotaEnforcer.java
new file mode 100644
index 0000000..a3aba6d
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/RestApiQuotaEnforcer.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.quota.QuotaBackend;
+import com.google.gerrit.server.quota.QuotaException;
+import com.google.gerrit.util.http.RequestUtil;
+import com.google.inject.Inject;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Enforces quota on specific REST API endpoints.
+ *
+ * <p>Examples:
+ *
+ * <ul>
+ *   <li>GET /a/accounts/self/detail => /restapi/accounts/detail:GET
+ *   <li>GET /changes/123/revisions/current/detail => /restapi/changes/revisions/detail:GET
+ *   <li>PUT /changes/10/reviewed => /restapi/changes/reviewed:PUT
+ * </ul>
+ *
+ * <p>Adds context (change, project, account) to the quota check if the call is for an existing
+ * entity that was successfully parsed. This quota check is generally enforced after the resource
+ * was parsed, but before the view is executed. If a quota enforcer desires to throttle earlier,
+ * they should consider quota groups in the {@code /http/*} space.
+ */
+public class RestApiQuotaEnforcer {
+  private final QuotaBackend quotaBackend;
+
+  @Inject
+  RestApiQuotaEnforcer(QuotaBackend quotaBackend) {
+    this.quotaBackend = quotaBackend;
+  }
+
+  /** Enforce quota on a request not tied to any {@code RestResource}. */
+  void enforce(HttpServletRequest req) throws QuotaException {
+    String pathForQuotaReporting = RequestUtil.getRestPathWithoutIds(req);
+    quotaBackend
+        .currentUser()
+        .requestToken(quotaGroup(pathForQuotaReporting, req.getMethod()))
+        .throwOnError();
+  }
+
+  /** Enforce quota on a request for a given resource. */
+  void enforce(RestResource rsrc, HttpServletRequest req) throws QuotaException {
+    String pathForQuotaReporting = RequestUtil.getRestPathWithoutIds(req);
+    // Enrich the quota request we are operating on an interesting collection
+    QuotaBackend.WithResource report = quotaBackend.currentUser();
+    if (rsrc instanceof ChangeResource) {
+      ChangeResource changeResource = (ChangeResource) rsrc;
+      report =
+          quotaBackend.currentUser().change(changeResource.getId(), changeResource.getProject());
+    } else if (rsrc instanceof AccountResource) {
+      AccountResource accountResource = (AccountResource) rsrc;
+      report = quotaBackend.currentUser().account(accountResource.getUser().getAccountId());
+    } else if (rsrc instanceof ProjectResource) {
+      ProjectResource projectResource = (ProjectResource) rsrc;
+      report = quotaBackend.currentUser().project(projectResource.getNameKey());
+    }
+
+    report.requestToken(quotaGroup(pathForQuotaReporting, req.getMethod())).throwOnError();
+  }
+
+  private static String quotaGroup(String path, String method) {
+    return "/restapi" + path + ":" + method;
+  }
+}
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 546bb9f..66768ec 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -16,8 +16,8 @@
 package com.google.gerrit.httpd.restapi;
 
 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;
@@ -32,22 +32,21 @@
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
-import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
-import static javax.servlet.http.HttpServletResponse.SC_CREATED;
 import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
-import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 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;
@@ -65,12 +64,10 @@
 import com.google.common.net.HttpHeaders;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AcceptsDelete;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -88,28 +85,46 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.extensions.restapi.RestCollectionDeleteMissingView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OptionUtil;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.audit.AuditService;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
 import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.logging.PerformanceLogContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
+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.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.quota.QuotaException;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gerrit.util.http.RequestUtil;
 import com.google.gson.ExclusionStrategy;
@@ -130,6 +145,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;
@@ -176,6 +192,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;
@@ -214,27 +232,41 @@
     final Provider<CurrentUser> currentUser;
     final DynamicItem<WebSession> webSession;
     final Provider<ParameterParser> paramParser;
+    final PluginSetContext<RequestListener> requestListeners;
     final PermissionBackend permissionBackend;
-    final AuditService auditService;
+    final GroupAuditService auditService;
     final RestApiMetrics metrics;
     final Pattern allowOrigin;
+    final RestApiQuotaEnforcer quotaChecker;
+    final Config config;
+    final DynamicSet<PerformanceLogger> performanceLoggers;
+    final ChangeFinder changeFinder;
 
     @Inject
     Globals(
         Provider<CurrentUser> currentUser,
         DynamicItem<WebSession> webSession,
         Provider<ParameterParser> paramParser,
+        PluginSetContext<RequestListener> requestListeners,
         PermissionBackend permissionBackend,
-        AuditService auditService,
+        GroupAuditService auditService,
         RestApiMetrics metrics,
-        @GerritServerConfig Config cfg) {
+        RestApiQuotaEnforcer quotaChecker,
+        @GerritServerConfig Config config,
+        DynamicSet<PerformanceLogger> performanceLoggers,
+        ChangeFinder changeFinder) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
+      this.requestListeners = requestListeners;
       this.permissionBackend = permissionBackend;
       this.auditService = auditService;
       this.metrics = metrics;
-      allowOrigin = makeAllowOrigin(cfg);
+      this.quotaChecker = quotaChecker;
+      this.config = config;
+      this.performanceLoggers = performanceLoggers;
+      this.changeFinder = changeFinder;
+      allowOrigin = makeAllowOrigin(config);
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -259,7 +291,7 @@
       Provider<? extends RestCollection<? extends RestResource, ? extends RestResource>> members) {
     @SuppressWarnings("unchecked")
     Provider<RestCollection<RestResource, RestResource>> n =
-        (Provider<RestCollection<RestResource, RestResource>>) checkNotNull((Object) members);
+        (Provider<RestCollection<RestResource, RestResource>>) requireNonNull((Object) members);
     this.globals = globals;
     this.members = n;
   }
@@ -273,257 +305,371 @@
     res.setHeader("X-Content-Type-Options", "nosniff");
     int status = SC_OK;
     long responseBytes = -1;
-    Object result = null;
+    Response<?> response = null;
     QueryParams qp = null;
     Object inputRequestBody = null;
     RestResource rsrc = TopLevelResource.INSTANCE;
     ViewData viewData = null;
 
-    try (PerThreadCache ignored = PerThreadCache.create()) {
-      if (isCorsPreflight(req)) {
-        doCorsPreflight(req, res);
-        return;
-      }
-
-      qp = ParameterParser.getQueryParams(req);
-      checkCors(req, res, qp.hasXdOverride());
-      if (qp.hasXdOverride()) {
-        req = applyXdOverrides(req, qp);
-      }
-      checkUserSession(req);
-
+    try (TraceContext traceContext = enableTracing(req, res)) {
       List<IdString> path = splitPath(req);
-      RestCollection<RestResource, RestResource> rc = members.get();
-      globals
-          .permissionBackend
-          .currentUser()
-          .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
 
-      viewData = new ViewData(null, null);
+      RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
+      globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
-      if (path.isEmpty()) {
-        if (rc instanceof NeedsParams) {
-          ((NeedsParams) rc).setParams(qp.params());
-        }
+      try (PerThreadCache ignored = PerThreadCache.create()) {
+        // It's important that the PerformanceLogContext is closed before the response is sent to
+        // the client. Only this way it is ensured that the invocation of the PerformanceLogger
+        // plugins happens before the client sees the response. This is needed for being able to
+        // test performance logging from an acceptance test (see
+        // TraceIT#performanceLoggingForRestCall()).
+        try (PerformanceLogContext performanceLogContext =
+            new PerformanceLogContext(globals.config, globals.performanceLoggers)) {
+          logger.atFinest().log(
+              "Received REST request: %s %s (parameters: %s)",
+              req.getMethod(), req.getRequestURI(), getParameterNames(req));
+          logger.atFinest().log(
+              "Calling user: %s (groups = %s)",
+              globals.currentUser.get().getLoggableName(),
+              globals.currentUser.get().getEffectiveGroups().getKnownGroups());
 
-        if (isRead(req)) {
-          viewData = new ViewData(null, rc.list());
-        } else if (rc instanceof AcceptsPost && isPost(req)) {
-          @SuppressWarnings("unchecked")
-          AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) rc;
-          viewData = new ViewData(null, ac.post(rsrc));
-        } else {
-          throw new MethodNotAllowedException();
-        }
-      } else {
-        IdString id = path.remove(0);
-        try {
-          rsrc = rc.parse(rsrc, id);
-          if (path.isEmpty()) {
-            checkPreconditions(req);
+          if (isCorsPreflight(req)) {
+            doCorsPreflight(req, res);
+            return;
           }
-        } catch (ResourceNotFoundException e) {
-          if (rc instanceof AcceptsCreate && path.isEmpty() && (isPost(req) || isPut(req))) {
-            @SuppressWarnings("unchecked")
-            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) rc;
-            viewData = new ViewData(null, ac.create(rsrc, id));
-            status = SC_CREATED;
-          } else {
-            throw e;
-          }
-        }
-        if (viewData.view == null) {
-          viewData = view(rsrc, rc, req.getMethod(), path);
-        }
-      }
-      checkRequiresCapability(viewData);
 
-      while (viewData.view instanceof RestCollection<?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollection<RestResource, RestResource> c =
-            (RestCollection<RestResource, RestResource>) viewData.view;
-
-        if (path.isEmpty()) {
-          if (isRead(req)) {
-            viewData = new ViewData(null, c.list());
-          } else if (c instanceof AcceptsPost && isPost(req)) {
-            @SuppressWarnings("unchecked")
-            AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) c;
-            viewData = new ViewData(null, ac.post(rsrc));
-          } else if (c instanceof AcceptsDelete && isDelete(req)) {
-            @SuppressWarnings("unchecked")
-            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
-            viewData = new ViewData(null, ac.delete(rsrc, null));
-          } else {
-            throw new MethodNotAllowedException();
+          qp = ParameterParser.getQueryParams(req);
+          checkCors(req, res, qp.hasXdOverride());
+          if (qp.hasXdOverride()) {
+            req = applyXdOverrides(req, qp);
           }
-          break;
-        }
-        IdString id = path.remove(0);
-        try {
-          rsrc = c.parse(rsrc, id);
-          checkPreconditions(req);
+          checkUserSession(req);
+
+          RestCollection<RestResource, RestResource> rc = members.get();
+          globals
+              .permissionBackend
+              .currentUser()
+              .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
+
           viewData = new ViewData(null, null);
-        } catch (ResourceNotFoundException e) {
-          if (c instanceof AcceptsCreate && path.isEmpty() && (isPost(req) || isPut(req))) {
-            @SuppressWarnings("unchecked")
-            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) c;
-            viewData = new ViewData(viewData.pluginName, ac.create(rsrc, id));
-            status = SC_CREATED;
-          } else if (c instanceof AcceptsDelete && path.isEmpty() && isDelete(req)) {
-            @SuppressWarnings("unchecked")
-            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
-            viewData = new ViewData(viewData.pluginName, ac.delete(rsrc, id));
-            status = SC_NO_CONTENT;
+
+          if (path.isEmpty()) {
+            globals.quotaChecker.enforce(req);
+            if (rc instanceof NeedsParams) {
+              ((NeedsParams) rc).setParams(qp.params());
+            }
+
+            if (isRead(req)) {
+              viewData = new ViewData(null, rc.list());
+            } else if (isPost(req)) {
+              RestView<RestResource> restCollectionView =
+                  rc.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
+              if (restCollectionView != null) {
+                viewData = new ViewData(null, restCollectionView);
+              } else {
+                throw methodNotAllowed(req);
+              }
+            } else {
+              // DELETE on root collections is not supported
+              throw methodNotAllowed(req);
+            }
           } else {
-            throw e;
+            IdString id = path.remove(0);
+            try {
+              rsrc = rc.parse(rsrc, id);
+              globals.quotaChecker.enforce(rsrc, req);
+              if (path.isEmpty()) {
+                checkPreconditions(req);
+              }
+            } catch (ResourceNotFoundException e) {
+              if (!path.isEmpty()) {
+                throw e;
+              }
+              globals.quotaChecker.enforce(req);
+
+              if (isPost(req) || isPut(req)) {
+                RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
+                if (createView != null) {
+                  viewData = new ViewData(null, createView);
+                  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);
+                  path.add(id);
+                } else {
+                  throw e;
+                }
+              } else {
+                throw e;
+              }
+            }
+            if (viewData.view == null) {
+              viewData = view(rc, req.getMethod(), path);
+            }
+          }
+          checkRequiresCapability(viewData);
+
+          while (viewData.view instanceof RestCollection<?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollection<RestResource, RestResource> c =
+                (RestCollection<RestResource, RestResource>) viewData.view;
+
+            if (path.isEmpty()) {
+              if (isRead(req)) {
+                viewData = new ViewData(null, c.list());
+              } else if (isPost(req)) {
+                // TODO: Here and on other collection methods: There is a bug that binds child views
+                // with pluginName="gerrit" instead of the real plugin name. This has never worked
+                // correctly and should be fixed where the binding gets created (DynamicMapProvider)
+                // and here.
+                RestView<RestResource> restCollectionView =
+                    c.views().get(PluginName.GERRIT, "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(PluginName.GERRIT, "DELETE_ON_COLLECTION./");
+                if (restCollectionView != null) {
+                  viewData = new ViewData(null, restCollectionView);
+                } else {
+                  throw methodNotAllowed(req);
+                }
+              } else {
+                throw methodNotAllowed(req);
+              }
+              break;
+            }
+            IdString id = path.remove(0);
+            try {
+              rsrc = c.parse(rsrc, id);
+              checkPreconditions(req);
+              viewData = new ViewData(null, null);
+            } catch (ResourceNotFoundException e) {
+              if (!path.isEmpty()) {
+                throw e;
+              }
+
+              if (isPost(req) || isPut(req)) {
+                RestView<RestResource> createView = c.views().get(PluginName.GERRIT, "CREATE./");
+                if (createView != null) {
+                  viewData = new ViewData(null, createView);
+                  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);
+                  path.add(id);
+                } else {
+                  throw e;
+                }
+              } else {
+                throw e;
+              }
+            }
+            if (viewData.view == null) {
+              viewData = view(c, req.getMethod(), path);
+            }
+            checkRequiresCapability(viewData);
+          }
+
+          if (notModified(req, rsrc, viewData.view)) {
+            logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
+            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)) {
+            response = ((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);
+            response = m.apply(rsrc, inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
+          } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollectionCreateView<RestResource, RestResource, Object> m =
+                (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            response = m.apply(rsrc, path.get(0), inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
+          } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+                (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
+
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            response = 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);
+            response = m.apply(rsrc, inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
+          } else {
+            throw new ResourceNotFoundException();
+          }
+
+          if (response instanceof Response.Redirect) {
+            CacheHeaders.setNotCacheable(res);
+            String location = ((Response.Redirect) response).location();
+            res.sendRedirect(location);
+            logger.atFinest().log("REST call redirected to: %s", location);
+            return;
+          } else if (response instanceof Response.Accepted) {
+            CacheHeaders.setNotCacheable(res);
+            res.setStatus(response.statusCode());
+            res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
+            logger.atFinest().log("REST call succeeded: %d", response.statusCode());
+            return;
+          }
+
+          status = response.statusCode();
+          configureCaching(req, res, rsrc, viewData.view, response.caching());
+          res.setStatus(status);
+          logger.atFinest().log("REST call succeeded: %d", status);
+        }
+
+        if (response != Response.none()) {
+          Object value = Response.unwrap(response);
+          if (value instanceof BinaryResult) {
+            responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
+          } else {
+            responseBytes = replyJson(req, res, false, qp.config(), value);
           }
         }
-        if (viewData.view == null) {
-          viewData = view(rsrc, c, req.getMethod(), path);
-        }
-        checkRequiresCapability(viewData);
-      }
-
-      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();
-      }
-
-      if (result instanceof Response) {
-        @SuppressWarnings("rawtypes")
-        Response<?> r = (Response) result;
-        status = r.statusCode();
-        configureCaching(req, res, rsrc, viewData.view, r.caching());
-      } else if (result instanceof Response.Redirect) {
-        CacheHeaders.setNotCacheable(res);
-        res.sendRedirect(((Response.Redirect) result).location());
-        return;
-      } else if (result instanceof Response.Accepted) {
-        CacheHeaders.setNotCacheable(res);
-        res.setStatus(SC_ACCEPTED);
-        res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
-        return;
-      } else {
-        CacheHeaders.setNotCacheable(res);
-      }
-      res.setStatus(status);
-
-      if (result != Response.none()) {
-        result = Response.unwrap(result);
-        if (result instanceof BinaryResult) {
-          responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
-        } else {
-          responseBytes = replyJson(req, res, qp.config(), result);
-        }
-      }
-    } catch (MalformedJsonException | JsonParseException e) {
-      responseBytes =
-          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
-    } catch (BadRequestException e) {
-      responseBytes =
-          replyError(
-              req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
-    } catch (AuthException e) {
-      responseBytes =
-          replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
-    } catch (AmbiguousViewException e) {
-      responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
-    } catch (ResourceNotFoundException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
-    } catch (MethodNotAllowedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_METHOD_NOT_ALLOWED,
-              messageOr(e, "Method Not Allowed"),
-              e.caching(),
-              e);
-    } catch (ResourceConflictException e) {
-      responseBytes =
-          replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
-    } catch (PreconditionFailedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_PRECONDITION_FAILED,
-              messageOr(e, "Precondition Failed"),
-              e.caching(),
-              e);
-    } catch (UnprocessableEntityException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_UNPROCESSABLE_ENTITY,
-              messageOr(e, "Unprocessable Entity"),
-              e.caching(),
-              e);
-    } catch (NotImplementedException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
-    } catch (UpdateException e) {
-      Throwable t = e.getCause();
-      if (t instanceof LockFailureException) {
+      } catch (MalformedJsonException | JsonParseException e) {
         responseBytes =
-            replyError(req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
-      } else {
+            replyError(
+                req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
+      } catch (BadRequestException e) {
+        responseBytes =
+            replyError(
+                req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
+      } catch (AuthException e) {
+        responseBytes =
+            replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
+      } catch (AmbiguousViewException e) {
+        responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
+      } catch (ResourceNotFoundException e) {
+        responseBytes =
+            replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
+      } catch (MethodNotAllowedException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_METHOD_NOT_ALLOWED,
+                messageOr(e, "Method Not Allowed"),
+                e.caching(),
+                e);
+      } catch (ResourceConflictException e) {
+        responseBytes =
+            replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
+      } catch (PreconditionFailedException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_PRECONDITION_FAILED,
+                messageOr(e, "Precondition Failed"),
+                e.caching(),
+                e);
+      } catch (UnprocessableEntityException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_UNPROCESSABLE_ENTITY,
+                messageOr(e, "Unprocessable Entity"),
+                e.caching(),
+                e);
+      } catch (NotImplementedException e) {
+        logger.atSevere().withCause(e).log("Error in %s %s", req.getMethod(), uriForLogging(req));
+        responseBytes =
+            replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
+      } catch (UpdateException e) {
+        Throwable t = e.getCause();
+        if (t instanceof LockFailureException) {
+          logger.atSevere().withCause(t).log("Error in %s %s", req.getMethod(), uriForLogging(req));
+          responseBytes = replyError(req, res, status = SC_SERVICE_UNAVAILABLE, "Lock failure", e);
+        } else {
+          status = SC_INTERNAL_SERVER_ERROR;
+          responseBytes = handleException(e, req, res);
+        }
+      } catch (QuotaException e) {
+        responseBytes =
+            replyError(req, res, status = 429, messageOr(e, "Quota limit reached"), e.caching(), e);
+      } 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,
+                response,
+                rsrc,
+                viewData == null ? null : viewData.view));
       }
-    } catch (Exception e) {
-      status = SC_INTERNAL_SERVER_ERROR;
-      responseBytes = handleException(e, req, res);
-    } finally {
-      String metric =
-          viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
-      globals.metrics.count.increment(metric);
-      if (status >= SC_BAD_REQUEST) {
-        globals.metrics.errorCount.increment(metric, status);
-      }
-      if (responseBytes != -1) {
-        globals.metrics.responseBytes.record(metric, responseBytes);
-      }
-      globals.metrics.serverLatency.record(
-          metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
-      globals.auditService.dispatch(
-          new ExtendedHttpAuditEvent(
-              globals.webSession.get().getSessionId(),
-              globals.currentUser.get(),
-              req,
-              auditStartTs,
-              qp != null ? qp.params() : ImmutableListMultimap.of(),
-              inputRequestBody,
-              status,
-              result,
-              rsrc,
-              viewData == null ? null : viewData.view));
     }
   }
 
@@ -735,6 +881,24 @@
     return ((ParameterizedType) supertype).getActualTypeArguments()[1];
   }
 
+  private static Type inputType(RestCollectionView<RestResource, RestResource, Object> m) {
+    // MyCollectionView implements RestCollectionView<SomeResource, SomeResource, MyInput>
+    TypeLiteral<?> typeLiteral = TypeLiteral.get(m.getClass());
+
+    // RestCollectionView<SomeResource, SomeResource, MyInput>
+    // This is smart enough to resolve even when there are intervening subclasses, even if they have
+    // reordered type arguments.
+    TypeLiteral<?> supertypeLiteral = typeLiteral.getSupertype(RestCollectionView.class);
+
+    Type supertype = supertypeLiteral.getType();
+    checkState(
+        supertype instanceof ParameterizedType,
+        "supertype of %s is not parameterized: %s",
+        typeLiteral,
+        supertypeLiteral);
+    return ((ParameterizedType) supertype).getActualTypeArguments()[2];
+  }
+
   private Object parseRequest(HttpServletRequest req, Type type)
       throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
           NoSuchMethodException, IllegalAccessException, InstantiationException,
@@ -868,9 +1032,22 @@
     throw new InstantiationException("Cannot make " + type);
   }
 
+  /**
+   * Sets a JSON reply on the given HTTP servlet response.
+   *
+   * @param req the HTTP servlet request
+   * @param res the HTTP servlet response on which the reply should be set
+   * @param allowTracing whether it is allowed to log the reply if tracing is enabled, must not be
+   *     set to {@code true} if the reply may contain sensitive data
+   * @param config config parameters for the JSON formatting
+   * @param result the object that should be formatted as JSON
+   * @return the length of the response
+   * @throws IOException
+   */
   public static long replyJson(
       @Nullable HttpServletRequest req,
       HttpServletResponse res,
+      boolean allowTracing,
       ListMultimap<String, String> config,
       Object result)
       throws IOException {
@@ -885,6 +1062,21 @@
     }
     w.write('\n');
     w.flush();
+
+    if (allowTracing) {
+      logger.atFinest().log(
+          "JSON response body:\n%s",
+          lazy(
+              () -> {
+                try {
+                  ByteArrayOutputStream debugOut = new ByteArrayOutputStream();
+                  buf.writeTo(debugOut, null);
+                  return debugOut.toString(UTF_8.name());
+                } catch (IOException e) {
+                  return "<JSON formatting failed>";
+                }
+              }));
+    }
     return replyBinaryResult(
         req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
   }
@@ -1066,10 +1258,7 @@
   }
 
   private ViewData view(
-      RestResource rsrc,
-      RestCollection<RestResource, RestResource> rc,
-      String method,
-      List<IdString> path)
+      RestCollection<RestResource, RestResource> rc, String method, List<IdString> path)
       throws AmbiguousViewException, RestApiException {
     DynamicMap<RestView<RestResource>> views = rc.views();
     final IdString projection = path.isEmpty() ? IdString.fromUrl("/") : path.remove(0);
@@ -1093,24 +1282,23 @@
         return new ViewData(p.get(0), view);
       }
       view = views.get(p.get(0), "GET." + viewname);
-      if (view != null && view instanceof AcceptsPost && "POST".equals(method)) {
-        @SuppressWarnings("unchecked")
-        AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) view;
-        return new ViewData(p.get(0), ap.post(rsrc));
+      if (view != null) {
+        return new ViewData(p.get(0), view);
       }
       throw new ResourceNotFoundException(projection);
     }
 
     String name = method + "." + p.get(0);
-    RestView<RestResource> core = views.get("gerrit", name);
+    RestView<RestResource> core = views.get(PluginName.GERRIT, name);
     if (core != null) {
-      return new ViewData(null, core);
+      return new ViewData(PluginName.GERRIT, core);
     }
-    core = views.get("gerrit", "GET." + p.get(0));
-    if (core instanceof AcceptsPost && "POST".equals(method)) {
-      @SuppressWarnings("unchecked")
-      AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core;
-      return new ViewData(null, ap.post(rsrc));
+
+    // Check if we want to delegate to a child collection. Child collections are bound with
+    // GET.name so we have to check for this since we haven't found any other views.
+    core = views.get(PluginName.GERRIT, "GET." + p.get(0));
+    if (core != null) {
+      return new ViewData(PluginName.GERRIT, core);
     }
 
     Map<String, RestView<RestResource>> r = new TreeMap<>();
@@ -1121,6 +1309,17 @@
       }
     }
 
+    if (r.isEmpty()) {
+      // Check if we want to delegate to a child collection. Child collections are bound with
+      // GET.name so we have to check for this since we haven't found any other views.
+      for (String plugin : views.plugins()) {
+        RestView<RestResource> action = views.get(plugin, "GET." + p.get(0));
+        if (action != null) {
+          r.put(plugin, action);
+        }
+      }
+    }
+
     if (r.size() == 1) {
       Map.Entry<String, RestView<RestResource>> entry = Iterables.getOnlyElement(r.entrySet());
       return new ViewData(entry.getKey(), entry.getValue());
@@ -1171,6 +1370,72 @@
     }
   }
 
+  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));
+    // 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 RequestInfo createRequestInfo(
+      TraceContext traceContext, String requestUri, List<IdString> path) {
+    RequestInfo.Builder requestInfo =
+        RequestInfo.builder(RequestInfo.RequestType.REST, globals.currentUser.get(), traceContext)
+            .requestUri(requestUri);
+
+    if (path.size() < 1) {
+      return requestInfo.build();
+    }
+
+    RestCollection<?, ?> rootCollection = members.get();
+    String resourceId = path.get(0).get();
+    if (rootCollection instanceof ProjectsCollection) {
+      requestInfo.project(Project.nameKey(resourceId));
+    } else if (rootCollection instanceof ChangesCollection) {
+      ChangeNotes changeNotes = globals.changeFinder.findOne(resourceId);
+      if (changeNotes != null) {
+        requestInfo.project(changeNotes.getProjectName());
+      }
+    }
+    return requestInfo.build();
+  }
+
   private boolean isDelete(HttpServletRequest req) {
     return "DELETE".equals(req.getMethod());
   }
@@ -1187,22 +1452,35 @@
     return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
   }
 
+  private static MethodNotAllowedException methodNotAllowed(HttpServletRequest req) {
+    return new MethodNotAllowedException(
+        String.format("Not implemented: %s %s", req.getMethod(), requestUri(req)));
+  }
+
+  private static String requestUri(HttpServletRequest req) {
+    String uri = req.getRequestURI();
+    if (uri.startsWith("/a/")) {
+      return uri.substring(2);
+    }
+    return uri;
+  }
+
   private void checkRequiresCapability(ViewData d)
       throws AuthException, PermissionBackendException {
-    globals
-        .permissionBackend
-        .currentUser()
-        .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
+    try {
+      globals.permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (AuthException e) {
+      // Skiping
+      globals
+          .permissionBackend
+          .currentUser()
+          .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
+    }
   }
 
   private static long handleException(
       Throwable err, HttpServletRequest req, HttpServletResponse res) throws IOException {
-    String uri = req.getRequestURI();
-    if (!Strings.isNullOrEmpty(req.getQueryString())) {
-      uri += "?" + req.getQueryString();
-    }
-    logger.atSevere().withCause(err).log("Error in %s %s", req.getMethod(), uri);
-
+    logger.atSevere().withCause(err).log("Error in %s %s", req.getMethod(), uriForLogging(req));
     if (!res.isCommitted()) {
       res.reset();
       return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
@@ -1210,6 +1488,14 @@
     return 0;
   }
 
+  private static String uriForLogging(HttpServletRequest req) {
+    String uri = req.getRequestURI();
+    if (!Strings.isNullOrEmpty(req.getQueryString())) {
+      uri += "?" + LogRedactUtil.redactQueryString(req.getQueryString());
+    }
+    return uri;
+  }
+
   public static long replyError(
       HttpServletRequest req,
       HttpServletResponse res,
@@ -1234,17 +1520,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().withCause(err).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));
   }
 
@@ -1319,8 +1622,9 @@
     return new TemporaryBuffer.Heap(est, max);
   }
 
-  @SuppressWarnings("serial")
   private static class AmbiguousViewException extends Exception {
+    private static final long serialVersionUID = 1L;
+
     AmbiguousViewException(String message) {
       super(message);
     }
diff --git a/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java b/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java
deleted file mode 100644
index 31819e8..0000000
--- a/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.rpc;
-
-import java.io.IOException;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpServletResponseWrapper;
-
-class AuditedHttpServletResponse extends HttpServletResponseWrapper implements HttpServletResponse {
-  private int status;
-
-  AuditedHttpServletResponse(HttpServletResponse response) {
-    super(response);
-  }
-
-  @SuppressWarnings("all") // @Override for servlet API 3.0+ only.
-  public int getStatus() {
-    return status;
-  }
-
-  @Override
-  public void setStatus(int sc) {
-    super.setStatus(sc);
-    this.status = sc;
-  }
-
-  @Override
-  @Deprecated
-  public void setStatus(int sc, String sm) {
-    super.setStatus(sc, sm);
-    this.status = sc;
-  }
-
-  @Override
-  public void sendError(int sc) throws IOException {
-    super.sendError(sc);
-    this.status = sc;
-  }
-
-  @Override
-  public void sendError(int sc, String msg) throws IOException {
-    super.sendError(sc, msg);
-    this.status = sc;
-  }
-
-  @Override
-  public void sendRedirect(String location) throws IOException {
-    super.sendRedirect(location);
-    this.status = SC_MOVED_TEMPORARILY;
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java b/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
deleted file mode 100644
index f5d2216..0000000
--- a/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
+++ /dev/null
@@ -1,288 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.rpc;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.audit.Audit;
-import com.google.gerrit.common.auth.SignInRequired;
-import com.google.gerrit.common.errors.NotSignedInException;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.audit.AuditService;
-import com.google.gerrit.server.audit.RpcAuditEvent;
-import com.google.gson.GsonBuilder;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.server.ActiveCall;
-import com.google.gwtjsonrpc.server.JsonServlet;
-import com.google.gwtjsonrpc.server.MethodHandle;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.lang.reflect.Field;
-import java.lang.reflect.Method;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-/** Base JSON servlet to ensure the current user is not forged. */
-@SuppressWarnings("serial")
-final class GerritJsonServlet extends JsonServlet<GerritJsonServlet.GerritCall> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final ThreadLocal<GerritCall> currentCall = new ThreadLocal<>();
-  private static final ThreadLocal<MethodHandle> currentMethod = new ThreadLocal<>();
-  private final DynamicItem<WebSession> session;
-  private final RemoteJsonService service;
-  private final AuditService audit;
-
-  @Inject
-  GerritJsonServlet(final DynamicItem<WebSession> w, RemoteJsonService s, AuditService a) {
-    session = w;
-    service = s;
-    audit = a;
-  }
-
-  @Override
-  protected GerritCall createActiveCall(final HttpServletRequest req, HttpServletResponse rsp) {
-    final GerritCall call = new GerritCall(session.get(), req, new AuditedHttpServletResponse(rsp));
-    currentCall.set(call);
-    return call;
-  }
-
-  @Override
-  protected GsonBuilder createGsonBuilder() {
-    return gerritDefaultGsonBuilder();
-  }
-
-  private static GsonBuilder gerritDefaultGsonBuilder() {
-    final GsonBuilder g = defaultGsonBuilder();
-
-    g.registerTypeAdapter(
-        org.eclipse.jgit.diff.Edit.class, new org.eclipse.jgit.diff.EditDeserializer());
-
-    return g;
-  }
-
-  @Override
-  protected void preInvoke(GerritCall call) {
-    super.preInvoke(call);
-
-    if (call.isComplete()) {
-      return;
-    }
-
-    if (call.getMethod().getAnnotation(SignInRequired.class) != null) {
-      // If SignInRequired is set on this method we must have both a
-      // valid XSRF token *and* have the user signed in. Doing these
-      // checks also validates that they agree on the user identity.
-      //
-      if (!call.requireXsrfValid() || !session.get().isSignedIn()) {
-        call.onFailure(new NotSignedInException());
-      }
-    }
-  }
-
-  @Override
-  protected Object createServiceHandle() {
-    return service;
-  }
-
-  @Override
-  protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
-    try {
-      super.service(req, resp);
-    } finally {
-      audit();
-      currentCall.set(null);
-    }
-  }
-
-  private void audit() {
-    try {
-      GerritCall call = currentCall.get();
-      MethodHandle method = call.getMethod();
-      if (method == null) {
-        return;
-      }
-      Audit note = method.getAnnotation(Audit.class);
-      if (note != null) {
-        String sid = call.getWebSession().getSessionId();
-        CurrentUser username = call.getWebSession().getUser();
-        ListMultimap<String, ?> args = extractParams(note, call);
-        String what = extractWhat(note, call);
-        Object result = call.getResult();
-
-        audit.dispatch(
-            new RpcAuditEvent(
-                sid,
-                username,
-                what,
-                call.getWhen(),
-                args,
-                call.getHttpServletRequest().getMethod(),
-                call.getHttpServletRequest().getMethod(),
-                ((AuditedHttpServletResponse) (call.getHttpServletResponse())).getStatus(),
-                result));
-      }
-    } catch (Throwable all) {
-      logger.atSevere().withCause(all).log("Unable to log the call");
-    }
-  }
-
-  private ListMultimap<String, ?> extractParams(Audit note, GerritCall call) {
-    ListMultimap<String, Object> args = MultimapBuilder.hashKeys().arrayListValues().build();
-
-    Object[] params = call.getParams();
-    for (int i = 0; i < params.length; i++) {
-      args.put("$" + i, params[i]);
-    }
-
-    for (int idx : note.obfuscate()) {
-      args.removeAll("$" + idx);
-      args.put("$" + idx, "*****");
-    }
-    return args;
-  }
-
-  private String extractWhat(Audit note, GerritCall call) {
-    Class<?> methodClass = call.getMethodClass();
-    String methodClassName = methodClass != null ? methodClass.getName() : "<UNKNOWN_CLASS>";
-    methodClassName = methodClassName.substring(methodClassName.lastIndexOf('.') + 1);
-    String what = note.action();
-    if (what.length() == 0) {
-      what = call.getMethod().getName();
-    }
-
-    return methodClassName + "." + what;
-  }
-
-  static class GerritCall extends ActiveCall {
-    private final WebSession session;
-    private final long when;
-    private static final Field resultField;
-    private static final Field methodField;
-
-    // Needed to allow access to non-public result field in GWT/JSON-RPC
-    static {
-      resultField = getPrivateField(ActiveCall.class, "result");
-      methodField = getPrivateField(MethodHandle.class, "method");
-    }
-
-    private static Field getPrivateField(Class<?> clazz, String fieldName) {
-      Field declaredField = null;
-      try {
-        declaredField = clazz.getDeclaredField(fieldName);
-        declaredField.setAccessible(true);
-      } catch (Exception e) {
-        logger.atSevere().log("Unable to expose RPS/JSON result field");
-      }
-      return declaredField;
-    }
-
-    // Surrogate of the missing getMethodClass() in GWT/JSON-RPC
-    public Class<?> getMethodClass() {
-      if (methodField == null) {
-        return null;
-      }
-
-      try {
-        Method method = (Method) methodField.get(this.getMethod());
-        return method.getDeclaringClass();
-      } catch (IllegalArgumentException e) {
-        logger.atSevere().log("Cannot access result field");
-      } catch (IllegalAccessException e) {
-        logger.atSevere().log("No permissions to access result field");
-      }
-
-      return null;
-    }
-
-    // Surrogate of the missing getResult() in GWT/JSON-RPC
-    public Object getResult() {
-      if (resultField == null) {
-        return null;
-      }
-
-      try {
-        return resultField.get(this);
-      } catch (IllegalArgumentException e) {
-        logger.atSevere().log("Cannot access result field");
-      } catch (IllegalAccessException e) {
-        logger.atSevere().log("No permissions to access result field");
-      }
-
-      return null;
-    }
-
-    GerritCall(WebSession session, HttpServletRequest i, HttpServletResponse o) {
-      super(i, o);
-      this.session = session;
-      this.when = TimeUtil.nowMs();
-    }
-
-    @Override
-    public MethodHandle getMethod() {
-      if (currentMethod.get() == null) {
-        return super.getMethod();
-      }
-      return currentMethod.get();
-    }
-
-    @Override
-    public void onFailure(Throwable error) {
-      if (error instanceof IllegalArgumentException || error instanceof IllegalStateException) {
-        super.onFailure(error);
-      } else if (error instanceof OrmException || error instanceof RuntimeException) {
-        onInternalFailure(error);
-      } else {
-        super.onFailure(error);
-      }
-    }
-
-    @Override
-    public boolean xsrfValidate() {
-      final String keyIn = getXsrfKeyIn();
-      if (keyIn == null || "".equals(keyIn)) {
-        // Anonymous requests don't need XSRF protection, they shouldn't
-        // be able to cause critical state changes.
-        //
-        return !session.isSignedIn();
-
-      } else if (session.isSignedIn() && session.isValidXGerritAuth(keyIn)) {
-        // The session must exist, and must be using this token.
-        //
-        session.getUser().setAccessPath(AccessPath.JSON_RPC);
-        return true;
-      }
-      return false;
-    }
-
-    public WebSession getWebSession() {
-      return session;
-    }
-
-    public long getWhen() {
-      return when;
-    }
-
-    public long getElapsed() {
-      return TimeUtil.nowMs() - when;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java b/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
deleted file mode 100644
index b167167..0000000
--- a/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc;
-
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-
-/** Creates {@link GerritJsonServlet} with a {@link RemoteJsonService}. */
-class GerritJsonServletProvider implements Provider<GerritJsonServlet> {
-  @Inject private Injector injector;
-
-  private final Class<? extends RemoteJsonService> serviceClass;
-
-  @Inject
-  GerritJsonServletProvider(Class<? extends RemoteJsonService> c) {
-    serviceClass = c;
-  }
-
-  @Override
-  public GerritJsonServlet get() {
-    final RemoteJsonService srv = injector.getInstance(serviceClass);
-    return injector
-        .createChildInjector(
-            new AbstractModule() {
-              @Override
-              protected void configure() {
-                bind(RemoteJsonService.class).toInstance(srv);
-              }
-            })
-        .getInstance(GerritJsonServlet.class);
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/Handler.java b/java/com/google/gerrit/httpd/rpc/Handler.java
deleted file mode 100644
index ae20571..0000000
--- a/java/com/google/gerrit/httpd/rpc/Handler.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc;
-
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.VoidResult;
-import com.google.gwtorm.server.OrmException;
-import java.util.concurrent.Callable;
-
-/**
- * Base class for RPC service implementations.
- *
- * <p>Typically an RPC service implementation will extend this class and use Guice injection to
- * manage its state. For example:
- *
- * <pre>
- *   class Foo extends Handler&lt;Result&gt; {
- *     interface Factory {
- *       Foo create(... args ...);
- *     }
- *     &#064;Inject
- *     Foo(state, @Assisted args) { ... }
- *     Result get() throws Exception { ... }
- *   }
- * </pre>
- *
- * @param <T> type of result for {@link AsyncCallback#onSuccess(Object)} if the operation completed
- *     successfully.
- */
-public abstract class Handler<T> implements Callable<T> {
-  public static <T> Handler<T> wrap(Callable<T> r) {
-    return new Handler<T>() {
-      @Override
-      public T call() throws Exception {
-        return r.call();
-      }
-    };
-  }
-
-  /**
-   * Run the operation and pass the result to the callback.
-   *
-   * @param callback callback to receive the result of {@link #call()}.
-   */
-  public final void to(AsyncCallback<T> callback) {
-    try {
-      final T r = call();
-      if (r != null) {
-        callback.onSuccess(r);
-      }
-    } catch (NoSuchProjectException | NoSuchChangeException | NoSuchRefException e) {
-      callback.onFailure(new NoSuchEntityException());
-
-    } catch (OrmException e) {
-      if (e.getCause() instanceof NoSuchEntityException) {
-        callback.onFailure(e.getCause());
-
-      } else {
-        callback.onFailure(e);
-      }
-    } catch (Exception e) {
-      callback.onFailure(e);
-    }
-  }
-
-  /**
-   * Compute the operation result.
-   *
-   * @return the result of the operation. Return {@link VoidResult#INSTANCE} if there is no
-   *     meaningful return value for the operation.
-   * @throws Exception the operation failed. The caller will log the exception and the stack trace,
-   *     if it is worth logging on the server side.
-   */
-  @Override
-  public abstract T call() throws Exception;
-}
diff --git a/java/com/google/gerrit/httpd/rpc/RpcServletModule.java b/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
deleted file mode 100644
index b03609e..0000000
--- a/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc;
-
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.inject.Key;
-import com.google.inject.Scopes;
-import com.google.inject.internal.UniqueAnnotations;
-import com.google.inject.servlet.ServletModule;
-
-/** Binds {@link RemoteJsonService} implementations to a JSON servlet. */
-public abstract class RpcServletModule extends ServletModule {
-  public static final String PREFIX = "/gerrit_ui/rpc/";
-
-  private final String prefix;
-
-  protected RpcServletModule(String pathPrefix) {
-    prefix = pathPrefix;
-  }
-
-  protected void rpc(Class<? extends RemoteJsonService> clazz) {
-    String name = clazz.getSimpleName();
-    if (name.endsWith("Impl")) {
-      name = name.substring(0, name.length() - 4);
-    }
-    rpc(name, clazz);
-  }
-
-  protected void rpc(String name, Class<? extends RemoteJsonService> clazz) {
-    final Key<GerritJsonServlet> srv = Key.get(GerritJsonServlet.class, UniqueAnnotations.create());
-    final GerritJsonServletProvider provider = new GerritJsonServletProvider(clazz);
-    bind(clazz);
-    serve(prefix + name).with(srv);
-    bind(srv).toProvider(provider).in(Scopes.SINGLETON);
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java b/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
deleted file mode 100644
index 634e8d8..0000000
--- a/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.rpc;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SshHostKey;
-import com.google.gerrit.common.data.SystemInfoService;
-import com.google.gerrit.server.ssh.SshInfo;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.VoidResult;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.jcraft.jsch.HostKey;
-import com.jcraft.jsch.JSch;
-import java.util.ArrayList;
-import java.util.List;
-import javax.servlet.http.HttpServletRequest;
-
-class SystemInfoServiceImpl implements SystemInfoService {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final JSch JSCH = new JSch();
-
-  private final List<HostKey> hostKeys;
-  private final Provider<HttpServletRequest> httpRequest;
-
-  @Inject
-  SystemInfoServiceImpl(SshInfo daemon, Provider<HttpServletRequest> hsr) {
-    hostKeys = daemon.getHostKeys();
-    httpRequest = hsr;
-  }
-
-  @Override
-  public void daemonHostKeys(AsyncCallback<List<SshHostKey>> callback) {
-    final ArrayList<SshHostKey> r = new ArrayList<>(hostKeys.size());
-    for (HostKey hk : hostKeys) {
-      String host = hk.getHost();
-      if (host.startsWith("*:")) {
-        final String port = host.substring(2);
-        host = "[" + httpRequest.get().getServerName() + "]:" + port;
-      }
-      final String fp = hk.getFingerPrint(JSCH);
-      r.add(new SshHostKey(host, hk.getType() + " " + hk.getKey(), fp));
-    }
-    callback.onSuccess(r);
-  }
-
-  @Override
-  public void clientError(String message, AsyncCallback<VoidResult> callback) {
-    HttpServletRequest r = httpRequest.get();
-    String ua = r.getHeader("User-Agent");
-    message = message.replaceAll("\n", "\n  ");
-    logger.atSevere().log("Client UI JavaScript error: User-Agent=%s: %s", ua, message);
-    callback.onSuccess(VoidResult.INSTANCE);
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/UiRpcModule.java b/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
deleted file mode 100644
index 9aab920..0000000
--- a/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc;
-
-import com.google.gerrit.httpd.rpc.project.ProjectModule;
-
-/** Registers servlets to answer RPCs from client UI. */
-public class UiRpcModule extends RpcServletModule {
-  public UiRpcModule() {
-    super(PREFIX);
-  }
-
-  @Override
-  protected void configureServlets() {
-    rpc(SystemInfoServiceImpl.class);
-
-    install(new ProjectModule());
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
deleted file mode 100644
index 24efb86..0000000
--- a/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.rpc.project;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CreateGroupPermissionSyncer;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.restapi.project.SetParent;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-class ChangeProjectAccess extends ProjectAccessHandler<ProjectAccess> {
-  interface Factory {
-    ChangeProjectAccess create(
-        @Assisted("projectName") Project.NameKey projectName,
-        @Nullable @Assisted ObjectId base,
-        @Assisted List<AccessSection> sectionList,
-        @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
-        @Nullable @Assisted String message);
-  }
-
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ProjectAccessFactory.Factory projectAccessFactory;
-  private final ProjectCache projectCache;
-  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
-
-  @Inject
-  ChangeProjectAccess(
-      ProjectAccessFactory.Factory projectAccessFactory,
-      ProjectCache projectCache,
-      GroupBackend groupBackend,
-      MetaDataUpdate.User metaDataUpdateFactory,
-      AllProjectsName allProjects,
-      Provider<SetParent> setParent,
-      GitReferenceUpdated gitRefUpdated,
-      ContributorAgreementsChecker contributorAgreements,
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      CreateGroupPermissionSyncer createGroupPermissionSyncer,
-      @Assisted("projectName") Project.NameKey projectName,
-      @Nullable @Assisted ObjectId base,
-      @Assisted List<AccessSection> sectionList,
-      @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
-      @Nullable @Assisted String message) {
-    super(
-        groupBackend,
-        metaDataUpdateFactory,
-        allProjects,
-        setParent,
-        user.get(),
-        projectName,
-        base,
-        sectionList,
-        parentProjectName,
-        message,
-        contributorAgreements,
-        permissionBackend,
-        true);
-    this.projectAccessFactory = projectAccessFactory;
-    this.projectCache = projectCache;
-    this.gitRefUpdated = gitRefUpdated;
-    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
-  }
-
-  @Override
-  protected ProjectAccess updateProjectConfig(
-      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
-      throws IOException, NoSuchProjectException, ConfigInvalidException,
-          PermissionBackendException, ResourceConflictException {
-    RevCommit commit = config.commit(md);
-
-    gitRefUpdated.fire(
-        config.getProject().getNameKey(),
-        RefNames.REFS_CONFIG,
-        base,
-        commit.getId(),
-        user.asIdentifiedUser().state());
-
-    projectCache.evict(config.getProject());
-    createGroupPermissionSyncer.syncIfNeeded();
-    return projectAccessFactory.create(projectName).call();
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
deleted file mode 100644
index 6193e45..0000000
--- a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ /dev/null
@@ -1,295 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.rpc.project;
-
-import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
-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;
-
-import com.google.common.collect.Maps;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupInfo;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.common.data.WebLinkInfoCommon;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.httpd.rpc.Handler;
-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.WebLinks;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.config.AllProjectsName;
-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.permissions.RefPermission;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-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.errors.ConfigInvalidException;
-
-class ProjectAccessFactory extends Handler<ProjectAccess> {
-  interface Factory {
-    ProjectAccessFactory create(@Assisted Project.NameKey name);
-  }
-
-  private final GroupBackend groupBackend;
-  private final ProjectCache projectCache;
-  private final PermissionBackend permissionBackend;
-  private final GroupControl.Factory groupControlFactory;
-  private final MetaDataUpdate.Server metaDataUpdateFactory;
-  private final AllProjectsName allProjectsName;
-
-  private final Project.NameKey projectName;
-  private WebLinks webLinks;
-
-  @Inject
-  ProjectAccessFactory(
-      GroupBackend groupBackend,
-      ProjectCache projectCache,
-      PermissionBackend permissionBackend,
-      GroupControl.Factory groupControlFactory,
-      MetaDataUpdate.Server metaDataUpdateFactory,
-      AllProjectsName allProjectsName,
-      WebLinks webLinks,
-      @Assisted final Project.NameKey name) {
-    this.groupBackend = groupBackend;
-    this.projectCache = projectCache;
-    this.permissionBackend = permissionBackend;
-    this.groupControlFactory = groupControlFactory;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allProjectsName = allProjectsName;
-    this.webLinks = webLinks;
-
-    this.projectName = name;
-  }
-
-  @Override
-  public ProjectAccess call()
-      throws NoSuchProjectException, IOException, ConfigInvalidException,
-          PermissionBackendException, ResourceConflictException {
-    ProjectState projectState = checkProjectState();
-
-    // Load the current configuration from the repository, ensuring its the most
-    // recent version available. If it differs from what was in the project
-    // state, force a cache flush now.
-    //
-    ProjectConfig config;
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
-      config = ProjectConfig.read(md);
-      if (config.updateGroupNames(groupBackend)) {
-        md.setMessage("Update group names\n");
-        config.commit(md);
-        projectCache.evict(config.getProject());
-        projectState = checkProjectState();
-      } else if (config.getRevision() != null
-          && !config.getRevision().equals(projectState.getConfig().getRevision())) {
-        projectCache.evict(config.getProject());
-        projectState = checkProjectState();
-      }
-    }
-
-    // The following implementation must match the GetAccess REST API endpoint.
-
-    List<AccessSection> local = new ArrayList<>();
-    Set<String> ownerOf = new HashSet<>();
-    Map<AccountGroup.UUID, Boolean> visibleGroups = new HashMap<>();
-    PermissionBackend.ForProject perm = permissionBackend.currentUser().project(projectName);
-    boolean checkReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
-    boolean canWriteProjectConfig = true;
-    try {
-      perm.check(ProjectPermission.WRITE_CONFIG);
-    } catch (AuthException e) {
-      canWriteProjectConfig = false;
-    }
-
-    for (AccessSection section : config.getAccessSections()) {
-      String name = section.getName();
-      if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-        if (canWriteProjectConfig) {
-          local.add(section);
-          ownerOf.add(name);
-
-        } else if (checkReadConfig) {
-          local.add(section);
-        }
-
-      } else if (RefConfigSection.isValid(name)) {
-        if (check(perm, name, WRITE_CONFIG)) {
-          local.add(section);
-          ownerOf.add(name);
-
-        } else if (checkReadConfig) {
-          local.add(section);
-
-        } else if (check(perm, name, READ)) {
-          // Filter the section to only add rules describing groups that
-          // are visible to the current-user. This includes any group the
-          // user is a member of, as well as groups they own or that
-          // are visible to all users.
-
-          AccessSection dst = null;
-          for (Permission srcPerm : section.getPermissions()) {
-            Permission dstPerm = null;
-
-            for (PermissionRule srcRule : srcPerm.getRules()) {
-              AccountGroup.UUID group = srcRule.getGroup().getUUID();
-              if (group == null) {
-                continue;
-              }
-
-              Boolean canSeeGroup = visibleGroups.get(group);
-              if (canSeeGroup == null) {
-                try {
-                  canSeeGroup = groupControlFactory.controlFor(group).isVisible();
-                } catch (NoSuchGroupException e) {
-                  canSeeGroup = Boolean.FALSE;
-                }
-                visibleGroups.put(group, canSeeGroup);
-              }
-
-              if (canSeeGroup) {
-                if (dstPerm == null) {
-                  if (dst == null) {
-                    dst = new AccessSection(name);
-                    local.add(dst);
-                  }
-                  dstPerm = dst.getPermission(srcPerm.getName(), true);
-                }
-                dstPerm.add(srcRule);
-              }
-            }
-          }
-        }
-      }
-    }
-
-    if (ownerOf.isEmpty() && isAdmin()) {
-      // Special case: If the section list is empty, this project has no current
-      // access control information. Fall back to site administrators.
-      ownerOf.add(AccessSection.ALL);
-    }
-
-    final ProjectAccess detail = new ProjectAccess();
-    detail.setProjectName(projectName);
-
-    if (config.getRevision() != null) {
-      detail.setRevision(config.getRevision().name());
-    }
-
-    detail.setInheritsFrom(config.getProject().getParent(allProjectsName));
-
-    if (projectName.equals(allProjectsName)
-        && permissionBackend.currentUser().testOrFalse(ADMINISTRATE_SERVER)) {
-      ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
-    }
-
-    detail.setLocal(local);
-    detail.setOwnerOf(ownerOf);
-    detail.setCanUpload(
-        canWriteProjectConfig
-            || (checkReadConfig
-                && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)
-                && projectState.statePermitsWrite()));
-    detail.setConfigVisible(canWriteProjectConfig || checkReadConfig);
-    detail.setGroupInfo(buildGroupInfo(local));
-    detail.setLabelTypes(projectState.getLabelTypes());
-    detail.setFileHistoryLinks(getConfigFileLogLinks(projectName.get()));
-    return detail;
-  }
-
-  private List<WebLinkInfoCommon> getConfigFileLogLinks(String projectName) {
-    List<WebLinkInfoCommon> links =
-        webLinks.getFileHistoryLinks(
-            projectName, RefNames.REFS_CONFIG, ProjectConfig.PROJECT_CONFIG);
-    return links.isEmpty() ? null : links;
-  }
-
-  private Map<AccountGroup.UUID, GroupInfo> buildGroupInfo(List<AccessSection> local) {
-    Map<AccountGroup.UUID, GroupInfo> infos = new HashMap<>();
-    for (AccessSection section : local) {
-      for (Permission permission : section.getPermissions()) {
-        for (PermissionRule rule : permission.getRules()) {
-          if (rule.getGroup() != null) {
-            AccountGroup.UUID uuid = rule.getGroup().getUUID();
-            if (uuid != null && !infos.containsKey(uuid)) {
-              GroupDescription.Basic group = groupBackend.get(uuid);
-              infos.put(uuid, group != null ? new GroupInfo(group) : null);
-            }
-          }
-        }
-      }
-    }
-    return Maps.filterEntries(infos, in -> in.getValue() != null);
-  }
-
-  private ProjectState checkProjectState()
-      throws NoSuchProjectException, IOException, PermissionBackendException,
-          ResourceConflictException {
-    ProjectState state = projectCache.checkedGet(projectName);
-    // Hidden projects(permitsRead = false) should only be accessible by the project owners.
-    // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
-    // be allowed for other users). Allowing project owners to access here will help them to view
-    // and update the config of hidden projects easily.
-    ProjectPermission permissionToCheck =
-        state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
-    try {
-      permissionBackend.currentUser().project(projectName).check(permissionToCheck);
-    } catch (AuthException e) {
-      throw new NoSuchProjectException(projectName);
-    }
-    state.checkStatePermitsRead();
-    return state;
-  }
-
-  private static boolean check(PermissionBackend.ForProject ctx, String ref, RefPermission perm)
-      throws PermissionBackendException {
-    try {
-      ctx.ref(ref).check(perm);
-      return true;
-    } catch (AuthException denied) {
-      return false;
-    }
-  }
-
-  private boolean isAdmin() throws PermissionBackendException {
-    try {
-      permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
deleted file mode 100644
index 0ae9c4c..0000000
--- a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ /dev/null
@@ -1,240 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.project;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.common.ProjectAccessUtil.mergeSections;
-
-import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.common.errors.UpdateParentFailedException;
-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.httpd.rpc.Handler;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupBackends;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-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.permissions.RefPermission;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RefPattern;
-import com.google.gerrit.server.restapi.project.SetParent;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
-import java.io.IOException;
-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.eclipse.jgit.lib.ObjectId;
-
-public abstract class ProjectAccessHandler<T> extends Handler<T> {
-
-  protected final GroupBackend groupBackend;
-  protected final Project.NameKey projectName;
-  protected final ObjectId base;
-  protected final CurrentUser user;
-
-  private final MetaDataUpdate.User metaDataUpdateFactory;
-  private final AllProjectsName allProjects;
-  private final Provider<SetParent> setParent;
-  private final ContributorAgreementsChecker contributorAgreements;
-  private final PermissionBackend permissionBackend;
-  private final Project.NameKey parentProjectName;
-
-  protected String message;
-
-  private List<AccessSection> sectionList;
-  private boolean checkIfOwner;
-  private Boolean canWriteConfig;
-
-  protected ProjectAccessHandler(
-      GroupBackend groupBackend,
-      MetaDataUpdate.User metaDataUpdateFactory,
-      AllProjectsName allProjects,
-      Provider<SetParent> setParent,
-      CurrentUser user,
-      Project.NameKey projectName,
-      ObjectId base,
-      List<AccessSection> sectionList,
-      Project.NameKey parentProjectName,
-      String message,
-      ContributorAgreementsChecker contributorAgreements,
-      PermissionBackend permissionBackend,
-      boolean checkIfOwner) {
-    this.groupBackend = groupBackend;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allProjects = allProjects;
-    this.setParent = setParent;
-    this.user = user;
-
-    this.projectName = projectName;
-    this.base = base;
-    this.sectionList = sectionList;
-    this.parentProjectName = parentProjectName;
-    this.message = message;
-    this.contributorAgreements = contributorAgreements;
-    this.permissionBackend = permissionBackend;
-    this.checkIfOwner = checkIfOwner;
-  }
-
-  @Override
-  public final T call()
-      throws NoSuchProjectException, IOException, ConfigInvalidException, InvalidNameException,
-          NoSuchGroupException, OrmException, UpdateParentFailedException, AuthException,
-          PermissionBackendException, ResourceConflictException {
-    contributorAgreements.check(projectName, user);
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
-      ProjectConfig config = ProjectConfig.read(md, base);
-      Set<String> toDelete = scanSectionNames(config);
-      PermissionBackend.ForProject forProject = permissionBackend.user(user).project(projectName);
-
-      for (AccessSection section : mergeSections(sectionList)) {
-        String name = section.getName();
-
-        if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-          if (checkIfOwner && !canWriteConfig()) {
-            continue;
-          }
-          replace(config, toDelete, section);
-
-        } else if (AccessSection.isValid(name)) {
-          if (checkIfOwner && !forProject.ref(name).test(RefPermission.WRITE_CONFIG)) {
-            continue;
-          }
-
-          RefPattern.validate(name);
-
-          replace(config, toDelete, section);
-        }
-      }
-
-      for (String name : toDelete) {
-        if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-          if (!checkIfOwner || canWriteConfig()) {
-            config.remove(config.getAccessSection(name));
-          }
-
-        } else if (!checkIfOwner || forProject.ref(name).test(RefPermission.WRITE_CONFIG)) {
-          config.remove(config.getAccessSection(name));
-        }
-      }
-
-      boolean parentProjectUpdate = false;
-      if (!config.getProject().getNameKey().equals(allProjects)
-          && !config.getProject().getParent(allProjects).equals(parentProjectName)) {
-        parentProjectUpdate = true;
-        try {
-          setParent
-              .get()
-              .validateParentUpdate(
-                  projectName,
-                  user.asIdentifiedUser(),
-                  MoreObjects.firstNonNull(parentProjectName, allProjects).get(),
-                  checkIfOwner);
-        } catch (AuthException e) {
-          throw new UpdateParentFailedException(
-              "You are not allowed to change the parent project since you are "
-                  + "not an administrator. You may save the modifications for review "
-                  + "so that an administrator can approve them.",
-              e);
-        } catch (ResourceConflictException | UnprocessableEntityException | BadRequestException e) {
-          throw new UpdateParentFailedException(e.getMessage(), e);
-        }
-        config.getProject().setParentName(parentProjectName);
-      }
-
-      if (message != null && !message.isEmpty()) {
-        if (!message.endsWith("\n")) {
-          message += "\n";
-        }
-        md.setMessage(message);
-      } else {
-        md.setMessage("Modify access rules\n");
-      }
-
-      return updateProjectConfig(config, md, parentProjectUpdate);
-    } catch (RepositoryNotFoundException notFound) {
-      throw new NoSuchProjectException(projectName);
-    }
-  }
-
-  protected abstract T updateProjectConfig(
-      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
-      throws IOException, NoSuchProjectException, ConfigInvalidException, OrmException,
-          AuthException, PermissionBackendException, ResourceConflictException;
-
-  private void replace(ProjectConfig config, Set<String> toDelete, AccessSection section)
-      throws NoSuchGroupException {
-    for (Permission permission : section.getPermissions()) {
-      for (PermissionRule rule : permission.getRules()) {
-        lookupGroup(rule);
-      }
-    }
-    config.replace(section);
-    toDelete.remove(section.getName());
-  }
-
-  private static Set<String> scanSectionNames(ProjectConfig config) {
-    Set<String> names = new HashSet<>();
-    for (AccessSection section : config.getAccessSections()) {
-      names.add(section.getName());
-    }
-    return names;
-  }
-
-  private void lookupGroup(PermissionRule rule) throws NoSuchGroupException {
-    GroupReference ref = rule.getGroup();
-    if (ref.getUUID() == null) {
-      final GroupReference group = GroupBackends.findBestSuggestion(groupBackend, ref.getName());
-      if (group == null) {
-        throw new NoSuchGroupException(ref.getName());
-      }
-      ref.setUUID(group.getUUID());
-    }
-  }
-
-  /** Provide a local cache for {@code ProjectPermission.WRITE_CONFIG} capability. */
-  private boolean canWriteConfig() throws PermissionBackendException {
-    checkNotNull(user);
-
-    if (canWriteConfig != null) {
-      return canWriteConfig;
-    }
-    try {
-      permissionBackend.user(user).project(projectName).check(ProjectPermission.WRITE_CONFIG);
-      canWriteConfig = true;
-    } catch (AuthException e) {
-      canWriteConfig = false;
-    }
-    return canWriteConfig;
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java b/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
deleted file mode 100644
index da471c3..0000000
--- a/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.rpc.project;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.common.data.ProjectAdminService;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.inject.Inject;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-
-class ProjectAdminServiceImpl implements ProjectAdminService {
-  private final ChangeProjectAccess.Factory changeProjectAccessFactory;
-  private final ReviewProjectAccess.Factory reviewProjectAccessFactory;
-  private final ProjectAccessFactory.Factory projectAccessFactory;
-
-  @Inject
-  ProjectAdminServiceImpl(
-      final ChangeProjectAccess.Factory changeProjectAccessFactory,
-      final ReviewProjectAccess.Factory reviewProjectAccessFactory,
-      final ProjectAccessFactory.Factory projectAccessFactory) {
-    this.changeProjectAccessFactory = changeProjectAccessFactory;
-    this.reviewProjectAccessFactory = reviewProjectAccessFactory;
-    this.projectAccessFactory = projectAccessFactory;
-  }
-
-  @Override
-  public void projectAccess(
-      final Project.NameKey projectName, AsyncCallback<ProjectAccess> callback) {
-    projectAccessFactory.create(projectName).to(callback);
-  }
-
-  private static ObjectId getBase(String baseRevision) {
-    if (baseRevision != null && !baseRevision.isEmpty()) {
-      return ObjectId.fromString(baseRevision);
-    }
-    return null;
-  }
-
-  @Override
-  public void changeProjectAccess(
-      Project.NameKey projectName,
-      String baseRevision,
-      String msg,
-      List<AccessSection> sections,
-      Project.NameKey parentProjectName,
-      AsyncCallback<ProjectAccess> cb) {
-    changeProjectAccessFactory
-        .create(projectName, getBase(baseRevision), sections, parentProjectName, msg)
-        .to(cb);
-  }
-
-  @Override
-  public void reviewProjectAccess(
-      Project.NameKey projectName,
-      String baseRevision,
-      String msg,
-      List<AccessSection> sections,
-      Project.NameKey parentProjectName,
-      AsyncCallback<Change.Id> cb) {
-    reviewProjectAccessFactory
-        .create(projectName, getBase(baseRevision), sections, parentProjectName, msg)
-        .to(cb);
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java b/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java
deleted file mode 100644
index 3d7d80f..0000000
--- a/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.project;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.httpd.rpc.RpcServletModule;
-import com.google.gerrit.httpd.rpc.UiRpcModule;
-
-public class ProjectModule extends RpcServletModule {
-  public ProjectModule() {
-    super(UiRpcModule.PREFIX);
-  }
-
-  @Override
-  protected void configureServlets() {
-    install(
-        new FactoryModule() {
-          @Override
-          protected void configure() {
-            factory(ChangeProjectAccess.Factory.class);
-            factory(ReviewProjectAccess.Factory.class);
-            factory(ProjectAccessFactory.Factory.class);
-          }
-        });
-    rpc(ProjectAdminServiceImpl.class);
-  }
-}
diff --git a/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java b/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
deleted file mode 100644
index aee0330..0000000
--- a/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ /dev/null
@@ -1,239 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.project;
-
-import com.google.common.base.Throwables;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-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.permissions.RefPermission;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.restapi.change.ChangesCollection;
-import com.google.gerrit.server.restapi.change.PostReviewers;
-import com.google.gerrit.server.restapi.project.SetParent;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class ReviewProjectAccess extends ProjectAccessHandler<Change.Id> {
-  interface Factory {
-    ReviewProjectAccess create(
-        @Assisted("projectName") Project.NameKey projectName,
-        @Nullable @Assisted ObjectId base,
-        @Assisted List<AccessSection> sectionList,
-        @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
-        @Nullable @Assisted String message);
-  }
-
-  private final ReviewDb db;
-  private final PermissionBackend permissionBackend;
-  private final Sequences seq;
-  private final Provider<PostReviewers> reviewersProvider;
-  private final ProjectCache projectCache;
-  private final ChangesCollection changes;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final BatchUpdate.Factory updateFactory;
-
-  @Inject
-  ReviewProjectAccess(
-      PermissionBackend permissionBackend,
-      GroupBackend groupBackend,
-      MetaDataUpdate.User metaDataUpdateFactory,
-      ReviewDb db,
-      Provider<PostReviewers> reviewersProvider,
-      ProjectCache projectCache,
-      AllProjectsName allProjects,
-      ChangesCollection changes,
-      ChangeInserter.Factory changeInserterFactory,
-      BatchUpdate.Factory updateFactory,
-      Provider<SetParent> setParent,
-      Sequences seq,
-      ContributorAgreementsChecker contributorAgreements,
-      Provider<CurrentUser> user,
-      @Assisted("projectName") Project.NameKey projectName,
-      @Nullable @Assisted ObjectId base,
-      @Assisted List<AccessSection> sectionList,
-      @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
-      @Nullable @Assisted String message) {
-    super(
-        groupBackend,
-        metaDataUpdateFactory,
-        allProjects,
-        setParent,
-        user.get(),
-        projectName,
-        base,
-        sectionList,
-        parentProjectName,
-        message,
-        contributorAgreements,
-        permissionBackend,
-        false);
-    this.db = db;
-    this.permissionBackend = permissionBackend;
-    this.seq = seq;
-    this.reviewersProvider = reviewersProvider;
-    this.projectCache = projectCache;
-    this.changes = changes;
-    this.changeInserterFactory = changeInserterFactory;
-    this.updateFactory = updateFactory;
-  }
-
-  // TODO(dborowitz): Hack MetaDataUpdate so it can be created within a BatchUpdate and we can avoid
-  // calling setUpdateRef(false).
-  @SuppressWarnings("deprecation")
-  @Override
-  protected Change.Id updateProjectConfig(
-      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
-      throws IOException, OrmException, AuthException, PermissionBackendException,
-          ConfigInvalidException, ResourceConflictException {
-    PermissionBackend.ForProject perm = permissionBackend.user(user).project(config.getName());
-    if (!check(perm, ProjectPermission.READ_CONFIG)) {
-      throw new AuthException(RefNames.REFS_CONFIG + " not visible");
-    }
-
-    if (!check(perm, ProjectPermission.WRITE_CONFIG)
-        && !check(perm.ref(RefNames.REFS_CONFIG), RefPermission.CREATE_CHANGE)) {
-      throw new AuthException("cannot create change for " + RefNames.REFS_CONFIG);
-    }
-
-    projectCache.checkedGet(config.getName()).checkStatePermitsWrite();
-
-    md.setInsertChangeId(true);
-    Change.Id changeId = new Change.Id(seq.nextChangeId());
-    RevCommit commit =
-        config.commitToNewRef(
-            md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
-    if (commit.getId().equals(base)) {
-      return null;
-    }
-
-    try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
-        ObjectReader objReader = objInserter.newReader();
-        RevWalk rw = new RevWalk(objReader);
-        BatchUpdate bu =
-            updateFactory.create(db, config.getProject().getNameKey(), user, TimeUtil.nowTs())) {
-      bu.setRepository(md.getRepository(), rw, objInserter);
-      bu.insertChange(
-          changeInserterFactory
-              .create(changeId, commit, RefNames.REFS_CONFIG)
-              .setValidate(false)
-              .setUpdateRef(false)); // Created by commitToNewRef.
-      bu.execute();
-    } catch (UpdateException | RestApiException e) {
-      throw new IOException(e);
-    }
-
-    ChangeResource rsrc;
-    try {
-      rsrc = changes.parse(changeId);
-    } catch (RestApiException e) {
-      throw new IOException(e);
-    }
-    addProjectOwnersAsReviewers(rsrc);
-    if (parentProjectUpdate) {
-      addAdministratorsAsReviewers(rsrc);
-    }
-    return changeId;
-  }
-
-  private void addProjectOwnersAsReviewers(ChangeResource rsrc) {
-    final String projectOwners = groupBackend.get(SystemGroupBackend.PROJECT_OWNERS).getName();
-    try {
-      AddReviewerInput input = new AddReviewerInput();
-      input.reviewer = projectOwners;
-      reviewersProvider.get().apply(rsrc, input);
-    } catch (Exception e) {
-      // one of the owner groups is not visible to the user and this it why it
-      // can't be added as reviewer
-      Throwables.throwIfUnchecked(e);
-    }
-  }
-
-  private void addAdministratorsAsReviewers(ChangeResource rsrc) {
-    List<PermissionRule> adminRules =
-        projectCache
-            .getAllProjects()
-            .getConfig()
-            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
-            .getPermission(GlobalCapability.ADMINISTRATE_SERVER)
-            .getRules();
-    for (PermissionRule r : adminRules) {
-      try {
-        AddReviewerInput input = new AddReviewerInput();
-        input.reviewer = r.getGroup().getUUID().get();
-        reviewersProvider.get().apply(rsrc, input);
-      } catch (Exception e) {
-        // ignore
-        Throwables.throwIfUnchecked(e);
-      }
-    }
-  }
-
-  private boolean check(PermissionBackend.ForRef perm, RefPermission p)
-      throws PermissionBackendException {
-    try {
-      perm.check(p);
-      return true;
-    } catch (AuthException denied) {
-      return false;
-    }
-  }
-
-  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
-      throws PermissionBackendException {
-    try {
-      perm.check(p);
-      return true;
-    } catch (AuthException denied) {
-      return false;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 2442b593..7fcf342 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -1,5 +1,3 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
-
 QUERY_PARSE_EXCEPTION_SRCS = [
     "query/QueryParseException.java",
     "query/QueryRequiresAuthException.java",
@@ -12,19 +10,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,13 +18,16 @@
     visibility = ["//visibility:public"],
     deps = [
         ":query_exception",
-        ":query_parser",
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
         "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
index b1ffac1..fb48104 100644
--- a/java/com/google/gerrit/index/FieldDef.java
+++ b/java/com/google/gerrit/index/FieldDef.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.index;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.CharMatcher;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import java.io.IOException;
 import java.sql.Timestamp;
 
@@ -60,7 +61,8 @@
 
   @FunctionalInterface
   public interface Getter<I, T> {
-    T get(I input) throws OrmException, IOException;
+    @Nullable
+    T get(I input) throws IOException;
   }
 
   public static class Builder<T> {
@@ -69,8 +71,8 @@
     private boolean stored;
 
     public Builder(FieldType<T> type, String name) {
-      this.type = checkNotNull(type);
-      this.name = checkNotNull(name);
+      this.type = requireNonNull(type);
+      this.name = requireNonNull(name);
     }
 
     public Builder<T> stored() {
@@ -99,10 +101,10 @@
         !(repeatable && type == FieldType.INTEGER_RANGE),
         "Range queries against repeated fields are unsupported");
     this.name = checkName(name);
-    this.type = checkNotNull(type);
+    this.type = requireNonNull(type);
     this.stored = stored;
     this.repeatable = repeatable;
-    this.getter = checkNotNull(getter);
+    this.getter = requireNonNull(getter);
   }
 
   private static String checkName(String name) {
@@ -131,13 +133,13 @@
    *
    * @param input input object.
    * @return the field value(s) to index.
-   * @throws OrmException
    */
-  public T get(I input) throws OrmException {
+  @Nullable
+  public T get(I input) {
     try {
       return getter.get(input);
     } catch (IOException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index 2d7e31e..44f8b42 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.index;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.List;
 import java.util.Optional;
 
 /**
@@ -48,24 +47,18 @@
    * searchers, but should be visible within a reasonable amount of time.
    *
    * @param obj document object
-   * @throws IOException
    */
-  void replace(V obj) throws IOException;
+  void replace(V obj);
 
   /**
    * Delete a document from the index by key.
    *
    * @param key document key
-   * @throws IOException
    */
-  void delete(K key) throws IOException;
+  void delete(K key);
 
-  /**
-   * Delete all documents from the index.
-   *
-   * @throws IOException
-   */
-  void deleteAll() throws IOException;
+  /** Delete all documents from the index. */
+  void deleteAll();
 
   /**
    * Convert the given operator predicate into a source searching the index and returning only the
@@ -91,20 +84,17 @@
    * @param opts query options. Options that do not make sense in the context of a single document,
    *     such as start, will be ignored.
    * @return a single document if present.
-   * @throws IOException
    */
-  default Optional<V> get(K key, QueryOptions opts) throws IOException {
+  default Optional<V> get(K key, QueryOptions opts) {
     opts = opts.withStart(0).withLimit(2);
-    List<V> results;
+    ImmutableList<V> results;
     try {
       results = getSource(keyPredicate(key), opts).read().toList();
     } catch (QueryParseException e) {
-      throw new IOException("Unexpected QueryParseException during get()", e);
-    } catch (OrmException e) {
-      throw new IOException(e);
+      throw new StorageException("Unexpected QueryParseException during get()", e);
     }
     if (results.size() > 1) {
-      throw new IOException("Multiple results found in index for key " + key + ": " + results);
+      throw new StorageException("Multiple results found in index for key " + key + ": " + results);
     }
     return results.stream().findFirst();
   }
@@ -116,20 +106,17 @@
    * @param opts query options. Options that do not make sense in the context of a single document,
    *     such as start, will be ignored.
    * @return an abstraction of a raw index document to retrieve fields from.
-   * @throws IOException
    */
-  default Optional<FieldBundle> getRaw(K key, QueryOptions opts) throws IOException {
+  default Optional<FieldBundle> getRaw(K key, QueryOptions opts) {
     opts = opts.withStart(0).withLimit(2);
-    List<FieldBundle> results;
+    ImmutableList<FieldBundle> results;
     try {
       results = getSource(keyPredicate(key), opts).readRaw().toList();
     } catch (QueryParseException e) {
-      throw new IOException("Unexpected QueryParseException during get()", e);
-    } catch (OrmException e) {
-      throw new IOException(e);
+      throw new StorageException("Unexpected QueryParseException during get()", e);
     }
     if (results.size() > 1) {
-      throw new IOException("Multiple results found in index for key " + key + ": " + results);
+      throw new StorageException("Multiple results found in index for key " + key + ": " + results);
     }
     return results.stream().findFirst();
   }
@@ -146,7 +133,6 @@
    * Mark whether this index is up-to-date and ready to serve reads.
    *
    * @param ready whether the index is ready
-   * @throws IOException
    */
-  void markReady(boolean ready) throws IOException;
+  void markReady(boolean ready);
 }
diff --git a/java/com/google/gerrit/index/IndexedQuery.java b/java/com/google/gerrit/index/IndexedQuery.java
deleted file mode 100644
index 143cc26..0000000
--- a/java/com/google/gerrit/index/IndexedQuery.java
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.FieldBundle;
-import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Paginated;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import java.util.Collection;
-import java.util.List;
-
-/**
- * Wrapper combining an {@link IndexPredicate} together with a {@link DataSource} that returns
- * matching results from the index.
- *
- * <p>Appropriate to return as the rootmost predicate that can be processed using the secondary
- * index; such predicates must also implement {@link DataSource} to be chosen by the query
- * processor.
- *
- * @param <I> The type of the IDs by which the entities are stored in the index.
- * @param <T> The type of the entities that are stored in the index.
- */
-public class IndexedQuery<I, T> extends Predicate<T> implements DataSource<T>, Paginated<T> {
-  protected final Index<I, T> index;
-
-  private QueryOptions opts;
-  private final Predicate<T> pred;
-  protected DataSource<T> source;
-
-  public IndexedQuery(Index<I, T> index, Predicate<T> pred, QueryOptions opts)
-      throws QueryParseException {
-    this.index = index;
-    this.opts = opts;
-    this.pred = pred;
-    this.source = index.getSource(pred, this.opts);
-  }
-
-  @Override
-  public int getChildCount() {
-    return 1;
-  }
-
-  @Override
-  public Predicate<T> getChild(int i) {
-    if (i == 0) {
-      return pred;
-    }
-    throw new ArrayIndexOutOfBoundsException(i);
-  }
-
-  @Override
-  public List<Predicate<T>> getChildren() {
-    return ImmutableList.of(pred);
-  }
-
-  @Override
-  public QueryOptions getOptions() {
-    return opts;
-  }
-
-  @Override
-  public int getCardinality() {
-    return source != null ? source.getCardinality() : opts.limit();
-  }
-
-  @Override
-  public ResultSet<T> read() throws OrmException {
-    return source.read();
-  }
-
-  @Override
-  public ResultSet<FieldBundle> readRaw() throws OrmException {
-    return source.readRaw();
-  }
-
-  @Override
-  public ResultSet<T> restart(int start) throws OrmException {
-    opts = opts.withStart(start);
-    try {
-      source = index.getSource(pred, opts);
-    } catch (QueryParseException e) {
-      // Don't need to show this exception to the user; the only thing that
-      // changed about pred was its start, and any other QPEs that might happen
-      // should have already thrown from the constructor.
-      throw new OrmException(e);
-    }
-    // Don't convert start to a limit, since the caller of this method (see
-    // AndSource) has calculated the actual number to skip.
-    return read();
-  }
-
-  @Override
-  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
-    return this;
-  }
-
-  @Override
-  public int hashCode() {
-    return pred.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other == null || getClass() != other.getClass()) {
-      return false;
-    }
-    IndexedQuery<?, ?> o = (IndexedQuery<?, ?>) other;
-    return pred.equals(o.pred) && opts.equals(o.opts);
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper("index").add("p", pred).add("opts", opts).toString();
-  }
-}
diff --git a/java/com/google/gerrit/index/RefState.java b/java/com/google/gerrit/index/RefState.java
new file mode 100644
index 0000000..dd8bcfa
--- /dev/null
+++ b/java/com/google/gerrit/index/RefState.java
@@ -0,0 +1,83 @@
+// 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.index;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+@AutoValue
+public abstract class RefState {
+  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
+    RefState.check(states != null, null);
+    SetMultimap<Project.NameKey, RefState> result =
+        MultimapBuilder.hashKeys().hashSetValues().build();
+    for (byte[] b : states) {
+      RefState.check(b != null, null);
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
+      result.put(Project.nameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
+    }
+    return result;
+  }
+
+  public static RefState create(String ref, String sha) {
+    return new AutoValue_RefState(ref, ObjectId.fromString(sha));
+  }
+
+  public static RefState create(String ref, @Nullable ObjectId id) {
+    return new AutoValue_RefState(ref, firstNonNull(id, ObjectId.zeroId()));
+  }
+
+  public static RefState of(Ref ref) {
+    return new AutoValue_RefState(ref.getName(), ref.getObjectId());
+  }
+
+  public byte[] toByteArray(Project.NameKey project) {
+    byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
+    byte[] b = new byte[a.length + ObjectIds.STR_LEN];
+    System.arraycopy(a, 0, b, 0, a.length);
+    id().copyTo(b, a.length);
+    return b;
+  }
+
+  public static void check(boolean condition, String str) {
+    checkArgument(condition, "invalid RefState: %s", str);
+  }
+
+  public abstract String ref();
+
+  public abstract ObjectId id();
+
+  public boolean match(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(ref());
+    ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
+    return id().equals(expected);
+  }
+}
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 18563ab..ed7ae30 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -16,18 +16,16 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 
 /** Specific version of a secondary index schema. */
@@ -176,27 +174,24 @@
   public final Iterable<Values<T>> buildFields(T obj) {
     return FluentIterable.from(fields.values())
         .transform(
-            new Function<FieldDef<T, ?>, Values<T>>() {
-              @Override
-              public Values<T> apply(FieldDef<T, ?> f) {
-                Object v;
-                try {
-                  v = f.get(obj);
-                } catch (OrmException e) {
-                  logger.atSevere().withCause(e).log(
-                      "error getting field %s of %s", f.getName(), obj);
-                  return null;
-                }
-                if (v == null) {
-                  return null;
-                } else if (f.isRepeatable()) {
-                  return new Values<>(f, (Iterable<?>) v);
-                } else {
-                  return new Values<>(f, Collections.singleton(v));
-                }
+            f -> {
+              Object v;
+              try {
+                v = f.get(obj);
+              } catch (RuntimeException e) {
+                logger.atSevere().withCause(e).log(
+                    "error getting field %s of %s", f.getName(), obj);
+                return null;
+              }
+              if (v == null) {
+                return null;
+              } else if (f.isRepeatable()) {
+                return new Values<>(f, (Iterable<?>) v);
+              } else {
+                return new Values<>(f, Collections.singleton(v));
               }
             })
-        .filter(Predicates.notNull());
+        .filter(Objects::nonNull);
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/SchemaDefinitions.java b/java/com/google/gerrit/index/SchemaDefinitions.java
index f9c690c..e8efd22 100644
--- a/java/com/google/gerrit/index/SchemaDefinitions.java
+++ b/java/com/google/gerrit/index/SchemaDefinitions.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.index;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.Iterables;
@@ -34,7 +34,7 @@
   private final ImmutableSortedMap<Integer, Schema<V>> schemas;
 
   protected SchemaDefinitions(String name, Class<V> valueClass) {
-    this.name = checkNotNull(name);
+    this.name = requireNonNull(name);
     this.schemas = SchemaUtil.schemasFromClass(getClass(), valueClass);
   }
 
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index 24b7a69..c3ab8a4 100644
--- a/java/com/google/gerrit/index/SiteIndexer.java
+++ b/java/com/google/gerrit/index/SiteIndexer.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.index;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
 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,18 +66,18 @@
 
   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;
   }
 
   public void setProgressOut(OutputStream out) {
-    progressOut = checkNotNull(out);
+    progressOut = requireNonNull(out);
   }
 
   public void setVerboseOut(OutputStream out) {
-    verboseWriter = new PrintWriter(checkNotNull(out));
+    verboseWriter = newPrintWriter(requireNonNull(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/IndexedProjectQuery.java b/java/com/google/gerrit/index/project/IndexedProjectQuery.java
index 4409ccb..383ba1c 100644
--- a/java/com/google/gerrit/index/project/IndexedProjectQuery.java
+++ b/java/com/google/gerrit/index/project/IndexedProjectQuery.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.index.project;
 
 import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexedQuery;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.IndexedQuery;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Project;
diff --git a/java/com/google/gerrit/index/project/ProjectData.java b/java/com/google/gerrit/index/project/ProjectData.java
index 9d6b571..fb029ac 100644
--- a/java/com/google/gerrit/index/project/ProjectData.java
+++ b/java/com/google/gerrit/index/project/ProjectData.java
@@ -14,23 +14,51 @@
 
 package com.google.gerrit.index.project;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.Project;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
 
 public class ProjectData {
   private final Project project;
-  private final ImmutableList<Project.NameKey> ancestors;
+  private final Optional<ProjectData> parent;
 
-  public ProjectData(Project project, Iterable<Project.NameKey> ancestors) {
+  public ProjectData(Project project, Optional<ProjectData> parent) {
     this.project = project;
-    this.ancestors = ImmutableList.copyOf(ancestors);
+    this.parent = parent;
   }
 
   public Project getProject() {
     return project;
   }
 
-  public ImmutableList<Project.NameKey> getAncestors() {
-    return ancestors;
+  public Optional<ProjectData> getParent() {
+    return parent;
+  }
+
+  /** Returns all {@link ProjectData} in the hierarchy starting with the current one. */
+  public ImmutableList<ProjectData> tree() {
+    List<ProjectData> parents = new ArrayList<>();
+    Optional<ProjectData> curr = Optional.of(this);
+    while (curr.isPresent()) {
+      parents.add(curr.get());
+      curr = curr.get().parent;
+    }
+    return ImmutableList.copyOf(parents);
+  }
+
+  public ImmutableList<String> getParentNames() {
+    return tree().stream().skip(1).map(p -> p.getProject().getName()).collect(toImmutableList());
+  }
+
+  @Override
+  public String toString() {
+    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
+    h.addValue(project.getName());
+    return h.toString();
   }
 }
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index 1c2f629b..119980c 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -14,23 +14,30 @@
 
 package com.google.gerrit.index.project;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.fullText;
 import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.storedOnly;
 
-import com.google.common.collect.Iterables;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 
 /** Index schema for projects. */
 public class ProjectField {
+  private static byte[] toRefState(Project project) {
+    return RefState.create(RefNames.REFS_CONFIG, project.getConfigRefState())
+        .toByteArray(project.getNameKey());
+  }
 
   public static final FieldDef<ProjectData, String> NAME =
       exact("name").stored().build(p -> p.getProject().getName());
 
   public static final FieldDef<ProjectData, String> DESCRIPTION =
-      fullText("description").build(p -> p.getProject().getDescription());
+      fullText("description").stored().build(p -> p.getProject().getDescription());
 
   public static final FieldDef<ProjectData, String> PARENT_NAME =
       exact("parent_name").build(p -> p.getProject().getParentName());
@@ -38,7 +45,24 @@
   public static final FieldDef<ProjectData, Iterable<String>> NAME_PART =
       prefix("name_part").buildRepeatable(p -> SchemaUtil.getNameParts(p.getProject().getName()));
 
+  public static final FieldDef<ProjectData, String> STATE =
+      exact("state").stored().build(p -> p.getProject().getState().name());
+
   public static final FieldDef<ProjectData, Iterable<String>> ANCESTOR_NAME =
-      exact("ancestor_name")
-          .buildRepeatable(p -> Iterables.transform(p.getAncestors(), Project.NameKey::get));
+      exact("ancestor_name").buildRepeatable(ProjectData::getParentNames);
+
+  /**
+   * All values of all refs that were used in the course of indexing this document. This covers
+   * {@code refs/meta/config} of the current project and all of its parents.
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
+   */
+  public static final FieldDef<ProjectData, Iterable<byte[]>> REF_STATE =
+      storedOnly("ref_state")
+          .buildRepeatable(
+              projectData ->
+                  projectData.tree().stream()
+                      .filter(p -> p.getProject().getConfigRefState() != null)
+                      .map(p -> toRefState(p.getProject()))
+                      .collect(toImmutableList()));
 }
diff --git a/java/com/google/gerrit/index/project/ProjectIndexRewriter.java b/java/com/google/gerrit/index/project/ProjectIndexRewriter.java
index 096bb64..9e2bbdc 100644
--- a/java/com/google/gerrit/index/project/ProjectIndexRewriter.java
+++ b/java/com/google/gerrit/index/project/ProjectIndexRewriter.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.index.project;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
@@ -36,7 +36,7 @@
   public Predicate<ProjectData> rewrite(Predicate<ProjectData> in, QueryOptions opts)
       throws QueryParseException {
     ProjectIndex index = indexes.getSearchIndex();
-    checkNotNull(index, "no active search index configured for projects");
+    requireNonNull(index, "no active search index configured for projects");
     return new IndexedProjectQuery(index, in, opts);
   }
 }
diff --git a/java/com/google/gerrit/index/project/ProjectIndexer.java b/java/com/google/gerrit/index/project/ProjectIndexer.java
index 44dccfe..1ca29f5 100644
--- a/java/com/google/gerrit/index/project/ProjectIndexer.java
+++ b/java/com/google/gerrit/index/project/ProjectIndexer.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.index.project;
 
 import com.google.gerrit.reviewdb.client.Project;
-import java.io.IOException;
 
 public interface ProjectIndexer {
 
@@ -24,5 +23,5 @@
    *
    * @param nameKey name key of project to index.
    */
-  void index(Project.NameKey nameKey) throws IOException;
+  void index(Project.NameKey nameKey);
 }
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index cbea4fe..f355216 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -21,6 +21,7 @@
 
 public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
 
+  @Deprecated
   static final Schema<ProjectData> V1 =
       schema(
           ProjectField.NAME,
@@ -29,9 +30,20 @@
           ProjectField.NAME_PART,
           ProjectField.ANCESTOR_NAME);
 
+  @Deprecated
+  static final Schema<ProjectData> V2 = schema(V1, ProjectField.STATE, ProjectField.REF_STATE);
+
+  // Bump Lucene version requires reindexing
+  @Deprecated static final Schema<ProjectData> V3 = schema(V2);
+
+  // Lucene index was changed to add an additional field for sorting.
+  static final Schema<ProjectData> V4 = schema(V3);
+
   public static final ProjectSchemaDefinitions INSTANCE = new ProjectSchemaDefinitions();
 
+  public static final String NAME = "projects";
+
   private ProjectSchemaDefinitions() {
-    super("projects", ProjectData.class);
+    super(NAME, ProjectData.class);
   }
 }
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index 7fba05f..ae13fb3 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -82,7 +81,7 @@
   }
 
   @Override
-  public boolean match(T object) throws OrmException {
+  public boolean match(T object) {
     for (Predicate<T> c : children) {
       checkState(
           c.isMatchable(),
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index e2605f4..538e11b 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -15,18 +15,14 @@
 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;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.ResultSet;
+import com.google.gerrit.exceptions.StorageException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 
@@ -78,78 +74,74 @@
   }
 
   @Override
-  public ResultSet<T> read() throws OrmException {
-    try {
-      return readImpl();
-    } catch (OrmRuntimeException err) {
-      if (err.getCause() != null) {
-        Throwables.throwIfInstanceOf(err.getCause(), OrmException.class);
-      }
-      throw new OrmException(err);
+  public ResultSet<T> read() {
+    if (source == null) {
+      throw new StorageException("No DataSource: " + this);
     }
+
+    // ResultSets are lazy. Calling #read here first and then dealing with ResultSets only when
+    // requested allows the index to run asynchronous queries.
+    ResultSet<T> resultSet = source.read();
+    return new LazyResultSet<>(
+        () -> {
+          List<T> r = new ArrayList<>();
+          T last = null;
+          int nextStart = 0;
+          boolean skipped = false;
+          for (T data : buffer(resultSet)) {
+            if (!isMatchable() || match(data)) {
+              r.add(data);
+            } else {
+              skipped = true;
+            }
+            last = data;
+            nextStart++;
+          }
+
+          if (skipped && last != null && source instanceof Paginated) {
+            // If our source is a paginated source and we skipped at
+            // least one of its results, we may not have filled the full
+            // limit the caller wants.  Restart the source and continue.
+            //
+            @SuppressWarnings("unchecked")
+            Paginated<T> p = (Paginated<T>) source;
+            while (skipped && r.size() < p.getOptions().limit() + start) {
+              skipped = false;
+              ResultSet<T> next = p.restart(nextStart);
+
+              for (T data : buffer(next)) {
+                if (match(data)) {
+                  r.add(data);
+                } else {
+                  skipped = true;
+                }
+                nextStart++;
+              }
+            }
+          }
+
+          if (start >= r.size()) {
+            return ImmutableList.of();
+          } else if (start > 0) {
+            return ImmutableList.copyOf(r.subList(start, r.size()));
+          }
+          return ImmutableList.copyOf(r);
+        });
   }
 
   @Override
-  public ResultSet<FieldBundle> readRaw() throws OrmException {
+  public ResultSet<FieldBundle> readRaw() {
     // TOOD(hiesel): Implement
     throw new UnsupportedOperationException("not implemented");
   }
 
-  private ResultSet<T> readImpl() throws OrmException {
-    if (source == null) {
-      throw new OrmException("No DataSource: " + this);
-    }
-    List<T> r = new ArrayList<>();
-    T last = null;
-    int nextStart = 0;
-    boolean skipped = false;
-    for (T data : buffer(source.read())) {
-      if (!isMatchable() || match(data)) {
-        r.add(data);
-      } else {
-        skipped = true;
-      }
-      last = data;
-      nextStart++;
-    }
-
-    if (skipped && last != null && source instanceof Paginated) {
-      // If our source is a paginated source and we skipped at
-      // least one of its results, we may not have filled the full
-      // limit the caller wants.  Restart the source and continue.
-      //
-      @SuppressWarnings("unchecked")
-      Paginated<T> p = (Paginated<T>) source;
-      while (skipped && r.size() < p.getOptions().limit() + start) {
-        skipped = false;
-        ResultSet<T> next = p.restart(nextStart);
-
-        for (T data : buffer(next)) {
-          if (match(data)) {
-            r.add(data);
-          } else {
-            skipped = true;
-          }
-          nextStart++;
-        }
-      }
-    }
-
-    if (start >= r.size()) {
-      r = ImmutableList.of();
-    } else if (start > 0) {
-      r = ImmutableList.copyOf(r.subList(start, r.size()));
-    }
-    return new ListResultSet<>(r);
-  }
-
   @Override
   public boolean isMatchable() {
     return isVisibleToPredicate != null || super.isMatchable();
   }
 
   @Override
-  public boolean match(T object) throws OrmException {
+  public boolean match(T object) {
     if (isVisibleToPredicate != null && !isVisibleToPredicate.match(object)) {
       return false;
     }
@@ -166,7 +158,7 @@
         .transformAndConcat(this::transformBuffer);
   }
 
-  protected List<T> transformBuffer(List<T> buffer) throws OrmRuntimeException {
+  protected List<T> transformBuffer(List<T> buffer) {
     return buffer;
   }
 
@@ -175,10 +167,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/DataSource.java b/java/com/google/gerrit/index/query/DataSource.java
index 88cc0e3c..2c2ba53 100644
--- a/java/com/google/gerrit/index/query/DataSource.java
+++ b/java/com/google/gerrit/index/query/DataSource.java
@@ -14,16 +14,13 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
 public interface DataSource<T> {
   /** @return an estimate of the number of results from {@link #read()}. */
   int getCardinality();
 
   /** @return read from the database and return the results. */
-  ResultSet<T> read() throws OrmException;
+  ResultSet<T> read();
 
   /** @return read from the database and return the raw results. */
-  ResultSet<FieldBundle> readRaw() throws OrmException;
+  ResultSet<FieldBundle> readRaw();
 }
diff --git a/java/com/google/gerrit/index/query/IndexedQuery.java b/java/com/google/gerrit/index/query/IndexedQuery.java
new file mode 100644
index 0000000..d9e33ea
--- /dev/null
+++ b/java/com/google/gerrit/index/query/IndexedQuery.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.QueryOptions;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Wrapper combining an {@link IndexPredicate} together with a {@link DataSource} that returns
+ * matching results from the index.
+ *
+ * <p>Appropriate to return as the rootmost predicate that can be processed using the secondary
+ * index; such predicates must also implement {@link DataSource} to be chosen by the query
+ * processor.
+ *
+ * @param <I> The type of the IDs by which the entities are stored in the index.
+ * @param <T> The type of the entities that are stored in the index.
+ */
+public class IndexedQuery<I, T> extends Predicate<T> implements DataSource<T>, Paginated<T> {
+  protected final Index<I, T> index;
+
+  private QueryOptions opts;
+  private final Predicate<T> pred;
+  protected DataSource<T> source;
+
+  public IndexedQuery(Index<I, T> index, Predicate<T> pred, QueryOptions opts)
+      throws QueryParseException {
+    this.index = index;
+    this.opts = opts;
+    this.pred = pred;
+    this.source = index.getSource(pred, this.opts);
+  }
+
+  @Override
+  public int getChildCount() {
+    return 1;
+  }
+
+  @Override
+  public Predicate<T> getChild(int i) {
+    if (i == 0) {
+      return pred;
+    }
+    throw new ArrayIndexOutOfBoundsException(i);
+  }
+
+  @Override
+  public List<Predicate<T>> getChildren() {
+    return ImmutableList.of(pred);
+  }
+
+  @Override
+  public QueryOptions getOptions() {
+    return opts;
+  }
+
+  @Override
+  public int getCardinality() {
+    return source != null ? source.getCardinality() : opts.limit();
+  }
+
+  @Override
+  public ResultSet<T> read() {
+    return source.read();
+  }
+
+  @Override
+  public ResultSet<FieldBundle> readRaw() {
+    return source.readRaw();
+  }
+
+  @Override
+  public ResultSet<T> restart(int start) {
+    opts = opts.withStart(start);
+    try {
+      source = index.getSource(pred, opts);
+    } catch (QueryParseException e) {
+      // Don't need to show this exception to the user; the only thing that
+      // changed about pred was its start, and any other QPEs that might happen
+      // should have already thrown from the constructor.
+      throw new StorageException(e);
+    }
+    // Don't convert start to a limit, since the caller of this method (see
+    // AndSource) has calculated the actual number to skip.
+    return read();
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    return this;
+  }
+
+  @Override
+  public int hashCode() {
+    return pred.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == null || getClass() != other.getClass()) {
+      return false;
+    }
+    IndexedQuery<?, ?> o = (IndexedQuery<?, ?>) other;
+    return pred.equals(o.pred) && opts.equals(o.opts);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("index").add("p", pred).add("opts", opts).toString();
+  }
+}
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 66351a8..6780867 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.RangeUtil.Range;
-import com.google.gwtorm.server.OrmException;
 
 public abstract class IntegerRangePredicate<T> extends IndexPredicate<T> {
   private final Range range;
@@ -30,9 +29,9 @@
     }
   }
 
-  protected abstract Integer getValueInt(T object) throws OrmException;
+  protected abstract Integer getValueInt(T object);
 
-  public boolean match(T object) throws OrmException {
+  public boolean match(T object) {
     Integer valueInt = getValueInt(object);
     if (valueInt == null) {
       return false;
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index 3a4b372..48e214e 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -17,16 +17,18 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
-import com.google.gwtorm.server.OrmException;
 import java.util.Arrays;
 import java.util.List;
+import java.util.function.Supplier;
 
 /**
  * Execute a single query over a secondary index, for use by Gerrit internals.
@@ -38,7 +40,7 @@
  * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
  * holding on to a single instance.
  */
-public class InternalQuery<T> {
+public class InternalQuery<T, Q extends InternalQuery<T, Q>> {
   private final QueryProcessor<T> queryProcessor;
   private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
 
@@ -53,34 +55,48 @@
     this.indexConfig = indexConfig;
   }
 
-  public InternalQuery<T> setLimit(int n) {
+  @SuppressWarnings("unchecked")
+  protected final Q self() {
+    return (Q) this;
+  }
+
+  final Q setStart(int start) {
+    queryProcessor.setStart(start);
+    return self();
+  }
+
+  public final Q setLimit(int n) {
     queryProcessor.setUserProvidedLimit(n);
-    return this;
+    return self();
   }
 
-  public InternalQuery<T> enforceVisibility(boolean enforce) {
+  public final Q enforceVisibility(boolean enforce) {
     queryProcessor.enforceVisibility(enforce);
-    return this;
+    return self();
   }
 
-  @SuppressWarnings("unchecked") // Can't set @SafeVarargs on a non-final method.
-  public InternalQuery<T> setRequestedFields(FieldDef<T, ?>... fields) {
+  @SafeVarargs
+  public final Q setRequestedFields(FieldDef<T, ?>... fields) {
     checkArgument(fields.length > 0, "requested field list is empty");
     queryProcessor.setRequestedFields(
         Arrays.stream(fields).map(FieldDef::getName).collect(toSet()));
-    return this;
+    return self();
   }
 
-  public InternalQuery<T> noFields() {
+  public final Q noFields() {
     queryProcessor.setRequestedFields(ImmutableSet.of());
-    return this;
+    return self();
   }
 
-  public List<T> query(Predicate<T> p) throws OrmException {
+  public final List<T> query(Predicate<T> p) {
+    return queryResults(p).entities();
+  }
+
+  final QueryResult<T> queryResults(Predicate<T> p) {
     try {
-      return queryProcessor.query(p).entities();
+      return queryProcessor.query(p);
     } catch (QueryParseException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
@@ -94,16 +110,58 @@
    * @return results of the queries, one list of results per input query, in the same order as the
    *     input.
    */
-  public List<List<T>> query(List<Predicate<T>> queries) throws OrmException {
+  public final List<List<T>> query(List<Predicate<T>> queries) {
     try {
       return Lists.transform(queryProcessor.query(queries), QueryResult::entities);
     } catch (QueryParseException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
-  protected Schema<T> schema() {
+  protected final Schema<T> schema() {
     Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
     return index != null ? index.getSchema() : null;
   }
+
+  /**
+   * Query a predicate repeatedly until all results are exhausted.
+   *
+   * <p>Capable of iterating through all results regardless of limits. The passed {@code
+   * querySupplier} may choose to pre-set limits or not; this only affects the number of queries
+   * that may be issued, not the size of the final results.
+   *
+   * <p>Since multiple queries may be issued, this method is subject to races when the result set
+   * changes mid-iteration. This may result in skipped results, if an entity gets modified to jump
+   * to the front of the list after this method has passed it. It may also result in duplicate
+   * results, if an entity at the end of one batch of results gets pushed back further, putting it
+   * at the beginning of the next batch. This race cannot be avoided unless we change the underlying
+   * index interface to support true continuation tokens.
+   *
+   * @param querySupplier supplier for queries. Callers will generally pass a lambda that invokes an
+   *     underlying {@code Provider<InternalFooQuery>}, since the instances are not reusable. The
+   *     lambda may also call additional methods on the newly-created query, such as {@link
+   *     #enforceVisibility(boolean)}.
+   * @param predicate predicate to search for.
+   * @param <T> result type.
+   * @return exhaustive list of results, subject to the race condition described above.
+   */
+  protected static <T> ImmutableList<T> queryExhaustively(
+      Supplier<? extends InternalQuery<T, ?>> querySupplier, Predicate<T> predicate) {
+    ImmutableList.Builder<T> b = null;
+    int start = 0;
+    while (true) {
+      QueryResult<T> qr = querySupplier.get().setStart(start).queryResults(predicate);
+      if (b == null) {
+        if (!qr.more()) {
+          return qr.entities();
+        }
+        b = ImmutableList.builder();
+      }
+      b.addAll(qr.entities());
+      if (!qr.more()) {
+        return b.build();
+      }
+      start += qr.entities().size();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/index/query/LazyResultSet.java b/java/com/google/gerrit/index/query/LazyResultSet.java
new file mode 100644
index 0000000..f3fab5f
--- /dev/null
+++ b/java/com/google/gerrit/index/query/LazyResultSet.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.index.query;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import java.util.Iterator;
+import java.util.function.Supplier;
+
+/**
+ * Result set that allows for asynchronous execution of the actual query. Callers should dispatch
+ * the query and call the constructor of this class with a supplier that fetches the result and
+ * blocks on it if necessary.
+ *
+ * <p>If the execution is synchronous or the results are known a priori, consider using {@link
+ * ListResultSet}.
+ */
+public class LazyResultSet<T> implements ResultSet<T> {
+  private final Supplier<ImmutableList<T>> resultsCallback;
+
+  private boolean resultsReturned = false;
+
+  public LazyResultSet(Supplier<ImmutableList<T>> r) {
+    resultsCallback = requireNonNull(r, "results can't be null");
+  }
+
+  @Override
+  public Iterator<T> iterator() {
+    return toList().iterator();
+  }
+
+  @Override
+  public ImmutableList<T> toList() {
+    if (resultsReturned) {
+      throw new IllegalStateException("Results already obtained");
+    }
+    resultsReturned = true;
+    return resultsCallback.get();
+  }
+
+  @Override
+  public void close() {}
+}
diff --git a/java/com/google/gerrit/index/query/ListResultSet.java b/java/com/google/gerrit/index/query/ListResultSet.java
new file mode 100644
index 0000000..9d7eadf
--- /dev/null
+++ b/java/com/google/gerrit/index/query/ListResultSet.java
@@ -0,0 +1,57 @@
+// Copyright 2008 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.index.query;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Result set for queries that run synchronously or for cases where the result is already known and
+ * we just need to pipe it back through our interfaces.
+ *
+ * <p>If your implementation benefits from asynchronous execution (i.e. dispatching a query and
+ * awaiting results only when {@link ResultSet#toList()} is called, consider using {@link
+ * LazyResultSet}.
+ */
+public class ListResultSet<T> implements ResultSet<T> {
+  private ImmutableList<T> results;
+
+  public ListResultSet(List<T> r) {
+    results = ImmutableList.copyOf(requireNonNull(r, "results can't be null"));
+  }
+
+  @Override
+  public Iterator<T> iterator() {
+    return toList().iterator();
+  }
+
+  @Override
+  public ImmutableList<T> toList() {
+    if (results == null) {
+      throw new IllegalStateException("Results already obtained");
+    }
+    ImmutableList<T> r = results;
+    results = null;
+    return r;
+  }
+
+  @Override
+  public void close() {
+    results = null;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/Matchable.java b/java/com/google/gerrit/index/query/Matchable.java
index 3d07943..7a16ae8 100644
--- a/java/com/google/gerrit/index/query/Matchable.java
+++ b/java/com/google/gerrit/index/query/Matchable.java
@@ -14,15 +14,9 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gwtorm.server.OrmException;
-
 public interface Matchable<T> {
-  /**
-   * Does this predicate match this object?
-   *
-   * @throws OrmException
-   */
-  boolean match(T object) throws OrmException;
+  /** Does this predicate match this object? */
+  boolean match(T object);
 
   /** @return a cost estimate to run this predicate, higher figures cost more. */
   int getCost();
diff --git a/java/com/google/gerrit/index/query/NotPredicate.java b/java/com/google/gerrit/index/query/NotPredicate.java
index 750759d..14cb740 100644
--- a/java/com/google/gerrit/index/query/NotPredicate.java
+++ b/java/com/google/gerrit/index/query/NotPredicate.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.gwtorm.server.OrmException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -64,7 +63,7 @@
   }
 
   @Override
-  public boolean match(T object) throws OrmException {
+  public boolean match(T object) {
     checkState(
         that.isMatchable(),
         "match invoked, but child predicate %s doesn't implement %s",
diff --git a/java/com/google/gerrit/index/query/OrPredicate.java b/java/com/google/gerrit/index/query/OrPredicate.java
index 8c3ed1c..9bc3769 100644
--- a/java/com/google/gerrit/index/query/OrPredicate.java
+++ b/java/com/google/gerrit/index/query/OrPredicate.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -82,7 +81,7 @@
   }
 
   @Override
-  public boolean match(T object) throws OrmException {
+  public boolean match(T object) {
     for (Predicate<T> c : children) {
       checkState(
           c.isMatchable(),
diff --git a/java/com/google/gerrit/index/query/Paginated.java b/java/com/google/gerrit/index/query/Paginated.java
index 20f65dc..e61dd53 100644
--- a/java/com/google/gerrit/index/query/Paginated.java
+++ b/java/com/google/gerrit/index/query/Paginated.java
@@ -15,11 +15,9 @@
 package com.google.gerrit.index.query;
 
 import com.google.gerrit.index.QueryOptions;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 
 public interface Paginated<T> {
   QueryOptions getOptions();
 
-  ResultSet<T> restart(int start) throws OrmException;
+  ResultSet<T> restart(int start);
 }
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index ca74a52..b5ed82d 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.index.query;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.collect.Iterables;
@@ -82,6 +83,8 @@
 
   /** Invert the passed node. */
   public static <T> Predicate<T> not(Predicate<T> that) {
+    checkArgument(
+        !(that instanceof Any), "negating any() is unsafe because it post-filters all results");
     if (that instanceof NotPredicate) {
       // Negate of a negate is the original predicate.
       //
@@ -105,6 +108,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/QueryBuilder.java b/java/com/google/gerrit/index/query/QueryBuilder.java
index c6c39c3..d24cfeb 100644
--- a/java/com/google/gerrit/index/query/QueryBuilder.java
+++ b/java/com/google/gerrit/index/query/QueryBuilder.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.index.query;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.index.query.Predicate.and;
 import static com.google.gerrit.index.query.Predicate.not;
 import static com.google.gerrit.index.query.Predicate.or;
 import static com.google.gerrit.index.query.QueryParser.AND;
+import static com.google.gerrit.index.query.QueryParser.COLON;
 import static com.google.gerrit.index.query.QueryParser.DEFAULT_FIELD;
 import static com.google.gerrit.index.query.QueryParser.EXACT_PHRASE;
 import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
@@ -25,7 +27,13 @@
 import static com.google.gerrit.index.query.QueryParser.OR;
 import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
 
+import com.google.common.base.Ascii;
+import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -34,9 +42,7 @@
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import org.antlr.runtime.tree.Tree;
 
 /**
@@ -68,45 +74,60 @@
  * <p>Subclasses may also declare a handler for values which appear without operator by overriding
  * {@link #defaultField(String)}.
  *
+ * <p>Instances are non-singletons and should only be used once, in order to rescan the {@code
+ * DynamicMap} of plugin-provided operators on each query invocation.
+ *
  * @param <T> type of object the predicates can evaluate in memory.
  */
-public abstract class QueryBuilder<T> {
+public abstract class QueryBuilder<T, Q extends QueryBuilder<T, Q>> {
   /** Converts a value string passed to an operator into a {@link Predicate}. */
-  public interface OperatorFactory<T, Q extends QueryBuilder<T>> {
+  public interface OperatorFactory<T, Q extends QueryBuilder<T, Q>> {
     Predicate<T> create(Q builder, String value) throws QueryParseException;
   }
 
   /**
    * Defines the operators known by a QueryBuilder.
    *
-   * <p>This class is thread-safe and may be reused or cached.
+   * <p>Operators are discovered by scanning for methods annotated with {@link Operator}. Operator
+   * methods must be public, non-abstract, return a {@code Predicate}, and take a single string as
+   * an argument.
    *
-   * @param <T> type of object the predicates can evaluate in memory.
+   * <p>This class is deeply immutable.
+   *
+   * @param <T> type of object the predicates can evaluate.
    * @param <Q> type of the query builder subclass.
    */
-  public static class Definition<T, Q extends QueryBuilder<T>> {
-    private final Map<String, OperatorFactory<T, Q>> opFactories = new HashMap<>();
+  public static class Definition<T, Q extends QueryBuilder<T, Q>> {
+    private final ImmutableMap<String, OperatorFactory<T, Q>> opFactories;
 
-    public Definition(Class<Q> clazz) {
-      // Guess at the supported operators by scanning methods.
-      //
+    public Definition(Class<? extends Q> clazz) {
+      ImmutableMap.Builder<String, OperatorFactory<T, Q>> b = ImmutableMap.builder();
       Class<?> c = clazz;
       while (c != QueryBuilder.class) {
         for (Method method : c.getDeclaredMethods()) {
-          if (method.getAnnotation(Operator.class) != null
-              && Predicate.class.isAssignableFrom(method.getReturnType())
-              && method.getParameterTypes().length == 1
-              && method.getParameterTypes()[0] == String.class
-              && (method.getModifiers() & Modifier.ABSTRACT) == 0
-              && (method.getModifiers() & Modifier.PUBLIC) == Modifier.PUBLIC) {
-            final String name = method.getName().toLowerCase();
-            if (!opFactories.containsKey(name)) {
-              opFactories.put(name, new ReflectionFactory<T, Q>(name, method));
-            }
+          if (method.getAnnotation(Operator.class) == null) {
+            continue;
           }
+          checkArgument(
+              CharMatcher.ascii().matchesAllOf(method.getName()),
+              "method name must be ASCII: %s",
+              method.getName());
+          checkArgument(
+              Predicate.class.isAssignableFrom(method.getReturnType())
+                  && method.getParameterTypes().length == 1
+                  && method.getParameterTypes()[0] == String.class
+                  && Modifier.isPublic(method.getModifiers())
+                  && !Modifier.isAbstract(method.getModifiers()),
+              "method must be of the form \"@%s public Predicate<T> %s(String value)\": %s",
+              Operator.class.getSimpleName(),
+              method.getName(),
+              method);
+          String name = Ascii.toLowerCase(method.getName());
+          b.put(name, new ReflectionFactory<>(name, method));
         }
         c = c.getSuperclass();
       }
+      opFactories = b.build();
     }
   }
 
@@ -161,14 +182,26 @@
     return null;
   }
 
-  protected final Definition<T, ? extends QueryBuilder<T>> builderDef;
+  protected final Definition<T, Q> builderDef;
+  private final ImmutableMap<String, OperatorFactory<T, Q>> opFactories;
 
-  protected final Map<String, OperatorFactory<?, ?>> opFactories;
-
-  @SuppressWarnings({"unchecked", "rawtypes"})
-  protected QueryBuilder(Definition<T, ? extends QueryBuilder<T>> def) {
+  protected QueryBuilder(
+      Definition<T, Q> def,
+      @Nullable DynamicMap<? extends OperatorFactory<T, Q>> dynamicOpFactories) {
     builderDef = def;
-    opFactories = (Map) def.opFactories;
+
+    if (dynamicOpFactories != null) {
+      ImmutableMap.Builder<String, OperatorFactory<T, Q>> opFactoriesBuilder =
+          ImmutableMap.builder();
+      opFactoriesBuilder.putAll(def.opFactories);
+      for (Extension<? extends OperatorFactory<T, Q>> e : dynamicOpFactories) {
+        String name = e.getExportName() + "_" + e.getPluginName();
+        opFactoriesBuilder.put(name, e.getProvider().get());
+      }
+      opFactories = opFactoriesBuilder.build();
+    } else {
+      opFactories = def.opFactories;
+    }
   }
 
   /**
@@ -214,44 +247,44 @@
         return not(toPredicate(onlyChildOf(r)));
 
       case DEFAULT_FIELD:
-        return defaultField(onlyChildOf(r));
+        return defaultField(concatenateChildText(r));
 
       case FIELD_NAME:
-        return operator(r.getText(), onlyChildOf(r));
+        return operator(r.getText(), concatenateChildText(r));
 
       default:
         throw error("Unsupported operator: " + r);
     }
   }
 
-  private Predicate<T> operator(String name, Tree val) throws QueryParseException {
-    switch (val.getType()) {
-        // Expand multiple values, "foo:(a b c)", as though they were written
-        // out with the longer form, "foo:a foo:b foo:c".
-        //
-      case AND:
-      case OR:
-        {
-          List<Predicate<T>> p = new ArrayList<>(val.getChildCount());
-          for (int i = 0; i < val.getChildCount(); i++) {
-            final Tree c = val.getChild(i);
-            if (c.getType() != DEFAULT_FIELD) {
-              throw error("Nested operator not expected: " + c);
-            }
-            p.add(operator(name, onlyChildOf(c)));
-          }
-          return val.getType() == AND ? and(p) : or(p);
-        }
+  private static String concatenateChildText(Tree r) throws QueryParseException {
+    if (r.getChildCount() == 0) {
+      throw error("Expected children under: " + r);
+    }
+    if (r.getChildCount() == 1) {
+      return getFieldValue(r.getChild(0));
+    }
+    StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < r.getChildCount(); i++) {
+      sb.append(getFieldValue(r.getChild(i)));
+    }
+    return sb.toString();
+  }
 
+  private static String getFieldValue(Tree r) throws QueryParseException {
+    if (r.getChildCount() != 0) {
+      throw error("Expected no children under: " + r);
+    }
+    switch (r.getType()) {
       case SINGLE_WORD:
+      case COLON:
       case EXACT_PHRASE:
-        if (val.getChildCount() != 0) {
-          throw error("Expected no children under: " + val);
-        }
-        return operator(name, val.getText());
-
+        return r.getText();
       default:
-        throw error("Unsupported node in operator " + name + ": " + val);
+        throw error(
+            String.format(
+                "Unsupported %s node in operator %s: %s",
+                QueryParser.tokenNames[r.getType()], r.getParent(), r));
     }
   }
 
@@ -265,20 +298,6 @@
     return f.create(this, value);
   }
 
-  private Predicate<T> defaultField(Tree r) throws QueryParseException {
-    switch (r.getType()) {
-      case SINGLE_WORD:
-      case EXACT_PHRASE:
-        if (r.getChildCount() != 0) {
-          throw error("Expected no children under: " + r);
-        }
-        return defaultField(r.getText());
-
-      default:
-        throw error("Unsupported node: " + r);
-    }
-  }
-
   /**
    * Handle a value present outside of an operator.
    *
@@ -322,7 +341,7 @@
   @Target(ElementType.METHOD)
   protected @interface Operator {}
 
-  private static class ReflectionFactory<T, Q extends QueryBuilder<T>>
+  private static class ReflectionFactory<T, Q extends QueryBuilder<T, Q>>
       implements OperatorFactory<T, Q> {
     private final String name;
     private final Method method;
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index b318199..61d609b 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -17,12 +17,16 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.flogger.LazyArgs.lazy;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Throwables;
 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.exceptions.StorageException;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
@@ -33,11 +37,11 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.ResultSet;
+import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.Metadata;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -51,18 +55,21 @@
  * 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;
 
     Metrics(MetricMaker metricMaker) {
-      Field<String> index = Field.ofString("index", "index name");
       executionTime =
           metricMaker.newTimer(
               "query/query_latency",
               new Description("Successful query latency, accumulated over the life of the process")
                   .setCumulative()
                   .setUnit(Description.Units.MILLISECONDS),
-              index);
+              Field.ofString("index", Metadata.Builder::indexName)
+                  .description("index name")
+                  .build());
     }
   }
 
@@ -73,6 +80,7 @@
   private final IndexRewriter<T> rewriter;
   private final String limitField;
   private final IntSupplier permittedLimit;
+  private final CallerFinder callerFinder;
 
   // This class is not generally thread-safe, but programmer error may result in it being shared
   // across threads. At least ensure the bit for checking if it's been used is threadsafe.
@@ -82,6 +90,7 @@
 
   private boolean enforceVisibility = true;
   private int userProvidedLimit;
+  private boolean isNoLimit;
   private Set<String> requestedFields;
 
   protected QueryProcessor(
@@ -100,6 +109,13 @@
     this.limitField = limitField;
     this.permittedLimit = permittedLimit;
     this.used = new AtomicBoolean(false);
+    this.callerFinder =
+        CallerFinder.builder()
+            .addTarget(InternalQuery.class)
+            .addTarget(QueryProcessor.class)
+            .matchSubClasses(true)
+            .skip(1)
+            .build();
   }
 
   public QueryProcessor<T> setStart(int n) {
@@ -141,6 +157,11 @@
     return this;
   }
 
+  public QueryProcessor<T> setNoLimit(boolean isNoLimit) {
+    this.isNoLimit = isNoLimit;
+    return this;
+  }
+
   public QueryProcessor<T> setRequestedFields(Set<String> fields) {
     requestedFields = fields;
     return this;
@@ -153,7 +174,7 @@
    * @param query the query.
    * @return results of the query.
    */
-  public QueryResult<T> query(Predicate<T> query) throws OrmException, QueryParseException {
+  public QueryResult<T> query(Predicate<T> query) throws QueryParseException {
     return query(ImmutableList.of(query)).get(0);
   }
 
@@ -168,13 +189,10 @@
    * @return results of the queries, one QueryResult per input query, in the same order as the
    *     input.
    */
-  public List<QueryResult<T>> query(List<Predicate<T>> queries)
-      throws OrmException, QueryParseException {
+  public List<QueryResult<T>> query(List<Predicate<T>> queries) throws QueryParseException {
     try {
       return query(null, queries);
-    } catch (OrmRuntimeException e) {
-      throw new OrmException(e.getMessage(), e);
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       if (e.getCause() != null) {
         Throwables.throwIfInstanceOf(e.getCause(), QueryParseException.class);
       }
@@ -183,8 +201,7 @@
   }
 
   private List<QueryResult<T>> query(
-      @Nullable List<String> queryStrings, List<Predicate<T>> queries)
-      throws OrmException, QueryParseException {
+      @Nullable List<String> queryStrings, List<Predicate<T>> queries) throws QueryParseException {
     long startNanos = System.nanoTime();
     checkState(!used.getAndSet(true), "%s has already been used", getClass().getSimpleName());
     int cnt = queries.size();
@@ -199,58 +216,81 @@
       return disabledResults(queryStrings, queries);
     }
 
-    // Parse and rewrite all queries.
-    List<Integer> limits = new ArrayList<>(cnt);
-    List<Predicate<T>> predicates = new ArrayList<>(cnt);
-    List<DataSource<T>> sources = new ArrayList<>(cnt);
-    for (Predicate<T> q : queries) {
-      int limit = getEffectiveLimit(q);
-      limits.add(limit);
+    logger.atFine().log(
+        "Executing %d %s index queries for %s",
+        cnt, schemaDef.getName(), callerFinder.findCaller());
+    List<QueryResult<T>> out;
+    try {
+      // Parse and rewrite all queries.
+      List<Integer> limits = new ArrayList<>(cnt);
+      List<Predicate<T>> predicates = new ArrayList<>(cnt);
+      List<DataSource<T>> sources = new ArrayList<>(cnt);
+      int queryCount = 0;
+      for (Predicate<T> q : queries) {
+        int limit = getEffectiveLimit(q);
+        limits.add(limit);
 
-      if (limit == getBackendSupportedLimit()) {
-        limit--;
+        if (limit == getBackendSupportedLimit()) {
+          limit--;
+        }
+
+        int page = (start / limit) + 1;
+        if (page > indexConfig.maxPages()) {
+          throw new QueryParseException(
+              "Cannot go beyond page " + indexConfig.maxPages() + " of results");
+        }
+
+        // Always bump limit by 1, even if this results in exceeding the permitted
+        // max for this user. The only way to see if there are more entities is to
+        // ask for one more result from the query.
+        QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
+        logger.atFine().log("Query options: " + opts);
+        Predicate<T> pred = rewriter.rewrite(q, opts);
+        if (enforceVisibility) {
+          pred = enforceVisibility(pred);
+        }
+        predicates.add(pred);
+        logger.atFine().log(
+            "%s index query[%d]:\n%s",
+            schemaDef.getName(),
+            queryCount++,
+            pred instanceof IndexedQuery ? pred.getChild(0) : pred);
+
+        @SuppressWarnings("unchecked")
+        DataSource<T> s = (DataSource<T>) pred;
+        sources.add(s);
       }
 
-      int page = (start / limit) + 1;
-      if (page > indexConfig.maxPages()) {
-        throw new QueryParseException(
-            "Cannot go beyond page " + indexConfig.maxPages() + " of results");
+      // Run each query asynchronously, if supported.
+      List<ResultSet<T>> matches = new ArrayList<>(cnt);
+      for (DataSource<T> s : sources) {
+        matches.add(s.read());
       }
 
-      // Always bump limit by 1, even if this results in exceeding the permitted
-      // max for this user. The only way to see if there are more entities is to
-      // ask for one more result from the query.
-      QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
-      Predicate<T> pred = rewriter.rewrite(q, opts);
-      if (enforceVisibility) {
-        pred = enforceVisibility(pred);
+      out = new ArrayList<>(cnt);
+      for (int i = 0; i < cnt; i++) {
+        ImmutableList<T> matchesList = matches.get(i).toList();
+        logger.atFine().log(
+            "Matches[%d]:\n%s",
+            i, lazy(() -> matchesList.stream().map(this::formatForLogging).collect(toSet())));
+        out.add(
+            QueryResult.create(
+                queryStrings != null ? queryStrings.get(i) : null,
+                predicates.get(i),
+                limits.get(i),
+                matchesList));
       }
-      predicates.add(pred);
 
-      @SuppressWarnings("unchecked")
-      DataSource<T> s = (DataSource<T>) pred;
-      sources.add(s);
+      // Only measure successful queries that actually touched the index.
+      metrics.executionTime.record(
+          schemaDef.getName(), System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+    } catch (StorageException e) {
+      Optional<QueryParseException> qpe = findQueryParseException(e);
+      if (qpe.isPresent()) {
+        throw new QueryParseException(qpe.get().getMessage(), e);
+      }
+      throw e;
     }
-
-    // Run each query asynchronously, if supported.
-    List<ResultSet<T>> matches = new ArrayList<>(cnt);
-    for (DataSource<T> s : sources) {
-      matches.add(s.read());
-    }
-
-    List<QueryResult<T>> out = new ArrayList<>(cnt);
-    for (int i = 0; i < cnt; i++) {
-      out.add(
-          QueryResult.create(
-              queryStrings != null ? queryStrings.get(i) : null,
-              predicates.get(i),
-              limits.get(i),
-              matches.get(i).toList()));
-    }
-
-    // Only measure successful queries that actually touched the index.
-    metrics.executionTime.record(
-        schemaDef.getName(), System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
     return out;
   }
 
@@ -286,7 +326,7 @@
       return requestedFields;
     }
     Index<?, T> index = indexes.getSearchIndex();
-    return index != null ? index.getSchema().getStoredFields().keySet() : ImmutableSet.<String>of();
+    return index != null ? index.getSchema().getStoredFields().keySet() : ImmutableSet.of();
   }
 
   /**
@@ -315,6 +355,9 @@
   }
 
   private int getEffectiveLimit(Predicate<T> p) {
+    if (isNoLimit == true) {
+      return Integer.MAX_VALUE;
+    }
     List<Integer> possibleLimits = new ArrayList<>(4);
     possibleLimits.add(getBackendSupportedLimit());
     possibleLimits.add(getPermittedLimit());
@@ -332,4 +375,13 @@
     checkState(result > 0, "effective limit should be positive");
     return result;
   }
+
+  private static Optional<QueryParseException> findQueryParseException(Throwable t) {
+    return Throwables.getCausalChain(t).stream()
+        .filter(c -> c instanceof QueryParseException)
+        .map(QueryParseException.class::cast)
+        .findFirst();
+  }
+
+  protected abstract String formatForLogging(T t);
 }
diff --git a/java/com/google/gerrit/index/query/QueryResult.java b/java/com/google/gerrit/index/query/QueryResult.java
index 341e2b6..33fcef0 100644
--- a/java/com/google/gerrit/index/query/QueryResult.java
+++ b/java/com/google/gerrit/index/query/QueryResult.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index.query;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import java.util.List;
 
@@ -22,15 +23,15 @@
 @AutoValue
 public abstract class QueryResult<T> {
   public static <T> QueryResult<T> create(
-      @Nullable String query, Predicate<T> predicate, int limit, List<T> entites) {
+      @Nullable String query, Predicate<T> predicate, int limit, List<T> entities) {
     boolean more;
-    if (entites.size() > limit) {
+    if (entities.size() > limit) {
       more = true;
-      entites = entites.subList(0, limit);
+      entities = entities.subList(0, limit);
     } else {
       more = false;
     }
-    return new AutoValue_QueryResult<>(query, predicate, entites, more);
+    return new AutoValue_QueryResult<>(query, predicate, ImmutableList.copyOf(entities), more);
   }
 
   /** @return the original query string, or null if the query was created programmatically. */
@@ -41,7 +42,7 @@
   public abstract Predicate<T> predicate();
 
   /** @return the query results. */
-  public abstract List<T> entities();
+  public abstract ImmutableList<T> entities();
 
   /**
    * @return whether the query could be retried with a higher start/limit to produce more results.
diff --git a/java/com/google/gerrit/index/query/ResultSet.java b/java/com/google/gerrit/index/query/ResultSet.java
new file mode 100644
index 0000000..65fcd45
--- /dev/null
+++ b/java/com/google/gerrit/index/query/ResultSet.java
@@ -0,0 +1,52 @@
+// Copyright 2008 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.index.query;
+
+import com.google.common.collect.ImmutableList;
+import java.util.Iterator;
+
+/**
+ * Result from any data store query function.
+ *
+ * @param <T> type of entity being returned by the query.
+ */
+public interface ResultSet<T> extends Iterable<T> {
+  /**
+   * Obtain an iterator to loop through the results.
+   *
+   * <p>The iterator can be obtained only once. When the iterator completes ( <code>hasNext()</code>
+   * returns false) {@link #close()} will be automatically called.
+   */
+  @Override
+  Iterator<T> iterator();
+
+  /**
+   * Materialize all results as a single list.
+   *
+   * <p>Prior to returning {@link #close()} is invoked. This method must not be combined with {@link
+   * #iterator()} on the same instance.
+   *
+   * @return immutable list of the complete results.
+   */
+  ImmutableList<T> toList();
+
+  /**
+   * Close the result, discarding any further results.
+   *
+   * <p>This method may be invoked more than once. Its main use is to stop obtaining results before
+   * the iterator has finished.
+   */
+  void close();
+}
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index edc2120..38b2b73 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.index.query;
 
 import com.google.gerrit.index.FieldDef;
-import com.google.gwtjsonrpc.common.JavaSqlTimestampHelper;
+import com.google.gerrit.json.JavaSqlTimestampHelper;
 import java.sql.Timestamp;
 import java.util.Date;
 
diff --git a/java/com/google/gerrit/index/query/testing/BUILD b/java/com/google/gerrit/index/query/testing/BUILD
new file mode 100644
index 0000000..ee346a8
--- /dev/null
+++ b/java/com/google/gerrit/index/query/testing/BUILD
@@ -0,0 +1,15 @@
+package(
+    default_testonly = True,
+    default_visibility = ["//visibility:public"],
+)
+
+java_library(
+    name = "testing",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//antlr3:query_parser",
+        "//lib:guava",
+        "//lib/antlr:java-runtime",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/index/query/testing/TreeSubject.java b/java/com/google/gerrit/index/query/testing/TreeSubject.java
new file mode 100644
index 0000000..7d2b868
--- /dev/null
+++ b/java/com/google/gerrit/index/query/testing/TreeSubject.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.index.query.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Truth.assertAbout;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.index.query.QueryParser;
+import org.antlr.runtime.tree.Tree;
+
+public class TreeSubject extends Subject {
+  public static TreeSubject assertThat(Tree actual) {
+    return assertAbout(TreeSubject::new).that(actual);
+  }
+
+  private final Tree tree;
+
+  private TreeSubject(FailureMetadata failureMetadata, Tree tree) {
+    super(failureMetadata, tree);
+    this.tree = tree;
+  }
+
+  public void hasType(int expectedType) {
+    isNotNull();
+    check("getType()").that(typeName(tree.getType())).isEqualTo(typeName(expectedType));
+  }
+
+  public void hasText(String expectedText) {
+    requireNonNull(expectedText);
+    isNotNull();
+    check("getText()").that(tree.getText()).isEqualTo(expectedText);
+  }
+
+  public void hasNoChildren() {
+    isNotNull();
+    check("getChildCount()").that(tree.getChildCount()).isEqualTo(0);
+  }
+
+  public void hasChildCount(int expectedChildCount) {
+    checkArgument(
+        expectedChildCount > 0, "expected child count must be positive: %s", expectedChildCount);
+    isNotNull();
+    check("getChildCount()").that(tree.getChildCount()).isEqualTo(expectedChildCount);
+  }
+
+  public TreeSubject child(int childIndex) {
+    isNotNull();
+    return check("getChild(%s)", childIndex)
+        .about(TreeSubject::new)
+        .that(tree.getChild(childIndex));
+  }
+
+  private static String typeName(int type) {
+    checkArgument(
+        type >= 0 && type < QueryParser.tokenNames.length,
+        "invalid token type %s, max is %s",
+        type,
+        QueryParser.tokenNames.length - 1);
+    return QueryParser.tokenNames[type];
+  }
+}
diff --git a/java/com/google/gerrit/jgit/BUILD b/java/com/google/gerrit/jgit/BUILD
new file mode 100644
index 0000000..2387614
--- /dev/null
+++ b/java/com/google/gerrit/jgit/BUILD
@@ -0,0 +1,11 @@
+java_library(
+    name = "jgit",
+    srcs = [
+        "diff/ReplaceEdit.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:gson",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/jgit/diff/ReplaceEdit.java b/java/com/google/gerrit/jgit/diff/ReplaceEdit.java
new file mode 100644
index 0000000..45bfad2
--- /dev/null
+++ b/java/com/google/gerrit/jgit/diff/ReplaceEdit.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.jgit.diff;
+
+import java.util.List;
+import org.eclipse.jgit.diff.Edit;
+
+public class ReplaceEdit extends Edit {
+  private List<Edit> internalEdit;
+
+  public ReplaceEdit(int as, int ae, int bs, int be, List<Edit> internal) {
+    super(as, ae, bs, be);
+    internalEdit = internal;
+  }
+
+  public ReplaceEdit(Edit orig, List<Edit> internal) {
+    super(orig.getBeginA(), orig.getEndA(), orig.getBeginB(), orig.getEndB());
+    internalEdit = internal;
+  }
+
+  public List<Edit> getInternalEdits() {
+    return internalEdit;
+  }
+}
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
new file mode 100644
index 0000000..030dddc
--- /dev/null
+++ b/java/com/google/gerrit/json/BUILD
@@ -0,0 +1,9 @@
+java_library(
+    name = "json",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:gson",
+        "//lib/flogger:api",
+    ],
+)
diff --git a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
new file mode 100644
index 0000000..dc74f67
--- /dev/null
+++ b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.json;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.internal.bind.TypeAdapters;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+import java.io.IOException;
+
+/**
+ * A {@code TypeAdapterFactory} for enums.
+ *
+ * <p>This factory introduces a wrapper around Gson's own default enum handler to add the following
+ * special behavior: log when input which doesn't match any existing enum value is encountered.
+ */
+public class EnumTypeAdapterFactory implements TypeAdapterFactory {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @SuppressWarnings({"unchecked"})
+  @Override
+  public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
+    TypeAdapter<T> defaultEnumAdapter = TypeAdapters.ENUM_FACTORY.create(gson, typeToken);
+    if (defaultEnumAdapter == null) {
+      // Not an enum. -> Enum type adapter doesn't apply.
+      return null;
+    }
+
+    return (TypeAdapter<T>) new EnumTypeAdapter(defaultEnumAdapter, typeToken);
+  }
+
+  private static class EnumTypeAdapter<T extends Enum<T>> extends TypeAdapter<T> {
+
+    private final TypeAdapter<T> defaultEnumAdapter;
+    private final TypeToken<T> typeToken;
+
+    public EnumTypeAdapter(TypeAdapter<T> defaultEnumAdapter, TypeToken<T> typeToken) {
+      this.defaultEnumAdapter = defaultEnumAdapter;
+      this.typeToken = typeToken;
+    }
+
+    @Override
+    public T read(JsonReader in) throws IOException {
+      // Still handle null values. -> Check them first.
+      if (in.peek() == JsonToken.NULL) {
+        in.nextNull();
+        return null;
+      }
+      T enumValue = defaultEnumAdapter.read(in);
+      if (enumValue == null) {
+        logger.atWarning().log("Expected an existing value for enum %s.", typeToken);
+      }
+      return enumValue;
+    }
+
+    @Override
+    public void write(JsonWriter out, T value) throws IOException {
+      defaultEnumAdapter.write(out, value);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/json/JavaSqlTimestampHelper.java b/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
new file mode 100644
index 0000000..b59cbd0d
--- /dev/null
+++ b/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
@@ -0,0 +1,108 @@
+// Copyright 2008 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.json;
+
+import java.sql.Timestamp;
+import java.util.Date;
+
+/** Utility to parse Timestamp from a string. */
+public class JavaSqlTimestampHelper {
+  /**
+   * Parse a string into a timestamp.
+   *
+   * <p>Note that {@link Timestamp}s have no timezone, so the result is relative to the UTC epoch.
+   *
+   * <p>Supports the format {@code yyyy-MM-dd[ HH:mm:ss[.SSS][ Z]]} where {@code Z} is a 4-digit
+   * offset with sign, e.g. {@code -0500}.
+   *
+   * @param s input string.
+   * @return resulting timestamp.
+   */
+  public static Timestamp parseTimestamp(String s) {
+    String[] components = s.split(" ");
+    if (components.length < 1 || components.length > 3) {
+      throw new IllegalArgumentException("Expected date and optional time: " + s);
+    }
+    String date = components[0];
+    String time = components.length >= 2 ? components[1] : null;
+    int off = components.length == 3 ? parseTimeZone(components[2]) : 0;
+    String[] dSplit = date.split("-");
+    if (dSplit.length != 3) {
+      throw new IllegalArgumentException("Invalid date format: " + date);
+    }
+    int yy, mm, dd;
+    try {
+      yy = Integer.parseInt(dSplit[0]) - 1900;
+      mm = Integer.parseInt(dSplit[1]) - 1;
+      dd = Integer.parseInt(dSplit[2]);
+    } catch (NumberFormatException e) {
+      throw new IllegalArgumentException("Invalid date format: " + date, e);
+    }
+
+    int hh, mi, ss, ns;
+    if (time != null) {
+      int p = time.indexOf('.');
+      String t;
+      double f;
+      try {
+        if (p >= 0) {
+          t = time.substring(0, p);
+          f = Double.parseDouble("0." + time.substring(p + 1));
+        } else {
+          t = time;
+          f = 0;
+        }
+        String[] tSplit = t.split(":");
+        if (tSplit.length != 3) {
+          throw new IllegalArgumentException("Invalid time format: " + time);
+        }
+        hh = Integer.parseInt(tSplit[0]);
+        mi = Integer.parseInt(tSplit[1]);
+        ss = Integer.parseInt(tSplit[2]);
+        ns = (int) Math.round(f * 1e9);
+      } catch (NumberFormatException e) {
+        throw new IllegalArgumentException("Invalid time format: " + time, e);
+      }
+    } else {
+      hh = 0;
+      mi = 0;
+      ss = 0;
+      ns = 0;
+    }
+    @SuppressWarnings("deprecation")
+    Timestamp result = new Timestamp(Date.UTC(yy, mm, dd, hh, mi, ss) - off);
+    result.setNanos(ns);
+    return result;
+  }
+
+  private static int parseTimeZone(String s) {
+    if (s.length() != 5 || (s.charAt(0) != '-' && s.charAt(0) != '+')) {
+      throw new IllegalArgumentException("Invalid time zone: " + s);
+    }
+    for (int i = 1; i < s.length(); i++) {
+      if (s.charAt(i) < '0' || s.charAt(i) > '9') {
+        throw new IllegalArgumentException("Invalid time zone: " + s);
+      }
+    }
+    int off =
+        (s.charAt(0) == '-' ? -1 : 1)
+            * 60
+            * 1000
+            * ((60 * Integer.parseInt(s.substring(1, 3))) + Integer.parseInt(s.substring(3, 5)));
+    return off;
+  }
+
+  private JavaSqlTimestampHelper() {}
+}
diff --git a/java/com/google/gerrit/json/OutputFormat.java b/java/com/google/gerrit/json/OutputFormat.java
new file mode 100644
index 0000000..3e7c319
--- /dev/null
+++ b/java/com/google/gerrit/json/OutputFormat.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.json;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import java.sql.Timestamp;
+
+/** Standard output format used by an API call. */
+public enum OutputFormat {
+  /**
+   * The output is a human readable text format. It may also be regular enough to be machine
+   * readable. Whether or not the text format is machine readable and will be committed to as a long
+   * term format that tools can build upon is specific to each API call.
+   */
+  TEXT,
+
+  /**
+   * Pretty-printed JSON format. This format uses whitespace to make the output readable by a human,
+   * but is also machine readable with a JSON library. The structure of the output is a long term
+   * format that tools can rely upon.
+   */
+  JSON,
+
+  /**
+   * Same as {@link #JSON}, but with unnecessary whitespace removed to save generation time and copy
+   * costs. Typically JSON_COMPACT format is used by a browser based HTML client running over the
+   * network.
+   */
+  JSON_COMPACT;
+
+  /** @return true when the format is either JSON or JSON_COMPACT. */
+  public boolean isJson() {
+    return this == JSON_COMPACT || this == JSON;
+  }
+
+  /** @return a new Gson instance configured according to the format. */
+  public GsonBuilder newGsonBuilder() {
+    if (!isJson()) {
+      throw new IllegalStateException(String.format("%s is not JSON", this));
+    }
+    GsonBuilder gb =
+        new GsonBuilder()
+            .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+            .registerTypeAdapter(Timestamp.class, new SqlTimestampDeserializer())
+            .registerTypeAdapterFactory(new EnumTypeAdapterFactory());
+    if (this == OutputFormat.JSON) {
+      gb.setPrettyPrinting();
+    }
+    return gb;
+  }
+
+  /** @return a new Gson instance configured according to the format. */
+  public Gson newGson() {
+    return newGsonBuilder().create();
+  }
+}
diff --git a/java/com/google/gerrit/json/SqlTimestampDeserializer.java b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
new file mode 100644
index 0000000..e1cf382
--- /dev/null
+++ b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
@@ -0,0 +1,72 @@
+// Copyright 2008 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.json;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.text.SimpleDateFormat;
+import java.util.TimeZone;
+
+class SqlTimestampDeserializer implements JsonDeserializer<Timestamp>, JsonSerializer<Timestamp> {
+  private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
+
+  @Override
+  public Timestamp deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (json.isJsonNull()) {
+      return null;
+    }
+    if (!json.isJsonPrimitive()) {
+      throw new JsonParseException("Expected string for timestamp type");
+    }
+    JsonPrimitive p = (JsonPrimitive) json;
+    if (!p.isString()) {
+      throw new JsonParseException("Expected string for timestamp type");
+    }
+
+    String input = p.getAsString();
+    if (input.trim().isEmpty()) {
+      // Magic timestamp to indicate no timestamp. (-> null object)
+      // Always create a new object as timestamps are mutable. Don't use TimeUtil.never() to not
+      // introduce an undesired dependency.
+      return new Timestamp(0);
+    }
+
+    return JavaSqlTimestampHelper.parseTimestamp(input);
+  }
+
+  @Override
+  public JsonElement serialize(Timestamp src, Type typeOfSrc, JsonSerializationContext context) {
+    if (src == null) {
+      return JsonNull.INSTANCE;
+    }
+    return new JsonPrimitive(newFormat().format(src) + "000000");
+  }
+
+  private static SimpleDateFormat newFormat() {
+    SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
+    f.setTimeZone(UTC);
+    f.setLenient(true);
+    return f;
+  }
+}
diff --git a/java/com/google/gerrit/launcher/BUILD b/java/com/google/gerrit/launcher/BUILD
index 18dcd52..bac0c53 100644
--- a/java/com/google/gerrit/launcher/BUILD
+++ b/java/com/google/gerrit/launcher/BUILD
@@ -3,17 +3,5 @@
 java_library(
     name = "launcher",
     srcs = ["GerritLauncher.java"],
-    resources = [":workspace-root.txt"],
-    visibility = ["//visibility:public"],
-)
-
-# The root of the workspace is non-hermetic, but we need it for
-# on-the-fly GWT recompiles and PolyGerrit updates.
-genrule(
-    name = "gen_root",
-    outs = ["workspace-root.txt"],
-    cmd = ("cat bazel-out/stable-status.txt | " +
-           "grep STABLE_WORKSPACE_ROOT | cut -d ' ' -f 2 > $@"),
-    stamp = 1,
     visibility = ["//visibility:public"],
 )
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 13dad0e..077c763a 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,7 +45,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Scanner;
+import java.util.Properties;
 import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.jar.Attributes;
@@ -76,7 +77,7 @@
    * @throws Exception if any error occurs.
    */
   public static int mainImpl(String[] argv) throws Exception {
-    if (argv.length == 0) {
+    if (argv.length == 0 || "-h".equals(argv[0]) || "--help".equals(argv[0])) {
       File me;
       try {
         me = getDistributionArchive();
@@ -92,7 +93,6 @@
       System.err.println("  init            Initialize a Gerrit installation");
       System.err.println("  reindex         Rebuild the secondary index");
       System.err.println("  daemon          Run the Gerrit network daemons");
-      System.err.println("  gsql            Run the interactive query console");
       System.err.println("  version         Display the build version number");
       System.err.println("  passwd          Set or change password in secure.config");
 
@@ -323,7 +323,7 @@
     }
 
     String name = ze.getName();
-    jars.put(name.substring(name.lastIndexOf('/'), name.length()), tmp.toURI().toURL());
+    jars.put(name.substring(name.lastIndexOf('/')), tmp.toURI().toURL());
   }
 
   private static void move(SortedMap<String, URL> jars, String prefix, List<URL> extapi) {
@@ -624,9 +624,7 @@
    * @return true if any thread has a stack frame in {@code org.eclipse.jdt}.
    */
   public static boolean isRunningInEclipse() {
-    return Thread.getAllStackTraces()
-        .values()
-        .stream()
+    return Thread.getAllStackTraces().values().stream()
         .flatMap(Arrays::stream)
         .anyMatch(e -> e.getClassName().startsWith("org.eclipse.jdt."));
   }
@@ -644,8 +642,26 @@
     return resolveInSourceRoot("eclipse-out");
   }
 
-  static final String SOURCE_ROOT_RESOURCE = "/com/google/gerrit/launcher/workspace-root.txt";
+  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";
   /**
    * Locate a path in the source tree.
    *
@@ -657,48 +673,40 @@
     // Find ourselves in the classpath, as a loose class file or jar.
     Class<GerritLauncher> self = GerritLauncher.class;
 
-    // If the build system provides us with a source root, use that.
-    try (InputStream stream = self.getResourceAsStream(SOURCE_ROOT_RESOURCE)) {
-      if (stream != null) {
-        try (Scanner scan = new Scanner(stream, UTF_8.name()).useDelimiter("\n")) {
-          if (scan.hasNext()) {
-            Path p = Paths.get(scan.next());
-            if (!Files.exists(p)) {
-              throw new FileNotFoundException("source root not found: " + p);
-            }
-            return p;
-          }
+    Path dir;
+    String sourceRoot = System.getProperty("sourceRoot");
+    if (sourceRoot != null) {
+      dir = Paths.get(sourceRoot);
+      if (!Files.exists(dir)) {
+        throw new FileNotFoundException("source root not found: " + dir);
+      }
+    } else {
+      URL u = self.getResource(self.getSimpleName() + ".class");
+      if (u == null) {
+        throw new FileNotFoundException("Cannot find class " + self.getName());
+      } else if ("jar".equals(u.getProtocol())) {
+        String p = u.getPath();
+        try {
+          u = new URL(p.substring(0, p.indexOf('!')));
+        } catch (MalformedURLException e) {
+          FileNotFoundException fnfe = new FileNotFoundException("Not a valid jar file: " + u);
+          fnfe.initCause(e);
+          throw fnfe;
         }
       }
-    } catch (IOException e) {
-      // not Bazel, then.
-    }
-
-    URL u = self.getResource(self.getSimpleName() + ".class");
-    if (u == null) {
-      throw new FileNotFoundException("Cannot find class " + self.getName());
-    } else if ("jar".equals(u.getProtocol())) {
-      String p = u.getPath();
-      try {
-        u = new URL(p.substring(0, p.indexOf('!')));
-      } catch (MalformedURLException e) {
-        FileNotFoundException fnfe = new FileNotFoundException("Not a valid jar file: " + u);
-        fnfe.initCause(e);
-        throw fnfe;
+      if (!"file".equals(u.getProtocol())) {
+        throw new FileNotFoundException("Cannot extract path from " + u);
       }
-    }
-    if (!"file".equals(u.getProtocol())) {
-      throw new FileNotFoundException("Cannot extract path from " + u);
-    }
 
-    // Pop up to the top-level source folder by looking for .buckconfig.
-    Path dir = Paths.get(u.getPath());
-    while (!Files.isRegularFile(dir.resolve("WORKSPACE"))) {
-      Path parent = dir.getParent();
-      if (parent == null) {
-        throw new FileNotFoundException("Cannot find source root from " + u);
+      // Pop up to the top-level source folder by looking for WORKSPACE.
+      dir = Paths.get(u.getPath());
+      while (!Files.isRegularFile(dir.resolve("WORKSPACE"))) {
+        Path parent = dir.getParent();
+        if (parent == null) {
+          throw new FileNotFoundException("Cannot find source root from " + u);
+        }
+        dir = parent;
       }
-      dir = parent;
     }
 
     Path ret = dir.resolve(name);
@@ -708,14 +716,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 +754,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/lifecycle/LifecycleManager.java b/java/com/google/gerrit/lifecycle/LifecycleManager.java
index ba3d7b2..4f09a09 100644
--- a/java/com/google/gerrit/lifecycle/LifecycleManager.java
+++ b/java/com/google/gerrit/lifecycle/LifecycleManager.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.lifecycle;
 
-import com.google.common.base.Preconditions;
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.events.LifecycleListener;
@@ -69,7 +70,7 @@
    * @param injector the injector to add.
    */
   public void add(Injector injector) {
-    Preconditions.checkState(startedIndex < 0, "Already started");
+    checkState(startedIndex < 0, "Already started");
     for (Binding<LifecycleListener> binding : get(injector)) {
       add(binding.getProvider());
     }
diff --git a/java/com/google/gerrit/lifecycle/LifecycleModule.java b/java/com/google/gerrit/lifecycle/LifecycleModule.java
index bfb61d2..0fb4653 100644
--- a/java/com/google/gerrit/lifecycle/LifecycleModule.java
+++ b/java/com/google/gerrit/lifecycle/LifecycleModule.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.lifecycle;
 
 import com.google.gerrit.extensions.config.FactoryModule;
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 3871ced..7a0430c 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -20,6 +20,7 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -29,6 +30,8 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.Index;
@@ -37,16 +40,14 @@
 import com.google.gerrit.index.Schema.Values;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.ListResultSet;
+import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
+import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import java.io.IOException;
 import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
@@ -54,6 +55,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;
@@ -61,15 +63,14 @@
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.Field.Store;
-import org.apache.lucene.document.IntField;
-import org.apache.lucene.document.LongField;
+import org.apache.lucene.document.LegacyIntField;
+import org.apache.lucene.document.LegacyLongField;
 import org.apache.lucene.document.StoredField;
 import org.apache.lucene.document.StringField;
 import org.apache.lucene.document.TextField;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexableField;
 import org.apache.lucene.index.Term;
-import org.apache.lucene.index.TrackingIndexWriter;
 import org.apache.lucene.search.ControlledRealTimeReopenThread;
 import org.apache.lucene.search.IndexSearcher;
 import org.apache.lucene.search.Query;
@@ -83,6 +84,7 @@
 import org.apache.lucene.store.Directory;
 
 /** Basic Lucene index implementation. */
+@SuppressWarnings("deprecation")
 public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -95,11 +97,11 @@
   private final Directory dir;
   private final String name;
   private final ListeningExecutorService writerThread;
-  private final TrackingIndexWriter writer;
+  private final IndexWriter writer;
   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,
@@ -115,25 +117,25 @@
     this.dir = dir;
     this.name = name;
     String index = Joiner.on('_').skipNulls().join(name, subIndex);
-    IndexWriter delegateWriter;
     long commitPeriod = writerConfig.getCommitWithinMs();
 
     if (commitPeriod < 0) {
-      delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
+      writer = new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
     } else if (commitPeriod == 0) {
-      delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig(), true);
+      writer = new AutoCommitWriter(dir, writerConfig.getLuceneConfig(), true);
     } else {
       final AutoCommitWriter autoCommitWriter =
           new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
-      delegateWriter = autoCommitWriter;
+      writer = 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(
@@ -161,19 +163,19 @@
               commitPeriod,
               MILLISECONDS);
     }
-    writer = new TrackingIndexWriter(delegateWriter);
-    searcherManager = new WrappableSearcherManager(writer.getIndexWriter(), true, searcherFactory);
+    searcherManager = new WrappableSearcherManager(writer, true, searcherFactory);
 
     notDoneNrtFutures = Sets.newConcurrentHashSet();
 
     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<>(
@@ -211,7 +213,7 @@
   }
 
   @Override
-  public void markReady(boolean ready) throws IOException {
+  public void markReady(boolean ready) {
     IndexUtils.setReady(sitePaths, name, schema.getVersion(), ready);
   }
 
@@ -246,7 +248,7 @@
     }
 
     try {
-      writer.getIndexWriter().close();
+      writer.close();
     } catch (AlreadyClosedException e) {
       // Ignore.
     } catch (IOException e) {
@@ -285,11 +287,15 @@
   }
 
   @Override
-  public void deleteAll() throws IOException {
-    writer.deleteAll();
+  public void deleteAll() {
+    try {
+      writer.deleteAll();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
   }
 
-  public TrackingIndexWriter getWriter() {
+  public IndexWriter getWriter() {
     return writer;
   }
 
@@ -311,6 +317,14 @@
     return result;
   }
 
+  /**
+   * Trasform an index document into a target object type.
+   *
+   * @param doc index document
+   * @return target object, or null if the target object was not found or failed to load from the
+   *     underlying store.
+   */
+  @Nullable
   protected abstract V fromDocument(Document doc);
 
   void add(Document doc, Values<V> values) {
@@ -320,15 +334,15 @@
 
     if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
       for (Object value : values.getValues()) {
-        doc.add(new IntField(name, (Integer) value, store));
+        doc.add(new LegacyIntField(name, (Integer) value, store));
       }
     } else if (type == FieldType.LONG) {
       for (Object value : values.getValues()) {
-        doc.add(new LongField(name, (Long) value, store));
+        doc.add(new LegacyLongField(name, (Long) value, store));
       }
     } else if (type == FieldType.TIMESTAMP) {
       for (Object value : values.getValues()) {
-        doc.add(new LongField(name, ((Timestamp) value).getTime(), store));
+        doc.add(new LegacyLongField(name, ((Timestamp) value).getTime(), store));
       }
     } else if (type == FieldType.EXACT || type == FieldType.PREFIX) {
       for (Object value : values.getValues()) {
@@ -476,49 +490,33 @@
     }
 
     @Override
-    public ResultSet<V> read() throws OrmException {
+    public ResultSet<V> read() {
       return readImpl(AbstractLuceneIndex.this::fromDocument);
     }
 
     @Override
-    public ResultSet<FieldBundle> readRaw() throws OrmException {
+    public ResultSet<FieldBundle> readRaw() {
       return readImpl(AbstractLuceneIndex.this::toFieldBundle);
     }
 
-    private <T> ResultSet<T> readImpl(Function<Document, T> mapper) throws OrmException {
+    private <T> ResultSet<T> readImpl(Function<Document, T> mapper) {
       IndexSearcher searcher = null;
       try {
         searcher = acquire();
         int realLimit = opts.start() + opts.limit();
         TopFieldDocs docs = searcher.search(query, realLimit, sort);
-        List<T> result = new ArrayList<>(docs.scoreDocs.length);
+        ImmutableList.Builder<T> b = ImmutableList.builderWithExpectedSize(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
           ScoreDoc sd = docs.scoreDocs[i];
           Document doc = searcher.doc(sd.doc, opts.fields());
           T mapperResult = mapper.apply(doc);
           if (mapperResult != null) {
-            result.add(mapperResult);
+            b.add(mapperResult);
           }
         }
-        final List<T> r = Collections.unmodifiableList(result);
-        return new ResultSet<T>() {
-          @Override
-          public Iterator<T> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<T> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
+        return new ListResultSet<>(b.build());
       } catch (IOException e) {
-        throw new OrmException(e);
+        throw new StorageException(e);
       } finally {
         if (searcher != null) {
           try {
diff --git a/java/com/google/gerrit/lucene/AutoCommitWriter.java b/java/com/google/gerrit/lucene/AutoCommitWriter.java
index 7a418aa..2cc7563 100644
--- a/java/com/google/gerrit/lucene/AutoCommitWriter.java
+++ b/java/com/google/gerrit/lucene/AutoCommitWriter.java
@@ -47,58 +47,64 @@
   }
 
   @Override
-  public void addDocument(Iterable<? extends IndexableField> doc) throws IOException {
-    super.addDocument(doc);
+  public long addDocument(Iterable<? extends IndexableField> doc) throws IOException {
+    long ret = super.addDocument(doc);
     autoFlush();
+    return ret;
   }
 
   @Override
-  public void addDocuments(Iterable<? extends Iterable<? extends IndexableField>> docs)
+  public long addDocuments(Iterable<? extends Iterable<? extends IndexableField>> docs)
       throws IOException {
-    super.addDocuments(docs);
+    long ret = super.addDocuments(docs);
     autoFlush();
+    return ret;
   }
 
   @Override
-  public void updateDocuments(
+  public long updateDocuments(
       Term delTerm, Iterable<? extends Iterable<? extends IndexableField>> docs)
       throws IOException {
-    super.updateDocuments(delTerm, docs);
+    long ret = super.updateDocuments(delTerm, docs);
     autoFlush();
+    return ret;
   }
 
   @Override
-  public void deleteDocuments(Term... term) throws IOException {
-    super.deleteDocuments(term);
+  public long deleteDocuments(Term... term) throws IOException {
+    long ret = super.deleteDocuments(term);
     autoFlush();
+    return ret;
   }
 
   @Override
-  public synchronized boolean tryDeleteDocument(IndexReader readerIn, int docID)
-      throws IOException {
-    boolean ret = super.tryDeleteDocument(readerIn, docID);
-    if (ret) {
+  public synchronized long tryDeleteDocument(IndexReader readerIn, int docID) throws IOException {
+    long ret = super.tryDeleteDocument(readerIn, docID);
+    if (ret != -1) {
       autoFlush();
     }
     return ret;
   }
 
   @Override
-  public void deleteDocuments(Query... queries) throws IOException {
-    super.deleteDocuments(queries);
+  public long deleteDocuments(Query... queries) throws IOException {
+    long ret = super.deleteDocuments(queries);
     autoFlush();
+    return ret;
   }
 
   @Override
-  public void updateDocument(Term term, Iterable<? extends IndexableField> doc) throws IOException {
-    super.updateDocument(term, doc);
+  public long updateDocument(Term term, Iterable<? extends IndexableField> doc) throws IOException {
+    long ret = super.updateDocument(term, doc);
     autoFlush();
+    return ret;
   }
 
   @Override
-  public void deleteAll() throws IOException {
-    super.deleteAll();
+  public long deleteAll() throws IOException {
+    long ret = super.deleteAll();
     autoFlush();
+    return ret;
   }
 
   void manualFlush() throws IOException {
diff --git a/java/com/google/gerrit/lucene/BUILD b/java/com/google/gerrit/lucene/BUILD
index 6cb7751..fa4c923 100644
--- a/java/com/google/gerrit/lucene/BUILD
+++ b/java/com/google/gerrit/lucene/BUILD
@@ -10,7 +10,6 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/lucene:lucene-core-and-backward-codecs",
     ],
 )
@@ -26,15 +25,18 @@
         ":query_builder",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
-        "//lib:gwtorm",
+        "//lib:protobuf",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 7d7cbef..98424b5 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -71,12 +71,12 @@
   }
 
   @Override
-  public void replace(ChangeData obj) throws IOException {
+  public void replace(ChangeData obj) {
     throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
   }
 
   @Override
-  public void delete(Change.Id key) throws IOException {
+  public void delete(Change.Id key) {
     throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
   }
 
diff --git a/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java b/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
index ada3220..75e03e3 100644
--- a/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
+++ b/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
@@ -19,8 +19,8 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.server.config.ConfigUtil;
+import org.apache.lucene.analysis.CharArraySet;
 import org.apache.lucene.analysis.standard.StandardAnalyzer;
-import org.apache.lucene.analysis.util.CharArraySet;
 import org.apache.lucene.index.IndexWriterConfig;
 import org.apache.lucene.index.IndexWriterConfig.OpenMode;
 import org.eclipse.jgit.lib.Config;
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 91d0e90..8e67fda 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -14,10 +14,16 @@
 
 package com.google.gerrit.lucene;
 
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.gerrit.server.index.account.AccountField.FULL_NAME;
 import static com.google.gerrit.server.index.account.AccountField.ID;
+import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT;
 
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.Schema.Values;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -35,6 +41,8 @@
 import java.nio.file.Path;
 import java.util.concurrent.ExecutionException;
 import org.apache.lucene.document.Document;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.document.SortedDocValuesField;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
@@ -42,16 +50,19 @@
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.store.RAMDirectory;
+import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
 public class LuceneAccountIndex extends AbstractLuceneIndex<Account.Id, AccountState>
     implements AccountIndex {
   private static final String ACCOUNTS = "accounts";
 
+  private static final String FULL_NAME_SORT_FIELD = sortFieldName(FULL_NAME);
+  private static final String EMAIL_SORT_FIELD = sortFieldName(PREFERRED_EMAIL_EXACT);
   private static final String ID_SORT_FIELD = sortFieldName(ID);
 
   private static Term idTerm(AccountState as) {
-    return idTerm(as.getAccount().getId());
+    return idTerm(as.getAccount().id());
   }
 
   private static Term idTerm(Account.Id id) {
@@ -93,20 +104,37 @@
   }
 
   @Override
-  public void replace(AccountState as) throws IOException {
+  void add(Document doc, Values<AccountState> values) {
+    // Add separate DocValues fields for those fields needed for sorting.
+    FieldDef<AccountState, ?> f = values.getField();
+    if (f == ID) {
+      int v = (Integer) getOnlyElement(values.getValues());
+      doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
+    } else if (f == FULL_NAME) {
+      String value = (String) getOnlyElement(values.getValues());
+      doc.add(new SortedDocValuesField(FULL_NAME_SORT_FIELD, new BytesRef(value)));
+    } else if (f == PREFERRED_EMAIL_EXACT) {
+      String value = (String) getOnlyElement(values.getValues());
+      doc.add(new SortedDocValuesField(EMAIL_SORT_FIELD, new BytesRef(value)));
+    }
+    super.add(doc, values);
+  }
+
+  @Override
+  public void replace(AccountState as) {
     try {
       replace(idTerm(as), toDocument(as)).get();
     } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
+      throw new StorageException(e);
     }
   }
 
   @Override
-  public void delete(Account.Id key) throws IOException {
+  public void delete(Account.Id key) {
     try {
       delete(idTerm(key)).get();
     } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
+      throw new StorageException(e);
     }
   }
 
@@ -114,14 +142,19 @@
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
     return new LuceneQuerySource(
-        opts.filterFields(IndexUtils::accountFields),
-        queryBuilder.toQuery(p),
-        new Sort(new SortField(ID_SORT_FIELD, SortField.Type.LONG, true)));
+        opts.filterFields(IndexUtils::accountFields), queryBuilder.toQuery(p), getSort());
+  }
+
+  private Sort getSort() {
+    return new Sort(
+        new SortField(FULL_NAME_SORT_FIELD, SortField.Type.STRING, false),
+        new SortField(EMAIL_SORT_FIELD, SortField.Type.STRING, false),
+        new SortField(ID_SORT_FIELD, SortField.Type.LONG, false));
   }
 
   @Override
   protected AccountState fromDocument(Document doc) {
-    Account.Id id = new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
+    Account.Id id = Account.id(doc.getField(ID.getName()).numericValue().intValue());
     // Use the AccountCache rather than depending on any stored fields in the document (of which
     // there shouldn't be any). The most expensive part to compute anyway is the effective group
     // IDs, and we don't have a good way to reindex when those change.
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 7dfaac1..5ad5dfd 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -14,22 +14,20 @@
 
 package com.google.gerrit.lucene;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -37,16 +35,22 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.ResultSet;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.converter.ChangeProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
+import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -58,24 +62,21 @@
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
+import java.util.function.Consumer;
+import java.util.function.Function;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexableField;
@@ -129,6 +130,7 @@
       ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName();
   private static final String SUBMIT_RECORD_STRICT_FIELD =
       ChangeField.STORED_SUBMIT_RECORD_STRICT.getName();
+  private static final String TOTAL_COMMENT_COUNT_FIELD = ChangeField.TOTAL_COMMENT_COUNT.getName();
   private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
       ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
 
@@ -141,7 +143,6 @@
   }
 
   private final ListeningExecutorService executor;
-  private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Schema<ChangeData> schema;
   private final QueryBuilder<ChangeData> queryBuilder;
@@ -153,12 +154,10 @@
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
-      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       @Assisted Schema<ChangeData> schema)
       throws IOException {
     this.executor = executor;
-    this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.schema = schema;
 
@@ -201,34 +200,34 @@
   }
 
   @Override
-  public void replace(ChangeData cd) throws IOException {
+  public void replace(ChangeData cd) {
     Term id = LuceneChangeIndex.idTerm(cd);
     // toDocument is essentially static and doesn't depend on the specific
     // sub-index, so just pick one.
     Document doc = openIndex.toDocument(cd);
     try {
-      if (cd.change().getStatus().isOpen()) {
+      if (cd.change().isNew()) {
         Futures.allAsList(closedIndex.delete(id), openIndex.replace(id, doc)).get();
       } else {
         Futures.allAsList(openIndex.delete(id), closedIndex.replace(id, doc)).get();
       }
-    } catch (OrmException | ExecutionException | InterruptedException e) {
-      throw new IOException(e);
+    } catch (ExecutionException | InterruptedException e) {
+      throw new StorageException(e);
     }
   }
 
   @Override
-  public void delete(Change.Id id) throws IOException {
+  public void delete(Change.Id id) {
     Term idTerm = LuceneChangeIndex.idTerm(id);
     try {
       Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
     } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
+      throw new StorageException(e);
     }
   }
 
   @Override
-  public void deleteAll() throws IOException {
+  public void deleteAll() {
     openIndex.deleteAll();
     closedIndex.deleteAll();
   }
@@ -248,7 +247,7 @@
   }
 
   @Override
-  public void markReady(boolean ready) throws IOException {
+  public void markReady(boolean ready) {
     // Arbitrary done on open index, as ready bit is set
     // per index and not sub index
     openIndex.markReady(ready);
@@ -260,10 +259,6 @@
         new SortField(ID_SORT_FIELD, SortField.Type.LONG, true));
   }
 
-  public ChangeSubIndex getClosedChangesIndex() {
-    return closedIndex;
-  }
-
   private class QuerySource implements ChangeDataSource {
     private final List<ChangeSubIndex> indexes;
     private final Predicate<ChangeData> predicate;
@@ -281,7 +276,7 @@
         throws QueryParseException {
       this.indexes = indexes;
       this.predicate = predicate;
-      this.query = checkNotNull(queryBuilder.toQuery(predicate), "null query from Lucene");
+      this.query = requireNonNull(queryBuilder.toQuery(predicate), "null query from Lucene");
       this.opts = opts;
       this.sort = sort;
       this.rawDocumentMapper = rawDocumentMapper;
@@ -303,10 +298,10 @@
     }
 
     @Override
-    public ResultSet<ChangeData> read() throws OrmException {
+    public ResultSet<ChangeData> read() {
       if (Thread.interrupted()) {
         Thread.currentThread().interrupt();
-        throw new OrmException("interrupted");
+        throw new StorageException("interrupted");
       }
 
       final Set<String> fields = IndexUtils.changeFields(opts);
@@ -327,14 +322,15 @@
     }
 
     @Override
-    public ResultSet<FieldBundle> readRaw() throws OrmException {
+    public ResultSet<FieldBundle> readRaw() {
       List<Document> documents;
       try {
         documents = doRead(IndexUtils.changeFields(opts));
       } catch (IOException e) {
-        throw new OrmException(e);
+        throw new StorageException(e);
       }
-      List<FieldBundle> fieldBundles = documents.stream().map(rawDocumentMapper).collect(toList());
+      ImmutableList<FieldBundle> fieldBundles =
+          documents.stream().map(rawDocumentMapper).collect(toImmutableList());
       return new ResultSet<FieldBundle>() {
         @Override
         public Iterator<FieldBundle> iterator() {
@@ -342,7 +338,7 @@
         }
 
         @Override
-        public List<FieldBundle> toList() {
+        public ImmutableList<FieldBundle> toList() {
           return fieldBundles;
         }
 
@@ -402,21 +398,22 @@
     }
 
     @Override
-    public List<ChangeData> toList() {
+    public ImmutableList<ChangeData> toList() {
       try {
         List<Document> docs = future.get();
-        List<ChangeData> result = new ArrayList<>(docs.size());
+        ImmutableList.Builder<ChangeData> result =
+            ImmutableList.builderWithExpectedSize(docs.size());
         String idFieldName = LEGACY_ID.getName();
         for (Document doc : docs) {
           result.add(toChangeData(fields(doc, fields), fields, idFieldName));
         }
-        return result;
+        return result.build();
       } catch (InterruptedException e) {
         close();
-        throw new OrmRuntimeException(e);
+        throw new StorageException(e);
       } catch (ExecutionException e) {
         Throwables.throwIfUnchecked(e.getCause());
-        throw new OrmRuntimeException(e.getCause());
+        throw new StorageException(e.getCause());
       }
     }
 
@@ -446,15 +443,13 @@
     IndexableField cb = Iterables.getFirst(doc.get(CHANGE_FIELD), null);
     if (cb != null) {
       BytesRef proto = cb.binaryValue();
-      cd =
-          changeDataFactory.create(
-              db.get(), CHANGE_CODEC.decode(proto.bytes, proto.offset, proto.length));
+      cd = changeDataFactory.create(parseProtoFrom(proto, ChangeProtoConverter.INSTANCE));
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
-      Change.Id id = new Change.Id(f.numericValue().intValue());
+      Change.Id id = Change.id(f.numericValue().intValue());
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
       IndexableField project = doc.get(PROJECT.getName()).iterator().next();
-      cd = changeDataFactory.create(db.get(), new Project.NameKey(project.stringValue()), id);
+      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), id);
     }
 
     // Any decoding that is done here must also be done in {@link ElasticChangeIndex}.
@@ -504,11 +499,12 @@
     }
 
     decodeUnresolvedCommentCount(doc, cd);
+    decodeTotalCommentCount(doc, cd);
     return cd;
   }
 
   private void decodePatchSets(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PATCH_SET_CODEC);
+    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PatchSetProtoConverter.INSTANCE);
     if (!patchSets.isEmpty()) {
       // Will be an empty list for schemas prior to when this field was stored;
       // this cannot be valid since a change needs at least one patch set.
@@ -517,7 +513,8 @@
   }
 
   private void decodeApprovals(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setCurrentApprovals(decodeProtos(doc, APPROVAL_FIELD, APPROVAL_CODEC));
+    cd.setCurrentApprovals(
+        decodeProtos(doc, APPROVAL_FIELD, PatchSetApprovalProtoConverter.INSTANCE));
   }
 
   private void decodeChangedLines(ListMultimap<String, IndexableField> doc, ChangeData cd) {
@@ -555,7 +552,7 @@
         if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
           break;
         }
-        accounts.add(new Account.Id(id));
+        accounts.add(Account.id(id));
       }
       cd.setReviewedBy(accounts);
     }
@@ -633,30 +630,39 @@
 
   private void decodeUnresolvedCommentCount(
       ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField f = Iterables.getFirst(doc.get(UNRESOLVED_COMMENT_COUNT_FIELD), null);
+    decodeIntField(doc, UNRESOLVED_COMMENT_COUNT_FIELD, cd::setUnresolvedCommentCount);
+  }
+
+  private void decodeTotalCommentCount(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    decodeIntField(doc, TOTAL_COMMENT_COUNT_FIELD, cd::setTotalCommentCount);
+  }
+
+  private static void decodeIntField(
+      ListMultimap<String, IndexableField> doc, String fieldName, Consumer<Integer> consumer) {
+    IndexableField f = Iterables.getFirst(doc.get(fieldName), null);
     if (f != null && f.numericValue() != null) {
-      cd.setUnresolvedCommentCount(f.numericValue().intValue());
+      consumer.accept(f.numericValue().intValue());
     }
   }
 
   private static <T> List<T> decodeProtos(
-      ListMultimap<String, IndexableField> doc, String fieldName, ProtobufCodec<T> codec) {
-    Collection<IndexableField> fields = doc.get(fieldName);
-    if (fields.isEmpty()) {
-      return Collections.emptyList();
-    }
+      ListMultimap<String, IndexableField> doc, String fieldName, ProtoConverter<?, T> converter) {
+    return doc.get(fieldName).stream()
+        .map(IndexableField::binaryValue)
+        .map(bytesRef -> parseProtoFrom(bytesRef, converter))
+        .collect(toImmutableList());
+  }
 
-    List<T> result = new ArrayList<>(fields.size());
-    for (IndexableField f : fields) {
-      BytesRef r = f.binaryValue();
-      result.add(codec.decode(r.bytes, r.offset, r.length));
-    }
-    return result;
+  private static <P extends MessageLite, T> T parseProtoFrom(
+      BytesRef bytesRef, ProtoConverter<P, T> converter) {
+    P message =
+        Protos.parseUnchecked(
+            converter.getParser(), bytesRef.bytes, bytesRef.offset, bytesRef.length);
+    return converter.fromProto(message);
   }
 
   private static List<byte[]> copyAsBytes(Collection<IndexableField> fields) {
-    return fields
-        .stream()
+    return fields.stream()
         .map(
             f -> {
               BytesRef ref = f.binaryValue();
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 7878afe..aab35d4 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.lucene;
 
+import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.server.index.group.GroupField.UUID;
 
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.Schema.Values;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -35,6 +39,7 @@
 import java.nio.file.Path;
 import java.util.concurrent.ExecutionException;
 import org.apache.lucene.document.Document;
+import org.apache.lucene.document.SortedDocValuesField;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
@@ -42,6 +47,7 @@
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.store.RAMDirectory;
+import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
 public class LuceneGroupIndex extends AbstractLuceneIndex<AccountGroup.UUID, InternalGroup>
@@ -94,20 +100,31 @@
   }
 
   @Override
-  public void replace(InternalGroup group) throws IOException {
+  void add(Document doc, Values<InternalGroup> values) {
+    // Add separate DocValues field for the field that is needed for sorting.
+    FieldDef<InternalGroup, ?> f = values.getField();
+    if (f == UUID) {
+      String value = (String) getOnlyElement(values.getValues());
+      doc.add(new SortedDocValuesField(UUID_SORT_FIELD, new BytesRef(value)));
+    }
+    super.add(doc, values);
+  }
+
+  @Override
+  public void replace(InternalGroup group) {
     try {
       replace(idTerm(group), toDocument(group)).get();
     } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
+      throw new StorageException(e);
     }
   }
 
   @Override
-  public void delete(AccountGroup.UUID key) throws IOException {
+  public void delete(AccountGroup.UUID key) {
     try {
       delete(idTerm(key)).get();
     } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
+      throw new StorageException(e);
     }
   }
 
@@ -122,7 +139,7 @@
 
   @Override
   protected InternalGroup fromDocument(Document doc) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(doc.getField(UUID.getName()).stringValue());
+    AccountGroup.UUID uuid = AccountGroup.uuid(doc.getField(UUID.getName()).stringValue());
     // Use the GroupCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any).
     return groupCache.get().get(uuid).orElse(null);
diff --git a/java/com/google/gerrit/lucene/LuceneIndexModule.java b/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 121b96b..302a2da 100644
--- a/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -29,29 +29,24 @@
 
 public class LuceneIndexModule extends AbstractIndexModule {
   public static LuceneIndexModule singleVersionAllLatest(int threads, boolean slave) {
-    return new LuceneIndexModule(ImmutableMap.of(), threads, false, slave);
+    return new LuceneIndexModule(ImmutableMap.of(), threads, slave);
   }
 
   public static LuceneIndexModule singleVersionWithExplicitVersions(
       Map<String, Integer> versions, int threads, boolean slave) {
-    return new LuceneIndexModule(versions, threads, false, slave);
+    return new LuceneIndexModule(versions, threads, slave);
   }
 
-  public static LuceneIndexModule latestVersionWithOnlineUpgrade(boolean slave) {
-    return new LuceneIndexModule(null, 0, true, slave);
-  }
-
-  public static LuceneIndexModule latestVersionWithoutOnlineUpgrade(boolean slave) {
-    return new LuceneIndexModule(null, 0, false, slave);
+  public static LuceneIndexModule latestVersion(boolean slave) {
+    return new LuceneIndexModule(null, 0, slave);
   }
 
   static boolean isInMemoryTest(Config cfg) {
     return cfg.getBoolean("index", "lucene", "testInmemory", false);
   }
 
-  private LuceneIndexModule(
-      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade, boolean slave) {
-    super(singleVersions, threads, onlineUpgrade, slave);
+  private LuceneIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
+    super(singleVersions, threads, slave);
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 3e2dc1e..44d7610 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.lucene;
 
+import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.index.project.ProjectField.NAME;
 
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.Schema.Values;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
@@ -28,6 +32,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -35,6 +40,7 @@
 import java.nio.file.Path;
 import java.util.concurrent.ExecutionException;
 import org.apache.lucene.document.Document;
+import org.apache.lucene.document.SortedDocValuesField;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
@@ -42,6 +48,7 @@
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.store.RAMDirectory;
+import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
 public class LuceneProjectIndex extends AbstractLuceneIndex<Project.NameKey, ProjectData>
@@ -93,20 +100,31 @@
   }
 
   @Override
-  public void replace(ProjectData projectState) throws IOException {
+  void add(Document doc, Values<ProjectData> values) {
+    // Add separate DocValues field for the field that is needed for sorting.
+    FieldDef<ProjectData, ?> f = values.getField();
+    if (f == NAME) {
+      String value = (String) getOnlyElement(values.getValues());
+      doc.add(new SortedDocValuesField(NAME_SORT_FIELD, new BytesRef(value)));
+    }
+    super.add(doc, values);
+  }
+
+  @Override
+  public void replace(ProjectData projectState) {
     try {
       replace(idTerm(projectState), toDocument(projectState)).get();
     } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
+      throw new StorageException(e);
     }
   }
 
   @Override
-  public void delete(Project.NameKey nameKey) throws IOException {
+  public void delete(Project.NameKey nameKey) {
     try {
       delete(idTerm(nameKey)).get();
     } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
+      throw new StorageException(e);
     }
   }
 
@@ -121,7 +139,8 @@
 
   @Override
   protected ProjectData fromDocument(Document doc) {
-    Project.NameKey nameKey = new Project.NameKey(doc.getField(NAME.getName()).stringValue());
-    return projectCache.get().get(nameKey).toProjectData();
+    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME.getName()).stringValue());
+    ProjectState projectState = projectCache.get().get(nameKey);
+    return projectState == null ? null : projectState.toProjectData();
   }
 }
diff --git a/java/com/google/gerrit/lucene/LuceneVersionManager.java b/java/com/google/gerrit/lucene/LuceneVersionManager.java
index 63abea8..f3ba73d 100644
--- a/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -16,7 +16,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.Schema;
@@ -25,6 +24,7 @@
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.OnlineUpgradeListener;
 import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -47,7 +47,7 @@
   LuceneVersionManager(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
-      DynamicSet<OnlineUpgradeListener> listeners,
+      PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs) {
     super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
   }
@@ -81,7 +81,7 @@
           continue;
         }
         if (!versions.containsKey(v)) {
-          versions.put(v, new Version<V>(null, v, true, cfg.getReady(def.getName(), v)));
+          versions.put(v, new Version<>(null, v, true, cfg.getReady(def.getName(), v)));
         }
       }
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index 6aab7c7..ce5ba98 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -38,19 +38,20 @@
 import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.BooleanQuery;
+import org.apache.lucene.search.LegacyNumericRangeQuery;
 import org.apache.lucene.search.MatchAllDocsQuery;
-import org.apache.lucene.search.NumericRangeQuery;
 import org.apache.lucene.search.PrefixQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.RegexpQuery;
 import org.apache.lucene.search.TermQuery;
 import org.apache.lucene.util.BytesRefBuilder;
-import org.apache.lucene.util.NumericUtils;
+import org.apache.lucene.util.LegacyNumericUtils;
 
+@SuppressWarnings("deprecation")
 public class QueryBuilder<V> {
   static Term intTerm(String name, int value) {
     BytesRefBuilder builder = new BytesRefBuilder();
-    NumericUtils.intToPrefixCoded(value, 0, builder);
+    LegacyNumericUtils.intToPrefixCoded(value, 0, builder);
     return new Term(name, builder.get());
   }
 
@@ -180,7 +181,8 @@
         // Just fall back to a standard integer query.
         return new TermQuery(intTerm(p.getField().getName(), minimum));
       }
-      return NumericRangeQuery.newIntRange(r.getField().getName(), minimum, maximum, true, true);
+      return LegacyNumericRangeQuery.newIntRange(
+          r.getField().getName(), minimum, maximum, true, true);
     }
     throw new QueryParseException("not an integer range: " + p);
   }
@@ -188,7 +190,7 @@
   private Query timestampQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
-      return NumericRangeQuery.newLongRange(
+      return LegacyNumericRangeQuery.newLongRange(
           r.getField().getName(),
           r.getMinTimestamp().getTime(),
           r.getMaxTimestamp().getTime(),
@@ -200,7 +202,7 @@
 
   private Query notTimestamp(TimestampRangePredicate<V> r) throws QueryParseException {
     if (r.getMinTimestamp().getTime() == 0) {
-      return NumericRangeQuery.newLongRange(
+      return LegacyNumericRangeQuery.newLongRange(
           r.getField().getName(), r.getMaxTimestamp().getTime(), null, true, true);
     }
     throw new QueryParseException("cannot negate: " + r);
diff --git a/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index f9ecac3..4044b90 100644
--- a/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -81,11 +81,17 @@
   WrappableSearcherManager(
       IndexWriter writer, boolean applyAllDeletes, SearcherFactory searcherFactory)
       throws IOException {
+    // TODO(davido): Make it configurable
+    // If true, new deletes will be written down to index files instead of carried over from writer
+    // to reader directly in heap
+    boolean writeAllDeletes = false;
     if (searcherFactory == null) {
       searcherFactory = new SearcherFactory();
     }
     this.searcherFactory = searcherFactory;
-    current = getSearcher(searcherFactory, DirectoryReader.open(writer, applyAllDeletes));
+    current =
+        getSearcher(
+            searcherFactory, DirectoryReader.open(writer, applyAllDeletes, writeAllDeletes));
   }
 
   /**
@@ -171,7 +177,7 @@
    * Expert: creates a searcher from the provided {@link IndexReader} using the provided {@link
    * SearcherFactory}. NOTE: this decRefs incoming reader on throwing an exception.
    */
-  @SuppressWarnings("resource")
+  @SuppressWarnings({"resource", "ReferenceEquality"})
   public static IndexSearcher getSearcher(SearcherFactory searcherFactory, IndexReader reader)
       throws IOException {
     boolean success = false;
diff --git a/java/com/google/gerrit/mail/Address.java b/java/com/google/gerrit/mail/Address.java
new file mode 100644
index 0000000..24ab353
--- /dev/null
+++ b/java/com/google/gerrit/mail/Address.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import com.google.gerrit.common.Nullable;
+
+public class Address {
+  public static Address parse(String in) {
+    final int lt = in.indexOf('<');
+    final int gt = in.indexOf('>');
+    final int at = in.indexOf("@");
+    if (0 <= lt && lt < gt && lt + 1 < at && at + 1 < gt) {
+      final String email = in.substring(lt + 1, gt).trim();
+      final String name = in.substring(0, lt).trim();
+      int nameStart = 0;
+      int nameEnd = name.length();
+      if (name.startsWith("\"")) {
+        nameStart++;
+      }
+      if (name.endsWith("\"")) {
+        nameEnd--;
+      }
+      return new Address(name.length() > 0 ? name.substring(nameStart, nameEnd) : null, email);
+    }
+
+    if (lt < 0 && gt < 0 && 0 < at && at < in.length() - 1) {
+      return new Address(in);
+    }
+
+    throw new IllegalArgumentException("Invalid email address: " + in);
+  }
+
+  public static Address tryParse(String in) {
+    try {
+      return parse(in);
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
+  @Nullable private final String name;
+  private final String email;
+
+  public Address(String email) {
+    this(null, email);
+  }
+
+  public Address(String name, String email) {
+    this.name = name;
+    this.email = email;
+  }
+
+  @Nullable
+  public String getName() {
+    return name;
+  }
+
+  public String getEmail() {
+    return email;
+  }
+
+  @Override
+  public int hashCode() {
+    return email.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other instanceof Address) {
+      return email.equals(((Address) other).email);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return toHeaderString();
+  }
+
+  public String toHeaderString() {
+    if (name != null) {
+      return quotedPhrase(name) + " <" + email + ">";
+    } else if (isSimple()) {
+      return email;
+    }
+    return "<" + email + ">";
+  }
+
+  private static final String MUST_QUOTE_EMAIL = "()<>,;:\\\"[]";
+  private static final String MUST_QUOTE_NAME = MUST_QUOTE_EMAIL + "@.";
+
+  private boolean isSimple() {
+    for (int i = 0; i < email.length(); i++) {
+      final char c = email.charAt(i);
+      if (c <= ' ' || 0x7F <= c || MUST_QUOTE_EMAIL.indexOf(c) != -1) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static String quotedPhrase(String name) {
+    if (EmailHeader.needsQuotedPrintable(name)) {
+      return EmailHeader.quotedPrintable(name);
+    }
+    for (int i = 0; i < name.length(); i++) {
+      final char c = name.charAt(i);
+      if (MUST_QUOTE_NAME.indexOf(c) != -1) {
+        return wrapInQuotes(name);
+      }
+    }
+    return name;
+  }
+
+  private static String wrapInQuotes(String name) {
+    final StringBuilder r = new StringBuilder(2 + name.length());
+    r.append('"');
+    for (int i = 0; i < name.length(); i++) {
+      char c = name.charAt(i);
+      if (c == '"' || c == '\\') {
+        r.append('\\');
+      }
+      r.append(c);
+    }
+    r.append('"');
+    return r.toString();
+  }
+}
diff --git a/java/com/google/gerrit/mail/BUILD b/java/com/google/gerrit/mail/BUILD
new file mode 100644
index 0000000..90bb82c
--- /dev/null
+++ b/java/com/google/gerrit/mail/BUILD
@@ -0,0 +1,18 @@
+java_library(
+    name = "mail",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib:guava",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/flogger:api",
+        "//lib/jsoup",
+        "//lib/log:jsonevent-layout",
+        "//lib/log:log4j",
+        "//lib/mime4j:core",
+        "//lib/mime4j:dom",
+    ],
+)
diff --git a/java/com/google/gerrit/mail/EmailHeader.java b/java/com/google/gerrit/mail/EmailHeader.java
new file mode 100644
index 0000000..69d5fcd
--- /dev/null
+++ b/java/com/google/gerrit/mail/EmailHeader.java
@@ -0,0 +1,233 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.MoreObjects;
+import java.io.IOException;
+import java.io.Writer;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+public abstract class EmailHeader {
+  public abstract boolean isEmpty();
+
+  public abstract void write(Writer w) throws IOException;
+
+  public static class String extends EmailHeader {
+    private final java.lang.String value;
+
+    public String(java.lang.String v) {
+      value = v;
+    }
+
+    public java.lang.String getString() {
+      return value;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return value == null || value.length() == 0;
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      if (needsQuotedPrintable(value)) {
+        w.write(quotedPrintable(value));
+      } else {
+        w.write(value);
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof String) && Objects.equals(value, ((String) o).value);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(value).toString();
+    }
+  }
+
+  public static boolean needsQuotedPrintable(java.lang.String value) {
+    for (int i = 0; i < value.length(); i++) {
+      if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  static boolean needsQuotedPrintableWithinPhrase(int cp) {
+    switch (cp) {
+      case '!':
+      case '*':
+      case '+':
+      case '-':
+      case '/':
+      case '=':
+      case '_':
+        return false;
+      default:
+        if (('a' <= cp && cp <= 'z') || ('A' <= cp && cp <= 'Z') || ('0' <= cp && cp <= '9')) {
+          return false;
+        }
+        return true;
+    }
+  }
+
+  public static java.lang.String quotedPrintable(java.lang.String value) {
+    final StringBuilder r = new StringBuilder();
+
+    r.append("=?UTF-8?Q?");
+    for (int i = 0; i < value.length(); i++) {
+      final int cp = value.codePointAt(i);
+      if (cp == ' ') {
+        r.append('_');
+
+      } else if (needsQuotedPrintableWithinPhrase(cp)) {
+        byte[] buf = new java.lang.String(Character.toChars(cp)).getBytes(UTF_8);
+        for (byte b : buf) {
+          r.append('=');
+          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
+          r.append(Integer.toHexString(b & 0x0f).toUpperCase());
+        }
+
+      } else {
+        r.append(Character.toChars(cp));
+      }
+    }
+    r.append("?=");
+
+    return r.toString();
+  }
+
+  public static class Date extends EmailHeader {
+    private final java.util.Date value;
+
+    public Date(java.util.Date v) {
+      value = v;
+    }
+
+    public java.util.Date getDate() {
+      return value;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return value == null;
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      final SimpleDateFormat fmt;
+      // Mon, 1 Jun 2009 10:49:44 -0700
+      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
+      w.write(fmt.format(value));
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof Date) && Objects.equals(value, ((Date) o).value);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(value).toString();
+    }
+  }
+
+  public static class AddressList extends EmailHeader {
+    private final List<Address> list = new ArrayList<>();
+
+    public AddressList() {}
+
+    public AddressList(Address addr) {
+      add(addr);
+    }
+
+    public List<Address> getAddressList() {
+      return Collections.unmodifiableList(list);
+    }
+
+    public void add(Address addr) {
+      list.add(addr);
+    }
+
+    public void remove(java.lang.String email) {
+      list.removeIf(address -> address.getEmail().equals(email));
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return list.isEmpty();
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      int len = 8;
+      boolean firstAddress = true;
+      boolean needComma = false;
+      for (Address addr : list) {
+        java.lang.String s = addr.toHeaderString();
+        if (firstAddress) {
+          firstAddress = false;
+        } else if (72 < len + s.length()) {
+          w.write(",\r\n\t");
+          len = 8;
+          needComma = false;
+        }
+
+        if (needComma) {
+          w.write(", ");
+        }
+        w.write(s);
+        len += s.length();
+        needComma = true;
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(list);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof AddressList) && Objects.equals(list, ((AddressList) o).list);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(list).toString();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/mail/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
new file mode 100644
index 0000000..265c412
--- /dev/null
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -0,0 +1,172 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.PeekingIterator;
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+
+/** Provides functionality for parsing the HTML part of a {@link MailMessage}. */
+public class HtmlParser {
+
+  private static final ImmutableSet<String> MAIL_PROVIDER_EXTRAS =
+      ImmutableSet.of(
+          "gmail_extra", // "On 01/01/2017 User<user@gmail.com> wrote:"
+          "gmail_quote" // Used for quoting original content
+          );
+
+  private static final ImmutableSet<String> WHITELISTED_HTML_TAGS =
+      ImmutableSet.of(
+          "div", // Most user-typed comments are contained in a <div> tag
+          "a", // We allow links to be contained in a comment
+          "font" // Some email clients like nesting input in a new font tag
+          );
+
+  private HtmlParser() {}
+
+  /**
+   * Parses comments from html email.
+   *
+   * <p>This parser goes though all html elements in the email and checks for matching patterns. It
+   * keeps track of the last file and comments it encountered to know in which context a parsed
+   * comment belongs. It uses the href attributes of <a> tags to identify comments sent out by
+   * Gerrit as these are generally more reliable then the text captions.
+   *
+   * @param email the message as received from the email service
+   * @param comments a specific set of comments as sent out in the original notification email.
+   *     Comments are expected to be in the same order as they were sent out to in the email.
+   * @param changeUrl canonical change URL that points to the change on this Gerrit instance.
+   *     Example: https://go-review.googlesource.com/#/c/91570
+   * @return list of MailComments parsed from the html part of the email
+   */
+  public static List<MailComment> parse(
+      MailMessage email, Collection<Comment> comments, String changeUrl) {
+    // TODO(hiesel) Add support for Gmail Mobile
+    // TODO(hiesel) Add tests for other popular email clients
+
+    // This parser goes though all html elements in the email and checks for
+    // matching patterns. It keeps track of the last file and comments it
+    // encountered to know in which context a parsed comment belongs.
+    // It uses the href attributes of <a> tags to identify comments sent out by
+    // Gerrit as these are generally more reliable then the text captions.
+    List<MailComment> parsedComments = new ArrayList<>();
+    Document d = Jsoup.parse(email.htmlContent());
+    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+
+    String lastEncounteredFileName = null;
+    Comment lastEncounteredComment = null;
+    for (Element e : d.body().getAllElements()) {
+      String elementName = e.tagName();
+      boolean isInBlockQuote =
+          e.parents().stream()
+              .anyMatch(
+                  p ->
+                      p.tagName().equals("blockquote")
+                          || MAIL_PROVIDER_EXTRAS.contains(p.className()));
+
+      if (elementName.equals("a")) {
+        String href = e.attr("href");
+        // Check if there is still a next comment that could be contained in
+        // this <a> tag
+        if (!iter.hasNext()) {
+          continue;
+        }
+        Comment perspectiveComment = iter.peek();
+        if (href.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
+          if (lastEncounteredFileName == null
+              || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
+            // Not a file-level comment, but users could have typed a comment
+            // right after this file annotation to create a new file-level
+            // comment. If this file has a file-level comment, we have already
+            // set lastEncounteredComment to that file-level comment when we
+            // encountered the file link and should not reset it now.
+            lastEncounteredFileName = perspectiveComment.key.filename;
+            lastEncounteredComment = null;
+          } else if (perspectiveComment.lineNbr == 0) {
+            // This was originally a file-level comment
+            lastEncounteredComment = perspectiveComment;
+            iter.next();
+          }
+          continue;
+        } else if (ParserUtil.isCommentUrl(href, changeUrl, perspectiveComment)) {
+          // This is a regular inline comment
+          lastEncounteredComment = perspectiveComment;
+          iter.next();
+          continue;
+        }
+      }
+
+      if (isInBlockQuote) {
+        // There is no user-input in quoted text
+        continue;
+      }
+      if (!WHITELISTED_HTML_TAGS.contains(elementName)) {
+        // We only accept a set of whitelisted tags that can contain user input
+        continue;
+      }
+      if (elementName.equals("a") && e.attr("href").startsWith("mailto:")) {
+        // We don't accept mailto: links in general as they often appear in reply-to lines
+        // (User<user@gmail.com> wrote: ...)
+        continue;
+      }
+
+      // This is a comment typed by the user
+      // Replace non-breaking spaces and trim string
+      String content = e.ownText().replace('\u00a0', ' ').trim();
+      boolean isLink = elementName.equals("a");
+      if (!Strings.isNullOrEmpty(content)) {
+        if (lastEncounteredComment == null && lastEncounteredFileName == null) {
+          // Remove quotation line, email signature and
+          // "Sent from my xyz device"
+          content = ParserUtil.trimQuotation(content);
+          // TODO(hiesel) Add more sanitizer
+          if (!Strings.isNullOrEmpty(content)) {
+            ParserUtil.appendOrAddNewComment(
+                new MailComment(
+                    content, null, null, MailComment.CommentType.CHANGE_MESSAGE, isLink),
+                parsedComments);
+          }
+        } else if (lastEncounteredComment == null) {
+          ParserUtil.appendOrAddNewComment(
+              new MailComment(
+                  content,
+                  lastEncounteredFileName,
+                  null,
+                  MailComment.CommentType.FILE_COMMENT,
+                  isLink),
+              parsedComments);
+        } else {
+          ParserUtil.appendOrAddNewComment(
+              new MailComment(
+                  content,
+                  null,
+                  lastEncounteredComment,
+                  MailComment.CommentType.INLINE_COMMENT,
+                  isLink),
+              parsedComments);
+        }
+      }
+    }
+    return parsedComments;
+  }
+}
diff --git a/java/com/google/gerrit/mail/MailComment.java b/java/com/google/gerrit/mail/MailComment.java
new file mode 100644
index 0000000..fd8198c
--- /dev/null
+++ b/java/com/google/gerrit/mail/MailComment.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.Objects;
+
+/** A comment parsed from inbound email */
+public class MailComment {
+  public enum CommentType {
+    CHANGE_MESSAGE,
+    FILE_COMMENT,
+    INLINE_COMMENT
+  }
+
+  CommentType type;
+  Comment inReplyTo;
+  String fileName;
+  String message;
+  boolean isLink;
+
+  public MailComment() {}
+
+  public MailComment(
+      String message, String fileName, Comment inReplyTo, CommentType type, boolean isLink) {
+    this.message = message;
+    this.fileName = fileName;
+    this.inReplyTo = inReplyTo;
+    this.type = type;
+    this.isLink = isLink;
+  }
+
+  public CommentType getType() {
+    return type;
+  }
+
+  public Comment getInReplyTo() {
+    return inReplyTo;
+  }
+
+  public String getFileName() {
+    return fileName;
+  }
+
+  public String getMessage() {
+    return message;
+  }
+
+  /**
+   * Checks if the provided comment concerns the same exact spot in the change. This is basically an
+   * equals method except that the message is not checked.
+   */
+  public boolean isSameCommentPath(MailComment c) {
+    return Objects.equals(fileName, c.fileName)
+        && Objects.equals(inReplyTo, c.inReplyTo)
+        && Objects.equals(type, c.type);
+  }
+}
diff --git a/java/com/google/gerrit/mail/MailHeader.java b/java/com/google/gerrit/mail/MailHeader.java
new file mode 100644
index 0000000..2f31a9c
--- /dev/null
+++ b/java/com/google/gerrit/mail/MailHeader.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.mail;
+
+/** Variables used by emails to hold data */
+public enum MailHeader {
+  // Gerrit metadata holders
+  ASSIGNEE("Gerrit-Assignee"),
+  BRANCH("Gerrit-Branch"),
+  CC("Gerrit-CC"),
+  COMMENT_IN_REPLY_TO("Comment-In-Reply-To"),
+  COMMENT_DATE("Gerrit-Comment-Date"),
+  CHANGE_ID("Gerrit-Change-Id"),
+  CHANGE_NUMBER("Gerrit-Change-Number"),
+  CHANGE_URL("Gerrit-ChangeURL"),
+  COMMIT("Gerrit-Commit"),
+  HAS_COMMENTS("Gerrit-HasComments"),
+  HAS_LABELS("Gerrit-Has-Labels"),
+  MESSAGE_TYPE("Gerrit-MessageType"),
+  OWNER("Gerrit-Owner"),
+  PATCH_SET("Gerrit-PatchSet"),
+  PROJECT("Gerrit-Project"),
+  REVIEWER("Gerrit-Reviewer"),
+
+  // Commonly used Email headers
+  AUTO_SUBMITTED("Auto-Submitted"),
+  PRECEDENCE("Precedence"),
+  REFERENCES("References");
+
+  private final String name;
+  private final String fieldName;
+
+  MailHeader(String name) {
+    boolean customHeader = name.startsWith("Gerrit-");
+    this.name = name;
+
+    if (customHeader) {
+      this.fieldName = "X-" + name;
+    } else {
+      this.fieldName = name;
+    }
+  }
+
+  public String fieldWithDelimiter() {
+    return fieldName() + ": ";
+  }
+
+  public String withDelimiter() {
+    return name + ": ";
+  }
+
+  public String fieldName() {
+    return fieldName;
+  }
+
+  public String getName() {
+    return name;
+  }
+}
diff --git a/java/com/google/gerrit/mail/MailHeaderParser.java b/java/com/google/gerrit/mail/MailHeaderParser.java
new file mode 100644
index 0000000..a4a6a03
--- /dev/null
+++ b/java/com/google/gerrit/mail/MailHeaderParser.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+
+/** Parse metadata from inbound email */
+public class MailHeaderParser {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static MailMetadata parse(MailMessage m) {
+    MailMetadata metadata = new MailMetadata();
+    // Find author
+    metadata.author = m.from().getEmail();
+
+    // Check email headers for X-Gerrit-<Name>
+    for (String header : m.additionalHeaders()) {
+      if (header.startsWith(MailHeader.CHANGE_NUMBER.fieldWithDelimiter())) {
+        String num = header.substring(MailHeader.CHANGE_NUMBER.fieldWithDelimiter().length());
+        metadata.changeNumber = Ints.tryParse(num);
+      } else if (header.startsWith(MailHeader.PATCH_SET.fieldWithDelimiter())) {
+        String ps = header.substring(MailHeader.PATCH_SET.fieldWithDelimiter().length());
+        metadata.patchSet = Ints.tryParse(ps);
+      } else if (header.startsWith(MailHeader.COMMENT_DATE.fieldWithDelimiter())) {
+        String ts = header.substring(MailHeader.COMMENT_DATE.fieldWithDelimiter().length()).trim();
+        try {
+          metadata.timestamp =
+              Timestamp.from(MailProcessingUtil.rfcDateformatter.parse(ts, Instant::from));
+        } catch (DateTimeParseException e) {
+          logger.atSevere().withCause(e).log(
+              "Mail: Error while parsing timestamp from header of message %s", m.id());
+        }
+      } else if (header.startsWith(MailHeader.MESSAGE_TYPE.fieldWithDelimiter())) {
+        metadata.messageType =
+            header.substring(MailHeader.MESSAGE_TYPE.fieldWithDelimiter().length());
+      }
+    }
+    if (metadata.hasRequiredFields()) {
+      return metadata;
+    }
+
+    // If the required fields were not yet found, continue to parse the text
+    if (!Strings.isNullOrEmpty(m.textContent())) {
+      Iterable<String> lines = Splitter.on('\n').split(m.textContent().replace("\r\n", "\n"));
+      extractFooters(lines, metadata, m);
+      if (metadata.hasRequiredFields()) {
+        return metadata;
+      }
+    }
+
+    // If the required fields were not yet found, continue to parse the HTML
+    // HTML footer are contained inside a <div> tag
+    if (!Strings.isNullOrEmpty(m.htmlContent())) {
+      Iterable<String> lines = Splitter.on("</div>").split(m.htmlContent().replace("\r\n", "\n"));
+      extractFooters(lines, metadata, m);
+      if (metadata.hasRequiredFields()) {
+        return metadata;
+      }
+    }
+
+    return metadata;
+  }
+
+  private static void extractFooters(Iterable<String> lines, MailMetadata metadata, MailMessage m) {
+    for (String line : lines) {
+      if (metadata.changeNumber == null && line.contains(MailHeader.CHANGE_NUMBER.getName())) {
+        metadata.changeNumber =
+            Ints.tryParse(extractFooter(MailHeader.CHANGE_NUMBER.withDelimiter(), line));
+      } else if (metadata.patchSet == null && line.contains(MailHeader.PATCH_SET.getName())) {
+        metadata.patchSet =
+            Ints.tryParse(extractFooter(MailHeader.PATCH_SET.withDelimiter(), line));
+      } else if (metadata.timestamp == null && line.contains(MailHeader.COMMENT_DATE.getName())) {
+        String ts = extractFooter(MailHeader.COMMENT_DATE.withDelimiter(), line);
+        try {
+          metadata.timestamp =
+              Timestamp.from(MailProcessingUtil.rfcDateformatter.parse(ts, Instant::from));
+        } catch (DateTimeParseException e) {
+          logger.atSevere().withCause(e).log(
+              "Mail: Error while parsing timestamp from footer of message %s", m.id());
+        }
+      } else if (metadata.messageType == null && line.contains(MailHeader.MESSAGE_TYPE.getName())) {
+        metadata.messageType = extractFooter(MailHeader.MESSAGE_TYPE.withDelimiter(), line);
+      }
+    }
+  }
+
+  private static String extractFooter(String key, String line) {
+    return line.substring(line.indexOf(key) + key.length()).trim();
+  }
+}
diff --git a/java/com/google/gerrit/mail/MailMessage.java b/java/com/google/gerrit/mail/MailMessage.java
new file mode 100644
index 0000000..bb83dfd
--- /dev/null
+++ b/java/com/google/gerrit/mail/MailMessage.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.time.Instant;
+
+/**
+ * A simplified representation of an RFC 2045-2047 mime email message used for representing received
+ * emails inside Gerrit. It is populated by the MailParser after MailReceiver has received a
+ * message. Transformations done by the parser include stitching mime parts together, transforming
+ * all content to UTF-16 and removing attachments.
+ *
+ * <p>A valid {@link MailMessage} contains at least the following fields: id, from, to, subject and
+ * dateReceived.
+ */
+@AutoValue
+public abstract class MailMessage {
+  // Unique Identifier
+  public abstract String id();
+  // Envelop Information
+  public abstract Address from();
+
+  public abstract ImmutableList<Address> to();
+
+  public abstract ImmutableList<Address> cc();
+  // Metadata
+  public abstract Instant dateReceived();
+
+  public abstract ImmutableList<String> additionalHeaders();
+  // Content
+  public abstract String subject();
+
+  @Nullable
+  public abstract String textContent();
+
+  @Nullable
+  public abstract String htmlContent();
+  // Raw content as received over the wire
+  @Nullable
+  public abstract ImmutableList<Integer> rawContent();
+
+  @Nullable
+  public abstract String rawContentUTF();
+
+  public static Builder builder() {
+    return new AutoValue_MailMessage.Builder();
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder id(String val);
+
+    public abstract Builder from(Address val);
+
+    public abstract ImmutableList.Builder<Address> toBuilder();
+
+    public Builder addTo(Address val) {
+      toBuilder().add(val);
+      return this;
+    }
+
+    public abstract ImmutableList.Builder<Address> ccBuilder();
+
+    public Builder addCc(Address val) {
+      ccBuilder().add(val);
+      return this;
+    }
+
+    public abstract Builder dateReceived(Instant instant);
+
+    public abstract ImmutableList.Builder<String> additionalHeadersBuilder();
+
+    public Builder addAdditionalHeader(String val) {
+      additionalHeadersBuilder().add(val);
+      return this;
+    }
+
+    public abstract Builder subject(String val);
+
+    public abstract Builder textContent(String val);
+
+    public abstract Builder htmlContent(String val);
+
+    public abstract Builder rawContent(ImmutableList<Integer> val);
+
+    public abstract Builder rawContentUTF(String val);
+
+    public abstract MailMessage build();
+  }
+}
diff --git a/java/com/google/gerrit/mail/MailMetadata.java b/java/com/google/gerrit/mail/MailMetadata.java
new file mode 100644
index 0000000..a311461
--- /dev/null
+++ b/java/com/google/gerrit/mail/MailMetadata.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import com.google.common.base.MoreObjects;
+import java.sql.Timestamp;
+
+/** MailMetadata represents metadata parsed from inbound email. */
+public class MailMetadata {
+  public Integer changeNumber;
+  public Integer patchSet;
+  public String author; // Author of the email
+  public Timestamp timestamp;
+  public String messageType; // we expect comment here
+
+  public boolean hasRequiredFields() {
+    return changeNumber != null
+        && patchSet != null
+        && author != null
+        && timestamp != null
+        && messageType != null;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("Change-Number", changeNumber)
+        .add("Patch-Set", patchSet)
+        .add("Author", author)
+        .add("Timestamp", timestamp)
+        .add("Message-Type", messageType)
+        .toString();
+  }
+}
diff --git a/java/com/google/gerrit/mail/MailParsingException.java b/java/com/google/gerrit/mail/MailParsingException.java
new file mode 100644
index 0000000..7e85a27
--- /dev/null
+++ b/java/com/google/gerrit/mail/MailParsingException.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+/** An {@link Exception} indicating that an email could not be parsed. */
+public class MailParsingException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public MailParsingException(String msg) {
+    super(msg);
+  }
+
+  public MailParsingException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
+}
diff --git a/java/com/google/gerrit/mail/MailProcessingUtil.java b/java/com/google/gerrit/mail/MailProcessingUtil.java
new file mode 100644
index 0000000..b63189a
--- /dev/null
+++ b/java/com/google/gerrit/mail/MailProcessingUtil.java
@@ -0,0 +1,23 @@
+// 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.mail;
+
+import java.time.format.DateTimeFormatter;
+
+public class MailProcessingUtil {
+
+  public static DateTimeFormatter rfcDateformatter =
+      DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
+}
diff --git a/java/com/google/gerrit/mail/ParserUtil.java b/java/com/google/gerrit/mail/ParserUtil.java
new file mode 100644
index 0000000..6a27ac4
--- /dev/null
+++ b/java/com/google/gerrit/mail/ParserUtil.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
+import java.util.StringJoiner;
+import java.util.regex.Pattern;
+
+public class ParserUtil {
+  private static final Pattern SIMPLE_EMAIL_PATTERN =
+      Pattern.compile(
+          "[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+"
+              + "(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})");
+
+  private ParserUtil() {}
+
+  /**
+   * Trims the quotation that email clients add Example: On Sun, Nov 20, 2016 at 10:33 PM,
+   * <gerrit@gerritcodereview.com> wrote:
+   *
+   * @param comment Comment parsed from an email.
+   * @return Trimmed comment.
+   */
+  public static String trimQuotation(String comment) {
+    StringJoiner j = new StringJoiner("\n");
+    List<String> lines = Splitter.on('\n').splitToList(comment);
+    for (int i = 0; i < lines.size() - 2; i++) {
+      j.add(lines.get(i));
+    }
+
+    // Check if the last line contains the full quotation pattern (date + email)
+    String lastLine = lines.get(lines.size() - 1);
+    if (containsQuotationPattern(lastLine)) {
+      if (lines.size() > 1) {
+        j.add(lines.get(lines.size() - 2));
+      }
+      return j.toString().trim();
+    }
+
+    // Check if the second last line + the last line contain the full quotation pattern. This is
+    // necessary, as the quotation line can be split across the last two lines if it gets too long.
+    if (lines.size() > 1) {
+      String lastLines = lines.get(lines.size() - 2) + lastLine;
+      if (containsQuotationPattern(lastLines)) {
+        return j.toString().trim();
+      }
+    }
+
+    // Add the last two lines
+    if (lines.size() > 1) {
+      j.add(lines.get(lines.size() - 2));
+    }
+    j.add(lines.get(lines.size() - 1));
+
+    return j.toString().trim();
+  }
+
+  /** Check if string is an inline comment url on a patch set or the base */
+  public static boolean isCommentUrl(String str, String changeUrl, Comment comment) {
+    int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine;
+    return str.equals(filePath(changeUrl, comment) + "@" + lineNbr)
+        || str.equals(filePath(changeUrl, comment) + "@a" + lineNbr);
+  }
+
+  /** Generate the fully qualified filepath */
+  public static String filePath(String changeUrl, Comment comment) {
+    return changeUrl + "/" + comment.key.patchSetId + "/" + comment.key.filename;
+  }
+
+  /**
+   * When parsing mail content, we need to append comments prematurely since we are parsing
+   * block-by-block and never know what comes next. This can result in a comment being parsed as two
+   * comments when it spans multiple blocks. This method takes care of merging those blocks or
+   * adding a new comment to the list of appropriate.
+   */
+  public static void appendOrAddNewComment(MailComment comment, List<MailComment> comments) {
+    if (comments.isEmpty()) {
+      comments.add(comment);
+      return;
+    }
+    MailComment lastComment = Iterables.getLast(comments);
+
+    if (comment.isSameCommentPath(lastComment)) {
+      // Merge the two comments. Links should just be appended, while regular text that came from
+      // different <div> elements should be separated by a paragraph.
+      lastComment.message += (comment.isLink ? " " : "\n\n") + comment.message;
+      return;
+    }
+
+    comments.add(comment);
+  }
+
+  private static boolean containsQuotationPattern(String s) {
+    // Identifying the quotation line is hard, as it can be in any language.
+    // We identify this line by it's characteristics: It usually contains a
+    // valid email address, some digits for the date in groups of 1-4 in a row
+    // as well as some characters.
+
+    // Count occurrences of digit groups
+    int numConsecutiveDigits = 0;
+    int maxConsecutiveDigits = 0;
+    int numDigitGroups = 0;
+    for (char c : s.toCharArray()) {
+      if (c >= '0' && c <= '9') {
+        numConsecutiveDigits++;
+      } else if (numConsecutiveDigits > 0) {
+        maxConsecutiveDigits = Integer.max(maxConsecutiveDigits, numConsecutiveDigits);
+        numConsecutiveDigits = 0;
+        numDigitGroups++;
+      }
+    }
+    if (numDigitGroups < 4 || maxConsecutiveDigits > 4) {
+      return false;
+    }
+
+    // Check if the string contains an email address
+    return SIMPLE_EMAIL_PATTERN.matcher(s).find();
+  }
+}
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
new file mode 100644
index 0000000..b7e2030
--- /dev/null
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -0,0 +1,174 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.io.CharStreams;
+import com.google.common.primitives.Ints;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import org.apache.james.mime4j.MimeException;
+import org.apache.james.mime4j.dom.Entity;
+import org.apache.james.mime4j.dom.Message;
+import org.apache.james.mime4j.dom.MessageBuilder;
+import org.apache.james.mime4j.dom.Multipart;
+import org.apache.james.mime4j.dom.TextBody;
+import org.apache.james.mime4j.dom.address.Mailbox;
+import org.apache.james.mime4j.message.DefaultMessageBuilder;
+
+/** Parses raw email content received through POP3 or IMAP into an internal {@link MailMessage}. */
+public class RawMailParser {
+  private static final ImmutableSet<String> MAIN_HEADERS =
+      ImmutableSet.of("to", "from", "cc", "date", "message-id", "subject", "content-type");
+
+  private RawMailParser() {}
+
+  /**
+   * Parses a MailMessage from a string.
+   *
+   * @param raw {@link String} payload as received over the wire
+   * @return parsed {@link MailMessage}
+   * @throws MailParsingException in case parsing fails
+   */
+  public static MailMessage parse(String raw) throws MailParsingException {
+    MailMessage.Builder messageBuilder = MailMessage.builder();
+    messageBuilder.rawContentUTF(raw);
+    Message mimeMessage;
+    try {
+      MessageBuilder builder = new DefaultMessageBuilder();
+      mimeMessage = builder.parseMessage(new ByteArrayInputStream(raw.getBytes(UTF_8)));
+    } catch (IOException | MimeException e) {
+      throw new MailParsingException("Can't parse email", e);
+    }
+    // Add general headers
+    if (mimeMessage.getMessageId() != null) {
+      messageBuilder.id(mimeMessage.getMessageId());
+    }
+    if (mimeMessage.getSubject() != null) {
+      messageBuilder.subject(mimeMessage.getSubject());
+    }
+    if (mimeMessage.getDate() != null) {
+      messageBuilder.dateReceived(mimeMessage.getDate().toInstant());
+    }
+
+    // Add From, To and Cc
+    if (mimeMessage.getFrom() != null && mimeMessage.getFrom().size() > 0) {
+      Mailbox from = mimeMessage.getFrom().get(0);
+      messageBuilder.from(new Address(from.getName(), from.getAddress()));
+    }
+    if (mimeMessage.getTo() != null) {
+      for (Mailbox m : mimeMessage.getTo().flatten()) {
+        messageBuilder.addTo(new Address(m.getName(), m.getAddress()));
+      }
+    }
+    if (mimeMessage.getCc() != null) {
+      for (Mailbox m : mimeMessage.getCc().flatten()) {
+        messageBuilder.addCc(new Address(m.getName(), m.getAddress()));
+      }
+    }
+
+    // Add additional headers
+    mimeMessage.getHeader().getFields().stream()
+        .filter(f -> !MAIN_HEADERS.contains(f.getName().toLowerCase()))
+        .forEach(f -> messageBuilder.addAdditionalHeader(f.getName() + ": " + f.getBody()));
+
+    // Add text and html body parts
+    StringBuilder textBuilder = new StringBuilder();
+    StringBuilder htmlBuilder = new StringBuilder();
+    try {
+      handleMimePart(mimeMessage, textBuilder, htmlBuilder);
+    } catch (IOException e) {
+      throw new MailParsingException("Can't parse email", e);
+    }
+    messageBuilder.textContent(Strings.emptyToNull(textBuilder.toString()));
+    messageBuilder.htmlContent(Strings.emptyToNull(htmlBuilder.toString()));
+
+    try {
+      // build() will only succeed if all required attributes were set. We wrap
+      // the IllegalStateException in a MailParsingException indicating that
+      // required attributes are missing, so that the caller doesn't fall over.
+      return messageBuilder.build();
+    } catch (IllegalStateException e) {
+      throw new MailParsingException("Missing required attributes after email was parsed", e);
+    }
+  }
+
+  /**
+   * Parses a MailMessage from an array of characters. Note that the character array is int-typed.
+   * This method is only used by POP3, which specifies that all transferred characters are US-ASCII
+   * (RFC 6856). When reading the input in Java, io.Reader yields ints. These can be safely
+   * converted to chars as all US-ASCII characters fit in a char. If emails contain non-ASCII
+   * characters, such as UTF runes, these will be encoded in ASCII using either Base64 or
+   * quoted-printable encoding.
+   *
+   * @param chars Array as received over the wire
+   * @return Parsed {@link MailMessage}
+   * @throws MailParsingException in case parsing fails
+   */
+  public static MailMessage parse(int[] chars) throws MailParsingException {
+    StringBuilder b = new StringBuilder(chars.length);
+    for (int c : chars) {
+      b.append((char) c);
+    }
+
+    MailMessage.Builder messageBuilder = parse(b.toString()).toBuilder();
+    messageBuilder.rawContent(ImmutableList.copyOf(Ints.asList(chars)));
+    return messageBuilder.build();
+  }
+
+  /**
+   * Traverses a mime tree and parses out text and html parts. All other parts will be dropped.
+   *
+   * @param part {@code MimePart} to parse
+   * @param textBuilder {@link StringBuilder} to append all plaintext parts
+   * @param htmlBuilder {@link StringBuilder} to append all html parts
+   * @throws IOException in case of a failure while transforming the input to a {@link String}
+   */
+  private static void handleMimePart(
+      Entity part, StringBuilder textBuilder, StringBuilder htmlBuilder) throws IOException {
+    if (isPlainOrHtml(part.getMimeType()) && !isAttachment(part.getDispositionType())) {
+      TextBody tb = (TextBody) part.getBody();
+      String result =
+          CharStreams.toString(new InputStreamReader(tb.getInputStream(), tb.getMimeCharset()));
+      if (part.getMimeType().equals("text/plain")) {
+        textBuilder.append(result);
+      } else if (part.getMimeType().equals("text/html")) {
+        htmlBuilder.append(result);
+      }
+    } else if (isMultipart(part.getMimeType())) {
+      Multipart multipart = (Multipart) part.getBody();
+      for (Entity e : multipart.getBodyParts()) {
+        handleMimePart(e, textBuilder, htmlBuilder);
+      }
+    }
+  }
+
+  private static boolean isPlainOrHtml(String mimeType) {
+    return (mimeType.equals("text/plain") || mimeType.equals("text/html"));
+  }
+
+  private static boolean isMultipart(String mimeType) {
+    return mimeType.startsWith("multipart/");
+  }
+
+  private static boolean isAttachment(String dispositionType) {
+    return dispositionType != null && dispositionType.equals("attachment");
+  }
+}
diff --git a/java/com/google/gerrit/mail/TextParser.java b/java/com/google/gerrit/mail/TextParser.java
new file mode 100644
index 0000000..1a63599
--- /dev/null
+++ b/java/com/google/gerrit/mail/TextParser.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.PeekingIterator;
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/** Provides parsing functionality for plaintext email. */
+public class TextParser {
+  private TextParser() {}
+
+  /**
+   * Parses comments from plaintext email.
+   *
+   * @param email @param email the message as received from the email service
+   * @param comments list of {@link Comment}s previously persisted on the change that caused the
+   *     original notification email to be sent out. Ordering must be the same as in the outbound
+   *     email
+   * @param changeUrl canonical change url that points to the change on this Gerrit instance.
+   *     Example: https://go-review.googlesource.com/#/c/91570
+   * @return list of MailComments parsed from the plaintext part of the email
+   */
+  public static List<MailComment> parse(
+      MailMessage email, Collection<Comment> comments, String changeUrl) {
+    String body = email.textContent();
+    // Replace CR-LF by \n
+    body = body.replace("\r\n", "\n");
+
+    List<MailComment> parsedComments = new ArrayList<>();
+
+    // Some email clients (like GMail) use >> for enquoting text when there are
+    // inline comments that the users typed. These will then be enquoted by a
+    // single >. We sanitize this by unifying it into >. Inline comments typed
+    // by the user will not be enquoted.
+    //
+    // Example:
+    // Some comment
+    // >> Quoted Text
+    // >> Quoted Text
+    // > A comment typed in the email directly
+    String singleQuotePattern = "\n> ";
+    String doubleQuotePattern = "\n>> ";
+    if (countOccurrences(body, doubleQuotePattern) > countOccurrences(body, singleQuotePattern)) {
+      body = body.replace(doubleQuotePattern, singleQuotePattern);
+    }
+
+    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+
+    MailComment currentComment = null;
+    String lastEncounteredFileName = null;
+    Comment lastEncounteredComment = null;
+    for (String line : Splitter.on('\n').split(body)) {
+      if (line.equals(">")) {
+        // Skip empty lines
+        continue;
+      }
+      if (line.startsWith("> ")) {
+        line = line.substring("> ".length()).trim();
+        // This is not a comment, try to advance the file/comment pointers and
+        // add previous comment to list if applicable
+        if (currentComment != null) {
+          if (currentComment.type == MailComment.CommentType.CHANGE_MESSAGE) {
+            currentComment.message = ParserUtil.trimQuotation(currentComment.message);
+          }
+          if (!Strings.isNullOrEmpty(currentComment.message)) {
+            ParserUtil.appendOrAddNewComment(currentComment, parsedComments);
+          }
+          currentComment = null;
+        }
+
+        if (!iter.hasNext()) {
+          continue;
+        }
+        Comment perspectiveComment = iter.peek();
+        if (line.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
+          if (lastEncounteredFileName == null
+              || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
+            // This is the annotation of a file
+            lastEncounteredFileName = perspectiveComment.key.filename;
+            lastEncounteredComment = null;
+          } else if (perspectiveComment.lineNbr == 0) {
+            // This was originally a file-level comment
+            lastEncounteredComment = perspectiveComment;
+            iter.next();
+          }
+        } else if (ParserUtil.isCommentUrl(line, changeUrl, perspectiveComment)) {
+          lastEncounteredComment = perspectiveComment;
+          iter.next();
+        }
+      } else {
+        // This is a comment. Try to append to previous comment if applicable or
+        // create a new comment.
+        if (currentComment == null) {
+          // Start new comment
+          currentComment = new MailComment();
+          currentComment.message = line;
+          if (lastEncounteredComment == null) {
+            if (lastEncounteredFileName == null) {
+              // Change message
+              currentComment.type = MailComment.CommentType.CHANGE_MESSAGE;
+            } else {
+              // File comment not sent in reply to another comment
+              currentComment.type = MailComment.CommentType.FILE_COMMENT;
+              currentComment.fileName = lastEncounteredFileName;
+            }
+          } else {
+            // Comment sent in reply to another comment
+            currentComment.inReplyTo = lastEncounteredComment;
+            currentComment.type = MailComment.CommentType.INLINE_COMMENT;
+          }
+        } else {
+          // Attach to previous comment
+          currentComment.message += "\n" + line;
+        }
+      }
+    }
+    // There is no need to attach the currentComment after this loop as all
+    // emails have footers and other enquoted text after the last comment
+    // appeared and the last comment will have already been added to the list
+    // at this point.
+
+    return parsedComments;
+  }
+
+  /** Counts the occurrences of pattern in s */
+  private static int countOccurrences(String s, String pattern) {
+    return (s.length() - s.replace(pattern, "").length()) / pattern.length();
+  }
+}
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index dda2c39..68b29a4 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -6,8 +6,10 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
-        "//java/org/eclipse/jgit:server",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
index ea408e2..1fb8c57 100644
--- a/java/com/google/gerrit/metrics/DisabledMetricMaker.java
+++ b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -68,9 +68,9 @@
 
   @Override
   public Timer0 newTimer(String name, Description desc) {
-    return new Timer0() {
+    return new Timer0(name) {
       @Override
-      public void record(long value, TimeUnit unit) {}
+      protected void doRecord(long value, TimeUnit unit) {}
 
       @Override
       public void remove() {}
@@ -79,9 +79,9 @@
 
   @Override
   public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
-    return new Timer1<F1>() {
+    return new Timer1<F1>(name, field1) {
       @Override
-      public void record(F1 field1, long value, TimeUnit unit) {}
+      protected void doRecord(F1 field1, long value, TimeUnit unit) {}
 
       @Override
       public void remove() {}
@@ -91,9 +91,9 @@
   @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, field1, field2) {
       @Override
-      public void record(F1 field1, F2 field2, long value, TimeUnit unit) {}
+      protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {}
 
       @Override
       public void remove() {}
@@ -103,9 +103,9 @@
   @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, field1, field2, field3) {
       @Override
-      public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
+      protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
 
       @Override
       public void remove() {}
@@ -187,9 +187,6 @@
 
   @Override
   public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics, Runnable trigger) {
-    return new RegistrationHandle() {
-      @Override
-      public void remove() {}
-    };
+    return () -> {};
   }
 }
diff --git a/java/com/google/gerrit/metrics/Field.java b/java/com/google/gerrit/metrics/Field.java
index 95eb9cf..bdae854 100644
--- a/java/com/google/gerrit/metrics/Field.java
+++ b/java/com/google/gerrit/metrics/Field.java
@@ -16,34 +16,36 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Function;
-import com.google.common.base.Functions;
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.logging.Metadata;
+import java.util.Optional;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
 
 /**
  * Describes a bucketing field used by a metric.
  *
  * @param <T> type of field
  */
-public class Field<T> {
-  /**
-   * Break down metrics by boolean true/false.
-   *
-   * @param name field name
-   * @return boolean field
-   */
-  public static Field<Boolean> ofBoolean(String name) {
-    return ofBoolean(name, null);
+@AutoValue
+public abstract class Field<T> {
+  public static <T> BiConsumer<Metadata.Builder, T> ignoreMetadata() {
+    return (metadataBuilder, fieldValue) -> {};
   }
 
   /**
    * Break down metrics by boolean true/false.
    *
    * @param name field name
-   * @param description field description
-   * @return boolean field
+   * @return builder for the boolean field
    */
-  public static Field<Boolean> ofBoolean(String name, String description) {
-    return new Field<>(name, Boolean.class, description);
+  public static Field.Builder<Boolean> ofBoolean(
+      String name, BiConsumer<Metadata.Builder, Boolean> metadataMapper) {
+    return new AutoValue_Field.Builder<Boolean>()
+        .valueType(Boolean.class)
+        .formatter(Object::toString)
+        .name(name)
+        .metadataMapper(metadataMapper);
   }
 
   /**
@@ -51,50 +53,17 @@
    *
    * @param enumType type of enum
    * @param name field name
-   * @return enum field
+   * @return builder for the enum field
    */
-  public static <E extends Enum<E>> Field<E> ofEnum(Class<E> enumType, String name) {
-    return ofEnum(enumType, name, null);
-  }
-
-  /**
-   * Break down metrics by cases of an enum.
-   *
-   * @param enumType type of enum
-   * @param name field name
-   * @param description field description
-   * @return enum field
-   */
-  public static <E extends Enum<E>> Field<E> ofEnum(
-      Class<E> enumType, String name, String description) {
-    return new Field<>(name, enumType, description);
-  }
-
-  /**
-   * Break down metrics by string.
-   *
-   * <p>Each unique string will allocate a new submetric. <b>Do not use user content as a field
-   * value</b> as field values are never reclaimed.
-   *
-   * @param name field name
-   * @return string field
-   */
-  public static Field<String> ofString(String name) {
-    return ofString(name, null);
-  }
-
-  /**
-   * Break down metrics by string.
-   *
-   * <p>Each unique string will allocate a new submetric. <b>Do not use user content as a field
-   * value</b> as field values are never reclaimed.
-   *
-   * @param name field name
-   * @param description field description
-   * @return string field
-   */
-  public static Field<String> ofString(String name, String description) {
-    return new Field<>(name, String.class, description);
+  public static <E extends Enum<E>> Field.Builder<E> ofEnum(
+      Class<E> enumType, String name, BiConsumer<Metadata.Builder, String> metadataMapper) {
+    return new AutoValue_Field.Builder<E>()
+        .valueType(enumType)
+        .formatter(Enum::name)
+        .name(name)
+        .metadataMapper(
+            (metadataBuilder, fieldValue) ->
+                metadataMapper.accept(metadataBuilder, fieldValue.name()));
   }
 
   /**
@@ -104,67 +73,68 @@
    * value</b> as field values are never reclaimed.
    *
    * @param name field name
-   * @return integer field
+   * @return builder for the integer field
    */
-  public static Field<Integer> ofInteger(String name) {
-    return ofInteger(name, null);
+  public static Field.Builder<Integer> ofInteger(
+      String name, BiConsumer<Metadata.Builder, Integer> metadataMapper) {
+    return new AutoValue_Field.Builder<Integer>()
+        .valueType(Integer.class)
+        .formatter(Object::toString)
+        .name(name)
+        .metadataMapper(metadataMapper);
   }
 
   /**
-   * Break down metrics by integer.
+   * Break down metrics by string.
    *
-   * <p>Each unique integer will allocate a new submetric. <b>Do not use user content as a field
+   * <p>Each unique string will allocate a new submetric. <b>Do not use user content as a field
    * value</b> as field values are never reclaimed.
    *
    * @param name field name
-   * @param description field description
-   * @return integer field
+   * @return builder for the string field
    */
-  public static Field<Integer> ofInteger(String name, String description) {
-    return new Field<>(name, Integer.class, description);
-  }
-
-  private final String name;
-  private final Class<T> keyType;
-  private final Function<T, String> formatter;
-  private final String description;
-
-  private Field(String name, Class<T> keyType, String description) {
-    checkArgument(name.matches("^[a-z_]+$"), "name must match [a-z_]");
-    this.name = name;
-    this.keyType = keyType;
-    this.formatter = initFormatter(keyType);
-    this.description = description;
+  public static Field.Builder<String> ofString(
+      String name, BiConsumer<Metadata.Builder, String> metadataMapper) {
+    return new AutoValue_Field.Builder<String>()
+        .valueType(String.class)
+        .formatter(s -> s)
+        .name(name)
+        .metadataMapper(metadataMapper);
   }
 
   /** @return name of this field within the metric. */
-  public String getName() {
-    return name;
-  }
+  public abstract String name();
 
   /** @return type of value used within the field. */
-  public Class<T> getType() {
-    return keyType;
-  }
+  public abstract Class<T> valueType();
+
+  /** @return mapper that maps a field value to a field in the {@link Metadata} class. */
+  public abstract BiConsumer<Metadata.Builder, T> metadataMapper();
 
   /** @return description text for the field explaining its range of values. */
-  public String getDescription() {
-    return description;
-  }
+  public abstract Optional<String> description();
 
-  public Function<T, String> formatter() {
-    return formatter;
-  }
+  /** @return formatter to format field values. */
+  public abstract Function<T, String> formatter();
 
-  @SuppressWarnings("unchecked")
-  private static <T> Function<T, String> initFormatter(Class<T> keyType) {
-    if (keyType == String.class) {
-      return (Function<T, String>) Functions.<String>identity();
-    } else if (keyType == Integer.class || keyType == Boolean.class) {
-      return (Function<T, String>) Functions.toStringFunction();
-    } else if (Enum.class.isAssignableFrom(keyType)) {
-      return in -> ((Enum<?>) in).name();
+  @AutoValue.Builder
+  public abstract static class Builder<T> {
+    abstract Builder<T> name(String name);
+
+    abstract Builder<T> valueType(Class<T> type);
+
+    abstract Builder<T> formatter(Function<T, String> formatter);
+
+    abstract Builder<T> metadataMapper(BiConsumer<Metadata.Builder, T> metadataMapper);
+
+    public abstract Builder<T> description(String description);
+
+    abstract Field<T> autoBuild();
+
+    public Field<T> build() {
+      Field<T> field = autoBuild();
+      checkArgument(field.name().matches("^[a-z_]+$"), "name must match [a-z_]");
+      return field;
     }
-    throw new IllegalStateException("unsupported type " + keyType.getName());
   }
 }
diff --git a/java/com/google/gerrit/metrics/MetricMaker.java b/java/com/google/gerrit/metrics/MetricMaker.java
index 401a6d6..42ec8a0 100644
--- a/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/java/com/google/gerrit/metrics/MetricMaker.java
@@ -136,7 +136,7 @@
    * @return registration handle
    */
   public RegistrationHandle newTrigger(CallbackMetric<?> metric1, Runnable trigger) {
-    return newTrigger(ImmutableSet.<CallbackMetric<?>>of(metric1), trigger);
+    return newTrigger(ImmutableSet.of(metric1), trigger);
   }
 
   public RegistrationHandle newTrigger(
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index 55d1ddf..d0033a4 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -16,7 +16,10 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -30,6 +33,8 @@
  * </pre>
  */
 public abstract class Timer0 implements RegistrationHandle {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static class Context extends TimerContext {
     private final Timer0 timer;
 
@@ -43,6 +48,12 @@
     }
   }
 
+  protected final String name;
+
+  public Timer0(String name) {
+    this.name = name;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
@@ -58,5 +69,19 @@
    * @param value value to record
    * @param unit time unit of the value
    */
-  public abstract void record(long value, TimeUnit unit);
+  public final void record(long value, TimeUnit unit) {
+    long durationMs = unit.toMillis(value);
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
+    logger.atFinest().log("%s took %dms", name, durationMs);
+    doRecord(value, unit);
+  }
+
+  /**
+   * Record a value in the distribution.
+   *
+   * @param value value to record
+   * @param unit time unit of the value
+   */
+  protected abstract void doRecord(long value, TimeUnit unit);
 }
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
index f623841..a8fb1a2 100644
--- a/java/com/google/gerrit/metrics/Timer1.java
+++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -16,7 +16,11 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -32,38 +36,68 @@
  * @param <F1> type of the field.
  */
 public abstract class Timer1<F1> implements RegistrationHandle {
-  public static class Context extends TimerContext {
-    private final Timer1<Object> timer;
-    private final Object field1;
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-    @SuppressWarnings("unchecked")
-    <F1> Context(Timer1<F1> timer, F1 field1) {
-      this.timer = (Timer1<Object>) timer;
-      this.field1 = field1;
+  public static class Context<F1> extends TimerContext {
+    private final Timer1<F1> timer;
+    private final F1 fieldValue;
+
+    Context(Timer1<F1> timer, F1 fieldValue) {
+      this.timer = timer;
+      this.fieldValue = fieldValue;
     }
 
     @Override
     public void record(long elapsed) {
-      timer.record(field1, elapsed, NANOSECONDS);
+      timer.record(fieldValue, elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+  protected final Field<F1> field;
+
+  public Timer1(String name, Field<F1> field) {
+    this.name = name;
+    this.field = field;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
-   * @param field1 bucket to record the timer
+   * @param fieldValue bucket to record the timer
    * @return timer context
    */
-  public Context start(F1 field1) {
-    return new Context(this, field1);
+  public Context<F1> start(F1 fieldValue) {
+    return new Context<>(this, fieldValue);
   }
 
   /**
    * Record a value in the distribution.
    *
-   * @param field1 bucket to record the timer
+   * @param fieldValue bucket to record the timer
    * @param value value to record
    * @param unit time unit of the value
    */
-  public abstract void record(F1 field1, long value, TimeUnit unit);
+  public final void record(F1 fieldValue, long value, TimeUnit unit) {
+    long durationMs = unit.toMillis(value);
+
+    Metadata.Builder metadataBuilder = Metadata.builder();
+    field.metadataMapper().accept(metadataBuilder, fieldValue);
+    Metadata metadata = metadataBuilder.build();
+
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+
+    logger.atFinest().log("%s (%s = %s) took %dms", name, field.name(), fieldValue, durationMs);
+    doRecord(fieldValue, value, unit);
+  }
+
+  /**
+   * Record a value in the distribution.
+   *
+   * @param fieldValue bucket to record the timer
+   * @param value value to record
+   * @param unit time unit of the value
+   */
+  protected abstract void doRecord(F1 fieldValue, long value, TimeUnit unit);
 }
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
index b03ff83..8a4a793 100644
--- a/java/com/google/gerrit/metrics/Timer2.java
+++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -16,7 +16,11 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -33,42 +37,78 @@
  * @param <F2> type of the field.
  */
 public abstract class Timer2<F1, F2> implements RegistrationHandle {
-  public static class Context extends TimerContext {
-    private final Timer2<Object, Object> timer;
-    private final Object field1;
-    private final Object field2;
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-    @SuppressWarnings("unchecked")
-    <F1, F2> Context(Timer2<F1, F2> timer, F1 field1, F2 field2) {
-      this.timer = (Timer2<Object, Object>) timer;
-      this.field1 = field1;
-      this.field2 = field2;
+  public static class Context<F1, F2> extends TimerContext {
+    private final Timer2<F1, F2> timer;
+    private final F1 fieldValue1;
+    private final F2 fieldValue2;
+
+    Context(Timer2<F1, F2> timer, F1 fieldValue1, F2 fieldValue2) {
+      this.timer = timer;
+      this.fieldValue1 = fieldValue1;
+      this.fieldValue2 = fieldValue2;
     }
 
     @Override
     public void record(long elapsed) {
-      timer.record(field1, field2, elapsed, NANOSECONDS);
+      timer.record(fieldValue1, fieldValue2, elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+  protected final Field<F1> field1;
+  protected final Field<F2> field2;
+
+  public Timer2(String name, Field<F1> field1, Field<F2> field2) {
+    this.name = name;
+    this.field1 = field1;
+    this.field2 = field2;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
    * @return timer context
    */
-  public Context start(F1 field1, F2 field2) {
-    return new Context(this, field1, field2);
+  public Context<F1, F2> start(F1 fieldValue1, F2 fieldValue2) {
+    return new Context<>(this, fieldValue1, fieldValue2);
   }
 
   /**
    * Record a value in the distribution.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
    * @param value value to record
    * @param unit time unit of the value
    */
-  public abstract void record(F1 field1, F2 field2, long value, TimeUnit unit);
+  public final void record(F1 fieldValue1, F2 fieldValue2, long value, TimeUnit unit) {
+    long durationMs = unit.toMillis(value);
+
+    Metadata.Builder metadataBuilder = Metadata.builder();
+    field1.metadataMapper().accept(metadataBuilder, fieldValue1);
+    field2.metadataMapper().accept(metadataBuilder, fieldValue2);
+    Metadata metadata = metadataBuilder.build();
+
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+
+    logger.atFinest().log(
+        "%s (%s = %s, %s = %s) took %dms",
+        name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationMs);
+    doRecord(fieldValue1, fieldValue2, value, unit);
+  }
+
+  /**
+   * Record a value in the distribution.
+   *
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
+   * @param value value to record
+   * @param unit time unit of the value
+   */
+  protected abstract void doRecord(F1 fieldValue1, F2 fieldValue2, long value, TimeUnit unit);
 }
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
index 91af42c..2044da6 100644
--- a/java/com/google/gerrit/metrics/Timer3.java
+++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -16,7 +16,11 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -34,46 +38,95 @@
  * @param <F3> type of the field.
  */
 public abstract class Timer3<F1, F2, F3> implements RegistrationHandle {
-  public static class Context extends TimerContext {
-    private final Timer3<Object, Object, Object> timer;
-    private final Object field1;
-    private final Object field2;
-    private final Object field3;
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-    @SuppressWarnings("unchecked")
-    <F1, F2, F3> Context(Timer3<F1, F2, F3> timer, F1 f1, F2 f2, F3 f3) {
-      this.timer = (Timer3<Object, Object, Object>) timer;
-      this.field1 = f1;
-      this.field2 = f2;
-      this.field3 = f3;
+  public static class Context<F1, F2, F3> extends TimerContext {
+    private final Timer3<F1, F2, F3> timer;
+    private final F1 fieldValue1;
+    private final F2 fieldValue2;
+    private final F3 fieldValue3;
+
+    Context(Timer3<F1, F2, F3> timer, F1 f1, F2 f2, F3 f3) {
+      this.timer = timer;
+      this.fieldValue1 = f1;
+      this.fieldValue2 = f2;
+      this.fieldValue3 = f3;
     }
 
     @Override
     public void record(long elapsed) {
-      timer.record(field1, field2, field3, elapsed, NANOSECONDS);
+      timer.record(fieldValue1, fieldValue2, fieldValue3, elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+  protected final Field<F1> field1;
+  protected final Field<F2> field2;
+  protected final Field<F3> field3;
+
+  public Timer3(String name, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    this.name = name;
+    this.field1 = field1;
+    this.field2 = field2;
+    this.field3 = field3;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
-   * @param field3 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
+   * @param fieldValue3 bucket to record the timer
    * @return timer context
    */
-  public Context start(F1 field1, F2 field2, F3 field3) {
-    return new Context(this, field1, field2, field3);
+  public Context<F1, F2, F3> start(F1 fieldValue1, F2 fieldValue2, F3 fieldValue3) {
+    return new Context<>(this, fieldValue1, fieldValue2, fieldValue3);
   }
 
   /**
    * Record a value in the distribution.
    *
-   * @param field1 bucket to record the timer
-   * @param field2 bucket to record the timer
-   * @param field3 bucket to record the timer
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
+   * @param fieldValue3 bucket to record the timer
    * @param value value to record
    * @param unit time unit of the value
    */
-  public abstract void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit);
+  public final void record(
+      F1 fieldValue1, F2 fieldValue2, F3 fieldValue3, long value, TimeUnit unit) {
+    long durationMs = unit.toMillis(value);
+
+    Metadata.Builder metadataBuilder = Metadata.builder();
+    field1.metadataMapper().accept(metadataBuilder, fieldValue1);
+    field2.metadataMapper().accept(metadataBuilder, fieldValue2);
+    field3.metadataMapper().accept(metadataBuilder, fieldValue3);
+    Metadata metadata = metadataBuilder.build();
+
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+
+    logger.atFinest().log(
+        "%s (%s = %s, %s = %s, %s = %s) took %dms",
+        name,
+        field1.name(),
+        fieldValue1,
+        field2.name(),
+        fieldValue2,
+        field3.name(),
+        fieldValue3,
+        durationMs);
+    doRecord(fieldValue1, fieldValue2, fieldValue3, value, unit);
+  }
+
+  /**
+   * Record a value in the distribution.
+   *
+   * @param fieldValue1 bucket to record the timer
+   * @param fieldValue2 bucket to record the timer
+   * @param fieldValue3 bucket to record the timer
+   * @param value value to record
+   * @param unit time unit of the value
+   */
+  protected abstract void doRecord(
+      F1 fieldValue1, F2 fieldValue2, F3 fieldValue3, long value, TimeUnit unit);
 }
diff --git a/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java b/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
index ee87397..e33c242 100644
--- a/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
+++ b/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
@@ -120,7 +120,7 @@
 
   @Override
   public Map<Object, Metric> getCells() {
-    return Maps.transformValues(cells, in -> (Metric) in);
+    return Maps.transformValues(cells, in -> in);
   }
 
   final class ValueGauge implements Gauge<V> {
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/CallbackMetricImpl0.java b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
index 5e25651..6e7e7d9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
+import com.codahale.metrics.Gauge;
 import com.codahale.metrics.MetricRegistry;
 import com.google.gerrit.metrics.CallbackMetric0;
 
@@ -71,12 +72,10 @@
   public void register(Runnable trigger) {
     registry.register(
         name,
-        new com.codahale.metrics.Gauge<V>() {
-          @Override
-          public V getValue() {
-            trigger.run();
-            return value;
-          }
-        });
+        (Gauge<V>)
+            () -> {
+              trigger.run();
+              return value;
+            });
   }
 }
diff --git a/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
index 6d1daf4..d718035 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.metrics.dropwizard;
 
 import com.codahale.metrics.MetricRegistry;
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.CallbackMetric1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedCallback} for single dimension. */
 class CallbackMetricImpl1<F1, V> extends BucketedCallback<V> {
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
index 46434ce..0e554a8 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedCounter} for single dimension. */
 class CounterImpl1<F1> extends BucketedCounter {
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
index 38c31a1..07afc2a 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Counter2;
 import com.google.gerrit.metrics.Counter3;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
+import java.util.function.Function;
 
 /** Generalized implementation of N-dimensional counter metrics. */
 class CounterImplN extends BucketedCounter implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
index fc53ee7..fcba0ee 100644
--- a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
+++ b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -305,14 +305,7 @@
     }
     trigger.run();
 
-    return new RegistrationHandle() {
-      @Override
-      public void remove() {
-        for (CallbackMetricGlue m : all) {
-          m.remove();
-        }
-      }
-    };
+    return () -> all.forEach(CallbackMetricGlue::remove);
   }
 
   synchronized void remove(String name) {
@@ -393,16 +386,15 @@
   }
 
   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;
     }
 
     @Override
-    public void record(long value, TimeUnit unit) {
+    protected void doRecord(long value, TimeUnit unit) {
       checkArgument(value >= 0, "timer delta must be >= 0");
       metric.update(value, unit);
     }
diff --git a/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
index ae1e6ec..12dabfa 100644
--- a/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
+++ b/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.metrics.dropwizard;
 
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,10 +37,10 @@
   }
 
   @Override
-  public MetricJson apply(MetricResource resource)
+  public Response<MetricJson> apply(MetricResource resource)
       throws AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
-    return new MetricJson(
-        resource.getMetric(), metrics.getAnnotations(resource.getName()), dataOnly);
+    return Response.ok(
+        new MetricJson(resource.getMetric(), metrics.getAnnotations(resource.getName()), dataOnly));
   }
 }
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
index 3eb12fa..4578db1 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Histogram1;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedHistogram} for single dimension. */
 class HistogramImpl1<F1> extends BucketedHistogram implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
index 3561c55a..446590c 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Histogram2;
 import com.google.gerrit.metrics.Histogram3;
+import java.util.function.Function;
 
 /** Generalized implementation of N-dimensional Histogram metrics. */
 class HistogramImplN extends BucketedHistogram implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 0c69452..7e472c9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -16,6 +16,7 @@
 
 import com.codahale.metrics.Metric;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -50,7 +51,7 @@
   }
 
   @Override
-  public Map<String, MetricJson> apply(ConfigResource resource)
+  public Response<Map<String, MetricJson>> apply(ConfigResource resource)
       throws AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
 
@@ -75,7 +76,7 @@
       }
     }
 
-    return out;
+    return Response.ok(out);
   }
 
   private MetricJson toJson(String q, Metric m) {
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
index 2080623..d59a1d9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -21,7 +21,6 @@
 import com.codahale.metrics.Metric;
 import com.codahale.metrics.Snapshot;
 import com.codahale.metrics.Timer;
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.metrics.Description;
@@ -30,6 +29,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.function.Function;
 
 class MetricJson {
   String description;
@@ -137,7 +137,7 @@
       p99_9 = s.get999thPercentile();
 
       min = (double) s.getMin();
-      avg = (double) s.getMean();
+      avg = s.getMean();
       max = (double) s.getMax();
       sum = s.getMean() * m.getCount();
       std_dev = s.getStdDev();
@@ -189,10 +189,10 @@
     String description;
 
     FieldJson(Field<?> field) {
-      this.name = field.getName();
-      this.description = field.getDescription();
+      this.name = field.name();
+      this.description = field.description().orElse(null);
       this.type =
-          Enum.class.isAssignableFrom(field.getType()) ? field.getType().getSimpleName() : null;
+          Enum.class.isAssignableFrom(field.valueType()) ? field.valueType().getSimpleName() : null;
     }
   }
 }
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
index fe6f70e..b7d535b 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Timer1;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedTimer} for single dimension. */
 class TimerImpl1<F1> extends BucketedTimer implements BucketedMetric {
@@ -26,10 +26,11 @@
     super(metrics, name, desc, field1);
   }
 
+  @SuppressWarnings("unchecked")
   Timer1<F1> timer() {
-    return new Timer1<F1>() {
+    return new Timer1<F1>(name, (Field<F1>) fields[0]) {
       @Override
-      public void record(F1 field1, long value, TimeUnit unit) {
+      protected void doRecord(F1 field1, long value, TimeUnit unit) {
         total.record(value, unit);
         forceCreate(field1).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..dee800e 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Description;
@@ -22,6 +21,7 @@
 import com.google.gerrit.metrics.Timer2;
 import com.google.gerrit.metrics.Timer3;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 
 /** Generalized implementation of N-dimensional timer metrics. */
 class TimerImplN extends BucketedTimer implements BucketedMetric {
@@ -29,10 +29,11 @@
     super(metrics, name, desc, fields);
   }
 
+  @SuppressWarnings("unchecked")
   <F1, F2> Timer2<F1, F2> timer2() {
-    return new Timer2<F1, F2>() {
+    return new Timer2<F1, F2>(name, (Field<F1>) fields[0], (Field<F2>) fields[1]) {
       @Override
-      public void record(F1 field1, F2 field2, long value, TimeUnit unit) {
+      protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {
         total.record(value, unit);
         forceCreate(field1, field2).record(value, unit);
       }
@@ -44,10 +45,12 @@
     };
   }
 
+  @SuppressWarnings("unchecked")
   <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
-    return new Timer3<F1, F2, F3>() {
+    return new Timer3<F1, F2, F3>(
+        name, (Field<F1>) fields[0], (Field<F2>) fields[1], (Field<F3>) fields[2]) {
       @Override
-      public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
+      protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
         total.record(value, unit);
         forceCreate(field1, field2, field3).record(value, unit);
       }
diff --git a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
index 10d589a..35c147e 100644
--- a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
+++ b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
@@ -51,10 +51,9 @@
   private OperatingSystemMXBeanProvider(OperatingSystemMXBean sys)
       throws ReflectiveOperationException {
     this.sys = sys;
-    getProcessCpuTime = sys.getClass().getMethod("getProcessCpuTime", new Class<?>[] {});
+    getProcessCpuTime = sys.getClass().getMethod("getProcessCpuTime");
     getProcessCpuTime.setAccessible(true);
-    getOpenFileDescriptorCount =
-        sys.getClass().getMethod("getOpenFileDescriptorCount", new Class<?>[] {});
+    getOpenFileDescriptorCount = sys.getClass().getMethod("getOpenFileDescriptorCount");
     getOpenFileDescriptorCount.setAccessible(true);
   }
 
diff --git a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 6b9176b..d9781b5 100644
--- a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -15,16 +15,15 @@
 package com.google.gerrit.metrics.proc;
 
 import com.google.common.base.Strings;
-import com.google.common.base.Supplier;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Version;
-import com.google.gerrit.metrics.CallbackMetric;
 import com.google.gerrit.metrics.CallbackMetric0;
 import com.google.gerrit.metrics.CallbackMetric1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.logging.Metadata;
 import java.lang.management.GarbageCollectorMXBean;
 import java.lang.management.ManagementFactory;
 import java.lang.management.MemoryMXBean;
@@ -75,12 +74,7 @@
           "proc/cpu/usage",
           Double.class,
           new Description("CPU time used by the process").setCumulative().setUnit(Units.SECONDS),
-          new Supplier<Double>() {
-            @Override
-            public Double get() {
-              return provider.getProcessCpuTime() / 1e9;
-            }
-          });
+          () -> provider.getProcessCpuTime() / 1e9);
     }
 
     if (provider.getOpenFileDescriptorCount() != -1) {
@@ -135,7 +129,7 @@
 
     MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
     metrics.newTrigger(
-        ImmutableSet.<CallbackMetric<?>>of(
+        ImmutableSet.of(
             heapCommitted, heapUsed, nonHeapCommitted, nonHeapUsed, objectPendingFinalizationCount),
         () -> {
           try {
@@ -155,12 +149,17 @@
   }
 
   private void procJvmGc(MetricMaker metrics) {
+    Field<String> gcNameField =
+        Field.ofString("gc_name", Metadata.Builder::garbageCollectorName)
+            .description("The name of the garbage collector")
+            .build();
+
     CallbackMetric1<String, Long> gcCount =
         metrics.newCallbackMetric(
             "proc/jvm/gc/count",
             Long.class,
             new Description("Number of GCs").setCumulative(),
-            Field.ofString("gc_name", "The name of the garbage collector"));
+            gcNameField);
 
     CallbackMetric1<String, Long> gcTime =
         metrics.newCallbackMetric(
@@ -169,7 +168,7 @@
             new Description("Approximate accumulated GC elapsed time")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            Field.ofString("gc_name", "The name of the garbage collector"));
+            gcNameField);
 
     metrics.newTrigger(
         gcCount,
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 95570ec..02c083c 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -18,12 +18,14 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/httpd/auth/oauth",
         "//java/com/google/gerrit/httpd/auth/openid",
         "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
@@ -36,16 +38,18 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/sshd",
         "//java/com/google/gerrit/util/http",
         "//lib:args4j",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:protobuf",
         "//lib:servlet-api-3_1-without-neverlink",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index c38e7f5..ab570a2 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static com.google.gerrit.common.Version.getVersion;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -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;
@@ -50,12 +51,14 @@
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks;
 import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
@@ -63,6 +66,7 @@
 import com.google.gerrit.server.config.AuthConfigModule;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
+import com.google.gerrit.server.config.DefaultUrlFormatter;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
@@ -78,23 +82,20 @@
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.OnlineUpgrader;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
 import com.google.gerrit.server.mail.receive.MailReceiver;
 import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
-import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
-import com.google.gerrit.server.notedb.rebuild.OnlineNoteDbMigrator;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginModule;
 import com.google.gerrit.server.project.DefaultProjectNameLockManager;
 import com.google.gerrit.server.restapi.RestApiModule;
-import com.google.gerrit.server.schema.DataSourceProvider;
-import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
-import com.google.gerrit.server.schema.SchemaVersionCheck;
+import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
@@ -117,15 +118,14 @@
 import com.google.inject.Provider;
 import com.google.inject.Stage;
 import java.io.IOException;
-import java.lang.Thread.UncaughtExceptionHandler;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import javax.servlet.http.HttpServletRequest;
 import org.eclipse.jgit.lib.Config;
 import org.kohsuke.args4j.Option;
-import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
 
 /** Run SSH daemon portions of Gerrit. */
 public class Daemon extends SiteProgram {
@@ -174,15 +174,6 @@
   @Option(name = "--stop-only", usage = "Stop the daemon", hidden = true)
   private boolean stopOnly;
 
-  @Option(
-      name = "--migrate-to-note-db",
-      usage = "Automatically migrate changes to NoteDb",
-      handler = ExplicitBooleanOptionHandler.class)
-  private boolean migrateToNoteDb;
-
-  @Option(name = "--trial", usage = "(With --migrate-to-note-db) " + MigrateToNoteDb.TRIAL_USAGE)
-  private boolean trial;
-
   private final LifecycleManager manager = new LifecycleManager();
   private Injector dbInjector;
   private Injector cfgInjector;
@@ -195,7 +186,8 @@
   private boolean inMemoryTest;
   private AbstractModule luceneModule;
   private Module emailModule;
-  private Module testSysModule;
+  private List<Module> testSysModules = new ArrayList<>();
+  private Module auditEventModule;
 
   private Runnable serverStarted;
   private IndexType indexType;
@@ -241,12 +233,7 @@
     }
     mustHaveValidSite();
     Thread.setDefaultUncaughtExceptionHandler(
-        new UncaughtExceptionHandler() {
-          @Override
-          public void uncaughtException(Thread t, Throwable e) {
-            logger.atSevere().withCause(e).log("Thread %s threw exception", t.getName());
-          }
-        });
+        (t, e) -> logger.atSevere().withCause(e).log("Thread %s threw exception", t.getName()));
 
     if (runId != null) {
       runFile = getSitePath().resolve("logs").resolve("gerrit.run");
@@ -285,8 +272,6 @@
       if (inspector) {
         JythonShell shell = new JythonShell();
         shell.set("m", manager);
-        shell.set("ds", dbInjector.getInstance(DataSourceProvider.class));
-        shell.set("schk", dbInjector.getInstance(SchemaVersionCheck.class));
         shell.set("d", this);
         shell.run();
       } else {
@@ -317,20 +302,25 @@
   }
 
   @VisibleForTesting
+  public void setAuditEventModuleForTesting(Module module) {
+    auditEventModule = module;
+  }
+
+  @VisibleForTesting
   public void setLuceneModule(LuceneIndexModule m) {
     luceneModule = m;
     inMemoryTest = true;
   }
 
   @VisibleForTesting
-  public void setAdditionalSysModuleForTesting(@Nullable Module m) {
-    testSysModule = m;
+  public void addAdditionalSysModuleForTesting(@Nullable Module... modules) {
+    testSysModules.addAll(Arrays.asList(modules));
   }
 
   @VisibleForTesting
   public void start() throws IOException {
     if (dbInjector == null) {
-      dbInjector = createDbInjector(true /* enableMetrics */, MULTI_USER);
+      dbInjector = createDbInjector(true /* enableMetrics */);
     }
     cfgInjector = createCfgInjector();
     config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
@@ -377,7 +367,15 @@
   }
 
   private String myVersion() {
-    return com.google.gerrit.common.Version.getVersion();
+    List<String> versionParts = new ArrayList<>();
+    if (slave) {
+      versionParts.add("[slave]");
+    }
+    if (headless) {
+      versionParts.add("[headless]");
+    }
+    versionParts.add(getVersion());
+    return Joiner.on(" ").join(versionParts);
   }
 
   private Injector createCfgInjector() {
@@ -388,23 +386,10 @@
 
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
-    modules.add(SchemaVersionCheck.module());
+    modules.add(NoteDbSchemaVersionCheck.module());
     modules.add(new DropWizardMetricMaker.RestModule());
     modules.add(new LogFileCompressor.Module());
 
-    // Plugin module needs to be inserted *before* the index module.
-    // There is the concept of LifecycleModule, in Gerrit's own extension
-    // to Guice, which has these:
-    //  listener().to(SomeClassImplementingLifecycleListener.class);
-    // and the start() methods of each such listener are executed in the
-    // order they are declared.
-    // Makes sure that PluginLoader.start() is executed before the
-    // LuceneIndexModule.start() so that plugins get loaded and the respective
-    // Guice modules installed so that the on-line reindexing will happen
-    // with the proper classes (e.g. group backends, custom Prolog
-    // predicates) and the associated rules ready to be evaluated.
-    modules.add(new PluginModule());
-
     // Index module shutdown must happen before work queue shutdown, otherwise
     // work queue can get stuck waiting on index futures that will never return.
     modules.add(createIndexModule());
@@ -412,10 +397,7 @@
     modules.add(new WorkQueue.Module());
     modules.add(new StreamEventsApiListener.Module());
     modules.add(new EventBroker.Module());
-    modules.add(
-        inMemoryTest
-            ? new InMemoryAccountPatchReviewStore.Module()
-            : new JdbcAccountPatchReviewStore.Module(config));
+    modules.add(new JdbcAccountPatchReviewStore.Module(config));
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
@@ -434,7 +416,16 @@
     } else {
       modules.add(new SmtpEmailSender.Module());
     }
+    if (auditEventModule != null) {
+      modules.add(auditEventModule);
+    } else {
+      modules.add(new AuditModule());
+    }
     modules.add(new SignedTokenEmailTokenVerifier.Module());
+    modules.add(new PluginModule());
+    if (VersionManager.getOnlineUpgrade(config)) {
+      modules.add(new OnlineUpgrader.Module());
+    }
     modules.add(new RestApiModule());
     modules.add(new GpgModule(config));
     modules.add(new StartupChecks.Module());
@@ -456,6 +447,7 @@
             }
           });
     }
+    modules.add(new DefaultUrlFormatter.Module());
     if (sshd) {
       modules.add(SshKeyCacheImpl.module());
     } else {
@@ -465,8 +457,7 @@
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class)
-                .toInstance(new GerritOptions(config, headless, slave, polyGerritDev));
+            bind(GerritOptions.class).toInstance(new GerritOptions(headless, slave, polyGerritDev));
             if (inMemoryTest) {
               bind(String.class)
                   .annotatedWith(SecureStoreClassName.class)
@@ -482,52 +473,31 @@
       modules.add(new AccountDeactivator.Module());
       modules.add(new ChangeCleanupRunner.Module());
     }
-    if (migrateToNoteDb()) {
-      modules.add(new OnlineNoteDbMigrator.Module(trial));
-    }
-    if (testSysModule != null) {
-      modules.add(testSysModule);
-    }
+    modules.addAll(testSysModules);
     modules.add(new LocalMergeSuperSetComputation.Module());
     modules.add(new DefaultProjectNameLockManager.Module());
     return cfgInjector.createChildInjector(
-        ModuleOverloader.override(modules, LibModuleLoader.loadModules(cfgInjector)));
-  }
-
-  private boolean migrateToNoteDb() {
-    return migrateToNoteDb || NoteDbMigrator.getAutoMigrate(checkNotNull(config));
+        ModuleOverloader.override(
+            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE)));
   }
 
   private Module createIndexModule() {
     if (luceneModule != null) {
       return luceneModule;
     }
-    boolean onlineUpgrade =
-        VersionManager.getOnlineUpgrade(config)
-            // Schema upgrade is handled by OnlineNoteDbMigrator in this case.
-            && !migrateToNoteDb();
-    switch (indexType) {
-      case LUCENE:
-        return onlineUpgrade
-            ? LuceneIndexModule.latestVersionWithOnlineUpgrade(slave)
-            : LuceneIndexModule.latestVersionWithoutOnlineUpgrade(slave);
-      case ELASTICSEARCH:
-        return onlineUpgrade
-            ? ElasticIndexModule.latestVersionWithOnlineUpgrade(slave)
-            : ElasticIndexModule.latestVersionWithoutOnlineUpgrade(slave);
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
+    if (indexType.isLucene()) {
+      return LuceneIndexModule.latestVersion(slave);
     }
+    if (indexType.isElasticsearch()) {
+      return ElasticIndexModule.latestVersion(slave);
+    }
+    throw new IllegalStateException("unsupported index.type = " + indexType);
   }
 
   private void initIndexType() {
     indexType = IndexModule.getIndexType(cfgInjector);
-    switch (indexType) {
-      case LUCENE:
-      case ELASTICSEARCH:
-        break;
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
+    if (!indexType.isLucene() && !indexType.isElasticsearch()) {
+      throw new IllegalStateException("unsupported index.type = " + indexType);
     }
   }
 
@@ -573,10 +543,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());
@@ -596,7 +567,10 @@
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
-    modules.add(sysInjector.getInstance(StaticModule.class));
+    GerritOptions opts = sysInjector.getInstance(GerritOptions.class);
+    if (opts.enableMasterFeatures()) {
+      modules.add(sysInjector.getInstance(StaticModule.class));
+    }
 
     return sysInjector.createChildInjector(modules);
   }
diff --git a/java/com/google/gerrit/pgm/Gsql.java b/java/com/google/gerrit/pgm/Gsql.java
deleted file mode 100644
index 004486b..0000000
--- a/java/com/google/gerrit/pgm/Gsql.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm;
-
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.SINGLE_USER;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.pgm.util.RuntimeShutdown;
-import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.sshd.commands.QueryShell;
-import com.google.gerrit.sshd.commands.QueryShell.Factory;
-import com.google.inject.Injector;
-import java.io.IOException;
-import org.kohsuke.args4j.Option;
-
-/** Run Gerrit's SQL query tool */
-public class Gsql extends SiteProgram {
-  private final LifecycleManager manager = new LifecycleManager();
-  private Injector dbInjector;
-
-  @Option(name = "--format", usage = "Set output format")
-  private QueryShell.OutputFormat format = QueryShell.OutputFormat.PRETTY;
-
-  @Option(name = "-c", metaVar = "SQL QUERY", usage = "Query to execute")
-  private String query;
-
-  @Override
-  public int run() throws Exception {
-    mustHaveValidSite();
-
-    dbInjector = createDbInjector(SINGLE_USER);
-    manager.add(dbInjector);
-    manager.start();
-    RuntimeShutdown.add(
-        () -> {
-          try {
-            System.in.close();
-          } catch (IOException e) {
-            // Ignored
-          }
-          manager.stop();
-        });
-    final QueryShell shell = shellFactory().create(System.in, System.out);
-    shell.setOutputFormat(format);
-    if (query != null) {
-      shell.execute(query);
-    } else {
-      shell.run();
-    }
-    return 0;
-  }
-
-  private Factory shellFactory() {
-    return dbInjector
-        .createChildInjector(
-            new FactoryModule() {
-              @Override
-              protected void configure() {
-                factory(QueryShell.Factory.class);
-              }
-            })
-        .getInstance(QueryShell.Factory.class);
-  }
-}
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index b9c7068..0537fe9 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.pgm;
 
+import static java.util.stream.Collectors.joining;
+
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.IoUtil;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.PluginData;
+import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.pgm.init.BaseInit;
 import com.google.gerrit.pgm.init.Browser;
 import com.google.gerrit.pgm.init.InitPlugins;
@@ -26,8 +30,8 @@
 import com.google.gerrit.pgm.util.ErrorLogFile;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.ioutil.HostPlatform;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
-import com.google.gerrit.server.util.HostPlatform;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -55,6 +59,9 @@
   @Option(name = "--no-auto-start", usage = "Don't automatically start daemon after init")
   private boolean noAutoStart;
 
+  @Option(name = "--no-reindex", usage = "Don't automatically reindex any entities")
+  private boolean noReindex;
+
   @Option(name = "--skip-plugins", usage = "Don't install plugins")
   private boolean skipPlugins;
 
@@ -88,7 +95,7 @@
   }
 
   public Init(Path sitePath) {
-    super(sitePath, true, true, new WarDistribution(), null);
+    super(sitePath, true, new WarDistribution(), null);
     batchMode = true;
     noAutoStart = true;
   }
@@ -137,6 +144,9 @@
         });
     modules.add(new GerritServerConfigModule());
     Guice.createInjector(modules).injectMembers(this);
+    if (!run.flags.cfg.getBoolean("container", "slave", false)) {
+      reindexProjects();
+    }
     start(run);
   }
 
@@ -182,7 +192,7 @@
 
   @Override
   protected List<String> getSkippedDownloads() {
-    return skippedDownloads != null ? skippedDownloads : Collections.<String>emptyList();
+    return skippedDownloads != null ? skippedDownloads : Collections.emptyList();
   }
 
   @Override
@@ -194,7 +204,6 @@
     if (run.flags.autoStart) {
       if (HostPlatform.isWin32()) {
         System.err.println("Automatic startup not supported on Win32.");
-
       } else {
         startDaemon(run);
         if (!run.ui.isBatch()) {
@@ -238,7 +247,7 @@
   }
 
   private void verifyInstallPluginList(ConsoleUI ui, List<PluginData> plugins) {
-    if (nullOrEmpty(installPlugins) || nullOrEmpty(plugins)) {
+    if (nullOrEmpty(installPlugins)) {
       return;
     }
     Set<String> missing = Sets.newHashSet(installPlugins);
@@ -249,6 +258,25 @@
     }
   }
 
+  private void reindexProjects() throws Exception {
+    if (noReindex) {
+      return;
+    }
+    // Reindex all projects, so that we bootstrap the project index for new installations
+    List<String> reindexArgs =
+        ImmutableList.of(
+            "--site-path",
+            getSitePath().toString(),
+            "--threads",
+            Integer.toString(1),
+            "--index",
+            ProjectSchemaDefinitions.NAME);
+    getConsoleUI().message("Init complete, reindexing projects with:");
+    getConsoleUI().message(" reindex " + reindexArgs.stream().collect(joining(" ")));
+    Reindex reindexPgm = new Reindex();
+    reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
+  }
+
   private static boolean nullOrEmpty(List<?> list) {
     return list == null || list.isEmpty();
   }
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 5bfc00f..e6e091c 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.pgm;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
+import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
@@ -29,8 +29,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
-import com.google.gerrit.server.schema.SchemaVersionCheck;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -54,8 +53,8 @@
 
   @Override
   public int run() throws Exception {
-    Injector dbInjector = createDbInjector(MULTI_USER);
-    manager.add(dbInjector, dbInjector.createChildInjector(SchemaVersionCheck.module()));
+    Injector dbInjector = createDbInjector();
+    manager.add(dbInjector, dbInjector.createChildInjector(NoteDbSchemaVersionCheck.module()));
     manager.start();
     dbInjector
         .createChildInjector(
@@ -97,7 +96,7 @@
   }
 
   private void convertLocalUserToLowerCase(ExternalIdNotes extIdNotes, ExternalId extId)
-      throws OrmDuplicateKeyException, IOException {
+      throws DuplicateKeyException, IOException {
     if (extId.isScheme(SCHEME_GERRIT)) {
       String localUser = extId.key().id();
       String localUserLowerCase = localUser.toLowerCase(Locale.US);
diff --git a/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java b/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
index 4ace62b..e12bde2 100644
--- a/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
+++ b/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -49,7 +48,7 @@
 
   @Override
   public int run() throws Exception {
-    Injector dbInjector = createDbInjector(DataSourceProvider.Context.SINGLE_USER);
+    Injector dbInjector = createDbInjector();
     SitePaths sitePaths = new SitePaths(getSitePath());
     ThreadSettingsConfig threadSettingsConfig = dbInjector.getInstance(ThreadSettingsConfig.class);
     Config fakeCfg = new Config();
diff --git a/java/com/google/gerrit/pgm/MigrateToNoteDb.java b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
deleted file mode 100644
index 07da3f7..0000000
--- a/java/com/google/gerrit/pgm/MigrateToNoteDb.java
+++ /dev/null
@@ -1,220 +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.pgm;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.pgm.util.BatchProgramModule;
-import com.google.gerrit.pgm.util.RuntimeShutdown;
-import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.pgm.util.ThreadLimiter;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.git.GarbageCollection;
-import com.google.gerrit.server.index.DummyIndexModule;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.server.notedb.rebuild.GcAllUsers;
-import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
-import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.List;
-import org.kohsuke.args4j.Option;
-import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
-
-public class MigrateToNoteDb extends SiteProgram {
-  static final String TRIAL_USAGE =
-      "Trial mode: migrate changes and turn on reading from NoteDb, but leave ReviewDb as the"
-          + " source of truth";
-
-  private static final int ISSUE_8022_THREAD_LIMIT = 4;
-
-  @Option(name = "--threads", usage = "Number of threads to use for rebuilding NoteDb")
-  private Integer threads;
-
-  @Option(
-      name = "--project",
-      usage =
-          "Only rebuild these projects, do no other migration; incompatible with --change;"
-              + " recommended for debugging only")
-  private List<String> projects = new ArrayList<>();
-
-  @Option(
-      name = "--change",
-      usage =
-          "Only rebuild these changes, do no other migration; incompatible with --project;"
-              + " recommended for debugging only")
-  private List<Integer> changes = new ArrayList<>();
-
-  @Option(
-      name = "--force",
-      usage =
-          "Force rebuilding changes where ReviewDb is still the source of truth, even if they"
-              + " were previously migrated")
-  private boolean force;
-
-  @Option(name = "--trial", usage = TRIAL_USAGE)
-  private boolean trial;
-
-  @Option(
-      name = "--sequence-gap",
-      usage =
-          "gap in change sequence numbers between last ReviewDb number and first NoteDb number;"
-              + " negative indicates using the value of noteDb.changes.initialSequenceGap (default"
-              + " 1000)")
-  private int sequenceGap;
-
-  @Option(
-      name = "--reindex",
-      usage =
-          "Reindex all changes after migration; defaults to false in trial mode, true otherwise",
-      handler = ExplicitBooleanOptionHandler.class)
-  private Boolean reindex;
-
-  private Injector dbInjector;
-  private Injector sysInjector;
-  private LifecycleManager dbManager;
-  private LifecycleManager sysManager;
-
-  @Inject private GcAllUsers gcAllUsers;
-  @Inject private Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
-
-  @Override
-  public int run() throws Exception {
-    RuntimeShutdown.add(this::stop);
-    try {
-      mustHaveValidSite();
-      dbInjector = createDbInjector(MULTI_USER);
-
-      dbManager = new LifecycleManager();
-      dbManager.add(dbInjector);
-      dbManager.start();
-
-      threads = limitThreads();
-
-      sysInjector = createSysInjector();
-      sysInjector.injectMembers(this);
-      sysManager = new LifecycleManager();
-      sysManager.add(sysInjector);
-      sysInjector
-          .getInstance(PluginGuiceEnvironment.class)
-          .setDbCfgInjector(dbInjector, dbInjector);
-      sysManager.start();
-
-      try (NoteDbMigrator migrator =
-          migratorBuilderProvider
-              .get()
-              .setThreads(threads)
-              .setProgressOut(System.err)
-              .setProjects(projects.stream().map(Project.NameKey::new).collect(toList()))
-              .setChanges(changes.stream().map(Change.Id::new).collect(toList()))
-              .setTrialMode(trial)
-              .setForceRebuild(force)
-              .setSequenceGap(sequenceGap)
-              .build()) {
-        if (!projects.isEmpty() || !changes.isEmpty()) {
-          migrator.rebuild();
-        } else {
-          migrator.migrate();
-        }
-      }
-      try (PrintWriter w = new PrintWriter(System.out, true)) {
-        gcAllUsers.run(w);
-      }
-    } finally {
-      stop();
-    }
-
-    boolean reindex = firstNonNull(this.reindex, !trial);
-    if (!reindex) {
-      return 0;
-    }
-    // Reindex all indices, to save the user from having to run yet another program by hand while
-    // their server is offline.
-    List<String> reindexArgs =
-        ImmutableList.of(
-            "--site-path",
-            getSitePath().toString(),
-            "--threads",
-            Integer.toString(threads),
-            "--index",
-            ChangeSchemaDefinitions.NAME);
-    System.out.println("Migration complete, reindexing changes with:");
-    System.out.println("  reindex " + reindexArgs.stream().collect(joining(" ")));
-    Reindex reindexPgm = new Reindex();
-    return reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
-  }
-
-  private int limitThreads() {
-    if (threads != null) {
-      return threads;
-    }
-    int actualThreads;
-    int procs = Runtime.getRuntime().availableProcessors();
-    DataSourceType dsType = dbInjector.getInstance(DataSourceType.class);
-    if (dsType.getDriver().equals("org.h2.Driver") && procs > ISSUE_8022_THREAD_LIMIT) {
-      System.out.println(
-          "Not using more than "
-              + ISSUE_8022_THREAD_LIMIT
-              + " threads due to http://crbug.com/gerrit/8022");
-      System.out.println("Can be increased by passing --threads, but may cause errors");
-      actualThreads = ISSUE_8022_THREAD_LIMIT;
-    } else {
-      actualThreads = procs;
-    }
-    actualThreads = ThreadLimiter.limitThreads(dbInjector, actualThreads);
-    return actualThreads;
-  }
-
-  private Injector createSysInjector() {
-    return dbInjector.createChildInjector(
-        new FactoryModule() {
-          @Override
-          public void configure() {
-            install(dbInjector.getInstance(BatchProgramModule.class));
-            install(new DummyIndexModule());
-            factory(ChangeResource.Factory.class);
-            factory(GarbageCollection.Factory.class);
-          }
-        });
-  }
-
-  private void stop() {
-    try {
-      LifecycleManager m = sysManager;
-      sysManager = null;
-      if (m != null) {
-        m.stop();
-      }
-    } finally {
-      LifecycleManager m = dbManager;
-      dbManager = null;
-      if (m != null) {
-        m.stop();
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/pgm/Passwd.java b/java/com/google/gerrit/pgm/Passwd.java
index f63d2f4..10ed07d 100644
--- a/java/com/google/gerrit/pgm/Passwd.java
+++ b/java/com/google/gerrit/pgm/Passwd.java
@@ -78,7 +78,7 @@
             bind(Boolean.class).annotatedWith(InstallAllPlugins.class).toInstance(Boolean.FALSE);
             bind(new TypeLiteral<List<String>>() {})
                 .annotatedWith(InstallPlugins.class)
-                .toInstance(new ArrayList<String>());
+                .toInstance(new ArrayList<>());
             bind(String.class)
                 .annotatedWith(SecureStoreClassName.class)
                 .toProvider(Providers.of(getConfiguredSecureStoreClass()));
diff --git a/java/com/google/gerrit/pgm/PrologShell.java b/java/com/google/gerrit/pgm/PrologShell.java
index 5decd68..2780f84 100644
--- a/java/com/google/gerrit/pgm/PrologShell.java
+++ b/java/com/google/gerrit/pgm/PrologShell.java
@@ -30,9 +30,14 @@
   @Option(name = "-s", metaVar = "FILE.pl", usage = "file to load")
   private List<String> fileName = new ArrayList<>();
 
+  @Option(name = "-q", usage = "quiet mode without banner")
+  private boolean quiet;
+
   @Override
   public int run() {
-    banner();
+    if (!quiet) {
+      banner();
+    }
 
     BufferingPrologControl pcl = new BufferingPrologControl();
     pcl.setPrologClassLoader(new PrologClassLoader(getClass().getClassLoader()));
@@ -55,7 +60,9 @@
 
     try {
       pcl.execute(Prolog.BUILTIN, "cafeteria");
-      write("% halt\n");
+      if (!quiet) {
+        write("% halt\n");
+      }
       return 0;
     } catch (HaltException halt) {
       write("% halt(" + halt.getStatus() + ")\n");
diff --git a/java/com/google/gerrit/pgm/ProtoGen.java b/java/com/google/gerrit/pgm/ProtoGen.java
deleted file mode 100644
index a882412..0000000
--- a/java/com/google/gerrit/pgm/ProtoGen.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.pgm.util.AbstractProgram;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.schema.java.JavaSchemaModel;
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.nio.ByteBuffer;
-import org.eclipse.jgit.internal.storage.file.LockFile;
-import org.eclipse.jgit.util.IO;
-import org.kohsuke.args4j.Option;
-
-public class ProtoGen extends AbstractProgram {
-  @Option(
-      name = "--output",
-      aliases = {"-o"},
-      required = true,
-      metaVar = "FILE",
-      usage = "File to write .proto into")
-  private File file;
-
-  @Override
-  public int run() throws Exception {
-    LockFile lock = new LockFile(file.getAbsoluteFile());
-    if (!lock.lock()) {
-      throw die("Cannot lock " + file);
-    }
-    try {
-      JavaSchemaModel jsm = new JavaSchemaModel(ReviewDb.class);
-      try (OutputStream o = lock.getOutputStream();
-          PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(o, UTF_8)))) {
-        String header;
-        try (InputStream in = getClass().getResourceAsStream("ProtoGenHeader.txt")) {
-          ByteBuffer buf = IO.readWholeStream(in, 1024);
-          int ptr = buf.arrayOffset() + buf.position();
-          int len = buf.remaining();
-          header = new String(buf.array(), ptr, len, UTF_8);
-        }
-
-        out.write(header);
-        jsm.generateProto(out);
-        out.flush();
-      }
-      if (!lock.commit()) {
-        throw die("Could not write to " + file);
-      }
-    } finally {
-      lock.unlock();
-    }
-    return 0;
-  }
-}
diff --git a/java/com/google/gerrit/pgm/ProtobufImport.java b/java/com/google/gerrit/pgm/ProtobufImport.java
deleted file mode 100644
index 0732b28..0000000
--- a/java/com/google/gerrit/pgm/ProtobufImport.java
+++ /dev/null
@@ -1,148 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.SINGLE_USER;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.pgm.util.RuntimeShutdown;
-import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.protobuf.CodecFactory;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.gwtorm.schema.RelationModel;
-import com.google.gwtorm.schema.java.JavaSchemaModel;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.protobuf.ByteString;
-import com.google.protobuf.Parser;
-import com.google.protobuf.UnknownFieldSet;
-import java.io.BufferedInputStream;
-import java.io.File;
-import java.io.InputStream;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.nio.file.Files;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.kohsuke.args4j.Option;
-
-/**
- * Import data from a protocol buffer dump into the database.
- *
- * <p>Takes as input a file containing protocol buffers concatenated together with varint length
- * encoding, as in {@link Parser#parseDelimitedFrom(InputStream)}. Each message contains a single
- * field with a tag corresponding to the relation ID in the {@link
- * com.google.gwtorm.server.Relation} annotation.
- *
- * <p><strong>Warning</strong>: This method blindly upserts data into the database. It should only
- * be used to restore a protobuf-formatted backup into a new, empty site.
- */
-public class ProtobufImport extends SiteProgram {
-  @Option(
-      name = "--file",
-      aliases = {"-f"},
-      required = true,
-      metaVar = "FILE",
-      usage = "File to import from")
-  private File file;
-
-  private final LifecycleManager manager = new LifecycleManager();
-  private final Map<Integer, Relation> relations = new HashMap<>();
-
-  @Inject private SchemaFactory<ReviewDb> schemaFactory;
-
-  @Override
-  public int run() throws Exception {
-    mustHaveValidSite();
-
-    Injector dbInjector = createDbInjector(SINGLE_USER);
-    manager.add(dbInjector);
-    manager.start();
-    RuntimeShutdown.add(manager::stop);
-    dbInjector.injectMembers(this);
-
-    ProgressMonitor progress = new TextProgressMonitor();
-    progress.beginTask("Importing entities", ProgressMonitor.UNKNOWN);
-    try (ReviewDb db = schemaFactory.open()) {
-      for (RelationModel model : new JavaSchemaModel(ReviewDb.class).getRelations()) {
-        relations.put(model.getRelationID(), Relation.create(model, db));
-      }
-
-      Parser<UnknownFieldSet> parser = UnknownFieldSet.getDefaultInstance().getParserForType();
-      try (InputStream in = new BufferedInputStream(Files.newInputStream(file.toPath()))) {
-        UnknownFieldSet msg;
-        while ((msg = parser.parseDelimitedFrom(in)) != null) {
-          Map.Entry<Integer, UnknownFieldSet.Field> e =
-              Iterables.getOnlyElement(msg.asMap().entrySet());
-          Relation rel =
-              checkNotNull(
-                  relations.get(e.getKey()),
-                  "unknown relation ID %s in message: %s",
-                  e.getKey(),
-                  msg);
-          List<ByteString> values = e.getValue().getLengthDelimitedList();
-          checkState(values.size() == 1, "expected one string field in message: %s", msg);
-          upsert(rel, values.get(0));
-          progress.update(1);
-        }
-      }
-      progress.endTask();
-    }
-
-    return 0;
-  }
-
-  @SuppressWarnings({"rawtypes", "unchecked"})
-  private static void upsert(Relation rel, ByteString s) throws OrmException {
-    Collection ents = Collections.singleton(rel.codec().decode(s));
-    try {
-      // Not all relations support update; fall back manually.
-      rel.access().insert(ents);
-    } catch (OrmDuplicateKeyException e) {
-      rel.access().delete(ents);
-      rel.access().insert(ents);
-    }
-  }
-
-  @AutoValue
-  abstract static class Relation {
-    private static Relation create(RelationModel model, ReviewDb db)
-        throws IllegalAccessException, InvocationTargetException, NoSuchMethodException,
-            ClassNotFoundException {
-      Method m = db.getClass().getMethod(model.getMethodName());
-      Class<?> clazz = Class.forName(model.getEntityTypeClassName());
-      return new AutoValue_ProtobufImport_Relation(
-          (Access<?, ?>) m.invoke(db), CodecFactory.encoder(clazz));
-    }
-
-    abstract Access<?, ?> access();
-
-    abstract ProtobufCodec<?> codec();
-  }
-}
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index d26e4f71..6d9fe59 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -14,8 +14,7 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.Sets;
@@ -29,7 +28,6 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.util.BatchProgramModule;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.pgm.util.ThreadLimiter;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexModule;
@@ -40,7 +38,6 @@
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -81,10 +78,9 @@
   @Override
   public int run() throws Exception {
     mustHaveValidSite();
-    dbInjector = createDbInjector(MULTI_USER);
+    dbInjector = createDbInjector();
     cfgInjector = dbInjector.createChildInjector();
     globalConfig = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    threads = ThreadLimiter.limitThreads(dbInjector, threads);
     overrideConfig();
     LifecycleManager dbManager = new LifecycleManager();
     dbManager.add(dbInjector);
@@ -117,7 +113,7 @@
     return true;
   }
 
-  private boolean reindex() throws IOException {
+  private boolean reindex() {
     boolean ok = true;
     for (IndexDefinition<?, ?, ?> def : indexDefs) {
       if (indices.isEmpty() || indices.contains(def.getName())) {
@@ -132,7 +128,7 @@
       return;
     }
 
-    checkNotNull(indexDefs, "Called this method before injectMembers?");
+    requireNonNull(indexDefs, "Called this method before injectMembers?");
     Set<String> valid = indexDefs.stream().map(IndexDefinition::getName).sorted().collect(toSet());
     Set<String> invalid = Sets.difference(Sets.newHashSet(indices), valid);
     if (invalid.isEmpty()) {
@@ -151,19 +147,16 @@
     boolean slave = globalConfig.getBoolean("container", "slave", false);
     List<Module> modules = new ArrayList<>();
     Module indexModule;
-    switch (IndexModule.getIndexType(dbInjector)) {
-      case LUCENE:
-        indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(versions, threads, slave);
-        break;
-      case ELASTICSEARCH:
-        indexModule =
-            ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, slave);
-        break;
-      default:
-        throw new IllegalStateException("unsupported index.type");
+    IndexType indexType = IndexModule.getIndexType(dbInjector);
+    if (indexType.isLucene()) {
+      indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(versions, threads, slave);
+    } else if (indexType.isElasticsearch()) {
+      indexModule = ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, slave);
+    } else {
+      throw new IllegalStateException("unsupported index.type = " + indexType);
     }
     modules.add(indexModule);
-    modules.add(dbInjector.getInstance(BatchProgramModule.class));
+    modules.add(new BatchProgramModule());
     modules.add(
         new FactoryModule() {
           @Override
@@ -177,7 +170,7 @@
 
   private void overrideConfig() {
     // Disable auto-commit for speed; committing will happen at the end of the process.
-    if (IndexModule.getIndexType(dbInjector) == IndexType.LUCENE) {
+    if (IndexModule.getIndexType(dbInjector).isLucene()) {
       globalConfig.setLong("index", "changes_open", "commitWithin", -1);
       globalConfig.setLong("index", "changes_closed", "commitWithin", -1);
     }
@@ -189,10 +182,10 @@
     globalConfig.setBoolean("index", null, "autoReindexIfStale", false);
   }
 
-  private <K, V, I extends Index<K, V>> boolean reindex(IndexDefinition<K, V, I> def)
-      throws IOException {
+  private <K, V, I extends Index<K, V>> boolean reindex(IndexDefinition<K, V, I> def) {
     I index = def.getIndexCollection().getSearchIndex();
-    checkNotNull(index, "no active search index configured for %s", def.getName());
+    requireNonNull(
+        index, () -> String.format("no active search index configured for %s", def.getName()));
     index.markReady(false);
     index.deleteAll();
 
diff --git a/java/com/google/gerrit/pgm/Rulec.java b/java/com/google/gerrit/pgm/Rulec.java
index add06ef..1592d0e 100644
--- a/java/com/google/gerrit/pgm/Rulec.java
+++ b/java/com/google/gerrit/pgm/Rulec.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.SINGLE_USER;
-
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.rules.PrologCompiler;
@@ -60,7 +58,7 @@
 
   @Override
   public int run() throws Exception {
-    dbInjector = createDbInjector(SINGLE_USER);
+    dbInjector = createDbInjector();
     manager.add(dbInjector);
     manager.start();
     dbInjector
@@ -75,7 +73,7 @@
 
     LinkedHashSet<Project.NameKey> names = new LinkedHashSet<>();
     for (String name : projectNames) {
-      names.add(new Project.NameKey(name));
+      names.add(Project.nameKey(name));
     }
     if (all) {
       names.addAll(gitManager.list());
diff --git a/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 3f10130..733c9d1 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.SINGLE_USER;
-
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
@@ -86,7 +84,7 @@
     logger.atInfo().log(
         "Current secureStoreClass property (%s) will be replaced with %s",
         currentSecureStoreName, newSecureStore);
-    Injector dbInjector = createDbInjector(SINGLE_USER);
+    Injector dbInjector = createDbInjector();
     SecureStore currentStore = getSecureStore(currentSecureStoreName, dbInjector);
     SecureStore newStore = getSecureStore(newSecureStore, dbInjector);
 
diff --git a/java/com/google/gerrit/pgm/http/jetty/BUILD b/java/com/google/gerrit/pgm/http/jetty/BUILD
index b1da011..a6a13dc 100644
--- a/java/com/google/gerrit/pgm/http/jetty/BUILD
+++ b/java/com/google/gerrit/pgm/http/jetty/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/sshd",
         "//java/com/google/gerrit/util/http",
         "//lib:guava",
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
index d7bc720..dab9d7e 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
@@ -14,18 +14,12 @@
 
 package com.google.gerrit.pgm.http.jetty;
 
-import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.GetUserFilter;
+import com.google.gerrit.httpd.restapi.LogRedactUtil;
 import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import java.util.Iterator;
 import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Level;
 import org.apache.log4j.Logger;
@@ -39,7 +33,6 @@
 class HttpLog extends AbstractLifeCycle implements RequestLog {
   private static final Logger log = Logger.getLogger(HttpLog.class);
   private static final String LOG_NAME = "httpd_log";
-  private static final ImmutableSet<String> REDACT_PARAM = ImmutableSet.of(XD_AUTHORIZATION);
 
   interface HttpLogFactory {
     HttpLog get();
@@ -52,6 +45,7 @@
   protected static final String P_PROTOCOL = "Version";
   protected static final String P_STATUS = "Status";
   protected static final String P_CONTENT_LENGTH = "Content-Length";
+  protected static final String P_LATENCY = "Latency";
   protected static final String P_REFERER = "Referer";
   protected static final String P_USER_AGENT = "User-Agent";
 
@@ -87,8 +81,9 @@
             );
 
     String uri = req.getRequestURI();
-    uri = redactQueryString(uri, req.getQueryString());
-
+    if (!Strings.isNullOrEmpty(req.getQueryString())) {
+      uri += "?" + LogRedactUtil.redactQueryString(req.getQueryString());
+    }
     String user = (String) req.getAttribute(GetUserFilter.USER_ATTR_KEY);
     if (user != null) {
       event.setProperty(P_USER, user);
@@ -100,37 +95,13 @@
     set(event, P_PROTOCOL, req.getProtocol());
     set(event, P_STATUS, rsp.getStatus());
     set(event, P_CONTENT_LENGTH, rsp.getContentCount());
+    set(event, P_LATENCY, System.currentTimeMillis() - req.getTimeStamp());
     set(event, P_REFERER, req.getHeader("Referer"));
     set(event, P_USER_AGENT, req.getHeader("User-Agent"));
 
     async.append(event);
   }
 
-  @VisibleForTesting
-  static String redactQueryString(String uri, String qs) {
-    if (Strings.isNullOrEmpty(qs)) {
-      return uri;
-    }
-
-    StringBuilder b = new StringBuilder(uri);
-    boolean first = true;
-    for (String kvPair : Splitter.on('&').split(qs)) {
-      Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
-      String key = i.next();
-      b.append(first ? '?' : '&').append(key);
-      first = false;
-      if (i.hasNext()) {
-        b.append('=');
-        if (REDACT_PARAM.contains(Url.decode(key))) {
-          b.append('*');
-        } else {
-          b.append(i.next());
-        }
-      }
-    }
-    return b.toString();
-  }
-
   private static void set(LoggingEvent event, String key, String val) {
     if (val != null && !val.isEmpty()) {
       event.setProperty(key, val);
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
index 2eea88d..bd7d998 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
@@ -67,6 +67,9 @@
     opt(buf, event, HttpLog.P_CONTENT_LENGTH);
 
     buf.append(' ');
+    opt(buf, event, HttpLog.P_LATENCY);
+
+    buf.append(' ');
     dq_opt(buf, event, HttpLog.P_REFERER);
 
     buf.append(' ');
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 25a28a4..22bc21d 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -49,7 +49,6 @@
 import org.eclipse.jetty.server.Handler;
 import org.eclipse.jetty.server.HttpConfiguration;
 import org.eclipse.jetty.server.HttpConnectionFactory;
-import org.eclipse.jetty.server.Request;
 import org.eclipse.jetty.server.SecureRequestCustomizer;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.server.ServerConnector;
@@ -197,7 +196,7 @@
         c = newServerConnector(server, acceptors, config);
 
       } else if ("https".equals(u.getScheme())) {
-        SslContextFactory ssl = new SslContextFactory();
+        SslContextFactory.Server ssl = new SslContextFactory.Server();
         final Path keystore = getFile(cfg, "sslkeystore", "etc/keystore");
         String password = cfg.getString("httpd", null, "sslkeypassword");
         if (password == null) {
@@ -241,13 +240,9 @@
         defaultPort = 8080;
         config.addCustomizer(new ForwardedRequestCustomizer());
         config.addCustomizer(
-            new HttpConfiguration.Customizer() {
-              @Override
-              public void customize(
-                  Connector connector, HttpConfiguration channelConfig, Request request) {
-                request.setScheme(HttpScheme.HTTPS.asString());
-                request.setSecure(true);
-              }
+            (connector, channelConfig, request) -> {
+              request.setScheme(HttpScheme.HTTPS.asString());
+              request.setSecure(true);
             });
         c = newServerConnector(server, acceptors, config);
 
@@ -350,7 +345,7 @@
             maxThreads,
             minThreads,
             idleTimeout,
-            new BlockingArrayQueue<Runnable>(
+            new BlockingArrayQueue<>(
                 minThreads, // capacity,
                 minThreads, // growBy,
                 maxCapacity // maxCapacity
diff --git a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
index 96cf7be..9354209 100644
--- a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
+++ b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
@@ -61,6 +61,7 @@
  * Jetty's HTTP parser to crash, so we instead block the SSH execution queue thread and ask Jetty to
  * resume processing on the web service thread.
  */
+@SuppressWarnings("deprecation")
 @Singleton
 public class ProjectQoSFilter implements Filter {
   private static final String ATT_SPACE = ProjectQoSFilter.class.getName();
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index ff94905..595e4fd 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.init;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
@@ -60,63 +61,59 @@
     this.allUsers = allUsers.get();
   }
 
-  public void insert(Account account) throws IOException {
+  public Account insert(Account.Builder account) throws IOException {
     File path = getPath();
-    if (path != null) {
-      try (Repository repo = new FileRepository(path);
-          ObjectInserter oi = repo.newObjectInserter()) {
-        PersonIdent ident =
-            new PersonIdent(
-                new GerritPersonIdentProvider(flags.cfg).get(), account.getRegisteredOn());
+    try (Repository repo = new FileRepository(path);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      PersonIdent ident =
+          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
 
-        Config accountConfig = new Config();
-        AccountProperties.writeToAccountConfig(
-            InternalAccountUpdate.builder()
-                .setActive(account.isActive())
-                .setFullName(account.getFullName())
-                .setPreferredEmail(account.getPreferredEmail())
-                .setStatus(account.getStatus())
-                .build(),
-            accountConfig);
+      Config accountConfig = new Config();
+      AccountProperties.writeToAccountConfig(
+          InternalAccountUpdate.builder()
+              .setActive(!account.inactive())
+              .setFullName(account.fullName())
+              .setPreferredEmail(account.preferredEmail())
+              .setStatus(account.status())
+              .build(),
+          accountConfig);
 
-        DirCache newTree = DirCache.newInCore();
-        DirCacheEditor editor = newTree.editor();
-        final ObjectId blobId =
-            oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
-        editor.add(
-            new PathEdit(AccountProperties.ACCOUNT_CONFIG) {
-              @Override
-              public void apply(DirCacheEntry ent) {
-                ent.setFileMode(FileMode.REGULAR_FILE);
-                ent.setObjectId(blobId);
-              }
-            });
-        editor.finish();
+      DirCache newTree = DirCache.newInCore();
+      DirCacheEditor editor = newTree.editor();
+      final ObjectId blobId = oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
+      editor.add(
+          new PathEdit(AccountProperties.ACCOUNT_CONFIG) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.REGULAR_FILE);
+              ent.setObjectId(blobId);
+            }
+          });
+      editor.finish();
 
-        ObjectId treeId = newTree.writeTree(oi);
+      ObjectId treeId = newTree.writeTree(oi);
 
-        CommitBuilder cb = new CommitBuilder();
-        cb.setTreeId(treeId);
-        cb.setCommitter(ident);
-        cb.setAuthor(ident);
-        cb.setMessage("Create Account");
-        ObjectId id = oi.insert(cb);
-        oi.flush();
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(treeId);
+      cb.setCommitter(ident);
+      cb.setAuthor(ident);
+      cb.setMessage("Create Account");
+      ObjectId id = oi.insert(cb);
+      oi.flush();
 
-        String refName = RefNames.refsUsers(account.getId());
-        RefUpdate ru = repo.updateRef(refName);
-        ru.setExpectedOldObjectId(ObjectId.zeroId());
-        ru.setNewObjectId(id);
-        ru.setRefLogIdent(ident);
-        ru.setRefLogMessage("Create Account", false);
-        Result result = ru.update();
-        if (result != Result.NEW) {
-          throw new IOException(
-              String.format("Failed to update ref %s: %s", refName, result.name()));
-        }
-        account.setMetaId(id.name());
+      String refName = RefNames.refsUsers(account.id());
+      RefUpdate ru = repo.updateRef(refName);
+      ru.setExpectedOldObjectId(ObjectId.zeroId());
+      ru.setNewObjectId(id);
+      ru.setRefLogIdent(ident);
+      ru.setRefLogMessage("Create Account", false);
+      Result result = ru.update();
+      if (result != Result.NEW) {
+        throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
       }
+      account.setMetaId(id.name()).build();
     }
+    return account.build();
   }
 
   public boolean hasAnyAccount() throws IOException {
@@ -133,6 +130,8 @@
   private File getPath() {
     Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
     checkArgument(basePath != null, "gerrit.basePath must be configured");
-    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+    File file = FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+    checkState(file != null, "%s does not exist", file.getAbsolutePath());
+    return file;
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index c781a60..b2a4d72 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/launcher",
@@ -17,10 +18,10 @@
         "//java/com/google/gerrit/pgm/util",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
         "//lib:h2",
         "//lib/commons:validator",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index deaf139..a7a8c58 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.pgm.init;
 
-import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.SINGLE_USER;
 import static com.google.inject.Scopes.SINGLETON;
 import static com.google.inject.Stage.PRODUCTION;
 
@@ -24,6 +22,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Die;
 import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
@@ -35,31 +34,24 @@
 import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
 import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.plugins.JarScanner;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gerrit.server.schema.SchemaUpdater;
+import com.google.gerrit.server.schema.NoteDbSchemaUpdater;
 import com.google.gerrit.server.schema.UpdateUI;
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
 import com.google.gerrit.server.securestore.SecureStoreProvider;
-import com.google.gwtorm.jdbc.JdbcExecutor;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.AbstractModule;
 import com.google.inject.CreationException;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Module;
-import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.spi.Message;
 import com.google.inject.util.Providers;
@@ -75,14 +67,12 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
-import javax.sql.DataSource;
 
 /** Initialize a new Gerrit installation. */
 public class BaseInit extends SiteProgram {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final boolean standalone;
-  private final boolean initDb;
   protected final PluginsDistribution pluginsDistribution;
   private final List<String> pluginsToInstall;
 
@@ -90,7 +80,6 @@
 
   protected BaseInit(PluginsDistribution pluginsDistribution, List<String> pluginsToInstall) {
     this.standalone = true;
-    this.initDb = true;
     this.pluginsDistribution = pluginsDistribution;
     this.pluginsToInstall = pluginsToInstall;
   }
@@ -98,22 +87,10 @@
   public BaseInit(
       Path sitePath,
       boolean standalone,
-      boolean initDb,
       PluginsDistribution pluginsDistribution,
       List<String> pluginsToInstall) {
-    this(sitePath, null, standalone, initDb, pluginsDistribution, pluginsToInstall);
-  }
-
-  public BaseInit(
-      Path sitePath,
-      final Provider<DataSource> dsProvider,
-      boolean standalone,
-      boolean initDb,
-      PluginsDistribution pluginsDistribution,
-      List<String> pluginsToInstall) {
-    super(sitePath, dsProvider);
+    super(sitePath);
     this.standalone = standalone;
-    this.initDb = initDb;
     this.pluginsDistribution = pluginsDistribution;
     this.pluginsToInstall = pluginsToInstall;
   }
@@ -141,7 +118,13 @@
       try {
         indexManager.start();
         run = createSiteRun(init);
-        run.upgradeSchema();
+        try {
+          run.upgradeSchema();
+        } catch (StorageException e) {
+          String msg = "Couldn't upgrade schema. Expected if slave and read-only database";
+          System.err.println(msg);
+          logger.atWarning().withCause(e).log(msg);
+        }
 
         init.initializer.postRun(sysInjector);
       } finally {
@@ -256,15 +239,14 @@
     }
 
     m.add(new GerritServerConfigModule());
-    m.add(new InitModule(standalone, initDb));
+    m.add(new InitModule(standalone));
     m.add(
         new AbstractModule() {
           @Override
           protected void configure() {
             bind(ConsoleUI.class).toInstance(ui);
             bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-            List<String> plugins =
-                MoreObjects.firstNonNull(getInstallPlugins(), new ArrayList<String>());
+            List<String> plugins = MoreObjects.firstNonNull(getInstallPlugins(), new ArrayList<>());
             bind(new TypeLiteral<List<String>>() {})
                 .annotatedWith(InstallPlugins.class)
                 .toInstance(plugins);
@@ -358,8 +340,7 @@
     public final ConsoleUI ui;
     public final SitePaths site;
     public final InitFlags flags;
-    final SchemaUpdater schemaUpdater;
-    final SchemaFactory<ReviewDb> schema;
+    final NoteDbSchemaUpdater noteDbSchemaUpdater;
     final GitRepositoryManager repositoryManager;
 
     @Inject
@@ -367,80 +348,50 @@
         ConsoleUI ui,
         SitePaths site,
         InitFlags flags,
-        SchemaUpdater schemaUpdater,
-        @ReviewDbFactory SchemaFactory<ReviewDb> schema,
+        NoteDbSchemaUpdater noteDbSchemaUpdater,
         GitRepositoryManager repositoryManager) {
       this.ui = ui;
       this.site = site;
       this.flags = flags;
-      this.schemaUpdater = schemaUpdater;
-      this.schema = schema;
+      this.noteDbSchemaUpdater = noteDbSchemaUpdater;
       this.repositoryManager = repositoryManager;
     }
 
-    void upgradeSchema() throws OrmException {
-      final List<String> pruneList = new ArrayList<>();
-      schemaUpdater.update(
-          new UpdateUI() {
-            @Override
-            public void message(String message) {
-              System.err.println(message);
-              System.err.flush();
-            }
+    void upgradeSchema() {
+      noteDbSchemaUpdater.update(new UpdateUIImpl(ui));
+    }
 
-            @Override
-            public boolean yesno(boolean defaultValue, String message) {
-              return ui.yesno(defaultValue, message);
-            }
+    private static class UpdateUIImpl implements UpdateUI {
+      private final ConsoleUI consoleUi;
 
-            @Override
-            public void waitForUser() {
-              ui.waitForUser();
-            }
+      UpdateUIImpl(ConsoleUI consoleUi) {
+        this.consoleUi = consoleUi;
+      }
 
-            @Override
-            public String readString(
-                String defaultValue, Set<String> allowedValues, String message) {
-              return ui.readString(defaultValue, allowedValues, message);
-            }
+      @Override
+      public void message(String message) {
+        System.err.println(message);
+        System.err.flush();
+      }
 
-            @Override
-            public boolean isBatch() {
-              return ui.isBatch();
-            }
+      @Override
+      public boolean yesno(boolean defaultValue, String message) {
+        return consoleUi.yesno(defaultValue, message);
+      }
 
-            @Override
-            public void pruneSchema(StatementExecutor e, List<String> prune) {
-              for (String p : prune) {
-                if (!pruneList.contains(p)) {
-                  pruneList.add(p);
-                }
-              }
-            }
-          });
+      @Override
+      public void waitForUser() {
+        consoleUi.waitForUser();
+      }
 
-      if (!pruneList.isEmpty()) {
-        StringBuilder msg = new StringBuilder();
-        msg.append("Execute the following SQL to drop unused objects:\n");
-        msg.append("\n");
-        for (String sql : pruneList) {
-          msg.append("  ");
-          msg.append(sql);
-          msg.append(";\n");
-        }
+      @Override
+      public String readString(String defaultValue, Set<String> allowedValues, String message) {
+        return consoleUi.readString(defaultValue, allowedValues, message);
+      }
 
-        if (ui.isBatch()) {
-          System.err.print(msg);
-          System.err.flush();
-
-        } else if (ui.yesno(true, "%s\nExecute now", msg)) {
-          try (JdbcSchema db = (JdbcSchema) unwrapDb(schema.open());
-              JdbcExecutor e = new JdbcExecutor(db)) {
-            for (String sql : pruneList) {
-              e.execute(sql);
-            }
-          }
-        }
+      @Override
+      public boolean isBatch() {
+        return consoleUi.isBatch();
       }
     }
   }
@@ -460,16 +411,15 @@
               bind(InitFlags.class).toInstance(init.flags);
             }
           });
-      Injector dbInjector = createDbInjector(SINGLE_USER);
-      switch (IndexModule.getIndexType(dbInjector)) {
-        case LUCENE:
-          modules.add(new LuceneIndexModuleOnInit());
-          break;
-        case ELASTICSEARCH:
-          modules.add(new ElasticIndexModuleOnInit());
-          break;
-        default:
-          throw new IllegalStateException("unsupported index.type");
+      Injector dbInjector = createDbInjector();
+
+      IndexType indexType = IndexModule.getIndexType(dbInjector);
+      if (indexType.isLucene()) {
+        modules.add(new LuceneIndexModuleOnInit());
+      } else if (indexType.isElasticsearch()) {
+        modules.add(new ElasticIndexModuleOnInit());
+      } else {
+        throw new IllegalStateException("unsupported index.type = " + indexType);
       }
       sysInjector = dbInjector.createChildInjector(modules);
     }
diff --git a/java/com/google/gerrit/pgm/init/DB2Initializer.java b/java/com/google/gerrit/pgm/init/DB2Initializer.java
deleted file mode 100644
index 9dc1088..0000000
--- a/java/com/google/gerrit/pgm/init/DB2Initializer.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.username;
-
-import com.google.gerrit.pgm.init.api.Section;
-
-public class DB2Initializer implements DatabaseConfigInitializer {
-
-  @Override
-  public void initConfig(Section databaseSection) {
-    final String defPort = "50001";
-    databaseSection.string("Server hostname", "hostname", "localhost");
-    databaseSection.string("Server port", "port", defPort, false);
-    databaseSection.string("Database name", "database", "gerrit");
-    databaseSection.string("Database username", "username", username());
-    databaseSection.password("username", "password");
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java b/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
deleted file mode 100644
index 2701957..0000000
--- a/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import com.google.gerrit.pgm.init.api.Section;
-
-/** Abstraction of initializer for the database section */
-interface DatabaseConfigInitializer {
-
-  /**
-   * Performs database platform specific configuration steps and writes configuration parameters
-   * into the given database section
-   */
-  void initConfig(Section databaseSection);
-}
diff --git a/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java b/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
deleted file mode 100644
index 44f883a..0000000
--- a/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.AbstractModule;
-import com.google.inject.name.Names;
-
-public class DatabaseConfigModule extends AbstractModule {
-
-  private final SitePaths site;
-
-  public DatabaseConfigModule(SitePaths site) {
-    this.site = site;
-  }
-
-  @Override
-  protected void configure() {
-    bind(SitePaths.class).toInstance(site);
-    bind(DatabaseConfigInitializer.class)
-        .annotatedWith(Names.named("db2"))
-        .to(DB2Initializer.class);
-    bind(DatabaseConfigInitializer.class)
-        .annotatedWith(Names.named("derby"))
-        .to(DerbyInitializer.class);
-    bind(DatabaseConfigInitializer.class).annotatedWith(Names.named("h2")).to(H2Initializer.class);
-    bind(DatabaseConfigInitializer.class)
-        .annotatedWith(Names.named("jdbc"))
-        .to(JDBCInitializer.class);
-    bind(DatabaseConfigInitializer.class)
-        .annotatedWith(Names.named("mariadb"))
-        .to(MariaDbInitializer.class);
-    bind(DatabaseConfigInitializer.class)
-        .annotatedWith(Names.named("mysql"))
-        .to(MySqlInitializer.class);
-    bind(DatabaseConfigInitializer.class)
-        .annotatedWith(Names.named("oracle"))
-        .to(OracleInitializer.class);
-    bind(DatabaseConfigInitializer.class)
-        .annotatedWith(Names.named("postgresql"))
-        .to(PostgreSQLInitializer.class);
-    bind(DatabaseConfigInitializer.class)
-        .annotatedWith(Names.named("maxdb"))
-        .to(MaxDbInitializer.class);
-    bind(DatabaseConfigInitializer.class)
-        .annotatedWith(Names.named("hana"))
-        .to(HANAInitializer.class);
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/DerbyInitializer.java b/java/com/google/gerrit/pgm/init/DerbyInitializer.java
deleted file mode 100644
index 3aad0f4..0000000
--- a/java/com/google/gerrit/pgm/init/DerbyInitializer.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.die;
-
-import com.google.gerrit.common.FileUtil;
-import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import java.nio.file.Path;
-
-class DerbyInitializer implements DatabaseConfigInitializer {
-
-  private final SitePaths site;
-
-  @Inject
-  DerbyInitializer(SitePaths site) {
-    this.site = site;
-  }
-
-  @Override
-  public void initConfig(Section databaseSection) {
-    String path = databaseSection.get("database");
-    Path db;
-    if (path == null) {
-      db = site.resolve("db").resolve("ReviewDB");
-      databaseSection.set("database", db.toString());
-    } else {
-      db = site.resolve(path);
-    }
-    if (db == null) {
-      throw die("database.database must be supplied for Derby");
-    }
-    db = db.getParent();
-    FileUtil.mkdirsOrDie(db, "cannot create database.database");
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 707802e..9519653 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -16,14 +16,13 @@
 
 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;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.File;
 import java.io.IOException;
@@ -39,25 +38,24 @@
 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)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException {
     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 +71,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..eaeeb67 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -20,14 +20,13 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 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;
@@ -37,7 +36,6 @@
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.File;
 import java.io.IOException;
@@ -54,9 +52,8 @@
 /**
  * A database accessor for calls related to groups.
  *
- * <p>All calls which read or write group related details to the database <strong>during
- * init</strong> (either ReviewDb or NoteDb) are gathered here. For non-init cases, use {@code
- * Groups} or {@code GroupsUpdate} instead.
+ * <p>All calls which read or write group related details to the NoteDb <strong>during init</strong>
+ * are gathered here. For non-init cases, use {@code Groups} or {@code GroupsUpdate} instead.
  *
  * <p>All methods of this class refer to <em>internal</em> groups.
  */
@@ -64,33 +61,31 @@
 
   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());
   }
 
   /**
    * Returns the {@code AccountGroup} for the specified {@code GroupReference}.
    *
-   * @param db the {@code ReviewDb} instance to use for lookups
    * @param groupReference the {@code GroupReference} of the group
    * @return the {@code InternalGroup} represented by the {@code GroupReference}
-   * @throws OrmException if the group couldn't be retrieved from ReviewDb
    * @throws IOException if an error occurs while reading from NoteDb
    * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
    * @throws NoSuchGroupException if a group with such a name doesn't exist
    */
-  public InternalGroup getExistingGroup(ReviewDb db, GroupReference groupReference)
-      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+  public InternalGroup getExistingGroup(GroupReference groupReference)
+      throws NoSuchGroupException, IOException, ConfigInvalidException {
     File allUsersRepoPath = getPathToAllUsersRepository();
     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()));
@@ -102,14 +97,11 @@
   /**
    * Returns {@code GroupReference}s for all internal groups.
    *
-   * @param db the {@code ReviewDb} instance to use for lookups
    * @return a stream of the {@code GroupReference}s of all internal groups
-   * @throws OrmException if an error occurs while reading from ReviewDb
    * @throws IOException if an error occurs while reading from NoteDb
    * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
    */
-  public Stream<GroupReference> getAllGroupReferences(ReviewDb db)
-      throws OrmException, IOException, ConfigInvalidException {
+  public Stream<GroupReference> getAllGroupReferences() throws IOException, ConfigInvalidException {
     File allUsersRepoPath = getPathToAllUsersRepository();
     if (allUsersRepoPath != null) {
       try (Repository allUsersRepo = new FileRepository(allUsersRepoPath)) {
@@ -126,14 +118,12 @@
    * <p><strong>Note</strong>: This method doesn't check whether the account exists! It also doesn't
    * update the account index!
    *
-   * @param db the {@code ReviewDb} instance to update
    * @param groupUuid the UUID of the group
    * @param account the account to add
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
    * @throws NoSuchGroupException if the specified group doesn't exist
    */
-  public void addGroupMember(ReviewDb db, AccountGroup.UUID groupUuid, Account account)
-      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+  public void addGroupMember(AccountGroup.UUID groupUuid, Account account)
+      throws NoSuchGroupException, IOException, ConfigInvalidException {
     File allUsersRepoPath = getPathToAllUsersRepository();
     if (allUsersRepoPath != null) {
       try (Repository repository = new FileRepository(allUsersRepoPath)) {
@@ -145,7 +135,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,12 +150,12 @@
   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) {
     return InternalGroupUpdate.builder()
-        .setMemberModification(members -> Sets.union(members, ImmutableSet.of(account.getId())))
+        .setMemberModification(members -> Sets.union(members, ImmutableSet.of(account.id())))
         .build();
   }
 
@@ -186,7 +176,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/H2Initializer.java b/java/com/google/gerrit/pgm/init/H2Initializer.java
deleted file mode 100644
index 63aa6ec..0000000
--- a/java/com/google/gerrit/pgm/init/H2Initializer.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.die;
-
-import com.google.gerrit.common.FileUtil;
-import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import java.nio.file.Path;
-
-class H2Initializer implements DatabaseConfigInitializer {
-
-  private final SitePaths site;
-
-  @Inject
-  H2Initializer(SitePaths site) {
-    this.site = site;
-  }
-
-  @Override
-  public void initConfig(Section databaseSection) {
-    String path = databaseSection.get("database");
-    Path db;
-    if (path == null) {
-      db = site.resolve("db").resolve("ReviewDB");
-      databaseSection.set("database", db.toString());
-    } else {
-      db = site.resolve(path);
-    }
-    if (db == null) {
-      throw die("database.database must be supplied for H2");
-    }
-    db = db.getParent();
-    FileUtil.mkdirsOrDie(db, "cannot create database.database");
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/HANAInitializer.java b/java/com/google/gerrit/pgm/init/HANAInitializer.java
deleted file mode 100644
index 713392d..0000000
--- a/java/com/google/gerrit/pgm/init/HANAInitializer.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.username;
-
-import com.google.gerrit.pgm.init.api.Section;
-
-public class HANAInitializer implements DatabaseConfigInitializer {
-
-  @Override
-  public void initConfig(Section databaseSection) {
-    final String defPort = "(hana default)";
-    databaseSection.string("Server hostname", "hostname", "localhost");
-    databaseSection.string("Server port", "port", defPort, true);
-    databaseSection.string("Database name", "database", null);
-    databaseSection.string("Database username", "username", username());
-    databaseSection.password("username", "password");
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index d0aed46..0af83c5 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -17,27 +17,23 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.SequencesOnInit;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gwtorm.server.SchemaFactory;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -51,13 +47,11 @@
 public class InitAdminUser implements InitStep {
   private final InitFlags flags;
   private final ConsoleUI ui;
-  private final AllUsersNameOnInitProvider allUsers;
   private final AccountsOnInit accounts;
   private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
   private final ExternalIdsOnInit externalIds;
   private final SequencesOnInit sequencesOnInit;
   private final GroupsOnInit groupsOnInit;
-  private SchemaFactory<ReviewDb> dbFactory;
   private AccountIndexCollection accountIndexCollection;
   private GroupIndexCollection groupIndexCollection;
 
@@ -65,7 +59,6 @@
   InitAdminUser(
       InitFlags flags,
       ConsoleUI ui,
-      AllUsersNameOnInitProvider allUsers,
       AccountsOnInit accounts,
       VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
       ExternalIdsOnInit externalIds,
@@ -73,7 +66,6 @@
       GroupsOnInit groupsOnInit) {
     this.flags = flags;
     this.ui = ui;
-    this.allUsers = allUsers;
     this.accounts = accounts;
     this.authorizedKeysFactory = authorizedKeysFactory;
     this.externalIds = externalIds;
@@ -84,11 +76,6 @@
   @Override
   public void run() {}
 
-  @Inject(optional = true)
-  void set(SchemaFactory<ReviewDb> dbFactory) {
-    this.dbFactory = dbFactory;
-  }
-
   @Inject
   void set(AccountIndexCollection accountIndexCollection) {
     this.accountIndexCollection = accountIndexCollection;
@@ -106,58 +93,54 @@
       return;
     }
 
-    try (ReviewDb db = dbFactory.open()) {
-      if (!accounts.hasAnyAccount()) {
-        ui.header("Gerrit Administrator");
-        if (ui.yesno(true, "Create administrator user")) {
-          Account.Id id = new Account.Id(sequencesOnInit.nextAccountId(db));
-          String username = ui.readString("admin", "username");
-          String name = ui.readString("Administrator", "name");
-          String httpPassword = ui.readString("secret", "HTTP password");
-          AccountSshKey sshKey = readSshKey(id);
-          String email = readEmail(sshKey);
+    if (!accounts.hasAnyAccount()) {
+      ui.header("Gerrit Administrator");
+      if (ui.yesno(true, "Create administrator user")) {
+        Account.Id id = Account.id(sequencesOnInit.nextAccountId());
+        String username = ui.readString("admin", "username");
+        String name = ui.readString("Administrator", "name");
+        String httpPassword = ui.readString("secret", "HTTP password");
+        AccountSshKey sshKey = readSshKey(id);
+        String email = readEmail(sshKey);
 
-          List<ExternalId> extIds = new ArrayList<>(2);
-          extIds.add(ExternalId.createUsername(username, id, httpPassword));
+        List<ExternalId> extIds = new ArrayList<>(2);
+        extIds.add(ExternalId.createUsername(username, id, httpPassword));
 
-          if (email != null) {
-            extIds.add(ExternalId.createEmail(id, email));
-          }
-          externalIds.insert("Add external IDs for initial admin user", extIds);
+        if (email != null) {
+          extIds.add(ExternalId.createEmail(id, email));
+        }
+        externalIds.insert("Add external IDs for initial admin user", extIds);
 
-          Account a = new Account(id, TimeUtil.nowTs());
-          a.setFullName(name);
-          a.setPreferredEmail(email);
-          accounts.insert(a);
+        Account persistedAccount =
+            accounts.insert(
+                Account.builder(id, TimeUtil.nowTs()).setFullName(name).setPreferredEmail(email));
+        // Only two groups should exist at this point in time and hence iterating over all of them
+        // is cheap.
+        Optional<GroupReference> adminGroupReference =
+            groupsOnInit
+                .getAllGroupReferences()
+                .filter(group -> group.getName().equals("Administrators"))
+                .findAny();
+        if (!adminGroupReference.isPresent()) {
+          throw new NoSuchGroupException("Administrators");
+        }
+        GroupReference adminGroup = adminGroupReference.get();
+        groupsOnInit.addGroupMember(adminGroup.getUUID(), persistedAccount);
 
-          // Only two groups should exist at this point in time and hence iterating over all of them
-          // is cheap.
-          Optional<GroupReference> adminGroupReference =
-              groupsOnInit
-                  .getAllGroupReferences(db)
-                  .filter(group -> group.getName().equals("Administrators"))
-                  .findAny();
-          if (!adminGroupReference.isPresent()) {
-            throw new NoSuchGroupException("Administrators");
-          }
-          GroupReference adminGroup = adminGroupReference.get();
-          groupsOnInit.addGroupMember(db, adminGroup.getUUID(), a);
+        if (sshKey != null) {
+          VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
+          authorizedKeys.addKey(sshKey.sshPublicKey());
+          authorizedKeys.save("Add SSH key for initial admin user\n");
+        }
 
-          if (sshKey != null) {
-            VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
-            authorizedKeys.addKey(sshKey.sshPublicKey());
-            authorizedKeys.save("Add SSH key for initial admin user\n");
-          }
+        AccountState as = AccountState.forAccount(persistedAccount, extIds);
+        for (AccountIndex accountIndex : accountIndexCollection.getWriteIndexes()) {
+          accountIndex.replace(as);
+        }
 
-          AccountState as = AccountState.forAccount(new AllUsersName(allUsers.get()), a, extIds);
-          for (AccountIndex accountIndex : accountIndexCollection.getWriteIndexes()) {
-            accountIndex.replace(as);
-          }
-
-          InternalGroup adminInternalGroup = groupsOnInit.getExistingGroup(db, adminGroup);
-          for (GroupIndex groupIndex : groupIndexCollection.getWriteIndexes()) {
-            groupIndex.replace(adminInternalGroup);
-          }
+        InternalGroup adminInternalGroup = groupsOnInit.getExistingGroup(adminGroup);
+        for (GroupIndex groupIndex : groupIndexCollection.getWriteIndexes()) {
+          groupIndex.replace(adminInternalGroup);
         }
       }
     }
diff --git a/java/com/google/gerrit/pgm/init/InitAuth.java b/java/com/google/gerrit/pgm/init/InitAuth.java
index a52d8ba..c15cff3 100644
--- a/java/com/google/gerrit/pgm/init/InitAuth.java
+++ b/java/com/google/gerrit/pgm/init/InitAuth.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
-import com.google.gwtjsonrpc.server.SignedToken;
+import com.google.gerrit.server.mail.SignedToken;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.EnumSet;
diff --git a/java/com/google/gerrit/pgm/init/InitDatabase.java b/java/com/google/gerrit/pgm/init/InitDatabase.java
deleted file mode 100644
index 558716c..0000000
--- a/java/com/google/gerrit/pgm/init/InitDatabase.java
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
-import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
-import static com.google.inject.Stage.PRODUCTION;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Sets;
-import com.google.gerrit.pgm.init.api.ConsoleUI;
-import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.pgm.init.api.InitStep;
-import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.server.config.GerritServerIdProvider;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-import com.google.inject.Binding;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import com.google.inject.name.Names;
-import java.lang.annotation.Annotation;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.lib.Config;
-
-/** Initialize the {@code database} configuration section. */
-@Singleton
-class InitDatabase implements InitStep {
-  private final ConsoleUI ui;
-  private final SitePaths site;
-  private final Libraries libraries;
-  private final InitFlags flags;
-  private final Section database;
-  private final Section idSection;
-  private final Section noteDbChanges;
-
-  @Inject
-  InitDatabase(
-      ConsoleUI ui,
-      SitePaths site,
-      Libraries libraries,
-      InitFlags flags,
-      Section.Factory sections) {
-    this.ui = ui;
-    this.site = site;
-    this.libraries = libraries;
-    this.flags = flags; // Don't grab any flags yet; they aren't initialized until BaseInit#run.
-    this.database = sections.get("database", null);
-    this.idSection = sections.get(GerritServerIdProvider.SECTION, null);
-    this.noteDbChanges = sections.get(SECTION_NOTE_DB, CHANGES.key());
-  }
-
-  @Override
-  public void run() {
-    initSqlDb();
-    if (flags.isNew) {
-      initNoteDb();
-    }
-  }
-
-  private void initSqlDb() {
-    ui.header("SQL Database");
-
-    Set<String> allowedValues = Sets.newTreeSet();
-    Injector i = Guice.createInjector(PRODUCTION, new DatabaseConfigModule(site));
-    List<Binding<DatabaseConfigInitializer>> dbConfigBindings =
-        i.findBindingsByType(new TypeLiteral<DatabaseConfigInitializer>() {});
-    for (Binding<DatabaseConfigInitializer> binding : dbConfigBindings) {
-      Annotation annotation = binding.getKey().getAnnotation();
-      if (annotation instanceof Named) {
-        allowedValues.add(((Named) annotation).value());
-      }
-    }
-
-    if (!Strings.isNullOrEmpty(database.get("url"))
-        && Strings.isNullOrEmpty(database.get("type"))) {
-      database.set("type", "jdbc");
-    }
-
-    String dbType = database.select("Database server type", "type", "h2", allowedValues);
-
-    DatabaseConfigInitializer dci =
-        i.getInstance(Key.get(DatabaseConfigInitializer.class, Names.named(dbType.toLowerCase())));
-
-    if (dci instanceof MySqlInitializer) {
-      libraries.mysqlDriver.downloadRequired();
-    } else if (dci instanceof MariaDbInitializer) {
-      libraries.mariadbDriver.downloadRequired();
-    } else if (dci instanceof OracleInitializer) {
-      libraries.oracleDriver.downloadRequired();
-    } else if (dci instanceof DB2Initializer) {
-      libraries.db2Driver.downloadRequired();
-    } else if (dci instanceof HANAInitializer) {
-      libraries.hanaDriver.downloadRequired();
-    }
-
-    dci.initConfig(database);
-
-    // Initialize UUID for NoteDb on first init.
-    String id = idSection.get(GerritServerIdProvider.KEY);
-    if (Strings.isNullOrEmpty(id)) {
-      idSection.set(GerritServerIdProvider.KEY, GerritServerIdProvider.generate());
-    }
-  }
-
-  private void initNoteDb() {
-    ui.header("NoteDb Database");
-    ui.message(
-        "Use NoteDb for change metadata?\n"
-            + "  See documentation:\n"
-            + "  https://gerrit-review.googlesource.com/Documentation/note-db.html\n");
-    if (!ui.yesno(true, "Enable")) {
-      return;
-    }
-
-    Config defaultConfig = new Config();
-    NotesMigrationState.FINAL.setConfigValues(defaultConfig);
-    for (String name : defaultConfig.getNames(SECTION_NOTE_DB, CHANGES.key())) {
-      noteDbChanges.set(name, defaultConfig.getString(SECTION_NOTE_DB, CHANGES.key(), name));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/InitHttpd.java b/java/com/google/gerrit/pgm/init/InitHttpd.java
index 1a86106..6b4c7ca 100644
--- a/java/com/google/gerrit/pgm/init/InitHttpd.java
+++ b/java/com/google/gerrit/pgm/init/InitHttpd.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gwtjsonrpc.server.SignedToken;
+import com.google.gerrit.server.mail.SignedToken;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/pgm/init/InitIndex.java b/java/com/google/gerrit/pgm/init/InitIndex.java
index ee6c440..5ede863 100644
--- a/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
@@ -54,33 +53,23 @@
 
   @Override
   public void run() throws IOException {
-    IndexType type = IndexType.LUCENE;
-    if (IndexType.values().length > 1) {
-      ui.header("Index");
-      type = index.select("Type", "type", type);
-    }
+    ui.header("Index");
+    IndexType type =
+        new IndexType(
+            index.select("Type", "type", IndexType.getDefault(), IndexType.getKnownTypes()));
 
-    if (type == IndexType.ELASTICSEARCH) {
+    if (type.isElasticsearch()) {
       Section elasticsearch = sections.get("elasticsearch", null);
       elasticsearch.string("Index Prefix", "prefix", "gerrit_");
-      String name = ui.readString("default", "Server Name");
-
-      Section defaultServer = sections.get("elasticsearch", name);
-      defaultServer.select(
-          "Transport protocol", "protocol", "http", Sets.newHashSet("http", "https"));
-      defaultServer.string("Hostname", "hostname", "localhost");
-      defaultServer.string("Port", "port", "9200");
+      elasticsearch.string("Server", "server", "http://localhost:9200");
       index.string("Result window size", "maxLimit", "10000");
     }
 
-    if ((site.isNew || isEmptySite()) && type == IndexType.LUCENE) {
+    if ((site.isNew || isEmptySite()) && type.isLucene()) {
       for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
         IndexUtils.setReady(site, def.getName(), def.getLatest().getVersion(), true);
       }
     } else {
-      if (IndexType.values().length <= 1) {
-        ui.header("Index");
-      }
       String message =
           String.format(
               "\nThe index must be %sbuilt before starting Gerrit:\n"
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
index 3d1ec7b..0797cf9 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -61,7 +61,7 @@
           KEY_LABEL,
           LABEL_VERIFIED,
           KEY_VALUE,
-          Arrays.asList(new String[] {"-1 Fails", "0 No score", "+1 Verified"}));
+          Arrays.asList("-1 Fails", "0 No score", "+1 Verified"));
       cfg.setBoolean(KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, true);
       allProjectsConfig.save("Configure 'Verified' label");
     }
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..b6d25bc
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/InitLogging.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.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..f2fc001 100644
--- a/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/java/com/google/gerrit/pgm/init/InitModule.java
@@ -26,11 +26,9 @@
 public class InitModule extends FactoryModule {
 
   private final boolean standalone;
-  private final boolean initDb;
 
-  public InitModule(boolean standalone, boolean initDb) {
+  public InitModule(boolean standalone) {
     this.standalone = standalone;
-    this.initDb = initDb;
   }
 
   @Override
@@ -43,12 +41,8 @@
 
     // Steps are executed in the order listed here.
     //
-    step().to(UpgradeFrom2_0_x.class);
-
     step().to(InitGitManager.class);
-    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 d2e280d..68bdefc 100644
--- a/java/com/google/gerrit/pgm/init/InitSshd.java
+++ b/java/com/google/gerrit/pgm/init/InitSshd.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.server.ioutil.HostPlatform;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -103,7 +103,7 @@
                 "-q" /* quiet */,
                 "-t",
                 "rsa",
-                "-P",
+                "-N",
                 emptyPassphraseArg,
                 "-C",
                 comment,
diff --git a/java/com/google/gerrit/pgm/init/JDBCInitializer.java b/java/com/google/gerrit/pgm/init/JDBCInitializer.java
deleted file mode 100644
index e3a1d66..0000000
--- a/java/com/google/gerrit/pgm/init/JDBCInitializer.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.username;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.pgm.init.api.Section;
-
-class JDBCInitializer implements DatabaseConfigInitializer {
-  @Override
-  public void initConfig(Section database) {
-    boolean hasUrl = Strings.emptyToNull(database.get("url")) != null;
-    database.string("URL", "url", null);
-    guessDriver(database);
-    database.string("Driver class name", "driver", null);
-    database.string("Database username", "username", hasUrl ? null : username());
-    database.password("username", "password");
-  }
-
-  private void guessDriver(Section database) {
-    String url = Strings.emptyToNull(database.get("url"));
-    if (url != null && Strings.isNullOrEmpty(database.get("driver"))) {
-      if (url.startsWith("jdbc:derby:")) {
-        database.set("driver", "org.apache.derby.jdbc.EmbeddedDriver");
-      } else if (url.startsWith("jdbc:h2:")) {
-        database.set("driver", "org.h2.Driver");
-      } else if (url.startsWith("jdbc:mariadb:")) {
-        database.set("driver", "org.mariadb.jdbc.Driver");
-      } else if (url.startsWith("jdbc:mysql:")) {
-        database.set("driver", "com.mysql.jdbc.Driver");
-      } else if (url.startsWith("jdbc:postgresql:")) {
-        database.set("driver", "org.postgresql.Driver");
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/MariaDbInitializer.java b/java/com/google/gerrit/pgm/init/MariaDbInitializer.java
deleted file mode 100644
index db32113..0000000
--- a/java/com/google/gerrit/pgm/init/MariaDbInitializer.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.username;
-
-import com.google.gerrit.pgm.init.api.Section;
-
-class MariaDbInitializer implements DatabaseConfigInitializer {
-
-  @Override
-  public void initConfig(Section databaseSection) {
-    final String defPort = "(mariadb default)";
-    databaseSection.string("Server hostname", "hostname", "localhost");
-    databaseSection.string("Server port", "port", defPort, true);
-    databaseSection.string("Database name", "database", "reviewdb");
-    databaseSection.string("Database username", "username", username());
-    databaseSection.password("username", "password");
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/MaxDbInitializer.java b/java/com/google/gerrit/pgm/init/MaxDbInitializer.java
deleted file mode 100644
index 0f696b7..0000000
--- a/java/com/google/gerrit/pgm/init/MaxDbInitializer.java
+++ /dev/null
@@ -1,32 +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.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.username;
-
-import com.google.gerrit.pgm.init.api.Section;
-
-public class MaxDbInitializer implements DatabaseConfigInitializer {
-
-  @Override
-  public void initConfig(Section databaseSection) {
-    final String defPort = "(maxdb default)";
-    databaseSection.string("Server hostname", "hostname", "localhost");
-    databaseSection.string("Server port", "port", defPort, true);
-    databaseSection.string("Database name", "database", "reviewdb");
-    databaseSection.string("Database username", "username", username());
-    databaseSection.password("username", "password");
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/MySqlInitializer.java b/java/com/google/gerrit/pgm/init/MySqlInitializer.java
deleted file mode 100644
index 037b52b..0000000
--- a/java/com/google/gerrit/pgm/init/MySqlInitializer.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.username;
-
-import com.google.gerrit.pgm.init.api.Section;
-
-class MySqlInitializer implements DatabaseConfigInitializer {
-
-  @Override
-  public void initConfig(Section databaseSection) {
-    final String defPort = "(mysql default)";
-    databaseSection.string("Server hostname", "hostname", "localhost");
-    databaseSection.string("Server port", "port", defPort, true);
-    databaseSection.string("Database name", "database", "reviewdb");
-    databaseSection.string("Database username", "username", username());
-    databaseSection.password("username", "password");
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/OracleInitializer.java b/java/com/google/gerrit/pgm/init/OracleInitializer.java
deleted file mode 100644
index ffbaf34..0000000
--- a/java/com/google/gerrit/pgm/init/OracleInitializer.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 static com.google.gerrit.pgm.init.api.InitUtil.username;
-
-import com.google.gerrit.pgm.init.api.Section;
-
-public class OracleInitializer implements DatabaseConfigInitializer {
-
-  @Override
-  public void initConfig(Section databaseSection) {
-    final String defPort = "1521";
-    databaseSection.string("Server hostname", "hostname", "localhost");
-    databaseSection.string("Server port", "port", defPort, false);
-    databaseSection.string("Instance name", "instance", "xe");
-    databaseSection.string("Database username", "username", username());
-    databaseSection.password("username", "password");
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java b/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java
deleted file mode 100644
index 65a66de..0000000
--- a/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.username;
-
-import com.google.gerrit.pgm.init.api.Section;
-
-class PostgreSQLInitializer implements DatabaseConfigInitializer {
-
-  @Override
-  public void initConfig(Section databaseSection) {
-    final String defPort = "(postgresql default)";
-    databaseSection.string("Server hostname", "hostname", "localhost");
-    databaseSection.string("Server port", "port", defPort, true);
-    databaseSection.string("Database name", "database", "reviewdb");
-    databaseSection.string("Database username", "username", username());
-    databaseSection.password("username", "password");
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index bc562cc..846bb82 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -107,6 +107,7 @@
     extractMailExample("Abandoned.soy");
     extractMailExample("AbandonedHtml.soy");
     extractMailExample("AddKey.soy");
+    extractMailExample("AddKeyHtml.soy");
     extractMailExample("ChangeFooter.soy");
     extractMailExample("ChangeFooterHtml.soy");
     extractMailExample("ChangeSubject.soy");
@@ -114,6 +115,8 @@
     extractMailExample("CommentHtml.soy");
     extractMailExample("CommentFooter.soy");
     extractMailExample("CommentFooterHtml.soy");
+    extractMailExample("DeleteKey.soy");
+    extractMailExample("DeleteKeyHtml.soy");
     extractMailExample("DeleteReviewer.soy");
     extractMailExample("DeleteReviewerHtml.soy");
     extractMailExample("DeleteVote.soy");
@@ -121,6 +124,8 @@
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
     extractMailExample("HeaderHtml.soy");
+    extractMailExample("HttpPasswordUpdate.soy");
+    extractMailExample("HttpPasswordUpdateHtml.soy");
     extractMailExample("InboundEmailRejection.soy");
     extractMailExample("InboundEmailRejectionHtml.soy");
     extractMailExample("Merged.soy");
diff --git a/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java b/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java
index c454cce..d0db8ab 100644
--- a/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java
+++ b/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java
@@ -39,12 +39,7 @@
   public void remove(String pattern) {
     if (!Strings.isNullOrEmpty(pattern)) {
       DirectoryStream.Filter<Path> filter =
-          new DirectoryStream.Filter<Path>() {
-            @Override
-            public boolean accept(Path entry) {
-              return entry.getFileName().toString().matches("^" + pattern + "$");
-            }
-          };
+          entry -> entry.getFileName().toString().matches("^" + pattern + "$");
       try (DirectoryStream<Path> paths = Files.newDirectoryStream(lib_dir, filter)) {
         for (Path p : paths) {
           String old = p.getFileName().toString();
diff --git a/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java b/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
deleted file mode 100644
index 95ff8d7..0000000
--- a/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
+++ /dev/null
@@ -1,291 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.die;
-import static com.google.gerrit.pgm.init.api.InitUtil.savePublic;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Splitter;
-import com.google.gerrit.pgm.init.api.ConsoleUI;
-import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.pgm.init.api.InitStep;
-import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.securestore.SecureStore;
-import com.google.gerrit.server.util.SocketUtil;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.UnsupportedEncodingException;
-import java.net.InetSocketAddress;
-import java.net.URLDecoder;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.Arrays;
-import java.util.Map;
-import java.util.Properties;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-
-/** Upgrade from a 2.0.x site to a 2.1 site. */
-@Singleton
-class UpgradeFrom2_0_x implements InitStep {
-  static final String[] etcFiles = {
-    "gerrit.config", //
-    "secure.config", //
-    "replication.config", //
-    "ssh_host_rsa_key", //
-    "ssh_host_rsa_key.pub", //
-    "ssh_host_dsa_key", //
-    "ssh_host_dsa_key.pub", //
-    "ssh_host_key", //
-    "contact_information.pub", //
-    "gitweb_config.perl", //
-    "keystore", //
-    "GerritSite.css", //
-    "GerritSiteFooter.html", //
-    "GerritSiteHeader.html", //
-  };
-
-  private final ConsoleUI ui;
-
-  private final FileBasedConfig cfg;
-  private final SecureStore sec;
-  private final Path site_path;
-  private final Path etc_dir;
-  private final Section.Factory sections;
-
-  @Inject
-  UpgradeFrom2_0_x(
-      final SitePaths site,
-      final InitFlags flags,
-      final ConsoleUI ui,
-      final Section.Factory sections) {
-    this.ui = ui;
-    this.sections = sections;
-
-    this.cfg = flags.cfg;
-    this.sec = flags.sec;
-    this.site_path = site.site_path;
-    this.etc_dir = site.etc_dir;
-  }
-
-  boolean isNeedUpgrade() {
-    for (String name : etcFiles) {
-      if (Files.exists(site_path.resolve(name))) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  @Override
-  public void run() throws IOException, ConfigInvalidException {
-    if (!isNeedUpgrade()) {
-      return;
-    }
-
-    if (!ui.yesno(true, "Upgrade '%s'", site_path.toAbsolutePath())) {
-      throw die("aborted by user");
-    }
-
-    for (String name : etcFiles) {
-      Path src = site_path.resolve(name);
-      Path dst = etc_dir.resolve(name);
-      if (Files.exists(src)) {
-        if (Files.exists(dst)) {
-          throw die("File " + src + " would overwrite " + dst);
-        }
-        try {
-          Files.move(src, dst);
-        } catch (IOException e) {
-          throw die("Cannot rename " + src + " to " + dst, e);
-        }
-      }
-    }
-
-    // We have to reload the configuration after the rename as
-    // the initial load pulled up an non-existent (and thus
-    // believed to be empty) file.
-    //
-    cfg.load();
-
-    final Properties oldprop = readGerritServerProperties();
-    if (oldprop != null) {
-      final Section database = sections.get("database", null);
-
-      String url = oldprop.getProperty("url");
-      if (url != null && !convertUrl(database, url)) {
-        database.set("type", "jdbc");
-        database.set("driver", oldprop.getProperty("driver"));
-        database.set("url", url);
-      }
-
-      String username = oldprop.getProperty("user");
-      if (username == null || username.isEmpty()) {
-        username = oldprop.getProperty("username");
-      }
-      if (username != null && !username.isEmpty()) {
-        cfg.setString("database", null, "username", username);
-      }
-
-      String password = oldprop.getProperty("password");
-      if (password != null && !password.isEmpty()) {
-        sec.set("database", null, "password", password);
-      }
-    }
-
-    String[] values;
-
-    values = cfg.getStringList("ldap", null, "password");
-    cfg.unset("ldap", null, "password");
-    sec.setList("ldap", null, "password", Arrays.asList(values));
-
-    values = cfg.getStringList("sendemail", null, "smtpPass");
-    cfg.unset("sendemail", null, "smtpPass");
-    sec.setList("sendemail", null, "smtpPass", Arrays.asList(values));
-
-    savePublic(cfg);
-  }
-
-  private boolean convertUrl(Section database, String url) throws UnsupportedEncodingException {
-    String username = null;
-    String password = null;
-
-    if (url.contains("?")) {
-      final int q = url.indexOf('?');
-      for (String pair : Splitter.on('&').split(url.substring(q + 1))) {
-        final int eq = pair.indexOf('=');
-        if (0 < eq) {
-          return false;
-        }
-
-        String n = URLDecoder.decode(pair.substring(0, eq), UTF_8.name());
-        String v = URLDecoder.decode(pair.substring(eq + 1), UTF_8.name());
-
-        if ("user".equals(n) || "username".equals(n)) {
-          username = v;
-
-        } else if ("password".equals(n)) {
-          password = v;
-
-        } else {
-          // There is a parameter setting we don't recognize, use the
-          // JDBC URL format instead to preserve the configuration.
-          //
-          return false;
-        }
-      }
-      url = url.substring(0, q);
-    }
-
-    if (url.startsWith("jdbc:h2:file:")) {
-      url = url.substring("jdbc:h2:file:".length());
-      database.set("type", "h2");
-      database.set("database", url);
-      return true;
-    }
-
-    if (url.startsWith("jdbc:postgresql://")) {
-      url = url.substring("jdbc:postgresql://".length());
-      final int sl = url.indexOf('/');
-      if (sl < 0) {
-        return false;
-      }
-
-      final InetSocketAddress addr = SocketUtil.parse(url.substring(0, sl), 0);
-      database.set("type", "postgresql");
-      sethost(database, addr);
-      database.set("database", url.substring(sl + 1));
-      setuser(database, username, password);
-      return true;
-    }
-
-    if (url.startsWith("jdbc:postgresql:")) {
-      url = url.substring("jdbc:postgresql:".length());
-      database.set("type", "postgresql");
-      database.set("hostname", "localhost");
-      database.set("database", url);
-      setuser(database, username, password);
-      return true;
-    }
-
-    if (url.startsWith("jdbc:mysql://")) {
-      url = url.substring("jdbc:mysql://".length());
-      final int sl = url.indexOf('/');
-      if (sl < 0) {
-        return false;
-      }
-
-      final InetSocketAddress addr = SocketUtil.parse(url.substring(0, sl), 0);
-      database.set("type", "mysql");
-      sethost(database, addr);
-      database.set("database", url.substring(sl + 1));
-      setuser(database, username, password);
-      return true;
-    }
-
-    return false;
-  }
-
-  private void sethost(Section database, InetSocketAddress addr) {
-    database.set("hostname", SocketUtil.hostname(addr));
-    if (0 < addr.getPort()) {
-      database.set("port", String.valueOf(addr.getPort()));
-    }
-  }
-
-  private void setuser(Section database, String username, String password) {
-    if (username != null && !username.isEmpty()) {
-      database.set("username", username);
-    }
-    if (password != null && !password.isEmpty()) {
-      sec.set("database", null, "password", password);
-    }
-  }
-
-  private Properties readGerritServerProperties() throws IOException {
-    final Properties srvprop = new Properties();
-    final String name = System.getProperty("GerritServer");
-    Path path;
-    if (name != null) {
-      path = Paths.get(name);
-    } else {
-      path = site_path.resolve("GerritServer.properties");
-      if (!Files.exists(path)) {
-        path = Paths.get("GerritServer.properties");
-      }
-    }
-    if (Files.exists(path)) {
-      try (InputStream in = Files.newInputStream(path)) {
-        srvprop.load(in);
-      } catch (IOException e) {
-        throw new IOException("Cannot read " + name, e);
-      }
-      final Properties dbprop = new Properties();
-      for (Map.Entry<Object, Object> e : srvprop.entrySet()) {
-        final String key = (String) e.getKey();
-        if (key.startsWith("database.")) {
-          dbprop.put(key.substring("database.".length()), e.getValue());
-        }
-      }
-      return dbprop;
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index 9fd3f16..c90124d 100644
--- a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.pgm.init.api;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.project.GroupList;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -27,16 +29,21 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.lib.StoredConfig;
 
 public class AllProjectsConfig extends VersionedMetaDataOnInit {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @Nullable private final StoredConfig baseConfig;
   private Config cfg;
   private GroupList groupList;
 
   @Inject
   AllProjectsConfig(AllProjectsNameOnInitProvider allProjects, SitePaths site, InitFlags flags) {
     super(flags, site, allProjects.get(), RefNames.REFS_CONFIG);
+    this.baseConfig =
+        ProjectConfig.Factory.getBaseConfig(
+            site, new AllProjectsName(allProjects.get()), Project.nameKey(allProjects.get()));
   }
 
   public Config getConfig() {
@@ -55,13 +62,16 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
+    if (baseConfig != null) {
+      baseConfig.load();
+    }
     groupList = readGroupList();
-    cfg = readConfig(ProjectConfig.PROJECT_CONFIG);
+    cfg = readConfig(ProjectConfig.PROJECT_CONFIG, baseConfig);
   }
 
   private GroupList readGroupList() throws IOException {
     return GroupList.parse(
-        new Project.NameKey(project),
+        Project.nameKey(project),
         readUTF8(GroupList.FILE_NAME),
         error ->
             logger.atSevere().log(
diff --git a/java/com/google/gerrit/pgm/init/api/BUILD b/java/com/google/gerrit/pgm/init/api/BUILD
index bc418dd..5b07fc6 100644
--- a/java/com/google/gerrit/pgm/init/api/BUILD
+++ b/java/com/google/gerrit/pgm/init/api/BUILD
@@ -5,10 +5,10 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index 444f64f..ea39a44 100644
--- a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -224,7 +224,7 @@
 
     @Override
     public void header(String fmt, Object... args) {
-      fmt = fmt.replaceAll("\n", "\n*** ");
+      fmt = fmt.replace("\n", "\n*** ");
       console.printf("\n*** " + fmt + "\n*** \n\n", args);
     }
 
diff --git a/java/com/google/gerrit/pgm/init/api/InitUtil.java b/java/com/google/gerrit/pgm/init/api/InitUtil.java
index 656f53a..d038de7 100644
--- a/java/com/google/gerrit/pgm/init/api/InitUtil.java
+++ b/java/com/google/gerrit/pgm/init/api/InitUtil.java
@@ -97,7 +97,7 @@
       p = name.indexOf(".");
       if (0 < p) {
         name = name.substring(p + 1);
-        name = "DC=" + name.replaceAll("\\.", ",DC=");
+        name = "DC=" + name.replace(".", ",DC=");
       } else {
         name = null;
       }
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index 009e989..cbf32a1 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());
@@ -118,8 +127,10 @@
 
   public <T extends Enum<?>, E extends EnumSet<? extends T>> T select(
       String title, String name, T defValue, boolean nullIfDefault) {
+    @SuppressWarnings("rawtypes")
+    Class<? extends Enum> declaringClass = defValue.getDeclaringClass();
     @SuppressWarnings("unchecked")
-    E allowedValues = (E) EnumSet.allOf(defValue.getClass());
+    E allowedValues = (E) EnumSet.allOf(declaringClass);
     return select(title, name, defValue, allowedValues, nullIfDefault);
   }
 
diff --git a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
index c9c3a64..71753c7 100644
--- a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
@@ -15,12 +15,10 @@
 package com.google.gerrit.pgm.init.api;
 
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -35,16 +33,14 @@
     this.allUsersName = allUsersName;
   }
 
-  public int nextAccountId(ReviewDb db) throws OrmException {
-    @SuppressWarnings("deprecation")
-    RepoSequence.Seed accountSeed = db::nextAccountId;
+  public int nextAccountId() {
     RepoSequence accountSeq =
         new RepoSequence(
             repoManager,
             GitReferenceUpdated.DISABLED,
-            new Project.NameKey(allUsersName.get()),
+            Project.nameKey(allUsersName.get()),
             Sequences.NAME_ACCOUNTS,
-            accountSeed,
+            () -> Sequences.FIRST_ACCOUNT_ID,
             1);
     return accountSeq.next();
   }
diff --git a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
index e3b95ee..d4af255 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(Project.nameKey(project), repo);
       }
     }
     return this;
diff --git a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
index b417d05..80de1e5 100644
--- a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
+++ b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.account.AllAccountsIndexer;
 import com.google.gerrit.server.index.group.AllGroupsIndexer;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexDefinition;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.inject.AbstractModule;
@@ -47,8 +46,7 @@
   static final String INDEX_MANAGER = "IndexModuleOnInit/IndexManager";
 
   private static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
-      ImmutableList.<SchemaDefinitions<?>>of(
-          AccountSchemaDefinitions.INSTANCE, GroupSchemaDefinitions.INSTANCE);
+      ImmutableList.of(AccountSchemaDefinitions.INSTANCE, GroupSchemaDefinitions.INSTANCE);
 
   @Override
   protected void configure() {
@@ -75,11 +73,9 @@
     // during init, hence we don't need AllGroupsIndexer.
     bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
 
-    bind(GroupIndexCollection.class);
-
     bind(new TypeLiteral<Map<String, Integer>>() {})
         .annotatedWith(Names.named(SingleVersionModule.SINGLE_VERSIONS))
-        .toInstance(ImmutableMap.<String, Integer>of());
+        .toInstance(ImmutableMap.of());
     bind(LifecycleListener.class)
         .annotatedWith(Names.named(INDEX_MANAGER))
         .to(SingleVersionListener.class);
@@ -88,8 +84,7 @@
   @Provides
   Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
       AccountIndexDefinition accounts, GroupIndexDefinition groups) {
-    Collection<IndexDefinition<?, ?, ?>> result =
-        ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups);
+    Collection<IndexDefinition<?, ?, ?>> result = ImmutableList.of(accounts, groups);
     Set<String> expected =
         FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
     Set<String> actual = FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
diff --git a/java/com/google/gerrit/pgm/rules/PrologCompiler.java b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
index 4ad7701..2663f42 100644
--- a/java/com/google/gerrit/pgm/rules/PrologCompiler.java
+++ b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.pgm.rules;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.googlecode.prolog_cafe.compiler.Compiler;
diff --git a/java/com/google/gerrit/pgm/util/AbstractProgram.java b/java/com/google/gerrit/pgm/util/AbstractProgram.java
index fca5551..96b042a 100644
--- a/java/com/google/gerrit/pgm/util/AbstractProgram.java
+++ b/java/com/google/gerrit/pgm/util/AbstractProgram.java
@@ -66,9 +66,9 @@
         final Throwable cause = err.getCause();
         final String diemsg = err.getMessage();
         if (cause != null && !cause.getMessage().equals(diemsg)) {
-          System.err.println("fatal: " + cause.getMessage().replaceAll("\n", "\nfatal: "));
+          System.err.println("fatal: " + cause.getMessage().replace("\n", "\nfatal: "));
         }
-        System.err.println("fatal: " + diemsg.replaceAll("\n", "\nfatal: "));
+        System.err.println("fatal: " + diemsg.replace("\n", "\nfatal: "));
       }
       return 128;
     }
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index 7fe3bfa..ffd1cbd 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -19,8 +19,6 @@
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:guava",
-        "//lib:gwtorm",
-        "//lib/commons:dbcp",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 8d77ed8..956ec75 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
@@ -47,9 +48,9 @@
 import com.google.gerrit.server.config.AdministrateServerGroups;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
-import com.google.gerrit.server.config.DisableReverseDnsLookupProvider;
-import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.config.EnableReverseDnsLookup;
+import com.google.gerrit.server.config.EnableReverseDnsLookupProvider;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.config.SysExecutorModule;
@@ -57,6 +58,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
@@ -72,45 +74,28 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
-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;
-import com.google.inject.Inject;
-import com.google.inject.Module;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
-import org.eclipse.jgit.lib.Config;
 
-/**
- * Module for programs that perform batch operations on a site.
- *
- * <p>Any program that requires this module likely also requires using {@link ThreadLimiter} to
- * limit the number of threads accessing the database concurrently.
- */
+/** Module for programs that perform batch operations on a site. */
 public class BatchProgramModule extends FactoryModule {
-  private final Config cfg;
-  private final Module reviewDbModule;
-
-  @Inject
-  BatchProgramModule(@GerritServerConfig Config cfg, PerThreadReviewDbModule reviewDbModule) {
-    this.cfg = cfg;
-    this.reviewDbModule = reviewDbModule;
-  }
-
   @SuppressWarnings("rawtypes")
   @Override
   protected void configure() {
-    install(reviewDbModule);
     install(new DiffExecutorModule());
     install(new SysExecutorModule());
     install(BatchUpdate.module());
     install(PatchListCacheImpl.module());
+    install(new DefaultUrlFormatter.Module());
 
     // There is the concept of LifecycleModule, in Gerrit's own extension to Guice, which has these:
     //  listener().to(SomeClassImplementingLifecycleListener.class);
@@ -123,28 +108,25 @@
 
     // We're just running through each change
     // once, so don't worry about cache removal.
-    bind(new TypeLiteral<DynamicSet<CacheRemovalListener>>() {})
-        .toInstance(DynamicSet.<CacheRemovalListener>emptySet());
-    bind(new TypeLiteral<DynamicMap<Cache<?, ?>>>() {})
-        .toInstance(DynamicMap.<Cache<?, ?>>emptyMap());
+    bind(new TypeLiteral<DynamicSet<CacheRemovalListener>>() {}).toInstance(DynamicSet.emptySet());
+    bind(new TypeLiteral<DynamicMap<Cache<?, ?>>>() {}).toInstance(DynamicMap.emptyMap());
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
-    bind(new TypeLiteral<DynamicMap<ChangeQueryProcessor.ChangeAttributeFactory>>() {})
-        .toInstance(DynamicMap.<ChangeQueryProcessor.ChangeAttributeFactory>emptyMap());
+    bind(new TypeLiteral<DynamicSet<ChangeAttributeFactory>>() {})
+        .toInstance(DynamicSet.emptySet());
     bind(new TypeLiteral<DynamicMap<RestView<CommitResource>>>() {})
-        .toInstance(DynamicMap.<RestView<CommitResource>>emptyMap());
+        .toInstance(DynamicMap.emptyMap());
     bind(String.class)
         .annotatedWith(CanonicalWebUrl.class)
         .toProvider(CanonicalWebUrlProvider.class);
     bind(Boolean.class)
-        .annotatedWith(DisableReverseDnsLookup.class)
-        .toProvider(DisableReverseDnsLookupProvider.class)
+        .annotatedWith(EnableReverseDnsLookup.class)
+        .toProvider(EnableReverseDnsLookupProvider.class)
         .in(SINGLETON);
     bind(Realm.class).to(FakeRealm.class);
-    bind(IdentifiedUser.class).toProvider(Providers.<IdentifiedUser>of(null));
-    bind(ReplacePatchSetSender.Factory.class)
-        .toProvider(Providers.<ReplacePatchSetSender.Factory>of(null));
+    bind(IdentifiedUser.class).toProvider(Providers.of(null));
+    bind(ReplacePatchSetSender.Factory.class).toProvider(Providers.of(null));
     bind(CurrentUser.class).to(IdentifiedUser.class);
     factory(MergeUtil.Factory.class);
     factory(PatchSetInserter.Factory.class);
@@ -152,17 +134,17 @@
 
     // As Reindex is a batch program, don't assume the index is available for
     // the change cache.
-    bind(SearchingChangeCacheImpl.class).toProvider(Providers.<SearchingChangeCacheImpl>of(null));
+    bind(SearchingChangeCacheImpl.class).toProvider(Providers.of(null));
 
     bind(new TypeLiteral<ImmutableSet<GroupReference>>() {})
         .annotatedWith(AdministrateServerGroups.class)
-        .toInstance(ImmutableSet.<GroupReference>of());
+        .toInstance(ImmutableSet.of());
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
         .annotatedWith(GitUploadPackGroups.class)
-        .toInstance(Collections.<AccountGroup.UUID>emptySet());
+        .toInstance(Collections.emptySet());
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
         .annotatedWith(GitReceivePackGroups.class)
-        .toInstance(Collections.<AccountGroup.UUID>emptySet());
+        .toInstance(Collections.emptySet());
 
     install(new BatchGitModule());
     install(new DefaultPermissionBackendModule());
@@ -170,7 +152,7 @@
     install(new H2CacheModule());
     install(new ExternalIdModule());
     install(new GroupModule());
-    install(new NoteDbModule(cfg));
+    install(new NoteDbModule());
     install(AccountCacheImpl.module());
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
@@ -179,6 +161,7 @@
     install(ChangeKindCacheImpl.module());
     install(MergeabilityCacheImpl.module());
     install(TagCache.module());
+    install(PureRevertCache.module());
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ProjectState.Factory.class);
@@ -188,9 +171,10 @@
     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));
+    bind(ChangeJson.Factory.class).toProvider(Providers.of(null));
+    bind(EventUtil.class).toProvider(Providers.of(null));
     bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
     bind(RevisionCreated.class).toInstance(RevisionCreated.DISABLED);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
diff --git a/java/com/google/gerrit/pgm/util/ErrorLogFile.java b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
index 5211f41..227719a 100644
--- a/java/com/google/gerrit/pgm/util/ErrorLogFile.java
+++ b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
@@ -35,16 +35,16 @@
   public static void errorOnlyConsole() {
     LogManager.resetConfiguration();
 
-    final PatternLayout layout = new PatternLayout();
+    PatternLayout layout = new PatternLayout();
     layout.setConversionPattern("%-5p %c %x: %m%n");
 
-    final ConsoleAppender dst = new ConsoleAppender();
+    ConsoleAppender dst = new ConsoleAppender();
     dst.setLayout(layout);
     dst.setTarget("System.err");
     dst.setThreshold(Level.ERROR);
     dst.activateOptions();
 
-    final Logger root = LogManager.getRootLogger();
+    Logger root = LogManager.getRootLogger();
     root.removeAllAppenders();
     root.addAppender(dst);
   }
@@ -68,7 +68,7 @@
   }
 
   private static void initLogSystem(Path logdir, Config config) {
-    final Logger root = LogManager.getRootLogger();
+    Logger root = LogManager.getRootLogger();
     root.removeAllAppenders();
 
     boolean json = config.getBoolean("log", "jsonLogging", false);
diff --git a/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java b/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
deleted file mode 100644
index bdd939f..0000000
--- a/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
+++ /dev/null
@@ -1,81 +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.pgm.util;
-
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Module to bind a single {@link ReviewDb} instance per thread.
- *
- * <p>New instances are opened on demand, but are closed only at shutdown.
- */
-class PerThreadReviewDbModule extends LifecycleModule {
-  private final SchemaFactory<ReviewDb> schema;
-
-  @Inject
-  PerThreadReviewDbModule(SchemaFactory<ReviewDb> schema) {
-    this.schema = schema;
-  }
-
-  @Override
-  protected void configure() {
-    final List<ReviewDb> dbs = Collections.synchronizedList(new ArrayList<ReviewDb>());
-    final ThreadLocal<ReviewDb> localDb = new ThreadLocal<>();
-
-    bind(ReviewDb.class)
-        .toProvider(
-            new Provider<ReviewDb>() {
-              @Override
-              public ReviewDb get() {
-                ReviewDb db = localDb.get();
-                if (db == null) {
-                  try {
-                    db = schema.open();
-                    dbs.add(db);
-                    localDb.set(db);
-                  } catch (OrmException e) {
-                    throw new ProvisionException("unable to open ReviewDb", e);
-                  }
-                }
-                return db;
-              }
-            });
-    listener()
-        .toInstance(
-            new LifecycleListener() {
-              @Override
-              public void start() {
-                // Do nothing.
-              }
-
-              @Override
-              public void stop() {
-                for (ReviewDb db : dbs) {
-                  db.close();
-                }
-              }
-            });
-  }
-}
diff --git a/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java b/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
deleted file mode 100644
index 1c7dc52..0000000
--- a/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.util;
-
-import com.google.gerrit.common.SiteLibraryLoaderUtil;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gerrit.server.schema.DataSourceProvider;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.nio.file.Path;
-import javax.sql.DataSource;
-import org.eclipse.jgit.lib.Config;
-
-/** Loads the site library if not yet loaded. */
-@Singleton
-public class SiteLibraryBasedDataSourceProvider extends DataSourceProvider {
-  private final Path libdir;
-  private boolean init;
-
-  @Inject
-  SiteLibraryBasedDataSourceProvider(
-      SitePaths site,
-      @GerritServerConfig Config cfg,
-      MetricMaker metrics,
-      ThreadSettingsConfig tsc,
-      DataSourceProvider.Context ctx,
-      DataSourceType dst) {
-    super(cfg, metrics, tsc, ctx, dst);
-    libdir = site.lib_dir;
-  }
-
-  @Override
-  public synchronized DataSource get() {
-    if (!init) {
-      SiteLibraryLoaderUtil.loadSiteLib(libdir);
-      init = true;
-    }
-    return super.get();
-  }
-}
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index 057496f..98558fb 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -15,52 +15,35 @@
 package com.google.gerrit.pgm.util;
 
 import static com.google.gerrit.server.config.GerritServerConfigModule.getSecureStoreClassName;
-import static com.google.inject.Scopes.SINGLETON;
 import static com.google.inject.Stage.PRODUCTION;
 
 import com.google.gerrit.common.Die;
-import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.LibModuleType;
+import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.config.GerritRuntime;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.schema.DataSourceModule;
-import com.google.gerrit.server.schema.DataSourceProvider;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.gerrit.server.schema.DatabaseModule;
+import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.schema.SchemaModule;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.AbstractModule;
-import com.google.inject.Binding;
 import com.google.inject.CreationException;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import com.google.inject.Key;
 import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import com.google.inject.name.Names;
 import com.google.inject.spi.Message;
 import com.google.inject.util.Providers;
-import java.lang.annotation.Annotation;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.sql.Connection;
-import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.List;
-import javax.sql.DataSource;
-import org.eclipse.jgit.lib.Config;
 import org.kohsuke.args4j.Option;
 
 public abstract class SiteProgram extends AbstractProgram {
@@ -69,22 +52,15 @@
       aliases = {"-d"},
       usage = "Local directory containing site data")
   private void setSitePath(String path) {
-    sitePath = Paths.get(path);
+    sitePath = Paths.get(path).normalize();
   }
 
-  protected Provider<DataSource> dsProvider;
-
   private Path sitePath = Paths.get(".");
 
   protected SiteProgram() {}
 
   protected SiteProgram(Path sitePath) {
-    this.sitePath = sitePath;
-  }
-
-  protected SiteProgram(Path sitePath, Provider<DataSource> dsProvider) {
-    this.sitePath = sitePath;
-    this.dsProvider = dsProvider;
+    this.sitePath = sitePath.normalize();
   }
 
   /** @return the site path specified on the command line. */
@@ -100,20 +76,19 @@
   }
 
   /** @return provides database connectivity and site path. */
-  protected Injector createDbInjector(DataSourceProvider.Context context) {
-    return createDbInjector(false, context);
+  protected Injector createDbInjector() {
+    return createDbInjector(false);
   }
 
   /** @return provides database connectivity and site path. */
-  protected Injector createDbInjector(boolean enableMetrics, DataSourceProvider.Context context) {
-    Path sitePath = getSitePath();
+  protected Injector createDbInjector(boolean enableMetrics) {
     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()));
@@ -137,20 +112,7 @@
         new LifecycleModule() {
           @Override
           protected void configure() {
-            bind(DataSourceProvider.Context.class).toInstance(context);
-            if (dsProvider != null) {
-              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-                  .toProvider(dsProvider)
-                  .in(SINGLETON);
-              if (LifecycleListener.class.isAssignableFrom(dsProvider.getClass())) {
-                listener().toInstance((LifecycleListener) dsProvider);
-              }
-            } else {
-              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-                  .toProvider(SiteLibraryBasedDataSourceProvider.class)
-                  .in(SINGLETON);
-              listener().to(SiteLibraryBasedDataSourceProvider.class);
-            }
+            listener().to(SystemReaderInstaller.class);
           }
         });
     Module configModule = new GerritServerConfigModule();
@@ -163,53 +125,19 @@
           }
         });
     Injector cfgInjector = Guice.createInjector(sitePathModule, configModule);
-    Config cfg = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    String dbType;
-    if (dsProvider != null) {
-      dbType = getDbType(dsProvider);
-    } else {
-      dbType = cfg.getString("database", null, "type");
-    }
 
-    if (dbType == null) {
-      throw new ProvisionException("database.type must be defined");
-    }
-
-    DataSourceType dst =
-        Guice.createInjector(new DataSourceModule(), configModule, sitePathModule)
-            .getInstance(Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
-
-    modules.add(
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(DataSourceType.class).toInstance(dst);
-          }
-        });
-    modules.add(new DatabaseModule());
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
-    modules.add(new NotesMigration.Module());
 
     try {
-      return Guice.createInjector(PRODUCTION, modules);
+      return Guice.createInjector(
+          PRODUCTION,
+          ModuleOverloader.override(
+              modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE)));
     } catch (CreationException ce) {
       Message first = ce.getErrorMessages().iterator().next();
       Throwable why = first.getCause();
 
-      if (why instanceof SQLException) {
-        throw die("Cannot connect to SQL database", 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("Cannot connect to SQL database", why);
-      }
-
       StringBuilder buf = new StringBuilder();
       if (why != null) {
         buf.append(why.getMessage());
@@ -234,45 +162,4 @@
   protected final String getConfiguredSecureStoreClass() {
     return getSecureStoreClassName(sitePath);
   }
-
-  private String getDbType(Provider<DataSource> dsProvider) {
-    String dbProductName;
-    try (Connection conn = dsProvider.get().getConnection()) {
-      dbProductName = conn.getMetaData().getDatabaseProductName().toLowerCase();
-    } catch (SQLException e) {
-      throw new RuntimeException(e);
-    }
-
-    List<Module> modules = new ArrayList<>();
-    modules.add(
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
-          }
-        });
-    modules.add(new GerritServerConfigModule());
-    modules.add(new DataSourceModule());
-    Injector i = Guice.createInjector(modules);
-    List<Binding<DataSourceType>> dsTypeBindings =
-        i.findBindingsByType(new TypeLiteral<DataSourceType>() {});
-    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();
-        }
-      }
-    }
-    throw new IllegalStateException(
-        String.format(
-            "Cannot guess database type from the database product name '%s'", dbProductName));
-  }
-
-  @SuppressWarnings("deprecation")
-  private static boolean isCannotCreatePoolException(Throwable why) {
-    return why instanceof org.apache.commons.dbcp.SQLNestedException
-        && why.getCause() != null
-        && why.getMessage().startsWith("Cannot create PoolableConnectionFactory");
-  }
 }
diff --git a/java/com/google/gerrit/pgm/util/ThreadLimiter.java b/java/com/google/gerrit/pgm/util/ThreadLimiter.java
deleted file mode 100644
index 64f703bd..0000000
--- a/java/com/google/gerrit/pgm/util/ThreadLimiter.java
+++ /dev/null
@@ -1,50 +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.pgm.util;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import org.eclipse.jgit.lib.Config;
-
-// TODO(dborowitz): Not necessary once we switch to NoteDb.
-/** Utility to limit threads used by a batch program. */
-public class ThreadLimiter {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static int limitThreads(Injector dbInjector, int threads) {
-    return limitThreads(
-        dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class)),
-        dbInjector.getInstance(DataSourceType.class),
-        dbInjector.getInstance(ThreadSettingsConfig.class),
-        threads);
-  }
-
-  private static int limitThreads(
-      Config cfg, DataSourceType dst, ThreadSettingsConfig threadSettingsConfig, int threads) {
-    boolean usePool = cfg.getBoolean("database", "connectionpool", dst.usePool());
-    int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
-    if (usePool && threads > poolLimit) {
-      logger.atWarning().log("Limiting program to %d threads due to database.poolLimit", poolLimit);
-      return poolLimit;
-    }
-    return threads;
-  }
-
-  private ThreadLimiter() {}
-}
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
index 366a1a0..88b5b60 100644
--- a/java/com/google/gerrit/prettify/BUILD
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -1,31 +1,10 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-gwt_module(
-    name = "client",
-    srcs = glob(["common/**/*.java"]),
-    exported_deps = [
-        "//java/com/google/gerrit/extensions:client",
-        "//java/com/google/gerrit/reviewdb:client",
-        "//java/com/google/gwtexpui/safehtml",
-        "//java/org/eclipse/jgit:Edit",
-        "//java/org/eclipse/jgit:client",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtjsonrpc_src",
-    ],
-    gwt_xml = "PrettyFormatter.gwt.xml",
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:user-neverlink"],
-)
-
 java_library(
     name = "server",
     srcs = glob(["common/**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/reviewdb:server",
-        "//java/org/eclipse/jgit:server",
         "//lib:guava",
-        "//lib:gwtjsonrpc",
         "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml b/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml
deleted file mode 100644
index 06035d27..0000000
--- a/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-<!--
- Copyright (C) 2008 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the 'License');
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an 'AS IS' BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <replace-with class='com.google.gerrit.prettify.client.PrivateScopeImplIE8'>
-    <when-type-is class='com.google.gerrit.prettify.client.PrivateScopeImpl'/>
-    <any>
-      <when-property-is name="user.agent" value="ie8" />
-    </any>
-  </replace-with>
-
-  <inherits name='com.google.gwt.resources.Resources'/>
-  <inherits name='com.google.gwtexpui.safehtml.SafeHtml'/>
-  <source path='common' />
-</module>
diff --git a/java/com/google/gerrit/prettify/common/EditList.java b/java/com/google/gerrit/prettify/common/EditList.java
index 61c807c..172a346 100644
--- a/java/com/google/gerrit/prettify/common/EditList.java
+++ b/java/com/google/gerrit/prettify/common/EditList.java
@@ -36,10 +36,8 @@
   }
 
   public Iterable<Hunk> getHunks() {
-    return new Iterable<Hunk>() {
-      @Override
-      public Iterator<Hunk> iterator() {
-        return new Iterator<Hunk>() {
+    return () ->
+        new Iterator<Hunk>() {
           private int curIdx;
 
           @Override
@@ -60,8 +58,6 @@
             throw new UnsupportedOperationException();
           }
         };
-      }
-    };
   }
 
   private int findCombinedEnd(int i) {
diff --git a/java/com/google/gerrit/proto/BUILD b/java/com/google/gerrit/proto/BUILD
new file mode 100644
index 0000000..4f05bf6
--- /dev/null
+++ b/java/com/google/gerrit/proto/BUILD
@@ -0,0 +1,8 @@
+java_library(
+    name = "proto",
+    srcs = ["Protos.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:protobuf",
+    ],
+)
diff --git a/java/com/google/gerrit/proto/Protos.java b/java/com/google/gerrit/proto/Protos.java
new file mode 100644
index 0000000..b4f0b55
--- /dev/null
+++ b/java/com/google/gerrit/proto/Protos.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.proto;
+
+import com.google.protobuf.ByteString;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.MessageLite;
+import com.google.protobuf.Parser;
+import java.io.IOException;
+
+/** Static utilities for dealing with protobuf-based objects. */
+public class Protos {
+  /**
+   * Serializes a proto to a byte array.
+   *
+   * <p>Guarantees deterministic serialization. No matter whether the use case cares about
+   * determinism or not, always use this method in preference to {@link MessageLite#toByteArray()},
+   * which is not guaranteed deterministic.
+   *
+   * @param message the proto message to serialize.
+   * @return a byte array with the message contents.
+   */
+  public static byte[] toByteArray(MessageLite message) {
+    byte[] bytes = new byte[message.getSerializedSize()];
+    CodedOutputStream cout = CodedOutputStream.newInstance(bytes);
+    cout.useDeterministicSerialization();
+    try {
+      message.writeTo(cout);
+      cout.checkNoSpaceLeft();
+      return bytes;
+    } catch (IOException e) {
+      throw new IllegalStateException("exception writing to byte array", e);
+    }
+  }
+
+  /**
+   * Serializes a proto to a {@code ByteString}.
+   *
+   * <p>Guarantees deterministic serialization. No matter whether the use case cares about
+   * determinism or not, always use this method in preference to {@link MessageLite#toByteString()},
+   * which is not guaranteed deterministic.
+   *
+   * @param message the proto message to serialize
+   * @return a {@code ByteString} with the message contents
+   */
+  public static ByteString toByteString(MessageLite message) {
+    try (ByteString.Output bout = ByteString.newOutput(message.getSerializedSize())) {
+      CodedOutputStream outputStream = CodedOutputStream.newInstance(bout);
+      outputStream.useDeterministicSerialization();
+      message.writeTo(outputStream);
+      outputStream.flush();
+      return bout.toByteString();
+    } catch (IOException e) {
+      throw new IllegalStateException("exception writing to ByteString", e);
+    }
+  }
+
+  /**
+   * Parses a byte array to a protobuf message.
+   *
+   * @param parser parser for the proto type.
+   * @param in byte array with the message contents.
+   * @return parsed proto.
+   */
+  public static <M extends MessageLite> M parseUnchecked(Parser<M> parser, byte[] in) {
+    try {
+      return parser.parseFrom(in);
+    } catch (IOException e) {
+      throw new IllegalArgumentException("exception parsing byte array to proto", e);
+    }
+  }
+
+  /**
+   * Parses a specific segment of a byte array to a protobuf message.
+   *
+   * @param parser parser for the proto type
+   * @param in byte array with the message contents
+   * @param offset offset in the byte array to start reading from
+   * @param length amount of read bytes
+   * @return parsed proto
+   */
+  public static <M extends MessageLite> M parseUnchecked(
+      Parser<M> parser, byte[] in, int offset, int length) {
+    try {
+      return parser.parseFrom(in, offset, length);
+    } catch (IOException e) {
+      throw new IllegalArgumentException("exception parsing byte array to proto", e);
+    }
+  }
+
+  /**
+   * Parses a {@code ByteString} to a protobuf message.
+   *
+   * @param parser parser for the proto type
+   * @param byteString {@code ByteString} with the message contents
+   * @return parsed proto
+   */
+  public static <M extends MessageLite> M parseUnchecked(Parser<M> parser, ByteString byteString) {
+    try {
+      return parser.parseFrom(byteString);
+    } catch (IOException e) {
+      throw new IllegalArgumentException("exception parsing ByteString to proto", e);
+    }
+  }
+
+  private Protos() {}
+}
diff --git a/java/com/google/gerrit/proto/testing/BUILD b/java/com/google/gerrit/proto/testing/BUILD
new file mode 100644
index 0000000..48115ff
--- /dev/null
+++ b/java/com/google/gerrit/proto/testing/BUILD
@@ -0,0 +1,13 @@
+package(default_testonly = True)
+
+java_library(
+    name = "testing",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//lib:guava",
+        "//lib/commons:lang3",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/proto/testing/SerializedClassSubject.java b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
new file mode 100644
index 0000000..e7dc276
--- /dev/null
+++ b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.proto.testing;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.Fact.simpleFact;
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Type;
+import java.util.Arrays;
+import java.util.Map;
+import org.apache.commons.lang3.reflect.FieldUtils;
+
+/**
+ * Subject about classes that are serialized into persistent caches or indices.
+ *
+ * <p>Hand-written {@link com.google.gerrit.server.cache.serialize.CacheSerializer CacheSerializer}
+ * and {@link com.google.gerrit.reviewdb.converter.ProtoConverter ProtoConverter} 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 custom serializer
+ * implementation.
+ *
+ * <p>Changing fields of a serialized class (or abstract methods, in the case of {@code @AutoValue}
+ * classes) will likely require changes to the serializer implementation, and may require bumping
+ * the {@link com.google.gerrit.server.cache.PersistentCacheBinding#version(int) version} in the
+ * cache binding, in case the representation has changed in such a way that old serialized data
+ * becomes unreadable.
+ *
+ * <p>Changes to a serialized class such as adding or removing fields generally requires a change to
+ * the hand-written serializer. Usually, serializer implementations should be written in such a way
+ * that new fields are considered optional, and won't require bumping the version.
+ */
+public class SerializedClassSubject extends Subject {
+  public static SerializedClassSubject assertThatSerializedClass(Class<?> actual) {
+    // This formulation fails in Eclipse 4.7.3a with "The type
+    // SerializedClassSubject does not define SerializedClassSubject() that is
+    // applicable here", due to
+    // https://bugs.eclipse.org/bugs/show_bug.cgi?id=534694 or a similar bug:
+    // return assertAbout(SerializedClassSubject::new).that(actual);
+    Subject.Factory<SerializedClassSubject, Class<?>> factory =
+        (m, a) -> new SerializedClassSubject(m, a);
+    return assertAbout(factory).that(actual);
+  }
+
+  private final Class<?> clazz;
+
+  private SerializedClassSubject(FailureMetadata metadata, Class<?> clazz) {
+    super(metadata, clazz);
+    this.clazz = clazz;
+  }
+
+  public void isAbstract() {
+    isNotNull();
+    if (!Modifier.isAbstract(clazz.getModifiers())) {
+      failWithActual(simpleFact("expected class to be abstract"));
+    }
+  }
+
+  public void isConcrete() {
+    isNotNull();
+    if (Modifier.isAbstract(clazz.getModifiers())) {
+      failWithActual(simpleFact("expected class to be concrete"));
+    }
+  }
+
+  public void hasFields(Map<String, Type> expectedFields) {
+    isConcrete();
+    check("fields()")
+        .that(
+            FieldUtils.getAllFieldsList(clazz).stream()
+                .filter(f -> !Modifier.isStatic(f.getModifiers()))
+                .collect(toImmutableMap(Field::getName, Field::getGenericType)))
+        .containsExactlyEntriesIn(expectedFields);
+  }
+
+  public void hasAutoValueMethods(Map<String, Type> expectedMethods) {
+    // Would be nice if we could check clazz is an @AutoValue, but the retention is not RUNTIME.
+    isAbstract();
+    check("noArgumentAbstractMethods()")
+        .that(
+            Arrays.stream(clazz.getDeclaredMethods())
+                .filter(m -> !Modifier.isStatic(m.getModifiers()))
+                .filter(m -> Modifier.isAbstract(m.getModifiers()))
+                .filter(m -> m.getParameters().length == 0)
+                .collect(toImmutableMap(Method::getName, Method::getGenericReturnType)))
+        .isEqualTo(expectedMethods);
+  }
+
+  public void extendsClass(Type superclassType) {
+    isNotNull();
+    check("getGenericSuperclass()").that(clazz.getGenericSuperclass()).isEqualTo(superclassType);
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/BUILD b/java/com/google/gerrit/reviewdb/BUILD
index 40f39c0..8c286ce 100644
--- a/java/com/google/gerrit/reviewdb/BUILD
+++ b/java/com/google/gerrit/reviewdb/BUILD
@@ -2,27 +2,19 @@
     default_visibility = ["//visibility:public"],
 )
 
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-gwt_module(
-    name = "client",
-    srcs = glob(["client/**/*.java"]),
-    gwt_xml = "ReviewDB.gwt.xml",
-    deps = [
-        "//java/com/google/gerrit/extensions:client",
-        "//lib:gwtorm-client",
-        "//lib:gwtorm-client_src",
-    ],
-)
-
 java_library(
     name = "server",
     srcs = glob(["**/*.java"]),
-    resource_strip_prefix = "resources",
-    resources = ["//resources/com/google/gerrit/reviewdb"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//lib:guava",
-        "//lib:gwtorm",
+        "//lib:protobuf",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//proto:entities_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/reviewdb/ReviewDB.gwt.xml b/java/com/google/gerrit/reviewdb/ReviewDB.gwt.xml
deleted file mode 100644
index 4402826..0000000
--- a/java/com/google/gerrit/reviewdb/ReviewDB.gwt.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name='com.google.gwtorm.GWTORM'/>
-</module>
diff --git a/java/com/google/gerrit/reviewdb/client/Account.java b/java/com/google/gerrit/reviewdb/client/Account.java
index 717090e..a26f3be 100644
--- a/java/com/google/gerrit/reviewdb/client/Account.java
+++ b/java/com/google/gerrit/reviewdb/client/Account.java
@@ -18,9 +18,10 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_STARRED_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.IntKey;
 import java.sql.Timestamp;
 import java.util.Optional;
 
@@ -37,52 +38,24 @@
  *   <li>ExternalId: OpenID identities and email addresses known to be registered to this user.
  *       Multiple records can exist when the user has more than one public identity, such as a work
  *       and a personal email address.
- *   <li>{@link AccountGroupMember}: membership of the user in a specific human managed {@link
- *       AccountGroup}. Multiple records can exist when the user is a member of more than one group.
  *   <li>AccountSshKey: user's public SSH keys, for authentication through the internal SSH daemon.
  *       One record per SSH key uploaded by the user, keys are checked in random order until a match
  *       is found.
  *   <li>{@link DiffPreferencesInfo}: user's preferences for rendering side-to-side and unified diff
  * </ul>
  */
-public final class Account {
-  /**
-   * Key local to Gerrit to identify a user.
-   *
-   * <p>Fields in this type must be annotated with {@link Column} so that account IDs can be
-   * converted into protos (protobuf requires the {@link Column} annotations for decoding/encoding).
-   * We need to be able to store account IDs as protos because we store change protos in the change
-   * index and a change references account IDs for the change owner and the assignee.
-   */
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+@AutoValue
+public abstract class Account {
+  public static Id id(int id) {
+    return new AutoValue_Account_Id(id);
+  }
 
-    @Column(id = 1)
-    protected int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
-    public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
-    }
-
+  /** Key local to Gerrit to identify a user. */
+  @AutoValue
+  public abstract static class Id implements Comparable<Id> {
     /** Parse an Account.Id out of a string representation. */
     public static Optional<Id> tryParse(String str) {
-      try {
-        return Optional.of(new Id(Integer.parseInt(str)));
-      } catch (NumberFormatException e) {
-        return Optional.empty();
-      }
+      return Optional.ofNullable(Ints.tryParse(str)).map(Account::id);
     }
 
     public static Id fromRef(String name) {
@@ -107,12 +80,12 @@
      */
     public static Id fromRefPart(String name) {
       Integer id = RefNames.parseShardedRefPart(name);
-      return id != null ? new Account.Id(id) : null;
+      return id != null ? Account.id(id) : null;
     }
 
     public static Id parseAfterShardedRefPart(String name) {
       Integer id = RefNames.parseAfterShardedRefPart(name);
-      return id != null ? new Account.Id(id) : null;
+      return id != null ? Account.id(id) : null;
     }
 
     /**
@@ -127,76 +100,64 @@
      */
     public static Id fromRefSuffix(String name) {
       Integer id = RefNames.parseRefSuffix(name);
-      return id != null ? new Account.Id(id) : null;
+      return id != null ? Account.id(id) : null;
+    }
+
+    abstract int id();
+
+    public int get() {
+      return id();
+    }
+
+    @Override
+    public final int compareTo(Id o) {
+      return Integer.compare(id(), o.id());
+    }
+
+    @Override
+    public final String toString() {
+      return Integer.toString(get());
     }
   }
 
-  private Id accountId;
+  public abstract Id id();
 
   /** Date and time the user registered with the review server. */
-  private Timestamp registeredOn;
+  public abstract Timestamp registeredOn();
 
   /** Full name of the user ("Given-name Surname" style). */
-  private String fullName;
+  @Nullable
+  public abstract String fullName();
 
   /** Email address the user prefers to be contacted through. */
-  private String preferredEmail;
+  @Nullable
+  public abstract String preferredEmail();
 
   /**
    * Is this user inactive? This is used to avoid showing some users (eg. former employees) in
    * auto-suggest.
    */
-  private boolean inactive;
+  public abstract boolean inactive();
 
   /** The user-settable status of this account (e.g. busy, OOO, available) */
-  private String status;
+  @Nullable
+  public abstract String status();
 
-  /**
-   * ID of the user branch from which the account was read, {@code null} if the account was read
-   * from ReviewDb.
-   */
-  private String metaId;
-
-  protected Account() {}
+  /** ID of the user branch from which the account was read. */
+  @Nullable
+  public abstract String metaId();
 
   /**
    * Create a new account.
    *
-   * @param newId unique id, see {@link com.google.gerrit.server.Sequences#nextAccountId()}.
+   * @param newId unique id, see {@link com.google.gerrit.server.notedb.Sequences#nextAccountId()}.
    * @param registeredOn when the account was registered.
    */
-  public Account(Account.Id newId, Timestamp registeredOn) {
-    this.accountId = newId;
-    this.registeredOn = registeredOn;
-  }
-
-  /** Get local id of this account, to link with in other entities */
-  public Account.Id getId() {
-    return accountId;
-  }
-
-  /** Get the full name of the user ("Given-name Surname" style). */
-  public String getFullName() {
-    return fullName;
-  }
-
-  /** Set the full name of the user ("Given-name Surname" style). */
-  public void setFullName(String name) {
-    if (name != null && !name.trim().isEmpty()) {
-      fullName = name.trim();
-    } else {
-      fullName = null;
-    }
-  }
-
-  /** Email address the user prefers to be contacted through. */
-  public String getPreferredEmail() {
-    return preferredEmail;
-  }
-
-  /** Set the email address the user prefers to be contacted through. */
-  public void setPreferredEmail(String addr) {
-    preferredEmail = addr;
+  public static Account.Builder builder(Account.Id newId, Timestamp registeredOn) {
+    return new AutoValue_Account.Builder()
+        .setInactive(false)
+        .setId(newId)
+        .setRegisteredOn(registeredOn);
   }
 
   /**
@@ -212,13 +173,13 @@
    *     generic string containing the accountId.
    */
   public String getName() {
-    if (fullName != null) {
-      return fullName;
+    if (fullName() != null) {
+      return fullName();
     }
-    if (preferredEmail != null) {
-      return preferredEmail;
+    if (preferredEmail() != null) {
+      return preferredEmail();
     }
-    return getName(accountId);
+    return getName(id());
   }
 
   public static String getName(Account.Id accountId) {
@@ -238,57 +199,65 @@
    * </ul>
    */
   public String getNameEmail(String anonymousCowardName) {
-    String name = fullName != null ? fullName : anonymousCowardName;
+    String name = fullName() != null ? fullName() : anonymousCowardName;
     StringBuilder b = new StringBuilder();
     b.append(name);
-    if (preferredEmail != null) {
+    if (preferredEmail() != null) {
       b.append(" <");
-      b.append(preferredEmail);
+      b.append(preferredEmail());
       b.append(">");
     } else {
       b.append(" (");
-      b.append(accountId.get());
+      b.append(id().get());
       b.append(")");
     }
     return b.toString();
   }
 
-  /** Get the date and time the user first registered. */
-  public Timestamp getRegisteredOn() {
-    return registeredOn;
-  }
-
-  public String getMetaId() {
-    return metaId;
-  }
-
-  public void setMetaId(String metaId) {
-    this.metaId = metaId;
-  }
-
   public boolean isActive() {
-    return !inactive;
+    return !inactive();
   }
 
-  public void setActive(boolean active) {
-    inactive = !active;
-  }
+  public abstract Builder toBuilder();
 
-  public String getStatus() {
-    return status;
-  }
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Id id();
 
-  public void setStatus(String status) {
-    this.status = status;
-  }
+    abstract Builder setId(Id id);
 
-  @Override
-  public boolean equals(Object o) {
-    return o instanceof Account && ((Account) o).getId().equals(getId());
-  }
+    public abstract Timestamp registeredOn();
 
-  @Override
-  public int hashCode() {
-    return getId().get();
+    abstract Builder setRegisteredOn(Timestamp registeredOn);
+
+    @Nullable
+    public abstract String fullName();
+
+    public abstract Builder setFullName(String fullName);
+
+    @Nullable
+    public abstract String preferredEmail();
+
+    public abstract Builder setPreferredEmail(String preferredEmail);
+
+    public abstract boolean inactive();
+
+    public abstract Builder setInactive(boolean inactive);
+
+    public Builder setActive(boolean active) {
+      return setInactive(!active);
+    }
+
+    @Nullable
+    public abstract String status();
+
+    public abstract Builder setStatus(String status);
+
+    @Nullable
+    public abstract String metaId();
+
+    public abstract Builder setMetaId(@Nullable String metaId);
+
+    public abstract Account build();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroup.java b/java/com/google/gerrit/reviewdb/client/AccountGroup.java
index c7dc420..4e01885 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroup.java
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroup.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.StringKey;
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /** Named group of one or more accounts, typically used for access controls. */
@@ -26,66 +26,52 @@
    * Time when the audit subsystem was implemented, used as the default value for {@link #createdOn}
    * when one couldn't be determined from the audit log.
    */
-  // Can't use Instant here because GWT. This is verified against a readable time in the tests,
-  // which don't need to compile under GWT.
-  private static final long AUDIT_CREATION_INSTANT_MS = 1244489460000L;
+  private static final Instant AUDIT_CREATION_INSTANT_MS = Instant.ofEpochMilli(1244489460000L);
 
   public static Timestamp auditCreationInstantTs() {
-    return new Timestamp(AUDIT_CREATION_INSTANT_MS);
+    return Timestamp.from(AUDIT_CREATION_INSTANT_MS);
+  }
+
+  public static NameKey nameKey(String n) {
+    return new AutoValue_AccountGroup_NameKey(n);
   }
 
   /** Group name key */
-  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class NameKey implements Comparable<NameKey> {
+    abstract String name();
 
-    @Column(id = 1)
-    protected String name;
-
-    protected NameKey() {}
-
-    public NameKey(String n) {
-      name = n;
-    }
-
-    @Override
     public String get() {
-      return name;
+      return name();
     }
 
     @Override
-    protected void set(String newValue) {
-      name = newValue;
+    public final int compareTo(NameKey o) {
+      return name().compareTo(o.name());
+    }
+
+    @Override
+    public final String toString() {
+      return KeyUtil.encode(get());
     }
   }
 
+  public static UUID uuid(String n) {
+    return new AutoValue_AccountGroup_UUID(n);
+  }
+
   /** Globally unique identifier. */
-  public static class UUID extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class UUID implements Comparable<UUID> {
+    abstract String uuid();
 
-    @Column(id = 1)
-    protected String uuid;
-
-    protected UUID() {}
-
-    public UUID(String n) {
-      uuid = n;
-    }
-
-    @Override
     public String get() {
-      return uuid;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      uuid = newValue;
+      return uuid();
     }
 
     /** Parse an {@link AccountGroup.UUID} out of a string representation. */
     public static UUID parse(String str) {
-      final UUID r = new UUID();
-      r.fromString(str);
-      return r;
+      return AccountGroup.uuid(KeyUtil.decode(str));
     }
 
     /** Parse an {@link AccountGroup.UUID} out of a ref-name. */
@@ -107,7 +93,17 @@
      */
     public static UUID fromRefPart(String refPart) {
       String uuid = RefNames.parseShardedUuidFromRefPart(refPart);
-      return uuid != null ? new AccountGroup.UUID(uuid) : null;
+      return uuid != null ? AccountGroup.uuid(uuid) : null;
+    }
+
+    @Override
+    public final int compareTo(UUID o) {
+      return uuid().compareTo(o.uuid());
+    }
+
+    @Override
+    public final String toString() {
+      return KeyUtil.encode(get());
     }
   }
 
@@ -116,61 +112,49 @@
     return uuid.get().matches("^[0-9a-f]{40}$");
   }
 
+  public static Id id(int id) {
+    return new AutoValue_AccountGroup_Id(id);
+  }
+
   /** Synthetic key to link to within the database */
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class Id {
+    abstract int id();
 
-    @Column(id = 1)
-    protected int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
     public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
+      return id();
     }
 
     /** Parse an AccountGroup.Id out of a string representation. */
     public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
+      return AccountGroup.id(Integer.parseInt(str));
+    }
+
+    @Override
+    public final String toString() {
+      return Integer.toString(get());
     }
   }
 
   /** Unique name of this group within the system. */
-  @Column(id = 1)
   protected NameKey name;
 
   /** Unique identity, to link entities as {@link #name} can change. */
-  @Column(id = 2)
   protected Id groupId;
 
   // DELETED: id = 3 (ownerGroupId)
 
   /** A textual description of the group's purpose. */
-  @Column(id = 4, length = Integer.MAX_VALUE, notNull = false)
-  protected String description;
+  @Nullable protected String description;
 
   // DELETED: id = 5 (groupType)
   // DELETED: id = 6 (externalName)
 
-  @Column(id = 7)
   protected boolean visibleToAll;
 
   // DELETED: id = 8 (emailOnlyAuthors)
 
   /** Globally unique identifier name for this group. */
-  @Column(id = 9)
   protected UUID groupUUID;
 
   /**
@@ -178,11 +162,9 @@
    *
    * <p>This can be a self-reference to indicate the group's members manage itself.
    */
-  @Column(id = 10)
   protected UUID ownerGroupUUID;
 
-  @Column(id = 11, notNull = false)
-  protected Timestamp createdOn;
+  @Nullable protected Timestamp createdOn;
 
   protected AccountGroup() {}
 
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupById.java b/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
deleted file mode 100644
index 17a205e..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-import java.util.Objects;
-
-/** Membership of an {@link AccountGroup} in an {@link AccountGroup}. */
-public final class AccountGroupById {
-  public static class Key extends CompoundKey<AccountGroup.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected AccountGroup.Id groupId;
-
-    @Column(id = 2)
-    protected AccountGroup.UUID includeUUID;
-
-    protected Key() {
-      groupId = new AccountGroup.Id();
-      includeUUID = new AccountGroup.UUID();
-    }
-
-    public Key(AccountGroup.Id g, AccountGroup.UUID u) {
-      groupId = g;
-      includeUUID = u;
-    }
-
-    @Override
-    public AccountGroup.Id getParentKey() {
-      return groupId;
-    }
-
-    public AccountGroup.Id getGroupId() {
-      return groupId;
-    }
-
-    public AccountGroup.UUID getIncludeUUID() {
-      return includeUUID;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  protected AccountGroupById() {}
-
-  public AccountGroupById(AccountGroupById.Key k) {
-    key = k;
-  }
-
-  public AccountGroupById.Key getKey() {
-    return key;
-  }
-
-  public AccountGroup.Id getGroupId() {
-    return key.groupId;
-  }
-
-  public AccountGroup.UUID getIncludeUUID() {
-    return key.includeUUID;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return (o instanceof AccountGroupById) && Objects.equals(key, ((AccountGroupById) o).key);
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName() + "{key=" + key + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
deleted file mode 100644
index 759e4f6..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
+++ /dev/null
@@ -1,172 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-import java.sql.Timestamp;
-import java.util.Objects;
-
-/** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
-public final class AccountGroupByIdAud {
-  public static class Key extends CompoundKey<AccountGroup.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected AccountGroup.Id groupId;
-
-    @Column(id = 2)
-    protected AccountGroup.UUID includeUUID;
-
-    @Column(id = 3)
-    protected Timestamp addedOn;
-
-    protected Key() {
-      groupId = new AccountGroup.Id();
-      includeUUID = new AccountGroup.UUID();
-    }
-
-    public Key(AccountGroup.Id g, AccountGroup.UUID u, Timestamp t) {
-      groupId = g;
-      includeUUID = u;
-      addedOn = t;
-    }
-
-    @Override
-    public AccountGroup.Id getParentKey() {
-      return groupId;
-    }
-
-    public AccountGroup.UUID getIncludeUUID() {
-      return includeUUID;
-    }
-
-    public Timestamp getAddedOn() {
-      return addedOn;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
-    }
-
-    @Override
-    public String toString() {
-      return "Key{"
-          + "groupId="
-          + groupId
-          + ", includeUUID="
-          + includeUUID
-          + ", addedOn="
-          + addedOn
-          + '}';
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  @Column(id = 2)
-  protected Account.Id addedBy;
-
-  @Column(id = 3, notNull = false)
-  protected Account.Id removedBy;
-
-  @Column(id = 4, notNull = false)
-  protected Timestamp removedOn;
-
-  protected AccountGroupByIdAud() {}
-
-  public AccountGroupByIdAud(final AccountGroupById m, Account.Id adder, Timestamp when) {
-    final AccountGroup.Id group = m.getGroupId();
-    final AccountGroup.UUID include = m.getIncludeUUID();
-    key = new AccountGroupByIdAud.Key(group, include, when);
-    addedBy = adder;
-  }
-
-  public AccountGroupByIdAud(AccountGroupByIdAud.Key key, Account.Id adder) {
-    this.key = key;
-    addedBy = adder;
-  }
-
-  public AccountGroupByIdAud.Key getKey() {
-    return key;
-  }
-
-  public AccountGroup.Id getGroupId() {
-    return key.getParentKey();
-  }
-
-  public AccountGroup.UUID getIncludeUUID() {
-    return key.getIncludeUUID();
-  }
-
-  public boolean isActive() {
-    return removedOn == null;
-  }
-
-  public void removed(Account.Id deleter, Timestamp when) {
-    removedBy = deleter;
-    removedOn = when;
-  }
-
-  public Account.Id getAddedBy() {
-    return addedBy;
-  }
-
-  public Timestamp getAddedOn() {
-    return key.getAddedOn();
-  }
-
-  public Account.Id getRemovedBy() {
-    return removedBy;
-  }
-
-  public Timestamp getRemovedOn() {
-    return removedOn;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof AccountGroupByIdAud)) {
-      return false;
-    }
-    AccountGroupByIdAud a = (AccountGroupByIdAud) o;
-    return Objects.equals(key, a.key)
-        && Objects.equals(addedBy, a.addedBy)
-        && Objects.equals(removedBy, a.removedBy)
-        && Objects.equals(removedOn, a.removedOn);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, addedBy, removedBy, removedOn);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName()
-        + "{"
-        + "key="
-        + key
-        + ", addedBy="
-        + addedBy
-        + ", removedBy="
-        + removedBy
-        + ", removedOn="
-        + removedOn
-        + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAudit.java b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAudit.java
new file mode 100644
index 0000000..e421d63
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAudit.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.reviewdb.client;
+
+import com.google.auto.value.AutoValue;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+/** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
+@AutoValue
+public abstract class AccountGroupByIdAudit {
+  public static Builder builder() {
+    return new AutoValue_AccountGroupByIdAudit.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder groupId(AccountGroup.Id groupId);
+
+    public abstract Builder includeUuid(AccountGroup.UUID includeUuid);
+
+    public abstract Builder addedBy(Account.Id addedBy);
+
+    public abstract Builder addedOn(Timestamp addedOn);
+
+    abstract Builder removedBy(Account.Id removedBy);
+
+    abstract Builder removedOn(Timestamp removedOn);
+
+    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+      return removedBy(removedBy).removedOn(removedOn);
+    }
+
+    public abstract AccountGroupByIdAudit build();
+  }
+
+  public abstract AccountGroup.Id groupId();
+
+  public abstract AccountGroup.UUID includeUuid();
+
+  public abstract Account.Id addedBy();
+
+  public abstract Timestamp addedOn();
+
+  public abstract Optional<Account.Id> removedBy();
+
+  public abstract Optional<Timestamp> removedOn();
+
+  public abstract Builder toBuilder();
+
+  public boolean isActive() {
+    return !removedOn().isPresent();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
deleted file mode 100644
index e1e0754..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-import java.util.Objects;
-
-/** Membership of an {@link Account} in an {@link AccountGroup}. */
-public final class AccountGroupMember {
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Account.Id accountId;
-
-    @Column(id = 2)
-    protected AccountGroup.Id groupId;
-
-    protected Key() {
-      accountId = new Account.Id();
-      groupId = new AccountGroup.Id();
-    }
-
-    public Key(Account.Id a, AccountGroup.Id g) {
-      accountId = a;
-      groupId = g;
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public AccountGroup.Id getAccountGroupId() {
-      return groupId;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {groupId};
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  protected AccountGroupMember() {}
-
-  public AccountGroupMember(AccountGroupMember.Key k) {
-    key = k;
-  }
-
-  public AccountGroupMember.Key getKey() {
-    return key;
-  }
-
-  public Account.Id getAccountId() {
-    return key.accountId;
-  }
-
-  public AccountGroup.Id getAccountGroupId() {
-    return key.groupId;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return (o instanceof AccountGroupMember) && Objects.equals(key, ((AccountGroupMember) o).key);
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName() + "{key=" + key + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
index fc7b2d8..37b57ee 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
@@ -14,164 +14,61 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
+import com.google.auto.value.AutoValue;
 import java.sql.Timestamp;
-import java.util.Objects;
+import java.util.Optional;
 
 /** Membership of an {@link Account} in an {@link AccountGroup}. */
-public final class AccountGroupMemberAudit {
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Account.Id accountId;
-
-    @Column(id = 2)
-    protected AccountGroup.Id groupId;
-
-    @Column(id = 3)
-    protected Timestamp addedOn;
-
-    protected Key() {
-      accountId = new Account.Id();
-      groupId = new AccountGroup.Id();
-    }
-
-    public Key(Account.Id a, AccountGroup.Id g, Timestamp t) {
-      accountId = a;
-      groupId = g;
-      addedOn = t;
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public AccountGroup.Id getGroupId() {
-      return groupId;
-    }
-
-    public Timestamp getAddedOn() {
-      return addedOn;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {groupId};
-    }
-
-    @Override
-    public String toString() {
-      return "Key{"
-          + "groupId="
-          + groupId
-          + ", accountId="
-          + accountId
-          + ", addedOn="
-          + addedOn
-          + '}';
-    }
+@AutoValue
+public abstract class AccountGroupMemberAudit {
+  public static Builder builder() {
+    return new AutoValue_AccountGroupMemberAudit.Builder();
   }
 
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder groupId(AccountGroup.Id groupId);
 
-  @Column(id = 2)
-  protected Account.Id addedBy;
+    public abstract Builder memberId(Account.Id accountId);
 
-  @Column(id = 3, notNull = false)
-  protected Account.Id removedBy;
+    public abstract Builder addedBy(Account.Id addedBy);
 
-  @Column(id = 4, notNull = false)
-  protected Timestamp removedOn;
+    abstract Account.Id addedBy();
 
-  protected AccountGroupMemberAudit() {}
+    public abstract Builder addedOn(Timestamp addedOn);
 
-  public AccountGroupMemberAudit(final AccountGroupMember m, Account.Id adder, Timestamp addedOn) {
-    final Account.Id who = m.getAccountId();
-    final AccountGroup.Id group = m.getAccountGroupId();
-    key = new AccountGroupMemberAudit.Key(who, group, addedOn);
-    addedBy = adder;
+    abstract Timestamp addedOn();
+
+    abstract Builder removedBy(Account.Id removedBy);
+
+    abstract Builder removedOn(Timestamp removedOn);
+
+    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+      return removedBy(removedBy).removedOn(removedOn);
+    }
+
+    public Builder removedLegacy() {
+      return removed(addedBy(), addedOn());
+    }
+
+    public abstract AccountGroupMemberAudit build();
   }
 
-  public AccountGroupMemberAudit(AccountGroupMemberAudit.Key key, Account.Id adder) {
-    this.key = key;
-    addedBy = adder;
-  }
+  public abstract AccountGroup.Id groupId();
 
-  public AccountGroupMemberAudit.Key getKey() {
-    return key;
-  }
+  public abstract Account.Id memberId();
 
-  public AccountGroup.Id getGroupId() {
-    return key.getGroupId();
-  }
+  public abstract Account.Id addedBy();
 
-  public Account.Id getMemberId() {
-    return key.getParentKey();
-  }
+  public abstract Timestamp addedOn();
+
+  public abstract Optional<Account.Id> removedBy();
+
+  public abstract Optional<Timestamp> removedOn();
+
+  public abstract Builder toBuilder();
 
   public boolean isActive() {
-    return removedOn == null;
-  }
-
-  public void removed(Account.Id deleter, Timestamp when) {
-    removedBy = deleter;
-    removedOn = when;
-  }
-
-  public void removedLegacy() {
-    removedBy = addedBy;
-    removedOn = key.addedOn;
-  }
-
-  public Account.Id getAddedBy() {
-    return addedBy;
-  }
-
-  public Timestamp getAddedOn() {
-    return key.getAddedOn();
-  }
-
-  public Account.Id getRemovedBy() {
-    return removedBy;
-  }
-
-  public Timestamp getRemovedOn() {
-    return removedOn;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof AccountGroupMemberAudit)) {
-      return false;
-    }
-    AccountGroupMemberAudit a = (AccountGroupMemberAudit) o;
-    return Objects.equals(key, a.key)
-        && Objects.equals(addedBy, a.addedBy)
-        && Objects.equals(removedBy, a.removedBy)
-        && Objects.equals(removedOn, a.removedOn);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, addedBy, removedBy, removedOn);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName()
-        + "{"
-        + "key="
-        + key
-        + ", addedBy="
-        + addedBy
-        + ", removedBy="
-        + removedBy
-        + ", removedOn="
-        + removedOn
-        + "}";
+    return !removedOn().isPresent();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupName.java b/java/com/google/gerrit/reviewdb/client/AccountGroupName.java
deleted file mode 100644
index 924f457..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupName.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-
-/** Unique name of an {@link AccountGroup}. */
-public class AccountGroupName {
-  @Column(id = 1)
-  protected AccountGroup.NameKey name;
-
-  @Column(id = 2)
-  protected AccountGroup.Id groupId;
-
-  protected AccountGroupName() {}
-
-  public AccountGroupName(AccountGroup.NameKey name, AccountGroup.Id groupId) {
-    this.name = name;
-    this.groupId = groupId;
-  }
-
-  public AccountGroupName(AccountGroup group) {
-    this(group.getNameKey(), group.getId());
-  }
-
-  public String getName() {
-    return getNameKey().get();
-  }
-
-  public AccountGroup.NameKey getNameKey() {
-    return name;
-  }
-
-  public AccountGroup.Id getId() {
-    return groupId;
-  }
-
-  public void setId(AccountGroup.Id id) {
-    groupId = id;
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java b/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java
index 765e38c..a70d254 100644
--- a/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java
+++ b/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java
@@ -40,7 +40,8 @@
   PRIVATE_BY_DEFAULT("change", "privateByDefault"),
   ENABLE_REVIEWER_BY_EMAIL("reviewer", "enableByEmail"),
   MATCH_AUTHOR_TO_COMMITTER_DATE("submit", "matchAuthorToCommitterDate"),
-  REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit");
+  REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit"),
+  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault");
 
   // Git config
   private final String section;
diff --git a/java/com/google/gerrit/reviewdb/client/Branch.java b/java/com/google/gerrit/reviewdb/client/Branch.java
deleted file mode 100644
index fd8bbfd..0000000
--- a/java/com/google/gerrit/reviewdb/client/Branch.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
-
-/** Line of development within a {@link Project}. */
-public final class Branch {
-  /** Branch name key */
-  public static class NameKey extends StringKey<Project.NameKey> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Project.NameKey projectName;
-
-    @Column(id = 2)
-    protected String branchName;
-
-    protected NameKey() {
-      projectName = new Project.NameKey();
-    }
-
-    public NameKey(Project.NameKey proj, String branchName) {
-      projectName = proj;
-      set(branchName);
-    }
-
-    public NameKey(String proj, String branchName) {
-      this(new Project.NameKey(proj), branchName);
-    }
-
-    @Override
-    public String get() {
-      return branchName;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      branchName = RefNames.fullName(newValue);
-    }
-
-    @Override
-    public Project.NameKey getParentKey() {
-      return projectName;
-    }
-
-    public String getShortName() {
-      return RefNames.shortName(get());
-    }
-  }
-
-  protected NameKey name;
-  protected RevId revision;
-  protected boolean canDelete;
-
-  protected Branch() {}
-
-  public Branch(Branch.NameKey newName) {
-    name = newName;
-  }
-
-  public Branch.NameKey getNameKey() {
-    return name;
-  }
-
-  public String getName() {
-    return name.get();
-  }
-
-  public String getShortName() {
-    return name.getShortName();
-  }
-
-  public RevId getRevision() {
-    return revision;
-  }
-
-  public void setRevision(RevId id) {
-    revision = id;
-  }
-
-  public boolean getCanDelete() {
-    return canDelete;
-  }
-
-  public void setCanDelete(boolean canDelete) {
-    this.canDelete = canDelete;
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/BranchNameKey.java b/java/com/google/gerrit/reviewdb/client/BranchNameKey.java
new file mode 100644
index 0000000..c4ef4c5
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/BranchNameKey.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.reviewdb.client;
+
+import com.google.auto.value.AutoValue;
+
+/** Branch name key */
+@AutoValue
+public abstract class BranchNameKey implements Comparable<BranchNameKey> {
+  public static BranchNameKey create(Project.NameKey projectName, String branchName) {
+    return new AutoValue_BranchNameKey(projectName, RefNames.fullName(branchName));
+  }
+
+  public static BranchNameKey create(String projectName, String branchName) {
+    return create(Project.nameKey(projectName), branchName);
+  }
+
+  public abstract Project.NameKey project();
+
+  public abstract String branch();
+
+  public String shortName() {
+    return RefNames.shortName(branch());
+  }
+
+  @Override
+  public final int compareTo(BranchNameKey o) {
+    // TODO(dborowitz): Only compares branch name in order to match old StringKey behavior.
+    // Consider comparing project name first.
+    return branch().compareTo(o.branch());
+  }
+
+  @Override
+  public final String toString() {
+    return project() + "," + KeyUtil.encode(branch());
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/Change.java b/java/com/google/gerrit/reviewdb/client/Change.java
index 201315e..9a3672f 100644
--- a/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/java/com/google/gerrit/reviewdb/client/Change.java
@@ -14,18 +14,18 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.RowVersion;
-import com.google.gwtorm.client.StringKey;
 import java.sql.Timestamp;
 import java.util.Arrays;
 
 /**
- * A change proposed to be merged into a {@link Branch}.
+ * A change proposed to be merged into a branch.
  *
  * <p>The data graph rooted below a Change can be quite complex:
  *
@@ -94,46 +94,17 @@
  * notice of a replacement patch set is sent, or when notice of the change submission occurs.
  */
 public final class Change {
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  public static Id id(int id) {
+    return new AutoValue_Change_Id(id);
+  }
 
-    @Column(id = 1)
-    public int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
-    public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
-    }
-
-    public String toRefPrefix() {
-      return refPrefixBuilder().toString();
-    }
-
-    StringBuilder refPrefixBuilder() {
-      StringBuilder r = new StringBuilder(32).append(REFS_CHANGES);
-      int m = id % 100;
-      if (m < 10) {
-        r.append('0');
-      }
-      return r.append(m).append('/').append(id).append('/');
-    }
-
+  @AutoValue
+  public abstract static class Id {
     /** Parse a Change.Id out of a string representation. */
     public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
+      Integer id = Ints.tryParse(str);
+      checkArgument(id != null, "invalid change ID: %s", str);
+      return Change.id(id);
     }
 
     public static Id fromRef(String ref) {
@@ -148,7 +119,7 @@
       if (ref.substring(ce).equals(RefNames.META_SUFFIX)
           || ref.substring(ce).equals(RefNames.ROBOT_COMMENTS_SUFFIX)
           || PatchSet.Id.fromRef(ref, ce) >= 0) {
-        return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
+        return Change.id(Integer.parseInt(ref.substring(cs, ce)));
       }
       return null;
     }
@@ -171,7 +142,7 @@
       }
       int ce = nextNonDigit(ref, cs);
       if (ce < ref.length() && ref.charAt(ce) == '/' && isNumeric(ref, ce + 1)) {
-        return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
+        return Change.id(Integer.parseInt(ref.substring(cs, ce)));
       }
       return null;
     }
@@ -193,14 +164,14 @@
       int endChangeId = nextNonDigit(ref, startChangeId);
       String id = ref.substring(startChangeId, endChangeId);
       if (id != null && !id.isEmpty()) {
-        return new Change.Id(Integer.parseInt(id));
+        return Change.id(Integer.parseInt(id));
       }
       return null;
     }
 
     public static Id fromRefPart(String ref) {
       Integer id = RefNames.parseShardedRefPart(ref);
-      return id != null ? new Change.Id(id) : null;
+      return id != null ? Change.id(id) : null;
     }
 
     static int startIndex(String ref) {
@@ -251,29 +222,52 @@
       }
       return i;
     }
+
+    abstract int id();
+
+    public int get() {
+      return id();
+    }
+
+    public String toRefPrefix() {
+      return refPrefixBuilder().toString();
+    }
+
+    StringBuilder refPrefixBuilder() {
+      StringBuilder r = new StringBuilder(32).append(REFS_CHANGES);
+      int m = get() % 100;
+      if (m < 10) {
+        r.append('0');
+      }
+      return r.append(m).append('/').append(get()).append('/');
+    }
+
+    @Override
+    public final String toString() {
+      return Integer.toString(get());
+    }
   }
 
-  /** Globally unique identification of this change. */
-  public static class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  public static Key key(String key) {
+    return new AutoValue_Change_Key(key);
+  }
 
-    @Column(id = 1, length = 60)
-    protected String id;
-
-    protected Key() {}
-
-    public Key(String id) {
-      this.id = id;
+  /**
+   * 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.
+   */
+  @AutoValue
+  public abstract static class Key {
+    // TODO(dborowitz): This hardly seems worth it: why would someone pass a URL-encoded change key?
+    // Ideally the standard key() factory method would enforce the format and throw IAE.
+    public static Key parse(String str) {
+      return Change.key(KeyUtil.decode(str));
     }
 
-    @Override
+    abstract String key();
+
     public String get() {
-      return id;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      id = newValue;
+      return key();
     }
 
     /** Construct a key that is after all keys prefixed by this key. */
@@ -281,7 +275,7 @@
       final StringBuilder revEnd = new StringBuilder(get().length() + 1);
       revEnd.append(get());
       revEnd.append('\u9fa5');
-      return new Key(revEnd.toString());
+      return Change.key(revEnd.toString());
     }
 
     /** Obtain a shorter version of this key string, using a leading prefix. */
@@ -290,11 +284,9 @@
       return s.substring(0, Math.min(s.length(), 9));
     }
 
-    /** Parse a Change.Key out of a string representation. */
-    public static Key parse(String str) {
-      final Key r = new Key();
-      r.fromString(str);
-      return r;
+    @Override
+    public final String toString() {
+      return get();
     }
   }
 
@@ -425,20 +417,15 @@
   }
 
   /** Locally assigned unique identifier of the change */
-  @Column(id = 1)
   protected Id changeId;
 
   /** Globally assigned unique identifier of the change */
-  @Column(id = 2)
   protected Key changeKey;
 
   /** optimistic locking */
-  @Column(id = 3)
-  @RowVersion
   protected int rowVersion;
 
   /** When this change was first introduced into the database. */
-  @Column(id = 4)
   protected Timestamp createdOn;
 
   /**
@@ -446,37 +433,30 @@
    *
    * <p>Note, this update timestamp includes its children.
    */
-  @Column(id = 5)
   protected Timestamp lastUpdatedOn;
 
   // DELETED: id = 6 (sortkey)
 
-  @Column(id = 7, name = "owner_account_id")
   protected Account.Id owner;
 
   /** The branch (and project) this change merges into. */
-  @Column(id = 8)
-  protected Branch.NameKey dest;
+  protected BranchNameKey dest;
 
   // DELETED: id = 9 (open)
 
   /** Current state code; see {@link Status}. */
-  @Column(id = 10)
   protected char status;
 
   // DELETED: id = 11 (nbrPatchSets)
 
   /** The current patch set. */
-  @Column(id = 12)
   protected int currentPatchSetId;
 
   /** Subject from the current patch set. */
-  @Column(id = 13)
   protected String subject;
 
   /** Topic name assigned by the user, if any. */
-  @Column(id = 14, notNull = false)
-  protected String topic;
+  @Nullable protected String topic;
 
   // DELETED: id = 15 (lastSha1MergeTested)
   // DELETED: id = 16 (mergeable)
@@ -487,39 +467,28 @@
    * <p>Unlike {@link #subject}, this string does not change if future patch sets change the first
    * line.
    */
-  @Column(id = 17, notNull = false)
-  protected String originalSubject;
+  @Nullable protected String originalSubject;
 
   /**
    * Unique id for the changes submitted together assigned during merging. Only set if the status is
    * MERGED.
    */
-  @Column(id = 18, notNull = false)
-  protected String submissionId;
+  @Nullable protected String submissionId;
 
   /** Allows assigning a change to a user. */
-  @Column(id = 19, notNull = false)
-  protected Account.Id assignee;
+  @Nullable protected Account.Id assignee;
 
   /** Whether the change is private. */
-  @Column(id = 20)
   protected boolean isPrivate;
 
   /** Whether the change is work in progress. */
-  @Column(id = 21)
   protected boolean workInProgress;
 
   /** Whether the change has started review. */
-  @Column(id = 22)
   protected boolean reviewStarted;
 
   /** References a change that this change reverts. */
-  @Column(id = 23, notNull = false)
-  protected Id revertOf;
-
-  /** @see com.google.gerrit.server.notedb.NoteDbChangeState */
-  @Column(id = 101, notNull = false, length = Integer.MAX_VALUE)
-  protected String noteDbState;
+  @Nullable protected Id revertOf;
 
   protected Change() {}
 
@@ -527,7 +496,7 @@
       Change.Key newKey,
       Change.Id newId,
       Account.Id ownedBy,
-      Branch.NameKey forBranch,
+      BranchNameKey forBranch,
       Timestamp ts) {
     changeKey = newKey;
     changeId = newId;
@@ -556,7 +525,6 @@
     isPrivate = other.isPrivate;
     workInProgress = other.workInProgress;
     reviewStarted = other.reviewStarted;
-    noteDbState = other.noteDbState;
     revertOf = other.revertOf;
   }
 
@@ -615,16 +583,16 @@
     this.owner = owner;
   }
 
-  public Branch.NameKey getDest() {
+  public BranchNameKey getDest() {
     return dest;
   }
 
-  public void setDest(Branch.NameKey dest) {
+  public void setDest(BranchNameKey dest) {
     this.dest = dest;
   }
 
   public Project.NameKey getProject() {
-    return dest.getParentKey();
+    return dest.project();
   }
 
   public String getSubject() {
@@ -642,7 +610,7 @@
   /** Get the id of the most current {@link PatchSet} in this change. */
   public PatchSet.Id currentPatchSetId() {
     if (currentPatchSetId > 0) {
-      return new PatchSet.Id(changeId, currentPatchSetId);
+      return PatchSet.id(changeId, currentPatchSetId);
     }
     return null;
   }
@@ -665,7 +633,7 @@
   }
 
   public void setCurrentPatchSet(PatchSet.Id psId, String subject, String originalSubject) {
-    if (!psId.getParentKey().equals(changeId)) {
+    if (!psId.changeId().equals(changeId)) {
       throw new IllegalArgumentException("patch set ID " + psId + " is not for change " + changeId);
     }
     currentPatchSetId = psId.get();
@@ -695,6 +663,22 @@
     status = newStatus.getCode();
   }
 
+  public boolean isNew() {
+    return getStatus().equals(Status.NEW);
+  }
+
+  public boolean isMerged() {
+    return getStatus().equals(Status.MERGED);
+  }
+
+  public boolean isAbandoned() {
+    return getStatus().equals(Status.ABANDONED);
+  }
+
+  public boolean isClosed() {
+    return isAbandoned() || isMerged();
+  }
+
   public String getTopic() {
     return topic;
   }
@@ -735,14 +719,6 @@
     return this.revertOf;
   }
 
-  public String getNoteDbState() {
-    return noteDbState;
-  }
-
-  public void setNoteDbState(String state) {
-    noteDbState = state;
-  }
-
   @Override
   public String toString() {
     return new StringBuilder(getClass().getSimpleName())
diff --git a/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
index 8e397f0..cc9c35e 100644
--- a/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
+++ b/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
@@ -14,73 +14,43 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
 import java.util.Objects;
 
 /** A message attached to a {@link Change}. */
 public final class ChangeMessage {
-  public static class Key extends StringKey<Change.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Change.Id changeId;
-
-    @Column(id = 2, length = 40)
-    protected String uuid;
-
-    protected Key() {
-      changeId = new Change.Id();
-    }
-
-    public Key(Change.Id change, String uuid) {
-      this.changeId = change;
-      this.uuid = uuid;
-    }
-
-    @Override
-    public Change.Id getParentKey() {
-      return changeId;
-    }
-
-    @Override
-    public String get() {
-      return uuid;
-    }
-
-    @Override
-    public void set(String newValue) {
-      uuid = newValue;
-    }
+  public static Key key(Change.Id changeId, String uuid) {
+    return new AutoValue_ChangeMessage_Key(changeId, uuid);
   }
 
-  @Column(id = 1, name = Column.NONE)
+  @AutoValue
+  public abstract static class Key {
+    public abstract Change.Id changeId();
+
+    public abstract String uuid();
+  }
+
   protected Key key;
 
   /** Who wrote this comment; null if it was written by the Gerrit system. */
-  @Column(id = 2, name = "author_id", notNull = false)
-  protected Account.Id author;
+  @Nullable protected Account.Id author;
 
   /** When this comment was drafted. */
-  @Column(id = 3)
   protected Timestamp writtenOn;
 
   /** The text left by the user. */
-  @Column(id = 4, notNull = false, length = Integer.MAX_VALUE)
-  protected String message;
+  @Nullable protected String message;
 
   /** Which patchset (if any) was this message generated from? */
-  @Column(id = 5, notNull = false)
-  protected PatchSet.Id patchset;
+  @Nullable protected PatchSet.Id patchset;
 
   /** Tag associated with change message */
-  @Column(id = 6, notNull = false)
-  protected String tag;
+  @Nullable protected String tag;
 
   /** Real user that added this message on behalf of the user recorded in {@link #author}. */
-  @Column(id = 7, notNull = false)
-  protected Account.Id realAuthor;
+  @Nullable protected Account.Id realAuthor;
 
   protected ChangeMessage() {}
 
diff --git a/java/com/google/gerrit/reviewdb/client/Comment.java b/java/com/google/gerrit/reviewdb/client/Comment.java
index 207643e..94e7583 100644
--- a/java/com/google/gerrit/reviewdb/client/Comment.java
+++ b/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -14,9 +14,14 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
 import java.util.Comparator;
 import java.util.Objects;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * This class represents inline comments in NoteDb. This means it determines the JSON format for
@@ -25,7 +30,7 @@
  * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
  * require a corresponding data migration (adding new optional fields is generally okay).
  *
- * <p>{@link PatchLineComment} also represents inline comments, but in ReviewDb. There are a few
+ * <p>{@link PatchLineComment} historically represented comments in ReviewDb. There are a few
  * notable differences:
  *
  * <ul>
@@ -41,9 +46,9 @@
  * </ul>
  *
  * <p>For all utility classes and middle layer functionality using Comment over PatchLineComment is
- * preferred, as PatchLineComment will go away together with ReviewDb. This means Comment should be
- * used everywhere and only for storing inline comment in ReviewDb a conversion to PatchLineComment
- * is done. Converting Comments to PatchLineComments and vice verse is done by
+ * preferred, as ReviewDb is gone so PatchLineComment is slated for deletion as well. This means
+ * Comment should be used everywhere and only for storing inline comment in ReviewDb a conversion to
+ * PatchLineComment is done. Converting Comments to PatchLineComments and vice verse is done by
  * CommentsUtil#toPatchLineComments(Change.Id, PatchLineComment.Status, Iterable) and
  * CommentsUtil#toComments(String, Iterable).
  */
@@ -65,17 +70,10 @@
 
     @Override
     public String toString() {
-      return new StringBuilder()
-          .append("Comment.Key{")
-          .append("uuid=")
-          .append(uuid)
-          .append(',')
-          .append("filename=")
-          .append(filename)
-          .append(',')
-          .append("patchSetId=")
-          .append(patchSetId)
-          .append('}')
+      return MoreObjects.toStringHelper(this)
+          .add("uuid", uuid)
+          .add("filename", filename)
+          .add("patchSetId", patchSetId)
           .toString();
     }
 
@@ -104,7 +102,7 @@
     }
 
     public Account.Id getId() {
-      return new Account.Id(id);
+      return Account.id(id);
     }
 
     @Override
@@ -122,12 +120,7 @@
 
     @Override
     public String toString() {
-      return new StringBuilder()
-          .append("Comment.Identity{")
-          .append("id=")
-          .append(id)
-          .append('}')
-          .toString();
+      return MoreObjects.toStringHelper(this).add("id", id).toString();
     }
   }
 
@@ -177,20 +170,11 @@
 
     @Override
     public String toString() {
-      return new StringBuilder()
-          .append("Comment.Range{")
-          .append("startLine=")
-          .append(startLine)
-          .append(',')
-          .append("startChar=")
-          .append(startChar)
-          .append(',')
-          .append("endLine=")
-          .append(endLine)
-          .append(',')
-          .append("endChar=")
-          .append(endChar)
-          .append('}')
+      return MoreObjects.toStringHelper(this)
+          .add("startLine", startLine)
+          .add("startChar", startChar)
+          .add("endLine", endLine)
+          .add("endChar", endChar)
           .toString();
     }
 
@@ -201,7 +185,9 @@
   }
 
   public Key key;
+  /** The line number (1-based) to which the comment refers, or 0 for a file comment. */
   public int lineNbr;
+
   public Identity author;
   protected Identity realAuthor;
   public Timestamp writtenOn;
@@ -211,8 +197,12 @@
   public Range range;
   public String tag;
 
-  // Hex commit SHA1 of the commit of the patchset to which this comment applies.
-  public String revId;
+  // Hex commit SHA1 of the commit of the patchset to which this comment applies. Other classes call
+  // this "commitId", but this class uses the old ReviewDb term "revId", and this field name is
+  // serialized into JSON in NoteDb, so it can't easily be changed. Callers do not access this field
+  // directly, and instead use the public getter/setter that wraps an ObjectId.
+  private String revId;
+
   public String serverId;
   public boolean unresolved;
 
@@ -269,8 +259,13 @@
     this.range = range != null ? range.asCommentRange() : null;
   }
 
-  public void setRevId(RevId revId) {
-    this.revId = revId != null ? revId.get() : null;
+  @Nullable
+  public ObjectId getCommitId() {
+    return revId != null ? ObjectId.fromString(revId) : null;
+  }
+
+  public void setCommitId(@Nullable AnyObjectId commitId) {
+    this.revId = commitId != null ? commitId.name() : null;
   }
 
   public void setRealAuthor(Account.Id id) {
@@ -283,57 +278,61 @@
 
   @Override
   public boolean equals(Object o) {
-    if (o instanceof Comment) {
-      return Objects.equals(key, ((Comment) o).key);
+    if (!(o instanceof Comment)) {
+      return false;
     }
-    return false;
+    Comment c = (Comment) o;
+    return Objects.equals(key, c.key)
+        && lineNbr == c.lineNbr
+        && Objects.equals(author, c.author)
+        && Objects.equals(realAuthor, c.realAuthor)
+        && Objects.equals(writtenOn, c.writtenOn)
+        && side == c.side
+        && Objects.equals(message, c.message)
+        && Objects.equals(parentUuid, c.parentUuid)
+        && Objects.equals(range, c.range)
+        && Objects.equals(tag, c.tag)
+        && Objects.equals(revId, c.revId)
+        && Objects.equals(serverId, c.serverId)
+        && unresolved == c.unresolved;
   }
 
   @Override
   public int hashCode() {
-    return key.hashCode();
+    return Objects.hash(
+        key,
+        lineNbr,
+        author,
+        realAuthor,
+        writtenOn,
+        side,
+        message,
+        parentUuid,
+        range,
+        tag,
+        revId,
+        serverId,
+        unresolved);
   }
 
   @Override
   public String toString() {
-    return new StringBuilder()
-        .append("Comment{")
-        .append("key=")
-        .append(key)
-        .append(',')
-        .append("lineNbr=")
-        .append(lineNbr)
-        .append(',')
-        .append("author=")
-        .append(author.getId().get())
-        .append(',')
-        .append("realAuthor=")
-        .append(realAuthor != null ? realAuthor.getId().get() : "")
-        .append(',')
-        .append("writtenOn=")
-        .append(writtenOn.toString())
-        .append(',')
-        .append("side=")
-        .append(side)
-        .append(',')
-        .append("message=")
-        .append(Objects.toString(message, ""))
-        .append(',')
-        .append("parentUuid=")
-        .append(Objects.toString(parentUuid, ""))
-        .append(',')
-        .append("range=")
-        .append(Objects.toString(range, ""))
-        .append(',')
-        .append("revId=")
-        .append(revId != null ? revId : "")
-        .append(',')
-        .append("tag=")
-        .append(Objects.toString(tag, ""))
-        .append(',')
-        .append("unresolved=")
-        .append(unresolved)
-        .append('}')
-        .toString();
+    return toStringHelper().toString();
+  }
+
+  protected ToStringHelper toStringHelper() {
+    return MoreObjects.toStringHelper(this)
+        .add("key", key)
+        .add("lineNbr", lineNbr)
+        .add("author", author.getId())
+        .add("realAuthor", realAuthor != null ? realAuthor.getId() : "")
+        .add("writtenOn", writtenOn)
+        .add("side", side)
+        .add("message", Objects.toString(message, ""))
+        .add("parentUuid", Objects.toString(parentUuid, ""))
+        .add("range", Objects.toString(range, ""))
+        .add("revId", Objects.toString(revId, ""))
+        .add("tag", Objects.toString(tag, ""))
+        .add("unresolved", unresolved);
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/CommentRange.java b/java/com/google/gerrit/reviewdb/client/CommentRange.java
index b9da8d5..e6c5078 100644
--- a/java/com/google/gerrit/reviewdb/client/CommentRange.java
+++ b/java/com/google/gerrit/reviewdb/client/CommentRange.java
@@ -14,29 +14,24 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
-
 public class CommentRange {
 
-  @Column(id = 1)
   protected int startLine;
 
-  @Column(id = 2)
   protected int startCharacter;
 
-  @Column(id = 3)
   protected int endLine;
 
-  @Column(id = 4)
   protected int endCharacter;
 
   protected CommentRange() {}
 
   public CommentRange(int sl, int sc, int el, int ec) {
-    startLine = sl;
-    startCharacter = sc;
-    endLine = el;
-    endCharacter = ec;
+    // Start position is inclusive; end position is exclusive.
+    startLine = sl; // 1-based
+    startCharacter = sc; // 0-based
+    endLine = el; // 1-based
+    endCharacter = ec; // 0-based
   }
 
   public int getStartLine() {
diff --git a/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java b/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
deleted file mode 100644
index 6a3b69c..0000000
--- a/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
-
-/** Current version of the database schema, to facilitate live upgrades. */
-public final class CurrentSchemaVersion {
-  public static final class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    private static final String VALUE = "X";
-
-    @Column(id = 1, length = 1)
-    public String one = VALUE;
-
-    public Key() {}
-
-    @Override
-    public String get() {
-      return VALUE;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      assert get().equals(newValue);
-    }
-  }
-
-  /** Construct a new, unconfigured instance. */
-  public static CurrentSchemaVersion create() {
-    final CurrentSchemaVersion r = new CurrentSchemaVersion();
-    r.singleton = new CurrentSchemaVersion.Key();
-    return r;
-  }
-
-  @Column(id = 1)
-  public Key singleton;
-
-  /** Current version number of the schema. */
-  @Column(id = 2)
-  public transient int versionNbr;
-
-  public CurrentSchemaVersion() {}
-}
diff --git a/java/com/google/gerrit/reviewdb/client/KeyUtil.java b/java/com/google/gerrit/reviewdb/client/KeyUtil.java
new file mode 100644
index 0000000..c6539a3
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/KeyUtil.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.reviewdb.client;
+
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+
+public class KeyUtil {
+  private static final char[] hexc = {
+    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
+  };
+  private static final char safe[];
+  private static final byte hexb[];
+
+  static {
+    safe = new char[256];
+    safe['-'] = '-';
+    safe['_'] = '_';
+    safe['.'] = '.';
+    safe['!'] = '!';
+    safe['~'] = '~';
+    safe['*'] = '*';
+    safe['\''] = '\'';
+    safe['('] = '(';
+    safe[')'] = ')';
+    safe['/'] = '/';
+    safe[' '] = '+';
+    for (char c = '0'; c <= '9'; c++) safe[c] = c;
+    for (char c = 'A'; c <= 'Z'; c++) safe[c] = c;
+    for (char c = 'a'; c <= 'z'; c++) safe[c] = c;
+
+    hexb = new byte['f' + 1];
+    Arrays.fill(hexb, (byte) -1);
+    for (char i = '0'; i <= '9'; i++) hexb[i] = (byte) (i - '0');
+    for (char i = 'A'; i <= 'F'; i++) hexb[i] = (byte) ((i - 'A') + 10);
+    for (char i = 'a'; i <= 'f'; i++) hexb[i] = (byte) ((i - 'a') + 10);
+  }
+
+  public static String encode(final String e) {
+    final byte[] b;
+    try {
+      b = e.getBytes("UTF-8");
+    } catch (UnsupportedEncodingException e1) {
+      throw new RuntimeException("No UTF-8 support", e1);
+    }
+
+    final StringBuilder r = new StringBuilder(b.length);
+    for (int i = 0; i < b.length; i++) {
+      final int c = b[i] & 0xff;
+      final char s = safe[c];
+      if (s == 0) {
+        r.append('%');
+        r.append(hexc[c >> 4]);
+        r.append(hexc[c & 15]);
+      } else {
+        r.append(s);
+      }
+    }
+    return r.toString();
+  }
+
+  public static String decode(final String e) {
+    if (e.indexOf('%') < 0) {
+      return e.replace('+', ' ');
+    }
+
+    final byte[] b = new byte[e.length()];
+    int bPtr = 0;
+    try {
+      for (int i = 0; i < e.length(); ) {
+        final char c = e.charAt(i);
+        if (c == '%' && i + 2 < e.length()) {
+          final int v = (hexb[e.charAt(i + 1)] << 4) | hexb[e.charAt(i + 2)];
+          if (v < 0) {
+            throw new IllegalArgumentException(e.substring(i, i + 3));
+          }
+          b[bPtr++] = (byte) v;
+          i += 3;
+        } else if (c == '+') {
+          b[bPtr++] = ' ';
+          i++;
+        } else {
+          b[bPtr++] = (byte) c;
+          i++;
+        }
+      }
+    } catch (ArrayIndexOutOfBoundsException err) {
+      throw new IllegalArgumentException("Bad encoding: " + e);
+    }
+    try {
+      return new String(b, 0, bPtr, "UTF-8");
+    } catch (UnsupportedEncodingException e1) {
+      throw new RuntimeException("No UTF-8 support", e1);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/LabelId.java b/java/com/google/gerrit/reviewdb/client/LabelId.java
index e69cab2..31056c4 100644
--- a/java/com/google/gerrit/reviewdb/client/LabelId.java
+++ b/java/com/google/gerrit/reviewdb/client/LabelId.java
@@ -14,34 +14,23 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
+import com.google.auto.value.AutoValue;
 
-public class LabelId extends StringKey<com.google.gwtorm.client.Key<?>> {
-  private static final long serialVersionUID = 1L;
-
+@AutoValue
+public abstract class LabelId {
   static final String LEGACY_SUBMIT_NAME = "SUBM";
 
+  public static LabelId create(String n) {
+    return new AutoValue_LabelId(n);
+  }
+
   public static LabelId legacySubmit() {
-    return new LabelId(LEGACY_SUBMIT_NAME);
+    return create(LEGACY_SUBMIT_NAME);
   }
 
-  @Column(id = 1)
-  public String id;
+  abstract String id();
 
-  public LabelId() {}
-
-  public LabelId(String n) {
-    id = n;
-  }
-
-  @Override
   public String get() {
-    return id;
-  }
-
-  @Override
-  protected void set(String newValue) {
-    id = newValue;
+    return id();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/Patch.java b/java/com/google/gerrit/reviewdb/client/Patch.java
index 0492c6c..0f7e4cf 100644
--- a/java/com/google/gerrit/reviewdb/client/Patch.java
+++ b/java/com/google/gerrit/reviewdb/client/Patch.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.primitives.Ints;
+import java.util.List;
 
 /** A single modified file in a {@link PatchSet}. */
 public final class Patch {
@@ -36,49 +40,30 @@
     return COMMIT_MSG.equals(path) || MERGE_LIST.equals(path);
   }
 
-  public static class Key extends StringKey<PatchSet.Id> {
-    private static final long serialVersionUID = 1L;
+  public static Key key(PatchSet.Id patchSetId, String fileName) {
+    return new AutoValue_Patch_Key(patchSetId, fileName);
+  }
 
-    @Column(id = 1, name = Column.NONE)
-    protected PatchSet.Id patchSetId;
-
-    @Column(id = 2)
-    protected String fileName;
-
-    protected Key() {
-      patchSetId = new PatchSet.Id();
-    }
-
-    public Key(PatchSet.Id ps, String name) {
-      this.patchSetId = ps;
-      this.fileName = name;
-    }
-
-    @Override
-    public PatchSet.Id getParentKey() {
-      return patchSetId;
-    }
-
-    @Override
-    public String get() {
-      return fileName;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      fileName = newValue;
-    }
-
-    /** Parse a Patch.Id out of a string representation. */
+  @AutoValue
+  public abstract static class Key {
+    /** Parse a Patch.Key out of a string representation. */
     public static Key parse(String str) {
-      final Key r = new Key();
-      r.fromString(str);
-      return r;
+      List<String> parts = Splitter.on(',').limit(3).splitToList(str);
+      checkKeyFormat(parts.size() == 3, str);
+      Integer changeId = Ints.tryParse(parts.get(0));
+      checkKeyFormat(changeId != null, str);
+      Integer patchSetNum = Ints.tryParse(parts.get(1));
+      checkKeyFormat(patchSetNum != null, str);
+      return key(PatchSet.id(Change.id(changeId), patchSetNum), parts.get(2));
     }
 
-    public String getFileName() {
-      return get();
+    private static void checkKeyFormat(boolean test, String input) {
+      checkArgument(test, "invalid patch key: %s", input);
     }
+
+    public abstract PatchSet.Id patchSetId();
+
+    public abstract String fileName();
   }
 
   /** Type of modification made to the file path. */
@@ -262,7 +247,7 @@
   }
 
   public String getFileName() {
-    return key.fileName;
+    return key.fileName();
   }
 
   public String getSourceFileName() {
diff --git a/java/com/google/gerrit/reviewdb/client/PatchLineComment.java b/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
index de953dc..5dbe68f 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
@@ -14,66 +14,21 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Comment.Range;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
 import java.sql.Timestamp;
 import java.util.Objects;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * A comment left by a user on a specific line of a {@link Patch}.
  *
- * <p>This class represents an inline comment in ReviewDb. It should only be used for
- * writing/reading inline comments to/from ReviewDb. For all other purposes inline comments should
- * be represented by {@link Comment}.
+ * <p>New APIs should not expose this class.
  *
  * @see Comment
  */
 public final class PatchLineComment {
-  public static class Key extends StringKey<Patch.Key> {
-    private static final long serialVersionUID = 1L;
-
-    public static Key from(Change.Id changeId, Comment.Key key) {
-      return new Key(
-          new Patch.Key(new PatchSet.Id(changeId, key.patchSetId), key.filename), key.uuid);
-    }
-
-    @Column(id = 1, name = Column.NONE)
-    protected Patch.Key patchKey;
-
-    @Column(id = 2, length = 40)
-    protected String uuid;
-
-    protected Key() {
-      patchKey = new Patch.Key();
-    }
-
-    public Key(Patch.Key p, String uuid) {
-      this.patchKey = p;
-      this.uuid = uuid;
-    }
-
-    @Override
-    public Patch.Key getParentKey() {
-      return patchKey;
-    }
-
-    @Override
-    public String get() {
-      return uuid;
-    }
-
-    @Override
-    public void set(String newValue) {
-      uuid = newValue;
-    }
-
-    public Comment.Key asCommentKey() {
-      return new Comment.Key(
-          get(), getParentKey().getFileName(), getParentKey().getParentKey().get());
-    }
-  }
-
   public static final char STATUS_DRAFT = 'd';
   public static final char STATUS_PUBLISHED = 'P';
 
@@ -104,12 +59,10 @@
 
   public static PatchLineComment from(
       Change.Id changeId, PatchLineComment.Status status, Comment c) {
-    PatchLineComment.Key key =
-        new PatchLineComment.Key(
-            new Patch.Key(new PatchSet.Id(changeId, c.key.patchSetId), c.key.filename), c.key.uuid);
-
+    Patch.Key patchKey = Patch.key(PatchSet.id(changeId, c.key.patchSetId), c.key.filename);
     PatchLineComment plc =
-        new PatchLineComment(key, c.lineNbr, c.author.getId(), c.parentUuid, c.writtenOn);
+        new PatchLineComment(
+            patchKey, c.key.uuid, c.lineNbr, c.author.getId(), c.parentUuid, c.writtenOn);
     plc.setSide(c.side);
     plc.setMessage(c.message);
     if (c.range != null) {
@@ -117,71 +70,57 @@
       plc.setRange(new CommentRange(r.startLine, r.startChar, r.endLine, r.endChar));
     }
     plc.setTag(c.tag);
-    plc.setRevId(new RevId(c.revId));
+    plc.setCommitId(c.getCommitId());
     plc.setStatus(status);
     plc.setRealAuthor(c.getRealAuthor().getId());
     plc.setUnresolved(c.unresolved);
     return plc;
   }
 
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
+  protected Patch.Key patchKey;
+
+  protected String uuid;
 
   /** Line number this comment applies to; it should display after the line. */
-  @Column(id = 2)
   protected int lineNbr;
 
   /** Who wrote this comment. */
-  @Column(id = 3, name = "author_id")
   protected Account.Id author;
 
   /** When this comment was drafted. */
-  @Column(id = 4)
   protected Timestamp writtenOn;
 
   /** Current publication state of the comment; see {@link Status}. */
-  @Column(id = 5)
   protected char status;
 
   /** Which file is this comment; 0 is ancestor, 1 is new version. */
-  @Column(id = 6)
   protected short side;
 
   /** The text left by the user. */
-  @Column(id = 7, notNull = false, length = Integer.MAX_VALUE)
-  protected String message;
+  @Nullable protected String message;
 
   /** The parent of this comment, or null if this is the first comment on this line */
-  @Column(id = 8, length = 40, notNull = false)
-  protected String parentUuid;
+  @Nullable protected String parentUuid;
 
-  @Column(id = 9, notNull = false)
-  protected CommentRange range;
+  @Nullable protected CommentRange range;
 
-  @Column(id = 10, notNull = false)
-  protected String tag;
+  @Nullable protected String tag;
 
   /** Real user that added this comment on behalf of the user recorded in {@link #author}. */
-  @Column(id = 11, notNull = false)
-  protected Account.Id realAuthor;
+  @Nullable protected Account.Id realAuthor;
 
   /** True if this comment requires further action. */
-  @Column(id = 12)
   protected boolean unresolved;
 
-  /**
-   * The RevId for the commit to which this comment is referring.
-   *
-   * <p>Note that this field is not stored in the database. It is just provided for users of this
-   * class to avoid a lookup when they don't have easy access to a ReviewDb.
-   */
-  protected RevId revId;
+  /** The ID of the commit to which this comment is referring. */
+  protected ObjectId commitId;
 
   protected PatchLineComment() {}
 
   public PatchLineComment(
-      PatchLineComment.Key id, int line, Account.Id a, String parentUuid, Timestamp when) {
-    key = id;
+      Patch.Key patchKey, String uuid, int line, Account.Id a, String parentUuid, Timestamp when) {
+    this.patchKey = patchKey;
+    this.uuid = uuid;
     lineNbr = line;
     author = a;
     setParentUuid(parentUuid);
@@ -190,7 +129,8 @@
   }
 
   public PatchLineComment(PatchLineComment o) {
-    key = o.key;
+    patchKey = o.patchKey;
+    uuid = o.uuid;
     lineNbr = o.lineNbr;
     author = o.author;
     realAuthor = o.realAuthor;
@@ -199,7 +139,7 @@
     side = o.side;
     message = o.message;
     parentUuid = o.parentUuid;
-    revId = o.revId;
+    commitId = o.commitId;
     if (o.range != null) {
       range =
           new CommentRange(
@@ -210,12 +150,8 @@
     }
   }
 
-  public PatchLineComment.Key getKey() {
-    return key;
-  }
-
   public PatchSet.Id getPatchSetId() {
-    return key.getParentKey().getParentKey();
+    return patchKey.patchSetId();
   }
 
   public int getLine() {
@@ -298,12 +234,12 @@
     return range;
   }
 
-  public void setRevId(RevId rev) {
-    revId = rev;
+  public void setCommitId(AnyObjectId commitId) {
+    this.commitId = commitId.copy();
   }
 
-  public RevId getRevId() {
-    return revId;
+  public ObjectId getCommitId() {
+    return commitId;
   }
 
   public void setTag(String tag) {
@@ -324,8 +260,15 @@
 
   public Comment asComment(String serverId) {
     Comment c =
-        new Comment(key.asCommentKey(), author, writtenOn, side, message, serverId, unresolved);
-    c.setRevId(revId);
+        new Comment(
+            new Comment.Key(uuid, patchKey.fileName(), patchKey.patchSetId().get()),
+            author,
+            writtenOn,
+            side,
+            message,
+            serverId,
+            unresolved);
+    c.setCommitId(commitId);
     c.setRange(range);
     c.lineNbr = lineNbr;
     c.parentUuid = parentUuid;
@@ -338,7 +281,8 @@
   public boolean equals(Object o) {
     if (o instanceof PatchLineComment) {
       PatchLineComment c = (PatchLineComment) o;
-      return Objects.equals(key, c.getKey())
+      return Objects.equals(patchKey, c.patchKey)
+          && Objects.equals(uuid, c.uuid)
           && Objects.equals(lineNbr, c.getLine())
           && Objects.equals(author, c.getAuthor())
           && Objects.equals(writtenOn, c.getWrittenOn())
@@ -347,7 +291,7 @@
           && Objects.equals(message, c.getMessage())
           && Objects.equals(parentUuid, c.getParentUuid())
           && Objects.equals(range, c.getRange())
-          && Objects.equals(revId, c.getRevId())
+          && Objects.equals(commitId, c.getCommitId())
           && Objects.equals(tag, c.getTag())
           && Objects.equals(unresolved, c.getUnresolved());
     }
@@ -356,14 +300,15 @@
 
   @Override
   public int hashCode() {
-    return key.hashCode();
+    return Objects.hash(patchKey, uuid);
   }
 
   @Override
   public String toString() {
     StringBuilder builder = new StringBuilder();
     builder.append("PatchLineComment{");
-    builder.append("key=").append(key).append(',');
+    builder.append("patchKey=").append(patchKey).append(',');
+    builder.append("uuid=").append(uuid).append(',');
     builder.append("lineNbr=").append(lineNbr).append(',');
     builder.append("author=").append(author.get()).append(',');
     builder.append("realAuthor=").append(realAuthor != null ? realAuthor.get() : "").append(',');
@@ -373,7 +318,7 @@
     builder.append("message=").append(Objects.toString(message, "")).append(',');
     builder.append("parentUuid=").append(Objects.toString(parentUuid, "")).append(',');
     builder.append("range=").append(Objects.toString(range, "")).append(',');
-    builder.append("revId=").append(revId != null ? revId.get() : "").append(',');
+    builder.append("revId=").append(commitId != null ? commitId.name() : "").append(',');
     builder.append("tag=").append(Objects.toString(tag, "")).append(',');
     builder.append("unresolved=").append(unresolved);
     builder.append('}');
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSet.java b/java/com/google/gerrit/reviewdb/client/PatchSet.java
index 849fd75..0c98993 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -14,16 +14,23 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.IntKey;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.common.primitives.Ints;
 import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** A single revision of a {@link Change}. */
-public final class PatchSet {
+@AutoValue
+public abstract class PatchSet {
   /** Is the reference name a change reference? */
   public static boolean isChangeRef(String name) {
     return Id.fromRef(name) != null;
@@ -39,86 +46,40 @@
     return isChangeRef(name);
   }
 
-  static String joinGroups(List<String> groups) {
-    if (groups == null) {
-      throw new IllegalArgumentException("groups may not be null");
+  public static String joinGroups(List<String> groups) {
+    requireNonNull(groups);
+    for (String group : groups) {
+      checkArgument(!group.contains(","), "group may not contain ',': %s", group);
     }
-    StringBuilder sb = new StringBuilder();
-    boolean first = true;
-    for (String g : groups) {
-      if (!first) {
-        sb.append(',');
-      } else {
-        first = false;
-      }
-      sb.append(g);
-    }
-    return sb.toString();
+    return String.join(",", groups);
   }
 
-  public static List<String> splitGroups(String joinedGroups) {
-    if (joinedGroups == null) {
-      throw new IllegalArgumentException("groups may not be null");
-    }
-    List<String> groups = new ArrayList<>();
-    int i = 0;
-    while (true) {
-      int idx = joinedGroups.indexOf(',', i);
-      if (idx < 0) {
-        groups.add(joinedGroups.substring(i, joinedGroups.length()));
-        break;
-      }
-      groups.add(joinedGroups.substring(i, idx));
-      i = idx + 1;
-    }
-    return groups;
+  public static ImmutableList<String> splitGroups(String joinedGroups) {
+    return Streams.stream(Splitter.on(',').split(joinedGroups)).collect(toImmutableList());
   }
 
-  public static class Id extends IntKey<Change.Id> {
-    private static final long serialVersionUID = 1L;
+  public static Id id(Change.Id changeId, int id) {
+    return new AutoValue_PatchSet_Id(changeId, id);
+  }
 
-    @Column(id = 1)
-    public Change.Id changeId;
-
-    @Column(id = 2)
-    public int patchSetId;
-
-    public Id() {
-      changeId = new Change.Id();
-    }
-
-    public Id(Change.Id change, int id) {
-      this.changeId = change;
-      this.patchSetId = id;
-    }
-
-    @Override
-    public Change.Id getParentKey() {
-      return changeId;
-    }
-
-    @Override
-    public int get() {
-      return patchSetId;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      patchSetId = newValue;
-    }
-
-    public String toRefName() {
-      return changeId.refPrefixBuilder().append(patchSetId).toString();
-    }
-
+  @AutoValue
+  public abstract static class Id {
     /** Parse a PatchSet.Id out of a string representation. */
     public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
+      List<String> parts = Splitter.on(',').splitToList(str);
+      checkIdFormat(parts.size() == 2, str);
+      Integer changeId = Ints.tryParse(parts.get(0));
+      checkIdFormat(changeId != null, str);
+      Integer id = Ints.tryParse(parts.get(1));
+      checkIdFormat(id != null, str);
+      return PatchSet.id(Change.id(changeId), id);
     }
 
-    /** Parse a PatchSet.Id from a {@link PatchSet#getRefName()} result. */
+    private static void checkIdFormat(boolean test, String input) {
+      checkArgument(test, "invalid patch set ID: %s", input);
+    }
+
+    /** Parse a PatchSet.Id from a {@link #refName()} result. */
     public static Id fromRef(String ref) {
       int cs = Change.Id.startIndex(ref);
       if (cs < 0) {
@@ -130,7 +91,7 @@
         return null;
       }
       int changeId = Integer.parseInt(ref.substring(cs, ce));
-      return new PatchSet.Id(new Change.Id(changeId), patchSetId);
+      return PatchSet.id(Change.id(changeId), patchSetId);
     }
 
     static int fromRef(String ref, int changeIdEnd) {
@@ -147,29 +108,96 @@
       return Integer.parseInt(ref.substring(ps));
     }
 
-    public String getId() {
-      return toId(patchSetId);
-    }
-
     public static String toId(int number) {
       return number == 0 ? "edit" : String.valueOf(number);
     }
+
+    public String getId() {
+      return toId(id());
+    }
+
+    public abstract Change.Id changeId();
+
+    abstract int id();
+
+    public int get() {
+      return id();
+    }
+
+    public String toRefName() {
+      return changeId().refPrefixBuilder().append(id()).toString();
+    }
+
+    @Override
+    public final String toString() {
+      return changeId().toString() + ',' + id();
+    }
   }
 
-  @Column(id = 1, name = Column.NONE)
-  protected Id id;
+  public static Builder builder() {
+    return new AutoValue_PatchSet.Builder().groups(ImmutableList.of());
+  }
 
-  @Column(id = 2, notNull = false)
-  protected RevId revision;
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder id(Id id);
 
-  @Column(id = 3, name = "uploader_account_id")
-  protected Account.Id uploader;
+    public abstract Id id();
 
-  /** When this patch set was first introduced onto the change. */
-  @Column(id = 4)
-  protected Timestamp createdOn;
+    public abstract Builder commitId(ObjectId commitId);
 
-  // @Column(id = 5)
+    public abstract Optional<ObjectId> commitId();
+
+    public abstract Builder uploader(Account.Id uploader);
+
+    public abstract Builder createdOn(Timestamp createdOn);
+
+    public abstract Builder groups(Iterable<String> groups);
+
+    public abstract ImmutableList<String> groups();
+
+    public abstract Builder pushCertificate(Optional<String> pushCertificate);
+
+    public abstract Builder pushCertificate(String pushCertificate);
+
+    public abstract Builder description(Optional<String> description);
+
+    public abstract Builder description(String description);
+
+    public abstract Optional<String> description();
+
+    public abstract PatchSet build();
+  }
+
+  /** ID of the patch set. */
+  public abstract Id id();
+
+  /**
+   * Commit ID of the patch set, also known as the revision.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data erroneously did not include a {@code commitId}, then this method will
+   * return {@link ObjectId#zeroId()}.
+   */
+  public abstract ObjectId commitId();
+
+  /**
+   * Account that uploaded the patch set.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data erroneously did not include an {@code uploader}, then this method will
+   * return an account ID of 0.
+   */
+  public abstract Account.Id uploader();
+
+  /**
+   * When this patch set was first introduced onto the change.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data erroneously did not include a {@code createdOn}, then this method will
+   * return a timestamp of 0.
+   */
+  public abstract Timestamp createdOn();
 
   /**
    * Opaque group identifier, usually assigned during creation.
@@ -180,128 +208,26 @@
    * <p>Changes on the same branch having patch sets with intersecting groups are considered
    * related, as in the "Related Changes" tab.
    */
-  @Column(id = 6, notNull = false, length = Integer.MAX_VALUE)
-  protected String groups;
-
-  // DELETED id = 7 (pushCertficate)
+  public abstract ImmutableList<String> groups();
 
   /** Certificate sent with a push that created this patch set. */
-  @Column(id = 8, notNull = false, length = Integer.MAX_VALUE)
-  protected String pushCertificate;
+  public abstract Optional<String> pushCertificate();
 
   /**
    * Optional user-supplied description for this patch set.
    *
-   * <p>When this field is null, the description was never set on the patch set. When this field is
-   * an empty string, the description was set and later cleared.
+   * <p>When this field is an empty {@code Optional}, the description was never set on the patch
+   * set. When this field is present but an empty string, the description was set and later cleared.
    */
-  @Column(id = 9, notNull = false, length = Integer.MAX_VALUE)
-  protected String description;
+  public abstract Optional<String> description();
 
-  protected PatchSet() {}
-
-  public PatchSet(PatchSet.Id k) {
-    id = k;
+  /** Patch set number. */
+  public int number() {
+    return id().get();
   }
 
-  public PatchSet(PatchSet src) {
-    this.id = src.id;
-    this.revision = src.revision;
-    this.uploader = src.uploader;
-    this.createdOn = src.createdOn;
-    this.groups = src.groups;
-    this.pushCertificate = src.pushCertificate;
-    this.description = src.description;
-  }
-
-  public PatchSet.Id getId() {
-    return id;
-  }
-
-  public int getPatchSetId() {
-    return id.get();
-  }
-
-  public RevId getRevision() {
-    return revision;
-  }
-
-  public void setRevision(RevId i) {
-    revision = i;
-  }
-
-  public Account.Id getUploader() {
-    return uploader;
-  }
-
-  public void setUploader(Account.Id who) {
-    uploader = who;
-  }
-
-  public Timestamp getCreatedOn() {
-    return createdOn;
-  }
-
-  public void setCreatedOn(Timestamp ts) {
-    createdOn = ts;
-  }
-
-  public List<String> getGroups() {
-    if (groups == null) {
-      return Collections.emptyList();
-    }
-    return splitGroups(groups);
-  }
-
-  public void setGroups(List<String> groups) {
-    if (groups == null) {
-      groups = Collections.emptyList();
-    }
-    this.groups = joinGroups(groups);
-  }
-
-  public String getRefName() {
-    return id.toRefName();
-  }
-
-  public String getPushCertificate() {
-    return pushCertificate;
-  }
-
-  public void setPushCertificate(String cert) {
-    pushCertificate = cert;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String description) {
-    this.description = description;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof PatchSet)) {
-      return false;
-    }
-    PatchSet p = (PatchSet) o;
-    return Objects.equals(id, p.id)
-        && Objects.equals(revision, p.revision)
-        && Objects.equals(uploader, p.uploader)
-        && Objects.equals(createdOn, p.createdOn)
-        && Objects.equals(groups, p.groups)
-        && Objects.equals(pushCertificate, p.pushCertificate)
-        && Objects.equals(description, p.description);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(id, revision, uploader, createdOn, groups, pushCertificate, description);
-  }
-
-  @Override
-  public String toString() {
-    return "[PatchSet " + getId().toString() + "]";
+  /** Name of the corresponding patch set ref. */
+  public String refName() {
+    return id().toRefName();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
index 0f3e4e1..c5c8166 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
@@ -14,59 +14,75 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
+import com.google.auto.value.AutoValue;
+import com.google.common.primitives.Shorts;
 import java.sql.Timestamp;
 import java.util.Date;
-import java.util.Objects;
+import java.util.Optional;
 
 /** An approval (or negative approval) on a patch set. */
-public final class PatchSetApproval {
-  public static class Key extends CompoundKey<PatchSet.Id> {
-    private static final long serialVersionUID = 1L;
+@AutoValue
+public abstract class PatchSetApproval {
+  public static Key key(PatchSet.Id patchSetId, Account.Id accountId, LabelId labelId) {
+    return new AutoValue_PatchSetApproval_Key(patchSetId, accountId, labelId);
+  }
 
-    @Column(id = 1, name = Column.NONE)
-    protected PatchSet.Id patchSetId;
+  @AutoValue
+  public abstract static class Key {
+    public abstract PatchSet.Id patchSetId();
 
-    @Column(id = 2)
-    protected Account.Id accountId;
+    public abstract Account.Id accountId();
 
-    @Column(id = 3)
-    protected LabelId categoryId;
+    public abstract LabelId labelId();
 
-    protected Key() {
-      patchSetId = new PatchSet.Id();
-      accountId = new Account.Id();
-      categoryId = new LabelId();
-    }
-
-    public Key(PatchSet.Id ps, Account.Id a, LabelId c) {
-      this.patchSetId = ps;
-      this.accountId = a;
-      this.categoryId = c;
-    }
-
-    @Override
-    public PatchSet.Id getParentKey() {
-      return patchSetId;
-    }
-
-    public Account.Id getAccountId() {
-      return accountId;
-    }
-
-    public LabelId getLabelId() {
-      return categoryId;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {accountId, categoryId};
+    public boolean isLegacySubmit() {
+      return LabelId.LEGACY_SUBMIT_NAME.equals(labelId().get());
     }
   }
 
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
+  public static Builder builder() {
+    return new AutoValue_PatchSetApproval.Builder().postSubmit(false);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder key(Key key);
+
+    public abstract Key key();
+
+    public abstract Builder value(short value);
+
+    public Builder value(int value) {
+      return value(Shorts.checkedCast(value));
+    }
+
+    public abstract Builder granted(Timestamp granted);
+
+    public Builder granted(Date granted) {
+      return granted(new Timestamp(granted.getTime()));
+    }
+
+    public abstract Builder tag(String tag);
+
+    public abstract Builder tag(Optional<String> tag);
+
+    public abstract Builder realAccountId(Account.Id realAccountId);
+
+    abstract Optional<Account.Id> realAccountId();
+
+    public abstract Builder postSubmit(boolean isPostSubmit);
+
+    abstract PatchSetApproval autoBuild();
+
+    public PatchSetApproval build() {
+      if (!realAccountId().isPresent()) {
+        realAccountId(key().accountId());
+      }
+      return autoBuild();
+    }
+  }
+
+  public abstract Key key();
 
   /**
    * Value assigned by the user.
@@ -84,148 +100,40 @@
    * and in the negative and positive direction a magnitude can be assumed.The further from 0 the
    * more assertive the approval.
    */
-  @Column(id = 2)
-  protected short value;
+  public abstract short value();
 
-  @Column(id = 3)
-  protected Timestamp granted;
+  public abstract Timestamp granted();
 
-  @Column(id = 6, notNull = false)
-  protected String tag;
+  public abstract Optional<String> tag();
 
   /** Real user that made this approval on behalf of the user recorded in {@link Key#accountId}. */
-  @Column(id = 7, notNull = false)
-  protected Account.Id realAccountId;
+  public abstract Account.Id realAccountId();
 
-  @Column(id = 8)
-  protected boolean postSubmit;
+  public abstract boolean postSubmit();
 
-  // DELETED: id = 4 (changeOpen)
-  // DELETED: id = 5 (changeSortKey)
+  public abstract Builder toBuilder();
 
-  protected PatchSetApproval() {}
-
-  public PatchSetApproval(PatchSetApproval.Key k, short v, Date ts) {
-    key = k;
-    setValue(v);
-    setGranted(ts);
+  public PatchSetApproval copyWithPatchSet(PatchSet.Id psId) {
+    return toBuilder().key(key(psId, key().accountId(), key().labelId())).build();
   }
 
-  public PatchSetApproval(PatchSet.Id psId, PatchSetApproval src) {
-    key = new PatchSetApproval.Key(psId, src.getAccountId(), src.getLabelId());
-    value = src.getValue();
-    granted = src.granted;
-    realAccountId = src.realAccountId;
-    tag = src.tag;
-    postSubmit = src.postSubmit;
+  public PatchSet.Id patchSetId() {
+    return key().patchSetId();
   }
 
-  public PatchSetApproval(PatchSetApproval src) {
-    this(src.getPatchSetId(), src);
+  public Account.Id accountId() {
+    return key().accountId();
   }
 
-  public PatchSetApproval.Key getKey() {
-    return key;
+  public LabelId labelId() {
+    return key().labelId();
   }
 
-  public PatchSet.Id getPatchSetId() {
-    return key.patchSetId;
-  }
-
-  public Account.Id getAccountId() {
-    return key.accountId;
-  }
-
-  public Account.Id getRealAccountId() {
-    return realAccountId != null ? realAccountId : getAccountId();
-  }
-
-  public void setRealAccountId(Account.Id id) {
-    // Use null for same real author, as before the column was added.
-    realAccountId = Objects.equals(getAccountId(), id) ? null : id;
-  }
-
-  public LabelId getLabelId() {
-    return key.categoryId;
-  }
-
-  public short getValue() {
-    return value;
-  }
-
-  public void setValue(short v) {
-    value = v;
-  }
-
-  public Timestamp getGranted() {
-    return granted;
-  }
-
-  public void setGranted(Date when) {
-    if (when instanceof Timestamp) {
-      granted = (Timestamp) when;
-    } else {
-      granted = new Timestamp(when.getTime());
-    }
-  }
-
-  public void setTag(String t) {
-    tag = t;
-  }
-
-  public String getLabel() {
-    return getLabelId().get();
+  public String label() {
+    return labelId().get();
   }
 
   public boolean isLegacySubmit() {
-    return LabelId.LEGACY_SUBMIT_NAME.equals(getLabel());
-  }
-
-  public String getTag() {
-    return tag;
-  }
-
-  public void setPostSubmit(boolean postSubmit) {
-    this.postSubmit = postSubmit;
-  }
-
-  public boolean isPostSubmit() {
-    return postSubmit;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb =
-        new StringBuilder("[")
-            .append(key)
-            .append(": ")
-            .append(value)
-            .append(",tag:")
-            .append(tag)
-            .append(",realAccountId:")
-            .append(realAccountId);
-    if (postSubmit) {
-      sb.append(",postSubmit");
-    }
-    return sb.append(']').toString();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof PatchSetApproval) {
-      PatchSetApproval p = (PatchSetApproval) o;
-      return Objects.equals(key, p.key)
-          && Objects.equals(value, p.value)
-          && Objects.equals(granted, p.granted)
-          && Objects.equals(tag, p.tag)
-          && Objects.equals(realAccountId, p.realAccountId)
-          && postSubmit == p.postSubmit;
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, value, granted, tag);
+    return key().isLegacySubmit();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java b/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
index f949013..21f6756 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
@@ -14,17 +14,21 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import static java.util.Objects.requireNonNull;
+
 import java.util.List;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Additional data about a {@link PatchSet} not normally loaded. */
 public final class PatchSetInfo {
   public static class ParentInfo {
-    public RevId id;
+    public ObjectId commitId;
     public String shortMessage;
 
-    public ParentInfo(RevId id, String shortMessage) {
-      this.id = id;
-      this.shortMessage = shortMessage;
+    public ParentInfo(AnyObjectId commitId, String shortMessage) {
+      this.commitId = commitId.copy();
+      this.shortMessage = requireNonNull(shortMessage);
     }
 
     protected ParentInfo() {}
@@ -47,8 +51,8 @@
   /** List of parents of the patch set. */
   protected List<ParentInfo> parents;
 
-  /** SHA-1 of commit */
-  protected String revId;
+  /** ID of commit. */
+  protected ObjectId commitId;
 
   /** Optional user-supplied description for the patch set. */
   protected String description;
@@ -107,12 +111,12 @@
     return parents;
   }
 
-  public void setRevId(String s) {
-    revId = s;
+  public void setCommitId(AnyObjectId commitId) {
+    this.commitId = commitId.copy();
   }
 
-  public String getRevId() {
-    return revId;
+  public ObjectId getCommitId() {
+    return commitId;
   }
 
   public void setDescription(String description) {
diff --git a/java/com/google/gerrit/reviewdb/client/Project.java b/java/com/google/gerrit/reviewdb/client/Project.java
index 921667e..edc8e27 100644
--- a/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/java/com/google/gerrit/reviewdb/client/Project.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
@@ -31,51 +31,62 @@
   /** Default submit type for root project (All-Projects). */
   public static final SubmitType DEFAULT_ALL_PROJECTS_SUBMIT_TYPE = SubmitType.MERGE_IF_NECESSARY;
 
-  /** Project name key */
-  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  public static NameKey nameKey(String name) {
+    return new NameKey(name);
+  }
 
-    @Column(id = 1)
-    protected String name;
-
-    protected NameKey() {}
-
-    public NameKey(String n) {
-      name = n;
+  /**
+   * Project name key.
+   *
+   * <p>This class has subclasses such as {@code AllProjectsName}, which make Guice injection more
+   * convenient. Subclasses must compare equal if they have the same name, regardless of the
+   * specific class. This implies that subclasses may not add additional fields.
+   *
+   * <p>Because of this unusual subclassing behavior, this class is not an {@code @AutoValue},
+   * unlike other key types in this package. However, this is strictly an implementation detail; its
+   * interface and semantics are otherwise analogous to the {@code @AutoValue} types.
+   */
+  public static class NameKey implements Comparable<NameKey> {
+    /** Parse a Project.NameKey out of a string representation. */
+    public static NameKey parse(String str) {
+      return nameKey(KeyUtil.decode(str));
     }
 
-    @Override
+    public static String asStringOrNull(NameKey key) {
+      return key == null ? null : key.get();
+    }
+
+    private final String name;
+
+    protected NameKey(String name) {
+      this.name = requireNonNull(name);
+    }
+
     public String get() {
       return name;
     }
 
     @Override
-    protected void set(String newValue) {
-      name = newValue;
-    }
-
-    @Override
-    public int hashCode() {
+    public final int hashCode() {
       return get().hashCode();
     }
 
     @Override
-    public boolean equals(Object b) {
+    public final boolean equals(Object b) {
       if (b instanceof NameKey) {
         return get().equals(((NameKey) b).get());
       }
       return false;
     }
 
-    /** Parse a Project.NameKey out of a string representation. */
-    public static NameKey parse(String str) {
-      final NameKey r = new NameKey();
-      r.fromString(str);
-      return r;
+    @Override
+    public final int compareTo(NameKey o) {
+      return get().compareTo(o.get());
     }
 
-    public static String asStringOrNull(NameKey key) {
-      return key == null ? null : key.get();
+    @Override
+    public final String toString() {
+      return KeyUtil.encode(get());
     }
   }
 
@@ -97,7 +108,7 @@
 
   protected String localDefaultDashboardId;
 
-  protected String themeName;
+  protected String configRefState;
 
   protected Project() {}
 
@@ -182,22 +193,6 @@
     this.localDefaultDashboardId = localDefaultDashboardId;
   }
 
-  public String getThemeName() {
-    return themeName;
-  }
-
-  public void setThemeName(String themeName) {
-    this.themeName = themeName;
-  }
-
-  public void copySettingsFrom(Project update) {
-    description = update.description;
-    booleanConfigs = new HashMap<>(update.booleanConfigs);
-    submitType = update.submitType;
-    state = update.state;
-    maxObjectSizeLimit = update.maxObjectSizeLimit;
-  }
-
   /**
    * Returns the name key of the parent project.
    *
@@ -233,10 +228,20 @@
   }
 
   public void setParentName(String n) {
-    parent = n != null ? new NameKey(n) : null;
+    parent = n != null ? nameKey(n) : null;
   }
 
   public void setParentName(NameKey n) {
     parent = n;
   }
+
+  /** Returns the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
+  public String getConfigRefState() {
+    return configRefState;
+  }
+
+  /** Sets the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
+  public void setConfigRefState(String state) {
+    configRefState = state;
+  }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/RefNames.java b/java/com/google/gerrit/reviewdb/client/RefNames.java
index fd2fb56..3854310 100644
--- a/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import com.google.gerrit.common.UsedAt;
+
 /** Constants and utilities for Gerrit-specific ref names. */
 public class RefNames {
   public static final String HEAD = "HEAD";
@@ -49,6 +51,9 @@
   /** Sequence counters in NoteDb. */
   public static final String REFS_SEQUENCES = "refs/sequences/";
 
+  /** NoteDb schema version number. */
+  public static final String REFS_VERSION = "refs/meta/version";
+
   /**
    * Prefix applied to merge commit base nodes.
    *
@@ -118,6 +123,11 @@
     return shard(id.get(), r).append(META_SUFFIX).toString();
   }
 
+  public static String patchSetRef(PatchSet.Id id) {
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.changeId().get(), r).append('/').append(id.get()).toString();
+  }
+
   public static String robotCommentsRef(Change.Id id) {
     StringBuilder r = newStringBuilder().append(REFS_CHANGES);
     return shard(id.get(), r).append(ROBOT_COMMENTS_SUFFIX).toString();
@@ -134,6 +144,11 @@
     return false;
   }
 
+  /** True if the provided ref is in {@code refs/changes/*}. */
+  public static boolean isRefsChanges(String ref) {
+    return ref.startsWith(REFS_CHANGES);
+  }
+
   public static String refsGroups(AccountGroup.UUID groupUuid) {
     return REFS_GROUPS + shardUuid(groupUuid.get());
   }
@@ -190,7 +205,8 @@
     return sb;
   }
 
-  private static String shardUuid(String uuid) {
+  @UsedAt(UsedAt.Project.PLUGINS_ALL)
+  public static String shardUuid(String uuid) {
     if (uuid == null || uuid.length() < 2) {
       throw new IllegalArgumentException("UUIDs must consist of at least two characters");
     }
@@ -263,6 +279,24 @@
     return REFS_CONFIG.equals(ref);
   }
 
+  /**
+   * Whether the ref is managed by Gerrit. Covers all Gerrit-internal refs like refs/cache-automerge
+   * and refs/meta as well as refs/changes. Does not cover user-created refs like branches or custom
+   * ref namespaces like refs/my-company.
+   */
+  public static boolean isGerritRef(String ref) {
+    return ref.startsWith(REFS_CHANGES)
+        || ref.startsWith(REFS_META)
+        || ref.startsWith(REFS_CACHE_AUTOMERGE)
+        || ref.startsWith(REFS_DRAFT_COMMENTS)
+        || ref.startsWith(REFS_DELETED_GROUPS)
+        || ref.startsWith(REFS_SEQUENCES)
+        || ref.startsWith(REFS_GROUPS)
+        || ref.startsWith(REFS_GROUPNAMES)
+        || ref.startsWith(REFS_USERS)
+        || ref.startsWith(REFS_STARRED_CHANGES);
+  }
+
   static Integer parseShardedRefPart(String name) {
     if (name == null) {
       return null;
@@ -305,7 +339,8 @@
     return id;
   }
 
-  static String parseShardedUuidFromRefPart(String name) {
+  @UsedAt(UsedAt.Project.PLUGINS_ALL)
+  public static String parseShardedUuidFromRefPart(String name) {
     if (name == null) {
       return null;
     }
@@ -429,7 +464,7 @@
     if (i == 0) {
       return null;
     }
-    return Integer.valueOf(name.substring(i, name.length()));
+    return Integer.valueOf(name.substring(i));
   }
 
   private static StringBuilder newStringBuilder() {
diff --git a/java/com/google/gerrit/reviewdb/client/RevId.java b/java/com/google/gerrit/reviewdb/client/RevId.java
deleted file mode 100644
index 0b0f74a..0000000
--- a/java/com/google/gerrit/reviewdb/client/RevId.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-
-/** A revision identifier for a file or a change. */
-public final class RevId {
-  public static final int ABBREV_LEN = 7;
-  public static final int LEN = 40;
-
-  @Column(id = 1, length = LEN)
-  protected String id;
-
-  protected RevId() {}
-
-  public RevId(String str) {
-    id = str;
-  }
-
-  /** @return the value of this revision id. */
-  public String get() {
-    return id;
-  }
-
-  /** @return true if this revision id has all required digits. */
-  public boolean isComplete() {
-    return get().length() == LEN;
-  }
-
-  /**
-   * @return if {@link #isComplete()}, {@code this}; otherwise a new RevId with 'z' appended on the
-   *     end.
-   */
-  public RevId max() {
-    if (isComplete()) {
-      return this;
-    }
-
-    final StringBuilder revEnd = new StringBuilder(get().length() + 1);
-    revEnd.append(get());
-    revEnd.append('z');
-    return new RevId(revEnd.toString());
-  }
-
-  @Override
-  public int hashCode() {
-    return id.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return (o instanceof RevId) && id.equals(((RevId) o).id);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName() + "{" + id + "}";
-  }
-
-  public boolean matches(String str) {
-    return id.startsWith(str.toLowerCase());
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/RobotComment.java b/java/com/google/gerrit/reviewdb/client/RobotComment.java
index eceb0bf..abe475f 100644
--- a/java/com/google/gerrit/reviewdb/client/RobotComment.java
+++ b/java/com/google/gerrit/reviewdb/client/RobotComment.java
@@ -42,58 +42,12 @@
 
   @Override
   public String toString() {
-    return new StringBuilder()
-        .append("RobotComment{")
-        .append("key=")
-        .append(key)
-        .append(',')
-        .append("robotId=")
-        .append(robotId)
-        .append(',')
-        .append("robotRunId=")
-        .append(robotRunId)
-        .append(',')
-        .append("lineNbr=")
-        .append(lineNbr)
-        .append(',')
-        .append("author=")
-        .append(author.getId().get())
-        .append(',')
-        .append("realAuthor=")
-        .append(realAuthor != null ? realAuthor.getId().get() : "")
-        .append(',')
-        .append("writtenOn=")
-        .append(writtenOn.toString())
-        .append(',')
-        .append("side=")
-        .append(side)
-        .append(',')
-        .append("message=")
-        .append(Objects.toString(message, ""))
-        .append(',')
-        .append("parentUuid=")
-        .append(Objects.toString(parentUuid, ""))
-        .append(',')
-        .append("range=")
-        .append(Objects.toString(range, ""))
-        .append(',')
-        .append("revId=")
-        .append(revId != null ? revId : "")
-        .append(',')
-        .append("tag=")
-        .append(Objects.toString(tag, ""))
-        .append(',')
-        .append("unresolved=")
-        .append(unresolved)
-        .append(',')
-        .append("url=")
-        .append(url)
-        .append(',')
-        .append("properties=")
-        .append(properties != null ? properties : "")
-        .append("fixSuggestions=")
-        .append(fixSuggestions != null ? fixSuggestions : "")
-        .append('}')
+    return toStringHelper()
+        .add("robotId", robotId)
+        .add("robotRunId", robotRunId)
+        .add("url", url)
+        .add("properties", Objects.toString(properties, ""))
+        .add("fixSuggestions", Objects.toString(fixSuggestions, ""))
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java b/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
index bd00d37..6a451bb 100644
--- a/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
+++ b/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
@@ -14,8 +14,7 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
+import java.util.Objects;
 
 /**
  * Defining a project/branch subscription to a project/branch project.
@@ -26,86 +25,48 @@
  * <p>A subscriber operates a submodule in defined path.
  */
 public final class SubmoduleSubscription {
-  /** Subscription key */
-  public static class Key extends StringKey<Branch.NameKey> {
-    private static final long serialVersionUID = 1L;
+  protected BranchNameKey superProject;
 
-    /**
-     * Indicates the super project, aka subscriber: the project owner of the gitlinks to the
-     * submodules.
-     */
-    @Column(id = 1)
-    protected Branch.NameKey superProject;
+  protected String submodulePath;
 
-    @Column(id = 2)
-    protected String submodulePath;
+  protected BranchNameKey submodule;
 
-    protected Key() {
-      superProject = new Branch.NameKey();
-    }
-
-    protected Key(Branch.NameKey superProject, String path) {
-      this.superProject = superProject;
-      this.submodulePath = path;
-    }
-
-    @Override
-    public Branch.NameKey getParentKey() {
-      return superProject;
-    }
-
-    @Override
-    public String get() {
-      return submodulePath;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      this.submodulePath = newValue;
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  @Column(id = 2)
-  protected Branch.NameKey submodule;
-
-  protected SubmoduleSubscription() {}
-
-  public SubmoduleSubscription(Branch.NameKey superProject, Branch.NameKey submodule, String path) {
-    this.key = new Key(superProject, path);
+  public SubmoduleSubscription(BranchNameKey superProject, BranchNameKey submodule, String path) {
+    this.superProject = superProject;
     this.submodule = submodule;
+    this.submodulePath = path;
   }
 
-  public Key getKey() {
-    return key;
-  }
-
-  public Branch.NameKey getSuperProject() {
-    return key.superProject;
+  /**
+   * Indicates the super project, aka subscriber: the project owner of the gitlinks to the
+   * submodules.
+   */
+  public BranchNameKey getSuperProject() {
+    return superProject;
   }
 
   public String getPath() {
-    return key.get();
+    return submodulePath;
   }
 
-  public Branch.NameKey getSubmodule() {
+  public BranchNameKey getSubmodule() {
     return submodule;
   }
 
   @Override
   public boolean equals(Object o) {
     if (o instanceof SubmoduleSubscription) {
-      return key.equals(((SubmoduleSubscription) o).key)
-          && submodule.equals(((SubmoduleSubscription) o).submodule);
+      SubmoduleSubscription s = (SubmoduleSubscription) o;
+      return superProject.equals(s.superProject)
+          && submodulePath.equals(s.submodulePath)
+          && submodule.equals(s.submodule);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return key.hashCode();
+    return Objects.hash(superProject, submodulePath, submodule);
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/client/SystemConfig.java b/java/com/google/gerrit/reviewdb/client/SystemConfig.java
deleted file mode 100644
index cd42dd1..0000000
--- a/java/com/google/gerrit/reviewdb/client/SystemConfig.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
-
-/** Global configuration needed to serve web requests. */
-public final class SystemConfig {
-  public static final class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    private static final String VALUE = "X";
-
-    @Column(id = 1, length = 1)
-    protected String one = VALUE;
-
-    public Key() {}
-
-    @Override
-    public String get() {
-      return VALUE;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      assert get().equals(newValue);
-    }
-  }
-
-  /** Construct a new, unconfigured instance. */
-  public static SystemConfig create() {
-    final SystemConfig r = new SystemConfig();
-    r.singleton = new SystemConfig.Key();
-    return r;
-  }
-
-  @Column(id = 1)
-  protected Key singleton;
-
-  /** Local filesystem location of header/footer/CSS configuration files */
-  @Column(id = 3, notNull = false, length = Integer.MAX_VALUE)
-  public transient String sitePath;
-
-  // DO NOT LOOK BELOW THIS LINE. These fields have all been deleted,
-  // but survive to support schema upgrade code.
-
-  /** DEPRECATED DO NOT USE */
-  @Column(id = 2, length = 36, notNull = false)
-  public transient String registerEmailPrivateKey;
-  /** DEPRECATED DO NOT USE */
-  @Column(id = 4, notNull = false)
-  public AccountGroup.Id adminGroupId;
-  /** DEPRECATED DO NOT USE */
-  @Column(id = 10, notNull = false)
-  public AccountGroup.UUID adminGroupUUID;
-  /** DEPRECATED DO NOT USE */
-  @Column(id = 5, notNull = false)
-  public AccountGroup.Id anonymousGroupId;
-  /** DEPRECATED DO NOT USE */
-  @Column(id = 6, notNull = false)
-  public AccountGroup.Id registeredGroupId;
-  /** DEPRECATED DO NOT USE */
-  @Column(id = 7, notNull = false)
-  public Project.NameKey wildProjectName;
-  /** DEPRECATED DO NOT USE */
-  @Column(id = 9, notNull = false)
-  public AccountGroup.Id ownerGroupId;
-  /** DEPRECATED DO NOT USE */
-  @Column(id = 8, notNull = false)
-  public AccountGroup.Id batchUsersGroupId;
-  /** DEPRECATED DO NOT USE */
-  @Column(id = 11, notNull = false)
-  public AccountGroup.UUID batchUsersGroupUUID;
-
-  protected SystemConfig() {}
-}
diff --git a/java/com/google/gerrit/reviewdb/client/TrackingId.java b/java/com/google/gerrit/reviewdb/client/TrackingId.java
deleted file mode 100644
index 2f6008f..0000000
--- a/java/com/google/gerrit/reviewdb/client/TrackingId.java
+++ /dev/null
@@ -1,159 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-import com.google.gwtorm.client.StringKey;
-
-/** External tracking id associated with a {@link Change} */
-public final class TrackingId {
-  public static final int TRACKING_ID_MAX_CHAR = 32;
-  public static final int TRACKING_SYSTEM_MAX_CHAR = 10;
-
-  /** External tracking id */
-  public static class Id extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1, length = TrackingId.TRACKING_ID_MAX_CHAR)
-    protected String id;
-
-    protected Id() {}
-
-    public Id(String id) {
-      this.id = id;
-    }
-
-    @Override
-    public String get() {
-      return id;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      id = newValue;
-    }
-  }
-
-  /** Name of external tracking system */
-  public static class System extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1, length = TrackingId.TRACKING_SYSTEM_MAX_CHAR)
-    protected String system;
-
-    protected System() {}
-
-    public System(String s) {
-      this.system = s;
-    }
-
-    @Override
-    public String get() {
-      return system;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      system = newValue;
-    }
-  }
-
-  public static class Key extends CompoundKey<Change.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Change.Id changeId;
-
-    @Column(id = 2)
-    protected Id trackingKey;
-
-    @Column(id = 3)
-    protected System trackingSystem;
-
-    protected Key() {
-      changeId = new Change.Id();
-      trackingKey = new Id();
-      trackingSystem = new System();
-    }
-
-    protected Key(Change.Id ch, Id id, System s) {
-      changeId = ch;
-      trackingKey = id;
-      trackingSystem = s;
-    }
-
-    @Override
-    public Change.Id getParentKey() {
-      return changeId;
-    }
-
-    public TrackingId.Id getTrackingId() {
-      return trackingKey;
-    }
-
-    public TrackingId.System getTrackingSystem() {
-      return trackingSystem;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {trackingKey, trackingSystem};
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  protected TrackingId() {}
-
-  public TrackingId(Change.Id ch, TrackingId.Id id, TrackingId.System s) {
-    key = new Key(ch, id, s);
-  }
-
-  public TrackingId(Change.Id ch, String id, String s) {
-    key = new Key(ch, new TrackingId.Id(id), new TrackingId.System(s));
-  }
-
-  public TrackingId.Key getKey() {
-    return key;
-  }
-
-  public Change.Id getChangeId() {
-    return key.changeId;
-  }
-
-  public String getTrackingId() {
-    return key.trackingKey.get();
-  }
-
-  public String getSystem() {
-    return key.trackingSystem.get();
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (obj instanceof TrackingId) {
-      final TrackingId tr = (TrackingId) obj;
-      return key.equals(tr.key);
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
new file mode 100644
index 0000000..5c7b03b
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.protobuf.Parser;
+
+@Immutable
+public enum AccountIdProtoConverter implements ProtoConverter<Entities.Account_Id, Account.Id> {
+  INSTANCE;
+
+  @Override
+  public Entities.Account_Id toProto(Account.Id accountId) {
+    return Entities.Account_Id.newBuilder().setId(accountId.get()).build();
+  }
+
+  @Override
+  public Account.Id fromProto(Entities.Account_Id proto) {
+    return Account.id(proto.getId());
+  }
+
+  @Override
+  public Parser<Entities.Account_Id> getParser() {
+    return Entities.Account_Id.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
new file mode 100644
index 0000000..6fa9353
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.protobuf.Parser;
+
+@Immutable
+public enum BranchNameKeyProtoConverter
+    implements ProtoConverter<Entities.Branch_NameKey, BranchNameKey> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.Project_NameKey, Project.NameKey> projectNameConverter =
+      ProjectNameKeyProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.Branch_NameKey toProto(BranchNameKey nameKey) {
+    return Entities.Branch_NameKey.newBuilder()
+        .setProject(projectNameConverter.toProto(nameKey.project()))
+        .setBranch(nameKey.branch())
+        .build();
+  }
+
+  @Override
+  public BranchNameKey fromProto(Entities.Branch_NameKey proto) {
+    return BranchNameKey.create(
+        projectNameConverter.fromProto(proto.getProject()), proto.getBranch());
+  }
+
+  @Override
+  public Parser<Entities.Branch_NameKey> getParser() {
+    return Entities.Branch_NameKey.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
new file mode 100644
index 0000000..5e90c87
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.protobuf.Parser;
+
+@Immutable
+public enum ChangeIdProtoConverter implements ProtoConverter<Entities.Change_Id, Change.Id> {
+  INSTANCE;
+
+  @Override
+  public Entities.Change_Id toProto(Change.Id changeId) {
+    return Entities.Change_Id.newBuilder().setId(changeId.get()).build();
+  }
+
+  @Override
+  public Change.Id fromProto(Entities.Change_Id proto) {
+    return Change.id(proto.getId());
+  }
+
+  @Override
+  public Parser<Entities.Change_Id> getParser() {
+    return Entities.Change_Id.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
new file mode 100644
index 0000000..4aa900b
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.protobuf.Parser;
+
+@Immutable
+public enum ChangeKeyProtoConverter implements ProtoConverter<Entities.Change_Key, Change.Key> {
+  INSTANCE;
+
+  @Override
+  public Entities.Change_Key toProto(Change.Key key) {
+    return Entities.Change_Key.newBuilder().setId(key.get()).build();
+  }
+
+  @Override
+  public Change.Key fromProto(Entities.Change_Key proto) {
+    return Change.key(proto.getId());
+  }
+
+  @Override
+  public Parser<Entities.Change_Key> getParser() {
+    return Entities.Change_Key.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
new file mode 100644
index 0000000..3d36293
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.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.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.protobuf.Parser;
+
+@Immutable
+public enum ChangeMessageKeyProtoConverter
+    implements ProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
+      ChangeIdProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.ChangeMessage_Key toProto(ChangeMessage.Key messageKey) {
+    return Entities.ChangeMessage_Key.newBuilder()
+        .setChangeId(changeIdConverter.toProto(messageKey.changeId()))
+        .setUuid(messageKey.uuid())
+        .build();
+  }
+
+  @Override
+  public ChangeMessage.Key fromProto(Entities.ChangeMessage_Key proto) {
+    return ChangeMessage.key(changeIdConverter.fromProto(proto.getChangeId()), proto.getUuid());
+  }
+
+  @Override
+  public Parser<Entities.ChangeMessage_Key> getParser() {
+    return Entities.ChangeMessage_Key.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
new file mode 100644
index 0000000..31b0e11
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.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.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.sql.Timestamp;
+import java.util.Objects;
+
+@Immutable
+public enum ChangeMessageProtoConverter
+    implements ProtoConverter<Entities.ChangeMessage, ChangeMessage> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key>
+      changeMessageKeyConverter = ChangeMessageKeyProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.ChangeMessage toProto(ChangeMessage changeMessage) {
+    Entities.ChangeMessage.Builder builder =
+        Entities.ChangeMessage.newBuilder()
+            .setKey(changeMessageKeyConverter.toProto(changeMessage.getKey()));
+    Account.Id author = changeMessage.getAuthor();
+    if (author != null) {
+      builder.setAuthorId(accountIdConverter.toProto(author));
+    }
+    Timestamp writtenOn = changeMessage.getWrittenOn();
+    if (writtenOn != null) {
+      builder.setWrittenOn(writtenOn.getTime());
+    }
+    String message = changeMessage.getMessage();
+    if (message != null) {
+      builder.setMessage(message);
+    }
+    PatchSet.Id patchSetId = changeMessage.getPatchSetId();
+    if (patchSetId != null) {
+      builder.setPatchset(patchSetIdConverter.toProto(patchSetId));
+    }
+    String tag = changeMessage.getTag();
+    if (tag != null) {
+      builder.setTag(tag);
+    }
+    Account.Id realAuthor = changeMessage.getRealAuthor();
+    // ChangeMessage#getRealAuthor automatically delegates to ChangeMessage#getAuthor if the real
+    // author is not set. However, the previous protobuf representation kept 'realAuthor' empty if
+    // it wasn't set. To ensure binary compatibility, simulate the previous behavior.
+    if (realAuthor != null && !Objects.equals(realAuthor, author)) {
+      builder.setRealAuthor(accountIdConverter.toProto(realAuthor));
+    }
+    return builder.build();
+  }
+
+  @Override
+  public ChangeMessage fromProto(Entities.ChangeMessage proto) {
+    ChangeMessage.Key key =
+        proto.hasKey() ? changeMessageKeyConverter.fromProto(proto.getKey()) : null;
+    Account.Id author =
+        proto.hasAuthorId() ? accountIdConverter.fromProto(proto.getAuthorId()) : null;
+    Timestamp writtenOn = proto.hasWrittenOn() ? new Timestamp(proto.getWrittenOn()) : null;
+    PatchSet.Id patchSetId =
+        proto.hasPatchset() ? patchSetIdConverter.fromProto(proto.getPatchset()) : null;
+    ChangeMessage changeMessage = new ChangeMessage(key, author, writtenOn, patchSetId);
+    if (proto.hasMessage()) {
+      changeMessage.setMessage(proto.getMessage());
+    }
+    if (proto.hasTag()) {
+      changeMessage.setTag(proto.getTag());
+    }
+    if (proto.hasRealAuthor()) {
+      changeMessage.setRealAuthor(accountIdConverter.fromProto(proto.getRealAuthor()));
+    }
+    return changeMessage;
+  }
+
+  @Override
+  public Parser<Entities.ChangeMessage> getParser() {
+    return Entities.ChangeMessage.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
new file mode 100644
index 0000000..2dfa516
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
@@ -0,0 +1,128 @@
+// 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.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.sql.Timestamp;
+
+@Immutable
+public enum ChangeProtoConverter implements ProtoConverter<Entities.Change, Change> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
+      ChangeIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.Change_Key, Change.Key> changeKeyConverter =
+      ChangeKeyProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.Branch_NameKey, BranchNameKey> branchNameConverter =
+      BranchNameKeyProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.Change toProto(Change change) {
+    Entities.Change.Builder builder =
+        Entities.Change.newBuilder()
+            .setChangeId(changeIdConverter.toProto(change.getId()))
+            .setRowVersion(change.getRowVersion())
+            .setChangeKey(changeKeyConverter.toProto(change.getKey()))
+            .setCreatedOn(change.getCreatedOn().getTime())
+            .setLastUpdatedOn(change.getLastUpdatedOn().getTime())
+            .setOwnerAccountId(accountIdConverter.toProto(change.getOwner()))
+            .setDest(branchNameConverter.toProto(change.getDest()))
+            .setStatus(change.getStatus().getCode())
+            .setIsPrivate(change.isPrivate())
+            .setWorkInProgress(change.isWorkInProgress())
+            .setReviewStarted(change.hasReviewStarted());
+    PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+    // Special behavior necessary to ensure binary compatibility.
+    builder.setCurrentPatchSetId(currentPatchSetId == null ? 0 : currentPatchSetId.get());
+    String subject = change.getSubject();
+    if (subject != null) {
+      builder.setSubject(subject);
+    }
+    String topic = change.getTopic();
+    if (topic != null) {
+      builder.setTopic(topic);
+    }
+    String originalSubject = change.getOriginalSubjectOrNull();
+    if (originalSubject != null) {
+      builder.setOriginalSubject(originalSubject);
+    }
+    String submissionId = change.getSubmissionId();
+    if (submissionId != null) {
+      builder.setSubmissionId(submissionId);
+    }
+    Account.Id assignee = change.getAssignee();
+    if (assignee != null) {
+      builder.setAssignee(accountIdConverter.toProto(assignee));
+    }
+    Change.Id revertOf = change.getRevertOf();
+    if (revertOf != null) {
+      builder.setRevertOf(changeIdConverter.toProto(revertOf));
+    }
+    return builder.build();
+  }
+
+  @Override
+  public Change fromProto(Entities.Change proto) {
+    Change.Id changeId = changeIdConverter.fromProto(proto.getChangeId());
+    Change.Key key =
+        proto.hasChangeKey() ? changeKeyConverter.fromProto(proto.getChangeKey()) : null;
+    Account.Id owner =
+        proto.hasOwnerAccountId() ? accountIdConverter.fromProto(proto.getOwnerAccountId()) : null;
+    BranchNameKey destination =
+        proto.hasDest() ? branchNameConverter.fromProto(proto.getDest()) : null;
+    Change change =
+        new Change(key, changeId, owner, destination, new Timestamp(proto.getCreatedOn()));
+    if (proto.hasLastUpdatedOn()) {
+      change.setLastUpdatedOn(new Timestamp(proto.getLastUpdatedOn()));
+    }
+    Change.Status status = Change.Status.forCode((char) proto.getStatus());
+    if (status != null) {
+      change.setStatus(status);
+    }
+    String subject = proto.hasSubject() ? proto.getSubject() : null;
+    String originalSubject = proto.hasOriginalSubject() ? proto.getOriginalSubject() : null;
+    change.setCurrentPatchSet(
+        PatchSet.id(changeId, proto.getCurrentPatchSetId()), subject, originalSubject);
+    if (proto.hasTopic()) {
+      change.setTopic(proto.getTopic());
+    }
+    if (proto.hasSubmissionId()) {
+      change.setSubmissionId(proto.getSubmissionId());
+    }
+    if (proto.hasAssignee()) {
+      change.setAssignee(accountIdConverter.fromProto(proto.getAssignee()));
+    }
+    change.setPrivate(proto.getIsPrivate());
+    change.setWorkInProgress(proto.getWorkInProgress());
+    change.setReviewStarted(proto.getReviewStarted());
+    if (proto.hasRevertOf()) {
+      change.setRevertOf(changeIdConverter.fromProto(proto.getRevertOf()));
+    }
+    return change;
+  }
+
+  @Override
+  public Parser<Entities.Change> getParser() {
+    return Entities.Change.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
new file mode 100644
index 0000000..42049a4
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.protobuf.Parser;
+
+@Immutable
+public enum LabelIdProtoConverter implements ProtoConverter<Entities.LabelId, LabelId> {
+  INSTANCE;
+
+  @Override
+  public Entities.LabelId toProto(LabelId labelId) {
+    return Entities.LabelId.newBuilder().setId(labelId.get()).build();
+  }
+
+  @Override
+  public LabelId fromProto(Entities.LabelId proto) {
+    return LabelId.create(proto.getId());
+  }
+
+  @Override
+  public Parser<Entities.LabelId> getParser() {
+    return Entities.LabelId.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java
new file mode 100644
index 0000000..246207d
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Proto converter for {@code ObjectId}s.
+ *
+ * <p>This converter uses the hex representation of object IDs embedded in a wrapper proto type,
+ * rather than a more parsimonious implementation (e.g. a raw byte array), for two reasons:
+ *
+ * <ul>
+ *   <li>Hex strings are easier to read and work with when reading and writing protos in text
+ *       formats, for example in test failure messages, or when using command-line tools.
+ *   <li>This maintains backwards wire compatibility with a pre-NoteDb implementation.
+ * </ul>
+ */
+@Immutable
+public enum ObjectIdProtoConverter implements ProtoConverter<Entities.ObjectId, ObjectId> {
+  INSTANCE;
+
+  @Override
+  public Entities.ObjectId toProto(ObjectId objectId) {
+    return Entities.ObjectId.newBuilder().setName(objectId.name()).build();
+  }
+
+  @Override
+  public ObjectId fromProto(Entities.ObjectId proto) {
+    return ObjectId.fromString(proto.getName());
+  }
+
+  @Override
+  public Parser<Entities.ObjectId> getParser() {
+    return Entities.ObjectId.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
new file mode 100644
index 0000000..d3136b1
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.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.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.protobuf.Parser;
+
+@Immutable
+public enum PatchSetApprovalKeyProtoConverter
+    implements ProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.LabelId, LabelId> labelIdConverter =
+      LabelIdProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.PatchSetApproval_Key toProto(PatchSetApproval.Key key) {
+    return Entities.PatchSetApproval_Key.newBuilder()
+        .setPatchSetId(patchSetIdConverter.toProto(key.patchSetId()))
+        .setAccountId(accountIdConverter.toProto(key.accountId()))
+        .setLabelId(labelIdConverter.toProto(key.labelId()))
+        .build();
+  }
+
+  @Override
+  public PatchSetApproval.Key fromProto(Entities.PatchSetApproval_Key proto) {
+    return PatchSetApproval.key(
+        patchSetIdConverter.fromProto(proto.getPatchSetId()),
+        accountIdConverter.fromProto(proto.getAccountId()),
+        labelIdConverter.fromProto(proto.getLabelId()));
+  }
+
+  @Override
+  public Parser<Entities.PatchSetApproval_Key> getParser() {
+    return Entities.PatchSetApproval_Key.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
new file mode 100644
index 0000000..a08d745
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.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.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.protobuf.Parser;
+import java.sql.Timestamp;
+import java.util.Objects;
+
+@Immutable
+public enum PatchSetApprovalProtoConverter
+    implements ProtoConverter<Entities.PatchSetApproval, PatchSetApproval> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key>
+      patchSetApprovalKeyProtoConverter = PatchSetApprovalKeyProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.PatchSetApproval toProto(PatchSetApproval patchSetApproval) {
+    Entities.PatchSetApproval.Builder builder =
+        Entities.PatchSetApproval.newBuilder()
+            .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.key()))
+            .setValue(patchSetApproval.value())
+            .setGranted(patchSetApproval.granted().getTime())
+            .setPostSubmit(patchSetApproval.postSubmit());
+
+    patchSetApproval.tag().ifPresent(builder::setTag);
+    Account.Id realAccountId = patchSetApproval.realAccountId();
+    // PatchSetApproval#getRealAccountId automatically delegates to PatchSetApproval#getAccountId if
+    // the real author is not set. However, the previous protobuf representation kept
+    // 'realAccountId' empty if it wasn't set. To ensure binary compatibility, simulate the previous
+    // behavior.
+    if (realAccountId != null && !Objects.equals(realAccountId, patchSetApproval.accountId())) {
+      builder.setRealAccountId(accountIdConverter.toProto(realAccountId));
+    }
+
+    return builder.build();
+  }
+
+  @Override
+  public PatchSetApproval fromProto(Entities.PatchSetApproval proto) {
+    PatchSetApproval.Builder builder =
+        PatchSetApproval.builder()
+            .key(patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()))
+            .value(proto.getValue())
+            .granted(new Timestamp(proto.getGranted()))
+            .postSubmit(proto.getPostSubmit());
+    if (proto.hasTag()) {
+      builder.tag(proto.getTag());
+    }
+    if (proto.hasRealAccountId()) {
+      builder.realAccountId(accountIdConverter.fromProto(proto.getRealAccountId()));
+    }
+    return builder.build();
+  }
+
+  @Override
+  public Parser<Entities.PatchSetApproval> getParser() {
+    return Entities.PatchSetApproval.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
new file mode 100644
index 0000000..154b0bf
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+
+@Immutable
+public enum PatchSetIdProtoConverter implements ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
+      ChangeIdProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.PatchSet_Id toProto(PatchSet.Id patchSetId) {
+    return Entities.PatchSet_Id.newBuilder()
+        .setChangeId(changeIdConverter.toProto(patchSetId.changeId()))
+        .setId(patchSetId.get())
+        .build();
+  }
+
+  @Override
+  public PatchSet.Id fromProto(Entities.PatchSet_Id proto) {
+    return PatchSet.id(changeIdConverter.fromProto(proto.getChangeId()), proto.getId());
+  }
+
+  @Override
+  public Parser<Entities.PatchSet_Id> getParser() {
+    return Entities.PatchSet_Id.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
new file mode 100644
index 0000000..5006906
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.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.reviewdb.converter;
+
+import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.sql.Timestamp;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Immutable
+public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet, PatchSet> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.ObjectId, ObjectId> objectIdConverter =
+      ObjectIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.PatchSet toProto(PatchSet patchSet) {
+    Entities.PatchSet.Builder builder =
+        Entities.PatchSet.newBuilder()
+            .setId(patchSetIdConverter.toProto(patchSet.id()))
+            .setCommitId(objectIdConverter.toProto(patchSet.commitId()))
+            .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
+            .setCreatedOn(patchSet.createdOn().getTime());
+    List<String> groups = patchSet.groups();
+    if (!groups.isEmpty()) {
+      builder.setGroups(PatchSet.joinGroups(groups));
+    }
+    patchSet.pushCertificate().ifPresent(builder::setPushCertificate);
+    patchSet.description().ifPresent(builder::setDescription);
+    return builder.build();
+  }
+
+  @Override
+  public PatchSet fromProto(Entities.PatchSet proto) {
+    PatchSet.Builder builder =
+        PatchSet.builder()
+            .id(patchSetIdConverter.fromProto(proto.getId()))
+            .groups(
+                proto.hasGroups() ? PatchSet.splitGroups(proto.getGroups()) : ImmutableList.of());
+    if (proto.hasPushCertificate()) {
+      builder.pushCertificate(proto.getPushCertificate());
+    }
+    if (proto.hasDescription()) {
+      builder.description(proto.getDescription());
+    }
+
+    // The following fields used to theoretically be nullable in PatchSet, but in practice no
+    // production codepath should have ever serialized an instance that was missing one of these
+    // fields.
+    //
+    // However, since some protos may theoretically be missing these fields, we need to support
+    // them. Populate specific sentinel values for each field as documented in the PatchSet javadoc.
+    // Callers that encounter one of these sentinels will likely fail, for example by failing to
+    // look up the zeroId. They would have also failed back when the fields were nullable, for
+    // example with NPE; the current behavior just fails slightly differently.
+    builder
+        .commitId(
+            proto.hasCommitId()
+                ? objectIdConverter.fromProto(proto.getCommitId())
+                : ObjectId.zeroId())
+        .uploader(
+            proto.hasUploaderAccountId()
+                ? accountIdConverter.fromProto(proto.getUploaderAccountId())
+                : Account.id(0))
+        .createdOn(proto.hasCreatedOn() ? new Timestamp(proto.getCreatedOn()) : new Timestamp(0));
+
+    return builder.build();
+  }
+
+  @Override
+  public Parser<Entities.PatchSet> getParser() {
+    return Entities.PatchSet.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
new file mode 100644
index 0000000..99048a0
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
@@ -0,0 +1,41 @@
+// 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.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.protobuf.Parser;
+
+@Immutable
+public enum ProjectNameKeyProtoConverter
+    implements ProtoConverter<Entities.Project_NameKey, Project.NameKey> {
+  INSTANCE;
+
+  @Override
+  public Entities.Project_NameKey toProto(Project.NameKey nameKey) {
+    return Entities.Project_NameKey.newBuilder().setName(nameKey.get()).build();
+  }
+
+  @Override
+  public Project.NameKey fromProto(Entities.Project_NameKey proto) {
+    return Project.nameKey(proto.getName());
+  }
+
+  @Override
+  public Parser<Entities.Project_NameKey> getParser() {
+    return Entities.Project_NameKey.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
new file mode 100644
index 0000000..f4f1de06
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.protobuf.MessageLite;
+import com.google.protobuf.Parser;
+
+@Immutable
+public interface ProtoConverter<P extends MessageLite, C> {
+
+  P toProto(C valueClass);
+
+  C fromProto(P proto);
+
+  Parser<P> getParser();
+}
diff --git a/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java
deleted file mode 100644
index c5a1caf..0000000
--- a/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountGroupAccess extends Access<AccountGroup, AccountGroup.Id> {
-  @Override
-  @PrimaryKey("groupId")
-  AccountGroup get(AccountGroup.Id id) throws OrmException;
-
-  @Query("WHERE groupUUID = ?")
-  ResultSet<AccountGroup> byUUID(AccountGroup.UUID uuid) throws OrmException;
-
-  @Query
-  ResultSet<AccountGroup> all() throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java
deleted file mode 100644
index 1634fda..0000000
--- a/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountGroupByIdAccess extends Access<AccountGroupById, AccountGroupById.Key> {
-  @Override
-  @PrimaryKey("key")
-  AccountGroupById get(AccountGroupById.Key key) throws OrmException;
-
-  @Query("WHERE key.includeUUID = ?")
-  ResultSet<AccountGroupById> byIncludeUUID(AccountGroup.UUID uuid) throws OrmException;
-
-  @Query("WHERE key.groupId = ?")
-  ResultSet<AccountGroupById> byGroup(AccountGroup.Id id) throws OrmException;
-
-  @Query("")
-  ResultSet<AccountGroupById> all() throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java
deleted file mode 100644
index 08f9a0a..0000000
--- a/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountGroupByIdAudAccess
-    extends Access<AccountGroupByIdAud, AccountGroupByIdAud.Key> {
-  @Override
-  @PrimaryKey("key")
-  AccountGroupByIdAud get(AccountGroupByIdAud.Key key) throws OrmException;
-
-  @Query("WHERE key.groupId = ? AND key.includeUUID = ?")
-  ResultSet<AccountGroupByIdAud> byGroupInclude(
-      AccountGroup.Id groupId, AccountGroup.UUID incGroupUUID) throws OrmException;
-
-  @Query("WHERE key.groupId = ?")
-  ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId) throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java
deleted file mode 100644
index 0213f25a..0000000
--- a/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountGroupMemberAccess
-    extends Access<AccountGroupMember, AccountGroupMember.Key> {
-  @Override
-  @PrimaryKey("key")
-  AccountGroupMember get(AccountGroupMember.Key key) throws OrmException;
-
-  @Query("WHERE key.accountId = ?")
-  ResultSet<AccountGroupMember> byAccount(Account.Id id) throws OrmException;
-
-  @Query("WHERE key.groupId = ?")
-  ResultSet<AccountGroupMember> byGroup(AccountGroup.Id id) throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java
deleted file mode 100644
index aa2f7c4..0000000
--- a/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountGroupMemberAuditAccess
-    extends Access<AccountGroupMemberAudit, AccountGroupMemberAudit.Key> {
-  @Override
-  @PrimaryKey("key")
-  AccountGroupMemberAudit get(AccountGroupMemberAudit.Key key) throws OrmException;
-
-  @Query("WHERE key.groupId = ? AND key.accountId = ?")
-  ResultSet<AccountGroupMemberAudit> byGroupAccount(AccountGroup.Id groupId, Account.Id accountId)
-      throws OrmException;
-
-  @Query("WHERE key.groupId = ?")
-  ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId) throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
deleted file mode 100644
index b8bc9f0..0000000
--- a/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupName;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountGroupNameAccess extends Access<AccountGroupName, AccountGroup.NameKey> {
-  @Override
-  @PrimaryKey("name")
-  AccountGroupName get(AccountGroup.NameKey name) throws OrmException;
-
-  @Query("ORDER BY name")
-  ResultSet<AccountGroupName> all() throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/ChangeAccess.java b/java/com/google/gerrit/reviewdb/server/ChangeAccess.java
deleted file mode 100644
index 4e46efb..0000000
--- a/java/com/google/gerrit/reviewdb/server/ChangeAccess.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface ChangeAccess extends Access<Change, Change.Id> {
-  @Override
-  @PrimaryKey("changeId")
-  Change get(Change.Id id) throws OrmException;
-
-  @Query
-  ResultSet<Change> all() throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java b/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java
deleted file mode 100644
index fe87e59..0000000
--- a/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface ChangeMessageAccess extends Access<ChangeMessage, ChangeMessage.Key> {
-  @Override
-  @PrimaryKey("key")
-  ChangeMessage get(ChangeMessage.Key id) throws OrmException;
-
-  @Query("WHERE key.changeId = ? ORDER BY writtenOn")
-  ResultSet<ChangeMessage> byChange(Change.Id id) throws OrmException;
-
-  @Query("WHERE patchset = ?")
-  ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) throws OrmException;
-
-  @Query
-  ResultSet<ChangeMessage> all() throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java b/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java
deleted file mode 100644
index fdf3d6c..0000000
--- a/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java
+++ /dev/null
@@ -1,281 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-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.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-public class DisallowReadFromChangesReviewDbWrapper extends ReviewDbWrapper {
-  private static final String MSG = "This table has been migrated to NoteDb";
-
-  private final Changes changes;
-  private final PatchSetApprovals patchSetApprovals;
-  private final ChangeMessages changeMessages;
-  private final PatchSets patchSets;
-  private final PatchLineComments patchComments;
-
-  public DisallowReadFromChangesReviewDbWrapper(ReviewDb db) {
-    super(db);
-    changes = new Changes(delegate.changes());
-    patchSetApprovals = new PatchSetApprovals(delegate.patchSetApprovals());
-    changeMessages = new ChangeMessages(delegate.changeMessages());
-    patchSets = new PatchSets(delegate.patchSets());
-    patchComments = new PatchLineComments(delegate.patchComments());
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    return changes;
-  }
-
-  @Override
-  public PatchSetApprovalAccess patchSetApprovals() {
-    return patchSetApprovals;
-  }
-
-  @Override
-  public ChangeMessageAccess changeMessages() {
-    return changeMessages;
-  }
-
-  @Override
-  public PatchSetAccess patchSets() {
-    return patchSets;
-  }
-
-  @Override
-  public PatchLineCommentAccess patchComments() {
-    return patchComments;
-  }
-
-  private static class Changes extends ChangeAccessWrapper {
-
-    protected Changes(ChangeAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<Change> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<Change, OrmException> getAsync(
-        Change.Id key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<Change> get(Iterable<Change.Id> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public Change get(Change.Id id) throws OrmException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<Change> all() throws OrmException {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-
-  private static class PatchSetApprovals extends PatchSetApprovalAccessWrapper {
-    PatchSetApprovals(PatchSetApprovalAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<PatchSetApproval, OrmException> getAsync(
-        PatchSetApproval.Key key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> get(Iterable<PatchSetApproval.Key> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public PatchSetApproval get(PatchSetApproval.Key key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byChange(Change.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-
-  private static class ChangeMessages extends ChangeMessageAccessWrapper {
-    ChangeMessages(ChangeMessageAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<ChangeMessage, OrmException> getAsync(
-        ChangeMessage.Key key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ChangeMessage get(ChangeMessage.Key id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byChange(Change.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> all() {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-
-  private static class PatchSets extends PatchSetAccessWrapper {
-    PatchSets(PatchSetAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<PatchSet> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<PatchSet, OrmException> getAsync(
-        PatchSet.Id key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public PatchSet get(PatchSet.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSet> byChange(Change.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-
-  private static class PatchLineComments extends PatchLineCommentAccessWrapper {
-    PatchLineComments(PatchLineCommentAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<PatchLineComment, OrmException> getAsync(
-        PatchLineComment.Key key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> get(Iterable<PatchLineComment.Key> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public PatchLineComment get(PatchLineComment.Key id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byChange(Change.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
-        PatchSet.Id patchset, Account.Id author) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
-        Change.Id id, String file, Account.Id author) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java b/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java
deleted file mode 100644
index 08b8484..0000000
--- a/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface PatchLineCommentAccess extends Access<PatchLineComment, PatchLineComment.Key> {
-  @Override
-  @PrimaryKey("key")
-  PatchLineComment get(PatchLineComment.Key id) throws OrmException;
-
-  @Query("WHERE key.patchKey.patchSetId.changeId = ?")
-  ResultSet<PatchLineComment> byChange(Change.Id id) throws OrmException;
-
-  @Query("WHERE key.patchKey.patchSetId = ?")
-  ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) throws OrmException;
-
-  @Query(
-      "WHERE key.patchKey.patchSetId.changeId = ?"
-          + " AND key.patchKey.fileName = ? AND status = '"
-          + PatchLineComment.STATUS_PUBLISHED
-          + "' ORDER BY lineNbr,writtenOn")
-  ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file) throws OrmException;
-
-  @Query(
-      "WHERE key.patchKey.patchSetId = ? AND status = '" + PatchLineComment.STATUS_PUBLISHED + "'")
-  ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset) throws OrmException;
-
-  @Query(
-      "WHERE key.patchKey.patchSetId = ? AND status = '"
-          + PatchLineComment.STATUS_DRAFT
-          + "' AND author = ? ORDER BY key.patchKey,lineNbr,writtenOn")
-  ResultSet<PatchLineComment> draftByPatchSetAuthor(PatchSet.Id patchset, Account.Id author)
-      throws OrmException;
-
-  @Query(
-      "WHERE key.patchKey.patchSetId.changeId = ?"
-          + " AND key.patchKey.fileName = ? AND author = ? AND status = '"
-          + PatchLineComment.STATUS_DRAFT
-          + "' ORDER BY lineNbr,writtenOn")
-  ResultSet<PatchLineComment> draftByChangeFileAuthor(Change.Id id, String file, Account.Id author)
-      throws OrmException;
-
-  @Query("WHERE status = '" + PatchLineComment.STATUS_DRAFT + "' AND author = ?")
-  ResultSet<PatchLineComment> draftByAuthor(Account.Id author) throws OrmException;
-
-  @Query
-  ResultSet<PatchLineComment> all() throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java b/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
deleted file mode 100644
index bf3c9e4..0000000
--- a/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface PatchSetAccess extends Access<PatchSet, PatchSet.Id> {
-  @Override
-  @PrimaryKey("id")
-  PatchSet get(PatchSet.Id id) throws OrmException;
-
-  @Query("WHERE id.changeId = ? ORDER BY id.patchSetId")
-  ResultSet<PatchSet> byChange(Change.Id id) throws OrmException;
-
-  @Query
-  ResultSet<PatchSet> all() throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java b/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java
deleted file mode 100644
index 69357bc..0000000
--- a/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface PatchSetApprovalAccess extends Access<PatchSetApproval, PatchSetApproval.Key> {
-  @Override
-  @PrimaryKey("key")
-  PatchSetApproval get(PatchSetApproval.Key key) throws OrmException;
-
-  @Query("WHERE key.patchSetId.changeId = ?")
-  ResultSet<PatchSetApproval> byChange(Change.Id id) throws OrmException;
-
-  @Query("WHERE key.patchSetId = ?")
-  ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) throws OrmException;
-
-  @Query("WHERE key.patchSetId = ? AND key.accountId = ?")
-  ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account)
-      throws OrmException;
-
-  @Query
-  ResultSet<PatchSetApproval> all() throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDb.java b/java/com/google/gerrit/reviewdb/server/ReviewDb.java
deleted file mode 100644
index 4e648b9..0000000
--- a/java/com/google/gerrit/reviewdb/server/ReviewDb.java
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.Relation;
-import com.google.gwtorm.server.Schema;
-import com.google.gwtorm.server.Sequence;
-
-/**
- * The review service database schema.
- *
- * <p>Root entities that are at the top level of some important data graph:
- *
- * <ul>
- *   <li>{@link Account}: Per-user account registration, preferences, identity.
- *   <li>{@link Change}: All review information about a single proposed change.
- *   <li>{@link SystemConfig}: Server-wide settings, managed by administrator.
- * </ul>
- */
-public interface ReviewDb extends Schema {
-  /* If you change anything, update SchemaVersion.C to use a new version. */
-
-  @Relation(id = 1)
-  SchemaVersionAccess schemaVersion();
-
-  @Relation(id = 2)
-  SystemConfigAccess systemConfig();
-
-  // Deleted @Relation(id = 3)
-
-  // Deleted @Relation(id = 4)
-
-  // Deleted @Relation(id = 6)
-
-  // Deleted @Relation(id = 7)
-
-  // Deleted @Relation(id = 8)
-
-  // Deleted @Relation(id = 10)
-
-  // Deleted @Relation(id = 11)
-
-  // Deleted @Relation(id = 12)
-
-  // Deleted @Relation(id = 13)
-
-  // Deleted @Relation(id = 17)
-
-  // Deleted @Relation(id = 18)
-
-  // Deleted @Relation(id = 19)
-
-  // Deleted @Relation(id = 20)
-
-  @Relation(id = 21)
-  ChangeAccess changes();
-
-  @Relation(id = 22)
-  PatchSetApprovalAccess patchSetApprovals();
-
-  @Relation(id = 23)
-  ChangeMessageAccess changeMessages();
-
-  @Relation(id = 24)
-  PatchSetAccess patchSets();
-
-  // Deleted @Relation(id = 25)
-
-  @Relation(id = 26)
-  PatchLineCommentAccess patchComments();
-
-  // Deleted @Relation(id = 28)
-
-  // Deleted @Relation(id = 29)
-
-  // Deleted @Relation(id = 30)
-
-  int FIRST_ACCOUNT_ID = 1000000;
-
-  /**
-   * Next unique id for a {@link Account}.
-   *
-   * @deprecated use {@link com.google.gerrit.server.Sequences#nextAccountId()}.
-   */
-  @Sequence(startWith = FIRST_ACCOUNT_ID)
-  @Deprecated
-  int nextAccountId() throws OrmException;
-
-  int FIRST_GROUP_ID = 1;
-
-  /** Next unique id for a {@link AccountGroup}. */
-  @Sequence(startWith = FIRST_GROUP_ID)
-  @Deprecated
-  int nextAccountGroupId() throws OrmException;
-
-  int FIRST_CHANGE_ID = 1;
-
-  /**
-   * Next unique id for a {@link Change}.
-   *
-   * @deprecated use {@link com.google.gerrit.server.Sequences#nextChangeId()}.
-   */
-  @Sequence(startWith = FIRST_CHANGE_ID)
-  @Deprecated
-  int nextChangeId() throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java b/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
deleted file mode 100644
index 2958464..0000000
--- a/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gwtorm.protobuf.CodecFactory;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-
-/** {@link ProtobufCodec} instances for ReviewDb types. */
-public class ReviewDbCodecs {
-  public static final ProtobufCodec<PatchSetApproval> APPROVAL_CODEC =
-      CodecFactory.encoder(PatchSetApproval.class);
-
-  public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
-
-  public static final ProtobufCodec<ChangeMessage> MESSAGE_CODEC =
-      CodecFactory.encoder(ChangeMessage.class);
-
-  public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
-      CodecFactory.encoder(PatchSet.class);
-
-  private ReviewDbCodecs() {}
-}
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java b/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
deleted file mode 100644
index aed9778..0000000
--- a/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.collect.Ordering;
-import com.google.common.collect.Sets;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.IntKey;
-import java.lang.reflect.Field;
-import java.util.Arrays;
-import java.util.Set;
-import java.util.TreeSet;
-
-/** Static utilities for ReviewDb types. */
-public class ReviewDbUtil {
-  private static final Ordering<? extends IntKey<?>> INT_KEY_ORDERING =
-      Ordering.natural().nullsFirst().<IntKey<?>>onResultOf(IntKey::get).nullsFirst();
-
-  /**
-   * Null-safe ordering over arbitrary subclass of {@code IntKey}.
-   *
-   * <p>In some cases, {@code Comparator.comparing(Change.Id::get)} may be shorter and cleaner.
-   * However, this method may be preferable in some cases:
-   *
-   * <ul>
-   *   <li>This ordering is null-safe over both input and the result of {@link IntKey#get()}; {@code
-   *       comparing} is only a good idea if all inputs are obviously non-null.
-   *   <li>{@code intKeyOrdering().sortedCopy(iterable)} is shorter than the stream equivalent.
-   *   <li>Creating derived comparators may be more readable with {@link Ordering} method chaining
-   *       rather than static {@code Comparator} methods.
-   * </ul>
-   */
-  @SuppressWarnings("unchecked")
-  public static <K extends IntKey<?>> Ordering<K> intKeyOrdering() {
-    return (Ordering<K>) INT_KEY_ORDERING;
-  }
-
-  public static ReviewDb unwrapDb(ReviewDb db) {
-    if (db instanceof DisallowReadFromChangesReviewDbWrapper) {
-      return unwrapDb(((DisallowReadFromChangesReviewDbWrapper) db).unsafeGetDelegate());
-    }
-    return db;
-  }
-
-  public static void checkColumns(Class<?> clazz, Integer... expected) {
-    Set<Integer> ids = new TreeSet<>();
-    for (Field f : clazz.getDeclaredFields()) {
-      Column col = f.getAnnotation(Column.class);
-      if (col != null) {
-        ids.add(col.id());
-      }
-    }
-    Set<Integer> expectedIds = Sets.newTreeSet(Arrays.asList(expected));
-    checkState(
-        ids.equals(expectedIds),
-        "Unexpected column set for %s: %s != %s",
-        clazz.getSimpleName(),
-        ids,
-        expectedIds);
-  }
-
-  private ReviewDbUtil() {}
-}
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java b/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
deleted file mode 100644
index f8e93ae..0000000
--- a/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
+++ /dev/null
@@ -1,1272 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-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.AccountGroupName;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.gwtorm.server.StatementExecutor;
-import java.util.Map;
-
-public class ReviewDbWrapper implements ReviewDb {
-  public static JdbcSchema unwrapJbdcSchema(ReviewDb db) {
-    if (db instanceof ReviewDbWrapper) {
-      return unwrapJbdcSchema(((ReviewDbWrapper) db).unsafeGetDelegate());
-    }
-    return (JdbcSchema) db;
-  }
-
-  protected final ReviewDb delegate;
-
-  private boolean inTransaction;
-
-  protected ReviewDbWrapper(ReviewDb delegate) {
-    this.delegate = checkNotNull(delegate);
-  }
-
-  public ReviewDb unsafeGetDelegate() {
-    return delegate;
-  }
-
-  public boolean inTransaction() {
-    return inTransaction;
-  }
-
-  public void beginTransaction() {
-    inTransaction = true;
-  }
-
-  @Override
-  public void commit() throws OrmException {
-    if (!inTransaction) {
-      // This reads a little weird, we're not in a transaction, so why are we calling commit?
-      // Because we want to let the underlying ReviewDb do its normal thing in this case (which may
-      // be throwing an exception, or not, depending on implementation).
-      delegate.commit();
-    }
-  }
-
-  @Override
-  public void rollback() throws OrmException {
-    if (inTransaction) {
-      inTransaction = false;
-    } else {
-      // See comment in commit(): we want to let the underlying ReviewDb do its thing.
-      delegate.rollback();
-    }
-  }
-
-  @Override
-  public void updateSchema(StatementExecutor e) throws OrmException {
-    delegate.updateSchema(e);
-  }
-
-  @Override
-  public void pruneSchema(StatementExecutor e) throws OrmException {
-    delegate.pruneSchema(e);
-  }
-
-  @Override
-  public Access<?, ?>[] allRelations() {
-    return delegate.allRelations();
-  }
-
-  @Override
-  public void close() {
-    delegate.close();
-  }
-
-  @Override
-  public SchemaVersionAccess schemaVersion() {
-    return delegate.schemaVersion();
-  }
-
-  @Override
-  public SystemConfigAccess systemConfig() {
-    return delegate.systemConfig();
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    return delegate.changes();
-  }
-
-  @Override
-  public PatchSetApprovalAccess patchSetApprovals() {
-    return delegate.patchSetApprovals();
-  }
-
-  @Override
-  public ChangeMessageAccess changeMessages() {
-    return delegate.changeMessages();
-  }
-
-  @Override
-  public PatchSetAccess patchSets() {
-    return delegate.patchSets();
-  }
-
-  @Override
-  public PatchLineCommentAccess patchComments() {
-    return delegate.patchComments();
-  }
-
-  @Override
-  @SuppressWarnings("deprecation")
-  public int nextAccountId() throws OrmException {
-    return delegate.nextAccountId();
-  }
-
-  @Override
-  @SuppressWarnings("deprecation")
-  public int nextAccountGroupId() throws OrmException {
-    return delegate.nextAccountGroupId();
-  }
-
-  @Override
-  @SuppressWarnings("deprecation")
-  public int nextChangeId() throws OrmException {
-    return delegate.nextChangeId();
-  }
-
-  public static class ChangeAccessWrapper implements ChangeAccess {
-    protected final ChangeAccess delegate;
-
-    protected ChangeAccessWrapper(ChangeAccess delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<Change> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public Change.Id primaryKey(Change entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<Change.Id, Change> toMap(Iterable<Change> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<Change, OrmException> getAsync(
-        Change.Id key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<Change> get(Iterable<Change.Id> keys) throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<Change> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<Change> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<Change> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<Change.Id> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<Change> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(Change.Id key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public Change atomicUpdate(Change.Id key, AtomicUpdate<Change> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public Change get(Change.Id id) throws OrmException {
-      return delegate.get(id);
-    }
-
-    @Override
-    public ResultSet<Change> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class PatchSetApprovalAccessWrapper implements PatchSetApprovalAccess {
-    protected final PatchSetApprovalAccess delegate;
-
-    protected PatchSetApprovalAccessWrapper(PatchSetApprovalAccess delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public PatchSetApproval.Key primaryKey(PatchSetApproval entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<PatchSetApproval.Key, PatchSetApproval> toMap(Iterable<PatchSetApproval> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<PatchSetApproval, OrmException> getAsync(
-        PatchSetApproval.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> get(Iterable<PatchSetApproval.Key> keys)
-        throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<PatchSetApproval> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<PatchSetApproval> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<PatchSetApproval> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<PatchSetApproval.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<PatchSetApproval> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(PatchSetApproval.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public PatchSetApproval atomicUpdate(
-        PatchSetApproval.Key key, AtomicUpdate<PatchSetApproval> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public PatchSetApproval get(PatchSetApproval.Key key) throws OrmException {
-      return delegate.get(key);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byChange(Change.Id id) throws OrmException {
-      return delegate.byChange(id);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) throws OrmException {
-      return delegate.byPatchSet(id);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account)
-        throws OrmException {
-      return delegate.byPatchSetUser(patchSet, account);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class ChangeMessageAccessWrapper implements ChangeMessageAccess {
-    protected final ChangeMessageAccess delegate;
-
-    protected ChangeMessageAccessWrapper(ChangeMessageAccess delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public ChangeMessage.Key primaryKey(ChangeMessage entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<ChangeMessage.Key, ChangeMessage> toMap(Iterable<ChangeMessage> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<ChangeMessage, OrmException> getAsync(
-        ChangeMessage.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys) throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<ChangeMessage> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<ChangeMessage> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<ChangeMessage> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<ChangeMessage.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<ChangeMessage> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(ChangeMessage.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public ChangeMessage atomicUpdate(ChangeMessage.Key key, AtomicUpdate<ChangeMessage> update)
-        throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public ChangeMessage get(ChangeMessage.Key id) throws OrmException {
-      return delegate.get(id);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byChange(Change.Id id) throws OrmException {
-      return delegate.byChange(id);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) throws OrmException {
-      return delegate.byPatchSet(id);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class PatchSetAccessWrapper implements PatchSetAccess {
-    protected final PatchSetAccess delegate;
-
-    protected PatchSetAccessWrapper(PatchSetAccess delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<PatchSet> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public PatchSet.Id primaryKey(PatchSet entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<PatchSet.Id, PatchSet> toMap(Iterable<PatchSet> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<PatchSet, OrmException> getAsync(
-        PatchSet.Id key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys) throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<PatchSet> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<PatchSet> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<PatchSet> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<PatchSet.Id> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<PatchSet> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(PatchSet.Id key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public PatchSet atomicUpdate(PatchSet.Id key, AtomicUpdate<PatchSet> update)
-        throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public PatchSet get(PatchSet.Id id) throws OrmException {
-      return delegate.get(id);
-    }
-
-    @Override
-    public ResultSet<PatchSet> byChange(Change.Id id) throws OrmException {
-      return delegate.byChange(id);
-    }
-
-    @Override
-    public ResultSet<PatchSet> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class PatchLineCommentAccessWrapper implements PatchLineCommentAccess {
-    protected PatchLineCommentAccess delegate;
-
-    protected PatchLineCommentAccessWrapper(PatchLineCommentAccess delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public PatchLineComment.Key primaryKey(PatchLineComment entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<PatchLineComment.Key, PatchLineComment> toMap(Iterable<PatchLineComment> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<PatchLineComment, OrmException> getAsync(
-        PatchLineComment.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> get(Iterable<PatchLineComment.Key> keys)
-        throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<PatchLineComment> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<PatchLineComment> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<PatchLineComment> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<PatchLineComment.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<PatchLineComment> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(PatchLineComment.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public PatchLineComment atomicUpdate(
-        PatchLineComment.Key key, AtomicUpdate<PatchLineComment> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public PatchLineComment get(PatchLineComment.Key id) throws OrmException {
-      return delegate.get(id);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byChange(Change.Id id) throws OrmException {
-      return delegate.byChange(id);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) throws OrmException {
-      return delegate.byPatchSet(id);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file)
-        throws OrmException {
-      return delegate.publishedByChangeFile(id, file);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset)
-        throws OrmException {
-      return delegate.publishedByPatchSet(patchset);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
-        PatchSet.Id patchset, Account.Id author) throws OrmException {
-      return delegate.draftByPatchSetAuthor(patchset, author);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
-        Change.Id id, String file, Account.Id author) throws OrmException {
-      return delegate.draftByChangeFileAuthor(id, file, author);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) throws OrmException {
-      return delegate.draftByAuthor(author);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class AccountGroupAccessWrapper implements AccountGroupAccess {
-    protected final AccountGroupAccess delegate;
-
-    protected AccountGroupAccessWrapper(AccountGroupAccess delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<AccountGroup> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public AccountGroup.Id primaryKey(AccountGroup entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<AccountGroup.Id, AccountGroup> toMap(Iterable<AccountGroup> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<AccountGroup, OrmException> getAsync(
-        AccountGroup.Id key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroup> get(Iterable<AccountGroup.Id> keys) throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<AccountGroup> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<AccountGroup> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<AccountGroup> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<AccountGroup.Id> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<AccountGroup> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(AccountGroup.Id key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public AccountGroup atomicUpdate(AccountGroup.Id key, AtomicUpdate<AccountGroup> update)
-        throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public AccountGroup get(AccountGroup.Id id) throws OrmException {
-      return delegate.get(id);
-    }
-
-    @Override
-    public ResultSet<AccountGroup> byUUID(AccountGroup.UUID uuid) throws OrmException {
-      return delegate.byUUID(uuid);
-    }
-
-    @Override
-    public ResultSet<AccountGroup> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class AccountGroupNameAccessWrapper implements AccountGroupNameAccess {
-    protected final AccountGroupNameAccess delegate;
-
-    protected AccountGroupNameAccessWrapper(AccountGroupNameAccess delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<AccountGroupName> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public AccountGroup.NameKey primaryKey(AccountGroupName entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<AccountGroup.NameKey, AccountGroupName> toMap(Iterable<AccountGroupName> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<AccountGroupName, OrmException> getAsync(
-        AccountGroup.NameKey key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroupName> get(Iterable<AccountGroup.NameKey> keys)
-        throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<AccountGroupName> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<AccountGroupName> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<AccountGroupName> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<AccountGroup.NameKey> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<AccountGroupName> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(AccountGroup.NameKey key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public AccountGroupName atomicUpdate(
-        AccountGroup.NameKey key, AtomicUpdate<AccountGroupName> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public AccountGroupName get(AccountGroup.NameKey name) throws OrmException {
-      return delegate.get(name);
-    }
-
-    @Override
-    public ResultSet<AccountGroupName> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class AccountGroupMemberAccessWrapper implements AccountGroupMemberAccess {
-    protected final AccountGroupMemberAccess delegate;
-
-    protected AccountGroupMemberAccessWrapper(AccountGroupMemberAccess delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<AccountGroupMember> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public AccountGroupMember.Key primaryKey(AccountGroupMember entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<AccountGroupMember.Key, AccountGroupMember> toMap(Iterable<AccountGroupMember> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMember, OrmException>
-        getAsync(AccountGroupMember.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroupMember> get(Iterable<AccountGroupMember.Key> keys)
-        throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<AccountGroupMember> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<AccountGroupMember> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<AccountGroupMember> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<AccountGroupMember.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<AccountGroupMember> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(AccountGroupMember.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public AccountGroupMember atomicUpdate(
-        AccountGroupMember.Key key, AtomicUpdate<AccountGroupMember> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public AccountGroupMember get(AccountGroupMember.Key key) throws OrmException {
-      return delegate.get(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroupMember> byAccount(Account.Id id) throws OrmException {
-      return delegate.byAccount(id);
-    }
-
-    @Override
-    public ResultSet<AccountGroupMember> byGroup(AccountGroup.Id id) throws OrmException {
-      return delegate.byGroup(id);
-    }
-  }
-
-  public static class AccountGroupMemberAuditAccessWrapper
-      implements AccountGroupMemberAuditAccess {
-    protected final AccountGroupMemberAuditAccess delegate;
-
-    protected AccountGroupMemberAuditAccessWrapper(AccountGroupMemberAuditAccess delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<AccountGroupMemberAudit> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public AccountGroupMemberAudit.Key primaryKey(AccountGroupMemberAudit entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<AccountGroupMemberAudit.Key, AccountGroupMemberAudit> toMap(
-        Iterable<AccountGroupMemberAudit> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMemberAudit, OrmException>
-        getAsync(AccountGroupMemberAudit.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroupMemberAudit> get(Iterable<AccountGroupMemberAudit.Key> keys)
-        throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<AccountGroupMemberAudit.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(AccountGroupMemberAudit.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public AccountGroupMemberAudit atomicUpdate(
-        AccountGroupMemberAudit.Key key, AtomicUpdate<AccountGroupMemberAudit> update)
-        throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public AccountGroupMemberAudit get(AccountGroupMemberAudit.Key key) throws OrmException {
-      return delegate.get(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroupMemberAudit> byGroupAccount(
-        AccountGroup.Id groupId, Account.Id accountId) throws OrmException {
-      return delegate.byGroupAccount(groupId, accountId);
-    }
-
-    @Override
-    public ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId) throws OrmException {
-      return delegate.byGroup(groupId);
-    }
-  }
-
-  public static class AccountGroupByIdAccessWrapper implements AccountGroupByIdAccess {
-    protected final AccountGroupByIdAccess delegate;
-
-    protected AccountGroupByIdAccessWrapper(AccountGroupByIdAccess delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<AccountGroupById> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public AccountGroupById.Key primaryKey(AccountGroupById entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<AccountGroupById.Key, AccountGroupById> toMap(Iterable<AccountGroupById> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<AccountGroupById, OrmException> getAsync(
-        AccountGroupById.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroupById> get(Iterable<AccountGroupById.Key> keys)
-        throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<AccountGroupById> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<AccountGroupById> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<AccountGroupById> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<AccountGroupById.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<AccountGroupById> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(AccountGroupById.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public AccountGroupById atomicUpdate(
-        AccountGroupById.Key key, AtomicUpdate<AccountGroupById> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public AccountGroupById get(AccountGroupById.Key key) throws OrmException {
-      return delegate.get(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroupById> byIncludeUUID(AccountGroup.UUID uuid) throws OrmException {
-      return delegate.byIncludeUUID(uuid);
-    }
-
-    @Override
-    public ResultSet<AccountGroupById> byGroup(AccountGroup.Id id) throws OrmException {
-      return delegate.byGroup(id);
-    }
-
-    @Override
-    public ResultSet<AccountGroupById> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class AccountGroupByIdAudAccessWrapper implements AccountGroupByIdAudAccess {
-    protected final AccountGroupByIdAudAccess delegate;
-
-    protected AccountGroupByIdAudAccessWrapper(AccountGroupByIdAudAccess delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<AccountGroupByIdAud> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public AccountGroupByIdAud.Key primaryKey(AccountGroupByIdAud entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<AccountGroupByIdAud.Key, AccountGroupByIdAud> toMap(
-        Iterable<AccountGroupByIdAud> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<AccountGroupByIdAud, OrmException>
-        getAsync(AccountGroupByIdAud.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroupByIdAud> get(Iterable<AccountGroupByIdAud.Key> keys)
-        throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<AccountGroupByIdAud> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<AccountGroupByIdAud> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<AccountGroupByIdAud> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<AccountGroupByIdAud.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<AccountGroupByIdAud> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(AccountGroupByIdAud.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public AccountGroupByIdAud atomicUpdate(
-        AccountGroupByIdAud.Key key, AtomicUpdate<AccountGroupByIdAud> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public AccountGroupByIdAud get(AccountGroupByIdAud.Key key) throws OrmException {
-      return delegate.get(key);
-    }
-
-    @Override
-    public ResultSet<AccountGroupByIdAud> byGroupInclude(
-        AccountGroup.Id groupId, AccountGroup.UUID incGroupUUID) throws OrmException {
-      return delegate.byGroupInclude(groupId, incGroupUUID);
-    }
-
-    @Override
-    public ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId) throws OrmException {
-      return delegate.byGroup(groupId);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java b/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java
deleted file mode 100644
index 8819a6c..0000000
--- a/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-
-/** Access interface for {@link CurrentSchemaVersion}. */
-public interface SchemaVersionAccess
-    extends Access<CurrentSchemaVersion, CurrentSchemaVersion.Key> {
-  @Override
-  @PrimaryKey("singleton")
-  CurrentSchemaVersion get(CurrentSchemaVersion.Key key) throws OrmException;
-}
diff --git a/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java b/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java
deleted file mode 100644
index a2177fd..0000000
--- a/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-/** Access interface for {@link SystemConfig}. */
-public interface SystemConfigAccess extends Access<SystemConfig, SystemConfig.Key> {
-  @Override
-  @PrimaryKey("singleton")
-  SystemConfig get(SystemConfig.Key key) throws OrmException;
-
-  @Query
-  ResultSet<SystemConfig> all() throws OrmException;
-}
diff --git a/java/com/google/gerrit/server/AccessPath.java b/java/com/google/gerrit/server/AccessPath.java
index cb720c8..4d07d62 100644
--- a/java/com/google/gerrit/server/AccessPath.java
+++ b/java/com/google/gerrit/server/AccessPath.java
@@ -22,9 +22,6 @@
   /** Access through the REST API. */
   REST_API,
 
-  /** Access through the old JSON-RPC interface. */
-  JSON_RPC,
-
   /** Access by a web cookie. This path is not protected like REST_API. */
   WEB_BROWSER,
 
diff --git a/java/com/google/gerrit/server/ApprovalCopier.java b/java/com/google/gerrit/server/ApprovalCopier.java
index 922922c..85a6079 100644
--- a/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/ApprovalCopier.java
@@ -15,26 +15,24 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 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.io.IOException;
@@ -43,7 +41,6 @@
 import java.util.List;
 import java.util.TreeMap;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -74,163 +71,76 @@
     this.psUtil = psUtil;
   }
 
-  /**
-   * Apply approval copy settings from prior PatchSets to a new PatchSet.
-   *
-   * @param db review database.
-   * @param notes change notes for user uploading PatchSet
-   * @param user user uploading PatchSet
-   * @param ps new PatchSet
-   * @param rw open walk that can read the patch set commit; null to open the repo on demand.
-   * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
-   * @throws OrmException
-   */
-  public void copyInReviewDb(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet ps,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig)
-      throws OrmException {
-    copyInReviewDb(db, notes, user, ps, rw, repoConfig, Collections.emptyList());
-  }
-
-  /**
-   * Apply approval copy settings from prior PatchSets to a new PatchSet.
-   *
-   * @param db review database.
-   * @param notes change notes for user uploading PatchSet
-   * @param user user uploading PatchSet
-   * @param ps new PatchSet
-   * @param rw open walk that can read the patch set commit; null to open the repo on demand.
-   * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
-   * @param dontCopy PatchSetApprovals indicating which (account, label) pairs should not be copied
-   * @throws OrmException
-   */
-  public void copyInReviewDb(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet ps,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig,
-      Iterable<PatchSetApproval> dontCopy)
-      throws OrmException {
-    if (PrimaryStorage.of(notes.getChange()) == PrimaryStorage.REVIEW_DB) {
-      db.patchSetApprovals().insert(getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy));
-    }
-  }
-
   Iterable<PatchSetApproval> getForPatchSet(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet.Id psId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig)
-      throws OrmException {
-    return getForPatchSet(
-        db, notes, user, psId, rw, repoConfig, Collections.<PatchSetApproval>emptyList());
-  }
+      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
 
-  Iterable<PatchSetApproval> getForPatchSet(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet.Id psId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig,
-      Iterable<PatchSetApproval> dontCopy)
-      throws OrmException {
-    PatchSet ps = psUtil.get(db, notes, psId);
+    PatchSet ps = psUtil.get(notes, psId);
     if (ps == null) {
       return Collections.emptyList();
     }
-    return getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy);
-  }
 
-  private Iterable<PatchSetApproval> getForPatchSet(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet ps,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig,
-      Iterable<PatchSetApproval> dontCopy)
-      throws OrmException {
-    checkNotNull(ps, "ps should not be null");
-    ChangeData cd = changeDataFactory.create(db, notes);
+    ChangeData cd = changeDataFactory.create(notes);
     try {
-      ProjectState project = projectCache.checkedGet(cd.change().getDest().getParentKey());
+      ProjectState project = projectCache.checkedGet(cd.change().getDest().project());
       ListMultimap<PatchSet.Id, PatchSetApproval> all = cd.approvals();
-      checkNotNull(all, "all should not be null");
-
-      Table<String, Account.Id, PatchSetApproval> wontCopy = HashBasedTable.create();
-      for (PatchSetApproval psa : dontCopy) {
-        wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
-      }
+      requireNonNull(all, "all should not be null");
 
       Table<String, Account.Id, PatchSetApproval> byUser = HashBasedTable.create();
-      for (PatchSetApproval psa : all.get(ps.getId())) {
-        if (!wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
-          byUser.put(psa.getLabel(), psa.getAccountId(), psa);
-        }
+      for (PatchSetApproval psa : all.get(ps.id())) {
+        byUser.put(psa.label(), psa.accountId(), psa);
       }
 
       TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
 
+      Table<String, Account.Id, PatchSetApproval> wontCopy = HashBasedTable.create();
+
       // Walk patch sets strictly less than current in descending order.
       Collection<PatchSet> allPrior =
-          patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
+          patchSets.descendingMap().tailMap(ps.id().get(), false).values();
       for (PatchSet priorPs : allPrior) {
-        List<PatchSetApproval> priorApprovals = all.get(priorPs.getId());
+        List<PatchSetApproval> priorApprovals = all.get(priorPs.id());
         if (priorApprovals.isEmpty()) {
           continue;
         }
 
         ChangeKind kind =
             changeKindCache.getChangeKind(
-                project.getNameKey(),
-                rw,
-                repoConfig,
-                ObjectId.fromString(priorPs.getRevision().get()),
-                ObjectId.fromString(ps.getRevision().get()));
+                project.getNameKey(), rw, repoConfig, priorPs.commitId(), ps.commitId());
 
         for (PatchSetApproval psa : priorApprovals) {
-          if (wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
+          if (wontCopy.contains(psa.label(), psa.accountId())) {
             continue;
           }
-          if (byUser.contains(psa.getLabel(), psa.getAccountId())) {
+          if (byUser.contains(psa.label(), psa.accountId())) {
             continue;
           }
-          if (!canCopy(project, psa, ps.getId(), kind)) {
-            wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
+          if (!canCopy(project, psa, ps.id(), kind)) {
+            wontCopy.put(psa.label(), psa.accountId(), psa);
             continue;
           }
-          byUser.put(psa.getLabel(), psa.getAccountId(), copy(psa, ps.getId()));
+          byUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
         }
       }
-      return labelNormalizer.normalize(notes, user, byUser.values()).getNormalized();
+      return labelNormalizer.normalize(notes, byUser.values()).getNormalized();
     } catch (IOException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
-  private static TreeMap<Integer, PatchSet> getPatchSets(ChangeData cd) throws OrmException {
+  private static TreeMap<Integer, PatchSet> getPatchSets(ChangeData cd) {
     Collection<PatchSet> patchSets = cd.patchSets();
     TreeMap<Integer, PatchSet> result = new TreeMap<>();
     for (PatchSet ps : patchSets) {
-      result.put(ps.getId().get(), ps);
+      result.put(ps.id().get(), ps);
     }
     return result;
   }
 
   private static boolean canCopy(
       ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
-    int n = psa.getKey().getParentKey().get();
+    int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
-    LabelType type = project.getLabelTypes().byLabel(psa.getLabelId());
+    LabelType type = project.getLabelTypes().byLabel(psa.labelId());
     if (type == null) {
       return false;
     } else if ((type.isCopyMinScore() && type.isMaxNegative(psa))
@@ -254,11 +164,4 @@
         return false;
     }
   }
-
-  private static PatchSetApproval copy(PatchSetApproval src, PatchSet.Id psId) {
-    if (src.getKey().getParentKey().equals(psId)) {
-      return src;
-    }
-    return new PatchSetApproval(psId, src);
-  }
 }
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 8365ddb..9befb46 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -17,21 +17,18 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static java.util.Comparator.comparing;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -41,10 +38,8 @@
 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.server.ReviewDb;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.LabelPermission;
@@ -52,7 +47,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -76,37 +70,27 @@
  * for each reviewer, even if the reviewer hasn't actually given a score to the change. To mark the
  * "no score" case, a dummy approval, which may live in any of the available categories, with a
  * score of 0 is used.
- *
- * <p>The methods in this class only modify the gwtorm database.
  */
 @Singleton
 public class ApprovalsUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final Ordering<PatchSetApproval> SORT_APPROVALS =
-      Ordering.from(comparing(PatchSetApproval::getGranted));
-
-  public static List<PatchSetApproval> sortApprovals(Iterable<PatchSetApproval> approvals) {
-    return SORT_APPROVALS.sortedCopy(approvals);
-  }
-
-  public static PatchSetApproval newApproval(
+  public static PatchSetApproval.Builder newApproval(
       PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Date when) {
-    PatchSetApproval psa =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(psId, user.getAccountId(), labelId),
-            Shorts.checkedCast(value),
-            when);
-    user.updateRealAccountId(psa::setRealAccountId);
-    return psa;
+    PatchSetApproval.Builder b =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, user.getAccountId(), labelId))
+            .value(value)
+            .granted(when);
+    user.updateRealAccountId(b::realAccountId);
+    return b;
   }
 
   private static Iterable<PatchSetApproval> filterApprovals(
       Iterable<PatchSetApproval> psas, Account.Id accountId) {
-    return Iterables.filter(psas, a -> Objects.equals(a.getAccountId(), accountId));
+    return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final NotesMigration migration;
   private final ApprovalCopier copier;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
@@ -114,11 +98,7 @@
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      NotesMigration migration,
-      ApprovalCopier copier,
-      PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
-    this.migration = migration;
+      ApprovalCopier copier, PermissionBackend permissionBackend, ProjectCache projectCache) {
     this.copier = copier;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
@@ -127,15 +107,10 @@
   /**
    * Get all reviewers for a change.
    *
-   * @param db review database.
    * @param notes change notes.
    * @return reviewers for the change.
-   * @throws OrmException if reviewers for the change could not be read.
    */
-  public ReviewerSet getReviewers(ReviewDb db, ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return ReviewerSet.fromApprovals(db.patchSetApprovals().byChange(notes.getChangeId()));
-    }
+  public ReviewerSet getReviewers(ChangeNotes notes) {
     return notes.load().getReviewers();
   }
 
@@ -144,46 +119,34 @@
    *
    * @param allApprovals all approvals to consider; must all belong to the same change.
    * @return reviewers for the change.
-   * @throws OrmException if reviewers for the change could not be read.
    */
-  public ReviewerSet getReviewers(ChangeNotes notes, Iterable<PatchSetApproval> allApprovals)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return ReviewerSet.fromApprovals(allApprovals);
-    }
+  public ReviewerSet getReviewers(ChangeNotes notes, Iterable<PatchSetApproval> allApprovals) {
     return notes.load().getReviewers();
   }
 
   /**
-   * Get updates to reviewer set. Always returns empty list for ReviewDb.
+   * Get updates to reviewer set.
    *
    * @param notes change notes.
    * @return reviewer updates for the change.
-   * @throws OrmException if reviewer updates for the change could not be read.
    */
-  public List<ReviewerStatusUpdate> getReviewerUpdates(ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return ImmutableList.of();
-    }
+  public List<ReviewerStatusUpdate> getReviewerUpdates(ChangeNotes notes) {
     return notes.load().getReviewerUpdates();
   }
 
   public List<PatchSetApproval> addReviewers(
-      ReviewDb db,
       ChangeUpdate update,
       LabelTypes labelTypes,
       Change change,
       PatchSet ps,
       PatchSetInfo info,
       Iterable<Account.Id> wantReviewers,
-      Collection<Account.Id> existingReviewers)
-      throws OrmException {
+      Collection<Account.Id> existingReviewers) {
     return addReviewers(
-        db,
         update,
         labelTypes,
         change,
-        ps.getId(),
+        ps.id(),
         info.getAuthor().getAccount(),
         info.getCommitter().getAccount(),
         wantReviewers,
@@ -191,22 +154,14 @@
   }
 
   public List<PatchSetApproval> addReviewers(
-      ReviewDb db,
       ChangeNotes notes,
       ChangeUpdate update,
       LabelTypes labelTypes,
       Change change,
-      Iterable<Account.Id> wantReviewers)
-      throws OrmException {
+      Iterable<Account.Id> wantReviewers) {
     PatchSet.Id psId = change.currentPatchSetId();
     Collection<Account.Id> existingReviewers;
-    if (migration.readChanges()) {
-      // If using NoteDB, we only want reviewers in the REVIEWER state.
-      existingReviewers = notes.load().getReviewers().byState(REVIEWER);
-    } else {
-      // Prior to NoteDB, we gather all reviewers regardless of state.
-      existingReviewers = getReviewers(db, notes).all();
-    }
+    existingReviewers = notes.load().getReviewers().byState(REVIEWER);
     // Existing reviewers should include pending additions in the REVIEWER
     // state, taken from ChangeUpdate.
     existingReviewers = Lists.newArrayList(existingReviewers);
@@ -216,11 +171,10 @@
       }
     }
     return addReviewers(
-        db, update, labelTypes, change, psId, null, null, wantReviewers, existingReviewers);
+        update, labelTypes, change, psId, null, null, wantReviewers, existingReviewers);
   }
 
   private List<PatchSetApproval> addReviewers(
-      ReviewDb db,
       ChangeUpdate update,
       LabelTypes labelTypes,
       Change change,
@@ -228,19 +182,18 @@
       Account.Id authorId,
       Account.Id committerId,
       Iterable<Account.Id> wantReviewers,
-      Collection<Account.Id> existingReviewers)
-      throws OrmException {
+      Collection<Account.Id> existingReviewers) {
     List<LabelType> allTypes = labelTypes.getLabelTypes();
     if (allTypes.isEmpty()) {
       return ImmutableList.of();
     }
 
     Set<Account.Id> need = Sets.newLinkedHashSet(wantReviewers);
-    if (authorId != null && canSee(db, update.getNotes(), authorId)) {
+    if (authorId != null && canSee(update.getNotes(), authorId)) {
       need.add(authorId);
     }
 
-    if (committerId != null && canSee(db, update.getNotes(), committerId)) {
+    if (committerId != null && canSee(update.getNotes(), committerId)) {
       need.add(committerId);
     }
     need.remove(change.getOwner());
@@ -253,22 +206,25 @@
     LabelId labelId = Iterables.getLast(allTypes).getLabelId();
     for (Account.Id account : need) {
       cells.add(
-          new PatchSetApproval(
-              new PatchSetApproval.Key(psId, account, labelId), (short) 0, update.getWhen()));
+          PatchSetApproval.builder()
+              .key(PatchSetApproval.key(psId, account, labelId))
+              .value(0)
+              .granted(update.getWhen())
+              .build());
       update.putReviewer(account, REVIEWER);
     }
-    db.patchSetApprovals().upsert(cells);
     return Collections.unmodifiableList(cells);
   }
 
-  private boolean canSee(ReviewDb db, ChangeNotes notes, Account.Id accountId) {
+  private boolean canSee(ChangeNotes notes, Account.Id accountId) {
     try {
-      return projectCache.checkedGet(notes.getProjectName()).statePermitsRead()
-          && permissionBackend
-              .absentUser(accountId)
-              .change(notes)
-              .database(db)
-              .test(ChangePermission.READ);
+      if (!projectCache.checkedGet(notes.getProjectName()).statePermitsRead()) {
+        return false;
+      }
+      permissionBackend.absentUser(accountId).change(notes).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
     } catch (IOException | PermissionBackendException e) {
       logger.atWarning().withCause(e).log(
           "Failed to check if account %d can see change %d",
@@ -284,10 +240,9 @@
    * @param update change update.
    * @param wantCCs accounts to CC.
    * @return whether a change was made.
-   * @throws OrmException
    */
   public Collection<Account.Id> addCcs(
-      ChangeNotes notes, ChangeUpdate update, Collection<Account.Id> wantCCs) throws OrmException {
+      ChangeNotes notes, ChangeUpdate update, Collection<Account.Id> wantCCs) {
     return addCcs(update, wantCCs, notes.load().getReviewers());
   }
 
@@ -303,45 +258,41 @@
   }
 
   /**
-   * Adds approvals to ChangeUpdate for a new patch set, and writes to ReviewDb.
+   * Adds approvals to ChangeUpdate for a new patch set, and writes to NoteDb.
    *
-   * @param db review database.
    * @param update change update.
    * @param labelTypes label types for the containing project.
    * @param ps patch set being approved.
    * @param user user adding approvals.
    * @param approvals approvals to add.
    * @throws RestApiException
-   * @throws OrmException
    */
   public Iterable<PatchSetApproval> addApprovalsForNewPatchSet(
-      ReviewDb db,
       ChangeUpdate update,
       LabelTypes labelTypes,
       PatchSet ps,
       CurrentUser user,
       Map<String, Short> approvals)
-      throws RestApiException, OrmException, PermissionBackendException {
+      throws RestApiException, PermissionBackendException {
     Account.Id accountId = user.getAccountId();
     checkArgument(
-        accountId.equals(ps.getUploader()),
+        accountId.equals(ps.uploader()),
         "expected user %s to match patch set uploader %s",
         accountId,
-        ps.getUploader());
+        ps.uploader());
     if (approvals.isEmpty()) {
       return ImmutableList.of();
     }
-    checkApprovals(approvals, permissionBackend.user(user).database(db).change(update.getNotes()));
+    checkApprovals(approvals, permissionBackend.user(user).change(update.getNotes()));
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
     Date ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       LabelType lt = labelTypes.byLabel(vote.getKey());
-      cells.add(newApproval(ps.getId(), user, lt.getLabelId(), vote.getValue(), ts));
+      cells.add(newApproval(ps.id(), user, lt.getLabelId(), vote.getValue(), ts).build());
     }
     for (PatchSetApproval psa : cells) {
-      update.putApproval(psa.getLabel(), psa.getValue());
+      update.putApproval(psa.label(), psa.value());
     }
-    db.patchSetApprovals().insert(cells);
     return cells;
   }
 
@@ -372,56 +323,32 @@
     }
   }
 
-  public ListMultimap<PatchSet.Id, PatchSetApproval> byChange(ReviewDb db, ChangeNotes notes)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      ImmutableListMultimap.Builder<PatchSet.Id, PatchSetApproval> result =
-          ImmutableListMultimap.builder();
-      for (PatchSetApproval psa : db.patchSetApprovals().byChange(notes.getChangeId())) {
-        result.put(psa.getPatchSetId(), psa);
-      }
-      return result.build();
-    }
+  public ListMultimap<PatchSet.Id, PatchSetApproval> byChange(ChangeNotes notes) {
     return notes.load().getApprovals();
   }
 
   public Iterable<PatchSetApproval> byPatchSet(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet.Id psId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return sortApprovals(db.patchSetApprovals().byPatchSet(psId));
-    }
-    return copier.getForPatchSet(db, notes, user, psId, rw, repoConfig);
+      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
+    return copier.getForPatchSet(notes, psId, rw, repoConfig);
   }
 
   public Iterable<PatchSetApproval> byPatchSetUser(
-      ReviewDb db,
       ChangeNotes notes,
-      CurrentUser user,
       PatchSet.Id psId,
       Account.Id accountId,
       @Nullable RevWalk rw,
-      @Nullable Config repoConfig)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return sortApprovals(db.patchSetApprovals().byPatchSetUser(psId, accountId));
-    }
-    return filterApprovals(byPatchSet(db, notes, user, psId, rw, repoConfig), accountId);
+      @Nullable Config repoConfig) {
+    return filterApprovals(byPatchSet(notes, psId, rw, repoConfig), accountId);
   }
 
-  public PatchSetApproval getSubmitter(ReviewDb db, ChangeNotes notes, PatchSet.Id c) {
+  public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) {
     if (c == null) {
       return null;
     }
     try {
       // Submit approval is never copied, so bypass expensive byPatchSet call.
-      return getSubmitter(c, byChange(db, notes).get(c));
-    } catch (OrmException e) {
+      return getSubmitter(c, byChange(notes).get(c));
+    } catch (StorageException e) {
       return null;
     }
   }
@@ -432,8 +359,8 @@
     }
     PatchSetApproval submitter = null;
     for (PatchSetApproval a : approvals) {
-      if (a.getPatchSetId().equals(c) && a.getValue() > 0 && a.isLegacySubmit()) {
-        if (submitter == null || a.getGranted().compareTo(submitter.getGranted()) > 0) {
+      if (a.patchSetId().equals(c) && a.value() > 0 && a.isLegacySubmit()) {
+        if (submitter == null || a.granted().compareTo(submitter.granted()) > 0) {
           submitter = a;
         }
       }
@@ -447,7 +374,7 @@
     if (!n.isEmpty()) {
       boolean first = true;
       for (Map.Entry<String, Short> e : n.entrySet()) {
-        if (c.containsKey(e.getKey()) && c.get(e.getKey()).getValue() == e.getValue()) {
+        if (c.containsKey(e.getKey()) && c.get(e.getKey()).value() == e.getValue()) {
           continue;
         }
         if (first) {
diff --git a/java/com/google/gerrit/server/AuditEvent.java b/java/com/google/gerrit/server/AuditEvent.java
new file mode 100644
index 0000000..773a307
--- /dev/null
+++ b/java/com/google/gerrit/server/AuditEvent.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.server.util.time.TimeUtil;
+
+public class AuditEvent {
+
+  public static final String UNKNOWN_SESSION_ID = "000000000000000000000000000";
+  protected static final ImmutableListMultimap<String, ?> EMPTY_PARAMS = ImmutableListMultimap.of();
+
+  public final String sessionId;
+  public final CurrentUser who;
+  public final long when;
+  public final String what;
+  public final ListMultimap<String, ?> params;
+  public final Object result;
+  public final long timeAtStart;
+  public final long elapsed;
+  public final UUID uuid;
+
+  @AutoValue
+  public abstract static class UUID {
+    private static UUID create() {
+      return new AutoValue_AuditEvent_UUID(
+          String.format("audit:%s", java.util.UUID.randomUUID().toString()));
+    }
+
+    public abstract String uuid();
+  }
+
+  /**
+   * Creates a new audit event with results
+   *
+   * @param sessionId session id the event belongs to
+   * @param who principal that has generated the event
+   * @param what object of the event
+   * @param when time-stamp of when the event started
+   * @param params parameters of the event
+   * @param result result of the event
+   */
+  public AuditEvent(
+      String sessionId,
+      CurrentUser who,
+      String what,
+      long when,
+      ListMultimap<String, ?> params,
+      Object result) {
+    requireNonNull(what, "what is a mandatory not null param !");
+
+    this.sessionId = MoreObjects.firstNonNull(sessionId, UNKNOWN_SESSION_ID);
+    this.who = who;
+    this.what = what;
+    this.when = when;
+    this.timeAtStart = this.when;
+    this.params = MoreObjects.firstNonNull(params, EMPTY_PARAMS);
+    this.uuid = UUID.create();
+    this.result = result;
+    this.elapsed = TimeUtil.nowMs() - timeAtStart;
+  }
+
+  @Override
+  public int hashCode() {
+    return uuid.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    if (obj == null) {
+      return false;
+    }
+    if (getClass() != obj.getClass()) {
+      return false;
+    }
+
+    AuditEvent other = (AuditEvent) obj;
+    return this.uuid.equals(other.uuid);
+  }
+
+  @Override
+  public String toString() {
+    return String.format(
+        "AuditEvent UUID:%s, SID:%s, TS:%d, who:%s, what:%s",
+        uuid.uuid(), sessionId, when, who, what);
+  }
+}
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 280a467..6d4dfcf 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -1,3 +1,5 @@
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
 CONSTANTS_SRC = [
     "documentation/Constants.java",
 ]
@@ -6,6 +8,11 @@
     "config/GerritGlobalModule.java",
 ]
 
+TESTING_SRC = [
+    "account/externalids/testing/ExternalIdInserter.java",
+    "account/externalids/testing/ExternalIdTestUtil.java",
+]
+
 java_library(
     name = "constants",
     srcs = CONSTANTS_SRC,
@@ -22,7 +29,7 @@
     name = "server",
     srcs = glob(
         ["**/*.java"],
-        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC,
+        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC,
     ),
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/server"],
@@ -31,32 +38,63 @@
         ":constants",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/jgit",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
+        "//java/com/google/gerrit/proto",
         "//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/server/util/time",
         "//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",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
         "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
-        "//lib:pegdown",
         "//lib:protobuf",
         "//lib:servlet-api-3_1",
         "//lib:soy",
@@ -103,6 +141,7 @@
         ":server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//lib:blame-cache",
         "//lib:guava",
@@ -112,8 +151,6 @@
     ],
 )
 
-load("//tools/bzl:javadoc.bzl", "java_doc")
-
 java_doc(
     name = "doc",
     libs = [":server"],
diff --git a/java/com/google/gerrit/server/ChangeFinder.java b/java/com/google/gerrit/server/ChangeFinder.java
deleted file mode 100644
index 677b091..0000000
--- a/java/com/google/gerrit/server/ChangeFinder.java
+++ /dev/null
@@ -1,280 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import com.google.common.base.Throwables;
-import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.metrics.Counter1;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-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.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.change.ChangeTriplet;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.name.Named;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class ChangeFinder {
-  private static final String CACHE_NAME = "changeid_project";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(CACHE_NAME, Change.Id.class, String.class).maximumWeight(1024);
-      }
-    };
-  }
-
-  public enum ChangeIdType {
-    ALL,
-    TRIPLET,
-    NUMERIC_ID,
-    I_HASH,
-    PROJECT_NUMERIC_ID,
-    COMMIT_HASH
-  }
-
-  private final IndexConfig indexConfig;
-  private final Cache<Change.Id, String> changeIdProjectCache;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Provider<ReviewDb> reviewDb;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final Counter1<ChangeIdType> changeIdCounter;
-  private final ImmutableSet<ChangeIdType> allowedIdTypes;
-
-  @Inject
-  ChangeFinder(
-      IndexConfig indexConfig,
-      @Named(CACHE_NAME) Cache<Change.Id, String> changeIdProjectCache,
-      Provider<InternalChangeQuery> queryProvider,
-      Provider<ReviewDb> reviewDb,
-      ChangeNotes.Factory changeNotesFactory,
-      MetricMaker metricMaker,
-      @GerritServerConfig Config config) {
-    this.indexConfig = indexConfig;
-    this.changeIdProjectCache = changeIdProjectCache;
-    this.queryProvider = queryProvider;
-    this.reviewDb = reviewDb;
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeIdCounter =
-        metricMaker.newCounter(
-            "http/server/rest_api/change_id_type",
-            new Description("Total number of API calls per identifier type.")
-                .setRate()
-                .setUnit("requests"),
-            Field.ofEnum(ChangeIdType.class, "change_id_type"));
-    List<ChangeIdType> configuredChangeIdTypes =
-        ConfigUtil.getEnumList(config, "change", "api", "allowedIdentifier", ChangeIdType.ALL);
-    // Ensure that PROJECT_NUMERIC_ID can't be removed
-    configuredChangeIdTypes.add(ChangeIdType.PROJECT_NUMERIC_ID);
-    this.allowedIdTypes = ImmutableSet.copyOf(configuredChangeIdTypes);
-  }
-
-  public ChangeNotes findOne(String id) throws OrmException {
-    List<ChangeNotes> ctls = find(id);
-    if (ctls.size() != 1) {
-      return null;
-    }
-    return ctls.get(0);
-  }
-
-  /**
-   * Find changes matching the given identifier.
-   *
-   * @param id change identifier.
-   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
-   * @throws OrmException if an error occurred querying the database.
-   */
-  public List<ChangeNotes> find(String id) throws OrmException {
-    try {
-      return find(id, false);
-    } catch (DeprecatedIdentifierException e) {
-      // This can't happen because we don't enforce deprecation
-      throw new OrmException(e);
-    }
-  }
-
-  /**
-   * Find changes matching the given identifier.
-   *
-   * @param id change identifier.
-   * @param enforceDeprecation boolean to see if we should throw {@link
-   *     DeprecatedIdentifierException} in case the identifier is deprecated
-   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
-   * @throws OrmException if an error occurred querying the database
-   * @throws DeprecatedIdentifierException if the identifier is deprecated.
-   */
-  public List<ChangeNotes> find(String id, boolean enforceDeprecation)
-      throws OrmException, DeprecatedIdentifierException {
-    if (id.isEmpty()) {
-      return Collections.emptyList();
-    }
-
-    int z = id.lastIndexOf('~');
-    int y = id.lastIndexOf('~', z - 1);
-    if (y < 0 && z > 0) {
-      // Try project~numericChangeId
-      Integer n = Ints.tryParse(id.substring(z + 1));
-      if (n != null) {
-        checkIdType(ChangeIdType.PROJECT_NUMERIC_ID, enforceDeprecation, n.toString());
-        return fromProjectNumber(id.substring(0, z), n.intValue());
-      }
-    }
-
-    if (y < 0 && z < 0) {
-      // Try numeric changeId
-      Integer n = Ints.tryParse(id);
-      if (n != null) {
-        checkIdType(ChangeIdType.NUMERIC_ID, enforceDeprecation, n.toString());
-        return find(new Change.Id(n));
-      }
-    }
-
-    // Use the index to search for changes, but don't return any stored fields,
-    // to force rereading in case the index is stale.
-    InternalChangeQuery query = queryProvider.get().noFields();
-
-    // Try commit hash
-    if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
-      checkIdType(ChangeIdType.COMMIT_HASH, enforceDeprecation, id);
-      return asChangeNotes(query.byCommit(id));
-    }
-
-    if (y > 0 && z > 0) {
-      // Try change triplet (project~branch~Ihash...)
-      Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id, y, z);
-      if (triplet.isPresent()) {
-        ChangeTriplet t = triplet.get();
-        checkIdType(ChangeIdType.TRIPLET, enforceDeprecation, triplet.get().toString());
-        return asChangeNotes(query.byBranchKey(t.branch(), t.id()));
-      }
-    }
-
-    // Try isolated Ihash... format ("Change-Id: Ihash").
-    List<ChangeNotes> notes = asChangeNotes(query.byKeyPrefix(id));
-    if (!notes.isEmpty()) {
-      checkIdType(ChangeIdType.I_HASH, enforceDeprecation, id);
-    }
-    return notes;
-  }
-
-  private List<ChangeNotes> fromProjectNumber(String project, int changeNumber)
-      throws OrmException {
-    Change.Id cId = new Change.Id(changeNumber);
-    try {
-      return ImmutableList.of(
-          changeNotesFactory.createChecked(reviewDb.get(), Project.NameKey.parse(project), cId));
-    } catch (NoSuchChangeException e) {
-      return Collections.emptyList();
-    } catch (OrmException e) {
-      // Distinguish between a RepositoryNotFoundException (project argument invalid) and
-      // other OrmExceptions (failure in the persistence layer).
-      if (Throwables.getRootCause(e) instanceof RepositoryNotFoundException) {
-        return Collections.emptyList();
-      }
-      throw e;
-    }
-  }
-
-  public ChangeNotes findOne(Change.Id id) throws OrmException {
-    List<ChangeNotes> notes = find(id);
-    if (notes.size() != 1) {
-      throw new NoSuchChangeException(id);
-    }
-    return notes.get(0);
-  }
-
-  public List<ChangeNotes> find(Change.Id id) throws OrmException {
-    String project = changeIdProjectCache.getIfPresent(id);
-    if (project != null) {
-      return fromProjectNumber(project, id.get());
-    }
-
-    // Use the index to search for changes, but don't return any stored fields,
-    // to force rereading in case the index is stale.
-    InternalChangeQuery query = queryProvider.get().noFields();
-    List<ChangeData> r = query.byLegacyChangeId(id);
-    if (r.size() == 1) {
-      changeIdProjectCache.put(id, r.get(0).project().get());
-    }
-    return asChangeNotes(r);
-  }
-
-  private List<ChangeNotes> asChangeNotes(List<ChangeData> cds) throws OrmException {
-    List<ChangeNotes> notes = new ArrayList<>(cds.size());
-    if (!indexConfig.separateChangeSubIndexes()) {
-      for (ChangeData cd : cds) {
-        notes.add(cd.notes());
-      }
-      return notes;
-    }
-
-    // If an index implementation uses separate non-atomic subindexes, it's possible to temporarily
-    // observe a change as present in both subindexes, if this search is concurrent with a write.
-    // Dedup to avoid confusing the caller. We can choose an arbitrary ChangeData instance because
-    // the index results have no stored fields, so the data is already reloaded. (It's also possible
-    // that a change might appear in zero subindexes, but there's nothing we can do here to help
-    // this case.)
-    Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
-    for (ChangeData cd : cds) {
-      if (seen.add(cd.getId())) {
-        notes.add(cd.notes());
-      }
-    }
-    return notes;
-  }
-
-  private void checkIdType(ChangeIdType type, boolean enforceDeprecation, String val)
-      throws DeprecatedIdentifierException {
-    if (enforceDeprecation
-        && !allowedIdTypes.contains(ChangeIdType.ALL)
-        && !allowedIdTypes.contains(type)) {
-      throw new DeprecatedIdentifierException(
-          String.format(
-              "The provided change identifier %s is deprecated. "
-                  + "Use 'project~changeNumber' instead.",
-              val));
-    }
-    changeIdCounter.increment(type);
-  }
-}
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index e635072..7b0a66f 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -14,35 +14,24 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
-import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 
-/**
- * Utility functions to manipulate ChangeMessages.
- *
- * <p>These methods either query for and update ChangeMessages in the NoteDb or ReviewDb, depending
- * on the state of the NotesMigration.
- */
+/** Utility functions to manipulate ChangeMessages. */
 @Singleton
 public class ChangeMessagesUtil {
   public static final String AUTOGENERATED_TAG_PREFIX = "autogenerated:";
@@ -79,14 +68,11 @@
 
   public static ChangeMessage newMessage(
       PatchSet.Id psId, CurrentUser user, Timestamp when, String body, @Nullable String tag) {
-    checkNotNull(psId);
+    requireNonNull(psId);
     Account.Id accountId = user.isInternalUser() ? null : user.getAccountId();
     ChangeMessage m =
         new ChangeMessage(
-            new ChangeMessage.Key(psId.getParentKey(), ChangeUtil.messageUuid()),
-            accountId,
-            when,
-            psId);
+            ChangeMessage.key(psId.changeId(), ChangeUtil.messageUuid()), accountId, when, psId);
     m.setMessage(body);
     m.setTag(tag);
     user.updateRealAccountId(m::setRealAuthor);
@@ -97,27 +83,11 @@
     return workInProgress ? TAG_UPLOADED_WIP_PATCH_SET : TAG_UPLOADED_PATCH_SET;
   }
 
-  private static List<ChangeMessage> sortChangeMessages(Iterable<ChangeMessage> changeMessage) {
-    return ChangeNotes.MESSAGE_BY_TIME.sortedCopy(changeMessage);
-  }
-
-  private final NotesMigration migration;
-
-  @VisibleForTesting
-  @Inject
-  public ChangeMessagesUtil(NotesMigration migration) {
-    this.migration = migration;
-  }
-
-  public List<ChangeMessage> byChange(ReviewDb db, ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return sortChangeMessages(db.changeMessages().byChange(notes.getChangeId()));
-    }
+  public List<ChangeMessage> byChange(ChangeNotes notes) {
     return notes.load().getChangeMessages();
   }
 
-  public void addChangeMessage(ReviewDb db, ChangeUpdate update, ChangeMessage changeMessage)
-      throws OrmException {
+  public void addChangeMessage(ChangeUpdate update, ChangeMessage changeMessage) {
     checkState(
         Objects.equals(changeMessage.getAuthor(), update.getNullableAccountId()),
         "cannot store change message by %s in update by %s",
@@ -125,7 +95,21 @@
         update.getNullableAccountId());
     update.setChangeMessage(changeMessage.getMessage());
     update.setTag(changeMessage.getTag());
-    db.changeMessages().insert(Collections.singleton(changeMessage));
+  }
+
+  /**
+   * Replace an existing change message with the provided new message.
+   *
+   * <p>The ID of a change message is different between NoteDb and ReviewDb. In NoteDb, it's the
+   * commit SHA-1, but in ReviewDb it was generated randomly. Taking the target message as an index
+   * rather than an ID allowed us to delete the message from both NoteDb and ReviewDb.
+   *
+   * @param update change update.
+   * @param targetMessageId the id of the target change message.
+   * @param newMessage the new message which is going to replace the old.
+   */
+  public void replaceChangeMessage(ChangeUpdate update, String targetMessageId, String newMessage) {
+    update.deleteChangeMessageByRewritingHistory(targetMessageId, newMessage);
   }
 
   /**
@@ -140,7 +124,7 @@
       ChangeMessage message, AccountLoader accountLoader) {
     PatchSet.Id patchNum = message.getPatchSetId();
     ChangeMessageInfo cmi = new ChangeMessageInfo();
-    cmi.id = message.getKey().get();
+    cmi.id = message.getKey().uuid();
     cmi.author = accountLoader.get(message.getAuthor());
     cmi.date = message.getWrittenOn();
     cmi.message = message.getMessage();
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index d90f5d0..8c207a8 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server;
 
 import static java.util.Comparator.comparingInt;
+import static java.util.stream.Collectors.toSet;
 
-import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.reviewdb.client.Change;
@@ -24,9 +24,11 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.security.SecureRandom;
+import java.util.Collection;
 import java.util.Map;
 import java.util.Random;
-import org.eclipse.jgit.lib.ObjectId;
+import java.util.Set;
+import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -37,16 +39,8 @@
   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();
-  }
+      Ordering.from(comparingInt(PatchSet::number));
 
   /** @return a new unique identifier for change message entities. */
   public static String messageUuid() {
@@ -58,8 +52,7 @@
   /**
    * Get the next patch set ID from a previously-read map of all refs.
    *
-   * @param allRefs map of full ref name to ref, in the same format returned by {@link
-   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)} when passing {@code ""}.
+   * @param allRefs map of full ref name to ref.
    * @param id previous patch set ID.
    * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
    *     names appear in the {@code allRefs} map.
@@ -75,20 +68,25 @@
   /**
    * Get the next patch set ID from a previously-read map of refs below the change prefix.
    *
-   * @param changeRefs map of ref suffix to SHA-1, where the keys are ref names with the {@code
-   *     refs/changes/CD/ABCD/} prefix stripped. All refs should be under {@code id}'s change ref
-   *     prefix. The keys match the format returned by {@link
-   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)} when passing the appropriate {@code
-   *     refs/changes/CD/ABCD}.
+   * @param changeRefNames existing full change ref names with the same change ID as {@code id}.
    * @param id previous patch set ID.
    * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
    *     names appear in the {@code changeRefs} map.
    */
-  public static PatchSet.Id nextPatchSetIdFromChangeRefsMap(
-      Map<String, ObjectId> changeRefs, PatchSet.Id id) {
-    int prefixLen = id.getParentKey().toRefPrefix().length();
+  public static PatchSet.Id nextPatchSetIdFromChangeRefs(
+      Collection<String> changeRefNames, PatchSet.Id id) {
+    return nextPatchSetIdFromChangeRefs(changeRefNames.stream(), id);
+  }
+
+  private static PatchSet.Id nextPatchSetIdFromChangeRefs(
+      Stream<String> changeRefNames, PatchSet.Id id) {
+    Set<PatchSet.Id> existing =
+        changeRefNames
+            .map(PatchSet.Id::fromRef)
+            .filter(psId -> psId != null && psId.changeId().equals(id.changeId()))
+            .collect(toSet());
     PatchSet.Id next = nextPatchSetId(id);
-    while (changeRefs.containsKey(next.toRefName().substring(prefixLen))) {
+    while (existing.contains(next)) {
       next = nextPatchSetId(next);
     }
     return next;
@@ -99,13 +97,13 @@
    *
    * <p>This patch set ID may or may not be available in the database; callers that want a
    * previously-unused ID should use {@link #nextPatchSetIdFromAllRefsMap} or {@link
-   * #nextPatchSetIdFromChangeRefsMap}.
+   * #nextPatchSetIdFromChangeRefs}.
    *
    * @param id previous patch set ID.
    * @return next patch set ID for the same change, incrementing by 1.
    */
   public static PatchSet.Id nextPatchSetId(PatchSet.Id id) {
-    return new PatchSet.Id(id.getParentKey(), id.get() + 1);
+    return PatchSet.id(id.changeId(), id.get() + 1);
   }
 
   /**
@@ -117,27 +115,12 @@
    *     names appear in the repository.
    */
   public static PatchSet.Id nextPatchSetId(Repository git, PatchSet.Id id) throws IOException {
-    return nextPatchSetIdFromChangeRefsMap(
-        Maps.transformValues(
-            git.getRefDatabase().getRefs(id.getParentKey().toRefPrefix()), Ref::getObjectId),
+    return nextPatchSetIdFromChangeRefs(
+        git.getRefDatabase().getRefsByPrefix(id.changeId().toRefPrefix()).stream()
+            .map(Ref::getName),
         id);
   }
 
-  public static String cropSubject(String subject) {
-    if (subject.length() > SUBJECT_MAX_LENGTH) {
-      int maxLength = SUBJECT_MAX_LENGTH - SUBJECT_CROP_APPENDIX.length();
-      for (int cropPosition = maxLength;
-          cropPosition > maxLength - SUBJECT_CROP_RANGE;
-          cropPosition--) {
-        if (Character.isWhitespace(subject.charAt(cropPosition - 1))) {
-          return subject.substring(0, cropPosition) + SUBJECT_CROP_APPENDIX;
-        }
-      }
-      return subject.substring(0, maxLength) + SUBJECT_CROP_APPENDIX;
-    }
-    return subject;
-  }
-
   public static String status(Change c) {
     return c != null ? c.getStatus().name().toLowerCase() : "deleted";
   }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index c178137..449d61b 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -16,17 +16,14 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.reviewdb.client.PatchLineComment.Status.PUBLISHED;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
-import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -35,47 +32,28 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
-/**
- * Utility functions to manipulate Comments.
- *
- * <p>These methods either query for and update Comments in the NoteDb or ReviewDb, depending on the
- * state of the NotesMigration.
- */
+/** Utility functions to manipulate Comments. */
 @Singleton
 public class CommentsUtil {
   public static final Ordering<Comment> COMMENT_ORDER =
@@ -113,7 +91,7 @@
       };
 
   public static PatchSet.Id getCommentPsId(Change.Id changeId, Comment comment) {
-    return new PatchSet.Id(changeId, comment.key.patchSetId);
+    return PatchSet.id(changeId, comment.key.patchSetId);
   }
 
   public static String extractMessageId(@Nullable String tag) {
@@ -127,18 +105,13 @@
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
-  private final NotesMigration migration;
   private final String serverId;
 
   @Inject
   CommentsUtil(
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      NotesMigration migration,
-      @GerritServerId String serverId) {
+      GitRepositoryManager repoManager, AllUsersName allUsers, @GerritServerId String serverId) {
     this.repoManager = repoManager;
     this.allUsers = allUsers;
-    this.migration = migration;
     this.serverId = serverId;
   }
 
@@ -150,15 +123,15 @@
       String message,
       @Nullable Boolean unresolved,
       @Nullable String parentUuid)
-      throws OrmException, UnprocessableEntityException {
+      throws UnprocessableEntityException {
     if (unresolved == null) {
       if (parentUuid == null) {
         // Default to false if comment is not descended from another.
         unresolved = false;
       } else {
         // Inherit unresolved value from inReplyTo comment if not specified.
-        Comment.Key key = new Comment.Key(parentUuid, path, psId.patchSetId);
-        Optional<Comment> parent = getPublished(ctx.getDb(), ctx.getNotes(), key);
+        Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
+        Optional<Comment> parent = getPublished(ctx.getNotes(), key);
         if (!parent.isPresent()) {
           throw new UnprocessableEntityException("Invalid parentUuid supplied for comment");
         }
@@ -201,119 +174,60 @@
     return c;
   }
 
-  public Optional<Comment> getPublished(ReviewDb db, ChangeNotes notes, Comment.Key key)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return getReviewDb(db, notes, key);
-    }
-    return publishedByChange(db, notes).stream().filter(c -> key.equals(c.key)).findFirst();
+  public Optional<Comment> getPublished(ChangeNotes notes, Comment.Key key) {
+    return publishedByChange(notes).stream().filter(c -> key.equals(c.key)).findFirst();
   }
 
-  public Optional<Comment> getDraft(
-      ReviewDb db, ChangeNotes notes, IdentifiedUser user, Comment.Key key) throws OrmException {
-    if (!migration.readChanges()) {
-      Optional<Comment> c = getReviewDb(db, notes, key);
-      if (c.isPresent() && !c.get().author.getId().equals(user.getAccountId())) {
-        throw new OrmException(
-            String.format(
-                "Expected draft %s to belong to account %s, but it belongs to %s",
-                key, user.getAccountId(), c.get().author.getId()));
-      }
-      return c;
-    }
-    return draftByChangeAuthor(db, notes, user.getAccountId())
-        .stream()
+  public Optional<Comment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
+    return draftByChangeAuthor(notes, user.getAccountId()).stream()
         .filter(c -> key.equals(c.key))
         .findFirst();
   }
 
-  private Optional<Comment> getReviewDb(ReviewDb db, ChangeNotes notes, Comment.Key key)
-      throws OrmException {
-    return Optional.ofNullable(
-            db.patchComments().get(PatchLineComment.Key.from(notes.getChangeId(), key)))
-        .map(plc -> plc.asComment(serverId));
-  }
-
-  public List<Comment> publishedByChange(ReviewDb db, ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(byCommentStatus(db.patchComments().byChange(notes.getChangeId()), PUBLISHED));
-    }
-
+  public List<Comment> publishedByChange(ChangeNotes notes) {
     notes.load();
     return sort(Lists.newArrayList(notes.getComments().values()));
   }
 
-  public List<RobotComment> robotCommentsByChange(ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return ImmutableList.of();
-    }
-
+  public List<RobotComment> robotCommentsByChange(ChangeNotes notes) {
     notes.load();
     return sort(Lists.newArrayList(notes.getRobotComments().values()));
   }
 
-  public List<Comment> draftByChange(ReviewDb db, ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(byCommentStatus(db.patchComments().byChange(notes.getChangeId()), Status.DRAFT));
-    }
-
+  public List<Comment> draftByChange(ChangeNotes notes) {
     List<Comment> comments = new ArrayList<>();
     for (Ref ref : getDraftRefs(notes.getChangeId())) {
       Account.Id account = Account.Id.fromRefSuffix(ref.getName());
       if (account != null) {
-        comments.addAll(draftByChangeAuthor(db, notes, account));
+        comments.addAll(draftByChangeAuthor(notes, account));
       }
     }
     return sort(comments);
   }
 
-  private List<Comment> byCommentStatus(
-      ResultSet<PatchLineComment> comments, PatchLineComment.Status status) {
-    return toComments(
-        serverId, Lists.newArrayList(Iterables.filter(comments, c -> c.getStatus() == status)));
-  }
-
-  public List<Comment> byPatchSet(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(toComments(serverId, db.patchComments().byPatchSet(psId).toList()));
-    }
+  public List<Comment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
     List<Comment> comments = new ArrayList<>();
-    comments.addAll(publishedByPatchSet(db, notes, psId));
+    comments.addAll(publishedByPatchSet(notes, psId));
 
     for (Ref ref : getDraftRefs(notes.getChangeId())) {
       Account.Id account = Account.Id.fromRefSuffix(ref.getName());
       if (account != null) {
-        comments.addAll(draftByPatchSetAuthor(db, psId, account, notes));
+        comments.addAll(draftByPatchSetAuthor(psId, account, notes));
       }
     }
     return sort(comments);
   }
 
-  public List<Comment> publishedByChangeFile(
-      ReviewDb db, ChangeNotes notes, Change.Id changeId, String file) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          toComments(serverId, db.patchComments().publishedByChangeFile(changeId, file).toList()));
-    }
+  public List<Comment> publishedByChangeFile(ChangeNotes notes, String file) {
     return commentsOnFile(notes.load().getComments().values(), file);
   }
 
-  public List<Comment> publishedByPatchSet(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return removeCommentsOnAncestorOfCommitMessage(
-          sort(toComments(serverId, db.patchComments().publishedByPatchSet(psId).toList())));
-    }
+  public List<Comment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
     return removeCommentsOnAncestorOfCommitMessage(
         commentsOnPatchSet(notes.load().getComments().values(), psId));
   }
 
-  public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes, PatchSet.Id psId)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return ImmutableList.of();
-    }
+  public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
     return commentsOnPatchSet(notes.load().getRobotComments().values(), psId);
   }
 
@@ -331,48 +245,25 @@
   }
 
   public List<Comment> draftByPatchSetAuthor(
-      ReviewDb db, PatchSet.Id psId, Account.Id author, ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          toComments(serverId, db.patchComments().draftByPatchSetAuthor(psId, author).toList()));
-    }
+      PatchSet.Id psId, Account.Id author, ChangeNotes notes) {
     return commentsOnPatchSet(notes.load().getDraftComments(author).values(), psId);
   }
 
-  public List<Comment> draftByChangeFileAuthor(
-      ReviewDb db, ChangeNotes notes, String file, Account.Id author) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          toComments(
-              serverId,
-              db.patchComments()
-                  .draftByChangeFileAuthor(notes.getChangeId(), file, author)
-                  .toList()));
-    }
+  public List<Comment> draftByChangeFileAuthor(ChangeNotes notes, String file, Account.Id author) {
     return commentsOnFile(notes.load().getDraftComments(author).values(), file);
   }
 
-  public List<Comment> draftByChangeAuthor(ReviewDb db, ChangeNotes notes, Account.Id author)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return Streams.stream(db.patchComments().draftByAuthor(author))
-          .filter(c -> c.getPatchSetId().getParentKey().equals(notes.getChangeId()))
-          .map(plc -> plc.asComment(serverId))
-          .sorted(COMMENT_ORDER)
-          .collect(toList());
-    }
+  public List<Comment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
     List<Comment> comments = new ArrayList<>();
     comments.addAll(notes.getDraftComments(author).values());
     return sort(comments);
   }
 
   public void putComments(
-      ReviewDb db, ChangeUpdate update, PatchLineComment.Status status, Iterable<Comment> comments)
-      throws OrmException {
+      ChangeUpdate update, PatchLineComment.Status status, Iterable<Comment> comments) {
     for (Comment c : comments) {
       update.putComment(status, c);
     }
-    db.patchComments().upsert(toPatchLineComments(update.getId(), status, comments));
   }
 
   public void putRobotComments(ChangeUpdate update, Iterable<RobotComment> comments) {
@@ -381,60 +272,17 @@
     }
   }
 
-  public void deleteComments(ReviewDb db, ChangeUpdate update, Iterable<Comment> comments)
-      throws OrmException {
+  public void deleteComments(ChangeUpdate update, Iterable<Comment> comments) {
     for (Comment c : comments) {
       update.deleteComment(c);
     }
-    db.patchComments()
-        .delete(toPatchLineComments(update.getId(), PatchLineComment.Status.DRAFT, comments));
   }
 
   public void deleteCommentByRewritingHistory(
-      ReviewDb db, ChangeUpdate update, Comment.Key commentKey, PatchSet.Id psId, String newMessage)
-      throws OrmException {
-    if (PrimaryStorage.of(update.getChange()).equals(PrimaryStorage.REVIEW_DB)) {
-      PatchLineComment.Key key =
-          new PatchLineComment.Key(new Patch.Key(psId, commentKey.filename), commentKey.uuid);
-
-      if (db instanceof BatchUpdateReviewDb) {
-        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-      }
-      db = ReviewDbUtil.unwrapDb(db);
-
-      PatchLineComment patchLineComment = db.patchComments().get(key);
-
-      if (!patchLineComment.getStatus().equals(PUBLISHED)) {
-        throw new OrmException(String.format("comment %s is not published", key));
-      }
-
-      patchLineComment.setMessage(newMessage);
-      db.patchComments().upsert(Collections.singleton(patchLineComment));
-    }
-
+      ChangeUpdate update, Comment.Key commentKey, String newMessage) {
     update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
   }
 
-  public void deleteAllDraftsFromAllUsers(Change.Id changeId) throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-      for (Ref ref : getDraftRefs(repo, changeId)) {
-        bru.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
-      }
-      bru.setRefLogMessage("Delete drafts from NoteDb", false);
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-      for (ReceiveCommand cmd : bru.getCommands()) {
-        if (cmd.getResult() != ReceiveCommand.Result.OK) {
-          throw new IOException(
-              String.format(
-                  "Failed to delete draft comment ref %s at %s: %s (%s)",
-                  cmd.getRefName(), cmd.getOldId(), cmd.getResult(), cmd.getMessage()));
-        }
-      }
-    }
-  }
-
   private static List<Comment> commentsOnFile(Collection<Comment> allComments, String file) {
     List<Comment> result = new ArrayList<>(allComments.size());
     for (Comment c : allComments) {
@@ -457,22 +305,22 @@
     return sort(result);
   }
 
-  public static void setCommentRevId(Comment c, PatchListCache cache, Change change, PatchSet ps)
+  public static void setCommentCommitId(Comment c, PatchListCache cache, Change change, PatchSet ps)
       throws PatchListNotAvailableException {
     checkArgument(
-        c.key.patchSetId == ps.getId().get(),
-        "cannot set RevId for patch set %s on comment %s",
-        ps.getId(),
+        c.key.patchSetId == ps.id().get(),
+        "cannot set commit ID for patch set %s on comment %s",
+        ps.id(),
         c);
-    if (c.revId == null) {
+    if (c.getCommitId() == null) {
       if (Side.fromShort(c.side) == Side.PARENT) {
         if (c.side < 0) {
-          c.revId = ObjectId.toString(cache.getOldId(change, ps, -c.side));
+          c.setCommitId(cache.getOldId(change, ps, -c.side));
         } else {
-          c.revId = ObjectId.toString(cache.getOldId(change, ps, null));
+          c.setCommitId(cache.getOldId(change, ps, null));
         }
       } else {
-        c.revId = ps.getRevision().get();
+        c.setCommitId(ps.commitId());
       }
     }
   }
@@ -489,20 +337,20 @@
    * @param changeId change ID.
    * @return raw refs from All-Users repo.
    */
-  public Collection<Ref> getDraftRefs(Change.Id changeId) throws OrmException {
+  public Collection<Ref> getDraftRefs(Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       return getDraftRefs(repo, changeId);
     } catch (IOException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
   private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId) throws IOException {
-    return repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(changeId)).values();
+    return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(changeId));
   }
 
   private static <T extends Comment> List<T> sort(List<T> comments) {
-    Collections.sort(comments, COMMENT_ORDER);
+    comments.sort(COMMENT_ORDER);
     return comments;
   }
 
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index b7bc036..e6c46df 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Sets;
@@ -57,17 +57,20 @@
   private final AllUsersName allUsers;
   private final ProjectCache projectCache;
   private final Provider<MetaDataUpdate.Server> metaDataUpdateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   CreateGroupPermissionSyncer(
       AllProjectsName allProjects,
       AllUsersName allUsers,
       ProjectCache projectCache,
-      Provider<MetaDataUpdate.Server> metaDataUpdateFactory) {
+      Provider<MetaDataUpdate.Server> metaDataUpdateFactory,
+      ProjectConfig.Factory projectConfigFactory) {
     this.allProjects = allProjects;
     this.allUsers = allUsers;
     this.projectCache = projectCache;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   /**
@@ -77,9 +80,11 @@
    */
   public void syncIfNeeded() throws IOException, ConfigInvalidException {
     ProjectState allProjectsState = projectCache.checkedGet(allProjects);
-    checkNotNull(allProjectsState, "Can't obtain project state for " + allProjects);
+    requireNonNull(
+        allProjectsState, () -> String.format("Can't obtain project state for %s", allProjects));
     ProjectState allUsersState = projectCache.checkedGet(allUsers);
-    checkNotNull(allUsersState, "Can't obtain project state for " + allUsers);
+    requireNonNull(
+        allUsersState, () -> String.format("Can't obtain project state for %s", allUsers));
 
     Set<PermissionRule> createGroupsGlobal =
         new HashSet<>(allProjectsState.getCapabilityCollection().createGroup);
@@ -100,14 +105,12 @@
     }
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsers)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       AccessSection createGroupAccessSection =
           config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
       if (createGroupsGlobal.isEmpty()) {
         createGroupAccessSection.setPermissions(
-            createGroupAccessSection
-                .getPermissions()
-                .stream()
+            createGroupAccessSection.getPermissions().stream()
                 .filter(p -> !Permission.CREATE.equals(p.getName()))
                 .collect(toList()));
         config.replace(createGroupAccessSection);
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index 3759f09..44d3493 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -26,7 +26,6 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.WeakHashMap;
 
 /** Helper class to define and parse options from plugins on ssh and RestAPI commands. */
@@ -50,8 +49,18 @@
    *   }
    * </pre>
    *
-   * The option will be prefixed by the plugin name. In the example above, if the plugin name was
+   * <p>The option will be prefixed by the plugin name. In the example above, if the plugin name was
    * my-plugin, then the --verbose option as used by the caller would be --my-plugin--verbose.
+   *
+   * <p>Additional options can be annotated with @RequiresOption which will cause them to be ignored
+   * unless the required option is present. For example:
+   *
+   * <pre>
+   *   {@literal @}RequiresOptions("--help")
+   *   {@literal @}Option(name = "--help-as-json",
+   *           usage = "display help text in json format")
+   *   public boolean displayHelpAsJson;
+   * </pre>
    */
   public interface DynamicBean {}
 
@@ -144,6 +153,21 @@
    */
   public interface BeanReceiver {
     void setDynamicBean(String plugin, DynamicBean dynamicBean);
+
+    /**
+     * Returns the class that should be used for looking up exported DynamicBean bindings from
+     * plugins. Override when a particular REST/SSH endpoint should respect DynamicBeans bound on a
+     * different endpoint. For example, {@code GetDetail} is just a synonym for a variant of {@code
+     * GetChange}, and it should respect any DynamicBeans on GetChange. GetChange}. So it should
+     * return {@code GetChange.class} from this method.
+     */
+    default Class<? extends BeanReceiver> getExportedBeanReceiver() {
+      return getClass();
+    }
+  }
+
+  public interface BeanProvider {
+    DynamicBean getDynamicBean(String plugin);
   }
 
   /**
@@ -161,8 +185,7 @@
    * classloaders.
    */
   protected static Map<ClassLoader, Map<ClassLoader, WeakReference<ClassLoader>>> mergedClByCls =
-      Collections.synchronizedMap(
-          new WeakHashMap<ClassLoader, Map<ClassLoader, WeakReference<ClassLoader>>>());
+      Collections.synchronizedMap(new WeakHashMap<>());
 
   protected Object bean;
   protected Map<String, DynamicBean> beansByPlugin;
@@ -187,9 +210,13 @@
     this.bean = bean;
     this.injector = injector;
     beansByPlugin = new HashMap<>();
+    Class<?> beanClass =
+        (bean instanceof BeanReceiver)
+            ? ((BeanReceiver) bean).getExportedBeanReceiver()
+            : getClass();
     for (String plugin : dynamicBeans.plugins()) {
       Provider<DynamicBean> provider =
-          dynamicBeans.byPlugin(plugin).get(bean.getClass().getCanonicalName());
+          dynamicBeans.byPlugin(plugin).get(beanClass.getCanonicalName());
       if (provider != null) {
         beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
       }
@@ -258,22 +285,23 @@
   }
 
   public void parseDynamicBeans(CmdLineParser clp) {
-    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+    for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
       clp.parseWithPrefix("--" + e.getKey(), e.getValue());
     }
+    clp.drainOptionQueue();
   }
 
   public void setDynamicBeans() {
     if (bean instanceof BeanReceiver) {
       BeanReceiver receiver = (BeanReceiver) bean;
-      for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
         receiver.setDynamicBean(e.getKey(), e.getValue());
       }
     }
   }
 
   public void onBeanParseStart() {
-    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+    for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
       DynamicBean instance = e.getValue();
       if (instance instanceof BeanParseListener) {
         BeanParseListener listener = (BeanParseListener) instance;
@@ -283,7 +311,7 @@
   }
 
   public void onBeanParseEnd() {
-    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+    for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
       DynamicBean instance = e.getValue();
       if (instance instanceof BeanParseListener) {
         BeanParseListener listener = (BeanParseListener) instance;
diff --git a/java/com/google/gerrit/server/GerritPersonIdent.java b/java/com/google/gerrit/server/GerritPersonIdent.java
index 5d259b3..544a106 100644
--- a/java/com/google/gerrit/server/GerritPersonIdent.java
+++ b/java/com/google/gerrit/server/GerritPersonIdent.java
@@ -20,8 +20,11 @@
 import java.lang.annotation.Retention;
 
 /**
- * Marker on a {@link org.eclipse.jgit.lib.PersonIdent} pointing to the identity representing Gerrit
- * server itself.
+ * Marker on a {@link org.eclipse.jgit.lib.PersonIdent} pointing to the identity + timestamp
+ * representing the Gerrit server itself.
+ *
+ * <p>When injecting this into a singleton class, use {@code Provider<PersonIdent>} so you get a
+ * fresh timestamp for each call to {@code get()}.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
diff --git a/java/com/google/gerrit/server/GerritPersonIdentProvider.java b/java/com/google/gerrit/server/GerritPersonIdentProvider.java
index 87ba55a..3ec07bd 100644
--- a/java/com/google/gerrit/server/GerritPersonIdentProvider.java
+++ b/java/com/google/gerrit/server/GerritPersonIdentProvider.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
+
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -30,12 +32,14 @@
 
   @Inject
   public GerritPersonIdentProvider(@GerritServerConfig Config cfg) {
-    String name = cfg.getString("user", null, "name");
-    if (name == null) {
-      name = "Gerrit Code Review";
-    }
-    this.name = name;
-    email = cfg.get(UserConfig.KEY).getCommitterEmail();
+    StringBuilder name = new StringBuilder();
+    PersonIdent.appendSanitized(
+        name, firstNonNull(cfg.getString("user", null, "name"), "Gerrit Code Review"));
+    this.name = name.toString();
+
+    StringBuilder email = new StringBuilder();
+    PersonIdent.appendSanitized(email, cfg.get(UserConfig.KEY).getCommitterEmail());
+    this.email = email.toString();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 16546f9..7e18280 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;
@@ -30,7 +32,7 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
+import com.google.gerrit.server.config.EnableReverseDnsLookup;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import com.google.inject.OutOfScopeException;
@@ -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 {
@@ -63,7 +67,7 @@
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
-    private final Boolean disableReverseDnsLookup;
+    private final Boolean enableReverseDnsLookup;
 
     @Inject
     public GenericFactory(
@@ -71,7 +75,7 @@
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
         @CanonicalWebUrl Provider<String> canonicalUrl,
-        @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
+        @EnableReverseDnsLookup Boolean enableReverseDnsLookup,
         AccountCache accountCache,
         GroupBackend groupBackend) {
       this.authConfig = authConfig;
@@ -80,7 +84,7 @@
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
-      this.disableReverseDnsLookup = disableReverseDnsLookup;
+      this.enableReverseDnsLookup = enableReverseDnsLookup;
     }
 
     public IdentifiedUser create(AccountState state) {
@@ -91,14 +95,14 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          disableReverseDnsLookup,
-          Providers.of((SocketAddress) null),
+          enableReverseDnsLookup,
+          Providers.of(null),
           state,
           null);
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return create((SocketAddress) null, id);
+      return create(null, id);
     }
 
     public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
@@ -114,7 +118,7 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          disableReverseDnsLookup,
+          enableReverseDnsLookup,
           Providers.of(remotePeer),
           id,
           caller);
@@ -124,8 +128,8 @@
   /**
    * Create an IdentifiedUser, relying on current request state.
    *
-   * <p>Can only be used from within a module that has defined request scoped {@code @RemotePeer
-   * SocketAddress} and {@code ReviewDb} providers.
+   * <p>Can only be used from within a module that has defined a request scoped {@code @RemotePeer
+   * SocketAddress} provider.
    */
   @Singleton
   public static class RequestFactory {
@@ -135,7 +139,7 @@
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
-    private final Boolean disableReverseDnsLookup;
+    private final Boolean enableReverseDnsLookup;
     private final Provider<SocketAddress> remotePeerProvider;
 
     @Inject
@@ -146,7 +150,7 @@
         @CanonicalWebUrl Provider<String> canonicalUrl,
         AccountCache accountCache,
         GroupBackend groupBackend,
-        @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
+        @EnableReverseDnsLookup Boolean enableReverseDnsLookup,
         @RemotePeer Provider<SocketAddress> remotePeerProvider) {
       this.authConfig = authConfig;
       this.realm = realm;
@@ -154,7 +158,7 @@
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
-      this.disableReverseDnsLookup = disableReverseDnsLookup;
+      this.enableReverseDnsLookup = enableReverseDnsLookup;
       this.remotePeerProvider = remotePeerProvider;
     }
 
@@ -166,7 +170,7 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          disableReverseDnsLookup,
+          enableReverseDnsLookup,
           remotePeerProvider,
           id,
           null);
@@ -180,7 +184,7 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          disableReverseDnsLookup,
+          enableReverseDnsLookup,
           remotePeerProvider,
           id,
           caller);
@@ -197,7 +201,7 @@
   private final Realm realm;
   private final GroupBackend groupBackend;
   private final String anonymousCowardName;
-  private final Boolean disableReverseDnsLookup;
+  private final Boolean enableReverseDnsLookup;
   private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
   private final CurrentUser realUser; // Must be final since cached properties depend on it.
 
@@ -217,7 +221,7 @@
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
-      Boolean disableReverseDnsLookup,
+      Boolean enableReverseDnsLookup,
       @Nullable Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
@@ -228,9 +232,9 @@
         canonicalUrl,
         accountCache,
         groupBackend,
-        disableReverseDnsLookup,
+        enableReverseDnsLookup,
         remotePeerProvider,
-        state.getAccount().getId(),
+        state.getAccount().id(),
         realUser);
     this.state = state;
   }
@@ -242,7 +246,7 @@
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
-      Boolean disableReverseDnsLookup,
+      Boolean enableReverseDnsLookup,
       @Nullable Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
       @Nullable CurrentUser realUser) {
@@ -252,7 +256,7 @@
     this.authConfig = authConfig;
     this.realm = realm;
     this.anonymousCowardName = anonymousCowardName;
-    this.disableReverseDnsLookup = disableReverseDnsLookup;
+    this.enableReverseDnsLookup = enableReverseDnsLookup;
     this.remotePeerProvider = remotePeerProvider;
     this.accountId = id;
     this.realUser = realUser != null ? realUser : this;
@@ -326,8 +330,7 @@
   @Override
   public String getLoggableName() {
     return getUserName()
-        .orElseGet(
-            () -> firstNonNull(getAccount().getPreferredEmail(), "a/" + getAccountId().get()));
+        .orElseGet(() -> firstNonNull(getAccount().preferredEmail(), "a/" + getAccountId().get()));
   }
 
   /**
@@ -375,8 +378,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;
@@ -394,29 +402,29 @@
   public PersonIdent newRefLogIdent(Date when, TimeZone tz) {
     final Account ua = getAccount();
 
-    String name = ua.getFullName();
+    String name = ua.fullName();
     if (name == null || name.isEmpty()) {
-      name = ua.getPreferredEmail();
+      name = ua.preferredEmail();
     }
     if (name == null || name.isEmpty()) {
       name = anonymousCowardName;
     }
 
-    String user = getUserName().orElse("") + "|account-" + ua.getId().toString();
+    String user = getUserName().orElse("") + "|account-" + ua.id().toString();
     return new PersonIdent(name, user + "@" + guessHost(), when, tz);
   }
 
   public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
     final Account ua = getAccount();
-    String name = ua.getFullName();
-    String email = ua.getPreferredEmail();
+    String name = ua.fullName();
+    String email = ua.preferredEmail();
 
     if (email == null || email.isEmpty()) {
       // No preferred email is configured. Use a generic identity so we
       // don't leak an address the user may have given us, but doesn't
       // necessarily want to publish through Git records.
       //
-      String user = getUserName().orElseGet(() -> "account-" + ua.getId().toString());
+      String user = getUserName().orElseGet(() -> "account-" + ua.id().toString());
 
       String host;
       if (canonicalUrl.get() != null) {
@@ -503,11 +511,8 @@
       remotePeer = Providers.of(remotePeerProvider.get());
     } catch (OutOfScopeException | ProvisionException e) {
       remotePeer =
-          new Provider<SocketAddress>() {
-            @Override
-            public SocketAddress get() {
-              throw e;
-            }
+          () -> {
+            throw e;
           };
     }
     return new IdentifiedUser(
@@ -517,7 +522,7 @@
         Providers.of(canonicalUrl.get()),
         accountCache,
         groupBackend,
-        disableReverseDnsLookup,
+        enableReverseDnsLookup,
         remotePeer,
         state,
         realUser);
@@ -548,7 +553,7 @@
   }
 
   private String getHost(InetAddress in) {
-    if (Boolean.FALSE.equals(disableReverseDnsLookup)) {
+    if (Boolean.TRUE.equals(enableReverseDnsLookup)) {
       return in.getCanonicalHostName();
     }
     return in.getHostAddress();
diff --git a/java/com/google/gerrit/server/LibModuleLoader.java b/java/com/google/gerrit/server/LibModuleLoader.java
index d1067e1..0a6fb9f 100644
--- a/java/com/google/gerrit/server/LibModuleLoader.java
+++ b/java/com/google/gerrit/server/LibModuleLoader.java
@@ -30,9 +30,9 @@
 public class LibModuleLoader {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static List<Module> loadModules(Injector parent) {
+  public static List<Module> loadModules(Injector parent, LibModuleType moduleType) {
     Config cfg = getConfig(parent);
-    return Arrays.stream(cfg.getStringList("gerrit", null, "installModule"))
+    return Arrays.stream(cfg.getStringList("gerrit", null, "install" + moduleType.getConfigKey()))
         .map(m -> createModule(parent, m))
         .collect(toList());
   }
diff --git a/java/com/google/gerrit/server/LibModuleType.java b/java/com/google/gerrit/server/LibModuleType.java
new file mode 100644
index 0000000..557f8c0
--- /dev/null
+++ b/java/com/google/gerrit/server/LibModuleType.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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;
+
+/** Loadable module type for the different Gerrit injectors. */
+public enum LibModuleType {
+
+  /** Module for the sysInjector. */
+  SYS_MODULE("Module"),
+
+  /** Module for the dbInjector. */
+  DB_MODULE("DbModule");
+
+  private final String configKey;
+
+  LibModuleType(String configKey) {
+    this.configKey = configKey;
+  }
+
+  /**
+   * Returns the module type for libModule loaded from <gerrit_site/lib> directory.
+   *
+   * @return module type string
+   */
+  public String getConfigKey() {
+    return configKey;
+  }
+}
diff --git a/java/com/google/gerrit/server/ModuleOverloader.java b/java/com/google/gerrit/server/ModuleOverloader.java
index 7083e6d..9a8fe84 100644
--- a/java/com/google/gerrit/server/ModuleOverloader.java
+++ b/java/com/google/gerrit/server/ModuleOverloader.java
@@ -27,8 +27,7 @@
 
     // group candidates by annotation existence
     Map<Boolean, List<Module>> grouped =
-        overrideCandidates
-            .stream()
+        overrideCandidates.stream()
             .collect(
                 Collectors.groupingBy(m -> m.getClass().getAnnotation(ModuleImpl.class) != null));
 
@@ -44,16 +43,14 @@
     }
 
     // swipe cache implementation with alternative provided in lib
-    return modules
-        .stream()
+    return modules.stream()
         .map(
             m -> {
               ModuleImpl a = m.getClass().getAnnotation(ModuleImpl.class);
               if (a == null) {
                 return m;
               }
-              return overrides
-                  .stream()
+              return overrides.stream()
                   .filter(
                       o ->
                           o.getClass()
diff --git a/java/com/google/gerrit/server/OutputFormat.java b/java/com/google/gerrit/server/OutputFormat.java
deleted file mode 100644
index e555845..0000000
--- a/java/com/google/gerrit/server/OutputFormat.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import com.google.gson.FieldNamingPolicy;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gwtjsonrpc.server.SqlTimestampDeserializer;
-import java.sql.Timestamp;
-
-/** Standard output format used by an API call. */
-public enum OutputFormat {
-  /**
-   * The output is a human readable text format. It may also be regular enough to be machine
-   * readable. Whether or not the text format is machine readable and will be committed to as a long
-   * term format that tools can build upon is specific to each API call.
-   */
-  TEXT,
-
-  /**
-   * Pretty-printed JSON format. This format uses whitespace to make the output readable by a human,
-   * but is also machine readable with a JSON library. The structure of the output is a long term
-   * format that tools can rely upon.
-   */
-  JSON,
-
-  /**
-   * Same as {@link #JSON}, but with unnecessary whitespace removed to save generation time and copy
-   * costs. Typically JSON_COMPACT format is used by a browser based HTML client running over the
-   * network.
-   */
-  JSON_COMPACT;
-
-  /** @return true when the format is either JSON or JSON_COMPACT. */
-  public boolean isJson() {
-    return this == JSON_COMPACT || this == JSON;
-  }
-
-  /** @return a new Gson instance configured according to the format. */
-  public GsonBuilder newGsonBuilder() {
-    if (!isJson()) {
-      throw new IllegalStateException(String.format("%s is not JSON", this));
-    }
-    GsonBuilder gb =
-        new GsonBuilder()
-            .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
-            .registerTypeAdapter(Timestamp.class, new SqlTimestampDeserializer());
-    if (this == OutputFormat.JSON) {
-      gb.setPrettyPrinting();
-    }
-    return gb;
-  }
-
-  /** @return a new Gson instance configured according to the format. */
-  public Gson newGson() {
-    return newGsonBuilder().create();
-  }
-}
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 252eb61..7e5b90c 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -15,17 +15,12 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.collect.ImmutableMap.toImmutableMap;
-import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
-import static com.google.gerrit.server.notedb.PatchSetState.PUBLISHED;
-import static java.util.function.Function.identity;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
-import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,22 +28,18 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
-import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -58,107 +49,72 @@
 /** Utilities for manipulating patch sets. */
 @Singleton
 public class PatchSetUtil {
-  private final NotesMigration migration;
   private final Provider<ApprovalsUtil> approvalsUtilProvider;
   private final ProjectCache projectCache;
-  private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
 
   @Inject
   PatchSetUtil(
-      NotesMigration migration,
       Provider<ApprovalsUtil> approvalsUtilProvider,
       ProjectCache projectCache,
-      Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager) {
-    this.migration = migration;
     this.approvalsUtilProvider = approvalsUtilProvider;
     this.projectCache = projectCache;
-    this.dbProvider = dbProvider;
     this.repoManager = repoManager;
   }
 
-  public PatchSet current(ReviewDb db, ChangeNotes notes) throws OrmException {
-    return get(db, notes, notes.getChange().currentPatchSetId());
+  public PatchSet current(ChangeNotes notes) {
+    return get(notes, notes.getChange().currentPatchSetId());
   }
 
-  public PatchSet get(ReviewDb db, ChangeNotes notes, PatchSet.Id psId) throws OrmException {
-    if (!migration.readChanges()) {
-      return db.patchSets().get(psId);
-    }
+  public PatchSet get(ChangeNotes notes, PatchSet.Id psId) {
     return notes.load().getPatchSets().get(psId);
   }
 
-  public ImmutableCollection<PatchSet> byChange(ReviewDb db, ChangeNotes notes)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return PS_ID_ORDER.immutableSortedCopy(db.patchSets().byChange(notes.getChangeId()));
-    }
+  public ImmutableCollection<PatchSet> byChange(ChangeNotes notes) {
     return notes.load().getPatchSets().values();
   }
 
-  public ImmutableMap<PatchSet.Id, PatchSet> byChangeAsMap(ReviewDb db, ChangeNotes notes)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      ImmutableMap.Builder<PatchSet.Id, PatchSet> result = ImmutableMap.builder();
-      for (PatchSet ps : PS_ID_ORDER.sortedCopy(db.patchSets().byChange(notes.getChangeId()))) {
-        result.put(ps.getId(), ps);
-      }
-      return result.build();
-    }
+  public ImmutableMap<PatchSet.Id, PatchSet> byChangeAsMap(ChangeNotes notes) {
     return notes.load().getPatchSets();
   }
 
   public ImmutableMap<PatchSet.Id, PatchSet> getAsMap(
-      ReviewDb db, ChangeNotes notes, Set<PatchSet.Id> patchSetIds) throws OrmException {
-    if (!migration.readChanges()) {
-      patchSetIds = Sets.filter(patchSetIds, p -> p.getParentKey().equals(notes.getChangeId()));
-      return Streams.stream(db.patchSets().get(patchSetIds))
-          .sorted(PS_ID_ORDER)
-          .collect(toImmutableMap(PatchSet::getId, identity()));
-    }
+      ChangeNotes notes, Set<PatchSet.Id> patchSetIds) {
     return ImmutableMap.copyOf(Maps.filterKeys(notes.load().getPatchSets(), patchSetIds::contains));
   }
 
   public PatchSet insert(
-      ReviewDb db,
       RevWalk rw,
       ChangeUpdate update,
       PatchSet.Id psId,
       ObjectId commit,
       List<String> groups,
-      String pushCertificate,
-      String description)
-      throws OrmException, IOException {
-    checkNotNull(groups, "groups may not be null");
+      @Nullable String pushCertificate,
+      @Nullable String description)
+      throws IOException {
+    requireNonNull(groups, "groups may not be null");
     ensurePatchSetMatches(psId, update);
 
-    PatchSet ps = new PatchSet(psId);
-    ps.setRevision(new RevId(commit.name()));
-    ps.setUploader(update.getAccountId());
-    ps.setCreatedOn(new Timestamp(update.getWhen().getTime()));
-    ps.setGroups(groups);
-    ps.setPushCertificate(pushCertificate);
-    ps.setDescription(description);
-    db.patchSets().insert(Collections.singleton(ps));
-
     update.setCommit(rw, commit, pushCertificate);
     update.setPsDescription(description);
     update.setGroups(groups);
 
-    return ps;
+    return PatchSet.builder()
+        .id(psId)
+        .commitId(commit)
+        .uploader(update.getAccountId())
+        .createdOn(new Timestamp(update.getWhen().getTime()))
+        .groups(groups)
+        .pushCertificate(Optional.ofNullable(pushCertificate))
+        .description(Optional.ofNullable(description))
+        .build();
   }
 
-  public void publish(ReviewDb db, ChangeUpdate update, PatchSet ps) throws OrmException {
-    ensurePatchSetMatches(ps.getId(), update);
-    update.setPatchSetState(PUBLISHED);
-    db.patchSets().update(Collections.singleton(ps));
-  }
-
-  private void ensurePatchSetMatches(PatchSet.Id psId, ChangeUpdate update) {
-    Change.Id changeId = update.getChange().getId();
+  private static void ensurePatchSetMatches(PatchSet.Id psId, ChangeUpdate update) {
+    Change.Id changeId = update.getId();
     checkArgument(
-        psId.getParentKey().equals(changeId),
+        psId.changeId().equals(changeId),
         "cannot modify patch set %s on update for change %s",
         psId,
         changeId);
@@ -173,55 +129,44 @@
     }
   }
 
-  public void setGroups(ReviewDb db, ChangeUpdate update, PatchSet ps, List<String> groups)
-      throws OrmException {
-    ps.setGroups(groups);
-    update.setGroups(groups);
-    db.patchSets().update(Collections.singleton(ps));
-  }
-
   /** Check if the current patch set of the change is locked. */
-  public void checkPatchSetNotLocked(ChangeNotes notes, CurrentUser user)
-      throws OrmException, IOException, ResourceConflictException {
-    if (isPatchSetLocked(notes, user)) {
+  public void checkPatchSetNotLocked(ChangeNotes notes)
+      throws IOException, ResourceConflictException {
+    if (isPatchSetLocked(notes)) {
       throw new ResourceConflictException(
           String.format("The current patch set of change %s is locked", notes.getChangeId()));
     }
   }
 
   /** Is the current patch set locked against state changes? */
-  public boolean isPatchSetLocked(ChangeNotes notes, CurrentUser user)
-      throws OrmException, IOException {
+  public boolean isPatchSetLocked(ChangeNotes notes) throws IOException {
     Change change = notes.getChange();
-    if (change.getStatus() == Change.Status.MERGED) {
+    if (change.isMerged()) {
       return false;
     }
 
     ProjectState projectState = projectCache.checkedGet(notes.getProjectName());
-    checkNotNull(projectState, "Failed to load project %s", notes.getProjectName());
+    requireNonNull(
+        projectState, () -> String.format("Failed to load project %s", notes.getProjectName()));
 
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
     for (PatchSetApproval ap :
-        approvalsUtil.byPatchSet(
-            dbProvider.get(), notes, user, change.currentPatchSetId(), null, null)) {
-      LabelType type = projectState.getLabelTypes(notes, user).byLabel(ap.getLabel());
-      if (type != null
-          && ap.getValue() == 1
-          && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
+        approvalsUtil.byPatchSet(notes, change.currentPatchSetId(), null, null)) {
+      LabelType type = projectState.getLabelTypes(notes).byLabel(ap.label());
+      if (type != null && ap.value() == 1 && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
       }
     }
     return false;
   }
 
-  /** Returns the full commit message for the given project at the given patchset revision */
-  public String getFullCommitMessage(Project.NameKey project, PatchSet patchSet)
-      throws IOException {
+  /** Returns the commit for the given project at the given patchset revision */
+  public RevCommit getRevCommit(Project.NameKey project, PatchSet patchSet) throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
+      RevCommit src = rw.parseCommit(patchSet.commitId());
       rw.parseBody(src);
-      return src.getFullMessage();
+      return src;
     }
   }
 }
diff --git a/java/com/google/gerrit/server/ProjectUtil.java b/java/com/google/gerrit/server/ProjectUtil.java
index 1a327db..490c143 100644
--- a/java/com/google/gerrit/server/ProjectUtil.java
+++ b/java/com/google/gerrit/server/ProjectUtil.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import java.io.IOException;
@@ -33,15 +33,40 @@
    * @throws RepositoryNotFoundException the repository of the branch's project does not exist.
    * @throws IOException error while retrieving the branch from the repository.
    */
-  public static boolean branchExists(final GitRepositoryManager repoManager, Branch.NameKey branch)
+  public static boolean branchExists(final GitRepositoryManager repoManager, BranchNameKey branch)
       throws RepositoryNotFoundException, IOException {
-    try (Repository repo = repoManager.openRepository(branch.getParentKey())) {
-      boolean exists = repo.getRefDatabase().exactRef(branch.get()) != null;
+    try (Repository repo = repoManager.openRepository(branch.project())) {
+      boolean exists = repo.getRefDatabase().exactRef(branch.branch()) != null;
       if (!exists) {
         exists =
-            repo.getFullBranch().equals(branch.get()) || RefNames.REFS_CONFIG.equals(branch.get());
+            repo.getFullBranch().equals(branch.branch())
+                || RefNames.REFS_CONFIG.equals(branch.branch());
       }
       return exists;
     }
   }
+
+  public static String sanitizeProjectName(String name) {
+    name = stripGitSuffix(name);
+    name = stripTrailingSlash(name);
+    return name;
+  }
+
+  public static String stripGitSuffix(String name) {
+    if (name.endsWith(".git")) {
+      // Be nice and drop the trailing ".git" suffix, which we never keep
+      // in our database, but clients might mistakenly provide anyway.
+      //
+      name = name.substring(0, name.length() - 4);
+      name = stripTrailingSlash(name);
+    }
+    return name;
+  }
+
+  private static String stripTrailingSlash(String name) {
+    while (name.endsWith("/")) {
+      name = name.substring(0, name.length() - 1);
+    }
+    return name;
+  }
 }
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index a90f3e7..6a1c859 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -18,15 +18,19 @@
 import static com.google.gerrit.reviewdb.client.PatchLineComment.Status.PUBLISHED;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSet.Id;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -47,21 +51,19 @@
   }
 
   public void publish(
-      ChangeContext ctx, PatchSet.Id psId, Collection<Comment> drafts, @Nullable String tag)
-      throws OrmException {
+      ChangeContext ctx, PatchSet.Id psId, Collection<Comment> drafts, @Nullable String tag) {
     ChangeNotes notes = ctx.getNotes();
     checkArgument(notes != null);
     if (drafts.isEmpty()) {
       return;
     }
 
-    Map<Id, PatchSet> patchSets =
-        psUtil.getAsMap(
-            ctx.getDb(), notes, drafts.stream().map(d -> psId(notes, d)).collect(toSet()));
+    Map<PatchSet.Id, PatchSet> patchSets =
+        psUtil.getAsMap(notes, drafts.stream().map(d -> psId(notes, d)).collect(toSet()));
     for (Comment d : drafts) {
       PatchSet ps = patchSets.get(psId(notes, d));
       if (ps == null) {
-        throw new OrmException("patch set " + ps + " not found");
+        throw new StorageException("patch set " + ps + " not found");
       }
       d.writtenOn = ctx.getWhen();
       d.tag = tag;
@@ -69,15 +71,31 @@
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
       ctx.getUser().updateRealAccountId(d::setRealAuthor);
       try {
-        CommentsUtil.setCommentRevId(d, patchListCache, notes.getChange(), ps);
+        CommentsUtil.setCommentCommitId(d, patchListCache, notes.getChange(), ps);
       } catch (PatchListNotAvailableException e) {
-        throw new OrmException(e);
+        throw new StorageException(e);
       }
     }
-    commentsUtil.putComments(ctx.getDb(), ctx.getUpdate(psId), PUBLISHED, drafts);
+    commentsUtil.putComments(ctx.getUpdate(psId), PUBLISHED, drafts);
   }
 
   private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
-    return new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
+    return PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+  }
+
+  /**
+   * Helper to run the specified set of {@link CommentValidator}-s on the specified comments.
+   *
+   * @return See {@link CommentValidator#validateComments(ImmutableList)}.
+   */
+  public static ImmutableList<CommentValidationFailure> findInvalidComments(
+      PluginSetContext<CommentValidator> commentValidators,
+      ImmutableList<CommentForValidation> commentsForValidation) {
+    ImmutableList.Builder<CommentValidationFailure> commentValidationFailures =
+        new ImmutableList.Builder<>();
+    commentValidators.runEach(
+        listener ->
+            commentValidationFailures.addAll(listener.validateComments(commentsForValidation)));
+    return commentValidationFailures.build();
   }
 }
diff --git a/java/com/google/gerrit/server/RequestInfo.java b/java/com/google/gerrit/server/RequestInfo.java
new file mode 100644
index 0000000..f5749fc
--- /dev/null
+++ b/java/com/google/gerrit/server/RequestInfo.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.logging.TraceContext;
+import java.util.Optional;
+
+/** Information about a request that was received from a user. */
+@AutoValue
+public abstract class RequestInfo {
+  /** Channel through which a user request was received. */
+  public enum RequestType {
+    /** request type for git push */
+    GIT_RECEIVE,
+
+    /** request type for git fetch */
+    GIT_UPLOAD,
+
+    /** request type for call to REST API */
+    REST,
+
+    /** request type for call to SSH API */
+    SSH
+  }
+
+  /**
+   * Type of the request, telling through which channel the request was coming in.
+   *
+   * <p>See {@link RequestType} for the types that are used by Gerrit core. Other request types are
+   * possible, e.g. if a plugin supports receiving requests through another channel.
+   */
+  public abstract String requestType();
+
+  /**
+   * Request URI.
+   *
+   * <p>Only set if request type is {@link RequestType#REST}.
+   *
+   * <p>Never includes the "/a" prefix.
+   */
+  public abstract Optional<String> requestUri();
+
+  /** The user that has sent the request. */
+  public abstract CurrentUser callingUser();
+
+  /** The trace context of the request. */
+  public abstract TraceContext traceContext();
+
+  /**
+   * The name of the project for which the request is being done. Only available if the request is
+   * tied to a project or change. If a project is available it's not guaranteed that it actually
+   * exists (e.g. if a user made a request for a project that doesn't exist).
+   */
+  public abstract Optional<Project.NameKey> project();
+
+  public static RequestInfo.Builder builder(
+      RequestType requestType, CurrentUser callingUser, TraceContext traceContext) {
+    return new AutoValue_RequestInfo.Builder()
+        .requestType(requestType)
+        .callingUser(callingUser)
+        .traceContext(traceContext);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder requestType(String requestType);
+
+    public Builder requestType(RequestType requestType) {
+      return requestType(requestType.name());
+    }
+
+    public abstract Builder requestUri(String requestUri);
+
+    public abstract Builder callingUser(CurrentUser callingUser);
+
+    public abstract Builder traceContext(TraceContext traceContext);
+
+    public abstract Builder project(Project.NameKey projectName);
+
+    public abstract RequestInfo build();
+  }
+}
diff --git a/java/com/google/gerrit/server/RequestListener.java b/java/com/google/gerrit/server/RequestListener.java
new file mode 100644
index 0000000..461b91a
--- /dev/null
+++ b/java/com/google/gerrit/server/RequestListener.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+@ExtensionPoint
+public interface RequestListener {
+  void onRequest(RequestInfo requestInfo);
+}
diff --git a/java/com/google/gerrit/server/ReviewerByEmailSet.java b/java/com/google/gerrit/server/ReviewerByEmailSet.java
index c16c9c8..caae45e 100644
--- a/java/com/google/gerrit/server/ReviewerByEmailSet.java
+++ b/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Table;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import java.sql.Timestamp;
 
diff --git a/java/com/google/gerrit/server/ReviewerSet.java b/java/com/google/gerrit/server/ReviewerSet.java
index 67b1d9d..f0bc23e 100644
--- a/java/com/google/gerrit/server/ReviewerSet.java
+++ b/java/com/google/gerrit/server/ReviewerSet.java
@@ -34,8 +34,7 @@
  * state {@link ReviewerStateInternal#REMOVED} are ever exposed by this interface.
  */
 public class ReviewerSet {
-  private static final ReviewerSet EMPTY =
-      new ReviewerSet(ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>of());
+  private static final ReviewerSet EMPTY = new ReviewerSet(ImmutableTable.of());
 
   public static ReviewerSet fromApprovals(Iterable<PatchSetApproval> approvals) {
     PatchSetApproval first = null;
@@ -45,18 +44,14 @@
         first = psa;
       } else {
         checkArgument(
-            first
-                .getKey()
-                .getParentKey()
-                .getParentKey()
-                .equals(psa.getKey().getParentKey().getParentKey()),
+            first.key().patchSetId().changeId().equals(psa.key().patchSetId().changeId()),
             "multiple change IDs: %s, %s",
-            first.getKey(),
-            psa.getKey());
+            first.key(),
+            psa.key());
       }
-      Account.Id id = psa.getAccountId();
-      reviewers.put(REVIEWER, id, psa.getGranted());
-      if (psa.getValue() != 0) {
+      Account.Id id = psa.accountId();
+      reviewers.put(REVIEWER, id, psa.granted());
+      if (psa.value() != 0) {
         reviewers.remove(CC, id);
       }
     }
diff --git a/java/com/google/gerrit/server/Sequences.java b/java/com/google/gerrit/server/Sequences.java
deleted file mode 100644
index fcf0759..0000000
--- a/java/com/google/gerrit/server/Sequences.java
+++ /dev/null
@@ -1,166 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Description.Units;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer2;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-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.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class Sequences {
-  public static final String NAME_ACCOUNTS = "accounts";
-  public static final String NAME_GROUPS = "groups";
-  public static final String NAME_CHANGES = "changes";
-
-  public static int getChangeSequenceGap(Config cfg) {
-    return cfg.getInt("noteDb", "changes", "initialSequenceGap", 1000);
-  }
-
-  private enum SequenceType {
-    ACCOUNTS,
-    CHANGES,
-    GROUPS;
-  }
-
-  private final Provider<ReviewDb> db;
-  private final NotesMigration migration;
-  private final RepoSequence accountSeq;
-  private final RepoSequence changeSeq;
-  private final RepoSequence groupSeq;
-  private final Timer2<SequenceType, Boolean> nextIdLatency;
-
-  @Inject
-  public Sequences(
-      @GerritServerConfig Config cfg,
-      Provider<ReviewDb> db,
-      NotesMigration migration,
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllProjectsName allProjects,
-      AllUsersName allUsers,
-      MetricMaker metrics) {
-    this.db = db;
-    this.migration = migration;
-
-    int accountBatchSize = cfg.getInt("noteDb", "accounts", "sequenceBatchSize", 1);
-    accountSeq =
-        new RepoSequence(
-            repoManager,
-            gitRefUpdated,
-            allUsers,
-            NAME_ACCOUNTS,
-            () -> ReviewDb.FIRST_ACCOUNT_ID,
-            accountBatchSize);
-
-    int gap = getChangeSequenceGap(cfg);
-    @SuppressWarnings("deprecation")
-    RepoSequence.Seed changeSeed = () -> db.get().nextChangeId() + gap;
-    int changeBatchSize = cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20);
-    changeSeq =
-        new RepoSequence(
-            repoManager, gitRefUpdated, allProjects, NAME_CHANGES, changeSeed, changeBatchSize);
-
-    RepoSequence.Seed groupSeed = () -> nextGroupId(db.get());
-    int groupBatchSize = 1;
-    groupSeq =
-        new RepoSequence(
-            repoManager, gitRefUpdated, allUsers, NAME_GROUPS, groupSeed, groupBatchSize);
-
-    nextIdLatency =
-        metrics.newTimer(
-            "sequence/next_id_latency",
-            new Description("Latency of requesting IDs from repo sequences")
-                .setCumulative()
-                .setUnit(Units.MILLISECONDS),
-            Field.ofEnum(SequenceType.class, "sequence"),
-            Field.ofBoolean("multiple"));
-  }
-
-  public int nextAccountId() throws OrmException {
-    try (Timer2.Context timer = nextIdLatency.start(SequenceType.ACCOUNTS, false)) {
-      return accountSeq.next();
-    }
-  }
-
-  public int nextChangeId() throws OrmException {
-    if (!migration.readChangeSequence()) {
-      return nextChangeId(db.get());
-    }
-    try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, false)) {
-      return changeSeq.next();
-    }
-  }
-
-  public ImmutableList<Integer> nextChangeIds(int count) throws OrmException {
-    if (migration.readChangeSequence()) {
-      try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
-        return changeSeq.next(count);
-      }
-    }
-
-    if (count == 0) {
-      return ImmutableList.of();
-    }
-    checkArgument(count > 0, "count is negative: %s", count);
-    List<Integer> ids = new ArrayList<>(count);
-    ReviewDb db = this.db.get();
-    for (int i = 0; i < count; i++) {
-      ids.add(nextChangeId(db));
-    }
-    return ImmutableList.copyOf(ids);
-  }
-
-  public int nextGroupId() throws OrmException {
-    try (Timer2.Context timer = nextIdLatency.start(SequenceType.GROUPS, false)) {
-      return groupSeq.next();
-    }
-  }
-
-  @VisibleForTesting
-  public RepoSequence getChangeIdRepoSequence() {
-    return changeSeq;
-  }
-
-  @SuppressWarnings("deprecation")
-  private static int nextChangeId(ReviewDb db) throws OrmException {
-    return db.nextChangeId();
-  }
-
-  @SuppressWarnings("deprecation")
-  static int nextGroupId(ReviewDb db) throws OrmException {
-    return db.nextAccountGroupId();
-  }
-}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 0fbf200..2cb670e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toSet;
 
@@ -31,21 +31,23 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -87,7 +89,7 @@
         if (id == null) {
           return null;
         }
-        Account.Id accountId = new Account.Id(id);
+        Account.Id accountId = Account.id(id);
         String label = s.substring(p + 1);
         return create(accountId, label);
       }
@@ -103,7 +105,7 @@
     public abstract String label();
 
     @Override
-    public String toString() {
+    public final String toString() {
       return accountId() + SEPARATOR + label();
     }
   }
@@ -115,7 +117,7 @@
 
     private static StarRef create(Ref ref, Iterable<String> labels) {
       return new AutoValue_StarredChangesUtil_StarRef(
-          checkNotNull(ref), ImmutableSortedSet.copyOf(labels));
+          requireNonNull(ref), ImmutableSortedSet.copyOf(labels));
     }
 
     @Nullable
@@ -165,8 +167,7 @@
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
-  private final Provider<ReviewDb> dbProvider;
-  private final PersonIdent serverIdent;
+  private final Provider<PersonIdent> serverIdent;
   private final ChangeIndexer indexer;
   private final Provider<InternalChangeQuery> queryProvider;
 
@@ -175,25 +176,22 @@
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
-      Provider<ReviewDb> dbProvider,
-      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       ChangeIndexer indexer,
       Provider<InternalChangeQuery> queryProvider) {
     this.repoManager = repoManager;
     this.gitRefUpdated = gitRefUpdated;
     this.allUsers = allUsers;
-    this.dbProvider = dbProvider;
     this.serverIdent = serverIdent;
     this.indexer = indexer;
     this.queryProvider = queryProvider;
   }
 
-  public ImmutableSortedSet<String> getLabels(Account.Id accountId, Change.Id changeId)
-      throws OrmException {
+  public ImmutableSortedSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)).labels();
     } catch (IOException e) {
-      throw new OrmException(
+      throw new StorageException(
           String.format(
               "Reading stars from change %d for account %d failed",
               changeId.get(), accountId.get()),
@@ -207,7 +205,7 @@
       Change.Id changeId,
       Set<String> labelsToAdd,
       Set<String> labelsToRemove)
-      throws OrmException, IllegalLabelException {
+      throws IllegalLabelException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
       StarRef old = readLabels(repo, refName);
@@ -227,21 +225,30 @@
         updateLabels(repo, refName, old.objectId(), labels);
       }
 
-      indexer.index(dbProvider.get(), project, changeId);
+      indexer.index(project, changeId);
       return ImmutableSortedSet.copyOf(labels);
     } catch (IOException e) {
-      throw new OrmException(
+      throw new StorageException(
           String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
           e);
     }
   }
 
-  public void unstarAll(Project.NameKey project, Change.Id changeId) throws OrmException {
+  /**
+   * Unstar the given change for all users.
+   *
+   * <p>Intended for use only when we're about to delete a change. For that reason, the change is
+   * not reindexed.
+   *
+   * @param changeId change ID.
+   * @throws IOException if an error occurred.
+   */
+  public void unstarAllForChangeDeletion(Change.Id changeId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
       BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
       batchUpdate.setAllowNonFastForwards(true);
-      batchUpdate.setRefLogIdent(serverIdent);
+      batchUpdate.setRefLogIdent(serverIdent.get());
       batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
       for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
         String refName = RefNames.refsStarredChanges(changeId, accountId);
@@ -257,13 +264,10 @@
                   changeId.get(), command.getRefName(), command.getResult()));
         }
       }
-      indexer.index(dbProvider.get(), project, changeId);
-    } catch (IOException e) {
-      throw new OrmException(String.format("Unstar change %d failed", changeId.get()), e);
     }
   }
 
-  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) throws OrmException {
+  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
       for (String refPart : getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))) {
@@ -271,18 +275,17 @@
         if (id == null) {
           continue;
         }
-        Account.Id accountId = new Account.Id(id);
+        Account.Id accountId = Account.id(id);
         builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
     } catch (IOException e) {
-      throw new OrmException(
+      throw new StorageException(
           String.format("Get accounts that starred change %d failed", changeId.get()), e);
     }
   }
 
-  public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId)
-      throws OrmException {
+  public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId) {
     List<ChangeData> changeData =
         queryProvider
             .get()
@@ -296,7 +299,9 @@
 
   private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
     RefDatabase refDb = repo.getRefDatabase();
-    return refDb.getRefs(prefix).keySet();
+    return refDb.getRefsByPrefix(prefix).stream()
+        .map(r -> r.getName().substring(prefix.length()))
+        .collect(toSet());
   }
 
   public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
@@ -311,7 +316,7 @@
     }
   }
 
-  public void ignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+  public void ignore(ChangeResource rsrc) throws IllegalLabelException {
     star(
         rsrc.getUser().asIdentifiedUser().getAccountId(),
         rsrc.getProject(),
@@ -320,7 +325,7 @@
         ImmutableSet.of());
   }
 
-  public void unignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+  public void unignore(ChangeResource rsrc) throws IllegalLabelException {
     star(
         rsrc.getUser().asIdentifiedUser().getAccountId(),
         rsrc.getProject(),
@@ -329,11 +334,11 @@
         ImmutableSet.of(IGNORE_LABEL));
   }
 
-  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) throws OrmException {
+  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) {
     return getLabels(accountId, changeId).contains(IGNORE_LABEL);
   }
 
-  public boolean isIgnored(ChangeResource rsrc) throws OrmException {
+  public boolean isIgnored(ChangeResource rsrc) {
     return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
   }
 
@@ -353,7 +358,7 @@
     return UNREVIEWED_LABEL + "/" + ps;
   }
 
-  public void markAsReviewed(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+  public void markAsReviewed(ChangeResource rsrc) throws IllegalLabelException {
     star(
         rsrc.getUser().asIdentifiedUser().getAccountId(),
         rsrc.getProject(),
@@ -362,7 +367,7 @@
         ImmutableSet.of(getUnreviewedLabel(rsrc.getChange())));
   }
 
-  public void markAsUnreviewed(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+  public void markAsUnreviewed(ChangeResource rsrc) throws IllegalLabelException {
     star(
         rsrc.getUser().asIdentifiedUser().getAccountId(),
         rsrc.getProject(),
@@ -372,18 +377,22 @@
   }
 
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
-    Ref ref = repo.exactRef(refName);
-    if (ref == null) {
-      return StarRef.MISSING;
-    }
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Read star labels", Metadata.builder().noteDbRefName(refName).build())) {
+      Ref ref = repo.exactRef(refName);
+      if (ref == null) {
+        return StarRef.MISSING;
+      }
 
-    try (ObjectReader reader = repo.newObjectReader()) {
-      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
-      return StarRef.create(
-          ref,
-          Splitter.on(CharMatcher.whitespace())
-              .omitEmptyStrings()
-              .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
+      try (ObjectReader reader = repo.newObjectReader()) {
+        ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
+        return StarRef.create(
+            ref,
+            Splitter.on(CharMatcher.whitespace())
+                .omitEmptyStrings()
+                .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
+      }
     }
   }
 
@@ -417,8 +426,7 @@
   }
 
   public static Set<Integer> getStarredPatchSets(Set<String> labels, String label) {
-    return labels
-        .stream()
+    return labels.stream()
         .filter(l -> l.startsWith(label + "/"))
         .filter(l -> Ints.tryParse(l.substring(label.length() + 1)) != null)
         .map(l -> Integer.valueOf(l.substring(label.length() + 1)))
@@ -443,13 +451,17 @@
 
   private void updateLabels(
       Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
-      throws IOException, OrmException, InvalidLabelsException {
-    try (RevWalk rw = new RevWalk(repo)) {
+      throws IOException, InvalidLabelsException {
+    try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Update star labels",
+                Metadata.builder().noteDbRefName(refName).resourceCount(labels.size()).build());
+        RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
       u.setExpectedOldObjectId(oldObjectId);
       u.setForceUpdate(true);
       u.setNewObjectId(writeLabels(repo, labels));
-      u.setRefLogIdent(serverIdent);
+      u.setRefLogIdent(serverIdent.get());
       u.setRefLogMessage("Update star labels", true);
       RefUpdate.Result result = u.update(rw);
       switch (result) {
@@ -468,43 +480,46 @@
         case REJECTED_MISSING_OBJECT:
         case REJECTED_OTHER_REASON:
         default:
-          throw new OrmException(
+          throw new StorageException(
               String.format("Update star labels on ref %s failed: %s", refName, result.name()));
       }
     }
   }
 
-  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId)
-      throws IOException, OrmException {
+  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId) throws IOException {
     if (ObjectId.zeroId().equals(oldObjectId)) {
       // ref doesn't exist
       return;
     }
 
-    RefUpdate u = repo.updateRef(refName);
-    u.setForceUpdate(true);
-    u.setExpectedOldObjectId(oldObjectId);
-    u.setRefLogIdent(serverIdent);
-    u.setRefLogMessage("Unstar change", true);
-    RefUpdate.Result result = u.delete();
-    switch (result) {
-      case FORCED:
-        gitRefUpdated.fire(allUsers, u, null);
-        return;
-      case NEW:
-      case NO_CHANGE:
-      case FAST_FORWARD:
-      case IO_FAILURE:
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case RENAMED:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        throw new OrmException(
-            String.format("Delete star ref %s failed: %s", refName, result.name()));
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Delete star labels", Metadata.builder().noteDbRefName(refName).build())) {
+      RefUpdate u = repo.updateRef(refName);
+      u.setForceUpdate(true);
+      u.setExpectedOldObjectId(oldObjectId);
+      u.setRefLogIdent(serverIdent.get());
+      u.setRefLogMessage("Unstar change", true);
+      RefUpdate.Result result = u.delete();
+      switch (result) {
+        case FORCED:
+          gitRefUpdated.fire(allUsers, u, null);
+          return;
+        case NEW:
+        case NO_CHANGE:
+        case FAST_FORWARD:
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          throw new StorageException(
+              String.format("Delete star ref %s failed: %s", refName, result.name()));
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/StartupChecks.java b/java/com/google/gerrit/server/StartupChecks.java
index 9df2604..9bf94ae 100644
--- a/java/com/google/gerrit/server/StartupChecks.java
+++ b/java/com/google/gerrit/server/StartupChecks.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.account.UniversalGroupBackend;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -34,18 +35,16 @@
     }
   }
 
-  private final DynamicSet<StartupCheck> startupChecks;
+  private final PluginSetContext<StartupCheck> startupChecks;
 
   @Inject
-  StartupChecks(DynamicSet<StartupCheck> startupChecks) {
+  StartupChecks(PluginSetContext<StartupCheck> startupChecks) {
     this.startupChecks = startupChecks;
   }
 
   @Override
   public void start() throws StartupException {
-    for (StartupCheck startupCheck : startupChecks) {
-      startupCheck.check();
-    }
+    startupChecks.runEach(StartupCheck::check, StartupException.class);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/TraceRequestListener.java b/java/com/google/gerrit/server/TraceRequestListener.java
new file mode 100644
index 0000000..773f712
--- /dev/null
+++ b/java/com/google/gerrit/server/TraceRequestListener.java
@@ -0,0 +1,228 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Request listener that sets additional logging tags and enables tracing automatically if the
+ * request matches any tracing configuration in gerrit.config (see description of
+ * 'tracing.<trace-id>' subsection in config-gerrit.txt).
+ */
+@Singleton
+public class TraceRequestListener implements RequestListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Config cfg;
+  private final ImmutableList<TraceConfig> traceConfigs;
+
+  @Inject
+  TraceRequestListener(@GerritServerConfig Config cfg) {
+    this.cfg = cfg;
+    this.traceConfigs = parseTraceConfigs();
+  }
+
+  @Override
+  public void onRequest(RequestInfo requestInfo) {
+    requestInfo.project().ifPresent(p -> requestInfo.traceContext().addTag("project", p));
+    traceConfigs.stream()
+        .filter(traceConfig -> traceConfig.matches(requestInfo))
+        .forEach(
+            traceConfig ->
+                requestInfo
+                    .traceContext()
+                    .forceLogging()
+                    .addTag(RequestId.Type.TRACE_ID, traceConfig.traceId()));
+  }
+
+  private ImmutableList<TraceConfig> parseTraceConfigs() {
+    ImmutableList.Builder<TraceConfig> traceConfigs = ImmutableList.builder();
+
+    for (String traceId : cfg.getSubsections("tracing")) {
+      try {
+        TraceConfig.Builder traceConfig = TraceConfig.builder();
+        traceConfig.traceId(traceId);
+        traceConfig.requestTypes(parseRequestTypes(traceId));
+        traceConfig.requestUriPatterns(parseRequestUriPatterns(traceId));
+        traceConfig.accountIds(parseAccounts(traceId));
+        traceConfig.projectPatterns(parseProjectPatterns(traceId));
+        traceConfigs.add(traceConfig.build());
+      } catch (ConfigInvalidException e) {
+        logger.atWarning().log("Ignoring invalid tracing configuration:\n %s", e.getMessage());
+      }
+    }
+
+    return traceConfigs.build();
+  }
+
+  private ImmutableSet<String> parseRequestTypes(String traceId) {
+    return ImmutableSet.copyOf(cfg.getStringList("tracing", traceId, "requestType"));
+  }
+
+  private ImmutableSet<Pattern> parseRequestUriPatterns(String traceId)
+      throws ConfigInvalidException {
+    return parsePatterns(traceId, "requestUriPattern");
+  }
+
+  private ImmutableSet<Account.Id> parseAccounts(String traceId) throws ConfigInvalidException {
+    ImmutableSet.Builder<Account.Id> accountIds = ImmutableSet.builder();
+    String[] accounts = cfg.getStringList("tracing", traceId, "account");
+    for (String account : accounts) {
+      Optional<Account.Id> accountId = Account.Id.tryParse(account);
+      if (!accountId.isPresent()) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Invalid tracing config ('tracing.%s.account = %s'): invalid account ID",
+                traceId, account));
+      }
+      accountIds.add(accountId.get());
+    }
+    return accountIds.build();
+  }
+
+  private ImmutableSet<Pattern> parseProjectPatterns(String traceId) throws ConfigInvalidException {
+    return parsePatterns(traceId, "projectPattern");
+  }
+
+  private ImmutableSet<Pattern> parsePatterns(String traceId, String name)
+      throws ConfigInvalidException {
+    ImmutableSet.Builder<Pattern> patterns = ImmutableSet.builder();
+    String[] patternRegExs = cfg.getStringList("tracing", traceId, name);
+    for (String patternRegEx : patternRegExs) {
+      try {
+        patterns.add(Pattern.compile(patternRegEx));
+      } catch (PatternSyntaxException e) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Invalid tracing config ('tracing.%s.%s = %s'): %s",
+                traceId, name, patternRegEx, e.getMessage()));
+      }
+    }
+    return patterns.build();
+  }
+
+  @AutoValue
+  abstract static class TraceConfig {
+    /** ID for the trace */
+    abstract String traceId();
+
+    /** request types that should be traced */
+    abstract ImmutableSet<String> requestTypes();
+
+    /** pattern matching request URIs */
+    abstract ImmutableSet<Pattern> requestUriPatterns();
+
+    /** accounts IDs matching calling user */
+    abstract ImmutableSet<Account.Id> accountIds();
+
+    /** pattern matching projects names */
+    abstract ImmutableSet<Pattern> projectPatterns();
+
+    static Builder builder() {
+      return new AutoValue_TraceRequestListener_TraceConfig.Builder();
+    }
+
+    /**
+     * Whether this trace config matches a given request.
+     *
+     * @param requestInfo request info
+     * @return whether this trace config matches
+     */
+    boolean matches(RequestInfo requestInfo) {
+      // If in the trace config request types are set and none of them matches, then the request is
+      // not matched.
+      if (!requestTypes().isEmpty()
+          && requestTypes().stream()
+              .noneMatch(type -> type.equalsIgnoreCase(requestInfo.requestType()))) {
+        return false;
+      }
+
+      // If in the trace config request URI patterns are set and none of them matches, then the
+      // request is not matched.
+      if (!requestUriPatterns().isEmpty()) {
+        if (!requestInfo.requestUri().isPresent()) {
+          // The request has no request URI, hence it cannot match a request URI pattern.
+          return false;
+        }
+
+        if (requestUriPatterns().stream()
+            .noneMatch(p -> p.matcher(requestInfo.requestUri().get()).matches())) {
+          return false;
+        }
+      }
+
+      // If in the trace config accounts are set and none of them matches, then the request is not
+      // matched.
+      if (!accountIds().isEmpty()) {
+        try {
+          if (accountIds().stream()
+              .noneMatch(id -> id.equals(requestInfo.callingUser().getAccountId()))) {
+            return false;
+          }
+        } catch (UnsupportedOperationException e) {
+          // The calling user is not logged in, hence it cannot match an account.
+          return false;
+        }
+      }
+
+      // If in the trace config project patterns are set and none of them matches, then the request
+      // is not matched.
+      if (!projectPatterns().isEmpty()) {
+        if (!requestInfo.project().isPresent()) {
+          // The request is not for a project, hence it cannot match a project pattern.
+          return false;
+        }
+
+        if (projectPatterns().stream()
+            .noneMatch(p -> p.matcher(requestInfo.project().get().get()).matches())) {
+          return false;
+        }
+      }
+
+      // For any match criteria (request type, request URI pattern, account, project pattern) that
+      // was specified in the trace config, at least one of the configured value matched the
+      // request.
+      return true;
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder traceId(String traceId);
+
+      abstract Builder requestTypes(ImmutableSet<String> requestTypes);
+
+      abstract Builder requestUriPatterns(ImmutableSet<Pattern> requestUriPatterns);
+
+      abstract Builder accountIds(ImmutableSet<Account.Id> accountIds);
+
+      abstract Builder projectPatterns(ImmutableSet<Pattern> projectPatterns);
+
+      abstract TraceConfig build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 39a2328..94bf53c 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 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.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.WebLinkInfoCommon;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -36,8 +36,8 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.Collections;
-import java.util.List;
+import java.util.function.Function;
+import java.util.function.Predicate;
 
 @Singleton
 public class WebLinks {
@@ -54,17 +54,6 @@
         return true;
       };
 
-  private static final Predicate<WebLinkInfoCommon> INVALID_WEBLINK_COMMON =
-      link -> {
-        if (link == null) {
-          return false;
-        } else if (Strings.isNullOrEmpty(link.name) || Strings.isNullOrEmpty(link.url)) {
-          logger.atWarning().log("%s is missing name and/or url", link.getClass().getName());
-          return false;
-        }
-        return true;
-      };
-
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
   private final DynamicSet<ParentWebLink> parentLinks;
   private final DynamicSet<FileWebLink> fileLinks;
@@ -99,7 +88,7 @@
    * @param commit SHA1 of commit.
    * @return Links for patch sets.
    */
-  public List<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
+  public ImmutableList<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
     return filterLinks(patchSetLinks, webLink -> webLink.getPatchSetWebLink(project.get(), commit));
   }
 
@@ -108,7 +97,7 @@
    * @param revision SHA1 of the parent revision.
    * @return Links for patch sets.
    */
-  public List<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
+  public ImmutableList<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
     return filterLinks(parentLinks, webLink -> webLink.getParentWebLink(project.get(), revision));
   }
 
@@ -118,9 +107,9 @@
    * @param file File name.
    * @return Links for files.
    */
-  public List<WebLinkInfo> getFileLinks(String project, String revision, String file) {
+  public ImmutableList<WebLinkInfo> getFileLinks(String project, String revision, String file) {
     return Patch.isMagic(file)
-        ? Collections.emptyList()
+        ? ImmutableList.of()
         : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, file));
   }
 
@@ -130,26 +119,15 @@
    * @param file File name.
    * @return Links for file history
    */
-  public List<WebLinkInfoCommon> getFileHistoryLinks(String project, String revision, String file) {
+  public ImmutableList<WebLinkInfo> getFileHistoryLinks(
+      String project, String revision, String file) {
     if (Patch.isMagic(file)) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
-    return FluentIterable.from(fileHistoryLinks)
-        .transform(
-            webLink -> {
-              WebLinkInfo info = webLink.getFileHistoryWebLink(project, revision, file);
-              if (info == null) {
-                return null;
-              }
-              WebLinkInfoCommon commonInfo = new WebLinkInfoCommon();
-              commonInfo.name = info.name;
-              commonInfo.imageUrl = info.imageUrl;
-              commonInfo.url = info.url;
-              commonInfo.target = info.target;
-              return commonInfo;
-            })
-        .filter(INVALID_WEBLINK_COMMON)
-        .toList();
+    return Streams.stream(fileHistoryLinks)
+        .map(webLink -> webLink.getFileHistoryWebLink(project, revision, file))
+        .filter(INVALID_WEBLINK)
+        .collect(toImmutableList());
   }
 
   /**
@@ -162,20 +140,20 @@
    * @param fileB File name of side B.
    * @return Links for file diffs.
    */
-  public List<DiffWebLinkInfo> getDiffLinks(
-      final String project,
-      final int changeId,
-      final Integer patchSetIdA,
-      final String revisionA,
-      final String fileA,
-      final int patchSetIdB,
-      final String revisionB,
-      final String fileB) {
+  public ImmutableList<DiffWebLinkInfo> getDiffLinks(
+      String project,
+      int changeId,
+      Integer patchSetIdA,
+      String revisionA,
+      String fileA,
+      int patchSetIdB,
+      String revisionB,
+      String fileB) {
     if (Patch.isMagic(fileA) || Patch.isMagic(fileB)) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
-    return FluentIterable.from(diffLinks)
-        .transform(
+    return Streams.stream(diffLinks)
+        .map(
             webLink ->
                 webLink.getDiffLink(
                     project,
@@ -187,14 +165,14 @@
                     revisionB,
                     fileB))
         .filter(INVALID_WEBLINK)
-        .toList();
+        .collect(toImmutableList());
   }
 
   /**
    * @param project Project name.
    * @return Links for projects.
    */
-  public List<WebLinkInfo> getProjectLinks(String project) {
+  public ImmutableList<WebLinkInfo> getProjectLinks(String project) {
     return filterLinks(projectLinks, webLink -> webLink.getProjectWeblink(project));
   }
 
@@ -203,7 +181,7 @@
    * @param branch Branch name
    * @return Links for branches.
    */
-  public List<WebLinkInfo> getBranchLinks(String project, String branch) {
+  public ImmutableList<WebLinkInfo> getBranchLinks(String project, String branch) {
     return filterLinks(branchLinks, webLink -> webLink.getBranchWebLink(project, branch));
   }
 
@@ -212,12 +190,15 @@
    * @param tag Tag name
    * @return Links for tags.
    */
-  public List<WebLinkInfo> getTagLinks(String project, String tag) {
+  public ImmutableList<WebLinkInfo> getTagLinks(String project, String tag) {
     return filterLinks(tagLinks, webLink -> webLink.getTagWebLink(project, tag));
   }
 
-  private <T extends WebLink> List<WebLinkInfo> filterLinks(
+  private <T extends WebLink> ImmutableList<WebLinkInfo> filterLinks(
       DynamicSet<T> links, Function<T, WebLinkInfo> transformer) {
-    return FluentIterable.from(links).transform(transformer).filter(INVALID_WEBLINK).toList();
+    return Streams.stream(links)
+        .map(transformer)
+        .filter(INVALID_WEBLINK)
+        .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 76bfcfd..af8d8b0 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -21,13 +21,15 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -66,18 +68,15 @@
     };
   }
 
-  private final AllUsersName allUsersName;
   private final ExternalIds externalIds;
   private final LoadingCache<Account.Id, Optional<AccountState>> byId;
   private final ExecutorService executor;
 
   @Inject
   AccountCacheImpl(
-      AllUsersName allUsersName,
       ExternalIds externalIds,
       @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId,
       @FanOutExecutor ExecutorService executor) {
-    this.allUsersName = allUsersName;
     this.externalIds = externalIds;
     this.byId = byId;
     this.executor = executor;
@@ -99,7 +98,7 @@
       return byId.get(accountId);
     } catch (ExecutionException e) {
       logger.atWarning().withCause(e).log("Cannot load AccountState for ID %s", accountId);
-      return null;
+      return Optional.empty();
     }
   }
 
@@ -130,7 +129,7 @@
     }
     for (Future<Optional<AccountState>> f : futures) {
       try {
-        f.get().ifPresent(s -> accountStates.put(s.getAccount().getId(), s));
+        f.get().ifPresent(s -> accountStates.put(s.getAccount().id(), s));
       } catch (InterruptedException | ExecutionException e) {
         logger.atSevere().withCause(e).log("Cannot load AccountState");
       }
@@ -147,26 +146,28 @@
           .orElseGet(Optional::empty);
     } catch (IOException | ConfigInvalidException e) {
       logger.atWarning().withCause(e).log("Cannot load AccountState for username %s", username);
-      return null;
+      return Optional.empty();
     }
   }
 
   @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();
   }
 
   private AccountState missing(Account.Id accountId) {
-    Account account = new Account(accountId, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(accountId, TimeUtil.nowTs());
     account.setActive(false);
-    return AccountState.forAccount(allUsersName, account);
+    return AccountState.forAccount(account.build());
   }
 
   static class ByIdLoader extends CacheLoader<Account.Id, Optional<AccountState>> {
@@ -179,7 +180,11 @@
 
     @Override
     public Optional<AccountState> load(Account.Id who) throws Exception {
-      return accounts.get(who);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading account", Metadata.builder().accountId(who.get()).build())) {
+        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..5263bad 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -30,10 +30,11 @@
 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;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -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,9 +89,10 @@
   private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
   private List<ValidationError> validationErrors;
 
-  public AccountConfig(Account.Id accountId, Repository allUsersRepo) {
-    this.accountId = checkNotNull(accountId, "accountId");
-    this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
+  public AccountConfig(Account.Id accountId, AllUsersName allUsersName, Repository allUsersRepo) {
+    this.accountId = requireNonNull(accountId, "accountId");
+    this.allUsersName = requireNonNull(allUsersName, "allUsersName");
+    this.repo = requireNonNull(allUsersRepo, "allUsersRepo");
     this.ref = RefNames.refsUsers(accountId);
   }
 
@@ -99,7 +102,7 @@
   }
 
   public AccountConfig load() throws IOException, ConfigInvalidException {
-    load(repo);
+    load(allUsersName, repo);
     return this;
   }
 
@@ -181,14 +184,14 @@
     checkLoaded();
     this.loadedAccountProperties =
         Optional.of(
-            new AccountProperties(account.getId(), account.getRegisteredOn(), new Config(), null));
+            new AccountProperties(account.id(), account.registeredOn(), new Config(), null));
     this.accountUpdate =
         Optional.of(
             InternalAccountUpdate.builder()
                 .setActive(account.isActive())
-                .setFullName(account.getFullName())
-                .setPreferredEmail(account.getPreferredEmail())
-                .setStatus(account.getStatus())
+                .setFullName(account.fullName())
+                .setPreferredEmail(account.preferredEmail())
+                .setStatus(account.status())
                 .build());
     return this;
   }
@@ -197,9 +200,9 @@
    * Creates a new account.
    *
    * @return the new account
-   * @throws OrmDuplicateKeyException if the user branch already exists
+   * @throws DuplicateKeyException if the user branch already exists
    */
-  public Account getNewAccount() throws OrmDuplicateKeyException {
+  public Account getNewAccount() throws DuplicateKeyException {
     return getNewAccount(TimeUtil.nowTs());
   }
 
@@ -207,12 +210,12 @@
    * Creates a new account.
    *
    * @return the new account
-   * @throws OrmDuplicateKeyException if the user branch already exists
+   * @throws DuplicateKeyException if the user branch already exists
    */
-  Account getNewAccount(Timestamp registeredOn) throws OrmDuplicateKeyException {
+  Account getNewAccount(Timestamp registeredOn) throws DuplicateKeyException {
     checkLoaded();
     if (revision != null) {
-      throw new OrmDuplicateKeyException(String.format("account %s already exists", accountId));
+      throw new DuplicateKeyException(String.format("account %s already exists", accountId));
     }
     this.loadedAccountProperties =
         Optional.of(new AccountProperties(accountId, registeredOn, new Config(), null));
@@ -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/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index 3772b4e..fc0bfd0 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -17,7 +17,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -80,7 +80,7 @@
 
   private Boolean viewAll;
 
-  AccountControl(
+  private AccountControl(
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       GroupControl.Factory groupControlFactory,
@@ -106,17 +106,6 @@
    * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective
    * groups.
    */
-  public boolean canSee(Account otherUser) {
-    return canSee(otherUser.getId());
-  }
-
-  /**
-   * Returns true if the current user is allowed to see the otherUser, based on the account
-   * visibility policy. Depending on the group membership realms supported, this may not be able to
-   * determine SAME_GROUP or VISIBLE_GROUP correctly (defaulting to not being visible). This is
-   * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective
-   * groups.
-   */
   public boolean canSee(Account.Id otherUser) {
     return canSee(
         new OtherUser() {
@@ -144,7 +133,7 @@
         new OtherUser() {
           @Override
           Account.Id getId() {
-            return otherUser.getAccount().getId();
+            return otherUser.getAccount().id();
           }
 
           @Override
@@ -215,9 +204,7 @@
   }
 
   private Set<AccountGroup.UUID> groupsOf(IdentifiedUser user) {
-    return user.getEffectiveGroups()
-        .getKnownGroups()
-        .stream()
+    return user.getEffectiveGroups().getKnownGroups().stream()
         .filter(a -> !SystemGroupBackend.isSystemGroup(a))
         .collect(toSet());
   }
diff --git a/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
index b0dc527..1bd17bc 100644
--- a/java/com/google/gerrit/server/account/AccountDeactivator.java
+++ b/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -108,7 +108,7 @@
     logger.atFine().log("processing account %s", userName);
     try {
       if (realm.accountBelongsToRealm(accountState.getExternalIds()) && !realm.isActive(userName)) {
-        sif.deactivate(accountState.getAccount().getId());
+        sif.deactivate(accountState.getAccount().id());
         logger.atInfo().log("deactivated account %s", userName);
         return true;
       }
@@ -117,7 +117,7 @@
     } catch (Exception e) {
       logger.atSevere().withCause(e).log(
           "Error deactivating account: %s (%s) %s",
-          userName, accountState.getAccount().getId(), e.getMessage());
+          userName, accountState.getAccount().id(), e.getMessage());
     }
     return false;
   }
diff --git a/java/com/google/gerrit/server/account/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
index 5c14c94..ee9265f 100644
--- a/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.util.Set;
 
 /**
@@ -48,16 +49,5 @@
   }
 
   public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
-      throws DirectoryException;
-
-  @SuppressWarnings("serial")
-  public static class DirectoryException extends Exception {
-    public DirectoryException(String message, Throwable why) {
-      super(message, why);
-    }
-
-    public DirectoryException(Throwable why) {
-      super(why);
-    }
-  }
+      throws PermissionBackendException;
 }
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index 7be51a0..09b9ac3 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -16,13 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Throwables;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountDirectory.DirectoryException;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
@@ -69,7 +68,8 @@
     provided = new ArrayList<>();
   }
 
-  public synchronized AccountInfo get(Account.Id id) {
+  @Nullable
+  public synchronized AccountInfo get(@Nullable Account.Id id) {
     if (id == null) {
       return null;
     }
@@ -86,23 +86,19 @@
     provided.add(info);
   }
 
-  public void fill() throws OrmException {
-    try {
-      directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
-    } catch (DirectoryException e) {
-      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
-      throw new OrmException(e);
-    }
+  public void fill() throws PermissionBackendException {
+    directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
   }
 
-  public void fill(Collection<? extends AccountInfo> infos) throws OrmException {
+  public void fill(Collection<? extends AccountInfo> infos) throws PermissionBackendException {
     for (AccountInfo info : infos) {
       put(info);
     }
     fill();
   }
 
-  public AccountInfo fillOne(Account.Id id) throws OrmException {
+  @Nullable
+  public AccountInfo fillOne(@Nullable Account.Id id) throws PermissionBackendException {
     AccountInfo info = get(id);
     fill();
     return info;
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index e2194cc..09757eb 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
@@ -25,12 +26,12 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate.AccountUpdater;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
@@ -40,9 +41,9 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -136,21 +137,7 @@
     try {
       Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
       if (!optionalExtId.isPresent()) {
-        if (who.getUserName().isPresent()) {
-          ExternalId.Key key = ExternalId.Key.create(SCHEME_USERNAME, who.getUserName().get());
-          Optional<ExternalId> existingId = externalIds.get(key);
-          if (existingId.isPresent()) {
-            // An inconsistency is detected in the database, having a record for scheme "username:"
-            // but no record for scheme "gerrit:". Try to recover by linking
-            // "gerrit:" identity to the existing account.
-            logger.atWarning().log(
-                "User %s already has an account; link new identity to the existing account.",
-                who.getUserName());
-            return link(existingId.get().accountId(), who);
-          }
-        }
         // New account, automatically create and return.
-        logger.atFine().log("External ID not found. Attempting to create new account.");
         return create(who);
       }
 
@@ -177,7 +164,7 @@
       // return the identity to the caller.
       update(who, extId);
       return new AuthResult(extId.accountId(), who.getExternalIdKey(), false);
-    } catch (OrmException | ConfigInvalidException e) {
+    } catch (StorageException | ConfigInvalidException e) {
       throw new AccountException("Authentication error", e);
     }
   }
@@ -209,18 +196,18 @@
 
     if (authRequest.isActive()) {
       try {
-        setInactiveFlag.activate(account.getId());
+        setInactiveFlag.activate(account.id());
       } catch (Exception e) {
-        throw new AccountException("Unable to activate account " + account.getId(), e);
+        throw new AccountException("Unable to activate account " + account.id(), e);
       }
     } else {
       try {
-        setInactiveFlag.deactivate(account.getId());
+        setInactiveFlag.deactivate(account.id());
       } catch (Exception e) {
-        throw new AccountException("Unable to deactivate account " + account.getId(), e);
+        throw new AccountException("Unable to deactivate account " + account.id(), e);
       }
     }
-    return byIdCache.get(account.getId()).map(AccountState::getAccount);
+    return byIdCache.get(account.id()).map(AccountState::getAccount);
   }
 
   private boolean shouldUpdateActiveStatus(AuthRequest authRequest) {
@@ -228,7 +215,7 @@
   }
 
   private void update(AuthRequest who, ExternalId extId)
-      throws OrmException, IOException, ConfigInvalidException, AccountException {
+      throws IOException, ConfigInvalidException, AccountException {
     IdentifiedUser user = userFactory.create(extId.accountId());
     List<Consumer<InternalAccountUpdate.Builder>> accountUpdates = new ArrayList<>();
 
@@ -243,15 +230,21 @@
       checkEmailNotUsed(extIdWithNewEmail);
       accountUpdates.add(u -> u.replaceExternalId(extId, extIdWithNewEmail));
 
-      if (oldEmail != null && oldEmail.equals(user.getAccount().getPreferredEmail())) {
+      if (oldEmail != null && oldEmail.equals(user.getAccount().preferredEmail())) {
         accountUpdates.add(u -> u.setPreferredEmail(newEmail));
       }
     }
 
-    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
-        && !Strings.isNullOrEmpty(who.getDisplayName())
-        && !Objects.equals(user.getAccount().getFullName(), who.getDisplayName())) {
+    if (!Strings.isNullOrEmpty(who.getDisplayName())
+        && !Objects.equals(user.getAccount().fullName(), who.getDisplayName())) {
       accountUpdates.add(u -> u.setFullName(who.getDisplayName()));
+      if (realm.allowsEdit(AccountFieldName.FULL_NAME)) {
+        accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
+      } else {
+        logger.atWarning().log(
+            "Not changing already set display name '%s' to '%s'",
+            user.getAccount().fullName(), who.getDisplayName());
+      }
     }
 
     if (!realm.allowsEdit(AccountFieldName.USER_NAME)
@@ -274,13 +267,13 @@
               user.getAccountId(),
               AccountUpdater.joinConsumers(accountUpdates))
           .orElseThrow(
-              () -> new OrmException("Account " + user.getAccountId() + " has been deleted"));
+              () -> new StorageException("Account " + user.getAccountId() + " has been deleted"));
     }
   }
 
   private AuthResult create(AuthRequest who)
-      throws OrmException, AccountException, IOException, ConfigInvalidException {
-    Account.Id newId = new Account.Id(sequences.nextAccountId());
+      throws AccountException, IOException, ConfigInvalidException {
+    Account.Id newId = Account.id(sequences.nextAccountId());
     logger.atFine().log("Assigning new Id %s to account", newId);
 
     ExternalId extId =
@@ -383,7 +376,7 @@
   }
 
   private void addGroupMember(AccountGroup.UUID groupUuid, IdentifiedUser user)
-      throws OrmException, IOException, ConfigInvalidException, AccountException {
+      throws IOException, ConfigInvalidException, AccountException {
     // The user initiated this request by logging in. -> Attribute all modifications to that user.
     GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
     InternalGroupUpdate groupUpdate =
@@ -408,9 +401,8 @@
    *     this time.
    */
   public AuthResult link(Account.Id to, AuthRequest who)
-      throws AccountException, OrmException, IOException, ConfigInvalidException {
+      throws AccountException, IOException, ConfigInvalidException {
     Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
-    logger.atFine().log("Link another authentication identity to an existing account");
     if (optionalExtId.isPresent()) {
       ExternalId extId = optionalExtId.get();
       if (!extId.accountId().equals(to)) {
@@ -419,7 +411,6 @@
       }
       update(who, extId);
     } else {
-      logger.atFine().log("Linking new external ID to the existing account");
       ExternalId newExtId =
           ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress());
       checkEmailNotUsed(newExtId);
@@ -430,7 +421,7 @@
               to,
               (a, u) -> {
                 u.addExternalId(newExtId);
-                if (who.getEmailAddress() != null && a.getAccount().getPreferredEmail() == null) {
+                if (who.getEmailAddress() != null && a.getAccount().preferredEmail() == null) {
                   u.setPreferredEmail(who.getEmailAddress());
                 }
               });
@@ -447,27 +438,27 @@
    * @param to account to link the identity onto.
    * @param who the additional identity.
    * @return the result of linking the identity to the user.
-   * @throws OrmException
    * @throws AccountException the identity belongs to a different account, or it cannot be linked at
    *     this time.
    */
   public AuthResult updateLink(Account.Id to, AuthRequest who)
-      throws OrmException, AccountException, IOException, ConfigInvalidException {
+      throws AccountException, IOException, ConfigInvalidException {
     accountsUpdateProvider
         .get()
         .update(
             "Delete External IDs on Update Link",
             to,
             (a, u) -> {
-              Collection<ExternalId> filteredExtIdsByScheme =
-                  a.getExternalIds(who.getExternalIdKey().scheme());
+              Set<ExternalId> filteredExtIdsByScheme =
+                  a.getExternalIds().stream()
+                      .filter(e -> e.key().isScheme(who.getExternalIdKey().scheme()))
+                      .collect(toImmutableSet());
               if (filteredExtIdsByScheme.isEmpty()) {
                 return;
               }
 
               if (filteredExtIdsByScheme.size() > 1
-                  || !filteredExtIdsByScheme
-                      .stream()
+                  || !filteredExtIdsByScheme.stream()
                       .anyMatch(e -> e.key().equals(who.getExternalIdKey()))) {
                 u.deleteExternalIds(filteredExtIdsByScheme);
               }
@@ -485,7 +476,7 @@
    *     found
    */
   public void unlink(Account.Id from, ExternalId.Key extIdKey)
-      throws AccountException, OrmException, IOException, ConfigInvalidException {
+      throws AccountException, IOException, ConfigInvalidException {
     unlink(from, ImmutableList.of(extIdKey));
   }
 
@@ -498,7 +489,7 @@
    *     identity was not found
    */
   public void unlink(Account.Id from, Collection<ExternalId.Key> extIdKeys)
-      throws AccountException, OrmException, IOException, ConfigInvalidException {
+      throws AccountException, IOException, ConfigInvalidException {
     if (extIdKeys.isEmpty()) {
       return;
     }
@@ -523,10 +514,9 @@
             from,
             (a, u) -> {
               u.deleteExternalIds(extIds);
-              if (a.getAccount().getPreferredEmail() != null
-                  && extIds
-                      .stream()
-                      .anyMatch(e -> a.getAccount().getPreferredEmail().equals(e.email()))) {
+              if (a.getAccount().preferredEmail() != null
+                  && extIds.stream()
+                      .anyMatch(e -> a.getAccount().preferredEmail().equals(e.email()))) {
                 u.setPreferredEmail(null);
               }
             });
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 6fcf56d..17b2bad 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -88,15 +88,16 @@
   }
 
   private void parse() {
-    account = new Account(accountId, registeredOn);
-    account.setActive(accountConfig.getBoolean(ACCOUNT, null, KEY_ACTIVE, true));
-    account.setFullName(get(accountConfig, KEY_FULL_NAME));
+    Account.Builder accountBuilder = Account.builder(accountId, registeredOn);
+    accountBuilder.setActive(accountConfig.getBoolean(ACCOUNT, null, KEY_ACTIVE, true));
+    accountBuilder.setFullName(get(accountConfig, KEY_FULL_NAME));
 
     String preferredEmail = get(accountConfig, KEY_PREFERRED_EMAIL);
-    account.setPreferredEmail(preferredEmail);
+    accountBuilder.setPreferredEmail(preferredEmail);
 
-    account.setStatus(get(accountConfig, KEY_STATUS));
-    account.setMetaId(metaId != null ? metaId.name() : null);
+    accountBuilder.setStatus(get(accountConfig, KEY_STATUS));
+    accountBuilder.setMetaId(metaId != null ? metaId.name() : null);
+    account = accountBuilder.build();
   }
 
   Config save(InternalAccountUpdate accountUpdate) {
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 017bcaa..244bb9e 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2019 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,182 +14,594 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static java.util.stream.Collectors.toSet;
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
-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.Collections;
-import java.util.HashSet;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeSet;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * Helper for resolving accounts given arbitrary user-provided input.
+ *
+ * <p>The {@code resolve*} methods each define a list of accepted formats for account resolution.
+ * The algorithm for resolving accounts from a list of formats is as follows:
+ *
+ * <ol>
+ *   <li>For each recognized format in the order listed in the method Javadoc, check whether the
+ *       input matches that format.
+ *   <li>If so, resolve accounts according to that format.
+ *   <li>Filter out invisible and inactive accounts.
+ *   <li>If the result list is non-empty, return.
+ *   <li>If the format is listed above as being short-circuiting, return.
+ *   <li>Otherwise, return to step 1 with the next format.
+ * </ol>
+ *
+ * <p>The result never includes accounts that are not visible to the calling user. It also never
+ * includes inactive accounts, with a small number of specific exceptions noted in method Javadoc.
+ */
 @Singleton
 public class AccountResolver {
-  private final Realm realm;
-  private final Accounts accounts;
-  private final AccountCache byId;
-  private final Provider<InternalAccountQuery> accountQueryProvider;
+  public static class UnresolvableAccountException extends UnprocessableEntityException {
+    private static final long serialVersionUID = 1L;
+    private final Result result;
+
+    @VisibleForTesting
+    UnresolvableAccountException(Result result) {
+      super(exceptionMessage(result));
+      this.result = result;
+    }
+
+    public boolean isSelf() {
+      return result.isSelf();
+    }
+  }
+
+  public static String exceptionMessage(Result result) {
+    checkArgument(result.asList().size() != 1);
+    if (result.asList().isEmpty()) {
+      if (result.isSelf()) {
+        return "Resolving account '" + result.input() + "' requires login";
+      }
+      if (result.filteredInactive().isEmpty()) {
+        return "Account '" + result.input() + "' not found";
+      }
+      return result.filteredInactive().stream()
+          .map(a -> formatForException(result, a))
+          .collect(
+              joining(
+                  "\n",
+                  "Account '"
+                      + result.input()
+                      + "' only matches inactive accounts. To use an inactive account, retry with"
+                      + " one of the following exact account IDs:\n",
+                  ""));
+    }
+
+    return result.asList().stream()
+        .map(a -> formatForException(result, a))
+        .collect(joining("\n", "Account '" + result.input() + "' is ambiguous:\n", ""));
+  }
+
+  private static String formatForException(Result result, AccountState state) {
+    return state.getAccount().id()
+        + ": "
+        + state.getAccount().getNameEmail(result.accountResolver().anonymousCowardName);
+  }
+
+  public static boolean isSelf(String input) {
+    return "self".equals(input) || "me".equals(input);
+  }
+
+  public class Result {
+    private final String input;
+    private final ImmutableList<AccountState> list;
+    private final ImmutableList<AccountState> filteredInactive;
+
+    @VisibleForTesting
+    Result(String input, List<AccountState> list, List<AccountState> filteredInactive) {
+      this.input = requireNonNull(input);
+      this.list = canonicalize(list);
+      this.filteredInactive = canonicalize(filteredInactive);
+    }
+
+    private ImmutableList<AccountState> canonicalize(List<AccountState> list) {
+      TreeSet<AccountState> set = new TreeSet<>(comparing(a -> a.getAccount().id().get()));
+      set.addAll(requireNonNull(list));
+      return ImmutableList.copyOf(set);
+    }
+
+    public String input() {
+      return input;
+    }
+
+    public boolean isSelf() {
+      return AccountResolver.isSelf(input);
+    }
+
+    public ImmutableList<AccountState> asList() {
+      return list;
+    }
+
+    public ImmutableSet<Account.Id> asNonEmptyIdSet() throws UnresolvableAccountException {
+      if (list.isEmpty()) {
+        throw new UnresolvableAccountException(this);
+      }
+      return asIdSet();
+    }
+
+    public ImmutableSet<Account.Id> asIdSet() {
+      return list.stream().map(a -> a.getAccount().id()).collect(toImmutableSet());
+    }
+
+    public AccountState asUnique() throws UnresolvableAccountException {
+      ensureUnique();
+      return list.get(0);
+    }
+
+    private void ensureUnique() throws UnresolvableAccountException {
+      if (list.size() != 1) {
+        throw new UnresolvableAccountException(this);
+      }
+    }
+
+    public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
+      ensureUnique();
+      if (isSelf()) {
+        // In the special case of "self", use the exact IdentifiedUser from the request context, to
+        // preserve the peer address and any other per-request state.
+        return self.get().asIdentifiedUser();
+      }
+      return userFactory.create(asUnique());
+    }
+
+    public IdentifiedUser asUniqueUserOnBehalfOf(CurrentUser caller)
+        throws UnresolvableAccountException {
+      ensureUnique();
+      if (isSelf()) {
+        // TODO(dborowitz): This preserves old behavior, but it seems wrong to discard the caller.
+        return self.get().asIdentifiedUser();
+      }
+      return userFactory.runAs(
+          null, list.get(0).getAccount().id(), requireNonNull(caller).getRealUser());
+    }
+
+    @VisibleForTesting
+    ImmutableList<AccountState> filteredInactive() {
+      return filteredInactive;
+    }
+
+    private AccountResolver accountResolver() {
+      return AccountResolver.this;
+    }
+  }
+
+  @VisibleForTesting
+  interface Searcher<I> {
+    default boolean callerShouldFilterOutInactiveCandidates() {
+      return true;
+    }
+
+    default boolean callerMayAssumeCandidatesAreVisible() {
+      return false;
+    }
+
+    Optional<I> tryParse(String input) throws IOException;
+
+    Stream<AccountState> search(I input) throws IOException, ConfigInvalidException;
+
+    boolean shortCircuitIfNoResults();
+
+    default Optional<Stream<AccountState>> trySearch(String input)
+        throws IOException, ConfigInvalidException {
+      Optional<I> parsed = tryParse(input);
+      return parsed.isPresent() ? Optional.of(search(parsed.get())) : Optional.empty();
+    }
+  }
+
+  @VisibleForTesting
+  abstract static class StringSearcher implements Searcher<String> {
+    @Override
+    public final Optional<String> tryParse(String input) {
+      return matches(input) ? Optional.of(input) : Optional.empty();
+    }
+
+    protected abstract boolean matches(String input);
+  }
+
+  private abstract class AccountIdSearcher implements Searcher<Account.Id> {
+    @Override
+    public final Stream<AccountState> search(Account.Id input) {
+      return Streams.stream(accountCache.get(input));
+    }
+  }
+
+  private class BySelf extends StringSearcher {
+    @Override
+    public boolean callerShouldFilterOutInactiveCandidates() {
+      return false;
+    }
+
+    @Override
+    public boolean callerMayAssumeCandidatesAreVisible() {
+      return true;
+    }
+
+    @Override
+    protected boolean matches(String input) {
+      return "self".equals(input) || "me".equals(input);
+    }
+
+    @Override
+    public Stream<AccountState> search(String input) {
+      CurrentUser user = self.get();
+      if (!user.isIdentifiedUser()) {
+        return Stream.empty();
+      }
+      return Stream.of(user.asIdentifiedUser().state());
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return true;
+    }
+  }
+
+  private class ByExactAccountId extends AccountIdSearcher {
+    @Override
+    public boolean callerShouldFilterOutInactiveCandidates() {
+      return false;
+    }
+
+    @Override
+    public Optional<Account.Id> tryParse(String input) {
+      return Account.Id.tryParse(input);
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return true;
+    }
+  }
+
+  private class ByParenthesizedAccountId extends AccountIdSearcher {
+    private final Pattern pattern = Pattern.compile("^.* \\(([1-9][0-9]*)\\)$");
+
+    @Override
+    public Optional<Account.Id> tryParse(String input) {
+      Matcher m = pattern.matcher(input);
+      return m.matches() ? Account.Id.tryParse(m.group(1)) : Optional.empty();
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return true;
+    }
+  }
+
+  private class ByUsername extends StringSearcher {
+    @Override
+    public boolean matches(String input) {
+      return ExternalId.isValidUsername(input);
+    }
+
+    @Override
+    public Stream<AccountState> search(String input) {
+      return Streams.stream(accountCache.getByUsername(input));
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return false;
+    }
+  }
+
+  private class ByNameAndEmail extends StringSearcher {
+    @Override
+    protected boolean matches(String input) {
+      int lt = input.indexOf('<');
+      int gt = input.indexOf('>');
+      return lt >= 0 && gt > lt && input.contains("@");
+    }
+
+    @Override
+    public Stream<AccountState> search(String nameOrEmail) throws IOException {
+      // TODO(dborowitz): This would probably work as a Searcher<Address>
+      int lt = nameOrEmail.indexOf('<');
+      int gt = nameOrEmail.indexOf('>');
+      Set<Account.Id> ids = emails.getAccountFor(nameOrEmail.substring(lt + 1, gt));
+      ImmutableList<AccountState> allMatches = toAccountStates(ids).collect(toImmutableList());
+      if (allMatches.isEmpty() || allMatches.size() == 1) {
+        return allMatches.stream();
+      }
+
+      // More than one match. If there are any that match the full name as well, return only that
+      // subset. Otherwise, all are equally non-matching, so return the full set.
+      String name = nameOrEmail.substring(0, lt - 1);
+      ImmutableList<AccountState> nameMatches =
+          allMatches.stream()
+              .filter(a -> name.equals(a.getAccount().fullName()))
+              .collect(toImmutableList());
+      return !nameMatches.isEmpty() ? nameMatches.stream() : allMatches.stream();
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return true;
+    }
+  }
+
+  private class ByEmail extends StringSearcher {
+    @Override
+    protected boolean matches(String input) {
+      return input.contains("@");
+    }
+
+    @Override
+    public Stream<AccountState> search(String input) throws IOException {
+      return toAccountStates(emails.getAccountFor(input));
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return true;
+    }
+  }
+
+  private class FromRealm extends AccountIdSearcher {
+    @Override
+    public Optional<Account.Id> tryParse(String input) throws IOException {
+      return Optional.ofNullable(realm.lookup(input));
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return false;
+    }
+  }
+
+  private class ByFullName implements Searcher<AccountState> {
+    @Override
+    public boolean callerMayAssumeCandidatesAreVisible() {
+      return true; // Rely on enforceVisibility from the index.
+    }
+
+    @Override
+    public Optional<AccountState> tryParse(String input) {
+      List<AccountState> results =
+          accountQueryProvider.get().enforceVisibility(true).byFullName(input);
+      return results.size() == 1 ? Optional.of(results.get(0)) : Optional.empty();
+    }
+
+    @Override
+    public Stream<AccountState> search(AccountState input) {
+      return Stream.of(input);
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return false;
+    }
+  }
+
+  private class ByDefaultSearch extends StringSearcher {
+    @Override
+    public boolean callerMayAssumeCandidatesAreVisible() {
+      return true; // Rely on enforceVisibility from the index.
+    }
+
+    @Override
+    protected boolean matches(String input) {
+      return true;
+    }
+
+    @Override
+    public Stream<AccountState> search(String input) {
+      // At this point we have no clue. Just perform a whole bunch of suggestions and pray we come
+      // up with a reasonable result list.
+      // TODO(dborowitz): This doesn't match the documentation; consider whether it's possible to be
+      // more strict here.
+      return accountQueryProvider.get().enforceVisibility(true).byDefault(input).stream();
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      // In practice this doesn't matter since this is the last searcher in the list, but considered
+      // on its own, it doesn't necessarily need to be terminal.
+      return false;
+    }
+  }
+
+  private final ImmutableList<Searcher<?>> nameOrEmailSearchers =
+      ImmutableList.of(
+          new ByNameAndEmail(),
+          new ByEmail(),
+          new FromRealm(),
+          new ByFullName(),
+          new ByDefaultSearch());
+
+  private final ImmutableList<Searcher<?>> searchers =
+      ImmutableList.<Searcher<?>>builder()
+          .add(new BySelf())
+          .add(new ByExactAccountId())
+          .add(new ByParenthesizedAccountId())
+          .add(new ByUsername())
+          .addAll(nameOrEmailSearchers)
+          .build();
+
+  private final AccountCache accountCache;
+  private final AccountControl.Factory accountControlFactory;
   private final Emails emails;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final Provider<CurrentUser> self;
+  private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final Realm realm;
+  private final String anonymousCowardName;
 
   @Inject
   AccountResolver(
-      Realm realm,
-      Accounts accounts,
-      AccountCache byId,
+      AccountCache accountCache,
+      Emails emails,
+      AccountControl.Factory accountControlFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      Provider<CurrentUser> self,
       Provider<InternalAccountQuery> accountQueryProvider,
-      Emails emails) {
+      Realm realm,
+      @AnonymousCowardName String anonymousCowardName) {
     this.realm = realm;
-    this.accounts = accounts;
-    this.byId = byId;
+    this.accountCache = accountCache;
+    this.accountControlFactory = accountControlFactory;
+    this.userFactory = userFactory;
+    this.self = self;
     this.accountQueryProvider = accountQueryProvider;
     this.emails = emails;
+    this.anonymousCowardName = anonymousCowardName;
   }
 
   /**
-   * Locate exactly one account matching the name or name/email string.
+   * Resolves all accounts matching the input string.
    *
-   * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
-   *     address ("email@example"), a full name ("Full Name"), an account id ("18419") or an user
-   *     name ("username").
-   * @return the single account that matches; null if no account matches or there are multiple
-   *     candidates.
+   * <p>The following input formats are recognized:
+   *
+   * <ul>
+   *   <li>The strings {@code "self"} and {@code "me"}, if the current user is an {@link
+   *       IdentifiedUser}. In this case, may return exactly one inactive account.
+   *   <li>A bare account ID ({@code "18419"}). In this case, may return exactly one inactive
+   *       account. This case short-circuits if the input matches.
+   *   <li>An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This
+   *       case short-circuits if the input matches.
+   *   <li>A username ({@code "username"}).
+   *   <li>A full name and email address ({@code "Full Name <email@example>"}). This case
+   *       short-circuits if the input matches.
+   *   <li>An email address ({@code "email@example"}. This case short-circuits if the input matches.
+   *   <li>An account name recognized by the configured {@link Realm#lookup(String)} Realm}.
+   *   <li>A full name ({@code "Full Name"}).
+   *   <li>As a fallback, a {@link
+   *       com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema,
+   *       boolean, String) default search} against the account index.
+   * </ul>
+   *
+   * @param input input string.
+   * @return a result describing matching accounts. Never null even if the result set is empty.
+   * @throws ConfigInvalidException if an error occurs.
+   * @throws IOException if an error occurs.
    */
-  public Account find(String nameOrEmail) throws OrmException, IOException, ConfigInvalidException {
-    Set<Account.Id> r = findAll(nameOrEmail);
-    if (r.size() == 1) {
-      return byId.get(r.iterator().next()).map(AccountState::getAccount).orElse(null);
-    }
+  public Result resolve(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(input, searchers, visibilitySupplier());
+  }
 
-    Account match = null;
-    for (Account.Id id : r) {
-      Optional<Account> account = byId.get(id).map(AccountState::getAccount);
-      if (!account.map(Account::isActive).orElse(false)) {
+  /**
+   * Resolves all accounts matching the input string by name or email.
+   *
+   * <p>The following input formats are recognized:
+   *
+   * <ul>
+   *   <li>A full name and email address ({@code "Full Name <email@example>"}). This case
+   *       short-circuits if the input matches.
+   *   <li>An email address ({@code "email@example"}. This case short-circuits if the input matches.
+   *   <li>An account name recognized by the configured {@link Realm#lookup(String)} Realm}.
+   *   <li>A full name ({@code "Full Name"}).
+   *   <li>As a fallback, a {@link
+   *       com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema,
+   *       boolean, String) default search} against the account index.
+   * </ul>
+   *
+   * @param input input string.
+   * @return a result describing matching accounts. Never null even if the result set is empty.
+   * @throws ConfigInvalidException if an error occurs.
+   * @throws IOException if an error occurs.
+   * @deprecated for use only by MailUtil for parsing commit footers; that class needs to be
+   *     reevaluated.
+   */
+  @Deprecated
+  public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(input, nameOrEmailSearchers, visibilitySupplier());
+  }
+
+  private Supplier<Predicate<AccountState>> visibilitySupplier() {
+    return () -> accountControlFactory.get()::canSee;
+  }
+
+  @VisibleForTesting
+  Result searchImpl(
+      String input,
+      ImmutableList<Searcher<?>> searchers,
+      Supplier<Predicate<AccountState>> visibilitySupplier)
+      throws ConfigInvalidException, IOException {
+    visibilitySupplier = Suppliers.memoize(visibilitySupplier::get);
+    List<AccountState> inactive = new ArrayList<>();
+
+    for (Searcher<?> searcher : searchers) {
+      Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input);
+      if (!maybeResults.isPresent()) {
         continue;
       }
-      if (match != null) {
-        return null;
+      Stream<AccountState> results = maybeResults.get();
+
+      if (!searcher.callerMayAssumeCandidatesAreVisible()) {
+        results = results.filter(visibilitySupplier.get());
       }
-      match = account.get();
+
+      List<AccountState> list;
+      if (searcher.callerShouldFilterOutInactiveCandidates()) {
+        // Keep track of all inactive candidates discovered by any searchers. If we end up short-
+        // circuiting, the inactive list will be discarded.
+        List<AccountState> active = new ArrayList<>();
+        results.forEach(a -> (a.getAccount().isActive() ? active : inactive).add(a));
+        list = active;
+      } else {
+        list = results.collect(toImmutableList());
+      }
+
+      if (!list.isEmpty()) {
+        return createResult(input, list);
+      }
+      if (searcher.shortCircuitIfNoResults()) {
+        // For a short-circuiting searcher, return results even if empty.
+        return !inactive.isEmpty() ? emptyResult(input, inactive) : createResult(input, list);
+      }
     }
-    return match;
+    return emptyResult(input, inactive);
   }
 
-  /**
-   * Find all accounts matching the name or name/email string.
-   *
-   * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
-   *     address ("email@example"), a full name ("Full Name"), an account id ("18419") or an user
-   *     name ("username").
-   * @return the accounts that match, empty collection if none. Never null.
-   */
-  public Set<Account.Id> findAll(String nameOrEmail)
-      throws OrmException, IOException, ConfigInvalidException {
-    Matcher m = Pattern.compile("^.* \\(([1-9][0-9]*)\\)$").matcher(nameOrEmail);
-    if (m.matches()) {
-      Optional<Account.Id> id = Account.Id.tryParse(m.group(1));
-      if (id.isPresent()) {
-        return Streams.stream(accounts.get(id.get()))
-            .map(a -> a.getAccount().getId())
-            .collect(toImmutableSet());
-      }
-    }
-
-    if (nameOrEmail.matches("^[1-9][0-9]*$")) {
-      Optional<Account.Id> id = Account.Id.tryParse(nameOrEmail);
-      if (id.isPresent()) {
-        return Streams.stream(accounts.get(id.get()))
-            .map(a -> a.getAccount().getId())
-            .collect(toImmutableSet());
-      }
-    }
-
-    if (ExternalId.isValidUsername(nameOrEmail)) {
-      Optional<AccountState> who = byId.getByUsername(nameOrEmail);
-      if (who.isPresent()) {
-        return ImmutableSet.of(who.map(a -> a.getAccount().getId()).get());
-      }
-    }
-
-    return findAllByNameOrEmail(nameOrEmail);
+  private Result createResult(String input, List<AccountState> list) {
+    return new Result(input, list, ImmutableList.of());
   }
 
-  /**
-   * Locate exactly one account matching the name or name/email string.
-   *
-   * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
-   *     address ("email@example"), a full name ("Full Name").
-   * @return the single account that matches; null if no account matches or there are multiple
-   *     candidates.
-   */
-  public Account findByNameOrEmail(String nameOrEmail) throws OrmException, IOException {
-    Set<Account.Id> r = findAllByNameOrEmail(nameOrEmail);
-    return r.size() == 1
-        ? byId.get(r.iterator().next()).map(AccountState::getAccount).orElse(null)
-        : null;
+  private Result emptyResult(String input, List<AccountState> inactive) {
+    return new Result(input, ImmutableList.of(), inactive);
   }
 
-  /**
-   * Locate exactly one account matching the name or name/email string.
-   *
-   * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
-   *     address ("email@example"), a full name ("Full Name").
-   * @return the accounts that match, empty collection if none. Never null.
-   */
-  public Set<Account.Id> findAllByNameOrEmail(String nameOrEmail) throws OrmException, IOException {
-    int lt = nameOrEmail.indexOf('<');
-    int gt = nameOrEmail.indexOf('>');
-    if (lt >= 0 && gt > lt && nameOrEmail.contains("@")) {
-      Set<Account.Id> ids = emails.getAccountFor(nameOrEmail.substring(lt + 1, gt));
-      if (ids.isEmpty() || ids.size() == 1) {
-        return ids;
-      }
-
-      // more than one match, try to return the best one
-      String name = nameOrEmail.substring(0, lt - 1);
-      Set<Account.Id> nameMatches = new HashSet<>();
-      for (Account.Id id : ids) {
-        Optional<Account> a = byId.get(id).map(AccountState::getAccount);
-        if (a.isPresent() && name.equals(a.get().getFullName())) {
-          nameMatches.add(id);
-        }
-      }
-      return nameMatches.isEmpty() ? ids : nameMatches;
-    }
-
-    if (nameOrEmail.contains("@")) {
-      return emails.getAccountFor(nameOrEmail);
-    }
-
-    Account.Id id = realm.lookup(nameOrEmail);
-    if (id != null) {
-      return Collections.singleton(id);
-    }
-
-    List<AccountState> m = accountQueryProvider.get().byFullName(nameOrEmail);
-    if (m.size() == 1) {
-      return Collections.singleton(m.get(0).getAccount().getId());
-    }
-
-    // At this point we have no clue. Just perform a whole bunch of suggestions
-    // and pray we come up with a reasonable result list.
-    return accountQueryProvider
-        .get()
-        .byDefault(nameOrEmail)
-        .stream()
-        .map(a -> a.getAccount().getId())
-        .collect(toSet());
+  private Stream<AccountState> toAccountStates(Set<Account.Id> ids) {
+    return accountCache.get(ids).values().stream();
   }
 }
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index e56ad72..8555166 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
 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;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
@@ -29,14 +26,11 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.config.AllUsersName;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Optional;
@@ -53,22 +47,17 @@
 public class AccountState {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
-      a -> a.getAccount().getId();
-
   /**
    * Creates an AccountState from the given account config.
    *
-   * @param allUsersName the name of the All-Users repository
    * @param externalIds class to access external IDs
    * @param accountConfig the account config, must already be loaded
    * @return the account state, {@link Optional#empty()} if the account doesn't exist
    * @throws IOException if accessing the external IDs fails
    */
   public static Optional<AccountState> fromAccountConfig(
-      AllUsersName allUsersName, ExternalIds externalIds, AccountConfig accountConfig)
-      throws IOException {
-    return fromAccountConfig(allUsersName, externalIds, accountConfig, null);
+      ExternalIds externalIds, AccountConfig accountConfig) throws IOException {
+    return fromAccountConfig(externalIds, accountConfig, null);
   }
 
   /**
@@ -81,7 +70,6 @@
    * updated the revision of the external IDs branch in account config is outdated. Hence after
    * updating external IDs the external ID notes must be provided.
    *
-   * @param allUsersName the name of the All-Users repository
    * @param externalIds class to access external IDs
    * @param accountConfig the account config, must already be loaded
    * @param extIdNotes external ID notes, must already be loaded, may be {@code null}
@@ -89,10 +77,7 @@
    * @throws IOException if accessing the external IDs fails
    */
   public static Optional<AccountState> fromAccountConfig(
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      AccountConfig accountConfig,
-      @Nullable ExternalIdNotes extIdNotes)
+      ExternalIds externalIds, AccountConfig accountConfig, @Nullable ExternalIdNotes extIdNotes)
       throws IOException {
     if (!accountConfig.getLoadedAccount().isPresent()) {
       return Optional.empty();
@@ -105,7 +90,7 @@
             : accountConfig.getExternalIdsRev();
     ImmutableSet<ExternalId> extIds =
         extIdsRev.isPresent()
-            ? ImmutableSet.copyOf(externalIds.byAccount(account.getId(), extIdsRev.get()))
+            ? ImmutableSet.copyOf(externalIds.byAccount(account.id(), extIdsRev.get()))
             : ImmutableSet.of();
 
     // Don't leak references to AccountConfig into the AccountState, since it holds a reference to
@@ -118,39 +103,29 @@
 
     return Optional.of(
         new AccountState(
-            allUsersName,
-            account,
-            extIds,
-            projectWatches,
-            generalPreferences,
-            diffPreferences,
-            editPreferences));
+            account, extIds, projectWatches, generalPreferences, diffPreferences, editPreferences));
   }
 
   /**
    * Creates an AccountState for a given account with no external IDs, no project watches and
    * default preferences.
    *
-   * @param allUsersName the name of the All-Users repository
    * @param account the account
    * @return the account state
    */
-  public static AccountState forAccount(AllUsersName allUsersName, Account account) {
-    return forAccount(allUsersName, account, ImmutableSet.of());
+  public static AccountState forAccount(Account account) {
+    return forAccount(account, ImmutableSet.of());
   }
 
   /**
    * Creates an AccountState for a given account with no project watches and default preferences.
    *
-   * @param allUsersName the name of the All-Users repository
    * @param account the account
    * @param extIds the external IDs
    * @return the account state
    */
-  public static AccountState forAccount(
-      AllUsersName allUsersName, Account account, Collection<ExternalId> extIds) {
+  public static AccountState forAccount(Account account, Collection<ExternalId> extIds) {
     return new AccountState(
-        allUsersName,
         account,
         ImmutableSet.copyOf(extIds),
         ImmutableMap.of(),
@@ -159,7 +134,6 @@
         EditPreferencesInfo.defaults());
   }
 
-  private final AllUsersName allUsersName;
   private final Account account;
   private final ImmutableSet<ExternalId> externalIds;
   private final Optional<String> userName;
@@ -167,17 +141,14 @@
   private final GeneralPreferencesInfo generalPreferences;
   private final DiffPreferencesInfo diffPreferences;
   private final EditPreferencesInfo editPreferences;
-  private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
 
   private AccountState(
-      AllUsersName allUsersName,
       Account account,
       ImmutableSet<ExternalId> externalIds,
       ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches,
       GeneralPreferencesInfo generalPreferences,
       DiffPreferencesInfo diffPreferences,
       EditPreferencesInfo editPreferences) {
-    this.allUsersName = allUsersName;
     this.account = account;
     this.externalIds = externalIds;
     this.userName = ExternalId.getUserName(externalIds);
@@ -187,10 +158,6 @@
     this.editPreferences = editPreferences;
   }
 
-  public AllUsersName getAllUsersNameForIndexing() {
-    return allUsersName;
-  }
-
   /** Get the cached account metadata. */
   public Account getAccount() {
     return account;
@@ -236,11 +203,6 @@
     return externalIds;
   }
 
-  /** The external identities that identify the account holder that match the given scheme. */
-  public ImmutableSet<ExternalId> getExternalIds(String scheme) {
-    return externalIds.stream().filter(e -> e.key().isScheme(scheme)).collect(toImmutableSet());
-  }
-
   /** The project watches of the account. */
   public ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> getProjectWatches() {
     return projectWatches;
@@ -261,58 +223,10 @@
     return editPreferences;
   }
 
-  /**
-   * Lookup a previously stored property.
-   *
-   * <p>All properties are automatically cleared when the account cache invalidates the {@code
-   * AccountState}. This method is thread-safe.
-   *
-   * @param key unique property key.
-   * @return previously stored value, or {@code null}.
-   */
-  @Nullable
-  public <T> T get(PropertyKey<T> key) {
-    Cache<PropertyKey<Object>, Object> p = properties(false);
-    if (p != null) {
-      @SuppressWarnings("unchecked")
-      T value = (T) p.getIfPresent(key);
-      return value;
-    }
-    return null;
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * <p>This method is thread-safe.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  public <T> void put(PropertyKey<T> key, @Nullable T value) {
-    Cache<PropertyKey<Object>, Object> p = properties(value != null);
-    if (p != null) {
-      @SuppressWarnings("unchecked")
-      PropertyKey<Object> k = (PropertyKey<Object>) key;
-      if (value != null) {
-        p.put(k, value);
-      } else {
-        p.invalidate(k);
-      }
-    }
-  }
-
-  private synchronized Cache<PropertyKey<Object>, Object> properties(boolean allocate) {
-    if (properties == null && allocate) {
-      properties =
-          CacheBuilder.newBuilder()
-              .concurrencyLevel(1)
-              .initialCapacity(16)
-              // Use weakKeys to ensure plugins that garbage collect will also
-              // eventually release data held in any still live AccountState.
-              .weakKeys()
-              .build();
-    }
-    return properties;
+  @Override
+  public String toString() {
+    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
+    h.addValue(getAccount().id());
+    return h.toString();
   }
 }
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
index 7dff74c..dbe7ba7 100644
--- a/java/com/google/gerrit/server/account/Accounts.java
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -134,14 +134,11 @@
   private Optional<AccountState> read(Repository allUsersRepository, Account.Id accountId)
       throws IOException, ConfigInvalidException {
     return AccountState.fromAccountConfig(
-        allUsersName, externalIds, new AccountConfig(accountId, allUsersRepository).load());
+        externalIds, new AccountConfig(accountId, allUsersName, allUsersRepository).load());
   }
 
   public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
-    return repo.getRefDatabase()
-        .getRefs(RefNames.REFS_USERS)
-        .values()
-        .stream()
+    return repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS).stream()
         .map(r -> Account.Id.fromRef(r.getName()))
         .filter(Objects::nonNull);
   }
diff --git a/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java b/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
index 0b63927..6ec3a05 100644
--- a/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
@@ -36,15 +36,13 @@
 
     for (AccountState accountState : accounts.all()) {
       Account account = accountState.getAccount();
-      if (account.getPreferredEmail() != null) {
-        if (!accountState
-            .getExternalIds()
-            .stream()
-            .anyMatch(e -> account.getPreferredEmail().equals(e.email()))) {
+      if (account.preferredEmail() != null) {
+        if (!accountState.getExternalIds().stream()
+            .anyMatch(e -> account.preferredEmail().equals(e.email()))) {
           addError(
               String.format(
                   "Account '%s' has no external ID for its preferred email '%s'",
-                  account.getId().get(), account.getPreferredEmail()),
+                  account.id().get(), account.preferredEmail()),
               problems);
         }
       }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 996e602..2920cef 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
@@ -23,33 +24,34 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.util.concurrent.Runnables;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.InternalAccountUpdate.Builder;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes.ExternalIdNotesLoader;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
-import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryHelper.Action;
 import com.google.gerrit.server.update.RetryHelper.ActionType;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -78,7 +80,7 @@
  * commit in the user branch, and thus help debugging.
  *
  * <p>For creating a new account a new account ID can be retrieved from {@link
- * com.google.gerrit.server.Sequences#nextAccountId()}.
+ * Sequences#nextAccountId()}.
  *
  * <p>The account updates are written to NoteDb. In NoteDb accounts are represented as user branches
  * in the {@code All-Users} repository. Optionally a user branch can contain a 'account.config' file
@@ -120,16 +122,28 @@
   public interface Factory {
     /**
      * Creates an {@code AccountsUpdate} which uses the identity of the specified user as author for
-     * all commits related to accounts. The Gerrit server identity will be used as committer.
+     * all commits related to accounts. The server identity will be used as committer.
      *
-     * <p><strong>Note</strong>: Please use this method with care and rather consider to use the
-     * correct annotation on the provider of an {@code AccountsUpdate} instead.
+     * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
+     * com.google.gerrit.server.UserInitiated} annotation on the provider of an {@code
+     * AccountsUpdate} instead.
      *
-     * @param currentUser the user to which modifications should be attributed, or {@code null} if
-     *     the Gerrit server identity should also be used as author
+     * @param currentUser the user to which modifications should be attributed
+     * @param externalIdNotesLoader the loader that should be used to load external ID notes
      */
-    AccountsUpdate create(
-        @Nullable IdentifiedUser currentUser, ExternalIdNotesLoader externalIdNotesLoader);
+    AccountsUpdate create(IdentifiedUser currentUser, ExternalIdNotesLoader externalIdNotesLoader);
+
+    /**
+     * Creates an {@code AccountsUpdate} which uses the server identity as author and committer for
+     * all commits related to accounts.
+     *
+     * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
+     * com.google.gerrit.server.ServerInitiated} annotation on the provider of an {@code
+     * AccountsUpdate} instead.
+     *
+     * @param externalIdNotesLoader the loader that should be used to load external ID notes
+     */
+    AccountsUpdate createWithServerIdent(ExternalIdNotesLoader externalIdNotesLoader);
   }
 
   /**
@@ -151,12 +165,9 @@
     void update(AccountState accountState, InternalAccountUpdate.Builder update) throws IOException;
 
     static AccountUpdater join(List<AccountUpdater> updaters) {
-      return new AccountUpdater() {
-        @Override
-        public void update(AccountState accountState, Builder update) throws IOException {
-          for (AccountUpdater updater : updaters) {
-            updater.update(accountState, update);
-          }
+      return (accountState, update) -> {
+        for (AccountUpdater updater : updaters) {
+          updater.update(accountState, update);
         }
       };
     }
@@ -172,7 +183,7 @@
 
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated gitRefUpdated;
-  @Nullable private final IdentifiedUser currentUser;
+  private final Optional<IdentifiedUser> currentUser;
   private final AllUsersName allUsersName;
   private final ExternalIds externalIds;
   private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
@@ -187,7 +198,7 @@
   // Invoked after updating the account but before committing the changes.
   private final Runnable beforeCommit;
 
-  @Inject
+  @AssistedInject
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -196,19 +207,44 @@
       Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
       RetryHelper retryHelper,
       @GerritPersonIdent PersonIdent serverIdent,
-      @Assisted @Nullable IdentifiedUser currentUser,
       @Assisted ExternalIdNotesLoader extIdNotesLoader) {
     this(
         repoManager,
         gitRefUpdated,
-        currentUser,
+        Optional.empty(),
         allUsersName,
         externalIds,
         metaDataUpdateInternalFactory,
         retryHelper,
         extIdNotesLoader,
         serverIdent,
-        createPersonIdent(serverIdent, currentUser),
+        createPersonIdent(serverIdent, Optional.empty()),
+        Runnables.doNothing(),
+        Runnables.doNothing());
+  }
+
+  @AssistedInject
+  AccountsUpdate(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      AllUsersName allUsersName,
+      ExternalIds externalIds,
+      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+      RetryHelper retryHelper,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @Assisted IdentifiedUser currentUser,
+      @Assisted ExternalIdNotesLoader extIdNotesLoader) {
+    this(
+        repoManager,
+        gitRefUpdated,
+        Optional.of(currentUser),
+        allUsersName,
+        externalIds,
+        metaDataUpdateInternalFactory,
+        retryHelper,
+        extIdNotesLoader,
+        serverIdent,
+        createPersonIdent(serverIdent, Optional.of(currentUser)),
         Runnables.doNothing(),
         Runnables.doNothing());
   }
@@ -217,7 +253,7 @@
   public AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
-      @Nullable IdentifiedUser currentUser,
+      Optional<IdentifiedUser> currentUser,
       AllUsersName allUsersName,
       ExternalIds externalIds,
       Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
@@ -227,27 +263,27 @@
       PersonIdent authorIdent,
       Runnable afterReadRevision,
       Runnable beforeCommit) {
-    this.repoManager = checkNotNull(repoManager, "repoManager");
-    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
+    this.repoManager = requireNonNull(repoManager, "repoManager");
+    this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
     this.currentUser = currentUser;
-    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
-    this.externalIds = checkNotNull(externalIds, "externalIds");
+    this.allUsersName = requireNonNull(allUsersName, "allUsersName");
+    this.externalIds = requireNonNull(externalIds, "externalIds");
     this.metaDataUpdateInternalFactory =
-        checkNotNull(metaDataUpdateInternalFactory, "metaDataUpdateInternalFactory");
-    this.retryHelper = checkNotNull(retryHelper, "retryHelper");
-    this.extIdNotesLoader = checkNotNull(extIdNotesLoader, "extIdNotesLoader");
-    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
-    this.authorIdent = checkNotNull(authorIdent, "authorIdent");
-    this.afterReadRevision = checkNotNull(afterReadRevision, "afterReadRevision");
-    this.beforeCommit = checkNotNull(beforeCommit, "beforeCommit");
+        requireNonNull(metaDataUpdateInternalFactory, "metaDataUpdateInternalFactory");
+    this.retryHelper = requireNonNull(retryHelper, "retryHelper");
+    this.extIdNotesLoader = requireNonNull(extIdNotesLoader, "extIdNotesLoader");
+    this.committerIdent = requireNonNull(committerIdent, "committerIdent");
+    this.authorIdent = requireNonNull(authorIdent, "authorIdent");
+    this.afterReadRevision = requireNonNull(afterReadRevision, "afterReadRevision");
+    this.beforeCommit = requireNonNull(beforeCommit, "beforeCommit");
   }
 
   private static PersonIdent createPersonIdent(
-      PersonIdent serverIdent, @Nullable IdentifiedUser user) {
-    if (user == null) {
+      PersonIdent serverIdent, Optional<IdentifiedUser> user) {
+    if (!user.isPresent()) {
       return serverIdent;
     }
-    return user.newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
+    return user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
   }
 
   /**
@@ -257,14 +293,13 @@
    * @param accountId ID of the new account
    * @param init consumer to populate the new account
    * @return the newly created account
-   * @throws OrmDuplicateKeyException if the account already exists
+   * @throws DuplicateKeyException if the account already exists
    * @throws IOException if creating the user branch fails due to an IO error
-   * @throws OrmException if creating the user branch fails
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
   public AccountState insert(
       String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> init)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException {
     return insert(message, accountId, AccountUpdater.fromConsumer(init));
   }
 
@@ -275,19 +310,18 @@
    * @param accountId ID of the new account
    * @param updater updater to populate the new account
    * @return the newly created account
-   * @throws OrmDuplicateKeyException if the account already exists
+   * @throws DuplicateKeyException if the account already exists
    * @throws IOException if creating the user branch fails due to an IO error
-   * @throws OrmException if creating the user branch fails
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
   public AccountState insert(String message, Account.Id accountId, AccountUpdater updater)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException {
     return updateAccount(
             r -> {
               AccountConfig accountConfig = read(r, accountId);
               Account account =
                   accountConfig.getNewAccount(new Timestamp(committerIdent.getWhen().getTime()));
-              AccountState accountState = AccountState.forAccount(allUsersName, account);
+              AccountState accountState = AccountState.forAccount(account);
               InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
               updater.update(accountState, updateBuilder);
 
@@ -296,7 +330,7 @@
               ExternalIdNotes extIdNotes =
                   createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
               UpdatedAccount updatedAccounts =
-                  new UpdatedAccount(allUsersName, externalIds, message, accountConfig, extIdNotes);
+                  new UpdatedAccount(externalIds, message, accountConfig, extIdNotes);
               updatedAccounts.setCreated(true);
               return updatedAccounts;
             })
@@ -315,12 +349,11 @@
    * @throws IOException if updating the user branch fails due to an IO error
    * @throws LockFailureException if updating the user branch still fails due to concurrent updates
    *     after the retry timeout exceeded
-   * @throws OrmException if updating the user branch fails
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
   public Optional<AccountState> update(
       String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> update)
-      throws OrmException, LockFailureException, IOException, ConfigInvalidException {
+      throws LockFailureException, IOException, ConfigInvalidException {
     return update(message, accountId, AccountUpdater.fromConsumer(update));
   }
 
@@ -336,16 +369,15 @@
    * @throws IOException if updating the user branch fails due to an IO error
    * @throws LockFailureException if updating the user branch still fails due to concurrent updates
    *     after the retry timeout exceeded
-   * @throws OrmException if updating the user branch fails
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
   public Optional<AccountState> update(String message, Account.Id accountId, AccountUpdater updater)
-      throws OrmException, LockFailureException, IOException, ConfigInvalidException {
+      throws LockFailureException, IOException, ConfigInvalidException {
     return updateAccount(
         r -> {
           AccountConfig accountConfig = read(r, accountId);
           Optional<AccountState> account =
-              AccountState.fromAccountConfig(allUsersName, externalIds, accountConfig);
+              AccountState.fromAccountConfig(externalIds, accountConfig);
           if (!account.isPresent()) {
             return null;
           }
@@ -358,20 +390,20 @@
           ExternalIdNotes extIdNotes =
               createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
           UpdatedAccount updatedAccounts =
-              new UpdatedAccount(allUsersName, externalIds, message, accountConfig, extIdNotes);
+              new UpdatedAccount(externalIds, message, accountConfig, extIdNotes);
           return updatedAccounts;
         });
   }
 
   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;
   }
 
   private Optional<AccountState> updateAccount(AccountUpdate accountUpdate)
-      throws IOException, ConfigInvalidException, OrmException {
+      throws IOException, ConfigInvalidException {
     return executeAccountUpdate(
         () -> {
           try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
@@ -387,7 +419,7 @@
   }
 
   private Optional<AccountState> executeAccountUpdate(Action<Optional<AccountState>> action)
-      throws IOException, ConfigInvalidException, OrmException {
+      throws IOException, ConfigInvalidException {
     try {
       return retryHelper.execute(
           ActionType.ACCOUNT_UPDATE, action, LockFailureException.class::isInstance);
@@ -395,8 +427,7 @@
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, IOException.class);
       Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
-      Throwables.throwIfInstanceOf(e, OrmException.class);
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
@@ -405,7 +436,7 @@
       Optional<ObjectId> rev,
       Account.Id accountId,
       InternalAccountUpdate update)
-      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+      throws IOException, ConfigInvalidException, DuplicateKeyException {
     ExternalIdNotes.checkSameAccount(
         Iterables.concat(
             update.getCreatedExternalIds(),
@@ -445,9 +476,24 @@
         updatedAccount.getExternalIdNotes());
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
-    updatedAccount.getExternalIdNotes().updateCaches();
+
+    // Skip accounts that are updated when evicting the account cache via ExternalIdNotes to avoid
+    // double reindexing. The updated accounts will already be reindexed by ReindexAfterRefUpdate.
+    Set<Account.Id> accountsThatWillBeReindexByReindexAfterRefUpdate =
+        getUpdatedAccounts(batchRefUpdate);
+    updatedAccount
+        .getExternalIdNotes()
+        .updateCaches(accountsThatWillBeReindexByReindexAfterRefUpdate);
+
     gitRefUpdated.fire(
-        allUsersName, batchRefUpdate, currentUser != null ? currentUser.state() : null);
+        allUsersName, batchRefUpdate, currentUser.map(user -> user.state()).orElse(null));
+  }
+
+  private static Set<Account.Id> getUpdatedAccounts(BatchRefUpdate batchRefUpdate) {
+    return batchRefUpdate.getCommands().stream()
+        .map(c -> Account.Id.fromRef(c.getRefName()))
+        .filter(Objects::nonNull)
+        .collect(toSet());
   }
 
   private void commitNewAccountConfig(
@@ -511,12 +557,10 @@
 
   @FunctionalInterface
   private static interface AccountUpdate {
-    UpdatedAccount update(Repository allUsersRepo)
-        throws IOException, ConfigInvalidException, OrmException;
+    UpdatedAccount update(Repository allUsersRepo) throws IOException, ConfigInvalidException;
   }
 
   private static class UpdatedAccount {
-    private final AllUsersName allUsersName;
     private final ExternalIds externalIds;
     private final String message;
     private final AccountConfig accountConfig;
@@ -525,17 +569,15 @@
     private boolean created;
 
     private UpdatedAccount(
-        AllUsersName allUsersName,
         ExternalIds externalIds,
         String message,
         AccountConfig accountConfig,
         ExternalIdNotes extIdNotes) {
       checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
-      this.allUsersName = checkNotNull(allUsersName);
-      this.externalIds = checkNotNull(externalIds);
-      this.message = checkNotNull(message);
-      this.accountConfig = checkNotNull(accountConfig);
-      this.extIdNotes = checkNotNull(extIdNotes);
+      this.externalIds = requireNonNull(externalIds);
+      this.message = requireNonNull(message);
+      this.accountConfig = requireNonNull(accountConfig);
+      this.extIdNotes = requireNonNull(extIdNotes);
     }
 
     public String getMessage() {
@@ -547,8 +589,7 @@
     }
 
     public AccountState getAccount() throws IOException {
-      return AccountState.fromAccountConfig(allUsersName, externalIds, accountConfig, extIdNotes)
-          .get();
+      return AccountState.fromAccountConfig(externalIds, accountConfig, extIdNotes).get();
     }
 
     public ExternalIdNotes getExternalIdNotes() {
diff --git a/java/com/google/gerrit/server/account/CapabilityCollection.java b/java/com/google/gerrit/server/account/CapabilityCollection.java
index ee74f47..b52d616 100644
--- a/java/com/google/gerrit/server/account/CapabilityCollection.java
+++ b/java/com/google/gerrit/server/account/CapabilityCollection.java
@@ -48,6 +48,7 @@
   public final ImmutableList<PermissionRule> batchChangesLimit;
   public final ImmutableList<PermissionRule> emailReviewers;
   public final ImmutableList<PermissionRule> priority;
+  public final ImmutableList<PermissionRule> readAs;
   public final ImmutableList<PermissionRule> queryLimit;
   public final ImmutableList<PermissionRule> createGroup;
 
@@ -80,7 +81,7 @@
     }
     configureDefaults(tmp, section);
     if (!tmp.containsKey(GlobalCapability.ADMINISTRATE_SERVER) && !admins.isEmpty()) {
-      tmp.put(GlobalCapability.ADMINISTRATE_SERVER, ImmutableList.<PermissionRule>of());
+      tmp.put(GlobalCapability.ADMINISTRATE_SERVER, ImmutableList.of());
     }
 
     ImmutableMap.Builder<String, ImmutableList<PermissionRule>> m = ImmutableMap.builder();
@@ -97,6 +98,7 @@
     batchChangesLimit = getPermission(GlobalCapability.BATCH_CHANGES_LIMIT);
     emailReviewers = getPermission(GlobalCapability.EMAIL_REVIEWERS);
     priority = getPermission(GlobalCapability.PRIORITY);
+    readAs = getPermission(GlobalCapability.READ_AS);
     queryLimit = getPermission(GlobalCapability.QUERY_LIMIT);
     createGroup = getPermission(GlobalCapability.CREATE_GROUP);
   }
@@ -121,7 +123,7 @@
 
   public ImmutableList<PermissionRule> getPermission(String permissionName) {
     ImmutableList<PermissionRule> r = permissions.get(permissionName);
-    return r != null ? r : ImmutableList.<PermissionRule>of();
+    return r != null ? r : ImmutableList.of();
   }
 
   private void configureDefaults(Map<String, List<PermissionRule>> out, AccessSection section) {
diff --git a/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
index 5bcb84b..573baee 100644
--- a/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -34,7 +34,7 @@
   }
 
   public void setGroupName(String n) {
-    groupName = n != null ? new AccountGroup.NameKey(n) : null;
+    groupName = n != null ? AccountGroup.nameKey(n) : null;
   }
 
   public void setGroupName(AccountGroup.NameKey n) {
diff --git a/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java
index dde6e81..33de2d2 100644
--- a/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -16,11 +16,11 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -87,7 +87,7 @@
         if (1 == c.size()) {
           return c.iterator().next();
         }
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         throw new IOException("Failed to query accounts by email", e);
       }
     }
diff --git a/java/com/google/gerrit/server/account/DestinationList.java b/java/com/google/gerrit/server/account/DestinationList.java
index 04e710a..8f4e72e 100644
--- a/java/com/google/gerrit/server/account/DestinationList.java
+++ b/java/com/google/gerrit/server/account/DestinationList.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.TabFile;
@@ -28,10 +28,10 @@
 
 public class DestinationList extends TabFile {
   public static final String DIR_NAME = "destinations";
-  private SetMultimap<String, Branch.NameKey> destinations =
+  private SetMultimap<String, BranchNameKey> destinations =
       MultimapBuilder.hashKeys().hashSetValues().build();
 
-  public Set<Branch.NameKey> getDestinations(String label) {
+  public Set<BranchNameKey> getDestinations(String label) {
     return destinations.get(label);
   }
 
@@ -40,21 +40,21 @@
   }
 
   String asText(String label) {
-    Set<Branch.NameKey> dests = destinations.get(label);
+    Set<BranchNameKey> dests = destinations.get(label);
     if (dests == null) {
       return null;
     }
     List<Row> rows = Lists.newArrayListWithCapacity(dests.size());
-    for (Branch.NameKey dest : sort(dests)) {
-      rows.add(new Row(dest.get(), dest.getParentKey().get()));
+    for (BranchNameKey dest : sort(dests)) {
+      rows.add(new Row(dest.branch(), dest.project().get()));
     }
     return asText("Ref", "Project", rows);
   }
 
-  private static Set<Branch.NameKey> toSet(List<Row> destRows) {
-    Set<Branch.NameKey> dests = Sets.newHashSetWithExpectedSize(destRows.size());
+  private static Set<BranchNameKey> toSet(List<Row> destRows) {
+    Set<BranchNameKey> dests = Sets.newHashSetWithExpectedSize(destRows.size());
     for (Row row : destRows) {
-      dests.add(new Branch.NameKey(new Project.NameKey(row.right), row.left));
+      dests.add(BranchNameKey.create(Project.nameKey(row.right), row.left));
     }
     return dests;
   }
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 8a48167..14f279b 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Streams;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -27,7 +28,6 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryHelper.Action;
 import com.google.gerrit.server.update.RetryHelper.ActionType;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -69,11 +69,11 @@
    *
    * @see #getAccountsFor(String...)
    */
-  public ImmutableSet<Account.Id> getAccountFor(String email) throws IOException, OrmException {
+  public ImmutableSet<Account.Id> getAccountFor(String email) throws IOException {
     return Streams.concat(
             externalIds.byEmail(email).stream().map(ExternalId::accountId),
             executeIndexQuery(() -> queryProvider.get().byPreferredEmail(email).stream())
-                .map(a -> a.getAccount().getId()))
+                .map(a -> a.getAccount().id()))
         .collect(toImmutableSet());
   }
 
@@ -83,25 +83,32 @@
    * @see #getAccountFor(String)
    */
   public ImmutableSetMultimap<String, Account.Id> getAccountsFor(String... emails)
-      throws IOException, OrmException {
+      throws IOException {
     ImmutableSetMultimap.Builder<String, Account.Id> builder = ImmutableSetMultimap.builder();
-    externalIds
-        .byEmails(emails)
-        .entries()
-        .stream()
+    externalIds.byEmails(emails).entries().stream()
         .forEach(e -> builder.put(e.getKey(), e.getValue().accountId()));
     executeIndexQuery(() -> queryProvider.get().byPreferredEmail(emails).entries().stream())
-        .forEach(e -> builder.put(e.getKey(), e.getValue().getAccount().getId()));
+        .forEach(e -> builder.put(e.getKey(), e.getValue().getAccount().id()));
     return builder.build();
   }
 
-  private <T> T executeIndexQuery(Action<T> action) throws OrmException {
+  /**
+   * Returns the accounts with the given email.
+   *
+   * <p>This method behaves just like {@link #getAccountFor(String)}, except that accounts are not
+   * looked up by their preferred email. Thus, this method does not rely on the accounts index.
+   */
+  public ImmutableSet<Account.Id> getAccountForExternal(String email) throws IOException {
+    return externalIds.byEmail(email).stream().map(ExternalId::accountId).collect(toImmutableSet());
+  }
+
+  private <T> T executeIndexQuery(Action<T> action) {
     try {
-      return retryHelper.execute(ActionType.INDEX_QUERY, action, OrmException.class::isInstance);
+      return retryHelper.execute(
+          ActionType.INDEX_QUERY, action, StorageException.class::isInstance);
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
-      Throwables.throwIfInstanceOf(e, OrmException.class);
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/GroupBackends.java b/java/com/google/gerrit/server/account/GroupBackends.java
index 803d491..1b15512 100644
--- a/java/com/google/gerrit/server/account/GroupBackends.java
+++ b/java/com/google/gerrit/server/account/GroupBackends.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import static java.util.Comparator.comparing;
+
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
@@ -25,12 +27,7 @@
 public class GroupBackends {
 
   public static final Comparator<GroupReference> GROUP_REF_NAME_COMPARATOR =
-      new Comparator<GroupReference>() {
-        @Override
-        public int compare(GroupReference a, GroupReference b) {
-          return a.getName().compareTo(b.getName());
-        }
-      };
+      comparing(GroupReference::getName);
 
   /**
    * Runs {@link GroupBackend#suggest(String, ProjectState)} and filters the result to return the
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index e7aae15..4618835 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -21,6 +21,9 @@
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.query.group.InternalGroupQuery;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -116,6 +119,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 +127,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 +135,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,7 +150,11 @@
 
     @Override
     public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
-      return groupQueryProvider.get().byId(key);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading group by ID", Metadata.builder().groupId(key.get()).build())) {
+        return groupQueryProvider.get().byId(key);
+      }
     }
   }
 
@@ -158,7 +168,11 @@
 
     @Override
     public Optional<InternalGroup> load(String name) throws Exception {
-      return groupQueryProvider.get().byName(new AccountGroup.NameKey(name));
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading group by name", Metadata.builder().groupName(name).build())) {
+        return groupQueryProvider.get().byName(AccountGroup.nameKey(name));
+      }
     }
   }
 
@@ -172,7 +186,11 @@
 
     @Override
     public Optional<InternalGroup> load(String uuid) throws Exception {
-      return groups.getGroup(new AccountGroup.UUID(uuid));
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading group by UUID", Metadata.builder().groupUuid(uuid).build())) {
+        return groups.getGroup(AccountGroup.uuid(uuid));
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index 5649629..b3e6739 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index f262a79..f3c19a8 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -27,8 +27,10 @@
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.query.group.InternalGroupQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
@@ -112,6 +114,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 +122,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);
       }
     }
@@ -147,13 +152,14 @@
     }
 
     @Override
-    public ImmutableSet<AccountGroup.UUID> load(Account.Id memberId) throws OrmException {
-      return groupQueryProvider
-          .get()
-          .byMember(memberId)
-          .stream()
-          .map(InternalGroup::getGroupUUID)
-          .collect(toImmutableSet());
+    public ImmutableSet<AccountGroup.UUID> load(Account.Id memberId) {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading groups with member", Metadata.builder().accountId(memberId.get()).build())) {
+        return groupQueryProvider.get().byMember(memberId).stream()
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableSet());
+      }
     }
   }
 
@@ -167,13 +173,14 @@
     }
 
     @Override
-    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
-      return groupQueryProvider
-          .get()
-          .bySubgroup(key)
-          .stream()
-          .map(InternalGroup::getGroupUUID)
-          .collect(toImmutableList());
+    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading parent groups", Metadata.builder().groupUuid(key.get()).build())) {
+        return groupQueryProvider.get().bySubgroup(key).stream()
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableList());
+      }
     }
   }
 
@@ -187,7 +194,9 @@
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
-      return groups.getExternalGroups().collect(toImmutableList());
+      try (TraceTimer timer = TraceContext.newTimer("Loading all external groups")) {
+        return groups.getExternalGroups().collect(toImmutableList());
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/GroupMembers.java b/java/com/google/gerrit/server/account/GroupMembers.java
index faa1621..d7e97ba 100644
--- a/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/java/com/google/gerrit/server/account/GroupMembers.java
@@ -67,7 +67,7 @@
       throw new IllegalStateException("listAccounts called with PROJECT_OWNERS argument");
     }
     try {
-      return listAccounts(groupUUID, null, new HashSet<AccountGroup.UUID>());
+      return listAccounts(groupUUID, null, new HashSet<>());
     } catch (NoSuchProjectException e) {
       throw new IllegalStateException(e);
     }
@@ -81,7 +81,7 @@
    */
   public Set<Account> listAccounts(AccountGroup.UUID groupUUID, Project.NameKey project)
       throws NoSuchProjectException, IOException {
-    return listAccounts(groupUUID, project, new HashSet<AccountGroup.UUID>());
+    return listAccounts(groupUUID, project, new HashSet<>());
   }
 
   private Set<Account> listAccounts(
@@ -127,9 +127,7 @@
     GroupControl groupControl = groupControlFactory.controlFor(new InternalGroupDescription(group));
 
     Set<Account> directMembers =
-        group
-            .getMembers()
-            .stream()
+        group.getMembers().stream()
             .filter(groupControl::canSeeMember)
             .map(accountCache::get)
             .flatMap(Streams::stream)
diff --git a/java/com/google/gerrit/server/account/GroupMembership.java b/java/com/google/gerrit/server/account/GroupMembership.java
index cc73222..b59b989 100644
--- a/java/com/google/gerrit/server/account/GroupMembership.java
+++ b/java/com/google/gerrit/server/account/GroupMembership.java
@@ -24,7 +24,7 @@
  * <p>Different accounts systems (eg. LDAP, gerrit groups) provide concrete implementations.
  */
 public interface GroupMembership {
-  GroupMembership EMPTY = new ListGroupMembership(Collections.<AccountGroup.UUID>emptySet());
+  GroupMembership EMPTY = new ListGroupMembership(Collections.emptySet());
 
   /**
    * Returns {@code true} when the user this object was created for is a member of the specified
diff --git a/java/com/google/gerrit/server/account/GroupUUID.java b/java/com/google/gerrit/server/account/GroupUUID.java
index a7b32a1..5bb9d57 100644
--- a/java/com/google/gerrit/server/account/GroupUUID.java
+++ b/java/com/google/gerrit/server/account/GroupUUID.java
@@ -26,7 +26,7 @@
     md.update(Constants.encode("group " + groupName + "\n"));
     md.update(Constants.encode("creator " + creator.toExternalString() + "\n"));
     md.update(Constants.encode(String.valueOf(Math.random())));
-    return new AccountGroup.UUID(ObjectId.fromRaw(md.digest()).name());
+    return AccountGroup.uuid(ObjectId.fromRaw(md.digest()).name());
   }
 
   private GroupUUID() {}
diff --git a/java/com/google/gerrit/server/account/HashedPassword.java b/java/com/google/gerrit/server/account/HashedPassword.java
index 427c8c3..bffa3ce 100644
--- a/java/com/google/gerrit/server/account/HashedPassword.java
+++ b/java/com/google/gerrit/server/account/HashedPassword.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Preconditions;
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.base.Splitter;
 import com.google.common.io.BaseEncoding;
 import com.google.common.primitives.Ints;
@@ -96,10 +97,10 @@
     this.hashed = hashed;
     this.cost = cost;
 
-    Preconditions.checkState(cost >= 4 && cost < 32);
+    checkState(cost >= 4 && cost < 32);
 
     // salt must be 128 bit.
-    Preconditions.checkState(salt.length == 16);
+    checkState(salt.length == 16);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 8c2bc10..8062eaf 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -18,16 +18,23 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -35,6 +42,7 @@
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 @Singleton
@@ -51,31 +59,60 @@
   private final AccountCache accountCache;
   private final DynamicItem<AvatarProvider> avatar;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   InternalAccountDirectory(
       AccountCache accountCache,
       DynamicItem<AvatarProvider> avatar,
-      IdentifiedUser.GenericFactory userFactory) {
+      IdentifiedUser.GenericFactory userFactory,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
     this.accountCache = accountCache;
     this.avatar = avatar;
     this.userFactory = userFactory;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
   public void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
-      throws DirectoryException {
+      throws PermissionBackendException {
     if (options.equals(ID_ONLY)) {
       return;
     }
-    Set<Account.Id> ids =
-        Streams.stream(in).map(a -> new Account.Id(a._accountId)).collect(toSet());
+
+    boolean canModifyAccount = false;
+    Account.Id currentUserId = null;
+    if (self.get().isIdentifiedUser()) {
+      currentUserId = self.get().getAccountId();
+
+      try {
+        permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+        canModifyAccount = true;
+      } catch (AuthException e) {
+        canModifyAccount = false;
+      }
+    }
+
+    Set<FillOptions> fillOptionsWithoutSecondaryEmails =
+        Sets.difference(options, EnumSet.of(FillOptions.SECONDARY_EMAILS));
+    Set<Account.Id> ids = Streams.stream(in).map(a -> Account.id(a._accountId)).collect(toSet());
     Map<Account.Id, AccountState> accountStates = accountCache.get(ids);
     for (AccountInfo info : in) {
-      Account.Id id = new Account.Id(info._accountId);
+      Account.Id id = Account.id(info._accountId);
       AccountState state = accountStates.get(id);
       if (state != null) {
-        fill(info, accountStates.get(id), options);
+        if (!options.contains(FillOptions.SECONDARY_EMAILS)
+            || Objects.equals(currentUserId, state.getAccount().id())
+            || canModifyAccount) {
+          fill(info, accountStates.get(id), options);
+        } else {
+          // user is not allowed to see secondary emails
+          fill(info, accountStates.get(id), fillOptionsWithoutSecondaryEmails);
+        }
+
       } else {
         info._accountId = options.contains(FillOptions.ID) ? id.get() : null;
       }
@@ -85,19 +122,19 @@
   private void fill(AccountInfo info, AccountState accountState, Set<FillOptions> options) {
     Account account = accountState.getAccount();
     if (options.contains(FillOptions.ID)) {
-      info._accountId = account.getId().get();
+      info._accountId = account.id().get();
     } else {
       // Was previously set to look up account for filling.
       info._accountId = null;
     }
     if (options.contains(FillOptions.NAME)) {
-      info.name = Strings.emptyToNull(account.getFullName());
+      info.name = Strings.emptyToNull(account.fullName());
       if (info.name == null) {
         info.name = accountState.getUserName().orElse(null);
       }
     }
     if (options.contains(FillOptions.EMAIL)) {
-      info.email = account.getPreferredEmail();
+      info.email = account.preferredEmail();
     }
     if (options.contains(FillOptions.SECONDARY_EMAILS)) {
       info.secondaryEmails = getSecondaryEmails(account, accountState.getExternalIds());
@@ -107,26 +144,25 @@
     }
 
     if (options.contains(FillOptions.STATUS)) {
-      info.status = account.getStatus();
+      info.status = account.status();
     }
 
     if (options.contains(FillOptions.AVATARS)) {
       AvatarProvider ap = avatar.get();
       if (ap != null) {
-        info.avatars = new ArrayList<>(3);
-        IdentifiedUser user = userFactory.create(account.getId());
+        info.avatars = new ArrayList<>();
+        IdentifiedUser user = userFactory.create(account.id());
 
-        // GWT UI uses DEFAULT_SIZE (26px).
+        // PolyGerrit UI uses the following sizes for avatars:
+        // - 32px for avatars next to names e.g. on the dashboard. This is also Gerrit's default.
+        // - 56px for the user's own avatar in the menu
+        // - 100ox for other user's avatars on dashboards
+        // - 120px for the user's own profile settings page
         addAvatar(ap, info, user, AvatarInfo.DEFAULT_SIZE);
-
-        // PolyGerrit UI prefers 32px and 100px.
         if (!info.avatars.isEmpty()) {
-          if (32 != AvatarInfo.DEFAULT_SIZE) {
-            addAvatar(ap, info, user, 32);
-          }
-          if (100 != AvatarInfo.DEFAULT_SIZE) {
-            addAvatar(ap, info, user, 100);
-          }
+          addAvatar(ap, info, user, 56);
+          addAvatar(ap, info, user, 100);
+          addAvatar(ap, info, user, 120);
         }
       }
     }
@@ -134,7 +170,7 @@
 
   public List<String> getSecondaryEmails(Account account, Collection<ExternalId> externalIds) {
     return ExternalId.getEmails(externalIds)
-        .filter(e -> !e.equals(account.getPreferredEmail()))
+        .filter(e -> !e.equals(account.preferredEmail()))
         .sorted()
         .collect(toList());
   }
diff --git a/java/com/google/gerrit/server/account/Preferences.java b/java/com/google/gerrit/server/account/Preferences.java
index aa09675..f33d8fe 100644
--- a/java/com/google/gerrit/server/account/Preferences.java
+++ b/java/com/google/gerrit/server/account/Preferences.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 import static com.google.gerrit.server.config.ConfigUtil.skipField;
@@ -27,6 +26,7 @@
 import static com.google.gerrit.server.git.UserConfigSections.KEY_TOKEN;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
 import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
@@ -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;
@@ -49,7 +50,6 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -103,10 +103,10 @@
       Config cfg,
       Config defaultCfg,
       ValidationError.Sink validationErrorSink) {
-    this.accountId = checkNotNull(accountId, "accountId");
-    this.cfg = checkNotNull(cfg, "cfg");
-    this.defaultCfg = checkNotNull(defaultCfg, "defaultCfg");
-    this.validationErrorSink = checkNotNull(validationErrorSink, "validationErrorSink");
+    this.accountId = requireNonNull(accountId, "accountId");
+    this.cfg = requireNonNull(cfg, "cfg");
+    this.defaultCfg = requireNonNull(defaultCfg, "defaultCfg");
+    this.validationErrorSink = requireNonNull(validationErrorSink, "validationErrorSink");
   }
 
   public GeneralPreferencesInfo getGeneralPreferences() {
@@ -414,25 +414,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();
   }
 
@@ -570,7 +573,7 @@
       }
 
       int i = 1;
-      for (Entry<String, String> e : urlAliases.entrySet()) {
+      for (Map.Entry<String, String> e : urlAliases.entrySet()) {
         cfg.setString(URL_ALIAS, URL_ALIAS + i, KEY_MATCH, e.getKey());
         cfg.setString(URL_ALIAS, URL_ALIAS + i, KEY_TOKEN, e.getValue());
         i++;
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index edecb18..d052fcf 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -112,9 +112,9 @@
   private ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches;
 
   ProjectWatches(Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
-    this.accountId = checkNotNull(accountId, "accountId");
-    this.cfg = checkNotNull(cfg, "cfg");
-    this.validationErrorSink = checkNotNull(validationErrorSink, "validationErrorSink");
+    this.accountId = requireNonNull(accountId, "accountId");
+    this.cfg = requireNonNull(cfg, "cfg");
+    this.validationErrorSink = requireNonNull(validationErrorSink, "validationErrorSink");
   }
 
   public ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> getProjectWatches() {
@@ -168,7 +168,7 @@
         }
 
         ProjectWatchKey key =
-            ProjectWatchKey.create(new Project.NameKey(projectName), notifyValue.filter());
+            ProjectWatchKey.create(Project.nameKey(projectName), notifyValue.filter());
         if (!projectWatches.containsKey(key)) {
           projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
         }
@@ -202,9 +202,7 @@
   private static ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> immutableCopyOf(
       Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
     ImmutableMap.Builder<ProjectWatchKey, ImmutableSet<NotifyType>> b = ImmutableMap.builder();
-    projectWatches
-        .entrySet()
-        .stream()
+    projectWatches.entrySet().stream()
         .forEach(e -> b.put(e.getKey(), ImmutableSet.copyOf(e.getValue())));
     return b.build();
   }
@@ -265,7 +263,7 @@
     public abstract ImmutableSet<NotifyType> notifyTypes();
 
     @Override
-    public String toString() {
+    public final String toString() {
       List<NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
       StringBuilder notifyValue = new StringBuilder();
       notifyValue.append(firstNonNull(filter(), FILTER_ALL)).append(" [");
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index af68d05..da2d640 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -14,16 +14,15 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -35,20 +34,20 @@
 
 @Singleton
 public class SetInactiveFlag {
-  private final DynamicSet<AccountActivationValidationListener>
+  private final PluginSetContext<AccountActivationValidationListener>
       accountActivationValidationListeners;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
 
   @Inject
   SetInactiveFlag(
-      DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
+      PluginSetContext<AccountActivationValidationListener> accountActivationValidationListeners,
       @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
     this.accountActivationValidationListeners = accountActivationValidationListeners;
     this.accountsUpdateProvider = accountsUpdateProvider;
   }
 
   public Response<?> deactivate(Account.Id accountId)
-      throws RestApiException, IOException, ConfigInvalidException, OrmException {
+      throws RestApiException, IOException, ConfigInvalidException {
     AtomicBoolean alreadyInactive = new AtomicBoolean(false);
     AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
     accountsUpdateProvider
@@ -60,13 +59,12 @@
               if (!a.getAccount().isActive()) {
                 alreadyInactive.set(true);
               } else {
-                for (AccountActivationValidationListener l : accountActivationValidationListeners) {
-                  try {
-                    l.validateDeactivation(a);
-                  } catch (ValidationException e) {
-                    exception.set(Optional.of(new ResourceConflictException(e.getMessage(), e)));
-                    return;
-                  }
+                try {
+                  accountActivationValidationListeners.runEach(
+                      l -> l.validateDeactivation(a), ValidationException.class);
+                } catch (ValidationException e) {
+                  exception.set(Optional.of(new ResourceConflictException(e.getMessage(), e)));
+                  return;
                 }
                 u.setActive(false);
               }
@@ -82,7 +80,7 @@
   }
 
   public Response<String> activate(Account.Id accountId)
-      throws RestApiException, IOException, ConfigInvalidException, OrmException {
+      throws RestApiException, IOException, ConfigInvalidException {
     AtomicBoolean alreadyActive = new AtomicBoolean(false);
     AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
     accountsUpdateProvider
@@ -94,13 +92,12 @@
               if (a.getAccount().isActive()) {
                 alreadyActive.set(true);
               } else {
-                for (AccountActivationValidationListener l : accountActivationValidationListeners) {
-                  try {
-                    l.validateActivation(a);
-                  } catch (ValidationException e) {
-                    exception.set(Optional.of(new ResourceConflictException(e.getMessage(), e)));
-                    return;
-                  }
+                try {
+                  accountActivationValidationListeners.runEach(
+                      l -> l.validateActivation(a), ValidationException.class);
+                } catch (ValidationException e) {
+                  exception.set(Optional.of(new ResourceConflictException(e.getMessage(), e)));
+                  return;
                 }
                 u.setActive(true);
               }
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 22e9dbd..da091e0 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -26,12 +26,13 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -48,19 +49,19 @@
 public class UniversalGroupBackend implements GroupBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<GroupBackend> backends;
+  private final PluginSetContext<GroupBackend> backends;
 
   @Inject
-  UniversalGroupBackend(DynamicSet<GroupBackend> backends) {
+  UniversalGroupBackend(PluginSetContext<GroupBackend> backends) {
     this.backends = backends;
   }
 
   @Nullable
   private GroupBackend backend(AccountGroup.UUID uuid) {
     if (uuid != null) {
-      for (GroupBackend g : backends) {
-        if (g.handles(uuid)) {
-          return g;
+      for (PluginSetEntryContext<GroupBackend> c : backends) {
+        if (c.call(b -> b.handles(uuid))) {
+          return c.get();
         }
       }
     }
@@ -88,9 +89,7 @@
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     Set<GroupReference> groups = Sets.newTreeSet(GROUP_REF_NAME_COMPARATOR);
-    for (GroupBackend g : backends) {
-      groups.addAll(g.suggest(name, project));
-    }
+    backends.runEach(g -> groups.addAll(g.suggest(name, project)));
     return groups;
   }
 
@@ -104,9 +103,7 @@
 
     private UniversalGroupMembership(IdentifiedUser user) {
       ImmutableMap.Builder<GroupBackend, GroupMembership> builder = ImmutableMap.builder();
-      for (GroupBackend g : backends) {
-        builder.put(g, g.membershipsOf(user));
-      }
+      backends.runEach(g -> builder.put(g, g.membershipsOf(user)));
       this.memberships = builder.build();
     }
 
@@ -200,9 +197,9 @@
 
   @Override
   public boolean isVisibleToAll(AccountGroup.UUID uuid) {
-    for (GroupBackend g : backends) {
-      if (g.handles(uuid)) {
-        return g.isVisibleToAll(uuid);
+    for (PluginSetEntryContext<GroupBackend> c : backends) {
+      if (c.call(b -> b.handles(uuid))) {
+        return c.call(b -> b.isVisibleToAll(uuid));
       }
     }
     return false;
@@ -221,11 +218,10 @@
     @Override
     public void check() throws StartupException {
       String invalid =
-          cfg.getSubsections("groups")
-              .stream()
+          cfg.getSubsections("groups").stream()
               .filter(
                   sub -> {
-                    AccountGroup.UUID uuid = new AccountGroup.UUID(sub);
+                    AccountGroup.UUID uuid = AccountGroup.uuid(sub);
                     GroupBackend groupBackend = universalGroupBackend.backend(uuid);
                     return groupBackend == null || groupBackend.get(uuid) == null;
                   })
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 04a3a95..c7808de 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -20,7 +20,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Ordering;
-import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.exceptions.InvalidSshKeyException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
@@ -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..611b44d 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -14,37 +14,93 @@
 
 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.proto.Protos;
+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.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(ExternalId::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(ExternalId::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 Protos.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(
+          Protos.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()),
+          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/DuplicateExternalIdKeyException.java b/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java
index b4c82d0..aa09278 100644
--- a/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java
+++ b/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gerrit.exceptions.DuplicateKeyException;
 
 /**
  * Exception that is thrown if an external ID cannot be inserted because an external ID with the
  * same key already exists.
  */
-public class DuplicateExternalIdKeyException extends OrmDuplicateKeyException {
+public class DuplicateExternalIdKeyException extends DuplicateKeyException {
   private static final long serialVersionUID = 1L;
 
   private final ExternalId.Key duplicateKey;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index db8ea41..8ae20f1 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -27,6 +27,7 @@
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.HashedPassword;
 import java.io.Serializable;
@@ -38,7 +39,6 @@
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
@@ -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);
 
@@ -86,8 +82,7 @@
    *     ExternalId#SCHEME_USERNAME} scheme
    */
   public static Optional<String> getUserName(Collection<ExternalId> extIds) {
-    return extIds
-        .stream()
+    return extIds.stream()
         .filter(e -> e.isScheme(SCHEME_USERNAME))
         .map(e -> e.key().id())
         .filter(u -> !Strings.isNullOrEmpty(u))
@@ -198,7 +193,7 @@
     }
 
     @Override
-    public String toString() {
+    public final String toString() {
       return get();
     }
 
@@ -288,7 +283,7 @@
   }
 
   public static ExternalId createEmail(Account.Id accountId, String email) {
-    return createWithEmail(SCHEME_MAILTO, email, accountId, checkNotNull(email));
+    return createWithEmail(SCHEME_MAILTO, email, accountId, requireNonNull(email));
   }
 
   static ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
@@ -335,7 +330,7 @@
 
   public static ExternalId parse(String noteId, Config externalIdConfig, ObjectId blobId)
       throws ConfigInvalidException {
-    checkNotNull(blobId);
+    requireNonNull(blobId);
 
     Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
     if (externalIdKeys.size() != 1) {
@@ -366,7 +361,7 @@
 
     return create(
         externalIdKey,
-        new Account.Id(accountId),
+        Account.id(accountId),
         Strings.emptyToNull(email),
         Strings.emptyToNull(password),
         blobId);
@@ -433,10 +428,10 @@
 
   public byte[] toByteArray() {
     checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
-    byte[] b = new byte[2 * Constants.OBJECT_ID_STRING_LENGTH + 1];
+    byte[] b = new byte[2 * ObjectIds.STR_LEN + 1];
     key().sha1().copyTo(b, 0);
-    b[Constants.OBJECT_ID_STRING_LENGTH] = ':';
-    blobId().copyTo(b, Constants.OBJECT_ID_STRING_LENGTH + 1);
+    b[ObjectIds.STR_LEN] = ':';
+    blobId().copyTo(b, ObjectIds.STR_LEN + 1);
     return b;
   }
 
@@ -446,7 +441,7 @@
    * that was loaded from Git can be equal with an external ID that was created from code.
    */
   @Override
-  public boolean equals(Object obj) {
+  public final boolean equals(Object obj) {
     if (!(obj instanceof ExternalId)) {
       return false;
     }
@@ -458,7 +453,7 @@
   }
 
   @Override
-  public int hashCode() {
+  public final int hashCode() {
     return Objects.hash(key(), accountId(), email(), password());
   }
 
@@ -476,7 +471,7 @@
    * </pre>
    */
   @Override
-  public String toString() {
+  public final String toString() {
     Config c = new Config();
     writeToConfig(c);
     return c.toText();
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..35c4c98 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 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 +57,7 @@
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
       Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd)
-      throws IOException {
+      Collection<ExternalId> toAdd) {
     updateCache(
         oldNotesRev,
         newNotesRev,
@@ -121,17 +117,22 @@
   private void updateCache(
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
-      Consumer<Multimap<Account.Id, ExternalId>> update) {
+      Consumer<SetMultimap<Account.Id, ExternalId>> update) {
+    if (oldNotesRev.equals(newNotesRev)) {
+      // No need to update external id cache since there is no update to those external ids.
+      return;
+    }
+
     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));
@@ -141,24 +142,4 @@
       lock.unlock();
     }
   }
-
-  static class Loader extends CacheLoader<ObjectId, AllExternalIds> {
-    private final ExternalIdReader externalIdReader;
-
-    @Inject
-    Loader(ExternalIdReader externalIdReader) {
-      this.externalIdReader = externalIdReader;
-    }
-
-    @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);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
new file mode 100644
index 0000000..68841da
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
@@ -0,0 +1,271 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 com.google.common.base.CharMatcher;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheLoader;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+
+/** Loads cache values for the external ID cache using either a full or a partial reload. */
+@Singleton
+public class ExternalIdCacheLoader extends CacheLoader<ObjectId, AllExternalIds> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  // Maximum number of prior states we inspect to find a base for differential. If no cached state
+  // is found within this number of parents, we fall back to reading everything from scratch.
+  private static final int MAX_HISTORY_LOOKBACK = 10;
+
+  private final ExternalIdReader externalIdReader;
+  private final Provider<Cache<ObjectId, AllExternalIds>> externalIdCache;
+  private final GitRepositoryManager gitRepositoryManager;
+  private final AllUsersName allUsersName;
+  private final Counter1<Boolean> reloadCounter;
+  private final Timer0 reloadDifferential;
+  private final boolean enablePartialReloads;
+
+  @Inject
+  ExternalIdCacheLoader(
+      GitRepositoryManager gitRepositoryManager,
+      AllUsersName allUsersName,
+      ExternalIdReader externalIdReader,
+      @Named(ExternalIdCacheImpl.CACHE_NAME)
+          Provider<Cache<ObjectId, AllExternalIds>> externalIdCache,
+      MetricMaker metricMaker,
+      @GerritServerConfig Config config) {
+    this.externalIdReader = externalIdReader;
+    this.externalIdCache = externalIdCache;
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.allUsersName = allUsersName;
+    this.reloadCounter =
+        metricMaker.newCounter(
+            "notedb/external_id_cache_load_count",
+            new Description("Total number of external ID cache reloads from Git.")
+                .setRate()
+                .setUnit("updates"),
+            Field.ofBoolean("partial", Metadata.Builder::partial).build());
+    this.reloadDifferential =
+        metricMaker.newTimer(
+            "notedb/external_id_partial_read_latency",
+            new Description(
+                    "Latency for generating a new external ID cache state from a prior state.")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS));
+    this.enablePartialReloads =
+        config.getBoolean("cache", ExternalIdCacheImpl.CACHE_NAME, "enablePartialReloads", false);
+  }
+
+  @Override
+  public AllExternalIds load(ObjectId notesRev) throws IOException, ConfigInvalidException {
+    if (!enablePartialReloads) {
+      logger.atInfo().log(
+          "Partial reloads of "
+              + ExternalIdCacheImpl.CACHE_NAME
+              + " disabled. Falling back to full reload.");
+      return reloadAllExternalIds(notesRev);
+    }
+
+    // The requested value was not in the cache (hence, this loader was invoked). Therefore, try to
+    // create this entry from a past value using the minimal amount of Git operations possible to
+    // reduce latency.
+    //
+    // First, try to find the most recent state we have in the cache. Most of the time, this will be
+    // the state before the last update happened, but it can also date further back. We try a best
+    // effort approach and check the last 10 states. If nothing is found, we default to loading the
+    // value from scratch.
+    //
+    // If a prior state was found, we use Git to diff the trees and find modifications. This is
+    // faster than just loading the complete current tree and working off of that because of how the
+    // data is structured: NotesMaps use nested trees, so, for example, a NotesMap with 200k entries
+    // has two layers of nesting: 12/34/1234..99. TreeWalk is smart in skipping the traversal of
+    // identical subtrees.
+    //
+    // Once we know what files changed, we apply additions and removals to the previously cached
+    // state.
+
+    try (Repository repo = gitRepositoryManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      long start = System.nanoTime();
+      Ref extIdRef = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+      if (extIdRef == null) {
+        logger.atInfo().log(
+            RefNames.REFS_EXTERNAL_IDS + " not initialized, falling back to full reload.");
+        return reloadAllExternalIds(notesRev);
+      }
+
+      RevCommit currentCommit = rw.parseCommit(extIdRef.getObjectId());
+      rw.markStart(currentCommit);
+      RevCommit parentWithCacheValue;
+      AllExternalIds oldExternalIds = null;
+      int i = 0;
+      while ((parentWithCacheValue = rw.next()) != null
+          && i++ < MAX_HISTORY_LOOKBACK
+          && parentWithCacheValue.getParentCount() < 2) {
+        oldExternalIds = externalIdCache.get().getIfPresent(parentWithCacheValue.getId());
+        if (oldExternalIds != null) {
+          // We found a previously cached state.
+          break;
+        }
+      }
+      if (oldExternalIds == null) {
+        logger.atWarning().log(
+            "Unable to find an old ExternalId cache state, falling back to full reload");
+        return reloadAllExternalIds(notesRev);
+      }
+
+      // Diff trees to recognize modifications
+      Set<ObjectId> removals = new HashSet<>(); // Set<Blob-Object-Id>
+      Map<ObjectId, ObjectId> additions = new HashMap<>(); // Map<Name-ObjectId, Blob-Object-Id>
+      try (TreeWalk treeWalk = new TreeWalk(repo)) {
+        treeWalk.setFilter(TreeFilter.ANY_DIFF);
+        treeWalk.setRecursive(true);
+        treeWalk.reset(parentWithCacheValue.getTree(), currentCommit.getTree());
+        while (treeWalk.next()) {
+          String path = treeWalk.getPathString();
+          ObjectId oldBlob = treeWalk.getObjectId(0);
+          ObjectId newBlob = treeWalk.getObjectId(1);
+          if (ObjectId.zeroId().equals(newBlob)) {
+            // Deletion
+            removals.add(oldBlob);
+          } else if (ObjectId.zeroId().equals(oldBlob)) {
+            // Addition
+            additions.put(fileNameToObjectId(path), newBlob);
+          } else {
+            // Modification
+            removals.add(oldBlob);
+            additions.put(fileNameToObjectId(path), newBlob);
+          }
+        }
+      }
+
+      AllExternalIds allExternalIds =
+          buildAllExternalIds(repo, oldExternalIds, additions, removals);
+      reloadCounter.increment(true);
+      reloadDifferential.record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
+      return allExternalIds;
+    }
+  }
+
+  private static ObjectId fileNameToObjectId(String path) {
+    return ObjectId.fromString(CharMatcher.is('/').removeFrom(path));
+  }
+
+  /**
+   * Build a new {@link AllExternalIds} from an old state by applying additions and removals that
+   * were performed since then.
+   *
+   * <p>Removals are applied before additions.
+   *
+   * @param repo open repository
+   * @param oldExternalIds prior state that is used as base
+   * @param additions map of name to blob ID for each external ID that should be added
+   * @param removals set of name {@link ObjectId}s that should be removed
+   */
+  private static AllExternalIds buildAllExternalIds(
+      Repository repo,
+      AllExternalIds oldExternalIds,
+      Map<ObjectId, ObjectId> additions,
+      Set<ObjectId> removals)
+      throws IOException {
+    ImmutableSetMultimap.Builder<Account.Id, ExternalId> byAccount = ImmutableSetMultimap.builder();
+    ImmutableSetMultimap.Builder<String, ExternalId> byEmail = ImmutableSetMultimap.builder();
+
+    // Copy over old ExternalIds but exclude deleted ones
+    for (ExternalId externalId : oldExternalIds.byAccount().values()) {
+      if (removals.contains(externalId.blobId())) {
+        continue;
+      }
+
+      byAccount.put(externalId.accountId(), externalId);
+      if (externalId.email() != null) {
+        byEmail.put(externalId.email(), externalId);
+      }
+    }
+
+    // Add newly discovered ExternalIds
+    try (ObjectReader reader = repo.newObjectReader()) {
+      for (Map.Entry<ObjectId, ObjectId> nameToBlob : additions.entrySet()) {
+        ExternalId parsedExternalId;
+        try {
+          parsedExternalId =
+              ExternalId.parse(
+                  nameToBlob.getKey().name(),
+                  reader.open(nameToBlob.getValue()).getCachedBytes(),
+                  nameToBlob.getValue());
+        } catch (ConfigInvalidException | RuntimeException e) {
+          logger.atSevere().withCause(e).log(
+              "Ignoring invalid external ID note %s", nameToBlob.getKey().name());
+          continue;
+        }
+
+        byAccount.put(parsedExternalId.accountId(), parsedExternalId);
+        if (parsedExternalId.email() != null) {
+          byEmail.put(parsedExternalId.email(), parsedExternalId);
+        }
+      }
+    }
+    return new AutoValue_AllExternalIds(byAccount.build(), byEmail.build());
+  }
+
+  private AllExternalIds reloadAllExternalIds(ObjectId notesRev)
+      throws IOException, ConfigInvalidException {
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Loading external IDs from scratch",
+            Metadata.builder().revision(notesRev.name()).build())) {
+      ImmutableSet<ExternalId> externalIds = externalIdReader.all(notesRev);
+      externalIds.forEach(ExternalId::checkThatBlobIdIsSet);
+      AllExternalIds allExternalIds = AllExternalIds.create(externalIds);
+      reloadCounter.increment(false);
+      return allExternalIds;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
index 228b1e6..3e5d7b8 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account.externalids;
 
-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,16 +23,23 @@
 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
         // from the cache. Extend the cache size by 1 to cover this case, but expire the extra
         // object after a short period of time, since it may be a potentially large amount of
         // memory.
+        // When loading a new value because the primary data advanced, we want to leverage the old
+        // cache state to recompute only what changed. This doesn't affect cache size though as
+        // Guava calls the loader first and evicts later on.
         .maximumWeight(2)
         .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
-        .loader(Loader.class);
+        .loader(ExternalIdCacheLoader.class)
+        .diskLimit(-1)
+        .version(1)
+        .keySerializer(ObjectIdCacheSerializer.INSTANCE)
+        .valueSerializer(AllExternalIds.Serializer.INSTANCE);
 
     bind(ExternalIdCacheImpl.class);
     bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 8057dd8..2c72f56 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -28,6 +28,7 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
@@ -35,9 +36,13 @@
 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.account.AccountsUpdate;
+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;
+import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -117,24 +122,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 +155,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 +169,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 +202,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 +225,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,18 +249,26 @@
    *
    * @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;
+  private final CallerFinder callerFinder;
 
   private NoteMap noteMap;
   private ObjectId oldRev;
@@ -243,15 +287,30 @@
       @Nullable AccountCache accountCache,
       @Nullable Provider<AccountIndexer> accountIndexer,
       MetricMaker metricMaker,
+      AllUsersName allUsersName,
       Repository allUsersRepo) {
-    this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
+    this.externalIdCache = requireNonNull(externalIdCache, "externalIdCache");
     this.accountCache = accountCache;
     this.accountIndexer = accountIndexer;
     this.updateCount =
         metricMaker.newCounter(
             "notedb/external_id_update_count",
             new Description("Total number of external ID updates.").setRate().setUnit("updates"));
-    this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
+    this.allUsersName = requireNonNull(allUsersName, "allUsersRepo");
+    this.repo = requireNonNull(allUsersRepo, "allUsersRepo");
+    this.callerFinder =
+        CallerFinder.builder()
+            // 1. callers that come through ExternalIds
+            .addTarget(ExternalIds.class)
+
+            // 2. callers that come through AccountsUpdate
+            .addTarget(AccountsUpdate.class)
+            .addIgnoredPackage("com.github.rholder.retry")
+            .addIgnoredClass(RetryHelper.class)
+
+            // 3. direct callers
+            .addTarget(ExternalIdNotes.class)
+            .build();
   }
 
   public ExternalIdNotes setAfterReadRevision(Runnable afterReadRevision) {
@@ -279,7 +338,7 @@
    * @return {@link ExternalIdNotes} instance for chaining
    */
   private ExternalIdNotes load() throws IOException, ConfigInvalidException {
-    load(repo);
+    load(allUsersName, repo);
     return this;
   }
 
@@ -298,10 +357,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,7 +674,12 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
-    noteMap = revision != null ? NoteMap.read(reader, revision) : NoteMap.newEmptyMap();
+    if (revision != null) {
+      logger.atFine().log("Reading external ID note map (caller: %s)", callerFinder.findCaller());
+      noteMap = NoteMap.read(reader, revision);
+    } else {
+      noteMap = NoteMap.newEmptyMap();
+    }
 
     if (afterReadRevision != null) {
       afterReadRevision.run();
@@ -624,7 +688,7 @@
 
   @Override
   public RevCommit commit(MetaDataUpdate update) throws IOException {
-    oldRev = revision != null ? revision.copy() : ObjectId.zeroId();
+    oldRev = ObjectIds.copyOrZero(revision);
     RevCommit commit = super.commit(update);
     updateCount.increment();
     return commit;
@@ -636,12 +700,29 @@
    *
    * <p>Must only be called after committing changes.
    *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(Repository)}.
+   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
    *
    * <p>No eviction from account cache and no reindex if this instance was created by {@link
    * FactoryNoReindex}.
    */
   public void updateCaches() throws IOException {
+    updateCaches(ImmutableSet.of());
+  }
+
+  /**
+   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
+   * external IDs were modified.
+   *
+   * <p>Must only be called after committing changes.
+   *
+   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
+   *
+   * <p>No eviction from account cache if this instance was created by {@link FactoryNoReindex}.
+   *
+   * @param accountsToSkip set of accounts that should not be evicted from the account cache, in
+   *     this case the caller must take care to evict them otherwise
+   */
+  public void updateCaches(Collection<Account.Id> accountsToSkip) throws IOException {
     checkState(oldRev != null, "no changes committed yet");
 
     ExternalIdCacheUpdates externalIdCacheUpdates = new ExternalIdCacheUpdates();
@@ -661,6 +742,7 @@
                   externalIdCacheUpdates.getAdded().stream(),
                   externalIdCacheUpdates.getRemoved().stream())
               .map(ExternalId::accountId)
+              .filter(i -> !accountsToSkip.contains(i))
               .collect(toSet())) {
         if (accountCache != null) {
           accountCache.evict(id);
@@ -683,6 +765,8 @@
       return false;
     }
 
+    logger.atFine().log("Updating external IDs");
+
     if (Strings.isNullOrEmpty(commit.getMessage())) {
       commit.setMessage("Update external IDs\n");
     }
@@ -699,8 +783,7 @@
       noteMapUpdates.clear();
       if (!footers.isEmpty()) {
         commit.setMessage(
-            footers
-                .stream()
+            footers.stream()
                 .sorted()
                 .collect(joining("\n", commit.getMessage().trim() + "\n\n", "")));
       }
@@ -740,7 +823,7 @@
       }
       checkState(
           accountId.equals(extId.accountId()),
-          "external id %s belongs to account %s, expected account %s",
+          "external id %s belongs to account %s, but expected account %s",
           extId.key().get(),
           extId.accountId().get(),
           accountId.get());
@@ -798,7 +881,7 @@
     ExternalId actualExtId = ExternalId.parse(noteId.name(), raw, noteDataId);
     checkState(
         extId.equals(actualExtId),
-        "external id %s should be removed, but it's not matching the actual external id %s",
+        "external id %s should be removed, but it doesn't match the actual external id %s",
         extId.toString(),
         actualExtId.toString());
     noteMap.remove(noteId);
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/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index b1a59b1..9098630 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -71,8 +71,7 @@
 
   /** Returns the external IDs of the specified account that have the given scheme. */
   public Set<ExternalId> byAccount(Account.Id accountId, String scheme) throws IOException {
-    return byAccount(accountId)
-        .stream()
+    return byAccount(accountId).stream()
         .filter(e -> e.key().isScheme(scheme))
         .collect(toImmutableSet());
   }
@@ -85,8 +84,7 @@
   /** Returns the external IDs of the specified account that have the given scheme. */
   public Set<ExternalId> byAccount(Account.Id accountId, String scheme, ObjectId rev)
       throws IOException {
-    return byAccount(accountId, rev)
-        .stream()
+    return byAccount(accountId, rev).stream()
         .filter(e -> e.key().isScheme(scheme))
         .collect(toImmutableSet());
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 0756a72..fe7cc48 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));
     }
   }
 
@@ -93,10 +93,7 @@
       }
     }
 
-    emails
-        .asMap()
-        .entrySet()
-        .stream()
+    emails.asMap().entrySet().stream()
         .filter(e -> e.getValue().size() > 1)
         .forEach(
             e ->
@@ -104,8 +101,7 @@
                     String.format(
                         "Email '%s' is not unique, it's used by the following external IDs: %s",
                         e.getKey(),
-                        e.getValue()
-                            .stream()
+                        e.getValue().stream()
                             .map(k -> "'" + k.get() + "'")
                             .sorted()
                             .collect(joining(", "))),
diff --git a/java/com/google/gerrit/server/account/externalids/testing/BUILD b/java/com/google/gerrit/server/account/externalids/testing/BUILD
new file mode 100644
index 0000000..ec98ec8
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/testing/BUILD
@@ -0,0 +1,11 @@
+java_library(
+    name = "testing",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdInserter.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdInserter.java
new file mode 100644
index 0000000..a93192a
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdInserter.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.testing;
+
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.notes.NoteMap;
+
+@FunctionalInterface
+public interface ExternalIdInserter {
+  public ObjectId addNote(ObjectInserter ins, NoteMap noteMap) throws IOException;
+}
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
new file mode 100644
index 0000000..97fff60
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
@@ -0,0 +1,163 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.testing;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import java.io.IOException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Common methods for dealing with external IDs in tests. */
+public class ExternalIdTestUtil {
+
+  public static String insertExternalIdWithoutAccountId(
+      Repository repo, RevWalk rw, PersonIdent ident, Account.Id accountId, String externalId)
+      throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        ident,
+        (ins, noteMap) -> {
+          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), accountId);
+          ObjectId noteId = extId.key().sha1();
+          Config c = new Config();
+          extId.writeToConfig(c);
+          c.unset("externalId", extId.key().get(), "accountId");
+          byte[] raw = c.toText().getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  public static String insertExternalIdWithKeyThatDoesntMatchNoteId(
+      Repository repo, RevWalk rw, PersonIdent ident, Account.Id accountId, String externalId)
+      throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        ident,
+        (ins, noteMap) -> {
+          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), accountId);
+          ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
+          Config c = new Config();
+          extId.writeToConfig(c);
+          byte[] raw = c.toText().getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  public static String insertExternalIdWithInvalidConfig(
+      Repository repo, RevWalk rw, PersonIdent ident, String externalId) throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        ident,
+        (ins, noteMap) -> {
+          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          byte[] raw = "bad-config".getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  public static String insertExternalIdWithEmptyNote(
+      Repository repo, RevWalk rw, PersonIdent ident, String externalId) throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        ident,
+        (ins, noteMap) -> {
+          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          byte[] raw = "".getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  private static String insertExternalId(
+      Repository repo, RevWalk rw, PersonIdent ident, ExternalIdInserter extIdInserter)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = extIdInserter.addNote(ins, noteMap);
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.setMessage("Update external IDs");
+      cb.setTreeId(noteMap.writeTree(ins));
+      cb.setAuthor(ident);
+      cb.setCommitter(ident);
+      if (!rev.equals(ObjectId.zeroId())) {
+        cb.setParentId(rev);
+      } else {
+        cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
+      }
+      if (cb.getTreeId() == null) {
+        if (rev.equals(ObjectId.zeroId())) {
+          cb.setTreeId(ins.insert(OBJ_TREE, new byte[] {})); // No parent, assume empty tree.
+        } else {
+          RevCommit p = rw.parseCommit(rev);
+          cb.setTreeId(p.getTree()); // Copy tree from parent.
+        }
+      }
+      ObjectId commitId = ins.insert(cb);
+      ins.flush();
+
+      RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
+      u.setExpectedOldObjectId(rev);
+      u.setNewObjectId(commitId);
+      RefUpdate.Result res = u.update();
+      switch (res) {
+        case NEW:
+        case FAST_FORWARD:
+        case NO_CHANGE:
+        case RENAMED:
+        case FORCED:
+          break;
+        case LOCK_FAILURE:
+        case IO_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          throw new IOException("Updating external IDs failed with " + res);
+      }
+      return noteId.getName();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/BUILD b/java/com/google/gerrit/server/api/BUILD
index 910ecd3..b9e26de 100644
--- a/java/com/google/gerrit/server/api/BUILD
+++ b/java/com/google/gerrit/server/api/BUILD
@@ -7,13 +7,15 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/util/cli",
+        "//lib:args4j",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 12c65ca..1d86e50 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -19,6 +19,9 @@
 
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
+import com.google.gerrit.extensions.api.accounts.AgreementInput;
+import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
 import com.google.gerrit.extensions.api.accounts.EmailApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
@@ -29,15 +32,17 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
-import com.google.gerrit.extensions.common.AgreementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.HttpPasswordInput;
 import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
@@ -50,6 +55,7 @@
 import com.google.gerrit.server.restapi.account.AddSshKey;
 import com.google.gerrit.server.restapi.account.CreateEmail;
 import com.google.gerrit.server.restapi.account.DeleteActive;
+import com.google.gerrit.server.restapi.account.DeleteDraftComments;
 import com.google.gerrit.server.restapi.account.DeleteEmail;
 import com.google.gerrit.server.restapi.account.DeleteExternalIds;
 import com.google.gerrit.server.restapi.account.DeleteSshKey;
@@ -57,6 +63,7 @@
 import com.google.gerrit.server.restapi.account.GetActive;
 import com.google.gerrit.server.restapi.account.GetAgreements;
 import com.google.gerrit.server.restapi.account.GetAvatar;
+import com.google.gerrit.server.restapi.account.GetDetail;
 import com.google.gerrit.server.restapi.account.GetDiffPreferences;
 import com.google.gerrit.server.restapi.account.GetEditPreferences;
 import com.google.gerrit.server.restapi.account.GetEmails;
@@ -69,6 +76,8 @@
 import com.google.gerrit.server.restapi.account.PostWatchedProjects;
 import com.google.gerrit.server.restapi.account.PutActive;
 import com.google.gerrit.server.restapi.account.PutAgreement;
+import com.google.gerrit.server.restapi.account.PutHttpPassword;
+import com.google.gerrit.server.restapi.account.PutName;
 import com.google.gerrit.server.restapi.account.PutStatus;
 import com.google.gerrit.server.restapi.account.SetDiffPreferences;
 import com.google.gerrit.server.restapi.account.SetEditPreferences;
@@ -77,7 +86,6 @@
 import com.google.gerrit.server.restapi.account.StarredChanges;
 import com.google.gerrit.server.restapi.account.Stars;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.List;
@@ -92,6 +100,7 @@
   private final AccountResource account;
   private final ChangesCollection changes;
   private final AccountLoader.Factory accountLoaderFactory;
+  private final GetDetail getDetail;
   private final GetAvatar getAvatar;
   private final GetPreferences getPreferences;
   private final SetPreferences setPreferences;
@@ -108,7 +117,7 @@
   private final Stars.Get starsGet;
   private final Stars.Post starsPost;
   private final GetEmails getEmails;
-  private final CreateEmail.Factory createEmailFactory;
+  private final CreateEmail createEmail;
   private final DeleteEmail deleteEmail;
   private final GpgApiAdapter gpgApiAdapter;
   private final GetSshKeys getSshKeys;
@@ -123,14 +132,18 @@
   private final Index index;
   private final GetExternalIds getExternalIds;
   private final DeleteExternalIds deleteExternalIds;
+  private final DeleteDraftComments deleteDraftComments;
   private final PutStatus putStatus;
   private final GetGroups getGroups;
   private final EmailApiImpl.Factory emailApi;
+  private final PutName putName;
+  private final PutHttpPassword putHttpPassword;
 
   @Inject
   AccountApiImpl(
       AccountLoader.Factory ailf,
       ChangesCollection changes,
+      GetDetail getDetail,
       GetAvatar getAvatar,
       GetPreferences getPreferences,
       SetPreferences setPreferences,
@@ -147,7 +160,7 @@
       Stars.Get starsGet,
       Stars.Post starsPost,
       GetEmails getEmails,
-      CreateEmail.Factory createEmailFactory,
+      CreateEmail createEmail,
       DeleteEmail deleteEmail,
       GpgApiAdapter gpgApiAdapter,
       GetSshKeys getSshKeys,
@@ -162,13 +175,17 @@
       Index index,
       GetExternalIds getExternalIds,
       DeleteExternalIds deleteExternalIds,
+      DeleteDraftComments deleteDraftComments,
       PutStatus putStatus,
       GetGroups getGroups,
       EmailApiImpl.Factory emailApi,
+      PutName putName,
+      PutHttpPassword putPassword,
       @Assisted AccountResource account) {
     this.account = account;
     this.accountLoaderFactory = ailf;
     this.changes = changes;
+    this.getDetail = getDetail;
     this.getAvatar = getAvatar;
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
@@ -185,7 +202,7 @@
     this.starsGet = starsGet;
     this.starsPost = starsPost;
     this.getEmails = getEmails;
-    this.createEmailFactory = createEmailFactory;
+    this.createEmail = createEmail;
     this.deleteEmail = deleteEmail;
     this.getSshKeys = getSshKeys;
     this.addSshKey = addSshKey;
@@ -200,9 +217,12 @@
     this.index = index;
     this.getExternalIds = getExternalIds;
     this.deleteExternalIds = deleteExternalIds;
+    this.deleteDraftComments = deleteDraftComments;
     this.putStatus = putStatus;
     this.getGroups = getGroups;
     this.emailApi = emailApi;
+    this.putName = putName;
+    this.putHttpPassword = putPassword;
   }
 
   @Override
@@ -213,7 +233,16 @@
       accountLoader.fill();
       return ai;
     } catch (Exception e) {
-      throw asRestApiException("Cannot parse change", e);
+      throw asRestApiException("Cannot parse account", e);
+    }
+  }
+
+  @Override
+  public AccountDetailInfo detail() throws RestApiException {
+    try {
+      return getDetail.apply(account).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get detail", e);
     }
   }
 
@@ -245,7 +274,7 @@
   @Override
   public GeneralPreferencesInfo getPreferences() throws RestApiException {
     try {
-      return getPreferences.apply(account);
+      return getPreferences.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get preferences", e);
     }
@@ -254,7 +283,7 @@
   @Override
   public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException {
     try {
-      return setPreferences.apply(account, in);
+      return setPreferences.apply(account, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set preferences", e);
     }
@@ -263,7 +292,7 @@
   @Override
   public DiffPreferencesInfo getDiffPreferences() throws RestApiException {
     try {
-      return getDiffPreferences.apply(account);
+      return getDiffPreferences.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot query diff preferences", e);
     }
@@ -272,7 +301,7 @@
   @Override
   public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException {
     try {
-      return setDiffPreferences.apply(account, in);
+      return setDiffPreferences.apply(account, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set diff preferences", e);
     }
@@ -281,7 +310,7 @@
   @Override
   public EditPreferencesInfo getEditPreferences() throws RestApiException {
     try {
-      return getEditPreferences.apply(account);
+      return getEditPreferences.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot query edit preferences", e);
     }
@@ -290,7 +319,7 @@
   @Override
   public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException {
     try {
-      return setEditPreferences.apply(account, in);
+      return setEditPreferences.apply(account, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set edit preferences", e);
     }
@@ -299,7 +328,7 @@
   @Override
   public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException {
     try {
-      return getWatchedProjects.apply(account);
+      return getWatchedProjects.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get watched projects", e);
     }
@@ -309,7 +338,7 @@
   public List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in)
       throws RestApiException {
     try {
-      return postWatchedProjects.apply(account, in);
+      return postWatchedProjects.apply(account, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot update watched projects", e);
     }
@@ -327,9 +356,8 @@
   @Override
   public void starChange(String changeId) throws RestApiException {
     try {
-      ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
-      starredChangesCreate.setChange(rsrc);
-      starredChangesCreate.apply(account, new StarredChanges.EmptyInput());
+      starredChangesCreate.apply(
+          account, IdString.fromUrl(changeId), new StarredChanges.EmptyInput());
     } catch (Exception e) {
       throw asRestApiException("Cannot star change", e);
     }
@@ -361,7 +389,7 @@
   public SortedSet<String> getStars(String changeId) throws RestApiException {
     try {
       AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
-      return starsGet.apply(rsrc);
+      return starsGet.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get stars", e);
     }
@@ -370,7 +398,7 @@
   @Override
   public List<ChangeInfo> getStarredChanges() throws RestApiException {
     try {
-      return stars.list().apply(account);
+      return stars.list().apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get starred changes", e);
     }
@@ -379,8 +407,8 @@
   @Override
   public List<GroupInfo> getGroups() throws RestApiException {
     try {
-      return getGroups.apply(account);
-    } catch (OrmException e) {
+      return getGroups.apply(account).value();
+    } catch (Exception e) {
       throw asRestApiException("Cannot get groups", e);
     }
   }
@@ -388,7 +416,7 @@
   @Override
   public List<EmailInfo> getEmails() throws RestApiException {
     try {
-      return getEmails.apply(account);
+      return getEmails.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get emails", e);
     }
@@ -398,7 +426,7 @@
   public void addEmail(EmailInput input) throws RestApiException {
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
-      createEmailFactory.create(input.email).apply(rsrc, input);
+      createEmail.apply(rsrc, IdString.fromDecoded(input.email), input);
     } catch (Exception e) {
       throw asRestApiException("Cannot add email", e);
     }
@@ -418,7 +446,7 @@
   public EmailApi createEmail(EmailInput input) throws RestApiException {
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
-      createEmailFactory.create(input.email).apply(rsrc, input);
+      createEmail.apply(rsrc, IdString.fromDecoded(input.email), input);
       return email(rsrc.getEmail());
     } catch (Exception e) {
       throw asRestApiException("Cannot create email", e);
@@ -447,7 +475,7 @@
   @Override
   public List<SshKeyInfo> listSshKeys() throws RestApiException {
     try {
-      return getSshKeys.apply(account);
+      return getSshKeys.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list SSH keys", e);
     }
@@ -505,7 +533,11 @@
 
   @Override
   public List<AgreementInfo> listAgreements() throws RestApiException {
-    return getAgreements.apply(account);
+    try {
+      return getAgreements.apply(account).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get agreements", e);
+    }
   }
 
   @Override
@@ -531,7 +563,7 @@
   @Override
   public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
     try {
-      return getExternalIds.apply(account);
+      return getExternalIds.apply(account).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get external IDs", e);
     }
@@ -545,4 +577,52 @@
       throw asRestApiException("Cannot delete external IDs", e);
     }
   }
+
+  @Override
+  public List<DeletedDraftCommentInfo> deleteDraftComments(DeleteDraftCommentsInput input)
+      throws RestApiException {
+    try {
+      return deleteDraftComments.apply(account, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete draft comments", e);
+    }
+  }
+
+  @Override
+  public void setName(String name) throws RestApiException {
+    NameInput input = new NameInput();
+    input.name = name;
+    try {
+      putName.apply(account, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set account name", e);
+    }
+  }
+
+  @Override
+  public String generateHttpPassword() throws RestApiException {
+    HttpPasswordInput input = new HttpPasswordInput();
+    input.generate = true;
+    try {
+      // Response should never be 'none' for a generated password, but
+      // let's make sure.
+      Response<String> result = putHttpPassword.apply(account, input);
+      return result.isNone() ? null : result.value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot generate HTTP password", e);
+    }
+  }
+
+  @Override
+  public String setHttpPassword(String password) throws RestApiException {
+    HttpPasswordInput input = new HttpPasswordInput();
+    input.generate = false;
+    input.httpPassword = password;
+    try {
+      Response<String> result = putHttpPassword.apply(account, input);
+      return result.isNone() ? null : result.value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot generate HTTP password", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
index 44b6610..012e6ce 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.api.accounts;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
@@ -45,7 +45,7 @@
   private final AccountApiImpl.Factory api;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
-  private final CreateAccount.Factory createAccount;
+  private final CreateAccount createAccount;
   private final Provider<QueryAccounts> queryAccountsProvider;
 
   @Inject
@@ -54,7 +54,7 @@
       AccountApiImpl.Factory api,
       PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
-      CreateAccount.Factory createAccount,
+      CreateAccount createAccount,
       Provider<QueryAccounts> queryAccountsProvider) {
     this.accounts = accounts;
     this.api = api;
@@ -69,7 +69,7 @@
     try {
       return api.create(accounts.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
     } catch (Exception e) {
-      throw asRestApiException("Cannot parse change", e);
+      throw asRestApiException("Cannot parse account", e);
     }
   }
 
@@ -95,13 +95,17 @@
 
   @Override
   public AccountApi create(AccountInput in) throws RestApiException {
-    if (checkNotNull(in, "AccountInput").username == null) {
+    if (requireNonNull(in, "AccountInput").username == null) {
       throw new BadRequestException("AccountInput must specify username");
     }
     try {
-      CreateAccount impl = createAccount.create(in.username);
-      permissionBackend.currentUser().checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
-      AccountInfo info = impl.apply(TopLevelResource.INSTANCE, in).value();
+      permissionBackend
+          .currentUser()
+          .checkAny(GlobalPermission.fromAnnotation(createAccount.getClass()));
+      AccountInfo info =
+          createAccount
+              .apply(TopLevelResource.INSTANCE, IdString.fromDecoded(in.username), in)
+              .value();
       return id(info._accountId);
     } catch (Exception e) {
       throw asRestApiException("Cannot create account " + in.username, e);
@@ -129,7 +133,7 @@
       myQueryAccounts.setSuggest(true);
       myQueryAccounts.setQuery(r.getQuery());
       myQueryAccounts.setLimit(r.getLimit());
-      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
+      return myQueryAccounts.apply(TopLevelResource.INSTANCE).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve suggested accounts", e);
     }
@@ -160,7 +164,7 @@
       for (ListAccountsOption option : r.getOptions()) {
         myQueryAccounts.addOption(option);
       }
-      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
+      return myQueryAccounts.apply(TopLevelResource.INSTANCE).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve suggested accounts", e);
     }
diff --git a/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java b/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
index 759f60c..f68142f 100644
--- a/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
@@ -61,7 +61,7 @@
   @Override
   public EmailInfo get() throws RestApiException {
     try {
-      return get.apply(resource());
+      return get.apply(resource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot read email", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index fb42ed0..3cbcb73 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -16,7 +16,10 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
@@ -33,6 +36,7 @@
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
@@ -43,21 +47,22 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.PureRevert;
+import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
@@ -68,14 +73,17 @@
 import com.google.gerrit.server.restapi.change.DeleteChange;
 import com.google.gerrit.server.restapi.change.DeletePrivate;
 import com.google.gerrit.server.restapi.change.GetAssignee;
+import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.GetHashtags;
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
+import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
 import com.google.gerrit.server.restapi.change.Ignore;
 import com.google.gerrit.server.restapi.change.Index;
 import com.google.gerrit.server.restapi.change.ListChangeComments;
 import com.google.gerrit.server.restapi.change.ListChangeDrafts;
 import com.google.gerrit.server.restapi.change.ListChangeRobotComments;
+import com.google.gerrit.server.restapi.change.ListReviewers;
 import com.google.gerrit.server.restapi.change.MarkAsReviewed;
 import com.google.gerrit.server.restapi.change.MarkAsUnreviewed;
 import com.google.gerrit.server.restapi.change.Move;
@@ -90,20 +98,22 @@
 import com.google.gerrit.server.restapi.change.Revert;
 import com.google.gerrit.server.restapi.change.Reviewers;
 import com.google.gerrit.server.restapi.change.Revisions;
-import com.google.gerrit.server.restapi.change.SetPrivateOp;
 import com.google.gerrit.server.restapi.change.SetReadyForReview;
 import com.google.gerrit.server.restapi.change.SetWorkInProgress;
 import com.google.gerrit.server.restapi.change.SubmittedTogether;
 import com.google.gerrit.server.restapi.change.SuggestChangeReviewers;
 import com.google.gerrit.server.restapi.change.Unignore;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.kohsuke.args4j.CmdLineException;
 
 class ChangeApiImpl implements ChangeApi {
   interface Factory {
@@ -118,6 +128,7 @@
   private final ChangeMessageApiImpl.Factory changeMessageApi;
   private final ChangeMessages changeMessages;
   private final SuggestChangeReviewers suggestReviewers;
+  private final ListReviewers listReviewers;
   private final ChangeResource change;
   private final Abandon abandon;
   private final Revert revert;
@@ -130,7 +141,7 @@
   private final PutTopic putTopic;
   private final ChangeIncludedIn includedIn;
   private final PostReviewers postReviewers;
-  private final ChangeJson.Factory changeJson;
+  private final Provider<GetChange> getChangeProvider;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
   private final PutAssignee putAssignee;
@@ -153,8 +164,9 @@
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
-  private final PureRevert pureRevert;
+  private final Provider<GetPureRevert> getPureRevertProvider;
   private final StarredChangesUtil stars;
+  private final DynamicOptionParser dynamicOptionParser;
 
   @Inject
   ChangeApiImpl(
@@ -166,6 +178,7 @@
       ChangeMessageApiImpl.Factory changeMessageApi,
       ChangeMessages changeMessages,
       SuggestChangeReviewers suggestReviewers,
+      ListReviewers listReviewers,
       Abandon abandon,
       Revert revert,
       Restore restore,
@@ -177,7 +190,7 @@
       PutTopic putTopic,
       ChangeIncludedIn includedIn,
       PostReviewers postReviewers,
-      ChangeJson.Factory changeJson,
+      Provider<GetChange> getChangeProvider,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
       PutAssignee putAssignee,
@@ -200,8 +213,9 @@
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
-      PureRevert pureRevert,
+      Provider<GetPureRevert> getPureRevertProvider,
       StarredChangesUtil stars,
+      DynamicOptionParser dynamicOptionParser,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
@@ -212,6 +226,7 @@
     this.changeMessageApi = changeMessageApi;
     this.changeMessages = changeMessages;
     this.suggestReviewers = suggestReviewers;
+    this.listReviewers = listReviewers;
     this.abandon = abandon;
     this.restore = restore;
     this.updateByMerge = updateByMerge;
@@ -222,7 +237,7 @@
     this.putTopic = putTopic;
     this.includedIn = includedIn;
     this.postReviewers = postReviewers;
-    this.changeJson = changeJson;
+    this.getChangeProvider = getChangeProvider;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
     this.putAssignee = putAssignee;
@@ -245,8 +260,9 @@
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
-    this.pureRevert = pureRevert;
+    this.getPureRevertProvider = getPureRevertProvider;
     this.stars = stars;
+    this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
   }
 
@@ -256,16 +272,6 @@
   }
 
   @Override
-  public RevisionApi current() throws RestApiException {
-    return revision("current");
-  }
-
-  @Override
-  public RevisionApi revision(int id) throws RestApiException {
-    return revision(String.valueOf(id));
-  }
-
-  @Override
   public RevisionApi revision(String id) throws RestApiException {
     try {
       return revisionApi.create(revisions.parse(change, IdString.fromDecoded(id)));
@@ -284,11 +290,6 @@
   }
 
   @Override
-  public void abandon() throws RestApiException {
-    abandon(new AbandonInput());
-  }
-
-  @Override
   public void abandon(AbandonInput in) throws RestApiException {
     try {
       abandon.apply(change, in);
@@ -298,11 +299,6 @@
   }
 
   @Override
-  public void restore() throws RestApiException {
-    restore(new RestoreInput());
-  }
-
-  @Override
   public void restore(RestoreInput in) throws RestApiException {
     try {
       restore.apply(change, in);
@@ -312,13 +308,6 @@
   }
 
   @Override
-  public void move(String destination) throws RestApiException {
-    MoveInput in = new MoveInput();
-    in.destinationBranch = destination;
-    move(in);
-  }
-
-  @Override
   public void move(MoveInput in) throws RestApiException {
     try {
       move.apply(change, in);
@@ -342,7 +331,7 @@
   }
 
   @Override
-  public void setWorkInProgress(String message) throws RestApiException {
+  public void setWorkInProgress(@Nullable String message) throws RestApiException {
     try {
       setWip.apply(change, new WorkInProgressOp.Input(message));
     } catch (Exception e) {
@@ -351,7 +340,7 @@
   }
 
   @Override
-  public void setReadyForReview(String message) throws RestApiException {
+  public void setReadyForReview(@Nullable String message) throws RestApiException {
     try {
       setReady.apply(change, new WorkInProgressOp.Input(message));
     } catch (Exception e) {
@@ -360,14 +349,9 @@
   }
 
   @Override
-  public ChangeApi revert() throws RestApiException {
-    return revert(new RevertInput());
-  }
-
-  @Override
   public ChangeApi revert(RevertInput in) throws RestApiException {
     try {
-      return changeApi.id(revert.apply(change, in)._number);
+      return changeApi.id(revert.apply(change, in).value()._number);
     } catch (Exception e) {
       throw asRestApiException("Cannot revert change", e);
     }
@@ -383,20 +367,6 @@
   }
 
   @Override
-  public List<ChangeInfo> submittedTogether() throws RestApiException {
-    SubmittedTogetherInfo info =
-        submittedTogether(
-            EnumSet.noneOf(ListChangesOption.class), EnumSet.noneOf(SubmittedTogetherOption.class));
-    return info.changes;
-  }
-
-  @Override
-  public SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
-      throws RestApiException {
-    return submittedTogether(EnumSet.noneOf(ListChangesOption.class), options);
-  }
-
-  @Override
   public SubmittedTogetherInfo submittedTogether(
       EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
       throws RestApiException {
@@ -411,17 +381,6 @@
     }
   }
 
-  @Deprecated
-  @Override
-  public void publish() throws RestApiException {
-    throw new UnsupportedOperationException("draft workflow is discontinued");
-  }
-
-  @Override
-  public void rebase() throws RestApiException {
-    rebase(new RebaseInput());
-  }
-
   @Override
   public void rebase(RebaseInput in) throws RestApiException {
     try {
@@ -442,7 +401,7 @@
 
   @Override
   public String topic() throws RestApiException {
-    return getTopic.apply(change);
+    return getTopic.apply(change).value();
   }
 
   @Override
@@ -459,23 +418,16 @@
   @Override
   public IncludedInInfo includedIn() throws RestApiException {
     try {
-      return includedIn.apply(change);
+      return includedIn.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Could not extract IncludedIn data", e);
     }
   }
 
   @Override
-  public AddReviewerResult addReviewer(String reviewer) throws RestApiException {
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = reviewer;
-    return addReviewer(in);
-  }
-
-  @Override
   public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
     try {
-      return postReviewers.apply(change, in);
+      return postReviewers.apply(change, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot add change reviewer", e);
     }
@@ -491,56 +443,46 @@
     };
   }
 
-  @Override
-  public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
-    return suggestReviewers().withQuery(query);
-  }
-
   private List<SuggestedReviewerInfo> suggestReviewers(SuggestedReviewersRequest r)
       throws RestApiException {
     try {
       suggestReviewers.setQuery(r.getQuery());
       suggestReviewers.setLimit(r.getLimit());
-      return suggestReviewers.apply(change);
+      return suggestReviewers.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve suggested reviewers", e);
     }
   }
 
   @Override
-  public ChangeInfo get(EnumSet<ListChangesOption> s) throws RestApiException {
+  public List<ReviewerInfo> reviewers() throws RestApiException {
     try {
-      return changeJson.create(s).format(change);
+      return listReviewers.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve reviewers", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo get(
+      EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException {
+    try {
+      GetChange getChange = getChangeProvider.get();
+      options.forEach(getChange::addOption);
+      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions);
+      return getChange.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change", e);
     }
   }
 
   @Override
-  public ChangeInfo get() throws RestApiException {
-    return get(
-        EnumSet.complementOf(
-            EnumSet.of(ListChangesOption.CHECK, ListChangesOption.SKIP_MERGEABLE)));
-  }
-
-  @Override
-  public EditInfo getEdit() throws RestApiException {
-    return edit().get().orElse(null);
-  }
-
-  @Override
   public ChangeEditApi edit() throws RestApiException {
     return changeEditApi.create(change);
   }
 
   @Override
-  public void setMessage(String msg) throws RestApiException {
-    CommitMessageInput in = new CommitMessageInput();
-    in.message = msg;
-    setMessage(in);
-  }
-
-  @Override
   public void setMessage(CommitMessageInput in) throws RestApiException {
     try {
       putMessage.apply(change, in);
@@ -550,11 +492,6 @@
   }
 
   @Override
-  public ChangeInfo info() throws RestApiException {
-    return get(EnumSet.noneOf(ListChangesOption.class));
-  }
-
-  @Override
   public void setHashtags(HashtagsInput input) throws RestApiException {
     try {
       postHashtags.apply(change, input);
@@ -575,7 +512,7 @@
   @Override
   public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
     try {
-      return putAssignee.apply(change, input);
+      return putAssignee.apply(change, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set assignee", e);
     }
@@ -613,7 +550,7 @@
   @Override
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
-      return listComments.apply(change);
+      return listComments.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get comments", e);
     }
@@ -622,7 +559,7 @@
   @Override
   public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
     try {
-      return listChangeRobotComments.apply(change);
+      return listChangeRobotComments.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get robot comments", e);
     }
@@ -631,7 +568,7 @@
   @Override
   public Map<String, List<CommentInfo>> drafts() throws RestApiException {
     try {
-      return listDrafts.apply(change);
+      return listDrafts.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get drafts", e);
     }
@@ -676,7 +613,7 @@
       } else {
         unignore.apply(change, new Input());
       }
-    } catch (OrmException | IllegalLabelException e) {
+    } catch (StorageException | IllegalLabelException e) {
       throw asRestApiException("Cannot ignore change", e);
     }
   }
@@ -685,7 +622,7 @@
   public boolean ignored() throws RestApiException {
     try {
       return stars.isIgnored(change);
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       throw asRestApiException("Cannot check if ignored", e);
     }
   }
@@ -700,7 +637,7 @@
       } else {
         markAsUnreviewed.apply(change, new Input());
       }
-    } catch (OrmException | IllegalLabelException e) {
+    } catch (StorageException | IllegalLabelException e) {
       throw asRestApiException(
           "Cannot mark change as " + (reviewed ? "reviewed" : "unreviewed"), e);
     }
@@ -714,7 +651,9 @@
   @Override
   public PureRevertInfo pureRevert(@Nullable String claimedOriginal) throws RestApiException {
     try {
-      return pureRevert.get(change.getNotes(), claimedOriginal);
+      GetPureRevert getPureRevert = getPureRevertProvider.get();
+      getPureRevert.setClaimedOriginal(claimedOriginal);
+      return getPureRevert.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot compute pure revert", e);
     }
@@ -723,7 +662,7 @@
   @Override
   public List<ChangeMessageInfo> messages() throws RestApiException {
     try {
-      return changeMessages.list().apply(change);
+      return changeMessages.list().apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list change messages", e);
     }
@@ -738,4 +677,36 @@
       throw asRestApiException("Cannot parse change message " + id, e);
     }
   }
+
+  @Singleton
+  static class DynamicOptionParser {
+    private final CmdLineParser.Factory cmdLineParserFactory;
+    private final Injector injector;
+    private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
+
+    @Inject
+    DynamicOptionParser(
+        CmdLineParser.Factory cmdLineParserFactory,
+        Injector injector,
+        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+      this.cmdLineParserFactory = cmdLineParserFactory;
+      this.injector = injector;
+      this.dynamicBeans = dynamicBeans;
+    }
+
+    void parseDynamicOptions(Object bean, ListMultimap<String, String> pluginOptions)
+        throws BadRequestException {
+      CmdLineParser clp = cmdLineParserFactory.create(bean);
+      DynamicOptions dynamicOptions = new DynamicOptions(bean, injector, dynamicBeans);
+      dynamicOptions.parseDynamicBeans(clp);
+      dynamicOptions.setDynamicBeans();
+      dynamicOptions.onBeanParseStart();
+      try {
+        clp.parseOptionMap(pluginOptions);
+      } catch (CmdLineException | NumberFormatException e) {
+        throw new BadRequestException(e.getMessage(), e);
+      }
+      dynamicOptions.onBeanParseEnd();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index 92aae03..7f0feba 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -33,8 +34,8 @@
 import com.google.gerrit.server.restapi.change.DeleteChangeEdit;
 import com.google.gerrit.server.restapi.change.PublishChangeEdit;
 import com.google.gerrit.server.restapi.change.RebaseChangeEdit;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.Optional;
@@ -44,51 +45,79 @@
     ChangeEditApiImpl create(ChangeResource changeResource);
   }
 
-  private final ChangeEdits.Detail editDetail;
+  private final Provider<ChangeEdits.Detail> editDetailProvider;
   private final ChangeEdits.Post changeEditsPost;
   private final DeleteChangeEdit deleteChangeEdit;
-  private final RebaseChangeEdit.Rebase rebaseChangeEdit;
-  private final PublishChangeEdit.Publish publishChangeEdit;
-  private final ChangeEdits.Get changeEditsGet;
+  private final RebaseChangeEdit rebaseChangeEdit;
+  private final PublishChangeEdit publishChangeEdit;
+  private final Provider<ChangeEdits.Get> changeEditsGetProvider;
   private final ChangeEdits.Put changeEditsPut;
   private final ChangeEdits.DeleteContent changeEditDeleteContent;
-  private final ChangeEdits.GetMessage getChangeEditCommitMessage;
+  private final Provider<ChangeEdits.GetMessage> getChangeEditCommitMessageProvider;
   private final ChangeEdits.EditMessage modifyChangeEditCommitMessage;
   private final ChangeEdits changeEdits;
   private final ChangeResource changeResource;
 
   @Inject
   public ChangeEditApiImpl(
-      ChangeEdits.Detail editDetail,
+      Provider<ChangeEdits.Detail> editDetailProvider,
       ChangeEdits.Post changeEditsPost,
       DeleteChangeEdit deleteChangeEdit,
-      RebaseChangeEdit.Rebase rebaseChangeEdit,
-      PublishChangeEdit.Publish publishChangeEdit,
-      ChangeEdits.Get changeEditsGet,
+      RebaseChangeEdit rebaseChangeEdit,
+      PublishChangeEdit publishChangeEdit,
+      Provider<ChangeEdits.Get> changeEditsGetProvider,
       ChangeEdits.Put changeEditsPut,
       ChangeEdits.DeleteContent changeEditDeleteContent,
-      ChangeEdits.GetMessage getChangeEditCommitMessage,
+      Provider<ChangeEdits.GetMessage> getChangeEditCommitMessageProvider,
       ChangeEdits.EditMessage modifyChangeEditCommitMessage,
       ChangeEdits changeEdits,
       @Assisted ChangeResource changeResource) {
-    this.editDetail = editDetail;
+    this.editDetailProvider = editDetailProvider;
     this.changeEditsPost = changeEditsPost;
     this.deleteChangeEdit = deleteChangeEdit;
     this.rebaseChangeEdit = rebaseChangeEdit;
     this.publishChangeEdit = publishChangeEdit;
-    this.changeEditsGet = changeEditsGet;
+    this.changeEditsGetProvider = changeEditsGetProvider;
     this.changeEditsPut = changeEditsPut;
     this.changeEditDeleteContent = changeEditDeleteContent;
-    this.getChangeEditCommitMessage = getChangeEditCommitMessage;
+    this.getChangeEditCommitMessageProvider = getChangeEditCommitMessageProvider;
     this.modifyChangeEditCommitMessage = modifyChangeEditCommitMessage;
     this.changeEdits = changeEdits;
     this.changeResource = changeResource;
   }
 
   @Override
+  public ChangeEditDetailRequest detail() throws RestApiException {
+    try {
+      return new ChangeEditDetailRequest() {
+        @Override
+        public Optional<EditInfo> get() throws RestApiException {
+          return ChangeEditApiImpl.this.get(this);
+        }
+      };
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve change edit", e);
+    }
+  }
+
+  private Optional<EditInfo> get(ChangeEditDetailRequest r) throws RestApiException {
+    try {
+      ChangeEdits.Detail editDetail = editDetailProvider.get();
+      editDetail.setBase(r.getBase());
+      editDetail.setList(r.options().contains(ChangeEditDetailOption.LIST_FILES));
+      editDetail.setDownloadCommands(
+          r.options().contains(ChangeEditDetailOption.DOWNLOAD_COMMANDS));
+      Response<EditInfo> edit = editDetail.apply(changeResource);
+      return edit.isNone() ? Optional.empty() : Optional.of(edit.value());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve change edit", e);
+    }
+  }
+
+  @Override
   public Optional<EditInfo> get() throws RestApiException {
     try {
-      Response<EditInfo> edit = editDetail.apply(changeResource);
+      Response<EditInfo> edit = editDetailProvider.get().apply(changeResource);
       return edit.isNone() ? Optional.empty() : Optional.of(edit.value());
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change edit", e);
@@ -140,7 +169,7 @@
   public Optional<BinaryResult> getFile(String filePath) throws RestApiException {
     try {
       ChangeEditResource changeEditResource = getChangeEditResource(filePath);
-      Response<BinaryResult> fileResponse = changeEditsGet.apply(changeEditResource);
+      Response<BinaryResult> fileResponse = changeEditsGetProvider.get().apply(changeEditResource);
       return fileResponse.isNone() ? Optional.empty() : Optional.of(fileResponse.value());
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve file of change edit", e);
@@ -191,7 +220,8 @@
   @Override
   public String getCommitMessage() throws RestApiException {
     try {
-      try (BinaryResult binaryResult = getChangeEditCommitMessage.apply(changeResource)) {
+      try (BinaryResult binaryResult =
+          getChangeEditCommitMessageProvider.get().apply(changeResource).value()) {
         return binaryResult.asString();
       }
     } catch (Exception e) {
@@ -211,7 +241,7 @@
   }
 
   private ChangeEditResource getChangeEditResource(String filePath)
-      throws ResourceNotFoundException, AuthException, IOException, OrmException {
+      throws ResourceNotFoundException, AuthException, IOException {
     return changeEdits.parse(changeResource, IdString.fromDecoded(filePath));
   }
 }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
index 988ac91..490ec5b 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
@@ -17,9 +17,11 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
+import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.ChangeMessageResource;
+import com.google.gerrit.server.restapi.change.DeleteChangeMessage;
 import com.google.gerrit.server.restapi.change.GetChangeMessage;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -30,21 +32,34 @@
   }
 
   private final GetChangeMessage getChangeMessage;
+  private final DeleteChangeMessage deleteChangeMessage;
   private final ChangeMessageResource changeMessageResource;
 
   @Inject
   ChangeMessageApiImpl(
-      GetChangeMessage getChangeMessage, @Assisted ChangeMessageResource changeMessageResource) {
+      GetChangeMessage getChangeMessage,
+      DeleteChangeMessage deleteChangeMessage,
+      @Assisted ChangeMessageResource changeMessageResource) {
     this.getChangeMessage = getChangeMessage;
+    this.deleteChangeMessage = deleteChangeMessage;
     this.changeMessageResource = changeMessageResource;
   }
 
   @Override
   public ChangeMessageInfo get() throws RestApiException {
     try {
-      return getChangeMessage.apply(changeMessageResource);
+      return getChangeMessage.apply(changeMessageResource).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change message", e);
     }
   }
+
+  @Override
+  public ChangeMessageInfo delete(DeleteChangeMessageInput input) throws RestApiException {
+    try {
+      return deleteChangeMessage.apply(changeMessageResource, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete change message", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index cefabf4..1dd8dca 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.api.changes;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.api.changes.ChangeApiImpl.DynamicOptionParser;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.CreateChange;
 import com.google.gerrit.server.restapi.change.QueryChanges;
@@ -43,6 +44,7 @@
   private final ChangesCollection changes;
   private final ChangeApiImpl.Factory api;
   private final CreateChange createChange;
+  private final DynamicOptionParser dynamicOptionParser;
   private final Provider<QueryChanges> queryProvider;
 
   @Inject
@@ -50,10 +52,12 @@
       ChangesCollection changes,
       ChangeApiImpl.Factory api,
       CreateChange createChange,
+      DynamicOptionParser dynamicOptionParser,
       Provider<QueryChanges> queryProvider) {
     this.changes = changes;
     this.api = api;
     this.createChange = createChange;
+    this.dynamicOptionParser = dynamicOptionParser;
     this.queryProvider = queryProvider;
   }
 
@@ -88,7 +92,7 @@
   public ChangeApi create(ChangeInput in) throws RestApiException {
     try {
       ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
-      return api.create(changes.parse(new Change.Id(out._number)));
+      return api.create(changes.parse(Change.id(out._number)));
     } catch (Exception e) {
       throw asRestApiException("Cannot create change", e);
     }
@@ -116,19 +120,21 @@
     }
     qc.setLimit(q.getLimit());
     qc.setStart(q.getStart());
+    qc.setNoLimit(q.getNoLimit());
     for (ListChangesOption option : q.getOptions()) {
       qc.addOption(option);
     }
+    dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions());
 
     try {
-      List<?> result = qc.apply(TopLevelResource.INSTANCE);
+      List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
       if (result.isEmpty()) {
         return ImmutableList.of();
       }
 
       // Check type safety of result; the extension API should be safer than the
       // REST API in this case, since it's intended to be used in Java.
-      Object first = checkNotNull(result.iterator().next());
+      Object first = requireNonNull(result.iterator().next());
       checkState(first instanceof ChangeInfo);
       @SuppressWarnings("unchecked")
       List<ChangeInfo> infos = (List<ChangeInfo>) result;
diff --git a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
index 418187d..c5fcab1 100644
--- a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
@@ -46,7 +46,7 @@
   @Override
   public CommentInfo get() throws RestApiException {
     try {
-      return getComment.apply(comment);
+      return getComment.apply(comment).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve comment", e);
     }
@@ -55,7 +55,7 @@
   @Override
   public CommentInfo delete(DeleteCommentInput input) throws RestApiException {
     try {
-      return deleteComment.apply(comment, input);
+      return deleteComment.apply(comment, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot delete comment", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
index 4d26b11..f6eb3c5 100644
--- a/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
@@ -54,7 +54,7 @@
   @Override
   public CommentInfo get() throws RestApiException {
     try {
-      return getDraft.apply(draft);
+      return getDraft.apply(draft).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve draft", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index 6e18bb8..24902d6 100644
--- a/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -18,11 +18,13 @@
 
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.restapi.change.GetContent;
 import com.google.gerrit.server.restapi.change.GetDiff;
+import com.google.gerrit.server.restapi.change.Reviewed;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -33,19 +35,28 @@
 
   private final GetContent getContent;
   private final GetDiff getDiff;
+  private final Reviewed.PutReviewed putReviewed;
+  private final Reviewed.DeleteReviewed deleteReviewed;
   private final FileResource file;
 
   @Inject
-  FileApiImpl(GetContent getContent, GetDiff getDiff, @Assisted FileResource file) {
+  FileApiImpl(
+      GetContent getContent,
+      GetDiff getDiff,
+      Reviewed.PutReviewed putReviewed,
+      Reviewed.DeleteReviewed deleteReviewed,
+      @Assisted FileResource file) {
     this.getContent = getContent;
     this.getDiff = getDiff;
+    this.putReviewed = putReviewed;
+    this.deleteReviewed = deleteReviewed;
     this.file = file;
   }
 
   @Override
   public BinaryResult content() throws RestApiException {
     try {
-      return getContent.apply(file);
+      return getContent.apply(file).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve file content", e);
     }
@@ -88,6 +99,19 @@
     };
   }
 
+  @Override
+  public void setReviewed(boolean reviewed) throws RestApiException {
+    try {
+      if (reviewed) {
+        putReviewed.apply(file, new Input());
+      } else {
+        deleteReviewed.apply(file, new Input());
+      }
+    } catch (Exception e) {
+      throw asRestApiException(String.format("Cannot set %sreviewed", reviewed ? "" : "un"), e);
+    }
+  }
+
   private DiffInfo get(DiffRequest r) throws RestApiException {
     if (r.getBase() != null) {
       getDiff.setBase(r.getBase());
diff --git a/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
index 11536cb..2174ef0 100644
--- a/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -54,7 +54,7 @@
   @Override
   public Map<String, Short> votes() throws RestApiException {
     try {
-      return listVotes.apply(reviewer);
+      return listVotes.apply(reviewer).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list votes", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 8357568..cc0a2f1 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -18,6 +18,9 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder.ListMultimapBuilder;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -26,6 +29,7 @@
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
@@ -34,6 +38,8 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.DescriptionInput;
@@ -42,11 +48,16 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -62,6 +73,7 @@
 import com.google.gerrit.server.restapi.change.GetDescription;
 import com.google.gerrit.server.restapi.change.GetMergeList;
 import com.google.gerrit.server.restapi.change.GetPatch;
+import com.google.gerrit.server.restapi.change.GetRelated;
 import com.google.gerrit.server.restapi.change.GetRevisionActions;
 import com.google.gerrit.server.restapi.change.ListRevisionComments;
 import com.google.gerrit.server.restapi.change.ListRevisionDrafts;
@@ -75,10 +87,12 @@
 import com.google.gerrit.server.restapi.change.RevisionReviewers;
 import com.google.gerrit.server.restapi.change.RobotComments;
 import com.google.gerrit.server.restapi.change.Submit;
+import com.google.gerrit.server.restapi.change.TestSubmitRule;
 import com.google.gerrit.server.restapi.change.TestSubmitType;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -124,9 +138,13 @@
   private final GetRevisionActions revisionActions;
   private final TestSubmitType testSubmitType;
   private final TestSubmitType.Get getSubmitType;
+  private final Provider<TestSubmitRule> testSubmitRule;
   private final Provider<GetMergeList> getMergeList;
+  private final GetRelated getRelated;
   private final PutDescription putDescription;
   private final GetDescription getDescription;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountLoader.Factory accountLoaderFactory;
 
   @Inject
   RevisionApiImpl(
@@ -163,9 +181,13 @@
       GetRevisionActions revisionActions,
       TestSubmitType testSubmitType,
       TestSubmitType.Get getSubmitType,
+      Provider<TestSubmitRule> testSubmitRule,
       Provider<GetMergeList> getMergeList,
+      GetRelated getRelated,
       PutDescription putDescription,
       GetDescription getDescription,
+      ApprovalsUtil approvalsUtil,
+      AccountLoader.Factory accountLoaderFactory,
       @Assisted RevisionResource r) {
     this.repoManager = repoManager;
     this.changes = changes;
@@ -200,9 +222,13 @@
     this.revisionActions = revisionActions;
     this.testSubmitType = testSubmitType;
     this.getSubmitType = getSubmitType;
+    this.testSubmitRule = testSubmitRule;
     this.getMergeList = getMergeList;
+    this.getRelated = getRelated;
     this.putDescription = putDescription;
     this.getDescription = getDescription;
+    this.approvalsUtil = approvalsUtil;
+    this.accountLoaderFactory = accountLoaderFactory;
     this.revision = r;
   }
 
@@ -216,12 +242,6 @@
   }
 
   @Override
-  public void submit() throws RestApiException {
-    SubmitInput in = new SubmitInput();
-    submit(in);
-  }
-
-  @Override
   public void submit(SubmitInput in) throws RestApiException {
     try {
       submit.apply(revision, in);
@@ -231,40 +251,19 @@
   }
 
   @Override
-  public BinaryResult submitPreview() throws RestApiException {
-    return submitPreview("zip");
-  }
-
-  @Override
   public BinaryResult submitPreview(String format) throws RestApiException {
     try {
       submitPreview.setFormat(format);
-      return submitPreview.apply(revision);
+      return submitPreview.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get submit preview", e);
     }
   }
 
   @Override
-  public void publish() throws RestApiException {
-    throw new UnsupportedOperationException("draft workflow is discontinued");
-  }
-
-  @Override
-  public void delete() throws RestApiException {
-    throw new UnsupportedOperationException("draft workflow is discontinued");
-  }
-
-  @Override
-  public ChangeApi rebase() throws RestApiException {
-    RebaseInput in = new RebaseInput();
-    return rebase(in);
-  }
-
-  @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
-      return changes.id(rebase.apply(revision, in)._number);
+      return changes.id(rebase.apply(revision, in).value()._number);
     } catch (Exception e) {
       throw asRestApiException("Cannot rebase ps", e);
     }
@@ -283,7 +282,16 @@
   @Override
   public ChangeApi cherryPick(CherryPickInput in) throws RestApiException {
     try {
-      return changes.id(cherryPick.apply(revision, in)._number);
+      return changes.id(cherryPick.apply(revision, in).value()._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot cherry pick", e);
+    }
+  }
+
+  @Override
+  public CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException {
+    try {
+      return cherryPick.apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot cherry pick", e);
     }
@@ -328,7 +336,7 @@
   @Override
   public MergeableInfo mergeable() throws RestApiException {
     try {
-      return mergeable.apply(revision);
+      return mergeable.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check mergeability", e);
     }
@@ -338,7 +346,7 @@
   public MergeableInfo mergeableOtherBranches() throws RestApiException {
     try {
       mergeable.setOtherBranches(true);
-      return mergeable.apply(revision);
+      return mergeable.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check mergeability", e);
     }
@@ -346,17 +354,7 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public Map<String, FileInfo> files() throws RestApiException {
-    try {
-      return (Map<String, FileInfo>) listFiles.apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve files", e);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public Map<String, FileInfo> files(String base) throws RestApiException {
+  public Map<String, FileInfo> files(@Nullable String base) throws RestApiException {
     try {
       return (Map<String, FileInfo>) listFiles.setBase(base).apply(revision).value();
     } catch (Exception e) {
@@ -402,7 +400,7 @@
   @Override
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
-      return listComments.apply(revision);
+      return listComments.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve comments", e);
     }
@@ -411,7 +409,7 @@
   @Override
   public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
     try {
-      return listRobotComments.apply(revision);
+      return listRobotComments.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve robot comments", e);
     }
@@ -429,7 +427,7 @@
   @Override
   public Map<String, List<CommentInfo>> drafts() throws RestApiException {
     try {
-      return listDrafts.apply(revision);
+      return listDrafts.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve drafts", e);
     }
@@ -478,7 +476,7 @@
       // Reread change to pick up new notes refs.
       return changes
           .id(revision.getChange().getId().get())
-          .revision(revision.getPatchSet().getId().get())
+          .revision(revision.getPatchSet().id().get())
           .draft(id);
     } catch (Exception e) {
       throw asRestApiException("Cannot create draft", e);
@@ -506,7 +504,7 @@
   @Override
   public BinaryResult patch() throws RestApiException {
     try {
-      return getPatch.apply(revision);
+      return getPatch.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get patch", e);
     }
@@ -515,7 +513,7 @@
   @Override
   public BinaryResult patch(String path) throws RestApiException {
     try {
-      return getPatch.setPath(path).apply(revision);
+      return getPatch.setPath(path).apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get patch", e);
     }
@@ -533,7 +531,7 @@
   @Override
   public SubmitType submitType() throws RestApiException {
     try {
-      return getSubmitType.apply(revision);
+      return getSubmitType.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get submit type", e);
     }
@@ -542,13 +540,22 @@
   @Override
   public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
     try {
-      return testSubmitType.apply(revision, in);
+      return testSubmitType.apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot test submit type", e);
     }
   }
 
   @Override
+  public List<TestSubmitRuleInfo> testSubmitRule(TestSubmitRuleInput in) throws RestApiException {
+    try {
+      return testSubmitRule.get().apply(revision, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot test submit rule", e);
+    }
+  }
+
+  @Override
   public MergeListRequest getMergeList() throws RestApiException {
     return new MergeListRequest() {
       @Override
@@ -566,6 +573,45 @@
   }
 
   @Override
+  public RelatedChangesInfo related() throws RestApiException {
+    try {
+      return getRelated.apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get related changes", e);
+    }
+  }
+
+  @Override
+  public ListMultimap<String, ApprovalInfo> votes() throws RestApiException {
+    ListMultimap<String, ApprovalInfo> result =
+        ListMultimapBuilder.treeKeys().arrayListValues().build();
+    try {
+      Iterable<PatchSetApproval> approvals =
+          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().id(), null, null);
+      AccountLoader accountLoader =
+          accountLoaderFactory.create(
+              EnumSet.of(
+                  FillOptions.ID, FillOptions.NAME, FillOptions.EMAIL, FillOptions.USERNAME));
+      for (PatchSetApproval approval : approvals) {
+        String label = approval.label();
+        ApprovalInfo info =
+            new ApprovalInfo(
+                approval.accountId().get(),
+                Integer.valueOf(approval.value()),
+                null,
+                approval.tag().orElse(null),
+                approval.granted());
+        accountLoader.put(info);
+        result.get(label).add(info);
+      }
+      accountLoader.fill();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get votes", e);
+    }
+    return result;
+  }
+
+  @Override
   public void description(String description) throws RestApiException {
     DescriptionInput in = new DescriptionInput();
     in.description = description;
@@ -578,7 +624,7 @@
 
   @Override
   public String description() throws RestApiException {
-    return getDescription.apply(revision);
+    return getDescription.apply(revision).value();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
index 8cad507..49c2d49 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
@@ -47,7 +47,7 @@
   @Override
   public Map<String, Short> votes() throws RestApiException {
     try {
-      return listVotes.apply(reviewer);
+      return listVotes.apply(reviewer).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list votes", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java b/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
index 37a56fe..ec13061 100644
--- a/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
@@ -41,7 +41,7 @@
   @Override
   public RobotCommentInfo get() throws RestApiException {
     try {
-      return getComment.apply(comment);
+      return getComment.apply(comment).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve robot comment", e);
     }
diff --git a/java/com/google/gerrit/server/api/config/ServerImpl.java b/java/com/google/gerrit/server/api/config/ServerImpl.java
index ec08507..4ca842b 100644
--- a/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -25,18 +25,21 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.restapi.config.CheckConsistency;
 import com.google.gerrit.server.restapi.config.GetDiffPreferences;
 import com.google.gerrit.server.restapi.config.GetEditPreferences;
 import com.google.gerrit.server.restapi.config.GetPreferences;
 import com.google.gerrit.server.restapi.config.GetServerInfo;
+import com.google.gerrit.server.restapi.config.ListTopMenus;
 import com.google.gerrit.server.restapi.config.SetDiffPreferences;
 import com.google.gerrit.server.restapi.config.SetEditPreferences;
 import com.google.gerrit.server.restapi.config.SetPreferences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.List;
 
 @Singleton
 public class ServerImpl implements Server {
@@ -48,6 +51,7 @@
   private final SetEditPreferences setEditPreferences;
   private final GetServerInfo getServerInfo;
   private final Provider<CheckConsistency> checkConsistency;
+  private final ListTopMenus listTopMenus;
 
   @Inject
   ServerImpl(
@@ -58,7 +62,8 @@
       GetEditPreferences getEditPreferences,
       SetEditPreferences setEditPreferences,
       GetServerInfo getServerInfo,
-      Provider<CheckConsistency> checkConsistency) {
+      Provider<CheckConsistency> checkConsistency,
+      ListTopMenus listTopMenus) {
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
     this.getDiffPreferences = getDiffPreferences;
@@ -67,6 +72,7 @@
     this.setEditPreferences = setEditPreferences;
     this.getServerInfo = getServerInfo;
     this.checkConsistency = checkConsistency;
+    this.listTopMenus = listTopMenus;
   }
 
   @Override
@@ -77,7 +83,7 @@
   @Override
   public ServerInfo getInfo() throws RestApiException {
     try {
-      return getServerInfo.apply(new ConfigResource());
+      return getServerInfo.apply(new ConfigResource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get server info", e);
     }
@@ -86,7 +92,7 @@
   @Override
   public GeneralPreferencesInfo getDefaultPreferences() throws RestApiException {
     try {
-      return getPreferences.apply(new ConfigResource());
+      return getPreferences.apply(new ConfigResource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get default general preferences", e);
     }
@@ -96,7 +102,7 @@
   public GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in)
       throws RestApiException {
     try {
-      return setPreferences.apply(new ConfigResource(), in);
+      return setPreferences.apply(new ConfigResource(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set default general preferences", e);
     }
@@ -105,7 +111,7 @@
   @Override
   public DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException {
     try {
-      return getDiffPreferences.apply(new ConfigResource());
+      return getDiffPreferences.apply(new ConfigResource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get default diff preferences", e);
     }
@@ -115,7 +121,7 @@
   public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in)
       throws RestApiException {
     try {
-      return setDiffPreferences.apply(new ConfigResource(), in);
+      return setDiffPreferences.apply(new ConfigResource(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set default diff preferences", e);
     }
@@ -124,7 +130,7 @@
   @Override
   public EditPreferencesInfo getDefaultEditPreferences() throws RestApiException {
     try {
-      return getEditPreferences.apply(new ConfigResource());
+      return getEditPreferences.apply(new ConfigResource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get default edit preferences", e);
     }
@@ -134,7 +140,7 @@
   public EditPreferencesInfo setDefaultEditPreferences(EditPreferencesInfo in)
       throws RestApiException {
     try {
-      return setEditPreferences.apply(new ConfigResource(), in);
+      return setEditPreferences.apply(new ConfigResource(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot set default edit preferences", e);
     }
@@ -143,9 +149,14 @@
   @Override
   public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException {
     try {
-      return checkConsistency.get().apply(new ConfigResource(), in);
+      return checkConsistency.get().apply(new ConfigResource(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check consistency", e);
     }
   }
+
+  @Override
+  public List<TopMenu.MenuEntry> topMenus() {
+    return listTopMenus.apply(new ConfigResource()).value();
+  }
 }
diff --git a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 9909ed7..5e58d49 100644
--- a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.restapi.group.PutOwner;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Arrays;
 import java.util.List;
 
 class GroupApiImpl implements GroupApi {
@@ -120,7 +119,7 @@
   @Override
   public GroupInfo get() throws RestApiException {
     try {
-      return getGroup.apply(rsrc);
+      return getGroup.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve group", e);
     }
@@ -129,7 +128,7 @@
   @Override
   public GroupInfo detail() throws RestApiException {
     try {
-      return getDetail.apply(rsrc);
+      return getDetail.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve group", e);
     }
@@ -137,7 +136,7 @@
 
   @Override
   public String name() throws RestApiException {
-    return getName.apply(rsrc);
+    return getName.apply(rsrc).value();
   }
 
   @Override
@@ -154,7 +153,7 @@
   @Override
   public GroupInfo owner() throws RestApiException {
     try {
-      return getOwner.apply(rsrc);
+      return getOwner.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get group owner", e);
     }
@@ -173,7 +172,7 @@
 
   @Override
   public String description() throws RestApiException {
-    return getDescription.apply(rsrc);
+    return getDescription.apply(rsrc).value();
   }
 
   @Override
@@ -189,7 +188,7 @@
 
   @Override
   public GroupOptionsInfo options() throws RestApiException {
-    return getOptions.apply(rsrc);
+    return getOptions.apply(rsrc).value();
   }
 
   @Override
@@ -210,25 +209,25 @@
   public List<AccountInfo> members(boolean recursive) throws RestApiException {
     listMembers.setRecursive(recursive);
     try {
-      return listMembers.apply(rsrc);
+      return listMembers.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list group members", e);
     }
   }
 
   @Override
-  public void addMembers(String... members) throws RestApiException {
+  public void addMembers(List<String> members) throws RestApiException {
     try {
-      addMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
+      addMembers.apply(rsrc, AddMembers.Input.fromMembers(members));
     } catch (Exception e) {
       throw asRestApiException("Cannot add group members", e);
     }
   }
 
   @Override
-  public void removeMembers(String... members) throws RestApiException {
+  public void removeMembers(List<String> members) throws RestApiException {
     try {
-      deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
+      deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(members));
     } catch (Exception e) {
       throw asRestApiException("Cannot remove group members", e);
     }
@@ -237,25 +236,25 @@
   @Override
   public List<GroupInfo> includedGroups() throws RestApiException {
     try {
-      return listSubgroups.apply(rsrc);
+      return listSubgroups.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list subgroups", e);
     }
   }
 
   @Override
-  public void addGroups(String... groups) throws RestApiException {
+  public void addGroups(List<String> groups) throws RestApiException {
     try {
-      addSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
+      addSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(groups));
     } catch (Exception e) {
       throw asRestApiException("Cannot add subgroups", e);
     }
   }
 
   @Override
-  public void removeGroups(String... groups) throws RestApiException {
+  public void removeGroups(List<String> groups) throws RestApiException {
     try {
-      deleteSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
+      deleteSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(groups));
     } catch (Exception e) {
       throw asRestApiException("Cannot remove subgroups", e);
     }
@@ -264,7 +263,7 @@
   @Override
   public List<? extends GroupAuditEventInfo> auditLog() throws RestApiException {
     try {
-      return getAuditLog.apply(rsrc);
+      return getAuditLog.apply(rsrc).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get audit log", e);
     }
diff --git a/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index 247be44..a46b59a 100644
--- a/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.api.groups;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -26,10 +26,11 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.group.GroupResolver;
 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.restapi.account.AccountsCollection;
 import com.google.gerrit.server.restapi.group.CreateGroup;
 import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.restapi.group.ListGroups;
@@ -43,27 +44,30 @@
 
 @Singleton
 class GroupsImpl implements Groups {
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final GroupsCollection groups;
+  private final GroupResolver groupResolver;
   private final ProjectsCollection projects;
   private final Provider<ListGroups> listGroups;
   private final Provider<QueryGroups> queryGroups;
   private final PermissionBackend permissionBackend;
-  private final CreateGroup.Factory createGroup;
+  private final CreateGroup createGroup;
   private final GroupApiImpl.Factory api;
 
   @Inject
   GroupsImpl(
-      AccountsCollection accounts,
+      AccountResolver accountResolver,
       GroupsCollection groups,
+      GroupResolver groupResolver,
       ProjectsCollection projects,
       Provider<ListGroups> listGroups,
       Provider<QueryGroups> queryGroups,
       PermissionBackend permissionBackend,
-      CreateGroup.Factory createGroup,
+      CreateGroup createGroup,
       GroupApiImpl.Factory api) {
-    this.accounts = accounts;
+    this.accountResolver = accountResolver;
     this.groups = groups;
+    this.groupResolver = groupResolver;
     this.projects = projects;
     this.listGroups = listGroups;
     this.queryGroups = queryGroups;
@@ -86,13 +90,15 @@
 
   @Override
   public GroupApi create(GroupInput in) throws RestApiException {
-    if (checkNotNull(in, "GroupInput").name == null) {
+    if (requireNonNull(in, "GroupInput").name == null) {
       throw new BadRequestException("GroupInput must specify name");
     }
     try {
-      CreateGroup impl = createGroup.create(in.name);
-      permissionBackend.currentUser().checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
-      GroupInfo info = impl.apply(TopLevelResource.INSTANCE, in);
+      permissionBackend
+          .currentUser()
+          .checkAny(GlobalPermission.fromAnnotation(createGroup.getClass()));
+      GroupInfo info =
+          createGroup.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(in.name), in).value();
       return id(info.id);
     } catch (Exception e) {
       throw asRestApiException("Cannot create group " + in.name, e);
@@ -124,7 +130,7 @@
     }
 
     for (String group : req.getGroups()) {
-      list.addGroup(groups.parse(group).getGroupUUID());
+      list.addGroup(groupResolver.parse(group).getGroupUUID());
     }
 
     list.setVisibleToAll(req.getVisibleToAll());
@@ -135,7 +141,7 @@
 
     if (req.getUser() != null) {
       try {
-        list.setUser(accounts.parse(req.getUser()).getAccountId());
+        list.setUser(accountResolver.resolve(req.getUser()).asUnique().getAccount().id());
       } catch (Exception e) {
         throw asRestApiException("Error looking up user " + req.getUser(), e);
       }
@@ -148,7 +154,7 @@
     list.setMatchRegex(req.getRegex());
     list.setSuggest(req.getSuggest());
     try {
-      return list.apply(tlr);
+      return list.apply(tlr).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list groups", e);
     }
@@ -178,7 +184,7 @@
       for (ListGroupsOption option : r.getOptions()) {
         myQueryGroups.addOption(option);
       }
-      return myQueryGroups.apply(TopLevelResource.INSTANCE);
+      return myQueryGroups.apply(TopLevelResource.INSTANCE).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot query groups", e);
     }
diff --git a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
index 71f7832..95912e4 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
@@ -53,7 +53,7 @@
 
   @Override
   public PluginInfo get() throws RestApiException {
-    return getStatus.apply(resource);
+    return getStatus.apply(resource).value();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/plugins/PluginsImpl.java b/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
index fb2fb27..e45b3e6 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.api.plugins;
 
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.api.plugins.PluginApi;
 import com.google.gerrit.extensions.api.plugins.Plugins;
-import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -59,12 +59,29 @@
     return new ListRequest() {
       @Override
       public SortedMap<String, PluginInfo> getAsMap() throws RestApiException {
-        return listProvider.get().request(this).apply(TopLevelResource.INSTANCE);
+        return listProvider.get().request(this).apply(TopLevelResource.INSTANCE).value();
       }
     };
   }
 
   @Override
+  @Deprecated
+  public PluginApi install(
+      String name, com.google.gerrit.extensions.common.InstallPluginInput input)
+      throws RestApiException {
+    return install(name, convertInput(input));
+  }
+
+  @SuppressWarnings("deprecation")
+  private InstallPluginInput convertInput(
+      com.google.gerrit.extensions.common.InstallPluginInput input) {
+    InstallPluginInput result = new InstallPluginInput();
+    result.url = input.url;
+    result.raw = input.raw;
+    return result;
+  }
+
+  @Override
   public PluginApi install(String name, InstallPluginInput input) throws RestApiException {
     try {
       Response<PluginInfo> created =
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 78b34b8..7def99e 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -46,7 +46,7 @@
   }
 
   private final BranchesCollection branches;
-  private final CreateBranch.Factory createBranchFactory;
+  private final CreateBranch createBranch;
   private final DeleteBranch deleteBranch;
   private final FilesCollection filesCollection;
   private final GetBranch getBranch;
@@ -58,7 +58,7 @@
   @Inject
   BranchApiImpl(
       BranchesCollection branches,
-      CreateBranch.Factory createBranchFactory,
+      CreateBranch createBranch,
       DeleteBranch deleteBranch,
       FilesCollection filesCollection,
       GetBranch getBranch,
@@ -67,7 +67,7 @@
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.branches = branches;
-    this.createBranchFactory = createBranchFactory;
+    this.createBranch = createBranch;
     this.deleteBranch = deleteBranch;
     this.filesCollection = filesCollection;
     this.getBranch = getBranch;
@@ -80,7 +80,7 @@
   @Override
   public BranchApi create(BranchInput input) throws RestApiException {
     try {
-      createBranchFactory.create(ref).apply(project, input);
+      createBranch.apply(project, IdString.fromDecoded(ref), input);
       return this;
     } catch (Exception e) {
       throw asRestApiException("Cannot create branch", e);
@@ -90,7 +90,7 @@
   @Override
   public BranchInfo get() throws RestApiException {
     try {
-      return getBranch.apply(resource());
+      return getBranch.apply(resource()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot read branch", e);
     }
@@ -109,7 +109,7 @@
   public BinaryResult file(String path) throws RestApiException {
     try {
       FileResource resource = filesCollection.parse(resource(), IdString.fromDecoded(path));
-      return getContent.apply(resource);
+      return getContent.apply(resource).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve file", e);
     }
@@ -118,7 +118,7 @@
   @Override
   public List<ReflogEntryInfo> reflog() throws RestApiException {
     try {
-      return getReflog.apply(resource());
+      return getReflog.apply(resource()).value();
     } catch (IOException | PermissionBackendException e) {
       throw new RestApiException("Cannot retrieve reflog", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
index d7c9bc7..22bb076 100644
--- a/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -44,6 +44,6 @@
   @Override
   public ProjectInfo get(boolean recursive) throws RestApiException {
     getChildProject.setRecursive(recursive);
-    return getChildProject.apply(rsrc);
+    return getChildProject.apply(rsrc).value();
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
index a81e0de..1b09e10 100644
--- a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -19,10 +19,12 @@
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
+import com.google.gerrit.server.restapi.project.CommitIncludedIn;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -33,22 +35,36 @@
 
   private final Changes changes;
   private final CherryPickCommit cherryPickCommit;
+  private final CommitIncludedIn includedIn;
   private final CommitResource commitResource;
 
   @Inject
   CommitApiImpl(
-      Changes changes, CherryPickCommit cherryPickCommit, @Assisted CommitResource commitResource) {
+      Changes changes,
+      CherryPickCommit cherryPickCommit,
+      CommitIncludedIn includedIn,
+      @Assisted CommitResource commitResource) {
     this.changes = changes;
     this.cherryPickCommit = cherryPickCommit;
+    this.includedIn = includedIn;
     this.commitResource = commitResource;
   }
 
   @Override
   public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
     try {
-      return changes.id(cherryPickCommit.apply(commitResource, input)._number);
+      return changes.id(cherryPickCommit.apply(commitResource, input).value()._number);
     } catch (Exception e) {
       throw asRestApiException("Cannot cherry pick", e);
     }
   }
+
+  @Override
+  public IncludedInInfo includedIn() throws RestApiException {
+    try {
+      return includedIn.apply(commitResource).value();
+    } catch (Exception e) {
+      throw asRestApiException("Could not extract IncludedIn data", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
index 07924fa..786ab95 100644
--- a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.projects.DashboardApi;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -67,7 +67,7 @@
   @Override
   public DashboardInfo get(boolean inherited) throws RestApiException {
     try {
-      return get.get().setInherited(inherited).apply(resource());
+      return get.get().setInherited(inherited).apply(resource()).value();
     } catch (IOException | PermissionBackendException | ConfigInvalidException e) {
       throw asRestApiException("Cannot read dashboard", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 46d9180..207f4bc 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
 import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
@@ -34,6 +36,7 @@
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.HeadInput;
+import com.google.gerrit.extensions.api.projects.IndexProjectInput;
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -52,6 +55,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.Check;
 import com.google.gerrit.server.restapi.project.CheckAccess;
 import com.google.gerrit.server.restapi.project.ChildProjectsCollection;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -64,8 +68,8 @@
 import com.google.gerrit.server.restapi.project.GetDescription;
 import com.google.gerrit.server.restapi.project.GetHead;
 import com.google.gerrit.server.restapi.project.GetParent;
+import com.google.gerrit.server.restapi.project.Index;
 import com.google.gerrit.server.restapi.project.ListBranches;
-import com.google.gerrit.server.restapi.project.ListChildProjects;
 import com.google.gerrit.server.restapi.project.ListDashboards;
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
@@ -88,7 +92,7 @@
   }
 
   private final PermissionBackend permissionBackend;
-  private final CreateProject.Factory createProjectFactory;
+  private final CreateProject createProject;
   private final ProjectApiImpl.Factory projectApi;
   private final ProjectsCollection projects;
   private final GetDescription getDescription;
@@ -113,16 +117,18 @@
   private final CommitApiImpl.Factory commitApi;
   private final DashboardApiImpl.Factory dashboardApi;
   private final CheckAccess checkAccess;
+  private final Check check;
   private final Provider<ListDashboards> listDashboards;
   private final GetHead getHead;
   private final SetHead setHead;
   private final GetParent getParent;
   private final SetParent setParent;
+  private final Index index;
 
   @AssistedInject
   ProjectApiImpl(
       PermissionBackend permissionBackend,
-      CreateProject.Factory createProjectFactory,
+      CreateProject createProject,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
       GetDescription getDescription,
@@ -145,15 +151,17 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
       GetParent getParent,
       SetParent setParent,
+      Index index,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
-        createProjectFactory,
+        createProject,
         projectApi,
         projects,
         getDescription,
@@ -177,18 +185,20 @@
         commitApi,
         dashboardApi,
         checkAccess,
+        check,
         listDashboards,
         getHead,
         setHead,
         getParent,
         setParent,
+        index,
         null);
   }
 
   @AssistedInject
   ProjectApiImpl(
       PermissionBackend permissionBackend,
-      CreateProject.Factory createProjectFactory,
+      CreateProject createProject,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
       GetDescription getDescription,
@@ -211,15 +221,17 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
       GetParent getParent,
       SetParent setParent,
+      Index index,
       @Assisted String name) {
     this(
         permissionBackend,
-        createProjectFactory,
+        createProject,
         projectApi,
         projects,
         getDescription,
@@ -243,17 +255,19 @@
         commitApi,
         dashboardApi,
         checkAccess,
+        check,
         listDashboards,
         getHead,
         setHead,
         getParent,
         setParent,
+        index,
         name);
   }
 
   private ProjectApiImpl(
       PermissionBackend permissionBackend,
-      CreateProject.Factory createProjectFactory,
+      CreateProject createProject,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
       GetDescription getDescription,
@@ -277,14 +291,16 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
       GetParent getParent,
       SetParent setParent,
+      Index index,
       String name) {
     this.permissionBackend = permissionBackend;
-    this.createProjectFactory = createProjectFactory;
+    this.createProject = createProject;
     this.projectApi = projectApi;
     this.projects = projects;
     this.getDescription = getDescription;
@@ -308,12 +324,14 @@
     this.createAccessChange = createAccessChange;
     this.dashboardApi = dashboardApi;
     this.checkAccess = checkAccess;
+    this.check = check;
     this.listDashboards = listDashboards;
     this.getHead = getHead;
     this.setHead = setHead;
     this.getParent = getParent;
     this.setParent = setParent;
     this.name = name;
+    this.index = index;
   }
 
   @Override
@@ -330,9 +348,10 @@
       if (in.name != null && !name.equals(in.name)) {
         throw new BadRequestException("name must match input.name");
       }
-      CreateProject impl = createProjectFactory.create(name);
-      permissionBackend.currentUser().checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
-      impl.apply(TopLevelResource.INSTANCE, in);
+      permissionBackend
+          .currentUser()
+          .checkAny(GlobalPermission.fromAnnotation(createProject.getClass()));
+      createProject.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(name), in);
       return projectApi.create(projects.parse(name));
     } catch (Exception e) {
       throw asRestApiException("Cannot create project: " + e.getMessage(), e);
@@ -349,31 +368,22 @@
 
   @Override
   public String description() throws RestApiException {
-    return getDescription.apply(checkExists());
+    return getDescription.apply(checkExists()).value();
   }
 
   @Override
   public ProjectAccessInfo access() throws RestApiException {
     try {
-      return getAccess.apply(checkExists());
+      return getAccess.apply(checkExists()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get access rights", e);
     }
   }
 
   @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);
+      return setAccess.apply(checkExists(), p).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot put access rights", e);
     }
@@ -389,6 +399,24 @@
   }
 
   @Override
+  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+    try {
+      return checkAccess.apply(checkExists(), in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check access rights", e);
+    }
+  }
+
+  @Override
+  public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException {
+    try {
+      return check.apply(checkExists(), in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check project", e);
+    }
+  }
+
+  @Override
   public void description(DescriptionInput in) throws RestApiException {
     try {
       putDescription.apply(checkExists(), in);
@@ -399,13 +427,13 @@
 
   @Override
   public ConfigInfo config() throws RestApiException {
-    return getConfig.apply(checkExists());
+    return getConfig.apply(checkExists()).value();
   }
 
   @Override
   public ConfigInfo config(ConfigInput in) throws RestApiException {
     try {
-      return putConfig.apply(checkExists(), in);
+      return putConfig.apply(checkExists(), in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list tags", e);
     }
@@ -417,7 +445,7 @@
       @Override
       public List<BranchInfo> get() throws RestApiException {
         try {
-          return listBranches.get().request(this).apply(checkExists());
+          return listBranches.get().request(this).apply(checkExists()).value();
         } catch (Exception e) {
           throw asRestApiException("Cannot list branches", e);
         }
@@ -431,7 +459,7 @@
       @Override
       public List<TagInfo> get() throws RestApiException {
         try {
-          return listTags.get().request(this).apply(checkExists());
+          return listTags.get().request(this).apply(checkExists()).value();
         } catch (Exception e) {
           throw asRestApiException("Cannot list tags", e);
         }
@@ -446,10 +474,17 @@
 
   @Override
   public List<ProjectInfo> children(boolean recursive) throws RestApiException {
-    ListChildProjects list = children.list();
-    list.setRecursive(recursive);
     try {
-      return list.apply(checkExists());
+      return children.list().withRecursive(recursive).apply(checkExists()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list children", e);
+    }
+  }
+
+  @Override
+  public List<ProjectInfo> children(int limit) throws RestApiException {
+    try {
+      return children.list().withLimit(limit).apply(checkExists()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot list children", e);
     }
@@ -539,7 +574,7 @@
       @Override
       public List<DashboardInfo> get() throws RestApiException {
         try {
-          List<?> r = listDashboards.get().apply(checkExists());
+          List<?> r = listDashboards.get().apply(checkExists()).value();
           if (r.isEmpty()) {
             return Collections.emptyList();
           }
@@ -557,7 +592,7 @@
   @Override
   public String head() throws RestApiException {
     try {
-      return getHead.apply(checkExists());
+      return getHead.apply(checkExists()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get HEAD", e);
     }
@@ -577,7 +612,7 @@
   @Override
   public String parent() throws RestApiException {
     try {
-      return getParent.apply(checkExists());
+      return getParent.apply(checkExists()).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get parent", e);
     }
@@ -594,6 +629,17 @@
     }
   }
 
+  @Override
+  public void index(boolean indexChildren) throws RestApiException {
+    try {
+      IndexProjectInput input = new IndexProjectInput();
+      input.indexChildren = indexChildren;
+      index.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index project", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 4552e7a..5d25d1a 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -16,20 +16,19 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.Projects;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.project.ListProjects;
 import com.google.gerrit.server.restapi.project.ListProjects.FilterType;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.restapi.project.QueryProjects;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -118,9 +117,6 @@
       case CODE:
         type = FilterType.CODE;
         break;
-      case PARENT_CANDIDATES:
-        type = FilterType.PARENT_CANDIDATES;
-        break;
       case PERMISSIONS:
         type = FilterType.PERMISSIONS;
         break;
@@ -153,13 +149,13 @@
 
   private List<ProjectInfo> query(QueryRequest r) throws RestApiException {
     try {
-      QueryProjects myQueryProjects = queryProvider.get();
-      myQueryProjects.setQuery(r.getQuery());
-      myQueryProjects.setLimit(r.getLimit());
-      myQueryProjects.setStart(r.getStart());
-
-      return myQueryProjects.apply(TopLevelResource.INSTANCE);
-    } catch (OrmException e) {
+      return queryProvider
+          .get()
+          .withQuery(r.getQuery())
+          .withLimit(r.getLimit())
+          .withStart(r.getStart())
+          .apply();
+    } catch (StorageException e) {
       throw new RestApiException("Cannot query projects", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 03e2162..005486a 100644
--- a/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -39,7 +39,7 @@
   }
 
   private final ListTags listTags;
-  private final CreateTag.Factory createTagFactory;
+  private final CreateTag createTag;
   private final DeleteTag deleteTag;
   private final TagsCollection tags;
   private final String ref;
@@ -48,13 +48,13 @@
   @Inject
   TagApiImpl(
       ListTags listTags,
-      CreateTag.Factory createTagFactory,
+      CreateTag createTag,
       DeleteTag deleteTag,
       TagsCollection tags,
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.listTags = listTags;
-    this.createTagFactory = createTagFactory;
+    this.createTag = createTag;
     this.deleteTag = deleteTag;
     this.tags = tags;
     this.project = project;
@@ -64,7 +64,7 @@
   @Override
   public TagApi create(TagInput input) throws RestApiException {
     try {
-      createTagFactory.create(ref).apply(project, input);
+      createTag.apply(project, IdString.fromDecoded(ref), input);
       return this;
     } catch (Exception e) {
       throw asRestApiException("Cannot create tag", e);
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
index f3393c1..34864f9 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
@@ -43,9 +45,9 @@
   @Override
   public final int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(n));
+    Optional<InternalGroup> group = groupCache.get(AccountGroup.nameKey(n));
     if (!group.isPresent()) {
-      throw new CmdLineException(owner, "Group \"" + n + "\" does not exist");
+      throw new CmdLineException(owner, localizable("Group \"%s\" does not exist"), n);
     }
     setter.addValue(group.get().getId());
     return 1;
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index 4d589a0..628dbbf 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -51,7 +53,7 @@
   @Override
   public final int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    AccountGroup.UUID uuid = new AccountGroup.UUID(n);
+    AccountGroup.UUID uuid = AccountGroup.uuid(n);
     if (groupBackend.handles(uuid)) {
       GroupDescription.Basic d = groupBackend.get(uuid);
       if (d != null) {
@@ -77,7 +79,7 @@
 
     GroupReference group = GroupBackends.findExactSuggestion(groupBackend, n);
     if (group == null) {
-      throw new CmdLineException(owner, "Group \"" + n + "\" does not exist");
+      throw new CmdLineException(owner, localizable("Group \"%s\" does not exist"), n);
     }
     setter.addValue(group.getUUID());
     return 1;
diff --git a/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index c7d3f73..36ae88a 100644
--- a/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -22,7 +26,6 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -58,10 +61,9 @@
     String token = params.getParameter(0);
     Account.Id accountId;
     try {
-      Account a = accountResolver.find(token);
-      if (a != null) {
-        accountId = a.getId();
-      } else {
+      try {
+        accountId = accountResolver.resolve(token).asUnique().getAccount().id();
+      } catch (UnprocessableEntityException e) {
         switch (authType) {
           case HTTP_LDAP:
           case CLIENT_SSL_CERT_LDAP:
@@ -76,11 +78,11 @@
           case OPENID:
           case OPENID_SSO:
           default:
-            throw new CmdLineException(owner, "user \"" + token + "\" not found");
+            throw new CmdLineException(owner, localizable("user \"%s\" not found"), token);
         }
       }
-    } catch (OrmException e) {
-      throw new CmdLineException(owner, "database is down");
+    } catch (StorageException e) {
+      throw new CmdLineException(owner, localizable("database is down"));
     } catch (IOException e) {
       throw new CmdLineException(owner, "Failed to load account", e);
     } catch (ConfigInvalidException e) {
@@ -92,7 +94,7 @@
 
   private Account.Id createAccountByLdap(String user) throws CmdLineException, IOException {
     if (!ExternalId.isValidUsername(user)) {
-      throw new CmdLineException(owner, "user \"" + user + "\" not found");
+      throw new CmdLineException(owner, localizable("user \"%s\" not found"), user);
     }
 
     try {
@@ -100,7 +102,7 @@
       req.setSkipAuthentication(true);
       return accountManager.authenticate(req).getAccountId();
     } catch (AccountException e) {
-      throw new CmdLineException(owner, "user \"" + user + "\" not found");
+      throw new CmdLineException(owner, localizable("user \"%s\" not found"), user);
     }
   }
 
diff --git a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
index adb5f63..a91883d 100644
--- a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.common.base.Splitter;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -52,24 +54,24 @@
     final List<String> tokens = Splitter.on(',').splitToList(token);
     if (tokens.size() != 3) {
       throw new CmdLineException(
-          owner, "change should be specified as <project>,<branch>,<change-id>");
+          owner, localizable("change should be specified as <project>,<branch>,<change-id>"));
     }
 
     try {
       final Change.Key key = Change.Key.parse(tokens.get(2));
-      final Project.NameKey project = new Project.NameKey(tokens.get(0));
-      final Branch.NameKey branch = new Branch.NameKey(project, tokens.get(1));
+      final Project.NameKey project = Project.nameKey(tokens.get(0));
+      final BranchNameKey branch = BranchNameKey.create(project, tokens.get(1));
       for (ChangeData cd : queryProvider.get().byBranchKey(branch, key)) {
         setter.addValue(cd.getId());
         return 1;
       }
     } catch (IllegalArgumentException e) {
-      throw new CmdLineException(owner, "Change-Id is not valid");
-    } catch (OrmException e) {
-      throw new CmdLineException(owner, "Database error: " + e.getMessage());
+      throw new CmdLineException(owner, localizable("Change-Id is not valid"));
+    } catch (StorageException e) {
+      throw new CmdLineException(owner, localizable("Database error: %s"), e.getMessage());
     }
 
-    throw new CmdLineException(owner, "\"" + token + "\": change not found");
+    throw new CmdLineException(owner, localizable("\"%s\": change not found"), token);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java b/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
index cb70abf..84c1d88 100644
--- a/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -41,7 +43,7 @@
     try {
       id = PatchSet.Id.parse(token);
     } catch (IllegalArgumentException e) {
-      throw new CmdLineException(owner, "\"" + token + "\" is not a valid patch set");
+      throw new CmdLineException(owner, localizable("\"%s\" is not a valid patch set"), token);
     }
 
     setter.addValue(id);
diff --git a/java/com/google/gerrit/server/args4j/ProjectHandler.java b/java/com/google/gerrit/server/args4j/ProjectHandler.java
index 7872812..a4af62d 100644
--- a/java/com/google/gerrit/server/args4j/ProjectHandler.java
+++ b/java/com/google/gerrit/server/args4j/ProjectHandler.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.ProjectUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -70,13 +72,13 @@
     }
 
     String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
-    Project.NameKey nameKey = new Project.NameKey(nameWithoutSuffix);
+    Project.NameKey nameKey = Project.nameKey(nameWithoutSuffix);
 
     ProjectState state;
     try {
       state = projectCache.checkedGet(nameKey);
       if (state == null) {
-        throw new CmdLineException(owner, String.format("project %s not found", nameWithoutSuffix));
+        throw new CmdLineException(owner, localizable("project %s not found"), nameWithoutSuffix);
       }
       // Hidden projects(permitsRead = false) should only be accessible by the project owners.
       // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
@@ -86,10 +88,12 @@
           state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
       permissionBackend.currentUser().project(nameKey).check(permissionToCheck);
     } catch (AuthException e) {
-      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
+      throw new CmdLineException(
+          owner, localizable(new NoSuchProjectException(nameKey).getMessage()));
     } catch (PermissionBackendException | IOException e) {
       logger.atWarning().withCause(e).log("Cannot load project %s", nameWithoutSuffix);
-      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
+      throw new CmdLineException(
+          owner, localizable(new NoSuchProjectException(nameKey).getMessage()));
     }
 
     setter.addValue(state);
diff --git a/java/com/google/gerrit/server/args4j/SocketAddressHandler.java b/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
index 4325c00..198cf67 100644
--- a/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
+++ b/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -41,7 +43,7 @@
     try {
       setter.addValue(SocketUtil.parse(token, 0));
     } catch (IllegalArgumentException e) {
-      throw new CmdLineException(owner, e.getMessage());
+      throw new CmdLineException(owner, localizable(e.getMessage()));
     }
     return 1;
   }
diff --git a/java/com/google/gerrit/server/audit/AuditEvent.java b/java/com/google/gerrit/server/audit/AuditEvent.java
deleted file mode 100644
index 4abdfd9..0000000
--- a/java/com/google/gerrit/server/audit/AuditEvent.java
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.audit;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.server.CurrentUser;
-
-public class AuditEvent {
-
-  public static final String UNKNOWN_SESSION_ID = "000000000000000000000000000";
-  protected static final ImmutableListMultimap<String, ?> EMPTY_PARAMS = ImmutableListMultimap.of();
-
-  public final String sessionId;
-  public final CurrentUser who;
-  public final long when;
-  public final String what;
-  public final ListMultimap<String, ?> params;
-  public final Object result;
-  public final long timeAtStart;
-  public final long elapsed;
-  public final UUID uuid;
-
-  @AutoValue
-  public abstract static class UUID {
-    private static UUID create() {
-      return new AutoValue_AuditEvent_UUID(
-          String.format("audit:%s", java.util.UUID.randomUUID().toString()));
-    }
-
-    public abstract String uuid();
-  }
-
-  /**
-   * Creates a new audit event with results
-   *
-   * @param sessionId session id the event belongs to
-   * @param who principal that has generated the event
-   * @param what object of the event
-   * @param when time-stamp of when the event started
-   * @param params parameters of the event
-   * @param result result of the event
-   */
-  public AuditEvent(
-      String sessionId,
-      CurrentUser who,
-      String what,
-      long when,
-      ListMultimap<String, ?> params,
-      Object result) {
-    Preconditions.checkNotNull(what, "what is a mandatory not null param !");
-
-    this.sessionId = MoreObjects.firstNonNull(sessionId, UNKNOWN_SESSION_ID);
-    this.who = who;
-    this.what = what;
-    this.when = when;
-    this.timeAtStart = this.when;
-    this.params = MoreObjects.firstNonNull(params, EMPTY_PARAMS);
-    this.uuid = UUID.create();
-    this.result = result;
-    this.elapsed = TimeUtil.nowMs() - timeAtStart;
-  }
-
-  @Override
-  public int hashCode() {
-    return uuid.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (this == obj) {
-      return true;
-    }
-    if (obj == null) {
-      return false;
-    }
-    if (getClass() != obj.getClass()) {
-      return false;
-    }
-
-    AuditEvent other = (AuditEvent) obj;
-    return this.uuid.equals(other.uuid);
-  }
-
-  @Override
-  public String toString() {
-    return String.format(
-        "AuditEvent UUID:%s, SID:%s, TS:%d, who:%s, what:%s",
-        uuid.uuid(), sessionId, when, who, what);
-  }
-}
diff --git a/java/com/google/gerrit/server/audit/AuditListener.java b/java/com/google/gerrit/server/audit/AuditListener.java
index 3f8c298..f555bbd 100644
--- a/java/com/google/gerrit/server/audit/AuditListener.java
+++ b/java/com/google/gerrit/server/audit/AuditListener.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.audit;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.AuditEvent;
 
 @ExtensionPoint
 public interface AuditListener {
diff --git a/java/com/google/gerrit/server/audit/AuditModule.java b/java/com/google/gerrit/server/audit/AuditModule.java
index 0052aaa..df037b6 100644
--- a/java/com/google/gerrit/server/audit/AuditModule.java
+++ b/java/com/google/gerrit/server/audit/AuditModule.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.audit.group.GroupAuditListener;
+import com.google.gerrit.server.group.GroupAuditService;
 import com.google.inject.AbstractModule;
 
 public class AuditModule extends AbstractModule {
@@ -24,6 +25,6 @@
   protected void configure() {
     DynamicSet.setOf(binder(), AuditListener.class);
     DynamicSet.setOf(binder(), GroupAuditListener.class);
-    bind(AuditService.class);
+    bind(GroupAuditService.class).to(AuditService.class);
   }
 }
diff --git a/java/com/google/gerrit/server/audit/AuditService.java b/java/com/google/gerrit/server/audit/AuditService.java
index 9528670..425e22a 100644
--- a/java/com/google/gerrit/server/audit/AuditService.java
+++ b/java/com/google/gerrit/server/audit/AuditService.java
@@ -15,99 +15,77 @@
 package com.google.gerrit.server.audit;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.AuditEvent;
 import com.google.gerrit.server.audit.group.GroupAuditListener;
 import com.google.gerrit.server.audit.group.GroupMemberAuditEvent;
 import com.google.gerrit.server.audit.group.GroupSubgroupAuditEvent;
+import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
 
 @Singleton
-public class AuditService {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final DynamicSet<AuditListener> auditListeners;
-  private final DynamicSet<GroupAuditListener> groupAuditListeners;
+public class AuditService implements GroupAuditService {
+  private final PluginSetContext<AuditListener> auditListeners;
+  private final PluginSetContext<GroupAuditListener> groupAuditListeners;
 
   @Inject
   public AuditService(
-      DynamicSet<AuditListener> auditListeners,
-      DynamicSet<GroupAuditListener> groupAuditListeners) {
+      PluginSetContext<AuditListener> auditListeners,
+      PluginSetContext<GroupAuditListener> groupAuditListeners) {
     this.auditListeners = auditListeners;
     this.groupAuditListeners = groupAuditListeners;
   }
 
+  @Override
   public void dispatch(AuditEvent action) {
-    for (AuditListener auditListener : auditListeners) {
-      auditListener.onAuditableAction(action);
-    }
+    auditListeners.runEach(l -> l.onAuditableAction(action));
   }
 
+  @Override
   public void dispatchAddMembers(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> addedMembers,
       Timestamp addedOn) {
-    for (GroupAuditListener auditListener : groupAuditListeners) {
-      try {
-        GroupMemberAuditEvent event =
-            GroupMemberAuditEvent.create(actor, updatedGroup, addedMembers, addedOn);
-        auditListener.onAddMembers(event);
-      } catch (RuntimeException e) {
-        logger.atSevere().withCause(e).log("failed to log add accounts to group event");
-      }
-    }
+    GroupMemberAuditEvent event =
+        GroupMemberAuditEvent.create(actor, updatedGroup, addedMembers, addedOn);
+    groupAuditListeners.runEach(l -> l.onAddMembers(event));
   }
 
+  @Override
   public void dispatchDeleteMembers(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> deletedMembers,
       Timestamp deletedOn) {
-    for (GroupAuditListener auditListener : groupAuditListeners) {
-      try {
-        GroupMemberAuditEvent event =
-            GroupMemberAuditEvent.create(actor, updatedGroup, deletedMembers, deletedOn);
-        auditListener.onDeleteMembers(event);
-      } catch (RuntimeException e) {
-        logger.atSevere().withCause(e).log("failed to log delete accounts from group event");
-      }
-    }
+    GroupMemberAuditEvent event =
+        GroupMemberAuditEvent.create(actor, updatedGroup, deletedMembers, deletedOn);
+    groupAuditListeners.runEach(l -> l.onDeleteMembers(event));
   }
 
+  @Override
   public void dispatchAddSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> addedSubgroups,
       Timestamp addedOn) {
-    for (GroupAuditListener auditListener : groupAuditListeners) {
-      try {
-        GroupSubgroupAuditEvent event =
-            GroupSubgroupAuditEvent.create(actor, updatedGroup, addedSubgroups, addedOn);
-        auditListener.onAddSubgroups(event);
-      } catch (RuntimeException e) {
-        logger.atSevere().withCause(e).log("failed to log add groups to group event");
-      }
-    }
+    GroupSubgroupAuditEvent event =
+        GroupSubgroupAuditEvent.create(actor, updatedGroup, addedSubgroups, addedOn);
+    groupAuditListeners.runEach(l -> l.onAddSubgroups(event));
   }
 
+  @Override
   public void dispatchDeleteSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> deletedSubgroups,
       Timestamp deletedOn) {
-    for (GroupAuditListener auditListener : groupAuditListeners) {
-      try {
-        GroupSubgroupAuditEvent event =
-            GroupSubgroupAuditEvent.create(actor, updatedGroup, deletedSubgroups, deletedOn);
-        auditListener.onDeleteSubgroups(event);
-      } catch (RuntimeException e) {
-        logger.atSevere().withCause(e).log("failed to log delete groups from group event");
-      }
-    }
+    GroupSubgroupAuditEvent event =
+        GroupSubgroupAuditEvent.create(actor, updatedGroup, deletedSubgroups, deletedOn);
+    groupAuditListeners.runEach(l -> l.onDeleteSubgroups(event));
   }
 }
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
new file mode 100644
index 0000000..71cd3a1
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -0,0 +1,94 @@
+java_library(
+    name = "audit",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/prettify:server",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/util/cli",
+        "//java/com/google/gerrit/util/ssl",
+        "//java/org/apache/commons/net",
+        "//lib:args4j",
+        "//lib:autolink",
+        "//lib:automaton",
+        "//lib:blame-cache",
+        "//lib:flexmark",
+        "//lib:flexmark-ext-abbreviation",
+        "//lib:flexmark-ext-anchorlink",
+        "//lib:flexmark-ext-autolink",
+        "//lib:flexmark-ext-definition",
+        "//lib:flexmark-ext-emoji",
+        "//lib:flexmark-ext-escaped-character",
+        "//lib:flexmark-ext-footnotes",
+        "//lib:flexmark-ext-gfm-issues",
+        "//lib:flexmark-ext-gfm-strikethrough",
+        "//lib:flexmark-ext-gfm-tables",
+        "//lib:flexmark-ext-gfm-tasklist",
+        "//lib:flexmark-ext-gfm-users",
+        "//lib:flexmark-ext-ins",
+        "//lib:flexmark-ext-jekyll-front-matter",
+        "//lib:flexmark-ext-superscript",
+        "//lib:flexmark-ext-tables",
+        "//lib:flexmark-ext-toc",
+        "//lib:flexmark-ext-typographic",
+        "//lib:flexmark-ext-wikilink",
+        "//lib:flexmark-ext-yaml-front-matter",
+        "//lib:flexmark-formatter",
+        "//lib:flexmark-html-parser",
+        "//lib:flexmark-profile-pegdown",
+        "//lib:flexmark-util",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:jsch",
+        "//lib:juniversalchardet",
+        "//lib:mime-util",
+        "//lib:protobuf",
+        "//lib:servlet-api-3_1",
+        "//lib:soy",
+        "//lib:tukaani-xz",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/bouncycastle:bcpkix-neverlink",
+        "//lib/bouncycastle:bcprov-neverlink",
+        "//lib/commons:codec",
+        "//lib/commons:compress",
+        "//lib/commons:dbcp",
+        "//lib/commons:lang",
+        "//lib/commons:net",
+        "//lib/commons:validator",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jsoup",
+        "//lib/log:jsonevent-layout",
+        "//lib/log:log4j",
+        "//lib/lucene:lucene-analyzers-common",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/lucene:lucene-queryparser",
+        "//lib/mime4j:core",
+        "//lib/mime4j:dom",
+        "//lib/ow2:ow2-asm",
+        "//lib/ow2:ow2-asm-tree",
+        "//lib/ow2:ow2-asm-util",
+        "//lib/prolog:runtime",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/server/audit/ExtendedHttpAuditEvent.java b/java/com/google/gerrit/server/audit/ExtendedHttpAuditEvent.java
index 29629f0..c981ba7 100644
--- a/java/com/google/gerrit/server/audit/ExtendedHttpAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/ExtendedHttpAuditEvent.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.audit;
 
-import com.google.common.base.Preconditions;
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -62,7 +63,7 @@
         input,
         status,
         result);
-    this.httpRequest = Preconditions.checkNotNull(httpRequest);
+    this.httpRequest = requireNonNull(httpRequest);
     this.resource = resource;
     this.view = view;
   }
diff --git a/java/com/google/gerrit/server/audit/HttpAuditEvent.java b/java/com/google/gerrit/server/audit/HttpAuditEvent.java
index 11a6b63..5ea2485 100644
--- a/java/com/google/gerrit/server/audit/HttpAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/HttpAuditEvent.java
@@ -14,6 +14,7 @@
 package com.google.gerrit.server.audit;
 
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.server.AuditEvent;
 import com.google.gerrit.server.CurrentUser;
 
 public class HttpAuditEvent extends AuditEvent {
diff --git a/java/com/google/gerrit/server/audit/RpcAuditEvent.java b/java/com/google/gerrit/server/audit/RpcAuditEvent.java
deleted file mode 100644
index 6c53bb2..0000000
--- a/java/com/google/gerrit/server/audit/RpcAuditEvent.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.audit;
-
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.server.CurrentUser;
-
-public class RpcAuditEvent extends HttpAuditEvent {
-
-  /**
-   * Creates a new audit event with results
-   *
-   * @param sessionId session id the event belongs to
-   * @param who principal that has generated the event
-   * @param what object of the event
-   * @param when time-stamp of when the event started
-   * @param params parameters of the event
-   * @param httpMethod HTTP method
-   * @param input input
-   * @param status HTTP status
-   * @param result result of the event
-   */
-  public RpcAuditEvent(
-      String sessionId,
-      CurrentUser who,
-      String what,
-      long when,
-      ListMultimap<String, ?> params,
-      String httpMethod,
-      Object input,
-      int status,
-      Object result) {
-    super(sessionId, who, what, when, params, httpMethod, input, status, result);
-  }
-}
diff --git a/java/com/google/gerrit/server/audit/SshAuditEvent.java b/java/com/google/gerrit/server/audit/SshAuditEvent.java
index 89f01ac..fee959e 100644
--- a/java/com/google/gerrit/server/audit/SshAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/SshAuditEvent.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.audit;
 
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.server.AuditEvent;
 import com.google.gerrit.server.CurrentUser;
 
 public class SshAuditEvent extends AuditEvent {
diff --git a/java/com/google/gerrit/server/auth/AuthUser.java b/java/com/google/gerrit/server/auth/AuthUser.java
index 71f29a4..987f086 100644
--- a/java/com/google/gerrit/server/auth/AuthUser.java
+++ b/java/com/google/gerrit/server/auth/AuthUser.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
@@ -48,7 +48,7 @@
    * @param username the name of the authenticated user.
    */
   public AuthUser(UUID uuid, @Nullable String username) {
-    this.uuid = checkNotNull(uuid);
+    this.uuid = requireNonNull(uuid);
     this.username = username;
   }
 
diff --git a/java/com/google/gerrit/server/auth/UniversalAuthBackend.java b/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
index af9c51b..4e93ff2 100644
--- a/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
+++ b/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.auth;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
@@ -25,10 +25,10 @@
 /** Universal implementation of the AuthBackend that works with the injected set of AuthBackends. */
 @Singleton
 public final class UniversalAuthBackend implements AuthBackend {
-  private final DynamicSet<AuthBackend> authBackends;
+  private final PluginSetContext<AuthBackend> authBackends;
 
   @Inject
-  UniversalAuthBackend(DynamicSet<AuthBackend> authBackends) {
+  UniversalAuthBackend(PluginSetContext<AuthBackend> authBackends) {
     this.authBackends = authBackends;
   }
 
@@ -36,15 +36,16 @@
   public AuthUser authenticate(AuthRequest request) throws AuthException {
     List<AuthUser> authUsers = new ArrayList<>();
     List<AuthException> authExs = new ArrayList<>();
-    for (AuthBackend backend : authBackends) {
-      try {
-        authUsers.add(checkNotNull(backend.authenticate(request)));
-      } catch (MissingCredentialsException ex) {
-        // Not handled by this backend.
-      } catch (AuthException ex) {
-        authExs.add(ex);
-      }
-    }
+    authBackends.runEach(
+        backend -> {
+          try {
+            authUsers.add(requireNonNull(backend.authenticate(request)));
+          } catch (MissingCredentialsException ex) {
+            // Not handled by this backend.
+          } catch (AuthException ex) {
+            authExs.add(ex);
+          }
+        });
 
     // Handle the valid responses
     if (authUsers.size() == 1) {
diff --git a/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/server/auth/ldap/Helper.java
index a53a8c2..bafee04 100644
--- a/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -73,6 +73,7 @@
   private final String password;
   private final String referral;
   private final boolean startTls;
+  private final boolean supportAnonymous;
   private final boolean sslVerify;
   private final String authentication;
   private volatile LdapSchema ldapSchema;
@@ -91,6 +92,7 @@
     this.password = LdapRealm.optional(config, "password", "");
     this.referral = LdapRealm.optional(config, "referral", "ignore");
     this.startTls = config.getBoolean("ldap", "startTls", false);
+    this.supportAnonymous = config.getBoolean("ldap", "supportAnonymous", true);
     this.sslVerify = config.getBoolean("ldap", "sslverify", true);
     this.groupsVisibleToAll = config.getBoolean("ldap", "groupsVisibleToAll", false);
     this.authentication = LdapRealm.optional(config, "authentication", "simple");
@@ -170,8 +172,15 @@
     if ("GSSAPI".equals(authentication)) {
       return kerberosOpen(env);
     }
+
+    if (!supportAnonymous && username != null) {
+      env.put(Context.SECURITY_PRINCIPAL, username);
+      env.put(Context.SECURITY_CREDENTIALS, password);
+    }
+
     LdapContext ctx = createContext(env);
-    if (username != null) {
+
+    if (supportAnonymous && username != null) {
       ctx.addToEnvironment(Context.SECURITY_PRINCIPAL, username);
       ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, password);
       ctx.reconnect(null);
@@ -186,13 +195,7 @@
     Subject subject = ctx.getSubject();
     try {
       return Subject.doAs(
-          subject,
-          new PrivilegedExceptionAction<DirContext>() {
-            @Override
-            public DirContext run() throws IOException, NamingException {
-              return createContext(env);
-            }
-          });
+          subject, (PrivilegedExceptionAction<DirContext>) () -> createContext(env));
     } catch (PrivilegedActionException e) {
       Throwables.throwIfInstanceOf(e.getException(), IOException.class);
       Throwables.throwIfInstanceOf(e.getException(), NamingException.class);
@@ -207,12 +210,23 @@
   DirContext authenticate(String dn, String password) throws AccountException {
     final Properties env = createContextProperties();
     try {
+      env.put(Context.REFERRAL, referral);
+
+      if (!supportAnonymous) {
+        env.put(Context.SECURITY_AUTHENTICATION, "simple");
+        env.put(Context.SECURITY_PRINCIPAL, dn);
+        env.put(Context.SECURITY_CREDENTIALS, password);
+      }
+
       LdapContext ctx = createContext(env);
-      ctx.addToEnvironment(Context.SECURITY_AUTHENTICATION, "simple");
-      ctx.addToEnvironment(Context.SECURITY_PRINCIPAL, dn);
-      ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, password);
-      ctx.addToEnvironment(Context.REFERRAL, referral);
-      ctx.reconnect(null);
+
+      if (supportAnonymous) {
+        ctx.addToEnvironment(Context.SECURITY_AUTHENTICATION, "simple");
+        ctx.addToEnvironment(Context.SECURITY_PRINCIPAL, dn);
+        ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, password);
+        ctx.reconnect(null);
+      }
+
       return ctx;
     } catch (IOException | NamingException e) {
       throw new AuthenticationFailedException("Incorrect username or password", e);
@@ -307,7 +321,7 @@
 
     final Set<AccountGroup.UUID> actual = new HashSet<>();
     for (String dn : groupDNs) {
-      actual.add(new AccountGroup.UUID(LDAP_UUID + dn));
+      actual.add(AccountGroup.uuid(LDAP_UUID + dn));
     }
 
     if (actual.isEmpty()) {
@@ -402,7 +416,7 @@
                   groupBase,
                   groupScope,
                   new ParameterizedString(groupMemberPattern),
-                  Collections.<String>emptySet());
+                  Collections.emptySet());
           if (groupMemberQuery.getParameters().isEmpty()) {
             throw new IllegalArgumentException("No variables in ldap.groupMemberPattern");
           }
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index c338cd3..2433f67 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -52,6 +53,7 @@
 import javax.naming.ldap.LdapName;
 import javax.naming.ldap.Rdn;
 import javax.security.auth.login.LoginException;
+import org.eclipse.jgit.lib.Config;
 
 /** Implementation of GroupBackend for the LDAP group system. */
 public class LdapGroupBackend implements GroupBackend {
@@ -65,6 +67,7 @@
   private final LoadingCache<String, Boolean> existsCache;
   private final ProjectCache projectCache;
   private final Provider<CurrentUser> userProvider;
+  private final Config gerritConfig;
 
   @Inject
   LdapGroupBackend(
@@ -72,12 +75,14 @@
       @Named(GROUP_CACHE) LoadingCache<String, Set<AccountGroup.UUID>> membershipCache,
       @Named(GROUP_EXIST_CACHE) LoadingCache<String, Boolean> existsCache,
       ProjectCache projectCache,
-      Provider<CurrentUser> userProvider) {
+      Provider<CurrentUser> userProvider,
+      @GerritServerConfig Config gerritConfig) {
     this.helper = helper;
     this.membershipCache = membershipCache;
     this.projectCache = projectCache;
     this.existsCache = existsCache;
     this.userProvider = userProvider;
+    this.gerritConfig = gerritConfig;
   }
 
   private boolean isLdapUUID(AccountGroup.UUID uuid) {
@@ -87,7 +92,7 @@
   private static GroupReference groupReference(ParameterizedString p, LdapQuery.Result res)
       throws NamingException {
     return new GroupReference(
-        new AccountGroup.UUID(LDAP_UUID + res.getDN()), LDAP_NAME + LdapRealm.apply(p, res));
+        AccountGroup.uuid(LDAP_UUID + res.getDN()), LDAP_NAME + LdapRealm.apply(p, res));
   }
 
   private static String cnFor(String dn) {
@@ -159,7 +164,7 @@
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(name);
+    AccountGroup.UUID uuid = AccountGroup.uuid(name);
     if (isLdapUUID(uuid)) {
       GroupDescription.Basic g = get(uuid);
       if (g == null) {
@@ -178,7 +183,7 @@
     if (id == null) {
       return GroupMembership.EMPTY;
     }
-    return new LdapGroupMembership(membershipCache, projectCache, id);
+    return new LdapGroupMembership(membershipCache, projectCache, id, gerritConfig);
   }
 
   private static String findId(Collection<ExternalId> extIds) {
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java b/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
index 7f0bd7b..f5406c2 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
@@ -22,20 +22,24 @@
 import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Config;
 
 class LdapGroupMembership implements GroupMembership {
   private final LoadingCache<String, Set<AccountGroup.UUID>> membershipCache;
   private final ProjectCache projectCache;
   private final String id;
+  private final boolean guessRelevantGroups;
   private GroupMembership membership;
 
   LdapGroupMembership(
       LoadingCache<String, Set<AccountGroup.UUID>> membershipCache,
       ProjectCache projectCache,
-      String id) {
+      String id,
+      Config gerritConfig) {
     this.membershipCache = membershipCache;
     this.projectCache = projectCache;
     this.id = id;
+    this.guessRelevantGroups = gerritConfig.getBoolean("ldap", "guessRelevantGroups", true);
   }
 
   @Override
@@ -56,7 +60,9 @@
   @Override
   public Set<AccountGroup.UUID> getKnownGroups() {
     Set<AccountGroup.UUID> g = new HashSet<>(get().getKnownGroups());
-    g.retainAll(projectCache.guessRelevantGroupUUIDs());
+    if (guessRelevantGroups) {
+      g.retainAll(projectCache.guessRelevantGroupUUIDs());
+    }
     return g;
   }
 
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 8d12d32..22f4002 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -37,6 +37,9 @@
 import com.google.gerrit.server.auth.NoSuchUserException;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
@@ -299,7 +302,7 @@
 
   @Override
   public void onCreateAccount(AuthRequest who, Account account) {
-    usernameCache.put(who.getLocalUser(), Optional.of(account.getId()));
+    usernameCache.put(who.getLocalUser(), Optional.of(account.id()));
   }
 
   @Override
@@ -351,9 +354,13 @@
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
-      return externalIds
-          .get(ExternalId.Key.create(SCHEME_GERRIT, username))
-          .map(ExternalId::accountId);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading account for username", Metadata.builder().username(username).build())) {
+        return externalIds
+            .get(ExternalId.Key.create(SCHEME_GERRIT, username))
+            .map(ExternalId::accountId);
+      }
     }
   }
 
@@ -367,11 +374,16 @@
 
     @Override
     public Set<AccountGroup.UUID> load(String username) throws Exception {
-      final DirContext ctx = helper.open();
-      try {
-        return helper.queryForGroups(ctx, username, null);
-      } finally {
-        helper.close(ctx);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading group for member with username",
+              Metadata.builder().username(username).build())) {
+        final DirContext ctx = helper.open();
+        try {
+          return helper.queryForGroups(ctx, username, null);
+        } finally {
+          helper.close(ctx);
+        }
       }
     }
   }
@@ -386,17 +398,21 @@
 
     @Override
     public Boolean load(String groupDn) throws Exception {
-      final DirContext ctx = helper.open();
-      try {
-        Name compositeGroupName = new CompositeName().add(groupDn);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading groupDn", Metadata.builder().authDomainName(groupDn).build())) {
+        final DirContext ctx = helper.open();
         try {
-          ctx.getAttributes(compositeGroupName);
-          return true;
-        } catch (NamingException e) {
-          return false;
+          Name compositeGroupName = new CompositeName().add(groupDn);
+          try {
+            ctx.getAttributes(compositeGroupName);
+            return true;
+          } catch (NamingException e) {
+            return false;
+          }
+        } finally {
+          helper.close(ctx);
         }
-      } finally {
-        helper.close(ctx);
       }
     }
   }
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 13a09a1..4d7d70e 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -14,20 +14,21 @@
 
 package com.google.gerrit.server.auth.oauth;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Converter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.proto.Protos;
 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.IntegerCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -45,7 +46,9 @@
       protected void configure() {
         persist(OAUTH_TOKENS, Account.Id.class, OAuthToken.class)
             .version(1)
-            .keySerializer(new IntKeyCacheSerializer<>(Account.Id::new))
+            .keySerializer(
+                CacheSerializer.convert(
+                    IntegerCacheSerializer.INSTANCE, Converter.from(Account.Id::get, Account::id)))
             .valueSerializer(new Serializer());
       }
     };
@@ -57,7 +60,7 @@
   static class Serializer implements CacheSerializer<OAuthToken> {
     @Override
     public byte[] serialize(OAuthToken object) {
-      return ProtoCacheSerializers.toByteArray(
+      return Protos.toByteArray(
           OAuthTokenProto.newBuilder()
               .setToken(object.getToken())
               .setSecret(object.getSecret())
@@ -69,7 +72,7 @@
 
     @Override
     public OAuthToken deserialize(byte[] in) {
-      OAuthTokenProto proto = ProtoCacheSerializers.parseUnchecked(OAuthTokenProto.parser(), in);
+      OAuthTokenProto proto = Protos.parseUnchecked(OAuthTokenProto.parser(), in);
       return new OAuthToken(
           proto.getToken(),
           proto.getSecret(),
@@ -103,7 +106,7 @@
   }
 
   public void put(Account.Id id, OAuthToken accessToken) {
-    cache.put(id, encrypt(checkNotNull(accessToken)));
+    cache.put(id, encrypt(requireNonNull(accessToken)));
   }
 
   public void remove(Account.Id id) {
diff --git a/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java b/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
deleted file mode 100644
index 59fc946..0000000
--- a/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.protobuf.TextFormat;
-import java.util.Arrays;
-
-public enum BooleanCacheSerializer implements CacheSerializer<Boolean> {
-  INSTANCE;
-
-  private static final byte[] TRUE = Boolean.toString(true).getBytes(UTF_8);
-  private static final byte[] FALSE = Boolean.toString(false).getBytes(UTF_8);
-
-  @Override
-  public byte[] serialize(Boolean object) {
-    byte[] bytes = checkNotNull(object) ? TRUE : FALSE;
-    return Arrays.copyOf(bytes, bytes.length);
-  }
-
-  @Override
-  public Boolean deserialize(byte[] in) {
-    if (Arrays.equals(in, TRUE)) {
-      return Boolean.TRUE;
-    } else if (Arrays.equals(in, FALSE)) {
-      return Boolean.FALSE;
-    }
-    throw new IllegalArgumentException("Invalid Boolean value: " + TextFormat.escapeBytes(in));
-  }
-}
diff --git a/java/com/google/gerrit/server/cache/CacheMetrics.java b/java/com/google/gerrit/server/cache/CacheMetrics.java
index 11f2034..1ef5a3b 100644
--- a/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -18,11 +18,14 @@
 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.Extension;
+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;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Set;
@@ -31,7 +34,7 @@
 public class CacheMetrics {
   @Inject
   public CacheMetrics(MetricMaker metrics, DynamicMap<Cache<?, ?>> cacheMap) {
-    Field<String> F_NAME = Field.ofString("cache_name");
+    Field<String> F_NAME = Field.ofString("cache_name", Metadata.Builder::cacheName).build();
 
     CallbackMetric1<String, Long> memEnt =
         metrics.newCallbackMetric(
@@ -65,12 +68,12 @@
             F_NAME);
 
     Set<CallbackMetric<?>> cacheMetrics =
-        ImmutableSet.<CallbackMetric<?>>of(memEnt, memHit, memEvict, perDiskEnt, perDiskHit);
+        ImmutableSet.of(memEnt, memHit, memEvict, perDiskEnt, perDiskHit);
 
     metrics.newTrigger(
         cacheMetrics,
         () -> {
-          for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+          for (Extension<Cache<?, ?>> e : cacheMap) {
             Cache<?, ?> c = e.getProvider().get();
             String name = metricNameOf(e);
             CacheStats cstats = c.stats();
@@ -94,8 +97,8 @@
     return ((double) d.hitCount() / d.requestCount() * 100);
   }
 
-  private static String metricNameOf(DynamicMap.Entry<Cache<?, ?>> e) {
-    if ("gerrit".equals(e.getPluginName())) {
+  private static String metricNameOf(Extension<Cache<?, ?>> e) {
+    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/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index 2bd570e..b1a9b91 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.cache;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
@@ -99,7 +99,7 @@
   @Override
   public CacheBinding<K, V> configKey(String name) {
     checkNotFrozen();
-    configKey = checkNotNull(name);
+    configKey = requireNonNull(name);
     return this;
   }
 
diff --git a/java/com/google/gerrit/server/cache/CacheSerializer.java b/java/com/google/gerrit/server/cache/CacheSerializer.java
deleted file mode 100644
index 08deecd..0000000
--- a/java/com/google/gerrit/server/cache/CacheSerializer.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// 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;
-
-/**
- * Interface for serializing/deserializing a type to/from a persistent cache.
- *
- * <p>Implementations are null-hostile and will throw exceptions from {@link #serialize} when passed
- * null values, unless otherwise specified.
- */
-public interface CacheSerializer<T> {
-  /**
-   * Serializes the object to a new byte array.
-   *
-   * @param object object to serialize.
-   * @return serialized byte array representation.
-   * @throws RuntimeException for malformed input, for example null or an otherwise unsupported
-   *     value.
-   */
-  byte[] serialize(T object);
-
-  /**
-   * Deserializes a single object from the given byte array.
-   *
-   * @param in serialized byte array representation.
-   * @throws RuntimeException for malformed input, for example null or an otherwise corrupt
-   *     serialized representation.
-   */
-  T deserialize(byte[] in);
-}
diff --git a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java b/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
deleted file mode 100644
index c5be783..0000000
--- a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Converter;
-import com.google.common.base.Enums;
-
-public class EnumCacheSerializer<E extends Enum<E>> implements CacheSerializer<E> {
-  private final Converter<String, E> converter;
-
-  public EnumCacheSerializer(Class<E> clazz) {
-    this.converter = Enums.stringConverter(clazz);
-  }
-
-  @Override
-  public byte[] serialize(E object) {
-    return converter.reverse().convert(checkNotNull(object)).getBytes(UTF_8);
-  }
-
-  @Override
-  public E deserialize(byte[] in) {
-    return converter.convert(new String(checkNotNull(in), UTF_8));
-  }
-}
diff --git a/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java b/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
index be06601..ee672cd 100644
--- a/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
+++ b/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
@@ -17,7 +17,8 @@
 import com.google.common.base.Strings;
 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.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -34,13 +35,13 @@
     ForwardingRemovalListener create(String cacheName);
   }
 
-  private final DynamicSet<CacheRemovalListener> listeners;
+  private final PluginSetContext<CacheRemovalListener> listeners;
   private final String cacheName;
-  private String pluginName = "gerrit";
+  private String pluginName = PluginName.GERRIT;
 
   @Inject
   ForwardingRemovalListener(
-      DynamicSet<CacheRemovalListener> listeners, @Assisted String cacheName) {
+      PluginSetContext<CacheRemovalListener> listeners, @Assisted String cacheName) {
     this.listeners = listeners;
     this.cacheName = cacheName;
   }
@@ -55,8 +56,6 @@
   @Override
   @SuppressWarnings("unchecked")
   public void onRemoval(RemovalNotification<K, V> notification) {
-    for (CacheRemovalListener<K, V> l : listeners) {
-      l.onRemoval(pluginName, cacheName, notification);
-    }
+    listeners.runEach(l -> l.onRemoval(pluginName, cacheName, notification));
   }
 }
diff --git a/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java b/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
deleted file mode 100644
index a07c004..0000000
--- a/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.gwtorm.client.IntKey;
-import java.util.function.Function;
-
-public class IntKeyCacheSerializer<K extends IntKey<?>> implements CacheSerializer<K> {
-  private final Function<Integer, K> factory;
-
-  public IntKeyCacheSerializer(Function<Integer, K> factory) {
-    this.factory = checkNotNull(factory);
-  }
-
-  @Override
-  public byte[] serialize(K object) {
-    return IntegerCacheSerializer.INSTANCE.serialize(object.get());
-  }
-
-  @Override
-  public K deserialize(byte[] in) {
-    return factory.apply(IntegerCacheSerializer.INSTANCE.deserialize(in));
-  }
-}
diff --git a/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java b/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
deleted file mode 100644
index 5eddb71..0000000
--- a/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.protobuf.CodedInputStream;
-import com.google.protobuf.CodedOutputStream;
-import com.google.protobuf.TextFormat;
-import java.io.IOException;
-import java.util.Arrays;
-
-public enum IntegerCacheSerializer implements CacheSerializer<Integer> {
-  INSTANCE;
-
-  // Same as com.google.protobuf.WireFormat#MAX_VARINT_SIZE. Note that negative values take up more
-  // than MAX_VARINT32_SIZE space.
-  private static final int MAX_VARINT_SIZE = 10;
-
-  @Override
-  public byte[] serialize(Integer object) {
-    byte[] buf = new byte[MAX_VARINT_SIZE];
-    CodedOutputStream cout = CodedOutputStream.newInstance(buf);
-    try {
-      cout.writeInt32NoTag(checkNotNull(object));
-      cout.flush();
-    } catch (IOException e) {
-      throw new IllegalStateException("Failed to serialize int");
-    }
-    int n = cout.getTotalBytesWritten();
-    return n == buf.length ? buf : Arrays.copyOfRange(buf, 0, n);
-  }
-
-  @Override
-  public Integer deserialize(byte[] in) {
-    CodedInputStream cin = CodedInputStream.newInstance(checkNotNull(in));
-    int ret;
-    try {
-      ret = cin.readRawVarint32();
-    } catch (IOException e) {
-      throw new IllegalArgumentException("Failed to deserialize int");
-    }
-    int n = cin.getTotalBytesRead();
-    if (n != in.length) {
-      throw new IllegalArgumentException(
-          "Extra bytes in int representation: "
-              + TextFormat.escapeBytes(Arrays.copyOfRange(in, n, in.length)));
-    }
-    return ret;
-  }
-}
diff --git a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java b/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
deleted file mode 100644
index 55358bc..0000000
--- a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import com.google.gerrit.common.Nullable;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-
-/**
- * Serializer that uses default Java serialization.
- *
- * <p>Unlike most {@link CacheSerializer} implementations, serializing null is supported.
- *
- * @param <T> type to serialize. Must implement {@code Serializable}, but due to implementation
- *     details this is only checked at runtime.
- */
-public class JavaCacheSerializer<T> implements CacheSerializer<T> {
-  @Override
-  public byte[] serialize(@Nullable T object) {
-    try (ByteArrayOutputStream bout = new ByteArrayOutputStream();
-        ObjectOutputStream oout = new ObjectOutputStream(bout)) {
-      oout.writeObject(object);
-      oout.flush();
-      return bout.toByteArray();
-    } catch (IOException e) {
-      throw new IllegalArgumentException("Failed to serialize object", e);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public T deserialize(byte[] in) {
-    Object object;
-    try (ByteArrayInputStream bin = new ByteArrayInputStream(in);
-        ObjectInputStream oin = new ObjectInputStream(bin)) {
-      object = oin.readObject();
-    } catch (ClassNotFoundException | IOException e) {
-      throw new IllegalArgumentException("Failed to deserialize object", e);
-    }
-    return (T) object;
-  }
-}
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/ProtoCacheSerializers.java b/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
deleted file mode 100644
index c6fc0b9..0000000
--- a/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static org.eclipse.jgit.lib.Constants.OBJECT_ID_LENGTH;
-
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.protobuf.ByteString;
-import com.google.protobuf.CodedOutputStream;
-import com.google.protobuf.MessageLite;
-import com.google.protobuf.Parser;
-import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
-
-/** Static utilities for writing protobuf-based {@link CacheSerializer} implementations. */
-public class ProtoCacheSerializers {
-  /**
-   * Serializes a proto to a byte array.
-   *
-   * <p>Guarantees deterministic serialization and thus is suitable for use in persistent caches.
-   * Should be used in preference to {@link MessageLite#toByteArray()}, which is not guaranteed
-   * deterministic.
-   *
-   * @param message the proto message to serialize.
-   * @return a byte array with the message contents.
-   */
-  public static byte[] toByteArray(MessageLite message) {
-    byte[] bytes = new byte[message.getSerializedSize()];
-    CodedOutputStream cout = CodedOutputStream.newInstance(bytes);
-    cout.useDeterministicSerialization();
-    try {
-      message.writeTo(cout);
-      cout.checkNoSpaceLeft();
-      return bytes;
-    } catch (IOException e) {
-      throw new IllegalStateException("exception writing to byte array", e);
-    }
-  }
-
-  /**
-   * Serializes an object to a {@link ByteString} using a protobuf codec.
-   *
-   * <p>Guarantees deterministic serialization and thus is suitable for use in persistent caches.
-   * Should be used in preference to {@link ProtobufCodec#encodeToByteString(Object)}, which is not
-   * guaranteed deterministic.
-   *
-   * @param object the object to serialize.
-   * @param codec codec for serializing.
-   * @return a {@code ByteString} with the message contents.
-   */
-  public static <T> ByteString toByteString(T object, ProtobufCodec<T> codec) {
-    try (ByteString.Output bout = ByteString.newOutput()) {
-      CodedOutputStream cout = CodedOutputStream.newInstance(bout);
-      cout.useDeterministicSerialization();
-      codec.encode(object, cout);
-      cout.flush();
-      return bout.toByteString();
-    } catch (IOException e) {
-      throw new IllegalStateException("exception writing to ByteString", e);
-    }
-  }
-
-  /**
-   * Parses a byte array to a protobuf message.
-   *
-   * @param parser parser for the proto type.
-   * @param in byte array with the message contents.
-   * @return parsed proto.
-   */
-  public static <M extends MessageLite> M parseUnchecked(Parser<M> parser, byte[] in) {
-    try {
-      return parser.parseFrom(in);
-    } catch (IOException e) {
-      throw new IllegalArgumentException("exception parsing byte array to proto", e);
-    }
-  }
-
-  /**
-   * Helper for serializing {@link ObjectId} instances to/from protobuf fields.
-   *
-   * <p>Reuse a single instance's {@link #toByteString(ObjectId)} and {@link
-   * #fromByteString(ByteString)} within a single {@link CacheSerializer#serialize} or {@link
-   * CacheSerializer#deserialize} method body to minimize allocation of temporary buffers.
-   *
-   * <p><strong>Note:</strong> This class is not threadsafe. Instances must not be stored in {@link
-   * CacheSerializer} fields if the serializer instances will be used from multiple threads.
-   */
-  public static class ObjectIdConverter {
-    public static ObjectIdConverter create() {
-      return new ObjectIdConverter();
-    }
-
-    private final byte[] buf = new byte[OBJECT_ID_LENGTH];
-
-    private ObjectIdConverter() {}
-
-    public ByteString toByteString(ObjectId id) {
-      id.copyRawTo(buf, 0);
-      return ByteString.copyFrom(buf);
-    }
-
-    public ObjectId fromByteString(ByteString in) {
-      checkArgument(
-          in.size() == OBJECT_ID_LENGTH,
-          "expected ByteString of length %s: %s",
-          OBJECT_ID_LENGTH,
-          in);
-      in.copyTo(buf, 0);
-      return ObjectId.fromRaw(buf);
-    }
-  }
-
-  private ProtoCacheSerializers() {}
-}
diff --git a/java/com/google/gerrit/server/cache/h2/BUILD b/java/com/google/gerrit/server/cache/h2/BUILD
index fc57a11..f85b498 100644
--- a/java/com/google/gerrit/server/cache/h2/BUILD
+++ b/java/com/google/gerrit/server/cache/h2/BUILD
@@ -8,6 +8,9 @@
         "//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",
+        "//java/com/google/gerrit/server/util/time",
         "//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..5d9ce60 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;
 
@@ -42,7 +42,6 @@
     return source.expireFromMemoryAfterAccess();
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public Weigher<K, V> weigher() {
     Weigher<K, V> weigher = source.weigher();
@@ -52,13 +51,10 @@
 
     // introduce weigher that performs calculations
     // on value that is being stored not on ValueHolder
-    return (Weigher<K, V>)
-        new Weigher<K, ValueHolder<V>>() {
-          @Override
-          public int weigh(K key, ValueHolder<V> value) {
-            return weigher.weigh(key, value.value);
-          }
-        };
+    Weigher<K, ValueHolder<V>> holderWeigher = (k, v) -> weigher.weigh(k, v.value);
+    @SuppressWarnings("unchecked")
+    Weigher<K, V> ret = (Weigher<K, V>) holderWeigher;
+    return ret;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 9abccbc..af1228d 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -28,6 +28,8 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
+import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -74,15 +76,17 @@
 
     if (cacheDir != null) {
       executor =
-          Executors.newFixedThreadPool(
-              1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build());
+          new LoggingContextAwareExecutorService(
+              Executors.newFixedThreadPool(
+                  1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build()));
       cleanup =
-          Executors.newScheduledThreadPool(
-              1,
-              new ThreadFactoryBuilder()
-                  .setNameFormat("DiskCache-Prune-%d")
-                  .setDaemon(true)
-                  .build());
+          new LoggingContextAwareScheduledExecutorService(
+              Executors.newScheduledThreadPool(
+                  1,
+                  new ThreadFactoryBuilder()
+                      .setNameFormat("DiskCache-Prune-%d")
+                      .setDaemon(true)
+                      .build()));
     } else {
       executor = null;
       cleanup = null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index d7baee2..ef4e44c 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -24,9 +24,12 @@
 import com.google.common.flogger.FluentLogger;
 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.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.io.InvalidClassException;
@@ -48,7 +51,6 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
-import org.h2.jdbc.JdbcSQLException;
 
 /**
  * Hybrid in-memory and database backed cache built on H2.
@@ -236,17 +238,21 @@
 
     @Override
     public ValueHolder<V> load(K key) throws Exception {
-      if (store.mightContain(key)) {
-        ValueHolder<V> h = store.getIfPresent(key);
-        if (h != null) {
-          return h;
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading value from cache", Metadata.builder().cacheKey(key.toString()).build())) {
+        if (store.mightContain(key)) {
+          ValueHolder<V> h = store.getIfPresent(key);
+          if (h != null) {
+            return h;
+          }
         }
-      }
 
-      final ValueHolder<V> h = new ValueHolder<>(loader.load(key));
-      h.created = TimeUtil.nowMs();
-      executor.execute(() -> store.put(key, h));
-      return h;
+        final ValueHolder<V> h = new ValueHolder<>(loader.load(key));
+        h.created = TimeUtil.nowMs();
+        executor.execute(() -> store.put(key, h));
+        return h;
+      }
     }
   }
 
@@ -341,8 +347,9 @@
               b.put(keyType.get(r, 1));
             }
           }
-        } catch (JdbcSQLException e) {
-          if (e.getCause() instanceof InvalidClassException) {
+        } catch (Exception e) {
+          if (Throwables.getCausalChain(e).stream()
+              .anyMatch(InvalidClassException.class::isInstance)) {
             // If deserialization failed using default Java serialization, this means we are using
             // the old serialVersionUID-based invalidation strategy. In that case, authors are
             // most likely bumping serialVersionUID rather than using the new versioning in the
@@ -531,13 +538,16 @@
         try (PreparedStatement ps = c.conn.prepareStatement("DELETE FROM data WHERE version!=?")) {
           ps.setInt(1, version);
           int oldEntries = ps.executeUpdate();
-          logger.atInfo().log(
-              "Pruned %d entries not matching version %d from cache %s", oldEntries, version, url);
+          if (oldEntries > 0) {
+            logger.atInfo().log(
+                "Pruned %d entries not matching version %d from cache %s",
+                oldEntries, version, url);
+          }
         }
         try (Statement s = c.conn.createStatement()) {
           // Compute size without restricting to version (although obsolete data was just pruned
           // anyway).
-          long used = 0;
+          long used;
           try (ResultSet r = s.executeQuery("SELECT SUM(space) FROM data")) {
             used = r.next() ? r.getLong(1) : 0;
           }
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/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index ad1d396..f76b8db 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -112,11 +112,6 @@
   }
 
   private static <K, V> Weigher<K, V> unitWeight() {
-    return new Weigher<K, V>() {
-      @Override
-      public int weigh(K key, V value) {
-        return 1;
-      }
-    };
+    return (key, value) -> 1;
   }
 }
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..a3a2054
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/BUILD
@@ -0,0 +1,13 @@
+java_library(
+    name = "serialize",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/proto",
+        "//lib:guava",
+        "//lib:protobuf",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
new file mode 100644
index 0000000..50fcf0c
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.protobuf.TextFormat;
+import java.util.Arrays;
+
+public enum BooleanCacheSerializer implements CacheSerializer<Boolean> {
+  INSTANCE;
+
+  private static final byte[] TRUE = Boolean.toString(true).getBytes(UTF_8);
+  private static final byte[] FALSE = Boolean.toString(false).getBytes(UTF_8);
+
+  @Override
+  public byte[] serialize(Boolean object) {
+    byte[] bytes = requireNonNull(object) ? TRUE : FALSE;
+    return Arrays.copyOf(bytes, bytes.length);
+  }
+
+  @Override
+  public Boolean deserialize(byte[] in) {
+    if (Arrays.equals(in, TRUE)) {
+      return Boolean.TRUE;
+    } else if (Arrays.equals(in, FALSE)) {
+      return Boolean.FALSE;
+    }
+    throw new IllegalArgumentException("Invalid Boolean value: " + TextFormat.escapeBytes(in));
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
new file mode 100644
index 0000000..5377fc1
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import com.google.common.base.Converter;
+
+/**
+ * Interface for serializing/deserializing a type to/from a persistent cache.
+ *
+ * <p>Implementations are null-hostile and will throw exceptions from {@link #serialize} when passed
+ * null values, unless otherwise specified.
+ */
+public interface CacheSerializer<T> {
+  /**
+   * Convert a serializer of one type to another type using a {@link Converter}.
+   *
+   * @param delegate underlying serializer.
+   * @param converter converter between an arbitrary type {@code T} and {@code delegate}'s type.
+   * @return serializer of type {@code T}.
+   */
+  static <T, D> CacheSerializer<T> convert(CacheSerializer<D> delegate, Converter<T, D> converter) {
+    return new CacheSerializer<T>() {
+      @Override
+      public byte[] serialize(T object) {
+        return delegate.serialize(converter.convert(object));
+      }
+
+      @Override
+      public T deserialize(byte[] in) {
+        return converter.reverse().convert(delegate.deserialize(in));
+      }
+    };
+  }
+
+  /**
+   * Serializes the object to a new byte array.
+   *
+   * @param object object to serialize.
+   * @return serialized byte array representation.
+   * @throws RuntimeException for malformed input, for example null or an otherwise unsupported
+   *     value.
+   */
+  byte[] serialize(T object);
+
+  /**
+   * Deserializes a single object from the given byte array.
+   *
+   * @param in serialized byte array representation.
+   * @throws RuntimeException for malformed input, for example null or an otherwise corrupt
+   *     serialized representation.
+   */
+  T deserialize(byte[] in);
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
new file mode 100644
index 0000000..ecb4963
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+
+public class EnumCacheSerializer<E extends Enum<E>> implements CacheSerializer<E> {
+  private final Converter<String, E> converter;
+
+  public EnumCacheSerializer(Class<E> clazz) {
+    this.converter = Enums.stringConverter(clazz);
+  }
+
+  @Override
+  public byte[] serialize(E object) {
+    return converter.reverse().convert(requireNonNull(object)).getBytes(UTF_8);
+  }
+
+  @Override
+  public E deserialize(byte[] in) {
+    return converter.convert(new String(requireNonNull(in), UTF_8));
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
new file mode 100644
index 0000000..4494454
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.protobuf.CodedInputStream;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.TextFormat;
+import java.io.IOException;
+import java.util.Arrays;
+
+public enum IntegerCacheSerializer implements CacheSerializer<Integer> {
+  INSTANCE;
+
+  // Same as com.google.protobuf.WireFormat#MAX_VARINT_SIZE. Note that negative values take up more
+  // than MAX_VARINT32_SIZE space.
+  private static final int MAX_VARINT_SIZE = 10;
+
+  @Override
+  public byte[] serialize(Integer object) {
+    byte[] buf = new byte[MAX_VARINT_SIZE];
+    CodedOutputStream cout = CodedOutputStream.newInstance(buf);
+    try {
+      cout.writeInt32NoTag(requireNonNull(object));
+      cout.flush();
+    } catch (IOException e) {
+      throw new IllegalStateException("Failed to serialize int");
+    }
+    int n = cout.getTotalBytesWritten();
+    return n == buf.length ? buf : Arrays.copyOfRange(buf, 0, n);
+  }
+
+  @Override
+  public Integer deserialize(byte[] in) {
+    CodedInputStream cin = CodedInputStream.newInstance(requireNonNull(in));
+    int ret;
+    try {
+      ret = cin.readRawVarint32();
+    } catch (IOException e) {
+      throw new IllegalArgumentException("Failed to deserialize int");
+    }
+    int n = cin.getTotalBytesRead();
+    if (n != in.length) {
+      throw new IllegalArgumentException(
+          "Extra bytes in int representation: "
+              + TextFormat.escapeBytes(Arrays.copyOfRange(in, n, in.length)));
+    }
+    return ret;
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
new file mode 100644
index 0000000..ee71846
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import com.google.gerrit.common.Nullable;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+
+/**
+ * Serializer that uses default Java serialization.
+ *
+ * <p>Unlike most {@link CacheSerializer} implementations, serializing null is supported.
+ *
+ * @param <T> type to serialize. Must implement {@code Serializable}, but due to implementation
+ *     details this is only checked at runtime.
+ */
+public class JavaCacheSerializer<T> implements CacheSerializer<T> {
+  @Override
+  public byte[] serialize(@Nullable T object) {
+    try (ByteArrayOutputStream bout = new ByteArrayOutputStream();
+        ObjectOutputStream oout = new ObjectOutputStream(bout)) {
+      oout.writeObject(object);
+      oout.flush();
+      return bout.toByteArray();
+    } catch (IOException e) {
+      throw new IllegalArgumentException("Failed to serialize object", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public T deserialize(byte[] in) {
+    Object object;
+    try (ByteArrayInputStream bin = new ByteArrayInputStream(in);
+        ObjectInputStream oin = new ObjectInputStream(bin)) {
+      object = oin.readObject();
+    } catch (ClassNotFoundException | IOException e) {
+      throw new IllegalArgumentException("Failed to deserialize object", e);
+    }
+    return (T) object;
+  }
+}
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..7c0f84f
--- /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 com.google.gerrit.git.ObjectIds;
+import org.eclipse.jgit.lib.ObjectId;
+
+public enum ObjectIdCacheSerializer implements CacheSerializer<ObjectId> {
+  INSTANCE;
+
+  @Override
+  public byte[] serialize(ObjectId object) {
+    byte[] buf = new byte[ObjectIds.LEN];
+    object.copyRawTo(buf, 0);
+    return buf;
+  }
+
+  @Override
+  public ObjectId deserialize(byte[] in) {
+    if (in == null || in.length != ObjectIds.LEN) {
+      throw new IllegalArgumentException("Failed to deserialize ObjectId");
+    }
+    return ObjectId.fromRaw(in);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java b/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
new file mode 100644
index 0000000..22654e5
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.git.ObjectIds;
+import com.google.protobuf.ByteString;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Helper for serializing {@link ObjectId} instances to/from protobuf fields.
+ *
+ * <p>Reuse a single instance's {@link #toByteString(ObjectId)} and {@link
+ * #fromByteString(ByteString)} within a single {@link CacheSerializer#serialize} or {@link
+ * CacheSerializer#deserialize} method body to minimize allocation of temporary buffers.
+ *
+ * <p><strong>Note:</strong> This class is not threadsafe. Instances must not be stored in {@link
+ * CacheSerializer} fields if the serializer instances will be used from multiple threads.
+ */
+public class ObjectIdConverter {
+  public static ObjectIdConverter create() {
+    return new ObjectIdConverter();
+  }
+
+  private final byte[] buf = new byte[ObjectIds.LEN];
+
+  private ObjectIdConverter() {}
+
+  public ByteString toByteString(ObjectId id) {
+    id.copyRawTo(buf, 0);
+    return ByteString.copyFrom(buf);
+  }
+
+  public ObjectId fromByteString(ByteString in) {
+    checkArgument(
+        in.size() == ObjectIds.LEN, "expected ByteString of length %s: %s", ObjectIds.LEN, in);
+    in.copyTo(buf, 0);
+    return ObjectId.fromRaw(buf);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/ProtobufSerializer.java b/java/com/google/gerrit/server/cache/serialize/ProtobufSerializer.java
new file mode 100644
index 0000000..180646b
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/ProtobufSerializer.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 com.google.gerrit.proto.Protos;
+import com.google.protobuf.MessageLite;
+import com.google.protobuf.Parser;
+
+/** A CacheSerializer for Protobuf messages. */
+public class ProtobufSerializer<T extends MessageLite> implements CacheSerializer<T> {
+  private final Parser<T> parser;
+
+  public ProtobufSerializer(Parser<T> parser) {
+    this.parser = parser;
+  }
+
+  @Override
+  public byte[] serialize(T object) {
+    return Protos.toByteArray(object);
+  }
+
+  @Override
+  public T deserialize(byte[] in) {
+    return Protos.parseUnchecked(parser, in);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
new file mode 100644
index 0000000..525b75b
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CodingErrorAction;
+
+public enum StringCacheSerializer implements CacheSerializer<String> {
+  INSTANCE;
+
+  @Override
+  public byte[] serialize(String object) {
+    return serialize(UTF_8, object);
+  }
+
+  @VisibleForTesting
+  static byte[] serialize(Charset charset, String s) {
+    if (s.isEmpty()) {
+      return new byte[0];
+    }
+    try {
+      ByteBuffer buf =
+          charset
+              .newEncoder()
+              .onMalformedInput(CodingErrorAction.REPORT)
+              .onUnmappableCharacter(CodingErrorAction.REPORT)
+              .encode(CharBuffer.wrap(s));
+      byte[] result = new byte[buf.remaining()];
+      buf.get(result);
+      return result;
+    } catch (CharacterCodingException e) {
+      throw new IllegalStateException("Failed to serialize string", e);
+    }
+  }
+
+  @Override
+  public String deserialize(byte[] in) {
+    if (in.length == 0) {
+      return "";
+    }
+    try {
+      return UTF_8
+          .newDecoder()
+          .onMalformedInput(CodingErrorAction.REPORT)
+          .onUnmappableCharacter(CodingErrorAction.REPORT)
+          .decode(ByteBuffer.wrap(in))
+          .toString();
+    } catch (CharacterCodingException e) {
+      throw new IllegalStateException("Failed to deserialize string", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/testing/BUILD b/java/com/google/gerrit/server/cache/testing/BUILD
index ed412af..16cbe17 100644
--- a/java/com/google/gerrit/server/cache/testing/BUILD
+++ b/java/com/google/gerrit/server/cache/testing/BUILD
@@ -1,13 +1,10 @@
-package(default_testonly = 1)
+package(default_testonly = True)
 
 java_library(
     name = "testing",
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//lib:guava",
         "//lib:protobuf",
-        "//lib/commons:lang3",
-        "//lib/truth",
     ],
 )
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
deleted file mode 100644
index 19c5b67..0000000
--- a/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// 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.testing;
-
-import static com.google.common.collect.ImmutableMap.toImmutableMap;
-import static com.google.common.truth.Truth.assertAbout;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
-
-import com.google.common.truth.FailureMetadata;
-import com.google.common.truth.Subject;
-import java.lang.reflect.Field;
-import java.lang.reflect.Method;
-import java.lang.reflect.Modifier;
-import java.lang.reflect.Type;
-import java.util.Arrays;
-import java.util.Map;
-import org.apache.commons.lang3.reflect.FieldUtils;
-
-/**
- * Subject about classes that are serialized into persistent caches.
- *
- * <p>Hand-written {@link com.google.gerrit.server.cache.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
- * custom serializer implementation.
- *
- * <p>Changing fields of a serialized class (or abstract methods, in the case of {@code @AutoValue}
- * classes) will likely require changes to the serializer implementation, and may require bumping
- * the {@link com.google.gerrit.server.cache.PersistentCacheBinding#version(int) version} in the
- * cache binding, in case the representation has changed in such a way that old serialized data
- * becomes unreadable.
- *
- * <p>Changes to a serialized class such as adding or removing fields generally requires a change to
- * the hand-written serializer. Usually, serializer implementations should be written in such a way
- * that new fields are considered optional, and won't require bumping the version.
- */
-public class SerializedClassSubject extends Subject<SerializedClassSubject, Class<?>> {
-  public static SerializedClassSubject assertThatSerializedClass(Class<?> actual) {
-    // This formulation fails in Eclipse 4.7.3a with "The type
-    // SerializedClassSubject does not define SerializedClassSubject() that is
-    // applicable here", due to
-    // https://bugs.eclipse.org/bugs/show_bug.cgi?id=534694 or a similar bug:
-    // return assertAbout(SerializedClassSubject::new).that(actual);
-    Subject.Factory<SerializedClassSubject, Class<?>> factory =
-        (m, a) -> new SerializedClassSubject(m, a);
-    return assertAbout(factory).that(actual);
-  }
-
-  private SerializedClassSubject(FailureMetadata metadata, Class<?> actual) {
-    super(metadata, actual);
-  }
-
-  public void isAbstract() {
-    isNotNull();
-    assertWithMessage("expected class %s to be abstract", actual().getName())
-        .that(Modifier.isAbstract(actual().getModifiers()))
-        .isTrue();
-  }
-
-  public void isConcrete() {
-    isNotNull();
-    assertWithMessage("expected class %s to be concrete", actual().getName())
-        .that(!Modifier.isAbstract(actual().getModifiers()))
-        .isTrue();
-  }
-
-  public void hasFields(Map<String, Type> expectedFields) {
-    isConcrete();
-    assertThat(
-            FieldUtils.getAllFieldsList(actual())
-                .stream()
-                .filter(f -> !Modifier.isStatic(f.getModifiers()))
-                .collect(toImmutableMap(Field::getName, Field::getGenericType)))
-        .containsExactlyEntriesIn(expectedFields);
-  }
-
-  public void hasAutoValueMethods(Map<String, Type> expectedMethods) {
-    // Would be nice if we could check clazz is an @AutoValue, but the retention is not RUNTIME.
-    isAbstract();
-    assertThat(
-            Arrays.stream(actual().getDeclaredMethods())
-                .filter(m -> !Modifier.isStatic(m.getModifiers()))
-                .filter(m -> Modifier.isAbstract(m.getModifiers()))
-                .filter(m -> m.getParameters().length == 0)
-                .collect(toImmutableMap(Method::getName, Method::getGenericReturnType)))
-        .named("no-argument abstract methods on %s", actual().getName())
-        .isEqualTo(expectedMethods);
-  }
-}
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index 5affd5c..31332ec 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -15,13 +15,9 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -36,7 +32,6 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -49,8 +44,6 @@
   private final ChangeAbandoned changeAbandoned;
 
   private final String msgTxt;
-  private final NotifyHandling notifyHandling;
-  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
   private final AccountState accountState;
 
   private Change change;
@@ -59,10 +52,7 @@
 
   public interface Factory {
     AbandonOp create(
-        @Assisted @Nullable AccountState accountState,
-        @Assisted @Nullable String msgTxt,
-        @Assisted NotifyHandling notifyHandling,
-        @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify);
+        @Assisted @Nullable AccountState accountState, @Assisted @Nullable String msgTxt);
   }
 
   @Inject
@@ -72,9 +62,7 @@
       PatchSetUtil psUtil,
       ChangeAbandoned changeAbandoned,
       @Assisted @Nullable AccountState accountState,
-      @Assisted @Nullable String msgTxt,
-      @Assisted NotifyHandling notifyHandling,
-      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+      @Assisted @Nullable String msgTxt) {
     this.abandonedSenderFactory = abandonedSenderFactory;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
@@ -82,8 +70,6 @@
 
     this.accountState = accountState;
     this.msgTxt = Strings.nullToEmpty(msgTxt);
-    this.notifyHandling = notifyHandling;
-    this.accountsToNotify = accountsToNotify;
   }
 
   @Nullable
@@ -92,20 +78,20 @@
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
+  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException {
     change = ctx.getChange();
     PatchSet.Id psId = change.currentPatchSetId();
     ChangeUpdate update = ctx.getUpdate(psId);
-    if (!change.getStatus().isOpen()) {
+    if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
-    patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+    patchSet = psUtil.get(ctx.getNotes(), psId);
     change.setStatus(Change.Status.ABANDONED);
     change.setLastUpdatedOn(ctx.getWhen());
 
     update.setStatus(change.getStatus());
     message = newMessage(ctx);
-    cmUtil.addChangeMessage(ctx.getDb(), update, message);
+    cmUtil.addChangeMessage(update, message);
     return true;
   }
 
@@ -121,19 +107,19 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) throws OrmException {
+  public void postUpdate(Context ctx) {
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
       ReplyToChangeSender cm = abandonedSenderFactory.create(ctx.getProject(), change.getId());
       if (accountState != null) {
-        cm.setFrom(accountState.getAccount().getId());
+        cm.setFrom(accountState.getAccount().id());
       }
       cm.setChangeMessage(message.getMessage(), ctx.getWhen());
-      cm.setNotify(notifyHandling);
-      cm.setAccountsToNotify(accountsToNotify);
+      cm.setNotify(notify);
       cm.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
-    changeAbandoned.fire(change, patchSet, accountState, msgTxt, ctx.getWhen(), notifyHandling);
+    changeAbandoned.fire(change, patchSet, accountState, msgTxt, ctx.getWhen(), notify.handling());
   }
 }
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index f505f6d..6f46498 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.InternalUser;
@@ -25,7 +26,6 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -96,14 +96,14 @@
         }
       }
       logger.atInfo().log("Auto-Abandoned %d of %d changes.", count, changesToAbandon.size());
-    } catch (QueryParseException | OrmException e) {
+    } catch (QueryParseException | StorageException e) {
       logger.atSevere().withCause(e).log(
           "Failed to query inactive open changes for auto-abandoning.");
     }
   }
 
   private Collection<ChangeData> getValidChanges(Collection<ChangeData> changes, String query)
-      throws OrmException, QueryParseException {
+      throws QueryParseException {
     Collection<ChangeData> validChanges = new ArrayList<>();
     for (ChangeData cd : changes) {
       String newQuery = query + " change:" + cd.getId();
diff --git a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
index 69825ea..fff3274 100644
--- a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -17,8 +17,8 @@
 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.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtorm.server.OrmException;
 import java.util.Collection;
 import java.util.Optional;
 
@@ -54,9 +54,8 @@
    * @param path file path
    * @return {@code true} if the reviewed flag was updated, {@code false} if the reviewed flag was
    *     already set
-   * @throws OrmException thrown if updating the reviewed flag failed
    */
-  boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path) throws OrmException;
+  boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path);
 
   /**
    * Marks the given files in the given patch set as reviewed by the given user.
@@ -64,10 +63,8 @@
    * @param psId patch set ID
    * @param accountId account ID of the user
    * @param paths file paths
-   * @throws OrmException thrown if updating the reviewed flag failed
    */
-  void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths)
-      throws OrmException;
+  void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths);
 
   /**
    * Clears the reviewed flag for the given file in the given patch set for the given user.
@@ -75,17 +72,22 @@
    * @param psId patch set ID
    * @param accountId account ID of the user
    * @param path file path
-   * @throws OrmException thrown if clearing the reviewed flag failed
    */
-  void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path) throws OrmException;
+  void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path);
 
   /**
    * Clears the reviewed flags for all files in the given patch set for all users.
    *
    * @param psId patch set ID
-   * @throws OrmException thrown if clearing the reviewed flags failed
    */
-  void clearReviewed(PatchSet.Id psId) throws OrmException;
+  void clearReviewed(PatchSet.Id psId);
+
+  /**
+   * Clears the reviewed flags for all files in all patch sets in the given change for all users.
+   *
+   * @param changeId change ID
+   */
+  void clearReviewed(Change.Id changeId);
 
   /**
    * Find the latest patch set, that is smaller or equals to the given patch set, where at least,
@@ -95,8 +97,6 @@
    * @param accountId account ID of the user
    * @return optionally, all files the have been reviewed by the given user that belong to the patch
    *     set that is smaller or equals to the given patch set
-   * @throws OrmException thrown if accessing the reviewed flags failed
    */
-  Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId)
-      throws OrmException;
+  Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId);
 }
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index fbabdd5..d493b31 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
@@ -29,11 +29,9 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.PrivateInternals_UiActionDescription;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -71,13 +69,13 @@
     this.userProvider = userProvider;
   }
 
-  public Map<String, ActionInfo> format(RevisionResource rsrc) throws OrmException {
+  public Map<String, ActionInfo> format(RevisionResource rsrc) {
     ChangeInfo changeInfo = null;
     RevisionInfo revisionInfo = null;
     List<ActionVisitor> visitors = visitors();
     if (!visitors.isEmpty()) {
       changeInfo = changeJson().format(rsrc);
-      revisionInfo = checkNotNull(Iterables.getOnlyElement(changeInfo.revisions.values()));
+      revisionInfo = requireNonNull(Iterables.getOnlyElement(changeInfo.revisions.values()));
       changeInfo.revisions = null;
     }
     return toActionMap(rsrc, visitors, changeInfo, revisionInfo);
@@ -98,7 +96,7 @@
   }
 
   public RevisionInfo addRevisionActions(
-      @Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) throws OrmException {
+      @Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) {
     List<ActionVisitor> visitors = visitors();
     if (!visitors.isEmpty()) {
       if (changeInfo != null) {
@@ -115,8 +113,8 @@
     if (visitors.isEmpty()) {
       return null;
     }
-    // Include all fields from ChangeJson#toChangeInfo that are not protected by
-    // any ListChangesOptions.
+    // Include all fields from ChangeJson#toChangeInfoImpl that are not protected by any
+    // ListChangesOptions.
     ChangeInfo copy = new ChangeInfo();
     copy.project = changeInfo.project;
     copy.branch = changeInfo.branch;
@@ -128,6 +126,7 @@
     copy.mergeable = changeInfo.mergeable;
     copy.insertions = changeInfo.insertions;
     copy.deletions = changeInfo.deletions;
+    copy.hasReviewStarted = changeInfo.hasReviewStarted;
     copy.isPrivate = changeInfo.isPrivate;
     copy.subject = changeInfo.subject;
     copy.status = changeInfo.status;
@@ -135,10 +134,13 @@
     copy.created = changeInfo.created;
     copy.updated = changeInfo.updated;
     copy._number = changeInfo._number;
+    copy.requirements = changeInfo.requirements;
+    copy.revertOf = changeInfo.revertOf;
     copy.starred = changeInfo.starred;
     copy.stars = changeInfo.stars;
     copy.submitted = changeInfo.submitted;
     copy.submitter = changeInfo.submitter;
+    copy.unresolvedCommentCount = changeInfo.unresolvedCommentCount;
     copy.workInProgress = changeInfo.workInProgress;
     copy.id = changeInfo.id;
     return copy;
@@ -148,8 +150,8 @@
     if (visitors.isEmpty()) {
       return null;
     }
-    // Include all fields from ChangeJson#toRevisionInfo that are not protected
-    // by any ListChangesOptions.
+    // Include all fields from RevisionJson#toRevisionInfo that are not protected by any
+    // ListChangesOptions.
     RevisionInfo copy = new RevisionInfo();
     copy.isCurrent = revisionInfo.isCurrent;
     copy._number = revisionInfo._number;
@@ -176,8 +178,7 @@
     // The followup action is a client-side only operation that does not
     // have a server side handler. It must be manually registered into the
     // resulting action map.
-    Status status = notes.getChange().getStatus();
-    if (status.isOpen() || status.equals(Status.MERGED)) {
+    if (!notes.getChange().isAbandoned()) {
       UiAction.Description descr = new UiAction.Description();
       PrivateInternals_UiActionDescription.setId(descr, "followup");
       PrivateInternals_UiActionDescription.setMethod(descr, "POST");
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
new file mode 100644
index 0000000..d9c5dad
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -0,0 +1,73 @@
+// 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.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collection;
+
+@Singleton
+public class AddReviewersEmail {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final AddReviewerSender.Factory addReviewerSenderFactory;
+
+  @Inject
+  AddReviewersEmail(AddReviewerSender.Factory addReviewerSenderFactory) {
+    this.addReviewerSenderFactory = addReviewerSenderFactory;
+  }
+
+  public void emailReviewers(
+      IdentifiedUser user,
+      Change change,
+      Collection<Account.Id> added,
+      Collection<Account.Id> copied,
+      Collection<Address> addedByEmail,
+      Collection<Address> copiedByEmail,
+      NotifyResolver.Result notify) {
+    // The user knows they added themselves, don't bother emailing them.
+    Account.Id userId = user.getAccountId();
+    ImmutableList<Account.Id> toMail =
+        added.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
+    ImmutableList<Account.Id> toCopy =
+        copied.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
+    if (toMail.isEmpty() && toCopy.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+      return;
+    }
+
+    try {
+      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
+      cm.setNotify(notify);
+      cm.setFrom(userId);
+      cm.addReviewers(toMail);
+      cm.addReviewersByEmail(addedByEmail);
+      cm.addExtraCC(toCopy);
+      cm.addExtraCCByEmail(copiedByEmail);
+      cm.send();
+    } catch (Exception err) {
+      logger.atSevere().withCause(err).log(
+          "Cannot send email to new reviewers of change %s", change.getId());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
new file mode 100644
index 0000000..a8ebcb2
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -0,0 +1,263 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.extensions.events.ReviewerAdded;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+public class AddReviewersOp implements BatchUpdateOp {
+  public interface Factory {
+
+    /**
+     * Create a new op.
+     *
+     * <p>Users may be added by account or by email addresses, as determined by {@code accountIds}
+     * and {@code addresses}. The reviewer state for both accounts and email addresses is determined
+     * by {@code state}.
+     *
+     * @param accountIds account IDs to add.
+     * @param addresses email addresses to add.
+     * @param state resulting reviewer state.
+     * @return batch update operation.
+     */
+    AddReviewersOp create(
+        Set<Account.Id> accountIds, Collection<Address> addresses, ReviewerState state);
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableList<PatchSetApproval> addedReviewers();
+
+    public abstract ImmutableList<Address> addedReviewersByEmail();
+
+    public abstract ImmutableList<Account.Id> addedCCs();
+
+    public abstract ImmutableList<Address> addedCCsByEmail();
+
+    static Builder builder() {
+      return new AutoValue_AddReviewersOp_Result.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setAddedReviewers(Iterable<PatchSetApproval> addedReviewers);
+
+      abstract Builder setAddedReviewersByEmail(Iterable<Address> addedReviewersByEmail);
+
+      abstract Builder setAddedCCs(Iterable<Account.Id> addedCCs);
+
+      abstract Builder setAddedCCsByEmail(Iterable<Address> addedCCsByEmail);
+
+      abstract Result build();
+    }
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ReviewerAdded reviewerAdded;
+  private final AccountCache accountCache;
+  private final ProjectCache projectCache;
+  private final AddReviewersEmail addReviewersEmail;
+  private final Set<Account.Id> accountIds;
+  private final Collection<Address> addresses;
+  private final ReviewerState state;
+
+  // Unlike addedCCs, addedReviewers is a PatchSetApproval because the AddReviewerResult returned
+  // via the REST API is supposed to include vote information.
+  private List<PatchSetApproval> addedReviewers = ImmutableList.of();
+  private Collection<Address> addedReviewersByEmail = ImmutableList.of();
+  private Collection<Account.Id> addedCCs = ImmutableList.of();
+  private Collection<Address> addedCCsByEmail = ImmutableList.of();
+
+  private boolean sendEmail = true;
+  private Change change;
+  private PatchSet patchSet;
+  private Result opResult;
+
+  @Inject
+  AddReviewersOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ReviewerAdded reviewerAdded,
+      AccountCache accountCache,
+      ProjectCache projectCache,
+      AddReviewersEmail addReviewersEmail,
+      @Assisted Set<Account.Id> accountIds,
+      @Assisted Collection<Address> addresses,
+      @Assisted ReviewerState state) {
+    checkArgument(state == REVIEWER || state == CC, "must be %s or %s: %s", REVIEWER, CC, state);
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.reviewerAdded = reviewerAdded;
+    this.accountCache = accountCache;
+    this.projectCache = projectCache;
+    this.addReviewersEmail = addReviewersEmail;
+
+    this.accountIds = accountIds;
+    this.addresses = addresses;
+    this.state = state;
+  }
+
+  // TODO(dborowitz): This mutable setter is ugly, but a) it's less ugly than adding boolean args
+  // all the way through the constructor stack, and b) this class is slated to be completely
+  // rewritten.
+  public void suppressEmail() {
+    this.sendEmail = false;
+  }
+
+  void setPatchSet(PatchSet patchSet) {
+    this.patchSet = requireNonNull(patchSet);
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException, IOException {
+    change = ctx.getChange();
+    if (!accountIds.isEmpty()) {
+      if (state == CC) {
+        addedCCs =
+            approvalsUtil.addCcs(
+                ctx.getNotes(), ctx.getUpdate(change.currentPatchSetId()), accountIds);
+      } else {
+        addedReviewers =
+            approvalsUtil.addReviewers(
+                ctx.getNotes(),
+                ctx.getUpdate(change.currentPatchSetId()),
+                projectCache.checkedGet(change.getProject()).getLabelTypes(change.getDest()),
+                change,
+                accountIds);
+      }
+    }
+
+    ImmutableList<Address> addressesToAdd = ImmutableList.of();
+    ReviewerStateInternal internalState = ReviewerStateInternal.fromReviewerState(state);
+
+    // TODO(dborowitz): This behavior should live in ApprovalsUtil or something, like addCcs does.
+    ImmutableSet<Address> existing = ctx.getNotes().getReviewersByEmail().byState(internalState);
+    addressesToAdd =
+        addresses.stream().filter(a -> !existing.contains(a)).collect(toImmutableList());
+
+    if (state == CC) {
+      addedCCsByEmail = addressesToAdd;
+    } else {
+      addedReviewersByEmail = addressesToAdd;
+    }
+    for (Address a : addressesToAdd) {
+      ctx.getUpdate(change.currentPatchSetId()).putReviewerByEmail(a, internalState);
+    }
+
+    if (addedCCs.isEmpty() && addedReviewers.isEmpty() && addressesToAdd.isEmpty()) {
+      return false;
+    }
+
+    checkAdded();
+
+    if (patchSet == null) {
+      patchSet = requireNonNull(psUtil.current(ctx.getNotes()));
+    }
+    return true;
+  }
+
+  private void checkAdded() {
+    // Should only affect either reviewers or CCs, not both. But the logic in updateChange is
+    // complex, so programmer error is conceivable.
+    boolean addedAnyReviewers = !addedReviewers.isEmpty() || !addedReviewersByEmail.isEmpty();
+    boolean addedAnyCCs = !addedCCs.isEmpty() || !addedCCsByEmail.isEmpty();
+    checkState(
+        !(addedAnyReviewers && addedAnyCCs),
+        "should not have added both reviewers and CCs:\n"
+            + "Arguments:\n"
+            + "  accountIds=%s\n"
+            + "  addresses=%s\n"
+            + "Results:\n"
+            + "  addedReviewers=%s\n"
+            + "  addedReviewersByEmail=%s\n"
+            + "  addedCCs=%s\n"
+            + "  addedCCsByEmail=%s",
+        accountIds,
+        addresses,
+        addedReviewers,
+        addedReviewersByEmail,
+        addedCCs,
+        addedCCsByEmail);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws Exception {
+    opResult =
+        Result.builder()
+            .setAddedReviewers(addedReviewers)
+            .setAddedReviewersByEmail(addedReviewersByEmail)
+            .setAddedCCs(addedCCs)
+            .setAddedCCsByEmail(addedCCsByEmail)
+            .build();
+    if (sendEmail) {
+      addReviewersEmail.emailReviewers(
+          ctx.getUser().asIdentifiedUser(),
+          change,
+          Lists.transform(addedReviewers, PatchSetApproval::accountId),
+          addedCCs,
+          addedReviewersByEmail,
+          addedCCsByEmail,
+          ctx.getNotify(change.getId()));
+    }
+    if (!addedReviewers.isEmpty()) {
+      List<AccountState> reviewers =
+          addedReviewers.stream()
+              .map(r -> accountCache.get(r.accountId()))
+              .flatMap(Streams::stream)
+              .collect(toList());
+      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+    }
+  }
+
+  public Result getResult() {
+    checkState(opResult != null, "Batch update wasn't executed yet");
+    return opResult;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ArchiveFormat.java b/java/com/google/gerrit/server/change/ArchiveFormat.java
index 0316c5f..d895a66 100644
--- a/java/com/google/gerrit/server/change/ArchiveFormat.java
+++ b/java/com/google/gerrit/server/change/ArchiveFormat.java
@@ -35,7 +35,9 @@
   TXZ("application/x-xz", new TxzFormat()),
   ZIP("application/x-zip", new ZipFormat());
 
+  @SuppressWarnings("ImmutableEnumChecker") // ArchiveCommand.Format is effectively immutable.
   private final ArchiveCommand.Format<?> format;
+
   private final String mimeType;
 
   ArchiveFormat(String mimeType, ArchiveCommand.Format<?> format) {
diff --git a/java/com/google/gerrit/server/change/BatchAbandon.java b/java/com/google/gerrit/server/change/BatchAbandon.java
index 0ecfcb0..8c67531 100644
--- a/java/com/google/gerrit/server/change/BatchAbandon.java
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -14,34 +14,25 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.Collection;
 
 @Singleton
 public class BatchAbandon {
-  private final Provider<ReviewDb> dbProvider;
   private final AbandonOp.Factory abandonOpFactory;
 
   @Inject
-  BatchAbandon(Provider<ReviewDb> dbProvider, AbandonOp.Factory abandonOpFactory) {
-    this.dbProvider = dbProvider;
+  BatchAbandon(AbandonOp.Factory abandonOpFactory) {
     this.abandonOpFactory = abandonOpFactory;
   }
 
@@ -58,14 +49,14 @@
       CurrentUser user,
       Collection<ChangeData> changes,
       String msgTxt,
-      NotifyHandling notifyHandling,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      NotifyResolver.Result notify)
       throws RestApiException, UpdateException {
     if (changes.isEmpty()) {
       return;
     }
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
-    try (BatchUpdate u = updateFactory.create(dbProvider.get(), project, user, TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.nowTs())) {
+      u.setNotify(notify);
       for (ChangeData change : changes) {
         if (!project.equals(change.project())) {
           throw new ResourceConflictException(
@@ -73,9 +64,7 @@
                   "Project name \"%s\" doesn't match \"%s\"",
                   change.project().get(), project.get()));
         }
-        u.addOp(
-            change.getId(),
-            abandonOpFactory.create(accountState, msgTxt, notifyHandling, accountsToNotify));
+        u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
       }
       u.execute();
     }
@@ -88,14 +77,7 @@
       Collection<ChangeData> changes,
       String msgTxt)
       throws RestApiException, UpdateException {
-    batchAbandon(
-        updateFactory,
-        project,
-        user,
-        changes,
-        msgTxt,
-        NotifyHandling.ALL,
-        ImmutableListMultimap.of());
+    batchAbandon(updateFactory, project, user, changes, msgTxt, NotifyResolver.Result.all());
   }
 
   public void batchAbandon(
@@ -104,7 +86,6 @@
       CurrentUser user,
       Collection<ChangeData> changes)
       throws RestApiException, UpdateException {
-    batchAbandon(
-        updateFactory, project, user, changes, "", NotifyHandling.ALL, ImmutableListMultimap.of());
+    batchAbandon(updateFactory, project, user, changes, "", NotifyResolver.Result.all());
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
new file mode 100644
index 0000000..95355cf
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.DynamicOptions.BeanProvider;
+import com.google.gerrit.server.query.change.ChangeData;
+
+/**
+ * Interface for plugins to provide additional fields in {@link
+ * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
+ *
+ * <p>Register a {@code ChangeAttributeFactory} in a plugin {@code Module} like this:
+ *
+ * <pre>
+ * DynamicSet.bind(binder(), ChangeAttributeFactory.class).to(YourClass.class);
+ * </pre>
+ *
+ * <p>See the <a
+ * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
+ * developer documentation for more details and examples.
+ */
+public interface ChangeAttributeFactory {
+
+  /**
+   * Create a plugin-provided info field.
+   *
+   * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
+   *
+   * @param cd change.
+   * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
+   * @param plugin plugin name.
+   * @return the plugin's special change info.
+   */
+  PluginDefinedInfo create(ChangeData cd, BeanProvider beanProvider, String plugin);
+}
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index b24d3ce..0299f10 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
 /** Runnable to enable scheduling change cleanups to run periodically */
@@ -85,7 +84,7 @@
             abandonUtil.abandonInactiveOpenChanges(updateFactory);
             return null;
           });
-    } catch (RestApiException | UpdateException | OrmException e) {
+    } catch (RestApiException | UpdateException e) {
       logger.atSevere().withCause(e).log("Failed to cleanup changes.");
     }
   }
diff --git a/java/com/google/gerrit/server/change/ChangeETagComputation.java b/java/com/google/gerrit/server/change/ChangeETagComputation.java
new file mode 100644
index 0000000..463989b
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeETagComputation.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+
+/**
+ * Allows plugins to contribute a value to the change ETag computation.
+ *
+ * <p>Plugins can affect the result of the get change / get change details REST endpoints by:
+ *
+ * <ul>
+ *   <li>providing plugin defined attributes to {@link
+ *       com.google.gerrit.extensions.common.ChangeInfo#plugins} (see {@link
+ *       ChangeAttributeFactory})
+ *   <li>implementing a {@link com.google.gerrit.server.rules.SubmitRule} which affects the
+ *       computation of {@link com.google.gerrit.extensions.common.ChangeInfo#submittable}
+ * </ul>
+ *
+ * <p>If the plugin defined part of {@link com.google.gerrit.extensions.common.ChangeInfo} depends
+ * on plugin specific data, callers that use the change ETags to avoid unneeded recomputations of
+ * ChangeInfos may see outdated plugin attributes and/or outdated submittable information, because a
+ * ChangeInfo is only reloaded if the change ETag changes.
+ *
+ * <p>By implementating this interface plugins can contribute to the change ETag computation and
+ * thus ensure that the ETag changes when the plugin data was changed. This way it is ensured that
+ * callers do not see outdated ChangeInfos.
+ *
+ * @see ChangeResource#getETag()
+ */
+@ExtensionPoint
+public interface ChangeETagComputation {
+  /**
+   * Computes an ETag of plugin-specific data for the given change.
+   *
+   * <p><strong>Note:</strong> Change ETags are computed very frequently and the computation must be
+   * cheap. Take good care to not perform any expensive computations when implementing this.
+   *
+   * <p>If an error is encountered during the ETag computation the plugin can indicate this by
+   * throwing any RuntimeException. In this case no value will be included in the change ETag
+   * computation. This means if the error is transient, the ETag will differ when the computation
+   * succeeds on a follow-up run.
+   *
+   * @param projectName the name of the project that contains the change
+   * @param changeId ID of the change for which the ETag should be computed
+   * @return the ETag
+   */
+  String getETag(Project.NameKey projectName, Change.Id changeId);
+}
diff --git a/java/com/google/gerrit/server/change/ChangeEditResource.java b/java/com/google/gerrit/server/change/ChangeEditResource.java
index 08bcabe..392709e 100644
--- a/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -20,7 +20,7 @@
 import com.google.inject.TypeLiteral;
 
 /**
- * Represents change edit resource, that is actualy two kinds of resources:
+ * Represents change edit resource, that is actually two kinds of resources:
  *
  * <ul>
  *   <li>the change edit itself
diff --git a/java/com/google/gerrit/server/change/ChangeFinder.java b/java/com/google/gerrit/server/change/ChangeFinder.java
new file mode 100644
index 0000000..2d4f105
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeFinder.java
@@ -0,0 +1,275 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Throwables;
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class ChangeFinder {
+  private static final String CACHE_NAME = "changeid_project";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(CACHE_NAME, Change.Id.class, String.class).maximumWeight(1024);
+      }
+    };
+  }
+
+  public enum ChangeIdType {
+    ALL,
+    TRIPLET,
+    NUMERIC_ID,
+    I_HASH,
+    PROJECT_NUMERIC_ID,
+    COMMIT_HASH
+  }
+
+  private final IndexConfig indexConfig;
+  private final Cache<Change.Id, String> changeIdProjectCache;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final Counter1<ChangeIdType> changeIdCounter;
+  private final ImmutableSet<ChangeIdType> allowedIdTypes;
+
+  @Inject
+  ChangeFinder(
+      IndexConfig indexConfig,
+      @Named(CACHE_NAME) Cache<Change.Id, String> changeIdProjectCache,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeNotes.Factory changeNotesFactory,
+      MetricMaker metricMaker,
+      @GerritServerConfig Config config) {
+    this.indexConfig = indexConfig;
+    this.changeIdProjectCache = changeIdProjectCache;
+    this.queryProvider = queryProvider;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeIdCounter =
+        metricMaker.newCounter(
+            "http/server/rest_api/change_id_type",
+            new Description("Total number of API calls per identifier type.")
+                .setRate()
+                .setUnit("requests"),
+            Field.ofEnum(ChangeIdType.class, "change_id_type", Metadata.Builder::changeIdType)
+                .build());
+    List<ChangeIdType> configuredChangeIdTypes =
+        ConfigUtil.getEnumList(config, "change", "api", "allowedIdentifier", ChangeIdType.ALL);
+    // Ensure that PROJECT_NUMERIC_ID can't be removed
+    configuredChangeIdTypes.add(ChangeIdType.PROJECT_NUMERIC_ID);
+    this.allowedIdTypes = ImmutableSet.copyOf(configuredChangeIdTypes);
+  }
+
+  public ChangeNotes findOne(String id) {
+    List<ChangeNotes> ctls = find(id);
+    if (ctls.size() != 1) {
+      return null;
+    }
+    return ctls.get(0);
+  }
+
+  /**
+   * Find changes matching the given identifier.
+   *
+   * @param id change identifier.
+   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
+   */
+  public List<ChangeNotes> find(String id) {
+    try {
+      return find(id, false);
+    } catch (DeprecatedIdentifierException e) {
+      // This can't happen because we don't enforce deprecation
+      throw new StorageException(e);
+    }
+  }
+
+  /**
+   * Find changes matching the given identifier.
+   *
+   * @param id change identifier.
+   * @param enforceDeprecation boolean to see if we should throw {@link
+   *     DeprecatedIdentifierException} in case the identifier is deprecated
+   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
+   * @throws DeprecatedIdentifierException if the identifier is deprecated.
+   */
+  public List<ChangeNotes> find(String id, boolean enforceDeprecation)
+      throws DeprecatedIdentifierException {
+    if (id.isEmpty()) {
+      return Collections.emptyList();
+    }
+
+    int z = id.lastIndexOf('~');
+    int y = id.lastIndexOf('~', z - 1);
+    if (y < 0 && z > 0) {
+      // Try project~numericChangeId
+      Integer n = Ints.tryParse(id.substring(z + 1));
+      if (n != null) {
+        checkIdType(ChangeIdType.PROJECT_NUMERIC_ID, enforceDeprecation, n.toString());
+        return fromProjectNumber(id.substring(0, z), n.intValue());
+      }
+    }
+
+    if (y < 0 && z < 0) {
+      // Try numeric changeId
+      Integer n = Ints.tryParse(id);
+      if (n != null) {
+        checkIdType(ChangeIdType.NUMERIC_ID, enforceDeprecation, n.toString());
+        return find(Change.id(n));
+      }
+    }
+
+    // Use the index to search for changes, but don't return any stored fields,
+    // to force rereading in case the index is stale.
+    InternalChangeQuery query = queryProvider.get().noFields();
+
+    // Try commit hash
+    if (id.matches("^([0-9a-fA-F]{" + ObjectIds.ABBREV_STR_LEN + "," + ObjectIds.STR_LEN + "})$")) {
+      checkIdType(ChangeIdType.COMMIT_HASH, enforceDeprecation, id);
+      return asChangeNotes(query.byCommit(id));
+    }
+
+    if (y > 0 && z > 0) {
+      // Try change triplet (project~branch~Ihash...)
+      Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id, y, z);
+      if (triplet.isPresent()) {
+        ChangeTriplet t = triplet.get();
+        checkIdType(ChangeIdType.TRIPLET, enforceDeprecation, triplet.get().toString());
+        return asChangeNotes(query.byBranchKey(t.branch(), t.id()));
+      }
+    }
+
+    // Try isolated Ihash... format ("Change-Id: Ihash").
+    List<ChangeNotes> notes = asChangeNotes(query.byKeyPrefix(id));
+    if (!notes.isEmpty()) {
+      checkIdType(ChangeIdType.I_HASH, enforceDeprecation, id);
+    }
+    return notes;
+  }
+
+  private List<ChangeNotes> fromProjectNumber(String project, int changeNumber) {
+    Change.Id cId = Change.id(changeNumber);
+    try {
+      return ImmutableList.of(
+          changeNotesFactory.createChecked(Project.NameKey.parse(project), cId));
+    } catch (NoSuchChangeException e) {
+      return Collections.emptyList();
+    } catch (StorageException e) {
+      // Distinguish between a RepositoryNotFoundException (project argument invalid) and
+      // other StorageExceptions (failure in the persistence layer).
+      if (Throwables.getRootCause(e) instanceof RepositoryNotFoundException) {
+        return Collections.emptyList();
+      }
+      throw e;
+    }
+  }
+
+  public ChangeNotes findOne(Change.Id id) {
+    List<ChangeNotes> notes = find(id);
+    if (notes.size() != 1) {
+      throw new NoSuchChangeException(id);
+    }
+    return notes.get(0);
+  }
+
+  public List<ChangeNotes> find(Change.Id id) {
+    String project = changeIdProjectCache.getIfPresent(id);
+    if (project != null) {
+      return fromProjectNumber(project, id.get());
+    }
+
+    // Use the index to search for changes, but don't return any stored fields,
+    // to force rereading in case the index is stale.
+    InternalChangeQuery query = queryProvider.get().noFields();
+    List<ChangeData> r = query.byLegacyChangeId(id);
+    if (r.size() == 1) {
+      changeIdProjectCache.put(id, Url.encode(r.get(0).project().get()));
+    }
+    return asChangeNotes(r);
+  }
+
+  private List<ChangeNotes> asChangeNotes(List<ChangeData> cds) {
+    List<ChangeNotes> notes = new ArrayList<>(cds.size());
+    if (!indexConfig.separateChangeSubIndexes()) {
+      for (ChangeData cd : cds) {
+        notes.add(cd.notes());
+      }
+      return notes;
+    }
+
+    // If an index implementation uses separate non-atomic subindexes, it's possible to temporarily
+    // observe a change as present in both subindexes, if this search is concurrent with a write.
+    // Dedup to avoid confusing the caller. We can choose an arbitrary ChangeData instance because
+    // the index results have no stored fields, so the data is already reloaded. (It's also possible
+    // that a change might appear in zero subindexes, but there's nothing we can do here to help
+    // this case.)
+    Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
+    for (ChangeData cd : cds) {
+      if (seen.add(cd.getId())) {
+        notes.add(cd.notes());
+      }
+    }
+    return notes;
+  }
+
+  private void checkIdType(ChangeIdType type, boolean enforceDeprecation, String val)
+      throws DeprecatedIdentifierException {
+    if (enforceDeprecation
+        && !allowedIdTypes.contains(ChangeIdType.ALL)
+        && !allowedIdTypes.contains(type)) {
+      throw new DeprecatedIdentifierException(
+          String.format(
+              "The provided change identifier %s is deprecated. "
+                  + "Use 'project~changeNumber' instead.",
+              val));
+    }
+    changeIdCounter.increment(type);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index c6fe93b..77f3e73 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -14,33 +14,40 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.reviewdb.client.Change.INITIAL_PATCH_SET_ID;
+import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static java.util.stream.Collectors.toSet;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ReviewerState;
 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.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.CommentAdded;
@@ -49,11 +56,8 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -64,18 +68,17 @@
 import com.google.gerrit.server.update.InsertChangeOp;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -100,7 +103,7 @@
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
-  private final NotesMigration migration;
+  private final ReviewerAdder reviewerAdder;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -116,16 +119,13 @@
   private boolean workInProgress;
   private List<String> groups = Collections.emptyList();
   private boolean validate = true;
-  private NotifyHandling notify = NotifyHandling.ALL;
-  private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
-  private Set<Account.Id> reviewers;
-  private Set<Account.Id> extraCC;
   private Map<String, Short> approvals;
   private RequestScopePropagator requestScopePropagator;
   private boolean fireRevisionCreated;
   private boolean sendMail;
   private boolean updateRef;
   private Change.Id revertOf;
+  private ImmutableList<InternalAddReviewerInput> reviewerInputs;
 
   // Fields set during the insertion process.
   private ReceiveCommand cmd;
@@ -135,6 +135,7 @@
   private PatchSet patchSet;
   private String pushCert;
   private ProjectState projectState;
+  private ReviewerAdditionList reviewerAdditions;
 
   @Inject
   ChangeInserter(
@@ -149,7 +150,7 @@
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
-      NotesMigration migration,
+      ReviewerAdder reviewerAdder,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -164,14 +165,13 @@
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
-    this.migration = migration;
+    this.reviewerAdder = reviewerAdder;
 
     this.changeId = changeId;
-    this.psId = new PatchSet.Id(changeId, INITIAL_PATCH_SET_ID);
+    this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
     this.commitId = commitId.copy();
     this.refName = refName;
-    this.reviewers = Collections.emptySet();
-    this.extraCC = Collections.emptySet();
+    this.reviewerInputs = ImmutableList.of();
     this.approvals = Collections.emptyMap();
     this.fireRevisionCreated = true;
     this.sendMail = true;
@@ -185,7 +185,7 @@
             getChangeKey(ctx.getRevWalk(), commitId),
             changeId,
             ctx.getAccountId(),
-            new Branch.NameKey(ctx.getProject(), refName),
+            BranchNameKey.create(ctx.getProject(), refName),
             ctx.getWhen());
     change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
     change.setTopic(topic);
@@ -201,8 +201,10 @@
     rw.parseBody(commit);
     List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
     if (!idList.isEmpty()) {
-      return new Change.Key(idList.get(idList.size() - 1).trim());
+      return Change.key(idList.get(idList.size() - 1).trim());
     }
+    // A Change-Id is computed for the review, but not appended to the commit message.
+    // This can happen if requireChangeId is false.
     ObjectId changeId =
         ChangeIdUtil.computeChangeId(
             commit.getTree(),
@@ -212,7 +214,7 @@
             commit.getShortMessage());
     StringBuilder changeIdStr = new StringBuilder();
     changeIdStr.append("I").append(ObjectId.toString(changeId));
-    return new Change.Key(changeIdStr.toString());
+    return Change.key(changeIdStr.toString());
   }
 
   public PatchSet.Id getPatchSetId() {
@@ -249,24 +251,22 @@
     return this;
   }
 
-  public ChangeInserter setNotify(NotifyHandling notify) {
-    this.notify = notify;
-    return this;
+  public ChangeInserter setReviewersAndCcs(
+      Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
+    return setReviewersAndCcsAsStrings(
+        Iterables.transform(reviewers, Account.Id::toString),
+        Iterables.transform(ccs, Account.Id::toString));
   }
 
-  public ChangeInserter setAccountsToNotify(
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    this.accountsToNotify = checkNotNull(accountsToNotify);
-    return this;
-  }
-
-  public ChangeInserter setReviewers(Set<Account.Id> reviewers) {
-    this.reviewers = reviewers;
-    return this;
-  }
-
-  public ChangeInserter setExtraCC(Set<Account.Id> extraCC) {
-    this.extraCC = extraCC;
+  public ChangeInserter setReviewersAndCcsAsStrings(
+      Iterable<String> reviewers, Iterable<String> ccs) {
+    reviewerInputs =
+        Streams.concat(
+                Streams.stream(reviewers)
+                    .distinct()
+                    .map(id -> newAddReviewerInput(id, ReviewerState.REVIEWER)),
+                Streams.stream(ccs).distinct().map(id -> newAddReviewerInput(id, ReviewerState.CC)))
+            .collect(toImmutableList());
     return this;
   }
 
@@ -288,7 +288,7 @@
   }
 
   public ChangeInserter setGroups(List<String> groups) {
-    checkNotNull(groups, "groups may not be empty");
+    requireNonNull(groups, "groups may not be empty");
     checkState(patchSet == null, "setGroups(Iterable<String>) only valid before creating change");
     this.groups = groups;
     return this;
@@ -370,9 +370,8 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException, PermissionBackendException {
+      throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException {
     change = ctx.getChange(); // Use defensive copy created by ChangeControl.
-    ReviewDb db = ctx.getDb();
     patchSetInfo =
         patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
     ctx.getChange().setCurrentPatchSet(patchSetInfo);
@@ -380,7 +379,7 @@
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setChangeId(change.getKey().get());
     update.setSubjectForCommit("Create change");
-    update.setBranch(change.getDest().get());
+    update.setBranch(change.getDest().branch());
     update.setTopic(change.getTopic());
     update.setPsDescription(patchSetDescription);
     update.setPrivate(isPrivate);
@@ -395,14 +394,7 @@
     }
     patchSet =
         psUtil.insert(
-            ctx.getDb(),
-            ctx.getRevWalk(),
-            update,
-            psId,
-            commitId,
-            newGroups,
-            pushCert,
-            patchSetDescription);
+            ctx.getRevWalk(), update, psId, commitId, newGroups, pushCert, patchSetDescription);
 
     /* TODO: fixStatus is used here because the tests
      * (byStatusClosed() in AbstractQueryChangesTest)
@@ -414,71 +406,43 @@
      */
     update.fixStatus(change.getStatus());
 
-    Set<Account.Id> reviewersToAdd = new HashSet<>(reviewers);
-    if (migration.readChanges()) {
-      approvalsUtil.addCcs(
-          ctx.getNotes(), update, filterOnChangeVisibility(db, ctx.getNotes(), extraCC));
-    } else {
-      reviewersToAdd.addAll(extraCC);
+    reviewerAdditions =
+        reviewerAdder.prepare(ctx.getNotes(), ctx.getUser(), getReviewerInputs(), true);
+    Optional<ReviewerAddition> reviewerError = reviewerAdditions.getFailures().stream().findFirst();
+    if (reviewerError.isPresent()) {
+      throw new UnprocessableEntityException(reviewerError.get().result.error);
     }
+    reviewerAdditions.updateChange(ctx, patchSet);
 
     LabelTypes labelTypes = projectState.getLabelTypes();
-    approvalsUtil.addReviewers(
-        db,
-        update,
-        labelTypes,
-        change,
-        patchSet,
-        patchSetInfo,
-        filterOnChangeVisibility(db, ctx.getNotes(), reviewersToAdd),
-        Collections.<Account.Id>emptySet());
     approvalsUtil.addApprovalsForNewPatchSet(
-        db, update, labelTypes, patchSet, ctx.getUser(), approvals);
+        update, labelTypes, patchSet, ctx.getUser(), approvals);
+
     // Check if approvals are changing in with this update. If so, add current user to reviewers.
     // Note that this is done separately as addReviewers is filtering out the change owner as
     // reviewer which is needed in several other code paths.
+    // TODO(dborowitz): Still necessary?
     if (!approvals.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
     if (message != null) {
       changeMessage =
           ChangeMessagesUtil.newMessage(
-              patchSet.getId(),
+              patchSet.id(),
               ctx.getUser(),
-              patchSet.getCreatedOn(),
+              patchSet.createdOn(),
               message,
               ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
-      cmUtil.addChangeMessage(db, update, changeMessage);
+      cmUtil.addChangeMessage(update, changeMessage);
     }
     return true;
   }
 
-  private Set<Account.Id> filterOnChangeVisibility(
-      final ReviewDb db, ChangeNotes notes, Set<Account.Id> accounts) {
-    return accounts
-        .stream()
-        .filter(
-            accountId -> {
-              try {
-                return permissionBackend
-                        .absentUser(accountId)
-                        .change(notes)
-                        .database(db)
-                        .test(ChangePermission.READ)
-                    && projectState.statePermitsRead();
-              } catch (PermissionBackendException e) {
-                logger.atWarning().withCause(e).log(
-                    "Failed to check if account %d can see change %d",
-                    accountId.get(), notes.getChangeId().get());
-                return false;
-              }
-            })
-        .collect(toSet());
-  }
-
   @Override
-  public void postUpdate(Context ctx) throws OrmException, IOException {
-    if (sendMail && (notify != NotifyHandling.NONE || !accountsToNotify.isEmpty())) {
+  public void postUpdate(Context ctx) throws Exception {
+    reviewerAdditions.postUpdate(ctx);
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
+    if (sendMail && notify.shouldNotify()) {
       Runnable sender =
           new Runnable() {
             @Override
@@ -489,9 +453,15 @@
                 cm.setFrom(change.getOwner());
                 cm.setPatchSet(patchSet, patchSetInfo);
                 cm.setNotify(notify);
-                cm.setAccountsToNotify(accountsToNotify);
-                cm.addReviewers(reviewers);
-                cm.addExtraCC(extraCC);
+                cm.addReviewers(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
+                        .map(PatchSetApproval::accountId)
+                        .collect(toImmutableSet()));
+                cm.addReviewersByEmail(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
+                cm.addExtraCC(reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
+                cm.addExtraCCByEmail(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
                 cm.send();
               } catch (Exception e) {
                 logger.atSevere().withCause(e).log(
@@ -521,8 +491,7 @@
     if (fireRevisionCreated) {
       revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
       if (approvals != null && !approvals.isEmpty()) {
-        List<LabelType> labels =
-            projectState.getLabelTypes(change.getDest(), ctx.getUser()).getLabelTypes();
+        List<LabelType> labels = projectState.getLabelTypes(change.getDest()).getLabelTypes();
         Map<String, Short> allApprovals = new HashMap<>();
         Map<String, Short> oldApprovals = new HashMap<>();
         for (LabelType lt : labels) {
@@ -546,21 +515,19 @@
       return;
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).project(ctx.getProject()).ref(refName);
     try {
       try (CommitReceivedEvent event =
           new CommitReceivedEvent(
               cmd,
               projectState.getProject(),
-              change.getDest().get(),
+              change.getDest().branch(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
               ctx.getIdentifiedUser())) {
         commitValidatorsFactory
             .forGerritCommits(
-                perm,
-                new Branch.NameKey(ctx.getProject(), refName),
+                permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
+                BranchNameKey.create(ctx.getProject(), refName),
                 ctx.getIdentifiedUser(),
                 new NoSshInfo(),
                 ctx.getRevWalk(),
@@ -571,4 +538,33 @@
       throw new ResourceConflictException(e.getFullMessage());
     }
   }
+
+  private static InternalAddReviewerInput newAddReviewerInput(
+      String reviewer, ReviewerState state) {
+    // Disable individual emails when adding reviewers, as all reviewers will receive the single
+    // bulk new change email.
+    InternalAddReviewerInput input =
+        ReviewerAdder.newAddReviewerInput(reviewer, state, NotifyHandling.NONE);
+
+    // Ignore failures for reasons like the reviewer being inactive or being unable to see the
+    // change. This is required for the push path, where it automatically sets reviewers from
+    // certain commit footers: putting a nonexistent user in a footer should not cause an error. In
+    // theory we could provide finer control to do this for some reviewers and not others, but it's
+    // not worth complicating the ChangeInserter interface further at this time.
+    input.otherFailureBehavior = ReviewerAdder.FailureBehavior.IGNORE;
+
+    return input;
+  }
+
+  private ImmutableList<InternalAddReviewerInput> getReviewerInputs() {
+    return Streams.concat(
+            reviewerInputs.stream(),
+            Streams.stream(
+                newAddReviewerInputFromCommitIdentity(
+                    change, patchSetInfo.getAuthor().getAccount(), NotifyHandling.NONE)),
+            Streams.stream(
+                newAddReviewerInputFromCommitIdentity(
+                    change, patchSetInfo.getCommitter().getAccount(), NotifyHandling.NONE)))
+        .collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 8629898..a3c2e92 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -14,59 +14,42 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
-import static com.google.gerrit.extensions.client.ListChangesOption.ALL_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
 import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.DOWNLOAD_COMMANDS;
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
 import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_MERGEABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
-import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
-import static com.google.gerrit.server.CommonConverters.toGitPerson;
 import static java.util.stream.Collectors.toList;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Throwables;
-import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRecord.Status;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -74,23 +57,15 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.FetchInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
-import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
-import com.google.gerrit.extensions.common.VotingRangeInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.config.DownloadCommand;
-import com.google.gerrit.extensions.config.DownloadScheme;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
@@ -98,74 +73,46 @@
 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.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
-import com.google.gerrit.server.query.change.PluginDefinedAttributesFactory;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TreeMap;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
+import java.util.function.Supplier;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
@@ -182,7 +129,7 @@
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().build();
 
-  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+  static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
       ImmutableSet.of(
           ALL_COMMITS,
           ALL_REVISIONS,
@@ -207,7 +154,13 @@
     }
 
     public ChangeJson create(Iterable<ListChangesOption> options) {
-      return factory.create(options);
+      return factory.create(options, Optional.empty());
+    }
+
+    public ChangeJson create(
+        Iterable<ListChangesOption> options,
+        PluginDefinedAttributesFactory pluginDefinedAttributesFactory) {
+      return factory.create(options, Optional.of(pluginDefinedAttributesFactory));
     }
 
     public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
@@ -216,7 +169,9 @@
   }
 
   public interface AssistedFactory {
-    ChangeJson create(Iterable<ListChangesOption> options);
+    ChangeJson create(
+        Iterable<ListChangesOption> options,
+        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory);
   }
 
   @Singleton
@@ -248,107 +203,65 @@
     }
   }
 
-  private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> userProvider;
-  private final AnonymousUser anonymous;
   private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager repoManager;
-  private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeData.Factory changeDataFactory;
-  private final FileInfoJson fileInfoJson;
   private final AccountLoader.Factory accountLoaderFactory;
-  private final DynamicMap<DownloadScheme> downloadSchemes;
-  private final DynamicMap<DownloadCommand> downloadCommands;
-  private final WebLinks webLinks;
   private final ImmutableSet<ListChangesOption> options;
   private final ChangeMessagesUtil cmUtil;
   private final Provider<ConsistencyChecker> checkerProvider;
   private final ActionJson actionJson;
-  private final GpgApiAdapter gpgApi;
   private final ChangeNotes.Factory notesFactory;
-  private final ChangeResource.Factory changeResourceFactory;
-  private final ChangeKindCache changeKindCache;
-  private final ApprovalsUtil approvalsUtil;
+  private final LabelsJson labelsJson;
   private final RemoveReviewerControl removeReviewerControl;
   private final TrackingFooters trackingFooters;
   private final Metrics metrics;
-  private final boolean enableParallelFormatting;
-  private final ExecutorService fanOutExecutor;
+  private final RevisionJson revisionJson;
+  private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
+  private final boolean excludeMergeableInChangeInfo;
+  private final boolean lazyLoad;
 
-  private boolean lazyLoad = true;
   private AccountLoader accountLoader;
   private FixInput fix;
-  private PluginDefinedAttributesFactory pluginDefinedAttributesFactory;
 
   @Inject
   ChangeJson(
-      Provider<ReviewDb> db,
       Provider<CurrentUser> user,
-      AnonymousUser au,
       PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
-      ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
-      IdentifiedUser.GenericFactory uf,
       ChangeData.Factory cdf,
-      FileInfoJson fileInfoJson,
       AccountLoader.Factory ailf,
-      DynamicMap<DownloadScheme> downloadSchemes,
-      DynamicMap<DownloadCommand> downloadCommands,
-      WebLinks webLinks,
       ChangeMessagesUtil cmUtil,
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
-      GpgApiAdapter gpgApi,
       ChangeNotes.Factory notesFactory,
-      ChangeResource.Factory changeResourceFactory,
-      ChangeKindCache changeKindCache,
-      ApprovalsUtil approvalsUtil,
+      LabelsJson.Factory labelsJsonFactory,
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
       Metrics metrics,
-      @GerritServerConfig Config config,
-      @FanOutExecutor ExecutorService fanOutExecutor,
-      @Assisted Iterable<ListChangesOption> options) {
-    this.db = db;
+      RevisionJson.Factory revisionJsonFactory,
+      @GerritServerConfig Config cfg,
+      @Assisted Iterable<ListChangesOption> options,
+      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory) {
     this.userProvider = user;
-    this.anonymous = au;
     this.changeDataFactory = cdf;
     this.permissionBackend = permissionBackend;
-    this.repoManager = repoManager;
-    this.userFactory = uf;
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.fileInfoJson = fileInfoJson;
     this.accountLoaderFactory = ailf;
-    this.downloadSchemes = downloadSchemes;
-    this.downloadCommands = downloadCommands;
-    this.webLinks = webLinks;
     this.cmUtil = cmUtil;
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
-    this.gpgApi = gpgApi;
     this.notesFactory = notesFactory;
-    this.changeResourceFactory = changeResourceFactory;
-    this.changeKindCache = changeKindCache;
-    this.approvalsUtil = approvalsUtil;
+    this.labelsJson = labelsJsonFactory.create(options);
     this.removeReviewerControl = removeReviewerControl;
     this.trackingFooters = trackingFooters;
     this.metrics = metrics;
-    this.enableParallelFormatting = config.getBoolean("change", "enableParallelFormatting", false);
-    this.fanOutExecutor = fanOutExecutor;
+    this.revisionJson = revisionJsonFactory.create(options);
     this.options = Sets.immutableEnumSet(options);
-  }
+    this.excludeMergeableInChangeInfo =
+        cfg.getBoolean("change", "api", "excludeMergeableInChangeInfo", false);
+    this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
+    this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
 
-  /**
-   * See {@link ChangeData#setLazyLoad(boolean)}. If lazyLoad is set, converting data from
-   * index-backed {@link ChangeData} will fail with an exception.
-   */
-  public ChangeJson lazyLoad(boolean load) {
-    lazyLoad = load;
-    return this;
+    logger.atFine().log("options = %s", options);
   }
 
   public ChangeJson fix(FixInput fix) {
@@ -356,74 +269,36 @@
     return this;
   }
 
-  public void setPluginDefinedAttributesFactory(PluginDefinedAttributesFactory pluginsFactory) {
-    this.pluginDefinedAttributesFactory = pluginsFactory;
+  public ChangeInfo format(ChangeResource rsrc) {
+    return format(changeDataFactory.create(rsrc.getNotes()));
   }
 
-  public ChangeInfo format(ChangeResource rsrc) throws OrmException {
-    return format(changeDataFactory.create(db.get(), rsrc.getNotes()));
+  public ChangeInfo format(Change change) {
+    return format(changeDataFactory.create(change));
   }
 
-  public ChangeInfo format(Change change) throws OrmException {
-    return format(changeDataFactory.create(db.get(), change));
+  public ChangeInfo format(Project.NameKey project, Change.Id id) {
+    return format(project, id, ChangeInfo::new);
   }
 
-  public ChangeInfo format(Project.NameKey project, Change.Id id) throws OrmException {
-    ChangeNotes notes;
-    try {
-      notes = notesFactory.createChecked(db.get(), project, id);
-    } catch (OrmException e) {
-      if (!has(CHECK)) {
-        throw e;
-      }
-      return checkOnly(changeDataFactory.create(db.get(), project, id));
-    }
-    return format(changeDataFactory.create(db.get(), notes));
+  public ChangeInfo format(ChangeData cd) {
+    return format(cd, Optional.empty(), true, ChangeInfo::new);
   }
 
-  public ChangeInfo format(ChangeData cd) throws OrmException {
-    return format(cd, Optional.empty(), true);
+  public ChangeInfo format(RevisionResource rsrc) {
+    ChangeData cd = changeDataFactory.create(rsrc.getNotes());
+    return format(cd, Optional.of(rsrc.getPatchSet().id()), true, ChangeInfo::new);
   }
 
-  private ChangeInfo format(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader)
-      throws OrmException {
-    try {
-      if (fillAccountLoader) {
-        accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-        ChangeInfo res = toChangeInfo(cd, limitToPsId);
-        accountLoader.fill();
-        return res;
-      }
-      return toChangeInfo(cd, limitToPsId);
-    } catch (PatchListNotAvailableException
-        | GpgException
-        | OrmException
-        | IOException
-        | PermissionBackendException
-        | RuntimeException e) {
-      if (!has(CHECK)) {
-        Throwables.throwIfInstanceOf(e, OrmException.class);
-        throw new OrmException(e);
-      }
-      return checkOnly(cd);
-    }
-  }
-
-  public ChangeInfo format(RevisionResource rsrc) throws OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().getId()), true);
-  }
-
-  public List<List<ChangeInfo>> formatQueryResults(List<QueryResult<ChangeData>> in)
-      throws OrmException {
+  public List<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
+      throws PermissionBackendException {
     try (Timer0.Context ignored = metrics.formatQueryResultsLatency.start()) {
       accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
       List<List<ChangeInfo>> res = new ArrayList<>(in.size());
       Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
       for (QueryResult<ChangeData> r : in) {
         List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
-        infos.forEach(c -> cache.put(new Change.Id(c._number), c));
+        infos.forEach(c -> cache.put(Change.id(c._number), c));
         if (!infos.isEmpty() && r.more()) {
           infos.get(infos.size() - 1)._moreChanges = true;
         }
@@ -434,17 +309,31 @@
     }
   }
 
-  public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in) throws OrmException {
+  public List<ChangeInfo> format(Collection<ChangeData> in) throws PermissionBackendException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     ensureLoaded(in);
     List<ChangeInfo> out = new ArrayList<>(in.size());
     for (ChangeData cd : in) {
-      out.add(format(cd, Optional.empty(), false));
+      out.add(format(cd, Optional.empty(), false, ChangeInfo::new));
     }
     accountLoader.fill();
     return out;
   }
 
+  public <I extends ChangeInfo> I format(
+      Project.NameKey project, Change.Id id, Supplier<I> changeInfoSupplier) {
+    ChangeNotes notes;
+    try {
+      notes = notesFactory.createChecked(project, id);
+    } catch (StorageException e) {
+      if (!has(CHECK)) {
+        throw e;
+      }
+      return checkOnly(changeDataFactory.create(project, id), changeInfoSupplier);
+    }
+    return format(changeDataFactory.create(notes), Optional.empty(), true, changeInfoSupplier);
+  }
+
   private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
     Collection<SubmitRequirementInfo> reqInfos = new ArrayList<>();
     for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
@@ -462,7 +351,44 @@
     return new SubmitRequirementInfo(status.name(), req.fallbackText(), req.type(), req.data());
   }
 
-  private void ensureLoaded(Iterable<ChangeData> all) throws OrmException {
+  private static void finish(ChangeInfo info) {
+    info.id =
+        Joiner.on('~')
+            .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
+  }
+
+  private static boolean containsAnyOf(
+      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
+    return !Sets.intersection(toFind, set).isEmpty();
+  }
+
+  private <I extends ChangeInfo> I format(
+      ChangeData cd,
+      Optional<PatchSet.Id> limitToPsId,
+      boolean fillAccountLoader,
+      Supplier<I> changeInfoSupplier) {
+    try {
+      if (fillAccountLoader) {
+        accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+        I res = toChangeInfo(cd, limitToPsId, changeInfoSupplier);
+        accountLoader.fill();
+        return res;
+      }
+      return toChangeInfo(cd, limitToPsId, changeInfoSupplier);
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | PermissionBackendException
+        | RuntimeException e) {
+      if (!has(CHECK)) {
+        Throwables.throwIfInstanceOf(e, StorageException.class);
+        throw new StorageException(e);
+      }
+      return checkOnly(cd, changeInfoSupplier);
+    }
+  }
+
+  private void ensureLoaded(Iterable<ChangeData> all) {
     if (lazyLoad) {
       ChangeData.ensureChangeLoaded(all);
       if (has(ALL_REVISIONS)) {
@@ -488,62 +414,32 @@
   private List<ChangeInfo> toChangeInfos(
       List<ChangeData> changes, Map<Change.Id, ChangeInfo> cache) {
     try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
-      // Create a list of formatting calls that can be called sequentially or in parallel
-      List<Callable<Optional<ChangeInfo>>> formattingCalls = new ArrayList<>(changes.size());
+      List<ChangeInfo> changeInfos = new ArrayList<>(changes.size());
       for (ChangeData cd : changes) {
-        formattingCalls.add(
-            () -> {
-              ChangeInfo i = cache.get(cd.getId());
-              if (i != null) {
-                return Optional.of(i);
-              }
-              try {
-                ensureLoaded(Collections.singleton(cd));
-                return Optional.of(format(cd, Optional.empty(), false));
-              } catch (OrmException | RuntimeException e) {
-                logger.atWarning().withCause(e).log(
-                    "Omitting corrupt change %s from results", cd.getId());
-                return Optional.empty();
-              }
-            });
-      }
-
-      long numProjects = changes.stream().map(ChangeData::project).distinct().count();
-      if (!enableParallelFormatting || !lazyLoad || changes.size() < 3 || numProjects < 2) {
-        // Format these changes in the request thread as the multithreading overhead would be too
-        // high.
-        List<ChangeInfo> result = new ArrayList<>(changes.size());
-        for (Callable<Optional<ChangeInfo>> c : formattingCalls) {
-          try {
-            c.call().ifPresent(result::add);
-          } catch (Exception e) {
-            logger.atWarning().withCause(e).log("Omitting change due to exception");
-          }
+        ChangeInfo i = cache.get(cd.getId());
+        if (i != null) {
+          continue;
         }
-        return result;
-      }
-
-      // Format the changes in parallel on the executor
-      List<ChangeInfo> result = new ArrayList<>(changes.size());
-      try {
-        for (Future<Optional<ChangeInfo>> f : fanOutExecutor.invokeAll(formattingCalls)) {
-          f.get().ifPresent(result::add);
+        try {
+          ensureLoaded(Collections.singleton(cd));
+          changeInfos.add(format(cd, Optional.empty(), false, ChangeInfo::new));
+        } catch (RuntimeException e) {
+          logger.atWarning().withCause(e).log(
+              "Omitting corrupt change %s from results", cd.getId());
         }
-      } catch (InterruptedException | ExecutionException e) {
-        throw new IllegalStateException(e);
       }
-      return result;
+      return changeInfos;
     }
   }
 
-  private ChangeInfo checkOnly(ChangeData cd) {
+  private <I extends ChangeInfo> I checkOnly(ChangeData cd, Supplier<I> changeInfoSupplier) {
     ChangeNotes notes;
     try {
       notes = cd.notes();
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       String msg = "Error loading change";
       logger.atWarning().withCause(e).log(msg + " %s", cd.getId());
-      ChangeInfo info = new ChangeInfo();
+      I info = changeInfoSupplier.get();
       info._number = cd.getId().get();
       ProblemInfo p = new ProblemInfo();
       p.message = msg;
@@ -552,12 +448,11 @@
     }
 
     ConsistencyChecker.Result result = checkerProvider.get().check(notes, fix);
-    ChangeInfo info;
+    I info = changeInfoSupplier.get();
     Change c = result.change();
     if (c != null) {
-      info = new ChangeInfo();
       info.project = c.getProject().get();
-      info.branch = c.getDest().getShortName();
+      info.branch = c.getDest().shortName();
       info.topic = c.getTopic();
       info.changeId = c.getKey().get();
       info.subject = c.getSubject();
@@ -572,25 +467,24 @@
       info.hasReviewStarted = c.hasReviewStarted();
       finish(info);
     } else {
-      info = new ChangeInfo();
       info._number = result.id().get();
       info.problems = result.problems();
     }
     return info;
   }
 
-  private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
-      throws PatchListNotAvailableException, GpgException, OrmException, PermissionBackendException,
-          IOException {
+  private <I extends ChangeInfo> I toChangeInfo(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, Supplier<I> changeInfoSupplier)
+      throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
-      return toChangeInfoImpl(cd, limitToPsId);
+      return toChangeInfoImpl(cd, limitToPsId, changeInfoSupplier);
     }
   }
 
-  private ChangeInfo toChangeInfoImpl(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
-      throws PatchListNotAvailableException, GpgException, OrmException, PermissionBackendException,
-          IOException {
-    ChangeInfo out = new ChangeInfo();
+  private <I extends ChangeInfo> I toChangeInfoImpl(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, Supplier<I> changeInfoSupplier)
+      throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
+    I out = changeInfoSupplier.get();
     CurrentUser user = userProvider.get();
 
     if (has(CHECK)) {
@@ -598,7 +492,7 @@
       // If any problems were fixed, the ChangeData needs to be reloaded.
       for (ProblemInfo p : out.problems) {
         if (p.status == ProblemInfo.Status.FIXED) {
-          cd = changeDataFactory.create(cd.db(), cd.project(), cd.getId());
+          cd = changeDataFactory.create(cd.project(), cd.getId());
           break;
         }
       }
@@ -606,27 +500,29 @@
 
     Change in = cd.change();
     out.project = in.getProject().get();
-    out.branch = in.getDest().getShortName();
+    out.branch = in.getDest().shortName();
     out.topic = in.getTopic();
     out.assignee = in.getAssignee() != null ? accountLoader.get(in.getAssignee()) : null;
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
-    if (in.getStatus().isOpen()) {
+    if (in.isNew()) {
       SubmitTypeRecord str = cd.submitTypeRecord();
       if (str.isOk()) {
         out.submitType = str.type;
       }
-      if (!has(SKIP_MERGEABLE)) {
+      if (!excludeMergeableInChangeInfo && !has(SKIP_MERGEABLE)) {
         out.mergeable = cd.isMergeable();
       }
       if (has(SUBMITTABLE)) {
         out.submittable = submittable(cd);
       }
     }
-    Optional<ChangedLines> changedLines = cd.changedLines();
-    if (changedLines.isPresent()) {
-      out.insertions = changedLines.get().insertions;
-      out.deletions = changedLines.get().deletions;
+    if (!has(SKIP_DIFFSTAT)) {
+      Optional<ChangedLines> changedLines = cd.changedLines();
+      if (changedLines.isPresent()) {
+        out.insertions = changedLines.get().insertions;
+        out.deletions = changedLines.get().deletions;
+      }
     }
     out.isPrivate = in.isPrivate() ? true : null;
     out.workInProgress = in.isWorkInProgress() ? true : null;
@@ -637,6 +533,7 @@
     out.created = in.getCreatedOn();
     out.updated = in.getLastUpdatedOn();
     out._number = in.getId().get();
+    out.totalCommentCount = cd.totalCommentCount();
     out.unresolvedCommentCount = cd.unresolvedCommentCount();
 
     if (user.isIdentifiedUser()) {
@@ -647,11 +544,11 @@
       }
     }
 
-    if (in.getStatus().isOpen() && has(REVIEWED) && user.isIdentifiedUser()) {
+    if (in.isNew() && has(REVIEWED) && user.isIdentifiedUser()) {
       out.reviewed = cd.isReviewedBy(user.getAccountId()) ? true : null;
     }
 
-    out.labels = labelsFor(cd, has(LABELS), has(DETAILED_LABELS));
+    out.labels = labelsJson.labelsFor(accountLoader, cd, has(LABELS), has(DETAILED_LABELS));
     out.requirements = requirementsFor(cd);
 
     if (out.labels != null && has(DETAILED_LABELS)) {
@@ -659,10 +556,9 @@
       // list permitted labels, since users can't vote on those patch sets.
       if (user.isIdentifiedUser()
           && (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId()))) {
-        PermissionBackend.ForChange perm = permissionBackendForChange(user, cd);
         out.permittedLabels =
-            cd.change().getStatus() != Change.Status.ABANDONED
-                ? permittedLabels(perm, cd)
+            !cd.change().isAbandoned()
+                ? labelsJson.permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
       }
 
@@ -672,8 +568,9 @@
     }
 
     setSubmitter(cd, out);
-    out.plugins =
-        pluginDefinedAttributesFactory != null ? pluginDefinedAttributesFactory.create(cd) : null;
+    if (pluginDefinedAttributesFactory.isPresent()) {
+      out.plugins = pluginDefinedAttributesFactory.get().create(cd);
+    }
     out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
 
     if (has(REVIEWER_UPDATES)) {
@@ -697,7 +594,7 @@
     // This block must come after the ChangeInfo is mostly populated, since
     // it will be passed to ActionVisitors as-is.
     if (needRevisions) {
-      out.revisions = revisions(cd, src, limitToPsId, out);
+      out.revisions = revisionJson.getRevisions(accountLoader, cd, src, limitToPsId, out);
       if (out.revisions != null) {
         for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
           if (entry.getValue().isCurrent) {
@@ -715,8 +612,7 @@
     if (has(TRACKING_IDS)) {
       ListMultimap<String, String> set = trackingFooters.extract(cd.commitFooters());
       out.trackingIds =
-          set.entries()
-              .stream()
+          set.entries().stream()
               .map(e -> new TrackingIdInfo(e.getKey(), e.getValue()))
               .collect(toList());
     }
@@ -740,7 +636,7 @@
     return reviewerMap;
   }
 
-  private Collection<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) throws OrmException {
+  private Collection<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) {
     List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
     List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
     for (ReviewerStatusUpdate c : reviewerUpdates) {
@@ -758,432 +654,17 @@
     return SubmitRecord.allRecordsOK(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT));
   }
 
-  private List<SubmitRecord> submitRecords(ChangeData cd) {
-    return cd.submitRecords(SUBMIT_RULE_OPTIONS_LENIENT);
-  }
-
-  private Map<String, LabelInfo> labelsFor(ChangeData cd, boolean standard, boolean detailed)
-      throws OrmException, PermissionBackendException {
-    if (!standard && !detailed) {
-      return null;
-    }
-
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelWithStatus> withStatus =
-        cd.change().getStatus() == Change.Status.MERGED
-            ? labelsForSubmittedChange(cd, labelTypes, standard, detailed)
-            : labelsForUnsubmittedChange(cd, labelTypes, standard, detailed);
-    return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
-  }
-
-  private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
-      ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
-      throws OrmException, PermissionBackendException {
-    Map<String, LabelWithStatus> labels = initLabels(cd, labelTypes, standard);
-    if (detailed) {
-      setAllApprovals(cd, labels);
-    }
-    for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-      LabelType type = labelTypes.byLabel(e.getKey());
-      if (type == null) {
-        continue;
-      }
-      if (standard) {
-        for (PatchSetApproval psa : cd.currentApprovals()) {
-          if (type.matches(psa)) {
-            short val = psa.getValue();
-            Account.Id accountId = psa.getAccountId();
-            setLabelScores(type, e.getValue(), val, accountId);
-          }
-        }
-      }
-      if (detailed) {
-        setLabelValues(type, e.getValue());
-      }
-    }
-    return labels;
-  }
-
-  private Map<String, LabelWithStatus> initLabels(
-      ChangeData cd, LabelTypes labelTypes, boolean standard) {
-    Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels == null) {
-        continue;
-      }
-      for (SubmitRecord.Label r : rec.labels) {
-        LabelWithStatus p = labels.get(r.label);
-        if (p == null || p.status().compareTo(r.status) < 0) {
-          LabelInfo n = new LabelInfo();
-          if (standard) {
-            switch (r.status) {
-              case OK:
-                n.approved = accountLoader.get(r.appliedBy);
-                break;
-              case REJECT:
-                n.rejected = accountLoader.get(r.appliedBy);
-                n.blocking = true;
-                break;
-              case IMPOSSIBLE:
-              case MAY:
-              case NEED:
-              default:
-                break;
-            }
-          }
-
-          n.optional = r.status == SubmitRecord.Label.Status.MAY ? true : null;
-          labels.put(r.label, LabelWithStatus.create(n, r.status));
-        }
-      }
-    }
-    return labels;
-  }
-
-  private void setLabelScores(
-      LabelType type, LabelWithStatus l, short score, Account.Id accountId) {
-    if (l.label().approved != null || l.label().rejected != null) {
-      return;
-    }
-
-    if (type.getMin() == null || type.getMax() == null) {
-      // Can't set score for unknown or misconfigured type.
-      return;
-    }
-
-    if (score != 0) {
-      if (score == type.getMin().getValue()) {
-        l.label().rejected = accountLoader.get(accountId);
-      } else if (score == type.getMax().getValue()) {
-        l.label().approved = accountLoader.get(accountId);
-      } else if (score < 0) {
-        l.label().disliked = accountLoader.get(accountId);
-        l.label().value = score;
-      } else if (score > 0 && l.label().disliked == null) {
-        l.label().recommended = accountLoader.get(accountId);
-        l.label().value = score;
-      }
-    }
-  }
-
-  private void setAllApprovals(ChangeData cd, Map<String, LabelWithStatus> labels)
-      throws OrmException, PermissionBackendException {
-    Change.Status status = cd.change().getStatus();
-    checkState(
-        status != Change.Status.MERGED, "should not call setAllApprovals on %s change", status);
-
-    // Include a user in the output for this label if either:
-    //  - They are an explicit reviewer.
-    //  - They ever voted on this change.
-    Set<Account.Id> allUsers = new HashSet<>();
-    allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
-    for (PatchSetApproval psa : cd.approvals().values()) {
-      allUsers.add(psa.getAccountId());
-    }
-
-    Table<Account.Id, String, PatchSetApproval> current =
-        HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
-    for (PatchSetApproval psa : cd.currentApprovals()) {
-      current.put(psa.getAccountId(), psa.getLabel(), psa);
-    }
-
-    LabelTypes labelTypes = cd.getLabelTypes();
-    for (Account.Id accountId : allUsers) {
-      PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
-      for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-        LabelType lt = labelTypes.byLabel(e.getKey());
-        if (lt == null) {
-          // Ignore submit record for undefined label; likely the submit rule
-          // author didn't intend for the label to show up in the table.
-          continue;
-        }
-        Integer value;
-        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
-        String tag = null;
-        Timestamp date = null;
-        PatchSetApproval psa = current.get(accountId, lt.getName());
-        if (psa != null) {
-          value = Integer.valueOf(psa.getValue());
-          if (value == 0) {
-            // This may be a dummy approval that was inserted when the reviewer
-            // was added. Explicitly check whether the user can vote on this
-            // label.
-            value = perm.test(new LabelPermission(lt)) ? 0 : null;
-          }
-          tag = psa.getTag();
-          date = psa.getGranted();
-          if (psa.isPostSubmit()) {
-            logger.atWarning().log("unexpected post-submit approval on open change: %s", psa);
-          }
-        } else {
-          // Either the user cannot vote on this label, or they were added as a
-          // reviewer but have not responded yet. Explicitly check whether the
-          // user can vote on this label.
-          value = perm.test(new LabelPermission(lt)) ? 0 : null;
-        }
-        addApproval(
-            e.getValue().label(), approvalInfo(accountId, value, permittedVotingRange, tag, date));
-      }
-    }
-  }
-
-  private Map<String, VotingRangeInfo> getPermittedVotingRanges(
-      Map<String, Collection<String>> permittedLabels) {
-    Map<String, VotingRangeInfo> permittedVotingRanges =
-        Maps.newHashMapWithExpectedSize(permittedLabels.size());
-    for (String label : permittedLabels.keySet()) {
-      List<Integer> permittedVotingRange =
-          permittedLabels
-              .get(label)
-              .stream()
-              .map(this::parseRangeValue)
-              .filter(java.util.Objects::nonNull)
-              .sorted()
-              .collect(toList());
-
-      if (permittedVotingRange.isEmpty()) {
-        permittedVotingRanges.put(label, null);
-      } else {
-        int minPermittedValue = permittedVotingRange.get(0);
-        int maxPermittedValue = Iterables.getLast(permittedVotingRange);
-        permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue));
-      }
-    }
-    return permittedVotingRanges;
-  }
-
-  private Integer parseRangeValue(String value) {
-    if (value.startsWith("+")) {
-      value = value.substring(1);
-    } else if (value.startsWith(" ")) {
-      value = value.trim();
-    }
-    return Ints.tryParse(value);
-  }
-
-  private void setSubmitter(ChangeData cd, ChangeInfo out) throws OrmException {
+  private void setSubmitter(ChangeData cd, ChangeInfo out) {
     Optional<PatchSetApproval> s = cd.getSubmitApproval();
     if (!s.isPresent()) {
       return;
     }
-    out.submitted = s.get().getGranted();
-    out.submitter = accountLoader.get(s.get().getAccountId());
+    out.submitted = s.get().granted();
+    out.submitter = accountLoader.get(s.get().accountId());
   }
 
-  private Map<String, LabelWithStatus> labelsForSubmittedChange(
-      ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
-      throws OrmException, PermissionBackendException {
-    Set<Account.Id> allUsers = new HashSet<>();
-    if (detailed) {
-      // Users expect to see all reviewers on closed changes, even if they
-      // didn't vote on the latest patch set. If we don't need detailed labels,
-      // we aren't including 0 votes for all users below, so we can just look at
-      // the latest patch set (in the next loop).
-      for (PatchSetApproval psa : cd.approvals().values()) {
-        allUsers.add(psa.getAccountId());
-      }
-    }
-
-    Set<String> labelNames = new HashSet<>();
-    SetMultimap<Account.Id, PatchSetApproval> current =
-        MultimapBuilder.hashKeys().hashSetValues().build();
-    for (PatchSetApproval a : cd.currentApprovals()) {
-      allUsers.add(a.getAccountId());
-      LabelType type = labelTypes.byLabel(a.getLabelId());
-      if (type != null) {
-        labelNames.add(type.getName());
-        // Not worth the effort to distinguish between votable/non-votable for 0
-        // values on closed changes, since they can't vote anyway.
-        current.put(a.getAccountId(), a);
-      }
-    }
-
-    // Since voting on merged changes is allowed all labels which apply to
-    // the change must be returned. All applying labels can be retrieved from
-    // the submit records, which is what initLabels does.
-    // It's not possible to only compute the labels based on the approvals
-    // since merged changes may not have approvals for all labels (e.g. if not
-    // all labels are required for submit or if the change was auto-closed due
-    // to direct push or if new labels were defined after the change was
-    // merged).
-    Map<String, LabelWithStatus> labels;
-    labels = initLabels(cd, labelTypes, standard);
-
-    // Also include all labels for which approvals exists. E.g. there can be
-    // approvals for labels that are ignored by a Prolog submit rule and hence
-    // it wouldn't be included in the submit records.
-    for (String name : labelNames) {
-      if (!labels.containsKey(name)) {
-        labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
-      }
-    }
-
-    if (detailed) {
-      labels
-          .entrySet()
-          .stream()
-          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
-          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
-    }
-
-    for (Account.Id accountId : allUsers) {
-      Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
-      Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
-      if (detailed) {
-        PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
-        pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
-        for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
-          ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
-          byLabel.put(entry.getKey(), ai);
-          addApproval(entry.getValue().label(), ai);
-        }
-      }
-      for (PatchSetApproval psa : current.get(accountId)) {
-        LabelType type = labelTypes.byLabel(psa.getLabelId());
-        if (type == null) {
-          continue;
-        }
-
-        short val = psa.getValue();
-        ApprovalInfo info = byLabel.get(type.getName());
-        if (info != null) {
-          info.value = Integer.valueOf(val);
-          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
-          info.date = psa.getGranted();
-          info.tag = psa.getTag();
-          if (psa.isPostSubmit()) {
-            info.postSubmit = true;
-          }
-        }
-        if (!standard) {
-          continue;
-        }
-
-        setLabelScores(type, labels.get(type.getName()), val, accountId);
-      }
-    }
-    return labels;
-  }
-
-  private ApprovalInfo approvalInfo(
-      Account.Id id,
-      Integer value,
-      VotingRangeInfo permittedVotingRange,
-      String tag,
-      Timestamp date) {
-    ApprovalInfo ai = getApprovalInfo(id, value, permittedVotingRange, tag, date);
-    accountLoader.put(ai);
-    return ai;
-  }
-
-  public static ApprovalInfo getApprovalInfo(
-      Account.Id id,
-      Integer value,
-      VotingRangeInfo permittedVotingRange,
-      String tag,
-      Timestamp date) {
-    ApprovalInfo ai = new ApprovalInfo(id.get());
-    ai.value = value;
-    ai.permittedVotingRange = permittedVotingRange;
-    ai.date = date;
-    ai.tag = tag;
-    return ai;
-  }
-
-  private static boolean isOnlyZero(Collection<String> values) {
-    return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
-  }
-
-  private void setLabelValues(LabelType type, LabelWithStatus l) {
-    l.label().defaultValue = type.getDefaultValue();
-    l.label().values = new LinkedHashMap<>();
-    for (LabelValue v : type.getValues()) {
-      l.label().values.put(v.formatValue(), v.getText());
-    }
-    if (isOnlyZero(l.label().values.keySet())) {
-      l.label().values = null;
-    }
-  }
-
-  private Map<String, Collection<String>> permittedLabels(
-      PermissionBackend.ForChange perm, ChangeData cd)
-      throws OrmException, PermissionBackendException {
-    boolean isMerged = cd.change().getStatus() == Change.Status.MERGED;
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelType> toCheck = new HashMap<>();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels != null) {
-        for (SubmitRecord.Label r : rec.labels) {
-          LabelType type = labelTypes.byLabel(r.label);
-          if (type != null && (!isMerged || type.allowPostSubmit())) {
-            toCheck.put(type.getName(), type);
-          }
-        }
-      }
-    }
-
-    Map<String, Short> labels = null;
-    Set<LabelPermission.WithValue> can = perm.testLabels(toCheck.values());
-    SetMultimap<String, String> permitted = LinkedHashMultimap.create();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels == null) {
-        continue;
-      }
-      for (SubmitRecord.Label r : rec.labels) {
-        LabelType type = labelTypes.byLabel(r.label);
-        if (type == null || (isMerged && !type.allowPostSubmit())) {
-          continue;
-        }
-
-        for (LabelValue v : type.getValues()) {
-          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
-          if (isMerged) {
-            if (labels == null) {
-              labels = currentLabels(perm, cd);
-            }
-            short prev = labels.getOrDefault(type.getName(), (short) 0);
-            ok &= v.getValue() >= prev;
-          }
-          if (ok) {
-            permitted.put(r.label, v.formatValue());
-          }
-        }
-      }
-    }
-
-    List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
-    for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
-      if (isOnlyZero(e.getValue())) {
-        toClear.add(e.getKey());
-      }
-    }
-    for (String label : toClear) {
-      permitted.removeAll(label);
-    }
-    return permitted.asMap();
-  }
-
-  private Map<String, Short> currentLabels(PermissionBackend.ForChange perm, ChangeData cd)
-      throws OrmException {
-    IdentifiedUser user = perm.user().asIdentifiedUser();
-    Map<String, Short> result = new HashMap<>();
-    for (PatchSetApproval psa :
-        approvalsUtil.byPatchSetUser(
-            db.get(),
-            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
-            user,
-            cd.change().currentPatchSetId(),
-            user.getAccountId(),
-            null,
-            null)) {
-      result.put(psa.getLabel(), psa.getValue());
-    }
-    return result;
-  }
-
-  private Collection<ChangeMessageInfo> messages(ChangeData cd) throws OrmException {
-    List<ChangeMessage> messages = cmUtil.byChange(db.get(), cd.notes());
+  private Collection<ChangeMessageInfo> messages(ChangeData cd) {
+    List<ChangeMessage> messages = cmUtil.byChange(cd.notes());
     if (messages.isEmpty()) {
       return Collections.emptyList();
     }
@@ -1196,7 +677,7 @@
   }
 
   private Collection<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
-      throws PermissionBackendException, OrmException {
+      throws PermissionBackendException {
     // Although this is called removableReviewers, this method also determines
     // which CCs are removable.
     //
@@ -1210,15 +691,22 @@
     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;
       }
       for (ApprovalInfo ai : label.all) {
-        Account.Id id = new Account.Id(ai._accountId);
+        Account.Id id = 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);
@@ -1234,8 +722,9 @@
     if (ccs != null) {
       for (AccountInfo ai : ccs) {
         if (ai._accountId != null) {
-          Account.Id id = new Account.Id(ai._accountId);
-          if (removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
+          Account.Id id = Account.id(ai._accountId);
+          if (canRemoveAnyReviewer
+              || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
             removable.add(id);
           }
         }
@@ -1263,64 +752,21 @@
   }
 
   private Collection<AccountInfo> toAccountInfo(Collection<Account.Id> accounts) {
-    return accounts
-        .stream()
+    return accounts.stream()
         .map(accountLoader::get)
         .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
         .collect(toList());
   }
 
   private Collection<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
-    return addresses
-        .stream()
+    return addresses.stream()
         .map(a -> new AccountInfo(a.getName(), a.getEmail()))
         .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
         .collect(toList());
   }
 
-  @Nullable
-  private Repository openRepoIfNecessary(Project.NameKey project) throws IOException {
-    if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
-      return repoManager.openRepository(project);
-    }
-    return null;
-  }
-
-  @Nullable
-  private RevWalk newRevWalk(@Nullable Repository repo) {
-    return repo != null ? new RevWalk(repo) : null;
-  }
-
-  private Map<String, RevisionInfo> revisions(
-      ChangeData cd,
-      Map<PatchSet.Id, PatchSet> map,
-      Optional<PatchSet.Id> limitToPsId,
-      ChangeInfo changeInfo)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
-          PermissionBackendException {
-    Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo = openRepoIfNecessary(cd.project());
-        RevWalk rw = newRevWalk(repo)) {
-      for (PatchSet in : map.values()) {
-        PatchSet.Id id = in.getId();
-        boolean want = false;
-        if (has(ALL_REVISIONS)) {
-          want = true;
-        } else if (limitToPsId.isPresent()) {
-          want = id.equals(limitToPsId.get());
-        } else {
-          want = id.equals(cd.change().currentPatchSetId());
-        }
-        if (want) {
-          res.put(in.getRevision().get(), toRevisionInfo(cd, in, repo, rw, false, changeInfo));
-        }
-      }
-      return res;
-    }
-  }
-
-  private Map<PatchSet.Id, PatchSet> loadPatchSets(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
-      throws OrmException {
+  private Map<PatchSet.Id, PatchSet> loadPatchSets(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId) {
     Collection<PatchSet> src;
     if (has(ALL_REVISIONS) || has(MESSAGES)) {
       src = cd.patchSets();
@@ -1329,251 +775,32 @@
       if (limitToPsId.isPresent()) {
         ps = cd.patchSet(limitToPsId.get());
         if (ps == null) {
-          throw new OrmException("missing patch set " + limitToPsId.get());
+          throw new StorageException("missing patch set " + limitToPsId.get());
         }
       } else {
         ps = cd.currentPatchSet();
         if (ps == null) {
-          throw new OrmException("missing current patch set for change " + cd.getId());
+          throw new StorageException("missing current patch set for change " + cd.getId());
         }
       }
       src = Collections.singletonList(ps);
     }
     Map<PatchSet.Id, PatchSet> map = Maps.newHashMapWithExpectedSize(src.size());
     for (PatchSet patchSet : src) {
-      map.put(patchSet.getId(), patchSet);
+      map.put(patchSet.id(), patchSet);
     }
     return map;
   }
 
-  public RevisionInfo getRevisionInfo(ChangeData cd, PatchSet in)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
-          PermissionBackendException {
-    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    try (Repository repo = openRepoIfNecessary(cd.project());
-        RevWalk rw = newRevWalk(repo)) {
-      RevisionInfo rev = toRevisionInfo(cd, in, repo, rw, true, null);
-      accountLoader.fill();
-      return rev;
-    }
-  }
-
-  private RevisionInfo toRevisionInfo(
-      ChangeData cd,
-      PatchSet in,
-      @Nullable Repository repo,
-      @Nullable RevWalk rw,
-      boolean fillCommit,
-      @Nullable ChangeInfo changeInfo)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
-          PermissionBackendException {
-    Change c = cd.change();
-    RevisionInfo out = new RevisionInfo();
-    out.isCurrent = in.getId().equals(c.currentPatchSetId());
-    out._number = in.getId().get();
-    out.ref = in.getRefName();
-    out.created = in.getCreatedOn();
-    out.uploader = accountLoader.get(in.getUploader());
-    out.fetch = makeFetchMap(cd, in);
-    out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
-    out.description = in.getDescription();
-
-    boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
-    boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
-    if (setCommit || addFooters) {
-      checkState(rw != null);
-      checkState(repo != null);
-      Project.NameKey project = c.getProject();
-      String rev = in.getRevision().get();
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
-      rw.parseBody(commit);
-      if (setCommit) {
-        out.commit = toCommit(project, rw, commit, has(WEB_LINKS), fillCommit);
-      }
-      if (addFooters) {
-        Ref ref = repo.exactRef(cd.change().getDest().get());
-        RevCommit mergeTip = null;
-        if (ref != null) {
-          mergeTip = rw.parseCommit(ref.getObjectId());
-          rw.parseBody(mergeTip);
-        }
-        out.commitWithFooters =
-            mergeUtilFactory
-                .create(projectCache.get(project))
-                .createCommitMessageOnSubmit(
-                    commit, mergeTip, cd.notes(), userProvider.get(), in.getId());
-      }
-    }
-
-    if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
-      out.files = fileInfoJson.toFileInfoMap(c, in);
-      out.files.remove(Patch.COMMIT_MSG);
-      out.files.remove(Patch.MERGE_LIST);
-    }
-
-    if (out.isCurrent && has(CURRENT_ACTIONS) && userProvider.get().isIdentifiedUser()) {
-
-      actionJson.addRevisionActions(
-          changeInfo,
-          out,
-          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
-    }
-
-    if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
-      if (in.getPushCertificate() != null) {
-        out.pushCertificate =
-            gpgApi.checkPushCertificate(
-                in.getPushCertificate(), userFactory.create(in.getUploader()));
-      } else {
-        out.pushCertificate = new PushCertificateInfo();
-      }
-    }
-
-    return out;
-  }
-
-  public CommitInfo toCommit(
-      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
-      throws IOException {
-    CommitInfo info = new CommitInfo();
-    if (fillCommit) {
-      info.commit = commit.name();
-    }
-    info.parents = new ArrayList<>(commit.getParentCount());
-    info.author = toGitPerson(commit.getAuthorIdent());
-    info.committer = toGitPerson(commit.getCommitterIdent());
-    info.subject = commit.getShortMessage();
-    info.message = commit.getFullMessage();
-
-    if (addLinks) {
-      List<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
-      info.webLinks = links.isEmpty() ? null : links;
-    }
-
-    for (RevCommit parent : commit.getParents()) {
-      rw.parseBody(parent);
-      CommitInfo i = new CommitInfo();
-      i.commit = parent.name();
-      i.subject = parent.getShortMessage();
-      if (addLinks) {
-        List<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
-        i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
-      }
-      info.parents.add(i);
-    }
-    return info;
-  }
-
-  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in)
-      throws PermissionBackendException, OrmException, IOException {
-    Map<String, FetchInfo> r = new LinkedHashMap<>();
-    for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
-      String schemeName = e.getExportName();
-      DownloadScheme scheme = e.getProvider().get();
-      if (!scheme.isEnabled()
-          || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
-        continue;
-      }
-      if (!scheme.isAuthSupported() && !isWorldReadable(cd)) {
-        continue;
-      }
-
-      String projectName = cd.project().get();
-      String url = scheme.getUrl(projectName);
-      String refName = in.getRefName();
-      FetchInfo fetchInfo = new FetchInfo(url, refName);
-      r.put(schemeName, fetchInfo);
-
-      if (has(DOWNLOAD_COMMANDS)) {
-        populateFetchMap(scheme, downloadCommands, projectName, refName, fetchInfo);
-      }
-    }
-
-    return r;
-  }
-
-  public static void populateFetchMap(
-      DownloadScheme scheme,
-      DynamicMap<DownloadCommand> commands,
-      String projectName,
-      String refName,
-      FetchInfo fetchInfo) {
-    for (DynamicMap.Entry<DownloadCommand> e2 : commands) {
-      String commandName = e2.getExportName();
-      DownloadCommand command = e2.getProvider().get();
-      String c = command.getCommand(scheme, projectName, refName);
-      if (c != null) {
-        addCommand(fetchInfo, commandName, c);
-      }
-    }
-  }
-
-  private static void addCommand(FetchInfo fetchInfo, String commandName, String c) {
-    if (fetchInfo.commands == null) {
-      fetchInfo.commands = new TreeMap<>();
-    }
-    fetchInfo.commands.put(commandName, c);
-  }
-
-  static void finish(ChangeInfo info) {
-    info.id =
-        Joiner.on('~')
-            .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
-  }
-
-  private static void addApproval(LabelInfo label, ApprovalInfo approval) {
-    if (label.all == null) {
-      label.all = new ArrayList<>();
-    }
-    label.all.add(approval);
-  }
-
-  private PermissionBackend.ForChange permissionBackendForChange(CurrentUser user, ChangeData cd)
-      throws OrmException {
-    return permissionBackendForChange(permissionBackend.user(user).database(db), cd);
-  }
-
-  private PermissionBackend.ForChange permissionBackendForChange(Account.Id user, ChangeData cd)
-      throws OrmException {
-    return permissionBackendForChange(permissionBackend.absentUser(user).database(db), cd);
-  }
-
   /**
    * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
    *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
    *     lazyload}.
    */
-  private PermissionBackend.ForChange permissionBackendForChange(
-      PermissionBackend.WithUser withUser, ChangeData cd) throws OrmException {
+  private PermissionBackend.ForChange permissionBackendForChange(CurrentUser user, ChangeData cd) {
+    PermissionBackend.WithUser withUser = permissionBackend.user(user);
     return lazyLoad
         ? withUser.change(cd)
         : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
   }
-
-  private boolean isWorldReadable(ChangeData cd)
-      throws OrmException, PermissionBackendException, IOException {
-    try {
-      permissionBackendForChange(anonymous, cd).check(ChangePermission.READ);
-    } catch (AuthException ae) {
-      return false;
-    }
-    ProjectState projectState = projectCache.checkedGet(cd.project());
-    if (projectState == null) {
-      logger.atSevere().log("project state for project %s is null", cd.project());
-      return false;
-    }
-    return projectState.statePermitsRead();
-  }
-
-  @AutoValue
-  abstract static class LabelWithStatus {
-    private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) {
-      return new AutoValue_ChangeJson_LabelWithStatus(label, status);
-    }
-
-    abstract LabelInfo label();
-
-    @Nullable
-    abstract SubmitRecord.Label.Status status();
-  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
new file mode 100644
index 0000000..f0fef20
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import java.lang.reflect.Type;
+
+/**
+ * Adapter that serializes {@link com.google.gerrit.reviewdb.client.Change.Key}'s {@code key} field
+ * as {@code id}, for backwards compatibility in stream-events.
+ */
+// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
+// AutoValue method.
+public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
+  @Override
+  public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
+    JsonObject obj = new JsonObject();
+    obj.addProperty("id", src.get());
+    return obj;
+  }
+
+  @Override
+  public Change.Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    JsonElement keyJson = json.getAsJsonObject().get("id");
+    if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
+      throw new JsonParseException("Key is not a string: " + keyJson);
+    }
+    String key = keyJson.getAsJsonPrimitive().getAsString();
+    return Change.key(key);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeKindCache.java b/java/com/google/gerrit/server/change/ChangeKindCache.java
index 6baeefc..44da4d6 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCache.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.query.change.ChangeData;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -39,7 +38,7 @@
       ObjectId prior,
       ObjectId next);
 
-  ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
+  ChangeKind getChangeKind(Change change, PatchSet patch);
 
   ChangeKind getChangeKind(
       @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch);
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 24685af..45fc8b1 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -23,23 +23,22 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.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.ObjectIdConverter;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.name.Named;
@@ -80,7 +79,6 @@
     };
   }
 
-  @VisibleForTesting
   public static class NoCache implements ChangeKindCache {
     private final boolean useRecursiveMerge;
     private final ChangeData.Factory changeDataFactory;
@@ -114,8 +112,8 @@
     }
 
     @Override
-    public ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch) {
-      return getChangeKindInternal(this, db, change, patch, changeDataFactory, repoManager);
+    public ChangeKind getChangeKind(Change change, PatchSet patch) {
+      return getChangeKindInternal(this, change, patch, changeDataFactory, repoManager);
     }
 
     @Override
@@ -146,7 +144,7 @@
       @Override
       public byte[] serialize(Key object) {
         ObjectIdConverter idConverter = ObjectIdConverter.create();
-        return ProtoCacheSerializers.toByteArray(
+        return Protos.toByteArray(
             ChangeKindKeyProto.newBuilder()
                 .setPrior(idConverter.toByteString(object.prior()))
                 .setNext(idConverter.toByteString(object.next()))
@@ -156,8 +154,7 @@
 
       @Override
       public Key deserialize(byte[] in) {
-        ChangeKindKeyProto proto =
-            ProtoCacheSerializers.parseUnchecked(ChangeKindKeyProto.parser(), in);
+        ChangeKindKeyProto proto = Protos.parseUnchecked(ChangeKindKeyProto.parser(), in);
         ObjectIdConverter idConverter = ObjectIdConverter.create();
         return create(
             idConverter.fromByteString(proto.getPrior()),
@@ -351,8 +348,8 @@
   }
 
   @Override
-  public ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch) {
-    return getChangeKindInternal(this, db, change, patch, changeDataFactory, repoManager);
+  public ChangeKind getChangeKind(Change change, PatchSet patch) {
+    return getChangeKindInternal(this, change, patch, changeDataFactory, repoManager);
   }
 
   @Override
@@ -370,13 +367,13 @@
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to use
     // the repository.
-    if (patch.getId().get() > 1) {
+    if (patch.id().get() > 1) {
       try {
         Collection<PatchSet> patchSetCollection = change.patchSets();
         PatchSet priorPs = patch;
         for (PatchSet ps : patchSetCollection) {
-          if (ps.getId().get() < patch.getId().get()
-              && (ps.getId().get() > priorPs.getId().get() || priorPs == patch)) {
+          if (ps.id().get() < patch.id().get()
+              && (ps.id().get() > priorPs.id().get() || priorPs == patch)) {
             // We only want the previous patch set, so walk until the last one
             priorPs = ps;
           }
@@ -389,17 +386,13 @@
         if (priorPs != patch) {
           kind =
               cache.getChangeKind(
-                  change.project(),
-                  rw,
-                  repoConfig,
-                  ObjectId.fromString(priorPs.getRevision().get()),
-                  ObjectId.fromString(patch.getRevision().get()));
+                  change.project(), rw, repoConfig, priorPs.commitId(), patch.commitId());
         }
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         // Do nothing; assume we have a complex change
         logger.atWarning().withCause(e).log(
             "Unable to get change kind for patchSet %s of change %s",
-            patch.getPatchSetId(), change.getId());
+            patch.number(), change.getId());
       }
     }
     return kind;
@@ -407,7 +400,6 @@
 
   private static ChangeKind getChangeKindInternal(
       ChangeKindCache cache,
-      ReviewDb db,
       Change change,
       PatchSet patch,
       ChangeData.Factory changeDataFactory,
@@ -416,17 +408,17 @@
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to open
     // the repository.
-    if (patch.getId().get() > 1) {
+    if (patch.id().get() > 1) {
       try (Repository repo = repoManager.openRepository(change.getProject());
           RevWalk rw = new RevWalk(repo)) {
         kind =
             getChangeKindInternal(
-                cache, rw, repo.getConfig(), changeDataFactory.create(db, change), patch);
+                cache, rw, repo.getConfig(), changeDataFactory.create(change), patch);
       } catch (IOException e) {
         // Do nothing; assume we have a complex change
         logger.atWarning().withCause(e).log(
             "Unable to get change kind for patchSet %s of change %s",
-            patch.getPatchSetId(), change.getChangeId());
+            patch.number(), change.getChangeId());
       }
     }
     return kind;
diff --git a/java/com/google/gerrit/server/change/ChangeMessages.java b/java/com/google/gerrit/server/change/ChangeMessages.java
index 41b6855..6cd3726 100644
--- a/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -25,9 +25,7 @@
   public String revertChangeDefaultMessage;
 
   public String reviewerCantSeeChange;
-  public String reviewerInactive;
   public String reviewerInvalid;
-  public String reviewerNotFoundUser;
   public String reviewerNotFoundUserOrGroup;
 
   public String groupIsNotAllowed;
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index ef8b2f9..d8d82c6 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -21,6 +21,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -29,7 +30,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -38,11 +38,10 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -71,40 +70,40 @@
 
   private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
 
-  private final Provider<ReviewDb> db;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalUtil;
   private final PatchSetUtil patchSetUtil;
   private final PermissionBackend permissionBackend;
   private final StarredChangesUtil starredChangesUtil;
   private final ProjectCache projectCache;
+  private final PluginSetContext<ChangeETagComputation> changeETagComputation;
   private final ChangeNotes notes;
   private final CurrentUser user;
 
   @Inject
   ChangeResource(
-      Provider<ReviewDb> db,
       AccountCache accountCache,
       ApprovalsUtil approvalUtil,
       PatchSetUtil patchSetUtil,
       PermissionBackend permissionBackend,
       StarredChangesUtil starredChangesUtil,
       ProjectCache projectCache,
+      PluginSetContext<ChangeETagComputation> changeETagComputation,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user) {
-    this.db = db;
     this.accountCache = accountCache;
     this.approvalUtil = approvalUtil;
     this.patchSetUtil = patchSetUtil;
     this.permissionBackend = permissionBackend;
     this.starredChangesUtil = starredChangesUtil;
     this.projectCache = projectCache;
+    this.changeETagComputation = changeETagComputation;
     this.notes = notes;
     this.user = user;
   }
 
   public PermissionBackend.ForChange permissions() {
-    return permissionBackend.user(user).database(db).change(notes);
+    return permissionBackend.user(user).change(notes);
   }
 
   public CurrentUser getUser() {
@@ -154,11 +153,7 @@
       accounts.add(getChange().getAssignee());
     }
     try {
-      patchSetUtil
-          .byChange(db.get(), notes)
-          .stream()
-          .map(PatchSet::getUploader)
-          .forEach(accounts::add);
+      patchSetUtil.byChange(notes).stream().map(PatchSet::uploader).forEach(accounts::add);
 
       // It's intentional to include the states for *all* reviewers into the ETag computation.
       // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
@@ -167,8 +162,8 @@
       // set of accounts that posted a message is too expensive. However everyone who posts a
       // message is automatically added as reviewer. Hence if we include removed reviewers we can
       // be sure that we have all accounts that posted messages on the change.
-      accounts.addAll(approvalUtil.getReviewers(db.get(), notes).all());
-    } catch (OrmException e) {
+      accounts.addAll(approvalUtil.getReviewers(notes).all());
+    } catch (StorageException e) {
       // This ETag will be invalidated if it loads next time.
     }
 
@@ -184,7 +179,7 @@
     ObjectId noteId;
     try {
       noteId = notes.loadRevision();
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       noteId = null; // This ETag will be invalidated if it loads next time.
     }
     hashObjectId(h, noteId, buf);
@@ -202,6 +197,14 @@
     for (ProjectState p : projectStateTree) {
       hashObjectId(h, p.getConfig().getRevision(), buf);
     }
+
+    changeETagComputation.runEach(
+        c -> {
+          String pluginETag = c.getETag(notes.getProjectName(), notes.getChangeId());
+          if (pluginETag != null) {
+            h.putString(pluginETag, UTF_8);
+          }
+        });
   }
 
   @Override
@@ -220,9 +223,9 @@
   }
 
   private void hashAccount(Hasher h, AccountState accountState, byte[] buf) {
-    h.putInt(accountState.getAccount().getId().get());
+    h.putInt(accountState.getAccount().id().get());
     h.putString(
-        MoreObjects.firstNonNull(accountState.getAccount().getMetaId(), ZERO_ID_STRING), UTF_8);
+        MoreObjects.firstNonNull(accountState.getAccount().metaId(), ZERO_ID_STRING), UTF_8);
     accountState.getExternalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeTriplet.java b/java/com/google/gerrit/server/change/ChangeTriplet.java
index 2daeb7c..1a347a4 100644
--- a/java/com/google/gerrit/server/change/ChangeTriplet.java
+++ b/java/com/google/gerrit/server/change/ChangeTriplet.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.Optional;
@@ -27,8 +27,8 @@
     return format(change.getDest(), change.getKey());
   }
 
-  private static String format(Branch.NameKey branch, Change.Key change) {
-    return branch.getParentKey().get() + "~" + branch.getShortName() + "~" + change.get();
+  private static String format(BranchNameKey branch, Change.Key change) {
+    return branch.project().get() + "~" + branch.shortName() + "~" + change.get();
   }
 
   /**
@@ -53,19 +53,19 @@
     String changeId = Url.decode(triplet.substring(z + 1));
     return Optional.of(
         new AutoValue_ChangeTriplet(
-            new Branch.NameKey(new Project.NameKey(project), branch), new Change.Key(changeId)));
+            BranchNameKey.create(Project.nameKey(project), branch), Change.key(changeId)));
   }
 
   public final Project.NameKey project() {
-    return branch().getParentKey();
+    return branch().project();
   }
 
-  public abstract Branch.NameKey branch();
+  public abstract BranchNameKey branch();
 
   public abstract Change.Key id();
 
   @Override
-  public String toString() {
+  public final String toString() {
     return format(branch(), id());
   }
 }
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 0aa6c2f..0e555e9 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.server.change;
 
 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.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
-import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
 import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Collections2;
@@ -29,18 +30,14 @@
 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.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ProblemInfo.Status;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -51,14 +48,14 @@
 import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -67,8 +64,10 @@
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -107,14 +106,13 @@
 
   private final ChangeNotes.Factory notesFactory;
   private final Accounts accounts;
-  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+  private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
   private final GitRepositoryManager repoManager;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final PatchSetUtil psUtil;
   private final Provider<CurrentUser> user;
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<ReviewDb> db;
   private final RetryHelper retryHelper;
 
   private BatchUpdate.Factory updateFactory;
@@ -136,17 +134,15 @@
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       ChangeNotes.Factory notesFactory,
       Accounts accounts,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
+      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
       GitRepositoryManager repoManager,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       PatchSetUtil psUtil,
       Provider<CurrentUser> user,
-      Provider<ReviewDb> db,
       RetryHelper retryHelper) {
     this.accounts = accounts;
     this.accountPatchReviewStore = accountPatchReviewStore;
-    this.db = db;
     this.notesFactory = notesFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
@@ -171,7 +167,7 @@
   }
 
   public Result check(ChangeNotes notes, @Nullable FixInput f) {
-    checkNotNull(notes);
+    requireNonNull(notes);
     try {
       return retryHelper.execute(
           buf -> {
@@ -231,18 +227,18 @@
 
   private void checkCurrentPatchSetEntity() {
     try {
-      currPs = psUtil.current(db.get(), notes);
+      currPs = psUtil.current(notes);
       if (currPs == null) {
         problem(
             String.format("Current patch set %d not found", change().currentPatchSetId().get()));
       }
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       error("Failed to look up current patch set", e);
     }
   }
 
   private boolean openRepo() {
-    Project.NameKey project = change().getDest().getParentKey();
+    Project.NameKey project = change().getDest().project();
     try {
       repo = repoManager.openRepository(project);
       oi = repo.newObjectInserter();
@@ -259,8 +255,8 @@
     List<PatchSet> all;
     try {
       // Iterate in descending order.
-      all = PS_ID_ORDER.sortedCopy(psUtil.byChange(db.get(), notes));
-    } catch (OrmException e) {
+      all = PS_ID_ORDER.sortedCopy(psUtil.byChange(notes));
+    } catch (StorageException e) {
       return error("Failed to look up patch sets", e);
     }
     patchSetsBySha = MultimapBuilder.hashKeys(all.size()).treeSetValues(PS_ID_ORDER).build();
@@ -269,7 +265,7 @@
     try {
       refs =
           repo.getRefDatabase()
-              .exactRef(all.stream().map(ps -> ps.getId().toRefName()).toArray(String[]::new));
+              .exactRef(all.stream().map(ps -> ps.id().toRefName()).toArray(String[]::new));
     } catch (IOException e) {
       error("error reading refs", e);
       refs = Collections.emptyMap();
@@ -278,12 +274,9 @@
     List<DeletePatchSetFromDbOp> deletePatchSetOps = new ArrayList<>();
     for (PatchSet ps : all) {
       // Check revision format.
-      int psNum = ps.getId().get();
-      String refName = ps.getId().toRefName();
-      ObjectId objId = parseObjectId(ps.getRevision().get(), "patch set " + psNum);
-      if (objId == null) {
-        continue;
-      }
+      int psNum = ps.id().get();
+      String refName = ps.id().toRefName();
+      ObjectId objId = ps.commitId();
       patchSetsBySha.put(objId, ps);
 
       // Check ref existence.
@@ -303,13 +296,13 @@
       RevCommit psCommit = parseCommit(objId, String.format("patch set %d", psNum));
       if (psCommit == null) {
         if (fix != null && fix.deletePatchSetIfCommitMissing) {
-          deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.getId()));
+          deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.id()));
         }
         continue;
       } else if (refProblem != null && fix != null) {
         fixPatchSetRef(refProblem, ps);
       }
-      if (ps.getId().equals(change().currentPatchSetId())) {
+      if (ps.id().equals(change().currentPatchSetId())) {
         currPsCommit = psCommit;
       }
     }
@@ -323,7 +316,7 @@
         problem(
             String.format(
                 "Multiple patch sets pointing to %s: %s",
-                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::getPatchSetId)));
+                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::number)));
       }
     }
 
@@ -331,7 +324,7 @@
   }
 
   private void checkMerged() {
-    String refName = change().getDest().get();
+    String refName = change().getDest().branch();
     Ref dest;
     try {
       dest = repo.getRefDatabase().exactRef(refName);
@@ -355,40 +348,55 @@
       try {
         merged = rw.isMergedInto(currPsCommit, tip);
       } catch (IOException e) {
-        problem("Error checking whether patch set " + currPs.getId().get() + " is merged");
+        problem("Error checking whether patch set " + currPs.id().get() + " is merged");
         return;
       }
-      checkMergedBitMatchesStatus(currPs.getId(), currPsCommit, merged);
+      checkMergedBitMatchesStatus(currPs.id(), currPsCommit, merged);
     }
   }
 
   private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
-    String refName = change().getDest().get();
+    String refName = change().getDest().branch();
     return problem(
-        String.format(
+        formatProblemMessage(
             "Patch set %d (%s) is merged into destination ref %s (%s), but change"
                 + " status is %s",
-            psId.get(), commit.name(), refName, tip.name(), change().getStatus()));
+            psId.get(), commit.name(), refName, tip.name()));
   }
 
   private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, boolean merged) {
-    String refName = change().getDest().get();
-    if (merged && change().getStatus() != Change.Status.MERGED) {
+    String refName = change().getDest().branch();
+    if (merged && !change().isMerged()) {
       ProblemInfo p = wrongChangeStatus(psId, commit);
       if (fix != null) {
         fixMerged(p);
       }
-    } else if (!merged && change().getStatus() == Change.Status.MERGED) {
+    } else if (!merged && change().isMerged()) {
       problem(
-          String.format(
+          formatProblemMessage(
               "Patch set %d (%s) is not merged into"
                   + " destination ref %s (%s), but change status is %s",
-              currPs.getId().get(), commit.name(), refName, tip.name(), change().getStatus()));
+              currPs.id().get(), commit.name(), refName, tip.name()));
     }
   }
 
+  private String formatProblemMessage(
+      String message, int psId, String commitName, String refName, String tipName) {
+    return String.format(
+        message,
+        psId,
+        commitName,
+        refName,
+        tipName,
+        ChangeUtil.status(change()).toUpperCase(Locale.US));
+  }
+
   private void checkExpectMergedAs() {
-    ObjectId objId = parseObjectId(fix.expectMergedAs, "expected merged commit");
+    if (!ObjectId.isId(fix.expectMergedAs)) {
+      problem("Invalid revision on expected merged commit: " + fix.expectMergedAs);
+      return;
+    }
+    ObjectId objId = ObjectId.fromString(fix.expectMergedAs);
     RevCommit commit = parseCommit(objId, "expected merged commit");
     if (commit == null) {
       return;
@@ -399,12 +407,12 @@
         problem(
             String.format(
                 "Expected merged commit %s is not merged into destination ref %s (%s)",
-                commit.name(), change().getDest().get(), tip.name()));
+                commit.name(), change().getDest().branch(), tip.name()));
         return;
       }
 
       List<PatchSet.Id> thisCommitPsIds = new ArrayList<>();
-      for (Ref ref : repo.getRefDatabase().getRefs(REFS_CHANGES).values()) {
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(REFS_CHANGES)) {
         if (!ref.getObjectId().equals(commit)) {
           continue;
         }
@@ -413,14 +421,11 @@
           continue;
         }
         try {
-          Change c =
-              notesFactory
-                  .createChecked(db.get(), change().getProject(), psId.getParentKey())
-                  .getChange();
+          Change c = notesFactory.createChecked(change().getProject(), psId.changeId()).getChange();
           if (!c.getDest().equals(change().getDest())) {
             continue;
           }
-        } catch (OrmException e) {
+        } catch (StorageException e) {
           warn(e);
           // Include this patch set; should cause an error below, which is good.
         }
@@ -464,7 +469,10 @@
           problem(
               String.format(
                   "Multiple patch sets for expected merged commit %s: %s",
-                  commit.name(), intKeyOrdering().sortedCopy(thisCommitPsIds)));
+                  commit.name(),
+                  thisCommitPsIds.stream()
+                      .sorted(comparing(PatchSet.Id::get))
+                      .collect(toImmutableList())));
           break;
       }
     } catch (IOException e) {
@@ -503,7 +511,7 @@
     List<ProblemInfo> currProblems = new ArrayList<>(3);
     currProblems.add(notFound);
     if (deleteOldPatchSetProblem != null) {
-      currProblems.add(insertPatchSetProblem);
+      currProblems.add(deleteOldPatchSetProblem);
     }
     currProblems.add(insertPatchSetProblem);
 
@@ -530,25 +538,25 @@
           if (!reuseOldPsId) {
             bu.addOp(
                 notes.getChangeId(),
-                new DeletePatchSetFromDbOp(checkNotNull(deleteOldPatchSetProblem), psIdToDelete));
+                new DeletePatchSetFromDbOp(requireNonNull(deleteOldPatchSetProblem), psIdToDelete));
           }
         }
 
+        bu.setNotify(NotifyResolver.Result.none());
         bu.addOp(
             notes.getChangeId(),
             inserter
                 .setValidate(false)
                 .setFireRevisionCreated(false)
-                .setNotify(NotifyHandling.NONE)
                 .setAllowClosed(true)
                 .setMessage("Patch set for merged commit inserted by consistency checker"));
         bu.addOp(notes.getChangeId(), new FixMergedOp(notFound));
         bu.execute();
       }
-      notes = notesFactory.createChecked(db.get(), inserter.getChange());
+      notes = notesFactory.createChecked(inserter.getChange());
       insertPatchSetProblem.status = Status.FIXED;
       insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
-    } catch (OrmException | IOException | UpdateException | RestApiException e) {
+    } catch (StorageException | IOException | UpdateException | RestApiException e) {
       warn(e);
       for (ProblemInfo pi : currProblems) {
         pi.status = Status.FIX_FAILED;
@@ -566,7 +574,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException {
+    public boolean updateChange(ChangeContext ctx) {
       ctx.getChange().setStatus(Change.Status.MERGED);
       ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
       p.status = Status.FIXED;
@@ -588,14 +596,14 @@
   }
 
   private BatchUpdate newBatchUpdate() {
-    return updateFactory.create(db.get(), change().getProject(), user.get(), TimeUtil.nowTs());
+    return updateFactory.create(change().getProject(), user.get(), TimeUtil.nowTs());
   }
 
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
     try {
-      RefUpdate ru = repo.updateRef(ps.getId().toRefName());
+      RefUpdate ru = repo.updateRef(ps.id().toRefName());
       ru.setForceUpdate(true);
-      ru.setNewObjectId(ObjectId.fromString(ps.getRevision().get()));
+      ru.setNewObjectId(ps.commitId());
       ru.setRefLogIdent(newRefLogIdent());
       ru.setRefLogMessage("Repair patch set ref", true);
       RefUpdate.Result result = ru.update();
@@ -622,7 +630,7 @@
       }
     } catch (IOException e) {
       String msg = "Error fixing patch set ref";
-      logger.atWarning().withCause(e).log("%s %s", msg, ps.getId().toRefName());
+      logger.atWarning().withCause(e).log("%s %s", msg, ps.id().toRefName());
       p.status = Status.FIX_FAILED;
       p.outcome = msg;
     }
@@ -632,7 +640,7 @@
     try (BatchUpdate bu = newBatchUpdate()) {
       bu.setRepository(repo, rw, oi);
       for (DeletePatchSetFromDbOp op : ops) {
-        checkArgument(op.psId.getParentKey().equals(notes.getChangeId()));
+        checkArgument(op.psId.changeId().equals(notes.getChangeId()));
         bu.addOp(notes.getChangeId(), op);
       }
       bu.addOp(notes.getChangeId(), new UpdateCurrentPatchSetOp(ops));
@@ -644,7 +652,7 @@
       }
     } catch (UpdateException | RestApiException e) {
       String msg = "Error deleting patch set";
-      logger.atWarning().withCause(e).log("%s of change %s", msg, ops.get(0).psId.getParentKey());
+      logger.atWarning().withCause(e).log("%s of change %s", msg, ops.get(0).psId.changeId());
       for (DeletePatchSetFromDbOp op : ops) {
         // Overwrite existing statuses that were set before the transaction was
         // rolled back.
@@ -664,18 +672,11 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws OrmException, PatchSetInfoNotAvailableException {
+    public boolean updateChange(ChangeContext ctx) throws PatchSetInfoNotAvailableException {
       // Delete dangling key references.
-      ReviewDb db = BatchUpdateReviewDb.unwrap(ctx.getDb());
-      accountPatchReviewStore.get().clearReviewed(psId);
-      db.changeMessages().delete(db.changeMessages().byChange(psId.getParentKey()));
-      db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
-      db.patchComments().delete(db.patchComments().byPatchSet(psId));
-      db.patchSets().deleteKeys(Collections.singleton(psId));
+      accountPatchReviewStore.run(s -> s.clearReviewed(psId));
 
-      // NoteDb requires no additional fiddling; setting the state to deleted is
-      // sufficient to filter everything else out.
+      // For NoteDb setting the state to deleted is sufficient to filter everything out.
       ctx.getUpdate(psId).setPatchSetState(PatchSetState.DELETED);
 
       p.status = Status.FIXED;
@@ -704,25 +705,23 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, PatchSetInfoNotAvailableException, NoPatchSetsWouldRemainException {
+        throws PatchSetInfoNotAvailableException, NoPatchSetsWouldRemainException {
       if (!toDelete.contains(ctx.getChange().currentPatchSetId())) {
         return false;
       }
-      Set<PatchSet.Id> all = new HashSet<>();
+      TreeSet<PatchSet.Id> all = new TreeSet<>(comparing(PatchSet.Id::get));
       // Doesn't make any assumptions about the order in which deletes happen
       // and whether they are seen by this op; we are already given the full set
       // of patch sets that will eventually be deleted in this update.
-      for (PatchSet ps : psUtil.byChange(ctx.getDb(), ctx.getNotes())) {
-        if (!toDelete.contains(ps.getId())) {
-          all.add(ps.getId());
+      for (PatchSet ps : psUtil.byChange(ctx.getNotes())) {
+        if (!toDelete.contains(ps.id())) {
+          all.add(ps.id());
         }
       }
       if (all.isEmpty()) {
         throw new NoPatchSetsWouldRemainException();
       }
-      PatchSet.Id latest = ReviewDbUtil.intKeyOrdering().max(all);
-      ctx.getChange()
-          .setCurrentPatchSet(patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), latest));
+      ctx.getChange().setCurrentPatchSet(patchSetInfoFactory.get(ctx.getNotes(), all.last()));
       return true;
     }
   }
@@ -735,15 +734,6 @@
     return serverIdent.get();
   }
 
-  private ObjectId parseObjectId(String objIdStr, String desc) {
-    try {
-      return ObjectId.fromString(objIdStr);
-    } catch (IllegalArgumentException e) {
-      problem(String.format("Invalid revision on %s: %s", desc, objIdStr));
-      return null;
-    }
-  }
-
   private RevCommit parseCommit(ObjectId objId, String desc) {
     try {
       return rw.parseCommit(objId);
@@ -759,7 +749,7 @@
 
   private ProblemInfo problem(String msg) {
     ProblemInfo p = new ProblemInfo();
-    p.message = checkNotNull(msg);
+    p.message = requireNonNull(msg);
     problems.add(p);
     return p;
   }
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
new file mode 100644
index 0000000..2449df2
--- /dev/null
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.extensions.events.ChangeDeleted;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class DeleteChangeOp implements BatchUpdateOp {
+  public interface Factory {
+    DeleteChangeOp create(Change.Id id);
+  }
+
+  private final PatchSetUtil psUtil;
+  private final StarredChangesUtil starredChangesUtil;
+  private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+  private final ChangeDeleted changeDeleted;
+  private final Change.Id id;
+
+  @Inject
+  DeleteChangeOp(
+      PatchSetUtil psUtil,
+      StarredChangesUtil starredChangesUtil,
+      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+      ChangeDeleted changeDeleted,
+      @Assisted Change.Id id) {
+    this.psUtil = psUtil;
+    this.starredChangesUtil = starredChangesUtil;
+    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.changeDeleted = changeDeleted;
+    this.id = id;
+  }
+
+  // The relative order of updateChange and updateRepo doesn't matter as long as all operations are
+  // executed in a single atomic BatchRefUpdate. Actually deleting the change refs first would not
+  // fail gracefully if the second delete fails, but fortunately that's not what happens.
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException, IOException {
+    Collection<PatchSet> patchSets = psUtil.byChange(ctx.getNotes());
+
+    ensureDeletable(ctx, id, patchSets);
+    // Cleaning up is only possible as long as the change and its elements are
+    // still part of the database.
+    cleanUpReferences(id);
+
+    ctx.deleteChange();
+    changeDeleted.fire(ctx.getChange(), ctx.getAccount(), ctx.getWhen());
+    return true;
+  }
+
+  private void ensureDeletable(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
+      throws ResourceConflictException, MethodNotAllowedException, IOException {
+    if (ctx.getChange().isMerged()) {
+      throw new MethodNotAllowedException("Deleting merged change " + id + " is not allowed");
+    }
+    for (PatchSet patchSet : patchSets) {
+      if (isPatchSetMerged(ctx, patchSet)) {
+        throw new ResourceConflictException(
+            String.format(
+                "Cannot delete change %s: patch set %s is already merged", id, patchSet.number()));
+      }
+    }
+  }
+
+  private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
+    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().branch());
+    if (!destId.isPresent()) {
+      return false;
+    }
+
+    RevWalk revWalk = ctx.getRevWalk();
+    return revWalk.isMergedInto(
+        revWalk.parseCommit(patchSet.commitId()), revWalk.parseCommit(destId.get()));
+  }
+
+  private void cleanUpReferences(Change.Id id) throws IOException {
+    accountPatchReviewStore.run(s -> s.clearReviewed(id));
+
+    // Non-atomic operation on All-Users refs; not much we can do to make it atomic.
+    starredChangesUtil.unstarAllForChangeDeletion(id);
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws IOException {
+    String prefix = PatchSet.id(id, 1).toRefName();
+    prefix = prefix.substring(0, prefix.length() - 1);
+    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
+      ctx.addRefUpdate(e.getValue(), ObjectId.zeroId(), prefix + e.getKey());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
new file mode 100644
index 0000000..b42e192
--- /dev/null
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collections;
+
+public class DeleteReviewerByEmailOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    DeleteReviewerByEmailOp create(Address reviewer);
+  }
+
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final Address reviewer;
+
+  private ChangeMessage changeMessage;
+  private Change change;
+
+  @Inject
+  DeleteReviewerByEmailOp(
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory, @Assisted Address reviewer) {
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.reviewer = reviewer;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) {
+    change = ctx.getChange();
+    PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+    String msg = "Removed reviewer " + reviewer;
+    changeMessage =
+        new ChangeMessage(
+            ChangeMessage.key(change.getId(), ChangeUtil.messageUuid()),
+            ctx.getAccountId(),
+            ctx.getWhen(),
+            psId);
+    changeMessage.setMessage(msg);
+
+    ctx.getUpdate(psId).setChangeMessage(msg);
+    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    try {
+      NotifyResolver.Result notify = ctx.getNotify(change.getId());
+      if (!notify.shouldNotify()) {
+        return;
+      }
+      DeleteReviewerSender cm =
+          deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
+      cm.setFrom(ctx.getAccountId());
+      cm.addReviewersByEmail(Collections.singleton(reviewer));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(notify);
+      cm.send();
+    } catch (Exception err) {
+      logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
new file mode 100644
index 0000000..4cb06ba
--- /dev/null
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -0,0 +1,226 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.extensions.events.ReviewerDeleted;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RemoveReviewerControl;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class DeleteReviewerOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    DeleteReviewerOp create(AccountState reviewerAccount, DeleteReviewerInput input);
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ReviewerDeleted reviewerDeleted;
+  private final Provider<IdentifiedUser> user;
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final RemoveReviewerControl removeReviewerControl;
+  private final ProjectCache projectCache;
+
+  private final AccountState reviewer;
+  private final DeleteReviewerInput input;
+
+  ChangeMessage changeMessage;
+  Change currChange;
+  PatchSet currPs;
+  Map<String, Short> newApprovals = new HashMap<>();
+  Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  DeleteReviewerOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      IdentifiedUser.GenericFactory userFactory,
+      ReviewerDeleted reviewerDeleted,
+      Provider<IdentifiedUser> user,
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      RemoveReviewerControl removeReviewerControl,
+      ProjectCache projectCache,
+      @Assisted AccountState reviewerAccount,
+      @Assisted DeleteReviewerInput input) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.userFactory = userFactory;
+    this.reviewerDeleted = reviewerDeleted;
+    this.user = user;
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.removeReviewerControl = removeReviewerControl;
+    this.projectCache = projectCache;
+    this.reviewer = reviewerAccount;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException {
+    Account.Id reviewerId = reviewer.getAccount().id();
+    // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
+    removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
+
+    if (!approvalsUtil.getReviewers(ctx.getNotes()).all().contains(reviewerId)) {
+      throw new ResourceNotFoundException();
+    }
+    currChange = ctx.getChange();
+    currPs = psUtil.current(ctx.getNotes());
+
+    LabelTypes labelTypes = projectCache.checkedGet(ctx.getProject()).getLabelTypes(ctx.getNotes());
+    // removing a reviewer will remove all her votes
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      newApprovals.put(lt.getName(), (short) 0);
+    }
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed reviewer " + reviewer.getAccount().fullName());
+    StringBuilder removedVotesMsg = new StringBuilder();
+    removedVotesMsg.append(" with the following votes:\n\n");
+    List<PatchSetApproval> del = new ArrayList<>();
+    boolean votesRemoved = false;
+    for (PatchSetApproval a : approvals(ctx, reviewerId)) {
+      // Check if removing this vote is OK
+      removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
+      del.add(a);
+      if (a.patchSetId().equals(currPs.id()) && a.value() != 0) {
+        oldApprovals.put(a.label(), a.value());
+        removedVotesMsg
+            .append("* ")
+            .append(a.label())
+            .append(formatLabelValue(a.value()))
+            .append(" by ")
+            .append(userFactory.create(a.accountId()).getNameEmail())
+            .append("\n");
+        votesRemoved = true;
+      }
+    }
+
+    if (votesRemoved) {
+      msg.append(removedVotesMsg);
+    } else {
+      msg.append(".");
+    }
+    ChangeUpdate update = ctx.getUpdate(currPs.id());
+    update.removeReviewer(reviewerId);
+
+    changeMessage =
+        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
+    cmUtil.addChangeMessage(update, changeMessage);
+
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    NotifyResolver.Result notify = ctx.getNotify(currChange.getId());
+    if (input.notify == null
+        && currChange.isWorkInProgress()
+        && !oldApprovals.isEmpty()
+        && notify.handling().compareTo(NotifyHandling.OWNER) < 0) {
+      // Override NotifyHandling from the context to notify owner if votes were removed on a WIP
+      // change.
+      notify = notify.withHandling(NotifyHandling.OWNER);
+    }
+    try {
+      if (notify.shouldNotify()) {
+        emailReviewers(ctx.getProject(), currChange, changeMessage, notify);
+      }
+    } catch (Exception err) {
+      logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
+    }
+    reviewerDeleted.fire(
+        currChange,
+        currPs,
+        reviewer,
+        ctx.getAccount(),
+        changeMessage.getMessage(),
+        newApprovals,
+        oldApprovals,
+        notify.handling(),
+        ctx.getWhen());
+  }
+
+  private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
+    Iterable<PatchSetApproval> approvals;
+    approvals = approvalsUtil.byChange(ctx.getNotes()).values();
+    return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
+  }
+
+  private String formatLabelValue(short value) {
+    if (value > 0) {
+      return "+" + value;
+    }
+    return Short.toString(value);
+  }
+
+  private void emailReviewers(
+      Project.NameKey projectName,
+      Change change,
+      ChangeMessage changeMessage,
+      NotifyResolver.Result notify)
+      throws EmailException {
+    Account.Id userId = user.get().getAccountId();
+    if (userId.equals(reviewer.getAccount().id())) {
+      // The user knows they removed themselves, don't bother emailing them.
+      return;
+    }
+    DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
+    cm.setFrom(userId);
+    cm.addReviewers(Collections.singleton(reviewer.getAccount().id()));
+    cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+    cm.setNotify(notify);
+    cm.send();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/DownloadCommandsJson.java b/java/com/google/gerrit/server/change/DownloadCommandsJson.java
new file mode 100644
index 0000000..f56a16c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/DownloadCommandsJson.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.FetchInfo;
+import com.google.gerrit.extensions.config.DownloadCommand;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
+import java.util.TreeMap;
+
+/** Populates the {@link FetchInfo} which is serialized to JSON afterwards. */
+public class DownloadCommandsJson {
+
+  private DownloadCommandsJson() {}
+
+  /**
+   * Populates the provided {@link FetchInfo} by calling all {@link DownloadCommand} extensions.
+   * Will mutate {@link FetchInfo#commands}.
+   */
+  public static void populateFetchMap(
+      DownloadScheme scheme,
+      DynamicMap<DownloadCommand> commands,
+      String projectName,
+      String refName,
+      FetchInfo fetchInfo) {
+    for (Extension<DownloadCommand> ext : commands) {
+      String commandName = ext.getExportName();
+      DownloadCommand command = ext.getProvider().get();
+      String c = command.getCommand(scheme, projectName, refName);
+      if (c != null) {
+        if (fetchInfo.commands == null) {
+          fetchInfo.commands = new TreeMap<>();
+        }
+        fetchInfo.commands.put(commandName, c);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 65bef70..c6bcd81 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -16,16 +16,11 @@
 
 import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
@@ -35,11 +30,7 @@
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
 import com.google.inject.assistedinject.Assisted;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
@@ -53,7 +44,6 @@
     // on the same set of inputs.
     /**
      * @param notify setting for handling notification.
-     * @param accountsToNotify detailed map of accounts to notify.
      * @param notes change notes.
      * @param patchSet patch set corresponding to the top-level op
      * @param user user the email should come from.
@@ -68,8 +58,7 @@
      * @return handle for sending email.
      */
     EmailReviewComments create(
-        NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify,
+        NotifyResolver.Result notify,
         ChangeNotes notes,
         PatchSet patchSet,
         IdentifiedUser user,
@@ -82,11 +71,9 @@
   private final ExecutorService sendEmailsExecutor;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommentSender.Factory commentSenderFactory;
-  private final SchemaFactory<ReviewDb> schemaFactory;
   private final ThreadLocalRequestContext requestContext;
 
-  private final NotifyHandling notify;
-  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+  private final NotifyResolver.Result notify;
   private final ChangeNotes notes;
   private final PatchSet patchSet;
   private final IdentifiedUser user;
@@ -94,17 +81,14 @@
   private final List<Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
-  private ReviewDb db;
 
   @Inject
   EmailReviewComments(
       @SendEmailExecutor ExecutorService executor,
       PatchSetInfoFactory patchSetInfoFactory,
       CommentSender.Factory commentSenderFactory,
-      SchemaFactory<ReviewDb> schemaFactory,
       ThreadLocalRequestContext requestContext,
-      @Assisted NotifyHandling notify,
-      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      @Assisted NotifyResolver.Result notify,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
@@ -115,10 +99,8 @@
     this.sendEmailsExecutor = executor;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commentSenderFactory = commentSenderFactory;
-    this.schemaFactory = schemaFactory;
     this.requestContext = requestContext;
     this.notify = notify;
-    this.accountsToNotify = accountsToNotify;
     this.notes = notes;
     this.patchSet = patchSet;
     this.user = user;
@@ -137,7 +119,6 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-
       CommentSender cm = commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
       cm.setFrom(user.getAccountId());
       cm.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
@@ -146,16 +127,11 @@
       cm.setPatchSetComment(patchSetComment);
       cm.setLabels(labels);
       cm.setNotify(notify);
-      cm.setAccountsToNotify(accountsToNotify);
       cm.send();
     } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.getId());
+      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
     } finally {
       requestContext.setContext(old);
-      if (db != null) {
-        db.close();
-        db = null;
-      }
     }
   }
 
@@ -168,21 +144,4 @@
   public CurrentUser getUser() {
     return user.getRealUser();
   }
-
-  @Override
-  public Provider<ReviewDb> getReviewDbProvider() {
-    return new Provider<ReviewDb>() {
-      @Override
-      public ReviewDb get() {
-        if (db == null) {
-          try {
-            db = schemaFactory.open();
-          } catch (OrmException e) {
-            throw new ProvisionException("Cannot open ReviewDb", e);
-          }
-        }
-        return db;
-      }
-    };
-  }
 }
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java
index 00b7a88..a806f94 100644
--- a/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -20,7 +20,6 @@
 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.common.data.PatchScript.FileMode;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import eu.medsea.mimeutil.MimeType;
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index f4b7457..8e7f8ea 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -43,32 +43,30 @@
 
   public Map<String, FileInfo> toFileInfoMap(Change change, PatchSet patchSet)
       throws PatchListNotAvailableException {
-    return toFileInfoMap(change, patchSet.getRevision(), null);
-  }
-
-  public Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, @Nullable PatchSet base)
-      throws PatchListNotAvailableException {
-    ObjectId objectId = ObjectId.fromString(revision.get());
-    return toFileInfoMap(change, objectId, base);
+    return toFileInfoMap(change, patchSet.commitId(), null);
   }
 
   public Map<String, FileInfo> toFileInfoMap(
       Change change, ObjectId objectId, @Nullable PatchSet base)
       throws PatchListNotAvailableException {
-    ObjectId a = (base == null) ? null : ObjectId.fromString(base.getRevision().get());
+    ObjectId a = base != null ? base.commitId() : null;
     return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
   }
 
-  public Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
+  public Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, int parent)
       throws PatchListNotAvailableException {
-    ObjectId b = ObjectId.fromString(revision.get());
     return toFileInfoMap(
-        change, PatchListKey.againstParentNum(parent + 1, b, Whitespace.IGNORE_NONE));
+        change, PatchListKey.againstParentNum(parent + 1, objectId, Whitespace.IGNORE_NONE));
   }
 
   private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
       throws PatchListNotAvailableException {
-    PatchList list = patchListCache.get(key, change.getProject());
+    return toFileInfoMap(change.getProject(), key);
+  }
+
+  public Map<String, FileInfo> toFileInfoMap(Project.NameKey project, PatchListKey key)
+      throws PatchListNotAvailableException {
+    PatchList list = patchListCache.get(key, project);
 
     Map<String, FileInfo> files = new TreeMap<>();
     for (PatchListEntry e : list.getPatches()) {
diff --git a/java/com/google/gerrit/server/change/FileResource.java b/java/com/google/gerrit/server/change/FileResource.java
index bd7557f..ba724ec 100644
--- a/java/com/google/gerrit/server/change/FileResource.java
+++ b/java/com/google/gerrit/server/change/FileResource.java
@@ -29,7 +29,7 @@
 
   public FileResource(RevisionResource rev, String name) {
     this.rev = rev;
-    this.key = new Patch.Key(rev.getPatchSet().getId(), name);
+    this.key = Patch.key(rev.getPatchSet().id(), name);
   }
 
   public Patch.Key getPatchKey() {
diff --git a/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
index 8f8925a..3ac6959 100644
--- a/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/java/com/google/gerrit/server/change/IncludedIn.java
@@ -18,12 +18,12 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -37,10 +37,11 @@
 @Singleton
 public class IncludedIn {
   private final GitRepositoryManager repoManager;
-  private final DynamicSet<ExternalIncludedIn> externalIncludedIn;
+  private final PluginSetContext<ExternalIncludedIn> externalIncludedIn;
 
   @Inject
-  IncludedIn(GitRepositoryManager repoManager, DynamicSet<ExternalIncludedIn> externalIncludedIn) {
+  IncludedIn(
+      GitRepositoryManager repoManager, PluginSetContext<ExternalIncludedIn> externalIncludedIn) {
     this.repoManager = repoManager;
     this.externalIncludedIn = externalIncludedIn;
   }
@@ -61,15 +62,17 @@
 
       IncludedInResolver.Result d = IncludedInResolver.resolve(r, rw, rev);
       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());
-        if (extIncludedIns != null) {
-          external.putAll(extIncludedIns);
-        }
-      }
+      externalIncludedIn.runEach(
+          ext -> {
+            ListMultimap<String, String> extIncludedIns =
+                ext.getIncludedIn(project.get(), rev.name(), d.tags(), d.branches());
+            if (extIncludedIns != null) {
+              external.putAll(extIncludedIns);
+            }
+          });
+
       return new IncludedInInfo(
-          d.getBranches(), d.getTags(), (!external.isEmpty() ? external.asMap() : null));
+          d.branches(), d.tags(), (!external.isEmpty() ? external.asMap() : null));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
index c577f2d..09ca258 100644
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -14,6 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -22,7 +29,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -85,19 +91,17 @@
 
   private Result resolve() throws IOException {
     RefDatabase refDb = repo.getRefDatabase();
-    Collection<Ref> tags = refDb.getRefs(Constants.R_TAGS).values();
-    Collection<Ref> branches = refDb.getRefs(Constants.R_HEADS).values();
+    Collection<Ref> tags = refDb.getRefsByPrefix(Constants.R_TAGS);
+    Collection<Ref> branches = refDb.getRefsByPrefix(Constants.R_HEADS);
     List<Ref> allTagsAndBranches = Lists.newArrayListWithCapacity(tags.size() + branches.size());
     allTagsAndBranches.addAll(tags);
     allTagsAndBranches.addAll(branches);
     parseCommits(allTagsAndBranches);
     Set<String> allMatchingTagsAndBranches = includedIn(tipsByCommitTime, 0);
 
-    Result detail = new Result();
-    detail.setBranches(getMatchingRefNames(allMatchingTagsAndBranches, branches));
-    detail.setTags(getMatchingRefNames(allMatchingTagsAndBranches, tags));
-
-    return detail;
+    return new AutoValue_IncludedInResolver_Result(
+        getMatchingRefNames(allMatchingTagsAndBranches, branches),
+        getMatchingRefNames(allMatchingTagsAndBranches, tags));
   }
 
   private boolean includedInOne(Collection<Ref> refs) throws IOException {
@@ -151,15 +155,7 @@
    */
   private void partition(List<RevCommit> before, List<RevCommit> after) {
     int insertionPoint =
-        Collections.binarySearch(
-            tipsByCommitTime,
-            target,
-            new Comparator<RevCommit>() {
-              @Override
-              public int compare(RevCommit c1, RevCommit c2) {
-                return c1.getCommitTime() - c2.getCommitTime();
-              }
-            });
+        Collections.binarySearch(tipsByCommitTime, target, comparing(RevCommit::getCommitTime));
     if (insertionPoint < 0) {
       insertionPoint = -(insertionPoint + 1);
     }
@@ -175,15 +171,13 @@
    * 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 +205,14 @@
       }
       commitToRef.put(commit, ref.getName());
     }
-    tipsByCommitTime = Lists.newArrayList(commitToRef.keySet());
-    sortOlderFirst(tipsByCommitTime);
+    tipsByCommitTime =
+        commitToRef.keySet().stream().sorted(comparing(RevCommit::getCommitTime)).collect(toList());
   }
 
-  private void sortOlderFirst(List<RevCommit> tips) {
-    Collections.sort(
-        tips,
-        new Comparator<RevCommit>() {
-          @Override
-          public int compare(RevCommit c1, RevCommit c2) {
-            return c1.getCommitTime() - c2.getCommitTime();
-          }
-        });
-  }
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableSortedSet<String> branches();
 
-  public static class Result {
-    private List<String> branches;
-    private List<String> tags;
-
-    public Result() {}
-
-    public void setBranches(List<String> b) {
-      Collections.sort(b);
-      branches = b;
-    }
-
-    public List<String> getBranches() {
-      return branches;
-    }
-
-    public void setTags(List<String> t) {
-      Collections.sort(t);
-      tags = t;
-    }
-
-    public List<String> getTags() {
-      return tags;
-    }
+    public abstract ImmutableSortedSet<String> tags();
   }
 }
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index f4dbf21..2a48c3b 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -26,11 +26,8 @@
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -71,62 +68,43 @@
     }
   }
 
-  private final IdentifiedUser.GenericFactory userFactory;
   private final ProjectCache projectCache;
 
   @Inject
-  LabelNormalizer(IdentifiedUser.GenericFactory userFactory, ProjectCache projectCache) {
-    this.userFactory = userFactory;
+  LabelNormalizer(ProjectCache projectCache) {
     this.projectCache = projectCache;
   }
 
   /**
-   * @param notes change containing the given approvals.
+   * @param notes change notes containing the given approvals.
    * @param approvals list of approvals.
    * @return copies of approvals normalized to the defined ranges for the label type. Approvals for
    *     unknown labels are not included in the output.
-   * @throws OrmException
    */
   public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals)
-      throws OrmException, IOException {
-    IdentifiedUser user = userFactory.create(notes.getChange().getOwner());
-    return normalize(notes, user, approvals);
-  }
-
-  /**
-   * @param notes change notes containing the given approvals.
-   * @param user current user.
-   * @param approvals list of approvals.
-   * @return copies of approvals normalized to the defined ranges for the label type. Approvals for
-   *     unknown labels are not included in the output.
-   */
-  public Result normalize(
-      ChangeNotes notes, CurrentUser user, Collection<PatchSetApproval> approvals)
       throws IOException {
     List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
-    LabelTypes labelTypes =
-        projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes, user);
+    LabelTypes labelTypes = projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes);
     for (PatchSetApproval psa : approvals) {
-      Change.Id changeId = psa.getKey().getParentKey().getParentKey();
+      Change.Id changeId = psa.key().patchSetId().changeId();
       checkArgument(
           changeId.equals(notes.getChangeId()),
           "Approval %s does not match change %s",
-          psa.getKey(),
+          psa.key(),
           notes.getChange().getKey());
       if (psa.isLegacySubmit()) {
         unchanged.add(psa);
         continue;
       }
-      LabelType label = labelTypes.byLabel(psa.getLabelId());
+      LabelType label = labelTypes.byLabel(psa.labelId());
       if (label == null) {
         deleted.add(psa);
         continue;
       }
-      PatchSetApproval copy = copy(psa);
-      applyTypeFloor(label, copy);
-      if (copy.getValue() != psa.getValue()) {
+      PatchSetApproval copy = applyTypeFloor(label, psa);
+      if (copy.value() != psa.value()) {
         updated.add(copy);
       } else {
         unchanged.add(psa);
@@ -135,18 +113,16 @@
     return Result.create(unchanged, updated, deleted);
   }
 
-  private PatchSetApproval copy(PatchSetApproval src) {
-    return new PatchSetApproval(src.getPatchSetId(), src);
-  }
-
-  private void applyTypeFloor(LabelType lt, PatchSetApproval a) {
+  private PatchSetApproval applyTypeFloor(LabelType lt, PatchSetApproval a) {
+    PatchSetApproval.Builder b = a.toBuilder();
     LabelValue atMin = lt.getMin();
-    if (atMin != null && a.getValue() < atMin.getValue()) {
-      a.setValue(atMin.getValue());
+    if (atMin != null && a.value() < atMin.getValue()) {
+      b.value(atMin.getValue());
     }
     LabelValue atMax = lt.getMax();
-    if (atMax != null && a.getValue() > atMax.getValue()) {
-      a.setValue(atMax.getValue());
+    if (atMax != null && a.value() > atMax.getValue()) {
+      b.value(atMax.getValue());
     }
+    return b.build();
   }
 }
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
new file mode 100644
index 0000000..dd9e08b
--- /dev/null
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -0,0 +1,545 @@
+// 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.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedHashMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.VotingRangeInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards.
+ */
+public class LabelsJson {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    LabelsJson create(Iterable<ListChangesOption> options);
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeNotes.Factory notesFactory;
+  private final PermissionBackend permissionBackend;
+  private final boolean lazyLoad;
+
+  @Inject
+  LabelsJson(
+      ApprovalsUtil approvalsUtil,
+      ChangeNotes.Factory notesFactory,
+      PermissionBackend permissionBackend,
+      @Assisted Iterable<ListChangesOption> options) {
+    this.approvalsUtil = approvalsUtil;
+    this.notesFactory = notesFactory;
+    this.permissionBackend = permissionBackend;
+    this.lazyLoad = containsAnyOf(Sets.immutableEnumSet(options), ChangeJson.REQUIRE_LAZY_LOAD);
+  }
+
+  /**
+   * Returns all {@link LabelInfo}s for a single change. Uses the provided {@link AccountLoader} to
+   * lazily populate accounts. Callers have to call {@link AccountLoader#fill()} afterwards to
+   * populate all accounts in the returned {@link LabelInfo}s.
+   */
+  Map<String, LabelInfo> labelsFor(
+      AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed)
+      throws PermissionBackendException {
+    if (!standard && !detailed) {
+      return null;
+    }
+
+    LabelTypes labelTypes = cd.getLabelTypes();
+    Map<String, LabelWithStatus> withStatus =
+        cd.change().isMerged()
+            ? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed)
+            : labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed);
+    return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
+  }
+
+  /** Returns all labels that the provided user has permission to vote on. */
+  Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd)
+      throws PermissionBackendException {
+    boolean isMerged = cd.change().isMerged();
+    LabelTypes labelTypes = cd.getLabelTypes();
+    Map<String, LabelType> toCheck = new HashMap<>();
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels != null) {
+        for (SubmitRecord.Label r : rec.labels) {
+          LabelType type = labelTypes.byLabel(r.label);
+          if (type != null && (!isMerged || type.allowPostSubmit())) {
+            toCheck.put(type.getName(), type);
+          }
+        }
+      }
+    }
+
+    Map<String, Short> labels = null;
+    Set<LabelPermission.WithValue> can =
+        permissionBackendForChange(filterApprovalsBy, cd).testLabels(toCheck.values());
+    SetMultimap<String, String> permitted = LinkedHashMultimap.create();
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels == null) {
+        continue;
+      }
+      for (SubmitRecord.Label r : rec.labels) {
+        LabelType type = labelTypes.byLabel(r.label);
+        if (type == null || (isMerged && !type.allowPostSubmit())) {
+          continue;
+        }
+
+        for (LabelValue v : type.getValues()) {
+          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
+          if (isMerged) {
+            if (labels == null) {
+              labels = currentLabels(filterApprovalsBy, cd);
+            }
+            short prev = labels.getOrDefault(type.getName(), (short) 0);
+            ok &= v.getValue() >= prev;
+          }
+          if (ok) {
+            permitted.put(r.label, v.formatValue());
+          }
+        }
+      }
+    }
+
+    List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
+    for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
+      if (isOnlyZero(e.getValue())) {
+        toClear.add(e.getKey());
+      }
+    }
+    for (String label : toClear) {
+      permitted.removeAll(label);
+    }
+    return permitted.asMap();
+  }
+
+  private static boolean containsAnyOf(
+      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
+    return !Sets.intersection(toFind, set).isEmpty();
+  }
+
+  private static boolean isOnlyZero(Collection<String> values) {
+    return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
+  }
+
+  private static void addApproval(LabelInfo label, ApprovalInfo approval) {
+    if (label.all == null) {
+      label.all = new ArrayList<>();
+    }
+    label.all.add(approval);
+  }
+
+  private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
+      AccountLoader accountLoader,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
+      throws PermissionBackendException {
+    Map<String, LabelWithStatus> labels = initLabels(accountLoader, cd, labelTypes, standard);
+    if (detailed) {
+      setAllApprovals(accountLoader, cd, labels);
+    }
+    for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
+      LabelType type = labelTypes.byLabel(e.getKey());
+      if (type == null) {
+        continue;
+      }
+      if (standard) {
+        for (PatchSetApproval psa : cd.currentApprovals()) {
+          if (type.matches(psa)) {
+            short val = psa.value();
+            Account.Id accountId = psa.accountId();
+            setLabelScores(accountLoader, type, e.getValue(), val, accountId);
+          }
+        }
+      }
+      if (detailed) {
+        setLabelValues(type, e.getValue());
+      }
+    }
+    return labels;
+  }
+
+  private Integer parseRangeValue(String value) {
+    if (value.startsWith("+")) {
+      value = value.substring(1);
+    } else if (value.startsWith(" ")) {
+      value = value.trim();
+    }
+    return Ints.tryParse(value);
+  }
+
+  private ApprovalInfo approvalInfo(
+      AccountLoader accountLoader,
+      Account.Id id,
+      Integer value,
+      VotingRangeInfo permittedVotingRange,
+      String tag,
+      Timestamp date) {
+    ApprovalInfo ai = new ApprovalInfo(id.get(), value, permittedVotingRange, tag, date);
+    accountLoader.put(ai);
+    return ai;
+  }
+
+  private void setLabelValues(LabelType type, LabelWithStatus l) {
+    l.label().defaultValue = type.getDefaultValue();
+    l.label().values = new LinkedHashMap<>();
+    for (LabelValue v : type.getValues()) {
+      l.label().values.put(v.formatValue(), v.getText());
+    }
+    if (isOnlyZero(l.label().values.keySet())) {
+      l.label().values = null;
+    }
+  }
+
+  private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
+    Map<String, Short> result = new HashMap<>();
+    for (PatchSetApproval psa :
+        approvalsUtil.byPatchSetUser(
+            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
+            cd.change().currentPatchSetId(),
+            accountId,
+            null,
+            null)) {
+      result.put(psa.label(), psa.value());
+    }
+    return result;
+  }
+
+  private Map<String, LabelWithStatus> labelsForSubmittedChange(
+      AccountLoader accountLoader,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
+      throws PermissionBackendException {
+    Set<Account.Id> allUsers = new HashSet<>();
+    if (detailed) {
+      // Users expect to see all reviewers on closed changes, even if they
+      // didn't vote on the latest patch set. If we don't need detailed labels,
+      // we aren't including 0 votes for all users below, so we can just look at
+      // the latest patch set (in the next loop).
+      for (PatchSetApproval psa : cd.approvals().values()) {
+        allUsers.add(psa.accountId());
+      }
+    }
+
+    Set<String> labelNames = new HashSet<>();
+    SetMultimap<Account.Id, PatchSetApproval> current =
+        MultimapBuilder.hashKeys().hashSetValues().build();
+    for (PatchSetApproval a : cd.currentApprovals()) {
+      allUsers.add(a.accountId());
+      LabelType type = labelTypes.byLabel(a.labelId());
+      if (type != null) {
+        labelNames.add(type.getName());
+        // Not worth the effort to distinguish between votable/non-votable for 0
+        // values on closed changes, since they can't vote anyway.
+        current.put(a.accountId(), a);
+      }
+    }
+
+    // Since voting on merged changes is allowed all labels which apply to
+    // the change must be returned. All applying labels can be retrieved from
+    // the submit records, which is what initLabels does.
+    // It's not possible to only compute the labels based on the approvals
+    // since merged changes may not have approvals for all labels (e.g. if not
+    // all labels are required for submit or if the change was auto-closed due
+    // to direct push or if new labels were defined after the change was
+    // merged).
+    Map<String, LabelWithStatus> labels;
+    labels = initLabels(accountLoader, cd, labelTypes, standard);
+
+    // Also include all labels for which approvals exists. E.g. there can be
+    // approvals for labels that are ignored by a Prolog submit rule and hence
+    // it wouldn't be included in the submit records.
+    for (String name : labelNames) {
+      if (!labels.containsKey(name)) {
+        labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
+      }
+    }
+
+    if (detailed) {
+      labels.entrySet().stream()
+          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
+          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
+    }
+
+    for (Account.Id accountId : allUsers) {
+      Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
+      Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
+      if (detailed) {
+        pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+        for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
+          ApprovalInfo ai = approvalInfo(accountLoader, accountId, 0, null, null, null);
+          byLabel.put(entry.getKey(), ai);
+          addApproval(entry.getValue().label(), ai);
+        }
+      }
+      for (PatchSetApproval psa : current.get(accountId)) {
+        LabelType type = labelTypes.byLabel(psa.labelId());
+        if (type == null) {
+          continue;
+        }
+
+        short val = psa.value();
+        ApprovalInfo info = byLabel.get(type.getName());
+        if (info != null) {
+          info.value = Integer.valueOf(val);
+          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
+          info.date = psa.granted();
+          info.tag = psa.tag().orElse(null);
+          if (psa.postSubmit()) {
+            info.postSubmit = true;
+          }
+        }
+        if (!standard) {
+          continue;
+        }
+
+        setLabelScores(accountLoader, type, labels.get(type.getName()), val, accountId);
+      }
+    }
+    return labels;
+  }
+
+  private Map<String, LabelWithStatus> initLabels(
+      AccountLoader accountLoader, ChangeData cd, LabelTypes labelTypes, boolean standard) {
+    Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels == null) {
+        continue;
+      }
+      for (SubmitRecord.Label r : rec.labels) {
+        LabelWithStatus p = labels.get(r.label);
+        if (p == null || p.status().compareTo(r.status) < 0) {
+          LabelInfo n = new LabelInfo();
+          if (standard) {
+            switch (r.status) {
+              case OK:
+                n.approved = accountLoader.get(r.appliedBy);
+                break;
+              case REJECT:
+                n.rejected = accountLoader.get(r.appliedBy);
+                n.blocking = true;
+                break;
+              case IMPOSSIBLE:
+              case MAY:
+              case NEED:
+              default:
+                break;
+            }
+          }
+
+          n.optional = r.status == SubmitRecord.Label.Status.MAY ? true : null;
+          labels.put(r.label, LabelWithStatus.create(n, r.status));
+        }
+      }
+    }
+    return labels;
+  }
+
+  private void setLabelScores(
+      AccountLoader accountLoader,
+      LabelType type,
+      LabelWithStatus l,
+      short score,
+      Account.Id accountId) {
+    if (l.label().approved != null || l.label().rejected != null) {
+      return;
+    }
+
+    if (type.getMin() == null || type.getMax() == null) {
+      // Can't set score for unknown or misconfigured type.
+      return;
+    }
+
+    if (score != 0) {
+      if (score == type.getMin().getValue()) {
+        l.label().rejected = accountLoader.get(accountId);
+      } else if (score == type.getMax().getValue()) {
+        l.label().approved = accountLoader.get(accountId);
+      } else if (score < 0) {
+        l.label().disliked = accountLoader.get(accountId);
+        l.label().value = score;
+      } else if (score > 0 && l.label().disliked == null) {
+        l.label().recommended = accountLoader.get(accountId);
+        l.label().value = score;
+      }
+    }
+  }
+
+  private void setAllApprovals(
+      AccountLoader accountLoader, ChangeData cd, Map<String, LabelWithStatus> labels)
+      throws PermissionBackendException {
+    checkState(
+        !cd.change().isMerged(),
+        "should not call setAllApprovals on %s change",
+        ChangeUtil.status(cd.change()));
+
+    // Include a user in the output for this label if either:
+    //  - They are an explicit reviewer.
+    //  - They ever voted on this change.
+    Set<Account.Id> allUsers = new HashSet<>();
+    allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
+    for (PatchSetApproval psa : cd.approvals().values()) {
+      allUsers.add(psa.accountId());
+    }
+
+    Table<Account.Id, String, PatchSetApproval> current =
+        HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
+    for (PatchSetApproval psa : cd.currentApprovals()) {
+      current.put(psa.accountId(), psa.label(), psa);
+    }
+
+    LabelTypes labelTypes = cd.getLabelTypes();
+    for (Account.Id accountId : allUsers) {
+      PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
+      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+      for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
+        LabelType lt = labelTypes.byLabel(e.getKey());
+        if (lt == null) {
+          // Ignore submit record for undefined label; likely the submit rule
+          // author didn't intend for the label to show up in the table.
+          continue;
+        }
+        Integer value;
+        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
+        String tag = null;
+        Timestamp date = null;
+        PatchSetApproval psa = current.get(accountId, lt.getName());
+        if (psa != null) {
+          value = Integer.valueOf(psa.value());
+          if (value == 0) {
+            // This may be a dummy approval that was inserted when the reviewer
+            // was added. Explicitly check whether the user can vote on this
+            // label.
+            value = perm.test(new LabelPermission(lt)) ? 0 : null;
+          }
+          tag = psa.tag().orElse(null);
+          date = psa.granted();
+          if (psa.postSubmit()) {
+            logger.atWarning().log("unexpected post-submit approval on open change: %s", psa);
+          }
+        } else {
+          // Either the user cannot vote on this label, or they were added as a
+          // reviewer but have not responded yet. Explicitly check whether the
+          // user can vote on this label.
+          value = perm.test(new LabelPermission(lt)) ? 0 : null;
+        }
+        addApproval(
+            e.getValue().label(),
+            approvalInfo(accountLoader, accountId, value, permittedVotingRange, tag, date));
+      }
+    }
+  }
+
+  /**
+   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
+   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
+   *     lazyload}.
+   */
+  private PermissionBackend.ForChange permissionBackendForChange(Account.Id user, ChangeData cd) {
+    PermissionBackend.WithUser withUser = permissionBackend.absentUser(user);
+    return lazyLoad
+        ? withUser.change(cd)
+        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
+  }
+
+  private List<SubmitRecord> submitRecords(ChangeData cd) {
+    return cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
+  }
+
+  private Map<String, VotingRangeInfo> getPermittedVotingRanges(
+      Map<String, Collection<String>> permittedLabels) {
+    Map<String, VotingRangeInfo> permittedVotingRanges =
+        Maps.newHashMapWithExpectedSize(permittedLabels.size());
+    for (String label : permittedLabels.keySet()) {
+      List<Integer> permittedVotingRange =
+          permittedLabels.get(label).stream()
+              .map(this::parseRangeValue)
+              .filter(java.util.Objects::nonNull)
+              .sorted()
+              .collect(toList());
+
+      if (permittedVotingRange.isEmpty()) {
+        permittedVotingRanges.put(label, null);
+      } else {
+        int minPermittedValue = permittedVotingRange.get(0);
+        int maxPermittedValue = Iterables.getLast(permittedVotingRange);
+        permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue));
+      }
+    }
+    return permittedVotingRanges;
+  }
+
+  @AutoValue
+  abstract static class LabelWithStatus {
+    private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) {
+      return new AutoValue_LabelsJson_LabelWithStatus(label, status);
+    }
+
+    abstract LabelInfo label();
+
+    @Nullable
+    abstract SubmitRecord.Label.Status status();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/MergeabilityCache.java b/java/com/google/gerrit/server/change/MergeabilityCache.java
index 3a7f3ab..944ac89 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCache.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCache.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -29,7 +29,7 @@
         Ref intoRef,
         SubmitType submitType,
         String mergeStrategy,
-        Branch.NameKey dest,
+        BranchNameKey dest,
         Repository repo) {
       throw new UnsupportedOperationException("Mergeability checking disabled");
     }
@@ -46,7 +46,7 @@
       Ref intoRef,
       SubmitType submitType,
       String mergeStrategy,
-      Branch.NameKey dest,
+      BranchNameKey dest,
       Repository repo);
 
   Boolean getIfPresent(ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy);
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index 74810e9..0903fc9 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Converter;
 import com.google.common.base.Enums;
@@ -25,13 +25,13 @@
 import com.google.common.flogger.FluentLogger;
 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.proto.Protos;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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.ObjectIdConverter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.submit.SubmitDryRun;
@@ -85,10 +85,10 @@
           "Cannot cache %s.%s",
           SubmitType.class.getSimpleName(),
           submitType);
-      this.commit = checkNotNull(commit, "commit");
-      this.into = checkNotNull(into, "into");
-      this.submitType = checkNotNull(submitType, "submitType");
-      this.mergeStrategy = checkNotNull(mergeStrategy, "mergeStrategy");
+      this.commit = requireNonNull(commit, "commit");
+      this.into = requireNonNull(into, "into");
+      this.submitType = requireNonNull(submitType, "submitType");
+      this.mergeStrategy = requireNonNull(mergeStrategy, "mergeStrategy");
     }
 
     public ObjectId getCommit() {
@@ -134,7 +134,7 @@
           .toString();
     }
 
-    static enum Serializer implements CacheSerializer<EntryKey> {
+    enum Serializer implements CacheSerializer<EntryKey> {
       INSTANCE;
 
       private static final Converter<String, SubmitType> SUBMIT_TYPE_CONVERTER =
@@ -143,7 +143,7 @@
       @Override
       public byte[] serialize(EntryKey object) {
         ObjectIdConverter idConverter = ObjectIdConverter.create();
-        return ProtoCacheSerializers.toByteArray(
+        return Protos.toByteArray(
             MergeabilityKeyProto.newBuilder()
                 .setCommit(idConverter.toByteString(object.getCommit()))
                 .setInto(idConverter.toByteString(object.getInto()))
@@ -154,8 +154,7 @@
 
       @Override
       public EntryKey deserialize(byte[] in) {
-        MergeabilityKeyProto proto =
-            ProtoCacheSerializers.parseUnchecked(MergeabilityKeyProto.parser(), in);
+        MergeabilityKeyProto proto = Protos.parseUnchecked(MergeabilityKeyProto.parser(), in);
         ObjectIdConverter idConverter = ObjectIdConverter.create();
         return new EntryKey(
             idConverter.fromByteString(proto.getCommit()),
@@ -192,7 +191,7 @@
       Ref intoRef,
       SubmitType submitType,
       String mergeStrategy,
-      Branch.NameKey dest,
+      BranchNameKey dest,
       Repository repo) {
     ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
     EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
@@ -208,7 +207,7 @@
               accepted.add(rw.parseCommit(key.into));
               accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
               return submitDryRun.run(
-                  key.submitType, repo, rw, dest, key.into, key.commit, accepted);
+                  null, key.submitType, repo, rw, dest, key.into, key.commit, accepted);
             }
           });
     } catch (ExecutionException | UncheckedExecutionException e) {
diff --git a/java/com/google/gerrit/server/change/NotifyResolver.java b/java/com/google/gerrit/server/change/NotifyResolver.java
new file mode 100644
index 0000000..62c0fdf
--- /dev/null
+++ b/java/com/google/gerrit/server/change/NotifyResolver.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class NotifyResolver {
+  @AutoValue
+  public abstract static class Result {
+    public static Result none() {
+      return create(NotifyHandling.NONE);
+    }
+
+    public static Result all() {
+      return create(NotifyHandling.ALL);
+    }
+
+    public static Result create(NotifyHandling notifyHandling) {
+      return create(notifyHandling, ImmutableSetMultimap.of());
+    }
+
+    public static Result create(
+        NotifyHandling handling, ImmutableSetMultimap<RecipientType, Account.Id> recipients) {
+      return new AutoValue_NotifyResolver_Result(handling, recipients);
+    }
+
+    public abstract NotifyHandling handling();
+
+    public abstract ImmutableSetMultimap<RecipientType, Account.Id> accounts();
+
+    public Result withHandling(NotifyHandling notifyHandling) {
+      return create(notifyHandling, accounts());
+    }
+
+    public boolean shouldNotify() {
+      return !accounts().isEmpty() || handling().compareTo(NotifyHandling.NONE) > 0;
+    }
+  }
+
+  private final AccountResolver accountResolver;
+
+  @Inject
+  NotifyResolver(AccountResolver accountResolver) {
+    this.accountResolver = accountResolver;
+  }
+
+  public Result resolve(
+      NotifyHandling handling, @Nullable Map<RecipientType, NotifyInfo> notifyDetails)
+      throws BadRequestException, IOException, ConfigInvalidException {
+    requireNonNull(handling);
+    ImmutableSetMultimap.Builder<RecipientType, Account.Id> b = ImmutableSetMultimap.builder();
+    if (notifyDetails != null) {
+      for (Map.Entry<RecipientType, NotifyInfo> e : notifyDetails.entrySet()) {
+        b.putAll(e.getKey(), find(e.getValue().accounts));
+      }
+    }
+    return Result.create(handling, b.build());
+  }
+
+  private ImmutableList<Account.Id> find(@Nullable List<String> inputs)
+      throws BadRequestException, IOException, ConfigInvalidException {
+    if (inputs == null || inputs.isEmpty()) {
+      return ImmutableList.of();
+    }
+    ImmutableList.Builder<Account.Id> r = ImmutableList.builder();
+    List<String> problems = new ArrayList<>(inputs.size());
+    for (String nameOrEmail : inputs) {
+      try {
+        r.add(accountResolver.resolve(nameOrEmail).asUnique().getAccount().id());
+      } catch (UnprocessableEntityException e) {
+        problems.add(e.getMessage());
+      }
+    }
+
+    if (!problems.isEmpty()) {
+      throw new BadRequestException(
+          "Some accounts that should be notified could not be resolved: "
+              + problems.stream().collect(joining("\n")));
+    }
+
+    return r.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/NotifyUtil.java b/java/com/google/gerrit/server/change/NotifyUtil.java
deleted file mode 100644
index c29faee..0000000
--- a/java/com/google/gerrit/server/change/NotifyUtil.java
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.util.stream.Collectors.joining;
-
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.NotifyInfo;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class NotifyUtil {
-  private final AccountResolver accountResolver;
-
-  @Inject
-  NotifyUtil(AccountResolver accountResolver) {
-    this.accountResolver = accountResolver;
-  }
-
-  public static boolean shouldNotify(
-      NotifyHandling notify, @Nullable Map<RecipientType, NotifyInfo> notifyDetails) {
-    if (!isNullOrEmpty(notifyDetails)) {
-      return true;
-    }
-
-    return notify.compareTo(NotifyHandling.NONE) > 0;
-  }
-
-  private static boolean isNullOrEmpty(@Nullable Map<RecipientType, NotifyInfo> notifyDetails) {
-    if (notifyDetails == null || notifyDetails.isEmpty()) {
-      return true;
-    }
-
-    for (NotifyInfo notifyInfo : notifyDetails.values()) {
-      if (!isEmpty(notifyInfo)) {
-        return false;
-      }
-    }
-
-    return true;
-  }
-
-  private static boolean isEmpty(NotifyInfo notifyInfo) {
-    return notifyInfo.accounts == null || notifyInfo.accounts.isEmpty();
-  }
-
-  public ListMultimap<RecipientType, Account.Id> resolveAccounts(
-      @Nullable Map<RecipientType, NotifyInfo> notifyDetails)
-      throws OrmException, BadRequestException, IOException, ConfigInvalidException {
-    if (isNullOrEmpty(notifyDetails)) {
-      return ImmutableListMultimap.of();
-    }
-
-    ListMultimap<RecipientType, Account.Id> m = null;
-    for (Entry<RecipientType, NotifyInfo> e : notifyDetails.entrySet()) {
-      List<String> accounts = e.getValue().accounts;
-      if (accounts != null) {
-        if (m == null) {
-          m = MultimapBuilder.hashKeys().arrayListValues().build();
-        }
-        m.putAll(e.getKey(), find(accounts));
-      }
-    }
-
-    return m != null ? m : ImmutableListMultimap.of();
-  }
-
-  private List<Account.Id> find(List<String> nameOrEmails)
-      throws OrmException, BadRequestException, IOException, ConfigInvalidException {
-    List<String> missing = new ArrayList<>(nameOrEmails.size());
-    List<Account.Id> r = new ArrayList<>(nameOrEmails.size());
-    for (String nameOrEmail : nameOrEmails) {
-      Account a = accountResolver.find(nameOrEmail);
-      if (a != null) {
-        r.add(a.getId());
-      } else {
-        missing.add(nameOrEmail);
-      }
-    }
-
-    if (!missing.isEmpty()) {
-      throw new BadRequestException(
-          "The following accounts that should be notified could not be resolved: "
-              + missing.stream().distinct().sorted().collect(joining(", ")));
-    }
-
-    return r;
-  }
-}
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index b979240..fecc099 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -14,26 +14,19 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.Objects.requireNonNull;
 
-import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -56,7 +49,6 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -80,7 +72,6 @@
   private final ProjectCache projectCache;
   private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
-  private final ApprovalCopier approvalCopier;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
 
@@ -99,10 +90,8 @@
   private boolean checkAddPatchSetPermission = true;
   private List<String> groups = Collections.emptyList();
   private boolean fireRevisionCreated = true;
-  private NotifyHandling notify = NotifyHandling.ALL;
-  private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
   private boolean allowClosed;
-  private boolean copyApprovals = true;
+  private boolean sendEmail = true;
 
   // Fields set during some phase of BatchUpdate.Op.
   private Change change;
@@ -115,7 +104,6 @@
   public PatchSetInserter(
       PermissionBackend permissionBackend,
       ApprovalsUtil approvalsUtil,
-      ApprovalCopier approvalCopier,
       ChangeMessagesUtil cmUtil,
       PatchSetInfoFactory patchSetInfoFactory,
       CommitValidators.Factory commitValidatorsFactory,
@@ -128,7 +116,6 @@
       @Assisted ObjectId commitId) {
     this.permissionBackend = permissionBackend;
     this.approvalsUtil = approvalsUtil;
-    this.approvalCopier = approvalCopier;
     this.cmUtil = cmUtil;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commitValidatorsFactory = commitValidatorsFactory;
@@ -167,7 +154,7 @@
   }
 
   public PatchSetInserter setGroups(List<String> groups) {
-    checkNotNull(groups, "groups may not be null");
+    requireNonNull(groups, "groups may not be null");
     this.groups = groups;
     return this;
   }
@@ -177,24 +164,13 @@
     return this;
   }
 
-  public PatchSetInserter setNotify(NotifyHandling notify) {
-    this.notify = Preconditions.checkNotNull(notify);
-    return this;
-  }
-
-  public PatchSetInserter setAccountsToNotify(
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    this.accountsToNotify = checkNotNull(accountsToNotify);
-    return this;
-  }
-
   public PatchSetInserter setAllowClosed(boolean allowClosed) {
     this.allowClosed = allowClosed;
     return this;
   }
 
-  public PatchSetInserter setCopyApprovals(boolean copyApprovals) {
-    this.copyApprovals = copyApprovals;
+  public PatchSetInserter setSendEmail(boolean sendEmail) {
+    this.sendEmail = sendEmail;
     return this;
   }
 
@@ -210,22 +186,18 @@
 
   @Override
   public void updateRepo(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException, OrmException,
-          PermissionBackendException {
+      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     validate(ctx);
     ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws ResourceConflictException, OrmException, IOException {
-    ReviewDb db = ctx.getDb();
-
+  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, IOException {
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setSubjectForCommit("Create patch set " + psId.get());
 
-    if (!change.getStatus().isOpen() && !allowClosed) {
+    if (!change.isNew() && !allowClosed) {
       throw new ResourceConflictException(
           String.format(
               "Cannot create new patch set of change %s because it is %s",
@@ -234,30 +206,23 @@
 
     List<String> newGroups = groups;
     if (newGroups.isEmpty()) {
-      PatchSet prevPs = psUtil.current(db, ctx.getNotes());
+      PatchSet prevPs = psUtil.current(ctx.getNotes());
       if (prevPs != null) {
-        newGroups = prevPs.getGroups();
+        newGroups = prevPs.groups();
       }
     }
     patchSet =
         psUtil.insert(
-            db,
-            ctx.getRevWalk(),
-            ctx.getUpdate(psId),
-            psId,
-            commitId,
-            newGroups,
-            null,
-            description);
+            ctx.getRevWalk(), ctx.getUpdate(psId), psId, commitId, newGroups, null, description);
 
-    if (notify != NotifyHandling.NONE) {
-      oldReviewers = approvalsUtil.getReviewers(db, ctx.getNotes());
+    if (ctx.getNotify(change.getId()).handling() != NotifyHandling.NONE) {
+      oldReviewers = approvalsUtil.getReviewers(ctx.getNotes());
     }
 
     if (message != null) {
       changeMessage =
           ChangeMessagesUtil.newMessage(
-              patchSet.getId(),
+              patchSet.id(),
               ctx.getUser(),
               ctx.getWhen(),
               message,
@@ -271,24 +236,17 @@
       change.setStatus(Change.Status.NEW);
     }
     change.setCurrentPatchSet(patchSetInfo);
-    if (copyApprovals) {
-      approvalCopier.copyInReviewDb(
-          db,
-          ctx.getNotes(),
-          ctx.getUser(),
-          patchSet,
-          ctx.getRevWalk(),
-          ctx.getRepoView().getConfig());
-    }
     if (changeMessage != null) {
-      cmUtil.addChangeMessage(db, update, changeMessage);
+      cmUtil.addChangeMessage(update, changeMessage);
     }
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) throws OrmException {
-    if (notify != NotifyHandling.NONE || !accountsToNotify.isEmpty()) {
+  public void postUpdate(Context ctx) {
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
+    if (notify.shouldNotify() && sendEmail) {
+      requireNonNull(changeMessage);
       try {
         ReplacePatchSetSender cm = replacePatchSetFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
@@ -297,7 +255,6 @@
         cm.addReviewers(oldReviewers.byState(REVIEWER));
         cm.addExtraCC(oldReviewers.byState(CC));
         cm.setNotify(notify);
-        cm.setAccountsToNotify(accountsToNotify);
         cm.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
@@ -311,26 +268,18 @@
   }
 
   private void validate(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException, PermissionBackendException,
-          OrmException {
+      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     // Not allowed to create a new patch set if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(origNotes, ctx.getUser());
+    psUtil.checkPatchSetNotLocked(origNotes);
 
     if (checkAddPatchSetPermission) {
-      permissionBackend
-          .user(ctx.getUser())
-          .database(ctx.getDb())
-          .change(origNotes)
-          .check(ChangePermission.ADD_PATCH_SET);
+      permissionBackend.user(ctx.getUser()).change(origNotes).check(ChangePermission.ADD_PATCH_SET);
     }
     projectCache.checkedGet(ctx.getProject()).checkStatePermitsWrite();
     if (!validate) {
       return;
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).ref(origNotes.getChange().getDest());
-
     String refName = getPatchSetId().toRefName();
     try (CommitReceivedEvent event =
         new CommitReceivedEvent(
@@ -339,13 +288,13 @@
                 commitId,
                 refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
             projectCache.checkedGet(origNotes.getProjectName()).getProject(),
-            origNotes.getChange().getDest().get(),
+            origNotes.getChange().getDest().branch(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
             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/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
new file mode 100644
index 0000000..9928125
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.concurrent.TimeUnit.MINUTES;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.server.DynamicOptions.BeanProvider;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+/** Static helpers for use by {@link PluginDefinedAttributesFactory} implementations. */
+public class PluginDefinedAttributesFactories {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Nullable
+  public static ImmutableList<PluginDefinedInfo> createAll(
+      ChangeData cd,
+      BeanProvider beanProvider,
+      Stream<Extension<ChangeAttributeFactory>> attrFactories) {
+    ImmutableList<PluginDefinedInfo> result =
+        attrFactories
+            .map(e -> tryCreate(cd, beanProvider, e.getPluginName(), e.get()))
+            .filter(Objects::nonNull)
+            .collect(toImmutableList());
+    return !result.isEmpty() ? result : null;
+  }
+
+  @Nullable
+  private static PluginDefinedInfo tryCreate(
+      ChangeData cd, BeanProvider beanProvider, String plugin, ChangeAttributeFactory attrFactory) {
+    PluginDefinedInfo pdi = null;
+    try {
+      pdi = attrFactory.create(cd, beanProvider, plugin);
+    } catch (RuntimeException ex) {
+      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
+          "error populating attribute on change %s from plugin %s", cd.getId(), plugin);
+    }
+    if (pdi != null) {
+      pdi.name = plugin;
+    }
+    return pdi;
+  }
+
+  private PluginDefinedAttributesFactories() {}
+}
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
new file mode 100644
index 0000000..08d6ce7
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.List;
+
+public interface PluginDefinedAttributesFactory {
+  List<PluginDefinedInfo> create(ChangeData cd);
+}
diff --git a/java/com/google/gerrit/server/change/PureRevert.java b/java/com/google/gerrit/server/change/PureRevert.java
index 850f33a..63146fa 100644
--- a/java/com/google/gerrit/server/change/PureRevert.java
+++ b/java/com/google/gerrit/server/change/PureRevert.java
@@ -14,114 +14,46 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.ByteArrayOutputStream;
+import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffFormatter;
+import java.util.Optional;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.ThreeWayMerger;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 
+/** Can check if a change is a pure revert (= a revert with no further modifications). */
+@Singleton
 public class PureRevert {
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final GitRepositoryManager repoManager;
-  private final ProjectCache projectCache;
-  private final ChangeNotes.Factory notesFactory;
-  private final Provider<ReviewDb> dbProvider;
-  private final PatchSetUtil psUtil;
+  private final PureRevertCache pureRevertCache;
 
   @Inject
-  PureRevert(
-      MergeUtil.Factory mergeUtilFactory,
-      GitRepositoryManager repoManager,
-      ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory,
-      Provider<ReviewDb> dbProvider,
-      PatchSetUtil psUtil) {
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.repoManager = repoManager;
-    this.projectCache = projectCache;
-    this.notesFactory = notesFactory;
-    this.dbProvider = dbProvider;
-    this.psUtil = psUtil;
+  PureRevert(PureRevertCache pureRevertCache) {
+    this.pureRevertCache = pureRevertCache;
   }
 
-  public PureRevertInfo get(ChangeNotes notes, @Nullable String claimedOriginal)
-      throws OrmException, IOException, BadRequestException, ResourceConflictException {
-    PatchSet currentPatchSet = psUtil.current(dbProvider.get(), notes);
+  public boolean get(ChangeNotes notes, Optional<String> claimedOriginal)
+      throws IOException, BadRequestException, ResourceConflictException {
+    PatchSet currentPatchSet = notes.getCurrentPatchSet();
     if (currentPatchSet == null) {
       throw new ResourceConflictException("current revision is missing");
     }
-
-    if (claimedOriginal == null) {
-      if (notes.getChange().getRevertOf() == null) {
-        throw new BadRequestException("no ID was provided and change isn't a revert");
-      }
-      PatchSet ps =
-          psUtil.current(
-              dbProvider.get(),
-              notesFactory.createChecked(
-                  dbProvider.get(), notes.getProjectName(), notes.getChange().getRevertOf()));
-      claimedOriginal = ps.getRevision().get();
+    if (!claimedOriginal.isPresent()) {
+      return pureRevertCache.isPureRevert(notes);
     }
 
-    try (Repository repo = repoManager.openRepository(notes.getProjectName());
-        ObjectInserter oi = repo.newObjectInserter();
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit claimedOriginalCommit;
-      try {
-        claimedOriginalCommit = rw.parseCommit(ObjectId.fromString(claimedOriginal));
-      } catch (InvalidObjectIdException | MissingObjectException e) {
-        throw new BadRequestException("invalid object ID");
-      }
-      if (claimedOriginalCommit.getParentCount() == 0) {
-        throw new BadRequestException("can't check against initial commit");
-      }
-      RevCommit claimedRevertCommit =
-          rw.parseCommit(ObjectId.fromString(currentPatchSet.getRevision().get()));
-      if (claimedRevertCommit.getParentCount() == 0) {
-        throw new BadRequestException("claimed revert has no parents");
-      }
-      // Rebase claimed revert onto claimed original
-      ThreeWayMerger merger =
-          mergeUtilFactory
-              .create(projectCache.checkedGet(notes.getProjectName()))
-              .newThreeWayMerger(oi, repo.getConfig());
-      merger.setBase(claimedRevertCommit.getParent(0));
-      merger.merge(claimedRevertCommit, claimedOriginalCommit);
-      if (merger.getResultTreeId() == null) {
-        // Merge conflict during rebase
-        return new PureRevertInfo(false);
-      }
-
-      // Any differences between claimed original's parent and the rebase result indicate that the
-      // claimedRevert is not a pure revert but made content changes
-      try (DiffFormatter df = new DiffFormatter(new ByteArrayOutputStream())) {
-        df.setRepository(repo);
-        List<DiffEntry> entries =
-            df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId());
-        return new PureRevertInfo(entries.isEmpty());
-      }
+    ObjectId claimedOriginalObjectId;
+    try {
+      claimedOriginalObjectId = ObjectId.fromString(claimedOriginal.get());
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException("invalid object ID");
     }
+
+    return pureRevertCache.isPureRevert(
+        notes.getProjectName(), notes.getCurrentPatchSet().commitId(), claimedOriginalObjectId);
   }
 }
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 0bbaccd..688d349 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -16,16 +16,15 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RebaseUtil.Base;
+import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -37,7 +36,6 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -69,9 +67,9 @@
   private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private boolean forceContentMerge;
-  private boolean copyApprovals = true;
   private boolean detailedCommitMessage;
   private boolean postMessage = true;
+  private boolean sendEmail = true;
   private boolean matchAuthorToCommitterDate = false;
 
   private RevCommit rebasedCommit;
@@ -126,11 +124,6 @@
     return this;
   }
 
-  public RebaseChangeOp setCopyApprovals(boolean copyApprovals) {
-    this.copyApprovals = copyApprovals;
-    return this;
-  }
-
   public RebaseChangeOp setDetailedCommitMessage(boolean detailedCommitMessage) {
     this.detailedCommitMessage = detailedCommitMessage;
     return this;
@@ -141,6 +134,11 @@
     return this;
   }
 
+  public RebaseChangeOp setSendEmail(boolean sendEmail) {
+    this.sendEmail = sendEmail;
+    return this;
+  }
+
   public RebaseChangeOp setMatchAuthorToCommitterDate(boolean matchAuthorToCommitterDate) {
     this.matchAuthorToCommitterDate = matchAuthorToCommitterDate;
     return this;
@@ -149,13 +147,11 @@
   @Override
   public void updateRepo(RepoContext ctx)
       throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
-          OrmException, NoSuchChangeException, PermissionBackendException {
+          NoSuchChangeException, PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
-    RevId oldRev = originalPatchSet.getRevision();
-
     RevWalk rw = ctx.getRevWalk();
-    RevCommit original = rw.parseCommit(ObjectId.fromString(oldRev.get()));
+    RevCommit original = rw.parseCommit(originalPatchSet.commitId());
     rw.parseBody(original);
     RevCommit baseCommit = rw.parseCommit(baseCommitId);
     CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
@@ -165,8 +161,7 @@
       rw.parseBody(baseCommit);
       newCommitMessage =
           newMergeUtil()
-              .createCommitMessageOnSubmit(
-                  original, baseCommit, notes, changeOwner, originalPatchSet.getId());
+              .createCommitMessageOnSubmit(original, baseCommit, notes, originalPatchSet.id());
     } else {
       newCommitMessage = original.getFullMessage();
     }
@@ -179,43 +174,47 @@
             baseCommitId.name());
 
     rebasedPatchSetId =
-        ChangeUtil.nextPatchSetIdFromChangeRefsMap(
-            ctx.getRepoView().getRefs(originalPatchSet.getId().getParentKey().toRefPrefix()),
+        ChangeUtil.nextPatchSetIdFromChangeRefs(
+            ctx.getRepoView().getRefs(originalPatchSet.id().changeId().toRefPrefix()).keySet(),
             notes.getChange().currentPatchSetId());
     patchSetInserter =
         patchSetInserterFactory
             .create(notes, rebasedPatchSetId, rebasedCommit)
             .setDescription("Rebase")
-            .setNotify(NotifyHandling.NONE)
             .setFireRevisionCreated(fireRevisionCreated)
-            .setCopyApprovals(copyApprovals)
             .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
-            .setValidate(validate);
+            .setValidate(validate)
+            .setSendEmail(sendEmail);
     if (postMessage) {
       patchSetInserter.setMessage(
           "Patch Set "
               + rebasedPatchSetId.get()
               + ": Patch Set "
-              + originalPatchSet.getId().get()
+              + originalPatchSet.id().get()
               + " was rebased");
     }
 
-    if (base != null) {
-      patchSetInserter.setGroups(base.patchSet().getGroups());
+    if (base != null && !base.notes().getChange().isMerged()) {
+      if (!base.notes().getChange().isMerged()) {
+        // Add to end of relation chain for open base change.
+        patchSetInserter.setGroups(base.patchSet().groups());
+      } else {
+        // If the base is merged, start a new relation chain.
+        patchSetInserter.setGroups(GroupCollector.getDefaultGroups(rebasedCommit));
+      }
     }
     patchSetInserter.updateRepo(ctx);
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws ResourceConflictException, OrmException, IOException {
+  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, IOException {
     boolean ret = patchSetInserter.updateChange(ctx);
     rebasedPatchSet = patchSetInserter.getPatchSet();
     return ret;
   }
 
   @Override
-  public void postUpdate(Context ctx) throws OrmException {
+  public void postUpdate(Context ctx) {
     patchSetInserter.postUpdate(ctx);
   }
 
@@ -263,9 +262,9 @@
     ThreeWayMerger merger =
         newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
     merger.setBase(parentCommit);
-    merger.merge(original, base);
+    boolean success = merger.merge(original, base);
 
-    if (merger.getResultTreeId() == null) {
+    if (!success || merger.getResultTreeId() == null) {
       throw new MergeConflictException(
           "The change could not be rebased due to a conflict during merge.");
     }
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 22f98b8..731648c 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -17,20 +17,18 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.exceptions.StorageException;
 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.reviewdb.client.Branch;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -46,30 +44,27 @@
 
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
-  private final Provider<ReviewDb> dbProvider;
   private final PatchSetUtil psUtil;
 
   @Inject
   RebaseUtil(
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
-      Provider<ReviewDb> dbProvider,
       PatchSetUtil psUtil) {
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
-    this.dbProvider = dbProvider;
     this.psUtil = psUtil;
   }
 
-  public boolean canRebase(PatchSet patchSet, Branch.NameKey dest, Repository git, RevWalk rw) {
+  public boolean canRebase(PatchSet patchSet, BranchNameKey dest, Repository git, RevWalk rw) {
     try {
       findBaseRevision(patchSet, dest, git, rw);
       return true;
     } catch (RestApiException e) {
       return false;
-    } catch (OrmException | IOException e) {
+    } catch (StorageException | IOException e) {
       logger.atWarning().withCause(e).log(
-          "Error checking if patch set %s on %s can be rebased", patchSet.getId(), dest);
+          "Error checking if patch set %s on %s can be rebased", patchSet.id(), dest);
       return false;
     }
   }
@@ -88,27 +83,24 @@
     public abstract PatchSet patchSet();
   }
 
-  public Base parseBase(RevisionResource rsrc, String base) throws OrmException {
-    ReviewDb db = dbProvider.get();
-
+  public Base parseBase(RevisionResource rsrc, String base) {
     // Try parsing the base as a ref string.
     PatchSet.Id basePatchSetId = PatchSet.Id.fromRef(base);
     if (basePatchSetId != null) {
-      Change.Id baseChangeId = basePatchSetId.getParentKey();
+      Change.Id baseChangeId = basePatchSetId.changeId();
       ChangeNotes baseNotes = notesFor(rsrc, baseChangeId);
       if (baseNotes != null) {
         return Base.create(
-            notesFor(rsrc, basePatchSetId.getParentKey()),
-            psUtil.get(db, baseNotes, basePatchSetId));
+            notesFor(rsrc, basePatchSetId.changeId()), psUtil.get(baseNotes, basePatchSetId));
       }
     }
 
     // Try parsing base as a change number (assume current patch set).
     Integer baseChangeId = Ints.tryParse(base);
     if (baseChangeId != null) {
-      ChangeNotes baseNotes = notesFor(rsrc, new Change.Id(baseChangeId));
+      ChangeNotes baseNotes = notesFor(rsrc, Change.id(baseChangeId));
       if (baseNotes != null) {
-        return Base.create(baseNotes, psUtil.current(db, baseNotes));
+        return Base.create(baseNotes, psUtil.current(baseNotes));
       }
     }
 
@@ -116,10 +108,10 @@
     Base ret = null;
     for (ChangeData cd : queryProvider.get().byProjectCommit(rsrc.getProject(), base)) {
       for (PatchSet ps : cd.patchSets()) {
-        if (!ps.getRevision().matches(base)) {
+        if (!ObjectIds.matchesAbbreviation(ps.commitId(), base)) {
           continue;
         }
-        if (ret == null || ret.patchSet().getId().get() < ps.getId().get()) {
+        if (ret == null || ret.patchSet().id().get() < ps.id().get()) {
           ret = Base.create(cd.notes(), ps);
         }
       }
@@ -127,11 +119,11 @@
     return ret;
   }
 
-  private ChangeNotes notesFor(RevisionResource rsrc, Change.Id id) throws OrmException {
+  private ChangeNotes notesFor(RevisionResource rsrc, Change.Id id) {
     if (rsrc.getChange().getId().equals(id)) {
       return rsrc.getNotes();
     }
-    return notesFactory.createChecked(dbProvider.get(), rsrc.getProject(), id);
+    return notesFactory.createChecked(rsrc.getProject(), id);
   }
 
   /**
@@ -147,13 +139,12 @@
    * @return the commit onto which the patch set should be rebased.
    * @throws RestApiException if rebase is not possible.
    * @throws IOException if accessing the repository fails.
-   * @throws OrmException if accessing the database fails.
    */
   public ObjectId findBaseRevision(
-      PatchSet patchSet, Branch.NameKey destBranch, Repository git, RevWalk rw)
-      throws RestApiException, IOException, OrmException {
-    String baseRev = null;
-    RevCommit commit = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
+      PatchSet patchSet, BranchNameKey destBranch, Repository git, RevWalk rw)
+      throws RestApiException, IOException {
+    ObjectId baseId = null;
+    RevCommit commit = rw.parseCommit(patchSet.commitId());
 
     if (commit.getParentCount() > 1) {
       throw new UnprocessableEntityException("Cannot rebase a change with multiple parents.");
@@ -162,44 +153,44 @@
           "Cannot rebase a change without any parents (is this the initial commit?).");
     }
 
-    RevId parentRev = new RevId(commit.getParent(0).name());
+    ObjectId parentId = commit.getParent(0);
 
     CHANGES:
-    for (ChangeData cd : queryProvider.get().byBranchCommit(destBranch, parentRev.get())) {
+    for (ChangeData cd : queryProvider.get().byBranchCommit(destBranch, parentId.name())) {
       for (PatchSet depPatchSet : cd.patchSets()) {
-        if (!depPatchSet.getRevision().equals(parentRev)) {
+        if (!depPatchSet.commitId().equals(parentId)) {
           continue;
         }
         Change depChange = cd.change();
-        if (depChange.getStatus() == Status.ABANDONED) {
+        if (depChange.isAbandoned()) {
           throw new ResourceConflictException(
               "Cannot rebase a change with an abandoned parent: " + depChange.getKey());
         }
 
-        if (depChange.getStatus().isOpen()) {
-          if (depPatchSet.getId().equals(depChange.currentPatchSetId())) {
+        if (depChange.isNew()) {
+          if (depPatchSet.id().equals(depChange.currentPatchSetId())) {
             throw new ResourceConflictException(
                 "Change is already based on the latest patch set of the dependent change.");
           }
-          baseRev = cd.currentPatchSet().getRevision().get();
+          baseId = cd.currentPatchSet().commitId();
         }
         break CHANGES;
       }
     }
 
-    if (baseRev == null) {
+    if (baseId == null) {
       // We are dependent on a merged PatchSet or have no PatchSet
       // dependencies at all.
-      Ref destRef = git.getRefDatabase().exactRef(destBranch.get());
+      Ref destRef = git.getRefDatabase().exactRef(destBranch.branch());
       if (destRef == null) {
         throw new UnprocessableEntityException(
-            "The destination branch does not exist: " + destBranch.get());
+            "The destination branch does not exist: " + destBranch.branch());
       }
-      baseRev = destRef.getObjectId().getName();
-      if (baseRev.equals(parentRev.get())) {
+      baseId = destRef.getObjectId();
+      if (baseId.equals(parentId)) {
         throw new ResourceConflictException("Change is already up to date.");
       }
     }
-    return ObjectId.fromString(baseRev);
+    return baseId;
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
new file mode 100644
index 0000000..1c4f63c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -0,0 +1,604 @@
+// 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.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+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.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+public class ReviewerAdder {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
+  public static final int DEFAULT_MAX_REVIEWERS = 20;
+
+  public enum FailureBehavior {
+    FAIL,
+    IGNORE;
+  }
+
+  private enum FailureType {
+    NOT_FOUND,
+    OTHER;
+  }
+
+  // TODO(dborowitz): Subclassing is not the right way to do this. We should instead use an internal
+  // type in the public interfaces of ReviewerAdder, rather than passing around the REST API type
+  // internally.
+  public static class InternalAddReviewerInput extends AddReviewerInput {
+    /**
+     * Behavior when identifying reviewers fails for any reason <em>besides</em> the input not
+     * resolving to an account/group/email.
+     */
+    public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
+  }
+
+  public static InternalAddReviewerInput newAddReviewerInput(
+      Account.Id reviewer, ReviewerState state, NotifyHandling notify) {
+    // AccountResolver always resolves by ID if the input string is numeric.
+    return newAddReviewerInput(reviewer.toString(), state, notify);
+  }
+
+  public static InternalAddReviewerInput newAddReviewerInput(
+      String reviewer, ReviewerState state, NotifyHandling notify) {
+    InternalAddReviewerInput in = new InternalAddReviewerInput();
+    in.reviewer = reviewer;
+    in.state = state;
+    in.notify = notify;
+    return in;
+  }
+
+  public static Optional<InternalAddReviewerInput> newAddReviewerInputFromCommitIdentity(
+      Change change, @Nullable Account.Id accountId, NotifyHandling notify) {
+    if (accountId == null || accountId.equals(change.getOwner())) {
+      // If git ident couldn't be resolved to a user, or if it's not forged, do nothing.
+      return Optional.empty();
+    }
+
+    InternalAddReviewerInput in = new InternalAddReviewerInput();
+    in.reviewer = accountId.toString();
+    in.state = REVIEWER;
+    in.notify = notify;
+    in.otherFailureBehavior = FailureBehavior.IGNORE;
+    return Optional.of(in);
+  }
+
+  private final AccountResolver accountResolver;
+  private final PermissionBackend permissionBackend;
+  private final GroupResolver groupResolver;
+  private final GroupMembers groupMembers;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final Config cfg;
+  private final ReviewerJson json;
+  private final ProjectCache projectCache;
+  private final Provider<AnonymousUser> anonymousProvider;
+  private final AddReviewersOp.Factory addReviewersOpFactory;
+  private final OutgoingEmailValidator validator;
+
+  @Inject
+  ReviewerAdder(
+      AccountResolver accountResolver,
+      PermissionBackend permissionBackend,
+      GroupResolver groupResolver,
+      GroupMembers groupMembers,
+      AccountLoader.Factory accountLoaderFactory,
+      @GerritServerConfig Config cfg,
+      ReviewerJson json,
+      ProjectCache projectCache,
+      Provider<AnonymousUser> anonymousProvider,
+      AddReviewersOp.Factory addReviewersOpFactory,
+      OutgoingEmailValidator validator) {
+    this.accountResolver = accountResolver;
+    this.permissionBackend = permissionBackend;
+    this.groupResolver = groupResolver;
+    this.groupMembers = groupMembers;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.cfg = cfg;
+    this.json = json;
+    this.projectCache = projectCache;
+    this.anonymousProvider = anonymousProvider;
+    this.addReviewersOpFactory = addReviewersOpFactory;
+    this.validator = validator;
+  }
+
+  /**
+   * Prepare application of a single {@link AddReviewerInput}.
+   *
+   * @param notes change notes.
+   * @param user user performing the reviewer addition.
+   * @param input input describing user or group to add as a reviewer.
+   * @param allowGroup whether to allow
+   * @return handle describing the addition operation. If the {@code op} field is present, this
+   *     operation may be added to a {@code BatchUpdate}. Otherwise, the {@code error} field
+   *     contains information about an error that occurred
+   * @throws IOException
+   * @throws PermissionBackendException
+   * @throws ConfigInvalidException
+   */
+  public ReviewerAddition prepare(
+      ChangeNotes notes, CurrentUser user, AddReviewerInput input, boolean allowGroup)
+      throws IOException, PermissionBackendException, ConfigInvalidException {
+    requireNonNull(input.reviewer);
+    boolean confirmed = input.confirmed();
+    boolean allowByEmail =
+        projectCache
+            .checkedGet(notes.getProjectName())
+            .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
+
+    ReviewerAddition byAccountId = addByAccountId(input, notes, user);
+
+    ReviewerAddition wholeGroup = null;
+    if (!byAccountId.exactMatchFound) {
+      wholeGroup = addWholeGroup(input, notes, user, confirmed, allowGroup, allowByEmail);
+      if (wholeGroup != null && wholeGroup.exactMatchFound) {
+        return wholeGroup;
+      }
+    }
+
+    if (wholeGroup != null
+        && byAccountId.failureType == FailureType.NOT_FOUND
+        && wholeGroup.failureType == FailureType.NOT_FOUND) {
+      return fail(
+          byAccountId.input,
+          FailureType.NOT_FOUND,
+          byAccountId.result.error + "\n" + wholeGroup.result.error);
+    }
+
+    if (byAccountId.failureType != FailureType.NOT_FOUND) {
+      return byAccountId;
+    }
+    if (wholeGroup != null) {
+      return wholeGroup;
+    }
+
+    return addByEmail(input, notes, user);
+  }
+
+  public ReviewerAddition ccCurrentUser(CurrentUser user, RevisionResource revision) {
+    return new ReviewerAddition(
+        newAddReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE),
+        revision.getNotes(),
+        revision.getUser(),
+        ImmutableSet.of(user.getAccountId()),
+        null,
+        true);
+  }
+
+  @Nullable
+  private ReviewerAddition addByAccountId(
+      AddReviewerInput input, ChangeNotes notes, CurrentUser user)
+      throws PermissionBackendException, IOException, ConfigInvalidException {
+    IdentifiedUser reviewerUser;
+    boolean exactMatchFound = false;
+    try {
+      reviewerUser = accountResolver.resolve(input.reviewer).asUniqueUser();
+      if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
+          || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
+        exactMatchFound = true;
+      }
+    } catch (UnprocessableEntityException e) {
+      // Caller might choose to ignore this NOT_FOUND result if they find another result e.g. by
+      // group, but if not, the error message will be useful.
+      return fail(input, FailureType.NOT_FOUND, e.getMessage());
+    }
+
+    if (isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) {
+      return new ReviewerAddition(
+          input, notes, user, ImmutableSet.of(reviewerUser.getAccountId()), null, exactMatchFound);
+    }
+    return fail(
+        input,
+        FailureType.OTHER,
+        MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
+  }
+
+  @Nullable
+  private ReviewerAddition addWholeGroup(
+      AddReviewerInput input,
+      ChangeNotes notes,
+      CurrentUser user,
+      boolean confirmed,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws IOException, PermissionBackendException {
+    if (!allowGroup) {
+      return null;
+    }
+
+    GroupDescription.Basic group;
+    try {
+      // TODO(dborowitz): This currently doesn't work in the push path because InternalGroupBackend
+      // depends on the Provider<CurrentUser> which returns anonymous in that path.
+      group = groupResolver.parseInternal(input.reviewer);
+    } catch (UnprocessableEntityException e) {
+      if (!allowByEmail) {
+        return fail(
+            input,
+            FailureType.NOT_FOUND,
+            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
+      }
+      return null;
+    }
+
+    if (!isLegalReviewerGroup(group.getGroupUUID())) {
+      return fail(
+          input,
+          FailureType.OTHER,
+          MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+    }
+
+    Set<Account.Id> reviewers = new HashSet<>();
+    Set<Account> members;
+    try {
+      members = groupMembers.listAccounts(group.getGroupUUID(), notes.getProjectName());
+    } catch (NoSuchProjectException e) {
+      return fail(input, FailureType.OTHER, e.getMessage());
+    }
+
+    // if maxAllowed is set to 0, it is allowed to add any number of
+    // reviewers
+    int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
+    if (maxAllowed > 0 && members.size() > maxAllowed) {
+      logger.atFine().log(
+          "Adding %d group members is not allowed (maxAllowed = %d)", members.size(), maxAllowed);
+      return fail(
+          input,
+          FailureType.OTHER,
+          MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
+    }
+
+    // if maxWithoutCheck is set to 0, we never ask for confirmation
+    int maxWithoutConfirmation =
+        cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+    if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
+      logger.atFine().log(
+          "Adding %d group members as reviewer requires confirmation (maxWithoutConfirmation = %d)",
+          members.size(), maxWithoutConfirmation);
+      return fail(
+          input,
+          FailureType.OTHER,
+          true,
+          MessageFormat.format(
+              ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
+    }
+
+    for (Account member : members) {
+      if (isValidReviewer(notes.getChange().getDest(), member)) {
+        reviewers.add(member.id());
+      }
+    }
+
+    return new ReviewerAddition(input, notes, user, reviewers, null, true);
+  }
+
+  @Nullable
+  private ReviewerAddition addByEmail(AddReviewerInput input, ChangeNotes notes, CurrentUser user)
+      throws PermissionBackendException {
+    try {
+      permissionBackend.user(anonymousProvider.get()).change(notes).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      return fail(
+          input,
+          FailureType.OTHER,
+          MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
+    }
+
+    Address adr = Address.tryParse(input.reviewer);
+    if (adr == null || !validator.isValid(adr.getEmail())) {
+      return fail(
+          input,
+          FailureType.NOT_FOUND,
+          MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
+    }
+    return new ReviewerAddition(input, notes, user, null, ImmutableList.of(adr), true);
+  }
+
+  private boolean isValidReviewer(BranchNameKey branch, Account member)
+      throws PermissionBackendException {
+    try {
+      // Check ref permission instead of change permission, since change permissions take into
+      // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
+      // see private changes.
+      permissionBackend.absentUser(member.id()).ref(branch).check(RefPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+
+  private ReviewerAddition fail(AddReviewerInput input, FailureType failureType, String error) {
+    return fail(input, failureType, false, error);
+  }
+
+  private ReviewerAddition fail(
+      AddReviewerInput input, FailureType failureType, boolean confirm, String error) {
+    ReviewerAddition addition = new ReviewerAddition(input, failureType);
+    addition.result.confirm = confirm ? true : null;
+    addition.result.error = error;
+    return addition;
+  }
+
+  public class ReviewerAddition {
+    public final AddReviewerResult result;
+    @Nullable public final AddReviewersOp op;
+    public final ImmutableSet<Account.Id> reviewers;
+    public final ImmutableSet<Address> reviewersByEmail;
+    @Nullable final IdentifiedUser caller;
+    final boolean exactMatchFound;
+    private final AddReviewerInput input;
+    @Nullable private final FailureType failureType;
+
+    private ReviewerAddition(AddReviewerInput input, FailureType failureType) {
+      this.input = input;
+      this.failureType = requireNonNull(failureType);
+      result = new AddReviewerResult(input.reviewer);
+      op = null;
+      reviewers = ImmutableSet.of();
+      reviewersByEmail = ImmutableSet.of();
+      caller = null;
+      exactMatchFound = false;
+    }
+
+    private ReviewerAddition(
+        AddReviewerInput input,
+        ChangeNotes notes,
+        CurrentUser caller,
+        @Nullable Iterable<Account.Id> reviewers,
+        @Nullable Iterable<Address> reviewersByEmail,
+        boolean exactMatchFound) {
+      checkArgument(
+          reviewers != null || reviewersByEmail != null,
+          "must have either reviewers or reviewersByEmail");
+
+      this.input = input;
+      this.failureType = null;
+      result = new AddReviewerResult(input.reviewer);
+      // Always silently ignore adding the owner as any type of reviewer on their own change. They
+      // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
+      this.reviewers = omitOwner(notes, reviewers);
+      this.reviewersByEmail =
+          reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
+      this.caller = caller.asIdentifiedUser();
+      op = addReviewersOpFactory.create(this.reviewers, this.reviewersByEmail, state());
+      this.exactMatchFound = exactMatchFound;
+    }
+
+    private ImmutableSet<Account.Id> omitOwner(ChangeNotes notes, Iterable<Account.Id> reviewers) {
+      return reviewers != null
+          ? Streams.stream(reviewers)
+              .filter(id -> !id.equals(notes.getChange().getOwner()))
+              .collect(toImmutableSet())
+          : ImmutableSet.of();
+    }
+
+    public void gatherResults(ChangeData cd) throws PermissionBackendException {
+      checkState(op != null, "addition did not result in an update op");
+      checkState(op.getResult() != null, "op did not return a result");
+
+      // Generate result details and fill AccountLoader. This occurs outside
+      // the Op because the accounts are in a different table.
+      AddReviewersOp.Result opResult = op.getResult();
+      if (state() == CC) {
+        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
+        for (Account.Id accountId : opResult.addedCCs()) {
+          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
+        }
+        accountLoaderFactory.create(true).fill(result.ccs);
+        for (Address a : opResult.addedCCsByEmail()) {
+          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
+        }
+      } else {
+        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
+        for (PatchSetApproval psa : opResult.addedReviewers()) {
+          // New reviewers have value 0, don't bother normalizing.
+          result.reviewers.add(
+              json.format(
+                  new ReviewerInfo(psa.accountId().get()),
+                  psa.accountId(),
+                  cd,
+                  ImmutableList.of(psa)));
+        }
+        accountLoaderFactory.create(true).fill(result.reviewers);
+        for (Address a : opResult.addedReviewersByEmail()) {
+          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
+        }
+      }
+    }
+
+    public ReviewerState state() {
+      return input.state();
+    }
+
+    public boolean isFailure() {
+      return failureType != null;
+    }
+
+    public boolean isIgnorableFailure() {
+      checkState(failureType != null);
+      FailureBehavior behavior =
+          (input instanceof InternalAddReviewerInput)
+              ? ((InternalAddReviewerInput) input).otherFailureBehavior
+              : FailureBehavior.FAIL;
+      return failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE;
+    }
+  }
+
+  public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
+    return !SystemGroupBackend.isSystemGroup(groupUUID);
+  }
+
+  public ReviewerAdditionList prepare(
+      ChangeNotes notes,
+      CurrentUser user,
+      Iterable<? extends AddReviewerInput> inputs,
+      boolean allowGroup)
+      throws IOException, PermissionBackendException, ConfigInvalidException {
+    // Process CC ops before reviewer ops, so a user that appears in both lists ends up as a
+    // reviewer; the last call to ChangeUpdate#putReviewer wins. This can happen if the caller
+    // specifies the same string twice, or less obviously if they specify multiple groups with
+    // overlapping members.
+    // TODO(dborowitz): Consider changing interface to allow excluding reviewers that were
+    // previously processed, to proactively prevent overlap so we don't have to rely on this subtle
+    // behavior.
+    ImmutableList<AddReviewerInput> sorted =
+        Streams.stream(inputs)
+            .sorted(
+                comparing(
+                    AddReviewerInput::state,
+                    Ordering.explicit(ReviewerState.CC, ReviewerState.REVIEWER)))
+            .collect(toImmutableList());
+    List<ReviewerAddition> additions = new ArrayList<>();
+    for (AddReviewerInput input : sorted) {
+      ReviewerAddition addition = prepare(notes, user, input, allowGroup);
+      if (addition.op != null) {
+        // Assume any callers preparing a list of batch insertions are handling their own email.
+        addition.op.suppressEmail();
+      }
+      additions.add(addition);
+    }
+    return new ReviewerAdditionList(additions);
+  }
+
+  // TODO(dborowitz): This class works, but ultimately feels wrong. It seems like an op but isn't
+  // really an op, it's a collection of ops, and it's only called from the body of other ops. We
+  // could make this class an op, but we would still have AddReviewersOp. Better would probably be
+  // to design a single op that supports combining multiple AddReviewerInputs together. That would
+  // probably also subsume the Addition class itself, which would be a good thing.
+  public static class ReviewerAdditionList {
+    private final ImmutableList<ReviewerAddition> additions;
+
+    private ReviewerAdditionList(List<ReviewerAddition> additions) {
+      this.additions = ImmutableList.copyOf(additions);
+    }
+
+    public ImmutableList<ReviewerAddition> getFailures() {
+      return additions.stream()
+          .filter(a -> a.isFailure() && !a.isIgnorableFailure())
+          .collect(toImmutableList());
+    }
+
+    // We never call updateRepo on the addition ops, which is only ok because it's a no-op.
+
+    public void updateChange(ChangeContext ctx, PatchSet patchSet)
+        throws RestApiException, IOException {
+      for (ReviewerAddition addition : additions()) {
+        addition.op.setPatchSet(patchSet);
+        addition.op.updateChange(ctx);
+      }
+    }
+
+    public void postUpdate(Context ctx) throws Exception {
+      for (ReviewerAddition addition : additions()) {
+        if (addition.op != null) {
+          addition.op.postUpdate(ctx);
+        }
+      }
+    }
+
+    public <T> ImmutableSet<T> flattenResults(
+        Function<AddReviewersOp.Result, ? extends Collection<T>> func) {
+      additions()
+          .forEach(
+              a ->
+                  checkArgument(
+                      a.op != null && a.op.getResult() != null, "missing result on %s", a));
+      return additions().stream()
+          .map(a -> a.op.getResult())
+          .map(func)
+          .flatMap(Collection::stream)
+          .collect(toImmutableSet());
+    }
+
+    private ImmutableList<ReviewerAddition> additions() {
+      return additions.stream()
+          .filter(
+              a -> {
+                if (a.isFailure()) {
+                  if (a.isIgnorableFailure()) {
+                    return false;
+                  }
+                  // Shouldn't happen, caller should have checked that there were no errors.
+                  throw new IllegalStateException("error in addition: " + a.result.error);
+                }
+                return true;
+              })
+          .collect(toImmutableList());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
new file mode 100644
index 0000000..93582f9
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.gerrit.common.data.LabelValue.formatValue;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.List;
+import java.util.TreeMap;
+
+@Singleton
+public class ReviewerJson {
+  private final PermissionBackend permissionBackend;
+  private final ChangeData.Factory changeDataFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final SubmitRuleEvaluator submitRuleEvaluator;
+
+  @Inject
+  ReviewerJson(
+      PermissionBackend permissionBackend,
+      ChangeData.Factory changeDataFactory,
+      ApprovalsUtil approvalsUtil,
+      AccountLoader.Factory accountLoaderFactory,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+    this.permissionBackend = permissionBackend;
+    this.changeDataFactory = changeDataFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.accountLoaderFactory = accountLoaderFactory;
+    submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
+  }
+
+  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
+      throws PermissionBackendException {
+    List<ReviewerInfo> infos = Lists.newArrayListWithCapacity(rsrcs.size());
+    AccountLoader loader = accountLoaderFactory.create(true);
+    ChangeData cd = null;
+    for (ReviewerResource rsrc : rsrcs) {
+      if (cd == null || !cd.getId().equals(rsrc.getChangeId())) {
+        cd = changeDataFactory.create(rsrc.getChangeResource().getNotes());
+      }
+      ReviewerInfo info;
+      if (rsrc.isByEmail()) {
+        Address address = rsrc.getReviewerByEmail();
+        info = ReviewerInfo.byEmail(address.getName(), address.getEmail());
+      } else {
+        Account.Id reviewerAccountId = rsrc.getReviewerUser().getAccountId();
+        info = format(new ReviewerInfo(reviewerAccountId.get()), reviewerAccountId, cd);
+        loader.put(info);
+      }
+      infos.add(info);
+    }
+    loader.fill();
+    return infos;
+  }
+
+  public List<ReviewerInfo> format(ReviewerResource rsrc) throws PermissionBackendException {
+    return format(ImmutableList.of(rsrc));
+  }
+
+  public ReviewerInfo format(ReviewerInfo out, Account.Id reviewerAccountId, ChangeData cd)
+      throws PermissionBackendException {
+    PatchSet.Id psId = cd.change().currentPatchSetId();
+    return format(
+        out,
+        reviewerAccountId,
+        cd,
+        approvalsUtil.byPatchSetUser(cd.notes(), psId, reviewerAccountId, null, null));
+  }
+
+  public ReviewerInfo format(
+      ReviewerInfo out,
+      Account.Id reviewerAccountId,
+      ChangeData cd,
+      Iterable<PatchSetApproval> approvals)
+      throws PermissionBackendException {
+    LabelTypes labelTypes = cd.getLabelTypes();
+
+    out.approvals = new TreeMap<>(labelTypes.nameComparator());
+    for (PatchSetApproval ca : approvals) {
+      LabelType at = labelTypes.byLabel(ca.labelId());
+      if (at != null) {
+        out.approvals.put(at.getName(), formatValue(ca.value()));
+      }
+    }
+
+    // Add dummy approvals for all permitted labels for the user even if they
+    // do not exist in the DB.
+    PatchSet ps = cd.currentPatchSet();
+    if (ps != null) {
+      PermissionBackend.ForChange perm = permissionBackend.absentUser(reviewerAccountId).change(cd);
+
+      for (SubmitRecord rec : submitRuleEvaluator.evaluate(cd)) {
+        if (rec.labels == null) {
+          continue;
+        }
+        for (SubmitRecord.Label label : rec.labels) {
+          String name = label.label;
+          LabelType type = labelTypes.byLabel(name);
+          if (out.approvals.containsKey(name) || type == null) {
+            continue;
+          }
+
+          try {
+            perm.check(new LabelPermission(type));
+            out.approvals.put(name, formatValue((short) 0));
+          } catch (AuthException e) {
+            // Do nothing.
+          }
+        }
+      }
+    }
+
+    if (out.approvals.isEmpty()) {
+      out.approvals = null;
+    }
+
+    return out;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ReviewerResource.java b/java/com/google/gerrit/server/change/ReviewerResource.java
index 778897e..52f3585 100644
--- a/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -19,10 +19,10 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.Address;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
new file mode 100644
index 0000000..f30b321
--- /dev/null
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -0,0 +1,387 @@
+// 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.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_FILES;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DOWNLOAD_COMMANDS;
+import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
+import static com.google.gerrit.server.CommonConverters.toGitPerson;
+
+import com.google.common.collect.ImmutableList;
+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.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FetchInfo;
+import com.google.gerrit.extensions.common.PushCertificateInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.config.DownloadCommand;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.GpgApiAdapter;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Produces {@link RevisionInfo} and {@link CommitInfo} which are serialized to JSON afterwards. */
+public class RevisionJson {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    RevisionJson create(Iterable<ListChangesOption> options);
+  }
+
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final FileInfoJson fileInfoJson;
+  private final GpgApiAdapter gpgApi;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeKindCache changeKindCache;
+  private final ActionJson actionJson;
+  private final DynamicMap<DownloadScheme> downloadSchemes;
+  private final DynamicMap<DownloadCommand> downloadCommands;
+  private final WebLinks webLinks;
+  private final Provider<CurrentUser> userProvider;
+  private final ProjectCache projectCache;
+  private final ImmutableSet<ListChangesOption> options;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final AnonymousUser anonymous;
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final ChangeNotes.Factory notesFactory;
+  private final boolean lazyLoad;
+
+  @Inject
+  RevisionJson(
+      Provider<CurrentUser> userProvider,
+      AnonymousUser anonymous,
+      ProjectCache projectCache,
+      IdentifiedUser.GenericFactory userFactory,
+      MergeUtil.Factory mergeUtilFactory,
+      FileInfoJson fileInfoJson,
+      AccountLoader.Factory accountLoaderFactory,
+      DynamicMap<DownloadScheme> downloadSchemes,
+      DynamicMap<DownloadCommand> downloadCommands,
+      WebLinks webLinks,
+      ActionJson actionJson,
+      GpgApiAdapter gpgApi,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeKindCache changeKindCache,
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      ChangeNotes.Factory notesFactory,
+      @Assisted Iterable<ListChangesOption> options) {
+    this.userProvider = userProvider;
+    this.anonymous = anonymous;
+    this.projectCache = projectCache;
+    this.userFactory = userFactory;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.fileInfoJson = fileInfoJson;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.downloadSchemes = downloadSchemes;
+    this.downloadCommands = downloadCommands;
+    this.webLinks = webLinks;
+    this.actionJson = actionJson;
+    this.gpgApi = gpgApi;
+    this.changeResourceFactory = changeResourceFactory;
+    this.changeKindCache = changeKindCache;
+    this.permissionBackend = permissionBackend;
+    this.notesFactory = notesFactory;
+    this.repoManager = repoManager;
+    this.options = ImmutableSet.copyOf(options);
+    this.lazyLoad = containsAnyOf(this.options, ChangeJson.REQUIRE_LAZY_LOAD);
+  }
+
+  /**
+   * Returns a {@link RevisionInfo} based on a change and patch set. Reads from the repository
+   * depending on the options provided when constructing this instance.
+   */
+  public RevisionInfo getRevisionInfo(ChangeData cd, PatchSet in)
+      throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
+    AccountLoader accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+    try (Repository repo = openRepoIfNecessary(cd.project());
+        RevWalk rw = newRevWalk(repo)) {
+      RevisionInfo rev = toRevisionInfo(accountLoader, cd, in, repo, rw, true, null);
+      accountLoader.fill();
+      return rev;
+    }
+  }
+
+  /**
+   * Returns a {@link CommitInfo} based on a commit and formatting options. Uses the provided
+   * RevWalk and assumes it is backed by an open repository.
+   */
+  public CommitInfo getCommitInfo(
+      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
+      throws IOException {
+    CommitInfo info = new CommitInfo();
+    if (fillCommit) {
+      info.commit = commit.name();
+    }
+    info.parents = new ArrayList<>(commit.getParentCount());
+    info.author = toGitPerson(commit.getAuthorIdent());
+    info.committer = toGitPerson(commit.getCommitterIdent());
+    info.subject = commit.getShortMessage();
+    info.message = commit.getFullMessage();
+
+    if (addLinks) {
+      ImmutableList<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      info.webLinks = links.isEmpty() ? null : links;
+    }
+
+    for (RevCommit parent : commit.getParents()) {
+      rw.parseBody(parent);
+      CommitInfo i = new CommitInfo();
+      i.commit = parent.name();
+      i.subject = parent.getShortMessage();
+      if (addLinks) {
+        ImmutableList<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
+      }
+      info.parents.add(i);
+    }
+    return info;
+  }
+
+  /**
+   * Returns multiple {@link RevisionInfo}s for a single change. Uses the provided {@link
+   * AccountLoader} to lazily populate accounts. Callers have to call {@link AccountLoader#fill()}
+   * afterwards to populate all accounts in the returned {@link RevisionInfo}s.
+   */
+  Map<String, RevisionInfo> getRevisions(
+      AccountLoader accountLoader,
+      ChangeData cd,
+      Map<PatchSet.Id, PatchSet> map,
+      Optional<PatchSet.Id> limitToPsId,
+      ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
+    Map<String, RevisionInfo> res = new LinkedHashMap<>();
+    try (Repository repo = openRepoIfNecessary(cd.project());
+        RevWalk rw = newRevWalk(repo)) {
+      for (PatchSet in : map.values()) {
+        PatchSet.Id id = in.id();
+        boolean want;
+        if (has(ALL_REVISIONS)) {
+          want = true;
+        } else if (limitToPsId.isPresent()) {
+          want = id.equals(limitToPsId.get());
+        } else {
+          want = id.equals(cd.change().currentPatchSetId());
+        }
+        if (want) {
+          res.put(
+              in.commitId().name(),
+              toRevisionInfo(accountLoader, cd, in, repo, rw, false, changeInfo));
+        }
+      }
+      return res;
+    }
+  }
+
+  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in)
+      throws PermissionBackendException, IOException {
+    Map<String, FetchInfo> r = new LinkedHashMap<>();
+    for (Extension<DownloadScheme> e : downloadSchemes) {
+      String schemeName = e.getExportName();
+      DownloadScheme scheme = e.getProvider().get();
+      if (!scheme.isEnabled()
+          || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
+        continue;
+      }
+      if (!scheme.isAuthSupported() && !isWorldReadable(cd)) {
+        continue;
+      }
+
+      String projectName = cd.project().get();
+      String url = scheme.getUrl(projectName);
+      String refName = in.refName();
+      FetchInfo fetchInfo = new FetchInfo(url, refName);
+      r.put(schemeName, fetchInfo);
+
+      if (has(DOWNLOAD_COMMANDS)) {
+        DownloadCommandsJson.populateFetchMap(
+            scheme, downloadCommands, projectName, refName, fetchInfo);
+      }
+    }
+
+    return r;
+  }
+
+  private RevisionInfo toRevisionInfo(
+      AccountLoader accountLoader,
+      ChangeData cd,
+      PatchSet in,
+      @Nullable Repository repo,
+      @Nullable RevWalk rw,
+      boolean fillCommit,
+      @Nullable ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
+    Change c = cd.change();
+    RevisionInfo out = new RevisionInfo();
+    out.isCurrent = in.id().equals(c.currentPatchSetId());
+    out._number = in.id().get();
+    out.ref = in.refName();
+    out.created = in.createdOn();
+    out.uploader = accountLoader.get(in.uploader());
+    out.fetch = makeFetchMap(cd, in);
+    out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
+    out.description = in.description().orElse(null);
+
+    boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
+    boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
+    if (setCommit || addFooters) {
+      checkState(rw != null);
+      checkState(repo != null);
+      Project.NameKey project = c.getProject();
+      String rev = in.commitId().name();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+      if (setCommit) {
+        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit);
+      }
+      if (addFooters) {
+        Ref ref = repo.exactRef(cd.change().getDest().branch());
+        RevCommit mergeTip = null;
+        if (ref != null) {
+          mergeTip = rw.parseCommit(ref.getObjectId());
+          rw.parseBody(mergeTip);
+        }
+        out.commitWithFooters =
+            mergeUtilFactory
+                .create(projectCache.get(project))
+                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.id());
+      }
+    }
+
+    if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
+      out.files = fileInfoJson.toFileInfoMap(c, in);
+      out.files.remove(Patch.COMMIT_MSG);
+      out.files.remove(Patch.MERGE_LIST);
+    }
+
+    if (out.isCurrent && has(CURRENT_ACTIONS) && userProvider.get().isIdentifiedUser()) {
+      actionJson.addRevisionActions(
+          changeInfo,
+          out,
+          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
+    }
+
+    if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
+      if (in.pushCertificate().isPresent()) {
+        out.pushCertificate =
+            gpgApi.checkPushCertificate(
+                in.pushCertificate().get(), userFactory.create(in.uploader()));
+      } else {
+        out.pushCertificate = new PushCertificateInfo();
+      }
+    }
+
+    return out;
+  }
+
+  private boolean has(ListChangesOption option) {
+    return options.contains(option);
+  }
+
+  /**
+   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
+   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
+   *     lazyload}.
+   */
+  private PermissionBackend.ForChange permissionBackendForChange(
+      PermissionBackend.WithUser withUser, ChangeData cd) {
+    return lazyLoad
+        ? withUser.change(cd)
+        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
+  }
+
+  private boolean isWorldReadable(ChangeData cd) throws PermissionBackendException, IOException {
+    try {
+      permissionBackendForChange(permissionBackend.user(anonymous), cd)
+          .check(ChangePermission.READ);
+    } catch (AuthException ae) {
+      return false;
+    }
+    ProjectState projectState = projectCache.checkedGet(cd.project());
+    if (projectState == null) {
+      logger.atSevere().log("project state for project %s is null", cd.project());
+      return false;
+    }
+    return projectState.statePermitsRead();
+  }
+
+  @Nullable
+  private Repository openRepoIfNecessary(Project.NameKey project) throws IOException {
+    if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
+      return repoManager.openRepository(project);
+    }
+    return null;
+  }
+
+  @Nullable
+  private RevWalk newRevWalk(@Nullable Repository repo) {
+    return repo != null ? new RevWalk(repo) : null;
+  }
+
+  private static boolean containsAnyOf(
+      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
+    return !Sets.intersection(toFind, set).isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index deb5022..efd9d2d 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -34,7 +34,7 @@
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
       new TypeLiteral<RestView<RevisionResource>>() {};
 
-  public static RevisionResource createNonCachable(ChangeResource change, PatchSet ps) {
+  public static RevisionResource createNonCacheable(ChangeResource change, PatchSet ps) {
     return new RevisionResource(change, ps, Optional.empty(), false);
   }
 
@@ -52,11 +52,11 @@
   }
 
   private RevisionResource(
-      ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit, boolean cachable) {
+      ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit, boolean cacheable) {
     this.change = change;
     this.ps = ps;
     this.edit = edit;
-    this.cacheable = cachable;
+    this.cacheable = cacheable;
   }
 
   public boolean isCacheable() {
@@ -114,7 +114,7 @@
 
   @Override
   public String toString() {
-    String s = ps.getId().toString();
+    String s = ps.id().toString();
     if (edit.isPresent()) {
       s = "edit:" + s;
     }
@@ -122,6 +122,6 @@
   }
 
   public boolean isCurrent() {
-    return ps.getId().equals(getChange().currentPatchSetId());
+    return ps.id().equals(getChange().currentPatchSetId());
   }
 }
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index e2258c0..8d350c3 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
@@ -27,12 +26,12 @@
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -45,7 +44,7 @@
   }
 
   private final ChangeMessagesUtil cmUtil;
-  private final DynamicSet<AssigneeValidationListener> validationListeners;
+  private final PluginSetContext<AssigneeValidationListener> validationListeners;
   private final IdentifiedUser newAssignee;
   private final AssigneeChanged assigneeChanged;
   private final SetAssigneeSender.Factory setAssigneeSenderFactory;
@@ -58,7 +57,7 @@
   @Inject
   SetAssigneeOp(
       ChangeMessagesUtil cmUtil,
-      DynamicSet<AssigneeValidationListener> validationListeners,
+      PluginSetContext<AssigneeValidationListener> validationListeners,
       AssigneeChanged assigneeChanged,
       SetAssigneeSender.Factory setAssigneeSenderFactory,
       Provider<IdentifiedUser> user,
@@ -70,21 +69,20 @@
     this.setAssigneeSenderFactory = setAssigneeSenderFactory;
     this.user = user;
     this.userFactory = userFactory;
-    this.newAssignee = checkNotNull(newAssignee, "assignee");
+    this.newAssignee = requireNonNull(newAssignee, "assignee");
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx) throws OrmException, RestApiException {
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
     change = ctx.getChange();
     if (newAssignee.getAccountId().equals(change.getAssignee())) {
       return false;
     }
     try {
-      for (AssigneeValidationListener validator : validationListeners) {
-        validator.validateAssignee(change, newAssignee.getAccount());
-      }
+      validationListeners.runEach(
+          l -> l.validateAssignee(change, newAssignee.getAccount()), ValidationException.class);
     } catch (ValidationException e) {
-      throw new ResourceConflictException(e.getMessage());
+      throw new ResourceConflictException(e.getMessage(), e);
     }
 
     if (change.getAssignee() != null) {
@@ -100,7 +98,7 @@
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
     StringBuilder msg = new StringBuilder();
     msg.append("Assignee ");
     if (oldAssignee == null) {
@@ -114,11 +112,11 @@
     }
     ChangeMessage cmsg =
         ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
-    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+    cmUtil.addChangeMessage(update, cmsg);
   }
 
   @Override
-  public void postUpdate(Context ctx) throws OrmException {
+  public void postUpdate(Context ctx) {
     try {
       SetAssigneeSender cm =
           setAssigneeSenderFactory.create(
diff --git a/java/com/google/gerrit/server/change/SetHashtagsOp.java b/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 1f17dd3..abc4eee 100644
--- a/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -33,13 +32,12 @@
 import com.google.gerrit.server.extensions.events.HashtagsEdited;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -52,9 +50,8 @@
     SetHashtagsOp create(HashtagsInput input);
   }
 
-  private final NotesMigration notesMigration;
   private final ChangeMessagesUtil cmUtil;
-  private final DynamicSet<HashtagValidationListener> validationListeners;
+  private final PluginSetContext<HashtagValidationListener> validationListeners;
   private final HashtagsEdited hashtagsEdited;
   private final HashtagsInput input;
 
@@ -67,12 +64,10 @@
 
   @Inject
   SetHashtagsOp(
-      NotesMigration notesMigration,
       ChangeMessagesUtil cmUtil,
-      DynamicSet<HashtagValidationListener> validationListeners,
+      PluginSetContext<HashtagValidationListener> validationListeners,
       HashtagsEdited hashtagsEdited,
       @Assisted @Nullable HashtagsInput input) {
-    this.notesMigration = notesMigration;
     this.cmUtil = cmUtil;
     this.validationListeners = validationListeners;
     this.hashtagsEdited = hashtagsEdited;
@@ -86,11 +81,7 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws AuthException, BadRequestException, MethodNotAllowedException, OrmException,
-          IOException {
-    if (!notesMigration.readChanges()) {
-      throw new MethodNotAllowedException("Cannot add hashtags; NoteDb is disabled");
-    }
+      throws AuthException, BadRequestException, MethodNotAllowedException, IOException {
     if (input == null || (input.add == null && input.remove == null)) {
       updatedHashtags = ImmutableSortedSet.of();
       return false;
@@ -106,10 +97,8 @@
       toAdd = new HashSet<>(extractTags(input.add));
       toRemove = new HashSet<>(extractTags(input.remove));
 
-      for (HashtagValidationListener validator : validationListeners) {
-        validator.validateHashtags(update.getChange(), toAdd, toRemove);
-      }
-
+      validationListeners.runEach(
+          l -> l.validateHashtags(update.getChange(), toAdd, toRemove), ValidationException.class);
       updated.addAll(existingHashtags);
       toAdd.removeAll(existingHashtags);
       toRemove.retainAll(existingHashtags);
@@ -123,17 +112,17 @@
       updatedHashtags = ImmutableSortedSet.copyOf(updated);
       return true;
     } catch (ValidationException | InvalidHashtagException e) {
-      throw new BadRequestException(e.getMessage());
+      throw new BadRequestException(e.getMessage(), e);
     }
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
     StringBuilder msg = new StringBuilder();
     appendHashtagMessage(msg, "added", toAdd);
     appendHashtagMessage(msg, "removed", toRemove);
     ChangeMessage cmsg =
         ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_HASHTAGS);
-    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+    cmUtil.addChangeMessage(update, cmsg);
   }
 
   private void appendHashtagMessage(StringBuilder b, String action, Set<String> hashtags) {
@@ -155,7 +144,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) throws OrmException {
+  public void postUpdate(Context ctx) {
     if (updated() && fireEvent) {
       hashtagsEdited.fire(
           change, ctx.getAccount(), updatedHashtags, toAdd, toRemove, ctx.getWhen());
diff --git a/java/com/google/gerrit/server/change/SetPrivateOp.java b/java/com/google/gerrit/server/change/SetPrivateOp.java
new file mode 100644
index 0000000..1600fd5
--- /dev/null
+++ b/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -0,0 +1,125 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+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.ChangeUtil;
+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;
+import com.google.gerrit.server.update.Context;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SetPrivateOp implements BatchUpdateOp {
+  public static class Input {
+    String message;
+
+    public Input() {}
+
+    public Input(String message) {
+      this.message = message;
+    }
+  }
+
+  public interface Factory {
+    SetPrivateOp create(boolean isPrivate, @Nullable Input input);
+  }
+
+  private final PrivateStateChanged privateStateChanged;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final boolean isPrivate;
+  @Nullable private final Input input;
+
+  private Change change;
+  private PatchSet ps;
+  private boolean isNoOp;
+
+  @Inject
+  SetPrivateOp(
+      PrivateStateChanged privateStateChanged,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      @Assisted boolean isPrivate,
+      @Assisted @Nullable Input input) {
+    this.privateStateChanged = privateStateChanged;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.isPrivate = isPrivate;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, BadRequestException {
+    change = ctx.getChange();
+    if (ctx.getChange().isPrivate() == isPrivate) {
+      // No-op
+      isNoOp = true;
+      return false;
+    }
+
+    if (isPrivate && !change.isNew()) {
+      throw new BadRequestException(
+          String.format("cannot set %s change to private", ChangeUtil.status(change)));
+    }
+    ChangeNotes notes = ctx.getNotes();
+    ps = psUtil.get(notes, change.currentPatchSetId());
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setPrivate(isPrivate);
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setPrivate(isPrivate);
+    addMessage(ctx, update);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (!isNoOp) {
+      privateStateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+    }
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+    Change c = ctx.getChange();
+    StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
+
+    String m = Strings.nullToEmpty(input == null ? null : input.message).trim();
+    if (!m.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(m);
+    }
+
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(
+            ctx,
+            buf.toString(),
+            c.isPrivate()
+                ? ChangeMessagesUtil.TAG_SET_PRIVATE
+                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+    cmUtil.addChangeMessage(update, cmsg);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/TestSubmitInput.java b/java/com/google/gerrit/server/change/TestSubmitInput.java
index b681bf8..bb85e66 100644
--- a/java/com/google/gerrit/server/change/TestSubmitInput.java
+++ b/java/com/google/gerrit/server/change/TestSubmitInput.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.change;
 
 import com.google.common.annotations.VisibleForTesting;
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index cff1ac7..056312c 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -24,17 +24,16 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Ordering;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 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;
@@ -42,7 +41,6 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
@@ -74,7 +72,7 @@
                 }
                 try {
                   return in.get(0).data().change().getProject();
-                } catch (OrmException e) {
+                } catch (StorageException e) {
                   throw new IllegalStateException(e);
                 }
               });
@@ -99,7 +97,7 @@
     return this;
   }
 
-  public Iterable<PatchSetData> sort(Iterable<ChangeData> in) throws OrmException, IOException {
+  public Iterable<PatchSetData> sort(Iterable<ChangeData> in) throws IOException {
     ListMultimap<Project.NameKey, ChangeData> byProject =
         MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeData cd : in) {
@@ -110,12 +108,12 @@
     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);
   }
 
   private List<PatchSetData> sortProject(Project.NameKey project, Collection<ChangeData> in)
-      throws OrmException, IOException {
+      throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.setRetainBody(retainBody);
@@ -218,33 +216,32 @@
   }
 
   private ListMultimap<RevCommit, PatchSetData> byCommit(RevWalk rw, Collection<ChangeData> in)
-      throws OrmException, IOException {
+      throws IOException {
     ListMultimap<RevCommit, PatchSetData> byCommit =
         MultimapBuilder.hashKeys(in.size()).arrayListValues(1).build();
     for (ChangeData cd : in) {
       PatchSet maxPs = null;
       for (PatchSet ps : cd.patchSets()) {
-        if (shouldInclude(ps) && (maxPs == null || ps.getId().get() > maxPs.getId().get())) {
+        if (shouldInclude(ps) && (maxPs == null || ps.id().get() > maxPs.id().get())) {
           maxPs = ps;
         }
       }
       if (maxPs == null) {
         continue; // No patch sets matched.
       }
-      ObjectId id = ObjectId.fromString(maxPs.getRevision().get());
       try {
-        RevCommit c = rw.parseCommit(id);
+        RevCommit c = rw.parseCommit(maxPs.commitId());
         byCommit.put(c, PatchSetData.create(cd, maxPs, c));
       } catch (MissingObjectException | IncorrectObjectTypeException e) {
         logger.atWarning().withCause(e).log(
-            "missing commit %s for patch set %s", id.name(), maxPs.getId());
+            "missing commit %s for patch set %s", maxPs.commitId().name(), maxPs.id());
       }
     }
     return byCommit;
   }
 
   private boolean shouldInclude(PatchSet ps) {
-    return includePatchSets.isEmpty() || includePatchSets.contains(ps.getId());
+    return includePatchSets.isEmpty() || includePatchSets.contains(ps.id());
   }
 
   private static void markStart(RevWalk rw, Iterable<RevCommit> commits) throws IOException {
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 75a9323..f3f1a29 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.reviewdb.client.Change;
@@ -31,7 +29,6 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -58,9 +55,9 @@
   private final PatchSetUtil psUtil;
   private final boolean workInProgress;
   private final Input in;
-  private final NotifyHandling notify;
   private final WorkInProgressStateChanged stateChanged;
 
+  private boolean sendEmail = true;
   private Change change;
   private ChangeNotes notes;
   private PatchSet ps;
@@ -80,16 +77,17 @@
     this.stateChanged = stateChanged;
     this.workInProgress = workInProgress;
     this.in = in;
-    notify =
-        MoreObjects.firstNonNull(
-            in.notify, workInProgress ? NotifyHandling.NONE : NotifyHandling.ALL);
+  }
+
+  public void suppressEmail() {
+    this.sendEmail = false;
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx) throws OrmException {
+  public boolean updateChange(ChangeContext ctx) {
     change = ctx.getChange();
     notes = ctx.getNotes();
-    ps = psUtil.get(ctx.getDb(), ctx.getNotes(), change.currentPatchSetId());
+    ps = psUtil.get(ctx.getNotes(), change.currentPatchSetId());
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setWorkInProgress(workInProgress);
     if (!change.hasReviewStarted() && !workInProgress) {
@@ -101,7 +99,7 @@
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
     Change c = ctx.getChange();
     StringBuilder buf =
         new StringBuilder(c.isWorkInProgress() ? "Set Work In Progress" : "Set Ready For Review");
@@ -120,19 +118,21 @@
                 ? ChangeMessagesUtil.TAG_SET_WIP
                 : ChangeMessagesUtil.TAG_SET_READY);
 
-    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+    cmUtil.addChangeMessage(update, cmsg);
   }
 
   @Override
   public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
-    if (workInProgress || notify.ordinal() < NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
+    if (workInProgress
+        || notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) < 0
+        || !sendEmail) {
       return;
     }
     email
         .create(
             notify,
-            ImmutableListMultimap.of(),
             notes,
             ps,
             ctx.getIdentifiedUser(),
diff --git a/java/com/google/gerrit/server/config/AllProjectsName.java b/java/com/google/gerrit/server/config/AllProjectsName.java
index 198d5c5..06466c4 100644
--- a/java/com/google/gerrit/server/config/AllProjectsName.java
+++ b/java/com/google/gerrit/server/config/AllProjectsName.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 
 /** Special name of the project that all projects derive from. */
-@SuppressWarnings("serial")
 public class AllProjectsName extends Project.NameKey {
   public AllProjectsName(String name) {
     super(name);
diff --git a/java/com/google/gerrit/server/config/AllUsersName.java b/java/com/google/gerrit/server/config/AllUsersName.java
index ff28be4..1b5028a 100644
--- a/java/com/google/gerrit/server/config/AllUsersName.java
+++ b/java/com/google/gerrit/server/config/AllUsersName.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 
 /** Special name of the project in which meta data for all users is stored. */
-@SuppressWarnings("serial")
 public class AllUsersName extends Project.NameKey {
   public AllUsersName(String name) {
     super(name);
diff --git a/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
index d3f9186..de57d04 100644
--- a/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/java/com/google/gerrit/server/config/AuthConfig.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
-import com.google.gwtjsonrpc.server.SignedToken;
-import com.google.gwtjsonrpc.server.XsrfException;
+import com.google.gerrit.server.mail.SignedToken;
+import com.google.gerrit.server.mail.XsrfException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/config/CacheResource.java b/java/com/google/gerrit/server/config/CacheResource.java
index 16c7508..7a835b1 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;
@@ -32,15 +33,7 @@
   }
 
   public CacheResource(String pluginName, String cacheName, Cache<?, ?> cache) {
-    this(
-        pluginName,
-        cacheName,
-        new Provider<Cache<?, ?>>() {
-          @Override
-          public Cache<?, ?> get() {
-            return cache;
-          }
-        });
+    this(pluginName, cacheName, () -> cache);
   }
 
   public String getName() {
@@ -52,7 +45,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/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
index 961dbbd..4ab97f8 100644
--- a/java/com/google/gerrit/server/config/CapabilityConstants.java
+++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -34,6 +34,7 @@
   public String maintainServer;
   public String modifyAccount;
   public String priority;
+  public String readAs;
   public String queryLimit;
   public String runAs;
   public String runGC;
diff --git a/java/com/google/gerrit/server/config/ChangeCleanupConfig.java b/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
index 632293e..f5c9fc2 100644
--- a/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
+++ b/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,22 +31,23 @@
   private static String KEY_ABANDON_MESSAGE = "abandonMessage";
   private static String DEFAULT_ABANDON_MESSAGE =
       "Auto-Abandoned due to inactivity, see "
-          + "${URL}Documentation/user-change-cleanup.html#auto-abandon\n"
+          + "${URL}\n"
           + "\n"
           + "If this change is still wanted it should be restored.";
 
+  private final DynamicItem<UrlFormatter> urlFormatter;
   private final Optional<Schedule> schedule;
   private final long abandonAfter;
   private final boolean abandonIfMergeable;
   private final String abandonMessage;
 
   @Inject
-  ChangeCleanupConfig(
-      @GerritServerConfig Config cfg, @CanonicalWebUrl @Nullable String canonicalWebUrl) {
+  ChangeCleanupConfig(@GerritServerConfig Config cfg, DynamicItem<UrlFormatter> urlFormatter) {
+    this.urlFormatter = urlFormatter;
     schedule = ScheduleConfig.createSchedule(cfg, SECTION);
     abandonAfter = readAbandonAfter(cfg);
     abandonIfMergeable = cfg.getBoolean(SECTION, null, KEY_ABANDON_IF_MERGEABLE, true);
-    abandonMessage = readAbandonMessage(cfg, canonicalWebUrl);
+    abandonMessage = readAbandonMessage(cfg);
   }
 
   private long readAbandonAfter(Config cfg) {
@@ -55,15 +56,9 @@
     return abandonAfter >= 0 ? abandonAfter : 0;
   }
 
-  private String readAbandonMessage(Config cfg, String webUrl) {
+  private String readAbandonMessage(Config cfg) {
     String abandonMessage = cfg.getString(SECTION, null, KEY_ABANDON_MESSAGE);
-    if (Strings.isNullOrEmpty(abandonMessage)) {
-      abandonMessage = DEFAULT_ABANDON_MESSAGE;
-    }
-    if (!Strings.isNullOrEmpty(webUrl)) {
-      abandonMessage = abandonMessage.replaceAll("\\$\\{URL\\}", webUrl);
-    }
-    return abandonMessage;
+    return Strings.isNullOrEmpty(abandonMessage) ? DEFAULT_ABANDON_MESSAGE : abandonMessage;
   }
 
   public Optional<Schedule> getSchedule() {
@@ -79,6 +74,8 @@
   }
 
   public String getAbandonMessage() {
-    return abandonMessage;
+    String docUrl =
+        urlFormatter.get().getDocUrl("user-change-cleanup.html", "auto-abandon").orElse("");
+    return docUrl.isEmpty() ? abandonMessage : abandonMessage.replace("${URL}", docUrl);
   }
 }
diff --git a/java/com/google/gerrit/server/config/ConfigKey.java b/java/com/google/gerrit/server/config/ConfigKey.java
index aa4ffb0..5cd2054 100644
--- a/java/com/google/gerrit/server/config/ConfigKey.java
+++ b/java/com/google/gerrit/server/config/ConfigKey.java
@@ -34,7 +34,7 @@
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     StringBuilder sb = new StringBuilder();
     sb.append(section()).append(".");
     if (subsection() != null) {
diff --git a/java/com/google/gerrit/server/config/ConfigResource.java b/java/com/google/gerrit/server/config/ConfigResource.java
index ec0e0c2..f2b7c8e 100644
--- a/java/com/google/gerrit/server/config/ConfigResource.java
+++ b/java/com/google/gerrit/server/config/ConfigResource.java
@@ -14,11 +14,22 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
+import java.util.concurrent.TimeUnit;
 
 public class ConfigResource implements RestResource {
   public static final TypeLiteral<RestView<ConfigResource>> CONFIG_KIND =
       new TypeLiteral<RestView<ConfigResource>>() {};
+
+  /**
+   * Default cache control that gets set on the 'Cache-Control' header for responses on this
+   * resource that are cacheable.
+   *
+   * <p>Not all resources are cacheable and in fact the vast majority might not be. Caching is a
+   * trade-off between the freshness of data and the number of QPS that the web UI sends.
+   */
+  public static CacheControl DEFAULT_CACHE_CONTROL = CacheControl.PRIVATE(300, TimeUnit.SECONDS);
 }
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
index 9bd4533..b37e489 100644
--- a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
+++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -13,10 +13,11 @@
 // limitations under the License.
 package com.google.gerrit.server.config;
 
-import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Multimap;
 import java.util.Collections;
 import java.util.LinkedHashSet;
-import java.util.List;
 import java.util.Objects;
 import java.util.Set;
 import org.apache.commons.lang.StringUtils;
@@ -36,6 +37,8 @@
  * (+ various overloaded versions of these)
  */
 public class ConfigUpdatedEvent {
+  public static final ImmutableMultimap<UpdateResult, ConfigUpdateEntry> NO_UPDATES =
+      new ImmutableMultimap.Builder<UpdateResult, ConfigUpdateEntry>().build();
   private final Config oldConfig;
   private final Config newConfig;
 
@@ -52,21 +55,29 @@
     return this.newConfig;
   }
 
-  public Update accept(ConfigKey entry) {
+  private String getString(ConfigKey key, Config config) {
+    return config.getString(key.section(), key.subsection(), key.name());
+  }
+
+  public Multimap<UpdateResult, ConfigUpdateEntry> accept(ConfigKey entry) {
     return accept(Collections.singleton(entry));
   }
 
-  public Update accept(Set<ConfigKey> entries) {
+  public Multimap<UpdateResult, ConfigUpdateEntry> accept(Set<ConfigKey> entries) {
     return createUpdate(entries, UpdateResult.APPLIED);
   }
 
-  public Update accept(String section) {
+  public Multimap<UpdateResult, ConfigUpdateEntry> accept(String section) {
     Set<ConfigKey> entries = getEntriesFromSection(oldConfig, section);
     entries.addAll(getEntriesFromSection(newConfig, section));
     return createUpdate(entries, UpdateResult.APPLIED);
   }
 
-  public Update reject(Set<ConfigKey> entries) {
+  public Multimap<UpdateResult, ConfigUpdateEntry> reject(ConfigKey entry) {
+    return reject(Collections.singleton(entry));
+  }
+
+  public Multimap<UpdateResult, ConfigUpdateEntry> reject(Set<ConfigKey> entries) {
     return createUpdate(entries, UpdateResult.REJECTED);
   }
 
@@ -83,20 +94,14 @@
     return res;
   }
 
-  private Update createUpdate(Set<ConfigKey> entries, UpdateResult updateResult) {
-    Update update = new Update(updateResult);
-    entries
-        .stream()
+  private Multimap<UpdateResult, ConfigUpdateEntry> createUpdate(
+      Set<ConfigKey> entries, UpdateResult updateResult) {
+    Multimap<UpdateResult, ConfigUpdateEntry> updates = ArrayListMultimap.create();
+    entries.stream()
         .filter(this::isValueUpdated)
-        .forEach(
-            key -> {
-              update.addConfigUpdate(
-                  new ConfigUpdateEntry(
-                      key,
-                      oldConfig.getString(key.section(), key.subsection(), key.name()),
-                      newConfig.getString(key.section(), key.subsection(), key.name())));
-            });
-    return update;
+        .map(e -> new ConfigUpdateEntry(e, getString(e, oldConfig), getString(e, newConfig)))
+        .forEach(e -> updates.put(updateResult, e));
+    return updates;
   }
 
   public boolean isSectionUpdated(String section) {
@@ -138,31 +143,6 @@
     }
   }
 
-  /**
-   * One Accepted/Rejected Update have one or more config updates (ConfigUpdateEntry) tied to it.
-   */
-  public static class Update {
-    private UpdateResult result;
-    private final Set<ConfigUpdateEntry> configUpdates;
-
-    public Update(UpdateResult result) {
-      this.configUpdates = new LinkedHashSet<>();
-      this.result = result;
-    }
-
-    public UpdateResult getResult() {
-      return result;
-    }
-
-    public List<ConfigUpdateEntry> getConfigUpdates() {
-      return ImmutableList.copyOf(configUpdates);
-    }
-
-    public void addConfigUpdate(ConfigUpdateEntry entry) {
-      this.configUpdates.add(entry);
-    }
-  }
-
   public enum ConfigEntryType {
     ADDED,
     REMOVED,
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index c6527fd..f476adf 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.base.Preconditions;
+import static java.util.Objects.requireNonNull;
+
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
@@ -289,7 +290,7 @@
         Object c = f.get(s);
         Object d = f.get(defaults);
         if (!isString(t) && !isCollectionOrMap(t)) {
-          Preconditions.checkNotNull(d, "Default cannot be null for: " + n);
+          requireNonNull(d, "Default cannot be null for: " + n);
         }
         if (c == null || c.equals(d)) {
           cfg.unset(section, sub, n);
@@ -347,7 +348,7 @@
         f.setAccessible(true);
         Object d = f.get(defaults);
         if (!isString(t) && !isCollectionOrMap(t)) {
-          Preconditions.checkNotNull(d, "Default cannot be null for: " + n);
+          requireNonNull(d, "Default cannot be null for: " + n);
         }
         if (isString(t)) {
           String v = cfg.getString(section, sub, n);
diff --git a/java/com/google/gerrit/server/config/DefaultUrlFormatter.java b/java/com/google/gerrit/server/config/DefaultUrlFormatter.java
new file mode 100644
index 0000000..060ee3f
--- /dev/null
+++ b/java/com/google/gerrit/server/config/DefaultUrlFormatter.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+@Singleton
+public class DefaultUrlFormatter implements UrlFormatter {
+  private final Provider<String> canonicalWebUrlProvider;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicItem.itemOf(binder(), UrlFormatter.class);
+      DynamicItem.bind(binder(), UrlFormatter.class).to(DefaultUrlFormatter.class);
+    }
+  }
+
+  @Inject
+  public DefaultUrlFormatter(@CanonicalWebUrl Provider<String> canonicalWebUrlProvider) {
+    this.canonicalWebUrlProvider = canonicalWebUrlProvider;
+  }
+
+  @Override
+  public Optional<String> getWebUrl() {
+    return Optional.ofNullable(canonicalWebUrlProvider.get());
+  }
+}
diff --git a/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java b/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java
deleted file mode 100644
index 336edeb..0000000
--- a/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java
+++ /dev/null
@@ -1,24 +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.config;
-
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
-
-@Retention(RUNTIME)
-@BindingAnnotation
-public @interface DisableReverseDnsLookup {}
diff --git a/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java b/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java
deleted file mode 100644
index 87d6bac..0000000
--- a/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import org.eclipse.jgit.lib.Config;
-
-public class DisableReverseDnsLookupProvider implements Provider<Boolean> {
-  private final boolean disableReverseDnsLookup;
-
-  @Inject
-  DisableReverseDnsLookupProvider(@GerritServerConfig Config config) {
-    disableReverseDnsLookup = config.getBoolean("gerrit", null, "disableReverseDnsLookup", false);
-  }
-
-  @Override
-  public Boolean get() {
-    return disableReverseDnsLookup;
-  }
-}
diff --git a/java/com/google/gerrit/server/config/EnableReverseDnsLookup.java b/java/com/google/gerrit/server/config/EnableReverseDnsLookup.java
new file mode 100644
index 0000000..ec57338
--- /dev/null
+++ b/java/com/google/gerrit/server/config/EnableReverseDnsLookup.java
@@ -0,0 +1,24 @@
+// 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.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface EnableReverseDnsLookup {}
diff --git a/java/com/google/gerrit/server/config/EnableReverseDnsLookupProvider.java b/java/com/google/gerrit/server/config/EnableReverseDnsLookupProvider.java
new file mode 100644
index 0000000..71086a9
--- /dev/null
+++ b/java/com/google/gerrit/server/config/EnableReverseDnsLookupProvider.java
@@ -0,0 +1,33 @@
+// 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.config;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+
+public class EnableReverseDnsLookupProvider implements Provider<Boolean> {
+  private final Boolean enableReverseDnsLookup;
+
+  @Inject
+  EnableReverseDnsLookupProvider(@GerritServerConfig Config config) {
+    enableReverseDnsLookup = config.getBoolean("gerrit", null, "enableReverseDnsLookup", false);
+  }
+
+  @Override
+  public Boolean get() {
+    return enableReverseDnsLookup;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritConfigListener.java b/java/com/google/gerrit/server/config/GerritConfigListener.java
index 337a962..f5b2976 100644
--- a/java/com/google/gerrit/server/config/GerritConfigListener.java
+++ b/java/com/google/gerrit/server/config/GerritConfigListener.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
 import java.util.EventListener;
-import java.util.List;
 
 /**
  * Implementations of the GerritConfigListener interface expects to react GerritServerConfig
@@ -24,5 +26,5 @@
  */
 @ExtensionPoint
 public interface GerritConfigListener extends EventListener {
-  List<ConfigUpdatedEvent.Update> configUpdated(ConfigUpdatedEvent event);
+  Multimap<UpdateResult, ConfigUpdateEntry> configUpdated(ConfigUpdatedEvent event);
 }
diff --git a/java/com/google/gerrit/server/config/GerritConfigListenerHelper.java b/java/com/google/gerrit/server/config/GerritConfigListenerHelper.java
index 1dfa3fc..d21e1c3 100644
--- a/java/com/google/gerrit/server/config/GerritConfigListenerHelper.java
+++ b/java/com/google/gerrit/server/config/GerritConfigListenerHelper.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.ImmutableSet;
-import java.util.Collections;
 
 public class GerritConfigListenerHelper {
   public static GerritConfigListener acceptIfChanged(ConfigKey... keys) {
     return e ->
         e.isEntriesUpdated(ImmutableSet.copyOf(keys))
-            ? Collections.singletonList(e.accept(ImmutableSet.copyOf(keys)))
-            : Collections.emptyList();
+            ? e.accept(ImmutableSet.copyOf(keys))
+            : ConfigUpdatedEvent.NO_UPDATES;
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 57255a3..45b70b2 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -29,10 +29,12 @@
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
@@ -60,6 +62,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.systemstatus.MessageOfTheDay;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.extensions.webui.BranchWebLink;
 import com.google.gerrit.extensions.webui.DiffWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
@@ -72,17 +75,17 @@
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.TraceRequestListener;
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.AccountExternalIdCreator;
 import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountVisibilityProvider;
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.account.EmailExpander;
@@ -91,7 +94,6 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
-import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
 import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
@@ -99,10 +101,15 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangeETagComputation;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.LabelsJson;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
@@ -114,6 +121,7 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.NotesBranchUtil;
+import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
@@ -132,23 +140,16 @@
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.group.db.GroupDbModule;
 import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.mail.AutoReplyMailFilter;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.ListMailFilter;
 import com.google.gerrit.server.mail.MailFilter;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.FromAddressGenerator;
 import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
-import com.google.gerrit.server.mail.send.MailSoyTofuProvider;
+import com.google.gerrit.server.mail.send.MailSoySauceProvider;
 import com.google.gerrit.server.mail.send.MailTemplates;
-import com.google.gerrit.server.mail.send.MergedSender;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
-import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
@@ -166,11 +167,12 @@
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.quota.QuotaEnforcer;
 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;
@@ -193,7 +195,7 @@
 import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.PostReceiveHook;
@@ -218,7 +220,6 @@
     bind(IdGenerator.class);
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
-    bind(Sequences.class);
     install(authModule);
     install(AccountCacheImpl.module());
     install(BatchUpdate.module());
@@ -234,6 +235,7 @@
     install(SubmitStrategy.module());
     install(TagCache.module());
     install(OAuthTokenCache.module());
+    install(PureRevertCache.module());
 
     install(new AccessControlModule());
     install(new CmdLineParserModule());
@@ -242,29 +244,22 @@
     install(new GitModule());
     install(new GroupDbModule());
     install(new GroupModule());
-    install(new NoteDbModule(cfg));
+    install(new NoteDbModule());
     install(new PrologModule());
     install(new DefaultSubmitRule.Module());
+    install(new IgnoreSelfApprovalRule.Module());
     install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
     install(ThreadLocalRequestContext.module());
 
-    bind(AccountResolver.class);
-
-    factory(AddReviewerSender.Factory.class);
-    factory(DeleteReviewerSender.Factory.class);
-    factory(AddKeySender.Factory.class);
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
-    factory(CreateChangeSender.Factory.class);
-    factory(MergedSender.Factory.class);
+    factory(LabelsJson.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(ProjectState.Factory.class);
-    factory(RegisterNewEmailSender.Factory.class);
-    factory(ReplacePatchSetSender.Factory.class);
-    factory(SetAssigneeSender.Factory.class);
+    factory(RevisionJson.Factory.class);
     factory(InboundEmailRejectionSender.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
@@ -290,27 +285,28 @@
 
     bind(ApprovalsUtil.class);
 
-    bind(SoyTofu.class).annotatedWith(MailTemplates.class).toProvider(MailSoyTofuProvider.class);
+    bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(MailSoySauceProvider.class);
     bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
     bind(Boolean.class)
-        .annotatedWith(DisableReverseDnsLookup.class)
-        .toProvider(DisableReverseDnsLookupProvider.class)
+        .annotatedWith(EnableReverseDnsLookup.class)
+        .toProvider(EnableReverseDnsLookupProvider.class)
         .in(SINGLETON);
 
     bind(PatchSetInfoFactory.class);
     bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
     bind(AccountControl.Factory.class);
 
-    install(new AuditModule());
     bind(UiActions.class);
 
     bind(GitReferenceUpdated.class);
     DynamicMap.mapOf(binder(), new TypeLiteral<Cache<?, ?>>() {});
     DynamicSet.setOf(binder(), CacheRemovalListener.class);
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
+    DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), AssigneeChangedListener.class);
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
+    DynamicSet.setOf(binder(), ChangeDeletedListener.class);
     DynamicSet.setOf(binder(), CommentAddedListener.class);
     DynamicSet.setOf(binder(), HashtagsEditedListener.class);
     DynamicSet.setOf(binder(), ChangeMergedListener.class);
@@ -349,6 +345,7 @@
     DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
+    DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
     DynamicSet.setOf(binder(), OnSubmitValidationListener.class);
@@ -388,6 +385,11 @@
     DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
     DynamicSet.setOf(binder(), SubmitRule.class);
+    DynamicSet.setOf(binder(), QuotaEnforcer.class);
+    DynamicSet.setOf(binder(), PerformanceLogger.class);
+    DynamicSet.setOf(binder(), RequestListener.class);
+    DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
+    DynamicSet.setOf(binder(), ChangeETagComputation.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
@@ -398,9 +400,10 @@
     factory(UploadValidators.Factory.class);
     DynamicSet.setOf(binder(), UploadValidationListener.class);
 
+    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
-    DynamicMap.mapOf(binder(), ChangeQueryProcessor.ChangeAttributeFactory.class);
+    DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
 
     install(new GitwebConfig.LegacyModule(cfg));
 
diff --git a/java/com/google/gerrit/server/config/GerritOptions.java b/java/com/google/gerrit/server/config/GerritOptions.java
index 0192ddd..17b65c9 100644
--- a/java/com/google/gerrit/server/config/GerritOptions.java
+++ b/java/com/google/gerrit/server/config/GerritOptions.java
@@ -14,29 +14,21 @@
 
 package com.google.gerrit.server.config;
 
-import org.eclipse.jgit.lib.Config;
-
 public class GerritOptions {
   private final boolean headless;
   private final boolean slave;
-  private final boolean enableGwtUi;
   private final boolean forcePolyGerritDev;
 
-  public GerritOptions(Config cfg, boolean headless, boolean slave, boolean forcePolyGerritDev) {
-    this.slave = slave;
-    this.enableGwtUi = cfg.getBoolean("gerrit", null, "enableGwtUi", true);
-    this.forcePolyGerritDev = forcePolyGerritDev;
+  public GerritOptions(boolean headless, boolean slave, boolean forcePolyGerritDev) {
     this.headless = headless;
+    this.slave = slave;
+    this.forcePolyGerritDev = forcePolyGerritDev;
   }
 
   public boolean headless() {
     return headless;
   }
 
-  public boolean enableGwtUi() {
-    return !headless && enableGwtUi;
-  }
-
   public boolean enableMasterFeatures() {
     return !slave;
   }
diff --git a/java/com/google/gerrit/server/config/GerritRequestModule.java b/java/com/google/gerrit/server/config/GerritRequestModule.java
index 6a1103f..6732513 100644
--- a/java/com/google/gerrit/server/config/GerritRequestModule.java
+++ b/java/com/google/gerrit/server/config/GerritRequestModule.java
@@ -26,7 +26,6 @@
   @Override
   protected void configure() {
     bind(RequestCleanup.class).in(RequestScoped.class);
-    bind(RequestScopedReviewDbProvider.class);
     bind(IdentifiedUser.RequestFactory.class).in(SINGLETON);
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigProvider.java b/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
index 8df21da..952f7d3 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -99,7 +98,7 @@
   private static void checkNoteDbConfig(FileBasedConfig noteDbConfig) {
     List<String> bad = new ArrayList<>();
     for (String section : noteDbConfig.getSections()) {
-      if (section.equals(NotesMigration.SECTION_NOTE_DB)) {
+      if (section.equals("noteDb")) {
         continue;
       }
       for (String subsection : noteDbConfig.getSubsections(section)) {
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigReloader.java b/java/com/google/gerrit/server/config/GerritServerConfigReloader.java
index 1890de8..5ecf6ed 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigReloader.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigReloader.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.List;
 
 /** Issues a configuration reload from the GerritServerConfigProvider and notify all listeners. */
 @Singleton
@@ -27,11 +29,12 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final GerritServerConfigProvider configProvider;
-  private final DynamicSet<GerritConfigListener> configListeners;
+  private final PluginSetContext<GerritConfigListener> configListeners;
 
   @Inject
   GerritServerConfigReloader(
-      GerritServerConfigProvider configProvider, DynamicSet<GerritConfigListener> configListeners) {
+      GerritServerConfigProvider configProvider,
+      PluginSetContext<GerritConfigListener> configListeners) {
     this.configProvider = configProvider;
     this.configListeners = configListeners;
   }
@@ -40,18 +43,18 @@
    * Reloads the Gerrit Server Configuration from disk. Synchronized to ensure that one issued
    * reload is fully completed before a new one starts.
    */
-  public List<ConfigUpdatedEvent.Update> reloadConfig() {
+  public Multimap<UpdateResult, ConfigUpdateEntry> reloadConfig() {
     logger.atInfo().log("Starting server configuration reload");
-    List<ConfigUpdatedEvent.Update> updates = fireUpdatedConfigEvent(configProvider.updateConfig());
+    Multimap<UpdateResult, ConfigUpdateEntry> updates =
+        fireUpdatedConfigEvent(configProvider.updateConfig());
     logger.atInfo().log("Server configuration reload completed succesfully");
     return updates;
   }
 
-  public List<ConfigUpdatedEvent.Update> fireUpdatedConfigEvent(ConfigUpdatedEvent event) {
-    ArrayList<ConfigUpdatedEvent.Update> result = new ArrayList<>();
-    for (GerritConfigListener configListener : configListeners) {
-      result.addAll(configListener.configUpdated(event));
-    }
-    return result;
+  public Multimap<UpdateResult, ConfigUpdateEntry> fireUpdatedConfigEvent(
+      ConfigUpdatedEvent event) {
+    Multimap<UpdateResult, ConfigUpdateEntry> updates = ArrayListMultimap.create();
+    configListeners.runEach(l -> updates.putAll(l.configUpdated(event)));
+    return updates;
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritServerIdProvider.java b/java/com/google/gerrit/server/config/GerritServerIdProvider.java
index c609cc4..4898f55 100644
--- a/java/com/google/gerrit/server/config/GerritServerIdProvider.java
+++ b/java/com/google/gerrit/server/config/GerritServerIdProvider.java
@@ -48,9 +48,7 @@
 
     // We're not generally supposed to do work in provider constructors, but this is a bit of a
     // special case because we really need to have the ID available by the time the dbInjector
-    // is created. This even applies during MigrateToNoteDb, which otherwise would have been a
-    // reasonable place to do the ID generation. Fortunately, it's not much work, and it happens
-    // once.
+    // is created. Fortunately, it's not much work, and it happens once.
     id = generate();
     Config newCfg = readGerritConfig(sitePaths);
     newCfg.setString(SECTION, null, KEY, id);
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index f38572d..b5f09fd 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -36,6 +36,8 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.net.MalformedURLException;
+import java.net.URL;
 import org.eclipse.jgit.lib.Config;
 
 public class GitwebConfig {
@@ -178,7 +180,11 @@
   private final GitwebType type;
 
   @Inject
-  GitwebConfig(GitwebCgiConfig cgiConfig, @GerritServerConfig Config cfg) {
+  GitwebConfig(
+      GitwebCgiConfig cgiConfig,
+      @GerritServerConfig Config cfg,
+      @Nullable @CanonicalWebUrl String gerritUrl)
+      throws MalformedURLException {
     if (isDisabled(cfg)) {
       type = null;
       url = null;
@@ -191,7 +197,14 @@
         // Use an externally managed gitweb instance, and not an internal one.
         url = cfgUrl;
       } else {
-        url = firstNonNull(cfgUrl, "gitweb");
+        String baseGerritUrl;
+        if (gerritUrl != null) {
+          URL u = new URL(gerritUrl);
+          baseGerritUrl = u.getPath();
+        } else {
+          baseGerritUrl = "/";
+        }
+        url = firstNonNull(cfgUrl, baseGerritUrl + "gitweb");
       }
     }
   }
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index d30e080..dfb5c7a 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -302,17 +302,21 @@
 
     private final GitRepositoryManager repoManager;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+    private final ProjectConfig.Factory projectConfigFactory;
 
     @Inject
     UpdateChecker(
-        GitRepositoryManager repoManager, DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+        GitRepositoryManager repoManager,
+        DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+        ProjectConfig.Factory projectConfigFactory) {
       this.repoManager = repoManager;
       this.pluginConfigEntries = pluginConfigEntries;
+      this.projectConfigFactory = projectConfigFactory;
     }
 
     @Override
     public void onGitReferenceUpdated(Event event) {
-      Project.NameKey p = new Project.NameKey(event.getProjectName());
+      Project.NameKey p = Project.nameKey(event.getProjectName());
       if (!event.getRefName().equals(RefNames.REFS_CONFIG)) {
         return;
       }
@@ -321,7 +325,7 @@
         ProjectConfig oldCfg = parseConfig(p, event.getOldObjectId());
         ProjectConfig newCfg = parseConfig(p, event.getNewObjectId());
         if (oldCfg != null && newCfg != null) {
-          for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+          for (Extension<ProjectConfigEntry> e : pluginConfigEntries) {
             ProjectConfigEntry configEntry = e.getProvider().get();
             String newValue = getValue(newCfg, e);
             String oldValue = getValue(oldCfg, e);
@@ -361,13 +365,13 @@
         return null;
       }
       try (Repository repo = repoManager.openRepository(p)) {
-        ProjectConfig pc = new ProjectConfig(p);
+        ProjectConfig pc = projectConfigFactory.create(p);
         pc.load(repo, id);
         return pc;
       }
     }
 
-    private static String getValue(ProjectConfig cfg, Entry<ProjectConfigEntry> e) {
+    private static String getValue(ProjectConfig cfg, Extension<ProjectConfigEntry> e) {
       String value = cfg.getPluginConfig(e.getPluginName()).getString(e.getExportName());
       if (value == null) {
         value = e.getProvider().get().getDefaultValue();
diff --git a/java/com/google/gerrit/server/config/RepositoryConfig.java b/java/com/google/gerrit/server/config/RepositoryConfig.java
index a52c076..d8c8468 100644
--- a/java/com/google/gerrit/server/config/RepositoryConfig.java
+++ b/java/com/google/gerrit/server/config/RepositoryConfig.java
@@ -61,8 +61,7 @@
   }
 
   public ImmutableList<Path> getAllBasePaths() {
-    return cfg.getSubsections(SECTION_NAME)
-        .stream()
+    return cfg.getSubsections(SECTION_NAME).stream()
         .map(sub -> cfg.getString(SECTION_NAME, sub, BASE_PATH_NAME))
         .filter(Objects::nonNull)
         .map(Paths::get)
@@ -90,8 +89,7 @@
    */
   @Nullable
   private String findSubSection(String project) {
-    return cfg.getSubsections(SECTION_NAME)
-        .stream()
+    return cfg.getSubsections(SECTION_NAME).stream()
         .filter(ss -> isMatch(ss, project))
         .max(comparing(String::length))
         .orElse(null);
diff --git a/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java b/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
deleted file mode 100644
index fdb400b..0000000
--- a/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.RequestCleanup;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.servlet.RequestScoped;
-
-/** Provides {@link ReviewDb} database handle live only for this request. */
-@RequestScoped
-public class RequestScopedReviewDbProvider implements Provider<ReviewDb> {
-  private final SchemaFactory<ReviewDb> schema;
-  private final Provider<RequestCleanup> cleanup;
-  private ReviewDb db;
-
-  @Inject
-  public RequestScopedReviewDbProvider(
-      final SchemaFactory<ReviewDb> schema, Provider<RequestCleanup> cleanup) {
-    this.schema = schema;
-    this.cleanup = cleanup;
-  }
-
-  @SuppressWarnings("resource")
-  @Override
-  public ReviewDb get() {
-    if (db == null) {
-      ReviewDb c;
-      try {
-        c = schema.open();
-      } catch (OrmException e) {
-        throw new ProvisionException("Cannot open ReviewDb", e);
-      }
-      try {
-        cleanup
-            .get()
-            .add(
-                () -> {
-                  c.close();
-                  db = null;
-                });
-      } catch (Throwable e) {
-        c.close();
-        throw new ProvisionException("Cannot defer cleanup of ReviewDb", e);
-      }
-      db = c;
-    }
-    return db;
-  }
-}
diff --git a/java/com/google/gerrit/server/config/ScheduleConfig.java b/java/com/google/gerrit/server/config/ScheduleConfig.java
index d62e7a2..f07715b 100644
--- a/java/com/google/gerrit/server/config/ScheduleConfig.java
+++ b/java/com/google/gerrit/server/config/ScheduleConfig.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.config;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static java.time.ZoneId.systemDefault;
+import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
@@ -91,7 +91,7 @@
  *       executions are {@code Wed 10:30}, {@code Fri 10:30}. etc.
  *   <li>
  *       <pre>
- * foo.startTime = 6:00
+ * foo.startTime = 06:00
  * foo.interval = 1 day
  * </pre>
  *       Assuming that the server is started on {@code Mon 7:00} then this yields the first run on
@@ -174,7 +174,18 @@
       return true;
     }
 
-    if (interval <= 0 || initialDelay < 0) {
+    if (interval != INVALID_CONFIG && interval <= 0) {
+      logger.atSevere().log("Invalid interval value \"%d\" for \"%s\": must be > 0", interval, key);
+      interval = INVALID_CONFIG;
+    }
+
+    if (initialDelay != INVALID_CONFIG && initialDelay < 0) {
+      logger.atSevere().log(
+          "Invalid initial delay value \"%d\" for \"%s\": must be >= 0", initialDelay, key);
+      initialDelay = INVALID_CONFIG;
+    }
+
+    if (interval == INVALID_CONFIG || initialDelay == INVALID_CONFIG) {
       logger.atSevere().log("Invalid schedule configuration for \"%s\" is ignored. ", key);
       return true;
     }
@@ -183,7 +194,7 @@
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     StringBuilder b = new StringBuilder();
     b.append(formatValue(keyInterval()));
     b.append(", ");
@@ -216,6 +227,9 @@
       return ConfigUtil.getTimeUnit(
           rc, section, subsection, keyInterval, MISSING_CONFIG, TimeUnit.MILLISECONDS);
     } catch (IllegalArgumentException e) {
+      // We only need to log the exception message; it already includes the
+      // section.subsection.key and bad value.
+      logger.atSevere().log("%s", e.getMessage());
       return INVALID_CONFIG;
     }
   }
@@ -239,7 +253,7 @@
   }
 
   private static long computeInitialDelay(long interval, String start, ZonedDateTime now) {
-    checkNotNull(start);
+    requireNonNull(start);
 
     try {
       DateTimeFormatter formatter = DateTimeFormatter.ofPattern("[E ]HH:mm").withLocale(Locale.US);
@@ -258,6 +272,7 @@
       }
       return delay;
     } catch (DateTimeParseException e) {
+      logger.atSevere().log("Invalid start time: %s", e.getMessage());
       return INVALID_CONFIG;
     }
   }
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index 11ec50c..ee95c6f 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -43,7 +43,6 @@
   public final Path mail_dir;
   public final Path hooks_dir;
   public final Path static_dir;
-  public final Path themes_dir;
   public final Path index_dir;
 
   public final Path gerrit_sh;
@@ -55,6 +54,8 @@
   public final Path secure_config;
   public final Path notedb_config;
 
+  public final Path jgit_config;
+
   public final Path ssl_keystore;
   public final Path ssh_key;
   public final Path ssh_rsa;
@@ -67,8 +68,7 @@
   public final Path site_css;
   public final Path site_header;
   public final Path site_footer;
-  // For PolyGerrit UI only.
-  public final Path site_theme;
+  public final Path site_theme; // For PolyGerrit UI only.
   public final Path site_gitweb;
 
   /** {@code true} if {@link #site_path} has not been initialized. */
@@ -90,7 +90,6 @@
     mail_dir = etc_dir.resolve("mail");
     hooks_dir = p.resolve("hooks");
     static_dir = p.resolve("static");
-    themes_dir = p.resolve("themes");
     index_dir = p.resolve("index");
 
     gerrit_sh = bin_dir.resolve("gerrit.sh");
@@ -102,6 +101,8 @@
     secure_config = etc_dir.resolve("secure.config");
     notedb_config = etc_dir.resolve("notedb.config");
 
+    jgit_config = etc_dir.resolve("jgit.config");
+
     ssl_keystore = etc_dir.resolve("keystore");
     ssh_key = etc_dir.resolve("ssh_host_key");
     ssh_rsa = etc_dir.resolve("ssh_host_rsa_key");
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index 2e97c06..6c729d9 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<>(poolSize),
+                    new ThreadFactoryBuilder()
+                        .setNameFormat("ChangeUpdate-%d")
+                        .setDaemon(true)
+                        .build(),
+                    new ThreadPoolExecutor.CallerRunsPolicy()))));
   }
 }
diff --git a/java/com/google/gerrit/server/config/ThreadSettingsConfig.java b/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
index 6cb32cc..c20e0a4 100644
--- a/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
+++ b/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
@@ -28,7 +28,7 @@
   @Inject
   ThreadSettingsConfig(@GerritServerConfig Config cfg) {
     int cores = Runtime.getRuntime().availableProcessors();
-    sshdThreads = cfg.getInt("sshd", "threads", 2 * cores);
+    sshdThreads = cfg.getInt("sshd", "threads", Math.max(4, 2 * cores));
     httpdMaxThreads = cfg.getInt("httpd", "maxThreads", 25);
     int defaultDatabasePoolLimit = sshdThreads + httpdMaxThreads + 2;
     databasePoolLimit = cfg.getInt("database", "poolLimit", defaultDatabasePoolLimit);
diff --git a/java/com/google/gerrit/server/config/TrackingFootersProvider.java b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
index ff1910d..2114b1a 100644
--- a/java/com/google/gerrit/server/config/TrackingFootersProvider.java
+++ b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.TrackingId;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -33,6 +32,8 @@
 public class TrackingFootersProvider implements Provider<TrackingFooters> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final int MAX_LENGTH = 10;
+
   private static String TRACKING_ID_TAG = "trackingid";
   private static String FOOTER_TAG = "footer";
   private static String SYSTEM_TAG = "system";
@@ -59,11 +60,11 @@
         configValid = false;
         logger.atSevere().log(
             "Missing %s.%s.%s in gerrit.config", TRACKING_ID_TAG, name, SYSTEM_TAG);
-      } else if (system.length() > TrackingId.TRACKING_SYSTEM_MAX_CHAR) {
+      } else if (system.length() > MAX_LENGTH) {
         configValid = false;
         logger.atSevere().log(
             "String too long \"%s\" in gerrit.config %s.%s.%s (max %d char)",
-            system, TRACKING_ID_TAG, name, SYSTEM_TAG, TrackingId.TRACKING_SYSTEM_MAX_CHAR);
+            system, TRACKING_ID_TAG, name, SYSTEM_TAG, MAX_LENGTH);
       }
 
       String match = cfg.getString(TRACKING_ID_TAG, name, REGEX_TAG);
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
new file mode 100644
index 0000000..740daf0
--- /dev/null
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.Optional;
+
+/**
+ * Formats URLs to different parts of the Gerrit API and UI.
+ *
+ * <p>By default, these gerrit URLs are formed by adding suffixes to the web URL. The interface
+ * centralizes these conventions, and also allows introducing different, custom URL schemes.
+ *
+ * <p>Unfortunately, Gerrit operates in modes for which there is no canonical URL. This can be in
+ * standalone utilities that have no HTTP server (eg. index upgrade commands), in servers that run
+ * SSH only, or in a HTTP/SSH server that is accessed over SSH without canonical web URL configured.
+ */
+public interface UrlFormatter {
+
+  /**
+   * The canonical base URL where this Gerrit installation can be reached.
+   *
+   * <p>For the default implementations below to work, it must end in "/".
+   */
+  Optional<String> getWebUrl();
+
+  /** Returns the URL for viewing a change. */
+  default Optional<String> getChangeViewUrl(Project.NameKey project, Change.Id id) {
+
+    // In the PolyGerrit URL (contrary to REST URLs) there is no need to URL-escape strings, since
+    // the /+/ separator unambiguously defines how to parse the path.
+    return getWebUrl().map(url -> url + "c/" + project.get() + "/+/" + id.get());
+  }
+
+  /** Returns the URL for viewing a file in a given patch set of a change. */
+  default Optional<String> getPatchFileView(Change change, int patchsetId, String filename) {
+    return getChangeViewUrl(change.getProject(), change.getId())
+        .map(url -> url + "/" + patchsetId + "/" + filename);
+  }
+
+  /** Returns the URL for viewing a comment in a file in a given patch set of a change. */
+  default Optional<String> getInlineCommentView(
+      Change change, int patchsetId, String filename, short side, int startLine) {
+    return getPatchFileView(change, patchsetId, filename)
+        .map(url -> url + String.format("@%s%d", side == 0 ? "a" : "", startLine));
+  }
+
+  /** Returns a URL pointing to a section of the settings page. */
+  default Optional<String> getSettingsUrl() {
+    return getWebUrl().map(url -> url + "settings");
+  }
+
+  /**
+   * Returns a URL pointing to a section of the settings page, or the settings page if {@code
+   * section} is null.
+   */
+  default Optional<String> getSettingsUrl(@Nullable String section) {
+    return Strings.isNullOrEmpty(section)
+        ? getSettingsUrl()
+        : getSettingsUrl().map(url -> url + "#" + section);
+  }
+
+  /** Returns a URL pointing to a documentation page, at a given named anchor. */
+  default Optional<String> getDocUrl(String page, String anchor) {
+    return getWebUrl().map(url -> url + "Documentation/" + page + "#" + anchor);
+  }
+
+  /** Returns a REST API URL for a given suffix (eg. "accounts/self/details") */
+  default Optional<String> getRestUrl(String suffix) {
+    return getWebUrl().map(url -> url + suffix);
+  }
+}
diff --git a/java/com/google/gerrit/server/data/ChangeAttribute.java b/java/com/google/gerrit/server/data/ChangeAttribute.java
index ec76f50..fde5922 100644
--- a/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -30,6 +30,7 @@
   public AccountAttribute assignee;
   public String url;
   public String commitMessage;
+  public List<String> hashtags;
 
   public Long createdOn;
   public Long lastUpdated;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index a7f9a05..2eb46f1 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -14,30 +14,33 @@
 
 package com.google.gerrit.server.documentation;
 
+import static com.vladsch.flexmark.profiles.pegdown.Extensions.ALL;
+import static com.vladsch.flexmark.profiles.pegdown.Extensions.HARDWRAPS;
+import static com.vladsch.flexmark.profiles.pegdown.Extensions.SUPPRESS_ALL_HTML;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.pegdown.Extensions.ALL;
-import static org.pegdown.Extensions.HARDWRAPS;
-import static org.pegdown.Extensions.SUPPRESS_ALL_HTML;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.vladsch.flexmark.Extension;
+import com.vladsch.flexmark.ast.Block;
+import com.vladsch.flexmark.ast.Heading;
+import com.vladsch.flexmark.ast.Node;
+import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
+import com.vladsch.flexmark.html.HtmlRenderer;
+import com.vladsch.flexmark.parser.Parser;
+import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
+import com.vladsch.flexmark.util.options.MutableDataHolder;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.UnsupportedEncodingException;
 import java.net.URL;
 import java.nio.charset.Charset;
+import java.util.ArrayList;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.commons.lang.StringEscapeUtils;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
-import org.pegdown.LinkRenderer;
-import org.pegdown.PegDownProcessor;
-import org.pegdown.ToHtmlSerializer;
-import org.pegdown.ast.HeaderNode;
-import org.pegdown.ast.Node;
-import org.pegdown.ast.RootNode;
-import org.pegdown.ast.TextNode;
 
 public class MarkdownFormatter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -48,9 +51,9 @@
     AtomicBoolean file = new AtomicBoolean();
     String src;
     try {
-      src = readPegdownCss(file);
+      src = readFlexMarkJavaCss(file);
     } catch (IOException err) {
-      logger.atWarning().withCause(err).log("Cannot load pegdown.css");
+      logger.atWarning().withCause(err).log("Cannot load flexmark-java.css");
       src = "";
     }
     defaultCss = file.get() ? null : src;
@@ -61,9 +64,9 @@
       return defaultCss;
     }
     try {
-      return readPegdownCss(new AtomicBoolean());
+      return readFlexMarkJavaCss(new AtomicBoolean());
     } catch (IOException err) {
-      logger.atWarning().withCause(err).log("Cannot load pegdown.css");
+      logger.atWarning().withCause(err).log("Cannot load flexmark-java.css");
       return "";
     }
   }
@@ -81,8 +84,28 @@
     return this;
   }
 
+  private MutableDataHolder markDownOptions() {
+    int options = ALL & ~(HARDWRAPS);
+    if (suppressHtml) {
+      options |= SUPPRESS_ALL_HTML;
+    }
+
+    MutableDataHolder optionsExt =
+        PegdownOptionsAdapter.flexmarkOptions(
+                options, MarkdownFormatterHeader.HeadingExtension.create())
+            .toMutable();
+
+    ArrayList<Extension> extensions = new ArrayList<>();
+    for (Extension extension : optionsExt.get(com.vladsch.flexmark.parser.Parser.EXTENSIONS)) {
+      extensions.add(extension);
+    }
+
+    return optionsExt;
+  }
+
   public byte[] markdownToDocHtml(String md, String charEnc) throws UnsupportedEncodingException {
-    RootNode root = parseMarkdown(md);
+    Node root = parseMarkdown(md);
+    HtmlRenderer renderer = HtmlRenderer.builder(markDownOptions()).build();
     String title = findTitle(root);
 
     StringBuilder html = new StringBuilder();
@@ -100,7 +123,7 @@
     html.append("\n</style>");
     html.append("</head>");
     html.append("<body>\n");
-    html.append(new ToHtmlSerializer(new LinkRenderer()).toHtml(root));
+    html.append(renderer.render(root));
     html.append("\n</body></html>");
     return html.toString().getBytes(charEnc);
   }
@@ -111,38 +134,36 @@
   }
 
   private String findTitle(Node root) {
-    if (root instanceof HeaderNode) {
-      HeaderNode h = (HeaderNode) root;
-      if (h.getLevel() == 1 && h.getChildren() != null && !h.getChildren().isEmpty()) {
-        StringBuilder b = new StringBuilder();
-        for (Node n : root.getChildren()) {
-          if (n instanceof TextNode) {
-            b.append(((TextNode) n).getText());
-          }
-        }
-        return b.toString();
+    if (root instanceof Heading) {
+      Heading h = (Heading) root;
+      if (h.getLevel() == 1 && h.hasChildren()) {
+        TextCollectingVisitor collectingVisitor = new TextCollectingVisitor();
+        return collectingVisitor.collectAndGetText(h);
       }
     }
 
-    for (Node n : root.getChildren()) {
-      String title = findTitle(n);
-      if (title != null) {
-        return title;
+    if (root instanceof Block && root.hasChildren()) {
+      Node child = root.getFirstChild();
+      while (child != null) {
+        String title = findTitle(child);
+        if (title != null) {
+          return title;
+        }
+        child = child.getNext();
       }
     }
+
     return null;
   }
 
-  private RootNode parseMarkdown(String md) {
-    int options = ALL & ~(HARDWRAPS);
-    if (suppressHtml) {
-      options |= SUPPRESS_ALL_HTML;
-    }
-    return new PegDownProcessor(options).parseMarkdown(md.toCharArray());
+  private Node parseMarkdown(String md) {
+    Parser parser = Parser.builder(markDownOptions()).build();
+    Node document = parser.parse(md);
+    return document;
   }
 
-  private static String readPegdownCss(AtomicBoolean file) throws IOException {
-    String name = "pegdown.css";
+  private static String readFlexMarkJavaCss(AtomicBoolean file) throws IOException {
+    String name = "flexmark-java.css";
     URL url = MarkdownFormatter.class.getResource(name);
     if (url == null) {
       throw new FileNotFoundException("Resource " + name);
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
new file mode 100644
index 0000000..00f7ec1
--- /dev/null
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.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.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<>(
+          Arrays.asList(
+              new NodeRenderingHandler<>(
+                  AnchorLink.class,
+                  (node, context, html) -> HeadingNodeRenderer.this.render(node, context)),
+              new NodeRenderingHandler<>(Heading.class, HeadingNodeRenderer.this::render)));
+    }
+
+    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(),
+                  () -> {
+                    html.srcPos(node.getText()).withAttr().tag("span");
+                    context.renderChildren(node);
+                    html.tag("/span");
+                  });
+        } else {
+          html.srcPos(node.getText())
+              .withAttr()
+              .tagLine("h" + node.getLevel(), () -> 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/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index c606919..569399b 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.documentation;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.zip.ZipEntry;
@@ -85,9 +85,9 @@
       // and skipped paging. Maybe add paging later.
       TopDocs results = searcher.search(query, Integer.MAX_VALUE);
       ScoreDoc[] hits = results.scoreDocs;
-      int totalHits = results.totalHits;
+      long totalHits = results.totalHits;
 
-      List<DocResult> out = Lists.newArrayListWithCapacity(totalHits);
+      List<DocResult> out = new ArrayList<>();
       for (int i = 0; i < totalHits; i++) {
         DocResult result = new DocResult();
         Document doc = searcher.doc(hits[i].doc);
@@ -129,8 +129,9 @@
     return parser != null && searcher != null;
   }
 
-  @SuppressWarnings("serial")
   public static class DocQueryException extends Exception {
+    private static final long serialVersionUID = 1L;
+
     DocQueryException() {}
 
     DocQueryException(String msg) {
diff --git a/java/com/google/gerrit/server/edit/ChangeEdit.java b/java/com/google/gerrit/server/edit/ChangeEdit.java
index e641abc..11dc380 100644
--- a/java/com/google/gerrit/server/edit/ChangeEdit.java
+++ b/java/com/google/gerrit/server/edit/ChangeEdit.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.edit;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -35,10 +35,10 @@
 
   public ChangeEdit(
       Change change, String editRefName, RevCommit editCommit, PatchSet basePatchSet) {
-    this.change = checkNotNull(change);
-    this.editRefName = checkNotNull(editRefName);
-    this.editCommit = checkNotNull(editCommit);
-    this.basePatchSet = checkNotNull(basePatchSet);
+    this.change = requireNonNull(change);
+    this.editRefName = requireNonNull(editRefName);
+    this.editCommit = requireNonNull(editCommit);
+    this.basePatchSet = requireNonNull(basePatchSet);
   }
 
   public Change getChange() {
diff --git a/java/com/google/gerrit/server/edit/ChangeEditJson.java b/java/com/google/gerrit/server/edit/ChangeEditJson.java
index 78baef7..25dcae0 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditJson.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditJson.java
@@ -20,9 +20,10 @@
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.DownloadCommandsJson;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -50,8 +51,9 @@
   public EditInfo toEditInfo(ChangeEdit edit, boolean downloadCommands) {
     EditInfo out = new EditInfo();
     out.commit = fillCommit(edit.getEditCommit());
-    out.baseRevision = edit.getBasePatchSet().getRevision().get();
-    out.basePatchSetNumber = edit.getBasePatchSet().getPatchSetId();
+    out.baseRevision = edit.getBasePatchSet().commitId().name();
+    out.basePatchSetNumber = edit.getBasePatchSet().number();
+    out.ref = edit.getRefName();
     if (downloadCommands) {
       out.fetch = fillFetchMap(edit);
     }
@@ -78,7 +80,7 @@
 
   private Map<String, FetchInfo> fillFetchMap(ChangeEdit edit) {
     Map<String, FetchInfo> r = new LinkedHashMap<>();
-    for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
+    for (Extension<DownloadScheme> e : downloadSchemes) {
       String schemeName = e.getExportName();
       DownloadScheme scheme = e.getProvider().get();
       if (!scheme.isEnabled()
@@ -96,7 +98,8 @@
       FetchInfo fetchInfo = new FetchInfo(scheme.getUrl(projectName), refName);
       r.put(schemeName, fetchInfo);
 
-      ChangeJson.populateFetchMap(scheme, downloadCommands, projectName, refName, fetchInfo);
+      DownloadCommandsJson.populateFetchMap(
+          scheme, downloadCommands, projectName, refName, fetchInfo);
     }
 
     return r;
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 80d1cd1..fcd38c3 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.edit;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -24,7 +23,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -43,7 +42,7 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.CommitMessageUtil;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -80,7 +79,6 @@
 
   private final TimeZone tz;
   private final ChangeIndexer indexer;
-  private final Provider<ReviewDb> reviewDb;
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
@@ -91,14 +89,12 @@
   ChangeEditModifier(
       @GerritPersonIdent PersonIdent gerritIdent,
       ChangeIndexer indexer,
-      Provider<ReviewDb> reviewDb,
       Provider<CurrentUser> currentUser,
       PermissionBackend permissionBackend,
       ChangeEditUtil changeEditUtil,
       PatchSetUtil patchSetUtil,
       ProjectCache projectCache) {
     this.indexer = indexer;
-    this.reviewDb = reviewDb;
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
     this.tz = gerritIdent.getTimeZone();
@@ -117,7 +113,7 @@
    * @throws PermissionBackendException
    */
   public void createEdit(Repository repository, ChangeNotes notes)
-      throws AuthException, IOException, InvalidChangeOperationException, OrmException,
+      throws AuthException, IOException, InvalidChangeOperationException,
           PermissionBackendException, ResourceConflictException {
     assertCanEdit(notes);
 
@@ -128,7 +124,7 @@
     }
 
     PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
-    ObjectId patchSetCommitId = getPatchSetCommitId(currentPatchSet);
+    ObjectId patchSetCommitId = currentPatchSet.commitId();
     createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
   }
 
@@ -145,8 +141,8 @@
    * @throws PermissionBackendException
    */
   public void rebaseEdit(Repository repository, ChangeNotes notes)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
-          MergeConflictException, PermissionBackendException, ResourceConflictException {
+      throws AuthException, InvalidChangeOperationException, IOException, MergeConflictException,
+          PermissionBackendException, ResourceConflictException {
     assertCanEdit(notes);
 
     Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
@@ -161,14 +157,14 @@
       throw new InvalidChangeOperationException(
           String.format(
               "Change edit for change %s is already based on latest patch set %s",
-              notes.getChangeId(), currentPatchSet.getId()));
+              notes.getChangeId(), currentPatchSet.id()));
     }
 
     rebase(repository, changeEdit, currentPatchSet);
   }
 
   private void rebase(Repository repository, ChangeEdit changeEdit, PatchSet currentPatchSet)
-      throws IOException, MergeConflictException, InvalidChangeOperationException, OrmException {
+      throws IOException, MergeConflictException, InvalidChangeOperationException {
     RevCommit currentEditCommit = changeEdit.getEditCommit();
     if (currentEditCommit.getParentCount() == 0) {
       throw new InvalidChangeOperationException(
@@ -210,7 +206,7 @@
    * @throws BadRequestException if the commit message is malformed
    */
   public void modifyMessage(Repository repository, ChangeNotes notes, String newCommitMessage)
-      throws AuthException, IOException, UnchangedCommitMessageException, OrmException,
+      throws AuthException, IOException, UnchangedCommitMessageException,
           PermissionBackendException, BadRequestException, ResourceConflictException {
     assertCanEdit(notes);
     newCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(newCommitMessage);
@@ -253,7 +249,7 @@
    */
   public void modifyFile(
       Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+      throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
     modifyTree(repository, notes, new ChangeFileContentModification(filePath, newContent));
   }
@@ -271,7 +267,7 @@
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void deleteFile(Repository repository, ChangeNotes notes, String file)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+      throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
     modifyTree(repository, notes, new DeleteFileModification(file));
   }
@@ -292,7 +288,7 @@
    */
   public void renameFile(
       Repository repository, ChangeNotes notes, String currentFilePath, String newFilePath)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+      throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
     modifyTree(repository, notes, new RenameFileModification(currentFilePath, newFilePath));
   }
@@ -310,14 +306,14 @@
    * @throws PermissionBackendException
    */
   public void restoreFile(Repository repository, ChangeNotes notes, String file)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+      throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
     modifyTree(repository, notes, new RestoreFileModification(file));
   }
 
   private void modifyTree(
       Repository repository, ChangeNotes notes, TreeModification treeModification)
-      throws AuthException, IOException, OrmException, InvalidChangeOperationException,
+      throws AuthException, IOException, InvalidChangeOperationException,
           PermissionBackendException, ResourceConflictException {
     assertCanEdit(notes);
 
@@ -363,7 +359,7 @@
       PatchSet patchSet,
       List<TreeModification> treeModifications)
       throws AuthException, IOException, InvalidChangeOperationException, MergeConflictException,
-          OrmException, PermissionBackendException, ResourceConflictException {
+          PermissionBackendException, ResourceConflictException {
     assertCanEdit(notes);
 
     Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
@@ -394,27 +390,21 @@
   }
 
   private void assertCanEdit(ChangeNotes notes)
-      throws AuthException, PermissionBackendException, IOException, ResourceConflictException,
-          OrmException {
+      throws AuthException, PermissionBackendException, IOException, ResourceConflictException {
     if (!currentUser.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
 
     Change c = notes.getChange();
-    if (!c.getStatus().isOpen()) {
+    if (!c.isNew()) {
       throw new ResourceConflictException(
-          String.format(
-              "change %s is %s", c.getChangeId(), c.getStatus().toString().toLowerCase()));
+          String.format("change %s is %s", c.getChangeId(), ChangeUtil.status(c)));
     }
 
     // Not allowed to edit if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(notes, currentUser.get());
+    patchSetUtil.checkPatchSetNotLocked(notes);
     try {
-      permissionBackend
-          .currentUser()
-          .database(reviewDb)
-          .change(notes)
-          .check(ChangePermission.ADD_PATCH_SET);
+      permissionBackend.currentUser().change(notes).check(ChangePermission.ADD_PATCH_SET);
       projectCache.checkedGet(notes.getProjectName()).checkStatePermitsWrite();
     } catch (AuthException denied) {
       throw new AuthException("edit not permitted", denied);
@@ -431,10 +421,10 @@
             String.format(
                 "Only the patch set %s on which the existing change edit is based may be modified "
                     + "(specified patch set: %s)",
-                changeEdit.getBasePatchSet().getId(), patchSet.getId()));
+                changeEdit.getBasePatchSet().id(), patchSet.id()));
       }
     } else {
-      PatchSet.Id patchSetId = patchSet.getId();
+      PatchSet.Id patchSetId = patchSet.id();
       PatchSet.Id currentPatchSetId = notes.getChange().currentPatchSetId();
       if (!patchSetId.equals(currentPatchSetId)) {
         throw new InvalidChangeOperationException(
@@ -450,24 +440,23 @@
     return changeEditUtil.byChange(notes);
   }
 
-  private PatchSet getBasePatchSet(Optional<ChangeEdit> optionalChangeEdit, ChangeNotes notes)
-      throws OrmException {
+  private PatchSet getBasePatchSet(Optional<ChangeEdit> optionalChangeEdit, ChangeNotes notes) {
     Optional<PatchSet> editBasePatchSet = optionalChangeEdit.map(ChangeEdit::getBasePatchSet);
     return editBasePatchSet.isPresent() ? editBasePatchSet.get() : lookupCurrentPatchSet(notes);
   }
 
-  private PatchSet lookupCurrentPatchSet(ChangeNotes notes) throws OrmException {
-    return patchSetUtil.current(reviewDb.get(), notes);
+  private PatchSet lookupCurrentPatchSet(ChangeNotes notes) {
+    return patchSetUtil.current(notes);
   }
 
   private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
     PatchSet editBasePatchSet = changeEdit.getBasePatchSet();
-    return editBasePatchSet.getId().equals(patchSet.getId());
+    return editBasePatchSet.id().equals(patchSet.id());
   }
 
   private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
       throws IOException {
-    ObjectId patchSetCommitId = getPatchSetCommitId(patchSet);
+    ObjectId patchSetCommitId = patchSet.commitId();
     return lookupCommit(repository, patchSetCommitId);
   }
 
@@ -494,7 +483,7 @@
   private static ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
       throws IOException, MergeConflictException {
     PatchSet basePatchSet = changeEdit.getBasePatchSet();
-    ObjectId basePatchSetCommitId = getPatchSetCommitId(basePatchSet);
+    ObjectId basePatchSetCommitId = basePatchSet.commitId();
     ObjectId editCommitId = changeEdit.getEditCommit();
 
     ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repository, true);
@@ -533,17 +522,13 @@
     return user.newCommitterIdent(commitTimestamp, tz);
   }
 
-  private static ObjectId getPatchSetCommitId(PatchSet patchSet) {
-    return ObjectId.fromString(patchSet.getRevision().get());
-  }
-
   private ChangeEdit createEdit(
       Repository repository,
       ChangeNotes notes,
       PatchSet basePatchSet,
       ObjectId newEditCommitId,
       Timestamp timestamp)
-      throws IOException, OrmException {
+      throws IOException {
     Change change = notes.getChange();
     String editRefName = getEditRefName(change, basePatchSet);
     updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommitId, timestamp);
@@ -555,12 +540,12 @@
 
   private String getEditRefName(Change change, PatchSet basePatchSet) {
     IdentifiedUser me = currentUser.get().asIdentifiedUser();
-    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.getId());
+    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.id());
   }
 
   private ChangeEdit updateEdit(
       Repository repository, ChangeEdit changeEdit, ObjectId newEditCommitId, Timestamp timestamp)
-      throws IOException, OrmException {
+      throws IOException {
     String editRefName = changeEdit.getRefName();
     RevCommit currentEditCommit = changeEdit.getEditCommit();
     updateReference(repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
@@ -627,7 +612,7 @@
     return user.newRefLogIdent(timestamp, tz);
   }
 
-  private void reindex(Change change) throws IOException, OrmException {
-    indexer.index(reviewDb.get(), change);
+  private void reindex(Change change) {
+    indexer.index(change);
   }
 }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 24ee881..1af8148 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -16,24 +16,20 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeIndexer;
@@ -42,7 +38,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -69,7 +65,6 @@
   private final GitRepositoryManager gitManager;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final ChangeIndexer indexer;
-  private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> userProvider;
   private final ChangeKindCache changeKindCache;
   private final PatchSetUtil psUtil;
@@ -79,14 +74,12 @@
       GitRepositoryManager gitManager,
       PatchSetInserter.Factory patchSetInserterFactory,
       ChangeIndexer indexer,
-      Provider<ReviewDb> db,
       Provider<CurrentUser> userProvider,
       ChangeKindCache changeKindCache,
       PatchSetUtil psUtil) {
     this.gitManager = gitManager;
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.indexer = indexer;
-    this.db = db;
     this.userProvider = userProvider;
     this.changeKindCache = changeKindCache;
     this.psUtil = psUtil;
@@ -129,7 +122,7 @@
       String[] refNames = new String[n];
       for (int i = n; i > 0; i--) {
         refNames[i - 1] =
-            RefNames.refsEdit(u.getAccountId(), change.getId(), new PatchSet.Id(change.getId(), i));
+            RefNames.refsEdit(u.getAccountId(), change.getId(), PatchSet.id(change.getId(), i));
       }
       Ref ref = repo.getRefDatabase().firstExactRef(refNames);
       if (ref == null) {
@@ -152,9 +145,7 @@
    * @param edit change edit to publish
    * @param notify Notify handling that defines to whom email notifications should be sent after the
    *     change edit is published.
-   * @param accountsToNotify Accounts that should be notified after the change edit is published.
    * @throws IOException
-   * @throws OrmException
    * @throws UpdateException
    * @throws RestApiException
    */
@@ -162,48 +153,40 @@
       BatchUpdate.Factory updateFactory,
       ChangeNotes notes,
       CurrentUser user,
-      final ChangeEdit edit,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
-      throws IOException, OrmException, RestApiException, UpdateException {
+      ChangeEdit edit,
+      NotifyResolver.Result notify)
+      throws IOException, RestApiException, UpdateException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject());
         ObjectInserter oi = repo.newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk rw = new RevWalk(reader)) {
       PatchSet basePatchSet = edit.getBasePatchSet();
-      if (!basePatchSet.getId().equals(change.currentPatchSetId())) {
+      if (!basePatchSet.id().equals(change.currentPatchSetId())) {
         throw new ResourceConflictException("only edit for current patch set can be published");
       }
 
       RevCommit squashed = squashEdit(rw, oi, edit.getEditCommit(), basePatchSet);
       PatchSet.Id psId = ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
-      PatchSetInserter inserter =
-          patchSetInserterFactory
-              .create(notes, psId, squashed)
-              .setNotify(notify)
-              .setAccountsToNotify(accountsToNotify);
+      PatchSetInserter inserter = patchSetInserterFactory.create(notes, psId, squashed);
 
       StringBuilder message =
           new StringBuilder("Patch Set ").append(inserter.getPatchSetId().get()).append(": ");
 
       // Previously checked that the base patch set is the current patch set.
-      ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
+      ObjectId prior = basePatchSet.commitId();
       ChangeKind kind =
           changeKindCache.getChangeKind(change.getProject(), rw, repo.getConfig(), prior, squashed);
       if (kind == ChangeKind.NO_CODE_CHANGE) {
         message.append("Commit message was updated.");
         inserter.setDescription("Edit commit message");
       } else {
-        message
-            .append("Published edit on patch set ")
-            .append(basePatchSet.getPatchSetId())
-            .append(".");
+        message.append("Published edit on patch set ").append(basePatchSet.number()).append(".");
       }
 
-      try (BatchUpdate bu =
-          updateFactory.create(db.get(), change.getProject(), user, TimeUtil.nowTs())) {
+      try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.nowTs())) {
         bu.setRepository(repo, rw, oi);
+        bu.setNotify(notify);
         bu.addOp(change.getId(), inserter.setMessage(message.toString()));
         bu.addOp(
             change.getId(),
@@ -223,14 +206,13 @@
    *
    * @param edit change edit to delete
    * @throws IOException
-   * @throws OrmException
    */
-  public void delete(ChangeEdit edit) throws IOException, OrmException {
+  public void delete(ChangeEdit edit) throws IOException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject())) {
       deleteRef(repo, edit);
     }
-    indexer.index(db.get(), change);
+    indexer.index(change);
   }
 
   private PatchSet getBasePatchSet(ChangeNotes notes, Ref ref) throws IOException {
@@ -238,9 +220,8 @@
       int pos = ref.getName().lastIndexOf('/');
       checkArgument(pos > 0, "invalid edit ref: %s", ref.getName());
       String psId = ref.getName().substring(pos + 1);
-      return psUtil.get(
-          db.get(), notes, new PatchSet.Id(notes.getChange().getId(), Integer.parseInt(psId)));
-    } catch (OrmException | NumberFormatException e) {
+      return psUtil.get(notes, PatchSet.id(notes.getChange().getId(), Integer.parseInt(psId)));
+    } catch (StorageException | NumberFormatException e) {
       throw new IOException(e);
     }
   }
@@ -248,7 +229,7 @@
   private RevCommit squashEdit(
       RevWalk rw, ObjectInserter inserter, RevCommit edit, PatchSet basePatchSet)
       throws IOException, ResourceConflictException {
-    RevCommit parent = rw.parseCommit(ObjectId.fromString(basePatchSet.getRevision().get()));
+    RevCommit parent = rw.parseCommit(basePatchSet.commitId());
     if (parent.getTree().equals(edit.getTree())
         && edit.getFullMessage().equals(parent.getFullMessage())) {
       throw new ResourceConflictException("identical tree and message");
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index a163b58..d91e2e8 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.edit.tree;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -43,7 +43,7 @@
 
   public ChangeFileContentModification(String filePath, RawInput newContent) {
     this.filePath = filePath;
-    this.newContent = checkNotNull(newContent, "new content required");
+    this.newContent = requireNonNull(newContent, "new content required");
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index e867e76..e6caf97 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.edit.tree;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -39,7 +39,7 @@
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
   public TreeCreator(RevCommit baseCommit) {
-    this.baseCommit = checkNotNull(baseCommit, "baseCommit is required");
+    this.baseCommit = requireNonNull(baseCommit, "baseCommit is required");
   }
 
   /**
@@ -49,7 +49,7 @@
    * @param treeModifications modifications which should be applied to the base tree
    */
   public void addTreeModifications(List<TreeModification> treeModifications) {
-    checkNotNull(treeModifications, "treeModifications must not be null");
+    requireNonNull(treeModifications, "treeModifications must not be null");
     this.treeModifications.addAll(treeModifications);
   }
 
diff --git a/java/com/google/gerrit/server/events/ChangeDeletedEvent.java b/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
new file mode 100644
index 0000000..63142fd
--- /dev/null
+++ b/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class ChangeDeletedEvent extends ChangeEvent {
+  public static final String TYPE = "change-deleted";
+  public Supplier<AccountAttribute> deleter;
+
+  public ChangeDeletedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/ChangeEvent.java b/java/com/google/gerrit/server/events/ChangeEvent.java
index 6029ded..95fdd77 100644
--- a/java/com/google/gerrit/server/events/ChangeEvent.java
+++ b/java/com/google/gerrit/server/events/ChangeEvent.java
@@ -29,7 +29,7 @@
   protected ChangeEvent(String type, Change change) {
     super(type);
     this.project = change.getProject();
-    this.refName = RefNames.fullName(change.getDest().get());
+    this.refName = RefNames.fullName(change.getDest().branch());
     this.changeKey = change.getKey();
   }
 
diff --git a/java/com/google/gerrit/server/events/Event.java b/java/com/google/gerrit/server/events/Event.java
index 20fbe2f..c07987a 100644
--- a/java/com/google/gerrit/server/events/Event.java
+++ b/java/com/google/gerrit/server/events/Event.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
 
 public abstract class Event {
   public final String type;
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index 7d35070..423f5af 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -16,14 +16,12 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -31,12 +29,13 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
+import com.google.gson.Gson;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 /** Distributes Events to listeners if they are allowed to see them */
@@ -49,46 +48,43 @@
     protected void configure() {
       DynamicItem.itemOf(binder(), EventDispatcher.class);
       DynamicItem.bind(binder(), EventDispatcher.class).to(EventBroker.class);
+
+      bind(Gson.class).annotatedWith(EventGson.class).toProvider(EventGsonProvider.class);
     }
   }
 
   /** Listeners to receive changes as they happen (limited by visibility of user). */
-  protected final DynamicSet<UserScopedEventListener> listeners;
+  protected final PluginSetContext<UserScopedEventListener> listeners;
 
   /** Listeners to receive all changes as they happen. */
-  protected final DynamicSet<EventListener> unrestrictedListeners;
+  protected final PluginSetContext<EventListener> unrestrictedListeners;
 
   private final PermissionBackend permissionBackend;
   protected final ProjectCache projectCache;
 
   protected final ChangeNotes.Factory notesFactory;
 
-  protected final Provider<ReviewDb> dbProvider;
-
   @Inject
   public EventBroker(
-      DynamicSet<UserScopedEventListener> listeners,
-      DynamicSet<EventListener> unrestrictedListeners,
+      PluginSetContext<UserScopedEventListener> listeners,
+      PluginSetContext<EventListener> unrestrictedListeners,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory,
-      Provider<ReviewDb> dbProvider) {
+      ChangeNotes.Factory notesFactory) {
     this.listeners = listeners;
     this.unrestrictedListeners = unrestrictedListeners;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.notesFactory = notesFactory;
-    this.dbProvider = dbProvider;
   }
 
   @Override
-  public void postEvent(Change change, ChangeEvent event)
-      throws OrmException, PermissionBackendException {
+  public void postEvent(Change change, ChangeEvent event) throws PermissionBackendException {
     fireEvent(change, event);
   }
 
   @Override
-  public void postEvent(Branch.NameKey branchName, RefEvent event)
+  public void postEvent(BranchNameKey branchName, RefEvent event)
       throws PermissionBackendException {
     fireEvent(branchName, event);
   }
@@ -99,49 +95,50 @@
   }
 
   @Override
-  public void postEvent(Event event) throws OrmException, PermissionBackendException {
+  public void postEvent(Event event) throws PermissionBackendException {
     fireEvent(event);
   }
 
   protected void fireEventForUnrestrictedListeners(Event event) {
-    for (EventListener listener : unrestrictedListeners) {
-      listener.onEvent(event);
-    }
+    unrestrictedListeners.runEach(l -> l.onEvent(event));
   }
 
-  protected void fireEvent(Change change, ChangeEvent event)
-      throws OrmException, PermissionBackendException {
-    for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(change, listener.getUser())) {
-        listener.onEvent(event);
+  protected void fireEvent(Change change, ChangeEvent event) throws PermissionBackendException {
+    for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
+      CurrentUser user = c.call(UserScopedEventListener::getUser);
+      if (isVisibleTo(change, user)) {
+        c.run(l -> l.onEvent(event));
       }
     }
     fireEventForUnrestrictedListeners(event);
   }
 
   protected void fireEvent(Project.NameKey project, ProjectEvent event) {
-    for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(project, listener.getUser())) {
-        listener.onEvent(event);
+    for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
+      CurrentUser user = c.call(UserScopedEventListener::getUser);
+      if (isVisibleTo(project, user)) {
+        c.run(l -> l.onEvent(event));
       }
     }
     fireEventForUnrestrictedListeners(event);
   }
 
-  protected void fireEvent(Branch.NameKey branchName, RefEvent event)
+  protected void fireEvent(BranchNameKey branchName, RefEvent event)
       throws PermissionBackendException {
-    for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(branchName, listener.getUser())) {
-        listener.onEvent(event);
+    for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
+      CurrentUser user = c.call(UserScopedEventListener::getUser);
+      if (isVisibleTo(branchName, user)) {
+        c.run(l -> l.onEvent(event));
       }
     }
     fireEventForUnrestrictedListeners(event);
   }
 
-  protected void fireEvent(Event event) throws OrmException, PermissionBackendException {
-    for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(event, listener.getUser())) {
-        listener.onEvent(event);
+  protected void fireEvent(Event event) throws PermissionBackendException {
+    for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
+      CurrentUser user = c.call(UserScopedEventListener::getUser);
+      if (isVisibleTo(event, user)) {
+        c.run(l -> l.onEvent(event));
       }
     }
     fireEventForUnrestrictedListeners(event);
@@ -161,8 +158,7 @@
     }
   }
 
-  protected boolean isVisibleTo(Change change, CurrentUser user)
-      throws OrmException, PermissionBackendException {
+  protected boolean isVisibleTo(Change change, CurrentUser user) throws PermissionBackendException {
     if (change == null) {
       return false;
     }
@@ -170,40 +166,44 @@
     if (pe == null || !pe.statePermitsRead()) {
       return false;
     }
-    ReviewDb db = dbProvider.get();
-    return permissionBackend
-        .user(user)
-        .change(notesFactory.createChecked(db, change))
-        .database(db)
-        .test(ChangePermission.READ);
-  }
-
-  protected boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user)
-      throws PermissionBackendException {
-    ProjectState pe = projectCache.get(branchName.getParentKey());
-    if (pe == null) {
+    try {
+      permissionBackend
+          .user(user)
+          .change(notesFactory.createChecked(change))
+          .check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
       return false;
     }
-    return pe.statePermitsRead()
-        && permissionBackend.user(user).ref(branchName).test(RefPermission.READ);
   }
 
-  protected boolean isVisibleTo(Event event, CurrentUser user)
-      throws OrmException, PermissionBackendException {
+  protected boolean isVisibleTo(BranchNameKey branchName, CurrentUser user)
+      throws PermissionBackendException {
+    ProjectState pe = projectCache.get(branchName.project());
+    if (pe == null || !pe.statePermitsRead()) {
+      return false;
+    }
+
+    try {
+      permissionBackend.user(user).ref(branchName).check(RefPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+
+  protected boolean isVisibleTo(Event event, CurrentUser user) throws PermissionBackendException {
     if (event instanceof RefEvent) {
       RefEvent refEvent = (RefEvent) event;
       String ref = refEvent.getRefName();
       if (PatchSet.isChangeRef(ref)) {
-        Change.Id cid = PatchSet.Id.fromRef(ref).getParentKey();
+        Change.Id cid = PatchSet.Id.fromRef(ref).changeId();
         try {
-          Change change =
-              notesFactory
-                  .createChecked(dbProvider.get(), refEvent.getProjectNameKey(), cid)
-                  .getChange();
+          Change change = notesFactory.createChecked(refEvent.getProjectNameKey(), cid).getChange();
           return isVisibleTo(change, user);
         } catch (NoSuchChangeException e) {
           logger.atFine().log(
-              "Change %s cannot be found, falling back on ref visibility check", cid.id);
+              "Change %s cannot be found, falling back on ref visibility check", cid.get());
         }
       }
       return isVisibleTo(refEvent.getBranchNameKey(), user);
diff --git a/java/com/google/gerrit/server/events/EventDispatcher.java b/java/com/google/gerrit/server/events/EventDispatcher.java
index cbf547e..ab84acc 100644
--- a/java/com/google/gerrit/server/events/EventDispatcher.java
+++ b/java/com/google/gerrit/server/events/EventDispatcher.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 
 /** Interface for posting (dispatching) Events */
 public interface EventDispatcher {
@@ -27,10 +26,9 @@
    *
    * @param change The change that the event is related to
    * @param event The event to post
-   * @throws OrmException on failure to post the event due to DB error
    * @throws PermissionBackendException on failure of permission checks
    */
-  void postEvent(Change change, ChangeEvent event) throws OrmException, PermissionBackendException;
+  void postEvent(Change change, ChangeEvent event) throws PermissionBackendException;
 
   /**
    * Post a stream event that is related to a branch
@@ -39,7 +37,7 @@
    * @param event The event to post
    * @throws PermissionBackendException on failure of permission checks
    */
-  void postEvent(Branch.NameKey branchName, RefEvent event) throws PermissionBackendException;
+  void postEvent(BranchNameKey branchName, RefEvent event) throws PermissionBackendException;
 
   /**
    * Post a stream event that is related to a project.
@@ -56,8 +54,7 @@
    * specific postEvent methods for those use cases.
    *
    * @param event The event to post.
-   * @throws OrmException on failure to post the event due to DB error
    * @throws PermissionBackendException on failure of permission checks
    */
-  void postEvent(Event event) throws OrmException, PermissionBackendException;
+  void postEvent(Event event) throws PermissionBackendException;
 }
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index f675dd5..bc91807 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -14,20 +14,21 @@
 
 package com.google.gerrit.server.events;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -35,14 +36,13 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.UserIdentity;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
@@ -64,8 +64,6 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -73,7 +71,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;
@@ -87,40 +84,37 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AccountCache accountCache;
+  private final DynamicItem<UrlFormatter> urlFormatter;
   private final Emails emails;
-  private final Provider<String> urlProvider;
   private final PatchListCache patchListCache;
-  private final PersonIdent myIdent;
+  private final Provider<PersonIdent> myIdent;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeKindCache changeKindCache;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final SchemaFactory<ReviewDb> schema;
   private final IndexConfig indexConfig;
 
   @Inject
   EventFactory(
       AccountCache accountCache,
       Emails emails,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      DynamicItem<UrlFormatter> urlFormatter,
       PatchListCache patchListCache,
-      @GerritPersonIdent PersonIdent myIdent,
+      @GerritPersonIdent Provider<PersonIdent> myIdent,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       ChangeKindCache changeKindCache,
       Provider<InternalChangeQuery> queryProvider,
-      SchemaFactory<ReviewDb> schema,
       IndexConfig indexConfig) {
     this.accountCache = accountCache;
+    this.urlFormatter = urlFormatter;
     this.emails = emails;
-    this.urlProvider = urlProvider;
     this.patchListCache = patchListCache;
     this.myIdent = myIdent;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.changeKindCache = changeKindCache;
     this.queryProvider = queryProvider;
-    this.schema = schema;
     this.indexConfig = indexConfig;
   }
 
@@ -131,31 +125,15 @@
    * @return object suitable for serialization to JSON
    */
   public ChangeAttribute asChangeAttribute(Change change) {
-    try (ReviewDb db = schema.open()) {
-      return asChangeAttribute(db, change);
-    } catch (OrmException e) {
-      logger.atSevere().withCause(e).log("Cannot open database connection");
-      return new ChangeAttribute();
-    }
-  }
-
-  /**
-   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
-   *
-   * @param db Review database
-   * @param change
-   * @return object suitable for serialization to JSON
-   */
-  public ChangeAttribute asChangeAttribute(ReviewDb db, Change change) {
     ChangeAttribute a = new ChangeAttribute();
     a.project = change.getProject().get();
-    a.branch = change.getDest().getShortName();
+    a.branch = change.getDest().shortName();
     a.topic = change.getTopic();
     a.id = change.getKey().get();
     a.number = change.getId().get();
     a.subject = change.getSubject();
     try {
-      a.commitMessage = changeDataFactory.create(db, change).commitMessage();
+      a.commitMessage = changeDataFactory.create(change).commitMessage();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log(
           "Error while getting full commit message for change %d", a.number);
@@ -171,6 +149,22 @@
   }
 
   /**
+   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
+   *
+   * @param change
+   * @param notes
+   * @return object suitable for serialization to JSON
+   */
+  public ChangeAttribute asChangeAttribute(Change change, ChangeNotes notes) {
+    ChangeAttribute a = asChangeAttribute(change);
+    Set<String> hashtags = notes.load().getHashtags();
+    if (!hashtags.isEmpty()) {
+      a.hashtags = new ArrayList<>(hashtags.size());
+      a.hashtags.addAll(hashtags);
+    }
+    return a;
+  }
+  /**
    * Create a RefUpdateAttribute for the given old ObjectId, new ObjectId, and branch that is
    * suitable for serialization to JSON.
    *
@@ -180,12 +174,12 @@
    * @return object suitable for serialization to JSON
    */
   public RefUpdateAttribute asRefUpdateAttribute(
-      ObjectId oldId, ObjectId newId, Branch.NameKey refName) {
+      ObjectId oldId, ObjectId newId, BranchNameKey refName) {
     RefUpdateAttribute ru = new RefUpdateAttribute();
     ru.newRev = newId != null ? newId.getName() : ObjectId.zeroId().getName();
     ru.oldRev = oldId != null ? oldId.getName() : ObjectId.zeroId().getName();
-    ru.project = refName.getParentKey().get();
-    ru.refName = refName.get();
+    ru.project = refName.project().get();
+    ru.refName = refName.branch();
     return ru;
   }
 
@@ -197,7 +191,7 @@
    */
   public void extend(ChangeAttribute a, Change change) {
     a.lastUpdated = change.getLastUpdatedOn().getTime() / 1000L;
-    a.open = change.getStatus().isOpen();
+    a.open = change.isNew();
   }
 
   /**
@@ -206,9 +200,8 @@
    * @param a
    * @param notes
    */
-  public void addAllReviewers(ReviewDb db, ChangeAttribute a, ChangeNotes notes)
-      throws OrmException {
-    Collection<Account.Id> reviewers = approvalsUtil.getReviewers(db, notes).all();
+  public void addAllReviewers(ChangeAttribute a, ChangeNotes notes) {
+    Collection<Account.Id> reviewers = approvalsUtil.getReviewers(notes).all();
     if (!reviewers.isEmpty()) {
       a.allReviewers = Lists.newArrayListWithCapacity(reviewers.size());
       for (Account.Id id : reviewers) {
@@ -278,7 +271,7 @@
     try {
       addDependsOn(rw, ca, change, currentPs);
       addNeededBy(rw, ca, change, currentPs);
-    } catch (OrmException | IOException e) {
+    } catch (StorageException | IOException e) {
       // Squash DB exceptions and leave dependency lists partially filled.
     }
     // Remove empty lists so a confusing label won't be displayed in the output.
@@ -291,8 +284,8 @@
   }
 
   private void addDependsOn(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
-      throws OrmException, IOException {
-    RevCommit commit = rw.parseCommit(ObjectId.fromString(currentPs.getRevision().get()));
+      throws IOException {
+    RevCommit commit = rw.parseCommit(currentPs.commitId());
     final List<String> parentNames = new ArrayList<>(commit.getParentCount());
     for (RevCommit p : commit.getParents()) {
       parentNames.add(p.name());
@@ -303,18 +296,17 @@
     for (ChangeData cd : queryProvider.get().byProjectCommits(change.getProject(), parentNames)) {
       for (PatchSet ps : cd.patchSets()) {
         for (String p : parentNames) {
-          if (!ps.getRevision().get().equals(p)) {
+          if (!ps.commitId().name().equals(p)) {
             continue;
           }
-          ca.dependsOn.add(newDependsOn(checkNotNull(cd.change()), ps));
+          ca.dependsOn.add(newDependsOn(requireNonNull(cd.change()), ps));
         }
       }
     }
     // 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;
@@ -325,24 +317,24 @@
   }
 
   private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
-      throws OrmException, IOException {
-    if (currentPs.getGroups().isEmpty()) {
+      throws IOException {
+    if (currentPs.groups().isEmpty()) {
       return;
     }
-    String rev = currentPs.getRevision().get();
+    String rev = currentPs.commitId().name();
     // Find changes in the same related group as this patch set, having a patch
     // set whose parent matches this patch set's revision.
     for (ChangeData cd :
         InternalChangeQuery.byProjectGroups(
-            queryProvider, indexConfig, change.getProject(), currentPs.getGroups())) {
+            queryProvider, indexConfig, change.getProject(), currentPs.groups())) {
       PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
-        RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+        RevCommit commit = rw.parseCommit(ps.commitId());
         for (RevCommit p : commit.getParents()) {
           if (!p.name().equals(rev)) {
             continue;
           }
-          ca.neededBy.add(newNeededBy(checkNotNull(cd.change()), ps));
+          ca.neededBy.add(newNeededBy(requireNonNull(cd.change()), ps));
           continue PATCH_SETS;
         }
       }
@@ -351,7 +343,7 @@
 
   private DependencyAttribute newDependsOn(Change c, PatchSet ps) {
     DependencyAttribute d = newDependencyAttribute(c, ps);
-    d.isCurrentPatchSet = ps.getId().equals(c.currentPatchSetId());
+    d.isCurrentPatchSet = ps.id().equals(c.currentPatchSetId());
     return d;
   }
 
@@ -363,8 +355,8 @@
     DependencyAttribute d = new DependencyAttribute();
     d.number = c.getId().get();
     d.id = c.getKey().toString();
-    d.revision = ps.getRevision().get();
-    d.ref = ps.getRefName();
+    d.revision = ps.commitId().name();
+    d.ref = ps.refName();
     return d;
   }
 
@@ -387,17 +379,15 @@
   }
 
   public void addPatchSets(
-      ReviewDb db,
       RevWalk revWalk,
       ChangeAttribute ca,
       Collection<PatchSet> ps,
       Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
       LabelTypes labelTypes) {
-    addPatchSets(db, revWalk, ca, ps, approvals, false, null, labelTypes);
+    addPatchSets(revWalk, ca, ps, approvals, false, null, labelTypes);
   }
 
   public void addPatchSets(
-      ReviewDb db,
       RevWalk revWalk,
       ChangeAttribute ca,
       Collection<PatchSet> ps,
@@ -408,9 +398,9 @@
     if (!ps.isEmpty()) {
       ca.patchSets = new ArrayList<>(ps.size());
       for (PatchSet p : ps) {
-        PatchSetAttribute psa = asPatchSetAttribute(db, revWalk, change, p);
+        PatchSetAttribute psa = asPatchSetAttribute(revWalk, change, p);
         if (approvals != null) {
-          addApprovals(psa, p.getId(), approvals, labelTypes);
+          addApprovals(psa, p.id(), approvals, labelTypes);
         }
         ca.patchSets.add(psa);
         if (includeFiles) {
@@ -468,35 +458,17 @@
   /**
    * Create a PatchSetAttribute for the given patchset suitable for serialization to JSON.
    *
-   * @param revWalk
    * @param patchSet
    * @return object suitable for serialization to JSON
    */
   public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
-    try (ReviewDb db = schema.open()) {
-      return asPatchSetAttribute(db, revWalk, change, patchSet);
-    } catch (OrmException e) {
-      logger.atSevere().withCause(e).log("Cannot open database connection");
-      return new PatchSetAttribute();
-    }
-  }
-
-  /**
-   * Create a PatchSetAttribute for the given patchset suitable for serialization to JSON.
-   *
-   * @param db Review database
-   * @param patchSet
-   * @return object suitable for serialization to JSON
-   */
-  public PatchSetAttribute asPatchSetAttribute(
-      ReviewDb db, RevWalk revWalk, Change change, PatchSet patchSet) {
     PatchSetAttribute p = new PatchSetAttribute();
-    p.revision = patchSet.getRevision().get();
-    p.number = patchSet.getPatchSetId();
-    p.ref = patchSet.getRefName();
-    p.uploader = asAccountAttribute(patchSet.getUploader());
-    p.createdOn = patchSet.getCreatedOn().getTime() / 1000L;
-    PatchSet.Id pId = patchSet.getId();
+    p.revision = patchSet.commitId().name();
+    p.number = patchSet.number();
+    p.ref = patchSet.refName();
+    p.uploader = asAccountAttribute(patchSet.uploader());
+    p.createdOn = patchSet.createdOn().getTime() / 1000L;
+    PatchSet.Id pId = patchSet.id();
     try {
       p.parents = new ArrayList<>();
       RevCommit c = revWalk.parseCommit(ObjectId.fromString(p.revision));
@@ -521,9 +493,9 @@
           p.sizeInsertions += pe.getInsertions();
         }
       }
-      p.kind = changeKindCache.getChangeKind(db, change, patchSet);
-    } catch (IOException | OrmException e) {
-      logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.getId());
+      p.kind = changeKindCache.getChangeKind(change, patchSet);
+    } catch (IOException | StorageException e) {
+      logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Cannot get size information for %s: %s", pId, e.getMessage());
     } catch (PatchListNotAvailableException e) {
@@ -534,7 +506,7 @@
 
   // TODO: The same method exists in PatchSetInfoFactory, find a common place
   // for it
-  private UserIdentity toUserIdentity(PersonIdent who) throws IOException, OrmException {
+  private UserIdentity toUserIdentity(PersonIdent who) throws IOException {
     UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
@@ -568,7 +540,7 @@
     if (!list.isEmpty()) {
       p.approvals = new ArrayList<>(list.size());
       for (PatchSetApproval a : list) {
-        if (a.getValue() != 0) {
+        if (a.value() != 0) {
           p.approvals.add(asApprovalAttribute(a, labelTypes));
         }
       }
@@ -599,8 +571,8 @@
    */
   public AccountAttribute asAccountAttribute(AccountState accountState) {
     AccountAttribute who = new AccountAttribute();
-    who.name = accountState.getAccount().getFullName();
-    who.email = accountState.getAccount().getPreferredEmail();
+    who.name = accountState.getAccount().fullName();
+    who.email = accountState.getAccount().preferredEmail();
     who.username = accountState.getUserName().orElse(null);
     return who;
   }
@@ -627,13 +599,13 @@
    */
   public ApprovalAttribute asApprovalAttribute(PatchSetApproval approval, LabelTypes labelTypes) {
     ApprovalAttribute a = new ApprovalAttribute();
-    a.type = approval.getLabelId().get();
-    a.value = Short.toString(approval.getValue());
-    a.by = asAccountAttribute(approval.getAccountId());
-    a.grantedOn = approval.getGranted().getTime() / 1000L;
+    a.type = approval.labelId().get();
+    a.value = Short.toString(approval.value());
+    a.by = asAccountAttribute(approval.accountId());
+    a.grantedOn = approval.granted().getTime() / 1000L;
     a.oldValue = null;
 
-    LabelType lt = labelTypes.byLabel(approval.getLabelId());
+    LabelType lt = labelTypes.byLabel(approval.labelId());
     if (lt != null) {
       a.description = lt.getName();
     }
@@ -646,7 +618,7 @@
     a.reviewer =
         message.getAuthor() != null
             ? asAccountAttribute(message.getAuthor())
-            : asAccountAttribute(myIdent);
+            : asAccountAttribute(myIdent.get());
     a.message = message.getMessage();
     return a;
   }
@@ -662,11 +634,8 @@
 
   /** Get a link to the change; null if the server doesn't know its own address. */
   private String getChangeUrl(Change change) {
-    if (change != null && urlProvider.get() != null) {
-      StringBuilder r = new StringBuilder();
-      r.append(urlProvider.get());
-      r.append(change.getChangeId());
-      return r.toString();
+    if (change != null) {
+      return urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId()).orElse(null);
     }
     return null;
   }
diff --git a/java/com/google/gerrit/server/events/EventGson.java b/java/com/google/gerrit/server/events/EventGson.java
new file mode 100644
index 0000000..87b45f6
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventGson.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@BindingAnnotation
+@Retention(RUNTIME)
+@Target({PARAMETER, FIELD})
+public @interface EventGson {}
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
new file mode 100644
index 0000000..2fe526b
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.ChangeKeyAdapter;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.inject.Provider;
+
+public class EventGsonProvider implements Provider<Gson> {
+  @Override
+  public Gson get() {
+    return new GsonBuilder()
+        .registerTypeAdapter(Event.class, new EventDeserializer())
+        .registerTypeAdapter(Supplier.class, new SupplierSerializer())
+        .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
+        .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
+        .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
+        .create();
+  }
+}
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index cd2b464..5498ec8 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -24,6 +24,7 @@
   static {
     register(AssigneeChangedEvent.TYPE, AssigneeChangedEvent.class);
     register(ChangeAbandonedEvent.TYPE, ChangeAbandonedEvent.class);
+    register(ChangeDeletedEvent.TYPE, ChangeDeletedEvent.class);
     register(ChangeMergedEvent.TYPE, ChangeMergedEvent.class);
     register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
     register(CommentAddedEvent.TYPE, CommentAddedEvent.class);
diff --git a/java/com/google/gerrit/server/events/EventsMetrics.java b/java/com/google/gerrit/server/events/EventsMetrics.java
index f73d6de..3c87cca 100644
--- a/java/com/google/gerrit/server/events/EventsMetrics.java
+++ b/java/com/google/gerrit/server/events/EventsMetrics.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -31,7 +32,7 @@
         metricMaker.newCounter(
             "events",
             new Description("Triggered events").setRate().setUnit("triggered events"),
-            Field.ofString("type"));
+            Field.ofString("type", Metadata.Builder::eventType).build());
   }
 
   @Override
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/ProjectCreatedEvent.java b/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
index dc979ca..42b6676 100644
--- a/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
+++ b/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
@@ -27,7 +27,7 @@
 
   @Override
   public Project.NameKey getProjectNameKey() {
-    return new Project.NameKey(projectName);
+    return Project.nameKey(projectName);
   }
 
   public String getHeadName() {
diff --git a/java/com/google/gerrit/server/events/ProjectNameKeyAdapter.java b/java/com/google/gerrit/server/events/ProjectNameKeyAdapter.java
new file mode 100644
index 0000000..29f2768
--- /dev/null
+++ b/java/com/google/gerrit/server/events/ProjectNameKeyAdapter.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import java.lang.reflect.Type;
+
+public class ProjectNameKeyAdapter
+    implements JsonSerializer<Project.NameKey>, JsonDeserializer<Project.NameKey> {
+  @Override
+  public JsonElement serialize(
+      Project.NameKey project, Type typeOfSrc, JsonSerializationContext context) {
+    return new JsonPrimitive(project.get());
+  }
+
+  @Override
+  public Project.NameKey deserialize(
+      JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (!json.isJsonPrimitive() || !json.getAsJsonPrimitive().isString()) {
+      throw new JsonParseException("Key is not a string: " + json);
+    }
+    return Project.nameKey(json.getAsString());
+  }
+}
diff --git a/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java b/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
deleted file mode 100644
index 743b314..0000000
--- a/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.events;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonPrimitive;
-import com.google.gson.JsonSerializationContext;
-import com.google.gson.JsonSerializer;
-import java.lang.reflect.Type;
-
-public class ProjectNameKeySerializer implements JsonSerializer<Project.NameKey> {
-  @Override
-  public JsonElement serialize(
-      Project.NameKey project, Type typeOfSrc, JsonSerializationContext context) {
-    return new JsonPrimitive(project.get());
-  }
-}
diff --git a/java/com/google/gerrit/server/events/RefEvent.java b/java/com/google/gerrit/server/events/RefEvent.java
index 951940f..3a8d246 100644
--- a/java/com/google/gerrit/server/events/RefEvent.java
+++ b/java/com/google/gerrit/server/events/RefEvent.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 
 public abstract class RefEvent extends ProjectEvent {
   protected RefEvent(String type) {
     super(type);
   }
 
-  public Branch.NameKey getBranchNameKey() {
-    return new Branch.NameKey(getProjectNameKey(), getRefName());
+  public BranchNameKey getBranchNameKey() {
+    return BranchNameKey.create(getProjectNameKey(), getRefName());
   }
 
   public abstract String getRefName();
diff --git a/java/com/google/gerrit/server/events/RefUpdatedEvent.java b/java/com/google/gerrit/server/events/RefUpdatedEvent.java
index d740543..fa16c4c 100644
--- a/java/com/google/gerrit/server/events/RefUpdatedEvent.java
+++ b/java/com/google/gerrit/server/events/RefUpdatedEvent.java
@@ -30,7 +30,7 @@
 
   @Override
   public Project.NameKey getProjectNameKey() {
-    return new Project.NameKey(refUpdate.get().project);
+    return Project.nameKey(refUpdate.get().project);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 9592238..85ef149 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -20,12 +20,14 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -39,34 +41,28 @@
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.VoteDeletedListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.PatchSetAttribute;
-import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Map.Entry;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -75,6 +71,7 @@
 public class StreamEventsApiListener
     implements AssigneeChangedListener,
         ChangeAbandonedListener,
+        ChangeDeletedListener,
         ChangeMergedListener,
         ChangeRestoredListener,
         WorkInProgressStateChangedListener,
@@ -95,6 +92,7 @@
     protected void configure() {
       DynamicSet.bind(binder(), AssigneeChangedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeRestoredListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), CommentAddedListener.class).to(StreamEventsApiListener.class);
@@ -114,8 +112,7 @@
     }
   }
 
-  private final DynamicItem<EventDispatcher> dispatcher;
-  private final Provider<ReviewDb> db;
+  private final PluginItemContext<EventDispatcher> dispatcher;
   private final EventFactory eventFactory;
   private final ProjectCache projectCache;
   private final GitRepositoryManager repoManager;
@@ -124,15 +121,13 @@
 
   @Inject
   StreamEventsApiListener(
-      DynamicItem<EventDispatcher> dispatcher,
-      Provider<ReviewDb> db,
+      PluginItemContext<EventDispatcher> dispatcher,
       EventFactory eventFactory,
       ProjectCache projectCache,
       GitRepositoryManager repoManager,
       PatchSetUtil psUtil,
       ChangeNotes.Factory changeNotesFactory) {
     this.dispatcher = dispatcher;
-    this.db = db;
     this.eventFactory = eventFactory;
     this.projectCache = projectCache;
     this.repoManager = repoManager;
@@ -140,63 +135,53 @@
     this.changeNotesFactory = changeNotesFactory;
   }
 
-  private ChangeNotes getNotes(ChangeInfo info) throws OrmException {
+  private ChangeNotes getNotes(ChangeInfo info) {
     try {
-      return changeNotesFactory.createChecked(new Change.Id(info._number));
+      return changeNotesFactory.createChecked(Change.id(info._number));
     } catch (NoSuchChangeException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
-  private Change getChange(ChangeInfo info) throws OrmException {
-    return getNotes(info).getChange();
+  private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info) {
+    return psUtil.get(notes, PatchSet.Id.fromRef(info.ref));
   }
 
-  private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info) throws OrmException {
-    return psUtil.get(db.get(), notes, PatchSet.Id.fromRef(info.ref));
-  }
-
-  private Supplier<ChangeAttribute> changeAttributeSupplier(Change change) {
+  private Supplier<ChangeAttribute> changeAttributeSupplier(Change change, ChangeNotes notes) {
     return Suppliers.memoize(
-        new Supplier<ChangeAttribute>() {
-          @Override
-          public ChangeAttribute get() {
-            return eventFactory.asChangeAttribute(change);
+        () -> {
+          try {
+            return eventFactory.asChangeAttribute(change, notes);
+          } catch (StorageException e) {
+            throw new RuntimeException(e);
           }
         });
   }
 
   private Supplier<AccountAttribute> accountAttributeSupplier(AccountInfo account) {
     return Suppliers.memoize(
-        new Supplier<AccountAttribute>() {
-          @Override
-          public AccountAttribute get() {
-            return account != null
-                ? eventFactory.asAccountAttribute(new Account.Id(account._accountId))
-                : null;
-          }
-        });
+        () ->
+            account != null
+                ? eventFactory.asAccountAttribute(Account.id(account._accountId))
+                : null);
   }
 
   private Supplier<PatchSetAttribute> patchSetAttributeSupplier(
       final Change change, PatchSet patchSet) {
     return Suppliers.memoize(
-        new Supplier<PatchSetAttribute>() {
-          @Override
-          public PatchSetAttribute get() {
-            try (Repository repo = repoManager.openRepository(change.getProject());
-                RevWalk revWalk = new RevWalk(repo)) {
-              return eventFactory.asPatchSetAttribute(revWalk, change, patchSet);
-            } catch (IOException e) {
-              throw new RuntimeException(e);
-            }
+        () -> {
+          try (Repository repo = repoManager.openRepository(change.getProject());
+              RevWalk revWalk = new RevWalk(repo)) {
+            return eventFactory.asPatchSetAttribute(revWalk, change, patchSet);
+          } catch (IOException e) {
+            throw new RuntimeException(e);
           }
         });
   }
 
   private static Map<String, Short> convertApprovalsMap(Map<String, ApprovalInfo> approvals) {
     Map<String, Short> result = new HashMap<>();
-    for (Entry<String, ApprovalInfo> e : approvals.entrySet()) {
+    for (Map.Entry<String, ApprovalInfo> e : approvals.entrySet()) {
       Short value = e.getValue().value == null ? null : e.getValue().value.shortValue();
       result.put(e.getKey(), value);
     }
@@ -204,7 +189,7 @@
   }
 
   private ApprovalAttribute getApprovalAttribute(
-      LabelTypes labelTypes, Entry<String, Short> approval, Map<String, Short> oldApprovals) {
+      LabelTypes labelTypes, Map.Entry<String, Short> approval, Map<String, Short> oldApprovals) {
     ApprovalAttribute a = new ApprovalAttribute();
     a.type = approval.getKey();
 
@@ -229,21 +214,18 @@
       final Map<String, ApprovalInfo> oldApprovals) {
     final Map<String, Short> approvals = convertApprovalsMap(newApprovals);
     return Suppliers.memoize(
-        new Supplier<ApprovalAttribute[]>() {
-          @Override
-          public ApprovalAttribute[] get() {
-            LabelTypes labelTypes = projectCache.get(change.getProject()).getLabelTypes();
-            if (approvals.size() > 0) {
-              ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
-              int i = 0;
-              for (Map.Entry<String, Short> approval : approvals.entrySet()) {
-                r[i++] =
-                    getApprovalAttribute(labelTypes, approval, convertApprovalsMap(oldApprovals));
-              }
-              return r;
+        () -> {
+          LabelTypes labelTypes = projectCache.get(change.getProject()).getLabelTypes();
+          if (approvals.size() > 0) {
+            ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
+            int i = 0;
+            for (Map.Entry<String, Short> approval : approvals.entrySet()) {
+              r[i++] =
+                  getApprovalAttribute(labelTypes, approval, convertApprovalsMap(oldApprovals));
             }
-            return null;
+            return r;
           }
+          return null;
         });
   }
 
@@ -257,15 +239,16 @@
   @Override
   public void onAssigneeChanged(AssigneeChangedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
       AssigneeChangedEvent event = new AssigneeChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
       event.oldAssignee = accountAttributeSupplier(ev.getOldAssignee());
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -273,15 +256,16 @@
   @Override
   public void onTopicEdited(TopicEditedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
       TopicChangedEvent event = new TopicChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
       event.oldTopic = ev.getOldTopic();
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -294,12 +278,12 @@
       PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.patchSet = patchSetAttributeSupplier(change, patchSet);
       event.uploader = accountAttributeSupplier(ev.getWho());
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -310,16 +294,16 @@
       ChangeNotes notes = getNotes(ev.getChange());
       Change change = notes.getChange();
       ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.change = changeAttributeSupplier(change, notes);
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
       event.remover = accountAttributeSupplier(ev.getWho());
       event.comment = ev.getComment();
       event.approvals =
           approvalsAttributeSupplier(change, ev.getNewApprovals(), ev.getOldApprovals());
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -331,13 +315,13 @@
       Change change = notes.getChange();
       ReviewerAddedEvent event = new ReviewerAddedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.change = changeAttributeSupplier(change, notes);
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
       for (AccountInfo reviewer : ev.getReviewers()) {
         event.reviewer = accountAttributeSupplier(reviewer);
-        dispatcher.get().postEvent(change, event);
+        dispatcher.run(d -> d.postEvent(event));
       }
-    } catch (OrmException | PermissionBackendException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -348,23 +332,24 @@
     event.projectName = ev.getProjectName();
     event.headName = ev.getHeadName();
 
-    dispatcher.get().postEvent(event.getProjectNameKey(), event);
+    dispatcher.run(d -> d.postEvent(event.getProjectNameKey(), event));
   }
 
   @Override
   public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
       HashtagsChangedEvent event = new HashtagsChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.editor = accountAttributeSupplier(ev.getWho());
       event.hashtags = hashtagArray(ev.getHashtags());
       event.added = hashtagArray(ev.getAddedHashtags());
       event.removed = hashtagArray(ev.getRemovedHashtags());
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -375,23 +360,15 @@
     if (ev.getUpdater() != null) {
       event.submitter = accountAttributeSupplier(ev.getUpdater());
     }
-    final Branch.NameKey refName = new Branch.NameKey(ev.getProjectName(), ev.getRefName());
+    final BranchNameKey refName = BranchNameKey.create(ev.getProjectName(), ev.getRefName());
     event.refUpdate =
         Suppliers.memoize(
-            new Supplier<RefUpdateAttribute>() {
-              @Override
-              public RefUpdateAttribute get() {
-                return eventFactory.asRefUpdateAttribute(
+            () ->
+                eventFactory.asRefUpdateAttribute(
                     ObjectId.fromString(ev.getOldObjectId()),
                     ObjectId.fromString(ev.getNewObjectId()),
-                    refName);
-              }
-            });
-    try {
-      dispatcher.get().postEvent(refName, event);
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log("Failed to dispatch event");
-    }
+                    refName));
+    dispatcher.run(d -> d.postEvent(refName, event));
   }
 
   @Override
@@ -402,14 +379,14 @@
       PatchSet ps = getPatchSet(notes, ev.getRevision());
       CommentAddedEvent event = new CommentAddedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.author = accountAttributeSupplier(ev.getWho());
       event.patchSet = patchSetAttributeSupplier(change, ps);
       event.comment = ev.getComment();
       event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -421,13 +398,13 @@
       Change change = notes.getChange();
       ChangeRestoredEvent event = new ChangeRestoredEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.restorer = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
       event.reason = ev.getReason();
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -439,13 +416,13 @@
       Change change = notes.getChange();
       ChangeMergedEvent event = new ChangeMergedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.submitter = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
       event.newRev = ev.getNewRevisionId();
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -457,13 +434,13 @@
       Change change = notes.getChange();
       ChangeAbandonedEvent event = new ChangeAbandonedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.abandoner = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
       event.reason = ev.getReason();
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -471,14 +448,17 @@
   @Override
   public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       WorkInProgressStateChangedEvent event = new WorkInProgressStateChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -486,14 +466,17 @@
   @Override
   public void onPrivateStateChanged(PrivateStateChangedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       PrivateStateChangedEvent event = new PrivateStateChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
@@ -505,15 +488,31 @@
       Change change = notes.getChange();
       VoteDeletedEvent event = new VoteDeletedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.change = changeAttributeSupplier(change, notes);
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
       event.comment = ev.getMessage();
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
       event.remover = accountAttributeSupplier(ev.getWho());
       event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
 
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log("Failed to dispatch event");
+    }
+  }
+
+  @Override
+  public void onChangeDeleted(ChangeDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeDeletedEvent event = new ChangeDeletedEvent(change);
+
+      event.change = changeAttributeSupplier(change, notes);
+      event.deleter = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
diff --git a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
index ad32672..5e52c7b 100644
--- a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
+++ b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
-public class WorkInProgressStateChangedEvent extends ChangeEvent {
+public class WorkInProgressStateChangedEvent extends PatchSetEvent {
   static final String TYPE = "wip-state-changed";
   public Supplier<AccountAttribute> changer;
 
diff --git a/java/com/google/gerrit/server/extensions/events/AgreementSignup.java b/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
index 1548c38..b692cf5 100644
--- a/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
+++ b/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
@@ -16,34 +16,28 @@
 
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class AgreementSignup {
-  private final DynamicSet<AgreementSignupListener> listeners;
+  private final PluginSetContext<AgreementSignupListener> listeners;
   private final EventUtil util;
 
   @Inject
-  AgreementSignup(DynamicSet<AgreementSignupListener> listeners, EventUtil util) {
+  AgreementSignup(PluginSetContext<AgreementSignupListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
   public void fire(AccountState accountState, String agreementName) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     Event event = new Event(util.accountInfo(accountState), agreementName);
-    for (AgreementSignupListener l : listeners) {
-      try {
-        l.onAgreementSignup(event);
-      } catch (Exception e) {
-        util.logEventListenerError(this, l, e);
-      }
-    }
+    listeners.runEach(l -> l.onAgreementSignup(event));
   }
 
   private static class Event extends AbstractNoNotifyEvent
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index 7320fd3..fdce1da 100644
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -31,18 +31,18 @@
 public class AssigneeChanged {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<AssigneeChangedListener> listeners;
+  private final PluginSetContext<AssigneeChangedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  AssigneeChanged(DynamicSet<AssigneeChangedListener> listeners, EventUtil util) {
+  AssigneeChanged(PluginSetContext<AssigneeChangedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
   public void fire(
       Change change, AccountState accountState, AccountState oldAssignee, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
@@ -52,14 +52,8 @@
               util.accountInfo(accountState),
               util.accountInfo(oldAssignee),
               when);
-      for (AssigneeChangedListener l : listeners) {
-        try {
-          l.onAssigneeChanged(event);
-        } catch (Exception e) {
-          util.logEventListenerError(event, l, e);
-        }
-      }
-    } catch (OrmException e) {
+      listeners.runEach(l -> l.onAssigneeChanged(event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index 3a19e97..a8c08b9 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 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.ChangeAbandonedListener;
-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;
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -38,11 +38,11 @@
 public class ChangeAbandoned {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<ChangeAbandonedListener> listeners;
+  private final PluginSetContext<ChangeAbandonedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ChangeAbandoned(DynamicSet<ChangeAbandonedListener> listeners, EventUtil util) {
+  ChangeAbandoned(PluginSetContext<ChangeAbandonedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -54,7 +54,7 @@
       String reason,
       Timestamp when,
       NotifyHandling notifyHandling) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
@@ -66,19 +66,13 @@
               reason,
               when,
               notifyHandling);
-      for (ChangeAbandonedListener l : listeners) {
-        try {
-          l.onChangeAbandoned(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
+      listeners.runEach(l -> l.onChangeAbandoned(event));
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Couldn't fire event: %s", e.getMessage());
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
-        | OrmException
+        | StorageException
         | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
new file mode 100644
index 0000000..9e3e979
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+
+@Singleton
+public class ChangeDeleted {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PluginSetContext<ChangeDeletedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeDeleted(PluginSetContext<ChangeDeletedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, AccountState deleter, Timestamp when) {
+    if (listeners.isEmpty()) {
+      return;
+    }
+    try {
+      Event event = new Event(util.changeInfo(change), util.accountInfo(deleter), when);
+      listeners.runEach(l -> l.onChangeDeleted(event));
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log("Couldn't fire event");
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent implements ChangeDeletedListener.Event {
+    Event(ChangeInfo change, AccountInfo deleter, Timestamp when) {
+      super(change, deleter, when, NotifyHandling.ALL);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 5b882b8..756f383 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 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.ChangeMergedListener;
-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;
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -38,18 +38,18 @@
 public class ChangeMerged {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<ChangeMergedListener> listeners;
+  private final PluginSetContext<ChangeMergedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ChangeMerged(DynamicSet<ChangeMergedListener> listeners, EventUtil util) {
+  ChangeMerged(PluginSetContext<ChangeMergedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
   public void fire(
       Change change, PatchSet ps, AccountState merger, String newRevisionId, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
@@ -60,19 +60,13 @@
               util.accountInfo(merger),
               newRevisionId,
               when);
-      for (ChangeMergedListener l : listeners) {
-        try {
-          l.onChangeMerged(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
+      listeners.runEach(l -> l.onChangeMerged(event));
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Couldn't fire event: %s", e.getMessage());
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
-        | OrmException
+        | StorageException
         | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index d62b6c1..e8bed56 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 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.ChangeRestoredListener;
-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;
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -38,18 +38,18 @@
 public class ChangeRestored {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<ChangeRestoredListener> listeners;
+  private final PluginSetContext<ChangeRestoredListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ChangeRestored(DynamicSet<ChangeRestoredListener> listeners, EventUtil util) {
+  ChangeRestored(PluginSetContext<ChangeRestoredListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
   public void fire(
       Change change, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
@@ -60,19 +60,13 @@
               util.accountInfo(restorer),
               reason,
               when);
-      for (ChangeRestoredListener l : listeners) {
-        try {
-          l.onChangeRestored(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
+      listeners.runEach(l -> l.onChangeRestored(event));
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Couldn't fire event: %s", e.getMessage());
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
-        | OrmException
+        | StorageException
         | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index 5f8f8c3..ccb17d5 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -29,29 +29,23 @@
 public class ChangeReverted {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<ChangeRevertedListener> listeners;
+  private final PluginSetContext<ChangeRevertedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ChangeReverted(DynamicSet<ChangeRevertedListener> listeners, EventUtil util) {
+  ChangeReverted(PluginSetContext<ChangeRevertedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
   public void fire(Change change, Change revertChange, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event = new Event(util.changeInfo(change), util.changeInfo(revertChange), when);
-      for (ChangeRevertedListener l : listeners) {
-        try {
-          l.onChangeReverted(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (OrmException e) {
+      listeners.runEach(l -> l.onChangeReverted(event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 8ba9f82..ea9ae31 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
-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;
@@ -29,7 +29,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -40,11 +40,11 @@
 public class CommentAdded {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<CommentAddedListener> listeners;
+  private final PluginSetContext<CommentAddedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  CommentAdded(DynamicSet<CommentAddedListener> listeners, EventUtil util) {
+  CommentAdded(PluginSetContext<CommentAddedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -57,7 +57,7 @@
       Map<String, Short> approvals,
       Map<String, Short> oldApprovals,
       Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
@@ -70,19 +70,13 @@
               util.approvals(author, approvals, when),
               util.approvals(author, oldApprovals, when),
               when);
-      for (CommentAddedListener l : listeners) {
-        try {
-          l.onCommentAdded(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
+      listeners.runEach(l -> l.onCommentAdded(event));
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Couldn't fire event: %s", e.getMessage());
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
-        | OrmException
+        | StorageException
         | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 74fba9a..8b58f4f 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -26,16 +25,14 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -45,8 +42,6 @@
 
 @Singleton
 public class EventUtil {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final ImmutableSet<ListChangesOption> CHANGE_OPTIONS;
 
   static {
@@ -65,44 +60,42 @@
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final Provider<ReviewDb> db;
   private final ChangeJson.Factory changeJsonFactory;
+  private final RevisionJson.Factory revisionJsonFactory;
 
   @Inject
   EventUtil(
       ChangeJson.Factory changeJsonFactory,
-      ChangeData.Factory changeDataFactory,
-      Provider<ReviewDb> db) {
+      RevisionJson.Factory revisionJsonFactory,
+      ChangeData.Factory changeDataFactory) {
     this.changeDataFactory = changeDataFactory;
-    this.db = db;
     this.changeJsonFactory = changeJsonFactory;
+    this.revisionJsonFactory = revisionJsonFactory;
   }
 
-  public ChangeInfo changeInfo(Change change) throws OrmException {
+  public ChangeInfo changeInfo(Change change) {
     return changeJsonFactory.create(CHANGE_OPTIONS).format(change);
   }
 
   public RevisionInfo revisionInfo(Project project, PatchSet ps)
-      throws OrmException, PatchListNotAvailableException, GpgException, IOException,
-          PermissionBackendException {
+      throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
     return revisionInfo(project.getNameKey(), ps);
   }
 
   public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
-      throws OrmException, PatchListNotAvailableException, GpgException, IOException,
-          PermissionBackendException {
-    ChangeData cd = changeDataFactory.create(db.get(), project, ps.getId().getParentKey());
-    return changeJsonFactory.create(CHANGE_OPTIONS).getRevisionInfo(cd, ps);
+      throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
+    ChangeData cd = changeDataFactory.create(project, ps.id().changeId());
+    return revisionJsonFactory.create(CHANGE_OPTIONS).getRevisionInfo(cd, ps);
   }
 
   public AccountInfo accountInfo(AccountState accountState) {
-    if (accountState == null || accountState.getAccount().getId() == null) {
+    if (accountState == null || accountState.getAccount().id() == null) {
       return null;
     }
     Account account = accountState.getAccount();
-    AccountInfo accountInfo = new AccountInfo(account.getId().get());
-    accountInfo.email = account.getPreferredEmail();
-    accountInfo.name = account.getFullName();
+    AccountInfo accountInfo = new AccountInfo(account.id().get());
+    accountInfo.email = account.preferredEmail();
+    accountInfo.name = account.fullName();
     accountInfo.username = accountState.getUserName().orElse(null);
     return accountInfo;
   }
@@ -114,23 +107,8 @@
       Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
       result.put(
           e.getKey(),
-          ChangeJson.getApprovalInfo(accountState.getAccount().getId(), value, null, null, ts));
+          new ApprovalInfo(accountState.getAccount().id().get(), value, null, null, ts));
     }
     return result;
   }
-
-  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());
-    logger.atFine().withCause(error).log(
-        "Cause of error in event listener %s:", listener.getClass().getName());
-  }
-
-  public static void logEventListenerError(Object listener, Exception error) {
-    logger.atWarning().log(
-        "Error in event listener %s: %s", listener.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/GitReferenceUpdated.java b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 61533da..bae17e7 100644
--- a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -17,9 +17,9 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -57,11 +57,11 @@
             Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {}
       };
 
-  private final DynamicSet<GitReferenceUpdatedListener> listeners;
+  private final PluginSetContext<GitReferenceUpdatedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  GitReferenceUpdated(DynamicSet<GitReferenceUpdatedListener> listeners, EventUtil util) {
+  GitReferenceUpdated(PluginSetContext<GitReferenceUpdatedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -121,7 +121,7 @@
   }
 
   public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
@@ -144,19 +144,13 @@
       ObjectId newObjectId,
       ReceiveCommand.Type type,
       AccountInfo updater) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     ObjectId o = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
     ObjectId n = newObjectId != null ? newObjectId : ObjectId.zeroId();
     Event event = new Event(project, ref, o.name(), n.name(), type, updater);
-    for (GitReferenceUpdatedListener l : listeners) {
-      try {
-        l.onGitReferenceUpdated(event);
-      } catch (Exception e) {
-        util.logEventListenerError(this, l, e);
-      }
-    }
+    listeners.runEach(l -> l.onGitReferenceUpdated(event));
   }
 
   public static class Event implements GitReferenceUpdatedListener.Event {
diff --git a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 9a0247a..65f5b8b 100644
--- a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -16,14 +16,14 @@
 
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -34,11 +34,11 @@
 public class HashtagsEdited {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<HashtagsEditedListener> listeners;
+  private final PluginSetContext<HashtagsEditedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  public HashtagsEdited(DynamicSet<HashtagsEditedListener> listeners, EventUtil util) {
+  public HashtagsEdited(PluginSetContext<HashtagsEditedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -50,21 +50,15 @@
       Set<String> added,
       Set<String> removed,
       Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
               util.changeInfo(change), util.accountInfo(editor), hashtags, added, removed, when);
-      for (HashtagsEditedListener l : listeners) {
-        try {
-          l.onHashtagsEdited(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (OrmException e) {
+      listeners.runEach(l -> l.onHashtagsEdited(event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
diff --git a/java/com/google/gerrit/server/extensions/events/PluginEvent.java b/java/com/google/gerrit/server/extensions/events/PluginEvent.java
index 8680ab1..60d27c9 100644
--- a/java/com/google/gerrit/server/extensions/events/PluginEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/PluginEvent.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.gerrit.extensions.events.PluginEventListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class PluginEvent {
-  private final DynamicSet<PluginEventListener> listeners;
+  private final PluginSetContext<PluginEventListener> listeners;
 
   @Inject
-  PluginEvent(DynamicSet<PluginEventListener> listeners) {
+  PluginEvent(PluginSetContext<PluginEventListener> listeners) {
     this.listeners = listeners;
   }
 
@@ -33,9 +33,7 @@
       return;
     }
     Event e = new Event(pluginName, type, data);
-    for (PluginEventListener l : listeners) {
-      l.onPluginEvent(e);
-    }
+    listeners.runEach(l -> l.onPluginEvent(e));
   }
 
   private static class Event extends AbstractNoNotifyEvent implements PluginEventListener.Event {
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index acd275d..49a6091 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -15,54 +15,63 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 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.gwtorm.server.OrmException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
 public class PrivateStateChanged {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<PrivateStateChangedListener> listeners;
+  private final PluginSetContext<PrivateStateChangedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  PrivateStateChanged(DynamicSet<PrivateStateChangedListener> listeners, EventUtil util) {
+  PrivateStateChanged(PluginSetContext<PrivateStateChangedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
-      for (PrivateStateChangedListener l : listeners) {
-        try {
-          l.onPrivateStateChanged(event);
-        } catch (Exception e) {
-          util.logEventListenerError(event, l, e);
-        }
-      }
-    } catch (OrmException e) {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
+      listeners.runEach(l -> l.onPrivateStateChanged(event));
+    } catch (StorageException
+        | 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/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index e33715b..f9f67f6 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -16,12 +16,12 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 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.ReviewerAddedListener;
-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;
@@ -29,7 +29,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -40,11 +40,11 @@
 public class ReviewerAdded {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<ReviewerAddedListener> listeners;
+  private final PluginSetContext<ReviewerAddedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ReviewerAdded(DynamicSet<ReviewerAddedListener> listeners, EventUtil util) {
+  ReviewerAdded(PluginSetContext<ReviewerAddedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -55,7 +55,7 @@
       List<AccountState> reviewers,
       AccountState adder,
       Timestamp when) {
-    if (!listeners.iterator().hasNext() || reviewers.isEmpty()) {
+    if (listeners.isEmpty() || reviewers.isEmpty()) {
       return;
     }
 
@@ -67,19 +67,13 @@
               Lists.transform(reviewers, util::accountInfo),
               util.accountInfo(adder),
               when);
-      for (ReviewerAddedListener l : listeners) {
-        try {
-          l.onReviewersAdded(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
+      listeners.runEach(l -> l.onReviewersAdded(event));
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Couldn't fire event: %s", e.getMessage());
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
-        | OrmException
+        | StorageException
         | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 011a3e8..b92f3e6 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.ReviewerDeletedListener;
-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;
@@ -29,7 +29,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -40,11 +40,11 @@
 public class ReviewerDeleted {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<ReviewerDeletedListener> listeners;
+  private final PluginSetContext<ReviewerDeletedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ReviewerDeleted(DynamicSet<ReviewerDeletedListener> listeners, EventUtil util) {
+  ReviewerDeleted(PluginSetContext<ReviewerDeletedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -59,7 +59,7 @@
       Map<String, Short> oldApprovals,
       NotifyHandling notify,
       Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
@@ -74,19 +74,13 @@
               util.approvals(reviewer, oldApprovals, when),
               notify,
               when);
-      for (ReviewerDeletedListener listener : listeners) {
-        try {
-          listener.onReviewerDeleted(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, listener, e);
-        }
-      }
+      listeners.runEach(l -> l.onReviewerDeleted(event));
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Couldn't fire event: %s", e.getMessage());
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
-        | OrmException
+        | StorageException
         | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index f203f5d..6fddcfe 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -15,20 +15,21 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 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.RevisionCreatedListener;
-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.change.NotifyResolver;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -46,14 +47,14 @@
             PatchSet patchSet,
             AccountState uploader,
             Timestamp when,
-            NotifyHandling notify) {}
+            NotifyResolver.Result notify) {}
       };
 
-  private final DynamicSet<RevisionCreatedListener> listeners;
+  private final PluginSetContext<RevisionCreatedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  RevisionCreated(DynamicSet<RevisionCreatedListener> listeners, EventUtil util) {
+  RevisionCreated(PluginSetContext<RevisionCreatedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -68,8 +69,8 @@
       PatchSet patchSet,
       AccountState uploader,
       Timestamp when,
-      NotifyHandling notify) {
-    if (!listeners.iterator().hasNext()) {
+      NotifyResolver.Result notify) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
@@ -79,20 +80,14 @@
               util.revisionInfo(change.getProject(), patchSet),
               util.accountInfo(uploader),
               when,
-              notify);
-      for (RevisionCreatedListener l : listeners) {
-        try {
-          l.onRevisionCreated(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
+              notify.handling());
+      listeners.runEach(l -> l.onRevisionCreated(event));
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Couldn't fire event: %s", e.getMessage());
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
-        | OrmException
+        | StorageException
         | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
diff --git a/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index 45962f9..9e1ae44 100644
--- a/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.TopicEditedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -31,30 +31,24 @@
 public class TopicEdited {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<TopicEditedListener> listeners;
+  private final PluginSetContext<TopicEditedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  TopicEdited(DynamicSet<TopicEditedListener> listeners, EventUtil util) {
+  TopicEdited(PluginSetContext<TopicEditedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
   public void fire(Change change, AccountState account, String oldTopicName, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(util.changeInfo(change), util.accountInfo(account), oldTopicName, when);
-      for (TopicEditedListener l : listeners) {
-        try {
-          l.onTopicEdited(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (OrmException e) {
+      listeners.runEach(l -> l.onTopicEdited(event));
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index 5480dd8..bd6873a 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.VoteDeletedListener;
-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;
@@ -29,7 +29,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -40,11 +40,11 @@
 public class VoteDeleted {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<VoteDeletedListener> listeners;
+  private final PluginSetContext<VoteDeletedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  VoteDeleted(DynamicSet<VoteDeletedListener> listeners, EventUtil util) {
+  VoteDeleted(PluginSetContext<VoteDeletedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -59,7 +59,7 @@
       String message,
       AccountState remover,
       Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
@@ -74,19 +74,13 @@
               message,
               util.accountInfo(remover),
               when);
-      for (VoteDeletedListener l : listeners) {
-        try {
-          l.onVoteDeleted(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
+      listeners.runEach(l -> l.onVoteDeleted(event));
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Couldn't fire event: %s", e.getMessage());
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
-        | OrmException
+        | StorageException
         | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index 3f9f35b..785d6fe 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -15,55 +15,64 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 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.gwtorm.server.OrmException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
 public class WorkInProgressStateChanged {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<WorkInProgressStateChangedListener> listeners;
+  private final PluginSetContext<WorkInProgressStateChangedListener> listeners;
   private final EventUtil util;
 
   @Inject
   WorkInProgressStateChanged(
-      DynamicSet<WorkInProgressStateChangedListener> listeners, EventUtil util) {
+      PluginSetContext<WorkInProgressStateChangedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
+    if (listeners.isEmpty()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
-      for (WorkInProgressStateChangedListener l : listeners) {
-        try {
-          l.onWorkInProgressStateChanged(event);
-        } catch (Exception e) {
-          util.logEventListenerError(event, l, e);
-        }
-      }
-    } catch (OrmException e) {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
+      listeners.runEach(l -> l.onWorkInProgressStateChanged(event));
+    } catch (StorageException
+        | 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..0bc3d5c 100644
--- a/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -19,13 +19,14 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Predicate;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 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.Extension;
+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;
@@ -36,6 +37,7 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendCondition;
@@ -48,6 +50,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.Predicate;
 
 @Singleton
 public class UiActions {
@@ -69,7 +72,7 @@
             new com.google.gerrit.metrics.Description("Latency for RestView#getDescription calls")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            Field.ofString("view"));
+            Field.ofString("view", Metadata.Builder::restViewName).build());
   }
 
   public <R extends RestResource> Iterable<UiAction.Description> from(
@@ -120,7 +123,7 @@
 
   @Nullable
   private <R extends RestResource> UiAction.Description describe(
-      DynamicMap.Entry<RestView<R>> e, R resource) {
+      Extension<RestView<R>> e, R resource) {
     int d = e.getExportName().indexOf('.');
     if (d < 0) {
       return null;
@@ -141,7 +144,7 @@
 
     String name = e.getExportName().substring(d + 1);
     UiAction.Description dsc;
-    try (Timer1.Context ignored = uiActionLatency.start(name)) {
+    try (Timer1.Context<String> ignored = uiActionLatency.start(name)) {
       dsc = ((UiAction<R>) view).getDescription(resource);
     }
 
@@ -169,7 +172,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/fixes/FixReplacementInterpreter.java b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
index 1e5088be..65f14db 100644
--- a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
+++ b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.fixes;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.groupingBy;
 
 import com.google.gerrit.common.RawInputUtil;
@@ -70,7 +70,7 @@
       ObjectId patchSetCommitId,
       List<FixReplacement> fixReplacements)
       throws ResourceNotFoundException, IOException, ResourceConflictException {
-    checkNotNull(fixReplacements, "Fix replacements must not be null");
+    requireNonNull(fixReplacements, "Fix replacements must not be null");
 
     Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
         fixReplacements.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
diff --git a/java/com/google/gerrit/server/fixes/LineIdentifier.java b/java/com/google/gerrit/server/fixes/LineIdentifier.java
index c32d822..3d09c34 100644
--- a/java/com/google/gerrit/server/fixes/LineIdentifier.java
+++ b/java/com/google/gerrit/server/fixes/LineIdentifier.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.fixes;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -36,7 +36,7 @@
   private int currentLineEndIndex;
 
   LineIdentifier(String string) {
-    checkNotNull(string);
+    requireNonNull(string);
     lineSeparatorMatcher = LINE_SEPARATOR_PATTERN.matcher(string);
     reset();
   }
diff --git a/java/com/google/gerrit/server/fixes/StringModifier.java b/java/com/google/gerrit/server/fixes/StringModifier.java
index ccd40b3..85d024b 100644
--- a/java/com/google/gerrit/server/fixes/StringModifier.java
+++ b/java/com/google/gerrit/server/fixes/StringModifier.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.fixes;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 /**
  * A modifier of a string. It allows to replace multiple parts of a string by indicating those parts
@@ -29,7 +29,7 @@
   private int previousEndOffset = Integer.MIN_VALUE;
 
   StringModifier(String string) {
-    checkNotNull(string, "string must not be null");
+    requireNonNull(string, "string must not be null");
     stringBuilder = new StringBuilder(string);
   }
 
@@ -45,7 +45,7 @@
    *     previous call of this method
    */
   public void replace(int startIndex, int endIndex, String replacement) {
-    checkNotNull(replacement, "replacement string must not be null");
+    requireNonNull(replacement, "replacement string must not be null");
     if (previousEndOffset > startIndex) {
       throw new StringIndexOutOfBoundsException(
           String.format(
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index 4991715..d8aeece 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -18,6 +18,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
@@ -74,7 +75,7 @@
   private final GitRepositoryManager repoManager;
   private final TimeZone tz;
   private final PermissionBackend permissionBackend;
-  private NotesBranchUtil.Factory notesBranchUtilFactory;
+  private final NotesBranchUtil.Factory notesBranchUtilFactory;
 
   @Inject
   BanCommit(
diff --git a/java/com/google/gerrit/server/git/ChangeMessageModifier.java b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
index 7d4edcf..85c700a 100644
--- a/java/com/google/gerrit/server/git/ChangeMessageModifier.java
+++ b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -47,5 +47,5 @@
    * @return a new not null commit message.
    */
   String onSubmit(
-      String newCommitMessage, RevCommit original, RevCommit mergeTip, Branch.NameKey destination);
+      String newCommitMessage, RevCommit original, RevCommit mergeTip, BranchNameKey destination);
 }
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
index 6ee5a2a..4c6be20 100644
--- a/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -16,12 +16,16 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import java.io.IOException;
+import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -44,7 +48,7 @@
       Ordering.natural()
           .onResultOf(
               (CodeReviewCommit c) ->
-                  c.getPatchsetId() != null ? c.getPatchsetId().getParentKey().get() : null)
+                  c.getPatchsetId() != null ? c.getPatchsetId().changeId().get() : null)
           .nullsFirst();
 
   public static CodeReviewRevWalk newRevWalk(Repository repo) {
@@ -118,6 +122,15 @@
    */
   private CommitMergeStatus statusCode;
 
+  /**
+   * Message for the status that is returned to the calling user if the status indicates a problem
+   * that prevents submit.
+   */
+  private Optional<String> statusMessage = Optional.empty();
+
+  /** List of files in this commit that contain Git conflict markers. */
+  private ImmutableSet<String> filesWithGitConflicts;
+
   public CodeReviewCommit(AnyObjectId id) {
     super(id);
   }
@@ -134,6 +147,25 @@
     this.statusCode = statusCode;
   }
 
+  public Optional<String> getStatusMessage() {
+    return statusMessage;
+  }
+
+  public void setStatusMessage(@Nullable String statusMessage) {
+    this.statusMessage = Optional.ofNullable(statusMessage);
+  }
+
+  public ImmutableSet<String> getFilesWithGitConflicts() {
+    return filesWithGitConflicts != null ? filesWithGitConflicts : ImmutableSet.of();
+  }
+
+  public void setFilesWithGitConflicts(@Nullable Set<String> filesWithGitConflicts) {
+    this.filesWithGitConflicts =
+        filesWithGitConflicts != null && !filesWithGitConflicts.isEmpty()
+            ? ImmutableSet.copyOf(filesWithGitConflicts)
+            : null;
+  }
+
   public PatchSet.Id getPatchsetId() {
     return patchsetId;
   }
diff --git a/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java
index 022b0e1..d1a6df6 100644
--- a/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import java.io.IOException;
+import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.AbstractAdvertiseRefsHook;
@@ -28,6 +33,7 @@
  * implements {@link org.eclipse.jgit.transport.AdvertiseRefsHook}.
  */
 public class DefaultAdvertiseRefsHook extends AbstractAdvertiseRefsHook {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend.ForProject perm;
   private final PermissionBackend.RefFilterOptions opts;
@@ -41,9 +47,13 @@
   @Override
   protected Map<String, Ref> getAdvertisedRefs(Repository repo, RevWalk revWalk)
       throws ServiceMayNotContinueException {
+    logger.atFine().log("ref filter options = %s", opts);
     try {
-      return perm.filter(repo.getAllRefs(), repo, opts);
-    } catch (PermissionBackendException e) {
+      List<String> prefixes =
+          !opts.prefixes().isEmpty() ? opts.prefixes() : ImmutableList.of(RefDatabase.ALL);
+      return perm.filter(
+          repo.getRefDatabase().getRefsByPrefix(prefixes.toArray(new String[0])), repo, opts);
+    } catch (IOException | PermissionBackendException e) {
       ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
       ex.initCause(e);
       throw ex;
diff --git a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
index ac69ff1..fc9abb4 100644
--- a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
+++ b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
@@ -14,41 +14,75 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.inject.Inject;
+import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.config.UrlFormatter;
+import com.google.inject.Inject;
+import java.util.Optional;
+
+/** Print a change description for use in git command-line progress. */
 public class DefaultChangeReportFormatter implements ChangeReportFormatter {
-  private final String canonicalWebUrl;
+  private static final int SUBJECT_MAX_LENGTH = 80;
+  private static final String SUBJECT_CROP_APPENDIX = "...";
+  private static final int SUBJECT_CROP_RANGE = 10;
+  private static final String NEW_CHANGE_INDICATOR = " [NEW]";
+
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
-  DefaultChangeReportFormatter(@CanonicalWebUrl String canonicalWebUrl) {
-    this.canonicalWebUrl = canonicalWebUrl;
+  DefaultChangeReportFormatter(DynamicItem<UrlFormatter> urlFormatter) {
+    this.urlFormatter = urlFormatter;
   }
 
   @Override
   public String newChange(ChangeReportFormatter.Input input) {
-    return formatChangeUrl(canonicalWebUrl, input);
+    return formatChangeUrl(input) + NEW_CHANGE_INDICATOR;
   }
 
   @Override
   public String changeUpdated(ChangeReportFormatter.Input input) {
-    return formatChangeUrl(canonicalWebUrl, input);
+    return formatChangeUrl(input);
   }
 
   @Override
   public String changeClosed(ChangeReportFormatter.Input input) {
+    Change c = input.change();
     return String.format(
-        "change %s closed", ChangeUtil.formatChangeUrl(canonicalWebUrl, input.change()));
+        "change %s closed",
+        urlFormatter
+            .get()
+            .getChangeViewUrl(c.getProject(), c.getId())
+            .orElse(c.getId().toString()));
   }
 
-  private String formatChangeUrl(String url, Input input) {
+  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(Input input) {
+    Change c = input.change();
+    Optional<String> changeUrl = urlFormatter.get().getChangeViewUrl(c.getProject(), c.getId());
+    checkState(changeUrl.isPresent());
+
     StringBuilder m =
         new StringBuilder()
             .append("  ")
-            .append(ChangeUtil.formatChangeUrl(url, input.change()))
+            .append(changeUrl.get())
             .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/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index 3624695..75c9012 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -18,10 +18,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GarbageCollectionResult;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GcConfig;
 import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import java.io.PrintWriter;
 import java.util.List;
@@ -43,7 +43,7 @@
   private final GitRepositoryManager repoManager;
   private final GarbageCollectionQueue gcQueue;
   private final GcConfig gcConfig;
-  private final DynamicSet<GarbageCollectorListener> listeners;
+  private final PluginSetContext<GarbageCollectorListener> listeners;
 
   public interface Factory {
     GarbageCollection create();
@@ -54,7 +54,7 @@
       GitRepositoryManager repoManager,
       GarbageCollectionQueue gcQueue,
       GcConfig config,
-      DynamicSet<GarbageCollectorListener> listeners) {
+      PluginSetContext<GarbageCollectorListener> listeners) {
     this.repoManager = repoManager;
     this.gcQueue = gcQueue;
     this.gcConfig = config;
@@ -113,13 +113,7 @@
       return;
     }
     Event event = new Event(p, statistics);
-    for (GarbageCollectorListener l : listeners) {
-      try {
-        l.onGarbageCollected(event);
-      } catch (RuntimeException e) {
-        logger.atWarning().withCause(e).log("Failure in GarbageCollectorListener");
-      }
-    }
+    listeners.runEach(l -> l.onGarbageCollected(event));
   }
 
   private static void logGcInfo(Project.NameKey projectName, String msg) {
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index bb65fa8..c7dcc73 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -30,11 +30,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import java.util.ArrayDeque;
 import java.util.Collection;
 import java.util.Deque;
@@ -76,10 +74,6 @@
 public class GroupCollector {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static List<String> getDefaultGroups(PatchSet ps) {
-    return ImmutableList.of(ps.getRevision().get());
-  }
-
   public static List<String> getDefaultGroups(ObjectId commit) {
     return ImmutableList.of(commit.name());
   }
@@ -88,13 +82,13 @@
     if (rsrc.getEdit().isPresent()) {
       // Groups for an edit are just the base revision's groups, since they have
       // the same parent.
-      return rsrc.getEdit().get().getBasePatchSet().getGroups();
+      return rsrc.getEdit().get().getBasePatchSet().groups();
     }
-    return rsrc.getPatchSet().getGroups();
+    return rsrc.getPatchSet().groups();
   }
 
   private interface Lookup {
-    List<String> lookup(PatchSet.Id psId) throws OrmException;
+    List<String> lookup(PatchSet.Id psId);
   }
 
   private final ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha;
@@ -106,33 +100,16 @@
 
   public static GroupCollector create(
       ListMultimap<ObjectId, Ref> changeRefsById,
-      ReviewDb db,
       PatchSetUtil psUtil,
       ChangeNotes.Factory notesFactory,
       Project.NameKey project) {
     return new GroupCollector(
         transformRefs(changeRefsById),
-        new Lookup() {
-          @Override
-          public List<String> lookup(PatchSet.Id psId) throws OrmException {
-            // TODO(dborowitz): Reuse open repository from caller.
-            ChangeNotes notes = notesFactory.createChecked(db, project, psId.getParentKey());
-            PatchSet ps = psUtil.get(db, notes, psId);
-            return ps != null ? ps.getGroups() : null;
-          }
-        });
-  }
-
-  public static GroupCollector createForSchemaUpgradeOnly(
-      ListMultimap<ObjectId, Ref> changeRefsById, ReviewDb db) {
-    return new GroupCollector(
-        transformRefs(changeRefsById),
-        new Lookup() {
-          @Override
-          public List<String> lookup(PatchSet.Id psId) throws OrmException {
-            PatchSet ps = db.patchSets().get(psId);
-            return ps != null ? ps.getGroups() : null;
-          }
+        psId -> {
+          // TODO(dborowitz): Reuse open repository from caller.
+          ChangeNotes notes = notesFactory.createChecked(project, psId.changeId());
+          PatchSet ps = psUtil.get(notes, psId);
+          return ps != null ? ps.groups() : null;
         });
   }
 
@@ -154,12 +131,9 @@
       ListMultimap<PatchSet.Id, String> groupLookup) {
     this(
         patchSetsBySha,
-        new Lookup() {
-          @Override
-          public List<String> lookup(PatchSet.Id psId) {
-            List<String> groups = groupLookup.get(psId);
-            return !groups.isEmpty() ? groups : null;
-          }
+        psId -> {
+          List<String> groups = groupLookup.get(psId);
+          return !groups.isEmpty() ? groups : null;
         });
   }
 
@@ -223,7 +197,7 @@
     }
   }
 
-  public SortedSetMultimap<ObjectId, String> getGroups() throws OrmException {
+  public SortedSetMultimap<ObjectId, String> getGroups() {
     done = true;
     SortedSetMultimap<ObjectId, String> result =
         MultimapBuilder.hashKeys(groups.keySet().size()).treeSetValues().build();
@@ -249,8 +223,7 @@
     return id != null && patchSetsBySha.containsKey(id);
   }
 
-  private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
-      throws OrmException {
+  private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates) {
     Set<String> actual = Sets.newTreeSet();
     Set<String> done = Sets.newHashSetWithExpectedSize(candidates.size());
     Set<String> seen = Sets.newHashSetWithExpectedSize(candidates.size());
@@ -285,7 +258,7 @@
     }
   }
 
-  private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws OrmException {
+  private Iterable<String> resolveGroup(ObjectId forCommit, String group) {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
       PatchSet.Id psId = Iterables.getFirst(patchSetsBySha.get(id), null);
diff --git a/java/com/google/gerrit/server/git/HookUtil.java b/java/com/google/gerrit/server/git/HookUtil.java
index 1762b95..6e8f27c 100644
--- a/java/com/google/gerrit/server/git/HookUtil.java
+++ b/java/com/google/gerrit/server/git/HookUtil.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.git;
 
+import static java.util.stream.Collectors.toMap;
+
 import java.io.IOException;
 import java.util.Map;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.transport.BaseReceivePack;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 
@@ -38,13 +39,13 @@
       return refs;
     }
     try {
-      refs = rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
+      refs =
+          rp.getRepository().getRefDatabase().getRefs().stream()
+              .collect(toMap(Ref::getName, r -> r));
     } catch (ServiceMayNotContinueException e) {
       throw e;
     } catch (IOException e) {
-      ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
-      ex.initCause(e);
-      throw ex;
+      throw new ServiceMayNotContinueException(e);
     }
     rp.setAdvertisedRefs(refs, rp.getAdvertisedObjects());
     return refs;
diff --git a/java/com/google/gerrit/server/git/InMemoryInserter.java b/java/com/google/gerrit/server/git/InMemoryInserter.java
index b80f846..8d12f2b 100644
--- a/java/com/google/gerrit/server/git/InMemoryInserter.java
+++ b/java/com/google/gerrit/server/git/InMemoryInserter.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.git;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
 import java.io.IOException;
@@ -40,7 +40,7 @@
   private final boolean closeReader;
 
   public InMemoryInserter(ObjectReader reader) {
-    this.reader = checkNotNull(reader);
+    this.reader = requireNonNull(reader);
     closeReader = false;
   }
 
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index abe3410..9646fc7 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -140,11 +140,8 @@
     FileKey loc = FileKey.lenient(path.resolve(name.get()).toFile(), FS.DETECTED);
     try {
       return RepositoryCache.open(loc);
-    } catch (IOException e1) {
-      final RepositoryNotFoundException e2;
-      e2 = new RepositoryNotFoundException("Cannot open repository " + name);
-      e2.initCause(e1);
-      throw e2;
+    } catch (IOException e) {
+      throw new RepositoryNotFoundException("Cannot open repository " + name, e);
     }
   }
 
@@ -197,11 +194,8 @@
       }
 
       return db;
-    } catch (IOException e1) {
-      final RepositoryNotFoundException e2;
-      e2 = new RepositoryNotFoundException("Cannot create repository " + name);
-      e2.initCause(e1);
-      throw e2;
+    } catch (IOException e) {
+      throw new RepositoryNotFoundException("Cannot create repository " + name, e);
     }
   }
 
@@ -260,7 +254,7 @@
       int newLen = projectName.length() - Constants.DOT_GIT_EXT.length();
       projectName = projectName.substring(0, newLen);
     }
-    return new Project.NameKey(projectName);
+    return Project.nameKey(projectName);
   }
 
   protected class ProjectVisitor extends SimpleFileVisitor<Path> {
diff --git a/java/com/google/gerrit/server/git/LockFailureException.java b/java/com/google/gerrit/server/git/LockFailureException.java
deleted file mode 100644
index 02a30e0..0000000
--- a/java/com/google/gerrit/server/git/LockFailureException.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.collect.ImmutableList;
-import java.io.IOException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/** Thrown when updating a ref in Git fails with LOCK_FAILURE. */
-public class LockFailureException extends IOException {
-  private static final long serialVersionUID = 1L;
-
-  private final ImmutableList<String> refs;
-
-  public LockFailureException(String message, RefUpdate refUpdate) {
-    super(message);
-    refs = ImmutableList.of(refUpdate.getName());
-  }
-
-  public LockFailureException(String message, BatchRefUpdate batchRefUpdate) {
-    super(message);
-    refs =
-        batchRefUpdate
-            .getCommands()
-            .stream()
-            .filter(c -> c.getResult() == ReceiveCommand.Result.LOCK_FAILURE)
-            .map(ReceiveCommand::getRefName)
-            .collect(toImmutableList());
-  }
-
-  /** Subset of ref names that caused the lock failure. */
-  public ImmutableList<String> getFailedRefs() {
-    return refs;
-  }
-}
diff --git a/java/com/google/gerrit/server/git/MergeTip.java b/java/com/google/gerrit/server/git/MergeTip.java
index 3bd0f38..204f453 100644
--- a/java/com/google/gerrit/server/git/MergeTip.java
+++ b/java/com/google/gerrit/server/git/MergeTip.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.common.Nullable;
 import java.util.Collection;
@@ -40,7 +40,7 @@
    * @param toMerge list of commits to be merged in merge operation; may not be null or empty.
    */
   public MergeTip(@Nullable CodeReviewCommit initialTip, Collection<CodeReviewCommit> toMerge) {
-    checkNotNull(toMerge, "toMerge may not be null");
+    requireNonNull(toMerge, "toMerge may not be null");
     checkArgument(!toMerge.isEmpty(), "toMerge may not be empty");
     this.initialTip = initialTip;
     this.branchTip = initialTip;
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 637be24..9766b92 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -15,36 +15,43 @@
 package com.google.gerrit.server.git;
 
 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.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.naturalOrder;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
 
-import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSet.Id;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-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;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectState;
@@ -53,20 +60,25 @@
 import com.google.gerrit.server.submit.IntegrationException;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.submit.MergeSorter;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Iterator;
-import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.diff.Sequence;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -74,7 +86,6 @@
 import org.eclipse.jgit.errors.NoMergeBaseException;
 import org.eclipse.jgit.errors.NoMergeBaseException.MergeBaseFailureReason;
 import org.eclipse.jgit.errors.RevisionSyntaxException;
-import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -82,6 +93,8 @@
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeFormatter;
+import org.eclipse.jgit.merge.MergeResult;
 import org.eclipse.jgit.merge.MergeStrategy;
 import org.eclipse.jgit.merge.Merger;
 import org.eclipse.jgit.merge.ResolveMerger;
@@ -93,6 +106,7 @@
 import org.eclipse.jgit.revwalk.RevFlag;
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.TemporaryBuffer;
 
 /**
  * Utility methods used during the merge process.
@@ -105,6 +119,13 @@
 public class MergeUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  /**
+   * Length of abbreviated hex SHA-1s in merged filenames.
+   *
+   * <p>This is a constant so output is stable over time even if the SHA-1 prefix becomes ambiguous.
+   */
+  private static final int NAME_ABBREV_LEN = 6;
+
   static class PluggableCommitMessageGenerator {
     private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
@@ -114,18 +135,31 @@
     }
 
     public String generate(
-        RevCommit original, RevCommit mergeTip, Branch.NameKey dest, String current) {
-      checkNotNull(original.getRawBuffer());
+        RevCommit original, RevCommit mergeTip, BranchNameKey dest, String originalMessage) {
+      requireNonNull(original.getRawBuffer());
       if (mergeTip != null) {
-        checkNotNull(mergeTip.getRawBuffer());
+        requireNonNull(mergeTip.getRawBuffer());
       }
-      for (ChangeMessageModifier changeMessageModifier : changeMessageModifiers) {
+
+      int count = 0;
+      String current = originalMessage;
+      for (Extension<ChangeMessageModifier> ext : changeMessageModifiers.entries()) {
+        ChangeMessageModifier changeMessageModifier = ext.get();
+        String className = changeMessageModifier.getClass().getName();
         current = changeMessageModifier.onSubmit(current, original, mergeTip, dest);
-        checkNotNull(
-            current,
-            changeMessageModifier.getClass().getName()
-                + ".OnSubmit returned null instead of new commit message");
+        checkState(
+            current != null,
+            "%s.onSubmit from plugin %s returned null instead of new commit message",
+            className,
+            ext.getPluginName());
+        count++;
+        logger.atFine().log(
+            "Invoked %s from plugin %s, message length now %d",
+            className, ext.getPluginName(), current.length());
       }
+      logger.atFine().log(
+          "Invoked %d ChangeMessageModifiers on message with original length %d",
+          count, originalMessage.length());
       return current;
     }
   }
@@ -146,9 +180,8 @@
     MergeUtil create(ProjectState project, boolean useContentMerge);
   }
 
-  private final Provider<ReviewDb> db;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
-  private final Provider<String> urlProvider;
+  private final DynamicItem<UrlFormatter> urlFormatter;
   private final ApprovalsUtil approvalsUtil;
   private final ProjectState project;
   private final boolean useContentMerge;
@@ -158,17 +191,15 @@
   @AssistedInject
   MergeUtil(
       @GerritServerConfig Config serverConfig,
-      Provider<ReviewDb> db,
       IdentifiedUser.GenericFactory identifiedUserFactory,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      DynamicItem<UrlFormatter> urlFormatter,
       ApprovalsUtil approvalsUtil,
       PluggableCommitMessageGenerator commitMessageGenerator,
       @Assisted ProjectState project) {
     this(
         serverConfig,
-        db,
         identifiedUserFactory,
-        urlProvider,
+        urlFormatter,
         approvalsUtil,
         project,
         commitMessageGenerator,
@@ -178,16 +209,14 @@
   @AssistedInject
   MergeUtil(
       @GerritServerConfig Config serverConfig,
-      Provider<ReviewDb> db,
       IdentifiedUser.GenericFactory identifiedUserFactory,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      DynamicItem<UrlFormatter> urlFormatter,
       ApprovalsUtil approvalsUtil,
       @Assisted ProjectState project,
       PluggableCommitMessageGenerator commitMessageGenerator,
       @Assisted boolean useContentMerge) {
-    this.db = db;
     this.identifiedUserFactory = identifiedUserFactory;
-    this.urlProvider = urlProvider;
+    this.urlFormatter = urlFormatter;
     this.approvalsUtil = approvalsUtil;
     this.project = project;
     this.useContentMerge = useContentMerge;
@@ -217,10 +246,10 @@
     List<CodeReviewCommit> result = new ArrayList<>();
     try {
       result.addAll(mergeSorter.sort(toSort));
-    } catch (IOException e) {
+    } catch (IOException | StorageException e) {
       throw new IntegrationException("Branch head sorting failed", e);
     }
-    Collections.sort(result, CodeReviewCommit.ORDER);
+    result.sort(CodeReviewCommit.ORDER);
     return result;
   }
 
@@ -233,29 +262,164 @@
       String commitMsg,
       CodeReviewRevWalk rw,
       int parentIndex,
-      boolean ignoreIdenticalTree)
+      boolean ignoreIdenticalTree,
+      boolean allowConflicts)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
-          MergeIdenticalTreeException, MergeConflictException {
+          MergeIdenticalTreeException, MergeConflictException, MethodNotAllowedException {
 
-    final ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
-
+    ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     m.setBase(originalCommit.getParent(parentIndex));
+
+    DirCache dc = DirCache.newInCore();
+    if (allowConflicts && m instanceof ResolveMerger) {
+      // The DirCache must be set on ResolveMerger before calling
+      // ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get populated.
+      ((ResolveMerger) m).setDirCache(dc);
+    }
+
+    ObjectId tree;
+    ImmutableSet<String> filesWithGitConflicts;
     if (m.merge(mergeTip, originalCommit)) {
-      ObjectId tree = m.getResultTreeId();
+      filesWithGitConflicts = null;
+      tree = m.getResultTreeId();
       if (tree.equals(mergeTip.getTree()) && !ignoreIdenticalTree) {
         throw new MergeIdenticalTreeException("identical tree");
       }
+    } else {
+      if (!allowConflicts) {
+        throw new MergeConflictException("merge conflict");
+      }
 
-      CommitBuilder mergeCommit = new CommitBuilder();
-      mergeCommit.setTreeId(tree);
-      mergeCommit.setParentId(mergeTip);
-      mergeCommit.setAuthor(originalCommit.getAuthorIdent());
-      mergeCommit.setCommitter(cherryPickCommitterIdent);
-      mergeCommit.setMessage(commitMsg);
-      matchAuthorToCommitterDate(project, mergeCommit);
-      return rw.parseCommit(inserter.insert(mergeCommit));
+      if (!useContentMerge) {
+        // If content merge is disabled we don't have a ResolveMerger and hence cannot merge with
+        // conflict markers.
+        throw new MethodNotAllowedException(
+            "Cherry-pick with allow conflicts requires that content merge is enabled.");
+      }
+
+      // For merging with conflict markers we need a ResolveMerger, double-check that we have one.
+      checkState(m instanceof ResolveMerger, "allow conflicts is not supported");
+      Map<String, MergeResult<? extends Sequence>> mergeResults =
+          ((ResolveMerger) m).getMergeResults();
+
+      filesWithGitConflicts =
+          mergeResults.entrySet().stream()
+              .filter(e -> e.getValue().containsConflicts())
+              .map(Map.Entry::getKey)
+              .collect(toImmutableSet());
+
+      tree =
+          mergeWithConflicts(
+              rw, inserter, dc, "HEAD", mergeTip, "CHANGE", originalCommit, mergeResults);
     }
-    throw new MergeConflictException("merge conflict");
+
+    CommitBuilder cherryPickCommit = new CommitBuilder();
+    cherryPickCommit.setTreeId(tree);
+    cherryPickCommit.setParentId(mergeTip);
+    cherryPickCommit.setAuthor(originalCommit.getAuthorIdent());
+    cherryPickCommit.setCommitter(cherryPickCommitterIdent);
+    cherryPickCommit.setMessage(commitMsg);
+    matchAuthorToCommitterDate(project, cherryPickCommit);
+    CodeReviewCommit commit = rw.parseCommit(inserter.insert(cherryPickCommit));
+    commit.setFilesWithGitConflicts(filesWithGitConflicts);
+    return commit;
+  }
+
+  @SuppressWarnings("resource") // TemporaryBuffer requires calling close before reading.
+  public static ObjectId mergeWithConflicts(
+      RevWalk rw,
+      ObjectInserter ins,
+      DirCache dc,
+      String oursName,
+      RevCommit ours,
+      String theirsName,
+      RevCommit theirs,
+      Map<String, MergeResult<? extends Sequence>> mergeResults)
+      throws IOException {
+    rw.parseBody(ours);
+    rw.parseBody(theirs);
+    String oursMsg = ours.getShortMessage();
+    String theirsMsg = theirs.getShortMessage();
+
+    int nameLength = Math.max(oursName.length(), theirsName.length());
+    String oursNameFormatted =
+        String.format(
+            "%0$-" + nameLength + "s (%s %s)",
+            oursName,
+            abbreviateName(ours, NAME_ABBREV_LEN),
+            oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
+    String theirsNameFormatted =
+        String.format(
+            "%0$-" + nameLength + "s (%s %s)",
+            theirsName,
+            abbreviateName(theirs, NAME_ABBREV_LEN),
+            theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
+
+    MergeFormatter fmt = new MergeFormatter();
+    Map<String, ObjectId> resolved = new HashMap<>();
+    for (Map.Entry<String, MergeResult<? extends Sequence>> entry : mergeResults.entrySet()) {
+      MergeResult<? extends Sequence> p = entry.getValue();
+      TemporaryBuffer buf = null;
+      try {
+        // TODO(dborowitz): Respect inCoreLimit here.
+        buf = new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024);
+        fmt.formatMerge(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
+        buf.close(); // Flush file and close for writes, but leave available for reading.
+
+        try (InputStream in = buf.openInputStream()) {
+          resolved.put(entry.getKey(), ins.insert(Constants.OBJ_BLOB, buf.length(), in));
+        }
+      } finally {
+        if (buf != null) {
+          buf.destroy();
+        }
+      }
+    }
+
+    DirCacheBuilder builder = dc.builder();
+    int cnt = dc.getEntryCount();
+    for (int i = 0; i < cnt; ) {
+      DirCacheEntry entry = dc.getEntry(i);
+      if (entry.getStage() == 0) {
+        builder.add(entry);
+        i++;
+        continue;
+      }
+
+      int next = dc.nextEntry(i);
+      String path = entry.getPathString();
+      DirCacheEntry res = new DirCacheEntry(path);
+      if (resolved.containsKey(path)) {
+        // For a file with content merge conflict that we produced a result
+        // above on, collapse the file down to a single stage 0 with just
+        // the blob content, and a randomly selected mode (the lowest stage,
+        // which should be the merge base, or ours).
+        res.setFileMode(entry.getFileMode());
+        res.setObjectId(resolved.get(path));
+
+      } else if (next == i + 1) {
+        // If there is exactly one stage present, shouldn't be a conflict...
+        res.setFileMode(entry.getFileMode());
+        res.setObjectId(entry.getObjectId());
+
+      } else if (next == i + 2) {
+        // Two stages suggests a delete/modify conflict. Pick the higher
+        // stage as the automatic result.
+        entry = dc.getEntry(i + 1);
+        res.setFileMode(entry.getFileMode());
+        res.setObjectId(entry.getObjectId());
+
+      } else {
+        // 3 stage conflict, no resolve above
+        // Punt on the 3-stage conflict and show the base, for now.
+        res.setFileMode(entry.getFileMode());
+        res.setObjectId(entry.getObjectId());
+      }
+      builder.add(res);
+      i = next;
+    }
+    builder.finish();
+    return dc.writeTree(ins);
   }
 
   public static RevCommit createMergeCommit(
@@ -315,12 +479,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();
@@ -348,21 +510,20 @@
       msgbuf.append('\n');
     }
 
-    final String siteUrl = urlProvider.get();
-    if (siteUrl != null) {
-      final String url = siteUrl + c.getId().get();
-      if (!contains(footers, FooterConstants.REVIEWED_ON, url)) {
-        msgbuf.append(FooterConstants.REVIEWED_ON.getName());
-        msgbuf.append(": ");
-        msgbuf.append(url);
-        msgbuf.append('\n');
+    Optional<String> url = urlFormatter.get().getChangeViewUrl(c.getProject(), c.getId());
+    if (url.isPresent()) {
+      if (!contains(footers, FooterConstants.REVIEWED_ON, url.get())) {
+        msgbuf
+            .append(FooterConstants.REVIEWED_ON.getName())
+            .append(": ")
+            .append(url.get())
+            .append('\n');
       }
     }
-
     PatchSetApproval submitAudit = null;
 
-    for (PatchSetApproval a : safeGetApprovals(notes, user, psId)) {
-      if (a.getValue() <= 0) {
+    for (PatchSetApproval a : safeGetApprovals(notes, psId)) {
+      if (a.value() <= 0) {
         // Negative votes aren't counted.
         continue;
       }
@@ -370,29 +531,29 @@
       if (a.isLegacySubmit()) {
         // Submit is treated specially, below (becomes committer)
         //
-        if (submitAudit == null || a.getGranted().compareTo(submitAudit.getGranted()) > 0) {
+        if (submitAudit == null || a.granted().compareTo(submitAudit.granted()) > 0) {
           submitAudit = a;
         }
         continue;
       }
 
-      final Account acc = identifiedUserFactory.create(a.getAccountId()).getAccount();
+      final Account acc = identifiedUserFactory.create(a.accountId()).getAccount();
       final StringBuilder identbuf = new StringBuilder();
-      if (acc.getFullName() != null && acc.getFullName().length() > 0) {
+      if (acc.fullName() != null && acc.fullName().length() > 0) {
         if (identbuf.length() > 0) {
           identbuf.append(' ');
         }
-        identbuf.append(acc.getFullName());
+        identbuf.append(acc.fullName());
       }
-      if (acc.getPreferredEmail() != null && acc.getPreferredEmail().length() > 0) {
-        if (isSignedOffBy(footers, acc.getPreferredEmail())) {
+      if (acc.preferredEmail() != null && acc.preferredEmail().length() > 0) {
+        if (isSignedOffBy(footers, acc.preferredEmail())) {
           continue;
         }
         if (identbuf.length() > 0) {
           identbuf.append(' ');
         }
         identbuf.append('<');
-        identbuf.append(acc.getPreferredEmail());
+        identbuf.append(acc.preferredEmail());
         identbuf.append('>');
       }
       if (identbuf.length() == 0) {
@@ -401,12 +562,12 @@
       }
 
       final String tag;
-      if (isCodeReview(a.getLabelId())) {
+      if (isCodeReview(a.labelId())) {
         tag = "Reviewed-by";
-      } else if (isVerified(a.getLabelId())) {
+      } else if (isVerified(a.labelId())) {
         tag = "Tested-by";
       } else {
-        final LabelType lt = project.getLabelTypes().byLabel(a.getLabelId());
+        final LabelType lt = project.getLabelTypes().byLabel(a.labelId());
         if (lt == null) {
           continue;
         }
@@ -424,12 +585,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 +598,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, PatchSet.Id id) {
     return commitMessageGenerator.generate(
-        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, user, id));
+        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, id));
   }
 
   private static boolean isCodeReview(LabelId id) {
@@ -460,11 +615,10 @@
     return "Verified".equalsIgnoreCase(id.get());
   }
 
-  private Iterable<PatchSetApproval> safeGetApprovals(
-      ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
+  private Iterable<PatchSetApproval> safeGetApprovals(ChangeNotes notes, PatchSet.Id psId) {
     try {
-      return approvalsUtil.byPatchSet(db.get(), notes, user, psId, null, null);
-    } catch (OrmException e) {
+      return approvalsUtil.byPatchSet(notes, psId, null, null);
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Can't read approval records for %s", psId);
       return Collections.emptyList();
     }
@@ -496,7 +650,7 @@
     }
 
     try (ObjectInserter ins = new InMemoryInserter(repo)) {
-      return newThreeWayMerger(ins, repo.getConfig()).merge(new AnyObjectId[] {mergeTip, toMerge});
+      return newThreeWayMerger(ins, repo.getConfig()).merge(mergeTip, toMerge);
     } catch (LargeObjectException e) {
       logger.atWarning().log("Cannot merge due to LargeObjectException: %s", toMerge.name());
       return false;
@@ -577,7 +731,7 @@
       throws IntegrationException {
     try {
       return !mergeSorter.sort(Collections.singleton(toMerge)).contains(toMerge);
-    } catch (IOException e) {
+    } catch (IOException | StorageException e) {
       throw new IntegrationException("Branch head sorting failed", e);
     }
   }
@@ -588,13 +742,13 @@
       CodeReviewRevWalk rw,
       ObjectInserter inserter,
       Config repoConfig,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       CodeReviewCommit mergeTip,
       CodeReviewCommit n)
       throws IntegrationException {
     ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     try {
-      if (m.merge(new AnyObjectId[] {mergeTip, n})) {
+      if (m.merge(mergeTip, n)) {
         return writeMergeCommit(
             author, committer, rw, inserter, destBranch, mergeTip, m.getResultTreeId(), n);
       }
@@ -643,7 +797,7 @@
       PersonIdent committer,
       CodeReviewRevWalk rw,
       ObjectInserter inserter,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       CodeReviewCommit mergeTip,
       ObjectId treeId,
       CodeReviewCommit n)
@@ -660,9 +814,9 @@
     }
 
     StringBuilder msgbuf = new StringBuilder().append(summarize(rw, merged));
-    if (!R_HEADS_MASTER.equals(destBranch.get())) {
+    if (!R_HEADS_MASTER.equals(destBranch.branch())) {
       msgbuf.append(" into ");
-      msgbuf.append(destBranch.getShortName());
+      msgbuf.append(destBranch.shortName());
     }
 
     if (merged.size() > 1) {
@@ -694,26 +848,22 @@
       return String.format("Merge \"%s\"", c.getShortMessage());
     }
 
-    LinkedHashSet<String> topics = new LinkedHashSet<>(4);
-    for (CodeReviewCommit c : merged) {
-      if (!Strings.isNullOrEmpty(c.change().getTopic())) {
-        topics.add(c.change().getTopic());
-      }
-    }
+    ImmutableSortedSet<String> topics =
+        merged.stream()
+            .map(c -> c.change().getTopic())
+            .filter(t -> !Strings.isNullOrEmpty(t))
+            .map(t -> "\"" + t + "\"")
+            .collect(toImmutableSortedSet(naturalOrder()));
 
-    if (topics.size() == 1) {
-      return String.format("Merge changes from topic \"%s\"", Iterables.getFirst(topics, null));
-    } else if (topics.size() > 1) {
-      return String.format("Merge changes from topics \"%s\"", Joiner.on("\", \"").join(topics));
-    } else {
+    if (!topics.isEmpty()) {
       return String.format(
-          "Merge changes %s%s",
-          FluentIterable.from(merged)
-              .limit(5)
-              .transform(c -> c.change().getKey().abbreviate())
-              .join(Joiner.on(',')),
-          merged.size() > 5 ? ", ..." : "");
+          "Merge changes from topic%s %s",
+          topics.size() > 1 ? "s" : "", topics.stream().collect(joining(", ")));
     }
+    return merged.stream()
+        .limit(5)
+        .map(c -> c.change().getKey().abbreviate())
+        .collect(joining(",", "Merge changes ", merged.size() > 5 ? ", ..." : ""));
   }
 
   public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) {
@@ -829,7 +979,7 @@
         if (c.getPatchsetId() == null) {
           continue;
         }
-        Change.Id id = c.getPatchsetId().getParentKey();
+        Change.Id id = c.getPatchsetId().changeId();
         if (!expected.contains(id)) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index eced9c3..3555862 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -14,16 +14,14 @@
 
 package com.google.gerrit.server.git;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.LabelId;
 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.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.config.SendEmailExecutor;
@@ -35,16 +33,13 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.Collections;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -54,7 +49,10 @@
 
   public interface Factory {
     MergedByPushOp create(
-        RequestScopePropagator requestScopePropagator, PatchSet.Id psId, String refName);
+        RequestScopePropagator requestScopePropagator,
+        PatchSet.Id psId,
+        @Assisted("refName") String refName,
+        @Assisted("mergeResultRevId") String mergeResultRevId);
   }
 
   private final RequestScopePropagator requestScopePropagator;
@@ -67,6 +65,7 @@
 
   private final PatchSet.Id psId;
   private final String refName;
+  private final String mergeResultRevId;
 
   private Change change;
   private boolean correctBranch;
@@ -84,7 +83,8 @@
       ChangeMerged changeMerged,
       @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted PatchSet.Id psId,
-      @Assisted String refName) {
+      @Assisted("refName") String refName,
+      @Assisted("mergeResultRevId") String mergeResultRevId) {
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.cmUtil = cmUtil;
     this.mergedSenderFactory = mergedSenderFactory;
@@ -94,6 +94,7 @@
     this.requestScopePropagator = requestScopePropagator;
     this.psId = psId;
     this.refName = refName;
+    this.mergeResultRevId = mergeResultRevId;
   }
 
   public String getMergedIntoRef() {
@@ -101,14 +102,14 @@
   }
 
   public MergedByPushOp setPatchSetProvider(Provider<PatchSet> patchSetProvider) {
-    this.patchSetProvider = checkNotNull(patchSetProvider);
+    this.patchSetProvider = requireNonNull(patchSetProvider);
     return this;
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx) throws OrmException, IOException {
+  public boolean updateChange(ChangeContext ctx) throws IOException {
     change = ctx.getChange();
-    correctBranch = refName.equals(change.getDest().get());
+    correctBranch = refName.equals(change.getDest().branch());
     if (!correctBranch) {
       return false;
     }
@@ -119,14 +120,14 @@
       patchSet = patchSetProvider.get();
     } else {
       patchSet =
-          checkNotNull(
-              psUtil.get(ctx.getDb(), ctx.getNotes(), psId), "patch set %s not found", psId);
+          requireNonNull(
+              psUtil.get(ctx.getNotes(), psId),
+              () -> String.format("patch set %s not found", psId));
     }
     info = getPatchSetInfo(ctx);
 
     ChangeUpdate update = ctx.getUpdate(psId);
-    Change.Status status = change.getStatus();
-    if (status == Change.Status.MERGED) {
+    if (change.isMerged()) {
       return true;
     }
     change.setCurrentPatchSet(info);
@@ -135,9 +136,13 @@
     // submitted, this is why we must fix the status
     update.fixStatus(Change.Status.MERGED);
     update.setCurrentPatchSet();
+    if (change.isWorkInProgress()) {
+      change.setWorkInProgress(false);
+      update.setWorkInProgress(false);
+    }
     StringBuilder msgBuf = new StringBuilder();
     msgBuf.append("Change has been successfully pushed");
-    if (!refName.equals(change.getDest().get())) {
+    if (!refName.equals(change.getDest().branch())) {
       msgBuf.append(" into ");
       if (refName.startsWith(Constants.R_HEADS)) {
         msgBuf.append("branch ");
@@ -150,14 +155,8 @@
     ChangeMessage msg =
         ChangeMessagesUtil.newMessage(
             psId, ctx.getUser(), ctx.getWhen(), msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
-    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
-
-    PatchSetApproval submitter =
-        ApprovalsUtil.newApproval(
-            change.currentPatchSetId(), ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
-    update.putApproval(submitter.getLabel(), submitter.getValue());
-    ctx.getDb().patchSetApprovals().upsert(Collections.singleton(submitter));
-
+    cmUtil.addChangeMessage(update, msg);
+    update.putApproval(LabelId.legacySubmit().get(), (short) 1);
     return true;
   }
 
@@ -175,7 +174,7 @@
                   public void run() {
                     try {
                       MergedSender cm =
-                          mergedSenderFactory.create(ctx.getProject(), psId.getParentKey());
+                          mergedSenderFactory.create(ctx.getProject(), psId.changeId());
                       cm.setFrom(ctx.getAccountId());
                       cm.setPatchSet(patchSet, info);
                       cm.send();
@@ -191,14 +190,12 @@
                   }
                 }));
 
-    changeMerged.fire(
-        change, patchSet, ctx.getAccount(), patchSet.getRevision().get(), ctx.getWhen());
+    changeMerged.fire(change, patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
   }
 
-  private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException, OrmException {
+  private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
     RevWalk rw = ctx.getRevWalk();
-    RevCommit commit =
-        rw.parseCommit(ObjectId.fromString(checkNotNull(patchSet).getRevision().get()));
+    RevCommit commit = rw.parseCommit(requireNonNull(patchSet).commitId());
     return patchSetInfoFactory.get(rw, commit, psId);
   }
 }
diff --git a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
index 6fafe4e..01e85cf 100644
--- a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -54,7 +54,7 @@
   }
 
   @Override
-  public Path getBasePath(NameKey name) {
+  public Path getBasePath(Project.NameKey name) {
     Path alternateBasePath = config.getBasePath(name);
     return alternateBasePath != null ? alternateBasePath : super.getBasePath(name);
   }
diff --git a/java/com/google/gerrit/server/git/NotesBranchUtil.java b/java/com/google/gerrit/server/git/NotesBranchUtil.java
index 24b3727..1636b85 100644
--- a/java/com/google/gerrit/server/git/NotesBranchUtil.java
+++ b/java/com/google/gerrit/server/git/NotesBranchUtil.java
@@ -16,10 +16,11 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.update.RefUpdateUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/git/NotifyConfig.java b/java/com/google/gerrit/server/git/NotifyConfig.java
index d2d3a39..2ca2744a 100644
--- a/java/com/google/gerrit/server/git/NotifyConfig.java
+++ b/java/com/google/gerrit/server/git/NotifyConfig.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.Address;
 import java.util.EnumSet;
 import java.util.HashSet;
 import java.util.Set;
@@ -113,6 +114,13 @@
 
   @Override
   public String toString() {
-    return "NotifyConfig[" + name + " = " + addresses + " + " + groups + "]";
+    return MoreObjects.toStringHelper(this)
+        .add("name", name)
+        .add("addresses", addresses)
+        .add("groups", groups)
+        .add("header", header)
+        .add("types", types)
+        .add("filter", filter)
+        .toString();
   }
 }
diff --git a/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/java/com/google/gerrit/server/git/PerThreadRequestScope.java
index a4719a9..b7db542 100644
--- a/java/com/google/gerrit/server/git/PerThreadRequestScope.java
+++ b/java/com/google/gerrit/server/git/PerThreadRequestScope.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestScopePropagator;
@@ -52,10 +51,8 @@
 
   public static class Propagator extends ThreadLocalRequestScopePropagator<Context> {
     @Inject
-    Propagator(
-        ThreadLocalRequestContext local,
-        Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
-      super(REQUEST, current, local, dbProviderProvider);
+    Propagator(ThreadLocalRequestContext local) {
+      super(REQUEST, current, local);
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
new file mode 100644
index 0000000..6679b52
--- /dev/null
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Throwables;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.proto.Cache.PureRevertKeyProto;
+import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.protobuf.ByteString;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/** Computes and caches if a change is a pure revert of another change. */
+@Singleton
+public class PureRevertCache {
+  private static final String ID_CACHE = "pure_revert";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        persist(ID_CACHE, Cache.PureRevertKeyProto.class, Boolean.class)
+            .maximumWeight(100)
+            .loader(Loader.class)
+            .version(1)
+            .keySerializer(new ProtobufSerializer<>(Cache.PureRevertKeyProto.parser()))
+            .valueSerializer(BooleanCacheSerializer.INSTANCE);
+      }
+    };
+  }
+
+  private final LoadingCache<PureRevertKeyProto, Boolean> cache;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  PureRevertCache(
+      @Named(ID_CACHE) LoadingCache<PureRevertKeyProto, Boolean> cache,
+      ChangeNotes.Factory notesFactory) {
+    this.cache = cache;
+    this.notesFactory = notesFactory;
+  }
+
+  /**
+   * Returns {@code true} if {@code claimedRevert} is a pure (clean) revert of the change that is
+   * referenced in {@link Change#getRevertOf()}.
+   *
+   * @return {@code true} if {@code claimedRevert} is a pure (clean) revert.
+   * @throws IOException if there was a problem with the storage layer
+   * @throws BadRequestException if there is a problem with the provided {@link ChangeNotes}
+   */
+  public boolean isPureRevert(ChangeNotes claimedRevert) throws IOException, BadRequestException {
+    if (claimedRevert.getChange().getRevertOf() == null) {
+      throw new BadRequestException("revertOf not set");
+    }
+    ChangeNotes claimedOriginal =
+        notesFactory.createChecked(
+            claimedRevert.getProjectName(), claimedRevert.getChange().getRevertOf());
+    return isPureRevert(
+        claimedRevert.getProjectName(),
+        claimedRevert.getCurrentPatchSet().commitId(),
+        claimedOriginal.getCurrentPatchSet().commitId());
+  }
+
+  /**
+   * Returns {@code true} if {@code claimedRevert} is a pure (clean) revert of {@code
+   * claimedOriginal}.
+   *
+   * @return {@code true} if {@code claimedRevert} is a pure (clean) revert of {@code
+   *     claimedOriginal}.
+   * @throws IOException if there was a problem with the storage layer
+   * @throws BadRequestException if there is a problem with the provided {@link ObjectId}s
+   */
+  public boolean isPureRevert(
+      Project.NameKey project, ObjectId claimedRevert, ObjectId claimedOriginal)
+      throws IOException, BadRequestException {
+    try {
+      return cache.get(key(project, claimedRevert, claimedOriginal));
+    } catch (ExecutionException e) {
+      Throwables.throwIfInstanceOf(e.getCause(), BadRequestException.class);
+      throw new IOException(e);
+    }
+  }
+
+  @VisibleForTesting
+  static PureRevertKeyProto key(
+      Project.NameKey project, ObjectId claimedRevert, ObjectId claimedOriginal) {
+    ByteString original = ObjectIdConverter.create().toByteString(claimedOriginal);
+    ByteString revert = ObjectIdConverter.create().toByteString(claimedRevert);
+    return PureRevertKeyProto.newBuilder()
+        .setProject(project.get())
+        .setClaimedOriginal(original)
+        .setClaimedRevert(revert)
+        .build();
+  }
+
+  static class Loader extends CacheLoader<PureRevertKeyProto, Boolean> {
+    private final GitRepositoryManager repoManager;
+    private final MergeUtil.Factory mergeUtilFactory;
+    private final ProjectCache projectCache;
+
+    @Inject
+    Loader(
+        GitRepositoryManager repoManager,
+        MergeUtil.Factory mergeUtilFactory,
+        ProjectCache projectCache) {
+      this.repoManager = repoManager;
+      this.mergeUtilFactory = mergeUtilFactory;
+      this.projectCache = projectCache;
+    }
+
+    @Override
+    public Boolean load(PureRevertKeyProto key) throws BadRequestException, IOException {
+      try (TraceContext.TraceTimer ignored =
+          TraceContext.newTimer(
+              "Loading pure revert",
+              Metadata.builder().cacheKey(key.toString()).projectName(key.getProject()).build())) {
+        ObjectId original = ObjectIdConverter.create().fromByteString(key.getClaimedOriginal());
+        ObjectId revert = ObjectIdConverter.create().fromByteString(key.getClaimedRevert());
+        Project.NameKey project = Project.nameKey(key.getProject());
+
+        try (Repository repo = repoManager.openRepository(project);
+            ObjectInserter oi = repo.newObjectInserter();
+            RevWalk rw = new RevWalk(repo)) {
+          RevCommit claimedOriginalCommit;
+          try {
+            claimedOriginalCommit = rw.parseCommit(original);
+          } catch (InvalidObjectIdException | MissingObjectException e) {
+            throw new BadRequestException("invalid object ID");
+          }
+          if (claimedOriginalCommit.getParentCount() == 0) {
+            throw new BadRequestException("can't check against initial commit");
+          }
+          RevCommit claimedRevertCommit = rw.parseCommit(revert);
+          if (claimedRevertCommit.getParentCount() == 0) {
+            return false;
+          }
+          // Rebase claimed revert onto claimed original
+          ThreeWayMerger merger =
+              mergeUtilFactory
+                  .create(projectCache.checkedGet(project))
+                  .newThreeWayMerger(oi, repo.getConfig());
+          merger.setBase(claimedRevertCommit.getParent(0));
+          boolean success = merger.merge(claimedRevertCommit, claimedOriginalCommit);
+          if (!success || merger.getResultTreeId() == null) {
+            // Merge conflict during rebase
+            return false;
+          }
+
+          // Any differences between claimed original's parent and the rebase result indicate that
+          // the claimedRevert is not a pure revert but made content changes
+          try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+            df.setReader(oi.newReader(), repo.getConfig());
+            List<DiffEntry> entries =
+                df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId());
+            return entries.isEmpty();
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 1b83097..44c0ee3 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -19,15 +19,18 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 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.server.ReviewDb;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -43,6 +46,13 @@
 import java.util.List;
 import java.util.concurrent.ExecutionException;
 
+/**
+ * Cache based on an index query of the most recent changes. The number of cached items depends on
+ * the index implementation and configuration.
+ *
+ * <p>This cache is intended to be used when filtering references. By design it returns only a
+ * fraction of all changes. These are the changes that were modified last.
+ */
 @Singleton
 public class SearchingChangeCacheImpl implements GitReferenceUpdatedListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -63,8 +73,7 @@
     @Override
     protected void configure() {
       if (slave) {
-        bind(SearchingChangeCacheImpl.class)
-            .toProvider(Providers.<SearchingChangeCacheImpl>of(null));
+        bind(SearchingChangeCacheImpl.class).toProvider(Providers.of(null));
       } else {
         cache(ID_CACHE, Project.NameKey.class, new TypeLiteral<List<CachedChange>>() {})
             .maximumWeight(0)
@@ -78,7 +87,8 @@
   }
 
   @AutoValue
-  abstract static class CachedChange {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public abstract static class CachedChange {
     // Subset of fields in ChangeData, specifically fields needed to serve
     // VisibleRefFilter without touching the database. More can be added as
     // necessary.
@@ -105,16 +115,15 @@
    * <p>Returned changes only include the {@code Change} object (with id, branch) and the reviewers.
    * Additional stored fields are not loaded from the index.
    *
-   * @param db database handle to populate missing change data (probably unused).
    * @param project project to read.
    * @return list of known changes; empty if no changes.
    */
-  public List<ChangeData> getChangeData(ReviewDb db, Project.NameKey project) {
+  public List<ChangeData> getChangeData(Project.NameKey project) {
     try {
       List<CachedChange> cached = cache.get(project);
       List<ChangeData> cds = new ArrayList<>(cached.size());
       for (CachedChange cc : cached) {
-        ChangeData cd = changeDataFactory.create(db, cc.change());
+        ChangeData cd = changeDataFactory.create(cc.change());
         cd.setReviewers(cc.reviewers());
         cds.add(cd);
       }
@@ -128,7 +137,7 @@
   @Override
   public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
     if (event.getRefName().startsWith(RefNames.REFS_CHANGES)) {
-      cache.invalidate(new Project.NameKey(event.getProjectName()));
+      cache.invalidate(Project.nameKey(event.getProjectName()));
     }
   }
 
@@ -144,7 +153,10 @@
 
     @Override
     public List<CachedChange> load(Project.NameKey key) throws Exception {
-      try (ManualRequestContext ctx = requestContext.open()) {
+      try (TraceTimer timer =
+              TraceContext.newTimer(
+                  "Loading changes of project", Metadata.builder().projectName(key.get()).build());
+          ManualRequestContext ctx = requestContext.open()) {
         List<ChangeData> cds =
             queryProvider
                 .get()
diff --git a/java/com/google/gerrit/server/git/SystemReaderInstaller.java b/java/com/google/gerrit/server/git/SystemReaderInstaller.java
new file mode 100644
index 0000000..1043210
--- /dev/null
+++ b/java/com/google/gerrit/server/git/SystemReaderInstaller.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+
+@Singleton
+public class SystemReaderInstaller implements LifecycleListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final SitePaths site;
+
+  @Inject
+  SystemReaderInstaller(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public void start() {
+    SystemReader.setInstance(customReader());
+    logger.atInfo().log("Set JGit's SystemReader to read system config from %s", site.jgit_config);
+  }
+
+  @Override
+  public void stop() {}
+
+  private SystemReader customReader() {
+    SystemReader current = SystemReader.getInstance();
+
+    return new SystemReader() {
+      @Override
+      public String getHostname() {
+        return current.getHostname();
+      }
+
+      @Override
+      public String getenv(String variable) {
+        return current.getenv(variable);
+      }
+
+      @Override
+      public String getProperty(String key) {
+        return current.getProperty(key);
+      }
+
+      @Override
+      public FileBasedConfig openUserConfig(Config parent, FS fs) {
+        return current.openUserConfig(parent, fs);
+      }
+
+      @Override
+      public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+        return new FileBasedConfig(parent, site.jgit_config.toFile(), FS.DETECTED);
+      }
+
+      @Override
+      public long getCurrentTime() {
+        return current.getCurrentTime();
+      }
+
+      @Override
+      public int getTimezone(long when) {
+        return current.getTimezone(when);
+      }
+    };
+  }
+}
diff --git a/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
index b8acd0a..535644d 100644
--- a/java/com/google/gerrit/server/git/TagCache.java
+++ b/java/com/google/gerrit/server/git/TagCache.java
@@ -17,14 +17,12 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import java.io.Serializable;
+import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
@@ -35,17 +33,19 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        persist(CACHE_NAME, String.class, EntryVal.class);
+        persist(CACHE_NAME, String.class, TagSetHolder.class)
+            .version(1)
+            .keySerializer(StringCacheSerializer.INSTANCE)
+            .valueSerializer(TagSetHolder.Serializer.INSTANCE);
         bind(TagCache.class);
       }
     };
   }
 
-  private final Cache<String, EntryVal> cache;
-  private final Object createLock = new Object();
+  private final Cache<String, TagSetHolder> cache;
 
   @Inject
-  TagCache(@Named(CACHE_NAME) Cache<String, EntryVal> cache) {
+  TagCache(@Named(CACHE_NAME) Cache<String, TagSetHolder> cache) {
     this.cache = cache;
   }
 
@@ -68,62 +68,26 @@
     // never fail with an exception. Some of these references can be null
     // (e.g. not all projects are cached, or the cache is not current).
     //
-    EntryVal val = cache.getIfPresent(name.get());
-    if (val != null) {
-      TagSetHolder holder = val.holder;
-      if (holder != null) {
-        TagSet tags = holder.getTagSet();
-        if (tags != null) {
-          if (tags.updateFastForward(refName, oldValue, newValue)) {
-            cache.put(name.get(), val);
-          }
+    TagSetHolder holder = cache.getIfPresent(name.get());
+    if (holder != null) {
+      TagSet tags = holder.getTagSet();
+      if (tags != null) {
+        if (tags.updateFastForward(refName, oldValue, newValue)) {
+          cache.put(name.get(), holder);
         }
       }
     }
   }
 
   public TagSetHolder get(Project.NameKey name) {
-    EntryVal val = cache.getIfPresent(name.get());
-    if (val == null) {
-      synchronized (createLock) {
-        val = cache.getIfPresent(name.get());
-        if (val == null) {
-          val = new EntryVal();
-          val.holder = new TagSetHolder(name);
-          cache.put(name.get(), val);
-        }
-      }
+    try {
+      return cache.get(name.get(), () -> new TagSetHolder(name));
+    } catch (ExecutionException e) {
+      throw new IllegalStateException(e);
     }
-    return val.holder;
   }
 
   void put(Project.NameKey name, TagSetHolder tags) {
-    EntryVal val = new EntryVal();
-    val.holder = tags;
-    cache.put(name.get(), val);
-  }
-
-  public static class EntryVal implements Serializable {
-    static final long serialVersionUID = 1L;
-
-    transient TagSetHolder holder;
-
-    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
-      holder = new TagSetHolder(new Project.NameKey(in.readUTF()));
-      if (in.readBoolean()) {
-        TagSet tags = new TagSet(holder.getProjectName());
-        tags.readObject(in);
-        holder.setTagSet(tags);
-      }
-    }
-
-    private void writeObject(ObjectOutputStream out) throws IOException {
-      TagSet tags = holder.getTagSet();
-      out.writeUTF(holder.getProjectName().get());
-      out.writeBoolean(tags != null);
-      if (tags != null) {
-        tags.writeObject(out);
-      }
-    }
+    cache.put(name.get(), tags);
   }
 }
diff --git a/java/com/google/gerrit/server/git/TagMatcher.java b/java/com/google/gerrit/server/git/TagMatcher.java
index 945e91e..f003b6f 100644
--- a/java/com/google/gerrit/server/git/TagMatcher.java
+++ b/java/com/google/gerrit/server/git/TagMatcher.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.server.git.TagSet.Tag;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.BitSet;
 import java.util.Collection;
@@ -50,9 +51,8 @@
     this.updated = updated;
   }
 
-  public boolean isReachable(Ref tagRef) {
-    tagRef = db.peel(tagRef);
-
+  public boolean isReachable(Ref tagRef) throws IOException {
+    tagRef = db.getRefDatabase().peel(tagRef);
     ObjectId tagObj = tagRef.getPeeledObjectId();
     if (tagObj == null) {
       tagObj = tagRef.getObjectId();
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 10b3411..860118c 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -14,16 +14,19 @@
 
 package com.google.gerrit.server.git;
 
-import static org.eclipse.jgit.lib.ObjectIdSerializer.readWithoutMarker;
-import static org.eclipse.jgit.lib.ObjectIdSerializer.writeWithoutMarker;
-
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.CachedRefProto;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.protobuf.ByteString;
 import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
 import java.util.BitSet;
 import java.util.HashMap;
 import java.util.Map;
@@ -34,7 +37,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -48,15 +50,35 @@
   private final ObjectIdOwnerMap<Tag> tags;
 
   TagSet(Project.NameKey projectName) {
+    this(projectName, new HashMap<>(), new ObjectIdOwnerMap<>());
+  }
+
+  TagSet(Project.NameKey projectName, HashMap<String, CachedRef> refs, ObjectIdOwnerMap<Tag> tags) {
     this.projectName = projectName;
-    this.refs = new HashMap<>();
-    this.tags = new ObjectIdOwnerMap<>();
+    this.refs = refs;
+    this.tags = tags;
+  }
+
+  Project.NameKey getProjectName() {
+    return projectName;
   }
 
   Tag lookupTag(AnyObjectId id) {
     return tags.get(id);
   }
 
+  // Test methods have obtuse names in addition to annotations, since they expose mutable state
+  // which would be easy to corrupt.
+  @VisibleForTesting
+  Map<String, CachedRef> getRefsForTesting() {
+    return refs;
+  }
+
+  @VisibleForTesting
+  ObjectIdOwnerMap<Tag> getTagsForTesting() {
+    return tags;
+  }
+
   boolean updateFastForward(String refName, ObjectId oldValue, ObjectId newValue) {
     CachedRef ref = refs.get(refName);
     if (ref != null) {
@@ -157,13 +179,17 @@
 
     try (TagWalk rw = new TagWalk(git)) {
       rw.setRetainBody(false);
-      for (Ref ref : git.getRefDatabase().getRefs(RefDatabase.ALL).values()) {
+      for (Ref ref : git.getRefDatabase().getRefs()) {
         if (skip(ref)) {
           continue;
 
         } else if (isTag(ref)) {
           // For a tag, remember where it points to.
-          addTag(rw, git.peel(ref));
+          try {
+            addTag(rw, git.getRefDatabase().peel(ref));
+          } catch (IOException e) {
+            addTag(rw, ref);
+          }
 
         } else {
           // New reference to include in the set.
@@ -188,36 +214,46 @@
     }
   }
 
-  void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
-    int refCnt = in.readInt();
-    for (int i = 0; i < refCnt; i++) {
-      String name = in.readUTF();
-      int flag = in.readInt();
-      ObjectId id = readWithoutMarker(in);
-      refs.put(name, new CachedRef(flag, id));
-    }
+  static TagSet fromProto(TagSetProto proto) {
+    ObjectIdConverter idConverter = ObjectIdConverter.create();
 
-    int tagCnt = in.readInt();
-    for (int i = 0; i < tagCnt; i++) {
-      ObjectId id = readWithoutMarker(in);
-      BitSet flags = (BitSet) in.readObject();
-      tags.add(new Tag(id, flags));
-    }
+    HashMap<String, CachedRef> refs = Maps.newHashMapWithExpectedSize(proto.getRefCount());
+    proto
+        .getRefMap()
+        .forEach(
+            (n, cr) ->
+                refs.put(n, new CachedRef(cr.getFlag(), idConverter.fromByteString(cr.getId()))));
+    ObjectIdOwnerMap<Tag> tags = new ObjectIdOwnerMap<>();
+    proto
+        .getTagList()
+        .forEach(
+            t ->
+                tags.add(
+                    new Tag(
+                        idConverter.fromByteString(t.getId()),
+                        BitSet.valueOf(t.getFlags().asReadOnlyByteBuffer()))));
+    return new TagSet(Project.nameKey(proto.getProjectName()), refs, tags);
   }
 
-  void writeObject(ObjectOutputStream out) throws IOException {
-    out.writeInt(refs.size());
-    for (Map.Entry<String, CachedRef> e : refs.entrySet()) {
-      out.writeUTF(e.getKey());
-      out.writeInt(e.getValue().flag);
-      writeWithoutMarker(out, e.getValue().get());
-    }
-
-    out.writeInt(tags.size());
-    for (Tag tag : tags) {
-      writeWithoutMarker(out, tag);
-      out.writeObject(tag.refFlags);
-    }
+  TagSetProto toProto() {
+    ObjectIdConverter idConverter = ObjectIdConverter.create();
+    TagSetProto.Builder b = TagSetProto.newBuilder().setProjectName(projectName.get());
+    refs.forEach(
+        (n, cr) ->
+            b.putRef(
+                n,
+                CachedRefProto.newBuilder()
+                    .setId(idConverter.toByteString(cr.get()))
+                    .setFlag(cr.flag)
+                    .build()));
+    tags.forEach(
+        t ->
+            b.addTag(
+                TagProto.newBuilder()
+                    .setId(idConverter.toByteString(t))
+                    .setFlags(ByteString.copyFrom(t.refFlags.toByteArray()))
+                    .build()));
+    return b.build();
   }
 
   private boolean refresh(TagSet old, TagMatcher m) {
@@ -338,8 +374,17 @@
     return ref.getName().startsWith(Constants.R_TAGS);
   }
 
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("projectName", projectName)
+        .add("refs", refs)
+        .add("tags", tags)
+        .toString();
+  }
+
   static final class Tag extends ObjectIdOwnerMap.Entry {
-    private final BitSet refFlags;
+    @VisibleForTesting final BitSet refFlags;
 
     Tag(AnyObjectId id, BitSet flags) {
       super(id);
@@ -349,9 +394,15 @@
     boolean has(BitSet mask) {
       return refFlags.intersects(mask);
     }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this).addValue(name()).add("refFlags", refFlags).toString();
+    }
   }
 
-  private static final class CachedRef extends AtomicReference<ObjectId> {
+  @VisibleForTesting
+  static final class CachedRef extends AtomicReference<ObjectId> {
     private static final long serialVersionUID = 1L;
 
     final int flag;
@@ -364,6 +415,15 @@
       this.flag = flag;
       set(id);
     }
+
+    @Override
+    public String toString() {
+      ObjectId id = get();
+      return MoreObjects.toStringHelper(this)
+          .addValue(id != null ? id.name() : "null")
+          .add("flag", flag)
+          .toString();
+    }
   }
 
   private static final class TagWalk extends RevWalk {
diff --git a/java/com/google/gerrit/server/git/TagSetHolder.java b/java/com/google/gerrit/server/git/TagSetHolder.java
index 3f08d10..d1e33ba 100644
--- a/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -16,7 +16,11 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.util.Collection;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -24,7 +28,8 @@
 public class TagSetHolder {
   private final Object buildLock = new Object();
   private final Project.NameKey projectName;
-  private volatile TagSet tags;
+
+  @Nullable private volatile TagSet tags;
 
   TagSetHolder(Project.NameKey projectName) {
     this.projectName = projectName;
@@ -94,4 +99,29 @@
       return cur;
     }
   }
+
+  enum Serializer implements CacheSerializer<TagSetHolder> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(TagSetHolder object) {
+      TagSetHolderProto.Builder b =
+          TagSetHolderProto.newBuilder().setProjectName(object.projectName.get());
+      TagSet tags = object.tags;
+      if (tags != null) {
+        b.setTags(tags.toProto());
+      }
+      return Protos.toByteArray(b.build());
+    }
+
+    @Override
+    public TagSetHolder deserialize(byte[] in) {
+      TagSetHolderProto proto = Protos.parseUnchecked(TagSetHolderProto.parser(), in);
+      TagSetHolder holder = new TagSetHolder(Project.nameKey(proto.getProjectName()));
+      if (proto.hasTags()) {
+        holder.tags = TagSet.fromProto(proto.getTags());
+      }
+      return holder;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/git/TransferConfig.java b/java/com/google/gerrit/server/git/TransferConfig.java
index 204a0d5..55b9448 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 inheritProjectMaxObjectSizeLimit() {
+    return inheritProjectMaxObjectSizeLimit;
   }
 }
diff --git a/java/com/google/gerrit/server/git/UploadPackMetricsHook.java b/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
index aa02fba..4afff2b 100644
--- a/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
+++ b/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.metrics.Histogram1;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import org.eclipse.jgit.storage.pack.PackStatistics;
@@ -43,14 +44,15 @@
 
   @Inject
   UploadPackMetricsHook(MetricMaker metricMaker) {
-    Field<Operation> operation = Field.ofEnum(Operation.class, "operation");
+    Field<Operation> operationField =
+        Field.ofEnum(Operation.class, "operation", Metadata.Builder::gitOperation).build();
     requestCount =
         metricMaker.newCounter(
             "git/upload-pack/request_count",
             new Description("Total number of git-upload-pack requests")
                 .setRate()
                 .setUnit("requests"),
-            operation);
+            operationField);
 
     counting =
         metricMaker.newTimer(
@@ -58,7 +60,7 @@
             new Description("Time spent in the 'Counting...' phase")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            operation);
+            operationField);
 
     compressing =
         metricMaker.newTimer(
@@ -66,7 +68,7 @@
             new Description("Time spent in the 'Compressing...' phase")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            operation);
+            operationField);
 
     writing =
         metricMaker.newTimer(
@@ -74,7 +76,7 @@
             new Description("Time spent transferring bytes to client")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            operation);
+            operationField);
 
     packBytes =
         metricMaker.newHistogram(
@@ -82,7 +84,7 @@
             new Description("Distribution of sizes of packs sent to clients")
                 .setCumulative()
                 .setUnit(Units.BYTES),
-            operation);
+            operationField);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 46518e5..d455b82 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -14,8 +14,9 @@
 
 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;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
@@ -24,6 +25,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 +45,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;
@@ -81,12 +85,8 @@
   }
 
   private static final UncaughtExceptionHandler LOG_UNCAUGHT_EXCEPTION =
-      new UncaughtExceptionHandler() {
-        @Override
-        public void uncaughtException(Thread t, Throwable e) {
+      (t, e) ->
           logger.atSevere().withCause(e).log("WorkQueue thread %s threw exception", t.getName());
-        }
-      };
 
   private final ScheduledExecutorService defaultQueue;
   private final IdGenerator idGenerator;
@@ -122,7 +122,7 @@
    * @param poolsize the size of the pool.
    * @param queueName the name of the queue.
    */
-  public ScheduledThreadPoolExecutor createQueue(int poolsize, String queueName) {
+  public ScheduledExecutorService createQueue(int poolsize, String queueName) {
     return createQueue(poolsize, queueName, Thread.NORM_PRIORITY, false);
   }
 
@@ -274,6 +274,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);
@@ -286,75 +355,44 @@
           new Description("Maximum allowed number of threads in the pool")
               .setGauge()
               .setUnit("threads"),
-          new Supplier<Long>() {
-            @Override
-            public Long get() {
-              return (long) getMaximumPoolSize();
-            }
-          });
+          () -> (long) getMaximumPoolSize());
       metrics.newCallbackMetric(
           getMetricName(queueName, "pool_size"),
           Long.class,
           new Description("Current number of threads in the pool").setGauge().setUnit("threads"),
-          new Supplier<Long>() {
-            @Override
-            public Long get() {
-              return (long) getPoolSize();
-            }
-          });
+          () -> (long) getPoolSize());
       metrics.newCallbackMetric(
           getMetricName(queueName, "active_threads"),
           Long.class,
           new Description("Number number of threads that are actively executing tasks")
               .setGauge()
               .setUnit("threads"),
-          new Supplier<Long>() {
-            @Override
-            public Long get() {
-              return (long) getActiveCount();
-            }
-          });
+          () -> (long) getActiveCount());
       metrics.newCallbackMetric(
           getMetricName(queueName, "scheduled_tasks"),
           Integer.class,
           new Description("Number of scheduled tasks in the queue").setGauge().setUnit("tasks"),
-          new Supplier<Integer>() {
-            @Override
-            public Integer get() {
-              return getQueue().size();
-            }
-          });
+          () -> getQueue().size());
       metrics.newCallbackMetric(
           getMetricName(queueName, "total_scheduled_tasks_count"),
           Long.class,
           new Description("Total number of tasks that have been scheduled for execution")
               .setCumulative()
               .setUnit("tasks"),
-          new Supplier<Long>() {
-            @Override
-            public Long get() {
-              return (long) getTaskCount();
-            }
-          });
+          this::getTaskCount);
       metrics.newCallbackMetric(
           getMetricName(queueName, "total_completed_tasks_count"),
           Long.class,
           new Description("Total number of tasks that have completed execution")
               .setCumulative()
               .setUnit("tasks"),
-          new Supplier<Long>() {
-            @Override
-            public Long get() {
-              return (long) getCompletedTaskCount();
-            }
-          });
+          this::getCompletedTaskCount);
     }
 
     private String getMetricName(String queueName, String metricName) {
       String name =
           CaseFormat.UPPER_CAMEL.to(
-              CaseFormat.LOWER_UNDERSCORE,
-              queueName.replaceFirst("SSH", "Ssh").replaceAll("-", ""));
+              CaseFormat.LOWER_UNDERSCORE, queueName.replaceFirst("SSH", "Ssh").replace("-", ""));
       return metrics.sanitizeMetricName(String.format("queue/%s/%s", name, metricName));
     }
 
@@ -367,6 +405,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 {
@@ -596,7 +638,7 @@
                 for (Field innerField : innerObj.getClass().getDeclaredFields()) {
                   if (innerField.getType().isAssignableFrom(Callable.class)) {
                     innerField.setAccessible(true);
-                    return ((Callable<?>) innerField.get(innerObj)).toString();
+                    return innerField.get(innerObj).toString();
                   }
                 }
               }
diff --git a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
index bbe0c62..97beefd 100644
--- a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
+++ b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -33,21 +34,22 @@
 
 /** Helps with the updating of a {@link VersionedMetaData}. */
 public class MetaDataUpdate implements AutoCloseable {
+  @Singleton
   public static class User {
     private final InternalFactory factory;
     private final GitRepositoryManager mgr;
-    private final PersonIdent serverIdent;
+    private final Provider<PersonIdent> serverIdentProvider;
     private final Provider<IdentifiedUser> identifiedUser;
 
     @Inject
     User(
         InternalFactory factory,
         GitRepositoryManager mgr,
-        @GerritPersonIdent PersonIdent serverIdent,
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
         Provider<IdentifiedUser> identifiedUser) {
       this.factory = factory;
       this.mgr = mgr;
-      this.serverIdent = serverIdent;
+      this.serverIdentProvider = serverIdentProvider;
       this.identifiedUser = identifiedUser;
     }
 
@@ -126,29 +128,31 @@
     public MetaDataUpdate create(
         Project.NameKey name, Repository repository, IdentifiedUser user, BatchRefUpdate batch) {
       MetaDataUpdate md = factory.create(name, repository, batch);
-      md.getCommitBuilder().setCommitter(serverIdent);
+      md.getCommitBuilder().setCommitter(serverIdentProvider.get());
       md.setAuthor(user);
       return md;
     }
 
     private PersonIdent createPersonIdent(IdentifiedUser user) {
+      PersonIdent serverIdent = serverIdentProvider.get();
       return user.newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
     }
   }
 
+  @Singleton
   public static class Server {
     private final InternalFactory factory;
     private final GitRepositoryManager mgr;
-    private final PersonIdent serverIdent;
+    private final Provider<PersonIdent> serverIdentProvider;
 
     @Inject
     Server(
         InternalFactory factory,
         GitRepositoryManager mgr,
-        @GerritPersonIdent PersonIdent serverIdent) {
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider) {
       this.factory = factory;
       this.mgr = mgr;
-      this.serverIdent = serverIdent;
+      this.serverIdentProvider = serverIdentProvider;
     }
 
     public MetaDataUpdate create(Project.NameKey name)
@@ -162,6 +166,7 @@
       Repository repo = mgr.openRepository(name);
       MetaDataUpdate md = factory.create(name, repo, batch);
       md.setCloseRepository(true);
+      PersonIdent serverIdent = serverIdentProvider.get();
       md.getCommitBuilder().setAuthor(serverIdent);
       md.getCommitBuilder().setCommitter(serverIdent);
       return md;
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..f2180d7 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -18,7 +18,12 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.StringReader;
@@ -81,6 +86,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;
@@ -105,7 +111,7 @@
   /** @return revision of the metadata that was loaded. */
   @Nullable
   public ObjectId getRevision() {
-    return revision != null ? revision.copy() : null;
+    return ObjectIds.copyOrNull(revision);
   }
 
   /**
@@ -114,13 +120,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 +141,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 +165,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 +186,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);
   }
 
   /**
@@ -451,7 +463,12 @@
   }
 
   protected Config readConfig(String fileName) throws IOException, ConfigInvalidException {
-    Config rc = new Config();
+    return readConfig(fileName, null);
+  }
+
+  protected Config readConfig(String fileName, Config baseConfig)
+      throws IOException, ConfigInvalidException {
+    Config rc = new Config(baseConfig);
     String text = readUTF8(fileName);
     if (!text.isEmpty()) {
       try {
@@ -481,7 +498,16 @@
       return new byte[] {};
     }
 
-    try (TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
+    try (TraceTimer timer =
+            TraceContext.newTimer(
+                "Read file",
+                Metadata.builder()
+                    .projectName(projectName.get())
+                    .noteDbRefName(getRefName())
+                    .revision(revision.name())
+                    .noteDbFilePath(fileName)
+                    .build());
+        TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
       if (tw != null) {
         ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
         return obj.getCachedBytes(Integer.MAX_VALUE);
@@ -553,20 +579,29 @@
   }
 
   protected void saveFile(String fileName, byte[] raw) throws IOException {
-    DirCacheEditor editor = newTree.editor();
-    if (raw != null && 0 < raw.length) {
-      final ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, raw);
-      editor.add(
-          new PathEdit(fileName) {
-            @Override
-            public void apply(DirCacheEntry ent) {
-              ent.setFileMode(FileMode.REGULAR_FILE);
-              ent.setObjectId(blobId);
-            }
-          });
-    } else {
-      editor.add(new DeletePath(fileName));
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Save file",
+            Metadata.builder()
+                .projectName(projectName.get())
+                .noteDbRefName(getRefName())
+                .noteDbFilePath(fileName)
+                .build())) {
+      DirCacheEditor editor = newTree.editor();
+      if (raw != null && 0 < raw.length) {
+        final ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, raw);
+        editor.add(
+            new PathEdit(fileName) {
+              @Override
+              public void apply(DirCacheEntry ent) {
+                ent.setFileMode(FileMode.REGULAR_FILE);
+                ent.setObjectId(blobId);
+              }
+            });
+      } else {
+        editor.add(new DeletePath(fileName));
+      }
+      editor.finish();
     }
-    editor.finish();
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 01ce468..b8a2aed 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -14,30 +14,41 @@
 
 package com.google.gerrit.server.git.receive;
 
-import com.google.common.collect.SetMultimap;
+import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ReceiveCommitsExecutor;
-import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.ProjectRunnable;
 import com.google.gerrit.server.git.TransferConfig;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.quota.QuotaBackend;
+import com.google.gerrit.server.quota.QuotaException;
+import com.google.gerrit.server.quota.QuotaResponse;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
@@ -50,7 +61,6 @@
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.io.OutputStream;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
@@ -58,14 +68,18 @@
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.AdvertiseRefsHookChain;
 import org.eclipse.jgit.transport.PreReceiveHook;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.ReceiveCommand.Result;
 import org.eclipse.jgit.transport.ReceivePack;
 
-/** Hook that delegates to {@link ReceiveCommits} in a worker thread. */
+/**
+ * Hook that delegates to {@link ReceiveCommits} in a worker thread.
+ *
+ * <p>Since the work that {@link ReceiveCommits} does may take a long, potentially unbounded amount
+ * of time, it runs in the background so it can be monitored for timeouts and cancelled, and have
+ * stalls reported to the user from the main thread.
+ */
 public class AsyncReceiveCommits implements PreReceiveHook {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -76,18 +90,19 @@
         ProjectState projectState,
         IdentifiedUser user,
         Repository repository,
-        @Nullable MessageSender messageSender,
-        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
+        @Nullable MessageSender messageSender);
   }
 
   public static class Module extends PrivateModule {
     @Override
     public void configure() {
+      install(new FactoryModuleBuilder().build(LazyPostReceiveHookChain.Factory.class));
       install(new FactoryModuleBuilder().build(AsyncReceiveCommits.Factory.class));
       expose(AsyncReceiveCommits.Factory.class);
       // 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
@@ -101,26 +116,30 @@
 
   private class Worker implements ProjectRunnable {
     final MultiProgressMonitor progress;
+    final String name;
 
     private final Collection<ReceiveCommand> commands;
-    private final ReceiveCommits rc;
 
-    private Worker(Collection<ReceiveCommand> commands) {
+    private Worker(Collection<ReceiveCommand> commands, String name) {
       this.commands = commands;
-      rc = factory.create(projectState, user, rp, allRefsWatcher, extraReviewers);
-      rc.init();
-      rc.setMessageSender(messageSender);
+      this.name = name;
       progress = new MultiProgressMonitor(new MessageSenderOutputStream(), "Processing changes");
     }
 
     @Override
     public void run() {
-      rc.processCommands(commands, progress);
+      String oldName = Thread.currentThread().getName();
+      Thread.currentThread().setName(oldName + "-for-" + name);
+      try {
+        receiveCommits.processCommands(commands, progress);
+      } finally {
+        Thread.currentThread().setName(oldName);
+      }
     }
 
     @Override
     public Project.NameKey getProjectNameKey() {
-      return rc.getProject().getNameKey();
+      return receiveCommits.getProject().getNameKey();
     }
 
     @Override
@@ -139,35 +158,91 @@
     }
 
     void sendMessages() {
-      rc.sendMessages();
+      receiveCommits.sendMessages();
     }
 
     private class MessageSenderOutputStream extends OutputStream {
       @Override
       public void write(int b) {
-        rc.getMessageSender().sendBytes(new byte[] {(byte) b});
+        receiveCommits.getMessageSender().sendBytes(new byte[] {(byte) b});
       }
 
       @Override
       public void write(byte[] what, int off, int len) {
-        rc.getMessageSender().sendBytes(what, off, len);
+        receiveCommits.getMessageSender().sendBytes(what, off, len);
       }
 
       @Override
       public void write(byte[] what) {
-        rc.getMessageSender().sendBytes(what);
+        receiveCommits.getMessageSender().sendBytes(what);
       }
 
       @Override
       public void flush() {
-        rc.getMessageSender().flush();
+        receiveCommits.getMessageSender().flush();
       }
     }
   }
 
-  private final ReceiveCommits.Factory factory;
+  private enum PushType {
+    CREATE_REPLACE,
+    NORMAL,
+    AUTOCLOSE,
+  }
+
+  @Singleton
+  private static class Metrics {
+    private final Histogram1<PushType> changes;
+    private final Timer1<PushType> latencyPerChange;
+    private final Timer1<PushType> latencyPerPush;
+    private final Counter0 timeouts;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      // For the changes metric the push type field is never set to PushType.NORMAL, hence it is not
+      // mentioned in the field description.
+      changes =
+          metricMaker.newHistogram(
+              "receivecommits/changes_per_push",
+              new Description("number of changes uploaded in a single push.").setCumulative(),
+              Field.ofEnum(PushType.class, "type", Metadata.Builder::pushType)
+                  .description("type of push (create/replace, autoclose)")
+                  .build());
+
+      Field<PushType> pushTypeField =
+          Field.ofEnum(PushType.class, "type", Metadata.Builder::pushType)
+              .description("type of push (create/replace, autoclose, normal)")
+              .build();
+
+      latencyPerChange =
+          metricMaker.newTimer(
+              "receivecommits/latency_per_push_per_change",
+              new Description(
+                      "Processing delay per push divided by the number of changes in said push. "
+                          + "(Only includes pushes which contain changes.)")
+                  .setUnit(Units.MILLISECONDS)
+                  .setCumulative(),
+              pushTypeField);
+
+      latencyPerPush =
+          metricMaker.newTimer(
+              "receivecommits/latency_per_push",
+              new Description("processing delay for a processing single push")
+                  .setUnit(Units.MILLISECONDS)
+                  .setCumulative(),
+              pushTypeField);
+
+      timeouts =
+          metricMaker.newCounter(
+              "receivecommits/timeout", new Description("rate of push timeouts").setRate());
+    }
+  }
+
+  private final Metrics metrics;
+  private final ReceiveCommits receiveCommits;
+  private final ResultChangeIds resultChangeIds;
   private final PermissionBackend.ForProject perm;
-  private final ReceivePack rp;
+  private final ReceivePack receivePack;
   private final ExecutorService executor;
   private final RequestScopePropagator scopePropagator;
   private final ReceiveConfig receiveConfig;
@@ -176,8 +251,6 @@
   private final ProjectState projectState;
   private final IdentifiedUser user;
   private final Repository repo;
-  private final MessageSender messageSender;
-  private final SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
   private final AllRefsWatcher allRefsWatcher;
 
   @Inject
@@ -189,16 +262,16 @@
       RequestScopePropagator scopePropagator,
       ReceiveConfig receiveConfig,
       TransferConfig transferConfig,
-      Provider<LazyPostReceiveHookChain> lazyPostReceive,
+      LazyPostReceiveHookChain.Factory lazyPostReceive,
       ContributorAgreementsChecker contributorAgreements,
+      Metrics metrics,
+      QuotaBackend quotaBackend,
       @Named(TIMEOUT_NAME) long timeoutMillis,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted Repository repo,
-      @Assisted @Nullable MessageSender messageSender,
-      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
+      @Assisted @Nullable MessageSender messageSender)
       throws PermissionBackendException {
-    this.factory = factory;
     this.executor = executor;
     this.scopePropagator = scopePropagator;
     this.receiveConfig = receiveConfig;
@@ -207,22 +280,21 @@
     this.projectState = projectState;
     this.user = user;
     this.repo = repo;
-    this.messageSender = messageSender;
-    this.extraReviewers = extraReviewers;
+    this.metrics = metrics;
 
     Project.NameKey projectName = projectState.getNameKey();
-    rp = new ReceivePack(repo);
-    rp.setAllowCreates(true);
-    rp.setAllowDeletes(true);
-    rp.setAllowNonFastForwards(true);
-    rp.setRefLogIdent(user.newRefLogIdent());
-    rp.setTimeout(transferConfig.getTimeout());
-    rp.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(projectState));
-    rp.setCheckReceivedObjects(projectState.getConfig().getCheckReceivedObjects());
-    rp.setRefFilter(new ReceiveRefFilter());
-    rp.setAllowPushOptions(true);
-    rp.setPreReceiveHook(this);
-    rp.setPostReceiveHook(lazyPostReceive.get());
+    receivePack = new ReceivePack(repo);
+    receivePack.setAllowCreates(true);
+    receivePack.setAllowDeletes(true);
+    receivePack.setAllowNonFastForwards(true);
+    receivePack.setRefLogIdent(user.newRefLogIdent());
+    receivePack.setTimeout(transferConfig.getTimeout());
+    receivePack.setMaxObjectSizeLimit(projectState.getEffectiveMaxObjectSizeLimit().value);
+    receivePack.setCheckReceivedObjects(projectState.getConfig().getCheckReceivedObjects());
+    receivePack.setRefFilter(new ReceiveRefFilter());
+    receivePack.setAllowPushOptions(true);
+    receivePack.setPreReceiveHook(this);
+    receivePack.setPostReceiveHook(lazyPostReceive.create(user, projectName));
 
     // If the user lacks READ permission, some references may be filtered and hidden from view.
     // Check objects mentioned inside the incoming pack file are reachable from visible refs.
@@ -231,17 +303,30 @@
       projectState.checkStatePermitsRead();
       this.perm.check(ProjectPermission.READ);
     } catch (AuthException | ResourceConflictException e) {
-      rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
+      receivePack.setCheckReferencedObjectsAreReachable(
+          receiveConfig.checkReferencedObjectsAreReachable);
     }
 
-    List<AdvertiseRefsHook> advHooks = new ArrayList<>(4);
     allRefsWatcher = new AllRefsWatcher();
-    advHooks.add(allRefsWatcher);
-    advHooks.add(
-        new DefaultAdvertiseRefsHook(perm, RefFilterOptions.builder().setFilterMeta(true).build()));
-    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
-    advHooks.add(new HackPushNegotiateHook());
-    rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
+    receivePack.setAdvertiseRefsHook(
+        ReceiveCommitsAdvertiseRefsHookChain.create(
+            allRefsWatcher, perm, queryProvider, projectName));
+    resultChangeIds = new ResultChangeIds();
+    receiveCommits =
+        factory.create(
+            projectState, user, receivePack, allRefsWatcher, messageSender, resultChangeIds);
+    receiveCommits.init();
+    QuotaResponse.Aggregated availableTokens =
+        quotaBackend.user(user).project(projectName).availableTokens(REPOSITORY_SIZE_GROUP);
+    try {
+      availableTokens.throwOnError();
+    } catch (QuotaException e) {
+      logger.atWarning().withCause(e).log(
+          "Quota %s availableTokens request failed for project %s",
+          REPOSITORY_SIZE_GROUP, projectName);
+      throw new RuntimeException(e);
+    }
+    availableTokens.availableTokens().ifPresent(v -> receivePack.setMaxObjectSizeLimit(v));
   }
 
   /** Determine if the user can upload commits. */
@@ -266,11 +351,19 @@
 
   @Override
   public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
-    Worker w = new Worker(commands);
+    if (commands.stream().anyMatch(c -> c.getResult() != Result.NOT_ATTEMPTED)) {
+      // Stop processing when command was already processed by previously invoked
+      // pre-receive hooks
+      return;
+    }
+
+    long startNanos = System.nanoTime();
+    Worker w = new Worker(commands, Thread.currentThread().getName());
     try {
       w.progress.waitFor(
           executor.submit(scopePropagator.wrap(w)), timeoutMillis, TimeUnit.MILLISECONDS);
     } catch (ExecutionException e) {
+      metrics.timeouts.increment();
       logger.atWarning().withCause(e).log(
           "Error in ReceiveCommits while processing changes for project %s",
           projectState.getName());
@@ -285,9 +378,42 @@
     } finally {
       w.sendMessages();
     }
+
+    long deltaNanos = System.nanoTime() - startNanos;
+    int totalChanges = 0;
+
+    PushType pushType;
+    if (resultChangeIds.isMagicPush()) {
+      pushType = PushType.CREATE_REPLACE;
+      List<Change.Id> created = resultChangeIds.get(ResultChangeIds.Key.CREATED);
+      List<Change.Id> replaced = resultChangeIds.get(ResultChangeIds.Key.REPLACED);
+      metrics.changes.record(pushType, created.size() + replaced.size());
+      totalChanges = replaced.size() + created.size();
+    } else {
+      List<Change.Id> autoclosed = resultChangeIds.get(ResultChangeIds.Key.AUTOCLOSED);
+      if (!autoclosed.isEmpty()) {
+        pushType = PushType.AUTOCLOSE;
+        metrics.changes.record(pushType, autoclosed.size());
+        totalChanges = autoclosed.size();
+      } else {
+        pushType = PushType.NORMAL;
+      }
+    }
+
+    if (totalChanges > 0) {
+      metrics.latencyPerChange.record(pushType, deltaNanos / totalChanges, NANOSECONDS);
+    }
+
+    metrics.latencyPerPush.record(pushType, deltaNanos, NANOSECONDS);
+  }
+
+  /** Returns the Change.Ids that were processed in onPreReceive */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public ResultChangeIds getResultChangeIds() {
+    return resultChangeIds;
   }
 
   public ReceivePack getReceivePack() {
-    return rp;
+    return receivePack;
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index fddb9d6..a4f4d93 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -5,13 +5,17 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
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..2706eaa
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -0,0 +1,177 @@
+// 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 com.google.gerrit.git.ObjectIds.abbreviateName;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+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.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 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 BranchNameKey branch;
+  private final SshInfo sshInfo;
+
+  interface Factory {
+    BranchCommitValidator create(
+        ProjectState projectState, BranchNameKey branch, IdentifiedUser user);
+  }
+
+  /** A boolean validation status and a list of additional messages. */
+  @AutoValue
+  abstract static class Result {
+    static Result create(boolean isValid, ImmutableList<CommitValidationMessage> messages) {
+      return new AutoValue_BranchCommitValidator_Result(isValid, messages);
+    }
+
+    /** Whether the commit is valid. */
+    abstract boolean isValid();
+
+    /**
+     * A list of messages related to the validation. Messages may be present regardless of the
+     * {@link #isValid()} status.
+     */
+    abstract ImmutableList<CommitValidationMessage> messages();
+  }
+
+  @Inject
+  BranchCommitValidator(
+      CommitValidators.Factory commitValidatorsFactory,
+      PermissionBackend permissionBackend,
+      SshInfo sshInfo,
+      @Assisted ProjectState projectState,
+      @Assisted BranchNameKey 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.
+   * @return The validation {@link Result}.
+   */
+  Result validateCommit(
+      ObjectReader objectReader,
+      ReceiveCommand cmd,
+      RevCommit commit,
+      boolean isMerged,
+      NoteMap rejectCommits,
+      @Nullable Change change)
+      throws IOException {
+    return validateCommit(objectReader, cmd, commit, isMerged, rejectCommits, change, false);
+  }
+
+  /**
+   * 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.
+   * @param skipValidation whether 'skip-validation' was requested.
+   * @return The validation {@link Result}.
+   */
+  Result validateCommit(
+      ObjectReader objectReader,
+      ReceiveCommand cmd,
+      RevCommit commit,
+      boolean isMerged,
+      NoteMap rejectCommits,
+      @Nullable Change change,
+      boolean skipValidation)
+      throws IOException {
+    ImmutableList.Builder<CommitValidationMessage> messages = new ImmutableList.Builder<>();
+    try (CommitReceivedEvent receiveEvent =
+        new CommitReceivedEvent(cmd, project, branch.branch(), 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,
+                skipValidation);
+      }
+
+      for (CommitValidationMessage m : validators.validate(receiveEvent)) {
+        messages.add(
+            new CommitValidationMessage(
+                messageForCommit(commit, m.getMessage(), objectReader), m.getType()));
+      }
+    } 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(), objectReader), m.getType()));
+      }
+      cmd.setResult(REJECTED_OTHER_REASON, messageForCommit(commit, e.getMessage(), objectReader));
+      return Result.create(false, messages.build());
+    }
+    return Result.create(true, messages.build());
+  }
+
+  private String messageForCommit(RevCommit c, String msg, ObjectReader objectReader)
+      throws IOException {
+    return String.format("commit %s: %s", abbreviateName(c, objectReader), msg);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
index 1001d04..0076e7a 100644
--- a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
+++ b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
@@ -14,16 +14,15 @@
 
 package com.google.gerrit.server.git.receive;
 
-import static org.eclipse.jgit.lib.RefDatabase.ALL;
+import static java.util.stream.Collectors.toMap;
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Map;
 import java.util.Set;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -49,7 +48,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   /** Size of an additional ".have" line. */
-  private static final int HAVE_LINE_LEN = 4 + Constants.OBJECT_ID_STRING_LENGTH + 1 + 5 + 1;
+  private static final int HAVE_LINE_LEN = 4 + ObjectIds.STR_LEN + 1 + 5 + 1;
 
   /**
    * Maximum number of bytes to "waste" in the advertisement with a peek at this repository's
@@ -78,13 +77,13 @@
     Map<String, Ref> r = rp.getAdvertisedRefs();
     if (r == null) {
       try {
-        r = rp.getRepository().getRefDatabase().getRefs(ALL);
+        r =
+            rp.getRepository().getRefDatabase().getRefs().stream()
+                .collect(toMap(Ref::getName, x -> x));
       } catch (ServiceMayNotContinueException e) {
         throw e;
       } catch (IOException e) {
-        ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
-        ex.initCause(e);
-        throw ex;
+        throw new ServiceMayNotContinueException(e);
       }
     }
     rp.setAdvertisedRefs(r, history(r.values(), rp));
@@ -92,35 +91,30 @@
 
   private Set<ObjectId> history(Collection<Ref> refs, BaseReceivePack rp) {
     Set<ObjectId> alreadySending = rp.getAdvertisedObjects();
-    if (alreadySending.isEmpty()) {
-      alreadySending = idsOf(refs);
-    }
-
-    int max = MAX_HISTORY - Math.max(0, alreadySending.size() - refs.size());
-    if (max <= 0) {
-      return Collections.emptySet();
+    if (MAX_HISTORY <= alreadySending.size()) {
+      return alreadySending;
     }
 
     // Scan history until the advertisement is full.
     RevWalk rw = rp.getRevWalk();
     rw.reset();
     try {
-      for (Ref ref : refs) {
+      Set<ObjectId> tips = idsOf(refs);
+      for (ObjectId tip : tips) {
         try {
-          if (ref.getObjectId() != null) {
-            rw.markStart(rw.parseCommit(ref.getObjectId()));
-          }
+          rw.markStart(rw.parseCommit(tip));
         } catch (IOException badCommit) {
           continue;
         }
       }
 
-      Set<ObjectId> history = Sets.newHashSetWithExpectedSize(max);
+      Set<ObjectId> history = Sets.newHashSetWithExpectedSize(MAX_HISTORY);
+      history.addAll(alreadySending);
       try {
         int stepCnt = 0;
-        for (RevCommit c; history.size() < max && (c = rw.next()) != null; ) {
+        for (RevCommit c; history.size() < MAX_HISTORY && (c = rw.next()) != null; ) {
           if (c.getParentCount() <= 1
-              && !alreadySending.contains(c)
+              && !tips.contains(c)
               && (history.size() < BASE_COMMITS || (++stepCnt % STEP_COMMITS) == 0)) {
             history.add(c);
           }
diff --git a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
index 7adb21b..8e200eb 100644
--- a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
+++ b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
@@ -14,25 +14,78 @@
 
 package com.google.gerrit.server.git.receive;
 
-import com.google.gerrit.extensions.registration.DynamicSet;
+import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.quota.QuotaBackend;
+import com.google.gerrit.server.quota.QuotaResponse;
 import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import org.eclipse.jgit.transport.PostReceiveHook;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.ReceivePack;
 
-class LazyPostReceiveHookChain implements PostReceiveHook {
-  private final DynamicSet<PostReceiveHook> hooks;
+/**
+ * Class is responsible for calling all registered post-receive hooks. In addition, in case when
+ * repository size quota is defined, it requests tokens (pack size) that were received. This is the
+ * final step of enforcing repository size quota that deducts token from available tokens.
+ */
+public class LazyPostReceiveHookChain implements PostReceiveHook {
+  interface Factory {
+    LazyPostReceiveHookChain create(CurrentUser user, Project.NameKey project);
+  }
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PluginSetContext<PostReceiveHook> hooks;
+  private final QuotaBackend quotaBackend;
+  private final CurrentUser user;
+  private final Project.NameKey project;
 
   @Inject
-  LazyPostReceiveHookChain(DynamicSet<PostReceiveHook> hooks) {
+  LazyPostReceiveHookChain(
+      PluginSetContext<PostReceiveHook> hooks,
+      QuotaBackend quotaBackend,
+      @Assisted CurrentUser user,
+      @Assisted Project.NameKey project) {
     this.hooks = hooks;
+    this.quotaBackend = quotaBackend;
+    this.user = user;
+    this.project = project;
   }
 
   @Override
   public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
-    for (PostReceiveHook h : hooks) {
-      h.onPostReceive(rp, commands);
+    hooks.runEach(h -> h.onPostReceive(rp, commands));
+    if (affectsSize(rp, commands)) {
+      QuotaResponse.Aggregated a =
+          quotaBackend
+              .user(user)
+              .project(project)
+              .requestTokens(REPOSITORY_SIZE_GROUP, rp.getPackSize());
+      if (a.hasError()) {
+        String msg =
+            String.format(
+                "%s request failed for project %s with [%s]",
+                REPOSITORY_SIZE_GROUP, project, a.errorMessage());
+        logger.atWarning().log(msg);
+        throw new RuntimeException(msg);
+      }
     }
   }
+
+  public static boolean affectsSize(ReceivePack rp, Collection<ReceiveCommand> commands) {
+    if (rp.getPackSize() > 0L) {
+      for (ReceiveCommand cmd : commands) {
+        if (cmd.getType() != ReceiveCommand.Type.DELETE) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/MessageSender.java b/java/com/google/gerrit/server/git/receive/MessageSender.java
index a338021..4fa5451 100644
--- a/java/com/google/gerrit/server/git/receive/MessageSender.java
+++ b/java/com/google/gerrit/server/git/receive/MessageSender.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.git.receive;
 
+import com.google.gerrit.common.UsedAt;
+
 /**
  * Interface used by {@link ReceiveCommits} for send messages over the wire during {@code
  * receive-pack}.
  */
+@UsedAt(UsedAt.Project.GOOGLE)
 public interface MessageSender {
   void sendMessage(String what);
 
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 5c3984c..38e60d4 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.server.git.receive;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-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;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.flogger.LazyArgs.lazy;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
@@ -29,7 +31,7 @@
 import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Comparator.comparingInt;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
@@ -38,14 +40,14 @@
 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.common.base.Function;
+import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.BiMap;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
@@ -53,60 +55,65 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
+import com.google.common.collect.Streams;
 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.UsedAt;
 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.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 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.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 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.PublishCommentUtil;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
 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.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
+import com.google.gerrit.server.change.SetPrivateOp;
 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,24 +123,29 @@
 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.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
 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.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.CreateRefControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -142,7 +154,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,10 +170,9 @@
 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.server.util.time.TimeUtil;
 import com.google.gerrit.util.cli.CmdLineParser;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -179,14 +189,17 @@
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
-import java.util.regex.Matcher;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -211,37 +224,24 @@
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.Option;
 
-/** Receives change upload using the Git receive-pack protocol. */
+/**
+ * Receives change upload using the Git receive-pack protocol.
+ *
+ * <p>Conceptually, most use of Gerrit is a push of some commits to refs/for/BRANCH. However, the
+ * receive-pack protocol that this is based on allows multiple ref updates to be processed at once.
+ * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH), and legacy pushes
+ * (refs/changes/CHANGE). It is hard to split this class up further, because normal pushes can also
+ * result in updates to reviews, through the autoclose mechanism.
+ */
 class ReceiveCommits {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private enum ReceiveError {
-    CONFIG_UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "Configuration changes can only be pushed by project owners\n"
-            + "who also have 'Push' rights on "
-            + RefNames.REFS_CONFIG),
-    UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "To push into this reference you need 'Push' rights."),
-    DELETE(
-        "You need 'Delete Reference' rights or 'Push' rights with the \n"
-            + "'Force Push' flag set to delete references."),
-    DELETE_CHANGES("Cannot delete from '" + REFS_CHANGES + "'"),
-    CODE_REVIEW(
-        "You need 'Push' rights to upload code review requests.\n"
-            + "Verify that you are pushing to the right branch.");
-
-    private final String value;
-
-    ReceiveError(String value) {
-      this.value = value;
-    }
-
-    String get() {
-      return value;
-    }
-  }
+  private static final String CODE_REVIEW_ERROR =
+      "You need 'Push' rights to upload code review requests.\n"
+          + "Verify that you are pushing to the right branch.";
+  private static final String CANNOT_DELETE_CHANGES = "Cannot delete from '" + REFS_CHANGES + "'";
+  private static final String CANNOT_DELETE_CONFIG =
+      "Cannot delete project configuration from '" + RefNames.REFS_CONFIG + "'";
 
   interface Factory {
     ReceiveCommits create(
@@ -249,18 +249,19 @@
         IdentifiedUser user,
         ReceivePack receivePack,
         AllRefsWatcher allRefsWatcher,
-        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
+        MessageSender messageSender,
+        ResultChangeIds resultChangeIds);
   }
 
   private class ReceivePackMessageSender implements MessageSender {
     @Override
     public void sendMessage(String what) {
-      rp.sendMessage(what);
+      receivePack.sendMessage(what);
     }
 
     @Override
     public void sendError(String what) {
-      rp.sendError(what);
+      receivePack.sendError(what);
     }
 
     @Override
@@ -271,7 +272,7 @@
     @Override
     public void sendBytes(byte[] what, int off, int len) {
       try {
-        rp.getMessageOutputStream().write(what, off, len);
+        receivePack.getMessageOutputStream().write(what, off, len);
       } catch (IOException e) {
         // Ignore write failures (matching JGit behavior).
       }
@@ -280,26 +281,21 @@
     @Override
     public void flush() {
       try {
-        rp.getMessageOutputStream().flush();
+        receivePack.getMessageOutputStream().flush();
       } catch (IOException e) {
         // Ignore write failures (matching JGit behavior).
       }
     }
   }
 
-  private static final Function<Exception, RestApiException> INSERT_EXCEPTION =
-      new Function<Exception, RestApiException>() {
-        @Override
-        public RestApiException apply(Exception input) {
-          if (input instanceof RestApiException) {
-            return (RestApiException) input;
-          } else if ((input instanceof ExecutionException)
-              && (input.getCause() instanceof RestApiException)) {
-            return (RestApiException) input.getCause();
-          }
-          return new RestApiException("Error inserting change/patchset", input);
-        }
-      };
+  private static RestApiException asRestApiException(Exception e) {
+    if (e instanceof RestApiException) {
+      return (RestApiException) e;
+    } else if ((e instanceof ExecutionException) && (e.getCause() instanceof RestApiException)) {
+      return (RestApiException) e.getCause();
+    }
+    return new RestApiException("Error inserting change/patchset", e);
+  }
 
   // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up
   // somewhat, and kept sorted lexicographically within sections, except where later assignments
@@ -307,7 +303,6 @@
 
   // Injected fields.
   private final AccountResolver accountResolver;
-  private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final AllProjectsName allProjectsName;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangeEditUtil editUtil;
@@ -316,15 +311,18 @@
   private final ChangeNotes.Factory notesFactory;
   private final ChangeReportFormatter changeFormatter;
   private final CmdLineParser.Factory optionParserFactory;
-  private final CommitValidators.Factory commitValidatorsFactory;
+  private final CommentsUtil commentsUtil;
+  private final PluginSetContext<CommentValidator> commentValidators;
+  private final BranchCommitValidator.Factory commitValidatorFactory;
+  private final Config config;
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final CreateRefControl createRefControl;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
-  private final DynamicSet<ReceivePackInitializer> initializers;
+  private final PluginSetContext<ReceivePackInitializer> initializers;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
-  private final NotesMigration notesMigration;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
+  private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final Provider<InternalChangeQuery> queryProvider;
@@ -333,90 +331,77 @@
   private final ReceiveConfig receiveConfig;
   private final RefOperationValidators.Factory refValidatorsFactory;
   private final ReplaceOp.Factory replaceOpFactory;
+  private final PluginSetContext<RequestListener> requestListeners;
   private final RetryHelper retryHelper;
   private final RequestScopePropagator requestScopePropagator;
-  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;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SetPrivateOp.Factory setPrivateOpFactory;
 
   // Assisted injected fields.
   private final AllRefsWatcher allRefsWatcher;
-  private final ImmutableSetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
   private final ProjectState projectState;
   private final IdentifiedUser user;
-  private final ReceivePack rp;
+  private final ReceivePack receivePack;
 
   // Immutable fields derived from constructor arguments.
-  private final boolean allowPushToRefsChanges;
+  private final boolean allowProjectOwnersToChangeParent;
   private final LabelTypes labelTypes;
   private final NoteMap rejectCommits;
   private final PermissionBackend.ForProject permissions;
   private final Project project;
   private final Repository repo;
-  private final RequestId receiveId;
 
   // Collections populated during processing.
   private final List<UpdateGroupsRequest> updateGroups;
   private final List<ValidationMessage> messages;
-  private final ListMultimap<ReceiveError, String> errors;
+  /** Multimap of error text to refnames that produced that error. */
+  private final ListMultimap<String, String> errors;
+
   private final ListMultimap<String, String> pushOptions;
   private final Map<Change.Id, ReplaceRequest> replaceByChange;
-  private final Set<ObjectId> validCommits;
-
-  /**
-   * Actual commands to be executed, as opposed to the mix of actual and magic commands that were
-   * provided over the wire.
-   *
-   * <p>Excludes commands executed implicitly as part of other {@link BatchUpdateOp}s, such as
-   * creating patch set refs.
-   */
-  private final List<ReceiveCommand> actualCommands;
 
   // Collections lazily populated during processing.
-  private List<CreateRequest> newChanges;
   private ListMultimap<Change.Id, Ref> refsByChange;
   private ListMultimap<ObjectId, Ref> refsById;
 
   // Other settings populated during processing.
   private MagicBranchInput magicBranch;
   private boolean newChangeForAllNotInTarget;
-  private String setFullNameTo;
   private boolean setChangeAsPrivate;
   private Optional<NoteDbPushOption> noteDbPushOption;
+  private Optional<String> tracePushOption;
 
-  // Handles for outputting back over the wire to the end user.
-  private Task newProgress;
-  private Task replaceProgress;
-  private Task closeProgress;
-  private Task commandProgress;
   private MessageSender messageSender;
+  private ResultChangeIds resultChangeIds;
 
   @Inject
   ReceiveCommits(
       AccountResolver accountResolver,
-      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       AllProjectsName allProjectsName,
       BatchUpdate.Factory batchUpdateFactory,
-      @GerritServerConfig Config cfg,
+      ProjectConfig.Factory projectConfigFactory,
+      @GerritServerConfig Config config,
       ChangeEditUtil editUtil,
       ChangeIndexer indexer,
       ChangeInserter.Factory changeInserterFactory,
       ChangeNotes.Factory notesFactory,
       DynamicItem<ChangeReportFormatter> changeFormatterProvider,
       CmdLineParser.Factory optionParserFactory,
-      CommitValidators.Factory commitValidatorsFactory,
+      CommentsUtil commentsUtil,
+      BranchCommitValidator.Factory commitValidatorFactory,
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      DynamicSet<ReceivePackInitializer> initializers,
+      PluginSetContext<ReceivePackInitializer> initializers,
+      PluginSetContext<CommentValidator> commentValidators,
       MergedByPushOp.Factory mergedByPushOpFactory,
-      NotesMigration notesMigration,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
+      DynamicSet<PerformanceLogger> performanceLoggers,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       Provider<InternalChangeQuery> queryProvider,
@@ -425,32 +410,33 @@
       ReceiveConfig receiveConfig,
       RefOperationValidators.Factory refValidatorsFactory,
       ReplaceOp.Factory replaceOpFactory,
+      PluginSetContext<RequestListener> requestListeners,
       RetryHelper retryHelper,
       RequestScopePropagator requestScopePropagator,
-      ReviewDb db,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
-      SshInfo sshInfo,
       SubmoduleOp.Factory subOpFactory,
       TagCache tagCache,
-      @CanonicalWebUrl @Nullable String canonicalWebUrl,
+      SetPrivateOp.Factory setPrivateOpFactory,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
       @Assisted AllRefsWatcher allRefsWatcher,
-      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
+      @Nullable @Assisted MessageSender messageSender,
+      @Assisted ResultChangeIds resultChangeIds)
       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.commentsUtil = commentsUtil;
+    this.commentValidators = commentValidators;
+    this.commitValidatorFactory = commitValidatorFactory;
+    this.config = config;
     this.createRefControl = createRefControl;
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
-    this.db = db;
     this.editUtil = editUtil;
     this.hashtagsFactory = hashtagsFactory;
     this.indexer = indexer;
@@ -458,7 +444,6 @@
     this.mergeOpProvider = mergeOpProvider;
     this.mergedByPushOpFactory = mergedByPushOpFactory;
     this.notesFactory = notesFactory;
-    this.notesMigration = notesMigration;
     this.optionParserFactory = optionParserFactory;
     this.ormProvider = ormProvider;
     this.patchSetInfoFactory = patchSetInfoFactory;
@@ -466,69 +451,57 @@
     this.pluginConfigEntries = pluginConfigEntries;
     this.projectCache = projectCache;
     this.psUtil = psUtil;
+    this.performanceLoggers = performanceLoggers;
     this.queryProvider = queryProvider;
     this.receiveConfig = receiveConfig;
     this.refValidatorsFactory = refValidatorsFactory;
     this.replaceOpFactory = replaceOpFactory;
+    this.requestListeners = requestListeners;
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
-    this.sshInfo = sshInfo;
     this.subOpFactory = subOpFactory;
     this.tagCache = tagCache;
+    this.projectConfigFactory = projectConfigFactory;
+    this.setPrivateOpFactory = setPrivateOpFactory;
 
     // Assisted injected fields.
     this.allRefsWatcher = allRefsWatcher;
-    this.extraReviewers = ImmutableSetMultimap.copyOf(extraReviewers);
     this.projectState = projectState;
     this.user = user;
-    this.rp = rp;
+    this.receivePack = rp;
 
     // Immutable fields derived from constructor arguments.
-    allowPushToRefsChanges = cfg.getBoolean("receive", "allowPushToRefsChanges", false);
     repo = rp.getRepository();
     project = projectState.getProject();
     labelTypes = projectState.getLabelTypes();
     permissions = permissionBackend.user(user).project(project.getNameKey());
-    receiveId = RequestId.forProject(project.getNameKey());
     rejectCommits = BanCommit.loadRejectCommitsMap(rp.getRepository(), rp.getRevWalk());
-    this.canonicalWebUrl = canonicalWebUrl;
 
     // Collections populated during processing.
-    actualCommands = new ArrayList<>();
     errors = MultimapBuilder.linkedHashKeys().arrayListValues().build();
     messages = new ArrayList<>();
     pushOptions = LinkedListMultimap.create();
     replaceByChange = new LinkedHashMap<>();
     updateGroups = new ArrayList<>();
-    validCommits = new HashSet<>();
 
-    // Collections lazily populated during processing.
-    newChanges = Collections.emptyList();
+    this.allowProjectOwnersToChangeParent =
+        config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
 
     // Other settings populated during processing.
     newChangeForAllNotInTarget =
         projectState.is(BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET);
 
     // Handles for outputting back over the wire to the end user.
-    messageSender = new ReceivePackMessageSender();
+    this.messageSender = messageSender != null ? messageSender : new ReceivePackMessageSender();
+    this.resultChangeIds = resultChangeIds;
   }
 
   void init() {
-    for (ReceivePackInitializer i : initializers) {
-      i.init(projectState.getNameKey(), rp);
-    }
-  }
-
-  /** Set a message sender for this operation. */
-  void setMessageSender(MessageSender ms) {
-    messageSender = ms != null ? ms : new ReceivePackMessageSender();
+    initializers.runEach(i -> i.init(projectState.getNameKey(), receivePack));
   }
 
   MessageSender getMessageSender() {
-    if (messageSender == null) {
-      setMessageSender(null);
-    }
     return messageSender;
   }
 
@@ -536,128 +509,281 @@
     return project;
   }
 
-  private void addMessage(String message) {
-    messages.add(new CommitValidationMessage(message, false));
+  private void addMessage(String message, ValidationMessage.Type type) {
+    messages.add(new CommitValidationMessage(message, type));
   }
 
-  void addError(String error) {
-    messages.add(new CommitValidationMessage(error, true));
+  private void addMessage(String message) {
+    messages.add(new CommitValidationMessage(message, ValidationMessage.Type.OTHER));
+  }
+
+  private void addError(String error) {
+    addMessage(error, ValidationMessage.Type.ERROR);
   }
 
   void sendMessages() {
     for (ValidationMessage m : messages) {
-      if (m.isError()) {
-        messageSender.sendError(m.getMessage());
-      } else {
-        messageSender.sendMessage(m.getMessage());
-      }
+      String msg = m.getType().getPrefix() + m.getMessage();
+
+      // Avoid calling sendError which will add its own error: prefix.
+      messageSender.sendMessage(msg);
     }
   }
 
   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);
+    parsePushOptions();
+    int commandCount = commands.size();
+    try (TraceContext traceContext =
+            TraceContext.newTrace(
+                tracePushOption.isPresent(),
+                tracePushOption.orElse(null),
+                (tagName, traceId) -> addMessage(tagName + ": " + traceId));
+        TraceTimer traceTimer =
+            newTimer("processCommands", Metadata.builder().resourceCount(commandCount));
+        PerformanceLogContext performanceLogContext =
+            new PerformanceLogContext(config, performanceLoggers)) {
+      RequestInfo requestInfo =
+          RequestInfo.builder(RequestInfo.RequestType.GIT_RECEIVE, user, traceContext)
+              .project(project.getNameKey())
+              .build();
+      requestListeners.runEach(l -> l.onRequest(requestInfo));
+      traceContext.addTag(RequestId.Type.RECEIVE_ID, new RequestId(project.getNameKey().get()));
 
-    try {
-      parsePushOptions();
-      parseCommands(commands);
-    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
-      for (ReceiveCommand cmd : actualCommands) {
-        if (cmd.getResult() == NOT_ATTEMPTED) {
-          cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
-        }
-      }
-      logError(String.format("Failed to process refs in %s", project.getName()), err);
+      // 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());
+
+      Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
+      commands =
+          commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
+      processCommandsUnsafe(commands, progress);
+      rejectRemaining(commands, "internal server error");
+
+      // 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();
     }
-    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-      selectNewAndReplacedChangesFromMagicBranch();
-    }
-    preparePatchSetsForReplace();
-    insertChangesAndPatchSets();
-    newProgress.end();
-    replaceProgress.end();
-
-    if (!errors.isEmpty()) {
-      logDebug("Handling error conditions: %s", errors.keySet());
-      for (ReceiveError error : errors.keySet()) {
-        rp.sendMessage(buildError(error, errors.get(error)));
-      }
-      rp.sendMessage(String.format("User: %s", user.getLoggableName()));
-      rp.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
-    }
-
-    Set<Branch.NameKey> branches = new HashSet<>();
-    for (ReceiveCommand c : actualCommands) {
-      // 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.
-      if (c.getResult() != OK) {
-        continue;
-      }
-      if (isHead(c) || isConfig(c)) {
-        switch (c.getType()) {
-          case CREATE:
-          case UPDATE:
-          case UPDATE_NONFASTFORWARD:
-            autoCloseChanges(c);
-            branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
-            break;
-
-          case DELETE:
-            break;
-        }
-      }
-    }
-
-    // Update superproject gitlinks if required.
-    if (!branches.isEmpty()) {
-      try (MergeOpRepoManager orm = ormProvider.get()) {
-        orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
-        SubmoduleOp op = subOpFactory.create(branches, orm);
-        op.updateSuperProjects();
-      } catch (SubmoduleException e) {
-        logError("Can't update the superprojects", e);
-      }
-    }
-
-    // Update account info with details discovered during commit walking.
-    updateAccountInfo();
-
-    closeProgress.end();
-    commandProgress.end();
-    progress.end();
-    reportMessages();
   }
 
-  private void reportMessages() {
-    List<CreateRequest> created =
-        newChanges.stream().filter(r -> r.change != null).collect(toList());
-    if (!created.isEmpty()) {
-      addMessage("");
-      addMessage("New Changes:");
-      for (CreateRequest c : created) {
-        addMessage(
-            changeFormatter.newChange(
-                ChangeReportFormatter.Input.builder().setChange(c.change).build()));
+  // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
+  private void processCommandsUnsafe(
+      Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
+    logger.atFine().log(
+        "Calling user: %s (groups = %s)",
+        user.getLoggableName(), user.getEffectiveGroups().getKnownGroups());
+
+    if (!projectState.getProject().getState().permitsWrite()) {
+      for (ReceiveCommand cmd : commands) {
+        reject(cmd, "prohibited by Gerrit: project state does not permit write");
       }
-      addMessage("");
+      return;
     }
 
-    List<ReplaceRequest> updated =
-        replaceByChange
-            .values()
-            .stream()
-            .filter(r -> !r.skip && r.inputCommand.getResult() == OK)
-            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
-            .collect(toList());
+    logger.atFine().log("Parsing %d commands", commands.size());
+
+    List<ReceiveCommand> magicCommands = new ArrayList<>();
+    List<ReceiveCommand> regularCommands = new ArrayList<>();
+
+    for (ReceiveCommand cmd : commands) {
+      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
+        magicCommands.add(cmd);
+      } else {
+        regularCommands.add(cmd);
+      }
+    }
+
+    int commandTypes = (magicCommands.isEmpty() ? 0 : 1) + (regularCommands.isEmpty() ? 0 : 1);
+
+    if (commandTypes > 1) {
+      rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
+      return;
+    }
+
+    try {
+      if (!regularCommands.isEmpty()) {
+        handleRegularCommands(regularCommands, progress);
+        return;
+      }
+
+      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);
+
+    logger.atFine().log(
+        "Command results: %s",
+        lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))));
+  }
+
+  private void sendErrorMessages() {
+    if (!errors.isEmpty()) {
+      logger.atFine().log("Handling error conditions: %s", errors.keySet());
+      for (String error : errors.keySet()) {
+        receivePack.sendMessage("error: " + buildError(error, errors.get(error)));
+      }
+      receivePack.sendMessage(String.format("User: %s", user.getLoggableName()));
+      receivePack.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
+    }
+  }
+
+  private void handleRegularCommands(List<ReceiveCommand> cmds, MultiProgressMonitor progress)
+      throws PermissionBackendException, IOException, NoSuchProjectException {
+    try (TraceTimer traceTimer =
+        newTimer("handleRegularCommands", Metadata.builder().resourceCount(cmds.size()))) {
+      resultChangeIds.setMagicPush(false);
+      for (ReceiveCommand cmd : cmds) {
+        parseRegularCommand(cmd);
+      }
+
+      try (BatchUpdate bu =
+              batchUpdateFactory.create(
+                  project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+          ObjectInserter ins = repo.newObjectInserter();
+          ObjectReader reader = ins.newReader();
+          RevWalk rw = new RevWalk(reader)) {
+        bu.setRepository(repo, rw, ins);
+        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) {
+        rejectRemaining(cmds, "internal server error");
+        logger.atFine().withCause(e).log("update failed:");
+      }
+
+      Set<BranchNameKey> branches = new HashSet<>();
+      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.
+        if (c.getResult() != OK) {
+          continue;
+        }
+        if (isHead(c) || isConfig(c)) {
+          switch (c.getType()) {
+            case CREATE:
+            case UPDATE:
+            case UPDATE_NONFASTFORWARD:
+              Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
+              autoCloseChanges(c, closeProgress);
+              closeProgress.end();
+              branches.add(BranchNameKey.create(project.getNameKey(), c.getRefName()));
+              break;
+
+            case DELETE:
+              break;
+          }
+        }
+      }
+
+      // Update superproject gitlinks if required.
+      if (!branches.isEmpty()) {
+        try (MergeOpRepoManager orm = ormProvider.get()) {
+          orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
+          SubmoduleOp op = subOpFactory.create(branches, orm);
+          op.updateSuperProjects();
+        } catch (SubmoduleException e) {
+          logger.atSevere().withCause(e).log("Can't update the superprojects");
+        }
+      }
+    }
+  }
+
+  /** Appends messages for successful change creation/updates. */
+  private void queueSuccessMessages(List<CreateRequest> newChanges) {
+    // adjacency list for commit => parent
+    Map<String, String> adjList = new HashMap<>();
+    for (CreateRequest cr : newChanges) {
+      String parent = cr.commit.getParentCount() == 0 ? null : cr.commit.getParent(0).name();
+      adjList.put(cr.commit.name(), parent);
+    }
+    for (ReplaceRequest rr : replaceByChange.values()) {
+      String parent = null;
+      if (rr.revCommit != null) {
+        parent = rr.revCommit.getParentCount() == 0 ? null : rr.revCommit.getParent(0).name();
+      }
+      adjList.put(rr.newCommitId.name(), parent);
+    }
+
+    // get commits that are not parents
+    Set<String> leafs = new TreeSet<>(adjList.keySet());
+    leafs.removeAll(adjList.values());
+    // go backwards from the last commit to its parent(s)
+    Set<String> ordered = new LinkedHashSet<>();
+    for (String leaf : leafs) {
+      if (ordered.contains(leaf)) {
+        continue;
+      }
+      while (leaf != null) {
+        if (!ordered.contains(leaf)) {
+          ordered.add(leaf);
+        }
+        leaf = adjList.get(leaf);
+      }
+    }
+    // reverse the order to start with earliest commit
+    List<String> orderedCommits = new ArrayList<>(ordered);
+    Collections.reverse(orderedCommits);
+
+    Map<String, CreateRequest> created =
+        newChanges.stream()
+            .filter(r -> r.change != null)
+            .collect(Collectors.toMap(r -> r.commit.name(), r -> r));
+    Map<String, ReplaceRequest> updated =
+        replaceByChange.values().stream()
+            .filter(r -> r.inputCommand.getResult() == OK)
+            .collect(Collectors.toMap(r -> r.newCommitId.name(), r -> r));
+
+    if (created.isEmpty() && updated.isEmpty()) {
+      return;
+    }
+
+    addMessage("");
+    addMessage("SUCCESS");
+    addMessage("");
+
+    boolean edit = false;
+    Boolean isPrivate = null;
+    Boolean wip = null;
     if (!updated.isEmpty()) {
-      addMessage("");
-      addMessage("Updated Changes:");
-      boolean edit = magicBranch != null && (magicBranch.edit || magicBranch.draft);
-      Boolean isPrivate = null;
-      Boolean wip = null;
+      edit = magicBranch != null && (magicBranch.edit || magicBranch.draft);
       if (magicBranch != null) {
         if (magicBranch.isPrivate) {
           isPrivate = true;
@@ -670,150 +796,166 @@
           wip = false;
         }
       }
-      for (ReplaceRequest u : updated) {
-        String subject;
-        if (edit) {
-          try {
-            subject = rp.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);
-            subject = u.notes.getChange().getSubject();
-          }
-        } else {
-          subject = u.info.getSubject();
-        }
+    }
 
-        if (isPrivate == null) {
-          isPrivate = u.notes.getChange().isPrivate();
-        }
-        if (wip == null) {
-          wip = u.notes.getChange().isWorkInProgress();
-        }
-
-        ChangeReportFormatter.Input input =
-            ChangeReportFormatter.Input.builder()
-                .setChange(u.notes.getChange())
-                .setSubject(subject)
-                .setIsEdit(edit)
-                .setIsPrivate(isPrivate)
-                .setIsWorkInProgress(wip)
-                .build();
-        addMessage(changeFormatter.changeUpdated(input));
+    for (String commit : orderedCommits) {
+      if (created.get(commit) != null) {
+        addCreatedMessage(created.get(commit));
+      } else if (updated.get(commit) != null) {
+        addReplacedMessage(updated.get(commit), edit, isPrivate, wip);
       }
-      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.");
-    }
+    addMessage("");
   }
 
-  private void insertChangesAndPatchSets() {
-    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())));
-      return;
+  private void addCreatedMessage(CreateRequest c) {
+    addMessage(
+        changeFormatter.newChange(
+            ChangeReportFormatter.Input.builder().setChange(c.change).build()));
+  }
+
+  private void addReplacedMessage(ReplaceRequest u, boolean edit, Boolean isPrivate, Boolean wip) {
+    String subject;
+    if (edit) {
+      subject =
+          u.revCommit == null ? u.notes.getChange().getSubject() : u.revCommit.getShortMessage();
+    } else {
+      subject = u.info.getSubject();
     }
 
-    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.setRequestId(receiveId);
-      bu.setRefLogMessage("push");
+    if (isPrivate == null) {
+      isPrivate = u.notes.getChange().isPrivate();
+    }
+    if (wip == null) {
+      wip = u.notes.getChange().isWorkInProgress();
+    }
 
-      logDebug("Adding %d replace requests", newChanges.size());
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        replace.addOps(bu, replaceProgress);
+    ChangeReportFormatter.Input input =
+        ChangeReportFormatter.Input.builder()
+            .setChange(u.notes.getChange())
+            .setSubject(subject)
+            .setIsEdit(edit)
+            .setIsPrivate(isPrivate)
+            .setIsWorkInProgress(wip)
+            .build();
+    addMessage(changeFormatter.changeUpdated(input));
+  }
+
+  private void insertChangesAndPatchSets(List<CreateRequest> newChanges, Task replaceProgress) {
+    try (TraceTimer traceTimer =
+        newTimer(
+            "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
+      ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
+      if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
+        logger.atWarning().log(
+            "Skipping change updates on %s because ref update failed: %s %s",
+            project.getName(),
+            magicBranchCmd.getResult(),
+            Strings.nullToEmpty(magicBranchCmd.getMessage()));
+        return;
       }
 
-      logDebug("Adding %d create requests", newChanges.size());
-      for (CreateRequest create : newChanges) {
-        create.addOps(bu);
-      }
-
-      logDebug("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");
-      try {
-        bu.execute();
-      } catch (UpdateException e) {
-        throw INSERT_EXCEPTION.apply(e);
-      }
-      if (magicBranchCmd != null) {
-        magicBranchCmd.setResult(OK);
-      }
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        String rejectMessage = replace.getRejectMessage();
-        if (rejectMessage == null) {
-          if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
-            // Not necessarily the magic branch, so need to set OK on the original value.
-            replace.inputCommand.setResult(OK);
-          }
-        } else {
-          logDebug("Rejecting due to message from ReplaceOp");
-          reject(replace.inputCommand, rejectMessage);
+      try (BatchUpdate bu =
+              batchUpdateFactory.create(
+                  project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+          ObjectInserter ins = repo.newObjectInserter();
+          ObjectReader reader = ins.newReader();
+          RevWalk rw = new RevWalk(reader)) {
+        bu.setRepository(repo, rw, ins);
+        bu.setRefLogMessage("push");
+        if (magicBranch != null) {
+          bu.setNotify(magicBranch.getNotifyForNewChange());
         }
-      }
 
-    } catch (ResourceConflictException e) {
-      addMessage(e.getMessage());
-      reject(magicBranchCmd, "conflict");
-    } catch (RestApiException | IOException err) {
-      logError("Can't insert change/patch set for " + project.getName(), err);
-      reject(magicBranchCmd, "internal server error: " + err.getMessage());
-    }
+        logger.atFine().log("Adding %d replace requests", newChanges.size());
+        for (ReplaceRequest replace : replaceByChange.values()) {
+          if (magicBranch != null) {
+            bu.setNotifyHandling(replace.ontoChange, magicBranch.getNotifyHandling(replace.notes));
+          }
+          replace.addOps(bu, replaceProgress);
+        }
 
-    if (magicBranch != null && magicBranch.submit) {
-      try {
-        submit(newChanges, replaceByChange.values());
+        logger.atFine().log("Adding %d create requests", newChanges.size());
+        for (CreateRequest create : newChanges) {
+          create.addOps(bu);
+        }
+
+        logger.atFine().log("Adding %d group update requests", newChanges.size());
+        updateGroups.forEach(r -> r.addOps(bu));
+
+        logger.atFine().log("Executing batch");
+        try {
+          bu.execute();
+        } catch (UpdateException e) {
+          throw asRestApiException(e);
+        }
+
+        replaceByChange.values().stream()
+            .forEach(req -> resultChangeIds.add(ResultChangeIds.Key.REPLACED, req.ontoChange));
+        newChanges.stream()
+            .forEach(req -> resultChangeIds.add(ResultChangeIds.Key.CREATED, req.changeId));
+
+        if (magicBranchCmd != null) {
+          magicBranchCmd.setResult(OK);
+        }
+        for (ReplaceRequest replace : replaceByChange.values()) {
+          String rejectMessage = replace.getRejectMessage();
+          if (rejectMessage == null) {
+            if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
+              // Not necessarily the magic branch, so need to set OK on the original value.
+              replace.inputCommand.setResult(OK);
+            }
+          } else {
+            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
-          | OrmException
-          | UpdateException
-          | IOException
-          | ConfigInvalidException
-          | PermissionBackendException e) {
-        logError("Error submitting changes to " + project.getName(), e);
-        reject(magicBranchCmd, "error during submit");
+      } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
+        logger.atFine().withCause(e).log("Rejecting due to client error");
+        reject(magicBranchCmd, e.getMessage());
+      } catch (RestApiException | IOException e) {
+        logger.atSevere().withCause(e).log(
+            "Can't insert change/patch set for %s", project.getName());
+        reject(magicBranchCmd, "internal server error: " + e.getMessage());
+      }
+
+      if (magicBranch != null && magicBranch.submit) {
+        try {
+          submit(newChanges, replaceByChange.values());
+        } catch (ResourceConflictException e) {
+          addError(e.getMessage());
+          reject(magicBranchCmd, "conflict");
+        } catch (RestApiException
+            | StorageException
+            | UpdateException
+            | IOException
+            | ConfigInvalidException
+            | PermissionBackendException e) {
+          logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
+          reject(magicBranchCmd, "error during submit");
+        }
       }
     }
   }
 
-  private String buildError(ReceiveError error, List<String> branches) {
+  private String buildError(String error, List<String> branches) {
     StringBuilder sb = new StringBuilder();
     if (branches.size() == 1) {
-      sb.append("Branch ").append(branches.get(0)).append(":\n");
-      sb.append(error.get());
+      sb.append("branch ").append(branches.get(0)).append(":\n");
+      sb.append(error);
       return sb.toString();
     }
-    sb.append("Branches");
-    String delim = " ";
-    for (String branch : branches) {
-      sb.append(delim).append(branch);
-      delim = ", ";
-    }
-    return sb.append(":\n").append(error.get()).toString();
+    sb.append("branches ").append(Joiner.on(", ").join(branches));
+    return sb.append(":\n").append(error).toString();
   }
 
+  /** Parses push options specified as "git push -o OPTION" */
   private void parsePushOptions() {
-    List<String> optionList = rp.getPushOptions();
+    List<String> optionList = receivePack.getPushOptions();
     if (optionList != null) {
       for (String option : optionList) {
         int e = option.indexOf('=');
@@ -828,8 +970,8 @@
     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.
-      String value = noteDbValues.get(noteDbValues.size() - 1);
+      // CmdLineParser behavior used by MagicBranchInput.
+      String value = Iterables.getLast(noteDbValues);
       noteDbPushOption = NoteDbPushOption.parse(value);
       if (!noteDbPushOption.isPresent()) {
         addError("Invalid value in -o " + NoteDbPushOption.OPTION_NAME + "=" + value);
@@ -837,66 +979,64 @@
     } else {
       noteDbPushOption = Optional.of(NoteDbPushOption.DISALLOW);
     }
+
+    List<String> traceValues = pushOptions.get("trace");
+    if (!traceValues.isEmpty()) {
+      tracePushOption = Optional.of(Iterables.getLast(traceValues));
+    } else {
+      tracePushOption = Optional.empty();
+    }
   }
 
-  private void parseCommands(Collection<ReceiveCommand> commands)
+  // Wrap ReceiveCommand so the progress counter works automatically.
+  private ReceiveCommand wrapReceiveCommand(ReceiveCommand cmd, Task progress) {
+    String refname = cmd.getRefName();
+
+    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+      refname = RefNames.refsUsers(user.getAccountId());
+      logger.atFine().log("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, refname);
+    }
+
+    // We must also update the original, because callers may inspect it afterwards to decide if
+    // the command went through or not.
+    return new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), refname, cmd.getType()) {
+      @Override
+      public void setResult(Result s, String m) {
+        if (getResult() == NOT_ATTEMPTED) { // Only report the progress update once.
+          progress.update(1);
+        }
+        // Counter intuitively, we don't check that results == NOT_ATTEMPTED here.
+        // This is so submit-on-push can still reject the update if the change is created
+        // successfully
+        // (status OK) but the submit failed (merge failed: REJECTED_OTHER_REASON).
+        super.setResult(s, m);
+        cmd.setResult(s, m);
+      }
+    };
+  }
+
+  /*
+   * Interpret a normal push.
+   */
+  private void parseRegularCommand(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
-    logDebug("Parsing %d commands", commands.size());
-    for (ReceiveCommand cmd : commands) {
+    try (TraceTimer traceTimer = newTimer("parseRegularCommand")) {
       if (cmd.getResult() != NOT_ATTEMPTED) {
         // Already rejected by the core receive process.
-        logDebug("Already processed by core: %s %s", cmd.getResult(), cmd);
-        continue;
+        logger.atFine().log("Already processed by core: %s %s", cmd.getResult(), cmd);
+        return;
       }
 
       if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
         reject(cmd, "not valid ref");
-        continue;
-      }
-
-      if (!projectState.getProject().getState().permitsWrite()) {
-        reject(cmd, "prohibited by Gerrit: project state does not permit write");
         return;
       }
-
-      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
-        parseMagicBranch(cmd);
-        continue;
-      }
-
-      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");
-        }
-        continue;
-      }
-
       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)) {
@@ -910,13 +1050,13 @@
                   + NoteDbPushOption.OPTION_NAME
                   + "="
                   + NoteDbPushOption.ALLOW.value());
-          continue;
+          return;
         }
         try {
           permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
         } catch (AuthException e) {
           reject(cmd, "NoteDb update requires access database permission");
-          continue;
+          return;
         }
       }
 
@@ -939,212 +1079,220 @@
 
         default:
           reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
-          continue;
+          return;
       }
 
       if (cmd.getResult() != NOT_ATTEMPTED) {
-        continue;
+        return;
       }
 
       if (isConfig(cmd)) {
-        logDebug("Processing %s command", cmd.getRefName());
-        try {
-          permissions.check(ProjectPermission.WRITE_CONFIG);
-        } catch (AuthException e) {
-          reject(
-              cmd,
-              String.format(
-                  "must be either project owner or have %s permission",
-                  ProjectPermission.WRITE_CONFIG.describeForException()));
-          continue;
-        }
+        validateConfigPush(cmd);
+      }
+    }
+  }
 
-        switch (cmd.getType()) {
-          case CREATE:
-          case UPDATE:
-          case UPDATE_NONFASTFORWARD:
-            try {
-              ProjectConfig cfg = new ProjectConfig(project.getNameKey());
-              cfg.load(rp.getRevWalk(), cmd.getNewId());
-              if (!cfg.getValidationErrors().isEmpty()) {
-                addError("Invalid project configuration:");
-                for (ValidationError err : cfg.getValidationErrors()) {
-                  addError("  " + err.getMessage());
-                }
-                reject(cmd, "invalid project configuration");
-                logError(
-                    "User "
-                        + user.getLoggableName()
-                        + " tried to push invalid project configuration "
-                        + cmd.getNewId().name()
-                        + " for "
-                        + project.getName());
-                continue;
+  /** Validates a push to refs/meta/config, and reject the command if it fails. */
+  private void validateConfigPush(ReceiveCommand cmd) throws PermissionBackendException {
+    try (TraceTimer traceTimer = newTimer("validateConfigPush")) {
+      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 = projectConfigFactory.create(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());
               }
-              Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
-              Project.NameKey oldParent = project.getParent(allProjectsName);
-              if (oldParent == null) {
-                // update of the 'All-Projects' project
-                if (newParent != null) {
-                  reject(cmd, "invalid project configuration: root project cannot have parent");
-                  continue;
-                }
-              } else {
-                if (!oldParent.equals(newParent)) {
+              reject(cmd, "invalid project configuration");
+              logger.atSevere().log(
+                  "User %s tried to push invalid project configuration %s for %s",
+                  user.getLoggableName(), cmd.getNewId().name(), project.getName());
+              return;
+            }
+            Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
+            Project.NameKey oldParent = project.getParent(allProjectsName);
+            if (oldParent == null) {
+              // update of the 'All-Projects' project
+              if (newParent != null) {
+                reject(cmd, "invalid project configuration: root project cannot have parent");
+                return;
+              }
+            } else {
+              if (!oldParent.equals(newParent)) {
+                if (allowProjectOwnersToChangeParent) {
+                  try {
+                    permissionBackend
+                        .user(user)
+                        .project(project.getNameKey())
+                        .check(ProjectPermission.WRITE_CONFIG);
+                  } catch (AuthException e) {
+                    reject(
+                        cmd, "invalid project configuration: only project owners can set parent");
+                    return;
+                  }
+                } else {
                   try {
                     permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
                   } catch (AuthException e) {
                     reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
-                    continue;
+                    return;
                   }
                 }
-
-                if (projectCache.get(newParent) == null) {
-                  reject(cmd, "invalid project configuration: parent does not exist");
-                  continue;
-                }
               }
 
-              for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
-                PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
-                ProjectConfigEntry configEntry = e.getProvider().get();
-                String value = pluginCfg.getString(e.getExportName());
-                String oldValue =
+              if (projectCache.get(newParent) == null) {
+                reject(cmd, "invalid project configuration: parent does not exist");
+                return;
+              }
+            }
+            validatePluginConfig(cmd, cfg);
+          } catch (Exception e) {
+            reject(cmd, "invalid project configuration");
+            logger.atSevere().withCause(e).log(
+                "User %s tried to push invalid project configuration %s for %s",
+                user.getLoggableName(), cmd.getNewId().name(), project.getName());
+            return;
+          }
+          break;
+
+        case DELETE:
+          break;
+
+        default:
+          reject(
+              cmd,
+              "prohibited by Gerrit: don't know how to handle config update of type "
+                  + cmd.getType());
+      }
+    }
+  }
+
+  /**
+   * validates a push to refs/meta/config for plugin configuration, and rejects the push if it
+   * fails.
+   */
+  private void validatePluginConfig(ReceiveCommand cmd, ProjectConfig cfg) {
+    for (Extension<ProjectConfigEntry> e : pluginConfigEntries) {
+      PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
+      ProjectConfigEntry configEntry = e.getProvider().get();
+      String value = pluginCfg.getString(e.getExportName());
+      String oldValue =
+          projectState.getConfig().getPluginConfig(e.getPluginName()).getString(e.getExportName());
+      if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
+        oldValue =
+            Arrays.stream(
                     projectState
                         .getConfig()
                         .getPluginConfig(e.getPluginName())
-                        .getString(e.getExportName());
-                if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
-                  oldValue =
-                      Arrays.stream(
-                              projectState
-                                  .getConfig()
-                                  .getPluginConfig(e.getPluginName())
-                                  .getStringList(e.getExportName()))
-                          .collect(joining("\n"));
-                }
+                        .getStringList(e.getExportName()))
+                .collect(joining("\n"));
+      }
 
-                if ((value == null ? oldValue != null : !value.equals(oldValue))
-                    && !configEntry.isEditable(projectState)) {
-                  reject(
-                      cmd,
-                      String.format(
-                          "invalid project configuration: Not allowed to set parameter"
-                              + " '%s' of plugin '%s' on project '%s'.",
-                          e.getExportName(), e.getPluginName(), project.getName()));
-                  continue;
-                }
+      if ((value == null ? oldValue != null : !value.equals(oldValue))
+          && !configEntry.isEditable(projectState)) {
+        reject(
+            cmd,
+            String.format(
+                "invalid project configuration: Not allowed to set parameter"
+                    + " '%s' of plugin '%s' on project '%s'.",
+                e.getExportName(), e.getPluginName(), project.getName()));
+        continue;
+      }
 
-                if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
-                    && value != null
-                    && !configEntry.getPermittedValues().contains(value)) {
-                  reject(
-                      cmd,
-                      String.format(
-                          "invalid project configuration: The value '%s' is "
-                              + "not permitted for parameter '%s' of plugin '%s'.",
-                          value, e.getExportName(), e.getPluginName()));
-                }
-              }
-            } catch (Exception e) {
-              reject(cmd, "invalid project configuration");
-              logError(
-                  "User "
-                      + user.getLoggableName()
-                      + " tried to push invalid project configuration "
-                      + cmd.getNewId().name()
-                      + " for "
-                      + project.getName(),
-                  e);
-              continue;
-            }
-            break;
-
-          case DELETE:
-            break;
-
-          default:
-            reject(
-                cmd,
-                "prohibited by Gerrit: don't know how to handle config update of type "
-                    + cmd.getType());
-            continue;
-        }
+      if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
+          && value != null
+          && !configEntry.getPermittedValues().contains(value)) {
+        reject(
+            cmd,
+            String.format(
+                "invalid project configuration: The value '%s' is "
+                    + "not permitted for parameter '%s' of plugin '%s'.",
+                value, e.getExportName(), e.getPluginName()));
       }
     }
   }
 
   private void parseCreate(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
-    RevObject obj;
-    try {
-      obj = rp.getRevWalk().parseAny(cmd.getNewId());
-    } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " creation",
-          err);
-      reject(cmd, "invalid object");
-      return;
-    }
-    logDebug("Creating %s", cmd);
+    try (TraceTimer traceTimer = newTimer("parseCreate")) {
+      RevObject obj;
+      try {
+        obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
+      } catch (IOException err) {
+        logger.atSevere().withCause(err).log(
+            "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName());
+        reject(cmd, "invalid object");
+        return;
+      }
+      logger.atFine().log("Creating %s", cmd);
 
-    if (isHead(cmd) && !isCommit(cmd)) {
-      return;
-    }
-
-    Branch.NameKey branch = new Branch.NameKey(project.getName(), cmd.getRefName());
-    try {
-      // Must pass explicit user instead of injecting a provider into CreateRefControl, since
-      // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
-      createRefControl.checkCreateRef(Providers.of(user), rp.getRepository(), branch, obj);
-    } catch (AuthException | ResourceConflictException denied) {
-      reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
-      return;
-    }
-
-    if (!validRefOperation(cmd)) {
-      // validRefOperation sets messages, so no need to provide more feedback.
-      return;
-    }
-
-    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) {
       if (isHead(cmd) && !isCommit(cmd)) {
         return;
       }
-      if (!validRefOperation(cmd)) {
+
+      BranchNameKey branch = BranchNameKey.create(project.getName(), cmd.getRefName());
+      try {
+        // Must pass explicit user instead of injecting a provider into CreateRefControl, since
+        // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
+        createRefControl.checkCreateRef(
+            Providers.of(user), receivePack.getRepository(), branch, obj);
+      } catch (AuthException denied) {
+        rejectProhibited(cmd, denied);
+        return;
+      } catch (ResourceConflictException denied) {
+        reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
         return;
       }
-      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());
+
+      if (validRefOperation(cmd)) {
+        validateRegularPushCommits(
+            BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
       }
-      reject(cmd, "prohibited by Gerrit: ref update access denied");
+    }
+  }
+
+  private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
+    try (TraceTimer traceTimer = TraceContext.newTimer("parseUpdate")) {
+      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)) {
+          validateRegularPushCommits(
+              BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
+        }
+      } else {
+        rejectProhibited(cmd, err.get());
+      }
     }
   }
 
   private boolean isCommit(ReceiveCommand cmd) {
     RevObject obj;
     try {
-      obj = rp.getRevWalk().parseAny(cmd.getNewId());
+      obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      logError("Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName(), err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return false;
     }
@@ -1157,93 +1305,116 @@
   }
 
   private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    logDebug("Deleting %s", cmd);
-    if (cmd.getRefName().startsWith(REFS_CHANGES)) {
-      errors.put(ReceiveError.DELETE_CHANGES, cmd.getRefName());
-      reject(cmd, "cannot delete changes");
-    } else if (canDelete(cmd)) {
-      if (!validRefOperation(cmd)) {
-        return;
+    try (TraceTimer traceTimer = newTimer("parseDelete")) {
+      logger.atFine().log("Deleting %s", cmd);
+      if (cmd.getRefName().startsWith(REFS_CHANGES)) {
+        errors.put(CANNOT_DELETE_CHANGES, cmd.getRefName());
+        reject(cmd, "cannot delete changes");
+      } else if (isConfigRef(cmd.getRefName())) {
+        errors.put(CANNOT_DELETE_CONFIG, cmd.getRefName());
+        reject(cmd, "cannot delete project configuration");
       }
-      actualCommands.add(cmd);
-    } else if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
-      reject(cmd, "cannot delete project configuration");
-    } 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;
+      Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE);
+      if (!err.isPresent()) {
+        validRefOperation(cmd);
+      } else {
+        rejectProhibited(cmd, err.get());
+      }
     }
   }
 
   private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException {
-    RevCommit newObject;
-    try {
-      newObject = rp.getRevWalk().parseCommit(cmd.getNewId());
-    } catch (IncorrectObjectTypeException notCommit) {
-      newObject = null;
-    } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " forced update",
-          err);
-      reject(cmd, "invalid object");
-      return;
-    }
-    logDebug("Rewinding %s", cmd);
-
-    if (newObject != null) {
-      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-      if (cmd.getResult() != NOT_ATTEMPTED) {
+    try (TraceTimer traceTimer = newTimer("parseRewind")) {
+      try {
+        receivePack.getRevWalk().parseCommit(cmd.getNewId());
+      } catch (IOException err) {
+        logger.atSevere().withCause(err).log(
+            "Invalid object %s for %s forced update", cmd.getNewId().name(), cmd.getRefName());
+        reject(cmd, "invalid object");
         return;
       }
-    }
+      logger.atFine().log("Rewinding %s", cmd);
 
-    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);
-    } else {
-      cmd.setResult(REJECTED_OTHER_REASON, "need '" + PermissionRule.FORCE_PUSH + "' privilege.");
+      validateRegularPushCommits(BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
+      if (cmd.getResult() != NOT_ATTEMPTED) {
+        return;
+      }
+
+      Optional<AuthException> err = checkRefPermission(cmd, RefPermission.FORCE_UPDATE);
+      if (err.isPresent()) {
+        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();
 
+    private final IdentifiedUser user;
+    private final ProjectState projectState;
+    private final boolean defaultPublishComments;
+
+    boolean deprecatedTopicSeen;
     final ReceiveCommand cmd;
     final LabelTypes labelTypes;
-    final NotesMigration notesMigration;
-    private final boolean defaultPublishComments;
-    Branch.NameKey dest;
+    /**
+     * Result of running {@link CommentValidator}-s on drafts that are published with the commit
+     * (which happens iff {@code --publish-comments} is set). Remains {@code true} if none are
+     * installed.
+     */
+    private boolean commentsValid = true;
+
+    BranchNameKey dest;
     PermissionBackend.ForRef perm;
-    Set<Account.Id> reviewer = Sets.newLinkedHashSet();
-    Set<Account.Id> cc = Sets.newLinkedHashSet();
+    Set<String> reviewer = Sets.newLinkedHashSet();
+    Set<String> cc = Sets.newLinkedHashSet();
     Map<String, Short> labels = new HashMap<>();
     String message;
     List<RevCommit> baseCommit;
-    CmdLineParser clp;
+    CmdLineParser cmdLineParser;
     Set<String> hashtags = new HashSet<>();
 
+    @Option(name = "--trace", metaVar = "NAME", usage = "enable tracing")
+    String trace;
+
     @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
     List<ObjectId> base;
 
@@ -1257,8 +1428,6 @@
                 + "for new changes and '--edit' for existing changes")
     boolean draft;
 
-    boolean publish;
-
     @Option(name = "--private", usage = "mark new/updated change as private")
     boolean isPrivate;
 
@@ -1301,29 +1470,38 @@
             "Notify handling that defines to whom email notifications "
                 + "should be sent. Allowed values are NONE, OWNER, "
                 + "OWNER_REVIEWERS, ALL. If not set, the default is ALL.")
-    private NotifyHandling notify;
+    private NotifyHandling notifyHandling;
 
-    @Option(name = "--notify-to", metaVar = "USER", usage = "user that should be notified")
-    List<Account.Id> tos = new ArrayList<>();
+    @Option(
+        name = "--notify-to",
+        metaVar = "USER",
+        usage = "user that should be notified one time by email")
+    List<Account.Id> notifyTo = new ArrayList<>();
 
-    @Option(name = "--notify-cc", metaVar = "USER", usage = "user that should be CC'd")
-    List<Account.Id> ccs = new ArrayList<>();
+    @Option(
+        name = "--notify-cc",
+        metaVar = "USER",
+        usage = "user that should be CC'd one time by email")
+    List<Account.Id> notifyCc = new ArrayList<>();
 
-    @Option(name = "--notify-bcc", metaVar = "USER", usage = "user that should be BCC'd")
-    List<Account.Id> bccs = new ArrayList<>();
+    @Option(
+        name = "--notify-bcc",
+        metaVar = "USER",
+        usage = "user that should be BCC'd one time by email")
+    List<Account.Id> notifyBcc = new ArrayList<>();
 
     @Option(
         name = "--reviewer",
         aliases = {"-r"},
-        metaVar = "EMAIL",
+        metaVar = "REVIEWER",
         usage = "add reviewer to changes")
-    void reviewer(Account.Id id) {
-      reviewer.add(id);
+    void reviewer(String str) {
+      reviewer.add(str);
     }
 
-    @Option(name = "--cc", metaVar = "EMAIL", usage = "notify user by CC")
-    void cc(Account.Id id) {
-      cc.add(id);
+    @Option(name = "--cc", metaVar = "CC", usage = "add CC to changes")
+    void cc(String str) {
+      cc.add(str);
     }
 
     @Option(
@@ -1337,7 +1515,7 @@
         LabelType.checkName(v.label());
         ApprovalsUtil.checkLabel(labelTypes, v.label(), v.value());
       } catch (BadRequestException e) {
-        throw clp.reject(e.getMessage());
+        throw cmdLineParser.reject(e.getMessage());
       }
       labels.put(v.label(), v.value());
     }
@@ -1368,10 +1546,7 @@
         aliases = {"-t"},
         metaVar = "HASHTAG",
         usage = "add hashtag to changes")
-    void addHashtag(String token) throws CmdLineException {
-      if (!notesMigration.readChanges()) {
-        throw clp.reject("cannot add hashtags; noteDb is disabled");
-      }
+    void addHashtag(String token) {
       String hashtag = cleanupHashtag(token);
       if (!hashtag.isEmpty()) {
         hashtags.add(hashtag);
@@ -1379,16 +1554,18 @@
       // TODO(dpursehouse): validate hashtags
     }
 
+    @UsedAt(UsedAt.Project.GOOGLE)
+    @Option(name = "--create-cod-token", usage = "create a token for consistency-on-demand")
+    private boolean createCodToken;
+
     MagicBranchInput(
-        IdentifiedUser user,
-        ReceiveCommand cmd,
-        LabelTypes labelTypes,
-        NotesMigration notesMigration) {
+        IdentifiedUser user, ProjectState projectState, ReceiveCommand cmd, LabelTypes labelTypes) {
+      this.user = user;
+      this.projectState = projectState;
+      this.deprecatedTopicSeen = false;
       this.cmd = cmd;
       this.draft = cmd.getRefName().startsWith(MagicBranch.NEW_DRAFT_CHANGE);
-      this.publish = cmd.getRefName().startsWith(MagicBranch.NEW_PUBLISH_CHANGE);
       this.labelTypes = labelTypes;
-      this.notesMigration = notesMigration;
       GeneralPreferencesInfo prefs = user.state().getGeneralPreferences();
       this.defaultPublishComments =
           prefs != null
@@ -1396,20 +1573,49 @@
               : false;
     }
 
-    MailRecipients getMailRecipients() {
-      return new MailRecipients(reviewer, cc);
+    /**
+     * Get reviewer strings from magic branch options, combined with additional recipients computed
+     * from some other place.
+     *
+     * <p>The set of reviewers on a change includes strings passed explicitly via options as well as
+     * account IDs computed from the commit message itself.
+     *
+     * @param additionalRecipients recipients parsed from the commit.
+     * @return set of reviewer strings to pass to {@code ReviewerAdder}.
+     */
+    ImmutableSet<String> getCombinedReviewers(MailRecipients additionalRecipients) {
+      return getCombinedReviewers(reviewer, additionalRecipients.getReviewers());
     }
 
-    ListMultimap<RecipientType, Account.Id> getAccountsToNotify() {
-      ListMultimap<RecipientType, Account.Id> accountsToNotify =
-          MultimapBuilder.hashKeys().arrayListValues().build();
-      accountsToNotify.putAll(RecipientType.TO, tos);
-      accountsToNotify.putAll(RecipientType.CC, ccs);
-      accountsToNotify.putAll(RecipientType.BCC, bccs);
-      return accountsToNotify;
+    /**
+     * Get CC strings from magic branch options, combined with additional recipients computed from
+     * some other place.
+     *
+     * <p>The set of CCs on a change includes strings passed explicitly via options as well as
+     * account IDs computed from the commit message itself.
+     *
+     * @param additionalRecipients recipients parsed from the commit.
+     * @return set of CC strings to pass to {@code ReviewerAdder}.
+     */
+    ImmutableSet<String> getCombinedCcs(MailRecipients additionalRecipients) {
+      return getCombinedReviewers(cc, additionalRecipients.getCcOnly());
+    }
+
+    private static ImmutableSet<String> getCombinedReviewers(
+        Set<String> strings, Set<Account.Id> ids) {
+      return Streams.concat(strings.stream(), ids.stream().map(Account.Id::toString))
+          .collect(toImmutableSet());
+    }
+
+    void setCommentsValid(boolean commentsValid) {
+      this.commentsValid = commentsValid;
     }
 
     boolean shouldPublishComments() {
+      if (!commentsValid) {
+        // Validation messages of type WARNING have already been added, now withhold the comments.
+        return false;
+      }
       if (publishComments) {
         return true;
       } else if (noPublishComments) {
@@ -1418,15 +1624,16 @@
       return defaultPublishComments;
     }
 
-    String parse(
-        CmdLineParser clp,
-        Repository repo,
-        Set<String> refs,
-        ListMultimap<String, String> pushOptions)
+    /**
+     * returns the destination ref of the magic branch, and populates options in the cmdLineParser.
+     */
+    String parse(Repository repo, Set<String> refs, ListMultimap<String, String> pushOptions)
         throws CmdLineException {
       String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
 
       ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
+
+      // Process and lop off the "%OPTION" suffix.
       int optionStart = ref.indexOf('%');
       if (0 < optionStart) {
         for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
@@ -1441,11 +1648,12 @@
       }
 
       if (!options.isEmpty()) {
-        clp.parseOptionMap(options);
+        cmdLineParser.parseOptionMap(options);
       }
 
-      // Split the destination branch by branch and topic. The topic
-      // suffix is entirely optional, so it might not even exist.
+      // We accept refs/for/BRANCHNAME/TOPIC. Since we don't know
+      // for sure where the branch ends and the topic starts, look
+      // backward for a split that works. This behavior is deprecated.
       String head = readHEAD(repo);
       int split = ref.length();
       for (; ; ) {
@@ -1461,23 +1669,40 @@
       }
       if (split < ref.length()) {
         topic = Strings.emptyToNull(ref.substring(split + 1));
+        deprecatedTopicSeen = true;
       }
       return ref.substring(0, split);
     }
 
-    NotifyHandling getNotify() {
-      if (notify != null) {
-        return notify;
-      }
+    public boolean shouldSetWorkInProgressOnNewChanges() {
+      // When wip or ready explicitly provided, leave it as is.
       if (workInProgress) {
-        return NotifyHandling.OWNER;
+        return true;
       }
-      return NotifyHandling.ALL;
+      if (ready) {
+        return false;
+      }
+
+      return projectState.is(BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT)
+          || firstNonNull(user.state().getGeneralPreferences().workInProgressByDefault, false);
     }
 
-    NotifyHandling getNotify(ChangeNotes notes) {
-      if (notify != null) {
-        return notify;
+    NotifyResolver.Result getNotifyForNewChange() {
+      return NotifyResolver.Result.create(
+          firstNonNull(
+              notifyHandling,
+              shouldSetWorkInProgressOnNewChanges() ? NotifyHandling.OWNER : NotifyHandling.ALL),
+          ImmutableSetMultimap.<RecipientType, Account.Id>builder()
+              .putAll(RecipientType.TO, notifyTo)
+              .putAll(RecipientType.CC, notifyCc)
+              .putAll(RecipientType.BCC, notifyBcc)
+              .build());
+    }
+
+    NotifyHandling getNotifyHandling(ChangeNotes notes) {
+      requireNonNull(notes);
+      if (notifyHandling != null) {
+        return notifyHandling;
       }
       if (workInProgress || (!ready && notes.getChange().isWorkInProgress())) {
         return NotifyHandling.OWNER;
@@ -1487,299 +1712,290 @@
   }
 
   /**
-   * Gets an unmodifiable view of the pushOptions.
+   * Parse the magic branch data (refs/for/BRANCH/OPTIONALTOPIC%OPTIONS) into the magicBranch
+   * member.
    *
-   * <p>The collection is empty if the client does not support push options, or if the client did
-   * not send any options.
-   *
-   * @return an unmodifiable view of pushOptions.
+   * <p>Assumes we are handling a magic branch here.
    */
-  @Nullable
-  ListMultimap<String, String> getPushOptions() {
-    return ImmutableListMultimap.copyOf(pushOptions);
-  }
-
   private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
-    // Permit exactly one new change request per push.
-    if (magicBranch != null) {
-      reject(cmd, "duplicate request");
-      return;
-    }
+    try (TraceTimer traceTimer = newTimer("parseMagicBranch")) {
+      logger.atFine().log("Found magic branch %s", cmd.getRefName());
+      MagicBranchInput magicBranch = new MagicBranchInput(user, projectState, cmd, labelTypes);
 
-    logDebug("Found magic branch %s", cmd.getRefName());
-    magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
-    magicBranch.reviewer.addAll(extraReviewers.get(ReviewerStateInternal.REVIEWER));
-    magicBranch.cc.addAll(extraReviewers.get(ReviewerStateInternal.CC));
+      String ref;
+      magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
 
-    String ref;
-    CmdLineParser clp = optionParserFactory.create(magicBranch);
-    magicBranch.clp = clp;
-
-    try {
-      ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet(), pushOptions);
-    } catch (CmdLineException e) {
-      if (!clp.wasHelpRequestedByOption()) {
-        logDebug("Invalid branch syntax");
-        reject(cmd, e.getMessage());
-        return;
-      }
-      ref = null; // never happen
-    }
-
-    if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
-      reject(
-          cmd, String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
-    }
-
-    if (clp.wasHelpRequestedByOption()) {
-      StringWriter w = new StringWriter();
-      w.write("\nHelp for refs/for/branch:\n\n");
-      clp.printUsage(w, null);
-      addMessage(w.toString());
-      reject(cmd, "see help");
-      return;
-    }
-    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
-      logDebug("Handling %s", RefNames.REFS_USERS_SELF);
-      ref = RefNames.refsUsers(user.getAccountId());
-    }
-    if (!rp.getAdvertisedRefs().containsKey(ref)
-        && !ref.equals(readHEAD(repo))
-        && !ref.equals(RefNames.REFS_CONFIG)) {
-      logDebug("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");
-      } else {
-        reject(cmd, ref + " not found");
-      }
-      return;
-    }
-
-    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");
-      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);
-      reject(cmd, "draft workflow is disabled");
-      return;
-    }
-
-    if (magicBranch.isPrivate && magicBranch.removePrivate) {
-      reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
-      return;
-    }
-
-    boolean privateByDefault =
-        projectCache.get(project.getNameKey()).is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
-    setChangeAsPrivate =
-        magicBranch.draft
-            || magicBranch.isPrivate
-            || (privateByDefault && !magicBranch.removePrivate);
-
-    if (receiveConfig.disablePrivateChanges && setChangeAsPrivate) {
-      reject(cmd, "private changes are disabled");
-      return;
-    }
-
-    if (magicBranch.workInProgress && magicBranch.ready) {
-      reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
-      return;
-    }
-    if (magicBranch.publishComments && magicBranch.noPublishComments) {
-      reject(
-          cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
-      return;
-    }
-
-    if (magicBranch.submit) {
       try {
-        permissions.ref(ref).check(RefPermission.UPDATE_BY_SUBMIT);
-      } catch (AuthException e) {
-        reject(cmd, e.getMessage());
+        ref = magicBranch.parse(repo, receivePack.getAdvertisedRefs().keySet(), pushOptions);
+      } catch (CmdLineException e) {
+        if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
+          logger.atFine().log("Invalid branch syntax");
+          reject(cmd, e.getMessage());
+          return;
+        }
+        ref = null; // never happens
+      }
+
+      if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+        reject(
+            cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH));
+      }
+
+      if (magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
+        StringWriter w = new StringWriter();
+        w.write("\nHelp for refs/for/branch:\n\n");
+        magicBranch.cmdLineParser.printUsage(w, null);
+        addMessage(w.toString());
+        reject(cmd, "see help");
         return;
       }
-    }
-
-    RevWalk walk = rp.getRevWalk();
-    RevCommit tip;
-    try {
-      tip = walk.parseCommit(magicBranch.cmd.getNewId());
-      logDebug("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);
-      return;
-    }
-
-    String destBranch = magicBranch.dest.get();
-    try {
-      if (magicBranch.merged) {
-        if (magicBranch.base != null) {
-          reject(cmd, "cannot use merged with base");
-          return;
+      if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
+        logger.atFine().log("Handling %s", RefNames.REFS_USERS_SELF);
+        ref = RefNames.refsUsers(user.getAccountId());
+      }
+      // Pushing changes for review usually requires that the target branch exists, but there is an
+      // exception for the branch to which HEAD points to and for refs/meta/config. Pushing for
+      // review to these branches is allowed even if the branch does not exist yet. This allows to
+      // push initial code for review to an empty repository and to review an initial project
+      // configuration.
+      if (!receivePack.getAdvertisedRefs().containsKey(ref)
+          && !ref.equals(readHEAD(repo))
+          && !ref.equals(RefNames.REFS_CONFIG)) {
+        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");
+        } else {
+          reject(cmd, ref + " not found");
         }
-        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
-        if (branchTip == null) {
-          return; // readBranchTip already rejected cmd.
-        }
-        if (!walk.isMergedInto(tip, branchTip)) {
-          reject(cmd, "not merged into branch");
+        return;
+      }
+
+      magicBranch.dest = BranchNameKey.create(project.getNameKey(), ref);
+      magicBranch.perm = permissions.ref(ref);
+
+      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(CODE_REVIEW_ERROR, ref);
+        reject(cmd, "draft workflow is disabled");
+        return;
+      }
+
+      if (magicBranch.isPrivate && magicBranch.removePrivate) {
+        reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
+        return;
+      }
+
+      boolean privateByDefault =
+          projectCache.get(project.getNameKey()).is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
+      setChangeAsPrivate =
+          magicBranch.draft
+              || magicBranch.isPrivate
+              || (privateByDefault && !magicBranch.removePrivate);
+
+      if (receiveConfig.disablePrivateChanges && setChangeAsPrivate) {
+        reject(cmd, "private changes are disabled");
+        return;
+      }
+
+      if (magicBranch.workInProgress && magicBranch.ready) {
+        reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
+        return;
+      }
+      if (magicBranch.publishComments && magicBranch.noPublishComments) {
+        reject(
+            cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
+        return;
+      }
+
+      if (magicBranch.submit) {
+        err = checkRefPermission(magicBranch.perm, RefPermission.UPDATE_BY_SUBMIT);
+        if (err.isPresent()) {
+          rejectProhibited(cmd, err.get());
           return;
         }
       }
 
-      // If tip is a merge commit, or the root commit or
-      // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
-      if (tip.getParentCount() > 1
-          || magicBranch.base != null
-          || magicBranch.merged
-          || tip.getParentCount() == 0) {
-        logDebug("Forcing newChangeForAllNotInTarget = false");
-        newChangeForAllNotInTarget = false;
+      RevWalk walk = receivePack.getRevWalk();
+      RevCommit tip;
+      try {
+        tip = walk.parseCommit(magicBranch.cmd.getNewId());
+        logger.atFine().log("Tip of push: %s", tip.name());
+      } catch (IOException ex) {
+        magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+        logger.atSevere().withCause(ex).log(
+            "Invalid pack upload; one or more objects weren't sent");
+        return;
       }
 
-      if (magicBranch.base != null) {
-        logDebug("Handling %base: %s", magicBranch.base);
-        magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
-        for (ObjectId id : magicBranch.base) {
-          try {
-            magicBranch.baseCommit.add(walk.parseCommit(id));
-          } catch (IncorrectObjectTypeException notCommit) {
-            reject(cmd, "base must be a commit");
+      String destBranch = magicBranch.dest.branch();
+      try {
+        if (magicBranch.merged) {
+          if (magicBranch.base != null) {
+            reject(cmd, "cannot use merged with base");
             return;
-          } catch (MissingObjectException e) {
-            reject(cmd, "base not found");
+          }
+          RevCommit branchTip = readBranchTip(magicBranch.dest);
+          if (branchTip == null) {
+            reject(cmd, magicBranch.dest.branch() + " not found");
             return;
-          } catch (IOException e) {
-            logWarn(String.format("Project %s cannot read %s", project.getName(), id.name()), e);
-            reject(cmd, "internal server error");
+          }
+          if (!walk.isMergedInto(tip, branchTip)) {
+            reject(cmd, "not merged into branch");
             return;
           }
         }
-      } else if (newChangeForAllNotInTarget) {
-        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
-        if (branchTip == null) {
-          return; // readBranchTip already rejected cmd.
-        }
-        magicBranch.baseCommit = Collections.singletonList(branchTip);
-        logDebug("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);
-      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.
-    //
-    try {
-      Ref targetRef = rp.getAdvertisedRefs().get(magicBranch.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");
+        // If tip is a merge commit, or the root commit or
+        // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
+        if (tip.getParentCount() > 1
+            || magicBranch.base != null
+            || magicBranch.merged
+            || tip.getParentCount() == 0) {
+          logger.atFine().log("Forcing newChangeForAllNotInTarget = false");
+          newChangeForAllNotInTarget = false;
+        }
+
+        if (magicBranch.base != null) {
+          logger.atFine().log("Handling %%base: %s", magicBranch.base);
+          magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
+          for (ObjectId id : magicBranch.base) {
+            try {
+              magicBranch.baseCommit.add(walk.parseCommit(id));
+            } catch (IncorrectObjectTypeException notCommit) {
+              reject(cmd, "base must be a commit");
+              return;
+            } catch (MissingObjectException e) {
+              reject(cmd, "base not found");
+              return;
+            } catch (IOException e) {
+              logger.atWarning().withCause(e).log(
+                  "Project %s cannot read %s", project.getName(), id.name());
+              reject(cmd, "internal server error");
+              return;
+            }
+          }
+        } else if (newChangeForAllNotInTarget) {
+          RevCommit branchTip = readBranchTip(magicBranch.dest);
+          if (branchTip != null) {
+            magicBranch.baseCommit = Collections.singletonList(branchTip);
+            logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
+          } else {
+            // The target branch does not exist. Usually pushing changes for review requires that
+            // the
+            // target branch exists, but there is an exception for the branch to which HEAD points
+            // to
+            // and for refs/meta/config. Pushing for review to these branches is allowed even if the
+            // branch does not exist yet. This allows to push initial code for review to an empty
+            // repository and to review an initial project configuration.
+            if (!ref.equals(readHEAD(repo)) && !ref.equals(RefNames.REFS_CONFIG)) {
+              reject(cmd, magicBranch.dest.branch() + " not found");
+              return;
+            }
+          }
+        }
+      } catch (IOException ex) {
+        logger.atWarning().withCause(ex).log(
+            "Error walking to %s in project %s", destBranch, project.getName());
+        reject(cmd, "internal server error");
         return;
       }
-      RevCommit h = walk.parseCommit(targetRef.getObjectId());
-      logDebug("Current branch tip: %s", h.name());
-      RevFilter oldRevFilter = walk.getRevFilter();
-      try {
-        walk.reset();
-        walk.setRevFilter(RevFilter.MERGE_BASE);
-        walk.markStart(tip);
-        walk.markStart(h);
-        if (walk.next() == null) {
-          reject(magicBranch.cmd, "no common ancestry");
-        }
-      } finally {
-        walk.reset();
-        walk.setRevFilter(oldRevFilter);
+
+      if (magicBranch.deprecatedTopicSeen) {
+        messages.add(
+            new ValidationMessage(
+                "WARNING: deprecated topic syntax. Use -o topic=TOPIC instead", false));
+        logger.atInfo().log("deprecated topic push seen for project %s", project.getName());
       }
-    } catch (IOException e) {
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
+
+      if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) {
+        this.magicBranch = magicBranch;
+        this.resultChangeIds.setMagicPush(true);
+      }
+    }
+  }
+
+  // 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, BranchNameKey dest, RevCommit tip) {
+    try (TraceTimer traceTimer =
+        newTimer("validateConnected", Metadata.builder().branchName(dest.branch()))) {
+      RevWalk walk = receivePack.getRevWalk();
+      try {
+        Ref targetRef = receivePack.getAdvertisedRefs().get(dest.branch());
+        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.
+          logger.atFine().log("Branch is unborn");
+
+          // This is not an error condition.
+          return true;
+        }
+
+        RevCommit h = walk.parseCommit(targetRef.getObjectId());
+        logger.atFine().log("Current branch tip: %s", h.name());
+        RevFilter oldRevFilter = walk.getRevFilter();
+        try {
+          walk.reset();
+          walk.setRevFilter(RevFilter.MERGE_BASE);
+          walk.markStart(tip);
+          walk.markStart(h);
+          if (walk.next() == null) {
+            reject(cmd, "no common ancestry");
+            return false;
+          }
+        } finally {
+          walk.reset();
+          walk.setRevFilter(oldRevFilter);
+        }
+      } catch (IOException 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) {
     try {
-      return repo.getFullBranch();
+      String head = repo.getFullBranch();
+      logger.atFine().log("HEAD = %s", head);
+      return head;
     } catch (IOException e) {
       logger.atSevere().withCause(e).log("Cannot read HEAD symref");
       return null;
     }
   }
 
-  private RevCommit readBranchTip(ReceiveCommand cmd, Branch.NameKey branch) throws IOException {
-    Ref r = allRefs().get(branch.get());
+  private RevCommit readBranchTip(BranchNameKey branch) throws IOException {
+    Ref r = allRefs().get(branch.branch());
     if (r == null) {
-      reject(cmd, branch.get() + " not found");
       return null;
     }
-    return rp.getRevWalk().parseCommit(r.getObjectId());
+    return receivePack.getRevWalk().parseCommit(r.getObjectId());
   }
 
-  private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
-    logDebug("Parsing replace command");
-    if (cmd.getType() != ReceiveCommand.Type.CREATE) {
-      reject(cmd, "invalid usage");
-      return;
-    }
-
-    RevCommit newCommit;
-    try {
-      newCommit = rp.getRevWalk().parseCommit(cmd.getNewId());
-      logDebug("Replacing with %s", newCommit);
-    } catch (IOException e) {
-      logError("Cannot parse " + cmd.getNewId().name() + " as commit", e);
-      reject(cmd, "invalid commit");
-      return;
-    }
-
-    Change changeEnt;
-    try {
-      changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
-    } catch (NoSuchChangeException e) {
-      logError("Change not found " + changeId, e);
-      reject(cmd, "change " + changeId + " not found");
-      return;
-    } catch (OrmException e) {
-      logError("Cannot lookup existing change " + changeId, e);
-      reject(cmd, "database error");
-      return;
-    }
-    if (!project.getNameKey().equals(changeEnt.getProject())) {
-      reject(cmd, "change " + changeId + " does not belong to project " + project.getName());
-      return;
-    }
-
-    logDebug("Replacing change %s", changeEnt.getId());
-    requestReplace(cmd, true, changeEnt, newCommit);
-  }
-
-  private boolean requestReplace(
+  /**
+   * Update an existing change. If draft comments are to be published, these are validated and may
+   * be withheld.
+   *
+   * @return True if the command succeeded, false if it was rejected.
+   */
+  private boolean requestReplaceAndValidateComments(
       ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
-    if (change.getStatus().isClosed()) {
+    if (change.isClosed()) {
       reject(
           cmd,
           changeFormatter.changeClosed(
@@ -1792,285 +2008,330 @@
       reject(cmd, "duplicate request");
       return false;
     }
+
+    if (magicBranch != null && magicBranch.shouldPublishComments()) {
+      List<Comment> drafts =
+          commentsUtil.draftByChangeAuthor(notesFactory.createChecked(change), user.getAccountId());
+      ImmutableList<CommentForValidation> draftsForValidation =
+          drafts.stream()
+              .map(
+                  comment ->
+                      CommentForValidation.create(
+                          comment.lineNbr > 0
+                              ? CommentType.INLINE_COMMENT
+                              : CommentType.FILE_COMMENT,
+                          comment.message))
+              .collect(toImmutableList());
+      ImmutableList<CommentValidationFailure> commentValidationFailures =
+          PublishCommentUtil.findInvalidComments(commentValidators, draftsForValidation);
+      magicBranch.setCommentsValid(commentValidationFailures.isEmpty());
+      commentValidationFailures.forEach(
+          failure ->
+              addMessage(
+                  "Comment validation failure: " + failure.getMessage(),
+                  ValidationMessage.Type.WARNING));
+    }
+
     replaceByChange.put(req.ontoChange, req);
     return true;
   }
 
-  private void selectNewAndReplacedChangesFromMagicBranch() {
-    logDebug("Finding new and replaced changes");
-    newChanges = new ArrayList<>();
-
-    ListMultimap<ObjectId, Ref> existing = changeRefsById();
-    GroupCollector groupCollector =
-        GroupCollector.create(changeRefsById(), db, psUtil, notesFactory, project.getNameKey());
-
-    try {
-      RevCommit start = setUpWalkForSelectingChanges();
-      if (start == null) {
-        return;
+  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);
 
-      LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
-      Set<Change.Key> newChangeIds = new HashSet<>();
-      int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user);
-      int total = 0;
-      int alreadyTracked = 0;
-      boolean rejectImplicitMerges =
-          start.getParentCount() == 1
-              && projectCache
-                  .get(project.getNameKey())
-                  .is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES)
-              // Don't worry about implicit merges when creating changes for
-              // already-merged commits; they're already in history, so it's too
-              // late.
-              && !magicBranch.merged;
-      Set<RevCommit> mergedParents;
-      if (rejectImplicitMerges) {
-        mergedParents = new HashSet<>();
-      } else {
-        mergedParents = null;
+      if (idList.isEmpty()) {
+        messages.add(
+            new ValidationMessage("warning: pushing without Change-Id is deprecated", false));
+        break;
       }
-
-      for (; ; ) {
-        RevCommit c = rp.getRevWalk().next();
-        if (c == null) {
-          break;
-        }
-        total++;
-        rp.getRevWalk().parseBody(c);
-        String name = c.name();
-        groupCollector.visit(c);
-        Collection<Ref> existingRefs = existing.get(c);
-
-        if (rejectImplicitMerges) {
-          Collections.addAll(mergedParents, c.getParents());
-          mergedParents.remove(c);
-        }
-
-        boolean commitAlreadyTracked = !existingRefs.isEmpty();
-        if (commitAlreadyTracked) {
-          alreadyTracked++;
-          // Corner cases where an existing commit might need a new group:
-          // A) Existing commit has a null group; wasn't assigned during schema
-          //    upgrade, or schema upgrade is performed on a running server.
-          // B) Let A<-B<-C, then:
-          //      1. Push A to refs/heads/master
-          //      2. Push B to refs/for/master
-          //      3. Force push A~ to refs/heads/master
-          //      4. Push C to refs/for/master.
-          //      B will be in existing so we aren't replacing the patch set. It
-          //      used to have its own group, but now needs to to be changed to
-          //      A's group.
-          // C) Commit is a PatchSet of a pre-existing change uploaded with a
-          //    different target branch.
-          for (Ref ref : existingRefs) {
-            updateGroups.add(new UpdateGroupsRequest(ref, c));
-          }
-          if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
-            continue;
-          }
-        }
-
-        List<String> idList = c.getFooterLines(CHANGE_ID);
-
-        String idStr = !idList.isEmpty() ? idList.get(idList.size() - 1).trim() : null;
-
-        if (idStr != null) {
-          pending.put(c, new ChangeLookup(c, new Change.Key(idStr)));
-        } else {
-          pending.put(c, new ChangeLookup(c));
-        }
-        int n = pending.size() + newChanges.size();
-        if (maxBatchChanges != 0 && n > maxBatchChanges) {
-          logDebug("%d changes exceeds limit of %d", n, maxBatchChanges);
-          reject(
-              magicBranch.cmd,
-              "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        if (commitAlreadyTracked) {
-          boolean changeExistsOnDestBranch = false;
-          for (ChangeData cd : pending.get(c).destChanges) {
-            if (cd.change().getDest().equals(magicBranch.dest)) {
-              changeExistsOnDestBranch = true;
-              break;
-            }
-          }
-          if (changeExistsOnDestBranch) {
-            continue;
-          }
-
-          logDebug("Creating new change for %s even though it is already tracked", name);
-        }
-
-        if (!validCommit(
-            rp.getRevWalk(), magicBranch.perm, magicBranch.dest, magicBranch.cmd, c, null)) {
-          // Not a change the user can propose? Abort as early as possible.
-          newChanges = Collections.emptyList();
-          logDebug("Aborting early due to invalid commit");
-          return;
-        }
-
-        // Don't allow merges to be uploaded in commit chain via all-not-in-target
-        if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
-          reject(
-              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);
-          // TODO(dborowitz): Should we early return here?
-        }
-
-        if (idList.isEmpty()) {
-          newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
-          continue;
-        }
-      }
-      logDebug(
-          "Finished initial RevWalk with %d commits total: %d already"
-              + " tracked, %d new changes with no Change-Id, and %d deferred"
-              + " lookups",
-          total, alreadyTracked, newChanges.size(), pending.size());
-
-      if (rejectImplicitMerges) {
-        rejectImplicitMerges(mergedParents);
-      }
-
-      for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
-        ChangeLookup p = itr.next();
-        if (p.changeKey == null) {
-          continue;
-        }
-
-        if (newChangeIds.contains(p.changeKey)) {
-          logDebug("Multiple commits with Change-Id %s", p.changeKey);
-          reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        List<ChangeData> changes = p.destChanges;
-        if (changes.size() > 1) {
-          logDebug(
-              "Multiple changes in branch %s with Change-Id %s: %s",
-              magicBranch.dest,
-              p.changeKey,
-              changes.stream().map(cd -> cd.getId().toString()).collect(joining()));
-          // WTF, multiple changes in this branch have the same key?
-          // Since the commit is new, the user should recreate it with
-          // a different Change-Id. In practice, we should never see
-          // this error message as Change-Id should be unique per branch.
-          //
-          reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        if (changes.size() == 1) {
-          // Schedule as a replacement to this one matching change.
-          //
-
-          RevId currentPs = changes.get(0).currentPatchSet().getRevision();
-          // If Commit is already current PatchSet of target Change.
-          if (p.commit.name().equals(currentPs.get())) {
-            if (pending.size() == 1) {
-              // There are no commits left to check, all commits in pending were already
-              // current PatchSet of the corresponding target changes.
-              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-            } else {
-              // Commit is already current PatchSet.
-              // Remove from pending and try next commit.
-              itr.remove();
-              continue;
-            }
-          }
-          if (requestReplace(magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
-            continue;
-          }
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        if (changes.size() == 0) {
-          if (!isValidChangeId(p.changeKey.get())) {
-            reject(magicBranch.cmd, "invalid Change-Id");
-            newChanges = Collections.emptyList();
-            return;
-          }
-
-          // In case the change look up from the index failed,
-          // double check against the existing refs
-          if (foundInExistingRef(existing.get(p.commit))) {
-            if (pending.size() == 1) {
-              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-              newChanges = Collections.emptyList();
-              return;
-            }
-            itr.remove();
-            continue;
-          }
-          newChangeIds.add(p.changeKey);
-        }
-        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
-      }
-      logDebug(
-          "Finished deferred lookups with %d updates and %d new changes",
-          replaceByChange.size(), newChanges.size());
-    } catch (IOException e) {
-      // Should never happen, the core receive process would have
-      // identified the missing object earlier before we got control.
-      //
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
-      newChanges = Collections.emptyList();
-      return;
-    } catch (OrmException e) {
-      logError("Cannot query database to locate prior changes", e);
-      reject(magicBranch.cmd, "database error");
-      newChanges = Collections.emptyList();
-      return;
-    }
-
-    if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
-      reject(magicBranch.cmd, "no new changes");
-      return;
-    }
-    if (!newChanges.isEmpty() && magicBranch.edit) {
-      reject(magicBranch.cmd, "edit is not supported for new changes");
-      return;
-    }
-
-    try {
-      SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
-      List<Integer> newIds = seq.nextChangeIds(newChanges.size());
-      for (int i = 0; i < newChanges.size(); i++) {
-        CreateRequest create = newChanges.get(i);
-        create.setChangeId(newIds.get(i));
-        create.groups = ImmutableList.copyOf(groups.get(create.commit));
-      }
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
-      }
-      for (UpdateGroupsRequest update : updateGroups) {
-        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
-      }
-      logDebug("Finished updating groups from GroupCollector");
-    } catch (OrmException e) {
-      logError("Error collecting groups for changes", e);
-      reject(magicBranch.cmd, "internal server error");
-      return;
     }
   }
 
-  private boolean foundInExistingRef(Collection<Ref> existingRefs) throws OrmException {
+  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress) {
+    try (TraceTimer traceTimer = newTimer("selectNewAndReplacedChangesFromMagicBranch")) {
+      logger.atFine().log("Finding new and replaced changes");
+      List<CreateRequest> newChanges = new ArrayList<>();
+
+      ListMultimap<ObjectId, Ref> existing = changeRefsById();
+      GroupCollector groupCollector =
+          GroupCollector.create(changeRefsById(), psUtil, notesFactory, project.getNameKey());
+
+      BranchCommitValidator validator =
+          commitValidatorFactory.create(projectState, magicBranch.dest, user);
+
+      try {
+        RevCommit start = setUpWalkForSelectingChanges();
+        if (start == null) {
+          return Collections.emptyList();
+        }
+
+        LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
+        Set<Change.Key> newChangeIds = new HashSet<>();
+        int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user);
+        int total = 0;
+        int alreadyTracked = 0;
+        boolean rejectImplicitMerges =
+            start.getParentCount() == 1
+                && projectCache
+                    .get(project.getNameKey())
+                    .is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES)
+                // Don't worry about implicit merges when creating changes for
+                // already-merged commits; they're already in history, so it's too
+                // late.
+                && !magicBranch.merged;
+        Set<RevCommit> mergedParents;
+        if (rejectImplicitMerges) {
+          mergedParents = new HashSet<>();
+        } else {
+          mergedParents = null;
+        }
+
+        for (; ; ) {
+          RevCommit c = receivePack.getRevWalk().next();
+          if (c == null) {
+            break;
+          }
+          total++;
+          receivePack.getRevWalk().parseBody(c);
+          String name = c.name();
+          groupCollector.visit(c);
+          Collection<Ref> existingRefs = existing.get(c);
+
+          if (rejectImplicitMerges) {
+            Collections.addAll(mergedParents, c.getParents());
+            mergedParents.remove(c);
+          }
+
+          boolean commitAlreadyTracked = !existingRefs.isEmpty();
+          if (commitAlreadyTracked) {
+            alreadyTracked++;
+            // Corner cases where an existing commit might need a new group:
+            // A) Existing commit has a null group; wasn't assigned during schema
+            //    upgrade, or schema upgrade is performed on a running server.
+            // B) Let A<-B<-C, then:
+            //      1. Push A to refs/heads/master
+            //      2. Push B to refs/for/master
+            //      3. Force push A~ to refs/heads/master
+            //      4. Push C to refs/for/master.
+            //      B will be in existing so we aren't replacing the patch set. It
+            //      used to have its own group, but now needs to to be changed to
+            //      A's group.
+            // C) Commit is a PatchSet of a pre-existing change uploaded with a
+            //    different target branch.
+            for (Ref ref : existingRefs) {
+              updateGroups.add(new UpdateGroupsRequest(ref, c));
+            }
+            if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
+              continue;
+            }
+          }
+
+          List<String> idList = c.getFooterLines(FooterConstants.CHANGE_ID);
+          if (!idList.isEmpty()) {
+            pending.put(c, lookupByChangeKey(c, Change.key(idList.get(idList.size() - 1).trim())));
+          } else {
+            pending.put(c, lookupByCommit(c));
+          }
+
+          int n = pending.size() + newChanges.size();
+          if (maxBatchChanges != 0 && 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);
+            return Collections.emptyList();
+          }
+
+          if (commitAlreadyTracked) {
+            boolean changeExistsOnDestBranch = false;
+            for (ChangeData cd : pending.get(c).destChanges) {
+              if (cd.change().getDest().equals(magicBranch.dest)) {
+                changeExistsOnDestBranch = true;
+                break;
+              }
+            }
+            if (changeExistsOnDestBranch) {
+              continue;
+            }
+
+            logger.atFine().log(
+                "Creating new change for %s even though it is already tracked", name);
+          }
+
+          BranchCommitValidator.Result validationResult =
+              validator.validateCommit(
+                  receivePack.getRevWalk().getObjectReader(),
+                  magicBranch.cmd,
+                  c,
+                  magicBranch.merged,
+                  rejectCommits,
+                  null);
+          messages.addAll(validationResult.messages());
+          if (!validationResult.isValid()) {
+            // Not a change the user can propose? Abort as early as possible.
+            logger.atFine().log("Aborting early due to invalid commit");
+            return Collections.emptyList();
+          }
+
+          // Don't allow merges to be uploaded in commit chain via all-not-in-target
+          if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
+            reject(
+                magicBranch.cmd,
+                "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
+                    + "to override please set the base manually");
+            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.branch(), newProgress));
+            continue;
+          }
+        }
+        logger.atFine().log(
+            "Finished initial RevWalk with %d commits total: %d already"
+                + " tracked, %d new changes with no Change-Id, and %d deferred"
+                + " lookups",
+            total, alreadyTracked, newChanges.size(), pending.size());
+
+        if (rejectImplicitMerges) {
+          rejectImplicitMerges(mergedParents);
+        }
+
+        for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
+          ChangeLookup p = itr.next();
+          if (p.changeKey == null) {
+            continue;
+          }
+
+          if (newChangeIds.contains(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) {
+            logger.atFine().log(
+                "Multiple changes in branch %s with Change-Id %s: %s",
+                magicBranch.dest,
+                p.changeKey,
+                changes.stream().map(cd -> cd.getId().toString()).collect(joining()));
+            // WTF, multiple changes in this branch have the same key?
+            // Since the commit is new, the user should recreate it with
+            // a different Change-Id. In practice, we should never see
+            // this error message as Change-Id should be unique per branch.
+            //
+            reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
+            return Collections.emptyList();
+          }
+
+          if (changes.size() == 1) {
+            // Schedule as a replacement to this one matching change.
+            //
+
+            ObjectId currentPs = changes.get(0).currentPatchSet().commitId();
+            // If Commit is already current PatchSet of target Change.
+            if (p.commit.equals(currentPs)) {
+              if (pending.size() == 1) {
+                // There are no commits left to check, all commits in pending were already
+                // current PatchSet of the corresponding target changes.
+                reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+              } else {
+                // Commit is already current PatchSet.
+                // Remove from pending and try next commit.
+                itr.remove();
+                continue;
+              }
+            }
+            if (requestReplaceAndValidateComments(
+                magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
+              continue;
+            }
+            return Collections.emptyList();
+          }
+
+          if (changes.size() == 0) {
+            if (!isValidChangeId(p.changeKey.get())) {
+              reject(magicBranch.cmd, "invalid Change-Id");
+              return Collections.emptyList();
+            }
+
+            // In case the change look up from the index failed,
+            // double check against the existing refs
+            if (foundInExistingRef(existing.get(p.commit))) {
+              if (pending.size() == 1) {
+                reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+                return Collections.emptyList();
+              }
+              itr.remove();
+              continue;
+            }
+            newChangeIds.add(p.changeKey);
+          }
+          newChanges.add(new CreateRequest(p.commit, magicBranch.dest.branch(), newProgress));
+        }
+        logger.atFine().log(
+            "Finished deferred lookups with %d updates and %d new changes",
+            replaceByChange.size(), newChanges.size());
+      } catch (IOException e) {
+        // Should never happen, the core receive process would have
+        // identified the missing object earlier before we got control.
+        //
+        magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+        logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
+        return Collections.emptyList();
+      } catch (StorageException e) {
+        logger.atSevere().withCause(e).log("Cannot query database to locate prior changes");
+        reject(magicBranch.cmd, "database error");
+        return Collections.emptyList();
+      }
+
+      if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
+        reject(magicBranch.cmd, "no new changes");
+        return Collections.emptyList();
+      }
+      if (!newChanges.isEmpty() && magicBranch.edit) {
+        reject(magicBranch.cmd, "edit is not supported for new changes");
+        return newChanges;
+      }
+
+      try {
+        SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
+        List<Integer> newIds = seq.nextChangeIds(newChanges.size());
+        for (int i = 0; i < newChanges.size(); i++) {
+          CreateRequest create = newChanges.get(i);
+          create.setChangeId(newIds.get(i));
+          create.groups = ImmutableList.copyOf(groups.get(create.commit));
+        }
+        for (ReplaceRequest replace : replaceByChange.values()) {
+          replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
+        }
+        for (UpdateGroupsRequest update : updateGroups) {
+          update.groups = ImmutableList.copyOf((groups.get(update.commit)));
+        }
+        logger.atFine().log("Finished updating groups from GroupCollector");
+      } catch (StorageException e) {
+        logger.atSevere().withCause(e).log("Error collecting groups for changes");
+        reject(magicBranch.cmd, "internal server error");
+      }
+      return newChanges;
+    }
+  }
+
+  private boolean foundInExistingRef(Collection<Ref> existingRefs) {
     for (Ref ref : existingRefs) {
       ChangeNotes notes =
-          notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
+          notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
       Change change = notes.getChange();
       if (change.getDest().equals(magicBranch.dest)) {
-        logDebug("Found change %s from existing refs.", change.getKey());
+        logger.atFine().log("Found change %s from existing refs.", change.getKey());
         // Reindex the change asynchronously, ignoring errors.
         @SuppressWarnings("unused")
         Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
@@ -2081,115 +2342,135 @@
   }
 
   private RevCommit setUpWalkForSelectingChanges() throws IOException {
-    RevWalk rw = rp.getRevWalk();
-    RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
+    try (TraceTimer traceTimer = newTimer("setUpWalkForSelectingChanges")) {
+      RevWalk rw = receivePack.getRevWalk();
+      RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
 
-    rw.reset();
-    rw.sort(RevSort.TOPO);
-    rw.sort(RevSort.REVERSE, true);
-    rp.getRevWalk().markStart(start);
-    if (magicBranch.baseCommit != null) {
-      markExplicitBasesUninteresting();
-    } else if (magicBranch.merged) {
-      logDebug("Marking parents of merged commit %s uninteresting", start.name());
-      for (RevCommit c : start.getParents()) {
-        rw.markUninteresting(c);
+      rw.reset();
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE, true);
+      receivePack.getRevWalk().markStart(start);
+      if (magicBranch.baseCommit != null) {
+        markExplicitBasesUninteresting();
+      } else if (magicBranch.merged) {
+        logger.atFine().log("Marking parents of merged commit %s uninteresting", start.name());
+        for (RevCommit c : start.getParents()) {
+          rw.markUninteresting(c);
+        }
+      } else {
+        markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.branch() : null);
       }
-    } else {
-      markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.get() : null);
+      return start;
     }
-    return start;
   }
 
   private void markExplicitBasesUninteresting() throws IOException {
-    logDebug("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
-    for (RevCommit c : magicBranch.baseCommit) {
-      rp.getRevWalk().markUninteresting(c);
-    }
-    Ref targetRef = allRefs().get(magicBranch.dest.get());
-    if (targetRef != null) {
-      logDebug(
-          "Marking target ref %s (%s) uninteresting",
-          magicBranch.dest.get(), targetRef.getObjectId().name());
-      rp.getRevWalk().markUninteresting(rp.getRevWalk().parseCommit(targetRef.getObjectId()));
+    try (TraceTimer traceTimer = newTimer("markExplicitBasesUninteresting")) {
+      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.branch());
+      if (targetRef != null) {
+        logger.atFine().log(
+            "Marking target ref %s (%s) uninteresting",
+            magicBranch.dest.branch(), targetRef.getObjectId().name());
+        receivePack
+            .getRevWalk()
+            .markUninteresting(receivePack.getRevWalk().parseCommit(targetRef.getObjectId()));
+      }
     }
   }
 
   private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
-    if (!mergedParents.isEmpty()) {
-      Ref targetRef = allRefs().get(magicBranch.dest.get());
-      if (targetRef != null) {
-        RevWalk rw = rp.getRevWalk();
-        RevCommit tip = rw.parseCommit(targetRef.getObjectId());
-        boolean containsImplicitMerges = true;
-        for (RevCommit p : mergedParents) {
-          containsImplicitMerges &= !rw.isMergedInto(p, tip);
-        }
-
-        if (containsImplicitMerges) {
-          rw.reset();
+    try (TraceTimer traceTimer = newTimer("rejectImplicitMerges")) {
+      if (!mergedParents.isEmpty()) {
+        Ref targetRef = allRefs().get(magicBranch.dest.branch());
+        if (targetRef != null) {
+          RevWalk rw = receivePack.getRevWalk();
+          RevCommit tip = rw.parseCommit(targetRef.getObjectId());
+          boolean containsImplicitMerges = true;
           for (RevCommit p : mergedParents) {
-            rw.markStart(p);
+            containsImplicitMerges &= !rw.isMergedInto(p, tip);
           }
-          rw.markUninteresting(tip);
-          RevCommit c;
-          while ((c = rw.next()) != null) {
-            rw.parseBody(c);
-            messages.add(
-                new CommitValidationMessage(
-                    "ERROR: Implicit Merge of "
-                        + c.abbreviate(7).name()
-                        + " "
-                        + c.getShortMessage(),
-                    false));
+
+          if (containsImplicitMerges) {
+            rw.reset();
+            for (RevCommit p : mergedParents) {
+              rw.markStart(p);
+            }
+            rw.markUninteresting(tip);
+            RevCommit c;
+            while ((c = rw.next()) != null) {
+              rw.parseBody(c);
+              messages.add(
+                  new CommitValidationMessage(
+                      "Implicit Merge of "
+                          + abbreviateName(c, rw.getObjectReader())
+                          + " "
+                          + c.getShortMessage(),
+                      ValidationMessage.Type.ERROR));
+            }
+            reject(magicBranch.cmd, "implicit merges detected");
           }
-          reject(magicBranch.cmd, "implicit merges detected");
         }
       }
     }
   }
 
+  // Mark all branch tips as uninteresting in the given revwalk,
+  // so we get only the new commits when walking rw.
   private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) {
-    int i = 0;
-    for (Ref ref : allRefs().values()) {
-      if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
-          && ref.getObjectId() != null) {
-        try {
-          rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
-          i++;
-        } catch (IOException e) {
-          logWarn(String.format("Invalid ref %s in %s", ref.getName(), project.getName()), e);
+    try (TraceTimer traceTimer =
+        newTimer("markHeadsAsUninteresting", Metadata.builder().branchName(forRef))) {
+      int i = 0;
+      for (Ref ref : allRefs().values()) {
+        if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
+            && ref.getObjectId() != null) {
+          try {
+            rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
+            i++;
+          } catch (IOException e) {
+            logger.atWarning().withCause(e).log(
+                "Invalid ref %s in %s", ref.getName(), project.getName());
+          }
         }
       }
+      logger.atFine().log("Marked %d heads as uninteresting", i);
     }
-    logDebug("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;
     }
   }
 
+  private ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) {
+    return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+  }
+
+  private ChangeLookup lookupByCommit(RevCommit c) {
+    return new ChangeLookup(
+        c, null, queryProvider.get().byBranchCommit(magicBranch.dest, c.getName()));
+  }
+
+  /** Represents a commit for which a Change should be created. */
   private class CreateRequest {
     final RevCommit commit;
-    private final String refName;
+    final Task progress;
+    final String refName;
 
     Change.Id changeId;
     ReceiveCommand cmd;
@@ -2198,210 +2479,234 @@
 
     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) {
+      try (TraceTimer traceTimer = newTimer(CreateRequest.class, "setChangeId")) {
+        changeId = Change.id(id);
+        ins =
+            changeInserterFactory
+                .create(changeId, commit, refName)
+                .setTopic(magicBranch.topic)
+                .setPrivate(setChangeAsPrivate)
+                .setWorkInProgress(magicBranch.shouldSetWorkInProgressOnNewChanges())
+                // Changes already validated in validateNewCommits.
+                .setValidate(false);
 
-      changeId = new Change.Id(id);
-      ins =
-          changeInserterFactory
-              .create(changeId, commit, refName)
-              .setTopic(magicBranch.topic)
-              .setPrivate(setChangeAsPrivate)
-              .setWorkInProgress(magicBranch.workInProgress)
-              // Changes already validated in validateNewCommits.
-              .setValidate(false);
-
-      if (magicBranch.merged) {
-        ins.setStatus(Change.Status.MERGED);
-      }
-      cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
-      if (rp.getPushCertificate() != null) {
-        ins.setPushCertificate(rp.getPushCertificate().toTextWithSignature());
+        if (magicBranch.merged) {
+          ins.setStatus(Change.Status.MERGED);
+        }
+        cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
+        if (receivePack.getPushCertificate() != null) {
+          ins.setPushCertificate(receivePack.getPushCertificate().toTextWithSignature());
+        }
       }
     }
 
     private void addOps(BatchUpdate bu) throws RestApiException {
-      checkState(changeId != null, "must call setChangeId before addOps");
-      try {
-        RevWalk rw = rp.getRevWalk();
-        rw.parseBody(commit);
-        final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
-        Account.Id me = user.getAccountId();
-        List<FooterLine> footerLines = commit.getFooterLines();
-        MailRecipients recipients = new MailRecipients();
-        Map<String, Short> approvals = new HashMap<>();
-        checkNotNull(magicBranch);
-        recipients.add(magicBranch.getMailRecipients());
-        approvals = magicBranch.labels;
-        recipients.add(getRecipientsFromFooters(accountResolver, footerLines));
-        recipients.remove(me);
-        StringBuilder msg =
-            new StringBuilder(
-                ApprovalsUtil.renderMessageWithApprovals(
-                    psId.get(), approvals, Collections.<String, PatchSetApproval>emptyMap()));
-        msg.append('.');
-        if (!Strings.isNullOrEmpty(magicBranch.message)) {
-          msg.append("\n").append(magicBranch.message);
-        }
+      try (TraceTimer traceTimer = newTimer(CreateRequest.class, "addOps")) {
+        checkState(changeId != null, "must call setChangeId before addOps");
+        try {
+          RevWalk rw = receivePack.getRevWalk();
+          rw.parseBody(commit);
+          final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
+          Account.Id me = user.getAccountId();
+          List<FooterLine> footerLines = commit.getFooterLines();
+          requireNonNull(magicBranch);
 
-        bu.insertChange(
-            ins.setReviewers(recipients.getReviewers())
-                .setExtraCC(recipients.getCcOnly())
-                .setApprovals(approvals)
-                .setMessage(msg.toString())
-                .setNotify(magicBranch.getNotify())
-                .setAccountsToNotify(magicBranch.getAccountsToNotify())
-                .setRequestScopePropagator(requestScopePropagator)
-                .setSendMail(true)
-                .setPatchSetDescription(magicBranch.message));
-        if (!magicBranch.hashtags.isEmpty()) {
-          // Any change owner is allowed to add hashtags when creating a change.
-          bu.addOp(
-              changeId,
-              hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags)).setFireEvent(false));
-        }
-        if (!Strings.isNullOrEmpty(magicBranch.topic)) {
+          // TODO(dborowitz): Support reviewers by email from footers? Maybe not: kernel developers
+          // with AOSP accounts already complain about these notifications, and that would make it
+          // worse. Might be better to get rid of the feature entirely:
+          // https://groups.google.com/d/topic/repo-discuss/tIFxY7L4DXk/discussion
+          MailRecipients fromFooters = getRecipientsFromFooters(accountResolver, footerLines);
+          fromFooters.remove(me);
+
+          Map<String, Short> approvals = magicBranch.labels;
+          StringBuilder msg =
+              new StringBuilder(
+                  ApprovalsUtil.renderMessageWithApprovals(
+                      psId.get(), approvals, Collections.emptyMap()));
+          msg.append('.');
+          if (!Strings.isNullOrEmpty(magicBranch.message)) {
+            msg.append("\n").append(magicBranch.message);
+          }
+
+          bu.setNotify(magicBranch.getNotifyForNewChange());
+          bu.insertChange(
+              ins.setReviewersAndCcsAsStrings(
+                      magicBranch.getCombinedReviewers(fromFooters),
+                      magicBranch.getCombinedCcs(fromFooters))
+                  .setApprovals(approvals)
+                  .setMessage(msg.toString())
+                  .setRequestScopePropagator(requestScopePropagator)
+                  .setSendMail(true)
+                  .setPatchSetDescription(magicBranch.message));
+          if (!magicBranch.hashtags.isEmpty()) {
+            // Any change owner is allowed to add hashtags when creating a change.
+            bu.addOp(
+                changeId,
+                hashtagsFactory
+                    .create(new HashtagsInput(magicBranch.hashtags))
+                    .setFireEvent(false));
+          }
+          if (!Strings.isNullOrEmpty(magicBranch.topic)) {
+            bu.addOp(
+                changeId,
+                new BatchUpdateOp() {
+                  @Override
+                  public boolean updateChange(ChangeContext ctx) {
+                    ctx.getUpdate(psId).setTopic(magicBranch.topic);
+                    return true;
+                  }
+                });
+          }
           bu.addOp(
               changeId,
               new BatchUpdateOp() {
                 @Override
                 public boolean updateChange(ChangeContext ctx) {
-                  ctx.getUpdate(psId).setTopic(magicBranch.topic);
-                  return true;
+                  CreateRequest.this.change = ctx.getChange();
+                  return false;
                 }
               });
+          bu.addOp(changeId, new ChangeProgressOp(progress));
+        } catch (Exception e) {
+          throw asRestApiException(e);
         }
-        bu.addOp(
-            changeId,
-            new BatchUpdateOp() {
-              @Override
-              public boolean updateChange(ChangeContext ctx) {
-                change = ctx.getChange();
-                return false;
-              }
-            });
-        bu.addOp(changeId, new ChangeProgressOp(newProgress));
-      } catch (Exception e) {
-        throw INSERT_EXCEPTION.apply(e);
       }
     }
   }
 
   private void submit(Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
-      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
           PermissionBackendException {
-    Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size());
-    for (CreateRequest r : create) {
-      checkNotNull(r.change, "cannot submit new change %s; op may not have run", r.changeId);
-      bySha.put(r.commit, r.change);
-    }
-    for (ReplaceRequest r : replace) {
-      bySha.put(r.newCommitId, r.notes.getChange());
-    }
-    Change tipChange = bySha.get(magicBranch.cmd.getNewId());
-    checkNotNull(
-        tipChange, "tip of push does not correspond to a change; found these changes: %s", bySha);
-    logDebug(
-        "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);
+    try (TraceTimer traceTimer = newTimer("submit")) {
+      Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size());
+      for (CreateRequest r : create) {
+        requireNonNull(
+            r.change,
+            () -> String.format("cannot submit new change %s; op may not have run", r.changeId));
+        bySha.put(r.commit, r.change);
+      }
+      for (ReplaceRequest r : replace) {
+        bySha.put(r.newCommitId, r.notes.getChange());
+      }
+      Change tipChange = bySha.get(magicBranch.cmd.getNewId());
+      requireNonNull(
+          tipChange,
+          () ->
+              String.format(
+                  "tip of push does not correspond to a change; found these changes: %s", bySha));
+      logger.atFine().log(
+          "Processing submit with tip change %s (%s)",
+          tipChange.getId(), magicBranch.cmd.getNewId());
+      try (MergeOp op = mergeOpProvider.get()) {
+        SubmitInput submitInput = new SubmitInput();
+        submitInput.notify = magicBranch.notifyHandling;
+        submitInput.notifyDetails = new HashMap<>();
+        submitInput.notifyDetails.put(
+            RecipientType.TO,
+            new NotifyInfo(magicBranch.notifyTo.stream().map(Object::toString).collect(toList())));
+        submitInput.notifyDetails.put(
+            RecipientType.CC,
+            new NotifyInfo(magicBranch.notifyCc.stream().map(Object::toString).collect(toList())));
+        submitInput.notifyDetails.put(
+            RecipientType.BCC,
+            new NotifyInfo(magicBranch.notifyBcc.stream().map(Object::toString).collect(toList())));
+        op.merge(tipChange, user, false, submitInput, false);
+      }
     }
   }
 
-  private void preparePatchSetsForReplace() {
-    try {
-      readChangesForReplace();
-      for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator(); itr.hasNext(); ) {
-        ReplaceRequest req = itr.next();
-        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.validate(false);
-          if (req.skip && req.cmd == null) {
-            itr.remove();
+  private void preparePatchSetsForReplace(List<CreateRequest> newChanges) {
+    try (TraceTimer traceTimer =
+        newTimer(
+            "preparePatchSetsForReplace", Metadata.builder().resourceCount(newChanges.size()))) {
+      try {
+        readChangesForReplace();
+        for (ReplaceRequest req : replaceByChange.values()) {
+          if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
+            req.validateNewPatchSet();
           }
         }
+      } catch (StorageException err) {
+        logger.atSevere().withCause(err).log(
+            "Cannot read database before replacement for project %s", project.getName());
+        rejectRemainingRequests(replaceByChange.values(), "internal server error");
+      } catch (IOException | PermissionBackendException err) {
+        logger.atSevere().withCause(err).log(
+            "Cannot read repository before replacement for project %s", project.getName());
+        rejectRemainingRequests(replaceByChange.values(), "internal server error");
       }
-    } catch (OrmException err) {
-      logError(
-          String.format(
-              "Cannot read database before replacement for project %s", project.getName()),
-          err);
-      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);
-      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.
-      for (ReplaceRequest req : replaceByChange.values()) {
-        if (req.inputCommand == magicBranch.cmd && req.cmd != null) {
+      if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
+        // Cancel creations tied to refs/for/ or refs/drafts/ command.
+        for (ReplaceRequest req : replaceByChange.values()) {
+          if (req.inputCommand == magicBranch.cmd && req.cmd != null) {
+            req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
+          }
+        }
+        for (CreateRequest req : newChanges) {
           req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
         }
       }
-      for (CreateRequest req : newChanges) {
-        req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
+    }
+  }
+
+  private void readChangesForReplace() {
+    try (TraceTimer traceTimer = newTimer("readChangesForReplace")) {
+      Collection<ChangeNotes> allNotes =
+          notesFactory.create(
+              replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
+      for (ChangeNotes notes : allNotes) {
+        replaceByChange.get(notes.getChangeId()).notes = notes;
       }
     }
   }
 
-  private void readChangesForReplace() throws OrmException {
-    Collection<ChangeNotes> allNotes =
-        notesFactory.create(
-            db, replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
-    for (ChangeNotes notes : allNotes) {
-      replaceByChange.get(notes.getChangeId()).notes = notes;
-    }
-  }
-
+  /** 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;
     final ReceiveCommand inputCommand;
     final boolean checkMergedInto;
+    RevCommit revCommit;
     ChangeNotes notes;
     BiMap<RevCommit, PatchSet.Id> revisions;
     PatchSet.Id psId;
     ReceiveCommand prev;
     ReceiveCommand cmd;
     PatchSetInfo info;
-    boolean skip;
-    private PatchSet.Id priorPatchSet;
+    PatchSet.Id priorPatchSet;
     List<String> groups = ImmutableList.of();
-    private ReplaceOp replaceOp;
+    ReplaceOp replaceOp;
 
     ReplaceRequest(
         Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
       this.ontoChange = toChange;
       this.newCommitId = newCommit.copy();
-      this.inputCommand = checkNotNull(cmd);
+      this.inputCommand = requireNonNull(cmd);
       this.checkMergedInto = checkMergedInto;
 
+      try {
+        revCommit = receivePack.getRevWalk().parseCommit(newCommitId);
+      } catch (IOException e) {
+        revCommit = null;
+      }
       revisions = HashBiMap.create();
       for (Ref ref : refs(toChange)) {
         try {
           revisions.forcePut(
-              rp.getRevWalk().parseCommit(ref.getObjectId()), PatchSet.Id.fromRef(ref.getName()));
+              receivePack.getRevWalk().parseCommit(ref.getObjectId()),
+              PatchSet.Id.fromRef(ref.getName()));
         } catch (IOException err) {
-          logWarn(
-              String.format(
-                  "Project %s contains invalid change ref %s", project.getName(), ref.getName()),
-              err);
+          logger.atWarning().withCause(err).log(
+              "Project %s contains invalid change ref %s", project.getName(), ref.getName());
         }
       }
     }
@@ -2414,21 +2719,48 @@
      * <ul>
      *   <li>May add error or warning messages to the progress monitor
      *   <li>Will reject {@code cmd} prior to returning false
-     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a walk.
+     *   <li>May reset {@code receivePack.getRevWalk()}; do not call in the middle of a walk.
      * </ul>
      *
-     * @param autoClose whether the caller intends to auto-close the change after adding a new patch
-     *     set.
      * @return whether the new commit is valid
      * @throws IOException
-     * @throws OrmException
      * @throws PermissionBackendException
      */
-    boolean validate(boolean autoClose)
-        throws IOException, OrmException, PermissionBackendException {
-      if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
+    boolean validateNewPatchSet() throws IOException, PermissionBackendException {
+      try (TraceTimer traceTimer = newTimer(ReplaceRequest.class, "validateNewPatchSet")) {
+        if (!validateNewPatchSetNoteDb()) {
+          return false;
+        }
+        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, PermissionBackendException {
+      if (!validateNewPatchSetNoteDb()) {
         return false;
-      } else if (notes == null) {
+      }
+
+      newPatchSet();
+      return true;
+    }
+
+    /** Validates the new PS against permissions and notedb status. */
+    private boolean validateNewPatchSetNoteDb() throws IOException, PermissionBackendException {
+      if (notes == null) {
         reject(inputCommand, "change " + ontoChange + " not found");
         return false;
       }
@@ -2440,27 +2772,22 @@
         return false;
       }
 
-      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+      RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
 
       // Not allowed to create a new patch set if the current patch set is locked.
-      if (psUtil.isPatchSetLocked(notes, user)) {
+      if (psUtil.isPatchSetLocked(notes)) {
         reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
         return false;
       }
 
       try {
-        permissions.change(notes).database(db).check(ChangePermission.ADD_PATCH_SET);
+        permissions.change(notes).check(ChangePermission.ADD_PATCH_SET);
       } catch (AuthException no) {
         reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
         return false;
       }
 
-      if (!projectState.statePermitsWrite()) {
-        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
-        return false;
-      }
-      if (change.getStatus().isClosed()) {
+      if (change.isClosed()) {
         reject(inputCommand, "change " + ontoChange + " closed");
         return false;
       } else if (revisions.containsKey(newCommit)) {
@@ -2468,7 +2795,7 @@
         return false;
       }
 
-      for (Ref r : rp.getRepository().getRefDatabase().getRefs("refs/changes").values()) {
+      for (Ref r : receivePack.getRepository().getRefDatabase().getRefsByPrefix("refs/changes")) {
         if (r.getObjectId().equals(newCommit)) {
           reject(inputCommand, "commit already exists (in the project)");
           return false;
@@ -2479,37 +2806,62 @@
         // Don't allow a change to directly depend upon itself. This is a
         // very common error due to users making a new commit rather than
         // amending when trying to address review comments.
-        if (rp.getRevWalk().isMergedInto(prior, newCommit)) {
+        if (receivePack.getRevWalk().isMergedInto(prior, newCommit)) {
           reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
           return false;
         }
       }
 
-      PermissionBackend.ForRef perm = permissions.ref(change.getDest().get());
-      if (!validCommit(rp.getRevWalk(), perm, change.getDest(), inputCommand, newCommit, change)) {
-        return false;
-      }
-      rp.getRevWalk().parseBody(priorCommit);
+      return true;
+    }
 
-      // Don't allow the same tree if the commit message is unmodified
-      // or no parents were updated (rebase), else warn that only part
-      // of the commit was modified.
+    /** Validates whether the WIP change is allowed. Rejects inputCommand if not. */
+    private void validateMagicBranchWipStatusChange() throws PermissionBackendException {
+      Change change = notes.getChange();
+      if ((magicBranch.workInProgress || magicBranch.ready)
+          && magicBranch.workInProgress != change.isWorkInProgress()
+          && !user.getAccountId().equals(change.getOwner())) {
+        boolean hasWriteConfigPermission = false;
+        try {
+          permissions.check(ProjectPermission.WRITE_CONFIG);
+          hasWriteConfigPermission = true;
+        } catch (AuthException e) {
+          // Do nothing.
+        }
+
+        if (!hasWriteConfigPermission) {
+          try {
+            permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+          } catch (AuthException e1) {
+            reject(inputCommand, ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
+          }
+        }
+      }
+    }
+
+    /** prints a warning if the new PS has the same tree as the previous commit. */
+    private void sameTreeWarning() throws IOException {
+      RevWalk rw = receivePack.getRevWalk();
+      RevCommit newCommit = rw.parseCommit(newCommitId);
+      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+
       if (newCommit.getTree().equals(priorCommit.getTree())) {
+        rw.parseBody(newCommit);
+        rw.parseBody(priorCommit);
         boolean messageEq =
             Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage());
         boolean parentsEq = parentsEqual(newCommit, priorCommit);
         boolean authorEq = authorEqual(newCommit, priorCommit);
-        ObjectReader reader = rp.getRevWalk().getObjectReader();
+        ObjectReader reader = receivePack.getRevWalk().getObjectReader();
 
-        if (messageEq && parentsEq && authorEq && !autoClose) {
+        if (messageEq && parentsEq && authorEq) {
           addMessage(
               String.format(
-                  "(W) No changes between prior commit %s and new commit %s",
-                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
+                  "warning: no changes between prior commit %s and new commit %s",
+                  abbreviateName(priorCommit, reader), abbreviateName(newCommit, reader)));
         } else {
           StringBuilder msg = new StringBuilder();
-          msg.append("(I) ");
-          msg.append(reader.abbreviate(newCommit).name());
+          msg.append("warning: ").append(abbreviateName(newCommit, reader));
           msg.append(":");
           msg.append(" no files changed");
           if (!authorEq) {
@@ -2524,38 +2876,25 @@
           addMessage(msg.toString());
         }
       }
-
-      if (magicBranch != null
-          && (magicBranch.workInProgress || magicBranch.ready)
-          && magicBranch.workInProgress != change.isWorkInProgress()
-          && (!user.getAccountId().equals(change.getOwner())
-              && !permissions.test(ProjectPermission.WRITE_CONFIG)
-              && !permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER))) {
-        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;
     }
 
+    /**
+     * 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;
       }
 
       if (edit.isPresent()) {
-        if (edit.get().getBasePatchSet().getId().equals(psId)) {
+        if (edit.get().getBasePatchSet().id().equals(psId)) {
           // replace edit
           cmd =
               new ReceiveCommand(edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
@@ -2573,8 +2912,8 @@
       return true;
     }
 
+    /** Creates a ReceiveCommand for a new edit. */
     private void createEditCommand() {
-      // create new edit
       cmd =
           new ReceiveCommand(
               ObjectId.zeroId(),
@@ -2582,47 +2921,51 @@
               RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId));
     }
 
-    private void newPatchSet() throws IOException, OrmException {
-      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
+    /** Updates 'this' to add a new patchset. */
+    private void newPatchSet() throws IOException {
+      RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
       psId =
           ChangeUtil.nextPatchSetIdFromAllRefsMap(allRefs(), notes.getChange().currentPatchSetId());
-      info = patchSetInfoFactory.get(rp.getRevWalk(), newCommit, psId);
+      info = patchSetInfoFactory.get(receivePack.getRevWalk(), newCommit, psId);
       cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
     }
 
     void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
-      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
-        bu.addOp(notes.getChangeId(), new ReindexOnlyOp());
-        if (prev != null) {
-          bu.addRepoOnlyOp(new UpdateOneRefOp(prev));
+      try (TraceTimer traceTimer = newTimer(ReplaceRequest.class, "addOps")) {
+        if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
+          bu.addOp(notes.getChangeId(), new ReindexOnlyOp());
+          if (prev != null) {
+            bu.addRepoOnlyOp(new UpdateOneRefOp(prev));
+          }
+          bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
+          return;
         }
-        bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
-        return;
-      }
-      RevWalk rw = rp.getRevWalk();
-      // TODO(dborowitz): Move to ReplaceOp#updateRepo.
-      RevCommit newCommit = rw.parseCommit(newCommitId);
-      rw.parseBody(newCommit);
+        RevWalk rw = receivePack.getRevWalk();
+        // TODO(dborowitz): Move to ReplaceOp#updateRepo.
+        RevCommit newCommit = rw.parseCommit(newCommitId);
+        rw.parseBody(newCommit);
 
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-      replaceOp =
-          replaceOpFactory
-              .create(
-                  projectState,
-                  notes.getChange().getDest(),
-                  checkMergedInto,
-                  priorPatchSet,
-                  priorCommit,
-                  psId,
-                  newCommit,
-                  info,
-                  groups,
-                  magicBranch,
-                  rp.getPushCertificate())
-              .setRequestScopePropagator(requestScopePropagator);
-      bu.addOp(notes.getChangeId(), replaceOp);
-      if (progress != null) {
-        bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
+        RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+        replaceOp =
+            replaceOpFactory
+                .create(
+                    projectState,
+                    notes.getChange().getDest(),
+                    checkMergedInto,
+                    checkMergedInto ? inputCommand.getNewId().name() : null,
+                    priorPatchSet,
+                    priorCommit,
+                    psId,
+                    newCommit,
+                    info,
+                    groups,
+                    magicBranch,
+                    receivePack.getPushCertificate())
+                .setRequestScopePropagator(requestScopePropagator);
+        bu.addOp(notes.getChangeId(), replaceOp);
+        if (progress != null) {
+          bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
+        }
       }
     }
 
@@ -2632,23 +2975,23 @@
   }
 
   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) {
-      this.psId = checkNotNull(PatchSet.Id.fromRef(ref.getName()));
+      this.psId = requireNonNull(PatchSet.Id.fromRef(ref.getName()));
       this.commit = commit;
     }
 
     private void addOps(BatchUpdate bu) {
       bu.addOp(
-          psId.getParentKey(),
+          psId.changeId(),
           new BatchUpdateOp() {
             @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-              List<String> oldGroups = ps.getGroups();
+            public boolean updateChange(ChangeContext ctx) {
+              PatchSet ps = psUtil.get(ctx.getNotes(), psId);
+              List<String> oldGroups = ps.groups();
               if (oldGroups == null) {
                 if (groups == null) {
                   return false;
@@ -2656,7 +2999,7 @@
               } else if (sameGroups(oldGroups, groups)) {
                 return false;
               }
-              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
+              ctx.getUpdate(psId).setGroups(groups);
               return true;
             }
           });
@@ -2668,10 +3011,10 @@
   }
 
   private class UpdateOneRefOp implements RepoOnlyOp {
-    private final ReceiveCommand cmd;
+    final ReceiveCommand cmd;
 
     private UpdateOneRefOp(ReceiveCommand cmd) {
-      this.cmd = checkNotNull(cmd);
+      this.cmd = requireNonNull(cmd);
     }
 
     @Override
@@ -2683,11 +3026,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) {
@@ -2696,7 +3039,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());
@@ -2738,7 +3081,7 @@
           PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
           if (psId != null) {
             refsById.put(obj, ref);
-            refsByChange.put(psId.getParentKey(), ref);
+            refsByChange.put(psId.changeId(), ref);
           }
         }
       }
@@ -2755,7 +3098,7 @@
     return refsById;
   }
 
-  static boolean parentsEqual(RevCommit a, RevCommit b) {
+  private static boolean parentsEqual(RevCommit a, RevCommit b) {
     if (a.getParentCount() != b.getParentCount()) {
       return false;
     }
@@ -2767,7 +3110,7 @@
     return true;
   }
 
-  static boolean authorEqual(RevCommit a, RevCommit b) {
+  private static boolean authorEqual(RevCommit a, RevCommit b) {
     PersonIdent aAuthor = a.getAuthorIdent();
     PersonIdent bAuthor = b.getAuthorIdent();
 
@@ -2781,298 +3124,291 @@
         && 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);
+    try (TraceTimer traceTimer = newTimer("validRefOperation")) {
+      RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
 
-    try {
-      messages.addAll(refValidators.validateForRefOperation());
-    } catch (RefOperationValidationException e) {
-      messages.addAll(Lists.newArrayList(e.getMessages()));
-      reject(cmd, e.getMessage());
-      return false;
-    }
-
-    return true;
-  }
-
-  private void validateNewCommits(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());
+        messages.addAll(refValidators.validateForRefOperation());
+      } catch (RefOperationValidationException e) {
+        messages.addAll(e.getMessages());
+        reject(cmd, e.getMessage());
+        return false;
       }
-      return;
-    }
 
-    boolean missingFullName = Strings.isNullOrEmpty(user.getAccount().getFullName());
-    RevWalk walk = rp.getRevWalk();
-    walk.reset();
-    walk.sort(RevSort.NONE);
-    try {
-      RevObject parsedObject = walk.parseAny(cmd.getNewId());
-      if (!(parsedObject instanceof RevCommit)) {
-        return;
-      }
-      ListMultimap<ObjectId, Ref> existing = changeRefsById();
-      walk.markStart((RevCommit) parsedObject);
-      markHeadsAsUninteresting(walk, cmd.getRefName());
-      int limit = receiveConfig.maxBatchCommits;
-      int n = 0;
-      for (RevCommit c; (c = walk.next()) != null; ) {
-        if (++n > limit) {
-          logDebug("Number of new commits exceeds limit of %d", limit);
-          addMessage(
-              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");
-          return;
-        }
-        if (existing.keySet().contains(c)) {
-          continue;
-        } else if (!validCommit(walk, perm, branch, cmd, c, 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);
-    } catch (IOException err) {
-      cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", err);
-    }
-  }
-
-  private boolean validCommit(
-      RevWalk rw,
-      PermissionBackend.ForRef perm,
-      Branch.NameKey branch,
-      ReceiveCommand cmd,
-      ObjectId id,
-      @Nullable Change change)
-      throws IOException {
-
-    if (validCommits.contains(id)) {
       return true;
     }
-
-    RevCommit c = rw.parseCommit(id);
-    rw.parseBody(c);
-
-    try (CommitReceivedEvent receiveEvent =
-        new CommitReceivedEvent(cmd, project, branch.get(), rw.getObjectReader(), c, user)) {
-      boolean isMerged =
-          magicBranch != null
-              && cmd.getRefName().equals(magicBranch.cmd.getRefName())
-              && magicBranch.merged;
-      CommitValidators validators =
-          isMerged
-              ? commitValidatorsFactory.forMergedCommits(
-                  project.getNameKey(), perm, user.asIdentifiedUser())
-              : commitValidatorsFactory.forReceiveCommits(
-                  perm, branch, user.asIdentifiedUser(), sshInfo, repo, rw, change);
-      messages.addAll(validators.validate(receiveEvent));
-    } catch (CommitValidationException e) {
-      logDebug("Commit validation failed on %s", c.name());
-      messages.addAll(e.getMessages());
-      reject(cmd, e.getMessage());
-      return false;
-    }
-    validCommits.add(c.copy());
-    return true;
   }
 
-  private void autoCloseChanges(ReceiveCommand cmd) {
-    logDebug("Starting auto-closing of changes");
-    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.
-    try {
-      retryHelper.execute(
-          updateFactory -> {
-            try (BatchUpdate bu =
-                    updateFactory.create(db, projectState.getNameKey(), user, TimeUtil.nowTs());
-                ObjectInserter ins = repo.newObjectInserter();
-                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.
+  /**
+   * Validates the commits that a regular push brings in.
+   *
+   * <p>On validation failure, the command is rejected.
+   */
+  private void validateRegularPushCommits(BranchNameKey branch, ReceiveCommand cmd)
+      throws PermissionBackendException {
+    try (TraceTimer traceTimer =
+        newTimer("validateRegularPushCommits", Metadata.builder().branchName(branch.branch()))) {
+      boolean skipValidation =
+          !RefNames.REFS_CONFIG.equals(cmd.getRefName())
+              && !(MagicBranch.isMagicBranch(cmd.getRefName())
+                  || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
+              && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION);
+      if (skipValidation) {
+        if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
+          reject(cmd, "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
+          return;
+        }
 
-              RevCommit newTip = rw.parseCommit(cmd.getNewId());
-              Branch.NameKey branch = new Branch.NameKey(project.getNameKey(), refName);
+        Optional<AuthException> err =
+            checkRefPermission(permissions.ref(branch.branch()), RefPermission.SKIP_VALIDATION);
+        if (err.isPresent()) {
+          rejectProhibited(cmd, err.get());
+          return;
+        }
+        if (!Iterables.isEmpty(rejectCommits)) {
+          reject(cmd, "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
+        }
+      }
 
-              rw.reset();
-              rw.markStart(newTip);
-              if (!ObjectId.zeroId().equals(cmd.getOldId())) {
-                rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
-              }
+      BranchCommitValidator validator = commitValidatorFactory.create(projectState, branch, user);
+      RevWalk walk = receivePack.getRevWalk();
+      walk.reset();
+      walk.sort(RevSort.NONE);
+      try {
+        RevObject parsedObject = walk.parseAny(cmd.getNewId());
+        if (!(parsedObject instanceof RevCommit)) {
+          return;
+        }
+        ListMultimap<ObjectId, Ref> existing = changeRefsById();
+        walk.markStart((RevCommit) parsedObject);
+        markHeadsAsUninteresting(walk, cmd.getRefName());
+        int limit = receiveConfig.maxBatchCommits;
+        int n = 0;
+        for (RevCommit c; (c = walk.next()) != null; ) {
+          // Even if skipValidation is set, we still get here when at least one plugin
+          // commit validator requires to validate all commits. In this case, however,
+          // we don't need to check the commit limit.
+          if (++n > limit && !skipValidation) {
+            logger.atFine().log("Number of new commits exceeds limit of %d", limit);
+            reject(
+                cmd,
+                String.format(
+                    "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
+            return;
+          }
+          if (existing.keySet().contains(c)) {
+            continue;
+          }
 
-              ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
-              Map<Change.Key, ChangeNotes> byKey = null;
-              List<ReplaceRequest> replaceAndClose = new ArrayList<>();
-
-              int existingPatchSets = 0;
-              int newPatchSets = 0;
-              COMMIT:
-              for (RevCommit c; (c = rw.next()) != null; ) {
-                rw.parseBody(c);
-
-                for (Ref ref : byCommit.get(c.copy())) {
-                  PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-                  Optional<ChangeNotes> notes = getChangeNotes(psId.getParentKey());
-                  if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
-                    existingPatchSets++;
-                    bu.addOp(
-                        psId.getParentKey(),
-                        mergedByPushOpFactory.create(requestScopePropagator, psId, refName));
-                    continue COMMIT;
-                  }
-                }
-
-                for (String changeId : c.getFooterLines(CHANGE_ID)) {
-                  if (byKey == null) {
-                    byKey = executeIndexQuery(() -> openChangesByKeyByBranch(branch));
-                  }
-
-                  ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
-                  if (onto != null) {
-                    newPatchSets++;
-                    // Hold onto this until we're done with the walk, as the call to
-                    // req.validate below calls isMergedInto which resets the walk.
-                    ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
-                    req.notes = onto;
-                    replaceAndClose.add(req);
-                    continue COMMIT;
-                  }
-                }
-              }
-
-              for (ReplaceRequest req : replaceAndClose) {
-                Change.Id id = req.notes.getChangeId();
-                if (!req.validate(true)) {
-                  logDebug("Not closing %s because validation failed", id);
-                  continue;
-                }
-                req.addOps(bu, null);
-                bu.addOp(
-                    id,
-                    mergedByPushOpFactory
-                        .create(requestScopePropagator, req.psId, refName)
-                        .setPatchSetProvider(req.replaceOp::getPatchSet));
-                bu.addOp(id, new ChangeProgressOp(closeProgress));
-              }
-
-              logDebug(
-                  "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);
-            }
-            return null;
-          },
-          // Use a multiple of the default timeout to account for inner retries that may otherwise
-          // eat up the whole timeout so that no time is left to retry this outer action.
-          RetryHelper.options()
-              .timeout(retryHelper.getDefaultTimeout(ActionType.CHANGE_UPDATE).multipliedBy(5))
-              .build());
-    } catch (RestApiException e) {
-      logError("Can't insert patchset", e);
-    } catch (UpdateException e) {
-      logError("Failed to auto-close changes", e);
+          BranchCommitValidator.Result validationResult =
+              validator.validateCommit(
+                  walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
+          messages.addAll(validationResult.messages());
+          if (!validationResult.isValid()) {
+            break;
+          }
+        }
+        logger.atFine().log("Validated %d new commits", n);
+      } catch (IOException err) {
+        cmd.setResult(REJECTED_MISSING_OBJECT);
+        logger.atSevere().withCause(err).log(
+            "Invalid pack upload; one or more objects weren't sent");
+      }
     }
   }
 
-  private Optional<ChangeNotes> getChangeNotes(Change.Id changeId) throws OrmException {
+  private void autoCloseChanges(ReceiveCommand cmd, Task progress) {
+    try (TraceTimer traceTimer = newTimer("autoCloseChanges")) {
+      logger.atFine().log("Starting auto-closing of changes");
+      String refName = cmd.getRefName();
+      Set<Change.Id> ids = new HashSet<>();
+
+      // TODO(dborowitz): Combine this BatchUpdate with the main one in
+      // handleRegularCommands
+      try {
+        retryHelper.execute(
+            updateFactory -> {
+              try (BatchUpdate bu =
+                      updateFactory.create(projectState.getNameKey(), user, TimeUtil.nowTs());
+                  ObjectInserter ins = repo.newObjectInserter();
+                  ObjectReader reader = ins.newReader();
+                  RevWalk rw = new RevWalk(reader)) {
+                bu.setRepository(repo, rw, ins);
+                // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
+
+                RevCommit newTip = rw.parseCommit(cmd.getNewId());
+                BranchNameKey branch = BranchNameKey.create(project.getNameKey(), refName);
+
+                rw.reset();
+                rw.sort(RevSort.REVERSE);
+                rw.markStart(newTip);
+                if (!ObjectId.zeroId().equals(cmd.getOldId())) {
+                  rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
+                }
+
+                ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
+                Map<Change.Key, ChangeNotes> byKey = null;
+                List<ReplaceRequest> replaceAndClose = new ArrayList<>();
+
+                int existingPatchSets = 0;
+                int newPatchSets = 0;
+                COMMIT:
+                for (RevCommit c; (c = rw.next()) != null; ) {
+                  rw.parseBody(c);
+
+                  for (Ref ref : byCommit.get(c.copy())) {
+                    PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+                    Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
+                    if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
+                      existingPatchSets++;
+                      bu.addOp(notes.get().getChangeId(), setPrivateOpFactory.create(false, null));
+                      bu.addOp(
+                          psId.changeId(),
+                          mergedByPushOpFactory.create(
+                              requestScopePropagator, psId, refName, newTip.getId().getName()));
+                      continue COMMIT;
+                    }
+                  }
+
+                  for (String changeId : c.getFooterLines(FooterConstants.CHANGE_ID)) {
+                    if (byKey == null) {
+                      byKey = executeIndexQuery(() -> openChangesByKeyByBranch(branch));
+                    }
+
+                    ChangeNotes onto = byKey.get(Change.key(changeId.trim()));
+                    if (onto != null) {
+                      newPatchSets++;
+                      // Hold onto this until we're done with the walk, as the call to
+                      // req.validate below calls isMergedInto which resets the walk.
+                      ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
+                      req.notes = onto;
+                      replaceAndClose.add(req);
+                      continue COMMIT;
+                    }
+                  }
+                }
+
+                for (ReplaceRequest req : replaceAndClose) {
+                  Change.Id id = req.notes.getChangeId();
+                  if (!req.validateNewPatchSetForAutoClose()) {
+                    logger.atFine().log("Not closing %s because validation failed", id);
+                    continue;
+                  }
+                  req.addOps(bu, null);
+                  bu.addOp(id, setPrivateOpFactory.create(false, null));
+                  bu.addOp(
+                      id,
+                      mergedByPushOpFactory
+                          .create(
+                              requestScopePropagator, req.psId, refName, newTip.getId().getName())
+                          .setPatchSetProvider(req.replaceOp::getPatchSet));
+                  bu.addOp(id, new ChangeProgressOp(progress));
+                  ids.add(id);
+                }
+
+                logger.atFine().log(
+                    "Auto-closing %s changes with existing patch sets and %s with new patch sets",
+                    existingPatchSets, newPatchSets);
+                bu.execute();
+              } catch (IOException | StorageException | PermissionBackendException e) {
+                logger.atSevere().withCause(e).log("Failed to auto-close changes");
+                return null;
+              }
+
+              // If we are here, we didn't throw UpdateException. Record the result.
+              // The ordering is indeterminate due to the HashSet; unfortunately, Change.Id doesn't
+              // fit into TreeSet.
+              ids.stream().forEach(id -> resultChangeIds.add(ResultChangeIds.Key.AUTOCLOSED, id));
+
+              return null;
+            },
+            // Use a multiple of the default timeout to account for inner retries that may otherwise
+            // eat up the whole timeout so that no time is left to retry this outer action.
+            RetryHelper.options()
+                .timeout(retryHelper.getDefaultTimeout(ActionType.CHANGE_UPDATE).multipliedBy(5))
+                .build());
+      } catch (RestApiException e) {
+        logger.atSevere().withCause(e).log("Can't insert patchset");
+      } catch (UpdateException e) {
+        logger.atSevere().withCause(e).log("Failed to auto-close changes");
+      }
+    }
+  }
+
+  private Optional<ChangeNotes> getChangeNotes(Change.Id changeId) {
     try {
-      return Optional.of(notesFactory.createChecked(db, project.getNameKey(), changeId));
+      return Optional.of(notesFactory.createChecked(project.getNameKey(), changeId));
     } catch (NoSuchChangeException e) {
       return Optional.empty();
     }
   }
 
-  private <T> T executeIndexQuery(Action<T> action) throws OrmException {
-    try {
-      return retryHelper.execute(ActionType.INDEX_QUERY, action, OrmException.class::isInstance);
+  private <T> T executeIndexQuery(Action<T> action) {
+    try (TraceTimer traceTimer = newTimer("executeIndexQuery")) {
+      return retryHelper.execute(
+          ActionType.INDEX_QUERY, action, StorageException.class::isInstance);
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
-      Throwables.throwIfInstanceOf(e, OrmException.class);
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
-  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<>();
-    for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
-      try {
-        r.put(cd.change().getKey(), cd.notes());
-      } catch (NoSuchChangeException e) {
-        // Ignore deleted change
+  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(BranchNameKey branch) {
+    try (TraceTimer traceTimer =
+        newTimer("openChangesByKeyByBranch", Metadata.builder().branchName(branch.branch()))) {
+      Map<Change.Key, ChangeNotes> r = new HashMap<>();
+      for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
+        try {
+          r.put(cd.change().getKey(), cd.notes());
+        } catch (NoSuchChangeException e) {
+          // Ignore deleted change
+        }
       }
+      return r;
     }
-    return r;
   }
 
+  // allRefsWatcher hooks into the protocol negotation to get a list of all known refs.
+  // This is used as a cache of ref -> sha1 values, and to build an inverse index
+  // of (change => list of refs) and a (SHA1 => refs).
   private Map<String, Ref> allRefs() {
     return allRefsWatcher.getAllRefs();
   }
 
-  private void reject(@Nullable ReceiveCommand cmd, String why) {
-    if (cmd != null) {
-      cmd.setResult(REJECTED_OTHER_REASON, why);
-      commandProgress.update(1);
-    }
+  private TraceTimer newTimer(String name) {
+    return newTimer(getClass(), name);
+  }
+
+  private TraceTimer newTimer(Class<?> clazz, String name) {
+    return newTimer(clazz, name, Metadata.builder());
+  }
+
+  private TraceTimer newTimer(String name, Metadata.Builder metadataBuilder) {
+    return newTimer(getClass(), name, metadataBuilder);
+  }
+
+  private TraceTimer newTimer(Class<?> clazz, String name, Metadata.Builder metadataBuilder) {
+    metadataBuilder.projectName(project.getName());
+    return TraceContext.newTimer(clazz.getSimpleName() + "#" + name, metadataBuilder.build());
+  }
+
+  private static void reject(ReceiveCommand cmd, String why) {
+    cmd.setResult(REJECTED_OTHER_REASON, why);
+  }
+
+  private static void rejectRemaining(Collection<ReceiveCommand> commands, String why) {
+    rejectRemaining(commands.stream(), why);
+  }
+
+  private static void rejectRemaining(Stream<ReceiveCommand> commands, String why) {
+    commands.filter(cmd -> cmd.getResult() == NOT_ATTEMPTED).forEach(cmd -> reject(cmd, why));
+  }
+
+  private static void rejectRemainingRequests(Collection<ReplaceRequest> requests, String why) {
+    rejectRemaining(requests.stream().map(req -> req.cmd), why);
   }
 
   private static boolean isHead(ReceiveCommand cmd) {
@@ -3083,45 +3419,14 @@
     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);
+  private static String commandToString(ReceiveCommand cmd) {
+    StringBuilder b = new StringBuilder();
+    b.append(cmd);
+    b.append("  (").append(cmd.getResult());
+    if (cmd.getMessage() != null) {
+      b.append(": ").append(cmd.getMessage());
+    }
+    b.append(")\n");
+    return b.toString();
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index 8cbcc88..2ea417e 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.git.receive;
 
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.Maps;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -27,30 +27,45 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.MagicBranch;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
+import java.io.IOException;
 import java.util.Collections;
 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.transport.AdvertiseRefsHook;
 import org.eclipse.jgit.transport.BaseReceivePack;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.UploadPack;
 
-/** Exposes only the non refs/changes/ reference names. */
+/**
+ * Exposes only the non refs/changes/ reference names and provide additional haves.
+ *
+ * <p>Negotiation on Git push is suboptimal in that it tends to send more objects to the server than
+ * it should. This results in increased latencies for {@code git push}.
+ *
+ * <p>Ref advertisement for Git pushes still works in a "the server speaks first fashion" as Git
+ * Protocol V2 only addressed fetches Therefore the server needs to send all available references.
+ * For large repositories, this can be in the tens of megabytes to send to the client. We therefore
+ * remove all refs in refs/changes/* to scale down that footprint. Trivially, this would increase
+ * the unnecessary objects that the client has to send to the server because the common ancestor it
+ * finds in negotiation might be further back in history.
+ *
+ * <p>To work around this, we advertise the last 32 changes in that repository as additional {@code
+ * .haves}. This is a heuristical approach that aims at scaling down the number of unnecessary
+ * objects that client sends to the server. Unnecessary here refers to objects that the server
+ * already has.
+ *
+ * <p>For some code paths in {@link com.google.gerrit.server.git.DefaultAdvertiseRefsHook}, we
+ * already removed refs/changes, so the logic to skip these in this class become a no-op.
+ *
+ * <p>TODO(hiesel): Instrument this heuristic and proof its value.
+ */
 public class ReceiveCommitsAdvertiseRefsHook implements AdvertiseRefsHook {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  @VisibleForTesting
-  @AutoValue
-  public abstract static class Result {
-    public abstract Map<String, Ref> allRefs();
-
-    public abstract Set<ObjectId> additionalHaves();
-  }
-
   private final Provider<InternalChangeQuery> queryProvider;
   private final Project.NameKey projectName;
 
@@ -68,28 +83,16 @@
 
   @Override
   public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
-    Result r = advertiseRefs(HookUtil.ensureAllRefsAdvertised(rp));
-    rp.setAdvertisedRefs(r.allRefs(), r.additionalHaves());
+    Map<String, Ref> advertisedRefs = HookUtil.ensureAllRefsAdvertised(rp);
+    advertisedRefs.keySet().stream()
+        .filter(ReceiveCommitsAdvertiseRefsHook::skip)
+        .collect(toImmutableList())
+        .forEach(r -> advertisedRefs.remove(r));
+    rp.setAdvertisedRefs(advertisedRefs, advertiseOpenChanges(rp.getRepository()));
   }
 
-  @VisibleForTesting
-  public Result advertiseRefs(Map<String, Ref> oldRefs) {
-    Map<String, Ref> r = Maps.newHashMapWithExpectedSize(oldRefs.size());
-    Set<ObjectId> allPatchSets = Sets.newHashSetWithExpectedSize(oldRefs.size());
-    for (Map.Entry<String, Ref> e : oldRefs.entrySet()) {
-      String name = e.getKey();
-      if (!skip(name)) {
-        r.put(name, e.getValue());
-      }
-      if (name.startsWith(RefNames.REFS_CHANGES)) {
-        allPatchSets.add(e.getValue().getObjectId());
-      }
-    }
-    return new AutoValue_ReceiveCommitsAdvertiseRefsHook_Result(
-        r, advertiseOpenChanges(allPatchSets));
-  }
-
-  private Set<ObjectId> advertiseOpenChanges(Set<ObjectId> allPatchSets) {
+  private Set<ObjectId> advertiseOpenChanges(Repository repo)
+      throws ServiceMayNotContinueException {
     // Advertise some recent open changes, in case a commit is based on one.
     int limit = 32;
     try {
@@ -108,17 +111,22 @@
               .byProjectOpen(projectName)) {
         PatchSet ps = cd.currentPatchSet();
         if (ps != null) {
-          ObjectId id = ObjectId.fromString(ps.getRevision().get());
           // Ensure we actually observed a patch set ref pointing to this
           // object, in case the database is out of sync with the repo and the
           // object doesn't actually exist.
-          if (allPatchSets.contains(id)) {
-            r.add(id);
+          try {
+            Ref psRef = repo.getRefDatabase().exactRef(RefNames.patchSetRef(ps.id()));
+            if (psRef != null) {
+              r.add(ps.commitId());
+            }
+          } catch (IOException e) {
+            throw new ServiceMayNotContinueException(e);
           }
         }
       }
+
       return r;
-    } catch (OrmException err) {
+    } catch (StorageException err) {
       logger.atSevere().withCause(err).log("Cannot list open changes of %s", projectName);
       return Collections.emptySet();
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
new file mode 100644
index 0000000..d10fc9a
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.AdvertiseRefsHookChain;
+
+/**
+ * Helper to ensure that the chain for advertising refs is the same in tests and production code.
+ */
+public class ReceiveCommitsAdvertiseRefsHookChain {
+
+  /**
+   * Returns a single {@link AdvertiseRefsHook} that encompasses a chain of {@link
+   * AdvertiseRefsHook} to be used for advertising when processing a Git push.
+   */
+  public static AdvertiseRefsHook create(
+      AllRefsWatcher allRefsWatcher,
+      PermissionBackend.ForProject perm,
+      Provider<InternalChangeQuery> queryProvider,
+      Project.NameKey projectName) {
+    return create(allRefsWatcher, perm, queryProvider, projectName, false);
+  }
+
+  /**
+   * Returns a single {@link AdvertiseRefsHook} that encompasses a chain of {@link
+   * AdvertiseRefsHook} to be used for advertising when processing a Git push. Omits {@link
+   * HackPushNegotiateHook} as that does not advertise refs on it's own but adds {@code .have} based
+   * on history which is not relevant for the tests we have.
+   */
+  @VisibleForTesting
+  public static AdvertiseRefsHook createForTest(
+      PermissionBackend.ForProject perm,
+      Provider<InternalChangeQuery> queryProvider,
+      Project.NameKey projectName) {
+    return create(new AllRefsWatcher(), perm, queryProvider, projectName, true);
+  }
+
+  private static AdvertiseRefsHook create(
+      AllRefsWatcher allRefsWatcher,
+      PermissionBackend.ForProject perm,
+      Provider<InternalChangeQuery> queryProvider,
+      Project.NameKey projectName,
+      boolean skipHackPushNegotiateHook) {
+    List<AdvertiseRefsHook> advHooks = new ArrayList<>();
+    advHooks.add(allRefsWatcher);
+    advHooks.add(
+        new DefaultAdvertiseRefsHook(perm, RefFilterOptions.builder().setFilterMeta(true).build()));
+    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
+    if (!skipHackPushNegotiateHook) {
+      advHooks.add(new HackPushNegotiateHook());
+    }
+    return AdvertiseRefsHookChain.newChain(advHooks);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
index b71f01e..03a1b33 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
@@ -24,8 +24,7 @@
       "only change owner or project owner can modify Work-in-Progress";
 
   static final String COMMAND_REJECTION_MESSAGE_FOOTER =
-      "Please read the documentation and contact an administrator\n"
-          + "if you feel the configuration is incorrect";
+      "Contact an administrator to fix the permissions";
 
   static final String SAME_CHANGE_ID_IN_MULTIPLE_CHANGES =
       "same Change-Id in multiple changes.\n"
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 36c5005..a9a49bf 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.server.git.receive;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
@@ -22,29 +25,37 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
@@ -63,7 +74,6 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.util.Providers;
@@ -75,6 +85,8 @@
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -87,8 +99,9 @@
   public interface Factory {
     ReplaceOp create(
         ProjectState projectState,
-        Branch.NameKey dest,
+        BranchNameKey dest,
         boolean checkMergedInto,
+        @Nullable String mergeResultRevId,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
         @Assisted("priorCommitId") ObjectId priorCommit,
         @Assisted("patchSetId") PatchSet.Id patchSetId,
@@ -102,7 +115,6 @@
   private static final String CHANGE_IS_CLOSED = "change is closed";
 
   private final AccountResolver accountResolver;
-  private final ApprovalCopier approvalCopier;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
@@ -117,10 +129,12 @@
   private final PatchSetUtil psUtil;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
+  private final ReviewerAdder reviewerAdder;
 
   private final ProjectState projectState;
-  private final Branch.NameKey dest;
+  private final BranchNameKey dest;
   private final boolean checkMergedInto;
+  private final String mergeResultRevId;
   private final PatchSet.Id priorPatchSetId;
   private final ObjectId priorCommitId;
   private final PatchSet.Id patchSetId;
@@ -128,10 +142,9 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
-  private List<String> groups = ImmutableList.of();
+  private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
-  private final MailRecipients recipients = new MailRecipients();
   private RevCommit commit;
   private ReceiveCommand cmd;
   private ChangeNotes notes;
@@ -142,11 +155,12 @@
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
   private RequestScopePropagator requestScopePropagator;
+  private ReviewerAdditionList reviewerAdditions;
+  private MailRecipients oldRecipients;
 
   @Inject
   ReplaceOp(
       AccountResolver accountResolver,
-      ApprovalCopier approvalCopier,
       ApprovalsUtil approvalsUtil,
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
@@ -161,9 +175,11 @@
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
+      ReviewerAdder reviewerAdder,
       @Assisted ProjectState projectState,
-      @Assisted Branch.NameKey dest,
+      @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
+      @Assisted @Nullable String mergeResultRevId,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
       @Assisted("priorCommitId") ObjectId priorCommitId,
       @Assisted("patchSetId") PatchSet.Id patchSetId,
@@ -173,7 +189,6 @@
       @Assisted @Nullable MagicBranchInput magicBranch,
       @Assisted @Nullable PushCertificate pushCertificate) {
     this.accountResolver = accountResolver;
-    this.approvalCopier = approvalCopier;
     this.approvalsUtil = approvalsUtil;
     this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
@@ -188,10 +203,12 @@
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
     this.sendEmailExecutor = sendEmailExecutor;
+    this.reviewerAdder = reviewerAdder;
 
     this.projectState = projectState;
     this.dest = dest;
     this.checkMergedInto = checkMergedInto;
+    this.mergeResultRevId = mergeResultRevId;
     this.priorPatchSetId = priorPatchSetId;
     this.priorCommitId = priorCommitId.copy();
     this.patchSetId = patchSetId;
@@ -215,10 +232,11 @@
             commitId);
 
     if (checkMergedInto) {
-      String mergedInto = findMergedInto(ctx, dest.get(), commit);
+      String mergedInto = findMergedInto(ctx, dest.branch(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
-            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto);
+            mergedByPushOpFactory.create(
+                requestScopePropagator, patchSetId, mergedInto, mergeResultRevId);
       }
     }
 
@@ -228,25 +246,27 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException, PermissionBackendException {
+      throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException {
     notes = ctx.getNotes();
     Change change = notes.getChange();
-    if (change == null || change.getStatus().isClosed()) {
+    if (change == null || change.isClosed()) {
       rejectMessage = CHANGE_IS_CLOSED;
       return false;
     }
     if (groups.isEmpty()) {
-      PatchSet prevPs = psUtil.current(ctx.getDb(), notes);
-      groups = prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of();
+      PatchSet prevPs = psUtil.current(notes);
+      groups = prevPs != null ? prevPs.groups() : ImmutableList.of();
     }
 
+    ChangeData cd = changeDataFactory.create(ctx.getNotes());
+    oldRecipients = getRecipientsFromReviewers(cd.reviewers());
+
     ChangeUpdate update = ctx.getUpdate(patchSetId);
     update.setSubjectForCommit("Create patch set " + patchSetId.get());
 
     String reviewMessage = null;
     String psDescription = null;
     if (magicBranch != null) {
-      recipients.add(magicBranch.getMailRecipients());
       reviewMessage = magicBranch.message;
       psDescription = magicBranch.message;
       approvals.putAll(magicBranch.labels);
@@ -275,7 +295,7 @@
       }
       if (shouldPublishComments()) {
         boolean workInProgress = change.isWorkInProgress();
-        if (magicBranch != null && magicBranch.workInProgress) {
+        if (magicBranch.workInProgress) {
           workInProgress = true;
         }
         comments = publishComments(ctx, workInProgress);
@@ -284,7 +304,6 @@
 
     newPatchSet =
         psUtil.insert(
-            ctx.getDb(),
             ctx.getRevWalk(),
             update,
             patchSetId,
@@ -294,35 +313,21 @@
             psDescription);
 
     update.setPsDescription(psDescription);
-    recipients.add(getRecipientsFromFooters(accountResolver, commit.getFooterLines()));
-    recipients.remove(ctx.getAccountId());
-    ChangeData cd = changeDataFactory.create(ctx.getDb(), ctx.getNotes());
-    MailRecipients oldRecipients = getRecipientsFromReviewers(cd.reviewers());
-    Iterable<PatchSetApproval> newApprovals =
-        approvalsUtil.addApprovalsForNewPatchSet(
-            ctx.getDb(),
-            update,
-            projectState.getLabelTypes(),
-            newPatchSet,
+    MailRecipients fromFooters = getRecipientsFromFooters(accountResolver, commit.getFooterLines());
+    approvalsUtil.addApprovalsForNewPatchSet(
+        update, projectState.getLabelTypes(), newPatchSet, ctx.getUser(), approvals);
+
+    reviewerAdditions =
+        reviewerAdder.prepare(
+            ctx.getNotes(),
             ctx.getUser(),
-            approvals);
-    approvalCopier.copyInReviewDb(
-        ctx.getDb(),
-        ctx.getNotes(),
-        ctx.getUser(),
-        newPatchSet,
-        ctx.getRevWalk(),
-        ctx.getRepoView().getConfig(),
-        newApprovals);
-    approvalsUtil.addReviewers(
-        ctx.getDb(),
-        update,
-        projectState.getLabelTypes(),
-        change,
-        newPatchSet,
-        info,
-        recipients.getReviewers(),
-        oldRecipients.getAll());
+            getReviewerInputs(magicBranch, fromFooters, ctx.getChange(), info),
+            true);
+    Optional<ReviewerAddition> reviewerError = reviewerAdditions.getFailures().stream().findFirst();
+    if (reviewerError.isPresent()) {
+      throw new UnprocessableEntityException(reviewerError.get().result.error);
+    }
+    reviewerAdditions.updateChange(ctx, newPatchSet);
 
     // Check if approvals are changing in with this update. If so, add current user to reviewers.
     // Note that this is done separately as addReviewers is filtering out the change owner as
@@ -331,10 +336,8 @@
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    recipients.add(oldRecipients);
-
     msg = createChangeMessage(ctx, reviewMessage);
-    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
+    cmUtil.addChangeMessage(update, msg);
 
     if (mergedByPushOp == null) {
       resetChange(ctx);
@@ -345,8 +348,49 @@
     return true;
   }
 
+  private static ImmutableList<AddReviewerInput> getReviewerInputs(
+      @Nullable MagicBranchInput magicBranch,
+      MailRecipients fromFooters,
+      Change change,
+      PatchSetInfo psInfo) {
+    // Disable individual emails when adding reviewers, as all reviewers will receive the single
+    // bulk new change email.
+    Stream<AddReviewerInput> inputs =
+        Streams.concat(
+            Streams.stream(
+                newAddReviewerInputFromCommitIdentity(
+                    change, psInfo.getAuthor().getAccount(), NotifyHandling.NONE)),
+            Streams.stream(
+                newAddReviewerInputFromCommitIdentity(
+                    change, psInfo.getCommitter().getAccount(), NotifyHandling.NONE)));
+    if (magicBranch != null) {
+      inputs =
+          Streams.concat(
+              inputs,
+              magicBranch.getCombinedReviewers(fromFooters).stream()
+                  .map(r -> newAddReviewerInput(r, ReviewerState.REVIEWER)),
+              magicBranch.getCombinedCcs(fromFooters).stream()
+                  .map(r -> newAddReviewerInput(r, ReviewerState.CC)));
+    }
+    return inputs.collect(toImmutableList());
+  }
+
+  private static InternalAddReviewerInput newAddReviewerInput(
+      String reviewer, ReviewerState state) {
+    // Disable individual emails when adding reviewers, as all reviewers will receive the single
+    // bulk new patch set email.
+    InternalAddReviewerInput input =
+        ReviewerAdder.newAddReviewerInput(reviewer, state, NotifyHandling.NONE);
+
+    // Ignore failures for reasons like the reviewer being inactive or being unable to see the
+    // change. See discussion in ChangeInserter.
+    input.otherFailureBehavior = ReviewerAdder.FailureBehavior.IGNORE;
+
+    return input;
+  }
+
   private ChangeMessage createChangeMessage(ChangeContext ctx, String reviewMessage)
-      throws OrmException, IOException {
+      throws IOException {
     String approvalMessage =
         ApprovalsUtil.renderMessageWithApprovals(
             patchSetId.get(), approvals, scanLabels(ctx, approvals));
@@ -400,15 +444,13 @@
   }
 
   private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, Map<String, Short> approvals)
-      throws OrmException, IOException {
+      throws IOException {
     Map<String, PatchSetApproval> current = new HashMap<>();
     // We optimize here and only retrieve current when approvals provided
     if (!approvals.isEmpty()) {
       for (PatchSetApproval a :
           approvalsUtil.byPatchSetUser(
-              ctx.getDb(),
               ctx.getNotes(),
-              ctx.getUser(),
               priorPatchSetId,
               ctx.getAccountId(),
               ctx.getRevWalk(),
@@ -417,7 +459,7 @@
           continue;
         }
 
-        LabelType lt = projectState.getLabelTypes().byLabel(a.getLabelId());
+        LabelType lt = projectState.getLabelTypes().byLabel(a.labelId());
         if (lt != null) {
           current.put(lt.getName(), a);
         }
@@ -439,17 +481,12 @@
     change.setCurrentPatchSet(info);
 
     List<String> idList = commit.getFooterLines(CHANGE_ID);
-    if (idList.isEmpty()) {
-      change.setKey(new Change.Key("I" + commitId.name()));
-    } else {
-      change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
-    }
+    change.setKey(Change.key(idList.get(idList.size() - 1).trim()));
   }
 
-  private List<Comment> publishComments(ChangeContext ctx, boolean workInProgress)
-      throws OrmException {
+  private List<Comment> publishComments(ChangeContext ctx, boolean workInProgress) {
     List<Comment> comments =
-        commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), ctx.getUser().getAccountId());
+        commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
     publishCommentUtil.publish(
         ctx, patchSetId, comments, ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
     return comments;
@@ -457,6 +494,7 @@
 
   @Override
   public void postUpdate(Context ctx) throws Exception {
+    reviewerAdditions.postUpdate(ctx);
     if (changeKind != ChangeKind.TRIVIAL_REBASE) {
       // TODO(dborowitz): Merge email templates so we only have to send one.
       Runnable e = new ReplaceEmailTask(ctx);
@@ -468,13 +506,11 @@
       }
     }
 
-    NotifyHandling notify = magicBranch != null ? magicBranch.getNotify(notes) : NotifyHandling.ALL;
-
+    NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
     if (shouldPublishComments()) {
       emailCommentsFactory
           .create(
               notify,
-              magicBranch != null ? magicBranch.getAccountsToNotify() : ImmutableListMultimap.of(),
               notes,
               newPatchSet,
               ctx.getUser().asIdentifiedUser(),
@@ -508,19 +544,26 @@
       try {
         ReplacePatchSetSender cm =
             replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
-        cm.setFrom(ctx.getAccount().getAccount().getId());
+        cm.setFrom(ctx.getAccount().getAccount().id());
         cm.setPatchSet(newPatchSet, info);
         cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
-        if (magicBranch != null) {
-          cm.setNotify(magicBranch.getNotify(notes));
-          cm.setAccountsToNotify(magicBranch.getAccountsToNotify());
-        }
-        cm.addReviewers(recipients.getReviewers());
-        cm.addExtraCC(recipients.getCcOnly());
+        cm.setNotify(ctx.getNotify(notes.getChangeId()));
+        cm.addReviewers(
+            Streams.concat(
+                    oldRecipients.getReviewers().stream(),
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
+                        .map(PatchSetApproval::accountId))
+                .collect(toImmutableSet()));
+        cm.addExtraCC(
+            Streams.concat(
+                    oldRecipients.getCcOnly().stream(),
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs).stream())
+                .collect(toImmutableSet()));
+        // TODO(dborowitz): Support byEmail
         cm.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log(
-            "Cannot send email for new patch set %s", newPatchSet.getId());
+            "Cannot send email for new patch set %s", newPatchSet.id());
       }
     }
 
@@ -541,10 +584,7 @@
      * show a transition from an oldValue of 0 to the new value.
      */
     List<LabelType> labels =
-        projectCache
-            .checkedGet(ctx.getProject())
-            .getLabelTypes(notes, ctx.getUser())
-            .getLabelTypes();
+        projectCache.checkedGet(ctx.getProject()).getLabelTypes(notes).getLabelTypes();
     Map<String, Short> allApprovals = new HashMap<>();
     Map<String, Short> oldApprovals = new HashMap<>();
     for (LabelType lt : labels) {
diff --git a/java/com/google/gerrit/server/git/receive/ResultChangeIds.java b/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
new file mode 100644
index 0000000..e326141
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ResultChangeIds.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.server.git.receive;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Change;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Keeps track of the change IDs thus far updated by ReceiveCommit.
+ *
+ * <p>This class is thread-safe.
+ */
+public class ResultChangeIds {
+  public enum Key {
+    CREATED,
+    REPLACED,
+    AUTOCLOSED,
+  }
+
+  private boolean isMagicPush;
+  private final Map<Key, List<Change.Id>> ids;
+
+  ResultChangeIds() {
+    ids = new EnumMap<>(Key.class);
+    for (Key k : Key.values()) {
+      ids.put(k, new ArrayList<>());
+    }
+  }
+
+  /** Record a change ID update as having completed. Thread-safe. */
+  public synchronized void add(Key key, Change.Id id) {
+    ids.get(key).add(id);
+  }
+
+  /** Indicate that the ReceiveCommits call involved a magic branch. */
+  public synchronized void setMagicPush(boolean magic) {
+    isMagicPush = magic;
+  }
+
+  public synchronized boolean isMagicPush() {
+    return isMagicPush;
+  }
+
+  /**
+   * Returns change IDs of the given type for which the BatchUpdate succeeded, or empty list if
+   * there are none. Thread-safe.
+   */
+  public synchronized List<Change.Id> get(Key key) {
+    return ImmutableList.copyOf(ids.get(key));
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/testing/BUILD b/java/com/google/gerrit/server/git/receive/testing/BUILD
new file mode 100644
index 0000000..82cd14b
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/testing/BUILD
@@ -0,0 +1,12 @@
+java_library(
+    name = "testing",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:guava",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
new file mode 100644
index 0000000..c54ab25
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.testing;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.StreamSupport;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.RefAdvertiser;
+
+/** Helper to collect advertised refs and additonal haves and verify them in tests. */
+public class TestRefAdvertiser extends RefAdvertiser {
+
+  @VisibleForTesting
+  @AutoValue
+  public abstract static class Result {
+    public abstract Map<String, Ref> allRefs();
+
+    public abstract Set<ObjectId> additionalHaves();
+
+    public static Result create(Map<String, Ref> allRefs, Set<ObjectId> additionalHaves) {
+      return new AutoValue_TestRefAdvertiser_Result(allRefs, additionalHaves);
+    }
+  }
+
+  private final Map<String, Ref> advertisedRefs;
+  private final Set<ObjectId> additionalHaves;
+  private final Repository repo;
+
+  public TestRefAdvertiser(Repository repo) {
+    advertisedRefs = new HashMap<>();
+    additionalHaves = new HashSet<>();
+    this.repo = repo;
+  }
+
+  @Override
+  protected void writeOne(CharSequence line) throws IOException {
+    List<String> lineParts =
+        StreamSupport.stream(Splitter.on(' ').split(line).spliterator(), false)
+            .map(String::trim)
+            .collect(toImmutableList());
+    if (".have".equals(lineParts.get(1))) {
+      additionalHaves.add(ObjectId.fromString(lineParts.get(0)));
+    } else {
+      ObjectId id = ObjectId.fromString(lineParts.get(0));
+      Ref ref =
+          repo.getRefDatabase().getRefs().stream()
+              .filter(r -> r.getObjectId().equals(id))
+              .findAny()
+              .orElseThrow(
+                  () ->
+                      new RuntimeException(
+                          line.toString() + " does not conform to expected pattern"));
+      advertisedRefs.put(lineParts.get(1), ref);
+    }
+  }
+
+  @Override
+  protected void end() {}
+
+  public Result result() {
+    return Result.create(advertisedRefs, additionalHaves);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index 5462631..cc4a7ec 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(
@@ -77,10 +87,10 @@
       messages.add("cannot deactivate own account");
     }
 
-    String newPreferredEmail = newAccount.get().getPreferredEmail();
+    String newPreferredEmail = newAccount.get().preferredEmail();
     if (newPreferredEmail != null
         && (!oldAccount.isPresent()
-            || !newPreferredEmail.equals(oldAccount.get().getPreferredEmail()))) {
+            || !newPreferredEmail.equals(oldAccount.get().preferredEmail()))) {
       if (!emailValidator.isValid(newPreferredEmail)) {
         messages.add(
             String.format(
@@ -94,19 +104,17 @@
 
   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
-              .getValidationErrors()
-              .stream()
+          accountConfig.getValidationErrors().stream()
               .map(ValidationError::getMessage)
               .collect(toSet()));
     }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidationListener.java b/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
index d9fab05..fbc582b 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
@@ -35,4 +35,14 @@
    */
   List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
       throws CommitValidationException;
+
+  /**
+   * Whether this validator should validate all commits.
+   *
+   * @return {@code true} if this validator should validate all commits, even when the {@code
+   *     skip-validation} push option was specified.
+   */
+  default boolean shouldValidateAllCommits() {
+    return false;
+  }
 }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidationMessage.java b/java/com/google/gerrit/server/git/validators/CommitValidationMessage.java
index a778482..941b66a 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidationMessage.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidationMessage.java
@@ -15,6 +15,10 @@
 package com.google.gerrit.server.git.validators;
 
 public class CommitValidationMessage extends ValidationMessage {
+  public CommitValidationMessage(String message, ValidationMessage.Type type) {
+    super(message, type);
+  }
+
   public CommitValidationMessage(String message, boolean isError) {
     super(message, isError);
   }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 932d1f8..a9a1a5d 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -14,45 +14,40 @@
 
 package com.google.gerrit.server.git.validators;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Branch.NameKey;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.UrlFormatter;
 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.git.validators.ValidationMessage.Type;
 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.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -67,6 +62,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -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();
 
@@ -88,8 +87,8 @@
   @Singleton
   public static class Factory {
     private final PersonIdent gerritIdent;
-    private final String canonicalWebUrl;
-    private final DynamicSet<CommitValidationListener> pluginValidators;
+    private final DynamicItem<UrlFormatter> urlFormatter;
+    private final PluginSetContext<CommitValidationListener> pluginValidators;
     private final GitRepositoryManager repoManager;
     private final AllUsersName allUsers;
     private final AllProjectsName allProjects;
@@ -97,21 +96,23 @@
     private final AccountValidator accountValidator;
     private final String installCommitMsgHookCommand;
     private final ProjectCache projectCache;
+    private final ProjectConfig.Factory projectConfigFactory;
 
     @Inject
     Factory(
         @GerritPersonIdent PersonIdent gerritIdent,
-        @CanonicalWebUrl @Nullable String canonicalWebUrl,
+        DynamicItem<UrlFormatter> urlFormatter,
         @GerritServerConfig Config cfg,
-        DynamicSet<CommitValidationListener> pluginValidators,
+        PluginSetContext<CommitValidationListener> pluginValidators,
         GitRepositoryManager repoManager,
         AllUsersName allUsers,
         AllProjectsName allProjects,
         ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
         AccountValidator accountValidator,
-        ProjectCache projectCache) {
+        ProjectCache projectCache,
+        ProjectConfig.Factory projectConfigFactory) {
       this.gerritIdent = gerritIdent;
-      this.canonicalWebUrl = canonicalWebUrl;
+      this.urlFormatter = urlFormatter;
       this.pluginValidators = pluginValidators;
       this.repoManager = repoManager;
       this.allUsers = allUsers;
@@ -121,66 +122,69 @@
       this.installCommitMsgHookCommand =
           cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
       this.projectCache = projectCache;
+      this.projectConfigFactory = projectConfigFactory;
     }
 
     public CommitValidators forReceiveCommits(
-        PermissionBackend.ForRef perm,
-        Branch.NameKey branch,
+        PermissionBackend.ForProject forProject,
+        BranchNameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
-        Repository repo,
+        NoteMap rejectCommits,
         RevWalk rw,
-        @Nullable Change change)
+        @Nullable Change change,
+        boolean skipValidation)
         throws IOException {
-      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
-      ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
+      PermissionBackend.ForRef perm = forProject.ref(branch.branch());
+      ProjectState projectState = projectCache.checkedGet(branch.project());
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(perm),
               new ProjectStateValidationListener(projectState),
               new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
-              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
-              new CommitterUploaderValidator(user, perm, canonicalWebUrl),
+              new AuthorUploaderValidator(user, perm, urlFormatter.get()),
+              new CommitterUploaderValidator(user, perm, urlFormatter.get()),
               new SignedOffByValidator(user, perm, projectState),
               new ChangeIdValidator(
                   projectState,
                   user,
-                  canonicalWebUrl,
+                  urlFormatter.get(),
                   installCommitMsgHookCommand,
                   sshInfo,
                   change),
-              new ConfigValidator(branch, user, rw, allUsers, allProjects),
+              new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects),
               new BannedCommitsValidator(rejectCommits),
-              new PluginCommitValidationListener(pluginValidators),
+              new PluginCommitValidationListener(pluginValidators, skipValidation),
               new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
               new AccountCommitValidator(repoManager, allUsers, accountValidator),
               new GroupCommitValidator(allUsers)));
     }
 
     public CommitValidators forGerritCommits(
-        ForRef perm,
-        NameKey branch,
+        PermissionBackend.ForProject forProject,
+        BranchNameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
-      ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
+      PermissionBackend.ForRef perm = forProject.ref(branch.branch());
+      ProjectState projectState = projectCache.checkedGet(branch.project());
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(perm),
               new ProjectStateValidationListener(projectState),
               new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
-              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
-              new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.getParentKey())),
+              new AuthorUploaderValidator(user, perm, urlFormatter.get()),
+              new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.project())),
               new ChangeIdValidator(
                   projectState,
                   user,
-                  canonicalWebUrl,
+                  urlFormatter.get(),
                   installCommitMsgHookCommand,
                   sshInfo,
                   change),
-              new ConfigValidator(branch, user, rw, allUsers, allProjects),
+              new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects),
               new PluginCommitValidationListener(pluginValidators),
               new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
               new AccountCommitValidator(repoManager, allUsers, accountValidator),
@@ -188,7 +192,7 @@
     }
 
     public CommitValidators forMergedCommits(
-        Project.NameKey project, PermissionBackend.ForRef perm, IdentifiedUser user)
+        PermissionBackend.ForProject forProject, BranchNameKey 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,12 +207,13 @@
       //    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.branch());
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(perm),
-              new ProjectStateValidationListener(projectCache.checkedGet(project)),
-              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
-              new CommitterUploaderValidator(user, perm, canonicalWebUrl)));
+              new ProjectStateValidationListener(projectCache.checkedGet(branch.project())),
+              new AuthorUploaderValidator(user, perm, urlFormatter.get()),
+              new CommitterUploaderValidator(user, perm, urlFormatter.get())));
     }
   }
 
@@ -237,31 +242,23 @@
 
   public static class ChangeIdValidator implements CommitValidationListener {
     private static final String CHANGE_ID_PREFIX = FooterConstants.CHANGE_ID.getName() + ":";
-    private static final String MISSING_CHANGE_ID_MSG =
-        "[%s] missing " + FooterConstants.CHANGE_ID.getName() + " in commit message footer";
+    private static final String MISSING_CHANGE_ID_MSG = "missing Change-Id in message footer";
     private static final String MISSING_SUBJECT_MSG =
-        "[%s] missing subject; "
-            + FooterConstants.CHANGE_ID.getName()
-            + " must be in commit message footer";
+        "missing subject; Change-Id must be in message footer";
+    private static final String CHANGE_ID_ABOVE_FOOTER_MSG = "Change-Id must be in message footer";
     private static final String MULTIPLE_CHANGE_ID_MSG =
-        "[%s] multiple " + FooterConstants.CHANGE_ID.getName() + " lines in commit message footer";
+        "multiple Change-Id lines in message footer";
     private static final String INVALID_CHANGE_ID_MSG =
-        "[%s] invalid "
-            + FooterConstants.CHANGE_ID.getName()
-            + " line format in commit message footer";
+        "invalid Change-Id line format in message footer";
 
     @VisibleForTesting
     public static final String CHANGE_ID_MISMATCH_MSG =
-        "[%s] "
-            + FooterConstants.CHANGE_ID.getName()
-            + " in commit message footer does not match"
-            + FooterConstants.CHANGE_ID.getName()
-            + " of target change";
+        "Change-Id in message footer does not match Change-Id of target change";
 
     private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
 
     private final ProjectState projectState;
-    private final String canonicalWebUrl;
+    private final UrlFormatter urlFormatter;
     private final String installCommitMsgHookCommand;
     private final SshInfo sshInfo;
     private final IdentifiedUser user;
@@ -270,12 +267,12 @@
     public ChangeIdValidator(
         ProjectState projectState,
         IdentifiedUser user,
-        String canonicalWebUrl,
+        UrlFormatter urlFormatter,
         String installCommitMsgHookCommand,
         SshInfo sshInfo,
         Change change) {
       this.projectState = projectState;
-      this.canonicalWebUrl = canonicalWebUrl;
+      this.urlFormatter = urlFormatter;
       this.installCommitMsgHookCommand = installCommitMsgHookCommand;
       this.sshInfo = sshInfo;
       this.user = user;
@@ -291,35 +288,41 @@
       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 (commit.getFullMessage().contains("\n" + CHANGE_ID_PREFIX)) {
+          messages.add(
+              new CommitValidationMessage(
+                  CHANGE_ID_ABOVE_FOOTER_MSG
+                      + "\n"
+                      + "\n"
+                      + "Hint: run\n"
+                      + "  git commit --amend\n"
+                      + "and move 'Change-Id: Ixxx..' to the bottom on a separate line\n",
+                  Type.ERROR));
+          throw new CommitValidationException(CHANGE_ID_ABOVE_FOOTER_MSG, messages);
         }
         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));
+          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));
+          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);
         }
       }
 
@@ -331,32 +334,17 @@
           || NEW_PATCHSET_PATTERN.matcher(event.command.getRefName()).matches();
     }
 
-    private CommitValidationMessage getMissingChangeIdErrorMsg(String errMsg, RevCommit c) {
-      StringBuilder sb = new StringBuilder();
-      sb.append("ERROR: ").append(errMsg);
-
-      if (c.getFullMessage().contains(CHANGE_ID_PREFIX)) {
-        String lastLine = Iterables.getLast(Splitter.on('\n').split(c.getFullMessage()), "");
-        if (!lastLine.contains(CHANGE_ID_PREFIX)) {
-          sb.append('\n');
-          sb.append('\n');
-          sb.append("Hint: A potential ");
-          sb.append(FooterConstants.CHANGE_ID.getName());
-          sb.append("Change-Id was found, but it was not in the ");
-          sb.append("footer (last paragraph) of the commit message.");
-        }
-      }
-      sb.append('\n');
-      sb.append('\n');
-      sb.append("Hint: To automatically insert ");
-      sb.append(FooterConstants.CHANGE_ID.getName());
-      sb.append(", install the hook:\n");
-      sb.append(getCommitMessageHookInstallationHint());
-      sb.append('\n');
-      sb.append("And then amend the commit:\n");
-      sb.append("  git commit --amend\n");
-
-      return new CommitValidationMessage(sb.toString(), false);
+    private CommitValidationMessage getMissingChangeIdErrorMsg(String errMsg) {
+      return new CommitValidationMessage(
+          errMsg
+              + "\n"
+              + "\nHint: to automatically insert a Change-Id, install the hook:\n"
+              + getCommitMessageHookInstallationHint()
+              + "\n"
+              + "and then amend the commit:\n"
+              + "  git commit --amend --no-edit\n"
+              + "Finally, push your changes again\n",
+          Type.ERROR);
     }
 
     private String getCommitMessageHookInstallationHint() {
@@ -367,11 +355,12 @@
 
       // If there are no SSH keys, the commit-msg hook must be installed via
       // HTTP(S)
+      Optional<String> webUrl = urlFormatter.getWebUrl();
       if (hostKeys.isEmpty()) {
-        String p = "${gitdir}/hooks/commit-msg";
+        checkState(webUrl.isPresent());
         return String.format(
-            "  gitdir=$(git rev-parse --git-dir); curl -o %s %stools/hooks/commit-msg ; chmod +x %s",
-            p, canonicalWebUrl, p);
+            "  f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
+            webUrl.get());
       }
 
       // SSH keys exist, so the hook can be installed with scp.
@@ -381,7 +370,8 @@
       int c = host.lastIndexOf(':');
       if (0 <= c) {
         if (host.startsWith("*:")) {
-          sshHost = getGerritHost(canonicalWebUrl);
+          checkState(webUrl.isPresent());
+          sshHost = getGerritHost(webUrl.get());
         } else {
           sshHost = host.substring(0, c);
         }
@@ -399,18 +389,21 @@
 
   /** If this is the special project configuration branch, validate the config. */
   public static class ConfigValidator implements CommitValidationListener {
-    private final Branch.NameKey branch;
+    private final ProjectConfig.Factory projectConfigFactory;
+    private final BranchNameKey branch;
     private final IdentifiedUser user;
     private final RevWalk rw;
     private final AllUsersName allUsers;
     private final AllProjectsName allProjects;
 
     public ConfigValidator(
-        Branch.NameKey branch,
+        ProjectConfig.Factory projectConfigFactory,
+        BranchNameKey branch,
         IdentifiedUser user,
         RevWalk rw,
         AllUsersName allUsers,
         AllProjectsName allProjects) {
+      this.projectConfigFactory = projectConfigFactory;
       this.branch = branch;
       this.user = user;
       this.rw = rw;
@@ -421,11 +414,11 @@
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
-      if (REFS_CONFIG.equals(branch.get())) {
+      if (REFS_CONFIG.equals(branch.branch())) {
         List<CommitValidationMessage> messages = new ArrayList<>();
 
         try {
-          ProjectConfig cfg = new ProjectConfig(receiveEvent.project.getNameKey());
+          ProjectConfig cfg = projectConfigFactory.create(receiveEvent.project.getNameKey());
           cfg.load(rw, receiveEvent.command.getNewId());
           if (!cfg.getValidationErrors().isEmpty()) {
             addError("Invalid project configuration:", messages);
@@ -484,28 +477,50 @@
 
   /** Execute commit validation plug-ins */
   public static class PluginCommitValidationListener implements CommitValidationListener {
-    private final DynamicSet<CommitValidationListener> commitValidationListeners;
+    private boolean skipValidation;
+    private final PluginSetContext<CommitValidationListener> commitValidationListeners;
 
     public PluginCommitValidationListener(
-        final DynamicSet<CommitValidationListener> commitValidationListeners) {
+        final PluginSetContext<CommitValidationListener> commitValidationListeners) {
+      this(commitValidationListeners, false);
+    }
+
+    public PluginCommitValidationListener(
+        final PluginSetContext<CommitValidationListener> commitValidationListeners,
+        boolean skipValidation) {
+      this.skipValidation = skipValidation;
       this.commitValidationListeners = commitValidationListeners;
     }
 
+    private void runValidator(
+        CommitValidationListener validator,
+        List<CommitValidationMessage> messages,
+        CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (skipValidation && !validator.shouldValidateAllCommits()) {
+        return;
+      }
+      messages.addAll(validator.onCommitReceived(receiveEvent));
+    }
+
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
       List<CommitValidationMessage> messages = new ArrayList<>();
-
-      for (CommitValidationListener validator : commitValidationListeners) {
-        try {
-          messages.addAll(validator.onCommitReceived(receiveEvent));
-        } catch (CommitValidationException e) {
-          messages.addAll(e.getMessages());
-          throw new CommitValidationException(e.getMessage(), messages);
-        }
+      try {
+        commitValidationListeners.runEach(
+            l -> runValidator(l, messages, receiveEvent), CommitValidationException.class);
+      } catch (CommitValidationException e) {
+        messages.addAll(e.getMessages());
+        throw new CommitValidationException(e.getMessage(), messages);
       }
       return messages;
     }
+
+    @Override
+    public boolean shouldValidateAllCommits() {
+      return commitValidationListeners.stream().anyMatch(v -> v.shouldValidateAllCommits());
+    }
   }
 
   public static class SignedOffByValidator implements CommitValidationListener {
@@ -549,7 +564,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");
@@ -563,13 +578,13 @@
   public static class AuthorUploaderValidator implements CommitValidationListener {
     private final IdentifiedUser user;
     private final PermissionBackend.ForRef perm;
-    private final String canonicalWebUrl;
+    private final UrlFormatter urlFormatter;
 
     public AuthorUploaderValidator(
-        IdentifiedUser user, PermissionBackend.ForRef perm, String canonicalWebUrl) {
+        IdentifiedUser user, PermissionBackend.ForRef perm, UrlFormatter urlFormatter) {
       this.user = user;
       this.perm = perm;
-      this.canonicalWebUrl = canonicalWebUrl;
+      this.urlFormatter = urlFormatter;
     }
 
     @Override
@@ -584,8 +599,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, urlFormatter));
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_AUTHOR");
         throw new CommitValidationException("internal auth error");
@@ -597,13 +611,13 @@
   public static class CommitterUploaderValidator implements CommitValidationListener {
     private final IdentifiedUser user;
     private final PermissionBackend.ForRef perm;
-    private final String canonicalWebUrl;
+    private final UrlFormatter urlFormatter;
 
     public CommitterUploaderValidator(
-        IdentifiedUser user, PermissionBackend.ForRef perm, String canonicalWebUrl) {
+        IdentifiedUser user, PermissionBackend.ForRef perm, UrlFormatter urlFormatter) {
       this.user = user;
       this.perm = perm;
-      this.canonicalWebUrl = canonicalWebUrl;
+      this.urlFormatter = urlFormatter;
     }
 
     @Override
@@ -618,8 +632,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, urlFormatter));
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
         throw new CommitValidationException("internal auth error");
@@ -713,12 +726,14 @@
           List<ConsistencyProblemInfo> problems =
               externalIdsConsistencyChecker.check(receiveEvent.commit);
           List<CommitValidationMessage> msgs =
-              problems
-                  .stream()
+              problems.stream()
                   .map(
                       p ->
                           new CommitValidationMessage(
-                              p.message, p.status == ConsistencyProblemInfo.Status.ERROR))
+                              p.message,
+                              p.status == ConsistencyProblemInfo.Status.ERROR
+                                  ? ValidationMessage.Type.ERROR
+                                  : ValidationMessage.Type.OTHER))
                   .collect(toList());
           if (msgs.stream().anyMatch(ValidationMessage::isError)) {
             throw new CommitValidationException("invalid external IDs", msgs);
@@ -777,9 +792,8 @@
         if (!errorMessages.isEmpty()) {
           throw new CommitValidationException(
               "invalid account configuration",
-              errorMessages
-                  .stream()
-                  .map(m -> new CommitValidationMessage(m, true))
+              errorMessages.stream()
+                  .map(m -> new CommitValidationMessage(m, Type.ERROR))
                   .collect(toList()));
         }
       } catch (IOException e) {
@@ -839,42 +853,30 @@
   }
 
   private static CommitValidationMessage invalidEmail(
-      RevCommit c,
-      String type,
-      PersonIdent who,
-      IdentifiedUser currentUser,
-      String canonicalWebUrl) {
+      String type, PersonIdent who, IdentifiedUser currentUser, UrlFormatter urlFormatter) {
     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");
+
+    if (urlFormatter.getSettingsUrl("").isPresent()) {
+      sb.append("To register an email address, visit:\n")
+          .append(urlFormatter.getSettingsUrl("EmailAddresses").get())
+          .append("\n\n");
     }
-    sb.append("\n");
-    return new CommitValidationMessage(sb.toString(), false);
+    return new CommitValidationMessage(sb.toString(), Type.ERROR);
   }
 
   /**
@@ -895,6 +897,6 @@
   }
 
   private static void addError(String error, List<CommitValidationMessage> messages) {
-    messages.add(new CommitValidationMessage(error, true));
+    messages.add(new CommitValidationMessage(error, Type.ERROR));
   }
 }
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index 6edd04e..ccb67d4 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git.validators;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -44,7 +44,7 @@
       Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       PatchSet.Id patchSetId,
       IdentifiedUser caller)
       throws MergeValidationException;
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 94d9996..08950b7 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -17,44 +17,45 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 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.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import 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;
 
 public class MergeValidators {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<MergeValidationListener> mergeValidationListeners;
+  private final PluginSetContext<MergeValidationListener> mergeValidationListeners;
   private final ProjectConfigValidator.Factory projectConfigValidatorFactory;
   private final AccountMergeValidator.Factory accountValidatorFactory;
   private final GroupMergeValidator.Factory groupValidatorFactory;
@@ -65,7 +66,7 @@
 
   @Inject
   MergeValidators(
-      DynamicSet<MergeValidationListener> mergeValidationListeners,
+      PluginSetContext<MergeValidationListener> mergeValidationListeners,
       ProjectConfigValidator.Factory projectConfigValidatorFactory,
       AccountMergeValidator.Factory accountValidatorFactory,
       GroupMergeValidator.Factory groupValidatorFactory) {
@@ -79,7 +80,7 @@
       Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       PatchSet.Id patchSetId,
       IdentifiedUser caller)
       throws MergeValidationException {
@@ -114,12 +115,18 @@
         "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 ProjectConfig.Factory projectConfigFactory;
+    private final boolean allowProjectOwnersToChangeParent;
 
     public interface Factory {
       ProjectConfigValidator create();
@@ -131,12 +138,17 @@
         AllUsersName allUsersName,
         ProjectCache projectCache,
         PermissionBackend permissionBackend,
-        DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+        DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+        ProjectConfig.Factory projectConfigFactory,
+        @GerritServerConfig Config config) {
       this.allProjectsName = allProjectsName;
       this.allUsersName = allUsersName;
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.pluginConfigEntries = pluginConfigEntries;
+      this.projectConfigFactory = projectConfigFactory;
+      this.allowProjectOwnersToChangeParent =
+          config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
     }
 
     @Override
@@ -144,15 +156,15 @@
         final Repository repo,
         final CodeReviewCommit commit,
         final ProjectState destProject,
-        final Branch.NameKey destBranch,
+        final BranchNameKey destBranch,
         final PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
-      if (RefNames.REFS_CONFIG.equals(destBranch.get())) {
+      if (RefNames.REFS_CONFIG.equals(destBranch.branch())) {
         final Project.NameKey newParent;
         try {
-          ProjectConfig cfg = new ProjectConfig(destProject.getNameKey());
-          cfg.load(repo, commit);
+          ProjectConfig cfg = projectConfigFactory.create(destProject.getNameKey());
+          cfg.load(destProject.getNameKey(), repo, commit);
           newParent = cfg.getProject().getParent(allProjectsName);
           final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
           if (oldParent == null) {
@@ -162,13 +174,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)) {
@@ -182,7 +208,7 @@
             }
           }
 
-          for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+          for (Extension<ProjectConfigEntry> e : pluginConfigEntries) {
             PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
             ProjectConfigEntry configEntry = e.getProvider().get();
 
@@ -213,10 +239,10 @@
 
   /** Execute merge validation plug-ins */
   public static class PluginMergeValidationListener implements MergeValidationListener {
-    private final DynamicSet<MergeValidationListener> mergeValidationListeners;
+    private final PluginSetContext<MergeValidationListener> mergeValidationListeners;
 
     public PluginMergeValidationListener(
-        DynamicSet<MergeValidationListener> mergeValidationListeners) {
+        PluginSetContext<MergeValidationListener> mergeValidationListeners) {
       this.mergeValidationListeners = mergeValidationListeners;
     }
 
@@ -225,13 +251,13 @@
         Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
-        Branch.NameKey destBranch,
+        BranchNameKey destBranch,
         PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
-      for (MergeValidationListener validator : mergeValidationListeners) {
-        validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
-      }
+      mergeValidationListeners.runEach(
+          l -> l.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller),
+          MergeValidationException.class);
     }
   }
 
@@ -240,18 +266,15 @@
       AccountMergeValidator create();
     }
 
-    private final Provider<ReviewDb> dbProvider;
     private final AllUsersName allUsersName;
     private final ChangeData.Factory changeDataFactory;
     private final AccountValidator accountValidator;
 
     @Inject
     public AccountMergeValidator(
-        Provider<ReviewDb> dbProvider,
         AllUsersName allUsersName,
         ChangeData.Factory changeDataFactory,
         AccountValidator accountValidator) {
-      this.dbProvider = dbProvider;
       this.allUsersName = allUsersName;
       this.changeDataFactory = changeDataFactory;
       this.accountValidator = accountValidator;
@@ -262,23 +285,22 @@
         Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
-        Branch.NameKey destBranch,
+        BranchNameKey destBranch,
         PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
-      Account.Id accountId = Account.Id.fromRef(destBranch.get());
+      Account.Id accountId = Account.Id.fromRef(destBranch.branch());
       if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) {
         return;
       }
 
       ChangeData cd =
-          changeDataFactory.create(
-              dbProvider.get(), destProject.getProject().getNameKey(), patchSetId.getParentKey());
+          changeDataFactory.create(destProject.getProject().getNameKey(), patchSetId.changeId());
       try {
         if (!cd.currentFilePaths().contains(AccountProperties.ACCOUNT_CONFIG)) {
           return;
         }
-      } catch (IOException | OrmException e) {
+      } catch (StorageException e) {
         logger.atSevere().withCause(e).log("Cannot validate account update");
         throw new MergeValidationException("account validation unavailable");
       }
@@ -313,13 +335,13 @@
         Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
-        Branch.NameKey destBranch,
+        BranchNameKey destBranch,
         PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
       // Groups are stored inside the 'All-Users' repository.
       if (!allUsersName.equals(destProject.getNameKey())
-          || !RefNames.isGroupRef(destBranch.get())) {
+          || !RefNames.isGroupRef(destBranch.branch())) {
         return;
       }
 
diff --git a/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java b/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
index a626998..308fdc0 100644
--- a/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.git.validators;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
@@ -52,9 +52,9 @@
      * @param commands commands to be executed.
      */
     Arguments(Project.NameKey project, RevWalk rw, ChainedReceiveCommands commands) {
-      this.project = checkNotNull(project);
-      this.rw = checkNotNull(rw);
-      this.refs = checkNotNull(commands);
+      this.project = requireNonNull(project);
+      this.rw = requireNonNull(rw);
+      this.refs = requireNonNull(commands);
       this.commands = ImmutableMap.copyOf(commands.getCommands());
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java b/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
index 3a50a15..409240e 100644
--- a/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.git.validators;
 
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener.Arguments;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.submit.IntegrationException;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gerrit.server.validators.ValidationException;
@@ -29,10 +29,10 @@
     OnSubmitValidators create();
   }
 
-  private final DynamicSet<OnSubmitValidationListener> listeners;
+  private final PluginSetContext<OnSubmitValidationListener> listeners;
 
   @Inject
-  OnSubmitValidators(DynamicSet<OnSubmitValidationListener> listeners) {
+  OnSubmitValidators(PluginSetContext<OnSubmitValidationListener> listeners) {
     this.listeners = listeners;
   }
 
@@ -41,11 +41,9 @@
       throws IntegrationException {
     try (RevWalk rw = new RevWalk(objectReader)) {
       Arguments args = new Arguments(project, rw, commands);
-      for (OnSubmitValidationListener listener : listeners) {
-        listener.preBranchUpdate(args);
-      }
+      listeners.runEach(l -> l.preBranchUpdate(args), ValidationException.class);
     } catch (ValidationException e) {
-      throw new IntegrationException(e.getMessage());
+      throw new IntegrationException(e.getMessage(), e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java b/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
index 9eaf2d2..d27cc38 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
@@ -14,27 +14,28 @@
 
 package com.google.gerrit.server.git.validators;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.validators.ValidationException;
 
 public class RefOperationValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
-  private final Iterable<ValidationMessage> messages;
+  private final ImmutableList<ValidationMessage> messages;
 
-  public RefOperationValidationException(String reason, Iterable<ValidationMessage> messages) {
+  public RefOperationValidationException(String reason, ImmutableList<ValidationMessage> messages) {
     super(reason);
     this.messages = messages;
   }
 
-  public Iterable<ValidationMessage> getMessages() {
+  public ImmutableList<ValidationMessage> getMessages() {
     return messages;
   }
 
   @Override
   public String getMessage() {
-    StringBuilder msg = new StringBuilder(super.getMessage());
-    for (ValidationMessage error : messages) {
-      msg.append("\n").append(error.getMessage());
-    }
-    return msg.toString();
+    return messages.stream()
+        .map(ValidationMessage::getMessage)
+        .collect(joining("\n", super.getMessage() + "\n", ""));
   }
 }
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 1df8da4..919bd5a 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -13,11 +13,10 @@
 // limitations under the License.
 package com.google.gerrit.server.git.validators;
 
-import com.google.common.base.Predicate;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
@@ -28,6 +27,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.plugincontext.PluginSetContext;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -39,8 +39,6 @@
 public class RefOperationValidators {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final GetErrorMessages GET_ERRORS = new GetErrorMessages();
-
   public interface Factory {
     RefOperationValidators create(Project project, IdentifiedUser user, ReceiveCommand cmd);
   }
@@ -52,14 +50,14 @@
 
   private final PermissionBackend.WithUser perm;
   private final AllUsersName allUsersName;
-  private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
+  private final PluginSetContext<RefOperationValidationListener> refOperationValidationListeners;
   private final RefReceivedEvent event;
 
   @Inject
   RefOperationValidators(
       PermissionBackend permissionBackend,
       AllUsersName allUsersName,
-      DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
+      PluginSetContext<RefOperationValidationListener> refOperationValidationListeners,
       @Assisted Project project,
       @Assisted IdentifiedUser user,
       @Assisted ReceiveCommand cmd) {
@@ -75,13 +73,12 @@
   public List<ValidationMessage> validateForRefOperation() throws RefOperationValidationException {
     List<ValidationMessage> messages = new ArrayList<>();
     boolean withException = false;
-    List<RefOperationValidationListener> listeners = new ArrayList<>();
-    listeners.add(new DisallowCreationAndDeletionOfUserBranches(perm, allUsersName));
-    refOperationValidationListeners.forEach(listeners::add);
     try {
-      for (RefOperationValidationListener listener : listeners) {
-        messages.addAll(listener.onRefOperation(event));
-      }
+      messages.addAll(
+          new DisallowCreationAndDeletionOfGerritMaintainedBranches(perm, allUsersName)
+              .onRefOperation(event));
+      refOperationValidationListeners.runEach(
+          l -> l.onRefOperation(event), ValidationException.class);
     } catch (ValidationException e) {
       messages.add(new ValidationMessage(e.getMessage(), true));
       withException = true;
@@ -94,30 +91,23 @@
     return messages;
   }
 
-  private void throwException(Iterable<ValidationMessage> messages, RefReceivedEvent event)
+  private void throwException(List<ValidationMessage> messages, RefReceivedEvent event)
       throws RefOperationValidationException {
-    Iterable<ValidationMessage> errors = Iterables.filter(messages, GET_ERRORS);
     String header =
         String.format(
             "Ref \"%s\" %S in project %s validation failed",
             event.command.getRefName(), event.command.getType(), event.project.getName());
     logger.atSevere().log(header);
-    throw new RefOperationValidationException(header, errors);
+    throw new RefOperationValidationException(
+        header, messages.stream().filter(ValidationMessage::isError).collect(toImmutableList()));
   }
 
-  private static class GetErrorMessages implements Predicate<ValidationMessage> {
-    @Override
-    public boolean apply(ValidationMessage input) {
-      return input.isError();
-    }
-  }
-
-  private static class DisallowCreationAndDeletionOfUserBranches
+  private static class DisallowCreationAndDeletionOfGerritMaintainedBranches
       implements RefOperationValidationListener {
     private final PermissionBackend.WithUser perm;
     private final AllUsersName allUsersName;
 
-    DisallowCreationAndDeletionOfUserBranches(
+    DisallowCreationAndDeletionOfGerritMaintainedBranches(
         PermissionBackend.WithUser perm, AllUsersName allUsersName) {
       this.perm = perm;
       this.allUsersName = allUsersName;
diff --git a/java/com/google/gerrit/server/git/validators/UploadValidators.java b/java/com/google/gerrit/server/git/validators/UploadValidators.java
index 84d4586..2595283 100644
--- a/java/com/google/gerrit/server/git/validators/UploadValidators.java
+++ b/java/com/google/gerrit/server/git/validators/UploadValidators.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.git.validators;
 
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -28,7 +28,7 @@
 
 public class UploadValidators implements PreUploadHook {
 
-  private final DynamicSet<UploadValidationListener> uploadValidationListeners;
+  private final PluginSetContext<UploadValidationListener> uploadValidationListeners;
   private final Project project;
   private final Repository repository;
   private final String remoteHost;
@@ -39,7 +39,7 @@
 
   @Inject
   UploadValidators(
-      DynamicSet<UploadValidationListener> uploadValidationListeners,
+      PluginSetContext<UploadValidationListener> uploadValidationListeners,
       @Assisted Project project,
       @Assisted Repository repository,
       @Assisted String remoteHost) {
@@ -53,12 +53,12 @@
   public void onSendPack(
       UploadPack up, Collection<? extends ObjectId> wants, Collection<? extends ObjectId> haves)
       throws ServiceMayNotContinueException {
-    for (UploadValidationListener validator : uploadValidationListeners) {
-      try {
-        validator.onPreUpload(repository, project, remoteHost, up, wants, haves);
-      } catch (ValidationException e) {
-        throw new UploadValidationException(e.getMessage());
-      }
+    try {
+      uploadValidationListeners.runEach(
+          l -> l.onPreUpload(repository, project, remoteHost, up, wants, haves),
+          ValidationException.class);
+    } catch (ValidationException e) {
+      throw new UploadValidationException(e.getMessage());
     }
   }
 
@@ -66,12 +66,12 @@
   public void onBeginNegotiateRound(
       UploadPack up, Collection<? extends ObjectId> wants, int cntOffered)
       throws ServiceMayNotContinueException {
-    for (UploadValidationListener validator : uploadValidationListeners) {
-      try {
-        validator.onBeginNegotiate(repository, project, remoteHost, up, wants, cntOffered);
-      } catch (ValidationException e) {
-        throw new UploadValidationException(e.getMessage());
-      }
+    try {
+      uploadValidationListeners.runEach(
+          l -> l.onBeginNegotiate(repository, project, remoteHost, up, wants, cntOffered),
+          ValidationException.class);
+    } catch (ValidationException e) {
+      throw new UploadValidationException(e.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/validators/ValidationMessage.java b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
index e1098aa..db59492 100644
--- a/java/com/google/gerrit/server/git/validators/ValidationMessage.java
+++ b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
@@ -15,19 +15,45 @@
 package com.google.gerrit.server.git.validators;
 
 public class ValidationMessage {
+  public enum Type {
+    ERROR("ERROR: "),
+    WARNING("WARNING: "),
+    HINT("hint: "),
+    OTHER("");
+
+    private final String prefix;
+
+    Type(String prefix) {
+      this.prefix = prefix;
+    }
+
+    public String getPrefix() {
+      return prefix;
+    }
+  }
+
   private final String message;
-  private final boolean isError;
+  private final Type type;
+
+  public ValidationMessage(String message, Type type) {
+    this.message = message;
+    this.type = type;
+  }
 
   public ValidationMessage(String message, boolean isError) {
     this.message = message;
-    this.isError = isError;
+    this.type = (isError ? Type.ERROR : Type.OTHER);
   }
 
   public String getMessage() {
     return message;
   }
 
+  public Type getType() {
+    return type;
+  }
+
   public boolean isError() {
-    return isError;
+    return type == Type.ERROR;
   }
 }
diff --git a/java/com/google/gerrit/server/group/GroupAuditService.java b/java/com/google/gerrit/server/group/GroupAuditService.java
new file mode 100644
index 0000000..4c02ada
--- /dev/null
+++ b/java/com/google/gerrit/server/group/GroupAuditService.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.AuditEvent;
+import java.sql.Timestamp;
+
+public interface GroupAuditService {
+  void dispatch(AuditEvent action);
+
+  void dispatchAddMembers(
+      Account.Id actor,
+      AccountGroup.UUID updatedGroup,
+      ImmutableSet<Account.Id> addedMembers,
+      Timestamp addedOn);
+
+  void dispatchDeleteMembers(
+      Account.Id actor,
+      AccountGroup.UUID updatedGroup,
+      ImmutableSet<Account.Id> deletedMembers,
+      Timestamp deletedOn);
+
+  void dispatchAddSubgroups(
+      Account.Id actor,
+      AccountGroup.UUID updatedGroup,
+      ImmutableSet<AccountGroup.UUID> addedSubgroups,
+      Timestamp addedOn);
+
+  void dispatchDeleteSubgroups(
+      Account.Id actor,
+      AccountGroup.UUID updatedGroup,
+      ImmutableSet<AccountGroup.UUID> deletedSubgroups,
+      Timestamp deletedOn);
+}
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
new file mode 100644
index 0000000..fab5b9e
--- /dev/null
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+@Singleton
+public class GroupResolver {
+  private final GroupBackend groupBackend;
+  private final GroupCache groupCache;
+  private final GroupControl.Factory groupControlFactory;
+
+  @Inject
+  GroupResolver(
+      GroupBackend groupBackend, GroupCache groupCache, GroupControl.Factory groupControlFactory) {
+    this.groupBackend = groupBackend;
+    this.groupCache = groupCache;
+    this.groupControlFactory = groupControlFactory;
+  }
+
+  /**
+   * Parses a group ID from a request body and returns the group.
+   *
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
+   * @return the group
+   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved or if the group
+   *     is not visible to the calling user
+   */
+  public GroupDescription.Basic parse(String id) throws UnprocessableEntityException {
+    GroupDescription.Basic group = parseId(id);
+    if (group == null || !groupControlFactory.controlFor(group).isVisible()) {
+      throw new UnprocessableEntityException(String.format("Group Not Found: %s", id));
+    }
+    return group;
+  }
+
+  /**
+   * Parses a group ID from a request body and returns the group if it is a Gerrit internal group.
+   *
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
+   * @return the group
+   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved, if the group is
+   *     not visible to the calling user or if it's an external group
+   */
+  public GroupDescription.Internal parseInternal(String id) throws UnprocessableEntityException {
+    GroupDescription.Basic group = parse(id);
+    if (group instanceof GroupDescription.Internal) {
+      return (GroupDescription.Internal) group;
+    }
+
+    throw new UnprocessableEntityException(String.format("External Group Not Allowed: %s", id));
+  }
+
+  /**
+   * Parses a group ID and returns the group without making any permission check whether the current
+   * user can see the group.
+   *
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
+   * @return the group, null if no group is found for the given group ID
+   */
+  public GroupDescription.Basic parseId(String id) {
+    AccountGroup.UUID uuid = AccountGroup.uuid(id);
+    if (groupBackend.handles(uuid)) {
+      GroupDescription.Basic d = groupBackend.get(uuid);
+      if (d != null) {
+        return d;
+      }
+    }
+
+    // Might be a numeric AccountGroup.Id. -> Internal group.
+    if (id.matches("^[1-9][0-9]*$")) {
+      try {
+        AccountGroup.Id groupId = AccountGroup.Id.parse(id);
+        Optional<InternalGroup> group = groupCache.get(groupId);
+        if (group.isPresent()) {
+          return new InternalGroupDescription(group.get());
+        }
+      } catch (IllegalArgumentException e) {
+        // Ignored
+      }
+    }
+
+    // Might be a group name, be nice and accept unique names.
+    GroupReference ref = GroupBackends.findExactSuggestion(groupBackend, id);
+    if (ref != null) {
+      GroupDescription.Basic d = groupBackend.get(ref.getUUID());
+      if (d != null) {
+        return d;
+      }
+    }
+
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
index 3981b70..1d2252d 100644
--- a/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.group;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
@@ -29,7 +29,7 @@
   private final InternalGroup internalGroup;
 
   public InternalGroupDescription(InternalGroup internalGroup) {
-    this.internalGroup = checkNotNull(internalGroup);
+    this.internalGroup = requireNonNull(internalGroup);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
index dbbc3f6..2a9538d 100644
--- a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
+++ b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
@@ -125,8 +125,7 @@
   public synchronized void run() {
     try (Repository allUsers = repoManager.openRepository(allUsersName)) {
       ImmutableSet<AccountGroup.UUID> newGroupUuids =
-          GroupNameNotes.loadAllGroups(allUsers)
-              .stream()
+          GroupNameNotes.loadAllGroups(allUsers).stream()
               .map(GroupReference::getUUID)
               .collect(toImmutableSet());
       GroupIndexer groupIndexer = groupIndexerProvider.get();
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 6fc5f46..75ce0de 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.group;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -56,19 +56,19 @@
 
   /** Common UUID assigned to the "Anonymous Users" group. */
   public static final AccountGroup.UUID ANONYMOUS_USERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Anonymous-Users");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Anonymous-Users");
 
   /** Common UUID assigned to the "Registered Users" group. */
   public static final AccountGroup.UUID REGISTERED_USERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Registered-Users");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Registered-Users");
 
   /** Common UUID assigned to the "Project Owners" placeholder group. */
   public static final AccountGroup.UUID PROJECT_OWNERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Project-Owners");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Project-Owners");
 
   /** Common UUID assigned to the "Change Owner" placeholder group. */
   public static final AccountGroup.UUID CHANGE_OWNER =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Change-Owner");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Change-Owner");
 
   private static final AccountGroup.UUID[] all = {
     ANONYMOUS_USERS, REGISTERED_USERS, PROJECT_OWNERS, CHANGE_OWNER,
@@ -117,7 +117,7 @@
   }
 
   public GroupReference getGroup(AccountGroup.UUID uuid) {
-    return checkNotNull(uuids.get(uuid), "group %s not found", uuid.get());
+    return requireNonNull(uuids.get(uuid), () -> String.format("group %s not found", uuid.get()));
   }
 
   public Set<String> getNames() {
diff --git a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
index bbbc095..454ce68 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.group.db;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
@@ -72,7 +72,7 @@
   }
 
   private static Optional<Account> getAccount(ImmutableSet<Account> accounts, Account.Id id) {
-    return accounts.stream().filter(account -> account.getId().equals(id)).findAny();
+    return accounts.stream().filter(account -> account.id().equals(id)).findAny();
   }
 
   public static AuditLogFormatter createPartiallyWorkingFallBack() {
@@ -90,16 +90,16 @@
       Function<Account.Id, Optional<Account>> accountRetriever,
       Function<AccountGroup.UUID, Optional<GroupDescription.Basic>> groupRetriever,
       String serverId) {
-    this.accountRetriever = checkNotNull(accountRetriever);
-    this.groupRetriever = checkNotNull(groupRetriever);
-    this.serverId = checkNotNull(serverId);
+    this.accountRetriever = requireNonNull(accountRetriever);
+    this.groupRetriever = requireNonNull(groupRetriever);
+    this.serverId = requireNonNull(serverId);
   }
 
   private AuditLogFormatter(
       Function<Account.Id, Optional<Account>> accountRetriever,
       Function<AccountGroup.UUID, Optional<GroupDescription.Basic>> groupRetriever) {
-    this.accountRetriever = checkNotNull(accountRetriever);
-    this.groupRetriever = checkNotNull(groupRetriever);
+    this.accountRetriever = requireNonNull(accountRetriever);
+    this.groupRetriever = requireNonNull(groupRetriever);
     serverId = null;
   }
 
@@ -119,7 +119,7 @@
    * @return a {@code PersonIdent} which can be used for the author of a commit
    */
   public PersonIdent getParsableAuthorIdent(Account account, PersonIdent personIdent) {
-    return getParsableAuthorIdent(account.getName(), account.getId(), personIdent);
+    return getParsableAuthorIdent(account.getName(), account.id(), personIdent);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index c6d1a6f..fb58577 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.group.db;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
@@ -21,9 +23,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAudit;
 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,90 +52,101 @@
   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
-  // ReviewDb. Once ReviewDb is gone, the audit record interface becomes more flexible and we can
-  // revisit this, e.g. to do only a single walk, or even change the record types.
+  // ReviewDb. Now that ReviewDb is gone, the audit record interface is more flexible and this may
+  // be changed, 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(
       AccountGroup.Id groupId, List<ParsedCommit> commits) {
-    ListMultimap<MemberKey, AccountGroupMemberAudit> audits =
+    ListMultimap<MemberKey, AccountGroupMemberAudit.Builder> audits =
         MultimapBuilder.hashKeys().linkedListValues().build();
-    ImmutableList.Builder<AccountGroupMemberAudit> result = ImmutableList.builder();
+    List<AccountGroupMemberAudit.Builder> result = new ArrayList<>();
     for (ParsedCommit pc : commits) {
       for (Account.Id id : pc.addedMembers()) {
         MemberKey key = MemberKey.create(groupId, id);
-        AccountGroupMemberAudit audit =
-            new AccountGroupMemberAudit(
-                new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
+        AccountGroupMemberAudit.Builder audit =
+            AccountGroupMemberAudit.builder()
+                .memberId(id)
+                .groupId(groupId)
+                .addedOn(pc.when())
+                .addedBy(pc.authorId());
         audits.put(key, audit);
         result.add(audit);
       }
       for (Account.Id id : pc.removedMembers()) {
-        List<AccountGroupMemberAudit> adds = audits.get(MemberKey.create(groupId, id));
+        List<AccountGroupMemberAudit.Builder> adds = audits.get(MemberKey.create(groupId, id));
         if (!adds.isEmpty()) {
-          AccountGroupMemberAudit audit = adds.remove(0);
+          AccountGroupMemberAudit.Builder audit = adds.remove(0);
           audit.removed(pc.authorId(), pc.when());
         } else {
           // Match old behavior of DbGroupAuditListener and add a "legacy" add/remove pair.
-          AccountGroupMemberAudit audit =
-              new AccountGroupMemberAudit(
-                  new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
-          audit.removedLegacy();
+          AccountGroupMemberAudit.Builder audit =
+              AccountGroupMemberAudit.builder()
+                  .groupId(groupId)
+                  .memberId(id)
+                  .addedOn(pc.when())
+                  .addedBy(pc.authorId())
+                  .removedLegacy();
           result.add(audit);
         }
       }
     }
-    return result.build();
+    return result.stream().map(AccountGroupMemberAudit.Builder::build).collect(toImmutableList());
   }
 
-  public ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(
+  public ImmutableList<AccountGroupByIdAudit> getSubgroupsAudit(
       Repository repo, AccountGroup.UUID uuid) throws IOException, ConfigInvalidException {
     return getSubgroupsAudit(getGroupId(repo, uuid), parseCommits(repo, uuid));
   }
 
-  private ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(
+  private ImmutableList<AccountGroupByIdAudit> getSubgroupsAudit(
       AccountGroup.Id groupId, List<ParsedCommit> commits) {
-    ListMultimap<SubgroupKey, AccountGroupByIdAud> audits =
+    ListMultimap<SubgroupKey, AccountGroupByIdAudit.Builder> audits =
         MultimapBuilder.hashKeys().linkedListValues().build();
-    ImmutableList.Builder<AccountGroupByIdAud> result = ImmutableList.builder();
+    List<AccountGroupByIdAudit.Builder> result = new ArrayList<>();
     for (ParsedCommit pc : commits) {
       for (AccountGroup.UUID uuid : pc.addedSubgroups()) {
         SubgroupKey key = SubgroupKey.create(groupId, uuid);
-        AccountGroupByIdAud audit =
-            new AccountGroupByIdAud(
-                new AccountGroupByIdAud.Key(groupId, uuid, pc.when()), pc.authorId());
+        AccountGroupByIdAudit.Builder audit =
+            AccountGroupByIdAudit.builder()
+                .groupId(groupId)
+                .includeUuid(uuid)
+                .addedOn(pc.when())
+                .addedBy(pc.authorId());
         audits.put(key, audit);
         result.add(audit);
       }
       for (AccountGroup.UUID uuid : pc.removedSubgroups()) {
-        List<AccountGroupByIdAud> adds = audits.get(SubgroupKey.create(groupId, uuid));
+        List<AccountGroupByIdAudit.Builder> adds = audits.get(SubgroupKey.create(groupId, uuid));
         if (!adds.isEmpty()) {
-          AccountGroupByIdAud audit = adds.remove(0);
+          AccountGroupByIdAudit.Builder audit = adds.remove(0);
           audit.removed(pc.authorId(), pc.when());
         } else {
           // Unlike members, DbGroupAuditListener didn't insert an add/remove pair here.
         }
       }
     }
-    return result.build();
+    return result.stream().map(AccountGroupByIdAudit.Builder::build).collect(toImmutableList());
   }
 
   private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
     Optional<Account.Id> authorId = NoteDbUtil.parseIdent(c.getAuthorIdent(), serverId);
     if (!authorId.isPresent()) {
-      // Only report audit events from identified users, since this is a non-nullable field in
-      // ReviewDb. May be revisited after groups are fully migrated to NoteDb.
+      // Only report audit events from identified users, since this was a non-nullable field in
+      // ReviewDb. May be revisited.
       return Optional.empty();
     }
 
@@ -179,7 +193,7 @@
       logInvalid(uuid, c, line);
       return Optional.empty();
     }
-    return Optional.of(new AccountGroup.UUID(ident.getEmailAddress()));
+    return Optional.of(AccountGroup.uuid(ident.getEmailAddress()));
   }
 
   private static void logInvalid(AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
@@ -211,10 +225,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..2c9a851 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.group.db;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -24,14 +24,15 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.exceptions.DuplicateKeyException;
 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;
 import com.google.gerrit.server.group.InternalGroup;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Arrays;
@@ -51,17 +52,18 @@
  * 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
+ * <p><strong>Note:</strong> Any modification (group creation or update) only becomes permanent (and
  * hence written to NoteDb) if {@link #commit(MetaDataUpdate)} is called.
  *
- * <p><strong>Warning: </strong>This class is a low-level API for groups in NoteDb. Most code which
+ * <p><strong>Warning:</strong> This class is a low-level API for groups in NoteDb. Most code which
  * deals with internal Gerrit groups should use {@link Groups} or {@link GroupsUpdate} instead.
  *
- * <p><em>Internal details</em>
+ * <h2>Internal details</h2>
  *
  * <p>Each group is represented by a commit on a branch as defined by {@link
  * RefNames#refsGroups(AccountGroup.UUID)}. Previous versions of the group exist as older commits on
@@ -97,9 +99,10 @@
    * {@link #setGroupUpdate(InternalGroupUpdate, AuditLogFormatter)} on the returned {@code
    * GroupConfig}.
    *
-   * <p><strong>Note: </strong>The returned {@code GroupConfig} has to be committed via {@link
+   * <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
@@ -107,13 +110,13 @@
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if a group with the same UUID already exists but can't be read
    *     due to an invalid format
-   * @throws OrmDuplicateKeyException if a group with the same UUID already exists
+   * @throws DuplicateKeyException if a group with the same UUID already exists
    */
   public static GroupConfig createForNewGroup(
-      Repository repository, InternalGroupCreation groupCreation)
-      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+      Project.NameKey projectName, Repository repository, InternalGroupCreation groupCreation)
+      throws IOException, ConfigInvalidException, DuplicateKeyException {
     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;
   }
 
@@ -178,7 +187,7 @@
   private boolean allowSaveEmptyName;
 
   private GroupConfig(AccountGroup.UUID groupUuid) {
-    this.groupUuid = checkNotNull(groupUuid);
+    this.groupUuid = requireNonNull(groupUuid);
     ref = RefNames.refsGroups(groupUuid);
   }
 
@@ -207,7 +216,7 @@
    * <p>If the group is newly created, the {@code InternalGroupUpdate} can be used to specify
    * optional properties.
    *
-   * <p><strong>Note: </strong>This method doesn't perform the update. It only contains the
+   * <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
    * instructions for the update. To apply the update for real and write the result back to NoteDb,
    * call {@link #commit(MetaDataUpdate)} on this {@code GroupConfig}.
    *
@@ -224,7 +233,7 @@
   /**
    * Allows the new name of a group to be empty during creation or update.
    *
-   * <p><strong>Note: </strong>This method exists only to support the migration of legacy groups
+   * <p><strong>Note:</strong> This method exists only to support the migration of legacy groups
    * which don't always necessarily have a name. Nowadays, we enforce that groups always have names.
    * When we remove the migration code, we can probably remove this method as well.
    */
@@ -232,11 +241,10 @@
     this.allowSaveEmptyName = true;
   }
 
-  private void setGroupCreation(InternalGroupCreation groupCreation)
-      throws OrmDuplicateKeyException {
+  private void setGroupCreation(InternalGroupCreation groupCreation) throws DuplicateKeyException {
     checkLoaded();
     if (loadedGroup.isPresent()) {
-      throw new OrmDuplicateKeyException(String.format("Group %s already exists", groupUuid.get()));
+      throw new DuplicateKeyException(String.format("Group %s already exists", groupUuid.get()));
     }
 
     this.groupCreation = Optional.of(groupCreation);
@@ -403,12 +411,12 @@
   }
 
   private ImmutableSet<Account.Id> readMembers() throws IOException, ConfigInvalidException {
-    return readFromFile(MEMBERS_FILE, entry -> new Account.Id(Integer.parseInt(entry)));
+    return readFromFile(MEMBERS_FILE, entry -> Account.id(Integer.parseInt(entry)));
   }
 
   private ImmutableSet<AccountGroup.UUID> readSubgroups()
       throws IOException, ConfigInvalidException {
-    return readFromFile(SUBGROUPS_FILE, AccountGroup.UUID::new);
+    return readFromFile(SUBGROUPS_FILE, AccountGroup::uuid);
   }
 
   private <E> ImmutableSet<E> readFromFile(String filePath, Function<String, E> fromStringFunction)
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java b/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
index 62cc20d..5627154 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
@@ -107,13 +107,11 @@
     Function<T, String> toString = element -> toParsableString.apply(auditLogFormatter, element);
 
     Stream<String> removedElements =
-        Sets.difference(oldElements, newElements)
-            .stream()
+        Sets.difference(oldElements, newElements).stream()
             .map(toString)
             .map((removalFooterKey.getName() + ": ")::concat);
     Stream<String> addedElements =
-        Sets.difference(newElements, oldElements)
-            .stream()
+        Sets.difference(newElements, oldElements).stream()
             .map(toString)
             .map((additionFooterKey.getName() + ": ")::concat);
     return Stream.concat(removedElements, addedElements);
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
index eff3458..d684436 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -25,7 +25,7 @@
  *
  * <p>Each property knows how to read and write its value from/to a JGit {@link Config} file.
  *
- * <p><strong>Warning: </strong>This class is a low-level API for properties of groups in NoteDb. It
+ * <p><strong>Warning:</strong> This class is a low-level API for properties of groups in NoteDb. It
  * may only be used by {@link GroupConfig}. Other classes should use {@link InternalGroupUpdate} to
  * modify the properties of a group.
  */
@@ -45,7 +45,7 @@
             String.format(
                 "ID of the group %s must not be negative, found %d", groupUuid.get(), id));
       }
-      group.setId(new AccountGroup.Id(id));
+      group.setId(AccountGroup.id(id));
     }
 
     @Override
@@ -77,7 +77,7 @@
       // the NoteDb migration converted such groups faithfully, so we need to be able to read them
       // back here.
       name = Strings.nullToEmpty(name);
-      group.setNameKey(new AccountGroup.NameKey(name));
+      group.setNameKey(AccountGroup.nameKey(name));
     }
 
     @Override
@@ -135,7 +135,7 @@
         throw new ConfigInvalidException(
             String.format("Owner UUID of the group %s must be defined", groupUuid.get()));
       }
-      group.setOwnerGroupUUID(new AccountGroup.UUID(ownerGroupUuid));
+      group.setOwnerGroupUUID(AccountGroup.uuid(ownerGroupUuid));
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index 1b74241..ff540a8 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.group.db;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.collect.ImmutableBiMap.toImmutableBiMap;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -25,13 +25,16 @@
 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.exceptions.DuplicateKeyException;
+import com.google.gerrit.git.ObjectIds;
 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;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Map;
@@ -62,12 +65,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 +90,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 +106,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
@@ -109,19 +115,20 @@
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if the note for the specified group doesn't exist or is in an
    *     invalid state
-   * @throws OrmDuplicateKeyException if a group with the new name already exists
+   * @throws DuplicateKeyException if a group with the new name already exists
    */
   public static GroupNameNotes forRename(
+      Project.NameKey projectName,
       Repository repository,
       AccountGroup.UUID groupUuid,
       AccountGroup.NameKey oldName,
       AccountGroup.NameKey newName)
-      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
-    checkNotNull(oldName);
-    checkNotNull(newName);
+      throws IOException, ConfigInvalidException, DuplicateKeyException {
+    requireNonNull(oldName);
+    requireNonNull(newName);
 
     GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, oldName, newName);
-    groupNameNotes.load(repository);
+    groupNameNotes.load(projectName, repository);
     groupNameNotes.ensureNewNameIsNotUsed();
     return groupNameNotes;
   }
@@ -133,21 +140,25 @@
    * 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
    * @return an instance of {@code GroupNameNotes} configured for a specific group creation
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException in no case so far
-   * @throws OrmDuplicateKeyException if a group with the new name already exists
+   * @throws DuplicateKeyException if a group with the new name already exists
    */
   public static GroupNameNotes forNewGroup(
-      Repository repository, AccountGroup.UUID groupUuid, AccountGroup.NameKey groupName)
-      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
-    checkNotNull(groupName);
+      Project.NameKey projectName,
+      Repository repository,
+      AccountGroup.UUID groupUuid,
+      AccountGroup.NameKey groupName)
+      throws IOException, ConfigInvalidException, DuplicateKeyException {
+    requireNonNull(groupName);
 
     GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, null, groupName);
-    groupNameNotes.load(repository);
+    groupNameNotes.load(projectName, repository);
     groupNameNotes.ensureNewNameIsNotUsed();
     return groupNameNotes;
   }
@@ -256,7 +267,7 @@
       RevCommit oldCommit = ref != null ? rw.parseCommit(ref.getObjectId()) : null;
 
       for (Map.Entry<AccountGroup.UUID, String> e : biMap.entrySet()) {
-        AccountGroup.NameKey nameKey = new AccountGroup.NameKey(e.getValue());
+        AccountGroup.NameKey nameKey = AccountGroup.nameKey(e.getValue());
         ObjectId noteKey = getNoteKey(nameKey);
         noteMap.set(noteKey, getAsNoteData(e.getKey(), nameKey), inserter);
       }
@@ -276,7 +287,7 @@
       cb.setMessage("Store " + n + " group name" + (n != 1 ? "s" : ""));
       ObjectId newId = inserter.insert(cb).copy();
 
-      ObjectId oldId = oldCommit != null ? oldCommit.copy() : ObjectId.zeroId();
+      ObjectId oldId = ObjectIds.copyOrZero(oldCommit);
       bru.addCommand(new ReceiveCommand(oldId, newId, RefNames.REFS_GROUPNAMES));
     }
   }
@@ -285,8 +296,7 @@
   private static ImmutableBiMap<AccountGroup.UUID, String> toBiMap(
       Collection<GroupReference> groupReferences) {
     try {
-      return groupReferences
-          .stream()
+      return groupReferences.stream()
           .collect(toImmutableBiMap(GroupReference::getUUID, GroupReference::getName));
     } catch (IllegalArgumentException e) {
       throw new IllegalArgumentException(UNIQUE_REF_ERROR, e);
@@ -303,7 +313,7 @@
       AccountGroup.UUID groupUuid,
       @Nullable AccountGroup.NameKey oldGroupName,
       @Nullable AccountGroup.NameKey newGroupName) {
-    this.groupUuid = checkNotNull(groupUuid);
+    this.groupUuid = requireNonNull(groupUuid);
 
     if (Objects.equals(oldGroupName, newGroupName)) {
       this.oldGroupName = Optional.empty();
@@ -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()) {
@@ -352,9 +364,9 @@
     }
   }
 
-  private void ensureNewNameIsNotUsed() throws OrmDuplicateKeyException {
+  private void ensureNewNameIsNotUsed() throws DuplicateKeyException {
     if (newGroupName.isPresent() && nameConflicting) {
-      throw new OrmDuplicateKeyException(
+      throw new DuplicateKeyException(
           String.format("Name '%s' is already used", newGroupName.get().get()));
     }
   }
@@ -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);
@@ -429,7 +443,7 @@
       throw new ConfigInvalidException(String.format("UUID for group '%s' must be defined", name));
     }
 
-    return new GroupReference(new AccountGroup.UUID(uuid), name);
+    return new GroupReference(AccountGroup.uuid(uuid), name);
   }
 
   private String getCommitMessage() {
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 46fa998..1c8d897 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAudit;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -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,36 +110,37 @@
    */
   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
-        .build()
-        .stream()
+    return allSubgroups.build().stream()
         .filter(groupUuid -> !AccountGroup.isInternalGroup(groupUuid));
   }
 
   /**
    * 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);
   }
 
   /**
@@ -151,7 +152,7 @@
    * @throws IOException if an error occurs while reading from NoteDb
    * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
    */
-  public List<AccountGroupByIdAud> getSubgroupsAudit(Repository repo, AccountGroup.UUID groupUuid)
+  public List<AccountGroupByIdAudit> getSubgroupsAudit(Repository repo, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
     return auditLogReader.getSubgroupsAudit(repo, groupUuid);
   }
diff --git a/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
index 9b86221..3afb793 100644
--- a/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
@@ -26,6 +26,8 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -35,8 +37,6 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
 
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index b5324f1..cea8101 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -28,7 +28,10 @@
 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 com.google.inject.Singleton;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
@@ -37,7 +40,6 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectLoader;
@@ -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,25 +72,29 @@
   }
 
   /** 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<>();
 
     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);
+    // Get group refs and group names ref using the most atomic API available, in an attempt to
+    // avoid seeing half-committed group updates.
+    List<Ref> refs =
+        allUsersRepo
+            .getRefDatabase()
+            .getRefsByPrefix(RefNames.REFS_GROUPS, RefNames.REFS_GROUPNAMES);
+    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,21 +107,21 @@
     return result;
   }
 
-  private void readGroups(Repository repo, Map<String, Ref> refs, Result result)
+  private void readGroups(Repository allUsersRepo, List<Ref> refs, Result result)
       throws IOException {
-    for (Map.Entry<String, Ref> entry : refs.entrySet()) {
-      if (!entry.getKey().startsWith(RefNames.REFS_GROUPS)) {
+    for (Ref ref : refs) {
+      if (!ref.getName().startsWith(RefNames.REFS_GROUPS)) {
         continue;
       }
 
-      AccountGroup.UUID uuid = AccountGroup.UUID.fromRef(entry.getKey());
+      AccountGroup.UUID uuid = AccountGroup.UUID.fromRef(ref.getName());
       if (uuid == null) {
-        result.problems.add(error("null UUID from %s", entry.getKey()));
+        result.problems.add(error("null UUID from %s", ref.getName()));
         continue;
       }
       try {
         GroupConfig cfg =
-            GroupConfig.loadForGroupSnapshot(repo, uuid, entry.getValue().getObjectId());
+            GroupConfig.loadForGroupSnapshot(allUsersName, allUsersRepo, uuid, ref.getObjectId());
         result.uuidToGroupMap.put(uuid, cfg.getLoadedGroup().get());
       } catch (ConfigInvalidException e) {
         result.problems.add(error("group %s does not parse: %s", uuid, e.getMessage()));
@@ -118,16 +131,18 @@
 
   private void readGroupNames(
       Repository repo,
-      Map<String, Ref> refs,
+      List<Ref> refs,
       Result result,
       BiMap<AccountGroup.UUID, String> uuidNameBiMap)
       throws IOException {
-    Ref ref = refs.get(RefNames.REFS_GROUPNAMES);
-    if (ref == null) {
+    Optional<Ref> maybeRef =
+        refs.stream().filter(r -> r.getName().equals(RefNames.REFS_GROUPNAMES)).findFirst();
+    if (!maybeRef.isPresent()) {
       String msg = String.format("ref %s does not exist", RefNames.REFS_GROUPNAMES);
       result.problems.add(error(msg));
       return;
     }
+    Ref ref = maybeRef.get();
 
     try (RevWalk rw = new RevWalk(repo)) {
       RevCommit c = rw.parseCommit(ref.getObjectId());
@@ -148,7 +163,7 @@
           continue;
         }
 
-        ObjectId nameKey = GroupNameNotes.getNoteKey(new AccountGroup.NameKey(gRef.getName()));
+        ObjectId nameKey = GroupNameNotes.getNoteKey(AccountGroup.nameKey(gRef.getName()));
         if (!Objects.equals(nameKey, note)) {
           result.problems.add(
               error("notename entry %s does not match name %s", note, gRef.getName()));
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 38db2e6..ed7eab8 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -19,9 +19,12 @@
 import com.google.common.base.Throwables;
 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.common.TimeUtil;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
@@ -31,21 +34,22 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupIncludeCache;
-import com.google.gerrit.server.audit.AuditService;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.GroupAuditService;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexer;
-import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.inject.Inject;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Objects;
@@ -75,30 +79,43 @@
      * modifications executed by it. For NoteDb, this identity is used as author and committer for
      * all related commits.
      *
-     * <p><strong>Note</strong>: Please use this method with care and rather consider to use the
-     * correct annotation on the provider of a {@code GroupsUpdate} instead.
+     * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
+     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@code GroupsUpdate}
+     * instead.
      *
-     * @param currentUser the user to which modifications should be attributed, or {@code null} if
-     *     the Gerrit server identity should be used
+     * @param currentUser the user to which modifications should be attributed
      */
-    GroupsUpdate create(@Nullable IdentifiedUser currentUser);
+    GroupsUpdate create(IdentifiedUser currentUser);
+
+    /**
+     * Creates a {@code GroupsUpdate} which uses the server identity to mark database modifications
+     * executed by it. For NoteDb, this identity is used as author and committer for all related
+     * commits.
+     *
+     * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
+     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@code
+     * GroupsUpdate} instead.
+     */
+    GroupsUpdate createWithServerIdent();
   }
 
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final GroupCache groupCache;
   private final GroupIncludeCache groupIncludeCache;
   private final Provider<GroupIndexer> indexer;
-  private final AuditService auditService;
+  private final GroupAuditService groupAuditService;
   private final RenameGroupOp.Factory renameGroupOpFactory;
-  @Nullable private final IdentifiedUser currentUser;
+  private final Optional<IdentifiedUser> currentUser;
   private final AuditLogFormatter auditLogFormatter;
   private final PersonIdent authorIdent;
   private final MetaDataUpdateFactory metaDataUpdateFactory;
   private final GitReferenceUpdated gitRefUpdated;
   private final RetryHelper retryHelper;
 
-  @Inject
+  @AssistedInject
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -106,7 +123,41 @@
       GroupCache groupCache,
       GroupIncludeCache groupIncludeCache,
       Provider<GroupIndexer> indexer,
-      AuditService auditService,
+      GroupAuditService auditService,
+      AccountCache accountCache,
+      RenameGroupOp.Factory renameGroupOpFactory,
+      @GerritServerId String serverId,
+      @GerritPersonIdent PersonIdent serverIdent,
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      GitReferenceUpdated gitRefUpdated,
+      RetryHelper retryHelper) {
+    this(
+        repoManager,
+        allUsersName,
+        groupBackend,
+        groupCache,
+        groupIncludeCache,
+        indexer,
+        auditService,
+        accountCache,
+        renameGroupOpFactory,
+        serverId,
+        serverIdent,
+        metaDataUpdateInternalFactory,
+        gitRefUpdated,
+        retryHelper,
+        Optional.empty());
+  }
+
+  @AssistedInject
+  GroupsUpdate(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      GroupBackend groupBackend,
+      GroupCache groupCache,
+      GroupIncludeCache groupIncludeCache,
+      Provider<GroupIndexer> indexer,
+      GroupAuditService auditService,
       AccountCache accountCache,
       RenameGroupOp.Factory renameGroupOpFactory,
       @GerritServerId String serverId,
@@ -114,13 +165,47 @@
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       GitReferenceUpdated gitRefUpdated,
       RetryHelper retryHelper,
-      @Assisted @Nullable IdentifiedUser currentUser) {
+      @Assisted IdentifiedUser currentUser) {
+    this(
+        repoManager,
+        allUsersName,
+        groupBackend,
+        groupCache,
+        groupIncludeCache,
+        indexer,
+        auditService,
+        accountCache,
+        renameGroupOpFactory,
+        serverId,
+        serverIdent,
+        metaDataUpdateInternalFactory,
+        gitRefUpdated,
+        retryHelper,
+        Optional.of(currentUser));
+  }
+
+  private GroupsUpdate(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      GroupBackend groupBackend,
+      GroupCache groupCache,
+      GroupIncludeCache groupIncludeCache,
+      Provider<GroupIndexer> indexer,
+      GroupAuditService auditService,
+      AccountCache accountCache,
+      RenameGroupOp.Factory renameGroupOpFactory,
+      @GerritServerId String serverId,
+      @GerritPersonIdent PersonIdent serverIdent,
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      GitReferenceUpdated gitRefUpdated,
+      RetryHelper retryHelper,
+      Optional<IdentifiedUser> currentUser) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.groupCache = groupCache;
     this.groupIncludeCache = groupIncludeCache;
     this.indexer = indexer;
-    this.auditService = auditService;
+    this.groupAuditService = auditService;
     this.renameGroupOpFactory = renameGroupOpFactory;
     this.gitRefUpdated = gitRefUpdated;
     this.retryHelper = retryHelper;
@@ -135,7 +220,7 @@
 
   private static MetaDataUpdateFactory getMetaDataUpdateFactory(
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
-      @Nullable IdentifiedUser currentUser,
+      Optional<IdentifiedUser> currentUser,
       PersonIdent serverIdent,
       AuditLogFormatter auditLogFormatter) {
     return (projectName, repository, batchRefUpdate) -> {
@@ -143,10 +228,10 @@
           metaDataUpdateInternalFactory.create(projectName, repository, batchRefUpdate);
       metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
       PersonIdent authorIdent;
-      if (currentUser != null) {
-        metaDataUpdate.setAuthor(currentUser);
+      if (currentUser.isPresent()) {
+        metaDataUpdate.setAuthor(currentUser.get());
         authorIdent =
-            auditLogFormatter.getParsableAuthorIdent(currentUser.getAccount(), serverIdent);
+            auditLogFormatter.getParsableAuthorIdent(currentUser.get().getAccount(), serverIdent);
       } else {
         authorIdent = serverIdent;
       }
@@ -156,8 +241,8 @@
   }
 
   private static PersonIdent getAuthorIdent(
-      PersonIdent serverIdent, @Nullable IdentifiedUser currentUser) {
-    return currentUser != null ? createPersonIdent(serverIdent, currentUser) : serverIdent;
+      PersonIdent serverIdent, Optional<IdentifiedUser> currentUser) {
+    return currentUser.map(user -> createPersonIdent(serverIdent, user)).orElse(serverIdent);
   }
 
   private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
@@ -172,17 +257,24 @@
    * @param groupUpdate an {@code InternalGroupUpdate} which specifies optional properties of the
    *     group. If this {@code InternalGroupUpdate} updates a property which was already specified
    *     by the {@code InternalGroupCreation}, the value of this {@code InternalGroupUpdate} wins.
-   * @throws OrmDuplicateKeyException if a group with the chosen name already exists
+   * @throws DuplicateKeyException if a group with the chosen name already exists
    * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
    * @return the created {@code InternalGroup}
    */
   public InternalGroup createGroup(
       InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
-      throws OrmDuplicateKeyException, IOException, ConfigInvalidException {
-    InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupUpdate);
-    updateCachesOnGroupCreation(createdGroup);
-    dispatchAuditEventsOnGroupCreation(createdGroup);
-    return createdGroup;
+      throws DuplicateKeyException, IOException, ConfigInvalidException {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Creating group",
+            Metadata.builder()
+                .groupName(groupUpdate.getName().orElseGet(groupCreation::getNameKey).get())
+                .build())) {
+      InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupUpdate);
+      evictCachesOnGroupCreation(createdGroup);
+      dispatchAuditEventsOnGroupCreation(createdGroup);
+      return createdGroup;
+    }
   }
 
   /**
@@ -191,26 +283,31 @@
    * @param groupUuid the UUID of the group to update
    * @param groupUpdate an {@code InternalGroupUpdate} which indicates the desired updates on the
    *     group
-   * @throws OrmDuplicateKeyException if the new name of the group is used by another group
+   * @throws DuplicateKeyException if the new name of the group is used by another group
    * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
    * @throws NoSuchGroupException if the specified group doesn't exist
    */
   public void updateGroup(AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
-      throws OrmDuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
-    Optional<Timestamp> updatedOn = groupUpdate.getUpdatedOn();
-    if (!updatedOn.isPresent()) {
-      updatedOn = Optional.of(TimeUtil.nowTs());
-      groupUpdate = groupUpdate.toBuilder().setUpdatedOn(updatedOn.get()).build();
-    }
+      throws DuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
+      Optional<Timestamp> updatedOn = groupUpdate.getUpdatedOn();
+      if (!updatedOn.isPresent()) {
+        updatedOn = Optional.of(TimeUtil.nowTs());
+        groupUpdate = groupUpdate.toBuilder().setUpdatedOn(updatedOn.get()).build();
+      }
 
-    UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupUpdate);
-    updateCachesOnGroupUpdate(result);
-    dispatchAuditEventsOnGroupUpdate(result, updatedOn.get());
+      UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupUpdate);
+      updateNameInProjectConfigsIfNecessary(result);
+      evictCachesOnGroupUpdate(result);
+      dispatchAuditEventsOnGroupUpdate(result, updatedOn.get());
+    }
   }
 
   private InternalGroup createGroupInNoteDbWithRetry(
       InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
-      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+      throws IOException, ConfigInvalidException, DuplicateKeyException {
     try {
       return retryHelper.execute(
           RetryHelper.ActionType.GROUP_UPDATE,
@@ -220,7 +317,7 @@
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, IOException.class);
       Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
-      Throwables.throwIfInstanceOf(e, OrmDuplicateKeyException.class);
+      Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
       throw new IOException(e);
     }
   }
@@ -228,13 +325,15 @@
   @VisibleForTesting
   public InternalGroup createGroupInNoteDb(
       InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
-      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+      throws IOException, ConfigInvalidException, DuplicateKeyException {
     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);
@@ -248,7 +347,7 @@
 
   private UpdateResult updateGroupInNoteDbWithRetry(
       AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
-      throws IOException, ConfigInvalidException, OrmDuplicateKeyException, NoSuchGroupException {
+      throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
     try {
       return retryHelper.execute(
           RetryHelper.ActionType.GROUP_UPDATE,
@@ -258,7 +357,7 @@
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, IOException.class);
       Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
-      Throwables.throwIfInstanceOf(e, OrmDuplicateKeyException.class);
+      Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
       Throwables.throwIfInstanceOf(e, NoSuchGroupException.class);
       throw new IOException(e);
     }
@@ -267,9 +366,9 @@
   @VisibleForTesting
   public UpdateResult updateGroupInNoteDb(
       AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
-      throws IOException, ConfigInvalidException, OrmDuplicateKeyException, NoSuchGroupException {
+      throws IOException, ConfigInvalidException, DuplicateKeyException, 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 +379,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);
@@ -338,25 +438,43 @@
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
     gitRefUpdated.fire(
-        allUsersName, batchRefUpdate, currentUser != null ? currentUser.state() : null);
+        allUsersName, batchRefUpdate, currentUser.map(user -> user.state()).orElse(null));
   }
 
-  private void updateCachesOnGroupCreation(InternalGroup createdGroup) throws IOException {
+  private void evictCachesOnGroupCreation(InternalGroup createdGroup) {
+    logger.atFine().log("evict caches on creation of group %s", createdGroup.getGroupUUID());
+    // By UUID is used for the index and hence should be evicted before refreshing the index.
+    groupCache.evict(createdGroup.getGroupUUID());
     indexer.get().index(createdGroup.getGroupUUID());
-    for (Account.Id modifiedMember : createdGroup.getMembers()) {
-      groupIncludeCache.evictGroupsWithMember(modifiedMember);
-    }
-    for (AccountGroup.UUID modifiedSubgroup : createdGroup.getSubgroups()) {
-      groupIncludeCache.evictParentGroupsOf(modifiedSubgroup);
-    }
+    // These caches use the result from the index and hence must be evicted after refreshing the
+    // index.
+    groupCache.evict(createdGroup.getId());
+    groupCache.evict(createdGroup.getNameKey());
+    createdGroup.getMembers().forEach(groupIncludeCache::evictGroupsWithMember);
+    createdGroup.getSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
   }
 
-  private void updateCachesOnGroupUpdate(UpdateResult result) throws IOException {
+  private void evictCachesOnGroupUpdate(UpdateResult result) {
+    logger.atFine().log("evict caches on update of group %s", result.getGroupUuid());
+    // By UUID is used for the index and hence should be evicted before refreshing the index.
+    groupCache.evict(result.getGroupUuid());
+    indexer.get().index(result.getGroupUuid());
+    // These caches use the result from the index and hence must be evicted after refreshing the
+    // index.
+    groupCache.evict(result.getGroupId());
+    groupCache.evict(result.getGroupName());
+    result.getPreviousGroupName().ifPresent(groupCache::evict);
+
+    result.getAddedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
+    result.getDeletedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
+    result.getAddedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
+    result.getDeletedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
+  }
+
+  private void updateNameInProjectConfigsIfNecessary(UpdateResult result) {
     if (result.getPreviousGroupName().isPresent()) {
       AccountGroup.NameKey previousName = result.getPreviousGroupName().get();
-      groupCache.evict(previousName);
 
-      // TODO(aliceks): After switching to NoteDb, consider to use a BatchRefUpdate.
       @SuppressWarnings("unused")
       Future<?> possiblyIgnoredError =
           renameGroupOpFactory
@@ -367,32 +485,23 @@
                   result.getGroupName().get())
               .start(0, TimeUnit.MILLISECONDS);
     }
-    groupCache.evict(result.getGroupUuid());
-    groupCache.evict(result.getGroupId());
-    groupCache.evict(result.getGroupName());
-    indexer.get().index(result.getGroupUuid());
-
-    result.getAddedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
-    result.getDeletedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
-    result.getAddedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
-    result.getDeletedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
   }
 
   private void dispatchAuditEventsOnGroupCreation(InternalGroup createdGroup) {
-    if (currentUser == null) {
+    if (!currentUser.isPresent()) {
       return;
     }
 
     if (!createdGroup.getMembers().isEmpty()) {
-      auditService.dispatchAddMembers(
-          currentUser.getAccountId(),
+      groupAuditService.dispatchAddMembers(
+          currentUser.get().getAccountId(),
           createdGroup.getGroupUUID(),
           createdGroup.getMembers(),
           createdGroup.getCreatedOn());
     }
     if (!createdGroup.getSubgroups().isEmpty()) {
-      auditService.dispatchAddSubgroups(
-          currentUser.getAccountId(),
+      groupAuditService.dispatchAddSubgroups(
+          currentUser.get().getAccountId(),
           createdGroup.getGroupUUID(),
           createdGroup.getSubgroups(),
           createdGroup.getCreatedOn());
@@ -400,25 +509,34 @@
   }
 
   private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Timestamp updatedOn) {
-    if (currentUser == null) {
+    if (!currentUser.isPresent()) {
       return;
     }
 
     if (!result.getAddedMembers().isEmpty()) {
-      auditService.dispatchAddMembers(
-          currentUser.getAccountId(), result.getGroupUuid(), result.getAddedMembers(), updatedOn);
+      groupAuditService.dispatchAddMembers(
+          currentUser.get().getAccountId(),
+          result.getGroupUuid(),
+          result.getAddedMembers(),
+          updatedOn);
     }
     if (!result.getDeletedMembers().isEmpty()) {
-      auditService.dispatchDeleteMembers(
-          currentUser.getAccountId(), result.getGroupUuid(), result.getDeletedMembers(), updatedOn);
+      groupAuditService.dispatchDeleteMembers(
+          currentUser.get().getAccountId(),
+          result.getGroupUuid(),
+          result.getDeletedMembers(),
+          updatedOn);
     }
     if (!result.getAddedSubgroups().isEmpty()) {
-      auditService.dispatchAddSubgroups(
-          currentUser.getAccountId(), result.getGroupUuid(), result.getAddedSubgroups(), updatedOn);
+      groupAuditService.dispatchAddSubgroups(
+          currentUser.get().getAccountId(),
+          result.getGroupUuid(),
+          result.getAddedSubgroups(),
+          updatedOn);
     }
     if (!result.getDeletedSubgroups().isEmpty()) {
-      auditService.dispatchDeleteSubgroups(
-          currentUser.getAccountId(),
+      groupAuditService.dispatchDeleteSubgroups(
+          currentUser.get().getAccountId(),
           result.getGroupUuid(),
           result.getDeletedSubgroups(),
           updatedOn);
diff --git a/java/com/google/gerrit/server/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index eada57d..e002192 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -49,6 +49,7 @@
 
   private final ProjectCache projectCache;
   private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   private final PersonIdent author;
   private final AccountGroup.UUID uuid;
@@ -63,6 +64,7 @@
       WorkQueue workQueue,
       ProjectCache projectCache,
       MetaDataUpdate.Server metaDataUpdateFactory,
+      ProjectConfig.Factory projectConfigFactory,
       @Assisted("author") PersonIdent author,
       @Assisted AccountGroup.UUID uuid,
       @Assisted("oldName") String oldName,
@@ -70,6 +72,7 @@
     super(workQueue);
     this.projectCache = projectCache;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectConfigFactory = projectConfigFactory;
 
     this.author = author;
     this.uuid = uuid;
@@ -109,7 +112,7 @@
   private void rename(MetaDataUpdate md) throws IOException, ConfigInvalidException {
     boolean success = false;
     for (int attempts = 0; !success && attempts < MAX_TRIES; attempts++) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
 
       // The group isn't referenced, or its name has been fixed already.
       //
diff --git a/java/com/google/gerrit/server/group/db/testing/BUILD b/java/com/google/gerrit/server/group/db/testing/BUILD
index 6961b65..c13abba 100644
--- a/java/com/google/gerrit/server/group/db/testing/BUILD
+++ b/java/com/google/gerrit/server/group/db/testing/BUILD
@@ -2,7 +2,7 @@
 
 java_library(
     name = "testing",
-    testonly = 1,
+    testonly = True,
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/common:server",
diff --git a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
index 5a0d28c..fa06281 100644
--- a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
+++ b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
@@ -45,8 +45,8 @@
       String fileName,
       String contents)
       throws Exception {
-    try (RevWalk rw = new RevWalk(allUsersRepo)) {
-      TestRepository<Repository> testRepository = new TestRepository<>(allUsersRepo, rw);
+    try (RevWalk rw = new RevWalk(allUsersRepo);
+        TestRepository<Repository> testRepository = new TestRepository<>(allUsersRepo, rw)) {
       TestRepository<Repository>.CommitBuilder builder =
           testRepository
               .branch(refName)
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index 8b8cd00..3ef712c 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -2,7 +2,7 @@
 
 java_library(
     name = "testing",
-    testonly = 1,
+    testonly = True,
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/common:server",
diff --git a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
index f0ab638..a2f6002 100644
--- a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
+++ b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
@@ -18,90 +18,84 @@
 
 import com.google.common.truth.BooleanSubject;
 import com.google.common.truth.ComparableSubject;
-import com.google.common.truth.DefaultSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
-public class InternalGroupSubject extends Subject<InternalGroupSubject, InternalGroup> {
+public class InternalGroupSubject extends Subject {
 
   public static InternalGroupSubject assertThat(InternalGroup group) {
-    return assertAbout(InternalGroupSubject::new).that(group);
+    return assertAbout(internalGroups()).that(group);
   }
 
-  private InternalGroupSubject(FailureMetadata metadata, InternalGroup actual) {
-    super(metadata, actual);
+  public static Subject.Factory<InternalGroupSubject, InternalGroup> internalGroups() {
+    return InternalGroupSubject::new;
   }
 
-  public ComparableSubject<?, AccountGroup.UUID> groupUuid() {
+  private final InternalGroup group;
+
+  private InternalGroupSubject(FailureMetadata metadata, InternalGroup group) {
+    super(metadata, group);
+    this.group = group;
+  }
+
+  public ComparableSubject<AccountGroup.UUID> groupUuid() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getGroupUUID()).named("groupUuid");
+    return check("getGroupUUID()").that(group.getGroupUUID());
   }
 
-  public ComparableSubject<?, AccountGroup.NameKey> nameKey() {
+  public ComparableSubject<AccountGroup.NameKey> nameKey() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getNameKey()).named("nameKey");
+    return check("getNameKey()").that(group.getNameKey());
   }
 
   public StringSubject name() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getName()).named("name");
+    return check("getName()").that(group.getName());
   }
 
-  public DefaultSubject id() {
+  public Subject id() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getId()).named("id");
+    return check("getId()").that(group.getId());
   }
 
   public StringSubject description() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getDescription()).named("description");
+    return check("getDescription()").that(group.getDescription());
   }
 
-  public ComparableSubject<?, AccountGroup.UUID> ownerGroupUuid() {
+  public ComparableSubject<AccountGroup.UUID> ownerGroupUuid() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getOwnerGroupUUID()).named("ownerGroupUuid");
+    return check("getOwnerGroupUUID()").that(group.getOwnerGroupUUID());
   }
 
   public BooleanSubject visibleToAll() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.isVisibleToAll()).named("visibleToAll");
+    return check("isVisibleToAll()").that(group.isVisibleToAll());
   }
 
-  public ComparableSubject<?, Timestamp> createdOn() {
+  public ComparableSubject<Timestamp> createdOn() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getCreatedOn()).named("createdOn");
+    return check("getCreatedOn()").that(group.getCreatedOn());
   }
 
   public IterableSubject members() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getMembers()).named("members");
+    return check("getMembers()").that(group.getMembers());
   }
 
   public IterableSubject subgroups() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getSubgroups()).named("subgroups");
+    return check("getSubgroups()").that(group.getSubgroups());
   }
 
-  public ComparableSubject<?, ObjectId> refState() {
+  public ComparableSubject<ObjectId> refState() {
     isNotNull();
-    InternalGroup group = actual();
-    return Truth.assertThat(group.getRefState()).named("refState");
+    return check("getRefState()").that(group.getRefState());
   }
 }
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index c0efe2b..fa36ead 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.group.testing;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GroupDescription;
@@ -42,8 +42,8 @@
    * @return the created group
    */
   public GroupDescription.Basic create(String name) {
-    checkNotNull(name);
-    return create(new AccountGroup.UUID(name.startsWith(PREFIX) ? name : PREFIX + name));
+    requireNonNull(name);
+    return create(AccountGroup.uuid(name.startsWith(PREFIX) ? name : PREFIX + name));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
index 12aedfd..995b4b6 100644
--- a/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.server.index;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -35,26 +32,32 @@
 
   private final int threads;
   private final Map<String, Integer> singleVersions;
-  private final boolean onlineUpgrade;
   private final boolean slave;
 
-  protected AbstractIndexModule(
-      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade, boolean slave) {
-    if (singleVersions != null) {
-      checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
-    }
+  protected AbstractIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
     this.singleVersions = singleVersions;
     this.threads = threads;
-    this.onlineUpgrade = onlineUpgrade;
     this.slave = slave;
   }
 
   @Override
   protected void configure() {
     if (slave) {
-      bind(AccountIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
-      bind(ChangeIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
-      bind(ProjectIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
+      bind(AccountIndex.Factory.class)
+          .toInstance(
+              s -> {
+                throw new UnsupportedOperationException();
+              });
+      bind(ChangeIndex.Factory.class)
+          .toInstance(
+              s -> {
+                throw new UnsupportedOperationException();
+              });
+      bind(ProjectIndex.Factory.class)
+          .toInstance(
+              s -> {
+                throw new UnsupportedOperationException();
+              });
     } else {
       install(
           new FactoryModuleBuilder()
@@ -82,11 +85,6 @@
     }
   }
 
-  @SuppressWarnings("unused")
-  private static <T> T createDummyIndexFactory(Schema<?> schema) {
-    throw new UnsupportedOperationException();
-  }
-
   protected abstract Class<? extends AccountIndex> getAccountIndex();
 
   protected abstract Class<? extends ChangeIndex> getChangeIndex();
@@ -113,9 +111,6 @@
       Class<? extends VersionManager> versionManagerClass = getVersionManager();
       bind(VersionManager.class).to(versionManagerClass);
       listener().to(versionManagerClass);
-      if (onlineUpgrade) {
-        listener().to(OnlineUpgrader.class);
-      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index d957558..899e061 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -17,11 +17,14 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 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.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.IndexDefinition;
@@ -70,13 +73,40 @@
  * (e.g. Lucene).
  */
 public class IndexModule extends LifecycleModule {
-  public enum IndexType {
-    LUCENE,
-    ELASTICSEARCH
+  public static class IndexType {
+    private static final String LUCENE = "lucene";
+    private static final String ELASTICSEARCH = "elasticsearch";
+
+    private final String type;
+
+    public IndexType(@Nullable String type) {
+      this.type = type == null ? getDefault() : type.toLowerCase();
+    }
+
+    public static String getDefault() {
+      return LUCENE;
+    }
+
+    public static ImmutableSet<String> getKnownTypes() {
+      return ImmutableSet.of(LUCENE, ELASTICSEARCH);
+    }
+
+    public boolean isLucene() {
+      return type.equals(LUCENE);
+    }
+
+    public boolean isElasticsearch() {
+      return type.equals(ELASTICSEARCH);
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this).add("type", type).toString();
+    }
   }
 
   public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
-      ImmutableList.<SchemaDefinitions<?>>of(
+      ImmutableList.of(
           AccountSchemaDefinitions.INSTANCE,
           ChangeSchemaDefinitions.INSTANCE,
           GroupSchemaDefinitions.INSTANCE,
@@ -84,8 +114,13 @@
 
   /** Type of secondary index. */
   public static IndexType getIndexType(Injector injector) {
-    Config cfg = injector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    return cfg.getEnum("index", null, "type", IndexType.LUCENE);
+    return getIndexType(injector.getInstance(Key.get(Config.class, GerritServerConfig.class)));
+  }
+
+  /** Type of secondary index. */
+  public static IndexType getIndexType(@Nullable Config cfg) {
+    String configValue = cfg != null ? cfg.getString("index", null, "type") : null;
+    return new IndexType(configValue);
   }
 
   private final int threads;
@@ -104,7 +139,7 @@
 
   public IndexModule(
       ListeningExecutorService interactiveExecutor, ListeningExecutorService batchExecutor) {
-    this.threads = -1;
+    this.threads = 0;
     this.interactiveExecutor = interactiveExecutor;
     this.batchExecutor = batchExecutor;
     this.closeExecutorsOnShutdown = false;
@@ -125,7 +160,7 @@
     factory(ChangeIndexer.Factory.class);
 
     bind(GroupIndexRewriter.class);
-    bind(GroupIndexCollection.class);
+    // GroupIndexCollection is already bound very high up in SchemaModule.
     listener().to(GroupIndexCollection.class);
     factory(GroupIndexerImpl.Factory.class);
 
@@ -160,7 +195,7 @@
     }
 
     Collection<IndexDefinition<?, ?, ?>> result =
-        ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups, changes, projects);
+        ImmutableList.of(accounts, groups, changes, projects);
     Set<String> expected =
         FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
     Set<String> actual = FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
@@ -211,11 +246,12 @@
       return interactiveExecutor;
     }
     int threads = this.threads;
-    if (threads <= 0) {
-      threads = config.getInt("index", null, "threads", 0);
-    }
-    if (threads <= 0) {
-      threads = Runtime.getRuntime().availableProcessors() / 2 + 1;
+    if (threads < 0) {
+      return MoreExecutors.newDirectExecutorService();
+    } else if (threads == 0) {
+      threads =
+          config.getInt(
+              "index", null, "threads", Runtime.getRuntime().availableProcessors() / 2 + 1);
     }
     return MoreExecutors.listeningDecorator(
         workQueue.createQueue(threads, "Index-Interactive", true));
@@ -230,7 +266,9 @@
       return batchExecutor;
     }
     int threads = config.getInt("index", null, "batchThreads", 0);
-    if (threads <= 0) {
+    if (threads < 0) {
+      return MoreExecutors.newDirectExecutorService();
+    } else if (threads == 0) {
       threads = Runtime.getRuntime().availableProcessors();
     }
     return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Batch", true));
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 9836e82..4b5cd49 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -18,10 +18,10 @@
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.server.CurrentUser;
@@ -31,44 +31,28 @@
 import com.google.gerrit.server.query.change.SingleGroupUser;
 import java.io.IOException;
 import java.util.Set;
-import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public final class IndexUtils {
   public static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
       ImmutableMap.of("_", " ", ".", " ");
 
-  public static final Function<Exception, IOException> MAPPER =
-      new Function<Exception, IOException>() {
-        @Override
-        public IOException apply(Exception in) {
-          if (in instanceof IOException) {
-            return (IOException) in;
-          } else if (in instanceof ExecutionException && in.getCause() instanceof IOException) {
-            return (IOException) in.getCause();
-          } else {
-            return new IOException(in);
-          }
-        }
-      };
-
-  public static void setReady(SitePaths sitePaths, String name, int version, boolean ready)
-      throws IOException {
+  public static void setReady(SitePaths sitePaths, String name, int version, boolean ready) {
     try {
       GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
       cfg.setReady(name, version, ready);
       cfg.save();
-    } catch (ConfigInvalidException e) {
-      throw new IOException(e);
+    } catch (ConfigInvalidException | IOException e) {
+      throw new StorageException(e);
     }
   }
 
-  public static boolean getReady(SitePaths sitePaths, String name, int version) throws IOException {
+  public static boolean getReady(SitePaths sitePaths, String name, int version) {
     try {
       GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
       return cfg.getReady(name, version);
-    } catch (ConfigInvalidException e) {
-      throw new IOException(e);
+    } catch (ConfigInvalidException | IOException e) {
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/index/OnlineReindexMode.java b/java/com/google/gerrit/server/index/OnlineReindexMode.java
new file mode 100644
index 0000000..123229a
--- /dev/null
+++ b/java/com/google/gerrit/server/index/OnlineReindexMode.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import java.util.Optional;
+
+public class OnlineReindexMode {
+  private static ThreadLocal<Boolean> isOnlineReindex = new ThreadLocal<>();
+
+  public static boolean isActive() {
+    return Optional.ofNullable(isOnlineReindex.get()).orElse(Boolean.FALSE);
+  }
+
+  public static void begin() {
+    isOnlineReindex.set(Boolean.TRUE);
+  }
+
+  public static void end() {
+    isOnlineReindex.set(Boolean.FALSE);
+  }
+}
diff --git a/java/com/google/gerrit/server/index/OnlineReindexer.java b/java/com/google/gerrit/server/index/OnlineReindexer.java
index 0695278..37677bdd 100644
--- a/java/com/google/gerrit/server/index/OnlineReindexer.java
+++ b/java/com/google/gerrit/server/index/OnlineReindexer.java
@@ -14,16 +14,15 @@
 
 package com.google.gerrit.server.index;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.SiteIndexer;
-import java.io.IOException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -35,7 +34,7 @@
   private final SiteIndexer<K, V, I> batchIndexer;
   private final int oldVersion;
   private final int newVersion;
-  private final DynamicSet<OnlineUpgradeListener> listeners;
+  private final PluginSetContext<OnlineUpgradeListener> listeners;
   private I index;
   private final AtomicBoolean running = new AtomicBoolean();
 
@@ -43,7 +42,7 @@
       IndexDefinition<K, V, I> def,
       int oldVersion,
       int newVersion,
-      DynamicSet<OnlineUpgradeListener> listeners) {
+      PluginSetContext<OnlineUpgradeListener> listeners) {
     this.name = def.getName();
     this.indexes = def.getIndexCollection();
     this.batchIndexer = def.getSiteIndexer();
@@ -62,15 +61,13 @@
               try {
                 reindex();
                 ok = true;
-              } catch (IOException e) {
+              } catch (RuntimeException e) {
                 logger.atSevere().withCause(e).log(
                     "Online reindex of %s schema version %s failed", name, version(index));
               } finally {
                 running.set(false);
                 if (!ok) {
-                  for (OnlineUpgradeListener listener : listeners) {
-                    listener.onFailure(name, oldVersion, newVersion);
-                  }
+                  listeners.runEach(listener -> listener.onFailure(name, oldVersion, newVersion));
                 }
               }
             }
@@ -93,16 +90,12 @@
     return i.getSchema().getVersion();
   }
 
-  private void reindex() throws IOException {
-    for (OnlineUpgradeListener listener : listeners) {
-      listener.onStart(name, oldVersion, newVersion);
-    }
+  private void reindex() {
+    listeners.runEach(listener -> listener.onStart(name, oldVersion, newVersion));
     index =
-        checkNotNull(
+        requireNonNull(
             indexes.getWriteIndex(newVersion),
-            "not an active write schema version: %s %s",
-            name,
-            newVersion);
+            () -> String.format("not an active write schema version: %s %s", name, newVersion));
     logger.atInfo().log(
         "Starting online reindex of %s from schema version %s to %s",
         name, version(indexes.getSearchIndex()), version(index));
@@ -120,19 +113,13 @@
     }
     logger.atInfo().log("Reindex %s to version %s complete", name, version(index));
     activateIndex();
-    for (OnlineUpgradeListener listener : listeners) {
-      listener.onSuccess(name, oldVersion, newVersion);
-    }
+    listeners.runEach(listener -> listener.onSuccess(name, oldVersion, newVersion));
   }
 
   public void activateIndex() {
     indexes.setSearchIndex(index);
     logger.atInfo().log("Using %s schema version %s", name, version(index));
-    try {
-      index.markReady(true);
-    } catch (IOException e) {
-      logger.atWarning().log("Error activating new %s schema version %s", name, version(index));
-    }
+    index.markReady(true);
 
     List<I> toRemove = Lists.newArrayListWithExpectedSize(1);
     for (I i : indexes.getWriteIndexes()) {
@@ -141,12 +128,8 @@
       }
     }
     for (I i : toRemove) {
-      try {
-        i.markReady(false);
-        indexes.removeWriteIndex(version(i));
-      } catch (IOException e) {
-        logger.atWarning().log("Error deactivating old %s schema version %s", name, version(i));
-      }
+      i.markReady(false);
+      indexes.removeWriteIndex(version(i));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/OnlineUpgrader.java b/java/com/google/gerrit/server/index/OnlineUpgrader.java
index 9fc3aa9..bfcf55f 100644
--- a/java/com/google/gerrit/server/index/OnlineUpgrader.java
+++ b/java/com/google/gerrit/server/index/OnlineUpgrader.java
@@ -15,10 +15,18 @@
 package com.google.gerrit.server.index;
 
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.inject.Inject;
 
 /** Listener to handle upgrading index schema versions at startup. */
 public class OnlineUpgrader implements LifecycleListener {
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(OnlineUpgrader.class);
+    }
+  }
+
   private final VersionManager versionManager;
 
   @Inject
diff --git a/java/com/google/gerrit/server/index/RefState.java b/java/com/google/gerrit/server/index/RefState.java
deleted file mode 100644
index 6b893f0..0000000
--- a/java/com/google/gerrit/server/index/RefState.java
+++ /dev/null
@@ -1,83 +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.server.index;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Splitter;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Project;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-
-@AutoValue
-public abstract class RefState {
-  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
-    RefState.check(states != null, null);
-    SetMultimap<Project.NameKey, RefState> result =
-        MultimapBuilder.hashKeys().hashSetValues().build();
-    for (byte[] b : states) {
-      RefState.check(b != null, null);
-      String s = new String(b, UTF_8);
-      List<String> parts = Splitter.on(':').splitToList(s);
-      RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
-      result.put(new Project.NameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
-    }
-    return result;
-  }
-
-  public static RefState create(String ref, String sha) {
-    return new AutoValue_RefState(ref, ObjectId.fromString(sha));
-  }
-
-  public static RefState create(String ref, @Nullable ObjectId id) {
-    return new AutoValue_RefState(ref, firstNonNull(id, ObjectId.zeroId()));
-  }
-
-  public static RefState of(Ref ref) {
-    return new AutoValue_RefState(ref.getName(), ref.getObjectId());
-  }
-
-  public byte[] toByteArray(Project.NameKey project) {
-    byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
-    byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
-    System.arraycopy(a, 0, b, 0, a.length);
-    id().copyTo(b, a.length);
-    return b;
-  }
-
-  public static void check(boolean condition, String str) {
-    checkArgument(condition, "invalid RefState: %s", str);
-  }
-
-  public abstract String ref();
-
-  public abstract ObjectId id();
-
-  public boolean match(Repository repo) throws IOException {
-    Ref ref = repo.exactRef(ref());
-    ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
-    return id().equals(expected);
-  }
-}
diff --git a/java/com/google/gerrit/server/index/VersionManager.java b/java/com/google/gerrit/server/index/VersionManager.java
index 8aabb60..3417379 100644
--- a/java/com/google/gerrit/server/index/VersionManager.java
+++ b/java/com/google/gerrit/server/index/VersionManager.java
@@ -21,13 +21,13 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.IndexDefinition.IndexFactory;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.ProvisionException;
 import java.io.IOException;
 import java.util.Collection;
@@ -61,7 +61,7 @@
   protected final String runReindexMsg;
   protected final SitePaths sitePaths;
 
-  private final DynamicSet<OnlineUpgradeListener> listeners;
+  private final PluginSetContext<OnlineUpgradeListener> listeners;
 
   // The following fields must be accessed synchronized on this.
   protected final Map<String, IndexDefinition<?, ?, ?>> defs;
@@ -69,7 +69,7 @@
 
   protected VersionManager(
       SitePaths sitePaths,
-      DynamicSet<OnlineUpgradeListener> listeners,
+      PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs,
       boolean onlineUpgrade) {
     this.sitePaths = sitePaths;
@@ -272,8 +272,6 @@
   }
 
   private ProvisionException fail(Throwable t) {
-    ProvisionException e = new ProvisionException("Error scanning indexes");
-    e.initCause(t);
-    return e;
+    return new ProvisionException("Error scanning indexes", t);
   }
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index 31e2ada..f425339 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -21,28 +21,30 @@
 import static com.google.gerrit.index.FieldDef.timestamp;
 import static java.util.stream.Collectors.toSet;
 
-import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.index.RefState;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Locale;
+import java.util.Objects;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Secondary index schemas for accounts. */
 public class AccountField {
   public static final FieldDef<AccountState, Integer> ID =
-      integer("id").stored().build(a -> a.getAccount().getId().get());
+      integer("id").stored().build(a -> a.getAccount().id().get());
 
   /**
    * External IDs.
@@ -75,10 +77,10 @@
    */
   public static final FieldDef<AccountState, Iterable<String>> NAME_PART_NO_SECONDARY_EMAIL =
       prefix("name2")
-          .buildRepeatable(a -> getNameParts(a, Arrays.asList(a.getAccount().getPreferredEmail())));
+          .buildRepeatable(a -> getNameParts(a, Arrays.asList(a.getAccount().preferredEmail())));
 
   public static final FieldDef<AccountState, String> FULL_NAME =
-      exact("full_name").build(a -> a.getAccount().getFullName());
+      exact("full_name").build(a -> a.getAccount().fullName());
 
   public static final FieldDef<AccountState, String> ACTIVE =
       exact("inactive").build(a -> a.getAccount().isActive() ? "1" : "0");
@@ -95,8 +97,8 @@
               a ->
                   FluentIterable.from(a.getExternalIds())
                       .transform(ExternalId::email)
-                      .append(Collections.singleton(a.getAccount().getPreferredEmail()))
-                      .filter(Predicates.notNull())
+                      .append(Collections.singleton(a.getAccount().preferredEmail()))
+                      .filter(Objects::nonNull)
                       .transform(String::toLowerCase)
                       .toSet());
 
@@ -104,15 +106,15 @@
       prefix("preferredemail")
           .build(
               a -> {
-                String preferredEmail = a.getAccount().getPreferredEmail();
+                String preferredEmail = a.getAccount().preferredEmail();
                 return preferredEmail != null ? preferredEmail.toLowerCase() : null;
               });
 
   public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
-      exact("preferredemail_exact").build(a -> a.getAccount().getPreferredEmail());
+      exact("preferredemail_exact").build(a -> a.getAccount().preferredEmail());
 
   public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      timestamp("registered").build(a -> a.getAccount().getRegisteredOn());
+      timestamp("registered").build(a -> a.getAccount().registeredOn());
 
   public static final FieldDef<AccountState, String> USERNAME =
       exact("username").build(a -> a.getUserName().map(String::toLowerCase).orElse(""));
@@ -136,15 +138,19 @@
       storedOnly("ref_state")
           .buildRepeatable(
               a -> {
-                if (a.getAccount().getMetaId() == null) {
+                if (a.getAccount().metaId() == null) {
                   return ImmutableList.of();
                 }
 
                 return ImmutableList.of(
                     RefState.create(
-                            RefNames.refsUsers(a.getAccount().getId()),
-                            ObjectId.fromString(a.getAccount().getMetaId()))
-                        .toByteArray(a.getAllUsersNameForIndexing()));
+                            RefNames.refsUsers(a.getAccount().id()),
+                            ObjectId.fromString(a.getAccount().metaId()))
+                        // We use the default AllUsers name to avoid having to pass around that
+                        // variable just for indexing.
+                        // This field is only used for staleness detection which will discover the
+                        // default name and replace it with the actually configured name.
+                        .toByteArray(new AllUsersName(AllUsersNameProvider.DEFAULT)));
               });
 
   /**
@@ -157,14 +163,13 @@
       storedOnly("external_id_state")
           .buildRepeatable(
               a ->
-                  a.getExternalIds()
-                      .stream()
+                  a.getExternalIds().stream()
                       .filter(e -> e.blobId() != null)
                       .map(ExternalId::toByteArray)
                       .collect(toSet()));
 
   private static final Set<String> getNameParts(AccountState a, Iterable<String> emails) {
-    String fullName = a.getAccount().getFullName();
+    String fullName = a.getAccount().fullName();
     Set<String> parts = SchemaUtil.getNameParts(fullName, emails);
 
     // Additional values not currently added by getPersonParts.
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
index bc0970e..35b967c 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.index.account;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
@@ -38,7 +38,7 @@
   public Predicate<AccountState> rewrite(Predicate<AccountState> in, QueryOptions opts)
       throws QueryParseException {
     AccountIndex index = indexes.getSearchIndex();
-    checkNotNull(index, "no active search index configured for accounts");
+    requireNonNull(index, "no active search index configured for accounts");
     return new IndexedAccountQuery(index, in, opts);
   }
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexer.java b/java/com/google/gerrit/server/index/account/AccountIndexer.java
index 91fa1d9..7f4f295 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexer.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.index.account;
 
 import com.google.gerrit.reviewdb.client.Account;
-import java.io.IOException;
 
 public interface AccountIndexer {
 
@@ -24,7 +23,7 @@
    *
    * @param id account id to index.
    */
-  void index(Account.Id id) throws IOException;
+  void index(Account.Id id);
 
   /**
    * Synchronously reindex an account if it is stale.
@@ -32,5 +31,5 @@
    * @param id account id to index.
    * @return whether the account was reindexed
    */
-  boolean reindexIfStale(Account.Id id) throws IOException;
+  boolean reindexIfStale(Account.Id id);
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index b7bb0dd..5c2f551 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -15,13 +15,18 @@
 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.exceptions.StorageException;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.Index;
 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.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -30,6 +35,8 @@
 import java.util.Optional;
 
 public class AccountIndexerImpl implements AccountIndexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     AccountIndexerImpl create(AccountIndexCollection indexes);
 
@@ -37,7 +44,7 @@
   }
 
   private final AccountCache byIdCache;
-  private final DynamicSet<AccountIndexedListener> indexedListener;
+  private final PluginSetContext<AccountIndexedListener> indexedListener;
   private final StalenessChecker stalenessChecker;
   @Nullable private final AccountIndexCollection indexes;
   @Nullable private final AccountIndex index;
@@ -45,7 +52,7 @@
   @AssistedInject
   AccountIndexerImpl(
       AccountCache byIdCache,
-      DynamicSet<AccountIndexedListener> indexedListener,
+      PluginSetContext<AccountIndexedListener> indexedListener,
       StalenessChecker stalenessChecker,
       @Assisted AccountIndexCollection indexes) {
     this.byIdCache = byIdCache;
@@ -58,7 +65,7 @@
   @AssistedInject
   AccountIndexerImpl(
       AccountCache byIdCache,
-      DynamicSet<AccountIndexedListener> indexedListener,
+      PluginSetContext<AccountIndexedListener> indexedListener,
       StalenessChecker stalenessChecker,
       @Assisted @Nullable AccountIndex index) {
     this.byIdCache = byIdCache;
@@ -69,33 +76,58 @@
   }
 
   @Override
-  public void index(Account.Id id) throws IOException {
+  public void index(Account.Id id) {
+    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 in index",
+                Metadata.builder()
+                    .accountId(id.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
+          i.replace(accountState.get());
+        }
       } else {
-        i.delete(id);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleting account in index",
+                Metadata.builder()
+                    .accountId(id.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
+          i.delete(id);
+        }
       }
     }
     fireAccountIndexedEvent(id.get());
   }
 
   @Override
-  public boolean reindexIfStale(Account.Id id) throws IOException {
-    if (stalenessChecker.isStale(id)) {
-      index(id);
-      return true;
+  public boolean reindexIfStale(Account.Id id) {
+    try {
+      if (stalenessChecker.isStale(id)) {
+        index(id);
+        return true;
+      }
+    } catch (IOException e) {
+      throw new StorageException(e);
     }
     return false;
   }
 
   private void fireAccountIndexedEvent(int id) {
-    for (AccountIndexedListener listener : indexedListener) {
-      listener.onAccountIndexed(id);
-    }
+    indexedListener.runEach(l -> l.onAccountIndexed(id));
   }
 
   private Collection<AccountIndex> getWriteIndexes() {
@@ -103,6 +135,6 @@
       return indexes.getWriteIndexes();
     }
 
-    return index != null ? Collections.singleton(index) : ImmutableSet.<AccountIndex>of();
+    return index != null ? Collections.singleton(index) : ImmutableSet.of();
   }
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 3e702f2..c41814f 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -42,8 +42,15 @@
 
   @Deprecated static final Schema<AccountState> V7 = schema(V6, AccountField.PREFERRED_EMAIL_EXACT);
 
+  @Deprecated
   static final Schema<AccountState> V8 = schema(V7, AccountField.NAME_PART_NO_SECONDARY_EMAIL);
 
+  // Bump Lucene version requires reindexing
+  @Deprecated static final Schema<AccountState> V9 = schema(V8);
+
+  // Lucene index was changed to add additional fields for sorting.
+  static final Schema<AccountState> V10 = schema(V9);
+
   public static final String NAME = "accounts";
   public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
 
diff --git a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index 0015268..acb7236 100644
--- a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -30,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -60,7 +59,7 @@
 
   @Override
   public SiteIndexer.Result indexAll(AccountIndex index) {
-    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     Stopwatch sw = Stopwatch.createStarted();
     List<Account.Id> ids;
diff --git a/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java b/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
index 644f1eb..8b9fa27 100644
--- a/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
+++ b/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
@@ -17,15 +17,14 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexedQuery;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.IndexedQuery;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gwtorm.server.OrmException;
 
 public class IndexedAccountQuery extends IndexedQuery<Account.Id, AccountState>
     implements DataSource<AccountState>, Matchable<AccountState> {
@@ -37,7 +36,7 @@
   }
 
   @Override
-  public boolean match(AccountState accountState) throws OrmException {
+  public boolean match(AccountState accountState) {
     Predicate<AccountState> pred = getChild(0);
     checkState(
         pred.isMatchable(),
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index 6403d3d..0423bb9 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.index.account;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
@@ -24,6 +24,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
@@ -31,9 +32,9 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.RefState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -106,7 +107,11 @@
 
     for (Map.Entry<Project.NameKey, RefState> e :
         RefState.parseStates(result.get().getValue(AccountField.REF_STATE)).entries()) {
-      try (Repository repo = repoManager.openRepository(e.getKey())) {
+      // Custom All-Users repository names are not indexed. Instead, the default name is used.
+      // Therefore, defer to the currently configured All-Users name.
+      Project.NameKey repoName =
+          e.getKey().get().equals(AllUsersNameProvider.DEFAULT) ? allUsersName : e.getKey();
+      try (Repository repo = repoManager.openRepository(repoName)) {
         if (!e.getValue().match(repo)) {
           // Ref was modified since the account was indexed.
           return true;
@@ -140,7 +145,7 @@
     }
 
     for (byte[] b : extIdStates) {
-      checkNotNull(b, "invalid external ID state");
+      requireNonNull(b, "invalid external ID state");
       String s = new String(b, UTF_8);
       List<String> parts = Splitter.on(':').splitToList(s);
       checkState(parts.size() == 2, "invalid external ID state: %s", s);
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index babcba1..dde7c1f 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -23,22 +23,22 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.index.SiteIndexer;
 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.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.OnlineReindexMode;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -58,7 +58,6 @@
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final SchemaFactory<ReviewDb> schemaFactory;
   private final ChangeData.Factory changeDataFactory;
   private final GitRepositoryManager repoManager;
   private final ListeningExecutorService executor;
@@ -68,14 +67,12 @@
 
   @Inject
   AllChangesIndexer(
-      SchemaFactory<ReviewDb> schemaFactory,
       ChangeData.Factory changeDataFactory,
       GitRepositoryManager repoManager,
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       ChangeIndexer.Factory indexerFactory,
       ChangeNotes.Factory notesFactory,
       ProjectCache projectCache) {
-    this.schemaFactory = schemaFactory;
     this.changeDataFactory = changeDataFactory;
     this.repoManager = repoManager;
     this.executor = executor;
@@ -113,7 +110,7 @@
     int projectsFailed = 0;
     for (Project.NameKey name : projectCache.all()) {
       try (Repository repo = repoManager.openRepository(name)) {
-        long size = estimateSize(repo);
+        int size = estimateSize(repo);
         changeCount += size;
         projects.add(new ProjectHolder(name, size));
       } catch (IOException e) {
@@ -131,18 +128,17 @@
     return indexAll(index, projects);
   }
 
-  private long estimateSize(Repository repo) throws IOException {
+  private int estimateSize(Repository repo) throws IOException {
     // Estimate size based on IDs that show up in ref names. This is not perfect, since patch set
     // refs may exist for changes whose metadata was never successfully stored. But that's ok, as
     // the estimate is just used as a heuristic for sorting projects.
-    return repo.getRefDatabase()
-        .getRefs(RefNames.REFS_CHANGES)
-        .values()
-        .stream()
-        .map(r -> Change.Id.fromRef(r.getName()))
-        .filter(Objects::nonNull)
-        .distinct()
-        .count();
+    long size =
+        repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES).stream()
+            .map(r -> Change.Id.fromRef(r.getName()))
+            .filter(Objects::nonNull)
+            .distinct()
+            .count();
+    return Ints.saturatedCast(size);
   }
 
   private SiteIndexer.Result indexAll(ChangeIndex index, SortedSet<ProjectHolder> projects) {
@@ -218,27 +214,30 @@
 
     @Override
     public Void call() throws Exception {
-      try (Repository repo = repoManager.openRepository(project);
-          ReviewDb db = schemaFactory.open()) {
+      try (Repository repo = repoManager.openRepository(project)) {
+        OnlineReindexMode.begin();
+
         // Order of scanning changes is undefined. This is ok if we assume that packfile locality is
         // not important for indexing, since sites should have a fully populated DiffSummary cache.
         // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
         // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
         // we don't have concrete proof that improving packfile locality would help.
-        notesFactory.scan(repo, db, project).forEach(r -> index(db, r));
+        notesFactory.scan(repo, project).forEach(r -> index(r));
       } catch (RepositoryNotFoundException rnfe) {
         logger.atSevere().log(rnfe.getMessage());
+      } finally {
+        OnlineReindexMode.end();
       }
       return null;
     }
 
-    private void index(ReviewDb db, ChangeNotesResult r) {
+    private void index(ChangeNotesResult r) {
       if (r.error().isPresent()) {
         fail("Failed to read change " + r.id() + " for indexing", true, r.error().get());
         return;
       }
       try {
-        indexer.index(changeDataFactory.create(db, r.notes()));
+        indexer.index(changeDataFactory.create(r.notes()));
         done.update(1);
         verboseWriter.println("Reindexed change " + r.id());
       } catch (RejectedExecutionException e) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 405e6fc..599c604 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.fullText;
 import static com.google.gerrit.index.FieldDef.intRange;
@@ -22,10 +24,8 @@
 import static com.google.gerrit.index.FieldDef.prefix;
 import static com.google.gerrit.index.FieldDef.storedOnly;
 import static com.google.gerrit.index.FieldDef.timestamp;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
@@ -39,28 +39,32 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.Files;
 import com.google.common.primitives.Longs;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.reviewdb.converter.ChangeProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
+import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.project.SubmitRuleOptions;
@@ -68,11 +72,6 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gson.Gson;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.gwtorm.server.OrmException;
-import com.google.protobuf.CodedOutputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -129,7 +128,7 @@
 
   /** Reference (aka branch) the change will submit onto. */
   public static final FieldDef<ChangeData, String> REF =
-      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().get()));
+      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().branch()));
 
   /** Topic, a short annotation on the branch. */
   public static final FieldDef<ChangeData, String> EXACT_TOPIC =
@@ -153,13 +152,8 @@
       exact(ChangeQueryBuilder.FIELD_FILE)
           .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
 
-  public static Set<String> getFileParts(ChangeData cd) throws OrmException {
-    List<String> paths;
-    try {
-      paths = cd.currentFilePaths();
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
+  public static Set<String> getFileParts(ChangeData cd) {
+    List<String> paths = cd.currentFilePaths();
 
     Splitter s = Splitter.on('/').omitEmptyStrings();
     Set<String> r = new HashSet<>();
@@ -186,6 +180,89 @@
   public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
       exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts);
 
+  /** File extensions of each file modified in the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> EXTENSION =
+      exact(ChangeQueryBuilder.FIELD_EXTENSION).buildRepeatable(ChangeField::getExtensions);
+
+  public static Set<String> getExtensions(ChangeData cd) {
+    return extensions(cd).collect(toSet());
+  }
+
+  /**
+   * File extensions of each file modified in the current patch set as a sorted list. The purpose of
+   * this field is to allow matching changes that only touch files with certain file extensions.
+   */
+  public static final FieldDef<ChangeData, String> ONLY_EXTENSIONS =
+      exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS).build(ChangeField::getAllExtensionsAsList);
+
+  public static String getAllExtensionsAsList(ChangeData cd) {
+    return extensions(cd).distinct().sorted().collect(joining(","));
+  }
+
+  /**
+   * Returns a stream with all file extensions that are used by files in the given change. A file
+   * extension is defined as the portion of the filename following the final `.`. Files with no `.`
+   * in their name have no extension. For them an empty string is returned as part of the stream.
+   *
+   * <p>If the change contains multiple files with the same extension the extension is returned
+   * multiple times in the stream (once per file).
+   */
+  private static Stream<String> extensions(ChangeData cd) {
+    return cd.currentFilePaths().stream()
+        // Use case-insensitive file extensions even though other file fields are case-sensitive.
+        // If we want to find "all Java files", we want to match both .java and .JAVA, even if we
+        // normally care about case sensitivity. (Whether we should change the existing file/path
+        // predicates to be case insensitive is a separate question.)
+        .map(f -> Files.getFileExtension(f).toLowerCase(Locale.US));
+  }
+
+  /** Footers from the commit message of the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> FOOTER =
+      exact(ChangeQueryBuilder.FIELD_FOOTER).buildRepeatable(ChangeField::getFooters);
+
+  public static Set<String> getFooters(ChangeData cd) {
+    return cd.commitFooters().stream()
+        .map(f -> f.toString().toLowerCase(Locale.US))
+        .collect(toSet());
+  }
+
+  /** Folders that are touched by the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> DIRECTORY =
+      exact(ChangeQueryBuilder.FIELD_DIRECTORY).buildRepeatable(ChangeField::getDirectories);
+
+  public static Set<String> getDirectories(ChangeData cd) {
+    List<String> paths = cd.currentFilePaths();
+
+    Splitter s = Splitter.on('/').omitEmptyStrings();
+    Set<String> r = new HashSet<>();
+    for (String path : paths) {
+      StringBuilder directory = new StringBuilder();
+      directory.append("");
+      r.add(directory.toString());
+      String nextPart = null;
+      for (String part : s.split(path)) {
+        if (nextPart != null) {
+          r.add(nextPart);
+
+          if (directory.length() > 0) {
+            directory.append("/");
+          }
+          directory.append(nextPart);
+
+          String intermediateDir = directory.toString();
+          int i = intermediateDir.indexOf('/');
+          while (i >= 0) {
+            r.add(intermediateDir);
+            intermediateDir = intermediateDir.substring(i + 1);
+            i = intermediateDir.indexOf('/');
+          }
+        }
+        nextPart = part;
+      }
+    }
+    return r;
+  }
+
   /** Owner/creator of the change. */
   public static final FieldDef<ChangeData, Integer> OWNER =
       integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
@@ -298,7 +375,7 @@
         continue;
       }
 
-      Long l = Longs.tryParse(v.substring(i2 + 1, v.length()));
+      Long l = Longs.tryParse(v.substring(i2 + 1));
       if (l == null) {
         logger.atWarning().log(
             "Failed to parse timestamp of reviewer field from change %s: %s", changeId.get(), v);
@@ -351,7 +428,7 @@
         continue;
       }
 
-      Long l = Longs.tryParse(v.substring(i2 + 1, v.length()));
+      Long l = Longs.tryParse(v.substring(i2 + 1));
       if (l == null) {
         logger.atWarning().log(
             "Failed to parse timestamp of reviewer by email field from change %s: %s",
@@ -373,14 +450,8 @@
   public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
       exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
 
-  private static Set<String> getRevisions(ChangeData cd) throws OrmException {
-    Set<String> revisions = new HashSet<>();
-    for (PatchSet ps : cd.patchSets()) {
-      if (ps.getRevision() != null) {
-        revisions.add(ps.getRevision().get());
-      }
-    }
-    return revisions;
+  private static ImmutableSet<String> getRevisions(ChangeData cd) {
+    return cd.patchSets().stream().map(ps -> ps.commitId().name()).collect(toImmutableSet());
   }
 
   /** Tracking id extracted from a footer. */
@@ -392,37 +463,35 @@
   public static final FieldDef<ChangeData, Iterable<String>> LABEL =
       exact("label2").buildRepeatable(cd -> getLabels(cd, true));
 
-  private static Iterable<String> getLabels(ChangeData cd, boolean owners) throws OrmException {
+  private static Iterable<String> getLabels(ChangeData cd, boolean owners) {
     Set<String> allApprovals = new HashSet<>();
     Set<String> distinctApprovals = new HashSet<>();
     for (PatchSetApproval a : cd.currentApprovals()) {
-      if (a.getValue() != 0 && !a.isLegacySubmit()) {
-        allApprovals.add(formatLabel(a.getLabel(), a.getValue(), a.getAccountId()));
-        if (owners && cd.change().getOwner().equals(a.getAccountId())) {
-          allApprovals.add(
-              formatLabel(a.getLabel(), a.getValue(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+      if (a.value() != 0 && !a.isLegacySubmit()) {
+        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        if (owners && cd.change().getOwner().equals(a.accountId())) {
+          allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
         }
-        distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
+        distinctApprovals.add(formatLabel(a.label(), a.value()));
       }
     }
     allApprovals.addAll(distinctApprovals);
     return allApprovals;
   }
 
-  public static Set<String> getAuthorParts(ChangeData cd) throws OrmException, IOException {
+  public static Set<String> getAuthorParts(ChangeData cd) {
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
 
-  public static Set<String> getAuthorNameAndEmail(ChangeData cd) throws OrmException, IOException {
+  public static Set<String> getAuthorNameAndEmail(ChangeData cd) {
     return getNameAndEmail(cd.getAuthor());
   }
 
-  public static Set<String> getCommitterParts(ChangeData cd) throws OrmException, IOException {
+  public static Set<String> getCommitterParts(ChangeData cd) {
     return SchemaUtil.getPersonParts(cd.getCommitter());
   }
 
-  public static Set<String> getCommitterNameAndEmail(ChangeData cd)
-      throws OrmException, IOException {
+  public static Set<String> getCommitterNameAndEmail(ChangeData cd) {
     return getNameAndEmail(cd.getCommitter());
   }
 
@@ -469,12 +538,14 @@
 
   /** Serialized change object, used for pre-populating results. */
   public static final FieldDef<ChangeData, byte[]> CHANGE =
-      storedOnly("_change").build(changeGetter(CHANGE_CODEC::encodeToByteArray));
+      storedOnly("_change")
+          .build(changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)));
 
   /** Serialized approvals for the current patch set, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
       storedOnly("_approval")
-          .buildRepeatable(cd -> toProtos(APPROVAL_CODEC, cd.currentApprovals()));
+          .buildRepeatable(
+              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()));
 
   public static String formatLabel(String label, int value) {
     return formatLabel(label, value, null);
@@ -508,11 +579,15 @@
                           cd.messages().stream().map(ChangeMessage::getMessage))
                       .collect(toSet()));
 
-  /** Number of unresolved comments of the change. */
+  /** Number of unresolved comment threads of the change, including robot comments. */
   public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
       intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
           .build(ChangeData::unresolvedCommentCount);
 
+  /** Total number of published inline comments of the change, including robot comments. */
+  public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT =
+      intRange("total_comments").build(ChangeData::totalCommentCount);
+
   /** Whether the change is mergeable. */
   public static final FieldDef<ChangeData, String> MERGEABLE =
       exact(ChangeQueryBuilder.FIELD_MERGEABLE)
@@ -587,12 +662,12 @@
   public static final FieldDef<ChangeData, Iterable<String>> GROUP =
       exact(ChangeQueryBuilder.FIELD_GROUP)
           .buildRepeatable(
-              cd ->
-                  cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet()));
+              cd -> cd.patchSets().stream().flatMap(ps -> ps.groups().stream()).collect(toSet()));
 
   /** Serialized patch set object, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
-      storedOnly("_patch_set").buildRepeatable(cd -> toProtos(PATCH_SET_CODEC, cd.patchSets()));
+      storedOnly("_patch_set")
+          .buildRepeatable(cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()));
 
   /** Users who have edits on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
@@ -693,7 +768,7 @@
           SubmitRecord.Label srl = new SubmitRecord.Label();
           srl.label = label.label;
           srl.status = label.status;
-          srl.appliedBy = label.appliedBy != null ? new Account.Id(label.appliedBy) : null;
+          srl.appliedBy = label.appliedBy != null ? Account.id(label.appliedBy) : null;
           rec.labels.add(srl);
         }
       }
@@ -737,8 +812,7 @@
 
   @VisibleForTesting
   static List<SubmitRecord> parseSubmitRecords(Collection<String> values) {
-    return values
-        .stream()
+    return values.stream()
         .map(v -> GSON.fromJson(v, StoredSubmitRecord.class).toSubmitRecord())
         .collect(toList());
   }
@@ -752,7 +826,7 @@
     return storedSubmitRecords(cd.submitRecords(opts));
   }
 
-  public static List<String> formatSubmitRecordValues(ChangeData cd) throws OrmException {
+  public static List<String> formatSubmitRecordValues(ChangeData cd) {
     return formatSubmitRecordValues(
         cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner());
   }
@@ -799,19 +873,17 @@
                     .values()
                     .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(allUsers(cd))));
 
-                if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
-                  ChangeNotes notes = cd.notes();
-                  result.add(
-                      RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project));
-                  notes.getRobotComments(); // Force loading robot comments.
-                  RobotCommentNotes robotNotes = notes.getRobotCommentNotes();
-                  result.add(
-                      RefState.create(robotNotes.getRefName(), robotNotes.getMetaId())
-                          .toByteArray(project));
-                  cd.draftRefs()
-                      .values()
-                      .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd))));
-                }
+                ChangeNotes notes = cd.notes();
+                result.add(
+                    RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project));
+                notes.getRobotComments(); // Force loading robot comments.
+                RobotCommentNotes robotNotes = notes.getRobotCommentNotes();
+                result.add(
+                    RefState.create(robotNotes.getRefName(), robotNotes.getMetaId())
+                        .toByteArray(project));
+                cd.draftRefs()
+                    .values()
+                    .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd))));
 
                 return result;
               });
@@ -836,15 +908,13 @@
                 result.add(
                     RefStatePattern.create(RefNames.refsStarredChangesPrefix(id) + "*")
                         .toByteArray(allUsers(cd)));
-                if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
-                  result.add(
-                      RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
-                          .toByteArray(allUsers(cd)));
-                }
+                result.add(
+                    RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
+                        .toByteArray(allUsers(cd)));
                 return result;
               });
 
-  private static String getTopic(ChangeData cd) throws OrmException {
+  private static String getTopic(ChangeData cd) {
     Change c = cd.change();
     if (c == null) {
       return null;
@@ -852,22 +922,12 @@
     return firstNonNull(c.getTopic(), "");
   }
 
-  private static <T> List<byte[]> toProtos(ProtobufCodec<T> codec, Collection<T> objs)
-      throws OrmException {
-    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
-    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
-    try {
-      for (T obj : objs) {
-        out.reset();
-        CodedOutputStream cos = CodedOutputStream.newInstance(out);
-        codec.encode(obj, cos);
-        cos.flush();
-        result.add(out.toByteArray());
-      }
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-    return result;
+  private static <T> List<byte[]> toProtos(ProtoConverter<?, T> converter, Collection<T> objects) {
+    return objects.stream().map(object -> toProto(converter, object)).collect(toImmutableList());
+  }
+
+  private static <T> byte[] toProto(ProtoConverter<?, T> converter, T object) {
+    return Protos.toByteArray(converter.toProto(object));
   }
 
   private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 976813f..e92a0f6 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -155,7 +155,7 @@
 
     MutableInteger leafTerms = new MutableInteger();
     Predicate<ChangeData> out = rewriteImpl(in, index, opts, leafTerms);
-    if (in == out || out instanceof IndexPredicate) {
+    if (isSameInstance(in, out) || out instanceof IndexPredicate) {
       return new IndexedChangeQuery(index, out, opts);
     } else if (out == null /* cannot rewrite */) {
       return in;
@@ -207,7 +207,7 @@
     for (int i = 0; i < n; i++) {
       Predicate<ChangeData> c = in.getChild(i);
       Predicate<ChangeData> nc = rewriteImpl(c, index, opts, leafTerms);
-      if (nc == c) {
+      if (isSameInstance(nc, c)) {
         isIndexed.set(i);
         newChildren.add(c);
       } else if (nc == null /* cannot rewrite c */) {
@@ -291,4 +291,9 @@
     return p.getChildCount() > 0
         && (p instanceof AndPredicate || p instanceof OrPredicate || p instanceof NotPredicate);
   }
+
+  @SuppressWarnings("ReferenceEquality")
+  private static <T> boolean isSameInstance(T a, T b) {
+    return a == b;
+  }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index e947e60..87ee27f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -14,47 +14,38 @@
 
 package com.google.gerrit.server.index.change;
 
-import static com.google.gerrit.server.extensions.events.EventUtil.logEventListenerError;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
+import com.google.common.base.Objects;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.util.concurrent.Atomics;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import com.google.inject.util.Providers;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Future;
-import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 
@@ -73,46 +64,32 @@
     ChangeIndexer create(ListeningExecutorService executor, ChangeIndexCollection indexes);
   }
 
-  @SuppressWarnings("deprecation")
-  public static com.google.common.util.concurrent.CheckedFuture<?, IOException> allAsList(
-      List<? extends ListenableFuture<?>> futures) {
-    // allAsList propagates the first seen exception, wrapped in
-    // ExecutionException, so we can reuse the same mapper as for a single
-    // future. Assume the actual contents of the exception are not useful to
-    // callers. All exceptions are already logged by IndexTask.
-    return Futures.makeChecked(Futures.allAsList(futures), IndexUtils.MAPPER);
-  }
-
   @Nullable private final ChangeIndexCollection indexes;
   @Nullable private final ChangeIndex index;
-  private final SchemaFactory<ReviewDb> schemaFactory;
-  private final NotesMigration notesMigration;
-  private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ThreadLocalRequestContext context;
   private final ListeningExecutorService batchExecutor;
   private final ListeningExecutorService executor;
-  private final DynamicSet<ChangeIndexedListener> indexedListeners;
+  private final PluginSetContext<ChangeIndexedListener> indexedListeners;
   private final StalenessChecker stalenessChecker;
   private final boolean autoReindexIfStale;
 
+  private final Set<IndexTask> queuedIndexTasks =
+      Collections.newSetFromMap(new ConcurrentHashMap<>());
+  private final Set<ReindexIfStaleTask> queuedReindexIfStaleTasks =
+      Collections.newSetFromMap(new ConcurrentHashMap<>());
+
   @AssistedInject
   ChangeIndexer(
       @GerritServerConfig Config cfg,
-      SchemaFactory<ReviewDb> schemaFactory,
-      NotesMigration notesMigration,
-      ChangeNotes.Factory changeNotesFactory,
       ChangeData.Factory changeDataFactory,
       ThreadLocalRequestContext context,
-      DynamicSet<ChangeIndexedListener> indexedListeners,
+      PluginSetContext<ChangeIndexedListener> indexedListeners,
       StalenessChecker stalenessChecker,
       @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndex index) {
     this.executor = executor;
-    this.schemaFactory = schemaFactory;
-    this.notesMigration = notesMigration;
-    this.changeNotesFactory = changeNotesFactory;
     this.changeDataFactory = changeDataFactory;
     this.context = context;
     this.indexedListeners = indexedListeners;
@@ -125,21 +102,15 @@
 
   @AssistedInject
   ChangeIndexer(
-      SchemaFactory<ReviewDb> schemaFactory,
       @GerritServerConfig Config cfg,
-      NotesMigration notesMigration,
-      ChangeNotes.Factory changeNotesFactory,
       ChangeData.Factory changeDataFactory,
       ThreadLocalRequestContext context,
-      DynamicSet<ChangeIndexedListener> indexedListeners,
+      PluginSetContext<ChangeIndexedListener> indexedListeners,
       StalenessChecker stalenessChecker,
       @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndexCollection indexes) {
     this.executor = executor;
-    this.schemaFactory = schemaFactory;
-    this.notesMigration = notesMigration;
-    this.changeNotesFactory = changeNotesFactory;
     this.changeDataFactory = changeDataFactory;
     this.context = context;
     this.indexedListeners = indexedListeners;
@@ -160,10 +131,12 @@
    * @param id change to index.
    * @return future for the indexing task.
    */
-  @SuppressWarnings("deprecation")
-  public com.google.common.util.concurrent.CheckedFuture<?, IOException> indexAsync(
-      Project.NameKey project, Change.Id id) {
-    return submit(new IndexTask(project, id));
+  public ListenableFuture<?> indexAsync(Project.NameKey project, Change.Id id) {
+    IndexTask task = new IndexTask(project, id);
+    if (queuedIndexTasks.add(task)) {
+      return submit(task);
+    }
+    return Futures.immediateFuture(null);
   }
 
   /**
@@ -172,14 +145,12 @@
    * @param ids changes to index.
    * @return future for completing indexing of all changes.
    */
-  @SuppressWarnings("deprecation")
-  public com.google.common.util.concurrent.CheckedFuture<?, IOException> indexAsync(
-      Project.NameKey project, Collection<Change.Id> ids) {
+  public ListenableFuture<?> indexAsync(Project.NameKey project, Collection<Change.Id> ids) {
     List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
       futures.add(indexAsync(project, id));
     }
-    return allAsList(futures);
+    return Futures.allAsList(futures);
   }
 
   /**
@@ -187,7 +158,7 @@
    *
    * @param cd change to index.
    */
-  public void index(ChangeData cd) throws IOException {
+  public void index(ChangeData cd) {
     indexImpl(cd);
 
     // Always double-check whether the change might be stale immediately after
@@ -211,53 +182,47 @@
     autoReindexIfStale(cd);
   }
 
-  private void indexImpl(ChangeData cd) throws IOException {
+  private void indexImpl(ChangeData cd) {
+    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 in index",
+              Metadata.builder()
+                  .changeId(cd.getId().get())
+                  .indexVersion(i.getSchema().getVersion())
+                  .build())) {
+        i.replace(cd);
+      }
     }
     fireChangeIndexedEvent(cd.project().get(), cd.getId().get());
   }
 
   private void fireChangeIndexedEvent(String projectName, int id) {
-    for (ChangeIndexedListener listener : indexedListeners) {
-      try {
-        listener.onChangeIndexed(projectName, id);
-      } catch (Exception e) {
-        logEventListenerError(listener, e);
-      }
-    }
+    indexedListeners.runEach(l -> l.onChangeIndexed(projectName, id));
   }
 
   private void fireChangeDeletedFromIndexEvent(int id) {
-    for (ChangeIndexedListener listener : indexedListeners) {
-      try {
-        listener.onChangeDeleted(id);
-      } catch (Exception e) {
-        logEventListenerError(listener, e);
-      }
-    }
+    indexedListeners.runEach(l -> l.onChangeDeleted(id));
   }
 
   /**
    * Synchronously index a change.
    *
-   * @param db review database.
    * @param change change to index.
    */
-  public void index(ReviewDb db, Change change) throws IOException, OrmException {
-    index(newChangeData(db, change));
+  public void index(Change change) {
+    index(changeDataFactory.create(change));
   }
 
   /**
    * Synchronously index a change.
    *
-   * @param db review database.
    * @param project the project to which the change belongs.
    * @param changeId ID of the change to index.
    */
-  public void index(ReviewDb db, Project.NameKey project, Change.Id changeId)
-      throws IOException, OrmException {
-    index(newChangeData(db, project, changeId));
+  public void index(Project.NameKey project, Change.Id changeId) {
+    index(changeDataFactory.create(project, changeId));
   }
 
   /**
@@ -266,8 +231,7 @@
    * @param id change to delete.
    * @return future for the deleting task.
    */
-  @SuppressWarnings("deprecation")
-  public com.google.common.util.concurrent.CheckedFuture<?, IOException> deleteAsync(Change.Id id) {
+  public ListenableFuture<?> deleteAsync(Change.Id id) {
     return submit(new DeleteTask(id));
   }
 
@@ -276,7 +240,7 @@
    *
    * @param id change ID to delete.
    */
-  public void delete(Change.Id id) throws IOException {
+  public void delete(Change.Id id) {
     new DeleteTask(id).call();
   }
 
@@ -290,10 +254,12 @@
    * @param id ID of the change to index.
    * @return future for reindexing the change; returns true if the change was stale.
    */
-  @SuppressWarnings("deprecation")
-  public com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStale(
-      Project.NameKey project, Change.Id id) {
-    return submit(new ReindexIfStaleTask(project, id), batchExecutor);
+  public ListenableFuture<Boolean> reindexIfStale(Project.NameKey project, Change.Id id) {
+    ReindexIfStaleTask task = new ReindexIfStaleTask(project, id);
+    if (queuedReindexIfStaleTasks.add(task)) {
+      return submit(task, batchExecutor);
+    }
+    return Futures.immediateFuture(false);
   }
 
   private void autoReindexIfStale(ChangeData cd) {
@@ -312,17 +278,13 @@
     return indexes != null ? indexes.getWriteIndexes() : Collections.singleton(index);
   }
 
-  @SuppressWarnings("deprecation")
-  private <T> com.google.common.util.concurrent.CheckedFuture<T, IOException> submit(
-      Callable<T> task) {
+  private <T> ListenableFuture<T> submit(Callable<T> task) {
     return submit(task, executor);
   }
 
-  @SuppressWarnings("deprecation")
-  private static <T> com.google.common.util.concurrent.CheckedFuture<T, IOException> submit(
+  private static <T> ListenableFuture<T> submit(
       Callable<T> task, ListeningExecutorService executor) {
-    return Futures.makeChecked(
-        Futures.nonCancellationPropagating(executor.submit(task)), IndexUtils.MAPPER);
+    return Futures.nonCancellationPropagating(executor.submit(task));
   }
 
   private abstract class AbstractIndexTask<T> implements Callable<T> {
@@ -334,7 +296,9 @@
       this.id = id;
     }
 
-    protected abstract T callImpl(Provider<ReviewDb> db) throws Exception;
+    protected abstract T callImpl() throws Exception;
+
+    protected abstract void remove();
 
     @Override
     public abstract String toString();
@@ -342,39 +306,15 @@
     @Override
     public final T call() throws Exception {
       try {
-        final AtomicReference<Provider<ReviewDb>> dbRef = Atomics.newReference();
         RequestContext newCtx =
-            new RequestContext() {
-              @Override
-              public Provider<ReviewDb> getReviewDbProvider() {
-                Provider<ReviewDb> db = dbRef.get();
-                if (db == null) {
-                  try {
-                    db = Providers.of(schemaFactory.open());
-                  } catch (OrmException e) {
-                    ProvisionException pe = new ProvisionException("error opening ReviewDb");
-                    pe.initCause(e);
-                    throw pe;
-                  }
-                  dbRef.set(db);
-                }
-                return db;
-              }
-
-              @Override
-              public CurrentUser getUser() {
-                throw new OutOfScopeException("No user during ChangeIndexer");
-              }
+            () -> {
+              throw new OutOfScopeException("No user during ChangeIndexer");
             };
         RequestContext oldCtx = context.setContext(newCtx);
         try {
-          return callImpl(newCtx.getReviewDbProvider());
+          return callImpl();
         } finally {
           context.setContext(oldCtx);
-          Provider<ReviewDb> db = dbRef.get();
-          if (db != null) {
-            db.get().close();
-          }
         }
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Failed to execute %s", this);
@@ -389,19 +329,39 @@
     }
 
     @Override
-    public Void callImpl(Provider<ReviewDb> db) throws Exception {
-      ChangeData cd = newChangeData(db.get(), project, id);
+    public Void callImpl() throws Exception {
+      remove();
+      ChangeData cd = changeDataFactory.create(project, id);
       index(cd);
       return null;
     }
 
     @Override
+    public int hashCode() {
+      return Objects.hashCode(IndexTask.class, id.get());
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (!(obj instanceof IndexTask)) {
+        return false;
+      }
+      IndexTask other = (IndexTask) obj;
+      return id.get() == other.id.get();
+    }
+
+    @Override
     public String toString() {
       return "index-change-" + id;
     }
+
+    @Override
+    protected void remove() {
+      queuedIndexTasks.remove(this);
+    }
   }
 
-  // Not AbstractIndexTask as it doesn't need ReviewDb.
+  // Not AbstractIndexTask as it doesn't need a request context.
   private class DeleteTask implements Callable<Void> {
     private final Change.Id id;
 
@@ -410,14 +370,22 @@
     }
 
     @Override
-    public Void call() throws IOException {
+    public Void call() {
+      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(
+                "Deleting change in index",
+                Metadata.builder()
+                    .changeId(id.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
+          i.delete(id);
+        }
       }
-      logger.atInfo().log("Deleted change %s from index.", id.get());
       fireChangeDeletedFromIndexEvent(id.get());
       return null;
     }
@@ -429,14 +397,13 @@
     }
 
     @Override
-    public Boolean callImpl(Provider<ReviewDb> db) throws Exception {
+    public Boolean callImpl() throws Exception {
+      remove();
       try {
         if (stalenessChecker.isStale(id)) {
-          indexImpl(newChangeData(db.get(), project, id));
+          indexImpl(changeDataFactory.create(project, id));
           return true;
         }
-      } catch (NoSuchChangeException nsce) {
-        logger.atFine().log("Change %s was deleted, aborting reindexing the change.", id.get());
       } catch (Exception e) {
         if (!isCausedByRepositoryNotFoundException(e)) {
           throw e;
@@ -449,9 +416,28 @@
     }
 
     @Override
+    public int hashCode() {
+      return Objects.hashCode(ReindexIfStaleTask.class, id.get());
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (!(obj instanceof ReindexIfStaleTask)) {
+        return false;
+      }
+      ReindexIfStaleTask other = (ReindexIfStaleTask) obj;
+      return id.get() == other.id.get();
+    }
+
+    @Override
     public String toString() {
       return "reindex-if-stale-change-" + id;
     }
+
+    @Override
+    protected void remove() {
+      queuedReindexIfStaleTasks.remove(this);
+    }
   }
 
   private boolean isCausedByRepositoryNotFoundException(Throwable throwable) {
@@ -463,26 +449,4 @@
     }
     return false;
   }
-
-  // Avoid auto-rebuilding when reindexing if reading is disabled. This just
-  // increases contention on the meta ref from a background indexing thread
-  // with little benefit. The next actual write to the entity may still incur a
-  // less-contentious rebuild.
-  private ChangeData newChangeData(ReviewDb db, Change change) throws OrmException {
-    if (!notesMigration.readChanges()) {
-      ChangeNotes notes = changeNotesFactory.createWithAutoRebuildingDisabled(change, null);
-      return changeDataFactory.create(db, notes);
-    }
-    return changeDataFactory.create(db, change);
-  }
-
-  private ChangeData newChangeData(ReviewDb db, Project.NameKey project, Change.Id changeId)
-      throws OrmException {
-    if (!notesMigration.readChanges()) {
-      ChangeNotes notes =
-          changeNotesFactory.createWithAutoRebuildingDisabled(db, project, changeId);
-      return changeDataFactory.create(db, notes);
-    }
-    return changeDataFactory.create(db, project, changeId);
-  }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 5e7e4dd..cde6a64 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -96,7 +96,23 @@
   // Rename of star label 'mute' to 'reviewed' requires reindexing
   @Deprecated static final Schema<ChangeData> V48 = schema(V47);
 
-  static final Schema<ChangeData> V49 = schema(V48);
+  @Deprecated static final Schema<ChangeData> V49 = schema(V48);
+
+  // Bump Lucene version requires reindexing
+  @Deprecated static final Schema<ChangeData> V50 = schema(V49);
+
+  @Deprecated static final Schema<ChangeData> V51 = schema(V50, ChangeField.TOTAL_COMMENT_COUNT);
+
+  @Deprecated static final Schema<ChangeData> V52 = schema(V51, ChangeField.EXTENSION);
+
+  @Deprecated static final Schema<ChangeData> V53 = schema(V52, ChangeField.ONLY_EXTENSIONS);
+
+  @Deprecated static final Schema<ChangeData> V54 = schema(V53, ChangeField.FOOTER);
+
+  @Deprecated static final Schema<ChangeData> V55 = schema(V54, ChangeField.DIRECTORY);
+
+  // The computation of the 'extension' field is changed, hence reindexing is required.
+  static final Schema<ChangeData> V56 = schema(V55);
 
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/index/change/DummyChangeIndex.java b/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
index f6cee6d..9be93f7 100644
--- a/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-import java.io.IOException;
 
 public class DummyChangeIndex implements ChangeIndex {
   @Override
@@ -32,13 +31,13 @@
   public void close() {}
 
   @Override
-  public void replace(ChangeData cd) throws IOException {}
+  public void replace(ChangeData cd) {}
 
   @Override
-  public void delete(Change.Id id) throws IOException {}
+  public void delete(Change.Id id) {}
 
   @Override
-  public void deleteAll() throws IOException {}
+  public void deleteAll() {}
 
   @Override
   public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts) {
@@ -46,7 +45,7 @@
   }
 
   @Override
-  public void markReady(boolean ready) throws IOException {}
+  public void markReady(boolean ready) {}
 
   public int getMaxLimit() {
     return Integer.MAX_VALUE;
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 66f8df2..ed09eed 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -19,25 +19,24 @@
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.IndexedQuery;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.IndexedQuery;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -52,7 +51,7 @@
 public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
     implements ChangeDataSource, Matchable<ChangeData> {
   public static QueryOptions oneResult() {
-    return createOptions(IndexConfig.createDefault(), 0, 1, ImmutableSet.<String>of());
+    return createOptions(IndexConfig.createDefault(), 0, 1, ImmutableSet.of());
   }
 
   public static QueryOptions createOptions(
@@ -81,7 +80,7 @@
   }
 
   @Override
-  public ResultSet<ChangeData> read() throws OrmException {
+  public ResultSet<ChangeData> read() {
     final DataSource<ChangeData> currSource = source;
     final ResultSet<ChangeData> rs = currSource.read();
 
@@ -98,8 +97,8 @@
       }
 
       @Override
-      public List<ChangeData> toList() {
-        List<ChangeData> r = rs.toList();
+      public ImmutableList<ChangeData> toList() {
+        ImmutableList<ChangeData> r = rs.toList();
         for (ChangeData cd : r) {
           fromSource.put(cd, currSource);
         }
@@ -114,7 +113,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     if (source != null && fromSource.get(cd) == source) {
       return true;
     }
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 609432b..21579d2 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -17,17 +17,17 @@
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
+import com.google.common.base.Objects;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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.server.ReviewDb;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -40,12 +40,14 @@
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
+import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Config;
 
@@ -63,6 +65,8 @@
   private final ListeningExecutorService executor;
   private final boolean enabled;
 
+  private final Set<Index> queuedIndexTasks = Collections.newSetFromMap(new ConcurrentHashMap<>());
+
   @Inject
   ReindexAfterRefUpdate(
       @GerritServerConfig Config cfg,
@@ -92,12 +96,8 @@
     if (allUsersName.get().equals(event.getProjectName())) {
       Account.Id accountId = Account.Id.fromRef(event.getRefName());
       if (accountId != null && !event.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
-        try {
-          accountCache.evict(accountId);
-          indexer.get().index(accountId);
-        } catch (IOException e) {
-          logger.atSevere().withCause(e).log("Reindex account %s failed.", accountId);
-        }
+        accountCache.evict(accountId);
+        indexer.get().index(accountId);
       }
     }
 
@@ -113,9 +113,12 @@
           @Override
           public void onSuccess(List<Change> changes) {
             for (Change c : changes) {
-              // Don't retry indefinitely; if this fails changes may be stale.
-              @SuppressWarnings("unused")
-              Future<?> possiblyIgnoredError = executor.submit(new Index(event, c.getId()));
+              Index task = new Index(event, c.getId());
+              if (queuedIndexTasks.add(task)) {
+                // Don't retry indefinitely; if this fails changes may be stale.
+                @SuppressWarnings("unused")
+                Future<?> possiblyIgnoredError = executor.submit(task);
+              }
             }
           }
 
@@ -145,6 +148,8 @@
     }
 
     protected abstract V impl(RequestContext ctx) throws Exception;
+
+    protected abstract void remove();
   }
 
   private class GetChanges extends Task<List<Change>> {
@@ -153,13 +158,13 @@
     }
 
     @Override
-    protected List<Change> impl(RequestContext ctx) throws OrmException {
+    protected List<Change> impl(RequestContext ctx) {
       String ref = event.getRefName();
-      Project.NameKey project = new Project.NameKey(event.getProjectName());
+      Project.NameKey project = Project.nameKey(event.getProjectName());
       if (ref.equals(RefNames.REFS_CONFIG)) {
         return asChanges(queryProvider.get().byProjectOpen(project));
       }
-      return asChanges(queryProvider.get().byBranchNew(new Branch.NameKey(project, ref)));
+      return asChanges(queryProvider.get().byBranchNew(BranchNameKey.create(project, ref)));
     }
 
     @Override
@@ -169,6 +174,9 @@
           + " update of project "
           + event.getProjectName();
     }
+
+    @Override
+    protected void remove() {}
   }
 
   private class Index extends Task<Void> {
@@ -180,15 +188,13 @@
     }
 
     @Override
-    protected Void impl(RequestContext ctx) throws OrmException, IOException {
+    protected Void impl(RequestContext ctx) throws IOException {
       // Reload change, as some time may have passed since GetChanges.
-      ReviewDb db = ctx.getReviewDbProvider().get();
+      remove();
       try {
         Change c =
-            notesFactory
-                .createChecked(db, new Project.NameKey(event.getProjectName()), id)
-                .getChange();
-        indexerFactory.create(executor, indexes).index(db, c);
+            notesFactory.createChecked(Project.nameKey(event.getProjectName()), id).getChange();
+        indexerFactory.create(executor, indexes).index(c);
       } catch (NoSuchChangeException e) {
         indexerFactory.create(executor, indexes).delete(id);
       }
@@ -196,8 +202,27 @@
     }
 
     @Override
+    public int hashCode() {
+      return Objects.hashCode(Index.class, id.get());
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (!(obj instanceof Index)) {
+        return false;
+      }
+      Index other = (Index) obj;
+      return id.get() == other.id.get();
+    }
+
+    @Override
     public String toString() {
       return "Index change " + id.get() + " of project " + event.getProjectName();
     }
+
+    @Override
+    protected void remove() {
+      queuedIndexTasks.remove(this);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 208e949..fc5320c 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 
@@ -29,19 +28,15 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.RefState;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
@@ -64,21 +59,16 @@
   private final ChangeIndexCollection indexes;
   private final GitRepositoryManager repoManager;
   private final IndexConfig indexConfig;
-  private final Provider<ReviewDb> db;
 
   @Inject
   StalenessChecker(
-      ChangeIndexCollection indexes,
-      GitRepositoryManager repoManager,
-      IndexConfig indexConfig,
-      Provider<ReviewDb> db) {
+      ChangeIndexCollection indexes, GitRepositoryManager repoManager, IndexConfig indexConfig) {
     this.indexes = indexes;
     this.repoManager = repoManager;
     this.indexConfig = indexConfig;
-    this.db = db;
   }
 
-  public boolean isStale(Change.Id id) throws IOException, OrmException {
+  public boolean isStale(Change.Id id) {
     ChangeIndex i = indexes.getSearchIndex();
     if (i == null) {
       return false; // No index; caller couldn't do anything if it is stale.
@@ -94,24 +84,16 @@
       return true; // Not in index, but caller wants it to be.
     }
     ChangeData cd = result.get();
-    return isStale(
-        repoManager,
-        id,
-        cd.change(),
-        ChangeNotes.readOneReviewDbChange(db.get(), id),
-        parseStates(cd),
-        parsePatterns(cd));
+    return isStale(repoManager, id, parseStates(cd), parsePatterns(cd));
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean isStale(
       GitRepositoryManager repoManager,
       Change.Id id,
-      Change indexChange,
-      @Nullable Change reviewDbChange,
       SetMultimap<Project.NameKey, RefState> states,
       ListMultimap<Project.NameKey, RefStatePattern> patterns) {
-    return reviewDbChangeIsStale(indexChange, reviewDbChange)
-        || refsAreStale(repoManager, id, states, patterns);
+    return refsAreStale(repoManager, id, states, patterns);
   }
 
   @VisibleForTesting
@@ -131,31 +113,6 @@
     return false;
   }
 
-  @VisibleForTesting
-  static boolean reviewDbChangeIsStale(Change indexChange, @Nullable Change reviewDbChange) {
-    checkNotNull(indexChange);
-    PrimaryStorage storageFromIndex = PrimaryStorage.of(indexChange);
-    PrimaryStorage storageFromReviewDb = PrimaryStorage.of(reviewDbChange);
-    if (reviewDbChange == null) {
-      if (storageFromIndex == PrimaryStorage.REVIEW_DB) {
-        return true; // Index says it should have been in ReviewDb, but it wasn't.
-      }
-      return false; // Not in ReviewDb, but that's ok.
-    }
-    checkArgument(
-        indexChange.getId().equals(reviewDbChange.getId()),
-        "mismatched change ID: %s != %s",
-        indexChange.getId(),
-        reviewDbChange.getId());
-    if (storageFromIndex != storageFromReviewDb) {
-      return true; // Primary storage differs, definitely stale.
-    }
-    if (storageFromReviewDb != PrimaryStorage.REVIEW_DB) {
-      return false; // Not a ReviewDb change, don't check rowVersion.
-    }
-    return reviewDbChange.getRowVersion() != indexChange.getRowVersion();
-  }
-
   private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
     return RefState.parseStates(cd.getRefStates());
   }
@@ -174,7 +131,7 @@
       String s = new String(b, UTF_8);
       List<String> parts = Splitter.on(':').splitToList(s);
       RefStatePattern.check(parts.size() == 2, s);
-      result.put(new Project.NameKey(parts.get(0)), RefStatePattern.create(parts.get(1)));
+      result.put(Project.nameKey(Url.decode(parts.get(0))), RefStatePattern.create(parts.get(1)));
     }
     return result;
   }
@@ -247,7 +204,7 @@
     }
 
     private boolean match(Repository repo, Set<RefState> expected) throws IOException {
-      for (Ref r : repo.getRefDatabase().getRefs(prefix()).values()) {
+      for (Ref r : repo.getRefDatabase().getRefsByPrefix(prefix())) {
         if (!match(r.getName())) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 2823c2e..3474934 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -33,7 +33,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -64,7 +63,7 @@
 
   @Override
   public SiteIndexer.Result indexAll(GroupIndex index) {
-    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     Stopwatch sw = Stopwatch.createStarted();
     List<AccountGroup.UUID> uuids;
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index 29e3867..83c1625 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -23,13 +23,13 @@
 import static com.google.gerrit.index.FieldDef.timestamp;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Secondary index schemas for groups. */
@@ -82,7 +82,7 @@
       storedOnly("ref_state")
           .build(
               g -> {
-                byte[] a = new byte[Constants.OBJECT_ID_STRING_LENGTH];
+                byte[] a = new byte[ObjectIds.STR_LEN];
                 MoreObjects.firstNonNull(g.getRefState(), ObjectId.zeroId()).copyTo(a, 0);
                 return a;
               });
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java b/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
index c658173..157c01a 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.index.group;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
@@ -37,7 +37,7 @@
   public Predicate<InternalGroup> rewrite(Predicate<InternalGroup> in, QueryOptions opts)
       throws QueryParseException {
     GroupIndex index = indexes.getSearchIndex();
-    checkNotNull(index, "no active search index configured for groups");
+    requireNonNull(index, "no active search index configured for groups");
     return new IndexedGroupQuery(index, in, opts);
   }
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexer.java b/java/com/google/gerrit/server/index/group/GroupIndexer.java
index 503fd6b..5d9232e 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexer.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexer.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import java.io.IOException;
 
 public interface GroupIndexer {
 
@@ -24,7 +23,7 @@
    *
    * @param uuid group UUID to index.
    */
-  void index(AccountGroup.UUID uuid) throws IOException;
+  void index(AccountGroup.UUID uuid);
 
   /**
    * Synchronously reindex a group if it is stale.
@@ -32,5 +31,5 @@
    * @param uuid group UUID to index.
    * @return whether the group was reindexed
    */
-  boolean reindexIfStale(AccountGroup.UUID uuid) throws IOException;
+  boolean reindexIfStale(AccountGroup.UUID uuid);
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index fcbdc57..8bcc52c 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -15,13 +15,18 @@
 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.exceptions.StorageException;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.Index;
 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.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -30,6 +35,8 @@
 import java.util.Optional;
 
 public class GroupIndexerImpl implements GroupIndexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     GroupIndexerImpl create(GroupIndexCollection indexes);
 
@@ -37,7 +44,7 @@
   }
 
   private final GroupCache groupCache;
-  private final DynamicSet<GroupIndexedListener> indexedListener;
+  private final PluginSetContext<GroupIndexedListener> indexedListener;
   private final StalenessChecker stalenessChecker;
   @Nullable private final GroupIndexCollection indexes;
   @Nullable private final GroupIndex index;
@@ -45,7 +52,7 @@
   @AssistedInject
   GroupIndexerImpl(
       GroupCache groupCache,
-      DynamicSet<GroupIndexedListener> indexedListener,
+      PluginSetContext<GroupIndexedListener> indexedListener,
       StalenessChecker stalenessChecker,
       @Assisted GroupIndexCollection indexes) {
     this.groupCache = groupCache;
@@ -58,7 +65,7 @@
   @AssistedInject
   GroupIndexerImpl(
       GroupCache groupCache,
-      DynamicSet<GroupIndexedListener> indexedListener,
+      PluginSetContext<GroupIndexedListener> indexedListener,
       StalenessChecker stalenessChecker,
       @Assisted @Nullable GroupIndex index) {
     this.groupCache = groupCache;
@@ -69,33 +76,58 @@
   }
 
   @Override
-  public void index(AccountGroup.UUID uuid) throws IOException {
+  public void index(AccountGroup.UUID uuid) {
+    // 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",
+                Metadata.builder()
+                    .groupUuid(uuid.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
+          i.replace(internalGroup.get());
+        }
       } else {
-        i.delete(uuid);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleting group",
+                Metadata.builder()
+                    .groupUuid(uuid.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
+          i.delete(uuid);
+        }
       }
     }
     fireGroupIndexedEvent(uuid.get());
   }
 
   @Override
-  public boolean reindexIfStale(AccountGroup.UUID uuid) throws IOException {
-    if (stalenessChecker.isStale(uuid)) {
-      index(uuid);
-      return true;
+  public boolean reindexIfStale(AccountGroup.UUID uuid) {
+    try {
+      if (stalenessChecker.isStale(uuid)) {
+        index(uuid);
+        return true;
+      }
+    } catch (IOException e) {
+      throw new StorageException(e);
     }
     return false;
   }
 
   private void fireGroupIndexedEvent(String uuid) {
-    for (GroupIndexedListener listener : indexedListener) {
-      listener.onGroupIndexed(uuid);
-    }
+    indexedListener.runEach(l -> l.onGroupIndexed(uuid));
   }
 
   private Collection<GroupIndex> getWriteIndexes() {
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 912524f..6d0f3b6 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -37,7 +37,13 @@
   @Deprecated
   static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
 
-  static final Schema<InternalGroup> V5 = schema(V4, GroupField.REF_STATE);
+  @Deprecated static final Schema<InternalGroup> V5 = schema(V4, GroupField.REF_STATE);
+
+  // Bump Lucene version requires reindexing
+  @Deprecated static final Schema<InternalGroup> V6 = schema(V5);
+
+  // Lucene index was changed to add an additional field for sorting.
+  static final Schema<InternalGroup> V7 = schema(V6);
 
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
 
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 79f25c0..32393b06 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -16,9 +16,9 @@
 
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.IndexedQuery;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.IndexedQuery;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
diff --git a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
index 650df22..305cd25 100644
--- a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
+++ b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
@@ -54,7 +53,7 @@
 
   @Override
   public SiteIndexer.Result indexAll(final ProjectIndex index) {
-    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     List<Project.NameKey> names = collectProjects(progress);
     return reindexProjects(index, names, progress);
diff --git a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
index a79bb7a..199119a 100644
--- a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
@@ -15,23 +15,28 @@
 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;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 
 public class ProjectIndexerImpl implements ProjectIndexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     ProjectIndexerImpl create(ProjectIndexCollection indexes);
 
@@ -39,14 +44,14 @@
   }
 
   private final ProjectCache projectCache;
-  private final DynamicSet<ProjectIndexedListener> indexedListener;
+  private final PluginSetContext<ProjectIndexedListener> indexedListener;
   @Nullable private final ProjectIndexCollection indexes;
   @Nullable private final ProjectIndex index;
 
   @AssistedInject
   ProjectIndexerImpl(
       ProjectCache projectCache,
-      DynamicSet<ProjectIndexedListener> indexedListener,
+      PluginSetContext<ProjectIndexedListener> indexedListener,
       @Assisted ProjectIndexCollection indexes) {
     this.projectCache = projectCache;
     this.indexedListener = indexedListener;
@@ -57,7 +62,7 @@
   @AssistedInject
   ProjectIndexerImpl(
       ProjectCache projectCache,
-      DynamicSet<ProjectIndexedListener> indexedListener,
+      PluginSetContext<ProjectIndexedListener> indexedListener,
       @Assisted @Nullable ProjectIndex index) {
     this.projectCache = projectCache;
     this.indexedListener = indexedListener;
@@ -66,25 +71,41 @@
   }
 
   @Override
-  public void index(Project.NameKey nameKey) throws IOException {
+  public void index(Project.NameKey nameKey) {
     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",
+                Metadata.builder()
+                    .projectName(nameKey.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
+          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",
+                Metadata.builder()
+                    .projectName(nameKey.get())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
+          i.delete(nameKey);
+        }
       }
     }
   }
 
   private void fireProjectIndexedEvent(String name) {
-    for (ProjectIndexedListener listener : indexedListener) {
-      listener.onProjectIndexed(name);
-    }
+    indexedListener.runEach(l -> l.onProjectIndexed(name));
   }
 
   private Collection<ProjectIndex> getWriteIndexes() {
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
new file mode 100644
index 0000000..dc5ebc6
--- /dev/null
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.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.server.index.project;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectField;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import java.util.Optional;
+
+public class StalenessChecker {
+  private static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+
+  private final ProjectCache projectCache;
+  private final ProjectIndexCollection indexes;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  StalenessChecker(
+      ProjectCache projectCache, ProjectIndexCollection indexes, IndexConfig indexConfig) {
+    this.projectCache = projectCache;
+    this.indexes = indexes;
+    this.indexConfig = indexConfig;
+  }
+
+  public boolean isStale(Project.NameKey project) {
+    ProjectData projectData = projectCache.get(project).toProjectData();
+    ProjectIndex i = indexes.getSearchIndex();
+    if (i == null) {
+      return false; // No index; caller couldn't do anything if it is stale.
+    }
+
+    Optional<FieldBundle> result =
+        i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
+    if (!result.isPresent()) {
+      return true;
+    }
+
+    SetMultimap<Project.NameKey, RefState> indexedRefStates =
+        RefState.parseStates(result.get().getValue(ProjectField.REF_STATE));
+
+    SetMultimap<Project.NameKey, RefState> currentRefStates =
+        MultimapBuilder.hashKeys().hashSetValues().build();
+    projectData.tree().stream()
+        .filter(p -> p.getProject().getConfigRefState() != null)
+        .forEach(
+            p ->
+                currentRefStates.put(
+                    p.getProject().getNameKey(),
+                    RefState.create(RefNames.REFS_CONFIG, p.getProject().getConfigRefState())));
+
+    return !currentRefStates.equals(indexedRefStates);
+  }
+}
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
index 06843c5..ea91929 100644
--- a/java/com/google/gerrit/server/ioutil/BUILD
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -3,7 +3,8 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/reviewdb:client",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib:automaton",
         "//lib:guava",
         "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/java/com/google/gerrit/server/ioutil/HexFormat.java b/java/com/google/gerrit/server/ioutil/HexFormat.java
new file mode 100644
index 0000000..fd9c17a
--- /dev/null
+++ b/java/com/google/gerrit/server/ioutil/HexFormat.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ioutil;
+
+public class HexFormat {
+  public static String fromInt(int id) {
+    final char[] r = new char[8];
+    for (int p = 7; 0 <= p; p--) {
+      final int h = id & 0xf;
+      r[p] = h < 10 ? (char) ('0' + h) : (char) ('a' + (h - 10));
+      id >>= 4;
+    }
+    return new String(r);
+  }
+}
diff --git a/java/com/google/gerrit/server/ioutil/HostPlatform.java b/java/com/google/gerrit/server/ioutil/HostPlatform.java
new file mode 100644
index 0000000..39e9c07
--- /dev/null
+++ b/java/com/google/gerrit/server/ioutil/HostPlatform.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ioutil;
+
+import java.security.AccessController;
+import java.security.PrivilegedAction;
+
+public final class HostPlatform {
+  private static final boolean win32 = compute("windows");
+  private static final boolean mac = compute("mac");
+
+  /** @return true if this JVM is running on a Windows platform. */
+  public static boolean isWin32() {
+    return win32;
+  }
+
+  public static boolean isMac() {
+    return mac;
+  }
+
+  private static boolean compute(String platform) {
+    final String osDotName =
+        AccessController.doPrivileged(
+            (PrivilegedAction<String>) () -> System.getProperty("os.name"));
+    return osDotName != null && osDotName.toLowerCase().contains(platform);
+  }
+
+  private HostPlatform() {}
+}
diff --git a/java/com/google/gerrit/server/ioutil/RegexListSearcher.java b/java/com/google/gerrit/server/ioutil/RegexListSearcher.java
new file mode 100644
index 0000000..dda373c
--- /dev/null
+++ b/java/com/google/gerrit/server/ioutil/RegexListSearcher.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ioutil;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Chars;
+import dk.brics.automaton.Automaton;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+/** Helper to search sorted lists for elements matching a {@link RegExp}. */
+public final class RegexListSearcher<T> {
+  public static RegexListSearcher<String> ofStrings(String re) {
+    return new RegexListSearcher<>(re, Function.identity());
+  }
+
+  private final RunAutomaton pattern;
+  private final Function<T, String> toStringFunc;
+
+  private final String prefixBegin;
+  private final String prefixEnd;
+  private final int prefixLen;
+  private final boolean prefixOnly;
+
+  public RegexListSearcher(String re, Function<T, String> toStringFunc) {
+    this.toStringFunc = requireNonNull(toStringFunc);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    Automaton automaton = new RegExp(re).toAutomaton();
+    prefixBegin = automaton.getCommonPrefix();
+    prefixLen = prefixBegin.length();
+
+    if (0 < prefixLen) {
+      char max = Chars.checkedCast(prefixBegin.charAt(prefixLen - 1) + 1);
+      prefixEnd = prefixBegin.substring(0, prefixLen - 1) + max;
+      prefixOnly = re.equals(prefixBegin + ".*");
+    } else {
+      prefixEnd = "";
+      prefixOnly = false;
+    }
+
+    pattern = prefixOnly ? null : new RunAutomaton(automaton);
+  }
+
+  public Stream<T> search(List<T> list) {
+    requireNonNull(list);
+    int begin;
+    int end;
+
+    if (0 < prefixLen) {
+      // Assumes many consecutive elements may have the same prefix, so the cost of two binary
+      // searches is less than iterating linearly and running the regexp find the endpoints.
+      List<String> strings = Lists.transform(list, toStringFunc::apply);
+      begin = find(strings, prefixBegin);
+      end = find(strings, prefixEnd);
+    } else {
+      begin = 0;
+      end = list.size();
+    }
+    if (begin >= end) {
+      return Stream.empty();
+    }
+
+    Stream<T> result = list.subList(begin, end).stream();
+    if (!prefixOnly) {
+      result = result.filter(x -> pattern.run(toStringFunc.apply(x)));
+    }
+    return result;
+  }
+
+  private static int find(List<String> list, String p) {
+    int r = Collections.binarySearch(list, p);
+    return r < 0 ? -(r + 1) : r;
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
new file mode 100644
index 0000000..f78ff5f
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -0,0 +1,18 @@
+java_library(
+    name = "logging",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server/util/time",
+        "//lib:guava",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/logging/CallerFinder.java b/java/com/google/gerrit/server/logging/CallerFinder.java
new file mode 100644
index 0000000..73ffeb5
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/CallerFinder.java
@@ -0,0 +1,273 @@
+// 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.flogger.LazyArgs.lazy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.LazyArg;
+import java.util.Optional;
+
+/**
+ * Utility to compute the caller of a method.
+ *
+ * <p>In the logs we see for each entry from where it was triggered (class/method/line) but in case
+ * the logging is done in a utility method or inside of a module this doesn't tell us from where the
+ * action was actually triggered. To get this information we could included the stacktrace into the
+ * logs (by calling {@link
+ * com.google.common.flogger.LoggingApi#withStackTrace(com.google.common.flogger.StackSize)} but
+ * sometimes there are too many uninteresting stacks so that this would blow up the logs too much.
+ * In this case CallerFinder can be used to find the first interesting caller from the current
+ * stacktrace by specifying the class that interesting callers invoke as target.
+ *
+ * <p>Example:
+ *
+ * <p>Index queries are executed by the {@code query(List<String>, List<Predicate<T>>)} method in
+ * {@link com.google.gerrit.index.query.QueryProcessor}. At this place the index query is logged but
+ * from the log we want to see which code triggered this index query.
+ *
+ * <p>E.g. the stacktrace could look like this:
+ *
+ * <pre>
+ * GroupQueryProcessor(QueryProcessor<T>).query(List<String>, List<Predicate<T>>) line: 216
+ * GroupQueryProcessor(QueryProcessor<T>).query(List<Predicate<T>>) line: 188
+ * GroupQueryProcessor(QueryProcessor<T>).query(Predicate<T>) line: 171
+ * InternalGroupQuery(InternalQuery<T>).query(Predicate<T>) line: 81
+ * InternalGroupQuery.getOnlyGroup(Predicate<InternalGroup>, String) line: 67
+ * InternalGroupQuery.byName(NameKey) line: 50
+ * GroupCacheImpl$ByNameLoader.load(String) line: 166
+ * GroupCacheImpl$ByNameLoader.load(Object) line: 1
+ * LocalCache$LoadingValueReference<K,V>.loadFuture(K, CacheLoader<? super K,V>) line: 3527
+ * ...
+ * </pre>
+ *
+ * <p>The first interesting caller is {@code GroupCacheImpl$ByNameLoader.load(String) line: 166}. To
+ * find this caller from the stacktrace we could specify {@link
+ * com.google.gerrit.server.query.group.InternalGroupQuery} as a target since we know that all
+ * internal group queries go through this class:
+ *
+ * <pre>
+ * CallerFinder.builder()
+ *   .addTarget(InternalGroupQuery.class)
+ *   .build();
+ * </pre>
+ *
+ * <p>Since in some places {@link com.google.gerrit.server.query.group.GroupQueryProcessor} may also
+ * be used directly we can add it as a secondary target to catch these callers as well:
+ *
+ * <pre>
+ * CallerFinder.builder()
+ *   .addTarget(InternalGroupQuery.class)
+ *   .addTarget(GroupQueryProcessor.class)
+ *   .build();
+ * </pre>
+ *
+ * <p>However since {@link com.google.gerrit.index.query.QueryProcessor} is also responsible to
+ * execute other index queries (for changes, accounts, projects) we would need to add the classes
+ * for them as targets too. Since there are common base classes we can simply specify the base
+ * classes and request matching of subclasses:
+ *
+ * <pre>
+ * CallerFinder.builder()
+ *   .addTarget(InternalQuery.class)
+ *   .addTarget(QueryProcessor.class)
+ *   .matchSubClasses(true)
+ *   .build();
+ * </pre>
+ *
+ * <p>Another special case is if the entry point is always an inner class of a known interface. E.g.
+ * {@link com.google.gerrit.server.permissions.PermissionBackend} is the entry point for all
+ * permission checks but they are done through inner classes, e.g. {@link
+ * com.google.gerrit.server.permissions.PermissionBackend.ForProject}. In this case matching of
+ * inner classes must be enabled as well:
+ *
+ * <pre>
+ * CallerFinder.builder()
+ *   .addTarget(PermissionBackend.class)
+ *   .matchSubClasses(true)
+ *   .matchInnerClasses(true)
+ *   .build();
+ * </pre>
+ *
+ * <p>Finding the interesting caller requires specifying the entry point class as target. This may
+ * easily break when code is refactored and hence should be used only with care. It's recommended to
+ * use this only when the corresponding code is relatively stable and logging the caller information
+ * brings some significant benefit.
+ *
+ * <p>Based on {@link com.google.common.flogger.util.CallerFinder}.
+ */
+@AutoValue
+public abstract class CallerFinder {
+  public static Builder builder() {
+    return new AutoValue_CallerFinder.Builder()
+        .matchSubClasses(false)
+        .matchInnerClasses(false)
+        .skip(0);
+  }
+
+  /**
+   * The target classes for which the caller should be found, in the order in which they should be
+   * checked.
+   *
+   * @return the target classes for which the caller should be found
+   */
+  public abstract ImmutableList<Class<?>> targets();
+
+  /**
+   * Whether inner classes should be matched.
+   *
+   * @return whether inner classes should be matched
+   */
+  public abstract boolean matchSubClasses();
+
+  /**
+   * Whether sub classes of the target classes should be matched.
+   *
+   * @return whether sub classes of the target classes should be matched
+   */
+  public abstract boolean matchInnerClasses();
+
+  /**
+   * The minimum number of calls known to have occurred between the first call to the target class
+   * and the call of {@link #findCaller()}. If in doubt, specify zero here to avoid accidentally
+   * skipping past the caller.
+   *
+   * @return the number of stack elements to skip when computing the caller
+   */
+  public abstract int skip();
+
+  /**
+   * Packages that should be ignored and not be considered as caller once a target has been found.
+   *
+   * @return the ignored packages
+   */
+  public abstract ImmutableList<String> ignoredPackages();
+
+  /**
+   * Classes that should be ignored and not be considered as caller once a target has been found.
+   *
+   * @return the qualified names of the ignored classes
+   */
+  public abstract ImmutableList<String> ignoredClasses();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    abstract ImmutableList.Builder<Class<?>> targetsBuilder();
+
+    public Builder addTarget(Class<?> target) {
+      targetsBuilder().add(target);
+      return this;
+    }
+
+    public abstract Builder matchSubClasses(boolean matchSubClasses);
+
+    public abstract Builder matchInnerClasses(boolean matchInnerClasses);
+
+    public abstract Builder skip(int skip);
+
+    abstract ImmutableList.Builder<String> ignoredPackagesBuilder();
+
+    public Builder addIgnoredPackage(String ignoredPackage) {
+      ignoredPackagesBuilder().add(ignoredPackage);
+      return this;
+    }
+
+    abstract ImmutableList.Builder<String> ignoredClassesBuilder();
+
+    public Builder addIgnoredClass(Class<?> ignoredClass) {
+      ignoredClassesBuilder().add(ignoredClass.getName());
+      return this;
+    }
+
+    public abstract CallerFinder build();
+  }
+
+  public LazyArg<String> findCaller() {
+    return lazy(
+        () ->
+            targets().stream()
+                .map(t -> findCallerOf(t, skip() + 1))
+                .filter(Optional::isPresent)
+                .findFirst()
+                .map(Optional::get)
+                .orElse("unknown"));
+  }
+
+  private Optional<String> findCallerOf(Class<?> target, int skip) {
+    // Skip one additional stack frame because we create the Throwable inside this method, not at
+    // the point that this method was invoked.
+    skip++;
+
+    StackTraceElement[] stack = new Throwable().getStackTrace();
+
+    // Note: To avoid having to reflect the getStackTraceDepth() method as well, we assume that we
+    // will find the caller on the stack and simply catch an exception if we fail (which should
+    // hardly ever happen).
+    boolean foundCaller = false;
+    try {
+      for (int index = skip; ; index++) {
+        StackTraceElement element = stack[index];
+        if (isCaller(target, element.getClassName(), matchSubClasses())) {
+          foundCaller = true;
+        } else if (foundCaller
+            && !ignoredPackages().contains(getPackageName(element))
+            && !ignoredClasses().contains(element.getClassName())) {
+          return Optional.of(element.toString());
+        }
+      }
+    } catch (Exception e) {
+      // This should only happen if a) the caller was not found on the stack
+      // (IndexOutOfBoundsException) b) a class that is mentioned in the stack was not found
+      // (ClassNotFoundException), however we don't want anything to be thrown from here.
+      return Optional.empty();
+    }
+  }
+
+  private static String getPackageName(StackTraceElement element) {
+    String className = element.getClassName();
+    return className.substring(0, className.lastIndexOf("."));
+  }
+
+  private boolean isCaller(Class<?> target, String className, boolean matchSubClasses)
+      throws ClassNotFoundException {
+    if (matchSubClasses) {
+      Class<?> clazz = Class.forName(className);
+      while (clazz != null) {
+        if (Object.class.getName().equals(clazz.getName())) {
+          break;
+        }
+
+        if (isCaller(target, clazz.getName(), false)) {
+          return true;
+        }
+        clazz = clazz.getSuperclass();
+      }
+    }
+
+    if (matchInnerClasses()) {
+      int i = className.indexOf('$');
+      if (i > 0) {
+        className = className.substring(0, i);
+      }
+    }
+
+    if (target.getName().equals(className)) {
+      return true;
+    }
+
+    return false;
+  }
+}
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..bc5634df
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -0,0 +1,269 @@
+// 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.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.backend.Tags;
+import com.google.inject.Provider;
+import java.util.List;
+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 static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
+      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;
+    }
+
+    // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareRunnable
+    // constructor so that performance log records that are created in the wrapped runnable are
+    // added to this MutablePerformanceLogRecords instance. This is important since performance
+    // log records are processed only at the end of the request and performance log records that
+    // are created in another thread should not get lost.
+    return new LoggingContextAwareRunnable(
+        runnable, getInstance().getMutablePerformanceLogRecords());
+  }
+
+  public static <T> Callable<T> copy(Callable<T> callable) {
+    if (callable instanceof LoggingContextAwareCallable) {
+      return callable;
+    }
+
+    // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareCallable
+    // constructor so that performance log records that are created in the wrapped runnable are
+    // added to this MutablePerformanceLogRecords instance. This is important since performance
+    // log records are processed only at the end of the request and performance log records that
+    // are created in another thread should not get lost.
+    return new LoggingContextAwareCallable<>(
+        callable, getInstance().getMutablePerformanceLogRecords());
+  }
+
+  public boolean isEmpty() {
+    return tags.get() == null
+        && forceLogging.get() == null
+        && performanceLogging.get() == null
+        && performanceLogRecords.get() == null;
+  }
+
+  public void clear() {
+    tags.remove();
+    forceLogging.remove();
+    performanceLogging.remove();
+    performanceLogRecords.remove();
+  }
+
+  @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;
+  }
+
+  boolean isPerformanceLogging() {
+    Boolean isPerformanceLogging = performanceLogging.get();
+    return isPerformanceLogging != null ? isPerformanceLogging : false;
+  }
+
+  /**
+   * Enables performance logging.
+   *
+   * <p>It's important to enable performance logging only in a context that ensures to consume the
+   * captured performance log records. Otherwise captured performance log records might leak into
+   * other requests that are executed by the same thread (if a thread pool is used to process
+   * requests).
+   *
+   * @param enable whether performance logging should be enabled.
+   * @return whether performance logging was be enabled before invoking this method (old value).
+   */
+  boolean performanceLogging(boolean enable) {
+    Boolean oldValue = performanceLogging.get();
+    if (enable) {
+      performanceLogging.set(true);
+    } else {
+      performanceLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+
+  /**
+   * Adds a performance log record, if performance logging is enabled.
+   *
+   * @param recordProvider Provider for the performance log record. This provider is only invoked if
+   *     performance logging is enabled. This means if performance logging is disabled, we avoid the
+   *     creation of a {@link PerformanceLogRecord}.
+   */
+  public void addPerformanceLogRecord(Provider<PerformanceLogRecord> recordProvider) {
+    if (!isPerformanceLogging()) {
+      // return early and avoid the creation of a PerformanceLogRecord
+      return;
+    }
+
+    getMutablePerformanceLogRecords().add(recordProvider.get());
+  }
+
+  ImmutableList<PerformanceLogRecord> getPerformanceLogRecords() {
+    MutablePerformanceLogRecords records = performanceLogRecords.get();
+    if (records != null) {
+      return records.list();
+    }
+    return ImmutableList.of();
+  }
+
+  void clearPerformanceLogEntries() {
+    performanceLogRecords.remove();
+  }
+
+  /**
+   * Set the performance log records in this logging context. Existing log records are overwritten.
+   *
+   * <p>This method makes a defensive copy of the passed in list.
+   *
+   * @param newPerformanceLogRecords performance log records that should be set
+   */
+  void setPerformanceLogRecords(List<PerformanceLogRecord> newPerformanceLogRecords) {
+    if (newPerformanceLogRecords.isEmpty()) {
+      performanceLogRecords.remove();
+      return;
+    }
+
+    getMutablePerformanceLogRecords().set(newPerformanceLogRecords);
+  }
+
+  /**
+   * Sets a {@link MutablePerformanceLogRecords} instance for storing performance log records.
+   *
+   * <p><strong>Attention:</strong> The passed in {@link MutablePerformanceLogRecords} instance is
+   * directly stored in the logging context.
+   *
+   * <p>This method is intended to be only used when the logging context is copied to a new thread
+   * to ensure that the performance log records that are added in the new thread are added to the
+   * same {@link MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and
+   * {@link LoggingContextAwareCallable}). This is important since performance log records are
+   * processed only at the end of the request and performance log records that are created in
+   * another thread should not get lost.
+   *
+   * @param mutablePerformanceLogRecords the {@link MutablePerformanceLogRecords} instance in which
+   *     performance log records should be stored
+   */
+  void setMutablePerformanceLogRecords(MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+    performanceLogRecords.set(requireNonNull(mutablePerformanceLogRecords));
+  }
+
+  private MutablePerformanceLogRecords getMutablePerformanceLogRecords() {
+    MutablePerformanceLogRecords records = performanceLogRecords.get();
+    if (records == null) {
+      records = new MutablePerformanceLogRecords();
+      performanceLogRecords.set(records);
+    }
+    return records;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("tags", tags.get())
+        .add("forceLogging", forceLogging.get())
+        .add("performanceLogging", performanceLogging.get())
+        .add("performanceLogRecords", performanceLogRecords.get())
+        .toString();
+  }
+}
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..d2701d7
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -0,0 +1,93 @@
+// 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.FluentLogger;
+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 static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Callable<T> callable;
+  private final Thread callingThread;
+  private final ImmutableSetMultimap<String, String> tags;
+  private final boolean forceLogging;
+  private final boolean performanceLogging;
+  private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+
+  /**
+   * Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
+   *
+   * @param callable Callable that should be wrapped.
+   * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
+   *     performance log records that are created from the runnable are added
+   */
+  LoggingContextAwareCallable(
+      Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+    this.callable = callable;
+    this.callingThread = Thread.currentThread();
+    this.tags = LoggingContext.getInstance().getTagsAsMap();
+    this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+    this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
+    this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+  }
+
+  @Override
+  public T call() throws Exception {
+    if (callingThread.equals(Thread.currentThread())) {
+      // propagation of logging context is not needed
+      return callable.call();
+    }
+
+    LoggingContext loggingCtx = LoggingContext.getInstance();
+
+    if (!loggingCtx.isEmpty()) {
+      logger.atWarning().log("Logging context is not empty: %s", loggingCtx);
+    }
+
+    // propagate logging context
+    loggingCtx.setTags(tags);
+    loggingCtx.forceLogging(forceLogging);
+    loggingCtx.performanceLogging(performanceLogging);
+
+    // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
+    // the logging context of the calling thread in the logging context of the new thread. This way
+    // performance log records that are created from the new thread are available from the logging
+    // context of the calling thread. This is important since performance log records are processed
+    // only at the end of the request and performance log records that are created in another thread
+    // should not get lost.
+    loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    try {
+      return callable.call();
+    } finally {
+      // Cleanup logging context. This is important if the thread is pooled and reused.
+      loggingCtx.clear();
+    }
+  }
+}
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..23162b1
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -0,0 +1,116 @@
+// 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.FluentLogger;
+
+/**
+ * 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 static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Runnable runnable;
+  private final Thread callingThread;
+  private final ImmutableSetMultimap<String, String> tags;
+  private final boolean forceLogging;
+  private final boolean performanceLogging;
+  private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+
+  /**
+   * Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
+   *
+   * @param runnable Runnable that should be wrapped.
+   * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
+   *     performance log records that are created from the runnable are added
+   */
+  LoggingContextAwareRunnable(
+      Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+    this.runnable = runnable;
+    this.callingThread = Thread.currentThread();
+    this.tags = LoggingContext.getInstance().getTagsAsMap();
+    this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+    this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
+    this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+  }
+
+  public Runnable unwrap() {
+    return runnable;
+  }
+
+  @Override
+  public void run() {
+    if (callingThread.equals(Thread.currentThread())) {
+      // propagation of logging context is not needed
+      runnable.run();
+      return;
+    }
+
+    LoggingContext loggingCtx = LoggingContext.getInstance();
+
+    if (!loggingCtx.isEmpty()) {
+      logger.atWarning().log("Logging context is not empty: %s", loggingCtx);
+    }
+
+    // propagate logging context
+    loggingCtx.setTags(tags);
+    loggingCtx.forceLogging(forceLogging);
+    loggingCtx.performanceLogging(performanceLogging);
+
+    // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
+    // the logging context of the calling thread in the logging context of the new thread. This way
+    // performance log records that are created from the new thread are available from the logging
+    // context of the calling thread. This is important since performance log records are processed
+    // only at the end of the request and performance log records that are created in another thread
+    // should not get lost.
+    loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    try {
+      runnable.run();
+    } finally {
+      // Cleanup logging context. This is important if the thread is pooled and reused.
+      loggingCtx.clear();
+    }
+  }
+}
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/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
new file mode 100644
index 0000000..7eba4de
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -0,0 +1,223 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
+/** Metadata that is provided to {@link PerformanceLogger}s as context for performance records. */
+@AutoValue
+public abstract class Metadata {
+  // The numeric ID of an account.
+  public abstract Optional<Integer> accountId();
+
+  // The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
+  // PLUGIN_UPDATE).
+  public abstract Optional<String> actionType();
+
+  // An authentication domain name.
+  public abstract Optional<String> authDomainName();
+
+  // The name of a branch.
+  public abstract Optional<String> branchName();
+
+  // Key of an entity in a cache.
+  public abstract Optional<String> cacheKey();
+
+  // The name of a cache.
+  public abstract Optional<String> cacheName();
+
+  // The name of the implementation class.
+  public abstract Optional<String> className();
+
+  // The numeric ID of a change.
+  public abstract Optional<Integer> changeId();
+
+  // The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
+  public abstract Optional<String> changeIdType();
+
+  // The type of an event.
+  public abstract Optional<String> eventType();
+
+  // The value of the @Export annotation which was used to register a plugin extension.
+  public abstract Optional<String> exportValue();
+
+  // Path of a file in a repository.
+  public abstract Optional<String> filePath();
+
+  // Garbage collector name.
+  public abstract Optional<String> garbageCollectorName();
+
+  // Git operation (CLONE, FETCH).
+  public abstract Optional<String> gitOperation();
+
+  // The numeric ID of an internal group.
+  public abstract Optional<Integer> groupId();
+
+  // The name of a group.
+  public abstract Optional<String> groupName();
+
+  // The UUID of a group.
+  public abstract Optional<String> groupUuid();
+
+  // HTTP status response code.
+  public abstract Optional<Integer> httpStatus();
+
+  // The name of a secondary index.
+  public abstract Optional<String> indexName();
+
+  // The version of a secondary index.
+  public abstract Optional<Integer> indexVersion();
+
+  // The name of the implementation method.
+  public abstract Optional<String> methodName();
+
+  // One or more resources
+  public abstract Optional<Boolean> multiple();
+
+  // Partial or full computation
+  public abstract Optional<Boolean> partial();
+
+  // Path of a metadata file in NoteDb.
+  public abstract Optional<String> noteDbFilePath();
+
+  // Name of a metadata ref in NoteDb.
+  public abstract Optional<String> noteDbRefName();
+
+  // Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS).
+  public abstract Optional<String> noteDbSequenceType();
+
+  // Name of a "table" in NoteDb (if set, always CHANGES).
+  public abstract Optional<String> noteDbTable();
+
+  // The ID of a patch set.
+  public abstract Optional<Integer> patchSetId();
+
+  // Plugin metadata that doesn't fit into any other category.
+  public abstract ImmutableList<PluginMetadata> pluginMetadata();
+
+  // The name of a plugin.
+  public abstract Optional<String> pluginName();
+
+  // The name of a Gerrit project (aka Git repository).
+  public abstract Optional<String> projectName();
+
+  // The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE).
+  public abstract Optional<String> pushType();
+
+  // The number of resources that is processed.
+  public abstract Optional<Integer> resourceCount();
+
+  // The name of a REST view.
+  public abstract Optional<String> restViewName();
+
+  // The SHA1 of Git commit.
+  public abstract Optional<String> revision();
+
+  // The username of an account.
+  public abstract Optional<String> username();
+
+  public static Metadata.Builder builder() {
+    return new AutoValue_Metadata.Builder();
+  }
+
+  public static Metadata empty() {
+    return builder().build();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder accountId(int accountId);
+
+    public abstract Builder actionType(@Nullable String actionType);
+
+    public abstract Builder authDomainName(@Nullable String authDomainName);
+
+    public abstract Builder branchName(@Nullable String branchName);
+
+    public abstract Builder cacheKey(@Nullable String cacheKey);
+
+    public abstract Builder cacheName(@Nullable String cacheName);
+
+    public abstract Builder className(@Nullable String className);
+
+    public abstract Builder changeId(int changeId);
+
+    public abstract Builder changeIdType(@Nullable String changeIdType);
+
+    public abstract Builder eventType(@Nullable String eventType);
+
+    public abstract Builder exportValue(@Nullable String exportValue);
+
+    public abstract Builder filePath(@Nullable String filePath);
+
+    public abstract Builder garbageCollectorName(@Nullable String garbageCollectorName);
+
+    public abstract Builder gitOperation(@Nullable String gitOperation);
+
+    public abstract Builder groupId(int groupId);
+
+    public abstract Builder groupName(@Nullable String groupName);
+
+    public abstract Builder groupUuid(@Nullable String groupUuid);
+
+    public abstract Builder httpStatus(int httpStatus);
+
+    public abstract Builder indexName(@Nullable String indexName);
+
+    public abstract Builder indexVersion(int indexVersion);
+
+    public abstract Builder methodName(@Nullable String methodName);
+
+    public abstract Builder multiple(boolean multiple);
+
+    public abstract Builder partial(boolean partial);
+
+    public abstract Builder noteDbFilePath(@Nullable String noteDbFilePath);
+
+    public abstract Builder noteDbRefName(@Nullable String noteDbRefName);
+
+    public abstract Builder noteDbSequenceType(@Nullable String noteDbSequenceType);
+
+    public abstract Builder noteDbTable(@Nullable String noteDbTable);
+
+    public abstract Builder patchSetId(int patchSetId);
+
+    abstract ImmutableList.Builder<PluginMetadata> pluginMetadataBuilder();
+
+    public Builder addPluginMetadata(PluginMetadata pluginMetadata) {
+      pluginMetadataBuilder().add(pluginMetadata);
+      return this;
+    }
+
+    public abstract Builder pluginName(@Nullable String pluginName);
+
+    public abstract Builder projectName(@Nullable String projectName);
+
+    public abstract Builder pushType(@Nullable String pushType);
+
+    public abstract Builder resourceCount(int resourceCount);
+
+    public abstract Builder restViewName(@Nullable String restViewName);
+
+    public abstract Builder revision(@Nullable String revision);
+
+    public abstract Builder username(@Nullable String username);
+
+    public abstract Metadata build();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
new file mode 100644
index 0000000..4ee70d7
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.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.server.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for performance log records.
+ *
+ * <p>This class is intended to keep track of performance log records in {@link LoggingContext}. It
+ * needs to be thread-safe because it gets shared between threads when the logging context is copied
+ * to another thread (see {@link LoggingContextAwareRunnable} and {@link
+ * LoggingContextAwareCallable}. In this case the logging contexts of both threads share the same
+ * instance of this class. This is important since performance log records are processed only at the
+ * end of a request and performance log records that are created in another thread should not get
+ * lost.
+ */
+public class MutablePerformanceLogRecords {
+  private final ArrayList<PerformanceLogRecord> performanceLogRecords = new ArrayList<>();
+
+  public synchronized void add(PerformanceLogRecord record) {
+    performanceLogRecords.add(record);
+  }
+
+  public synchronized void set(List<PerformanceLogRecord> records) {
+    performanceLogRecords.clear();
+    performanceLogRecords.addAll(records);
+  }
+
+  public synchronized ImmutableList<PerformanceLogRecord> list() {
+    return ImmutableList.copyOf(performanceLogRecords);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("performanceLogRecords", performanceLogRecords)
+        .toString();
+  }
+}
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..83009a6
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableTags.java
@@ -0,0 +1,120 @@
+// 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.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+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) {
+    requireNonNull(name, "tag name is required");
+    requireNonNull(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) {
+    requireNonNull(name, "tag name is required");
+    requireNonNull(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();
+  }
+
+  @Override
+  public String toString() {
+    buildTags();
+    return MoreObjects.toStringHelper(this).add("tags", tags).toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
new file mode 100644
index 0000000..b6dafdc
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Context for capturing performance log records. When the context is closed the performance log
+ * records are handed over to the registered {@link PerformanceLogger}s.
+ *
+ * <p>Capturing performance log records is disabled if there are no {@link PerformanceLogger}
+ * registered (in this case the captured performance log records would never be used).
+ *
+ * <p>It's important to enable capturing of performance log records in a context that ensures to
+ * consume the captured performance log records. Otherwise captured performance log records might
+ * leak into other requests that are executed by the same thread (if a thread pool is used to
+ * process requests).
+ */
+public class PerformanceLogContext implements AutoCloseable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  // Do not use PluginSetContext. PluginSetContext traces the plugin latency with a timer metric
+  // which would result in a performance log and we don't want to log the performance of writing
+  // a performance log in the performance log (endless loop).
+  private final DynamicSet<PerformanceLogger> performanceLoggers;
+
+  private final boolean oldPerformanceLogging;
+  private final ImmutableList<PerformanceLogRecord> oldPerformanceLogRecords;
+
+  public PerformanceLogContext(
+      Config gerritConfig, DynamicSet<PerformanceLogger> performanceLoggers) {
+    this.performanceLoggers = performanceLoggers;
+
+    // Just in case remember the old state and reset performance log entries.
+    this.oldPerformanceLogging = LoggingContext.getInstance().isPerformanceLogging();
+    this.oldPerformanceLogRecords = LoggingContext.getInstance().getPerformanceLogRecords();
+    LoggingContext.getInstance().clearPerformanceLogEntries();
+
+    // Do not create performance log entries if performance logging is disabled or if no
+    // PerformanceLogger is registered.
+    boolean enablePerformanceLogging =
+        gerritConfig.getBoolean("tracing", "performanceLogging", true);
+    LoggingContext.getInstance()
+        .performanceLogging(
+            enablePerformanceLogging && !Iterables.isEmpty(performanceLoggers.entries()));
+  }
+
+  @Override
+  public void close() {
+    if (LoggingContext.getInstance().isPerformanceLogging()) {
+      runEach(performanceLoggers, LoggingContext.getInstance().getPerformanceLogRecords());
+    }
+
+    // Restore old state. Required to support nesting of PerformanceLogContext's.
+    LoggingContext.getInstance().performanceLogging(oldPerformanceLogging);
+    LoggingContext.getInstance().setPerformanceLogRecords(oldPerformanceLogRecords);
+  }
+
+  /**
+   * Invokes all performance loggers.
+   *
+   * <p>Similar to how {@code com.google.gerrit.server.plugincontext.PluginContext} invokes plugins
+   * but without recording metrics for invoking {@link PerformanceLogger}s.
+   *
+   * @param performanceLoggers the performance loggers that should be invoked
+   * @param performanceLogRecords the performance log records that should be handed over to the
+   *     performance loggers
+   */
+  private static void runEach(
+      DynamicSet<PerformanceLogger> performanceLoggers,
+      ImmutableList<PerformanceLogRecord> performanceLogRecords) {
+    performanceLoggers
+        .entries()
+        .forEach(
+            p -> {
+              try (TraceContext traceContext = newPluginTrace(p)) {
+                performanceLogRecords.forEach(r -> r.writeTo(p.get()));
+              } catch (Throwable e) {
+                logger.atWarning().withCause(e).log(
+                    "Failure in %s of plugin %s", p.get().getClass(), p.getPluginName());
+              }
+            });
+  }
+
+  /**
+   * Opens a trace context for a plugin that implements {@link PerformanceLogger}.
+   *
+   * <p>Basically the same as {@code
+   * com.google.gerrit.server.plugincontext.PluginContext#newTrace(Extension<T>)}. We have this
+   * method here to avoid a dependency on PluginContext which lives in
+   * "//java/com/google/gerrit/server". This package ("//java/com/google/gerrit/server/logging")
+   * should have as few dependencies as possible.
+   *
+   * @param extension performance logger extension
+   * @return the trace context
+   */
+  private static TraceContext newPluginTrace(Extension<PerformanceLogger> extension) {
+    return TraceContext.open().addPluginTag(extension.getPluginName());
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
new file mode 100644
index 0000000..046eeb3
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.auto.value.AutoValue;
+import java.util.Optional;
+
+/**
+ * The record of an operation for which the execution time was measured.
+ *
+ * <p>Metadata to provide additional context can be included by providing a {@link Metadata}
+ * instance.
+ */
+@AutoValue
+public abstract class PerformanceLogRecord {
+  /**
+   * Creates a performance log record without meta data.
+   *
+   * @param operation the name of operation the is was performed
+   * @param durationMs the execution time in milliseconds
+   * @return the performance log record
+   */
+  public static PerformanceLogRecord create(String operation, long durationMs) {
+    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.empty());
+  }
+
+  /**
+   * Creates a performance log record with meta data.
+   *
+   * @param operation the name of operation the is was performed
+   * @param durationMs the execution time in milliseconds
+   * @param metadata metadata
+   * @return the performance log record
+   */
+  public static PerformanceLogRecord create(String operation, long durationMs, Metadata metadata) {
+    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.of(metadata));
+  }
+
+  public abstract String operation();
+
+  public abstract long durationMs();
+
+  public abstract Optional<Metadata> metadata();
+
+  void writeTo(PerformanceLogger performanceLogger) {
+    if (metadata().isPresent()) {
+      performanceLogger.log(operation(), durationMs(), metadata().get());
+    } else {
+      performanceLogger.log(operation(), durationMs());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogger.java b/java/com/google/gerrit/server/logging/PerformanceLogger.java
new file mode 100644
index 0000000..74a1684
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PerformanceLogger.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Extension point for logging performance records.
+ *
+ * <p>This extension point is invoked for all operations for which the execution time is measured.
+ * The invocation of the extension point does not happen immediately, but only at the end of a
+ * request (REST call, SSH call, git push). Implementors can write the execution times into a
+ * performance log for further analysis.
+ *
+ * <p>For optimal performance implementors should overwrite the default <code>log</code> methods to
+ * avoid an unneeded instantiation of Metadata.
+ */
+@ExtensionPoint
+public interface PerformanceLogger {
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   */
+  default void log(String operation, long durationMs) {
+    log(operation, durationMs, Metadata.empty());
+  }
+
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   * @param metadata metadata
+   */
+  void log(String operation, long durationMs, Metadata metadata);
+}
diff --git a/java/com/google/gerrit/server/logging/PluginMetadata.java b/java/com/google/gerrit/server/logging/PluginMetadata.java
new file mode 100644
index 0000000..21f7359
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PluginMetadata.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
+/**
+ * Key-value pair for custom metadata that is provided by plugins.
+ *
+ * <p>PluginMetadata allows plugins to include custom metadata into the {@link Metadata} instances
+ * that are provided as context for performance tracing.
+ *
+ * <p>Plugins should use PluginMetadata only for metadata kinds that are not known to Gerrit core
+ * (metadata for which {@link Metadata} doesn't have a dedicated field).
+ */
+@AutoValue
+public abstract class PluginMetadata {
+  public static PluginMetadata create(String key, @Nullable String value) {
+    return new AutoValue_PluginMetadata(key, Optional.ofNullable(value));
+  }
+
+  public abstract String key();
+
+  public abstract Optional<String> value();
+}
diff --git a/java/com/google/gerrit/server/logging/RequestId.java b/java/com/google/gerrit/server/logging/RequestId.java
new file mode 100644
index 0000000..ceb5da0
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/RequestId.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.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.server.util.time.TimeUtil;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+/** Unique identifier for an end-user request, used in logs and similar. */
+public class RequestId {
+  private static final String MACHINE_ID;
+
+  static {
+    String id;
+    try {
+      id = InetAddress.getLocalHost().getHostAddress();
+    } catch (UnknownHostException e) {
+      id = "unknown";
+    }
+    MACHINE_ID = id;
+  }
+
+  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 boolean isSet() {
+    return LoggingContext.getInstance().getTagsAsMap().keySet().stream().anyMatch(Type::isId);
+  }
+
+  private final String str;
+
+  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 != null ? resourceId + "-" : "")
+            + TimeUtil.nowTs().getTime()
+            + "-"
+            + h.hash().toString().substring(0, 8);
+  }
+
+  @Override
+  public String toString() {
+    return str;
+  }
+
+  public String toStringForStorage() {
+    return str.substring(1, str.length() - 1);
+  }
+}
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..06db7b4
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -0,0 +1,266 @@
+// 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.Objects.requireNonNull;
+
+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 {
+  private static final String PLUGIN_TAG = "PLUGIN";
+
+  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.
+   *
+   * @param operation the name of operation the is being performed
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(String operation) {
+    return new TraceTimer(requireNonNull(operation, "operation is required"));
+  }
+
+  /**
+   * Opens a new timer that logs the time for an operation if request tracing is enabled.
+   *
+   * @param operation the name of operation the is being performed
+   * @param metadata metadata
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(String operation, Metadata metadata) {
+    return new TraceTimer(
+        requireNonNull(operation, "operation is required"),
+        requireNonNull(metadata, "metadata is required"));
+  }
+
+  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 operation) {
+      this(
+          elapsedMs -> {
+            LoggingContext.getInstance()
+                .addPerformanceLogRecord(() -> PerformanceLogRecord.create(operation, elapsedMs));
+            logger.atFine().log("%s (%d ms)", operation, elapsedMs);
+          });
+    }
+
+    private TraceTimer(String operation, Metadata metadata) {
+      this(
+          elapsedMs -> {
+            LoggingContext.getInstance()
+                .addPerformanceLogRecord(
+                    () -> PerformanceLogRecord.create(operation, elapsedMs, metadata));
+            logger.atFine().log("%s (%s) (%d ms)", operation, metadata, 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(requireNonNull(requestId, "request ID is required").name(), tagValue);
+  }
+
+  public TraceContext addTag(String tagName, Object tagValue) {
+    String name = requireNonNull(tagName, "tag name is required");
+    String value = requireNonNull(tagValue, "tag value is required").toString();
+    tags.put(name, value, LoggingContext.getInstance().addTag(name, value));
+    return this;
+  }
+
+  public TraceContext addPluginTag(String pluginName) {
+    return addTag(PLUGIN_TAG, pluginName);
+  }
+
+  public TraceContext forceLogging() {
+    if (stopForceLoggingOnClose) {
+      return this;
+    }
+
+    stopForceLoggingOnClose = !LoggingContext.getInstance().forceLogging(true);
+    return this;
+  }
+
+  public boolean isTracing() {
+    return LoggingContext.getInstance().isLoggingForced();
+  }
+
+  public Optional<String> getTraceId() {
+    return LoggingContext.getInstance().getTagsAsMap().get(RequestId.Type.TRACE_ID.name()).stream()
+        .findFirst();
+  }
+
+  @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/Address.java b/java/com/google/gerrit/server/mail/Address.java
deleted file mode 100644
index e91f3f3..0000000
--- a/java/com/google/gerrit/server/mail/Address.java
+++ /dev/null
@@ -1,139 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import com.google.gerrit.server.mail.send.EmailHeader;
-
-public class Address {
-  public static Address parse(String in) {
-    final int lt = in.indexOf('<');
-    final int gt = in.indexOf('>');
-    final int at = in.indexOf("@");
-    if (0 <= lt && lt < gt && lt + 1 < at && at + 1 < gt) {
-      final String email = in.substring(lt + 1, gt).trim();
-      final String name = in.substring(0, lt).trim();
-      int nameStart = 0;
-      int nameEnd = name.length();
-      if (name.startsWith("\"")) {
-        nameStart++;
-      }
-      if (name.endsWith("\"")) {
-        nameEnd--;
-      }
-      return new Address(name.length() > 0 ? name.substring(nameStart, nameEnd) : null, email);
-    }
-
-    if (lt < 0 && gt < 0 && 0 < at && at < in.length() - 1) {
-      return new Address(in);
-    }
-
-    throw new IllegalArgumentException("Invalid email address: " + in);
-  }
-
-  public static Address tryParse(String in) {
-    try {
-      return parse(in);
-    } catch (IllegalArgumentException e) {
-      return null;
-    }
-  }
-
-  final String name;
-  final String email;
-
-  public Address(String email) {
-    this(null, email);
-  }
-
-  public Address(String name, String email) {
-    this.name = name;
-    this.email = email;
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public String getEmail() {
-    return email;
-  }
-
-  @Override
-  public int hashCode() {
-    return email.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other instanceof Address) {
-      return email.equals(((Address) other).email);
-    }
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    return toHeaderString();
-  }
-
-  public String toHeaderString() {
-    if (name != null) {
-      return quotedPhrase(name) + " <" + email + ">";
-    } else if (isSimple()) {
-      return email;
-    }
-    return "<" + email + ">";
-  }
-
-  private static final String MUST_QUOTE_EMAIL = "()<>,;:\\\"[]";
-  private static final String MUST_QUOTE_NAME = MUST_QUOTE_EMAIL + "@.";
-
-  private boolean isSimple() {
-    for (int i = 0; i < email.length(); i++) {
-      final char c = email.charAt(i);
-      if (c <= ' ' || 0x7F <= c || MUST_QUOTE_EMAIL.indexOf(c) != -1) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  private static String quotedPhrase(String name) {
-    if (EmailHeader.needsQuotedPrintable(name)) {
-      return EmailHeader.quotedPrintable(name);
-    }
-    for (int i = 0; i < name.length(); i++) {
-      final char c = name.charAt(i);
-      if (MUST_QUOTE_NAME.indexOf(c) != -1) {
-        return wrapInQuotes(name);
-      }
-    }
-    return name;
-  }
-
-  private static String wrapInQuotes(String name) {
-    final StringBuilder r = new StringBuilder(2 + name.length());
-    r.append('"');
-    for (int i = 0; i < name.length(); i++) {
-      char c = name.charAt(i);
-      if (c == '"' || c == '\\') {
-        r.append('\\');
-      }
-      r.append(c);
-    }
-    r.append('"');
-    return r.toString();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java b/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
index 9032932..e18fd42 100644
--- a/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
+++ b/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
@@ -15,7 +15,8 @@
 package com.google.gerrit.server.mail;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.mail.MailMessage;
 import com.google.inject.Singleton;
 
 /** Filters out auto-reply messages according to RFC 3834. */
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index 9bf97dd..cc3db75 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -16,20 +16,38 @@
 
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.mail.send.CommentSender;
+import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
+import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.mail.send.RevertedSender;
+import com.google.gerrit.server.mail.send.SetAssigneeSender;
 
 public class EmailModule extends FactoryModule {
   @Override
   protected void configure() {
     factory(AbandonedSender.Factory.class);
+    factory(AddKeySender.Factory.class);
+    factory(AddReviewerSender.Factory.class);
     factory(CommentSender.Factory.class);
+    factory(CreateChangeSender.Factory.class);
+    factory(DeleteKeySender.Factory.class);
     factory(DeleteReviewerSender.Factory.class);
     factory(DeleteVoteSender.Factory.class);
+    factory(HttpPasswordUpdateSender.Factory.class);
+    factory(MergedSender.Factory.class);
+    factory(RegisterNewEmailSender.Factory.class);
+    factory(ReplacePatchSetSender.Factory.class);
     factory(RestoredSender.Factory.class);
     factory(RevertedSender.Factory.class);
+    factory(SetAssigneeSender.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/ListMailFilter.java b/java/com/google/gerrit/server/mail/ListMailFilter.java
index 5a41c77..1549f8d 100644
--- a/java/com/google/gerrit/server/mail/ListMailFilter.java
+++ b/java/com/google/gerrit/server/mail/ListMailFilter.java
@@ -17,8 +17,8 @@
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.receive.MailMessage;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Arrays;
@@ -52,8 +52,9 @@
       return true;
     }
 
-    boolean match = mailPattern.matcher(message.from().email).find();
-    if (mode == ListFilterMode.WHITELIST && !match || mode == ListFilterMode.BLACKLIST && match) {
+    boolean match = mailPattern.matcher(message.from().getEmail()).find();
+    if ((mode == ListFilterMode.WHITELIST && !match)
+        || (mode == ListFilterMode.BLACKLIST && match)) {
       logger.atInfo().log("Mail message from %s rejected by list filter", message.from());
       return false;
     }
diff --git a/java/com/google/gerrit/server/mail/MailFilter.java b/java/com/google/gerrit/server/mail/MailFilter.java
index d50064d..5fff8a3 100644
--- a/java/com/google/gerrit/server/mail/MailFilter.java
+++ b/java/com/google/gerrit/server/mail/MailFilter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.mail;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
 
 /**
  * Listener to filter incoming email.
diff --git a/java/com/google/gerrit/server/mail/MailHeader.java b/java/com/google/gerrit/server/mail/MailHeader.java
deleted file mode 100644
index cf145e5..0000000
--- a/java/com/google/gerrit/server/mail/MailHeader.java
+++ /dev/null
@@ -1,71 +0,0 @@
-// 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.mail;
-
-/** Variables used by emails to hold data */
-public enum MailHeader {
-  // Gerrit metadata holders
-  ASSIGNEE("Gerrit-Assignee"),
-  BRANCH("Gerrit-Branch"),
-  CC("Gerrit-CC"),
-  COMMENT_IN_REPLY_TO("Comment-In-Reply-To"),
-  COMMENT_DATE("Gerrit-Comment-Date"),
-  CHANGE_ID("Gerrit-Change-Id"),
-  CHANGE_NUMBER("Gerrit-Change-Number"),
-  CHANGE_URL("Gerrit-ChangeURL"),
-  COMMIT("Gerrit-Commit"),
-  HAS_COMMENTS("Gerrit-HasComments"),
-  HAS_LABELS("Gerrit-Has-Labels"),
-  MESSAGE_TYPE("Gerrit-MessageType"),
-  OWNER("Gerrit-Owner"),
-  PATCH_SET("Gerrit-PatchSet"),
-  PROJECT("Gerrit-Project"),
-  REVIEWER("Gerrit-Reviewer"),
-
-  // Commonly used Email headers
-  AUTO_SUBMITTED("Auto-Submitted"),
-  PRECEDENCE("Precedence"),
-  REFERENCES("References");
-
-  private final String name;
-  private final String fieldName;
-
-  MailHeader(String name) {
-    boolean customHeader = name.startsWith("Gerrit-");
-    this.name = name;
-
-    if (customHeader) {
-      this.fieldName = "X-" + name;
-    } else {
-      this.fieldName = name;
-    }
-  }
-
-  public String fieldWithDelimiter() {
-    return fieldName() + ": ";
-  }
-
-  public String withDelimiter() {
-    return name + ": ";
-  }
-
-  public String fieldName() {
-    return fieldName;
-  }
-
-  public String getName() {
-    return name;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/MailUtil.java b/java/com/google/gerrit/server/mail/MailUtil.java
index 0487cc0..26ebb5c 100644
--- a/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/java/com/google/gerrit/server/mail/MailUtil.java
@@ -18,28 +18,25 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.errors.NoSuchAccountException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
-import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.FooterLine;
 
 public class MailUtil {
-  public static DateTimeFormatter rfcDateformatter =
-      DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
 
   public static MailRecipients getRecipientsFromFooters(
       AccountResolver accountResolver, List<FooterLine> footerLines)
-      throws OrmException, IOException {
+      throws IOException, ConfigInvalidException {
     MailRecipients recipients = new MailRecipients();
     for (FooterLine footerLine : footerLines) {
       try {
@@ -48,7 +45,7 @@
         } else if (footerLine.matches(FooterKey.CC)) {
           recipients.cc.add(toAccountId(accountResolver, footerLine.getValue().trim()));
         }
-      } catch (NoSuchAccountException e) {
+      } catch (UnprocessableEntityException e) {
         continue;
       }
     }
@@ -62,13 +59,10 @@
     return recipients;
   }
 
+  @SuppressWarnings("deprecation")
   private static Account.Id toAccountId(AccountResolver accountResolver, String nameOrEmail)
-      throws OrmException, NoSuchAccountException, IOException {
-    Account a = accountResolver.findByNameOrEmail(nameOrEmail);
-    if (a == null) {
-      throw new NoSuchAccountException("\"" + nameOrEmail + "\" is not registered");
-    }
-    return a.getId();
+      throws UnprocessableEntityException, IOException, ConfigInvalidException {
+    return accountResolver.resolveByNameOrEmail(nameOrEmail).asUnique().getAccount().id();
   }
 
   private static boolean isReviewer(FooterLine candidateFooterLine) {
@@ -127,7 +121,7 @@
       return Pattern.compile(".*");
     }
 
-    StringBuilder sb = new StringBuilder("");
+    StringBuilder sb = new StringBuilder();
     for (String domain : domains) {
       String quoted = "\\Q" + domain.replace("\\E", "\\E\\\\E\\Q") + "\\E|";
       sb.append(quoted.replace("*", "\\E.*\\Q"));
diff --git a/java/com/google/gerrit/server/mail/SignedToken.java b/java/com/google/gerrit/server/mail/SignedToken.java
new file mode 100644
index 0000000..436b854
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/SignedToken.java
@@ -0,0 +1,213 @@
+// Copyright 2008 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import javax.crypto.Mac;
+import javax.crypto.ShortBufferException;
+import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.codec.binary.Base64;
+
+/**
+ * Utility function to compute and verify XSRF tokens.
+ *
+ * <p>{@link SignedTokenEmailTokenVerifier} uses this class to verify tokens appearing in the custom
+ * <code>xsrfKey
+ * </code> JSON request property. The tokens protect against cross-site request forgery by depending
+ * upon the browser's security model. The classic browser security model prohibits a script from
+ * site A from reading any data received from site B. By sending unforgeable tokens from the server
+ * and asking the client to return them to us, the client script must have had read access to the
+ * token at some point and is therefore also from our server.
+ */
+public class SignedToken {
+  private static final int INT_SZ = 4;
+  private static final String MAC_ALG = "HmacSHA1";
+
+  /**
+   * Generate a random key for use with the XSRF library.
+   *
+   * @return a new private key, base 64 encoded.
+   */
+  public static String generateRandomKey() {
+    final byte[] r = new byte[26];
+    new SecureRandom().nextBytes(r);
+    return encodeBase64(r);
+  }
+
+  private final int maxAge;
+  private final SecretKeySpec key;
+  private final SecureRandom rng;
+  private final int tokenLength;
+
+  /**
+   * Create a new utility, using the specific key.
+   *
+   * @param age the number of seconds a token may remain valid.
+   * @param keyBase64 base 64 encoded representation of the key.
+   * @throws XsrfException the JVM doesn't support the necessary algorithms.
+   */
+  public SignedToken(final int age, final String keyBase64) throws XsrfException {
+    maxAge = age > 5 ? age / 5 : age;
+    key = new SecretKeySpec(decodeBase64(keyBase64), MAC_ALG);
+    rng = new SecureRandom();
+    tokenLength = 2 * INT_SZ + newMac().getMacLength();
+  }
+
+  /**
+   * Generate a new signed token.
+   *
+   * @param text the text string to sign. Typically this should be some user-specific string, to
+   *     prevent replay attacks. The text must be safe to appear in whatever context the token
+   *     itself will appear, as the text is included on the end of the token.
+   * @return the signed token. The text passed in <code>text</code> will appear after the first ','
+   *     in the returned token string.
+   * @throws XsrfException the JVM doesn't support the necessary algorithms.
+   */
+  String newToken(final String text) throws XsrfException {
+    final int q = rng.nextInt();
+    final byte[] buf = new byte[tokenLength];
+    encodeInt(buf, 0, q);
+    encodeInt(buf, INT_SZ, now() ^ q);
+    computeToken(buf, text);
+    return encodeBase64(buf) + '$' + text;
+  }
+
+  /**
+   * Validate a returned token.
+   *
+   * @param tokenString a token string previously created by this class.
+   * @param text text that must have been used during {@link #newToken(String)} in order for the
+   *     token to be valid. If null the text will be taken from the token string itself.
+   * @return true if the token is valid; false if the token is null, the empty string, has expired,
+   *     does not match the text supplied, or is a forged token.
+   * @throws XsrfException the JVM doesn't support the necessary algorithms to generate a token.
+   *     XSRF services are simply not available.
+   */
+  ValidToken checkToken(final String tokenString, final String text) throws XsrfException {
+    if (tokenString == null || tokenString.length() == 0) {
+      return null;
+    }
+
+    final int s = tokenString.indexOf('$');
+    if (s <= 0) {
+      return null;
+    }
+
+    final String recvText = tokenString.substring(s + 1);
+    final byte[] in;
+    try {
+      in = decodeBase64(tokenString.substring(0, s));
+    } catch (RuntimeException e) {
+      return null;
+    }
+    if (in.length != tokenLength) {
+      return null;
+    }
+
+    final int q = decodeInt(in, 0);
+    final int c = decodeInt(in, INT_SZ) ^ q;
+    final int n = now();
+    if (maxAge > 0 && Math.abs(c - n) > maxAge) {
+      return null;
+    }
+
+    final byte[] gen = new byte[tokenLength];
+    System.arraycopy(in, 0, gen, 0, 2 * INT_SZ);
+    computeToken(gen, text != null ? text : recvText);
+    if (!Arrays.equals(gen, in)) {
+      return null;
+    }
+
+    return new ValidToken(maxAge > 0 && c + (maxAge >> 1) <= n, recvText);
+  }
+
+  private void computeToken(final byte[] buf, final String text) throws XsrfException {
+    final Mac m = newMac();
+    m.update(buf, 0, 2 * INT_SZ);
+    m.update(toBytes(text));
+    try {
+      m.doFinal(buf, 2 * INT_SZ);
+    } catch (ShortBufferException e) {
+      throw new XsrfException("Unexpected token overflow", e);
+    }
+  }
+
+  private Mac newMac() throws XsrfException {
+    try {
+      final Mac m = Mac.getInstance(MAC_ALG);
+      m.init(key);
+      return m;
+    } catch (NoSuchAlgorithmException e) {
+      throw new XsrfException(MAC_ALG + " not supported", e);
+    } catch (InvalidKeyException e) {
+      throw new XsrfException("Invalid private key", e);
+    }
+  }
+
+  private static int now() {
+    return (int) (System.currentTimeMillis() / 5000L);
+  }
+
+  private static byte[] decodeBase64(final String s) {
+    return Base64.decodeBase64(toBytes(s));
+  }
+
+  private static String encodeBase64(final byte[] buf) {
+    return toString(Base64.encodeBase64(buf));
+  }
+
+  private static void encodeInt(final byte[] buf, final int o, final int v) {
+    int _v = v;
+    buf[o + 3] = (byte) _v;
+    _v >>>= 8;
+
+    buf[o + 2] = (byte) _v;
+    _v >>>= 8;
+
+    buf[o + 1] = (byte) _v;
+    _v >>>= 8;
+
+    buf[o] = (byte) _v;
+  }
+
+  private static int decodeInt(final byte[] buf, final int o) {
+    int r = buf[o] << 8;
+
+    r |= buf[o + 1] & 0xff;
+    r <<= 8;
+
+    r |= buf[o + 2] & 0xff;
+    return (r << 8) | (buf[o + 3] & 0xff);
+  }
+
+  private static byte[] toBytes(final String s) {
+    final byte[] r = new byte[s.length()];
+    for (int k = r.length - 1; k >= 0; k--) {
+      r[k] = (byte) s.charAt(k);
+    }
+    return r;
+  }
+
+  private static String toString(final byte[] b) {
+    final StringBuilder r = new StringBuilder(b.length);
+    for (int i = 0; i < b.length; i++) {
+      r.append((char) b[i]);
+    }
+    return r.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index 66fe07e..af492f1 100644
--- a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -20,9 +20,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
-import com.google.gwtjsonrpc.server.SignedToken;
-import com.google.gwtjsonrpc.server.ValidToken;
-import com.google.gwtjsonrpc.server.XsrfException;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/mail/ValidToken.java b/java/com/google/gerrit/server/mail/ValidToken.java
new file mode 100644
index 0000000..19d6cb0
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/ValidToken.java
@@ -0,0 +1,36 @@
+// Copyright 2008 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+/** A validated token from {@link SignedToken#checkToken(String, String)} */
+class ValidToken {
+  private final boolean refresh;
+  private final String data;
+
+  ValidToken(final boolean ref, final String d) {
+    refresh = ref;
+    data = d;
+  }
+
+  /** The text protected by the token's encryption key. */
+  String getData() {
+    return data;
+  }
+
+  /** True if the token's life span is almost half-over and should be renewed. */
+  boolean needsRefresh() {
+    return refresh;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/XsrfException.java b/java/com/google/gerrit/server/mail/XsrfException.java
new file mode 100644
index 0000000..eda17c3
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/XsrfException.java
@@ -0,0 +1,24 @@
+// Copyright 2008 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+/** Indicates the requested method is not known. */
+public class XsrfException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  XsrfException(final String message, final Throwable why) {
+    super(message, why);
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/receive/HtmlParser.java b/java/com/google/gerrit/server/mail/receive/HtmlParser.java
deleted file mode 100644
index d68f076..0000000
--- a/java/com/google/gerrit/server/mail/receive/HtmlParser.java
+++ /dev/null
@@ -1,173 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterators;
-import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import org.jsoup.Jsoup;
-import org.jsoup.nodes.Document;
-import org.jsoup.nodes.Element;
-
-/** Provides functionality for parsing the HTML part of a {@link MailMessage}. */
-public class HtmlParser {
-
-  private static final ImmutableSet<String> MAIL_PROVIDER_EXTRAS =
-      ImmutableSet.of(
-          "gmail_extra", // "On 01/01/2017 User<user@gmail.com> wrote:"
-          "gmail_quote" // Used for quoting original content
-          );
-
-  private static final ImmutableSet<String> WHITELISTED_HTML_TAGS =
-      ImmutableSet.of(
-          "div", // Most user-typed comments are contained in a <div> tag
-          "a", // We allow links to be contained in a comment
-          "font" // Some email clients like nesting input in a new font tag
-          );
-
-  private HtmlParser() {}
-
-  /**
-   * Parses comments from html email.
-   *
-   * <p>This parser goes though all html elements in the email and checks for matching patterns. It
-   * keeps track of the last file and comments it encountered to know in which context a parsed
-   * comment belongs. It uses the href attributes of <a> tags to identify comments sent out by
-   * Gerrit as these are generally more reliable then the text captions.
-   *
-   * @param email the message as received from the email service
-   * @param comments a specific set of comments as sent out in the original notification email.
-   *     Comments are expected to be in the same order as they were sent out to in the email.
-   * @param changeUrl canonical change URL that points to the change on this Gerrit instance.
-   *     Example: https://go-review.googlesource.com/#/c/91570
-   * @return list of MailComments parsed from the html part of the email
-   */
-  public static List<MailComment> parse(
-      MailMessage email, Collection<Comment> comments, String changeUrl) {
-    // TODO(hiesel) Add support for Gmail Mobile
-    // TODO(hiesel) Add tests for other popular email clients
-
-    // This parser goes though all html elements in the email and checks for
-    // matching patterns. It keeps track of the last file and comments it
-    // encountered to know in which context a parsed comment belongs.
-    // It uses the href attributes of <a> tags to identify comments sent out by
-    // Gerrit as these are generally more reliable then the text captions.
-    List<MailComment> parsedComments = new ArrayList<>();
-    Document d = Jsoup.parse(email.htmlContent());
-    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
-
-    String lastEncounteredFileName = null;
-    Comment lastEncounteredComment = null;
-    for (Element e : d.body().getAllElements()) {
-      String elementName = e.tagName();
-      boolean isInBlockQuote =
-          e.parents()
-              .stream()
-              .anyMatch(
-                  p ->
-                      p.tagName().equals("blockquote")
-                          || MAIL_PROVIDER_EXTRAS.contains(p.className()));
-
-      if (elementName.equals("a")) {
-        String href = e.attr("href");
-        // Check if there is still a next comment that could be contained in
-        // this <a> tag
-        if (!iter.hasNext()) {
-          continue;
-        }
-        Comment perspectiveComment = iter.peek();
-        if (href.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
-          if (lastEncounteredFileName == null
-              || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
-            // Not a file-level comment, but users could have typed a comment
-            // right after this file annotation to create a new file-level
-            // comment. If this file has a file-level comment, we have already
-            // set lastEncounteredComment to that file-level comment when we
-            // encountered the file link and should not reset it now.
-            lastEncounteredFileName = perspectiveComment.key.filename;
-            lastEncounteredComment = null;
-          } else if (perspectiveComment.lineNbr == 0) {
-            // This was originally a file-level comment
-            lastEncounteredComment = perspectiveComment;
-            iter.next();
-          }
-          continue;
-        } else if (ParserUtil.isCommentUrl(href, changeUrl, perspectiveComment)) {
-          // This is a regular inline comment
-          lastEncounteredComment = perspectiveComment;
-          iter.next();
-          continue;
-        }
-      }
-
-      if (isInBlockQuote) {
-        // There is no user-input in quoted text
-        continue;
-      }
-      if (!WHITELISTED_HTML_TAGS.contains(elementName)) {
-        // We only accept a set of whitelisted tags that can contain user input
-        continue;
-      }
-      if (elementName.equals("a") && e.attr("href").startsWith("mailto:")) {
-        // We don't accept mailto: links in general as they often appear in reply-to lines
-        // (User<user@gmail.com> wrote: ...)
-        continue;
-      }
-
-      // This is a comment typed by the user
-      // Replace non-breaking spaces and trim string
-      String content = e.ownText().replace('\u00a0', ' ').trim();
-      boolean isLink = elementName.equals("a");
-      if (!Strings.isNullOrEmpty(content)) {
-        if (lastEncounteredComment == null && lastEncounteredFileName == null) {
-          // Remove quotation line, email signature and
-          // "Sent from my xyz device"
-          content = ParserUtil.trimQuotation(content);
-          // TODO(hiesel) Add more sanitizer
-          if (!Strings.isNullOrEmpty(content)) {
-            ParserUtil.appendOrAddNewComment(
-                new MailComment(
-                    content, null, null, MailComment.CommentType.CHANGE_MESSAGE, isLink),
-                parsedComments);
-          }
-        } else if (lastEncounteredComment == null) {
-          ParserUtil.appendOrAddNewComment(
-              new MailComment(
-                  content,
-                  lastEncounteredFileName,
-                  null,
-                  MailComment.CommentType.FILE_COMMENT,
-                  isLink),
-              parsedComments);
-        } else {
-          ParserUtil.appendOrAddNewComment(
-              new MailComment(
-                  content,
-                  null,
-                  lastEncounteredComment,
-                  MailComment.CommentType.INLINE_COMMENT,
-                  isLink),
-              parsedComments);
-        }
-      }
-    }
-    return parsedComments;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java b/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
index 169b41e..648006d 100644
--- a/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
@@ -15,6 +15,9 @@
 package com.google.gerrit.server.mail.receive;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailParsingException;
+import com.google.gerrit.mail.RawMailParser;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.mail.Encryption;
diff --git a/java/com/google/gerrit/server/mail/receive/MailComment.java b/java/com/google/gerrit/server/mail/receive/MailComment.java
deleted file mode 100644
index 8571e12..0000000
--- a/java/com/google/gerrit/server/mail/receive/MailComment.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.Objects;
-
-/** A comment parsed from inbound email */
-public class MailComment {
-  enum CommentType {
-    CHANGE_MESSAGE,
-    FILE_COMMENT,
-    INLINE_COMMENT
-  }
-
-  CommentType type;
-  Comment inReplyTo;
-  String fileName;
-  String message;
-  boolean isLink;
-
-  public MailComment() {}
-
-  public MailComment(
-      String message, String fileName, Comment inReplyTo, CommentType type, boolean isLink) {
-    this.message = message;
-    this.fileName = fileName;
-    this.inReplyTo = inReplyTo;
-    this.type = type;
-    this.isLink = isLink;
-  }
-
-  /**
-   * Checks if the provided comment concerns the same exact spot in the change. This is basically an
-   * equals method except that the message is not checked.
-   */
-  public boolean isSameCommentPath(MailComment c) {
-    return Objects.equals(fileName, c.fileName)
-        && Objects.equals(inReplyTo, c.inReplyTo)
-        && Objects.equals(type, c.type);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/receive/MailHeaderParser.java b/java/com/google/gerrit/server/mail/receive/MailHeaderParser.java
deleted file mode 100644
index d176095..0000000
--- a/java/com/google/gerrit/server/mail/receive/MailHeaderParser.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.server.mail.MailHeader;
-import com.google.gerrit.server.mail.MailUtil;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.format.DateTimeParseException;
-
-/** Parse metadata from inbound email */
-public class MailHeaderParser {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static MailMetadata parse(MailMessage m) {
-    MailMetadata metadata = new MailMetadata();
-    // Find author
-    metadata.author = m.from().getEmail();
-
-    // Check email headers for X-Gerrit-<Name>
-    for (String header : m.additionalHeaders()) {
-      if (header.startsWith(MailHeader.CHANGE_NUMBER.fieldWithDelimiter())) {
-        String num = header.substring(MailHeader.CHANGE_NUMBER.fieldWithDelimiter().length());
-        metadata.changeNumber = Ints.tryParse(num);
-      } else if (header.startsWith(MailHeader.PATCH_SET.fieldWithDelimiter())) {
-        String ps = header.substring(MailHeader.PATCH_SET.fieldWithDelimiter().length());
-        metadata.patchSet = Ints.tryParse(ps);
-      } else if (header.startsWith(MailHeader.COMMENT_DATE.fieldWithDelimiter())) {
-        String ts = header.substring(MailHeader.COMMENT_DATE.fieldWithDelimiter().length()).trim();
-        try {
-          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
-        } catch (DateTimeParseException e) {
-          logger.atSevere().withCause(e).log(
-              "Mail: Error while parsing timestamp from header of message %s", m.id());
-        }
-      } else if (header.startsWith(MailHeader.MESSAGE_TYPE.fieldWithDelimiter())) {
-        metadata.messageType =
-            header.substring(MailHeader.MESSAGE_TYPE.fieldWithDelimiter().length());
-      }
-    }
-    if (metadata.hasRequiredFields()) {
-      return metadata;
-    }
-
-    // If the required fields were not yet found, continue to parse the text
-    if (!Strings.isNullOrEmpty(m.textContent())) {
-      Iterable<String> lines = Splitter.on('\n').split(m.textContent().replace("\r\n", "\n"));
-      extractFooters(lines, metadata, m);
-      if (metadata.hasRequiredFields()) {
-        return metadata;
-      }
-    }
-
-    // If the required fields were not yet found, continue to parse the HTML
-    // HTML footer are contained inside a <div> tag
-    if (!Strings.isNullOrEmpty(m.htmlContent())) {
-      Iterable<String> lines = Splitter.on("</div>").split(m.htmlContent().replace("\r\n", "\n"));
-      extractFooters(lines, metadata, m);
-      if (metadata.hasRequiredFields()) {
-        return metadata;
-      }
-    }
-
-    return metadata;
-  }
-
-  private static void extractFooters(Iterable<String> lines, MailMetadata metadata, MailMessage m) {
-    for (String line : lines) {
-      if (metadata.changeNumber == null && line.contains(MailHeader.CHANGE_NUMBER.getName())) {
-        metadata.changeNumber =
-            Ints.tryParse(extractFooter(MailHeader.CHANGE_NUMBER.withDelimiter(), line));
-      } else if (metadata.patchSet == null && line.contains(MailHeader.PATCH_SET.getName())) {
-        metadata.patchSet =
-            Ints.tryParse(extractFooter(MailHeader.PATCH_SET.withDelimiter(), line));
-      } else if (metadata.timestamp == null && line.contains(MailHeader.COMMENT_DATE.getName())) {
-        String ts = extractFooter(MailHeader.COMMENT_DATE.withDelimiter(), line);
-        try {
-          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
-        } catch (DateTimeParseException e) {
-          logger.atSevere().withCause(e).log(
-              "Mail: Error while parsing timestamp from footer of message %s", m.id());
-        }
-      } else if (metadata.messageType == null && line.contains(MailHeader.MESSAGE_TYPE.getName())) {
-        metadata.messageType = extractFooter(MailHeader.MESSAGE_TYPE.withDelimiter(), line);
-      }
-    }
-  }
-
-  private static String extractFooter(String key, String line) {
-    return line.substring(line.indexOf(key) + key.length(), line.length()).trim();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/receive/MailMessage.java b/java/com/google/gerrit/server/mail/receive/MailMessage.java
deleted file mode 100644
index 0d20464..0000000
--- a/java/com/google/gerrit/server/mail/receive/MailMessage.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.mail.Address;
-import java.time.Instant;
-
-/**
- * A simplified representation of an RFC 2045-2047 mime email message used for representing received
- * emails inside Gerrit. It is populated by the MailParser after MailReceiver has received a
- * message. Transformations done by the parser include stitching mime parts together, transforming
- * all content to UTF-16 and removing attachments.
- *
- * <p>A valid {@link MailMessage} contains at least the following fields: id, from, to, subject and
- * dateReceived.
- */
-@AutoValue
-public abstract class MailMessage {
-  // Unique Identifier
-  public abstract String id();
-  // Envelop Information
-  public abstract Address from();
-
-  public abstract ImmutableList<Address> to();
-
-  public abstract ImmutableList<Address> cc();
-  // Metadata
-  public abstract Instant dateReceived();
-
-  public abstract ImmutableList<String> additionalHeaders();
-  // Content
-  public abstract String subject();
-
-  @Nullable
-  public abstract String textContent();
-
-  @Nullable
-  public abstract String htmlContent();
-  // Raw content as received over the wire
-  @Nullable
-  public abstract ImmutableList<Integer> rawContent();
-
-  @Nullable
-  public abstract String rawContentUTF();
-
-  public static Builder builder() {
-    return new AutoValue_MailMessage.Builder();
-  }
-
-  public abstract Builder toBuilder();
-
-  @AutoValue.Builder
-  public abstract static class Builder {
-    public abstract Builder id(String val);
-
-    public abstract Builder from(Address val);
-
-    public abstract ImmutableList.Builder<Address> toBuilder();
-
-    public Builder addTo(Address val) {
-      toBuilder().add(val);
-      return this;
-    }
-
-    public abstract ImmutableList.Builder<Address> ccBuilder();
-
-    public Builder addCc(Address val) {
-      ccBuilder().add(val);
-      return this;
-    }
-
-    public abstract Builder dateReceived(Instant instant);
-
-    public abstract ImmutableList.Builder<String> additionalHeadersBuilder();
-
-    public Builder addAdditionalHeader(String val) {
-      additionalHeadersBuilder().add(val);
-      return this;
-    }
-
-    public abstract Builder subject(String val);
-
-    public abstract Builder textContent(String val);
-
-    public abstract Builder htmlContent(String val);
-
-    public abstract Builder rawContent(ImmutableList<Integer> val);
-
-    public abstract Builder rawContentUTF(String val);
-
-    public abstract MailMessage build();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/receive/MailMetadata.java b/java/com/google/gerrit/server/mail/receive/MailMetadata.java
deleted file mode 100644
index 04c2add..0000000
--- a/java/com/google/gerrit/server/mail/receive/MailMetadata.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.common.base.MoreObjects;
-import java.sql.Timestamp;
-
-/** MailMetadata represents metadata parsed from inbound email. */
-public class MailMetadata {
-  public Integer changeNumber;
-  public Integer patchSet;
-  public String author; // Author of the email
-  public Timestamp timestamp;
-  public String messageType; // we expect comment here
-
-  public boolean hasRequiredFields() {
-    return changeNumber != null
-        && patchSet != null
-        && author != null
-        && timestamp != null
-        && messageType != null;
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper(this)
-        .add("Change-Number", changeNumber)
-        .add("Patch-Set", patchSet)
-        .add("Author", author)
-        .add("Timestamp", timestamp)
-        .add("Message-Type", messageType)
-        .toString();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/receive/MailParsingException.java b/java/com/google/gerrit/server/mail/receive/MailParsingException.java
deleted file mode 100644
index b91bb18..0000000
--- a/java/com/google/gerrit/server/mail/receive/MailParsingException.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-/** An {@link Exception} indicating that an email could not be parsed. */
-public class MailParsingException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public MailParsingException(String msg) {
-    super(msg);
-  }
-
-  public MailParsingException(String msg, Throwable cause) {
-    super(msg, cause);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 0e0bca6..034bcc9 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -17,15 +17,25 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.mail.HtmlParser;
+import com.google.gerrit.mail.MailComment;
+import com.google.gerrit.mail.MailHeaderParser;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailMetadata;
+import com.google.gerrit.mail.TextParser;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -37,17 +47,19 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -58,7 +70,7 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -77,6 +89,15 @@
 public class MailProcessor {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final ImmutableMap<MailComment.CommentType, CommentForValidation.CommentType>
+      MAIL_COMMENT_TYPE_TO_VALIDATION_TYPE =
+          ImmutableMap.of(
+              MailComment.CommentType.CHANGE_MESSAGE,
+                  CommentForValidation.CommentType.CHANGE_MESSAGE,
+              MailComment.CommentType.FILE_COMMENT, CommentForValidation.CommentType.FILE_COMMENT,
+              MailComment.CommentType.INLINE_COMMENT,
+                  CommentForValidation.CommentType.INLINE_COMMENT);
+
   private final Emails emails;
   private final InboundEmailRejectionSender.Factory emailRejectionSender;
   private final RetryHelper retryHelper;
@@ -91,7 +112,8 @@
   private final CommentAdded commentAdded;
   private final ApprovalsUtil approvalsUtil;
   private final AccountCache accountCache;
-  private final Provider<String> canonicalUrl;
+  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final PluginSetContext<CommentValidator> commentValidators;
 
   @Inject
   public MailProcessor(
@@ -109,7 +131,8 @@
       ApprovalsUtil approvalsUtil,
       CommentAdded commentAdded,
       AccountCache accountCache,
-      @CanonicalWebUrl Provider<String> canonicalUrl) {
+      DynamicItem<UrlFormatter> urlFormatter,
+      PluginSetContext<CommentValidator> commentValidators) {
     this.emails = emails;
     this.emailRejectionSender = emailRejectionSender;
     this.retryHelper = retryHelper;
@@ -124,7 +147,8 @@
     this.commentAdded = commentAdded;
     this.approvalsUtil = approvalsUtil;
     this.accountCache = accountCache;
-    this.canonicalUrl = canonicalUrl;
+    this.urlFormatter = urlFormatter;
+    this.commentValidators = commentValidators;
   }
 
   /**
@@ -141,8 +165,8 @@
   }
 
   private void processImpl(BatchUpdate.Factory buf, MailMessage message)
-      throws OrmException, UpdateException, RestApiException, IOException {
-    for (DynamicMap.Entry<MailFilter> filter : mailFilters) {
+      throws UpdateException, RestApiException, IOException {
+    for (Extension<MailFilter> filter : mailFilters) {
       if (!filter.getProvider().get().shouldProcessMessage(message)) {
         logger.atWarning().log(
             "Message %s filtered by plugin %s %s. Will delete message.",
@@ -202,10 +226,10 @@
 
   private void persistComments(
       BatchUpdate.Factory buf, MailMessage message, MailMetadata metadata, Account.Id sender)
-      throws OrmException, UpdateException, RestApiException {
+      throws UpdateException, RestApiException {
     try (ManualRequestContext ctx = oneOffRequestContext.openAs(sender)) {
       List<ChangeData> changeDataList =
-          queryProvider.get().byLegacyChangeId(new Change.Id(metadata.changeNumber));
+          queryProvider.get().byLegacyChangeId(Change.id(metadata.changeNumber));
       if (changeDataList.size() != 1) {
         logger.atSevere().log(
             "Message %s references unique change %s,"
@@ -225,13 +249,19 @@
       // comments from the outbound email.
       // TODO(hiesel) Also filter by original comment author.
       Collection<Comment> comments =
-          cd.publishedComments()
-              .stream()
+          cd.publishedComments().stream()
               .filter(c -> (c.writtenOn.getTime() / 1000) == (metadata.timestamp.getTime() / 1000))
               .sorted(CommentsUtil.COMMENT_ORDER)
               .collect(toList());
       Project.NameKey project = cd.project();
-      String changeUrl = canonicalUrl.get() + "#/c/" + cd.getId().get();
+
+      // If URL is not defined, we won't be able to parse line comments. We still attempt to get the
+      // other ones.
+      String changeUrl =
+          urlFormatter
+              .get()
+              .getChangeViewUrl(cd.project(), cd.getId())
+              .orElse("http://gerrit.invalid/");
 
       List<MailComment> parsedComments;
       if (useHtmlParser(message)) {
@@ -247,8 +277,23 @@
         return;
       }
 
-      Op o = new Op(new PatchSet.Id(cd.getId(), metadata.patchSet), parsedComments, message.id());
-      BatchUpdate batchUpdate = buf.create(cd.db(), project, ctx.getUser(), TimeUtil.nowTs());
+      ImmutableList<CommentForValidation> parsedCommentsForValidation =
+          parsedComments.stream()
+              .map(
+                  comment ->
+                      CommentForValidation.create(
+                          MAIL_COMMENT_TYPE_TO_VALIDATION_TYPE.get(comment.getType()),
+                          comment.getMessage()))
+              .collect(ImmutableList.toImmutableList());
+      ImmutableList<CommentValidationFailure> commentValidationFailures =
+          PublishCommentUtil.findInvalidComments(commentValidators, parsedCommentsForValidation);
+      if (!commentValidationFailures.isEmpty()) {
+        sendRejectionEmail(message, InboundEmailRejectionSender.Error.COMMENT_REJECTED);
+        return;
+      }
+
+      Op o = new Op(PatchSet.id(cd.getId(), metadata.patchSet), parsedComments, message.id());
+      BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.nowTs());
       batchUpdate.addOp(cd.getId(), o);
       batchUpdate.execute();
     }
@@ -271,29 +316,26 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, UnprocessableEntityException, PatchListNotAvailableException {
-      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+        throws UnprocessableEntityException, PatchListNotAvailableException {
+      patchSet = psUtil.get(ctx.getNotes(), psId);
       notes = ctx.getNotes();
       if (patchSet == null) {
-        throw new OrmException("patch set not found: " + psId);
+        throw new StorageException("patch set not found: " + psId);
       }
 
       changeMessage = generateChangeMessage(ctx);
-      changeMessagesUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
+      changeMessagesUtil.addChangeMessage(ctx.getUpdate(psId), changeMessage);
 
       comments = new ArrayList<>();
       for (MailComment c : parsedComments) {
-        if (c.type == MailComment.CommentType.CHANGE_MESSAGE) {
+        if (c.getType() == MailComment.CommentType.CHANGE_MESSAGE) {
           continue;
         }
         comments.add(
             persistentCommentFromMailComment(ctx, c, targetPatchSetForComment(ctx, c, patchSet)));
       }
       commentsUtil.putComments(
-          ctx.getDb(),
-          ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-          Status.PUBLISHED,
-          comments);
+          ctx.getUpdate(ctx.getChange().currentPatchSetId()), Status.PUBLISHED, comments);
 
       return true;
     }
@@ -301,14 +343,13 @@
     @Override
     public void postUpdate(Context ctx) throws Exception {
       String patchSetComment = null;
-      if (parsedComments.get(0).type == MailComment.CommentType.CHANGE_MESSAGE) {
-        patchSetComment = parsedComments.get(0).message;
+      if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
+        patchSetComment = parsedComments.get(0).getMessage();
       }
       // Send email notifications
       outgoingMailFactory
           .create(
-              NotifyHandling.ALL,
-              ArrayListMultimap.create(),
+              ctx.getNotify(notes.getChangeId()),
               notes,
               patchSet,
               ctx.getUser().asIdentifiedUser(),
@@ -321,14 +362,8 @@
       Map<String, Short> approvals = new HashMap<>();
       approvalsUtil
           .byPatchSetUser(
-              ctx.getDb(),
-              notes,
-              ctx.getUser(),
-              psId,
-              ctx.getAccountId(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())
-          .forEach(a -> approvals.put(a.getLabel(), a.getValue()));
+              notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
+          .forEach(a -> approvals.put(a.label(), a.value()));
       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
       // are always the same here.
       commentAdded.fire(
@@ -343,12 +378,12 @@
 
     private ChangeMessage generateChangeMessage(ChangeContext ctx) {
       String changeMsg = "Patch Set " + psId.get() + ":";
-      if (parsedComments.get(0).type == MailComment.CommentType.CHANGE_MESSAGE) {
+      if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
         // Add a blank line after Patch Set to follow the default format
         if (parsedComments.size() > 1) {
           changeMsg += "\n\n" + numComments(parsedComments.size() - 1);
         }
-        changeMsg += "\n\n" + parsedComments.get(0).message;
+        changeMsg += "\n\n" + parsedComments.get(0).getMessage();
       } else {
         changeMsg += "\n\n" + numComments(parsedComments.size());
       }
@@ -356,28 +391,27 @@
     }
 
     private PatchSet targetPatchSetForComment(
-        ChangeContext ctx, MailComment mailComment, PatchSet current) throws OrmException {
-      if (mailComment.inReplyTo != null) {
+        ChangeContext ctx, MailComment mailComment, PatchSet current) {
+      if (mailComment.getInReplyTo() != null) {
         return psUtil.get(
-            ctx.getDb(),
             ctx.getNotes(),
-            new PatchSet.Id(ctx.getChange().getId(), mailComment.inReplyTo.key.patchSetId));
+            PatchSet.id(ctx.getChange().getId(), mailComment.getInReplyTo().key.patchSetId));
       }
       return current;
     }
 
     private Comment persistentCommentFromMailComment(
         ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment)
-        throws OrmException, UnprocessableEntityException, PatchListNotAvailableException {
+        throws UnprocessableEntityException, PatchListNotAvailableException {
       String fileName;
       // The patch set that this comment is based on is different if this
       // comment was sent in reply to a comment on a previous patch set.
       Side side;
-      if (mailComment.inReplyTo != null) {
-        fileName = mailComment.inReplyTo.key.filename;
-        side = Side.fromShort(mailComment.inReplyTo.side);
+      if (mailComment.getInReplyTo() != null) {
+        fileName = mailComment.getInReplyTo().key.filename;
+        side = Side.fromShort(mailComment.getInReplyTo().side);
       } else {
-        fileName = mailComment.fileName;
+        fileName = mailComment.getFileName();
         side = Side.REVISION;
       }
 
@@ -385,20 +419,20 @@
           commentsUtil.newComment(
               ctx,
               fileName,
-              patchSetForComment.getId(),
+              patchSetForComment.id(),
               (short) side.ordinal(),
-              mailComment.message,
+              mailComment.getMessage(),
               false,
               null);
 
       comment.tag = tag;
-      if (mailComment.inReplyTo != null) {
-        comment.parentUuid = mailComment.inReplyTo.key.uuid;
-        comment.lineNbr = mailComment.inReplyTo.lineNbr;
-        comment.range = mailComment.inReplyTo.range;
-        comment.unresolved = mailComment.inReplyTo.unresolved;
+      if (mailComment.getInReplyTo() != null) {
+        comment.parentUuid = mailComment.getInReplyTo().key.uuid;
+        comment.lineNbr = mailComment.getInReplyTo().lineNbr;
+        comment.range = mailComment.getInReplyTo().range;
+        comment.unresolved = mailComment.getInReplyTo().unresolved;
       }
-      CommentsUtil.setCommentRevId(comment, patchListCache, ctx.getChange(), patchSetForComment);
+      CommentsUtil.setCommentCommitId(comment, patchListCache, ctx.getChange(), patchSetForComment);
       return comment;
     }
   }
@@ -411,10 +445,9 @@
     return "(" + numComments + (numComments > 1 ? " comments)" : " comment)");
   }
 
-  private Set<String> existingMessageIds(ChangeData cd) throws OrmException {
+  private Set<String> existingMessageIds(ChangeData cd) {
     Set<String> existingMessageIds = new HashSet<>();
-    cd.messages()
-        .stream()
+    cd.messages().stream()
         .forEach(
             m -> {
               String messageId = CommentsUtil.extractMessageId(m.getTag());
@@ -422,8 +455,7 @@
                 existingMessageIds.add(messageId);
               }
             });
-    cd.publishedComments()
-        .stream()
+    cd.publishedComments().stream()
         .forEach(
             c -> {
               String messageId = CommentsUtil.extractMessageId(c.tag);
diff --git a/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index e4ad969..dc99b46 100644
--- a/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.update.UpdateException;
diff --git a/java/com/google/gerrit/server/mail/receive/ParserUtil.java b/java/com/google/gerrit/server/mail/receive/ParserUtil.java
deleted file mode 100644
index e770a3e..0000000
--- a/java/com/google/gerrit/server/mail/receive/ParserUtil.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.common.base.Splitter;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.List;
-import java.util.StringJoiner;
-import java.util.regex.Pattern;
-
-public class ParserUtil {
-  private static final Pattern SIMPLE_EMAIL_PATTERN =
-      Pattern.compile(
-          "[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+"
-              + "(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})");
-
-  private ParserUtil() {}
-
-  /**
-   * Trims the quotation that email clients add Example: On Sun, Nov 20, 2016 at 10:33 PM,
-   * <gerrit@gerritcodereview.com> wrote:
-   *
-   * @param comment Comment parsed from an email.
-   * @return Trimmed comment.
-   */
-  public static String trimQuotation(String comment) {
-    StringJoiner j = new StringJoiner("\n");
-    List<String> lines = Splitter.on('\n').splitToList(comment);
-    for (int i = 0; i < lines.size() - 2; i++) {
-      j.add(lines.get(i));
-    }
-
-    // Check if the last line contains the full quotation pattern (date + email)
-    String lastLine = lines.get(lines.size() - 1);
-    if (containsQuotationPattern(lastLine)) {
-      if (lines.size() > 1) {
-        j.add(lines.get(lines.size() - 2));
-      }
-      return j.toString().trim();
-    }
-
-    // Check if the second last line + the last line contain the full quotation pattern. This is
-    // necessary, as the quotation line can be split across the last two lines if it gets too long.
-    if (lines.size() > 1) {
-      String lastLines = lines.get(lines.size() - 2) + lastLine;
-      if (containsQuotationPattern(lastLines)) {
-        return j.toString().trim();
-      }
-    }
-
-    // Add the last two lines
-    if (lines.size() > 1) {
-      j.add(lines.get(lines.size() - 2));
-    }
-    j.add(lines.get(lines.size() - 1));
-
-    return j.toString().trim();
-  }
-
-  /** Check if string is an inline comment url on a patch set or the base */
-  public static boolean isCommentUrl(String str, String changeUrl, Comment comment) {
-    int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine;
-    return str.equals(filePath(changeUrl, comment) + "@" + lineNbr)
-        || str.equals(filePath(changeUrl, comment) + "@a" + lineNbr);
-  }
-
-  /** Generate the fully qualified filepath */
-  public static String filePath(String changeUrl, Comment comment) {
-    return changeUrl + "/" + comment.key.patchSetId + "/" + comment.key.filename;
-  }
-
-  /**
-   * When parsing mail content, we need to append comments prematurely since we are parsing
-   * block-by-block and never know what comes next. This can result in a comment being parsed as two
-   * comments when it spans multiple blocks. This method takes care of merging those blocks or
-   * adding a new comment to the list of appropriate.
-   */
-  public static void appendOrAddNewComment(MailComment comment, List<MailComment> comments) {
-    if (comments.isEmpty()) {
-      comments.add(comment);
-      return;
-    }
-    MailComment lastComment = Iterables.getLast(comments);
-
-    if (comment.isSameCommentPath(lastComment)) {
-      // Merge the two comments. Links should just be appended, while regular text that came from
-      // different <div> elements should be separated by a paragraph.
-      lastComment.message += (comment.isLink ? " " : "\n\n") + comment.message;
-      return;
-    }
-
-    comments.add(comment);
-  }
-
-  private static boolean containsQuotationPattern(String s) {
-    // Identifying the quotation line is hard, as it can be in any language.
-    // We identify this line by it's characteristics: It usually contains a
-    // valid email address, some digits for the date in groups of 1-4 in a row
-    // as well as some characters.
-
-    // Count occurrences of digit groups
-    int numConsecutiveDigits = 0;
-    int maxConsecutiveDigits = 0;
-    int numDigitGroups = 0;
-    for (char c : s.toCharArray()) {
-      if (c >= '0' && c <= '9') {
-        numConsecutiveDigits++;
-      } else if (numConsecutiveDigits > 0) {
-        maxConsecutiveDigits = Integer.max(maxConsecutiveDigits, numConsecutiveDigits);
-        numConsecutiveDigits = 0;
-        numDigitGroups++;
-      }
-    }
-    if (numDigitGroups < 4 || maxConsecutiveDigits > 4) {
-      return false;
-    }
-
-    // Check if the string contains an email address
-    return SIMPLE_EMAIL_PATTERN.matcher(s).find();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java b/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
index a3ea265..54971c4 100644
--- a/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
@@ -16,6 +16,9 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailParsingException;
+import com.google.gerrit.mail.RawMailParser;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.mail.Encryption;
diff --git a/java/com/google/gerrit/server/mail/receive/RawMailParser.java b/java/com/google/gerrit/server/mail/receive/RawMailParser.java
deleted file mode 100644
index 57fe21f..0000000
--- a/java/com/google/gerrit/server/mail/receive/RawMailParser.java
+++ /dev/null
@@ -1,178 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.io.CharStreams;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.server.mail.Address;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import org.apache.james.mime4j.MimeException;
-import org.apache.james.mime4j.dom.Entity;
-import org.apache.james.mime4j.dom.Message;
-import org.apache.james.mime4j.dom.MessageBuilder;
-import org.apache.james.mime4j.dom.Multipart;
-import org.apache.james.mime4j.dom.TextBody;
-import org.apache.james.mime4j.dom.address.Mailbox;
-import org.apache.james.mime4j.message.DefaultMessageBuilder;
-
-/** Parses raw email content received through POP3 or IMAP into an internal {@link MailMessage}. */
-public class RawMailParser {
-  private static final ImmutableSet<String> MAIN_HEADERS =
-      ImmutableSet.of("to", "from", "cc", "date", "message-id", "subject", "content-type");
-
-  private RawMailParser() {}
-
-  /**
-   * Parses a MailMessage from a string.
-   *
-   * @param raw {@link String} payload as received over the wire
-   * @return parsed {@link MailMessage}
-   * @throws MailParsingException in case parsing fails
-   */
-  public static MailMessage parse(String raw) throws MailParsingException {
-    MailMessage.Builder messageBuilder = MailMessage.builder();
-    messageBuilder.rawContentUTF(raw);
-    Message mimeMessage;
-    try {
-      MessageBuilder builder = new DefaultMessageBuilder();
-      mimeMessage = builder.parseMessage(new ByteArrayInputStream(raw.getBytes(UTF_8)));
-    } catch (IOException | MimeException e) {
-      throw new MailParsingException("Can't parse email", e);
-    }
-    // Add general headers
-    if (mimeMessage.getMessageId() != null) {
-      messageBuilder.id(mimeMessage.getMessageId());
-    }
-    if (mimeMessage.getSubject() != null) {
-      messageBuilder.subject(mimeMessage.getSubject());
-    }
-    if (mimeMessage.getDate() != null) {
-      messageBuilder.dateReceived(mimeMessage.getDate().toInstant());
-    }
-
-    // Add From, To and Cc
-    if (mimeMessage.getFrom() != null && mimeMessage.getFrom().size() > 0) {
-      Mailbox from = mimeMessage.getFrom().get(0);
-      messageBuilder.from(new Address(from.getName(), from.getAddress()));
-    }
-    if (mimeMessage.getTo() != null) {
-      for (Mailbox m : mimeMessage.getTo().flatten()) {
-        messageBuilder.addTo(new Address(m.getName(), m.getAddress()));
-      }
-    }
-    if (mimeMessage.getCc() != null) {
-      for (Mailbox m : mimeMessage.getCc().flatten()) {
-        messageBuilder.addCc(new Address(m.getName(), m.getAddress()));
-      }
-    }
-
-    // Add additional headers
-    mimeMessage
-        .getHeader()
-        .getFields()
-        .stream()
-        .filter(f -> !MAIN_HEADERS.contains(f.getName().toLowerCase()))
-        .forEach(f -> messageBuilder.addAdditionalHeader(f.getName() + ": " + f.getBody()));
-
-    // Add text and html body parts
-    StringBuilder textBuilder = new StringBuilder();
-    StringBuilder htmlBuilder = new StringBuilder();
-    try {
-      handleMimePart(mimeMessage, textBuilder, htmlBuilder);
-    } catch (IOException e) {
-      throw new MailParsingException("Can't parse email", e);
-    }
-    messageBuilder.textContent(Strings.emptyToNull(textBuilder.toString()));
-    messageBuilder.htmlContent(Strings.emptyToNull(htmlBuilder.toString()));
-
-    try {
-      // build() will only succeed if all required attributes were set. We wrap
-      // the IllegalStateException in a MailParsingException indicating that
-      // required attributes are missing, so that the caller doesn't fall over.
-      return messageBuilder.build();
-    } catch (IllegalStateException e) {
-      throw new MailParsingException("Missing required attributes after email was parsed", e);
-    }
-  }
-
-  /**
-   * Parses a MailMessage from an array of characters. Note that the character array is int-typed.
-   * This method is only used by POP3, which specifies that all transferred characters are US-ASCII
-   * (RFC 6856). When reading the input in Java, io.Reader yields ints. These can be safely
-   * converted to chars as all US-ASCII characters fit in a char. If emails contain non-ASCII
-   * characters, such as UTF runes, these will be encoded in ASCII using either Base64 or
-   * quoted-printable encoding.
-   *
-   * @param chars Array as received over the wire
-   * @return Parsed {@link MailMessage}
-   * @throws MailParsingException in case parsing fails
-   */
-  public static MailMessage parse(int[] chars) throws MailParsingException {
-    StringBuilder b = new StringBuilder(chars.length);
-    for (int c : chars) {
-      b.append((char) c);
-    }
-
-    MailMessage.Builder messageBuilder = parse(b.toString()).toBuilder();
-    messageBuilder.rawContent(ImmutableList.copyOf(Ints.asList(chars)));
-    return messageBuilder.build();
-  }
-
-  /**
-   * Traverses a mime tree and parses out text and html parts. All other parts will be dropped.
-   *
-   * @param part {@code MimePart} to parse
-   * @param textBuilder {@link StringBuilder} to append all plaintext parts
-   * @param htmlBuilder {@link StringBuilder} to append all html parts
-   * @throws IOException in case of a failure while transforming the input to a {@link String}
-   */
-  private static void handleMimePart(
-      Entity part, StringBuilder textBuilder, StringBuilder htmlBuilder) throws IOException {
-    if (isPlainOrHtml(part.getMimeType()) && !isAttachment(part.getDispositionType())) {
-      TextBody tb = (TextBody) part.getBody();
-      String result =
-          CharStreams.toString(new InputStreamReader(tb.getInputStream(), tb.getMimeCharset()));
-      if (part.getMimeType().equals("text/plain")) {
-        textBuilder.append(result);
-      } else if (part.getMimeType().equals("text/html")) {
-        htmlBuilder.append(result);
-      }
-    } else if (isMultipart(part.getMimeType())) {
-      Multipart multipart = (Multipart) part.getBody();
-      for (Entity e : multipart.getBodyParts()) {
-        handleMimePart(e, textBuilder, htmlBuilder);
-      }
-    }
-  }
-
-  private static boolean isPlainOrHtml(String mimeType) {
-    return (mimeType.equals("text/plain") || mimeType.equals("text/html"));
-  }
-
-  private static boolean isMultipart(String mimeType) {
-    return mimeType.startsWith("multipart/");
-  }
-
-  private static boolean isAttachment(String dispositionType) {
-    return dispositionType != null && dispositionType.equals("attachment");
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/receive/TextParser.java b/java/com/google/gerrit/server/mail/receive/TextParser.java
deleted file mode 100644
index b99c608..0000000
--- a/java/com/google/gerrit/server/mail/receive/TextParser.java
+++ /dev/null
@@ -1,147 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterators;
-import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-
-/** Provides parsing functionality for plaintext email. */
-public class TextParser {
-  private TextParser() {}
-
-  /**
-   * Parses comments from plaintext email.
-   *
-   * @param email @param email the message as received from the email service
-   * @param comments list of {@link Comment}s previously persisted on the change that caused the
-   *     original notification email to be sent out. Ordering must be the same as in the outbound
-   *     email
-   * @param changeUrl canonical change url that points to the change on this Gerrit instance.
-   *     Example: https://go-review.googlesource.com/#/c/91570
-   * @return list of MailComments parsed from the plaintext part of the email
-   */
-  public static List<MailComment> parse(
-      MailMessage email, Collection<Comment> comments, String changeUrl) {
-    String body = email.textContent();
-    // Replace CR-LF by \n
-    body = body.replace("\r\n", "\n");
-
-    List<MailComment> parsedComments = new ArrayList<>();
-
-    // Some email clients (like GMail) use >> for enquoting text when there are
-    // inline comments that the users typed. These will then be enquoted by a
-    // single >. We sanitize this by unifying it into >. Inline comments typed
-    // by the user will not be enquoted.
-    //
-    // Example:
-    // Some comment
-    // >> Quoted Text
-    // >> Quoted Text
-    // > A comment typed in the email directly
-    String singleQuotePattern = "\n> ";
-    String doubleQuotePattern = "\n>> ";
-    if (countOccurrences(body, doubleQuotePattern) > countOccurrences(body, singleQuotePattern)) {
-      body = body.replace(doubleQuotePattern, singleQuotePattern);
-    }
-
-    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
-
-    MailComment currentComment = null;
-    String lastEncounteredFileName = null;
-    Comment lastEncounteredComment = null;
-    for (String line : Splitter.on('\n').split(body)) {
-      if (line.equals(">")) {
-        // Skip empty lines
-        continue;
-      }
-      if (line.startsWith("> ")) {
-        line = line.substring("> ".length()).trim();
-        // This is not a comment, try to advance the file/comment pointers and
-        // add previous comment to list if applicable
-        if (currentComment != null) {
-          if (currentComment.type == MailComment.CommentType.CHANGE_MESSAGE) {
-            currentComment.message = ParserUtil.trimQuotation(currentComment.message);
-          }
-          if (!Strings.isNullOrEmpty(currentComment.message)) {
-            ParserUtil.appendOrAddNewComment(currentComment, parsedComments);
-          }
-          currentComment = null;
-        }
-
-        if (!iter.hasNext()) {
-          continue;
-        }
-        Comment perspectiveComment = iter.peek();
-        if (line.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
-          if (lastEncounteredFileName == null
-              || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
-            // This is the annotation of a file
-            lastEncounteredFileName = perspectiveComment.key.filename;
-            lastEncounteredComment = null;
-          } else if (perspectiveComment.lineNbr == 0) {
-            // This was originally a file-level comment
-            lastEncounteredComment = perspectiveComment;
-            iter.next();
-          }
-        } else if (ParserUtil.isCommentUrl(line, changeUrl, perspectiveComment)) {
-          lastEncounteredComment = perspectiveComment;
-          iter.next();
-        }
-      } else {
-        // This is a comment. Try to append to previous comment if applicable or
-        // create a new comment.
-        if (currentComment == null) {
-          // Start new comment
-          currentComment = new MailComment();
-          currentComment.message = line;
-          if (lastEncounteredComment == null) {
-            if (lastEncounteredFileName == null) {
-              // Change message
-              currentComment.type = MailComment.CommentType.CHANGE_MESSAGE;
-            } else {
-              // File comment not sent in reply to another comment
-              currentComment.type = MailComment.CommentType.FILE_COMMENT;
-              currentComment.fileName = lastEncounteredFileName;
-            }
-          } else {
-            // Comment sent in reply to another comment
-            currentComment.inReplyTo = lastEncounteredComment;
-            currentComment.type = MailComment.CommentType.INLINE_COMMENT;
-          }
-        } else {
-          // Attach to previous comment
-          currentComment.message += "\n" + line;
-        }
-      }
-    }
-    // There is no need to attach the currentComment after this loop as all
-    // emails have footers and other enquoted text after the last comment
-    // appeared and the last comment will have already been added to the list
-    // at this point.
-
-    return parsedComments;
-  }
-
-  /** Counts the occurrences of pattern in s */
-  private static int countOccurrences(String s, String pattern) {
-    return (s.length() - s.replace(pattern, "").length()) / pattern.length();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
index 05dd542..8b1857f 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -26,14 +25,13 @@
 public class AbandonedSender extends ReplyToChangeSender {
   public interface Factory extends ReplyToChangeSender.Factory<AbandonedSender> {
     @Override
-    AbandonedSender create(Project.NameKey project, Change.Id change);
+    AbandonedSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public AbandonedSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "abandon", ChangeEmail.newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "abandon", ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index ae8ac31..8b3d3f7 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -15,15 +15,11 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountSshKey;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.List;
@@ -35,22 +31,14 @@
     AddKeySender create(IdentifiedUser user, List<String> gpgKey);
   }
 
-  private final PermissionBackend permissionBackend;
-  private final IdentifiedUser callingUser;
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
   private final List<String> gpgKeys;
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments ea,
-      PermissionBackend permissionBackend,
-      IdentifiedUser callingUser,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(ea, "addkey");
-    this.permissionBackend = permissionBackend;
-    this.callingUser = callingUser;
+      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+    super(args, "addkey");
     this.user = user;
     this.sshKey = sshKey;
     this.gpgKeys = null;
@@ -58,14 +46,8 @@
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments ea,
-      PermissionBackend permissionBackend,
-      IdentifiedUser callingUser,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeys) {
-    super(ea, "addkey");
-    this.permissionBackend = permissionBackend;
-    this.callingUser = callingUser;
+      EmailArguments args, @Assisted IdentifiedUser user, @Assisted List<String> gpgKeys) {
+    super(args, "addkey");
     this.user = user;
     this.sshKey = null;
     this.gpgKeys = gpgKeys;
@@ -85,20 +67,7 @@
       return false;
     }
 
-    if (user.equals(callingUser)) {
-      // Send email if the user self-added a key; this notification is necessary to alert
-      // the user if their account was compromised and a key was unexpectedly added.
-      return true;
-    }
-
-    try {
-      // Don't email if an administrator added a key on behalf of the user.
-      permissionBackend.user(callingUser).check(GlobalPermission.ADMINISTRATE_SERVER);
-      return false;
-    } catch (AuthException | PermissionBackendException e) {
-      // Send email if a non-administrator modified the keys, e.g. by MODIFY_ACCOUNT.
-      return true;
-    }
+    return true;
   }
 
   @Override
@@ -110,7 +79,7 @@
   }
 
   public String getEmail() {
-    return user.getAccount().getPreferredEmail();
+    return user.getAccount().preferredEmail();
   }
 
   public String getUserNameEmail() {
diff --git a/java/com/google/gerrit/server/mail/send/AddReviewerSender.java b/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
index cb70106..9e0d01f 100644
--- a/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
@@ -14,24 +14,22 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 /** Asks a user to review a change. */
 public class AddReviewerSender extends NewChangeSender {
   public interface Factory {
-    AddReviewerSender create(Project.NameKey project, Change.Id id);
+    AddReviewerSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public AddReviewerSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 503fbd0..949541e 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -15,14 +15,18 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -30,7 +34,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.MailHeader;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
@@ -43,14 +46,10 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.template.soy.data.SoyListData;
-import com.google.template.soy.data.SoyMapData;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.text.MessageFormat;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -70,7 +69,7 @@
 
   protected static ChangeData newChangeData(
       EmailArguments ea, Project.NameKey project, Change.Id id) {
-    return ea.changeDataFactory.create(ea.db.get(), project, id);
+    return ea.changeDataFactory.create(project, id);
   }
 
   protected final Change change;
@@ -85,11 +84,11 @@
   protected Set<Account.Id> authors;
   protected boolean emailOnlyAuthors;
 
-  protected ChangeEmail(EmailArguments ea, String mc, ChangeData cd) throws OrmException {
-    super(ea, mc, cd.change().getDest());
-    changeData = cd;
-    change = cd.change();
-    emailOnlyAuthors = false;
+  protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
+    super(args, messageClass, changeData.change().getDest());
+    this.changeData = changeData;
+    this.change = changeData.change();
+    this.emailOnlyAuthors = false;
   }
 
   @Override
@@ -151,18 +150,17 @@
     if (patchSet == null) {
       try {
         patchSet = changeData.currentPatchSet();
-      } catch (OrmException err) {
+      } catch (StorageException err) {
         patchSet = null;
       }
     }
 
     if (patchSet != null) {
-      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.getPatchSetId() + "");
+      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
       if (patchSetInfo == null) {
         try {
-          patchSetInfo =
-              args.patchSetInfoFactory.get(args.db.get(), changeData.notes(), patchSet.getId());
-        } catch (PatchSetInfoNotAvailableException | OrmException err) {
+          patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
+        } catch (PatchSetInfoNotAvailableException | StorageException err) {
           patchSetInfo = null;
         }
       }
@@ -171,7 +169,7 @@
 
     try {
       stars = changeData.stars();
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
     }
 
@@ -185,14 +183,14 @@
     setChangeUrlHeader();
     setCommitIdHeader();
 
-    if (notify.ordinal() >= NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+    if (notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
       try {
         addByEmail(
             RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
         addByEmail(
             RecipientType.CC,
             changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
       }
     }
@@ -206,11 +204,8 @@
   }
 
   private void setCommitIdHeader() {
-    if (patchSet != null
-        && patchSet.getRevision() != null
-        && patchSet.getRevision().get() != null
-        && patchSet.getRevision().get().length() > 0) {
-      setHeader(MailHeader.COMMIT.fieldName(), patchSet.getRevision().get());
+    if (patchSet != null) {
+      setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
     }
   }
 
@@ -219,14 +214,12 @@
   }
 
   /** Get a link to the change; null if the server doesn't know its own address. */
+  @Nullable
   public String getChangeUrl() {
-    if (getGerritUrl() != null) {
-      final StringBuilder r = new StringBuilder();
-      r.append(getGerritUrl());
-      r.append(change.getChangeId());
-      return r.toString();
-    }
-    return null;
+    return args.urlFormatter
+        .get()
+        .getChangeViewUrl(change.getProject(), change.getId())
+        .orElse(null);
   }
 
   public String getChangeMessageThreadId() {
@@ -239,15 +232,6 @@
         + ">";
   }
 
-  /** Format the sender's "cover letter", {@link #getCoverLetter()}. */
-  protected void formatCoverLetter() {
-    final String cover = getCoverLetter();
-    if (!"".equals(cover)) {
-      appendText(cover);
-      appendText("\n\n");
-    }
-  }
-
   /** Get the text of the "cover letter". */
   public String getCoverLetter() {
     if (changeMessage != null) {
@@ -256,11 +240,6 @@
     return "";
   }
 
-  /** Format the change message and the affected file list. */
-  protected void formatChangeDetail() {
-    appendText(getChangeDetail());
-  }
-
   /** Create the change message and the affected file list. */
   public String getChangeDetail() {
     try {
@@ -304,6 +283,21 @@
     }
   }
 
+  /** Get the patch list corresponding to patch set patchSetId of this change. */
+  protected PatchList getPatchList(int patchSetId) throws PatchListNotAvailableException {
+    PatchSet ps;
+    if (patchSetId == patchSet.number()) {
+      ps = patchSet;
+    } else {
+      try {
+        ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
+      } catch (StorageException e) {
+        throw new PatchListNotAvailableException("Failed to get patchSet");
+      }
+    }
+    return args.patchListCache.get(change, ps);
+  }
+
   /** Get the patch list corresponding to this patch set. */
   protected PatchList getPatchList() throws PatchListNotAvailableException {
     if (patchSet != null) {
@@ -317,14 +311,6 @@
     return projectState;
   }
 
-  /** Get the groups which own the project. */
-  protected Set<AccountGroup.UUID> getProjectOwners() {
-    final ProjectState r;
-
-    r = args.projectCache.get(change.getProject());
-    return r != null ? r.getOwners() : Collections.<AccountGroup.UUID>emptySet();
-  }
-
   /** TO or CC all vested parties (change owner, patch set uploader, author). */
   protected void rcptToAuthors(RecipientType rt) {
     for (Account.Id id : authors) {
@@ -334,7 +320,7 @@
 
   /** BCC any user who has starred this change. */
   protected void bccStarredBy() {
-    if (!NotifyHandling.ALL.equals(notify)) {
+    if (!NotifyHandling.ALL.equals(notify.handling())) {
       return;
     }
 
@@ -354,19 +340,19 @@
   }
 
   @Override
-  protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
-      throws OrmException {
-    if (!NotifyHandling.ALL.equals(notify)) {
+  protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+    if (!NotifyHandling.ALL.equals(notify.handling())) {
       return new Watchers();
     }
 
-    ProjectWatch watch = new ProjectWatch(args, branch.getParentKey(), projectState, changeData);
+    ProjectWatch watch = new ProjectWatch(args, branch.project(), projectState, changeData);
     return watch.getWatchers(type, includeWatchersFromNotifyConfig);
   }
 
   /** Any user who has published comments on this change. */
   protected void ccAllApprovals() {
-    if (!NotifyHandling.ALL.equals(notify) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+    if (!NotifyHandling.ALL.equals(notify.handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
       return;
     }
 
@@ -374,14 +360,15 @@
       for (Account.Id id : changeData.reviewers().all()) {
         add(RecipientType.CC, id);
       }
-    } catch (OrmException err) {
+    } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
     }
   }
 
   /** Users who have non-zero approval codes on the change. */
   protected void ccExistingReviewers() {
-    if (!NotifyHandling.ALL.equals(notify) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+    if (!NotifyHandling.ALL.equals(notify.handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
       return;
     }
 
@@ -389,7 +376,7 @@
       for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
         add(RecipientType.CC, id);
       }
-    } catch (OrmException err) {
+    } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
     }
   }
@@ -403,25 +390,28 @@
 
   @Override
   protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
-    return projectState.statePermitsRead()
-        && args.permissionBackend
-            .absentUser(to)
-            .change(changeData)
-            .database(args.db)
-            .test(ChangePermission.READ);
+    if (!projectState.statePermitsRead()) {
+      return false;
+    }
+    try {
+      args.permissionBackend.absentUser(to).change(changeData).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 
   /** Find all users who are authors of any part of this change. */
   protected Set<Account.Id> getAuthors() {
     Set<Account.Id> authors = new HashSet<>();
 
-    switch (notify) {
+    switch (notify.handling()) {
       case NONE:
         break;
       case ALL:
       default:
         if (patchSet != null) {
-          authors.add(patchSet.getUploader());
+          authors.add(patchSet.uploader());
         }
         if (patchSetInfo != null) {
           if (patchSetInfo.getAuthor().getAccount() != null) {
@@ -471,8 +461,8 @@
     soyContext.put("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
-    patchSetData.put("patchSetId", patchSet.getPatchSetId());
-    patchSetData.put("refName", patchSet.getRefName());
+    patchSetData.put("patchSetId", patchSet.number());
+    patchSetData.put("refName", patchSet.refName());
     soyContext.put("patchSet", patchSetData);
 
     Map<String, Object> patchSetInfoData = new HashMap<>();
@@ -482,7 +472,7 @@
 
     footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
     footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + Integer.toString(change.getChangeId()));
-    footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.getPatchSetId());
+    footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
     footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
     if (change.getAssignee() != null) {
       footers.add(MailHeader.ASSIGNEE.withDelimiter() + getNameEmailFor(change.getAssignee()));
@@ -512,7 +502,7 @@
       for (Account.Id who : changeData.reviewers().byState(state)) {
         reviewers.add(getNameEmailFor(who));
       }
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       logger.atWarning().withCause(e).log("Cannot get change reviewers");
     }
     return reviewers;
@@ -566,15 +556,15 @@
   }
 
   /**
-   * Generate a Soy list of maps representing each line of the unified diff. The line maps will have
-   * a 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to
-   * the line's content.
+   * Generate a list of maps representing each line of the unified diff. The line maps will have a
+   * 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to the
+   * line's content.
    */
-  private SoyListData getDiffTemplateData() {
-    SoyListData result = new SoyListData();
+  private ImmutableList<ImmutableMap<String, String>> getDiffTemplateData() {
+    ImmutableList.Builder<ImmutableMap<String, String>> result = ImmutableList.builder();
     Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
     for (String diffLine : lineSplitter.split(getUnifiedDiff())) {
-      SoyMapData lineData = new SoyMapData();
+      ImmutableMap.Builder<String, String> lineData = ImmutableMap.builder();
       lineData.put("text", diffLine);
 
       // Skip empty lines and lines that look like diff headers.
@@ -593,8 +583,8 @@
             break;
         }
       }
-      result.add(lineData);
+      result.add(lineData.build());
     }
-    return result;
+    return result.build();
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 0095fc1..9e3cd2f 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -17,31 +17,30 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.Ordering;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.FilenameComparator;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.common.errors.NoSuchEntityException;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.exceptions.NoSuchEntityException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.KeyUtil;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.MailHeader;
-import com.google.gerrit.server.mail.MailUtil;
 import com.google.gerrit.server.mail.receive.Protocol;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -55,7 +54,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
@@ -65,7 +63,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    CommentSender create(Project.NameKey project, Change.Id id);
+    CommentSender create(Project.NameKey project, Change.Id changeId);
   }
 
   private class FileCommentGroup {
@@ -75,21 +73,19 @@
     public List<Comment> comments = new ArrayList<>();
 
     /** @return a web link to the given patch set and file. */
-    public String getLink() {
-      String url = getGerritUrl();
-      if (url == null) {
-        return null;
-      }
+    public String getFileLink() {
+      return args.urlFormatter
+          .get()
+          .getPatchFileView(change, patchSetId, KeyUtil.encode(filename))
+          .orElse(null);
+    }
 
-      return new StringBuilder()
-          .append(url)
-          .append("#/c/")
-          .append(change.getId())
-          .append('/')
-          .append(patchSetId)
-          .append('/')
-          .append(KeyUtil.encode(filename))
-          .toString();
+    /** @return a web link to a comment within a given patch set and file. */
+    public String getCommentLink(short side, int startLine) {
+      return args.urlFormatter
+          .get()
+          .getInlineCommentView(change, patchSetId, KeyUtil.encode(filename), side, startLine)
+          .orElse(null);
     }
 
     /**
@@ -115,13 +111,12 @@
 
   @Inject
   public CommentSender(
-      EmailArguments ea,
+      EmailArguments args,
       CommentsUtil commentsUtil,
       @GerritServerConfig Config cfg,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "comment", newChangeData(ea, project, id));
+      @Assisted Change.Id changeId) {
+    super(args, "comment", newChangeData(args, project, changeId));
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
@@ -129,16 +124,8 @@
     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
   }
 
-  public void setComments(List<Comment> comments) throws OrmException {
+  public void setComments(List<Comment> comments) {
     inlineComments = comments;
-
-    Set<String> paths = new HashSet<>();
-    for (Comment c : comments) {
-      if (!Patch.isMagic(c.key.filename)) {
-        paths.add(c.key.filename);
-      }
-    }
-    changeData.setCurrentFilePaths(Ordering.natural().sortedCopy(paths));
   }
 
   public void setPatchSetComment(String comment) {
@@ -153,10 +140,10 @@
   protected void init() throws EmailException {
     super.init();
 
-    if (notify.compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
+    if (notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
       ccAllApprovals();
     }
-    if (notify.compareTo(NotifyHandling.ALL) >= 0) {
+    if (notify.handling().compareTo(NotifyHandling.ALL) >= 0) {
       bccStarredBy();
       includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
     }
@@ -198,17 +185,6 @@
    */
   private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) {
     List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
-    // Get the patch list:
-    PatchList patchList = null;
-    if (repo != null) {
-      try {
-        patchList = getPatchList();
-      } catch (PatchListObjectTooLargeException e) {
-        logger.atWarning().log("Failed to get patch list: %s", e.getMessage());
-      } catch (PatchListNotAvailableException e) {
-        logger.atSevere().withCause(e).log("Failed to get patch list");
-      }
-    }
 
     // Loop over the comments and collect them into groups based on the file
     // location of the comment.
@@ -221,6 +197,16 @@
         currentGroup = new FileCommentGroup();
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
+        // Get the patch list:
+        PatchList patchList = null;
+        try {
+          patchList = getPatchList(c.key.patchSetId);
+        } catch (PatchListObjectTooLargeException e) {
+          logger.atWarning().log("Failed to get patch list: %s", e.getMessage());
+        } catch (PatchListNotAvailableException e) {
+          logger.atSevere().withCause(e).log("Failed to get patch list");
+        }
+
         groups.add(currentGroup);
         if (patchList != null) {
           try {
@@ -239,7 +225,7 @@
       }
     }
 
-    Collections.sort(groups, Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
+    groups.sort(Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
     return groups;
   }
 
@@ -316,8 +302,8 @@
 
     Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
     try {
-      return commentsUtil.getPublished(args.db.get(), changeData.notes(), key);
-    } catch (OrmException e) {
+      return commentsUtil.getPublished(changeData.notes(), key);
+    } catch (StorageException e) {
       logger.atWarning().log("Could not find the parent of this comment: %s", child);
       return Optional.empty();
     }
@@ -393,7 +379,7 @@
 
     for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
-      groupData.put("link", group.getLink());
+      groupData.put("link", group.getFileLink());
       groupData.put("title", group.getTitle());
       groupData.put("patchSetId", group.patchSetId);
 
@@ -422,11 +408,9 @@
 
         // Set the comment link.
         if (comment.lineNbr == 0) {
-          commentData.put("link", group.getLink());
-        } else if (comment.side == 0) {
-          commentData.put("link", group.getLink() + "@a" + startLine);
+          commentData.put("link", group.getFileLink());
         } else {
-          commentData.put("link", group.getLink() + '@' + startLine);
+          commentData.put("link", group.getCommentLink(comment.side, startLine));
         }
 
         // Set robot comment data.
@@ -459,8 +443,7 @@
   }
 
   private List<Map<String, Object>> commentBlocksToSoyData(List<CommentFormatter.Block> blocks) {
-    return blocks
-        .stream()
+    return blocks.stream()
         .map(
             b -> {
               Map<String, Object> map = new HashMap<>();
@@ -507,7 +490,7 @@
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
-    boolean hasComments = false;
+    boolean hasComments;
     try (Repository repo = getRepository()) {
       List<Map<String, Object>> files = getCommentGroupsTemplateData(repo);
       soyContext.put("commentFiles", files);
@@ -541,7 +524,8 @@
     } catch (IndexOutOfBoundsException err) {
       // Default to the empty string if the given line number does not appear
       // in the file.
-      logger.atFine().withCause(err).log("Failed to get line number of file on side %d", side);
+      logger.atFine().withCause(err).log(
+          "Failed to get line number %d of file on side %d", lineNbr, side);
       return "";
     } catch (NoSuchEntityException err) {
       // Default to the empty string if the side cannot be found.
@@ -566,7 +550,7 @@
 
   private String getCommentTimestamp() {
     // Grouping is currently done by timestamp.
-    return MailUtil.rfcDateformatter.format(
+    return MailProcessingUtil.rfcDateformatter.format(
         ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index fc9c14a..150907b 100644
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -15,7 +15,8 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -24,7 +25,6 @@
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.stream.StreamSupport;
@@ -34,19 +34,18 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    CreateChangeSender create(Project.NameKey project, Change.Id id);
+    CreateChangeSender create(Project.NameKey project, Change.Id changeId);
   }
 
   private final PermissionBackend permissionBackend;
 
   @Inject
   public CreateChangeSender(
-      EmailArguments ea,
+      EmailArguments args,
       PermissionBackend permissionBackend,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, newChangeData(ea, project, id));
+      @Assisted Change.Id changeId) {
+    super(args, newChangeData(args, project, changeId));
     this.permissionBackend = permissionBackend;
   }
 
@@ -66,7 +65,7 @@
       add(RecipientType.TO, matching.to);
       add(RecipientType.CC, matching.cc);
       add(RecipientType.BCC, matching.bcc);
-    } catch (OrmException err) {
+    } catch (StorageException err) {
       // Just don't CC everyone. Better to send a partial message to those
       // we already have queued up then to fail deliver entirely to people
       // who have a lower interest in the change.
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
new file mode 100644
index 0000000..c9bb1e4
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.util.Collections;
+import java.util.List;
+
+public class DeleteKeySender extends OutgoingEmail {
+  public interface Factory {
+    DeleteKeySender create(IdentifiedUser user, AccountSshKey sshKey);
+
+    DeleteKeySender create(IdentifiedUser user, List<String> gpgKeyFingerprints);
+  }
+
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeyFingerprints;
+
+  @AssistedInject
+  public DeleteKeySender(
+      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+    super(args, "deletekey");
+    this.user = user;
+    this.gpgKeyFingerprints = Collections.emptyList();
+    this.sshKey = sshKey;
+  }
+
+  @AssistedInject
+  public DeleteKeySender(
+      EmailArguments args,
+      @Assisted IdentifiedUser user,
+      @Assisted List<String> gpgKeyFingerprints) {
+    super(args, "deletekey");
+    this.user = user;
+    this.gpgKeyFingerprints = gpgKeyFingerprints;
+    this.sshKey = null;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+    setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
+    add(RecipientType.TO, new Address(getEmail()));
+  }
+
+  @Override
+  protected boolean shouldSendMessage() {
+    return true;
+  }
+
+  @Override
+  protected void format() throws EmailException {
+    appendText(textTemplate("DeleteKey"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("DeleteKeyHtml"));
+    }
+  }
+
+  public String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  public String getUserNameEmail() {
+    return getUserNameEmailFor(user.getAccountId());
+  }
+
+  public String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeyFingerprints != null) {
+      return "GPG";
+    }
+    throw new IllegalStateException("key type is not SSH or GPG");
+  }
+
+  public String getSshKey() {
+    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
+  }
+
+  public String getGpgKeyFingerprints() {
+    if (!gpgKeyFingerprints.isEmpty()) {
+      return Joiner.on("\n").join(gpgKeyFingerprints);
+    }
+    return null;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("email", getEmail());
+    soyContextEmailData.put("gpgKeyFingerprints", getGpgKeyFingerprints());
+    soyContextEmailData.put("keyType", getKeyType());
+    soyContextEmailData.put("sshKey", getSshKey());
+    soyContextEmailData.put("userNameEmail", getUserNameEmail());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index c434d06..4ed8da8 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.Address;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
@@ -37,14 +36,13 @@
 
   public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
     @Override
-    DeleteReviewerSender create(Project.NameKey project, Change.Id change);
+    DeleteReviewerSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public DeleteReviewerSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "deleteReviewer", newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "deleteReviewer", newChangeData(args, project, changeId));
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
index 0c81293..1cf5122 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -26,14 +25,13 @@
 public class DeleteVoteSender extends ReplyToChangeSender {
   public interface Factory extends ReplyToChangeSender.Factory<DeleteVoteSender> {
     @Override
-    DeleteVoteSender create(Project.NameKey project, Change.Id change);
+    DeleteVoteSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   protected DeleteVoteSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "deleteVote", newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "deleteVote", newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 04f4d6c..ede5765 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -14,22 +14,23 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritInstanceName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -44,11 +45,12 @@
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.template.soy.tofu.SoyTofu;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
+@UsedAt(UsedAt.Project.PLUGINS_ALL)
 public class EmailArguments {
   final GitRepositoryManager server;
   final ProjectCache projectCache;
@@ -56,6 +58,7 @@
   final GroupBackend groupBackend;
   final AccountCache accountCache;
   final PatchListCache patchListCache;
+  final PatchSetUtil patchSetUtil;
   final ApprovalsUtil approvalsUtil;
   final FromAddressGenerator fromAddressGenerator;
   final EmailSender emailSender;
@@ -65,15 +68,14 @@
   final AnonymousUser anonymousUser;
   final String anonymousCowardName;
   final PersonIdent gerritPersonIdent;
-  final Provider<String> urlProvider;
+  final DynamicItem<UrlFormatter> urlFormatter;
   final AllProjectsName allProjectsName;
   final List<String> sshAddresses;
   final SitePaths site;
 
   final ChangeQueryBuilder queryBuilder;
-  final Provider<ReviewDb> db;
   final ChangeData.Factory changeDataFactory;
-  final SoyTofu soyTofu;
+  final SoySauce soySauce;
   final EmailSettings settings;
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
   final Provider<InternalAccountQuery> accountQueryProvider;
@@ -89,6 +91,7 @@
       GroupBackend groupBackend,
       AccountCache accountCache,
       PatchListCache patchListCache,
+      PatchSetUtil patchSetUtil,
       ApprovalsUtil approvalsUtil,
       FromAddressGenerator fromAddressGenerator,
       EmailSender emailSender,
@@ -98,12 +101,11 @@
       AnonymousUser anonymousUser,
       @AnonymousCowardName String anonymousCowardName,
       GerritPersonIdentProvider gerritPersonIdentProvider,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      DynamicItem<UrlFormatter> urlFormatter,
       AllProjectsName allProjectsName,
       ChangeQueryBuilder queryBuilder,
-      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
-      @MailTemplates SoyTofu soyTofu,
+      @MailTemplates SoySauce soySauce,
       EmailSettings settings,
       @SshAdvertisedAddresses List<String> sshAddresses,
       SitePaths site,
@@ -118,6 +120,7 @@
     this.groupBackend = groupBackend;
     this.accountCache = accountCache;
     this.patchListCache = patchListCache;
+    this.patchSetUtil = patchSetUtil;
     this.approvalsUtil = approvalsUtil;
     this.fromAddressGenerator = fromAddressGenerator;
     this.emailSender = emailSender;
@@ -127,12 +130,11 @@
     this.anonymousUser = anonymousUser;
     this.anonymousCowardName = anonymousCowardName;
     this.gerritPersonIdent = gerritPersonIdentProvider.get();
-    this.urlProvider = urlProvider;
+    this.urlFormatter = urlFormatter;
     this.allProjectsName = allProjectsName;
     this.queryBuilder = queryBuilder;
-    this.db = db;
     this.changeDataFactory = changeDataFactory;
-    this.soyTofu = soyTofu;
+    this.soySauce = soySauce;
     this.settings = settings;
     this.sshAddresses = sshAddresses;
     this.site = site;
diff --git a/java/com/google/gerrit/server/mail/send/EmailHeader.java b/java/com/google/gerrit/server/mail/send/EmailHeader.java
deleted file mode 100644
index 29354f2..0000000
--- a/java/com/google/gerrit/server/mail/send/EmailHeader.java
+++ /dev/null
@@ -1,234 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import com.google.gerrit.server.mail.Address;
-import java.io.IOException;
-import java.io.Writer;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Locale;
-import java.util.Objects;
-
-public abstract class EmailHeader {
-  public abstract boolean isEmpty();
-
-  public abstract void write(Writer w) throws IOException;
-
-  public static class String extends EmailHeader {
-    private final java.lang.String value;
-
-    public String(java.lang.String v) {
-      value = v;
-    }
-
-    public java.lang.String getString() {
-      return value;
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return value == null || value.length() == 0;
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      if (needsQuotedPrintable(value)) {
-        w.write(quotedPrintable(value));
-      } else {
-        w.write(value);
-      }
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(value);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof String) && Objects.equals(value, ((String) o).value);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(value).toString();
-    }
-  }
-
-  public static boolean needsQuotedPrintable(java.lang.String value) {
-    for (int i = 0; i < value.length(); i++) {
-      if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  static boolean needsQuotedPrintableWithinPhrase(int cp) {
-    switch (cp) {
-      case '!':
-      case '*':
-      case '+':
-      case '-':
-      case '/':
-      case '=':
-      case '_':
-        return false;
-      default:
-        if (('a' <= cp && cp <= 'z') || ('A' <= cp && cp <= 'Z') || ('0' <= cp && cp <= '9')) {
-          return false;
-        }
-        return true;
-    }
-  }
-
-  public static java.lang.String quotedPrintable(java.lang.String value) {
-    final StringBuilder r = new StringBuilder();
-
-    r.append("=?UTF-8?Q?");
-    for (int i = 0; i < value.length(); i++) {
-      final int cp = value.codePointAt(i);
-      if (cp == ' ') {
-        r.append('_');
-
-      } else if (needsQuotedPrintableWithinPhrase(cp)) {
-        byte[] buf = new java.lang.String(Character.toChars(cp)).getBytes(UTF_8);
-        for (byte b : buf) {
-          r.append('=');
-          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
-          r.append(Integer.toHexString(b & 0x0f).toUpperCase());
-        }
-
-      } else {
-        r.append(Character.toChars(cp));
-      }
-    }
-    r.append("?=");
-
-    return r.toString();
-  }
-
-  public static class Date extends EmailHeader {
-    private final java.util.Date value;
-
-    public Date(java.util.Date v) {
-      value = v;
-    }
-
-    public java.util.Date getDate() {
-      return value;
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return value == null;
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      final SimpleDateFormat fmt;
-      // Mon, 1 Jun 2009 10:49:44 -0700
-      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
-      w.write(fmt.format(value));
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(value);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof Date) && Objects.equals(value, ((Date) o).value);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(value).toString();
-    }
-  }
-
-  public static class AddressList extends EmailHeader {
-    private final List<Address> list = new ArrayList<>();
-
-    public AddressList() {}
-
-    public AddressList(Address addr) {
-      add(addr);
-    }
-
-    public List<Address> getAddressList() {
-      return Collections.unmodifiableList(list);
-    }
-
-    public void add(Address addr) {
-      list.add(addr);
-    }
-
-    void remove(java.lang.String email) {
-      list.removeIf(address -> address.getEmail().equals(email));
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return list.isEmpty();
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      int len = 8;
-      boolean firstAddress = true;
-      boolean needComma = false;
-      for (Address addr : list) {
-        java.lang.String s = addr.toHeaderString();
-        if (firstAddress) {
-          firstAddress = false;
-        } else if (72 < len + s.length()) {
-          w.write(",\r\n\t");
-          len = 8;
-          needComma = false;
-        }
-
-        if (needComma) {
-          w.write(", ");
-        }
-        w.write(s);
-        len += s.length();
-        needComma = true;
-      }
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(list);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof AddressList) && Objects.equals(list, ((AddressList) o).list);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(list).toString();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/EmailSender.java b/java/com/google/gerrit/server/mail/send/EmailSender.java
index 23fa1fe..9b3a1f7 100644
--- a/java/com/google/gerrit/server/mail/send/EmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/EmailSender.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 import java.util.Collection;
 import java.util.Map;
 
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
index 2489063..5baabe9 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.mail.Address;
 
 /** Constructs an address to send email from. */
 public interface FromAddressGenerator {
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
index 500eef3..c5f0257 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -17,13 +17,13 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.MailUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -124,8 +124,8 @@
       String senderName;
       if (fromId != null) {
         Optional<Account> a = accountCache.get(fromId).map(AccountState::getAccount);
-        String fullName = a.map(Account::getFullName).orElse(null);
-        String userEmail = a.map(Account::getPreferredEmail).orElse(null);
+        String fullName = a.map(Account::fullName).orElse(null);
+        String userEmail = a.map(Account::preferredEmail).orElse(null);
         if (canRelay(userEmail)) {
           return new Address(fullName, userEmail);
         }
@@ -208,8 +208,7 @@
       final String senderName;
 
       if (fromId != null) {
-        String fullName =
-            accountCache.get(fromId).map(a -> a.getAccount().getFullName()).orElse(null);
+        String fullName = accountCache.get(fromId).map(a -> a.getAccount().fullName()).orElse(null);
         if (fullName == null || "".equals(fullName)) {
           fullName = anonymousCowardName;
         }
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
new file mode 100644
index 0000000..2db2d6d
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+public class HttpPasswordUpdateSender extends OutgoingEmail {
+  public interface Factory {
+    HttpPasswordUpdateSender create(IdentifiedUser user, String operation);
+  }
+
+  private final IdentifiedUser user;
+  private final String operation;
+
+  @AssistedInject
+  public HttpPasswordUpdateSender(
+      EmailArguments args, @Assisted IdentifiedUser user, @Assisted String operation) {
+    super(args, "HttpPasswordUpdate");
+    this.user = user;
+    this.operation = operation;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+    setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
+    add(RecipientType.TO, new Address(getEmail()));
+  }
+
+  @Override
+  protected boolean shouldSendMessage() {
+    // Always send an email if the HTTP password is updated.
+    return true;
+  }
+
+  @Override
+  protected void format() throws EmailException {
+    appendText(textTemplate("HttpPasswordUpdate"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("HttpPasswordUpdateHtml"));
+    }
+  }
+
+  public String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  public String getUserNameEmail() {
+    return getUserNameEmailFor(user.getAccountId());
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("email", getEmail());
+    soyContextEmailData.put("userNameEmail", getUserNameEmail());
+    soyContextEmailData.put("operation", operation);
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index 5143dc7..110f26a 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailHeader;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import org.apache.james.mime4j.dom.field.FieldName;
@@ -32,7 +32,8 @@
     PARSING_ERROR,
     INACTIVE_ACCOUNT,
     UNKNOWN_ACCOUNT,
-    INTERNAL_EXCEPTION;
+    INTERNAL_EXCEPTION,
+    COMMENT_REJECTED
   }
 
   public interface Factory {
@@ -45,11 +46,14 @@
 
   @Inject
   public InboundEmailRejectionSender(
-      EmailArguments ea, @Assisted Address to, @Assisted String threadId, @Assisted Error reason) {
-    super(ea, "error");
-    this.to = checkNotNull(to);
-    this.threadId = checkNotNull(threadId);
-    this.reason = checkNotNull(reason);
+      EmailArguments args,
+      @Assisted Address to,
+      @Assisted String threadId,
+      @Assisted Error reason) {
+    super(args, "error");
+    this.to = requireNonNull(to);
+    this.threadId = requireNonNull(threadId);
+    this.reason = requireNonNull(reason);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
new file mode 100644
index 0000000..151567e
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.common.io.CharStreams;
+import com.google.common.io.Resources;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.jbcsrc.api.SoySauce;
+import com.google.template.soy.shared.SoyAstCache;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/** Configures Soy Sauce object for rendering email templates. */
+@Singleton
+public class MailSoySauceProvider implements Provider<SoySauce> {
+
+  // Note: will fail to construct the tofu object if this array is empty.
+  private static final String[] TEMPLATES = {
+    "Abandoned.soy",
+    "AbandonedHtml.soy",
+    "AddKey.soy",
+    "AddKeyHtml.soy",
+    "ChangeFooter.soy",
+    "ChangeFooterHtml.soy",
+    "ChangeSubject.soy",
+    "Comment.soy",
+    "CommentHtml.soy",
+    "CommentFooter.soy",
+    "CommentFooterHtml.soy",
+    "DeleteKey.soy",
+    "DeleteKeyHtml.soy",
+    "DeleteReviewer.soy",
+    "DeleteReviewerHtml.soy",
+    "DeleteVote.soy",
+    "DeleteVoteHtml.soy",
+    "InboundEmailRejection.soy",
+    "InboundEmailRejectionHtml.soy",
+    "Footer.soy",
+    "FooterHtml.soy",
+    "HeaderHtml.soy",
+    "HttpPasswordUpdate.soy",
+    "HttpPasswordUpdateHtml.soy",
+    "Merged.soy",
+    "MergedHtml.soy",
+    "NewChange.soy",
+    "NewChangeHtml.soy",
+    "NoReplyFooter.soy",
+    "NoReplyFooterHtml.soy",
+    "Private.soy",
+    "RegisterNewEmail.soy",
+    "ReplacePatchSet.soy",
+    "ReplacePatchSetHtml.soy",
+    "Restored.soy",
+    "RestoredHtml.soy",
+    "Reverted.soy",
+    "RevertedHtml.soy",
+    "SetAssignee.soy",
+    "SetAssigneeHtml.soy",
+  };
+
+  private final SitePaths site;
+  private final SoyAstCache cache;
+
+  @Inject
+  MailSoySauceProvider(SitePaths site, SoyAstCache cache) {
+    this.site = site;
+    this.cache = cache;
+  }
+
+  @Override
+  public SoySauce get() throws ProvisionException {
+    SoyFileSet.Builder builder = SoyFileSet.builder();
+    builder.setSoyAstCache(cache);
+    for (String name : TEMPLATES) {
+      addTemplate(builder, name);
+    }
+    return builder.build().compileTemplates();
+  }
+
+  private void addTemplate(SoyFileSet.Builder builder, String name) throws ProvisionException {
+    // Load as a file in the mail templates directory if present.
+    Path tmpl = site.mail_dir.resolve(name);
+    if (Files.isRegularFile(tmpl)) {
+      String content;
+      // TODO(davido): Consider using JGit's FileSnapshot to cache based on
+      // mtime.
+      try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
+        content = CharStreams.toString(r);
+      } catch (IOException err) {
+        throw new ProvisionException(
+            "Failed to read template file " + tmpl.toAbsolutePath().toString(), err);
+      }
+      builder.add(content, tmpl.toAbsolutePath().toString());
+      return;
+    }
+
+    // Otherwise load the template as a resource.
+    String resourcePath = "com/google/gerrit/server/mail/" + name;
+    builder.add(Resources.getResource(resourcePath));
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java b/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
deleted file mode 100644
index 8d7df41..0000000
--- a/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.common.io.CharStreams;
-import com.google.common.io.Resources;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import com.google.template.soy.SoyFileSet;
-import com.google.template.soy.shared.SoyAstCache;
-import com.google.template.soy.tofu.SoyTofu;
-import java.io.IOException;
-import java.io.Reader;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-/** Configures Soy Tofu object for rendering email templates. */
-@Singleton
-public class MailSoyTofuProvider implements Provider<SoyTofu> {
-
-  // Note: will fail to construct the tofu object if this array is empty.
-  private static final String[] TEMPLATES = {
-    "Abandoned.soy",
-    "AbandonedHtml.soy",
-    "AddKey.soy",
-    "AddKeyHtml.soy",
-    "ChangeFooter.soy",
-    "ChangeFooterHtml.soy",
-    "ChangeSubject.soy",
-    "Comment.soy",
-    "CommentHtml.soy",
-    "CommentFooter.soy",
-    "CommentFooterHtml.soy",
-    "DeleteReviewer.soy",
-    "DeleteReviewerHtml.soy",
-    "DeleteVote.soy",
-    "DeleteVoteHtml.soy",
-    "InboundEmailRejection.soy",
-    "InboundEmailRejectionHtml.soy",
-    "Footer.soy",
-    "FooterHtml.soy",
-    "HeaderHtml.soy",
-    "Merged.soy",
-    "MergedHtml.soy",
-    "NewChange.soy",
-    "NewChangeHtml.soy",
-    "NoReplyFooter.soy",
-    "NoReplyFooterHtml.soy",
-    "Private.soy",
-    "RegisterNewEmail.soy",
-    "ReplacePatchSet.soy",
-    "ReplacePatchSetHtml.soy",
-    "Restored.soy",
-    "RestoredHtml.soy",
-    "Reverted.soy",
-    "RevertedHtml.soy",
-    "SetAssignee.soy",
-    "SetAssigneeHtml.soy",
-  };
-
-  private final SitePaths site;
-  private final SoyAstCache cache;
-
-  @Inject
-  MailSoyTofuProvider(SitePaths site, SoyAstCache cache) {
-    this.site = site;
-    this.cache = cache;
-  }
-
-  @Override
-  public SoyTofu get() throws ProvisionException {
-    SoyFileSet.Builder builder = SoyFileSet.builder();
-    builder.setSoyAstCache(cache);
-    for (String name : TEMPLATES) {
-      addTemplate(builder, name);
-    }
-    return builder.build().compileToTofu();
-  }
-
-  private void addTemplate(SoyFileSet.Builder builder, String name) throws ProvisionException {
-    // Load as a file in the mail templates directory if present.
-    Path tmpl = site.mail_dir.resolve(name);
-    if (Files.isRegularFile(tmpl)) {
-      String content;
-      // TODO(davido): Consider using JGit's FileSnapshot to cache based on
-      // mtime.
-      try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
-        content = CharStreams.toString(r);
-      } catch (IOException err) {
-        throw new ProvisionException(
-            "Failed to read template file " + tmpl.toAbsolutePath().toString(), err);
-      }
-      builder.add(content, tmpl.toAbsolutePath().toString());
-      return;
-    }
-
-    // Otherwise load the template as a resource.
-    String resourcePath = "com/google/gerrit/server/mail/" + name;
-    builder.add(Resources.getResource(resourcePath));
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index cf9257c..77332c1 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -19,28 +19,28 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 /** Send notice about a change successfully merged. */
 public class MergedSender extends ReplyToChangeSender {
   public interface Factory {
-    MergedSender create(Project.NameKey project, Change.Id id);
+    MergedSender create(Project.NameKey project, Change.Id changeId);
   }
 
   private final LabelTypes labelTypes;
 
   @Inject
-  public MergedSender(EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "merged", newChangeData(ea, project, id));
+  public MergedSender(
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "merged", newChangeData(args, project, changeId));
     labelTypes = changeData.getLabelTypes();
   }
 
@@ -69,26 +69,20 @@
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
       for (PatchSetApproval ca :
-          args.approvalsUtil.byPatchSet(
-              args.db.get(),
-              changeData.notes(),
-              args.identifiedUserFactory.create(changeData.change().getOwner()),
-              patchSet.getId(),
-              null,
-              null)) {
-        LabelType lt = labelTypes.byLabel(ca.getLabelId());
+          args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id(), null, null)) {
+        LabelType lt = labelTypes.byLabel(ca.labelId());
         if (lt == null) {
           continue;
         }
-        if (ca.getValue() > 0) {
-          pos.put(ca.getAccountId(), lt.getName(), ca);
-        } else if (ca.getValue() < 0) {
-          neg.put(ca.getAccountId(), lt.getName(), ca);
+        if (ca.value() > 0) {
+          pos.put(ca.accountId(), lt.getName(), ca);
+        } else if (ca.value() < 0) {
+          neg.put(ca.accountId(), lt.getName(), ca);
         }
       }
 
       return format("Approvals", pos) + format("Objections", neg);
-    } catch (OrmException err) {
+    } catch (StorageException err) {
       // Don't list the approvals
     }
     return "";
@@ -123,7 +117,7 @@
         } else {
           txt.append(lt.getName());
           txt.append('=');
-          txt.append(LabelValue.formatValue(ca.getValue()));
+          txt.append(LabelValue.formatValue(ca.value()));
         }
       }
       txt.append('\n');
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 9f94fa3..1f12cbb 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -33,8 +32,8 @@
   private final Set<Account.Id> extraCC = new HashSet<>();
   private final Set<Address> extraCCByEmail = new HashSet<>();
 
-  protected NewChangeSender(EmailArguments ea, ChangeData cd) throws OrmException {
-    super(ea, "newchange", cd);
+  protected NewChangeSender(EmailArguments args, ChangeData changeData) {
+    super(args, "newchange", changeData);
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
@@ -57,9 +56,11 @@
   protected void init() throws EmailException {
     super.init();
 
-    setHeader("Message-ID", getChangeMessageThreadId());
+    String threadId = getChangeMessageThreadId();
+    setHeader("Message-ID", threadId);
+    setHeader("References", threadId);
 
-    switch (notify) {
+    switch (notify.handling()) {
       case NONE:
       case OWNER:
         break;
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 0cc7a1d..943a29b 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -17,15 +17,15 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gwtorm.server.OrmException;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -33,10 +33,10 @@
 public abstract class NotificationEmail extends OutgoingEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  protected Branch.NameKey branch;
+  protected BranchNameKey branch;
 
-  protected NotificationEmail(EmailArguments ea, String mc, Branch.NameKey branch) {
-    super(ea, mc);
+  protected NotificationEmail(EmailArguments args, String messageClass, BranchNameKey branch) {
+    super(args, messageClass);
     this.branch = branch;
   }
 
@@ -50,7 +50,7 @@
     // Set a reasonable list id so that filters can be used to sort messages
     setHeader(
         "List-Id",
-        "<gerrit-" + branch.getParentKey().get().replace('/', '-') + "." + getGerritHost() + ">");
+        "<gerrit-" + branch.project().get().replace('/', '-') + "." + getGerritHost() + ">");
     if (getSettingsUrl() != null) {
       setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
     }
@@ -68,7 +68,7 @@
       add(RecipientType.TO, matching.to);
       add(RecipientType.CC, matching.cc);
       add(RecipientType.BCC, matching.bcc);
-    } catch (OrmException err) {
+    } catch (StorageException err) {
       // Just don't CC everyone. Better to send a partial message to those
       // we already have queued up then to fail deliver entirely to people
       // who have a lower interest in the change.
@@ -77,8 +77,7 @@
   }
 
   /** Returns all watchers that are relevant */
-  protected abstract Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
-      throws OrmException;
+  protected abstract Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig);
 
   /** Add users or email addresses to the TO, CC, or BCC list. */
   protected void add(RecipientType type, Watchers.List list) {
@@ -105,7 +104,7 @@
   protected void setupSoyContext() {
     super.setupSoyContext();
 
-    String projectName = branch.getParentKey().get();
+    String projectName = branch.project().get();
     soyContext.put("projectName", projectName);
     // shortProjectName is the project name with the path abbreviated.
     soyContext.put("shortProjectName", getShortProjectName(projectName));
@@ -119,11 +118,11 @@
     soyContextEmailData.put("sshHost", getSshHost());
 
     Map<String, String> branchData = new HashMap<>();
-    branchData.put("shortName", branch.getShortName());
+    branchData.put("shortName", branch.shortName());
     soyContext.put("branch", branchData);
 
-    footers.add(MailHeader.PROJECT.withDelimiter() + branch.getParentKey().get());
-    footers.add("Gerrit-Branch: " + branch.getShortName());
+    footers.add(MailHeader.PROJECT.withDelimiter() + branch.project().get());
+    footers.add("Gerrit-Branch: " + branch.shortName());
   }
 
   @VisibleForTesting
@@ -132,6 +131,9 @@
     if (lastIndexSlash == 0) {
       return projectName.substring(1); // Remove the first slash
     }
+    if (lastIndexSlash == -1) { // No slash in the project name
+      return projectName;
+    }
 
     return "..." + projectName.substring(lastIndexSlash + 1);
   }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index a62a910..61b5327 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -14,28 +14,27 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
+import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.mail.EmailHeader.AddressList;
+import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
-import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
-import com.google.template.soy.data.SanitizedContent;
+import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.ArrayList;
@@ -55,6 +54,7 @@
 
 /** Sends an email to one or more interested parties. */
 public abstract class OutgoingEmail {
+  private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template.";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected String messageClass;
@@ -64,30 +64,25 @@
   private Address smtpFromAddress;
   private StringBuilder textBody;
   private StringBuilder htmlBody;
-  private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
   protected Map<String, Object> soyContext;
   protected Map<String, Object> soyContextEmailData;
   protected List<String> footers;
   protected final EmailArguments args;
   protected Account.Id fromId;
-  protected NotifyHandling notify = NotifyHandling.ALL;
+  protected NotifyResolver.Result notify = NotifyResolver.Result.all();
 
-  protected OutgoingEmail(EmailArguments ea, String mc) {
-    args = ea;
-    messageClass = mc;
-    headers = new LinkedHashMap<>();
+  protected OutgoingEmail(EmailArguments args, String messageClass) {
+    this.args = args;
+    this.messageClass = messageClass;
+    this.headers = new LinkedHashMap<>();
   }
 
   public void setFrom(Account.Id id) {
     fromId = id;
   }
 
-  public void setNotify(NotifyHandling notify) {
-    this.notify = checkNotNull(notify);
-  }
-
-  public void setAccountsToNotify(ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    this.accountsToNotify = checkNotNull(accountsToNotify);
+  public void setNotify(NotifyResolver.Result notify) {
+    this.notify = requireNonNull(notify);
   }
 
   /**
@@ -96,7 +91,7 @@
    * @throws EmailException
    */
   public void send() throws EmailException {
-    if (NotifyHandling.NONE.equals(notify) && accountsToNotify.isEmpty()) {
+    if (!notify.shouldNotify()) {
       return;
     }
 
@@ -128,7 +123,7 @@
             // on their behalf to others.
             //
             add(RecipientType.CC, fromId);
-          } else if (!accountsToNotify.containsValue(fromId) && rcptTo.remove(fromId)) {
+          } else if (!notify.accounts().containsValue(fromId) && rcptTo.remove(fromId)) {
             // If they don't want a copy, but we queued one up anyway,
             // drop them from the recipient lists.
             //
@@ -149,7 +144,7 @@
           } else if (useHtml() && prefs.getEmailFormat() == EmailFormat.PLAINTEXT) {
             removeUser(thisUserAccount);
             smtpRcptToPlaintextOnly.add(
-                new Address(thisUserAccount.getFullName(), thisUserAccount.getPreferredEmail()));
+                new Address(thisUserAccount.fullName(), thisUserAccount.preferredEmail()));
           }
         }
         if (smtpRcptTo.isEmpty() && smtpRcptToPlaintextOnly.isEmpty()) {
@@ -237,8 +232,8 @@
     setHeader(FieldName.MESSAGE_ID, "");
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
-    for (RecipientType recipientType : accountsToNotify.keySet()) {
-      add(recipientType, accountsToNotify.get(recipientType));
+    for (RecipientType recipientType : notify.accounts().keySet()) {
+      add(recipientType, notify.accounts().get(recipientType));
     }
 
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -255,8 +250,8 @@
     StringBuilder f = new StringBuilder();
     Optional<Account> account = args.accountCache.get(fromId).map(AccountState::getAccount);
     if (account.isPresent()) {
-      String name = account.get().getFullName();
-      String email = account.get().getPreferredEmail();
+      String name = account.get().fullName();
+      String email = account.get().preferredEmail();
       if ((name != null && !name.isEmpty()) || (email != null && !email.isEmpty())) {
         f.append("From");
         if (name != null && !name.isEmpty()) {
@@ -288,17 +283,11 @@
   }
 
   public String getSettingsUrl() {
-    if (getGerritUrl() != null) {
-      final StringBuilder r = new StringBuilder();
-      r.append(getGerritUrl());
-      r.append("settings");
-      return r.toString();
-    }
-    return null;
+    return args.urlFormatter.get().getSettingsUrl().orElse(null);
   }
 
-  public String getGerritUrl() {
-    return args.urlProvider.get();
+  private String getGerritUrl() {
+    return args.urlFormatter.get().getWebUrl().orElse(null);
   }
 
   /** Set a header in the outgoing message. */
@@ -338,9 +327,9 @@
     Optional<Account> account = args.accountCache.get(accountId).map(AccountState::getAccount);
     String name = null;
     if (account.isPresent()) {
-      name = account.get().getFullName();
+      name = account.get().fullName();
       if (name == null) {
-        name = account.get().getPreferredEmail();
+        name = account.get().preferredEmail();
       }
     }
     if (name == null) {
@@ -356,11 +345,11 @@
    * @param accountId user to fetch.
    * @return name/email of account, or Anonymous Coward if unset.
    */
-  public String getNameEmailFor(Account.Id accountId) {
+  protected String getNameEmailFor(Account.Id accountId) {
     Optional<Account> account = args.accountCache.get(accountId).map(AccountState::getAccount);
     if (account.isPresent()) {
-      String name = account.get().getFullName();
-      String email = account.get().getPreferredEmail();
+      String name = account.get().fullName();
+      String email = account.get().preferredEmail();
       if (name != null && email != null) {
         return name + " <" + email + ">";
       } else if (name != null) {
@@ -379,15 +368,15 @@
    * @param accountId user to fetch.
    * @return name/email of account, username, or null if unset.
    */
-  public String getUserNameEmailFor(Account.Id accountId) {
+  protected String getUserNameEmailFor(Account.Id accountId) {
     Optional<AccountState> accountState = args.accountCache.get(accountId);
     if (!accountState.isPresent()) {
       return null;
     }
 
     Account account = accountState.get().getAccount();
-    String name = account.getFullName();
-    String email = account.getPreferredEmail();
+    String name = account.fullName();
+    String email = account.preferredEmail();
     if (name != null && email != null) {
       return name + " <" + email + ">";
     } else if (email != null) {
@@ -411,7 +400,7 @@
       return false;
     }
 
-    if ((accountsToNotify == null || accountsToNotify.isEmpty())
+    if (notify.accounts().isEmpty()
         && smtpRcptTo.size() == 1
         && rcptTo.size() == 1
         && rcptTo.contains(fromId)) {
@@ -522,11 +511,11 @@
     }
 
     Account account = accountState.get();
-    String e = account.getPreferredEmail();
+    String e = account.preferredEmail();
     if (!account.isActive() || e == null) {
       return null;
     }
-    return new Address(account.getFullName(), e);
+    return new Address(account.fullName(), e);
   }
 
   protected void setupSoyContext() {
@@ -548,46 +537,23 @@
     return args.instanceNameProvider.get();
   }
 
-  private String soyTemplate(String name, SanitizedContent.ContentKind kind) {
-    return args.soyTofu
-        .newRenderer("com.google.gerrit.server.mail.template." + name)
-        .setContentKind(kind)
-        .setData(soyContext)
-        .render();
-  }
-
+  /** Renders a soy template of kind="text". */
   protected String textTemplate(String name) {
-    return soyTemplate(name, SanitizedContent.ContentKind.TEXT);
+    return configureRenderer(name).renderText().get();
   }
 
+  /** Renders a soy template of kind="html". */
   protected String soyHtmlTemplate(String name) {
-    return soyTemplate(name, SanitizedContent.ContentKind.HTML);
+    return configureRenderer(name).renderHtml().get().toString();
   }
 
-  public String joinStrings(Iterable<Object> in, String joiner) {
-    return joinStrings(in.iterator(), joiner);
-  }
-
-  public String joinStrings(Iterator<Object> in, String joiner) {
-    if (!in.hasNext()) {
-      return "";
-    }
-
-    Object first = in.next();
-    if (!in.hasNext()) {
-      return safeToString(first);
-    }
-
-    StringBuilder r = new StringBuilder();
-    r.append(safeToString(first));
-    while (in.hasNext()) {
-      r.append(joiner).append(safeToString(in.next()));
-    }
-    return r.toString();
+  /** Configures a soy renderer for the given template name and rendering data map. */
+  private SoySauce.Renderer configureRenderer(String templateName) {
+    return args.soySauce.renderTemplate(SOY_TEMPLATE_NAMESPACE + templateName).setData(soyContext);
   }
 
   protected void removeUser(Account user) {
-    String fromEmail = user.getPreferredEmail();
+    String fromEmail = user.preferredEmail();
     for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
       if (j.next().getEmail().equals(fromEmail)) {
         j.remove();
@@ -601,10 +567,6 @@
     }
   }
 
-  private static String safeToString(Object obj) {
-    return obj != null ? obj.toString() : "";
-  }
-
   protected final boolean useHtml() {
     return args.settings.html && supportsHtml();
   }
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 15197ef..8b426ac 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
@@ -30,12 +31,10 @@
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.git.NotifyConfig;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.SingleGroupUser;
-import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -62,13 +61,12 @@
   }
 
   /** Returns all watchers that are relevant */
-  public final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
-      throws OrmException {
+  public final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
     Watchers matching = new Watchers();
     Set<Account.Id> projectWatchers = new HashSet<>();
 
     for (AccountState a : args.accountQueryProvider.get().byWatchedProject(project)) {
-      Account.Id accountId = a.getAccount().getId();
+      Account.Id accountId = a.getAccount().id();
       for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e :
           a.getProjectWatches().entrySet()) {
         if (project.equals(e.getKey().project())
@@ -83,7 +81,7 @@
       for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e :
           a.getProjectWatches().entrySet()) {
         if (args.allProjectsName.equals(e.getKey().project())) {
-          Account.Id accountId = a.getAccount().getId();
+          Account.Id accountId = a.getAccount().id();
           if (!projectWatchers.contains(accountId)) {
             add(matching, accountId, e.getKey(), e.getValue(), type);
           }
@@ -99,9 +97,9 @@
       for (NotifyConfig nc : state.getConfig().getNotifyConfigs()) {
         if (nc.isNotify(type)) {
           try {
-            add(matching, nc);
+            add(matching, state.getNameKey(), nc);
           } catch (QueryParseException e) {
-            logger.atWarning().log(
+            logger.atInfo().log(
                 "Project %s has invalid notify %s filter \"%s\": %s",
                 state.getName(), nc.getName(), nc.getFilter(), e.getMessage());
           }
@@ -148,17 +146,27 @@
     }
   }
 
-  private void add(Watchers matching, NotifyConfig nc) throws OrmException, QueryParseException {
-    for (GroupReference ref : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(ref.getUUID());
+  private void add(Watchers matching, Project.NameKey projectName, NotifyConfig nc)
+      throws QueryParseException {
+    logger.atFine().log("Checking watchers for notify config %s from project %s", nc, projectName);
+    for (GroupReference groupRef : nc.getGroups()) {
+      CurrentUser user = new SingleGroupUser(groupRef.getUUID());
       if (filterMatch(user, nc.getFilter())) {
-        deliverToMembers(matching.list(nc.getHeader()), ref.getUUID());
+        deliverToMembers(matching.list(nc.getHeader()), groupRef.getUUID());
+        logger.atFine().log("Added watchers for group %s", groupRef);
+      } else {
+        logger.atFine().log("The filter did not match for group %s; skip notification", groupRef);
       }
     }
 
     if (!nc.getAddresses().isEmpty()) {
       if (filterMatch(null, nc.getFilter())) {
         matching.list(nc.getHeader()).emails.addAll(nc.getAddresses());
+        logger.atFine().log("Added watchers for these addresses: %s", nc.getAddresses());
+      } else {
+        logger.atFine().log(
+            "The filter did not match; skip notification for these addresses: %s",
+            nc.getAddresses());
       }
     }
   }
@@ -174,19 +182,24 @@
       AccountGroup.UUID uuid = q.remove(q.size() - 1);
       GroupDescription.Basic group = args.groupBackend.get(uuid);
       if (group == null) {
+        logger.atFine().log("group %s not found, skip notification", uuid);
         continue;
       }
       if (!Strings.isNullOrEmpty(group.getEmailAddress())) {
         // If the group has an email address, do not expand membership.
         matching.emails.add(new Address(group.getEmailAddress()));
+        logger.atFine().log(
+            "notify group email address %s; skip expanding to members", group.getEmailAddress());
         continue;
       }
 
       if (!(group instanceof GroupDescription.Internal)) {
         // Non-internal groups cannot be expanded by the server.
+        logger.atFine().log("group %s is not an internal group, skip notification", uuid);
         continue;
       }
 
+      logger.atFine().log("adding the members of group %s as watchers", uuid);
       GroupDescription.Internal ig = (GroupDescription.Internal) group;
       matching.accounts.addAll(ig.getMembers());
       for (AccountGroup.UUID m : ig.getSubgroups()) {
@@ -202,10 +215,10 @@
       Account.Id accountId,
       ProjectWatchKey key,
       Set<NotifyType> watchedTypes,
-      NotifyType type)
-      throws OrmException {
-    IdentifiedUser user = args.identifiedUserFactory.create(accountId);
+      NotifyType type) {
+    logger.atFine().log("Checking project watch %s of account %s", key, accountId);
 
+    IdentifiedUser user = args.identifiedUserFactory.create(accountId);
     try {
       if (filterMatch(user, key.filter())) {
         // If we are set to notify on this type, add the user.
@@ -213,16 +226,19 @@
         if (watchedTypes.contains(type)) {
           matching.bcc.accounts.add(accountId);
         }
+        logger.atFine().log("Added account %s as watcher", accountId);
         return true;
       }
+      logger.atFine().log("The filter did not match for account %s; skip notification", accountId);
     } catch (QueryParseException e) {
       // Ignore broken filter expressions.
+      logger.atInfo().log(
+          "Account %s has invalid filter in project watch %s: %s", accountId, key, e.getMessage());
     }
     return false;
   }
 
-  private boolean filterMatch(CurrentUser user, String filter)
-      throws OrmException, QueryParseException {
+  private boolean filterMatch(CurrentUser user, String filter) throws QueryParseException {
     ChangeQueryBuilder qb;
     Predicate<ChangeData> p = null;
 
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index c667026..91d8e81 100644
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.EmailTokenVerifier;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -36,14 +36,14 @@
 
   @Inject
   public RegisterNewEmailSender(
-      EmailArguments ea,
-      EmailTokenVerifier etv,
+      EmailArguments args,
+      EmailTokenVerifier tokenVerifier,
       IdentifiedUser callingUser,
       @Assisted final String address) {
-    super(ea, "registernewemail");
-    tokenVerifier = etv;
-    user = callingUser;
-    addr = address;
+    super(args, "registernewemail");
+    this.tokenVerifier = tokenVerifier;
+    this.user = callingUser;
+    this.addr = address;
   }
 
   @Override
@@ -64,7 +64,7 @@
 
   public String getEmailRegistrationToken() {
     if (emailToken == null) {
-      emailToken = checkNotNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
+      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
     }
     return emailToken;
   }
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 2398b82..807c09f 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
@@ -33,7 +32,7 @@
 /** Send notice of new patch sets for reviewers. */
 public class ReplacePatchSetSender extends ReplyToChangeSender {
   public interface Factory {
-    ReplacePatchSetSender create(Project.NameKey project, Change.Id id);
+    ReplacePatchSetSender create(Project.NameKey project, Change.Id changeId);
   }
 
   private final Set<Account.Id> reviewers = new HashSet<>();
@@ -41,9 +40,8 @@
 
   @Inject
   public ReplacePatchSetSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "newpatchset", newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "newpatchset", newChangeData(args, project, changeId));
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
@@ -63,7 +61,8 @@
       //
       reviewers.remove(fromId);
     }
-    if (notify == NotifyHandling.ALL || notify == NotifyHandling.OWNER_REVIEWERS) {
+    if (notify.handling() == NotifyHandling.ALL
+        || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
       add(RecipientType.TO, reviewers);
       add(RecipientType.CC, extraCC);
     }
diff --git a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
index 61e9d1d..46d5923 100644
--- a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 
 /** Alert a user to a reply to a change, usually commentary made during review. */
 public abstract class ReplyToChangeSender extends ChangeEmail {
@@ -27,8 +26,8 @@
     T create(Project.NameKey project, Change.Id id);
   }
 
-  protected ReplyToChangeSender(EmailArguments ea, String mc, ChangeData cd) throws OrmException {
-    super(ea, mc, cd);
+  protected ReplyToChangeSender(EmailArguments args, String messageClass, ChangeData changeData) {
+    super(args, messageClass, changeData);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
index d7f8eb5..e52f337 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -26,14 +25,13 @@
 public class RestoredSender extends ReplyToChangeSender {
   public interface Factory extends ReplyToChangeSender.Factory<RestoredSender> {
     @Override
-    RestoredSender create(Project.NameKey project, Change.Id id);
+    RestoredSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public RestoredSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "restore", ChangeEmail.newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "restore", ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
index 21703a3..374495f 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -14,25 +14,23 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 /** Send notice about a change being reverted. */
 public class RevertedSender extends ReplyToChangeSender {
   public interface Factory {
-    RevertedSender create(Project.NameKey project, Change.Id id);
+    RevertedSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
   public RevertedSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "revert", ChangeEmail.newChangeData(ea, project, id));
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, "revert", ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
index 9708b1b..03845de 100644
--- a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
+++ b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
@@ -14,30 +14,28 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 public class SetAssigneeSender extends ChangeEmail {
   public interface Factory {
-    SetAssigneeSender create(Project.NameKey project, Change.Id id, Account.Id assignee);
+    SetAssigneeSender create(Project.NameKey project, Change.Id changeId, Account.Id assignee);
   }
 
   private final Account.Id assignee;
 
   @Inject
   public SetAssigneeSender(
-      EmailArguments ea,
+      EmailArguments args,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id,
-      @Assisted Account.Id assignee)
-      throws OrmException {
-    super(ea, "setassignee", newChangeData(ea, project, id));
+      @Assisted Change.Id changeId,
+      @Assisted Account.Id assignee) {
+    super(args, "setassignee", newChangeData(args, project, changeId));
     this.assignee = assignee;
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 10e8b0b..3f103fc 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -19,13 +19,14 @@
 import com.google.common.io.BaseEncoding;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.Version;
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.Encryption;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -169,7 +170,7 @@
       throw new EmailException("Sending email is disabled");
     }
 
-    StringBuffer rejected = new StringBuffer();
+    StringBuilder rejected = new StringBuilder();
     try {
       final SMTPClient client = open();
       try {
@@ -206,10 +207,11 @@
              */
             throw new EmailException(
                 rejected
-                    + "Server "
-                    + smtpHost
-                    + " rejected DATA command: "
-                    + client.getReplyString());
+                    .append("Server ")
+                    .append(smtpHost)
+                    .append(" rejected DATA command: ")
+                    .append(client.getReplyString())
+                    .toString());
           }
 
           render(messageDataWriter, callerHeaders, textBody, htmlBody);
diff --git a/java/com/google/gerrit/server/mime/MimeUtil2Module.java b/java/com/google/gerrit/server/mime/MimeUtil2Module.java
index 387482a..7fdc4fb 100644
--- a/java/com/google/gerrit/server/mime/MimeUtil2Module.java
+++ b/java/com/google/gerrit/server/mime/MimeUtil2Module.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.mime;
 
-import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.server.ioutil.HostPlatform;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java b/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
index eecf935..0e9a2b7 100644
--- a/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
+++ b/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.mime;
 
+import static java.util.Comparator.comparing;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -22,12 +24,9 @@
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
 import java.io.InputStream;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 
@@ -115,16 +114,7 @@
       return MimeUtil2.UNKNOWN_MIME_TYPE;
     }
 
-    final List<MimeType> types = new ArrayList<>(mimeTypes);
-    Collections.sort(
-        types,
-        new Comparator<MimeType>() {
-          @Override
-          public int compare(MimeType a, MimeType b) {
-            return getCorrectedMimeSpecificity(b) - getCorrectedMimeSpecificity(a);
-          }
-        });
-    return types.get(0);
+    return Collections.max(mimeTypes, comparing(this::getCorrectedMimeSpecificity));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index a083a71..9c8bc1b 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -14,27 +14,25 @@
 
 package com.google.gerrit.server.notedb;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+import static java.util.Objects.requireNonNull;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -44,92 +42,84 @@
 public abstract class AbstractChangeNotes<T> {
   @VisibleForTesting
   @Singleton
+  @UsedAt(UsedAt.Project.PLUGIN_CHECKS)
   public static class Args {
-    final GitRepositoryManager repoManager;
-    final NotesMigration migration;
-    final AllUsersName allUsers;
-    final ChangeNoteJson changeNoteJson;
-    final LegacyChangeNoteRead legacyChangeNoteRead;
-    final NoteDbMetrics metrics;
-    final Provider<ReviewDb> db;
+    // TODO(dborowitz): Some less smelly way of disabling NoteDb in tests.
+    public final AtomicBoolean failOnLoadForTest;
+    public final ChangeNoteJson changeNoteJson;
+    public final GitRepositoryManager repoManager;
+    public final AllUsersName allUsers;
+    public final LegacyChangeNoteRead legacyChangeNoteRead;
+    public final NoteDbMetrics metrics;
 
     // Providers required to avoid dependency cycles.
 
-    // ChangeRebuilder -> ChangeNotes.Factory -> Args
-    final Provider<ChangeRebuilder> rebuilder;
-
     // ChangeNoteCache -> Args
-    final Provider<ChangeNotesCache> cache;
+    public final Provider<ChangeNotesCache> cache;
 
     @Inject
     Args(
         GitRepositoryManager repoManager,
-        NotesMigration migration,
         AllUsersName allUsers,
         ChangeNoteJson changeNoteJson,
         LegacyChangeNoteRead legacyChangeNoteRead,
         NoteDbMetrics metrics,
-        Provider<ReviewDb> db,
-        Provider<ChangeRebuilder> rebuilder,
         Provider<ChangeNotesCache> cache) {
+      this.failOnLoadForTest = new AtomicBoolean();
       this.repoManager = repoManager;
-      this.migration = migration;
       this.allUsers = allUsers;
       this.legacyChangeNoteRead = legacyChangeNoteRead;
       this.changeNoteJson = changeNoteJson;
       this.metrics = metrics;
-      this.db = db;
-      this.rebuilder = rebuilder;
       this.cache = cache;
     }
   }
 
-  @AutoValue
-  public abstract static class LoadHandle implements AutoCloseable {
-    public static LoadHandle create(ChangeNotesRevWalk walk, ObjectId id) {
+  public static class LoadHandle implements AutoCloseable {
+    private final Repository repo;
+    private final ObjectId id;
+    private ChangeNotesRevWalk rw;
+
+    private LoadHandle(Repository repo, @Nullable ObjectId id) {
+      this.repo = requireNonNull(repo);
+
       if (ObjectId.zeroId().equals(id)) {
         id = null;
       } else if (id != null) {
         id = id.copy();
       }
-      return new AutoValue_AbstractChangeNotes_LoadHandle(checkNotNull(walk), id);
+      this.id = id;
     }
 
-    public static LoadHandle missing() {
-      return new AutoValue_AbstractChangeNotes_LoadHandle(null, null);
+    public ChangeNotesRevWalk walk() {
+      if (rw == null) {
+        rw = ChangeNotesCommit.newRevWalk(repo);
+      }
+      return rw;
     }
 
     @Nullable
-    public abstract ChangeNotesRevWalk walk();
-
-    @Nullable
-    public abstract ObjectId id();
+    public ObjectId id() {
+      return id;
+    }
 
     @Override
     public void close() {
-      if (walk() != null) {
-        walk().close();
+      if (rw != null) {
+        rw.close();
       }
     }
   }
 
   protected final Args args;
-  protected final PrimaryStorage primaryStorage;
-  protected final boolean autoRebuild;
   private final Change.Id changeId;
 
   private ObjectId revision;
   private boolean loaded;
 
-  AbstractChangeNotes(
-      Args args, Change.Id changeId, @Nullable PrimaryStorage primaryStorage, boolean autoRebuild) {
-    this.args = checkNotNull(args);
-    this.changeId = checkNotNull(changeId);
-    this.primaryStorage = primaryStorage;
-    this.autoRebuild =
-        primaryStorage == PrimaryStorage.REVIEW_DB
-            && !args.migration.disableChangeReviewDb()
-            && autoRebuild;
+  protected AbstractChangeNotes(Args args, Change.Id changeId) {
+    this.args = requireNonNull(args);
+    this.changeId = requireNonNull(changeId);
   }
 
   public Change.Id getChangeId() {
@@ -141,39 +131,24 @@
     return revision;
   }
 
-  public T load() throws OrmException {
+  public T load() {
     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);
+
+    if (args.failOnLoadForTest.get()) {
+      throw new StorageException("Reading from NoteDb is disabled");
     }
-    boolean readOrWrite = read || args.migration.rawWriteChangesSetting();
-    if (!readOrWrite) {
-      // Don't even open the repo if we neither write to nor read from NoteDb. It's possible that
-      // there is some garbage in the noteDbState field and/or the repo, but at this point NoteDb is
-      // completely off so it's none of our business.
-      loadDefaults();
-      return self();
-    }
-    if (args.migration.failOnLoadForTest()) {
-      throw new OrmException("Reading from NoteDb is disabled");
-    }
-    try (Timer1.Context timer = args.metrics.readLatency.start(CHANGES);
+    try (Timer1.Context<NoteDbTable> timer = args.metrics.readLatency.start(CHANGES);
         Repository repo = args.repoManager.openRepository(getProjectName());
         // Call openHandle even if reading is disabled, to trigger
         // auto-rebuilding before this object may get passed to a ChangeUpdate.
         LoadHandle handle = openHandle(repo)) {
-      if (read) {
-        revision = handle.id();
-        onLoad(handle);
-      } else {
-        loadDefaults();
-      }
+      revision = handle.id();
+      onLoad(handle);
       loaded = true;
     } catch (ConfigInvalidException | IOException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
     return self();
   }
@@ -198,25 +173,23 @@
   }
 
   protected LoadHandle openHandle(Repository repo, ObjectId id) {
-    return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), id);
+    return new LoadHandle(repo, id);
   }
 
-  public T reload() throws NoSuchChangeException, OrmException {
+  public T reload() {
     loaded = false;
     return load();
   }
 
-  public ObjectId loadRevision() throws OrmException {
+  public ObjectId loadRevision() {
     if (loaded) {
       return getRevision();
-    } else if (!args.migration.readChanges()) {
-      return null;
     }
     try (Repository repo = args.repoManager.openRepository(getProjectName())) {
       Ref ref = repo.getRefDatabase().exactRef(getRefName());
       return ref != null ? ref.getObjectId() : null;
     } catch (IOException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 3653bc7..f0314c6 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;
@@ -26,12 +27,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
-import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.Date;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -41,13 +39,13 @@
 
 /** A single delta related to a specific patch-set of a change. */
 public abstract class AbstractChangeUpdate {
-  protected final NotesMigration migration;
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final ChangeNoteUtil noteUtil;
   protected final Account.Id accountId;
   protected final Account.Id realAccountId;
   protected final PersonIdent authorIdent;
   protected final Date when;
-  private final long readOnlySkewMs;
 
   @Nullable private final ChangeNotes notes;
   private final Change change;
@@ -58,14 +56,11 @@
   protected boolean rootOnly;
 
   protected AbstractChangeUpdate(
-      Config cfg,
-      NotesMigration migration,
       ChangeNotes notes,
       CurrentUser user,
       PersonIdent serverIdent,
       ChangeNoteUtil noteUtil,
       Date when) {
-    this.migration = migration;
     this.noteUtil = noteUtil;
     this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
@@ -75,12 +70,9 @@
     this.realAccountId = realAccountId != null ? realAccountId : accountId;
     this.authorIdent = ident(noteUtil, serverIdent, user, when);
     this.when = when;
-    this.readOnlySkewMs = NoteDbChangeState.getReadOnlySkew(cfg);
   }
 
   protected AbstractChangeUpdate(
-      Config cfg,
-      NotesMigration migration,
       ChangeNoteUtil noteUtil,
       PersonIdent serverIdent,
       @Nullable ChangeNotes notes,
@@ -92,7 +84,6 @@
     checkArgument(
         (notes != null && change == null) || (notes == null && change != null),
         "exactly one of notes or change required");
-    this.migration = migration;
     this.noteUtil = noteUtil;
     this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
@@ -101,7 +92,6 @@
     this.realAccountId = realAccountId;
     this.authorIdent = authorIdent;
     this.when = when;
-    this.readOnlySkewMs = NoteDbChangeState.getReadOnlySkew(cfg);
   }
 
   private static void checkUserType(CurrentUser user) {
@@ -120,9 +110,7 @@
       ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
-      return noteUtil
-          .getLegacyChangeNoteWrite()
-          .newIdent(u.asIdentifiedUser().getAccount(), when, serverIdent);
+      return noteUtil.newIdent(u.asIdentifiedUser().getAccount(), when, serverIdent);
     } else if (u instanceof InternalUser) {
       return serverIdent;
     }
@@ -159,7 +147,7 @@
   }
 
   public void setPatchSetId(PatchSet.Id psId) {
-    checkArgument(psId == null || psId.getParentKey().equals(getId()));
+    checkArgument(psId == null || psId.changeId().equals(getId()));
     this.psId = psId;
   }
 
@@ -177,7 +165,7 @@
   }
 
   protected PersonIdent newIdent(Account.Id authorId, Date when) {
-    return noteUtil.getLegacyChangeNoteWrite().newIdent(authorId, when, serverIdent);
+    return noteUtil.newIdent(authorId, when, serverIdent);
   }
 
   /** Whether no updates have been done. */
@@ -197,6 +185,14 @@
   protected abstract String getRefName();
 
   /**
+   * Whether to allow bypassing the check that an update does not exceed the max update count on an
+   * object.
+   */
+  protected boolean bypassMaxUpdates() {
+    return false;
+  }
+
+  /**
    * Apply this update to the given inserter.
    *
    * @param rw walk for reading back any objects needed for the update.
@@ -205,21 +201,19 @@
    * @return commit ID produced by inserting this update's commit, or null if this update is a no-op
    *     and should be skipped. The zero ID is a valid return value, and indicates the ref should be
    *     deleted.
-   * @throws OrmException if a Gerrit-level error occurred.
    * @throws IOException if a lower-level error occurred.
    */
-  final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException {
+  final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException {
     if (isEmpty()) {
       return null;
     }
 
-    // Allow this method to proceed even if migration.failChangeWrites() = true.
-    // This may be used by an auto-rebuilding step that the caller does not plan
-    // to actually store.
-
     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) {
@@ -247,18 +241,6 @@
     return result;
   }
 
-  protected void checkNotReadOnly() throws OrmException {
-    ChangeNotes notes = getNotes();
-    if (notes == null) {
-      // Can only happen during ChangeRebuilder, which will never include a read-only lease.
-      return;
-    }
-    Timestamp until = notes.getReadOnlyUntil();
-    if (until != null && NoteDbChangeState.timeForReadOnlyCheck(readOnlySkewMs).before(until)) {
-      throw new OrmException("change " + notes.getChangeId() + " is read-only until " + until);
-    }
-  }
-
   /**
    * Create a commit containing the contents of this update.
    *
@@ -269,11 +251,10 @@
    *     indicates to the caller that it should be copied from the parent commit. To indicate that
    *     this update is a no-op (but this could not be determined by {@link #isEmpty()}), return the
    *     sentinel {@link #NO_OP_UPDATE}.
-   * @throws OrmException if a Gerrit-level error occurred.
    * @throws IOException if a lower-level error occurred.
    */
   protected abstract CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException;
+      throws IOException;
 
   protected static final CommitBuilder NO_OP_UPDATE = new CommitBuilder();
 
@@ -290,7 +271,7 @@
   }
 
   protected void verifyComment(Comment c) {
-    checkArgument(c.revId != null, "RevId required for comment: %s", c);
+    checkArgument(c.getCommitId() != null, "commit ID required for comment: %s", c);
     checkArgument(
         c.author.getId().equals(getAccountId()),
         "The author for the following comment does not match the author of this %s (%s): %s",
diff --git a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
new file mode 100644
index 0000000..5d909d0
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.FanOutExecutor;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.transport.PushCertificate;
+
+/**
+ * Performs an update on {@code All-Users} asynchronously if required. No-op in case no updates were
+ * scheduled for asynchronous execution.
+ */
+public class AllUsersAsyncUpdate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ExecutorService executor;
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager repoManager;
+  private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+
+  private PersonIdent serverIdent;
+
+  @Inject
+  AllUsersAsyncUpdate(
+      @FanOutExecutor ExecutorService executor,
+      AllUsersName allUsersName,
+      GitRepositoryManager repoManager) {
+    this.executor = executor;
+    this.allUsersName = allUsersName;
+    this.repoManager = repoManager;
+    this.draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
+  }
+
+  void setDraftUpdates(ListMultimap<String, ChangeDraftUpdate> draftUpdates) {
+    checkState(isEmpty(), "attempted to set draft comment updates for async execution twice");
+    boolean allPublishOnly =
+        draftUpdates.values().stream().allMatch(ChangeDraftUpdate::canRunAsync);
+    checkState(allPublishOnly, "not all updates can be run asynchronously");
+    // Add deep copies to avoid any threading issues.
+    for (Map.Entry<String, ChangeDraftUpdate> entry : draftUpdates.entries()) {
+      this.draftUpdates.put(entry.getKey(), entry.getValue().copy());
+    }
+    if (draftUpdates.size() > 0) {
+      // Save the PersonIdent for later so that we get consistent time stamps in the commit and ref
+      // log.
+      serverIdent = Iterables.get(draftUpdates.entries(), 0).getValue().serverIdent;
+    }
+  }
+
+  /** Returns true if no operations should be performed on the repo. */
+  boolean isEmpty() {
+    return draftUpdates.isEmpty();
+  }
+
+  /** Executes repository update asynchronously. No-op in case no updates were scheduled. */
+  void execute(PersonIdent refLogIdent, String refLogMessage, PushCertificate pushCert) {
+    if (isEmpty()) {
+      return;
+    }
+
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        executor.submit(
+            () -> {
+              try (OpenRepo allUsersRepo = OpenRepo.open(repoManager, allUsersName)) {
+                allUsersRepo.addUpdates(draftUpdates);
+                allUsersRepo.flush();
+                BatchRefUpdate bru = allUsersRepo.repo.getRefDatabase().newBatchUpdate();
+                bru.setPushCertificate(pushCert);
+                if (refLogMessage != null) {
+                  bru.setRefLogMessage(refLogMessage, false);
+                } else {
+                  bru.setRefLogMessage(
+                      firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs async"),
+                      false);
+                }
+                bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent);
+                bru.setAtomic(true);
+                allUsersRepo.cmds.addTo(bru);
+                bru.setAllowNonFastForwards(true);
+                RefUpdateUtil.executeChecked(bru, allUsersRepo.rw);
+              } catch (IOException e) {
+                logger.atSevere().withCause(e).log(
+                    "Failed to delete draft comments asynchronously after publishing them");
+              }
+            });
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeBundle.java b/java/com/google/gerrit/server/notedb/ChangeBundle.java
deleted file mode 100644
index 1d3c752..0000000
--- a/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ /dev/null
@@ -1,1015 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.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.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
-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;
-import com.google.common.collect.ImmutableSortedMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
-import com.google.common.collect.Sets;
-import com.google.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.PatchSetApproval;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.server.OrmException;
-import java.lang.reflect.Field;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-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.
- *
- * <p>See the {@link Change} Javadoc for a depiction of this tree. Bundles may be compared using
- * {@link #differencesFrom(ChangeBundle)}, which normalizes out the minor implementation differences
- * between ReviewDb and NoteDb.
- */
-public class ChangeBundle {
-  public enum Source {
-    REVIEW_DB,
-    NOTE_DB;
-  }
-
-  public static ChangeBundle fromNotes(CommentsUtil commentsUtil, ChangeNotes notes)
-      throws OrmException {
-    return new ChangeBundle(
-        notes.getChange(),
-        notes.getChangeMessages(),
-        notes.getPatchSets().values(),
-        notes.getApprovals().values(),
-        Iterables.concat(
-            CommentsUtil.toPatchLineComments(
-                notes.getChangeId(),
-                PatchLineComment.Status.DRAFT,
-                commentsUtil.draftByChange(null, notes)),
-            CommentsUtil.toPatchLineComments(
-                notes.getChangeId(),
-                PatchLineComment.Status.PUBLISHED,
-                commentsUtil.publishedByChange(null, notes))),
-        notes.getReviewers(),
-        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;
-  }
-
-  // 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 ImmutableList<ChangeMessage> changeMessageList(Iterable<ChangeMessage> in) {
-    return CHANGE_MESSAGE_ORDER.immutableSortedCopy(in);
-  }
-
-  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 Map<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;
-  }
-
-  private static Map<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;
-  }
-
-  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());
-  }
-
-  static {
-    // Initialization-time checks that the column set hasn't changed since the
-    // last time this file was updated.
-    checkColumns(Change.Id.class, 1);
-
-    checkColumns(
-        Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 20, 21, 22, 23, 101);
-    checkColumns(ChangeMessage.Key.class, 1, 2);
-    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
-    checkColumns(PatchSet.Id.class, 1, 2);
-    checkColumns(PatchSet.class, 1, 2, 3, 4, 6, 8, 9);
-    checkColumns(PatchSetApproval.Key.class, 1, 2, 3);
-    checkColumns(PatchSetApproval.class, 1, 2, 3, 6, 7, 8);
-    checkColumns(PatchLineComment.Key.class, 1, 2);
-    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
-  }
-
-  private final Change change;
-  private final ImmutableList<ChangeMessage> changeMessages;
-  private final ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
-  private final ImmutableMap<PatchSetApproval.Key, PatchSetApproval> patchSetApprovals;
-  private final ImmutableMap<PatchLineComment.Key, PatchLineComment> patchLineComments;
-  private final ReviewerSet reviewers;
-  private final Source source;
-
-  public ChangeBundle(
-      Change change,
-      Iterable<ChangeMessage> changeMessages,
-      Iterable<PatchSet> patchSets,
-      Iterable<PatchSetApproval> patchSetApprovals,
-      Iterable<PatchLineComment> patchLineComments,
-      ReviewerSet reviewers,
-      Source source) {
-    this.change = checkNotNull(change);
-    this.changeMessages = changeMessageList(changeMessages);
-    this.patchSets = ImmutableSortedMap.copyOfSorted(patchSetMap(patchSets));
-    this.patchSetApprovals = ImmutableMap.copyOf(patchSetApprovalMap(patchSetApprovals));
-    this.patchLineComments = ImmutableMap.copyOf(patchLineCommentMap(patchLineComments));
-    this.reviewers = checkNotNull(reviewers);
-    this.source = checkNotNull(source);
-
-    for (ChangeMessage m : this.changeMessages) {
-      checkArgument(m.getKey().getParentKey().equals(change.getId()));
-    }
-    for (PatchSet.Id id : this.patchSets.keySet()) {
-      checkArgument(id.getParentKey().equals(change.getId()));
-    }
-    for (PatchSetApproval.Key k : this.patchSetApprovals.keySet()) {
-      checkArgument(k.getParentKey().getParentKey().equals(change.getId()));
-    }
-    for (PatchLineComment.Key k : this.patchLineComments.keySet()) {
-      checkArgument(k.getParentKey().getParentKey().getParentKey().equals(change.getId()));
-    }
-  }
-
-  public Change getChange() {
-    return change;
-  }
-
-  public ImmutableCollection<ChangeMessage> getChangeMessages() {
-    return changeMessages;
-  }
-
-  public ImmutableCollection<PatchSet> getPatchSets() {
-    return patchSets.values();
-  }
-
-  public ImmutableCollection<PatchSetApproval> getPatchSetApprovals() {
-    return patchSetApprovals.values();
-  }
-
-  public ImmutableCollection<PatchLineComment> getPatchLineComments() {
-    return patchLineComments.values();
-  }
-
-  public ReviewerSet getReviewers() {
-    return reviewers;
-  }
-
-  public Source getSource() {
-    return source;
-  }
-
-  public ImmutableList<String> differencesFrom(ChangeBundle o) {
-    List<String> diffs = new ArrayList<>();
-    diffChanges(diffs, this, o);
-    diffChangeMessages(diffs, this, o);
-    diffPatchSets(diffs, this, o);
-    diffPatchSetApprovals(diffs, this, o);
-    diffReviewers(diffs, this, o);
-    diffPatchLineComments(diffs, this, o);
-    return ImmutableList.copyOf(diffs);
-  }
-
-  private Timestamp getFirstPatchSetTime() {
-    if (patchSets.isEmpty()) {
-      return change.getCreatedOn();
-    }
-    return patchSets.firstEntry().getValue().getCreatedOn();
-  }
-
-  private Timestamp getLatestTimestamp() {
-    Ordering<Timestamp> o = Ordering.natural().nullsFirst();
-    Timestamp ts = null;
-    for (ChangeMessage cm : filterChangeMessages()) {
-      ts = o.max(ts, cm.getWrittenOn());
-    }
-    for (PatchSet ps : getPatchSets()) {
-      ts = o.max(ts, ps.getCreatedOn());
-    }
-    for (PatchSetApproval psa : filterPatchSetApprovals().values()) {
-      ts = o.max(ts, psa.getGranted());
-    }
-    for (PatchLineComment plc : filterPatchLineComments().values()) {
-      // Ignore draft comments, as they do not show up in the change meta graph.
-      if (plc.getStatus() != PatchLineComment.Status.DRAFT) {
-        ts = o.max(ts, plc.getWrittenOn());
-      }
-    }
-    return firstNonNull(ts, change.getLastUpdatedOn());
-  }
-
-  private Map<PatchSetApproval.Key, PatchSetApproval> filterPatchSetApprovals() {
-    return limitToValidPatchSets(patchSetApprovals, PatchSetApproval.Key::getParentKey);
-  }
-
-  private Map<PatchLineComment.Key, PatchLineComment> filterPatchLineComments() {
-    return limitToValidPatchSets(patchLineComments, k -> k.getParentKey().getParentKey());
-  }
-
-  private <K, V> Map<K, V> limitToValidPatchSets(Map<K, V> in, Function<K, PatchSet.Id> func) {
-    return Maps.filterKeys(in, Predicates.compose(validPatchSetPredicate(), func));
-  }
-
-  private Predicate<PatchSet.Id> validPatchSetPredicate() {
-    return patchSets::containsKey;
-  }
-
-  private Collection<ChangeMessage> filterChangeMessages() {
-    final Predicate<PatchSet.Id> validPatchSet = validPatchSetPredicate();
-    return Collections2.filter(
-        changeMessages,
-        m -> {
-          PatchSet.Id psId = m.getPatchSetId();
-          if (psId == null) {
-            return true;
-          }
-          return validPatchSet.apply(psId);
-        });
-  }
-
-  private static void diffChanges(List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    Change a = bundleA.change;
-    Change b = bundleB.change;
-    String desc = a.getId().equals(b.getId()) ? describe(a.getId()) : "Changes";
-
-    boolean excludeCreatedOn = false;
-    boolean excludeCurrentPatchSetId = false;
-    boolean excludeTopic = false;
-    Timestamp aCreated = a.getCreatedOn();
-    Timestamp bCreated = b.getCreatedOn();
-    Timestamp aUpdated = a.getLastUpdatedOn();
-    Timestamp bUpdated = b.getLastUpdatedOn();
-
-    boolean excludeSubject = false;
-    boolean excludeOrigSubj = false;
-    // Subject is not technically a nullable field, but we observed some null
-    // subjects in the wild on googlesource.com, so treat null as empty.
-    String aSubj = Strings.nullToEmpty(a.getSubject());
-    String bSubj = Strings.nullToEmpty(b.getSubject());
-
-    // Allow created timestamp in NoteDb to be any of:
-    //  - The created timestamp of the change.
-    //  - The timestamp of the first remaining patch set.
-    //  - The last updated timestamp, if it is less than the created timestamp.
-    //
-    // Ignore subject if the NoteDb subject starts with the ReviewDb subject.
-    // The NoteDb subject is read directly from the commit, whereas the ReviewDb
-    // subject historically may have been truncated to fit in a SQL varchar
-    // column.
-    //
-    // Ignore original subject on the ReviewDb side when comparing to NoteDb.
-    // This field may have any number of values:
-    //  - It may be null, if the change has had no new patch sets pushed since
-    //    migrating to schema 103.
-    //  - It may match the first patch set subject, if the change was created
-    //    after migrating to schema 103.
-    //  - It may match the subject of the first patch set that was pushed after
-    //    the migration to schema 103, even though that is neither the subject
-    //    of the first patch set nor the subject of the last patch set. (See
-    //    Change#setCurrentPatchSet as of 43b10f86 for this behavior.) This
-    //    subject of an intermediate patch set is not available to the
-    //    ChangeBundle; we would have to get the subject from the repo, which is
-    //    inconvenient at this point.
-    //
-    // Ignore original subject on the ReviewDb side if it equals the subject of
-    // the current patch set.
-    //
-    // For all of the above subject comparisons, first trim any leading spaces
-    // from the NoteDb strings. (We actually do represent the leading spaces
-    // faithfully during conversion, but JGit's FooterLine parser trims them
-    // when reading.)
-    //
-    // Ignore empty topic on the ReviewDb side if it is null on the NoteDb side.
-    //
-    // Ignore currentPatchSetId on NoteDb side if ReviewDb does not point to a
-    // valid patch set.
-    //
-    // Use max timestamp of all ReviewDb entities when comparing with NoteDb.
-    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-      boolean createdOnMatchesFirstPs =
-          !timestampsDiffer(bundleA, bundleA.getFirstPatchSetTime(), bundleB, bCreated);
-      boolean createdOnMatchesLastUpdatedOn =
-          !timestampsDiffer(bundleA, aUpdated, bundleB, bCreated);
-      boolean createdAfterUpdated = aCreated.compareTo(aUpdated) > 0;
-      excludeCreatedOn =
-          createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
-
-      aSubj = cleanReviewDbSubject(aSubj);
-      bSubj = cleanNoteDbSubject(bSubj);
-      excludeCurrentPatchSetId = !bundleA.validPatchSetPredicate().apply(a.currentPatchSetId());
-      excludeSubject = bSubj.startsWith(aSubj) || excludeCurrentPatchSetId;
-      excludeOrigSubj = true;
-      String aTopic = trimOrNull(a.getTopic());
-      excludeTopic =
-          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 =
-          !timestampsDiffer(bundleA, aCreated, bundleB, bundleB.getFirstPatchSetTime());
-      boolean createdOnMatchesLastUpdatedOn =
-          !timestampsDiffer(bundleA, aCreated, bundleB, bUpdated);
-      boolean createdAfterUpdated = bCreated.compareTo(bUpdated) > 0;
-      excludeCreatedOn =
-          createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
-
-      aSubj = cleanNoteDbSubject(aSubj);
-      bSubj = cleanReviewDbSubject(bSubj);
-      excludeCurrentPatchSetId = !bundleB.validPatchSetPredicate().apply(b.currentPatchSetId());
-      excludeSubject = aSubj.startsWith(bSubj) || excludeCurrentPatchSetId;
-      excludeOrigSubj = true;
-      String bTopic = trimOrNull(b.getTopic());
-      excludeTopic =
-          Objects.equals(bTopic, a.getTopic()) || a.getTopic() == null && "".equals(bTopic);
-      bUpdated = bundleB.getLatestTimestamp();
-    }
-
-    String subjectField = "subject";
-    String updatedField = "lastUpdatedOn";
-    List<String> exclude =
-        Lists.newArrayList(subjectField, updatedField, "noteDbState", "rowVersion");
-    if (excludeCreatedOn) {
-      exclude.add("createdOn");
-    }
-    if (excludeCurrentPatchSetId) {
-      exclude.add("currentPatchSetId");
-    }
-    if (excludeOrigSubj) {
-      exclude.add("originalSubject");
-    }
-    if (excludeTopic) {
-      exclude.add("topic");
-    }
-    diffColumnsExcluding(diffs, Change.class, desc, bundleA, a, bundleB, b, exclude);
-
-    // Allow last updated timestamps to either be exactly equal (within slop),
-    // or the NoteDb timestamp to be equal to the latest entity timestamp in the
-    // whole ReviewDb bundle (within slop).
-    if (timestampsDiffer(bundleA, a.getLastUpdatedOn(), bundleB, b.getLastUpdatedOn())) {
-      diffTimestamps(
-          diffs, desc, bundleA, aUpdated, bundleB, bUpdated, "effective last updated time");
-    }
-    if (!excludeSubject) {
-      diffValues(diffs, desc, aSubj, bSubj, subjectField);
-    }
-  }
-
-  private static String trimOrNull(String s) {
-    return s != null ? CharMatcher.whitespace().trimFrom(s) : null;
-  }
-
-  private static String cleanReviewDbSubject(String s) {
-    s = CharMatcher.is(' ').trimLeadingFrom(s);
-
-    // An old JGit bug failed to extract subjects from commits with "\r\n"
-    // terminators: https://bugs.eclipse.org/bugs/show_bug.cgi?id=400707
-    // Changes created with this bug may have "\r\n" converted to "\r " and the
-    // entire commit in the subject. The version of JGit used to read NoteDb
-    // changes parses these subjects correctly, so we need to clean up old
-    // ReviewDb subjects before comparing.
-    int rn = s.indexOf("\r \r ");
-    if (rn >= 0) {
-      s = s.substring(0, rn);
-    }
-    return NoteDbUtil.sanitizeFooter(s);
-  }
-
-  private static String cleanNoteDbSubject(String s) {
-    return NoteDbUtil.sanitizeFooter(s);
-  }
-
-  /**
-   * Set of fields that must always exactly match between ReviewDb and NoteDb.
-   *
-   * <p>Used to limit the worst-case quadratic search when pairing off matching messages below.
-   */
-  @AutoValue
-  abstract static class ChangeMessageCandidate {
-    static ChangeMessageCandidate create(ChangeMessage cm) {
-      return new AutoValue_ChangeBundle_ChangeMessageCandidate(
-          cm.getAuthor(), cm.getMessage(), cm.getTag());
-    }
-
-    @Nullable
-    abstract Account.Id author();
-
-    @Nullable
-    abstract String message();
-
-    @Nullable
-    abstract String tag();
-
-    // Exclude:
-    //  - patch set, which may be null on ReviewDb side but not NoteDb
-    //  - UUID, which is always different between ReviewDb and NoteDb
-    //  - writtenOn, which is fuzzy
-  }
-
-  private static void diffChangeMessages(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    if (bundleA.source == REVIEW_DB && bundleB.source == REVIEW_DB) {
-      // Both came from ReviewDb: check all fields exactly.
-      Map<ChangeMessage.Key, ChangeMessage> as = changeMessageMap(bundleA.filterChangeMessages());
-      Map<ChangeMessage.Key, ChangeMessage> bs = changeMessageMap(bundleB.filterChangeMessages());
-
-      for (ChangeMessage.Key k : diffKeySets(diffs, as, bs)) {
-        ChangeMessage a = as.get(k);
-        ChangeMessage b = bs.get(k);
-        String desc = describe(k);
-        diffColumns(diffs, ChangeMessage.class, desc, bundleA, a, bundleB, b);
-      }
-      return;
-    }
-    Change.Id id = bundleA.getChange().getId();
-    checkArgument(id.equals(bundleB.getChange().getId()));
-
-    // Try to pair up matching ChangeMessages from each side, and succeed only
-    // if both collections are empty at the end. Quadratic in the worst case,
-    // but easy to reason about.
-    List<ChangeMessage> as = new LinkedList<>(bundleA.filterChangeMessages());
-
-    ListMultimap<ChangeMessageCandidate, ChangeMessage> bs = LinkedListMultimap.create();
-    for (ChangeMessage b : bundleB.filterChangeMessages()) {
-      bs.put(ChangeMessageCandidate.create(b), b);
-    }
-
-    Iterator<ChangeMessage> ait = as.iterator();
-    A:
-    while (ait.hasNext()) {
-      ChangeMessage a = ait.next();
-      Iterator<ChangeMessage> bit = bs.get(ChangeMessageCandidate.create(a)).iterator();
-      while (bit.hasNext()) {
-        ChangeMessage b = bit.next();
-        if (changeMessagesMatch(bundleA, a, bundleB, b)) {
-          ait.remove();
-          bit.remove();
-          continue A;
-        }
-      }
-    }
-
-    if (as.isEmpty() && bs.isEmpty()) {
-      return;
-    }
-    StringBuilder sb =
-        new StringBuilder("ChangeMessages differ for Change.Id ").append(id).append('\n');
-    if (!as.isEmpty()) {
-      sb.append("Only in A:");
-      for (ChangeMessage cm : as) {
-        sb.append("\n  ").append(cm);
-      }
-      if (!bs.isEmpty()) {
-        sb.append('\n');
-      }
-    }
-    if (!bs.isEmpty()) {
-      sb.append("Only in B:");
-      for (ChangeMessage cm : CHANGE_MESSAGE_ORDER.sortedCopy(bs.values())) {
-        sb.append("\n  ").append(cm);
-      }
-    }
-    diffs.add(sb.toString());
-  }
-
-  private static boolean changeMessagesMatch(
-      ChangeBundle bundleA, ChangeMessage a, ChangeBundle bundleB, ChangeMessage b) {
-    List<String> tempDiffs = new ArrayList<>();
-    String temp = "temp";
-
-    // ReviewDb allows timestamps before patch set was created, but NoteDb
-    // truncates this to the patch set creation timestamp.
-    Timestamp ta = a.getWrittenOn();
-    Timestamp tb = b.getWrittenOn();
-    PatchSet psa = bundleA.patchSets.get(a.getPatchSetId());
-    PatchSet psb = bundleB.patchSets.get(b.getPatchSetId());
-    boolean excludePatchSet = false;
-    boolean excludeWrittenOn = false;
-    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-      excludePatchSet = a.getPatchSetId() == null;
-      excludeWrittenOn =
-          psa != null
-              && psb != null
-              && ta.before(psa.getCreatedOn())
-              && tb.equals(psb.getCreatedOn());
-    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-      excludePatchSet = b.getPatchSetId() == null;
-      excludeWrittenOn =
-          psa != null
-              && psb != null
-              && tb.before(psb.getCreatedOn())
-              && ta.equals(psa.getCreatedOn());
-    }
-
-    List<String> exclude = Lists.newArrayList("key");
-    if (excludePatchSet) {
-      exclude.add("patchset");
-    }
-    if (excludeWrittenOn) {
-      exclude.add("writtenOn");
-    }
-
-    diffColumnsExcluding(tempDiffs, ChangeMessage.class, temp, bundleA, a, bundleB, b, exclude);
-    return tempDiffs.isEmpty();
-  }
-
-  private static void diffPatchSets(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    Map<PatchSet.Id, PatchSet> as = bundleA.patchSets;
-    Map<PatchSet.Id, PatchSet> bs = bundleB.patchSets;
-    Optional<PatchSet.Id> minA = as.keySet().stream().min(intKeyOrdering());
-    Optional<PatchSet.Id> minB = bs.keySet().stream().min(intKeyOrdering());
-    Set<PatchSet.Id> ids = diffKeySets(diffs, as, bs);
-
-    // Old versions of Gerrit had a bug that created patch sets during
-    // rebase or submission with a createdOn timestamp earlier than the patch
-    // set it was replacing. (In the cases I examined, it was equal to createdOn
-    // for the change, but we're not counting on this exact behavior.)
-    //
-    // ChangeRebuilder ensures patch set events come out in order, but it's hard
-    // to predict what the resulting timestamps would look like. So, completely
-    // ignore the createdOn timestamps if both:
-    //   * ReviewDb timestamps are non-monotonic.
-    //   * NoteDb timestamps are monotonic.
-    //
-    // Allow the timestamp of the first patch set to match the creation time of
-    // the change.
-    boolean excludeAllCreatedOn = false;
-    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-      excludeAllCreatedOn = !createdOnIsMonotonic(as, ids) && createdOnIsMonotonic(bs, ids);
-    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-      excludeAllCreatedOn = createdOnIsMonotonic(as, ids) && !createdOnIsMonotonic(bs, ids);
-    }
-
-    for (PatchSet.Id id : ids) {
-      PatchSet a = as.get(id);
-      PatchSet b = bs.get(id);
-      String desc = describe(id);
-      String pushCertField = "pushCertificate";
-
-      boolean excludeCreatedOn = excludeAllCreatedOn;
-      boolean excludeDesc = false;
-      if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-        excludeDesc = Objects.equals(trimOrNull(a.getDescription()), b.getDescription());
-        excludeCreatedOn |=
-            Optional.of(id).equals(minB) && b.getCreatedOn().equals(bundleB.change.getCreatedOn());
-      } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-        excludeDesc = Objects.equals(a.getDescription(), trimOrNull(b.getDescription()));
-        excludeCreatedOn |=
-            Optional.of(id).equals(minA) && a.getCreatedOn().equals(bundleA.change.getCreatedOn());
-      }
-
-      List<String> exclude = Lists.newArrayList(pushCertField);
-      if (excludeCreatedOn) {
-        exclude.add("createdOn");
-      }
-      if (excludeDesc) {
-        exclude.add("description");
-      }
-
-      diffColumnsExcluding(diffs, PatchSet.class, desc, bundleA, a, bundleB, b, exclude);
-      diffValues(diffs, desc, trimPushCert(a), trimPushCert(b), pushCertField);
-    }
-  }
-
-  private static String trimPushCert(PatchSet ps) {
-    if (ps.getPushCertificate() == null) {
-      return null;
-    }
-    return CharMatcher.is('\n').trimTrailingFrom(ps.getPushCertificate());
-  }
-
-  private static boolean createdOnIsMonotonic(
-      Map<?, PatchSet> patchSets, Set<PatchSet.Id> limitToIds) {
-    List<PatchSet> orderedById =
-        patchSets
-            .values()
-            .stream()
-            .filter(ps -> limitToIds.contains(ps.getId()))
-            .sorted(ChangeUtil.PS_ID_ORDER)
-            .collect(toList());
-    return Ordering.natural().onResultOf(PatchSet::getCreatedOn).isOrdered(orderedById);
-  }
-
-  private static void diffPatchSetApprovals(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    Map<PatchSetApproval.Key, PatchSetApproval> as = bundleA.filterPatchSetApprovals();
-    Map<PatchSetApproval.Key, PatchSetApproval> bs = bundleB.filterPatchSetApprovals();
-    for (PatchSetApproval.Key k : diffKeySets(diffs, as, bs)) {
-      PatchSetApproval a = as.get(k);
-      PatchSetApproval b = bs.get(k);
-      String desc = describe(k);
-
-      // ReviewDb allows timestamps before patch set was created, but NoteDb
-      // truncates this to the patch set creation timestamp.
-      //
-      // ChangeRebuilder ensures all post-submit approvals happen after the
-      // actual submit, so the timestamps may not line up. This shouldn't really
-      // happen, because postSubmit shouldn't be set in ReviewDb until after the
-      // change is submitted in ReviewDb, but you never know.
-      //
-      // Due to a quirk of PostReview, post-submit 0 votes might not have the
-      // postSubmit bit set in ReviewDb. As these are only used for tombstone
-      // purposes, ignore the postSubmit bit in NoteDb in this case.
-      Timestamp ta = a.getGranted();
-      Timestamp tb = b.getGranted();
-      PatchSet psa = checkNotNull(bundleA.patchSets.get(a.getPatchSetId()));
-      PatchSet psb = checkNotNull(bundleB.patchSets.get(b.getPatchSetId()));
-      boolean excludeGranted = false;
-      boolean excludePostSubmit = false;
-      List<String> exclude = new ArrayList<>(1);
-      if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-        excludeGranted =
-            (ta.before(psa.getCreatedOn()) && tb.equals(psb.getCreatedOn()))
-                || ta.compareTo(tb) < 0;
-        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;
-        excludePostSubmit = b.getValue() == 0 && a.isPostSubmit();
-      }
-
-      // Legacy submit approvals may or may not have tags associated with them,
-      // depending on whether ChangeRebuilder happened to group them with the
-      // status change.
-      boolean excludeTag =
-          bundleA.source != bundleB.source && a.isLegacySubmit() && b.isLegacySubmit();
-
-      if (excludeGranted) {
-        exclude.add("granted");
-      }
-      if (excludePostSubmit) {
-        exclude.add("postSubmit");
-      }
-      if (excludeTag) {
-        exclude.add("tag");
-      }
-
-      diffColumnsExcluding(diffs, PatchSetApproval.class, desc, bundleA, a, bundleB, b, exclude);
-    }
-  }
-
-  private static void diffReviewers(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    diffSets(diffs, bundleA.reviewers.all(), bundleB.reviewers.all(), "reviewer");
-  }
-
-  private static void diffPatchLineComments(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    Map<PatchLineComment.Key, PatchLineComment> as = bundleA.filterPatchLineComments();
-    Map<PatchLineComment.Key, PatchLineComment> bs = bundleB.filterPatchLineComments();
-    for (PatchLineComment.Key k : diffKeySets(diffs, as, bs)) {
-      PatchLineComment a = as.get(k);
-      PatchLineComment b = bs.get(k);
-      String desc = describe(k);
-      diffColumns(diffs, PatchLineComment.class, desc, bundleA, a, bundleB, b);
-    }
-  }
-
-  private static <T> Set<T> diffKeySets(List<String> diffs, Map<T, ?> a, Map<T, ?> b) {
-    if (a.isEmpty() && b.isEmpty()) {
-      return a.keySet();
-    }
-    String clazz = keyClass((!a.isEmpty() ? a.keySet() : b.keySet()).iterator().next());
-    return diffSets(diffs, a.keySet(), b.keySet(), clazz);
-  }
-
-  private static <T> Set<T> diffSets(List<String> diffs, Set<T> as, Set<T> bs, String desc) {
-    if (as.isEmpty() && bs.isEmpty()) {
-      return as;
-    }
-
-    Set<T> aNotB = Sets.difference(as, bs);
-    Set<T> bNotA = Sets.difference(bs, as);
-    if (aNotB.isEmpty() && bNotA.isEmpty()) {
-      return as;
-    }
-    diffs.add(desc + " sets differ: " + aNotB + " only in A; " + bNotA + " only in B");
-    return Sets.intersection(as, bs);
-  }
-
-  private static <T> void diffColumns(
-      List<String> diffs,
-      Class<T> clazz,
-      String desc,
-      ChangeBundle bundleA,
-      T a,
-      ChangeBundle bundleB,
-      T b) {
-    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b);
-  }
-
-  private static <T> void diffColumnsExcluding(
-      List<String> diffs,
-      Class<T> clazz,
-      String desc,
-      ChangeBundle bundleA,
-      T a,
-      ChangeBundle bundleB,
-      T b,
-      String... exclude) {
-    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b, Arrays.asList(exclude));
-  }
-
-  private static <T> void diffColumnsExcluding(
-      List<String> diffs,
-      Class<T> clazz,
-      String desc,
-      ChangeBundle bundleA,
-      T a,
-      ChangeBundle bundleB,
-      T b,
-      Iterable<String> exclude) {
-    Set<String> toExclude = Sets.newLinkedHashSet(exclude);
-    for (Field f : clazz.getDeclaredFields()) {
-      Column col = f.getAnnotation(Column.class);
-      if (col == null) {
-        continue;
-      } else if (toExclude.remove(f.getName())) {
-        continue;
-      }
-      f.setAccessible(true);
-      try {
-        if (Timestamp.class.isAssignableFrom(f.getType())) {
-          diffTimestamps(diffs, desc, bundleA, a, bundleB, b, f.getName());
-        } else {
-          diffValues(diffs, desc, f.get(a), f.get(b), f.getName());
-        }
-      } catch (IllegalAccessException e) {
-        throw new IllegalArgumentException(e);
-      }
-    }
-    checkArgument(
-        toExclude.isEmpty(),
-        "requested columns to exclude not present in %s: %s",
-        clazz.getSimpleName(),
-        toExclude);
-  }
-
-  private static void diffTimestamps(
-      List<String> diffs,
-      String desc,
-      ChangeBundle bundleA,
-      Object a,
-      ChangeBundle bundleB,
-      Object b,
-      String field) {
-    checkArgument(a.getClass() == b.getClass());
-    Class<?> clazz = a.getClass();
-
-    Timestamp ta;
-    Timestamp tb;
-    try {
-      Field f = clazz.getDeclaredField(field);
-      checkArgument(f.getAnnotation(Column.class) != null);
-      f.setAccessible(true);
-      ta = (Timestamp) f.get(a);
-      tb = (Timestamp) f.get(b);
-    } catch (IllegalAccessException | NoSuchFieldException | SecurityException e) {
-      throw new IllegalArgumentException(e);
-    }
-    diffTimestamps(diffs, desc, bundleA, ta, bundleB, tb, field);
-  }
-
-  private static void diffTimestamps(
-      List<String> diffs,
-      String desc,
-      ChangeBundle bundleA,
-      Timestamp ta,
-      ChangeBundle bundleB,
-      Timestamp tb,
-      String fieldDesc) {
-    if (bundleA.source == bundleB.source || ta == null || tb == null) {
-      diffValues(diffs, desc, ta, tb, fieldDesc);
-    } else if (bundleA.source == NOTE_DB) {
-      diffTimestamps(diffs, desc, bundleA.getChange(), ta, bundleB.getChange(), tb, fieldDesc);
-    } else {
-      diffTimestamps(diffs, desc, bundleB.getChange(), tb, bundleA.getChange(), ta, fieldDesc);
-    }
-  }
-
-  private static boolean timestampsDiffer(
-      ChangeBundle bundleA, Timestamp ta, ChangeBundle bundleB, Timestamp tb) {
-    List<String> tempDiffs = new ArrayList<>(1);
-    diffTimestamps(tempDiffs, "temp", bundleA, ta, bundleB, tb, "temp");
-    return !tempDiffs.isEmpty();
-  }
-
-  private static void diffTimestamps(
-      List<String> diffs,
-      String desc,
-      Change changeFromNoteDb,
-      Timestamp tsFromNoteDb,
-      Change changeFromReviewDb,
-      Timestamp tsFromReviewDb,
-      String field) {
-    // Because ChangeRebuilder may batch events together that are several
-    // seconds apart, the timestamp in NoteDb may actually be several seconds
-    // *earlier* than the timestamp in ReviewDb that it was converted from.
-    checkArgument(
-        tsFromNoteDb.equals(truncateToSecond(tsFromNoteDb)),
-        "%s from NoteDb has non-rounded %s timestamp: %s",
-        desc,
-        field,
-        tsFromNoteDb);
-
-    if (tsFromReviewDb.before(changeFromReviewDb.getCreatedOn())
-        && tsFromNoteDb.equals(changeFromNoteDb.getCreatedOn())) {
-      // Timestamp predates change creation. These are truncated to change
-      // creation time during NoteDb conversion, so allow this if the timestamp
-      // in NoteDb matches the createdOn time in NoteDb.
-      return;
-    }
-
-    long delta = tsFromReviewDb.getTime() - tsFromNoteDb.getTime();
-    long max = ChangeRebuilderImpl.MAX_WINDOW_MS;
-    if (delta < 0 || delta > max) {
-      diffs.add(
-          field
-              + " differs for "
-              + desc
-              + " in NoteDb vs. ReviewDb:"
-              + " {"
-              + tsFromNoteDb
-              + "} != {"
-              + tsFromReviewDb
-              + "}");
-    }
-  }
-
-  private static void diffValues(
-      List<String> diffs, String desc, Object va, Object vb, String name) {
-    if (!Objects.equals(va, vb)) {
-      diffs.add(name + " differs for " + desc + ": {" + va + "} != {" + vb + "}");
-    }
-  }
-
-  private static String describe(Object key) {
-    return keyClass(key) + " " + key;
-  }
-
-  private static String keyClass(Object obj) {
-    Class<?> clazz = obj.getClass();
-    String name = clazz.getSimpleName();
-    checkArgument(name.endsWith("Key") || name.endsWith("Id"), "not an Id/Key class: %s", name);
-    if (name.equals("Key") || name.equals("Id")) {
-      return clazz.getEnclosingClass().getSimpleName() + "." + name;
-    } else if (name.startsWith("AutoValue_")) {
-      return name.substring(name.lastIndexOf('_') + 1);
-    }
-    return name;
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName()
-        + "{id="
-        + change.getId()
-        + ", ChangeMessage["
-        + changeMessages.size()
-        + "]"
-        + ", PatchSet["
-        + patchSets.size()
-        + "]"
-        + ", PatchSetApproval["
-        + patchSetApprovals.size()
-        + "]"
-        + ", PatchLineComment["
-        + patchLineComments.size()
-        + "]"
-        + "}";
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/ChangeBundleReader.java b/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
deleted file mode 100644
index 9e7a1fe1..0000000
--- a/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-
-public interface ChangeBundleReader {
-  ChangeBundle fromReviewDb(ReviewDb db, Change.Id id) throws OrmException;
-}
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 6b4bea7..bf27019 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -15,34 +15,32 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Sets;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 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.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
-import java.util.HashSet;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -76,25 +74,29 @@
 
   @AutoValue
   abstract static class Key {
-    abstract String revId();
+    abstract ObjectId commitId();
 
     abstract Comment.Key key();
   }
 
+  enum DeleteReason {
+    DELETED,
+    PUBLISHED,
+    FIXED
+  }
+
   private static Key key(Comment c) {
-    return new AutoValue_ChangeDraftUpdate_Key(c.revId, c.key);
+    return new AutoValue_ChangeDraftUpdate_Key(c.getCommitId(), c.key);
   }
 
   private final AllUsersName draftsProject;
 
   private List<Comment> put = new ArrayList<>();
-  private Set<Key> delete = new HashSet<>();
+  private Map<Key, DeleteReason> delete = new HashMap<>();
 
   @AssistedInject
   private ChangeDraftUpdate(
-      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
-      NotesMigration migration,
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       @Assisted ChangeNotes notes,
@@ -102,25 +104,13 @@
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        notes,
-        null,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
+    super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
 
   @AssistedInject
   private ChangeDraftUpdate(
-      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
-      NotesMigration migration,
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
@@ -128,62 +118,99 @@
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        null,
-        change,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
+    super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
 
   public void putComment(Comment c) {
+    checkState(!put.contains(c), "comment already added");
     verifyComment(c);
     put.add(c);
   }
 
-  public void deleteComment(Comment c) {
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user published it.
+   */
+  public void markCommentPublished(Comment c) {
+    checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
-    delete.add(key(c));
+    delete.put(key(c), DeleteReason.PUBLISHED);
   }
 
-  public void deleteComment(String revId, Comment.Key key) {
-    delete.add(new AutoValue_ChangeDraftUpdate_Key(revId, key));
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user removed it.
+   */
+  public void deleteComment(Comment c) {
+    checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
+    verifyComment(c);
+    delete.put(key(c), DeleteReason.DELETED);
+  }
+
+  /**
+   * Marks a comment for deletion. Called when the comment should have been deleted previously, but
+   * wasn't, so we're fixing it up.
+   */
+  public void deleteComment(ObjectId commitId, Comment.Key key) {
+    Key commentKey = new AutoValue_ChangeDraftUpdate_Key(commitId, key);
+    checkState(!delete.containsKey(commentKey), "comment already marked for deletion");
+    delete.put(commentKey, DeleteReason.FIXED);
+  }
+
+  /**
+   * Returns true if all we do in this operations is deletes caused by publishing or fixing up
+   * comments.
+   */
+  public boolean canRunAsync() {
+    return put.isEmpty()
+        && delete.values().stream()
+            .allMatch(r -> r == DeleteReason.PUBLISHED || r == DeleteReason.FIXED);
+  }
+
+  /**
+   * Returns a copy of the current {@link ChangeDraftUpdate} that contains references to all
+   * deletions. Copying of {@link ChangeDraftUpdate} is only allowed if it contains no new comments.
+   */
+  ChangeDraftUpdate copy() {
+    checkState(
+        put.isEmpty(),
+        "copying ChangeDraftUpdate is allowed only if it doesn't contain new comments");
+    ChangeDraftUpdate clonedUpdate =
+        new ChangeDraftUpdate(
+            authorIdent,
+            draftsProject,
+            noteUtil,
+            new Change(getChange()),
+            accountId,
+            realAccountId,
+            authorIdent,
+            when);
+    clonedUpdate.delete.putAll(delete);
+    return clonedUpdate;
   }
 
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
-      throws ConfigInvalidException, OrmException, IOException {
+      throws ConfigInvalidException, IOException {
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    Set<ObjectId> updatedCommits = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
     for (Comment c : put) {
-      if (!delete.contains(key(c))) {
-        cache.get(new RevId(c.revId)).putComment(c);
+      if (!delete.keySet().contains(key(c))) {
+        cache.get(c.getCommitId()).putComment(c);
       }
     }
-    for (Key k : delete) {
-      cache.get(new RevId(k.revId())).deleteComment(k.key());
+    for (Key k : delete.keySet()) {
+      cache.get(k.commitId()).deleteComment(k.key());
     }
 
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     boolean touchedAnyRevs = false;
     boolean hasComments = false;
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      updatedRevs.add(e.getKey());
-      ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data =
-          e.getValue()
-              .build(
-                  noteUtil.getChangeNoteJson(),
-                  noteUtil.getLegacyChangeNoteWrite(),
-                  noteUtil.getChangeNoteJson().getWriteJson());
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
+      updatedCommits.add(e.getKey());
+      ObjectId id = e.getKey();
+      byte[] data = e.getValue().build(noteUtil.getChangeNoteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
       }
@@ -205,7 +232,7 @@
 
     // If we touched every revision and there are no comments left, tell the
     // caller to delete the entire ref.
-    boolean touchedAllRevs = updatedRevs.equals(rnm.revisionNotes.keySet());
+    boolean touchedAllRevs = updatedCommits.equals(rnm.revisionNotes.keySet());
     if (touchedAllRevs && !hasComments) {
       return null;
     }
@@ -215,20 +242,17 @@
   }
 
   private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
-    if (migration.readChanges()) {
-      // If reading from changes is enabled, then the old DraftCommentNotes
-      // already parsed the revision notes. We can reuse them as long as the ref
-      // hasn't advanced.
-      ChangeNotes changeNotes = getNotes();
-      if (changeNotes != null) {
-        DraftCommentNotes draftNotes = changeNotes.load().getDraftCommentNotes();
-        if (draftNotes != null) {
-          ObjectId idFromNotes = firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
-          RevisionNoteMap<ChangeRevisionNote> rnm = draftNotes.getRevisionNoteMap();
-          if (idFromNotes.equals(curr) && rnm != null) {
-            return rnm;
-          }
+      throws ConfigInvalidException, IOException {
+    // The old DraftCommentNotes already parsed the revision notes. We can reuse them as long as
+    // the ref hasn't advanced.
+    ChangeNotes changeNotes = getNotes();
+    if (changeNotes != null) {
+      DraftCommentNotes draftNotes = changeNotes.load().getDraftCommentNotes();
+      if (draftNotes != null) {
+        ObjectId idFromNotes = firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
+        RevisionNoteMap<ChangeRevisionNote> rnm = draftNotes.getRevisionNoteMap();
+        if (idFromNotes.equals(curr) && rnm != null) {
+          return rnm;
         }
       }
     }
@@ -251,13 +275,13 @@
 
   @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException {
+      throws IOException {
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage("Update draft comments");
     try {
       return storeCommentsInNotes(rw, ins, curr, cb);
     } catch (ConfigInvalidException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 0475fe3..483b2e9 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -14,18 +14,14 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class ChangeNoteJson {
   private final Gson gson = newGson();
-  private final boolean writeJson;
 
   static Gson newGson() {
     return new GsonBuilder()
@@ -37,13 +33,4 @@
   public Gson getGson() {
     return gson;
   }
-
-  public boolean getWriteJson() {
-    return writeJson;
-  }
-
-  @Inject
-  ChangeNoteJson(@GerritServerConfig Config config) {
-    this.writeJson = config.getBoolean("notedb", "writeJson", true);
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 070f974..1ccd52f 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -14,8 +14,17 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.inject.Inject;
+import java.util.Date;
+import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.RawParseUtils;
 
 public class ChangeNoteUtil {
   public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
@@ -30,7 +39,6 @@
   public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
       new FooterKey("Patch-set-description");
   public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
-  public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until");
   public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
   public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
   public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
@@ -56,17 +64,17 @@
   static final String TAG = FOOTER_TAG.getName();
 
   private final LegacyChangeNoteRead legacyChangeNoteRead;
-  private final LegacyChangeNoteWrite legacyChangeNoteWrite;
   private final ChangeNoteJson changeNoteJson;
+  private final String serverId;
 
   @Inject
   public ChangeNoteUtil(
       ChangeNoteJson changeNoteJson,
       LegacyChangeNoteRead legacyChangeNoteRead,
-      LegacyChangeNoteWrite legacyChangeNoteWrite) {
+      @GerritServerId String serverId) {
+    this.serverId = serverId;
     this.changeNoteJson = changeNoteJson;
     this.legacyChangeNoteRead = legacyChangeNoteRead;
-    this.legacyChangeNoteWrite = legacyChangeNoteWrite;
   }
 
   public LegacyChangeNoteRead getLegacyChangeNoteRead() {
@@ -77,7 +85,103 @@
     return changeNoteJson;
   }
 
-  public LegacyChangeNoteWrite getLegacyChangeNoteWrite() {
-    return legacyChangeNoteWrite;
+  public PersonIdent newIdent(Account.Id authorId, Date when, PersonIdent serverIdent) {
+    return new PersonIdent(
+        "Gerrit User " + authorId.toString(),
+        authorId.get() + "@" + serverId,
+        when,
+        serverIdent.getTimeZone());
+  }
+
+  @VisibleForTesting
+  public PersonIdent newIdent(Account author, Date when, PersonIdent serverIdent) {
+    return new PersonIdent(
+        "Gerrit User " + author.id(),
+        author.id().get() + "@" + serverId,
+        when,
+        serverIdent.getTimeZone());
+  }
+
+  public static Optional<CommitMessageRange> parseCommitMessageRange(RevCommit commit) {
+    byte[] raw = commit.getRawBuffer();
+    int size = raw.length;
+
+    int subjectStart = RawParseUtils.commitMessage(raw, 0);
+    if (subjectStart < 0 || subjectStart >= size) {
+      return Optional.empty();
+    }
+
+    int subjectEnd = RawParseUtils.endOfParagraph(raw, subjectStart);
+    if (subjectEnd == size) {
+      return Optional.empty();
+    }
+
+    int changeMessageStart;
+
+    if (raw[subjectEnd] == '\n') {
+      changeMessageStart = subjectEnd + 2; // \n\n ends paragraph
+    } else if (raw[subjectEnd] == '\r') {
+      changeMessageStart = subjectEnd + 4; // \r\n\r\n ends paragraph
+    } else {
+      return Optional.empty();
+    }
+
+    int ptr = size - 1;
+    int changeMessageEnd = -1;
+    while (ptr > changeMessageStart) {
+      ptr = RawParseUtils.prevLF(raw, ptr, '\r');
+      if (ptr == -1) {
+        break;
+      }
+      if (raw[ptr] == '\n') {
+        changeMessageEnd = ptr - 1;
+        break;
+      } else if (raw[ptr] == '\r') {
+        changeMessageEnd = ptr - 3;
+        break;
+      }
+    }
+
+    if (ptr <= changeMessageStart) {
+      return Optional.empty();
+    }
+
+    CommitMessageRange range =
+        CommitMessageRange.builder()
+            .subjectStart(subjectStart)
+            .subjectEnd(subjectEnd)
+            .changeMessageStart(changeMessageStart)
+            .changeMessageEnd(changeMessageEnd)
+            .build();
+
+    return Optional.of(range);
+  }
+
+  @AutoValue
+  public abstract static class CommitMessageRange {
+    public abstract int subjectStart();
+
+    public abstract int subjectEnd();
+
+    public abstract int changeMessageStart();
+
+    public abstract int changeMessageEnd();
+
+    public static Builder builder() {
+      return new AutoValue_ChangeNoteUtil_CommitMessageRange.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder subjectStart(int subjectStart);
+
+      abstract Builder subjectEnd(int subjectEnd);
+
+      abstract Builder changeMessageStart(int changeMessageStart);
+
+      abstract Builder changeMessageEnd(int changeMessageEnd);
+
+      abstract CommitMessageRange build();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 7e66d929..e1217c2 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.notedb;
 
 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.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -28,20 +27,18 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Iterators;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Sets.SetView;
-import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -49,35 +46,24 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
-import com.google.gerrit.server.git.RepoRefCache;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -90,7 +76,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
-      Ordering.from(comparing(PatchSetApproval::getGranted));
+      Ordering.from(comparing(PatchSetApproval::granted));
 
   public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
       Ordering.from(comparing(ChangeMessage::getWrittenOn));
@@ -100,11 +86,6 @@
     return new ConfigInvalidException("Change " + changeId + ": " + String.format(fmt, args));
   }
 
-  @Nullable
-  public static Change readOneReviewDbChange(ReviewDb db, Change.Id id) throws OrmException {
-    return ReviewDbUtil.unwrapDb(db).changes().get(id);
-  }
-
   @Singleton
   public static class Factory {
     private final Args args;
@@ -120,27 +101,16 @@
       this.projectCache = projectCache;
     }
 
-    public ChangeNotes createChecked(ReviewDb db, Change c) throws OrmException {
-      return createChecked(db, c.getProject(), c.getId());
+    public ChangeNotes createChecked(Change c) {
+      return createChecked(c.getProject(), c.getId());
     }
 
-    public ChangeNotes createChecked(ReviewDb db, Project.NameKey project, Change.Id changeId)
-        throws OrmException {
-      Change change = readOneReviewDbChange(db, changeId);
-      if (change == null) {
-        if (!args.migration.readChanges()) {
-          throw new NoSuchChangeException(changeId);
-        }
-        // Change isn't in ReviewDb, but its primary storage might be in NoteDb.
-        // Prepopulate the change exists with proper noteDbState field.
-        change = newNoteDbOnlyChange(project, changeId);
-      } else if (!change.getProject().equals(project)) {
-        throw new NoSuchChangeException(changeId);
-      }
-      return new ChangeNotes(args, change).load();
+    public ChangeNotes createChecked(Project.NameKey project, Change.Id changeId) {
+      Change change = newChange(project, changeId);
+      return new ChangeNotes(args, change, true, null).load();
     }
 
-    public ChangeNotes createChecked(Change.Id changeId) throws OrmException {
+    public ChangeNotes createChecked(Change.Id changeId) {
       InternalChangeQuery query = queryProvider.get().noFields();
       List<ChangeData> changes = query.byLegacyChangeId(changeId);
       if (changes.isEmpty()) {
@@ -153,43 +123,14 @@
       return changes.get(0).notes();
     }
 
-    public static Change newNoteDbOnlyChange(Project.NameKey project, Change.Id changeId) {
-      Change change =
-          new Change(
-              null, changeId, null, new Branch.NameKey(project, "INVALID_NOTE_DB_ONLY"), null);
-      change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-      return change;
+    public static Change newChange(Project.NameKey project, Change.Id changeId) {
+      return new Change(
+          null, changeId, null, BranchNameKey.create(project, "INVALID_NOTE_DB_ONLY"), null);
     }
 
-    private Change loadChangeFromDb(ReviewDb db, Project.NameKey project, Change.Id changeId)
-        throws OrmException {
+    public ChangeNotes create(Project.NameKey project, Change.Id changeId) {
       checkArgument(project != null, "project is required");
-      Change change = readOneReviewDbChange(db, changeId);
-
-      if (change == null) {
-        if (args.migration.readChanges()) {
-          return newNoteDbOnlyChange(project, changeId);
-        }
-        throw new NoSuchChangeException(changeId);
-      }
-      checkArgument(
-          change.getProject().equals(project),
-          "passed project %s when creating ChangeNotes for %s, but actual project is %s",
-          project,
-          changeId,
-          change.getProject());
-      return change;
-    }
-
-    public ChangeNotes create(ReviewDb db, Project.NameKey project, Change.Id changeId)
-        throws OrmException {
-      return new ChangeNotes(args, loadChangeFromDb(db, project, changeId)).load();
-    }
-
-    public ChangeNotes createWithAutoRebuildingDisabled(
-        ReviewDb db, Project.NameKey project, Change.Id changeId) throws OrmException {
-      return new ChangeNotes(args, loadChangeFromDb(db, project, changeId), true, false, null)
-          .load();
+      return new ChangeNotes(args, newChange(project, changeId), true, null).load();
     }
 
     /**
@@ -200,201 +141,101 @@
      * @return change notes
      */
     public ChangeNotes createFromIndexedChange(Change change) {
-      return new ChangeNotes(args, change);
+      return new ChangeNotes(args, change, true, null);
     }
 
-    public ChangeNotes createForBatchUpdate(Change change, boolean shouldExist)
-        throws OrmException {
-      return new ChangeNotes(args, change, shouldExist, false, null).load();
+    public ChangeNotes createForBatchUpdate(Change change, boolean shouldExist) {
+      return new ChangeNotes(args, change, shouldExist, null).load();
     }
 
-    public ChangeNotes createWithAutoRebuildingDisabled(Change change, RefCache refs)
-        throws OrmException {
-      return new ChangeNotes(args, change, true, false, refs).load();
+    public ChangeNotes create(Change change, RefCache refs) {
+      return new ChangeNotes(args, change, true, refs).load();
     }
 
-    // TODO(ekempin): Remove when database backend is deleted
-    /**
-     * Instantiate ChangeNotes for a change that has been loaded by a batch read from the database.
-     */
-    private ChangeNotes createFromChangeOnlyWhenNoteDbDisabled(Change change) throws OrmException {
-      checkState(
-          !args.migration.readChanges(),
-          "do not call createFromChangeWhenNoteDbDisabled when NoteDb is enabled");
-      return new ChangeNotes(args, change).load();
-    }
-
-    public List<ChangeNotes> create(ReviewDb db, Collection<Change.Id> changeIds)
-        throws OrmException {
+    public List<ChangeNotes> create(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
-      if (args.migration.readChanges()) {
-        for (Change.Id changeId : changeIds) {
-          try {
-            notes.add(createChecked(changeId));
-          } catch (NoSuchChangeException e) {
-            // Ignore missing changes to match Access#get(Iterable) behavior.
-          }
+      for (Change.Id changeId : changeIds) {
+        try {
+          notes.add(createChecked(changeId));
+        } catch (NoSuchChangeException e) {
+          // Ignore missing changes to match Access#get(Iterable) behavior.
         }
-        return notes;
-      }
-
-      for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
-        notes.add(createFromChangeOnlyWhenNoteDbDisabled(c));
       }
       return notes;
     }
 
     public List<ChangeNotes> create(
-        ReviewDb db,
         Project.NameKey project,
         Collection<Change.Id> changeIds,
-        Predicate<ChangeNotes> predicate)
-        throws OrmException {
+        Predicate<ChangeNotes> predicate) {
       List<ChangeNotes> notes = new ArrayList<>();
-      if (args.migration.readChanges()) {
-        for (Change.Id cid : changeIds) {
-          try {
-            ChangeNotes cn = create(db, project, cid);
-            if (cn.getChange() != null && predicate.test(cn)) {
-              notes.add(cn);
-            }
-          } catch (NoSuchChangeException e) {
-            // Match ReviewDb behavior, returning not found; maybe the caller learned about it from
-            // a dangling patch set ref or something.
-            continue;
-          }
-        }
-        return notes;
-      }
-
-      for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
-        if (c != null && project.equals(c.getDest().getParentKey())) {
-          ChangeNotes cn = createFromChangeOnlyWhenNoteDbDisabled(c);
-          if (predicate.test(cn)) {
+      for (Change.Id cid : changeIds) {
+        try {
+          ChangeNotes cn = create(project, cid);
+          if (cn.getChange() != null && predicate.test(cn)) {
             notes.add(cn);
           }
+        } catch (NoSuchChangeException e) {
+          // Match ReviewDb behavior, returning not found; maybe the caller learned about it from
+          // a dangling patch set ref or something.
+          continue;
         }
       }
       return notes;
     }
 
-    public ListMultimap<Project.NameKey, ChangeNotes> create(
-        ReviewDb db, Predicate<ChangeNotes> predicate) throws IOException, OrmException {
+    public ListMultimap<Project.NameKey, ChangeNotes> create(Predicate<ChangeNotes> predicate)
+        throws IOException {
       ListMultimap<Project.NameKey, ChangeNotes> m =
           MultimapBuilder.hashKeys().arrayListValues().build();
-      if (args.migration.readChanges()) {
-        for (Project.NameKey project : projectCache.all()) {
-          try (Repository repo = args.repoManager.openRepository(project)) {
-            scanNoteDb(repo, db, project)
-                .filter(r -> !r.error().isPresent())
-                .map(ChangeNotesResult::notes)
-                .filter(predicate)
-                .forEach(n -> m.put(n.getProjectName(), n));
-          }
-        }
-      } else {
-        for (Change change : ReviewDbUtil.unwrapDb(db).changes().all()) {
-          ChangeNotes notes = createFromChangeOnlyWhenNoteDbDisabled(change);
-          if (predicate.test(notes)) {
-            m.put(change.getProject(), notes);
-          }
+      for (Project.NameKey project : projectCache.all()) {
+        try (Repository repo = args.repoManager.openRepository(project)) {
+          scan(repo, project)
+              .filter(r -> !r.error().isPresent())
+              .map(ChangeNotesResult::notes)
+              .filter(predicate)
+              .forEach(n -> m.put(n.getProjectName(), n));
         }
       }
       return ImmutableListMultimap.copyOf(m);
     }
 
-    public Stream<ChangeNotesResult> scan(Repository repo, ReviewDb db, Project.NameKey project)
+    public Stream<ChangeNotesResult> scan(Repository repo, Project.NameKey project)
         throws IOException {
-      return args.migration.readChanges() ? scanNoteDb(repo, db, project) : scanReviewDb(repo, db);
-    }
-
-    private Stream<ChangeNotesResult> scanReviewDb(Repository repo, ReviewDb db)
-        throws IOException {
-      // Scan IDs that might exist in ReviewDb, assuming that each change has at least one patch set
-      // ref. Not all changes might exist: some patch set refs might have been written where the
-      // corresponding ReviewDb write failed. These will be silently filtered out by the batch get
-      // call below, which is intended.
-      Set<Change.Id> ids = scanChangeIds(repo).fromPatchSetRefs();
-
-      // A batch size of N may overload get(Iterable), so use something smaller, but still >1.
-      return Streams.stream(Iterators.partition(ids.iterator(), 30))
-          .flatMap(
-              batch -> {
-                try {
-                  return Streams.stream(ReviewDbUtil.unwrapDb(db).changes().get(batch))
-                      .map(this::toResult)
-                      .filter(Objects::nonNull);
-                } catch (OrmException e) {
-                  // Return this error for each Id in the input batch.
-                  return batch.stream().map(id -> ChangeNotesResult.error(id, e));
-                }
-              });
-    }
-
-    private Stream<ChangeNotesResult> scanNoteDb(
-        Repository repo, ReviewDb db, Project.NameKey project) throws IOException {
       ScanResult sr = scanChangeIds(repo);
-      PrimaryStorage defaultStorage = args.migration.changePrimaryStorage();
 
-      return sr.all()
-          .stream()
-          .map(id -> scanOneNoteDbChange(db, project, sr, defaultStorage, id))
-          .filter(Objects::nonNull);
+      return sr.all().stream().map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
     }
 
-    private ChangeNotesResult scanOneNoteDbChange(
-        ReviewDb db,
-        Project.NameKey project,
-        ScanResult sr,
-        PrimaryStorage defaultStorage,
-        Change.Id id) {
-      Change change;
-      try {
-        change = readOneReviewDbChange(db, id);
-      } catch (OrmException e) {
-        return ChangeNotesResult.error(id, e);
-      }
-
-      if (change == null) {
-        if (!sr.fromMetaRefs().contains(id)) {
-          // Stray patch set refs can happen due to normal error conditions, e.g. failed
-          // push processing, so aren't worth even a warning.
-          return null;
-        }
-        if (defaultStorage == PrimaryStorage.REVIEW_DB) {
-          // If changes should exist in ReviewDb, it's worth warning about a meta ref with
-          // no corresponding ReviewDb data.
-          logger.atWarning().log(
-              "skipping change %s found in project %s but not in ReviewDb", id, project);
-          return null;
-        }
-        // TODO(dborowitz): See discussion in NoteDbBatchUpdate#newChangeContext.
-        change = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
-      } else if (!change.getProject().equals(project)) {
-        logger.atSevere().log(
-            "skipping change %s found in project %s because ReviewDb change has project %s",
-            id, project, change.getProject());
+    private ChangeNotesResult scanOneChange(Project.NameKey project, ScanResult sr, Change.Id id) {
+      if (!sr.fromMetaRefs().contains(id)) {
+        // Stray patch set refs can happen due to normal error conditions, e.g. failed
+        // push processing, so aren't worth even a warning.
         return null;
       }
+
+      // TODO(dborowitz): See discussion in BatchUpdate#newChangeContext.
+      Change change = ChangeNotes.Factory.newChange(project, id);
+
       logger.atFine().log("adding change %s found in project %s", id, project);
       return toResult(change);
     }
 
     @Nullable
-    private ChangeNotesResult toResult(Change rawChangeFromReviewDbOrNoteDb) {
-      ChangeNotes n = new ChangeNotes(args, rawChangeFromReviewDbOrNoteDb);
+    private ChangeNotesResult toResult(Change rawChangeFromNoteDb) {
+      ChangeNotes n = new ChangeNotes(args, rawChangeFromNoteDb, true, null);
       try {
         n.load();
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         return ChangeNotesResult.error(n.getChangeId(), e);
       }
       return ChangeNotesResult.notes(n);
     }
 
-    /** Result of {@link #scan(Repository, ReviewDb, Project.NameKey)}. */
+    /** Result of {@link #scan(Repository,Project.NameKey)}. */
     @AutoValue
     public abstract static class ChangeNotesResult {
-      static ChangeNotesResult error(Change.Id id, OrmException e) {
+      static ChangeNotesResult error(Change.Id id, StorageException e) {
         return new AutoValue_ChangeNotes_Factory_ChangeNotesResult(id, Optional.of(e), null);
       }
 
@@ -407,7 +248,7 @@
       public abstract Change.Id id();
 
       /** Error encountered while loading this change, if any. */
-      public abstract Optional<OrmException> error();
+      public abstract Optional<StorageException> error();
 
       /**
        * Notes loaded for this change.
@@ -439,7 +280,7 @@
     private static ScanResult scanChangeIds(Repository repo) throws IOException {
       ImmutableSet.Builder<Change.Id> fromPs = ImmutableSet.builder();
       ImmutableSet.Builder<Change.Id> fromMeta = ImmutableSet.builder();
-      for (Ref r : repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES).values()) {
+      for (Ref r : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
         Change.Id id = Change.Id.fromRef(r.getName());
         if (id != null) {
           (r.getName().endsWith(RefNames.META_SUFFIX) ? fromMeta : fromPs).add(id);
@@ -459,7 +300,6 @@
   // notes easier.
   RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
-  private NoteDbUpdateManager.Result rebuildResult;
   private DraftCommentNotes draftCommentNotes;
   private RobotCommentNotes robotCommentNotes;
 
@@ -470,13 +310,8 @@
   private ImmutableSet<Comment.Key> commentKeys;
 
   @VisibleForTesting
-  public ChangeNotes(Args args, Change change) {
-    this(args, change, true, true, null);
-  }
-
-  private ChangeNotes(
-      Args args, Change change, boolean shouldExist, boolean autoRebuild, @Nullable RefCache refs) {
-    super(args, change.getId(), PrimaryStorage.of(change), autoRebuild);
+  public ChangeNotes(Args args, Change change, boolean shouldExist, @Nullable RefCache refs) {
+    super(args, change.getId());
     this.change = new Change(change);
     this.shouldExist = shouldExist;
     this.refs = refs;
@@ -494,9 +329,7 @@
     if (patchSets == null) {
       ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
           ImmutableSortedMap.orderedBy(comparing(PatchSet.Id::get));
-      for (Map.Entry<PatchSet.Id, PatchSet> e : state.patchSets()) {
-        b.put(e.getKey(), new PatchSet(e.getValue()));
-      }
+      b.putAll(state.patchSets());
       patchSets = b.build();
     }
     return patchSets;
@@ -504,12 +337,7 @@
 
   public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
     if (approvals == null) {
-      ImmutableListMultimap.Builder<PatchSet.Id, PatchSetApproval> b =
-          ImmutableListMultimap.builder();
-      for (Map.Entry<PatchSet.Id, PatchSetApproval> e : state.approvals()) {
-        b.put(e.getKey(), new PatchSetApproval(e.getValue()));
-      }
-      approvals = b.build();
+      approvals = ImmutableListMultimap.copyOf(state.approvals());
     }
     return approvals;
   }
@@ -566,7 +394,7 @@
   }
 
   /** @return inline comments on each revision. */
-  public ImmutableListMultimap<RevId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, Comment> getComments() {
     return state.publishedComments();
   }
 
@@ -581,13 +409,16 @@
     return commentKeys;
   }
 
-  public ImmutableListMultimap<RevId, Comment> getDraftComments(Account.Id author)
-      throws OrmException {
+  public int getUpdateCount() {
+    return state.updateCount();
+  }
+
+  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null);
   }
 
-  public ImmutableListMultimap<RevId, Comment> getDraftComments(
-      Account.Id author, @Nullable Ref ref) throws OrmException {
+  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(
+      Account.Id author, @Nullable Ref ref) {
     loadDraftComments(author, ref);
     // Filter out any zombie draft comments. These are drafts that are also in
     // the published map, and arise when the update to All-Users to delete them
@@ -597,7 +428,7 @@
             draftCommentNotes.getComments(), e -> !getCommentKeys().contains(e.getValue().key)));
   }
 
-  public ImmutableListMultimap<RevId, RobotComment> getRobotComments() throws OrmException {
+  public ImmutableListMultimap<ObjectId, RobotComment> getRobotComments() {
     loadRobotComments();
     return robotCommentNotes.getComments();
   }
@@ -607,15 +438,14 @@
    * However, this method will load the comments if no draft comments have been loaded or if the
    * caller would like the drafts for another author.
    */
-  private void loadDraftComments(Account.Id author, @Nullable Ref ref) throws OrmException {
+  private void loadDraftComments(Account.Id author, @Nullable Ref ref) {
     if (draftCommentNotes == null || !author.equals(draftCommentNotes.getAuthor()) || ref != null) {
-      draftCommentNotes =
-          new DraftCommentNotes(args, change, author, autoRebuild, rebuildResult, ref);
+      draftCommentNotes = new DraftCommentNotes(args, getChangeId(), author, ref);
       draftCommentNotes.load();
     }
   }
 
-  private void loadRobotComments() throws OrmException {
+  private void loadRobotComments() {
     if (robotCommentNotes == null) {
       robotCommentNotes = new RobotCommentNotes(args, change);
       robotCommentNotes.load();
@@ -631,7 +461,7 @@
     return robotCommentNotes;
   }
 
-  public boolean containsComment(Comment c) throws OrmException {
+  public boolean containsComment(Comment c) {
     if (containsCommentPublished(c)) {
       return true;
     }
@@ -655,22 +485,15 @@
 
   public PatchSet getCurrentPatchSet() {
     PatchSet.Id psId = change.currentPatchSetId();
-    return checkNotNull(getPatchSets().get(psId), "missing current patch set %s", psId.get());
-  }
-
-  @VisibleForTesting
-  public Timestamp getReadOnlyUntil() {
-    return state.readOnlyUntil();
+    return requireNonNull(
+        getPatchSets().get(psId), () -> String.format("missing current patch set %s", psId.get()));
   }
 
   @Override
-  protected void onLoad(LoadHandle handle)
-      throws NoSuchChangeException, IOException, ConfigInvalidException {
+  protected void onLoad(LoadHandle handle) throws NoSuchChangeException, IOException {
     ObjectId rev = handle.id();
     if (rev == null) {
-      if (args.migration.readChanges()
-          && PrimaryStorage.of(change) == PrimaryStorage.NOTE_DB
-          && shouldExist) {
+      if (shouldExist) {
         throw new NoSuchChangeException(getChangeId());
       }
       loadDefaults();
@@ -678,7 +501,7 @@
     }
 
     ChangeNotesCache.Value v =
-        args.cache.get().get(getProjectName(), getChangeId(), rev, handle.walk());
+        args.cache.get().get(getProjectName(), getChangeId(), rev, handle::walk);
     state = v.state();
     state.copyColumnsTo(change);
     revisionNoteMap = v.revisionNoteMap();
@@ -698,89 +521,4 @@
   protected ObjectId readRef(Repository repo) throws IOException {
     return refs != null ? refs.get(getRefName()).orElse(null) : super.readRef(repo);
   }
-
-  @Override
-  protected LoadHandle openHandle(Repository repo) throws NoSuchChangeException, IOException {
-    if (autoRebuild) {
-      NoteDbChangeState state = NoteDbChangeState.parse(change);
-      if (args.migration.disableChangeReviewDb()) {
-        checkState(
-            state != null,
-            "shouldn't have null NoteDbChangeState when ReviewDb disabled: %s",
-            change);
-      }
-      ObjectId id = readRef(repo);
-      if (id == null) {
-        // Meta ref doesn't exist in NoteDb.
-
-        if (state == null) {
-          // Either ReviewDb change is being newly created, or it exists in ReviewDb but has not yet
-          // been rebuilt for the first time, e.g. because we just turned on write-only mode. In
-          // both cases, we don't want to auto-rebuild, just proceed with an empty ChangeNotes.
-          return super.openHandle(repo, id);
-        } else if (shouldExist && state.getPrimaryStorage() == PrimaryStorage.NOTE_DB) {
-          throw new NoSuchChangeException(getChangeId());
-        }
-
-        // ReviewDb claims NoteDb state exists, but meta ref isn't present: fall through and
-        // auto-rebuild if necessary.
-      }
-      RefCache refs = this.refs != null ? this.refs : new RepoRefCache(repo);
-      if (!NoteDbChangeState.isChangeUpToDate(state, refs, getChangeId())) {
-        return rebuildAndOpen(repo, id);
-      }
-    }
-    return super.openHandle(repo);
-  }
-
-  private LoadHandle rebuildAndOpen(Repository repo, ObjectId oldId) throws IOException {
-    Timer1.Context timer = args.metrics.autoRebuildLatency.start(CHANGES);
-    try {
-      Change.Id cid = getChangeId();
-      ReviewDb db = args.db.get();
-      ChangeRebuilder rebuilder = args.rebuilder.get();
-      NoteDbUpdateManager.Result r;
-      try (NoteDbUpdateManager manager = rebuilder.stage(db, cid)) {
-        if (manager == null) {
-          return super.openHandle(repo, oldId); // May be null in tests.
-        }
-        manager.setRefLogMessage("Auto-rebuilding change");
-        r = manager.stageAndApplyDelta(change);
-        try {
-          rebuilder.execute(db, cid, manager);
-          repo.scanForRepoChanges();
-        } catch (OrmException | IOException e) {
-          // Rebuilding failed. Most likely cause is contention on one or more
-          // change refs; there are other types of errors that can happen during
-          // rebuilding, but generally speaking they should happen during stage(),
-          // not execute(). Assume that some other worker is going to successfully
-          // store the rebuilt state, which is deterministic given an input
-          // ChangeBundle.
-          //
-          // Parse notes from the staged result so we can return something useful
-          // to the caller instead of throwing.
-          logger.atFine().log("Rebuilding change %s failed: %s", getChangeId(), e.getMessage());
-          args.metrics.autoRebuildFailureCount.increment(CHANGES);
-          rebuildResult = checkNotNull(r);
-          checkNotNull(r.newState());
-          checkNotNull(r.staged());
-          checkNotNull(r.staged().changeObjects());
-          return LoadHandle.create(
-              ChangeNotesCommit.newStagedRevWalk(repo, r.staged().changeObjects()),
-              r.newState().getChangeMetaId());
-        }
-      }
-      return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), r.newState().getChangeMetaId());
-    } catch (NoSuchChangeException e) {
-      return super.openHandle(repo, oldId);
-    } catch (OrmException e) {
-      throw new IOException(e);
-    } finally {
-      logger.atFine().log(
-          "Rebuilt change %s in project %s in %s ms",
-          getChangeId(),
-          getProjectName(),
-          TimeUnit.MILLISECONDS.convert(timer.stop(), TimeUnit.NANOSECONDS));
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 0bf2108..517898a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -18,17 +18,18 @@
 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.proto.Protos;
 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.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.ObjectIdConverter;
 import com.google.gerrit.server.notedb.AbstractChangeNotes.Args;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.inject.Inject;
@@ -41,11 +42,14 @@
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
+import java.util.function.Supplier;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class ChangeNotesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @VisibleForTesting static final String CACHE_NAME = "change_notes";
 
   public static Module module() {
@@ -77,12 +81,12 @@
     abstract ObjectId id();
 
     @VisibleForTesting
-    static enum Serializer implements CacheSerializer<Key> {
+    enum Serializer implements CacheSerializer<Key> {
       INSTANCE;
 
       @Override
       public byte[] serialize(Key object) {
-        return ProtoCacheSerializers.toByteArray(
+        return Protos.toByteArray(
             ChangeNotesKeyProto.newBuilder()
                 .setProject(object.project().get())
                 .setChangeId(object.changeId().get())
@@ -92,11 +96,10 @@
 
       @Override
       public Key deserialize(byte[] in) {
-        ChangeNotesKeyProto proto =
-            ProtoCacheSerializers.parseUnchecked(ChangeNotesKeyProto.parser(), in);
+        ChangeNotesKeyProto proto = Protos.parseUnchecked(ChangeNotesKeyProto.parser(), in);
         return Key.create(
-            new Project.NameKey(proto.getProject()),
-            new Change.Id(proto.getChangeId()),
+            Project.nameKey(proto.getProject()),
+            Change.id(proto.getChangeId()),
             ObjectIdConverter.create().fromByteString(proto.getId()));
       }
     }
@@ -109,8 +112,11 @@
     // Single pointer overhead.
     private static final int P = 8;
 
+    // Single int overhead.
+    private static final int I = 4;
+
     // Single IntKey overhead.
-    private static final int K = O + 4;
+    private static final int K = O + I;
 
     // Single Timestamp overhead.
     private static final int T = O + 8;
@@ -168,10 +174,10 @@
           + list(state.changeMessages(), changeMessage())
           + P
           + map(state.publishedComments().asMap(), comment())
-          + T // readOnlyUntil
           + 1 // isPrivate
           + 1 // workInProgress
-          + 1; // reviewStarted
+          + 1 // reviewStarted
+          + I; // updateCount
     }
 
     private static int ptr(Object o, int size) {
@@ -334,22 +340,24 @@
 
   private class Loader implements Callable<ChangeNotesState> {
     private final Key key;
-    private final ChangeNotesRevWalk rw;
+    private final Supplier<ChangeNotesRevWalk> walkSupplier;
 
     private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
-    private Loader(Key key, ChangeNotesRevWalk rw) {
+    private Loader(Key key, Supplier<ChangeNotesRevWalk> walkSupplier) {
       this.key = key;
-      this.rw = rw;
+      this.walkSupplier = walkSupplier;
     }
 
     @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(),
               key.id(),
-              rw,
+              walkSupplier.get(),
               args.changeNoteJson,
               args.legacyChangeNoteRead,
               args.metrics);
@@ -370,11 +378,15 @@
     this.args = args;
   }
 
-  Value get(Project.NameKey project, Change.Id changeId, ObjectId metaId, ChangeNotesRevWalk rw)
+  Value get(
+      Project.NameKey project,
+      Change.Id changeId,
+      ObjectId metaId,
+      Supplier<ChangeNotesRevWalk> walkSupplier)
       throws IOException {
     try {
       Key key = Key.create(project, changeId, metaId);
-      Loader loader = new Loader(key, rw);
+      Loader loader = new Loader(key, walkSupplier);
       ChangeNotesState s = cache.get(key, loader);
       return new AutoValue_ChangeNotesCache_Value(s, loader.revisionNoteMap);
     } catch (ExecutionException e) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 5f2593b..27c2aa6 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -26,7 +26,6 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
@@ -36,10 +35,11 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.joining;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
@@ -47,6 +47,7 @@
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
@@ -55,6 +56,7 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -65,27 +67,21 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
 import java.nio.charset.Charset;
 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;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -99,29 +95,11 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.FooterKey;
-import org.eclipse.jgit.util.GitDateParser;
 import org.eclipse.jgit.util.RawParseUtils;
 
 class ChangeNotesParser {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  // Sentinel RevId indicating a mutable field on a patch set was parsed, but
-  // the parser does not yet know its commit SHA-1.
-  private static final RevId PARTIAL_PATCH_SET = new RevId("INVALID PARTIAL PATCH SET");
-
-  @AutoValue
-  abstract static class ApprovalKey {
-    abstract PatchSet.Id psId();
-
-    abstract Account.Id accountId();
-
-    abstract String label();
-
-    private static ApprovalKey create(PatchSet.Id psId, Account.Id accountId, String label) {
-      return new AutoValue_ChangeNotesParser_ApprovalKey(psId, accountId, label);
-    }
-  }
-
   // Private final members initialized in the constructor.
   private final ChangeNoteJson changeNoteJson;
   private final LegacyChangeNoteRead legacyChangeNoteRead;
@@ -138,13 +116,13 @@
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   private final List<SubmitRecord> submitRecords;
-  private final ListMultimap<RevId, Comment> comments;
-  private final Map<PatchSet.Id, PatchSet> patchSets;
+  private final ListMultimap<ObjectId, Comment> comments;
+  private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
   private final List<PatchSet.Id> currentPatchSets;
-  private final Map<ApprovalKey, PatchSetApproval> approvals;
-  private final List<PatchSetApproval> bufferedApprovals;
+  private final Map<PatchSetApproval.Key, PatchSetApproval.Builder> approvals;
+  private final List<PatchSetApproval.Builder> bufferedApprovals;
   private final List<ChangeMessage> allChangeMessages;
 
   // Non-final private members filled in during the parsing process.
@@ -163,7 +141,6 @@
   private String submissionId;
   private String tag;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
-  private Timestamp readOnlyUntil;
   private Boolean isPrivate;
   private Boolean workInProgress;
   private Boolean previousWorkInProgressFooter;
@@ -171,6 +148,7 @@
   private ReviewerSet pendingReviewers;
   private ReviewerByEmailSet pendingReviewersByEmail;
   private Change.Id revertOf;
+  private int updateCount;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -209,7 +187,7 @@
     walk.reset();
     walk.markStart(walk.parseCommit(tip));
 
-    try (Timer1.Context timer = metrics.parseLatency.start(CHANGES)) {
+    try (Timer1.Context<NoteDbTable> timer = metrics.parseLatency.start(CHANGES)) {
       ChangeNotesCommit commit;
       while ((commit = walk.next()) != null) {
         parse(commit);
@@ -237,11 +215,11 @@
     return revisionNoteMap;
   }
 
-  private ChangeNotesState buildState() {
+  private ChangeNotesState buildState() throws ConfigInvalidException {
     return ChangeNotesState.create(
         tip.copy(),
         id,
-        new Change.Key(changeId),
+        Change.key(changeId),
         createdOn,
         lastUpdatedOn,
         ownerId,
@@ -255,7 +233,7 @@
         status,
         Sets.newLinkedHashSet(Lists.reverse(pastAssignees)),
         firstNonNull(hashtags, ImmutableSet.of()),
-        patchSets,
+        buildPatchSets(),
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
         ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)),
@@ -266,18 +244,33 @@
         submitRecords,
         buildAllMessages(),
         comments,
-        readOnlyUntil,
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
-        revertOf);
+        revertOf,
+        updateCount);
+  }
+
+  private Map<PatchSet.Id, PatchSet> buildPatchSets() throws ConfigInvalidException {
+    Map<PatchSet.Id, PatchSet> result = Maps.newHashMapWithExpectedSize(patchSets.size());
+    for (Map.Entry<PatchSet.Id, PatchSet.Builder> e : patchSets.entrySet()) {
+      try {
+        PatchSet ps = e.getValue().build();
+        result.put(ps.id(), ps);
+      } catch (Exception ex) {
+        ConfigInvalidException cie = parseException("Error building patch set %s", e.getKey());
+        cie.initCause(ex);
+        throw cie;
+      }
+    }
+    return result;
   }
 
   private PatchSet.Id buildCurrentPatchSetId() {
     // currentPatchSets are in parse order, i.e. newest first. Pick the first
     // patch set that was marked as current, excluding deleted patch sets.
     for (PatchSet.Id psId : currentPatchSets) {
-      if (patchSets.containsKey(psId)) {
+      if (patchSetCommitParsed(psId)) {
         return psId;
       }
     }
@@ -287,18 +280,16 @@
   private ListMultimap<PatchSet.Id, PatchSetApproval> buildApprovals() {
     ListMultimap<PatchSet.Id, PatchSetApproval> result =
         MultimapBuilder.hashKeys().arrayListValues().build();
-    for (PatchSetApproval a : approvals.values()) {
-      if (!patchSets.containsKey(a.getPatchSetId())) {
+    for (PatchSetApproval.Builder a : approvals.values()) {
+      if (!patchSetCommitParsed(a.key().patchSetId())) {
         continue; // Patch set deleted or missing.
-      } else if (allPastReviewers.contains(a.getAccountId())
-          && !reviewers.containsRow(a.getAccountId())) {
+      } else if (allPastReviewers.contains(a.key().accountId())
+          && !reviewers.containsRow(a.key().accountId())) {
         continue; // Reviewer was explicitly removed.
       }
-      result.put(a.getPatchSetId(), a);
+      result.put(a.key().patchSetId(), a.build());
     }
-    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;
   }
 
@@ -319,6 +310,7 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
+    updateCount++;
     Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
 
     createdOn = ts;
@@ -369,11 +361,14 @@
       submissionId = parseSubmissionId(commit);
     }
 
+    // Parse mutable patch set fields first so they can be recorded in the PendingPatchSetFields.
+    parseDescription(psId, commit);
+    parseGroups(psId, commit);
+
     ObjectId currRev = parseRevision(commit);
     if (currRev != null) {
       parsePatchSet(psId, currRev, accountId, ts);
     }
-    parseGroups(psId, commit);
     parseCurrentPatchSet(psId, commit);
 
     if (submitRecords.isEmpty()) {
@@ -403,10 +398,6 @@
       // behavior.
     }
 
-    if (readOnlyUntil == null) {
-      parseReadOnlyUntil(commit);
-    }
-
     if (isPrivate == null) {
       parseIsPrivate(commit);
     }
@@ -421,8 +412,6 @@
     if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
       lastUpdatedOn = ts;
     }
-
-    parseDescription(psId, commit);
   }
 
   private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
@@ -471,7 +460,7 @@
       throws ConfigInvalidException {
     String line = parseOneFooter(commit, footerKey);
     if (line == null) {
-      throw expectedOneFooter(footerKey, Collections.<String>emptyList());
+      throw expectedOneFooter(footerKey, Collections.emptyList());
     }
     return line;
   }
@@ -495,24 +484,27 @@
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
-    PatchSet ps = patchSets.get(psId);
-    if (ps == null) {
-      ps = new PatchSet(psId);
-      patchSets.put(psId, ps);
-    } else if (!ps.getRevision().equals(PARTIAL_PATCH_SET)) {
+    if (patchSetCommitParsed(psId)) {
       if (deletedPatchSets.contains(psId)) {
-        // Do not update PS details as PS was deleted and this meta data is of
-        // no relevance
+        // Do not update PS details as PS was deleted and this meta data is of no relevance.
         return;
       }
+      ObjectId commitId = patchSets.get(psId).commitId().orElseThrow(IllegalStateException::new);
       throw new ConfigInvalidException(
           String.format(
               "Multiple revisions parsed for patch set %s: %s and %s",
-              psId.get(), patchSets.get(psId).getRevision(), rev.name()));
+              psId.get(), commitId.name(), rev.name()));
     }
-    ps.setRevision(new RevId(rev.name()));
-    ps.setUploader(accountId);
-    ps.setCreatedOn(ts);
+    patchSets
+        .computeIfAbsent(psId, id -> PatchSet.builder())
+        .id(psId)
+        .commitId(rev)
+        .uploader(accountId)
+        .createdOn(ts);
+    // Fields not set here:
+    // * Groups, parsed earlier in parseGroups.
+    // * Description, parsed earlier in parseDescription.
+    // * Push certificate, parsed later in parseNotes.
   }
 
   private void parseGroups(PatchSet.Id psId, ChangeNotesCommit commit)
@@ -521,15 +513,11 @@
     if (groupsStr == null) {
       return;
     }
-    PatchSet ps = patchSets.get(psId);
-    if (ps == null) {
-      ps = new PatchSet(psId);
-      ps.setRevision(PARTIAL_PATCH_SET);
-      patchSets.put(psId, ps);
-    } else if (!ps.getGroups().isEmpty()) {
-      return;
+    checkPatchSetCommitNotParsed(psId, FOOTER_GROUPS);
+    PatchSet.Builder pending = patchSets.computeIfAbsent(psId, id -> PatchSet.builder());
+    if (pending.groups().isEmpty()) {
+      pending.groups(PatchSet.splitGroups(groupsStr));
     }
-    ps.setGroups(PatchSet.splitGroups(groupsStr));
   }
 
   private void parseCurrentPatchSet(PatchSet.Id psId, ChangeNotesCommit commit)
@@ -624,9 +612,9 @@
     // exception is the legacy SUBM approval, which is never considered post-submit, but might end
     // up sorted after the submit during rebuilding.
     if (status == Change.Status.MERGED) {
-      for (PatchSetApproval psa : bufferedApprovals) {
-        if (!psa.isLegacySubmit()) {
-          psa.setPostSubmit(true);
+      for (PatchSetApproval.Builder psa : bufferedApprovals) {
+        if (!psa.key().isLegacySubmit()) {
+          psa.postSubmit(true);
         }
       }
     }
@@ -642,7 +630,7 @@
     if (psId == null) {
       throw invalidFooter(FOOTER_PATCH_SET, psIdStr);
     }
-    return new PatchSet.Id(id, psId);
+    return PatchSet.id(id, psId);
   }
 
   private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
@@ -670,16 +658,14 @@
     List<String> descLines = commit.getFooterLineValues(FOOTER_PATCH_SET_DESCRIPTION);
     if (descLines.isEmpty()) {
       return;
-    } else if (descLines.size() == 1) {
+    }
+
+    checkPatchSetCommitNotParsed(psId, FOOTER_PATCH_SET_DESCRIPTION);
+    if (descLines.size() == 1) {
       String desc = descLines.get(0).trim();
-      PatchSet ps = patchSets.get(psId);
-      if (ps == null) {
-        ps = new PatchSet(psId);
-        ps.setRevision(PARTIAL_PATCH_SET);
-        patchSets.put(psId, ps);
-      }
-      if (ps.getDescription() == null) {
-        ps.setDescription(desc);
+      PatchSet.Builder pending = patchSets.computeIfAbsent(psId, p -> PatchSet.builder());
+      if (!pending.description().isPresent()) {
+        pending.description(Optional.of(desc));
       }
     } else {
       throw expectedOneFooter(FOOTER_PATCH_SET_DESCRIPTION, descLines);
@@ -692,61 +678,33 @@
       Account.Id realAccountId,
       ChangeNotesCommit commit,
       Timestamp ts) {
-    byte[] raw = commit.getRawBuffer();
-    int size = raw.length;
-    Charset enc = RawParseUtils.parseEncoding(raw);
-
-    int subjectStart = RawParseUtils.commitMessage(raw, 0);
-    if (subjectStart < 0 || subjectStart >= size) {
+    Optional<String> changeMsgString = getChangeMessageString(commit);
+    if (!changeMsgString.isPresent()) {
       return;
     }
 
-    int subjectEnd = RawParseUtils.endOfParagraph(raw, subjectStart);
-    if (subjectEnd == size) {
-      return;
-    }
-
-    int changeMessageStart;
-
-    if (raw[subjectEnd] == '\n') {
-      changeMessageStart = subjectEnd + 2; // \n\n ends paragraph
-    } else if (raw[subjectEnd] == '\r') {
-      changeMessageStart = subjectEnd + 4; // \r\n\r\n ends paragraph
-    } else {
-      return;
-    }
-
-    int ptr = size - 1;
-    int changeMessageEnd = -1;
-    while (ptr > changeMessageStart) {
-      ptr = RawParseUtils.prevLF(raw, ptr, '\r');
-      if (ptr == -1) {
-        break;
-      }
-      if (raw[ptr] == '\n') {
-        changeMessageEnd = ptr - 1;
-        break;
-      } else if (raw[ptr] == '\r') {
-        changeMessageEnd = ptr - 3;
-        break;
-      }
-    }
-
-    if (ptr <= changeMessageStart) {
-      return;
-    }
-
-    String changeMsgString =
-        RawParseUtils.decode(enc, raw, changeMessageStart, changeMessageEnd + 1);
     ChangeMessage changeMessage =
-        new ChangeMessage(
-            new ChangeMessage.Key(psId.getParentKey(), commit.name()), accountId, ts, psId);
-    changeMessage.setMessage(changeMsgString);
+        new ChangeMessage(ChangeMessage.key(psId.changeId(), commit.name()), accountId, ts, psId);
+    changeMessage.setMessage(changeMsgString.get());
     changeMessage.setTag(tag);
     changeMessage.setRealAuthor(realAccountId);
     allChangeMessages.add(changeMessage);
   }
 
+  public static Optional<String> getChangeMessageString(ChangeNotesCommit commit) {
+    byte[] raw = commit.getRawBuffer();
+    Charset enc = RawParseUtils.parseEncoding(raw);
+
+    Optional<ChangeNoteUtil.CommitMessageRange> range = parseCommitMessageRange(commit);
+    return range.map(
+        commitMessageRange ->
+            RawParseUtils.decode(
+                enc,
+                raw,
+                commitMessageRange.changeMessageStart(),
+                commitMessageRange.changeMessageEnd() + 1));
+  }
+
   private void parseNotes() throws IOException, ConfigInvalidException {
     ObjectReader reader = walk.getObjectReader();
     ChangeNotesCommit tipCommit = walk.parseCommit(tip);
@@ -758,18 +716,23 @@
             reader,
             NoteMap.read(reader, tipCommit),
             PatchLineComment.Status.PUBLISHED);
-    Map<RevId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
+    Map<ObjectId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
-    for (Map.Entry<RevId, ChangeRevisionNote> e : rns.entrySet()) {
-      for (Comment c : e.getValue().getComments()) {
+    for (Map.Entry<ObjectId, ChangeRevisionNote> e : rns.entrySet()) {
+      for (Comment c : e.getValue().getEntities()) {
         comments.put(e.getKey(), c);
       }
     }
 
-    for (PatchSet ps : patchSets.values()) {
-      ChangeRevisionNote rn = rns.get(ps.getRevision());
+    for (PatchSet.Builder b : patchSets.values()) {
+      ObjectId commitId =
+          b.commitId()
+              .orElseThrow(
+                  () ->
+                      new IllegalStateException("never parsed commit ID for patch set " + b.id()));
+      ChangeRevisionNote rn = rns.get(commitId);
       if (rn != null && rn.getPushCert() != null) {
-        ps.setPushCertificate(rn.getPushCert());
+        b.pushCertificate(Optional.of(rn.getPushCert()));
       }
     }
   }
@@ -780,7 +743,7 @@
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
-    PatchSetApproval psa;
+    PatchSetApproval.Builder psa;
     if (line.startsWith("-")) {
       psa = parseRemoveApproval(psId, accountId, realAccountId, ts, line);
     } else {
@@ -789,7 +752,7 @@
     bufferedApprovals.add(psa);
   }
 
-  private PatchSetApproval parseAddApproval(
+  private PatchSetApproval.Builder parseAddApproval(
       PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
       throws ConfigInvalidException {
     // There are potentially 3 accounts involved here:
@@ -826,23 +789,20 @@
       throw pe;
     }
 
-    PatchSetApproval psa =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(psId, effectiveAccountId, new LabelId(l.label())),
-            l.value(),
-            ts);
-    psa.setTag(tag);
+    PatchSetApproval.Builder psa =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(l.label())))
+            .value(l.value())
+            .granted(ts)
+            .tag(Optional.ofNullable(tag));
     if (!Objects.equals(realAccountId, committerId)) {
-      psa.setRealAccountId(realAccountId);
+      psa.realAccountId(realAccountId);
     }
-    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, l.label());
-    if (!approvals.containsKey(k)) {
-      approvals.put(k, psa);
-    }
+    approvals.putIfAbsent(psa.key(), psa);
     return psa;
   }
 
-  private PatchSetApproval parseRemoveApproval(
+  private PatchSetApproval.Builder parseRemoveApproval(
       PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
       throws ConfigInvalidException {
     // See comments in parseAddApproval about the various users involved.
@@ -867,22 +827,17 @@
       throw pe;
     }
 
-    // Store an actual 0-vote approval in the map for a removed approval, for
-    // several reasons:
-    //  - This is closer to the ReviewDb representation, which leads to less
-    //    confusion and special-casing of NoteDb.
-    //  - More importantly, ApprovalCopier needs an actual approval in order to
-    //    block copying an earlier approval over a later delete.
-    PatchSetApproval remove =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(psId, effectiveAccountId, new LabelId(label)), (short) 0, ts);
+    // Store an actual 0-vote approval in the map for a removed approval, because ApprovalCopier
+    // needs an actual approval in order to block copying an earlier approval over a later delete.
+    PatchSetApproval.Builder remove =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(label)))
+            .value(0)
+            .granted(ts);
     if (!Objects.equals(realAccountId, committerId)) {
-      remove.setRealAccountId(realAccountId);
+      remove.realAccountId(realAccountId);
     }
-    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, label);
-    if (!approvals.containsKey(k)) {
-      approvals.put(k, remove);
-    }
+    approvals.putIfAbsent(remove.key(), remove);
     return remove;
   }
 
@@ -962,20 +917,6 @@
     }
   }
 
-  private void parseReadOnlyUntil(ChangeNotesCommit commit) throws ConfigInvalidException {
-    String raw = parseOneFooter(commit, FOOTER_READ_ONLY_UNTIL);
-    if (raw == null) {
-      return;
-    }
-    try {
-      readOnlyUntil = new Timestamp(GitDateParser.parse(raw, null, Locale.US).getTime());
-    } catch (ParseException e) {
-      ConfigInvalidException cie = invalidFooter(FOOTER_READ_ONLY_UNTIL, raw);
-      cie.initCause(e);
-      throw cie;
-    }
-  }
-
   private void parseIsPrivate(ChangeNotesCommit commit) throws ConfigInvalidException {
     String raw = parseOneFooter(commit, FOOTER_PRIVATE);
     if (raw == null) {
@@ -1031,7 +972,7 @@
     if (revertOf == null) {
       throw invalidFooter(FOOTER_REVERT_OF, footer);
     }
-    return new Change.Id(revertOf);
+    return Change.id(revertOf);
   }
 
   private void pruneReviewers() {
@@ -1057,14 +998,9 @@
   }
 
   private void updatePatchSetStates() {
-    Set<PatchSet.Id> missing = new TreeSet<>(ReviewDbUtil.intKeyOrdering());
-    for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext(); ) {
-      PatchSet ps = it.next();
-      if (ps.getRevision().equals(PARTIAL_PATCH_SET)) {
-        missing.add(ps.getId());
-        it.remove();
-      }
-    }
+    Set<PatchSet.Id> missing = new TreeSet<>(comparing(PatchSet.Id::get));
+    patchSets.keySet().stream().filter(p -> !patchSetCommitParsed(p)).forEach(p -> missing.add(p));
+
     for (Map.Entry<PatchSet.Id, PatchSetState> e : patchSetStates.entrySet()) {
       switch (e.getValue()) {
         case PUBLISHED:
@@ -1085,10 +1021,10 @@
         pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
-            comments.values(), c -> new PatchSet.Id(id, c.key.patchSetId), missing);
+            comments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
-            approvals.values(), PatchSetApproval::getPatchSetId, missing);
+            approvals.values(), psa -> psa.key().patchSetId(), missing);
 
     if (!missing.isEmpty()) {
       logger.atWarning().log(
@@ -1101,7 +1037,7 @@
     int pruned = 0;
     for (Iterator<T> it = ents.iterator(); it.hasNext(); ) {
       PatchSet.Id psId = psIdFunc.apply(it.next());
-      if (!patchSets.containsKey(psId)) {
+      if (!patchSetCommitParsed(psId)) {
         pruned++;
         missing.add(psId);
         it.remove();
@@ -1144,6 +1080,20 @@
     }
   }
 
+  private void checkPatchSetCommitNotParsed(PatchSet.Id psId, FooterKey footer)
+      throws ConfigInvalidException {
+    if (patchSetCommitParsed(psId)) {
+      throw parseException(
+          "%s field found for patch set %s before patch set was originally defined",
+          footer.getName(), psId.get());
+    }
+  }
+
+  private boolean patchSetCommitParsed(PatchSet.Id psId) {
+    PatchSet.Builder pending = patchSets.get(psId);
+    return pending != null && pending.commitId().isPresent();
+  }
+
   private ConfigInvalidException parseException(String fmt, Object... args) {
     return ChangeNotes.parseException(id, fmt, args);
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 3eb06b2..2728516 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -15,15 +15,11 @@
 package com.google.gerrit.server.notedb;
 
 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.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-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 java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -39,32 +35,35 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.reviewdb.converter.ChangeMessageProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
+import com.google.gerrit.reviewdb.converter.ProtoConverter;
 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.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gson.Gson;
-import java.io.IOException;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.Map;
@@ -117,17 +116,20 @@
       List<ReviewerStatusUpdate> reviewerUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
-      ListMultimap<RevId, Comment> publishedComments,
-      @Nullable Timestamp readOnlyUntil,
+      ListMultimap<ObjectId, Comment> publishedComments,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
-      @Nullable Change.Id revertOf) {
-    checkNotNull(
+      @Nullable Change.Id revertOf,
+      int updateCount) {
+    requireNonNull(
         metaId,
-        "metaId is required when passing arguments to create(...). To create an empty %s without"
-            + " NoteDb data, use empty(...) instead",
-        ChangeNotesState.class.getSimpleName());
+        () ->
+            String.format(
+                "metaId is required when passing arguments to create(...)."
+                    + " To create an empty %s without"
+                    + " NoteDb data, use empty(...) instead",
+                ChangeNotesState.class.getSimpleName()));
     return builder()
         .metaId(metaId)
         .changeId(changeId)
@@ -163,16 +165,13 @@
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
-        .readOnlyUntil(readOnlyUntil)
+        .updateCount(updateCount)
         .build();
   }
 
   /**
    * Subset of Change columns that can be represented in NoteDb.
    *
-   * <p>Notable exceptions include rowVersion and noteDbState, which are only make sense when read
-   * from NoteDb, so they cannot be cached.
-   *
    * <p>Fields should match the column names in {@link Change}, and are in listed column order.
    */
   @AutoValue
@@ -297,61 +296,38 @@
 
   abstract ImmutableList<ChangeMessage> changeMessages();
 
-  abstract ImmutableListMultimap<RevId, Comment> publishedComments();
+  abstract ImmutableListMultimap<ObjectId, Comment> publishedComments();
 
-  @Nullable
-  abstract Timestamp readOnlyUntil();
+  abstract int updateCount();
 
   Change newChange(Project.NameKey project) {
-    ChangeColumns c = checkNotNull(columns(), "columns are required");
+    ChangeColumns c = requireNonNull(columns(), "columns are required");
     Change change =
         new Change(
             c.changeKey(),
             changeId(),
             c.owner(),
-            new Branch.NameKey(project, c.branch()),
+            BranchNameKey.create(project, c.branch()),
             c.createdOn());
     copyNonConstructorColumnsTo(change);
-    change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
     return change;
   }
 
-  void copyColumnsTo(Change change) throws IOException {
+  void copyColumnsTo(Change change) {
     ChangeColumns c = columns();
     checkState(
         c != null && metaId() != null,
         "missing columns or metaId in ChangeNotesState; is NoteDb enabled? %s",
         this);
-    checkMetaId(change);
     change.setKey(c.changeKey());
     change.setOwner(c.owner());
-    change.setDest(new Branch.NameKey(change.getProject(), c.branch()));
+    change.setDest(BranchNameKey.create(change.getProject(), c.branch()));
     change.setCreatedOn(c.createdOn());
     copyNonConstructorColumnsTo(change);
   }
 
-  private void checkMetaId(Change change) throws IOException {
-    NoteDbChangeState state = NoteDbChangeState.parse(change);
-    if (state == null) {
-      return; // Can happen during small NoteDb tests.
-    } else if (state.getPrimaryStorage() == PrimaryStorage.NOTE_DB) {
-      return;
-    }
-    checkState(state.getRefState().isPresent(), "expected RefState: %s", state);
-    ObjectId idFromState = state.getRefState().get().changeMetaId();
-    if (!idFromState.equals(metaId())) {
-      throw new IOException(
-          "cannot copy ChangeNotesState into Change "
-              + changeId()
-              + "; this ChangeNotesState was created from "
-              + metaId()
-              + ", but change requires state "
-              + idFromState);
-    }
-  }
-
   private void copyNonConstructorColumnsTo(Change change) {
-    ChangeColumns c = checkNotNull(columns(), "columns are required");
+    ChangeColumns c = requireNonNull(columns(), "columns are required");
     if (c.status() != null) {
       change.setStatus(c.status());
     }
@@ -390,7 +366,8 @@
           .reviewerUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
-          .publishedComments(ImmutableListMultimap.of());
+          .publishedComments(ImmutableListMultimap.of())
+          .updateCount(0);
     }
 
     abstract Builder metaId(ObjectId metaId);
@@ -423,14 +400,14 @@
 
     abstract Builder changeMessages(List<ChangeMessage> changeMessages);
 
-    abstract Builder publishedComments(ListMultimap<RevId, Comment> publishedComments);
+    abstract Builder publishedComments(ListMultimap<ObjectId, Comment> publishedComments);
 
-    abstract Builder readOnlyUntil(@Nullable Timestamp readOnlyUntil);
+    abstract Builder updateCount(int updateCount);
 
     abstract ChangeNotesState build();
   }
 
-  static enum Serializer implements CacheSerializer<ChangeNotesState> {
+  enum Serializer implements CacheSerializer<ChangeNotesState> {
     INSTANCE;
 
     @VisibleForTesting static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
@@ -452,8 +429,15 @@
 
       object.pastAssignees().forEach(a -> b.addPastAssignee(a.get()));
       object.hashtags().forEach(b::addHashtag);
-      object.patchSets().forEach(e -> b.addPatchSet(toByteString(e.getValue(), PATCH_SET_CODEC)));
-      object.approvals().forEach(e -> b.addApproval(toByteString(e.getValue(), APPROVAL_CODEC)));
+      object
+          .patchSets()
+          .forEach(e -> b.addPatchSet(toByteString(e.getValue(), PatchSetProtoConverter.INSTANCE)));
+      object
+          .approvals()
+          .forEach(
+              e ->
+                  b.addApproval(
+                      toByteString(e.getValue(), PatchSetApprovalProtoConverter.INSTANCE)));
 
       object.reviewers().asTable().cellSet().forEach(c -> b.addReviewer(toReviewerSetEntry(c)));
       object
@@ -477,14 +461,19 @@
       object
           .submitRecords()
           .forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
-      object.changeMessages().forEach(m -> b.addChangeMessage(toByteString(m, MESSAGE_CODEC)));
+      object
+          .changeMessages()
+          .forEach(m -> b.addChangeMessage(toByteString(m, ChangeMessageProtoConverter.INSTANCE)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
+      b.setUpdateCount(object.updateCount());
 
-      if (object.readOnlyUntil() != null) {
-        b.setReadOnlyUntil(object.readOnlyUntil().getTime()).setHasReadOnlyUntil(true);
-      }
+      return Protos.toByteArray(b.build());
+    }
 
-      return ProtoCacheSerializers.toByteArray(b.build());
+    @VisibleForTesting
+    static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
+      MessageLite message = converter.toProto(object);
+      return Protos.toByteString(message);
     }
 
     private static ChangeColumnsProto toChangeColumnsProto(ChangeColumns cols) {
@@ -552,9 +541,8 @@
 
     @Override
     public ChangeNotesState deserialize(byte[] in) {
-      ChangeNotesStateProto proto =
-          ProtoCacheSerializers.parseUnchecked(ChangeNotesStateProto.parser(), in);
-      Change.Id changeId = new Change.Id(proto.getChangeId());
+      ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
+      Change.Id changeId = Change.id(proto.getChangeId());
 
       ChangeNotesState.Builder b =
           builder()
@@ -562,71 +550,57 @@
               .changeId(changeId)
               .columns(toChangeColumns(changeId, proto.getColumns()))
               .pastAssignees(
-                  proto
-                      .getPastAssigneeList()
-                      .stream()
-                      .map(Account.Id::new)
-                      .collect(toImmutableSet()))
+                  proto.getPastAssigneeList().stream().map(Account::id).collect(toImmutableSet()))
               .hashtags(proto.getHashtagList())
               .patchSets(
-                  proto
-                      .getPatchSetList()
-                      .stream()
-                      .map(PATCH_SET_CODEC::decode)
-                      .map(ps -> Maps.immutableEntry(ps.getId(), ps))
+                  proto.getPatchSetList().stream()
+                      .map(bytes -> parseProtoFrom(PatchSetProtoConverter.INSTANCE, bytes))
+                      .map(ps -> Maps.immutableEntry(ps.id(), ps))
                       .collect(toImmutableList()))
               .approvals(
-                  proto
-                      .getApprovalList()
-                      .stream()
-                      .map(APPROVAL_CODEC::decode)
-                      .map(a -> Maps.immutableEntry(a.getPatchSetId(), a))
+                  proto.getApprovalList().stream()
+                      .map(bytes -> parseProtoFrom(PatchSetApprovalProtoConverter.INSTANCE, bytes))
+                      .map(a -> Maps.immutableEntry(a.patchSetId(), a))
                       .collect(toImmutableList()))
               .reviewers(toReviewerSet(proto.getReviewerList()))
               .reviewersByEmail(toReviewerByEmailSet(proto.getReviewerByEmailList()))
               .pendingReviewers(toReviewerSet(proto.getPendingReviewerList()))
               .pendingReviewersByEmail(toReviewerByEmailSet(proto.getPendingReviewerByEmailList()))
               .allPastReviewers(
-                  proto
-                      .getPastReviewerList()
-                      .stream()
-                      .map(Account.Id::new)
-                      .collect(toImmutableList()))
+                  proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
               .submitRecords(
-                  proto
-                      .getSubmitRecordList()
-                      .stream()
+                  proto.getSubmitRecordList().stream()
                       .map(r -> GSON.fromJson(r, StoredSubmitRecord.class).toSubmitRecord())
                       .collect(toImmutableList()))
               .changeMessages(
-                  proto
-                      .getChangeMessageList()
-                      .stream()
-                      .map(MESSAGE_CODEC::decode)
+                  proto.getChangeMessageList().stream()
+                      .map(bytes -> parseProtoFrom(ChangeMessageProtoConverter.INSTANCE, bytes))
                       .collect(toImmutableList()))
               .publishedComments(
-                  proto
-                      .getPublishedCommentList()
-                      .stream()
+                  proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, Comment.class))
-                      .collect(toImmutableListMultimap(c -> new RevId(c.revId), c -> c)));
-      if (proto.getHasReadOnlyUntil()) {
-        b.readOnlyUntil(new Timestamp(proto.getReadOnlyUntil()));
-      }
+                      .collect(toImmutableListMultimap(Comment::getCommitId, c -> c)))
+              .updateCount(proto.getUpdateCount());
       return b.build();
     }
 
+    private static <P extends MessageLite, T> T parseProtoFrom(
+        ProtoConverter<P, T> converter, ByteString byteString) {
+      P message = Protos.parseUnchecked(converter.getParser(), byteString);
+      return converter.fromProto(message);
+    }
+
     private static ChangeColumns toChangeColumns(Change.Id changeId, ChangeColumnsProto proto) {
       ChangeColumns.Builder b =
           ChangeColumns.builder()
-              .changeKey(new Change.Key(proto.getChangeKey()))
+              .changeKey(Change.key(proto.getChangeKey()))
               .createdOn(new Timestamp(proto.getCreatedOn()))
               .lastUpdatedOn(new Timestamp(proto.getLastUpdatedOn()))
-              .owner(new Account.Id(proto.getOwner()))
+              .owner(Account.id(proto.getOwner()))
               .branch(proto.getBranch());
       if (proto.getHasCurrentPatchSetId()) {
-        b.currentPatchSetId(new PatchSet.Id(changeId, proto.getCurrentPatchSetId()));
+        b.currentPatchSetId(PatchSet.id(changeId, proto.getCurrentPatchSetId()));
       }
       b.subject(proto.getSubject());
       if (proto.getHasTopic()) {
@@ -639,7 +613,7 @@
         b.submissionId(proto.getSubmissionId());
       }
       if (proto.getHasAssignee()) {
-        b.assignee(new Account.Id(proto.getAssignee()));
+        b.assignee(Account.id(proto.getAssignee()));
       }
       if (proto.getHasStatus()) {
         b.status(STATUS_CONVERTER.convert(proto.getStatus()));
@@ -648,7 +622,7 @@
           .workInProgress(proto.getWorkInProgress())
           .reviewStarted(proto.getReviewStarted());
       if (proto.getHasRevertOf()) {
-        b.revertOf(new Change.Id(proto.getRevertOf()));
+        b.revertOf(Change.id(proto.getRevertOf()));
       }
       return b.build();
     }
@@ -659,7 +633,7 @@
       for (ReviewerSetEntryProto e : protos) {
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
-            new Account.Id(e.getAccountId()),
+            Account.id(e.getAccountId()),
             new Timestamp(e.getTimestamp()));
       }
       return ReviewerSet.fromTable(b.build());
@@ -685,8 +659,8 @@
         b.add(
             ReviewerStatusUpdate.create(
                 new Timestamp(proto.getDate()),
-                new Account.Id(proto.getUpdatedBy()),
-                new Account.Id(proto.getReviewer()),
+                Account.id(proto.getUpdatedBy()),
+                Account.id(proto.getReviewer()),
                 REVIEWER_STATE_CONVERTER.convert(proto.getState())));
       }
       return b.build();
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 894e979..66dd5e8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -88,7 +88,7 @@
     return comments;
   }
 
-  private static boolean isJson(byte[] raw, int offset) {
+  static boolean isJson(byte[] raw, int offset) {
     return raw[offset] == '{' || raw[offset] == '[';
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 445f7a0..8e751de 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -16,7 +16,6 @@
 
 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.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
@@ -30,7 +29,6 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
@@ -41,7 +39,8 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter;
-import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
+import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -50,28 +49,23 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Table;
 import com.google.common.collect.TreeBasedTable;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.Date;
@@ -84,7 +78,6 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -106,18 +99,8 @@
  */
 public class ChangeUpdate extends AbstractChangeUpdate {
   public interface Factory {
-    ChangeUpdate create(ChangeNotes notes, CurrentUser user);
-
     ChangeUpdate create(ChangeNotes notes, CurrentUser user, Date when);
 
-    ChangeUpdate create(
-        Change change,
-        @Assisted("effective") @Nullable Account.Id accountId,
-        @Assisted("real") @Nullable Account.Id realAccountId,
-        PersonIdent authorIdent,
-        Date when,
-        Comparator<String> labelNameComparator);
-
     @VisibleForTesting
     ChangeUpdate create(
         ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
@@ -152,7 +135,6 @@
   private boolean isAllowWriteToNewtRef;
   private String psDescription;
   private boolean currentPatchSet;
-  private Timestamp readOnlyUntil;
   private Boolean isPrivate;
   private Boolean workInProgress;
   private Integer revertOf;
@@ -160,40 +142,11 @@
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
   private DeleteCommentRewriter deleteCommentRewriter;
+  private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
 
   @AssistedInject
   private ChangeUpdate(
-      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
-      NotesMigration migration,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      RobotCommentUpdate.Factory robotCommentUpdateFactory,
-      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
-      ProjectCache projectCache,
-      @Assisted ChangeNotes notes,
-      @Assisted CurrentUser user,
-      ChangeNoteUtil noteUtil) {
-    this(
-        cfg,
-        serverIdent,
-        migration,
-        updateManagerFactory,
-        draftUpdateFactory,
-        robotCommentUpdateFactory,
-        deleteCommentRewriterFactory,
-        projectCache,
-        notes,
-        user,
-        serverIdent.getWhen(),
-        noteUtil);
-  }
-
-  @AssistedInject
-  private ChangeUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      NotesMigration migration,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
@@ -204,9 +157,7 @@
       @Assisted Date when,
       ChangeNoteUtil noteUtil) {
     this(
-        cfg,
         serverIdent,
-        migration,
         updateManagerFactory,
         draftUpdateFactory,
         robotCommentUpdateFactory,
@@ -220,14 +171,12 @@
 
   private static Table<String, Account.Id, Optional<Short>> approvals(
       Comparator<String> nameComparator) {
-    return TreeBasedTable.create(nameComparator, comparing(IntKey::get));
+    return TreeBasedTable.create(nameComparator, naturalOrder());
   }
 
   @AssistedInject
   private ChangeUpdate(
-      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
-      NotesMigration migration,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
@@ -237,7 +186,7 @@
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator,
       ChangeNoteUtil noteUtil) {
-    super(cfg, migration, notes, user, serverIdent, noteUtil, when);
+    super(notes, user, serverIdent, noteUtil, when);
     this.updateManagerFactory = updateManagerFactory;
     this.draftUpdateFactory = draftUpdateFactory;
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
@@ -245,44 +194,9 @@
     this.approvals = approvals(labelNameComparator);
   }
 
-  @AssistedInject
-  private ChangeUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      NotesMigration migration,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      RobotCommentUpdate.Factory robotCommentUpdateFactory,
-      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
-      ChangeNoteUtil noteUtil,
-      @Assisted Change change,
-      @Assisted("effective") @Nullable Account.Id accountId,
-      @Assisted("real") @Nullable Account.Id realAccountId,
-      @Assisted PersonIdent authorIdent,
-      @Assisted Date when,
-      @Assisted Comparator<String> labelNameComparator) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        null,
-        change,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
-    this.draftUpdateFactory = draftUpdateFactory;
-    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
-    this.updateManagerFactory = updateManagerFactory;
-    this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
-    this.approvals = approvals(labelNameComparator);
-  }
-
-  public ObjectId commit() throws IOException, OrmException {
+  public ObjectId commit() throws IOException {
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
       updateManager.add(this);
-      updateManager.stageAndApplyDelta(getChange());
       updateManager.execute();
     }
     return getResult();
@@ -334,11 +248,6 @@
     checkArgument(!this.submitRecords.isEmpty(), "no submit records specified at submit time");
   }
 
-  @Deprecated // Only until we improve ChangeRebuilder to call merge().
-  public void setSubmissionId(String submissionId) {
-    this.submissionId = submissionId;
-  }
-
   public void setSubjectForCommit(String commitSubject) {
     this.commitSubject = commitSubject;
   }
@@ -371,11 +280,7 @@
       draftUpdate.putComment(c);
     } else {
       comments.add(c);
-      // Always delete the corresponding comment from drafts. Published comments
-      // are immutable, meaning in normal operation we only hit this path when
-      // publishing a comment. It's exactly in that case that we have to delete
-      // the draft.
-      draftUpdate.deleteComment(c);
+      draftUpdate.markCommentPublished(c);
     }
   }
 
@@ -395,6 +300,11 @@
         deleteCommentRewriterFactory.create(getChange().getId(), uuid, newMessage);
   }
 
+  public void deleteChangeMessageByRewritingHistory(String targetMessageId, String newMessage) {
+    deleteChangeMessageRewriter =
+        new DeleteChangeMessageRewriter(getChange().getId(), targetMessageId, newMessage);
+  }
+
   @VisibleForTesting
   ChangeDraftUpdate createDraftUpdateIfNull() {
     if (draftUpdate == null) {
@@ -402,6 +312,7 @@
       if (notes != null) {
         draftUpdate = draftUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when);
       } else {
+        // tests will always take the notes != null path above.
         draftUpdate =
             draftUpdateFactory.create(getChange(), accountId, realAccountId, authorIdent, when);
       }
@@ -494,12 +405,12 @@
   }
 
   public void setGroups(List<String> groups) {
-    checkNotNull(groups, "groups may not be null");
+    requireNonNull(groups, "groups may not be null");
     this.groups = groups;
   }
 
   public void setRevertOf(int revertOf) {
-    int ownId = getChange().getId().get();
+    int ownId = getId().get();
     checkArgument(ownId != revertOf, "A change cannot revert itself");
     this.revertOf = revertOf;
     rootOnly = true;
@@ -507,7 +418,7 @@
 
   /** @return the tree id for the updated tree */
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
+      throws ConfigInvalidException, IOException {
     if (comments.isEmpty() && pushCert == null) {
       return null;
     }
@@ -516,45 +427,35 @@
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
     for (Comment c : comments) {
       c.tag = tag;
-      cache.get(new RevId(c.revId)).putComment(c);
+      cache.get(c.getCommitId()).putComment(c);
     }
     if (pushCert != null) {
       checkState(commit != null);
-      cache.get(new RevId(commit)).setPushCertificate(pushCert);
+      cache.get(ObjectId.fromString(commit)).setPushCertificate(pushCert);
     }
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     checkComments(rnm.revisionNotes, builders);
 
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      ObjectId data =
-          inserter.insert(
-              OBJ_BLOB,
-              e.getValue()
-                  .build(
-                      noteUtil.getChangeNoteJson(),
-                      noteUtil.getLegacyChangeNoteWrite(),
-                      noteUtil.getChangeNoteJson().getWriteJson()));
-      rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
+      ObjectId data = inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil.getChangeNoteJson()));
+      rnm.noteMap.set(e.getKey(), data);
     }
 
     return rnm.noteMap.writeTree(inserter);
   }
 
   private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
+      throws ConfigInvalidException, IOException {
     if (curr.equals(ObjectId.zeroId())) {
       return RevisionNoteMap.emptyMap();
     }
-    if (migration.readChanges()) {
-      // If reading from changes is enabled, then the old ChangeNotes may have
-      // already parsed the revision notes. We can reuse them as long as the ref
-      // hasn't advanced.
-      ChangeNotes notes = getNotes();
-      if (notes != null && notes.revisionNoteMap != null) {
-        ObjectId idFromNotes = firstNonNull(notes.load().getRevision(), ObjectId.zeroId());
-        if (idFromNotes.equals(curr)) {
-          return notes.revisionNoteMap;
-        }
+    // The old ChangeNotes may have already parsed the revision notes. We can reuse them as long as
+    // the ref hasn't advanced.
+    ChangeNotes notes = getNotes();
+    if (notes != null && notes.revisionNoteMap != null) {
+      ObjectId idFromNotes = firstNonNull(notes.load().getRevision(), ObjectId.zeroId());
+      if (idFromNotes.equals(curr)) {
+        return notes.revisionNoteMap;
       }
     }
     NoteMap noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
@@ -570,12 +471,12 @@
   }
 
   private void checkComments(
-      Map<RevId, ChangeRevisionNote> existingNotes, Map<RevId, RevisionNoteBuilder> toUpdate)
-      throws OrmException {
+      Map<ObjectId, ChangeRevisionNote> existingNotes,
+      Map<ObjectId, RevisionNoteBuilder> toUpdate) {
     // Prohibit various kinds of illegal operations on comments.
     Set<Comment.Key> existing = new HashSet<>();
     for (ChangeRevisionNote rn : existingNotes.values()) {
-      for (Comment c : rn.getComments()) {
+      for (Comment c : rn.getEntities()) {
         existing.add(c.key);
         if (draftUpdate != null) {
           // Take advantage of an existing update on All-Users to prune any
@@ -593,7 +494,7 @@
           // separate commit. But note that we don't care much about the commit
           // graph of the draft ref, particularly because the ref is completely
           // deleted when all drafts are gone.
-          draftUpdate.deleteComment(c.revId, c.key);
+          draftUpdate.deleteComment(c.getCommitId(), c.key);
         }
       }
     }
@@ -601,7 +502,7 @@
     for (RevisionNoteBuilder b : toUpdate.values()) {
       for (Comment c : b.put.values()) {
         if (existing.contains(c.key)) {
-          throw new OrmException("Cannot update existing published comment: " + c);
+          throw new StorageException("Cannot update existing published comment: " + c);
         }
       }
     }
@@ -613,9 +514,17 @@
   }
 
   @Override
+  protected boolean bypassMaxUpdates() {
+    // Allow abandoning or submitting a change even if it would exceed the max update count.
+    return status != null && status.isClosed();
+  }
+
+  @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException {
-    checkState(deleteCommentRewriter == null, "cannot update and rewrite ref in one BatchUpdate");
+      throws IOException {
+    checkState(
+        deleteCommentRewriter == null && deleteChangeMessageRewriter == null,
+        "cannot update and rewrite ref in one BatchUpdate");
 
     CommitBuilder cb = new CommitBuilder();
 
@@ -748,10 +657,6 @@
       addIdent(msg, realAccountId).append('\n');
     }
 
-    if (readOnlyUntil != null) {
-      addFooter(msg, FOOTER_READ_ONLY_UNTIL, NoteDbUtil.formatTime(serverIdent, readOnlyUntil));
-    }
-
     if (isPrivate != null) {
       addFooter(msg, FOOTER_PRIVATE, isPrivate);
     }
@@ -771,7 +676,7 @@
         cb.setTreeId(treeId);
       }
     } catch (ConfigInvalidException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
     return cb;
   }
@@ -811,7 +716,6 @@
         && tag == null
         && psDescription == null
         && !currentPatchSet
-        && readOnlyUntil == null
         && isPrivate == null
         && workInProgress == null
         && revertOf == null;
@@ -829,6 +733,10 @@
     return deleteCommentRewriter;
   }
 
+  public DeleteChangeMessageRewriter getDeleteChangeMessageRewriter() {
+    return deleteChangeMessageRewriter;
+  }
+
   public void setAllowWriteToNewRef(boolean allow) {
     isAllowWriteToNewtRef = allow;
   }
@@ -846,10 +754,6 @@
     this.workInProgress = workInProgress;
   }
 
-  void setReadOnlyUntil(Timestamp readOnlyUntil) {
-    this.readOnlyUntil = readOnlyUntil;
-  }
-
   private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
     return sb.append(footer.getName()).append(": ");
   }
@@ -871,13 +775,4 @@
     sb.append('>');
     return sb;
   }
-
-  @Override
-  protected void checkNotReadOnly() throws OrmException {
-    // Allow setting Read-only-until to 0 to release an existing lease.
-    if (readOnlyUntil != null && readOnlyUntil.getTime() == 0) {
-      return;
-    }
-    super.checkNotReadOnly();
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
new file mode 100644
index 0000000..6d0530a
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
+import static java.util.Objects.requireNonNull;
+import static org.eclipse.jgit.util.RawParseUtils.decode;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.Optional;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/**
+ * Deletes a change message from NoteDb by rewriting the commit history. After deletion, the whole
+ * change message will be replaced by a new message indicating the original change message has been
+ * deleted for the given reason.
+ */
+public class DeleteChangeMessageRewriter implements NoteDbRewriter {
+
+  private final Change.Id changeId;
+  private final String targetMessageId;
+  private final String newChangeMessage;
+
+  DeleteChangeMessageRewriter(Change.Id changeId, String targetMessageId, String newChangeMessage) {
+    this.changeId = changeId;
+    this.targetMessageId = requireNonNull(targetMessageId);
+    this.newChangeMessage = newChangeMessage;
+  }
+
+  @Override
+  public String getRefName() {
+    return RefNames.changeMetaRef(changeId);
+  }
+
+  @Override
+  public ObjectId rewriteCommitHistory(RevWalk revWalk, ObjectInserter inserter, ObjectId currTip)
+      throws IOException {
+    checkArgument(!currTip.equals(ObjectId.zeroId()));
+
+    // Walk from the first commit of the branch.
+    revWalk.reset();
+    revWalk.markStart(revWalk.parseCommit(currTip));
+    revWalk.sort(RevSort.TOPO);
+    revWalk.sort(RevSort.REVERSE);
+
+    ObjectId newTipId = null;
+    RevCommit originalCommit;
+    boolean startRewrite = false;
+    while ((originalCommit = revWalk.next()) != null) {
+      boolean isTargetCommit = originalCommit.getId().getName().equals(targetMessageId);
+      if (!startRewrite && !isTargetCommit) {
+        newTipId = originalCommit;
+        continue;
+      }
+
+      startRewrite = true;
+      String newCommitMessage =
+          isTargetCommit ? createNewCommitMessage(originalCommit) : originalCommit.getFullMessage();
+      newTipId = rewriteOneCommit(originalCommit, newTipId, newCommitMessage, inserter);
+    }
+    return newTipId;
+  }
+
+  private String createNewCommitMessage(RevCommit commit) {
+    byte[] raw = commit.getRawBuffer();
+
+    Optional<ChangeNoteUtil.CommitMessageRange> range = parseCommitMessageRange(commit);
+    checkState(range.isPresent(), "failed to parse commit message");
+
+    // Only replace the commit message body, which is the user-provided message. The subject and
+    // footers are NoteDb metadata.
+    Charset encoding = RawParseUtils.parseEncoding(raw);
+    String prefix =
+        decode(encoding, raw, range.get().subjectStart(), range.get().changeMessageStart());
+    String postfix = decode(encoding, raw, range.get().changeMessageEnd() + 1, raw.length);
+    return prefix + newChangeMessage + postfix;
+  }
+
+  /**
+   * Rewrites one commit.
+   *
+   * @param originalCommit the original commit to be rewritten.
+   * @param parentCommitId the parent of the new commit. For the first rewritten commit, it's the
+   *     parent of 'originalCommit'. For the latter rewritten commits, it's the commit rewritten
+   *     just before it.
+   * @param commitMessage the full commit message of the new commit.
+   * @param inserter the {@code ObjectInserter} for the rewrite process.
+   * @return the {@code objectId} of the new commit.
+   * @throws IOException
+   */
+  private ObjectId rewriteOneCommit(
+      RevCommit originalCommit,
+      ObjectId parentCommitId,
+      String commitMessage,
+      ObjectInserter inserter)
+      throws IOException {
+    CommitBuilder cb = new CommitBuilder();
+    if (parentCommitId != null) {
+      cb.setParentId(parentCommitId);
+    }
+    cb.setTreeId(originalCommit.getTree());
+    cb.setMessage(commitMessage);
+    cb.setCommitter(originalCommit.getCommitterIdent());
+    cb.setAuthor(originalCommit.getAuthorIdent());
+    cb.setEncoding(originalCommit.getEncoding());
+    return inserter.insert(cb);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index 9a8c130..dceffa3 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -24,8 +24,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -86,7 +84,7 @@
 
   @Override
   public ObjectId rewriteCommitHistory(RevWalk revWalk, ObjectInserter inserter, ObjectId currTip)
-      throws IOException, ConfigInvalidException, OrmException {
+      throws IOException, ConfigInvalidException {
     checkArgument(!currTip.equals(ObjectId.zeroId()));
 
     // Walk from the first commit of the branch.
@@ -141,10 +139,8 @@
       throws IOException, ConfigInvalidException {
     return RevisionNoteMap.parse(
             changeNoteJson, legacyChangeNoteRead, changeId, reader, noteMap, PUBLISHED)
-        .revisionNotes
-        .values()
-        .stream()
-        .flatMap(n -> n.getComments().stream())
+        .revisionNotes.values().stream()
+        .flatMap(n -> n.getEntities().stream())
         .collect(toMap(c -> c.key.uuid, Function.identity()));
   }
 
@@ -189,9 +185,7 @@
    */
   private List<Comment> getDeletedComments(
       Map<String, Comment> parMap, Map<String, Comment> curMap) {
-    return parMap
-        .entrySet()
-        .stream()
+    return parMap.entrySet().stream()
         .filter(c -> !curMap.containsKey(c.getKey()))
         .map(Map.Entry::getValue)
         .collect(toList());
@@ -229,23 +223,17 @@
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNotesMap);
 
     for (Comment c : putInComments) {
-      cache.get(new RevId(c.revId)).putComment(c);
+      cache.get(c.getCommitId()).putComment(c);
     }
 
     for (Comment c : deletedComments) {
-      cache.get(new RevId(c.revId)).deleteComment(c.key);
+      cache.get(c.getCommitId()).deleteComment(c.key);
     }
 
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
-    for (Map.Entry<RevId, RevisionNoteBuilder> entry : builders.entrySet()) {
-      ObjectId objectId = ObjectId.fromString(entry.getKey().get());
-      byte[] data =
-          entry
-              .getValue()
-              .build(
-                  noteUtil.getChangeNoteJson(),
-                  noteUtil.getLegacyChangeNoteWrite(),
-                  noteUtil.getChangeNoteJson().getWriteJson());
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> entry : builders.entrySet()) {
+      ObjectId objectId = entry.getKey();
+      byte[] data = entry.getValue().build(noteUtil.getChangeNoteJson());
       if (data.length == 0) {
         revNotesMap.noteMap.remove(objectId);
       } else {
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 79da7e1..e62c396 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableListMultimap;
@@ -25,24 +24,14 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.RepoRefCache;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.StagedResult;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -50,53 +39,29 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** View of the draft comments for a single {@link Change} based on the log of its drafts branch. */
 public class DraftCommentNotes extends AbstractChangeNotes<DraftCommentNotes> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    DraftCommentNotes create(Change change, Account.Id accountId);
-
-    DraftCommentNotes createWithAutoRebuildingDisabled(Change.Id changeId, Account.Id accountId);
+    DraftCommentNotes create(Change.Id changeId, Account.Id accountId);
   }
 
-  private final Change change;
   private final Account.Id author;
-  private final NoteDbUpdateManager.Result rebuildResult;
   private final Ref ref;
 
-  private ImmutableListMultimap<RevId, Comment> comments;
+  private ImmutableListMultimap<ObjectId, Comment> comments;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   @AssistedInject
-  DraftCommentNotes(Args args, @Assisted Change change, @Assisted Account.Id author) {
-    this(args, change, author, true, null, null);
-  }
-
-  @AssistedInject
   DraftCommentNotes(Args args, @Assisted Change.Id changeId, @Assisted Account.Id author) {
-    // PrimaryStorage is unknown; this should only called by
-    // PatchLineCommentsUtil#draftByAuthor, which can live with this.
-    super(args, changeId, null, false);
-    this.change = null;
-    this.author = author;
-    this.rebuildResult = null;
-    this.ref = null;
+    this(args, changeId, author, null);
   }
 
-  DraftCommentNotes(
-      Args args,
-      Change change,
-      Account.Id author,
-      boolean autoRebuild,
-      @Nullable NoteDbUpdateManager.Result rebuildResult,
-      @Nullable Ref ref) {
-    super(args, change.getId(), PrimaryStorage.of(change), autoRebuild);
-    this.change = change;
-    this.author = author;
-    this.rebuildResult = rebuildResult;
+  DraftCommentNotes(Args args, Change.Id changeId, Account.Id author, @Nullable Ref ref) {
+    super(args, changeId);
+    this.author = requireNonNull(author);
     this.ref = ref;
     if (ref != null) {
       checkArgument(
@@ -116,7 +81,7 @@
     return author;
   }
 
-  public ImmutableListMultimap<RevId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, Comment> getComments() {
     return comments;
   }
 
@@ -150,6 +115,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 =
@@ -160,10 +127,10 @@
             reader,
             NoteMap.read(reader, tipCommit),
             PatchLineComment.Status.DRAFT);
-    ListMultimap<RevId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ListMultimap<ObjectId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
-      for (Comment c : rn.getComments()) {
-        cs.put(new RevId(c.revId), c);
+      for (Comment c : rn.getEntities()) {
+        cs.put(c.getCommitId(), c);
       }
     }
     comments = ImmutableListMultimap.copyOf(cs);
@@ -179,79 +146,6 @@
     return args.allUsers;
   }
 
-  @Override
-  protected LoadHandle openHandle(Repository repo) throws NoSuchChangeException, IOException {
-    if (rebuildResult != null) {
-      StagedResult sr = checkNotNull(rebuildResult.staged());
-      return LoadHandle.create(
-          ChangeNotesCommit.newStagedRevWalk(repo, sr.allUsersObjects()),
-          findNewId(sr.allUsersCommands(), getRefName()));
-    } else if (change != null && autoRebuild) {
-      NoteDbChangeState state = NoteDbChangeState.parse(change);
-      // Only check if this particular user's drafts are up to date, to avoid
-      // reading unnecessary refs.
-      if (!NoteDbChangeState.areDraftsUpToDate(
-          state, new RepoRefCache(repo), getChangeId(), author)) {
-        return rebuildAndOpen(repo);
-      }
-    }
-    return super.openHandle(repo);
-  }
-
-  private static ObjectId findNewId(Iterable<ReceiveCommand> cmds, String refName) {
-    for (ReceiveCommand cmd : cmds) {
-      if (cmd.getRefName().equals(refName)) {
-        return cmd.getNewId();
-      }
-    }
-    return null;
-  }
-
-  private LoadHandle rebuildAndOpen(Repository repo) throws NoSuchChangeException, IOException {
-    Timer1.Context timer = args.metrics.autoRebuildLatency.start(CHANGES);
-    try {
-      Change.Id cid = getChangeId();
-      ReviewDb db = args.db.get();
-      ChangeRebuilder rebuilder = args.rebuilder.get();
-      NoteDbUpdateManager.Result r;
-      try (NoteDbUpdateManager manager = rebuilder.stage(db, cid)) {
-        if (manager == null) {
-          return super.openHandle(repo); // May be null in tests.
-        }
-        r = manager.stageAndApplyDelta(change);
-        try {
-          rebuilder.execute(db, cid, manager);
-          repo.scanForRepoChanges();
-        } catch (OrmException | IOException e) {
-          // See ChangeNotes#rebuildAndOpen.
-          logger.atFine().log(
-              "Rebuilding change %s via drafts failed: %s", getChangeId(), e.getMessage());
-          args.metrics.autoRebuildFailureCount.increment(CHANGES);
-          checkNotNull(r.staged());
-          return LoadHandle.create(
-              ChangeNotesCommit.newStagedRevWalk(repo, r.staged().allUsersObjects()), draftsId(r));
-        }
-      }
-      return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), draftsId(r));
-    } catch (NoSuchChangeException e) {
-      return super.openHandle(repo);
-    } catch (OrmException e) {
-      throw new IOException(e);
-    } finally {
-      logger.atFine().log(
-          "Rebuilt change %s in %s in %s ms via drafts",
-          getChangeId(),
-          change != null ? "project " + change.getProject() : "unknown project",
-          TimeUnit.MILLISECONDS.convert(timer.stop(), TimeUnit.NANOSECONDS));
-    }
-  }
-
-  private ObjectId draftsId(NoteDbUpdateManager.Result r) {
-    checkNotNull(r);
-    checkNotNull(r.newState());
-    return r.newState().getDraftIds().get(author);
-  }
-
   @VisibleForTesting
   NoteMap getNoteMap() {
     return revisionNoteMap != null ? revisionNoteMap.noteMap : null;
diff --git a/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java b/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
deleted file mode 100644
index 34ed64c..0000000
--- a/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.notedb.ChangeBundle.Source;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.List;
-
-@Singleton
-public class GwtormChangeBundleReader implements ChangeBundleReader {
-  @Inject
-  GwtormChangeBundleReader() {}
-
-  @Override
-  public ChangeBundle fromReviewDb(ReviewDb db, Change.Id id) throws OrmException {
-    // TODO(dborowitz): Figure out how to do this more consistently, e.g. hand-written inner joins.
-    List<PatchSetApproval> approvals = db.patchSetApprovals().byChange(id).toList();
-    return new ChangeBundle(
-        db.changes().get(id),
-        db.changeMessages().byChange(id),
-        db.patchSets().byChange(id),
-        approvals,
-        db.patchComments().byChange(id),
-        ReviewerSet.fromApprovals(approvals),
-        Source.REVIEW_DB);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/IntBlob.java b/java/com/google/gerrit/server/notedb/IntBlob.java
new file mode 100644
index 0000000..6305a54
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/IntBlob.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.CharMatcher;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@AutoValue
+public abstract class IntBlob {
+  public static Optional<IntBlob> parse(Repository repo, String refName) throws IOException {
+    try (ObjectReader or = repo.newObjectReader()) {
+      return parse(repo, refName, or);
+    }
+  }
+
+  public static Optional<IntBlob> parse(Repository repo, String refName, RevWalk rw)
+      throws IOException {
+    return parse(repo, refName, rw.getObjectReader());
+  }
+
+  private static Optional<IntBlob> parse(Repository repo, String refName, ObjectReader or)
+      throws IOException {
+    Ref ref = repo.exactRef(refName);
+    if (ref == null) {
+      return Optional.empty();
+    }
+    ObjectId id = ref.getObjectId();
+    ObjectLoader ol = or.open(id, OBJ_BLOB);
+    if (ol.getType() != OBJ_BLOB) {
+      // In theory this should be thrown by open but not all implementations may do it properly
+      // (certainly InMemoryRepository doesn't).
+      throw new IncorrectObjectTypeException(id, OBJ_BLOB);
+    }
+    String str = CharMatcher.whitespace().trimFrom(new String(ol.getCachedBytes(), UTF_8));
+    Integer value = Ints.tryParse(str);
+    if (value == null) {
+      throw new StorageException("invalid value in " + refName + " blob at " + id.name());
+    }
+    return Optional.of(IntBlob.create(id, value));
+  }
+
+  public static RefUpdate tryStore(
+      Repository repo,
+      RevWalk rw,
+      Project.NameKey projectName,
+      String refName,
+      @Nullable ObjectId oldId,
+      int val,
+      GitReferenceUpdated gitRefUpdated)
+      throws IOException {
+    ObjectId newId;
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
+      ins.flush();
+    }
+    RefUpdate ru = repo.updateRef(refName);
+    if (oldId != null) {
+      ru.setExpectedOldObjectId(oldId);
+    }
+    ru.disableRefLog();
+    ru.setNewObjectId(newId);
+    ru.setForceUpdate(true); // Required for non-commitish updates.
+    RefUpdate.Result result = ru.update(rw);
+    if (refUpdated(result)) {
+      gitRefUpdated.fire(projectName, ru, null);
+    }
+    return ru;
+  }
+
+  public static void store(
+      Repository repo,
+      RevWalk rw,
+      Project.NameKey projectName,
+      String refName,
+      @Nullable ObjectId oldId,
+      int val,
+      GitReferenceUpdated gitRefUpdated)
+      throws IOException {
+    RefUpdateUtil.checkResult(tryStore(repo, rw, projectName, refName, oldId, val, gitRefUpdated));
+  }
+
+  private static boolean refUpdated(RefUpdate.Result result) {
+    return result == RefUpdate.Result.NEW || result == RefUpdate.Result.FORCED;
+  }
+
+  @VisibleForTesting
+  static IntBlob create(AnyObjectId id, int value) {
+    return new AutoValue_IntBlob(id.copy(), value);
+  }
+
+  public abstract ObjectId id();
+
+  public abstract int value();
+}
diff --git a/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java b/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java
index 819c8ac..36bfe47 100644
--- a/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java
+++ b/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java
@@ -21,10 +21,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Comment.Key;
 import com.google.gerrit.reviewdb.client.CommentRange;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
@@ -35,6 +33,7 @@
 import java.util.Locale;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.GitDateParser;
 import org.eclipse.jgit.util.MutableInteger;
@@ -71,14 +70,15 @@
     if (p.value >= note.length) {
       return ImmutableList.of();
     }
-    Set<Key> seen = new HashSet<>();
+    Set<Comment.Key> seen = new HashSet<>();
     List<Comment> result = new ArrayList<>();
     int sizeOfNote = note.length;
     byte[] psb = ChangeNoteUtil.PATCH_SET.getBytes(UTF_8);
     byte[] bpsb = ChangeNoteUtil.BASE_PATCH_SET.getBytes(UTF_8);
     byte[] bpn = ChangeNoteUtil.PARENT_NUMBER.getBytes(UTF_8);
 
-    RevId revId = new RevId(parseStringField(note, p, changeId, ChangeNoteUtil.REVISION));
+    ObjectId commitId =
+        ObjectId.fromString(parseStringField(note, p, changeId, ChangeNoteUtil.REVISION));
     String fileName = null;
     PatchSet.Id psId = null;
     boolean isForBase = false;
@@ -106,7 +106,7 @@
             ChangeNoteUtil.BASE_PATCH_SET);
       }
 
-      Comment c = parseComment(note, p, fileName, psId, revId, isForBase, parentNumber);
+      Comment c = parseComment(note, p, fileName, psId, commitId, isForBase, parentNumber);
       fileName = c.key.filename;
       if (!seen.add(c.key)) {
         throw parseException(changeId, "multiple comments for %s in note", c.key);
@@ -121,11 +121,11 @@
       MutableInteger curr,
       String currentFileName,
       PatchSet.Id psId,
-      RevId revId,
+      ObjectId commitId,
       boolean isForBase,
       Integer parentNumber)
       throws ConfigInvalidException {
-    Change.Id changeId = psId.getParentKey();
+    Change.Id changeId = psId.changeId();
 
     // Check if there is a new file.
     boolean newFile =
@@ -190,7 +190,7 @@
     c.lineNbr = range.getEndLine();
     c.parentUuid = parentUUID;
     c.tag = tag;
-    c.setRevId(revId);
+    c.setCommitId(commitId);
     if (raId != null) {
       c.setRealAuthor(raId);
     }
@@ -286,7 +286,7 @@
     }
     checkResult(patchSetId, "patchset id", changeId);
     curr.value = endOfLine;
-    return new PatchSet.Id(changeId, patchSetId);
+    return PatchSet.id(changeId, patchSetId);
   }
 
   private static Integer parseParentNumber(byte[] note, MutableInteger curr, Change.Id changeId)
diff --git a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java b/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
index 1cf0c7c..604a211 100644
--- a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
+++ b/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
@@ -15,59 +15,50 @@
 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.common.UsedAt;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.inject.Inject;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Date;
 import java.util.List;
-import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.QuotedString;
 
 public class LegacyChangeNoteWrite {
 
-  private final AccountCache accountCache;
   private final PersonIdent serverIdent;
   private final String serverId;
 
   @Inject
   public LegacyChangeNoteWrite(
-      AccountCache accountCache,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @GerritServerId String serverId) {
-    this.accountCache = accountCache;
+      @GerritPersonIdent PersonIdent serverIdent, @GerritServerId String serverId) {
     this.serverIdent = serverIdent;
     this.serverId = serverId;
   }
 
   public PersonIdent newIdent(Account.Id authorId, Date when, PersonIdent serverIdent) {
-    Optional<Account> author = accountCache.get(authorId).map(AccountState::getAccount);
     return new PersonIdent(
-        author.map(Account::getName).orElseGet(() -> Account.getName(authorId)),
-        authorId.get() + "@" + serverId,
-        when,
-        serverIdent.getTimeZone());
+        authorId.toString(), authorId.get() + "@" + serverId, when, serverIdent.getTimeZone());
   }
 
   @VisibleForTesting
   public PersonIdent newIdent(Account author, Date when, PersonIdent serverIdent) {
     return new PersonIdent(
-        author.getName(), author.getId().get() + "@" + serverId, when, serverIdent.getTimeZone());
+        author.toString(), author.id().get() + "@" + serverId, when, serverIdent.getTimeZone());
   }
 
   public String getServerId() {
@@ -87,22 +78,23 @@
    *
    * @param comments Comments to be written to the output stream, keyed by patch set ID; multiple
    *     patch sets are allowed since base revisions may be shared across patch sets. All of the
-   *     comments must share the same RevId, and all the comments for a given patch set must have
+   *     comments must share the same commitId, and all the comments for a given patch set must have
    *     the same side.
    * @param out output stream to write to.
    */
-  void buildNote(ListMultimap<Integer, Comment> comments, OutputStream out) {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public void buildNote(ListMultimap<Integer, Comment> comments, OutputStream out) {
     if (comments.isEmpty()) {
       return;
     }
 
-    List<Integer> psIds = new ArrayList<>(comments.keySet());
-    Collections.sort(psIds);
+    ImmutableList<Integer> psIds = comments.keySet().stream().sorted().collect(toImmutableList());
 
     OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
     try (PrintWriter writer = new PrintWriter(streamWriter)) {
-      String revId = comments.values().iterator().next().revId;
-      appendHeaderField(writer, ChangeNoteUtil.REVISION, revId);
+      ObjectId commitId = comments.values().iterator().next().getCommitId();
+      String commitName = commitId.name();
+      appendHeaderField(writer, ChangeNoteUtil.REVISION, commitName);
 
       for (int psId : psIds) {
         List<Comment> psComments = COMMENT_ORDER.sortedCopy(comments.get(psId));
@@ -121,11 +113,11 @@
 
         for (Comment c : psComments) {
           checkArgument(
-              revId.equals(c.revId),
-              "All comments being added must have all the same RevId. The "
-                  + "comment below does not have the same RevId as the others "
+              commitId.equals(c.getCommitId()),
+              "All comments being added must have all the same commitId. The "
+                  + "comment below does not have the same commitId as the others "
                   + "(%s).\n%s",
-              revId,
+              commitId,
               c);
           checkArgument(
               side == c.side,
diff --git a/java/com/google/gerrit/server/notedb/MutableNotesMigration.java b/java/com/google/gerrit/server/notedb/MutableNotesMigration.java
deleted file mode 100644
index 7f4912b..0000000
--- a/java/com/google/gerrit/server/notedb/MutableNotesMigration.java
+++ /dev/null
@@ -1,104 +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.server.notedb;
-
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.function.Function;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * {@link NotesMigration} with additional methods for altering the migration state at runtime.
- *
- * <p>Almost all callers care only about inspecting the migration state, and for safety should not
- * have access to mutation methods, which must be used with extreme care. Those callers should
- * inject {@link NotesMigration}.
- *
- * <p>Some callers, namely the NoteDb migration pipeline and tests, do need to alter the migration
- * state at runtime, and those callers are expected to take the necessary precautions such as
- * keeping the in-memory and on-disk config state in sync. Those callers use this class.
- *
- * <p>Mutations to the {@link MutableNotesMigration} are guaranteed to be instantly visible to all
- * callers that use the non-mutable {@link NotesMigration}. The current implementation accomplishes
- * this by always binding {@link NotesMigration} to {@link MutableNotesMigration} in Guice, so there
- * is just one {@link NotesMigration} instance process-wide.
- */
-@Singleton
-public class MutableNotesMigration extends NotesMigration {
-  public static MutableNotesMigration newDisabled() {
-    return new MutableNotesMigration(new Config());
-  }
-
-  public static MutableNotesMigration fromConfig(Config cfg) {
-    return new MutableNotesMigration(cfg);
-  }
-
-  @Inject
-  MutableNotesMigration(@GerritServerConfig Config cfg) {
-    super(Snapshot.create(cfg));
-  }
-
-  public MutableNotesMigration setReadChanges(boolean readChanges) {
-    return set(b -> b.setReadChanges(readChanges));
-  }
-
-  public MutableNotesMigration setWriteChanges(boolean writeChanges) {
-    return set(b -> b.setWriteChanges(writeChanges));
-  }
-
-  public MutableNotesMigration setReadChangeSequence(boolean readChangeSequence) {
-    return set(b -> b.setReadChangeSequence(readChangeSequence));
-  }
-
-  public MutableNotesMigration setChangePrimaryStorage(PrimaryStorage changePrimaryStorage) {
-    return set(b -> b.setChangePrimaryStorage(changePrimaryStorage));
-  }
-
-  public MutableNotesMigration setDisableChangeReviewDb(boolean disableChangeReviewDb) {
-    return set(b -> b.setDisableChangeReviewDb(disableChangeReviewDb));
-  }
-
-  public MutableNotesMigration setFailOnLoadForTest(boolean failOnLoadForTest) {
-    return set(b -> b.setFailOnLoadForTest(failOnLoadForTest));
-  }
-
-  /**
-   * Set the in-memory values returned by this instance to match the given state.
-   *
-   * <p>This method is only intended for use by {@link
-   * com.google.gerrit.server.notedb.rebuild.NoteDbMigrator}.
-   *
-   * <p>This <em>only</em> modifies the in-memory state; if this instance was initialized from a
-   * file-based config, the underlying storage is not updated. Callers are responsible for managing
-   * the underlying storage on their own.
-   */
-  public MutableNotesMigration setFrom(NotesMigrationState state) {
-    snapshot.set(state.snapshot());
-    return this;
-  }
-
-  /** @see #setFrom(NotesMigrationState) */
-  public MutableNotesMigration setFrom(NotesMigration other) {
-    snapshot.set(other.snapshot.get());
-    return this;
-  }
-
-  private MutableNotesMigration set(Function<Snapshot.Builder, Snapshot.Builder> f) {
-    snapshot.updateAndGet(s -> f.apply(s.toBuilder()).build());
-    return this;
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
deleted file mode 100644
index 1c11e8b..0000000
--- a/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
+++ /dev/null
@@ -1,477 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-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.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Maps;
-import com.google.common.primitives.Longs;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.git.RefCache;
-import com.google.gwtorm.server.OrmRuntimeException;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-
-/**
- * The state of all relevant NoteDb refs across all repos corresponding to a given Change entity.
- *
- * <p>Stored serialized in the {@code Change#noteDbState} field, and used to determine whether the
- * state in NoteDb is out of date.
- *
- * <p>Serialized in one of the forms:
- *
- * <ul>
- *   <li>[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
- *   <li>R,[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
- *   <li>R=[read-only-until],[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
- *   <li>N
- *   <li>N=[read-only-until]
- * </ul>
- *
- * in numeric account ID order, with hex SHA-1s for human readability.
- */
-public class NoteDbChangeState {
-  public static final String NOTE_DB_PRIMARY_STATE = "N";
-
-  public enum PrimaryStorage {
-    REVIEW_DB('R'),
-    NOTE_DB('N');
-
-    private final char code;
-
-    PrimaryStorage(char code) {
-      this.code = code;
-    }
-
-    public static PrimaryStorage of(@Nullable Change c) {
-      return of(NoteDbChangeState.parse(c));
-    }
-
-    public static PrimaryStorage of(@Nullable NoteDbChangeState s) {
-      return s != null ? s.getPrimaryStorage() : REVIEW_DB;
-    }
-  }
-
-  @AutoValue
-  public abstract static class Delta {
-    @VisibleForTesting
-    public static Delta create(
-        Change.Id changeId,
-        Optional<ObjectId> newChangeMetaId,
-        Map<Account.Id, ObjectId> newDraftIds) {
-      if (newDraftIds == null) {
-        newDraftIds = ImmutableMap.of();
-      }
-      return new AutoValue_NoteDbChangeState_Delta(
-          changeId, newChangeMetaId, ImmutableMap.copyOf(newDraftIds));
-    }
-
-    abstract Change.Id changeId();
-
-    abstract Optional<ObjectId> newChangeMetaId();
-
-    abstract ImmutableMap<Account.Id, ObjectId> newDraftIds();
-  }
-
-  @AutoValue
-  public abstract static class RefState {
-    @VisibleForTesting
-    public static RefState create(ObjectId changeMetaId, Map<Account.Id, ObjectId> draftIds) {
-      return new AutoValue_NoteDbChangeState_RefState(
-          changeMetaId.copy(),
-          ImmutableMap.copyOf(Maps.filterValues(draftIds, id -> !ObjectId.zeroId().equals(id))));
-    }
-
-    private static Optional<RefState> parse(Change.Id changeId, List<String> parts) {
-      checkArgument(!parts.isEmpty(), "missing state string for change %s", changeId);
-      ObjectId changeMetaId = ObjectId.fromString(parts.get(0));
-      Map<Account.Id, ObjectId> draftIds = Maps.newHashMapWithExpectedSize(parts.size() - 1);
-      Splitter s = Splitter.on('=');
-      for (int i = 1; i < parts.size(); i++) {
-        String p = parts.get(i);
-        List<String> draftParts = s.splitToList(p);
-        checkArgument(
-            draftParts.size() == 2, "invalid draft state part for change %s: %s", changeId, p);
-        Optional<Account.Id> accountId = Account.Id.tryParse(draftParts.get(0));
-        checkArgument(
-            accountId.isPresent(),
-            "invalid account ID in draft state part for change %s: %s",
-            changeId,
-            p);
-        draftIds.put(accountId.get(), ObjectId.fromString(draftParts.get(1)));
-      }
-      return Optional.of(create(changeMetaId, draftIds));
-    }
-
-    abstract ObjectId changeMetaId();
-
-    abstract ImmutableMap<Account.Id, ObjectId> draftIds();
-
-    @Override
-    public String toString() {
-      return appendTo(new StringBuilder()).toString();
-    }
-
-    StringBuilder appendTo(StringBuilder sb) {
-      sb.append(changeMetaId().name());
-      for (Account.Id id : ReviewDbUtil.intKeyOrdering().sortedCopy(draftIds().keySet())) {
-        sb.append(',').append(id.get()).append('=').append(draftIds().get(id).name());
-      }
-      return sb;
-    }
-  }
-
-  public static NoteDbChangeState parse(@Nullable Change c) {
-    return c != null ? parse(c.getId(), c.getNoteDbState()) : null;
-  }
-
-  @VisibleForTesting
-  public static NoteDbChangeState parse(Change.Id id, @Nullable String str) {
-    if (Strings.isNullOrEmpty(str)) {
-      // Return null rather than Optional as this is what goes in the field in
-      // ReviewDb.
-      return null;
-    }
-    List<String> parts = Splitter.on(',').splitToList(str);
-    String first = parts.get(0);
-    Optional<Timestamp> readOnlyUntil = parseReadOnlyUntil(id, str, first);
-
-    // Only valid NOTE_DB state is "N".
-    if (parts.size() == 1 && first.charAt(0) == NOTE_DB.code) {
-      return new NoteDbChangeState(id, NOTE_DB, Optional.empty(), readOnlyUntil);
-    }
-
-    // Otherwise it must be REVIEW_DB, either "R,<RefState>" or just
-    // "<RefState>". Allow length > 0 for forward compatibility.
-    if (first.length() > 0) {
-      Optional<RefState> refState;
-      if (first.charAt(0) == REVIEW_DB.code) {
-        refState = RefState.parse(id, parts.subList(1, parts.size()));
-      } else {
-        refState = RefState.parse(id, parts);
-      }
-      return new NoteDbChangeState(id, REVIEW_DB, refState, readOnlyUntil);
-    }
-    throw invalidState(id, str);
-  }
-
-  private static Optional<Timestamp> parseReadOnlyUntil(
-      Change.Id id, String fullStr, String first) {
-    if (first.length() > 2 && first.charAt(1) == '=') {
-      Long ts = Longs.tryParse(first.substring(2));
-      if (ts == null) {
-        throw invalidState(id, fullStr);
-      }
-      return Optional.of(new Timestamp(ts));
-    }
-    return Optional.empty();
-  }
-
-  private static IllegalArgumentException invalidState(Change.Id id, String str) {
-    return new IllegalArgumentException("invalid state string for change " + id + ": " + str);
-  }
-
-  /**
-   * Apply a delta to the state stored in a change entity.
-   *
-   * <p>This method does not check whether the old state was read-only; it is up to the caller to
-   * not violate read-only semantics when storing the change back in ReviewDb.
-   *
-   * @param change change entity. The delta is applied against this entity's {@code noteDbState} and
-   *     the new state is stored back in the entity as a side effect.
-   * @param delta delta to apply.
-   * @return new state, equivalent to what is stored in {@code change} as a side effect.
-   */
-  public static NoteDbChangeState applyDelta(Change change, Delta delta) {
-    if (delta == null) {
-      return null;
-    }
-    String oldStr = change.getNoteDbState();
-    if (oldStr == null && !delta.newChangeMetaId().isPresent()) {
-      // Neither an old nor a new meta ID was present, most likely because we
-      // aren't writing a NoteDb graph at all for this change at this point. No
-      // point in proceeding.
-      return null;
-    }
-    NoteDbChangeState oldState = parse(change.getId(), oldStr);
-    if (oldState != null && oldState.getPrimaryStorage() == NOTE_DB) {
-      // NOTE_DB state doesn't include RefState, so applying a delta is a no-op.
-      return oldState;
-    }
-
-    ObjectId changeMetaId;
-    if (delta.newChangeMetaId().isPresent()) {
-      changeMetaId = delta.newChangeMetaId().get();
-      if (changeMetaId.equals(ObjectId.zeroId())) {
-        change.setNoteDbState(null);
-        return null;
-      }
-    } else {
-      changeMetaId = oldState.getChangeMetaId();
-    }
-
-    Map<Account.Id, ObjectId> draftIds = new HashMap<>();
-    if (oldState != null) {
-      draftIds.putAll(oldState.getDraftIds());
-    }
-    for (Map.Entry<Account.Id, ObjectId> e : delta.newDraftIds().entrySet()) {
-      if (e.getValue().equals(ObjectId.zeroId())) {
-        draftIds.remove(e.getKey());
-      } else {
-        draftIds.put(e.getKey(), e.getValue());
-      }
-    }
-
-    NoteDbChangeState state =
-        new NoteDbChangeState(
-            change.getId(),
-            oldState != null ? oldState.getPrimaryStorage() : REVIEW_DB,
-            Optional.of(RefState.create(changeMetaId, draftIds)),
-            // Copy old read-only deadline rather than advancing it; the caller is
-            // still responsible for finishing the rest of its work before the lease
-            // runs out.
-            oldState != null ? oldState.getReadOnlyUntil() : Optional.empty());
-    change.setNoteDbState(state.toString());
-    return state;
-  }
-
-  // TODO(dborowitz): Ugly. Refactor these static methods into a Checker class
-  // or something. They do not belong in NoteDbChangeState itself because:
-  //  - need to inject Config but don't want a whole Factory
-  //  - can't be methods on NoteDbChangeState because state is nullable (though
-  //    we could also solve this by inventing an empty-but-non-null state)
-  // Also we should clean up duplicated code between static/non-static methods.
-  public static boolean isChangeUpToDate(
-      @Nullable NoteDbChangeState state, RefCache changeRepoRefs, Change.Id changeId)
-      throws IOException {
-    if (PrimaryStorage.of(state) == NOTE_DB) {
-      return true; // Primary storage is NoteDb, up to date by definition.
-    }
-    if (state == null) {
-      return !changeRepoRefs.get(changeMetaRef(changeId)).isPresent();
-    }
-    return state.isChangeUpToDate(changeRepoRefs);
-  }
-
-  public static boolean areDraftsUpToDate(
-      @Nullable NoteDbChangeState state,
-      RefCache draftsRepoRefs,
-      Change.Id changeId,
-      Account.Id accountId)
-      throws IOException {
-    if (PrimaryStorage.of(state) == NOTE_DB) {
-      return true; // Primary storage is NoteDb, up to date by definition.
-    }
-    if (state == null) {
-      return !draftsRepoRefs.get(refsDraftComments(changeId, accountId)).isPresent();
-    }
-    return state.areDraftsUpToDate(draftsRepoRefs, accountId);
-  }
-
-  public static long getReadOnlySkew(Config cfg) {
-    return cfg.getTimeUnit("notedb", null, "maxTimestampSkew", 1000, TimeUnit.MILLISECONDS);
-  }
-
-  static Timestamp timeForReadOnlyCheck(long skewMs) {
-    // Subtract some slop in case the machine that set the change's read-only
-    // lease has a clock behind ours.
-    return new Timestamp(TimeUtil.nowMs() - skewMs);
-  }
-
-  public static void checkNotReadOnly(@Nullable Change change, long skewMs) {
-    checkNotReadOnly(parse(change), skewMs);
-  }
-
-  public static void checkNotReadOnly(@Nullable NoteDbChangeState state, long skewMs) {
-    if (state == null) {
-      return; // No state means ReviewDb primary non-read-only.
-    } else if (state.isReadOnly(timeForReadOnlyCheck(skewMs))) {
-      throw new OrmRuntimeException(
-          "change "
-              + state.getChangeId()
-              + " is read-only until "
-              + state.getReadOnlyUntil().get());
-    }
-  }
-
-  private final Change.Id changeId;
-  private final PrimaryStorage primaryStorage;
-  private final Optional<RefState> refState;
-  private final Optional<Timestamp> readOnlyUntil;
-
-  public NoteDbChangeState(
-      Change.Id changeId,
-      PrimaryStorage primaryStorage,
-      Optional<RefState> refState,
-      Optional<Timestamp> readOnlyUntil) {
-    this.changeId = checkNotNull(changeId);
-    this.primaryStorage = checkNotNull(primaryStorage);
-    this.refState = checkNotNull(refState);
-    this.readOnlyUntil = checkNotNull(readOnlyUntil);
-
-    switch (primaryStorage) {
-      case REVIEW_DB:
-        checkArgument(
-            refState.isPresent(),
-            "expected RefState for change %s with primary storage %s",
-            changeId,
-            primaryStorage);
-        break;
-      case NOTE_DB:
-        checkArgument(
-            !refState.isPresent(),
-            "expected no RefState for change %s with primary storage %s",
-            changeId,
-            primaryStorage);
-        break;
-      default:
-        throw new IllegalStateException("invalid PrimaryStorage: " + primaryStorage);
-    }
-  }
-
-  public PrimaryStorage getPrimaryStorage() {
-    return primaryStorage;
-  }
-
-  public boolean isChangeUpToDate(RefCache changeRepoRefs) throws IOException {
-    if (primaryStorage == NOTE_DB) {
-      return true; // Primary storage is NoteDb, up to date by definition.
-    }
-    Optional<ObjectId> id = changeRepoRefs.get(changeMetaRef(changeId));
-    if (!id.isPresent()) {
-      return getChangeMetaId().equals(ObjectId.zeroId());
-    }
-    return id.get().equals(getChangeMetaId());
-  }
-
-  public boolean areDraftsUpToDate(RefCache draftsRepoRefs, Account.Id accountId)
-      throws IOException {
-    if (primaryStorage == NOTE_DB) {
-      return true; // Primary storage is NoteDb, up to date by definition.
-    }
-    Optional<ObjectId> id = draftsRepoRefs.get(refsDraftComments(changeId, accountId));
-    if (!id.isPresent()) {
-      return !getDraftIds().containsKey(accountId);
-    }
-    return id.get().equals(getDraftIds().get(accountId));
-  }
-
-  public boolean isUpToDate(RefCache changeRepoRefs, RefCache draftsRepoRefs) throws IOException {
-    if (primaryStorage == NOTE_DB) {
-      return true; // Primary storage is NoteDb, up to date by definition.
-    }
-    if (!isChangeUpToDate(changeRepoRefs)) {
-      return false;
-    }
-    for (Account.Id accountId : getDraftIds().keySet()) {
-      if (!areDraftsUpToDate(draftsRepoRefs, accountId)) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  public boolean isReadOnly(Timestamp now) {
-    return readOnlyUntil.isPresent() && now.before(readOnlyUntil.get());
-  }
-
-  public Optional<Timestamp> getReadOnlyUntil() {
-    return readOnlyUntil;
-  }
-
-  public NoteDbChangeState withReadOnlyUntil(Timestamp ts) {
-    return new NoteDbChangeState(changeId, primaryStorage, refState, Optional.of(ts));
-  }
-
-  public Change.Id getChangeId() {
-    return changeId;
-  }
-
-  public ObjectId getChangeMetaId() {
-    return refState().changeMetaId();
-  }
-
-  public ImmutableMap<Account.Id, ObjectId> getDraftIds() {
-    return refState().draftIds();
-  }
-
-  public Optional<RefState> getRefState() {
-    return refState;
-  }
-
-  private RefState refState() {
-    checkState(refState.isPresent(), "state for %s has no RefState: %s", changeId, this);
-    return refState.get();
-  }
-
-  @Override
-  public String toString() {
-    switch (primaryStorage) {
-      case REVIEW_DB:
-        if (!readOnlyUntil.isPresent()) {
-          // Don't include enum field, just IDs (though parse would accept it).
-          return refState().toString();
-        }
-        return primaryStorage.code + "=" + readOnlyUntil.get().getTime() + "," + refState.get();
-      case NOTE_DB:
-        if (!readOnlyUntil.isPresent()) {
-          return NOTE_DB_PRIMARY_STATE;
-        }
-        return primaryStorage.code + "=" + readOnlyUntil.get().getTime();
-      default:
-        throw new IllegalArgumentException("Unsupported PrimaryStorage: " + primaryStorage);
-    }
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(changeId, primaryStorage, refState, readOnlyUntil);
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof NoteDbChangeState)) {
-      return false;
-    }
-    NoteDbChangeState s = (NoteDbChangeState) o;
-    return changeId.equals(s.changeId)
-        && primaryStorage.equals(s.primaryStorage)
-        && refState.equals(s.refState)
-        && readOnlyUntil.equals(s.readOnlyUntil);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbMetrics.java b/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
index be06d11..18ffd17 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -44,19 +44,10 @@
    */
   final Timer1<NoteDbTable> parseLatency;
 
-  /**
-   * Latency due to auto-rebuilding entities when out of date.
-   *
-   * <p>Excludes latency from reading ref to check whether the entity is up to date.
-   */
-  final Timer1<NoteDbTable> autoRebuildLatency;
-
-  /** Count of auto-rebuild attempts that failed. */
-  final Counter1<NoteDbTable> autoRebuildFailureCount;
-
   @Inject
   NoteDbMetrics(MetricMaker metrics) {
-    Field<NoteDbTable> view = Field.ofEnum(NoteDbTable.class, "table");
+    Field<NoteDbTable> tableField =
+        Field.ofEnum(NoteDbTable.class, "table", Metadata.Builder::noteDbTable).build();
 
     updateLatency =
         metrics.newTimer(
@@ -64,7 +55,7 @@
             new Description("NoteDb update latency by table")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            view);
+            tableField);
 
     stageUpdateLatency =
         metrics.newTimer(
@@ -72,7 +63,7 @@
             new Description("Latency for staging updates to NoteDb by table")
                 .setCumulative()
                 .setUnit(Units.MICROSECONDS),
-            view);
+            tableField);
 
     readLatency =
         metrics.newTimer(
@@ -80,7 +71,7 @@
             new Description("NoteDb read latency by table")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            view);
+            tableField);
 
     parseLatency =
         metrics.newTimer(
@@ -88,20 +79,6 @@
             new Description("NoteDb parse latency by table")
                 .setCumulative()
                 .setUnit(Units.MICROSECONDS),
-            view);
-
-    autoRebuildLatency =
-        metrics.newTimer(
-            "notedb/auto_rebuild_latency",
-            new Description("NoteDb auto-rebuilding latency by table")
-                .setCumulative()
-                .setUnit(Units.MILLISECONDS),
-            view);
-
-    autoRebuildFailureCount =
-        metrics.newCounter(
-            "notedb/auto_rebuild_failure_count",
-            new Description("NoteDb auto-rebuilding attempts that failed by table").setCumulative(),
-            view);
+            tableField);
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbModule.java b/java/com/google/gerrit/server/notedb/NoteDbModule.java
index c76c39b..d8a5fd5 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -17,33 +17,21 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Id;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
-import com.google.gerrit.server.notedb.rebuild.NotesMigrationStateListener;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
-import org.eclipse.jgit.lib.Config;
 
 public class NoteDbModule extends FactoryModule {
-  private final Config cfg;
   private final boolean useTestBindings;
 
-  static NoteDbModule forTest(Config cfg) {
-    return new NoteDbModule(cfg, true);
+  static NoteDbModule forTest() {
+    return new NoteDbModule(true);
   }
 
-  public NoteDbModule(Config cfg) {
-    this(cfg, false);
+  public NoteDbModule() {
+    this(false);
   }
 
-  private NoteDbModule(Config cfg, boolean useTestBindings) {
-    this.cfg = cfg;
+  private NoteDbModule(boolean useTestBindings) {
     this.useTestBindings = useTestBindings;
   }
 
@@ -56,60 +44,13 @@
     factory(NoteDbUpdateManager.Factory.class);
     factory(RobotCommentNotes.Factory.class);
     factory(RobotCommentUpdate.Factory.class);
-    DynamicSet.setOf(binder(), NotesMigrationStateListener.class);
 
     if (!useTestBindings) {
       install(ChangeNotesCache.module());
-      if (cfg.getBoolean("noteDb", null, "testRebuilderWrapper", false)) {
-        // Yes, another variety of test bindings with a different way of
-        // configuring it.
-        bind(ChangeRebuilder.class).to(TestChangeRebuilderWrapper.class);
-      } else {
-        bind(ChangeRebuilder.class).to(ChangeRebuilderImpl.class);
-      }
     } else {
-      bind(ChangeRebuilder.class)
-          .toInstance(
-              new ChangeRebuilder(null) {
-                @Override
-                public Result rebuild(ReviewDb db, Change.Id changeId) {
-                  return null;
-                }
-
-                @Override
-                public Result rebuildEvenIfReadOnly(ReviewDb db, Id changeId) {
-                  return null;
-                }
-
-                @Override
-                public Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle) {
-                  return null;
-                }
-
-                @Override
-                public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId) {
-                  return null;
-                }
-
-                @Override
-                public Result execute(
-                    ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager) {
-                  return null;
-                }
-
-                @Override
-                public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle) {
-                  // Do nothing.
-                }
-
-                @Override
-                public void rebuildReviewDb(ReviewDb db, Project.NameKey project, Id changeId) {
-                  // Do nothing.
-                }
-              });
       bind(new TypeLiteral<Cache<ChangeNotesCache.Key, ChangeNotesState>>() {})
           .annotatedWith(Names.named(ChangeNotesCache.CACHE_NAME))
-          .toInstance(CacheBuilder.newBuilder().<ChangeNotesCache.Key, ChangeNotesState>build());
+          .toInstance(CacheBuilder.newBuilder().build());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbRewriter.java b/java/com/google/gerrit/server/notedb/NoteDbRewriter.java
index 3c7b0a3..19754d1 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbRewriter.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbRewriter.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -35,5 +34,5 @@
    * @return the {@code ObjectId} of the ref's new tip commit.
    */
   ObjectId rewriteCommitHistory(RevWalk revWalk, ObjectInserter inserter, ObjectId currTip)
-      throws IOException, ConfigInvalidException, OrmException;
+      throws IOException, ConfigInvalidException;
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index c599c8e..32c8b06 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -16,51 +16,37 @@
 
 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.reviewdb.client.RefNames.REFS_DRAFT_COMMENTS;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.InMemoryInserter;
-import com.google.gerrit.server.git.InsertedObject;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
-import com.google.gerrit.server.update.RefUpdateUtil;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gwtorm.server.OrmConcurrencyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -78,180 +64,16 @@
  * {@link #stage()}.
  */
 public class NoteDbUpdateManager implements AutoCloseable {
-  public static final String CHANGES_READ_ONLY = "NoteDb changes are read-only";
-
-  private static final ImmutableList<String> PACKAGE_PREFIXES =
-      ImmutableList.of("com.google.gerrit.server.", "com.google.gerrit.");
-  private static final ImmutableSet<String> SERVLET_NAMES =
-      ImmutableSet.of(
-          "com.google.gerrit.httpd.restapi.RestApiServlet", RetryingRestModifyView.class.getName());
-
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
   }
 
-  @AutoValue
-  public abstract static class StagedResult {
-    private static StagedResult create(
-        Change.Id id, NoteDbChangeState.Delta delta, OpenRepo changeRepo, OpenRepo allUsersRepo) {
-      ImmutableList<ReceiveCommand> changeCommands = ImmutableList.of();
-      ImmutableList<InsertedObject> changeObjects = ImmutableList.of();
-      if (changeRepo != null) {
-        changeCommands = changeRepo.getCommandsSnapshot();
-        changeObjects = changeRepo.getInsertedObjects();
-      }
-      ImmutableList<ReceiveCommand> allUsersCommands = ImmutableList.of();
-      ImmutableList<InsertedObject> allUsersObjects = ImmutableList.of();
-      if (allUsersRepo != null) {
-        allUsersCommands = allUsersRepo.getCommandsSnapshot();
-        allUsersObjects = allUsersRepo.getInsertedObjects();
-      }
-      return new AutoValue_NoteDbUpdateManager_StagedResult(
-          id, delta,
-          changeCommands, changeObjects,
-          allUsersCommands, allUsersObjects);
-    }
-
-    public abstract Change.Id id();
-
-    @Nullable
-    public abstract NoteDbChangeState.Delta delta();
-
-    public abstract ImmutableList<ReceiveCommand> changeCommands();
-
-    /**
-     * Objects inserted into the change repo for this change.
-     *
-     * <p>Includes all objects inserted for any change in this repo that may have been processed by
-     * the corresponding {@link NoteDbUpdateManager} instance, not just those objects that were
-     * inserted to handle this specific change's updates.
-     *
-     * @return inserted objects, or null if the corresponding {@link NoteDbUpdateManager} was
-     *     configured not to {@link NoteDbUpdateManager#setSaveObjects(boolean) save objects}.
-     */
-    @Nullable
-    public abstract ImmutableList<InsertedObject> changeObjects();
-
-    public abstract ImmutableList<ReceiveCommand> allUsersCommands();
-
-    /**
-     * Objects inserted into the All-Users repo for this change.
-     *
-     * <p>Includes all objects inserted into All-Users for any change that may have been processed
-     * by the corresponding {@link NoteDbUpdateManager} instance, not just those objects that were
-     * inserted to handle this specific change's updates.
-     *
-     * @return inserted objects, or null if the corresponding {@link NoteDbUpdateManager} was
-     *     configured not to {@link NoteDbUpdateManager#setSaveObjects(boolean) save objects}.
-     */
-    @Nullable
-    public abstract ImmutableList<InsertedObject> allUsersObjects();
-  }
-
-  @AutoValue
-  public abstract static class Result {
-    static Result create(NoteDbUpdateManager.StagedResult staged, NoteDbChangeState newState) {
-      return new AutoValue_NoteDbUpdateManager_Result(newState, staged);
-    }
-
-    @Nullable
-    public abstract NoteDbChangeState newState();
-
-    @Nullable
-    abstract NoteDbUpdateManager.StagedResult staged();
-  }
-
-  public static class OpenRepo implements AutoCloseable {
-    public final Repository repo;
-    public final RevWalk rw;
-    public final ChainedReceiveCommands cmds;
-
-    private final InMemoryInserter inMemIns;
-    private final ObjectInserter tempIns;
-    @Nullable private final ObjectInserter finalIns;
-
-    private final boolean close;
-    private final boolean saveObjects;
-
-    private OpenRepo(
-        Repository repo,
-        RevWalk rw,
-        @Nullable ObjectInserter ins,
-        ChainedReceiveCommands cmds,
-        boolean close,
-        boolean saveObjects) {
-      ObjectReader reader = rw.getObjectReader();
-      checkArgument(
-          ins == null || reader.getCreatedFromInserter() == ins,
-          "expected reader to be created from %s, but was %s",
-          ins,
-          reader.getCreatedFromInserter());
-      this.repo = checkNotNull(repo);
-
-      if (saveObjects) {
-        this.inMemIns = new InMemoryInserter(rw.getObjectReader());
-        this.tempIns = inMemIns;
-      } else {
-        checkArgument(ins != null);
-        this.inMemIns = null;
-        this.tempIns = ins;
-      }
-
-      this.rw = new RevWalk(tempIns.newReader());
-      this.finalIns = ins;
-      this.cmds = checkNotNull(cmds);
-      this.close = close;
-      this.saveObjects = saveObjects;
-    }
-
-    public Optional<ObjectId> getObjectId(String refName) throws IOException {
-      return cmds.get(refName);
-    }
-
-    ImmutableList<ReceiveCommand> getCommandsSnapshot() {
-      return ImmutableList.copyOf(cmds.getCommands().values());
-    }
-
-    @Nullable
-    ImmutableList<InsertedObject> getInsertedObjects() {
-      return saveObjects ? inMemIns.getInsertedObjects() : null;
-    }
-
-    void flush() throws IOException {
-      flushToFinalInserter();
-      finalIns.flush();
-    }
-
-    void flushToFinalInserter() throws IOException {
-      if (!saveObjects) {
-        return;
-      }
-      checkState(finalIns != null);
-      for (InsertedObject obj : inMemIns.getInsertedObjects()) {
-        finalIns.insert(obj.type(), obj.data().toByteArray());
-      }
-      inMemIns.clear();
-    }
-
-    @Override
-    public void close() {
-      rw.getObjectReader().close();
-      rw.close();
-      if (close) {
-        if (finalIns != null) {
-          finalIns.close();
-        }
-        repo.close();
-      }
-    }
-  }
-
   private final Provider<PersonIdent> serverIdent;
   private final GitRepositoryManager repoManager;
-  private final NotesMigration migration;
   private final AllUsersName allUsersName;
   private final NoteDbMetrics metrics;
   private final Project.NameKey projectName;
+  private final int maxUpdates;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
   private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
@@ -260,28 +82,28 @@
 
   private OpenRepo changeRepo;
   private OpenRepo allUsersRepo;
-  private Map<Change.Id, StagedResult> staged;
-  private boolean checkExpectedState = true;
-  private boolean saveObjects = true;
-  private boolean atomicRefUpdates = true;
+  private AllUsersAsyncUpdate updateAllUsersAsync;
+  private boolean executed;
   private String refLogMessage;
   private PersonIdent refLogIdent;
   private PushCertificate pushCert;
 
   @Inject
   NoteDbUpdateManager(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       GitRepositoryManager repoManager,
-      NotesMigration migration,
       AllUsersName allUsersName,
       NoteDbMetrics metrics,
+      AllUsersAsyncUpdate updateAllUsersAsync,
       @Assisted Project.NameKey projectName) {
     this.serverIdent = serverIdent;
     this.repoManager = repoManager;
-    this.migration = migration;
     this.allUsersName = allUsersName;
     this.metrics = metrics;
+    this.updateAllUsersAsync = updateAllUsersAsync;
     this.projectName = projectName;
+    maxUpdates = cfg.getInt("change", null, "maxUpdates", 1000);
     changeUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
@@ -309,50 +131,7 @@
   public NoteDbUpdateManager setChangeRepo(
       Repository repo, RevWalk rw, @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
     checkState(changeRepo == null, "change repo already initialized");
-    changeRepo = new OpenRepo(repo, rw, ins, cmds, false, saveObjects);
-    return this;
-  }
-
-  public NoteDbUpdateManager setAllUsersRepo(
-      Repository repo, RevWalk rw, @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
-    checkState(allUsersRepo == null, "All-Users repo already initialized");
-    allUsersRepo = new OpenRepo(repo, rw, ins, cmds, false, saveObjects);
-    return this;
-  }
-
-  public NoteDbUpdateManager setCheckExpectedState(boolean checkExpectedState) {
-    this.checkExpectedState = checkExpectedState;
-    return this;
-  }
-
-  /**
-   * Set whether to save objects and make them available in {@link StagedResult}s.
-   *
-   * <p>If set, all objects inserted into all repos managed by this instance will be buffered in
-   * memory, and the {@link StagedResult}s will return non-null lists from {@link
-   * StagedResult#changeObjects()} and {@link StagedResult#allUsersObjects()}.
-   *
-   * <p>Not recommended if modifying a large number of changes with a single manager.
-   *
-   * @param saveObjects whether to save objects; defaults to true.
-   * @return this
-   */
-  public NoteDbUpdateManager setSaveObjects(boolean saveObjects) {
-    this.saveObjects = saveObjects;
-    return this;
-  }
-
-  /**
-   * Set whether to use atomic ref updates.
-   *
-   * <p>Can be set to false when the change updates represented by this manager aren't logically
-   * related, e.g. when the updater is only used to group objects together with a single inserter.
-   *
-   * @param atomicRefUpdates whether to use atomic ref updates; defaults to true.
-   * @return this
-   */
-  public NoteDbUpdateManager setAtomicRefUpdates(boolean atomicRefUpdates) {
-    this.atomicRefUpdates = atomicRefUpdates;
+    changeRepo = new OpenRepo(repo, rw, ins, cmds, false);
     return this;
   }
 
@@ -385,54 +164,27 @@
     return this;
   }
 
-  public OpenRepo getChangeRepo() throws IOException {
-    initChangeRepo();
-    return changeRepo;
-  }
-
-  public OpenRepo getAllUsersRepo() throws IOException {
-    initAllUsersRepo();
-    return allUsersRepo;
-  }
-
   private void initChangeRepo() throws IOException {
     if (changeRepo == null) {
-      changeRepo = openRepo(projectName);
+      changeRepo = OpenRepo.open(repoManager, projectName);
     }
   }
 
   private void initAllUsersRepo() throws IOException {
     if (allUsersRepo == null) {
-      allUsersRepo = openRepo(allUsersName);
-    }
-  }
-
-  private OpenRepo openRepo(Project.NameKey p) throws IOException {
-    Repository repo = repoManager.openRepository(p); // Closed by OpenRepo#close.
-    ObjectInserter ins = repo.newObjectInserter(); // Closed by OpenRepo#close.
-    ObjectReader reader = ins.newReader(); // Not closed by OpenRepo#close.
-    try (RevWalk rw = new RevWalk(reader)) { // Doesn't escape OpenRepo constructor.
-      return new OpenRepo(repo, rw, ins, new ChainedReceiveCommands(repo), true, saveObjects) {
-        @Override
-        public void close() {
-          reader.close();
-          super.close();
-        }
-      };
+      allUsersRepo = OpenRepo.open(repoManager, allUsersName);
     }
   }
 
   private boolean isEmpty() {
-    if (!migration.commitChangeWrites()) {
-      return true;
-    }
     return changeUpdates.isEmpty()
         && draftUpdates.isEmpty()
         && robotCommentUpdates.isEmpty()
         && rewriters.isEmpty()
         && toDelete.isEmpty()
         && !hasCommands(changeRepo)
-        && !hasCommands(allUsersRepo);
+        && !hasCommands(allUsersRepo)
+        && updateAllUsersAsync.isEmpty();
   }
 
   private static boolean hasCommands(@Nullable OpenRepo or) {
@@ -448,12 +200,12 @@
    * @param update the update to add.
    */
   public void add(ChangeUpdate update) {
+    checkNotExecuted();
     checkArgument(
         update.getProjectName().equals(projectName),
         "update for project %s cannot be added to manager for project %s",
         update.getProjectName(),
         projectName);
-    checkState(staged == null, "cannot add new update after staging");
     checkArgument(
         !rewriters.containsKey(update.getRefName()),
         "cannot update & rewrite ref %s in one BatchUpdate",
@@ -467,119 +219,69 @@
     if (rcu != null) {
       robotCommentUpdates.put(rcu.getRefName(), rcu);
     }
-    DeleteCommentRewriter rwt = update.getDeleteCommentRewriter();
-    if (rwt != null) {
-      // Checks whether there is any ChangeUpdate added earlier trying to update the same ref.
+    DeleteCommentRewriter deleteCommentRewriter = update.getDeleteCommentRewriter();
+    if (deleteCommentRewriter != null) {
+      // Checks whether there is any ChangeUpdate or rewriter added earlier for the same ref.
       checkArgument(
-          !changeUpdates.containsKey(rwt.getRefName()),
+          !changeUpdates.containsKey(deleteCommentRewriter.getRefName()),
           "cannot update & rewrite ref %s in one BatchUpdate",
-          rwt.getRefName());
-      rewriters.put(rwt.getRefName(), rwt);
+          deleteCommentRewriter.getRefName());
+      checkArgument(
+          !rewriters.containsKey(deleteCommentRewriter.getRefName()),
+          "cannot rewrite the same ref %s in one BatchUpdate",
+          deleteCommentRewriter.getRefName());
+      rewriters.put(deleteCommentRewriter.getRefName(), deleteCommentRewriter);
+    }
+
+    DeleteChangeMessageRewriter deleteChangeMessageRewriter =
+        update.getDeleteChangeMessageRewriter();
+    if (deleteChangeMessageRewriter != null) {
+      // Checks whether there is any ChangeUpdate or rewriter added earlier for the same ref.
+      checkArgument(
+          !changeUpdates.containsKey(deleteChangeMessageRewriter.getRefName()),
+          "cannot update & rewrite ref %s in one BatchUpdate",
+          deleteChangeMessageRewriter.getRefName());
+      checkArgument(
+          !rewriters.containsKey(deleteChangeMessageRewriter.getRefName()),
+          "cannot rewrite the same ref %s in one BatchUpdate",
+          deleteChangeMessageRewriter.getRefName());
+      rewriters.put(deleteChangeMessageRewriter.getRefName(), deleteChangeMessageRewriter);
     }
 
     changeUpdates.put(update.getRefName(), update);
   }
 
   public void add(ChangeDraftUpdate draftUpdate) {
-    checkState(staged == null, "cannot add new update after staging");
+    checkNotExecuted();
     draftUpdates.put(draftUpdate.getRefName(), draftUpdate);
   }
 
   public void deleteChange(Change.Id id) {
-    checkState(staged == null, "cannot add new change to delete after staging");
+    checkNotExecuted();
     toDelete.add(id);
   }
 
   /**
    * Stage updates in the manager's internal list of commands.
    *
-   * @return map of the state that would get written to the applicable repo(s) for each affected
-   *     change.
-   * @throws OrmException if a database layer error occurs.
    * @throws IOException if a storage layer error occurs.
    */
-  public Map<Change.Id, StagedResult> stage() throws OrmException, IOException {
-    if (staged != null) {
-      return staged;
-    }
-    try (Timer1.Context timer = metrics.stageUpdateLatency.start(CHANGES)) {
-      staged = new HashMap<>();
+  private void stage() throws IOException {
+    try (Timer1.Context<NoteDbTable> timer = metrics.stageUpdateLatency.start(CHANGES)) {
       if (isEmpty()) {
-        return staged;
+        return;
       }
 
       initChangeRepo();
       if (!draftUpdates.isEmpty() || !toDelete.isEmpty()) {
         initAllUsersRepo();
       }
-      checkExpectedState();
       addCommands();
-
-      Table<Change.Id, Account.Id, ObjectId> allDraftIds = getDraftIds();
-      Set<Change.Id> changeIds = new HashSet<>();
-      for (ReceiveCommand cmd : changeRepo.getCommandsSnapshot()) {
-        Change.Id changeId = Change.Id.fromRef(cmd.getRefName());
-        if (changeId == null || !cmd.getRefName().equals(RefNames.changeMetaRef(changeId))) {
-          // Not a meta ref update, likely due to a repo update along with the change meta update.
-          continue;
-        }
-        changeIds.add(changeId);
-        Optional<ObjectId> metaId = Optional.of(cmd.getNewId());
-        staged.put(
-            changeId,
-            StagedResult.create(
-                changeId,
-                NoteDbChangeState.Delta.create(
-                    changeId, metaId, allDraftIds.rowMap().remove(changeId)),
-                changeRepo,
-                allUsersRepo));
-      }
-
-      for (Map.Entry<Change.Id, Map<Account.Id, ObjectId>> e : allDraftIds.rowMap().entrySet()) {
-        // If a change remains in the table at this point, it means we are
-        // updating its drafts but not the change itself.
-        StagedResult r =
-            StagedResult.create(
-                e.getKey(),
-                NoteDbChangeState.Delta.create(e.getKey(), Optional.empty(), e.getValue()),
-                changeRepo,
-                allUsersRepo);
-        checkState(
-            r.changeCommands().isEmpty(),
-            "should not have change commands when updating only drafts: %s",
-            r);
-        staged.put(r.id(), r);
-      }
-
-      return staged;
     }
   }
 
-  public Result stageAndApplyDelta(Change change) throws OrmException, IOException {
-    StagedResult sr = stage().get(change.getId());
-    NoteDbChangeState newState =
-        NoteDbChangeState.applyDelta(change, sr != null ? sr.delta() : null);
-    return Result.create(sr, newState);
-  }
-
-  private Table<Change.Id, Account.Id, ObjectId> getDraftIds() {
-    Table<Change.Id, Account.Id, ObjectId> draftIds = HashBasedTable.create();
-    if (allUsersRepo == null) {
-      return draftIds;
-    }
-    for (ReceiveCommand cmd : allUsersRepo.getCommandsSnapshot()) {
-      String r = cmd.getRefName();
-      if (r.startsWith(REFS_DRAFT_COMMENTS)) {
-        Change.Id changeId = Change.Id.fromRefPart(r.substring(REFS_DRAFT_COMMENTS.length()));
-        Account.Id accountId = Account.Id.fromRefSuffix(r);
-        checkDraftRef(accountId != null && changeId != null, r);
-        draftIds.put(changeId, accountId, cmd.getNewId());
-      }
-    }
-    return draftIds;
-  }
-
   public void flush() throws IOException {
+    checkNotExecuted();
     if (changeRepo != null) {
       changeRepo.flush();
     }
@@ -589,20 +291,18 @@
   }
 
   @Nullable
-  public BatchRefUpdate execute() throws OrmException, IOException {
+  public BatchRefUpdate execute() throws IOException {
     return execute(false);
   }
 
   @Nullable
-  public BatchRefUpdate execute(boolean dryrun) throws OrmException, IOException {
-    // Check before even inspecting the list, as this is a programmer error.
-    if (migration.failChangeWrites()) {
-      throw new OrmException(CHANGES_READ_ONLY);
-    }
+  public BatchRefUpdate execute(boolean dryrun) throws IOException {
+    checkNotExecuted();
     if (isEmpty()) {
+      executed = true;
       return null;
     }
-    try (Timer1.Context timer = metrics.updateLatency.start(CHANGES)) {
+    try (Timer1.Context<NoteDbTable> timer = metrics.updateLatency.start(CHANGES)) {
       stage();
       // ChangeUpdates must execute before ChangeDraftUpdates.
       //
@@ -614,6 +314,14 @@
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
       BatchRefUpdate result = execute(changeRepo, dryrun, pushCert);
       execute(allUsersRepo, dryrun, null);
+      if (!dryrun) {
+        // Only execute the asynchronous operation if we are not in dry-run mode: The dry run would
+        // have to run synchronous to be of any value at all. For the removal of draft comments from
+        // All-Users we don't care much of the operation succeeds, so we are skipping the dry run
+        // altogether.
+        updateAllUsersAsync.execute(refLogIdent, refLogMessage, pushCert);
+      }
+      executed = true;
       return result;
     } finally {
       close();
@@ -638,10 +346,11 @@
     if (refLogMessage != null) {
       bru.setRefLogMessage(refLogMessage, false);
     } else {
-      bru.setRefLogMessage(firstNonNull(guessRestApiHandler(), "Update NoteDb refs"), false);
+      bru.setRefLogMessage(
+          firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs"), false);
     }
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
-    bru.setAtomic(atomicRefUpdates);
+    bru.setAtomic(true);
     or.cmds.addTo(bru);
     bru.setAllowNonFastForwards(true);
 
@@ -651,59 +360,18 @@
     return bru;
   }
 
-  private static String guessRestApiHandler() {
-    StackTraceElement[] trace = Thread.currentThread().getStackTrace();
-    int i = findRestApiServlet(trace);
-    if (i < 0) {
-      return null;
-    }
-    try {
-      for (i--; i >= 0; i--) {
-        String cn = trace[i].getClassName();
-        Class<?> cls = Class.forName(cn);
-        if (RestModifyView.class.isAssignableFrom(cls) && cls != RetryingRestModifyView.class) {
-          return viewName(cn);
-        }
-      }
-      return null;
-    } catch (ClassNotFoundException e) {
-      return null;
-    }
-  }
-
-  private static String viewName(String cn) {
-    String impl = cn.replace('$', '.');
-    for (String p : PACKAGE_PREFIXES) {
-      if (impl.startsWith(p)) {
-        return impl.substring(p.length());
-      }
-    }
-    return impl;
-  }
-
-  private static int findRestApiServlet(StackTraceElement[] trace) {
-    for (int i = 0; i < trace.length; i++) {
-      if (SERVLET_NAMES.contains(trace[i].getClassName())) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  private void addCommands() throws OrmException, IOException {
-    if (isEmpty()) {
-      return;
-    }
-    checkState(changeRepo != null, "must set change repo");
+  private void addCommands() throws IOException {
+    changeRepo.addUpdates(changeUpdates, Optional.of(maxUpdates));
     if (!draftUpdates.isEmpty()) {
-      checkState(allUsersRepo != null, "must set all users repo");
-    }
-    addUpdates(changeUpdates, changeRepo);
-    if (!draftUpdates.isEmpty()) {
-      addUpdates(draftUpdates, allUsersRepo);
+      boolean publishOnly = draftUpdates.values().stream().allMatch(ChangeDraftUpdate::canRunAsync);
+      if (publishOnly) {
+        updateAllUsersAsync.setDraftUpdates(draftUpdates);
+      } else {
+        allUsersRepo.addUpdates(draftUpdates);
+      }
     }
     if (!robotCommentUpdates.isEmpty()) {
-      addUpdates(robotCommentUpdates, changeRepo);
+      changeRepo.addUpdates(robotCommentUpdates);
     }
     if (!rewriters.isEmpty()) {
       addRewrites(rewriters, changeRepo);
@@ -712,7 +380,6 @@
     for (Change.Id id : toDelete) {
       doDelete(id);
     }
-    checkExpectedState();
   }
 
   private void doDelete(Change.Id id) throws IOException {
@@ -724,7 +391,7 @@
 
     // Just scan repo for ref names, but get "old" values from cmds.
     for (Ref r :
-        allUsersRepo.repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(id)).values()) {
+        allUsersRepo.repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(id))) {
       old = allUsersRepo.cmds.get(r.getName());
       if (old.isPresent()) {
         allUsersRepo.cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), r.getName()));
@@ -732,114 +399,18 @@
     }
   }
 
-  public static class MismatchedStateException extends OrmException {
-    private static final long serialVersionUID = 1L;
-
-    private MismatchedStateException(Change.Id id, NoteDbChangeState expectedState) {
-      super(
-          String.format(
-              "cannot apply NoteDb updates for change %s; change meta ref does not match %s",
-              id, expectedState.getChangeMetaId().name()));
-    }
-  }
-
-  private void checkExpectedState() throws OrmException, IOException {
-    if (!checkExpectedState) {
-      return;
-    }
-
-    // Refuse to apply an update unless the state in NoteDb matches the state
-    // claimed in the ref. This means we may have failed a NoteDb ref update,
-    // and it would be incorrect to claim that the ref is up to date after this
-    // pipeline.
-    //
-    // Generally speaking, this case should be rare; in most cases, we should
-    // have detected and auto-fixed the stale state when creating ChangeNotes
-    // that got passed into the ChangeUpdate.
-    for (Collection<ChangeUpdate> us : changeUpdates.asMap().values()) {
-      ChangeUpdate u = us.iterator().next();
-      NoteDbChangeState expectedState = NoteDbChangeState.parse(u.getChange());
-
-      if (expectedState == null) {
-        // No previous state means we haven't previously written NoteDb graphs
-        // for this change yet. This means either:
-        //  - The change is new, and we'll be creating its ref.
-        //  - We short-circuited before adding any commands that update this
-        //    ref, and we won't stage a delta for this change either.
-        // Either way, it is safe to proceed here rather than throwing
-        // MismatchedStateException.
-        continue;
-      }
-
-      if (expectedState.getPrimaryStorage() == PrimaryStorage.NOTE_DB) {
-        // NoteDb is primary, no need to compare state to ReviewDb.
-        continue;
-      }
-
-      if (!expectedState.isChangeUpToDate(changeRepo.cmds.getRepoRefCache())) {
-        throw new MismatchedStateException(u.getId(), expectedState);
-      }
-    }
-
-    for (Collection<ChangeDraftUpdate> us : draftUpdates.asMap().values()) {
-      ChangeDraftUpdate u = us.iterator().next();
-      NoteDbChangeState expectedState = NoteDbChangeState.parse(u.getChange());
-
-      if (expectedState == null || expectedState.getPrimaryStorage() == PrimaryStorage.NOTE_DB) {
-        continue; // See above.
-      }
-
-      Account.Id accountId = u.getAccountId();
-      if (!expectedState.areDraftsUpToDate(allUsersRepo.cmds.getRepoRefCache(), accountId)) {
-        ObjectId expectedDraftId =
-            firstNonNull(expectedState.getDraftIds().get(accountId), ObjectId.zeroId());
-        throw new OrmConcurrencyException(
-            String.format(
-                "cannot apply NoteDb updates for change %s;"
-                    + " draft ref for account %s does not match %s",
-                u.getId(), accountId, expectedDraftId.name()));
-      }
-    }
-  }
-
-  private static <U extends AbstractChangeUpdate> void addUpdates(
-      ListMultimap<String, U> all, OpenRepo or) throws OrmException, IOException {
-    for (Map.Entry<String, Collection<U>> e : all.asMap().entrySet()) {
-      String refName = e.getKey();
-      Collection<U> updates = e.getValue();
-      ObjectId old = or.cmds.get(refName).orElse(ObjectId.zeroId());
-      // Only actually write to the ref if one of the updates explicitly allows
-      // us to do so, i.e. it is known to represent a new change. This avoids
-      // writing partial change meta if the change hasn't been backfilled yet.
-      if (!allowWrite(updates, old)) {
-        continue;
-      }
-
-      ObjectId curr = old;
-      for (U u : updates) {
-        if (u.isRootOnly() && !old.equals(ObjectId.zeroId())) {
-          throw new OrmException("Given ChangeUpdate is only allowed on initial commit");
-        }
-        ObjectId next = u.apply(or.rw, or.tempIns, curr);
-        if (next == null) {
-          continue;
-        }
-        curr = next;
-      }
-      if (!old.equals(curr)) {
-        or.cmds.add(new ReceiveCommand(old, curr, refName));
-      }
-    }
+  private void checkNotExecuted() {
+    checkState(!executed, "update has already been executed");
   }
 
   private static void addRewrites(ListMultimap<String, NoteDbRewriter> rewriters, OpenRepo openRepo)
-      throws OrmException, IOException {
+      throws IOException {
     for (Map.Entry<String, Collection<NoteDbRewriter>> entry : rewriters.asMap().entrySet()) {
       String refName = entry.getKey();
       ObjectId oldTip = openRepo.cmds.get(refName).orElse(ObjectId.zeroId());
 
       if (oldTip.equals(ObjectId.zeroId())) {
-        throw new OrmException(String.format("Ref %s is empty", refName));
+        throw new StorageException(String.format("Ref %s is empty", refName));
       }
 
       ObjectId currTip = oldTip;
@@ -852,7 +423,7 @@
           }
         }
       } catch (ConfigInvalidException e) {
-        throw new OrmException("Cannot rewrite commit history", e);
+        throw new StorageException("Cannot rewrite commit history", e);
       }
 
       if (!oldTip.equals(currTip)) {
@@ -860,16 +431,4 @@
       }
     }
   }
-
-  private static <U extends AbstractChangeUpdate> boolean allowWrite(
-      Collection<U> updates, ObjectId old) {
-    if (!old.equals(ObjectId.zeroId())) {
-      return true;
-    }
-    return updates.iterator().next().allowWriteToNewRef();
-  }
-
-  private static void checkDraftRef(boolean condition, String refName) {
-    checkState(condition, "invalid draft ref: %s", refName);
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 21fada8..c53f4b9 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import java.sql.Timestamp;
 import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -25,6 +29,14 @@
 
 public class NoteDbUtil {
 
+  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");
+
+  private static final ImmutableList<String> PACKAGE_PREFIXES =
+      ImmutableList.of("com.google.gerrit.server.", "com.google.gerrit.");
+  private static final ImmutableSet<String> SERVLET_NAMES =
+      ImmutableSet.of(
+          "com.google.gerrit.httpd.restapi.RestApiServlet", RetryingRestModifyView.class.getName());
+
   /**
    * Returns an AccountId for the given email address. Returns empty if the address isn't on this
    * server.
@@ -33,19 +45,17 @@
     String email = ident.getEmailAddress();
     int at = email.indexOf('@');
     if (at >= 0) {
-      String host = email.substring(at + 1, email.length());
+      String host = email.substring(at + 1);
       if (host.equals(serverId)) {
         Integer id = Ints.tryParse(email.substring(0, at));
         if (id != null) {
-          return Optional.of(new Account.Id(id));
+          return Optional.of(Account.id(id));
         }
       }
     }
     return Optional.empty();
   }
 
-  private NoteDbUtil() {}
-
   public static String formatTime(PersonIdent ident, Timestamp t) {
     GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT);
     // TODO(dborowitz): Use a ThreadLocal or use Joda.
@@ -53,7 +63,29 @@
     return dateFormatter.formatDate(newIdent);
   }
 
-  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");
+  /**
+   * Returns the name of the REST API handler that is in the stack trace of the caller of this
+   * method.
+   */
+  static String guessRestApiHandler() {
+    StackTraceElement[] trace = Thread.currentThread().getStackTrace();
+    int i = findRestApiServlet(trace);
+    if (i < 0) {
+      return null;
+    }
+    try {
+      for (i--; i >= 0; i--) {
+        String cn = trace[i].getClassName();
+        Class<?> cls = Class.forName(cn);
+        if (RestModifyView.class.isAssignableFrom(cls) && cls != RetryingRestModifyView.class) {
+          return viewName(cn);
+        }
+      }
+      return null;
+    } catch (ClassNotFoundException e) {
+      return null;
+    }
+  }
 
   static String sanitizeFooter(String value) {
     // Remove characters that would confuse JGit's footer parser if they were
@@ -65,4 +97,25 @@
     // empty paragraph for the purposes of footer parsing.
     return INVALID_FOOTER_CHARS.trimAndCollapseFrom(value, ' ');
   }
+
+  private static int findRestApiServlet(StackTraceElement[] trace) {
+    for (int i = 0; i < trace.length; i++) {
+      if (SERVLET_NAMES.contains(trace[i].getClassName())) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  private static String viewName(String cn) {
+    String impl = cn.replace('$', '.');
+    for (String p : PACKAGE_PREFIXES) {
+      if (impl.startsWith(p)) {
+        return impl.substring(p.length());
+      }
+    }
+    return impl;
+  }
+
+  private NoteDbUtil() {}
 }
diff --git a/java/com/google/gerrit/server/notedb/NotesMigration.java b/java/com/google/gerrit/server/notedb/NotesMigration.java
deleted file mode 100644
index 9cee2cd..0000000
--- a/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ /dev/null
@@ -1,250 +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.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.inject.AbstractModule;
-import java.util.concurrent.atomic.AtomicReference;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Current low-level settings of the NoteDb migration for changes.
- *
- * <p>This class only describes the migration state of the {@link
- * com.google.gerrit.reviewdb.client.Change Change} entity group, since it is possible for a given
- * site to be in different states of the Change NoteDb migration process while staying at the same
- * ReviewDb schema version. It does <em>not</em> describe the migration state of non-Change tables;
- * those are automatically migrated using the ReviewDb schema migration process, so the NoteDb
- * migration state at a given ReviewDb schema cannot vary.
- *
- * <p>In many places, core Gerrit code should not directly care about the NoteDb migration state,
- * and should prefer high-level APIs like {@link com.google.gerrit.server.ApprovalsUtil
- * ApprovalsUtil} that don't require callers to inspect the migration state. The
- * <em>implementation</em> of those utilities does care about the state, and should query the {@code
- * NotesMigration} for the properties of the migration, for example, {@link #changePrimaryStorage()
- * where new changes should be stored}.
- *
- * <p>Core Gerrit code is mostly interested in one facet of the migration at a time (reading or
- * writing, say), but not all combinations of return values are supported or even make sense.
- *
- * <p>This class controls the state of the migration according to options in {@code gerrit.config}.
- * In general, any changes to these options should only be made by adventurous administrators, who
- * know what they're doing, on non-production data, for the purposes of testing the NoteDb
- * implementation. Changing options quite likely requires re-running {@code MigrateToNoteDb}. For
- * these reasons, the options remain undocumented.
- *
- * <p><strong>Note:</strong> Callers should not assume the values returned by {@code
- * NotesMigration}'s methods will not change in a running server.
- */
-public abstract class NotesMigration {
-  public static final String SECTION_NOTE_DB = "noteDb";
-  public static final String READ = "read";
-  public static final String WRITE = "write";
-  public static final String DISABLE_REVIEW_DB = "disableReviewDb";
-
-  private static final String PRIMARY_STORAGE = "primaryStorage";
-  private static final String SEQUENCE = "sequence";
-
-  public static class Module extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(MutableNotesMigration.class);
-      bind(NotesMigration.class).to(MutableNotesMigration.class);
-    }
-  }
-
-  @AutoValue
-  abstract static class Snapshot {
-    static Builder builder() {
-      // Default values are defined as what we would read from an empty config.
-      return create(new Config()).toBuilder();
-    }
-
-    static Snapshot create(Config cfg) {
-      return new AutoValue_NotesMigration_Snapshot.Builder()
-          .setWriteChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, false))
-          .setReadChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, false))
-          .setReadChangeSequence(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, false))
-          .setChangePrimaryStorage(
-              cfg.getEnum(
-                  SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB))
-          .setDisableChangeReviewDb(
-              cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false))
-          .setFailOnLoadForTest(false) // Only set in tests, can't be set via config.
-          .build();
-    }
-
-    abstract boolean writeChanges();
-
-    abstract boolean readChanges();
-
-    abstract boolean readChangeSequence();
-
-    abstract PrimaryStorage changePrimaryStorage();
-
-    abstract boolean disableChangeReviewDb();
-
-    abstract boolean failOnLoadForTest();
-
-    abstract Builder toBuilder();
-
-    void setConfigValues(Config cfg) {
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, writeChanges());
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, readChanges());
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, readChangeSequence());
-      cfg.setEnum(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, changePrimaryStorage());
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, disableChangeReviewDb());
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder setWriteChanges(boolean writeChanges);
-
-      abstract Builder setReadChanges(boolean readChanges);
-
-      abstract Builder setReadChangeSequence(boolean readChangeSequence);
-
-      abstract Builder setChangePrimaryStorage(PrimaryStorage changePrimaryStorage);
-
-      abstract Builder setDisableChangeReviewDb(boolean disableChangeReviewDb);
-
-      abstract Builder setFailOnLoadForTest(boolean failOnLoadForTest);
-
-      abstract Snapshot autoBuild();
-
-      Snapshot build() {
-        Snapshot s = autoBuild();
-        checkArgument(
-            !(s.disableChangeReviewDb() && s.changePrimaryStorage() != PrimaryStorage.NOTE_DB),
-            "cannot disable ReviewDb for changes if default change primary storage is ReviewDb");
-        return s;
-      }
-    }
-  }
-
-  protected final AtomicReference<Snapshot> snapshot;
-
-  /**
-   * Read changes from NoteDb.
-   *
-   * <p>Change data is read from NoteDb refs, but ReviewDb is still the source of truth. If the
-   * loader determines NoteDb is out of date, the change data in NoteDb will be transparently
-   * rebuilt. This means that some code paths that look read-only may in fact attempt to write.
-   *
-   * <p>If true and {@code writeChanges() = false}, changes can still be read from NoteDb, but any
-   * attempts to write will generate an error.
-   */
-  public final boolean readChanges() {
-    return snapshot.get().readChanges();
-  }
-
-  /**
-   * Write changes to NoteDb.
-   *
-   * <p>This method is awkwardly named because you should be using either {@link
-   * #commitChangeWrites()} or {@link #failChangeWrites()} instead.
-   *
-   * <p>Updates to change data are written to NoteDb refs, but ReviewDb is still the source of
-   * truth. Change data will not be written unless the NoteDb refs are already up to date, and the
-   * write path will attempt to rebuild the change if not.
-   *
-   * <p>If false, the behavior when attempting to write depends on {@code readChanges()}. If {@code
-   * readChanges() = false}, writes to NoteDb are simply ignored; if {@code true}, any attempts to
-   * write will generate an error.
-   */
-  public final boolean rawWriteChangesSetting() {
-    return snapshot.get().writeChanges();
-  }
-
-  /**
-   * Read sequential change ID numbers from NoteDb.
-   *
-   * <p>If true, change IDs are read from {@code refs/sequences/changes} in All-Projects. If false,
-   * change IDs are read from ReviewDb's native sequences.
-   */
-  public final boolean readChangeSequence() {
-    return snapshot.get().readChangeSequence();
-  }
-
-  /** @return default primary storage for new changes. */
-  public final PrimaryStorage changePrimaryStorage() {
-    return snapshot.get().changePrimaryStorage();
-  }
-
-  /**
-   * Disable ReviewDb access for changes.
-   *
-   * <p>When set, ReviewDb operations involving the Changes table become no-ops. Lookups return no
-   * results; updates do nothing, as does opening, committing, or rolling back a transaction on the
-   * Changes table.
-   */
-  public final boolean disableChangeReviewDb() {
-    return snapshot.get().disableChangeReviewDb();
-  }
-
-  /**
-   * Whether to fail when reading any data from NoteDb.
-   *
-   * <p>Used in conjunction with {@link #readChanges()} for tests.
-   */
-  public boolean failOnLoadForTest() {
-    return snapshot.get().failOnLoadForTest();
-  }
-
-  public final boolean commitChangeWrites() {
-    // It may seem odd that readChanges() without writeChanges() means we should
-    // attempt to commit writes. However, this method is used by callers to know
-    // whether or not they should short-circuit and skip attempting to read or
-    // write NoteDb refs.
-    //
-    // It is possible for commitChangeWrites() to return true and
-    // failChangeWrites() to also return true, causing an error later in the
-    // same codepath. This specific condition is used by the auto-rebuilding
-    // path to rebuild a change and stage the results, but not commit them due
-    // to failChangeWrites().
-    return rawWriteChangesSetting() || readChanges();
-  }
-
-  public final boolean failChangeWrites() {
-    return !rawWriteChangesSetting() && readChanges();
-  }
-
-  public final void setConfigValues(Config cfg) {
-    snapshot.get().setConfigValues(cfg);
-  }
-
-  @Override
-  public final boolean equals(Object o) {
-    return o instanceof NotesMigration
-        && snapshot.get().equals(((NotesMigration) o).snapshot.get());
-  }
-
-  @Override
-  public final int hashCode() {
-    return snapshot.get().hashCode();
-  }
-
-  protected NotesMigration(Snapshot snapshot) {
-    this.snapshot = new AtomicReference<>(snapshot);
-  }
-
-  final Snapshot snapshot() {
-    return snapshot.get();
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/NotesMigrationState.java b/java/com/google/gerrit/server/notedb/NotesMigrationState.java
deleted file mode 100644
index c682aed..0000000
--- a/java/com/google/gerrit/server/notedb/NotesMigrationState.java
+++ /dev/null
@@ -1,92 +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.server.notedb;
-
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration.Snapshot;
-import java.util.Optional;
-import java.util.stream.Stream;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Possible high-level states of the NoteDb migration for changes.
- *
- * <p>This class describes the series of states required to migrate a site from ReviewDb-only to
- * NoteDb-only. This process has several steps, and covers only a small subset of the theoretically
- * possible combinations of {@link NotesMigration} return values.
- *
- * <p>These states are ordered: a one-way migration from ReviewDb to NoteDb will pass through states
- * in the order in which they are defined.
- */
-public enum NotesMigrationState {
-  REVIEW_DB(false, false, false, PrimaryStorage.REVIEW_DB, false),
-
-  WRITE(false, true, false, PrimaryStorage.REVIEW_DB, false),
-
-  READ_WRITE_NO_SEQUENCE(true, true, false, PrimaryStorage.REVIEW_DB, false),
-
-  READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY(true, true, true, PrimaryStorage.REVIEW_DB, false),
-
-  READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY(true, true, true, PrimaryStorage.NOTE_DB, false),
-
-  NOTE_DB(true, true, true, PrimaryStorage.NOTE_DB, true);
-
-  public static final NotesMigrationState FINAL = NOTE_DB;
-
-  public static Optional<NotesMigrationState> forConfig(Config cfg) {
-    return forSnapshot(Snapshot.create(cfg));
-  }
-
-  public static Optional<NotesMigrationState> forNotesMigration(NotesMigration migration) {
-    return forSnapshot(migration.snapshot());
-  }
-
-  private static Optional<NotesMigrationState> forSnapshot(Snapshot s) {
-    return Stream.of(values()).filter(v -> v.snapshot.equals(s)).findFirst();
-  }
-
-  private final Snapshot snapshot;
-
-  NotesMigrationState(
-      // Arguments match abstract methods in NotesMigration.
-      boolean readChanges,
-      boolean rawWriteChangesSetting,
-      boolean readChangeSequence,
-      PrimaryStorage changePrimaryStorage,
-      boolean disableChangeReviewDb) {
-    this.snapshot =
-        Snapshot.builder()
-            .setReadChanges(readChanges)
-            .setWriteChanges(rawWriteChangesSetting)
-            .setReadChangeSequence(readChangeSequence)
-            .setChangePrimaryStorage(changePrimaryStorage)
-            .setDisableChangeReviewDb(disableChangeReviewDb)
-            .build();
-  }
-
-  public void setConfigValues(Config cfg) {
-    snapshot.setConfigValues(cfg);
-  }
-
-  public String toText() {
-    Config cfg = new Config();
-    setConfigValues(cfg);
-    return cfg.toText();
-  }
-
-  Snapshot snapshot() {
-    return snapshot;
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/OpenRepo.java b/java/com/google/gerrit/server/notedb/OpenRepo.java
new file mode 100644
index 0000000..4595607
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/OpenRepo.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.InsertedObject;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Wrapper around {@link Repository} that keeps track of related {@link ObjectInserter}s and other
+ * objects that are jointly closed when invoking {@link #close}.
+ */
+class OpenRepo implements AutoCloseable {
+  /** Returns a {@link OpenRepo} wrapping around an open {@link Repository}. */
+  static OpenRepo open(GitRepositoryManager repoManager, Project.NameKey project)
+      throws IOException {
+    Repository repo = repoManager.openRepository(project); // Closed by OpenRepo#close.
+    ObjectInserter ins = repo.newObjectInserter(); // Closed by OpenRepo#close.
+    ObjectReader reader = ins.newReader(); // Not closed by OpenRepo#close.
+    try (RevWalk rw = new RevWalk(reader)) { // Doesn't escape OpenRepo constructor.
+      return new OpenRepo(repo, rw, ins, new ChainedReceiveCommands(repo), true) {
+        @Override
+        public void close() {
+          reader.close();
+          super.close();
+        }
+      };
+    }
+  }
+
+  final Repository repo;
+  final RevWalk rw;
+  final ChainedReceiveCommands cmds;
+  final ObjectInserter tempIns;
+
+  private final InMemoryInserter inMemIns;
+  @Nullable private final ObjectInserter finalIns;
+  private final boolean close;
+
+  OpenRepo(
+      Repository repo,
+      RevWalk rw,
+      @Nullable ObjectInserter ins,
+      ChainedReceiveCommands cmds,
+      boolean close) {
+    ObjectReader reader = rw.getObjectReader();
+    checkArgument(
+        ins == null || reader.getCreatedFromInserter() == ins,
+        "expected reader to be created from %s, but was %s",
+        ins,
+        reader.getCreatedFromInserter());
+    this.repo = requireNonNull(repo);
+
+    this.inMemIns = new InMemoryInserter(rw.getObjectReader());
+    this.tempIns = inMemIns;
+
+    this.rw = new RevWalk(tempIns.newReader());
+    this.finalIns = ins;
+    this.cmds = requireNonNull(cmds);
+    this.close = close;
+  }
+
+  @Override
+  public void close() {
+    rw.getObjectReader().close();
+    rw.close();
+    if (close) {
+      if (finalIns != null) {
+        finalIns.close();
+      }
+      repo.close();
+    }
+  }
+
+  void flush() throws IOException {
+    flushToFinalInserter();
+    finalIns.flush();
+  }
+
+  void flushToFinalInserter() throws IOException {
+    checkState(finalIns != null);
+    for (InsertedObject obj : inMemIns.getInsertedObjects()) {
+      finalIns.insert(obj.type(), obj.data().toByteArray());
+    }
+    inMemIns.clear();
+  }
+
+  private static <U extends AbstractChangeUpdate> boolean allowWrite(
+      Collection<U> updates, ObjectId old) {
+    if (!old.equals(ObjectId.zeroId())) {
+      return true;
+    }
+    return updates.iterator().next().allowWriteToNewRef();
+  }
+
+  <U extends AbstractChangeUpdate> void addUpdates(ListMultimap<String, U> all) throws IOException {
+    addUpdates(all, Optional.empty());
+  }
+
+  <U extends AbstractChangeUpdate> void addUpdates(
+      ListMultimap<String, U> all, Optional<Integer> maxUpdates) throws IOException {
+    for (Map.Entry<String, Collection<U>> e : all.asMap().entrySet()) {
+      String refName = e.getKey();
+      Collection<U> updates = e.getValue();
+      ObjectId old = cmds.get(refName).orElse(ObjectId.zeroId());
+      // Only actually write to the ref if one of the updates explicitly allows
+      // us to do so, i.e. it is known to represent a new change. This avoids
+      // writing partial change meta if the change hasn't been backfilled yet.
+      if (!allowWrite(updates, old)) {
+        continue;
+      }
+
+      int updateCount;
+      U first = updates.iterator().next();
+      if (maxUpdates.isPresent()) {
+        checkState(first.getNotes() != null, "expected ChangeNotes on %s", first);
+        updateCount = first.getNotes().getUpdateCount();
+      } else {
+        updateCount = 0;
+      }
+
+      ObjectId curr = old;
+      for (U u : updates) {
+        if (u.isRootOnly() && !old.equals(ObjectId.zeroId())) {
+          throw new StorageException("Given ChangeUpdate is only allowed on initial commit");
+        }
+        ObjectId next = u.apply(rw, tempIns, curr);
+        if (next == null) {
+          continue;
+        }
+        if (maxUpdates.isPresent()
+            && !Objects.equals(next, curr)
+            && ++updateCount > maxUpdates.get()
+            && !u.bypassMaxUpdates()) {
+          throw new TooManyUpdatesException(u.getId(), maxUpdates.get());
+        }
+        curr = next;
+      }
+      if (!old.equals(curr)) {
+        cmds.add(new ReceiveCommand(old, curr, refName));
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java b/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
deleted file mode 100644
index 43ed722..0000000
--- a/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
+++ /dev/null
@@ -1,510 +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.server.notedb;
-
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.NANOSECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.github.rholder.retry.RetryException;
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.github.rholder.retry.WaitStrategies;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Stopwatch;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.RepoRefCache;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.TreeSet;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-
-/** Helper to migrate the {@link PrimaryStorage} of individual changes. */
-@Singleton
-public class PrimaryStorageMigrator {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  /**
-   * Exception thrown during migration if the change has no {@code noteDbState} field at the
-   * beginning of the migration.
-   */
-  public static class NoNoteDbStateException extends RuntimeException {
-    private static final long serialVersionUID = 1L;
-
-    private NoNoteDbStateException(Change.Id id) {
-      super("change " + id + " has no note_db_state; rebuild it first");
-    }
-  }
-
-  private final AllUsersName allUsers;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeRebuilder rebuilder;
-  private final ChangeUpdate.Factory updateFactory;
-  private final GitRepositoryManager repoManager;
-  private final InternalUser.Factory internalUserFactory;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Provider<ReviewDb> db;
-  private final RetryHelper retryHelper;
-
-  private final long skewMs;
-  private final long timeoutMs;
-  private final Retryer<NoteDbChangeState> testEnsureRebuiltRetryer;
-
-  @Inject
-  PrimaryStorageMigrator(
-      @GerritServerConfig Config cfg,
-      Provider<ReviewDb> db,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      ChangeRebuilder rebuilder,
-      ChangeNotes.Factory changeNotesFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeUpdate.Factory updateFactory,
-      InternalUser.Factory internalUserFactory,
-      RetryHelper retryHelper) {
-    this(
-        cfg,
-        db,
-        repoManager,
-        allUsers,
-        rebuilder,
-        null,
-        changeNotesFactory,
-        queryProvider,
-        updateFactory,
-        internalUserFactory,
-        retryHelper);
-  }
-
-  @VisibleForTesting
-  public PrimaryStorageMigrator(
-      Config cfg,
-      Provider<ReviewDb> db,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      ChangeRebuilder rebuilder,
-      @Nullable Retryer<NoteDbChangeState> testEnsureRebuiltRetryer,
-      ChangeNotes.Factory changeNotesFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeUpdate.Factory updateFactory,
-      InternalUser.Factory internalUserFactory,
-      RetryHelper retryHelper) {
-    this.db = db;
-    this.repoManager = repoManager;
-    this.allUsers = allUsers;
-    this.rebuilder = rebuilder;
-    this.testEnsureRebuiltRetryer = testEnsureRebuiltRetryer;
-    this.changeNotesFactory = changeNotesFactory;
-    this.queryProvider = queryProvider;
-    this.updateFactory = updateFactory;
-    this.internalUserFactory = internalUserFactory;
-    this.retryHelper = retryHelper;
-    skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
-
-    String s = "notedb";
-    timeoutMs =
-        cfg.getTimeUnit(
-            s,
-            null,
-            "primaryStorageMigrationTimeout",
-            MILLISECONDS.convert(60, SECONDS),
-            MILLISECONDS);
-  }
-
-  /**
-   * Migrate a change's primary storage from ReviewDb to NoteDb.
-   *
-   * <p>This method will return only if the primary storage of the change is NoteDb afterwards. (It
-   * may return early if the primary storage was already NoteDb.)
-   *
-   * <p>If this method throws an exception, then the primary storage of the change is probably not
-   * NoteDb. (It is possible that the primary storage of the change is NoteDb in this case, but
-   * there was an error reading the state.) Moreover, after an exception, the change may be
-   * read-only until a lease expires. If the caller chooses to retry, they should wait until the
-   * read-only lease expires; this method will fail relatively quickly if called on a read-only
-   * change.
-   *
-   * <p>Note that if the change is read-only after this method throws an exception, that does not
-   * necessarily guarantee that the read-only lease was acquired during that particular method
-   * invocation; this call may have in fact failed because another thread acquired the lease first.
-   *
-   * @param id change ID.
-   * @throws OrmException if a ReviewDb-level error occurs.
-   * @throws IOException if a repo-level error occurs.
-   */
-  public void migrateToNoteDbPrimary(Change.Id id) throws OrmException, IOException {
-    // Since there are multiple non-atomic steps in this method, we need to
-    // consider what happens when there is another writer concurrent with the
-    // thread executing this method.
-    //
-    // Let:
-    // * OR = other writer writes noteDbState & new data to ReviewDb (in one
-    //        transaction)
-    // * ON = other writer writes to NoteDb
-    // * MRO = migrator sets state to read-only
-    // * MR = ensureRebuilt writes rebuilt noteDbState to ReviewDb (but does not
-    //        otherwise update ReviewDb in this transaction)
-    // * MN = ensureRebuilt writes rebuilt state to NoteDb
-    //
-    // Consider all the interleavings of these operations.
-    //
-    // * OR,ON,MRO,...
-    //   Other writer completes before migrator begins; this is not a concurrent
-    //   write.
-    // * MRO,...,OR,...
-    //   OR will fail, since it atomically checks that the noteDbState is not
-    //   read-only before proceeding. This results in an exception, but not a
-    //   concurrent write.
-    //
-    // Thus all the "interesting" interleavings start with OR,MRO, and differ on
-    // where ON falls relative to MR/MN.
-    //
-    // * OR,MRO,ON,MR,MN
-    //   The other NoteDb write succeeds despite the noteDbState being
-    //   read-only. Because the read-only state from MRO includes the update
-    //   from OR, the change is up-to-date at this point. Thus MR,MN is a no-op.
-    //   The end result is an up-to-date, read-only change.
-    //
-    // * OR,MRO,MR,ON,MN
-    //   The change is out-of-date when ensureRebuilt begins, because OR
-    //   succeeded but the corresponding ON has not happened yet. ON will
-    //   succeed, because there have been no intervening NoteDb writes. MN will
-    //   fail, because ON updated the state in NoteDb to something other than
-    //   what MR claimed. This leaves the change in an out-of-date, read-only
-    //   state.
-    //
-    //   If this method threw an exception in this case, the change would
-    //   eventually switch back to read-write when the read-only lease expires,
-    //   so this situation is recoverable. However, it would be inconvenient for
-    //   a change to be read-only for so long.
-    //
-    //   Thus, as an optimization, we have a retry loop that attempts
-    //   ensureRebuilt while still holding the same read-only lease. This
-    //   effectively results in the interleaving OR,MR,ON,MR,MN; in contrast
-    //   with the previous case, here, MR/MN actually rebuilds the change. In
-    //   the case of a write failure, MR/MN might fail and get retried again. If
-    //   it exceeds the maximum number of retries, an exception is thrown.
-    //
-    // * OR,MRO,MR,MN,ON
-    //   The change is out-of-date when ensureRebuilt begins. The change is
-    //   rebuilt, leaving a new state in NoteDb. ON will fail, because the old
-    //   NoteDb state has changed since the ref state was read when the update
-    //   began (prior to OR). This results in an exception from ON, but the end
-    //   result is still an up-to-date, read-only change. The end user that
-    //   initiated the other write observes an error, but this is no different
-    //   from other errors that need retrying, e.g. due to a backend write
-    //   failure.
-
-    Stopwatch sw = Stopwatch.createStarted();
-    Change readOnlyChange = setReadOnlyInReviewDb(id); // MRO
-    if (readOnlyChange == null) {
-      return; // Already migrated.
-    }
-
-    NoteDbChangeState rebuiltState;
-    try {
-      // MR,MN
-      rebuiltState =
-          ensureRebuiltRetryer(sw)
-              .call(
-                  () ->
-                      ensureRebuilt(
-                          readOnlyChange.getProject(),
-                          id,
-                          NoteDbChangeState.parse(readOnlyChange)));
-    } catch (RetryException | ExecutionException e) {
-      throw new OrmException(e);
-    }
-
-    // At this point, the noteDbState in ReviewDb is read-only, and it is
-    // guaranteed to match the state actually in NoteDb. Now it is safe to set
-    // the primary storage to NoteDb.
-
-    setPrimaryStorageNoteDb(id, rebuiltState);
-    logger.atFine().log(
-        "Migrated change %s to NoteDb primary in %sms", id, sw.elapsed(MILLISECONDS));
-  }
-
-  private Change setReadOnlyInReviewDb(Change.Id id) throws OrmException {
-    AtomicBoolean alreadyMigrated = new AtomicBoolean(false);
-    Change result =
-        db().changes()
-            .atomicUpdate(
-                id,
-                new AtomicUpdate<Change>() {
-                  @Override
-                  public Change update(Change change) {
-                    NoteDbChangeState state = NoteDbChangeState.parse(change);
-                    if (state == null) {
-                      // Could rebuild the change here, but that's more complexity, and this
-                      // normally shouldn't happen.
-                      //
-                      // Known cases where this happens are described in and handled by
-                      // NoteDbMigrator#canSkipPrimaryStorageMigration.
-                      throw new NoNoteDbStateException(id);
-                    }
-                    // If the change is already read-only, then the lease is held by another
-                    // (likely failed) migrator thread. Fail early, as we can't take over
-                    // the lease.
-                    NoteDbChangeState.checkNotReadOnly(change, skewMs);
-                    if (state.getPrimaryStorage() != PrimaryStorage.NOTE_DB) {
-                      Timestamp now = TimeUtil.nowTs();
-                      Timestamp until = new Timestamp(now.getTime() + timeoutMs);
-                      change.setNoteDbState(state.withReadOnlyUntil(until).toString());
-                    } else {
-                      alreadyMigrated.set(true);
-                    }
-                    return change;
-                  }
-                });
-    return alreadyMigrated.get() ? null : result;
-  }
-
-  private Retryer<NoteDbChangeState> ensureRebuiltRetryer(Stopwatch sw) {
-    if (testEnsureRebuiltRetryer != null) {
-      return testEnsureRebuiltRetryer;
-    }
-    // Retry the ensureRebuilt step with backoff until half the timeout has
-    // expired, leaving the remaining half for the rest of the steps.
-    long remainingNanos = (MILLISECONDS.toNanos(timeoutMs) / 2) - sw.elapsed(NANOSECONDS);
-    remainingNanos = Math.max(remainingNanos, 0);
-    return RetryerBuilder.<NoteDbChangeState>newBuilder()
-        .retryIfException(e -> (e instanceof IOException) || (e instanceof OrmException))
-        .withWaitStrategy(
-            WaitStrategies.join(
-                WaitStrategies.exponentialWait(250, MILLISECONDS),
-                WaitStrategies.randomWait(50, MILLISECONDS)))
-        .withStopStrategy(StopStrategies.stopAfterDelay(remainingNanos, NANOSECONDS))
-        .build();
-  }
-
-  private NoteDbChangeState ensureRebuilt(
-      Project.NameKey project, Change.Id id, NoteDbChangeState readOnlyState)
-      throws IOException, OrmException, RepositoryNotFoundException {
-    try (Repository changeRepo = repoManager.openRepository(project);
-        Repository allUsersRepo = repoManager.openRepository(allUsers)) {
-      if (!readOnlyState.isUpToDate(new RepoRefCache(changeRepo), new RepoRefCache(allUsersRepo))) {
-        NoteDbUpdateManager.Result r = rebuilder.rebuildEvenIfReadOnly(db(), id);
-        checkState(
-            r.newState().getReadOnlyUntil().equals(readOnlyState.getReadOnlyUntil()),
-            "state after rebuilding has different read-only lease: %s != %s",
-            r.newState(),
-            readOnlyState);
-        readOnlyState = r.newState();
-      }
-    }
-    return readOnlyState;
-  }
-
-  private void setPrimaryStorageNoteDb(Change.Id id, NoteDbChangeState expectedState)
-      throws OrmException {
-    db().changes()
-        .atomicUpdate(
-            id,
-            new AtomicUpdate<Change>() {
-              @Override
-              public Change update(Change change) {
-                NoteDbChangeState state = NoteDbChangeState.parse(change);
-                if (!Objects.equals(state, expectedState)) {
-                  throw new OrmRuntimeException(badState(state, expectedState));
-                }
-                Timestamp until = state.getReadOnlyUntil().get();
-                if (TimeUtil.nowTs().after(until)) {
-                  throw new OrmRuntimeException(
-                      "read-only lease on change " + id + " expired at " + until);
-                }
-                change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-                return change;
-              }
-            });
-  }
-
-  private ReviewDb db() {
-    return ReviewDbUtil.unwrapDb(db.get());
-  }
-
-  private String badState(NoteDbChangeState actual, NoteDbChangeState expected) {
-    return "state changed unexpectedly: " + actual + " != " + expected;
-  }
-
-  public void migrateToReviewDbPrimary(Change.Id id, @Nullable Project.NameKey project)
-      throws OrmException, IOException {
-    // Migrating back to ReviewDb primary is much simpler than the original migration to NoteDb
-    // primary, because when NoteDb is primary, each write only goes to one storage location rather
-    // than both. We only need to consider whether a concurrent writer (OR) conflicts with the first
-    // setReadOnlyInNoteDb step (MR) in this method.
-    //
-    // If OR wins, then either:
-    // * MR will set read-only after OR is completed, which is not a concurrent write.
-    // * MR will fail to set read-only with a lock failure. The caller will have to retry, but the
-    //   change is not in a read-only state, so behavior is not degraded in the meantime.
-    //
-    // If MR wins, then either:
-    // * OR will fail with a read-only exception (via AbstractChangeNotes#apply).
-    // * OR will fail with a lock failure.
-    //
-    // In all of these scenarios, the change is read-only if and only if MR succeeds.
-    //
-    // There will be no concurrent writes to ReviewDb for this change until
-    // setPrimaryStorageReviewDb completes, because ReviewDb writes are not attempted when primary
-    // storage is NoteDb. After the primary storage changes back, it is possible for subsequent
-    // NoteDb writes to conflict with the releaseReadOnlyLeaseInNoteDb step, but at this point,
-    // since ReviewDb is primary, we are back to ignoring them.
-    Stopwatch sw = Stopwatch.createStarted();
-    if (project == null) {
-      project = getProject(id);
-    }
-    ObjectId newMetaId = setReadOnlyInNoteDb(project, id);
-    rebuilder.rebuildReviewDb(db(), project, id);
-    setPrimaryStorageReviewDb(id, newMetaId);
-    releaseReadOnlyLeaseInNoteDb(project, id);
-    logger.atFine().log(
-        "Migrated change %s to ReviewDb primary in %sms", id, sw.elapsed(MILLISECONDS));
-  }
-
-  private ObjectId setReadOnlyInNoteDb(Project.NameKey project, Change.Id id)
-      throws OrmException, IOException {
-    Timestamp now = TimeUtil.nowTs();
-    Timestamp until = new Timestamp(now.getTime() + timeoutMs);
-    ChangeUpdate update =
-        updateFactory.create(
-            changeNotesFactory.createChecked(db.get(), project, id), internalUserFactory.create());
-    update.setReadOnlyUntil(until);
-    return update.commit();
-  }
-
-  private void setPrimaryStorageReviewDb(Change.Id id, ObjectId newMetaId)
-      throws OrmException, IOException {
-    ImmutableMap.Builder<Account.Id, ObjectId> draftIds = ImmutableMap.builder();
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      for (Ref draftRef :
-          repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(id)).values()) {
-        Account.Id accountId = Account.Id.fromRef(draftRef.getName());
-        if (accountId != null) {
-          draftIds.put(accountId, draftRef.getObjectId().copy());
-        }
-      }
-    }
-    NoteDbChangeState newState =
-        new NoteDbChangeState(
-            id,
-            PrimaryStorage.REVIEW_DB,
-            Optional.of(RefState.create(newMetaId, draftIds.build())),
-            Optional.empty());
-    db().changes()
-        .atomicUpdate(
-            id,
-            new AtomicUpdate<Change>() {
-              @Override
-              public Change update(Change change) {
-                if (PrimaryStorage.of(change) != PrimaryStorage.NOTE_DB) {
-                  throw new OrmRuntimeException(
-                      "change " + id + " is not NoteDb primary: " + change.getNoteDbState());
-                }
-                change.setNoteDbState(newState.toString());
-                return change;
-              }
-            });
-  }
-
-  private void releaseReadOnlyLeaseInNoteDb(Project.NameKey project, Change.Id id)
-      throws OrmException {
-    // Use a BatchUpdate since ReviewDb is primary at this point, so it needs to reflect the update.
-    // (In practice retrying won't happen, since we aren't using fused updates at this point.)
-    try {
-      retryHelper.execute(
-          updateFactory -> {
-            try (BatchUpdate bu =
-                updateFactory.create(
-                    db.get(), project, internalUserFactory.create(), TimeUtil.nowTs())) {
-              bu.addOp(
-                  id,
-                  new BatchUpdateOp() {
-                    @Override
-                    public boolean updateChange(ChangeContext ctx) {
-                      ctx.getUpdate(ctx.getChange().currentPatchSetId())
-                          .setReadOnlyUntil(new Timestamp(0));
-                      return true;
-                    }
-                  });
-              bu.execute();
-              return null;
-            }
-          });
-    } catch (RestApiException | UpdateException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  private Project.NameKey getProject(Change.Id id) throws OrmException {
-    List<ChangeData> cds =
-        queryProvider.get().setRequestedFields(ChangeField.PROJECT).byLegacyChangeId(id);
-    Set<Project.NameKey> projects = new TreeSet<>();
-    for (ChangeData cd : cds) {
-      projects.add(cd.project());
-    }
-    if (projects.size() != 1) {
-      throw new OrmException(
-          "zero or multiple projects found for change "
-              + id
-              + ", must specify project explicitly: "
-              + projects);
-    }
-    return projects.iterator().next();
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 47fec59..4d96565 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.github.rholder.retry.RetryException;
@@ -27,33 +27,28 @@
 import com.github.rholder.retry.StopStrategies;
 import com.github.rholder.retry.WaitStrategies;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Predicates;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import com.google.common.primitives.Ints;
+import com.google.common.collect.Iterables;
 import com.google.common.util.concurrent.Runnables;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.Callable;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -71,13 +66,16 @@
 public class RepoSequence {
   @FunctionalInterface
   public interface Seed {
-    int get() throws OrmException;
+    int get();
   }
 
   @VisibleForTesting
-  static RetryerBuilder<RefUpdate.Result> retryerBuilder() {
-    return RetryerBuilder.<RefUpdate.Result>newBuilder()
-        .retryIfResult(Predicates.equalTo(RefUpdate.Result.LOCK_FAILURE))
+  static RetryerBuilder<ImmutableList<Integer>> retryerBuilder() {
+    return RetryerBuilder.<ImmutableList<Integer>>newBuilder()
+        .retryIfException(
+            t ->
+                t instanceof StorageException
+                    && ((StorageException) t).getCause() instanceof LockFailureException)
         .withWaitStrategy(
             WaitStrategies.join(
                 WaitStrategies.exponentialWait(5, TimeUnit.SECONDS),
@@ -85,7 +83,7 @@
         .withStopStrategy(StopStrategies.stopAfterDelay(30, TimeUnit.SECONDS));
   }
 
-  private static final Retryer<RefUpdate.Result> RETRYER = retryerBuilder().build();
+  private static final Retryer<ImmutableList<Integer>> RETRYER = retryerBuilder().build();
 
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated gitRefUpdated;
@@ -95,7 +93,7 @@
   private final int floor;
   private final int batchSize;
   private final Runnable afterReadRef;
-  private final Retryer<RefUpdate.Result> retryer;
+  private final Retryer<ImmutableList<Integer>> retryer;
 
   // Protects all non-final fields.
   private final Lock counterLock;
@@ -153,7 +151,7 @@
       Seed seed,
       int batchSize,
       Runnable afterReadRef,
-      Retryer<RefUpdate.Result> retryer) {
+      Retryer<ImmutableList<Integer>> retryer) {
     this(repoManager, gitRefUpdated, projectName, name, seed, batchSize, afterReadRef, retryer, 0);
   }
 
@@ -165,11 +163,11 @@
       Seed seed,
       int batchSize,
       Runnable afterReadRef,
-      Retryer<RefUpdate.Result> retryer,
+      Retryer<ImmutableList<Integer>> retryer,
       int floor) {
-    this.repoManager = checkNotNull(repoManager, "repoManager");
-    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
-    this.projectName = checkNotNull(projectName, "projectName");
+    this.repoManager = requireNonNull(repoManager, "repoManager");
+    this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
+    this.projectName = requireNonNull(projectName, "projectName");
 
     checkArgument(
         name != null
@@ -179,216 +177,115 @@
         name);
     this.refName = RefNames.REFS_SEQUENCES + name;
 
-    this.seed = checkNotNull(seed, "seed");
+    this.seed = requireNonNull(seed, "seed");
     this.floor = floor;
 
     checkArgument(batchSize > 0, "expected batchSize > 0, got: %s", batchSize);
     this.batchSize = batchSize;
-    this.afterReadRef = checkNotNull(afterReadRef, "afterReadRef");
-    this.retryer = checkNotNull(retryer, "retryer");
+    this.afterReadRef = requireNonNull(afterReadRef, "afterReadRef");
+    this.retryer = requireNonNull(retryer, "retryer");
 
     counterLock = new ReentrantLock(true);
   }
 
-  public int next() throws OrmException {
-    counterLock.lock();
-    try {
-      if (counter >= limit) {
-        acquire(batchSize);
-      }
-      return counter++;
-    } finally {
-      counterLock.unlock();
-    }
+  /**
+   * Retrieves the next available sequence number.
+   *
+   * <p>This method is thread-safe.
+   *
+   * @return the next available sequence number
+   */
+  public int next() {
+    return Iterables.getOnlyElement(next(1));
   }
 
-  public ImmutableList<Integer> next(int count) throws OrmException {
+  /**
+   * Retrieves the next N available sequence number.
+   *
+   * <p>This method is thread-safe.
+   *
+   * @param count the number of sequence numbers which should be returned
+   * @return the next N available sequence numbers
+   */
+  public ImmutableList<Integer> next(int count) {
     if (count == 0) {
       return ImmutableList.of();
     }
     checkArgument(count > 0, "count is negative: %s", count);
-    counterLock.lock();
-    try {
-      List<Integer> ids = new ArrayList<>(count);
-      while (counter < limit) {
-        ids.add(counter++);
-        if (ids.size() == count) {
-          return ImmutableList.copyOf(ids);
-        }
-      }
-      acquire(Math.max(count - ids.size(), batchSize));
-      while (ids.size() < count) {
-        ids.add(counter++);
-      }
-      return ImmutableList.copyOf(ids);
-    } finally {
-      counterLock.unlock();
-    }
-  }
 
-  @VisibleForTesting
-  public void set(int val) throws OrmException {
-    // Don't bother spinning. This is only for tests, and a test that calls set
-    // concurrently with other writes is doing it wrong.
-    counterLock.lock();
     try {
-      try (Repository repo = repoManager.openRepository(projectName);
-          RevWalk rw = new RevWalk(repo)) {
-        checkResult(store(repo, rw, null, val));
-        counter = limit;
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    } finally {
-      counterLock.unlock();
-    }
-  }
+      return retryer.call(
+          () -> {
+            counterLock.lock();
+            try {
+              if (count == 1) {
+                if (counter >= limit) {
+                  acquire(batchSize);
+                }
+                return ImmutableList.of(counter++);
+              }
 
-  public void increaseTo(int val) throws OrmException {
-    counterLock.lock();
-    try {
-      try (Repository repo = repoManager.openRepository(projectName);
-          RevWalk rw = new RevWalk(repo)) {
-        TryIncreaseTo attempt = new TryIncreaseTo(repo, rw, val);
-        checkResult(retryer.call(attempt));
-        counter = limit;
-      } catch (ExecutionException | RetryException e) {
-        if (e.getCause() != null) {
-          Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
-        }
-        throw new OrmException(e);
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    } finally {
-      counterLock.unlock();
-    }
-  }
-
-  private void acquire(int count) throws OrmException {
-    try (Repository repo = repoManager.openRepository(projectName);
-        RevWalk rw = new RevWalk(repo)) {
-      TryAcquire attempt = new TryAcquire(repo, rw, count);
-      checkResult(retryer.call(attempt));
-      counter = attempt.next;
-      limit = counter + count;
-      acquireCount++;
+              List<Integer> ids = new ArrayList<>(count);
+              while (counter < limit) {
+                ids.add(counter++);
+                if (ids.size() == count) {
+                  return ImmutableList.copyOf(ids);
+                }
+              }
+              acquire(Math.max(count - ids.size(), batchSize));
+              while (ids.size() < count) {
+                ids.add(counter++);
+              }
+              return ImmutableList.copyOf(ids);
+            } finally {
+              counterLock.unlock();
+            }
+          });
     } catch (ExecutionException | RetryException e) {
       if (e.getCause() != null) {
-        Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), StorageException.class);
       }
-      throw new OrmException(e);
-    } catch (IOException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
-  private void checkResult(RefUpdate.Result result) throws OrmException {
-    if (!refUpdated(result) && result != Result.NO_CHANGE) {
-      throw new OrmException("failed to update " + refName + ": " + result);
-    }
-  }
-
-  private boolean refUpdated(RefUpdate.Result result) {
-    return result == RefUpdate.Result.NEW || result == RefUpdate.Result.FORCED;
-  }
-
-  private class TryAcquire implements Callable<RefUpdate.Result> {
-    private final Repository repo;
-    private final RevWalk rw;
-    private final int count;
-
-    private int next;
-
-    private TryAcquire(Repository repo, RevWalk rw, int count) {
-      this.repo = repo;
-      this.rw = rw;
-      this.count = count;
-    }
-
-    @Override
-    public RefUpdate.Result call() throws Exception {
-      Ref ref = repo.exactRef(refName);
+  /**
+   * Updates the next available sequence number in NoteDb in order to have a batch of sequence
+   * numbers available that can be handed out. {@link #counter} stores the next sequence number that
+   * can be handed out. When {@link #limit} is reached a new batch of sequence numbers needs to be
+   * retrieved by calling this method.
+   *
+   * <p><strong>Note:</strong> Callers are required to acquire the {@link #counterLock} before
+   * calling this method.
+   *
+   * @param count the number of sequence numbers which should be retrieved
+   */
+  private void acquire(int count) {
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
+      Optional<IntBlob> blob = IntBlob.parse(repo, refName, rw);
       afterReadRef.run();
       ObjectId oldId;
-      if (ref == null) {
+      int next;
+      if (!blob.isPresent()) {
         oldId = ObjectId.zeroId();
         next = seed.get();
       } else {
-        oldId = ref.getObjectId();
-        next = parse(rw, oldId);
+        oldId = blob.get().id();
+        next = blob.get().value();
       }
       next = Math.max(floor, next);
-      return store(repo, rw, oldId, next + count);
+      RefUpdate refUpdate =
+          IntBlob.tryStore(repo, rw, projectName, refName, oldId, next + count, gitRefUpdated);
+      RefUpdateUtil.checkResult(refUpdate);
+      counter = next;
+      limit = counter + count;
+      acquireCount++;
+    } catch (IOException e) {
+      throw new StorageException(e);
     }
   }
 
-  private class TryIncreaseTo implements Callable<RefUpdate.Result> {
-    private final Repository repo;
-    private final RevWalk rw;
-    private final int value;
-
-    private TryIncreaseTo(Repository repo, RevWalk rw, int value) {
-      this.repo = repo;
-      this.rw = rw;
-      this.value = value;
-    }
-
-    @Override
-    public RefUpdate.Result call() throws Exception {
-      Ref ref = repo.exactRef(refName);
-      afterReadRef.run();
-      ObjectId oldId;
-      if (ref == null) {
-        oldId = ObjectId.zeroId();
-      } else {
-        oldId = ref.getObjectId();
-        int next = parse(rw, oldId);
-        if (next >= value) {
-          // a concurrent write updated the ref already to this or a higher value
-          return RefUpdate.Result.NO_CHANGE;
-        }
-      }
-      return store(repo, rw, oldId, value);
-    }
-  }
-
-  private int parse(RevWalk rw, ObjectId id) throws IOException, OrmException {
-    ObjectLoader ol = rw.getObjectReader().open(id, OBJ_BLOB);
-    if (ol.getType() != OBJ_BLOB) {
-      // In theory this should be thrown by open but not all implementations
-      // may do it properly (certainly InMemoryRepository doesn't).
-      throw new IncorrectObjectTypeException(id, OBJ_BLOB);
-    }
-    String str = CharMatcher.whitespace().trimFrom(new String(ol.getCachedBytes(), UTF_8));
-    Integer val = Ints.tryParse(str);
-    if (val == null) {
-      throw new OrmException("invalid value in " + refName + " blob at " + id.name());
-    }
-    return val;
-  }
-
-  private RefUpdate.Result store(Repository repo, RevWalk rw, @Nullable ObjectId oldId, int val)
-      throws IOException {
-    ObjectId newId;
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
-      ins.flush();
-    }
-    RefUpdate ru = repo.updateRef(refName);
-    if (oldId != null) {
-      ru.setExpectedOldObjectId(oldId);
-    }
-    ru.disableRefLog();
-    ru.setNewObjectId(newId);
-    ru.setForceUpdate(true); // Required for non-commitish updates.
-    RefUpdate.Result result = ru.update(rw);
-    if (refUpdated(result)) {
-      gitRefUpdated.fire(projectName, ru, null);
-    }
-    return result;
-  }
-
   public static ReceiveCommand storeNew(ObjectInserter ins, String name, int val)
       throws IOException {
     ObjectId newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
diff --git a/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
index fad9832..d5a7259 100644
--- a/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
+++ b/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -21,13 +21,13 @@
 /** State of a reviewer on a change. */
 public enum ReviewerStateInternal {
   /** The user has contributed at least one nonzero vote on the change. */
-  REVIEWER(new FooterKey("Reviewer"), ReviewerState.REVIEWER),
+  REVIEWER("Reviewer", ReviewerState.REVIEWER),
 
   /** The reviewer was added to the change, but has not voted. */
-  CC(new FooterKey("CC"), ReviewerState.CC),
+  CC("CC", ReviewerState.CC),
 
   /** The user was previously a reviewer on the change, but was removed. */
-  REMOVED(new FooterKey("Removed"), ReviewerState.REMOVED);
+  REMOVED("Removed", ReviewerState.REMOVED);
 
   public static ReviewerStateInternal fromReviewerState(ReviewerState state) {
     return ReviewerStateInternal.values()[state.ordinal()];
@@ -50,20 +50,20 @@
     }
   }
 
-  private final FooterKey footerKey;
+  private final String footer;
   private final ReviewerState state;
 
-  ReviewerStateInternal(FooterKey footerKey, ReviewerState state) {
-    this.footerKey = footerKey;
+  ReviewerStateInternal(String footer, ReviewerState state) {
+    this.footer = footer;
     this.state = state;
   }
 
   FooterKey getFooterKey() {
-    return footerKey;
+    return new FooterKey(footer);
   }
 
   FooterKey getByEmailFooterKey() {
-    return new FooterKey(footerKey.getName() + "-email");
+    return new FooterKey(footer + "-email");
   }
 
   public ReviewerState asReviewerState() {
diff --git a/java/com/google/gerrit/server/notedb/RevisionNote.java b/java/com/google/gerrit/server/notedb/RevisionNote.java
index deec7e9..ff649a9 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNote.java
@@ -18,7 +18,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.common.UsedAt;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -26,7 +26,8 @@
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.util.MutableInteger;
 
-abstract class RevisionNote<T extends Comment> {
+@UsedAt(UsedAt.Project.PLUGIN_CHECKS)
+public abstract class RevisionNote<T> {
   static final int MAX_NOTE_SZ = 25 << 20;
 
   protected static void trimLeadingEmptyLines(byte[] bytes, MutableInteger p) {
@@ -39,9 +40,9 @@
   private final ObjectId noteId;
 
   private byte[] raw;
-  private ImmutableList<T> comments;
+  private ImmutableList<T> entities;
 
-  RevisionNote(ObjectReader reader, ObjectId noteId) {
+  public RevisionNote(ObjectReader reader, ObjectId noteId) {
     this.reader = reader;
     this.noteId = noteId;
   }
@@ -51,9 +52,16 @@
     return raw;
   }
 
-  public ImmutableList<T> getComments() {
+  @UsedAt(UsedAt.Project.PLUGIN_CHECKS)
+  public T getOnlyEntity() {
     checkParsed();
-    return comments;
+    checkState(entities.size() == 1, "expected exactly one entity");
+    return entities.get(0);
+  }
+
+  public ImmutableList<T> getEntities() {
+    checkParsed();
+    return entities;
   }
 
   public void parse() throws IOException, ConfigInvalidException {
@@ -61,11 +69,11 @@
     MutableInteger p = new MutableInteger();
     trimLeadingEmptyLines(raw, p);
     if (p.value >= raw.length) {
-      comments = ImmutableList.of();
+      entities = ImmutableList.of();
       return;
     }
 
-    comments = ImmutableList.copyOf(parse(raw, p.value));
+    entities = ImmutableList.copyOf(parse(raw, p.value));
   }
 
   protected abstract List<T> parse(byte[] raw, int offset)
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 8bf286d..b0364e0 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.RevId;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -33,27 +32,29 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 class RevisionNoteBuilder {
   static class Cache {
     private final RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap;
-    private final Map<RevId, RevisionNoteBuilder> builders;
+    private final Map<ObjectId, RevisionNoteBuilder> builders;
 
     Cache(RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap) {
       this.revisionNoteMap = revisionNoteMap;
       this.builders = new HashMap<>();
     }
 
-    RevisionNoteBuilder get(RevId revId) {
-      RevisionNoteBuilder b = builders.get(revId);
+    RevisionNoteBuilder get(AnyObjectId commitId) {
+      RevisionNoteBuilder b = builders.get(commitId);
       if (b == null) {
-        b = new RevisionNoteBuilder(revisionNoteMap.revisionNotes.get(revId));
-        builders.put(revId, b);
+        b = new RevisionNoteBuilder(revisionNoteMap.revisionNotes.get(commitId));
+        builders.put(commitId.copy(), b);
       }
       return b;
     }
 
-    Map<RevId, RevisionNoteBuilder> getBuilders() {
+    Map<ObjectId, RevisionNoteBuilder> getBuilders() {
       return Collections.unmodifiableMap(builders);
     }
   }
@@ -68,7 +69,7 @@
   RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
     if (base != null) {
       baseRaw = base.getRaw();
-      baseComments = base.getComments();
+      baseComments = base.getEntities();
       put = Maps.newHashMapWithExpectedSize(baseComments.size());
       if (base instanceof ChangeRevisionNote) {
         pushCert = ((ChangeRevisionNote) base).getPushCert();
@@ -82,19 +83,13 @@
     delete = new HashSet<>();
   }
 
-  public byte[] build(ChangeNoteUtil noteUtil, boolean writeJson) throws IOException {
-    return build(noteUtil.getChangeNoteJson(), noteUtil.getLegacyChangeNoteWrite(), writeJson);
+  public byte[] build(ChangeNoteUtil noteUtil) throws IOException {
+    return build(noteUtil.getChangeNoteJson());
   }
 
-  public byte[] build(
-      ChangeNoteJson changeNoteJson, LegacyChangeNoteWrite legacyChangeNoteWrite, boolean writeJson)
-      throws IOException {
+  public byte[] build(ChangeNoteJson changeNoteJson) throws IOException {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
-    if (writeJson) {
-      buildNoteJson(changeNoteJson, out);
-    } else {
-      buildNoteLegacy(legacyChangeNoteWrite, out);
-    }
+    buildNoteJson(changeNoteJson, out);
     return out.toByteArray();
   }
 
@@ -142,22 +137,4 @@
       noteUtil.getGson().toJson(data, osw);
     }
   }
-
-  private void buildNoteLegacy(LegacyChangeNoteWrite noteUtil, OutputStream out)
-      throws IOException {
-    if (pushCert != null) {
-      byte[] certBytes = pushCert.getBytes(UTF_8);
-      out.write(certBytes, 0, trimTrailingNewlines(certBytes));
-      out.write('\n');
-    }
-    noteUtil.buildNote(buildCommentMap(), out);
-  }
-
-  private static int trimTrailingNewlines(byte[] bytes) {
-    int p = bytes.length;
-    while (p > 1 && bytes[p - 1] == '\n') {
-      p--;
-    }
-    return p;
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 17a061a..03f912b 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -18,18 +18,18 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.RevId;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 
 class RevisionNoteMap<T extends RevisionNote<? extends Comment>> {
   final NoteMap noteMap;
-  final ImmutableMap<RevId, T> revisionNotes;
+  final ImmutableMap<ObjectId, T> revisionNotes;
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
       ChangeNoteJson noteJson,
@@ -39,13 +39,13 @@
       NoteMap noteMap,
       PatchLineComment.Status status)
       throws ConfigInvalidException, IOException {
-    Map<RevId, ChangeRevisionNote> result = new HashMap<>();
+    Map<ObjectId, ChangeRevisionNote> result = new HashMap<>();
     for (Note note : noteMap) {
       ChangeRevisionNote rn =
           new ChangeRevisionNote(
               noteJson, legacyChangeNoteRead, changeId, reader, note.getData(), status);
       rn.parse();
-      result.put(new RevId(note.name()), rn);
+      result.put(note.copy(), rn);
     }
     return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
@@ -53,21 +53,21 @@
   static RevisionNoteMap<RobotCommentsRevisionNote> parseRobotComments(
       ChangeNoteJson changeNoteJson, ObjectReader reader, NoteMap noteMap)
       throws ConfigInvalidException, IOException {
-    Map<RevId, RobotCommentsRevisionNote> result = new HashMap<>();
+    Map<ObjectId, RobotCommentsRevisionNote> result = new HashMap<>();
     for (Note note : noteMap) {
       RobotCommentsRevisionNote rn =
           new RobotCommentsRevisionNote(changeNoteJson, reader, note.getData());
       rn.parse();
-      result.put(new RevId(note.name()), rn);
+      result.put(note.copy(), rn);
     }
     return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
 
   static <T extends RevisionNote<? extends Comment>> RevisionNoteMap<T> emptyMap() {
-    return new RevisionNoteMap<>(NoteMap.newEmptyMap(), ImmutableMap.<RevId, T>of());
+    return new RevisionNoteMap<>(NoteMap.newEmptyMap(), ImmutableMap.of());
   }
 
-  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<RevId, T> revisionNotes) {
+  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<ObjectId, T> revisionNotes) {
     this.noteMap = noteMap;
     this.revisionNotes = revisionNotes;
   }
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index 7eb3a54..e863652 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -17,13 +17,12 @@
 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;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -34,19 +33,21 @@
 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);
   }
 
   private final Change change;
 
-  private ImmutableListMultimap<RevId, RobotComment> comments;
+  private ImmutableListMultimap<ObjectId, RobotComment> comments;
   private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
   private ObjectId metaId;
 
   @Inject
   RobotCommentNotes(Args args, @Assisted Change change) {
-    super(args, change.getId(), PrimaryStorage.of(change), false);
+    super(args, change.getId());
     this.change = change;
   }
 
@@ -54,7 +55,7 @@
     return revisionNoteMap;
   }
 
-  public ImmutableListMultimap<RevId, RobotComment> getComments() {
+  public ImmutableListMultimap<ObjectId, RobotComment> getComments() {
     return comments;
   }
 
@@ -86,15 +87,17 @@
     }
     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 =
         RevisionNoteMap.parseRobotComments(
             args.changeNoteJson, reader, NoteMap.read(reader, tipCommit));
-    ListMultimap<RevId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ListMultimap<ObjectId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
     for (RobotCommentsRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
-      for (RobotComment c : rn.getComments()) {
-        cs.put(new RevId(c.revId), c);
+      for (RobotComment c : rn.getEntities()) {
+        cs.put(c.getCommitId(), c);
       }
     }
     comments = ImmutableListMultimap.copyOf(cs);
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index 3a0d595..a31f511 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -19,14 +19,12 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -38,7 +36,6 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -74,50 +71,26 @@
 
   @AssistedInject
   private RobotCommentUpdate(
-      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
-      NotesMigration migration,
       ChangeNoteUtil noteUtil,
       @Assisted ChangeNotes notes,
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        notes,
-        null,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
+    super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
   }
 
   @AssistedInject
   private RobotCommentUpdate(
-      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
-      NotesMigration migration,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        null,
-        change,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
+    super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
   }
 
   public void putComment(RobotComment c) {
@@ -127,22 +100,22 @@
 
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
-      throws ConfigInvalidException, OrmException, IOException {
+      throws ConfigInvalidException, IOException {
     RevisionNoteMap<RobotCommentsRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    Set<ObjectId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
     for (RobotComment c : put) {
-      cache.get(new RevId(c.revId)).putComment(c);
+      cache.get(c.getCommitId()).putComment(c);
     }
 
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     boolean touchedAnyRevs = false;
     boolean hasComments = false;
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      updatedRevs.add(e.getKey());
-      ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data = e.getValue().build(noteUtil, true);
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
+      ObjectId id = e.getKey();
+      updatedRevs.add(id);
+      byte[] data = e.getValue().build(noteUtil);
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
       }
@@ -174,23 +147,20 @@
   }
 
   private RevisionNoteMap<RobotCommentsRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
+      throws ConfigInvalidException, IOException {
     if (curr.equals(ObjectId.zeroId())) {
       return RevisionNoteMap.emptyMap();
     }
-    if (migration.readChanges()) {
-      // If reading from changes is enabled, then the old RobotCommentNotes
-      // already parsed the revision notes. We can reuse them as long as the ref
-      // hasn't advanced.
-      ChangeNotes changeNotes = getNotes();
-      if (changeNotes != null) {
-        RobotCommentNotes robotCommentNotes = changeNotes.load().getRobotCommentNotes();
-        if (robotCommentNotes != null) {
-          ObjectId idFromNotes = firstNonNull(robotCommentNotes.getRevision(), ObjectId.zeroId());
-          RevisionNoteMap<RobotCommentsRevisionNote> rnm = robotCommentNotes.getRevisionNoteMap();
-          if (idFromNotes.equals(curr) && rnm != null) {
-            return rnm;
-          }
+    // The old RobotCommentNotes already parsed the revision notes. We can reuse them as long as
+    // the ref hasn't advanced.
+    ChangeNotes changeNotes = getNotes();
+    if (changeNotes != null) {
+      RobotCommentNotes robotCommentNotes = changeNotes.load().getRobotCommentNotes();
+      if (robotCommentNotes != null) {
+        ObjectId idFromNotes = firstNonNull(robotCommentNotes.getRevision(), ObjectId.zeroId());
+        RevisionNoteMap<RobotCommentsRevisionNote> rnm = robotCommentNotes.getRevisionNoteMap();
+        if (idFromNotes.equals(curr) && rnm != null) {
+          return rnm;
         }
       }
     }
@@ -208,13 +178,13 @@
 
   @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException {
+      throws IOException {
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage("Update robot comments");
     try {
       return storeCommentsInNotes(rw, ins, curr, cb);
     } catch (ConfigInvalidException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/Sequences.java b/java/com/google/gerrit/server/notedb/Sequences.java
new file mode 100644
index 0000000..73cc600
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/Sequences.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer2;
+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.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class Sequences {
+  public static final String NAME_ACCOUNTS = "accounts";
+  public static final String NAME_GROUPS = "groups";
+  public static final String NAME_CHANGES = "changes";
+
+  public static final int FIRST_ACCOUNT_ID = 1000000;
+  public static final int FIRST_GROUP_ID = 1;
+  public static final int FIRST_CHANGE_ID = 1;
+
+  private enum SequenceType {
+    ACCOUNTS,
+    CHANGES,
+    GROUPS;
+  }
+
+  private final RepoSequence accountSeq;
+  private final RepoSequence changeSeq;
+  private final RepoSequence groupSeq;
+  private final Timer2<SequenceType, Boolean> nextIdLatency;
+
+  @Inject
+  public Sequences(
+      @GerritServerConfig Config cfg,
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      AllProjectsName allProjects,
+      AllUsersName allUsers,
+      MetricMaker metrics) {
+
+    int accountBatchSize = cfg.getInt("noteDb", "accounts", "sequenceBatchSize", 1);
+    accountSeq =
+        new RepoSequence(
+            repoManager,
+            gitRefUpdated,
+            allUsers,
+            NAME_ACCOUNTS,
+            () -> FIRST_ACCOUNT_ID,
+            accountBatchSize);
+
+    int changeBatchSize = cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20);
+    changeSeq =
+        new RepoSequence(
+            repoManager,
+            gitRefUpdated,
+            allProjects,
+            NAME_CHANGES,
+            () -> FIRST_CHANGE_ID,
+            changeBatchSize);
+
+    int groupBatchSize = 1;
+    groupSeq =
+        new RepoSequence(
+            repoManager,
+            gitRefUpdated,
+            allUsers,
+            NAME_GROUPS,
+            () -> FIRST_GROUP_ID,
+            groupBatchSize);
+
+    nextIdLatency =
+        metrics.newTimer(
+            "sequence/next_id_latency",
+            new Description("Latency of requesting IDs from repo sequences")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            Field.ofEnum(SequenceType.class, "sequence", Metadata.Builder::noteDbSequenceType)
+                .build(),
+            Field.ofBoolean("multiple", Metadata.Builder::multiple).build());
+  }
+
+  public int nextAccountId() {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(SequenceType.ACCOUNTS, false)) {
+      return accountSeq.next();
+    }
+  }
+
+  public int nextChangeId() {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(SequenceType.CHANGES, false)) {
+      return changeSeq.next();
+    }
+  }
+
+  public ImmutableList<Integer> nextChangeIds(int count) {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
+      return changeSeq.next(count);
+    }
+  }
+
+  public int nextGroupId() {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(SequenceType.GROUPS, false)) {
+      return groupSeq.next();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java b/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
deleted file mode 100644
index 11fef24..0000000
--- a/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Id;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-@VisibleForTesting
-@Singleton
-public class TestChangeRebuilderWrapper extends ChangeRebuilder {
-  private final ChangeRebuilderImpl delegate;
-  private final AtomicBoolean failNextUpdate;
-  private final AtomicBoolean stealNextUpdate;
-
-  @Inject
-  TestChangeRebuilderWrapper(SchemaFactory<ReviewDb> schemaFactory, ChangeRebuilderImpl rebuilder) {
-    super(schemaFactory);
-    this.delegate = rebuilder;
-    this.failNextUpdate = new AtomicBoolean();
-    this.stealNextUpdate = new AtomicBoolean();
-  }
-
-  public void failNextUpdate() {
-    failNextUpdate.set(true);
-  }
-
-  public void stealNextUpdate() {
-    stealNextUpdate.set(true);
-  }
-
-  @Override
-  public Result rebuild(ReviewDb db, Change.Id changeId) throws IOException, OrmException {
-    return rebuild(db, changeId, true);
-  }
-
-  @Override
-  public Result rebuildEvenIfReadOnly(ReviewDb db, Change.Id changeId)
-      throws IOException, OrmException {
-    return rebuild(db, changeId, false);
-  }
-
-  private Result rebuild(ReviewDb db, Change.Id changeId, boolean checkReadOnly)
-      throws IOException, OrmException {
-    if (failNextUpdate.getAndSet(false)) {
-      throw new IOException("Update failed");
-    }
-    Result result =
-        checkReadOnly
-            ? delegate.rebuild(db, changeId)
-            : delegate.rebuildEvenIfReadOnly(db, changeId);
-    if (stealNextUpdate.getAndSet(false)) {
-      throw new IOException("Update stolen");
-    }
-    return result;
-  }
-
-  @Override
-  public Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws IOException, OrmException {
-    // stealNextUpdate doesn't really apply in this case because the IOException
-    // would normally come from the manager.execute() method, which isn't called
-    // here.
-    return delegate.rebuild(manager, bundle);
-  }
-
-  @Override
-  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
-      throws IOException, OrmException {
-    // Don't inspect stealNextUpdate; that happens in execute() below.
-    return delegate.stage(db, changeId);
-  }
-
-  @Override
-  public Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
-      throws OrmException, IOException {
-    if (failNextUpdate.getAndSet(false)) {
-      throw new IOException("Update failed");
-    }
-    Result result = delegate.execute(db, changeId, manager);
-    if (stealNextUpdate.getAndSet(false)) {
-      throw new IOException("Update stolen");
-    }
-    return result;
-  }
-
-  @Override
-  public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws IOException, OrmException {
-    // Don't check for manual failure; that happens in execute().
-    delegate.buildUpdates(manager, bundle);
-  }
-
-  @Override
-  public void rebuildReviewDb(ReviewDb db, Project.NameKey project, Id changeId)
-      throws OrmException {
-    if (failNextUpdate.getAndSet(false)) {
-      throw new OrmException("Update failed");
-    }
-    delegate.rebuildReviewDb(db, project, changeId);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java b/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java
new file mode 100644
index 0000000..421e8c4
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.reviewdb.client.Change;
+
+/**
+ * Exception indicating that the change has received too many updates. Further actions apart from
+ * {@code abandon} or {@code submit} are blocked.
+ */
+public class TooManyUpdatesException extends StorageException {
+  @VisibleForTesting
+  public static String message(Change.Id id, int maxUpdates) {
+    return "Change "
+        + id
+        + " may not exceed "
+        + maxUpdates
+        + " updates. It may still be abandoned or submitted. To continue working on this "
+        + "change, recreate it with a new Change-Id, then abandon this one.";
+  }
+
+  private static final long serialVersionUID = 1L;
+
+  TooManyUpdatesException(Change.Id id, int maxUpdates) {
+    super(message(id, maxUpdates));
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java b/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
deleted file mode 100644
index 0e6d3e9..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import com.google.gwtorm.server.OrmRuntimeException;
-
-class AbortUpdateException extends OrmRuntimeException {
-  private static final long serialVersionUID = 1L;
-
-  AbortUpdateException() {
-    super("aborted");
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java b/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
deleted file mode 100644
index 9ecf476..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import com.google.common.base.MoreObjects.ToStringHelper;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import java.sql.Timestamp;
-
-class ApprovalEvent extends Event {
-  private PatchSetApproval psa;
-
-  ApprovalEvent(PatchSetApproval psa, Timestamp changeCreatedOn) {
-    super(
-        psa.getPatchSetId(),
-        psa.getAccountId(),
-        psa.getRealAccountId(),
-        psa.getGranted(),
-        changeCreatedOn,
-        psa.getTag());
-    this.psa = psa;
-  }
-
-  @Override
-  boolean uniquePerUpdate() {
-    return false;
-  }
-
-  @Override
-  protected boolean canHaveTag() {
-    // Legacy SUBM approvals don't have a tag field set, but the corresponding
-    // ChangeMessage for merging the change does. We need to let these be in the
-    // same meta commit so the SUBM approval isn't counted as post-submit.
-    return !psa.isLegacySubmit();
-  }
-
-  @Override
-  void apply(ChangeUpdate update) {
-    checkUpdate(update);
-    update.putApproval(psa.getLabel(), psa.getValue());
-  }
-
-  @Override
-  protected boolean isPostSubmitApproval() {
-    return psa.isPostSubmit();
-  }
-
-  @Override
-  protected void addToString(ToStringHelper helper) {
-    helper.add("approval", psa);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
deleted file mode 100644
index 53c9dc4..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
+++ /dev/null
@@ -1,185 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import com.google.common.base.MoreObjects.ToStringHelper;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gwtorm.server.OrmException;
-import java.sql.Timestamp;
-import java.util.Map;
-import java.util.Optional;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-class ChangeMessageEvent extends Event {
-  private static final ImmutableMap<Change.Status, Pattern> STATUS_PATTERNS =
-      ImmutableMap.of(
-          Change.Status.ABANDONED, Pattern.compile("^Abandoned(\n.*)*$"),
-          Change.Status.MERGED,
-              Pattern.compile(
-                  "^Change has been successfully (merged|cherry-picked|rebased|pushed).*$"),
-          Change.Status.NEW, Pattern.compile("^Restored(\n.*)*$"));
-
-  private static final Pattern PRIVATE_SET_REGEXP = Pattern.compile("^Set private$");
-  private static final Pattern PRIVATE_UNSET_REGEXP = Pattern.compile("^Unset private$");
-
-  private static final Pattern TOPIC_SET_REGEXP = Pattern.compile("^Topic set to (.+)$");
-  private static final Pattern TOPIC_CHANGED_REGEXP =
-      Pattern.compile("^Topic changed from (.+) to (.+)$");
-  private static final Pattern TOPIC_REMOVED_REGEXP = Pattern.compile("^Topic (.+) removed$");
-
-  private static final Pattern WIP_SET_REGEXP = Pattern.compile("^Set Work In Progress$");
-  private static final Pattern WIP_UNSET_REGEXP = Pattern.compile("^Set Ready For Review$");
-
-  private final Change change;
-  private final Change noteDbChange;
-  private final Optional<Change.Status> status;
-  private final ChangeMessage message;
-
-  ChangeMessageEvent(
-      Change change, Change noteDbChange, ChangeMessage message, Timestamp changeCreatedOn) {
-    super(
-        message.getPatchSetId(),
-        message.getAuthor(),
-        message.getRealAuthor(),
-        message.getWrittenOn(),
-        changeCreatedOn,
-        message.getTag());
-    this.change = change;
-    this.noteDbChange = noteDbChange;
-    this.message = message;
-    this.status = parseStatus(message);
-  }
-
-  @Override
-  boolean uniquePerUpdate() {
-    return true;
-  }
-
-  @Override
-  protected boolean isSubmit() {
-    return status.isPresent() && status.get() == Change.Status.MERGED;
-  }
-
-  @Override
-  protected boolean canHaveTag() {
-    return true;
-  }
-
-  @SuppressWarnings("deprecation")
-  @Override
-  void apply(ChangeUpdate update) throws OrmException {
-    checkUpdate(update);
-    update.setChangeMessage(message.getMessage());
-    setPrivate(update);
-    setTopic(update);
-    setWorkInProgress(update);
-
-    if (status.isPresent()) {
-      Change.Status s = status.get();
-      update.fixStatus(s);
-      noteDbChange.setStatus(s);
-      if (s == Change.Status.MERGED) {
-        update.setSubmissionId(change.getSubmissionId());
-        noteDbChange.setSubmissionId(change.getSubmissionId());
-      }
-    }
-  }
-
-  private static Optional<Change.Status> parseStatus(ChangeMessage message) {
-    String msg = message.getMessage();
-    if (msg == null) {
-      return Optional.empty();
-    }
-    for (Map.Entry<Change.Status, Pattern> e : STATUS_PATTERNS.entrySet()) {
-      if (e.getValue().matcher(msg).matches()) {
-        return Optional.of(e.getKey());
-      }
-    }
-    return Optional.empty();
-  }
-
-  private void setPrivate(ChangeUpdate update) {
-    String msg = message.getMessage();
-    if (msg == null) {
-      return;
-    }
-    Matcher m = PRIVATE_SET_REGEXP.matcher(msg);
-    if (m.matches()) {
-      update.setPrivate(true);
-      noteDbChange.setPrivate(true);
-      return;
-    }
-
-    m = PRIVATE_UNSET_REGEXP.matcher(msg);
-    if (m.matches()) {
-      update.setPrivate(false);
-      noteDbChange.setPrivate(false);
-    }
-  }
-
-  private void setTopic(ChangeUpdate update) {
-    String msg = message.getMessage();
-    if (msg == null) {
-      return;
-    }
-    Matcher m = TOPIC_SET_REGEXP.matcher(msg);
-    if (m.matches()) {
-      String topic = m.group(1);
-      update.setTopic(topic);
-      noteDbChange.setTopic(topic);
-      return;
-    }
-
-    m = TOPIC_CHANGED_REGEXP.matcher(msg);
-    if (m.matches()) {
-      String topic = m.group(2);
-      update.setTopic(topic);
-      noteDbChange.setTopic(topic);
-      return;
-    }
-
-    if (TOPIC_REMOVED_REGEXP.matcher(msg).matches()) {
-      update.setTopic(null);
-      noteDbChange.setTopic(null);
-    }
-  }
-
-  private void setWorkInProgress(ChangeUpdate update) {
-    String msg = Strings.nullToEmpty(message.getMessage());
-    String tag = message.getTag();
-    if (ChangeMessagesUtil.TAG_SET_WIP.equals(tag)
-        || ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET.equals(tag)
-        || WIP_SET_REGEXP.matcher(msg).matches()) {
-      update.setWorkInProgress(true);
-      noteDbChange.setWorkInProgress(true);
-    } else if (ChangeMessagesUtil.TAG_SET_READY.equals(tag)
-        || ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET.equals(tag)
-        || WIP_UNSET_REGEXP.matcher(msg).matches()) {
-      update.setWorkInProgress(false);
-      noteDbChange.setWorkInProgress(false);
-    }
-  }
-
-  @Override
-  protected void addToString(ToStringHelper helper) {
-    helper.add("message", message);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
deleted file mode 100644
index 8ce9987..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import java.io.IOException;
-
-public abstract class ChangeRebuilder {
-  public static class NoPatchSetsException extends OrmException {
-    private static final long serialVersionUID = 1L;
-
-    NoPatchSetsException(Change.Id changeId) {
-      super("Change " + changeId + " cannot be rebuilt because it has no patch sets");
-    }
-  }
-
-  private final SchemaFactory<ReviewDb> schemaFactory;
-
-  protected ChangeRebuilder(SchemaFactory<ReviewDb> schemaFactory) {
-    this.schemaFactory = schemaFactory;
-  }
-
-  public final ListenableFuture<Result> rebuildAsync(
-      Change.Id id, ListeningExecutorService executor) {
-    return executor.submit(
-        () -> {
-          try (ReviewDb db = schemaFactory.open()) {
-            return rebuild(db, id);
-          }
-        });
-  }
-
-  /**
-   * Rebuild ReviewDb contents by copying from NoteDb.
-   *
-   * <p>Requires NoteDb to be the primary storage for the change.
-   */
-  public abstract void rebuildReviewDb(ReviewDb db, Project.NameKey project, Change.Id changeId)
-      throws OrmException;
-
-  // In the following methods "rebuilding" always refers to copying the state
-  // from ReviewDb to NoteDb, i.e. assuming ReviewDb is the primary storage.
-
-  public abstract Result rebuild(ReviewDb db, Change.Id changeId) throws IOException, OrmException;
-
-  public abstract Result rebuildEvenIfReadOnly(ReviewDb db, Change.Id changeId)
-      throws IOException, OrmException;
-
-  public abstract Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws IOException, OrmException;
-
-  public abstract void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws IOException, OrmException;
-
-  public abstract NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
-      throws IOException, OrmException;
-
-  public abstract Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
-      throws OrmException, IOException;
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
deleted file mode 100644
index 3a0bfc1..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ /dev/null
@@ -1,688 +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.notedb.rebuild;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Splitter;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableCollection;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Ordering;
-import com.google.common.collect.Sets;
-import com.google.common.collect.Table;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeDraftUpdate;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.OpenRepo;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.ChainedReceiveCommands;
-import com.google.gwtorm.client.Key;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.TreeMap;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-public class ChangeRebuilderImpl extends ChangeRebuilder {
-  /**
-   * The maximum amount of time between the ReviewDb timestamp of the first and last events batched
-   * together into a single NoteDb update.
-   *
-   * <p>Used to account for the fact that different records with their own timestamps (e.g. {@link
-   * PatchSetApproval} and {@link ChangeMessage}) historically didn't necessarily use the same
-   * timestamp, and tended to call {@code System.currentTimeMillis()} independently.
-   */
-  public static final long MAX_WINDOW_MS = SECONDS.toMillis(3);
-
-  /**
-   * The maximum amount of time between two consecutive events to consider them to be in the same
-   * batch.
-   */
-  static final long MAX_DELTA_MS = SECONDS.toMillis(1);
-
-  private final ChangeBundleReader bundleReader;
-  private final ChangeDraftUpdate.Factory draftUpdateFactory;
-  private final ChangeNoteUtil changeNoteUtil;
-  private final ChangeNotes.Factory notesFactory;
-  private final ChangeUpdate.Factory updateFactory;
-  private final CommentsUtil commentsUtil;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final NotesMigration migration;
-  private final PatchListCache patchListCache;
-  private final PersonIdent serverIdent;
-  private final ProjectCache projectCache;
-  private final String serverId;
-  private final long skewMs;
-
-  @Inject
-  ChangeRebuilderImpl(
-      @GerritServerConfig Config cfg,
-      SchemaFactory<ReviewDb> schemaFactory,
-      ChangeBundleReader bundleReader,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      ChangeNoteUtil changeNoteUtil,
-      ChangeNotes.Factory notesFactory,
-      ChangeUpdate.Factory updateFactory,
-      CommentsUtil commentsUtil,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      NotesMigration migration,
-      PatchListCache patchListCache,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Nullable ProjectCache projectCache,
-      @GerritServerId String serverId) {
-    super(schemaFactory);
-    this.bundleReader = bundleReader;
-    this.draftUpdateFactory = draftUpdateFactory;
-    this.changeNoteUtil = changeNoteUtil;
-    this.notesFactory = notesFactory;
-    this.updateFactory = updateFactory;
-    this.commentsUtil = commentsUtil;
-    this.updateManagerFactory = updateManagerFactory;
-    this.migration = migration;
-    this.patchListCache = patchListCache;
-    this.serverIdent = serverIdent;
-    this.projectCache = projectCache;
-    this.serverId = serverId;
-    this.skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
-  }
-
-  @Override
-  public Result rebuild(ReviewDb db, Change.Id changeId) throws IOException, OrmException {
-    return rebuild(db, changeId, true);
-  }
-
-  @Override
-  public Result rebuildEvenIfReadOnly(ReviewDb db, Change.Id changeId)
-      throws IOException, OrmException {
-    return rebuild(db, changeId, false);
-  }
-
-  private Result rebuild(ReviewDb db, Change.Id changeId, boolean checkReadOnly)
-      throws IOException, OrmException {
-    db = ReviewDbUtil.unwrapDb(db);
-    // Read change just to get project; this instance is then discarded so we can read a consistent
-    // ChangeBundle inside a transaction.
-    Change change = db.changes().get(changeId);
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-    try (NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject())) {
-      buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
-      return execute(db, changeId, manager, checkReadOnly, true);
-    }
-  }
-
-  @Override
-  public Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws NoSuchChangeException, IOException, OrmException {
-    Change change = new Change(bundle.getChange());
-    buildUpdates(manager, bundle);
-    return manager.stageAndApplyDelta(change);
-  }
-
-  @Override
-  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
-      throws IOException, OrmException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-    NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject());
-    buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
-    manager.stage();
-    return manager;
-  }
-
-  @Override
-  public Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
-      throws OrmException, IOException {
-    return execute(db, changeId, manager, true, true);
-  }
-
-  public Result execute(
-      ReviewDb db,
-      Change.Id changeId,
-      NoteDbUpdateManager manager,
-      boolean checkReadOnly,
-      boolean executeManager)
-      throws OrmException, IOException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    String oldNoteDbStateStr = change.getNoteDbState();
-    Result r = manager.stageAndApplyDelta(change);
-    String newNoteDbStateStr = change.getNoteDbState();
-    if (newNoteDbStateStr == null) {
-      throw new OrmException(
-          "Rebuilding change %s produced no writes to NoteDb: "
-              + bundleReader.fromReviewDb(db, changeId));
-    }
-    NoteDbChangeState newNoteDbState =
-        checkNotNull(NoteDbChangeState.parse(changeId, newNoteDbStateStr));
-    try {
-      db.changes()
-          .atomicUpdate(
-              changeId,
-              new AtomicUpdate<Change>() {
-                @Override
-                public Change update(Change change) {
-                  if (checkReadOnly) {
-                    NoteDbChangeState.checkNotReadOnly(change, skewMs);
-                  }
-                  String currNoteDbStateStr = change.getNoteDbState();
-                  if (Objects.equals(currNoteDbStateStr, newNoteDbStateStr)) {
-                    // Another thread completed the same rebuild we were about to.
-                    throw new AbortUpdateException();
-                  } else if (!Objects.equals(oldNoteDbStateStr, currNoteDbStateStr)) {
-                    // Another thread updated the state to something else.
-                    throw new ConflictingUpdateRuntimeException(change, oldNoteDbStateStr);
-                  }
-                  change.setNoteDbState(newNoteDbStateStr);
-                  return change;
-                }
-              });
-    } catch (ConflictingUpdateRuntimeException e) {
-      // Rethrow as an OrmException so the caller knows to use staged results. Strictly speaking
-      // they are not completely up to date, but result we send to the caller is the same as if this
-      // rebuild had executed before the other thread.
-      throw new ConflictingUpdateException(e);
-    } catch (AbortUpdateException e) {
-      if (newNoteDbState.isUpToDate(
-          manager.getChangeRepo().cmds.getRepoRefCache(),
-          manager.getAllUsersRepo().cmds.getRepoRefCache())) {
-        // If the state in ReviewDb matches NoteDb at this point, it means another thread
-        // successfully completed this rebuild. It's ok to not execute the update in this case,
-        // since the object referenced in the Result was flushed to the repo by whatever thread won
-        // the race.
-        return r;
-      }
-      // If the state doesn't match, that means another thread attempted this rebuild, but
-      // failed. Fall through and try to update the ref again.
-    }
-    if (migration.failChangeWrites()) {
-      // Don't even attempt to execute if read-only, it would fail anyway. But do throw an exception
-      // to the caller so they know to use the staged results instead of reading from the repo.
-      throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
-    }
-    if (executeManager) {
-      manager.execute();
-    }
-    return r;
-  }
-
-  static Change checkNoteDbState(Change c) throws OrmException {
-    // Can only rebuild a change if its primary storage is ReviewDb.
-    NoteDbChangeState s = NoteDbChangeState.parse(c);
-    if (s != null && s.getPrimaryStorage() != PrimaryStorage.REVIEW_DB) {
-      throw new OrmException(
-          String.format("cannot rebuild change " + c.getId() + " with state " + s));
-    }
-    return c;
-  }
-
-  @Override
-  public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws IOException, OrmException {
-    manager.setCheckExpectedState(false).setRefLogMessage("Rebuilding change");
-    Change change = new Change(bundle.getChange());
-    if (bundle.getPatchSets().isEmpty()) {
-      throw new NoPatchSetsException(change.getId());
-    }
-    if (change.getLastUpdatedOn().compareTo(change.getCreatedOn()) < 0) {
-      // A bug in data migration might set created_on to the time of the migration. The
-      // correct timestamps were lost, but we can at least set it so created_on is not after
-      // last_updated_on.
-      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7397
-      change.setCreatedOn(change.getLastUpdatedOn());
-    }
-
-    // We will rebuild all events, except for draft comments, in buckets based on author and
-    // timestamp.
-    List<Event> events = new ArrayList<>();
-    ListMultimap<Account.Id, DraftCommentEvent> draftCommentEvents =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-
-    events.addAll(getHashtagsEvents(change, manager));
-
-    // Delete ref only after hashtags have been read.
-    deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
-    deleteDraftRefs(change, manager.getAllUsersRepo());
-
-    Integer minPsNum = getMinPatchSetNum(bundle);
-    TreeMap<PatchSet.Id, PatchSetEvent> patchSetEvents =
-        new TreeMap<>(ReviewDbUtil.intKeyOrdering());
-
-    for (PatchSet ps : bundle.getPatchSets()) {
-      PatchSetEvent pse = new PatchSetEvent(change, ps, manager.getChangeRepo().rw);
-      patchSetEvents.put(ps.getId(), pse);
-      events.add(pse);
-      for (Comment c : getComments(bundle, serverId, Status.PUBLISHED, ps)) {
-        CommentEvent e = new CommentEvent(c, change, ps, patchListCache);
-        events.add(e.addDep(pse));
-      }
-      for (Comment c : getComments(bundle, serverId, Status.DRAFT, ps)) {
-        DraftCommentEvent e = new DraftCommentEvent(c, change, ps, patchListCache);
-        draftCommentEvents.put(c.author.getId(), e);
-      }
-    }
-    ensurePatchSetOrder(patchSetEvents);
-
-    for (PatchSetApproval psa : bundle.getPatchSetApprovals()) {
-      PatchSetEvent pse = patchSetEvents.get(psa.getPatchSetId());
-      if (pse != null) {
-        events.add(new ApprovalEvent(psa, change.getCreatedOn()).addDep(pse));
-      }
-    }
-
-    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> r :
-        bundle.getReviewers().asTable().cellSet()) {
-      events.add(new ReviewerEvent(r, change.getCreatedOn()));
-    }
-
-    Change noteDbChange = new Change(null, null, null, null, null);
-    for (ChangeMessage msg : bundle.getChangeMessages()) {
-      Event msgEvent = new ChangeMessageEvent(change, noteDbChange, msg, change.getCreatedOn());
-      if (msg.getPatchSetId() != null) {
-        PatchSetEvent pse = patchSetEvents.get(msg.getPatchSetId());
-        if (pse == null) {
-          continue; // Ignore events for missing patch sets.
-        }
-        msgEvent.addDep(pse);
-      }
-      events.add(msgEvent);
-    }
-
-    sortAndFillEvents(change, noteDbChange, bundle.getPatchSets(), events, minPsNum);
-
-    EventList<Event> el = new EventList<>();
-    for (Event e : events) {
-      if (!el.canAdd(e)) {
-        flushEventsToUpdate(manager, el, change);
-        checkState(el.canAdd(e));
-      }
-      el.add(e);
-    }
-    flushEventsToUpdate(manager, el, change);
-
-    EventList<DraftCommentEvent> plcel = new EventList<>();
-    for (Account.Id author : draftCommentEvents.keys()) {
-      for (DraftCommentEvent e : Ordering.natural().sortedCopy(draftCommentEvents.get(author))) {
-        if (!plcel.canAdd(e)) {
-          flushEventsToDraftUpdate(manager, plcel, change);
-          checkState(plcel.canAdd(e));
-        }
-        plcel.add(e);
-      }
-      flushEventsToDraftUpdate(manager, plcel, change);
-    }
-  }
-
-  private static Integer getMinPatchSetNum(ChangeBundle bundle) {
-    Integer minPsNum = null;
-    for (PatchSet ps : bundle.getPatchSets()) {
-      int n = ps.getId().get();
-      if (minPsNum == null || n < minPsNum) {
-        minPsNum = n;
-      }
-    }
-    return minPsNum;
-  }
-
-  private static void ensurePatchSetOrder(TreeMap<PatchSet.Id, PatchSetEvent> events) {
-    if (events.isEmpty()) {
-      return;
-    }
-    Iterator<PatchSetEvent> it = events.values().iterator();
-    PatchSetEvent curr = it.next();
-    while (it.hasNext()) {
-      PatchSetEvent next = it.next();
-      next.addDep(curr);
-      curr = next;
-    }
-  }
-
-  private static List<Comment> getComments(
-      ChangeBundle bundle, String serverId, PatchLineComment.Status status, PatchSet ps) {
-    return bundle
-        .getPatchLineComments()
-        .stream()
-        .filter(c -> c.getPatchSetId().equals(ps.getId()) && c.getStatus() == status)
-        .map(plc -> plc.asComment(serverId))
-        .sorted(CommentsUtil.COMMENT_ORDER)
-        .collect(toList());
-  }
-
-  private void sortAndFillEvents(
-      Change change,
-      Change noteDbChange,
-      ImmutableCollection<PatchSet> patchSets,
-      List<Event> events,
-      Integer minPsNum) {
-    Event finalUpdates = new FinalUpdatesEvent(change, noteDbChange, patchSets);
-    events.add(finalUpdates);
-    setPostSubmitDeps(events);
-    new EventSorter(events).sort();
-
-    // Ensure the first event in the list creates the change, setting the author and any required
-    // footers. Also force the creation time of the first patch set to match the creation time of
-    // the change.
-    Event first = events.get(0);
-    if (first instanceof PatchSetEvent && change.getOwner().equals(first.user)) {
-      first.when = change.getCreatedOn();
-      ((PatchSetEvent) first).createChange = true;
-    } else {
-      events.add(0, new CreateChangeEvent(change, minPsNum));
-    }
-
-    // Final pass to correct some inconsistencies.
-    //
-    // First, fill in any missing patch set IDs using the latest patch set of the change at the time
-    // of the event, because NoteDb can't represent actions with no associated patch set ID. This
-    // workaround is as if a user added a ChangeMessage on the change by replying from the latest
-    // patch set.
-    //
-    // Start with the first patch set that actually exists. If there are no patch sets at all,
-    // minPsNum will be null, so just bail and use 1 as the patch set ID.
-    //
-    // Second, ensure timestamps are nondecreasing, by copying the previous timestamp if this
-    // happens. This assumes that the only way this can happen is due to dependency constraints, and
-    // it is ok to give an event the same timestamp as one of its dependencies.
-    int ps = firstNonNull(minPsNum, 1);
-    for (int i = 0; i < events.size(); i++) {
-      Event e = events.get(i);
-      if (e.psId == null) {
-        e.psId = new PatchSet.Id(change.getId(), ps);
-      } else {
-        ps = Math.max(ps, e.psId.get());
-      }
-
-      if (i > 0) {
-        Event p = events.get(i - 1);
-        if (e.when.before(p.when)) {
-          e.when = p.when;
-        }
-      }
-    }
-  }
-
-  private void setPostSubmitDeps(List<Event> events) {
-    Optional<Event> submitEvent =
-        Lists.reverse(events).stream().filter(Event::isSubmit).findFirst();
-    if (submitEvent.isPresent()) {
-      events.stream().filter(Event::isPostSubmitApproval).forEach(e -> e.addDep(submitEvent.get()));
-    }
-  }
-
-  private void flushEventsToUpdate(
-      NoteDbUpdateManager manager, EventList<Event> events, Change change)
-      throws OrmException, IOException {
-    if (events.isEmpty()) {
-      return;
-    }
-    Comparator<String> labelNameComparator;
-    if (projectCache != null) {
-      labelNameComparator = projectCache.get(change.getProject()).getLabelTypes().nameComparator();
-    } else {
-      // No project cache available, bail and use natural ordering; there's no semantic difference
-      // anyway difference.
-      labelNameComparator = Ordering.natural();
-    }
-    ChangeUpdate update =
-        updateFactory.create(
-            change,
-            events.getAccountId(),
-            events.getRealAccountId(),
-            newAuthorIdent(events),
-            events.getWhen(),
-            labelNameComparator);
-    update.setAllowWriteToNewRef(true);
-    update.setPatchSetId(events.getPatchSetId());
-    update.setTag(events.getTag());
-    for (Event e : events) {
-      e.apply(update);
-    }
-    manager.add(update);
-    events.clear();
-  }
-
-  private void flushEventsToDraftUpdate(
-      NoteDbUpdateManager manager, EventList<DraftCommentEvent> events, Change change) {
-    if (events.isEmpty()) {
-      return;
-    }
-    ChangeDraftUpdate update =
-        draftUpdateFactory.create(
-            change,
-            events.getAccountId(),
-            events.getRealAccountId(),
-            newAuthorIdent(events),
-            events.getWhen());
-    update.setPatchSetId(events.getPatchSetId());
-    for (DraftCommentEvent e : events) {
-      e.applyDraft(update);
-    }
-    manager.add(update);
-    events.clear();
-  }
-
-  private PersonIdent newAuthorIdent(EventList<?> events) {
-    Account.Id id = events.getAccountId();
-    if (id == null) {
-      return new PersonIdent(serverIdent, events.getWhen());
-    }
-    return changeNoteUtil.getLegacyChangeNoteWrite().newIdent(id, events.getWhen(), serverIdent);
-  }
-
-  private List<HashtagsEvent> getHashtagsEvents(Change change, NoteDbUpdateManager manager)
-      throws IOException {
-    String refName = changeMetaRef(change.getId());
-    Optional<ObjectId> old = manager.getChangeRepo().getObjectId(refName);
-    if (!old.isPresent()) {
-      return Collections.emptyList();
-    }
-
-    RevWalk rw = manager.getChangeRepo().rw;
-    List<HashtagsEvent> events = new ArrayList<>();
-    rw.reset();
-    rw.markStart(rw.parseCommit(old.get()));
-    for (RevCommit commit : rw) {
-      Account.Id authorId;
-      try {
-        authorId =
-            changeNoteUtil
-                .getLegacyChangeNoteRead()
-                .parseIdent(commit.getAuthorIdent(), change.getId());
-      } catch (ConfigInvalidException e) {
-        continue; // Corrupt data, no valid hashtags in this commit.
-      }
-      PatchSet.Id psId = parsePatchSetId(change, commit);
-      Set<String> hashtags = parseHashtags(commit);
-      if (authorId == null || psId == null || hashtags == null) {
-        continue;
-      }
-
-      Timestamp commitTime = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
-      events.add(new HashtagsEvent(psId, authorId, commitTime, hashtags, change.getCreatedOn()));
-    }
-    return events;
-  }
-
-  private Set<String> parseHashtags(RevCommit commit) {
-    List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
-    if (hashtagsLines.isEmpty() || hashtagsLines.size() > 1) {
-      return null;
-    }
-
-    if (hashtagsLines.get(0).isEmpty()) {
-      return ImmutableSet.of();
-    }
-    return Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
-  }
-
-  private PatchSet.Id parsePatchSetId(Change change, RevCommit commit) {
-    List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
-    if (psIdLines.size() != 1) {
-      return null;
-    }
-    Integer psId = Ints.tryParse(psIdLines.get(0));
-    if (psId == null) {
-      return null;
-    }
-    return new PatchSet.Id(change.getId(), psId);
-  }
-
-  private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds) throws IOException {
-    String refName = changeMetaRef(change.getId());
-    Optional<ObjectId> old = cmds.get(refName);
-    if (old.isPresent()) {
-      cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), refName));
-    }
-  }
-
-  private void deleteDraftRefs(Change change, OpenRepo allUsersRepo) throws IOException {
-    for (Ref r :
-        allUsersRepo
-            .repo
-            .getRefDatabase()
-            .getRefs(RefNames.refsDraftCommentsPrefix(change.getId()))
-            .values()) {
-      allUsersRepo.cmds.add(new ReceiveCommand(r.getObjectId(), ObjectId.zeroId(), r.getName()));
-    }
-  }
-
-  static void createChange(ChangeUpdate update, Change change) {
-    update.setSubjectForCommit("Create change");
-    update.setChangeId(change.getKey().get());
-    update.setBranch(change.getDest().get());
-    update.setSubject(change.getOriginalSubject());
-    if (change.getRevertOf() != null) {
-      update.setRevertOf(change.getRevertOf().get());
-    }
-  }
-
-  @Override
-  public void rebuildReviewDb(ReviewDb db, Project.NameKey project, Change.Id changeId)
-      throws OrmException {
-    // TODO(dborowitz): Fail fast if changes tables are disabled in ReviewDb.
-    ChangeNotes notes = notesFactory.create(db, project, changeId);
-    ChangeBundle bundle = ChangeBundle.fromNotes(commentsUtil, notes);
-
-    db = ReviewDbUtil.unwrapDb(db);
-    db.changes().beginTransaction(changeId);
-    try {
-      Change c = db.changes().get(changeId);
-      if (c != null) {
-        PrimaryStorage ps = PrimaryStorage.of(c);
-        switch (ps) {
-          case REVIEW_DB:
-            return; // Nothing to do.
-          case NOTE_DB:
-            break; // Continue and rebuild.
-          default:
-            throw new OrmException("primary storage of " + changeId + " is " + ps);
-        }
-      } else {
-        c = notes.getChange();
-      }
-      db.changes().upsert(Collections.singleton(c));
-      putExactlyEntities(
-          db.changeMessages(), db.changeMessages().byChange(c.getId()), bundle.getChangeMessages());
-      putExactlyEntities(db.patchSets(), db.patchSets().byChange(c.getId()), bundle.getPatchSets());
-      putExactlyEntities(
-          db.patchSetApprovals(),
-          db.patchSetApprovals().byChange(c.getId()),
-          bundle.getPatchSetApprovals());
-      putExactlyEntities(
-          db.patchComments(),
-          db.patchComments().byChange(c.getId()),
-          bundle.getPatchLineComments());
-      db.commit();
-    } finally {
-      db.rollback();
-    }
-  }
-
-  private static <T, K extends Key<?>> void putExactlyEntities(
-      Access<T, K> access, Iterable<T> existing, Collection<T> ents) throws OrmException {
-    Set<K> toKeep = access.toMap(ents).keySet();
-    access.delete(
-        FluentIterable.from(existing).filter(e -> !toKeep.contains(access.primaryKey(e))));
-    access.upsert(ents);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java b/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
deleted file mode 100644
index 8f7b387..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
-
-import com.google.common.base.MoreObjects.ToStringHelper;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-
-class CommentEvent extends Event {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public final Comment c;
-  private final Change change;
-  private final PatchSet ps;
-  private final PatchListCache cache;
-
-  CommentEvent(Comment c, Change change, PatchSet ps, PatchListCache cache) {
-    super(
-        CommentsUtil.getCommentPsId(change.getId(), c),
-        c.author.getId(),
-        c.getRealAuthor().getId(),
-        c.writtenOn,
-        change.getCreatedOn(),
-        c.tag);
-    this.c = c;
-    this.change = change;
-    this.ps = ps;
-    this.cache = cache;
-  }
-
-  @Override
-  boolean uniquePerUpdate() {
-    return false;
-  }
-
-  @Override
-  protected boolean canHaveTag() {
-    return true;
-  }
-
-  @Override
-  void apply(ChangeUpdate update) {
-    checkUpdate(update);
-    if (c.revId == null) {
-      try {
-        setCommentRevId(c, cache, change, ps);
-      } catch (PatchListNotAvailableException e) {
-        logger.atWarning().log(
-            "Unable to determine parent commit of patch set %s (%s); omitting inline comment %s",
-            ps.getId(), ps.getRevision(), c);
-        return;
-      }
-    }
-    update.putComment(PatchLineComment.Status.PUBLISHED, c);
-  }
-
-  @Override
-  protected void addToString(ToStringHelper helper) {
-    helper.add("message", c.message);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java b/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
deleted file mode 100644
index d8e7480..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
+++ /dev/null
@@ -1,32 +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.server.notedb.rebuild;
-
-import com.google.gwtorm.server.OrmException;
-
-/**
- * {@link com.google.gwtorm.server.OrmException} thrown by {@link ChangeRebuilder} when rebuilding a
- * change failed because another operation modified its {@link
- * com.google.gerrit.server.notedb.NoteDbChangeState}.
- */
-public class ConflictingUpdateException extends OrmException {
-  private static final long serialVersionUID = 1L;
-
-  // Always created from a ConflictingUpdateRuntimeException because it originates from an
-  // AtomicUpdate, which cannot throw checked exceptions.
-  ConflictingUpdateException(ConflictingUpdateRuntimeException cause) {
-    super(cause.getMessage(), cause);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java b/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java
deleted file mode 100644
index abfafa2..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.OrmRuntimeException;
-
-class ConflictingUpdateRuntimeException extends OrmRuntimeException {
-  private static final long serialVersionUID = 1L;
-
-  ConflictingUpdateRuntimeException(Change change, String expectedNoteDbState) {
-    super(
-        String.format(
-            "Expected change %s to have noteDbState %s but was %s",
-            change.getId(), expectedNoteDbState, change.getNoteDbState()));
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java b/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
deleted file mode 100644
index d01071b..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-
-class CreateChangeEvent extends Event {
-  private final Change change;
-
-  private static PatchSet.Id psId(Change change, Integer minPsNum) {
-    int n;
-    if (minPsNum == null) {
-      // There were no patch sets for the change at all, so something is very
-      // wrong. Bail and use 1 as the patch set.
-      n = 1;
-    } else {
-      n = minPsNum;
-    }
-    return new PatchSet.Id(change.getId(), n);
-  }
-
-  CreateChangeEvent(Change change, Integer minPsNum) {
-    super(
-        psId(change, minPsNum),
-        change.getOwner(),
-        change.getOwner(),
-        change.getCreatedOn(),
-        change.getCreatedOn(),
-        null);
-    this.change = change;
-  }
-
-  @Override
-  boolean uniquePerUpdate() {
-    return true;
-  }
-
-  @Override
-  void apply(ChangeUpdate update) throws IOException, OrmException {
-    checkUpdate(update);
-    ChangeRebuilderImpl.createChange(update, change);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java b/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
deleted file mode 100644
index 2a2795d..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
-
-import com.google.common.base.MoreObjects.ToStringHelper;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.notedb.ChangeDraftUpdate;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-
-class DraftCommentEvent extends Event {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public final Comment c;
-  private final Change change;
-  private final PatchSet ps;
-  private final PatchListCache cache;
-
-  DraftCommentEvent(Comment c, Change change, PatchSet ps, PatchListCache cache) {
-    super(
-        CommentsUtil.getCommentPsId(change.getId(), c),
-        c.author.getId(),
-        c.getRealAuthor().getId(),
-        c.writtenOn,
-        change.getCreatedOn(),
-        c.tag);
-    this.c = c;
-    this.change = change;
-    this.ps = ps;
-    this.cache = cache;
-  }
-
-  @Override
-  boolean uniquePerUpdate() {
-    return false;
-  }
-
-  @Override
-  void apply(ChangeUpdate update) {
-    throw new UnsupportedOperationException();
-  }
-
-  void applyDraft(ChangeDraftUpdate draftUpdate) {
-    if (c.revId == null) {
-      try {
-        setCommentRevId(c, cache, change, ps);
-      } catch (PatchListNotAvailableException e) {
-        logger.atWarning().log(
-            "Unable to determine parent commit of patch set %s (%s);"
-                + " omitting draft inline comment %s",
-            ps.getId(), ps.getRevision(), c);
-        return;
-      }
-    }
-    draftUpdate.putComment(c);
-  }
-
-  @Override
-  protected void addToString(ToStringHelper helper) {
-    helper.add("message", c.message);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/Event.java b/java/com/google/gerrit/server/notedb/rebuild/Event.java
deleted file mode 100644
index 3957c5c..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/Event.java
+++ /dev/null
@@ -1,146 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl.MAX_WINDOW_MS;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.MoreObjects.ToStringHelper;
-import com.google.common.collect.ComparisonChain;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.notedb.AbstractChangeUpdate;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-
-abstract class Event implements Comparable<Event> {
-  // NOTE: EventList only supports direct subclasses, not an arbitrary
-  // hierarchy.
-
-  final Account.Id user;
-  final Account.Id realUser;
-  final String tag;
-  final boolean predatesChange;
-
-  /** Dependencies of this event; other events that must happen before this one. */
-  final List<Event> deps;
-
-  Timestamp when;
-  PatchSet.Id psId;
-
-  protected Event(
-      PatchSet.Id psId,
-      Account.Id effectiveUser,
-      Account.Id realUser,
-      Timestamp when,
-      Timestamp changeCreatedOn,
-      String tag) {
-    this.psId = psId;
-    this.user = effectiveUser;
-    this.realUser = realUser != null ? realUser : effectiveUser;
-    this.tag = tag;
-    // Truncate timestamps at the change's createdOn timestamp.
-    predatesChange = when.before(changeCreatedOn);
-    this.when = predatesChange ? changeCreatedOn : when;
-    deps = new ArrayList<>();
-  }
-
-  protected void checkUpdate(AbstractChangeUpdate update) {
-    checkState(
-        Objects.equals(update.getPatchSetId(), psId),
-        "cannot apply event for %s to update for %s",
-        update.getPatchSetId(),
-        psId);
-    checkState(
-        when.getTime() - update.getWhen().getTime() <= MAX_WINDOW_MS,
-        "event at %s outside update window starting at %s",
-        when,
-        update.getWhen());
-    checkState(
-        Objects.equals(update.getNullableAccountId(), user),
-        "cannot apply event by %s to update by %s",
-        user,
-        update.getNullableAccountId());
-  }
-
-  Event addDep(Event e) {
-    deps.add(e);
-    return this;
-  }
-
-  /**
-   * @return whether this event type must be unique per {@link ChangeUpdate}, i.e. there may be at
-   *     most one of this type.
-   */
-  abstract boolean uniquePerUpdate();
-
-  abstract void apply(ChangeUpdate update) throws OrmException, IOException;
-
-  protected boolean isPostSubmitApproval() {
-    return false;
-  }
-
-  protected boolean isSubmit() {
-    return false;
-  }
-
-  protected boolean canHaveTag() {
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    ToStringHelper helper =
-        MoreObjects.toStringHelper(this)
-            .add("psId", psId)
-            .add("effectiveUser", user)
-            .add("realUser", realUser)
-            .add("when", when)
-            .add("tag", tag);
-    addToString(helper);
-    return helper.toString();
-  }
-
-  /** @param helper toString helper to add fields to */
-  protected void addToString(ToStringHelper helper) {}
-
-  @Override
-  public int compareTo(Event other) {
-    return ComparisonChain.start()
-        .compareFalseFirst(this.isFinalUpdates(), other.isFinalUpdates())
-        .compare(this.when, other.when)
-        .compareTrueFirst(isPatchSet(), isPatchSet())
-        .compareTrueFirst(this.predatesChange, other.predatesChange)
-        .compare(this.user, other.user, ReviewDbUtil.intKeyOrdering())
-        .compare(this.realUser, other.realUser, ReviewDbUtil.intKeyOrdering())
-        .compare(this.psId, other.psId, ReviewDbUtil.intKeyOrdering().nullsLast())
-        .result();
-  }
-
-  private boolean isPatchSet() {
-    return this instanceof PatchSetEvent;
-  }
-
-  private boolean isFinalUpdates() {
-    return this instanceof FinalUpdatesEvent;
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/EventList.java b/java/com/google/gerrit/server/notedb/rebuild/EventList.java
deleted file mode 100644
index 773215e..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/EventList.java
+++ /dev/null
@@ -1,170 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-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 com.google.common.collect.Lists;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.Objects;
-
-class EventList<E extends Event> implements Iterable<E> {
-  private final ArrayList<E> list = new ArrayList<>();
-  private boolean isSubmit;
-
-  @Override
-  public Iterator<E> iterator() {
-    return list.iterator();
-  }
-
-  void add(E e) {
-    list.add(e);
-    if (e.isSubmit()) {
-      isSubmit = true;
-    }
-  }
-
-  void clear() {
-    list.clear();
-    isSubmit = false;
-  }
-
-  boolean isEmpty() {
-    return list.isEmpty();
-  }
-
-  boolean canAdd(E e) {
-    if (isEmpty()) {
-      return true;
-    }
-    if (e instanceof FinalUpdatesEvent) {
-      return false; // FinalUpdatesEvent always gets its own update.
-    }
-
-    Event last = getLast();
-    if (!Objects.equals(e.user, last.user)
-        || !Objects.equals(e.realUser, last.realUser)
-        || !e.psId.equals(last.psId)) {
-      return false; // Different patch set or author.
-    }
-    if (e.canHaveTag() && canHaveTag() && !Objects.equals(e.tag, getTag())) {
-      // We should trust the tag field, and it doesn't match.
-      return false;
-    }
-    if (e.isPostSubmitApproval() && isSubmit) {
-      // Post-submit approvals must come after the update that submits.
-      return false;
-    }
-
-    long t = e.when.getTime();
-    long tFirst = getFirstTime();
-    long tLast = getLastTime();
-    checkArgument(t >= tLast, "event %s is before previous event in list %s", e, last);
-    if (t - tLast > ChangeRebuilderImpl.MAX_DELTA_MS
-        || t - tFirst > ChangeRebuilderImpl.MAX_WINDOW_MS) {
-      return false; // Too much time elapsed.
-    }
-
-    if (!e.uniquePerUpdate()) {
-      return true;
-    }
-    for (Event o : this) {
-      if (e.getClass() == o.getClass()) {
-        return false; // Only one event of this type allowed per update.
-      }
-    }
-
-    // TODO(dborowitz): Additional heuristics, like keeping events separate if
-    // they affect overlapping fields within a single entity.
-
-    return true;
-  }
-
-  Timestamp getWhen() {
-    return get(0).when;
-  }
-
-  PatchSet.Id getPatchSetId() {
-    PatchSet.Id id = checkNotNull(get(0).psId);
-    for (int i = 1; i < size(); i++) {
-      checkState(
-          get(i).psId.equals(id), "mismatched patch sets in EventList: %s != %s", id, get(i).psId);
-    }
-    return id;
-  }
-
-  Account.Id getAccountId() {
-    Account.Id id = get(0).user;
-    for (int i = 1; i < size(); i++) {
-      checkState(
-          Objects.equals(id, get(i).user),
-          "mismatched users in EventList: %s != %s",
-          id,
-          get(i).user);
-    }
-    return id;
-  }
-
-  Account.Id getRealAccountId() {
-    Account.Id id = get(0).realUser;
-    for (int i = 1; i < size(); i++) {
-      checkState(
-          Objects.equals(id, get(i).realUser),
-          "mismatched real users in EventList: %s != %s",
-          id,
-          get(i).realUser);
-    }
-    return id;
-  }
-
-  String getTag() {
-    for (E e : Lists.reverse(list)) {
-      if (e.tag != null) {
-        return e.tag;
-      }
-    }
-    return null;
-  }
-
-  private boolean canHaveTag() {
-    return list.stream().anyMatch(Event::canHaveTag);
-  }
-
-  private E get(int i) {
-    return list.get(i);
-  }
-
-  private int size() {
-    return list.size();
-  }
-
-  private E getLast() {
-    return list.get(list.size() - 1);
-  }
-
-  private long getLastTime() {
-    return getLast().when.getTime();
-  }
-
-  private long getFirstTime() {
-    return list.get(0).when.getTime();
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java b/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java
deleted file mode 100644
index 077a027..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import java.util.Collections;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.PriorityQueue;
-
-/**
- * Helper to sort a list of events.
- *
- * <p>Events are sorted in two passes:
- *
- * <ol>
- *   <li>Sort by natural order (timestamp, patch set, author, etc.)
- *   <li>Postpone any events with dependencies to occur only after all of their dependencies, where
- *       this violates natural order.
- * </ol>
- *
- * {@link #sort()} modifies the event list in place (similar to {@link Collections#sort(List)}), but
- * does not modify any event. In particular, events might end up out of order with respect to
- * timestamp; callers are responsible for adjusting timestamps later if they prefer monotonicity.
- */
-class EventSorter {
-  private final List<Event> out;
-  private final LinkedHashSet<Event> sorted;
-  private ListMultimap<Event, Event> waiting;
-  private SetMultimap<Event, Event> deps;
-
-  EventSorter(List<Event> events) {
-    LinkedHashSet<Event> all = new LinkedHashSet<>(events);
-    out = events;
-
-    for (Event e : events) {
-      for (Event d : e.deps) {
-        checkArgument(all.contains(d), "dep %s of %s not in input list", d, e);
-      }
-    }
-
-    all.clear();
-    sorted = all; // Presized.
-  }
-
-  void sort() {
-    // First pass: sort by natural order.
-    PriorityQueue<Event> todo = new PriorityQueue<>(out);
-
-    // Populate waiting map after initial sort to preserve natural order.
-    waiting = MultimapBuilder.hashKeys().arrayListValues().build();
-    deps = MultimapBuilder.hashKeys().hashSetValues().build();
-    for (Event e : todo) {
-      for (Event d : e.deps) {
-        deps.put(e, d);
-        waiting.put(d, e);
-      }
-    }
-
-    // Second pass: enforce dependencies.
-    int size = out.size();
-    while (!todo.isEmpty()) {
-      process(todo.remove(), todo);
-    }
-    checkState(
-        sorted.size() == size, "event sort expected %s elements, got %s", size, sorted.size());
-
-    // Modify out in-place a la Collections#sort.
-    out.clear();
-    out.addAll(sorted);
-  }
-
-  void process(Event e, PriorityQueue<Event> todo) {
-    if (sorted.contains(e)) {
-      return; // Already emitted.
-    }
-    if (!deps.get(e).isEmpty()) {
-      // Not all events that e depends on have been emitted yet. Ignore e for
-      // now; it will get added back to the queue in the block below once its
-      // last dependency is processed.
-      return;
-    }
-
-    // All events that e depends on have been emitted, so e can be emitted.
-    sorted.add(e);
-
-    // Remove e from the dependency set of all events waiting on e, and add
-    // those events back to the queue in the original priority order for
-    // reconsideration.
-    for (Event w : waiting.get(e)) {
-      deps.get(w).remove(e);
-      todo.add(w);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java b/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
deleted file mode 100644
index 55d5a31..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
+++ /dev/null
@@ -1,101 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
-
-import com.google.common.base.MoreObjects.ToStringHelper;
-import com.google.common.collect.ImmutableCollection;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gwtorm.server.OrmException;
-import java.util.Objects;
-
-class FinalUpdatesEvent extends Event {
-  private final Change change;
-  private final Change noteDbChange;
-  private final ImmutableCollection<PatchSet> patchSets;
-
-  FinalUpdatesEvent(Change change, Change noteDbChange, ImmutableCollection<PatchSet> patchSets) {
-    super(
-        change.currentPatchSetId(),
-        change.getOwner(),
-        change.getOwner(),
-        change.getLastUpdatedOn(),
-        change.getCreatedOn(),
-        null);
-    this.change = change;
-    this.noteDbChange = noteDbChange;
-    this.patchSets = patchSets;
-  }
-
-  @Override
-  boolean uniquePerUpdate() {
-    return true;
-  }
-
-  @SuppressWarnings("deprecation")
-  @Override
-  void apply(ChangeUpdate update) throws OrmException {
-    if (!Objects.equals(change.getTopic(), noteDbChange.getTopic())) {
-      update.setTopic(change.getTopic());
-    }
-    if (!statusMatches()) {
-      // TODO(dborowitz): Stamp approximate approvals at this time.
-      update.fixStatus(change.getStatus());
-    }
-    if (change.isPrivate() != noteDbChange.isPrivate()) {
-      update.setPrivate(change.isPrivate());
-    }
-    if (change.isWorkInProgress() != noteDbChange.isWorkInProgress()) {
-      update.setWorkInProgress(change.isWorkInProgress());
-    }
-    if (change.getSubmissionId() != null && noteDbChange.getSubmissionId() == null) {
-      update.setSubmissionId(change.getSubmissionId());
-    }
-    if (!Objects.equals(change.getAssignee(), noteDbChange.getAssignee())) {
-      // TODO(dborowitz): Parse intermediate values out from messages.
-      update.setAssignee(change.getAssignee());
-    }
-    if (!patchSets.isEmpty() && !highestNumberedPatchSetIsCurrent()) {
-      update.setCurrentPatchSet();
-    }
-    if (!update.isEmpty()) {
-      update.setSubjectForCommit("Final NoteDb migration updates");
-    }
-  }
-
-  private boolean statusMatches() {
-    return Objects.equals(change.getStatus(), noteDbChange.getStatus());
-  }
-
-  private boolean highestNumberedPatchSetIsCurrent() {
-    PatchSet.Id max = patchSets.stream().map(PatchSet::getId).max(intKeyOrdering()).get();
-    return max.equals(change.currentPatchSetId());
-  }
-
-  @Override
-  protected boolean isSubmit() {
-    return change.getStatus() == Change.Status.MERGED;
-  }
-
-  @Override
-  protected void addToString(ToStringHelper helper) {
-    if (!statusMatches()) {
-      helper.add("status", change.getStatus());
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/GcAllUsers.java b/java/com/google/gerrit/server/notedb/rebuild/GcAllUsers.java
deleted file mode 100644
index 6544b23..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/GcAllUsers.java
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static org.eclipse.jgit.lib.ConfigConstants.CONFIG_GC_SECTION;
-import static org.eclipse.jgit.lib.ConfigConstants.CONFIG_KEY_AUTO;
-
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GarbageCollectionResult;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GarbageCollection;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LocalDiskRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.util.function.Consumer;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class GcAllUsers {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final AllUsersName allUsers;
-  private final GarbageCollection.Factory gcFactory;
-  private final GitRepositoryManager repoManager;
-
-  @Inject
-  GcAllUsers(
-      AllUsersName allUsers,
-      GarbageCollection.Factory gcFactory,
-      GitRepositoryManager repoManager) {
-    this.allUsers = allUsers;
-    this.gcFactory = gcFactory;
-    this.repoManager = repoManager;
-  }
-
-  public void runWithLogger() {
-    // Print log messages using logger, and skip progress.
-    run(s -> logger.atInfo().log(s), null);
-  }
-
-  public void run(PrintWriter writer) {
-    // Print both log messages and progress to given writer.
-    run(checkNotNull(writer)::println, writer);
-  }
-
-  private void run(Consumer<String> logOneLine, @Nullable PrintWriter progressWriter) {
-    if (!(repoManager instanceof LocalDiskRepositoryManager)) {
-      logOneLine.accept("Skipping GC of " + allUsers + "; not a local disk repo");
-      return;
-    }
-    if (!enableAutoGc(logOneLine)) {
-      logOneLine.accept(
-          "Skipping GC of "
-              + allUsers
-              + " due to disabling "
-              + CONFIG_GC_SECTION
-              + "."
-              + CONFIG_KEY_AUTO);
-      logOneLine.accept(
-          "If loading accounts is slow after the NoteDb migration, run `git gc` on "
-              + allUsers
-              + " manually");
-      return;
-    }
-
-    if (progressWriter == null) {
-      // Mimic log line from GarbageCollection.
-      logOneLine.accept("collecting garbage for \"" + allUsers + "\":\n");
-    }
-    GarbageCollectionResult result =
-        gcFactory.create().run(ImmutableList.of(allUsers), progressWriter);
-    if (!result.hasErrors()) {
-      return;
-    }
-    for (GarbageCollectionResult.Error e : result.getErrors()) {
-      switch (e.getType()) {
-        case GC_ALREADY_SCHEDULED:
-          logOneLine.accept("GC already scheduled for " + e.getProjectName());
-          break;
-        case GC_FAILED:
-          logOneLine.accept("GC failed for " + e.getProjectName());
-          break;
-        case REPOSITORY_NOT_FOUND:
-          logOneLine.accept(e.getProjectName() + " repo not found");
-          break;
-        default:
-          logOneLine.accept("GC failed for " + e.getProjectName() + ": " + e.getType());
-          break;
-      }
-    }
-  }
-
-  private boolean enableAutoGc(Consumer<String> logOneLine) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return repo.getConfig().getInt(CONFIG_GC_SECTION, CONFIG_KEY_AUTO, -1) != 0;
-    } catch (IOException e) {
-      logOneLine.accept(
-          "Error reading config for " + allUsers + ":\n" + Throwables.getStackTraceAsString(e));
-      return false;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java b/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
deleted file mode 100644
index 4f6f6ad..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import com.google.common.base.MoreObjects.ToStringHelper;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gwtorm.server.OrmException;
-import java.sql.Timestamp;
-import java.util.Set;
-
-class HashtagsEvent extends Event {
-  private final Set<String> hashtags;
-
-  HashtagsEvent(
-      PatchSet.Id psId,
-      Account.Id who,
-      Timestamp when,
-      Set<String> hashtags,
-      Timestamp changeCreatdOn) {
-    super(
-        psId,
-        who,
-        who,
-        when,
-        changeCreatdOn,
-        // Somewhat confusingly, hashtags do not use the setTag method on
-        // AbstractChangeUpdate, so pass null as the tag.
-        null);
-    this.hashtags = hashtags;
-  }
-
-  @Override
-  boolean uniquePerUpdate() {
-    // Since these are produced from existing commits in the old NoteDb graph,
-    // we know that there must be one per commit in the rebuilt graph.
-    return true;
-  }
-
-  @Override
-  void apply(ChangeUpdate update) throws OrmException {
-    update.setHashtags(hashtags);
-  }
-
-  @Override
-  protected void addToString(ToStringHelper helper) {
-    helper.add("hashtags", hashtags);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java b/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java
deleted file mode 100644
index 0cf78be..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java
+++ /dev/null
@@ -1,30 +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.server.notedb.rebuild;
-
-import java.io.IOException;
-
-/** Exception thrown by {@link NoteDbMigrator} when migration fails. */
-public class MigrationException extends IOException {
-  private static final long serialVersionUID = 1L;
-
-  MigrationException(String message) {
-    super(message);
-  }
-
-  MigrationException(String message, Throwable why) {
-    super(message, why);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
deleted file mode 100644
index e064a8c..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
+++ /dev/null
@@ -1,1026 +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.server.notedb.rebuild;
-
-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.reviewdb.server.ReviewDbUtil.unwrapDb;
-import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
-import static com.google.gerrit.server.notedb.NotesMigrationState.NOTE_DB;
-import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_NO_SEQUENCE;
-import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY;
-import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY;
-import static com.google.gerrit.server.notedb.NotesMigrationState.WRITE;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Stopwatch;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Ordering;
-import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Streams;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.util.concurrent.Futures;
-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.FormatUtil;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfigProvider;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LockFailureException;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.notedb.NoteDbTable;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
-import com.google.gerrit.server.notedb.PrimaryStorageMigrator.NoNoteDbStateException;
-import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.update.ChainedReceiveCommands;
-import com.google.gerrit.server.update.RefUpdateUtil;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.BufferedWriter;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Consumer;
-import java.util.function.Predicate;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.internal.storage.file.PackInserter;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.util.FS;
-import org.eclipse.jgit.util.io.NullOutputStream;
-
-/** One stop shop for migrating a site's change storage from ReviewDb to NoteDb. */
-public class NoteDbMigrator implements AutoCloseable {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final String AUTO_MIGRATE = "autoMigrate";
-  private static final String TRIAL = "trial";
-
-  public static boolean getAutoMigrate(Config cfg) {
-    return cfg.getBoolean(SECTION_NOTE_DB, NoteDbTable.CHANGES.key(), AUTO_MIGRATE, false);
-  }
-
-  private static void setAutoMigrate(Config cfg, boolean autoMigrate) {
-    cfg.setBoolean(SECTION_NOTE_DB, NoteDbTable.CHANGES.key(), AUTO_MIGRATE, autoMigrate);
-  }
-
-  public static boolean getTrialMode(Config cfg) {
-    return cfg.getBoolean(SECTION_NOTE_DB, NoteDbTable.CHANGES.key(), TRIAL, false);
-  }
-
-  public static void setTrialMode(Config cfg, boolean trial) {
-    cfg.setBoolean(SECTION_NOTE_DB, NoteDbTable.CHANGES.key(), TRIAL, trial);
-  }
-
-  public static class Builder {
-    private final Config cfg;
-    private final SitePaths sitePaths;
-    private final Provider<PersonIdent> serverIdent;
-    private final AllUsersName allUsers;
-    private final SchemaFactory<ReviewDb> schemaFactory;
-    private final GitRepositoryManager repoManager;
-    private final NoteDbUpdateManager.Factory updateManagerFactory;
-    private final ChangeBundleReader bundleReader;
-    private final AllProjectsName allProjects;
-    private final InternalUser.Factory userFactory;
-    private final ThreadLocalRequestContext requestContext;
-    private final ChangeRebuilderImpl rebuilder;
-    private final WorkQueue workQueue;
-    private final MutableNotesMigration globalNotesMigration;
-    private final PrimaryStorageMigrator primaryStorageMigrator;
-    private final DynamicSet<NotesMigrationStateListener> listeners;
-
-    private int threads;
-    private ImmutableList<Project.NameKey> projects = ImmutableList.of();
-    private ImmutableList<Change.Id> changes = ImmutableList.of();
-    private OutputStream progressOut = NullOutputStream.INSTANCE;
-    private NotesMigrationState stopAtState;
-    private boolean trial;
-    private boolean forceRebuild;
-    private int sequenceGap = -1;
-    private boolean autoMigrate;
-
-    @Inject
-    Builder(
-        GerritServerConfigProvider configProvider,
-        SitePaths sitePaths,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        AllUsersName allUsers,
-        SchemaFactory<ReviewDb> schemaFactory,
-        GitRepositoryManager repoManager,
-        NoteDbUpdateManager.Factory updateManagerFactory,
-        ChangeBundleReader bundleReader,
-        AllProjectsName allProjects,
-        ThreadLocalRequestContext requestContext,
-        InternalUser.Factory userFactory,
-        ChangeRebuilderImpl rebuilder,
-        WorkQueue workQueue,
-        MutableNotesMigration globalNotesMigration,
-        PrimaryStorageMigrator primaryStorageMigrator,
-        DynamicSet<NotesMigrationStateListener> listeners) {
-      // Reload gerrit.config/notedb.config on each migrator invocation, in case a previous
-      // migration in the same process modified the on-disk contents. This ensures the defaults for
-      // trial/autoMigrate get set correctly below.
-      this.cfg = configProvider.loadConfig();
-      this.sitePaths = sitePaths;
-      this.serverIdent = serverIdent;
-      this.allUsers = allUsers;
-      this.schemaFactory = schemaFactory;
-      this.repoManager = repoManager;
-      this.updateManagerFactory = updateManagerFactory;
-      this.bundleReader = bundleReader;
-      this.allProjects = allProjects;
-      this.requestContext = requestContext;
-      this.userFactory = userFactory;
-      this.rebuilder = rebuilder;
-      this.workQueue = workQueue;
-      this.globalNotesMigration = globalNotesMigration;
-      this.primaryStorageMigrator = primaryStorageMigrator;
-      this.listeners = listeners;
-      this.trial = getTrialMode(cfg);
-      this.autoMigrate = getAutoMigrate(cfg);
-    }
-
-    /**
-     * Set the number of threads used by parallelizable phases of the migration, such as rebuilding
-     * all changes.
-     *
-     * <p>Not all phases are parallelizable, and calling {@link #rebuild()} directly will do
-     * substantial work in the calling thread regardless of the number of threads configured.
-     *
-     * <p>By default, all work is done in the calling thread.
-     *
-     * @param threads thread count; if less than 2, all work happens in the calling thread.
-     * @return this.
-     */
-    public Builder setThreads(int threads) {
-      this.threads = threads;
-      return this;
-    }
-
-    /**
-     * Limit the set of projects that are processed.
-     *
-     * <p>Incompatible with {@link #setChanges(Collection)}.
-     *
-     * <p>By default, all projects will be processed.
-     *
-     * @param projects set of projects; if null or empty, all projects will be processed.
-     * @return this.
-     */
-    public Builder setProjects(@Nullable Collection<Project.NameKey> projects) {
-      this.projects = projects != null ? ImmutableList.copyOf(projects) : ImmutableList.of();
-      return this;
-    }
-
-    /**
-     * Limit the set of changes that are processed.
-     *
-     * <p>Incompatible with {@link #setProjects(Collection)}.
-     *
-     * <p>By default, all changes will be processed.
-     *
-     * @param changes set of changes; if null or empty, all changes will be processed.
-     * @return this.
-     */
-    public Builder setChanges(@Nullable Collection<Change.Id> changes) {
-      this.changes = changes != null ? ImmutableList.copyOf(changes) : ImmutableList.of();
-      return this;
-    }
-
-    /**
-     * Set output stream for progress monitors.
-     *
-     * <p>By default, there is no progress monitor output (although there may be other logs).
-     *
-     * @param progressOut output stream.
-     * @return this.
-     */
-    public Builder setProgressOut(OutputStream progressOut) {
-      this.progressOut = checkNotNull(progressOut);
-      return this;
-    }
-
-    /**
-     * Stop at a specific migration state, for testing only.
-     *
-     * @param stopAtState state to stop at.
-     * @return this.
-     */
-    @VisibleForTesting
-    public Builder setStopAtStateForTesting(NotesMigrationState stopAtState) {
-      this.stopAtState = stopAtState;
-      return this;
-    }
-
-    /**
-     * Rebuild in "trial mode": configure Gerrit to write to and read from NoteDb, but leave
-     * ReviewDb as the source of truth for all changes.
-     *
-     * <p>By default, trial mode is off, and NoteDb is the source of truth for all changes following
-     * the migration.
-     *
-     * @param trial whether to rebuild in trial mode.
-     * @return this.
-     */
-    public Builder setTrialMode(boolean trial) {
-      this.trial = trial;
-      return this;
-    }
-
-    /**
-     * Rebuild all changes in NoteDb from ReviewDb, even if Gerrit is currently configured to read
-     * from NoteDb.
-     *
-     * <p>Only supported if ReviewDb is still the source of truth for all changes.
-     *
-     * <p>By default, force rebuilding is off.
-     *
-     * @param forceRebuild whether to force rebuilding.
-     * @return this.
-     */
-    public Builder setForceRebuild(boolean forceRebuild) {
-      this.forceRebuild = forceRebuild;
-      return this;
-    }
-
-    /**
-     * Gap between ReviewDb change sequence numbers and NoteDb.
-     *
-     * <p>If NoteDb sequences are enabled in a running server, there is a race between the migration
-     * step that calls {@code nextChangeId()} to seed the ref, and other threads that call {@code
-     * nextChangeId()} to create new changes. In order to prevent these operations stepping on one
-     * another, we use this value to skip some predefined sequence numbers. This is strongly
-     * recommended in a running server.
-     *
-     * <p>If the migration takes place offline, there is no race with other threads, and this option
-     * may be set to 0. However, admins may still choose to use a gap, for example to make it easier
-     * to distinguish changes that were created before and after the NoteDb migration.
-     *
-     * <p>By default, uses the value from {@code noteDb.changes.initialSequenceGap} in {@code
-     * gerrit.config}, which defaults to 1000.
-     *
-     * @param sequenceGap sequence gap size; if negative, use the default.
-     * @return this.
-     */
-    public Builder setSequenceGap(int sequenceGap) {
-      this.sequenceGap = sequenceGap;
-      return this;
-    }
-
-    /**
-     * Enable auto-migration on subsequent daemon launches.
-     *
-     * <p>If true, prior to running any migration steps, sets the necessary configuration in {@code
-     * gerrit.config} to make {@code gerrit.war daemon} retry the migration on next startup, if it
-     * fails.
-     *
-     * @param autoMigrate whether to set auto-migration config.
-     * @return this.
-     */
-    public Builder setAutoMigrate(boolean autoMigrate) {
-      this.autoMigrate = autoMigrate;
-      return this;
-    }
-
-    public NoteDbMigrator build() throws MigrationException {
-      return new NoteDbMigrator(
-          sitePaths,
-          schemaFactory,
-          serverIdent,
-          allUsers,
-          repoManager,
-          updateManagerFactory,
-          bundleReader,
-          allProjects,
-          requestContext,
-          userFactory,
-          rebuilder,
-          globalNotesMigration,
-          primaryStorageMigrator,
-          listeners,
-          threads > 1
-              ? MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "RebuildChange"))
-              : MoreExecutors.newDirectExecutorService(),
-          projects,
-          changes,
-          progressOut,
-          stopAtState,
-          trial,
-          forceRebuild,
-          sequenceGap >= 0 ? sequenceGap : Sequences.getChangeSequenceGap(cfg),
-          autoMigrate);
-    }
-  }
-
-  private final FileBasedConfig gerritConfig;
-  private final FileBasedConfig noteDbConfig;
-  private final SchemaFactory<ReviewDb> schemaFactory;
-  private final Provider<PersonIdent> serverIdent;
-  private final AllUsersName allUsers;
-  private final GitRepositoryManager repoManager;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final ChangeBundleReader bundleReader;
-  private final AllProjectsName allProjects;
-  private final ThreadLocalRequestContext requestContext;
-  private final InternalUser.Factory userFactory;
-  private final ChangeRebuilderImpl rebuilder;
-  private final MutableNotesMigration globalNotesMigration;
-  private final PrimaryStorageMigrator primaryStorageMigrator;
-  private final DynamicSet<NotesMigrationStateListener> listeners;
-
-  private final ListeningExecutorService executor;
-  private final ImmutableList<Project.NameKey> projects;
-  private final ImmutableList<Change.Id> changes;
-  private final OutputStream progressOut;
-  private final NotesMigrationState stopAtState;
-  private final boolean trial;
-  private final boolean forceRebuild;
-  private final int sequenceGap;
-  private final boolean autoMigrate;
-
-  private NoteDbMigrator(
-      SitePaths sitePaths,
-      SchemaFactory<ReviewDb> schemaFactory,
-      Provider<PersonIdent> serverIdent,
-      AllUsersName allUsers,
-      GitRepositoryManager repoManager,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeBundleReader bundleReader,
-      AllProjectsName allProjects,
-      ThreadLocalRequestContext requestContext,
-      InternalUser.Factory userFactory,
-      ChangeRebuilderImpl rebuilder,
-      MutableNotesMigration globalNotesMigration,
-      PrimaryStorageMigrator primaryStorageMigrator,
-      DynamicSet<NotesMigrationStateListener> listeners,
-      ListeningExecutorService executor,
-      ImmutableList<Project.NameKey> projects,
-      ImmutableList<Change.Id> changes,
-      OutputStream progressOut,
-      NotesMigrationState stopAtState,
-      boolean trial,
-      boolean forceRebuild,
-      int sequenceGap,
-      boolean autoMigrate)
-      throws MigrationException {
-    if (!changes.isEmpty() && !projects.isEmpty()) {
-      throw new MigrationException("Cannot set both changes and projects");
-    }
-    if (sequenceGap < 0) {
-      throw new MigrationException("Sequence gap must be non-negative: " + sequenceGap);
-    }
-
-    this.schemaFactory = schemaFactory;
-    this.serverIdent = serverIdent;
-    this.allUsers = allUsers;
-    this.rebuilder = rebuilder;
-    this.repoManager = repoManager;
-    this.updateManagerFactory = updateManagerFactory;
-    this.bundleReader = bundleReader;
-    this.allProjects = allProjects;
-    this.requestContext = requestContext;
-    this.userFactory = userFactory;
-    this.globalNotesMigration = globalNotesMigration;
-    this.primaryStorageMigrator = primaryStorageMigrator;
-    this.listeners = listeners;
-    this.executor = executor;
-    this.projects = projects;
-    this.changes = changes;
-    this.progressOut = progressOut;
-    this.stopAtState = stopAtState;
-    this.trial = trial;
-    this.forceRebuild = forceRebuild;
-    this.sequenceGap = sequenceGap;
-    this.autoMigrate = autoMigrate;
-
-    // Stack notedb.config over gerrit.config, in the same way as GerritServerConfigProvider.
-    this.gerritConfig = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
-    this.noteDbConfig =
-        new FileBasedConfig(gerritConfig, sitePaths.notedb_config.toFile(), FS.detect());
-  }
-
-  @Override
-  public void close() {
-    executor.shutdownNow();
-  }
-
-  public void migrate() throws OrmException, IOException {
-    if (!changes.isEmpty() || !projects.isEmpty()) {
-      throw new MigrationException(
-          "Cannot set changes or projects during full migration; call rebuild() instead");
-    }
-    Optional<NotesMigrationState> maybeState = loadState();
-    if (!maybeState.isPresent()) {
-      throw new MigrationException("Could not determine initial migration state");
-    }
-
-    NotesMigrationState state = maybeState.get();
-    if (trial && state.compareTo(READ_WRITE_NO_SEQUENCE) > 0) {
-      throw new MigrationException(
-          "Migration has already progressed past the endpoint of the \"trial mode\" state;"
-              + " NoteDb is already the primary storage for some changes");
-    }
-    if (forceRebuild && state.compareTo(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY) > 0) {
-      throw new MigrationException(
-          "Cannot force rebuild changes; NoteDb is already the primary storage for some changes");
-    }
-    setControlFlags();
-
-    boolean rebuilt = false;
-    while (state.compareTo(NOTE_DB) < 0) {
-      if (state.equals(stopAtState)) {
-        return;
-      }
-      boolean stillNeedsRebuild = forceRebuild && !rebuilt;
-      if (trial && state.compareTo(READ_WRITE_NO_SEQUENCE) >= 0) {
-        if (stillNeedsRebuild && state == READ_WRITE_NO_SEQUENCE) {
-          // We're at the end state of trial mode, but still need a rebuild due to forceRebuild. Let
-          // the loop go one more time.
-        } else {
-          return;
-        }
-      }
-      switch (state) {
-        case REVIEW_DB:
-          state = turnOnWrites(state);
-          break;
-        case WRITE:
-          state = rebuildAndEnableReads(state);
-          rebuilt = true;
-          break;
-        case READ_WRITE_NO_SEQUENCE:
-          if (stillNeedsRebuild) {
-            state = rebuildAndEnableReads(state);
-            rebuilt = true;
-          } else {
-            state = enableSequences(state);
-          }
-          break;
-        case READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY:
-          if (stillNeedsRebuild) {
-            state = rebuildAndEnableReads(state);
-            rebuilt = true;
-          } else {
-            state = setNoteDbPrimary(state);
-          }
-          break;
-        case READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY:
-          // The only way we can get here is if there was a failure on a previous run of
-          // setNoteDbPrimary, since that method moves to NOTE_DB if it completes
-          // successfully. Assume that not all changes were converted and re-run the step.
-          // migrateToNoteDbPrimary is a relatively fast no-op for already-migrated changes, so this
-          // isn't actually repeating work.
-          state = setNoteDbPrimary(state);
-          break;
-        case NOTE_DB:
-          // Done!
-          break;
-        default:
-          throw new MigrationException(
-              "Migration out of the following state is not supported:\n" + state.toText());
-      }
-    }
-  }
-
-  private NotesMigrationState turnOnWrites(NotesMigrationState prev) throws IOException {
-    return saveState(prev, WRITE);
-  }
-
-  private NotesMigrationState rebuildAndEnableReads(NotesMigrationState prev)
-      throws OrmException, IOException {
-    rebuild();
-    return saveState(prev, READ_WRITE_NO_SEQUENCE);
-  }
-
-  private NotesMigrationState enableSequences(NotesMigrationState prev)
-      throws OrmException, IOException {
-    try (ReviewDb db = schemaFactory.open()) {
-      @SuppressWarnings("deprecation")
-      final int nextChangeId = db.nextChangeId();
-
-      RepoSequence seq =
-          new RepoSequence(
-              repoManager,
-              GitReferenceUpdated.DISABLED,
-              allProjects,
-              Sequences.NAME_CHANGES,
-              // If sequenceGap is 0, this writes into the sequence ref the same ID that is returned
-              // by the call to seq.next() below. If we actually used this as a change ID, that
-              // would be a problem, but we just discard it, so this is safe.
-              () -> nextChangeId + sequenceGap - 1,
-              1,
-              nextChangeId);
-      seq.next();
-    }
-    return saveState(prev, READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
-  }
-
-  private NotesMigrationState setNoteDbPrimary(NotesMigrationState prev)
-      throws MigrationException, OrmException, IOException {
-    checkState(
-        projects.isEmpty() && changes.isEmpty(),
-        "Should not have attempted setNoteDbPrimary with a subset of changes");
-    checkState(
-        prev == READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY
-            || prev == READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY,
-        "Unexpected start state for setNoteDbPrimary: %s",
-        prev);
-
-    // Before changing the primary storage of old changes, ensure new changes are created with
-    // NoteDb primary.
-    prev = saveState(prev, READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
-
-    Stopwatch sw = Stopwatch.createStarted();
-    logger.atInfo().log("Setting primary storage to NoteDb");
-    List<Change.Id> allChanges;
-    try (ReviewDb db = unwrapDb(schemaFactory.open())) {
-      allChanges = Streams.stream(db.changes().all()).map(Change::getId).collect(toList());
-    }
-
-    try (ContextHelper contextHelper = new ContextHelper()) {
-      List<ListenableFuture<Boolean>> futures =
-          allChanges
-              .stream()
-              .map(
-                  id ->
-                      executor.submit(
-                          () -> {
-                            try (ManualRequestContext ctx = contextHelper.open()) {
-                              try {
-                                primaryStorageMigrator.migrateToNoteDbPrimary(id);
-                              } catch (NoNoteDbStateException e) {
-                                if (canSkipPrimaryStorageMigration(
-                                    ctx.getReviewDbProvider().get(), id)) {
-                                  logger.atWarning().withCause(e).log(
-                                      "Change %s previously failed to rebuild;"
-                                          + " skipping primary storage migration",
-                                      id);
-                                } else {
-                                  throw e;
-                                }
-                              }
-                              return true;
-                            } catch (Exception e) {
-                              logger.atSevere().withCause(e).log(
-                                  "Error migrating primary storage for %s", id);
-                              return false;
-                            }
-                          }))
-              .collect(toList());
-
-      boolean ok = futuresToBoolean(futures, "Error migrating primary storage");
-      double t = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
-      logger.atInfo().log(
-          "Migrated primary storage of %d changes in %.01fs (%.01f/s)\n",
-          allChanges.size(), t, allChanges.size() / t);
-      if (!ok) {
-        throw new MigrationException("Migrating primary storage for some changes failed, see log");
-      }
-    }
-
-    return disableReviewDb(prev);
-  }
-
-  /**
-   * Checks whether a change is so corrupt that it can be completely skipped by the primary storage
-   * migration step.
-   *
-   * <p>To get to the point where this method is called from {@link #setNoteDbPrimary}, it means we
-   * attempted to rebuild it, and encountered an error that was then caught in {@link
-   * #rebuildProject} and skipped. As a result, there is no {@code noteDbState} field in the change
-   * by the time we get to {@link #setNoteDbPrimary}, so {@code migrateToNoteDbPrimary} throws an
-   * exception.
-   *
-   * <p>We have to do this hacky double-checking because we don't have a way for the rebuilding
-   * phase to communicate to the primary storage migration phase that the change is skippable. It
-   * would be possible to store this info in some field in this class, but there is no guarantee
-   * that the rebuild and primary storage migration phases are run in the same JVM invocation.
-   *
-   * <p>In an ideal world, we could do this through the {@link
-   * com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage} enum, having a separate value
-   * for errors. However, that would be an invasive change touching many non-migration-related parts
-   * of the NoteDb migration code, which is too risky to attempt in the stable branch where this bug
-   * had to be fixed.
-   *
-   * <p>As of this writing, the only case where this happens is when a change has no patch sets.
-   */
-  private static boolean canSkipPrimaryStorageMigration(ReviewDb db, Change.Id id) {
-    try {
-      return Iterables.isEmpty(unwrapDb(db).patchSets().byChange(id));
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log(
-          "Error checking if change %s can be skipped, assuming no", id);
-      return false;
-    }
-  }
-
-  private NotesMigrationState disableReviewDb(NotesMigrationState prev) throws IOException {
-    return saveState(prev, NOTE_DB, c -> setAutoMigrate(c, false));
-  }
-
-  private Optional<NotesMigrationState> loadState() throws IOException {
-    try {
-      gerritConfig.load();
-      noteDbConfig.load();
-      return NotesMigrationState.forConfig(noteDbConfig);
-    } catch (ConfigInvalidException | IllegalArgumentException e) {
-      logger.atWarning().withCause(e).log(
-          "error reading NoteDb migration options from %s", noteDbConfig.getFile());
-      return Optional.empty();
-    }
-  }
-
-  private NotesMigrationState saveState(
-      NotesMigrationState expectedOldState, NotesMigrationState newState) throws IOException {
-    return saveState(expectedOldState, newState, c -> {});
-  }
-
-  private NotesMigrationState saveState(
-      NotesMigrationState expectedOldState,
-      NotesMigrationState newState,
-      Consumer<Config> additionalUpdates)
-      throws IOException {
-    synchronized (globalNotesMigration) {
-      // This read-modify-write is racy. We're counting on the fact that no other Gerrit operation
-      // modifies gerrit.config, and hoping that admins don't either.
-      Optional<NotesMigrationState> actualOldState = loadState();
-      if (!actualOldState.equals(Optional.of(expectedOldState))) {
-        throw new MigrationException(
-            "Cannot move to new state:\n"
-                + newState.toText()
-                + "\n\n"
-                + "Expected this state in gerrit.config:\n"
-                + expectedOldState.toText()
-                + "\n\n"
-                + (actualOldState.isPresent()
-                    ? "But found this state:\n" + actualOldState.get().toText()
-                    : "But could not parse the current state"));
-      }
-
-      preStateChange(expectedOldState, newState);
-
-      newState.setConfigValues(noteDbConfig);
-      additionalUpdates.accept(noteDbConfig);
-      noteDbConfig.save();
-
-      // Only set in-memory state once it's been persisted to storage.
-      globalNotesMigration.setFrom(newState);
-      logger.atInfo().log("Migration state: %s => %s", expectedOldState, newState);
-
-      return newState;
-    }
-  }
-
-  private void preStateChange(NotesMigrationState oldState, NotesMigrationState newState)
-      throws IOException {
-    for (NotesMigrationStateListener listener : listeners) {
-      listener.preStateChange(oldState, newState);
-    }
-  }
-
-  private void setControlFlags() throws MigrationException {
-    synchronized (globalNotesMigration) {
-      try {
-        noteDbConfig.load();
-        setAutoMigrate(noteDbConfig, autoMigrate);
-        setTrialMode(noteDbConfig, trial);
-        noteDbConfig.save();
-      } catch (ConfigInvalidException | IOException e) {
-        throw new MigrationException("Error saving auto-migration config", e);
-      }
-    }
-  }
-
-  public void rebuild() throws MigrationException, OrmException {
-    if (!globalNotesMigration.commitChangeWrites()) {
-      throw new MigrationException("Cannot rebuild without noteDb.changes.write=true");
-    }
-    Stopwatch sw = Stopwatch.createStarted();
-    logger.atInfo().log("Rebuilding changes in NoteDb");
-
-    ImmutableListMultimap<Project.NameKey, Change.Id> changesByProject = getChangesByProject();
-    List<ListenableFuture<Boolean>> futures = new ArrayList<>();
-    try (ContextHelper contextHelper = new ContextHelper()) {
-      List<Project.NameKey> projectNames =
-          Ordering.usingToString().sortedCopy(changesByProject.keySet());
-      for (Project.NameKey project : projectNames) {
-        ListenableFuture<Boolean> future =
-            executor.submit(
-                () -> {
-                  try {
-                    return rebuildProject(contextHelper.getReviewDb(), changesByProject, project);
-                  } catch (Exception e) {
-                    logger.atSevere().withCause(e).log("Error rebuilding project %s", project);
-                    return false;
-                  }
-                });
-        futures.add(future);
-      }
-
-      boolean ok = futuresToBoolean(futures, "Error rebuilding projects");
-      double t = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
-      logger.atInfo().log(
-          "Rebuilt %d changes in %.01fs (%.01f/s)\n",
-          changesByProject.size(), t, changesByProject.size() / t);
-      if (!ok) {
-        throw new MigrationException("Rebuilding some changes failed, see log");
-      }
-    }
-  }
-
-  private ImmutableListMultimap<Project.NameKey, Change.Id> getChangesByProject()
-      throws OrmException {
-    // Memoize all changes so we can close the db connection and allow other threads to use the full
-    // connection pool.
-    SetMultimap<Project.NameKey, Change.Id> out =
-        MultimapBuilder.treeKeys(comparing(Project.NameKey::get))
-            .treeSetValues(comparing(Change.Id::get))
-            .build();
-    try (ReviewDb db = unwrapDb(schemaFactory.open())) {
-      if (!projects.isEmpty()) {
-        return byProject(db.changes().all(), c -> projects.contains(c.getProject()), out);
-      }
-      if (!changes.isEmpty()) {
-        return byProject(db.changes().get(changes), c -> true, out);
-      }
-      return byProject(db.changes().all(), c -> true, out);
-    }
-  }
-
-  private static ImmutableListMultimap<Project.NameKey, Change.Id> byProject(
-      Iterable<Change> changes,
-      Predicate<Change> pred,
-      SetMultimap<Project.NameKey, Change.Id> out) {
-    Streams.stream(changes).filter(pred).forEach(c -> out.put(c.getProject(), c.getId()));
-    return ImmutableListMultimap.copyOf(out);
-  }
-
-  private static ObjectInserter newPackInserter(Repository repo) {
-    if (!(repo instanceof FileRepository)) {
-      return repo.newObjectInserter();
-    }
-    PackInserter ins = ((FileRepository) repo).getObjectDatabase().newPackInserter();
-    ins.checkExisting(false);
-    return ins;
-  }
-
-  private boolean rebuildProject(
-      ReviewDb db,
-      ImmutableListMultimap<Project.NameKey, Change.Id> allChanges,
-      Project.NameKey project) {
-    checkArgument(allChanges.containsKey(project));
-    boolean ok = true;
-    ProgressMonitor pm =
-        new TextProgressMonitor(
-            new PrintWriter(new BufferedWriter(new OutputStreamWriter(progressOut, UTF_8))));
-    try (Repository changeRepo = repoManager.openRepository(project);
-        // Only use a PackInserter for the change repo, not All-Users.
-        //
-        // It's not possible to share a single inserter for All-Users across all project tasks, and
-        // we don't want to add one pack per project to All-Users. Adding many loose objects is
-        // preferable to many packs.
-        //
-        // Anyway, the number of objects inserted into All-Users is proportional to the number
-        // of pending draft comments, which should not be high (relative to the total number of
-        // changes), so the number of loose objects shouldn't be too unreasonable.
-        ObjectInserter changeIns = newPackInserter(changeRepo);
-        ObjectReader changeReader = changeIns.newReader();
-        RevWalk changeRw = new RevWalk(changeReader);
-        Repository allUsersRepo = repoManager.openRepository(allUsers);
-        ObjectInserter allUsersIns = allUsersRepo.newObjectInserter();
-        ObjectReader allUsersReader = allUsersIns.newReader();
-        RevWalk allUsersRw = new RevWalk(allUsersReader)) {
-      ChainedReceiveCommands changeCmds = new ChainedReceiveCommands(changeRepo);
-      ChainedReceiveCommands allUsersCmds = new ChainedReceiveCommands(allUsersRepo);
-
-      Collection<Change.Id> changes = allChanges.get(project);
-      pm.beginTask(FormatUtil.elide("Rebuilding " + project.get(), 50), changes.size());
-      int toSave = 0;
-      try {
-        for (Change.Id changeId : changes) {
-          // NoteDbUpdateManager assumes that all commands in its OpenRepo were added by itself, so
-          // we can't share the top-level ChainedReceiveCommands. Use a new set of commands sharing
-          // the same underlying repo, and copy commands back to the top-level
-          // ChainedReceiveCommands later. This also assumes that each ref in the final list of
-          // commands was only modified by a single NoteDbUpdateManager; since we use one manager
-          // per change, and each ref corresponds to exactly one change, this assumption should be
-          // safe.
-          ChainedReceiveCommands tmpChangeCmds =
-              new ChainedReceiveCommands(changeCmds.getRepoRefCache());
-          ChainedReceiveCommands tmpAllUsersCmds =
-              new ChainedReceiveCommands(allUsersCmds.getRepoRefCache());
-
-          try (NoteDbUpdateManager manager =
-              updateManagerFactory
-                  .create(project)
-                  .setAtomicRefUpdates(false)
-                  .setSaveObjects(false)
-                  .setChangeRepo(changeRepo, changeRw, changeIns, tmpChangeCmds)
-                  .setAllUsersRepo(allUsersRepo, allUsersRw, allUsersIns, tmpAllUsersCmds)) {
-            rebuild(db, changeId, manager);
-
-            // Executing with dryRun=true writes all objects to the underlying inserters and adds
-            // commands to the ChainedReceiveCommands. Afterwards, we can discard the manager, so we
-            // don't keep using any memory beyond what may be buffered in the PackInserter.
-            manager.execute(true);
-
-            tmpChangeCmds.getCommands().values().forEach(c -> addCommand(changeCmds, c));
-            tmpAllUsersCmds.getCommands().values().forEach(c -> addCommand(allUsersCmds, c));
-
-            toSave++;
-          } catch (NoPatchSetsException e) {
-            logger.atWarning().log(e.getMessage());
-          } catch (ConflictingUpdateException ex) {
-            logger.atWarning().log(
-                "Rebuilding detected a conflicting ReviewDb update for change %s;"
-                    + " will be auto-rebuilt at runtime",
-                changeId);
-          } catch (Throwable t) {
-            logger.atSevere().withCause(t).log("Failed to rebuild change %s", changeId);
-            ok = false;
-          }
-          pm.update(1);
-        }
-      } finally {
-        pm.endTask();
-      }
-
-      pm.beginTask(FormatUtil.elide("Saving " + project.get(), 50), ProgressMonitor.UNKNOWN);
-      try {
-        save(changeRepo, changeRw, changeIns, changeCmds);
-        save(allUsersRepo, allUsersRw, allUsersIns, allUsersCmds);
-        // This isn't really useful progress. If we passed a real ProgressMonitor to
-        // BatchRefUpdate#execute we might get something more incremental, but that doesn't allow us
-        // to specify the repo name in the task text.
-        pm.update(toSave);
-      } catch (LockFailureException e) {
-        logger.atWarning().log(
-            "Rebuilding detected a conflicting NoteDb update for the following refs, which will"
-                + " be auto-rebuilt at runtime: %s",
-            e.getFailedRefs().stream().distinct().sorted().collect(joining(", ")));
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Failed to save NoteDb state for %s", project);
-      } finally {
-        pm.endTask();
-      }
-    } catch (RepositoryNotFoundException e) {
-      logger.atWarning().log("Repository %s not found", project);
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Failed to rebuild project %s", project);
-    }
-    return ok;
-  }
-
-  private void rebuild(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
-      throws OrmException, IOException {
-    // Match ChangeRebuilderImpl#stage, but without calling manager.stage(), since that can only be
-    // called after building updates for all changes.
-    Change change =
-        ChangeRebuilderImpl.checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
-    if (change == null) {
-      // Could log here instead, but this matches the behavior of ChangeRebuilderImpl#stage.
-      throw new NoSuchChangeException(changeId);
-    }
-    rebuilder.buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
-
-    rebuilder.execute(db, changeId, manager, true, false);
-  }
-
-  private static void addCommand(ChainedReceiveCommands cmds, ReceiveCommand cmd) {
-    // ChainedReceiveCommands doesn't allow no-ops, but these occur when rebuilding a
-    // previously-rebuilt change.
-    if (!cmd.getOldId().equals(cmd.getNewId())) {
-      cmds.add(cmd);
-    }
-  }
-
-  private void save(Repository repo, RevWalk rw, ObjectInserter ins, ChainedReceiveCommands cmds)
-      throws IOException {
-    if (cmds.isEmpty()) {
-      return;
-    }
-    ins.flush();
-    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-    bru.setRefLogMessage("Migrate changes to NoteDb", false);
-    bru.setRefLogIdent(serverIdent.get());
-    bru.setAtomic(false);
-    bru.setAllowNonFastForwards(true);
-    cmds.addTo(bru);
-    RefUpdateUtil.executeChecked(bru, rw);
-  }
-
-  private static boolean futuresToBoolean(List<ListenableFuture<Boolean>> futures, String errMsg) {
-    try {
-      return Futures.allAsList(futures).get().stream().allMatch(b -> b);
-    } catch (InterruptedException | ExecutionException e) {
-      logger.atSevere().withCause(e).log(errMsg);
-      return false;
-    }
-  }
-
-  private class ContextHelper implements AutoCloseable {
-    private final Thread callingThread;
-    private ReviewDb db;
-    private Runnable closeDb;
-
-    ContextHelper() {
-      callingThread = Thread.currentThread();
-    }
-
-    ManualRequestContext open() throws OrmException {
-      return new ManualRequestContext(
-          userFactory.create(),
-          // Reuse the same lazily-opened ReviewDb on the original calling thread, otherwise open
-          // SchemaFactory in the normal way.
-          Thread.currentThread().equals(callingThread) ? this::getReviewDb : schemaFactory,
-          requestContext);
-    }
-
-    synchronized ReviewDb getReviewDb() throws OrmException {
-      if (db == null) {
-        ReviewDb actual = schemaFactory.open();
-        closeDb = actual::close;
-        db =
-            new ReviewDbWrapper(unwrapDb(actual)) {
-              @Override
-              public void close() {
-                // Closed by ContextHelper#close.
-              }
-            };
-      }
-      return db;
-    }
-
-    @Override
-    public synchronized void close() {
-      if (db != null) {
-        closeDb.run();
-        db = null;
-        closeDb = null;
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java b/java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java
deleted file mode 100644
index aef30a2..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java
+++ /dev/null
@@ -1,35 +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.server.notedb.rebuild;
-
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-import java.io.IOException;
-
-/** Listener for state changes performed by {@link OnlineNoteDbMigrator}. */
-@ExtensionPoint
-public interface NotesMigrationStateListener {
-  /**
-   * Invoked just before saving the new migration state.
-   *
-   * @param oldState state prior to this state change.
-   * @param newState state after this state change.
-   * @throws IOException if an error occurred, which will cause the migration to abort. Exceptions
-   *     that should be considered non-fatal must be caught (and ideally logged) by the
-   *     implementation rather than thrown.
-   */
-  void preStateChange(NotesMigrationState oldState, NotesMigrationState newState)
-      throws IOException;
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java
deleted file mode 100644
index b5a8236..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java
+++ /dev/null
@@ -1,108 +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.server.notedb.rebuild;
-
-import com.google.common.base.Stopwatch;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.OnlineUpgrader;
-import com.google.gerrit.server.index.VersionManager;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.name.Named;
-import com.google.inject.name.Names;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class OnlineNoteDbMigrator implements LifecycleListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final String TRIAL = "OnlineNoteDbMigrator/trial";
-
-  public static class Module extends LifecycleModule {
-    private final boolean trial;
-
-    public Module(boolean trial) {
-      this.trial = trial;
-    }
-
-    @Override
-    public void configure() {
-      listener().to(OnlineNoteDbMigrator.class);
-      bindConstant().annotatedWith(Names.named(TRIAL)).to(trial);
-    }
-  }
-
-  private final GcAllUsers gcAllUsers;
-  private final OnlineUpgrader indexUpgrader;
-  private final Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
-  private final boolean upgradeIndex;
-  private final boolean trial;
-
-  @Inject
-  OnlineNoteDbMigrator(
-      @GerritServerConfig Config cfg,
-      GcAllUsers gcAllUsers,
-      OnlineUpgrader indexUpgrader,
-      Provider<NoteDbMigrator.Builder> migratorBuilderProvider,
-      @Named(TRIAL) boolean trial) {
-    this.gcAllUsers = gcAllUsers;
-    this.indexUpgrader = indexUpgrader;
-    this.migratorBuilderProvider = migratorBuilderProvider;
-    this.upgradeIndex = VersionManager.getOnlineUpgrade(cfg);
-    this.trial = trial || NoteDbMigrator.getTrialMode(cfg);
-  }
-
-  @Override
-  public void start() {
-    Thread t = new Thread(this::migrate);
-    t.setDaemon(true);
-    t.setName(getClass().getSimpleName());
-    t.start();
-  }
-
-  private void migrate() {
-    logger.atInfo().log("Starting online NoteDb migration");
-    if (upgradeIndex) {
-      logger.atInfo().log(
-          "Online index schema upgrades will be deferred until NoteDb migration is complete");
-    }
-    Stopwatch sw = Stopwatch.createStarted();
-    // TODO(dborowitz): Tune threads, maybe expose a progress monitor somewhere.
-    try (NoteDbMigrator migrator =
-        migratorBuilderProvider.get().setAutoMigrate(true).setTrialMode(trial).build()) {
-      migrator.migrate();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Error in online NoteDb migration");
-    }
-    gcAllUsers.runWithLogger();
-    logger.atInfo().log("Online NoteDb migration completed in %ss", sw.elapsed(TimeUnit.SECONDS));
-
-    if (upgradeIndex) {
-      logger.atInfo().log("Starting deferred index schema upgrades");
-      indexUpgrader.start();
-    }
-  }
-
-  @Override
-  public void stop() {
-    // Do nothing; upgrade process uses daemon threads and knows how to recover from failures on
-    // next attempt.
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java b/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
deleted file mode 100644
index acb80c0..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-class PatchSetEvent extends Event {
-  private final Change change;
-  private final PatchSet ps;
-  private final RevWalk rw;
-  boolean createChange;
-
-  PatchSetEvent(Change change, PatchSet ps, RevWalk rw) {
-    super(
-        ps.getId(),
-        ps.getUploader(),
-        ps.getUploader(),
-        ps.getCreatedOn(),
-        change.getCreatedOn(),
-        null);
-    this.change = change;
-    this.ps = ps;
-    this.rw = rw;
-  }
-
-  @Override
-  boolean uniquePerUpdate() {
-    return true;
-  }
-
-  @Override
-  void apply(ChangeUpdate update) throws IOException, OrmException {
-    checkUpdate(update);
-    if (createChange) {
-      ChangeRebuilderImpl.createChange(update, change);
-    } else {
-      update.setSubject(change.getSubject());
-      update.setSubjectForCommit("Create patch set " + ps.getPatchSetId());
-    }
-    setRevision(update, ps);
-    update.setPsDescription(ps.getDescription());
-    List<String> groups = ps.getGroups();
-    if (!groups.isEmpty()) {
-      update.setGroups(ps.getGroups());
-    }
-  }
-
-  private void setRevision(ChangeUpdate update, PatchSet ps) throws IOException {
-    String rev = ps.getRevision().get();
-    String cert = ps.getPushCertificate();
-    ObjectId id;
-    try {
-      id = ObjectId.fromString(rev);
-    } catch (InvalidObjectIdException e) {
-      update.setRevisionForMissingCommit(rev, cert);
-      return;
-    }
-    try {
-      update.setCommit(rw, id, cert);
-    } catch (MissingObjectException e) {
-      update.setRevisionForMissingCommit(rev, cert);
-      return;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java b/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
deleted file mode 100644
index 2ecf969..0000000
--- a/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import com.google.common.base.MoreObjects.ToStringHelper;
-import com.google.common.collect.Table;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.sql.Timestamp;
-
-class ReviewerEvent extends Event {
-  private Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer;
-
-  ReviewerEvent(
-      Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer,
-      Timestamp changeCreatedOn) {
-    super(
-        // Reviewers aren't generally associated with a particular patch set
-        // (although as an implementation detail they were in ReviewDb). Just
-        // use the latest patch set at the time of the event.
-        null,
-        reviewer.getColumnKey(),
-        // TODO(dborowitz): Real account ID shouldn't really matter for
-        // reviewers, but we might have to deal with this to avoid ChangeBundle
-        // diffs when run against real data.
-        reviewer.getColumnKey(),
-        reviewer.getValue(),
-        changeCreatedOn,
-        null);
-    this.reviewer = reviewer;
-  }
-
-  @Override
-  boolean uniquePerUpdate() {
-    return false;
-  }
-
-  @Override
-  void apply(ChangeUpdate update) throws IOException, OrmException {
-    checkUpdate(update);
-    update.putReviewer(reviewer.getColumnKey(), reviewer.getRowKey());
-  }
-
-  @Override
-  protected void addToString(ToStringHelper helper) {
-    helper.add("account", reviewer.getColumnKey()).add("state", reviewer.getRowKey());
-  }
-}
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index f1b6639..18aa8b9 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -15,26 +15,20 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.MergeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.io.InputStream;
-import java.util.HashMap;
-import java.util.Map;
-import org.eclipse.jgit.diff.Sequence;
 import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -42,18 +36,16 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.MergeFormatter;
-import org.eclipse.jgit.merge.MergeResult;
 import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.TemporaryBuffer;
 
 public class AutoMerger {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
@@ -125,84 +117,17 @@
     ObjectId treeId;
     if (couldMerge) {
       treeId = m.getResultTreeId();
-
     } else {
-      RevCommit ours = merge.getParent(0);
-      RevCommit theirs = merge.getParent(1);
-      rw.parseBody(ours);
-      rw.parseBody(theirs);
-      String oursMsg = ours.getShortMessage();
-      String theirsMsg = theirs.getShortMessage();
-
-      String oursName =
-          String.format(
-              "HEAD   (%s %s)",
-              ours.abbreviate(6).name(), oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
-      String theirsName =
-          String.format(
-              "BRANCH (%s %s)",
-              theirs.abbreviate(6).name(),
-              theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
-
-      MergeFormatter fmt = new MergeFormatter();
-      Map<String, MergeResult<? extends Sequence>> r = m.getMergeResults();
-      Map<String, ObjectId> resolved = new HashMap<>();
-      for (Map.Entry<String, MergeResult<? extends Sequence>> entry : r.entrySet()) {
-        MergeResult<? extends Sequence> p = entry.getValue();
-        try (TemporaryBuffer buf = new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024)) {
-          fmt.formatMerge(buf, p, "BASE", oursName, theirsName, UTF_8.name());
-          buf.close();
-
-          try (InputStream in = buf.openInputStream()) {
-            resolved.put(entry.getKey(), ins.insert(Constants.OBJ_BLOB, buf.length(), in));
-          }
-        }
-      }
-
-      DirCacheBuilder builder = dc.builder();
-      int cnt = dc.getEntryCount();
-      for (int i = 0; i < cnt; ) {
-        DirCacheEntry entry = dc.getEntry(i);
-        if (entry.getStage() == 0) {
-          builder.add(entry);
-          i++;
-          continue;
-        }
-
-        int next = dc.nextEntry(i);
-        String path = entry.getPathString();
-        DirCacheEntry res = new DirCacheEntry(path);
-        if (resolved.containsKey(path)) {
-          // For a file with content merge conflict that we produced a result
-          // above on, collapse the file down to a single stage 0 with just
-          // the blob content, and a randomly selected mode (the lowest stage,
-          // which should be the merge base, or ours).
-          res.setFileMode(entry.getFileMode());
-          res.setObjectId(resolved.get(path));
-
-        } else if (next == i + 1) {
-          // If there is exactly one stage present, shouldn't be a conflict...
-          res.setFileMode(entry.getFileMode());
-          res.setObjectId(entry.getObjectId());
-
-        } else if (next == i + 2) {
-          // Two stages suggests a delete/modify conflict. Pick the higher
-          // stage as the automatic result.
-          entry = dc.getEntry(i + 1);
-          res.setFileMode(entry.getFileMode());
-          res.setObjectId(entry.getObjectId());
-
-        } else {
-          // 3 stage conflict, no resolve above
-          // Punt on the 3-stage conflict and show the base, for now.
-          res.setFileMode(entry.getFileMode());
-          res.setObjectId(entry.getObjectId());
-        }
-        builder.add(res);
-        i = next;
-      }
-      builder.finish();
-      treeId = dc.writeTree(ins);
+      treeId =
+          MergeUtil.mergeWithConflicts(
+              rw,
+              ins,
+              dc,
+              "HEAD",
+              merge.getParent(0),
+              "BRANCH",
+              merge.getParent(1),
+              m.getMergeResults());
     }
 
     return commit(repo, rw, tmpIns, ins, refName, treeId, merge);
diff --git a/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
index abbb680..260c507 100644
--- a/java/com/google/gerrit/server/patch/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.patch;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
+import static java.util.Objects.requireNonNull;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -59,7 +59,7 @@
   }
 
   public int getParentNum() {
-    checkNotNull(parentNum);
+    requireNonNull(parentNum);
     return parentNum;
   }
 
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/EditTransformer.java b/java/com/google/gerrit/server/patch/EditTransformer.java
index 9083ede..90f442e 100644
--- a/java/com/google/gerrit/server/patch/EditTransformer.java
+++ b/java/com/google/gerrit/server/patch/EditTransformer.java
@@ -89,8 +89,7 @@
    * @return the transformed edits per file path
    */
   public Multimap<String, ContextAwareEdit> getEditsPerFilePath() {
-    return edits
-        .stream()
+    return edits.stream()
         .collect(
             toMultimap(
                 ContextAwareEdit::getNewFilePath, Function.identity(), ArrayListMultimap::create));
@@ -112,9 +111,7 @@
         transformingEntries.stream().collect(groupingBy(EditTransformer::getOldFilePath));
 
     edits =
-        editsPerFilePath
-            .entrySet()
-            .stream()
+        editsPerFilePath.entrySet().stream()
             .flatMap(
                 pathAndEdits -> {
                   List<PatchListEntry> transEntries =
@@ -137,12 +134,11 @@
     }
 
     // TODO(aliceks): Find a way to prevent an explosion of the number of entries.
-    return transformingEntries
-        .stream()
+    return transformingEntries.stream()
         .flatMap(
             transEntry ->
                 transformEdits(
-                        sideStrategy, originalEdits, transEntry.getEdits(), transEntry.getNewName())
+                    sideStrategy, originalEdits, transEntry.getEdits(), transEntry.getNewName())
                     .stream());
   }
 
diff --git a/java/com/google/gerrit/server/patch/IntraLineDiff.java b/java/com/google/gerrit/server/patch/IntraLineDiff.java
index a182335..1c3d78a 100644
--- a/java/com/google/gerrit/server/patch/IntraLineDiff.java
+++ b/java/com/google/gerrit/server/patch/IntraLineDiff.java
@@ -21,6 +21,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.jgit.diff.ReplaceEdit;
 import com.google.gerrit.reviewdb.client.CodedEnum;
 import java.io.IOException;
 import java.io.InputStream;
@@ -32,7 +33,6 @@
 import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.diff.ReplaceEdit;
 
 public class IntraLineDiff implements Serializable {
   static final long serialVersionUID = IntraLineDiffKey.serialVersionUID;
diff --git a/java/com/google/gerrit/server/patch/IntraLineDiffKey.java b/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
index 06e2c45..ccf4e6b 100644
--- a/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
+++ b/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
@@ -21,7 +21,7 @@
 
 @AutoValue
 public abstract class IntraLineDiffKey implements Serializable {
-  public static final long serialVersionUID = 12L;
+  public static final long serialVersionUID = 13L;
 
   public static IntraLineDiffKey create(ObjectId aId, ObjectId bId, Whitespace whitespace) {
     return new AutoValue_IntraLineDiffKey(aId, bId, whitespace);
diff --git a/java/com/google/gerrit/server/patch/IntraLineLoader.java b/java/com/google/gerrit/server/patch/IntraLineLoader.java
index 022fd9e..34ac3d8 100644
--- a/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.jgit.diff.ReplaceEdit;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -34,7 +35,6 @@
 import java.util.regex.Pattern;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.MyersDiff;
-import org.eclipse.jgit.diff.ReplaceEdit;
 import org.eclipse.jgit.lib.Config;
 
 class IntraLineLoader implements Callable<IntraLineDiff> {
diff --git a/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
index aff519a..80f9ba0 100644
--- a/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -16,7 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.common.errors.NoSuchEntityException;
+import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.reviewdb.client.Patch;
 import java.io.IOException;
 import org.eclipse.jgit.errors.CorruptObjectException;
@@ -39,6 +39,15 @@
   private final RevTree aTree;
   private final RevTree bTree;
 
+  // Full text of both sides of the file. For standard files, these are not directly reconstructable
+  // from the PatchListEntry, which comes from the PatchListCache and only contains the diff between
+  // the two blobs. This is intentional, to avoid storing entire large blobs in the cache. For
+  // regular files, the full text is initialized from the repo lazily only when necessary, e.g. in
+  // getLine. Although it's a safe assumption that any caller constructing a PatchSet will want to
+  // read some content, we don't know in advance which side they are interested in.
+  //
+  // For special files like COMMIT_MSG, the full text is loaded eagerly during the constructor.
+  // TODO(dborowitz): I see why the logic is different, but I don't see why it needs to be eager.
   private Text a;
   private Text b;
 
@@ -87,6 +96,14 @@
     }
   }
 
+  private String getOldName() {
+    String name = entry.getOldName();
+    if (name != null) {
+      return name;
+    }
+    return entry.getNewName();
+  }
+
   /**
    * Extract a line from the file, as a string.
    *
@@ -100,7 +117,7 @@
     switch (file) {
       case 0:
         if (a == null) {
-          a = load(aTree, entry.getOldName());
+          a = load(aTree, getOldName());
         }
         return a.getString(line - 1);
 
@@ -115,33 +132,6 @@
     }
   }
 
-  /**
-   * Return number of lines in file.
-   *
-   * @param file the file index to extract.
-   * @return number of lines in file.
-   * @throws IOException the patch or complete file content cannot be read.
-   * @throws NoSuchEntityException the file is not exist.
-   */
-  public int getLineCount(int file) throws IOException, NoSuchEntityException {
-    switch (file) {
-      case 0:
-        if (a == null) {
-          a = load(aTree, entry.getOldName());
-        }
-        return a.size();
-
-      case 1:
-        if (b == null) {
-          b = load(bTree, entry.getNewName());
-        }
-        return b.size();
-
-      default:
-        throw new NoSuchEntityException();
-    }
-  }
-
   private Text load(ObjectId tree, String path)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           IOException {
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index cf5df4a..35df1f5 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -25,6 +25,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import java.io.ByteArrayInputStream;
@@ -47,12 +48,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) {
@@ -83,7 +79,7 @@
       boolean isMerge,
       ComparisonType comparisonType,
       PatchListEntry[] patches) {
-    this.oldId = oldId != null ? oldId.copy() : null;
+    this.oldId = ObjectIds.copyOrNull(oldId);
     this.newId = newId.copy();
     this.isMerge = isMerge;
     this.comparisonType = comparisonType;
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index 6039fff..8201947 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -134,10 +134,7 @@
   private PatchList get(Change change, PatchSet patchSet, Integer parentNum)
       throws PatchListNotAvailableException {
     Project.NameKey project = change.getProject();
-    if (patchSet.getRevision() == null) {
-      throw new PatchListNotAvailableException("revision is null for " + patchSet.getId());
-    }
-    ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
+    ObjectId b = patchSet.commitId();
     Whitespace ws = Whitespace.IGNORE_NONE;
     if (parentNum != null) {
       return get(PatchListKey.againstParentNum(parentNum, b, ws), project);
diff --git a/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
index 96f66f6..7aa47c599 100644
--- a/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.patch;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readBytes;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readEnum;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readFixInt64;
@@ -24,6 +26,7 @@
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeFixInt64;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -39,7 +42,6 @@
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.patch.CombinedFileHeader;
 import org.eclipse.jgit.patch.FileHeader;
 import org.eclipse.jgit.util.IntList;
@@ -187,11 +189,13 @@
   }
 
   public ImmutableList<Edit> getEdits() {
-    return edits;
+    // Edits are mutable objects. As we serialize PatchListEntry asynchronously in H2CacheImpl, we
+    // must ensure that its state isn't modified until it was properly stored in the cache.
+    return deepCopyEdits(edits);
   }
 
   public ImmutableSet<Edit> getEditsDueToRebase() {
-    return editsDueToRebase;
+    return deepCopyEdits(editsDueToRebase);
   }
 
   public int getInsertions() {
@@ -219,13 +223,13 @@
       if (header[e - 1] == '\n') {
         e--;
       }
-      headerLines.add(RawParseUtils.decode(Constants.CHARSET, header, b, e));
+      headerLines.add(RawParseUtils.decode(UTF_8, header, b, e));
     }
     return headerLines;
   }
 
   Patch toPatch(PatchSet.Id setId) {
-    final Patch p = new Patch(new Patch.Key(setId, getNewName()));
+    final Patch p = new Patch(Patch.key(setId, getNewName()));
     p.setChangeType(getChangeType());
     p.setPatchType(getPatchType());
     p.setSourceFileName(getOldName());
@@ -234,6 +238,18 @@
     return p;
   }
 
+  private static ImmutableList<Edit> deepCopyEdits(List<Edit> edits) {
+    return edits.stream().map(PatchListEntry::copy).collect(toImmutableList());
+  }
+
+  private static ImmutableSet<Edit> deepCopyEdits(Set<Edit> edits) {
+    return edits.stream().map(PatchListEntry::copy).collect(toImmutableSet());
+  }
+
+  private static Edit copy(Edit edit) {
+    return new Edit(edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB());
+  }
+
   void writeTo(OutputStream out) throws IOException {
     writeEnum(out, changeType);
     writeEnum(out, patchType);
diff --git a/java/com/google/gerrit/server/patch/PatchListKey.java b/java/com/google/gerrit/server/patch/PatchListKey.java
index 083c142..bf38029 100644
--- a/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
@@ -32,7 +33,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 public class PatchListKey implements Serializable {
-  public static final long serialVersionUID = 31L;
+  public static final long serialVersionUID = 32L;
 
   public static final ImmutableBiMap<Whitespace, Character> WHITESPACE_TYPES =
       ImmutableBiMap.of(
@@ -82,7 +83,7 @@
   private transient Whitespace whitespace;
 
   private PatchListKey(AnyObjectId a, AnyObjectId b, Whitespace ws) {
-    oldId = a != null ? a.copy() : null;
+    oldId = ObjectIds.copyOrNull(a);
     newId = b.copy();
     whitespace = ws;
   }
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index 8301ee6..08de537 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -185,11 +185,11 @@
       df.setDetectRenames(true);
       List<DiffEntry> diffEntries = df.scan(aTree, bTree);
 
-      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath = ImmutableMultimap.of();
       EditsDueToRebaseResult editsDueToRebaseResult =
           determineEditsDueToRebase(aCommit, b, diffEntries, df, rw);
       diffEntries = editsDueToRebaseResult.getRelevantOriginalDiffEntries();
-      editsDueToRebasePerFilePath = editsDueToRebaseResult.getEditsDueToRebasePerFilePath();
+      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath =
+          editsDueToRebaseResult.getEditsDueToRebasePerFilePath();
 
       List<PatchListEntry> entries = new ArrayList<>();
       entries.add(
@@ -287,8 +287,7 @@
     }
 
     List<DiffEntry> relevantDiffEntries =
-        diffEntries
-            .stream()
+        diffEntries.stream()
             .filter(diffEntry -> isTouched(touchedFilePaths, diffEntry))
             .collect(toImmutableList());
 
@@ -397,8 +396,7 @@
   }
 
   private static Set<Edit> getContentEdits(Set<ContextAwareEdit> editsDueToRebase) {
-    return editsDueToRebase
-        .stream()
+    return editsDueToRebase.stream()
         .map(ContextAwareEdit::toEdit)
         .filter(Optional::isPresent)
         .map(Optional::get)
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 6f3e055..acf88e1 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.CommentDetail;
@@ -34,9 +35,10 @@
 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.Objects;
+import java.util.Optional;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -54,13 +56,7 @@
   static final int MAX_CONTEXT = 5000000;
   static final int BIG_FILE = 9000;
 
-  private static final Comparator<Edit> EDIT_SORT =
-      new Comparator<Edit>() {
-        @Override
-        public int compare(Edit o1, Edit o2) {
-          return o1.getBeginA() - o2.getBeginA();
-        }
-      };
+  private static final Comparator<Edit> EDIT_SORT = comparing(Edit::getBeginA);
 
   private Repository db;
   private Project.NameKey projectKey;
@@ -172,6 +168,8 @@
       }
     }
 
+    correctForDifferencesInNewlineAtEnd();
+
     if (comments != null) {
       ensureCommentsVisible(comments);
     }
@@ -277,6 +275,50 @@
     }
   }
 
+  private void correctForDifferencesInNewlineAtEnd() {
+    // a.src.size() is the size ignoring a newline at the end whereas a.size() considers it.
+    int aSize = a.src.size();
+    int bSize = b.src.size();
+
+    if (edits.isEmpty() && (aSize == 0 || bSize == 0)) {
+      // The diff was requested for a file which was either added or deleted but which JGit doesn't
+      // consider a file addition/deletion (e.g. requesting a diff for the old file name of a
+      // renamed file looks like a deletion).
+      return;
+    }
+
+    Optional<Edit> lastEdit = getLast(edits);
+    if (isNewlineAtEndDeleted()) {
+      Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndA() == aSize);
+      if (lastLineEdit.isPresent()) {
+        lastLineEdit.get().extendA();
+      } else {
+        Edit newlineEdit = new Edit(aSize, aSize + 1, bSize, bSize);
+        edits.add(newlineEdit);
+      }
+    } else if (isNewlineAtEndAdded()) {
+      Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndB() == bSize);
+      if (lastLineEdit.isPresent()) {
+        lastLineEdit.get().extendB();
+      } else {
+        Edit newlineEdit = new Edit(aSize, aSize, bSize, bSize + 1);
+        edits.add(newlineEdit);
+      }
+    }
+  }
+
+  private static <T> Optional<T> getLast(List<T> list) {
+    return list.isEmpty() ? Optional.empty() : Optional.ofNullable(list.get(list.size() - 1));
+  }
+
+  private boolean isNewlineAtEndDeleted() {
+    return !a.src.isMissingNewlineAtEnd() && b.src.isMissingNewlineAtEnd();
+  }
+
+  private boolean isNewlineAtEndAdded() {
+    return a.src.isMissingNewlineAtEnd() && !b.src.isMissingNewlineAtEnd();
+  }
+
   private void ensureCommentsVisible(CommentDetail comments) {
     if (comments.getCommentsA().isEmpty() && comments.getCommentsB().isEmpty()) {
       // No comments, no additional dummy edits are required.
@@ -322,7 +364,7 @@
     // them correctly later.
     //
     edits.addAll(empty);
-    Collections.sort(edits, EDIT_SORT);
+    edits.sort(EDIT_SORT);
   }
 
   private void safeAdd(List<Edit> empty, Edit toAdd) {
@@ -396,14 +438,14 @@
     for (EditList.Hunk hunk : list.getHunks()) {
       while (hunk.next()) {
         if (hunk.isContextLine()) {
-          final String lineA = a.src.getString(hunk.getCurA());
+          String lineA = a.getSourceLine(hunk.getCurA());
           a.dst.addLine(hunk.getCurA(), lineA);
 
           if (ignoredWhitespace) {
             // If we ignored whitespace in some form, also get the line
             // from b when it does not exactly match the line from a.
             //
-            final String lineB = b.src.getString(hunk.getCurB());
+            String lineB = b.getSourceLine(hunk.getCurB());
             if (!lineA.equals(lineB)) {
               b.dst.addLine(hunk.getCurB(), lineB);
             }
@@ -437,19 +479,29 @@
     final SparseFileContent dst = new SparseFileContent();
 
     int size() {
-      return src != null ? src.size() : 0;
+      if (src == null) {
+        return 0;
+      }
+      if (src.isMissingNewlineAtEnd()) {
+        return src.size();
+      }
+      return src.size() + 1;
     }
 
-    void addLine(int line) {
-      dst.addLine(line, src.getString(line));
+    void addLine(int lineNumber) {
+      String lineContent = getSourceLine(lineNumber);
+      dst.addLine(lineNumber, lineContent);
+    }
+
+    String getSourceLine(int lineNumber) {
+      return lineNumber >= src.size() ? "" : src.getString(lineNumber);
     }
 
     void resolve(Side other, ObjectId within) throws IOException {
       try {
         final boolean reuse;
         if (Patch.COMMIT_MSG.equals(path)) {
-          if (comparisonType.isAgainstParentOrAutoMerge()
-              && (aId == within || within.equals(aId))) {
+          if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
             id = ObjectId.zeroId();
             src = Text.EMPTY;
             srcContent = Text.NO_BYTES;
@@ -468,8 +520,7 @@
           }
           reuse = false;
         } else if (Patch.MERGE_LIST.equals(path)) {
-          if (comparisonType.isAgainstParentOrAutoMerge()
-              && (aId == within || within.equals(aId))) {
+          if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
             id = ObjectId.zeroId();
             src = Text.EMPTY;
             srcContent = Text.NO_BYTES;
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index b1e0e3c..ec05200 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 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;
@@ -29,7 +30,6 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -44,7 +44,6 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -82,7 +81,6 @@
   private final PatchSetUtil psUtil;
   private final Provider<PatchScriptBuilder> builderFactory;
   private final PatchListCache patchListCache;
-  private final ReviewDb db;
   private final CommentsUtil commentsUtil;
 
   private final String fileName;
@@ -112,7 +110,6 @@
       PatchSetUtil psUtil,
       Provider<PatchScriptBuilder> builderFactory,
       PatchListCache patchListCache,
-      ReviewDb db,
       CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
       Provider<CurrentUser> userProvider,
@@ -127,7 +124,6 @@
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
     this.patchListCache = patchListCache;
-    this.db = db;
     this.notes = notes;
     this.commentsUtil = commentsUtil;
     this.editReader = editReader;
@@ -141,7 +137,7 @@
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
 
-    changeId = patchSetB.getParentKey();
+    changeId = patchSetB.changeId();
   }
 
   @AssistedInject
@@ -150,7 +146,6 @@
       PatchSetUtil psUtil,
       Provider<PatchScriptBuilder> builderFactory,
       PatchListCache patchListCache,
-      ReviewDb db,
       CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
       Provider<CurrentUser> userProvider,
@@ -165,7 +160,6 @@
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
     this.patchListCache = patchListCache;
-    this.db = db;
     this.notes = notes;
     this.commentsUtil = commentsUtil;
     this.editReader = editReader;
@@ -179,7 +173,7 @@
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
 
-    changeId = patchSetB.getParentKey();
+    changeId = patchSetB.changeId();
     checkArgument(parentNum >= 0, "parentNum must be >= 0");
   }
 
@@ -193,21 +187,30 @@
 
   @Override
   public PatchScript call()
-      throws OrmException, LargeObjectException, AuthException, InvalidChangeOperationException,
-          IOException, PermissionBackendException {
-    if (parentNum < 0) {
-      validatePatchSetId(psa);
-    }
+      throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
+          PermissionBackendException {
+    validatePatchSetId(psa);
     validatePatchSetId(psb);
 
-    PatchSet psEntityA = psa != null ? psUtil.get(db, notes, psa) : null;
-    PatchSet psEntityB = psb.get() == 0 ? new PatchSet(psb) : psUtil.get(db, notes, psb);
-    if (psEntityA != null || psEntityB != null) {
-      try {
-        permissionBackend.currentUser().change(notes).database(db).check(ChangePermission.READ);
-      } catch (AuthException e) {
-        throw new NoSuchChangeException(changeId);
-      }
+    if (psa != null) {
+      checkState(parentNum < 0, "expected no parentNum when psa is present");
+      checkArgument(psa.get() != 0, "edit not supported for left side");
+      aId = getCommitId(psa);
+    } else {
+      aId = null;
+    }
+
+    if (psb.get() != 0) {
+      bId = getCommitId(psb);
+    } else {
+      // Change edit: create synthetic PatchSet corresponding to the edit.
+      bId = getEditRev();
+    }
+
+    try {
+      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache.checkedGet(notes.getProjectName()).statePermitsRead()) {
@@ -215,11 +218,6 @@
     }
 
     try (Repository git = repoManager.openRepository(notes.getProjectName())) {
-      bId = toObjectId(psEntityB);
-      if (parentNum < 0) {
-        aId = psEntityA != null ? toObjectId(psEntityA) : null;
-      }
-
       try {
         final PatchList list = listFor(keyFor(diffPrefs.ignoreWhitespace));
         final PatchScriptBuilder b = newBuilder(list, git);
@@ -265,23 +263,15 @@
     return b;
   }
 
-  private ObjectId toObjectId(PatchSet ps) throws AuthException, IOException, OrmException {
-    if (ps.getId().get() == 0) {
-      return getEditRev();
+  private ObjectId getCommitId(PatchSet.Id psId) {
+    PatchSet ps = psUtil.get(notes, psId);
+    if (ps == null) {
+      throw new NoSuchChangeException(psId.changeId());
     }
-    if (ps.getRevision() == null || ps.getRevision().get() == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    try {
-      return ObjectId.fromString(ps.getRevision().get());
-    } catch (IllegalArgumentException e) {
-      logger.atSevere().log("Patch set %s has invalid revision", ps.getId());
-      throw new NoSuchChangeException(changeId, e);
-    }
+    return ps.commitId();
   }
 
-  private ObjectId getEditRev() throws AuthException, IOException, OrmException {
+  private ObjectId getEditRev() throws AuthException, IOException {
     edit = editReader.byChange(notes);
     if (edit.isPresent()) {
       return edit.get().getEditCommit();
@@ -291,14 +281,13 @@
 
   private void validatePatchSetId(PatchSet.Id psId) throws NoSuchChangeException {
     if (psId == null) { // OK, means use base;
-    } else if (changeId.equals(psId.getParentKey())) { // OK, same change;
+    } else if (changeId.equals(psId.changeId())) { // OK, same change;
     } else {
       throw new NoSuchChangeException(changeId);
     }
   }
 
-  private void loadCommentsAndHistory(ChangeType changeType, String oldName, String newName)
-      throws OrmException {
+  private void loadCommentsAndHistory(ChangeType changeType, String oldName, String newName) {
     Map<Patch.Key, Patch> byKey = new HashMap<>();
 
     if (loadHistory) {
@@ -308,13 +297,13 @@
       // proper rename detection between the patch sets.
       //
       history = new ArrayList<>();
-      for (PatchSet ps : psUtil.byChange(db, notes)) {
+      for (PatchSet ps : psUtil.byChange(notes)) {
         String name = fileName;
         if (psa != null) {
           switch (changeType) {
             case COPIED:
             case RENAMED:
-              if (ps.getId().equals(psa)) {
+              if (ps.id().equals(psa)) {
                 name = oldName;
               }
               break;
@@ -327,12 +316,12 @@
           }
         }
 
-        Patch p = new Patch(new Patch.Key(ps.getId(), name));
+        Patch p = new Patch(Patch.key(ps.id(), name));
         history.add(p);
         byKey.put(p.getKey(), p);
       }
       if (edit != null && edit.isPresent()) {
-        Patch p = new Patch(new Patch.Key(new PatchSet.Id(psb.getParentKey(), 0), fileName));
+        Patch p = new Patch(Patch.key(PatchSet.id(psb.changeId(), 0), fileName));
         history.add(p);
         byKey.put(p.getKey(), p);
       }
@@ -390,11 +379,11 @@
     }
   }
 
-  private void loadPublished(Map<Patch.Key, Patch> byKey, String file) throws OrmException {
-    for (Comment c : commentsUtil.publishedByChangeFile(db, notes, changeId, file)) {
+  private void loadPublished(Map<Patch.Key, Patch> byKey, String file) {
+    for (Comment c : commentsUtil.publishedByChangeFile(notes, file)) {
       comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+      Patch.Key pKey = Patch.key(psId, c.key.filename);
       Patch p = byKey.get(pKey);
       if (p != null) {
         p.setCommentCount(p.getCommentCount() + 1);
@@ -402,12 +391,11 @@
     }
   }
 
-  private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file)
-      throws OrmException {
-    for (Comment c : commentsUtil.draftByChangeFileAuthor(db, notes, file, me)) {
+  private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file) {
+    for (Comment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
       comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+      Patch.Key pKey = Patch.key(psId, c.key.filename);
       Patch p = byKey.get(pKey);
       if (p != null) {
         p.setDraftCount(p.getDraftCount() + 1);
diff --git a/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java b/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
index 41bade6..c684da5 100644
--- a/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
@@ -14,18 +14,16 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.UserIdentity;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -34,7 +32,6 @@
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -54,24 +51,23 @@
     this.emails = emails;
   }
 
-  public PatchSetInfo get(RevWalk rw, RevCommit src, PatchSet.Id psi)
-      throws IOException, OrmException {
+  public PatchSetInfo get(RevWalk rw, RevCommit src, PatchSet.Id psi) throws IOException {
     rw.parseBody(src);
     PatchSetInfo info = new PatchSetInfo(psi);
     info.setSubject(src.getShortMessage());
     info.setMessage(src.getFullMessage());
     info.setAuthor(toUserIdentity(src.getAuthorIdent()));
     info.setCommitter(toUserIdentity(src.getCommitterIdent()));
-    info.setRevId(src.getName());
+    info.setCommitId(src);
     return info;
   }
 
-  public PatchSetInfo get(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
+  public PatchSetInfo get(ChangeNotes notes, PatchSet.Id psId)
       throws PatchSetInfoNotAvailableException {
     try {
-      PatchSet patchSet = psUtil.get(db, notes, psId);
+      PatchSet patchSet = psUtil.get(notes, psId);
       return get(notes.getProjectName(), patchSet);
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       throw new PatchSetInfoNotAvailableException(e);
     }
   }
@@ -80,17 +76,17 @@
       throws PatchSetInfoNotAvailableException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      final RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
-      PatchSetInfo info = get(rw, src, patchSet.getId());
+      RevCommit src = rw.parseCommit(patchSet.commitId());
+      PatchSetInfo info = get(rw, src, patchSet.id());
       info.setParents(toParentInfos(src.getParents(), rw));
       return info;
-    } catch (IOException | OrmException e) {
+    } catch (IOException | StorageException e) {
       throw new PatchSetInfoNotAvailableException(e);
     }
   }
 
   // TODO: The same method exists in EventFactory, find a common place for it
-  private UserIdentity toUserIdentity(PersonIdent who) throws IOException, OrmException {
+  private UserIdentity toUserIdentity(PersonIdent who) throws IOException {
     final UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
@@ -113,9 +109,8 @@
     List<PatchSetInfo.ParentInfo> pInfos = new ArrayList<>(parents.length);
     for (RevCommit parent : parents) {
       walk.parseBody(parent);
-      RevId rev = new RevId(parent.getId().name());
       String msg = parent.getShortMessage();
-      pInfos.add(new PatchSetInfo.ParentInfo(rev, msg));
+      pInfos.add(new PatchSetInfo.ParentInfo(parent, msg));
     }
     return pInfos;
   }
diff --git a/java/com/google/gerrit/server/patch/Text.java b/java/com/google/gerrit/server/patch/Text.java
index 172dbaf..f127f44 100644
--- a/java/com/google/gerrit/server/patch/Text.java
+++ b/java/com/google/gerrit/server/patch/Text.java
@@ -18,6 +18,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.nio.charset.IllegalCharsetNameException;
@@ -62,7 +63,7 @@
             RevCommit p = c.getParent(0);
             rw.parseBody(p);
             b.append("Parent:     ");
-            b.append(reader.abbreviate(p, 8).name());
+            b.append(abbreviateName(p, reader));
             b.append(" (");
             b.append(p.getShortMessage());
             b.append(")\n");
@@ -73,7 +74,7 @@
             RevCommit p = c.getParent(i);
             rw.parseBody(p);
             b.append(i == 0 ? "Merge Of:   " : "            ");
-            b.append(reader.abbreviate(p, 8).name());
+            b.append(abbreviateName(p, reader));
             b.append(" (");
             b.append(p.getShortMessage());
             b.append(")\n");
@@ -106,7 +107,7 @@
           b.append("Merge List:\n\n");
           for (RevCommit commit : MergeListBuilder.build(rw, c, uniterestingParent)) {
             b.append("* ");
-            b.append(reader.abbreviate(commit, 8).name());
+            b.append(abbreviateName(commit, reader));
             b.append(" ");
             b.append(commit.getShortMessage());
             b.append("\n");
@@ -116,6 +117,10 @@
     }
   }
 
+  private static String abbreviateName(RevCommit p, ObjectReader reader) throws IOException {
+    return ObjectIds.abbreviateName(p, 8, reader);
+  }
+
   private static void appendPersonIdent(StringBuilder b, String field, PersonIdent person) {
     if (person != null) {
       b.append(field).append(":    ");
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 3a17965..ee362002 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 
@@ -23,19 +22,17 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.EnumSet;
@@ -48,55 +45,35 @@
   static class Factory {
     private final ChangeData.Factory changeDataFactory;
     private final ChangeNotes.Factory notesFactory;
-    private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
     @Inject
-    Factory(
-        ChangeData.Factory changeDataFactory,
-        ChangeNotes.Factory notesFactory,
-        IdentifiedUser.GenericFactory identifiedUserFactory) {
+    Factory(ChangeData.Factory changeDataFactory, ChangeNotes.Factory notesFactory) {
       this.changeDataFactory = changeDataFactory;
       this.notesFactory = notesFactory;
-      this.identifiedUserFactory = identifiedUserFactory;
     }
 
-    ChangeControl create(
-        RefControl refControl, ReviewDb db, Project.NameKey project, Change.Id changeId)
-        throws OrmException {
-      return create(refControl, notesFactory.create(db, project, changeId));
+    ChangeControl create(RefControl refControl, Project.NameKey project, Change.Id changeId) {
+      return create(refControl, notesFactory.create(project, changeId));
     }
 
     ChangeControl create(RefControl refControl, ChangeNotes notes) {
-      return new ChangeControl(changeDataFactory, identifiedUserFactory, refControl, notes);
+      return new ChangeControl(changeDataFactory, refControl, notes);
     }
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final RefControl refControl;
   private final ChangeNotes notes;
 
   private ChangeControl(
-      ChangeData.Factory changeDataFactory,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      RefControl refControl,
-      ChangeNotes notes) {
+      ChangeData.Factory changeDataFactory, RefControl refControl, ChangeNotes notes) {
     this.changeDataFactory = changeDataFactory;
-    this.identifiedUserFactory = identifiedUserFactory;
     this.refControl = refControl;
     this.notes = notes;
   }
 
-  ForChange asForChange(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
-    return new ForChangeImpl(cd, db);
-  }
-
-  private ChangeControl forUser(CurrentUser who) {
-    if (getUser().equals(who)) {
-      return this;
-    }
-    return new ChangeControl(
-        changeDataFactory, identifiedUserFactory, refControl.forUser(who), notes);
+  ForChange asForChange(@Nullable ChangeData cd) {
+    return new ForChangeImpl(cd);
   }
 
   private CurrentUser getUser() {
@@ -112,8 +89,8 @@
   }
 
   /** Can this user see this change? */
-  private boolean isVisible(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
-    if (getChange().isPrivate() && !isPrivateVisible(db, cd)) {
+  private boolean isVisible(@Nullable ChangeData cd) {
+    if (getChange().isPrivate() && !isPrivateVisible(cd)) {
       return false;
     }
     return refControl.isVisible();
@@ -176,9 +153,9 @@
   }
 
   /** Is this user a reviewer for the change? */
-  private boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+  private boolean isReviewer(@Nullable ChangeData cd) {
     if (getUser().isIdentifiedUser()) {
-      cd = cd != null ? cd : changeDataFactory.create(db, notes);
+      cd = cd != null ? cd : changeDataFactory.create(notes);
       Collection<Account.Id> results = cd.reviewers().all();
       return results.contains(getUser().getAccountId());
     }
@@ -187,7 +164,7 @@
 
   /** Can this user edit the topic name? */
   private boolean canEditTopicName() {
-    if (getChange().getStatus().isOpen()) {
+    if (getChange().isNew()) {
       return isOwner() // owner (aka creator) of the change can edit topic
           || refControl.isOwner() // branch owner can edit topic
           || getProjectControl().isOwner() // project owner can edit topic
@@ -198,9 +175,17 @@
     return refControl.canForceEditTopicName();
   }
 
+  /** Can this user toggle WorkInProgress state? */
+  private boolean canToggleWorkInProgressState() {
+    return isOwner()
+        || getProjectControl().isOwner()
+        || refControl.canPerform(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
+        || getProjectControl().isAdmin();
+  }
+
   /** Can this user edit the description? */
   private boolean canEditDescription() {
-    if (getChange().getStatus().isOpen()) {
+    if (getChange().isNew()) {
       return isOwner() // owner (aka creator) of the change can edit desc
           || refControl.isOwner() // branch owner can edit desc
           || getProjectControl().isOwner() // project owner can edit desc
@@ -226,9 +211,9 @@
         || getProjectControl().isAdmin();
   }
 
-  private boolean isPrivateVisible(ReviewDb db, ChangeData cd) throws OrmException {
+  private boolean isPrivateVisible(ChangeData cd) {
     return isOwner()
-        || isReviewer(db, cd)
+        || isReviewer(cd)
         || refControl.canPerform(Permission.VIEW_PRIVATE_CHANGES)
         || getUser().isInternalUser();
   }
@@ -238,46 +223,18 @@
     private Map<String, PermissionRange> labels;
     private String resourcePath;
 
-    ForChangeImpl(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
+    ForChangeImpl(@Nullable ChangeData cd) {
       this.cd = cd;
-      this.db = db;
-    }
-
-    private ReviewDb db() {
-      if (db != null) {
-        return db.get();
-      } else if (cd != null) {
-        return cd.db();
-      } else {
-        return null;
-      }
     }
 
     private ChangeData changeData() {
       if (cd == null) {
-        ReviewDb reviewDb = db();
-        checkState(reviewDb != null, "need ReviewDb");
-        cd = changeDataFactory.create(reviewDb, notes);
+        cd = changeDataFactory.create(notes);
       }
       return cd;
     }
 
     @Override
-    public CurrentUser user() {
-      return getUser();
-    }
-
-    @Override
-    public ForChange user(CurrentUser user) {
-      return user().equals(user) ? this : forUser(user).asForChange(cd, db);
-    }
-
-    @Override
-    public ForChange absentUser(Account.Id id) {
-      return user(identifiedUserFactory.create(id));
-    }
-
-    @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath =
@@ -308,6 +265,11 @@
       return ok;
     }
 
+    @Override
+    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
+      return new PermissionBackendCondition.ForChange(this, perm, getUser());
+    }
+
     private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
       if (perm instanceof ChangePermission) {
         return can((ChangePermission) perm);
@@ -323,12 +285,11 @@
       try {
         switch (perm) {
           case READ:
-            return isVisible(db(), changeData());
+            return isVisible(changeData());
           case ABANDON:
             return canAbandon();
           case DELETE:
-            return (isOwner() && refControl.canPerform(Permission.DELETE_OWN_CHANGES))
-                || getProjectControl().isAdmin();
+            return (getProjectControl().isAdmin() || (refControl.canDeleteChanges(isOwner())));
           case ADD_PATCH_SET:
             return canAddPatchSet();
           case EDIT_ASSIGNEE:
@@ -345,12 +306,14 @@
             return canRestore();
           case SUBMIT:
             return refControl.canSubmit(isOwner());
+          case TOGGLE_WORK_IN_PROGRESS_STATE:
+            return canToggleWorkInProgressState();
 
           case REMOVE_REVIEWER:
           case SUBMIT_AS:
             return refControl.canPerform(changePermissionName(perm));
         }
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         throw new PermissionBackendException("unavailable", e);
       }
       throw new PermissionBackendException(perm + " unsupported");
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index ba1785d..2fba4ef 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.extensions.api.access.GerritPermission;
 
@@ -55,7 +55,8 @@
    */
   REBASE,
   SUBMIT,
-  SUBMIT_AS("submit on behalf of other users");
+  SUBMIT_AS("submit on behalf of other users"),
+  TOGGLE_WORK_IN_PROGRESS_STATE;
 
   private final String description;
 
@@ -64,7 +65,7 @@
   }
 
   ChangePermission(String description) {
-    this.description = checkNotNull(description);
+    this.description = requireNonNull(description);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 490b45e..b23c85f 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -14,15 +14,17 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalPermissionName;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -38,13 +40,14 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import java.util.EnumSet;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
 @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;
@@ -75,12 +78,12 @@
 
   @Override
   public WithUser user(CurrentUser user) {
-    return new WithUserImpl(checkNotNull(user, "user"));
+    return new WithUserImpl(requireNonNull(user, "user"));
   }
 
   @Override
   public WithUser absentUser(Account.Id id) {
-    IdentifiedUser identifiedUser = identifiedUserFactory.create(checkNotNull(id, "user"));
+    IdentifiedUser identifiedUser = identifiedUserFactory.create(requireNonNull(id, "user"));
     return new WithUserImpl(identifiedUser);
   }
 
@@ -94,12 +97,7 @@
     private Boolean admin;
 
     WithUserImpl(CurrentUser user) {
-      this.user = checkNotNull(user, "user");
-    }
-
-    @Override
-    public CurrentUser user() {
-      return user;
+      this.user = requireNonNull(user, "user");
     }
 
     @Override
@@ -110,7 +108,7 @@
             PerThreadCache.getOrCompute(
                 PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
                 () -> projectControlFactory.create(user, state));
-        return control.asForProject().database(db);
+        return control.asForProject();
       } catch (Exception e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
         return FailedPermissionBackend.project(
@@ -129,7 +127,7 @@
     @Override
     public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
         throws PermissionBackendException {
-      Set<T> ok = newSet(permSet);
+      Set<T> ok = Sets.newHashSetWithExpectedSize(permSet.size());
       for (T perm : permSet) {
         if (can(perm)) {
           ok.add(perm);
@@ -138,12 +136,17 @@
       return ok;
     }
 
+    @Override
+    public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+      return new PermissionBackendCondition.WithUser(this, perm, user);
+    }
+
     private boolean can(GlobalOrPluginPermission perm) throws PermissionBackendException {
       if (perm instanceof GlobalPermission) {
         return can((GlobalPermission) perm);
       } else if (perm instanceof PluginPermission) {
         PluginPermission pluginPermission = (PluginPermission) perm;
-        return has(DefaultPermissionMappings.pluginPermissionName(pluginPermission))
+        return has(DefaultPermissionMappings.pluginCapabilityName(pluginPermission))
             || (pluginPermission.fallBackToAdmin() && isAdmin());
       }
       throw new PermissionBackendException(perm + " unsupported");
@@ -168,6 +171,7 @@
         case CREATE_PROJECT:
         case MAINTAIN_SERVER:
         case MODIFY_ACCOUNT:
+        case READ_AS:
         case STREAM_EVENTS:
         case VIEW_ALL_ACCOUNTS:
         case VIEW_CONNECTIONS:
@@ -185,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;
     }
@@ -209,18 +220,38 @@
 
     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(requireNonNull(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) {
       return user.getEffectiveGroups()
           .containsAnyOf(
-              rules
-                  .stream()
+              rules.stream()
                   .filter(r -> r.getAction() == Action.ALLOW)
                   .map(r -> r.getGroup().getUUID())
                   .collect(toSet()));
@@ -228,22 +259,11 @@
 
     private boolean notDenied(Collection<PermissionRule> rules) {
       Set<AccountGroup.UUID> denied =
-          rules
-              .stream()
+          rules.stream()
               .filter(r -> r.getAction() != Action.ALLOW)
               .map(r -> r.getGroup().getUUID())
               .collect(toSet());
       return denied.isEmpty() || !user.getEffectiveGroups().containsAnyOf(denied);
     }
   }
-
-  private static <T extends GlobalOrPluginPermission> Set<T> newSet(Collection<T> permSet) {
-    if (permSet instanceof EnumSet) {
-      @SuppressWarnings({"unchecked", "rawtypes"})
-      Set<T> s = ((EnumSet) permSet).clone();
-      s.clear();
-      return s;
-    }
-    return Sets.newHashSetWithExpectedSize(permSet.size());
-  }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 9593521..8215083 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.ImmutableMap;
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.api.access.PluginProjectPermission;
 import com.google.gerrit.server.permissions.LabelPermission.ForUser;
 import java.util.EnumSet;
 import java.util.Optional;
@@ -50,6 +51,7 @@
           .put(GlobalPermission.KILL_TASK, GlobalCapability.KILL_TASK)
           .put(GlobalPermission.MAINTAIN_SERVER, GlobalCapability.MAINTAIN_SERVER)
           .put(GlobalPermission.MODIFY_ACCOUNT, GlobalCapability.MODIFY_ACCOUNT)
+          .put(GlobalPermission.READ_AS, GlobalCapability.READ_AS)
           .put(GlobalPermission.RUN_AS, GlobalCapability.RUN_AS)
           .put(GlobalPermission.RUN_GC, GlobalCapability.RUN_GC)
           .put(GlobalPermission.STREAM_EVENTS, GlobalCapability.STREAM_EVENTS)
@@ -96,6 +98,9 @@
           .put(ChangePermission.REBASE, Permission.REBASE)
           .put(ChangePermission.SUBMIT, Permission.SUBMIT)
           .put(ChangePermission.SUBMIT_AS, Permission.SUBMIT_AS)
+          .put(
+              ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE,
+              Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
           .build();
 
   private static <T extends Enum<T>> void checkMapContainsAllEnumValues(
@@ -109,21 +114,25 @@
   }
 
   public static String globalPermissionName(GlobalPermission globalPermission) {
-    return checkNotNull(CAPABILITIES.get(globalPermission));
+    return requireNonNull(CAPABILITIES.get(globalPermission));
   }
 
   public static Optional<GlobalPermission> globalPermission(String capabilityName) {
     return Optional.ofNullable(CAPABILITIES.inverse().get(capabilityName));
   }
 
-  public static String pluginPermissionName(PluginPermission pluginPermission) {
+  public static String pluginCapabilityName(PluginPermission pluginPermission) {
     return pluginPermission.pluginName() + '-' + pluginPermission.capability();
   }
 
+  public static String pluginProjectPermissionName(PluginProjectPermission pluginPermission) {
+    return "plugin-" + pluginPermission.pluginName() + '-' + pluginPermission.permission();
+  }
+
   public static String globalOrPluginPermissionName(GlobalOrPluginPermission permission) {
     return permission instanceof GlobalPermission
         ? globalPermissionName((GlobalPermission) permission)
-        : pluginPermissionName((PluginPermission) permission);
+        : pluginCapabilityName((PluginPermission) permission);
   }
 
   public static Optional<String> projectPermissionName(ProjectPermission projectPermission) {
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 3b88080..858edf2 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -14,40 +14,49 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_SELF;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toMap;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TagMatcher;
 import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.notedb.AbstractChangeNotes;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -55,8 +64,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
-import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -72,95 +80,215 @@
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
   @Nullable private final SearchingChangeCacheImpl changeCache;
-  private final Provider<ReviewDb> db;
   private final GroupCache groupCache;
   private final PermissionBackend permissionBackend;
   private final ProjectControl projectControl;
   private final CurrentUser user;
   private final ProjectState projectState;
   private final PermissionBackend.ForProject permissionBackendForProject;
+  private final Counter0 fullFilterCount;
+  private final Counter0 skipFilterCount;
+  private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
 
-  private Map<Change.Id, Branch.NameKey> visibleChanges;
+  private Map<Change.Id, BranchNameKey> visibleChanges;
 
   @Inject
   DefaultRefFilter(
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
       @Nullable SearchingChangeCacheImpl changeCache,
-      Provider<ReviewDb> db,
       GroupCache groupCache,
       PermissionBackend permissionBackend,
+      @GerritServerConfig Config config,
+      MetricMaker metricMaker,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
     this.changeNotesFactory = changeNotesFactory;
     this.changeCache = changeCache;
-    this.db = db;
     this.groupCache = groupCache;
     this.permissionBackend = permissionBackend;
+    this.skipFullRefEvaluationIfAllRefsAreVisible =
+        config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
 
     this.user = projectControl.getUser();
     this.projectState = projectControl.getProjectState();
     this.permissionBackendForProject =
-        permissionBackend.user(user).database(db).project(projectState.getNameKey());
+        permissionBackend.user(user).project(projectState.getNameKey());
+    this.fullFilterCount =
+        metricMaker.newCounter(
+            "permissions/ref_filter/full_filter_count",
+            new Description("Rate of full ref filter operations").setRate());
+    this.skipFilterCount =
+        metricMaker.newCounter(
+            "permissions/ref_filter/skip_filter_count",
+            new Description(
+                    "Rate of ref filter operations where we skip full evaluation"
+                        + " because the user can read all refs")
+                .setRate());
   }
 
-  Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts) {
-    if (projectState.isAllUsers()) {
-      refs = addUsersSelfSymref(refs);
-    }
+  /** Filters given refs and tags by visibility. */
+  Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+      throws PermissionBackendException {
+    logger.atFinest().log(
+        "Filter refs for repository %s by visibility (options = %s, refs = %s)",
+        projectState.getNameKey(), opts, refs);
+    logger.atFinest().log(
+        "Calling user: %s (groups = %s)",
+        user.getLoggableName(), user.getEffectiveGroups().getKnownGroups());
+    logger.atFinest().log(
+        "auth.skipFullRefEvaluationIfAllRefsAreVisible = %s",
+        skipFullRefEvaluationIfAllRefsAreVisible);
+    logger.atFinest().log(
+        "Project state %s permits read = %s",
+        projectState.getProject().getState(), projectState.statePermitsRead());
 
-    PermissionBackend.WithUser withUser = permissionBackend.user(user);
-    PermissionBackend.ForProject forProject = withUser.project(projectState.getNameKey());
-    if (!projectState.isAllUsers()) {
-      if (projectState.statePermitsRead()
-          && checkProjectPermission(forProject, ProjectPermission.READ)) {
-        return refs;
-      } else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
-        return fastHideRefsMetaConfig(refs);
+    // See if we can get away with a single, cheap ref evaluation.
+    if (refs.size() == 1) {
+      String refName = Iterables.getOnlyElement(refs.values()).getName();
+      if (opts.filterMeta() && isMetadata(refName)) {
+        logger.atFinest().log("Filter out metadata ref %s", refName);
+        return ImmutableMap.of();
+      }
+      if (RefNames.isRefsChanges(refName)) {
+        boolean isChangeRefVisisble = canSeeSingleChangeRef(refName);
+        if (isChangeRefVisisble) {
+          logger.atFinest().log("Change ref %s is visible", refName);
+          return refs;
+        }
+        logger.atFinest().log("Filter out non-visible change ref %s", refName);
+        return ImmutableMap.of();
       }
     }
 
+    // Perform an initial ref filtering with all the refs the caller asked for. If we find tags that
+    // we have to investigate separately (deferred tags) then perform a reachability check starting
+    // from all visible branches (refs/heads/*).
+    Result initialRefFilter = filterRefs(refs, repo, opts);
+    Map<String, Ref> visibleRefs = initialRefFilter.visibleRefs();
+    if (!initialRefFilter.deferredTags().isEmpty()) {
+      try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
+        Result allVisibleBranches = filterRefs(getTaggableRefsMap(repo), repo, opts);
+        checkState(
+            allVisibleBranches.deferredTags().isEmpty(),
+            "unexpected tags found when filtering refs/heads/* "
+                + allVisibleBranches.deferredTags());
+
+        TagMatcher tags =
+            tagCache
+                .get(projectState.getNameKey())
+                .matcher(tagCache, repo, allVisibleBranches.visibleRefs().values());
+        for (Ref tag : initialRefFilter.deferredTags()) {
+          try {
+            if (tags.isReachable(tag)) {
+              logger.atFinest().log("Include reachable tag %s", tag.getName());
+              visibleRefs.put(tag.getName(), tag);
+            } else {
+              logger.atFinest().log("Filter out non-reachable tag %s", tag.getName());
+            }
+          } catch (IOException e) {
+            throw new PermissionBackendException(e);
+          }
+        }
+      }
+    }
+
+    logger.atFinest().log("visible refs = %s", visibleRefs);
+    return visibleRefs;
+  }
+
+  /**
+   * Filters refs by visibility. Returns tags where visibility can't be trivially computed
+   * separately for later rev-walk-based visibility computation. Tags where visibility is trivial to
+   * compute will be returned as part of {@link Result#visibleRefs()}.
+   */
+  Result filterRefs(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+      throws PermissionBackendException {
+    logger.atFinest().log("Filter refs (refs = %s)", refs);
+
+    if (projectState.isAllUsers()) {
+      refs = addUsersSelfSymref(repo, refs);
+    }
+
+    // TODO(hiesel): Remove when optimization is done.
+    boolean hasReadOnRefsStar =
+        checkProjectPermission(permissionBackendForProject, ProjectPermission.READ);
+    logger.atFinest().log("User has READ on refs/* = %s", hasReadOnRefsStar);
+    if (skipFullRefEvaluationIfAllRefsAreVisible && !projectState.isAllUsers()) {
+      if (projectState.statePermitsRead() && hasReadOnRefsStar) {
+        skipFilterCount.increment();
+        logger.atFinest().log(
+            "Fast path, all refs are visible because user has READ on refs/*: %s", refs);
+        return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+      } else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
+        skipFilterCount.increment();
+        refs = fastHideRefsMetaConfig(refs);
+        logger.atFinest().log(
+            "Fast path, all refs except %s are visible: %s", RefNames.REFS_CONFIG, refs);
+        return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+      }
+    }
+    logger.atFinest().log("Doing full ref filtering");
+    fullFilterCount.increment();
+
     boolean viewMetadata;
     boolean isAdmin;
     Account.Id userId;
     IdentifiedUser identifiedUser;
+    PermissionBackend.WithUser withUser = permissionBackend.user(user);
     if (user.isIdentifiedUser()) {
       viewMetadata = withUser.testOrFalse(GlobalPermission.ACCESS_DATABASE);
       isAdmin = withUser.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
       identifiedUser = user.asIdentifiedUser();
       userId = identifiedUser.getAccountId();
+      logger.atFinest().log(
+          "Account = %d; can view metadata = %s; is admin = %s",
+          userId.get(), viewMetadata, isAdmin);
     } else {
+      logger.atFinest().log("User is anonymous");
       viewMetadata = false;
       isAdmin = false;
       userId = null;
       identifiedUser = null;
     }
 
-    Map<String, Ref> result = new HashMap<>();
+    Map<String, Ref> resultRefs = new HashMap<>();
     List<Ref> deferredTags = new ArrayList<>();
-
     for (Ref ref : refs.values()) {
       String name = ref.getName();
       Change.Id changeId;
       Account.Id accountId;
       AccountGroup.UUID accountGroupUuid;
-      if (name.startsWith(REFS_CACHE_AUTOMERGE) || (opts.filterMeta() && isMetadata(name))) {
+      if (name.startsWith(REFS_CACHE_AUTOMERGE)) {
+        logger.atFinest().log("Filter out ref %s", name);
+        continue;
+      } else if (opts.filterMeta() && isMetadata(name)) {
+        logger.atFinest().log("Filter out metadata ref %s", name);
         continue;
       } else if (RefNames.isRefsEdit(name)) {
         // Edits are visible only to the owning user, if change is visible.
         if (viewMetadata || visibleEdit(repo, name)) {
-          result.put(name, ref);
+          logger.atFinest().log("Include edit ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out edit ref %s", name);
         }
       } else if ((changeId = Change.Id.fromRef(name)) != null) {
         // Change ref is visible only if the change is visible.
         if (viewMetadata || visible(repo, changeId)) {
-          result.put(name, ref);
+          logger.atFinest().log("Include change ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out change ref %s", name);
         }
       } else if ((accountId = Account.Id.fromRef(name)) != null) {
         // Account ref is visible only to the corresponding account.
         if (viewMetadata || (accountId.equals(userId) && canReadRef(name))) {
-          result.put(name, ref);
+          logger.atFinest().log("Include user ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out user ref %s", name);
         }
       } else if ((accountGroupUuid = AccountGroup.UUID.fromRef(name)) != null) {
         // Group ref is visible only to the corresponding owner group.
@@ -169,67 +297,98 @@
             || (group != null
                 && isGroupOwner(group, identifiedUser, isAdmin)
                 && canReadRef(name))) {
-          result.put(name, ref);
+          logger.atFinest().log("Include group ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out group ref %s", name);
         }
       } else if (isTag(ref)) {
-        // If its a tag, consider it later.
-        if (ref.getObjectId() != null) {
-          deferredTags.add(ref);
+        if (hasReadOnRefsStar) {
+          // The user has READ on refs/*. This is the broadest permission one can assign. There is
+          // no way to grant access to (specific) tags in Gerrit, so we have to assume that these
+          // users can see all tags because there could be tags that aren't reachable by any visible
+          // ref while the user can see all non-Gerrit refs. This matches Gerrit's historic
+          // behavior.
+          // This makes it so that these users could see commits that they can't see otherwise
+          // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
+          // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
+          // is a negligible risk.
+          logger.atFinest().log("Include tag ref %s because user has read on refs/*", name);
+          resultRefs.put(name, ref);
+        } else {
+          // If its a tag, consider it later.
+          if (ref.getObjectId() != null) {
+            logger.atFinest().log("Defer tag ref %s", name);
+            deferredTags.add(ref);
+          } else {
+            logger.atFinest().log("Filter out tag ref %s that is not a tag", name);
+          }
         }
       } else if (name.startsWith(RefNames.REFS_SEQUENCES)) {
         // Sequences are internal database implementation details.
         if (viewMetadata) {
-          result.put(name, ref);
+          logger.atFinest().log("Include sequence ref %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out sequence ref %s", name);
         }
       } else if (projectState.isAllUsers()
           && (name.equals(RefNames.REFS_EXTERNAL_IDS) || name.equals(RefNames.REFS_GROUPNAMES))) {
         // The notes branches with the external IDs / group names must not be exposed to normal
         // users.
         if (viewMetadata) {
-          result.put(name, ref);
+          logger.atFinest().log("Include external IDs branch %s", name);
+          resultRefs.put(name, ref);
+        } else {
+          logger.atFinest().log("Filter out external IDs branch %s", name);
         }
       } else if (canReadRef(ref.getLeaf().getName())) {
         // Use the leaf to lookup the control data. If the reference is
         // symbolic we want the control around the final target. If its
         // not symbolic then getLeaf() is a no-op returning ref itself.
-        result.put(name, ref);
+        logger.atFinest().log(
+            "Include ref %s because its leaf %s is readable", name, ref.getLeaf().getName());
+        resultRefs.put(name, ref);
       } else if (isRefsUsersSelf(ref)) {
         // viewMetadata allows to see all account refs, hence refs/users/self should be included as
         // well
         if (viewMetadata) {
-          result.put(name, ref);
+          logger.atFinest().log("Include ref %s", REFS_USERS_SELF);
+          resultRefs.put(name, ref);
         }
+      } else {
+        logger.atFinest().log("Filter out ref %s", name);
       }
     }
-
-    // If we have tags that were deferred, we need to do a revision walk
-    // to identify what tags we can actually reach, and what we cannot.
-    //
-    if (!deferredTags.isEmpty() && (!result.isEmpty() || opts.filterTagsSeparately())) {
-      TagMatcher tags =
-          tagCache
-              .get(projectState.getNameKey())
-              .matcher(
-                  tagCache,
-                  repo,
-                  opts.filterTagsSeparately()
-                      ? filter(
-                              repo.getAllRefs(),
-                              repo,
-                              opts.toBuilder().setFilterTagsSeparately(false).build())
-                          .values()
-                      : result.values());
-      for (Ref tag : deferredTags) {
-        if (tags.isReachable(tag)) {
-          result.put(tag.getName(), tag);
-        }
-      }
-    }
-
+    Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
+    logger.atFinest().log("Result of ref filtering = %s", result);
     return result;
   }
 
-  private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs) {
+  /**
+   * Returns all refs tag we regard as starting points for reachability computation for tags. In
+   * general, these are all refs not managed by Gerrit excluding symbolic refs and tags.
+   *
+   * <p>We exclude symbolic refs because their target will be included and this will suffice for
+   * computing reachability.
+   */
+  private static Map<String, Ref> getTaggableRefsMap(Repository repo)
+      throws PermissionBackendException {
+    try {
+      return repo.getRefDatabase().getRefs().stream()
+          .filter(
+              r ->
+                  !RefNames.isGerritRef(r.getName())
+                      && !r.getName().startsWith(RefNames.REFS_TAGS)
+                      && !r.isSymbolic())
+          .collect(toMap(Ref::getName, r -> r));
+    } catch (IOException e) {
+      throw new PermissionBackendException(e);
+    }
+  }
+
+  private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs)
+      throws PermissionBackendException {
     if (refs.containsKey(REFS_CONFIG) && !canReadRef(REFS_CONFIG)) {
       Map<String, Ref> r = new HashMap<>(refs);
       r.remove(REFS_CONFIG);
@@ -238,116 +397,147 @@
     return refs;
   }
 
-  private Map<String, Ref> addUsersSelfSymref(Map<String, Ref> refs) {
+  private Map<String, Ref> addUsersSelfSymref(Repository repo, Map<String, Ref> refs)
+      throws PermissionBackendException {
     if (user.isIdentifiedUser()) {
-      Ref r = refs.get(RefNames.refsUsers(user.getAccountId()));
-      if (r != null) {
+      String refName = RefNames.refsUsers(user.getAccountId());
+      try {
+        Ref r = repo.exactRef(refName);
+        if (r == null) {
+          logger.atWarning().log("User ref %s not found", refName);
+          return refs;
+        }
+
         SymbolicRef s = new SymbolicRef(REFS_USERS_SELF, r);
         refs = new HashMap<>(refs);
         refs.put(s.getName(), s);
+        logger.atFinest().log("Added %s as alias for user ref %s", REFS_USERS_SELF, refName);
+      } catch (IOException e) {
+        throw new PermissionBackendException(e);
       }
     }
     return refs;
   }
 
-  private boolean visible(Repository repo, Change.Id changeId) {
+  private boolean visible(Repository repo, Change.Id changeId) throws PermissionBackendException {
     if (visibleChanges == null) {
       if (changeCache == null) {
         visibleChanges = visibleChangesByScan(repo);
       } else {
         visibleChanges = visibleChangesBySearch();
       }
+      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
     }
     return visibleChanges.containsKey(changeId);
   }
 
-  private boolean visibleEdit(Repository repo, String name) {
+  private boolean visibleEdit(Repository repo, String name) throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
-    // Initialize if it wasn't yet
-    if (visibleChanges == null) {
-      visible(repo, id);
-    }
     if (id == null) {
+      logger.atWarning().log("Couldn't extract change ID from edit ref %s", name);
       return false;
     }
+
     if (user.isIdentifiedUser()
         && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
         && visible(repo, id)) {
+      logger.atFinest().log("Own change edit ref is visible: %s", name);
       return true;
     }
+
+    // Initialize visibleChanges if it wasn't initialized yet.
+    if (visibleChanges == null) {
+      visible(repo, id);
+    }
     if (visibleChanges.containsKey(id)) {
       try {
         // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
         permissionBackendForProject
-            .ref(visibleChanges.get(id).get())
+            .ref(visibleChanges.get(id).branch())
             .check(RefPermission.READ_PRIVATE_CHANGES);
+        logger.atFinest().log("Foreign change edit ref is visible: %s", name);
         return true;
       } catch (AuthException e) {
-        return false;
-      } catch (PermissionBackendException e) {
-        logger.atSevere().withCause(e).log(
-            "Failed to check permission for %s in %s", id, projectState.getName());
+        logger.atFinest().log("Foreign change edit ref is not visible: %s", name);
         return false;
       }
     }
+
+    logger.atFinest().log("Change %d of change edit ref %s is not visible", id.get(), name);
     return false;
   }
 
-  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch() {
+  private Map<Change.Id, BranchNameKey> visibleChangesBySearch() throws PermissionBackendException {
     Project.NameKey project = projectState.getNameKey();
     try {
-      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
-      for (ChangeData cd : changeCache.getChangeData(db.get(), project)) {
+      Map<Change.Id, BranchNameKey> visibleChanges = new HashMap<>();
+      for (ChangeData cd : changeCache.getChangeData(project)) {
         ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
-        if (projectState.statePermitsRead()
-            && permissionBackendForProject.indexedChange(cd, notes).test(ChangePermission.READ)) {
+        if (!projectState.statePermitsRead()) {
+          continue;
+        }
+        try {
+          permissionBackendForProject.indexedChange(cd, notes).check(ChangePermission.READ);
           visibleChanges.put(cd.getId(), cd.change().getDest());
+        } catch (AuthException e) {
+          // Do nothing.
         }
       }
       return visibleChanges;
-    } catch (OrmException | PermissionBackendException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Cannot load changes for project %s, assuming no changes are visible", project);
       return Collections.emptyMap();
     }
   }
 
-  private Map<Change.Id, Branch.NameKey> visibleChangesByScan(Repository repo) {
+  private Map<Change.Id, BranchNameKey> visibleChangesByScan(Repository repo)
+      throws PermissionBackendException {
     Project.NameKey p = projectState.getNameKey();
-    Stream<ChangeNotesResult> s;
+    ImmutableList<ChangeNotesResult> changes;
     try {
-      s = changeNotesFactory.scan(repo, db.get(), p);
+      changes = changeNotesFactory.scan(repo, p).collect(toImmutableList());
     } catch (IOException e) {
       logger.atSevere().withCause(e).log(
           "Cannot load changes for project %s, assuming no changes are visible", p);
       return Collections.emptyMap();
     }
-    return s.map(this::toNotes)
-        .filter(Objects::nonNull)
-        .collect(toMap(AbstractChangeNotes::getChangeId, n -> n.getChange().getDest()));
+
+    Map<Change.Id, BranchNameKey> result = Maps.newHashMapWithExpectedSize(changes.size());
+    for (ChangeNotesResult notesResult : changes) {
+      ChangeNotes notes = toNotes(notesResult);
+      if (notes != null) {
+        result.put(notes.getChangeId(), notes.getChange().getDest());
+      }
+    }
+    return result;
   }
 
   @Nullable
-  private ChangeNotes toNotes(ChangeNotesResult r) {
+  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
     if (r.error().isPresent()) {
       logger.atWarning().withCause(r.error().get()).log(
           "Failed to load change %s in %s", r.id(), projectState.getName());
       return null;
     }
+
+    if (!projectState.statePermitsRead()) {
+      return null;
+    }
+
     try {
-      if (projectState.statePermitsRead()
-          && permissionBackendForProject.change(r.notes()).test(ChangePermission.READ)) {
-        return r.notes();
-      }
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check permission for %s in %s", r.id(), projectState.getName());
+      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
+      return r.notes();
+    } catch (AuthException e) {
+      // Skip.
     }
     return null;
   }
 
   private boolean isMetadata(String name) {
-    return name.startsWith(REFS_CHANGES) || RefNames.isRefsEdit(name);
+    boolean isMetaData = RefNames.isRefsChanges(name) || RefNames.isRefsEdit(name);
+    logger.atFinest().log("ref %s is " + (isMetaData ? "" : "not ") + "a metadata ref", name);
+    return isMetaData;
   }
 
   private static boolean isTag(Ref ref) {
@@ -358,38 +548,76 @@
     return ref.getName().startsWith(REFS_USERS_SELF);
   }
 
-  private boolean canReadRef(String ref) {
+  private boolean canReadRef(String ref) throws PermissionBackendException {
     try {
       permissionBackendForProject.ref(ref).check(RefPermission.READ);
     } catch (AuthException e) {
       return false;
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log("unable to check permissions");
-      return false;
     }
     return projectState.statePermitsRead();
   }
 
   private boolean checkProjectPermission(
-      PermissionBackend.ForProject forProject, ProjectPermission perm) {
+      PermissionBackend.ForProject forProject, ProjectPermission perm)
+      throws PermissionBackendException {
     try {
       forProject.check(perm);
     } catch (AuthException e) {
       return false;
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log(
-          "Can't check permission for user %s on project %s", user, projectState.getName());
-      return false;
     }
     return true;
   }
 
   private boolean isGroupOwner(
       InternalGroup group, @Nullable IdentifiedUser user, boolean isAdmin) {
-    checkNotNull(group);
+    requireNonNull(group);
 
     // Keep this logic in sync with GroupControl#isOwner().
-    return isAdmin
-        || (user != null && user.getEffectiveGroups().contains(group.getOwnerGroupUUID()));
+    boolean isGroupOwner =
+        isAdmin || (user != null && user.getEffectiveGroups().contains(group.getOwnerGroupUUID()));
+    logger.atFinest().log("User is owner of group %s = %s", group.getGroupUUID(), isGroupOwner);
+    return isGroupOwner;
+  }
+
+  /**
+   * Returns true if the user can see the provided change ref. Uses NoteDb for evaluation, hence
+   * does not suffer from the limitations documented in {@link SearchingChangeCacheImpl}.
+   *
+   * <p>This code lets users fetch changes that are not among the fraction of most recently modified
+   * changes that {@link SearchingChangeCacheImpl} returns. This works only when Git Protocol v2
+   * with refs-in-wants is used as that enables Gerrit to skip traditional advertisement of all
+   * visible refs.
+   */
+  private boolean canSeeSingleChangeRef(String refName) throws PermissionBackendException {
+    // We are treating just a single change ref. We are therefore not going through regular ref
+    // filtering, but use NoteDb directly. This makes it so that we can always serve this ref
+    // even if the change is not part of the set of most recent changes that
+    // SearchingChangeCacheImpl returns.
+    Change.Id cId = Change.Id.fromRef(refName);
+    requireNonNull(cId, () -> String.format("invalid change id for ref %s", refName));
+    ChangeNotes notes;
+    try {
+      notes = changeNotesFactory.create(projectState.getNameKey(), cId);
+    } catch (StorageException e) {
+      throw new PermissionBackendException("can't construct change notes", e);
+    }
+    try {
+      permissionBackendForProject.change(notes).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+
+  @AutoValue
+  abstract static class Result {
+    /** Subset of the refs passed into the computation that is visible to the user. */
+    abstract Map<String, Ref> visibleRefs();
+
+    /**
+     * List of tags where we couldn't figure out visibility in the first pass and need to do an
+     * expensive ref walk.
+     */
+    abstract List<Ref> deferredTags();
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 431bfd9..5c7ee0d 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.permissions;
 
+import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
@@ -26,7 +25,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackend.WithUser;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Provider;
 import java.util.Collection;
 import java.util.Map;
 import java.util.Set;
@@ -84,11 +82,6 @@
     }
 
     @Override
-    public CurrentUser user() {
-      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
-    }
-
-    @Override
     public ForProject project(Project.NameKey project) {
       return new FailedProject(message, cause);
     }
@@ -103,6 +96,12 @@
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
+
+    @Override
+    public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+      throw new UnsupportedOperationException(
+          "FailedPermissionBackend does not support conditions");
+    }
   }
 
   private static class FailedProject extends ForProject {
@@ -115,26 +114,6 @@
     }
 
     @Override
-    public ForProject database(Provider<ReviewDb> db) {
-      return this;
-    }
-
-    @Override
-    public CurrentUser user() {
-      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
-    }
-
-    @Override
-    public ForProject user(CurrentUser user) {
-      return this;
-    }
-
-    @Override
-    public ForProject absentUser(Account.Id id) {
-      return this;
-    }
-
-    @Override
     public String resourcePath() {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend is not scoped to a resource");
@@ -146,17 +125,23 @@
     }
 
     @Override
-    public void check(ProjectPermission perm) throws PermissionBackendException {
+    public void check(CoreOrPluginProjectPermission perm) throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
 
     @Override
-    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+    public <T extends CoreOrPluginProjectPermission> Set<T> test(Collection<T> permSet)
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
 
     @Override
+    public BooleanCondition testCond(CoreOrPluginProjectPermission perm) {
+      throw new UnsupportedOperationException(
+          "FailedPermissionBackend does not support conditions");
+    }
+
+    @Override
     public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
@@ -173,26 +158,6 @@
     }
 
     @Override
-    public ForRef database(Provider<ReviewDb> db) {
-      return this;
-    }
-
-    @Override
-    public CurrentUser user() {
-      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
-    }
-
-    @Override
-    public ForRef user(CurrentUser user) {
-      return this;
-    }
-
-    @Override
-    public ForRef absentUser(Account.Id id) {
-      return this;
-    }
-
-    @Override
     public String resourcePath() {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend is not scoped to a resource");
@@ -223,6 +188,12 @@
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
+
+    @Override
+    public BooleanCondition testCond(RefPermission perm) {
+      throw new UnsupportedOperationException(
+          "FailedPermissionBackend does not support conditions");
+    }
   }
 
   private static class FailedChange extends ForChange {
@@ -235,21 +206,6 @@
     }
 
     @Override
-    public ForChange database(Provider<ReviewDb> db) {
-      return this;
-    }
-
-    @Override
-    public ForChange user(CurrentUser user) {
-      return this;
-    }
-
-    @Override
-    public ForChange absentUser(Account.Id id) {
-      return this;
-    }
-
-    @Override
     public String resourcePath() {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend is not scoped to a resource");
@@ -267,8 +223,9 @@
     }
 
     @Override
-    public CurrentUser user() {
-      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
+    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
+      throw new UnsupportedOperationException(
+          "FailedPermissionBackend does not support conditions");
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 71718fb..07c9e84 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.api.access.GerritPermission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.registration.PluginName;
 import java.lang.annotation.Annotation;
 import java.util.Collections;
 import java.util.LinkedHashSet;
@@ -42,6 +43,7 @@
   KILL_TASK,
   MAINTAIN_SERVER,
   MODIFY_ACCOUNT,
+  READ_AS,
   RUN_AS,
   RUN_GC,
   STREAM_EVENTS,
@@ -116,7 +118,7 @@
       Class<?> annotationClass)
       throws PermissionBackendException {
     if (pluginName != null
-        && !"gerrit".equals(pluginName)
+        && !PluginName.GERRIT.equals(pluginName)
         && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
       return new PluginPermission(pluginName, capability, fallBackToAdmin);
     }
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index a80cc15..7cce9c4 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
@@ -67,7 +67,7 @@
    * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
    */
   public LabelPermission(ForUser forUser, String name) {
-    this.forUser = checkNotNull(forUser, "ForUser");
+    this.forUser = requireNonNull(forUser, "ForUser");
     this.name = LabelType.checkName(name);
   }
 
@@ -195,8 +195,8 @@
      * @param label label name and vote.
      */
     public WithValue(ForUser forUser, LabelVote label) {
-      this.forUser = checkNotNull(forUser, "ForUser");
-      this.label = checkNotNull(label, "LabelVote");
+      this.forUser = requireNonNull(forUser, "ForUser");
+      this.label = requireNonNull(label, "LabelVote");
     }
 
     /** @return {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 357770d..119d414 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -14,32 +14,33 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toMap;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.ImplementedBy;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -150,45 +151,21 @@
     // delegates to the appropriate testOrFalse method in PermissionBackend.
   }
 
-  /** PermissionBackend with an optional per-request ReviewDb handle. */
-  public abstract static class AcceptsReviewDb<T> {
-    protected Provider<ReviewDb> db;
-
-    public T database(Provider<ReviewDb> db) {
-      if (db != null) {
-        this.db = db;
-      }
-      return self();
-    }
-
-    public T database(ReviewDb db) {
-      return database(Providers.of(checkNotNull(db, "ReviewDb")));
-    }
-
-    @SuppressWarnings("unchecked")
-    private T self() {
-      return (T) this;
-    }
-  }
-
   /** PermissionBackend scoped to a specific user. */
-  public abstract static class WithUser extends AcceptsReviewDb<WithUser> {
-    /** Returns the user this instance is scoped to. */
-    public abstract CurrentUser user();
-
+  public abstract static class WithUser {
     /** Returns an instance scoped for the specified project. */
     public abstract ForProject project(Project.NameKey project);
 
     /** Returns an instance scoped for the {@code ref}, and its parent project. */
-    public ForRef ref(Branch.NameKey ref) {
-      return project(ref.getParentKey()).ref(ref.get()).database(db);
+    public ForRef ref(BranchNameKey ref) {
+      return project(ref.project()).ref(ref.branch());
     }
 
     /** Returns an instance scoped for the change, and its destination ref and project. */
     public ForChange change(ChangeData cd) {
       try {
         return ref(cd.change().getDest()).change(cd);
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
     }
@@ -257,9 +234,7 @@
       }
     }
 
-    public BooleanCondition testCond(GlobalOrPluginPermission perm) {
-      return new PermissionBackendCondition.WithUser(this, perm);
-    }
+    public abstract BooleanCondition testCond(GlobalOrPluginPermission perm);
 
     /**
      * Filter a set of projects using {@code check(perm)}.
@@ -271,8 +246,8 @@
      */
     public Set<Project.NameKey> filter(ProjectPermission perm, Collection<Project.NameKey> projects)
         throws PermissionBackendException {
-      checkNotNull(perm, "ProjectPermission");
-      checkNotNull(projects, "projects");
+      requireNonNull(perm, "ProjectPermission");
+      requireNonNull(projects, "projects");
       Set<Project.NameKey> allowed = Sets.newHashSetWithExpectedSize(projects.size());
       for (Project.NameKey project : projects) {
         try {
@@ -295,34 +270,25 @@
   }
 
   /** PermissionBackend scoped to a user and project. */
-  public abstract static class ForProject extends AcceptsReviewDb<ForProject> {
-    /** Returns the user this instance is scoped to. */
-    public abstract CurrentUser user();
-
+  public abstract static class ForProject {
     /** Returns the fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Returns a new instance rescoped to same project, but different {@code user}. */
-    public abstract ForProject user(CurrentUser user);
-
-    /** @see PermissionBackend#absentUser(Account.Id) */
-    public abstract ForProject absentUser(Account.Id id);
-
     /** Returns an instance scoped for {@code ref} in this project. */
     public abstract ForRef ref(String ref);
 
     /** Returns an instance scoped for the change, and its destination ref and project. */
     public ForChange change(ChangeData cd) {
       try {
-        return ref(cd.change().getDest().get()).change(cd);
-      } catch (OrmException e) {
+        return ref(cd.change().getDest().branch()).change(cd);
+      } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
     }
 
     /** Returns an instance scoped for the change, and its destination ref and project. */
     public ForChange change(ChangeNotes notes) {
-      return ref(notes.getChange().getDest().get()).change(notes);
+      return ref(notes.getChange().getDest().branch()).change(notes);
     }
 
     /**
@@ -331,22 +297,27 @@
      * stale data from the index is acceptable.
      */
     public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return ref(notes.getChange().getDest().get()).indexedChange(cd, notes);
+      return ref(notes.getChange().getDest().branch()).indexedChange(cd, notes);
     }
 
     /** Verify scoped user can {@code perm}, throwing if denied. */
-    public abstract void check(ProjectPermission perm)
+    public abstract void check(CoreOrPluginProjectPermission perm)
         throws AuthException, PermissionBackendException;
 
     /** Filter {@code permSet} to permissions scoped user might be able to perform. */
-    public abstract Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+    public abstract <T extends CoreOrPluginProjectPermission> Set<T> test(Collection<T> permSet)
         throws PermissionBackendException;
 
-    public boolean test(ProjectPermission perm) throws PermissionBackendException {
-      return test(EnumSet.of(perm)).contains(perm);
+    public boolean test(CoreOrPluginProjectPermission perm) throws PermissionBackendException {
+      if (perm instanceof ProjectPermission) {
+        return test(EnumSet.of((ProjectPermission) perm)).contains(perm);
+      }
+
+      // TODO(xchangcheng): implement for plugin defined project permissions.
+      return false;
     }
 
-    public boolean testOrFalse(ProjectPermission perm) {
+    public boolean testOrFalse(CoreOrPluginProjectPermission perm) {
       try {
         return test(perm);
       } catch (PermissionBackendException e) {
@@ -355,9 +326,7 @@
       }
     }
 
-    public BooleanCondition testCond(ProjectPermission perm) {
-      return new PermissionBackendCondition.ForProject(this, perm);
-    }
+    public abstract BooleanCondition testCond(CoreOrPluginProjectPermission perm);
 
     /**
      * Filter a map of references by visibility.
@@ -372,6 +341,21 @@
     public abstract Map<String, Ref> filter(
         Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException;
+
+    /**
+     * Filter a list of references by visibility.
+     *
+     * @param refs a list of references to filter.
+     * @param repo an open {@link Repository} handle for this instance's project
+     * @param opts further options for filtering.
+     * @return a partition of the provided refs that are visible to the user that this instance is
+     *     scoped to.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Map<String, Ref> filter(List<Ref> refs, Repository repo, RefFilterOptions opts)
+        throws PermissionBackendException {
+      return filter(refs.stream().collect(toMap(Ref::getName, r -> r, (a, b) -> b)), repo, opts);
+    }
   }
 
   /** Options for filtering refs using {@link ForProject}. */
@@ -380,22 +364,25 @@
     /** Remove all NoteDb refs (refs/changes/*, refs/users/*, edit refs) from the result. */
     public abstract boolean filterMeta();
 
-    /** Separately add reachable tags. */
-    public abstract boolean filterTagsSeparately();
+    /**
+     * Select only refs with names matching prefixes per {@link
+     * org.eclipse.jgit.lib.RefDatabase#getRefsByPrefix}.
+     */
+    public abstract ImmutableList<String> prefixes();
 
     public abstract Builder toBuilder();
 
     public static Builder builder() {
       return new AutoValue_PermissionBackend_RefFilterOptions.Builder()
           .setFilterMeta(false)
-          .setFilterTagsSeparately(false);
+          .setPrefixes(Collections.singletonList(""));
     }
 
     @AutoValue.Builder
     public abstract static class Builder {
       public abstract Builder setFilterMeta(boolean val);
 
-      public abstract Builder setFilterTagsSeparately(boolean val);
+      public abstract Builder setPrefixes(List<String> prefixes);
 
       public abstract RefFilterOptions build();
     }
@@ -406,19 +393,10 @@
   }
 
   /** PermissionBackend scoped to a user, project and reference. */
-  public abstract static class ForRef extends AcceptsReviewDb<ForRef> {
-    /** Returns the user this instance is scoped to. */
-    public abstract CurrentUser user();
-
+  public abstract static class ForRef {
     /** Returns a fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Returns a new instance rescoped to same reference, but different {@code user}. */
-    public abstract ForRef user(CurrentUser user);
-
-    /** @see PermissionBackend#absentUser(Account.Id) */
-    public abstract ForRef absentUser(Account.Id id);
-
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeData cd);
 
@@ -461,25 +439,14 @@
       }
     }
 
-    public BooleanCondition testCond(RefPermission perm) {
-      return new PermissionBackendCondition.ForRef(this, perm);
-    }
+    public abstract BooleanCondition testCond(RefPermission perm);
   }
 
   /** PermissionBackend scoped to a user, project, reference and change. */
-  public abstract static class ForChange extends AcceptsReviewDb<ForChange> {
-    /** Returns the user this instance is scoped to. */
-    public abstract CurrentUser user();
-
+  public abstract static class ForChange {
     /** Returns the fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Returns a new instance rescoped to same change, but different {@code user}. */
-    public abstract ForChange user(CurrentUser user);
-
-    /** @see PermissionBackend#absentUser(Account.Id) */
-    public abstract ForChange absentUser(Account.Id id);
-
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException;
@@ -511,9 +478,7 @@
       }
     }
 
-    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
-      return new PermissionBackendCondition.ForChange(this, perm);
-    }
+    public abstract BooleanCondition testCond(ChangePermissionOrLabel perm);
 
     /**
      * Test which values of a label the user may be able to set.
@@ -523,7 +488,7 @@
      * @throws PermissionBackendException if failure consulting backend configuration.
      */
     public Set<LabelPermission.WithValue> test(LabelType label) throws PermissionBackendException {
-      return test(valuesOf(checkNotNull(label, "LabelType")));
+      return test(valuesOf(requireNonNull(label, "LabelType")));
     }
 
     /**
@@ -535,14 +500,12 @@
      */
     public Set<LabelPermission.WithValue> testLabels(Collection<LabelType> types)
         throws PermissionBackendException {
-      checkNotNull(types, "LabelType");
+      requireNonNull(types, "LabelType");
       return test(types.stream().flatMap((t) -> valuesOf(t).stream()).collect(toSet()));
     }
 
     private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
-      return label
-          .getValues()
-          .stream()
+      return label.getValues().stream()
           .map((v) -> new LabelPermission.WithValue(label, v))
           .collect(toSet());
     }
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java b/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
index 3a661cda..a92e504 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.permissions;
 
+import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.conditions.PrivateInternals_BooleanCondition;
@@ -56,10 +57,13 @@
   public static class WithUser extends PermissionBackendCondition {
     private final PermissionBackend.WithUser impl;
     private final GlobalOrPluginPermission perm;
+    private final CurrentUser user;
 
-    WithUser(PermissionBackend.WithUser impl, GlobalOrPluginPermission perm) {
+    public WithUser(
+        PermissionBackend.WithUser impl, GlobalOrPluginPermission perm, CurrentUser user) {
       this.impl = impl;
       this.perm = perm;
+      this.user = user;
     }
 
     public PermissionBackend.WithUser withUser() {
@@ -82,7 +86,7 @@
 
     @Override
     public int hashCode() {
-      return Objects.hash(perm, hashForUser(impl.user()));
+      return Objects.hash(perm, hashForUser(user));
     }
 
     @Override
@@ -91,24 +95,27 @@
         return false;
       }
       WithUser other = (WithUser) obj;
-      return Objects.equals(perm, other.perm) && usersAreEqual(impl.user(), other.impl.user());
+      return Objects.equals(perm, other.perm) && usersAreEqual(user, other.user);
     }
   }
 
   public static class ForProject extends PermissionBackendCondition {
     private final PermissionBackend.ForProject impl;
-    private final ProjectPermission perm;
+    private final CoreOrPluginProjectPermission perm;
+    private final CurrentUser user;
 
-    ForProject(PermissionBackend.ForProject impl, ProjectPermission perm) {
+    public ForProject(
+        PermissionBackend.ForProject impl, CoreOrPluginProjectPermission perm, CurrentUser user) {
       this.impl = impl;
       this.perm = perm;
+      this.user = user;
     }
 
     public PermissionBackend.ForProject project() {
       return impl;
     }
 
-    public ProjectPermission permission() {
+    public CoreOrPluginProjectPermission permission() {
       return perm;
     }
 
@@ -124,7 +131,7 @@
 
     @Override
     public int hashCode() {
-      return Objects.hash(perm, impl.resourcePath(), hashForUser(impl.user()));
+      return Objects.hash(perm, impl.resourcePath(), hashForUser(user));
     }
 
     @Override
@@ -135,17 +142,19 @@
       ForProject other = (ForProject) obj;
       return Objects.equals(perm, other.perm)
           && Objects.equals(impl.resourcePath(), other.impl.resourcePath())
-          && usersAreEqual(impl.user(), other.impl.user());
+          && usersAreEqual(user, other.user);
     }
   }
 
   public static class ForRef extends PermissionBackendCondition {
     private final PermissionBackend.ForRef impl;
     private final RefPermission perm;
+    private final CurrentUser user;
 
-    ForRef(PermissionBackend.ForRef impl, RefPermission perm) {
+    public ForRef(PermissionBackend.ForRef impl, RefPermission perm, CurrentUser user) {
       this.impl = impl;
       this.perm = perm;
+      this.user = user;
     }
 
     public PermissionBackend.ForRef ref() {
@@ -168,7 +177,7 @@
 
     @Override
     public int hashCode() {
-      return Objects.hash(perm, impl.resourcePath(), hashForUser(impl.user()));
+      return Objects.hash(perm, impl.resourcePath(), hashForUser(user));
     }
 
     @Override
@@ -179,17 +188,20 @@
       ForRef other = (ForRef) obj;
       return Objects.equals(perm, other.perm)
           && Objects.equals(impl.resourcePath(), other.impl.resourcePath())
-          && usersAreEqual(impl.user(), other.impl.user());
+          && usersAreEqual(user, other.user);
     }
   }
 
   public static class ForChange extends PermissionBackendCondition {
     private final PermissionBackend.ForChange impl;
     private final ChangePermissionOrLabel perm;
+    private final CurrentUser user;
 
-    ForChange(PermissionBackend.ForChange impl, ChangePermissionOrLabel perm) {
+    public ForChange(
+        PermissionBackend.ForChange impl, ChangePermissionOrLabel perm, CurrentUser user) {
       this.impl = impl;
       this.perm = perm;
+      this.user = user;
     }
 
     public PermissionBackend.ForChange change() {
@@ -212,7 +224,7 @@
 
     @Override
     public int hashCode() {
-      return Objects.hash(perm, impl.resourcePath(), hashForUser(impl.user()));
+      return Objects.hash(perm, impl.resourcePath(), hashForUser(user));
     }
 
     @Override
@@ -223,7 +235,7 @@
       ForChange other = (ForChange) obj;
       return Objects.equals(perm, other.perm)
           && Objects.equals(impl.resourcePath(), other.impl.resourcePath())
-          && usersAreEqual(impl.user(), other.impl.user());
+          && usersAreEqual(user, other.user);
     }
   }
 
diff --git a/java/com/google/gerrit/server/permissions/PermissionCollection.java b/java/com/google/gerrit/server/permissions/PermissionCollection.java
index 81e8d24..1a3198d 100644
--- a/java/com/google/gerrit/server/permissions/PermissionCollection.java
+++ b/java/com/google/gerrit/server/permissions/PermissionCollection.java
@@ -26,6 +26,10 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -54,10 +58,18 @@
   @Singleton
   public static class Factory {
     private final SectionSortCache sorter;
+    // TODO(hiesel): Remove this once we got production data
+    private final Timer0 filterLatency;
 
     @Inject
-    Factory(SectionSortCache sorter) {
+    Factory(SectionSortCache sorter, MetricMaker metricMaker) {
       this.sorter = sorter;
+      this.filterLatency =
+          metricMaker.newTimer(
+              "permissions/permission_collection/filter_latency",
+              new Description("Latency for access filter computations in PermissionCollection")
+                  .setCumulative()
+                  .setUnit(Units.NANOSECONDS));
     }
 
     /**
@@ -117,41 +129,42 @@
      */
     PermissionCollection filter(
         Iterable<SectionMatcher> matcherList, String ref, CurrentUser user) {
-      if (isRE(ref)) {
-        ref = RefPattern.shortestExample(ref);
-      } else if (ref.endsWith("/*")) {
-        ref = ref.substring(0, ref.length() - 1);
+      try (Timer0.Context ignored = filterLatency.start()) {
+        if (isRE(ref)) {
+          ref = RefPattern.shortestExample(ref);
+        } else if (ref.endsWith("/*")) {
+          ref = ref.substring(0, ref.length() - 1);
+        }
+
+        // LinkedHashMap to maintain input ordering.
+        Map<AccessSection, Project.NameKey> sectionToProject = new LinkedHashMap<>();
+        boolean perUser = filterRefMatchingSections(matcherList, ref, user, sectionToProject);
+        List<AccessSection> sections = Lists.newArrayList(sectionToProject.keySet());
+
+        // Sort by ref pattern specificity. For equally specific patterns, the sections from the
+        // project closer to the current one come first.
+        sorter.sort(ref, sections);
+
+        // For block permissions, we want a different order: first, we want to go from parent to
+        // child.
+        List<Map.Entry<AccessSection, Project.NameKey>> accessDescending =
+            Lists.reverse(Lists.newArrayList(sectionToProject.entrySet()));
+
+        Map<Project.NameKey, List<AccessSection>> accessByProject =
+            accessDescending.stream()
+                .collect(
+                    Collectors.groupingBy(
+                        Map.Entry::getValue,
+                        LinkedHashMap::new,
+                        mapping(Map.Entry::getKey, toList())));
+        // Within each project, sort by ref specificity.
+        for (List<AccessSection> secs : accessByProject.values()) {
+          sorter.sort(ref, secs);
+        }
+
+        return new PermissionCollection(
+            Lists.newArrayList(accessByProject.values()), sections, perUser);
       }
-
-      // LinkedHashMap to maintain input ordering.
-      Map<AccessSection, Project.NameKey> sectionToProject = new LinkedHashMap<>();
-      boolean perUser = filterRefMatchingSections(matcherList, ref, user, sectionToProject);
-      List<AccessSection> sections = Lists.newArrayList(sectionToProject.keySet());
-
-      // Sort by ref pattern specificity. For equally specific patterns, the sections from the
-      // project closer to the current one come first.
-      sorter.sort(ref, sections);
-
-      // For block permissions, we want a different order: first, we want to go from parent to
-      // child.
-      List<Map.Entry<AccessSection, Project.NameKey>> accessDescending =
-          Lists.reverse(Lists.newArrayList(sectionToProject.entrySet()));
-
-      Map<Project.NameKey, List<AccessSection>> accessByProject =
-          accessDescending
-              .stream()
-              .collect(
-                  Collectors.groupingBy(
-                      Map.Entry::getValue,
-                      LinkedHashMap::new,
-                      mapping(Map.Entry::getKey, toList())));
-      // Within each project, sort by ref specificity.
-      for (List<AccessSection> secs : accessByProject.values()) {
-        sorter.sort(ref, secs);
-      }
-
-      return new PermissionCollection(
-          Lists.newArrayList(accessByProject.values()), sections, perUser);
     }
   }
 
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..b9e86cd
--- /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 java.util.Objects.requireNonNull;
+
+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 + requireNonNull(permission).describeForException());
+    this.permission = permission;
+    this.resource = Optional.empty();
+  }
+
+  public PermissionDeniedException(GerritPermission permission, String resource) {
+    super(
+        MESSAGE_PREFIX
+            + requireNonNull(permission).describeForException()
+            + " on "
+            + requireNonNull(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/PluginPermissionsUtil.java b/java/com/google/gerrit/server/permissions/PluginPermissionsUtil.java
new file mode 100644
index 0000000..b147926
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/PluginPermissionsUtil.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.gerrit.extensions.api.access.PluginProjectPermission.PLUGIN_PERMISSION_NAME_PATTERN_STRING;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.config.PluginPermissionDefinition;
+import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.regex.Pattern;
+
+/** Utilities for plugin permissions. */
+@Singleton
+public final class PluginPermissionsUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String PLUGIN_NAME_PATTERN_STRING = "[a-zA-Z0-9-]+";
+
+  /**
+   * Name pattern for a plugin non-capability permission stored in the config file.
+   *
+   * <p>This pattern requires a plugin declared permission to have a name in the access section of
+   * {@code ProjectConfig} with a format like "plugin-{pluginName}-{permissionName}", which makes it
+   * easier to tell if a config name represents a plugin permission or not. Note "-" isn't clear
+   * enough for this purpose since some core permissions, e.g. "label-", also contain "-".
+   */
+  private static final Pattern PLUGIN_PERMISSION_NAME_IN_CONFIG_PATTERN =
+      Pattern.compile(
+          "^plugin-"
+              + PLUGIN_NAME_PATTERN_STRING
+              + "-"
+              + PLUGIN_PERMISSION_NAME_PATTERN_STRING
+              + "$");
+
+  /** Name pattern for a Gerrit plugin. */
+  private static final Pattern PLUGIN_NAME_PATTERN =
+      Pattern.compile("^" + PLUGIN_NAME_PATTERN_STRING + "$");
+
+  private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
+  private final DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions;
+
+  @Inject
+  PluginPermissionsUtil(
+      DynamicMap<CapabilityDefinition> capabilityDefinitions,
+      DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions) {
+    this.capabilityDefinitions = capabilityDefinitions;
+    this.pluginProjectPermissionDefinitions = pluginProjectPermissionDefinitions;
+  }
+
+  /**
+   * Collects all the plugin declared capabilities.
+   *
+   * @return a map of plugin declared capabilities with "pluginName" as its keys and
+   *     "pluginName-{permissionName}" as its values.
+   */
+  public ImmutableMap<String, String> collectPluginCapabilities() {
+    return collectPermissions(capabilityDefinitions, "");
+  }
+
+  /**
+   * Collects all the plugin declared project permissions.
+   *
+   * @return a map of plugin declared project permissions with "{pluginName}" as its keys and
+   *     "plugin-{pluginName}-{permissionName}" as its values.
+   */
+  public ImmutableMap<String, String> collectPluginProjectPermissions() {
+    return collectPermissions(pluginProjectPermissionDefinitions, "plugin-");
+  }
+
+  private static <T extends PluginPermissionDefinition>
+      ImmutableMap<String, String> collectPermissions(DynamicMap<T> definitions, String prefix) {
+    ImmutableMap.Builder<String, String> permissionIdNames = ImmutableMap.builder();
+
+    for (Extension<T> extension : definitions) {
+      String pluginName = extension.getPluginName();
+      if (!PLUGIN_NAME_PATTERN.matcher(pluginName).matches()) {
+        logger.atWarning().log(
+            "Plugin name '%s' must match '%s' to use permissions; rename the plugin",
+            pluginName, PLUGIN_NAME_PATTERN.pattern());
+        continue;
+      }
+
+      String id = prefix + pluginName + "-" + extension.getExportName();
+      permissionIdNames.put(id, extension.get().getDescription());
+    }
+
+    return permissionIdNames.build();
+  }
+
+  /**
+   * Checks if a given name matches the plugin declared permission name pattern for configs.
+   *
+   * @param name a config name which may stand for a plugin permission.
+   * @return whether the name matches the plugin permission name pattern for configs.
+   */
+  public static boolean isValidPluginPermission(String name) {
+    return PLUGIN_PERMISSION_NAME_IN_CONFIG_PATTERN.matcher(name).matches();
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 2d2a64d..5ebe673 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -15,20 +15,26 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.common.data.AccessSection.ALL;
+import static com.google.gerrit.common.data.AccessSection.REGEX_PREFIX;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
+import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
 
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
+import com.google.gerrit.extensions.api.access.PluginProjectPermission;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
@@ -41,12 +47,10 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionMatcher;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -69,7 +73,6 @@
   private final ChangeControl.Factory changeControlFactory;
   private final PermissionCollection.Factory permissionFilter;
   private final DefaultRefFilter.Factory refFilterFactory;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
   private List<SectionMatcher> allSections;
   private Map<String, RefControl> refControls;
@@ -83,7 +86,6 @@
       ChangeControl.Factory changeControlFactory,
       PermissionBackend permissionBackend,
       DefaultRefFilter.Factory refFilterFactory,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
     this.changeControlFactory = changeControlFactory;
@@ -92,43 +94,25 @@
     this.permissionFilter = permissionFilter;
     this.permissionBackend = permissionBackend;
     this.refFilterFactory = refFilterFactory;
-    this.identifiedUserFactory = identifiedUserFactory;
     user = who;
     state = ps;
   }
 
-  ProjectControl forUser(CurrentUser who) {
-    ProjectControl r =
-        new ProjectControl(
-            uploadGroups,
-            receiveGroups,
-            permissionFilter,
-            changeControlFactory,
-            permissionBackend,
-            refFilterFactory,
-            identifiedUserFactory,
-            who,
-            state);
-    // Not per-user, and reusing saves lookup time.
-    r.allSections = allSections;
-    return r;
-  }
-
   ForProject asForProject() {
     return new ForProjectImpl();
   }
 
-  ChangeControl controlFor(ReviewDb db, Change change) throws OrmException {
+  ChangeControl controlFor(Change change) {
     return changeControlFactory.create(
-        controlForRef(change.getDest()), db, change.getProject(), change.getId());
+        controlForRef(change.getDest()), change.getProject(), change.getId());
   }
 
   ChangeControl controlFor(ChangeNotes notes) {
     return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
   }
 
-  RefControl controlForRef(Branch.NameKey ref) {
-    return controlForRef(ref.get());
+  RefControl controlForRef(BranchNameKey ref) {
+    return controlForRef(ref.branch());
   }
 
   public RefControl controlForRef(String refName) {
@@ -138,7 +122,7 @@
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
       PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl = new RefControl(identifiedUserFactory, this, refName, relevant);
+      ctl = new RefControl(this, refName, relevant);
       refControls.put(refName, ctl);
     }
     return ctl;
@@ -158,7 +142,7 @@
 
   /** Is this user a project owner? */
   boolean isOwner() {
-    return (isDeclaredOwner() && controlForRef("refs/*").canPerform(Permission.OWNER)) || isAdmin();
+    return (isDeclaredOwner() && controlForRef(ALL).canPerform(Permission.OWNER)) || isAdmin();
   }
 
   /**
@@ -212,10 +196,15 @@
     return (canPerformOnAnyRef(Permission.CREATE) || isAdmin());
   }
 
+  private boolean canAddTagRefs() {
+    return (canPerformOnTagRef(Permission.CREATE) || isAdmin());
+  }
+
   private boolean canCreateChanges() {
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.getSection();
-      if (section.getName().startsWith("refs/for/")) {
+      if (section.getName().startsWith(NEW_CHANGE)
+          || section.getName().startsWith(REGEX_PREFIX + NEW_CHANGE)) {
         Permission permission = section.getPermission(Permission.PUSH);
         if (permission != null && controlForRef(section.getName()).canPerform(Permission.PUSH)) {
           return true;
@@ -233,6 +222,27 @@
     return declaredOwner;
   }
 
+  private boolean canPerformOnTagRef(String permissionName) {
+    for (SectionMatcher matcher : access()) {
+      AccessSection section = matcher.getSection();
+
+      if (section.getName().startsWith(REFS_TAGS)
+          || section.getName().startsWith(REGEX_PREFIX + 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,29 +251,37 @@
         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);
-    if (patterns.contains(AccessSection.ALL)) {
+    if (patterns.contains(ALL)) {
       // Only possible if granted on the pattern that
       // matches every possible reference.  Check all
       // patterns also have the permission.
@@ -323,21 +341,6 @@
     private String resourcePath;
 
     @Override
-    public CurrentUser user() {
-      return getUser();
-    }
-
-    @Override
-    public ForProject user(CurrentUser user) {
-      return forUser(user).asForProject().database(db);
-    }
-
-    @Override
-    public ForProject absentUser(Account.Id id) {
-      return user(identifiedUserFactory.create(id));
-    }
-
-    @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath = "/projects/" + getProjectState().getName();
@@ -347,7 +350,7 @@
 
     @Override
     public ForRef ref(String ref) {
-      return controlForRef(ref).asForRef().database(db);
+      return controlForRef(ref).asForRef();
     }
 
     @Override
@@ -355,7 +358,7 @@
       try {
         checkProject(cd.change());
         return super.change(cd);
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
     }
@@ -376,17 +379,18 @@
     }
 
     @Override
-    public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
+    public void check(CoreOrPluginProjectPermission perm)
+        throws AuthException, PermissionBackendException {
       if (!can(perm)) {
         throw new AuthException(perm.describeForException() + " not permitted");
       }
     }
 
     @Override
-    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+    public <T extends CoreOrPluginProjectPermission> Set<T> test(Collection<T> permSet)
         throws PermissionBackendException {
-      EnumSet<ProjectPermission> ok = EnumSet.noneOf(ProjectPermission.class);
-      for (ProjectPermission perm : permSet) {
+      Set<T> ok = Sets.newHashSetWithExpectedSize(permSet.size());
+      for (T perm : permSet) {
         if (can(perm)) {
           ok.add(perm);
         }
@@ -395,6 +399,11 @@
     }
 
     @Override
+    public BooleanCondition testCond(CoreOrPluginProjectPermission perm) {
+      return new PermissionBackendCondition.ForProject(this, perm, getUser());
+    }
+
+    @Override
     public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
       if (refFilter == null) {
@@ -403,6 +412,17 @@
       return refFilter.filter(refs, repo, opts);
     }
 
+    private boolean can(CoreOrPluginProjectPermission perm) throws PermissionBackendException {
+      if (perm instanceof ProjectPermission) {
+        return can((ProjectPermission) perm);
+      } else if (perm instanceof PluginProjectPermission) {
+        // TODO(xchangcheng): implement for plugin defined project permissions.
+        return false;
+      }
+
+      throw new PermissionBackendException(perm.describeForException() + " unsupported");
+    }
+
     private boolean can(ProjectPermission perm) throws PermissionBackendException {
       switch (perm) {
         case ACCESS:
@@ -413,6 +433,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..653303a 100644
--- a/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
 import com.google.gerrit.extensions.api.access.GerritPermission;
 import com.google.gerrit.reviewdb.client.RefNames;
 
-public enum ProjectPermission implements GerritPermission {
+public enum ProjectPermission implements CoreOrPluginProjectPermission {
   /**
    * Can access at least one reference or change within the repository.
    *
@@ -51,6 +52,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
@@ -93,7 +109,7 @@
   }
 
   ProjectPermission(String description) {
-    this.description = checkNotNull(description);
+    this.description = requireNonNull(description);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index cd1f84a..9a2ecdd 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -16,24 +16,24 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.MagicBranch;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.util.Providers;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
@@ -41,13 +41,16 @@
 
 /** Manages access control for Git references (aka branches, tags). */
 class RefControl {
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final ProjectControl projectControl;
   private final String refName;
 
   /** All permissions that apply to this reference. */
   private final PermissionCollection relevant;
 
+  private final CallerFinder callerFinder;
+
   // The next 4 members are cached canPerform() permissions.
 
   private Boolean owner;
@@ -55,15 +58,17 @@
   private Boolean canForgeCommitter;
   private Boolean isVisible;
 
-  RefControl(
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      ProjectControl projectControl,
-      String ref,
-      PermissionCollection relevant) {
-    this.identifiedUserFactory = identifiedUserFactory;
+  RefControl(ProjectControl projectControl, String ref, PermissionCollection relevant) {
     this.projectControl = projectControl;
     this.refName = ref;
     this.relevant = relevant;
+    this.callerFinder =
+        CallerFinder.builder()
+            .addTarget(PermissionBackend.class)
+            .matchSubClasses(true)
+            .matchInnerClasses(true)
+            .skip(1)
+            .build();
   }
 
   ProjectControl getProjectControl() {
@@ -74,14 +79,6 @@
     return projectControl.getUser();
   }
 
-  RefControl forUser(CurrentUser who) {
-    ProjectControl newCtl = projectControl.forUser(who);
-    if (relevant.isUserSpecific()) {
-      return newCtl.controlForRef(refName);
-    }
-    return new RefControl(identifiedUserFactory, newCtl, refName, relevant);
-  }
-
   /** Is this user a ref owner? */
   boolean isOwner() {
     if (owner == null) {
@@ -133,6 +130,12 @@
     return canPerform(Permission.EDIT_TOPIC_NAME, false, true);
   }
 
+  /** @return true if this user can delete changes. */
+  boolean canDeleteChanges(boolean isChangeOwner) {
+    return canPerform(Permission.DELETE_CHANGES)
+        || (isChangeOwner && canPerform(Permission.DELETE_OWN_CHANGES, isChangeOwner, false));
+  }
+
   /** The range of permitted values associated with a label permission. */
   PermissionRange getRange(String permission) {
     return getRange(permission, false);
@@ -193,7 +196,6 @@
       case GIT:
         return false;
 
-      case JSON_RPC:
       case REST_API:
       case SSH_COMMAND:
       case UNKNOWN:
@@ -225,7 +227,6 @@
       case GIT:
         return canPushWithForce() || canPerform(Permission.DELETE);
 
-      case JSON_RPC:
       case REST_API:
       case SSH_COMMAND:
       case UNKNOWN:
@@ -386,15 +387,40 @@
   /** 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' (caller: %s)"
+              + " because this permission is blocked",
+          getUser().getLoggableName(),
+          permissionName,
+          withForce,
+          projectControl.getProject().getName(),
+          refName,
+          callerFinder.findCaller());
       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' (caller: %s)",
+            getUser().getLoggableName(),
+            permissionName,
+            withForce,
+            projectControl.getProject().getName(),
+            refName,
+            callerFinder.findCaller());
         return true;
       }
     }
 
+    logger.atFine().log(
+        "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
+        getUser().getLoggableName(),
+        permissionName,
+        withForce,
+        projectControl.getProject().getName(),
+        refName,
+        callerFinder.findCaller());
     return false;
   }
 
@@ -402,21 +428,6 @@
     private String resourcePath;
 
     @Override
-    public CurrentUser user() {
-      return getUser();
-    }
-
-    @Override
-    public ForRef user(CurrentUser user) {
-      return forUser(user).asForRef().database(db);
-    }
-
-    @Override
-    public ForRef absentUser(Account.Id id) {
-      return user(identifiedUserFactory.create(id));
-    }
-
-    @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath =
@@ -430,10 +441,8 @@
     public ForChange change(ChangeData cd) {
       try {
         // TODO(hiesel) Force callers to call database() and use db instead of cd.db()
-        return getProjectControl()
-            .controlFor(cd.db(), cd.change())
-            .asForChange(cd, Providers.of(cd.db()));
-      } catch (OrmException e) {
+        return getProjectControl().controlFor(cd.change()).asForChange(cd);
+      } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
     }
@@ -447,18 +456,99 @@
           "expected change in project %s, not %s",
           project,
           change.getProject());
-      return getProjectControl().controlFor(notes).asForChange(null, db);
+      return getProjectControl().controlFor(notes).asForChange(null);
     }
 
     @Override
     public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return getProjectControl().controlFor(notes).asForChange(cd, db);
+      return getProjectControl().controlFor(notes).asForChange(cd);
     }
 
     @Override
     public void check(RefPermission perm) throws AuthException, PermissionBackendException {
       if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted for " + refName);
+        PermissionDeniedException pde = new PermissionDeniedException(perm, refName);
+        switch (perm) {
+          case UPDATE:
+            if (refName.equals(RefNames.REFS_CONFIG)) {
+              pde.setAdvice(
+                  "Configuration changes can only be pushed by project owners\n"
+                      + "who also have 'Push' rights on "
+                      + RefNames.REFS_CONFIG);
+            } else {
+              pde.setAdvice("To push into this reference you need 'Push' rights.");
+            }
+            break;
+          case DELETE:
+            pde.setAdvice(
+                "You need 'Delete Reference' rights or 'Push' rights with the \n"
+                    + "'Force Push' flag set to delete references.");
+            break;
+          case CREATE_CHANGE:
+            // This is misleading in the default permission backend, since "create change" on a
+            // branch is encoded as "push" on refs/for/DESTINATION.
+            pde.setAdvice(
+                "You need 'Create Change' rights to upload code review requests.\n"
+                    + "Verify that you are pushing to the right branch.");
+            break;
+          case CREATE:
+            pde.setAdvice("You need 'Create' rights to create new references.");
+            break;
+          case CREATE_SIGNED_TAG:
+            pde.setAdvice("You need 'Create Signed Tag' rights to push a signed tag.");
+            break;
+          case CREATE_TAG:
+            pde.setAdvice("You need 'Create Tag' rights to push a normal tag.");
+            break;
+          case FORCE_UPDATE:
+            pde.setAdvice(
+                "You need 'Push' rights with 'Force' flag set to do a non-fastforward push.");
+            break;
+          case FORGE_AUTHOR:
+            pde.setAdvice(
+                "You need 'Forge Author' rights to push commits with another user as author.");
+            break;
+          case FORGE_COMMITTER:
+            pde.setAdvice(
+                "You need 'Forge Committer' rights to push commits with another user as committer.");
+            break;
+          case FORGE_SERVER:
+            pde.setAdvice(
+                "You need 'Forge Server' rights to push merge commits authored by the server.");
+            break;
+          case MERGE:
+            pde.setAdvice(
+                "You need 'Push Merge' in addition to 'Push' rights to push merge commits.");
+            break;
+
+          case READ:
+            pde.setAdvice("You need 'Read' rights to fetch or clone this ref.");
+            break;
+
+          case READ_CONFIG:
+            pde.setAdvice("You need 'Read' rights on refs/meta/config to see the configuration.");
+            break;
+          case READ_PRIVATE_CHANGES:
+            pde.setAdvice("You need 'Read Private Changes' to see private changes.");
+            break;
+          case SET_HEAD:
+            pde.setAdvice("You need 'Set HEAD' rights to set the default branch.");
+            break;
+          case SKIP_VALIDATION:
+            pde.setAdvice(
+                "You need 'Forge Author', 'Forge Server', 'Forge Committer'\n"
+                    + "and 'Push Merge' rights to skip validation.");
+            break;
+          case UPDATE_BY_SUBMIT:
+            pde.setAdvice(
+                "You need 'Submit' rights on refs/for/ to submit changes during change upload.");
+            break;
+
+          case WRITE_CONFIG:
+            pde.setAdvice("You need 'Write' rights on refs/meta/config.");
+            break;
+        }
+        throw pde;
       }
     }
 
@@ -474,6 +564,11 @@
       return ok;
     }
 
+    @Override
+    public BooleanCondition testCond(RefPermission perm) {
+      return new PermissionBackendCondition.ForRef(this, perm, getUser());
+    }
+
     private boolean can(RefPermission perm) throws PermissionBackendException {
       switch (perm) {
         case READ:
diff --git a/java/com/google/gerrit/server/permissions/RefPermission.java b/java/com/google/gerrit/server/permissions/RefPermission.java
index a9f2758..09eed24 100644
--- a/java/com/google/gerrit/server/permissions/RefPermission.java
+++ b/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.extensions.api.access.GerritPermission;
 
@@ -81,7 +81,7 @@
   }
 
   RefPermission(String description) {
-    this.description = checkNotNull(description);
+    this.description = requireNonNull(description);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index 48c8bff..814a8d2 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)) {
@@ -142,7 +141,7 @@
     }
 
     @Override
-    public int hashCode() {
+    public final int hashCode() {
       return cachedHashCode();
     }
   }
diff --git a/java/com/google/gerrit/server/plugincontext/PluginContext.java b/java/com/google/gerrit/server/plugincontext/PluginContext.java
new file mode 100644
index 0000000..90d56c8
--- /dev/null
+++ b/java/com/google/gerrit/server/plugincontext/PluginContext.java
@@ -0,0 +1,423 @@
+// 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.plugincontext;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * Context for invoking plugin extensions.
+ *
+ * <p>Invoking a plugin extension through a PluginContext sets a logging tag with the plugin name is
+ * set. This way any errors that are triggered by the plugin extension (even if they happen in
+ * Gerrit code which is called by the plugin extension) can be easily attributed to the plugin.
+ *
+ * <p>If possible plugin extensions should be invoked through:
+ *
+ * <ul>
+ *   <li>{@link PluginItemContext} for extensions from {@link DynamicItem}
+ *   <li>{@link PluginSetContext} for extensions from {@link DynamicSet}
+ *   <li>{@link PluginMapContext} for extensions from {@link DynamicMap}
+ * </ul>
+ *
+ * <p>A plugin context can be manually opened by invoking the newTrace methods. This should only be
+ * needed if an extension throws multiple exceptions that need to be handled:
+ *
+ * <pre>
+ * public interface Foo {
+ *   void doFoo() throws Exception1, Exception2, Exception3;
+ * }
+ *
+ * ...
+ *
+ * for (Extension<Foo> fooExtension : fooDynamicMap) {
+ *   try (TraceContext traceContext = PluginContext.newTrace(fooExtension)) {
+ *     fooExtension.get().doFoo();
+ *   }
+ * }
+ * </pre>
+ *
+ * <p>This class hosts static methods with generic functionality to invoke plugin extensions with a
+ * trace context that are commonly used by {@link PluginItemContext}, {@link PluginSetContext} and
+ * {@link PluginMapContext}.
+ *
+ * <p>The run* methods execute an extension but don't deliver a result back to the caller.
+ * Exceptions can be caught and logged.
+ *
+ * <p>The call* methods execute an extension and deliver a result back to the caller.
+ */
+public class PluginContext<T> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @FunctionalInterface
+  public interface ExtensionImplConsumer<T> {
+    void run(T t) throws Exception;
+  }
+
+  @FunctionalInterface
+  public interface ExtensionImplFunction<T, R> {
+    R call(T input);
+  }
+
+  @FunctionalInterface
+  public interface CheckedExtensionImplFunction<T, R, X extends Exception> {
+    R call(T input) throws X;
+  }
+
+  @FunctionalInterface
+  public interface ExtensionConsumer<T extends Extension<?>> {
+    void run(T extension) throws Exception;
+  }
+
+  @FunctionalInterface
+  public interface ExtensionFunction<T extends Extension<?>, R> {
+    R call(T extension);
+  }
+
+  @FunctionalInterface
+  public interface CheckedExtensionFunction<T extends Extension<?>, R, X extends Exception> {
+    R call(T extension) throws X;
+  }
+
+  @Singleton
+  public static class PluginMetrics {
+    public static final PluginMetrics DISABLED_INSTANCE =
+        new PluginMetrics(new DisabledMetricMaker());
+
+    final Timer3<String, String, String> latency;
+    final Counter3<String, String, String> errorCount;
+
+    @Inject
+    PluginMetrics(MetricMaker metricMaker) {
+      Field<String> pluginNameField =
+          Field.ofString("plugin_name", Metadata.Builder::pluginName).build();
+      Field<String> classNameField =
+          Field.ofString("class_name", Metadata.Builder::className).build();
+      Field<String> exportValueField =
+          Field.ofString("export_value", Metadata.Builder::exportValue).build();
+
+      this.latency =
+          metricMaker.newTimer(
+              "plugin/latency",
+              new Description("Latency for plugin invocation")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS),
+              pluginNameField,
+              classNameField,
+              exportValueField);
+      this.errorCount =
+          metricMaker.newCounter(
+              "plugin/error_count",
+              new Description("Number of plugin errors").setCumulative().setUnit("errors"),
+              pluginNameField,
+              classNameField,
+              exportValueField);
+    }
+
+    Timer3.Context<String, String, String> startLatency(Extension<?> extension) {
+      return latency.start(
+          extension.getPluginName(),
+          extension.get().getClass().getName(),
+          Strings.nullToEmpty(extension.getExportName()));
+    }
+
+    void incrementErrorCount(Extension<?> extension) {
+      errorCount.increment(
+          extension.getPluginName(),
+          extension.get().getClass().getName(),
+          Strings.nullToEmpty(extension.getExportName()));
+    }
+  }
+
+  /**
+   * Opens a new trace context for invoking a plugin extension.
+   *
+   * @param dynamicItem dynamic item that holds the extension implementation that is being invoked
+   *     from within the trace context
+   * @return the created trace context
+   */
+  public static <T> TraceContext newTrace(DynamicItem<T> dynamicItem) {
+    Extension<T> extension = dynamicItem.getEntry();
+    if (extension == null) {
+      return TraceContext.open();
+    }
+    return newTrace(extension);
+  }
+
+  /**
+   * Opens a new trace context for invoking a plugin extension.
+   *
+   * @param extension extension that is being invoked from within the trace context
+   * @return the created trace context
+   */
+  public static <T> TraceContext newTrace(Extension<T> extension) {
+    return TraceContext.open().addPluginTag(requireNonNull(extension).getPluginName());
+  }
+
+  /**
+   * Runs a plugin extension. All exceptions from the plugin extension are caught and logged.
+   *
+   * <p>The consumer gets the extension implementation provided that should be invoked.
+   *
+   * @param pluginMetrics the plugin metrics
+   * @param extension extension that is being invoked
+   * @param extensionImplConsumer the consumer that invokes the extension
+   */
+  static <T> void runLogExceptions(
+      PluginMetrics pluginMetrics,
+      Extension<T> extension,
+      ExtensionImplConsumer<T> extensionImplConsumer) {
+    T extensionImpl = extension.get();
+    if (extensionImpl == null) {
+      return;
+    }
+    try (TraceContext traceContext = newTrace(extension);
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
+      extensionImplConsumer.run(extensionImpl);
+    } catch (Throwable e) {
+      pluginMetrics.incrementErrorCount(extension);
+      logger.atWarning().withCause(e).log(
+          "Failure in %s of plugin %s", extensionImpl.getClass(), extension.getPluginName());
+    }
+  }
+
+  /**
+   * Runs a plugin extension. All exceptions from the plugin extension are caught and logged.
+   *
+   * <p>The consumer get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * @param pluginMetrics the plugin metrics
+   * @param extension extension that is being invoked
+   * @param extensionConsumer the consumer that invokes the extension
+   */
+  static <T> void runLogExceptions(
+      PluginMetrics pluginMetrics,
+      Extension<T> extension,
+      ExtensionConsumer<Extension<T>> extensionConsumer) {
+    T extensionImpl = extension.get();
+    if (extensionImpl == null) {
+      return;
+    }
+
+    try (TraceContext traceContext = newTrace(extension);
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
+      extensionConsumer.run(extension);
+    } catch (Throwable e) {
+      pluginMetrics.incrementErrorCount(extension);
+      logger.atWarning().withCause(e).log(
+          "Failure in %s of plugin %s", extensionImpl.getClass(), extension.getPluginName());
+    }
+  }
+
+  /**
+   * Runs a plugin extension. All exceptions from the plugin extension except exceptions of the
+   * specified type are caught and logged. Exceptions of the specified type are thrown and must be
+   * handled by the caller.
+   *
+   * <p>The consumer gets the extension implementation provided that should be invoked.
+   *
+   * @param pluginMetrics the plugin metrics
+   * @param extension extension that is being invoked
+   * @param extensionImplConsumer the consumer that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @throws X expected exception from the plugin extension
+   */
+  static <T, X extends Exception> void runLogExceptions(
+      PluginMetrics pluginMetrics,
+      Extension<T> extension,
+      ExtensionImplConsumer<T> extensionImplConsumer,
+      Class<X> exceptionClass)
+      throws X {
+    T extensionImpl = extension.get();
+    if (extensionImpl == null) {
+      return;
+    }
+
+    try (TraceContext traceContext = newTrace(extension);
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
+      extensionImplConsumer.run(extensionImpl);
+    } catch (Throwable e) {
+      Throwables.throwIfInstanceOf(e, exceptionClass);
+      Throwables.throwIfUnchecked(e);
+      pluginMetrics.incrementErrorCount(extension);
+      logger.atWarning().withCause(e).log(
+          "Failure in %s of plugin %s", extensionImpl.getClass(), extension.getPluginName());
+    }
+  }
+
+  /**
+   * Runs a plugin extension. All exceptions from the plugin extension except exceptions of the
+   * specified type are caught and logged. Exceptions of the specified type are thrown and must be
+   * handled by the caller.
+   *
+   * <p>The consumer get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * @param pluginMetrics the plugin metrics
+   * @param extension extension that is being invoked
+   * @param extensionConsumer the consumer that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @throws X expected exception from the plugin extension
+   */
+  static <T, X extends Exception> void runLogExceptions(
+      PluginMetrics pluginMetrics,
+      Extension<T> extension,
+      ExtensionConsumer<Extension<T>> extensionConsumer,
+      Class<X> exceptionClass)
+      throws X {
+    T extensionImpl = extension.get();
+    if (extensionImpl == null) {
+      return;
+    }
+
+    try (TraceContext traceContext = newTrace(extension);
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
+      extensionConsumer.run(extension);
+    } catch (Throwable e) {
+      Throwables.throwIfInstanceOf(e, exceptionClass);
+      Throwables.throwIfUnchecked(e);
+      pluginMetrics.incrementErrorCount(extension);
+      logger.atWarning().withCause(e).log(
+          "Failure in %s of plugin %s", extensionImpl.getClass(), extension.getPluginName());
+    }
+  }
+
+  /**
+   * Calls a plugin extension and returns the result from the plugin extension call.
+   *
+   * <p>The function gets the extension implementation provided that should be invoked.
+   *
+   * @param pluginMetrics the plugin metrics
+   * @param extension extension that is being invoked
+   * @param extensionImplFunction function that invokes the extension
+   * @return the result from the plugin extension
+   */
+  static <T, R> R call(
+      PluginMetrics pluginMetrics,
+      Extension<T> extension,
+      ExtensionImplFunction<T, R> extensionImplFunction) {
+    try (TraceContext traceContext = newTrace(extension);
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
+      return extensionImplFunction.call(extension.get());
+    }
+  }
+
+  /**
+   * Calls a plugin extension and returns the result from the plugin extension call. Exceptions of
+   * the specified type are thrown and must be handled by the caller.
+   *
+   * <p>The function gets the extension implementation provided that should be invoked.
+   *
+   * @param pluginMetrics the plugin metrics
+   * @param extension extension that is being invoked
+   * @param checkedExtensionImplFunction function that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @return the result from the plugin extension
+   * @throws X expected exception from the plugin extension
+   */
+  static <T, R, X extends Exception> R call(
+      PluginMetrics pluginMetrics,
+      Extension<T> extension,
+      CheckedExtensionImplFunction<T, R, X> checkedExtensionImplFunction,
+      Class<X> exceptionClass)
+      throws X {
+    try (TraceContext traceContext = newTrace(extension);
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
+      try {
+        return checkedExtensionImplFunction.call(extension.get());
+      } catch (Exception e) {
+        // The only exception that can be thrown is X, but we cannot catch X since it is a generic
+        // type.
+        Throwables.throwIfInstanceOf(e, exceptionClass);
+        Throwables.throwIfUnchecked(e);
+        throw new IllegalStateException("unexpected exception: " + e.getMessage(), e);
+      }
+    }
+  }
+
+  /**
+   * Calls a plugin extension and returns the result from the plugin extension call.
+   *
+   * <p>The function get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * @param pluginMetrics the plugin metrics
+   * @param extension extension that is being invoked
+   * @param extensionFunction function that invokes the extension
+   * @return the result from the plugin extension
+   */
+  static <T, R> R call(
+      PluginMetrics pluginMetrics,
+      Extension<T> extension,
+      ExtensionFunction<Extension<T>, R> extensionFunction) {
+    try (TraceContext traceContext = newTrace(extension);
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
+      return extensionFunction.call(extension);
+    }
+  }
+
+  /**
+   * Calls a plugin extension and returns the result from the plugin extension call. Exceptions of
+   * the specified type are thrown and must be handled by the caller.
+   *
+   * <p>The function get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * @param pluginMetrics the plugin metrics
+   * @param extension extension that is being invoked
+   * @param checkedExtensionFunction function that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @return the result from the plugin extension
+   * @throws X expected exception from the plugin extension
+   */
+  static <T, R, X extends Exception> R call(
+      PluginMetrics pluginMetrics,
+      Extension<T> extension,
+      CheckedExtensionFunction<Extension<T>, R, X> checkedExtensionFunction,
+      Class<X> exceptionClass)
+      throws X {
+    try (TraceContext traceContext = newTrace(extension);
+        Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
+      try {
+        return checkedExtensionFunction.call(extension);
+      } catch (Exception e) {
+        // The only exception that can be thrown is X, but we cannot catch X since it is a generic
+        // type.
+        Throwables.throwIfInstanceOf(e, exceptionClass);
+        Throwables.throwIfUnchecked(e);
+        throw new IllegalStateException("unexpected exception: " + e.getMessage(), e);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/plugincontext/PluginItemContext.java b/java/com/google/gerrit/server/plugincontext/PluginItemContext.java
new file mode 100644
index 0000000..421b3ad
--- /dev/null
+++ b/java/com/google/gerrit/server/plugincontext/PluginItemContext.java
@@ -0,0 +1,194 @@
+// 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.plugincontext;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.server.plugincontext.PluginContext.CheckedExtensionImplFunction;
+import com.google.gerrit.server.plugincontext.PluginContext.ExtensionImplConsumer;
+import com.google.gerrit.server.plugincontext.PluginContext.ExtensionImplFunction;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.inject.Inject;
+
+/**
+ * Context to invoke an extension from a {@link DynamicItem}.
+ *
+ * <p>When the plugin extension is invoked a logging tag with the plugin name is set. This way any
+ * errors that are triggered by the plugin extension (even if they happen in Gerrit code which is
+ * called by the plugin extension) can be easily attributed to the plugin.
+ *
+ * <p>The run* methods execute an extension but don't deliver a result back to the caller.
+ * Exceptions can be caught and logged.
+ *
+ * <p>The call* methods execute an extension and deliver a result back to the caller.
+ *
+ * <p>Example if all exceptions should be caught and logged:
+ *
+ * <pre>
+ * fooPluginItemContext.run(foo -> foo.doFoo());
+ * </pre>
+ *
+ * <p>Example if all exceptions, but one, should be caught and logged:
+ *
+ * <pre>
+ * try {
+ *   fooPluginItemContext.run(foo -> foo.doFoo(), MyException.class);
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if return values should be handled:
+ *
+ * <pre>
+ * Object result = fooPluginItemContext.call(foo -> foo.getFoo());
+ * </pre>
+ *
+ * <p>Example if return values and a single exception should be handled:
+ *
+ * <pre>
+ * Object result;
+ * try {
+ *   result = fooPluginItemContext.call(foo -> foo.getFoo(), MyException.class);
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if several exceptions should be handled:
+ *
+ * <pre>
+ * try (TraceContext traceContext = PluginContext.newTrace(fooDynamicItem.getEntry())) {
+ *   fooDynamicItem.get().doFoo();
+ * } catch (MyException1 | MyException2 | MyException3 e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ */
+public class PluginItemContext<T> {
+  @Nullable private final DynamicItem<T> dynamicItem;
+  private final PluginMetrics pluginMetrics;
+
+  @VisibleForTesting
+  @Inject
+  public PluginItemContext(DynamicItem<T> dynamicItem, PluginMetrics pluginMetrics) {
+    this.dynamicItem = dynamicItem;
+    this.pluginMetrics = pluginMetrics;
+  }
+
+  /**
+   * Checks if an implementation for this extension point has been registered.
+   *
+   * @return {@code true} if an implementation for this extension point has been registered,
+   *     otherwise {@code false}
+   */
+  public boolean hasImplementation() {
+    return dynamicItem.getEntry() != null;
+  }
+
+  /**
+   * Returns the name of the plugin that registered the extension.
+   *
+   * @return the plugin name, {@code null} if no implementation is registered for this extension
+   *     point
+   */
+  @Nullable
+  public String getPluginName() {
+    return dynamicItem.getPluginName();
+  }
+
+  /**
+   * Invokes the plugin extension of the item. All exceptions from the plugin extension are caught
+   * and logged.
+   *
+   * <p>The consumer gets the extension implementation provided that should be invoked.
+   *
+   * <p>No-op if no implementation is registered for this extension point.
+   *
+   * @param extensionImplConsumer consumer that invokes the extension
+   */
+  public void run(ExtensionImplConsumer<T> extensionImplConsumer) {
+    Extension<T> extension = dynamicItem.getEntry();
+    if (extension == null) {
+      return;
+    }
+    PluginContext.runLogExceptions(pluginMetrics, extension, extensionImplConsumer);
+  }
+
+  /**
+   * Invokes the plugin extension of the item. All exceptions from the plugin extension are caught
+   * and logged.
+   *
+   * <p>The consumer gets the extension implementation provided that should be invoked.
+   *
+   * <p>No-op if no implementation is registered for this extension point.
+   *
+   * @param extensionImplConsumer consumer that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @throws X expected exception from the plugin extension
+   */
+  public <X extends Exception> void run(
+      ExtensionImplConsumer<T> extensionImplConsumer, Class<X> exceptionClass) throws X {
+    Extension<T> extension = dynamicItem.getEntry();
+    if (extension == null) {
+      return;
+    }
+    PluginContext.runLogExceptions(pluginMetrics, extension, extensionImplConsumer, exceptionClass);
+  }
+
+  /**
+   * Calls the plugin extension of the item and returns the result from the plugin extension call.
+   *
+   * <p>The function gets the extension implementation provided that should be invoked.
+   *
+   * <p>Fails with {@link IllegalStateException} if no implementation is registered for the item.
+   *
+   * @param extensionImplFunction function that invokes the extension
+   * @return the result from the plugin extension
+   * @throws IllegalStateException if no implementation is registered for the item
+   */
+  public <R> R call(ExtensionImplFunction<T, R> extensionImplFunction) {
+    Extension<T> extension = dynamicItem.getEntry();
+    checkState(extension != null);
+    return PluginContext.call(pluginMetrics, extension, extensionImplFunction);
+  }
+
+  /**
+   * Calls the plugin extension of the item and returns the result from the plugin extension call.
+   * Exceptions of the specified type are thrown and must be handled by the caller.
+   *
+   * <p>The function gets the extension implementation provided that should be invoked.
+   *
+   * <p>Fails with {@link IllegalStateException} if no implementation is registered for the item.
+   *
+   * @param checkedExtensionImplFunction function that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @return the result from the plugin extension
+   * @throws X expected exception from the plugin extension
+   * @throws IllegalStateException if no implementation is registered for the item
+   */
+  public <R, X extends Exception> R call(
+      CheckedExtensionImplFunction<T, R, X> checkedExtensionImplFunction, Class<X> exceptionClass)
+      throws X {
+    Extension<T> extension = dynamicItem.getEntry();
+    checkState(extension != null);
+    return PluginContext.call(
+        pluginMetrics, extension, checkedExtensionImplFunction, exceptionClass);
+  }
+}
diff --git a/java/com/google/gerrit/server/plugincontext/PluginMapContext.java b/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
new file mode 100644
index 0000000..b02ad27
--- /dev/null
+++ b/java/com/google/gerrit/server/plugincontext/PluginMapContext.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.plugincontext;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Iterators;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.server.plugincontext.PluginContext.ExtensionConsumer;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.inject.Inject;
+import java.util.Iterator;
+import java.util.SortedSet;
+
+/**
+ * Context to invoke extensions from a {@link DynamicMap}.
+ *
+ * <p>When a plugin extension is invoked a logging tag with the plugin name is set. This way any
+ * errors that are triggered by the plugin extension (even if they happen in Gerrit code which is
+ * called by the plugin extension) can be easily attributed to the plugin.
+ *
+ * <p>Example if all exceptions should be caught and logged:
+ *
+ * <pre>
+ * Map<String, Object> results = new HashMap<>();
+ * fooPluginMapContext.runEach(
+ *     extension -> results.put(extension.getExportName(), extension.get().getFoo());
+ * </pre>
+ *
+ * <p>Example if all exceptions, but one, should be caught and logged:
+ *
+ * <pre>
+ * Map<String, Object> results = new HashMap<>();
+ * try {
+ *   fooPluginMapContext.runEach(
+ *       extension -> results.put(extension.getExportName(), extension.get().getFoo(),
+ *       MyException.class);
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if return values should be handled:
+ *
+ * <pre>
+ * Map<String, Object> results = new HashMap<>();
+ * for (PluginMapEntryContext<Foo> c : fooPluginMapContext) {
+ *   if (c.call(extension -> extension.get().handles(x))) {
+ *     c.run(extension -> results.put(extension.getExportName(), extension.get().getFoo());
+ *   }
+ * }
+ * </pre>
+ *
+ * <p>Example if return values and a single exception should be handled:
+ *
+ * <pre>
+ * Map<String, Object> results = new HashMap<>();
+ * try {
+ *   for (PluginMapEntryContext<Foo> c : fooPluginMapContext) {
+ *     if (c.call(extension -> extension.handles(x), MyException.class)) {
+ *       c.run(extension -> results.put(extension.getExportName(), extension.get().getFoo(),
+ *           MyException.class);
+ *     }
+ *   }
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if several exceptions should be handled:
+ *
+ * <pre>
+ * for (Extension<Foo> fooExtension : fooDynamicMap) {
+ *   try (TraceContext traceContext = PluginContext.newTrace(fooExtension)) {
+ *     fooExtension.get().doFoo();
+ *   } catch (MyException1 | MyException2 | MyException3 e) {
+ *     // handle the exception
+ *   }
+ * }
+ * </pre>
+ */
+public class PluginMapContext<T> implements Iterable<PluginMapEntryContext<T>> {
+  private final DynamicMap<T> dynamicMap;
+  private final PluginMetrics pluginMetrics;
+
+  @VisibleForTesting
+  @Inject
+  public PluginMapContext(DynamicMap<T> dynamicMap, PluginMetrics pluginMetrics) {
+    this.dynamicMap = dynamicMap;
+    this.pluginMetrics = pluginMetrics;
+  }
+
+  /**
+   * Iterator that provides contexts for invoking the extensions in this map.
+   *
+   * <p>This is useful if:
+   *
+   * <ul>
+   *   <li>invoking of each extension returns a result that should be handled
+   *   <li>a sequence of invocations should be done on each extension
+   * </ul>
+   */
+  @Override
+  public Iterator<PluginMapEntryContext<T>> iterator() {
+    return Iterators.transform(
+        dynamicMap.iterator(), e -> new PluginMapEntryContext<>(e, pluginMetrics));
+  }
+
+  /**
+   * Checks if no implementations for this extension point have been registered.
+   *
+   * @return {@code true} if no implementations for this extension point have been registered,
+   *     otherwise {@code false}
+   */
+  public boolean isEmpty() {
+    return !dynamicMap.iterator().hasNext();
+  }
+
+  /**
+   * Returns a sorted list of the plugins that have registered implementations for this extension
+   * point.
+   *
+   * @return sorted list of the plugins that have registered implementations for this extension
+   *     point
+   */
+  public SortedSet<String> plugins() {
+    return dynamicMap.plugins();
+  }
+
+  /**
+   * Invokes each extension in the map. All exceptions from the plugin extensions are caught and
+   * logged.
+   *
+   * <p>The consumer get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * <p>All extension in the map are invoked, even if invoking some of the extensions failed.
+   *
+   * @param extensionConsumer consumer that invokes the extension
+   */
+  public void runEach(ExtensionConsumer<Extension<T>> extensionConsumer) {
+    dynamicMap.forEach(p -> PluginContext.runLogExceptions(pluginMetrics, p, extensionConsumer));
+  }
+
+  /**
+   * Invokes each extension in the map. All exceptions from the plugin extensions except exceptions
+   * of the specified type are caught and logged.
+   *
+   * <p>The consumer get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * <p>All extension in the map are invoked, even if invoking some of the extensions failed.
+   *
+   * @param extensionConsumer consumer that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @throws X expected exception from the plugin extension
+   */
+  public <X extends Exception> void runEach(
+      ExtensionConsumer<Extension<T>> extensionConsumer, Class<X> exceptionClass) throws X {
+    for (Extension<T> extension : dynamicMap) {
+      PluginContext.runLogExceptions(pluginMetrics, extension, extensionConsumer, exceptionClass);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/plugincontext/PluginMapEntryContext.java b/java/com/google/gerrit/server/plugincontext/PluginMapEntryContext.java
new file mode 100644
index 0000000..68589cf
--- /dev/null
+++ b/java/com/google/gerrit/server/plugincontext/PluginMapEntryContext.java
@@ -0,0 +1,173 @@
+// 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.plugincontext;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.server.plugincontext.PluginContext.CheckedExtensionFunction;
+import com.google.gerrit.server.plugincontext.PluginContext.ExtensionConsumer;
+import com.google.gerrit.server.plugincontext.PluginContext.ExtensionFunction;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+
+/**
+ * Context to invoke an extension from {@link DynamicMap}.
+ *
+ * <p>When the plugin extension is invoked a logging tag with the plugin name is set. This way any
+ * errors that are triggered by the plugin extension (even if they happen in Gerrit code which is
+ * called by the plugin extension) can be easily attributed to the plugin.
+ *
+ * <p>The run* methods execute the extension but don't deliver a result back to the caller.
+ * Exceptions can be caught and logged.
+ *
+ * <p>The call* methods execute the extension and deliver a result back to the caller.
+ *
+ * <pre>
+ * Map<String, Object> results = new HashMap<>();
+ * fooPluginMapEntryContext.run(
+ *     extension -> results.put(extension.getExportName(), extension.get().getFoo());
+ * </pre>
+ *
+ * <p>Example if all exceptions, but one, should be caught and logged:
+ *
+ * <pre>
+ * Map<String, Object> results = new HashMap<>();
+ * try {
+ *   fooPluginMapEntryContext.run(
+ *     extension -> results.put(extension.getExportName(), extension.get().getFoo(),
+ *     MyException.class);
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if return values should be handled:
+ *
+ * <pre>
+ * Object result = fooPluginMapEntryContext.call(extension -> extension.get().getFoo());
+ * </pre>
+ *
+ * <p>Example if return values and a single exception should be handled:
+ *
+ * <pre>
+ * Object result;
+ * try {
+ *   result = fooPluginMapEntryContext.call(extension -> extension.get().getFoo(), MyException.class);
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if several exceptions should be handled:
+ *
+ * <pre>
+ * for (Extension<Foo> fooExtension : fooDynamicMap) {
+ *   try (TraceContext traceContext = PluginContext.newTrace(fooExtension)) {
+ *     fooExtension.get().doFoo();
+ *   } catch (MyException1 | MyException2 | MyException3 e) {
+ *     // handle the exception
+ *   }
+ * }
+ * </pre>
+ */
+public class PluginMapEntryContext<T> {
+  private final Extension<T> extension;
+  private final PluginMetrics pluginMetrics;
+
+  PluginMapEntryContext(Extension<T> extension, PluginMetrics pluginMetrics) {
+    requireNonNull(extension);
+    requireNonNull(extension.getExportName(), "export name must be set for plugin map entries");
+    this.extension = extension;
+    this.pluginMetrics = pluginMetrics;
+  }
+
+  /**
+   * Returns the name of the plugin that registered this map entry.
+   *
+   * @return the plugin name
+   */
+  public String getPluginName() {
+    return extension.getPluginName();
+  }
+
+  /**
+   * Returns the export name for which this map entry was registered.
+   *
+   * @return the export name
+   */
+  public String getExportName() {
+    return extension.getExportName();
+  }
+
+  /**
+   * Invokes the plugin extension. All exceptions from the plugin extension are caught and logged.
+   *
+   * <p>The consumer get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * @param extensionConsumer consumer that invokes the extension
+   */
+  public void run(ExtensionConsumer<Extension<T>> extensionConsumer) {
+    PluginContext.runLogExceptions(pluginMetrics, extension, extensionConsumer);
+  }
+
+  /**
+   * Invokes the plugin extension. All exceptions from the plugin extension are caught and logged.
+   *
+   * <p>The consumer get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * @param extensionConsumer consumer that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @throws X expected exception from the plugin extension
+   */
+  public <X extends Exception> void run(
+      ExtensionConsumer<Extension<T>> extensionConsumer, Class<X> exceptionClass) throws X {
+    PluginContext.runLogExceptions(pluginMetrics, extension, extensionConsumer, exceptionClass);
+  }
+
+  /**
+   * Calls the plugin extension and returns the result from the plugin extension call.
+   *
+   * <p>The consumer get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * @param extensionFunction function that invokes the extension
+   * @return the result from the plugin extension
+   */
+  public <R> R call(ExtensionFunction<Extension<T>, R> extensionFunction) {
+    return PluginContext.call(pluginMetrics, extension, extensionFunction);
+  }
+
+  /**
+   * Calls the plugin extension and returns the result from the plugin extension call. Exceptions of
+   * the specified type are thrown and must be handled by the caller.
+   *
+   * <p>The consumer get the {@link Extension} provided that should be invoked. The extension
+   * provides access to the plugin name and the export name.
+   *
+   * @param checkedExtensionFunction function that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @return the result from the plugin extension
+   * @throws X expected exception from the plugin extension
+   */
+  public <R, X extends Exception> R call(
+      CheckedExtensionFunction<Extension<T>, R, X> checkedExtensionFunction,
+      Class<X> exceptionClass)
+      throws X {
+    return PluginContext.call(pluginMetrics, extension, checkedExtensionFunction, exceptionClass);
+  }
+}
diff --git a/java/com/google/gerrit/server/plugincontext/PluginSetContext.java b/java/com/google/gerrit/server/plugincontext/PluginSetContext.java
new file mode 100644
index 0000000..b64cfeb
--- /dev/null
+++ b/java/com/google/gerrit/server/plugincontext/PluginSetContext.java
@@ -0,0 +1,174 @@
+// 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.plugincontext;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Iterators;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.server.plugincontext.PluginContext.ExtensionImplConsumer;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.inject.Inject;
+import java.util.Iterator;
+import java.util.SortedSet;
+import java.util.stream.Stream;
+
+/**
+ * Context to invoke extensions from a {@link DynamicSet}.
+ *
+ * <p>When a plugin extension is invoked a logging tag with the plugin name is set. This way any
+ * errors that are triggered by the plugin extension (even if they happen in Gerrit code which is
+ * called by the plugin extension) can be easily attributed to the plugin.
+ *
+ * <p>Example if all exceptions should be caught and logged:
+ *
+ * <pre>
+ * fooPluginSetContext.runEach(foo -> foo.doFoo());
+ * </pre>
+ *
+ * <p>Example if all exceptions, but one, should be caught and logged:
+ *
+ * <pre>
+ * try {
+ *   fooPluginSetContext.runEach(foo -> foo.doFoo(), MyException.class);
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if return values should be handled:
+ *
+ * <pre>
+ * for (PluginSetEntryContext<Foo> c : fooPluginSetContext) {
+ *   if (c.call(foo -> foo.handles(x))) {
+ *     c.run(foo -> foo.doFoo());
+ *   }
+ * }
+ * </pre>
+ *
+ * <p>Example if return values and a single exception should be handled:
+ *
+ * <pre>
+ * try {
+ *   for (PluginSetEntryContext<Foo> c : fooPluginSetContext) {
+ *     if (c.call(foo -> foo.handles(x), MyException.class)) {
+ *       c.run(foo -> foo.doFoo(), MyException.class);
+ *     }
+ *   }
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if several exceptions should be handled:
+ *
+ * <pre>
+ * for (Extension<Foo> fooExtension : fooDynamicSet.entries()) {
+ *   try (TraceContext traceContext = PluginContext.newTrace(fooExtension)) {
+ *     fooExtension.get().doFoo();
+ *   } catch (MyException1 | MyException2 | MyException3 e) {
+ *     // handle the exception
+ *   }
+ * }
+ * </pre>
+ */
+public class PluginSetContext<T> implements Iterable<PluginSetEntryContext<T>> {
+  private final DynamicSet<T> dynamicSet;
+  private final PluginMetrics pluginMetrics;
+
+  @VisibleForTesting
+  @Inject
+  public PluginSetContext(DynamicSet<T> dynamicSet, PluginMetrics pluginMetrics) {
+    this.dynamicSet = dynamicSet;
+    this.pluginMetrics = pluginMetrics;
+  }
+
+  /**
+   * Iterator that provides contexts for invoking the extensions in this set.
+   *
+   * <p>This is useful if:
+   *
+   * <ul>
+   *   <li>invoking of each extension returns a result that should be handled
+   *   <li>a sequence of invocations should be done on each extension
+   * </ul>
+   */
+  @Override
+  public Iterator<PluginSetEntryContext<T>> iterator() {
+    return Iterators.transform(
+        dynamicSet.entries().iterator(), e -> new PluginSetEntryContext<>(e, pluginMetrics));
+  }
+
+  /**
+   * Checks if no implementations for this extension point have been registered.
+   *
+   * @return {@code true} if no implementations for this extension point have been registered,
+   *     otherwise {@code false}
+   */
+  public boolean isEmpty() {
+    return !dynamicSet.iterator().hasNext();
+  }
+
+  /**
+   * Returns a sorted list of the plugins that have registered implementations for this extension
+   * point.
+   *
+   * @return sorted list of the plugins that have registered implementations for this extension
+   *     point
+   */
+  public SortedSet<String> plugins() {
+    return dynamicSet.plugins();
+  }
+
+  /**
+   * Invokes each extension in the set. All exceptions from the plugin extensions are caught and
+   * logged.
+   *
+   * <p>The consumer gets the extension implementation provided that should be invoked.
+   *
+   * <p>All extension in the set are invoked, even if invoking some of the extensions failed.
+   *
+   * @param extensionImplConsumer consumer that invokes the extension
+   */
+  public void runEach(ExtensionImplConsumer<T> extensionImplConsumer) {
+    dynamicSet
+        .entries()
+        .forEach(p -> PluginContext.runLogExceptions(pluginMetrics, p, extensionImplConsumer));
+  }
+
+  public Stream<T> stream() {
+    return dynamicSet.stream();
+  }
+
+  /**
+   * Invokes each extension in the set. All exceptions from the plugin extensions except exceptions
+   * of the specified type are caught and logged.
+   *
+   * <p>The consumer gets the extension implementation provided that should be invoked.
+   *
+   * <p>All extension in the set are invoked, even if invoking some of the extensions failed.
+   *
+   * @param extensionImplConsumer consumer that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @throws X expected exception from the plugin extension
+   */
+  public <X extends Exception> void runEach(
+      ExtensionImplConsumer<T> extensionImplConsumer, Class<X> exceptionClass) throws X {
+    for (Extension<T> extension : dynamicSet.entries()) {
+      PluginContext.runLogExceptions(
+          pluginMetrics, extension, extensionImplConsumer, exceptionClass);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java b/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
new file mode 100644
index 0000000..2268c07
--- /dev/null
+++ b/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
@@ -0,0 +1,170 @@
+// 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.plugincontext;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.server.plugincontext.PluginContext.CheckedExtensionImplFunction;
+import com.google.gerrit.server.plugincontext.PluginContext.ExtensionImplConsumer;
+import com.google.gerrit.server.plugincontext.PluginContext.ExtensionImplFunction;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+
+/**
+ * Context to invoke an extension from {@link DynamicSet}.
+ *
+ * <p>When the plugin extension is invoked a logging tag with the plugin name is set. This way any
+ * errors that are triggered by the plugin extension (even if they happen in Gerrit code which is
+ * called by the plugin extension) can be easily attributed to the plugin.
+ *
+ * <p>The run* methods execute the extension but don't deliver a result back to the caller.
+ * Exceptions can be caught and logged.
+ *
+ * <p>The call* methods execute the extension and deliver a result back to the caller.
+ *
+ * <p>Example if all exceptions should be caught and logged:
+ *
+ * <pre>
+ * fooPluginSetEntryContext.run(foo -> foo.doFoo());
+ * </pre>
+ *
+ * <p>Example if all exceptions, but one, should be caught and logged:
+ *
+ * <pre>
+ * try {
+ *   fooPluginSetEntryContext.run(foo -> foo.doFoo(), MyException.class);
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if return values should be handled:
+ *
+ * <pre>
+ * Object result = fooPluginSetEntryContext.call(foo -> foo.getFoo());
+ * </pre>
+ *
+ * <p>Example if return values and a single exception should be handled:
+ *
+ * <pre>
+ * Object result;
+ * try {
+ *   result = fooPluginSetEntryContext.call(foo -> foo.getFoo(), MyException.class);
+ * } catch (MyException e) {
+ *   // handle the exception
+ * }
+ * </pre>
+ *
+ * <p>Example if several exceptions should be handled:
+ *
+ * <pre>
+ * for (Extension<Foo> fooExtension : fooDynamicSet.entries()) {
+ *   try (TraceContext traceContext = PluginContext.newTrace(fooExtension)) {
+ *     fooExtension.get().doFoo();
+ *   } catch (MyException1 | MyException2 | MyException3 e) {
+ *     // handle the exception
+ *   }
+ * }
+ * </pre>
+ */
+public class PluginSetEntryContext<T> {
+  private final Extension<T> extension;
+  private final PluginMetrics pluginMetrics;
+
+  PluginSetEntryContext(Extension<T> extension, PluginMetrics pluginMetrics) {
+    this.extension = requireNonNull(extension);
+    this.pluginMetrics = pluginMetrics;
+  }
+
+  /**
+   * Returns the name of the plugin that registered this extension.
+   *
+   * @return the plugin name
+   */
+  public String getPluginName() {
+    return extension.getPluginName();
+  }
+
+  /**
+   * Returns the implementation of this extension.
+   *
+   * <p>Should only be used in exceptional cases to get direct access to the extension
+   * implementation. If possible the extension should be invoked through {@link
+   * #run(PluginContext.ExtensionImplConsumer)}, {@link #run(PluginContext.ExtensionImplConsumer,
+   * java.lang.Class)}, {@link #call(PluginContext.ExtensionImplFunction)} and {@link
+   * #call(PluginContext.CheckedExtensionImplFunction, java.lang.Class)}.
+   *
+   * @return the implementation of this extension
+   */
+  public T get() {
+    return extension.get();
+  }
+
+  /**
+   * Invokes the plugin extension. All exceptions from the plugin extension are caught and logged.
+   *
+   * <p>The consumer gets the extension implementation provided that should be invoked.
+   *
+   * @param extensionImplConsumer consumer that invokes the extension
+   */
+  public void run(ExtensionImplConsumer<T> extensionImplConsumer) {
+    PluginContext.runLogExceptions(pluginMetrics, extension, extensionImplConsumer);
+  }
+
+  /**
+   * Invokes the plugin extension. All exceptions from the plugin extension are caught and logged.
+   *
+   * <p>The consumer gets the extension implementation provided that should be invoked.
+   *
+   * @param extensionImplConsumer consumer that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @throws X expected exception from the plugin extension
+   */
+  public <X extends Exception> void run(
+      ExtensionImplConsumer<T> extensionImplConsumer, Class<X> exceptionClass) throws X {
+    PluginContext.runLogExceptions(pluginMetrics, extension, extensionImplConsumer, exceptionClass);
+  }
+
+  /**
+   * Calls the plugin extension and returns the result from the plugin extension call.
+   *
+   * <p>The function gets the extension point provided that should be invoked.
+   *
+   * @param extensionImplFunction function that invokes the extension
+   * @return the result from the plugin extension
+   */
+  public <R> R call(ExtensionImplFunction<T, R> extensionImplFunction) {
+    return PluginContext.call(pluginMetrics, extension, extensionImplFunction);
+  }
+
+  /**
+   * Calls the plugin extension and returns the result from the plugin extension call. Exceptions of
+   * the specified type are thrown and must be handled by the caller.
+   *
+   * <p>The function gets the extension implementation provided that should be invoked.
+   *
+   * @param checkedExtensionImplFunction function that invokes the extension
+   * @param exceptionClass type of the exceptions that should be thrown
+   * @return the result from the plugin extension
+   * @throws X expected exception from the plugin extension
+   */
+  public <R, X extends Exception> R call(
+      CheckedExtensionImplFunction<T, R, X> checkedExtensionImplFunction, Class<X> exceptionClass)
+      throws X {
+    return PluginContext.call(
+        pluginMetrics, extension, checkedExtensionImplFunction, exceptionClass);
+  }
+}
diff --git a/java/com/google/gerrit/server/plugins/CopyConfigModule.java b/java/com/google/gerrit/server/plugins/CopyConfigModule.java
index 9f937e6..090d257 100644
--- a/java/com/google/gerrit/server/plugins/CopyConfigModule.java
+++ b/java/com/google/gerrit/server/plugins/CopyConfigModule.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.plugins;
 
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
@@ -24,7 +23,6 @@
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.securestore.SecureStore;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provides;
@@ -73,13 +71,6 @@
     return gerritServerConfig;
   }
 
-  @Inject private SchemaFactory<ReviewDb> schemaFactory;
-
-  @Provides
-  SchemaFactory<ReviewDb> getSchemaFactory() {
-    return schemaFactory;
-  }
-
   @Inject private GitRepositoryManager gitRepositoryManager;
 
   @Provides
diff --git a/java/com/google/gerrit/server/plugins/DisablePlugin.java b/java/com/google/gerrit/server/plugins/DisablePlugin.java
index 62eb993..8adae52 100644
--- a/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -17,6 +17,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -30,15 +32,20 @@
 
   private final PluginLoader loader;
   private final PermissionBackend permissionBackend;
+  private final MandatoryPluginsCollection mandatoryPluginsCollection;
 
   @Inject
-  DisablePlugin(PluginLoader loader, PermissionBackend permissionBackend) {
+  DisablePlugin(
+      PluginLoader loader,
+      PermissionBackend permissionBackend,
+      MandatoryPluginsCollection mandatoryPluginsCollection) {
     this.loader = loader;
     this.permissionBackend = permissionBackend;
+    this.mandatoryPluginsCollection = mandatoryPluginsCollection;
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+  public Response<PluginInfo> apply(PluginResource resource, Input input) throws RestApiException {
     try {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     } catch (PermissionBackendException e) {
@@ -46,7 +53,10 @@
     }
     loader.checkRemoteAdminEnabled();
     String name = resource.getName();
+    if (mandatoryPluginsCollection.contains(name)) {
+      throw new MethodNotAllowedException("Plugin " + name + " is mandatory");
+    }
     loader.disablePlugins(ImmutableSet.of(name));
-    return ListPlugins.toPluginInfo(loader.get(name));
+    return Response.ok(ListPlugins.toPluginInfo(loader.get(name)));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/EnablePlugin.java b/java/com/google/gerrit/server/plugins/EnablePlugin.java
index 569bc39..b45aaf1f 100644
--- a/java/com/google/gerrit/server/plugins/EnablePlugin.java
+++ b/java/com/google/gerrit/server/plugins/EnablePlugin.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.inject.Inject;
@@ -39,7 +40,7 @@
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+  public Response<PluginInfo> apply(PluginResource resource, Input input) throws RestApiException {
     loader.checkRemoteAdminEnabled();
     String name = resource.getName();
     try {
@@ -52,6 +53,6 @@
       pw.flush();
       throw new ResourceConflictException(buf.toString());
     }
-    return ListPlugins.toPluginInfo(loader.get(name));
+    return Response.ok(ListPlugins.toPluginInfo(loader.get(name)));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/GetStatus.java b/java/com/google/gerrit/server/plugins/GetStatus.java
index cbd864a..5fcc96a 100644
--- a/java/com/google/gerrit/server/plugins/GetStatus.java
+++ b/java/com/google/gerrit/server/plugins/GetStatus.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.plugins;
 
 import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetStatus implements RestReadView<PluginResource> {
   @Override
-  public PluginInfo apply(PluginResource resource) {
-    return ListPlugins.toPluginInfo(resource.getPlugin());
+  public Response<PluginInfo> apply(PluginResource resource) {
+    return Response.ok(ListPlugins.toPluginInfo(resource.getPlugin()));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/InstallPlugin.java b/java/com/google/gerrit/server/plugins/InstallPlugin.java
index ee9099e..a79a5a6 100644
--- a/java/com/google/gerrit/server/plugins/InstallPlugin.java
+++ b/java/com/google/gerrit/server/plugins/InstallPlugin.java
@@ -16,15 +16,18 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.common.InstallPluginInput;
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.PrintWriter;
@@ -92,6 +95,28 @@
   }
 
   @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+  @Singleton
+  static class Create
+      implements RestCollectionCreateView<TopLevelResource, PluginResource, InstallPluginInput> {
+    private final PluginLoader loader;
+    private final Provider<InstallPlugin> install;
+
+    @Inject
+    Create(PluginLoader loader, Provider<InstallPlugin> install) {
+      this.loader = loader;
+      this.install = install;
+    }
+
+    @Override
+    public Response<PluginInfo> apply(
+        TopLevelResource parentResource, IdString id, InstallPluginInput input) throws Exception {
+      loader.checkRemoteAdminEnabled();
+      return install.get().setName(id.get()).setCreated(true).apply(parentResource, input);
+    }
+  }
+
+  @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+  @Singleton
   static class Overwrite implements RestModifyView<PluginResource, InstallPluginInput> {
     private final Provider<InstallPlugin> install;
 
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index 229f394..5b80059 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.SitePaths;
@@ -90,7 +91,7 @@
 
   @Override
   public String getProviderPluginName() {
-    return "gerrit";
+    return PluginName.GERRIT;
   }
 
   private static String getExtension(Path path) {
diff --git a/java/com/google/gerrit/server/plugins/JarScanner.java b/java/com/google/gerrit/server/plugins/JarScanner.java
index 1a9b859..0f87135 100644
--- a/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -114,7 +114,7 @@
     for (Class<? extends Annotation> annotoation : annotations) {
       String descr = classObjToClassDescr.get(annotoation);
       Collection<ClassData> discoverdData = rawMap.get(descr);
-      Collection<ClassData> values = firstNonNull(discoverdData, Collections.<ClassData>emptySet());
+      Collection<ClassData> values = firstNonNull(discoverdData, Collections.emptySet());
 
       result.put(
           annotoation,
@@ -144,7 +144,7 @@
         continue;
       }
 
-      ClassData def = new ClassData(Collections.<String>emptySet());
+      ClassData def = new ClassData(Collections.emptySet());
       try {
         new ClassReader(read(jarFile, entry)).accept(def, SKIP_ALL);
       } catch (RuntimeException err) {
@@ -195,7 +195,7 @@
     Collection<String> exports;
 
     private ClassData(Collection<String> exports) {
-      super(Opcodes.ASM6);
+      super(Opcodes.ASM7);
       this.exports = exports;
     }
 
@@ -263,7 +263,7 @@
 
   private abstract static class AbstractAnnotationVisitor extends AnnotationVisitor {
     AbstractAnnotationVisitor() {
-      super(Opcodes.ASM6);
+      super(Opcodes.ASM7);
     }
 
     @Override
@@ -331,13 +331,6 @@
     if (attributes == null) {
       return Collections.emptyMap();
     }
-    return Maps.transformEntries(
-        attributes,
-        new Maps.EntryTransformer<Object, Object, String>() {
-          @Override
-          public String transformEntry(Object key, Object value) {
-            return (String) value;
-          }
-        });
+    return Maps.transformEntries(attributes, (key, value) -> (String) value);
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/ListPlugins.java b/java/com/google/gerrit/server/plugins/ListPlugins.java
index 84e63d0..465d041 100644
--- a/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.api.plugins.Plugins;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
@@ -111,7 +112,8 @@
   }
 
   @Override
-  public SortedMap<String, PluginInfo> apply(TopLevelResource resource) throws BadRequestException {
+  public Response<SortedMap<String, PluginInfo>> apply(TopLevelResource resource)
+      throws BadRequestException {
     Stream<Plugin> s = Streams.stream(pluginLoader.getPlugins(all));
     if (matchPrefix != null) {
       checkMatchOptions(matchSubstring == null && matchRegex == null);
@@ -132,7 +134,7 @@
     if (limit > 0) {
       s = s.limit(limit);
     }
-    return new TreeMap<>(s.collect(toMap(Plugin::getName, ListPlugins::toPluginInfo)));
+    return Response.ok(new TreeMap<>(s.collect(toMap(Plugin::getName, ListPlugins::toPluginInfo))));
   }
 
   private void checkMatchOptions(boolean cond) throws BadRequestException {
diff --git a/java/com/google/gerrit/server/plugins/MandatoryPluginsCollection.java b/java/com/google/gerrit/server/plugins/MandatoryPluginsCollection.java
new file mode 100644
index 0000000..70a0fff
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/MandatoryPluginsCollection.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.plugins;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class MandatoryPluginsCollection {
+  private final CopyOnWriteArraySet<String> members;
+
+  @Inject
+  MandatoryPluginsCollection(@GerritServerConfig Config cfg) {
+    members = Sets.newCopyOnWriteArraySet();
+    members.addAll(Arrays.asList(cfg.getStringList("plugins", null, "mandatory")));
+  }
+
+  public boolean contains(String name) {
+    return members.contains(name);
+  }
+
+  public Set<String> asSet() {
+    return ImmutableSet.copyOf(members);
+  }
+
+  @VisibleForTesting
+  public void add(String name) {
+    members.add(name);
+  }
+}
diff --git a/java/com/google/gerrit/server/plugins/MissingMandatoryPluginsException.java b/java/com/google/gerrit/server/plugins/MissingMandatoryPluginsException.java
new file mode 100644
index 0000000..1c23550
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/MissingMandatoryPluginsException.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.plugins;
+
+import java.util.Collection;
+
+/** Raised when one or more mandatory plugins are missing. */
+public class MissingMandatoryPluginsException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public MissingMandatoryPluginsException(Collection<String> pluginNames) {
+    super(getMessage(pluginNames));
+  }
+
+  private static String getMessage(Collection<String> pluginNames) {
+    return String.format("Cannot find or load the following mandatory plugins: %s", pluginNames);
+  }
+}
diff --git a/java/com/google/gerrit/server/plugins/PluginCleanerTask.java b/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
index 11a4eab..6919bbc 100644
--- a/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
+++ b/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.plugins;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.concurrent.Future;
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..c032c46 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.plugins;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.dynamicItemsOf;
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.dynamicMapsOf;
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.dynamicSetsOf;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.annotations.RootRelative;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -215,7 +216,7 @@
     // This supports older plugins that bound a plugin in the HttpModule.
     TypeLiteral<WebUiPlugin> key = TypeLiteral.get(WebUiPlugin.class);
     DynamicSet<?> web = sysSets.get(key);
-    checkNotNull(web, "DynamicSet<WebUiPlugin> exists in sysInjector");
+    requireNonNull(web, "DynamicSet<WebUiPlugin> exists in sysInjector");
 
     Map<TypeLiteral<?>, DynamicSet<?>> m = new HashMap<>(dynamicSetsOf(i));
     m.put(key, web);
@@ -226,7 +227,8 @@
     return httpModule != null;
   }
 
-  Module getHttpModule() {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public Module getHttpModule() {
     return httpModule;
   }
 
@@ -274,14 +276,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 +437,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/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 57e7e49..c4f4a1f 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -52,10 +52,10 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Queue;
 import java.util.Set;
 import java.util.TreeSet;
@@ -88,6 +88,7 @@
   private final Provider<String> urlProvider;
   private final PersistentCacheFactory persistentCacheFactory;
   private final boolean remoteAdmin;
+  private final MandatoryPluginsCollection mandatoryPlugins;
   private final UniversalServerPluginProvider serverPluginFactory;
   private final GerritRuntime gerritRuntime;
 
@@ -102,6 +103,7 @@
       @CanonicalWebUrl Provider<String> provider,
       PersistentCacheFactory cacheFactory,
       UniversalServerPluginProvider pluginFactory,
+      MandatoryPluginsCollection mpc,
       GerritRuntime gerritRuntime) {
     pluginsDir = sitePaths.plugins_dir;
     dataDir = sitePaths.data_dir;
@@ -115,6 +117,7 @@
     serverPluginFactory = pluginFactory;
 
     remoteAdmin = cfg.getBoolean("plugins", null, "allowRemoteAdmin", false);
+    mandatoryPlugins = mpc;
     this.gerritRuntime = gerritRuntime;
 
     long checkFrequency =
@@ -227,6 +230,11 @@
           continue;
         }
 
+        if (mandatoryPlugins.contains(name)) {
+          logger.atWarning().log("Mandatory plugin %s cannot be disabled", name);
+          continue;
+        }
+
         logger.atInfo().log("Disabling plugin %s", active.getName());
         Path off =
             active.getSrcFile().resolveSibling(active.getSrcFile().getFileName() + ".disabled");
@@ -290,12 +298,7 @@
 
   private void removeStalePluginFiles() {
     DirectoryStream.Filter<Path> filter =
-        new DirectoryStream.Filter<Path>() {
-          @Override
-          public boolean accept(Path entry) throws IOException {
-            return entry.getFileName().toString().startsWith("plugin_");
-          }
-        };
+        entry -> entry.getFileName().toString().startsWith("plugin_");
     try (DirectoryStream<Path> files = Files.newDirectoryStream(tempDir, filter)) {
       for (Path file : files) {
         logger.atInfo().log("Removing stale plugin file: %s", file.toFile().getName());
@@ -387,69 +390,77 @@
   }
 
   public synchronized void rescan() {
+    Set<String> loadedPlugins = new HashSet<>();
     SetMultimap<String, Path> pluginsFiles = prunePlugins(pluginsDir);
-    if (pluginsFiles.isEmpty()) {
-      return;
+
+    if (!pluginsFiles.isEmpty()) {
+      syncDisabledPlugins(pluginsFiles);
+
+      Map<String, Path> activePlugins = filterDisabled(pluginsFiles);
+      for (Map.Entry<String, Path> entry : jarsFirstSortedPluginsSet(activePlugins)) {
+        String name = entry.getKey();
+        Path path = entry.getValue();
+        String fileName = path.getFileName().toString();
+        if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
+          logger.atWarning().log(
+              "No Plugin provider was found that handles this file format: %s", fileName);
+          continue;
+        }
+
+        FileSnapshot brokenTime = broken.get(name);
+        if (brokenTime != null && !brokenTime.isModified(path.toFile())) {
+          continue;
+        }
+
+        Plugin active = running.get(name);
+        if (active != null && !active.isModified(path)) {
+          loadedPlugins.add(name);
+          continue;
+        }
+
+        if (active != null) {
+          logger.atInfo().log("Reloading plugin %s", active.getName());
+        }
+
+        try {
+          Plugin loadedPlugin = runPlugin(name, path, active);
+          if (!loadedPlugin.isDisabled()) {
+            loadedPlugins.add(name);
+            logger.atInfo().log(
+                "%s plugin %s, version %s",
+                active == null ? "Loaded" : "Reloaded",
+                loadedPlugin.getName(),
+                loadedPlugin.getVersion());
+          }
+        } catch (PluginInstallException e) {
+          logger.atWarning().withCause(e.getCause()).log("Cannot load plugin %s", name);
+        }
+      }
     }
 
-    syncDisabledPlugins(pluginsFiles);
-
-    Map<String, Path> activePlugins = filterDisabled(pluginsFiles);
-    for (Map.Entry<String, Path> entry : jarsFirstSortedPluginsSet(activePlugins)) {
-      String name = entry.getKey();
-      Path path = entry.getValue();
-      String fileName = path.getFileName().toString();
-      if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
-        logger.atWarning().log(
-            "No Plugin provider was found that handles this file format: %s", fileName);
-        continue;
-      }
-
-      FileSnapshot brokenTime = broken.get(name);
-      if (brokenTime != null && !brokenTime.isModified(path.toFile())) {
-        continue;
-      }
-
-      Plugin active = running.get(name);
-      if (active != null && !active.isModified(path)) {
-        continue;
-      }
-
-      if (active != null) {
-        logger.atInfo().log("Reloading plugin %s", active.getName());
-      }
-
-      try {
-        Plugin loadedPlugin = runPlugin(name, path, active);
-        if (!loadedPlugin.isDisabled()) {
-          logger.atInfo().log(
-              "%s plugin %s, version %s",
-              active == null ? "Loaded" : "Reloaded",
-              loadedPlugin.getName(),
-              loadedPlugin.getVersion());
-        }
-      } catch (PluginInstallException e) {
-        logger.atWarning().withCause(e.getCause()).log("Cannot load plugin %s", name);
-      }
+    Set<String> missingMandatory = Sets.difference(mandatoryPlugins.asSet(), loadedPlugins);
+    if (!missingMandatory.isEmpty()) {
+      throw new MissingMandatoryPluginsException(missingMandatory);
     }
 
     cleanInBackground();
   }
 
-  private void addAllEntries(Map<String, Path> from, TreeSet<Entry<String, Path>> to) {
-    Iterator<Entry<String, Path>> it = from.entrySet().iterator();
+  private void addAllEntries(Map<String, Path> from, TreeSet<Map.Entry<String, Path>> to) {
+    Iterator<Map.Entry<String, Path>> it = from.entrySet().iterator();
     while (it.hasNext()) {
-      Entry<String, Path> entry = it.next();
+      Map.Entry<String, Path> entry = it.next();
       to.add(new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), entry.getValue()));
     }
   }
 
-  private TreeSet<Entry<String, Path>> jarsFirstSortedPluginsSet(Map<String, Path> activePlugins) {
-    TreeSet<Entry<String, Path>> sortedPlugins =
+  private TreeSet<Map.Entry<String, Path>> jarsFirstSortedPluginsSet(
+      Map<String, Path> activePlugins) {
+    TreeSet<Map.Entry<String, Path>> sortedPlugins =
         Sets.newTreeSet(
-            new Comparator<Entry<String, Path>>() {
+            new Comparator<Map.Entry<String, Path>>() {
               @Override
-              public int compare(Entry<String, Path> e1, Entry<String, Path> e2) {
+              public int compare(Map.Entry<String, Path> e1, Map.Entry<String, Path> e2) {
                 Path n1 = e1.getValue().getFileName();
                 Path n2 = e2.getValue().getFileName();
                 return ComparisonChain.start()
@@ -476,6 +487,12 @@
       throws PluginInstallException {
     FileSnapshot snapshot = FileSnapshot.save(plugin.toFile());
     try {
+      boolean restartRequired = oldPlugin != null && !oldPlugin.canReload();
+      if (restartRequired && mandatoryPlugins.contains(name)) {
+        logger.atWarning().log("Restarting mandatory plugin %s not allowed", name);
+        return oldPlugin;
+      }
+
       Plugin newPlugin = loadPlugin(name, plugin, snapshot);
       if (newPlugin.getCleanupHandle() != null) {
         cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle());
diff --git a/java/com/google/gerrit/server/plugins/PluginMetricMaker.java b/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
index b1a01d3..b49a190 100644
--- a/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
+++ b/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
@@ -47,7 +47,7 @@
   public PluginMetricMaker(MetricMaker root, String prefix) {
     this.root = root;
     this.prefix = prefix.endsWith("/") ? prefix : prefix + "/";
-    cleanup = Collections.synchronizedSet(new HashSet<RegistrationHandle>());
+    cleanup = Collections.synchronizedSet(new HashSet<>());
   }
 
   @Override
@@ -160,12 +160,9 @@
   public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics, Runnable trigger) {
     final RegistrationHandle handle = root.newTrigger(metrics, trigger);
     cleanup.add(handle);
-    return new RegistrationHandle() {
-      @Override
-      public void remove() {
-        handle.remove();
-        cleanup.remove(handle);
-      }
+    return () -> {
+      handle.remove();
+      cleanup.remove(handle);
     };
   }
 
diff --git a/java/com/google/gerrit/server/plugins/PluginModule.java b/java/com/google/gerrit/server/plugins/PluginModule.java
index 6bc37bd..71186e5 100644
--- a/java/com/google/gerrit/server/plugins/PluginModule.java
+++ b/java/com/google/gerrit/server/plugins/PluginModule.java
@@ -34,6 +34,7 @@
     bind(PluginLoader.class);
     bind(CopyConfigModule.class);
     listener().to(PluginLoader.class);
+    bind(MandatoryPluginsCollection.class);
 
     DynamicSet.setOf(binder(), ServerPluginProvider.class);
     DynamicSet.bind(binder(), ServerPluginProvider.class).to(JarPluginProvider.class);
diff --git a/java/com/google/gerrit/server/plugins/PluginRestApiModule.java b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
index cad0e1e..8dbea78 100644
--- a/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
+++ b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
@@ -27,6 +27,7 @@
     requireBinding(Key.get(PluginUser.Factory.class));
     bind(PluginsCollection.class);
     DynamicMap.mapOf(binder(), PLUGIN_KIND);
+    create(PLUGIN_KIND).to(InstallPlugin.Create.class);
     put(PLUGIN_KIND).to(InstallPlugin.Overwrite.class);
     delete(PLUGIN_KIND).to(DisablePlugin.class);
     get(PLUGIN_KIND, "status").to(GetStatus.class);
diff --git a/java/com/google/gerrit/server/plugins/PluginUtil.java b/java/com/google/gerrit/server/plugins/PluginUtil.java
index 5e67e2c..d4110ca 100644
--- a/java/com/google/gerrit/server/plugins/PluginUtil.java
+++ b/java/com/google/gerrit/server/plugins/PluginUtil.java
@@ -34,17 +34,14 @@
       return ImmutableList.of();
     }
     DirectoryStream.Filter<Path> filter =
-        new DirectoryStream.Filter<Path>() {
-          @Override
-          public boolean accept(Path entry) throws IOException {
-            String n = entry.getFileName().toString();
-            boolean accept =
-                !n.startsWith(".last_") && !n.startsWith(".next_") && Files.isRegularFile(entry);
-            if (!Strings.isNullOrEmpty(suffix)) {
-              accept &= n.endsWith(suffix);
-            }
-            return accept;
+        entry -> {
+          String n = entry.getFileName().toString();
+          boolean accept =
+              !n.startsWith(".last_") && !n.startsWith(".next_") && Files.isRegularFile(entry);
+          if (!Strings.isNullOrEmpty(suffix)) {
+            accept &= n.endsWith(suffix);
           }
+          return accept;
         };
     try (DirectoryStream<Path> files = Files.newDirectoryStream(pluginsDir, filter)) {
       return Ordering.natural().sortedCopy(files);
diff --git a/java/com/google/gerrit/server/plugins/PluginsCollection.java b/java/com/google/gerrit/server/plugins/PluginsCollection.java
index 0d2a018..7cc006e 100644
--- a/java/com/google/gerrit/server/plugins/PluginsCollection.java
+++ b/java/com/google/gerrit/server/plugins/PluginsCollection.java
@@ -15,10 +15,8 @@
 package com.google.gerrit.server.plugins;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -27,24 +25,18 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class PluginsCollection
-    implements RestCollection<TopLevelResource, PluginResource>, AcceptsCreate<TopLevelResource> {
+public class PluginsCollection implements RestCollection<TopLevelResource, PluginResource> {
 
   private final DynamicMap<RestView<PluginResource>> views;
   private final PluginLoader loader;
   private final Provider<ListPlugins> list;
-  private final Provider<InstallPlugin> install;
 
   @Inject
   public PluginsCollection(
-      DynamicMap<RestView<PluginResource>> views,
-      PluginLoader loader,
-      Provider<ListPlugins> list,
-      Provider<InstallPlugin> install) {
+      DynamicMap<RestView<PluginResource>> views, PluginLoader loader, Provider<ListPlugins> list) {
     this.views = views;
     this.loader = loader;
     this.list = list;
-    this.install = install;
   }
 
   @Override
@@ -67,12 +59,6 @@
   }
 
   @Override
-  public InstallPlugin create(TopLevelResource parent, IdString id) throws RestApiException {
-    loader.checkRemoteAdminEnabled();
-    return install.get().setName(id.get()).setCreated(true);
-  }
-
-  @Override
   public DynamicMap<RestView<PluginResource>> views() {
     return views;
   }
diff --git a/java/com/google/gerrit/server/plugins/ReloadPlugin.java b/java/com/google/gerrit/server/plugins/ReloadPlugin.java
index 1134f50..490c4aa 100644
--- a/java/com/google/gerrit/server/plugins/ReloadPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ReloadPlugin.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -38,7 +39,8 @@
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws ResourceConflictException {
+  public Response<PluginInfo> apply(PluginResource resource, Input input)
+      throws ResourceConflictException {
     String name = resource.getName();
     try {
       loader.reload(ImmutableList.of(name));
@@ -52,6 +54,6 @@
       pw.flush();
       throw new ResourceConflictException(buf.toString());
     }
-    return ListPlugins.toPluginInfo(loader.get(name));
+    return Response.ok(ListPlugins.toPluginInfo(loader.get(name)));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java b/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
index 0bef1e5..22cd84c 100644
--- a/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
@@ -15,7 +15,8 @@
 package com.google.gerrit.server.plugins;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.PluginName;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.nio.file.Path;
@@ -27,10 +28,10 @@
 class UniversalServerPluginProvider implements ServerPluginProvider {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DynamicSet<ServerPluginProvider> serverPluginProviders;
+  private final PluginSetContext<ServerPluginProvider> serverPluginProviders;
 
   @Inject
-  UniversalServerPluginProvider(DynamicSet<ServerPluginProvider> sf) {
+  UniversalServerPluginProvider(PluginSetContext<ServerPluginProvider> sf) {
     this.serverPluginProviders = sf;
   }
 
@@ -60,7 +61,7 @@
 
   @Override
   public String getProviderPluginName() {
-    return "gerrit";
+    return PluginName.GERRIT;
   }
 
   private ServerPluginProvider providerOf(Path srcPath) {
@@ -79,15 +80,16 @@
 
   private List<ServerPluginProvider> providersForHandlingPlugin(Path srcPath) {
     List<ServerPluginProvider> providers = new ArrayList<>();
-    for (ServerPluginProvider serverPluginProvider : serverPluginProviders) {
-      boolean handles = serverPluginProvider.handles(srcPath);
-      logger.atFine().log(
-          "File %s handled by %s ? => %s",
-          srcPath, serverPluginProvider.getProviderPluginName(), handles);
-      if (handles) {
-        providers.add(serverPluginProvider);
-      }
-    }
+    serverPluginProviders.runEach(
+        serverPluginProvider -> {
+          boolean handles = serverPluginProvider.handles(srcPath);
+          logger.atFine().log(
+              "File %s handled by %s ? => %s",
+              srcPath, serverPluginProvider.getProviderPluginName(), handles);
+          if (handles) {
+            providers.add(serverPluginProvider);
+          }
+        });
     return providers;
   }
 }
diff --git a/java/com/google/gerrit/server/project/AccountsSection.java b/java/com/google/gerrit/server/project/AccountsSection.java
index 087a314a..30bd244 100644
--- a/java/com/google/gerrit/server/project/AccountsSection.java
+++ b/java/com/google/gerrit/server/project/AccountsSection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.PermissionRule;
 import java.util.ArrayList;
 import java.util.List;
@@ -21,14 +22,14 @@
 public class AccountsSection {
   protected List<PermissionRule> sameGroupVisibility;
 
-  public List<PermissionRule> getSameGroupVisibility() {
+  public ImmutableList<PermissionRule> getSameGroupVisibility() {
     if (sameGroupVisibility == null) {
-      sameGroupVisibility = new ArrayList<>();
+      sameGroupVisibility = ImmutableList.of();
     }
-    return sameGroupVisibility;
+    return ImmutableList.copyOf(sameGroupVisibility);
   }
 
   public void setSameGroupVisibility(List<PermissionRule> sameGroupVisibility) {
-    this.sameGroupVisibility = sameGroupVisibility;
+    this.sameGroupVisibility = new ArrayList<>(sameGroupVisibility);
   }
 }
diff --git a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
index 61a7ef2..79eccbb 100644
--- a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
+++ b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
@@ -68,6 +68,9 @@
           .put(
               BooleanProjectConfig.REJECT_EMPTY_COMMIT,
               new Mapper(i -> i.rejectEmptyCommit, (i, v) -> i.rejectEmptyCommit = v))
+          .put(
+              BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT,
+              new Mapper(i -> i.workInProgressByDefault, (i, v) -> i.workInProgressByDefault = v))
           .build();
 
   static {
diff --git a/java/com/google/gerrit/server/project/BranchResource.java b/java/com/google/gerrit/server/project/BranchResource.java
index 622b1dd..40403b4 100644
--- a/java/com/google/gerrit/server/project/BranchResource.java
+++ b/java/com/google/gerrit/server/project/BranchResource.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 import org.eclipse.jgit.lib.Ref;
@@ -33,8 +33,8 @@
     this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
   }
 
-  public Branch.NameKey getBranchKey() {
-    return new Branch.NameKey(getNameKey(), refName);
+  public BranchNameKey getBranchKey() {
+    return BranchNameKey.create(getNameKey(), refName);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/project/ChildProjects.java b/java/com/google/gerrit/server/project/ChildProjects.java
index 868d0af..ce9992e 100644
--- a/java/com/google/gerrit/server/project/ChildProjects.java
+++ b/java/com/google/gerrit/server/project/ChildProjects.java
@@ -93,8 +93,7 @@
       Project.NameKey parent)
       throws PermissionBackendException {
     List<Project.NameKey> canSee =
-        perm.filter(ProjectPermission.ACCESS, children.get(parent))
-            .stream()
+        perm.filter(ProjectPermission.ACCESS, children.get(parent)).stream()
             .sorted()
             .collect(toList());
     children.removeAll(parent); // removing all entries prevents cycles.
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index 56cf51e..4987d00 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -16,15 +16,17 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.server.config.ConfigUpdatedEvent;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
@@ -64,11 +66,11 @@
   }
 
   @Override
-  public List<ConfigUpdatedEvent.Update> configUpdated(ConfigUpdatedEvent event) {
+  public Multimap<UpdateResult, ConfigUpdateEntry> configUpdated(ConfigUpdatedEvent event) {
     if (event.isSectionUpdated(ProjectConfig.COMMENTLINK)) {
       commentLinks = parseConfig(event.getNewConfig());
-      return Collections.singletonList(event.accept(ProjectConfig.COMMENTLINK));
+      return event.accept(ProjectConfig.COMMENTLINK);
     }
-    return Collections.emptyList();
+    return ConfigUpdatedEvent.NO_UPDATES;
   }
 }
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index d840123..f4a3203 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
+
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
@@ -29,18 +31,20 @@
 import com.google.gerrit.reviewdb.client.Project;
 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.UrlFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
 
 @Singleton
 public class ContributorAgreementsChecker {
 
-  private final String canonicalWebUrl;
+  private final DynamicItem<UrlFormatter> urlFormatter;
   private final ProjectCache projectCache;
   private final Metrics metrics;
 
@@ -59,10 +63,8 @@
 
   @Inject
   ContributorAgreementsChecker(
-      @CanonicalWebUrl @Nullable String canonicalWebUrl,
-      ProjectCache projectCache,
-      Metrics metrics) {
-    this.canonicalWebUrl = canonicalWebUrl;
+      DynamicItem<UrlFormatter> urlFormatter, ProjectCache projectCache, Metrics metrics) {
+    this.urlFormatter = urlFormatter;
     this.projectCache = projectCache;
     this.metrics = metrics;
   }
@@ -97,28 +99,58 @@
       List<AccountGroup.UUID> groupIds;
       groupIds = okGroupIds;
 
+      // matchProjects defaults to match all projects when missing.
+      List<String> matchProjectsRegexes = ca.getMatchProjectsRegexes();
+      if (!matchProjectsRegexes.isEmpty()
+          && !projectMatchesAnyPattern(project.get(), matchProjectsRegexes)) {
+        // Doesn't match, isn't checked.
+        continue;
+      }
+      // excludeProjects defaults to exclude no projects when missing.
+      List<String> excludeProjectsRegexes = ca.getExcludeProjectsRegexes();
+      if (!excludeProjectsRegexes.isEmpty()
+          && projectMatchesAnyPattern(project.get(), excludeProjectsRegexes)) {
+        // Matches, isn't checked.
+        continue;
+      }
       for (PermissionRule rule : ca.getAccepted()) {
         if ((rule.getAction() == Action.ALLOW)
             && (rule.getGroup() != null)
             && (rule.getGroup().getUUID() != null)) {
-          groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
+          groupIds.add(AccountGroup.uuid(rule.getGroup().getUUID().get()));
         }
       }
     }
 
-    if (!iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
+    if (!okGroupIds.isEmpty() && !iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
       final StringBuilder msg = new StringBuilder();
-      msg.append("A Contributor Agreement must be completed before uploading");
-      if (canonicalWebUrl != null) {
-        msg.append(":\n\n  ");
-        msg.append(canonicalWebUrl);
-        msg.append("#");
-        msg.append(PageLinks.SETTINGS_AGREEMENTS);
-        msg.append("\n");
-      } else {
-        msg.append(".");
-      }
+      msg.append("No Contributor Agreement on file for user ")
+          .append(iUser.getNameEmail())
+          .append(" (id=")
+          .append(iUser.getAccountId())
+          .append(")");
+
+      msg.append(urlFormatter.get().getSettingsUrl("Agreements").orElse(""));
       throw new AuthException(msg.toString());
     }
   }
+
+  private boolean projectMatchesAnyPattern(String projectName, List<String> regexes) {
+    requireNonNull(regexes);
+    checkArgument(!regexes.isEmpty());
+    for (String patternString : regexes) {
+      Pattern pattern;
+      try {
+        pattern = Pattern.compile(patternString);
+      } catch (PatternSyntaxException e) {
+        // Should never happen: Regular expressions validated when reading project.config.
+        throw new IllegalStateException(
+            "Invalid matchProjects or excludeProjects clause in project.config", e);
+      }
+      if (pattern.matcher(projectName).find()) {
+        return true;
+      }
+    }
+    return false;
+  }
 }
diff --git a/java/com/google/gerrit/server/project/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
index e4623b2..7405df1 100644
--- a/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -35,6 +35,8 @@
   public InheritableBoolean newChangeForAllNotInTarget;
   public InheritableBoolean changeIdRequired;
   public InheritableBoolean rejectEmptyCommit;
+  public InheritableBoolean enableSignedPush;
+  public InheritableBoolean requireSignedPush;
   public boolean createEmptyCommit;
   public String maxObjectSizeLimit;
 
@@ -44,7 +46,10 @@
     contentMerge = InheritableBoolean.INHERIT;
     changeIdRequired = InheritableBoolean.INHERIT;
     newChangeForAllNotInTarget = InheritableBoolean.INHERIT;
+    enableSignedPush = InheritableBoolean.INHERIT;
+    requireSignedPush = InheritableBoolean.INHERIT;
     submitType = SubmitType.MERGE_IF_NECESSARY;
+    rejectEmptyCommit = InheritableBoolean.INHERIT;
   }
 
   public Project.NameKey getProject() {
@@ -56,7 +61,7 @@
   }
 
   public void setProjectName(String n) {
-    projectName = n != null ? new Project.NameKey(n) : null;
+    projectName = n != null ? Project.nameKey(n) : null;
   }
 
   public void setProjectName(Project.NameKey n) {
diff --git a/java/com/google/gerrit/server/project/CreateRefControl.java b/java/com/google/gerrit/server/project/CreateRefControl.java
index f89e298..21be8e3 100644
--- a/java/com/google/gerrit/server/project/CreateRefControl.java
+++ b/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -17,7 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -27,6 +27,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -63,15 +64,12 @@
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void checkCreateRef(
-      Provider<? extends CurrentUser> user,
-      Repository repo,
-      Branch.NameKey branch,
-      RevObject object)
+      Provider<? extends CurrentUser> user, Repository repo, BranchNameKey branch, RevObject object)
       throws AuthException, PermissionBackendException, NoSuchProjectException, IOException,
           ResourceConflictException {
-    ProjectState ps = projectCache.checkedGet(branch.getParentKey());
+    ProjectState ps = projectCache.checkedGet(branch.project());
     if (ps == null) {
-      throw new NoSuchProjectException(branch.getParentKey());
+      throw new NoSuchProjectException(branch.project());
     }
     ps.checkStatePermitsWrite();
 
@@ -84,8 +82,7 @@
       try (RevWalk rw = new RevWalk(repo)) {
         rw.parseBody(tag);
       } catch (IOException e) {
-        logger.atSevere().withCause(e).log(
-            "RevWalk(%s) parsing %s:", branch.getParentKey(), tag.name());
+        logger.atSevere().withCause(e).log("RevWalk(%s) parsing %s:", branch.project(), tag.name());
         throw e;
       }
 
@@ -121,7 +118,7 @@
    */
   private void checkCreateCommit(
       Repository repo, RevCommit commit, Project.NameKey project, PermissionBackend.ForRef forRef)
-      throws AuthException, PermissionBackendException {
+      throws AuthException, PermissionBackendException, IOException {
     try {
       // If the user has update (push) permission, they can create the ref regardless
       // of whether they are pushing any new objects along with the create.
@@ -130,7 +127,11 @@
     } catch (AuthException denied) {
       // Fall through to check reachability.
     }
-    if (reachable.fromHeadsOrTags(project, repo, commit)) {
+    if (reachable.fromRefs(
+        project,
+        repo,
+        commit,
+        repo.getRefDatabase().getRefsByPrefix(Constants.R_HEADS, Constants.R_TAGS))) {
       // If the user has no push permissions, check whether the object is
       // merged into a branch or tag readable by this user. If so, they are
       // not effectively "pushing" more objects, so they can create the ref
@@ -138,9 +139,15 @@
       return;
     }
 
-    throw new AuthException(
+    AuthException e =
+        new AuthException(
+            String.format(
+                "%s for creating new commit object not permitted",
+                RefPermission.UPDATE.describeForException()));
+    e.setAdvice(
         String.format(
-            "%s for creating new commit object not permitted",
+            "use a SHA1 visible to you, or get %s permission on the ref",
             RefPermission.UPDATE.describeForException()));
+    throw e;
   }
 }
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index fdb8740..23eb9a8 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -48,7 +48,7 @@
         logger.atWarning().log("null field in group list for %s:\n%s", project, text);
         continue;
       }
-      AccountGroup.UUID uuid = new AccountGroup.UUID(row.left);
+      AccountGroup.UUID uuid = AccountGroup.uuid(row.left);
       String name = row.right;
       GroupReference ref = new GroupReference(uuid, name);
 
diff --git a/java/com/google/gerrit/server/project/NoSuchChangeException.java b/java/com/google/gerrit/server/project/NoSuchChangeException.java
index 7946a3a..6f65659 100644
--- a/java/com/google/gerrit/server/project/NoSuchChangeException.java
+++ b/java/com/google/gerrit/server/project/NoSuchChangeException.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.OrmException;
 
 /** Indicates the change does not exist. */
-public class NoSuchChangeException extends OrmException {
+public class NoSuchChangeException extends StorageException {
   private static final long serialVersionUID = 1L;
 
   public NoSuchChangeException(Change.Id key) {
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index c7858dd..509caa4 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -57,7 +57,7 @@
    *     errors.
    * @return the cached data or null when strict = false
    */
-  public ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception;
+  ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception;
 
   /**
    * Invalidate the cached information about the given project, and triggers reindexing for it
diff --git a/java/com/google/gerrit/server/project/ProjectCacheClock.java b/java/com/google/gerrit/server/project/ProjectCacheClock.java
index 5d208f3..eb451fd 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheClock.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheClock.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.concurrent.Executors;
@@ -53,13 +54,14 @@
       // Start with generation 1 (to avoid magic 0 below).
       generation.set(1);
       executor =
-          Executors.newScheduledThreadPool(
-              1,
-              new ThreadFactoryBuilder()
-                  .setNameFormat("ProjectCacheClock-%d")
-                  .setDaemon(true)
-                  .setPriority(Thread.MIN_PRIORITY)
-                  .build());
+          new LoggingContextAwareScheduledExecutorService(
+              Executors.newScheduledThreadPool(
+                  1,
+                  new ThreadFactoryBuilder()
+                      .setNameFormat("ProjectCacheClock-%d")
+                      .setDaemon(true)
+                      .setPriority(Thread.MIN_PRIORITY)
+                      .build()));
       @SuppressWarnings("unused") // Runnable already handles errors
       Future<?> possiblyIgnoredError =
           executor.scheduleAtFixedRate(
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 1f51fda..0bfd36d 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
@@ -25,12 +26,19 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
@@ -87,6 +95,7 @@
   private final Lock listLock;
   private final ProjectCacheClock clock;
   private final Provider<ProjectIndexer> indexer;
+  private final Timer0 guessRelevantGroupsLatency;
 
   @Inject
   ProjectCacheImpl(
@@ -95,7 +104,8 @@
       @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
       @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
       ProjectCacheClock clock,
-      Provider<ProjectIndexer> indexer) {
+      Provider<ProjectIndexer> indexer,
+      MetricMaker metricMaker) {
     this.allProjectsName = allProjectsName;
     this.allUsersName = allUsersName;
     this.byName = byName;
@@ -103,6 +113,13 @@
     this.listLock = new ReentrantLock(true /* fair */);
     this.clock = clock;
     this.indexer = indexer;
+
+    this.guessRelevantGroupsLatency =
+        metricMaker.newTimer(
+            "group/guess_relevant_groups_latency",
+            new Description("Latency for guessing relevant groups")
+                .setCumulative()
+                .setUnit(Units.NANOSECONDS));
   }
 
   @Override
@@ -146,7 +163,9 @@
     } catch (Exception e) {
       if (!(e.getCause() instanceof RepositoryNotFoundException)) {
         logger.atWarning().withCause(e).log("Cannot read project %s", projectName.get());
-        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+        if (e.getCause() != null) {
+          Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+        }
         throw new IOException(e);
       }
       logger.atFine().withCause(e).log("Cannot find project %s", projectName.get());
@@ -176,6 +195,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);
@@ -229,21 +249,22 @@
 
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-    return all()
-        .stream()
-        .map(n -> byName.getIfPresent(n.get()))
-        .filter(Objects::nonNull)
-        .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
-        // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
-        // against them just in case there is a bug or corner case.
-        .filter(id -> id != null && id.get() != null)
-        .collect(toSet());
+    try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
+      return all().stream()
+          .map(n -> byName.getIfPresent(n.get()))
+          .filter(Objects::nonNull)
+          .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
+          // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+          // against them just in case there is a bug or corner case.
+          .filter(id -> id != null && id.get() != null)
+          .collect(toSet());
+    }
   }
 
   @Override
   public ImmutableSortedSet<Project.NameKey> byName(String pfx) {
-    Project.NameKey start = new Project.NameKey(pfx);
-    Project.NameKey end = new Project.NameKey(pfx + Character.MAX_VALUE);
+    Project.NameKey start = Project.nameKey(pfx);
+    Project.NameKey end = Project.nameKey(pfx + Character.MAX_VALUE);
     try {
       // Right endpoint is exclusive, but U+FFFF is a non-character so no project ends with it.
       return list.get(ListKey.ALL).subSet(start, end);
@@ -257,25 +278,35 @@
     private final ProjectState.Factory projectStateFactory;
     private final GitRepositoryManager mgr;
     private final ProjectCacheClock clock;
+    private final ProjectConfig.Factory projectConfigFactory;
 
     @Inject
-    Loader(ProjectState.Factory psf, GitRepositoryManager g, ProjectCacheClock clock) {
+    Loader(
+        ProjectState.Factory psf,
+        GitRepositoryManager g,
+        ProjectCacheClock clock,
+        ProjectConfig.Factory projectConfigFactory) {
       projectStateFactory = psf;
       mgr = g;
       this.clock = clock;
+      this.projectConfigFactory = projectConfigFactory;
     }
 
     @Override
     public ProjectState load(String projectName) throws Exception {
-      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);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading project", Metadata.builder().projectName(projectName).build())) {
+        long now = clock.read();
+        Project.NameKey key = Project.nameKey(projectName);
+        try (Repository git = mgr.openRepository(key)) {
+          ProjectConfig cfg = projectConfigFactory.create(key);
+          cfg.load(key, git);
 
-        ProjectState state = projectStateFactory.create(cfg);
-        state.initLastCheck(now);
-        return state;
+          ProjectState state = projectStateFactory.create(cfg);
+          state.initLastCheck(now);
+          return state;
+        }
       }
     }
   }
@@ -296,7 +327,19 @@
 
     @Override
     public ImmutableSortedSet<Project.NameKey> load(ListKey key) throws Exception {
-      return ImmutableSortedSet.copyOf(mgr.list());
+      try (TraceTimer timer = TraceContext.newTimer("Loading project list")) {
+        return ImmutableSortedSet.copyOf(mgr.list());
+      }
     }
   }
+
+  @VisibleForTesting
+  public void evictAllByName() {
+    byName.invalidateAll();
+  }
+
+  @VisibleForTesting
+  public long sizeAllByName() {
+    return byName.size();
+  }
 }
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 1796c40..d01954c 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -15,16 +15,22 @@
 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 com.google.gerrit.server.permissions.PluginPermissionsUtil.isValidPluginPermission;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -36,26 +42,29 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.common.data.RefConfigSection;
 import com.google.gerrit.common.data.SubscribeSection;
-import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
-import com.google.gerrit.server.mail.Address;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -68,7 +77,7 @@
 import java.util.List;
 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,7 +86,12 @@
 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.lib.StoredConfig;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.util.FS;
 
 public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
   public static final String COMMENTLINK = "commentlink";
@@ -86,6 +100,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";
@@ -120,6 +135,8 @@
   private static final String KEY_ACCEPTED = "accepted";
   private static final String KEY_AUTO_VERIFY = "autoVerify";
   private static final String KEY_AGREEMENT_URL = "agreementUrl";
+  private static final String KEY_MATCH_PROJECTS = "matchProjects";
+  private static final String KEY_EXCLUDE_PROJECTS = "excludeProjects";
 
   private static final String NOTIFY = "notify";
   private static final String KEY_EMAIL = "email";
@@ -158,7 +175,51 @@
 
   private static final Pattern EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN = Pattern.compile("[, \t]{1,}");
 
-  private Project.NameKey projectName;
+  // Don't use an assisted factory, since instances created by an assisted factory retain references
+  // to their enclosing injector. Instances of ProjectConfig are cached for a long time in the
+  // ProjectCache, so this would retain lots more memory.
+  @Singleton
+  public static class Factory {
+    @Nullable
+    public static StoredConfig getBaseConfig(
+        SitePaths sitePaths, AllProjectsName allProjects, Project.NameKey projectName) {
+      return projectName.equals(allProjects)
+          // Delay loading till onLoad method.
+          ? new FileBasedConfig(
+              sitePaths.etc_dir.resolve(allProjects.get()).resolve(PROJECT_CONFIG).toFile(),
+              FS.DETECTED)
+          : null;
+    }
+
+    private final SitePaths sitePaths;
+    private final AllProjectsName allProjects;
+
+    @Inject
+    Factory(SitePaths sitePaths, AllProjectsName allProjects) {
+      this.sitePaths = sitePaths;
+      this.allProjects = allProjects;
+    }
+
+    public ProjectConfig create(Project.NameKey projectName) {
+      return new ProjectConfig(projectName, getBaseConfig(sitePaths, allProjects, projectName));
+    }
+
+    public ProjectConfig read(MetaDataUpdate update) throws IOException, ConfigInvalidException {
+      ProjectConfig r = create(update.getProjectName());
+      r.load(update);
+      return r;
+    }
+
+    public ProjectConfig read(MetaDataUpdate update, ObjectId id)
+        throws IOException, ConfigInvalidException {
+      ProjectConfig r = create(update.getProjectName());
+      r.load(update, id);
+      return r;
+    }
+  }
+
+  private final StoredConfig baseConfig;
+
   private Project project;
   private AccountsSection accountsSection;
   private GroupList groupList;
@@ -169,7 +230,7 @@
   private Map<String, LabelType> labelSections;
   private ConfiguredMimeTypes mimeTypes;
   private Map<Project.NameKey, SubscribeSection> subscribeSections;
-  private List<CommentLinkInfoImpl> commentLinkSections;
+  private Map<String, CommentLinkInfoImpl> commentLinkSections;
   private List<ValidationError> validationErrors;
   private ObjectId rulesId;
   private long maxObjectSizeLimit;
@@ -180,20 +241,6 @@
   private Map<String, List<String>> extensionPanelSections;
   private Map<String, GroupReference> groupsByName;
 
-  public static ProjectConfig read(MetaDataUpdate update)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig r = new ProjectConfig(update.getProjectName());
-    r.load(update);
-    return r;
-  }
-
-  public static ProjectConfig read(MetaDataUpdate update, ObjectId id)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig r = new ProjectConfig(update.getProjectName());
-    r.load(update, id);
-    return r;
-  }
-
   public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name, boolean allowRaw)
       throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
@@ -232,11 +279,26 @@
   }
 
   public void addCommentLinkSection(CommentLinkInfoImpl commentLink) {
-    commentLinkSections.add(commentLink);
+    commentLinkSections.put(commentLink.name, commentLink);
   }
 
-  public ProjectConfig(Project.NameKey projectName) {
+  private ProjectConfig(Project.NameKey projectName, @Nullable StoredConfig baseConfig) {
     this.projectName = projectName;
+    this.baseConfig = baseConfig;
+  }
+
+  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() {
@@ -268,6 +330,10 @@
     return as;
   }
 
+  public ImmutableSet<String> getAccessSectionNames() {
+    return ImmutableSet.copyOf(accessSections.keySet());
+  }
+
   public Collection<AccessSection> getAccessSections() {
     return sort(accessSections.values());
   }
@@ -280,7 +346,7 @@
     return subscribeSections;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
+  public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
     Collection<SubscribeSection> ret = new ArrayList<>();
     for (SubscribeSection s : subscribeSections.values()) {
       if (s.appliesTo(branch)) {
@@ -299,7 +365,7 @@
       String name = section.getName();
       if (sectionsWithUnknownPermissions.contains(name)) {
         AccessSection a = accessSections.get(name);
-        a.setPermissions(new ArrayList<Permission>());
+        a.setPermissions(new ArrayList<>());
       } else {
         accessSections.remove(name);
       }
@@ -395,17 +461,13 @@
   }
 
   public Collection<CommentLinkInfoImpl> getCommentLinkSections() {
-    return commentLinkSections;
+    return commentLinkSections.values();
   }
 
   public ConfiguredMimeTypes getMimeTypes() {
     return mimeTypes;
   }
 
-  public GroupReference resolve(AccountGroup group) {
-    return resolve(GroupReference.forGroup(group));
-  }
-
   public GroupReference resolve(GroupReference group) {
     GroupReference groupRef = groupList.resolve(group);
     if (groupRef != null
@@ -441,10 +503,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;
   }
@@ -491,11 +550,14 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
+    if (baseConfig != null) {
+      baseConfig.load();
+    }
     readGroupList();
     groupsByName = mapGroupReferences();
 
     rulesId = getObjectId("rules.pl");
-    Config rc = readConfig(PROJECT_CONFIG);
+    Config rc = readConfig(PROJECT_CONFIG, baseConfig);
     project = new Project(projectName);
 
     Project p = project;
@@ -503,6 +565,9 @@
     if (p.getDescription() == null) {
       p.setDescription("");
     }
+    if (revision != null) {
+      p.setConfigRefState(revision.toObjectId().name());
+    }
 
     if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
       // The config must not contain more than one parent to inherit from
@@ -577,6 +642,9 @@
       ca.setAgreementUrl(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_AGREEMENT_URL));
       ca.setAccepted(
           loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false));
+      ca.setExcludeProjectsRegexes(
+          loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_EXCLUDE_PROJECTS));
+      ca.setMatchProjectsRegexes(loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_MATCH_PROJECTS));
 
       List<PermissionRule> rules =
           loadPermissionRules(
@@ -646,7 +714,7 @@
         if (groupName != null) {
           GroupReference ref = groupsByName.get(groupName);
           if (ref == null) {
-            ref = new GroupReference(null, groupName);
+            ref = new GroupReference(groupName);
             groupsByName.put(ref.getName(), ref);
           }
           if (ref.getUUID() != null) {
@@ -678,13 +746,13 @@
     accessSections = new HashMap<>();
     sectionsWithUnknownPermissions = new HashSet<>();
     for (String refName : rc.getSubsections(ACCESS)) {
-      if (RefConfigSection.isValid(refName) && isValidRegex(refName)) {
+      if (AccessSection.isValidRefSectionName(refName) && isValidRegex(refName)) {
         AccessSection as = getAccessSection(refName, true);
 
         for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
           for (String n : Splitter.on(EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN).split(varName)) {
             n = convertLegacyPermission(n);
-            if (isPermission(n)) {
+            if (isCoreOrPluginPermission(n)) {
               as.getPermission(n, true).setExclusiveGroup(true);
             }
           }
@@ -692,7 +760,7 @@
 
         for (String varName : rc.getNames(ACCESS, refName)) {
           String convertedName = convertLegacyPermission(varName);
-          if (isPermission(convertedName)) {
+          if (isCoreOrPluginPermission(convertedName)) {
             Permission perm = as.getPermission(convertedName, true);
             loadPermissionRules(
                 rc,
@@ -721,6 +789,12 @@
     }
   }
 
+  private boolean isCoreOrPluginPermission(String permission) {
+    // Since plugins are loaded dynamically, here we can't load all plugin permissions and verify
+    // their existence.
+    return isPermission(permission) || isValidPluginPermission(permission);
+  }
+
   private boolean isValidRegex(String refPattern) {
     try {
       RefPattern.validateRegExp(refPattern);
@@ -737,7 +811,23 @@
     }
   }
 
-  private List<PermissionRule> loadPermissionRules(
+  private ImmutableList<String> loadPatterns(
+      Config rc, String section, String subsection, String varName) {
+    ImmutableList.Builder<String> patterns = ImmutableList.builder();
+    for (String patternString : rc.getStringList(section, subsection, varName)) {
+      try {
+        // While one could just use getStringList directly, compiling first will cause the server
+        // to fail fast if any of the patterns are invalid.
+        patterns.add(Pattern.compile(patternString).pattern());
+      } catch (PatternSyntaxException e) {
+        error(new ValidationError(PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
+        continue;
+      }
+    }
+    return patterns.build();
+  }
+
+  private ImmutableList<PermissionRule> loadPermissionRules(
       Config rc,
       String section,
       String subsection,
@@ -746,7 +836,7 @@
       boolean useRange) {
     Permission perm = new Permission(varName);
     loadPermissionRules(rc, section, subsection, varName, groupsByName, perm, useRange);
-    return perm.getRules();
+    return ImmutableList.copyOf(perm.getRules());
   }
 
   private void loadPermissionRules(
@@ -868,6 +958,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(
@@ -920,10 +1012,10 @@
 
   private void loadCommentLinkSections(Config rc) {
     Set<String> subsections = rc.getSubsections(COMMENTLINK);
-    commentLinkSections = new ArrayList<>(subsections.size());
+    commentLinkSections = new LinkedHashMap<>(subsections.size());
     for (String name : subsections) {
       try {
-        commentLinkSections.add(buildCommentLink(rc, name, false));
+        commentLinkSections.put(name, buildCommentLink(rc, name, false));
       } catch (PatternSyntaxException e) {
         error(
             new ValidationError(
@@ -947,7 +1039,7 @@
     subscribeSections = new HashMap<>();
     try {
       for (String projectName : subsections) {
-        Project.NameKey p = new Project.NameKey(projectName);
+        Project.NameKey p = Project.nameKey(projectName);
         SubscribeSection ss = new SubscribeSection(p);
         for (String s :
             rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
@@ -1110,7 +1202,7 @@
 
   private void saveCommentLinkSections(Config rc) {
     if (commentLinkSections != null) {
-      for (CommentLinkInfoImpl cm : commentLinkSections) {
+      for (CommentLinkInfoImpl cm : commentLinkSections.values()) {
         rc.setString(COMMENTLINK, cm.name, KEY_MATCH, cm.match);
         if (!Strings.isNullOrEmpty(cm.html)) {
           rc.setString(COMMENTLINK, cm.name, KEY_HTML, cm.html);
@@ -1145,26 +1237,33 @@
           ca.getName(),
           KEY_ACCEPTED,
           ruleToStringList(ca.getAccepted(), keepGroups));
+      rc.setStringList(
+          CONTRIBUTOR_AGREEMENT,
+          ca.getName(),
+          KEY_EXCLUDE_PROJECTS,
+          patternToStringList(ca.getExcludeProjectsRegexes()));
+      rc.setStringList(
+          CONTRIBUTOR_AGREEMENT,
+          ca.getName(),
+          KEY_MATCH_PROJECTS,
+          patternToStringList(ca.getMatchProjectsRegexes()));
     }
   }
 
   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(GroupReference::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()) {
@@ -1189,6 +1288,10 @@
     }
   }
 
+  private List<String> patternToStringList(List<String> list) {
+    return list;
+  }
+
   private List<String> ruleToStringList(
       List<PermissionRule> list, Set<AccountGroup.UUID> keepGroups) {
     List<String> rules = new ArrayList<>();
@@ -1266,7 +1369,7 @@
       }
 
       for (String varName : rc.getNames(ACCESS, refName)) {
-        if (isPermission(convertLegacyPermission(varName))
+        if (isCoreOrPluginPermission(convertLegacyPermission(varName))
             && !have.contains(varName.toLowerCase())) {
           rc.unset(ACCESS, refName, varName);
         }
@@ -1274,7 +1377,7 @@
     }
 
     for (String name : rc.getSubsections(ACCESS)) {
-      if (RefConfigSection.isValid(name) && !accessSections.containsKey(name)) {
+      if (AccessSection.isValidRefSectionName(name) && !accessSections.containsKey(name)) {
         rc.unsetSection(ACCESS, name);
       }
     }
@@ -1308,6 +1411,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);
@@ -1380,7 +1490,7 @@
       rc.unsetSection(PLUGIN, name);
     }
 
-    for (Entry<String, Config> e : pluginConfigs.entrySet()) {
+    for (Map.Entry<String, Config> e : pluginConfigs.entrySet()) {
       String plugin = e.getKey();
       Config pluginConfig = e.getValue();
       for (String name : pluginConfig.getNames(PLUGIN, plugin)) {
@@ -1438,12 +1548,11 @@
     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());
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public boolean hasLegacyPermissions() {
     return hasLegacyPermissions;
   }
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
new file mode 100644
index 0000000..b50b046
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -0,0 +1,255 @@
+// 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 com.google.common.base.MoreObjects;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+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.IdentifiedUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+public class ProjectCreator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager repoManager;
+  private final PluginSetContext<NewProjectCreatedListener> createdListeners;
+  private final ProjectCache projectCache;
+  private final GroupBackend groupBackend;
+  private final MetaDataUpdate.User metaDataUpdateFactory;
+  private final GitReferenceUpdated referenceUpdated;
+  private final RepositoryConfig repositoryCfg;
+  private final Provider<PersonIdent> serverIdent;
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final ProjectConfig.Factory projectConfigFactory;
+
+  @Inject
+  ProjectCreator(
+      GitRepositoryManager repoManager,
+      PluginSetContext<NewProjectCreatedListener> createdListeners,
+      ProjectCache projectCache,
+      GroupBackend groupBackend,
+      MetaDataUpdate.User metaDataUpdateFactory,
+      GitReferenceUpdated referenceUpdated,
+      RepositoryConfig repositoryCfg,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      Provider<IdentifiedUser> identifiedUser,
+      ProjectConfig.Factory projectConfigFactory) {
+    this.repoManager = repoManager;
+    this.createdListeners = createdListeners;
+    this.projectCache = projectCache;
+    this.groupBackend = groupBackend;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.referenceUpdated = referenceUpdated;
+    this.repositoryCfg = repositoryCfg;
+    this.serverIdent = serverIdent;
+    this.identifiedUser = identifiedUser;
+    this.projectConfigFactory = projectConfigFactory;
+  }
+
+  public ProjectState createProject(CreateProjectArgs args)
+      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
+    final Project.NameKey nameKey = args.getProject();
+    try {
+      final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
+      try (Repository repo = repoManager.openRepository(nameKey)) {
+        if (repo.getObjectDatabase().exists()) {
+          throw new ResourceConflictException("project \"" + nameKey + "\" exists");
+        }
+      } catch (RepositoryNotFoundException e) {
+        // It does not exist, safe to ignore.
+      }
+      try (Repository repo = repoManager.createRepository(nameKey)) {
+        RefUpdate u = repo.updateRef(Constants.HEAD);
+        u.disableRefLog();
+        u.link(head);
+
+        createProjectConfig(args);
+
+        if (!args.permissionsOnly && args.createEmptyCommit) {
+          createEmptyCommits(repo, nameKey, args.branch);
+        }
+
+        fire(nameKey, head);
+
+        return projectCache.get(nameKey);
+      }
+    } catch (RepositoryCaseMismatchException e) {
+      throw new ResourceConflictException(
+          "Cannot create "
+              + nameKey.get()
+              + " because the name is already occupied by another project."
+              + " The other project has the same name, only spelled in a"
+              + " different case.");
+    } catch (RepositoryNotFoundException badName) {
+      throw new BadRequestException("invalid project name: " + nameKey);
+    } catch (ConfigInvalidException e) {
+      String msg = "Cannot create " + nameKey;
+      logger.atSevere().withCause(e).log(msg);
+      throw e;
+    }
+  }
+
+  private void createProjectConfig(CreateProjectArgs args)
+      throws IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      Project newProject = config.getProject();
+      newProject.setDescription(args.projectDescription);
+      newProject.setSubmitType(
+          MoreObjects.firstNonNull(
+              args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
+      newProject.setBooleanConfig(
+          BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, args.contributorAgreements);
+      newProject.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, args.signedOffBy);
+      newProject.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, args.contentMerge);
+      newProject.setBooleanConfig(
+          BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+          args.newChangeForAllNotInTarget);
+      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
+      newProject.setBooleanConfig(BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
+      newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
+      newProject.setBooleanConfig(BooleanProjectConfig.ENABLE_SIGNED_PUSH, args.enableSignedPush);
+      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_SIGNED_PUSH, args.requireSignedPush);
+      if (args.newParent != null) {
+        newProject.setParentName(args.newParent);
+      }
+
+      if (!args.ownerIds.isEmpty()) {
+        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
+        for (AccountGroup.UUID ownerId : args.ownerIds) {
+          GroupDescription.Basic g = groupBackend.get(ownerId);
+          if (g != null) {
+            GroupReference group = config.resolve(GroupReference.forGroup(g));
+            all.getPermission(Permission.OWNER, true).add(new PermissionRule(group));
+          }
+        }
+      }
+
+      md.setMessage("Created project\n");
+      config.commit(md);
+      md.getRepository().setGitwebDescription(args.projectDescription);
+    }
+    projectCache.onCreateProject(args.getProject());
+  }
+
+  private void createEmptyCommits(Repository repo, Project.NameKey project, List<String> refs)
+      throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
+      cb.setAuthor(metaDataUpdateFactory.getUserPersonIdent());
+      cb.setCommitter(serverIdent.get());
+      cb.setMessage("Initial empty repository\n");
+
+      ObjectId id = oi.insert(cb);
+      oi.flush();
+
+      for (String ref : refs) {
+        RefUpdate ru = repo.updateRef(ref);
+        ru.setNewObjectId(id);
+        Result result = ru.update();
+        switch (result) {
+          case NEW:
+            referenceUpdated.fire(
+                project, ru, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
+            break;
+          case FAST_FORWARD:
+          case FORCED:
+          case IO_FAILURE:
+          case LOCK_FAILURE:
+          case NOT_ATTEMPTED:
+          case NO_CHANGE:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            {
+              throw new IOException(
+                  String.format("Failed to create ref \"%s\": %s", ref, result.name()));
+            }
+        }
+      }
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Cannot create empty commit for %s", project.get());
+      throw e;
+    }
+  }
+
+  private void fire(Project.NameKey name, String head) {
+    if (createdListeners.isEmpty()) {
+      return;
+    }
+
+    ProjectCreator.Event event = new ProjectCreator.Event(name, head);
+    createdListeners.runEach(l -> l.onNewProjectCreated(event));
+  }
+
+  static class Event extends AbstractNoNotifyEvent implements NewProjectCreatedListener.Event {
+    private final Project.NameKey name;
+    private final String head;
+
+    Event(Project.NameKey name, String head) {
+      this.name = name;
+      this.head = head;
+    }
+
+    @Override
+    public String getProjectName() {
+      return name.get();
+    }
+
+    @Override
+    public String getHeadName() {
+      return head;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index f2a93d3..ca6c9f4 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toMap;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.extensions.common.LabelTypeInfo;
@@ -29,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.HashMap;
-import java.util.List;
 
 @Singleton
 public class ProjectJson {
@@ -65,7 +65,7 @@
     info.description = Strings.emptyToNull(p.getDescription());
     info.state = p.getState();
     info.id = Url.encode(info.name);
-    List<WebLinkInfo> links = webLinks.getProjectLinks(p.getName());
+    ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(p.getName());
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index a490f10..a43047f 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.project;
 
 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;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.AccessSection;
@@ -28,47 +27,51 @@
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.RefConfigSection;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-import com.google.gerrit.extensions.api.projects.ThemeInfo;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-/** Cached information on a project. */
+/**
+ * Cached information on a project. Must not contain any data derived from parents other than it's
+ * immediate parent's {@link com.google.gerrit.reviewdb.client.Project.NameKey}.
+ */
 public class ProjectState {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -78,7 +81,6 @@
 
   private final boolean isAllProjects;
   private final boolean isAllUsers;
-  private final SitePaths sitePaths;
   private final AllProjectsName allProjectsName;
   private final ProjectCache projectCache;
   private final GitRepositoryManager gitMgr;
@@ -87,6 +89,11 @@
   private final ProjectConfig config;
   private final Map<String, ProjectLevelConfig> configs;
   private final Set<AccountGroup.UUID> localOwners;
+  private final long globalMaxObjectSizeLimit;
+  private final boolean inheritProjectMaxObjectSizeLimit;
+
+  // TODO(hiesel): Remove this once we got production data
+  private final Timer1<String> computationLatency;
 
   /** Last system time the configuration's revision was examined. */
   private volatile long lastCheckGeneration;
@@ -94,26 +101,20 @@
   /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
   private volatile List<SectionMatcher> localAccessSections;
 
-  /** Theme information loaded from site_path/themes. */
-  private volatile ThemeInfo theme;
-
   /** If this is all projects, the capabilities used by the server. */
   private final CapabilityCollection capabilities;
 
-  /** All label types applicable to changes in this project. */
-  private LabelTypes labelTypes;
-
   @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) {
-    this.sitePaths = sitePaths;
+      ProjectCache projectCache,
+      AllProjectsName allProjectsName,
+      AllUsersName allUsersName,
+      GitRepositoryManager gitMgr,
+      List<CommentLinkInfo> commentLinks,
+      CapabilityCollection.Factory limitsFactory,
+      TransferConfig transferConfig,
+      MetricMaker metricMaker,
+      @Assisted ProjectConfig config) {
     this.projectCache = projectCache;
     this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
     this.isAllUsers = config.getProject().getNameKey().equals(allUsersName);
@@ -126,6 +127,16 @@
         isAllProjects
             ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
             : null;
+    this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
+    this.inheritProjectMaxObjectSizeLimit = transferConfig.inheritProjectMaxObjectSizeLimit();
+
+    this.computationLatency =
+        metricMaker.newTimer(
+            "permissions/project_state/computation_latency",
+            new Description("Latency for access computations in ProjectState")
+                .setCumulative()
+                .setUnit(Units.NANOSECONDS),
+            Field.ofString("method", Metadata.Builder::methodName).build());
 
     if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
       localOwners = Collections.emptySet();
@@ -193,8 +204,7 @@
     }
 
     // If not, we check the parents.
-    return parents()
-        .stream()
+    return parents().stream()
         .map(ProjectState::getConfig)
         .map(ProjectConfig::getRulesId)
         .anyMatch(Objects::nonNull);
@@ -223,7 +233,7 @@
 
     ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
     try (Repository git = gitMgr.openRepository(getNameKey())) {
-      cfg.load(git, config.getRevision());
+      cfg.load(getNameKey(), git, config.getRevision());
     } catch (IOException | ConfigInvalidException e) {
       logger.atWarning().withCause(e).log("Failed to load %s for %s", fileName, getName());
     }
@@ -258,6 +268,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;
@@ -291,15 +355,21 @@
    * cached. Callers should try to cache this result per-request as much as possible.
    */
   public List<SectionMatcher> getAllSections() {
-    if (isAllProjects) {
-      return getLocalAccessSections();
-    }
+    try (Timer1.Context<String> ignored = computationLatency.start("getAllSections")) {
+      if (isAllProjects) {
+        return getLocalAccessSections();
+      }
 
-    List<SectionMatcher> all = new ArrayList<>();
-    for (ProjectState s : tree()) {
-      all.addAll(s.getLocalAccessSections());
+      List<SectionMatcher> all = new ArrayList<>();
+      Iterable<ProjectState> tree = tree();
+      try (Timer1.Context<String> ignored2 =
+          computationLatency.start("getAllSections-parsing-only")) {
+        for (ProjectState s : tree) {
+          all.addAll(s.getLocalAccessSections());
+        }
+      }
+      return all;
     }
-    return all;
   }
 
   /**
@@ -337,12 +407,7 @@
    *     Starts from this project and progresses up the hierarchy to All-Projects.
    */
   public Iterable<ProjectState> tree() {
-    return new Iterable<ProjectState>() {
-      @Override
-      public Iterator<ProjectState> iterator() {
-        return new ProjectHierarchyIterator(projectCache, allProjectsName, ProjectState.this);
-      }
-    };
+    return () -> new ProjectHierarchyIterator(projectCache, allProjectsName, ProjectState.this);
   }
 
   /**
@@ -388,19 +453,32 @@
 
   /** All available label types. */
   public LabelTypes getLabelTypes() {
-    if (labelTypes == null) {
-      labelTypes = loadLabelTypes();
+    Map<String, LabelType> types = new LinkedHashMap<>();
+    for (ProjectState s : treeInOrder()) {
+      for (LabelType type : s.getConfig().getLabelSections().values()) {
+        String lower = type.getName().toLowerCase();
+        LabelType old = types.get(lower);
+        if (old == null || old.canOverride()) {
+          types.put(lower, type);
+        }
+      }
     }
-    return labelTypes;
+    List<LabelType> all = Lists.newArrayListWithCapacity(types.size());
+    for (LabelType type : types.values()) {
+      if (!type.getValues().isEmpty()) {
+        all.add(type);
+      }
+    }
+    return new LabelTypes(Collections.unmodifiableList(all));
   }
 
-  /** All available label types for this change and user. */
-  public LabelTypes getLabelTypes(ChangeNotes notes, CurrentUser user) {
-    return getLabelTypes(notes.getChange().getDest(), user);
+  /** All available label types for this change. */
+  public LabelTypes getLabelTypes(ChangeNotes notes) {
+    return getLabelTypes(notes.getChange().getDest());
   }
 
-  /** All available label types for this branch and user. */
-  public LabelTypes getLabelTypes(Branch.NameKey destination, CurrentUser user) {
+  /** All available label types for this branch. */
+  public LabelTypes getLabelTypes(BranchNameKey destination) {
     List<LabelType> all = getLabelTypes().getLabelTypes();
 
     List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
@@ -410,7 +488,15 @@
         r.add(l);
       } else {
         for (String refPattern : refs) {
-          if (RefConfigSection.isValid(refPattern) && match(destination, refPattern, user)) {
+          if (refPattern.contains("${")) {
+            logger.atWarning().log(
+                "Ref pattern for label %s in project %s contains illegal expanded parameters: %s."
+                    + " Ref pattern will be ignored.",
+                l, getName(), refPattern);
+            continue;
+          }
+
+          if (AccessSection.isValidRefSectionName(refPattern) && match(destination, refPattern)) {
             r.add(l);
             break;
           }
@@ -453,7 +539,7 @@
     return null;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
+  public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
     Collection<SubscribeSection> ret = new ArrayList<>();
     for (ProjectState s : tree()) {
       ret.addAll(s.getConfig().getSubscribeSections(branch));
@@ -461,24 +547,6 @@
     return ret;
   }
 
-  public ThemeInfo getTheme() {
-    ThemeInfo theme = this.theme;
-    if (theme == null) {
-      synchronized (this) {
-        theme = this.theme;
-        if (theme == null) {
-          theme = loadTheme();
-          this.theme = theme;
-        }
-      }
-    }
-    if (theme == ThemeInfo.INHERIT) {
-      ProjectState parent = Iterables.getFirst(parents(), null);
-      return parent != null ? parent.getTheme() : null;
-    }
-    return theme;
-  }
-
   public Set<GroupReference> getAllGroups() {
     return getGroups(getAllSections());
   }
@@ -510,55 +578,15 @@
     return all;
   }
 
-  private ThemeInfo loadTheme() {
-    String name = getConfig().getProject().getName();
-    Path dir = sitePaths.themes_dir.resolve(name);
-    if (!Files.exists(dir)) {
-      return ThemeInfo.INHERIT;
-    } else if (!Files.isDirectory(dir)) {
-      logger.atWarning().log("Bad theme for %s: not a directory", name);
-      return ThemeInfo.INHERIT;
-    }
-    try {
-      return new ThemeInfo(
-          readFile(dir.resolve(SitePaths.CSS_FILENAME)),
-          readFile(dir.resolve(SitePaths.HEADER_FILENAME)),
-          readFile(dir.resolve(SitePaths.FOOTER_FILENAME)));
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Error reading theme for %s", name);
-      return ThemeInfo.INHERIT;
-    }
-  }
-
   public ProjectData toProjectData() {
-    return new ProjectData(getProject(), parents().transform(s -> s.getProject().getNameKey()));
-  }
-
-  private String readFile(Path p) throws IOException {
-    return Files.exists(p) ? new String(Files.readAllBytes(p), UTF_8) : null;
-  }
-
-  private LabelTypes loadLabelTypes() {
-    Map<String, LabelType> types = new LinkedHashMap<>();
-    for (ProjectState s : treeInOrder()) {
-      for (LabelType type : s.getConfig().getLabelSections().values()) {
-        String lower = type.getName().toLowerCase();
-        LabelType old = types.get(lower);
-        if (old == null || old.canOverride()) {
-          types.put(lower, type);
-        }
-      }
+    ProjectData project = null;
+    for (ProjectState state : treeInOrder()) {
+      project = new ProjectData(state.getProject(), Optional.ofNullable(project));
     }
-    List<LabelType> all = Lists.newArrayListWithCapacity(types.size());
-    for (LabelType type : types.values()) {
-      if (!type.getValues().isEmpty()) {
-        all.add(type);
-      }
-    }
-    return new LabelTypes(Collections.unmodifiableList(all));
+    return project;
   }
 
-  private boolean match(Branch.NameKey destination, String refPattern, CurrentUser user) {
-    return RefPatternMatcher.getMatcher(refPattern).match(destination.get(), user);
+  private boolean match(BranchNameKey destination, String refPattern) {
+    return RefPatternMatcher.getMatcher(refPattern).match(destination.branch(), null);
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
new file mode 100644
index 0000000..83393bc
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -0,0 +1,330 @@
+// 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.exceptions.StorageException;
+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.PatchSet;
+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.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, 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, 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 = 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) {
+    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)));
+              },
+              StorageException.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(PatchSet::commitId)
+                        .collect(toSet())) {
+                  if (mergedSha1s.contains(patchSetSha1)) {
+                    autoCloseableChangesByBranch.add(
+                        changeJson(fix, patchSetSha1).format(autoCloseableChange));
+                    break;
+                  }
+                }
+                return null;
+              },
+              StorageException.class::isInstance);
+        }
+      }
+
+      return autoCloseableChangesByBranch;
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw new StorageException(e);
+    }
+  }
+
+  private ChangeJson changeJson(Boolean fix, ObjectId mergedAs) {
+    ChangeJson changeJson = changeJsonFactory.create(ListChangesOption.CHECK);
+    if (fix != null && fix.booleanValue()) {
+      FixInput fixInput = new FixInput();
+      fixInput.expectMergedAs = mergedAs.name();
+      changeJson.fix(fixInput);
+    }
+    return changeJson;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 322d362e..8119ef5 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.IncludedInResolver;
@@ -25,11 +23,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collection;
+import java.util.List;
 import java.util.Map;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -49,15 +45,19 @@
     this.permissionBackend = permissionBackend;
   }
 
-  /** @return true if a commit is reachable from a given set of refs. */
+  /**
+   * @return true if a commit is reachable from a given set of refs. This method enforces
+   *     permissions on the given set of refs and performs a reachability check. Tags are not
+   *     filtered separately and will only be returned if reachable by a provided ref.
+   */
   public boolean fromRefs(
-      Project.NameKey project, Repository repo, RevCommit commit, Map<String, Ref> refs) {
+      Project.NameKey project, Repository repo, RevCommit commit, List<Ref> refs) {
     try (RevWalk rw = new RevWalk(repo)) {
       Map<String, Ref> filtered =
           permissionBackend
               .currentUser()
               .project(project)
-              .filter(refs, repo, RefFilterOptions.builder().setFilterTagsSeparately(true).build());
+              .filter(refs, repo, RefFilterOptions.defaults());
       return IncludedInResolver.includedInAny(repo, rw, commit, filtered.values());
     } catch (IOException | PermissionBackendException e) {
       logger.atSevere().withCause(e).log(
@@ -65,22 +65,4 @@
       return false;
     }
   }
-
-  /** @return true if a commit is reachable from a repo's branches and tags. */
-  boolean fromHeadsOrTags(Project.NameKey project, Repository repo, RevCommit commit) {
-    try {
-      RefDatabase refdb = repo.getRefDatabase();
-      Collection<Ref> heads = refdb.getRefs(Constants.R_HEADS).values();
-      Collection<Ref> tags = refdb.getRefs(Constants.R_TAGS).values();
-      Map<String, Ref> refs = Maps.newHashMapWithExpectedSize(heads.size() + tags.size());
-      for (Ref r : Iterables.concat(heads, tags)) {
-        refs.put(r.getName(), r);
-      }
-      return fromRefs(project, repo, commit, refs);
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot verify permissions to commit object %s in repository %s", commit.name(), project);
-      return false;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/project/RefFilter.java b/java/com/google/gerrit/server/project/RefFilter.java
index 76bafc0..cdabcbe 100644
--- a/java/com/google/gerrit/server/project/RefFilter.java
+++ b/java/com/google/gerrit/server/project/RefFilter.java
@@ -14,15 +14,17 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.Predicate;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.api.projects.RefInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 import java.util.List;
 import java.util.Locale;
+import java.util.stream.Stream;
 
 public class RefFilter<T extends RefInfo> {
   private final String prefix;
@@ -55,15 +57,17 @@
     return this;
   }
 
-  public List<T> filter(List<T> refs) throws BadRequestException {
+  public ImmutableList<T> filter(List<T> refs) throws BadRequestException {
     if (!Strings.isNullOrEmpty(matchSubstring) && !Strings.isNullOrEmpty(matchRegex)) {
       throw new BadRequestException("specify exactly one of m/r");
     }
-    FluentIterable<T> results = FluentIterable.from(refs);
+    Stream<T> results = refs.stream();
     if (!Strings.isNullOrEmpty(matchSubstring)) {
-      results = results.filter(new SubstringPredicate(matchSubstring));
+      String lowercaseSubstring = matchSubstring.toLowerCase(Locale.US);
+      results = results.filter(refInfo -> matchesSubstring(prefix, lowercaseSubstring, refInfo));
     } else if (!Strings.isNullOrEmpty(matchRegex)) {
-      results = results.filter(new RegexPredicate(matchRegex));
+      RunAutomaton a = parseRegex(matchRegex);
+      results = results.filter(refInfo -> matchesRegex(prefix, a, refInfo));
     }
     if (start > 0) {
       results = results.skip(start);
@@ -71,51 +75,39 @@
     if (limit > 0) {
       results = results.limit(limit);
     }
-    return results.toList();
+    return results.collect(toImmutableList());
   }
 
-  private class SubstringPredicate implements Predicate<T> {
-    private final String substring;
-
-    private SubstringPredicate(String substring) {
-      this.substring = substring.toLowerCase(Locale.US);
+  private static <T extends RefInfo> boolean matchesSubstring(
+      String prefix, String lowercaseSubstring, T refInfo) {
+    String ref = refInfo.ref;
+    if (ref.startsWith(prefix)) {
+      ref = ref.substring(prefix.length());
     }
+    ref = ref.toLowerCase(Locale.US);
+    return ref.contains(lowercaseSubstring);
+  }
 
-    @Override
-    public boolean apply(T in) {
-      String ref = in.ref;
-      if (ref.startsWith(prefix)) {
-        ref = ref.substring(prefix.length());
+  private static RunAutomaton parseRegex(String regex) throws BadRequestException {
+    if (regex.startsWith("^")) {
+      regex = regex.substring(1);
+      if (regex.endsWith("$") && !regex.endsWith("\\$")) {
+        regex = regex.substring(0, regex.length() - 1);
       }
-      ref = ref.toLowerCase(Locale.US);
-      return ref.contains(substring);
+    }
+    try {
+      return new RunAutomaton(new RegExp(regex).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage());
     }
   }
 
-  private class RegexPredicate implements Predicate<T> {
-    private final RunAutomaton a;
-
-    private RegexPredicate(String regex) throws BadRequestException {
-      if (regex.startsWith("^")) {
-        regex = regex.substring(1);
-        if (regex.endsWith("$") && !regex.endsWith("\\$")) {
-          regex = regex.substring(0, regex.length() - 1);
-        }
-      }
-      try {
-        a = new RunAutomaton(new RegExp(regex).toAutomaton());
-      } catch (IllegalArgumentException e) {
-        throw new BadRequestException(e.getMessage());
-      }
+  private static <T extends RefInfo> boolean matchesRegex(
+      String prefix, RunAutomaton a, T refInfo) {
+    String ref = refInfo.ref;
+    if (ref.startsWith(prefix)) {
+      ref = ref.substring(prefix.length());
     }
-
-    @Override
-    public boolean apply(T in) {
-      String ref = in.ref;
-      if (ref.startsWith(prefix)) {
-        ref = ref.substring(prefix.length());
-      }
-      return a.run(ref);
-    }
+    return a.run(ref);
   }
 }
diff --git a/java/com/google/gerrit/server/project/RefPattern.java b/java/com/google/gerrit/server/project/RefPattern.java
index 72face2..0e916fb 100644
--- a/java/com/google/gerrit/server/project/RefPattern.java
+++ b/java/com/google/gerrit/server/project/RefPattern.java
@@ -19,8 +19,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.exceptions.InvalidNameException;
 import dk.brics.automaton.RegExp;
 import java.util.concurrent.ExecutionException;
 import java.util.regex.Pattern;
@@ -78,11 +77,11 @@
   }
 
   public static void validate(String refPattern) throws InvalidNameException {
-    if (refPattern.startsWith(RefConfigSection.REGEX_PREFIX)) {
+    if (refPattern.startsWith(AccessSection.REGEX_PREFIX)) {
       if (!Repository.isValidRefName(shortestExample(refPattern))) {
         throw new InvalidNameException(refPattern);
       }
-    } else if (refPattern.equals(RefConfigSection.ALL)) {
+    } else if (refPattern.equals(AccessSection.ALL)) {
       // This is a special case we have to allow, it fails below.
     } else if (refPattern.endsWith("/*")) {
       String prefix = refPattern.substring(0, refPattern.length() - 2);
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index e5951a8..9f1fa4a 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -71,7 +71,7 @@
       RefDatabase refDb = repo.getRefDatabase();
       Iterable<Ref> refs =
           Iterables.concat(
-              refDb.getRefs(Constants.R_HEADS).values(), refDb.getRefs(Constants.R_TAGS).values());
+              refDb.getRefsByPrefix(Constants.R_HEADS), refDb.getRefsByPrefix(Constants.R_TAGS));
       Ref rc = refDb.exactRef(RefNames.REFS_CONFIG);
       if (rc != null) {
         refs = Iterables.concat(refs, Collections.singleton(rc));
diff --git a/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
index 0a5980c..67c0d03 100644
--- a/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -43,7 +43,7 @@
       throws ResourceConflictException {
     RefOperationValidators refValidators =
         refValidatorsFactory.create(
-            new Project(new Project.NameKey(projectName)),
+            new Project(Project.nameKey(projectName)),
             user,
             RefOperationValidators.getCommand(update, operationType));
     try {
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index a0091a3..efd99dd 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -27,20 +26,16 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class RemoveReviewerControl {
   private final PermissionBackend permissionBackend;
-  private final Provider<ReviewDb> dbProvider;
 
   @Inject
-  RemoveReviewerControl(PermissionBackend permissionBackend, Provider<ReviewDb> dbProvider) {
+  RemoveReviewerControl(PermissionBackend permissionBackend) {
     this.permissionBackend = permissionBackend;
-    this.dbProvider = dbProvider;
   }
 
   /**
@@ -52,7 +47,7 @@
   public void checkRemoveReviewer(
       ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
       throws PermissionBackendException, AuthException {
-    checkRemoveReviewer(notes, currentUser, approval.getAccountId(), approval.getValue());
+    checkRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
   }
 
   /**
@@ -70,16 +65,12 @@
   /** @return true if the user is allowed to remove this reviewer. */
   public boolean testRemoveReviewer(
       ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
-      throws PermissionBackendException, OrmException {
+      throws PermissionBackendException {
     if (canRemoveReviewerWithoutPermissionCheck(
         permissionBackend, cd.change(), currentUser, reviewer, value)) {
       return true;
     }
-    return permissionBackend
-        .user(currentUser)
-        .change(cd)
-        .database(dbProvider)
-        .test(ChangePermission.REMOVE_REVIEWER);
+    return permissionBackend.user(currentUser).change(cd).test(ChangePermission.REMOVE_REVIEWER);
   }
 
   private void checkRemoveReviewer(
@@ -90,11 +81,7 @@
       return;
     }
 
-    permissionBackend
-        .user(currentUser)
-        .change(notes)
-        .database(dbProvider)
-        .check(ChangePermission.REMOVE_REVIEWER);
+    permissionBackend.user(currentUser).change(notes).check(ChangePermission.REMOVE_REVIEWER);
   }
 
   private static boolean canRemoveReviewerWithoutPermissionCheck(
@@ -104,7 +91,7 @@
       Account.Id reviewer,
       int value)
       throws PermissionBackendException {
-    if (!change.getStatus().isOpen()) {
+    if (change.isMerged()) {
       return false;
     }
 
@@ -121,7 +108,7 @@
     // owner and site admin can remove anyone
     PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
     PermissionBackend.ForProject forProject = withUser.project(change.getProject());
-    if (check(forProject.ref(change.getDest().get()), RefPermission.WRITE_CONFIG)
+    if (check(forProject.ref(change.getDest().branch()), RefPermission.WRITE_CONFIG)
         || check(withUser, GlobalPermission.ADMINISTRATE_SERVER)) {
       return true;
     }
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 11b1f37..a8ebd98 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.server.CurrentUser;
 
 /**
@@ -28,7 +27,7 @@
 public class SectionMatcher extends RefPatternMatcher {
   static SectionMatcher wrap(Project.NameKey project, AccessSection section) {
     String ref = section.getName();
-    if (AccessSection.isValid(ref)) {
+    if (AccessSection.isValidRefSectionName(ref)) {
       return new SectionMatcher(project, section, getMatcher(ref));
     }
     return null;
@@ -57,7 +56,7 @@
     return matcher;
   }
 
-  public NameKey getProject() {
+  public Project.NameKey getProject() {
     return project;
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 3fcb3a9..1b1869c 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -14,22 +14,23 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.OnlineReindexMode;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologRule;
 import com.google.gerrit.server.rules.SubmitRule;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
-import java.util.stream.StreamSupport;
 
 /**
  * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
@@ -42,7 +43,7 @@
 
   private final ProjectCache projectCache;
   private final PrologRule prologRule;
-  private final DynamicSet<SubmitRule> submitRules;
+  private final PluginSetContext<SubmitRule> submitRules;
   private final SubmitRuleOptions opts;
 
   public interface Factory {
@@ -54,7 +55,7 @@
   private SubmitRuleEvaluator(
       ProjectCache projectCache,
       PrologRule prologRule,
-      DynamicSet<SubmitRule> submitRules,
+      PluginSetContext<SubmitRule> submitRules,
       @Assisted SubmitRuleOptions options) {
     this.projectCache = projectCache;
     this.prologRule = prologRule;
@@ -91,18 +92,18 @@
     try {
       change = cd.change();
       if (change == null) {
-        throw new OrmException("Change not found");
+        throw new StorageException("Change not found");
       }
 
       projectState = projectCache.get(cd.project());
       if (projectState == null) {
         throw new NoSuchProjectException(cd.project());
       }
-    } catch (OrmException | NoSuchProjectException e) {
+    } catch (StorageException | NoSuchProjectException e) {
       return ruleError("Error looking up change " + cd.getId(), e);
     }
 
-    if (!opts.allowClosed() && change.getStatus().isClosed()) {
+    if ((!opts.allowClosed() || OnlineReindexMode.isActive()) && change.isClosed()) {
       SubmitRecord rec = new SubmitRecord();
       rec.status = SubmitRecord.Status.CLOSED;
       return Collections.singletonList(rec);
@@ -110,8 +111,8 @@
 
     // We evaluate all the plugin-defined evaluators,
     // and then we collect the results in one list.
-    return StreamSupport.stream(submitRules.spliterator(), false)
-        .map(s -> s.evaluate(cd, opts))
+    return Streams.stream(submitRules)
+        .map(c -> c.call(s -> s.evaluate(cd, opts)))
         .flatMap(Collection::stream)
         .collect(Collectors.toList());
   }
diff --git a/java/com/google/gerrit/server/project/SuggestParentCandidates.java b/java/com/google/gerrit/server/project/SuggestParentCandidates.java
index 99833af..d3dfdcd 100644
--- a/java/com/google/gerrit/server/project/SuggestParentCandidates.java
+++ b/java/com/google/gerrit/server/project/SuggestParentCandidates.java
@@ -42,9 +42,7 @@
   }
 
   public List<Project.NameKey> getNameKeys() throws PermissionBackendException {
-    return permissionBackend
-        .currentUser()
-        .filter(ProjectPermission.ACCESS, readableParents())
+    return permissionBackend.currentUser().filter(ProjectPermission.ACCESS, readableParents())
         .stream()
         .sorted()
         .collect(toList());
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
index ca1ffae..f221e00 100644
--- a/java/com/google/gerrit/server/project/testing/BUILD
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -1,6 +1,6 @@
 java_library(
     name = "project-test-util",
-    testonly = 1,
+    testonly = True,
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
new file mode 100644
index 0000000..6c2ddde
--- /dev/null
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.testing;
+
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import java.util.Arrays;
+
+public class TestLabels {
+  public static LabelType codeReview() {
+    return label(
+        "Code-Review",
+        value(2, "Looks good to me, approved"),
+        value(1, "Looks good to me, but someone else must approve"),
+        value(0, "No score"),
+        value(-1, "I would prefer this is not merged as is"),
+        value(-2, "This shall not be merged"));
+  }
+
+  public static LabelType verified() {
+    return label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+  }
+
+  public static LabelType patchSetLock() {
+    LabelType label =
+        label("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
+    label.setFunction(LabelFunction.PATCH_SET_LOCK);
+    return label;
+  }
+
+  public static LabelValue value(int value, String text) {
+    return new LabelValue((short) value, text);
+  }
+
+  public static LabelType label(String name, LabelValue... values) {
+    return new LabelType(name, Arrays.asList(values));
+  }
+
+  private TestLabels() {}
+}
diff --git a/java/com/google/gerrit/server/project/testing/Util.java b/java/com/google/gerrit/server/project/testing/Util.java
deleted file mode 100644
index abfd2bd..0000000
--- a/java/com/google/gerrit/server/project/testing/Util.java
+++ /dev/null
@@ -1,227 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.testing;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.project.ProjectConfig;
-import java.util.Arrays;
-
-public class Util {
-  public static final AccountGroup.UUID ADMIN = new AccountGroup.UUID("test.admin");
-  public static final AccountGroup.UUID DEVS = new AccountGroup.UUID("test.devs");
-
-  public static final LabelType codeReview() {
-    return category(
-        "Code-Review",
-        value(2, "Looks good to me, approved"),
-        value(1, "Looks good to me, but someone else must approve"),
-        value(0, "No score"),
-        value(-1, "I would prefer this is not merged as is"),
-        value(-2, "This shall not be merged"));
-  }
-
-  public static final LabelType verified() {
-    return category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
-  }
-
-  public static final LabelType patchSetLock() {
-    LabelType label =
-        category("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
-    label.setFunction(LabelFunction.PATCH_SET_LOCK);
-    return label;
-  }
-
-  public static LabelValue value(int value, String text) {
-    return new LabelValue((short) value, text);
-  }
-
-  public static LabelType category(String name, LabelValue... values) {
-    return new LabelType(name, Arrays.asList(values));
-  }
-
-  public static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
-    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
-    group = project.resolve(group);
-
-    return new PermissionRule(group);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    return grant(project, permissionName, rule, ref);
-  }
-
-  public static PermissionRule allowExclusive(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    return grant(project, permissionName, rule, ref, true);
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    PermissionRule r = grant(project, permissionName, rule, ref);
-    r.setBlock();
-    return r;
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    return grant(project, permissionName, newRule(project, group), ref);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String permissionName,
-      AccountGroup.UUID group,
-      String ref,
-      boolean exclusive) {
-    return grant(project, permissionName, newRule(project, group), ref, exclusive);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .add(rule);
-    if (GlobalCapability.hasRange(capabilityName)) {
-      PermissionRange.WithDefaults range = GlobalCapability.getRange(capabilityName);
-      if (range != null) {
-        rule.setRange(range.getDefaultMin(), range.getDefaultMax());
-      }
-    }
-    return rule;
-  }
-
-  public static PermissionRule remove(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .remove(rule);
-    return rule;
-  }
-
-  public static PermissionRule remove(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule rule = newRule(project, group);
-    project.getAccessSection(ref, true).getPermission(permissionName, true).remove(rule);
-    return rule;
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .add(rule);
-    return rule;
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
-    r.setBlock();
-    return r;
-  }
-
-  public static PermissionRule blockLabel(
-      ProjectConfig project, String labelName, AccountGroup.UUID group, String ref) {
-    return blockLabel(project, labelName, -1, 1, group, ref);
-  }
-
-  public static PermissionRule blockLabel(
-      ProjectConfig project,
-      String labelName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule r = grant(project, Permission.LABEL + labelName, newRule(project, group), ref);
-    r.setBlock();
-    r.setRange(min, max);
-    return r;
-  }
-
-  public static PermissionRule deny(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
-    r.setDeny();
-    return r;
-  }
-
-  public static void doNotInherit(ProjectConfig project, String permissionName, String ref) {
-    project
-        .getAccessSection(ref, true) //
-        .getPermission(permissionName, true) //
-        .setExclusiveGroup(true);
-  }
-
-  private static PermissionRule grant(
-      ProjectConfig project, String permissionName, PermissionRule rule, String ref) {
-    return grant(project, permissionName, rule, ref, false);
-  }
-
-  private static PermissionRule grant(
-      ProjectConfig project,
-      String permissionName,
-      PermissionRule rule,
-      String ref,
-      boolean exclusive) {
-    Permission permission = project.getAccessSection(ref, true).getPermission(permissionName, true);
-    if (exclusive) {
-      permission.setExclusiveGroup(exclusive);
-    }
-    permission.add(rule);
-    return rule;
-  }
-
-  private Util() {}
-}
diff --git a/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
index cc9fc0d..f4ff441 100644
--- a/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -14,13 +14,15 @@
 
 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;
 import com.google.gerrit.server.index.IndexUtils;
-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) {
@@ -29,8 +31,12 @@
   }
 
   @Override
-  public boolean match(AccountState accountState) throws OrmException {
-    return accountControl.canSee(accountState);
+  public boolean match(AccountState accountState) {
+    boolean canSee = accountControl.canSee(accountState);
+    if (!canSee) {
+      logger.atFine().log("Filter out non-visisble account: %s", accountState);
+    }
+    return canSee;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 57a0dcc..55b3eda 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import java.util.List;
 
 public class AccountPredicates {
@@ -45,7 +44,7 @@
     List<Predicate<AccountState>> preds = Lists.newArrayListWithCapacity(3);
     Integer id = Ints.tryParse(query);
     if (id != null) {
-      preds.add(id(new Account.Id(id)));
+      preds.add(id(Account.id(id)));
     }
     if (canSeeSecondaryEmails) {
       preds.add(equalsNameIncludingSecondaryEmails(query));
@@ -126,7 +125,7 @@
 
   public static Predicate<AccountState> cansee(
       AccountQueryBuilder.Arguments args, ChangeNotes changeNotes) {
-    return new CanSeeChangePredicate(args.db, args.permissionBackend, changeNotes);
+    return new CanSeeChangePredicate(args.permissionBackend, changeNotes);
   }
 
   static class AccountPredicate extends IndexPredicate<AccountState>
@@ -140,7 +139,7 @@
     }
 
     @Override
-    public boolean match(AccountState object) throws OrmException {
+    public boolean match(AccountState object) {
       return true;
     }
 
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index e7ffd5e..70f4a2d 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -1,5 +1,4 @@
 // Copyright (C) 2016 The Android Open Source Project
-//
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
 // You may obtain a copy of the License at
@@ -18,7 +17,9 @@
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.common.errors.NotSignedInException;
+import com.google.gerrit.exceptions.NotSignedInException;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.LimitPredicate;
@@ -26,11 +27,10 @@
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -38,13 +38,12 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 
 /** Parses a query string meant to be applied to account objects. */
-public class AccountQueryBuilder extends QueryBuilder<AccountState> {
+public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQueryBuilder> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static final String FIELD_ACCOUNT = "account";
@@ -61,7 +60,6 @@
       new QueryBuilder.Definition<>(AccountQueryBuilder.class);
 
   public static class Arguments {
-    final Provider<ReviewDb> db;
     final ChangeFinder changeFinder;
     final PermissionBackend permissionBackend;
 
@@ -72,12 +70,10 @@
     public Arguments(
         Provider<CurrentUser> self,
         AccountIndexCollection indexes,
-        Provider<ReviewDb> db,
         ChangeFinder changeFinder,
         PermissionBackend permissionBackend) {
       this.self = self;
       this.indexes = indexes;
-      this.db = db;
       this.changeFinder = changeFinder;
       this.permissionBackend = permissionBackend;
     }
@@ -112,20 +108,21 @@
 
   @Inject
   AccountQueryBuilder(Arguments args) {
-    super(mydef);
+    super(mydef, null);
     this.args = args;
   }
 
   @Operator
   public Predicate<AccountState> cansee(String change)
-      throws QueryParseException, OrmException, PermissionBackendException {
+      throws QueryParseException, PermissionBackendException {
     ChangeNotes changeNotes = args.changeFinder.findOne(change);
-    if (changeNotes == null
-        || !args.permissionBackend
-            .user(args.getUser())
-            .database(args.db)
-            .change(changeNotes)
-            .test(ChangePermission.READ)) {
+    if (changeNotes == null) {
+      throw error(String.format("change %s not found", change));
+    }
+
+    try {
+      args.permissionBackend.user(args.getUser()).change(changeNotes).check(ChangePermission.READ);
+    } catch (AuthException e) {
       throw error(String.format("change %s not found", change));
     }
 
@@ -198,7 +195,7 @@
     if (query.startsWith("cansee:")) {
       try {
         return cansee(query.substring(7));
-      } catch (OrmException | QueryParseException | PermissionBackendException e) {
+      } catch (StorageException | QueryParseException | PermissionBackendException e) {
         // Ignore, fall back to default query
       }
     }
@@ -218,7 +215,12 @@
   }
 
   private boolean canSeeSecondaryEmails() throws PermissionBackendException, QueryParseException {
-    return args.permissionBackend.user(args.getUser()).test(GlobalPermission.MODIFY_ACCOUNT);
+    try {
+      args.permissionBackend.user(args.getUser()).check(GlobalPermission.MODIFY_ACCOUNT);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 
   private boolean checkedCanSeeSecondaryEmails() {
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index a33118d..19d2215 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -74,4 +74,9 @@
     return new AndSource<>(
         pred, new AccountIsVisibleToPredicate(accountControlFactory.get()), start);
   }
+
+  @Override
+  protected String formatForLogging(AccountState accountState) {
+    return accountState.getAccount().id().toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
index b008092..c2d8de9 100644
--- a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
@@ -14,39 +14,37 @@
 
 package com.google.gerrit.server.query.account;
 
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
 
 public class CanSeeChangePredicate extends PostFilterPredicate<AccountState> {
-  private final Provider<ReviewDb> db;
   private final PermissionBackend permissionBackend;
   private final ChangeNotes changeNotes;
 
-  CanSeeChangePredicate(
-      Provider<ReviewDb> db, PermissionBackend permissionBackend, ChangeNotes changeNotes) {
+  CanSeeChangePredicate(PermissionBackend permissionBackend, ChangeNotes changeNotes) {
     super(AccountQueryBuilder.FIELD_CAN_SEE, changeNotes.getChangeId().toString());
-    this.db = db;
     this.permissionBackend = permissionBackend;
     this.changeNotes = changeNotes;
   }
 
   @Override
-  public boolean match(AccountState accountState) throws OrmException {
+  public boolean match(AccountState accountState) {
     try {
-      return permissionBackend
-          .absentUser(accountState.getAccount().getId())
-          .database(db)
+      permissionBackend
+          .absentUser(accountState.getAccount().id())
           .change(changeNotes)
-          .test(ChangePermission.READ);
+          .check(ChangePermission.READ);
+      return true;
     } catch (PermissionBackendException e) {
-      throw new OrmException("Failed to check if account can see change", e);
+      throw new StorageException("Failed to check if account can see change", e);
+    } catch (AuthException e) {
+      return false;
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index d0840d6..0253ede 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import java.util.List;
@@ -42,7 +41,7 @@
  * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
  * holding on to a single instance.
  */
-public class InternalAccountQuery extends InternalQuery<AccountState> {
+public class InternalAccountQuery extends InternalQuery<AccountState, InternalAccountQuery> {
   @Inject
   InternalAccountQuery(
       AccountQueryProcessor queryProcessor,
@@ -51,44 +50,19 @@
     super(queryProcessor, indexes, indexConfig);
   }
 
-  @Override
-  public InternalAccountQuery setLimit(int n) {
-    super.setLimit(n);
-    return this;
-  }
-
-  @Override
-  public InternalAccountQuery enforceVisibility(boolean enforce) {
-    super.enforceVisibility(enforce);
-    return this;
-  }
-
-  @SafeVarargs
-  @Override
-  public final InternalAccountQuery setRequestedFields(FieldDef<AccountState, ?>... fields) {
-    super.setRequestedFields(fields);
-    return this;
-  }
-
-  @Override
-  public InternalAccountQuery noFields() {
-    super.noFields();
-    return this;
-  }
-
-  public List<AccountState> byDefault(String query) throws OrmException {
+  public List<AccountState> byDefault(String query) {
     return query(AccountPredicates.defaultPredicate(schema(), true, query));
   }
 
-  public List<AccountState> byExternalId(String scheme, String id) throws OrmException {
+  public List<AccountState> byExternalId(String scheme, String id) {
     return byExternalId(ExternalId.Key.create(scheme, id));
   }
 
-  public List<AccountState> byExternalId(ExternalId.Key externalId) throws OrmException {
+  public List<AccountState> byExternalId(ExternalId.Key externalId) {
     return query(AccountPredicates.externalIdIncludingSecondaryEmails(externalId.toString()));
   }
 
-  public List<AccountState> byFullName(String fullName) throws OrmException {
+  public List<AccountState> byFullName(String fullName) {
     return query(AccountPredicates.fullName(fullName));
   }
 
@@ -97,9 +71,8 @@
    *
    * @param email preferred email by which accounts should be found
    * @return list of accounts that have a preferred email that exactly matches the given email
-   * @throws OrmException if query cannot be parsed
    */
-  public List<AccountState> byPreferredEmail(String email) throws OrmException {
+  public List<AccountState> byPreferredEmail(String email) {
     if (hasPreferredEmailExact()) {
       return query(AccountPredicates.preferredEmailExact(email));
     }
@@ -108,9 +81,8 @@
       return ImmutableList.of();
     }
 
-    return query(AccountPredicates.preferredEmail(email))
-        .stream()
-        .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+    return query(AccountPredicates.preferredEmail(email)).stream()
+        .filter(a -> a.getAccount().preferredEmail().equals(email))
         .collect(toList());
   }
 
@@ -120,9 +92,8 @@
    * @param emails preferred emails by which accounts should be found
    * @return multimap of the given emails to accounts that have a preferred email that exactly
    *     matches this email
-   * @throws OrmException if query cannot be parsed
    */
-  public Multimap<String, AccountState> byPreferredEmail(String... emails) throws OrmException {
+  public Multimap<String, AccountState> byPreferredEmail(String... emails) {
     List<String> emailList = Arrays.asList(emails);
 
     if (hasPreferredEmailExact()) {
@@ -145,16 +116,15 @@
     for (int i = 0; i < emailList.size(); i++) {
       String email = emailList.get(i);
       Set<AccountState> matchingAccounts =
-          r.get(i)
-              .stream()
-              .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+          r.get(i).stream()
+              .filter(a -> a.getAccount().preferredEmail().equals(email))
               .collect(toSet());
       accountsByEmail.putAll(email, matchingAccounts);
     }
     return accountsByEmail;
   }
 
-  public List<AccountState> byWatchedProject(Project.NameKey project) throws OrmException {
+  public List<AccountState> byWatchedProject(Project.NameKey project) {
     return query(AccountPredicates.watchedProject(project));
   }
 
diff --git a/java/com/google/gerrit/server/query/change/AddedPredicate.java b/java/com/google/gerrit/server/query/change/AddedPredicate.java
index 099e841..1f526c5 100644
--- a/java/com/google/gerrit/server/query/change/AddedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AddedPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class AddedPredicate extends IntegerRangeChangePredicate {
   public AddedPredicate(String value) throws QueryParseException {
@@ -24,7 +23,7 @@
   }
 
   @Override
-  protected Integer getValueInt(ChangeData changeData) throws OrmException {
+  protected Integer getValueInt(ChangeData changeData) {
     return ChangeField.ADDED.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index de57b3b..df5a71d 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 import java.util.Date;
 
 public class AfterPredicate extends TimestampRangeChangePredicate {
@@ -38,7 +37,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     return cd.change().getLastUpdatedOn().getTime() >= cut.getTime();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index 6310665..1cf2c2f 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -17,11 +17,10 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.sql.Timestamp;
 
 public class AgePredicate extends TimestampRangeChangePredicate {
@@ -46,7 +45,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     return change != null && change.getLastUpdatedOn().getTime() <= cut;
   }
diff --git a/java/com/google/gerrit/server/query/change/AndChangeSource.java b/java/com/google/gerrit/server/query/change/AndChangeSource.java
index ff1ab23..4a3b936 100644
--- a/java/com/google/gerrit/server/query/change/AndChangeSource.java
+++ b/java/com/google/gerrit/server/query/change/AndChangeSource.java
@@ -17,8 +17,6 @@
 import com.google.gerrit.index.query.AndSource;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
 import java.util.Collection;
 import java.util.List;
 
@@ -43,13 +41,9 @@
   }
 
   @Override
-  protected List<ChangeData> transformBuffer(List<ChangeData> buffer) throws OrmRuntimeException {
+  protected List<ChangeData> transformBuffer(List<ChangeData> buffer) {
     if (!hasChange()) {
-      try {
-        ChangeData.ensureChangeLoaded(buffer);
-      } catch (OrmException e) {
-        throw new OrmRuntimeException(e);
-      }
+      ChangeData.ensureChangeLoaded(buffer);
     }
     return super.transformBuffer(buffer);
   }
diff --git a/java/com/google/gerrit/server/query/change/AssigneePredicate.java b/java/com/google/gerrit/server/query/change/AssigneePredicate.java
index 63f7467..fb19e85 100644
--- a/java/com/google/gerrit/server/query/change/AssigneePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AssigneePredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class AssigneePredicate extends ChangeIndexPredicate {
   protected final Account.Id id;
@@ -27,7 +26,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     if (id.get() == ChangeField.NO_ASSIGNEE) {
       Account.Id assignee = object.change().getAssignee();
       return assignee == null;
diff --git a/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/java/com/google/gerrit/server/query/change/AuthorPredicate.java
index 3ee3352..79914a3 100644
--- a/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AuthorPredicate.java
@@ -18,8 +18,6 @@
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_AUTHOR;
 
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
 
 public class AuthorPredicate extends ChangeIndexPredicate {
   public AuthorPredicate(String value) {
@@ -27,12 +25,8 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
-    try {
-      return ChangeField.getAuthorParts(object).contains(getValue().toLowerCase());
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
+  public boolean match(ChangeData object) {
+    return ChangeField.getAuthorParts(object).contains(getValue().toLowerCase());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 4d6ed69..dacabc0 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 import java.util.Date;
 
 public class BeforePredicate extends TimestampRangeChangePredicate {
@@ -38,7 +37,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     return cd.change().getLastUpdatedOn().getTime() <= cut.getTime();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/BooleanPredicate.java b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index 5930b74..68f83e8 100644
--- a/java/com/google/gerrit/server/query/change/BooleanPredicate.java
+++ b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.index.FieldDef;
-import com.google.gwtorm.server.OrmException;
 
 public class BooleanPredicate extends ChangeIndexPredicate {
   public BooleanPredicate(FieldDef<ChangeData, String> field) {
@@ -23,7 +22,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     return getValue().equals(getField().get(object));
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 0a2f219..59cbf32 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.ApprovalsUtil.sortApprovals;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -30,10 +30,12 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -46,12 +48,10 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -65,7 +65,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -76,8 +75,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -93,9 +91,6 @@
 import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Stream;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -105,9 +100,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class ChangeData {
-  private static final int BATCH_SIZE = 50;
-
-  public static List<Change> asChanges(List<ChangeData> changeDatas) throws OrmException {
+  public static List<Change> asChanges(List<ChangeData> changeDatas) {
     List<Change> result = new ArrayList<>(changeDatas.size());
     for (ChangeData cd : changeDatas) {
       result.add(cd.change());
@@ -119,154 +112,65 @@
     return changes.stream().collect(toMap(ChangeData::getId, Function.identity()));
   }
 
-  public static void ensureChangeLoaded(Iterable<ChangeData> changes) throws OrmException {
+  public static void ensureChangeLoaded(Iterable<ChangeData> changes) {
     ChangeData first = Iterables.getFirst(changes, null);
     if (first == null) {
       return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.change();
-      }
-      return;
     }
 
-    Map<Change.Id, ChangeData> missing = new HashMap<>();
     for (ChangeData cd : changes) {
-      if (cd.change == null) {
-        missing.put(cd.getId(), cd);
-      }
-    }
-    if (missing.isEmpty()) {
-      return;
-    }
-    for (ChangeNotes notes : first.notesFactory.create(first.db, missing.keySet())) {
-      missing.get(notes.getChangeId()).change = notes.getChange();
+      cd.change();
     }
   }
 
-  public static void ensureAllPatchSetsLoaded(Iterable<ChangeData> changes) throws OrmException {
+  public static void ensureAllPatchSetsLoaded(Iterable<ChangeData> changes) {
     ChangeData first = Iterables.getFirst(changes, null);
     if (first == null) {
       return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.patchSets();
-      }
-      return;
     }
 
-    List<ResultSet<PatchSet>> results = new ArrayList<>(BATCH_SIZE);
-    for (List<ChangeData> batch : Iterables.partition(changes, BATCH_SIZE)) {
-      results.clear();
-      for (ChangeData cd : batch) {
-        if (cd.patchSets == null) {
-          results.add(cd.db.patchSets().byChange(cd.getId()));
-        } else {
-          results.add(null);
-        }
-      }
-      for (int i = 0; i < batch.size(); i++) {
-        ResultSet<PatchSet> result = results.get(i);
-        if (result != null) {
-          batch.get(i).patchSets = result.toList();
-        }
-      }
-    }
-  }
-
-  public static void ensureCurrentPatchSetLoaded(Iterable<ChangeData> changes) throws OrmException {
-    ChangeData first = Iterables.getFirst(changes, null);
-    if (first == null) {
-      return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.currentPatchSet();
-      }
-      return;
-    }
-
-    Map<PatchSet.Id, ChangeData> missing = new HashMap<>();
     for (ChangeData cd : changes) {
-      if (cd.currentPatchSet == null && cd.patchSets == null) {
-        missing.put(cd.change().currentPatchSetId(), cd);
-      }
-    }
-    if (missing.isEmpty()) {
-      return;
-    }
-    for (PatchSet ps : first.db.patchSets().get(missing.keySet())) {
-      missing.get(ps.getId()).currentPatchSet = ps;
+      cd.patchSets();
     }
   }
 
-  public static void ensureCurrentApprovalsLoaded(Iterable<ChangeData> changes)
-      throws OrmException {
+  public static void ensureCurrentPatchSetLoaded(Iterable<ChangeData> changes) {
     ChangeData first = Iterables.getFirst(changes, null);
     if (first == null) {
       return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.currentApprovals();
-      }
-      return;
     }
 
-    List<ResultSet<PatchSetApproval>> results = new ArrayList<>(BATCH_SIZE);
-    for (List<ChangeData> batch : Iterables.partition(changes, BATCH_SIZE)) {
-      results.clear();
-      for (ChangeData cd : batch) {
-        if (cd.currentApprovals == null) {
-          PatchSet.Id psId = cd.change().currentPatchSetId();
-          results.add(cd.db.patchSetApprovals().byPatchSet(psId));
-        } else {
-          results.add(null);
-        }
-      }
-      for (int i = 0; i < batch.size(); i++) {
-        ResultSet<PatchSetApproval> result = results.get(i);
-        if (result != null) {
-          batch.get(i).currentApprovals = sortApprovals(result);
-        }
-      }
+    for (ChangeData cd : changes) {
+      cd.currentPatchSet();
     }
   }
 
-  public static void ensureMessagesLoaded(Iterable<ChangeData> changes) throws OrmException {
+  public static void ensureCurrentApprovalsLoaded(Iterable<ChangeData> changes) {
     ChangeData first = Iterables.getFirst(changes, null);
     if (first == null) {
       return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.messages();
-      }
-      return;
     }
 
-    List<ResultSet<ChangeMessage>> results = new ArrayList<>(BATCH_SIZE);
-    for (List<ChangeData> batch : Iterables.partition(changes, BATCH_SIZE)) {
-      results.clear();
-      for (ChangeData cd : batch) {
-        if (cd.messages == null) {
-          PatchSet.Id psId = cd.change().currentPatchSetId();
-          results.add(cd.db.changeMessages().byPatchSet(psId));
-        } else {
-          results.add(null);
-        }
-      }
-      for (int i = 0; i < batch.size(); i++) {
-        ResultSet<ChangeMessage> result = results.get(i);
-        if (result != null) {
-          batch.get(i).messages = result.toList();
-        }
-      }
+    for (ChangeData cd : changes) {
+      cd.currentApprovals();
     }
   }
 
-  public static void ensureReviewedByLoadedForOpenChanges(Iterable<ChangeData> changes)
-      throws OrmException {
+  public static void ensureMessagesLoaded(Iterable<ChangeData> changes) {
+    ChangeData first = Iterables.getFirst(changes, null);
+    if (first == null) {
+      return;
+    }
+
+    for (ChangeData cd : changes) {
+      cd.messages();
+    }
+  }
+
+  public static void ensureReviewedByLoadedForOpenChanges(Iterable<ChangeData> changes) {
     List<ChangeData> pending = new ArrayList<>();
     for (ChangeData cd : changes) {
-      if (cd.reviewedBy == null && cd.change().getStatus().isOpen()) {
+      if (cd.reviewedBy == null && cd.change().isNew()) {
         pending.add(cd);
       }
     }
@@ -288,23 +192,22 @@
       this.assistedFactory = assistedFactory;
     }
 
-    public ChangeData create(ReviewDb db, Project.NameKey project, Change.Id id) {
-      return assistedFactory.create(db, project, id, null, null);
+    public ChangeData create(Project.NameKey project, Change.Id id) {
+      return assistedFactory.create(project, id, null, null);
     }
 
-    public ChangeData create(ReviewDb db, Change change) {
-      return assistedFactory.create(db, change.getProject(), change.getId(), change, null);
+    public ChangeData create(Change change) {
+      return assistedFactory.create(change.getProject(), change.getId(), change, null);
     }
 
-    public ChangeData create(ReviewDb db, ChangeNotes notes) {
+    public ChangeData create(ChangeNotes notes) {
       return assistedFactory.create(
-          db, notes.getChange().getProject(), notes.getChangeId(), notes.getChange(), notes);
+          notes.getChange().getProject(), notes.getChangeId(), notes.getChange(), notes);
     }
   }
 
   public interface AssistedFactory {
     ChangeData create(
-        ReviewDb db,
         Project.NameKey project,
         Change.Id id,
         @Nullable Change change,
@@ -321,12 +224,18 @@
    * @return instance for testing.
    */
   public static ChangeData createForTest(
-      Project.NameKey project, Change.Id id, int currentPatchSetId) {
+      Project.NameKey project, Change.Id id, int currentPatchSetId, ObjectId commitId) {
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, project, id, null, null);
-    cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
+            null, project, id, null, null);
+    cd.currentPatchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(id, currentPatchSetId))
+            .commitId(commitId)
+            .uploader(Account.id(1000))
+            .createdOn(TimeUtil.nowTs())
+            .build();
     return cd;
   }
 
@@ -338,10 +247,8 @@
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
   private final GitRepositoryManager repoManager;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final MergeUtil.Factory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
-  private final NotesMigration notesMigration;
   private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
   private final ProjectCache projectCache;
@@ -350,7 +257,6 @@
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   // Required assisted injected fields.
-  private final ReviewDb db;
   private final Project.NameKey project;
   private final Change.Id legacyId;
 
@@ -393,6 +299,7 @@
   private PersonIdent committer;
   private int parentCount;
   private Integer unresolvedCommentCount;
+  private Integer totalCommentCount;
   private LabelTypes labelTypes;
 
   private ImmutableList<byte[]> refStates;
@@ -407,17 +314,14 @@
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
       GitRepositoryManager repoManager,
-      IdentifiedUser.GenericFactory userFactory,
       MergeUtil.Factory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
-      NotesMigration notesMigration,
       PatchListCache patchListCache,
       PatchSetUtil psUtil,
       ProjectCache projectCache,
       TrackingFooters trackingFooters,
       PureRevert pureRevert,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
-      @Assisted ReviewDb db,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
       @Assisted @Nullable Change change,
@@ -428,10 +332,8 @@
     this.notesFactory = notesFactory;
     this.commentsUtil = commentsUtil;
     this.repoManager = repoManager;
-    this.userFactory = userFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.mergeabilityCache = mergeabilityCache;
-    this.notesMigration = notesMigration;
     this.patchListCache = patchListCache;
     this.psUtil = psUtil;
     this.projectCache = projectCache;
@@ -440,11 +342,6 @@
     this.pureRevert = pureRevert;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
 
-    // May be null in tests when created via createForTest above, in which case lazy-loading will
-    // intentionally fail with NPE. Still not marked @Nullable in the constructor, to force callers
-    // using Guice to pass a non-null value.
-    this.db = db;
-
     this.project = project;
     this.legacyId = id;
 
@@ -464,22 +361,19 @@
     return this;
   }
 
-  public ReviewDb db() {
-    return db;
-  }
-
   public AllUsersName getAllUsersNameForIndexing() {
     return allUsersName;
   }
 
-  public void setCurrentFilePaths(List<String> filePaths) throws OrmException {
+  @VisibleForTesting
+  public void setCurrentFilePaths(List<String> filePaths) {
     PatchSet ps = currentPatchSet();
     if (ps != null) {
       currentFiles = ImmutableList.copyOf(filePaths);
     }
   }
 
-  public List<String> currentFilePaths() throws IOException, OrmException {
+  public List<String> currentFilePaths() {
     if (currentFiles == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
@@ -490,7 +384,7 @@
     return currentFiles;
   }
 
-  private Optional<DiffSummary> getDiffSummary() throws OrmException, IOException {
+  private Optional<DiffSummary> getDiffSummary() {
     if (diffSummary == null) {
       if (!lazyLoad) {
         return Optional.empty();
@@ -502,7 +396,7 @@
         return Optional.empty();
       }
 
-      ObjectId id = ObjectId.fromString(ps.getRevision().get());
+      ObjectId id = ps.commitId();
       Whitespace ws = Whitespace.IGNORE_NONE;
       PatchListKey pk =
           parentCount > 1
@@ -518,7 +412,7 @@
     return diffSummary;
   }
 
-  private Optional<ChangedLines> computeChangedLines() throws OrmException, IOException {
+  private Optional<ChangedLines> computeChangedLines() {
     Optional<DiffSummary> ds = getDiffSummary();
     if (ds.isPresent()) {
       return Optional.of(ds.get().getChangedLines());
@@ -526,7 +420,7 @@
     return Optional.empty();
   }
 
-  public Optional<ChangedLines> changedLines() throws OrmException, IOException {
+  public Optional<ChangedLines> changedLines() {
     if (changedLines == null) {
       if (!lazyLoad) {
         return Optional.empty();
@@ -560,7 +454,7 @@
     visibleTo = user;
   }
 
-  public Change change() throws OrmException {
+  public Change change() {
     if (change == null && lazyLoad) {
       reloadChange();
     }
@@ -571,48 +465,48 @@
     change = c;
   }
 
-  public Change reloadChange() throws OrmException {
+  public Change reloadChange() {
     try {
-      notes = notesFactory.createChecked(db, project, legacyId);
+      notes = notesFactory.createChecked(project, legacyId);
     } catch (NoSuchChangeException e) {
-      throw new OrmException("Unable to load change " + legacyId, e);
+      throw new StorageException("Unable to load change " + legacyId, e);
     }
     change = notes.getChange();
     setPatchSets(null);
     return change;
   }
 
-  public LabelTypes getLabelTypes() throws OrmException {
+  public LabelTypes getLabelTypes() {
     if (labelTypes == null) {
       ProjectState state;
       try {
         state = projectCache.checkedGet(project());
       } catch (IOException e) {
-        throw new OrmException("project state not available", e);
+        throw new StorageException("project state not available", e);
       }
-      labelTypes = state.getLabelTypes(change().getDest(), userFactory.create(change().getOwner()));
+      labelTypes = state.getLabelTypes(change().getDest());
     }
     return labelTypes;
   }
 
-  public ChangeNotes notes() throws OrmException {
+  public ChangeNotes notes() {
     if (notes == null) {
       if (!lazyLoad) {
-        throw new OrmException("ChangeNotes not available, lazyLoad = false");
+        throw new StorageException("ChangeNotes not available, lazyLoad = false");
       }
-      notes = notesFactory.create(db, project(), legacyId);
+      notes = notesFactory.create(project(), legacyId);
     }
     return notes;
   }
 
-  public PatchSet currentPatchSet() throws OrmException {
+  public PatchSet currentPatchSet() {
     if (currentPatchSet == null) {
       Change c = change();
       if (c == null) {
         return null;
       }
       for (PatchSet p : patchSets()) {
-        if (p.getId().equals(c.currentPatchSetId())) {
+        if (p.id().equals(c.currentPatchSetId())) {
           currentPatchSet = p;
           return p;
         }
@@ -621,7 +515,7 @@
     return currentPatchSet;
   }
 
-  public List<PatchSetApproval> currentApprovals() throws OrmException {
+  public List<PatchSetApproval> currentApprovals() {
     if (currentApprovals == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
@@ -633,14 +527,8 @@
         try {
           currentApprovals =
               ImmutableList.copyOf(
-                  approvalsUtil.byPatchSet(
-                      db,
-                      notes(),
-                      userFactory.create(c.getOwner()),
-                      c.currentPatchSetId(),
-                      null,
-                      null));
-        } catch (OrmException e) {
+                  approvalsUtil.byPatchSet(notes(), c.currentPatchSetId(), null, null));
+        } catch (StorageException e) {
           if (e.getCause() instanceof NoSuchChangeException) {
             currentApprovals = Collections.emptyList();
           } else {
@@ -656,7 +544,7 @@
     currentApprovals = approvals;
   }
 
-  public String commitMessage() throws IOException, OrmException {
+  public String commitMessage() {
     if (commitMessage == null) {
       if (!loadCommitData()) {
         return null;
@@ -665,7 +553,7 @@
     return commitMessage;
   }
 
-  public List<FooterLine> commitFooters() throws IOException, OrmException {
+  public List<FooterLine> commitFooters() {
     if (commitFooters == null) {
       if (!loadCommitData()) {
         return null;
@@ -674,11 +562,11 @@
     return commitFooters;
   }
 
-  public ListMultimap<String, String> trackingFooters() throws IOException, OrmException {
+  public ListMultimap<String, String> trackingFooters() {
     return trackingFooters.extract(commitFooters());
   }
 
-  public PersonIdent getAuthor() throws IOException, OrmException {
+  public PersonIdent getAuthor() {
     if (author == null) {
       if (!loadCommitData()) {
         return null;
@@ -687,7 +575,7 @@
     return author;
   }
 
-  public PersonIdent getCommitter() throws IOException, OrmException {
+  public PersonIdent getCommitter() {
     if (committer == null) {
       if (!loadCommitData()) {
         return null;
@@ -696,33 +584,29 @@
     return committer;
   }
 
-  private boolean loadCommitData()
-      throws OrmException, RepositoryNotFoundException, IOException, MissingObjectException,
-          IncorrectObjectTypeException {
+  private boolean loadCommitData() {
     PatchSet ps = currentPatchSet();
     if (ps == null) {
       return false;
     }
-    String sha1 = ps.getRevision().get();
     try (Repository repo = repoManager.openRepository(project());
         RevWalk walk = new RevWalk(repo)) {
-      RevCommit c = walk.parseCommit(ObjectId.fromString(sha1));
+      RevCommit c = walk.parseCommit(ps.commitId());
       commitMessage = c.getFullMessage();
       commitFooters = c.getFooterLines();
       author = c.getAuthorIdent();
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
+    } catch (IOException e) {
+      throw new StorageException(e);
     }
     return true;
   }
 
-  /**
-   * @return patches for the change, in patch set ID order.
-   * @throws OrmException an error occurred reading the database.
-   */
-  public Collection<PatchSet> patchSets() throws OrmException {
+  /** @return patches for the change, in patch set ID order. */
+  public Collection<PatchSet> patchSets() {
     if (patchSets == null) {
-      patchSets = psUtil.byChange(db, notes());
+      patchSets = psUtil.byChange(notes());
     }
     return patchSets;
   }
@@ -732,16 +616,13 @@
     this.patchSets = patchSets;
   }
 
-  /**
-   * @return patch with the given ID, or null if it does not exist.
-   * @throws OrmException an error occurred reading the database.
-   */
-  public PatchSet patchSet(PatchSet.Id psId) throws OrmException {
-    if (currentPatchSet != null && currentPatchSet.getId().equals(psId)) {
+  /** @return patch with the given ID, or null if it does not exist. */
+  public PatchSet patchSet(PatchSet.Id psId) {
+    if (currentPatchSet != null && currentPatchSet.id().equals(psId)) {
       return currentPatchSet;
     }
     for (PatchSet ps : patchSets()) {
-      if (ps.getId().equals(psId)) {
+      if (ps.id().equals(psId)) {
         return ps;
       }
     }
@@ -751,27 +632,23 @@
   /**
    * @return all patch set approvals for the change, keyed by ID, ordered by timestamp within each
    *     patch set.
-   * @throws OrmException an error occurred reading the database.
    */
-  public ListMultimap<PatchSet.Id, PatchSetApproval> approvals() throws OrmException {
+  public ListMultimap<PatchSet.Id, PatchSetApproval> approvals() {
     if (allApprovals == null) {
       if (!lazyLoad) {
         return ImmutableListMultimap.of();
       }
-      allApprovals = approvalsUtil.byChange(db, notes());
+      allApprovals = approvalsUtil.byChange(notes());
     }
     return allApprovals;
   }
 
-  /**
-   * @return The submit ('SUBM') approval label
-   * @throws OrmException an error occurred reading the database.
-   */
-  public Optional<PatchSetApproval> getSubmitApproval() throws OrmException {
+  /** @return The submit ('SUBM') approval label */
+  public Optional<PatchSetApproval> getSubmitApproval() {
     return currentApprovals().stream().filter(PatchSetApproval::isLegacySubmit).findFirst();
   }
 
-  public ReviewerSet reviewers() throws OrmException {
+  public ReviewerSet reviewers() {
     if (reviewers == null) {
       if (!lazyLoad) {
         return ReviewerSet.empty();
@@ -789,7 +666,7 @@
     return reviewers;
   }
 
-  public ReviewerByEmailSet reviewersByEmail() throws OrmException {
+  public ReviewerByEmailSet reviewersByEmail() {
     if (reviewersByEmail == null) {
       if (!lazyLoad) {
         return ReviewerByEmailSet.empty();
@@ -815,7 +692,7 @@
     return this.pendingReviewers;
   }
 
-  public ReviewerSet pendingReviewers() throws OrmException {
+  public ReviewerSet pendingReviewers() {
     if (pendingReviewers == null) {
       if (!lazyLoad) {
         return ReviewerSet.empty();
@@ -833,7 +710,7 @@
     return pendingReviewersByEmail;
   }
 
-  public ReviewerByEmailSet pendingReviewersByEmail() throws OrmException {
+  public ReviewerByEmailSet pendingReviewersByEmail() {
     if (pendingReviewersByEmail == null) {
       if (!lazyLoad) {
         return ReviewerByEmailSet.empty();
@@ -843,7 +720,7 @@
     return pendingReviewersByEmail;
   }
 
-  public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
+  public List<ReviewerStatusUpdate> reviewerUpdates() {
     if (reviewerUpdates == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
@@ -861,17 +738,17 @@
     return reviewerUpdates;
   }
 
-  public Collection<Comment> publishedComments() throws OrmException {
+  public Collection<Comment> publishedComments() {
     if (publishedComments == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
       }
-      publishedComments = commentsUtil.publishedByChange(db, notes());
+      publishedComments = commentsUtil.publishedByChange(notes());
     }
     return publishedComments;
   }
 
-  public Collection<RobotComment> robotComments() throws OrmException {
+  public Collection<RobotComment> robotComments() {
     if (robotComments == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
@@ -881,7 +758,7 @@
     return robotComments;
   }
 
-  public Integer unresolvedCommentCount() throws OrmException {
+  public Integer unresolvedCommentCount() {
     if (unresolvedCommentCount == null) {
       if (!lazyLoad) {
         return null;
@@ -935,18 +812,35 @@
     this.unresolvedCommentCount = count;
   }
 
-  public List<ChangeMessage> messages() throws OrmException {
+  public Integer totalCommentCount() {
+    if (totalCommentCount == null) {
+      if (!lazyLoad) {
+        return null;
+      }
+
+      // Fail on overflow.
+      totalCommentCount =
+          Ints.checkedCast((long) publishedComments().size() + robotComments().size());
+    }
+    return totalCommentCount;
+  }
+
+  public void setTotalCommentCount(Integer count) {
+    this.totalCommentCount = count;
+  }
+
+  public List<ChangeMessage> messages() {
     if (messages == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
       }
-      messages = cmUtil.byChange(db, notes());
+      messages = cmUtil.byChange(notes());
     }
     return messages;
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
-    List<SubmitRecord> records = submitRecords.get(options);
+    List<SubmitRecord> records = getCachedSubmitRecord(options);
     if (records == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
@@ -959,7 +853,21 @@
 
   @Nullable
   public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
-    return submitRecords.get(options);
+    return getCachedSubmitRecord(options);
+  }
+
+  private List<SubmitRecord> getCachedSubmitRecord(SubmitRuleOptions options) {
+    List<SubmitRecord> records = submitRecords.get(options);
+    if (records != null) {
+      return records;
+    }
+
+    if (options.allowClosed() && change != null && change.getStatus().isOpen()) {
+      SubmitRuleOptions openSubmitRuleOptions = options.toBuilder().allowClosed(false).build();
+      return submitRecords.get(openSubmitRuleOptions);
+    }
+
+    return null;
   }
 
   public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
@@ -979,15 +887,15 @@
   }
 
   @Nullable
-  public Boolean isMergeable() throws OrmException {
+  public Boolean isMergeable() {
     if (mergeable == null) {
       Change c = change();
       if (c == null) {
         return null;
       }
-      if (c.getStatus() == Change.Status.MERGED) {
+      if (c.isMerged()) {
         mergeable = true;
-      } else if (c.getStatus() == Change.Status.ABANDONED) {
+      } else if (c.isAbandoned()) {
         return null;
       } else if (c.isWorkInProgress()) {
         return null;
@@ -1001,7 +909,7 @@
         }
 
         try (Repository repo = repoManager.openRepository(project())) {
-          Ref ref = repo.getRefDatabase().exactRef(c.getDest().get());
+          Ref ref = repo.getRefDatabase().exactRef(c.getDest().branch());
           SubmitTypeRecord str = submitTypeRecord();
           if (!str.isOk()) {
             // If submit type rules are broken, it's definitely not mergeable.
@@ -1011,26 +919,20 @@
           String mergeStrategy =
               mergeUtilFactory.create(projectCache.get(project())).mergeStrategyName();
           mergeable =
-              mergeabilityCache.get(
-                  ObjectId.fromString(ps.getRevision().get()),
-                  ref,
-                  str.type,
-                  mergeStrategy,
-                  c.getDest(),
-                  repo);
+              mergeabilityCache.get(ps.commitId(), ref, str.type, mergeStrategy, c.getDest(), repo);
         } catch (IOException e) {
-          throw new OrmException(e);
+          throw new StorageException(e);
         }
       }
     }
     return mergeable;
   }
 
-  public Set<Account.Id> editsByUser() throws OrmException {
+  public Set<Account.Id> editsByUser() {
     return editRefs().keySet();
   }
 
-  public Map<Account.Id, Ref> editRefs() throws OrmException {
+  public Map<Account.Id, Ref> editRefs() {
     if (editsByUser == null) {
       if (!lazyLoad) {
         return Collections.emptyMap();
@@ -1040,29 +942,29 @@
         return Collections.emptyMap();
       }
       editsByUser = new HashMap<>();
-      Change.Id id = checkNotNull(change.getId());
+      Change.Id id = requireNonNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
-        for (Map.Entry<String, Ref> e :
-            repo.getRefDatabase().getRefs(RefNames.REFS_USERS).entrySet()) {
-          if (id.equals(Change.Id.fromEditRefPart(e.getKey()))) {
-            Account.Id accountId = Account.Id.fromRefPart(e.getKey());
+        for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS)) {
+          String name = ref.getName().substring(RefNames.REFS_USERS.length());
+          if (id.equals(Change.Id.fromEditRefPart(name))) {
+            Account.Id accountId = Account.Id.fromRefPart(name);
             if (accountId != null) {
-              editsByUser.put(accountId, e.getValue());
+              editsByUser.put(accountId, ref);
             }
           }
         }
       } catch (IOException e) {
-        throw new OrmException(e);
+        throw new StorageException(e);
       }
     }
     return editsByUser;
   }
 
-  public Set<Account.Id> draftsByUser() throws OrmException {
+  public Set<Account.Id> draftsByUser() {
     return draftRefs().keySet();
   }
 
-  public Map<Account.Id, Ref> draftRefs() throws OrmException {
+  public Map<Account.Id, Ref> draftRefs() {
     if (draftsByUser == null) {
       if (!lazyLoad) {
         return Collections.emptyMap();
@@ -1073,46 +975,41 @@
       }
 
       draftsByUser = new HashMap<>();
-      if (notesMigration.readChanges()) {
-        for (Ref ref : commentsUtil.getDraftRefs(notes.getChangeId())) {
-          Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-          if (account != null
-              // Double-check that any drafts exist for this user after
-              // filtering out zombies. If some but not all drafts in the ref
-              // were zombies, the returned Ref still includes those zombies;
-              // this is suboptimal, but is ok for the purposes of
-              // draftsByUser(), and easier than trying to rebuild the change at
-              // this point.
-              && !notes().getDraftComments(account, ref).isEmpty()) {
-            draftsByUser.put(account, ref);
-          }
-        }
-      } else {
-        for (Comment sc : commentsUtil.draftByChange(db, notes())) {
-          draftsByUser.put(sc.author.getId(), null);
+      for (Ref ref : commentsUtil.getDraftRefs(notes.getChangeId())) {
+        Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+        if (account != null
+            // Double-check that any drafts exist for this user after
+            // filtering out zombies. If some but not all drafts in the ref
+            // were zombies, the returned Ref still includes those zombies;
+            // this is suboptimal, but is ok for the purposes of
+            // draftsByUser(), and easier than trying to rebuild the change at
+            // this point.
+            && !notes().getDraftComments(account, ref).isEmpty()) {
+          draftsByUser.put(account, ref);
         }
       }
     }
     return draftsByUser;
   }
 
-  public boolean isReviewedBy(Account.Id accountId) throws OrmException {
+  public boolean isReviewedBy(Account.Id accountId) {
     Collection<String> stars = stars(accountId);
 
-    if (stars.contains(
-        StarredChangesUtil.REVIEWED_LABEL + "/" + currentPatchSet().getPatchSetId())) {
-      return true;
-    }
+    PatchSet ps = currentPatchSet();
+    if (ps != null) {
+      if (stars.contains(StarredChangesUtil.REVIEWED_LABEL + "/" + ps.number())) {
+        return true;
+      }
 
-    if (stars.contains(
-        StarredChangesUtil.UNREVIEWED_LABEL + "/" + currentPatchSet().getPatchSetId())) {
-      return false;
+      if (stars.contains(StarredChangesUtil.UNREVIEWED_LABEL + "/" + ps.number())) {
+        return false;
+      }
     }
 
     return reviewedBy().contains(accountId);
   }
 
-  public Set<Account.Id> reviewedBy() throws OrmException {
+  public Set<Account.Id> reviewedBy() {
     if (reviewedBy == null) {
       if (!lazyLoad) {
         return Collections.emptySet();
@@ -1144,7 +1041,7 @@
     this.reviewedBy = reviewedBy;
   }
 
-  public Set<String> hashtags() throws OrmException {
+  public Set<String> hashtags() {
     if (hashtags == null) {
       if (!lazyLoad) {
         return Collections.emptySet();
@@ -1158,7 +1055,7 @@
     this.hashtags = hashtags;
   }
 
-  public ImmutableListMultimap<Account.Id, String> stars() throws OrmException {
+  public ImmutableListMultimap<Account.Id, String> stars() {
     if (stars == null) {
       if (!lazyLoad) {
         return ImmutableListMultimap.of();
@@ -1176,17 +1073,17 @@
     this.stars = ImmutableListMultimap.copyOf(stars);
   }
 
-  public ImmutableMap<Account.Id, StarRef> starRefs() throws OrmException {
+  public ImmutableMap<Account.Id, StarRef> starRefs() {
     if (starRefs == null) {
       if (!lazyLoad) {
         return ImmutableMap.of();
       }
-      starRefs = checkNotNull(starredChangesUtil).byChange(legacyId);
+      starRefs = requireNonNull(starredChangesUtil).byChange(legacyId);
     }
     return starRefs;
   }
 
-  public Set<String> stars(Account.Id accountId) throws OrmException {
+  public Set<String> stars(Account.Id accountId) {
     if (starsOf != null) {
       if (!starsOf.accountId().equals(accountId)) {
         starsOf = null;
@@ -1210,14 +1107,14 @@
    *     false otherwise.
    */
   @Nullable
-  public Boolean isPureRevert() throws OrmException {
+  public Boolean isPureRevert() {
     if (change().getRevertOf() == null) {
       return null;
     }
     try {
-      return pureRevert.get(notes(), null).isPureRevert;
+      return pureRevert.get(notes(), Optional.empty());
     } catch (IOException | BadRequestException | ResourceConflictException e) {
-      throw new OrmException("could not compute pure revert", e);
+      throw new StorageException("could not compute pure revert", e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
index d541d18..74ad0ef 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 /** Predicate over Change-Id strings (aka Change.Key). */
 public class ChangeIdPredicate extends ChangeIndexPredicate {
@@ -25,7 +24,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     Change change = cd.change();
     if (change == null) {
       return false;
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index 1eb2770..7428e3a 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -17,9 +17,22 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.Predicate;
 
 public abstract class ChangeIndexPredicate extends IndexPredicate<ChangeData>
     implements Matchable<ChangeData> {
+  /**
+   * Returns an index predicate that matches no changes in the index.
+   *
+   * <p>This predicate should be used in preference to a non-index predicate (such as {@code
+   * Predicate.not(Predicate.any())}), since it can be matched efficiently against the index.
+   *
+   * @return an index predicate matching no changes.
+   */
+  public static Predicate<ChangeData> none() {
+    return ChangeStatusPredicate.NONE;
+  }
+
   protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String value) {
     super(def, value);
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 346ac8e..60b4d38 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.index.IndexUtils;
@@ -27,7 +28,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -35,22 +36,20 @@
 public class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  protected final Provider<ReviewDb> db;
   protected final ChangeNotes.Factory notesFactory;
   protected final CurrentUser user;
   protected final PermissionBackend permissionBackend;
   protected final ProjectCache projectCache;
   private final Provider<AnonymousUser> anonymousUserProvider;
 
+  @Inject
   public ChangeIsVisibleToPredicate(
-      Provider<ReviewDb> db,
       ChangeNotes.Factory notesFactory,
       CurrentUser user,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       Provider<AnonymousUser> anonymousUserProvider) {
     super(ChangeQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
-    this.db = db;
     this.notesFactory = notesFactory;
     this.user = user;
     this.permissionBackend = permissionBackend;
@@ -59,7 +58,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     if (cd.fastIsVisibleTo(user)) {
       return true;
     }
@@ -73,37 +72,39 @@
     try {
       ProjectState projectState = projectCache.checkedGet(cd.project());
       if (projectState == null) {
-        logger.atInfo().log("No such project: %s", cd.project());
+        logger.atFine().log("Filter out change %s of non-existing project %s", cd, cd.project());
         return false;
       }
       if (!projectState.statePermitsRead()) {
+        logger.atFine().log("Filter out change %s of non-reabable project %s", cd, cd.project());
         return false;
       }
     } catch (IOException e) {
-      throw new OrmException("unable to read project state", e);
+      throw new StorageException("unable to read project state", e);
     }
 
-    boolean visible;
     PermissionBackend.WithUser withUser =
         user.isIdentifiedUser()
             ? permissionBackend.absentUser(user.getAccountId())
             : permissionBackend.user(anonymousUserProvider.get());
     try {
-      visible = withUser.indexedChange(cd, notes).database(db).test(ChangePermission.READ);
+      withUser.indexedChange(cd, notes).check(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       Throwable cause = e.getCause();
       if (cause instanceof RepositoryNotFoundException) {
         logger.atWarning().withCause(e).log(
-            "Skipping change %s because the corresponding repository was not found", cd.getId());
+            "Filter out change %s because the corresponding repository %s was not found",
+            cd, cd.project());
         return false;
       }
-      throw new OrmException("unable to check permissions on change " + cd.getId(), e);
+      throw new StorageException("unable to check permissions on change " + cd.getId(), e);
+    } catch (AuthException e) {
+      logger.atFine().log("Filter out non-visisble change: %s", cd);
+      return false;
     }
-    if (visible) {
-      cd.cacheVisibleTo(user);
-      return true;
-    }
-    return false;
+
+    cd.cacheVisibleTo(user);
+    return true;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 3113504..2f722d3 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
+import static com.google.gerrit.server.account.AccountResolver.isSelf;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -22,14 +24,13 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.errors.NotSignedInException;
+import com.google.gerrit.exceptions.NotSignedInException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
@@ -39,12 +40,12 @@
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
@@ -52,6 +53,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupMembers;
@@ -60,21 +62,20 @@
 import com.google.gerrit.server.change.ChangeTriplet;
 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.GitRepositoryManager;
+import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.submit.SubmitDryRun;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
@@ -93,10 +94,11 @@
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
 /** Parses a query string meant to be applied to change objects. */
-public class ChangeQueryBuilder extends QueryBuilder<ChangeData> {
+public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuilder> {
   public interface ChangeOperatorFactory extends OperatorFactory<ChangeData, ChangeQueryBuilder> {}
 
   /**
@@ -122,8 +124,8 @@
 
   static final int MAX_ACCOUNTS_PER_DEFAULT_FIELD = 10;
 
-  // NOTE: As new search operations are added, please keep the
-  // SearchSuggestOracle up to date.
+  // NOTE: As new search operations are added, please keep the suggestions in
+  // gr-search-bar.js up to date.
 
   public static final String FIELD_ADDED = "added";
   public static final String FIELD_AGE = "age";
@@ -137,7 +139,11 @@
   public static final String FIELD_COMMENTBY = "commentby";
   public static final String FIELD_COMMIT = "commit";
   public static final String FIELD_COMMITTER = "committer";
+  public static final String FIELD_DIRECTORY = "directory";
   public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
+  public static final String FIELD_EXTENSION = "extension";
+  public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
+  public static final String FIELD_FOOTER = "footer";
   public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DELETED = "deleted";
   public static final String FIELD_DELTA = "delta";
@@ -183,7 +189,7 @@
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
-  public static final Account.Id OWNER_ACCOUNT_ID = new Account.Id(0);
+  public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
 
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
       new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
@@ -207,12 +213,10 @@
     final GroupBackend groupBackend;
     final IdentifiedUser.GenericFactory userFactory;
     final IndexConfig indexConfig;
-    final NotesMigration notesMigration;
     final PatchListCache patchListCache;
     final ProjectCache projectCache;
     final Provider<InternalChangeQuery> queryProvider;
     final ChildProjects childProjects;
-    final Provider<ReviewDb> db;
     final StarredChangesUtil starredChangesUtil;
     final SubmitDryRun submitDryRun;
     final GroupMembers groupMembers;
@@ -223,7 +227,6 @@
     @Inject
     @VisibleForTesting
     public Arguments(
-        Provider<ReviewDb> db,
         Provider<InternalChangeQuery> queryProvider,
         ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
@@ -248,11 +251,9 @@
         IndexConfig indexConfig,
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
-        NotesMigration notesMigration,
         GroupMembers groupMembers,
         Provider<AnonymousUser> anonymousUserProvider) {
       this(
-          db,
           queryProvider,
           rewriter,
           opFactories,
@@ -277,13 +278,11 @@
           indexConfig,
           starredChangesUtil,
           accountCache,
-          notesMigration,
           groupMembers,
           anonymousUserProvider);
     }
 
     private Arguments(
-        Provider<ReviewDb> db,
         Provider<InternalChangeQuery> queryProvider,
         ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
@@ -308,10 +307,8 @@
         IndexConfig indexConfig,
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
-        NotesMigration notesMigration,
         GroupMembers groupMembers,
         Provider<AnonymousUser> anonymousUserProvider) {
-      this.db = db;
       this.queryProvider = queryProvider;
       this.rewriter = rewriter;
       this.opFactories = opFactories;
@@ -336,14 +333,12 @@
       this.starredChangesUtil = starredChangesUtil;
       this.accountCache = accountCache;
       this.hasOperands = hasOperands;
-      this.notesMigration = notesMigration;
       this.groupMembers = groupMembers;
       this.anonymousUserProvider = anonymousUserProvider;
     }
 
     Arguments asUser(CurrentUser otherUser) {
       return new Arguments(
-          db,
           queryProvider,
           rewriter,
           opFactories,
@@ -368,7 +363,6 @@
           indexConfig,
           starredChangesUtil,
           accountCache,
-          notesMigration,
           groupMembers,
           anonymousUserProvider);
     }
@@ -412,27 +406,19 @@
 
   private final Arguments args;
 
+  private @Inject @GerritServerConfig Config cfg;
+
   @Inject
   ChangeQueryBuilder(Arguments args) {
-    super(mydef);
-    this.args = args;
-    setupDynamicOperators();
+    this(mydef, args);
   }
 
   @VisibleForTesting
-  protected ChangeQueryBuilder(
-      Definition<ChangeData, ? extends QueryBuilder<ChangeData>> def, Arguments args) {
-    super(def);
+  protected ChangeQueryBuilder(Definition<ChangeData, ChangeQueryBuilder> def, Arguments args) {
+    super(def, args.opFactories);
     this.args = args;
   }
 
-  private void setupDynamicOperators() {
-    for (DynamicMap.Entry<ChangeOperatorFactory> e : args.opFactories) {
-      String name = e.getExportName() + "_" + e.getPluginName();
-      opFactories.put(name, e.getProvider().get());
-    }
-  }
-
   public Arguments getArgs() {
     return args;
   }
@@ -472,13 +458,13 @@
     if (triplet.isPresent()) {
       return Predicate.and(
           project(triplet.get().project().get()),
-          branch(triplet.get().branch().get()),
+          branch(triplet.get().branch().branch()),
           new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
     }
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return new LegacyChangeIdPredicate(new Change.Id(id));
+        return new LegacyChangeIdPredicate(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return new ChangeIdPredicate(parseChangeId(query));
@@ -564,9 +550,9 @@
       if (args.getSchema().hasField(ChangeField.WIP)) {
         return Predicate.and(
             Predicate.not(new BooleanPredicate(ChangeField.WIP)),
-            ReviewerPredicate.reviewer(args, self()));
+            ReviewerPredicate.reviewer(self()));
       }
-      return ReviewerPredicate.reviewer(args, self());
+      return ReviewerPredicate.reviewer(self());
     }
 
     if ("cc".equalsIgnoreCase(value)) {
@@ -586,11 +572,11 @@
     }
 
     if ("assigned".equalsIgnoreCase(value)) {
-      return Predicate.not(new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE)));
+      return Predicate.not(new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE)));
     }
 
     if ("unassigned".equalsIgnoreCase(value)) {
-      return new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE));
+      return new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE));
     }
 
     if ("submittable".equalsIgnoreCase(value)) {
@@ -625,7 +611,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> conflicts(String value) throws OrmException, QueryParseException {
+  public Predicate<ChangeData> conflicts(String value) throws QueryParseException {
     List<Change> changes = parseChange(value);
     List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
     for (Change c : changes) {
@@ -658,6 +644,36 @@
   }
 
   @Operator
+  public Predicate<ChangeData> repository(String name) {
+    return project(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> repositories(String name) {
+    return projects(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> parentrepository(String name) {
+    return parentproject(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> repo(String name) {
+    return project(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> repos(String name) {
+    return projects(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> parentrepo(String name) {
+    return parentproject(name);
+  }
+
+  @Operator
   public Predicate<ChangeData> branch(String name) {
     if (name.startsWith("^")) {
       return ref("^" + RefNames.fullName(name.substring(1)));
@@ -716,16 +732,75 @@
   }
 
   @Operator
+  public Predicate<ChangeData> ext(String ext) throws QueryParseException {
+    return extension(ext);
+  }
+
+  @Operator
+  public Predicate<ChangeData> extension(String ext) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.EXTENSION)) {
+      if (ext.isEmpty() && IndexModule.getIndexType(cfg).isElasticsearch()) {
+        return new FileWithNoExtensionInElasticPredicate();
+      }
+      return new FileExtensionPredicate(ext);
+    }
+    throw new QueryParseException("'extension' operator is not supported by change index version");
+  }
+
+  @Operator
+  public Predicate<ChangeData> onlyexts(String extList) throws QueryParseException {
+    return onlyextensions(extList);
+  }
+
+  @Operator
+  public Predicate<ChangeData> onlyextensions(String extList) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.ONLY_EXTENSIONS)) {
+      return new FileExtensionListPredicate(extList);
+    }
+    throw new QueryParseException(
+        "'onlyextensions' operator is not supported by change index version");
+  }
+
+  @Operator
+  public Predicate<ChangeData> footer(String footer) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.FOOTER)) {
+      return new FooterPredicate(footer);
+    }
+    throw new QueryParseException("'footer' operator is not supported by change index version");
+  }
+
+  @Operator
+  public Predicate<ChangeData> dir(String directory) throws QueryParseException {
+    return directory(directory);
+  }
+
+  @Operator
+  public Predicate<ChangeData> directory(String directory) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.DIRECTORY)) {
+      if (directory.startsWith("^")) {
+        return new RegexDirectoryPredicate(directory);
+      }
+
+      if (IndexModule.getIndexType(cfg).isElasticsearch()
+          && (directory.isEmpty() || directory.equals("/"))) {
+        return Predicate.any();
+      }
+      return new DirectoryPredicate(directory);
+    }
+    throw new QueryParseException("'directory' operator is not supported by change index version");
+  }
+
+  @Operator
   public Predicate<ChangeData> label(String name)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     Set<Account.Id> accounts = null;
     AccountGroup.UUID group = null;
 
     // Parse for:
-    // label:CodeReview=1,user=jsmith or
-    // label:CodeReview=1,jsmith or
-    // label:CodeReview=1,group=android_approvers or
-    // label:CodeReview=1,android_approvers
+    // label:Code-Review=1,user=jsmith or
+    // label:Code-Review=1,jsmith or
+    // label:Code-Review=1,group=android_approvers or
+    // label:Code-Review=1,android_approvers
     // user/groups without a label will first attempt to match user
     // Special case: votes by owners can be tracked with ",owner":
     // label:Code-Review+2,owner
@@ -817,7 +892,7 @@
 
   @Operator
   public Predicate<ChangeData> starredby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return starredby(parseAccount(who));
   }
 
@@ -835,7 +910,7 @@
 
   @Operator
   public Predicate<ChangeData> watchedby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     Set<Account.Id> m = parseAccount(who);
     List<IsWatchedByPredicate> p = Lists.newArrayListWithCapacity(m.size());
 
@@ -860,7 +935,7 @@
 
   @Operator
   public Predicate<ChangeData> draftby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     Set<Account.Id> m = parseAccount(who);
     List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
     for (Account.Id id : m) {
@@ -873,26 +948,24 @@
     return new HasDraftByPredicate(who);
   }
 
-  private boolean isSelf(String who) {
-    return "self".equals(who) || "me".equals(who);
-  }
-
   @Operator
   public Predicate<ChangeData> visibleto(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     if (isSelf(who)) {
       return is_visible();
     }
-    Set<Account.Id> m = args.accountResolver.findAll(who);
-    if (!m.isEmpty()) {
-      List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
-      for (Account.Id id : m) {
-        return visibleto(args.userFactory.create(id));
+    try {
+      return Predicate.or(
+          parseAccount(who).stream()
+              .map(a -> visibleto(args.userFactory.create(a)))
+              .collect(toImmutableList()));
+    } catch (QueryParseException e) {
+      if (e instanceof QueryRequiresAuthException) {
+        throw e;
       }
-      return Predicate.or(p);
+      // Otherwise continue: if it's not an account, maybe it's a group?
     }
 
-    // If its not an account, maybe its a group?
     Collection<GroupReference> suggestions = args.groupBackend.suggest(who, null);
     if (!suggestions.isEmpty()) {
       HashSet<AccountGroup.UUID> ids = new HashSet<>();
@@ -907,7 +980,6 @@
 
   public Predicate<ChangeData> visibleto(CurrentUser user) {
     return new ChangeIsVisibleToPredicate(
-        args.db,
         args.notesFactory,
         user,
         args.permissionBackend,
@@ -921,13 +993,13 @@
 
   @Operator
   public Predicate<ChangeData> o(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return owner(who);
   }
 
   @Operator
   public Predicate<ChangeData> owner(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return owner(parseAccount(who));
   }
 
@@ -940,7 +1012,7 @@
   }
 
   private Predicate<ChangeData> ownerDefaultField(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     Set<Account.Id> accounts = parseAccount(who);
     if (accounts.size() > MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
       return Predicate.any();
@@ -950,7 +1022,7 @@
 
   @Operator
   public Predicate<ChangeData> assignee(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return assignee(parseAccount(who));
   }
 
@@ -985,23 +1057,23 @@
 
   @Operator
   public Predicate<ChangeData> r(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return reviewer(who);
   }
 
   @Operator
   public Predicate<ChangeData> reviewer(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return reviewer(who, false);
   }
 
   private Predicate<ChangeData> reviewerDefaultField(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return reviewer(who, true);
   }
 
   private Predicate<ChangeData> reviewer(String who, boolean forDefaultField)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     Predicate<ChangeData> byState =
         reviewerByState(who, ReviewerStateInternal.REVIEWER, forDefaultField);
     if (Objects.equals(byState, Predicate.<ChangeData>any())) {
@@ -1015,7 +1087,7 @@
 
   @Operator
   public Predicate<ChangeData> cc(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return reviewerByState(who, ReviewerStateInternal.CC, false);
   }
 
@@ -1069,7 +1141,7 @@
 
   @Operator
   public Predicate<ChangeData> commentby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return commentby(parseAccount(who));
   }
 
@@ -1083,7 +1155,7 @@
 
   @Operator
   public Predicate<ChangeData> from(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     Set<Account.Id> ownerIds = parseAccount(who);
     return Predicate.or(owner(ownerIds), commentby(ownerIds));
   }
@@ -1092,7 +1164,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);
@@ -1108,7 +1180,7 @@
 
   @Operator
   public Predicate<ChangeData> reviewedby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     return IsReviewedPredicate.create(parseAccount(who));
   }
 
@@ -1116,8 +1188,8 @@
   public Predicate<ChangeData> destination(String name) throws QueryParseException {
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
-      d.load(git);
-      Set<Branch.NameKey> destinations = d.getDestinationList().getDestinations(name);
+      d.load(args.allUsersName, git);
+      Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, name);
       }
@@ -1198,7 +1270,7 @@
       if (!Objects.equals(p, Predicate.<ChangeData>any())) {
         predicates.add(p);
       }
-    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
+    } catch (StorageException | IOException | ConfigInvalidException | QueryParseException e) {
       // Skip.
     }
     try {
@@ -1206,13 +1278,13 @@
       if (!Objects.equals(p, Predicate.<ChangeData>any())) {
         predicates.add(p);
       }
-    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
+    } catch (StorageException | IOException | ConfigInvalidException | QueryParseException e) {
       // Skip.
     }
     predicates.add(file(query));
     try {
       predicates.add(label(query));
-    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
+    } catch (StorageException | IOException | ConfigInvalidException | QueryParseException e) {
       // Skip.
     }
     predicates.add(commit(query));
@@ -1254,11 +1326,11 @@
   private Set<Account.Id> getMembers(AccountGroup.UUID g) throws IOException {
     Set<Account.Id> accounts;
     Set<Account.Id> allMembers =
-        args.groupMembers.listAccounts(g).stream().map(Account::getId).collect(toSet());
+        args.groupMembers.listAccounts(g).stream().map(Account::id).collect(toSet());
     int maxTerms = args.indexConfig.maxTerms();
     if (allMembers.size() > maxTerms) {
       // limit the number of query terms otherwise Gerrit will barf
-      accounts = ImmutableSet.copyOf(Iterables.limit(allMembers, maxTerms));
+      accounts = allMembers.stream().limit(maxTerms).collect(toSet());
     } else {
       accounts = allMembers;
     }
@@ -1266,15 +1338,15 @@
   }
 
   private Set<Account.Id> parseAccount(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    if (isSelf(who)) {
-      return Collections.singleton(self());
+      throws QueryParseException, IOException, ConfigInvalidException {
+    try {
+      return args.accountResolver.resolve(who).asNonEmptyIdSet();
+    } catch (UnresolvableAccountException e) {
+      if (e.isSelf()) {
+        throw new QueryRequiresAuthException(e.getMessage(), e);
+      }
+      throw new QueryParseException(e.getMessage(), e);
     }
-    Set<Account.Id> matches = args.accountResolver.findAll(who);
-    if (matches.isEmpty()) {
-      throw error("User " + who + " not found");
-    }
-    return matches;
   }
 
   private GroupReference parseGroup(String group) throws QueryParseException {
@@ -1285,7 +1357,7 @@
     return g;
   }
 
-  private List<Change> parseChange(String value) throws OrmException, QueryParseException {
+  private List<Change> parseChange(String value) throws QueryParseException {
     if (PAT_LEGACY_ID.matcher(value).matches()) {
       return asChanges(args.queryProvider.get().byLegacyChangeId(Change.Id.parse(value)));
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
@@ -1312,7 +1384,7 @@
 
   public Predicate<ChangeData> reviewerByState(
       String who, ReviewerStateInternal state, boolean forDefaultField)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+      throws QueryParseException, IOException, ConfigInvalidException {
     Predicate<ChangeData> reviewerByEmailPredicate = null;
     if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
       Address address = Address.tryParse(who);
@@ -1327,8 +1399,7 @@
       if (!forDefaultField || accounts.size() <= MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
         reviewerPredicate =
             Predicate.or(
-                accounts
-                    .stream()
+                accounts.stream()
                     .map(id -> ReviewerPredicate.forState(id, state))
                     .collect(toList()));
       }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 21d7b2d..f9263a9 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,18 +17,25 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.account.AccountLimits;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
+import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
@@ -38,8 +45,8 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -49,24 +56,14 @@
  * holding on to a single instance.
  */
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
-    implements PluginDefinedAttributesFactory {
-  /**
-   * Register a ChangeAttributeFactory in a config Module like this:
-   *
-   * <p>bind(ChangeAttributeFactory.class) .annotatedWith(Exports.named("export-name"))
-   * .to(YourClass.class);
-   */
-  public interface ChangeAttributeFactory {
-    PluginDefinedInfo create(ChangeData a, ChangeQueryProcessor qp, String plugin);
-  }
-
-  private final Provider<ReviewDb> db;
+    implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider {
   private final Provider<CurrentUser> userProvider;
   private final ChangeNotes.Factory notesFactory;
-  private final DynamicMap<ChangeAttributeFactory> attributeFactories;
+  private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final Provider<AnonymousUser> anonymousUserProvider;
+  private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -83,9 +80,8 @@
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
-      Provider<ReviewDb> db,
       ChangeNotes.Factory notesFactory,
-      DynamicMap<ChangeAttributeFactory> attributeFactories,
+      DynamicSet<ChangeAttributeFactory> attributeFactories,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       Provider<AnonymousUser> anonymousUserProvider) {
@@ -97,13 +93,18 @@
         rewriter,
         FIELD_LIMIT,
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
-    this.db = db;
     this.userProvider = userProvider;
     this.notesFactory = notesFactory;
-    this.attributeFactories = attributeFactories;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.anonymousUserProvider = anonymousUserProvider;
+
+    ImmutableListMultimap.Builder<String, ChangeAttributeFactory> factoriesBuilder =
+        ImmutableListMultimap.builder();
+    // Eagerly call Extension#get() rather than storing Extensions, since that method invokes the
+    // Provider on every call, which could be expensive if we invoke it once for every change.
+    attributeFactories.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
+    attributeFactoriesByPlugin = factoriesBuilder.build();
   }
 
   @Override
@@ -119,27 +120,25 @@
   }
 
   @Override
-  public List<PluginDefinedInfo> create(ChangeData cd) {
-    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactories.plugins().size());
-    for (String plugin : attributeFactories.plugins()) {
-      for (Provider<ChangeAttributeFactory> provider :
-          attributeFactories.byPlugin(plugin).values()) {
-        PluginDefinedInfo pda = null;
-        try {
-          pda = provider.get().create(cd, this, plugin);
-        } catch (RuntimeException e) {
-          /* Eat runtime exceptions so that queries don't fail. */
-        }
-        if (pda != null) {
-          pda.name = plugin;
-          plugins.add(pda);
-        }
-      }
-    }
-    if (plugins.isEmpty()) {
-      plugins = null;
-    }
-    return plugins;
+  public void setDynamicBean(String plugin, DynamicBean dynamicBean) {
+    dynamicBeans.put(plugin, dynamicBean);
+  }
+
+  @Override
+  public DynamicBean getDynamicBean(String plugin) {
+    return dynamicBeans.get(plugin);
+  }
+
+  public PluginDefinedAttributesFactory getAttributesFactory() {
+    return this::buildPluginInfo;
+  }
+
+  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
+    return PluginDefinedAttributesFactories.createAll(
+        cd,
+        this,
+        attributeFactoriesByPlugin.entries().stream()
+            .map(e -> new Extension<>(e.getKey(), e::getValue)));
   }
 
   @Override
@@ -147,7 +146,6 @@
     return new AndChangeSource(
         pred,
         new ChangeIsVisibleToPredicate(
-            db,
             notesFactory,
             userProvider.get(),
             permissionBackend,
@@ -155,4 +153,9 @@
             anonymousUserProvider),
         start);
   }
+
+  @Override
+  protected String formatForLogging(ChangeData changeData) {
+    return changeData.getId().toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 155b016..66790e7 100644
--- a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -14,15 +14,14 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -41,7 +40,7 @@
  */
 public final class ChangeStatusPredicate extends ChangeIndexPredicate {
   private static final String INVALID_STATUS = "__invalid__";
-  private static final Predicate<ChangeData> NONE = new ChangeStatusPredicate(null);
+  static final Predicate<ChangeData> NONE = new ChangeStatusPredicate(null);
 
   private static final TreeMap<String, Predicate<ChangeData>> PREDICATES;
   private static final Predicate<ChangeData> CLOSED;
@@ -98,7 +97,7 @@
   }
 
   public static ChangeStatusPredicate forStatus(Change.Status status) {
-    return new ChangeStatusPredicate(checkNotNull(status));
+    return new ChangeStatusPredicate(requireNonNull(status));
   }
 
   @Nullable private final Change.Status status;
@@ -119,7 +118,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     return change != null && Objects.equals(status, change.getStatus());
   }
diff --git a/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index 7ad7afe..0747bb2 100644
--- a/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 import java.util.Objects;
 
 public class CommentByPredicate extends ChangeIndexPredicate {
@@ -34,7 +33,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     for (ChangeMessage m : cd.messages()) {
       if (Objects.equals(m.getAuthor(), id)) {
         return true;
diff --git a/java/com/google/gerrit/server/query/change/CommentPredicate.java b/java/com/google/gerrit/server/query/change/CommentPredicate.java
index 5a6d186..d193bb6 100644
--- a/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommentPredicate.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gwtorm.server.OrmException;
 
 public class CommentPredicate extends ChangeIndexPredicate {
   protected final ChangeIndex index;
@@ -30,7 +30,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     try {
       Predicate<ChangeData> p = Predicate.and(new LegacyChangeIdPredicate(object.getId()), this);
       for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
@@ -39,7 +39,7 @@
         }
       }
     } catch (QueryParseException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
 
     return false;
diff --git a/java/com/google/gerrit/server/query/change/CommitPredicate.java b/java/com/google/gerrit/server/query/change/CommitPredicate.java
index d1ae529..25d3ec3 100644
--- a/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommitPredicate.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.gerrit.git.ObjectIds.matchesAbbreviation;
 import static com.google.gerrit.server.index.change.ChangeField.COMMIT;
 import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMIT;
-import static org.eclipse.jgit.lib.Constants.OBJECT_ID_STRING_LENGTH;
 
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtorm.server.OrmException;
 
 public class CommitPredicate extends ChangeIndexPredicate {
   static FieldDef<ChangeData, ?> commitField(String id) {
-    if (id.length() == OBJECT_ID_STRING_LENGTH) {
+    if (id.length() == ObjectIds.STR_LEN) {
       return EXACT_COMMIT;
     }
     return COMMIT;
@@ -35,7 +35,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     String id = getValue().toLowerCase();
     for (PatchSet p : object.patchSets()) {
       if (equals(p, id)) {
@@ -46,9 +46,10 @@
   }
 
   protected boolean equals(PatchSet p, String id) {
-    boolean exact = getField() == EXACT_COMMIT;
-    String rev = p.getRevision() != null ? p.getRevision().get() : null;
-    return (exact && id.equals(rev)) || (!exact && rev != null && rev.startsWith(id));
+    if (getField() == EXACT_COMMIT) {
+      return p.commitId().name().equals(id);
+    }
+    return matchesAbbreviation(p.commitId(), id);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/java/com/google/gerrit/server/query/change/CommitterPredicate.java
index 797cb9d..1dcf97f 100644
--- a/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommitterPredicate.java
@@ -18,8 +18,6 @@
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_COMMITTER;
 
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
 
 public class CommitterPredicate extends ChangeIndexPredicate {
   public CommitterPredicate(String value) {
@@ -27,12 +25,8 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
-    try {
-      return ChangeField.getCommitterParts(object).contains(getValue().toLowerCase());
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
+  public boolean match(ChangeData object) {
+    return ChangeField.getCommitterParts(object).contains(getValue().toLowerCase());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ConflictKey.java b/java/com/google/gerrit/server/query/change/ConflictKey.java
index 9daf886..01fdbfa 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.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.ConflictKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -61,7 +61,7 @@
 
   public abstract boolean contentMerge();
 
-  public static enum Serializer implements CacheSerializer<ConflictKey> {
+  public enum Serializer implements CacheSerializer<ConflictKey> {
     INSTANCE;
 
     private static final Converter<String, SubmitType> SUBMIT_TYPE_CONVERTER =
@@ -70,7 +70,7 @@
     @Override
     public byte[] serialize(ConflictKey object) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
-      return ProtoCacheSerializers.toByteArray(
+      return Protos.toByteArray(
           ConflictKeyProto.newBuilder()
               .setCommit(idConverter.toByteString(object.commit()))
               .setOtherCommit(idConverter.toByteString(object.otherCommit()))
@@ -81,7 +81,7 @@
 
     @Override
     public ConflictKey deserialize(byte[] in) {
-      ConflictKeyProto proto = ProtoCacheSerializers.parseUnchecked(ConflictKeyProto.parser(), in);
+      ConflictKeyProto proto = Protos.parseUnchecked(ConflictKeyProto.parser(), in);
       ObjectIdConverter idConverter = ObjectIdConverter.create();
       return create(
           idConverter.fromByteString(proto.getCommit()),
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/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 7dc7a0b..f18a5a7 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -14,13 +14,20 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.flogger.LazyArgs.lazy;
+import static java.util.concurrent.TimeUnit.MINUTES;
+
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -29,7 +36,6 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gerrit.server.submit.IntegrationException;
 import com.google.gerrit.server.submit.SubmitDryRun;
-import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -41,20 +47,27 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class ConflictsPredicate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   // UI code may depend on this string, so use caution when changing.
   protected static final String TOO_MANY_FILES = "too many files to find conflicts";
 
   private ConflictsPredicate() {}
 
   public static Predicate<ChangeData> create(Arguments args, String value, Change c)
-      throws QueryParseException, OrmException {
+      throws QueryParseException {
     ChangeData cd;
     List<String> files;
     try {
-      cd = args.changeDataFactory.create(args.db.get(), c);
+      cd = args.changeDataFactory.create(c);
       files = cd.currentFilePaths();
-    } catch (IOException e) {
-      throw new OrmException(e);
+    } catch (StorageException e) {
+      warnWithOccasionalStackTrace(
+          e,
+          "Error constructing conflicts predicates for change %s in %s",
+          c.getId(),
+          c.getProject());
+      return ChangeIndexPredicate.none();
     }
 
     if (3 + files.size() > args.indexConfig.maxTerms()) {
@@ -73,7 +86,7 @@
 
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(new ProjectPredicate(c.getProject().get()));
-    and.add(new RefPredicate(c.getDest().get()));
+    and.add(new RefPredicate(c.getDest().branch()));
     and.add(Predicate.not(new LegacyChangeIdPredicate(c.getId())));
     and.add(Predicate.or(filePredicates));
 
@@ -84,7 +97,7 @@
 
   private static final class CheckConflict extends PostFilterPredicate<ChangeData> {
     private final Arguments args;
-    private final Branch.NameKey dest;
+    private final BranchNameKey dest;
     private final ChangeDataCache changeDataCache;
 
     CheckConflict(String value, Arguments args, Change c, ChangeDataCache changeDataCache) {
@@ -95,51 +108,66 @@
     }
 
     @Override
-    public boolean match(ChangeData object) throws OrmException {
-      Change otherChange = object.change();
-      if (otherChange == null || !otherChange.getDest().equals(dest)) {
-        return false;
-      }
-
-      SubmitTypeRecord str = object.submitTypeRecord();
-      if (!str.isOk()) {
-        return false;
-      }
-
-      ProjectState projectState;
+    public boolean match(ChangeData object) {
+      Change.Id id = object.getId();
+      Project.NameKey otherProject = null;
+      ObjectId other = null;
       try {
-        projectState = changeDataCache.getProjectState();
-      } catch (NoSuchProjectException e) {
-        return false;
-      }
+        Change otherChange = object.change();
+        if (otherChange == null || !otherChange.getDest().equals(dest)) {
+          return false;
+        }
+        otherProject = otherChange.getProject();
 
-      ObjectId other = ObjectId.fromString(object.currentPatchSet().getRevision().get());
-      ConflictKey conflictsKey =
-          ConflictKey.create(
-              changeDataCache.getTestAgainst(),
-              other,
-              str.type,
-              projectState.is(BooleanProjectConfig.USE_CONTENT_MERGE));
-      Boolean maybeConflicts = args.conflictsCache.getIfPresent(conflictsKey);
-      if (maybeConflicts != null) {
-        return maybeConflicts;
-      }
+        SubmitTypeRecord str = object.submitTypeRecord();
+        if (!str.isOk()) {
+          return false;
+        }
 
-      try (Repository repo = args.repoManager.openRepository(otherChange.getProject());
-          CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        boolean conflicts =
-            !args.submitDryRun.run(
-                str.type,
-                repo,
-                rw,
-                otherChange.getDest(),
+        ProjectState projectState;
+        try {
+          projectState = changeDataCache.getProjectState();
+        } catch (NoSuchProjectException e) {
+          return false;
+        }
+
+        other = object.currentPatchSet().commitId();
+        ConflictKey conflictsKey =
+            ConflictKey.create(
                 changeDataCache.getTestAgainst(),
                 other,
-                getAlreadyAccepted(repo, rw));
-        args.conflictsCache.put(conflictsKey, conflicts);
-        return conflicts;
-      } catch (IntegrationException | NoSuchProjectException | IOException e) {
-        throw new OrmException(e);
+                str.type,
+                projectState.is(BooleanProjectConfig.USE_CONTENT_MERGE));
+        Boolean maybeConflicts = args.conflictsCache.getIfPresent(conflictsKey);
+        if (maybeConflicts != null) {
+          return maybeConflicts;
+        }
+
+        try (Repository repo = args.repoManager.openRepository(otherChange.getProject());
+            CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
+          boolean conflicts =
+              !args.submitDryRun.run(
+                  null,
+                  str.type,
+                  repo,
+                  rw,
+                  otherChange.getDest(),
+                  changeDataCache.getTestAgainst(),
+                  other,
+                  getAlreadyAccepted(repo, rw));
+          args.conflictsCache.put(conflictsKey, conflicts);
+          return conflicts;
+        }
+      } catch (IntegrationException | NoSuchProjectException | StorageException | IOException e) {
+        ObjectId finalOther = other;
+        warnWithOccasionalStackTrace(
+            e,
+            "Merge failure checking conflicts of change %s in %s (%s): %s",
+            id,
+            firstNonNull(otherProject, "unknown project"),
+            lazy(() -> finalOther != null ? finalOther.name() : "unknown commit"),
+            e.getMessage());
+        return false;
       }
     }
 
@@ -158,7 +186,7 @@
           accepted.add(rw.parseCommit(tip));
         }
         return accepted;
-      } catch (OrmException | IOException e) {
+      } catch (StorageException | IOException e) {
         throw new IntegrationException("Failed to determine already accepted commits.", e);
       }
     }
@@ -177,9 +205,9 @@
       this.projectCache = projectCache;
     }
 
-    ObjectId getTestAgainst() throws OrmException {
+    ObjectId getTestAgainst() {
       if (testAgainst == null) {
-        testAgainst = ObjectId.fromString(cd.currentPatchSet().getRevision().get());
+        testAgainst = cd.currentPatchSet().commitId();
       }
       return testAgainst;
     }
@@ -201,4 +229,13 @@
       return alreadyAccepted;
     }
   }
+
+  private static void warnWithOccasionalStackTrace(Throwable cause, String format, Object... args) {
+    logger.atWarning().logVarargs(format, args);
+    logger
+        .atWarning()
+        .withCause(cause)
+        .atMostEvery(1, MINUTES)
+        .logVarargs("(Re-logging with stack trace) " + format, args);
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/java/com/google/gerrit/server/query/change/DeletedPredicate.java
index 6232fc5..d4bdc67 100644
--- a/java/com/google/gerrit/server/query/change/DeletedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DeletedPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class DeletedPredicate extends IntegerRangeChangePredicate {
   public DeletedPredicate(String value) throws QueryParseException {
@@ -24,7 +23,7 @@
   }
 
   @Override
-  protected Integer getValueInt(ChangeData changeData) throws OrmException {
+  protected Integer getValueInt(ChangeData changeData) {
     return ChangeField.DELETED.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/java/com/google/gerrit/server/query/change/DeltaPredicate.java
index aae0a20..821ec94 100644
--- a/java/com/google/gerrit/server/query/change/DeltaPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DeltaPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class DeltaPredicate extends IntegerRangeChangePredicate {
   public DeltaPredicate(String value) throws QueryParseException {
@@ -24,7 +23,7 @@
   }
 
   @Override
-  protected Integer getValueInt(ChangeData changeData) throws OrmException {
+  protected Integer getValueInt(ChangeData changeData) {
     return ChangeField.DELTA.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index a824a87..bd07914 100644
--- a/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -15,21 +15,20 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.OrmException;
 import java.util.Set;
 
 public class DestinationPredicate extends PostFilterPredicate<ChangeData> {
-  protected Set<Branch.NameKey> destinations;
+  protected Set<BranchNameKey> destinations;
 
-  public DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
+  public DestinationPredicate(Set<BranchNameKey> destinations, String value) {
     super(ChangeQueryBuilder.FIELD_DESTINATION, value);
     this.destinations = destinations;
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     if (change == null) {
       return false;
diff --git a/java/com/google/gerrit/server/query/change/DirectoryPredicate.java b/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
new file mode 100644
index 0000000..3ab3e26
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.base.CharMatcher;
+import com.google.gerrit.server.index.change.ChangeField;
+import java.util.Locale;
+
+public class DirectoryPredicate extends ChangeIndexPredicate {
+  private static String clean(String directory) {
+    return CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US);
+  }
+
+  DirectoryPredicate(String value) {
+    super(ChangeField.DIRECTORY, clean(value));
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return ChangeField.getDirectories(cd).contains(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/EditByPredicate.java b/java/com/google/gerrit/server/query/change/EditByPredicate.java
index 3238dc9..dfe7310 100644
--- a/java/com/google/gerrit/server/query/change/EditByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EditByPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class EditByPredicate extends ChangeIndexPredicate {
   protected final Account.Id id;
@@ -27,7 +26,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     return cd.editsByUser().contains(id);
   }
 
diff --git a/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
index b5a2d05..9c033b6 100644
--- a/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-import com.google.gwtorm.server.OrmException;
 
 public class EqualsFilePredicate extends ChangeIndexPredicate {
   public static Predicate<ChangeData> create(Arguments args, String value) {
@@ -33,7 +32,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     return ChangeField.getFileParts(object).contains(value);
   }
 
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 19fd4a1..0e07a18 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -16,11 +16,11 @@
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -28,15 +28,12 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
 import java.io.IOException;
 
 public class EqualsLabelPredicate extends ChangeIndexPredicate {
   protected final ProjectCache projectCache;
   protected final PermissionBackend permissionBackend;
   protected final IdentifiedUser.GenericFactory userFactory;
-  protected final Provider<ReviewDb> dbProvider;
   protected final String label;
   protected final int expVal;
   protected final Account.Id account;
@@ -48,7 +45,6 @@
     this.permissionBackend = args.permissionBackend;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
-    this.dbProvider = args.dbProvider;
     this.group = args.group;
     this.label = label;
     this.expVal = expVal;
@@ -56,7 +52,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change c = object.change();
     if (c == null) {
       // The change has disappeared.
@@ -64,7 +60,7 @@
       return false;
     }
 
-    ProjectState project = projectCache.get(c.getDest().getParentKey());
+    ProjectState project = projectCache.get(c.getDest().project());
     if (project == null) {
       // The project has disappeared.
       //
@@ -80,7 +76,7 @@
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
-        if (match(object, p.getValue(), p.getAccountId())) {
+        if (match(object, p.value(), p.accountId())) {
           return true;
         }
       }
@@ -122,13 +118,15 @@
 
     // Check the user has 'READ' permission.
     try {
-      PermissionBackend.ForChange perm =
-          permissionBackend.absentUser(approver).database(dbProvider).change(cd);
+      PermissionBackend.ForChange perm = permissionBackend.absentUser(approver).change(cd);
       ProjectState projectState = projectCache.checkedGet(cd.project());
-      return projectState != null
-          && projectState.statePermitsRead()
-          && perm.test(ChangePermission.READ);
-    } catch (PermissionBackendException | IOException e) {
+      if (projectState == null || !projectState.statePermitsRead()) {
+        return false;
+      }
+
+      perm.check(ChangePermission.READ);
+      return true;
+    } catch (PermissionBackendException | IOException | AuthException e) {
       return false;
     }
   }
diff --git a/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
index fc00283..76936fa 100644
--- a/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
@@ -15,10 +15,7 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
 import java.util.Collections;
-import java.util.List;
 
 public class EqualsPathPredicate extends ChangeIndexPredicate {
   public EqualsPathPredicate(String fieldName, String value) {
@@ -26,14 +23,8 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
-    List<String> files;
-    try {
-      files = object.currentFilePaths();
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-    return Collections.binarySearch(files, value) >= 0;
+  public boolean match(ChangeData object) {
+    return Collections.binarySearch(object.currentFilePaths(), value) >= 0;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java b/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
index bca5d3b..c1b6928 100644
--- a/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
@@ -18,8 +18,6 @@
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTAUTHOR;
 
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
 import java.util.Locale;
 
 public class ExactAuthorPredicate extends ChangeIndexPredicate {
@@ -28,12 +26,8 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
-    try {
-      return ChangeField.getAuthorNameAndEmail(object).contains(getValue());
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
+  public boolean match(ChangeData object) {
+    return ChangeField.getAuthorNameAndEmail(object).contains(getValue());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java b/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
index 3fae5e5..dac63af 100644
--- a/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
@@ -18,8 +18,6 @@
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTCOMMITTER;
 
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
 import java.util.Locale;
 
 public class ExactCommitterPredicate extends ChangeIndexPredicate {
@@ -28,12 +26,8 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
-    try {
-      return ChangeField.getCommitterNameAndEmail(object).contains(getValue());
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
+  public boolean match(ChangeData object) {
+    return ChangeField.getCommitterNameAndEmail(object).contains(getValue());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
index 138cce5..c6ade75e 100644
--- a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.server.index.change.ChangeField.EXACT_TOPIC;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.OrmException;
 
 public class ExactTopicPredicate extends ChangeIndexPredicate {
   public ExactTopicPredicate(String topic) {
@@ -25,7 +24,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     if (change == null) {
       return false;
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
new file mode 100644
index 0000000..bddd2ec
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.Splitter;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class FileExtensionListPredicate extends ChangeIndexPredicate {
+  private static String clean(String extList) {
+    return Splitter.on(',').splitToList(extList).stream()
+        .map(FileExtensionPredicate::clean)
+        .distinct()
+        .sorted()
+        .collect(joining(","));
+  }
+
+  FileExtensionListPredicate(String value) {
+    super(ChangeField.ONLY_EXTENSIONS, clean(value));
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return ChangeField.getAllExtensionsAsList(cd).equals(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
new file mode 100644
index 0000000..ee573a7
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import java.util.Locale;
+
+public class FileExtensionPredicate extends ChangeIndexPredicate {
+  static String clean(String ext) {
+    if (ext.startsWith(".")) {
+      ext = ext.substring(1);
+    }
+    return ext.toLowerCase(Locale.US);
+  }
+
+  FileExtensionPredicate(String value) {
+    super(ChangeField.EXTENSION, clean(value));
+  }
+
+  @Override
+  public boolean match(ChangeData object) {
+    return ChangeField.getExtensions(object).contains(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/FileWithNoExtensionInElasticPredicate.java b/java/com/google/gerrit/server/query/change/FileWithNoExtensionInElasticPredicate.java
new file mode 100644
index 0000000..d886baf
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FileWithNoExtensionInElasticPredicate.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class FileWithNoExtensionInElasticPredicate extends PostFilterPredicate<ChangeData> {
+
+  private static final String NO_EXT = "";
+
+  public FileWithNoExtensionInElasticPredicate() {
+    super(ChangeField.EXTENSION.getName(), NO_EXT);
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return ChangeField.getExtensions(cd).contains(NO_EXT);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/FooterPredicate.java b/java/com/google/gerrit/server/query/change/FooterPredicate.java
new file mode 100644
index 0000000..4d7588c
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FooterPredicate.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import java.util.Locale;
+
+public class FooterPredicate extends ChangeIndexPredicate {
+  private static String clean(String value) {
+    int indexEquals = value.indexOf('=');
+    int indexColon = value.indexOf(':');
+
+    // footer key cannot contain '='
+    if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
+      value = value.substring(0, indexEquals) + ": " + value.substring(indexEquals + 1);
+    }
+    return value.toLowerCase(Locale.US);
+  }
+
+  FooterPredicate(String value) {
+    super(ChangeField.FOOTER, clean(value));
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return ChangeField.getFooters(cd).contains(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
index 545b668..140f26b 100644
--- a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
@@ -17,12 +17,12 @@
 import static com.google.gerrit.server.index.change.ChangeField.FUZZY_TOPIC;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gwtorm.server.OrmException;
 
 public class FuzzyTopicPredicate extends ChangeIndexPredicate {
   protected final ChangeIndex index;
@@ -33,7 +33,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     Change change = cd.change();
     if (change == null) {
       return false;
@@ -48,7 +48,7 @@
           index.getSource(and(thisId, this), IndexedChangeQuery.oneResult()).read();
       return !Iterables.isEmpty(results);
     } catch (QueryParseException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
index d2645dc..0e6f45d 100644
--- a/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 import java.util.List;
 
 public class GroupPredicate extends ChangeIndexPredicate {
@@ -25,9 +24,9 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     for (PatchSet ps : cd.patchSets()) {
-      List<String> groups = ps.getGroups();
+      List<String> groups = ps.groups();
       if (groups != null && groups.contains(getValue())) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
index e422b74..e57a8b3 100644
--- a/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class HasDraftByPredicate extends ChangeIndexPredicate {
   protected final Account.Id accountId;
@@ -27,7 +26,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     return cd.draftsByUser().contains(accountId);
   }
 
diff --git a/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
index b17fffd..0c99cdf 100644
--- a/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class HasStarsPredicate extends ChangeIndexPredicate {
   protected final Account.Id accountId;
@@ -27,7 +26,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     return cd.stars().containsKey(accountId);
   }
 
diff --git a/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/java/com/google/gerrit/server/query/change/HashtagPredicate.java
index bea5688..1fe4af4 100644
--- a/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ b/java/com/google/gerrit/server/query/change/HashtagPredicate.java
@@ -16,15 +16,16 @@
 
 import com.google.gerrit.server.change.HashtagsUtil;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class HashtagPredicate extends ChangeIndexPredicate {
   public HashtagPredicate(String hashtag) {
-    super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag));
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    // TODO(dborowitz): Change both.
+    super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     for (String hashtag : object.notes().load().getHashtags()) {
       if (hashtag.equalsIgnoreCase(getValue())) {
         return true;
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6e63a32..b364e98 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -25,18 +25,15 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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.server.ReviewDb;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -46,6 +43,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.function.Supplier;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -56,9 +54,9 @@
  * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
  * holding on to a single instance.
  */
-public class InternalChangeQuery extends InternalQuery<ChangeData> {
-  private static Predicate<ChangeData> ref(Branch.NameKey branch) {
-    return new RefPredicate(branch.get());
+public class InternalChangeQuery extends InternalQuery<ChangeData, InternalChangeQuery> {
+  private static Predicate<ChangeData> ref(BranchNameKey branch) {
+    return new RefPredicate(branch.branch());
   }
 
   private static Predicate<ChangeData> change(Change.Key key) {
@@ -92,44 +90,19 @@
     this.notesFactory = notesFactory;
   }
 
-  @Override
-  public InternalChangeQuery setLimit(int n) {
-    super.setLimit(n);
-    return this;
-  }
-
-  @Override
-  public InternalChangeQuery enforceVisibility(boolean enforce) {
-    super.enforceVisibility(enforce);
-    return this;
-  }
-
-  @SafeVarargs
-  @Override
-  public final InternalChangeQuery setRequestedFields(FieldDef<ChangeData, ?>... fields) {
-    super.setRequestedFields(fields);
-    return this;
-  }
-
-  @Override
-  public InternalChangeQuery noFields() {
-    super.noFields();
-    return this;
-  }
-
-  public List<ChangeData> byKey(Change.Key key) throws OrmException {
+  public List<ChangeData> byKey(Change.Key key) {
     return byKeyPrefix(key.get());
   }
 
-  public List<ChangeData> byKeyPrefix(String prefix) throws OrmException {
+  public List<ChangeData> byKeyPrefix(String prefix) {
     return query(new ChangeIdPredicate(prefix));
   }
 
-  public List<ChangeData> byLegacyChangeId(Change.Id id) throws OrmException {
+  public List<ChangeData> byLegacyChangeId(Change.Id id) {
     return query(new LegacyChangeIdPredicate(id));
   }
 
-  public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) throws OrmException {
+  public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
       preds.add(new LegacyChangeIdPredicate(id));
@@ -137,28 +110,39 @@
     return query(or(preds));
   }
 
-  public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key) throws OrmException {
-    return query(and(ref(branch), project(branch.getParentKey()), change(key)));
+  public List<ChangeData> byBranchKey(BranchNameKey branch, Change.Key key) {
+    return query(byBranchKeyPred(branch, key));
   }
 
-  public List<ChangeData> byProject(Project.NameKey project) throws OrmException {
+  public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key) {
+    return query(and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open()));
+  }
+
+  public static Predicate<ChangeData> byBranchKeyOpenPred(
+      Project.NameKey project, String branch, Change.Key key) {
+    return and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open());
+  }
+
+  private static Predicate<ChangeData> byBranchKeyPred(BranchNameKey branch, Change.Key key) {
+    return and(ref(branch), project(branch.project()), change(key));
+  }
+
+  public List<ChangeData> byProject(Project.NameKey project) {
     return query(project(project));
   }
 
-  public List<ChangeData> byBranchOpen(Branch.NameKey branch) throws OrmException {
-    return query(and(ref(branch), project(branch.getParentKey()), open()));
+  public List<ChangeData> byBranchOpen(BranchNameKey branch) {
+    return query(and(ref(branch), project(branch.project()), open()));
   }
 
-  public List<ChangeData> byBranchNew(Branch.NameKey branch) throws OrmException {
-    return query(and(ref(branch), project(branch.getParentKey()), status(Change.Status.NEW)));
+  public List<ChangeData> byBranchNew(BranchNameKey branch) {
+    return query(and(ref(branch), project(branch.project()), status(Change.Status.NEW)));
   }
 
   public Iterable<ChangeData> byCommitsOnBranchNotMerged(
-      Repository repo, ReviewDb db, Branch.NameKey branch, Collection<String> hashes)
-      throws OrmException, IOException {
+      Repository repo, BranchNameKey branch, Collection<String> hashes) throws IOException {
     return byCommitsOnBranchNotMerged(
         repo,
-        db,
         branch,
         hashes,
         // Account for all commit predicates plus ref, project, status.
@@ -167,24 +151,19 @@
 
   @VisibleForTesting
   Iterable<ChangeData> byCommitsOnBranchNotMerged(
-      Repository repo,
-      ReviewDb db,
-      Branch.NameKey branch,
-      Collection<String> hashes,
-      int indexLimit)
-      throws OrmException, IOException {
+      Repository repo, BranchNameKey branch, Collection<String> hashes, int indexLimit)
+      throws IOException {
     if (hashes.size() > indexLimit) {
-      return byCommitsOnBranchNotMergedFromDatabase(repo, db, branch, hashes);
+      return byCommitsOnBranchNotMergedFromDatabase(repo, branch, hashes);
     }
     return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
-      Repository repo, ReviewDb db, Branch.NameKey branch, Collection<String> hashes)
-      throws OrmException, IOException {
+      Repository repo, BranchNameKey branch, Collection<String> hashes) throws IOException {
     Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
     String lastPrefix = null;
-    for (Ref ref : repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES).values()) {
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
       String r = ref.getName();
       if ((lastPrefix != null && r.startsWith(lastPrefix))
           || !hashes.contains(ref.getObjectId().name())) {
@@ -201,22 +180,21 @@
 
     List<ChangeNotes> notes =
         notesFactory.create(
-            db,
-            branch.getParentKey(),
+            branch.project(),
             changeIds,
             cn -> {
               Change c = cn.getChange();
-              return c.getDest().equals(branch) && c.getStatus() != Change.Status.MERGED;
+              return c.getDest().equals(branch) && !c.isMerged();
             });
-    return Lists.transform(notes, n -> changeDataFactory.create(db, n));
+    return Lists.transform(notes, n -> changeDataFactory.create(n));
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromIndex(
-      Branch.NameKey branch, Collection<String> hashes) throws OrmException {
+      BranchNameKey branch, Collection<String> hashes) {
     return query(
         and(
             ref(branch),
-            project(branch.getParentKey()),
+            project(branch.project()),
             not(status(Change.Status.MERGED)),
             or(commits(hashes))));
   }
@@ -229,83 +207,100 @@
     return commits;
   }
 
-  public List<ChangeData> byProjectOpen(Project.NameKey project) throws OrmException {
+  public List<ChangeData> byProjectOpen(Project.NameKey project) {
     return query(and(project(project), open()));
   }
 
-  public List<ChangeData> byTopicOpen(String topic) throws OrmException {
+  public List<ChangeData> byTopicOpen(String topic) {
     return query(and(new ExactTopicPredicate(topic), open()));
   }
 
-  public List<ChangeData> byCommit(ObjectId id) throws OrmException {
+  public List<ChangeData> byCommit(ObjectId id) {
     return byCommit(id.name());
   }
 
-  public List<ChangeData> byCommit(String hash) throws OrmException {
+  public List<ChangeData> byCommit(String hash) {
     return query(commit(hash));
   }
 
-  public List<ChangeData> byProjectCommit(Project.NameKey project, ObjectId id)
-      throws OrmException {
+  public List<ChangeData> byProjectCommit(Project.NameKey project, ObjectId id) {
     return byProjectCommit(project, id.name());
   }
 
-  public List<ChangeData> byProjectCommit(Project.NameKey project, String hash)
-      throws OrmException {
+  public List<ChangeData> byProjectCommit(Project.NameKey project, String hash) {
     return query(and(project(project), commit(hash)));
   }
 
-  public List<ChangeData> byProjectCommits(Project.NameKey project, List<String> hashes)
-      throws OrmException {
+  public List<ChangeData> byProjectCommits(Project.NameKey project, List<String> hashes) {
     int n = indexConfig.maxTerms() - 1;
     checkArgument(hashes.size() <= n, "cannot exceed %s commits", n);
     return query(and(project(project), or(commits(hashes))));
   }
 
-  public List<ChangeData> byBranchCommit(String project, String branch, String hash)
-      throws OrmException {
-    return query(and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash)));
+  public List<ChangeData> byBranchCommit(String project, String branch, String 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> byBranchCommit(BranchNameKey branch, String hash) {
+    return byBranchCommit(branch.project().get(), branch.branch(), hash);
   }
 
-  public List<ChangeData> bySubmissionId(String cs) throws OrmException {
+  public List<ChangeData> byBranchCommitOpen(String project, String branch, String hash) {
+    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) {
     if (Strings.isNullOrEmpty(cs)) {
       return Collections.emptyList();
     }
     return query(new SubmissionIdPredicate(cs));
   }
 
-  private List<ChangeData> byProjectGroups(Project.NameKey project, Collection<String> groups)
-      throws OrmException {
+  private static Predicate<ChangeData> byProjectGroupsPredicate(
+      IndexConfig indexConfig, Project.NameKey project, Collection<String> groups) {
     int n = indexConfig.maxTerms() - 1;
     checkArgument(groups.size() <= n, "cannot exceed %s groups", n);
     List<GroupPredicate> groupPredicates = new ArrayList<>(groups.size());
     for (String g : groups) {
       groupPredicates.add(new GroupPredicate(g));
     }
-    return query(and(project(project), or(groupPredicates)));
+    return and(project(project), or(groupPredicates));
   }
 
-  // Batching via multiple queries requires passing in a Provider since the underlying
-  // QueryProcessor instance is not reusable.
   public static List<ChangeData> byProjectGroups(
       Provider<InternalChangeQuery> queryProvider,
       IndexConfig indexConfig,
       Project.NameKey project,
-      Collection<String> groups)
-      throws OrmException {
+      Collection<String> groups) {
+    // These queries may be complex along multiple dimensions:
+    //  * Many groups per change, if there are very many patch sets. This requires partitioning the
+    //    list of predicates and combining results.
+    //  * Many changes with the same set of groups, if the relation chain is very long. This
+    //    requires querying exhaustively with pagination.
+    // For both cases, we need to invoke the queryProvider multiple times, since each
+    // InternalChangeQuery is single-use.
+
+    Supplier<InternalChangeQuery> querySupplier = () -> queryProvider.get().enforceVisibility(true);
     int batchSize = indexConfig.maxTerms() - 1;
     if (groups.size() <= batchSize) {
-      return queryProvider.get().enforceVisibility(true).byProjectGroups(project, groups);
+      return queryExhaustively(
+          querySupplier, byProjectGroupsPredicate(indexConfig, project, groups));
     }
     Set<Change.Id> seen = new HashSet<>();
     List<ChangeData> result = new ArrayList<>();
     for (List<String> part : Iterables.partition(groups, batchSize)) {
       for (ChangeData cd :
-          queryProvider.get().enforceVisibility(true).byProjectGroups(project, part)) {
+          queryExhaustively(querySupplier, byProjectGroupsPredicate(indexConfig, project, part))) {
         if (!seen.add(cd.getId())) {
           result.add(cd);
         }
diff --git a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
index 7ff5a28..1b3029f 100644
--- a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
@@ -19,14 +19,13 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Set;
 
 public class IsReviewedPredicate extends ChangeIndexPredicate {
-  protected static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
+  protected static final Account.Id NOT_REVIEWED = Account.id(ChangeField.NOT_REVIEWED);
 
   public static Predicate<ChangeData> create() {
     return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
@@ -48,7 +47,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     Set<Account.Id> reviewedBy = cd.reviewedBy();
     return !reviewedBy.isEmpty() ? reviewedBy.contains(id) : id.equals(NOT_REVIEWED);
   }
diff --git a/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java b/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
index 225dc454..27309af 100644
--- a/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class IsUnresolvedPredicate extends IntegerRangeChangePredicate {
   public IsUnresolvedPredicate() throws QueryParseException {
@@ -28,7 +27,7 @@
   }
 
   @Override
-  protected Integer getValueInt(ChangeData changeData) throws OrmException {
+  protected Integer getValueInt(ChangeData changeData) {
     return ChangeField.UNRESOLVED_COMMENT_COUNT.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index 87845d4..6028f2d 100644
--- a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -81,7 +81,7 @@
       }
     }
     if (r.isEmpty()) {
-      return none();
+      return ImmutableList.of(ChangeIndexPredicate.none());
     } else if (checkIsVisible) {
       return ImmutableList.of(or(r), builder.is_visible());
     } else {
@@ -95,12 +95,7 @@
     if (user.isIdentifiedUser()) {
       return user.asIdentifiedUser().state().getProjectWatches().keySet();
     }
-    return Collections.<ProjectWatchKey>emptySet();
-  }
-
-  protected static List<Predicate<ChangeData>> none() {
-    Predicate<ChangeData> any = any();
-    return ImmutableList.of(not(any));
+    return Collections.emptySet();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index f8bd2e3..b5d375c 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -21,12 +21,10 @@
 import com.google.gerrit.index.query.RangeUtil.Range;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.inject.Provider;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
@@ -38,7 +36,6 @@
     protected final ProjectCache projectCache;
     protected final PermissionBackend permissionBackend;
     protected final IdentifiedUser.GenericFactory userFactory;
-    protected final Provider<ReviewDb> dbProvider;
     protected final String value;
     protected final Set<Account.Id> accounts;
     protected final AccountGroup.UUID group;
@@ -47,14 +44,12 @@
         ProjectCache projectCache,
         PermissionBackend permissionBackend,
         IdentifiedUser.GenericFactory userFactory,
-        Provider<ReviewDb> dbProvider,
         String value,
         Set<Account.Id> accounts,
         AccountGroup.UUID group) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.userFactory = userFactory;
-      this.dbProvider = dbProvider;
       this.value = value;
       this.accounts = accounts;
       this.group = group;
@@ -82,8 +77,7 @@
       AccountGroup.UUID group) {
     super(
         predicates(
-            new Args(
-                a.projectCache, a.permissionBackend, a.userFactory, a.db, value, accounts, group)));
+            new Args(a.projectCache, a.permissionBackend, a.userFactory, value, accounts, group)));
     this.value = value;
   }
 
diff --git a/java/com/google/gerrit/server/query/change/MessagePredicate.java b/java/com/google/gerrit/server/query/change/MessagePredicate.java
index 0cfcedb..0bd8c88 100644
--- a/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ b/java/com/google/gerrit/server/query/change/MessagePredicate.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gwtorm.server.OrmException;
 
 /** Predicate to match changes that contains specified text in commit messages body. */
 public class MessagePredicate extends ChangeIndexPredicate {
@@ -31,7 +31,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     try {
       Predicate<ChangeData> p = Predicate.and(new LegacyChangeIdPredicate(object.getId()), this);
       for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
@@ -40,7 +40,7 @@
         }
       }
     } catch (QueryParseException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
 
     return false;
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
index b3e1c27..66255f1 100644
--- a/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -14,17 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.LazyResultSet;
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 public class OrSource extends OrPredicate<ChangeData> implements ChangeDataSource {
@@ -35,27 +39,34 @@
   }
 
   @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    // TODO(spearce) This probably should be more lazy.
-    //
-    List<ChangeData> r = new ArrayList<>();
-    Set<Change.Id> have = new HashSet<>();
-    for (Predicate<ChangeData> p : getChildren()) {
-      if (p instanceof ChangeDataSource) {
-        for (ChangeData cd : ((ChangeDataSource) p).read()) {
-          if (have.add(cd.getId())) {
-            r.add(cd);
-          }
-        }
-      } else {
-        throw new OrmException("No ChangeDataSource: " + p);
-      }
+  public ResultSet<ChangeData> read() {
+    Optional<Predicate<ChangeData>> nonChangeDataSource =
+        getChildren().stream().filter(p -> !(p instanceof ChangeDataSource)).findAny();
+    if (nonChangeDataSource.isPresent()) {
+      throw new StorageException("No ChangeDataSource: " + nonChangeDataSource.get());
     }
-    return new ListResultSet<>(r);
+
+    // ResultSets are lazy. Calling #read here first and then dealing with ResultSets only when
+    // requested allows the index to run asynchronous queries.
+    List<ResultSet<ChangeData>> results =
+        getChildren().stream().map(p -> ((ChangeDataSource) p).read()).collect(toImmutableList());
+    return new LazyResultSet<>(
+        () -> {
+          List<ChangeData> r = new ArrayList<>();
+          Set<Change.Id> have = new HashSet<>();
+          for (ResultSet<ChangeData> resultSet : results) {
+            for (ChangeData result : resultSet) {
+              if (have.add(result.getId())) {
+                r.add(result);
+              }
+            }
+          }
+          return ImmutableList.copyOf(r);
+        });
   }
 
   @Override
-  public ResultSet<FieldBundle> readRaw() throws OrmException {
+  public ResultSet<FieldBundle> readRaw() {
     throw new UnsupportedOperationException("not implemented");
   }
 
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index dc57a9b..08e6f33 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -18,13 +18,13 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.PatchSetAttribute;
@@ -33,8 +33,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.Gson;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.BufferedWriter;
 import java.io.IOException;
@@ -75,7 +75,8 @@
     JSON
   }
 
-  private final ReviewDb db;
+  public static final Gson GSON = new Gson();
+
   private final GitRepositoryManager repoManager;
   private final ChangeQueryBuilder queryBuilder;
   private final ChangeQueryProcessor queryProcessor;
@@ -99,14 +100,12 @@
 
   @Inject
   OutputStreamQuery(
-      ReviewDb db,
       GitRepositoryManager repoManager,
       ChangeQueryBuilder queryBuilder,
       ChangeQueryProcessor queryProcessor,
       EventFactory eventFactory,
       TrackingFooters trackingFooters,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
-    this.db = db;
     this.repoManager = repoManager;
     this.queryBuilder = queryBuilder;
     this.queryProcessor = queryProcessor;
@@ -119,6 +118,10 @@
     queryProcessor.setUserProvidedLimit(n);
   }
 
+  public void setNoLimit(boolean on) {
+    queryProcessor.setNoLimit(on);
+  }
+
   public void setStart(int n) {
     queryProcessor.setStart(n);
   }
@@ -180,6 +183,10 @@
     this.outputFormat = fmt;
   }
 
+  public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+    queryProcessor.setDynamicBean(plugin, dynamicBean);
+  }
+
   public void query(String queryString) throws IOException {
     out =
         new PrintWriter( //
@@ -212,7 +219,7 @@
         stats.moreChanges = results.more();
         stats.runTimeMilliseconds = TimeUtil.nowMs() - stats.runTimeMilliseconds;
         show(stats);
-      } catch (OrmException err) {
+      } catch (StorageException err) {
         logger.atSevere().withCause(err).log("Cannot execute query: %s", queryString);
 
         ErrorMessage m = new ErrorMessage();
@@ -235,9 +242,9 @@
 
   private ChangeAttribute buildChangeAttribute(
       ChangeData d, Map<Project.NameKey, Repository> repos, Map<Project.NameKey, RevWalk> revWalks)
-      throws OrmException, IOException {
+      throws IOException {
     LabelTypes labelTypes = d.getLabelTypes();
-    ChangeAttribute c = eventFactory.asChangeAttribute(db, d.change());
+    ChangeAttribute c = eventFactory.asChangeAttribute(d.change(), d.notes());
     eventFactory.extend(c, d.change());
 
     if (!trackingFooters.isEmpty()) {
@@ -245,7 +252,7 @@
     }
 
     if (includeAllReviewers) {
-      eventFactory.addAllReviewers(db, c, d.notes());
+      eventFactory.addAllReviewers(c, d.notes());
     }
 
     if (includeSubmitRecords) {
@@ -272,7 +279,6 @@
 
     if (includePatchSets) {
       eventFactory.addPatchSets(
-          db,
           rw,
           c,
           d.patchSets(),
@@ -285,7 +291,7 @@
     if (includeCurrentPatchSet) {
       PatchSet current = d.currentPatchSet();
       if (current != null) {
-        c.currentPatchSet = eventFactory.asPatchSetAttribute(db, rw, d.change(), current);
+        c.currentPatchSet = eventFactory.asPatchSetAttribute(rw, d.change(), current);
         eventFactory.addApprovals(c.currentPatchSet, d.currentApprovals(), labelTypes);
 
         if (includeFiles) {
@@ -301,7 +307,6 @@
       eventFactory.addComments(c, d.messages());
       if (includePatchSets) {
         eventFactory.addPatchSets(
-            db,
             rw,
             c,
             d.patchSets(),
@@ -319,7 +324,7 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
-    c.plugins = queryProcessor.create(d);
+    c.plugins = queryProcessor.getAttributesFactory().create(d);
     return c;
   }
 
@@ -352,7 +357,7 @@
         break;
 
       case JSON:
-        out.print(new Gson().toJson(data));
+        out.print(GSON.toJson(data));
         out.print('\n');
         break;
     }
@@ -394,7 +399,7 @@
       // Idention for multi-line text is
       // current depth indetion + length of field + length of ": "
       indent = indent(indent.length() + field.length() + spacesDepthRatio);
-      out.print(((String) value).replaceAll("\n", "\n" + indent).trim());
+      out.print(((String) value).replace("\n", "\n" + indent).trim());
       out.print('\n');
     } else if (value instanceof Long && isDateField(field)) {
       out.print(' ');
diff --git a/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/java/com/google/gerrit/server/query/change/OwnerPredicate.java
index ff494fc..100a66c 100644
--- a/java/com/google/gerrit/server/query/change/OwnerPredicate.java
+++ b/java/com/google/gerrit/server/query/change/OwnerPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class OwnerPredicate extends ChangeIndexPredicate {
   protected final Account.Id id;
@@ -32,7 +31,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     return change != null && id.equals(change.getOwner());
   }
diff --git a/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
index c48bdd5..41b3204 100644
--- a/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
+++ b/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gwtorm.server.OrmException;
 
 public class OwnerinPredicate extends PostFilterPredicate<ChangeData> {
   protected final IdentifiedUser.GenericFactory userFactory;
@@ -31,7 +30,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     final Change change = object.change();
     if (change == null) {
       return false;
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 17d6448..ec411ee 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -40,7 +40,7 @@
 
   protected static List<Predicate<ChangeData>> predicates(
       ProjectCache projectCache, ChildProjects childProjects, String value) {
-    ProjectState projectState = projectCache.get(new Project.NameKey(value));
+    ProjectState projectState = projectCache.get(Project.nameKey(value));
     if (projectState == null) {
       return Collections.emptyList();
     }
diff --git a/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
deleted file mode 100644
index a795025..0000000
--- a/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import java.util.List;
-
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
-}
diff --git a/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
index 09a46a4..c1cc999 100644
--- a/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class ProjectPredicate extends ChangeIndexPredicate {
   public ProjectPredicate(String id) {
@@ -25,17 +24,17 @@
   }
 
   protected Project.NameKey getValueKey() {
-    return new Project.NameKey(getValue());
+    return Project.nameKey(getValue());
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     if (change == null) {
       return false;
     }
 
-    Project.NameKey p = change.getDest().getParentKey();
+    Project.NameKey p = change.getDest().project();
     return p.equals(getValueKey());
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
index 28b1302..b337336 100644
--- a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class ProjectPrefixPredicate extends ChangeIndexPredicate {
   public ProjectPrefixPredicate(String prefix) {
@@ -24,9 +23,9 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change c = object.change();
-    return c != null && c.getDest().getParentKey().get().startsWith(getValue());
+    return c != null && c.getDest().project().get().startsWith(getValue());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RefPredicate.java b/java/com/google/gerrit/server/query/change/RefPredicate.java
index c9314e4..10eea71 100644
--- a/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RefPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class RefPredicate extends ChangeIndexPredicate {
   public RefPredicate(String ref) {
@@ -24,12 +23,12 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     if (change == null) {
       return false;
     }
-    return getValue().equals(change.getDest().get());
+    return getValue().equals(change.getDest().branch());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
new file mode 100644
index 0000000..1787c76
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+public class RegexDirectoryPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
+
+  public RegexDirectoryPredicate(String re) {
+    super(ChangeField.DIRECTORY, re);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return ChangeField.getDirectories(cd).stream().anyMatch(pattern::run);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 3764a98..4c3c04c 100644
--- a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -15,10 +15,7 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.util.RegexListSearcher;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.List;
+import com.google.gerrit.server.ioutil.RegexListSearcher;
 
 public class RegexPathPredicate extends ChangeRegexPredicate {
   public RegexPathPredicate(String re) {
@@ -26,14 +23,11 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
-    List<String> files;
-    try {
-      files = object.currentFilePaths();
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-    return RegexListSearcher.ofStrings(getValue()).search(files).findAny().isPresent();
+  public boolean match(ChangeData object) {
+    return RegexListSearcher.ofStrings(getValue())
+        .search(object.currentFilePaths())
+        .findAny()
+        .isPresent();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index 1efc77d..a859b32 100644
--- a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
@@ -39,13 +38,13 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     if (change == null) {
       return false;
     }
 
-    Project.NameKey p = change.getDest().getParentKey();
+    Project.NameKey p = change.getDest().project();
     return pattern.run(p.get());
   }
 
diff --git a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index 92abafb..f999cc4 100644
--- a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
@@ -38,12 +37,12 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     if (change == null) {
       return false;
     }
-    return pattern.run(change.getDest().get());
+    return pattern.run(change.getDest().branch());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java b/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
index 2b58c88..0441afa 100644
--- a/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.server.index.change.ChangeField.EXACT_TOPIC;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.OrmException;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
@@ -39,7 +38,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     if (change == null || change.getTopic() == null) {
       return false;
diff --git a/java/com/google/gerrit/server/query/change/RevertOfPredicate.java b/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
index 7f4ade0..eea1b1e 100644
--- a/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class RevertOfPredicate extends ChangeIndexPredicate {
   public RevertOfPredicate(String revertOf) {
@@ -23,7 +22,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     if (cd.change().getRevertOf() == null) {
       return false;
     }
diff --git a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
index f4e979c..070f800 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -17,10 +17,9 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gwtorm.server.OrmException;
 
 class ReviewerByEmailPredicate extends ChangeIndexPredicate {
 
@@ -43,7 +42,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     return cd.reviewersByEmail().asTable().get(state, adr) != null;
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 5364a66..19104d3 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -15,15 +15,11 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-import com.google.gwtorm.server.OrmException;
-import java.util.stream.Stream;
 
 public class ReviewerPredicate extends ChangeIndexPredicate {
   protected static Predicate<ChangeData> forState(Account.Id id, ReviewerStateInternal state) {
@@ -31,31 +27,14 @@
     return new ReviewerPredicate(state, id);
   }
 
-  protected static Predicate<ChangeData> reviewer(Arguments args, Account.Id id) {
-    if (args.notesMigration.readChanges()) {
-      // With NoteDb, Reviewer/CC are clearly distinct states, so only choose reviewer.
-      return new ReviewerPredicate(ReviewerStateInternal.REVIEWER, id);
-    }
-    // Without NoteDb, Reviewer/CC are a bit unpredictable; maintain the old behavior of matching
-    // any reviewer state.
-    return anyReviewerState(id);
+  protected static Predicate<ChangeData> reviewer(Account.Id id) {
+    return new ReviewerPredicate(ReviewerStateInternal.REVIEWER, id);
   }
 
   protected static Predicate<ChangeData> cc(Account.Id id) {
-    // As noted above, CC is nebulous without NoteDb, but it certainly doesn't make sense to return
-    // Reviewers for cc:foo. Most likely this will just not match anything, but let the index sort
-    // it out.
     return new ReviewerPredicate(ReviewerStateInternal.CC, id);
   }
 
-  protected static Predicate<ChangeData> anyReviewerState(Account.Id id) {
-    return Predicate.or(
-        Stream.of(ReviewerStateInternal.values())
-            .filter(s -> s != ReviewerStateInternal.REMOVED)
-            .map(s -> new ReviewerPredicate(s, id))
-            .collect(toList()));
-  }
-
   protected final ReviewerStateInternal state;
   protected final Account.Id id;
 
@@ -70,7 +49,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     return cd.reviewers().asTable().get(state, id) != null;
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
index a0aa8b5..542a357 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gwtorm.server.OrmException;
 
 public class ReviewerinPredicate extends PostFilterPredicate<ChangeData> {
   protected final IdentifiedUser.GenericFactory userFactory;
@@ -36,7 +35,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     for (Account.Id accountId : object.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
       IdentifiedUser reviewer = userFactory.create(accountId);
       if (reviewer.getEffectiveGroups().contains(uuid)) {
diff --git a/java/com/google/gerrit/server/query/change/StarPredicate.java b/java/com/google/gerrit/server/query/change/StarPredicate.java
index 12d4753..6c5fd78 100644
--- a/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ b/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class StarPredicate extends ChangeIndexPredicate {
   protected final Account.Id accountId;
@@ -30,7 +29,7 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) {
     return cd.stars().get(accountId).contains(label);
   }
 
diff --git a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
index 5fdeb68..0995a59 100644
--- a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class SubmissionIdPredicate extends ChangeIndexPredicate {
   public SubmissionIdPredicate(String changeSet) {
@@ -24,7 +23,7 @@
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) {
     Change change = object.change();
     if (change == null) {
       return false;
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index 17034df..e59ae43 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 import java.util.Set;
 
 public class SubmitRecordPredicate extends ChangeIndexPredicate {
@@ -31,8 +30,7 @@
       return new SubmitRecordPredicate(status.name() + ',' + lowerLabel);
     }
     return Predicate.or(
-        accounts
-            .stream()
+        accounts.stream()
             .map(a -> new SubmitRecordPredicate(status.name() + ',' + lowerLabel + ',' + a.get()))
             .collect(toList()));
   }
@@ -42,7 +40,7 @@
   }
 
   @Override
-  public boolean match(ChangeData in) throws OrmException {
+  public boolean match(ChangeData in) {
     return ChangeField.formatSubmitRecordValues(in).contains(getValue());
   }
 
diff --git a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index df78315..c507f1c 100644
--- a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
 
 public class SubmittablePredicate extends ChangeIndexPredicate {
   protected final SubmitRecord.Status status;
@@ -27,9 +26,8 @@
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
-    return cd.submitRecords(ChangeField.SUBMIT_RULE_OPTIONS_STRICT)
-        .stream()
+  public boolean match(ChangeData cd) {
+    return cd.submitRecords(ChangeField.SUBMIT_RULE_OPTIONS_STRICT).stream()
         .anyMatch(r -> r.status == status);
   }
 
diff --git a/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
index 4f751c5..622fa2c 100644
--- a/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ b/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
@@ -14,26 +14,16 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
 
 public class TrackingIdPredicate extends ChangeIndexPredicate {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public TrackingIdPredicate(String trackingId) {
     super(ChangeField.TR, trackingId);
   }
 
   @Override
-  public boolean match(ChangeData cd) throws OrmException {
-    try {
-      return cd.trackingFooters().containsValue(getValue());
-    } catch (IOException e) {
-      logger.atWarning().withCause(e).log("Cannot extract footers from %s", cd.getId());
-    }
-    return false;
+  public boolean match(ChangeData cd) {
+    return cd.trackingFooters().containsValue(getValue());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
index ffa59c2..8248bf5 100644
--- a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -14,16 +14,18 @@
 
 package com.google.gerrit.server.query.group;
 
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
-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;
 
@@ -35,9 +37,13 @@
   }
 
   @Override
-  public boolean match(InternalGroup group) throws OrmException {
+  public boolean match(InternalGroup group) {
     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/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index 296dc17..2e9bc4b 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -24,14 +24,15 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
@@ -40,7 +41,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Parses a query string meant to be applied to group objects. */
-public class GroupQueryBuilder extends QueryBuilder<InternalGroup> {
+public class GroupQueryBuilder extends QueryBuilder<InternalGroup, GroupQueryBuilder> {
   public static final String FIELD_UUID = "uuid";
   public static final String FIELD_DESCRIPTION = "description";
   public static final String FIELD_INNAME = "inname";
@@ -68,13 +69,13 @@
 
   @Inject
   GroupQueryBuilder(Arguments args) {
-    super(mydef);
+    super(mydef, null);
     this.args = args;
   }
 
   @Operator
   public Predicate<InternalGroup> uuid(String uuid) {
-    return GroupPredicates.uuid(new AccountGroup.UUID(uuid));
+    return GroupPredicates.uuid(AccountGroup.uuid(uuid));
   }
 
   @Operator
@@ -133,7 +134,7 @@
 
   @Operator
   public Predicate<InternalGroup> member(String query)
-      throws QueryParseException, OrmException, ConfigInvalidException, IOException {
+      throws QueryParseException, ConfigInvalidException, IOException {
     Set<Account.Id> accounts = parseAccount(query);
     List<Predicate<InternalGroup>> predicates =
         accounts.stream().map(GroupPredicates::member).collect(toImmutableList());
@@ -156,16 +157,19 @@
   }
 
   private Set<Account.Id> parseAccount(String nameOrEmail)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    Set<Account.Id> foundAccounts = args.accountResolver.findAll(nameOrEmail);
-    if (foundAccounts.isEmpty()) {
-      throw error("User " + nameOrEmail + " not found");
+      throws QueryParseException, IOException, ConfigInvalidException {
+    try {
+      return args.accountResolver.resolve(nameOrEmail).asNonEmptyIdSet();
+    } catch (UnresolvableAccountException e) {
+      if (e.isSelf()) {
+        throw new QueryRequiresAuthException(e.getMessage(), e);
+      }
+      throw new QueryParseException(e.getMessage(), e);
     }
-    return foundAccounts;
   }
 
   private AccountGroup.UUID parseGroup(String groupNameOrUuid) throws QueryParseException {
-    Optional<InternalGroup> group = args.groupCache.get(new AccountGroup.UUID(groupNameOrUuid));
+    Optional<InternalGroup> group = args.groupCache.get(AccountGroup.uuid(groupNameOrUuid));
     if (group.isPresent()) {
       return group.get().getGroupUUID();
     }
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index 8554ecf..86c574d 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -76,4 +76,9 @@
     return new AndSource<>(
         pred, new GroupIsVisibleToPredicate(groupControlFactory, userProvider.get()), start);
   }
+
+  @Override
+  protected String formatForLogging(InternalGroup internalGroup) {
+    return internalGroup.getGroupUUID().get();
+  }
 }
diff --git a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
index d9808f2..5749809 100644
--- a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
+++ b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.List;
 import java.util.Optional;
@@ -37,7 +36,7 @@
  * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
  * holding on to a single instance.
  */
-public class InternalGroupQuery extends InternalQuery<InternalGroup> {
+public class InternalGroupQuery extends InternalQuery<InternalGroup, InternalGroupQuery> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   @Inject
@@ -46,24 +45,24 @@
     super(queryProcessor, indexes, indexConfig);
   }
 
-  public Optional<InternalGroup> byName(AccountGroup.NameKey groupName) throws OrmException {
+  public Optional<InternalGroup> byName(AccountGroup.NameKey groupName) {
     return getOnlyGroup(GroupPredicates.name(groupName.get()), "group name '" + groupName + "'");
   }
 
-  public Optional<InternalGroup> byId(AccountGroup.Id groupId) throws OrmException {
+  public Optional<InternalGroup> byId(AccountGroup.Id groupId) {
     return getOnlyGroup(GroupPredicates.id(groupId), "group id '" + groupId + "'");
   }
 
-  public List<InternalGroup> byMember(Account.Id memberId) throws OrmException {
+  public List<InternalGroup> byMember(Account.Id memberId) {
     return query(GroupPredicates.member(memberId));
   }
 
-  public List<InternalGroup> bySubgroup(AccountGroup.UUID subgroupId) throws OrmException {
+  public List<InternalGroup> bySubgroup(AccountGroup.UUID subgroupId) {
     return query(GroupPredicates.subgroup(subgroupId));
   }
 
   private Optional<InternalGroup> getOnlyGroup(
-      Predicate<InternalGroup> predicate, String groupDescription) throws OrmException {
+      Predicate<InternalGroup> predicate, String groupDescription) {
     List<InternalGroup> groups = query(predicate);
     if (groups.isEmpty()) {
       return Optional.empty();
diff --git a/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
index 24209c7..2c84b9a 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;
@@ -21,9 +22,10 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
-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;
 
@@ -34,15 +36,21 @@
   }
 
   @Override
-  public boolean match(ProjectData pd) throws OrmException {
+  public boolean match(ProjectData pd) {
     if (!pd.getProject().getState().permitsRead()) {
+      logger.atFine().log("Filter out non-readable project: %s", pd);
       return false;
     }
 
-    return permissionBackend
-        .user(user)
-        .project(pd.getProject().getNameKey())
-        .testOrFalse(ProjectPermission.READ);
+    boolean canSee =
+        permissionBackend
+            .user(user)
+            .project(pd.getProject().getNameKey())
+            .testOrFalse(ProjectPermission.ACCESS);
+    if (!canSee) {
+      logger.atFine().log("Filter out non-visible project: %s", pd);
+    }
+    return canSee;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index b4f56d4..5f13236 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.project;
 
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.index.project.ProjectPredicate;
@@ -26,6 +27,10 @@
     return new ProjectPredicate(ProjectField.NAME, nameKey.get());
   }
 
+  public static Predicate<ProjectData> parent(Project.NameKey parentNameKey) {
+    return new ProjectPredicate(ProjectField.PARENT_NAME, parentNameKey.get());
+  }
+
   public static Predicate<ProjectData> inname(String name) {
     return new ProjectPredicate(ProjectField.NAME_PART, name.toLowerCase(Locale.US));
   }
@@ -34,5 +39,9 @@
     return new ProjectPredicate(ProjectField.DESCRIPTION, description);
   }
 
+  public static Predicate<ProjectData> state(ProjectState state) {
+    return new ProjectPredicate(ProjectField.STATE, state.name());
+  }
+
   private ProjectPredicates() {}
 }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index be7ea22..6637c6f 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
@@ -27,7 +28,7 @@
 import java.util.List;
 
 /** Parses a query string meant to be applied to project objects. */
-public class ProjectQueryBuilder extends QueryBuilder<ProjectData> {
+public class ProjectQueryBuilder extends QueryBuilder<ProjectData, ProjectQueryBuilder> {
   public static final String FIELD_LIMIT = "limit";
 
   private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilder> mydef =
@@ -35,12 +36,17 @@
 
   @Inject
   ProjectQueryBuilder() {
-    super(mydef);
+    super(mydef, null);
   }
 
   @Operator
   public Predicate<ProjectData> name(String name) {
-    return ProjectPredicates.name(new Project.NameKey(name));
+    return ProjectPredicates.name(Project.nameKey(name));
+  }
+
+  @Operator
+  public Predicate<ProjectData> parent(String parentName) {
+    return ProjectPredicates.parent(Project.nameKey(parentName));
   }
 
   @Operator
@@ -60,6 +66,23 @@
     return ProjectPredicates.description(description);
   }
 
+  @Operator
+  public Predicate<ProjectData> state(String state) throws QueryParseException {
+    if (Strings.isNullOrEmpty(state)) {
+      throw error("state operator requires a value");
+    }
+    ProjectState parsedState;
+    try {
+      parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase());
+    } catch (IllegalArgumentException e) {
+      throw error("state operator must be either 'active' or 'read-only'");
+    }
+    if (parsedState == ProjectState.HIDDEN) {
+      throw error("state operator must be either 'active' or 'read-only'");
+    }
+    return ProjectPredicates.state(parsedState);
+  }
+
   @Override
   protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
     // Adapt the capacity of this list when adding more default predicates.
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
index 79b7943..66eab7b 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
@@ -76,4 +76,9 @@
     return new AndSource<>(
         pred, new ProjectIsVisibleToPredicate(permissionBackend, userProvider.get()), start);
   }
+
+  @Override
+  protected String formatForLogging(ProjectData projectData) {
+    return projectData.getProject().getName();
+  }
 }
diff --git a/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
new file mode 100644
index 0000000..d39e55c
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
@@ -0,0 +1,182 @@
+// 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.quota;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
+import com.google.gerrit.server.quota.QuotaResponse.Aggregated;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+public class DefaultQuotaBackend implements QuotaBackend {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Provider<CurrentUser> userProvider;
+  private final PluginSetContext<QuotaEnforcer> quotaEnforcers;
+
+  @Inject
+  DefaultQuotaBackend(
+      Provider<CurrentUser> userProvider, PluginSetContext<QuotaEnforcer> quotaEnforcers) {
+    this.userProvider = userProvider;
+    this.quotaEnforcers = quotaEnforcers;
+  }
+
+  @Override
+  public WithUser currentUser() {
+    return new WithUser(quotaEnforcers, userProvider.get());
+  }
+
+  @Override
+  public WithUser user(CurrentUser user) {
+    return new WithUser(quotaEnforcers, user);
+  }
+
+  private static QuotaResponse.Aggregated request(
+      PluginSetContext<QuotaEnforcer> quotaEnforcers,
+      String quotaGroup,
+      QuotaRequestContext requestContext,
+      long numTokens,
+      boolean deduct) {
+    checkState(numTokens > 0, "numTokens must be a positive, non-zero long");
+
+    // PluginSets can change their content when plugins (de-)register. Copy the currently registered
+    // plugins so that we can iterate twice on a stable list.
+    List<PluginSetEntryContext<QuotaEnforcer>> enforcers = ImmutableList.copyOf(quotaEnforcers);
+    List<QuotaResponse> responses = new ArrayList<>(enforcers.size());
+    for (PluginSetEntryContext<QuotaEnforcer> enforcer : enforcers) {
+      try {
+        if (deduct) {
+          responses.add(enforcer.call(p -> p.requestTokens(quotaGroup, requestContext, numTokens)));
+        } else {
+          responses.add(enforcer.call(p -> p.dryRun(quotaGroup, requestContext, numTokens)));
+        }
+      } catch (RuntimeException e) {
+        // Roll back the quota request for all enforcers that deducted the quota. Rethrow the
+        // exception to adhere to the API contract.
+        if (deduct) {
+          refillAfterErrorOrException(enforcers, responses, quotaGroup, requestContext, numTokens);
+        }
+        throw e;
+      }
+    }
+
+    if (deduct && responses.stream().anyMatch(r -> r.status().isError())) {
+      // Roll back the quota request for all enforcers that deducted the quota (= the request
+      // succeeded). Don't touch failed enforcers as the interface contract said that failed
+      // requests should not be deducted.
+      refillAfterErrorOrException(enforcers, responses, quotaGroup, requestContext, numTokens);
+    }
+
+    logger.atFine().log(
+        "Quota request for %s with %s (deduction=%s) for %s token returned %s",
+        quotaGroup,
+        requestContext,
+        deduct ? "(deduction=yes)" : "(deduction=no)",
+        numTokens,
+        responses);
+    return QuotaResponse.Aggregated.create(ImmutableList.copyOf(responses));
+  }
+
+  private static QuotaResponse.Aggregated availableTokens(
+      PluginSetContext<QuotaEnforcer> quotaEnforcers,
+      String quotaGroup,
+      QuotaRequestContext requestContext) {
+    // PluginSets can change their content when plugins (de-)register. Copy the currently registered
+    // plugins so that we can iterate twice on a stable list.
+    List<PluginSetEntryContext<QuotaEnforcer>> enforcers = ImmutableList.copyOf(quotaEnforcers);
+    List<QuotaResponse> responses = new ArrayList<>(enforcers.size());
+    for (PluginSetEntryContext<QuotaEnforcer> enforcer : enforcers) {
+      responses.add(enforcer.call(p -> p.availableTokens(quotaGroup, requestContext)));
+    }
+    return QuotaResponse.Aggregated.create(responses);
+  }
+
+  private static void refillAfterErrorOrException(
+      List<PluginSetEntryContext<QuotaEnforcer>> enforcers,
+      List<QuotaResponse> collectedResponses,
+      String quotaGroup,
+      QuotaRequestContext requestContext,
+      long numTokens) {
+    for (int i = 0; i < collectedResponses.size(); i++) {
+      if (collectedResponses.get(i).status().isOk()) {
+        enforcers.get(i).run(p -> p.refill(quotaGroup, requestContext, numTokens));
+      }
+    }
+  }
+
+  static class WithUser extends WithResource implements QuotaBackend.WithUser {
+    WithUser(PluginSetContext<QuotaEnforcer> quotaEnforcers, CurrentUser user) {
+      super(quotaEnforcers, QuotaRequestContext.builder().user(user).build());
+    }
+
+    @Override
+    public QuotaBackend.WithResource account(Account.Id account) {
+      QuotaRequestContext ctx = requestContext.toBuilder().account(account).build();
+      return new WithResource(quotaEnforcers, ctx);
+    }
+
+    @Override
+    public QuotaBackend.WithResource project(Project.NameKey project) {
+      QuotaRequestContext ctx = requestContext.toBuilder().project(project).build();
+      return new WithResource(quotaEnforcers, ctx);
+    }
+
+    @Override
+    public QuotaBackend.WithResource change(Change.Id change, Project.NameKey project) {
+      QuotaRequestContext ctx = requestContext.toBuilder().change(change).project(project).build();
+      return new WithResource(quotaEnforcers, ctx);
+    }
+  }
+
+  static class WithResource implements QuotaBackend.WithResource {
+    protected final QuotaRequestContext requestContext;
+    protected final PluginSetContext<QuotaEnforcer> quotaEnforcers;
+
+    private WithResource(
+        PluginSetContext<QuotaEnforcer> quotaEnforcers, QuotaRequestContext quotaRequestContext) {
+      this.quotaEnforcers = quotaEnforcers;
+      this.requestContext = quotaRequestContext;
+    }
+
+    @Override
+    public QuotaResponse.Aggregated requestTokens(String quotaGroup, long numTokens) {
+      return DefaultQuotaBackend.request(
+          quotaEnforcers, quotaGroup, requestContext, numTokens, true);
+    }
+
+    @Override
+    public QuotaResponse.Aggregated dryRun(String quotaGroup, long numTokens) {
+      return DefaultQuotaBackend.request(
+          quotaEnforcers, quotaGroup, requestContext, numTokens, false);
+    }
+
+    @Override
+    public Aggregated availableTokens(String quotaGroup) {
+      return DefaultQuotaBackend.availableTokens(quotaEnforcers, quotaGroup, requestContext);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaBackend.java b/java/com/google/gerrit/server/quota/QuotaBackend.java
new file mode 100644
index 0000000..11ce61f
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaBackend.java
@@ -0,0 +1,103 @@
+// 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.quota;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.inject.ImplementedBy;
+
+/**
+ * Backend interface to perform quota requests on. By default, this interface is backed by {@link
+ * DefaultQuotaBackend} which calls all plugins that implement {@link QuotaEnforcer}. A different
+ * implementation might be bound in tests. Plugins are not supposed to implement this interface, but
+ * bind a {@link QuotaEnforcer} implementation instead.
+ *
+ * <p>All quota requests require a quota group and a user. Enriching them with a top-level entity
+ * {@code Change, Project, Account} is optional but should be done if the request is targeted.
+ *
+ * <p>Example usage:
+ *
+ * <pre>
+ *   quotaBackend.currentUser().project(projectName).requestToken("/projects/create").throwOnError();
+ *   quotaBackend.user(user).requestToken("/restapi/config/put").throwOnError();
+ *   QuotaResponse.Aggregated result = quotaBackend.currentUser().account(accountId).requestToken("/restapi/accounts/emails/validate");
+ *   QuotaResponse.Aggregated result = quotaBackend.currentUser().project(projectName).requestTokens("/projects/git/upload", numBytesInPush);
+ * </pre>
+ *
+ * <p>All quota groups must be documented in {@code quota.txt} and detail the metadata that is
+ * provided (i.e. the parameters used to scope down the quota request).
+ */
+@ImplementedBy(DefaultQuotaBackend.class)
+public interface QuotaBackend {
+  /** Constructs a request for the current user. */
+  WithUser currentUser();
+
+  /**
+   * See {@link #currentUser()}. Use this method only if you can't guarantee that the request is for
+   * the current user (e.g. impersonation).
+   */
+  WithUser user(CurrentUser user);
+
+  /**
+   * An interface capable of issuing quota requests. Scope can be futher reduced by providing a
+   * top-level entity.
+   */
+  interface WithUser extends WithResource {
+    /** Scope the request down to an account. */
+    WithResource account(Account.Id account);
+
+    /** Scope the request down to a project. */
+    WithResource project(Project.NameKey project);
+
+    /** Scope the request down to a change. */
+    WithResource change(Change.Id change, Project.NameKey project);
+  }
+
+  /** An interface capable of issuing quota requests. */
+  interface WithResource {
+    /** Issues a single quota request for {@code 1} token. */
+    default QuotaResponse.Aggregated requestToken(String quotaGroup) {
+      return requestTokens(quotaGroup, 1);
+    }
+
+    /** Issues a single quota request for {@code numTokens} tokens. */
+    QuotaResponse.Aggregated requestTokens(String quotaGroup, long numTokens);
+
+    /**
+     * Issues a single quota request for {@code numTokens} tokens but signals the implementations
+     * not to deduct any quota yet. Can be used to do pre-flight requests where necessary
+     */
+    QuotaResponse.Aggregated dryRun(String quotaGroup, long tokens);
+
+    /**
+     * Requests a minimum number of tokens available in implementations. This is a pre-flight check
+     * for the exceptional case when the requested number of tokens is not known in advance but
+     * boundary can be specified. For instance, when the commit is received its size is not known
+     * until the transfer happens however one can specify how many bytes can be accepted to meet the
+     * repository size quota.
+     *
+     * <p>By definition, this is not an allocating request, therefore, it should be followed by the
+     * call to {@link #requestTokens(String, long)} when the size gets determined so that quota
+     * could be properly adjusted. It is in developer discretion to ensure that it gets called.
+     * There might be a case when particular quota gets temporarily overbooked when multiple
+     * requests are performed but the following calls to {@link #requestTokens(String, long)} will
+     * fail at the moment when a quota is exhausted. It is not a subject of quota backend to reclaim
+     * tokens that were used due to overbooking.
+     */
+    QuotaResponse.Aggregated availableTokens(String quotaGroup);
+  }
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaEnforcer.java b/java/com/google/gerrit/server/quota/QuotaEnforcer.java
new file mode 100644
index 0000000..4c1de14
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaEnforcer.java
@@ -0,0 +1,85 @@
+// 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.quota;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Allows plugins to enforce different types of quota.
+ *
+ * <p>Enforcing quotas can be helpful in many scenarios. For example:
+ *
+ * <ul>
+ *   <li>Reducing the number of QPS a user can send to Gerrit on the REST API
+ *   <li>Limiting the size of a repository (project)
+ *   <li>Limiting the number of changes in a repository
+ *   <li>Limiting the number of actions that have the potential for spam, abuse or flooding if not
+ *       limited
+ * </ul>
+ *
+ * This endpoint gives plugins the capability to enforce any of these limits. The server will ask
+ * all plugins that registered this endpoint and collect all results. In case {@link
+ * #requestTokens(String, QuotaRequestContext, long)} was called and one or more plugins returned an
+ * erroneous result, the server will call {@link #refill(String, QuotaRequestContext, long)} on all
+ * plugins with the same parameters. Plugins that deducted tokens in the {@link
+ * #requestTokens(String, QuotaRequestContext, long)} call can refill them so that users don't get
+ * charged any quota for failed requests.
+ *
+ * <p>Not all implementations will need to deduct quota on {@link #requestTokens(String,
+ * QuotaRequestContext, long)}}. Implementations that work on top of instance-attributes, such as
+ * the number of projects per instance can choose not to keep any state and always check how many
+ * existing projects there are and if adding the inquired number would exceed the limit. In this
+ * case, {@link #requestTokens(String, QuotaRequestContext, long)} and {@link #dryRun(String,
+ * QuotaRequestContext, long)} share the same implementation and {@link #refill(String,
+ * QuotaRequestContext, long)} is a no-op.
+ */
+@ExtensionPoint
+public interface QuotaEnforcer {
+  /**
+   * Checks if there is at least {@code numTokens} quota to fulfil the request. Bucket-based
+   * implementations can deduct the inquired number of tokens from the bucket.
+   */
+  QuotaResponse requestTokens(String quotaGroup, QuotaRequestContext ctx, long numTokens);
+
+  /**
+   * Checks if there is at least {@code numTokens} quota to fulfil the request. This is a pre-flight
+   * request, implementations should not deduct tokens from a bucket, yet.
+   */
+  QuotaResponse dryRun(String quotaGroup, QuotaRequestContext ctx, long numTokens);
+
+  /**
+   * Returns available tokens that can be later requested.
+   *
+   * <p>This is used as a pre-flight check for the exceptional case when the requested number of
+   * tokens is not known in advance. Implementation should not deduct tokens from a bucket. It
+   * should be followed by a call to {@link #requestTokens(String, QuotaRequestContext, long)} with
+   * the number of tokens that were eventually used. It is in {@link QuotaBackend} callers
+   * discretion to ensure that {@link
+   * com.google.gerrit.server.quota.QuotaBackend.WithResource#requestTokens(String, long)} is
+   * called.
+   */
+  QuotaResponse availableTokens(String quotaGroup, QuotaRequestContext ctx);
+
+  /**
+   * A previously requested and deducted quota has to be refilled (if possible) because the request
+   * failed other quota checks. Implementations can choose to leave this a no-op in case they are
+   * the first line of defence (e.g. always deduct HTTP quota even if the request failed for other
+   * quota issues so that the user gets throttled).
+   *
+   * <p>Will not be called if the {@link #requestTokens(String, QuotaRequestContext, long)} call
+   * returned {@link QuotaResponse.Status#NO_OP}.
+   */
+  void refill(String quotaGroup, QuotaRequestContext ctx, long numTokens);
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaException.java b/java/com/google/gerrit/server/quota/QuotaException.java
new file mode 100644
index 0000000..be13c0ec0
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaException.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.quota;
+
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+/**
+ * Exception that was encountered while checking if there is sufficient quota to fulfil the request.
+ * Can be propagated directly to the REST API.
+ */
+public class QuotaException extends RestApiException {
+  private static final long serialVersionUID = 1L;
+
+  public QuotaException(String reason) {
+    super(reason);
+  }
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaGroupDefinitions.java b/java/com/google/gerrit/server/quota/QuotaGroupDefinitions.java
new file mode 100644
index 0000000..5110538
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaGroupDefinitions.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.quota;
+
+public class QuotaGroupDefinitions {
+  /**
+   * Definition of repository size quota group. {@link QuotaEnforcer} implementations for repository
+   * size quota have to act on requests with this group name.
+   */
+  public static final String REPOSITORY_SIZE_GROUP = "/repository:size";
+
+  private QuotaGroupDefinitions() {}
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaRequestContext.java b/java/com/google/gerrit/server/quota/QuotaRequestContext.java
new file mode 100644
index 0000000..90b501c
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaRequestContext.java
@@ -0,0 +1,54 @@
+// 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.quota;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import java.util.Optional;
+
+@AutoValue
+public abstract class QuotaRequestContext {
+
+  public static Builder builder() {
+    return new AutoValue_QuotaRequestContext.Builder().user(new AnonymousUser());
+  }
+
+  public abstract CurrentUser user();
+
+  public abstract Optional<Project.NameKey> project();
+
+  public abstract Optional<Change.Id> change();
+
+  public abstract Optional<Account.Id> account();
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract QuotaRequestContext.Builder user(CurrentUser user);
+
+    public abstract QuotaRequestContext.Builder account(Account.Id account);
+
+    public abstract QuotaRequestContext.Builder project(Project.NameKey project);
+
+    public abstract QuotaRequestContext.Builder change(Change.Id change);
+
+    public abstract QuotaRequestContext build();
+  }
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaResponse.java b/java/com/google/gerrit/server/quota/QuotaResponse.java
new file mode 100644
index 0000000..940f731
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaResponse.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.quota;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.stream.Collectors;
+
+@AutoValue
+public abstract class QuotaResponse {
+  public enum Status {
+    /** The quota requests succeeded. */
+    OK,
+
+    /**
+     * The quota succeeded, but was a no-op because the plugin does not enforce this quota group
+     * (equivalent to OK, but relevant for debugging).
+     */
+    NO_OP,
+
+    /**
+     * The requested quota could not be allocated. This status code is not used to indicate
+     * processing failures as these are propagated as {@code RuntimeException}s.
+     */
+    ERROR;
+
+    public boolean isOk() {
+      return this == OK;
+    }
+
+    public boolean isError() {
+      return this == ERROR;
+    }
+  }
+
+  public static QuotaResponse ok() {
+    return new AutoValue_QuotaResponse.Builder().status(Status.OK).build();
+  }
+
+  public static QuotaResponse ok(long tokens) {
+    return new AutoValue_QuotaResponse.Builder().status(Status.OK).availableTokens(tokens).build();
+  }
+
+  public static QuotaResponse noOp() {
+    return new AutoValue_QuotaResponse.Builder().status(Status.NO_OP).build();
+  }
+
+  public static QuotaResponse error(String message) {
+    return new AutoValue_QuotaResponse.Builder().status(Status.ERROR).message(message).build();
+  }
+
+  public abstract Status status();
+
+  public abstract Optional<Long> availableTokens();
+
+  public abstract Optional<String> message();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract QuotaResponse.Builder status(Status status);
+
+    public abstract QuotaResponse.Builder availableTokens(Long tokens);
+
+    public abstract QuotaResponse.Builder message(String message);
+
+    public abstract QuotaResponse build();
+  }
+
+  @AutoValue
+  public abstract static class Aggregated {
+    public static Aggregated create(Collection<QuotaResponse> responses) {
+      return new AutoValue_QuotaResponse_Aggregated(ImmutableList.copyOf(responses));
+    }
+
+    protected abstract ImmutableList<QuotaResponse> responses();
+
+    public boolean hasError() {
+      return responses().stream().anyMatch(r -> r.status().isError());
+    }
+
+    public ImmutableList<QuotaResponse> all() {
+      return responses();
+    }
+
+    public ImmutableList<QuotaResponse> ok() {
+      return responses().stream().filter(r -> r.status().isOk()).collect(toImmutableList());
+    }
+
+    public OptionalLong availableTokens() {
+      return responses().stream()
+          .filter(r -> r.status().isOk() && r.availableTokens().isPresent())
+          .mapToLong(r -> r.availableTokens().get())
+          .min();
+    }
+
+    public ImmutableList<QuotaResponse> error() {
+      return responses().stream().filter(r -> r.status().isError()).collect(toImmutableList());
+    }
+
+    public String errorMessage() {
+      return error().stream()
+          .map(QuotaResponse::message)
+          .flatMap(Streams::stream)
+          .collect(Collectors.joining(", "));
+    }
+
+    public void throwOnError() throws QuotaException {
+      String errorMessage = errorMessage();
+      if (!Strings.isNullOrEmpty(errorMessage)) {
+        throw new QuotaException(errorMessage);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index ff114fae..f2d6e0f 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -8,21 +8,27 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/jgit",
+        "//java/com/google/gerrit/json",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/ioutil",
-        "//java/org/eclipse/jgit:server",
+        "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:blame-cache",
         "//lib:gson",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/server/restapi/access/ListAccess.java b/java/com/google/gerrit/server/restapi/access/ListAccess.java
index 3f01c6c..437f04c 100644
--- a/java/com/google/gerrit/server/restapi/access/ListAccess.java
+++ b/java/com/google/gerrit/server/restapi/access/ListAccess.java
@@ -17,12 +17,12 @@
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.project.GetAccess;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -48,13 +48,13 @@
   }
 
   @Override
-  public Map<String, ProjectAccessInfo> apply(TopLevelResource resource)
+  public Response<Map<String, ProjectAccessInfo>> apply(TopLevelResource resource)
       throws ResourceNotFoundException, ResourceConflictException, IOException,
-          PermissionBackendException, OrmException {
+          PermissionBackendException {
     Map<String, ProjectAccessInfo> access = new TreeMap<>();
     for (String p : projects) {
-      access.put(p, getAccess.apply(new Project.NameKey(p)));
+      access.put(p, getAccess.apply(Project.nameKey(p)));
     }
-    return access;
+    return Response.ok(access);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
index 6cec565..119e2e4 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -14,25 +14,16 @@
 
 package com.google.gerrit.server.restapi.account;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -40,114 +31,32 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class AccountsCollection
-    implements RestCollection<TopLevelResource, AccountResource>, AcceptsCreate<TopLevelResource> {
-  private final Provider<CurrentUser> self;
-  private final AccountResolver resolver;
-  private final AccountControl.Factory accountControlFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
+public class AccountsCollection implements RestCollection<TopLevelResource, AccountResource> {
+  private final AccountResolver accountResolver;
   private final Provider<QueryAccounts> list;
   private final DynamicMap<RestView<AccountResource>> views;
-  private final CreateAccount.Factory createAccountFactory;
 
   @Inject
   public AccountsCollection(
-      Provider<CurrentUser> self,
-      AccountResolver resolver,
-      AccountControl.Factory accountControlFactory,
-      IdentifiedUser.GenericFactory userFactory,
+      AccountResolver accountResolver,
       Provider<QueryAccounts> list,
-      DynamicMap<RestView<AccountResource>> views,
-      CreateAccount.Factory createAccountFactory) {
-    this.self = self;
-    this.resolver = resolver;
-    this.accountControlFactory = accountControlFactory;
-    this.userFactory = userFactory;
+      DynamicMap<RestView<AccountResource>> views) {
+    this.accountResolver = accountResolver;
     this.list = list;
     this.views = views;
-    this.createAccountFactory = createAccountFactory;
   }
 
   @Override
   public AccountResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException, AuthException, OrmException, IOException,
-          ConfigInvalidException {
-    IdentifiedUser user = parseId(id.get());
-    if (user == null || !accountControlFactory.get().canSee(user.getAccount())) {
-      throw new ResourceNotFoundException(
-          String.format("Account '%s' is not found or ambiguous", id));
-    }
-    return new AccountResource(user);
-  }
-
-  /**
-   * Parses a account ID from a request body and returns the user.
-   *
-   * @param id ID of the account, can be a string of the format "{@code Full Name
-   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
-   *     a user name or "{@code self}" for the calling user
-   * @return the user, never null.
-   * @throws UnprocessableEntityException thrown if the account ID cannot be resolved or if the
-   *     account is not visible to the calling user
-   */
-  public IdentifiedUser parse(String id)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    return parseOnBehalfOf(null, id);
-  }
-
-  /**
-   * Parses an account ID and returns the user without making any permission check whether the
-   * current user can see the account.
-   *
-   * @param id ID of the account, can be a string of the format "{@code Full Name
-   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
-   *     a user name or "{@code self}" for the calling user
-   * @return the user, null if no user is found for the given account ID
-   * @throws AuthException thrown if 'self' is used as account ID and the current user is not
-   *     authenticated
-   * @throws OrmException
-   * @throws ConfigInvalidException
-   * @throws IOException
-   */
-  public IdentifiedUser parseId(String id)
-      throws AuthException, OrmException, IOException, ConfigInvalidException {
-    return parseIdOnBehalfOf(null, id);
-  }
-
-  /**
-   * Like {@link #parse(String)}, but also sets the {@link CurrentUser#getRealUser()} on the result.
-   */
-  public IdentifiedUser parseOnBehalfOf(@Nullable CurrentUser caller, String id)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    IdentifiedUser user = parseIdOnBehalfOf(caller, id);
-    if (user == null || !accountControlFactory.get().canSee(user.getAccount())) {
-      throw new UnprocessableEntityException(
-          String.format("Account '%s' is not found or ambiguous", id));
-    }
-    return user;
-  }
-
-  private IdentifiedUser parseIdOnBehalfOf(@Nullable CurrentUser caller, String id)
-      throws AuthException, OrmException, IOException, ConfigInvalidException {
-    if (id.equals("self")) {
-      CurrentUser user = self.get();
-      if (user.isIdentifiedUser()) {
-        return user.asIdentifiedUser();
-      } else if (user instanceof AnonymousUser) {
-        throw new AuthException("Authentication required");
-      } else {
-        return null;
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
+    try {
+      return new AccountResource(accountResolver.resolve(id.get()).asUniqueUser());
+    } catch (UnresolvableAccountException e) {
+      if (e.isSelf()) {
+        throw new AuthException(e.getMessage(), e);
       }
+      throw new ResourceNotFoundException(e.getMessage(), e);
     }
-
-    Account match = resolver.find(id);
-    if (match == null) {
-      return null;
-    }
-    CurrentUser realUser = caller != null ? caller.getRealUser() : null;
-    return userFactory.runAs(null, match.getId(), realUser);
   }
 
   @Override
@@ -159,9 +68,4 @@
   public DynamicMap<RestView<AccountResource>> views() {
     return views;
   }
-
-  @Override
-  public CreateAccount create(TopLevelResource parent, IdString username) throws RestApiException {
-    return createAccountFactory.create(username.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AddSshKey.java b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
index ab06e25..1fcf0bd 100644
--- a/java/com/google/gerrit/server/restapi/account/AddSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
@@ -18,15 +18,15 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteSource;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.exceptions.InvalidSshKeyException;
 import com.google.gerrit.extensions.api.accounts.SshKeyInput;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -37,7 +37,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -46,7 +45,8 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class AddSshKey implements RestModifyView<AccountResource, SshKeyInput> {
+public class AddSshKey
+    implements RestCollectionModifyView<AccountResource, AccountResource.SshKey, SshKeyInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<CurrentUser> self;
@@ -71,7 +71,7 @@
 
   @Override
   public Response<SshKeyInfo> apply(AccountResource rsrc, SshKeyInput input)
-      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
+      throws AuthException, BadRequestException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
@@ -104,11 +104,11 @@
         addKeyFactory.create(user, sshKey).send();
       } catch (EmailException e) {
         logger.atSevere().withCause(e).log(
-            "Cannot send SSH key added message to %s", user.getAccount().getPreferredEmail());
+            "Cannot send SSH key added message to %s", user.getAccount().preferredEmail());
       }
 
       user.getUserName().ifPresent(sshKeyCache::evict);
-      return Response.<SshKeyInfo>created(GetSshKeys.newSshKeyInfo(sshKey));
+      return Response.created(GetSshKeys.newSshKeyInfo(sshKey));
     } catch (InvalidSshKeyException e) {
       throw new BadRequestException(e.getMessage());
     }
diff --git a/java/com/google/gerrit/server/restapi/account/Capabilities.java b/java/com/google/gerrit/server/restapi/account/Capabilities.java
index ec16e2b..07b1214 100644
--- a/java/com/google/gerrit/server/restapi/account/Capabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/Capabilities.java
@@ -71,10 +71,12 @@
     }
 
     GlobalOrPluginPermission perm = parse(id);
-    if (permissionBackend.user(target).test(perm)) {
+    try {
+      permissionBackend.absentUser(target.getAccountId()).check(perm);
       return new AccountResource.Capability(target, globalOrPluginPermissionName(perm));
+    } catch (AuthException e) {
+      throw new ResourceNotFoundException(id);
     }
-    throw new ResourceNotFoundException(id);
   }
 
   private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index 404b3d3..92937f1 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -22,37 +22,39 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.InvalidSshKeyException;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.InvalidSshKeyException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountExternalIdCreator;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -61,36 +63,32 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
-public class CreateAccount implements RestModifyView<TopLevelResource, AccountInput> {
-  public interface Factory {
-    CreateAccount create(String username);
-  }
-
+@Singleton
+public class CreateAccount
+    implements RestCollectionCreateView<TopLevelResource, AccountResource, AccountInput> {
   private final Sequences seq;
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final AccountLoader.Factory infoLoader;
-  private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
+  private final PluginSetContext<AccountExternalIdCreator> externalIdCreators;
   private final Provider<GroupsUpdate> groupsUpdate;
   private final OutgoingEmailValidator validator;
-  private final String username;
 
   @Inject
   CreateAccount(
       Sequences seq,
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       AccountLoader.Factory infoLoader,
-      DynamicSet<AccountExternalIdCreator> externalIdCreators,
+      PluginSetContext<AccountExternalIdCreator> externalIdCreators,
       @UserInitiated Provider<GroupsUpdate> groupsUpdate,
-      OutgoingEmailValidator validator,
-      @Assisted String username) {
+      OutgoingEmailValidator validator) {
     this.seq = seq;
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.accountsUpdateProvider = accountsUpdateProvider;
@@ -98,51 +96,48 @@
     this.externalIdCreators = externalIdCreators;
     this.groupsUpdate = groupsUpdate;
     this.validator = validator;
-    this.username = username;
   }
 
   @Override
-  public Response<AccountInfo> apply(TopLevelResource rsrc, @Nullable AccountInput input)
+  public Response<AccountInfo> apply(
+      TopLevelResource rsrc, IdString id, @Nullable AccountInput input)
       throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
-          OrmException, IOException, ConfigInvalidException {
-    return apply(input != null ? input : new AccountInput());
+          IOException, ConfigInvalidException, PermissionBackendException {
+    return apply(id, input != null ? input : new AccountInput());
   }
 
-  public Response<AccountInfo> apply(AccountInput input)
+  public Response<AccountInfo> apply(IdString id, AccountInput input)
       throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
-          OrmException, IOException, ConfigInvalidException {
+          IOException, ConfigInvalidException, PermissionBackendException {
+    String username = id.get();
     if (input.username != null && !username.equals(input.username)) {
       throw new BadRequestException("username must match URL");
     }
-
     if (!ExternalId.isValidUsername(username)) {
-      throw new BadRequestException(
-          "Username '" + username + "' must contain only letters, numbers, _, - or .");
+      throw new BadRequestException("Invalid username '" + username + "'");
     }
 
     Set<AccountGroup.UUID> groups = parseGroups(input.groups);
 
-    Account.Id id = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     List<ExternalId> extIds = new ArrayList<>();
 
     if (input.email != null) {
       if (!validator.isValid(input.email)) {
         throw new BadRequestException("invalid email address");
       }
-      extIds.add(ExternalId.createEmail(id, input.email));
+      extIds.add(ExternalId.createEmail(accountId, input.email));
     }
 
-    extIds.add(ExternalId.createUsername(username, id, input.httpPassword));
-    for (AccountExternalIdCreator c : externalIdCreators) {
-      extIds.addAll(c.create(id, username, input.email));
-    }
+    extIds.add(ExternalId.createUsername(username, accountId, input.httpPassword));
+    externalIdCreators.runEach(c -> extIds.addAll(c.create(accountId, username, input.email)));
 
     try {
       accountsUpdateProvider
           .get()
           .insert(
               "Create Account via API",
-              id,
+              accountId,
               u -> u.setFullName(input.name).setPreferredEmail(input.email).addExternalIds(extIds));
     } catch (DuplicateExternalIdKeyException e) {
       if (e.getDuplicateKey().isScheme(SCHEME_USERNAME)) {
@@ -159,7 +154,7 @@
 
     for (AccountGroup.UUID groupUuid : groups) {
       try {
-        addGroupMember(groupUuid, id);
+        addGroupMember(groupUuid, accountId);
       } catch (NoSuchGroupException e) {
         throw new UnprocessableEntityException(String.format("Group %s not found", groupUuid));
       }
@@ -167,7 +162,7 @@
 
     if (input.sshKey != null) {
       try {
-        authorizedKeys.addKey(id, input.sshKey);
+        authorizedKeys.addKey(accountId, input.sshKey);
         sshKeyCache.evict(username);
       } catch (InvalidSshKeyException e) {
         throw new BadRequestException(e.getMessage());
@@ -175,7 +170,7 @@
     }
 
     AccountLoader loader = infoLoader.create(true);
-    AccountInfo info = loader.get(id);
+    AccountInfo info = loader.get(accountId);
     loader.fill();
     return Response.created(info);
   }
@@ -185,7 +180,7 @@
     Set<AccountGroup.UUID> groupUuids = new HashSet<>();
     if (groups != null) {
       for (String g : groups) {
-        GroupDescription.Internal internalGroup = groupsCollection.parseInternal(g);
+        GroupDescription.Internal internalGroup = groupResolver.parseInternal(g);
         groupUuids.add(internalGroup.getGroupUUID());
       }
     }
@@ -193,7 +188,7 @@
   }
 
   private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
-      throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
+      throws IOException, NoSuchGroupException, ConfigInvalidException {
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, ImmutableSet.of(accountId)))
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index abc6dd9..ae45b68 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -17,16 +17,17 @@
 import static com.google.gerrit.extensions.client.AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountException;
@@ -40,20 +41,17 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
-public class CreateEmail implements RestModifyView<AccountResource, EmailInput> {
+@Singleton
+public class CreateEmail
+    implements RestCollectionCreateView<AccountResource, AccountResource.Email, EmailInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    CreateEmail create(String email);
-  }
-
   private final Provider<CurrentUser> self;
   private final Realm realm;
   private final PermissionBackend permissionBackend;
@@ -61,7 +59,6 @@
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
-  private final String email;
   private final boolean isDevMode;
 
   @Inject
@@ -73,8 +70,7 @@
       AccountManager accountManager,
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
-      OutgoingEmailValidator validator,
-      @Assisted String email) {
+      OutgoingEmailValidator validator) {
     this.self = self;
     this.realm = realm;
     this.permissionBackend = permissionBackend;
@@ -82,13 +78,12 @@
     this.registerNewEmailFactory = registerNewEmailFactory;
     this.putPreferred = putPreferred;
     this.validator = validator;
-    this.email = email != null ? email.trim() : null;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
   }
 
   @Override
-  public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
-      throws RestApiException, OrmException, EmailException, MethodNotAllowedException, IOException,
+  public Response<EmailInfo> apply(AccountResource rsrc, IdString id, EmailInput input)
+      throws RestApiException, EmailException, MethodNotAllowedException, IOException,
           ConfigInvalidException, PermissionBackendException {
     if (input == null) {
       input = new EmailInput();
@@ -102,13 +97,15 @@
       throw new MethodNotAllowedException("realm does not allow adding emails");
     }
 
-    return apply(rsrc.getUser(), input);
+    return Response.created(apply(rsrc.getUser(), id, input));
   }
 
   /** To be used from plugins that want to create emails without permission checks. */
-  public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
-      throws RestApiException, OrmException, EmailException, MethodNotAllowedException, IOException,
+  public EmailInfo apply(IdentifiedUser user, IdString id, EmailInput input)
+      throws RestApiException, EmailException, MethodNotAllowedException, IOException,
           ConfigInvalidException, PermissionBackendException {
+    String email = id.get().trim();
+
     if (input == null) {
       input = new EmailInput();
     }
@@ -149,6 +146,6 @@
         throw e;
       }
     }
-    return Response.created(info);
+    return info;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteActive.java b/java/com/google/gerrit/server/restapi/account/DeleteActive.java
index 4302513..ffd7893 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteActive.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteActive.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.SetInactiveFlag;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -46,7 +45,7 @@
 
   @Override
   public Response<?> apply(AccountResource rsrc, Input input)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, IOException, ConfigInvalidException {
     if (self.get().hasSameAccountId(rsrc.getUser())) {
       throw new ResourceConflictException("cannot deactivate own account");
     }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
new file mode 100644
index 0000000..b815f9c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -0,0 +1,206 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.HasDraftByPredicate;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.CommentJson;
+import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdate.Factory;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Singleton
+public class DeleteDraftComments
+    implements RestModifyView<AccountResource, DeleteDraftCommentsInput> {
+
+  private final Provider<CurrentUser> userProvider;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeJson.Factory changeJsonFactory;
+  private final Provider<CommentJson> commentJsonProvider;
+  private final CommentsUtil commentsUtil;
+  private final PatchSetUtil psUtil;
+  private final PatchListCache patchListCache;
+
+  @Inject
+  DeleteDraftComments(
+      Provider<CurrentUser> userProvider,
+      Factory batchUpdateFactory,
+      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeData.Factory changeDataFactory,
+      ChangeJson.Factory changeJsonFactory,
+      Provider<CommentJson> commentJsonProvider,
+      CommentsUtil commentsUtil,
+      PatchSetUtil psUtil,
+      PatchListCache patchListCache) {
+    this.userProvider = userProvider;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryProvider = queryProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.changeJsonFactory = changeJsonFactory;
+    this.commentJsonProvider = commentJsonProvider;
+    this.commentsUtil = commentsUtil;
+    this.psUtil = psUtil;
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  public Response<ImmutableList<DeletedDraftCommentInfo>> apply(
+      AccountResource rsrc, DeleteDraftCommentsInput input)
+      throws RestApiException, UpdateException {
+    CurrentUser user = userProvider.get();
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    if (!rsrc.getUser().hasSameAccountId(user)) {
+      // Disallow even for admins or users with Modify Account. Drafts are not like preferences or
+      // other account info; there is no way even for admins to read or delete another user's drafts
+      // using the normal draft endpoints under the change resource, so disallow it here as well.
+      // (Admins may still call this endpoint with impersonation, but in that case it would pass the
+      // hasSameAccountId check.)
+      throw new AuthException("Cannot delete drafts of other user");
+    }
+
+    CommentFormatter commentFormatter = commentJsonProvider.get().newCommentFormatter();
+    Account.Id accountId = rsrc.getUser().getAccountId();
+    Timestamp now = TimeUtil.nowTs();
+    Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
+    List<Op> ops = new ArrayList<>();
+    for (ChangeData cd :
+        queryProvider
+            .get()
+            // Don't attempt to mutate any changes the user can't currently see.
+            .enforceVisibility(true)
+            .query(predicate(accountId, input))) {
+      BatchUpdate update =
+          updates.computeIfAbsent(
+              cd.project(), p -> batchUpdateFactory.create(p, rsrc.getUser(), now));
+      Op op = new Op(commentFormatter, accountId);
+      update.addOp(cd.getId(), op);
+      ops.add(op);
+    }
+
+    // Currently there's no way to let some updates succeed even if others fail. Even if there were,
+    // all updates from this operation only happen in All-Users and thus are fully atomic, so
+    // allowing partial failure would have little value.
+    BatchUpdate.execute(updates.values(), BatchUpdateListener.NONE, false);
+
+    return Response.ok(
+        ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList()));
+  }
+
+  private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
+      throws BadRequestException {
+    Predicate<ChangeData> hasDraft = new HasDraftByPredicate(accountId);
+    if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
+      return hasDraft;
+    }
+    try {
+      return Predicate.and(hasDraft, queryBuilderProvider.get().parse(input.query));
+    } catch (QueryParseException e) {
+      throw new BadRequestException("Invalid query: " + e.getMessage(), e);
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final CommentFormatter commentFormatter;
+    private final Account.Id accountId;
+    private DeletedDraftCommentInfo result;
+
+    Op(CommentFormatter commentFormatter, Account.Id accountId) {
+      this.commentFormatter = commentFormatter;
+      this.accountId = accountId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws PatchListNotAvailableException, PermissionBackendException {
+      ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
+      boolean dirty = false;
+      for (Comment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
+        dirty = true;
+        PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
+        setCommentCommitId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+        commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
+        comments.add(commentFormatter.format(c));
+      }
+      if (dirty) {
+        result = new DeletedDraftCommentInfo();
+        result.change =
+            changeJsonFactory
+                .create(ListChangesOption.SKIP_MERGEABLE)
+                .format(changeDataFactory.create(ctx.getNotes()));
+        result.deleted = comments.build();
+      }
+      return dirty;
+    }
+
+    @Nullable
+    DeletedDraftCommentInfo getResult() {
+      return result;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteEmail.java b/java/com/google/gerrit/server/restapi/account/DeleteEmail.java
index f0269f1..7a03005 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteEmail.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -69,7 +68,7 @@
   @Override
   public Response<?> apply(AccountResource.Email rsrc, Input input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException,
-          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException,
+          MethodNotAllowedException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
@@ -79,15 +78,13 @@
 
   public Response<?> apply(IdentifiedUser user, String email)
       throws ResourceNotFoundException, ResourceConflictException, MethodNotAllowedException,
-          OrmException, IOException, ConfigInvalidException {
+          IOException, ConfigInvalidException {
     if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow deleting emails");
     }
 
     Set<ExternalId> extIds =
-        externalIds
-            .byAccount(user.getAccountId())
-            .stream()
+        externalIds.byAccount(user.getAccountId()).stream()
             .filter(e -> email.equals(e.email()))
             .collect(toSet());
     if (extIds.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
index 05b1771..82b445f 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -66,8 +65,7 @@
 
   @Override
   public Response<?> apply(AccountResource resource, List<String> extIds)
-      throws RestApiException, IOException, OrmException, ConfigInvalidException,
-          PermissionBackendException {
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(resource.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ACCESS_DATABASE);
     }
@@ -77,9 +75,7 @@
     }
 
     Map<ExternalId.Key, ExternalId> externalIdMap =
-        externalIds
-            .byAccount(resource.getUser().getAccountId())
-            .stream()
+        externalIds.byAccount(resource.getUser().getAccountId()).stream()
             .collect(toMap(ExternalId::key, Function.identity()));
 
     List<ExternalId> toDelete = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index b7b3c83..b470be8 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -14,18 +14,21 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.mail.send.DeleteKeySender;
 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.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -35,34 +38,45 @@
 
 @Singleton
 public class DeleteSshKey implements RestModifyView<AccountResource.SshKey, Input> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
+  private final DeleteKeySender.Factory deleteKeySenderFactory;
 
   @Inject
   DeleteSshKey(
       Provider<CurrentUser> self,
       PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
-      SshKeyCache sshKeyCache) {
+      SshKeyCache sshKeyCache,
+      DeleteKeySender.Factory deleteKeySenderFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
+    this.deleteKeySenderFactory = deleteKeySenderFactory;
   }
 
   @Override
   public Response<?> apply(AccountResource.SshKey rsrc, Input input)
-      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException, PermissionBackendException {
+      throws AuthException, RepositoryNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    authorizedKeys.deleteKey(rsrc.getUser().getAccountId(), rsrc.getSshKey().seq());
-    rsrc.getUser().getUserName().ifPresent(sshKeyCache::evict);
+    IdentifiedUser user = rsrc.getUser();
+    authorizedKeys.deleteKey(user.getAccountId(), rsrc.getSshKey().seq());
+    try {
+      deleteKeySenderFactory.create(user, rsrc.getSshKey()).send();
+    } catch (EmailException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot send SSH key deletion message to %s", user.getAccount().preferredEmail());
+    }
+    user.getUserName().ifPresent(sshKeyCache::evict);
 
     return Response.none();
   }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
index 0e2edb9..666851b 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -59,8 +58,8 @@
 
   @Override
   public Response<?> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException, PermissionBackendException {
+      throws AuthException, UnprocessableEntityException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
@@ -76,10 +75,9 @@
             accountId,
             u ->
                 u.deleteProjectWatches(
-                    input
-                        .stream()
+                    input.stream()
                         .filter(Objects::nonNull)
-                        .map(w -> ProjectWatchKey.create(new Project.NameKey(w.project), w.filter))
+                        .map(w -> ProjectWatchKey.create(Project.nameKey(w.project), w.filter))
                         .collect(toList())));
     return Response.none();
   }
diff --git a/java/com/google/gerrit/server/restapi/account/EmailsCollection.java b/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
index 8694da0..7a498f4 100644
--- a/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -33,27 +32,22 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class EmailsCollection
-    implements ChildCollection<AccountResource, AccountResource.Email>,
-        AcceptsCreate<AccountResource> {
+public class EmailsCollection implements ChildCollection<AccountResource, AccountResource.Email> {
   private final DynamicMap<RestView<AccountResource.Email>> views;
   private final GetEmails list;
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
-  private final CreateEmail.Factory createEmailFactory;
 
   @Inject
   EmailsCollection(
       DynamicMap<RestView<AccountResource.Email>> views,
       GetEmails list,
       Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      CreateEmail.Factory createEmailFactory) {
+      PermissionBackend permissionBackend) {
     this.views = views;
     this.list = list;
     this.self = self;
     this.permissionBackend = permissionBackend;
-    this.createEmailFactory = createEmailFactory;
   }
 
   @Override
@@ -69,7 +63,7 @@
     }
 
     if ("preferred".equals(id.get())) {
-      String email = rsrc.getUser().getAccount().getPreferredEmail();
+      String email = rsrc.getUser().getAccount().preferredEmail();
       if (Strings.isNullOrEmpty(email)) {
         throw new ResourceNotFoundException(id);
       }
@@ -85,9 +79,4 @@
   public DynamicMap<RestView<Email>> views() {
     return views;
   }
-
-  @Override
-  public CreateEmail create(AccountResource parent, IdString email) {
-    return createEmailFactory.create(email.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAccount.java b/java/com/google/gerrit/server/restapi/account/GetAccount.java
index 0d8e25e..898b0bb 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAccount.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -32,10 +33,10 @@
   }
 
   @Override
-  public AccountInfo apply(AccountResource rsrc) throws OrmException {
+  public Response<AccountInfo> apply(AccountResource rsrc) throws PermissionBackendException {
     AccountLoader loader = infoFactory.create(true);
     AccountInfo info = loader.get(rsrc.getUser().getAccountId());
     loader.fill();
-    return info;
+    return Response.ok(info);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index dced4d7..e985441 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -28,6 +29,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.config.AgreementJson;
 import com.google.inject.Inject;
@@ -60,7 +62,8 @@
   }
 
   @Override
-  public List<AgreementInfo> apply(AccountResource resource) throws RestApiException {
+  public Response<List<AgreementInfo>> apply(AccountResource resource)
+      throws RestApiException, PermissionBackendException {
     if (!agreementsEnabled) {
       throw new MethodNotAllowedException("contributor agreements disabled");
     }
@@ -95,6 +98,6 @@
         results.add(agreementJson.format(ca));
       }
     }
-    return results;
+    return Response.ok(results);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java b/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
index 904b15f..e97e0a0 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.avatar.AvatarProvider;
@@ -33,7 +34,7 @@
   }
 
   @Override
-  public String apply(AccountResource rsrc) throws ResourceNotFoundException {
+  public Response<String> apply(AccountResource rsrc) throws ResourceNotFoundException {
     AvatarProvider impl = avatarProvider.get();
     if (impl == null) {
       throw new ResourceNotFoundException();
@@ -43,6 +44,6 @@
     if (Strings.isNullOrEmpty(url)) {
       throw new ResourceNotFoundException();
     }
-    return url;
+    return Response.ok(url);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index d2236fd..fa9ab18 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.common.data.GlobalCapability.PRIORITY;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalOrPluginPermissionName;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalPermissionName;
-import static com.google.gerrit.server.permissions.DefaultPermissionMappings.pluginPermissionName;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.pluginCapabilityName;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -28,11 +28,11 @@
 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;
 import com.google.gerrit.server.OptionUtil;
-import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountResource.Capability;
@@ -40,7 +40,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -79,13 +78,13 @@
   }
 
   @Override
-  public Object apply(AccountResource resource)
+  public Response<Map<String, Object>> apply(AccountResource resource)
       throws RestApiException, PermissionBackendException {
     permissionBackend.checkUsesDefaultCapabilities();
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
     if (!self.get().hasSameAccountId(resource.getUser())) {
       perm.check(GlobalPermission.ADMINISTRATE_SERVER);
-      perm = permissionBackend.user(resource.getUser());
+      perm = permissionBackend.absentUser(resource.getUser().getAccountId());
     }
 
     Map<String, Object> have = new LinkedHashMap<>();
@@ -97,9 +96,7 @@
     addRanges(have, limits);
     addPriority(have, limits);
 
-    return OutputFormat.JSON
-        .newGson()
-        .toJsonTree(have, new TypeToken<Map<String, Object>>() {}.getType());
+    return Response.ok(have);
   }
 
   private Set<GlobalOrPluginPermission> permissionsToTest() {
@@ -113,7 +110,7 @@
     for (String pluginName : pluginCapabilities.plugins()) {
       for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
         PluginPermission p = new PluginPermission(pluginName, capability);
-        if (want(pluginPermissionName(p))) {
+        if (want(pluginCapabilityName(p))) {
           toTest.add(p);
         }
       }
@@ -172,9 +169,9 @@
     }
 
     @Override
-    public BinaryResult apply(Capability resource) throws ResourceNotFoundException {
+    public Response<BinaryResult> apply(Capability resource) throws ResourceNotFoundException {
       permissionBackend.checkUsesDefaultCapabilities();
-      return BinaryResult.create("ok\n");
+      return Response.ok(BinaryResult.create("ok\n"));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetDetail.java b/java/com/google/gerrit/server/restapi/account/GetDetail.java
index de9928c..e70afbf 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDetail.java
@@ -14,24 +14,21 @@
 
 package com.google.gerrit.server.restapi.account;
 
-import com.google.common.base.Throwables;
-import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountDirectory.DirectoryException;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.InternalAccountDirectory;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.EnumSet;
 
 @Singleton
 public class GetDetail implements RestReadView<AccountResource> {
-
   private final InternalAccountDirectory directory;
 
   @Inject
@@ -40,26 +37,12 @@
   }
 
   @Override
-  public AccountDetailInfo apply(AccountResource rsrc) throws OrmException {
+  public Response<AccountDetailInfo> apply(AccountResource rsrc) throws PermissionBackendException {
     Account a = rsrc.getUser().getAccount();
-    AccountDetailInfo info = new AccountDetailInfo(a.getId().get());
-    info.registeredOn = a.getRegisteredOn();
+    AccountDetailInfo info = new AccountDetailInfo(a.id().get());
+    info.registeredOn = a.registeredOn();
     info.inactive = !a.isActive() ? true : null;
-    try {
-      directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
-    } catch (DirectoryException e) {
-      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
-      throw new OrmException(e);
-    }
-    return info;
-  }
-
-  public static class AccountDetailInfo extends AccountInfo {
-    public Timestamp registeredOn;
-    public Boolean inactive;
-
-    public AccountDetailInfo(Integer id) {
-      super(id);
-    }
+    directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
+    return Response.ok(info);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
index a8d14f6..c9773f5 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
@@ -48,16 +49,17 @@
   }
 
   @Override
-  public DiffPreferencesInfo apply(AccountResource rsrc)
+  public Response<DiffPreferencesInfo> apply(AccountResource rsrc)
       throws RestApiException, ConfigInvalidException, IOException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountCache
-        .get(id)
-        .map(AccountState::getDiffPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountCache
+            .get(id)
+            .map(AccountState::getDiffPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java b/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
index 0ecd6ea..ae3a215 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
@@ -48,16 +49,17 @@
   }
 
   @Override
-  public EditPreferencesInfo apply(AccountResource rsrc)
+  public Response<EditPreferencesInfo> apply(AccountResource rsrc)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountCache
-        .get(id)
-        .map(AccountState::getEditPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountCache
+            .get(id)
+            .map(AccountState::getEditPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmail.java b/java/com/google/gerrit/server/restapi/account/GetEmail.java
index 3118380..afcdac2 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmail.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Inject;
@@ -26,10 +27,10 @@
   public GetEmail() {}
 
   @Override
-  public EmailInfo apply(AccountResource.Email rsrc) {
+  public Response<EmailInfo> apply(AccountResource.Email rsrc) {
     EmailInfo e = new EmailInfo();
     e.email = rsrc.getEmail();
-    e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
-    return e;
+    e.preferred(rsrc.getUser().getAccount().preferredEmail());
+    return Response.ok(e);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmails.java b/java/com/google/gerrit/server/restapi/account/GetEmails.java
index 63d042c..9db9f05 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmails.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmails.java
@@ -14,8 +14,12 @@
 
 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.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -25,10 +29,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> {
@@ -42,29 +44,23 @@
   }
 
   @Override
-  public List<EmailInfo> apply(AccountResource rsrc)
+  public Response<List<EmailInfo>> apply(AccountResource rsrc)
       throws AuthException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
+    return Response.ok(
+        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().preferredEmail());
+    return e;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
index 7a420ab..0e52af2 100644
--- a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+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;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -59,15 +59,15 @@
   }
 
   @Override
-  public List<AccountExternalIdInfo> apply(AccountResource resource)
-      throws RestApiException, IOException, OrmException, PermissionBackendException {
+  public Response<List<AccountExternalIdInfo>> apply(AccountResource resource)
+      throws RestApiException, IOException, PermissionBackendException {
     if (!self.get().hasSameAccountId(resource.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ACCESS_DATABASE);
     }
 
     Collection<ExternalId> ids = externalIds.byAccount(resource.getUser().getAccountId());
     if (ids.isEmpty()) {
-      return ImmutableList.of();
+      return Response.ok(ImmutableList.of());
     }
     List<AccountExternalIdInfo> result = Lists.newArrayListWithCapacity(ids.size());
     for (ExternalId id : ids) {
@@ -84,7 +84,7 @@
       }
       result.add(info);
     }
-    return result;
+    return Response.ok(result);
   }
 
   private static Boolean toBoolean(boolean v) {
diff --git a/java/com/google/gerrit/server/restapi/account/GetGroups.java b/java/com/google/gerrit/server/restapi/account/GetGroups.java
index 486a151..5848e1e 100644
--- a/java/com/google/gerrit/server/restapi/account/GetGroups.java
+++ b/java/com/google/gerrit/server/restapi/account/GetGroups.java
@@ -14,16 +14,17 @@
 
 package com.google.gerrit.server.restapi.account;
 
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.GroupJson;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
@@ -42,7 +43,8 @@
   }
 
   @Override
-  public List<GroupInfo> apply(AccountResource resource) throws OrmException {
+  public Response<List<GroupInfo>> apply(AccountResource resource)
+      throws PermissionBackendException {
     IdentifiedUser user = resource.getUser();
     Account.Id userId = user.getAccountId();
     Set<AccountGroup.UUID> knownGroups = user.getEffectiveGroups().getKnownGroups();
@@ -58,6 +60,6 @@
         visibleGroups.add(json.format(ctl.getGroup()));
       }
     }
-    return visibleGroups;
+    return Response.ok(visibleGroups);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetName.java b/java/com/google/gerrit/server/restapi/account/GetName.java
index bdf379e..ca33887 100644
--- a/java/com/google/gerrit/server/restapi/account/GetName.java
+++ b/java/com/google/gerrit/server/restapi/account/GetName.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 @Singleton
 public class GetName implements RestReadView<AccountResource> {
   @Override
-  public String apply(AccountResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getUser().getAccount().getFullName());
+  public Response<String> apply(AccountResource rsrc) {
+    return Response.ok(Strings.nullToEmpty(rsrc.getUser().getAccount().fullName()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java b/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
index 395c159..24682c0 100644
--- a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
+++ b/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -50,7 +51,7 @@
   }
 
   @Override
-  public OAuthTokenInfo apply(AccountResource rsrc)
+  public Response<OAuthTokenInfo> apply(AccountResource rsrc)
       throws AuthException, ResourceNotFoundException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       throw new AuthException("not allowed to get access token");
@@ -66,7 +67,7 @@
     accessTokenInfo.providerId = accessToken.getProviderId();
     accessTokenInfo.expiresAt = Long.toString(accessToken.getExpiresAt());
     accessTokenInfo.type = BEARER_TYPE;
-    return accessTokenInfo;
+    return Response.ok(accessTokenInfo);
   }
 
   private static String getHostName(String canonicalWebUrl) {
diff --git a/java/com/google/gerrit/server/restapi/account/GetPreferences.java b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
index a185898..90884c7 100644
--- a/java/com/google/gerrit/server/restapi/account/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
@@ -36,26 +40,50 @@
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
+  private final DynamicMap<DownloadScheme> downloadSchemes;
 
   @Inject
   GetPreferences(
-      Provider<CurrentUser> self, PermissionBackend permissionBackend, AccountCache accountCache) {
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AccountCache accountCache,
+      DynamicMap<DownloadScheme> downloadSchemes) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
+    this.downloadSchemes = downloadSchemes;
   }
 
   @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc)
+  public Response<GeneralPreferencesInfo> apply(AccountResource rsrc)
       throws RestApiException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountCache
-        .get(id)
-        .map(AccountState::getGeneralPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    GeneralPreferencesInfo preferencesInfo =
+        accountCache
+            .get(id)
+            .map(AccountState::getGeneralPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(unsetDownloadSchemeIfUnsupported(preferencesInfo));
+  }
+
+  private GeneralPreferencesInfo unsetDownloadSchemeIfUnsupported(
+      GeneralPreferencesInfo preferencesInfo) {
+    if (preferencesInfo.downloadScheme == null) {
+      return preferencesInfo;
+    }
+
+    for (Extension<DownloadScheme> e : downloadSchemes) {
+      if (e.getExportName().equals(preferencesInfo.downloadScheme)
+          && e.getProvider().get().isEnabled()) {
+        return preferencesInfo;
+      }
+    }
+
+    preferencesInfo.downloadScheme = null;
+    return preferencesInfo;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKey.java b/java/com/google/gerrit/server/restapi/account/GetSshKey.java
index dc72663..58b5d12 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKey.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountResource.SshKey;
@@ -24,7 +25,7 @@
 public class GetSshKey implements RestReadView<AccountResource.SshKey> {
 
   @Override
-  public SshKeyInfo apply(SshKey rsrc) {
-    return GetSshKeys.newSshKeyInfo(rsrc.getSshKey());
+  public Response<SshKeyInfo> apply(SshKey rsrc) {
+    return Response.ok(GetSshKeys.newSshKeyInfo(rsrc.getSshKey()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
index a49f9df..0ca9b9e 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -27,7 +28,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -54,13 +54,13 @@
   }
 
   @Override
-  public List<SshKeyInfo> apply(AccountResource rsrc)
-      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException, PermissionBackendException {
+  public Response<List<SshKeyInfo>> apply(AccountResource rsrc)
+      throws AuthException, RepositoryNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
-    return apply(rsrc.getUser());
+    return Response.ok(apply(rsrc.getUser()));
   }
 
   public List<SshKeyInfo> apply(IdentifiedUser user)
diff --git a/java/com/google/gerrit/server/restapi/account/GetStatus.java b/java/com/google/gerrit/server/restapi/account/GetStatus.java
index bc7094f..447ad76 100644
--- a/java/com/google/gerrit/server/restapi/account/GetStatus.java
+++ b/java/com/google/gerrit/server/restapi/account/GetStatus.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 @Singleton
 public class GetStatus implements RestReadView<AccountResource> {
   @Override
-  public String apply(AccountResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getUser().getAccount().getStatus());
+  public Response<String> apply(AccountResource rsrc) {
+    return Response.ok(Strings.nullToEmpty(rsrc.getUser().getAccount().status()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetUsername.java b/java/com/google/gerrit/server/restapi/account/GetUsername.java
index 01185c3..7e58f94 100644
--- a/java/com/google/gerrit/server/restapi/account/GetUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/GetUsername.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Inject;
@@ -27,7 +28,8 @@
   public GetUsername() {}
 
   @Override
-  public String apply(AccountResource rsrc) throws AuthException, ResourceNotFoundException {
-    return rsrc.getUser().getUserName().orElseThrow(ResourceNotFoundException::new);
+  public Response<String> apply(AccountResource rsrc)
+      throws AuthException, ResourceNotFoundException {
+    return Response.ok(rsrc.getUser().getUserName().orElseThrow(ResourceNotFoundException::new));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index 112bb24..d60bfd5 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -14,12 +14,15 @@
 
 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;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
@@ -31,16 +34,11 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-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.Collections;
-import java.util.Comparator;
 import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -58,40 +56,35 @@
   }
 
   @Override
-  public List<ProjectWatchInfo> apply(AccountResource rsrc)
-      throws OrmException, AuthException, IOException, ConfigInvalidException,
-          PermissionBackendException, ResourceNotFoundException {
+  public Response<List<ProjectWatchInfo>> apply(AccountResource rsrc)
+      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException,
+          ResourceNotFoundException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     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 Response.ok(
+        account.getProjectWatches().entrySet().stream()
+            .map(e -> toProjectWatchInfo(e.getKey(), e.getValue()))
+            .sorted(
+                comparing((ProjectWatchInfo pwi) -> pwi.project)
+                    .thenComparing(pwi -> Strings.nullToEmpty(pwi.filter)))
+            .collect(toList()));
+  }
+
+  private static ProjectWatchInfo toProjectWatchInfo(
+      ProjectWatchKey key, ImmutableSet<NotifyType> watchTypes) {
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.filter = key.filter();
+    pwi.project = key.project().get();
+    pwi.notifyAbandonedChanges = toBoolean(watchTypes.contains(NotifyType.ABANDONED_CHANGES));
+    pwi.notifyNewChanges = toBoolean(watchTypes.contains(NotifyType.NEW_CHANGES));
+    pwi.notifyNewPatchSets = toBoolean(watchTypes.contains(NotifyType.NEW_PATCHSETS));
+    pwi.notifySubmittedChanges = toBoolean(watchTypes.contains(NotifyType.SUBMITTED_CHANGES));
+    pwi.notifyAllComments = toBoolean(watchTypes.contains(NotifyType.ALL_COMMENTS));
+    return pwi;
   }
 
   private static Boolean toBoolean(boolean value) {
diff --git a/java/com/google/gerrit/server/restapi/account/Module.java b/java/com/google/gerrit/server/restapi/account/Module.java
index ca5f08e..f41764d 100644
--- a/java/com/google/gerrit/server/restapi/account/Module.java
+++ b/java/com/google/gerrit/server/restapi/account/Module.java
@@ -43,6 +43,7 @@
     DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
     DynamicMap.mapOf(binder(), STAR_KIND);
 
+    create(ACCOUNT_KIND).to(CreateAccount.class);
     put(ACCOUNT_KIND).to(PutAccount.class);
     get(ACCOUNT_KIND).to(GetAccount.class);
     get(ACCOUNT_KIND, "detail").to(GetDetail.class);
@@ -58,18 +59,19 @@
     put(ACCOUNT_KIND, "active").to(PutActive.class);
     delete(ACCOUNT_KIND, "active").to(DeleteActive.class);
     child(ACCOUNT_KIND, "emails").to(EmailsCollection.class);
+    create(EMAIL_KIND).to(CreateEmail.class);
     get(EMAIL_KIND).to(GetEmail.class);
     put(EMAIL_KIND).to(PutEmail.class);
     delete(EMAIL_KIND).to(DeleteEmail.class);
     put(EMAIL_KIND, "preferred").to(PutPreferred.class);
     put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
     delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
-    child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
-    post(ACCOUNT_KIND, "sshkeys").to(AddSshKey.class);
     get(ACCOUNT_KIND, "watched.projects").to(GetWatchedProjects.class);
     post(ACCOUNT_KIND, "watched.projects").to(PostWatchedProjects.class);
     post(ACCOUNT_KIND, "watched.projects:delete").to(DeleteWatchedProjects.class);
 
+    child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
+    postOnCollection(SSH_KEY_KIND).to(AddSshKey.class);
     get(SSH_KEY_KIND).to(GetSshKey.class);
     delete(SSH_KEY_KIND).to(DeleteSshKey.class);
 
@@ -93,6 +95,7 @@
     put(ACCOUNT_KIND, "agreements").to(PutAgreement.class);
 
     child(ACCOUNT_KIND, "starred.changes").to(StarredChanges.class);
+    create(STARRED_CHANGE_KIND).to(StarredChanges.Create.class);
     put(STARRED_CHANGE_KIND).to(StarredChanges.Put.class);
     delete(STARRED_CHANGE_KIND).to(StarredChanges.Delete.class);
     bind(StarredChanges.Create.class);
@@ -104,8 +107,10 @@
     get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class);
     post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
 
-    factory(CreateAccount.Factory.class);
-    factory(CreateEmail.Factory.class);
+    post(ACCOUNT_KIND, "drafts:delete").to(DeleteDraftComments.class);
+
+    // The gpgkeys REST endpoints are bound via GpgApiModule.
+
     factory(AccountsUpdate.Factory.class);
   }
 
@@ -113,7 +118,7 @@
   @ServerInitiated
   AccountsUpdate provideServerInitiatedAccountsUpdate(
       AccountsUpdate.Factory accountsUpdateFactory, ExternalIdNotes.Factory extIdNotesFactory) {
-    return accountsUpdateFactory.create(null, extIdNotesFactory);
+    return accountsUpdateFactory.createWithServerIdent(extIdNotesFactory);
   }
 
   @Provides
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index f29a0eb..5236174 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.IdentifiedUser;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -65,9 +65,8 @@
   }
 
   @Override
-  public List<ProjectWatchInfo> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
-      throws OrmException, RestApiException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+  public Response<List<ProjectWatchInfo>> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/PutActive.java b/java/com/google/gerrit/server/restapi/account/PutActive.java
index 8255781..a6ffaa6 100644
--- a/java/com/google/gerrit/server/restapi/account/PutActive.java
+++ b/java/com/google/gerrit/server/restapi/account/PutActive.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.SetInactiveFlag;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -41,7 +40,7 @@
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, IOException, ConfigInvalidException {
     return setInactiveFlag.activate(rsrc.getUser().getAccountId());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/PutAgreement.java b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
index 3f1a833..5985e17 100644
--- a/java/com/google/gerrit/server/restapi/account/PutAgreement.java
+++ b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
@@ -17,8 +17,8 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.AgreementInput;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.extensions.api.accounts.AgreementInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -35,7 +35,6 @@
 import com.google.gerrit.server.extensions.events.AgreementSignup;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.group.AddMembers;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -67,7 +66,7 @@
 
   @Override
   public Response<String> apply(AccountResource resource, AgreementInput input)
-      throws IOException, OrmException, RestApiException, ConfigInvalidException {
+      throws IOException, RestApiException, ConfigInvalidException {
     if (!agreementsEnabled) {
       throw new MethodNotAllowedException("contributor agreements disabled");
     }
@@ -94,7 +93,7 @@
 
     AccountState accountState = self.get().state();
     try {
-      addMembers.addMembers(uuid, ImmutableSet.of(accountState.getAccount().getId()));
+      addMembers.addMembers(uuid, ImmutableSet.of(accountState.getAccount().id()));
     } catch (NoSuchGroupException e) {
       throw new ResourceConflictException("autoverify group not found");
     }
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index e42e5d1..7b89b9c 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -17,6 +17,9 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.common.HttpPasswordInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -30,10 +33,10 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -44,6 +47,8 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class PutHttpPassword implements RestModifyView<AccountResource, HttpPasswordInput> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final int LEN = 31;
   private static final SecureRandom rng;
 
@@ -59,23 +64,26 @@
   private final PermissionBackend permissionBackend;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory;
 
   @Inject
   PutHttpPassword(
       Provider<CurrentUser> self,
       PermissionBackend permissionBackend,
       ExternalIds externalIds,
-      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
+      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+      HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
+    this.httpPasswordUpdateSenderFactory = httpPasswordUpdateSenderFactory;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, HttpPasswordInput input)
-      throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
-          IOException, ConfigInvalidException, PermissionBackendException {
+      throws AuthException, ResourceNotFoundException, ResourceConflictException, IOException,
+          ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
@@ -98,8 +106,9 @@
     return apply(rsrc.getUser(), newPassword);
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
   public Response<String> apply(IdentifiedUser user, String newPassword)
-      throws ResourceNotFoundException, ResourceConflictException, OrmException, IOException,
+      throws ResourceNotFoundException, ResourceConflictException, IOException,
           ConfigInvalidException {
     String userName =
         user.getUserName().orElseThrow(() -> new ResourceConflictException("username must be set"));
@@ -116,9 +125,19 @@
                     ExternalId.createWithPassword(
                         extId.key(), extId.accountId(), extId.email(), newPassword)));
 
-    return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
+    try {
+      httpPasswordUpdateSenderFactory
+          .create(user, newPassword == null ? "deleted" : "added or updated")
+          .send();
+    } catch (EmailException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot send HttpPassword update message to %s", user.getAccount().preferredEmail());
+    }
+
+    return Strings.isNullOrEmpty(newPassword) ? Response.none() : Response.ok(newPassword);
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
   public static String generate() {
     byte[] rand = new byte[LEN];
     rng.nextBytes(rand);
diff --git a/java/com/google/gerrit/server/restapi/account/PutName.java b/java/com/google/gerrit/server/restapi/account/PutName.java
index 1e00aac..9e8f5be 100644
--- a/java/com/google/gerrit/server/restapi/account/PutName.java
+++ b/java/com/google/gerrit/server/restapi/account/PutName.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -60,8 +59,8 @@
 
   @Override
   public Response<String> apply(AccountResource rsrc, NameInput input)
-      throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-          IOException, PermissionBackendException, ConfigInvalidException {
+      throws AuthException, MethodNotAllowedException, ResourceNotFoundException, IOException,
+          PermissionBackendException, ConfigInvalidException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
@@ -70,7 +69,7 @@
 
   public Response<String> apply(IdentifiedUser user, NameInput input)
       throws MethodNotAllowedException, ResourceNotFoundException, IOException,
-          ConfigInvalidException, OrmException {
+          ConfigInvalidException {
     if (input == null) {
       input = new NameInput();
     }
@@ -85,8 +84,8 @@
             .get()
             .update("Set Full Name via API", user.getAccountId(), u -> u.setFullName(newName))
             .orElseThrow(() -> new ResourceNotFoundException("account not found"));
-    return Strings.isNullOrEmpty(accountState.getAccount().getFullName())
+    return Strings.isNullOrEmpty(accountState.getAccount().fullName())
         ? Response.none()
-        : Response.ok(accountState.getAccount().getFullName());
+        : Response.ok(accountState.getAccount().fullName());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index 51d28ed..3799b24 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -69,16 +68,15 @@
 
   @Override
   public Response<String> apply(AccountResource.Email rsrc, Input input)
-      throws RestApiException, OrmException, IOException, PermissionBackendException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser()) {
+      throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), rsrc.getEmail());
   }
 
   public Response<String> apply(IdentifiedUser user, String preferredEmail)
-      throws RestApiException, IOException, ConfigInvalidException, OrmException {
+      throws RestApiException, IOException, ConfigInvalidException {
     AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
     AtomicBoolean alreadyPreferred = new AtomicBoolean(false);
     accountsUpdateProvider
@@ -87,14 +85,13 @@
             "Set Preferred Email via API",
             user.getAccountId(),
             (a, u) -> {
-              if (preferredEmail.equals(a.getAccount().getPreferredEmail())) {
+              if (preferredEmail.equals(a.getAccount().preferredEmail())) {
                 alreadyPreferred.set(true);
               } else {
                 // check if the user has a matching email
                 String matchingEmail = null;
                 for (String email :
-                    a.getExternalIds()
-                        .stream()
+                    a.getExternalIds().stream()
                         .map(ExternalId::email)
                         .filter(Objects::nonNull)
                         .collect(toSet())) {
@@ -121,8 +118,7 @@
                               + " by the following account(s): %s",
                           preferredEmail,
                           user.getAccountId(),
-                          existingExtIdsWithThisEmail
-                              .stream()
+                          existingExtIdsWithThisEmail.stream()
                               .map(ExternalId::accountId)
                               .collect(toList()));
                       exception.set(
@@ -132,7 +128,7 @@
                     }
 
                     // claim the email now
-                    u.addExternalId(ExternalId.createEmail(a.getAccount().getId(), preferredEmail));
+                    u.addExternalId(ExternalId.createEmail(a.getAccount().id(), preferredEmail));
                     matchingEmail = preferredEmail;
                   } else {
                     // Realm says that the email doesn't belong to the user. This can only happen as
diff --git a/java/com/google/gerrit/server/restapi/account/PutStatus.java b/java/com/google/gerrit/server/restapi/account/PutStatus.java
index 9aee0a3..29f69ab 100644
--- a/java/com/google/gerrit/server/restapi/account/PutStatus.java
+++ b/java/com/google/gerrit/server/restapi/account/PutStatus.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -54,8 +53,8 @@
 
   @Override
   public Response<String> apply(AccountResource rsrc, StatusInput input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException {
+      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException,
+          ConfigInvalidException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
@@ -63,7 +62,7 @@
   }
 
   public Response<String> apply(IdentifiedUser user, StatusInput input)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException, OrmException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException {
     if (input == null) {
       input = new StatusInput();
     }
@@ -74,8 +73,8 @@
             .get()
             .update("Set Status via API", user.getAccountId(), u -> u.setStatus(newStatus))
             .orElseThrow(() -> new ResourceNotFoundException("account not found"));
-    return Strings.isNullOrEmpty(accountState.getAccount().getStatus())
+    return Strings.isNullOrEmpty(accountState.getAccount().status())
         ? Response.none()
-        : Response.ok(accountState.getAccount().getStatus());
+        : Response.ok(accountState.getAccount().status());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/PutUsername.java b/java/com/google/gerrit/server/restapi/account/PutUsername.java
index 856a5db..a0fff02 100644
--- a/java/com/google/gerrit/server/restapi/account/PutUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/PutUsername.java
@@ -17,11 +17,14 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.api.accounts.UsernameInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -36,8 +39,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -71,10 +72,8 @@
   }
 
   @Override
-  public String apply(AccountResource rsrc, UsernameInput input)
-      throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
-          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+  public Response<String> apply(AccountResource rsrc, UsernameInput input)
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
@@ -83,17 +82,13 @@
       throw new MethodNotAllowedException("realm does not allow editing username");
     }
 
-    if (input == null) {
-      input = new UsernameInput();
-    }
-
     Account.Id accountId = rsrc.getUser().getAccountId();
     if (!externalIds.byAccount(accountId, SCHEME_USERNAME).isEmpty()) {
       throw new MethodNotAllowedException("Username cannot be changed.");
     }
 
-    if (Strings.isNullOrEmpty(input.username)) {
-      return input.username;
+    if (input == null || Strings.isNullOrEmpty(input.username)) {
+      throw new BadRequestException("input required");
     }
 
     if (!ExternalId.isValidUsername(input.username)) {
@@ -108,11 +103,11 @@
               "Set Username via API",
               accountId,
               u -> u.addExternalId(ExternalId.create(key, accountId, null, null)));
-    } catch (OrmDuplicateKeyException dupeErr) {
+    } catch (DuplicateKeyException dupeErr) {
       // If we are using this identity, don't report the exception.
       Optional<ExternalId> other = externalIds.get(key);
       if (other.isPresent() && other.get().accountId().equals(accountId)) {
-        return input.username;
+        return Response.ok(input.username);
       }
 
       // Otherwise, someone else has this identity.
@@ -120,6 +115,6 @@
     }
 
     sshKeyCache.evict(input.username);
-    return input.username;
+    return Response.ok(input.username);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 8784d23..55019f46 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -17,10 +17,13 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.ListAccountsOption;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -39,7 +42,6 @@
 import com.google.gerrit.server.query.account.AccountPredicates;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
 import com.google.gerrit.server.query.account.AccountQueryProcessor;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.Collections;
 import java.util.EnumSet;
@@ -96,7 +98,7 @@
 
   @Option(name = "-O", usage = "Output option flags, in hex")
   void setOptionFlagsHex(String hex) {
-    options.addAll(ListAccountsOption.fromBits(Integer.parseInt(hex, 16)));
+    options.addAll(ListOption.fromBits(ListAccountsOption.class, Integer.parseInt(hex, 16)));
   }
 
   @Option(
@@ -146,14 +148,14 @@
   }
 
   @Override
-  public List<AccountInfo> apply(TopLevelResource rsrc)
-      throws OrmException, RestApiException, PermissionBackendException {
+  public Response<List<AccountInfo>> apply(TopLevelResource rsrc)
+      throws RestApiException, PermissionBackendException {
     if (Strings.isNullOrEmpty(query)) {
       throw new BadRequestException("missing query field");
     }
 
     if (suggest && (!suggestConfig || query.length() < suggestFrom)) {
-      return Collections.emptyList();
+      return Response.ok(Collections.emptyList());
     }
 
     Set<FillOptions> fillOptions = EnumSet.of(FillOptions.ID);
@@ -171,9 +173,15 @@
       fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
       fillOptions.add(FillOptions.EMAIL);
 
-      if (modifyAccountCapabilityChecked
-          || permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
+      if (modifyAccountCapabilityChecked) {
         fillOptions.add(FillOptions.SECONDARY_EMAILS);
+      } else {
+        try {
+          permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+          fillOptions.add(FillOptions.SECONDARY_EMAILS);
+        } catch (AuthException e) {
+          // Do nothing.
+        }
       }
     }
     accountLoader = accountLoaderFactory.create(fillOptions);
@@ -202,7 +210,7 @@
       }
       QueryResult<AccountState> result = queryProcessor.query(queryPred);
       for (AccountState accountState : result.entities()) {
-        Account.Id id = accountState.getAccount().getId();
+        Account.Id id = accountState.getAccount().id();
         matches.put(id, accountLoader.get(id));
       }
 
@@ -213,10 +221,10 @@
       if (!sorted.isEmpty() && result.more()) {
         sorted.get(sorted.size() - 1)._moreAccounts = true;
       }
-      return sorted;
+      return Response.ok(sorted);
     } catch (QueryParseException e) {
       if (suggest) {
-        return ImmutableList.of();
+        return Response.ok(ImmutableList.of());
       }
       throw new BadRequestException(e.getMessage());
     }
diff --git a/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
index 6aa88de..1a63993 100644
--- a/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -54,10 +54,10 @@
   }
 
   @Override
-  public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo input)
+  public Response<DiffPreferencesInfo> apply(AccountResource rsrc, DiffPreferencesInfo input)
       throws RestApiException, ConfigInvalidException, RepositoryNotFoundException, IOException,
-          PermissionBackendException, OrmException {
-    if (self.get() != rsrc.getUser()) {
+          PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
@@ -66,10 +66,11 @@
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountsUpdateProvider
-        .get()
-        .update("Set Diff Preferences via API", id, u -> u.setDiffPreferences(input))
-        .map(AccountState::getDiffPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountsUpdateProvider
+            .get()
+            .update("Set Diff Preferences via API", id, u -> u.setDiffPreferences(input))
+            .map(AccountState::getDiffPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
index dad6e0f..c85adde 100644
--- a/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -55,10 +55,10 @@
   }
 
   @Override
-  public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo input)
+  public Response<EditPreferencesInfo> apply(AccountResource rsrc, EditPreferencesInfo input)
       throws RestApiException, RepositoryNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException, OrmException {
-    if (self.get() != rsrc.getUser()) {
+          PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
@@ -67,10 +67,11 @@
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
-    return accountsUpdateProvider
-        .get()
-        .update("Set Edit Preferences via API", id, u -> u.setEditPreferences(input))
-        .map(AccountState::getEditPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountsUpdateProvider
+            .get()
+            .update("Set Edit Preferences via API", id, u -> u.setEditPreferences(input))
+            .map(AccountState::getEditPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/SetPreferences.java b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
index 11ecfdb..7967f2d 100644
--- a/java/com/google/gerrit/server/restapi/account/SetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
@@ -18,9 +18,11 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
@@ -33,7 +35,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -60,10 +61,9 @@
   }
 
   @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc, GeneralPreferencesInfo input)
-      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException,
-          OrmException {
-    if (self.get() != rsrc.getUser()) {
+  public Response<GeneralPreferencesInfo> apply(AccountResource rsrc, GeneralPreferencesInfo input)
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
@@ -71,11 +71,12 @@
     Preferences.validateMy(input.my);
     Account.Id id = rsrc.getUser().getAccountId();
 
-    return accountsUpdateProvider
-        .get()
-        .update("Set General Preferences via API", id, u -> u.setGeneralPreferences(input))
-        .map(AccountState::getGeneralPreferences)
-        .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString())));
+    return Response.ok(
+        accountsUpdateProvider
+            .get()
+            .update("Set General Preferences via API", id, u -> u.setGeneralPreferences(input))
+            .map(AccountState::getGeneralPreferences)
+            .orElseThrow(() -> new ResourceNotFoundException(IdString.fromDecoded(id.toString()))));
   }
 
   private void checkDownloadScheme(String downloadScheme) throws BadRequestException {
@@ -83,7 +84,7 @@
       return;
     }
 
-    for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
+    for (Extension<DownloadScheme> e : downloadSchemes) {
       if (e.getExportName().equals(downloadScheme) && e.getProvider().get().isEnabled()) {
         return;
       }
diff --git a/java/com/google/gerrit/server/restapi/account/SshKeys.java b/java/com/google/gerrit/server/restapi/account/SshKeys.java
index 4e44c71..6e3f905 100644
--- a/java/com/google/gerrit/server/restapi/account/SshKeys.java
+++ b/java/com/google/gerrit/server/restapi/account/SshKeys.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -64,7 +63,7 @@
 
   @Override
   public AccountResource.SshKey parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException,
+      throws ResourceNotFoundException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       try {
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index e804b64..3c14ad3 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -25,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -40,8 +42,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.QueryChanges;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -49,30 +49,26 @@
 
 @Singleton
 public class StarredChanges
-    implements ChildCollection<AccountResource, AccountResource.StarredChange>,
-        AcceptsCreate<AccountResource> {
+    implements ChildCollection<AccountResource, AccountResource.StarredChange> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangesCollection changes;
   private final DynamicMap<RestView<AccountResource.StarredChange>> views;
-  private final Provider<Create> createProvider;
   private final StarredChangesUtil starredChangesUtil;
 
   @Inject
   StarredChanges(
       ChangesCollection changes,
       DynamicMap<RestView<AccountResource.StarredChange>> views,
-      Provider<Create> createProvider,
       StarredChangesUtil starredChangesUtil) {
     this.changes = changes;
     this.views = views;
-    this.createProvider = createProvider;
     this.starredChangesUtil = starredChangesUtil;
   }
 
   @Override
   public AccountResource.StarredChange parse(AccountResource parent, IdString id)
-      throws RestApiException, OrmException, PermissionBackendException, IOException {
+      throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     if (starredChangesUtil
@@ -90,53 +86,49 @@
 
   @Override
   public RestView<AccountResource> list() throws ResourceNotFoundException {
-    return new RestReadView<AccountResource>() {
-      @Override
-      public Object apply(AccountResource self)
-          throws BadRequestException, AuthException, OrmException {
-        QueryChanges query = changes.list();
-        query.addQuery("starredby:" + self.getUser().getAccountId().get());
-        return query.apply(TopLevelResource.INSTANCE);
-      }
-    };
-  }
-
-  @Override
-  public RestModifyView<AccountResource, EmptyInput> create(AccountResource parent, IdString id)
-      throws RestApiException {
-    try {
-      return createProvider.get().setChange(changes.parse(TopLevelResource.INSTANCE, id));
-    } catch (ResourceNotFoundException e) {
-      throw new UnprocessableEntityException(String.format("change %s not found", id.get()));
-    } catch (OrmException | PermissionBackendException | IOException e) {
-      logger.atSevere().withCause(e).log("cannot resolve change");
-      throw new UnprocessableEntityException("internal server error");
-    }
+    return (RestReadView<AccountResource>)
+        self -> {
+          QueryChanges query = changes.list();
+          query.addQuery("starredby:" + self.getUser().getAccountId().get());
+          return query.apply(TopLevelResource.INSTANCE);
+        };
   }
 
   @Singleton
-  public static class Create implements RestModifyView<AccountResource, EmptyInput> {
+  public static class Create
+      implements RestCollectionCreateView<
+          AccountResource, AccountResource.StarredChange, EmptyInput> {
     private final Provider<CurrentUser> self;
+    private final ChangesCollection changes;
     private final StarredChangesUtil starredChangesUtil;
-    private ChangeResource change;
 
     @Inject
-    Create(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
+    Create(
+        Provider<CurrentUser> self,
+        ChangesCollection changes,
+        StarredChangesUtil starredChangesUtil) {
       this.self = self;
+      this.changes = changes;
       this.starredChangesUtil = starredChangesUtil;
     }
 
-    public Create setChange(ChangeResource change) {
-      this.change = change;
-      return this;
-    }
-
     @Override
-    public Response<?> apply(AccountResource rsrc, EmptyInput in)
-        throws RestApiException, OrmException, IOException {
+    public Response<?> apply(AccountResource rsrc, IdString id, EmptyInput in)
+        throws RestApiException, IOException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to add starred change");
       }
+
+      ChangeResource change;
+      try {
+        change = changes.parse(TopLevelResource.INSTANCE, id);
+      } catch (ResourceNotFoundException e) {
+        throw new UnprocessableEntityException(String.format("change %s not found", id.get()));
+      } catch (StorageException | PermissionBackendException | IOException e) {
+        logger.atSevere().withCause(e).log("cannot resolve change");
+        throw new UnprocessableEntityException("internal server error");
+      }
+
       try {
         starredChangesUtil.star(
             self.get().getAccountId(),
@@ -148,7 +140,7 @@
         throw new ResourceConflictException(e.getMessage());
       } catch (IllegalLabelException e) {
         throw new BadRequestException(e.getMessage());
-      } catch (OrmDuplicateKeyException e) {
+      } catch (DuplicateKeyException e) {
         return Response.none();
       }
       return Response.none();
@@ -187,7 +179,7 @@
 
     @Override
     public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
-        throws AuthException, OrmException, IOException, IllegalLabelException {
+        throws AuthException, IOException, IllegalLabelException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
       }
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
index fb809ee..bbbfa27 100644
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -36,7 +37,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.QueryChanges;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -68,7 +68,7 @@
 
   @Override
   public Star parse(AccountResource parent, IdString id)
-      throws RestApiException, OrmException, PermissionBackendException, IOException {
+      throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
@@ -98,14 +98,24 @@
 
     @Override
     @SuppressWarnings("unchecked")
-    public List<ChangeInfo> apply(AccountResource rsrc)
-        throws BadRequestException, AuthException, OrmException {
+    public Response<List<ChangeInfo>> apply(AccountResource rsrc)
+        throws BadRequestException, AuthException, PermissionBackendException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to list stars of another account");
       }
+
+      // The type of the value in the response that is returned by QueryChanges depends on the
+      // number of queries that is provided as input. If a single query is provided as input the
+      // value type is {@code List<ChangeInfo>}, if multiple queries are provided as input the value
+      // type is {@code List<List<ChangeInfo>>) (one {@code List<ChangeInfo>} as result to each
+      // query). Since in this case we provide exactly one query ("has:stars") as input we know that
+      // the value always has the type {@code List<ChangeInfo>} and hence we can safely cast the
+      // value to this type.
       QueryChanges query = changes.list();
       query.addQuery("has:stars");
-      return (List<ChangeInfo>) query.apply(TopLevelResource.INSTANCE);
+      Response<?> response = query.apply(TopLevelResource.INSTANCE);
+      List<ChangeInfo> value = (List<ChangeInfo>) response.value();
+      return Response.ok(value);
     }
   }
 
@@ -121,11 +131,12 @@
     }
 
     @Override
-    public SortedSet<String> apply(AccountResource.Star rsrc) throws AuthException, OrmException {
+    public Response<SortedSet<String>> apply(AccountResource.Star rsrc) throws AuthException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to get stars of another account");
       }
-      return starredChangesUtil.getLabels(self.get().getAccountId(), rsrc.getChange().getId());
+      return Response.ok(
+          starredChangesUtil.getLabels(self.get().getAccountId(), rsrc.getChange().getId()));
     }
   }
 
@@ -141,18 +152,19 @@
     }
 
     @Override
-    public Collection<String> apply(AccountResource.Star rsrc, StarsInput in)
-        throws AuthException, BadRequestException, OrmException {
+    public Response<Collection<String>> apply(AccountResource.Star rsrc, StarsInput in)
+        throws AuthException, BadRequestException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to update stars of another account");
       }
       try {
-        return starredChangesUtil.star(
-            self.get().getAccountId(),
-            rsrc.getChange().getProject(),
-            rsrc.getChange().getId(),
-            in.add,
-            in.remove);
+        return Response.ok(
+            starredChangesUtil.star(
+                self.get().getAccountId(),
+                rsrc.getChange().getProject(),
+                rsrc.getChange().getId(),
+                in.add,
+                in.remove));
       } catch (IllegalLabelException e) {
         throw new BadRequestException(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 7978990..12d9388 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -14,26 +14,22 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -41,9 +37,8 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 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;
@@ -53,37 +48,34 @@
     implements UiAction<ChangeResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final AbandonOp.Factory abandonOpFactory;
-  private final NotifyUtil notifyUtil;
+  private final NotifyResolver notifyResolver;
   private final PatchSetUtil patchSetUtil;
 
   @Inject
   Abandon(
-      Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       RetryHelper retryHelper,
       AbandonOp.Factory abandonOpFactory,
-      NotifyUtil notifyUtil,
+      NotifyResolver notifyResolver,
       PatchSetUtil patchSetUtil) {
     super(retryHelper);
-    this.dbProvider = dbProvider;
     this.json = json;
     this.abandonOpFactory = abandonOpFactory;
-    this.notifyUtil = notifyUtil;
+    this.notifyResolver = notifyResolver;
     this.patchSetUtil = patchSetUtil;
   }
 
   @Override
-  protected ChangeInfo applyImpl(
+  protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, AbandonInput input)
-      throws RestApiException, UpdateException, OrmException, PermissionBackendException,
-          IOException, ConfigInvalidException {
+      throws RestApiException, UpdateException, PermissionBackendException, IOException,
+          ConfigInvalidException {
     // Not allowed to abandon if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
-    rsrc.permissions().database(dbProvider).check(ChangePermission.ABANDON);
+    rsrc.permissions().check(ChangePermission.ABANDON);
 
     NotifyHandling notify = input.notify == null ? defaultNotify(rsrc.getChange()) : input.notify;
     Change change =
@@ -92,9 +84,8 @@
             rsrc.getNotes(),
             rsrc.getUser(),
             input.message,
-            notify,
-            notifyUtil.resolveAccounts(input.notifyDetails));
-    return json.noOptions().format(change);
+            notifyResolver.resolve(notify, input.notifyDetails));
+    return Response.ok(json.noOptions().format(change));
   }
 
   private NotifyHandling defaultNotify(Change change) {
@@ -108,8 +99,7 @@
         notes,
         user,
         "",
-        defaultNotify(notes.getChange()),
-        ImmutableListMultimap.of());
+        NotifyResolver.Result.create(defaultNotify(notes.getChange())));
   }
 
   public Change abandon(
@@ -120,8 +110,7 @@
         notes,
         user,
         msgTxt,
-        defaultNotify(notes.getChange()),
-        ImmutableListMultimap.of());
+        NotifyResolver.Result.create(defaultNotify(notes.getChange())));
   }
 
   public Change abandon(
@@ -129,13 +118,12 @@
       ChangeNotes notes,
       CurrentUser user,
       String msgTxt,
-      NotifyHandling notifyHandling,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      NotifyResolver.Result notify)
       throws RestApiException, UpdateException {
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
-    AbandonOp op = abandonOpFactory.create(accountState, msgTxt, notifyHandling, accountsToNotify);
-    try (BatchUpdate u =
-        updateFactory.create(dbProvider.get(), notes.getProjectName(), user, TimeUtil.nowTs())) {
+    AbandonOp op = abandonOpFactory.create(accountState, msgTxt);
+    try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.nowTs())) {
+      u.setNotify(notify);
       u.addOp(notes.getChangeId(), op).execute();
     }
     return op.getChange();
@@ -150,15 +138,15 @@
             .setVisible(false);
 
     Change change = rsrc.getChange();
-    if (!change.getStatus().isOpen()) {
+    if (!change.isNew()) {
       return description;
     }
 
     try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
-    } catch (OrmException | IOException e) {
+    } catch (StorageException | IOException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if the current patch set of change %s is locked", change.getId());
       return description;
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
index e4940ec..dc5c2b1 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -34,12 +34,10 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
@@ -67,18 +65,17 @@
 
   @Override
   public Response<EditInfo> apply(FixResource fixResource, Void nothing)
-      throws AuthException, OrmException, ResourceConflictException, IOException,
-          ResourceNotFoundException, PermissionBackendException {
+      throws AuthException, ResourceConflictException, IOException, ResourceNotFoundException,
+          PermissionBackendException {
     RevisionResource revisionResource = fixResource.getRevisionResource();
     Project.NameKey project = revisionResource.getProject();
     ProjectState projectState = projectCache.checkedGet(project);
     PatchSet patchSet = revisionResource.getPatchSet();
-    ObjectId patchSetCommitId = ObjectId.fromString(patchSet.getRevision().get());
 
     try (Repository repository = gitRepositoryManager.openRepository(project)) {
       List<TreeModification> treeModifications =
           fixReplacementInterpreter.toTreeModifications(
-              repository, projectState, patchSetCommitId, fixResource.getFixReplacements());
+              repository, projectState, patchSet.commitId(), fixResource.getFixReplacements());
       ChangeEdit changeEdit =
           changeEditModifier.combineWithModifiedPatchSetTree(
               repository, revisionResource.getNotes(), patchSet, treeModifications);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index b7a029b..a59ffbf 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -15,13 +15,11 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AcceptsDelete;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -32,7 +30,9 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.extensions.restapi.RestCollectionDeleteMissingView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -55,47 +55,31 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
 @Singleton
-public class ChangeEdits
-    implements ChildCollection<ChangeResource, ChangeEditResource>,
-        AcceptsCreate<ChangeResource>,
-        AcceptsPost<ChangeResource>,
-        AcceptsDelete<ChangeResource> {
+public class ChangeEdits implements ChildCollection<ChangeResource, ChangeEditResource> {
   private final DynamicMap<RestView<ChangeEditResource>> views;
-  private final Create.Factory createFactory;
-  private final DeleteFile.Factory deleteFileFactory;
   private final Provider<Detail> detail;
   private final ChangeEditUtil editUtil;
-  private final Post post;
 
   @Inject
   ChangeEdits(
       DynamicMap<RestView<ChangeEditResource>> views,
-      Create.Factory createFactory,
       Provider<Detail> detail,
-      ChangeEditUtil editUtil,
-      Post post,
-      DeleteFile.Factory deleteFileFactory) {
+      ChangeEditUtil editUtil) {
     this.views = views;
-    this.createFactory = createFactory;
     this.detail = detail;
     this.editUtil = editUtil;
-    this.post = post;
-    this.deleteFileFactory = deleteFileFactory;
   }
 
   @Override
@@ -110,7 +94,7 @@
 
   @Override
   public ChangeEditResource parse(ChangeResource rsrc, IdString id)
-      throws ResourceNotFoundException, AuthException, IOException, OrmException {
+      throws ResourceNotFoundException, AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
     if (!edit.isPresent()) {
       throw new ResourceNotFoundException(id);
@@ -118,73 +102,41 @@
     return new ChangeEditResource(rsrc, edit.get(), id.get());
   }
 
-  @Override
-  public Create create(ChangeResource parent, IdString id) throws RestApiException {
-    return createFactory.create(id.get());
-  }
-
-  @Override
-  public Post post(ChangeResource parent) throws RestApiException {
-    return post;
-  }
-
   /**
    * Create handler that is activated when collection element is accessed but doesn't exist, e. g.
    * PUT request with a path was called but change edit wasn't created yet. Change edit is created
    * and PUT handler is called.
    */
-  @Override
-  public DeleteFile delete(ChangeResource parent, IdString id) throws RestApiException {
-    // It's safe to assume that id can never be null, because
-    // otherwise we would end up in dedicated endpoint for
-    // deleting of change edits and not a file in change edit
-    return deleteFileFactory.create(id.get());
-  }
-
-  public static class Create implements RestModifyView<ChangeResource, Put.Input> {
-
-    interface Factory {
-      Create create(String path);
-    }
-
+  public static class Create
+      implements RestCollectionCreateView<ChangeResource, ChangeEditResource, Put.Input> {
     private final Put putEdit;
-    private final String path;
 
     @Inject
-    Create(Put putEdit, @Assisted String path) {
+    Create(Put putEdit) {
       this.putEdit = putEdit;
-      this.path = path;
     }
 
     @Override
-    public Response<?> apply(ChangeResource resource, Put.Input input)
-        throws AuthException, ResourceConflictException, IOException, OrmException,
-            PermissionBackendException {
-      putEdit.apply(resource, path, input.content);
+    public Response<?> apply(ChangeResource resource, IdString id, Put.Input input)
+        throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
+      putEdit.apply(resource, id.get(), input.content);
       return Response.none();
     }
   }
 
-  public static class DeleteFile implements RestModifyView<ChangeResource, Input> {
-
-    interface Factory {
-      DeleteFile create(String path);
-    }
-
+  public static class DeleteFile
+      implements RestCollectionDeleteMissingView<ChangeResource, ChangeEditResource, Input> {
     private final DeleteContent deleteContent;
-    private final String path;
 
     @Inject
-    DeleteFile(DeleteContent deleteContent, @Assisted String path) {
+    DeleteFile(DeleteContent deleteContent) {
       this.deleteContent = deleteContent;
-      this.path = path;
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, Input in)
-        throws IOException, AuthException, ResourceConflictException, OrmException,
-            PermissionBackendException {
-      return deleteContent.apply(rsrc, path);
+    public Response<?> apply(ChangeResource rsrc, IdString id, Input in)
+        throws IOException, AuthException, ResourceConflictException, PermissionBackendException {
+      return deleteContent.apply(rsrc, id.get());
     }
   }
 
@@ -196,14 +148,24 @@
     private final FileInfoJson fileInfoJson;
     private final Revisions revisions;
 
+    private String base;
+    private boolean list;
+    private boolean downloadCommands;
+
     @Option(name = "--base", metaVar = "revision-id")
-    String base;
+    public void setBase(String base) {
+      this.base = base;
+    }
 
     @Option(name = "--list")
-    boolean list;
+    public void setList(boolean list) {
+      this.list = list;
+    }
 
     @Option(name = "--download-commands")
-    boolean downloadCommands;
+    public void setDownloadCommands(boolean downloadCommands) {
+      this.downloadCommands = downloadCommands;
+    }
 
     @Inject
     Detail(
@@ -219,8 +181,7 @@
 
     @Override
     public Response<EditInfo> apply(ChangeResource rsrc)
-        throws AuthException, IOException, ResourceNotFoundException, OrmException,
-            PermissionBackendException {
+        throws AuthException, IOException, ResourceNotFoundException, PermissionBackendException {
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
       if (!edit.isPresent()) {
         return Response.none();
@@ -256,7 +217,8 @@
    * The combination of two operations in one request is supported.
    */
   @Singleton
-  public static class Post implements RestModifyView<ChangeResource, Post.Input> {
+  public static class Post
+      implements RestCollectionModifyView<ChangeResource, ChangeEditResource, Post.Input> {
     public static class Input {
       public String restorePath;
       public String oldPath;
@@ -274,8 +236,7 @@
 
     @Override
     public Response<?> apply(ChangeResource resource, Post.Input input)
-        throws AuthException, IOException, ResourceConflictException, OrmException,
-            PermissionBackendException {
+        throws AuthException, IOException, ResourceConflictException, PermissionBackendException {
       Project.NameKey project = resource.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
         if (isRestoreFile(input)) {
@@ -320,14 +281,12 @@
 
     @Override
     public Response<?> apply(ChangeEditResource rsrc, Input input)
-        throws AuthException, ResourceConflictException, IOException, OrmException,
-            PermissionBackendException {
+        throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
       return apply(rsrc.getChangeResource(), rsrc.getPath(), input.content);
     }
 
     public Response<?> apply(ChangeResource rsrc, String path, RawInput newContent)
-        throws ResourceConflictException, AuthException, IOException, OrmException,
-            PermissionBackendException {
+        throws ResourceConflictException, AuthException, IOException, PermissionBackendException {
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
         throw new ResourceConflictException("Invalid path: " + path);
       }
@@ -361,14 +320,12 @@
 
     @Override
     public Response<?> apply(ChangeEditResource rsrc, Input input)
-        throws AuthException, ResourceConflictException, OrmException, IOException,
-            PermissionBackendException {
+        throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
       return apply(rsrc.getChangeResource(), rsrc.getPath());
     }
 
     public Response<?> apply(ChangeResource rsrc, String filePath)
-        throws AuthException, IOException, OrmException, ResourceConflictException,
-            PermissionBackendException {
+        throws AuthException, IOException, ResourceConflictException, PermissionBackendException {
       try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
         editModifier.deleteFile(repository, rsrc.getNotes(), filePath);
       } catch (InvalidChangeOperationException e) {
@@ -401,9 +358,7 @@
         return Response.ok(
             fileContentUtil.getContent(
                 projectCache.checkedGet(rsrc.getChangeResource().getProject()),
-                base
-                    ? ObjectId.fromString(edit.getBasePatchSet().getRevision().get())
-                    : edit.getEditCommit(),
+                base ? edit.getBasePatchSet().commitId() : edit.getEditCommit(),
                 rsrc.getPath(),
                 null));
       } catch (ResourceNotFoundException | BadRequestException e) {
@@ -422,22 +377,22 @@
     }
 
     @Override
-    public FileInfo apply(ChangeEditResource rsrc) {
+    public Response<FileInfo> apply(ChangeEditResource rsrc) {
       FileInfo r = new FileInfo();
       ChangeEdit edit = rsrc.getChangeEdit();
       Change change = edit.getChange();
-      List<DiffWebLinkInfo> links =
+      ImmutableList<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(
               change.getProject().get(),
               change.getChangeId(),
-              edit.getBasePatchSet().getPatchSetId(),
-              edit.getBasePatchSet().getRefName(),
+              edit.getBasePatchSet().number(),
+              edit.getBasePatchSet().refName(),
               rsrc.getPath(),
               0,
               edit.getRefName(),
               rsrc.getPath());
       r.webLinks = links.isEmpty() ? null : links;
-      return r;
+      return Response.ok(r);
     }
 
     public static class FileInfo {
@@ -461,9 +416,9 @@
     }
 
     @Override
-    public Object apply(ChangeResource rsrc, Input input)
+    public Response<Object> apply(ChangeResource rsrc, Input input)
         throws AuthException, IOException, BadRequestException, ResourceConflictException,
-            OrmException, PermissionBackendException {
+            PermissionBackendException {
       if (input == null || Strings.isNullOrEmpty(input.message)) {
         throw new BadRequestException("commit message must be provided");
       }
@@ -496,26 +451,25 @@
     }
 
     @Override
-    public BinaryResult apply(ChangeResource rsrc)
-        throws AuthException, IOException, ResourceNotFoundException, OrmException {
+    public Response<BinaryResult> apply(ChangeResource rsrc)
+        throws AuthException, IOException, ResourceNotFoundException {
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
       String msg;
       if (edit.isPresent()) {
         if (base) {
           try (Repository repo = repoManager.openRepository(rsrc.getProject());
               RevWalk rw = new RevWalk(repo)) {
-            RevCommit commit =
-                rw.parseCommit(
-                    ObjectId.fromString(edit.get().getBasePatchSet().getRevision().get()));
+            RevCommit commit = rw.parseCommit(edit.get().getBasePatchSet().commitId());
             msg = commit.getFullMessage();
           }
         } else {
           msg = edit.get().getEditCommit().getFullMessage();
         }
 
-        return BinaryResult.create(msg)
-            .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
-            .base64();
+        return Response.ok(
+            BinaryResult.create(msg)
+                .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
+                .base64());
       }
       throw new ResourceNotFoundException();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java b/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
index 12b3797..ca783f3 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
@@ -15,36 +15,31 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+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.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.IncludedIn;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
 @Singleton
 public class ChangeIncludedIn implements RestReadView<ChangeResource> {
-  private Provider<ReviewDb> db;
   private PatchSetUtil psUtil;
   private IncludedIn includedIn;
 
   @Inject
-  ChangeIncludedIn(Provider<ReviewDb> db, PatchSetUtil psUtil, IncludedIn includedIn) {
-    this.db = db;
+  ChangeIncludedIn(PatchSetUtil psUtil, IncludedIn includedIn) {
     this.psUtil = psUtil;
     this.includedIn = includedIn;
   }
 
   @Override
-  public IncludedInInfo apply(ChangeResource rsrc)
-      throws RestApiException, OrmException, IOException {
-    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
-    return includedIn.apply(rsrc.getProject(), ps.getRevision().get());
+  public Response<IncludedInInfo> apply(ChangeResource rsrc) throws RestApiException, IOException {
+    PatchSet ps = psUtil.current(rsrc.getNotes());
+    return Response.ok(includedIn.apply(rsrc.getProject(), ps.commitId().name()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
index 251c66d..595d570 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -51,10 +51,10 @@
 
   @Override
   public ChangeMessageResource parse(ChangeResource parent, IdString id)
-      throws OrmException, ResourceNotFoundException {
+      throws ResourceNotFoundException, PermissionBackendException {
     String uuid = id.get();
 
-    List<ChangeMessageInfo> changeMessages = listChangeMessages.apply(parent);
+    List<ChangeMessageInfo> changeMessages = listChangeMessages.apply(parent).value();
     int index = -1;
     for (int i = 0; i < changeMessages.size(); ++i) {
       ChangeMessageInfo changeMessage = changeMessages.get(i);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index 58ea185..9f2a52c 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
@@ -25,9 +25,8 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -35,7 +34,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -43,35 +41,28 @@
 import java.util.List;
 
 @Singleton
-public class ChangesCollection
-    implements RestCollection<TopLevelResource, ChangeResource>, AcceptsPost<TopLevelResource> {
-  private final Provider<ReviewDb> db;
+public class ChangesCollection implements RestCollection<TopLevelResource, ChangeResource> {
   private final Provider<CurrentUser> user;
   private final Provider<QueryChanges> queryFactory;
   private final DynamicMap<RestView<ChangeResource>> views;
   private final ChangeFinder changeFinder;
-  private final CreateChange createChange;
   private final ChangeResource.Factory changeResourceFactory;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
 
   @Inject
   public ChangesCollection(
-      Provider<ReviewDb> db,
       Provider<CurrentUser> user,
       Provider<QueryChanges> queryFactory,
       DynamicMap<RestView<ChangeResource>> views,
       ChangeFinder changeFinder,
-      CreateChange createChange,
       ChangeResource.Factory changeResourceFactory,
       PermissionBackend permissionBackend,
       ProjectCache projectCache) {
-    this.db = db;
     this.user = user;
     this.queryFactory = queryFactory;
     this.views = views;
     this.changeFinder = changeFinder;
-    this.createChange = createChange;
     this.changeResourceFactory = changeResourceFactory;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
@@ -89,7 +80,7 @@
 
   @Override
   public ChangeResource parse(TopLevelResource root, IdString id)
-      throws RestApiException, OrmException, PermissionBackendException, IOException {
+      throws RestApiException, PermissionBackendException, IOException {
     List<ChangeNotes> notes = changeFinder.find(id.encoded(), true);
     if (notes.isEmpty()) {
       throw new ResourceNotFoundException(id);
@@ -106,7 +97,8 @@
   }
 
   public ChangeResource parse(Change.Id id)
-      throws RestApiException, OrmException, PermissionBackendException, IOException {
+      throws ResourceConflictException, ResourceNotFoundException, PermissionBackendException,
+          IOException {
     List<ChangeNotes> notes = changeFinder.find(id);
     if (notes.isEmpty()) {
       throw new ResourceNotFoundException(toIdString(id));
@@ -130,14 +122,9 @@
     return changeResourceFactory.create(notes, user);
   }
 
-  @Override
-  public CreateChange post(TopLevelResource parent) throws RestApiException {
-    return createChange;
-  }
-
   private boolean canRead(ChangeNotes notes) throws PermissionBackendException, IOException {
     try {
-      permissionBackend.currentUser().change(notes).database(db).check(ChangePermission.READ);
+      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
     } catch (AuthException e) {
       return false;
     }
@@ -149,7 +136,7 @@
   }
 
   private void checkProjectStatePermitsRead(Project.NameKey project)
-      throws IOException, RestApiException {
+      throws IOException, ResourceNotFoundException, ResourceConflictException {
     ProjectState projectState = projectCache.checkedGet(project);
     if (projectState == null) {
       throw new ResourceNotFoundException("project not found: " + project.get());
diff --git a/java/com/google/gerrit/server/restapi/change/Check.java b/java/com/google/gerrit/server/restapi/change/Check.java
index f3e0077..f62aa5a 100644
--- a/java/com/google/gerrit/server/restapi/change/Check.java
+++ b/java/com/google/gerrit/server/restapi/change/Check.java
@@ -29,10 +29,9 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
-import javax.inject.Singleton;
 
 @Singleton
 public class Check
@@ -47,14 +46,13 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException, OrmException {
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException {
     return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, FixInput input)
-      throws RestApiException, OrmException, PermissionBackendException, NoSuchProjectException,
-          IOException {
+      throws RestApiException, PermissionBackendException, NoSuchProjectException, IOException {
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
     if (!rsrc.isUserOwner()) {
       try {
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPick.java b/java/com/google/gerrit/server/restapi/change/CherryPick.java
index 53d81d7..bcbee4d 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPick.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPick.java
@@ -18,13 +18,13 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.RevisionResource;
@@ -42,7 +42,6 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -50,7 +49,7 @@
 
 @Singleton
 public class CherryPick
-    extends RetryingRestModifyView<RevisionResource, CherryPickInput, ChangeInfo>
+    extends RetryingRestModifyView<RevisionResource, CherryPickInput, CherryPickChangeInfo>
     implements UiAction<RevisionResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -77,10 +76,10 @@
   }
 
   @Override
-  public ChangeInfo applyImpl(
+  public Response<CherryPickChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, RevisionResource rsrc, CherryPickInput input)
-      throws OrmException, IOException, UpdateException, RestApiException,
-          PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
+      throws IOException, UpdateException, RestApiException, PermissionBackendException,
+          ConfigInvalidException, NoSuchProjectException {
     input.parent = input.parent == null ? 1 : input.parent;
     if (input.message == null || input.message.trim().isEmpty()) {
       throw new BadRequestException("message must be non-empty");
@@ -99,14 +98,19 @@
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
     try {
-      Change.Id cherryPickedChangeId =
+      CherryPickChange.Result cherryPickResult =
           cherryPickChange.cherryPick(
               updateFactory,
               rsrc.getChange(),
               rsrc.getPatchSet(),
               input,
-              new Branch.NameKey(rsrc.getProject(), refName));
-      return json.noOptions().format(rsrc.getProject(), cherryPickedChangeId);
+              BranchNameKey.create(rsrc.getProject(), refName));
+      CherryPickChangeInfo changeInfo =
+          json.noOptions()
+              .format(rsrc.getProject(), cherryPickResult.changeId(), CherryPickChangeInfo::new);
+      changeInfo.containsGitConflicts =
+          !cherryPickResult.filesWithGitConflicts().isEmpty() ? true : null;
+      return Response.ok(changeInfo);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
     } catch (IntegrationException | NoSuchChangeException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index b777461..afd9d8e9 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -14,33 +14,32 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
+
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 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.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -48,6 +47,7 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -57,10 +57,8 @@
 import com.google.gerrit.server.submit.IntegrationException;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -84,8 +82,17 @@
 
 @Singleton
 public class CherryPickChange {
+  @AutoValue
+  abstract static class Result {
+    static Result create(Change.Id changeId, ImmutableSet<String> filesWithGitConflicts) {
+      return new AutoValue_CherryPickChange_Result(changeId, filesWithGitConflicts);
+    }
 
-  private final Provider<ReviewDb> dbProvider;
+    abstract Change.Id changeId();
+
+    abstract ImmutableSet<String> filesWithGitConflicts();
+  }
+
   private final Sequences seq;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitRepositoryManager gitManager;
@@ -97,12 +104,10 @@
   private final ChangeNotes.Factory changeNotesFactory;
   private final ProjectCache projectCache;
   private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil changeMessagesUtil;
-  private final NotifyUtil notifyUtil;
+  private final NotifyResolver notifyResolver;
 
   @Inject
   CherryPickChange(
-      Provider<ReviewDb> dbProvider,
       Sequences seq,
       Provider<InternalChangeQuery> queryProvider,
       @GerritPersonIdent PersonIdent myIdent,
@@ -114,9 +119,7 @@
       ChangeNotes.Factory changeNotesFactory,
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil changeMessagesUtil,
-      NotifyUtil notifyUtil) {
-    this.dbProvider = dbProvider;
+      NotifyResolver notifyResolver) {
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
@@ -128,38 +131,30 @@
     this.changeNotesFactory = changeNotesFactory;
     this.projectCache = projectCache;
     this.approvalsUtil = approvalsUtil;
-    this.changeMessagesUtil = changeMessagesUtil;
-    this.notifyUtil = notifyUtil;
+    this.notifyResolver = notifyResolver;
   }
 
-  public Change.Id cherryPick(
+  public Result cherryPick(
       BatchUpdate.Factory batchUpdateFactory,
       Change change,
       PatchSet patch,
       CherryPickInput input,
-      Branch.NameKey dest)
-      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
-          UpdateException, RestApiException, ConfigInvalidException, NoSuchProjectException {
+      BranchNameKey dest)
+      throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
+          RestApiException, ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        batchUpdateFactory,
-        change,
-        patch.getId(),
-        change.getProject(),
-        ObjectId.fromString(patch.getRevision().get()),
-        input,
-        dest);
+        batchUpdateFactory, change, change.getProject(), patch.commitId(), input, dest);
   }
 
-  public Change.Id cherryPick(
+  public Result cherryPick(
       BatchUpdate.Factory batchUpdateFactory,
       @Nullable Change sourceChange,
-      @Nullable PatchSet.Id sourcePatchId,
       Project.NameKey project,
       ObjectId sourceCommit,
       CherryPickInput input,
-      Branch.NameKey dest)
-      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
-          UpdateException, RestApiException, ConfigInvalidException, NoSuchProjectException {
+      BranchNameKey dest)
+      throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
+          RestApiException, ConfigInvalidException, NoSuchProjectException {
 
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
@@ -169,10 +164,10 @@
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
-      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      Ref destRef = git.getRefDatabase().exactRef(dest.branch());
       if (destRef == null) {
         throw new InvalidChangeOperationException(
-            String.format("Branch %s does not exist.", dest.get()));
+            String.format("Branch %s does not exist.", dest.branch()));
       }
 
       RevCommit baseCommit = getBaseCommit(destRef, project.get(), revWalk, input.base);
@@ -200,35 +195,42 @@
       String commitMessage = ChangeIdUtil.insertId(input.message, computedChangeId).trim() + '\n';
 
       CodeReviewCommit cherryPickCommit;
-      ProjectState projectState = projectCache.checkedGet(dest.getParentKey());
+      ProjectState projectState = projectCache.checkedGet(dest.project());
       if (projectState == null) {
-        throw new NoSuchProjectException(dest.getParentKey());
+        throw new NoSuchProjectException(dest.project());
       }
       try {
+        MergeUtil mergeUtil;
+        if (input.allowConflicts) {
+          // allowConflicts requires to use content merge
+          mergeUtil = mergeUtilFactory.create(projectState, true);
+        } else {
+          // use content merge only if it's configured on the project
+          mergeUtil = mergeUtilFactory.create(projectState);
+        }
         cherryPickCommit =
-            mergeUtilFactory
-                .create(projectState)
-                .createCherryPickFromCommit(
-                    oi,
-                    git.getConfig(),
-                    baseCommit,
-                    commitToCherryPick,
-                    committerIdent,
-                    commitMessage,
-                    revWalk,
-                    input.parent - 1,
-                    false);
+            mergeUtil.createCherryPickFromCommit(
+                oi,
+                git.getConfig(),
+                baseCommit,
+                commitToCherryPick,
+                committerIdent,
+                commitMessage,
+                revWalk,
+                input.parent - 1,
+                false,
+                input.allowConflicts);
 
         Change.Key changeKey;
         final List<String> idList = cherryPickCommit.getFooterLines(FooterConstants.CHANGE_ID);
         if (!idList.isEmpty()) {
           final String idStr = idList.get(idList.size() - 1).trim();
-          changeKey = new Change.Key(idStr);
+          changeKey = Change.key(idStr);
         } else {
-          changeKey = new Change.Key("I" + computedChangeId.name());
+          changeKey = Change.key("I" + computedChangeId.name());
         }
 
-        Branch.NameKey newDest = new Branch.NameKey(project, destRef.getName());
+        BranchNameKey newDest = BranchNameKey.create(project, destRef.getName());
         List<ChangeData> destChanges =
             queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
         if (destChanges.size() > 1) {
@@ -238,34 +240,33 @@
                   + " reside on the same branch. "
                   + "Cannot create a new patch set.");
         }
-        try (BatchUpdate bu =
-            batchUpdateFactory.create(dbProvider.get(), project, identifiedUser, now)) {
+        try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, now)) {
           bu.setRepository(git, revWalk, oi);
-          Change.Id result;
+          bu.setNotify(resolveNotify(input));
+          Change.Id changeId;
           if (destChanges.size() == 1) {
             // The change key exists on the destination branch. The cherry pick
             // will be added as a new patch set.
-            result = insertPatchSet(bu, git, destChanges.get(0).notes(), cherryPickCommit, input);
+            changeId = insertPatchSet(bu, git, destChanges.get(0).notes(), cherryPickCommit);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
             String newTopic = null;
             if (sourceChange != null && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
-              newTopic = sourceChange.getTopic() + "-" + newDest.getShortName();
+              newTopic = sourceChange.getTopic() + "-" + newDest.shortName();
             }
-            result =
+            changeId =
                 createNewChange(
-                    bu, cherryPickCommit, dest.get(), newTopic, sourceChange, sourceCommit, input);
-
-            if (sourceChange != null && sourcePatchId != null) {
-              bu.addOp(
-                  sourceChange.getId(),
-                  new AddMessageToSourceChangeOp(
-                      changeMessagesUtil, sourcePatchId, dest.getShortName(), cherryPickCommit));
-            }
+                    bu,
+                    cherryPickCommit,
+                    dest.branch(),
+                    newTopic,
+                    sourceChange,
+                    sourceCommit,
+                    input);
           }
           bu.execute();
-          return result;
+          return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
         }
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationException("Cherry pick failed: " + e.getMessage());
@@ -274,7 +275,7 @@
   }
 
   private RevCommit getBaseCommit(Ref destRef, String project, RevWalk revWalk, String base)
-      throws RestApiException, IOException, OrmException {
+      throws RestApiException, IOException {
     RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
     // The tip commit of the destination ref is the default base for the newly created change.
     if (Strings.isNullOrEmpty(base)) {
@@ -305,31 +306,24 @@
     }
 
     Change change = changeDatas.get(0).change();
-    Change.Status status = change.getStatus();
-    if (status == Status.NEW || status == Status.MERGED) {
+    if (!change.isAbandoned()) {
       // The base commit is a valid change revision.
       return baseCommit;
     }
 
     throw new ResourceConflictException(
         String.format(
-            "Change %s with commit %s is %s", change.getChangeId(), base, status.asChangeStatus()));
+            "Change %s with commit %s is %s",
+            change.getChangeId(), base, ChangeUtil.status(change)));
   }
 
   private Change.Id insertPatchSet(
-      BatchUpdate bu,
-      Repository git,
-      ChangeNotes destNotes,
-      CodeReviewCommit cherryPickCommit,
-      CherryPickInput input)
-      throws IOException, OrmException, BadRequestException, ConfigInvalidException {
+      BatchUpdate bu, Repository git, ChangeNotes destNotes, CodeReviewCommit cherryPickCommit)
+      throws IOException {
     Change destChange = destNotes.getChange();
     PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
     PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
-    inserter
-        .setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".")
-        .setNotify(input.notify)
-        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+    inserter.setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".");
     bu.addOp(destChange.getId(), inserter);
     return destChange.getId();
   }
@@ -342,77 +336,58 @@
       @Nullable Change sourceChange,
       ObjectId sourceCommit,
       CherryPickInput input)
-      throws OrmException, IOException, BadRequestException, ConfigInvalidException {
-    Change.Id changeId = new Change.Id(seq.nextChangeId());
+      throws IOException {
+    Change.Id changeId = Change.id(seq.nextChangeId());
     ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
-    Branch.NameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
-    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch, sourceCommit))
+    BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
+    ins.setMessage(
+            messageForDestinationChange(
+                ins.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit))
         .setTopic(topic)
-        .setNotify(input.notify)
-        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+        .setWorkInProgress(
+            (sourceChange != null && sourceChange.isWorkInProgress())
+                || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
     if (input.keepReviewers && sourceChange != null) {
       ReviewerSet reviewerSet =
-          approvalsUtil.getReviewers(
-              dbProvider.get(), changeNotesFactory.createChecked(dbProvider.get(), sourceChange));
+          approvalsUtil.getReviewers(changeNotesFactory.createChecked(sourceChange));
       Set<Account.Id> reviewers =
           new HashSet<>(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
       reviewers.add(sourceChange.getOwner());
       reviewers.remove(user.get().getAccountId());
       Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
       ccs.remove(user.get().getAccountId());
-      ins.setReviewers(reviewers).setExtraCC(ccs);
+      ins.setReviewersAndCcs(reviewers, ccs);
     }
     bu.insertChange(ins);
     return changeId;
   }
 
-  private static class AddMessageToSourceChangeOp implements BatchUpdateOp {
-    private final ChangeMessagesUtil cmUtil;
-    private final PatchSet.Id psId;
-    private final String destBranch;
-    private final ObjectId cherryPickCommit;
-
-    private AddMessageToSourceChangeOp(
-        ChangeMessagesUtil cmUtil, PatchSet.Id psId, String destBranch, ObjectId cherryPickCommit) {
-      this.cmUtil = cmUtil;
-      this.psId = psId;
-      this.destBranch = destBranch;
-      this.cherryPickCommit = cherryPickCommit;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException {
-      StringBuilder sb =
-          new StringBuilder("Patch Set ")
-              .append(psId.get())
-              .append(": Cherry Picked")
-              .append("\n\n")
-              .append("This patchset was cherry picked to branch ")
-              .append(destBranch)
-              .append(" as commit ")
-              .append(cherryPickCommit.name());
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              psId,
-              ctx.getUser(),
-              ctx.getWhen(),
-              sb.toString(),
-              ChangeMessagesUtil.TAG_CHERRY_PICK_CHANGE);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
-      return true;
-    }
+  private NotifyResolver.Result resolveNotify(CherryPickInput input)
+      throws BadRequestException, ConfigInvalidException, IOException {
+    return notifyResolver.resolve(
+        firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
   }
 
   private String messageForDestinationChange(
-      PatchSet.Id patchSetId, Branch.NameKey sourceBranch, ObjectId sourceCommit) {
+      PatchSet.Id patchSetId,
+      BranchNameKey sourceBranch,
+      ObjectId sourceCommit,
+      CodeReviewCommit cherryPickCommit) {
     StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
-
     if (sourceBranch != null) {
-      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
+      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.shortName());
     } else {
       stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
     }
+    stringBuilder.append(".");
 
-    return stringBuilder.append(".").toString();
+    if (!cherryPickCommit.getFilesWithGitConflicts().isEmpty()) {
+      stringBuilder.append("\n\nThe following files contain Git conflicts:");
+      cherryPickCommit.getFilesWithGitConflicts().stream()
+          .sorted()
+          .forEach(filePath -> stringBuilder.append("\n* ").append(filePath));
+    }
+
+    return stringBuilder.toString();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
index 11aa256..25cd924 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
@@ -16,12 +16,12 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -48,7 +47,7 @@
 
 @Singleton
 public class CherryPickCommit
-    extends RetryingRestModifyView<CommitResource, CherryPickInput, ChangeInfo> {
+    extends RetryingRestModifyView<CommitResource, CherryPickInput, CherryPickChangeInfo> {
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final CherryPickChange cherryPickChange;
@@ -72,10 +71,10 @@
   }
 
   @Override
-  public ChangeInfo applyImpl(
+  public Response<CherryPickChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, CommitResource rsrc, CherryPickInput input)
-      throws OrmException, IOException, UpdateException, RestApiException,
-          PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
+      throws IOException, UpdateException, RestApiException, PermissionBackendException,
+          ConfigInvalidException, NoSuchProjectException {
     RevCommit commit = rsrc.getCommit();
     String message = Strings.nullToEmpty(input.message).trim();
     input.message = message.isEmpty() ? commit.getFullMessage() : message;
@@ -97,16 +96,20 @@
     rsrc.getProjectState().checkStatePermitsWrite();
 
     try {
-      Change.Id cherryPickedChangeId =
+      CherryPickChange.Result cherryPickResult =
           cherryPickChange.cherryPick(
               updateFactory,
               null,
-              null,
               projectName,
               commit,
               input,
-              new Branch.NameKey(rsrc.getProjectState().getNameKey(), refName));
-      return json.noOptions().format(projectName, cherryPickedChangeId);
+              BranchNameKey.create(rsrc.getProjectState().getNameKey(), refName));
+      CherryPickChangeInfo changeInfo =
+          json.noOptions()
+              .format(projectName, cherryPickResult.changeId(), CherryPickChangeInfo::new);
+      changeInfo.containsGitConflicts =
+          !cherryPickResult.filesWithGitConflicts().isEmpty() ? true : null;
+      return Response.ok(changeInfo);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
     } catch (IntegrationException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 4d06c73..7112bbf 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.CommentsUtil.COMMENT_INFO_ORDER;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
@@ -32,15 +34,14 @@
 import com.google.gerrit.reviewdb.client.FixSuggestion;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
 
-class CommentJson {
+public class CommentJson {
 
   private final AccountLoader.Factory accountLoaderFactory;
 
@@ -71,7 +72,7 @@
   }
 
   private abstract class BaseCommentFormatter<F extends Comment, T extends CommentInfo> {
-    public T format(F comment) throws OrmException {
+    public T format(F comment) throws PermissionBackendException {
       AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
       T info = toInfo(comment, loader);
       if (loader != null) {
@@ -80,7 +81,7 @@
       return info;
     }
 
-    public Map<String, List<T>> format(Iterable<F> comments) throws OrmException {
+    public Map<String, List<T>> format(Iterable<F> comments) throws PermissionBackendException {
       AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
 
       Map<String, List<T>> out = new TreeMap<>();
@@ -96,9 +97,7 @@
         list.add(o);
       }
 
-      for (List<T> list : out.values()) {
-        Collections.sort(list, COMMENT_INFO_ORDER);
-      }
+      out.values().forEach(l -> l.sort(COMMENT_INFO_ORDER));
 
       if (loader != null) {
         loader.fill();
@@ -106,13 +105,14 @@
       return out;
     }
 
-    public List<T> formatAsList(Iterable<F> comments) throws OrmException {
+    public ImmutableList<T> formatAsList(Iterable<F> comments) throws PermissionBackendException {
       AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
 
-      List<T> out =
-          FluentIterable.from(comments)
-              .transform(c -> toInfo(c, loader))
-              .toSortedList(COMMENT_INFO_ORDER);
+      ImmutableList<T> out =
+          Streams.stream(comments)
+              .map(c -> toInfo(c, loader))
+              .sorted(COMMENT_INFO_ORDER)
+              .collect(toImmutableList());
 
       if (loader != null) {
         loader.fill();
@@ -161,7 +161,7 @@
     }
   }
 
-  class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
+  public class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
     @Override
     protected CommentInfo toInfo(Comment c, AccountLoader loader) {
       CommentInfo ci = new CommentInfo();
diff --git a/java/com/google/gerrit/server/restapi/change/Comments.java b/java/com/google/gerrit/server/restapi/change/Comments.java
index f563cc6..84771b1 100644
--- a/java/com/google/gerrit/server/restapi/change/Comments.java
+++ b/java/com/google/gerrit/server/restapi/change/Comments.java
@@ -20,32 +20,26 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.CommentResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class Comments implements ChildCollection<RevisionResource, CommentResource> {
   private final DynamicMap<RestView<CommentResource>> views;
   private final ListRevisionComments list;
-  private final Provider<ReviewDb> dbProvider;
   private final CommentsUtil commentsUtil;
 
   @Inject
   Comments(
       DynamicMap<RestView<CommentResource>> views,
       ListRevisionComments list,
-      Provider<ReviewDb> dbProvider,
       CommentsUtil commentsUtil) {
     this.views = views;
     this.list = list;
-    this.dbProvider = dbProvider;
     this.commentsUtil = commentsUtil;
   }
 
@@ -60,13 +54,11 @@
   }
 
   @Override
-  public CommentResource parse(RevisionResource rev, IdString id)
-      throws ResourceNotFoundException, OrmException {
+  public CommentResource parse(RevisionResource rev, IdString id) throws ResourceNotFoundException {
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (Comment c :
-        commentsUtil.publishedByPatchSet(dbProvider.get(), notes, rev.getPatchSet().getId())) {
+    for (Comment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().id())) {
       if (uuid.equals(c.key.uuid)) {
         return new CommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 73669a1..4e89306 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
 import com.google.common.base.Joiner;
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -40,22 +41,21 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -68,14 +68,13 @@
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.RetryingRestCollectionModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.UnsupportedEncodingException;
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
@@ -97,9 +96,9 @@
 
 @Singleton
 public class CreateChange
-    extends RetryingRestModifyView<TopLevelResource, ChangeInput, Response<ChangeInfo>> {
+    extends RetryingRestCollectionModifyView<
+        TopLevelResource, ChangeResource, ChangeInput, ChangeInfo> {
   private final String anonymousCowardName;
-  private final Provider<ReviewDb> db;
   private final GitRepositoryManager gitManager;
   private final Sequences seq;
   private final TimeZone serverTimeZone;
@@ -113,14 +112,13 @@
   private final PatchSetUtil psUtil;
   private final MergeUtil.Factory mergeUtilFactory;
   private final SubmitType submitType;
-  private final NotifyUtil notifyUtil;
+  private final NotifyResolver notifyResolver;
   private final ContributorAgreementsChecker contributorAgreements;
   private final boolean disablePrivateChanges;
 
   @Inject
   CreateChange(
       @AnonymousCowardName String anonymousCowardName,
-      Provider<ReviewDb> db,
       GitRepositoryManager gitManager,
       Sequences seq,
       @GerritPersonIdent PersonIdent myIdent,
@@ -135,11 +133,10 @@
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
       MergeUtil.Factory mergeUtilFactory,
-      NotifyUtil notifyUtil,
+      NotifyResolver notifyResolver,
       ContributorAgreementsChecker contributorAgreements) {
     super(retryHelper);
     this.anonymousCowardName = anonymousCowardName;
-    this.db = db;
     this.gitManager = gitManager;
     this.seq = seq;
     this.serverTimeZone = myIdent.getTimeZone();
@@ -154,15 +151,44 @@
     this.submitType = config.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
     this.disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
     this.mergeUtilFactory = mergeUtilFactory;
-    this.notifyUtil = notifyUtil;
+    this.notifyResolver = notifyResolver;
     this.contributorAgreements = contributorAgreements;
   }
 
   @Override
   protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, TopLevelResource parent, ChangeInput input)
-      throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
-          UpdateException, PermissionBackendException, ConfigInvalidException {
+      throws IOException, InvalidChangeOperationException, RestApiException, UpdateException,
+          PermissionBackendException, ConfigInvalidException {
+    IdentifiedUser me = user.get().asIdentifiedUser();
+    checkAndSanitizeChangeInput(input, me);
+
+    ProjectResource projectResource = projectsCollection.parse(input.project);
+    ProjectState projectState = projectResource.getProjectState();
+    projectState.checkStatePermitsWrite();
+
+    Project.NameKey project = projectResource.getNameKey();
+    contributorAgreements.check(project, user.get());
+
+    checkRequiredPermissions(project, input.branch);
+
+    Change newChange = createNewChange(input, me, projectState, updateFactory);
+    ChangeJson json = jsonFactory.noOptions();
+    return Response.created(json.format(newChange));
+  }
+
+  /**
+   * Checks and sanitizes the user input, e.g. check whether the input is legal; clean the input so
+   * that it meets the requirement for creating a change; set a field based on the global configs,
+   * etc.
+   *
+   * @param input the {@code ChangeInput} from the request. Note this method modify the {@code
+   *     ChangeInput} object so that it can be reused directly by follow-up code.
+   * @param me the user who sent the current request to create a change.
+   * @throws BadRequestException if the input is not legal.
+   */
+  private void checkAndSanitizeChangeInput(ChangeInput input, IdentifiedUser me)
+      throws RestApiException, PermissionBackendException, IOException {
     if (Strings.isNullOrEmpty(input.project)) {
       throw new BadRequestException("project must be non-empty");
     }
@@ -170,159 +196,222 @@
     if (Strings.isNullOrEmpty(input.branch)) {
       throw new BadRequestException("branch must be non-empty");
     }
+    input.branch = RefNames.fullName(input.branch);
 
-    String subject = clean(Strings.nullToEmpty(input.subject));
-    if (Strings.isNullOrEmpty(subject)) {
+    String subject = Strings.nullToEmpty(input.subject);
+    subject = subject.replaceAll("(?m)^#.*$\n?", "").trim();
+    if (subject.isEmpty()) {
       throw new BadRequestException("commit message must be non-empty");
     }
+    input.subject = subject;
 
-    if (input.status != null) {
-      if (input.status != ChangeStatus.NEW) {
-        throw new BadRequestException("unsupported change status");
-      }
+    if (input.topic != null) {
+      input.topic = Strings.emptyToNull(input.topic.trim());
+    }
+
+    if (input.status != null && input.status != ChangeStatus.NEW) {
+      throw new BadRequestException("unsupported change status");
     }
 
     if (input.baseChange != null && input.baseCommit != null) {
       throw new BadRequestException("only provide one of base_change or base_commit");
     }
 
-    ProjectResource rsrc = projectsCollection.parse(input.project);
-    boolean privateByDefault = rsrc.getProjectState().is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
+    ProjectResource projectResource = projectsCollection.parse(input.project);
+    // Checks whether the change to be created should be a private change.
+    boolean privateByDefault =
+        projectResource.getProjectState().is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
     boolean isPrivate = input.isPrivate == null ? privateByDefault : input.isPrivate;
-
     if (isPrivate && disablePrivateChanges) {
       throw new MethodNotAllowedException("private changes are disabled");
     }
+    input.isPrivate = isPrivate;
 
-    contributorAgreements.check(rsrc.getNameKey(), rsrc.getUser());
+    ProjectState projectState = projectResource.getProjectState();
 
-    Project.NameKey project = rsrc.getNameKey();
-    String refName = RefNames.fullName(input.branch);
+    if (input.workInProgress == null) {
+      if (projectState.is(BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT)) {
+        input.workInProgress = true;
+      } else {
+        input.workInProgress =
+            firstNonNull(me.state().getGeneralPreferences().workInProgressByDefault, false);
+      }
+    }
+
+    if (input.merge != null) {
+      if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
+          || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
+        throw new BadRequestException("Submit type: " + submitType + " is not supported");
+      }
+    }
+  }
+
+  private void checkRequiredPermissions(Project.NameKey project, String refName)
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    try {
+      permissionBackend.currentUser().project(project).ref(refName).check(RefPermission.READ);
+    } catch (AuthException e) {
+      throw new ResourceNotFoundException(String.format("ref %s not found", refName));
+    }
+
     permissionBackend
         .currentUser()
         .project(project)
         .ref(refName)
         .check(RefPermission.CREATE_CHANGE);
-    rsrc.getProjectState().checkStatePermitsWrite();
+  }
 
-    try (Repository git = gitManager.openRepository(project);
+  private Change createNewChange(
+      ChangeInput input,
+      IdentifiedUser me,
+      ProjectState projectState,
+      BatchUpdate.Factory updateFactory)
+      throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
+          UpdateException {
+    try (Repository git = gitManager.openRepository(projectState.getNameKey());
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk rw = new RevWalk(reader)) {
-      ObjectId parentCommit;
-      List<String> groups;
-      Ref destRef = git.getRefDatabase().exactRef(refName);
+      PatchSet basePatchSet = null;
+      List<String> groups = Collections.emptyList();
       if (input.baseChange != null) {
-        List<ChangeNotes> notes = changeFinder.find(input.baseChange);
-        if (notes.size() != 1) {
-          throw new UnprocessableEntityException("Base change not found: " + input.baseChange);
-        }
-        ChangeNotes change = Iterables.getOnlyElement(notes);
-        try {
-          permissionBackend.currentUser().change(change).database(db).check(ChangePermission.READ);
-        } catch (AuthException e) {
-          throw new UnprocessableEntityException("Read not permitted for " + input.baseChange);
-        }
-        PatchSet ps = psUtil.current(db.get(), change);
-        parentCommit = ObjectId.fromString(ps.getRevision().get());
-        groups = ps.getGroups();
-      } else if (input.baseCommit != null) {
-        try {
-          parentCommit = ObjectId.fromString(input.baseCommit);
-        } catch (InvalidObjectIdException e) {
-          throw new UnprocessableEntityException(
-              String.format("Base %s doesn't represent a valid SHA-1", input.baseCommit));
-        }
-        RevCommit parentRevCommit = rw.parseCommit(parentCommit);
-        RevCommit destRefRevCommit = rw.parseCommit(destRef.getObjectId());
-        if (!rw.isMergedInto(parentRevCommit, destRefRevCommit)) {
-          throw new BadRequestException(
-              String.format("Commit %s doesn't exist on ref %s", input.baseCommit, refName));
-        }
-        groups = Collections.emptyList();
-      } else {
-        if (destRef != null) {
-          if (Boolean.TRUE.equals(input.newBranch)) {
-            throw new ResourceConflictException(
-                String.format("Branch %s already exists.", refName));
-          }
-          parentCommit = destRef.getObjectId();
-        } else {
-          if (Boolean.TRUE.equals(input.newBranch)) {
-            parentCommit = null;
-          } else {
-            throw new BadRequestException("Must provide a destination branch");
-          }
-        }
-        groups = Collections.emptyList();
+        ChangeNotes baseChange = getBaseChange(input.baseChange);
+        basePatchSet = psUtil.current(baseChange);
+        groups = basePatchSet.groups();
       }
+      ObjectId parentCommit =
+          getParentCommit(git, rw, input.branch, input.newBranch, basePatchSet, input.baseCommit);
+
       RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
 
       Timestamp now = TimeUtil.nowTs();
-      IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
-      AccountState accountState = me.state();
-      GeneralPreferencesInfo info = accountState.getGeneralPreferences();
-
-      // Add a Change-Id line if there isn't already one
-      String commitMessage = subject;
-      if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
-        ObjectId treeId = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree();
-        ObjectId id = ChangeIdUtil.computeChangeId(treeId, mergeTip, author, author, commitMessage);
-        commitMessage = ChangeIdUtil.insertId(commitMessage, id);
-      }
-
-      if (Boolean.TRUE.equals(info.signedOffBy)) {
-        commitMessage =
-            Joiner.on("\n")
-                .join(
-                    commitMessage.trim(),
-                    String.format(
-                        "%s%s",
-                        SIGNED_OFF_BY_TAG,
-                        accountState.getAccount().getNameEmail(anonymousCowardName)));
-      }
+      String commitMessage = getCommitMessage(input.subject, me, oi, mergeTip, author);
 
       RevCommit c;
       if (input.merge != null) {
         // create a merge commit
-        if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
-            || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
-          throw new BadRequestException("Submit type: " + submitType + " is not supported");
-        }
-        c =
-            newMergeCommit(
-                git, oi, rw, rsrc.getProjectState(), mergeTip, input.merge, author, commitMessage);
+        c = newMergeCommit(git, oi, rw, projectState, mergeTip, input.merge, author, commitMessage);
       } else {
         // create an empty commit
         c = newCommit(oi, rw, author, mergeTip, commitMessage);
       }
 
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
-      ChangeInserter ins = changeInserterFactory.create(changeId, c, refName);
+      Change.Id changeId = Change.id(seq.nextChangeId());
+      ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
       ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
-      String topic = input.topic;
-      if (topic != null) {
-        topic = Strings.emptyToNull(topic.trim());
-      }
-      ins.setTopic(topic);
-      ins.setPrivate(isPrivate);
-      ins.setWorkInProgress(input.workInProgress != null && input.workInProgress);
+      ins.setTopic(input.topic);
+      ins.setPrivate(input.isPrivate);
+      ins.setWorkInProgress(input.workInProgress);
       ins.setGroups(groups);
-      ins.setNotify(input.notify);
-      ins.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-      try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
+      try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
         bu.setRepository(git, rw, oi);
+        bu.setNotify(
+            notifyResolver.resolve(
+                firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
         bu.insertChange(ins);
         bu.execute();
       }
-      ChangeJson json = jsonFactory.noOptions();
-      return Response.created(json.format(ins.getChange()));
+      return ins.getChange();
     } catch (IllegalArgumentException e) {
       throw new BadRequestException(e.getMessage());
     }
   }
 
+  private ChangeNotes getBaseChange(String baseChange)
+      throws UnprocessableEntityException, PermissionBackendException {
+    List<ChangeNotes> notes = changeFinder.find(baseChange);
+    if (notes.size() != 1) {
+      throw new UnprocessableEntityException("Base change not found: " + baseChange);
+    }
+    ChangeNotes change = Iterables.getOnlyElement(notes);
+    try {
+      permissionBackend.currentUser().change(change).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new UnprocessableEntityException("Read not permitted for " + baseChange);
+    }
+
+    return change;
+  }
+
+  @Nullable
+  private ObjectId getParentCommit(
+      Repository repo,
+      RevWalk revWalk,
+      String inputBranch,
+      @Nullable Boolean newBranch,
+      @Nullable PatchSet basePatchSet,
+      @Nullable String baseCommit)
+      throws BadRequestException, IOException, UnprocessableEntityException,
+          ResourceConflictException {
+    if (basePatchSet != null) {
+      return basePatchSet.commitId();
+    }
+
+    Ref destRef = repo.getRefDatabase().exactRef(inputBranch);
+    ObjectId parentCommit;
+    if (baseCommit != null) {
+      try {
+        parentCommit = ObjectId.fromString(baseCommit);
+      } catch (InvalidObjectIdException e) {
+        throw new UnprocessableEntityException(
+            String.format("Base %s doesn't represent a valid SHA-1", baseCommit));
+      }
+
+      RevCommit parentRevCommit = revWalk.parseCommit(parentCommit);
+      RevCommit destRefRevCommit = revWalk.parseCommit(destRef.getObjectId());
+      if (!revWalk.isMergedInto(parentRevCommit, destRefRevCommit)) {
+        throw new BadRequestException(
+            String.format("Commit %s doesn't exist on ref %s", baseCommit, inputBranch));
+      }
+    } else {
+      if (destRef != null) {
+        if (Boolean.TRUE.equals(newBranch)) {
+          throw new ResourceConflictException(
+              String.format("Branch %s already exists.", inputBranch));
+        }
+        parentCommit = destRef.getObjectId();
+      } else {
+        if (Boolean.TRUE.equals(newBranch)) {
+          parentCommit = null;
+        } else {
+          throw new BadRequestException("Must provide a destination branch");
+        }
+      }
+    }
+
+    return parentCommit;
+  }
+
+  private String getCommitMessage(
+      String subject,
+      IdentifiedUser me,
+      ObjectInserter objectInserter,
+      RevCommit mergeTip,
+      PersonIdent author)
+      throws IOException {
+    // Add a Change-Id line if there isn't already one
+    String commitMessage = subject;
+    if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
+      ObjectId treeId = mergeTip == null ? emptyTreeId(objectInserter) : mergeTip.getTree();
+      ObjectId id = ChangeIdUtil.computeChangeId(treeId, mergeTip, author, author, commitMessage);
+      commitMessage = ChangeIdUtil.insertId(commitMessage, id);
+    }
+
+    if (Boolean.TRUE.equals(me.state().getGeneralPreferences().signedOffBy)) {
+      commitMessage =
+          Joiner.on("\n")
+              .join(
+                  commitMessage.trim(),
+                  String.format(
+                      "%s%s",
+                      SIGNED_OFF_BY_TAG,
+                      me.state().getAccount().getNameEmail(anonymousCowardName)));
+    }
+
+    return commitMessage;
+  }
+
   private static RevCommit newCommit(
       ObjectInserter oi,
       RevWalk rw,
@@ -365,8 +454,7 @@
     MergeUtil mergeUtil = mergeUtilFactory.create(projectState);
     // default merge strategy from project settings
     String mergeStrategy =
-        MoreObjects.firstNonNull(
-            Strings.emptyToNull(merge.strategy), mergeUtil.mergeStrategyName());
+        firstNonNull(Strings.emptyToNull(merge.strategy), mergeUtil.mergeStrategyName());
 
     return MergeUtil.createMergeCommit(
         oi,
@@ -379,8 +467,7 @@
         rw);
   }
 
-  private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit)
-      throws IOException, UnsupportedEncodingException {
+  private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit) throws IOException {
     ObjectId id = inserter.insert(commit);
     inserter.flush();
     return id;
@@ -389,16 +476,4 @@
   private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
     return inserter.insert(new TreeFormatter());
   }
-
-  /**
-   * Remove comment lines from a commit message.
-   *
-   * <p>Based on {@link org.eclipse.jgit.util.ChangeIdUtil#clean}.
-   *
-   * @param msg
-   * @return message without comment lines, possibly empty.
-   */
-  private String clean(String msg) {
-    return msg.replaceAll("(?m)^#.*$\n?", "").trim();
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index be689ca..65bda7d 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -29,19 +28,19 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -49,8 +48,7 @@
 
 @Singleton
 public class CreateDraftComment
-    extends RetryingRestModifyView<RevisionResource, DraftInput, Response<CommentInfo>> {
-  private final Provider<ReviewDb> db;
+    extends RetryingRestModifyView<RevisionResource, DraftInput, CommentInfo> {
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
@@ -58,14 +56,12 @@
 
   @Inject
   CreateDraftComment(
-      Provider<ReviewDb> db,
       RetryHelper retryHelper,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache) {
     super(retryHelper);
-    this.db = db;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
@@ -75,7 +71,7 @@
   @Override
   protected Response<CommentInfo> applyImpl(
       BatchUpdate.Factory updateFactory, RevisionResource rsrc, DraftInput in)
-      throws RestApiException, UpdateException, OrmException {
+      throws RestApiException, UpdateException, PermissionBackendException {
     if (Strings.isNullOrEmpty(in.path)) {
       throw new BadRequestException("path must be non-empty");
     } else if (in.message == null || in.message.trim().isEmpty()) {
@@ -87,8 +83,8 @@
     }
 
     try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getPatchSet().getId(), in);
+        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getPatchSet().id(), in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.created(
@@ -109,9 +105,9 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, OrmException, UnprocessableEntityException,
+        throws ResourceNotFoundException, UnprocessableEntityException,
             PatchListNotAvailableException {
-      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      PatchSet ps = psUtil.get(ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
@@ -119,15 +115,13 @@
 
       comment =
           commentsUtil.newComment(
-              ctx, in.path, ps.getId(), in.side(), in.message.trim(), in.unresolved, parentUuid);
+              ctx, in.path, ps.id(), in.side(), in.message.trim(), in.unresolved, parentUuid);
       comment.setLineNbrAndRange(in.line, in.range);
       comment.tag = in.tag;
 
-      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
 
-      commentsUtil.putComments(
-          ctx.getDb(), ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
-      ctx.dontBumpLastUpdatedOn();
+      commentsUtil.putComments(ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index ff85880..0539a44 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -17,8 +17,6 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.MergeInput;
@@ -30,19 +28,19 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
@@ -58,7 +56,7 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -78,8 +76,7 @@
 
 @Singleton
 public class CreateMergePatchSet
-    extends RetryingRestModifyView<ChangeResource, MergePatchSetInput, Response<ChangeInfo>> {
-  private final Provider<ReviewDb> db;
+    extends RetryingRestModifyView<ChangeResource, MergePatchSetInput, ChangeInfo> {
   private final GitRepositoryManager gitManager;
   private final CommitsCollection commits;
   private final TimeZone serverTimeZone;
@@ -94,7 +91,6 @@
 
   @Inject
   CreateMergePatchSet(
-      Provider<ReviewDb> db,
       GitRepositoryManager gitManager,
       CommitsCollection commits,
       @GerritPersonIdent PersonIdent myIdent,
@@ -108,7 +104,6 @@
       ChangeFinder changeFinder,
       PermissionBackend permissionBackend) {
     super(retryHelper);
-    this.db = db;
     this.gitManager = gitManager;
     this.commits = commits;
     this.serverTimeZone = myIdent.getTimeZone();
@@ -125,12 +120,11 @@
   @Override
   protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, MergePatchSetInput in)
-      throws OrmException, IOException, RestApiException, UpdateException,
-          PermissionBackendException {
+      throws IOException, RestApiException, UpdateException, PermissionBackendException {
     // Not allowed to create a new patch set if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
-    rsrc.permissions().database(db).check(ChangePermission.ADD_PATCH_SET);
+    rsrc.permissions().check(ChangePermission.ADD_PATCH_SET);
 
     ProjectState projectState = projectCache.checkedGet(rsrc.getProject());
     projectState.checkStatePermitsWrite();
@@ -141,10 +135,10 @@
     }
     in.baseChange = Strings.nullToEmpty(in.baseChange).trim();
 
-    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
+    PatchSet ps = psUtil.current(rsrc.getNotes());
     Change change = rsrc.getChange();
     Project.NameKey project = change.getProject();
-    Branch.NameKey dest = change.getDest();
+    BranchNameKey dest = change.getDest();
     try (Repository git = gitManager.openRepository(project);
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
@@ -160,10 +154,10 @@
       List<String> groups = null;
       if (!in.inheritParent && !in.baseChange.isEmpty()) {
         PatchSet basePS = findBasePatchSet(in.baseChange);
-        currentPsCommit = rw.parseCommit(ObjectId.fromString(basePS.getRevision().get()));
-        groups = basePS.getGroups();
+        currentPsCommit = rw.parseCommit(basePS.commitId());
+        groups = basePS.groups();
       } else {
-        currentPsCommit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+        currentPsCommit = rw.parseCommit(ps.commitId());
       }
 
       Timestamp now = TimeUtil.nowTs();
@@ -182,16 +176,15 @@
               author,
               ObjectId.fromString(change.getKey().get().substring(1)));
 
-      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
+      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.id());
       PatchSetInserter psInserter =
           patchSetInserterFactory.create(rsrc.getNotes(), nextPsId, newCommit);
-      try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
+      try (BatchUpdate bu = updateFactory.create(project, me, now)) {
         bu.setRepository(git, rw, oi);
+        bu.setNotify(NotifyResolver.Result.none());
         psInserter
             .setMessage("Uploaded patch set " + nextPsId.get() + ".")
-            .setNotify(NotifyHandling.NONE)
-            .setCheckAddPatchSetPermission(false)
-            .setNotify(NotifyHandling.NONE);
+            .setCheckAddPatchSetPermission(false);
         if (groups != null) {
           psInserter.setGroups(groups);
         }
@@ -205,24 +198,24 @@
   }
 
   private PatchSet findBasePatchSet(String baseChange)
-      throws PermissionBackendException, OrmException, UnprocessableEntityException {
+      throws PermissionBackendException, UnprocessableEntityException {
     List<ChangeNotes> notes = changeFinder.find(baseChange);
     if (notes.size() != 1) {
       throw new UnprocessableEntityException("Base change not found: " + baseChange);
     }
     ChangeNotes change = Iterables.getOnlyElement(notes);
     try {
-      permissionBackend.currentUser().change(change).database(db).check(ChangePermission.READ);
+      permissionBackend.currentUser().change(change).check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new UnprocessableEntityException("Read not permitted for " + baseChange);
     }
-    return psUtil.current(db.get(), change);
+    return psUtil.current(change);
   }
 
   private RevCommit createMergeCommit(
       MergePatchSetInput in,
       ProjectState projectState,
-      Branch.NameKey dest,
+      BranchNameKey dest,
       Repository git,
       ObjectInserter oi,
       RevWalk rw,
@@ -241,7 +234,7 @@
       parentCommit = currentPsCommit.getId();
     } else {
       // get the current branch tip of destination branch
-      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      Ref destRef = git.getRefDatabase().exactRef(dest.branch());
       if (destRef != null) {
         parentCommit = destRef.getObjectId();
       } else {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index eb1e10e..2a4f16b 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
@@ -22,7 +21,6 @@
 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.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
@@ -39,17 +37,14 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeleteAssignee
-    extends RetryingRestModifyView<ChangeResource, Input, Response<AccountInfo>> {
+public class DeleteAssignee extends RetryingRestModifyView<ChangeResource, Input, AccountInfo> {
 
   private final ChangeMessagesUtil cmUtil;
-  private final Provider<ReviewDb> db;
   private final AssigneeChanged assigneeChanged;
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountLoader.Factory accountLoaderFactory;
@@ -58,13 +53,11 @@
   DeleteAssignee(
       RetryHelper retryHelper,
       ChangeMessagesUtil cmUtil,
-      Provider<ReviewDb> db,
       AssigneeChanged assigneeChanged,
       IdentifiedUser.GenericFactory userFactory,
       AccountLoader.Factory accountLoaderFactory) {
     super(retryHelper);
     this.cmUtil = cmUtil;
-    this.db = db;
     this.assigneeChanged = assigneeChanged;
     this.userFactory = userFactory;
     this.accountLoaderFactory = accountLoaderFactory;
@@ -73,11 +66,11 @@
   @Override
   protected Response<AccountInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+      throws RestApiException, UpdateException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
 
     try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op();
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -93,7 +86,7 @@
     private AccountState deletedAssignee;
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws RestApiException, OrmException {
+    public boolean updateChange(ChangeContext ctx) throws RestApiException {
       change = ctx.getChange();
       ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
       Account.Id currentAssigneeId = change.getAssignee();
@@ -111,21 +104,21 @@
     }
 
     public Account.Id getDeletedAssignee() {
-      return deletedAssignee != null ? deletedAssignee.getAccount().getId() : null;
+      return deletedAssignee != null ? deletedAssignee.getAccount().id() : null;
     }
 
-    private void addMessage(ChangeContext ctx, ChangeUpdate update, IdentifiedUser deletedAssignee)
-        throws OrmException {
+    private void addMessage(
+        ChangeContext ctx, ChangeUpdate update, IdentifiedUser deletedAssignee) {
       ChangeMessage cmsg =
           ChangeMessagesUtil.newMessage(
               ctx,
               "Assignee deleted: " + deletedAssignee.getNameEmail(),
               ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
-      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+      cmUtil.addChangeMessage(update, cmsg);
     }
 
     @Override
-    public void postUpdate(Context ctx) throws OrmException {
+    public void postUpdate(Context ctx) {
       assigneeChanged.fire(change, ctx.getAccount(), deletedAssignee, ctx.getWhen());
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChange.java b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
index 4bd10ed..7e5881f 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChange.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
@@ -16,56 +16,50 @@
 
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.DeleteChangeOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.Order;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Object>
     implements UiAction<ChangeResource> {
 
-  private final Provider<ReviewDb> db;
-  private final Provider<DeleteChangeOp> opProvider;
+  private final DeleteChangeOp.Factory opFactory;
 
   @Inject
-  public DeleteChange(
-      Provider<ReviewDb> db, RetryHelper retryHelper, Provider<DeleteChangeOp> opProvider) {
+  public DeleteChange(RetryHelper retryHelper, DeleteChangeOp.Factory opFactory) {
     super(retryHelper);
-    this.db = db;
-    this.opProvider = opProvider;
+    this.opFactory = opFactory;
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<Object> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
-    if (!isChangeDeletable(rsrc.getChange().getStatus())) {
+    if (!isChangeDeletable(rsrc)) {
       throw new MethodNotAllowedException("delete not permitted");
     }
-    rsrc.permissions().database(db).check(ChangePermission.DELETE);
+    rsrc.permissions().check(ChangePermission.DELETE);
 
     try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Change.Id id = rsrc.getChange().getId();
-      bu.setOrder(Order.DB_BEFORE_REPO);
-      bu.addOp(id, opProvider.get());
+      bu.addOp(id, opFactory.create(id));
       bu.execute();
     }
     return Response.none();
@@ -73,16 +67,16 @@
 
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
-    Change.Status status = rsrc.getChange().getStatus();
-    PermissionBackend.ForChange perm = rsrc.permissions().database(db);
+    PermissionBackend.ForChange perm = rsrc.permissions();
     return new UiAction.Description()
         .setLabel("Delete")
         .setTitle("Delete change " + rsrc.getId())
-        .setVisible(and(isChangeDeletable(status), perm.testCond(ChangePermission.DELETE)));
+        .setVisible(and(isChangeDeletable(rsrc), perm.testCond(ChangePermission.DELETE)));
   }
 
-  private static boolean isChangeDeletable(Change.Status status) {
-    if (status == Change.Status.MERGED) {
+  private static boolean isChangeDeletable(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    if (change.isMerged()) {
       // Merged changes should never be deleted.
       return false;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java
index 942b191..f7f808a 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java
@@ -18,19 +18,19 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.server.change.ChangeEditResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 
 @Singleton
-public class DeleteChangeEdit implements RestModifyView<ChangeResource, Input> {
-
+public class DeleteChangeEdit
+    implements RestCollectionModifyView<ChangeResource, ChangeEditResource, Input> {
   private final ChangeEditUtil editUtil;
 
   @Inject
@@ -40,7 +40,7 @@
 
   @Override
   public Response<?> apply(ChangeResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, IOException, OrmException {
+      throws AuthException, ResourceNotFoundException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
     if (edit.isPresent()) {
       editUtil.delete(edit.get());
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
new file mode 100644
index 0000000..8931196
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ChangeMessageResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+
+/** Deletes a change message by rewriting history. */
+@Singleton
+public class DeleteChangeMessage
+    extends RetryingRestModifyView<
+        ChangeMessageResource, DeleteChangeMessageInput, ChangeMessageInfo> {
+
+  private final Provider<CurrentUser> userProvider;
+  private final PermissionBackend permissionBackend;
+  private final ChangeMessagesUtil changeMessagesUtil;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  public DeleteChangeMessage(
+      Provider<CurrentUser> userProvider,
+      PermissionBackend permissionBackend,
+      ChangeMessagesUtil changeMessagesUtil,
+      AccountLoader.Factory accountLoaderFactory,
+      ChangeNotes.Factory notesFactory,
+      RetryHelper retryHelper) {
+    super(retryHelper);
+    this.userProvider = userProvider;
+    this.permissionBackend = permissionBackend;
+    this.changeMessagesUtil = changeMessagesUtil;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.notesFactory = notesFactory;
+  }
+
+  @Override
+  public Response<ChangeMessageInfo> applyImpl(
+      BatchUpdate.Factory updateFactory,
+      ChangeMessageResource resource,
+      DeleteChangeMessageInput input)
+      throws RestApiException, PermissionBackendException, UpdateException, IOException {
+    CurrentUser user = userProvider.get();
+    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    String newChangeMessage =
+        createNewChangeMessage(user.asIdentifiedUser().getName(), input.reason);
+    DeleteChangeMessageOp deleteChangeMessageOp =
+        new DeleteChangeMessageOp(resource.getChangeMessageId(), newChangeMessage);
+    try (BatchUpdate batchUpdate =
+        updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.nowTs())) {
+      batchUpdate.addOp(resource.getChangeId(), deleteChangeMessageOp).execute();
+    }
+
+    ChangeMessageInfo updatedMessageInfo =
+        createUpdatedChangeMessageInfo(resource.getChangeId(), resource.getChangeMessageIndex());
+    return Response.created(updatedMessageInfo);
+  }
+
+  private ChangeMessageInfo createUpdatedChangeMessageInfo(Change.Id id, int targetIdx)
+      throws PermissionBackendException {
+    List<ChangeMessage> messages = changeMessagesUtil.byChange(notesFactory.createChecked(id));
+    ChangeMessage updatedChangeMessage = messages.get(targetIdx);
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
+    ChangeMessageInfo info = createChangeMessageInfo(updatedChangeMessage, accountLoader);
+    accountLoader.fill();
+    return info;
+  }
+
+  @VisibleForTesting
+  public static String createNewChangeMessage(String deletedBy, @Nullable String deletedReason) {
+    requireNonNull(deletedBy, "user name must not be null");
+
+    if (Strings.isNullOrEmpty(deletedReason)) {
+      return createNewChangeMessage(deletedBy);
+    }
+    return String.format("Change message removed by: %s\nReason: %s", deletedBy, deletedReason);
+  }
+
+  @VisibleForTesting
+  public static String createNewChangeMessage(String deletedBy) {
+    requireNonNull(deletedBy, "user name must not be null");
+
+    return "Change message removed by: " + deletedBy;
+  }
+
+  private class DeleteChangeMessageOp implements BatchUpdateOp {
+    private final String targetMessageId;
+    private final String newMessage;
+
+    DeleteChangeMessageOp(String targetMessageIdx, String newMessage) {
+      this.targetMessageId = targetMessageIdx;
+      this.newMessage = newMessage;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) {
+      PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+      changeMessagesUtil.replaceChangeMessage(ctx.getUpdate(psId), targetMessageId, newMessage);
+      return true;
+    }
+  }
+
+  @Singleton
+  public static class DefaultDeleteChangeMessage
+      extends RetryingRestModifyView<ChangeMessageResource, Input, ChangeMessageInfo> {
+    private final DeleteChangeMessage deleteChangeMessage;
+
+    @Inject
+    public DefaultDeleteChangeMessage(
+        DeleteChangeMessage deleteChangeMessage, RetryHelper retryHelper) {
+      super(retryHelper);
+      this.deleteChangeMessage = deleteChangeMessage;
+    }
+
+    @Override
+    protected Response<ChangeMessageInfo> applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeMessageResource resource, Input input)
+        throws Exception {
+      return deleteChangeMessage.applyImpl(updateFactory, resource, new DeleteChangeMessageInput());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
deleted file mode 100644
index 9658fb4..0000000
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Order;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-class DeleteChangeOp implements BatchUpdateOp {
-  private final PatchSetUtil psUtil;
-  private final StarredChangesUtil starredChangesUtil;
-  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-
-  private Change.Id id;
-
-  @Inject
-  DeleteChangeOp(
-      PatchSetUtil psUtil,
-      StarredChangesUtil starredChangesUtil,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
-    this.psUtil = psUtil;
-    this.starredChangesUtil = starredChangesUtil;
-    this.accountPatchReviewStore = accountPatchReviewStore;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException, NoSuchChangeException {
-    checkState(
-        ctx.getOrder() == Order.DB_BEFORE_REPO, "must use DeleteChangeOp with DB_BEFORE_REPO");
-    checkState(id == null, "cannot reuse DeleteChangeOp");
-
-    id = ctx.getChange().getId();
-    Collection<PatchSet> patchSets = psUtil.byChange(ctx.getDb(), ctx.getNotes());
-
-    ensureDeletable(ctx, id, patchSets);
-    // Cleaning up is only possible as long as the change and its elements are
-    // still part of the database.
-    cleanUpReferences(ctx, id, patchSets);
-    deleteChangeElementsFromDb(ctx, id);
-
-    ctx.deleteChange();
-    return true;
-  }
-
-  private void ensureDeletable(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
-      throws ResourceConflictException, MethodNotAllowedException, IOException {
-    Change.Status status = ctx.getChange().getStatus();
-    if (status == Change.Status.MERGED) {
-      throw new MethodNotAllowedException("Deleting merged change " + id + " is not allowed");
-    }
-    for (PatchSet patchSet : patchSets) {
-      if (isPatchSetMerged(ctx, patchSet)) {
-        throw new ResourceConflictException(
-            String.format(
-                "Cannot delete change %s: patch set %s is already merged",
-                id, patchSet.getPatchSetId()));
-      }
-    }
-  }
-
-  private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
-    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().get());
-    if (!destId.isPresent()) {
-      return false;
-    }
-
-    RevWalk revWalk = ctx.getRevWalk();
-    ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
-    return revWalk.isMergedInto(revWalk.parseCommit(objectId), revWalk.parseCommit(destId.get()));
-  }
-
-  private void deleteChangeElementsFromDb(ChangeContext ctx, Change.Id id) throws OrmException {
-    // Only delete from ReviewDb here; deletion from NoteDb is handled in
-    // BatchUpdate.
-    //
-    // This is special. We want to delete exactly the rows that are present in
-    // the database, even when reading everything else from NoteDb, so we need
-    // to bypass the write-only wrapper.
-    ReviewDb db = BatchUpdateReviewDb.unwrap(ctx.getDb());
-    db.patchComments().delete(db.patchComments().byChange(id));
-    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
-    db.patchSets().delete(db.patchSets().byChange(id));
-    db.changeMessages().delete(db.changeMessages().byChange(id));
-  }
-
-  private void cleanUpReferences(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
-      throws OrmException, NoSuchChangeException {
-    for (PatchSet ps : patchSets) {
-      accountPatchReviewStore.get().clearReviewed(ps.getId());
-    }
-
-    // Non-atomic operation on Accounts table; not much we can do to make it
-    // atomic.
-    starredChangesUtil.unstarAll(ctx.getChange().getProject(), id);
-  }
-
-  @Override
-  public void updateRepo(RepoContext ctx) throws IOException {
-    String prefix = new PatchSet.Id(id, 1).toRefName();
-    prefix = prefix.substring(0, prefix.length() - 1);
-    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
-      ctx.addRefUpdate(e.getValue(), ObjectId.zeroId(), prefix + e.getKey());
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 4320cd6..fe793b5 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -15,15 +15,14 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.CommentResource;
@@ -37,7 +36,7 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -51,7 +50,6 @@
     extends RetryingRestModifyView<CommentResource, DeleteCommentInput, CommentInfo> {
 
   private final Provider<CurrentUser> userProvider;
-  private final Provider<ReviewDb> dbProvider;
   private final PermissionBackend permissionBackend;
   private final CommentsUtil commentsUtil;
   private final Provider<CommentJson> commentJson;
@@ -60,7 +58,6 @@
   @Inject
   public DeleteComment(
       Provider<CurrentUser> userProvider,
-      Provider<ReviewDb> dbProvider,
       PermissionBackend permissionBackend,
       RetryHelper retryHelper,
       CommentsUtil commentsUtil,
@@ -68,7 +65,6 @@
       ChangeNotes.Factory notesFactory) {
     super(retryHelper);
     this.userProvider = userProvider;
-    this.dbProvider = dbProvider;
     this.permissionBackend = permissionBackend;
     this.commentsUtil = commentsUtil;
     this.commentJson = commentJson;
@@ -76,24 +72,28 @@
   }
 
   @Override
-  public CommentInfo applyImpl(
+  public Response<CommentInfo> applyImpl(
       BatchUpdate.Factory batchUpdateFactory, CommentResource rsrc, DeleteCommentInput input)
-      throws RestApiException, IOException, ConfigInvalidException, OrmException,
-          PermissionBackendException, UpdateException {
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException,
+          UpdateException {
     CurrentUser user = userProvider.get();
     permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
 
+    if (input == null) {
+      input = new DeleteCommentInput();
+    }
+
     String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
     DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
     try (BatchUpdate batchUpdate =
         batchUpdateFactory.create(
-            dbProvider.get(), rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
+            rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
       batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
     }
 
     ChangeNotes updatedNotes =
         notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
-    List<Comment> changeComments = commentsUtil.publishedByChange(dbProvider.get(), updatedNotes);
+    List<Comment> changeComments = commentsUtil.publishedByChange(updatedNotes);
     Optional<Comment> updatedComment =
         changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
     if (!updatedComment.isPresent()) {
@@ -101,7 +101,7 @@
       throw new ResourceNotFoundException("comment not found: " + rsrc.getComment().key);
     }
 
-    return commentJson.get().newCommentFormatter().format(updatedComment.get());
+    return Response.ok(commentJson.get().newCommentFormatter().format(updatedComment.get()));
   }
 
   private static String getCommentNewMessage(String name, String reason) {
@@ -123,14 +123,10 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceConflictException, OrmException, ResourceNotFoundException {
+        throws ResourceConflictException, ResourceNotFoundException {
       PatchSet.Id psId = ctx.getChange().currentPatchSetId();
       commentsUtil.deleteCommentByRewritingHistory(
-          ctx.getDb(),
-          ctx.getUpdate(psId),
-          rsrc.getComment().key,
-          rsrc.getPatchSet().getId(),
-          newMessage);
+          ctx.getUpdate(psId), rsrc.getComment().key, newMessage);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index e81f9f1..a3228ce 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -24,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
@@ -36,31 +34,27 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.Collections;
 import java.util.Optional;
 
 @Singleton
 public class DeleteDraftComment
-    extends RetryingRestModifyView<DraftCommentResource, Input, Response<CommentInfo>> {
+    extends RetryingRestModifyView<DraftCommentResource, Input, CommentInfo> {
 
-  private final Provider<ReviewDb> db;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComment(
-      Provider<ReviewDb> db,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       RetryHelper retryHelper,
       PatchListCache patchListCache) {
     super(retryHelper);
-    this.db = db;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
@@ -71,8 +65,7 @@
       BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, Input input)
       throws RestApiException, UpdateException {
     try (BatchUpdate bu =
-        updateFactory.create(
-            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op(rsrc.getComment().key);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -89,21 +82,20 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, OrmException, PatchListNotAvailableException {
+        throws ResourceNotFoundException, PatchListNotAvailableException {
       Optional<Comment> maybeComment =
-          commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
+          commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
       }
-      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), key.patchSetId);
-      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), key.patchSetId);
+      PatchSet ps = psUtil.get(ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
       Comment c = maybeComment.get();
-      setCommentRevId(c, patchListCache, ctx.getChange(), ps);
-      commentsUtil.deleteComments(ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
-      ctx.dontBumpLastUpdatedOn();
+      setCommentCommitId(c, patchListCache, ctx.getChange(), ps);
+      commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index 4ff1b66..de7a683 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -16,50 +16,43 @@
 
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class DeletePrivate
-    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>> {
-  private final ChangeMessagesUtil cmUtil;
-  private final Provider<ReviewDb> dbProvider;
+    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, String> {
   private final PermissionBackend permissionBackend;
   private final SetPrivateOp.Factory setPrivateOpFactory;
 
   @Inject
   DeletePrivate(
-      Provider<ReviewDb> dbProvider,
       RetryHelper retryHelper,
-      ChangeMessagesUtil cmUtil,
       PermissionBackend permissionBackend,
       SetPrivateOp.Factory setPrivateOpFactory) {
     super(retryHelper);
-    this.dbProvider = dbProvider;
-    this.cmUtil = cmUtil;
     this.permissionBackend = permissionBackend;
     this.setPrivateOpFactory = setPrivateOpFactory;
   }
 
   @Override
   protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, @Nullable SetPrivateOp.Input input)
       throws RestApiException, UpdateException {
     if (!canDeletePrivate(rsrc).value()) {
       throw new AuthException("not allowed to unmark private");
@@ -69,10 +62,9 @@
       throw new ResourceConflictException("change is not private");
     }
 
-    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, false, input);
+    SetPrivateOp op = setPrivateOpFactory.create(false, input);
     try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       u.addOp(rsrc.getId(), op).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java b/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java
index cf0143a..c86d0ca 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java
@@ -17,25 +17,21 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class DeletePrivateByPost extends DeletePrivate implements UiAction<ChangeResource> {
   @Inject
   DeletePrivateByPost(
-      Provider<ReviewDb> dbProvider,
       RetryHelper retryHelper,
-      ChangeMessagesUtil cmUtil,
       PermissionBackend permissionBackend,
       SetPrivateOp.Factory setPrivateOpFactory) {
-    super(dbProvider, retryHelper, cmUtil, permissionBackend, setPrivateOpFactory);
+    super(retryHelper, permissionBackend, setPrivateOpFactory);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index a92cf6c..0a01bab 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -14,43 +14,43 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.DeleteReviewerByEmailOp;
+import com.google.gerrit.server.change.DeleteReviewerOp;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class DeleteReviewer
-    extends RetryingRestModifyView<ReviewerResource, DeleteReviewerInput, Response<?>> {
+    extends RetryingRestModifyView<ReviewerResource, DeleteReviewerInput, Object> {
 
-  private final Provider<ReviewDb> dbProvider;
   private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
   private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
 
   @Inject
   DeleteReviewer(
-      Provider<ReviewDb> dbProvider,
       RetryHelper retryHelper,
       DeleteReviewerOp.Factory deleteReviewerOpFactory,
       DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
     super(retryHelper);
-    this.dbProvider = dbProvider;
     this.deleteReviewerOpFactory = deleteReviewerOpFactory;
     this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<Object> applyImpl(
       BatchUpdate.Factory updateFactory, ReviewerResource rsrc, DeleteReviewerInput input)
       throws RestApiException, UpdateException {
     if (input == null) {
@@ -59,13 +59,13 @@
 
     try (BatchUpdate bu =
         updateFactory.create(
-            dbProvider.get(),
             rsrc.getChangeResource().getProject(),
             rsrc.getChangeResource().getUser(),
             TimeUtil.nowTs())) {
+      bu.setNotify(getNotify(rsrc.getChange(), input));
       BatchUpdateOp op;
       if (rsrc.isByEmail()) {
-        op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail(), input);
+        op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail());
       } else {
         op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().state(), input);
       }
@@ -74,4 +74,12 @@
     }
     return Response.none();
   }
+
+  private static NotifyResolver.Result getNotify(Change change, DeleteReviewerInput input) {
+    NotifyHandling notifyHandling = input.notify;
+    if (notifyHandling == null) {
+      notifyHandling = change.isWorkInProgress() ? NotifyHandling.NONE : NotifyHandling.ALL;
+    }
+    return NotifyResolver.Result.create(notifyHandling);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java
deleted file mode 100644
index fac1003..0000000
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java
+++ /dev/null
@@ -1,105 +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.server.restapi.change;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.change.NotifyUtil;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Collections;
-
-public class DeleteReviewerByEmailOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface Factory {
-    DeleteReviewerByEmailOp create(Address reviewer, DeleteReviewerInput input);
-  }
-
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
-  private final NotifyUtil notifyUtil;
-  private final Address reviewer;
-  private final DeleteReviewerInput input;
-
-  private ChangeMessage changeMessage;
-  private Change change;
-
-  @Inject
-  DeleteReviewerByEmailOp(
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
-      NotifyUtil notifyUtil,
-      @Assisted Address reviewer,
-      @Assisted DeleteReviewerInput input) {
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
-    this.notifyUtil = notifyUtil;
-    this.reviewer = reviewer;
-    this.input = input;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws OrmException {
-    change = ctx.getChange();
-    PatchSet.Id psId = ctx.getChange().currentPatchSetId();
-    String msg = "Removed reviewer " + reviewer;
-    changeMessage =
-        new ChangeMessage(
-            new ChangeMessage.Key(change.getId(), ChangeUtil.messageUuid()),
-            ctx.getAccountId(),
-            ctx.getWhen(),
-            psId);
-    changeMessage.setMessage(msg);
-
-    ctx.getUpdate(psId).setChangeMessage(msg);
-    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
-    if (input.notify == null) {
-      if (change.isWorkInProgress()) {
-        input.notify = NotifyHandling.NONE;
-      } else {
-        input.notify = NotifyHandling.ALL;
-      }
-    }
-    if (!NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-      return;
-    }
-    try {
-      DeleteReviewerSender cm =
-          deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-      cm.setFrom(ctx.getAccountId());
-      cm.addReviewersByEmail(Collections.singleton(reviewer));
-      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-      cm.setNotify(input.notify);
-      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-      cm.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java
deleted file mode 100644
index 91f5d15..0000000
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java
+++ /dev/null
@@ -1,253 +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.server.restapi.change;
-
-import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.change.NotifyUtil;
-import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class DeleteReviewerOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface Factory {
-    DeleteReviewerOp create(AccountState reviewerAccount, DeleteReviewerInput input);
-  }
-
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ReviewerDeleted reviewerDeleted;
-  private final Provider<IdentifiedUser> user;
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
-  private final RemoveReviewerControl removeReviewerControl;
-  private final ProjectCache projectCache;
-
-  private final AccountState reviewer;
-  private final DeleteReviewerInput input;
-
-  ChangeMessage changeMessage;
-  Change currChange;
-  PatchSet currPs;
-  Map<String, Short> newApprovals = new HashMap<>();
-  Map<String, Short> oldApprovals = new HashMap<>();
-
-  @Inject
-  DeleteReviewerOp(
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
-      IdentifiedUser.GenericFactory userFactory,
-      ReviewerDeleted reviewerDeleted,
-      Provider<IdentifiedUser> user,
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
-      NotesMigration migration,
-      NotifyUtil notifyUtil,
-      RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache,
-      @Assisted AccountState reviewerAccount,
-      @Assisted DeleteReviewerInput input) {
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
-    this.userFactory = userFactory;
-    this.reviewerDeleted = reviewerDeleted;
-    this.user = user;
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
-    this.removeReviewerControl = removeReviewerControl;
-    this.projectCache = projectCache;
-    this.reviewer = reviewerAccount;
-    this.input = input;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws AuthException, ResourceNotFoundException, OrmException, PermissionBackendException,
-          IOException {
-    Account.Id reviewerId = reviewer.getAccount().getId();
-    // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
-    removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
-
-    if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
-      throw new ResourceNotFoundException();
-    }
-    currChange = ctx.getChange();
-    currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
-
-    LabelTypes labelTypes =
-        projectCache.checkedGet(ctx.getProject()).getLabelTypes(ctx.getNotes(), ctx.getUser());
-    // removing a reviewer will remove all her votes
-    for (LabelType lt : labelTypes.getLabelTypes()) {
-      newApprovals.put(lt.getName(), (short) 0);
-    }
-
-    StringBuilder msg = new StringBuilder();
-    msg.append("Removed reviewer " + reviewer.getAccount().getFullName());
-    StringBuilder removedVotesMsg = new StringBuilder();
-    removedVotesMsg.append(" with the following votes:\n\n");
-    List<PatchSetApproval> del = new ArrayList<>();
-    boolean votesRemoved = false;
-    for (PatchSetApproval a : approvals(ctx, reviewerId)) {
-      // Check if removing this vote is OK
-      removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-      del.add(a);
-      if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
-        oldApprovals.put(a.getLabel(), a.getValue());
-        removedVotesMsg
-            .append("* ")
-            .append(a.getLabel())
-            .append(formatLabelValue(a.getValue()))
-            .append(" by ")
-            .append(userFactory.create(a.getAccountId()).getNameEmail())
-            .append("\n");
-        votesRemoved = true;
-      }
-    }
-
-    if (votesRemoved) {
-      msg.append(removedVotesMsg);
-    } else {
-      msg.append(".");
-    }
-    ctx.getDb().patchSetApprovals().delete(del);
-    ChangeUpdate update = ctx.getUpdate(currPs.getId());
-    update.removeReviewer(reviewerId);
-
-    changeMessage =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
-    cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
-
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
-    if (input.notify == null) {
-      if (currChange.isWorkInProgress()) {
-        input.notify = oldApprovals.isEmpty() ? NotifyHandling.NONE : NotifyHandling.OWNER;
-      } else {
-        input.notify = NotifyHandling.ALL;
-      }
-    }
-    if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-      emailReviewers(ctx.getProject(), currChange, changeMessage);
-    }
-    reviewerDeleted.fire(
-        currChange,
-        currPs,
-        reviewer,
-        ctx.getAccount(),
-        changeMessage.getMessage(),
-        newApprovals,
-        oldApprovals,
-        input.notify,
-        ctx.getWhen());
-  }
-
-  private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
-      throws OrmException {
-    Change.Id changeId = ctx.getNotes().getChangeId();
-    Iterable<PatchSetApproval> approvals;
-    PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
-
-    if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
-      // Because NoteDb and ReviewDb have different semantics for zero-value
-      // approvals, we must fall back to ReviewDb as the source of truth here.
-      ReviewDb db = ctx.getDb();
-
-      if (db instanceof BatchUpdateReviewDb) {
-        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-      }
-      db = ReviewDbUtil.unwrapDb(db);
-      approvals = db.patchSetApprovals().byChange(changeId);
-    } else {
-      approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
-    }
-
-    return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
-  }
-
-  private String formatLabelValue(short value) {
-    if (value > 0) {
-      return "+" + value;
-    }
-    return Short.toString(value);
-  }
-
-  private void emailReviewers(
-      Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
-    Account.Id userId = user.get().getAccountId();
-    if (userId.equals(reviewer.getAccount().getId())) {
-      // The user knows they removed themselves, don't bother emailing them.
-      return;
-    }
-    try {
-      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
-      cm.setFrom(userId);
-      cm.addReviewers(Collections.singleton(reviewer.getAccount().getId()));
-      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-      cm.setNotify(input.notify);
-      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-      cm.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index f268a30..a80863e 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,16 +30,14 @@
 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.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
 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.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
@@ -57,33 +55,30 @@
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class DeleteVote extends RetryingRestModifyView<VoteResource, DeleteVoteInput, Response<?>> {
+public class DeleteVote extends RetryingRestModifyView<VoteResource, DeleteVoteInput, Object> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final Provider<ReviewDb> db;
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
   private final IdentifiedUser.GenericFactory userFactory;
   private final VoteDeleted voteDeleted;
   private final DeleteVoteSender.Factory deleteVoteSenderFactory;
-  private final NotifyUtil notifyUtil;
+  private final NotifyResolver notifyResolver;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
 
   @Inject
   DeleteVote(
-      Provider<ReviewDb> db,
       RetryHelper retryHelper,
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
@@ -91,26 +86,25 @@
       IdentifiedUser.GenericFactory userFactory,
       VoteDeleted voteDeleted,
       DeleteVoteSender.Factory deleteVoteSenderFactory,
-      NotifyUtil notifyUtil,
+      NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache) {
     super(retryHelper);
-    this.db = db;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
     this.userFactory = userFactory;
     this.voteDeleted = voteDeleted;
     this.deleteVoteSenderFactory = deleteVoteSenderFactory;
-    this.notifyUtil = notifyUtil;
+    this.notifyResolver = notifyResolver;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<Object> applyImpl(
       BatchUpdate.Factory updateFactory, VoteResource rsrc, DeleteVoteInput input)
-      throws RestApiException, UpdateException, IOException {
+      throws RestApiException, UpdateException, IOException, ConfigInvalidException {
     if (input == null) {
       input = new DeleteVoteInput();
     }
@@ -129,7 +123,10 @@
 
     try (BatchUpdate bu =
         updateFactory.create(
-            db.get(), change.getProject(), r.getChangeResource().getUser(), TimeUtil.nowTs())) {
+            change.getProject(), r.getChangeResource().getUser(), TimeUtil.nowTs())) {
+      bu.setNotify(
+          notifyResolver.resolve(
+              firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
       bu.addOp(
           change.getId(),
           new Op(
@@ -165,31 +162,24 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, AuthException, ResourceNotFoundException, IOException,
-            PermissionBackendException {
+        throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
       change = ctx.getChange();
       PatchSet.Id psId = change.currentPatchSetId();
-      ps = psUtil.current(db.get(), ctx.getNotes());
+      ps = psUtil.current(ctx.getNotes());
 
       boolean found = false;
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
 
-      Account.Id accountId = accountState.getAccount().getId();
+      Account.Id accountId = accountState.getAccount().id();
 
       for (PatchSetApproval a :
           approvalsUtil.byPatchSetUser(
-              ctx.getDb(),
-              ctx.getNotes(),
-              ctx.getUser(),
-              psId,
-              accountId,
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
-        if (labelTypes.byLabel(a.getLabelId()) == null) {
+              ctx.getNotes(), psId, accountId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+        if (labelTypes.byLabel(a.labelId()) == null) {
           continue; // Ignore undefined labels.
-        } else if (!a.getLabel().equals(label)) {
+        } else if (!a.label().equals(label)) {
           // Populate map for non-matching labels, needed by VoteDeleted.
-          newApprovals.put(a.getLabel(), a.getValue());
+          newApprovals.put(a.label(), a.value());
           continue;
         } else {
           try {
@@ -199,11 +189,11 @@
           }
         }
         // Set the approval to 0 if vote is being removed.
-        newApprovals.put(a.getLabel(), (short) 0);
+        newApprovals.put(a.label(), (short) 0);
         found = true;
 
         // Set old value, as required by VoteDeleted.
-        oldApprovals.put(a.getLabel(), a.getValue());
+        oldApprovals.put(a.label(), a.value());
         break;
       }
       if (!found) {
@@ -211,30 +201,18 @@
       }
 
       ctx.getUpdate(psId).removeApprovalFor(accountId, label);
-      ctx.getDb().patchSetApprovals().upsert(Collections.singleton(deletedApproval(ctx)));
 
       StringBuilder msg = new StringBuilder();
       msg.append("Removed ");
-      LabelVote.appendTo(msg, label, checkNotNull(oldApprovals.get(label)));
+      LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
       msg.append(" by ").append(userFactory.create(accountId).getNameEmail()).append("\n");
       changeMessage =
           ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
+      cmUtil.addChangeMessage(ctx.getUpdate(psId), changeMessage);
 
       return true;
     }
 
-    private PatchSetApproval deletedApproval(ChangeContext ctx) {
-      // Set the effective user to the account we're trying to remove, and don't
-      // set the real user; this preserves the calling user as the NoteDb
-      // committer.
-      return new PatchSetApproval(
-          new PatchSetApproval.Key(
-              ps.getId(), accountState.getAccount().getId(), new LabelId(label)),
-          (short) 0,
-          ctx.getWhen());
-    }
-
     @Override
     public void postUpdate(Context ctx) {
       if (changeMessage == null) {
@@ -242,17 +220,17 @@
       }
 
       IdentifiedUser user = ctx.getIdentifiedUser();
-      if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-        try {
+      try {
+        NotifyResolver.Result notify = ctx.getNotify(change.getId());
+        if (notify.shouldNotify()) {
           ReplyToChangeSender cm = deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
           cm.setFrom(user.getAccountId());
           cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
-          cm.setNotify(input.notify);
-          cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+          cm.setNotify(notify);
           cm.send();
-        } catch (Exception e) {
-          logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
         }
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
 
       voteDeleted.fire(
diff --git a/java/com/google/gerrit/server/restapi/change/DownloadContent.java b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
index b6564c0..4a4a680 100644
--- a/java/com/google/gerrit/server/restapi/change/DownloadContent.java
+++ b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
@@ -16,16 +16,15 @@
 
 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.RestReadView;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.Option;
 
 public class DownloadContent implements RestReadView<FileResource> {
@@ -42,12 +41,12 @@
   }
 
   @Override
-  public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, NoSuchChangeException, OrmException {
-    String path = rsrc.getPatchKey().get();
+  public Response<BinaryResult> apply(FileResource rsrc)
+      throws ResourceNotFoundException, IOException, NoSuchChangeException {
+    String path = rsrc.getPatchKey().fileName();
     RevisionResource rev = rsrc.getRevision();
-    ObjectId revstr = ObjectId.fromString(rev.getPatchSet().getRevision().get());
-    return fileContentUtil.downloadContent(
-        projectCache.checkedGet(rev.getProject()), revstr, path, parent);
+    return Response.ok(
+        fileContentUtil.downloadContent(
+            projectCache.checkedGet(rev.getProject()), rev.getPatchSet().commitId(), path, parent));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index b8e24a5..6a1e0f1 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -21,12 +21,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -36,7 +34,6 @@
   private final DynamicMap<RestView<DraftCommentResource>> views;
   private final Provider<CurrentUser> user;
   private final ListRevisionDrafts list;
-  private final Provider<ReviewDb> dbProvider;
   private final CommentsUtil commentsUtil;
 
   @Inject
@@ -44,12 +41,10 @@
       DynamicMap<RestView<DraftCommentResource>> views,
       Provider<CurrentUser> user,
       ListRevisionDrafts list,
-      Provider<ReviewDb> dbProvider,
       CommentsUtil commentsUtil) {
     this.views = views;
     this.user = user;
     this.list = list;
-    this.dbProvider = dbProvider;
     this.commentsUtil = commentsUtil;
   }
 
@@ -66,12 +61,12 @@
 
   @Override
   public DraftCommentResource parse(RevisionResource rev, IdString id)
-      throws ResourceNotFoundException, OrmException, AuthException {
+      throws ResourceNotFoundException, AuthException {
     checkIdentifiedUser();
     String uuid = id.get();
     for (Comment c :
         commentsUtil.draftByPatchSetAuthor(
-            dbProvider.get(), rev.getPatchSet().getId(), rev.getAccountId(), rev.getNotes())) {
+            rev.getPatchSet().id(), rev.getAccountId(), rev.getNotes())) {
       if (uuid.equals(c.key.uuid)) {
         return new DraftCommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index bb2f668..aa3b68c 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -18,8 +18,8 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.FileInfo;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -34,7 +34,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
@@ -49,7 +48,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -63,7 +62,6 @@
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -113,26 +111,23 @@
     @Option(name = "-q")
     String query;
 
-    private final Provider<ReviewDb> db;
     private final Provider<CurrentUser> self;
     private final FileInfoJson fileInfoJson;
     private final Revisions revisions;
     private final GitRepositoryManager gitManager;
     private final PatchListCache patchListCache;
     private final PatchSetUtil psUtil;
-    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+    private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
     ListFiles(
-        Provider<ReviewDb> db,
         Provider<CurrentUser> self,
         FileInfoJson fileInfoJson,
         Revisions revisions,
         GitRepositoryManager gitManager,
         PatchListCache patchListCache,
         PatchSetUtil psUtil,
-        DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
-      this.db = db;
+        PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore) {
       this.self = self;
       this.fileInfoJson = fileInfoJson;
       this.revisions = revisions;
@@ -149,7 +144,7 @@
 
     @Override
     public Response<?> apply(RevisionResource resource)
-        throws AuthException, BadRequestException, ResourceNotFoundException, OrmException,
+        throws AuthException, BadRequestException, ResourceNotFoundException,
             RepositoryNotFoundException, IOException, PatchListNotAvailableException,
             PermissionBackendException {
       checkOptions();
@@ -167,13 +162,13 @@
             Response.ok(
                 fileInfoJson.toFileInfoMap(
                     resource.getChange(),
-                    resource.getPatchSet().getRevision(),
+                    resource.getPatchSet().commitId(),
                     baseResource.getPatchSet()));
       } else if (parentNum > 0) {
         r =
             Response.ok(
                 fileInfoJson.toFileInfoMap(
-                    resource.getChange(), resource.getPatchSet().getRevision(), parentNum - 1));
+                    resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
       } else {
         r = Response.ok(fileInfoJson.toFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
@@ -210,8 +205,7 @@
           ObjectReader or = git.newObjectReader();
           RevWalk rw = new RevWalk(or);
           TreeWalk tw = new TreeWalk(or)) {
-        RevCommit c =
-            rw.parseCommit(ObjectId.fromString(resource.getPatchSet().getRevision().get()));
+        RevCommit c = rw.parseCommit(resource.getPatchSet().commitId());
 
         tw.addTree(c.getTree());
         tw.setRecursive(true);
@@ -226,8 +220,7 @@
       }
     }
 
-    private Collection<String> reviewed(RevisionResource resource)
-        throws AuthException, OrmException {
+    private Collection<String> reviewed(RevisionResource resource) throws AuthException {
       CurrentUser user = self.get();
       if (!(user.isIdentifiedUser())) {
         throw new AuthException("Authentication required");
@@ -235,12 +228,12 @@
 
       Account.Id userId = user.getAccountId();
       PatchSet patchSetId = resource.getPatchSet();
-      Optional<PatchSetWithReviewedFiles> o =
-          accountPatchReviewStore.get().findReviewed(patchSetId.getId(), userId);
+      Optional<PatchSetWithReviewedFiles> o;
+      o = accountPatchReviewStore.call(s -> s.findReviewed(patchSetId.id(), userId));
 
       if (o.isPresent()) {
         PatchSetWithReviewedFiles res = o.get();
-        if (res.patchSetId().equals(patchSetId.getId())) {
+        if (res.patchSetId().equals(patchSetId.id())) {
           return res.files();
         }
 
@@ -258,14 +251,14 @@
 
     private List<String> copy(
         Set<String> paths, PatchSet.Id old, RevisionResource resource, Account.Id userId)
-        throws IOException, PatchListNotAvailableException, OrmException {
+        throws IOException, PatchListNotAvailableException {
       Project.NameKey project = resource.getChange().getProject();
       try (Repository git = gitManager.openRepository(project);
           ObjectReader reader = git.newObjectReader();
           RevWalk rw = new RevWalk(reader);
           TreeWalk tw = new TreeWalk(reader)) {
         Change change = resource.getChange();
-        PatchSet patchSet = psUtil.get(db.get(), resource.getNotes(), old);
+        PatchSet patchSet = psUtil.get(resource.getNotes(), old);
         if (patchSet == null) {
           throw new PatchListNotAvailableException(
               String.format(
@@ -317,9 +310,9 @@
             pathList.add(path);
           }
         }
-        accountPatchReviewStore
-            .get()
-            .markReviewed(resource.getPatchSet().getId(), userId, pathList);
+
+        accountPatchReviewStore.run(
+            s -> s.markReviewed(resource.getPatchSet().id(), userId, pathList));
         return pathList;
       }
     }
@@ -329,7 +322,7 @@
       return this;
     }
 
-    public ListFiles setBase(String base) {
+    public ListFiles setBase(@Nullable String base) {
       this.base = base;
       return this;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Fixes.java b/java/com/google/gerrit/server/restapi/change/Fixes.java
index 1d8726d..9255ee3 100644
--- a/java/com/google/gerrit/server/restapi/change/Fixes.java
+++ b/java/com/google/gerrit/server/restapi/change/Fixes.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.change.FixResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -50,12 +49,12 @@
 
   @Override
   public FixResource parse(RevisionResource revisionResource, IdString id)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException {
     String fixId = id.get();
     ChangeNotes changeNotes = revisionResource.getNotes();
 
     List<RobotComment> robotComments =
-        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().getId());
+        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().id());
     for (RobotComment robotComment : robotComments) {
       for (FixSuggestion fixSuggestion : robotComment.fixSuggestions) {
         if (Objects.equals(fixId, fixSuggestion.fixId)) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetArchive.java b/java/com/google/gerrit/server/restapi/change/GetArchive.java
index 1bd1bce..4ebcbdd 100644
--- a/java/com/google/gerrit/server/restapi/change/GetArchive.java
+++ b/java/com/google/gerrit/server/restapi/change/GetArchive.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ArchiveFormat;
 import com.google.gerrit.server.change.RevisionResource;
@@ -27,7 +30,6 @@
 import java.io.OutputStream;
 import org.eclipse.jgit.api.ArchiveCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -47,7 +49,7 @@
   }
 
   @Override
-  public BinaryResult apply(RevisionResource rsrc)
+  public Response<BinaryResult> apply(RevisionResource rsrc)
       throws BadRequestException, IOException, MethodNotAllowedException {
     if (Strings.isNullOrEmpty(format)) {
       throw new BadRequestException("format is not specified");
@@ -65,7 +67,7 @@
       final RevCommit commit;
       String name;
       try (RevWalk rw = new RevWalk(repo)) {
-        commit = rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
+        commit = rw.parseCommit(rsrc.getPatchSet().commitId());
         name = name(f, rw, commit);
       }
 
@@ -93,7 +95,7 @@
       bin.disableGzip().setContentType(f.getMimeType()).setAttachmentName(name);
 
       close = false;
-      return bin;
+      return Response.ok(bin);
     } finally {
       if (close) {
         repo.close();
@@ -104,6 +106,6 @@
   private static String name(ArchiveFormat format, RevWalk rw, RevCommit commit)
       throws IOException {
     return String.format(
-        "%s%s", rw.getObjectReader().abbreviate(commit, 7).name(), format.getDefaultSuffix());
+        "%s%s", abbreviateName(commit, rw.getObjectReader()), format.getDefaultSuffix());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetAssignee.java b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
index f78fae2..f89fe1b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Optional;
@@ -35,7 +35,7 @@
   }
 
   @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc) throws OrmException {
+  public Response<AccountInfo> apply(ChangeResource rsrc) throws PermissionBackendException {
     Optional<Account.Id> assignee = Optional.ofNullable(rsrc.getChange().getAssignee());
     if (assignee.isPresent()) {
       return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.get()));
diff --git a/java/com/google/gerrit/server/restapi/change/GetBlame.java b/java/com/google/gerrit/server/restapi/change/GetBlame.java
index c7a8015..cade702 100644
--- a/java/com/google/gerrit/server/restapi/change/GetBlame.java
+++ b/java/com/google/gerrit/server/restapi/change/GetBlame.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gitiles.blame.cache.BlameCache;
 import com.google.gitiles.blame.cache.Region;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -79,7 +78,7 @@
 
   @Override
   public Response<List<BlameInfo>> apply(FileResource resource)
-      throws RestApiException, OrmException, IOException, InvalidChangeOperationException {
+      throws RestApiException, IOException, InvalidChangeOperationException {
     Project.NameKey project = resource.getRevision().getChange().getProject();
     try (Repository repository = repoManager.openRepository(project);
         ObjectInserter ins = repository.newObjectInserter();
@@ -88,7 +87,7 @@
       String refName =
           resource.getRevision().getEdit().isPresent()
               ? resource.getRevision().getEdit().get().getRefName()
-              : resource.getRevision().getPatchSet().getRefName();
+              : resource.getRevision().getPatchSet().refName();
 
       Ref ref = repository.findRef(refName);
       if (ref == null) {
@@ -98,7 +97,7 @@
       RevCommit revCommit = revWalk.parseCommit(objectId);
       RevCommit[] parents = revCommit.getParents();
 
-      String path = resource.getPatchKey().getFileName();
+      String path = resource.getPatchKey().fileName();
 
       List<BlameInfo> result;
       if (!base) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index a8f8bbb..c28741b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -14,43 +14,79 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
 import org.kohsuke.args4j.Option;
 
-public class GetChange implements RestReadView<ChangeResource> {
+public class GetChange
+    implements RestReadView<ChangeResource>,
+        DynamicOptions.BeanReceiver,
+        DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
+  private final DynamicSet<ChangeAttributeFactory> attrFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+  private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
 
   @Option(name = "-o", usage = "Output options")
-  void addOption(ListChangesOption o) {
+  public void addOption(ListChangesOption o) {
     options.add(o);
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
   void setOptionFlagsHex(String hex) {
-    options.addAll(ListChangesOption.fromBits(Integer.parseInt(hex, 16)));
+    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
   }
 
   @Inject
-  GetChange(ChangeJson.Factory json) {
+  GetChange(ChangeJson.Factory json, DynamicSet<ChangeAttributeFactory> attrFactories) {
     this.json = json;
+    this.attrFactories = attrFactories;
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
-    return Response.withMustRevalidate(json.create(options).format(rsrc));
+  public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+    dynamicBeans.put(plugin, dynamicBean);
   }
 
-  Response<ChangeInfo> apply(RevisionResource rsrc) throws OrmException {
-    return Response.withMustRevalidate(json.create(options).format(rsrc));
+  @Override
+  public DynamicBean getDynamicBean(String plugin) {
+    return dynamicBeans.get(plugin);
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource rsrc) {
+    return Response.withMustRevalidate(newChangeJson().format(rsrc));
+  }
+
+  Response<ChangeInfo> apply(RevisionResource rsrc) {
+    return Response.withMustRevalidate(newChangeJson().format(rsrc));
+  }
+
+  private ChangeJson newChangeJson() {
+    return json.create(options, this::buildPluginInfo);
+  }
+
+  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
+    return PluginDefinedAttributesFactories.createAll(
+        cd, this, Streams.stream(attrFactories.entries()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetChangeMessage.java b/java/com/google/gerrit/server/restapi/change/GetChangeMessage.java
index f55785d..9e0e0e3 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChangeMessage.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.inject.Singleton;
@@ -23,7 +24,7 @@
 @Singleton
 public class GetChangeMessage implements RestReadView<ChangeMessageResource> {
   @Override
-  public ChangeMessageInfo apply(ChangeMessageResource resource) {
-    return resource.getChangeMessage();
+  public Response<ChangeMessageInfo> apply(ChangeMessageResource resource) {
+    return Response.ok(resource.getChangeMessage());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetComment.java b/java/com/google/gerrit/server/restapi/change/GetComment.java
index b8db6a5..5103325 100644
--- a/java/com/google/gerrit/server/restapi/change/GetComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetComment.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.CommentResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -33,7 +34,7 @@
   }
 
   @Override
-  public CommentInfo apply(CommentResource rsrc) throws OrmException {
-    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
+  public Response<CommentInfo> apply(CommentResource rsrc) throws PermissionBackendException {
+    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 645d7d1..aeaafc4 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -14,18 +14,18 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -33,12 +33,12 @@
 
 public class GetCommit implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
-  private final ChangeJson.Factory json;
+  private final RevisionJson.Factory json;
 
   private boolean addLinks;
 
   @Inject
-  GetCommit(GitRepositoryManager repoManager, ChangeJson.Factory json) {
+  GetCommit(GitRepositoryManager repoManager, RevisionJson.Factory json) {
     this.repoManager = repoManager;
     this.json = json;
   }
@@ -54,10 +54,11 @@
     Project.NameKey p = rsrc.getChange().getProject();
     try (Repository repo = repoManager.openRepository(p);
         RevWalk rw = new RevWalk(repo)) {
-      String rev = rsrc.getPatchSet().getRevision().get();
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
       rw.parseBody(commit);
-      CommitInfo info = json.noOptions().toCommit(rsrc.getProject(), rw, commit, addLinks, true);
+      CommitInfo info =
+          json.create(ImmutableSet.of())
+              .getCommitInfo(rsrc.getProject(), rw, commit, addLinks, true);
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
diff --git a/java/com/google/gerrit/server/restapi/change/GetContent.java b/java/com/google/gerrit/server/restapi/change/GetContent.java
index 6b9bf17..9048401 100644
--- a/java/com/google/gerrit/server/restapi/change/GetContent.java
+++ b/java/com/google/gerrit/server/restapi/change/GetContent.java
@@ -17,11 +17,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 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.RestReadView;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.FileResource;
@@ -31,19 +31,15 @@
 import com.google.gerrit.server.patch.Text;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
 public class GetContent implements RestReadView<FileResource> {
-  private final Provider<ReviewDb> db;
   private final GitRepositoryManager gitManager;
   private final PatchSetUtil psUtil;
   private final FileContentUtil fileContentUtil;
@@ -54,12 +50,10 @@
 
   @Inject
   GetContent(
-      Provider<ReviewDb> db,
       GitRepositoryManager gitManager,
       PatchSetUtil psUtil,
       FileContentUtil fileContentUtil,
       ProjectCache projectCache) {
-    this.db = db;
     this.gitManager = gitManager;
     this.psUtil = psUtil;
     this.fileContentUtil = fileContentUtil;
@@ -67,46 +61,49 @@
   }
 
   @Override
-  public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, BadRequestException, OrmException {
-    String path = rsrc.getPatchKey().get();
+  public Response<BinaryResult> apply(FileResource rsrc)
+      throws ResourceNotFoundException, IOException, BadRequestException {
+    String path = rsrc.getPatchKey().fileName();
     if (Patch.COMMIT_MSG.equals(path)) {
       String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes());
-      return BinaryResult.create(msg)
-          .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
-          .base64();
+      return Response.ok(
+          BinaryResult.create(msg)
+              .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
+              .base64());
     } else if (Patch.MERGE_LIST.equals(path)) {
       byte[] mergeList = getMergeList(rsrc.getRevision().getChangeResource().getNotes());
-      return BinaryResult.create(mergeList)
-          .setContentType(FileContentUtil.TEXT_X_GERRIT_MERGE_LIST)
-          .base64();
+      return Response.ok(
+          BinaryResult.create(mergeList)
+              .setContentType(FileContentUtil.TEXT_X_GERRIT_MERGE_LIST)
+              .base64());
     }
-    return fileContentUtil.getContent(
-        projectCache.checkedGet(rsrc.getRevision().getProject()),
-        ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get()),
-        path,
-        parent);
+    return Response.ok(
+        fileContentUtil.getContent(
+            projectCache.checkedGet(rsrc.getRevision().getProject()),
+            rsrc.getRevision().getPatchSet().commitId(),
+            path,
+            parent));
   }
 
-  private String getMessage(ChangeNotes notes) throws OrmException, IOException {
+  private String getMessage(ChangeNotes notes) throws IOException {
     Change.Id changeId = notes.getChangeId();
-    PatchSet ps = psUtil.current(db.get(), notes);
+    PatchSet ps = psUtil.current(notes);
     if (ps == null) {
       throw new NoSuchChangeException(changeId);
     }
 
     try (Repository git = gitManager.openRepository(notes.getProjectName());
         RevWalk revWalk = new RevWalk(git)) {
-      RevCommit commit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      RevCommit commit = revWalk.parseCommit(ps.commitId());
       return commit.getFullMessage();
     } catch (RepositoryNotFoundException e) {
       throw new NoSuchChangeException(changeId, e);
     }
   }
 
-  private byte[] getMergeList(ChangeNotes notes) throws OrmException, IOException {
+  private byte[] getMergeList(ChangeNotes notes) throws IOException {
     Change.Id changeId = notes.getChangeId();
-    PatchSet ps = psUtil.current(db.get(), notes);
+    PatchSet ps = psUtil.current(notes);
     if (ps == null) {
       throw new NoSuchChangeException(changeId);
     }
@@ -114,9 +111,7 @@
     try (Repository git = gitManager.openRepository(notes.getProjectName());
         RevWalk revWalk = new RevWalk(git)) {
       return Text.forMergeList(
-              ComparisonType.againstAutoMerge(),
-              revWalk.getObjectReader(),
-              ObjectId.fromString(ps.getRevision().get()))
+              ComparisonType.againstAutoMerge(), revWalk.getObjectReader(), ps.commitId())
           .getContent();
     } catch (RepositoryNotFoundException e) {
       throw new NoSuchChangeException(changeId, e);
diff --git a/java/com/google/gerrit/server/restapi/change/GetDescription.java b/java/com/google/gerrit/server/restapi/change/GetDescription.java
index 1a7ec63..6794d81 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDescription.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.inject.Singleton;
@@ -22,7 +22,7 @@
 @Singleton
 public class GetDescription implements RestReadView<RevisionResource> {
   @Override
-  public String apply(RevisionResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getPatchSet().getDescription());
+  public Response<String> apply(RevisionResource rsrc) {
+    return Response.ok(rsrc.getPatchSet().description().orElse(""));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index ab75ab7..e31d84b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -18,12 +18,13 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import org.kohsuke.args4j.Option;
 
-public class GetDetail implements RestReadView<ChangeResource> {
+public class GetDetail implements RestReadView<ChangeResource>, DynamicOptions.BeanReceiver {
   private final GetChange delegate;
 
   @Option(name = "-o", usage = "Output options")
@@ -47,7 +48,17 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
+  public void setDynamicBean(String plugin, DynamicBean dynamicBean) {
+    delegate.setDynamicBean(plugin, dynamicBean);
+  }
+
+  @Override
+  public Class<? extends DynamicOptions.BeanReceiver> getExportedBeanReceiver() {
+    return delegate.getExportedBeanReceiver();
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource rsrc) {
     return delegate.apply(rsrc);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 489c7cb..57e52ac 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.util.cli.Localizable.localizable;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
@@ -39,6 +40,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.jgit.diff.ReplaceEdit;
 import com.google.gerrit.prettify.common.SparseFileContent;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -55,14 +57,12 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.diff.ReplaceEdit;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.NamedOptionDef;
@@ -125,7 +125,7 @@
 
   @Override
   public Response<DiffInfo> apply(FileResource resource)
-      throws ResourceConflictException, ResourceNotFoundException, OrmException, AuthException,
+      throws ResourceConflictException, ResourceNotFoundException, AuthException,
           InvalidChangeOperationException, IOException, PermissionBackendException {
     DiffPreferencesInfo prefs = new DiffPreferencesInfo();
     if (whitespace != null) {
@@ -140,14 +140,14 @@
 
     PatchScriptFactory psf;
     PatchSet basePatchSet = null;
-    PatchSet.Id pId = resource.getPatchKey().getParentKey();
-    String fileName = resource.getPatchKey().getFileName();
+    PatchSet.Id pId = resource.getPatchKey().patchSetId();
+    String fileName = resource.getPatchKey().fileName();
     ChangeNotes notes = resource.getRevision().getNotes();
     if (base != null) {
       RevisionResource baseResource =
           revisions.parse(resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
       basePatchSet = baseResource.getPatchSet();
-      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.getId(), pId, prefs);
+      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.id(), pId, prefs);
     } else if (parentNum > 0) {
       psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
     } else {
@@ -195,20 +195,20 @@
       ProjectState state = projectCache.get(resource.getRevision().getChange().getProject());
 
       DiffInfo result = new DiffInfo();
-      String revA = basePatchSet != null ? basePatchSet.getRefName() : content.commitIdA;
+      String revA = basePatchSet != null ? basePatchSet.refName() : content.commitIdA;
       String revB =
           resource.getRevision().getEdit().isPresent()
               ? resource.getRevision().getEdit().get().getRefName()
-              : resource.getRevision().getPatchSet().getRefName();
+              : resource.getRevision().getPatchSet().refName();
 
-      List<DiffWebLinkInfo> links =
+      ImmutableList<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(
               state.getName(),
-              resource.getPatchKey().getParentKey().getParentKey().get(),
-              basePatchSet != null ? basePatchSet.getId().get() : null,
+              resource.getPatchKey().patchSetId().changeId().get(),
+              basePatchSet != null ? basePatchSet.id().get() : null,
               revA,
               MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
-              resource.getPatchKey().getParentKey().get(),
+              resource.getPatchKey().patchSetId().get(),
               revB,
               ps.getNewName());
       result.webLinks = links.isEmpty() ? null : links;
@@ -273,7 +273,7 @@
   }
 
   private List<WebLinkInfo> getFileWebLinks(Project project, String rev, String file) {
-    List<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
+    ImmutableList<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
     return links.isEmpty() ? null : links;
   }
 
@@ -440,9 +440,9 @@
         } catch (NumberFormatException e) {
           throw new CmdLineException(
               owner,
-              String.format(
-                  "\"%s\" is not a valid value for \"%s\"",
-                  value, ((NamedOptionDef) option).name()));
+              localizable("\"%s\" is not a valid value for \"%s\""),
+              value,
+              ((NamedOptionDef) option).name());
         }
       }
       setter.addValue(context);
diff --git a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
index 787c93e..797dc9e 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.DraftCommentResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -33,7 +34,7 @@
   }
 
   @Override
-  public CommentInfo apply(DraftCommentResource rsrc) throws OrmException {
-    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
+  public Response<CommentInfo> apply(DraftCommentResource rsrc) throws PermissionBackendException {
+    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetHashtags.java b/java/com/google/gerrit/server/restapi/change/GetHashtags.java
index 8369acf..aff3a44 100644
--- a/java/com/google/gerrit/server/restapi/change/GetHashtags.java
+++ b/java/com/google/gerrit/server/restapi/change/GetHashtags.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collections;
@@ -30,7 +29,7 @@
 public class GetHashtags implements RestReadView<ChangeResource> {
   @Override
   public Response<Set<String>> apply(ChangeResource req)
-      throws AuthException, OrmException, IOException, BadRequestException {
+      throws AuthException, IOException, BadRequestException {
     ChangeNotes notes = req.getNotes().load();
     Set<String> hashtags = notes.getHashtags();
     if (hashtags == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 2f3b536..48d6dcb 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.MergeListBuilder;
@@ -30,7 +31,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -38,7 +38,7 @@
 
 public class GetMergeList implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
-  private final ChangeJson.Factory json;
+  private final RevisionJson.Factory json;
 
   @Option(name = "--parent", usage = "Uninteresting parent (1-based, default = 1)")
   private int uninterestingParent = 1;
@@ -47,7 +47,7 @@
   private boolean addLinks;
 
   @Inject
-  GetMergeList(GitRepositoryManager repoManager, ChangeJson.Factory json) {
+  GetMergeList(GitRepositoryManager repoManager, RevisionJson.Factory json) {
     this.repoManager = repoManager;
     this.json = json;
   }
@@ -66,8 +66,7 @@
     Project.NameKey p = rsrc.getChange().getProject();
     try (Repository repo = repoManager.openRepository(p);
         RevWalk rw = new RevWalk(repo)) {
-      String rev = rsrc.getPatchSet().getRevision().get();
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
       rw.parseBody(commit);
 
       if (uninterestingParent < 1 || uninterestingParent > commit.getParentCount()) {
@@ -75,14 +74,14 @@
       }
 
       if (commit.getParentCount() < 2) {
-        return createResponse(rsrc, ImmutableList.<CommitInfo>of());
+        return createResponse(rsrc, ImmutableList.of());
       }
 
       List<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
       List<CommitInfo> result = new ArrayList<>(commits.size());
-      ChangeJson changeJson = json.noOptions();
+      RevisionJson changeJson = json.create(ImmutableSet.of());
       for (RevCommit c : commits) {
-        result.add(changeJson.toCommit(rsrc.getProject(), rw, c, addLinks, true));
+        result.add(changeJson.getCommitInfo(rsrc.getProject(), rw, c, addLinks, true));
       }
       return createResponse(rsrc, result);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
index 354558b..1d56669 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collections;
@@ -39,7 +39,7 @@
   }
 
   @Override
-  public Response<List<AccountInfo>> apply(ChangeResource rsrc) throws OrmException {
+  public Response<List<AccountInfo>> apply(ChangeResource rsrc) throws PermissionBackendException {
 
     Set<Account.Id> pastAssignees = rsrc.getNotes().load().getPastAssignees();
     if (pastAssignees == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index ccad9e0..ece8c68 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -31,8 +33,6 @@
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
 import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.lib.AbbreviatedObjectId;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -60,15 +60,14 @@
   }
 
   @Override
-  public BinaryResult apply(RevisionResource rsrc)
+  public Response<BinaryResult> apply(RevisionResource rsrc)
       throws ResourceConflictException, IOException, ResourceNotFoundException {
     final Repository repo = repoManager.openRepository(rsrc.getProject());
     boolean close = true;
     try {
       final RevWalk rw = new RevWalk(repo);
       try {
-        final RevCommit commit =
-            rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
+        final RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
         RevCommit[] parents = commit.getParents();
         if (parents.length > 1) {
           throw new ResourceConflictException("Revision has more than 1 parent.");
@@ -132,7 +131,7 @@
         }
 
         close = false;
-        return bin;
+        return Response.ok(bin);
       } finally {
         if (close) {
           rw.close();
@@ -189,7 +188,6 @@
   }
 
   private static String fileName(RevWalk rw, RevCommit commit) throws IOException {
-    AbbreviatedObjectId id = rw.getObjectReader().abbreviate(commit, 7);
-    return id.name() + ".diff";
+    return abbreviateName(commit, rw.getObjectReader()) + ".diff";
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
index 42675f6..765be5f 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
@@ -19,24 +19,27 @@
 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.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.PureRevert;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import org.kohsuke.args4j.Option;
 
 public class GetPureRevert implements RestReadView<ChangeResource> {
 
   private final PureRevert pureRevert;
+  @Nullable private String claimedOriginal;
 
   @Option(
       name = "--claimed-original",
       aliases = {"-o"},
       usage = "SHA1 (40 digit hex) of the original commit")
-  @Nullable
-  private String claimedOriginal;
+  public void setClaimedOriginal(String claimedOriginal) {
+    this.claimedOriginal = claimedOriginal;
+  }
 
   @Inject
   GetPureRevert(PureRevert pureRevert) {
@@ -44,9 +47,9 @@
   }
 
   @Override
-  public PureRevertInfo apply(ChangeResource rsrc)
-      throws ResourceConflictException, IOException, BadRequestException, OrmException,
-          AuthException {
-    return pureRevert.get(rsrc.getNotes(), claimedOriginal);
+  public Response<PureRevertInfo> apply(ChangeResource rsrc)
+      throws ResourceConflictException, IOException, BadRequestException, AuthException {
+    boolean isPureRevert = pureRevert.get(rsrc.getNotes(), Optional.ofNullable(claimedOriginal));
+    return Response.ok(new PureRevertInfo(isPureRevert));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index 3313136..b678799 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -17,16 +17,18 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.MoreObjects;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -35,7 +37,6 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -43,13 +44,13 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class GetRelated implements RestReadView<RevisionResource> {
-  private final Provider<ReviewDb> db;
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
   private final RelatedChangesSorter sorter;
@@ -57,12 +58,10 @@
 
   @Inject
   GetRelated(
-      Provider<ReviewDb> db,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
       RelatedChangesSorter sorter,
       IndexConfig indexConfig) {
-    this.db = db;
     this.queryProvider = queryProvider;
     this.psUtil = psUtil;
     this.sorter = sorter;
@@ -70,17 +69,17 @@
   }
 
   @Override
-  public RelatedInfo apply(RevisionResource rsrc)
-      throws RepositoryNotFoundException, IOException, OrmException, NoSuchProjectException,
+  public Response<RelatedChangesInfo> apply(RevisionResource rsrc)
+      throws RepositoryNotFoundException, IOException, NoSuchProjectException,
           PermissionBackendException {
-    RelatedInfo relatedInfo = new RelatedInfo();
-    relatedInfo.changes = getRelated(rsrc);
-    return relatedInfo;
+    RelatedChangesInfo relatedChangesInfo = new RelatedChangesInfo();
+    relatedChangesInfo.changes = getRelated(rsrc);
+    return Response.ok(relatedChangesInfo);
   }
 
-  private List<ChangeAndCommit> getRelated(RevisionResource rsrc)
-      throws OrmException, IOException, PermissionBackendException {
-    Set<String> groups = getAllGroups(rsrc.getNotes(), db.get(), psUtil);
+  private List<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
+      throws IOException, PermissionBackendException {
+    Set<String> groups = getAllGroups(rsrc.getNotes(), psUtil);
     if (groups.isEmpty()) {
       return Collections.emptyList();
     }
@@ -94,7 +93,7 @@
     if (cds.size() == 1 && cds.get(0).getId().equals(rsrc.getChange().getId())) {
       return Collections.emptyList();
     }
-    List<ChangeAndCommit> result = new ArrayList<>(cds.size());
+    List<RelatedChangeAndCommitInfo> result = new ArrayList<>(cds.size());
 
     boolean isEdit = rsrc.getEdit().isPresent();
     PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
@@ -104,19 +103,19 @@
     for (RelatedChangesSorter.PatchSetData d : sorter.sort(cds, basePs)) {
       PatchSet ps = d.patchSet();
       RevCommit commit;
-      if (isEdit && ps.getId().equals(basePs.getId())) {
+      if (isEdit && ps.id().equals(basePs.id())) {
         // Replace base of an edit with the edit itself.
         ps = rsrc.getPatchSet();
         commit = rsrc.getEdit().get().getEditCommit();
       } else {
         commit = d.commit();
       }
-      result.add(new ChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
+      result.add(newChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
     }
 
     if (result.size() == 1) {
-      ChangeAndCommit r = result.get(0);
-      if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
+      RelatedChangeAndCommitInfo r = result.get(0);
+      if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().commitId().name())) {
         return Collections.emptyList();
       }
     }
@@ -124,88 +123,44 @@
   }
 
   @VisibleForTesting
-  public static Set<String> getAllGroups(ChangeNotes notes, ReviewDb db, PatchSetUtil psUtil)
-      throws OrmException {
-    return psUtil
-        .byChange(db, notes)
-        .stream()
-        .flatMap(ps -> ps.getGroups().stream())
-        .collect(toSet());
+  public static Set<String> getAllGroups(ChangeNotes notes, PatchSetUtil psUtil) {
+    return psUtil.byChange(notes).stream().flatMap(ps -> ps.groups().stream()).collect(toSet());
   }
 
-  private void reloadChangeIfStale(List<ChangeData> cds, PatchSet wantedPs) throws OrmException {
+  private void reloadChangeIfStale(List<ChangeData> cds, PatchSet wantedPs) {
     for (ChangeData cd : cds) {
-      if (cd.getId().equals(wantedPs.getId().getParentKey())) {
-        if (cd.patchSet(wantedPs.getId()) == null) {
+      if (cd.getId().equals(wantedPs.id().changeId())) {
+        if (cd.patchSet(wantedPs.id()) == null) {
           cd.reloadChange();
         }
       }
     }
   }
 
-  public static class RelatedInfo {
-    public List<ChangeAndCommit> changes;
-  }
+  static RelatedChangeAndCommitInfo newChangeAndCommit(
+      Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
+    RelatedChangeAndCommitInfo info = new RelatedChangeAndCommitInfo();
+    info.project = project.get();
 
-  public static class ChangeAndCommit {
-    public String project;
-    public String changeId;
-    public CommitInfo commit;
-    public Integer _changeNumber;
-    public Integer _revisionNumber;
-    public Integer _currentRevisionNumber;
-    public String status;
-
-    public ChangeAndCommit() {}
-
-    ChangeAndCommit(
-        Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
-      this.project = project.get();
-
-      if (change != null) {
-        changeId = change.getKey().get();
-        _changeNumber = change.getChangeId();
-        _revisionNumber = ps != null ? ps.getPatchSetId() : null;
-        PatchSet.Id curr = change.currentPatchSetId();
-        _currentRevisionNumber = curr != null ? curr.get() : null;
-        status = change.getStatus().asChangeStatus().toString();
-      }
-
-      commit = new CommitInfo();
-      commit.commit = c.name();
-      commit.parents = Lists.newArrayListWithCapacity(c.getParentCount());
-      for (int i = 0; i < c.getParentCount(); i++) {
-        CommitInfo p = new CommitInfo();
-        p.commit = c.getParent(i).name();
-        commit.parents.add(p);
-      }
-      commit.author = CommonConverters.toGitPerson(c.getAuthorIdent());
-      commit.subject = c.getShortMessage();
+    if (change != null) {
+      info.changeId = change.getKey().get();
+      info._changeNumber = change.getChangeId();
+      info._revisionNumber = ps != null ? ps.number() : null;
+      PatchSet.Id curr = change.currentPatchSetId();
+      info._currentRevisionNumber = curr != null ? curr.get() : null;
+      info.status = ChangeUtil.status(change).toUpperCase(Locale.US);
     }
 
-    @Override
-    public String toString() {
-      return MoreObjects.toStringHelper(this)
-          .add("project", project)
-          .add("changeId", changeId)
-          .add("commit", toString(commit))
-          .add("_changeNumber", _changeNumber)
-          .add("_revisionNumber", _revisionNumber)
-          .add("_currentRevisionNumber", _currentRevisionNumber)
-          .add("status", status)
-          .toString();
+    info.commit = new CommitInfo();
+    info.commit.commit = c.name();
+    info.commit.parents = Lists.newArrayListWithCapacity(c.getParentCount());
+    for (int i = 0; i < c.getParentCount(); i++) {
+      CommitInfo p = new CommitInfo();
+      p.commit = c.getParent(i).name();
+      info.commit.parents.add(p);
     }
-
-    private static String toString(CommitInfo commit) {
-      return MoreObjects.toStringHelper(commit)
-          .add("commit", commit.commit)
-          .add("parent", commit.parents)
-          .add("author", commit.author)
-          .add("committer", commit.committer)
-          .add("subject", commit.subject)
-          .add("message", commit.message)
-          .add("webLinks", commit.webLinks)
-          .toString();
-    }
+    info.commit.author = CommonConverters.toGitPerson(c.getAuthorIdent());
+    info.commit.subject = c.getShortMessage();
+    return info;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetReview.java b/java/com/google/gerrit/server/restapi/change/GetReview.java
index 40e132d..8d941ab 100644
--- a/java/com/google/gerrit/server/restapi/change/GetReview.java
+++ b/java/com/google/gerrit/server/restapi/change/GetReview.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -35,7 +34,7 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(RevisionResource rsrc) throws OrmException {
+  public Response<ChangeInfo> apply(RevisionResource rsrc) {
     return delegate.apply(rsrc);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetReviewer.java b/java/com/google/gerrit/server/restapi/change/GetReviewer.java
index b9b6b09..a672b176 100644
--- a/java/com/google/gerrit/server/restapi/change/GetReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/GetReviewer.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -33,8 +34,8 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(ReviewerResource rsrc)
-      throws OrmException, PermissionBackendException {
-    return json.format(rsrc);
+  public Response<List<ReviewerInfo>> apply(ReviewerResource rsrc)
+      throws PermissionBackendException {
+    return Response.ok(json.format(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
index 03b95a6..c4da3b6 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -16,10 +16,10 @@
 
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ActionJson;
 import com.google.gerrit.server.change.ChangeResource;
@@ -29,8 +29,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.submit.ChangeSet;
 import com.google.gerrit.server.submit.MergeSuperSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -42,26 +40,23 @@
 public class GetRevisionActions implements ETagView<RevisionResource> {
   private final ActionJson delegate;
   private final Config config;
-  private final Provider<ReviewDb> dbProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
   private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
   GetRevisionActions(
       ActionJson delegate,
-      Provider<ReviewDb> dbProvider,
       Provider<MergeSuperSet> mergeSuperSet,
       ChangeResource.Factory changeResourceFactory,
       @GerritServerConfig Config config) {
     this.delegate = delegate;
-    this.dbProvider = dbProvider;
     this.mergeSuperSet = mergeSuperSet;
     this.changeResourceFactory = changeResourceFactory;
     this.config = config;
   }
 
   @Override
-  public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) throws OrmException {
+  public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
     return Response.withMustRevalidate(delegate.format(rsrc));
   }
 
@@ -72,14 +67,13 @@
     try {
       rsrc.getChangeResource().prepareETag(h, user);
       h.putBoolean(MergeSuperSet.wholeTopicEnabled(config));
-      ReviewDb db = dbProvider.get();
-      ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, rsrc.getChange(), user);
+      ChangeSet cs = mergeSuperSet.get().completeChangeSet(rsrc.getChange(), user);
       for (ChangeData cd : cs.changes()) {
         changeResourceFactory.create(cd.notes(), user).prepareETag(h, user);
       }
       h.putBoolean(cs.furtherHiddenChanges());
-    } catch (IOException | OrmException | PermissionBackendException e) {
-      throw new OrmRuntimeException(e);
+    } catch (IOException | PermissionBackendException e) {
+      throw new StorageException(e);
     }
     return h.hash().toString();
   }
diff --git a/java/com/google/gerrit/server/restapi/change/GetRobotComment.java b/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
index bd1f66a..4ff9942 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RobotCommentResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -33,7 +34,8 @@
   }
 
   @Override
-  public RobotCommentInfo apply(RobotCommentResource rsrc) throws OrmException {
-    return commentJson.get().newRobotCommentFormatter().format(rsrc.getComment());
+  public Response<RobotCommentInfo> apply(RobotCommentResource rsrc)
+      throws PermissionBackendException {
+    return Response.ok(commentJson.get().newRobotCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetTopic.java b/java/com/google/gerrit/server/restapi/change/GetTopic.java
index 7ab1cb1..6951fa5 100644
--- a/java/com/google/gerrit/server/restapi/change/GetTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/GetTopic.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 @Singleton
 public class GetTopic implements RestReadView<ChangeResource> {
   @Override
-  public String apply(ChangeResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getChange().getTopic());
+  public Response<String> apply(ChangeResource rsrc) {
+    return Response.ok(Strings.nullToEmpty(rsrc.getChange().getTopic()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Ignore.java b/java/com/google/gerrit/server/restapi/change/Ignore.java
index e319451..25cf311 100644
--- a/java/com/google/gerrit/server/restapi/change/Ignore.java
+++ b/java/com/google/gerrit/server/restapi/change/Ignore.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -26,7 +27,6 @@
 import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -51,7 +51,7 @@
 
   @Override
   public Response<String> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, OrmException, IllegalLabelException {
+      throws RestApiException, IllegalLabelException {
     try {
       if (rsrc.isUserOwner()) {
         throw new BadRequestException("cannot ignore own change");
@@ -73,7 +73,7 @@
   private boolean isIgnored(ChangeResource rsrc) {
     try {
       return stars.isIgnored(rsrc);
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("failed to check ignored star");
     }
     return false;
diff --git a/java/com/google/gerrit/server/restapi/change/Index.java b/java/com/google/gerrit/server/restapi/change/Index.java
index a5dd868..5a17c07 100644
--- a/java/com/google/gerrit/server/restapi/change/Index.java
+++ b/java/com/google/gerrit/server/restapi/change/Index.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -26,37 +25,28 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
 @Singleton
-public class Index extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
-
-  private final Provider<ReviewDb> db;
+public class Index extends RetryingRestModifyView<ChangeResource, Input, Object> {
   private final PermissionBackend permissionBackend;
   private final ChangeIndexer indexer;
 
   @Inject
-  Index(
-      Provider<ReviewDb> db,
-      RetryHelper retryHelper,
-      PermissionBackend permissionBackend,
-      ChangeIndexer indexer) {
+  Index(RetryHelper retryHelper, PermissionBackend permissionBackend, ChangeIndexer indexer) {
     super(retryHelper);
-    this.db = db;
     this.permissionBackend = permissionBackend;
     this.indexer = indexer;
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<Object> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
-      throws IOException, AuthException, OrmException, PermissionBackendException {
+      throws IOException, AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.MAINTAIN_SERVER);
-    indexer.index(db.get(), rsrc.getChange());
+    indexer.index(rsrc.getChange());
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index 37dc207..26e02b1 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -16,12 +16,12 @@
 
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -30,32 +30,30 @@
 
 @Singleton
 public class ListChangeComments implements RestReadView<ChangeResource> {
-  private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
 
   @Inject
   ListChangeComments(
-      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil) {
-    this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
   }
 
   @Override
-  public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
-      throws AuthException, OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .setFillPatchSet(true)
-        .newCommentFormatter()
-        .format(commentsUtil.publishedByChange(db.get(), cd.notes()));
+  public Response<Map<String, List<CommentInfo>>> apply(ChangeResource rsrc)
+      throws AuthException, PermissionBackendException {
+    ChangeData cd = changeDataFactory.create(rsrc.getNotes());
+    return Response.ok(
+        commentJson
+            .get()
+            .setFillAccounts(true)
+            .setFillPatchSet(true)
+            .newCommentFormatter()
+            .format(commentsUtil.publishedByChange(cd.notes())));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index d7a102a..f9e84dc 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -16,13 +16,13 @@
 
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -31,37 +31,35 @@
 
 @Singleton
 public class ListChangeDrafts implements RestReadView<ChangeResource> {
-  private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
 
   @Inject
   ListChangeDrafts(
-      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil) {
-    this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
   }
 
   @Override
-  public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
-      throws AuthException, OrmException {
+  public Response<Map<String, List<CommentInfo>>> apply(ChangeResource rsrc)
+      throws AuthException, PermissionBackendException {
     if (!rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
+    ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     List<Comment> drafts =
-        commentsUtil.draftByChangeAuthor(db.get(), cd.notes(), rsrc.getUser().getAccountId());
-    return commentJson
-        .get()
-        .setFillAccounts(false)
-        .setFillPatchSet(true)
-        .newCommentFormatter()
-        .format(drafts);
+        commentsUtil.draftByChangeAuthor(cd.notes(), rsrc.getUser().getAccountId());
+    return Response.ok(
+        commentJson
+            .get()
+            .setFillAccounts(false)
+            .setFillPatchSet(true)
+            .newCommentFormatter()
+            .format(drafts));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
index cf76ef1..12afe4d 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
@@ -17,45 +17,39 @@
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
 import java.util.stream.Collectors;
 
 @Singleton
 public class ListChangeMessages implements RestReadView<ChangeResource> {
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final AccountLoader accountLoader;
 
   @Inject
   public ListChangeMessages(
-      Provider<ReviewDb> dbProvider,
-      ChangeMessagesUtil changeMessagesUtil,
-      AccountLoader.Factory accountLoaderFactory) {
-    this.dbProvider = dbProvider;
+      ChangeMessagesUtil changeMessagesUtil, AccountLoader.Factory accountLoaderFactory) {
     this.changeMessagesUtil = changeMessagesUtil;
     this.accountLoader = accountLoaderFactory.create(true);
   }
 
   @Override
-  public List<ChangeMessageInfo> apply(ChangeResource resource) throws OrmException {
-    List<ChangeMessage> messages =
-        changeMessagesUtil.byChange(dbProvider.get(), resource.getNotes());
+  public Response<List<ChangeMessageInfo>> apply(ChangeResource resource)
+      throws PermissionBackendException {
+    List<ChangeMessage> messages = changeMessagesUtil.byChange(resource.getNotes());
     List<ChangeMessageInfo> messageInfos =
-        messages
-            .stream()
+        messages.stream()
             .map(m -> createChangeMessageInfo(m, accountLoader))
             .collect(Collectors.toList());
     accountLoader.fill();
-    return messageInfos;
+    return Response.ok(messageInfos);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
index dd8de6f..719a477 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
@@ -16,44 +16,42 @@
 
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.List;
 import java.util.Map;
 
 public class ListChangeRobotComments implements RestReadView<ChangeResource> {
-  private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
 
   @Inject
   ListChangeRobotComments(
-      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil) {
-    this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
   }
 
   @Override
-  public Map<String, List<RobotCommentInfo>> apply(ChangeResource rsrc)
-      throws AuthException, OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .setFillPatchSet(true)
-        .newRobotCommentFormatter()
-        .format(commentsUtil.robotCommentsByChange(cd.notes()));
+  public Response<Map<String, List<RobotCommentInfo>>> apply(ChangeResource rsrc)
+      throws AuthException, PermissionBackendException {
+    ChangeData cd = changeDataFactory.create(rsrc.getNotes());
+    return Response.ok(
+        commentJson
+            .get()
+            .setFillAccounts(true)
+            .setFillPatchSet(true)
+            .newRobotCommentFormatter()
+            .format(commentsUtil.robotCommentsByChange(cd.notes())));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
index 750e74f..0297589 100644
--- a/java/com/google/gerrit/server/restapi/change/ListReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -15,47 +15,39 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
 @Singleton
-class ListReviewers implements RestReadView<ChangeResource> {
-  private final Provider<ReviewDb> dbProvider;
+public class ListReviewers implements RestReadView<ChangeResource> {
   private final ApprovalsUtil approvalsUtil;
   private final ReviewerJson json;
   private final ReviewerResource.Factory resourceFactory;
 
   @Inject
   ListReviewers(
-      Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      ReviewerResource.Factory resourceFactory,
-      ReviewerJson json) {
-    this.dbProvider = dbProvider;
+      ApprovalsUtil approvalsUtil, ReviewerResource.Factory resourceFactory, ReviewerJson json) {
     this.approvalsUtil = approvalsUtil;
     this.resourceFactory = resourceFactory;
     this.json = json;
   }
 
   @Override
-  public List<ReviewerInfo> apply(ChangeResource rsrc)
-      throws OrmException, PermissionBackendException {
+  public Response<List<ReviewerInfo>> apply(ChangeResource rsrc) throws PermissionBackendException {
     Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
-    ReviewDb db = dbProvider.get();
-    for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
+    for (Account.Id accountId : approvalsUtil.getReviewers(rsrc.getNotes()).all()) {
       if (!reviewers.containsKey(accountId.toString())) {
         reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
       }
@@ -65,6 +57,6 @@
         reviewers.put(adr.toString(), new ReviewerResource(rsrc, adr));
       }
     }
-    return json.format(reviewers.values());
+    return Response.ok(json.format(reviewers.values()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
index 964e560..f2e5a37 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
@@ -15,11 +15,9 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -27,9 +25,8 @@
 @Singleton
 public class ListRevisionComments extends ListRevisionDrafts {
   @Inject
-  ListRevisionComments(
-      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
-    super(db, commentJson, commentsUtil);
+  ListRevisionComments(Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
+    super(commentJson, commentsUtil);
   }
 
   @Override
@@ -38,8 +35,8 @@
   }
 
   @Override
-  protected Iterable<Comment> listComments(RevisionResource rsrc) throws OrmException {
+  protected Iterable<Comment> listComments(RevisionResource rsrc) {
     ChangeNotes notes = rsrc.getNotes();
-    return commentsUtil.publishedByPatchSet(db.get(), notes, rsrc.getPatchSet().getId());
+    return commentsUtil.publishedByPatchSet(notes, rsrc.getPatchSet().id());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
index b7dc553..f4f4abb 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -29,21 +30,18 @@
 
 @Singleton
 public class ListRevisionDrafts implements RestReadView<RevisionResource> {
-  protected final Provider<ReviewDb> db;
   protected final Provider<CommentJson> commentJson;
   protected final CommentsUtil commentsUtil;
 
   @Inject
-  ListRevisionDrafts(
-      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
-    this.db = db;
+  ListRevisionDrafts(Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
   }
 
-  protected Iterable<Comment> listComments(RevisionResource rsrc) throws OrmException {
+  protected Iterable<Comment> listComments(RevisionResource rsrc) {
     return commentsUtil.draftByPatchSetAuthor(
-        db.get(), rsrc.getPatchSet().getId(), rsrc.getAccountId(), rsrc.getNotes());
+        rsrc.getPatchSet().id(), rsrc.getAccountId(), rsrc.getNotes());
   }
 
   protected boolean includeAuthorInfo() {
@@ -51,15 +49,18 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> apply(RevisionResource rsrc) throws OrmException {
-    return commentJson
-        .get()
-        .setFillAccounts(includeAuthorInfo())
-        .newCommentFormatter()
-        .format(listComments(rsrc));
+  public Response<Map<String, List<CommentInfo>>> apply(RevisionResource rsrc)
+      throws PermissionBackendException {
+    return Response.ok(
+        commentJson
+            .get()
+            .setFillAccounts(includeAuthorInfo())
+            .newCommentFormatter()
+            .format(listComments(rsrc)));
   }
 
-  public List<CommentInfo> getComments(RevisionResource rsrc) throws OrmException {
+  public ImmutableList<CommentInfo> getComments(RevisionResource rsrc)
+      throws PermissionBackendException {
     return commentJson
         .get()
         .setFillAccounts(includeAuthorInfo())
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
index d0630b7..a3b196f 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -16,17 +16,16 @@
 
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -34,33 +33,27 @@
 
 @Singleton
 class ListRevisionReviewers implements RestReadView<RevisionResource> {
-  private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
   private final ReviewerJson json;
   private final ReviewerResource.Factory resourceFactory;
 
   @Inject
   ListRevisionReviewers(
-      Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      ReviewerResource.Factory resourceFactory,
-      ReviewerJson json) {
-    this.dbProvider = dbProvider;
+      ApprovalsUtil approvalsUtil, ReviewerResource.Factory resourceFactory, ReviewerJson json) {
     this.approvalsUtil = approvalsUtil;
     this.resourceFactory = resourceFactory;
     this.json = json;
   }
 
   @Override
-  public List<ReviewerInfo> apply(RevisionResource rsrc)
-      throws OrmException, MethodNotAllowedException, PermissionBackendException {
+  public Response<List<ReviewerInfo>> apply(RevisionResource rsrc)
+      throws MethodNotAllowedException, PermissionBackendException {
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot list reviewers on non-current patch set");
     }
 
     Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
-    ReviewDb db = dbProvider.get();
-    for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
+    for (Account.Id accountId : approvalsUtil.getReviewers(rsrc.getNotes()).all()) {
       if (!reviewers.containsKey(accountId.toString())) {
         reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
       }
@@ -70,6 +63,6 @@
         reviewers.put(address.toString(), new ReviewerResource(rsrc, address));
       }
     }
-    return json.format(reviewers.values());
+    return Response.ok(json.format(reviewers.values()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
index 61219d3..4d56770 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -29,28 +30,28 @@
 
 @Singleton
 public class ListRobotComments implements RestReadView<RevisionResource> {
-  protected final Provider<ReviewDb> db;
   protected final Provider<CommentJson> commentJson;
   protected final CommentsUtil commentsUtil;
 
   @Inject
-  ListRobotComments(
-      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
-    this.db = db;
+  ListRobotComments(Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
   }
 
   @Override
-  public Map<String, List<RobotCommentInfo>> apply(RevisionResource rsrc) throws OrmException {
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .newRobotCommentFormatter()
-        .format(listComments(rsrc));
+  public Response<Map<String, List<RobotCommentInfo>>> apply(RevisionResource rsrc)
+      throws PermissionBackendException {
+    return Response.ok(
+        commentJson
+            .get()
+            .setFillAccounts(true)
+            .newRobotCommentFormatter()
+            .format(listComments(rsrc)));
   }
 
-  public List<RobotCommentInfo> getComments(RevisionResource rsrc) throws OrmException {
+  public ImmutableList<RobotCommentInfo> getComments(RevisionResource rsrc)
+      throws PermissionBackendException {
     return commentJson
         .get()
         .setFillAccounts(true)
@@ -58,7 +59,7 @@
         .formatAsList(listComments(rsrc));
   }
 
-  private Iterable<RobotComment> listComments(RevisionResource rsrc) throws OrmException {
-    return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().getId());
+  private Iterable<RobotComment> listComments(RevisionResource rsrc) {
+    return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().id());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
index 7c9ba73..4c942d2 100644
--- a/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
@@ -15,19 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
@@ -35,16 +33,11 @@
     implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeData.Factory changeDataFactory;
   private final StarredChangesUtil stars;
 
   @Inject
-  MarkAsReviewed(
-      Provider<ReviewDb> dbProvider,
-      ChangeData.Factory changeDataFactory,
-      StarredChangesUtil stars) {
-    this.dbProvider = dbProvider;
+  MarkAsReviewed(ChangeData.Factory changeDataFactory, StarredChangesUtil stars) {
     this.changeDataFactory = changeDataFactory;
     this.stars = stars;
   }
@@ -59,7 +52,7 @@
 
   @Override
   public Response<String> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, OrmException, IllegalLabelException {
+      throws RestApiException, IllegalLabelException {
     stars.markAsReviewed(rsrc);
     return Response.ok("");
   }
@@ -67,9 +60,9 @@
   private boolean isReviewed(ChangeResource rsrc) {
     try {
       return changeDataFactory
-          .create(dbProvider.get(), rsrc.getNotes())
+          .create(rsrc.getNotes())
           .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("failed to check if change is reviewed");
     }
     return false;
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
index 6e15dcc..5945b14 100644
--- a/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
@@ -15,18 +15,16 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
@@ -34,16 +32,11 @@
     implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeData.Factory changeDataFactory;
   private final StarredChangesUtil stars;
 
   @Inject
-  MarkAsUnreviewed(
-      Provider<ReviewDb> dbProvider,
-      ChangeData.Factory changeDataFactory,
-      StarredChangesUtil stars) {
-    this.dbProvider = dbProvider;
+  MarkAsUnreviewed(ChangeData.Factory changeDataFactory, StarredChangesUtil stars) {
     this.changeDataFactory = changeDataFactory;
     this.stars = stars;
   }
@@ -57,8 +50,7 @@
   }
 
   @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws OrmException, IllegalLabelException {
+  public Response<String> apply(ChangeResource rsrc, Input input) throws IllegalLabelException {
     stars.markAsUnreviewed(rsrc);
     return Response.ok("");
   }
@@ -66,9 +58,9 @@
   private boolean isReviewed(ChangeResource rsrc) {
     try {
       return changeDataFactory
-          .create(dbProvider.get(), rsrc.getNotes())
+          .create(rsrc.getNotes())
           .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("failed to check if change is reviewed");
     }
     return false;
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index b196347..f7e1108 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -14,18 +14,17 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.MergeableInfo;
 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.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.RevisionResource;
@@ -38,12 +37,9 @@
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 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.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.Future;
@@ -54,8 +50,6 @@
 import org.kohsuke.args4j.Option;
 
 public class Mergeable implements RestReadView<RevisionResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   @Option(
       name = "--other-branches",
       aliases = {"-o"},
@@ -66,7 +60,6 @@
   private final ProjectCache projectCache;
   private final MergeUtil.Factory mergeUtilFactory;
   private final ChangeData.Factory changeDataFactory;
-  private final Provider<ReviewDb> db;
   private final ChangeIndexer indexer;
   private final MergeabilityCache cache;
   private final SubmitRuleEvaluator submitRuleEvaluator;
@@ -77,7 +70,6 @@
       ProjectCache projectCache,
       MergeUtil.Factory mergeUtilFactory,
       ChangeData.Factory changeDataFactory,
-      Provider<ReviewDb> db,
       ChangeIndexer indexer,
       MergeabilityCache cache,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
@@ -85,7 +77,6 @@
     this.projectCache = projectCache;
     this.mergeUtilFactory = mergeUtilFactory;
     this.changeDataFactory = changeDataFactory;
-    this.db = db;
     this.indexer = indexer;
     this.cache = cache;
     submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
@@ -96,26 +87,25 @@
   }
 
   @Override
-  public MergeableInfo apply(RevisionResource resource)
-      throws AuthException, ResourceConflictException, BadRequestException, OrmException,
-          IOException {
+  public Response<MergeableInfo> apply(RevisionResource resource)
+      throws AuthException, ResourceConflictException, BadRequestException, IOException {
     Change change = resource.getChange();
     PatchSet ps = resource.getPatchSet();
     MergeableInfo result = new MergeableInfo();
 
-    if (!change.getStatus().isOpen()) {
+    if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    } else if (!ps.getId().equals(change.currentPatchSetId())) {
+    } else if (!ps.id().equals(change.currentPatchSetId())) {
       // Only the current revision is mergeable. Others always fail.
-      return result;
+      return Response.ok(result);
     }
 
-    ChangeData cd = changeDataFactory.create(db.get(), resource.getNotes());
+    ChangeData cd = changeDataFactory.create(resource.getNotes());
     result.submitType = getSubmitType(cd);
 
     try (Repository git = gitManager.openRepository(change.getProject())) {
-      ObjectId commit = toId(ps);
-      Ref ref = git.getRefDatabase().exactRef(change.getDest().get());
+      ObjectId commit = ps.commitId();
+      Ref ref = git.getRefDatabase().exactRef(change.getDest().branch());
       ProjectState projectState = projectCache.get(change.getProject());
       String strategy = mergeUtilFactory.create(projectState).mergeStrategyName();
       result.strategy = strategy;
@@ -140,13 +130,13 @@
         }
       }
     }
-    return result;
+    return Response.ok(result);
   }
 
-  private SubmitType getSubmitType(ChangeData cd) throws OrmException {
+  private SubmitType getSubmitType(ChangeData cd) {
     SubmitTypeRecord rec = submitRuleEvaluator.getSubmitType(cd);
     if (rec.status != SubmitTypeRecord.Status.OK) {
-      throw new OrmException("Submit type rule failed: " + rec);
+      throw new StorageException("Submit type rule failed: " + rec);
     }
     return rec.type;
   }
@@ -157,8 +147,7 @@
       ObjectId commit,
       Ref ref,
       SubmitType submitType,
-      String strategy)
-      throws OrmException {
+      String strategy) {
     if (commit == null) {
       return false;
     }
@@ -170,15 +159,6 @@
     return refresh(change, commit, ref, submitType, strategy, git, old);
   }
 
-  private static ObjectId toId(PatchSet ps) {
-    try {
-      return ObjectId.fromString(ps.getRevision().get());
-    } catch (IllegalArgumentException e) {
-      logger.atSevere().log("Invalid revision on patch set %s", ps);
-      return null;
-    }
-  }
-
   private boolean refresh(
       final Change change,
       ObjectId commit,
@@ -186,25 +166,14 @@
       SubmitType type,
       String strategy,
       Repository git,
-      Boolean old)
-      throws OrmException {
-    final boolean mergeable = cache.get(commit, ref, type, strategy, change.getDest(), git);
+      Boolean old) {
+    boolean mergeable = cache.get(commit, ref, type, strategy, change.getDest(), git);
+    // TODO(dborowitz): Include something else in the change ETag that it's possible to bump here,
+    // such as cache or secondary index update time.
     if (!Objects.equals(mergeable, old)) {
-      invalidateETag(change.getId(), db.get());
-
       @SuppressWarnings("unused")
       Future<?> possiblyIgnoredError = indexer.indexAsync(change.getProject(), change.getId());
     }
     return mergeable;
   }
-
-  private static void invalidateETag(Change.Id id, ReviewDb db) throws OrmException {
-    // Empty update of Change to bump rowVersion, changing its ETag.
-    // TODO(dborowitz): Include cache info in ETag somehow instead.
-    db = ReviewDbUtil.unwrapDb(db);
-    Change c = db.changes().get(id);
-    if (c != null) {
-      db.changes().update(Collections.singleton(c));
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 7955fa59..a57bd64 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -25,19 +25,23 @@
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
 import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
 import static com.google.gerrit.server.change.VoteResource.VOTE_KIND;
-import static com.google.gerrit.server.project.CommitResource.COMMIT_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.DeleteChangeOp;
+import com.google.gerrit.server.change.DeleteReviewerByEmailOp;
+import com.google.gerrit.server.change.DeleteReviewerOp;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.SetAssigneeOp;
 import com.google.gerrit.server.change.SetHashtagsOp;
+import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
@@ -68,6 +72,7 @@
     DynamicMap.mapOf(binder(), VOTE_KIND);
     DynamicMap.mapOf(binder(), CHANGE_MESSAGE_KIND);
 
+    postOnCollection(CHANGE_KIND).to(CreateChange.class);
     get(CHANGE_KIND).to(GetChange.class);
     post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
     get(CHANGE_KIND, "detail").to(GetDetail.class);
@@ -95,7 +100,6 @@
     get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
     post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
     post(CHANGE_KIND, "index").to(Index.class);
-    post(CHANGE_KIND, "rebuild.notedb").to(Rebuild.class);
     post(CHANGE_KIND, "move").to(Move.class);
     post(CHANGE_KIND, "private").to(PostPrivate.class);
     post(CHANGE_KIND, "private.delete").to(DeletePrivateByPost.class);
@@ -108,9 +112,9 @@
     post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
     put(CHANGE_KIND, "message").to(PutMessage.class);
 
-    post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
+    postOnCollection(REVIEWER_KIND).to(PostReviewers.class);
     get(REVIEWER_KIND).to(GetReviewer.class);
     delete(REVIEWER_KIND).to(DeleteReviewer.class);
     post(REVIEWER_KIND, "delete").to(DeleteReviewer.class);
@@ -165,30 +169,33 @@
     get(FILE_KIND, "blame").to(GetBlame.class);
 
     child(CHANGE_KIND, "edit").to(ChangeEdits.class);
-    delete(CHANGE_KIND, "edit").to(DeleteChangeEdit.class);
-    child(CHANGE_KIND, "edit:publish").to(PublishChangeEdit.class);
-    child(CHANGE_KIND, "edit:rebase").to(RebaseChangeEdit.class);
+    create(CHANGE_EDIT_KIND).to(ChangeEdits.Create.class);
+    deleteMissing(CHANGE_EDIT_KIND).to(ChangeEdits.DeleteFile.class);
+    postOnCollection(CHANGE_EDIT_KIND).to(ChangeEdits.Post.class);
+    deleteOnCollection(CHANGE_EDIT_KIND).to(DeleteChangeEdit.class);
+    post(CHANGE_KIND, "edit:publish").to(PublishChangeEdit.class);
+    post(CHANGE_KIND, "edit:rebase").to(RebaseChangeEdit.class);
     put(CHANGE_KIND, "edit:message").to(ChangeEdits.EditMessage.class);
     get(CHANGE_KIND, "edit:message").to(ChangeEdits.GetMessage.class);
     put(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Put.class);
     delete(CHANGE_EDIT_KIND).to(ChangeEdits.DeleteContent.class);
     get(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Get.class);
     get(CHANGE_EDIT_KIND, "meta").to(ChangeEdits.GetMeta.class);
-    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
 
     child(CHANGE_KIND, "messages").to(ChangeMessages.class);
     get(CHANGE_MESSAGE_KIND).to(GetChangeMessage.class);
+    delete(CHANGE_MESSAGE_KIND).to(DeleteChangeMessage.DefaultDeleteChangeMessage.class);
+    post(CHANGE_MESSAGE_KIND, "delete").to(DeleteChangeMessage.class);
 
     factory(AccountLoader.Factory.class);
-    factory(ChangeEdits.Create.Factory.class);
-    factory(ChangeEdits.DeleteFile.Factory.class);
     factory(ChangeInserter.Factory.class);
     factory(ChangeResource.Factory.class);
+    factory(DeleteChangeOp.Factory.class);
     factory(DeleteReviewerByEmailOp.Factory.class);
     factory(DeleteReviewerOp.Factory.class);
     factory(EmailReviewComments.Factory.class);
     factory(PatchSetInserter.Factory.class);
-    factory(PostReviewersOp.Factory.class);
+    factory(AddReviewersOp.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 899d773..2ba1de0 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -22,32 +22,33 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -61,13 +62,14 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 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.List;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -79,7 +81,6 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
@@ -87,12 +88,11 @@
   private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
   private final ProjectCache projectCache;
-  private final Provider<CurrentUser> userProvider;
+  private final boolean moveEnabled;
 
   @Inject
   Move(
       PermissionBackend permissionBackend,
-      Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
@@ -101,10 +101,9 @@
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
       ProjectCache projectCache,
-      Provider<CurrentUser> userProvider) {
+      @GerritServerConfig Config gerritConfig) {
     super(retryHelper);
     this.permissionBackend = permissionBackend;
-    this.dbProvider = dbProvider;
     this.json = json;
     this.repoManager = repoManager;
     this.queryProvider = queryProvider;
@@ -112,54 +111,61 @@
     this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
     this.projectCache = projectCache;
-    this.userProvider = userProvider;
+    this.moveEnabled = gerritConfig.getBoolean("change", null, "move", true);
   }
 
   @Override
-  protected ChangeInfo applyImpl(
+  protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, MoveInput input)
-      throws RestApiException, OrmException, UpdateException, PermissionBackendException,
-          IOException {
+      throws RestApiException, UpdateException, PermissionBackendException, IOException {
+    if (!moveEnabled) {
+      // This will be removed with the above config once we reach consensus for the move change
+      // behavior. See: https://bugs.chromium.org/p/gerrit/issues/detail?id=9877
+      throw new MethodNotAllowedException("move changes endpoint is disabled");
+    }
+
     Change change = rsrc.getChange();
     Project.NameKey project = rsrc.getProject();
     IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
+    if (input.destinationBranch == null) {
+      throw new BadRequestException("destination branch is required");
+    }
     input.destinationBranch = RefNames.fullName(input.destinationBranch);
 
-    if (change.getStatus().isClosed()) {
+    if (!change.isNew()) {
       throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
     }
 
-    Branch.NameKey newDest = new Branch.NameKey(project, input.destinationBranch);
+    BranchNameKey newDest = BranchNameKey.create(project, input.destinationBranch);
     if (change.getDest().equals(newDest)) {
       throw new ResourceConflictException("Change is already destined for the specified branch");
     }
 
     // Not allowed to move if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     // Move requires abandoning this change, and creating a new change.
     try {
-      rsrc.permissions().database(dbProvider).check(ABANDON);
-      permissionBackend.user(caller).database(dbProvider).ref(newDest).check(CREATE_CHANGE);
+      rsrc.permissions().check(ABANDON);
+      permissionBackend.user(caller).ref(newDest).check(CREATE_CHANGE);
     } catch (AuthException denied) {
       throw new AuthException("move not permitted", denied);
     }
     projectCache.checkedGet(project).checkStatePermitsWrite();
 
     Op op = new Op(input);
-    try (BatchUpdate u =
-        updateFactory.create(dbProvider.get(), project, caller, TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.nowTs())) {
       u.addOp(change.getId(), op);
       u.execute();
     }
-    return json.noOptions().format(op.getChange());
+    return Response.ok(json.noOptions().format(op.getChange()));
   }
 
   private class Op implements BatchUpdateOp {
     private final MoveInput input;
 
     private Change change;
-    private Branch.NameKey newDestKey;
+    private BranchNameKey newDestKey;
 
     Op(MoveInput input) {
       this.input = input;
@@ -171,16 +177,15 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws OrmException, ResourceConflictException, IOException {
+    public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, IOException {
       change = ctx.getChange();
-      if (change.getStatus() != Status.NEW) {
+      if (!change.isNew()) {
         throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
       }
 
       Project.NameKey projectKey = change.getProject();
-      newDestKey = new Branch.NameKey(projectKey, input.destinationBranch);
-      Branch.NameKey changePrevDest = change.getDest();
+      newDestKey = BranchNameKey.create(projectKey, input.destinationBranch);
+      BranchNameKey changePrevDest = change.getDest();
       if (changePrevDest.equals(newDestKey)) {
         throw new ResourceConflictException("Change is already destined for the specified branch");
       }
@@ -189,9 +194,7 @@
       try (Repository repo = repoManager.openRepository(projectKey);
           RevWalk revWalk = new RevWalk(repo)) {
         RevCommit currPatchsetRevCommit =
-            revWalk.parseCommit(
-                ObjectId.fromString(
-                    psUtil.current(ctx.getDb(), ctx.getNotes()).getRevision().get()));
+            revWalk.parseCommit(psUtil.current(ctx.getNotes()).commitId());
         if (currPatchsetRevCommit.getParentCount() > 1) {
           throw new ResourceConflictException("Merge commit cannot be moved");
         }
@@ -213,7 +216,7 @@
       if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey)).isEmpty()) {
         throw new ResourceConflictException(
             "Destination "
-                + newDestKey.getShortName()
+                + newDestKey.shortName()
                 + " has a different change with same change key "
                 + changeKey);
       }
@@ -224,23 +227,23 @@
 
       PatchSet.Id psId = change.currentPatchSetId();
       ChangeUpdate update = ctx.getUpdate(psId);
-      update.setBranch(newDestKey.get());
+      update.setBranch(newDestKey.branch());
       change.setDest(newDestKey);
 
       updateApprovals(ctx, update, psId, projectKey);
 
       StringBuilder msgBuf = new StringBuilder();
       msgBuf.append("Change destination moved from ");
-      msgBuf.append(changePrevDest.getShortName());
+      msgBuf.append(changePrevDest.shortName());
       msgBuf.append(" to ");
-      msgBuf.append(newDestKey.getShortName());
+      msgBuf.append(newDestKey.shortName());
       if (!Strings.isNullOrEmpty(input.message)) {
         msgBuf.append("\n\n");
         msgBuf.append(input.message);
       }
       ChangeMessage cmsg =
           ChangeMessagesUtil.newMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
-      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+      cmUtil.addChangeMessage(update, cmsg);
 
       return true;
     }
@@ -253,19 +256,13 @@
      */
     private void updateApprovals(
         ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project)
-        throws IOException, OrmException {
+        throws IOException {
       List<PatchSetApproval> approvals = new ArrayList<>();
       for (PatchSetApproval psa :
           approvalsUtil.byPatchSet(
-              ctx.getDb(),
-              ctx.getNotes(),
-              userProvider.get(),
-              psId,
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
+              ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
         ProjectState projectState = projectCache.checkedGet(project);
-        LabelType type =
-            projectState.getLabelTypes(ctx.getNotes(), ctx.getUser()).byLabel(psa.getLabelId());
+        LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
         // Only keep veto votes, defined as votes where:
         // 1- the label function allows minimum values to block submission.
         // 2- the vote holds the minimum value.
@@ -274,15 +271,14 @@
         }
 
         // Remove votes from NoteDb.
-        update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
+        update.removeApprovalFor(psa.accountId(), psa.label());
         approvals.add(
-            new PatchSetApproval(
-                new PatchSetApproval.Key(psId, psa.getAccountId(), new LabelId(psa.getLabel())),
-                (short) 0,
-                ctx.getWhen()));
+            PatchSetApproval.builder()
+                .key(PatchSetApproval.key(psId, psa.accountId(), LabelId.create(psa.label())))
+                .value(0)
+                .granted(ctx.getWhen())
+                .build());
       }
-      // Remove votes from ReviewDb.
-      ctx.getDb().patchSetApprovals().upsert(approvals);
     }
   }
 
@@ -295,7 +291,7 @@
             .setVisible(false);
 
     Change change = rsrc.getChange();
-    if (!change.getStatus().isOpen()) {
+    if (!change.isNew()) {
       return description;
     }
 
@@ -310,10 +306,10 @@
     }
 
     try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
-    } catch (OrmException | IOException e) {
+    } catch (StorageException | IOException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if the current patch set of change %s is locked", change.getId());
       return description;
@@ -322,6 +318,6 @@
     return description.setVisible(
         and(
             permissionBackend.user(rsrc.getUser()).ref(change.getDest()).testCond(CREATE_CHANGE),
-            rsrc.permissions().database(dbProvider).testCond(ABANDON)));
+            rsrc.permissions().testCond(ABANDON)));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostHashtags.java b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
index f31d04e..516dead 100644
--- a/java/com/google/gerrit/server/restapi/change/PostHashtags.java
+++ b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
@@ -15,12 +15,10 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableSortedSet;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -29,23 +27,19 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class PostHashtags
-    extends RetryingRestModifyView<
-        ChangeResource, HashtagsInput, Response<ImmutableSortedSet<String>>>
+    extends RetryingRestModifyView<ChangeResource, HashtagsInput, ImmutableSortedSet<String>>
     implements UiAction<ChangeResource> {
-  private final Provider<ReviewDb> db;
   private final SetHashtagsOp.Factory hashtagsFactory;
 
   @Inject
-  PostHashtags(
-      Provider<ReviewDb> db, RetryHelper retryHelper, SetHashtagsOp.Factory hashtagsFactory) {
+  PostHashtags(RetryHelper retryHelper, SetHashtagsOp.Factory hashtagsFactory) {
     super(retryHelper);
-    this.db = db;
     this.hashtagsFactory = hashtagsFactory;
   }
 
@@ -56,12 +50,11 @@
     req.permissions().check(ChangePermission.EDIT_HASHTAGS);
 
     try (BatchUpdate bu =
-        updateFactory.create(
-            db.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       SetHashtagsOp op = hashtagsFactory.create(input);
       bu.addOp(req.getId(), op);
       bu.execute();
-      return Response.<ImmutableSortedSet<String>>ok(op.getUpdatedHashtags());
+      return Response.ok(op.getUpdatedHashtags());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
index 5a13346..37a288d 100644
--- a/java/com/google/gerrit/server/restapi/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -25,9 +24,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -35,32 +33,25 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class PostPrivate
-    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>>
+public class PostPrivate extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, String>
     implements UiAction<ChangeResource> {
-  private final ChangeMessagesUtil cmUtil;
-  private final Provider<ReviewDb> dbProvider;
   private final PermissionBackend permissionBackend;
   private final SetPrivateOp.Factory setPrivateOpFactory;
   private final boolean disablePrivateChanges;
 
   @Inject
   PostPrivate(
-      Provider<ReviewDb> dbProvider,
       RetryHelper retryHelper,
-      ChangeMessagesUtil cmUtil,
       PermissionBackend permissionBackend,
       SetPrivateOp.Factory setPrivateOpFactory,
       @GerritServerConfig Config config) {
     super(retryHelper);
-    this.dbProvider = dbProvider;
-    this.cmUtil = cmUtil;
     this.permissionBackend = permissionBackend;
     this.setPrivateOpFactory = setPrivateOpFactory;
     this.disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
@@ -82,10 +73,9 @@
       return Response.ok("");
     }
 
-    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, true, input);
+    SetPrivateOp op = setPrivateOpFactory.create(true, input);
     try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       u.addOp(rsrc.getId(), op).execute();
     }
 
@@ -98,13 +88,16 @@
     return new UiAction.Description()
         .setLabel("Mark private")
         .setTitle("Mark change as private")
-        .setVisible(and(!disablePrivateChanges && !change.isPrivate(), canSetPrivate(rsrc)));
+        .setVisible(
+            and(
+                !disablePrivateChanges && !change.isPrivate() && change.isNew(),
+                canSetPrivate(rsrc)));
   }
 
   private BooleanCondition canSetPrivate(ChangeResource rsrc) {
     PermissionBackend.WithUser user = permissionBackend.user(rsrc.getUser());
     return or(
-        rsrc.isUserOwner() && rsrc.getChange().getStatus() != Change.Status.MERGED,
+        rsrc.isUserOwner() && !rsrc.getChange().isMerged(),
         user.testCond(GlobalPermission.ADMINISTRATE_SERVER));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index e6f4f69..910bc0c 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,12 +15,13 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
@@ -29,20 +30,20 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.common.collect.ListMultimap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
 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.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
@@ -58,12 +59,16 @@
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -76,28 +81,28 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.AddReviewersEmail;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.CommentAdded;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -107,22 +112,22 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.Gson;
-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.nio.charset.StandardCharsets;
@@ -136,18 +141,21 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.OptionalInt;
 import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class PostReview
-    extends RetryingRestModifyView<RevisionResource, ReviewInput, Response<ReviewResult>> {
-  public static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
-  public static final String ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS =
-      "only change owner can specify work_in_progress or ready";
+    extends RetryingRestModifyView<RevisionResource, ReviewInput, ReviewResult> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
@@ -156,7 +164,6 @@
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
   private static final int DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;
 
-  private final Provider<ReviewDb> db;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
@@ -165,20 +172,21 @@
   private final PublishCommentUtil publishCommentUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
-  private final PostReviewers postReviewers;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
+  private final ReviewerAdder reviewerAdder;
+  private final AddReviewersEmail addReviewersEmail;
+  private final NotifyResolver notifyResolver;
   private final Config gerritConfig;
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+  private final PluginSetContext<CommentValidator> commentValidators;
   private final boolean strictLabels;
 
   @Inject
   PostReview(
-      Provider<ReviewDb> db,
       RetryHelper retryHelper,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
@@ -188,17 +196,18 @@
       PublishCommentUtil publishCommentUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
-      AccountsCollection accounts,
+      AccountResolver accountResolver,
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
-      PostReviewers postReviewers,
-      NotesMigration migration,
-      NotifyUtil notifyUtil,
+      ReviewerAdder reviewerAdder,
+      AddReviewersEmail addReviewersEmail,
+      NotifyResolver notifyResolver,
       @GerritServerConfig Config gerritConfig,
       WorkInProgressOp.Factory workInProgressOpFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      PluginSetContext<CommentValidator> commentValidators) {
     super(retryHelper);
-    this.db = db;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.commentsUtil = commentsUtil;
@@ -207,37 +216,39 @@
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.accounts = accounts;
+    this.accountResolver = accountResolver;
     this.email = email;
     this.commentAdded = commentAdded;
-    this.postReviewers = postReviewers;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
+    this.reviewerAdder = reviewerAdder;
+    this.addReviewersEmail = addReviewersEmail;
+    this.notifyResolver = notifyResolver;
     this.gerritConfig = gerritConfig;
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.commentValidators = commentValidators;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
   }
 
   @Override
   protected Response<ReviewResult> applyImpl(
       BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input)
-      throws RestApiException, UpdateException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException, PatchListNotAvailableException {
+      throws RestApiException, UpdateException, IOException, PermissionBackendException,
+          ConfigInvalidException, PatchListNotAvailableException {
     return apply(updateFactory, revision, input, TimeUtil.nowTs());
   }
 
   public Response<ReviewResult> apply(
       BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input, Timestamp ts)
-      throws RestApiException, UpdateException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException, PatchListNotAvailableException {
+      throws RestApiException, UpdateException, IOException, PermissionBackendException,
+          ConfigInvalidException, PatchListNotAvailableException {
     // Respect timestamp, but truncate at change created-on time.
     ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
     if (revision.getEdit().isPresent()) {
       throw new ResourceConflictException("cannot post review on edit");
     }
     ProjectState projectState = projectCache.checkedGet(revision.getProject());
-    LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes(), revision.getUser());
+    LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes());
     input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
     if (input.onBehalfOf != null) {
       revision = onBehalfOf(revision, labelTypes, input);
@@ -250,32 +261,22 @@
       checkComments(revision, input.comments);
     }
     if (input.robotComments != null) {
-      if (!migration.readChanges()) {
-        throw new MethodNotAllowedException("robot comments not supported");
-      }
       checkRobotComments(revision, input.robotComments);
     }
 
-    NotifyHandling reviewerNotify = input.notify;
     if (input.notify == null) {
       input.notify = defaultNotify(revision.getChange(), input);
     }
 
-    ListMultimap<RecipientType, Account.Id> accountsToNotify =
-        notifyUtil.resolveAccounts(input.notifyDetails);
-
     Map<String, AddReviewerResult> reviewerJsonResults = null;
-    List<PostReviewers.Addition> reviewerResults = Lists.newArrayList();
+    List<ReviewerAddition> reviewerResults = Lists.newArrayList();
     boolean hasError = false;
     boolean confirm = false;
     if (input.reviewers != null) {
       reviewerJsonResults = Maps.newHashMap();
       for (AddReviewerInput reviewerInput : input.reviewers) {
-        // Prevent notifications because setting reviewers is batched.
-        reviewerInput.notify = NotifyHandling.NONE;
-
-        PostReviewers.Addition result =
-            postReviewers.prepareApplication(revision.getChangeResource(), reviewerInput, true);
+        ReviewerAddition result =
+            reviewerAdder.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
         reviewerJsonResults.put(reviewerInput.reviewer, result.result);
         if (result.result.error != null) {
           hasError = true;
@@ -298,24 +299,25 @@
     output.labels = input.labels;
 
     try (BatchUpdate bu =
-        updateFactory.create(db.get(), revision.getChange().getProject(), revision.getUser(), ts)) {
+        updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
       Account.Id id = revision.getUser().getAccountId();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
-        ccOrReviewer = input.labels.values().stream().filter(v -> v != 0).findFirst().isPresent();
+        ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
       }
 
       if (!ccOrReviewer) {
         // Check if user was already CCed or reviewing prior to this review.
         ReviewerSet currentReviewers =
-            approvalsUtil.getReviewers(db.get(), revision.getChangeResource().getNotes());
+            approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
         ccOrReviewer = currentReviewers.all().contains(id);
       }
 
       // Apply reviewer changes first. Revision emails should be sent to the
       // updated set of reviewers. Also keep track of whether the user added
       // themselves as a reviewer or to the CC list.
-      for (PostReviewers.Addition reviewerResult : reviewerResults) {
+      for (ReviewerAddition reviewerResult : reviewerResults) {
+        reviewerResult.op.suppressEmail(); // Send a single batch email below.
         bu.addOp(revision.getChange().getId(), reviewerResult.op);
         if (!ccOrReviewer && reviewerResult.result.reviewers != null) {
           for (ReviewerInfo reviewerInfo : reviewerResult.result.reviewers) {
@@ -339,8 +341,8 @@
         // User posting this review isn't currently in the reviewer or CC list,
         // isn't being explicitly added, and isn't voting on any label.
         // Automatically CC them on this change so they receive replies.
-        PostReviewers.Addition selfAddition =
-            postReviewers.ccCurrentUser(revision.getUser(), revision);
+        ReviewerAddition selfAddition = reviewerAdder.ccCurrentUser(revision.getUser(), revision);
+        selfAddition.op.suppressEmail();
         bu.addOp(revision.getChange().getId(), selfAddition.op);
       }
 
@@ -350,43 +352,57 @@
           output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
           return Response.withStatusCode(SC_BAD_REQUEST, output);
         }
-        if (!revision.getChange().getOwner().equals(revision.getUser().getAccountId())) {
-          output.error = ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS;
-          return Response.withStatusCode(SC_BAD_REQUEST, output);
-        }
+
+        revision
+            .getChangeResource()
+            .permissions()
+            .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
+
         if (input.ready) {
           output.ready = true;
         }
 
-        // Suppress notifications in WorkInProgressOp, we'll take care of
-        // them in this endpoint.
-        WorkInProgressOp.Input wipIn = new WorkInProgressOp.Input();
-        wipIn.notify = NotifyHandling.NONE;
-        bu.addOp(
-            revision.getChange().getId(),
-            workInProgressOpFactory.create(input.workInProgress, wipIn));
+        WorkInProgressOp wipOp =
+            workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
+        wipOp.suppressEmail();
+        bu.addOp(revision.getChange().getId(), wipOp);
       }
 
       // Add the review op.
       bu.addOp(
-          revision.getChange().getId(),
-          new Op(projectState, revision.getPatchSet().getId(), input, accountsToNotify));
+          revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
+
+      // Notify based on ReviewInput, ignoring the notify settings from any AddReviewerInputs.
+      NotifyResolver.Result notify =
+          notifyResolver.resolve(getNotifyHandling(input, output, revision), input.notifyDetails);
+      bu.setNotify(notify);
 
       bu.execute();
 
-      for (PostReviewers.Addition reviewerResult : reviewerResults) {
-        reviewerResult.gatherResults();
+      // Re-read change to take into account results of the update.
+      ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
+      for (ReviewerAddition reviewerResult : reviewerResults) {
+        reviewerResult.gatherResults(cd);
       }
 
-      boolean readyForReview =
-          (output.ready != null && output.ready) || !revision.getChange().isWorkInProgress();
-      emailReviewers(
-          revision.getChange(), reviewerResults, reviewerNotify, accountsToNotify, readyForReview);
+      // Sending from AddReviewersOp was suppressed so we can send a single batch email here.
+      batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
     }
 
     return Response.ok(output);
   }
 
+  private NotifyHandling getNotifyHandling(
+      ReviewInput input, ReviewResult output, RevisionResource revision) {
+    if (input.notify != null) {
+      return input.notify;
+    }
+    if ((output.ready != null && output.ready) || !revision.getChange().isWorkInProgress()) {
+      return NotifyHandling.ALL;
+    }
+    return NotifyHandling.NONE;
+  }
+
   private NotifyHandling defaultNotify(Change c, ReviewInput in) {
     boolean workInProgress = c.isWorkInProgress();
     if (in.workInProgress) {
@@ -402,44 +418,39 @@
     }
 
     if (workInProgress && !c.hasReviewStarted()) {
-      // If review hasn't started we want to minimize recipients, no matter who
-      // the author is.
-      return NotifyHandling.OWNER;
+      // If review hasn't started we want to eliminate notifications, no matter who the author is.
+      return NotifyHandling.NONE;
     }
 
+    // Otherwise, it's either a non-WIP change, or a WIP change where review has started. Notify
+    // everyone.
     return NotifyHandling.ALL;
   }
 
-  private void emailReviewers(
+  private void batchEmailReviewers(
+      CurrentUser user,
       Change change,
-      List<PostReviewers.Addition> reviewerAdditions,
-      @Nullable NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
-      boolean readyForReview) {
+      List<ReviewerAddition> reviewerAdditions,
+      NotifyResolver.Result notify) {
     List<Account.Id> to = new ArrayList<>();
     List<Account.Id> cc = new ArrayList<>();
     List<Address> toByEmail = new ArrayList<>();
     List<Address> ccByEmail = new ArrayList<>();
-    for (PostReviewers.Addition addition : reviewerAdditions) {
-      if (addition.state == ReviewerState.REVIEWER) {
+    for (ReviewerAddition addition : reviewerAdditions) {
+      if (addition.state() == ReviewerState.REVIEWER) {
         to.addAll(addition.reviewers);
         toByEmail.addAll(addition.reviewersByEmail);
-      } else if (addition.state == ReviewerState.CC) {
+      } else if (addition.state() == ReviewerState.CC) {
         cc.addAll(addition.reviewers);
         ccByEmail.addAll(addition.reviewersByEmail);
       }
     }
-    if (reviewerAdditions.size() > 0) {
-      reviewerAdditions
-          .get(0)
-          .op
-          .emailReviewers(
-              change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify, readyForReview);
-    }
+    addReviewersEmail.emailReviewers(
+        user.asIdentifiedUser(), change, to, cc, toByEmail, ccByEmail, notify);
   }
 
   private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
-      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException,
+      throws BadRequestException, AuthException, UnprocessableEntityException,
           PermissionBackendException, IOException, ConfigInvalidException {
     if (in.labels == null || in.labels.isEmpty()) {
       throw new AuthException(
@@ -450,7 +461,7 @@
     }
 
     CurrentUser caller = rev.getUser();
-    PermissionBackend.ForChange perm = rev.permissions().database(db);
+    PermissionBackend.ForChange perm = rev.permissions();
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
@@ -480,9 +491,9 @@
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
     }
 
-    IdentifiedUser reviewer = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    IdentifiedUser reviewer = accountResolver.resolve(in.onBehalfOf).asUniqueUserOnBehalfOf(caller);
     try {
-      perm.user(reviewer).check(ChangePermission.READ);
+      permissionBackend.user(reviewer).change(rev.getNotes()).check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new UnprocessableEntityException(
           String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()));
@@ -572,7 +583,7 @@
     Set<String> revisionFilePaths = getAffectedFilePaths(revision);
     for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
       String path = entry.getKey();
-      PatchSet.Id patchSetId = revision.getPatchSet().getId();
+      PatchSet.Id patchSetId = revision.getPatchSet().id();
       ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
 
       List<T> comments = entry.getValue();
@@ -586,7 +597,7 @@
 
   private Set<String> getAffectedFilePaths(RevisionResource revision)
       throws PatchListNotAvailableException {
-    ObjectId newId = ObjectId.fromString(revision.getPatchSet().getRevision().get());
+    ObjectId newId = revision.getPatchSet().commitId();
     DiffSummaryKey key =
         DiffSummaryKey.fromPatchListKey(
             PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
@@ -776,8 +787,7 @@
   private static void ensureRangesDoNotOverlap(
       String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
     List<Range> sortedRanges =
-        fixReplacementInfos
-            .stream()
+        fixReplacementInfos.stream()
             .map(fixReplacementInfo -> fixReplacementInfo.range)
             .sorted()
             .collect(toList());
@@ -795,7 +805,10 @@
     }
   }
 
-  /** Used to compare Comments with CommentInput comments. */
+  /**
+   * Used to compare existing {@link Comment}-s with {@link CommentInput} comments by copying only
+   * the fields to compare.
+   */
   @AutoValue
   abstract static class CommentSetEntry {
     private static CommentSetEntry create(
@@ -838,7 +851,6 @@
     private final ProjectState projectState;
     private final PatchSet.Id psId;
     private final ReviewInput in;
-    private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
 
     private IdentifiedUser user;
     private ChangeNotes notes;
@@ -849,26 +861,20 @@
     private Map<String, Short> approvals = new HashMap<>();
     private Map<String, Short> oldApprovals = new HashMap<>();
 
-    private Op(
-        ProjectState projectState,
-        PatchSet.Id psId,
-        ReviewInput in,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    private Op(ProjectState projectState, PatchSet.Id psId, ReviewInput in) {
       this.projectState = projectState;
       this.psId = psId;
       this.in = in;
-      this.accountsToNotify = checkNotNull(accountsToNotify);
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, ResourceConflictException, UnprocessableEntityException, IOException,
-            PatchListNotAvailableException {
+        throws ResourceConflictException, UnprocessableEntityException, IOException,
+            PatchListNotAvailableException, CommentsRejectedException {
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
-      ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-      boolean dirty = false;
-      dirty |= insertComments(ctx);
+      ps = psUtil.get(ctx.getNotes(), psId);
+      boolean dirty = insertComments(ctx);
       dirty |= insertRobotComments(ctx);
       dirty |= updateLabels(projectState, ctx);
       dirty |= insertMessage(ctx);
@@ -876,22 +882,14 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws OrmException {
+    public void postUpdate(Context ctx) {
       if (message == null) {
         return;
       }
-      if (in.notify.compareTo(NotifyHandling.NONE) > 0 || !accountsToNotify.isEmpty()) {
+      NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
+      if (notify.shouldNotify()) {
         email
-            .create(
-                in.notify,
-                accountsToNotify,
-                notes,
-                ps,
-                user,
-                message,
-                comments,
-                in.message,
-                labelDelta)
+            .create(notify, notes, ps, user, message, comments, in.message, labelDelta)
             .sendAsync();
       }
       commentAdded.fire(
@@ -905,14 +903,19 @@
     }
 
     private boolean insertComments(ChangeContext ctx)
-        throws OrmException, UnprocessableEntityException, PatchListNotAvailableException {
-      Map<String, List<CommentInput>> map = in.comments;
-      if (map == null) {
-        map = Collections.emptyMap();
+        throws UnprocessableEntityException, PatchListNotAvailableException,
+            CommentsRejectedException {
+      Map<String, List<CommentInput>> inputComments = in.comments;
+      if (inputComments == null) {
+        inputComments = Collections.emptyMap();
       }
 
-      Map<String, Comment> drafts = Collections.emptyMap();
-      if (!map.isEmpty() || in.drafts != DraftHandling.KEEP) {
+      // HashMap instead of Collections.emptyMap() avoids warning about remove() on immutable
+      // object.
+      Map<String, Comment> drafts = new HashMap<>();
+      // If there are inputComments we need the deduplication loop below, so we have to read (and
+      // publish) drafts here.
+      if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
         if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
           drafts = changeDrafts(ctx);
         } else {
@@ -920,53 +923,85 @@
         }
       }
 
+      // This will be populated with Comment-s created from inputComments.
       List<Comment> toPublish = new ArrayList<>();
 
-      Set<CommentSetEntry> existingIds =
+      Set<CommentSetEntry> existingComments =
           in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
 
-      for (Map.Entry<String, List<CommentInput>> ent : map.entrySet()) {
-        String path = ent.getKey();
-        for (CommentInput c : ent.getValue()) {
-          String parent = Url.decode(c.inReplyTo);
-          Comment e = drafts.remove(Url.decode(c.id));
-          if (e == null) {
-            e = commentsUtil.newComment(ctx, path, psId, c.side(), c.message, c.unresolved, parent);
+      // Deduplication:
+      // - Ignore drafts with the same ID as an inputComment here. These are deleted later.
+      // - Swallow comments that already exist.
+      for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
+        String path = entry.getKey();
+        for (CommentInput inputComment : entry.getValue()) {
+          Comment comment = drafts.remove(Url.decode(inputComment.id));
+          if (comment == null) {
+            String parent = Url.decode(inputComment.inReplyTo);
+            comment =
+                commentsUtil.newComment(
+                    ctx,
+                    path,
+                    psId,
+                    inputComment.side(),
+                    inputComment.message,
+                    inputComment.unresolved,
+                    parent);
           } else {
-            e.writtenOn = ctx.getWhen();
-            e.side = c.side();
-            e.message = c.message;
+            // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
+            comment.writtenOn = ctx.getWhen();
+            comment.side = inputComment.side();
+            comment.message = inputComment.message;
           }
 
-          setCommentRevId(e, patchListCache, ctx.getChange(), ps);
-          e.setLineNbrAndRange(c.line, c.range);
-          e.tag = in.tag;
+          setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+          comment.setLineNbrAndRange(inputComment.line, inputComment.range);
+          comment.tag = in.tag;
 
-          if (existingIds.contains(CommentSetEntry.create(e))) {
+          if (existingComments.contains(CommentSetEntry.create(comment))) {
             continue;
           }
-          toPublish.add(e);
+          toPublish.add(comment);
         }
       }
 
       switch (in.drafts) {
         case PUBLISH:
         case PUBLISH_ALL_REVISIONS:
+          validateComments(Streams.concat(drafts.values().stream(), toPublish.stream()));
           publishCommentUtil.publish(ctx, psId, drafts.values(), in.tag);
           comments.addAll(drafts.values());
           break;
         case KEEP:
         default:
+          validateComments(toPublish.stream());
           break;
       }
-      ChangeUpdate u = ctx.getUpdate(psId);
-      commentsUtil.putComments(ctx.getDb(), u, Status.PUBLISHED, toPublish);
+      ChangeUpdate changeUpdate = ctx.getUpdate(psId);
+      commentsUtil.putComments(changeUpdate, Status.PUBLISHED, toPublish);
       comments.addAll(toPublish);
       return !toPublish.isEmpty();
     }
 
-    private boolean insertRobotComments(ChangeContext ctx)
-        throws OrmException, PatchListNotAvailableException {
+    private void validateComments(Stream<Comment> comments) throws CommentsRejectedException {
+      ImmutableList<CommentForValidation> draftsForValidation =
+          comments
+              .map(
+                  comment ->
+                      CommentForValidation.create(
+                          comment.lineNbr > 0
+                              ? CommentForValidation.CommentType.INLINE_COMMENT
+                              : CommentForValidation.CommentType.FILE_COMMENT,
+                          comment.message))
+              .collect(toImmutableList());
+      ImmutableList<CommentValidationFailure> draftValidationFailures =
+          PublishCommentUtil.findInvalidComments(commentValidators, draftsForValidation);
+      if (!draftValidationFailures.isEmpty()) {
+        throw new CommentsRejectedException(draftValidationFailures);
+      }
+    }
+
+    private boolean insertRobotComments(ChangeContext ctx) throws PatchListNotAvailableException {
       if (in.robotComments == null) {
         return false;
       }
@@ -978,7 +1013,7 @@
     }
 
     private List<RobotComment> getNewRobotComments(ChangeContext ctx)
-        throws OrmException, PatchListNotAvailableException {
+        throws PatchListNotAvailableException {
       List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
 
       Set<CommentSetEntry> existingIds =
@@ -1014,7 +1049,7 @@
       robotComment.properties = robotCommentInput.properties;
       robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
       robotComment.tag = in.tag;
-      setCommentRevId(robotComment, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(robotComment, patchListCache, ctx.getChange(), ps);
       robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
       return robotComment;
     }
@@ -1047,46 +1082,38 @@
       return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
     }
 
-    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) throws OrmException {
-      return commentsUtil
-          .publishedByChange(ctx.getDb(), ctx.getNotes())
-          .stream()
+    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
+      return commentsUtil.publishedByChange(ctx.getNotes()).stream()
           .map(CommentSetEntry::create)
           .collect(toSet());
     }
 
-    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) throws OrmException {
-      return commentsUtil
-          .robotCommentsByChange(ctx.getNotes())
-          .stream()
+    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
+      return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
           .map(CommentSetEntry::create)
           .collect(toSet());
     }
 
-    private Map<String, Comment> changeDrafts(ChangeContext ctx) throws OrmException {
-      Map<String, Comment> drafts = new HashMap<>();
-      for (Comment c :
-          commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), user.getAccountId())) {
-        c.tag = in.tag;
-        drafts.put(c.key.uuid, c);
-      }
-      return drafts;
+    private Map<String, Comment> changeDrafts(ChangeContext ctx) {
+      return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
+          .collect(
+              Collectors.toMap(
+                  c -> c.key.uuid,
+                  c -> {
+                    c.tag = in.tag;
+                    return c;
+                  }));
     }
 
-    private Map<String, Comment> patchSetDrafts(ChangeContext ctx) throws OrmException {
-      Map<String, Comment> drafts = new HashMap<>();
-      for (Comment c :
-          commentsUtil.draftByPatchSetAuthor(
-              ctx.getDb(), psId, user.getAccountId(), ctx.getNotes())) {
-        drafts.put(c.key.uuid, c);
-      }
-      return drafts;
+    private Map<String, Comment> patchSetDrafts(ChangeContext ctx) {
+      return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
+          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
     }
 
     private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
       Map<String, Short> labels = new HashMap<>();
       for (PatchSetApproval psa : patchsetApprovals) {
-        labels.put(psa.getLabel(), psa.getValue());
+        labels.put(psa.label(), psa.value());
       }
       return labels;
     }
@@ -1122,33 +1149,30 @@
       return previous;
     }
 
-    private boolean isReviewer(ChangeContext ctx) throws OrmException {
+    private boolean isReviewer(ChangeContext ctx) {
       if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
         return true;
       }
-      ChangeData cd = changeDataFactory.create(db.get(), ctx.getNotes());
+      ChangeData cd = changeDataFactory.create(ctx.getNotes());
       ReviewerSet reviewers = cd.reviewers();
-      if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
-        return true;
-      }
-      return false;
+      return reviewers.byState(REVIEWER).contains(ctx.getAccountId());
     }
 
     private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
-        throws OrmException, ResourceConflictException, IOException {
+        throws ResourceConflictException, IOException {
       Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
 
       // If no labels were modified and change is closed, abort early.
       // This avoids trying to record a modified label caused by a user
       // losing access to a label after the change was submitted.
-      if (inLabels.isEmpty() && ctx.getChange().getStatus().isClosed()) {
+      if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
         return false;
       }
 
       List<PatchSetApproval> del = new ArrayList<>();
       List<PatchSetApproval> ups = new ArrayList<>();
       Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
       Map<String, Short> allApprovals =
           getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
       Map<String, Short> previous =
@@ -1157,7 +1181,7 @@
       ChangeUpdate update = ctx.getUpdate(psId);
       for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
         String name = ent.getKey();
-        LabelType lt = checkNotNull(labelTypes.byLabel(name), name);
+        LabelType lt = requireNonNull(labelTypes.byLabel(name), name);
 
         PatchSetApproval c = current.remove(lt.getName());
         String normName = lt.getName();
@@ -1166,35 +1190,40 @@
           // User requested delete of this label.
           oldApprovals.put(normName, null);
           if (c != null) {
-            if (c.getValue() != 0) {
+            if (c.value() != 0) {
               addLabelDelta(normName, (short) 0);
               oldApprovals.put(normName, previous.get(normName));
             }
             del.add(c);
             update.putApproval(normName, (short) 0);
           }
-        } else if (c != null && c.getValue() != ent.getValue()) {
-          c.setValue(ent.getValue());
-          c.setGranted(ctx.getWhen());
-          c.setTag(in.tag);
-          ctx.getUser().updateRealAccountId(c::setRealAccountId);
+        } else if (c != null && c.value() != ent.getValue()) {
+          PatchSetApproval.Builder b =
+              c.toBuilder()
+                  .value(ent.getValue())
+                  .granted(ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag));
+          ctx.getUser().updateRealAccountId(b::realAccountId);
+          c = b.build();
           ups.add(c);
-          addLabelDelta(normName, c.getValue());
+          addLabelDelta(normName, c.value());
           oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.getValue());
+          approvals.put(normName, c.value());
           update.putApproval(normName, ent.getValue());
-        } else if (c != null && c.getValue() == ent.getValue()) {
+        } else if (c != null && c.value() == ent.getValue()) {
           current.put(normName, c);
           oldApprovals.put(normName, null);
-          approvals.put(normName, c.getValue());
+          approvals.put(normName, c.value());
         } else if (c == null) {
-          c = ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen());
-          c.setTag(in.tag);
-          c.setGranted(ctx.getWhen());
+          c =
+              ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag))
+                  .granted(ctx.getWhen())
+                  .build();
           ups.add(c);
-          addLabelDelta(normName, c.getValue());
+          addLabelDelta(normName, c.value());
           oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.getValue());
+          approvals.put(normName, c.value());
           update.putReviewer(user.getAccountId(), REVIEWER);
           update.putApproval(normName, ent.getValue());
         }
@@ -1209,8 +1238,7 @@
       }
 
       forceCallerAsReviewer(projectState, ctx, current, ups, del);
-      ctx.getDb().patchSetApprovals().delete(del);
-      ctx.getDb().patchSetApprovals().upsert(ups);
+
       return !del.isEmpty() || !ups.isEmpty();
     }
 
@@ -1221,11 +1249,11 @@
         List<PatchSetApproval> ups,
         List<PatchSetApproval> del)
         throws ResourceConflictException {
-      if (ctx.getChange().getStatus().isOpen()) {
+      if (ctx.getChange().isNew()) {
         return; // Not closed, nothing to validate.
       } else if (del.isEmpty() && ups.isEmpty()) {
         return; // No new votes.
-      } else if (ctx.getChange().getStatus() != Change.Status.MERGED) {
+      } else if (!ctx.getChange().isMerged()) {
         throw new ResourceConflictException("change is closed");
       }
 
@@ -1237,7 +1265,7 @@
       List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
 
       for (PatchSetApproval psa : del) {
-        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
+        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
         if (!lt.allowPostSubmit()) {
           disallowed.add(normName);
@@ -1249,7 +1277,7 @@
       }
 
       for (PatchSetApproval psa : ups) {
-        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
+        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
         if (!lt.allowPostSubmit()) {
           disallowed.add(normName);
@@ -1258,14 +1286,11 @@
         if (prev == null) {
           continue;
         }
-        checkState(prev != psa.getValue()); // Should be filtered out above.
-        if (prev > psa.getValue()) {
+        checkState(prev != psa.value()); // Should be filtered out above.
+        if (prev > psa.value()) {
           reduced.add(psa);
-        } else {
-          // Set postSubmit bit in ReviewDb; not required for NoteDb, which sets
-          // it automatically.
-          psa.setPostSubmit(true);
         }
+        // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
       }
 
       if (!disallowed.isEmpty()) {
@@ -1276,9 +1301,8 @@
       if (!reduced.isEmpty()) {
         throw new ResourceConflictException(
             "Cannot reduce vote on labels for closed change: "
-                + reduced
-                    .stream()
-                    .map(PatchSetApproval::getLabel)
+                + reduced.stream()
+                    .map(PatchSetApproval::label)
                     .distinct()
                     .sorted()
                     .collect(joining(", ")));
@@ -1296,24 +1320,25 @@
         if (del.isEmpty()) {
           // If no existing label is being set to 0, hack in the caller
           // as a reviewer by picking the first server-wide LabelType.
-          LabelId labelId =
-              projectState
-                  .getLabelTypes(ctx.getNotes(), ctx.getUser())
-                  .getLabelTypes()
-                  .get(0)
-                  .getLabelId();
-          PatchSetApproval c = ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen());
-          c.setTag(in.tag);
-          c.setGranted(ctx.getWhen());
-          ups.add(c);
+          List<LabelType> labelTypes = projectState.getLabelTypes(ctx.getNotes()).getLabelTypes();
+          if (labelTypes.isEmpty()) {
+            logger.atWarning().log(
+                "no label type found for project %s, change %s",
+                projectState.getName(), ctx.getChange().getChangeId());
+            return;
+          }
+
+          LabelId labelId = labelTypes.get(0).getLabelId();
+          ups.add(
+              ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag))
+                  .granted(ctx.getWhen())
+                  .build());
         } else {
           // Pick a random label that is about to be deleted and keep it.
           Iterator<PatchSetApproval> i = del.iterator();
-          PatchSetApproval c = i.next();
-          c.setValue((short) 0);
-          c.setGranted(ctx.getWhen());
+          ups.add(i.next().toBuilder().value(0).granted(ctx.getWhen()).build());
           i.remove();
-          ups.add(c);
         }
       }
       ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
@@ -1321,15 +1346,13 @@
 
     private Map<String, PatchSetApproval> scanLabels(
         ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
-        throws OrmException, IOException {
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
+        throws IOException {
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
       Map<String, PatchSetApproval> current = new HashMap<>();
 
       for (PatchSetApproval a :
           approvalsUtil.byPatchSetUser(
-              ctx.getDb(),
               ctx.getNotes(),
-              ctx.getUser(),
               psId,
               user.getAccountId(),
               ctx.getRevWalk(),
@@ -1338,7 +1361,7 @@
           continue;
         }
 
-        LabelType lt = labelTypes.byLabel(a.getLabelId());
+        LabelType lt = labelTypes.byLabel(a.labelId());
         if (lt != null) {
           current.put(lt.getName(), a);
         } else {
@@ -1348,7 +1371,7 @@
       return current;
     }
 
-    private boolean insertMessage(ChangeContext ctx) throws OrmException {
+    private boolean insertMessage(ChangeContext ctx) throws CommentsRejectedException {
       String msg = Strings.nullToEmpty(in.message).trim();
 
       StringBuilder buf = new StringBuilder();
@@ -1361,6 +1384,15 @@
         buf.append(String.format("\n\n(%d comments)", comments.size()));
       }
       if (!msg.isEmpty()) {
+        ImmutableList<CommentValidationFailure> messageValidationFailure =
+            PublishCommentUtil.findInvalidComments(
+                commentValidators,
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.CHANGE_MESSAGE, msg)));
+        if (!messageValidationFailure.isEmpty()) {
+          throw new CommentsRejectedException(messageValidationFailure);
+        }
         buf.append("\n\n").append(msg);
       } else if (in.ready) {
         buf.append("\n\n" + START_REVIEW_MESSAGE);
@@ -1372,7 +1404,7 @@
       message =
           ChangeMessagesUtil.newMessage(
               psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), message);
+      cmUtil.addChangeMessage(ctx.getUpdate(psId), message);
       return true;
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 65c7db7..9f506fb 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -14,480 +14,84 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.GroupMembers;
-import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.NotifyUtil;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.RetryingRestCollectionModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.text.MessageFormat;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class PostReviewers
-    extends RetryingRestModifyView<ChangeResource, AddReviewerInput, AddReviewerResult> {
+    extends RetryingRestCollectionModifyView<
+        ChangeResource, ReviewerResource, AddReviewerInput, AddReviewerResult> {
 
-  public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
-  public static final int DEFAULT_MAX_REVIEWERS = 20;
-
-  private final AccountsCollection accounts;
-  private final PermissionBackend permissionBackend;
-
-  private final GroupsCollection groupsCollection;
-  private final GroupMembers groupMembers;
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeData.Factory changeDataFactory;
-  private final Config cfg;
-  private final ReviewerJson json;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
-  private final ProjectCache projectCache;
-  private final Provider<AnonymousUser> anonymousProvider;
-  private final PostReviewersOp.Factory postReviewersOpFactory;
-  private final OutgoingEmailValidator validator;
+  private final NotifyResolver notifyResolver;
+  private final ReviewerAdder reviewerAdder;
 
   @Inject
   PostReviewers(
-      AccountsCollection accounts,
-      PermissionBackend permissionBackend,
-      GroupsCollection groupsCollection,
-      GroupMembers groupMembers,
-      AccountLoader.Factory accountLoaderFactory,
-      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       RetryHelper retryHelper,
-      @GerritServerConfig Config cfg,
-      ReviewerJson json,
-      NotesMigration migration,
-      NotifyUtil notifyUtil,
-      ProjectCache projectCache,
-      Provider<AnonymousUser> anonymousProvider,
-      PostReviewersOp.Factory postReviewersOpFactory,
-      OutgoingEmailValidator validator) {
+      NotifyResolver notifyResolver,
+      ReviewerAdder reviewerAdder) {
     super(retryHelper);
-    this.accounts = accounts;
-    this.permissionBackend = permissionBackend;
-    this.groupsCollection = groupsCollection;
-    this.groupMembers = groupMembers;
-    this.accountLoaderFactory = accountLoaderFactory;
-    this.dbProvider = db;
     this.changeDataFactory = changeDataFactory;
-    this.cfg = cfg;
-    this.json = json;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
-    this.projectCache = projectCache;
-    this.anonymousProvider = anonymousProvider;
-    this.postReviewersOpFactory = postReviewersOpFactory;
-    this.validator = validator;
+    this.notifyResolver = notifyResolver;
+    this.reviewerAdder = reviewerAdder;
   }
 
   @Override
-  protected AddReviewerResult applyImpl(
+  protected Response<AddReviewerResult> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, AddReviewerInput input)
-      throws IOException, OrmException, RestApiException, UpdateException,
-          PermissionBackendException, ConfigInvalidException {
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          ConfigInvalidException {
     if (input.reviewer == null) {
       throw new BadRequestException("missing reviewer field");
     }
 
-    Addition addition = prepareApplication(rsrc, input, true);
+    ReviewerAddition addition = reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
     if (addition.op == null) {
-      return addition.result;
+      return Response.ok(addition.result);
     }
     try (BatchUpdate bu =
-        updateFactory.create(
-            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.setNotify(resolveNotify(rsrc, input));
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, addition.op);
       bu.execute();
-      addition.gatherResults();
     }
-    return addition.result;
+
+    // Re-read change to take into account results of the update.
+    addition.gatherResults(changeDataFactory.create(rsrc.getProject(), rsrc.getId()));
+    return Response.ok(addition.result);
   }
 
-  public Addition prepareApplication(
-      ChangeResource rsrc, AddReviewerInput input, boolean allowGroup)
-      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
-    String reviewer = input.reviewer;
-    ReviewerState state = input.state();
-    NotifyHandling notify = input.notify;
-    ListMultimap<RecipientType, Account.Id> accountsToNotify = null;
-    try {
-      accountsToNotify = notifyUtil.resolveAccounts(input.notifyDetails);
-    } catch (BadRequestException e) {
-      return fail(reviewer, e.getMessage());
+  private NotifyResolver.Result resolveNotify(ChangeResource rsrc, AddReviewerInput input)
+      throws BadRequestException, ConfigInvalidException, IOException {
+    NotifyHandling notifyHandling = input.notify;
+    if (notifyHandling == null) {
+      notifyHandling =
+          rsrc.getChange().isWorkInProgress() ? NotifyHandling.NONE : NotifyHandling.ALL;
     }
-    boolean confirmed = input.confirmed();
-    boolean allowByEmail =
-        projectCache
-            .checkedGet(rsrc.getProject())
-            .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
-
-    Addition byAccountId =
-        addByAccountId(reviewer, rsrc, state, notify, accountsToNotify, allowGroup, allowByEmail);
-
-    Addition wholeGroup = null;
-    if (byAccountId == null || !byAccountId.exactMatchFound) {
-      wholeGroup =
-          addWholeGroup(
-              reviewer, rsrc, state, notify, accountsToNotify, confirmed, allowGroup, allowByEmail);
-      if (wholeGroup != null && wholeGroup.exactMatchFound) {
-        return wholeGroup;
-      }
-    }
-
-    if (byAccountId != null) {
-      return byAccountId;
-    }
-    if (wholeGroup != null) {
-      return wholeGroup;
-    }
-
-    return addByEmail(reviewer, rsrc, state, notify, accountsToNotify);
-  }
-
-  Addition ccCurrentUser(CurrentUser user, RevisionResource revision) {
-    return new Addition(
-        user.getUserName().orElse(null),
-        revision.getChangeResource(),
-        ImmutableSet.of(user.getAccountId()),
-        null,
-        CC,
-        NotifyHandling.NONE,
-        ImmutableListMultimap.of(),
-        true);
-  }
-
-  @Nullable
-  private Addition addByAccountId(
-      String reviewer,
-      ChangeResource rsrc,
-      ReviewerState state,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
-      boolean allowGroup,
-      boolean allowByEmail)
-      throws OrmException, PermissionBackendException, IOException, ConfigInvalidException {
-    IdentifiedUser reviewerUser = null;
-    boolean exactMatchFound = false;
-    try {
-      reviewerUser = accounts.parse(reviewer);
-      if (reviewer.equalsIgnoreCase(reviewerUser.getName())
-          || reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
-        exactMatchFound = true;
-      }
-    } catch (UnprocessableEntityException | AuthException e) {
-      // AuthException won't occur since the user is authenticated at this point.
-      if (!allowGroup && !allowByEmail) {
-        // Only return failure if we aren't going to try other interpretations.
-        return fail(
-            reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
-      }
-      return null;
-    }
-
-    PermissionBackend.ForRef perm =
-        permissionBackend.absentUser(reviewerUser.getAccountId()).ref(rsrc.getChange().getDest());
-    if (isValidReviewer(reviewerUser.getAccount(), perm)) {
-      return new Addition(
-          reviewer,
-          rsrc,
-          ImmutableSet.of(reviewerUser.getAccountId()),
-          null,
-          state,
-          notify,
-          accountsToNotify,
-          exactMatchFound);
-    }
-    if (!reviewerUser.getAccount().isActive()) {
-      if (allowByEmail && state == CC) {
-        return null;
-      }
-      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInactive, reviewer));
-    }
-    return fail(
-        reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
-  }
-
-  @Nullable
-  private Addition addWholeGroup(
-      String reviewer,
-      ChangeResource rsrc,
-      ReviewerState state,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
-      boolean confirmed,
-      boolean allowGroup,
-      boolean allowByEmail)
-      throws IOException, PermissionBackendException {
-    if (!allowGroup) {
-      return null;
-    }
-
-    GroupDescription.Basic group = null;
-    try {
-      group = groupsCollection.parseInternal(reviewer);
-    } catch (UnprocessableEntityException e) {
-      if (!allowByEmail) {
-        return fail(
-            reviewer,
-            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, reviewer));
-      }
-      return null;
-    }
-
-    if (!isLegalReviewerGroup(group.getGroupUUID())) {
-      return fail(
-          reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
-    }
-
-    Set<Account.Id> reviewers = new HashSet<>();
-    Set<Account> members;
-    try {
-      members = groupMembers.listAccounts(group.getGroupUUID(), rsrc.getProject());
-    } catch (NoSuchProjectException e) {
-      return fail(reviewer, e.getMessage());
-    }
-
-    // if maxAllowed is set to 0, it is allowed to add any number of
-    // reviewers
-    int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
-    if (maxAllowed > 0 && members.size() > maxAllowed) {
-      return fail(
-          reviewer,
-          MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
-    }
-
-    // if maxWithoutCheck is set to 0, we never ask for confirmation
-    int maxWithoutConfirmation =
-        cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
-    if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
-      return fail(
-          reviewer,
-          true,
-          MessageFormat.format(
-              ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
-    }
-
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(rsrc.getUser()).ref(rsrc.getChange().getDest());
-    for (Account member : members) {
-      if (isValidReviewer(member, perm)) {
-        reviewers.add(member.getId());
-      }
-    }
-
-    return new Addition(reviewer, rsrc, reviewers, null, state, notify, accountsToNotify, true);
-  }
-
-  @Nullable
-  private Addition addByEmail(
-      String reviewer,
-      ChangeResource rsrc,
-      ReviewerState state,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
-      throws PermissionBackendException {
-    if (!permissionBackend
-        .user(anonymousProvider.get())
-        .change(rsrc.getNotes())
-        .database(dbProvider)
-        .test(ChangePermission.READ)) {
-      return fail(
-          reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
-    }
-    if (!migration.readChanges()) {
-      // addByEmail depends on NoteDb.
-      return fail(
-          reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
-    }
-    Address adr = Address.tryParse(reviewer);
-    if (adr == null || !validator.isValid(adr.getEmail())) {
-      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInvalid, reviewer));
-    }
-    return new Addition(
-        reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify, true);
-  }
-
-  private boolean isValidReviewer(Account member, PermissionBackend.ForRef perm)
-      throws PermissionBackendException {
-    if (!member.isActive()) {
-      return false;
-    }
-
-    // Does not account for draft status as a user might want to let a
-    // reviewer see a draft.
-    try {
-      perm.absentUser(member.getId()).check(RefPermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
-
-  private Addition fail(String reviewer, String error) {
-    return fail(reviewer, false, error);
-  }
-
-  private Addition fail(String reviewer, boolean confirm, String error) {
-    Addition addition = new Addition(reviewer);
-    addition.result.confirm = confirm ? true : null;
-    addition.result.error = error;
-    return addition;
-  }
-
-  public class Addition {
-    final AddReviewerResult result;
-    final PostReviewersOp op;
-    final Set<Account.Id> reviewers;
-    final Collection<Address> reviewersByEmail;
-    final ReviewerState state;
-    final ChangeNotes notes;
-    final IdentifiedUser caller;
-    final boolean exactMatchFound;
-
-    Addition(String reviewer) {
-      result = new AddReviewerResult(reviewer);
-      op = null;
-      reviewers = ImmutableSet.of();
-      reviewersByEmail = ImmutableSet.of();
-      state = REVIEWER;
-      notes = null;
-      caller = null;
-      exactMatchFound = false;
-    }
-
-    protected Addition(
-        String reviewer,
-        ChangeResource rsrc,
-        @Nullable Set<Account.Id> reviewers,
-        @Nullable Collection<Address> reviewersByEmail,
-        ReviewerState state,
-        @Nullable NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify,
-        boolean exactMatchFound) {
-      checkArgument(
-          reviewers != null || reviewersByEmail != null,
-          "must have either reviewers or reviewersByEmail");
-
-      result = new AddReviewerResult(reviewer);
-      this.reviewers = reviewers == null ? ImmutableSet.of() : reviewers;
-      this.reviewersByEmail = reviewersByEmail == null ? ImmutableList.of() : reviewersByEmail;
-      this.state = state;
-      notes = rsrc.getNotes();
-      caller = rsrc.getUser().asIdentifiedUser();
-      op =
-          postReviewersOpFactory.create(
-              rsrc, this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
-      this.exactMatchFound = exactMatchFound;
-    }
-
-    void gatherResults() throws OrmException, PermissionBackendException {
-      if (notes == null || caller == null) {
-        // When notes or caller is missing this is likely just carrying an error message
-        // in the contained AddReviewerResult.
-        return;
-      }
-
-      ChangeData cd = changeDataFactory.create(dbProvider.get(), notes);
-      PermissionBackend.ForChange perm =
-          permissionBackend.user(caller).database(dbProvider).change(cd);
-
-      // Generate result details and fill AccountLoader. This occurs outside
-      // the Op because the accounts are in a different table.
-      PostReviewersOp.Result opResult = op.getResult();
-      if (migration.readChanges() && state == CC) {
-        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
-        for (Account.Id accountId : opResult.addedCCs()) {
-          result.ccs.add(
-              json.format(new ReviewerInfo(accountId.get()), perm.absentUser(accountId), cd));
-        }
-        accountLoaderFactory.create(true).fill(result.ccs);
-        for (Address a : reviewersByEmail) {
-          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
-        }
-      } else {
-        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
-        for (PatchSetApproval psa : opResult.addedReviewers()) {
-          // New reviewers have value 0, don't bother normalizing.
-          result.reviewers.add(
-              json.format(
-                  new ReviewerInfo(psa.getAccountId().get()),
-                  perm.absentUser(psa.getAccountId()),
-                  cd,
-                  ImmutableList.of(psa)));
-        }
-        accountLoaderFactory.create(true).fill(result.reviewers);
-        for (Address a : reviewersByEmail) {
-          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
-        }
-      }
-    }
-  }
-
-  public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
-    return !SystemGroupBackend.isSystemGroup(groupUUID);
+    return notifyResolver.resolve(notifyHandling, input.notifyDetails);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
deleted file mode 100644
index 0502e91..0000000
--- a/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
+++ /dev/null
@@ -1,273 +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.server.restapi.change;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Streams;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-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.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.extensions.events.ReviewerAdded;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-
-public class PostReviewersOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface Factory {
-    PostReviewersOp create(
-        ChangeResource rsrc,
-        Set<Account.Id> reviewers,
-        Collection<Address> reviewersByEmail,
-        ReviewerState state,
-        @Nullable NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify);
-  }
-
-  @AutoValue
-  public abstract static class Result {
-    public abstract ImmutableList<PatchSetApproval> addedReviewers();
-
-    public abstract ImmutableList<Account.Id> addedCCs();
-
-    static Builder builder() {
-      return new AutoValue_PostReviewersOp_Result.Builder();
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder setAddedReviewers(ImmutableList<PatchSetApproval> addedReviewers);
-
-      abstract Builder setAddedCCs(ImmutableList<Account.Id> addedCCs);
-
-      abstract Result build();
-    }
-  }
-
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ReviewerAdded reviewerAdded;
-  private final AccountCache accountCache;
-  private final ProjectCache projectCache;
-  private final AddReviewerSender.Factory addReviewerSenderFactory;
-  private final NotesMigration migration;
-  private final Provider<IdentifiedUser> user;
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeResource rsrc;
-  private final Set<Account.Id> reviewers;
-  private final Collection<Address> reviewersByEmail;
-  private final ReviewerState state;
-  private final NotifyHandling notify;
-  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-
-  private List<PatchSetApproval> addedReviewers = new ArrayList<>();
-  private Collection<Account.Id> addedCCs = new ArrayList<>();
-  private Collection<Address> addedCCsByEmail = new ArrayList<>();
-  private PatchSet patchSet;
-  private Result opResult;
-
-  @Inject
-  PostReviewersOp(
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ReviewerAdded reviewerAdded,
-      AccountCache accountCache,
-      ProjectCache projectCache,
-      AddReviewerSender.Factory addReviewerSenderFactory,
-      NotesMigration migration,
-      Provider<IdentifiedUser> user,
-      Provider<ReviewDb> dbProvider,
-      @Assisted ChangeResource rsrc,
-      @Assisted Set<Account.Id> reviewers,
-      @Assisted Collection<Address> reviewersByEmail,
-      @Assisted ReviewerState state,
-      @Assisted @Nullable NotifyHandling notify,
-      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.reviewerAdded = reviewerAdded;
-    this.accountCache = accountCache;
-    this.projectCache = projectCache;
-    this.addReviewerSenderFactory = addReviewerSenderFactory;
-    this.migration = migration;
-    this.user = user;
-    this.dbProvider = dbProvider;
-
-    this.rsrc = rsrc;
-    this.reviewers = reviewers;
-    this.reviewersByEmail = reviewersByEmail;
-    this.state = state;
-    this.notify = notify;
-    this.accountsToNotify = accountsToNotify;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException {
-    if (!reviewers.isEmpty()) {
-      if (migration.readChanges() && state == CC) {
-        addedCCs =
-            approvalsUtil.addCcs(
-                ctx.getNotes(), ctx.getUpdate(ctx.getChange().currentPatchSetId()), reviewers);
-        if (addedCCs.isEmpty()) {
-          return false;
-        }
-      } else {
-        addedReviewers =
-            approvalsUtil.addReviewers(
-                ctx.getDb(),
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                projectCache
-                    .checkedGet(rsrc.getProject())
-                    .getLabelTypes(rsrc.getChange().getDest(), ctx.getUser()),
-                rsrc.getChange(),
-                reviewers);
-        if (addedReviewers.isEmpty()) {
-          return false;
-        }
-      }
-    }
-
-    for (Address a : reviewersByEmail) {
-      ctx.getUpdate(ctx.getChange().currentPatchSetId())
-          .putReviewerByEmail(a, ReviewerStateInternal.fromReviewerState(state));
-    }
-
-    patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) throws Exception {
-    opResult =
-        Result.builder()
-            .setAddedReviewers(ImmutableList.copyOf(addedReviewers))
-            .setAddedCCs(ImmutableList.copyOf(addedCCs))
-            .build();
-    emailReviewers(
-        rsrc.getChange(),
-        Lists.transform(addedReviewers, PatchSetApproval::getAccountId),
-        addedCCs == null ? ImmutableList.of() : addedCCs,
-        reviewersByEmail,
-        addedCCsByEmail,
-        notify,
-        accountsToNotify,
-        !rsrc.getChange().isWorkInProgress());
-    if (!addedReviewers.isEmpty()) {
-      List<AccountState> reviewers =
-          addedReviewers
-              .stream()
-              .map(r -> accountCache.get(r.getAccountId()))
-              .flatMap(Streams::stream)
-              .collect(toList());
-      reviewerAdded.fire(rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
-    }
-  }
-
-  public void emailReviewers(
-      Change change,
-      Collection<Account.Id> added,
-      Collection<Account.Id> copied,
-      Collection<Address> addedByEmail,
-      Collection<Address> copiedByEmail,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
-      boolean readyForReview) {
-    if (added.isEmpty() && copied.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
-      return;
-    }
-
-    // Email the reviewers
-    //
-    // The user knows they added themselves, don't bother emailing them.
-    List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
-    Account.Id userId = user.get().getAccountId();
-    for (Account.Id id : added) {
-      if (!id.equals(userId)) {
-        toMail.add(id);
-      }
-    }
-    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
-    for (Account.Id id : copied) {
-      if (!id.equals(userId)) {
-        toCopy.add(id);
-      }
-    }
-    if (toMail.isEmpty() && toCopy.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
-      return;
-    }
-
-    try {
-      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
-      // Default to silent operation on WIP changes.
-      NotifyHandling defaultNotifyHandling =
-          readyForReview ? NotifyHandling.ALL : NotifyHandling.NONE;
-      cm.setNotify(MoreObjects.firstNonNull(notify, defaultNotifyHandling));
-      cm.setAccountsToNotify(accountsToNotify);
-      cm.setFrom(userId);
-      cm.addReviewers(toMail);
-      cm.addReviewersByEmail(addedByEmail);
-      cm.addExtraCC(toCopy);
-      cm.addExtraCCByEmail(copiedByEmail);
-      cm.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot send email to new reviewers of change %s", change.getId());
-    }
-  }
-
-  public Result getResult() {
-    checkState(opResult != null, "Batch update wasn't executed yet");
-    return opResult;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
index 18e86d1..cbea2a5 100644
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
@@ -21,12 +21,12 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+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.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.ArchiveFormat;
@@ -40,7 +40,6 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -61,7 +60,6 @@
 public class PreviewSubmit implements RestReadView<RevisionResource> {
   private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
 
-  private final Provider<ReviewDb> dbProvider;
   private final Provider<MergeOp> mergeOpProvider;
   private final AllowedFormats allowedFormats;
   private int maxBundleSize;
@@ -74,19 +72,17 @@
 
   @Inject
   PreviewSubmit(
-      Provider<ReviewDb> dbProvider,
       Provider<MergeOp> mergeOpProvider,
       AllowedFormats allowedFormats,
       @GerritServerConfig Config cfg) {
-    this.dbProvider = dbProvider;
     this.mergeOpProvider = mergeOpProvider;
     this.allowedFormats = allowedFormats;
     this.maxBundleSize = cfg.getInt("download", "maxBundleSize", MAX_DEFAULT_BUNDLE_SIZE);
   }
 
   @Override
-  public BinaryResult apply(RevisionResource rsrc)
-      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+  public Response<BinaryResult> apply(RevisionResource rsrc)
+      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (Strings.isNullOrEmpty(format)) {
       throw new BadRequestException("format is not specified");
@@ -103,34 +99,32 @@
     }
 
     Change change = rsrc.getChange();
-    if (!change.getStatus().isOpen()) {
+    if (!change.isNew()) {
       throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
     }
     if (!rsrc.getUser().isIdentifiedUser()) {
       throw new MethodNotAllowedException("Anonymous users cannot submit");
     }
 
-    return getBundles(rsrc, f);
+    return Response.ok(getBundles(rsrc, f));
   }
 
   private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormat f)
-      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
           PermissionBackendException {
-    ReviewDb db = dbProvider.get();
     IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
     Change change = rsrc.getChange();
 
     @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
     MergeOp op = mergeOpProvider.get();
     try {
-      op.merge(db, change, caller, false, new SubmitInput(), true);
+      op.merge(change, caller, false, new SubmitInput(), true);
       BinaryResult bin = new SubmitPreviewResult(op, f, maxBundleSize);
       bin.disableGzip()
           .setContentType(f.getMimeType())
           .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
       return bin;
-    } catch (OrmException
-        | RestApiException
+    } catch (RestApiException
         | UpdateException
         | IOException
         | ConfigInvalidException
diff --git a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
index b356f18..44f35a0 100644
--- a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
@@ -14,19 +14,15 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.change.ChangeEditResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
@@ -35,7 +31,6 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -44,77 +39,43 @@
 
 @Singleton
 public class PublishChangeEdit
-    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
-
-  private final Publish publish;
+    extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Object> {
+  private final ChangeEditUtil editUtil;
+  private final NotifyResolver notifyResolver;
+  private final ContributorAgreementsChecker contributorAgreementsChecker;
 
   @Inject
-  PublishChangeEdit(Publish publish) {
-    this.publish = publish;
+  PublishChangeEdit(
+      RetryHelper retryHelper,
+      ChangeEditUtil editUtil,
+      NotifyResolver notifyResolver,
+      ContributorAgreementsChecker contributorAgreementsChecker) {
+    super(retryHelper);
+    this.editUtil = editUtil;
+    this.notifyResolver = notifyResolver;
+    this.contributorAgreementsChecker = contributorAgreementsChecker;
   }
 
   @Override
-  public DynamicMap<RestView<ChangeEditResource>> views() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public RestView<ChangeResource> list() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public ChangeEditResource parse(ChangeResource parent, IdString id) {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public Publish post(ChangeResource parent) throws RestApiException {
-    return publish;
-  }
-
-  @Singleton
-  public static class Publish
-      extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Response<?>> {
-
-    private final ChangeEditUtil editUtil;
-    private final NotifyUtil notifyUtil;
-    private final ContributorAgreementsChecker contributorAgreementsChecker;
-
-    @Inject
-    Publish(
-        RetryHelper retryHelper,
-        ChangeEditUtil editUtil,
-        NotifyUtil notifyUtil,
-        ContributorAgreementsChecker contributorAgreementsChecker) {
-      super(retryHelper);
-      this.editUtil = editUtil;
-      this.notifyUtil = notifyUtil;
-      this.contributorAgreementsChecker = contributorAgreementsChecker;
+  protected Response<Object> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
+      throws IOException, RestApiException, UpdateException, ConfigInvalidException,
+          NoSuchProjectException {
+    contributorAgreementsChecker.check(rsrc.getProject(), rsrc.getUser());
+    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
+    if (!edit.isPresent()) {
+      throw new ResourceConflictException(
+          String.format("no edit exists for change %s", rsrc.getChange().getChangeId()));
     }
-
-    @Override
-    protected Response<?> applyImpl(
-        BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
-        throws IOException, OrmException, RestApiException, UpdateException, ConfigInvalidException,
-            NoSuchProjectException {
-      contributorAgreementsChecker.check(rsrc.getProject(), rsrc.getUser());
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
-      if (!edit.isPresent()) {
-        throw new ResourceConflictException(
-            String.format("no edit exists for change %s", rsrc.getChange().getChangeId()));
-      }
-      if (in == null) {
-        in = new PublishChangeEditInput();
-      }
-      editUtil.publish(
-          updateFactory,
-          rsrc.getNotes(),
-          rsrc.getUser(),
-          edit.get(),
-          in.notify,
-          notifyUtil.resolveAccounts(in.notifyDetails));
-      return Response.none();
+    if (in == null) {
+      in = new PublishChangeEditInput();
     }
+    editUtil.publish(
+        updateFactory,
+        rsrc.getNotes(),
+        rsrc.getUser(),
+        edit.get(),
+        notifyResolver.resolve(firstNonNull(in.notify, NotifyHandling.ALL), in.notifyDetails));
+    return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index b6fc010..21e2e4f 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -23,25 +22,25 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.SetAssigneeOp;
 import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
-import com.google.gerrit.server.restapi.change.PostReviewers.Addition;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 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;
@@ -50,33 +49,33 @@
 public class PutAssignee extends RetryingRestModifyView<ChangeResource, AssigneeInput, AccountInfo>
     implements UiAction<ChangeResource> {
 
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final SetAssigneeOp.Factory assigneeFactory;
-  private final Provider<ReviewDb> db;
-  private final PostReviewers postReviewers;
+  private final ReviewerAdder reviewerAdder;
   private final AccountLoader.Factory accountLoaderFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   PutAssignee(
-      AccountsCollection accounts,
+      AccountResolver accountResolver,
       SetAssigneeOp.Factory assigneeFactory,
       RetryHelper retryHelper,
-      Provider<ReviewDb> db,
-      PostReviewers postReviewers,
-      AccountLoader.Factory accountLoaderFactory) {
+      ReviewerAdder reviewerAdder,
+      AccountLoader.Factory accountLoaderFactory,
+      PermissionBackend permissionBackend) {
     super(retryHelper);
-    this.accounts = accounts;
+    this.accountResolver = accountResolver;
     this.assigneeFactory = assigneeFactory;
-    this.db = db;
-    this.postReviewers = postReviewers;
+    this.reviewerAdder = reviewerAdder;
     this.accountLoaderFactory = accountLoaderFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  protected AccountInfo applyImpl(
+  protected Response<AccountInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, AssigneeInput input)
-      throws RestApiException, UpdateException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException {
+      throws RestApiException, UpdateException, IOException, PermissionBackendException,
+          ConfigInvalidException {
     rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
 
     input.assignee = Strings.nullToEmpty(input.assignee).trim();
@@ -84,38 +83,38 @@
       throw new BadRequestException("missing assignee field");
     }
 
-    IdentifiedUser assignee = accounts.parse(input.assignee);
-    if (!assignee.getAccount().isActive()) {
-      throw new UnprocessableEntityException(input.assignee + " is not active");
-    }
+    IdentifiedUser assignee = accountResolver.resolve(input.assignee).asUniqueUser();
     try {
-      rsrc.permissions().database(db).user(assignee).check(ChangePermission.READ);
+      permissionBackend
+          .absentUser(assignee.getAccountId())
+          .change(rsrc.getNotes())
+          .check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new AuthException("read not permitted for " + input.assignee);
     }
 
     try (BatchUpdate bu =
-        updateFactory.create(
-            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       SetAssigneeOp op = assigneeFactory.create(assignee);
       bu.addOp(rsrc.getId(), op);
 
-      PostReviewers.Addition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
+      ReviewerAddition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
+      reviewersAddition.op.suppressEmail();
       bu.addOp(rsrc.getId(), reviewersAddition.op);
 
       bu.execute();
-      return accountLoaderFactory.create(true).fillOne(assignee.getAccountId());
+      return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.getAccountId()));
     }
   }
 
-  private Addition addAssigneeAsCC(ChangeResource rsrc, String assignee)
-      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
+  private ReviewerAddition addAssigneeAsCC(ChangeResource rsrc, String assignee)
+      throws IOException, PermissionBackendException, ConfigInvalidException {
     AddReviewerInput reviewerInput = new AddReviewerInput();
     reviewerInput.reviewer = assignee;
     reviewerInput.state = ReviewerState.CC;
     reviewerInput.confirmed = true;
     reviewerInput.notify = NotifyHandling.NONE;
-    return postReviewers.prepareApplication(rsrc, reviewerInput, false);
+    return reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), reviewerInput, false);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index 38fc2e2..279d5de 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -15,14 +15,12 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -35,28 +33,20 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.Collections;
 
 @Singleton
 public class PutDescription
-    extends RetryingRestModifyView<RevisionResource, DescriptionInput, Response<String>>
+    extends RetryingRestModifyView<RevisionResource, DescriptionInput, String>
     implements UiAction<RevisionResource> {
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
 
   @Inject
-  PutDescription(
-      Provider<ReviewDb> dbProvider,
-      ChangeMessagesUtil cmUtil,
-      RetryHelper retryHelper,
-      PatchSetUtil psUtil) {
+  PutDescription(ChangeMessagesUtil cmUtil, RetryHelper retryHelper, PatchSetUtil psUtil) {
     super(retryHelper);
-    this.dbProvider = dbProvider;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
   }
@@ -67,10 +57,9 @@
       throws UpdateException, RestApiException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
 
-    Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().getId());
+    Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().id());
     try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       u.addOp(rsrc.getChange().getId(), op);
       u.execute();
     }
@@ -92,11 +81,10 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException {
-      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+    public boolean updateChange(ChangeContext ctx) {
       ChangeUpdate update = ctx.getUpdate(psId);
       newDescription = Strings.nullToEmpty(input.description);
-      oldDescription = Strings.nullToEmpty(ps.getDescription());
+      oldDescription = psUtil.get(ctx.getNotes(), psId).description().orElse("");
       if (oldDescription.equals(newDescription)) {
         return false;
       }
@@ -109,15 +97,12 @@
         summary = "Description changed to \"" + newDescription + "\"";
       }
 
-      ps.setDescription(newDescription);
       update.setPsDescription(newDescription);
 
-      ctx.getDb().patchSets().update(Collections.singleton(ps));
-
       ChangeMessage cmsg =
           ChangeMessagesUtil.newMessage(
               psId, ctx.getUser(), ctx.getWhen(), summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
-      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+      cmUtil.addChangeMessage(update, cmsg);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index e6ede34..4b5386e 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -27,20 +26,20 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -50,9 +49,8 @@
 
 @Singleton
 public class PutDraftComment
-    extends RetryingRestModifyView<DraftCommentResource, DraftInput, Response<CommentInfo>> {
+    extends RetryingRestModifyView<DraftCommentResource, DraftInput, CommentInfo> {
 
-  private final Provider<ReviewDb> db;
   private final DeleteDraftComment delete;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
@@ -61,7 +59,6 @@
 
   @Inject
   PutDraftComment(
-      Provider<ReviewDb> db,
       DeleteDraftComment delete,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
@@ -69,7 +66,6 @@
       Provider<CommentJson> commentJson,
       PatchListCache patchListCache) {
     super(retryHelper);
-    this.db = db;
     this.delete = delete;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
@@ -80,7 +76,7 @@
   @Override
   protected Response<CommentInfo> applyImpl(
       BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, DraftInput in)
-      throws RestApiException, UpdateException, OrmException {
+      throws RestApiException, UpdateException, PermissionBackendException {
     if (in == null || in.message == null || in.message.trim().isEmpty()) {
       return delete.applyImpl(updateFactory, rsrc, null);
     } else if (in.id != null && !rsrc.getId().equals(in.id)) {
@@ -92,8 +88,7 @@
     }
 
     try (BatchUpdate bu =
-        updateFactory.create(
-            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op(rsrc.getComment().key, in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -115,9 +110,9 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, OrmException, PatchListNotAvailableException {
+        throws ResourceNotFoundException, PatchListNotAvailableException {
       Optional<Comment> maybeComment =
-          commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
+          commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         // Disappeared out from under us. Can't easily fall back to insert,
         // because the input might be missing required fields. Just give up.
@@ -129,10 +124,10 @@
       // user.
       ctx.getUser().updateRealAccountId(comment::setRealAuthor);
 
-      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), origComment.key.patchSetId);
+      PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), origComment.key.patchSetId);
       ChangeUpdate update = ctx.getUpdate(psId);
 
-      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      PatchSet ps = psUtil.get(ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
@@ -140,16 +135,12 @@
         // Updating the path alters the primary key, which isn't possible.
         // Delete then recreate the comment instead of an update.
 
-        commentsUtil.deleteComments(ctx.getDb(), update, Collections.singleton(origComment));
+        commentsUtil.deleteComments(update, Collections.singleton(origComment));
         comment.key.filename = in.path;
       }
-      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
       commentsUtil.putComments(
-          ctx.getDb(),
-          update,
-          Status.DRAFT,
-          Collections.singleton(update(comment, in, ctx.getWhen())));
-      ctx.dontBumpLastUpdatedOn();
+          update, Status.DRAFT, Collections.singleton(update(comment, in, ctx.getWhen())));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index eb46521..36a073f 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -25,13 +24,12 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -44,7 +42,7 @@
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.CommitMessageUtil;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -63,17 +61,15 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
-public class PutMessage
-    extends RetryingRestModifyView<ChangeResource, CommitMessageInput, Response<?>> {
+public class PutMessage extends RetryingRestModifyView<ChangeResource, CommitMessageInput, String> {
 
   private final GitRepositoryManager repositoryManager;
   private final Provider<CurrentUser> userProvider;
-  private final Provider<ReviewDb> db;
   private final TimeZone tz;
   private final PatchSetInserter.Factory psInserterFactory;
   private final PermissionBackend permissionBackend;
   private final PatchSetUtil psUtil;
-  private final NotifyUtil notifyUtil;
+  private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
 
   @Inject
@@ -81,22 +77,20 @@
       RetryHelper retryHelper,
       GitRepositoryManager repositoryManager,
       Provider<CurrentUser> userProvider,
-      Provider<ReviewDb> db,
       PatchSetInserter.Factory psInserterFactory,
       PermissionBackend permissionBackend,
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
-      NotifyUtil notifyUtil,
+      NotifyResolver notifyResolver,
       ProjectCache projectCache) {
     super(retryHelper);
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
-    this.db = db;
     this.psInserterFactory = psInserterFactory;
     this.tz = gerritIdent.getTimeZone();
     this.permissionBackend = permissionBackend;
     this.psUtil = psUtil;
-    this.notifyUtil = notifyUtil;
+    this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
   }
 
@@ -104,8 +98,8 @@
   protected Response<String> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource resource, CommitMessageInput input)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
-          OrmException, ConfigInvalidException {
-    PatchSet ps = psUtil.current(db.get(), resource.getNotes());
+          ConfigInvalidException {
+    PatchSet ps = psUtil.current(resource.getNotes());
     if (ps == null) {
       throw new ResourceConflictException("current revision is missing");
     }
@@ -116,20 +110,18 @@
     String sanitizedCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(input.message);
 
     ensureCanEditCommitMessage(resource.getNotes());
-    ensureChangeIdIsCorrect(
-        projectCache.checkedGet(resource.getProject()).is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
-        resource.getChange().getKey().get(),
-        sanitizedCommitMessage);
-
-    NotifyHandling notify = input.notify;
-    if (notify == null) {
-      notify = resource.getChange().isWorkInProgress() ? NotifyHandling.OWNER : NotifyHandling.ALL;
-    }
+    sanitizedCommitMessage =
+        ensureChangeIdIsCorrect(
+            projectCache
+                .checkedGet(resource.getProject())
+                .is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
+            resource.getChange().getKey().get(),
+            sanitizedCommitMessage);
 
     try (Repository repository = repositoryManager.openRepository(resource.getProject());
         RevWalk revWalk = new RevWalk(repository);
         ObjectInserter objectInserter = repository.newObjectInserter()) {
-      RevCommit patchSetCommit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      RevCommit patchSetCommit = revWalk.parseCommit(ps.commitId());
 
       String currentCommitMessage = patchSetCommit.getFullMessage();
       if (input.message.equals(currentCommitMessage)) {
@@ -138,20 +130,18 @@
 
       Timestamp ts = TimeUtil.nowTs();
       try (BatchUpdate bu =
-          updateFactory.create(
-              db.get(), resource.getChange().getProject(), userProvider.get(), ts)) {
+          updateFactory.create(resource.getChange().getProject(), userProvider.get(), ts)) {
         // Ensure that BatchUpdate will update the same repo
         bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
 
-        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.getId());
+        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.id());
         ObjectId newCommit =
             createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
         PatchSetInserter inserter = psInserterFactory.create(resource.getNotes(), psId, newCommit);
         inserter.setMessage(
             String.format("Patch Set %s: Commit message was updated.", psId.getId()));
         inserter.setDescription("Edit commit message");
-        inserter.setNotify(notify);
-        inserter.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+        bu.setNotify(resolveNotify(input, resource));
         bu.addOp(resource.getChange().getId(), inserter);
         bu.execute();
       }
@@ -159,6 +149,16 @@
     return Response.ok("ok");
   }
 
+  private NotifyResolver.Result resolveNotify(CommitMessageInput input, ChangeResource resource)
+      throws BadRequestException, ConfigInvalidException, IOException {
+    NotifyHandling notifyHandling = input.notify;
+    if (notifyHandling == null) {
+      notifyHandling =
+          resource.getChange().isWorkInProgress() ? NotifyHandling.OWNER : NotifyHandling.ALL;
+    }
+    return notifyResolver.resolve(notifyHandling, input.notifyDetails);
+  }
+
   private ObjectId createCommit(
       ObjectInserter objectInserter,
       RevCommit basePatchSetCommit,
@@ -177,18 +177,16 @@
   }
 
   private void ensureCanEditCommitMessage(ChangeNotes changeNotes)
-      throws AuthException, PermissionBackendException, IOException, ResourceConflictException,
-          OrmException {
+      throws AuthException, PermissionBackendException, IOException, ResourceConflictException {
     if (!userProvider.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
 
     // Not allowed to put message if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(changeNotes, userProvider.get());
+    psUtil.checkPatchSetNotLocked(changeNotes);
     try {
       permissionBackend
           .user(userProvider.get())
-          .database(db.get())
           .change(changeNotes)
           .check(ChangePermission.ADD_PATCH_SET);
       projectCache.checkedGet(changeNotes.getProjectName()).checkStatePermitsWrite();
@@ -197,7 +195,7 @@
     }
   }
 
-  private static void ensureChangeIdIsCorrect(
+  private static String ensureChangeIdIsCorrect(
       boolean requireChangeId, String currentChangeId, String newCommitMessage)
       throws ResourceConflictException, BadRequestException {
     RevCommit revCommit =
@@ -208,14 +206,21 @@
     CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
 
     List<String> changeIdFooters = revCommit.getFooterLines(FooterConstants.CHANGE_ID);
-    if (requireChangeId && changeIdFooters.isEmpty()) {
-      throw new ResourceConflictException("missing Change-Id footer");
-    }
     if (!changeIdFooters.isEmpty() && !changeIdFooters.get(0).equals(currentChangeId)) {
       throw new ResourceConflictException("wrong Change-Id footer");
     }
-    if (changeIdFooters.size() > 1) {
+
+    if (requireChangeId && revCommit.getFooterLines().isEmpty()) {
+      // sanitization always adds '\n' at the end.
+      newCommitMessage += "\n";
+    }
+
+    if (requireChangeId && changeIdFooters.isEmpty()) {
+      newCommitMessage += FooterConstants.CHANGE_ID.getName() + ": " + currentChangeId + "\n";
+    } else if (changeIdFooters.size() > 1) {
       throw new ResourceConflictException("multiple Change-Id footers");
     }
+
+    return newCommitMessage;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 45a837a..cfc4f9e 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -23,7 +22,6 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
@@ -38,26 +36,19 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutTopic extends RetryingRestModifyView<ChangeResource, TopicInput, Response<String>>
+public class PutTopic extends RetryingRestModifyView<ChangeResource, TopicInput, String>
     implements UiAction<ChangeResource> {
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
   private final TopicEdited topicEdited;
 
   @Inject
-  PutTopic(
-      Provider<ReviewDb> dbProvider,
-      ChangeMessagesUtil cmUtil,
-      RetryHelper retryHelper,
-      TopicEdited topicEdited) {
+  PutTopic(ChangeMessagesUtil cmUtil, RetryHelper retryHelper, TopicEdited topicEdited) {
     super(retryHelper);
-    this.dbProvider = dbProvider;
     this.cmUtil = cmUtil;
     this.topicEdited = topicEdited;
   }
@@ -82,8 +73,7 @@
 
     Op op = new Op(sanitizedInput);
     try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
       u.execute();
     }
@@ -102,7 +92,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException {
+    public boolean updateChange(ChangeContext ctx) {
       change = ctx.getChange();
       ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
       newTopicName = Strings.nullToEmpty(input.topic);
@@ -123,7 +113,7 @@
 
       ChangeMessage cmsg =
           ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
-      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+      cmUtil.addChangeMessage(update, cmsg);
       return true;
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 1c9e420..50e1e42 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -14,24 +14,25 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -39,7 +40,7 @@
 import java.util.List;
 import org.kohsuke.args4j.Option;
 
-public class QueryChanges implements RestReadView<TopLevelResource> {
+public class QueryChanges implements RestReadView<TopLevelResource>, DynamicOptions.BeanReceiver {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangeJson.Factory json;
@@ -70,7 +71,7 @@
 
   @Option(name = "-O", usage = "Output option flags, in hex")
   void setOptionFlagsHex(String hex) {
-    options.addAll(ListChangesOption.fromBits(Integer.parseInt(hex, 16)));
+    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
   }
 
   @Option(
@@ -82,6 +83,16 @@
     imp.setStart(start);
   }
 
+  @Option(name = "--no-limit", usage = "Return all results, overriding the default limit")
+  public void setNoLimit(boolean on) {
+    imp.setNoLimit(on);
+  }
+
+  @Override
+  public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+    imp.setDynamicBean(plugin, dynamicBean);
+  }
+
   @Inject
   QueryChanges(ChangeJson.Factory json, ChangeQueryBuilder qb, ChangeQueryProcessor qp) {
     this.json = json;
@@ -103,8 +114,8 @@
   }
 
   @Override
-  public List<?> apply(TopLevelResource rsrc)
-      throws BadRequestException, AuthException, OrmException {
+  public Response<List<?>> apply(TopLevelResource rsrc)
+      throws BadRequestException, AuthException, PermissionBackendException {
     List<List<ChangeInfo>> out;
     try {
       out = query();
@@ -114,10 +125,10 @@
       logger.atFine().withCause(e).log("Reject change query with 400 Bad Request: %s", queries);
       throw new BadRequestException(e.getMessage(), e);
     }
-    return out.size() == 1 ? out.get(0) : out;
+    return Response.ok(out.size() == 1 ? out.get(0) : out);
   }
 
-  private List<List<ChangeInfo>> query() throws OrmException, QueryParseException {
+  private List<List<ChangeInfo>> query() throws QueryParseException, PermissionBackendException {
     if (imp.isDisabled()) {
       throw new QueryParseException("query disabled");
     }
@@ -131,14 +142,8 @@
 
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
-
-    ChangeJson cjson = json.create(options);
-    cjson.setPluginDefinedAttributesFactory(this.imp);
     List<List<ChangeInfo>> res =
-        cjson
-            .lazyLoad(containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
-            .formatQueryResults(results);
-
+        json.create(options, this.imp.getAttributesFactory()).format(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more() && !info.isEmpty()) {
@@ -147,9 +152,4 @@
     }
     return res;
   }
-
-  private static boolean containsAnyOf(
-      EnumSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
-    return !Sets.intersection(toFind, set).isEmpty();
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 99a755ae..35152e5 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -17,24 +17,24 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
@@ -49,9 +49,8 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -74,7 +73,6 @@
   private final RebaseChangeOp.Factory rebaseFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeJson.Factory json;
-  private final Provider<ReviewDb> dbProvider;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final PatchSetUtil patchSetUtil;
@@ -86,7 +84,6 @@
       RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
-      Provider<ReviewDb> dbProvider,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       PatchSetUtil patchSetUtil) {
@@ -95,21 +92,19 @@
     this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
     this.json = json;
-    this.dbProvider = dbProvider;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.patchSetUtil = patchSetUtil;
   }
 
   @Override
-  protected ChangeInfo applyImpl(
+  protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, RevisionResource rsrc, RebaseInput input)
-      throws OrmException, UpdateException, RestApiException, IOException,
-          PermissionBackendException {
+      throws UpdateException, RestApiException, IOException, PermissionBackendException {
     // Not allowed to rebase if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
-    rsrc.permissions().database(dbProvider).check(ChangePermission.REBASE);
+    rsrc.permissions().check(ChangePermission.REBASE);
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
     Change change = rsrc.getChange();
@@ -118,14 +113,15 @@
         ObjectReader reader = oi.newReader();
         RevWalk rw = new RevWalk(reader);
         BatchUpdate bu =
-            updateFactory.create(
-                dbProvider.get(), change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      if (!change.getStatus().isOpen()) {
+            updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      if (!change.isNew()) {
         throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
         throw new ResourceConflictException(
             "cannot rebase merge commits or commit with no ancestor");
       }
+      // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
+      bu.setNotify(NotifyResolver.Result.none());
       bu.setRepository(repo, rw, oi);
       bu.addOp(
           change.getId(),
@@ -135,14 +131,14 @@
               .setFireRevisionCreated(true));
       bu.execute();
     }
-    return json.create(OPTIONS).format(change.getProject(), change.getId());
+    return Response.ok(json.create(OPTIONS).format(change.getProject(), change.getId()));
   }
 
   private ObjectId findBaseRev(
       Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws RestApiException, OrmException, IOException, NoSuchChangeException, AuthException,
+      throws RestApiException, IOException, NoSuchChangeException, AuthException,
           PermissionBackendException {
-    Branch.NameKey destRefKey = rsrc.getChange().getDest();
+    BranchNameKey destRefKey = rsrc.getChange().getDest();
     if (input == null || input.base == null) {
       return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
     }
@@ -151,28 +147,25 @@
     String str = input.base.trim();
     if (str.equals("")) {
       // Remove existing dependency to other patch set.
-      Ref destRef = repo.exactRef(destRefKey.get());
+      Ref destRef = repo.exactRef(destRefKey.branch());
       if (destRef == null) {
         throw new ResourceConflictException(
-            "can't rebase onto tip of branch " + destRefKey.get() + "; branch doesn't exist");
+            "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
       }
       return destRef.getObjectId();
     }
 
     Base base = rebaseUtil.parseBase(rsrc, str);
     if (base == null) {
-      throw new ResourceConflictException("base revision is missing: " + str);
+      throw new ResourceConflictException(
+          "base revision is missing from the destination branch: " + str);
     }
-    PatchSet.Id baseId = base.patchSet().getId();
-    if (change.getId().equals(baseId.getParentKey())) {
+    PatchSet.Id baseId = base.patchSet().id();
+    if (change.getId().equals(baseId.changeId())) {
       throw new ResourceConflictException("cannot rebase change onto itself");
     }
 
-    permissionBackend
-        .user(rsrc.getUser())
-        .database(dbProvider)
-        .change(base.notes())
-        .check(ChangePermission.READ);
+    permissionBackend.user(rsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
 
     Change baseChange = base.notes().getChange();
     if (!baseChange.getProject().equals(change.getProject())) {
@@ -181,7 +174,7 @@
     } else if (!baseChange.getDest().equals(change.getDest())) {
       throw new ResourceConflictException(
           "base change is targeting wrong branch: " + baseChange.getDest());
-    } else if (baseChange.getStatus() == Status.ABANDONED) {
+    } else if (baseChange.isAbandoned()) {
       throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
     } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
       throw new ResourceConflictException(
@@ -189,18 +182,18 @@
               + baseChange.getKey()
               + " is a descendant of the current change - recursion not allowed");
     }
-    return ObjectId.fromString(base.patchSet().getRevision().get());
+    return base.patchSet().commitId();
   }
 
   private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
-    ObjectId baseId = ObjectId.fromString(base.getRevision().get());
-    ObjectId tipId = ObjectId.fromString(tip.getRevision().get());
+    ObjectId baseId = base.commitId();
+    ObjectId tipId = tip.commitId();
     return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
   }
 
   private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
     // Prevent rebase of exotic changes (merge commit, no ancestor).
-    RevCommit c = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+    RevCommit c = rw.parseCommit(ps.commitId());
     return c.getParentCount() == 1;
   }
 
@@ -213,7 +206,7 @@
             .setVisible(false);
 
     Change change = rsrc.getChange();
-    if (!(change.getStatus().isOpen() && rsrc.isCurrent())) {
+    if (!(change.isNew() && rsrc.isCurrent())) {
       return description;
     }
 
@@ -228,28 +221,32 @@
     }
 
     try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
-    } catch (OrmException | IOException e) {
+    } catch (StorageException | IOException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if the current patch set of change %s is locked", change.getId());
       return description;
     }
 
     boolean enabled = false;
-    try (Repository repo = repoManager.openRepository(change.getDest().getParentKey());
+    try (Repository repo = repoManager.openRepository(change.getDest().project());
         RevWalk rw = new RevWalk(repo)) {
       if (hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
       }
-    } catch (IOException e) {
+    } catch (Exception e) {
+      // Be generous here with the exceptions that we log and swallow. RebaseUtil#canRebase uses the
+      // change index and this UI action is on the critical path of rendering a change details page.
+      // If the index is broken, we log and disable the UI action, but still show the page to the
+      // user.
       logger.atSevere().withCause(e).log(
           "Failed to check if patch set can be rebased: %s", rsrc.getPatchSet());
       return description;
     }
 
-    if (rsrc.permissions().database(dbProvider).testOrFalse(ChangePermission.REBASE)) {
+    if (rsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
       return description.setVisible(true).setEnabled(enabled);
     }
     return description;
@@ -268,15 +265,15 @@
     }
 
     @Override
-    protected ChangeInfo applyImpl(
+    protected Response<ChangeInfo> applyImpl(
         BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
-        throws OrmException, UpdateException, RestApiException, IOException,
-            PermissionBackendException {
-      PatchSet ps = psUtil.current(rebase.dbProvider.get(), rsrc.getNotes());
+        throws UpdateException, RestApiException, IOException, PermissionBackendException {
+      PatchSet ps = psUtil.current(rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
       }
-      return rebase.applyImpl(updateFactory, new RevisionResource(rsrc, ps), input);
+      return Response.ok(
+          rebase.applyImpl(updateFactory, new RevisionResource(rsrc, ps), input).value());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
index 7e1bb4d..8e0ff4b 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
@@ -15,84 +15,48 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.ChangeEditResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class RebaseChangeEdit
-    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
-
-  private final Rebase rebase;
+public class RebaseChangeEdit extends RetryingRestModifyView<ChangeResource, Input, Object> {
+  private final GitRepositoryManager repositoryManager;
+  private final ChangeEditModifier editModifier;
 
   @Inject
-  RebaseChangeEdit(Rebase rebase) {
-    this.rebase = rebase;
+  RebaseChangeEdit(
+      RetryHelper retryHelper,
+      GitRepositoryManager repositoryManager,
+      ChangeEditModifier editModifier) {
+    super(retryHelper);
+    this.repositoryManager = repositoryManager;
+    this.editModifier = editModifier;
   }
 
   @Override
-  public DynamicMap<RestView<ChangeEditResource>> views() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public RestView<ChangeResource> list() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public ChangeEditResource parse(ChangeResource parent, IdString id) {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public Rebase post(ChangeResource parent) throws RestApiException {
-    return rebase;
-  }
-
-  @Singleton
-  public static class Rebase implements RestModifyView<ChangeResource, Input> {
-
-    private final GitRepositoryManager repositoryManager;
-    private final ChangeEditModifier editModifier;
-
-    @Inject
-    Rebase(GitRepositoryManager repositoryManager, ChangeEditModifier editModifier) {
-      this.repositoryManager = repositoryManager;
-      this.editModifier = editModifier;
+  protected Response<Object> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input in)
+      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
+    Project.NameKey project = rsrc.getProject();
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      editModifier.rebaseEdit(repository, rsrc.getNotes());
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage());
     }
-
-    @Override
-    public Response<?> apply(ChangeResource rsrc, Input in)
-        throws AuthException, ResourceConflictException, IOException, OrmException,
-            PermissionBackendException {
-      Project.NameKey project = rsrc.getProject();
-      try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.rebaseEdit(repository, rsrc.getNotes());
-      } catch (InvalidChangeOperationException e) {
-        throw new ResourceConflictException(e.getMessage());
-      }
-      return Response.none();
-    }
+    return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Rebuild.java b/java/com/google/gerrit/server/restapi/change/Rebuild.java
deleted file mode 100644
index 4508a99..0000000
--- a/java/com/google/gerrit/server/restapi/change/Rebuild.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static java.util.stream.Collectors.joining;
-
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class Rebuild implements RestModifyView<ChangeResource, Input> {
-
-  private final Provider<ReviewDb> db;
-  private final NotesMigration migration;
-  private final ChangeRebuilder rebuilder;
-  private final ChangeBundleReader bundleReader;
-  private final CommentsUtil commentsUtil;
-  private final ChangeNotes.Factory notesFactory;
-
-  @Inject
-  Rebuild(
-      Provider<ReviewDb> db,
-      NotesMigration migration,
-      ChangeRebuilder rebuilder,
-      ChangeBundleReader bundleReader,
-      CommentsUtil commentsUtil,
-      ChangeNotes.Factory notesFactory) {
-    this.db = db;
-    this.migration = migration;
-    this.rebuilder = rebuilder;
-    this.bundleReader = bundleReader;
-    this.commentsUtil = commentsUtil;
-    this.notesFactory = notesFactory;
-  }
-
-  @Override
-  public BinaryResult apply(ChangeResource rsrc, Input input)
-      throws ResourceNotFoundException, IOException, OrmException, ConfigInvalidException {
-    if (!migration.commitChangeWrites()) {
-      throw new ResourceNotFoundException();
-    }
-    if (!migration.readChanges()) {
-      // ChangeBundle#fromNotes currently doesn't work if reading isn't enabled,
-      // so don't attempt a diff.
-      rebuild(rsrc);
-      return BinaryResult.create("Rebuilt change successfully");
-    }
-
-    // Not the same transaction as the rebuild, so may result in spurious diffs
-    // in the case of races. This should be easy enough to detect by rerunning.
-    ChangeBundle reviewDbBundle =
-        bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db.get()), rsrc.getId());
-    rebuild(rsrc);
-    ChangeNotes notes = notesFactory.create(db.get(), rsrc.getChange().getProject(), rsrc.getId());
-    ChangeBundle noteDbBundle = ChangeBundle.fromNotes(commentsUtil, notes);
-    List<String> diffs = reviewDbBundle.differencesFrom(noteDbBundle);
-    if (diffs.isEmpty()) {
-      return BinaryResult.create("No differences between ReviewDb and NoteDb");
-    }
-    return BinaryResult.create(
-        diffs.stream().collect(joining("\n", "Differences between ReviewDb and NoteDb:\n", "\n")));
-  }
-
-  private void rebuild(ChangeResource rsrc)
-      throws ResourceNotFoundException, OrmException, IOException {
-    try {
-      rebuilder.rebuild(db.get(), rsrc.getId());
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getId().toString()));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
index fbdfb54..f28c547 100644
--- a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -28,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,9 +35,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayDeque;
@@ -61,27 +58,24 @@
 class RelatedChangesSorter {
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
-  private final Provider<ReviewDb> dbProvider;
   private final ProjectCache projectCache;
 
   @Inject
   RelatedChangesSorter(
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
-      Provider<ReviewDb> dbProvider,
       ProjectCache projectCache) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
-    this.dbProvider = dbProvider;
     this.projectCache = projectCache;
   }
 
   public List<PatchSetData> sort(List<ChangeData> in, PatchSet startPs)
-      throws OrmException, IOException, PermissionBackendException {
+      throws IOException, PermissionBackendException {
     checkArgument(!in.isEmpty(), "Input may not be empty");
     // Map of all patch sets, keyed by commit SHA-1.
-    Map<String, PatchSetData> byId = collectById(in);
-    PatchSetData start = byId.get(startPs.getRevision().get());
+    Map<ObjectId, PatchSetData> byId = collectById(in);
+    PatchSetData start = byId.get(startPs.commitId());
     checkArgument(start != null, "%s not found in %s", startPs, in);
 
     // Map of patch set -> immediate parent.
@@ -95,12 +89,12 @@
 
     for (ChangeData cd : in) {
       for (PatchSet ps : cd.patchSets()) {
-        PatchSetData thisPsd = checkNotNull(byId.get(ps.getRevision().get()));
-        if (cd.getId().equals(start.id()) && !ps.getId().equals(start.psId())) {
+        PatchSetData thisPsd = requireNonNull(byId.get(ps.commitId()));
+        if (cd.getId().equals(start.id()) && !ps.id().equals(start.psId())) {
           otherPatchSetsOfStart.add(thisPsd);
         }
         for (RevCommit p : thisPsd.commit().getParents()) {
-          PatchSetData parentPsd = byId.get(p.name());
+          PatchSetData parentPsd = byId.get(p);
           if (parentPsd != null) {
             parents.put(thisPsd, parentPsd);
             children.put(parentPsd, thisPsd);
@@ -118,10 +112,9 @@
     return result;
   }
 
-  private Map<String, PatchSetData> collectById(List<ChangeData> in)
-      throws OrmException, IOException {
+  private Map<ObjectId, PatchSetData> collectById(List<ChangeData> in) throws IOException {
     Project.NameKey project = in.get(0).change().getProject();
-    Map<String, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
+    Map<ObjectId, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.setRetainBody(true);
@@ -133,10 +126,9 @@
             project,
             cd.change().getProject());
         for (PatchSet ps : cd.patchSets()) {
-          String id = ps.getRevision().get();
-          RevCommit c = rw.parseCommit(ObjectId.fromString(id));
+          RevCommit c = rw.parseCommit(ps.commitId());
           PatchSetData psd = PatchSetData.create(cd, ps, c);
-          result.put(id, psd);
+          result.put(ps.commitId(), psd);
         }
       }
     }
@@ -227,7 +219,7 @@
     // If we saw the same change multiple times, prefer the latest patch set.
     List<PatchSetData> result = new ArrayList<>(allPatchSets.size());
     for (PatchSetData psd : allPatchSets) {
-      if (checkNotNull(maxPatchSetIds.get(psd.id())).equals(psd.psId())) {
+      if (requireNonNull(maxPatchSetIds.get(psd.id())).equals(psd.psId())) {
         result.add(psd);
       }
     }
@@ -235,7 +227,7 @@
   }
 
   private boolean isVisible(PatchSetData psd) throws PermissionBackendException, IOException {
-    PermissionBackend.WithUser perm = permissionBackend.currentUser().database(dbProvider);
+    PermissionBackend.WithUser perm = permissionBackend.currentUser();
     try {
       perm.change(psd.data()).check(ChangePermission.READ);
     } catch (AuthException e) {
@@ -259,25 +251,25 @@
     abstract RevCommit commit();
 
     PatchSet.Id psId() {
-      return patchSet().getId();
+      return patchSet().id();
     }
 
     Change.Id id() {
-      return psId().getParentKey();
+      return psId().changeId();
     }
 
     @Override
-    public int hashCode() {
-      return Objects.hash(patchSet().getId(), commit());
+    public final int hashCode() {
+      return Objects.hash(patchSet().id(), commit());
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public final boolean equals(Object obj) {
       if (!(obj instanceof PatchSetData)) {
         return false;
       }
       PatchSetData o = (PatchSetData) obj;
-      return Objects.equals(patchSet().getId(), o.patchSet().getId())
+      return Objects.equals(patchSet().id(), o.patchSet().id())
           && Objects.equals(commit(), o.commit());
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 5e4ede3..13cb322 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -16,17 +16,17 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
@@ -46,9 +46,8 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
@@ -58,7 +57,6 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final RestoredSender.Factory restoredSenderFactory;
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
@@ -68,7 +66,6 @@
   @Inject
   Restore(
       RestoredSender.Factory restoredSenderFactory,
-      Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
@@ -77,7 +74,6 @@
       ProjectCache projectCache) {
     super(retryHelper);
     this.restoredSenderFactory = restoredSenderFactory;
-    this.dbProvider = dbProvider;
     this.json = json;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
@@ -86,23 +82,21 @@
   }
 
   @Override
-  protected ChangeInfo applyImpl(
+  protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, RestoreInput input)
-      throws RestApiException, UpdateException, OrmException, PermissionBackendException,
-          IOException {
+      throws RestApiException, UpdateException, PermissionBackendException, IOException {
     // Not allowed to restore if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
-    rsrc.permissions().database(dbProvider).check(ChangePermission.RESTORE);
+    rsrc.permissions().check(ChangePermission.RESTORE);
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
     Op op = new Op(input);
     try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       u.addOp(rsrc.getId(), op).execute();
     }
-    return json.noOptions().format(op.change);
+    return Response.ok(json.noOptions().format(op.change));
   }
 
   private class Op implements BatchUpdateOp {
@@ -117,20 +111,20 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
+    public boolean updateChange(ChangeContext ctx) throws ResourceConflictException {
       change = ctx.getChange();
-      if (change == null || change.getStatus() != Status.ABANDONED) {
+      if (change == null || !change.isAbandoned()) {
         throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       }
       PatchSet.Id psId = change.currentPatchSetId();
       ChangeUpdate update = ctx.getUpdate(psId);
-      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      patchSet = psUtil.get(ctx.getNotes(), psId);
       change.setStatus(Status.NEW);
       change.setLastUpdatedOn(ctx.getWhen());
       update.setStatus(change.getStatus());
 
       message = newMessage(ctx);
-      cmUtil.addChangeMessage(ctx.getDb(), update, message);
+      cmUtil.addChangeMessage(update, message);
       return true;
     }
 
@@ -145,7 +139,7 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws OrmException {
+    public void postUpdate(Context ctx) {
       try {
         ReplyToChangeSender cm = restoredSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
@@ -168,7 +162,7 @@
             .setVisible(false);
 
     Change change = rsrc.getChange();
-    if (change.getStatus() != Status.ABANDONED) {
+    if (!change.isAbandoned()) {
       return description;
     }
 
@@ -183,16 +177,16 @@
     }
 
     try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
-    } catch (OrmException | IOException e) {
+    } catch (StorageException | IOException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if the current patch set of change %s is locked", change.getId());
       return description;
     }
 
-    boolean visible = rsrc.permissions().database(dbProvider).testOrFalse(ChangePermission.RESTORE);
+    boolean visible = rsrc.permissions().testOrFalse(ChangePermission.RESTORE);
     return description.setVisible(visible);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index 55d0933..413d780 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -14,19 +14,18 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
@@ -34,7 +33,6 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -42,17 +40,17 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
@@ -66,7 +64,7 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -92,7 +90,6 @@
     implements UiAction<ChangeResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final Provider<ReviewDb> db;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final ChangeInserter.Factory changeInserterFactory;
@@ -101,16 +98,15 @@
   private final PatchSetUtil psUtil;
   private final RevertedSender.Factory revertedSenderFactory;
   private final ChangeJson.Factory json;
-  private final PersonIdent serverIdent;
+  private final Provider<PersonIdent> serverIdent;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeReverted changeReverted;
   private final ContributorAgreementsChecker contributorAgreements;
   private final ProjectCache projectCache;
-  private final NotifyUtil notifyUtil;
+  private final NotifyResolver notifyResolver;
 
   @Inject
   Revert(
-      Provider<ReviewDb> db,
       PermissionBackend permissionBackend,
       GitRepositoryManager repoManager,
       ChangeInserter.Factory changeInserterFactory,
@@ -120,14 +116,13 @@
       PatchSetUtil psUtil,
       RevertedSender.Factory revertedSenderFactory,
       ChangeJson.Factory json,
-      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       ApprovalsUtil approvalsUtil,
       ChangeReverted changeReverted,
       ContributorAgreementsChecker contributorAgreements,
       ProjectCache projectCache,
-      NotifyUtil notifyUtil) {
+      NotifyResolver notifyResolver) {
     super(retryHelper);
-    this.db = db;
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.changeInserterFactory = changeInserterFactory;
@@ -141,16 +136,16 @@
     this.changeReverted = changeReverted;
     this.contributorAgreements = contributorAgreements;
     this.projectCache = projectCache;
-    this.notifyUtil = notifyUtil;
+    this.notifyResolver = notifyResolver;
   }
 
   @Override
-  public ChangeInfo applyImpl(
+  public Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, RevertInput input)
-      throws IOException, OrmException, RestApiException, UpdateException, NoSuchChangeException,
+      throws IOException, RestApiException, UpdateException, NoSuchChangeException,
           PermissionBackendException, NoSuchProjectException, ConfigInvalidException {
     Change change = rsrc.getChange();
-    if (change.getStatus() != Change.Status.MERGED) {
+    if (!change.isMerged()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
 
@@ -159,16 +154,16 @@
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
     Change.Id revertId = revert(updateFactory, rsrc.getNotes(), rsrc.getUser(), input);
-    return json.noOptions().format(rsrc.getProject(), revertId);
+    return Response.ok(json.noOptions().format(rsrc.getProject(), revertId));
   }
 
   private Change.Id revert(
       BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, RevertInput input)
-      throws OrmException, IOException, RestApiException, UpdateException, ConfigInvalidException {
+      throws IOException, RestApiException, UpdateException, ConfigInvalidException {
     String message = Strings.emptyToNull(input.message);
     Change.Id changeIdToRevert = notes.getChangeId();
     PatchSet.Id patchSetId = notes.getChange().currentPatchSetId();
-    PatchSet patch = psUtil.get(db.get(), notes, patchSetId);
+    PatchSet patch = psUtil.get(notes, patchSetId);
     if (patch == null) {
       throw new ResourceNotFoundException(changeIdToRevert.toString());
     }
@@ -178,14 +173,13 @@
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk revWalk = new RevWalk(reader)) {
-      RevCommit commitToRevert =
-          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
+      RevCommit commitToRevert = revWalk.parseCommit(patch.commitId());
       if (commitToRevert.getParentCount() == 0) {
         throw new ResourceConflictException("Cannot revert initial commit");
       }
 
       Timestamp now = TimeUtil.nowTs();
-      PersonIdent committerIdent = new PersonIdent(serverIdent, now);
+      PersonIdent committerIdent = serverIdent.get();
       PersonIdent authorIdent =
           user.asIdentifiedUser().newCommitterIdent(now, committerIdent.getTimeZone());
 
@@ -204,7 +198,7 @@
             MessageFormat.format(
                 ChangeMessages.get().revertChangeDefaultMessage,
                 changeToRevert.getSubject(),
-                patch.getRevision().get());
+                patch.commitId().name());
       }
 
       ObjectId computedChangeId =
@@ -216,38 +210,36 @@
               message);
       revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, computedChangeId, true));
 
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      Change.Id changeId = Change.id(seq.nextChangeId());
       ObjectId id = oi.insert(revertCommitBuilder);
       RevCommit revertCommit = revWalk.parseCommit(id);
 
-      ListMultimap<RecipientType, Account.Id> accountsToNotify =
-          notifyUtil.resolveAccounts(input.notifyDetails);
+      NotifyResolver.Result notify =
+          notifyResolver.resolve(
+              firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
 
       ChangeInserter ins =
           changeInserterFactory
-              .create(changeId, revertCommit, notes.getChange().getDest().get())
+              .create(changeId, revertCommit, notes.getChange().getDest().branch())
               .setTopic(changeToRevert.getTopic());
       ins.setMessage("Uploaded patch set 1.");
-      ins.setNotify(input.notify);
-      ins.setAccountsToNotify(accountsToNotify);
 
-      ReviewerSet reviewerSet = approvalsUtil.getReviewers(db.get(), notes);
+      ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
       Set<Account.Id> reviewers = new HashSet<>();
       reviewers.add(changeToRevert.getOwner());
       reviewers.addAll(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
       reviewers.remove(user.getAccountId());
-      ins.setReviewers(reviewers);
-
       Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
       ccs.remove(user.getAccountId());
-      ins.setExtraCC(ccs);
+      ins.setReviewersAndCcs(reviewers, ccs);
       ins.setRevertOf(changeIdToRevert);
 
-      try (BatchUpdate bu = updateFactory.create(db.get(), project, user, now)) {
+      try (BatchUpdate bu = updateFactory.create(project, user, now)) {
         bu.setRepository(git, revWalk, oi);
+        bu.setNotify(notify);
         bu.insertChange(ins);
-        bu.addOp(changeId, new NotifyOp(changeToRevert, ins, input.notify, accountsToNotify));
+        bu.addOp(changeId, new NotifyOp(changeToRevert, ins));
         bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(computedChangeId));
         bu.execute();
       }
@@ -272,7 +264,7 @@
         .setTitle("Revert the change")
         .setVisible(
             and(
-                change.getStatus() == Change.Status.MERGED && projectStatePermitsWrite,
+                change.isMerged() && projectStatePermitsWrite,
                 permissionBackend
                     .user(rsrc.getUser())
                     .ref(change.getDest())
@@ -282,18 +274,10 @@
   private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
-    private final NotifyHandling notifyHandling;
-    private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
 
-    NotifyOp(
-        Change change,
-        ChangeInserter ins,
-        NotifyHandling notifyHandling,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    NotifyOp(Change change, ChangeInserter ins) {
       this.change = change;
       this.ins = ins;
-      this.notifyHandling = notifyHandling;
-      this.accountsToNotify = accountsToNotify;
     }
 
     @Override
@@ -302,8 +286,7 @@
       try {
         RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
-        cm.setNotify(notifyHandling);
-        cm.setAccountsToNotify(accountsToNotify);
+        cm.setNotify(ctx.getNotify(change.getId()));
         cm.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
@@ -320,7 +303,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws Exception {
+    public boolean updateChange(ChangeContext ctx) {
       Change change = ctx.getChange();
       PatchSet.Id patchSetId = change.currentPatchSetId();
       ChangeMessage changeMessage =
@@ -328,7 +311,7 @@
               ctx,
               "Created a revert of this change as I" + computedChangeId.name(),
               ChangeMessagesUtil.TAG_REVERT);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(patchSetId), changeMessage);
+      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewed.java b/java/com/google/gerrit/server/restapi/change/Reviewed.java
index d1a2168..7152799 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewed.java
@@ -15,12 +15,11 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.change.FileResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -28,44 +27,43 @@
 
   @Singleton
   public static class PutReviewed implements RestModifyView<FileResource, Input> {
-    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+    private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
-    PutReviewed(DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+    PutReviewed(PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore) {
       this.accountPatchReviewStore = accountPatchReviewStore;
     }
 
     @Override
-    public Response<String> apply(FileResource resource, Input input) throws OrmException {
-      if (accountPatchReviewStore
-          .get()
-          .markReviewed(
-              resource.getPatchKey().getParentKey(),
-              resource.getAccountId(),
-              resource.getPatchKey().getFileName())) {
-        return Response.created("");
-      }
-      return Response.ok("");
+    public Response<String> apply(FileResource resource, Input input) {
+      boolean reviewFlagUpdated =
+          accountPatchReviewStore.call(
+              s ->
+                  s.markReviewed(
+                      resource.getPatchKey().patchSetId(),
+                      resource.getAccountId(),
+                      resource.getPatchKey().fileName()));
+      return reviewFlagUpdated ? Response.created("") : Response.ok("");
     }
   }
 
   @Singleton
   public static class DeleteReviewed implements RestModifyView<FileResource, Input> {
-    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+    private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
-    DeleteReviewed(DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+    DeleteReviewed(PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore) {
       this.accountPatchReviewStore = accountPatchReviewStore;
     }
 
     @Override
-    public Response<?> apply(FileResource resource, Input input) throws OrmException {
-      accountPatchReviewStore
-          .get()
-          .clearReviewed(
-              resource.getPatchKey().getParentKey(),
-              resource.getAccountId(),
-              resource.getPatchKey().getFileName());
+    public Response<?> apply(FileResource resource, Input input) {
+      accountPatchReviewStore.run(
+          s ->
+              s.clearReviewed(
+                  resource.getPatchKey().patchSetId(),
+                  resource.getAccountId(),
+                  resource.getPatchKey().fileName()));
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
deleted file mode 100644
index cfd20c2..0000000
--- a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static com.google.gerrit.common.data.LabelValue.formatValue;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-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.account.AccountLoader;
-import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.permissions.LabelPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-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.Provider;
-import com.google.inject.Singleton;
-import java.util.Collection;
-import java.util.List;
-import java.util.TreeMap;
-
-@Singleton
-public class ReviewerJson {
-  private final Provider<ReviewDb> db;
-  private final PermissionBackend permissionBackend;
-  private final ChangeData.Factory changeDataFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final SubmitRuleEvaluator submitRuleEvaluator;
-
-  @Inject
-  ReviewerJson(
-      Provider<ReviewDb> db,
-      PermissionBackend permissionBackend,
-      ChangeData.Factory changeDataFactory,
-      ApprovalsUtil approvalsUtil,
-      AccountLoader.Factory accountLoaderFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
-    this.db = db;
-    this.permissionBackend = permissionBackend;
-    this.changeDataFactory = changeDataFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.accountLoaderFactory = accountLoaderFactory;
-    submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
-  }
-
-  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
-      throws OrmException, PermissionBackendException {
-    List<ReviewerInfo> infos = Lists.newArrayListWithCapacity(rsrcs.size());
-    AccountLoader loader = accountLoaderFactory.create(true);
-    ChangeData cd = null;
-    for (ReviewerResource rsrc : rsrcs) {
-      if (cd == null || !cd.getId().equals(rsrc.getChangeId())) {
-        cd = changeDataFactory.create(db.get(), rsrc.getChangeResource().getNotes());
-      }
-      ReviewerInfo info =
-          format(
-              new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
-              permissionBackend
-                  .absentUser(rsrc.getReviewerUser().getAccountId())
-                  .database(db)
-                  .change(cd),
-              cd);
-      loader.put(info);
-      infos.add(info);
-    }
-    loader.fill();
-    return infos;
-  }
-
-  public List<ReviewerInfo> format(ReviewerResource rsrc)
-      throws OrmException, PermissionBackendException {
-    return format(ImmutableList.<ReviewerResource>of(rsrc));
-  }
-
-  public ReviewerInfo format(ReviewerInfo out, PermissionBackend.ForChange perm, ChangeData cd)
-      throws OrmException, PermissionBackendException {
-    PatchSet.Id psId = cd.change().currentPatchSetId();
-    return format(
-        out,
-        perm,
-        cd,
-        approvalsUtil.byPatchSetUser(
-            db.get(), cd.notes(), perm.user(), psId, new Account.Id(out._accountId), null, null));
-  }
-
-  public ReviewerInfo format(
-      ReviewerInfo out,
-      PermissionBackend.ForChange perm,
-      ChangeData cd,
-      Iterable<PatchSetApproval> approvals)
-      throws OrmException, PermissionBackendException {
-    LabelTypes labelTypes = cd.getLabelTypes();
-
-    out.approvals = new TreeMap<>(labelTypes.nameComparator());
-    for (PatchSetApproval ca : approvals) {
-      LabelType at = labelTypes.byLabel(ca.getLabelId());
-      if (at != null) {
-        out.approvals.put(at.getName(), formatValue(ca.getValue()));
-      }
-    }
-
-    // Add dummy approvals for all permitted labels for the user even if they
-    // do not exist in the DB.
-    PatchSet ps = cd.currentPatchSet();
-    if (ps != null) {
-      for (SubmitRecord rec : submitRuleEvaluator.evaluate(cd)) {
-        if (rec.labels == null) {
-          continue;
-        }
-        for (SubmitRecord.Label label : rec.labels) {
-          String name = label.label;
-          LabelType type = labelTypes.byLabel(name);
-          if (!out.approvals.containsKey(name)
-              && type != null
-              && perm.test(new LabelPermission(type))) {
-            out.approvals.put(name, formatValue((short) 0));
-          }
-        }
-      }
-    }
-
-    if (out.approvals.isEmpty()) {
-      out.approvals = null;
-    }
-
-    return out;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 47c6970..ea7182f 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -23,12 +23,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.change.ReviewerSuggestion;
@@ -36,11 +34,11 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.plugincontext.PluginMapContext;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -51,7 +49,6 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -77,19 +74,17 @@
 
   private final ChangeQueryBuilder changeQueryBuilder;
   private final Config config;
-  private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap;
+  private final PluginMapContext<ReviewerSuggestion> reviewerSuggestionPluginMap;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ExecutorService executor;
-  private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
 
   @Inject
   ReviewerRecommender(
       ChangeQueryBuilder changeQueryBuilder,
-      DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap,
+      PluginMapContext<ReviewerSuggestion> reviewerSuggestionPluginMap,
       Provider<InternalChangeQuery> queryProvider,
       @FanOutExecutor ExecutorService executor,
-      Provider<ReviewDb> dbProvider,
       ApprovalsUtil approvalsUtil,
       @GerritServerConfig Config config) {
     this.changeQueryBuilder = changeQueryBuilder;
@@ -97,7 +92,6 @@
     this.queryProvider = queryProvider;
     this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
     this.executor = executor;
-    this.dbProvider = dbProvider;
     this.approvalsUtil = approvalsUtil;
   }
 
@@ -106,9 +100,14 @@
       SuggestReviewers suggestReviewers,
       ProjectState projectState,
       List<Account.Id> candidateList)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws 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 +115,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
@@ -124,30 +124,30 @@
         new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
     List<Double> weights = new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
 
-    for (DynamicMap.Entry<ReviewerSuggestion> plugin : reviewerSuggestionPluginMap) {
-      tasks.add(
-          () ->
-              plugin
-                  .getProvider()
-                  .get()
-                  .suggestReviewers(
-                      projectState.getNameKey(),
-                      changeNotes != null ? changeNotes.getChangeId() : null,
-                      query,
-                      reviewerScores.keySet()));
-      String key = plugin.getPluginName() + "-" + plugin.getExportName();
-      String pluginWeight = config.getString("addReviewer", key, "weight");
-      if (Strings.isNullOrEmpty(pluginWeight)) {
-        pluginWeight = "1";
-      }
-      logger.atFine().log("weight for %s: %s", key, pluginWeight);
-      try {
-        weights.add(Double.parseDouble(pluginWeight));
-      } catch (NumberFormatException e) {
-        logger.atSevere().withCause(e).log("Exception while parsing weight for %s", key);
-        weights.add(1d);
-      }
-    }
+    reviewerSuggestionPluginMap.runEach(
+        extension -> {
+          tasks.add(
+              () ->
+                  extension
+                      .get()
+                      .suggestReviewers(
+                          projectState.getNameKey(),
+                          changeNotes != null ? changeNotes.getChangeId() : null,
+                          query,
+                          reviewerScores.keySet()));
+          String key = extension.getPluginName() + "-" + extension.getExportName();
+          String pluginWeight = config.getString("addReviewer", key, "weight");
+          if (Strings.isNullOrEmpty(pluginWeight)) {
+            pluginWeight = "1";
+          }
+          logger.atFine().log("weight for %s: %s", key, pluginWeight);
+          try {
+            weights.add(Double.parseDouble(pluginWeight));
+          } catch (NumberFormatException e) {
+            logger.atSevere().withCause(e).log("Exception while parsing weight for %s", key);
+            weights.add(1d);
+          }
+        });
 
     try {
       List<Future<Set<SuggestedReviewer>>> futures =
@@ -163,6 +163,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,26 +171,33 @@
 
     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(changeNotes)
+          .byState(REVIEWER)
+          .forEach(
+              r -> {
+                if (reviewerScores.remove(r) != null) {
+                  logger.atFine().log("Remove existing reviewer %s", r);
+                }
+              });
     }
 
     // Sort results
-    Stream<Entry<Account.Id, MutableDouble>> sorted =
-        reviewerScores
-            .entrySet()
-            .stream()
+    Stream<Map.Entry<Account.Id, MutableDouble>> sorted =
+        reviewerScores.entrySet().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;
   }
 
   private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(double baseWeight)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException {
     // Get the user's last 25 changes, check approvals
     try {
       List<ChangeData> result =
@@ -201,7 +209,7 @@
       Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
       for (ChangeData cd : result) {
         for (PatchSetApproval approval : cd.currentApprovals()) {
-          Account.Id id = approval.getAccountId();
+          Account.Id id = approval.accountId();
           if (suggestions.containsKey(id)) {
             suggestions.get(id).add(baseWeight);
           } else {
@@ -219,7 +227,7 @@
 
   private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
       List<Account.Id> candidates, ProjectState projectState, double baseWeight)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException {
     // Get each reviewer's activity based on number of applied labels
     // (weighted 10d), number of comments (weighted 0.5d) and number of owned
     // changes (weighted 1d).
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index a4cfbd2..546ca01 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -21,16 +21,13 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
@@ -39,7 +36,6 @@
 @Singleton
 public class Reviewers implements ChildCollection<ChangeResource, ReviewerResource> {
   private final DynamicMap<RestView<ReviewerResource>> views;
-  private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
   private final AccountsCollection accounts;
   private final ReviewerResource.Factory resourceFactory;
@@ -47,13 +43,11 @@
 
   @Inject
   Reviewers(
-      Provider<ReviewDb> dbProvider,
       ApprovalsUtil approvalsUtil,
       AccountsCollection accounts,
       ReviewerResource.Factory resourceFactory,
       DynamicMap<RestView<ReviewerResource>> views,
       ListReviewers list) {
-    this.dbProvider = dbProvider;
     this.approvalsUtil = approvalsUtil;
     this.accounts = accounts;
     this.resourceFactory = resourceFactory;
@@ -73,8 +67,7 @@
 
   @Override
   public ReviewerResource parse(ChangeResource rsrc, IdString id)
-      throws OrmException, ResourceNotFoundException, AuthException, IOException,
-          ConfigInvalidException {
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
     Address address = Address.tryParse(id.get());
 
     Account.Id accountId = null;
@@ -98,7 +91,7 @@
     throw new ResourceNotFoundException(id);
   }
 
-  private Collection<Account.Id> fetchAccountIds(ChangeResource rsrc) throws OrmException {
-    return approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
+  private Collection<Account.Id> fetchAccountIds(ChangeResource rsrc) {
+    return approvalsUtil.getReviewers(rsrc.getNotes()).all();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 65052a5..d2725c1 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -20,8 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
@@ -33,6 +32,7 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
@@ -43,20 +43,18 @@
 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.change.ReviewerAdder;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.account.AccountPredicates;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -115,9 +113,9 @@
     }
   }
 
-  // Generate a candidate list at 2x the size of what the user wants to see to
+  // Generate a candidate list at 3x the size of what the user wants to see to
   // give the ranking algorithm a good set of candidates it can work with
-  private static final int CANDIDATE_LIST_MULTIPLIER = 2;
+  private static final int CANDIDATE_LIST_MULTIPLIER = 3;
 
   private final AccountLoader.Factory accountLoaderFactory;
   private final AccountQueryBuilder accountQueryBuilder;
@@ -129,7 +127,6 @@
   private final IndexConfig indexConfig;
   private final AccountControl.Factory accountControlFactory;
   private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
 
   @Inject
   ReviewersUtil(
@@ -142,8 +139,7 @@
       AccountIndexCollection accountIndexes,
       IndexConfig indexConfig,
       AccountControl.Factory accountControlFactory,
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend) {
+      Provider<CurrentUser> self) {
     this.accountLoaderFactory = accountLoaderFactory;
     this.accountQueryBuilder = accountQueryBuilder;
     this.groupBackend = groupBackend;
@@ -154,11 +150,10 @@
     this.indexConfig = indexConfig;
     this.accountControlFactory = accountControlFactory;
     this.self = self;
-    this.permissionBackend = permissionBackend;
   }
 
   public interface VisibilityControl {
-    boolean isVisibleTo(Account.Id account) throws OrmException;
+    boolean isVisibleTo(Account.Id account);
   }
 
   public List<SuggestedReviewerInfo> suggestReviewers(
@@ -167,7 +162,7 @@
       ProjectState projectState,
       VisibilityControl visibilityControl,
       boolean excludeGroups)
-      throws IOException, OrmException, ConfigInvalidException, PermissionBackendException {
+      throws IOException, ConfigInvalidException, PermissionBackendException {
     CurrentUser currentUser = self.get();
     if (changeNotes != null) {
       logger.atFine().log(
@@ -226,30 +221,34 @@
     return suggestedReviewers;
   }
 
-  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers) throws OrmException {
+  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers) {
     try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
       try {
         // 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 -> Account.id(f.getValue(AccountField.ID).intValue()))
+                .collect(toList());
+        logger.atFine().log("Matches: %s", matches);
+        return matches;
       } catch (QueryParseException e) {
         return ImmutableList.of();
       }
@@ -262,7 +261,7 @@
       VisibilityControl visibilityControl,
       boolean excludeGroups,
       List<Account.Id> filteredRecommendations)
-      throws OrmException, PermissionBackendException, IOException {
+      throws PermissionBackendException, IOException {
     List<SuggestedReviewerInfo> suggestedReviewers = loadAccounts(filteredRecommendations);
 
     int limit = suggestReviewers.getLimit();
@@ -291,7 +290,7 @@
       SuggestReviewers suggestReviewers,
       ProjectState projectState,
       List<Account.Id> candidateList)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException {
     try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
       return reviewerRecommender.suggestReviewers(
           changeNotes, suggestReviewers, projectState, candidateList);
@@ -299,18 +298,14 @@
   }
 
   private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds)
-      throws OrmException, PermissionBackendException {
+      throws PermissionBackendException {
     Set<FillOptions> fillOptions =
-        permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)
-            ? EnumSet.of(FillOptions.SECONDARY_EMAILS)
-            : EnumSet.noneOf(FillOptions.class);
-    fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+        Sets.union(AccountLoader.DETAILED_OPTIONS, EnumSet.of(FillOptions.SECONDARY_EMAILS));
     AccountLoader accountLoader = accountLoaderFactory.create(fillOptions);
 
     try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) {
       List<SuggestedReviewerInfo> reviewer =
-          accountIds
-              .stream()
+          accountIds.stream()
               .map(accountLoader::get)
               .filter(Objects::nonNull)
               .map(
@@ -331,7 +326,7 @@
       ProjectState projectState,
       VisibilityControl visibilityControl,
       int limit)
-      throws OrmException, IOException {
+      throws IOException {
     try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
       List<SuggestedReviewerInfo> groups = new ArrayList<>();
       for (GroupReference g : suggestAccountGroups(suggestReviewers, projectState)) {
@@ -360,10 +355,9 @@
 
   private List<GroupReference> suggestAccountGroups(
       SuggestReviewers suggestReviewers, ProjectState projectState) {
-    return Lists.newArrayList(
-        Iterables.limit(
-            groupBackend.suggest(suggestReviewers.getQuery(), projectState),
-            suggestReviewers.getLimit()));
+    return groupBackend.suggest(suggestReviewers.getQuery(), projectState).stream()
+        .limit(suggestReviewers.getLimit())
+        .collect(toList());
   }
 
   private static class GroupAsReviewer {
@@ -377,12 +371,14 @@
       Project project,
       GroupReference group,
       VisibilityControl visibilityControl)
-      throws OrmException, IOException {
+      throws IOException {
     GroupAsReviewer result = new GroupAsReviewer();
     int maxAllowed = suggestReviewers.getMaxAllowed();
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
+    logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
 
-    if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) {
+    if (!ReviewerAdder.isLegalReviewerGroup(group.getUUID())) {
+      logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
       return result;
     }
 
@@ -390,6 +386,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;
       }
 
@@ -398,19 +395,28 @@
         return result;
       }
 
-      boolean needsConfirmation = result.size > maxAllowedWithoutConfirmation;
+      boolean needsConfirmation =
+          maxAllowedWithoutConfirmation > 0 && 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) {
-        if (visibilityControl.isVisibleTo(account.getId())) {
+        if (visibilityControl.isVisibleTo(account.id())) {
           if (needsConfirmation) {
             result.allowedWithConfirmation = true;
           } 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;
     }
@@ -419,8 +425,7 @@
   }
 
   private static String formatSuggestedReviewers(List<SuggestedReviewerInfo> suggestedReviewers) {
-    return suggestedReviewers
-        .stream()
+    return suggestedReviewers.stream()
         .map(
             r -> {
               if (r.account != null) {
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
index 7cf30e2..a41143c 100644
--- a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -22,16 +22,13 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
@@ -40,7 +37,6 @@
 @Singleton
 public class RevisionReviewers implements ChildCollection<RevisionResource, ReviewerResource> {
   private final DynamicMap<RestView<ReviewerResource>> views;
-  private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
   private final AccountsCollection accounts;
   private final ReviewerResource.Factory resourceFactory;
@@ -48,13 +44,11 @@
 
   @Inject
   RevisionReviewers(
-      Provider<ReviewDb> dbProvider,
       ApprovalsUtil approvalsUtil,
       AccountsCollection accounts,
       ReviewerResource.Factory resourceFactory,
       DynamicMap<RestView<ReviewerResource>> views,
       ListRevisionReviewers list) {
-    this.dbProvider = dbProvider;
     this.approvalsUtil = approvalsUtil;
     this.accounts = accounts;
     this.resourceFactory = resourceFactory;
@@ -74,8 +68,8 @@
 
   @Override
   public ReviewerResource parse(RevisionResource rsrc, IdString id)
-      throws OrmException, ResourceNotFoundException, AuthException, MethodNotAllowedException,
-          IOException, ConfigInvalidException {
+      throws ResourceNotFoundException, AuthException, MethodNotAllowedException, IOException,
+          ConfigInvalidException {
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot access on non-current patch set");
     }
@@ -89,8 +83,7 @@
         throw e;
       }
     }
-    Collection<Account.Id> reviewers =
-        approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
+    Collection<Account.Id> reviewers = approvalsUtil.getReviewers(rsrc.getNotes()).all();
     // See if the id exists as a reviewer for this change
     if (reviewers.contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 557d77a..dfba895 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -16,15 +16,15 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -34,21 +34,20 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
-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.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
   private final DynamicMap<RestView<RevisionResource>> views;
-  private final Provider<ReviewDb> dbProvider;
   private final ChangeEditUtil editUtil;
   private final PatchSetUtil psUtil;
   private final PermissionBackend permissionBackend;
@@ -57,13 +56,11 @@
   @Inject
   Revisions(
       DynamicMap<RestView<RevisionResource>> views,
-      Provider<ReviewDb> dbProvider,
       ChangeEditUtil editUtil,
       PatchSetUtil psUtil,
       PermissionBackend permissionBackend,
       ProjectCache projectCache) {
     this.views = views;
-    this.dbProvider = dbProvider;
     this.editUtil = editUtil;
     this.psUtil = psUtil;
     this.permissionBackend = permissionBackend;
@@ -82,12 +79,11 @@
 
   @Override
   public RevisionResource parse(ChangeResource change, IdString id)
-      throws ResourceNotFoundException, AuthException, OrmException, IOException,
-          PermissionBackendException {
+      throws ResourceNotFoundException, AuthException, IOException, PermissionBackendException {
     if (id.get().equals("current")) {
-      PatchSet ps = psUtil.current(dbProvider.get(), change.getNotes());
+      PatchSet ps = psUtil.current(change.getNotes());
       if (ps != null && visible(change)) {
-        return RevisionResource.createNonCachable(change, ps);
+        return RevisionResource.createNonCacheable(change, ps);
       }
       throw new ResourceNotFoundException(id);
     }
@@ -114,7 +110,6 @@
       permissionBackend
           .user(change.getUser())
           .change(change.getNotes())
-          .database(dbProvider)
           .check(ChangePermission.READ);
       return projectCache.checkedGet(change.getProject()).statePermitsRead();
     } catch (AuthException e) {
@@ -123,52 +118,52 @@
   }
 
   private List<RevisionResource> find(ChangeResource change, String id)
-      throws OrmException, IOException, AuthException {
+      throws IOException, AuthException {
     if (id.equals("0") || id.equals("edit")) {
       return loadEdit(change, null);
     } else if (id.length() < 6 && id.matches("^[1-9][0-9]{0,4}$")) {
       // Legacy patch set number syntax.
       return byLegacyPatchSetId(change, id);
-    } else if (id.length() < 4 || id.length() > RevId.LEN) {
+    } else if (id.length() < 4 || id.length() > ObjectIds.STR_LEN) {
       // Require a minimum of 4 digits.
       // Impossibly long identifier will never match.
       return Collections.emptyList();
     } else {
       List<RevisionResource> out = new ArrayList<>();
-      for (PatchSet ps : psUtil.byChange(dbProvider.get(), change.getNotes())) {
-        if (ps.getRevision() != null && ps.getRevision().get().startsWith(id)) {
+      for (PatchSet ps : psUtil.byChange(change.getNotes())) {
+        if (ObjectIds.matchesAbbreviation(ps.commitId(), id)) {
           out.add(new RevisionResource(change, ps));
         }
       }
       // Not an existing patch set on a change, but might be an edit.
-      if (out.isEmpty() && id.length() == RevId.LEN) {
-        return loadEdit(change, new RevId(id));
+      if (out.isEmpty() && ObjectId.isId(id)) {
+        return loadEdit(change, ObjectId.fromString(id));
       }
       return out;
     }
   }
 
-  private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id)
-      throws OrmException {
-    PatchSet ps =
-        psUtil.get(
-            dbProvider.get(),
-            change.getNotes(),
-            new PatchSet.Id(change.getId(), Integer.parseInt(id)));
+  private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id) {
+    PatchSet ps = psUtil.get(change.getNotes(), PatchSet.id(change.getId(), Integer.parseInt(id)));
     if (ps != null) {
       return Collections.singletonList(new RevisionResource(change, ps));
     }
     return Collections.emptyList();
   }
 
-  private List<RevisionResource> loadEdit(ChangeResource change, RevId revid)
+  private List<RevisionResource> loadEdit(ChangeResource change, @Nullable ObjectId commitId)
       throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
     if (edit.isPresent()) {
-      PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
-      RevId editRevId = new RevId(ObjectId.toString(edit.get().getEditCommit()));
-      ps.setRevision(editRevId);
-      if (revid == null || editRevId.equals(revid)) {
+      RevCommit editCommit = edit.get().getEditCommit();
+      PatchSet ps =
+          PatchSet.builder()
+              .id(PatchSet.id(change.getId(), 0))
+              .commitId(editCommit)
+              .uploader(change.getUser().getAccountId())
+              .createdOn(new Timestamp(editCommit.getCommitterIdent().getWhen().getTime()))
+              .build();
+      if (commitId == null || editCommit.equals(commitId)) {
         return Collections.singletonList(new RevisionResource(change, ps, edit));
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RobotComments.java b/java/com/google/gerrit/server/restapi/change/RobotComments.java
index 6570ae0..4ff8ca9 100644
--- a/java/com/google/gerrit/server/restapi/change/RobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/RobotComments.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.RobotCommentResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -56,11 +55,11 @@
 
   @Override
   public RobotCommentResource parse(RevisionResource rev, IdString id)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException {
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(notes, rev.getPatchSet().getId())) {
+    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(notes, rev.getPatchSet().id())) {
       if (uuid.equals(c.key.uuid)) {
         return new RobotCommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
deleted file mode 100644
index 1b50834..0000000
--- a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
+++ /dev/null
@@ -1,100 +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.server.restapi.change;
-
-import com.google.common.base.Strings;
-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.server.ChangeMessagesUtil;
-import com.google.gerrit.server.extensions.events.PrivateStateChanged;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-public class SetPrivateOp implements BatchUpdateOp {
-  public static class Input {
-    String message;
-
-    public Input() {}
-
-    public Input(String message) {
-      this.message = message;
-    }
-  }
-
-  public interface Factory {
-    SetPrivateOp create(ChangeMessagesUtil cmUtil, boolean isPrivate, Input input);
-  }
-
-  private final ChangeMessagesUtil cmUtil;
-  private final boolean isPrivate;
-  private final Input input;
-  private final PrivateStateChanged privateStateChanged;
-
-  private Change change;
-
-  @Inject
-  SetPrivateOp(
-      PrivateStateChanged privateStateChanged,
-      @Assisted ChangeMessagesUtil cmUtil,
-      @Assisted boolean isPrivate,
-      @Assisted Input input) {
-    this.cmUtil = cmUtil;
-    this.isPrivate = isPrivate;
-    this.input = input;
-    this.privateStateChanged = privateStateChanged;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
-    change = ctx.getChange();
-    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    change.setPrivate(isPrivate);
-    change.setLastUpdatedOn(ctx.getWhen());
-    update.setPrivate(isPrivate);
-    addMessage(ctx, update);
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
-    privateStateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
-  }
-
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
-    Change c = ctx.getChange();
-    StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
-
-    String m = Strings.nullToEmpty(input == null ? null : input.message).trim();
-    if (!m.isEmpty()) {
-      buf.append("\n\n");
-      buf.append(m);
-    }
-
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(
-            ctx,
-            buf.toString(),
-            c.isPrivate()
-                ? ChangeMessagesUtil.TAG_SET_PRIVATE
-                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
-    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index 8fe5612..a594086 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -14,68 +14,49 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.change.WorkInProgressOp.Input;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
-public class SetReadyForReview extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+public class SetReadyForReview extends RetryingRestModifyView<ChangeResource, Input, String>
     implements UiAction<ChangeResource> {
   private final WorkInProgressOp.Factory opFactory;
-  private final Provider<ReviewDb> db;
-  private final PermissionBackend permissionBackend;
 
   @Inject
-  SetReadyForReview(
-      RetryHelper retryHelper,
-      WorkInProgressOp.Factory opFactory,
-      Provider<ReviewDb> db,
-      PermissionBackend permissionBackend) {
+  SetReadyForReview(RetryHelper retryHelper, WorkInProgressOp.Factory opFactory) {
     super(retryHelper);
     this.opFactory = opFactory;
-    this.db = db;
-    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<String> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
-    Change change = rsrc.getChange();
-    if (!rsrc.isUserOwner()
-        && !permissionBackend.currentUser().test(GlobalPermission.ADMINISTRATE_SERVER)
-        && !permissionBackend
-            .currentUser()
-            .project(rsrc.getProject())
-            .test(ProjectPermission.WRITE_CONFIG)) {
-      throw new AuthException("not allowed to set ready for review");
-    }
+    rsrc.permissions().check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
 
-    if (change.getStatus() != Status.NEW) {
+    Change change = rsrc.getChange();
+    if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
 
@@ -84,7 +65,8 @@
     }
 
     try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
       bu.execute();
       return Response.ok("");
@@ -98,16 +80,7 @@
         .setTitle("Set Ready For Review")
         .setVisible(
             and(
-                rsrc.getChange().getStatus() == Status.NEW && rsrc.getChange().isWorkInProgress(),
-                or(
-                    rsrc.isUserOwner(),
-                    or(
-                        permissionBackend
-                            .currentUser()
-                            .testCond(GlobalPermission.ADMINISTRATE_SERVER),
-                        permissionBackend
-                            .currentUser()
-                            .project(rsrc.getProject())
-                            .testCond(ProjectPermission.WRITE_CONFIG)))));
+                rsrc.getChange().isNew() && rsrc.getChange().isWorkInProgress(),
+                rsrc.permissions().testCond(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE)));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index 9524903..865ca64 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -14,69 +14,49 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.change.WorkInProgressOp.Input;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
-public class SetWorkInProgress extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+public class SetWorkInProgress extends RetryingRestModifyView<ChangeResource, Input, String>
     implements UiAction<ChangeResource> {
   private final WorkInProgressOp.Factory opFactory;
-  private final Provider<ReviewDb> db;
-  private final PermissionBackend permissionBackend;
 
   @Inject
-  SetWorkInProgress(
-      WorkInProgressOp.Factory opFactory,
-      RetryHelper retryHelper,
-      Provider<ReviewDb> db,
-      PermissionBackend permissionBackend) {
+  SetWorkInProgress(WorkInProgressOp.Factory opFactory, RetryHelper retryHelper) {
     super(retryHelper);
     this.opFactory = opFactory;
-    this.db = db;
-    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  protected Response<?> applyImpl(
+  protected Response<String> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
+
     Change change = rsrc.getChange();
-
-    if (!rsrc.isUserOwner()
-        && !permissionBackend.currentUser().test(GlobalPermission.ADMINISTRATE_SERVER)
-        && !permissionBackend
-            .currentUser()
-            .project(rsrc.getProject())
-            .test(ProjectPermission.WRITE_CONFIG)) {
-      throw new AuthException("not allowed to set work in progress");
-    }
-
-    if (change.getStatus() != Status.NEW) {
+    if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
 
@@ -85,7 +65,8 @@
     }
 
     try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
       bu.execute();
       return Response.ok("");
@@ -99,16 +80,7 @@
         .setTitle("Set Work In Progress")
         .setVisible(
             and(
-                rsrc.getChange().getStatus() == Status.NEW && !rsrc.getChange().isWorkInProgress(),
-                or(
-                    rsrc.isUserOwner(),
-                    or(
-                        permissionBackend
-                            .currentUser()
-                            .testCond(GlobalPermission.ADMINISTRATE_SERVER),
-                        permissionBackend
-                            .currentUser()
-                            .project(rsrc.getProject())
-                            .testCond(ProjectPermission.WRITE_CONFIG)))));
+                rsrc.getChange().isNew() && !rsrc.getChange().isWorkInProgress(),
+                rsrc.permissions().testCond(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE)));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index a161767..8df290e 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.MoreObjects;
@@ -23,25 +24,26 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ProjectUtil;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -55,13 +57,10 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.submit.ChangeSet;
 import com.google.gerrit.server.submit.MergeOp;
 import com.google.gerrit.server.submit.MergeSuperSet;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -70,7 +69,6 @@
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -107,14 +105,13 @@
     }
   }
 
-  private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final String label;
   private final String labelWithParents;
   private final ParameterizedString titlePattern;
@@ -128,26 +125,24 @@
 
   @Inject
   Submit(
-      Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeSuperSet> mergeSuperSet,
-      AccountsCollection accounts,
+      AccountResolver accountResolver,
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
       ProjectCache projectCache) {
-    this.dbProvider = dbProvider;
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.changeNotesFactory = changeNotesFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
-    this.accounts = accounts;
+    this.accountResolver = accountResolver;
     this.label =
         MoreObjects.firstNonNull(
             Strings.emptyToNull(cfg.getString("change", null, "submitLabel")), "Submit");
@@ -179,9 +174,9 @@
   }
 
   @Override
-  public Output apply(RevisionResource rsrc, SubmitInput input)
-      throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
-          PermissionBackendException, UpdateException, ConfigInvalidException {
+  public Response<Output> apply(RevisionResource rsrc, SubmitInput input)
+      throws RestApiException, RepositoryNotFoundException, IOException, PermissionBackendException,
+          UpdateException, ConfigInvalidException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
     IdentifiedUser submitter;
     if (input.onBehalfOf != null) {
@@ -192,46 +187,44 @@
     }
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
-    return new Output(mergeChange(rsrc, submitter, input));
+    return Response.ok(new Output(mergeChange(rsrc, submitter, input)));
   }
 
   public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
-      throws OrmException, RestApiException, IOException, UpdateException, ConfigInvalidException,
+      throws RestApiException, IOException, UpdateException, ConfigInvalidException,
           PermissionBackendException {
     Change change = rsrc.getChange();
-    if (!change.getStatus().isOpen()) {
+    if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
       throw new ResourceConflictException(
-          String.format("destination branch \"%s\" not found.", change.getDest().get()));
-    } else if (!rsrc.getPatchSet().getId().equals(change.currentPatchSetId())) {
+          String.format("destination branch \"%s\" not found.", change.getDest().branch()));
+    } else if (!rsrc.getPatchSet().id().equals(change.currentPatchSetId())) {
       // TODO Allow submitting non-current revision by changing the current.
       throw new ResourceConflictException(
           String.format(
-              "revision %s is not current revision", rsrc.getPatchSet().getRevision().get()));
+              "revision %s is not current revision", rsrc.getPatchSet().commitId().name()));
     }
 
     try (MergeOp op = mergeOpProvider.get()) {
-      ReviewDb db = dbProvider.get();
-      op.merge(db, change, submitter, true, input, false);
-      try {
-        change =
-            changeNotesFactory.createChecked(db, change.getProject(), change.getId()).getChange();
-      } catch (NoSuchChangeException e) {
-        throw new ResourceConflictException("change is deleted");
-      }
+      op.merge(change, submitter, true, input, false);
     }
 
-    switch (change.getStatus()) {
-      case MERGED:
-        return change;
-      case NEW:
-        throw new RestApiException(
-            "change unexpectedly had status " + change.getStatus() + " after submit attempt");
-      case ABANDONED:
-      default:
-        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    // Read the ChangeNotes only after MergeOp is fully done (including MergeOp#close) to be sure
+    // to have the correct state of the repo.
+    try {
+      change = changeNotesFactory.createChecked(change.getProject(), change.getId()).getChange();
+    } catch (NoSuchChangeException e) {
+      throw new ResourceConflictException("change is deleted");
     }
+
+    if (change.isMerged()) {
+      return change;
+    }
+    if (change.isNew()) {
+      throw new RestApiException("change unexpectedly had status NEW after submit attempt");
+    }
+    throw new ResourceConflictException("change is " + ChangeUtil.status(change));
   }
 
   /**
@@ -248,21 +241,21 @@
   private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
     try {
       if (cs.furtherHiddenChanges()) {
+        logger.atFine().log(
+            "Change %d cannot be submitted by user %s because it depends on hidden changes: %s",
+            cd.getId().get(), user.getLoggableName(), cs.nonVisibleChanges());
         return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
       }
       for (ChangeData c : cs.changes()) {
-        if (cd.getId().equals(c.getId())) {
-          // We ignore the change about to be submitted, as these checks are already done in the
-          // #apply and #getDescription methods.
-          continue;
-        }
         Set<ChangePermission> can =
             permissionBackend
                 .user(user)
-                .database(dbProvider)
                 .change(c)
                 .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
         if (!can.contains(ChangePermission.READ)) {
+          logger.atFine().log(
+              "Change %d cannot be submitted by user %s because it depends on change %d which the user cannot read",
+              cd.getId().get(), user.getLoggableName(), c.getId().get());
           return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
         }
         if (!can.contains(ChangePermission.SUBMIT)) {
@@ -291,9 +284,9 @@
         return "Problems with change(s): "
             + unmergeable.stream().map(c -> c.getId().toString()).collect(joining(", "));
       }
-    } catch (PermissionBackendException | OrmException | IOException e) {
+    } catch (PermissionBackendException | IOException e) {
       logger.atSevere().withCause(e).log("Error checking if change is submittable");
-      throw new OrmRuntimeException("Could not determine problems for the change", e);
+      throw new StorageException("Could not determine problems for the change", e);
     }
     return null;
   }
@@ -301,10 +294,7 @@
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
     Change change = resource.getChange();
-    if (!change.getStatus().isOpen()
-        || change.isWorkInProgress()
-        || !resource.isCurrent()
-        || !resource.permissions().testOrFalse(ChangePermission.SUBMIT)) {
+    if (!change.isNew() || !resource.isCurrent()) {
       return null; // submit not visible
     }
 
@@ -314,51 +304,39 @@
       }
     } catch (IOException e) {
       logger.atSevere().withCause(e).log("Error checking if change is submittable");
-      throw new OrmRuntimeException("Could not determine problems for the change", e);
+      throw new StorageException("Could not determine problems for the change", e);
     }
 
-    ReviewDb db = dbProvider.get();
-    ChangeData cd = changeDataFactory.create(db, resource.getNotes());
+    ChangeData cd = changeDataFactory.create(resource.getNotes());
     try {
       MergeOp.checkSubmitRule(cd, false);
     } catch (ResourceConflictException e) {
       return null; // submit not visible
-    } catch (OrmException e) {
-      logger.atSevere().withCause(e).log("Error checking if change is submittable");
-      throw new OrmRuntimeException("Could not determine problems for the change", e);
     }
 
     ChangeSet cs;
     try {
-      cs = mergeSuperSet.get().completeChangeSet(db, cd.change(), resource.getUser());
-    } catch (OrmException | IOException | PermissionBackendException e) {
-      throw new OrmRuntimeException(
-          "Could not determine complete set of changes to be submitted", e);
+      cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
+    } catch (IOException | PermissionBackendException e) {
+      throw new StorageException("Could not determine complete set of changes to be submitted", e);
     }
 
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
-      topicSize = getChangesByTopic(topic).size();
+      topicSize = queryProvider.get().noFields().byTopicOpen(topic).size();
     }
     boolean treatWithTopic = submitWholeTopic && !Strings.isNullOrEmpty(topic) && topicSize > 1;
 
     String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
 
-    Boolean enabled;
-    try {
-      // Recheck mergeability rather than using value stored in the index,
-      // which may be stale.
-      // TODO(dborowitz): This is ugly; consider providing a way to not read
-      // stored fields from the index in the first place.
-      // cd.setMergeable(null);
-      // That was done in unmergeableChanges which was called by
-      // problemsForSubmittingChangeset, so now it is safe to read from
-      // the cache, as it yields the same result.
-      enabled = cd.isMergeable();
-    } catch (OrmException e) {
-      throw new OrmRuntimeException("Could not determine mergeability", e);
-    }
+    // Recheck mergeability rather than using value stored in the index, which may be stale.
+    // TODO(dborowitz): This is ugly; consider providing a way to not read stored fields from the
+    // index in the first place.
+    // cd.setMergeable(null);
+    // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
+    // now it is safe to read from the cache, as it yields the same result.
+    Boolean enabled = cd.isMergeable();
 
     if (submitProblems != null) {
       return new UiAction.Description()
@@ -379,12 +357,11 @@
           .setVisible(true)
           .setEnabled(Boolean.TRUE.equals(enabled));
     }
-    RevId revId = resource.getPatchSet().getRevision();
     Map<String, String> params =
         ImmutableMap.of(
-            "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
-            "branch", change.getDest().getShortName(),
-            "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
+            "patchSet", String.valueOf(resource.getPatchSet().number()),
+            "branch", change.getDest().shortName(),
+            "commit", abbreviateName(resource.getPatchSet().commitId()),
             "submitSize", String.valueOf(cs.size()));
     ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
     return new UiAction.Description()
@@ -394,16 +371,16 @@
         .setEnabled(Boolean.TRUE.equals(enabled));
   }
 
-  public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws OrmException, IOException {
+  public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
     for (ChangeData change : cs.changes()) {
       mergeabilityMap.add(change);
     }
 
-    ListMultimap<Branch.NameKey, ChangeData> cbb = cs.changesByBranch();
-    for (Branch.NameKey branch : cbb.keySet()) {
+    ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
+    for (BranchNameKey branch : cbb.keySet()) {
       Collection<ChangeData> targetBranch = cbb.get(branch);
-      HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.getParentKey());
+      HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.project());
 
       Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
       for (RevCommit commit : commits.values()) {
@@ -443,15 +420,12 @@
   }
 
   private HashMap<Change.Id, RevCommit> findCommits(
-      Collection<ChangeData> changes, Project.NameKey project) throws IOException, OrmException {
+      Collection<ChangeData> changes, Project.NameKey project) throws IOException {
     HashMap<Change.Id, RevCommit> commits = new HashMap<>();
     try (Repository repo = repoManager.openRepository(project);
         RevWalk walk = new RevWalk(repo)) {
       for (ChangeData change : changes) {
-        RevCommit commit =
-            walk.parseCommit(
-                ObjectId.fromString(
-                    psUtil.current(dbProvider.get(), change.notes()).getRevision().get()));
+        RevCommit commit = walk.parseCommit(psUtil.current(change.notes()).commitId());
         commits.put(change.getId(), commit);
       }
     }
@@ -459,16 +433,17 @@
   }
 
   private IdentifiedUser onBehalfOf(RevisionResource rsrc, SubmitInput in)
-      throws AuthException, UnprocessableEntityException, OrmException, PermissionBackendException,
-          IOException, ConfigInvalidException {
-    PermissionBackend.ForChange perm = rsrc.permissions().database(dbProvider);
+      throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
+          ConfigInvalidException {
+    PermissionBackend.ForChange perm = rsrc.permissions();
     perm.check(ChangePermission.SUBMIT);
     perm.check(ChangePermission.SUBMIT_AS);
 
     CurrentUser caller = rsrc.getUser();
-    IdentifiedUser submitter = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    IdentifiedUser submitter =
+        accountResolver.resolve(in.onBehalfOf).asUniqueUserOnBehalfOf(caller);
     try {
-      perm.user(submitter).check(ChangePermission.READ);
+      permissionBackend.user(submitter).change(rsrc.getNotes()).check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new UnprocessableEntityException(
           String.format("on_behalf_of account %s cannot see change", submitter.getAccountId()));
@@ -476,43 +451,29 @@
     return submitter;
   }
 
-  private List<ChangeData> getChangesByTopic(String topic) {
-    try {
-      return queryProvider.get().byTopicOpen(topic);
-    } catch (OrmException e) {
-      throw new OrmRuntimeException(e);
-    }
-  }
-
   public static class CurrentRevision implements RestModifyView<ChangeResource, SubmitInput> {
-    private final Provider<ReviewDb> dbProvider;
     private final Submit submit;
     private final ChangeJson.Factory json;
     private final PatchSetUtil psUtil;
 
     @Inject
-    CurrentRevision(
-        Provider<ReviewDb> dbProvider,
-        Submit submit,
-        ChangeJson.Factory json,
-        PatchSetUtil psUtil) {
-      this.dbProvider = dbProvider;
+    CurrentRevision(Submit submit, ChangeJson.Factory json, PatchSetUtil psUtil) {
       this.submit = submit;
       this.json = json;
       this.psUtil = psUtil;
     }
 
     @Override
-    public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
-        throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
+    public Response<ChangeInfo> apply(ChangeResource rsrc, SubmitInput input)
+        throws RestApiException, RepositoryNotFoundException, IOException,
             PermissionBackendException, UpdateException, ConfigInvalidException {
-      PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes());
+      PatchSet ps = psUtil.current(rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
       }
 
-      Output out = submit.apply(new RevisionResource(rsrc, ps), input);
-      return json.noOptions().format(out.change);
+      Output out = submit.apply(new RevisionResource(rsrc, ps), input).value();
+      return Response.ok(json.noOptions().format(out.change));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 4ced4c2..a9ec256 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -19,16 +19,16 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
-import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 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.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.WalkSorter;
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.submit.ChangeSet;
 import com.google.gerrit.server.submit.MergeSuperSet;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -59,22 +58,19 @@
       EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.SUBMITTABLE);
 
   private static final Comparator<ChangeData> COMPARATOR =
-      Comparator.comparing(ChangeData::project).thenComparing(cd -> cd.getId().id, reverseOrder());
+      Comparator.comparing(ChangeData::project)
+          .thenComparing(cd -> cd.getId().get(), reverseOrder());
 
   private final ChangeJson.Factory json;
-  private final Provider<ReviewDb> dbProvider;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
   private final Provider<WalkSorter> sorter;
 
-  private boolean lazyLoad = false;
-
   @Option(name = "-o", usage = "Output options")
   void addOption(String option) {
     for (ListChangesOption o : ListChangesOption.values()) {
       if (o.name().equalsIgnoreCase(option)) {
         jsonOpt.add(o);
-        lazyLoad |= ChangeJson.REQUIRE_LAZY_LOAD.contains(o);
         return;
       }
     }
@@ -92,12 +88,10 @@
   @Inject
   SubmittedTogether(
       ChangeJson.Factory json,
-      Provider<ReviewDb> dbProvider,
       Provider<InternalChangeQuery> queryProvider,
       Provider<MergeSuperSet> mergeSuperSet,
       Provider<WalkSorter> sorter) {
     this.json = json;
-    this.dbProvider = dbProvider;
     this.queryProvider = queryProvider;
     this.mergeSuperSet = mergeSuperSet;
     this.sorter = sorter;
@@ -114,29 +108,28 @@
   }
 
   @Override
-  public Object apply(ChangeResource resource)
+  public Response<Object> apply(ChangeResource resource)
       throws AuthException, BadRequestException, ResourceConflictException, IOException,
-          OrmException, PermissionBackendException {
+          PermissionBackendException {
     SubmittedTogetherInfo info = applyInfo(resource);
     if (options.isEmpty()) {
-      return info.changes;
+      return Response.ok(info.changes);
     }
-    return info;
+    return Response.ok(info);
   }
 
   public SubmittedTogetherInfo applyInfo(ChangeResource resource)
-      throws AuthException, IOException, OrmException, PermissionBackendException {
+      throws AuthException, IOException, PermissionBackendException {
     Change c = resource.getChange();
     try {
       List<ChangeData> cds;
       int hidden;
 
-      if (c.getStatus().isOpen()) {
-        ChangeSet cs =
-            mergeSuperSet.get().completeChangeSet(dbProvider.get(), c, resource.getUser());
+      if (c.isNew()) {
+        ChangeSet cs = mergeSuperSet.get().completeChangeSet(c, resource.getUser());
         cds = ensureRequiredDataIsLoaded(cs.changes().asList());
         hidden = cs.nonVisibleChanges().size();
-      } else if (c.getStatus().asChangeStatus() == ChangeStatus.MERGED) {
+      } else if (c.isMerged()) {
         cds = queryProvider.get().bySubmissionId(c.getSubmissionId());
         hidden = 0;
       } else {
@@ -150,16 +143,16 @@
 
       cds = sort(cds, hidden);
       SubmittedTogetherInfo info = new SubmittedTogetherInfo();
-      info.changes = json.create(jsonOpt).lazyLoad(lazyLoad).formatChangeDatas(cds);
+      info.changes = json.create(jsonOpt).format(cds);
       info.nonVisibleChanges = hidden;
       return info;
-    } catch (OrmException | IOException e) {
+    } catch (StorageException | IOException e) {
       logger.atSevere().withCause(e).log("Error on getting a ChangeSet");
       throw e;
     }
   }
 
-  private List<ChangeData> sort(List<ChangeData> cds, int hidden) throws OrmException, IOException {
+  private List<ChangeData> sort(List<ChangeData> cds, int hidden) throws IOException {
     if (cds.size() <= 1 && hidden == 0) {
       // Skip sorting for singleton lists, to avoid WalkSorter opening the
       // repo just to fill out the commit field in PatchSetData.
@@ -184,8 +177,7 @@
     return sorted;
   }
 
-  private static List<ChangeData> ensureRequiredDataIsLoaded(List<ChangeData> cds)
-      throws OrmException {
+  private static List<ChangeData> ensureRequiredDataIsLoaded(List<ChangeData> cds) {
     // TODO(hiesel): Instead of calling these manually, either implement a helper that brings a
     // database-backed change on-par with an index-backed change in terms of the populated fields in
     // ChangeData or check if any of the ChangeDatas was loaded from the database and allow
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
index bc3dfa7..213bae9 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -18,12 +18,9 @@
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -31,7 +28,6 @@
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.change.ReviewersUtil.VisibilityControl;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -56,44 +52,42 @@
   @Inject
   SuggestChangeReviewers(
       AccountVisibility av,
-      GenericFactory identifiedUserFactory,
-      Provider<ReviewDb> dbProvider,
       PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       @GerritServerConfig Config cfg,
       ReviewersUtil reviewersUtil,
       ProjectCache projectCache) {
-    super(av, identifiedUserFactory, dbProvider, cfg, reviewersUtil);
+    super(av, cfg, reviewersUtil);
     this.permissionBackend = permissionBackend;
     this.self = self;
     this.projectCache = projectCache;
   }
 
   @Override
-  public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
-      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
+  public Response<List<SuggestedReviewerInfo>> apply(ChangeResource rsrc)
+      throws AuthException, BadRequestException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (!self.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    return reviewersUtil.suggestReviewers(
-        rsrc.getNotes(),
-        this,
-        projectCache.checkedGet(rsrc.getProject()),
-        getVisibility(rsrc),
-        excludeGroups);
+    return Response.ok(
+        reviewersUtil.suggestReviewers(
+            rsrc.getNotes(),
+            this,
+            projectCache.checkedGet(rsrc.getProject()),
+            getVisibility(rsrc),
+            excludeGroups));
   }
 
   private VisibilityControl getVisibility(ChangeResource rsrc) {
-    // Use the destination reference, not the change, as private changes deny anyone who is not
-    // already a reviewer.
-    PermissionBackend.ForRef perm = permissionBackend.currentUser().ref(rsrc.getChange().getDest());
-    return new VisibilityControl() {
-      @Override
-      public boolean isVisibleTo(Account.Id account) throws OrmException {
-        IdentifiedUser who = identifiedUserFactory.create(account);
-        return perm.user(who).testOrFalse(RefPermission.READ);
-      }
+
+    return account -> {
+      // Use the destination reference, not the change, as private changes deny anyone who is not
+      // already a reviewer.
+      return permissionBackend
+          .absentUser(account)
+          .ref(rsrc.getChange().getDest())
+          .testOrFalse(RefPermission.READ);
     };
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index ac8e81c..e071c89 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -16,22 +16,21 @@
 
 import static com.google.gerrit.server.config.GerritConfigListenerHelper.acceptIfChanged;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.server.config.ConfigKey;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import org.eclipse.jgit.lib.Config;
 import org.kohsuke.args4j.Option;
 
 public class SuggestReviewers {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final int DEFAULT_MAX_SUGGESTED = 10;
 
-  protected final Provider<ReviewDb> dbProvider;
-  protected final IdentifiedUser.GenericFactory identifiedUserFactory;
   protected final ReviewersUtil reviewersUtil;
 
   private final boolean suggestAccounts;
@@ -81,13 +80,7 @@
 
   @Inject
   public SuggestReviewers(
-      AccountVisibility av,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      Provider<ReviewDb> dbProvider,
-      @GerritServerConfig Config cfg,
-      ReviewersUtil reviewersUtil) {
-    this.dbProvider = dbProvider;
-    this.identifiedUserFactory = identifiedUserFactory;
+      AccountVisibility av, @GerritServerConfig Config cfg, ReviewersUtil reviewersUtil) {
     this.reviewersUtil = reviewersUtil;
     this.maxSuggestedReviewers =
         cfg.getInt("suggest", "maxSuggestedReviewers", DEFAULT_MAX_SUGGESTED);
@@ -99,12 +92,14 @@
       this.suggestAccounts = (av != AccountVisibility.NONE);
     }
 
-    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", PostReviewers.DEFAULT_MAX_REVIEWERS);
+    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", ReviewerAdder.DEFAULT_MAX_REVIEWERS);
     this.maxAllowedWithoutConfirmation =
         cfg.getInt(
             "addreviewer",
             "maxWithoutConfirmation",
-            PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+            ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+
+    logger.atFine().log("AccountVisibility: %s", av.name());
   }
 
   public static GerritConfigListener configListener() {
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index 2a18612..afd02a9 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -15,55 +15,62 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
+import com.google.gerrit.server.rules.PrologRule;
 import com.google.gerrit.server.rules.RulesCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.Map;
 import org.kohsuke.args4j.Option;
 
 public class TestSubmitRule implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
-  private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final RulesCache rules;
   private final AccountLoader.Factory accountInfoFactory;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+  private final ProjectCache projectCache;
+  private final DefaultSubmitRule defaultSubmitRule;
+  private final PrologRule prologRule;
 
   @Option(name = "--filters", usage = "impact of filters in parent projects")
   private Filters filters = Filters.RUN;
 
   @Inject
   TestSubmitRule(
-      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       RulesCache rules,
       AccountLoader.Factory infoFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
-    this.db = db;
+      ProjectCache projectCache,
+      DefaultSubmitRule defaultSubmitRule,
+      PrologRule prologRule) {
     this.changeDataFactory = changeDataFactory;
     this.rules = rules;
     this.accountInfoFactory = infoFactory;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+    this.projectCache = projectCache;
+    this.defaultSubmitRule = defaultSubmitRule;
+    this.prologRule = prologRule;
   }
 
   @Override
-  public List<Record> apply(RevisionResource rsrc, TestSubmitRuleInput input)
-      throws AuthException, OrmException {
+  public Response<List<TestSubmitRuleInfo>> apply(RevisionResource rsrc, TestSubmitRuleInput input)
+      throws AuthException, PermissionBackendException, BadRequestException {
     if (input == null) {
       input = new TestSubmitRuleInput();
     }
@@ -79,74 +86,76 @@
             .logErrors(false)
             .build();
 
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    List<SubmitRecord> records = submitRuleEvaluatorFactory.create(opts).evaluate(cd);
+    ProjectState projectState = projectCache.get(rsrc.getProject());
+    if (projectState == null) {
+      throw new BadRequestException("project not found");
+    }
+    ChangeData cd = changeDataFactory.create(rsrc.getNotes());
+    List<SubmitRecord> records;
+    if (projectState.hasPrologRules() || input.rule != null) {
+      records = ImmutableList.copyOf(prologRule.evaluate(cd, opts));
+    } else {
+      // No rules were provided as input and we have no rules.pl. This means we are supposed to run
+      // the default rules. Nowadays, the default rules are implemented in Java, not Prolog.
+      // Therefore, we call the DefaultRuleEvaluator instead.
+      records = ImmutableList.copyOf(defaultSubmitRule.evaluate(cd, opts));
+    }
 
-    List<Record> out = Lists.newArrayListWithCapacity(records.size());
+    List<TestSubmitRuleInfo> out = Lists.newArrayListWithCapacity(records.size());
     AccountLoader accounts = accountInfoFactory.create(true);
     for (SubmitRecord r : records) {
-      out.add(new Record(r, accounts));
+      out.add(newSubmitRuleInfo(r, accounts));
     }
     accounts.fill();
-    return out;
+    return Response.ok(out);
   }
 
-  static class Record {
-    SubmitRecord.Status status;
-    String errorMessage;
-    Map<String, AccountInfo> ok;
-    Map<String, AccountInfo> reject;
-    Map<String, None> need;
-    Map<String, AccountInfo> may;
-    Map<String, None> impossible;
+  private static TestSubmitRuleInfo newSubmitRuleInfo(SubmitRecord r, AccountLoader accounts) {
+    TestSubmitRuleInfo info = new TestSubmitRuleInfo();
+    info.status = r.status.name();
+    info.errorMessage = r.errorMessage;
 
-    Record(SubmitRecord r, AccountLoader accounts) {
-      this.status = r.status;
-      this.errorMessage = r.errorMessage;
+    if (r.labels != null) {
+      for (SubmitRecord.Label n : r.labels) {
+        AccountInfo who = n.appliedBy != null ? accounts.get(n.appliedBy) : new AccountInfo(null);
+        label(info, n, who);
+      }
+    }
+    return info;
+  }
 
-      if (r.labels != null) {
-        for (SubmitRecord.Label n : r.labels) {
-          AccountInfo who = n.appliedBy != null ? accounts.get(n.appliedBy) : new AccountInfo(null);
-          label(n, who);
+  private static void label(TestSubmitRuleInfo info, SubmitRecord.Label n, AccountInfo who) {
+    switch (n.status) {
+      case OK:
+        if (info.ok == null) {
+          info.ok = new LinkedHashMap<>();
         }
-      }
-    }
-
-    private void label(SubmitRecord.Label n, AccountInfo who) {
-      switch (n.status) {
-        case OK:
-          if (ok == null) {
-            ok = new LinkedHashMap<>();
-          }
-          ok.put(n.label, who);
-          break;
-        case REJECT:
-          if (reject == null) {
-            reject = new LinkedHashMap<>();
-          }
-          reject.put(n.label, who);
-          break;
-        case NEED:
-          if (need == null) {
-            need = new LinkedHashMap<>();
-          }
-          need.put(n.label, new None());
-          break;
-        case MAY:
-          if (may == null) {
-            may = new LinkedHashMap<>();
-          }
-          may.put(n.label, who);
-          break;
-        case IMPOSSIBLE:
-          if (impossible == null) {
-            impossible = new LinkedHashMap<>();
-          }
-          impossible.put(n.label, new None());
-          break;
-      }
+        info.ok.put(n.label, who);
+        break;
+      case REJECT:
+        if (info.reject == null) {
+          info.reject = new LinkedHashMap<>();
+        }
+        info.reject.put(n.label, who);
+        break;
+      case NEED:
+        if (info.need == null) {
+          info.need = new LinkedHashMap<>();
+        }
+        info.need.put(n.label, TestSubmitRuleInfo.None.INSTANCE);
+        break;
+      case MAY:
+        if (info.may == null) {
+          info.may = new LinkedHashMap<>();
+        }
+        info.may.put(n.label, who);
+        break;
+      case IMPOSSIBLE:
+        if (info.impossible == null) {
+          info.impossible = new LinkedHashMap<>();
+        }
+        info.impossible.put(n.label, TestSubmitRuleInfo.None.INSTANCE);
+        break;
     }
   }
-
-  static class None {}
 }
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
index c1be1ce..9e8ee67 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -21,21 +21,18 @@
 import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.RulesCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import org.kohsuke.args4j.Option;
 
 public class TestSubmitType implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
-  private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final RulesCache rules;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
@@ -45,19 +42,17 @@
 
   @Inject
   TestSubmitType(
-      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       RulesCache rules,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
-    this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.rules = rules;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
   }
 
   @Override
-  public SubmitType apply(RevisionResource rsrc, TestSubmitRuleInput input)
-      throws AuthException, BadRequestException, OrmException {
+  public Response<SubmitType> apply(RevisionResource rsrc, TestSubmitRuleInput input)
+      throws AuthException, BadRequestException {
     if (input == null) {
       input = new TestSubmitRuleInput();
     }
@@ -74,14 +69,14 @@
             .build();
 
     SubmitRuleEvaluator evaluator = submitRuleEvaluatorFactory.create(opts);
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
+    ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     SubmitTypeRecord rec = evaluator.getSubmitType(cd);
 
     if (rec.status != SubmitTypeRecord.Status.OK) {
       throw new BadRequestException(String.format("rule produced invalid result: %s", rec));
     }
 
-    return rec.type;
+    return Response.ok(rec.type);
   }
 
   public static class Get implements RestReadView<RevisionResource> {
@@ -93,8 +88,8 @@
     }
 
     @Override
-    public SubmitType apply(RevisionResource resource)
-        throws AuthException, BadRequestException, OrmException {
+    public Response<SubmitType> apply(RevisionResource resource)
+        throws AuthException, BadRequestException {
       return test.apply(resource, null);
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Unignore.java b/java/com/google/gerrit/server/restapi/change/Unignore.java
index 6f2144a..26d3233 100644
--- a/java/com/google/gerrit/server/restapi/change/Unignore.java
+++ b/java/com/google/gerrit/server/restapi/change/Unignore.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -22,7 +23,6 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -46,8 +46,7 @@
   }
 
   @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws OrmException, IllegalLabelException {
+  public Response<String> apply(ChangeResource rsrc, Input input) throws IllegalLabelException {
     if (isIgnored(rsrc)) {
       stars.unignore(rsrc);
     }
@@ -57,7 +56,7 @@
   private boolean isIgnored(ChangeResource rsrc) {
     try {
       return stars.isIgnored(rsrc);
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("failed to check ignored star");
     }
     return false;
diff --git a/java/com/google/gerrit/server/restapi/change/Votes.java b/java/com/google/gerrit/server/restapi/change/Votes.java
index b931c7e..1cf51ab 100644
--- a/java/com/google/gerrit/server/restapi/change/Votes.java
+++ b/java/com/google/gerrit/server/restapi/change/Votes.java
@@ -20,16 +20,14 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 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.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.Map;
 import java.util.TreeMap;
@@ -57,7 +55,7 @@
 
   @Override
   public VoteResource parse(ReviewerResource reviewer, IdString id)
-      throws ResourceNotFoundException, OrmException, AuthException, MethodNotAllowedException {
+      throws ResourceNotFoundException, AuthException, MethodNotAllowedException {
     if (reviewer.getRevisionResource() != null && !reviewer.getRevisionResource().isCurrent()) {
       throw new MethodNotAllowedException("Cannot access on non-current patch set");
     }
@@ -66,18 +64,16 @@
 
   @Singleton
   public static class List implements RestReadView<ReviewerResource> {
-    private final Provider<ReviewDb> db;
     private final ApprovalsUtil approvalsUtil;
 
     @Inject
-    List(Provider<ReviewDb> db, ApprovalsUtil approvalsUtil) {
-      this.db = db;
+    List(ApprovalsUtil approvalsUtil) {
       this.approvalsUtil = approvalsUtil;
     }
 
     @Override
-    public Map<String, Short> apply(ReviewerResource rsrc)
-        throws OrmException, MethodNotAllowedException {
+    public Response<Map<String, Short>> apply(ReviewerResource rsrc)
+        throws MethodNotAllowedException {
       if (rsrc.getRevisionResource() != null && !rsrc.getRevisionResource().isCurrent()) {
         throw new MethodNotAllowedException("Cannot list votes on non-current patch set");
       }
@@ -85,17 +81,15 @@
       Map<String, Short> votes = new TreeMap<>();
       Iterable<PatchSetApproval> byPatchSetUser =
           approvalsUtil.byPatchSetUser(
-              db.get(),
               rsrc.getChangeResource().getNotes(),
-              rsrc.getChangeResource().getUser(),
               rsrc.getChange().currentPatchSetId(),
               rsrc.getReviewerUser().getAccountId(),
               null,
               null);
       for (PatchSetApproval psa : byPatchSetUser) {
-        votes.put(psa.getLabel(), psa.getValue());
+        votes.put(psa.label(), psa.value());
       }
-      return votes;
+      return Response.ok(votes);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/AgreementJson.java b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
index 548bc03..d5c085b 100644
--- a/java/com/google/gerrit/server/restapi/config/AgreementJson.java
+++ b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
@@ -17,14 +17,15 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.GroupJson;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -48,7 +49,7 @@
     this.groupJson = groupJson;
   }
 
-  public AgreementInfo format(ContributorAgreement ca) {
+  public AgreementInfo format(ContributorAgreement ca) throws PermissionBackendException {
     AgreementInfo info = new AgreementInfo();
     info.name = ca.getName();
     info.description = ca.getDescription();
@@ -60,7 +61,7 @@
         GroupControl gc = genericGroupControlFactory.controlFor(user, autoVerifyGroup.getUUID());
         GroupResource group = new GroupResource(gc);
         info.autoVerifyGroup = groupJson.format(group);
-      } catch (NoSuchGroupException | OrmException e) {
+      } catch (NoSuchGroupException | StorageException e) {
         logger.atWarning().log(
             "autoverify group \"%s\" does not exist, referenced in CLA \"%s\"",
             autoVerifyGroup.getName(), ca.getName());
diff --git a/java/com/google/gerrit/server/restapi/config/CachesCollection.java b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
index e3d9e3c..a4b8802 100644
--- a/java/com/google/gerrit/server/restapi/config/CachesCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
@@ -20,12 +20,11 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.config.CacheResource;
 import com.google.gerrit.server.config.ConfigResource;
@@ -38,27 +37,23 @@
 
 @RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
 @Singleton
-public class CachesCollection
-    implements ChildCollection<ConfigResource, CacheResource>, AcceptsPost<ConfigResource> {
+public class CachesCollection implements ChildCollection<ConfigResource, CacheResource> {
 
   private final DynamicMap<RestView<CacheResource>> views;
   private final Provider<ListCaches> list;
   private final PermissionBackend permissionBackend;
   private final DynamicMap<Cache<?, ?>> cacheMap;
-  private final PostCaches postCaches;
 
   @Inject
   CachesCollection(
       DynamicMap<RestView<CacheResource>> views,
       Provider<ListCaches> list,
       PermissionBackend permissionBackend,
-      DynamicMap<Cache<?, ?>> cacheMap,
-      PostCaches postCaches) {
+      DynamicMap<Cache<?, ?>> cacheMap) {
     this.views = views;
     this.list = list;
     this.permissionBackend = permissionBackend;
     this.cacheMap = cacheMap;
-    this.postCaches = postCaches;
   }
 
   @Override
@@ -72,7 +67,7 @@
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
 
     String cacheName = id.get();
-    String pluginName = "gerrit";
+    String pluginName = PluginName.GERRIT;
     int i = cacheName.lastIndexOf('-');
     if (i != -1) {
       pluginName = cacheName.substring(0, i);
@@ -90,9 +85,4 @@
   public DynamicMap<RestView<CacheResource>> views() {
     return views;
   }
-
-  @Override
-  public PostCaches post(ConfigResource parent) throws RestApiException {
-    return postCaches;
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/CheckConsistency.java b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
index a16736b..50e774a 100644
--- a/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
+++ b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckGroupsResultInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountsConsistencyChecker;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 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;
@@ -55,9 +55,8 @@
   }
 
   @Override
-  public ConsistencyCheckInfo apply(ConfigResource resource, ConsistencyCheckInput input)
-      throws RestApiException, IOException, OrmException, PermissionBackendException,
-          ConfigInvalidException {
+  public Response<ConsistencyCheckInfo> apply(ConfigResource resource, ConsistencyCheckInput input)
+      throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException {
     permissionBackend.currentUser().check(GlobalPermission.ACCESS_DATABASE);
 
     if (input == null
@@ -82,6 +81,6 @@
           new CheckGroupsResultInfo(groupsConsistencyChecker.check());
     }
 
-    return consistencyCheckInfo;
+    return Response.ok(consistencyCheckInfo);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
index 5a1592f..152a4db 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.mail.EmailTokenVerifier;
 import com.google.gerrit.server.restapi.config.ConfirmEmail.Input;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -55,8 +54,7 @@
 
   @Override
   public Response<?> apply(ConfigResource rsrc, Input input)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
+      throws AuthException, UnprocessableEntityException, IOException, ConfigInvalidException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
diff --git a/java/com/google/gerrit/server/restapi/config/GetCache.java b/java/com/google/gerrit/server/restapi/config/GetCache.java
index 5abaf1e..93600ea 100644
--- a/java/com/google/gerrit/server/restapi/config/GetCache.java
+++ b/java/com/google/gerrit/server/restapi/config/GetCache.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.CacheResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 public class GetCache implements RestReadView<CacheResource> {
 
   @Override
-  public ListCaches.CacheInfo apply(CacheResource rsrc) {
-    return new ListCaches.CacheInfo(rsrc.getName(), rsrc.getCache());
+  public Response<ListCaches.CacheInfo> apply(CacheResource rsrc) {
+    return Response.ok(new ListCaches.CacheInfo(rsrc.getName(), rsrc.getCache()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
index 4a89dfc..5cf93d8 100644
--- a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.Preferences;
 import com.google.gerrit.server.config.AllUsersName;
@@ -41,10 +42,10 @@
   }
 
   @Override
-  public DiffPreferencesInfo apply(ConfigResource configResource)
+  public Response<DiffPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Preferences.readDefaultDiffPreferences(git);
+      return Response.ok(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..d2e1031 100644
--- a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.Preferences;
 import com.google.gerrit.server.config.AllUsersName;
@@ -40,10 +41,10 @@
   }
 
   @Override
-  public EditPreferencesInfo apply(ConfigResource configResource)
+  public Response<EditPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Preferences.readDefaultEditPreferences(git);
+      return Response.ok(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..bf0ad39 100644
--- a/java/com/google/gerrit/server/restapi/config/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.Preferences;
 import com.google.gerrit.server.config.AllUsersName;
@@ -38,10 +39,10 @@
   }
 
   @Override
-  public GeneralPreferencesInfo apply(ConfigResource rsrc)
+  public Response<GeneralPreferencesInfo> apply(ConfigResource rsrc)
       throws IOException, ConfigInvalidException {
     try (Repository git = gitMgr.openRepository(allUsersName)) {
-      return Preferences.readDefaultGeneralPreferences(git);
+      return Response.ok(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 70db0f9..32d6f17 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -20,7 +20,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.extensions.common.AccountsInfo;
 import com.google.gerrit.extensions.common.AuthInfo;
 import com.google.gerrit.extensions.common.ChangeConfigInfo;
@@ -36,9 +35,7 @@
 import com.google.gerrit.extensions.config.CloneCommand;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.EnableSignedPush;
@@ -52,22 +49,22 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.plugincontext.PluginMapContext;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.change.AllowedFormats;
 import com.google.gerrit.server.submit.MergeSuperSet;
 import com.google.inject.Inject;
-import java.net.MalformedURLException;
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
@@ -83,21 +80,19 @@
   private final AccountVisibilityProvider accountVisibilityProvider;
   private final AuthConfig authConfig;
   private final Realm realm;
-  private final DynamicMap<DownloadScheme> downloadSchemes;
-  private final DynamicMap<DownloadCommand> downloadCommands;
-  private final DynamicMap<CloneCommand> cloneCommands;
-  private final DynamicSet<WebUiPlugin> plugins;
+  private final PluginMapContext<DownloadScheme> downloadSchemes;
+  private final PluginMapContext<DownloadCommand> downloadCommands;
+  private final PluginMapContext<CloneCommand> cloneCommands;
+  private final PluginSetContext<WebUiPlugin> plugins;
   private final AllowedFormats archiveFormats;
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
   private final String anonymousCowardName;
-  private final DynamicItem<AvatarProvider> avatar;
+  private final PluginItemContext<AvatarProvider> avatar;
   private final boolean enableSignedPush;
   private final QueryDocumentationExecutor docSearcher;
-  private final NotesMigration migration;
   private final ProjectCache projectCache;
   private final AgreementJson agreementJson;
-  private final GerritOptions gerritOptions;
   private final ChangeIndexCollection indexes;
   private final SitePaths sitePaths;
 
@@ -107,21 +102,19 @@
       AccountVisibilityProvider accountVisibilityProvider,
       AuthConfig authConfig,
       Realm realm,
-      DynamicMap<DownloadScheme> downloadSchemes,
-      DynamicMap<DownloadCommand> downloadCommands,
-      DynamicMap<CloneCommand> cloneCommands,
-      DynamicSet<WebUiPlugin> webUiPlugins,
+      PluginMapContext<DownloadScheme> downloadSchemes,
+      PluginMapContext<DownloadCommand> downloadCommands,
+      PluginMapContext<CloneCommand> cloneCommands,
+      PluginSetContext<WebUiPlugin> webUiPlugins,
       AllowedFormats archiveFormats,
       AllProjectsName allProjectsName,
       AllUsersName allUsersName,
       @AnonymousCowardName String anonymousCowardName,
-      DynamicItem<AvatarProvider> avatar,
+      PluginItemContext<AvatarProvider> avatar,
       @EnableSignedPush boolean enableSignedPush,
       QueryDocumentationExecutor docSearcher,
-      NotesMigration migration,
       ProjectCache projectCache,
       AgreementJson agreementJson,
-      GerritOptions gerritOptions,
       ChangeIndexCollection indexes,
       SitePaths sitePaths) {
     this.config = config;
@@ -139,52 +132,47 @@
     this.avatar = avatar;
     this.enableSignedPush = enableSignedPush;
     this.docSearcher = docSearcher;
-    this.migration = migration;
     this.projectCache = projectCache;
     this.agreementJson = agreementJson;
-    this.gerritOptions = gerritOptions;
     this.indexes = indexes;
     this.sitePaths = sitePaths;
   }
 
   @Override
-  public ServerInfo apply(ConfigResource rsrc) throws MalformedURLException {
+  public Response<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.noteDbEnabled = toBoolean(isNoteDbEnabled());
+    info.accounts = getAccountsInfo();
+    info.auth = getAuthInfo();
+    info.change = getChangeInfo();
+    info.download = getDownloadInfo();
+    info.gerrit = getGerritInfo();
+    info.noteDbEnabled = true;
     info.plugin = getPluginInfo();
-    if (Files.exists(sitePaths.site_theme)) {
-      info.defaultTheme = "/static/" + SitePaths.THEME_FILENAME;
-    }
-    info.sshd = getSshdInfo(config);
-    info.suggest = getSuggestInfo(config);
+    info.defaultTheme = getDefaultTheme();
+    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;
+    return Response.ok(info);
   }
 
-  private AccountsInfo getAccountsInfo(AccountVisibilityProvider accountVisibilityProvider) {
+  private AccountsInfo getAccountsInfo() {
     AccountsInfo info = new AccountsInfo();
     info.visibility = accountVisibilityProvider.get();
     return info;
   }
 
-  private AuthInfo getAuthInfo(AuthConfig cfg, Realm realm) {
+  private AuthInfo getAuthInfo() throws PermissionBackendException {
     AuthInfo info = new AuthInfo();
-    info.authType = cfg.getAuthType();
-    info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
+    info.authType = authConfig.getAuthType();
+    info.useContributorAgreements = toBoolean(authConfig.isUseContributorAgreements());
     info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
-    info.switchAccountUrl = cfg.getSwitchAccountUrl();
-    info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
+    info.switchAccountUrl = authConfig.getSwitchAccountUrl();
+    info.gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
 
     if (info.useContributorAgreements != null) {
       Collection<ContributorAgreement> agreements =
@@ -200,22 +188,22 @@
     switch (info.authType) {
       case LDAP:
       case LDAP_BIND:
-        info.registerUrl = cfg.getRegisterUrl();
-        info.registerText = cfg.getRegisterText();
-        info.editFullNameUrl = cfg.getEditFullNameUrl();
+        info.registerUrl = authConfig.getRegisterUrl();
+        info.registerText = authConfig.getRegisterText();
+        info.editFullNameUrl = authConfig.getEditFullNameUrl();
         break;
 
       case CUSTOM_EXTENSION:
-        info.registerUrl = cfg.getRegisterUrl();
-        info.registerText = cfg.getRegisterText();
-        info.editFullNameUrl = cfg.getEditFullNameUrl();
-        info.httpPasswordUrl = cfg.getHttpPasswordUrl();
+        info.registerUrl = authConfig.getRegisterUrl();
+        info.registerText = authConfig.getRegisterText();
+        info.editFullNameUrl = authConfig.getEditFullNameUrl();
+        info.httpPasswordUrl = authConfig.getHttpPasswordUrl();
         break;
 
       case HTTP:
       case HTTP_LDAP:
-        info.loginUrl = cfg.getLoginUrl();
-        info.loginText = cfg.getLoginText();
+        info.loginUrl = authConfig.getLoginUrl();
+        info.loginText = authConfig.getLoginText();
         break;
 
       case CLIENT_SSL_CERT_LDAP:
@@ -228,140 +216,147 @@
     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));
-      }
-    }
+    downloadSchemes.runEach(
+        extension -> {
+          DownloadScheme scheme = extension.get();
+          if (scheme.isEnabled() && scheme.getUrl("${project}") != null) {
+            info.schemes.put(extension.getExportName(), getDownloadSchemeInfo(scheme));
+          }
+        });
     info.archives =
         archiveFormats.getAllowed().stream().map(ArchiveFormat::getShortName).collect(toList());
     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());
     info.isAuthSupported = toBoolean(scheme.isAuthSupported());
 
     info.commands = new HashMap<>();
-    for (DynamicMap.Entry<DownloadCommand> e : downloadCommands) {
-      String commandName = e.getExportName();
-      DownloadCommand command = e.getProvider().get();
-      String c = command.getCommand(scheme, "${project}", "${ref}");
-      if (c != null) {
-        info.commands.put(commandName, c);
-      }
-    }
+    downloadCommands.runEach(
+        extension -> {
+          String commandName = extension.getExportName();
+          DownloadCommand command = extension.get();
+          String c = command.getCommand(scheme, "${project}", "${ref}");
+          if (c != null) {
+            info.commands.put(commandName, c);
+          }
+        });
 
     info.cloneCommands = new HashMap<>();
-    for (DynamicMap.Entry<CloneCommand> e : cloneCommands) {
-      String commandName = e.getExportName();
-      CloneCommand command = e.getProvider().get();
-      String c = command.getCommand(scheme, "${project-path}/${project-base-name}");
-      if (c != null) {
-        c = c.replaceAll("\\$\\{project-path\\}/\\$\\{project-base-name\\}", "\\$\\{project\\}");
-        info.cloneCommands.put(commandName, c);
-      }
-    }
+    cloneCommands.runEach(
+        extension -> {
+          String commandName = extension.getExportName();
+          CloneCommand command = extension.getProvider().get();
+          String c = command.getCommand(scheme, "${project-path}/${project-base-name}");
+          if (c != null) {
+            c =
+                c.replaceAll(
+                    "\\$\\{project-path\\}/\\$\\{project-base-name\\}", "\\$\\{project\\}");
+            info.cloneCommands.put(commandName, c);
+          }
+        });
 
     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.docUrl = getDocUrl();
     info.docSearch = docSearcher.isAvailable();
     info.editGpgKeys =
-        toBoolean(enableSignedPush && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
-    info.webUis = EnumSet.noneOf(UiType.class);
-    info.webUis.add(UiType.POLYGERRIT);
-    if (gerritOptions.enableGwtUi()) {
-      info.webUis.add(UiType.GWT);
-    }
+        toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
+    info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
     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;
     }
     return CharMatcher.is('/').trimTrailingFrom(docUrl) + '/';
   }
 
-  private boolean isNoteDbEnabled() {
-    return migration.readChanges();
-  }
-
   private PluginConfigInfo getPluginInfo() {
     PluginConfigInfo info = new PluginConfigInfo();
-    info.hasAvatars = toBoolean(avatar.get() != null);
+    info.hasAvatars = toBoolean(avatar.hasImplementation());
     info.jsResourcePaths = new ArrayList<>();
     info.htmlResourcePaths = new ArrayList<>();
-    for (WebUiPlugin u : plugins) {
-      String path =
-          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath());
-      if (path.endsWith(".html")) {
-        info.htmlResourcePaths.add(path);
-      } else {
-        info.jsResourcePaths.add(path);
-      }
-    }
+    plugins.runEach(
+        plugin -> {
+          String path =
+              String.format(
+                  "plugins/%s/%s", plugin.getPluginName(), plugin.getJavaScriptResourcePath());
+          if (path.endsWith(".html")) {
+            info.htmlResourcePaths.add(path);
+          } else {
+            info.jsResourcePaths.add(path);
+          }
+        });
     return info;
   }
 
-  private Map<String, String> getUrlAliasesInfo(Config cfg) {
+  private static final String DEFAULT_THEME = "/static/" + SitePaths.THEME_FILENAME;
+
+  private String getDefaultTheme() {
+    if (config.getString("theme", null, "enableDefault") == null) {
+      // If not explicitly enabled or disabled, check for the existence of the theme file.
+      return Files.exists(sitePaths.site_theme) ? DEFAULT_THEME : null;
+    }
+    if (config.getBoolean("theme", null, "enableDefault", true)) {
+      // Return non-null theme path without checking for file existence. Even if the file doesn't
+      // exist under the site path, it may be served from a CDN (in which case it's up to the admin
+      // to also pass a proper asset path to the index Soy template).
+      return DEFAULT_THEME;
+    }
+    return null;
+  }
+
+  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;
     }
@@ -374,13 +369,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/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index a382436..1df485f 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.SitePath;
@@ -69,7 +70,7 @@
   }
 
   @Override
-  public SummaryInfo apply(ConfigResource rsrc) {
+  public Response<SummaryInfo> apply(ConfigResource rsrc) {
     if (gc) {
       System.gc();
       System.runFinalization();
@@ -83,7 +84,7 @@
     if (jvm) {
       summary.jvmSummary = getJvmSummary();
     }
-    return summary;
+    return Response.ok(summary);
   }
 
   private TaskSummaryInfo getTaskSummary() {
diff --git a/java/com/google/gerrit/server/restapi/config/GetTask.java b/java/com/google/gerrit/server/restapi/config/GetTask.java
index a32f3ba..513c99a 100644
--- a/java/com/google/gerrit/server/restapi/config/GetTask.java
+++ b/java/com/google/gerrit/server/restapi/config/GetTask.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.TaskResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 public class GetTask implements RestReadView<TaskResource> {
 
   @Override
-  public ListTasks.TaskInfo apply(TaskResource rsrc) {
-    return new ListTasks.TaskInfo(rsrc.getTask());
+  public Response<ListTasks.TaskInfo> apply(TaskResource rsrc) {
+    return Response.ok(new ListTasks.TaskInfo(rsrc.getTask()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetVersion.java b/java/com/google/gerrit/server/restapi/config/GetVersion.java
index 8135719..ee206d6 100644
--- a/java/com/google/gerrit/server/restapi/config/GetVersion.java
+++ b/java/com/google/gerrit/server/restapi/config/GetVersion.java
@@ -15,19 +15,22 @@
 package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Singleton;
+import java.util.concurrent.TimeUnit;
 
 @Singleton
 public class GetVersion implements RestReadView<ConfigResource> {
   @Override
-  public String apply(ConfigResource resource) throws ResourceNotFoundException {
+  public Response<String> apply(ConfigResource resource) throws ResourceNotFoundException {
     String version = Version.getVersion();
     if (version == null) {
       throw new ResourceNotFoundException();
     }
-    return version;
+    return Response.ok(version).caching(CacheControl.PRIVATE(30, TimeUnit.SECONDS));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ListCaches.java b/java/com/google/gerrit/server/restapi/config/ListCaches.java
index c0a9d71..ccafbe8 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCaches.java
@@ -14,26 +14,28 @@
 
 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.registration.Extension;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 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})
@@ -60,7 +62,7 @@
 
   public Map<String, CacheInfo> getCacheInfos() {
     Map<String, CacheInfo> cacheInfos = new TreeMap<>();
-    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+    for (Extension<Cache<?, ?>> e : cacheMap) {
       cacheInfos.put(
           cacheNameOf(e.getPluginName(), e.getExportName()), new CacheInfo(e.getProvider().get()));
     }
@@ -68,23 +70,22 @@
   }
 
   @Override
-  public Object apply(ConfigResource rsrc) {
+  public Response<Object> apply(ConfigResource rsrc) {
     if (format == null) {
-      return getCacheInfos();
+      return Response.ok(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))
-          .base64()
-          .setContentType("text/plain")
-          .setCharacterEncoding(UTF_8);
+      return Response.ok(
+          BinaryResult.create(cacheNames.collect(joining("\n")))
+              .base64()
+              .setContentType("text/plain")
+              .setCharacterEncoding(UTF_8));
     }
-    return cacheNames;
+    return Response.ok(cacheNames.collect(toImmutableList()));
   }
 
   public enum CacheType {
diff --git a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
index fa9bfde..6c8bf74 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
@@ -14,66 +14,55 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+
 import com.google.common.collect.ImmutableMap;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.config.CapabilityDefinition;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.CapabilityConstants;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PluginPermissionsUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.regex.Pattern;
 
 /** List capabilities visible to the calling user. */
 @Singleton
 public class ListCapabilities implements RestReadView<ConfigResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final Pattern PLUGIN_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9-]+$");
-
   private final PermissionBackend permissionBackend;
-  private final DynamicMap<CapabilityDefinition> pluginCapabilities;
+  private final PluginPermissionsUtil pluginPermissionsUtil;
 
   @Inject
   public ListCapabilities(
-      PermissionBackend permissionBackend, DynamicMap<CapabilityDefinition> pluginCapabilities) {
+      PermissionBackend permissionBackend, PluginPermissionsUtil pluginPermissionsUtil) {
     this.permissionBackend = permissionBackend;
-    this.pluginCapabilities = pluginCapabilities;
+    this.pluginPermissionsUtil = pluginPermissionsUtil;
   }
 
   @Override
-  public Map<String, CapabilityInfo> apply(ConfigResource resource)
+  public Response<Map<String, CapabilityInfo>> apply(ConfigResource resource)
       throws ResourceNotFoundException, IllegalAccessException, NoSuchFieldException {
     permissionBackend.checkUsesDefaultCapabilities();
-    return ImmutableMap.<String, CapabilityInfo>builder()
-        .putAll(collectCoreCapabilities())
-        .putAll(collectPluginCapabilities())
-        .build();
+    return Response.ok(
+        ImmutableMap.<String, CapabilityInfo>builder()
+            .putAll(collectCoreCapabilities())
+            .putAll(collectPluginCapabilities())
+            .build());
   }
 
   public Map<String, CapabilityInfo> collectPluginCapabilities() {
-    Map<String, CapabilityInfo> output = new HashMap<>();
-    for (String pluginName : pluginCapabilities.plugins()) {
-      if (!PLUGIN_NAME_PATTERN.matcher(pluginName).matches()) {
-        logger.atWarning().log(
-            "Plugin name '%s' must match '%s' to use capabilities; rename the plugin",
-            pluginName, PLUGIN_NAME_PATTERN.pattern());
-        continue;
-      }
-      for (Map.Entry<String, Provider<CapabilityDefinition>> entry :
-          pluginCapabilities.byPlugin(pluginName).entrySet()) {
-        String id = String.format("%s-%s", pluginName, entry.getKey());
-        output.put(id, new CapabilityInfo(id, entry.getValue().get().getDescription()));
-      }
-    }
-    return output;
+    return convertToPermissionInfos(pluginPermissionsUtil.collectPluginCapabilities());
+  }
+
+  private static ImmutableMap<String, CapabilityInfo> convertToPermissionInfos(
+      ImmutableMap<String, String> permissionIdNames) {
+    return permissionIdNames.entrySet().stream()
+        .collect(
+            toImmutableMap(Map.Entry::getKey, e -> new CapabilityInfo(e.getKey(), e.getValue())));
   }
 
   private Map<String, CapabilityInfo> collectCoreCapabilities()
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index d700028..37ca692 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -14,8 +14,11 @@
 
 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.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -23,20 +26,18 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
 import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.ioutil.HexFormat;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -62,7 +63,7 @@
   }
 
   @Override
-  public List<TaskInfo> apply(ConfigResource resource)
+  public Response<List<TaskInfo>> apply(ConfigResource resource)
       throws AuthException, PermissionBackendException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
@@ -72,7 +73,7 @@
     List<TaskInfo> allTasks = getTasks();
     try {
       permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
-      return allTasks;
+      return Response.ok(allTasks);
     } catch (AuthException e) {
       // Fall through to filter tasks.
     }
@@ -83,7 +84,7 @@
       if (task.projectName != null) {
         Boolean visible = visibilityCache.get(task.projectName);
         if (visible == null) {
-          Project.NameKey nameKey = new Project.NameKey(task.projectName);
+          Project.NameKey nameKey = Project.nameKey(task.projectName);
           ProjectState state = projectCache.get(nameKey);
           if (state == null || !state.statePermitsRead()) {
             visible = false;
@@ -102,24 +103,16 @@
         }
       }
     }
-    return visibleTasks;
+    return Response.ok(visibleTasks);
   }
 
   private List<TaskInfo> getTasks() {
-    List<TaskInfo> taskInfos = workQueue.getTaskInfos(TaskInfo::new);
-    Collections.sort(
-        taskInfos,
-        new Comparator<TaskInfo>() {
-          @Override
-          public int compare(TaskInfo a, TaskInfo b) {
-            return ComparisonChain.start()
-                .compare(a.state.ordinal(), b.state.ordinal())
-                .compare(a.delay, b.delay)
-                .compare(a.command, b.command)
-                .result();
-          }
-        });
-    return taskInfos;
+    return workQueue.getTaskInfos(TaskInfo::new).stream()
+        .sorted(
+            comparing((TaskInfo t) -> t.state.ordinal())
+                .thenComparing(t -> t.delay)
+                .thenComparing(t -> t.command))
+        .collect(toList());
   }
 
   public static class TaskInfo {
@@ -133,7 +126,7 @@
     public String queueName;
 
     public TaskInfo(Task<?> task) {
-      this.id = IdGenerator.format(task.getTaskId());
+      this.id = HexFormat.fromInt(task.getTaskId());
       this.state = task.getState();
       this.startTime = new Timestamp(task.getStartTime().getTime());
       this.delay = task.getDelay(TimeUnit.MILLISECONDS);
diff --git a/java/com/google/gerrit/server/restapi/config/ListTopMenus.java b/java/com/google/gerrit/server/restapi/config/ListTopMenus.java
index 7a85bcd..01c273c 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTopMenus.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTopMenus.java
@@ -14,30 +14,30 @@
 
 package com.google.gerrit.server.restapi.config;
 
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.webui.TopMenu;
+import com.google.gerrit.extensions.webui.TopMenu.MenuEntry;
 import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.List;
 
 @Singleton
-class ListTopMenus implements RestReadView<ConfigResource> {
-  private final DynamicSet<TopMenu> extensions;
+public class ListTopMenus implements RestReadView<ConfigResource> {
+  private final PluginSetContext<TopMenu> extensions;
 
   @Inject
-  ListTopMenus(DynamicSet<TopMenu> extensions) {
+  ListTopMenus(PluginSetContext<TopMenu> extensions) {
     this.extensions = extensions;
   }
 
   @Override
-  public List<TopMenu.MenuEntry> apply(ConfigResource resource) {
+  public Response<List<MenuEntry>> apply(ConfigResource resource) {
     List<TopMenu.MenuEntry> entries = new ArrayList<>();
-    for (TopMenu extension : extensions) {
-      entries.addAll(extension.getEntries());
-    }
-    return entries;
+    extensions.runEach(extension -> entries.addAll(extension.getEntries()));
+    return Response.ok(entries).caching(ConfigResource.DEFAULT_CACHE_CONTROL);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/PostCaches.java b/java/com/google/gerrit/server/restapi/config/PostCaches.java
index f21672c..c633af0 100644
--- a/java/com/google/gerrit/server/restapi/config/PostCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/PostCaches.java
@@ -20,10 +20,12 @@
 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.Extension;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.CacheResource;
 import com.google.gerrit.server.config.ConfigResource;
@@ -36,7 +38,7 @@
 
 @RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
 @Singleton
-public class PostCaches implements RestModifyView<ConfigResource, Input> {
+public class PostCaches implements RestCollectionModifyView<ConfigResource, CacheResource, Input> {
   public static class Input {
     public Operation operation;
     public List<String> caches;
@@ -95,7 +97,7 @@
   }
 
   private void flushAll() throws AuthException, PermissionBackendException {
-    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+    for (Extension<Cache<?, ?>> e : cacheMap) {
       CacheResource cacheResource =
           new CacheResource(e.getPluginName(), e.getExportName(), e.getProvider());
       if (FlushCache.WEB_SESSIONS.equals(cacheResource.getName())) {
@@ -110,7 +112,7 @@
     List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
 
     for (String n : cacheNames) {
-      String pluginName = "gerrit";
+      String pluginName = PluginName.GERRIT;
       String cacheName = n;
       int i = cacheName.lastIndexOf('-');
       if (i != -1) {
diff --git a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
index de3c3ee..9ce7ffd 100644
--- a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
+++ b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
@@ -16,12 +16,13 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.api.config.ConfigUpdateEntryInfo;
 import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.ConfigUpdatedEvent;
 import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
 import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
 import com.google.gerrit.server.config.GerritServerConfigReloader;
@@ -29,10 +30,11 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 public class ReloadConfig implements RestModifyView<ConfigResource, Input> {
 
@@ -46,29 +48,23 @@
   }
 
   @Override
-  public Map<String, List<ConfigUpdateEntryInfo>> apply(ConfigResource resource, Input input)
-      throws RestApiException, PermissionBackendException {
+  public Response<Map<String, List<ConfigUpdateEntryInfo>>> apply(
+      ConfigResource resource, Input input) throws RestApiException, PermissionBackendException {
     permissions.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
-
-    List<ConfigUpdatedEvent.Update> updates = config.reloadConfig();
-
-    Map<String, List<ConfigUpdateEntryInfo>> reply = new HashMap<>();
-    for (UpdateResult result : UpdateResult.values()) {
-      reply.put(result.name().toLowerCase(), new ArrayList<>());
-    }
+    Multimap<UpdateResult, ConfigUpdateEntry> updates = config.reloadConfig();
     if (updates.isEmpty()) {
-      return reply;
+      return Response.ok(Collections.emptyMap());
     }
-    updates
-        .stream()
-        .forEach(u -> reply.get(u.getResult().name().toLowerCase()).addAll(toEntryInfos(u)));
-    return reply;
+    return Response.ok(
+        updates.asMap().entrySet().stream()
+            .collect(
+                Collectors.toMap(
+                    e -> e.getKey().name().toLowerCase(), e -> toEntryInfos(e.getValue()))));
   }
 
-  private static List<ConfigUpdateEntryInfo> toEntryInfos(ConfigUpdatedEvent.Update update) {
-    return update
-        .getConfigUpdates()
-        .stream()
+  private static List<ConfigUpdateEntryInfo> toEntryInfos(
+      Collection<ConfigUpdateEntry> updateEntries) {
+    return updateEntries.stream()
         .map(ReloadConfig::toConfigUpdateEntryInfo)
         .collect(toImmutableList());
   }
diff --git a/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
index 7283033..c929bc6 100644
--- a/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
+++ b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
@@ -26,6 +26,7 @@
   protected void configure() {
     DynamicMap.mapOf(binder(), CACHE_KIND);
     child(CONFIG_KIND, "caches").to(CachesCollection.class);
+    postOnCollection(CACHE_KIND).to(PostCaches.class);
     get(CACHE_KIND).to(GetCache.class);
     post(CACHE_KIND, "flush").to(FlushCache.class);
     get(CONFIG_KIND, "summary").to(GetSummary.class);
diff --git a/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
index 068f332..fb81665 100644
--- a/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.Preferences;
@@ -54,7 +55,8 @@
   }
 
   @Override
-  public DiffPreferencesInfo apply(ConfigResource configResource, DiffPreferencesInfo input)
+  public Response<DiffPreferencesInfo> apply(
+      ConfigResource configResource, DiffPreferencesInfo input)
       throws BadRequestException, IOException, ConfigInvalidException {
     if (input == null) {
       throw new BadRequestException("input must be provided");
@@ -66,7 +68,7 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       DiffPreferencesInfo updatedPrefs = Preferences.updateDefaultDiffPreferences(md, input);
       accountCache.evictAll();
-      return updatedPrefs;
+      return Response.ok(updatedPrefs);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java b/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java
index daca734..178a4e1 100644
--- a/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.Preferences;
@@ -54,7 +55,8 @@
   }
 
   @Override
-  public EditPreferencesInfo apply(ConfigResource configResource, EditPreferencesInfo input)
+  public Response<EditPreferencesInfo> apply(
+      ConfigResource configResource, EditPreferencesInfo input)
       throws BadRequestException, IOException, ConfigInvalidException {
     if (input == null) {
       throw new BadRequestException("input must be provided");
@@ -66,7 +68,7 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       EditPreferencesInfo updatedPrefs = Preferences.updateDefaultEditPreferences(md, input);
       accountCache.evictAll();
-      return updatedPrefs;
+      return Response.ok(updatedPrefs);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/SetPreferences.java b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
index 6a0c22b..779f3e7 100644
--- a/java/com/google/gerrit/server/restapi/config/SetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.Preferences;
@@ -54,7 +55,7 @@
   }
 
   @Override
-  public GeneralPreferencesInfo apply(ConfigResource rsrc, GeneralPreferencesInfo input)
+  public Response<GeneralPreferencesInfo> apply(ConfigResource rsrc, GeneralPreferencesInfo input)
       throws BadRequestException, IOException, ConfigInvalidException {
     if (!hasSetFields(input)) {
       throw new BadRequestException("unsupported option");
@@ -63,7 +64,7 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       GeneralPreferencesInfo updatedPrefs = Preferences.updateDefaultGeneralPreferences(md, input);
       accountCache.evictAll();
-      return updatedPrefs;
+      return Response.ok(updatedPrefs);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index 461989d..1ab1f38 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -18,13 +18,16 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -35,6 +38,7 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.GroupControl;
@@ -44,9 +48,8 @@
 import com.google.gerrit.server.group.MemberResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddMembers.Input;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -87,7 +90,6 @@
 
   private final AccountManager accountManager;
   private final AuthType authType;
-  private final AccountsCollection accounts;
   private final AccountResolver accountResolver;
   private final AccountCache accountCache;
   private final AccountLoader.Factory infoFactory;
@@ -97,14 +99,12 @@
   AddMembers(
       AccountManager accountManager,
       AuthConfig authConfig,
-      AccountsCollection accounts,
       AccountResolver accountResolver,
       AccountCache accountCache,
       AccountLoader.Factory infoFactory,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
     this.accountManager = accountManager;
     this.authType = authConfig.getAuthType();
-    this.accounts = accounts;
     this.accountResolver = accountResolver;
     this.accountCache = accountCache;
     this.infoFactory = infoFactory;
@@ -112,9 +112,9 @@
   }
 
   @Override
-  public List<AccountInfo> apply(GroupResource resource, Input input)
-      throws AuthException, NotInternalGroupException, UnprocessableEntityException, OrmException,
-          IOException, ConfigInvalidException, ResourceNotFoundException {
+  public Response<List<AccountInfo>> apply(GroupResource resource, Input input)
+      throws AuthException, NotInternalGroupException, UnprocessableEntityException, IOException,
+          ConfigInvalidException, ResourceNotFoundException, PermissionBackendException {
     GroupDescription.Internal internalGroup =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     input = Input.init(input);
@@ -131,7 +131,7 @@
         throw new UnprocessableEntityException(
             String.format("Account Inactive: %s", nameOrEmailOrId));
       }
-      newMemberIds.add(a.getId());
+      newMemberIds.add(a.id());
     }
 
     AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
@@ -140,23 +140,22 @@
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
     }
-    return toAccountInfoList(newMemberIds);
+    return Response.ok(toAccountInfoList(newMemberIds));
   }
 
   Account findAccount(String nameOrEmailOrId)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
+      throws UnprocessableEntityException, IOException, ConfigInvalidException {
+    AccountResolver.Result result = accountResolver.resolve(nameOrEmailOrId);
     try {
-      return accounts.parse(nameOrEmailOrId).getAccount();
-    } catch (UnprocessableEntityException e) {
-      // might be because the account does not exist or because the account is
-      // not visible
+      return result.asUnique().getAccount();
+    } catch (UnresolvableAccountException e) {
       switch (authType) {
         case HTTP_LDAP:
         case CLIENT_SSL_CERT_LDAP:
         case LDAP:
-          if (accountResolver.find(nameOrEmailOrId) == null) {
-            // account does not exist, try to create it
+          if (!e.isSelf() && result.asList().isEmpty()) {
+            // Account does not exist, try to create it. This may leak account existence, since we
+            // can't distinguish between a nonexistent account and one that the caller can't see.
             Optional<Account> a = createAccountByLdap(nameOrEmailOrId);
             if (a.isPresent()) {
               return a.get();
@@ -177,7 +176,7 @@
   }
 
   public void addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> newMemberIds)
-      throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
+      throws IOException, NoSuchGroupException, ConfigInvalidException {
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, newMemberIds))
@@ -201,7 +200,8 @@
     }
   }
 
-  private List<AccountInfo> toAccountInfoList(Set<Account.Id> accountIds) throws OrmException {
+  private List<AccountInfo> toAccountInfoList(Set<Account.Id> accountIds)
+      throws PermissionBackendException {
     List<AccountInfo> result = new ArrayList<>();
     AccountLoader loader = infoFactory.create(true);
     for (Account.Id accId : accountIds) {
@@ -211,26 +211,26 @@
     return result;
   }
 
-  public static class PutMember implements RestModifyView<GroupResource, Input> {
-
+  @Singleton
+  public static class CreateMember
+      implements RestCollectionCreateView<GroupResource, MemberResource, Input> {
     private final AddMembers put;
-    private final String id;
 
-    public PutMember(AddMembers put, String id) {
+    @Inject
+    public CreateMember(AddMembers put) {
       this.put = put;
-      this.id = id;
     }
 
     @Override
-    public AccountInfo apply(GroupResource resource, Input input)
-        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-            IOException, ConfigInvalidException {
+    public Response<AccountInfo> apply(GroupResource resource, IdString id, Input input)
+        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, IOException,
+            ConfigInvalidException, PermissionBackendException {
       AddMembers.Input in = new AddMembers.Input();
-      in._oneMember = id;
+      in._oneMember = id.get();
       try {
-        List<AccountInfo> list = put.apply(resource, in);
+        List<AccountInfo> list = put.apply(resource, in).value();
         if (list.size() == 1) {
-          return list.get(0);
+          return Response.created(list.get(0));
         }
         throw new IllegalStateException();
       } catch (UnprocessableEntityException e) {
@@ -249,7 +249,8 @@
     }
 
     @Override
-    public AccountInfo apply(MemberResource resource, Input input) throws OrmException {
+    public Response<AccountInfo> apply(MemberResource resource, Input input)
+        throws PermissionBackendException {
       // Do nothing, the user is already a member.
       return get.apply(resource);
     }
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index d0be5ac..3a3b9f4 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -19,23 +19,27 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddSubgroups.Input;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -73,24 +77,25 @@
     }
   }
 
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupJson json;
 
   @Inject
   public AddSubgroups(
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupJson json) {
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.json = json;
   }
 
   @Override
-  public List<GroupInfo> apply(GroupResource resource, Input input)
-      throws NotInternalGroupException, AuthException, UnprocessableEntityException, OrmException,
-          ResourceNotFoundException, IOException, ConfigInvalidException {
+  public Response<List<GroupInfo>> apply(GroupResource resource, Input input)
+      throws NotInternalGroupException, AuthException, UnprocessableEntityException,
+          ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     input = Input.init(input);
@@ -103,7 +108,7 @@
     List<GroupInfo> result = new ArrayList<>();
     Set<AccountGroup.UUID> subgroupUuids = new LinkedHashSet<>();
     for (String subgroupIdentifier : input.groups) {
-      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
+      GroupDescription.Basic subgroup = groupResolver.parse(subgroupIdentifier);
       subgroupUuids.add(subgroup.getGroupUUID());
       result.add(json.format(subgroup));
     }
@@ -114,12 +119,12 @@
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
     }
-    return result;
+    return Response.ok(result);
   }
 
   private void addSubgroups(
       AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> newSubgroupUuids)
-      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+      throws NoSuchGroupException, IOException, ConfigInvalidException {
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setSubgroupModification(subgroupUuids -> Sets.union(subgroupUuids, newSubgroupUuids))
@@ -127,26 +132,26 @@
     groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupUpdate);
   }
 
-  public static class PutSubgroup implements RestModifyView<GroupResource, Input> {
-
+  @Singleton
+  public static class CreateSubgroup
+      implements RestCollectionCreateView<GroupResource, SubgroupResource, Input> {
     private final AddSubgroups addSubgroups;
-    private final String id;
 
-    public PutSubgroup(AddSubgroups addSubgroups, String id) {
+    @Inject
+    public CreateSubgroup(AddSubgroups addSubgroups) {
       this.addSubgroups = addSubgroups;
-      this.id = id;
     }
 
     @Override
-    public GroupInfo apply(GroupResource resource, Input input)
-        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-            IOException, ConfigInvalidException {
+    public Response<GroupInfo> apply(GroupResource resource, IdString id, Input input)
+        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, IOException,
+            ConfigInvalidException, PermissionBackendException {
       AddSubgroups.Input in = new AddSubgroups.Input();
-      in.groups = ImmutableList.of(id);
+      in.groups = ImmutableList.of(id.get());
       try {
-        List<GroupInfo> list = addSubgroups.apply(resource, in);
+        List<GroupInfo> list = addSubgroups.apply(resource, in).value();
         if (list.size() == 1) {
-          return list.get(0);
+          return Response.created(list.get(0));
         }
         throw new IllegalStateException();
       } catch (UnprocessableEntityException e) {
@@ -165,7 +170,8 @@
     }
 
     @Override
-    public GroupInfo apply(SubgroupResource resource, Input input) throws OrmException {
+    public Response<GroupInfo> apply(SubgroupResource resource, Input input)
+        throws PermissionBackendException {
       // Do nothing, the group is already included.
       return get.get().apply(resource);
     }
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 79f9688..5cb885b 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -19,16 +19,18 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
@@ -36,25 +38,27 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.CreateGroupArgs;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -67,22 +71,19 @@
 import org.eclipse.jgit.lib.PersonIdent;
 
 @RequiresCapability(GlobalCapability.CREATE_GROUP)
-public class CreateGroup implements RestModifyView<TopLevelResource, GroupInput> {
-  public interface Factory {
-    CreateGroup create(@Assisted String name);
-  }
-
+@Singleton
+public class CreateGroup
+    implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
   private final PersonIdent serverIdent;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
-  private final GroupsCollection groups;
+  private final GroupResolver groups;
   private final GroupJson json;
-  private final DynamicSet<GroupCreationValidationListener> groupCreationValidationListeners;
+  private final PluginSetContext<GroupCreationValidationListener> groupCreationValidationListeners;
   private final AddMembers addMembers;
   private final SystemGroupBackend systemGroupBackend;
   private final boolean defaultVisibleToAll;
-  private final String name;
   private final Sequences sequences;
 
   @Inject
@@ -91,13 +92,12 @@
       @GerritPersonIdent PersonIdent serverIdent,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupCache groupCache,
-      GroupsCollection groups,
+      GroupResolver groups,
       GroupJson json,
-      DynamicSet<GroupCreationValidationListener> groupCreationValidationListeners,
+      PluginSetContext<GroupCreationValidationListener> groupCreationValidationListeners,
       AddMembers addMembers,
       SystemGroupBackend systemGroupBackend,
       @GerritServerConfig Config cfg,
-      @Assisted String name,
       Sequences sequences) {
     this.self = self;
     this.serverIdent = serverIdent;
@@ -109,7 +109,6 @@
     this.addMembers = addMembers;
     this.systemGroupBackend = systemGroupBackend;
     this.defaultVisibleToAll = cfg.getBoolean("groups", "newGroupsVisibleToAll", false);
-    this.name = name;
     this.sequences = sequences;
   }
 
@@ -124,10 +123,11 @@
   }
 
   @Override
-  public GroupInfo apply(TopLevelResource resource, GroupInput input)
+  public Response<GroupInfo> apply(TopLevelResource resource, IdString id, GroupInput input)
       throws AuthException, BadRequestException, UnprocessableEntityException,
-          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
-          ResourceNotFoundException {
+          ResourceConflictException, IOException, ConfigInvalidException, ResourceNotFoundException,
+          PermissionBackendException {
+    String name = id.get();
     if (input == null) {
       input = new GroupInput();
     }
@@ -149,25 +149,24 @@
           throw new UnprocessableEntityException(
               String.format("Account Inactive: %s", nameOrEmailOrId));
         }
-        members.add(a.getId());
+        members.add(a.id());
       }
       args.initialMembers = members;
     } else {
       args.initialMembers =
           ownerUuid == null
               ? Collections.singleton(self.get().getAccountId())
-              : Collections.<Account.Id>emptySet();
+              : Collections.emptySet();
     }
 
-    for (GroupCreationValidationListener l : groupCreationValidationListeners) {
-      try {
-        l.validateNewGroup(args);
-      } catch (ValidationException e) {
-        throw new ResourceConflictException(e.getMessage(), e);
-      }
+    try {
+      groupCreationValidationListeners.runEach(
+          l -> l.validateNewGroup(args), ValidationException.class);
+    } catch (ValidationException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
     }
 
-    return json.format(new InternalGroupDescription(createGroup(args)));
+    return Response.created(json.format(new InternalGroupDescription(createGroup(args))));
   }
 
   private AccountGroup.UUID owner(GroupInput input) throws UnprocessableEntityException {
@@ -179,7 +178,7 @@
   }
 
   private InternalGroup createGroup(CreateGroupArgs createGroupArgs)
-      throws OrmException, ResourceConflictException, IOException, ConfigInvalidException {
+      throws ResourceConflictException, IOException, ConfigInvalidException {
 
     String nameLower = createGroupArgs.getGroupName().toLowerCase(Locale.US);
 
@@ -195,7 +194,7 @@
       }
     }
 
-    AccountGroup.Id groupId = new AccountGroup.Id(sequences.nextGroupId());
+    AccountGroup.Id groupId = AccountGroup.id(sequences.nextGroupId());
     AccountGroup.UUID uuid =
         GroupUUID.make(
             createGroupArgs.getGroupName(),
@@ -219,7 +218,7 @@
         members -> ImmutableSet.copyOf(createGroupArgs.initialMembers));
     try {
       return groupsUpdateProvider.get().createGroup(groupCreation, groupUpdateBuilder.build());
-    } catch (OrmDuplicateKeyException e) {
+    } catch (DuplicateKeyException e) {
       throw new ResourceConflictException(
           "group '" + createGroupArgs.getGroupName() + "' already exists");
     }
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
index bcacb65..5d1d447 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -16,7 +16,7 @@
 
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -26,14 +26,13 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.MemberResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.restapi.group.AddMembers.Input;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -44,20 +43,20 @@
 
 @Singleton
 public class DeleteMembers implements RestModifyView<GroupResource, Input> {
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Inject
   DeleteMembers(
-      AccountsCollection accounts, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.accounts = accounts;
+      AccountResolver accountResolver, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.accountResolver = accountResolver;
     this.groupsUpdateProvider = groupsUpdateProvider;
   }
 
   @Override
   public Response<?> apply(GroupResource resource, Input input)
-      throws AuthException, NotInternalGroupException, UnprocessableEntityException, OrmException,
-          IOException, ConfigInvalidException, ResourceNotFoundException {
+      throws AuthException, NotInternalGroupException, UnprocessableEntityException, IOException,
+          ConfigInvalidException, ResourceNotFoundException {
     GroupDescription.Internal internalGroup =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     input = Input.init(input);
@@ -69,8 +68,7 @@
 
     Set<Account.Id> membersToRemove = new HashSet<>();
     for (String nameOrEmail : input.members) {
-      Account a = accounts.parse(nameOrEmail).getAccount();
-      membersToRemove.add(a.getId());
+      membersToRemove.add(accountResolver.resolve(nameOrEmail).asUnique().getAccount().id());
     }
     AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
     try {
@@ -83,7 +81,7 @@
   }
 
   private void removeGroupMembers(AccountGroup.UUID groupUuid, Set<Account.Id> accountIds)
-      throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
+      throws IOException, NoSuchGroupException, ConfigInvalidException {
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setMemberModification(memberIds -> Sets.difference(memberIds, accountIds))
@@ -103,8 +101,8 @@
 
     @Override
     public Response<?> apply(MemberResource resource, Input input)
-        throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
-            IOException, ConfigInvalidException, ResourceNotFoundException {
+        throws AuthException, MethodNotAllowedException, UnprocessableEntityException, IOException,
+            ConfigInvalidException, ResourceNotFoundException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = resource.getMember().getAccountId().toString();
       return delete.get().apply(resource, in);
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
index 934698b..5821be5 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -27,12 +27,12 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.restapi.group.AddSubgroups.Input;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -43,20 +43,19 @@
 
 @Singleton
 public class DeleteSubgroups implements RestModifyView<GroupResource, Input> {
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Inject
   DeleteSubgroups(
-      GroupsCollection groupsCollection,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.groupsCollection = groupsCollection;
+      GroupResolver groupResolver, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.groupResolver = groupResolver;
     this.groupsUpdateProvider = groupsUpdateProvider;
   }
 
   @Override
   public Response<?> apply(GroupResource resource, Input input)
-      throws AuthException, NotInternalGroupException, UnprocessableEntityException, OrmException,
+      throws AuthException, NotInternalGroupException, UnprocessableEntityException,
           ResourceNotFoundException, IOException, ConfigInvalidException {
     GroupDescription.Internal internalGroup =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
@@ -70,7 +69,7 @@
 
     Set<AccountGroup.UUID> subgroupsToRemove = new HashSet<>();
     for (String subgroupIdentifier : input.groups) {
-      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
+      GroupDescription.Basic subgroup = groupResolver.parse(subgroupIdentifier);
       subgroupsToRemove.add(subgroup.getGroupUUID());
     }
 
@@ -86,7 +85,7 @@
 
   private void removeSubgroups(
       AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> removedSubgroupUuids)
-      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+      throws NoSuchGroupException, IOException, ConfigInvalidException {
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setSubgroupModification(
@@ -107,7 +106,7 @@
 
     @Override
     public Response<?> apply(SubgroupResource resource, Input input)
-        throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+        throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
             ResourceNotFoundException, IOException, ConfigInvalidException {
       AddSubgroups.Input in = new AddSubgroups.Input();
       in.groups = ImmutableList.of(resource.getMember().get());
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index eb66a37..77f6243 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -21,10 +21,11 @@
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAudit;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupBackend;
@@ -35,12 +36,11 @@
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -75,9 +75,9 @@
   }
 
   @Override
-  public List<? extends GroupAuditEventInfo> apply(GroupResource rsrc)
-      throws AuthException, NotInternalGroupException, OrmException, IOException,
-          ConfigInvalidException {
+  public Response<List<? extends GroupAuditEventInfo>> apply(GroupResource rsrc)
+      throws AuthException, NotInternalGroupException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     GroupDescription.Internal group =
         rsrc.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     if (!rsrc.getControl().isOwner()) {
@@ -91,22 +91,24 @@
     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
       for (AccountGroupMemberAudit auditEvent :
           groups.getMembersAudit(allUsersRepo, group.getGroupUUID())) {
-        AccountInfo member = accountLoader.get(auditEvent.getMemberId());
+        AccountInfo member = accountLoader.get(auditEvent.memberId());
 
         auditEvents.add(
             GroupAuditEventInfo.createAddUserEvent(
-                accountLoader.get(auditEvent.getAddedBy()), auditEvent.getAddedOn(), member));
+                accountLoader.get(auditEvent.addedBy()), auditEvent.addedOn(), member));
 
         if (!auditEvent.isActive()) {
           auditEvents.add(
               GroupAuditEventInfo.createRemoveUserEvent(
-                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+                  accountLoader.get(auditEvent.removedBy().orElse(null)),
+                  auditEvent.removedOn(),
+                  member));
         }
       }
 
-      for (AccountGroupByIdAud auditEvent :
+      for (AccountGroupByIdAudit auditEvent :
           groups.getSubgroupsAudit(allUsersRepo, group.getGroupUUID())) {
-        AccountGroup.UUID includedGroupUUID = auditEvent.getIncludeUUID();
+        AccountGroup.UUID includedGroupUUID = auditEvent.includeUuid();
         Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
         GroupInfo member;
         if (includedGroup.isPresent()) {
@@ -122,14 +124,14 @@
 
         auditEvents.add(
             GroupAuditEventInfo.createAddGroupEvent(
-                accountLoader.get(auditEvent.getAddedBy()),
-                auditEvent.getKey().getAddedOn(),
-                member));
+                accountLoader.get(auditEvent.addedBy()), auditEvent.addedOn(), member));
 
         if (!auditEvent.isActive()) {
           auditEvents.add(
               GroupAuditEventInfo.createRemoveGroupEvent(
-                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+                  accountLoader.get(auditEvent.removedBy().orElse(null)),
+                  auditEvent.removedOn(),
+                  member));
         }
       }
     }
@@ -137,7 +139,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());
-    return auditEvents;
+    auditEvents.sort(comparing((GroupAuditEventInfo a) -> a.date).reversed());
+    return Response.ok(auditEvents);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetDescription.java b/java/com/google/gerrit/server/restapi/group/GetDescription.java
index c34fda7..b770281 100644
--- a/java/com/google/gerrit/server/restapi/group/GetDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/GetDescription.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.inject.Singleton;
@@ -23,9 +24,9 @@
 @Singleton
 public class GetDescription implements RestReadView<GroupResource> {
   @Override
-  public String apply(GroupResource resource) throws NotInternalGroupException {
+  public Response<String> apply(GroupResource resource) throws NotInternalGroupException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
-    return Strings.nullToEmpty(group.getDescription());
+    return Response.ok(Strings.nullToEmpty(group.getDescription()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetDetail.java b/java/com/google/gerrit/server/restapi/group/GetDetail.java
index e7b240e..f6b8930 100644
--- a/java/com/google/gerrit/server/restapi/group/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/group/GetDetail.java
@@ -16,9 +16,10 @@
 
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -32,7 +33,7 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource rsrc) throws OrmException {
-    return json.format(rsrc);
+  public Response<GroupInfo> apply(GroupResource rsrc) throws PermissionBackendException {
+    return Response.ok(json.format(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetGroup.java b/java/com/google/gerrit/server/restapi/group/GetGroup.java
index 81057fd..4785d25 100644
--- a/java/com/google/gerrit/server/restapi/group/GetGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/GetGroup.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -31,7 +32,7 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource resource) throws OrmException {
-    return json.format(resource.getGroup());
+  public Response<GroupInfo> apply(GroupResource resource) throws PermissionBackendException {
+    return Response.ok(json.format(resource.getGroup()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetMember.java b/java/com/google/gerrit/server/restapi/group/GetMember.java
index db33785..8dbcd27 100644
--- a/java/com/google/gerrit/server/restapi/group/GetMember.java
+++ b/java/com/google/gerrit/server/restapi/group/GetMember.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.group.MemberResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -32,10 +33,10 @@
   }
 
   @Override
-  public AccountInfo apply(MemberResource rsrc) throws OrmException {
+  public Response<AccountInfo> apply(MemberResource rsrc) throws PermissionBackendException {
     AccountLoader loader = infoFactory.create(true);
     AccountInfo info = loader.get(rsrc.getMember().getAccountId());
     loader.fill();
-    return info;
+    return Response.ok(info);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetName.java b/java/com/google/gerrit/server/restapi/group/GetName.java
index 8cc1fe0..131dbe4 100644
--- a/java/com/google/gerrit/server/restapi/group/GetName.java
+++ b/java/com/google/gerrit/server/restapi/group/GetName.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.group;
 
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 public class GetName implements RestReadView<GroupResource> {
 
   @Override
-  public String apply(GroupResource resource) {
-    return resource.getName();
+  public Response<String> apply(GroupResource resource) {
+    return Response.ok(resource.getName());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetOptions.java b/java/com/google/gerrit/server/restapi/group/GetOptions.java
index e5bfe30..5d8ba02 100644
--- a/java/com/google/gerrit/server/restapi/group/GetOptions.java
+++ b/java/com/google/gerrit/server/restapi/group/GetOptions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.inject.Singleton;
@@ -23,7 +24,7 @@
 public class GetOptions implements RestReadView<GroupResource> {
 
   @Override
-  public GroupOptionsInfo apply(GroupResource resource) {
-    return GroupJson.createOptions(resource.getGroup());
+  public Response<GroupOptionsInfo> apply(GroupResource resource) {
+    return Response.ok(GroupJson.createOptions(resource.getGroup()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetOwner.java b/java/com/google/gerrit/server/restapi/group/GetOwner.java
index be19a24..30d5c16 100644
--- a/java/com/google/gerrit/server/restapi/group/GetOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/GetOwner.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -38,13 +39,13 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource resource)
-      throws NotInternalGroupException, ResourceNotFoundException, OrmException {
+  public Response<GroupInfo> apply(GroupResource resource)
+      throws NotInternalGroupException, ResourceNotFoundException, PermissionBackendException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     try {
       GroupControl c = controlFactory.validateFor(group.getOwnerGroupUUID());
-      return json.format(c.getGroup());
+      return Response.ok(json.format(c.getGroup()));
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException();
     }
diff --git a/java/com/google/gerrit/server/restapi/group/GetSubgroup.java b/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
index 98e6ce5..c209511 100644
--- a/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
+++ b/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.SubgroupResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -31,7 +32,7 @@
   }
 
   @Override
-  public GroupInfo apply(SubgroupResource rsrc) throws OrmException {
-    return json.format(rsrc.getMemberDescription());
+  public Response<GroupInfo> apply(SubgroupResource rsrc) throws PermissionBackendException {
+    return Response.ok(json.format(rsrc.getMemberDescription()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
index 3c7799b..12b9d61 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupJson.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.Collection;
@@ -75,17 +75,17 @@
     return this;
   }
 
-  public GroupInfo format(GroupResource rsrc) throws OrmException {
+  public GroupInfo format(GroupResource rsrc) throws PermissionBackendException {
     return createGroupInfo(rsrc.getGroup(), rsrc::getControl);
   }
 
-  public GroupInfo format(GroupDescription.Basic group) throws OrmException {
+  public GroupInfo format(GroupDescription.Basic group) throws PermissionBackendException {
     return createGroupInfo(group, Suppliers.memoize(() -> groupControlFactory.controlFor(group)));
   }
 
   private GroupInfo createGroupInfo(
       GroupDescription.Basic group, Supplier<GroupControl> groupControlSupplier)
-      throws OrmException {
+      throws PermissionBackendException {
     GroupInfo info = createBasicGroupInfo(group);
 
     if (group instanceof GroupDescription.Internal) {
@@ -108,7 +108,7 @@
       GroupInfo info,
       GroupDescription.Internal internalGroup,
       Supplier<GroupControl> groupControlSupplier)
-      throws OrmException {
+      throws PermissionBackendException {
     info.description = Strings.emptyToNull(internalGroup.getDescription());
     info.groupId = internalGroup.getId().get();
 
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index fba1f1f..52fe9d0 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -16,44 +16,30 @@
 
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.NeedsParams;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupBackends;
-import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.Optional;
 
 public class GroupsCollection
-    implements RestCollection<TopLevelResource, GroupResource>,
-        AcceptsCreate<TopLevelResource>,
-        NeedsParams {
+    implements RestCollection<TopLevelResource, GroupResource>, NeedsParams {
   private final DynamicMap<RestView<GroupResource>> views;
   private final Provider<ListGroups> list;
   private final Provider<QueryGroups> queryGroups;
-  private final CreateGroup.Factory createGroup;
   private final GroupControl.Factory groupControlFactory;
-  private final GroupBackend groupBackend;
-  private final GroupCache groupCache;
+  private final GroupResolver groupResolver;
   private final Provider<CurrentUser> self;
 
   private boolean hasQuery2;
@@ -63,18 +49,14 @@
       DynamicMap<RestView<GroupResource>> views,
       Provider<ListGroups> list,
       Provider<QueryGroups> queryGroups,
-      CreateGroup.Factory createGroup,
       GroupControl.Factory groupControlFactory,
-      GroupBackend groupBackend,
-      GroupCache groupCache,
+      GroupResolver groupResolver,
       Provider<CurrentUser> self) {
     this.views = views;
     this.list = list;
     this.queryGroups = queryGroups;
-    this.createGroup = createGroup;
     this.groupControlFactory = groupControlFactory;
-    this.groupBackend = groupBackend;
-    this.groupCache = groupCache;
+    this.groupResolver = groupResolver;
     this.self = self;
   }
 
@@ -114,7 +96,7 @@
       throw new ResourceNotFoundException(id);
     }
 
-    GroupDescription.Basic group = parseId(id.get());
+    GroupDescription.Basic group = groupResolver.parseId(id.get());
     if (group == null) {
       throw new ResourceNotFoundException(id.get());
     }
@@ -125,85 +107,6 @@
     return new GroupResource(ctl);
   }
 
-  /**
-   * Parses a group ID from a request body and returns the group.
-   *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
-   * @return the group
-   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved or if the group
-   *     is not visible to the calling user
-   */
-  public GroupDescription.Basic parse(String id) throws UnprocessableEntityException {
-    GroupDescription.Basic group = parseId(id);
-    if (group == null || !groupControlFactory.controlFor(group).isVisible()) {
-      throw new UnprocessableEntityException(String.format("Group Not Found: %s", id));
-    }
-    return group;
-  }
-
-  /**
-   * Parses a group ID from a request body and returns the group if it is a Gerrit internal group.
-   *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
-   * @return the group
-   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved, if the group is
-   *     not visible to the calling user or if it's an external group
-   */
-  public GroupDescription.Internal parseInternal(String id) throws UnprocessableEntityException {
-    GroupDescription.Basic group = parse(id);
-    if (group instanceof GroupDescription.Internal) {
-      return (GroupDescription.Internal) group;
-    }
-
-    throw new UnprocessableEntityException(String.format("External Group Not Allowed: %s", id));
-  }
-
-  /**
-   * Parses a group ID and returns the group without making any permission check whether the current
-   * user can see the group.
-   *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
-   * @return the group, null if no group is found for the given group ID
-   */
-  public GroupDescription.Basic parseId(String id) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(id);
-    if (groupBackend.handles(uuid)) {
-      GroupDescription.Basic d = groupBackend.get(uuid);
-      if (d != null) {
-        return d;
-      }
-    }
-
-    // Might be a numeric AccountGroup.Id. -> Internal group.
-    if (id.matches("^[1-9][0-9]*$")) {
-      try {
-        AccountGroup.Id groupId = AccountGroup.Id.parse(id);
-        Optional<InternalGroup> group = groupCache.get(groupId);
-        if (group.isPresent()) {
-          return new InternalGroupDescription(group.get());
-        }
-      } catch (IllegalArgumentException e) {
-        // Ignored
-      }
-    }
-
-    // Might be a group name, be nice and accept unique names.
-    GroupReference ref = GroupBackends.findExactSuggestion(groupBackend, id);
-    if (ref != null) {
-      GroupDescription.Basic d = groupBackend.get(ref.getUUID());
-      if (d != null) {
-        return d;
-      }
-    }
-
-    return null;
-  }
-
-  @Override
-  public CreateGroup create(TopLevelResource root, IdString name) throws RestApiException {
-    return createGroup.create(name.get());
-  }
-
   @Override
   public DynamicMap<RestView<GroupResource>> views() {
     return views;
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index c7f1d5e..37ea55c 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -15,18 +15,20 @@
 package com.google.gerrit.server.restapi.group;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.ListGroupsOption;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+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.extensions.restapi.TopLevelResource;
@@ -39,11 +41,12 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.account.GetGroups;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -81,7 +84,7 @@
   private final GroupJson json;
   private final GroupBackend groupBackend;
   private final Groups groups;
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
 
   private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
   private boolean visibleToAll;
@@ -200,7 +203,7 @@
 
   @Option(name = "-O", usage = "Output option flags, in hex")
   void setOptionFlagsHex(String hex) {
-    options.addAll(ListGroupsOption.fromBits(Integer.parseInt(hex, 16)));
+    options.addAll(ListOption.fromBits(ListGroupsOption.class, Integer.parseInt(hex, 16)));
   }
 
   @Option(name = "--owned-by", usage = "list groups owned by the given group uuid")
@@ -216,7 +219,7 @@
       final Provider<IdentifiedUser> identifiedUser,
       final IdentifiedUser.GenericFactory userFactory,
       final GetGroups accountGetGroups,
-      final GroupsCollection groupsCollection,
+      final GroupResolver groupResolver,
       GroupJson json,
       GroupBackend groupBackend,
       Groups groups) {
@@ -229,7 +232,7 @@
     this.json = json;
     this.groupBackend = groupBackend;
     this.groups = groups;
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
   }
 
   public void setOptions(EnumSet<ListGroupsOption> options) {
@@ -245,18 +248,18 @@
   }
 
   @Override
-  public SortedMap<String, GroupInfo> apply(TopLevelResource resource)
-      throws OrmException, RestApiException, IOException, ConfigInvalidException {
+  public Response<SortedMap<String, GroupInfo>> apply(TopLevelResource resource)
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     SortedMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
       info.name = null;
     }
-    return output;
+    return Response.ok(output);
   }
 
   public List<GroupInfo> get()
-      throws OrmException, RestApiException, IOException, ConfigInvalidException {
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!Strings.isNullOrEmpty(suggest)) {
       return suggestGroups();
     }
@@ -274,13 +277,14 @@
     }
 
     if (user != null) {
-      return accountGetGroups.apply(new AccountResource(userFactory.create(user)));
+      return accountGetGroups.apply(new AccountResource(userFactory.create(user))).value();
     }
 
     return getAllGroups();
   }
 
-  private List<GroupInfo> getAllGroups() throws OrmException, IOException, ConfigInvalidException {
+  private List<GroupInfo> getAllGroups()
+      throws IOException, ConfigInvalidException, PermissionBackendException {
     Pattern pattern = getRegexPattern();
     Stream<GroupDescription.Internal> existingGroups =
         getAllExistingGroups()
@@ -303,8 +307,7 @@
 
   private Stream<GroupReference> getAllExistingGroups() throws IOException, ConfigInvalidException {
     if (!projects.isEmpty()) {
-      return projects
-          .stream()
+      return projects.stream()
           .map(ProjectState::getAllGroups)
           .flatMap(Collection::stream)
           .distinct();
@@ -312,16 +315,15 @@
     return groups.getAllGroupReferences();
   }
 
-  private List<GroupInfo> suggestGroups() throws OrmException, BadRequestException {
+  private List<GroupInfo> suggestGroups() throws BadRequestException, PermissionBackendException {
     if (conflictingSuggestParameters()) {
       throw new BadRequestException(
           "You should only have no more than one --project and -n with --suggest");
     }
     List<GroupReference> groupRefs =
-        Lists.newArrayList(
-            Iterables.limit(
-                groupBackend.suggest(suggest, projects.stream().findFirst().orElse(null)),
-                limit <= 0 ? 10 : Math.min(limit, 10)));
+        groupBackend.suggest(suggest, projects.stream().findFirst().orElse(null)).stream()
+            .limit(limit <= 0 ? 10 : Math.min(limit, 10))
+            .collect(toList());
 
     List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groupRefs.size());
     for (GroupReference ref : groupRefs) {
@@ -368,7 +370,7 @@
   }
 
   private List<GroupInfo> filterGroupsOwnedBy(Predicate<GroupDescription.Internal> filter)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException, PermissionBackendException {
     Pattern pattern = getRegexPattern();
     Stream<? extends GroupDescription.Internal> foundGroups =
         groups
@@ -396,13 +398,13 @@
   }
 
   private List<GroupInfo> getGroupsOwnedBy(String id)
-      throws OrmException, RestApiException, IOException, ConfigInvalidException {
-    String uuid = groupsCollection.parse(id).getGroupUUID().get();
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+    String uuid = groupResolver.parse(id).getGroupUUID().get();
     return filterGroupsOwnedBy(group -> group.getOwnerGroupUUID().get().equals(uuid));
   }
 
   private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException, PermissionBackendException {
     return filterGroupsOwnedBy(group -> isOwner(user, group));
   }
 
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 7c68aab..af57282 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -32,7 +33,7 @@
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -65,17 +66,18 @@
   }
 
   @Override
-  public List<AccountInfo> apply(GroupResource resource)
-      throws NotInternalGroupException, OrmException {
+  public Response<List<AccountInfo>> apply(GroupResource resource)
+      throws NotInternalGroupException, PermissionBackendException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     if (recursive) {
-      return getTransitiveMembers(group, resource.getControl());
+      return Response.ok(getTransitiveMembers(group, resource.getControl()));
     }
-    return getDirectMembers(group, resource.getControl());
+    return Response.ok(getDirectMembers(group, resource.getControl()));
   }
 
-  public List<AccountInfo> getTransitiveMembers(AccountGroup.UUID groupUuid) throws OrmException {
+  public List<AccountInfo> getTransitiveMembers(AccountGroup.UUID groupUuid)
+      throws PermissionBackendException {
     Optional<InternalGroup> group = groupCache.get(groupUuid);
     if (group.isPresent()) {
       InternalGroupDescription internalGroup = new InternalGroupDescription(group.get());
@@ -86,7 +88,8 @@
   }
 
   private List<AccountInfo> getTransitiveMembers(
-      GroupDescription.Internal group, GroupControl groupControl) throws OrmException {
+      GroupDescription.Internal group, GroupControl groupControl)
+      throws PermissionBackendException {
     checkSameGroup(group, groupControl);
     Set<Account.Id> members =
         getTransitiveMemberIds(
@@ -94,19 +97,21 @@
     return toAccountInfos(members);
   }
 
-  public List<AccountInfo> getDirectMembers(InternalGroup group) throws OrmException {
+  public List<AccountInfo> getDirectMembers(InternalGroup group) throws PermissionBackendException {
     InternalGroupDescription internalGroup = new InternalGroupDescription(group);
     return getDirectMembers(internalGroup, groupControlFactory.controlFor(internalGroup));
   }
 
   public List<AccountInfo> getDirectMembers(
-      GroupDescription.Internal group, GroupControl groupControl) throws OrmException {
+      GroupDescription.Internal group, GroupControl groupControl)
+      throws PermissionBackendException {
     checkSameGroup(group, groupControl);
     Set<Account.Id> directMembers = getDirectMemberIds(group, groupControl);
     return toAccountInfos(directMembers);
   }
 
-  private List<AccountInfo> toAccountInfos(Set<Account.Id> members) throws OrmException {
+  private List<AccountInfo> toAccountInfos(Set<Account.Id> members)
+      throws PermissionBackendException {
     List<AccountInfo> memberInfos = new ArrayList<>(members.size());
     for (Account.Id member : members) {
       memberInfos.add(accountLoader.get(member));
diff --git a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
index 835a613..3e5b152 100644
--- a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
@@ -15,21 +15,21 @@
 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;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 @Singleton
@@ -46,15 +46,17 @@
   }
 
   @Override
-  public List<GroupInfo> apply(GroupResource rsrc) throws NotInternalGroupException, OrmException {
+  public Response<List<GroupInfo>> apply(GroupResource rsrc)
+      throws NotInternalGroupException, PermissionBackendException {
     GroupDescription.Internal group =
         rsrc.asInternalGroup().orElseThrow(NotInternalGroupException::new);
 
-    return getDirectSubgroups(group, rsrc.getControl());
+    return Response.ok(getDirectSubgroups(group, rsrc.getControl()));
   }
 
   public List<GroupInfo> getDirectSubgroups(
-      GroupDescription.Internal group, GroupControl groupControl) throws OrmException {
+      GroupDescription.Internal group, GroupControl groupControl)
+      throws PermissionBackendException {
     boolean ownerOfParent = groupControl.isOwner();
     List<GroupInfo> included = new ArrayList<>();
     for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
@@ -69,18 +71,8 @@
         continue;
       }
     }
-    Collections.sort(
-        included,
-        new Comparator<GroupInfo>() {
-          @Override
-          public int compare(GroupInfo a, GroupInfo b) {
-            int cmp = nullToEmpty(a.name).compareTo(nullToEmpty(b.name));
-            if (cmp != 0) {
-              return cmp;
-            }
-            return nullToEmpty(a.id).compareTo(nullToEmpty(b.id));
-          }
-        });
+    included.sort(
+        comparing((GroupInfo g) -> nullToEmpty(g.name)).thenComparing(g -> nullToEmpty(g.id)));
     return included;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/MembersCollection.java b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
index cf2e0b8..6dfb2b6 100644
--- a/java/com/google/gerrit/server/restapi/group/MembersCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -27,8 +26,6 @@
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.MemberResource;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
-import com.google.gerrit.server.restapi.group.AddMembers.PutMember;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -36,23 +33,19 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class MembersCollection
-    implements ChildCollection<GroupResource, MemberResource>, AcceptsCreate<GroupResource> {
+public class MembersCollection implements ChildCollection<GroupResource, MemberResource> {
   private final DynamicMap<RestView<MemberResource>> views;
   private final Provider<ListMembers> list;
   private final AccountsCollection accounts;
-  private final AddMembers put;
 
   @Inject
   MembersCollection(
       DynamicMap<RestView<MemberResource>> views,
       Provider<ListMembers> list,
-      AccountsCollection accounts,
-      AddMembers put) {
+      AccountsCollection accounts) {
     this.views = views;
     this.list = list;
     this.accounts = accounts;
-    this.put = put;
   }
 
   @Override
@@ -62,8 +55,8 @@
 
   @Override
   public MemberResource parse(GroupResource parent, IdString id)
-      throws NotInternalGroupException, AuthException, ResourceNotFoundException, OrmException,
-          IOException, ConfigInvalidException {
+      throws NotInternalGroupException, AuthException, ResourceNotFoundException, IOException,
+          ConfigInvalidException {
     GroupDescription.Internal group =
         parent.asInternalGroup().orElseThrow(NotInternalGroupException::new);
 
@@ -79,11 +72,6 @@
   }
 
   @Override
-  public PutMember create(GroupResource group, IdString id) {
-    return new PutMember(put, id.get());
-  }
-
-  @Override
   public DynamicMap<RestView<MemberResource>> views() {
     return views;
   }
diff --git a/java/com/google/gerrit/server/restapi/group/Module.java b/java/com/google/gerrit/server/restapi/group/Module.java
index fa1e5c7..45ac411 100644
--- a/java/com/google/gerrit/server/restapi/group/Module.java
+++ b/java/com/google/gerrit/server/restapi/group/Module.java
@@ -24,7 +24,9 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.restapi.group.AddMembers.CreateMember;
 import com.google.gerrit.server.restapi.group.AddMembers.UpdateMember;
+import com.google.gerrit.server.restapi.group.AddSubgroups.CreateSubgroup;
 import com.google.gerrit.server.restapi.group.AddSubgroups.UpdateSubgroup;
 import com.google.gerrit.server.restapi.group.DeleteMembers.DeleteMember;
 import com.google.gerrit.server.restapi.group.DeleteSubgroups.DeleteSubgroup;
@@ -40,6 +42,7 @@
     DynamicMap.mapOf(binder(), MEMBER_KIND);
     DynamicMap.mapOf(binder(), SUBGROUP_KIND);
 
+    create(GROUP_KIND).to(CreateGroup.class);
     get(GROUP_KIND).to(GetGroup.class);
     put(GROUP_KIND).to(PutGroup.class);
     get(GROUP_KIND, "detail").to(GetDetail.class);
@@ -62,23 +65,24 @@
     get(GROUP_KIND, "log.audit").to(GetAuditLog.class);
 
     child(GROUP_KIND, "members").to(MembersCollection.class);
+    create(MEMBER_KIND).to(CreateMember.class);
     get(MEMBER_KIND).to(GetMember.class);
     put(MEMBER_KIND).to(UpdateMember.class);
     delete(MEMBER_KIND).to(DeleteMember.class);
 
     child(GROUP_KIND, "groups").to(SubgroupsCollection.class);
+    create(SUBGROUP_KIND).to(CreateSubgroup.class);
     get(SUBGROUP_KIND).to(GetSubgroup.class);
     put(SUBGROUP_KIND).to(UpdateSubgroup.class);
     delete(SUBGROUP_KIND).to(DeleteSubgroup.class);
 
-    factory(CreateGroup.Factory.class);
     factory(GroupsUpdate.Factory.class);
   }
 
   @Provides
   @ServerInitiated
   GroupsUpdate provideServerInitiatedGroupsUpdate(GroupsUpdate.Factory groupsUpdateFactory) {
-    return groupsUpdateFactory.create(null);
+    return groupsUpdateFactory.createWithServerIdent();
   }
 
   @Provides
diff --git a/java/com/google/gerrit/server/restapi/group/PutDescription.java b/java/com/google/gerrit/server/restapi/group/PutDescription.java
index d407f69..c9078b0 100644
--- a/java/com/google/gerrit/server/restapi/group/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/PutDescription.java
@@ -16,7 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -27,7 +27,6 @@
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -46,8 +45,8 @@
 
   @Override
   public Response<String> apply(GroupResource resource, DescriptionInput input)
-      throws AuthException, NotInternalGroupException, ResourceNotFoundException, OrmException,
-          IOException, ConfigInvalidException {
+      throws AuthException, NotInternalGroupException, ResourceNotFoundException, IOException,
+          ConfigInvalidException {
     if (input == null) {
       input = new DescriptionInput(); // Delete would set description to null.
     }
@@ -72,7 +71,7 @@
     }
 
     return Strings.isNullOrEmpty(input.description)
-        ? Response.<String>none()
+        ? Response.none()
         : Response.ok(input.description);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/PutName.java b/java/com/google/gerrit/server/restapi/group/PutName.java
index 1f1968a..a5dd04f 100644
--- a/java/com/google/gerrit/server/restapi/group/PutName.java
+++ b/java/com/google/gerrit/server/restapi/group/PutName.java
@@ -16,19 +16,20 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.NameInput;
 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.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -45,7 +46,7 @@
   }
 
   @Override
-  public String apply(GroupResource rsrc, NameInput input)
+  public Response<String> apply(GroupResource rsrc, NameInput input)
       throws NotInternalGroupException, AuthException, BadRequestException,
           ResourceConflictException, ResourceNotFoundException, IOException,
           ConfigInvalidException {
@@ -62,11 +63,11 @@
     }
 
     if (internalGroup.getName().equals(newName)) {
-      return newName;
+      return Response.ok(newName);
     }
 
     renameGroup(internalGroup, newName);
-    return newName;
+    return Response.ok(newName);
   }
 
   private void renameGroup(GroupDescription.Internal group, String newName)
@@ -74,12 +75,12 @@
           ConfigInvalidException {
     AccountGroup.UUID groupUuid = group.getGroupUUID();
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(newName)).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey(newName)).build();
     try {
       groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-    } catch (OrmDuplicateKeyException e) {
+    } catch (DuplicateKeyException e) {
       throw new ResourceConflictException("group with name " + newName + " already exists");
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/group/PutOptions.java b/java/com/google/gerrit/server/restapi/group/PutOptions.java
index 29b87d2..b2ea8d3 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOptions.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOptions.java
@@ -15,18 +15,18 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -43,9 +43,9 @@
   }
 
   @Override
-  public GroupOptionsInfo apply(GroupResource resource, GroupOptionsInfo input)
+  public Response<GroupOptionsInfo> apply(GroupResource resource, GroupOptionsInfo input)
       throws NotInternalGroupException, AuthException, BadRequestException,
-          ResourceNotFoundException, OrmException, IOException, ConfigInvalidException {
+          ResourceNotFoundException, IOException, ConfigInvalidException {
     GroupDescription.Internal internalGroup =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     if (!resource.getControl().isOwner()) {
@@ -74,6 +74,6 @@
     if (input.visibleToAll) {
       options.visibleToAll = true;
     }
-    return options;
+    return Response.ok(options);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/PutOwner.java b/java/com/google/gerrit/server/restapi/group/PutOwner.java
index 5e7563e..e3e933e 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOwner.java
@@ -16,20 +16,22 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.groups.OwnerInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -38,25 +40,25 @@
 
 @Singleton
 public class PutOwner implements RestModifyView<GroupResource, OwnerInput> {
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupJson json;
 
   @Inject
   PutOwner(
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupJson json) {
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.json = json;
   }
 
   @Override
-  public GroupInfo apply(GroupResource resource, OwnerInput input)
+  public Response<GroupInfo> apply(GroupResource resource, OwnerInput input)
       throws ResourceNotFoundException, NotInternalGroupException, AuthException,
-          BadRequestException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
+          BadRequestException, UnprocessableEntityException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     GroupDescription.Internal internalGroup =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     if (!resource.getControl().isOwner()) {
@@ -67,7 +69,7 @@
       throw new BadRequestException("owner is required");
     }
 
-    GroupDescription.Basic owner = groupsCollection.parse(input.owner);
+    GroupDescription.Basic owner = groupResolver.parse(input.owner);
     if (!internalGroup.getOwnerGroupUUID().equals(owner.getGroupUUID())) {
       AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
       InternalGroupUpdate groupUpdate =
@@ -78,6 +80,6 @@
         throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
       }
     }
-    return json.format(owner);
+    return Response.ok(json.format(owner));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index c262003..816fcf6 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -17,18 +17,20 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.client.ListGroupsOption;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.group.GroupQueryBuilder;
 import com.google.gerrit.server.query.group.GroupQueryProcessor;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.EnumSet;
@@ -81,7 +83,7 @@
 
   @Option(name = "-O", usage = "Output option flags, in hex")
   public void setOptionFlagsHex(String hex) {
-    options.addAll(ListGroupsOption.fromBits(Integer.parseInt(hex, 16)));
+    options.addAll(ListOption.fromBits(ListGroupsOption.class, Integer.parseInt(hex, 16)));
   }
 
   @Inject
@@ -93,8 +95,8 @@
   }
 
   @Override
-  public List<GroupInfo> apply(TopLevelResource resource)
-      throws BadRequestException, MethodNotAllowedException, OrmException {
+  public Response<List<GroupInfo>> apply(TopLevelResource resource)
+      throws BadRequestException, MethodNotAllowedException, PermissionBackendException {
     if (Strings.isNullOrEmpty(query)) {
       throw new BadRequestException("missing query field");
     }
@@ -123,7 +125,7 @@
       if (!groupInfos.isEmpty() && result.more()) {
         groupInfos.get(groupInfos.size() - 1)._moreGroups = true;
       }
-      return groupInfos;
+      return Response.ok(groupInfos);
     } catch (QueryParseException e) {
       throw new BadRequestException(e.getMessage());
     }
diff --git a/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
index 83520f1..cebc27a 100644
--- a/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -25,28 +24,23 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
-import com.google.gerrit.server.restapi.group.AddSubgroups.PutSubgroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class SubgroupsCollection
-    implements ChildCollection<GroupResource, SubgroupResource>, AcceptsCreate<GroupResource> {
+public class SubgroupsCollection implements ChildCollection<GroupResource, SubgroupResource> {
   private final DynamicMap<RestView<SubgroupResource>> views;
   private final ListSubgroups list;
   private final GroupsCollection groupsCollection;
-  private final AddSubgroups addSubgroups;
 
   @Inject
   SubgroupsCollection(
       DynamicMap<RestView<SubgroupResource>> views,
       ListSubgroups list,
-      GroupsCollection groupsCollection,
-      AddSubgroups addSubgroups) {
+      GroupsCollection groupsCollection) {
     this.views = views;
     this.list = list;
     this.groupsCollection = groupsCollection;
-    this.addSubgroups = addSubgroups;
   }
 
   @Override
@@ -74,11 +68,6 @@
   }
 
   @Override
-  public PutSubgroup create(GroupResource group, IdString id) {
-    return new PutSubgroup(addSubgroups, id.get());
-  }
-
-  @Override
   public DynamicMap<RestView<SubgroupResource>> views() {
     return views;
   }
diff --git a/java/com/google/gerrit/server/restapi/project/BanCommit.java b/java/com/google/gerrit/server/restapi/project/BanCommit.java
index 3d101b2..64e38b0 100644
--- a/java/com/google/gerrit/server/restapi/project/BanCommit.java
+++ b/java/com/google/gerrit/server/restapi/project/BanCommit.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.projects.BanCommitInput;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.git.BanCommitResult;
@@ -45,7 +46,7 @@
   }
 
   @Override
-  protected BanResultInfo applyImpl(
+  protected Response<BanResultInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ProjectResource rsrc, BanCommitInput input)
       throws RestApiException, UpdateException, IOException, PermissionBackendException {
     BanResultInfo r = new BanResultInfo();
@@ -65,7 +66,7 @@
       r.alreadyBanned = transformCommits(result.getAlreadyBannedCommits());
       r.ignored = transformCommits(result.getIgnoredObjectIds());
     }
-    return r;
+    return Response.ok(r);
   }
 
   private static List<String> transformCommits(List<ObjectId> commits) {
diff --git a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
index f8ff7b9..2b7e089 100644
--- a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -39,26 +38,22 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class BranchesCollection
-    implements ChildCollection<ProjectResource, BranchResource>, AcceptsCreate<ProjectResource> {
+public class BranchesCollection implements ChildCollection<ProjectResource, BranchResource> {
   private final DynamicMap<RestView<BranchResource>> views;
   private final Provider<ListBranches> list;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
-  private final CreateBranch.Factory createBranchFactory;
 
   @Inject
   BranchesCollection(
       DynamicMap<RestView<BranchResource>> views,
       Provider<ListBranches> list,
       PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
-      CreateBranch.Factory createBranchFactory) {
+      GitRepositoryManager repoManager) {
     this.views = views;
     this.list = list;
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
-    this.createBranchFactory = createBranchFactory;
   }
 
   @Override
@@ -71,7 +66,6 @@
       throws RestApiException, IOException, PermissionBackendException {
     parent.getProjectState().checkStatePermitsRead();
     Project.NameKey project = parent.getNameKey();
-    parent.getProjectState().checkStatePermitsRead();
     try (Repository repo = repoManager.openRepository(project)) {
       Ref ref = repo.exactRef(RefNames.fullName(id.get()));
       if (ref == null) {
@@ -98,9 +92,4 @@
   public DynamicMap<RestView<BranchResource>> views() {
     return views;
   }
-
-  @Override
-  public CreateBranch create(ProjectResource parent, IdString name) {
-    return createBranchFactory.create(name.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/Check.java b/java/com/google/gerrit/server/restapi/project/Check.java
new file mode 100644
index 0000000..66a2df4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/Check.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.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.Response;
+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 Response<CheckProjectResultInfo> apply(ProjectResource rsrc, CheckProjectInput input)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    permissionBackend.user(rsrc.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
+    return Response.ok(projectsConsistencyChecker.check(rsrc.getNameKey(), input));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 865f077..516e126 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -21,12 +21,11 @@
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.DefaultPermissionMappings;
@@ -36,7 +35,6 @@
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -48,26 +46,22 @@
 @Singleton
 public class CheckAccess implements RestModifyView<ProjectResource, AccessCheckInput> {
   private final AccountResolver accountResolver;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitRepositoryManager;
 
   @Inject
   CheckAccess(
       AccountResolver resolver,
-      IdentifiedUser.GenericFactory userFactory,
       PermissionBackend permissionBackend,
       GitRepositoryManager gitRepositoryManager) {
     this.accountResolver = resolver;
-    this.userFactory = userFactory;
     this.permissionBackend = permissionBackend;
     this.gitRepositoryManager = gitRepositoryManager;
   }
 
   @Override
-  public AccessCheckInfo apply(ProjectResource rsrc, AccessCheckInput input)
-      throws OrmException, PermissionBackendException, RestApiException, IOException,
-          ConfigInvalidException {
+  public Response<AccessCheckInfo> apply(ProjectResource rsrc, AccessCheckInput input)
+      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
     permissionBackend.user(rsrc.getUser()).check(GlobalPermission.VIEW_ACCESS);
 
     rsrc.getProjectState().checkStatePermitsRead();
@@ -79,27 +73,21 @@
       throw new BadRequestException("input requires 'account'");
     }
 
-    Account match = accountResolver.find(input.account);
-    if (match == null) {
-      throw new UnprocessableEntityException(
-          String.format("cannot find account %s", input.account));
-    }
+    Account.Id match = accountResolver.resolve(input.account).asUnique().getAccount().id();
 
     AccessCheckInfo info = new AccessCheckInfo();
-
-    IdentifiedUser user = userFactory.create(match.getId());
     try {
-      permissionBackend.user(user).project(rsrc.getNameKey()).check(ProjectPermission.ACCESS);
+      permissionBackend
+          .absentUser(match)
+          .project(rsrc.getNameKey())
+          .check(ProjectPermission.ACCESS);
     } catch (AuthException e) {
-      info.message =
-          String.format(
-              "user %s (%s) cannot see project %s",
-              user.getNameEmail(), user.getAccount().getId(), rsrc.getName());
+      info.message = String.format("user %s cannot see project %s", match, rsrc.getName());
       info.status = HttpServletResponse.SC_FORBIDDEN;
-      return info;
+      return Response.ok(info);
     }
 
-    RefPermission refPerm = null;
+    RefPermission refPerm;
     if (!Strings.isNullOrEmpty(input.permission)) {
       if (Strings.isNullOrEmpty(input.ref)) {
         throw new BadRequestException("must set 'ref' when specifying 'permission'");
@@ -118,31 +106,27 @@
     if (!Strings.isNullOrEmpty(input.ref)) {
       try {
         permissionBackend
-            .user(user)
-            .ref(new Branch.NameKey(rsrc.getNameKey(), input.ref))
+            .absentUser(match)
+            .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
             .check(refPerm);
       } catch (AuthException e) {
         info.status = HttpServletResponse.SC_FORBIDDEN;
         info.message =
             String.format(
-                "user %s (%s) lacks permission %s for %s in project %s",
-                user.getNameEmail(),
-                user.getAccount().getId(),
-                input.permission,
-                input.ref,
-                rsrc.getName());
-        return info;
+                "user %s lacks permission %s for %s in project %s",
+                match, input.permission, input.ref, rsrc.getName());
+        return Response.ok(info);
       }
     } else {
       // We say access is okay if there are no refs, but this warrants a warning,
       // as access denied looks the same as no branches to the user.
       try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
-        if (repo.getRefDatabase().getRefs(REFS_HEADS).isEmpty()) {
+        if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
           info.message = "access is OK, but repository has no branches under refs/heads/";
         }
       }
     }
     info.status = HttpServletResponse.SC_OK;
-    return info;
+    return Response.ok(info);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
index b14a16d..6aaa678 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
@@ -16,13 +16,13 @@
 
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
+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.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import java.io.IOException;
-import javax.inject.Inject;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.kohsuke.args4j.Option;
 
@@ -49,9 +49,8 @@
   }
 
   @Override
-  public AccessCheckInfo apply(ProjectResource rsrc)
-      throws OrmException, PermissionBackendException, RestApiException, IOException,
-          ConfigInvalidException {
+  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
+      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
 
     AccessCheckInput input = new AccessCheckInput();
     input.ref = refName;
diff --git a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
index de2ac64..69a6da8 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -75,7 +76,7 @@
   }
 
   @Override
-  public MergeableInfo apply(BranchResource resource)
+  public Response<MergeableInfo> apply(BranchResource resource)
       throws IOException, BadRequestException, ResourceNotFoundException {
     if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
         || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
@@ -106,7 +107,7 @@
         result.mergeable = true;
         result.commitMerged = true;
         result.contentMerged = true;
-        return result;
+        return Response.ok(result);
       }
 
       if (m.merge(false, targetCommit, sourceCommit)) {
@@ -122,6 +123,6 @@
     } catch (IllegalArgumentException e) {
       throw new BadRequestException(e.getMessage());
     }
-    return result;
+    return Response.ok(result);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java b/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java
index 3855b78..0eb60f5 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+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.reviewdb.client.Project;
 import com.google.gerrit.server.change.IncludedIn;
 import com.google.gerrit.server.project.CommitResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -36,10 +36,9 @@
   }
 
   @Override
-  public IncludedInInfo apply(CommitResource rsrc)
-      throws RestApiException, OrmException, IOException {
+  public Response<IncludedInInfo> apply(CommitResource rsrc) throws RestApiException, IOException {
     RevCommit commit = rsrc.getCommit();
     Project.NameKey project = rsrc.getProjectState().getNameKey();
-    return includedIn.apply(project, commit.getId().getName());
+    return Response.ok(includedIn.apply(project, commit.getId().getName()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 15cd824..25bdadb 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.flogger.FluentLogger;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -22,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.project.CommitResource;
@@ -30,7 +32,6 @@
 import com.google.gerrit.server.project.Reachable;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -39,14 +40,13 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
 public class CommitsCollection implements ChildCollection<ProjectResource, CommitResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final DynamicMap<RestView<CommitResource>> views;
   private final GitRepositoryManager repoManager;
   private final ChangeIndexCollection indexes;
@@ -105,23 +105,27 @@
   }
 
   /** @return true if {@code commit} is visible to the caller. */
-  public boolean canRead(ProjectState state, Repository repo, RevCommit commit) {
+  public boolean canRead(ProjectState state, Repository repo, RevCommit commit) throws IOException {
     Project.NameKey project = state.getNameKey();
-
-    // Look for changes associated with the commit.
-    if (indexes.getSearchIndex() != null) {
-      try {
-        List<ChangeData> changes =
-            queryProvider.get().enforceVisibility(true).byProjectCommit(project, commit);
-        if (!changes.isEmpty()) {
-          return true;
-        }
-      } catch (OrmException e) {
-        logger.atSevere().withCause(e).log(
-            "Cannot look up change for commit %s in %s", commit.name(), project);
-      }
+    if (indexes.getSearchIndex() == null) {
+      // No index in slaves, fall back to scanning refs.
+      return reachable.fromRefs(project, repo, commit, repo.getRefDatabase().getRefs());
     }
 
-    return reachable.fromRefs(project, repo, commit, repo.getAllRefs());
+    // Check first if any change references the commit in question. This is much cheaper than ref
+    // visibility filtering and reachability computation.
+    List<ChangeData> changes =
+        queryProvider.get().enforceVisibility(true).setLimit(1).byProjectCommit(project, commit);
+    if (!changes.isEmpty()) {
+      return true;
+    }
+
+    // If we have already checked change refs using the change index, spare any further checks for
+    // changes.
+    List<Ref> refs =
+        repo.getRefDatabase().getRefs().stream()
+            .filter(r -> !r.getName().startsWith(RefNames.REFS_CHANGES))
+            .collect(toImmutableList());
+    return reachable.fromRefs(project, repo, commit, refs);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
index 0d52090..37bc265 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
@@ -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();
@@ -109,18 +101,27 @@
     for (UiAction.Description d : uiActions.from(views, new ProjectResource(projectState, user))) {
       actions.put(d.getId(), new ActionInfo(d));
     }
-    this.theme = projectState.getTheme();
 
     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,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects) {
     TreeMap<String, Map<String, ConfigParameterInfo>> pluginConfig = new TreeMap<>();
-    for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+    for (Extension<ProjectConfigEntry> e : pluginConfigEntries) {
       ProjectConfigEntry configEntry = e.getProvider().get();
       PluginConfig cfg = cfgFactory.getFromProjectConfig(project, e.getPluginName());
       String configuredValue = cfg.getString(e.getExportName());
@@ -163,7 +164,7 @@
   }
 
   private String getInheritedValue(
-      ProjectState project, PluginConfigFactory cfgFactory, Entry<ProjectConfigEntry> e) {
+      ProjectState project, PluginConfigFactory cfgFactory, Extension<ProjectConfigEntry> e) {
     ProjectConfigEntry configEntry = e.getProvider().get();
     ProjectState parent = Iterables.getFirst(project.parents(), null);
     String inheritedValue = configEntry.getDefaultValue();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 1529dae..2734da2 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -29,12 +28,11 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -44,7 +42,7 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -64,10 +62,10 @@
   private final ChangeInserter.Factory changeInserterFactory;
   private final BatchUpdate.Factory updateFactory;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final Provider<ReviewDb> db;
   private final SetAccessUtil setAccess;
   private final ChangeJson.Factory jsonFactory;
   private final ProjectCache projectCache;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   CreateAccessChange(
@@ -76,25 +74,25 @@
       BatchUpdate.Factory updateFactory,
       Sequences seq,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      Provider<ReviewDb> db,
       SetAccessUtil accessUtil,
       ChangeJson.Factory jsonFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ProjectConfig.Factory projectConfigFactory) {
     this.permissionBackend = permissionBackend;
     this.seq = seq;
     this.changeInserterFactory = changeInserterFactory;
     this.updateFactory = updateFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.db = db;
     this.setAccess = accessUtil;
     this.jsonFactory = jsonFactory;
     this.projectCache = projectCache;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
   public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
       throws PermissionBackendException, AuthException, IOException, ConfigInvalidException,
-          OrmException, InvalidNameException, UpdateException, RestApiException {
+          InvalidNameException, UpdateException, RestApiException {
     PermissionBackend.ForProject forProject =
         permissionBackend.user(rsrc.getUser()).project(rsrc.getNameKey());
     if (!check(forProject, ProjectPermission.READ_CONFIG)) {
@@ -114,10 +112,10 @@
     List<AccessSection> additions = setAccess.getAccessSections(input.add);
 
     Project.NameKey newParentProjectName =
-        input.parent == null ? null : new Project.NameKey(input.parent);
+        input.parent == null ? null : Project.nameKey(input.parent);
 
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       ObjectId oldCommit = config.getRevision();
       String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
 
@@ -136,11 +134,10 @@
 
       md.setMessage("Review access change");
       md.setInsertChangeId(true);
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      Change.Id changeId = Change.id(seq.nextChangeId());
 
       RevCommit commit =
-          config.commitToNewRef(
-              md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+          config.commitToNewRef(md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
 
       if (commit.name().equals(oldCommitSha1)) {
         throw new BadRequestException("no change");
@@ -150,13 +147,15 @@
           ObjectReader objReader = objInserter.newReader();
           RevWalk rw = new RevWalk(objReader);
           BatchUpdate bu =
-              updateFactory.create(db.get(), rsrc.getNameKey(), rsrc.getUser(), TimeUtil.nowTs())) {
+              updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.nowTs())) {
         bu.setRepository(md.getRepository(), rw, objInserter);
         ChangeInserter ins = newInserter(changeId, commit);
         bu.insertChange(ins);
         bu.execute();
         return Response.created(jsonFactory.noOptions().format(ins.getChange()));
       }
+    } catch (InvalidNameException e) {
+      throw new BadRequestException(e.toString());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 0296c9c..711d09e 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -21,9 +21,11 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -31,6 +33,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.project.CreateRefControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectResource;
@@ -39,7 +42,7 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
@@ -50,20 +53,17 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class CreateBranch implements RestModifyView<ProjectResource, BranchInput> {
+@Singleton
+public class CreateBranch
+    implements RestCollectionCreateView<ProjectResource, BranchResource, BranchInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    CreateBranch create(String ref);
-  }
-
   private final Provider<IdentifiedUser> identifiedUser;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated referenceUpdated;
   private final RefValidationHelper refCreationValidator;
   private final CreateRefControl createRefControl;
-  private String ref;
 
   @Inject
   CreateBranch(
@@ -72,21 +72,20 @@
       GitRepositoryManager repoManager,
       GitReferenceUpdated referenceUpdated,
       RefValidationHelper.Factory refHelperFactory,
-      CreateRefControl createRefControl,
-      @Assisted String ref) {
+      CreateRefControl createRefControl) {
     this.identifiedUser = identifiedUser;
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.referenceUpdated = referenceUpdated;
     this.refCreationValidator = refHelperFactory.create(ReceiveCommand.Type.CREATE);
     this.createRefControl = createRefControl;
-    this.ref = ref;
   }
 
   @Override
-  public BranchInfo apply(ProjectResource rsrc, BranchInput input)
+  public Response<BranchInfo> apply(ProjectResource rsrc, IdString id, BranchInput input)
       throws BadRequestException, AuthException, ResourceConflictException, IOException,
           PermissionBackendException, NoSuchProjectException {
+    String ref = id.get();
     if (input == null) {
       input = new BranchInput();
     }
@@ -110,7 +109,7 @@
               + "\"");
     }
 
-    final Branch.NameKey name = new Branch.NameKey(rsrc.getNameKey(), ref);
+    final BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
@@ -142,7 +141,7 @@
           case NEW:
           case NO_CHANGE:
             referenceUpdated.fire(
-                name.getParentKey(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
+                name.project(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
             break;
           case LOCK_FAILURE:
             if (repo.getRefDatabase().exactRef(ref) != null) {
@@ -180,7 +179,7 @@
         info.ref = ref;
         info.revision = revid.getName();
 
-        if (isConfigRef(name.get())) {
+        if (isConfigRef(name.branch())) {
           // Never allow to delete the meta config branch.
           info.canDelete = null;
         } else {
@@ -190,7 +189,7 @@
                   ? true
                   : null;
         }
-        return info;
+        return Response.created(info);
       } catch (IOException err) {
         logger.atSevere().withCause(err).log("Cannot create branch \"%s\"", name);
         throw err;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
new file mode 100644
index 0000000..9904b1f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DashboardResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.kohsuke.args4j.Option;
+
+@Singleton
+public class CreateDashboard
+    implements RestCollectionCreateView<ProjectResource, DashboardResource, SetDashboardInput> {
+  private final Provider<SetDefaultDashboard> setDefault;
+
+  @Option(name = "--inherited", usage = "set dashboard inherited by children")
+  private boolean inherited;
+
+  @Inject
+  CreateDashboard(Provider<SetDefaultDashboard> setDefault) {
+    this.setDefault = setDefault;
+  }
+
+  @Override
+  public Response<DashboardInfo> apply(ProjectResource parent, IdString id, SetDashboardInput input)
+      throws RestApiException, IOException, PermissionBackendException {
+    parent.getProjectState().checkStatePermitsWrite();
+    if (!DashboardsCollection.isDefaultDashboard(id)) {
+      throw new ResourceNotFoundException(id);
+    }
+    SetDefaultDashboard set = setDefault.get();
+    set.inherited = inherited;
+    return Response.created(
+        set.apply(
+                DashboardResource.projectDefault(parent.getProjectState(), parent.getUser()), input)
+            .value());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 3a9a0e7..6844cac 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -14,153 +14,99 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.ProjectUtil;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.events.NewProjectCreatedListener;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-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.IdentifiedUser;
-import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
-import com.google.gerrit.server.config.RepositoryConfig;
-import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.RepositoryCaseMismatchException;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.CreateProjectArgs;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectCreator;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectNameLockManager;
+import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.locks.Lock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 @RequiresCapability(GlobalCapability.CREATE_PROJECT)
-public class CreateProject implements RestModifyView<TopLevelResource, ProjectInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface Factory {
-    CreateProject create(String name);
-  }
-
+@Singleton
+public class CreateProject
+    implements RestCollectionCreateView<TopLevelResource, ProjectResource, ProjectInput> {
   private final Provider<ProjectsCollection> projectsCollection;
-  private final Provider<GroupsCollection> groupsCollection;
-  private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+  private final Provider<GroupResolver> groupResolver;
+  private final PluginSetContext<ProjectCreationValidationListener>
+      projectCreationValidationListeners;
   private final ProjectJson json;
-  private final GitRepositoryManager repoManager;
-  private final DynamicSet<NewProjectCreatedListener> createdListeners;
-  private final ProjectCache projectCache;
-  private final GroupBackend groupBackend;
   private final ProjectOwnerGroupsProvider.Factory projectOwnerGroups;
-  private final MetaDataUpdate.User metaDataUpdateFactory;
-  private final GitReferenceUpdated referenceUpdated;
-  private final RepositoryConfig repositoryCfg;
-  private final PersonIdent serverIdent;
-  private final Provider<IdentifiedUser> identifiedUser;
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
-  private final DynamicItem<ProjectNameLockManager> lockManager;
-  private final String name;
+  private final PluginItemContext<ProjectNameLockManager> lockManager;
+  private final ProjectCreator projectCreator;
 
   @Inject
   CreateProject(
+      ProjectCreator projectCreator,
       Provider<ProjectsCollection> projectsCollection,
-      Provider<GroupsCollection> groupsCollection,
+      Provider<GroupResolver> groupResolver,
       ProjectJson json,
-      DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
-      GitRepositoryManager repoManager,
-      DynamicSet<NewProjectCreatedListener> createdListeners,
-      ProjectCache projectCache,
-      GroupBackend groupBackend,
+      PluginSetContext<ProjectCreationValidationListener> projectCreationValidationListeners,
       ProjectOwnerGroupsProvider.Factory projectOwnerGroups,
-      MetaDataUpdate.User metaDataUpdateFactory,
-      GitReferenceUpdated referenceUpdated,
-      RepositoryConfig repositoryCfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      Provider<IdentifiedUser> identifiedUser,
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      DynamicItem<ProjectNameLockManager> lockManager,
-      @Assisted String name) {
+      PluginItemContext<ProjectNameLockManager> lockManager) {
     this.projectsCollection = projectsCollection;
-    this.groupsCollection = groupsCollection;
+    this.projectCreator = projectCreator;
+    this.groupResolver = groupResolver;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
     this.json = json;
-    this.repoManager = repoManager;
-    this.createdListeners = createdListeners;
-    this.projectCache = projectCache;
-    this.groupBackend = groupBackend;
     this.projectOwnerGroups = projectOwnerGroups;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.referenceUpdated = referenceUpdated;
-    this.repositoryCfg = repositoryCfg;
-    this.serverIdent = serverIdent;
-    this.identifiedUser = identifiedUser;
     this.putConfig = putConfig;
     this.allProjects = allProjects;
     this.allUsers = allUsers;
     this.lockManager = lockManager;
-    this.name = name;
   }
 
   @Override
-  public Response<ProjectInfo> apply(TopLevelResource resource, ProjectInput input)
+  public Response<ProjectInfo> apply(TopLevelResource resource, IdString id, ProjectInput input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+    String name = id.get();
     if (input == null) {
       input = new ProjectInput();
     }
@@ -169,7 +115,7 @@
     }
 
     CreateProjectArgs args = new CreateProjectArgs();
-    args.setProjectName(ProjectUtil.stripGitSuffix(name));
+    args.setProjectName(ProjectUtil.sanitizeProjectName(name));
 
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
@@ -188,7 +134,7 @@
     } else {
       args.ownerIds = Lists.newArrayListWithCapacity(input.owners.size());
       for (String owner : input.owners) {
-        args.ownerIds.add(groupsCollection.get().parse(owner).getGroupUUID());
+        args.ownerIds.add(groupResolver.get().parse(owner).getGroupUUID());
       }
     }
     args.contributorAgreements =
@@ -205,25 +151,30 @@
         MoreObjects.firstNonNull(input.requireChangeId, InheritableBoolean.INHERIT);
     args.rejectEmptyCommit =
         MoreObjects.firstNonNull(input.rejectEmptyCommit, InheritableBoolean.INHERIT);
+    args.enableSignedPush =
+        MoreObjects.firstNonNull(input.enableSignedPush, InheritableBoolean.INHERIT);
+    args.requireSignedPush =
+        MoreObjects.firstNonNull(input.requireSignedPush, InheritableBoolean.INHERIT);
     try {
       args.maxObjectSizeLimit = ProjectConfig.validMaxObjectSizeLimit(input.maxObjectSizeLimit);
     } catch (ConfigInvalidException e) {
       throw new BadRequestException(e.getMessage());
     }
 
-    Lock nameLock = lockManager.get().getLock(args.getProject());
+    Lock nameLock = lockManager.call(lockManager -> lockManager.getLock(args.getProject()));
     nameLock.lock();
     try {
-      for (ProjectCreationValidationListener l : projectCreationValidationListeners) {
-        try {
-          l.validateNewProject(args);
-        } catch (ValidationException e) {
-          throw new ResourceConflictException(e.getMessage(), e);
-        }
+      try {
+        projectCreationValidationListeners.runEach(
+            l -> l.validateNewProject(args), ValidationException.class);
+      } catch (ValidationException e) {
+        throw new ResourceConflictException(e.getMessage(), e);
       }
 
-      ProjectState projectState = createProject(args);
-      checkNotNull(projectState, "failed to create project " + args.getProject().get());
+      ProjectState projectState = projectCreator.createProject(args);
+      requireNonNull(
+          projectState,
+          () -> String.format("failed to create project %s", args.getProject().get()));
 
       if (input.pluginConfigValues != null) {
         ConfigInput in = new ConfigInput();
@@ -236,91 +187,6 @@
     }
   }
 
-  private ProjectState createProject(CreateProjectArgs args)
-      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
-    final Project.NameKey nameKey = args.getProject();
-    try {
-      final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
-      try (Repository repo = repoManager.openRepository(nameKey)) {
-        if (repo.getObjectDatabase().exists()) {
-          throw new ResourceConflictException("project \"" + nameKey + "\" exists");
-        }
-      } catch (RepositoryNotFoundException e) {
-        // It does not exist, safe to ignore.
-      }
-      try (Repository repo = repoManager.createRepository(nameKey)) {
-        RefUpdate u = repo.updateRef(Constants.HEAD);
-        u.disableRefLog();
-        u.link(head);
-
-        createProjectConfig(args);
-
-        if (!args.permissionsOnly && args.createEmptyCommit) {
-          createEmptyCommits(repo, nameKey, args.branch);
-        }
-
-        fire(nameKey, head);
-
-        return projectCache.get(nameKey);
-      }
-    } catch (RepositoryCaseMismatchException e) {
-      throw new ResourceConflictException(
-          "Cannot create "
-              + nameKey.get()
-              + " because the name is already occupied by another project."
-              + " The other project has the same name, only spelled in a"
-              + " different case.");
-    } catch (RepositoryNotFoundException badName) {
-      throw new BadRequestException("invalid project name: " + nameKey);
-    } catch (ConfigInvalidException e) {
-      String msg = "Cannot create " + nameKey;
-      logger.atSevere().withCause(e).log(msg);
-      throw e;
-    }
-  }
-
-  private void createProjectConfig(CreateProjectArgs args)
-      throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
-      ProjectConfig config = ProjectConfig.read(md);
-
-      Project newProject = config.getProject();
-      newProject.setDescription(args.projectDescription);
-      newProject.setSubmitType(
-          MoreObjects.firstNonNull(
-              args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
-      newProject.setBooleanConfig(
-          BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, args.contributorAgreements);
-      newProject.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, args.signedOffBy);
-      newProject.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, args.contentMerge);
-      newProject.setBooleanConfig(
-          BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
-          args.newChangeForAllNotInTarget);
-      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
-      newProject.setBooleanConfig(BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
-      newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
-      if (args.newParent != null) {
-        newProject.setParentName(args.newParent);
-      }
-
-      if (!args.ownerIds.isEmpty()) {
-        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-        for (AccountGroup.UUID ownerId : args.ownerIds) {
-          GroupDescription.Basic g = groupBackend.get(ownerId);
-          if (g != null) {
-            GroupReference group = config.resolve(GroupReference.forGroup(g));
-            all.getPermission(Permission.OWNER, true).add(new PermissionRule(group));
-          }
-        }
-      }
-
-      md.setMessage("Created project\n");
-      config.commit(md);
-      md.getRepository().setGitwebDescription(args.projectDescription);
-    }
-    projectCache.onCreateProject(args.getProject());
-  }
-
   private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
     if (branches == null || branches.isEmpty()) {
       return Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
@@ -341,84 +207,4 @@
     }
     return normalizedBranches;
   }
-
-  private void createEmptyCommits(Repository repo, Project.NameKey project, List<String> refs)
-      throws IOException {
-    try (ObjectInserter oi = repo.newObjectInserter()) {
-      CommitBuilder cb = new CommitBuilder();
-      cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
-      cb.setAuthor(metaDataUpdateFactory.getUserPersonIdent());
-      cb.setCommitter(serverIdent);
-      cb.setMessage("Initial empty repository\n");
-
-      ObjectId id = oi.insert(cb);
-      oi.flush();
-
-      for (String ref : refs) {
-        RefUpdate ru = repo.updateRef(ref);
-        ru.setNewObjectId(id);
-        Result result = ru.update();
-        switch (result) {
-          case NEW:
-            referenceUpdated.fire(
-                project, ru, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
-            break;
-          case FAST_FORWARD:
-          case FORCED:
-          case IO_FAILURE:
-          case LOCK_FAILURE:
-          case NOT_ATTEMPTED:
-          case NO_CHANGE:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            {
-              throw new IOException(
-                  String.format("Failed to create ref \"%s\": %s", ref, result.name()));
-            }
-        }
-      }
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Cannot create empty commit for %s", project.get());
-      throw e;
-    }
-  }
-
-  private void fire(Project.NameKey name, String head) {
-    if (!createdListeners.iterator().hasNext()) {
-      return;
-    }
-
-    Event event = new Event(name, head);
-    for (NewProjectCreatedListener l : createdListeners) {
-      try {
-        l.onNewProjectCreated(event);
-      } catch (RuntimeException e) {
-        logger.atWarning().withCause(e).log("Failure in NewProjectCreatedListener");
-      }
-    }
-  }
-
-  static class Event extends AbstractNoNotifyEvent implements NewProjectCreatedListener.Event {
-    private final Project.NameKey name;
-    private final String head;
-
-    Event(Project.NameKey name, String head) {
-      this.name = name;
-      this.head = head;
-    }
-
-    @Override
-    public String getProjectName() {
-      return name.get();
-    }
-
-    @Override
-    public String getHeadName() {
-      return head;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index b09d870..dca6e9a 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -18,15 +18,16 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -38,8 +39,10 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.RefUtil;
 import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
+import com.google.gerrit.server.project.TagResource;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.TimeZone;
 import org.eclipse.jgit.api.Git;
@@ -52,19 +55,14 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-public class CreateTag implements RestModifyView<ProjectResource, TagInput> {
+@Singleton
+public class CreateTag implements RestCollectionCreateView<ProjectResource, TagResource, TagInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface Factory {
-    CreateTag create(String ref);
-  }
-
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final TagCache tagCache;
   private final GitReferenceUpdated referenceUpdated;
   private final WebLinks links;
-  private String ref;
 
   @Inject
   CreateTag(
@@ -72,19 +70,18 @@
       GitRepositoryManager repoManager,
       TagCache tagCache,
       GitReferenceUpdated referenceUpdated,
-      WebLinks webLinks,
-      @Assisted String ref) {
+      WebLinks webLinks) {
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.tagCache = tagCache;
     this.referenceUpdated = referenceUpdated;
     this.links = webLinks;
-    this.ref = ref;
   }
 
   @Override
-  public TagInfo apply(ProjectResource resource, TagInput input)
+  public Response<TagInfo> apply(ProjectResource resource, IdString id, TagInput input)
       throws RestApiException, IOException, PermissionBackendException, NoSuchProjectException {
+    String ref = id.get();
     if (input == null) {
       input = new TagInput();
     }
@@ -144,7 +141,8 @@
             result.getObjectId(),
             resource.getUser().asIdentifiedUser().state());
         try (RevWalk w = new RevWalk(repo)) {
-          return ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links);
+          return Response.created(
+              ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
         }
       }
     } catch (InvalidRevisionException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
index b7589cf..07691e7 100644
--- a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
@@ -25,14 +25,12 @@
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Project;
@@ -60,14 +58,12 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class DashboardsCollection
-    implements ChildCollection<ProjectResource, DashboardResource>, AcceptsCreate<ProjectResource> {
+public class DashboardsCollection implements ChildCollection<ProjectResource, DashboardResource> {
   public static final String DEFAULT_DASHBOARD_NAME = "default";
 
   private final GitRepositoryManager gitManager;
   private final DynamicMap<RestView<DashboardResource>> views;
   private final Provider<ListDashboards> list;
-  private final Provider<SetDefaultDashboard.CreateDefault> createDefault;
   private final PermissionBackend permissionBackend;
 
   @Inject
@@ -75,12 +71,10 @@
       GitRepositoryManager gitManager,
       DynamicMap<RestView<DashboardResource>> views,
       Provider<ListDashboards> list,
-      Provider<SetDefaultDashboard.CreateDefault> createDefault,
       PermissionBackend permissionBackend) {
     this.gitManager = gitManager;
     this.views = views;
     this.list = list;
-    this.createDefault = createDefault;
     this.permissionBackend = permissionBackend;
   }
 
@@ -98,16 +92,6 @@
   }
 
   @Override
-  public RestModifyView<ProjectResource, ?> create(ProjectResource parent, IdString id)
-      throws RestApiException {
-    parent.getProjectState().checkStatePermitsWrite();
-    if (isDefaultDashboard(id)) {
-      return createDefault.get();
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
   public DashboardResource parse(ProjectResource parent, IdString id)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     parent.getProjectState().checkStatePermitsRead();
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
index aed372c..92949fa 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
@@ -23,12 +23,9 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -38,36 +35,28 @@
 public class DeleteBranch implements RestModifyView<BranchResource, Input> {
 
   private final Provider<InternalChangeQuery> queryProvider;
-  private final DeleteRef.Factory deleteRefFactory;
-  private final PermissionBackend permissionBackend;
+  private final DeleteRef deleteRef;
 
   @Inject
-  DeleteBranch(
-      Provider<InternalChangeQuery> queryProvider,
-      DeleteRef.Factory deleteRefFactory,
-      PermissionBackend permissionBackend) {
+  DeleteBranch(Provider<InternalChangeQuery> queryProvider, DeleteRef deleteRef) {
     this.queryProvider = queryProvider;
-    this.deleteRefFactory = deleteRefFactory;
-    this.permissionBackend = permissionBackend;
+    this.deleteRef = deleteRef;
   }
 
   @Override
   public Response<?> apply(BranchResource rsrc, Input input)
-      throws RestApiException, OrmException, IOException, PermissionBackendException {
-    if (isConfigRef(rsrc.getBranchKey().get())) {
+      throws RestApiException, IOException, PermissionBackendException {
+    if (isConfigRef(rsrc.getBranchKey().branch())) {
       // Never allow to delete the meta config branch.
       throw new MethodNotAllowedException(
-          "not allowed to delete branch " + rsrc.getBranchKey().get());
+          "not allowed to delete branch " + rsrc.getBranchKey().branch());
     }
 
-    permissionBackend.currentUser().ref(rsrc.getBranchKey()).check(RefPermission.DELETE);
-    rsrc.getProjectState().checkStatePermitsWrite();
-
     if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
       throw new ResourceConflictException("branch " + rsrc.getBranchKey() + " has open changes");
     }
 
-    deleteRefFactory.create(rsrc).ref(rsrc.getRef()).prefix(R_HEADS).delete();
+    deleteRef.deleteSingleRef(rsrc.getProjectState(), rsrc.getRef(), R_HEADS);
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
index d8166e1..d429655 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
@@ -16,6 +16,7 @@
 
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -23,27 +24,27 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
 @Singleton
 public class DeleteBranches implements RestModifyView<ProjectResource, DeleteBranchesInput> {
-  private final DeleteRef.Factory deleteRefFactory;
+  private final DeleteRef deleteRef;
 
   @Inject
-  DeleteBranches(DeleteRef.Factory deleteRefFactory) {
-    this.deleteRefFactory = deleteRefFactory;
+  DeleteBranches(DeleteRef deleteRef) {
+    this.deleteRef = deleteRef;
   }
 
   @Override
   public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
-      throws OrmException, IOException, RestApiException, PermissionBackendException {
+      throws IOException, RestApiException, PermissionBackendException {
     if (input == null || input.branches == null || input.branches.isEmpty()) {
       throw new BadRequestException("branches must be specified");
     }
-    deleteRefFactory.create(project).refs(input.branches).prefix(R_HEADS).delete();
+    deleteRef.deleteMultipleRefs(
+        project.getProjectState(), ImmutableSet.copyOf(input.branches), R_HEADS);
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
index b9b69b2..2702d58 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 769eaf8..6b7987c 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -14,33 +14,34 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
 import static java.lang.String.format;
-import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RefValidationHelper;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 import org.eclipse.jgit.errors.LockFailedException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
@@ -52,6 +53,7 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.ReceiveCommand.Result;
 
+@Singleton
 public class DeleteRef {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -64,13 +66,6 @@
   private final GitReferenceUpdated referenceUpdated;
   private final RefValidationHelper refDeletionValidator;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final ProjectResource resource;
-  private final List<String> refsToDelete;
-  private String prefix;
-
-  public interface Factory {
-    DeleteRef create(ProjectResource r);
-  }
 
   @Inject
   DeleteRef(
@@ -79,136 +74,159 @@
       GitRepositoryManager repoManager,
       GitReferenceUpdated referenceUpdated,
       RefValidationHelper.Factory refDeletionValidatorFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      @Assisted ProjectResource resource) {
+      Provider<InternalChangeQuery> queryProvider) {
     this.identifiedUser = identifiedUser;
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.referenceUpdated = referenceUpdated;
     this.refDeletionValidator = refDeletionValidatorFactory.create(DELETE);
     this.queryProvider = queryProvider;
-    this.resource = resource;
-    this.refsToDelete = new ArrayList<>();
   }
 
-  public DeleteRef ref(String ref) {
-    this.refsToDelete.add(ref);
-    return this;
+  /**
+   * Deletes a single ref from the repository.
+   *
+   * @param projectState the {@code ProjectState} of the project containing the target ref.
+   * @param ref the ref to be deleted.
+   * @throws IOException
+   * @throws ResourceConflictException
+   */
+  public void deleteSingleRef(ProjectState projectState, String ref)
+      throws IOException, ResourceConflictException, AuthException, PermissionBackendException {
+    deleteSingleRef(projectState, ref, null);
   }
 
-  public DeleteRef refs(List<String> refs) {
-    this.refsToDelete.addAll(refs);
-    return this;
-  }
-
-  public DeleteRef prefix(String prefix) {
-    this.prefix = prefix;
-    return this;
-  }
-
-  public void delete()
-      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
-    if (!refsToDelete.isEmpty()) {
-      try (Repository r = repoManager.openRepository(resource.getNameKey())) {
-        if (refsToDelete.size() == 1) {
-          deleteSingleRef(r);
-        } else {
-          deleteMultipleRefs(r);
-        }
-      }
-    }
-  }
-
-  private void deleteSingleRef(Repository r) throws IOException, ResourceConflictException {
-    String ref = refsToDelete.get(0);
+  /**
+   * Deletes a single ref from the repository.
+   *
+   * @param projectState the {@code ProjectState} of the project containing the target ref.
+   * @param ref the ref to be deleted.
+   * @param prefix the prefix of the ref.
+   * @throws IOException
+   * @throws ResourceConflictException
+   */
+  public void deleteSingleRef(ProjectState projectState, String ref, @Nullable String prefix)
+      throws IOException, ResourceConflictException, AuthException, PermissionBackendException {
     if (prefix != null && !ref.startsWith(R_REFS)) {
       ref = prefix + ref;
     }
-    RefUpdate.Result result;
-    RefUpdate u = r.updateRef(ref);
-    u.setExpectedOldObjectId(r.exactRef(ref).getObjectId());
-    u.setNewObjectId(ObjectId.zeroId());
-    u.setForceUpdate(true);
-    refDeletionValidator.validateRefOperation(resource.getName(), identifiedUser.get(), u);
-    int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
-    for (; ; ) {
-      try {
-        result = u.delete();
-      } catch (LockFailedException e) {
-        result = RefUpdate.Result.LOCK_FAILURE;
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Cannot delete %s", ref);
-        throw e;
-      }
-      if (result == RefUpdate.Result.LOCK_FAILURE && --remainingLockFailureCalls > 0) {
+
+    projectState.checkStatePermitsWrite();
+    permissionBackend
+        .currentUser()
+        .project(projectState.getNameKey())
+        .ref(ref)
+        .check(RefPermission.DELETE);
+
+    try (Repository repository = repoManager.openRepository(projectState.getNameKey())) {
+      RefUpdate.Result result;
+      RefUpdate u = repository.updateRef(ref);
+      u.setExpectedOldObjectId(repository.exactRef(ref).getObjectId());
+      u.setNewObjectId(ObjectId.zeroId());
+      u.setForceUpdate(true);
+      refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
+      int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
+      for (; ; ) {
         try {
-          Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
-        } catch (InterruptedException ie) {
-          // ignore
+          result = u.delete();
+        } catch (LockFailedException e) {
+          result = RefUpdate.Result.LOCK_FAILURE;
         }
-      } else {
-        break;
+        if (result == RefUpdate.Result.LOCK_FAILURE && --remainingLockFailureCalls > 0) {
+          try {
+            Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
+          } catch (InterruptedException ie) {
+            // ignore
+          }
+        } else {
+          break;
+        }
       }
-    }
 
-    switch (result) {
-      case NEW:
-      case NO_CHANGE:
-      case FAST_FORWARD:
-      case FORCED:
-        referenceUpdated.fire(
-            resource.getNameKey(), u, ReceiveCommand.Type.DELETE, identifiedUser.get().state());
-        break;
+      switch (result) {
+        case NEW:
+        case NO_CHANGE:
+        case FAST_FORWARD:
+        case FORCED:
+          referenceUpdated.fire(
+              projectState.getNameKey(),
+              u,
+              ReceiveCommand.Type.DELETE,
+              identifiedUser.get().state());
+          break;
 
-      case REJECTED_CURRENT_BRANCH:
-        logger.atSevere().log("Cannot delete %s: %s", ref, result.name());
-        throw new ResourceConflictException("cannot delete current branch");
+        case REJECTED_CURRENT_BRANCH:
+          logger.atSevere().log("Cannot delete %s: %s", ref, result.name());
+          throw new ResourceConflictException("cannot delete current branch");
 
-      case IO_FAILURE:
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case RENAMED:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        logger.atSevere().log("Cannot delete %s: %s", ref, result.name());
-        throw new ResourceConflictException("cannot delete: " + result.name());
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          logger.atSevere().log("Cannot delete %s: %s", ref, result.name());
+          throw new ResourceConflictException("cannot delete: " + result.name());
+      }
     }
   }
 
-  private void deleteMultipleRefs(Repository r)
-      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
-    BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
-    batchUpdate.setAtomic(false);
-    List<String> refs =
-        prefix == null
-            ? refsToDelete
-            : refsToDelete
-                .stream()
-                .map(ref -> ref.startsWith(R_REFS) ? ref : prefix + ref)
-                .collect(toList());
-    for (String ref : refs) {
-      batchUpdate.addCommand(createDeleteCommand(resource, r, ref));
+  /**
+   * Deletes a set of refs from the repository.
+   *
+   * @param projectState the {@code ProjectState} of the project whose refs are to be deleted.
+   * @param refsToDelete the refs to be deleted.
+   * @param prefix the prefix of the refs.
+   * @throws IOException
+   * @throws ResourceConflictException
+   * @throws PermissionBackendException
+   */
+  public void deleteMultipleRefs(
+      ProjectState projectState, ImmutableSet<String> refsToDelete, String prefix)
+      throws IOException, ResourceConflictException, PermissionBackendException, AuthException {
+    if (refsToDelete.isEmpty()) {
+      return;
     }
-    try (RevWalk rw = new RevWalk(r)) {
-      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+
+    if (refsToDelete.size() == 1) {
+      deleteSingleRef(projectState, Iterables.getOnlyElement(refsToDelete), prefix);
+      return;
     }
-    StringBuilder errorMessages = new StringBuilder();
-    for (ReceiveCommand command : batchUpdate.getCommands()) {
-      if (command.getResult() == Result.OK) {
-        postDeletion(resource, command);
-      } else {
-        appendAndLogErrorMessage(errorMessages, command);
+
+    try (Repository repository = repoManager.openRepository(projectState.getNameKey())) {
+      BatchRefUpdate batchUpdate = repository.getRefDatabase().newBatchUpdate();
+      batchUpdate.setAtomic(false);
+      ImmutableSet<String> refs =
+          prefix == null
+              ? refsToDelete
+              : refsToDelete.stream()
+                  .map(ref -> ref.startsWith(R_REFS) ? ref : prefix + ref)
+                  .collect(toImmutableSet());
+      for (String ref : refs) {
+        batchUpdate.addCommand(createDeleteCommand(projectState, repository, ref));
       }
-    }
-    if (errorMessages.length() > 0) {
-      throw new ResourceConflictException(errorMessages.toString());
+      try (RevWalk rw = new RevWalk(repository)) {
+        batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+      }
+      StringBuilder errorMessages = new StringBuilder();
+      for (ReceiveCommand command : batchUpdate.getCommands()) {
+        if (command.getResult() == Result.OK) {
+          postDeletion(projectState.getNameKey(), command);
+        } else {
+          appendAndLogErrorMessage(errorMessages, command);
+        }
+      }
+      if (errorMessages.length() > 0) {
+        throw new ResourceConflictException(errorMessages.toString());
+      }
     }
   }
 
-  private ReceiveCommand createDeleteCommand(ProjectResource project, Repository r, String refName)
-      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
+  private ReceiveCommand createDeleteCommand(
+      ProjectState projectState, Repository r, String refName)
+      throws IOException, ResourceConflictException, PermissionBackendException {
     Ref ref = r.getRefDatabase().getRef(refName);
     ReceiveCommand command;
     if (ref == null) {
@@ -227,7 +245,7 @@
       try {
         permissionBackend
             .currentUser()
-            .project(project.getNameKey())
+            .project(projectState.getNameKey())
             .ref(refName)
             .check(RefPermission.DELETE);
       } catch (AuthException denied) {
@@ -237,12 +255,12 @@
       }
     }
 
-    if (!project.getProjectState().statePermitsWrite()) {
+    if (!projectState.statePermitsWrite()) {
       command.setResult(Result.REJECTED_OTHER_REASON, "project state does not permit write");
     }
 
     if (!refName.startsWith(R_TAGS)) {
-      Branch.NameKey branchKey = new Branch.NameKey(project.getNameKey(), ref.getName());
+      BranchNameKey branchKey = BranchNameKey.create(projectState.getNameKey(), ref.getName());
       if (!queryProvider.get().setLimit(1).byBranchOpen(branchKey).isEmpty()) {
         command.setResult(Result.REJECTED_OTHER_REASON, "it has open changes");
       }
@@ -252,12 +270,12 @@
     u.setForceUpdate(true);
     u.setExpectedOldObjectId(r.exactRef(refName).getObjectId());
     u.setNewObjectId(ObjectId.zeroId());
-    refDeletionValidator.validateRefOperation(project.getName(), identifiedUser.get(), u);
+    refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
     return command;
   }
 
   private void appendAndLogErrorMessage(StringBuilder errorMessages, ReceiveCommand cmd) {
-    String msg = null;
+    String msg;
     switch (cmd.getResult()) {
       case REJECTED_CURRENT_BRANCH:
         msg = format("Cannot delete %s: it is the current branch", cmd.getRefName());
@@ -281,7 +299,7 @@
     errorMessages.append("\n");
   }
 
-  private void postDeletion(ProjectResource project, ReceiveCommand cmd) {
-    referenceUpdated.fire(project.getNameKey(), cmd, identifiedUser.get().state());
+  private void postDeletion(Project.NameKey project, ReceiveCommand cmd) {
+    referenceUpdated.fire(project, cmd, identifiedUser.get().state());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
index bd5f444..33955ee 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTag.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -21,12 +21,9 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.RefUtil;
 import com.google.gerrit.server.project.TagResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -34,18 +31,16 @@
 @Singleton
 public class DeleteTag implements RestModifyView<TagResource, Input> {
 
-  private final PermissionBackend permissionBackend;
-  private final DeleteRef.Factory deleteRefFactory;
+  private final DeleteRef deleteRef;
 
   @Inject
-  DeleteTag(PermissionBackend permissionBackend, DeleteRef.Factory deleteRefFactory) {
-    this.permissionBackend = permissionBackend;
-    this.deleteRefFactory = deleteRefFactory;
+  DeleteTag(DeleteRef deleteRef) {
+    this.deleteRef = deleteRef;
   }
 
   @Override
   public Response<?> apply(TagResource resource, Input input)
-      throws OrmException, RestApiException, IOException, PermissionBackendException {
+      throws RestApiException, IOException, PermissionBackendException {
     String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
 
     if (isConfigRef(tag)) {
@@ -53,13 +48,7 @@
       throw new MethodNotAllowedException("not allowed to delete " + tag);
     }
 
-    permissionBackend
-        .currentUser()
-        .project(resource.getNameKey())
-        .ref(tag)
-        .check(RefPermission.DELETE);
-    resource.getProjectState().checkStatePermitsWrite();
-    deleteRefFactory.create(resource).ref(tag).delete();
+    deleteRef.deleteSingleRef(resource.getProjectState(), tag);
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTags.java b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
index 83fa1ea..6e8ec37 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTags.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
@@ -16,6 +16,7 @@
 
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -23,27 +24,27 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
 @Singleton
 public class DeleteTags implements RestModifyView<ProjectResource, DeleteTagsInput> {
-  private final DeleteRef.Factory deleteRefFactory;
+  private final DeleteRef deleteRef;
 
   @Inject
-  DeleteTags(DeleteRef.Factory deleteRefFactory) {
-    this.deleteRefFactory = deleteRefFactory;
+  DeleteTags(DeleteRef deleteRef) {
+    this.deleteRef = deleteRef;
   }
 
   @Override
   public Response<?> apply(ProjectResource project, DeleteTagsInput input)
-      throws OrmException, RestApiException, IOException, PermissionBackendException {
+      throws RestApiException, IOException, PermissionBackendException {
     if (input == null || input.tags == null || input.tags.isEmpty()) {
       throw new BadRequestException("tags must be specified");
     }
-    deleteRefFactory.create(project).refs(input.tags).prefix(R_TAGS).delete();
+    deleteRef.deleteMultipleRefs(
+        project.getProjectState(), ImmutableSet.copyOf(input.tags), R_TAGS);
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 53411b8..a5a4dda 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -14,34 +14,49 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.server.change.FileInfoJson;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.project.FileResource;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.kohsuke.args4j.Option;
 
 @Singleton
 public class FilesInCommitCollection implements ChildCollection<CommitResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
+  private final Provider<ListFiles> list;
   private final GitRepositoryManager repoManager;
 
   @Inject
   FilesInCommitCollection(
-      DynamicMap<RestView<FileResource>> views, GitRepositoryManager repoManager) {
+      DynamicMap<RestView<FileResource>> views,
+      Provider<ListFiles> list,
+      GitRepositoryManager repoManager) {
     this.views = views;
+    this.list = list;
     this.repoManager = repoManager;
   }
 
   @Override
   public RestView<CommitResource> list() throws ResourceNotFoundException {
-    throw new ResourceNotFoundException();
+    return list.get();
   }
 
   @Override
@@ -57,4 +72,33 @@
   public DynamicMap<RestView<FileResource>> views() {
     return views;
   }
+
+  public static final class ListFiles implements RestReadView<CommitResource> {
+    @Option(name = "--parent", metaVar = "parent-number")
+    int parentNum;
+
+    private final FileInfoJson fileInfoJson;
+
+    @Inject
+    public ListFiles(FileInfoJson fileInfoJson) {
+      this.fileInfoJson = fileInfoJson;
+    }
+
+    @Override
+    public Response<Map<String, FileInfo>> apply(CommitResource resource)
+        throws PatchListNotAvailableException {
+      RevCommit commit = resource.getCommit();
+      PatchListKey key;
+
+      if (parentNum > 0) {
+        key =
+            PatchListKey.againstParentNum(
+                parentNum, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+      } else {
+        key = PatchListKey.againstCommit(null, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+      }
+
+      return Response.ok(fileInfoJson.toFileInfoMap(resource.getProjectState().getNameKey(), key));
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
index ea1620b..e9caa97 100644
--- a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
+++ b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
@@ -14,32 +14,34 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.data.GarbageCollectionResult;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.ioutil.HexFormat;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.GarbageCollect.Input;
-import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.util.Collections;
+import java.util.Optional;
 
 @RequiresCapability(GlobalCapability.RUN_GC)
 @Singleton
@@ -54,27 +56,27 @@
   private final boolean canGC;
   private final GarbageCollection.Factory garbageCollectionFactory;
   private final WorkQueue workQueue;
-  private final Provider<String> canonicalUrl;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   GarbageCollect(
       GitRepositoryManager repoManager,
       GarbageCollection.Factory garbageCollectionFactory,
       WorkQueue workQueue,
-      @CanonicalWebUrl Provider<String> canonicalUrl) {
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.workQueue = workQueue;
-    this.canonicalUrl = canonicalUrl;
+    this.urlFormatter = urlFormatter;
     this.canGC = repoManager instanceof LocalDiskRepositoryManager;
     this.garbageCollectionFactory = garbageCollectionFactory;
   }
 
   @Override
-  public Object apply(ProjectResource rsrc, Input input) {
+  public Response<?> apply(ProjectResource rsrc, Input input) {
     Project.NameKey project = rsrc.getNameKey();
     if (input.async) {
       return applyAsync(project, input);
     }
-    return applySync(project, input);
+    return Response.ok(applySync(project, input));
   }
 
   private Response.Accepted applyAsync(Project.NameKey project, Input input) {
@@ -97,10 +99,13 @@
     @SuppressWarnings("unchecked")
     WorkQueue.Task<Void> task = (WorkQueue.Task<Void>) workQueue.getDefaultQueue().submit(job);
 
-    String location =
-        canonicalUrl.get() + "a/config/server/tasks/" + IdGenerator.format(task.getTaskId());
-
-    return Response.accepted(location);
+    Optional<String> url =
+        urlFormatter
+            .get()
+            .getRestUrl("a/config/server/tasks/" + HexFormat.fromInt(task.getTaskId()));
+    // We're in a HTTP handler, so must be present.
+    checkState(url.isPresent());
+    return Response.accepted(url.get());
   }
 
   @SuppressWarnings("resource")
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index 6a50c2f..51374ab 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;
@@ -28,17 +29,15 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.common.data.WebLinkInfoCommon;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
 import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
@@ -94,6 +93,7 @@
   private final MetaDataUpdate.Server metaDataUpdateFactory;
   private final GroupBackend groupBackend;
   private final WebLinks webLinks;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   public GetAccess(
@@ -104,7 +104,8 @@
       MetaDataUpdate.Server metaDataUpdateFactory,
       ProjectJson projectJson,
       GroupBackend groupBackend,
-      WebLinks webLinks) {
+      WebLinks webLinks,
+      ProjectConfig.Factory projectConfigFactory) {
     this.user = self;
     this.permissionBackend = permissionBackend;
     this.allProjectsName = allProjectsName;
@@ -113,6 +114,7 @@
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.groupBackend = groupBackend;
     this.webLinks = webLinks;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   public ProjectAccessInfo apply(Project.NameKey nameKey)
@@ -122,11 +124,11 @@
     if (state == null) {
       throw new ResourceNotFoundException(nameKey.get());
     }
-    return apply(new ProjectResource(state, user.get()));
+    return apply(new ProjectResource(state, user.get())).value();
   }
 
   @Override
-  public ProjectAccessInfo apply(ProjectResource rsrc)
+  public Response<ProjectAccessInfo> apply(ProjectResource rsrc)
       throws ResourceNotFoundException, ResourceConflictException, IOException,
           PermissionBackendException {
     // Load the current configuration from the repository, ensuring it's the most
@@ -140,18 +142,14 @@
 
     ProjectConfig config;
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
-      config = ProjectConfig.read(md);
+      config = projectConfigFactory.read(md);
       info.configWebLinks = new ArrayList<>();
 
       // config may have a null revision if the repo doesn't have its own refs/meta/config.
       if (config.getRevision() != null) {
-        // WebLinks operates in terms of the data types used in the GWT UI. Once the GWT UI is
-        // gone, WebLinks should be fixed to use the extension data types.
-        for (WebLinkInfoCommon wl :
+        info.configWebLinks.addAll(
             webLinks.getFileHistoryLinks(
-                projectName.get(), config.getRevision().getName(), ProjectConfig.PROJECT_CONFIG)) {
-          info.configWebLinks.add(new WebLinkInfo(wl.name, wl.imageUrl, wl.url, wl.target));
-        }
+                projectName.get(), config.getRevision().getName(), ProjectConfig.PROJECT_CONFIG));
       }
 
       if (config.updateGroupNames(groupBackend)) {
@@ -198,7 +196,7 @@
           info.local.put(section.getName(), createAccessSection(groups, section));
         }
 
-      } else if (RefConfigSection.isValid(name)) {
+      } else if (AccessSection.isValidRefSectionName(name)) {
         if (check(perm, name, WRITE_CONFIG)) {
           info.local.put(name, createAccessSection(groups, section));
           info.ownerOf.add(name);
@@ -237,11 +235,15 @@
       }
     }
 
-    if (info.ownerOf.isEmpty()
-        && permissionBackend.currentUser().test(GlobalPermission.ADMINISTRATE_SERVER)) {
-      // Special case: If the section list is empty, this project has no current
-      // access control information. Fall back to site administrators.
-      info.ownerOf.add(AccessSection.ALL);
+    if (info.ownerOf.isEmpty()) {
+      try {
+        permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+        // Special case: If the section list is empty, this project has no current
+        // access control information. Fall back to site administrators.
+        info.ownerOf.add(AccessSection.ALL);
+      } catch (AuthException e) {
+        // Do nothing.
+      }
     }
 
     if (config.getRevision() != null) {
@@ -266,16 +268,15 @@
                     || (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 =
-        groups
-            .entrySet()
-            .stream()
+        groups.entrySet().stream()
             .filter(e -> e.getValue() != null)
             .collect(toMap(e -> e.getKey().get(), Map.Entry::getValue));
 
-    return info;
+    return Response.ok(info);
   }
 
   private void loadGroup(Map<AccountGroup.UUID, GroupInfo> groups, AccountGroup.UUID id) {
diff --git a/java/com/google/gerrit/server/restapi/project/GetBranch.java b/java/com/google/gerrit/server/restapi/project/GetBranch.java
index 7d32f3d..52a47a4 100644
--- a/java/com/google/gerrit/server/restapi/project/GetBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/GetBranch.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.BranchResource;
@@ -34,8 +35,8 @@
   }
 
   @Override
-  public BranchInfo apply(BranchResource rsrc)
+  public Response<BranchInfo> apply(BranchResource rsrc)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
-    return list.get().toBranchInfo(rsrc);
+    return Response.ok(list.get().toBranchInfo(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetChildProject.java b/java/com/google/gerrit/server/restapi/project/GetChildProject.java
index e69907e..b90f6ee 100644
--- a/java/com/google/gerrit/server/restapi/project/GetChildProject.java
+++ b/java/com/google/gerrit/server/restapi/project/GetChildProject.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.ChildProjectResource;
 import com.google.gerrit.server.project.ProjectJson;
@@ -37,9 +38,9 @@
   }
 
   @Override
-  public ProjectInfo apply(ChildProjectResource rsrc) throws ResourceNotFoundException {
+  public Response<ProjectInfo> apply(ChildProjectResource rsrc) throws ResourceNotFoundException {
     if (recursive || rsrc.isDirectChild()) {
-      return json.format(rsrc.getChild().getProject());
+      return Response.ok(json.format(rsrc.getChild().getProject()));
     }
     throw new ResourceNotFoundException(rsrc.getChild().getName());
   }
diff --git a/java/com/google/gerrit/server/restapi/project/GetCommit.java b/java/com/google/gerrit/server/restapi/project/GetCommit.java
index 1c1ae90..cca6a1a 100644
--- a/java/com/google/gerrit/server/restapi/project/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/project/GetCommit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.project.CommitResource;
@@ -25,7 +26,7 @@
 public class GetCommit implements RestReadView<CommitResource> {
 
   @Override
-  public CommitInfo apply(CommitResource rsrc) throws IOException {
-    return CommitUtil.toCommitInfo(rsrc.getCommit());
+  public Response<CommitInfo> apply(CommitResource rsrc) throws IOException {
+    return Response.ok(CommitUtil.toCommitInfo(rsrc.getCommit()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index aafff9e..ce45e7d 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.EnableSignedPush;
@@ -23,7 +24,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 +31,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 +40,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;
@@ -57,16 +54,16 @@
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource resource) {
-    return new ConfigInfoImpl(
-        serverEnableSignedPush,
-        resource.getProjectState(),
-        resource.getUser(),
-        config,
-        pluginConfigEntries,
-        cfgFactory,
-        allProjects,
-        uiActions,
-        views);
+  public Response<ConfigInfo> apply(ProjectResource resource) {
+    return Response.ok(
+        new ConfigInfoImpl(
+            serverEnableSignedPush,
+            resource.getProjectState(),
+            resource.getUser(),
+            pluginConfigEntries,
+            cfgFactory,
+            allProjects,
+            uiActions,
+            views));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetContent.java b/java/com/google/gerrit/server/restapi/project/GetContent.java
index 132b644..4e3fc8e 100644
--- a/java/com/google/gerrit/server/restapi/project/GetContent.java
+++ b/java/com/google/gerrit/server/restapi/project/GetContent.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 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.RestReadView;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.project.FileResource;
@@ -34,8 +35,9 @@
   }
 
   @Override
-  public BinaryResult apply(FileResource rsrc)
+  public Response<BinaryResult> apply(FileResource rsrc)
       throws ResourceNotFoundException, BadRequestException, IOException {
-    return fileContentUtil.getContent(rsrc.getProjectState(), rsrc.getRev(), rsrc.getPath(), null);
+    return Response.ok(
+        fileContentUtil.getContent(rsrc.getProjectState(), rsrc.getRev(), rsrc.getPath(), null));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetDashboard.java b/java/com/google/gerrit/server/restapi/project/GetDashboard.java
index 2ec67e7..03bfb77 100644
--- a/java/com/google/gerrit/server/restapi/project/GetDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/GetDashboard.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.Url;
@@ -56,7 +57,7 @@
   }
 
   @Override
-  public DashboardInfo apply(DashboardResource rsrc)
+  public Response<DashboardInfo> apply(DashboardResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
     if (inherited && !rsrc.isProjectDefault()) {
       throw new BadRequestException("inherited flag can only be used with default");
@@ -71,13 +72,14 @@
       }
     }
 
-    return DashboardsCollection.parse(
-        rsrc.getProjectState().getProject(),
-        rsrc.getRefName().substring(REFS_DASHBOARDS.length()),
-        rsrc.getPathName(),
-        rsrc.getConfig(),
-        rsrc.getProjectState().getName(),
-        true);
+    return Response.ok(
+        DashboardsCollection.parse(
+            rsrc.getProjectState().getProject(),
+            rsrc.getRefName().substring(REFS_DASHBOARDS.length()),
+            rsrc.getPathName(),
+            rsrc.getConfig(),
+            rsrc.getProjectState().getName(),
+            true));
   }
 
   private DashboardResource defaultOf(ProjectState projectState, CurrentUser user)
diff --git a/java/com/google/gerrit/server/restapi/project/GetDescription.java b/java/com/google/gerrit/server/restapi/project/GetDescription.java
index d387ff1..2561b91 100644
--- a/java/com/google/gerrit/server/restapi/project/GetDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/GetDescription.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Singleton;
@@ -22,7 +23,7 @@
 @Singleton
 public class GetDescription implements RestReadView<ProjectResource> {
   @Override
-  public String apply(ProjectResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getProjectState().getProject().getDescription());
+  public Response<String> apply(ProjectResource rsrc) {
+    return Response.ok(Strings.nullToEmpty(rsrc.getProjectState().getProject().getDescription()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetHead.java b/java/com/google/gerrit/server/restapi/project/GetHead.java
index bc267c8..928db97 100644
--- a/java/com/google/gerrit/server/restapi/project/GetHead.java
+++ b/java/com/google/gerrit/server/restapi/project/GetHead.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -52,7 +53,7 @@
   }
 
   @Override
-  public String apply(ProjectResource rsrc)
+  public Response<String> apply(ProjectResource rsrc)
       throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
     rsrc.getProjectState().statePermitsRead();
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
@@ -66,12 +67,12 @@
             .project(rsrc.getNameKey())
             .ref(n)
             .check(RefPermission.READ);
-        return n;
+        return Response.ok(n);
       } else if (head.getObjectId() != null) {
         try (RevWalk rw = new RevWalk(repo)) {
           RevCommit commit = rw.parseCommit(head.getObjectId());
           if (commits.canRead(rsrc.getProjectState(), repo, commit)) {
-            return head.getObjectId().name();
+            return Response.ok(head.getObjectId().name());
           }
           throw new AuthException("not allowed to see HEAD");
         } catch (MissingObjectException | IncorrectObjectTypeException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/GetParent.java b/java/com/google/gerrit/server/restapi/project/GetParent.java
index a4942e3..76b28e9 100644
--- a/java/com/google/gerrit/server/restapi/project/GetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/GetParent.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -31,9 +32,9 @@
   }
 
   @Override
-  public String apply(ProjectResource resource) {
+  public Response<String> apply(ProjectResource resource) {
     Project project = resource.getProjectState().getProject();
     Project.NameKey parentName = project.getParent(allProjectsName);
-    return parentName != null ? parentName.get() : "";
+    return Response.ok(parentName != null ? parentName.get() : "");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetProject.java b/java/com/google/gerrit/server/restapi/project/GetProject.java
index 26159e4..2f7d370 100644
--- a/java/com/google/gerrit/server/restapi/project/GetProject.java
+++ b/java/com/google/gerrit/server/restapi/project/GetProject.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
@@ -32,7 +33,7 @@
   }
 
   @Override
-  public ProjectInfo apply(ProjectResource rsrc) {
-    return json.format(rsrc.getProjectState());
+  public Response<ProjectInfo> apply(ProjectResource rsrc) {
+    return Response.ok(json.format(rsrc.getProjectState()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetReflog.java b/java/com/google/gerrit/server/restapi/project/GetReflog.java
index 4b9a489..a249f26 100644
--- a/java/com/google/gerrit/server/restapi/project/GetReflog.java
+++ b/java/com/google/gerrit/server/restapi/project/GetReflog.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 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.CommonConverters;
@@ -89,7 +90,7 @@
   }
 
   @Override
-  public List<ReflogEntryInfo> apply(BranchResource rsrc)
+  public Response<List<ReflogEntryInfo>> apply(BranchResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
     permissionBackend
         .user(rsrc.getUser())
@@ -123,7 +124,7 @@
           }
         }
       }
-      return Lists.transform(entries, this::newReflogEntryInfo);
+      return Response.ok(Lists.transform(entries, this::newReflogEntryInfo));
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/GetStatistics.java b/java/com/google/gerrit/server/restapi/project/GetStatistics.java
index 048c018..d68e0af 100644
--- a/java/com/google/gerrit/server/restapi/project/GetStatistics.java
+++ b/java/com/google/gerrit/server/restapi/project/GetStatistics.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.ProjectResource;
@@ -42,11 +43,11 @@
   }
 
   @Override
-  public RepositoryStatistics apply(ProjectResource rsrc)
+  public Response<RepositoryStatistics> apply(ProjectResource rsrc)
       throws ResourceNotFoundException, ResourceConflictException {
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       GarbageCollectCommand gc = Git.wrap(repo).gc();
-      return new RepositoryStatistics(gc.getStatistics());
+      return Response.ok(new RepositoryStatistics(gc.getStatistics()));
     } catch (GitAPIException | JGitInternalException e) {
       throw new ResourceConflictException(e.getMessage());
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/GetTag.java b/java/com/google/gerrit/server/restapi/project/GetTag.java
index 6d5a510..6ab2f8b 100644
--- a/java/com/google/gerrit/server/restapi/project/GetTag.java
+++ b/java/com/google/gerrit/server/restapi/project/GetTag.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.TagResource;
 import com.google.inject.Singleton;
@@ -23,7 +24,7 @@
 public class GetTag implements RestReadView<TagResource> {
 
   @Override
-  public TagInfo apply(TagResource resource) {
-    return resource.getTagInfo();
+  public Response<TagInfo> apply(TagResource resource) {
+    return Response.ok(resource.getTagInfo());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/Index.java b/java/com/google/gerrit/server/restapi/project/Index.java
index 24f32f6..bc4f668 100644
--- a/java/com/google/gerrit/server/restapi/project/Index.java
+++ b/java/com/google/gerrit/server/restapi/project/Index.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -16,57 +16,65 @@
 
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
-import com.google.common.io.ByteStreams;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.IndexProjectInput;
+import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.MultiProgressMonitor;
-import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.change.AllChangesIndexer;
-import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.concurrent.Future;
-import org.eclipse.jgit.util.io.NullOutputStream;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
 @Singleton
-public class Index implements RestModifyView<ProjectResource, ProjectInput> {
-
-  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
-  private final ChangeIndexer indexer;
+public class Index implements RestModifyView<ProjectResource, IndexProjectInput> {
+  private final ProjectIndexer indexer;
   private final ListeningExecutorService executor;
+  private final Provider<ListChildProjects> listChildProjectsProvider;
 
   @Inject
   Index(
-      Provider<AllChangesIndexer> allChangesIndexerProvider,
-      ChangeIndexer indexer,
-      @IndexExecutor(BATCH) ListeningExecutorService executor) {
-    this.allChangesIndexerProvider = allChangesIndexerProvider;
+      ProjectIndexer indexer,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      Provider<ListChildProjects> listChildProjectsProvider) {
     this.indexer = indexer;
     this.executor = executor;
+    this.listChildProjectsProvider = listChildProjectsProvider;
   }
 
   @Override
-  public Response.Accepted apply(ProjectResource resource, ProjectInput input) {
-    Project.NameKey project = resource.getNameKey();
-    Task mpt =
-        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
-            .beginSubTask("", MultiProgressMonitor.UNKNOWN);
-    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
-    allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
-    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
-    // return value.
-    @SuppressWarnings("unused")
-    Future<Void> ignored =
-        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
-    return Response.accepted("Project " + project + " submitted for reindexing");
+  public Response.Accepted apply(ProjectResource rsrc, IndexProjectInput input)
+      throws IOException, PermissionBackendException, RestApiException {
+    String response = "Project " + rsrc.getName() + " submitted for reindexing";
+
+    reindex(rsrc.getNameKey(), input.async);
+    if (Boolean.TRUE.equals(input.indexChildren)) {
+      for (ProjectInfo child :
+          listChildProjectsProvider.get().withRecursive(true).apply(rsrc).value()) {
+        reindex(Project.nameKey(child.name), input.async);
+      }
+
+      response += " (indexing children recursively)";
+    }
+    return Response.accepted(response);
+  }
+
+  private void reindex(Project.NameKey project, Boolean async) {
+    if (Boolean.TRUE.equals(async)) {
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError = executor.submit(() -> indexer.index(project));
+    } else {
+      indexer.index(project);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/IndexChanges.java b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
new file mode 100644
index 0000000..b6b3d6b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.util.io.NullOutputStream;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class IndexChanges implements RestModifyView<ProjectResource, Input> {
+
+  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
+  private final ChangeIndexer indexer;
+  private final ListeningExecutorService executor;
+
+  @Inject
+  IndexChanges(
+      Provider<AllChangesIndexer> allChangesIndexerProvider,
+      ChangeIndexer indexer,
+      @IndexExecutor(BATCH) ListeningExecutorService executor) {
+    this.allChangesIndexerProvider = allChangesIndexerProvider;
+    this.indexer = indexer;
+    this.executor = executor;
+  }
+
+  @Override
+  public Response.Accepted apply(ProjectResource resource, Input input) {
+    Project.NameKey project = resource.getNameKey();
+    Task mpt =
+        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
+            .beginSubTask("", MultiProgressMonitor.UNKNOWN);
+    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
+    allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
+    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
+    // return value.
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
+    return Response.accepted("Project " + project + " submitted for reindexing");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index 6417967..068b89f 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -24,7 +24,9 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -45,7 +47,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;
@@ -127,15 +128,16 @@
   }
 
   @Override
-  public List<BranchInfo> apply(ProjectResource rsrc)
+  public Response<ImmutableList<BranchInfo>> apply(ProjectResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
     rsrc.getProjectState().checkStatePermitsRead();
-    return new RefFilter<BranchInfo>(Constants.R_HEADS)
-        .subString(matchSubstring)
-        .regex(matchRegex)
-        .start(start)
-        .limit(limit)
-        .filter(allBranches(rsrc));
+    return Response.ok(
+        new RefFilter<BranchInfo>(Constants.R_HEADS)
+            .subString(matchSubstring)
+            .regex(matchRegex)
+            .start(start)
+            .limit(limit)
+            .filter(allBranches(rsrc)));
   }
 
   BranchInfo toBranchInfo(BranchResource rsrc)
@@ -155,7 +157,7 @@
       throws IOException, ResourceNotFoundException, PermissionBackendException {
     List<Ref> refs;
     try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
-      Collection<Ref> heads = db.getRefDatabase().getRefs(Constants.R_HEADS).values();
+      Collection<Ref> heads = db.getRefDatabase().getRefsByPrefix(Constants.R_HEADS);
       refs = new ArrayList<>(heads.size() + 3);
       refs.addAll(heads);
       refs.addAll(
@@ -185,9 +187,13 @@
         // showing the resolved value, show the name it references.
         //
         String target = ref.getTarget().getName();
-        if (!perm.ref(target).test(RefPermission.READ)) {
+
+        try {
+          perm.ref(target).check(RefPermission.READ);
+        } catch (AuthException e) {
           continue;
         }
+
         if (target.startsWith(Constants.R_HEADS)) {
           target = target.substring(Constants.R_HEADS.length());
         }
@@ -212,13 +218,16 @@
         continue;
       }
 
-      if (perm.ref(ref.getName()).test(RefPermission.READ)) {
+      try {
+        perm.ref(ref.getName()).check(RefPermission.READ);
         branches.add(
             createBranchInfo(
                 perm.ref(ref.getName()), ref, rsrc.getProjectState(), rsrc.getUser(), targets));
+      } catch (AuthException e) {
+        // Do nothing.
       }
     }
-    Collections.sort(branches, new BranchComparator());
+    branches.sort(new BranchComparator());
     return branches;
   }
 
@@ -271,7 +280,8 @@
       info.actions.put(d.getId(), new ActionInfo(d));
     }
 
-    List<WebLinkInfo> links = webLinks.getBranchLinks(projectState.getName(), ref.getName());
+    ImmutableList<WebLinkInfo> links =
+        webLinks.getBranchLinks(projectState.getName(), ref.getName());
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
diff --git a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
index 3067c89..9313dfc 100644
--- a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
@@ -17,22 +17,20 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllProjectsName;
 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.ChildProjects;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
-import java.util.HashMap;
+import com.google.inject.Provider;
 import java.util.List;
-import java.util.Map;
 import org.kohsuke.args4j.Option;
 
 public class ListChildProjects implements RestReadView<ProjectResource> {
@@ -40,58 +38,55 @@
   @Option(name = "--recursive", usage = "to list child projects recursively")
   private boolean recursive;
 
-  private final ProjectCache projectCache;
+  @Option(name = "--limit", usage = "maximum number of parents projects to list")
+  private int limit;
+
   private final PermissionBackend permissionBackend;
-  private final AllProjectsName allProjects;
-  private final ProjectJson json;
   private final ChildProjects childProjects;
+  private final Provider<QueryProjects> queryProvider;
 
   @Inject
   ListChildProjects(
-      ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      AllProjectsName allProjectsName,
-      ProjectJson json,
-      ChildProjects childProjects) {
-    this.projectCache = projectCache;
+      ChildProjects childProjects,
+      Provider<QueryProjects> queryProvider) {
     this.permissionBackend = permissionBackend;
-    this.allProjects = allProjectsName;
-    this.json = json;
     this.childProjects = childProjects;
+    this.queryProvider = queryProvider;
   }
 
-  public void setRecursive(boolean recursive) {
+  public ListChildProjects withRecursive(boolean recursive) {
     this.recursive = recursive;
+    return this;
+  }
+
+  public ListChildProjects withLimit(int limit) {
+    this.limit = limit;
+    return this;
   }
 
   @Override
-  public List<ProjectInfo> apply(ProjectResource rsrc)
-      throws PermissionBackendException, ResourceConflictException {
+  public Response<List<ProjectInfo>> apply(ProjectResource rsrc)
+      throws PermissionBackendException, RestApiException {
+    if (limit < 0) {
+      throw new BadRequestException("limit must be a positive number");
+    }
+    if (recursive && limit != 0) {
+      throw new ResourceConflictException("recursive and limit options are mutually exclusive");
+    }
     rsrc.getProjectState().checkStatePermitsRead();
     if (recursive) {
-      return childProjects.list(rsrc.getNameKey());
+      return Response.ok(childProjects.list(rsrc.getNameKey()));
     }
 
-    return directChildProjects(rsrc.getNameKey());
+    return Response.ok(directChildProjects(rsrc.getNameKey()));
   }
 
-  private List<ProjectInfo> directChildProjects(Project.NameKey parent)
-      throws PermissionBackendException {
-    Map<Project.NameKey, Project> children = new HashMap<>();
-    for (Project.NameKey name : projectCache.all()) {
-      ProjectState c = projectCache.get(name);
-      if (c != null
-          && parent.equals(c.getProject().getParent(allProjects))
-          && c.statePermitsRead()) {
-        children.put(c.getNameKey(), c.getProject());
-      }
-    }
-    return permissionBackend
-        .currentUser()
-        .filter(ProjectPermission.ACCESS, children.keySet())
-        .stream()
-        .sorted()
-        .map((p) -> json.format(children.get(p)))
+  private List<ProjectInfo> directChildProjects(Project.NameKey parent) throws RestApiException {
+    PermissionBackend.WithUser currentUser = permissionBackend.currentUser();
+    return queryProvider.get().withQuery("parent:" + parent.get()).withLimit(limit).apply().stream()
+        .filter(
+            p -> currentUser.project(Project.nameKey(p.name)).testOrFalse(ProjectPermission.ACCESS))
         .collect(toList());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListDashboards.java b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
index 0f6b54f..404458f 100644
--- a/java/com/google/gerrit/server/restapi/project/ListDashboards.java
+++ b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
@@ -16,9 +16,12 @@
 
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -61,11 +64,11 @@
   }
 
   @Override
-  public List<?> apply(ProjectResource rsrc)
+  public Response<List<?>> apply(ProjectResource rsrc)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
     String project = rsrc.getName();
     if (!inherited) {
-      return scan(rsrc.getProjectState(), project, true);
+      return Response.ok(scan(rsrc.getProjectState(), project, true));
     }
 
     List<List<DashboardInfo>> all = new ArrayList<>();
@@ -81,7 +84,7 @@
         all.add(list);
       }
     }
-    return all;
+    return Response.ok(all);
   }
 
   private Collection<ProjectState> tree(ProjectResource rsrc) throws PermissionBackendException {
@@ -99,13 +102,20 @@
 
   private List<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
+    if (!state.statePermitsRead()) {
+      return ImmutableList.of();
+    }
+
     PermissionBackend.ForProject perm = permissionBackend.currentUser().project(state.getNameKey());
     try (Repository git = gitManager.openRepository(state.getNameKey());
         RevWalk rw = new RevWalk(git)) {
       List<DashboardInfo> all = new ArrayList<>();
-      for (Ref ref : git.getRefDatabase().getRefs(REFS_DASHBOARDS).values()) {
-        if (perm.ref(ref.getName()).test(RefPermission.READ) && state.statePermitsRead()) {
+      for (Ref ref : git.getRefDatabase().getRefsByPrefix(REFS_DASHBOARDS)) {
+        try {
+          perm.ref(ref.getName()).check(RefPermission.READ);
           all.addAll(scanDashboards(state.getProject(), git, rw, ref, project, setDefault));
+        } catch (AuthException e) {
+          // Do nothing.
         }
       }
       return all;
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 72a0788..4c3afaa 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -14,34 +14,42 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.isNullOrEmpty;
+import static com.google.common.collect.Ordering.natural;
 import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Strings;
+import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.json.OutputFormat;
 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.CurrentUser;
-import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.ioutil.RegexListSearcher;
 import com.google.gerrit.server.ioutil.StringUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -49,11 +57,10 @@
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
-import com.google.gerrit.server.util.RegexListSearcher;
 import com.google.gerrit.server.util.TreeFormatter;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.BufferedWriter;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -62,7 +69,6 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
@@ -70,12 +76,15 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -97,17 +106,6 @@
         return true;
       }
     },
-    PARENT_CANDIDATES {
-      @Override
-      boolean matches(Repository git) {
-        return true;
-      }
-
-      @Override
-      boolean useMatch() {
-        return false;
-      }
-    },
     PERMISSIONS {
       @Override
       boolean matches(Repository git) throws IOException {
@@ -141,7 +139,7 @@
 
   private final CurrentUser currentUser;
   private final ProjectCache projectCache;
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final GroupControl.Factory groupControlFactory;
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
@@ -257,25 +255,31 @@
   private String matchSubstring;
   private String matchRegex;
   private AccountGroup.UUID groupUuid;
+  private final Provider<QueryProjects> queryProjectsProvider;
+  private final boolean listProjectsFromIndex;
 
   @Inject
   protected ListProjects(
       CurrentUser currentUser,
       ProjectCache projectCache,
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       GroupControl.Factory groupControlFactory,
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
       ProjectNode.Factory projectNodeFactory,
-      WebLinks webLinks) {
+      WebLinks webLinks,
+      Provider<QueryProjects> queryProjectsProvider,
+      @GerritServerConfig Config config) {
     this.currentUser = currentUser;
     this.projectCache = projectCache;
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.groupControlFactory = groupControlFactory;
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.projectNodeFactory = projectNodeFactory;
     this.webLinks = webLinks;
+    this.queryProjectsProvider = queryProjectsProvider;
+    this.listProjectsFromIndex = config.getBoolean("gerrit", "listProjectsFromIndex", false);
   }
 
   public List<String> getShowBranch() {
@@ -300,25 +304,115 @@
   }
 
   @Override
-  public Object apply(TopLevelResource resource)
+  public Response<Object> apply(TopLevelResource resource)
       throws BadRequestException, PermissionBackendException {
     if (format == OutputFormat.TEXT) {
       ByteArrayOutputStream buf = new ByteArrayOutputStream();
-      display(buf);
-      return BinaryResult.create(buf.toByteArray())
-          .setContentType("text/plain")
-          .setCharacterEncoding(UTF_8);
+      displayToStream(buf);
+      return Response.ok(
+          BinaryResult.create(buf.toByteArray())
+              .setContentType("text/plain")
+              .setCharacterEncoding(UTF_8));
     }
-    return apply();
+    return Response.ok(apply());
   }
 
   public SortedMap<String, ProjectInfo> apply()
       throws BadRequestException, PermissionBackendException {
+    Optional<String> projectQuery = expressAsProjectsQuery();
+    if (projectQuery.isPresent()) {
+      return applyAsQuery(projectQuery.get());
+    }
+
     format = OutputFormat.JSON;
     return display(null);
   }
 
-  public SortedMap<String, ProjectInfo> display(@Nullable OutputStream displayOutputStream)
+  private Optional<String> expressAsProjectsQuery() {
+    return listProjectsFromIndex
+            && !all
+            && state != HIDDEN
+            && isNullOrEmpty(matchPrefix)
+            && isNullOrEmpty(matchRegex)
+            && isNullOrEmpty(matchSubstring) // TODO: see Issue 10446
+            && type == FilterType.ALL
+            && showBranch.isEmpty()
+            && !showTree
+        ? Optional.of(stateToQuery())
+        : Optional.empty();
+  }
+
+  private String stateToQuery() {
+    List<String> queries = new ArrayList<>();
+    if (state == null) {
+      queries.add("(state:active OR state:read-only)");
+    } else {
+      queries.add(String.format("(state:%s)", state.name()));
+    }
+
+    return Joiner.on(" AND ").join(queries).toString();
+  }
+
+  private SortedMap<String, ProjectInfo> applyAsQuery(String query) throws BadRequestException {
+    try {
+      return queryProjectsProvider.get().withQuery(query).withStart(start).withLimit(limit).apply()
+          .stream()
+          .collect(
+              ImmutableSortedMap.toImmutableSortedMap(
+                  natural(), p -> p.name, p -> showDescription ? p : nullifyDescription(p)));
+    } catch (StorageException | MethodNotAllowedException e) {
+      logger.atWarning().withCause(e).log(
+          "Internal error while processing the query '%s' request", query);
+      throw new BadRequestException("Internal error while processing the query request");
+    }
+  }
+
+  private ProjectInfo nullifyDescription(ProjectInfo p) {
+    p.description = null;
+    return p;
+  }
+
+  private void printQueryResults(String query, PrintWriter out) throws BadRequestException {
+    try {
+      if (format.isJson()) {
+        format.newGson().toJson(applyAsQuery(query), out);
+      } else {
+        newProjectsNamesStream(query).forEach(out::println);
+      }
+      out.flush();
+    } catch (StorageException | MethodNotAllowedException e) {
+      logger.atWarning().withCause(e).log(
+          "Internal error while processing the query '%s' request", query);
+      throw new BadRequestException("Internal error while processing the query request");
+    }
+  }
+
+  private Stream<String> newProjectsNamesStream(String query)
+      throws MethodNotAllowedException, BadRequestException {
+    Stream<String> projects =
+        queryProjectsProvider.get().withQuery(query).apply().stream().map(p -> p.name).skip(start);
+    if (limit > 0) {
+      projects = projects.limit(limit);
+    }
+
+    return projects;
+  }
+
+  public void displayToStream(OutputStream displayOutputStream)
+      throws BadRequestException, PermissionBackendException {
+    PrintWriter stdout =
+        new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
+    Optional<String> projectsQuery = expressAsProjectsQuery();
+
+    if (projectsQuery.isPresent()) {
+      printQueryResults(projectsQuery.get(), stdout);
+    } else {
+      display(stdout);
+    }
+  }
+
+  @Nullable
+  public SortedMap<String, ProjectInfo> display(@Nullable PrintWriter stdout)
       throws BadRequestException, PermissionBackendException {
     if (all && state != null) {
       throw new BadRequestException("'all' and 'state' may not be used together");
@@ -333,17 +427,6 @@
       }
     }
 
-    PrintWriter stdout = null;
-    if (displayOutputStream != null) {
-      stdout =
-          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
-    }
-
-    if (type == FilterType.PARENT_CANDIDATES) {
-      // Historically, PARENT_CANDIDATES implied showDescription.
-      showDescription = true;
-    }
-
     int foundIndex = 0;
     int found = 0;
     TreeMap<String, ProjectInfo> output = new TreeMap<>();
@@ -352,9 +435,10 @@
     PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
     final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
     try {
-      for (Project.NameKey projectName : filter(perm)) {
-        final ProjectState e = projectCache.get(projectName);
-        if (e == null || (e.getProject().getState() == HIDDEN && !all && state != HIDDEN)) {
+      Iterable<ProjectState> projectStatesIt = filter(perm)::iterator;
+      for (ProjectState e : projectStatesIt) {
+        Project.NameKey projectName = e.getNameKey();
+        if (e.getProject().getState() == HIDDEN && !all && state != HIDDEN) {
           // If we can't get it from the cache, pretend it's not present.
           // If all wasn't selected, and it's HIDDEN, pretend it's not present.
           // If state HIDDEN wasn't selected, and it's HIDDEN, pretend it's not present.
@@ -367,34 +451,30 @@
 
         if (groupUuid != null
             && !e.getLocalGroups()
-                .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
+                .contains(GroupReference.forGroup(groupResolver.parseId(groupUuid.get())))) {
           continue;
         }
 
-        ProjectInfo info = new ProjectInfo();
         if (showTree && !format.isJson()) {
           treeMap.put(projectName, projectNodeFactory.create(e.getProject(), true));
           continue;
         }
 
+        if (foundIndex++ < start) {
+          continue;
+        }
+        if (limit > 0 && ++found > limit) {
+          break;
+        }
+
+        ProjectInfo info = new ProjectInfo();
         info.name = projectName.get();
         if (showTree && format.isJson()) {
-          ProjectState parent = Iterables.getFirst(e.parents(), null);
-          if (parent != null) {
-            if (isParentAccessible(accessibleParents, perm, parent)) {
-              info.parent = parent.getName();
-            } else {
-              info.parent = hiddenNames.get(parent.getName());
-              if (info.parent == null) {
-                info.parent = "?-" + (hiddenNames.size() + 1);
-                hiddenNames.put(parent.getName(), info.parent);
-              }
-            }
-          }
+          addParentProjectInfo(hiddenNames, accessibleParents, perm, e, info);
         }
 
         if (showDescription) {
-          info.description = Strings.emptyToNull(e.getProject().getDescription());
+          info.description = emptyToNull(e.getProject().getDescription());
         }
         info.state = e.getProject().getState();
 
@@ -405,32 +485,12 @@
                 continue;
               }
 
-              boolean canReadAllRefs = e.statePermitsRead();
-              if (canReadAllRefs) {
-                try {
-                  permissionBackend
-                      .user(currentUser)
-                      .project(e.getNameKey())
-                      .check(ProjectPermission.READ);
-                } catch (AuthException exp) {
-                  canReadAllRefs = false;
-                }
-              }
-
-              List<Ref> refs = getBranchRefs(projectName, canReadAllRefs);
+              List<Ref> refs = retieveBranchRefs(e);
               if (!hasValidRef(refs)) {
                 continue;
               }
 
-              for (int i = 0; i < showBranch.size(); i++) {
-                Ref ref = refs.get(i);
-                if (ref != null && ref.getObjectId() != null) {
-                  if (info.branches == null) {
-                    info.branches = new LinkedHashMap<>();
-                  }
-                  info.branches.put(showBranch.get(i), ref.getObjectId().name());
-                }
-              }
+              addProjectBranchesInfo(info, refs);
             }
           } else if (!showTree && type.useMatch()) {
             try (Repository git = repoManager.openRepository(projectName)) {
@@ -447,17 +507,8 @@
           continue;
         }
 
-        if (type != FilterType.PARENT_CANDIDATES) {
-          List<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
-          info.webLinks = links.isEmpty() ? null : links;
-        }
-
-        if (foundIndex++ < start) {
-          continue;
-        }
-        if (limit > 0 && ++found > limit) {
-          break;
-        }
+        ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
+        info.webLinks = links.isEmpty() ? null : links;
 
         if (stdout == null || format.isJson()) {
           output.put(info.name, info);
@@ -465,15 +516,7 @@
         }
 
         if (!showBranch.isEmpty()) {
-          for (String name : showBranch) {
-            String ref = info.branches != null ? info.branches.get(name) : null;
-            if (ref == null) {
-              // Print stub (forty '-' symbols)
-              ref = "----------------------------------------";
-            }
-            stdout.print(ref);
-            stdout.print(' ');
-          }
+          printProjectBranches(stdout, info);
         }
         stdout.print(info.name);
 
@@ -506,55 +549,79 @@
     }
   }
 
-  private Collection<Project.NameKey> filter(PermissionBackend.WithUser perm)
-      throws BadRequestException, PermissionBackendException {
-    Stream<Project.NameKey> matches = scan();
-    if (type == FilterType.PARENT_CANDIDATES) {
-      matches = parentsOf(matches);
+  private void printProjectBranches(PrintWriter stdout, ProjectInfo info) {
+    for (String name : showBranch) {
+      String ref = info.branches != null ? info.branches.get(name) : null;
+      if (ref == null) {
+        // Print stub (forty '-' symbols)
+        ref = "----------------------------------------";
+      }
+      stdout.print(ref);
+      stdout.print(' ');
     }
+  }
 
-    List<Project.NameKey> results = new ArrayList<>();
-    List<Project.NameKey> projectNameKeys = matches.sorted().collect(toList());
-    for (Project.NameKey nameKey : projectNameKeys) {
-      ProjectState state = projectCache.get(nameKey);
-      checkNotNull(state, "Failed to load project %s", nameKey);
+  private void addProjectBranchesInfo(ProjectInfo info, List<Ref> refs) {
+    for (int i = 0; i < showBranch.size(); i++) {
+      Ref ref = refs.get(i);
+      if (ref != null && ref.getObjectId() != null) {
+        if (info.branches == null) {
+          info.branches = new LinkedHashMap<>();
+        }
+        info.branches.put(showBranch.get(i), ref.getObjectId().name());
+      }
+    }
+  }
 
-      // Hidden projects(permitsRead = false) should only be accessible by the project owners.
-      // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
-      // be allowed for other users). Allowing project owners to access here will help them to view
-      // and update the config of hidden projects easily.
-      ProjectPermission permissionToCheck =
-          state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
+  private List<Ref> retieveBranchRefs(ProjectState e) throws PermissionBackendException {
+    boolean canReadAllRefs = e.statePermitsRead();
+    if (canReadAllRefs) {
       try {
-        perm.project(nameKey).check(permissionToCheck);
-        results.add(nameKey);
-      } catch (AuthException e) {
-        // Not added to results.
+        permissionBackend.user(currentUser).project(e.getNameKey()).check(ProjectPermission.READ);
+      } catch (AuthException exp) {
+        canReadAllRefs = false;
       }
     }
 
-    return results;
+    return getBranchRefs(e.getNameKey(), canReadAllRefs);
   }
 
-  private Stream<Project.NameKey> parentsOf(Stream<Project.NameKey> matches) {
-    return matches
-        .map(
-            p -> {
-              ProjectState ps = projectCache.get(p);
-              if (ps != null) {
-                Project.NameKey parent = ps.getProject().getParent();
-                if (parent != null) {
-                  if (projectCache.get(parent) != null) {
-                    return parent;
-                  }
-                  logger.atWarning().log(
-                      "parent project %s of project %s not found", parent.get(), ps.getName());
-                }
-              }
-              return null;
-            })
+  private void addParentProjectInfo(
+      Map<String, String> hiddenNames,
+      Map<Project.NameKey, Boolean> accessibleParents,
+      PermissionBackend.WithUser perm,
+      ProjectState e,
+      ProjectInfo info)
+      throws PermissionBackendException {
+    ProjectState parent = Iterables.getFirst(e.parents(), null);
+    if (parent != null) {
+      if (isParentAccessible(accessibleParents, perm, parent)) {
+        info.parent = parent.getName();
+      } else {
+        info.parent = hiddenNames.get(parent.getName());
+        if (info.parent == null) {
+          info.parent = "?-" + (hiddenNames.size() + 1);
+          hiddenNames.put(parent.getName(), info.parent);
+        }
+      }
+    }
+  }
+
+  private Stream<ProjectState> filter(PermissionBackend.WithUser perm) throws BadRequestException {
+    return StreamSupport.stream(scan().spliterator(), false)
+        .map(projectCache::get)
         .filter(Objects::nonNull)
-        .distinct();
+        .filter(p -> permissionCheck(p, perm));
+  }
+
+  private boolean permissionCheck(ProjectState state, PermissionBackend.WithUser perm) {
+    // Hidden projects(permitsRead = false) should only be accessible by the project owners.
+    // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
+    // be allowed for other users). Allowing project owners to access here will help them to view
+    // and update the config of hidden projects easily.
+    return perm.project(state.getNameKey())
+        .testOrFalse(
+            state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG);
   }
 
   private boolean isParentAccessible(
@@ -587,9 +654,7 @@
       return projectCache.byName(matchPrefix).stream();
     } else if (matchSubstring != null) {
       checkMatchOptions(matchPrefix == null && matchRegex == null);
-      return projectCache
-          .all()
-          .stream()
+      return projectCache.all().stream()
           .filter(
               p -> p.get().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US)));
     } else if (matchRegex != null) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index e79fdca..e7291a4 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -15,13 +15,15 @@
 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.common.collect.ImmutableList;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -39,8 +41,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;
@@ -117,7 +117,7 @@
   }
 
   @Override
-  public List<TagInfo> apply(ProjectResource resource)
+  public Response<ImmutableList<TagInfo>> apply(ProjectResource resource)
       throws IOException, ResourceNotFoundException, RestApiException, PermissionBackendException {
     resource.getProjectState().checkStatePermitsRead();
 
@@ -128,28 +128,23 @@
     try (Repository repo = getRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
       Map<String, Ref> all =
-          visibleTags(resource.getNameKey(), repo, repo.getRefDatabase().getRefs(Constants.R_TAGS));
+          visibleTags(
+              resource.getNameKey(), repo, repo.getRefDatabase().getRefsByPrefix(Constants.R_TAGS));
       for (Ref ref : all.values()) {
         tags.add(
             createTagInfo(perm.ref(ref.getName()), ref, rw, resource.getProjectState(), links));
       }
     }
 
-    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)
-        .limit(limit)
-        .subString(matchSubstring)
-        .regex(matchRegex)
-        .filter(tags);
+    return Response.ok(
+        new RefFilter<TagInfo>(Constants.R_TAGS)
+            .start(start)
+            .limit(limit)
+            .subString(matchSubstring)
+            .regex(matchRegex)
+            .filter(tags));
   }
 
   public TagInfo get(ProjectResource resource, IdString id)
@@ -162,8 +157,7 @@
       }
       Ref ref = repo.getRefDatabase().exactRef(tagName);
       if (ref != null
-          && !visibleTags(resource.getNameKey(), repo, ImmutableMap.of(ref.getName(), ref))
-              .isEmpty()) {
+          && !visibleTags(resource.getNameKey(), repo, ImmutableList.of(ref)).isEmpty()) {
         return createTagInfo(
             permissionBackend
                 .user(resource.getUser())
@@ -190,7 +184,7 @@
           perm.testOrFalse(RefPermission.DELETE) && projectState.statePermitsWrite() ? true : null;
     }
 
-    List<WebLinkInfo> webLinks = links.getTagLinks(projectState.getName(), ref.getName());
+    ImmutableList<WebLinkInfo> webLinks = links.getTagLinks(projectState.getName(), ref.getName());
     if (object instanceof RevTag) {
       // Annotated or signed tag
       RevTag tag = (RevTag) object;
@@ -229,15 +223,11 @@
     }
   }
 
-  private Map<String, Ref> visibleTags(
-      Project.NameKey project, Repository repo, Map<String, Ref> tags)
+  private Map<String, Ref> visibleTags(Project.NameKey project, Repository repo, List<Ref> tags)
       throws PermissionBackendException {
     return permissionBackend
         .currentUser()
         .project(project)
-        .filter(
-            tags,
-            repo,
-            RefFilterOptions.builder().setFilterMeta(true).setFilterTagsSeparately(true).build());
+        .filter(tags, repo, RefFilterOptions.builder().setFilterMeta(true).build());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index 17a675d..de5661d 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -23,7 +23,9 @@
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.project.RefValidationHelper;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
 
@@ -41,6 +43,9 @@
     DynamicMap.mapOf(binder(), COMMIT_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
 
+    DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
+
+    create(PROJECT_KIND).to(CreateProject.class);
     put(PROJECT_KIND).to(PutProject.class);
     get(PROJECT_KIND).to(GetProject.class);
     get(PROJECT_KIND, "description").to(GetDescription.class);
@@ -50,9 +55,10 @@
     get(PROJECT_KIND, "access").to(GetAccess.class);
     post(PROJECT_KIND, "access").to(SetAccess.class);
     put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
-    post(PROJECT_KIND, "check.access").to(CheckAccess.class);
     get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
 
+    post(PROJECT_KIND, "check").to(Check.class);
+
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
 
@@ -67,13 +73,14 @@
     get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
     post(PROJECT_KIND, "gc").to(GarbageCollect.class);
     post(PROJECT_KIND, "index").to(Index.class);
+    post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
 
     child(PROJECT_KIND, "branches").to(BranchesCollection.class);
+    create(BRANCH_KIND).to(CreateBranch.class);
     put(BRANCH_KIND).to(PutBranch.class);
     get(BRANCH_KIND).to(GetBranch.class);
     delete(BRANCH_KIND).to(DeleteBranch.class);
     post(PROJECT_KIND, "branches:delete").to(DeleteBranches.class);
-    factory(CreateBranch.Factory.class);
     get(BRANCH_KIND, "mergeable").to(CheckMergeability.class);
     factory(RefValidationHelper.Factory.class);
     get(BRANCH_KIND, "reflog").to(GetReflog.class);
@@ -86,23 +93,22 @@
     child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
 
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
+    create(TAG_KIND).to(CreateTag.class);
     get(TAG_KIND).to(GetTag.class);
     put(TAG_KIND).to(PutTag.class);
     delete(TAG_KIND).to(DeleteTag.class);
     post(PROJECT_KIND, "tags:delete").to(DeleteTags.class);
-    factory(CreateTag.Factory.class);
 
     child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
+    create(DASHBOARD_KIND).to(CreateDashboard.class);
     get(DASHBOARD_KIND).to(GetDashboard.class);
     put(DASHBOARD_KIND).to(SetDashboard.class);
     delete(DASHBOARD_KIND).to(DeleteDashboard.class);
-    factory(CreateProject.Factory.class);
 
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
     post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
 
-    factory(DeleteRef.Factory.class);
     factory(ProjectNode.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index 1ba993c..875dcfb 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.collect.ListMultimap;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -29,9 +29,10 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -42,20 +43,18 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import org.eclipse.jgit.lib.Constants;
 
 @Singleton
 public class ProjectsCollection
-    implements RestCollection<TopLevelResource, ProjectResource>,
-        AcceptsCreate<TopLevelResource>,
-        NeedsParams {
+    implements RestCollection<TopLevelResource, ProjectResource>, NeedsParams {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<ListProjects> list;
   private final Provider<QueryProjects> queryProjects;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
-  private final CreateProject.Factory createProjectFactory;
 
   private boolean hasQuery;
 
@@ -66,7 +65,6 @@
       Provider<QueryProjects> queryProjects,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      CreateProject.Factory factory,
       Provider<CurrentUser> user) {
     this.views = views;
     this.list = list;
@@ -74,7 +72,6 @@
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.user = user;
-    this.createProjectFactory = factory;
   }
 
   @Override
@@ -139,37 +136,33 @@
   @Nullable
   private ProjectResource _parse(String id, boolean checkAccess)
       throws IOException, PermissionBackendException, ResourceConflictException {
-    if (id.endsWith(Constants.DOT_GIT_EXT)) {
-      id = id.substring(0, id.length() - Constants.DOT_GIT_EXT.length());
-    }
+    id = ProjectUtil.sanitizeProjectName(id);
 
-    Project.NameKey nameKey = new Project.NameKey(id);
+    Project.NameKey nameKey = Project.nameKey(id);
     ProjectState state = projectCache.checkedGet(nameKey);
     if (state == null) {
       return null;
     }
 
+    logger.atFine().log("Project %s has state %s", nameKey, state.getProject().getState());
+
     if (checkAccess) {
       // Hidden projects(permitsRead = false) should only be accessible by the project owners.
-      // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
+      // WRITE_CONFIG is checked here because it's only allowed to project owners (ACCESS may also
       // be allowed for other users). Allowing project owners to access here will help them to view
       // and update the config of hidden projects easily.
-      ProjectPermission permissionToCheck =
-          state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
-      try {
-        permissionBackend.currentUser().project(nameKey).check(permissionToCheck);
-      } catch (AuthException e) {
-        return null; // Pretend like not found on access denied.
-      }
-      // If the project's state does not permit reading, we want to hide it from all callers. The
-      // only exception to that are users who are allowed to mutate the project's configuration.
-      // This enables these users to still mutate the project's state (e.g. set a HIDDEN project to
-      // ACTIVE). Individual views should still check for checkStatePermitsRead() and this should
-      // just serve as a safety net in case the individual check is forgotten.
-      try {
-        permissionBackend.currentUser().project(nameKey).check(ProjectPermission.WRITE_CONFIG);
-      } catch (AuthException e) {
-        state.checkStatePermitsRead();
+      if (state.statePermitsRead()) {
+        try {
+          permissionBackend.currentUser().project(nameKey).check(ProjectPermission.ACCESS);
+        } catch (AuthException e) {
+          return null;
+        }
+      } else {
+        try {
+          permissionBackend.currentUser().project(nameKey).check(ProjectPermission.WRITE_CONFIG);
+        } catch (AuthException e) {
+          state.checkStatePermitsRead();
+        }
       }
     }
     return new ProjectResource(state, user.get());
@@ -179,9 +172,4 @@
   public DynamicMap<RestView<ProjectResource>> views() {
     return views;
   }
-
-  @Override
-  public CreateProject create(TopLevelResource parent, IdString name) throws RestApiException {
-    return createProjectFactory.create(name.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/PutBranch.java b/java/com/google/gerrit/server/restapi/project/PutBranch.java
index fec8abf..02fc6689 100644
--- a/java/com/google/gerrit/server/restapi/project/PutBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/PutBranch.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.inject.Singleton;
@@ -25,7 +26,8 @@
 public class PutBranch implements RestModifyView<BranchResource, BranchInput> {
 
   @Override
-  public BranchInfo apply(BranchResource rsrc, BranchInput input) throws ResourceConflictException {
+  public Response<BranchInfo> apply(BranchResource rsrc, BranchInput input)
+      throws ResourceConflictException {
     throw new ResourceConflictException("Branch \"" + rsrc.getRef() + "\" already exists");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index db596e6..a5a7154 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -26,6 +26,7 @@
 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.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -38,7 +39,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;
@@ -48,6 +48,7 @@
 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.project.ProjectState.Factory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -55,7 +56,6 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -71,7 +71,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;
@@ -79,26 +78,26 @@
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<CurrentUser> user;
   private final PermissionBackend permissionBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   PutConfig(
       @EnableSignedPush boolean serverEnableSignedPush,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
-      ProjectState.Factory projectStateFactory,
-      TransferConfig config,
+      Factory projectStateFactory,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views,
       Provider<CurrentUser> user,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ProjectConfig.Factory projectConfigFactory) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
-    this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
@@ -106,16 +105,17 @@
     this.views = views;
     this.user = user;
     this.permissionBackend = permissionBackend;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input)
+  public Response<ConfigInfo> apply(ProjectResource rsrc, ConfigInput input)
       throws RestApiException, PermissionBackendException {
     permissionBackend
         .currentUser()
         .project(rsrc.getNameKey())
         .check(ProjectPermission.WRITE_CONFIG);
-    return apply(rsrc.getProjectState(), input);
+    return Response.ok(apply(rsrc.getProjectState(), input));
   }
 
   public ConfigInfo apply(ProjectState projectState, ConfigInput input)
@@ -126,7 +126,7 @@
     }
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig projectConfig = ProjectConfig.read(md);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
       Project p = projectConfig.getProject();
 
       p.setDescription(Strings.emptyToNull(input.description));
@@ -168,12 +168,11 @@
         throw new ResourceConflictException("Cannot update " + projectName);
       }
 
-      ProjectState state = projectStateFactory.create(projectConfig);
+      ProjectState state = projectStateFactory.create(projectConfigFactory.read(md));
       return new ConfigInfoImpl(
           serverEnableSignedPush,
           state,
           user.get(),
-          config,
           pluginConfigEntries,
           cfgFactory,
           allProjects,
@@ -193,10 +192,10 @@
       ProjectConfig projectConfig,
       Map<String, Map<String, ConfigValue>> pluginConfigValues)
       throws BadRequestException {
-    for (Entry<String, Map<String, ConfigValue>> e : pluginConfigValues.entrySet()) {
+    for (Map.Entry<String, Map<String, ConfigValue>> e : pluginConfigValues.entrySet()) {
       String pluginName = e.getKey();
       PluginConfig cfg = projectConfig.getPluginConfig(pluginName);
-      for (Entry<String, ConfigValue> v : e.getValue().entrySet()) {
+      for (Map.Entry<String, ConfigValue> v : e.getValue().entrySet()) {
         ProjectConfigEntry projectConfigEntry = pluginConfigEntries.get(pluginName, v.getKey());
         if (projectConfigEntry != null) {
           if (!PARAMETER_NAME_PATTERN.matcher(v.getKey()).matches()) {
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index 1c74021..cd0bd5c 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -42,15 +42,18 @@
   private final ProjectCache cache;
   private final MetaDataUpdate.Server updateFactory;
   private final PermissionBackend permissionBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   PutDescription(
       ProjectCache cache,
       MetaDataUpdate.Server updateFactory,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ProjectConfig.Factory projectConfigFactory) {
     this.cache = cache;
     this.updateFactory = updateFactory;
     this.permissionBackend = permissionBackend;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
@@ -68,7 +71,7 @@
         .check(ProjectPermission.WRITE_CONFIG);
 
     try (MetaDataUpdate md = updateFactory.create(resource.getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Project project = config.getProject();
       project.setDescription(Strings.emptyToNull(input.description));
 
@@ -85,7 +88,7 @@
       md.getRepository().setGitwebDescription(project.getDescription());
 
       return Strings.isNullOrEmpty(project.getDescription())
-          ? Response.<String>none()
+          ? Response.none()
           : Response.ok(project.getDescription());
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(resource.getName());
diff --git a/java/com/google/gerrit/server/restapi/project/PutTag.java b/java/com/google/gerrit/server/restapi/project/PutTag.java
index 06c5157..b6f3f24 100644
--- a/java/com/google/gerrit/server/restapi/project/PutTag.java
+++ b/java/com/google/gerrit/server/restapi/project/PutTag.java
@@ -17,13 +17,15 @@
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.project.TagResource;
 
 public class PutTag implements RestModifyView<TagResource, TagInput> {
 
   @Override
-  public TagInfo apply(TagResource resource, TagInput input) throws ResourceConflictException {
+  public Response<TagInfo> apply(TagResource resource, TagInput input)
+      throws ResourceConflictException {
     throw new ResourceConflictException("Tag \"" + resource.getTagInfo().ref + "\" already exists");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index 1e094a0..7066d9a 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.project.ProjectData;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.query.project.ProjectQueryBuilder;
 import com.google.gerrit.server.query.project.ProjectQueryProcessor;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.List;
@@ -49,8 +49,9 @@
       name = "--query",
       aliases = {"-q"},
       usage = "project query")
-  public void setQuery(String query) {
+  public QueryProjects withQuery(String query) {
     this.query = query;
+    return this;
   }
 
   @Option(
@@ -58,8 +59,9 @@
       aliases = {"-n"},
       metaVar = "CNT",
       usage = "maximum number of projects to list")
-  public void setLimit(int limit) {
+  public QueryProjects withLimit(int limit) {
     this.limit = limit;
+    return this;
   }
 
   @Option(
@@ -67,8 +69,9 @@
       aliases = {"-S"},
       metaVar = "CNT",
       usage = "number of projects to skip")
-  public void setStart(int start) {
+  public QueryProjects withStart(int start) {
     this.start = start;
+    return this;
   }
 
   @Inject
@@ -84,8 +87,12 @@
   }
 
   @Override
-  public List<ProjectInfo> apply(TopLevelResource resource)
-      throws BadRequestException, MethodNotAllowedException, OrmException {
+  public Response<List<ProjectInfo>> apply(TopLevelResource resource)
+      throws BadRequestException, MethodNotAllowedException {
+    return Response.ok(apply());
+  }
+
+  public List<ProjectInfo> apply() throws BadRequestException, MethodNotAllowedException {
     if (Strings.isNullOrEmpty(query)) {
       throw new BadRequestException("missing query field");
     }
diff --git a/java/com/google/gerrit/server/restapi/project/RepositoryStatistics.java b/java/com/google/gerrit/server/restapi/project/RepositoryStatistics.java
index 2a2fc866..9f686eb 100644
--- a/java/com/google/gerrit/server/restapi/project/RepositoryStatistics.java
+++ b/java/com/google/gerrit/server/restapi/project/RepositoryStatistics.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.CaseFormat;
-import java.util.Map.Entry;
+import java.util.Map;
 import java.util.Properties;
 import java.util.TreeMap;
 
@@ -23,7 +23,7 @@
   private static final long serialVersionUID = 1L;
 
   RepositoryStatistics(Properties p) {
-    for (Entry<Object, Object> e : p.entrySet()) {
+    for (Map.Entry<Object, Object> e : p.entrySet()) {
       put(
           CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getKey().toString()),
           e.getValue());
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index c9d69a5..c6919d4 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -17,13 +17,14 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 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.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
@@ -38,7 +39,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -56,6 +56,7 @@
   private final Provider<IdentifiedUser> identifiedUser;
   private final SetAccessUtil accessUtil;
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   private SetAccess(
@@ -66,7 +67,8 @@
       GetAccess getAccess,
       Provider<IdentifiedUser> identifiedUser,
       SetAccessUtil accessUtil,
-      CreateGroupPermissionSyncer createGroupPermissionSyncer) {
+      CreateGroupPermissionSyncer createGroupPermissionSyncer,
+      ProjectConfig.Factory projectConfigFactory) {
     this.groupBackend = groupBackend;
     this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -75,13 +77,13 @@
     this.identifiedUser = identifiedUser;
     this.accessUtil = accessUtil;
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
-  public ProjectAccessInfo apply(ProjectResource rsrc, ProjectAccessInput input)
+  public Response<ProjectAccessInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
       throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
-          BadRequestException, UnprocessableEntityException, OrmException,
-          PermissionBackendException {
+          BadRequestException, UnprocessableEntityException, PermissionBackendException {
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
 
     ProjectConfig config;
@@ -89,7 +91,7 @@
     List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
     List<AccessSection> additions = accessUtil.getAccessSections(input.add);
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      config = ProjectConfig.read(md);
+      config = projectConfigFactory.read(md);
 
       // Check that the user has the right permissions.
       boolean checkedAdmin = false;
@@ -116,7 +118,7 @@
           identifiedUser.get(),
           config,
           rsrc.getNameKey(),
-          input.parent == null ? null : new Project.NameKey(input.parent),
+          input.parent == null ? null : Project.nameKey(input.parent),
           !checkedAdmin);
 
       if (!Strings.isNullOrEmpty(input.message)) {
@@ -137,6 +139,6 @@
       throw new ResourceConflictException(rsrc.getName());
     }
 
-    return getAccess.apply(rsrc.getNameKey());
+    return Response.ok(getAccess.apply(rsrc.getNameKey()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 422c749..e206319 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
 import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
@@ -32,11 +32,11 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.restapi.config.ListCapabilities;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -48,18 +48,18 @@
 
 @Singleton
 public class SetAccessUtil {
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final AllProjectsName allProjects;
   private final Provider<SetParent> setParent;
   private final ListCapabilities listCapabilities;
 
   @Inject
   private SetAccessUtil(
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
       ListCapabilities listCapabilities) {
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.allProjects = allProjects;
     this.setParent = setParent;
     this.listCapabilities = listCapabilities;
@@ -91,7 +91,7 @@
 
         for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
             permissionEntry.getValue().rules.entrySet()) {
-          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
+          GroupDescription.Basic group = groupResolver.parseId(permissionRuleInfoEntry.getKey());
           if (group == null) {
             throw new UnprocessableEntityException(
                 permissionRuleInfoEntry.getKey() + " is not a valid group ID");
@@ -115,7 +115,7 @@
           }
           p.add(r);
         }
-        accessSection.getPermissions().add(p);
+        accessSection.addPermission(p);
       }
       sections.add(accessSection);
     }
@@ -146,7 +146,7 @@
       boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
 
       if (!isGlobalCapabilities) {
-        if (!AccessSection.isValid(name)) {
+        if (!AccessSection.isValidRefSectionName(name)) {
           throw new BadRequestException("invalid section name");
         }
         RefPattern.validate(name);
diff --git a/java/com/google/gerrit/server/restapi/project/SetDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
index 891978b..2804b7c 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index ba91e0e..1ea3efd 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -17,7 +17,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -47,9 +47,10 @@
   private final DashboardsCollection dashboards;
   private final Provider<GetDashboard> get;
   private final PermissionBackend permissionBackend;
+  private final ProjectConfig.Factory projectConfigFactory;
 
   @Option(name = "--inherited", usage = "set dashboard inherited by children")
-  private boolean inherited;
+  boolean inherited;
 
   @Inject
   SetDefaultDashboard(
@@ -57,12 +58,14 @@
       MetaDataUpdate.Server updateFactory,
       DashboardsCollection dashboards,
       Provider<GetDashboard> get,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ProjectConfig.Factory projectConfigFactory) {
     this.cache = cache;
     this.updateFactory = updateFactory;
     this.dashboards = dashboards;
     this.get = get;
     this.permissionBackend = permissionBackend;
+    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
@@ -93,7 +96,7 @@
     }
 
     try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Project project = config.getProject();
       if (inherited) {
         project.setDefaultDashboard(input.id);
@@ -116,9 +119,9 @@
       cache.evict(rsrc.getProjectState().getProject());
 
       if (target != null) {
-        DashboardInfo info = get.get().apply(target);
-        info.isDefault = true;
-        return Response.ok(info);
+        Response<DashboardInfo> response = get.get().apply(target);
+        response.value().isDefault = true;
+        return response;
       }
       return Response.none();
     } catch (RepositoryNotFoundException notFound) {
@@ -128,25 +131,4 @@
           String.format("invalid project.config: %s", e.getMessage()));
     }
   }
-
-  static class CreateDefault implements RestModifyView<ProjectResource, SetDashboardInput> {
-    private final Provider<SetDefaultDashboard> setDefault;
-
-    @Option(name = "--inherited", usage = "set dashboard inherited by children")
-    private boolean inherited;
-
-    @Inject
-    CreateDefault(Provider<SetDefaultDashboard> setDefault) {
-      this.setDefault = setDefault;
-    }
-
-    @Override
-    public Response<DashboardInfo> apply(ProjectResource resource, SetDashboardInput input)
-        throws RestApiException, IOException, PermissionBackendException {
-      SetDefaultDashboard set = setDefault.get();
-      set.inherited = inherited;
-      return set.apply(
-          DashboardResource.projectDefault(resource.getProjectState(), resource.getUser()), input);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetHead.java b/java/com/google/gerrit/server/restapi/project/SetHead.java
index feff98e..5533f74 100644
--- a/java/com/google/gerrit/server/restapi/project/SetHead.java
+++ b/java/com/google/gerrit/server/restapi/project/SetHead.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.projects.HeadInput;
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
@@ -32,6 +31,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -46,18 +46,16 @@
 
 @Singleton
 public class SetHead implements RestModifyView<ProjectResource, HeadInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final GitRepositoryManager repoManager;
   private final Provider<IdentifiedUser> identifiedUser;
-  private final DynamicSet<HeadUpdatedListener> headUpdatedListeners;
+  private final PluginSetContext<HeadUpdatedListener> headUpdatedListeners;
   private final PermissionBackend permissionBackend;
 
   @Inject
   SetHead(
       GitRepositoryManager repoManager,
       Provider<IdentifiedUser> identifiedUser,
-      DynamicSet<HeadUpdatedListener> headUpdatedListeners,
+      PluginSetContext<HeadUpdatedListener> headUpdatedListeners,
       PermissionBackend permissionBackend) {
     this.repoManager = repoManager;
     this.identifiedUser = identifiedUser;
@@ -66,7 +64,7 @@
   }
 
   @Override
-  public String apply(ProjectResource rsrc, HeadInput input)
+  public Response<String> apply(ProjectResource rsrc, HeadInput input)
       throws AuthException, ResourceNotFoundException, BadRequestException,
           UnprocessableEntityException, IOException, PermissionBackendException {
     if (input == null || Strings.isNullOrEmpty(input.ref)) {
@@ -112,24 +110,19 @@
 
         fire(rsrc.getNameKey(), oldHead, newHead);
       }
-      return ref;
+      return Response.ok(ref);
     } catch (RepositoryNotFoundException e) {
       throw new ResourceNotFoundException(rsrc.getName());
     }
   }
 
   private void fire(Project.NameKey nameKey, String oldHead, String newHead) {
-    if (!headUpdatedListeners.iterator().hasNext()) {
+    if (headUpdatedListeners.isEmpty()) {
       return;
     }
+
     Event event = new Event(nameKey, oldHead, newHead);
-    for (HeadUpdatedListener l : headUpdatedListeners) {
-      try {
-        l.onHeadUpdated(event);
-      } catch (RuntimeException e) {
-        logger.atWarning().withCause(e).log("Failure in HeadUpdatedListener");
-      }
-    }
+    headUpdatedListeners.runEach(l -> l.onHeadUpdated(event));
   }
 
   static class Event extends AbstractNoNotifyEvent implements HeadUpdatedListener.Event {
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index 6710f6c..0b91551 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -14,26 +14,35 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.api.projects.ParentInput;
 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.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
 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.ConfigKey;
+import com.google.gerrit.server.config.ConfigUpdatedEvent;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
+import com.google.gerrit.server.config.GerritConfigListener;
+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,14 +52,18 @@
 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> {
+public class SetParent
+    implements RestModifyView<ProjectResource, ParentInput>, GerritConfigListener {
   private final ProjectCache cache;
   private final PermissionBackend permissionBackend;
   private final MetaDataUpdate.Server updateFactory;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private volatile boolean allowProjectOwnersToChangeParent;
 
   @Inject
   SetParent(
@@ -58,20 +71,25 @@
       PermissionBackend permissionBackend,
       MetaDataUpdate.Server updateFactory,
       AllProjectsName allProjects,
-      AllUsersName allUsers) {
+      AllUsersName allUsers,
+      ProjectConfig.Factory projectConfigFactory,
+      @GerritServerConfig Config config) {
     this.cache = cache;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.allProjects = allProjects;
     this.allUsers = allUsers;
+    this.projectConfigFactory = projectConfigFactory;
+    this.allowProjectOwnersToChangeParent =
+        config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
   }
 
   @Override
-  public String apply(ProjectResource rsrc, ParentInput input)
+  public Response<String> apply(ProjectResource rsrc, ParentInput input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
           UnprocessableEntityException, IOException, PermissionBackendException,
           BadRequestException {
-    return apply(rsrc, input, true);
+    return Response.ok(apply(rsrc, input, true));
   }
 
   public String apply(ProjectResource rsrc, ParentInput input, boolean checkIfAdmin)
@@ -83,7 +101,7 @@
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
     validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
     try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Project project = config.getProject();
       project.setParentName(parentName);
 
@@ -99,7 +117,7 @@
       cache.evict(rsrc.getProjectState().getProject());
 
       Project.NameKey parent = project.getParent(allProjects);
-      checkNotNull(parent);
+      requireNonNull(parent);
       return parent.get();
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(rsrc.getName());
@@ -114,7 +132,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)) {
@@ -133,7 +155,7 @@
 
     newParent = Strings.emptyToNull(newParent);
     if (newParent != null) {
-      ProjectState parent = cache.get(new Project.NameKey(newParent));
+      ProjectState parent = cache.get(Project.nameKey(newParent));
       if (parent == null) {
         throw new UnprocessableEntityException("parent project " + newParent + " not found");
       }
@@ -153,4 +175,20 @@
       }
     }
   }
+
+  @Override
+  public Multimap<UpdateResult, ConfigUpdateEntry> configUpdated(ConfigUpdatedEvent event) {
+    ConfigKey receiveSetParent = ConfigKey.create("receive", "allowProjectOwnersToChangeParent");
+    if (!event.isValueUpdated(receiveSetParent)) {
+      return ConfigUpdatedEvent.NO_UPDATES;
+    }
+    try {
+      boolean enabled =
+          event.getNewConfig().getBoolean("receive", "allowProjectOwnersToChangeParent", false);
+      this.allowProjectOwnersToChangeParent = enabled;
+    } catch (IllegalArgumentException iae) {
+      return event.reject(receiveSetParent);
+    }
+    return event.accept(receiveSetParent);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/TagsCollection.java b/java/com/google/gerrit/server/restapi/project/TagsCollection.java
index fdace77..a129bda 100644
--- a/java/com/google/gerrit/server/restapi/project/TagsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/TagsCollection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -30,20 +29,14 @@
 import java.io.IOException;
 
 @Singleton
-public class TagsCollection
-    implements ChildCollection<ProjectResource, TagResource>, AcceptsCreate<ProjectResource> {
+public class TagsCollection implements ChildCollection<ProjectResource, TagResource> {
   private final DynamicMap<RestView<TagResource>> views;
   private final Provider<ListTags> list;
-  private final CreateTag.Factory createTagFactory;
 
   @Inject
-  public TagsCollection(
-      DynamicMap<RestView<TagResource>> views,
-      Provider<ListTags> list,
-      CreateTag.Factory createTagFactory) {
+  public TagsCollection(DynamicMap<RestView<TagResource>> views, Provider<ListTags> list) {
     this.views = views;
     this.list = list;
-    this.createTagFactory = createTagFactory;
   }
 
   @Override
@@ -62,9 +55,4 @@
   public DynamicMap<RestView<TagResource>> views() {
     return views;
   }
-
-  @Override
-  public CreateTag create(ProjectResource resource, IdString name) {
-    return createTagFactory.create(name.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 65ac88f..8401c1d 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -20,6 +20,7 @@
 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.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
@@ -27,7 +28,6 @@
 import com.google.gerrit.server.project.ProjectState;
 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;
@@ -80,7 +80,7 @@
     try {
       labelTypes = cd.getLabelTypes().getLabelTypes();
       approvals = cd.currentApprovals();
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Unable to fetch labels and approvals for change %s", cd.getId());
 
@@ -124,9 +124,8 @@
 
   private static List<PatchSetApproval> getApprovalsForLabel(
       List<PatchSetApproval> approvals, LabelType t) {
-    return approvals
-        .stream()
-        .filter(input -> input.getLabel().equals(t.getLabelId().get()))
+    return approvals.stream()
+        .filter(input -> input.label().equals(t.getLabelId().get()))
         .collect(toImmutableList());
   }
 }
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..4695800
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -0,0 +1,169 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.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.exceptions.StorageException;
+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.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 (StorageException e) {
+      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_LABELS);
+      return singletonRuleError(E_UNABLE_TO_FETCH_LABELS);
+    }
+
+    boolean shouldIgnoreSelfApproval = labelTypes.stream().anyMatch(LabelType::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().uploader();
+    } catch (StorageException 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.value() < 0 || !input.accountId().equals(user))
+        .collect(toImmutableList());
+  }
+
+  @VisibleForTesting
+  static Collection<PatchSetApproval> filterApprovalsByLabel(
+      Collection<PatchSetApproval> approvals, LabelType t) {
+    return approvals.stream()
+        .filter(input -> input.labelId().get().equals(t.getLabelId().get()))
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PredicateClassLoader.java b/java/com/google/gerrit/server/rules/PredicateClassLoader.java
index 2589253..0a7a47f 100644
--- a/java/com/google/gerrit/server/rules/PredicateClassLoader.java
+++ b/java/com/google/gerrit/server/rules/PredicateClassLoader.java
@@ -16,7 +16,7 @@
 
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.SetMultimap;
-import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import java.util.Collection;
 
 /** Loads the classes for Prolog predicates. */
@@ -26,14 +26,15 @@
       LinkedHashMultimap.create();
 
   public PredicateClassLoader(
-      final DynamicSet<PredicateProvider> predicateProviders, ClassLoader parent) {
+      PluginSetContext<PredicateProvider> predicateProviders, ClassLoader parent) {
     super(parent);
 
-    for (PredicateProvider predicateProvider : predicateProviders) {
-      for (String pkg : predicateProvider.getPackages()) {
-        packageClassLoaderMap.put(pkg, predicateProvider.getClass().getClassLoader());
-      }
-    }
+    predicateProviders.runEach(
+        predicateProvider -> {
+          for (String pkg : predicateProvider.getPackages()) {
+            packageClassLoaderMap.put(pkg, predicateProvider.getClass().getClassLoader());
+          }
+        });
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index 083898b..a327d6e 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -18,7 +18,9 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -81,7 +83,9 @@
   @Override
   public void setPredicate(Predicate goal) {
     super.setPredicate(goal);
-    setReductionLimit(args.reductionLimit(goal));
+    int reductionLimit = args.reductionLimit(goal);
+    logger.atFine().log("setting reductionLimit %d", reductionLimit);
+    setReductionLimit(reductionLimit);
   }
 
   /**
@@ -169,6 +173,7 @@
     private final ProjectCache projectCache;
     private final PermissionBackend permissionBackend;
     private final GitRepositoryManager repositoryManager;
+    private final PluginConfigFactory pluginConfigFactory;
     private final PatchListCache patchListCache;
     private final PatchSetInfoFactory patchSetInfoFactory;
     private final IdentifiedUser.GenericFactory userFactory;
@@ -176,26 +181,31 @@
     private final int reductionLimit;
     private final int compileLimit;
     private final PatchSetUtil patchsetUtil;
+    private Emails emails;
 
     @Inject
     Args(
         ProjectCache projectCache,
         PermissionBackend permissionBackend,
         GitRepositoryManager repositoryManager,
+        PluginConfigFactory pluginConfigFactory,
         PatchListCache patchListCache,
         PatchSetInfoFactory patchSetInfoFactory,
         IdentifiedUser.GenericFactory userFactory,
         Provider<AnonymousUser> anonymousUser,
         @GerritServerConfig Config config,
-        PatchSetUtil patchsetUtil) {
+        PatchSetUtil patchsetUtil,
+        Emails emails) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.repositoryManager = repositoryManager;
+      this.pluginConfigFactory = pluginConfigFactory;
       this.patchListCache = patchListCache;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.userFactory = userFactory;
       this.anonymousUser = anonymousUser;
       this.patchsetUtil = patchsetUtil;
+      this.emails = emails;
 
       int limit = config.getInt("rules", null, "reductionLimit", 100000);
       reductionLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
@@ -207,6 +217,8 @@
               "compileReductionLimit",
               (int) Math.min(10L * limit, Integer.MAX_VALUE));
       compileLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
+
+      logger.atInfo().log("reductionLimit: %d, compileLimit: %d", reductionLimit, compileLimit);
     }
 
     private int reductionLimit(Predicate goal) {
@@ -228,6 +240,10 @@
       return repositoryManager;
     }
 
+    public PluginConfigFactory getPluginConfigFactory() {
+      return pluginConfigFactory;
+    }
+
     public PatchListCache getPatchListCache() {
       return patchListCache;
     }
@@ -247,5 +263,9 @@
     public PatchSetUtil getPatchsetUtil() {
       return patchsetUtil;
     }
+
+    public Emails getEmails() {
+      return emails;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/rules/PrologRule.java b/java/com/google/gerrit/server/rules/PrologRule.java
index deddc36..0c54f40 100644
--- a/java/com/google/gerrit/server/rules/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/PrologRule.java
@@ -39,8 +39,8 @@
   @Override
   public Collection<SubmitRecord> evaluate(ChangeData cd, SubmitRuleOptions opts) {
     ProjectState projectState = projectCache.get(cd.project());
-    // We only want to run the prolog engine if we have at least one rules.pl file to use.
-    if (projectState == null || !projectState.hasPrologRules()) {
+    // We only want to run the Prolog engine if we have at least one rules.pl file to use.
+    if ((projectState == null || !projectState.hasPrologRules()) && opts.rule() == null) {
       return Collections.emptyList();
     }
 
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 8e39b50..c036c86 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -36,7 +37,6 @@
 import com.google.gerrit.server.project.RuleEvalException;
 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.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
@@ -149,17 +149,17 @@
     try {
       change = cd.change();
       if (change == null) {
-        throw new OrmException("No change found");
+        throw new StorageException("No change found");
       }
 
       if (projectState == null) {
         throw new NoSuchProjectException(cd.project());
       }
-    } catch (OrmException | NoSuchProjectException e) {
+    } catch (StorageException | NoSuchProjectException e) {
       return ruleError("Error looking up change " + cd.getId(), e);
     }
 
-    if (!opts.allowClosed() && change.getStatus().isClosed()) {
+    if (!opts.allowClosed() && change.isClosed()) {
       SubmitRecord rec = new SubmitRecord();
       rec.status = SubmitRecord.Status.CLOSED;
       return Collections.singletonList(rec);
@@ -297,7 +297,8 @@
     try {
       return LabelType.checkName(name);
     } catch (IllegalArgumentException e) {
-      return LabelType.checkName("Invalid-Prolog-Rules-Label-Name--" + sanitizeLabelName(name));
+      String newName = "Invalid-Prolog-Rules-Label-Name-" + sanitizeLabelName(name);
+      return LabelType.checkName(newName.replace("--", "-"));
     }
   }
 
@@ -486,7 +487,6 @@
     env.set(StoredValues.ACCOUNTS, accounts);
     env.set(StoredValues.ACCOUNT_CACHE, accountCache);
     env.set(StoredValues.EMAILS, emails);
-    env.set(StoredValues.REVIEW_DB, cd.db());
     env.set(StoredValues.CHANGE_DATA, cd);
     env.set(StoredValues.PROJECT_STATE, projectState);
     return env;
@@ -540,7 +540,7 @@
     if (status instanceof StructureTerm && status.arity() == 1) {
       Term who = status.arg(0);
       if (isUser(who)) {
-        label.appliedBy = new Account.Id(((IntegerTerm) who.arg(0)).intValue());
+        label.appliedBy = Account.id(((IntegerTerm) who.arg(0)).intValue());
       } else {
         throw new UserTermExpected(label);
       }
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index c26975b..d4e90f9 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -19,13 +19,13 @@
 import com.google.common.base.Joiner;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -91,7 +91,7 @@
   private final Path cacheDir;
   private final Path rulesDir;
   private final GitRepositoryManager gitMgr;
-  private final DynamicSet<PredicateProvider> predicateProviders;
+  private final PluginSetContext<PredicateProvider> predicateProviders;
   private final ClassLoader systemLoader;
   private final PrologMachineCopy defaultMachine;
   private final Cache<ObjectId, PrologMachineCopy> machineCache;
@@ -101,7 +101,7 @@
       @GerritServerConfig Config config,
       SitePaths site,
       GitRepositoryManager gm,
-      DynamicSet<PredicateProvider> predicateProviders,
+      PluginSetContext<PredicateProvider> predicateProviders,
       @Named(CACHE_NAME) Cache<ObjectId, PrologMachineCopy> machineCache) {
     maxDbSize = config.getInt("rules", null, "maxPrologDatabaseSize", 256);
     maxSrcBytes = config.getInt("rules", null, "maxSourceBytes", 128 << 10);
@@ -258,9 +258,8 @@
 
     List<String> packages = new ArrayList<>();
     packages.addAll(PACKAGE_LIST);
-    for (PredicateProvider predicateProvider : predicateProviders) {
-      packages.addAll(predicateProvider.getPackages());
-    }
+    predicateProviders.runEach(
+        predicateProvider -> packages.addAll(predicateProvider.getPackages()));
 
     // Bootstrap the interpreter and ensure there is clean state.
     ctl.initialize(packages.toArray(new String[packages.size()]));
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 6770732..40f0ff5 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -16,43 +16,39 @@
 
 import static com.google.gerrit.server.rules.StoredValue.create;
 
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 public final class StoredValues {
   public static final StoredValue<Accounts> ACCOUNTS = create(Accounts.class);
   public static final StoredValue<AccountCache> ACCOUNT_CACHE = create(AccountCache.class);
   public static final StoredValue<Emails> EMAILS = create(Emails.class);
-  public static final StoredValue<ReviewDb> REVIEW_DB = create(ReviewDb.class);
   public static final StoredValue<ChangeData> CHANGE_DATA = create(ChangeData.class);
   public static final StoredValue<ProjectState> PROJECT_STATE = create(ProjectState.class);
 
@@ -60,7 +56,7 @@
     ChangeData cd = CHANGE_DATA.get(engine);
     try {
       return cd.change();
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       throw new SystemException("Cannot load change " + cd.getId());
     }
   }
@@ -69,37 +65,21 @@
     ChangeData cd = CHANGE_DATA.get(engine);
     try {
       return cd.currentPatchSet();
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       throw new SystemException(e.getMessage());
     }
   }
 
-  public static final StoredValue<PatchSetInfo> PATCH_SET_INFO =
-      new StoredValue<PatchSetInfo>() {
+  public static final StoredValue<RevCommit> COMMIT =
+      new StoredValue<RevCommit>() {
         @Override
-        public PatchSetInfo createValue(Prolog engine) {
-          Change change = getChange(engine);
-          PatchSet ps = getPatchSet(engine);
-          PrologEnvironment env = (PrologEnvironment) engine.control;
-          PatchSetInfoFactory patchInfoFactory = env.getArgs().getPatchSetInfoFactory();
-          try {
-            return patchInfoFactory.get(change.getProject(), ps);
-          } catch (PatchSetInfoNotAvailableException e) {
-            throw new SystemException(e.getMessage());
-          }
-        }
-      };
-
-  public static final StoredValue<String> COMMIT_MESSAGE =
-      new StoredValue<String>() {
-        @Override
-        public String createValue(Prolog engine) {
+        public RevCommit createValue(Prolog engine) {
           Change change = getChange(engine);
           PatchSet ps = getPatchSet(engine);
           PrologEnvironment env = (PrologEnvironment) engine.control;
           PatchSetUtil patchSetUtil = env.getArgs().getPatchsetUtil();
           try {
-            return patchSetUtil.getFullCommitMessage(change.getProject(), ps);
+            return patchSetUtil.getRevCommit(change.getProject(), ps);
           } catch (IOException e) {
             throw new SystemException(e.getMessage());
           }
@@ -115,9 +95,8 @@
           PatchListCache plCache = env.getArgs().getPatchListCache();
           Change change = getChange(engine);
           Project.NameKey project = change.getProject();
-          ObjectId b = ObjectId.fromString(ps.getRevision().get());
           Whitespace ws = Whitespace.IGNORE_NONE;
-          PatchListKey plKey = PatchListKey.againstDefaultBase(b, ws);
+          PatchListKey plKey = PatchListKey.againstDefaultBase(ps.commitId(), ws);
           PatchList patchList;
           try {
             patchList = plCache.get(plKey, project);
@@ -128,6 +107,27 @@
         }
       };
 
+  // Accessing GitRepositoryManager could be slow.
+  // It should be minimized or cached to reduce pause time
+  // when evaluating Prolog submit rules.
+  public static final StoredValue<GitRepositoryManager> REPO_MANAGER =
+      new StoredValue<GitRepositoryManager>() {
+        @Override
+        public GitRepositoryManager createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          return env.getArgs().getGitRepositoryManager();
+        }
+      };
+
+  public static final StoredValue<PluginConfigFactory> PLUGIN_CONFIG_FACTORY =
+      new StoredValue<PluginConfigFactory>() {
+        @Override
+        public PluginConfigFactory createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          return env.getArgs().getPluginConfigFactory();
+        }
+      };
+
   public static final StoredValue<Repository> REPOSITORY =
       new StoredValue<Repository>() {
         @Override
diff --git a/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java b/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java
deleted file mode 100644
index 17eb56e..0000000
--- a/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java
+++ /dev/null
@@ -1,141 +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.server.schema;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.util.concurrent.Futures;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gwtorm.client.Key;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import java.util.Map;
-import java.util.function.Function;
-
-abstract class AbstractDisabledAccess<T, K extends Key<?>> implements Access<T, K> {
-  private static <T> ResultSet<T> empty() {
-    return new ListResultSet<>(ImmutableList.of());
-  }
-
-  @SuppressWarnings("deprecation")
-  private static <T>
-      com.google.common.util.concurrent.CheckedFuture<T, OrmException> emptyFuture() {
-    return Futures.immediateCheckedFuture(null);
-  }
-
-  // Don't even hold a reference to delegate, so it's not possible to use it
-  // accidentally.
-  private final ReviewDbWrapper wrapper;
-  private final String relationName;
-  private final int relationId;
-  private final Function<T, K> primaryKey;
-  private final Function<Iterable<T>, Map<K, T>> toMap;
-
-  AbstractDisabledAccess(ReviewDbWrapper wrapper, Access<T, K> delegate) {
-    this.wrapper = wrapper;
-    this.relationName = delegate.getRelationName();
-    this.relationId = delegate.getRelationID();
-    this.primaryKey = delegate::primaryKey;
-    this.toMap = delegate::toMap;
-  }
-
-  @Override
-  public final int getRelationID() {
-    return relationId;
-  }
-
-  @Override
-  public final String getRelationName() {
-    return relationName;
-  }
-
-  @Override
-  public final K primaryKey(T entity) {
-    return primaryKey.apply(entity);
-  }
-
-  @Override
-  public final Map<K, T> toMap(Iterable<T> iterable) {
-    return toMap.apply(iterable);
-  }
-
-  @Override
-  public final ResultSet<T> iterateAllEntities() {
-    return empty();
-  }
-
-  @SuppressWarnings("deprecation")
-  @Override
-  public final com.google.common.util.concurrent.CheckedFuture<T, OrmException> getAsync(K key) {
-    return emptyFuture();
-  }
-
-  @Override
-  public final ResultSet<T> get(Iterable<K> keys) {
-    return empty();
-  }
-
-  @Override
-  public final void insert(Iterable<T> instances) {
-    // Do nothing.
-  }
-
-  @Override
-  public final void update(Iterable<T> instances) {
-    // Do nothing.
-  }
-
-  @Override
-  public final void upsert(Iterable<T> instances) {
-    // Do nothing.
-  }
-
-  @Override
-  public final void deleteKeys(Iterable<K> keys) {
-    // Do nothing.
-  }
-
-  @Override
-  public final void delete(Iterable<T> instances) {
-    // Do nothing.
-  }
-
-  @Override
-  public final void beginTransaction(K key) {
-    // Keep track of when we've started a transaction so that we can avoid calling commit/rollback
-    // on the underlying ReviewDb. This is just a simple arm's-length approach, and may produce
-    // slightly different results from a native ReviewDb in corner cases like:
-    // * beginning transactions on different tables simultaneously
-    // * doing work between commit and rollback
-    // These kinds of things are already misuses of ReviewDb, and shouldn't be happening in current
-    // code anyway.
-    checkState(!wrapper.inTransaction(), "already in transaction");
-    wrapper.beginTransaction();
-  }
-
-  @Override
-  public final T atomicUpdate(K key, AtomicUpdate<T> update) {
-    return null;
-  }
-
-  @Override
-  public final T get(K id) {
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index a91d5e6..9446b7c 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -22,33 +22,24 @@
 import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.server.schema.AclUtil.rule;
 
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -66,68 +57,42 @@
 
 /** 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 NoteDbSchemaVersionManager versionManager;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final GroupReference anonymous;
   private final GroupReference registered;
   private final GroupReference owners;
 
   @Inject
   AllProjectsCreator(
-      GitRepositoryManager mgr,
+      GitRepositoryManager repositoryManager,
       AllProjectsName allProjectsName,
-      SystemGroupBackend systemGroupBackend,
       @GerritPersonIdent PersonIdent serverUser,
-      NotesMigration notesMigration) {
-    this.mgr = mgr;
+      NoteDbSchemaVersionManager versionManager,
+      SystemGroupBackend systemGroupBackend,
+      ProjectConfig.Factory projectConfigFactory) {
+    this.repositoryManager = repositoryManager;
     this.allProjectsName = allProjectsName;
     this.serverUser = serverUser;
-    this.notesMigration = notesMigration;
+    this.versionManager = versionManager;
+    this.projectConfigFactory = projectConfigFactory;
 
     this.anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS);
     this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
     this.owners = systemGroupBackend.getGroup(PROJECT_OWNERS);
   }
 
-  /** If called, grant default permissions to this admin group */
-  public AllProjectsCreator setAdministrators(GroupReference admin) {
-    this.admin = admin;
-    return this;
-  }
-
-  /** If called, grant stream-events permission and set appropriate priority for this group */
-  public AllProjectsCreator setBatchUsers(GroupReference batch) {
-    this.batch = batch;
-    return this;
-  }
-
-  public AllProjectsCreator setCommitMessage(String message) {
-    this.message = message;
-    return this;
-  }
-
-  public AllProjectsCreator setFirstChangeIdForNoteDb(int id) {
-    checkArgument(id > 0, "id must be positive: %s", id);
-    firstChangeId = id;
-    return this;
-  }
-
-  public void create() throws IOException, ConfigInvalidException {
-    try (Repository git = mgr.openRepository(allProjectsName)) {
-      initAllProjects(git);
+  public void create(AllProjectsInput input) throws IOException, ConfigInvalidException {
+    try (Repository git = repositoryManager.openRepository(allProjectsName)) {
+      initAllProjects(git, input);
     } 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)) {
-        initAllProjects(git);
+      try (Repository git = repositoryManager.createRepository(allProjectsName)) {
+        initAllProjects(git, input);
         RefUpdate u = git.updateRef(Constants.HEAD);
         u.link(RefNames.REFS_CONFIG);
       } catch (RepositoryNotFoundException err) {
@@ -137,98 +102,131 @@
     }
   }
 
-  private void initAllProjects(Repository git) throws IOException, ConfigInvalidException {
+  private void initAllProjects(Repository git, AllProjectsInput input)
+      throws ConfigInvalidException, IOException {
     BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
     try (MetaDataUpdate md =
         new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git, bru)) {
       md.getCommitBuilder().setAuthor(serverUser);
       md.getCommitBuilder().setCommitter(serverUser);
       md.setMessage(
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(message),
-              "Initialized Gerrit Code Review " + Version.getVersion()));
+          input.commitMessage().isPresent()
+              ? input.commitMessage().get()
+              : "Initialized Gerrit Code Review " + Version.getVersion());
 
-      ProjectConfig config = ProjectConfig.read(md);
+      // init basic project configs.
+      ProjectConfig config = projectConfigFactory.read(md);
       Project p = config.getProject();
-      p.setDescription("Access inherited by all other projects.");
-      p.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.TRUE);
-      p.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, InheritableBoolean.TRUE);
-      p.setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, InheritableBoolean.FALSE);
-      p.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, InheritableBoolean.FALSE);
-      p.setBooleanConfig(BooleanProjectConfig.ENABLE_SIGNED_PUSH, InheritableBoolean.FALSE);
+      p.setDescription(
+          input.projectDescription().orElse("Access inherited by all other projects."));
 
-      AccessSection cap = config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true);
-      AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-      AccessSection heads = config.getAccessSection(AccessSection.HEADS, true);
-      AccessSection tags = config.getAccessSection("refs/tags/*", true);
-      AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
-      AccessSection refsFor = config.getAccessSection("refs/for/*", true);
-      AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
+      // init boolean project configs.
+      input.booleanProjectConfigs().forEach(p::setBooleanConfig);
 
-      grant(config, cap, GlobalCapability.ADMINISTRATE_SERVER, admin);
-      grant(config, all, Permission.READ, admin, anonymous);
-      grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+      // init labels.
+      input
+          .codeReviewLabel()
+          .ifPresent(
+              codeReviewLabel ->
+                  config.getLabelSections().put(codeReviewLabel.getName(), codeReviewLabel));
 
-      if (batch != null) {
-        Permission priority = cap.getPermission(GlobalCapability.PRIORITY, true);
-        PermissionRule r = rule(config, batch);
-        r.setAction(Action.BATCH);
-        priority.add(r);
-
-        Permission stream = cap.getPermission(GlobalCapability.STREAM_EVENTS, true);
-        stream.add(rule(config, batch));
+      if (input.initDefaultAcls()) {
+        // init access sections.
+        initDefaultAcls(config, input);
       }
 
-      LabelType cr = initCodeReviewLabel(config);
-      grant(config, heads, cr, -1, 1, registered);
-
-      grant(config, heads, cr, -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);
-      grant(config, heads, Permission.FORGE_AUTHOR, registered);
-      grant(config, heads, Permission.FORGE_COMMITTER, admin, owners);
-      grant(config, heads, Permission.EDIT_TOPIC_NAME, true, admin, owners);
-
-      grant(config, tags, Permission.CREATE, admin, owners);
-      grant(config, tags, Permission.CREATE_TAG, admin, owners);
-      grant(config, tags, Permission.CREATE_SIGNED_TAG, admin, owners);
-
-      grant(config, magic, Permission.PUSH, registered);
-      grant(config, magic, Permission.PUSH_MERGE, registered);
-
-      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, Permission.CREATE, admin, owners);
-      grant(config, meta, Permission.PUSH, admin, owners);
-      grant(config, meta, Permission.SUBMIT, admin, owners);
-
+      // commit all the above configs as a commit in "refs/meta/config" branch of the All-Projects.
       config.commitToNewRef(md, RefNames.REFS_CONFIG);
-      initSequences(git, bru);
+
+      // init sequence number.
+      initSequences(git, bru, input.firstChangeIdForNoteDb());
+
+      // init schema
+      versionManager.init();
+
       execute(git, bru);
     }
   }
 
-  public static LabelType initCodeReviewLabel(ProjectConfig c) {
-    LabelType type =
-        new LabelType(
-            "Code-Review",
-            ImmutableList.of(
-                new LabelValue((short) 2, "Looks good to me, approved"),
-                new LabelValue((short) 1, "Looks good to me, but someone else must approve"),
-                new LabelValue((short) 0, "No score"),
-                new LabelValue((short) -1, "I would prefer this is not merged as is"),
-                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 initDefaultAcls(ProjectConfig config, AllProjectsInput input) {
+    AccessSection capabilities = config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true);
+    AccessSection heads = config.getAccessSection(AccessSection.HEADS, true);
+
+    checkArgument(input.codeReviewLabel().isPresent());
+    LabelType codeReviewLabel = input.codeReviewLabel().get();
+
+    initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+
+    input
+        .batchUsersGroup()
+        .ifPresent(
+            batchUsersGroup -> initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
+
+    input
+        .administratorsGroup()
+        .ifPresent(
+            adminsGroup ->
+                initDefaultAclsForAdmins(
+                    capabilities, config, heads, codeReviewLabel, adminsGroup));
   }
 
-  private void initSequences(Repository git, BatchRefUpdate bru) throws IOException {
-    if (notesMigration.readChangeSequence()
-        && git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
+  private void initDefaultAclsForRegisteredUsers(
+      AccessSection heads, LabelType codeReviewLabel, ProjectConfig config) {
+    AccessSection refsFor = config.getAccessSection("refs/for/*", true);
+    AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
+
+    grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+    grant(config, heads, codeReviewLabel, -1, 1, registered);
+    grant(config, heads, Permission.FORGE_AUTHOR, registered);
+    grant(config, magic, Permission.PUSH, registered);
+    grant(config, magic, Permission.PUSH_MERGE, registered);
+  }
+
+  private void initDefaultAclsForBatchUsers(
+      AccessSection capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
+    Permission priority = capabilities.getPermission(GlobalCapability.PRIORITY, true);
+    PermissionRule r = rule(config, batchUsersGroup);
+    r.setAction(Action.BATCH);
+    priority.add(r);
+
+    Permission stream = capabilities.getPermission(GlobalCapability.STREAM_EVENTS, true);
+    stream.add(rule(config, batchUsersGroup));
+  }
+
+  private void initDefaultAclsForAdmins(
+      AccessSection capabilities,
+      ProjectConfig config,
+      AccessSection heads,
+      LabelType codeReviewLabel,
+      GroupReference adminsGroup) {
+    AccessSection all = config.getAccessSection(AccessSection.ALL, true);
+    AccessSection tags = config.getAccessSection("refs/tags/*", true);
+    AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
+
+    grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
+    grant(config, all, Permission.READ, adminsGroup, anonymous);
+    grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
+    grant(config, heads, Permission.CREATE, adminsGroup, owners);
+    grant(config, heads, Permission.PUSH, adminsGroup, owners);
+    grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
+    grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
+    grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+
+    grant(config, tags, Permission.CREATE, adminsGroup, owners);
+    grant(config, tags, Permission.CREATE_TAG, adminsGroup, owners);
+    grant(config, tags, Permission.CREATE_SIGNED_TAG, adminsGroup, owners);
+
+    meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
+    grant(config, meta, Permission.READ, adminsGroup, owners);
+    grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
+    grant(config, meta, Permission.CREATE, adminsGroup, owners);
+    grant(config, meta, Permission.PUSH, adminsGroup, owners);
+    grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
+  }
+
+  private void initSequences(Repository git, BatchRefUpdate bru, int firstChangeId)
+      throws IOException {
+    if (git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
       // Can't easily reuse the inserter from MetaDataUpdate, but this shouldn't slow down site
       // initialization unduly.
       try (ObjectInserter ins = git.newObjectInserter()) {
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
new file mode 100644
index 0000000..7231b18
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.server.notedb.Sequences;
+import java.util.Optional;
+
+@AutoValue
+public abstract class AllProjectsInput {
+
+  /** Default boolean configs set when initializing All-Projects. */
+  public static final ImmutableMap<BooleanProjectConfig, InheritableBoolean>
+      DEFAULT_BOOLEAN_PROJECT_CONFIGS =
+          ImmutableMap.of(
+              BooleanProjectConfig.REQUIRE_CHANGE_ID,
+              InheritableBoolean.TRUE,
+              BooleanProjectConfig.USE_CONTENT_MERGE,
+              InheritableBoolean.TRUE,
+              BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS,
+              InheritableBoolean.FALSE,
+              BooleanProjectConfig.USE_SIGNED_OFF_BY,
+              InheritableBoolean.FALSE,
+              BooleanProjectConfig.ENABLE_SIGNED_PUSH,
+              InheritableBoolean.FALSE);
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public static LabelType getDefaultCodeReviewLabel() {
+    LabelType type =
+        new LabelType(
+            "Code-Review",
+            ImmutableList.of(
+                new LabelValue((short) 2, "Looks good to me, approved"),
+                new LabelValue((short) 1, "Looks good to me, but someone else must approve"),
+                new LabelValue((short) 0, "No score"),
+                new LabelValue((short) -1, "I would prefer this is not merged as is"),
+                new LabelValue((short) -2, "This shall not be merged")));
+    type.setCopyMinScore(true);
+    type.setCopyAllScoresOnTrivialRebase(true);
+    return type;
+  }
+
+  /** The administrator group which gets default permissions granted. */
+  public abstract Optional<GroupReference> administratorsGroup();
+
+  /** The group which gets stream-events permission granted and appropriate properties set. */
+  public abstract Optional<GroupReference> batchUsersGroup();
+
+  /** The commit message used when commit the project config change. */
+  public abstract Optional<String> commitMessage();
+
+  /** The first change-id used in this host. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public abstract int firstChangeIdForNoteDb();
+
+  /** The "Code-Review" label to be defined in All-Projects. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public abstract Optional<LabelType> codeReviewLabel();
+
+  /** Description for the All-Projects. */
+  public abstract Optional<String> projectDescription();
+
+  /** Boolean project configs to be set in All-Projects. */
+  public abstract ImmutableMap<BooleanProjectConfig, InheritableBoolean> booleanProjectConfigs();
+
+  /** Whether initializing default access sections in All-Projects. */
+  public abstract boolean initDefaultAcls();
+
+  public abstract Builder toBuilder();
+
+  public static Builder builder() {
+    Builder builder =
+        new AutoValue_AllProjectsInput.Builder()
+            .codeReviewLabel(getDefaultCodeReviewLabel())
+            .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
+            .initDefaultAcls(true);
+    DEFAULT_BOOLEAN_PROJECT_CONFIGS.forEach(builder::addBooleanProjectConfig);
+
+    return builder;
+  }
+
+  public static Builder builderWithNoDefault() {
+    return new AutoValue_AllProjectsInput.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder administratorsGroup(GroupReference adminGroup);
+
+    public abstract Builder batchUsersGroup(GroupReference batchGroup);
+
+    public abstract Builder commitMessage(String commitMessage);
+
+    public abstract Builder firstChangeIdForNoteDb(int firstChangeId);
+
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public abstract Builder codeReviewLabel(LabelType codeReviewLabel);
+
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public abstract Builder projectDescription(String projectDescription);
+
+    public abstract ImmutableMap.Builder<BooleanProjectConfig, InheritableBoolean>
+        booleanProjectConfigsBuilder();
+
+    public Builder addBooleanProjectConfig(
+        BooleanProjectConfig booleanProjectConfig, InheritableBoolean inheritableBoolean) {
+      booleanProjectConfigsBuilder().put(booleanProjectConfig, inheritableBoolean);
+      return this;
+    }
+
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public abstract Builder initDefaultAcls(boolean initDefaultACLs);
+
+    public abstract AllProjectsInput build();
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 90002f6..d2f5ef1 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -14,10 +14,13 @@
 
 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.AllProjectsInput.getDefaultCodeReviewLabel;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GroupReference;
@@ -32,6 +35,7 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectConfig.Factory;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -47,20 +51,25 @@
   private final GitRepositoryManager mgr;
   private final AllUsersName allUsersName;
   private final PersonIdent serverUser;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final GroupReference registered;
 
   @Nullable private GroupReference admin;
+  private LabelType codeReviewLabel;
 
   @Inject
   AllUsersCreator(
       GitRepositoryManager mgr,
       AllUsersName allUsersName,
       SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
+      @GerritPersonIdent PersonIdent serverUser,
+      Factory projectConfigFactory) {
     this.mgr = mgr;
     this.allUsersName = allUsersName;
     this.serverUser = serverUser;
     this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
+    this.projectConfigFactory = projectConfigFactory;
+    this.codeReviewLabel = getDefaultCodeReviewLabel();
   }
 
   /**
@@ -72,6 +81,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);
@@ -93,18 +111,21 @@
       md.getCommitBuilder().setCommitter(serverUser);
       md.setMessage("Initialized Gerrit Code Review " + Version.getVersion());
 
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       Project project = config.getProject();
       project.setDescription("Individual user settings and preferences.");
 
       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/BUILD b/java/com/google/gerrit/server/schema/BUILD
index 44bede9..aa552ed 100644
--- a/java/com/google/gerrit/server/schema/BUILD
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -7,14 +7,17 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//java/org/eclipse/jgit:server",
+        "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:dbcp",
diff --git a/java/com/google/gerrit/server/schema/BaseDataSourceType.java b/java/com/google/gerrit/server/schema/BaseDataSourceType.java
deleted file mode 100644
index 4b3a570..0000000
--- a/java/com/google/gerrit/server/schema/BaseDataSourceType.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import java.io.IOException;
-import java.io.InputStream;
-
-public abstract class BaseDataSourceType implements DataSourceType {
-
-  private static final String DEFAULT_VALIDATION_QUERY = "select 1";
-  private final String driver;
-
-  protected BaseDataSourceType(String driver) {
-    this.driver = driver;
-  }
-
-  @Override
-  public final String getDriver() {
-    return driver;
-  }
-
-  @Override
-  public boolean usePool() {
-    return true;
-  }
-
-  @Override
-  public String getValidationQuery() {
-    return DEFAULT_VALIDATION_QUERY;
-  }
-
-  @Override
-  public ScriptRunner getIndexScript() throws IOException {
-    return getScriptRunner("index_generic.sql");
-  }
-
-  protected static final ScriptRunner getScriptRunner(String path) throws IOException {
-    if (path == null) {
-      return ScriptRunner.NOOP;
-    }
-    ScriptRunner runner;
-    try (InputStream in = ReviewDb.class.getResourceAsStream(path)) {
-      if (in == null) {
-        throw new IllegalStateException("SQL script " + path + " not found");
-      }
-      runner = new ScriptRunner(path, in);
-    }
-    return runner;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/DB2.java b/java/com/google/gerrit/server/schema/DB2.java
deleted file mode 100644
index fcf8c1f..0000000
--- a/java/com/google/gerrit/server/schema/DB2.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.schema.JdbcUtil.hostname;
-import static com.google.gerrit.server.schema.JdbcUtil.port;
-
-import com.google.gerrit.server.config.ConfigSection;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.Config;
-
-public class DB2 extends BaseDataSourceType {
-  private Config cfg;
-
-  @Inject
-  public DB2(@GerritServerConfig Config cfg) {
-    super("com.ibm.db2.jcc.DB2Driver");
-    this.cfg = cfg;
-  }
-
-  @Override
-  public String getUrl() {
-    final StringBuilder b = new StringBuilder();
-    final ConfigSection dbc = new ConfigSection(cfg, "database");
-    b.append("jdbc:db2://");
-    b.append(hostname(dbc.optional("hostname")));
-    b.append(port(dbc.optional("port")));
-    b.append("/");
-    b.append(dbc.required("database"));
-    return b.toString();
-  }
-
-  @Override
-  public String getValidationQuery() {
-    return "SELECT 1 FROM SYSIBM.SYSDUMMY1";
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/DataSourceModule.java b/java/com/google/gerrit/server/schema/DataSourceModule.java
deleted file mode 100644
index ee57c8b..0000000
--- a/java/com/google/gerrit/server/schema/DataSourceModule.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.AbstractModule;
-import com.google.inject.name.Names;
-
-public class DataSourceModule extends AbstractModule {
-
-  @Override
-  protected void configure() {
-    bind(DataSourceType.class).annotatedWith(Names.named("db2")).to(DB2.class);
-    bind(DataSourceType.class).annotatedWith(Names.named("derby")).to(Derby.class);
-    bind(DataSourceType.class).annotatedWith(Names.named("h2")).to(H2.class);
-    bind(DataSourceType.class).annotatedWith(Names.named("jdbc")).to(JDBC.class);
-    bind(DataSourceType.class).annotatedWith(Names.named("mariadb")).to(MariaDb.class);
-    bind(DataSourceType.class).annotatedWith(Names.named("mysql")).to(MySql.class);
-    bind(DataSourceType.class).annotatedWith(Names.named("oracle")).to(Oracle.class);
-    bind(DataSourceType.class).annotatedWith(Names.named("postgresql")).to(PostgreSQL.class);
-    /*
-     * DatabaseMetaData.getDatabaseProductName() returns "sap db" for MaxDB.
-     * For auto-detection of the DB type (com.google.gerrit.pgm.util.SiteProgram#getDbType)
-     * we have to map "sap db" additionally to "maxdb", which is used for explicit configuration.
-     */
-    bind(DataSourceType.class).annotatedWith(Names.named("maxdb")).to(MaxDb.class);
-    bind(DataSourceType.class).annotatedWith(Names.named("sap db")).to(MaxDb.class);
-    bind(DataSourceType.class).annotatedWith(Names.named("hana")).to(HANA.class);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/DataSourceProvider.java b/java/com/google/gerrit/server/schema/DataSourceProvider.java
deleted file mode 100644
index 98acc64..0000000
--- a/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.extensions.persistence.DataSourceInterceptor;
-import com.google.gerrit.metrics.CallbackMetric1;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.server.config.ConfigSection;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gwtorm.jdbc.SimpleDataSource;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import java.lang.reflect.Constructor;
-import java.lang.reflect.InvocationTargetException;
-import java.sql.SQLException;
-import java.util.Properties;
-import javax.sql.DataSource;
-import org.apache.commons.dbcp.BasicDataSource;
-import org.eclipse.jgit.lib.Config;
-
-/** Provides access to the DataSource. */
-@Singleton
-public class DataSourceProvider implements Provider<DataSource>, LifecycleListener {
-  private final Config cfg;
-  private final MetricMaker metrics;
-  private final Context ctx;
-  private final DataSourceType dst;
-  private final ThreadSettingsConfig threadSettingsConfig;
-  private DataSource ds;
-
-  @Inject
-  protected DataSourceProvider(
-      @GerritServerConfig Config cfg,
-      MetricMaker metrics,
-      ThreadSettingsConfig threadSettingsConfig,
-      Context ctx,
-      DataSourceType dst) {
-    this.cfg = cfg;
-    this.metrics = metrics;
-    this.threadSettingsConfig = threadSettingsConfig;
-    this.ctx = ctx;
-    this.dst = dst;
-  }
-
-  @Override
-  public synchronized DataSource get() {
-    if (ds == null) {
-      ds = open(cfg, ctx, dst);
-    }
-    return ds;
-  }
-
-  @Override
-  public void start() {}
-
-  @Override
-  public synchronized void stop() {
-    if (ds instanceof BasicDataSource) {
-      try {
-        ((BasicDataSource) ds).close();
-      } catch (SQLException e) {
-        // Ignore the close failure.
-      }
-    }
-  }
-
-  public enum Context {
-    SINGLE_USER,
-    MULTI_USER
-  }
-
-  private DataSource open(Config cfg, Context context, DataSourceType dst) {
-    ConfigSection dbs = new ConfigSection(cfg, "database");
-    String driver = dbs.optional("driver");
-    if (Strings.isNullOrEmpty(driver)) {
-      driver = dst.getDriver();
-    }
-
-    String url = dbs.optional("url");
-    if (Strings.isNullOrEmpty(url)) {
-      url = dst.getUrl();
-    }
-
-    String username = dbs.optional("username");
-    String password = dbs.optional("password");
-    String interceptor = dbs.optional("dataSourceInterceptorClass");
-
-    boolean usePool;
-    if (context == Context.SINGLE_USER) {
-      usePool = false;
-    } else {
-      usePool = cfg.getBoolean("database", "connectionpool", dst.usePool());
-    }
-
-    if (usePool) {
-      final BasicDataSource ds = new BasicDataSource();
-      ds.setDriverClassName(driver);
-      ds.setUrl(url);
-      if (username != null && !username.isEmpty()) {
-        ds.setUsername(username);
-      }
-      if (password != null && !password.isEmpty()) {
-        ds.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(
-          ConfigUtil.getTimeUnit(
-              cfg,
-              "database",
-              null,
-              "poolmaxwait",
-              MILLISECONDS.convert(30, SECONDS),
-              MILLISECONDS));
-      ds.setInitialSize(ds.getMinIdle());
-      long evictIdleTimeMs = 1000L * 60;
-      ds.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
-      ds.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
-      ds.setValidationQuery(dst.getValidationQuery());
-      ds.setValidationQueryTimeout(5);
-      exportPoolMetrics(ds);
-      return intercept(interceptor, ds);
-    }
-    // Don't use the connection pool.
-    //
-    try {
-      final Properties p = new Properties();
-      p.setProperty("driver", driver);
-      p.setProperty("url", url);
-      if (username != null) {
-        p.setProperty("user", username);
-      }
-      if (password != null) {
-        p.setProperty("password", password);
-      }
-      return intercept(interceptor, new SimpleDataSource(p));
-    } catch (SQLException se) {
-      throw new ProvisionException("Database unavailable", se);
-    }
-  }
-
-  private void exportPoolMetrics(BasicDataSource pool) {
-    CallbackMetric1<Boolean, Integer> cnt =
-        metrics.newCallbackMetric(
-            "sql/connection_pool/connections",
-            Integer.class,
-            new Description("SQL database connections").setGauge().setUnit("connections"),
-            Field.ofBoolean("active"));
-    metrics.newTrigger(
-        cnt,
-        () -> {
-          synchronized (pool) {
-            cnt.set(true, pool.getNumActive());
-            cnt.set(false, pool.getNumIdle());
-          }
-        });
-  }
-
-  private DataSource intercept(String interceptor, DataSource ds) {
-    if (interceptor == null) {
-      return ds;
-    }
-    try {
-      Constructor<?> c = Class.forName(interceptor).getConstructor();
-      DataSourceInterceptor datasourceInterceptor = (DataSourceInterceptor) c.newInstance();
-      return datasourceInterceptor.intercept("reviewDb", ds);
-    } catch (ClassNotFoundException
-        | SecurityException
-        | NoSuchMethodException
-        | IllegalArgumentException
-        | InstantiationException
-        | IllegalAccessException
-        | InvocationTargetException e) {
-      throw new ProvisionException("Cannot intercept datasource", e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/DataSourceType.java b/java/com/google/gerrit/server/schema/DataSourceType.java
deleted file mode 100644
index cbdcf0f..0000000
--- a/java/com/google/gerrit/server/schema/DataSourceType.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import java.io.IOException;
-
-/** Abstraction of a supported database platform */
-public interface DataSourceType {
-
-  String getDriver();
-
-  String getUrl();
-
-  String getValidationQuery();
-
-  boolean usePool();
-
-  /**
-   * Return a ScriptRunner that runs the index script. Must not return {@code null}, but may return
-   * a ScriptRunner that does nothing.
-   *
-   * @throws IOException
-   */
-  ScriptRunner getIndexScript() throws IOException;
-}
diff --git a/java/com/google/gerrit/server/schema/DatabaseModule.java b/java/com/google/gerrit/server/schema/DatabaseModule.java
deleted file mode 100644
index 38a7751..0000000
--- a/java/com/google/gerrit/server/schema/DatabaseModule.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.inject.Scopes.SINGLETON;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
-import com.google.gwtorm.jdbc.Database;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Key;
-import com.google.inject.TypeLiteral;
-
-/** Loads the database with standard dependencies. */
-public class DatabaseModule extends FactoryModule {
-  @Override
-  protected void configure() {
-    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
-        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
-    TypeLiteral<Database<ReviewDb>> database = new TypeLiteral<Database<ReviewDb>>() {};
-
-    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
-    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(database).in(SINGLETON);
-    bind(database).toProvider(ReviewDbDatabaseProvider.class);
-    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Derby.java b/java/com/google/gerrit/server/schema/Derby.java
deleted file mode 100644
index 9fb761d..0000000
--- a/java/com/google/gerrit/server/schema/Derby.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.Config;
-
-class Derby extends BaseDataSourceType {
-
-  protected final Config cfg;
-  private final SitePaths site;
-
-  @Inject
-  Derby(@GerritServerConfig Config cfg, SitePaths site) {
-    super("org.apache.derby.jdbc.EmbeddedDriver");
-    this.cfg = cfg;
-    this.site = site;
-  }
-
-  @Override
-  public String getUrl() {
-    String database = cfg.getString("database", null, "database");
-    if (database == null || database.isEmpty()) {
-      database = "db/ReviewDB";
-    }
-    return "jdbc:derby:" + site.resolve(database).toString() + ";create=true";
-  }
-
-  @Override
-  public String getValidationQuery() {
-    return "values 1";
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/GroupBundle.java b/java/com/google/gerrit/server/schema/GroupBundle.java
deleted file mode 100644
index 58d3435..0000000
--- a/java/com/google/gerrit/server/schema/GroupBundle.java
+++ /dev/null
@@ -1,776 +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.server.schema;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.reviewdb.server.ReviewDbUtil.checkColumns;
-import static java.util.Comparator.naturalOrder;
-import static java.util.Comparator.nullsLast;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Multimaps;
-import com.google.common.collect.Streams;
-import com.google.common.flogger.FluentLogger;
-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.AccountGroupById;
-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.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.db.AuditLogReader;
-import com.google.gerrit.server.group.db.GroupConfig;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.function.Function;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
-
-/**
- * A bundle of all entities rooted at a single {@link AccountGroup} entity.
- *
- * <p>Used primarily during the migration process. Most callers should prefer {@link InternalGroup}
- * instead.
- */
-@AutoValue
-abstract class GroupBundle {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  static {
-    // Initialization-time checks that the column set hasn't changed since the
-    // last time this file was updated.
-    checkColumns(AccountGroup.NameKey.class, 1);
-    checkColumns(AccountGroup.UUID.class, 1);
-    checkColumns(AccountGroup.Id.class, 1);
-    checkColumns(AccountGroup.class, 1, 2, 4, 7, 9, 10, 11);
-
-    checkColumns(AccountGroupById.Key.class, 1, 2);
-    checkColumns(AccountGroupById.class, 1);
-
-    checkColumns(AccountGroupByIdAud.Key.class, 1, 2, 3);
-    checkColumns(AccountGroupByIdAud.class, 1, 2, 3, 4);
-
-    checkColumns(AccountGroupMember.Key.class, 1, 2);
-    checkColumns(AccountGroupMember.class, 1);
-
-    checkColumns(AccountGroupMemberAudit.Key.class, 1, 2, 3);
-    checkColumns(AccountGroupMemberAudit.class, 1, 2, 3, 4);
-  }
-
-  public enum Source {
-    REVIEW_DB("ReviewDb"),
-    NOTE_DB("NoteDb");
-
-    private final String name;
-
-    private Source(String name) {
-      this.name = name;
-    }
-
-    @Override
-    public String toString() {
-      return name;
-    }
-  }
-
-  @Singleton
-  public static class Factory {
-    private final AuditLogReader auditLogReader;
-
-    @Inject
-    Factory(AuditLogReader auditLogReader) {
-      this.auditLogReader = auditLogReader;
-    }
-
-    public GroupBundle fromNoteDb(Repository repo, AccountGroup.UUID uuid)
-        throws ConfigInvalidException, IOException {
-      GroupConfig groupConfig = GroupConfig.loadForGroup(repo, uuid);
-      InternalGroup internalGroup = groupConfig.getLoadedGroup().get();
-      AccountGroup.Id groupId = internalGroup.getId();
-
-      AccountGroup accountGroup =
-          new AccountGroup(
-              internalGroup.getNameKey(),
-              internalGroup.getId(),
-              internalGroup.getGroupUUID(),
-              internalGroup.getCreatedOn());
-      accountGroup.setDescription(internalGroup.getDescription());
-      accountGroup.setOwnerGroupUUID(internalGroup.getOwnerGroupUUID());
-      accountGroup.setVisibleToAll(internalGroup.isVisibleToAll());
-
-      return create(
-          Source.NOTE_DB,
-          accountGroup,
-          internalGroup
-              .getMembers()
-              .stream()
-              .map(
-                  accountId ->
-                      new AccountGroupMember(new AccountGroupMember.Key(accountId, groupId)))
-              .collect(toImmutableSet()),
-          auditLogReader.getMembersAudit(repo, uuid),
-          internalGroup
-              .getSubgroups()
-              .stream()
-              .map(
-                  subgroupUuid ->
-                      new AccountGroupById(new AccountGroupById.Key(groupId, subgroupUuid)))
-              .collect(toImmutableSet()),
-          auditLogReader.getSubgroupsAudit(repo, uuid));
-    }
-
-    public static GroupBundle fromReviewDb(ReviewDb db, AccountGroup.UUID groupUuid)
-        throws OrmException {
-      JdbcSchema jdbcSchema = ReviewDbWrapper.unwrapJbdcSchema(db);
-      AccountGroup group = readAccountGroupFromReviewDb(jdbcSchema, groupUuid);
-      AccountGroup.Id groupId = group.getId();
-
-      return create(
-          Source.REVIEW_DB,
-          group,
-          readAccountGroupMembersFromReviewDb(jdbcSchema, groupId),
-          readAccountGroupMemberAuditsFromReviewDb(jdbcSchema, groupId),
-          readAccountGroupSubgroupsFromReviewDb(jdbcSchema, groupId),
-          readAccountGroupSubgroupAuditsFromReviewDb(jdbcSchema, groupId));
-    }
-
-    private static AccountGroup readAccountGroupFromReviewDb(
-        JdbcSchema jdbcSchema, AccountGroup.UUID groupUuid) throws OrmException {
-      try (Statement stmt = jdbcSchema.getConnection().createStatement();
-          ResultSet rs =
-              stmt.executeQuery(
-                  "SELECT group_id,"
-                      + " name,"
-                      + " created_on,"
-                      + " description,"
-                      + " owner_group_uuid,"
-                      + " visible_to_all"
-                      + " FROM account_groups"
-                      + " WHERE group_uuid = '"
-                      + groupUuid.get()
-                      + "'")) {
-        if (!rs.next()) {
-          throw new OrmException(String.format("Group %s not found", groupUuid));
-        }
-
-        AccountGroup.Id groupId = new AccountGroup.Id(rs.getInt(1));
-        AccountGroup.NameKey groupName = new AccountGroup.NameKey(rs.getString(2));
-        Timestamp createdOn = rs.getTimestamp(3);
-        String description = rs.getString(4);
-        AccountGroup.UUID ownerGroupUuid = new AccountGroup.UUID(rs.getString(5));
-        boolean visibleToAll = "Y".equals(rs.getString(6));
-
-        AccountGroup group = new AccountGroup(groupName, groupId, groupUuid, createdOn);
-        group.setDescription(description);
-        group.setOwnerGroupUUID(ownerGroupUuid);
-        group.setVisibleToAll(visibleToAll);
-
-        if (rs.next()) {
-          throw new OrmException(String.format("Group UUID %s is ambiguous", groupUuid));
-        }
-
-        return group;
-      } catch (SQLException e) {
-        throw new OrmException(
-            String.format("Failed to read account group %s from ReviewDb", groupUuid.get()), e);
-      }
-    }
-
-    private static List<AccountGroupMember> readAccountGroupMembersFromReviewDb(
-        JdbcSchema jdbcSchema, AccountGroup.Id groupId) throws OrmException {
-      try (Statement stmt = jdbcSchema.getConnection().createStatement();
-          ResultSet rs =
-              stmt.executeQuery(
-                  "SELECT account_id"
-                      + " FROM account_group_members"
-                      + " WHERE group_id = '"
-                      + groupId.get()
-                      + "'")) {
-        List<AccountGroupMember> members = new ArrayList<>();
-        while (rs.next()) {
-          Account.Id accountId = new Account.Id(rs.getInt(1));
-          members.add(new AccountGroupMember(new AccountGroupMember.Key(accountId, groupId)));
-        }
-        return members;
-      } catch (SQLException e) {
-        throw new OrmException(
-            String.format(
-                "Failed to read members of account group %s from ReviewDb", groupId.get()),
-            e);
-      }
-    }
-
-    private static List<AccountGroupMemberAudit> readAccountGroupMemberAuditsFromReviewDb(
-        JdbcSchema jdbcSchema, AccountGroup.Id groupId) throws OrmException {
-      try (Statement stmt = jdbcSchema.getConnection().createStatement();
-          ResultSet rs =
-              stmt.executeQuery(
-                  "SELECT account_id, added_by, added_on, removed_by, removed_on"
-                      + " FROM account_group_members_audit"
-                      + " WHERE group_id = '"
-                      + groupId.get()
-                      + "'")) {
-        List<AccountGroupMemberAudit> audits = new ArrayList<>();
-        while (rs.next()) {
-          Account.Id accountId = new Account.Id(rs.getInt(1));
-
-          Account.Id addedBy = new Account.Id(rs.getInt(2));
-          Timestamp addedOn = rs.getTimestamp(3);
-
-          Timestamp removedOn = rs.getTimestamp(5);
-          Account.Id removedBy = removedOn != null ? new Account.Id(rs.getInt(4)) : null;
-
-          AccountGroupMemberAudit.Key key =
-              new AccountGroupMemberAudit.Key(accountId, groupId, addedOn);
-          AccountGroupMemberAudit audit = new AccountGroupMemberAudit(key, addedBy);
-          audit.removed(removedBy, removedOn);
-          audits.add(audit);
-        }
-        return audits;
-      } catch (SQLException e) {
-        throw new OrmException(
-            String.format(
-                "Failed to read member audits of account group %s from ReviewDb", groupId.get()),
-            e);
-      }
-    }
-
-    private static List<AccountGroupById> readAccountGroupSubgroupsFromReviewDb(
-        JdbcSchema jdbcSchema, AccountGroup.Id groupId) throws OrmException {
-      try (Statement stmt = jdbcSchema.getConnection().createStatement();
-          ResultSet rs =
-              stmt.executeQuery(
-                  "SELECT include_uuid"
-                      + " FROM account_group_by_id"
-                      + " WHERE group_id = '"
-                      + groupId.get()
-                      + "'")) {
-        List<AccountGroupById> subgroups = new ArrayList<>();
-        while (rs.next()) {
-          AccountGroup.UUID includedGroupUuid = new AccountGroup.UUID(rs.getString(1));
-          subgroups.add(new AccountGroupById(new AccountGroupById.Key(groupId, includedGroupUuid)));
-        }
-        return subgroups;
-      } catch (SQLException e) {
-        throw new OrmException(
-            String.format(
-                "Failed to read subgroups of account group %s from ReviewDb", groupId.get()),
-            e);
-      }
-    }
-
-    private static List<AccountGroupByIdAud> readAccountGroupSubgroupAuditsFromReviewDb(
-        JdbcSchema jdbcSchema, AccountGroup.Id groupId) throws OrmException {
-      try (Statement stmt = jdbcSchema.getConnection().createStatement();
-          ResultSet rs =
-              stmt.executeQuery(
-                  "SELECT include_uuid, added_by, added_on, removed_by, removed_on"
-                      + " FROM account_group_by_id_aud"
-                      + " WHERE group_id = '"
-                      + groupId.get()
-                      + "'")) {
-        List<AccountGroupByIdAud> audits = new ArrayList<>();
-        while (rs.next()) {
-          AccountGroup.UUID includedGroupUuid = new AccountGroup.UUID(rs.getString(1));
-
-          Account.Id addedBy = new Account.Id(rs.getInt(2));
-          Timestamp addedOn = rs.getTimestamp(3);
-
-          Timestamp removedOn = rs.getTimestamp(5);
-          Account.Id removedBy = removedOn != null ? new Account.Id(rs.getInt(4)) : null;
-
-          AccountGroupByIdAud.Key key =
-              new AccountGroupByIdAud.Key(groupId, includedGroupUuid, addedOn);
-          AccountGroupByIdAud audit = new AccountGroupByIdAud(key, addedBy);
-          audit.removed(removedBy, removedOn);
-          audits.add(audit);
-        }
-        return audits;
-      } catch (SQLException e) {
-        throw new OrmException(
-            String.format(
-                "Failed to read subgroup audits of account group %s from ReviewDb", groupId.get()),
-            e);
-      }
-    }
-  }
-
-  private static final Comparator<AccountGroupMember> ACCOUNT_GROUP_MEMBER_COMPARATOR =
-      Comparator.comparingInt((AccountGroupMember m) -> m.getAccountGroupId().get())
-          .thenComparingInt(m -> m.getAccountId().get());
-
-  private static final Comparator<AccountGroupMemberAudit> ACCOUNT_GROUP_MEMBER_AUDIT_COMPARATOR =
-      Comparator.comparingInt((AccountGroupMemberAudit a) -> a.getGroupId().get())
-          .thenComparing(AccountGroupMemberAudit::getAddedOn)
-          .thenComparingInt(a -> a.getAddedBy().get())
-          .thenComparingInt(a -> a.getMemberId().get())
-          .thenComparing(
-              a -> a.getRemovedBy() != null ? a.getRemovedBy().get() : null,
-              nullsLast(naturalOrder()))
-          .thenComparing(AccountGroupMemberAudit::getRemovedOn, nullsLast(naturalOrder()));
-
-  private static final Comparator<AccountGroupById> ACCOUNT_GROUP_BY_ID_COMPARATOR =
-      Comparator.comparingInt((AccountGroupById m) -> m.getGroupId().get())
-          .thenComparing(AccountGroupById::getIncludeUUID);
-
-  private static final Comparator<AccountGroupByIdAud> ACCOUNT_GROUP_BY_ID_AUD_COMPARATOR =
-      Comparator.comparingInt((AccountGroupByIdAud a) -> a.getGroupId().get())
-          .thenComparing(AccountGroupByIdAud::getAddedOn)
-          .thenComparingInt(a -> a.getAddedBy().get())
-          .thenComparing(AccountGroupByIdAud::getIncludeUUID)
-          .thenComparing(
-              a -> a.getRemovedBy() != null ? a.getRemovedBy().get() : null,
-              nullsLast(naturalOrder()))
-          .thenComparing(AccountGroupByIdAud::getRemovedOn, nullsLast(naturalOrder()));
-
-  private static final Comparator<AuditEntry> AUDIT_ENTRY_COMPARATOR =
-      Comparator.comparing(AuditEntry::getTimestamp)
-          .thenComparing(AuditEntry::getAction, Comparator.comparingInt(Action::getOrder));
-
-  public static GroupBundle create(
-      Source source,
-      AccountGroup group,
-      Iterable<AccountGroupMember> members,
-      Iterable<AccountGroupMemberAudit> memberAudit,
-      Iterable<AccountGroupById> byId,
-      Iterable<AccountGroupByIdAud> byIdAudit) {
-    AccountGroup.UUID uuid = group.getGroupUUID();
-    return new AutoValue_GroupBundle.Builder()
-        .source(source)
-        .group(group)
-        .members(
-            logIfNotUnique(
-                source, uuid, members, ACCOUNT_GROUP_MEMBER_COMPARATOR, AccountGroupMember.class))
-        .memberAudit(
-            logIfNotUnique(
-                source,
-                uuid,
-                memberAudit,
-                ACCOUNT_GROUP_MEMBER_AUDIT_COMPARATOR,
-                AccountGroupMemberAudit.class))
-        .byId(
-            logIfNotUnique(
-                source, uuid, byId, ACCOUNT_GROUP_BY_ID_COMPARATOR, AccountGroupById.class))
-        .byIdAudit(
-            logIfNotUnique(
-                source,
-                uuid,
-                byIdAudit,
-                ACCOUNT_GROUP_BY_ID_AUD_COMPARATOR,
-                AccountGroupByIdAud.class))
-        .build();
-  }
-
-  private static <T> ImmutableSet<T> logIfNotUnique(
-      Source source,
-      AccountGroup.UUID uuid,
-      Iterable<T> iterable,
-      Comparator<T> comparator,
-      Class<T> clazz) {
-    List<T> list = Streams.stream(iterable).sorted(comparator).collect(toList());
-    ImmutableSet<T> set = ImmutableSet.copyOf(list);
-    if (set.size() != list.size()) {
-      // One way this can happen is that distinct audit entities can compare equal, because
-      // AccountGroup{MemberAudit,ByIdAud}.Key does not include the addedOn timestamp in its
-      // members() list. However, this particular issue only applies to pure adds, since removedOn
-      // *is* included in equality. As a result, if this happens, it means the audit log is already
-      // corrupt, and it's not clear if we can programmatically repair it. For migrating to NoteDb,
-      // we'll try our best to recreate it, but no guarantees it will match the real sequence of
-      // attempted operations, which is in any case lost in the mists of time.
-      logger.atWarning().log(
-          "group %s in %s has duplicate %s entities: %s",
-          uuid, source, clazz.getSimpleName(), iterable);
-    }
-    return set;
-  }
-
-  static Builder builder() {
-    return new AutoValue_GroupBundle.Builder().members().memberAudit().byId().byIdAudit();
-  }
-
-  public static ImmutableList<String> compareWithAudits(
-      GroupBundle reviewDbBundle, GroupBundle noteDbBundle) {
-    return compare(reviewDbBundle, noteDbBundle, true);
-  }
-
-  public static ImmutableList<String> compareWithoutAudits(
-      GroupBundle reviewDbBundle, GroupBundle noteDbBundle) {
-    return compare(reviewDbBundle, noteDbBundle, false);
-  }
-
-  private static ImmutableList<String> compare(
-      GroupBundle reviewDbBundle, GroupBundle noteDbBundle, boolean compareAudits) {
-    // Normalize the ReviewDb bundle to what we expect in NoteDb. This means that values in error
-    // messages will not reflect the actual data in ReviewDb, but it will make it easier for humans
-    // to see the difference.
-    reviewDbBundle = reviewDbBundle.truncateToSecond();
-    AccountGroup reviewDbGroup = new AccountGroup(reviewDbBundle.group());
-    reviewDbGroup.setDescription(Strings.emptyToNull(reviewDbGroup.getDescription()));
-    reviewDbBundle = reviewDbBundle.toBuilder().group(reviewDbGroup).build();
-
-    checkArgument(
-        reviewDbBundle.source() == Source.REVIEW_DB,
-        "first bundle's source must be %s: %s",
-        Source.REVIEW_DB,
-        reviewDbBundle);
-    checkArgument(
-        noteDbBundle.source() == Source.NOTE_DB,
-        "second bundle's source must be %s: %s",
-        Source.NOTE_DB,
-        noteDbBundle);
-
-    ImmutableList.Builder<String> result = ImmutableList.builder();
-    if (!reviewDbBundle.group().equals(noteDbBundle.group())) {
-      result.add(
-          "AccountGroups differ\n"
-              + ("ReviewDb: " + reviewDbBundle.group() + "\n")
-              + ("NoteDb  : " + noteDbBundle.group()));
-    }
-    if (!reviewDbBundle.members().equals(noteDbBundle.members())) {
-      result.add(
-          "AccountGroupMembers differ\n"
-              + ("ReviewDb: " + reviewDbBundle.members() + "\n")
-              + ("NoteDb  : " + noteDbBundle.members()));
-    }
-    if (compareAudits
-        && !areMemberAuditsConsideredEqual(
-            reviewDbBundle.memberAudit(), noteDbBundle.memberAudit())) {
-      result.add(
-          "AccountGroupMemberAudits differ\n"
-              + ("ReviewDb: " + reviewDbBundle.memberAudit() + "\n")
-              + ("NoteDb  : " + noteDbBundle.memberAudit()));
-    }
-    if (!reviewDbBundle.byId().equals(noteDbBundle.byId())) {
-      result.add(
-          "AccountGroupByIds differ\n"
-              + ("ReviewDb: " + reviewDbBundle.byId() + "\n")
-              + ("NoteDb  : " + noteDbBundle.byId()));
-    }
-    if (compareAudits
-        && !areByIdAuditsConsideredEqual(reviewDbBundle.byIdAudit(), noteDbBundle.byIdAudit())) {
-      result.add(
-          "AccountGroupByIdAudits differ\n"
-              + ("ReviewDb: " + reviewDbBundle.byIdAudit() + "\n")
-              + ("NoteDb  : " + noteDbBundle.byIdAudit()));
-    }
-    return result.build();
-  }
-
-  private static boolean areMemberAuditsConsideredEqual(
-      ImmutableSet<AccountGroupMemberAudit> reviewDbMemberAudits,
-      ImmutableSet<AccountGroupMemberAudit> noteDbMemberAudits) {
-    ListMultimap<String, AuditEntry> reviewDbMemberAuditsByMemberId =
-        toMemberAuditEntriesByMemberId(reviewDbMemberAudits);
-    ListMultimap<String, AuditEntry> noteDbMemberAuditsByMemberId =
-        toMemberAuditEntriesByMemberId(noteDbMemberAudits);
-
-    return areConsideredEqual(reviewDbMemberAuditsByMemberId, noteDbMemberAuditsByMemberId);
-  }
-
-  private static boolean areByIdAuditsConsideredEqual(
-      ImmutableSet<AccountGroupByIdAud> reviewDbByIdAudits,
-      ImmutableSet<AccountGroupByIdAud> noteDbByIdAudits) {
-    ListMultimap<String, AuditEntry> reviewDbByIdAuditsById =
-        toByIdAuditEntriesById(reviewDbByIdAudits);
-    ListMultimap<String, AuditEntry> noteDbByIdAuditsById =
-        toByIdAuditEntriesById(noteDbByIdAudits);
-
-    return areConsideredEqual(reviewDbByIdAuditsById, noteDbByIdAuditsById);
-  }
-
-  private static ListMultimap<String, AuditEntry> toMemberAuditEntriesByMemberId(
-      ImmutableSet<AccountGroupMemberAudit> memberAudits) {
-    return memberAudits
-        .stream()
-        .flatMap(GroupBundle::toAuditEntries)
-        .collect(
-            Multimaps.toMultimap(
-                AuditEntry::getTarget,
-                Function.identity(),
-                MultimapBuilder.hashKeys().arrayListValues()::build));
-  }
-
-  private static Stream<AuditEntry> toAuditEntries(AccountGroupMemberAudit memberAudit) {
-    AuditEntry additionAuditEntry =
-        AuditEntry.create(
-            Action.ADD,
-            memberAudit.getAddedBy(),
-            memberAudit.getMemberId(),
-            memberAudit.getAddedOn());
-    if (memberAudit.isActive()) {
-      return Stream.of(additionAuditEntry);
-    }
-
-    AuditEntry removalAuditEntry =
-        AuditEntry.create(
-            Action.REMOVE,
-            memberAudit.getRemovedBy(),
-            memberAudit.getMemberId(),
-            memberAudit.getRemovedOn());
-    return Stream.of(additionAuditEntry, removalAuditEntry);
-  }
-
-  private static ListMultimap<String, AuditEntry> toByIdAuditEntriesById(
-      ImmutableSet<AccountGroupByIdAud> byIdAudits) {
-    return byIdAudits
-        .stream()
-        .flatMap(GroupBundle::toAuditEntries)
-        .collect(
-            Multimaps.toMultimap(
-                AuditEntry::getTarget,
-                Function.identity(),
-                MultimapBuilder.hashKeys().arrayListValues()::build));
-  }
-
-  private static Stream<AuditEntry> toAuditEntries(AccountGroupByIdAud byIdAudit) {
-    AuditEntry additionAuditEntry =
-        AuditEntry.create(
-            Action.ADD, byIdAudit.getAddedBy(), byIdAudit.getIncludeUUID(), byIdAudit.getAddedOn());
-    if (byIdAudit.isActive()) {
-      return Stream.of(additionAuditEntry);
-    }
-
-    AuditEntry removalAuditEntry =
-        AuditEntry.create(
-            Action.REMOVE,
-            byIdAudit.getRemovedBy(),
-            byIdAudit.getIncludeUUID(),
-            byIdAudit.getRemovedOn());
-    return Stream.of(additionAuditEntry, removalAuditEntry);
-  }
-
-  /**
-   * Determines whether the audit log entries are equal except for redundant entries. Entries of the
-   * same type (addition/removal) which follow directly on each other according to their timestamp
-   * are considered redundant.
-   */
-  private static boolean areConsideredEqual(
-      ListMultimap<String, AuditEntry> reviewDbMemberAuditsByTarget,
-      ListMultimap<String, AuditEntry> noteDbMemberAuditsByTarget) {
-    for (String target : reviewDbMemberAuditsByTarget.keySet()) {
-      ImmutableList<AuditEntry> reviewDbAuditEntries =
-          reviewDbMemberAuditsByTarget
-              .get(target)
-              .stream()
-              .sorted(AUDIT_ENTRY_COMPARATOR)
-              .collect(toImmutableList());
-      ImmutableSet<AuditEntry> noteDbAuditEntries =
-          noteDbMemberAuditsByTarget
-              .get(target)
-              .stream()
-              .sorted(AUDIT_ENTRY_COMPARATOR)
-              .collect(toImmutableSet());
-
-      int reviewDbIndex = 0;
-      for (AuditEntry noteDbAuditEntry : noteDbAuditEntries) {
-        Set<AuditEntry> redundantReviewDbAuditEntries = new HashSet<>();
-        while (reviewDbIndex < reviewDbAuditEntries.size()) {
-          AuditEntry reviewDbAuditEntry = reviewDbAuditEntries.get(reviewDbIndex);
-          if (!reviewDbAuditEntry.getAction().equals(noteDbAuditEntry.getAction())) {
-            break;
-          }
-          redundantReviewDbAuditEntries.add(reviewDbAuditEntry);
-          reviewDbIndex++;
-        }
-
-        // The order of the entries is not perfect as ReviewDb included milliseconds for timestamps
-        // and we cut off everything below seconds due to NoteDb/git. Consequently, we don't have a
-        // way to know in this method in which exact order additions/removals within the same second
-        // happened. The best we can do is to group all additions within the same second as
-        // redundant entries and the removals afterward. To compensate that we possibly group
-        // non-redundant additions/removals, we also accept NoteDb audit entries which just occur
-        // anywhere as ReviewDb audit entries.
-        if (!redundantReviewDbAuditEntries.contains(noteDbAuditEntry)
-            && !reviewDbAuditEntries.contains(noteDbAuditEntry)) {
-          return false;
-        }
-      }
-
-      if (reviewDbIndex < reviewDbAuditEntries.size()) {
-        // Some of the ReviewDb audit log entries aren't matched by NoteDb audit log entries.
-        return false;
-      }
-    }
-    return true;
-  }
-
-  public AccountGroup.Id id() {
-    return group().getId();
-  }
-
-  public AccountGroup.UUID uuid() {
-    return group().getGroupUUID();
-  }
-
-  public abstract Source source();
-
-  public abstract AccountGroup group();
-
-  public abstract ImmutableSet<AccountGroupMember> members();
-
-  public abstract ImmutableSet<AccountGroupMemberAudit> memberAudit();
-
-  public abstract ImmutableSet<AccountGroupById> byId();
-
-  public abstract ImmutableSet<AccountGroupByIdAud> byIdAudit();
-
-  public abstract Builder toBuilder();
-
-  public GroupBundle truncateToSecond() {
-    AccountGroup newGroup = new AccountGroup(group());
-    if (newGroup.getCreatedOn() != null) {
-      newGroup.setCreatedOn(TimeUtil.truncateToSecond(newGroup.getCreatedOn()));
-    }
-    return toBuilder()
-        .group(newGroup)
-        .memberAudit(
-            memberAudit().stream().map(GroupBundle::truncateToSecond).collect(toImmutableSet()))
-        .byIdAudit(
-            byIdAudit().stream().map(GroupBundle::truncateToSecond).collect(toImmutableSet()))
-        .build();
-  }
-
-  private static AccountGroupMemberAudit truncateToSecond(AccountGroupMemberAudit a) {
-    AccountGroupMemberAudit result =
-        new AccountGroupMemberAudit(
-            new AccountGroupMemberAudit.Key(
-                a.getKey().getParentKey(),
-                a.getKey().getGroupId(),
-                TimeUtil.truncateToSecond(a.getKey().getAddedOn())),
-            a.getAddedBy());
-    if (a.getRemovedOn() != null) {
-      result.removed(a.getRemovedBy(), TimeUtil.truncateToSecond(a.getRemovedOn()));
-    }
-    return result;
-  }
-
-  private static AccountGroupByIdAud truncateToSecond(AccountGroupByIdAud a) {
-    AccountGroupByIdAud result =
-        new AccountGroupByIdAud(
-            new AccountGroupByIdAud.Key(
-                a.getKey().getParentKey(),
-                a.getKey().getIncludeUUID(),
-                TimeUtil.truncateToSecond(a.getKey().getAddedOn())),
-            a.getAddedBy());
-    if (a.getRemovedOn() != null) {
-      result.removed(a.getRemovedBy(), TimeUtil.truncateToSecond(a.getRemovedOn()));
-    }
-    return result;
-  }
-
-  public InternalGroup toInternalGroup() {
-    return InternalGroup.create(
-        group(),
-        members().stream().map(AccountGroupMember::getAccountId).collect(toImmutableSet()),
-        byId().stream().map(AccountGroupById::getIncludeUUID).collect(toImmutableSet()));
-  }
-
-  @Override
-  public int hashCode() {
-    throw new UnsupportedOperationException(
-        "hashCode is not supported because equals is not supported");
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    throw new UnsupportedOperationException("Use GroupBundle.compare(a, b) instead of equals");
-  }
-
-  @AutoValue
-  abstract static class AuditEntry {
-    private static AuditEntry create(
-        Action action, Account.Id userId, Account.Id memberId, Timestamp timestamp) {
-      return new AutoValue_GroupBundle_AuditEntry(
-          action, userId, String.valueOf(memberId.get()), timestamp);
-    }
-
-    private static AuditEntry create(
-        Action action, Account.Id userId, AccountGroup.UUID subgroupId, Timestamp timestamp) {
-      return new AutoValue_GroupBundle_AuditEntry(action, userId, subgroupId.get(), timestamp);
-    }
-
-    abstract Action getAction();
-
-    abstract Account.Id getUserId();
-
-    abstract String getTarget();
-
-    abstract Timestamp getTimestamp();
-  }
-
-  enum Action {
-    ADD(1),
-    REMOVE(2);
-
-    private final int order;
-
-    Action(int order) {
-      this.order = order;
-    }
-
-    public int getOrder() {
-      return order;
-    }
-  }
-
-  @AutoValue.Builder
-  abstract static class Builder {
-    abstract Builder source(Source source);
-
-    abstract Builder group(AccountGroup group);
-
-    abstract Builder members(AccountGroupMember... member);
-
-    abstract Builder members(Iterable<AccountGroupMember> member);
-
-    abstract Builder memberAudit(AccountGroupMemberAudit... audit);
-
-    abstract Builder memberAudit(Iterable<AccountGroupMemberAudit> audit);
-
-    abstract Builder byId(AccountGroupById... byId);
-
-    abstract Builder byId(Iterable<AccountGroupById> byId);
-
-    abstract Builder byIdAudit(AccountGroupByIdAud... audit);
-
-    abstract Builder byIdAudit(Iterable<AccountGroupByIdAud> audit);
-
-    abstract GroupBundle build();
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/GroupRebuilder.java b/java/com/google/gerrit/server/schema/GroupRebuilder.java
deleted file mode 100644
index f98c948..0000000
--- a/java/com/google/gerrit/server/schema/GroupRebuilder.java
+++ /dev/null
@@ -1,304 +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.server.schema;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-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.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.git.meta.VersionedMetaData.BatchMetaDataUpdate;
-import com.google.gerrit.server.group.db.AuditLogFormatter;
-import com.google.gerrit.server.group.db.GroupConfig;
-import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate.MemberModification;
-import com.google.gerrit.server.group.db.InternalGroupUpdate.SubgroupModification;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.Map;
-import java.util.NavigableSet;
-import java.util.Optional;
-import java.util.function.Consumer;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-/** Helper for rebuilding an entire group's NoteDb refs. */
-class GroupRebuilder {
-  private final PersonIdent serverIdent;
-  private final AllUsersName allUsers;
-  private final AuditLogFormatter auditLogFormatter;
-
-  public GroupRebuilder(
-      PersonIdent serverIdent, AllUsersName allUsers, AuditLogFormatter auditLogFormatter) {
-    this.serverIdent = serverIdent;
-    this.allUsers = allUsers;
-    this.auditLogFormatter = auditLogFormatter;
-  }
-
-  public void rebuild(Repository allUsersRepo, GroupBundle bundle, @Nullable BatchRefUpdate bru)
-      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
-    AccountGroup group = bundle.group();
-    InternalGroupCreation groupCreation =
-        InternalGroupCreation.builder()
-            .setId(bundle.id())
-            .setNameKey(group.getNameKey())
-            .setGroupUUID(group.getGroupUUID())
-            .build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
-    groupConfig.setAllowSaveEmptyName();
-
-    InternalGroupUpdate.Builder updateBuilder =
-        InternalGroupUpdate.builder()
-            .setOwnerGroupUUID(group.getOwnerGroupUUID())
-            .setVisibleToAll(group.isVisibleToAll())
-            .setUpdatedOn(group.getCreatedOn());
-    if (bundle.group().getDescription() != null) {
-      updateBuilder.setDescription(group.getDescription());
-    }
-    groupConfig.setGroupUpdate(updateBuilder.build(), auditLogFormatter);
-
-    Map<Key, Collection<Event>> events = toEvents(bundle).asMap();
-    PersonIdent nowServerIdent = getServerIdent(events);
-
-    MetaDataUpdate md = createMetaDataUpdate(allUsers, allUsersRepo, bru);
-
-    // Creation is done by the server (unlike later audit events).
-    PersonIdent created = new PersonIdent(nowServerIdent, group.getCreatedOn());
-    md.getCommitBuilder().setAuthor(created);
-    md.getCommitBuilder().setCommitter(created);
-
-    // Rebuild group ref.
-    try (BatchMetaDataUpdate batch = groupConfig.openUpdate(md)) {
-      batch.write(groupConfig, md.getCommitBuilder());
-
-      for (Map.Entry<Key, Collection<Event>> e : events.entrySet()) {
-        InternalGroupUpdate.Builder ub = InternalGroupUpdate.builder();
-        e.getValue().forEach(event -> event.update().accept(ub));
-        ub.setUpdatedOn(e.getKey().when());
-        groupConfig.setGroupUpdate(ub.build(), auditLogFormatter);
-
-        PersonIdent currServerIdent = new PersonIdent(nowServerIdent, e.getKey().when());
-        CommitBuilder cb = new CommitBuilder();
-        cb.setAuthor(
-            e.getKey()
-                .accountId()
-                .map(id -> auditLogFormatter.getParsableAuthorIdent(id, currServerIdent))
-                .orElse(currServerIdent));
-        cb.setCommitter(currServerIdent);
-        batch.write(groupConfig, cb);
-      }
-
-      batch.createRef(groupConfig.getRefName());
-    }
-  }
-
-  private ListMultimap<Key, Event> toEvents(GroupBundle bundle) {
-    ListMultimap<Key, Event> result =
-        MultimapBuilder.treeKeys(Key.COMPARATOR).arrayListValues(1).build();
-    Event e;
-
-    for (AccountGroupMemberAudit a : bundle.memberAudit()) {
-      checkArgument(
-          a.getKey().getGroupId().equals(bundle.id()),
-          "key %s does not match group %s",
-          a.getKey(),
-          bundle.id());
-      Account.Id accountId = a.getKey().getParentKey();
-      e = event(Type.ADD_MEMBER, a.getAddedBy(), a.getKey().getAddedOn(), addMember(accountId));
-      result.put(e.key(), e);
-      if (!a.isActive()) {
-        e = event(Type.REMOVE_MEMBER, a.getRemovedBy(), a.getRemovedOn(), removeMember(accountId));
-        result.put(e.key(), e);
-      }
-    }
-
-    for (AccountGroupByIdAud a : bundle.byIdAudit()) {
-      checkArgument(
-          a.getKey().getParentKey().equals(bundle.id()),
-          "key %s does not match group %s",
-          a.getKey(),
-          bundle.id());
-      AccountGroup.UUID uuid = a.getKey().getIncludeUUID();
-      e = event(Type.ADD_GROUP, a.getAddedBy(), a.getKey().getAddedOn(), addGroup(uuid));
-      result.put(e.key(), e);
-      if (!a.isActive()) {
-        e = event(Type.REMOVE_GROUP, a.getRemovedBy(), a.getRemovedOn(), removeGroup(uuid));
-        result.put(e.key(), e);
-      }
-    }
-
-    // Due to clock skew, audit events may be in the future relative to this machine. Ensure the
-    // fixup event happens after any other events, both for the purposes of sorting Keys correctly
-    // and to avoid non-monotonic timestamps in the commit history.
-    Timestamp maxTs =
-        Stream.concat(result.keySet().stream().map(Key::when), Stream.of(TimeUtil.nowTs()))
-            .max(Comparator.naturalOrder())
-            .get();
-    Timestamp fixupTs = new Timestamp(maxTs.getTime() + 1);
-    e = serverEvent(Type.FIXUP, fixupTs, setCurrentMembership(bundle));
-    result.put(e.key(), e);
-
-    return result;
-  }
-
-  private PersonIdent getServerIdent(Map<Key, Collection<Event>> events) {
-    // Created with MultimapBuilder.treeKeys, so the keySet is navigable.
-    Key lastKey = ((NavigableSet<Key>) events.keySet()).last();
-    checkState(lastKey.type() == Type.FIXUP);
-    return new PersonIdent(
-        serverIdent.getName(),
-        serverIdent.getEmailAddress(),
-        Iterables.getOnlyElement(events.get(lastKey)).when(),
-        serverIdent.getTimeZone());
-  }
-
-  private static MetaDataUpdate createMetaDataUpdate(
-      Project.NameKey projectName, Repository repository, @Nullable BatchRefUpdate batchRefUpdate) {
-    return new MetaDataUpdate(
-        GitReferenceUpdated.DISABLED, projectName, repository, batchRefUpdate);
-  }
-
-  private static Consumer<InternalGroupUpdate.Builder> addMember(Account.Id toAdd) {
-    return b -> {
-      MemberModification prev = b.getMemberModification();
-      b.setMemberModification(in -> Sets.union(prev.apply(in), ImmutableSet.of(toAdd)));
-    };
-  }
-
-  private static Consumer<InternalGroupUpdate.Builder> removeMember(Account.Id toRemove) {
-    return b -> {
-      MemberModification prev = b.getMemberModification();
-      b.setMemberModification(in -> Sets.difference(prev.apply(in), ImmutableSet.of(toRemove)));
-    };
-  }
-
-  private static Consumer<InternalGroupUpdate.Builder> addGroup(AccountGroup.UUID toAdd) {
-    return b -> {
-      SubgroupModification prev = b.getSubgroupModification();
-      b.setSubgroupModification(in -> Sets.union(prev.apply(in), ImmutableSet.of(toAdd)));
-    };
-  }
-
-  private static Consumer<InternalGroupUpdate.Builder> removeGroup(AccountGroup.UUID toRemove) {
-    return b -> {
-      SubgroupModification prev = b.getSubgroupModification();
-      b.setSubgroupModification(in -> Sets.difference(prev.apply(in), ImmutableSet.of(toRemove)));
-    };
-  }
-
-  private static Consumer<InternalGroupUpdate.Builder> setCurrentMembership(GroupBundle bundle) {
-    // Overwrite members and subgroups with the current values. The storage layer will do the
-    // set differences to compute the appropriate delta, if any.
-    return b ->
-        b.setMemberModification(
-                in ->
-                    bundle
-                        .members()
-                        .stream()
-                        .map(AccountGroupMember::getAccountId)
-                        .collect(toImmutableSet()))
-            .setSubgroupModification(
-                in ->
-                    bundle
-                        .byId()
-                        .stream()
-                        .map(AccountGroupById::getIncludeUUID)
-                        .collect(toImmutableSet()));
-  }
-
-  private static Event event(
-      Type type,
-      Account.Id accountId,
-      Timestamp when,
-      Consumer<InternalGroupUpdate.Builder> update) {
-    return new AutoValue_GroupRebuilder_Event(type, Optional.of(accountId), when, update);
-  }
-
-  private static Event serverEvent(
-      Type type, Timestamp when, Consumer<InternalGroupUpdate.Builder> update) {
-    return new AutoValue_GroupRebuilder_Event(type, Optional.empty(), when, update);
-  }
-
-  @AutoValue
-  abstract static class Event {
-    abstract Type type();
-
-    abstract Optional<Account.Id> accountId();
-
-    abstract Timestamp when();
-
-    abstract Consumer<InternalGroupUpdate.Builder> update();
-
-    Key key() {
-      return new AutoValue_GroupRebuilder_Key(accountId(), when(), type());
-    }
-  }
-
-  /**
-   * Distinct event types.
-   *
-   * <p>Events at the same time by the same user are batched together by type. The types should
-   * correspond to the possible batch operations supported by {@link
-   * com.google.gerrit.server.audit.AuditService}.
-   */
-  enum Type {
-    ADD_MEMBER,
-    REMOVE_MEMBER,
-    ADD_GROUP,
-    REMOVE_GROUP,
-    FIXUP;
-  }
-
-  @AutoValue
-  abstract static class Key {
-    static final Comparator<Key> COMPARATOR =
-        Comparator.comparing(Key::when)
-            .thenComparing(
-                k -> k.accountId().map(Account.Id::get).orElse(null),
-                Comparator.nullsFirst(Comparator.naturalOrder()))
-            .thenComparing(Key::type);
-
-    abstract Optional<Account.Id> accountId();
-
-    abstract Timestamp when();
-
-    abstract Type type();
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/H2.java b/java/com/google/gerrit/server/schema/H2.java
deleted file mode 100644
index 840eaf0..0000000
--- a/java/com/google/gerrit/server/schema/H2.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import java.nio.file.Path;
-import org.eclipse.jgit.lib.Config;
-
-class H2 extends BaseDataSourceType {
-
-  protected final Config cfg;
-  private final SitePaths site;
-
-  @Inject
-  H2(SitePaths site, @GerritServerConfig Config cfg) {
-    super("org.h2.Driver");
-    this.cfg = cfg;
-    this.site = site;
-  }
-
-  @Override
-  public String getUrl() {
-    String database = cfg.getString("database", null, "database");
-    if (database == null || database.isEmpty()) {
-      database = "db/ReviewDB";
-    }
-    return appendUrlOptions(cfg, createUrl(site.resolve(database)));
-  }
-
-  public static String createUrl(Path path) {
-    return new StringBuilder().append("jdbc:h2:").append(path.toUri().toString()).toString();
-  }
-
-  public static String appendUrlOptions(Config cfg, String url) {
-    long h2CacheSize = cfg.getLong("database", "h2", "cacheSize", -1);
-    boolean h2AutoServer = cfg.getBoolean("database", "h2", "autoServer", false);
-
-    StringBuilder urlBuilder = new StringBuilder().append(url);
-
-    if (h2CacheSize >= 0) {
-      // H2 CACHE_SIZE is always given in KB
-      urlBuilder.append(";CACHE_SIZE=").append(h2CacheSize / 1024);
-    }
-    if (h2AutoServer) {
-      urlBuilder.append(";AUTO_SERVER=TRUE");
-    }
-    return urlBuilder.toString();
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
index 5146140..3f74cda 100644
--- a/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.SQLException;
@@ -36,17 +36,17 @@
   }
 
   @Override
-  public OrmException convertError(String op, SQLException err) {
+  public StorageException convertError(String op, SQLException err) {
     switch (getSQLStateInt(err)) {
       case 23001: // UNIQUE CONSTRAINT VIOLATION
       case 23505: // DUPLICATE_KEY_1
-        return new OrmDuplicateKeyException("account_patch_reviews", err);
+        return new DuplicateKeyException("account_patch_reviews", err);
 
       default:
         if (err.getCause() == null && err.getNextException() != null) {
           err.initCause(err.getNextException());
         }
-        return new OrmException(op + " failure on account_patch_reviews", err);
+        return new StorageException(op + " failure on account_patch_reviews", err);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/schema/HANA.java b/java/com/google/gerrit/server/schema/HANA.java
deleted file mode 100644
index f9811c6..0000000
--- a/java/com/google/gerrit/server/schema/HANA.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.schema.JdbcUtil.hostname;
-import static com.google.gerrit.server.schema.JdbcUtil.port;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.server.config.ConfigSection;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.lib.Config;
-
-class HANA extends BaseDataSourceType {
-
-  private Config cfg;
-
-  @Inject
-  HANA(@GerritServerConfig Config cfg) {
-    super("com.sap.db.jdbc.Driver");
-    this.cfg = cfg;
-  }
-
-  @Override
-  public String getUrl() {
-    final StringBuilder b = new StringBuilder();
-    final ConfigSection dbs = new ConfigSection(cfg, "database");
-    b.append("jdbc:sap://");
-    b.append(hostname(dbs.required("hostname")));
-    b.append(port(dbs.optional("port")));
-    String database = dbs.optional("database");
-    if (!Strings.isNullOrEmpty(database)) {
-      b.append("?databaseName=").append(database);
-    }
-    return b.toString();
-  }
-
-  @Override
-  public ScriptRunner getIndexScript() throws IOException {
-    // HANA uses column tables and should not require additional indices
-    return ScriptRunner.NOOP;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java
deleted file mode 100644
index 35e81b2..0000000
--- a/java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java
+++ /dev/null
@@ -1,58 +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.server.schema;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gwtorm.jdbc.SimpleDataSource;
-import java.sql.SQLException;
-import java.util.Properties;
-import javax.sql.DataSource;
-
-public class InMemoryAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
-  @VisibleForTesting
-  public static class Module extends LifecycleModule {
-    @Override
-    protected void configure() {
-      InMemoryAccountPatchReviewStore inMemoryStore = new InMemoryAccountPatchReviewStore();
-      DynamicItem.bind(binder(), AccountPatchReviewStore.class).toInstance(inMemoryStore);
-      listener().toInstance(inMemoryStore);
-    }
-  }
-
-  /**
-   * Creates an in-memory H2 database to store the reviewed flags. This should be used for tests
-   * only.
-   */
-  @VisibleForTesting
-  private InMemoryAccountPatchReviewStore() {
-    super(newDataSource());
-  }
-
-  private static synchronized DataSource newDataSource() {
-    final Properties p = new Properties();
-    p.setProperty("driver", "org.h2.Driver");
-    // DB_CLOSE_DELAY=-1: By default the content of an in-memory H2 database is lost at the moment
-    // the last connection is closed. This option keeps the content as long as the vm lives.
-    p.setProperty("url", "jdbc:h2:mem:account_patch_reviews;DB_CLOSE_DELAY=-1");
-    try {
-      return new SimpleDataSource(p);
-    } catch (SQLException e) {
-      throw new RuntimeException("Unable to create test datasource", e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/JDBC.java b/java/com/google/gerrit/server/schema/JDBC.java
deleted file mode 100644
index d188df4..0000000
--- a/java/com/google/gerrit/server/schema/JDBC.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.Config;
-
-class JDBC extends BaseDataSourceType {
-
-  protected final Config cfg;
-
-  @Inject
-  JDBC(@GerritServerConfig Config cfg) {
-    super(ConfigUtil.getRequired(cfg, "database", "driver"));
-    this.cfg = cfg;
-  }
-
-  @Override
-  public String getUrl() {
-    return ConfigUtil.getRequired(cfg, "database", "url");
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 83a0986..cb91dea 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -17,21 +17,27 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import java.nio.file.Path;
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
@@ -47,6 +53,12 @@
     implements AccountPatchReviewStore, LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  // DB_CLOSE_DELAY=-1: By default the content of an in-memory H2 database is lost at the moment the
+  // last connection is closed. This option keeps the content as long as the VM lives.
+  @VisibleForTesting
+  public static final String TEST_IN_MEMORY_URL =
+      "jdbc:h2:mem:account_patch_reviews;DB_CLOSE_DELAY=-1";
+
   private static final String ACCOUNT_PATCH_REVIEW_DB = "accountPatchReviewDb";
   private static final String H2_DB = "h2";
   private static final String MARIADB = "mariadb";
@@ -108,14 +120,10 @@
     this.ds = createDataSource(cfg, sitePaths, threadSettingsConfig);
   }
 
-  protected JdbcAccountPatchReviewStore(DataSource ds) {
-    this.ds = ds;
-  }
-
   private static String getUrl(@GerritServerConfig Config cfg, SitePaths sitePaths) {
     String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
     if (url == null) {
-      return H2.createUrl(sitePaths.db_dir.resolve("account_patch_reviews"));
+      return createH2Url(sitePaths.db_dir.resolve("account_patch_reviews"));
     }
     return url;
   }
@@ -163,7 +171,7 @@
   public void start() {
     try {
       createTableIfNotExists();
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Failed to create table to store account patch reviews");
     }
   }
@@ -172,7 +180,7 @@
     return ds.getConnection();
   }
 
-  public void createTableIfNotExists() throws OrmException {
+  public void createTableIfNotExists() {
     try (Connection con = ds.getConnection();
         Statement stmt = con.createStatement()) {
       doCreateTable(stmt);
@@ -193,7 +201,7 @@
             + ")");
   }
 
-  public void dropTableIfExists() throws OrmException {
+  public void dropTableIfExists() {
     try (Connection con = ds.getConnection();
         Statement stmt = con.createStatement()) {
       stmt.executeUpdate("DROP TABLE IF EXISTS account_patch_reviews");
@@ -206,23 +214,30 @@
   public void stop() {}
 
   @Override
-  public boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path)
-      throws OrmException {
-    try (Connection con = ds.getConnection();
+  public boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path) {
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Mark file as reviewed",
+                Metadata.builder()
+                    .patchSetId(psId.get())
+                    .accountId(accountId.get())
+                    .filePath(path)
+                    .build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "INSERT INTO account_patch_reviews "
                     + "(account_id, change_id, patch_set_id, file_name) VALUES "
                     + "(?, ?, ?, ?)")) {
       stmt.setInt(1, accountId.get());
-      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(2, psId.changeId().get());
       stmt.setInt(3, psId.get());
       stmt.setString(4, path);
       stmt.executeUpdate();
       return true;
     } catch (SQLException e) {
-      OrmException ormException = convertError("insert", e);
-      if (ormException instanceof OrmDuplicateKeyException) {
+      StorageException ormException = convertError("insert", e);
+      if (ormException instanceof DuplicateKeyException) {
         return false;
       }
       throw ormException;
@@ -230,13 +245,20 @@
   }
 
   @Override
-  public void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths)
-      throws OrmException {
+  public void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths) {
     if (paths == null || paths.isEmpty()) {
       return;
     }
 
-    try (Connection con = ds.getConnection();
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Mark files as reviewed",
+                Metadata.builder()
+                    .patchSetId(psId.get())
+                    .accountId(accountId.get())
+                    .resourceCount(paths.size())
+                    .build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "INSERT INTO account_patch_reviews "
@@ -244,15 +266,15 @@
                     + "(?, ?, ?, ?)")) {
       for (String path : paths) {
         stmt.setInt(1, accountId.get());
-        stmt.setInt(2, psId.getParentKey().get());
+        stmt.setInt(2, psId.changeId().get());
         stmt.setInt(3, psId.get());
         stmt.setString(4, path);
         stmt.addBatch();
       }
       stmt.executeBatch();
     } catch (SQLException e) {
-      OrmException ormException = convertError("insert", e);
-      if (ormException instanceof OrmDuplicateKeyException) {
+      StorageException ormException = convertError("insert", e);
+      if (ormException instanceof DuplicateKeyException) {
         return;
       }
       throw ormException;
@@ -260,16 +282,23 @@
   }
 
   @Override
-  public void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path)
-      throws OrmException {
-    try (Connection con = ds.getConnection();
+  public void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path) {
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Clear reviewed flag",
+                Metadata.builder()
+                    .patchSetId(psId.get())
+                    .accountId(accountId.get())
+                    .filePath(path)
+                    .build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "DELETE FROM account_patch_reviews "
                     + "WHERE account_id = ? AND change_id = ? AND "
                     + "patch_set_id = ? AND file_name = ?")) {
       stmt.setInt(1, accountId.get());
-      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(2, psId.changeId().get());
       stmt.setInt(3, psId.get());
       stmt.setString(4, path);
       stmt.executeUpdate();
@@ -279,13 +308,17 @@
   }
 
   @Override
-  public void clearReviewed(PatchSet.Id psId) throws OrmException {
-    try (Connection con = ds.getConnection();
+  public void clearReviewed(PatchSet.Id psId) {
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Clear all reviewed flags of patch set",
+                Metadata.builder().patchSetId(psId.get()).build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "DELETE FROM account_patch_reviews "
                     + "WHERE change_id = ? AND patch_set_id = ?")) {
-      stmt.setInt(1, psId.getParentKey().get());
+      stmt.setInt(1, psId.changeId().get());
       stmt.setInt(2, psId.get());
       stmt.executeUpdate();
     } catch (SQLException e) {
@@ -294,9 +327,28 @@
   }
 
   @Override
-  public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId)
-      throws OrmException {
-    try (Connection con = ds.getConnection();
+  public void clearReviewed(Change.Id changeId) {
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Clear all reviewed flags of change",
+                Metadata.builder().changeId(changeId.get()).build());
+        Connection con = ds.getConnection();
+        PreparedStatement stmt =
+            con.prepareStatement("DELETE FROM account_patch_reviews WHERE change_id = ?")) {
+      stmt.setInt(1, changeId.get());
+      stmt.executeUpdate();
+    } catch (SQLException e) {
+      throw convertError("delete", e);
+    }
+  }
+
+  @Override
+  public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Find reviewed flags",
+                Metadata.builder().patchSetId(psId.get()).accountId(accountId.get()).build());
+        Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "SELECT patch_set_id, file_name FROM account_patch_reviews APR1 "
@@ -306,11 +358,11 @@
                     + "AND APR1.change_id = APR2.change_id "
                     + "AND patch_set_id <= ?)")) {
       stmt.setInt(1, accountId.get());
-      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(2, psId.changeId().get());
       stmt.setInt(3, psId.get());
       try (ResultSet rs = stmt.executeQuery()) {
         if (rs.next()) {
-          PatchSet.Id id = new PatchSet.Id(psId.getParentKey(), rs.getInt("patch_set_id"));
+          PatchSet.Id id = PatchSet.id(psId.changeId(), rs.getInt("patch_set_id"));
           ImmutableSet.Builder<String> builder = ImmutableSet.builder();
           do {
             builder.add(rs.getString("file_name"));
@@ -327,11 +379,11 @@
     }
   }
 
-  public OrmException convertError(String op, SQLException err) {
+  public StorageException convertError(String op, SQLException err) {
     if (err.getCause() == null && err.getNextException() != null) {
       err.initCause(err.getNextException());
     }
-    return new OrmException(op + " failure on account_patch_reviews", err);
+    return new StorageException(op + " failure on account_patch_reviews", err);
   }
 
   private static String getSQLState(SQLException err) {
@@ -352,4 +404,8 @@
     }
     return 0;
   }
+
+  private static String createH2Url(Path path) {
+    return new StringBuilder().append("jdbc:h2:").append(path.toUri().toString()).toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/schema/JdbcUtil.java b/java/com/google/gerrit/server/schema/JdbcUtil.java
index 2624923..9f6822c 100644
--- a/java/com/google/gerrit/server/schema/JdbcUtil.java
+++ b/java/com/google/gerrit/server/schema/JdbcUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.common.UsedAt;
+
 public class JdbcUtil {
 
   public static String hostname(String hostname) {
@@ -26,6 +28,8 @@
     return hostname;
   }
 
+  // TODO(dborowitz): Still used by plugins post-ReviewDb?
+  @UsedAt(UsedAt.Project.PLUGINS_ALL)
   public static String port(String port) {
     if (port != null && !port.isEmpty()) {
       return ":" + port;
diff --git a/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java
index aa05a08..b0a3370 100644
--- a/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.SQLException;
@@ -36,18 +36,18 @@
   }
 
   @Override
-  public OrmException convertError(String op, SQLException err) {
+  public StorageException convertError(String op, SQLException err) {
     switch (getSQLStateInt(err)) {
       case 1022: // ER_DUP_KEY
       case 1062: // ER_DUP_ENTRY
       case 1169: // ER_DUP_UNIQUE;
-        return new OrmDuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
+        return new DuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
 
       default:
         if (err.getCause() == null && err.getNextException() != null) {
           err.initCause(err.getNextException());
         }
-        return new OrmException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
+        return new StorageException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/schema/MariaDb.java b/java/com/google/gerrit/server/schema/MariaDb.java
deleted file mode 100644
index 6c5dd35..0000000
--- a/java/com/google/gerrit/server/schema/MariaDb.java
+++ /dev/null
@@ -1,55 +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.server.schema;
-
-import static com.google.gerrit.server.schema.JdbcUtil.hostname;
-import static com.google.gerrit.server.schema.JdbcUtil.port;
-
-import com.google.gerrit.server.config.ConfigSection;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.Config;
-
-class MariaDb extends BaseDataSourceType {
-  private final Config cfg;
-
-  @Inject
-  MariaDb(@GerritServerConfig Config cfg) {
-    super("org.mariadb.jdbc.Driver");
-    this.cfg = cfg;
-  }
-
-  @Override
-  public String getUrl() {
-    StringBuilder b = new StringBuilder();
-    ConfigSection dbs = new ConfigSection(cfg, "database");
-    b.append("jdbc:mariadb://");
-    b.append(hostname(dbs.optional("hostname")));
-    b.append(port(dbs.optional("port")));
-    b.append("/");
-    b.append(dbs.required("database"));
-    b.append("?useBulkStmts=false");
-    return b.toString();
-  }
-
-  @Override
-  public boolean usePool() {
-    // MariaDB has given us trouble with the connection pool,
-    // sometimes the backend disconnects and the pool winds
-    // up with a stale connection. Fortunately opening up
-    // a new MariaDB connection is usually very fast.
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/MaxDb.java b/java/com/google/gerrit/server/schema/MaxDb.java
deleted file mode 100644
index d552eb65..0000000
--- a/java/com/google/gerrit/server/schema/MaxDb.java
+++ /dev/null
@@ -1,50 +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.schema;
-
-import static com.google.gerrit.server.schema.JdbcUtil.hostname;
-
-import com.google.gerrit.server.config.ConfigSection;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.lib.Config;
-
-class MaxDb extends BaseDataSourceType {
-
-  private Config cfg;
-
-  @Inject
-  MaxDb(@GerritServerConfig Config cfg) {
-    super("com.sap.dbtech.jdbc.DriverSapDB");
-    this.cfg = cfg;
-  }
-
-  @Override
-  public String getUrl() {
-    final StringBuilder b = new StringBuilder();
-    final ConfigSection dbs = new ConfigSection(cfg, "database");
-    b.append("jdbc:sapdb://");
-    b.append(hostname(dbs.optional("hostname")));
-    b.append("/");
-    b.append(dbs.required("database"));
-    return b.toString();
-  }
-
-  @Override
-  public ScriptRunner getIndexScript() throws IOException {
-    return getScriptRunner("index_maxdb.sql");
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/MySql.java b/java/com/google/gerrit/server/schema/MySql.java
deleted file mode 100644
index e5f59d7..0000000
--- a/java/com/google/gerrit/server/schema/MySql.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.schema.JdbcUtil.hostname;
-import static com.google.gerrit.server.schema.JdbcUtil.port;
-
-import com.google.gerrit.server.config.ConfigSection;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.Config;
-
-class MySql extends BaseDataSourceType {
-
-  private Config cfg;
-
-  @Inject
-  MySql(@GerritServerConfig Config cfg) {
-    super("com.mysql.jdbc.Driver");
-    this.cfg = cfg;
-  }
-
-  @Override
-  public String getUrl() {
-    final StringBuilder b = new StringBuilder();
-    final ConfigSection dbs = new ConfigSection(cfg, "database");
-    b.append("jdbc:mysql://");
-    b.append(hostname(dbs.optional("hostname")));
-    b.append(port(dbs.optional("port")));
-    b.append("/");
-    b.append(dbs.required("database"));
-    // See
-    // https://stackoverflow.com/questions/42084633/table-name-pattern-can-not-be-null-or-empty-in-java
-    b.append("?nullNamePatternMatchesAll=true");
-    return b.toString();
-  }
-
-  @Override
-  public boolean usePool() {
-    // MySQL has given us trouble with the connection pool,
-    // sometimes the backend disconnects and the pool winds
-    // up with a stale connection. Fortunately opening up
-    // a new MySQL connection is usually very fast.
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
index d648ed0..677b2de 100644
--- a/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.SQLException;
@@ -37,18 +37,18 @@
   }
 
   @Override
-  public OrmException convertError(String op, SQLException err) {
-    switch (getSQLStateInt(err)) {
+  public StorageException convertError(String op, SQLException err) {
+    switch (err.getErrorCode()) {
       case 1022: // ER_DUP_KEY
       case 1062: // ER_DUP_ENTRY
       case 1169: // ER_DUP_UNIQUE;
-        return new OrmDuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
+        return new DuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
 
       default:
         if (err.getCause() == null && err.getNextException() != null) {
           err.initCause(err.getNextException());
         }
-        return new OrmException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
+        return new StorageException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
     }
   }
 
diff --git a/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java b/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
deleted file mode 100644
index 7247490..0000000
--- a/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
+++ /dev/null
@@ -1,214 +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.server.schema;
-
-import com.google.common.collect.ImmutableList;
-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.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ChangeAccess;
-import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
-import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
-import com.google.gerrit.reviewdb.server.PatchSetAccess;
-import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-/**
- * Wrapper for ReviewDb that never calls the underlying change tables.
- *
- * <p>See {@link NotesMigrationSchemaFactory} for discussion.
- */
-class NoChangesReviewDbWrapper extends ReviewDbWrapper {
-  private static <T> ResultSet<T> empty() {
-    return new ListResultSet<>(ImmutableList.of());
-  }
-
-  private final ChangeAccess changes;
-  private final PatchSetApprovalAccess patchSetApprovals;
-  private final ChangeMessageAccess changeMessages;
-  private final PatchSetAccess patchSets;
-  private final PatchLineCommentAccess patchComments;
-
-  NoChangesReviewDbWrapper(ReviewDb db) {
-    super(db);
-    changes = new Changes(this, delegate);
-    patchSetApprovals = new PatchSetApprovals(this, delegate);
-    changeMessages = new ChangeMessages(this, delegate);
-    patchSets = new PatchSets(this, delegate);
-    patchComments = new PatchLineComments(this, delegate);
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    return changes;
-  }
-
-  @Override
-  public PatchSetApprovalAccess patchSetApprovals() {
-    return patchSetApprovals;
-  }
-
-  @Override
-  public ChangeMessageAccess changeMessages() {
-    return changeMessages;
-  }
-
-  @Override
-  public PatchSetAccess patchSets() {
-    return patchSets;
-  }
-
-  @Override
-  public PatchLineCommentAccess patchComments() {
-    return patchComments;
-  }
-
-  private static class Changes extends AbstractDisabledAccess<Change, Change.Id>
-      implements ChangeAccess {
-    private Changes(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.changes());
-    }
-
-    @Override
-    public ResultSet<Change> all() {
-      return empty();
-    }
-  }
-
-  private static class ChangeMessages
-      extends AbstractDisabledAccess<ChangeMessage, ChangeMessage.Key>
-      implements ChangeMessageAccess {
-    private ChangeMessages(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.changeMessages());
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byChange(Change.Id id) throws OrmException {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) throws OrmException {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> all() throws OrmException {
-      return empty();
-    }
-  }
-
-  private static class PatchSets extends AbstractDisabledAccess<PatchSet, PatchSet.Id>
-      implements PatchSetAccess {
-    private PatchSets(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.patchSets());
-    }
-
-    @Override
-    public ResultSet<PatchSet> byChange(Change.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchSet> all() {
-      return empty();
-    }
-  }
-
-  private static class PatchSetApprovals
-      extends AbstractDisabledAccess<PatchSetApproval, PatchSetApproval.Key>
-      implements PatchSetApprovalAccess {
-    private PatchSetApprovals(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.patchSetApprovals());
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byChange(Change.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> all() {
-      return empty();
-    }
-  }
-
-  private static class PatchLineComments
-      extends AbstractDisabledAccess<PatchLineComment, PatchLineComment.Key>
-      implements PatchLineCommentAccess {
-    private PatchLineComments(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.patchComments());
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byChange(Change.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
-        PatchSet.Id patchset, Account.Id author) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
-        Change.Id id, String file, Account.Id author) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> all() {
-      return empty();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
new file mode 100644
index 0000000..3d08a73
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
@@ -0,0 +1,191 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+
+public class NoteDbSchemaUpdater {
+  private final Config cfg;
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager repoManager;
+  private final SchemaCreator schemaCreator;
+  private final NoteDbSchemaVersionManager versionManager;
+  private final NoteDbSchemaVersion.Arguments args;
+  private final ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> schemaVersions;
+
+  @Inject
+  NoteDbSchemaUpdater(
+      @GerritServerConfig Config cfg,
+      AllUsersName allUsersName,
+      GitRepositoryManager repoManager,
+      SchemaCreator schemaCreator,
+      NoteDbSchemaVersionManager versionManager,
+      NoteDbSchemaVersion.Arguments args) {
+    this(
+        cfg,
+        allUsersName,
+        repoManager,
+        schemaCreator,
+        versionManager,
+        args,
+        NoteDbSchemaVersions.ALL);
+  }
+
+  NoteDbSchemaUpdater(
+      Config cfg,
+      AllUsersName allUsersName,
+      GitRepositoryManager repoManager,
+      SchemaCreator schemaCreator,
+      NoteDbSchemaVersionManager versionManager,
+      NoteDbSchemaVersion.Arguments args,
+      ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> schemaVersions) {
+    this.cfg = cfg;
+    this.allUsersName = allUsersName;
+    this.repoManager = repoManager;
+    this.schemaCreator = schemaCreator;
+    this.versionManager = versionManager;
+    this.args = args;
+    this.schemaVersions = schemaVersions;
+  }
+
+  public void update(UpdateUI ui) {
+    ensureSchemaCreated();
+
+    int currentVersion = versionManager.read();
+    if (currentVersion == 0) {
+      // The only valid case where there is no refs/meta/version is when running 3.x init for the
+      // first time on a site that previously ran init on 2.16. A freshly created 3.x site will have
+      // seeded refs/meta/version during AllProjectsCreator, so it won't hit this block.
+      checkNoteDbConfigFor216();
+    }
+
+    for (int nextVersion : requiredUpgrades(currentVersion, schemaVersions.keySet())) {
+      try {
+        ui.message(String.format("Migrating data to schema %d ...", nextVersion));
+        NoteDbSchemaVersions.get(schemaVersions, nextVersion).upgrade(args, ui);
+        versionManager.increment(nextVersion - 1);
+      } catch (Exception e) {
+        throw new StorageException(
+            String.format("Failed to upgrade to schema version %d", nextVersion), e);
+      }
+    }
+  }
+
+  private void ensureSchemaCreated() {
+    try {
+      schemaCreator.ensureCreated();
+    } catch (IOException | ConfigInvalidException e) {
+      throw new StorageException("Cannot initialize Gerrit site");
+    }
+  }
+
+  // Config#getEnum requires this to be public, so give it an off-putting name.
+  public enum PrimaryStorageFor216Compatibility {
+    REVIEW_DB,
+    NOTE_DB
+  }
+
+  private void checkNoteDbConfigFor216() {
+    // Check that the NoteDb migration config matches what we expect from a site that both:
+    // * Completed the change migration to NoteDB.
+    // * Ran schema upgrades from a 2.16 final release.
+
+    if (!cfg.getBoolean("noteDb", "changes", "write", false)
+        || !cfg.getBoolean("noteDb", "changes", "read", false)
+        || cfg.getEnum(
+                "noteDb", "changes", "primaryStorage", PrimaryStorageFor216Compatibility.REVIEW_DB)
+            != PrimaryStorageFor216Compatibility.NOTE_DB
+        || !cfg.getBoolean("noteDb", "changes", "disableReviewDb", false)) {
+      throw new StorageException(
+          "You appear to be upgrading from a 2.x site, but the NoteDb change migration was"
+              + " not completed. See documentation:\n"
+              + "https://gerrit-review.googlesource.com/Documentation/note-db.html#migration");
+    }
+
+    // We don't have a direct way to check that 2.16 init was run; the most obvious side effect
+    // would be upgrading the *ReviewDb* schema to the latest 2.16 schema version. But in 3.x we can
+    // no longer access ReviewDb, so we can't check that directly.
+    //
+    // Instead, check for a NoteDb-specific side effect of the migration process: the presence of
+    // the NoteDb group sequence ref. This is created by the schema 163 migration, which was part of
+    // 2.16 and not 2.15.
+    //
+    // There are a few corner cases where we will proceed even if the schema is not fully up to
+    // date:
+    //  * If a user happened to run init from master after schema 163 was added but before 2.16
+    //    final. We assume that someone savvy enough to do that has followed the documented
+    //    requirement of upgrading to 2.16 final before 3.0.
+    //  * If a user ran init in 2.16.x and the upgrade to 163 succeeded but a later update failed.
+    //    In this case the server literally will not start under 2.16. We assume the user will fix
+    //    this and get 2.16 running rather than abandoning 2.16 and jumping to 3.0 at this point.
+    try (Repository allUsers = repoManager.openRepository(allUsersName)) {
+      if (allUsers.exactRef(RefNames.REFS_SEQUENCES + Sequences.NAME_GROUPS) == null) {
+        throw new StorageException(
+            "You appear to be upgrading to 3.x from a version prior to 2.16; you must upgrade to"
+                + " 2.16.x first");
+      }
+    } catch (IOException e) {
+      throw new StorageException("Failed to check NoteDb migration state", e);
+    }
+  }
+
+  @VisibleForTesting
+  static ImmutableList<Integer> requiredUpgrades(
+      int currentVersion, ImmutableSortedSet<Integer> allVersions) {
+    int firstVersion = allVersions.first();
+    int latestVersion = allVersions.last();
+    if (currentVersion == latestVersion) {
+      return ImmutableList.of();
+    } else if (currentVersion > latestVersion) {
+      throw new StorageException(
+          String.format(
+              "Cannot downgrade NoteDb schema from version %d to %d",
+              currentVersion, latestVersion));
+    }
+
+    int firstUpgradeVersion;
+    if (currentVersion == 0) {
+      // Bootstrap NoteDb version to minimum supported schema number.
+      firstUpgradeVersion = firstVersion;
+    } else {
+      if (currentVersion < firstVersion - 1) {
+        throw new StorageException(
+            String.format(
+                "Cannot skip NoteDb schema from version %d to %d", currentVersion, firstVersion));
+      }
+      firstUpgradeVersion = currentVersion + 1;
+    }
+    return IntStream.rangeClosed(firstUpgradeVersion, latestVersion)
+        .boxed()
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
new file mode 100644
index 0000000..b6a7a1c
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * Schema upgrade implementation.
+ *
+ * <p>Implementations must have a single non-private constructor with no arguments (e.g. the default
+ * constructor).
+ */
+interface NoteDbSchemaVersion {
+  @Singleton
+  class Arguments {
+    final GitRepositoryManager repoManager;
+    final AllProjectsName allProjects;
+    final AllUsersName allUsers;
+
+    @Inject
+    Arguments(
+        GitRepositoryManager repoManager, AllProjectsName allProjects, AllUsersName allUsers) {
+      this.repoManager = repoManager;
+      this.allProjects = allProjects;
+      this.allUsers = allUsers;
+    }
+  }
+
+  void upgrade(Arguments args, UpdateUI ui) throws Exception;
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java
new file mode 100644
index 0000000..33534fc
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java
@@ -0,0 +1,77 @@
+// 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.schema;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.ProvisionException;
+
+public class NoteDbSchemaVersionCheck implements LifecycleListener {
+  public static Module module() {
+    return new LifecycleModule() {
+      @Override
+      protected void configure() {
+        listener().to(NoteDbSchemaVersionCheck.class);
+      }
+    };
+  }
+
+  private final NoteDbSchemaVersionManager versionManager;
+  private final SitePaths sitePaths;
+
+  @Inject
+  NoteDbSchemaVersionCheck(NoteDbSchemaVersionManager versionManager, SitePaths sitePaths) {
+    this.versionManager = versionManager;
+    this.sitePaths = sitePaths;
+  }
+
+  @Override
+  public void start() {
+    try {
+      int current = versionManager.read();
+      if (current == 0) {
+        throw new ProvisionException(
+            String.format(
+                "Schema not yet initialized. Run init to initialize the schema:\n"
+                    + "$ java -jar gerrit.war init -d %s",
+                sitePaths.site_path.toAbsolutePath()));
+      }
+      int expected = NoteDbSchemaVersions.LATEST;
+      if (current != expected) {
+        String advice =
+            current > expected
+                ? "Downgrade is not supported"
+                : String.format(
+                    "Run init to upgrade:\n$ java -jar %s init -d %s",
+                    sitePaths.gerrit_war.toAbsolutePath(), sitePaths.site_path.toAbsolutePath());
+        throw new ProvisionException(
+            String.format(
+                "Unsupported schema version %d; expected schema version %d. %s",
+                current, expected, advice));
+      }
+    } catch (StorageException e) {
+      throw new ProvisionException("Failed to read NoteDb schema version", e);
+    }
+  }
+
+  @Override
+  public void stop() {
+    // Do nothing.
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersionManager.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionManager.java
new file mode 100644
index 0000000..7ff0ea6
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionManager.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.schema;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_VERSION;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.IntBlob;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class NoteDbSchemaVersionManager {
+  private final AllProjectsName allProjectsName;
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  @VisibleForTesting
+  public NoteDbSchemaVersionManager(
+      AllProjectsName allProjectsName, GitRepositoryManager repoManager) {
+    // Can't inject GitReferenceUpdated here because it has dependencies that are not always
+    // available in this injector (e.g. during init). This is ok for now since no other ref updates
+    // during init are available to plugins, and there are not any other use cases for listening for
+    // updates to the version ref.
+    this.allProjectsName = allProjectsName;
+    this.repoManager = repoManager;
+  }
+
+  public int read() {
+    try (Repository repo = repoManager.openRepository(allProjectsName)) {
+      return IntBlob.parse(repo, REFS_VERSION).map(IntBlob::value).orElse(0);
+    } catch (IOException e) {
+      throw new StorageException("Failed to read " + REFS_VERSION, e);
+    }
+  }
+
+  public void init() throws IOException {
+    try (Repository repo = repoManager.openRepository(allProjectsName);
+        RevWalk rw = new RevWalk(repo)) {
+      Optional<IntBlob> old = IntBlob.parse(repo, REFS_VERSION, rw);
+      if (old.isPresent()) {
+        throw new StorageException(
+            String.format(
+                "Expected no old version for %s, found %s", REFS_VERSION, old.get().value()));
+      }
+      IntBlob.store(
+          repo,
+          rw,
+          allProjectsName,
+          REFS_VERSION,
+          old.map(IntBlob::id).orElse(ObjectId.zeroId()),
+          NoteDbSchemaVersions.LATEST,
+          GitReferenceUpdated.DISABLED);
+    }
+  }
+
+  public void increment(int expectedOldVersion) throws IOException {
+    try (Repository repo = repoManager.openRepository(allProjectsName);
+        RevWalk rw = new RevWalk(repo)) {
+      Optional<IntBlob> old = IntBlob.parse(repo, REFS_VERSION, rw);
+      if (old.isPresent() && old.get().value() != expectedOldVersion) {
+        throw new StorageException(
+            String.format(
+                "Expected old version %d for %s, found %d",
+                expectedOldVersion, REFS_VERSION, old.get().value()));
+      }
+      IntBlob.store(
+          repo,
+          rw,
+          allProjectsName,
+          REFS_VERSION,
+          old.map(IntBlob::id).orElse(ObjectId.zeroId()),
+          expectedOldVersion + 1,
+          GitReferenceUpdated.DISABLED);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
new file mode 100644
index 0000000..02250f2
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
@@ -0,0 +1,60 @@
+// 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.schema;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap;
+import static java.util.Comparator.naturalOrder;
+
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.UsedAt;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+public class NoteDbSchemaVersions {
+  static final ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> ALL =
+      // List all supported NoteDb schema versions here.
+      Stream.of(Schema_180.class, Schema_181.class)
+          .collect(toImmutableSortedMap(naturalOrder(), v -> guessVersion(v).get(), v -> v));
+
+  public static final int FIRST = ALL.firstKey();
+  public static final int LATEST = ALL.lastKey();
+
+  // TODO(dborowitz): Migrate delete-project plugin to use this implementation.
+  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
+  public static Optional<Integer> guessVersion(Class<?> c) {
+    String prefix = "Schema_";
+    if (!c.getSimpleName().startsWith(prefix)) {
+      return Optional.empty();
+    }
+    return Optional.ofNullable(Ints.tryParse(c.getSimpleName().substring(prefix.length())));
+  }
+
+  public static NoteDbSchemaVersion get(
+      ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> schemaVersions, int i) {
+    Class<? extends NoteDbSchemaVersion> clazz = schemaVersions.get(i);
+    checkArgument(clazz != null, "Schema version not found: %s", i);
+    try {
+      return clazz.getDeclaredConstructor().newInstance();
+    } catch (InstantiationException
+        | IllegalAccessException
+        | NoSuchMethodException
+        | InvocationTargetException e) {
+      throw new IllegalStateException("failed to invoke constructor on " + clazz.getName(), e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
deleted file mode 100644
index 0d95610..0000000
--- a/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.DisallowReadFromChangesReviewDbWrapper;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class NotesMigrationSchemaFactory implements SchemaFactory<ReviewDb> {
-  private final SchemaFactory<ReviewDb> delegate;
-  private final NotesMigration migration;
-
-  @Inject
-  NotesMigrationSchemaFactory(
-      @ReviewDbFactory SchemaFactory<ReviewDb> delegate, NotesMigration migration) {
-    this.delegate = delegate;
-    this.migration = migration;
-  }
-
-  @Override
-  public ReviewDb open() throws OrmException {
-    // There are two levels at which this class disables access to Changes and related tables,
-    // corresponding to two phases of the NoteDb migration:
-    //
-    // 1. When changes are read from NoteDb but some changes might still have their primary storage
-    //    in ReviewDb, it is generally programmer error to read changes from ReviewDb. However,
-    //    since ReviewDb is still the primary storage for most or all changes, we still need to
-    //    support writing to ReviewDb. This behavior is accomplished by wrapping in a
-    //    DisallowReadFromChangesReviewDbWrapper.
-    //
-    //    Some codepaths might need to be able to read from ReviewDb if they really need to,
-    //    because they need to operate on the underlying source of truth, for example when reading
-    //    a change to determine its primary storage. To support this, ReviewDbUtil#unwrapDb can
-    //    detect and unwrap databases of this type.
-    //
-    // 2. After all changes have their primary storage in NoteDb, we can completely shut off access
-    //    to the change tables. At this point in the migration, we are by definition not using the
-    //    ReviewDb tables at all; we could even delete the tables at this point, and Gerrit would
-    //    continue to function.
-    //
-    //    This is accomplished by setting the delegate ReviewDb *underneath*
-    //    DisallowReadFromChanges to be a complete no-op, with NoChangesReviewDbWrapper. With this
-    //    wrapper, all read operations return no results, and write operations silently do nothing.
-    //    This wrapper is not a public class and nobody should ever attempt to unwrap it.
-
-    // First create the wrappers which can not be removed by ReviewDbUtil#unwrapDb(ReviewDb).
-    ReviewDb db = delegate.open();
-    if (migration.readChanges() && migration.disableChangeReviewDb()) {
-      // Disable writes to change tables in ReviewDb (ReviewDb access for changes are No-Ops).
-      db = new NoChangesReviewDbWrapper(db);
-    }
-
-    // Second create the wrappers which can be removed by ReviewDbUtil#unwrapDb(ReviewDb).
-    if (migration.readChanges()) {
-      // If reading changes from NoteDb is configured, changes should not be read from ReviewDb.
-      // Make sure that any attempt to read a change from ReviewDb anyway fails with an exception.
-      db = new DisallowReadFromChangesReviewDbWrapper(db);
-    }
-    return db;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Oracle.java b/java/com/google/gerrit/server/schema/Oracle.java
deleted file mode 100644
index 4ff7243..0000000
--- a/java/com/google/gerrit/server/schema/Oracle.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.schema.JdbcUtil.hostname;
-import static com.google.gerrit.server.schema.JdbcUtil.port;
-
-import com.google.gerrit.server.config.ConfigSection;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.Config;
-
-public class Oracle extends BaseDataSourceType {
-  private Config cfg;
-
-  @Inject
-  public Oracle(@GerritServerConfig Config cfg) {
-    super("oracle.jdbc.driver.OracleDriver");
-    this.cfg = cfg;
-  }
-
-  @Override
-  public String getUrl() {
-    final StringBuilder b = new StringBuilder();
-    final ConfigSection dbc = new ConfigSection(cfg, "database");
-    b.append("jdbc:oracle:thin:@");
-    b.append(hostname(dbc.optional("hostname")));
-    b.append(port(dbc.optional("port")));
-    b.append(":");
-    b.append(dbc.required("instance"));
-    return b.toString();
-  }
-
-  @Override
-  public String getValidationQuery() {
-    return "select 1 from dual";
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/PostgreSQL.java b/java/com/google/gerrit/server/schema/PostgreSQL.java
deleted file mode 100644
index d6aee94..0000000
--- a/java/com/google/gerrit/server/schema/PostgreSQL.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.schema.JdbcUtil.hostname;
-import static com.google.gerrit.server.schema.JdbcUtil.port;
-
-import com.google.gerrit.server.config.ConfigSection;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.lib.Config;
-
-class PostgreSQL extends BaseDataSourceType {
-
-  private Config cfg;
-
-  @Inject
-  PostgreSQL(@GerritServerConfig Config cfg) {
-    super("org.postgresql.Driver");
-    this.cfg = cfg;
-  }
-
-  @Override
-  public String getUrl() {
-    final StringBuilder b = new StringBuilder();
-    final ConfigSection dbc = new ConfigSection(cfg, "database");
-    b.append("jdbc:postgresql://");
-    b.append(hostname(dbc.optional("hostname")));
-    b.append(port(dbc.optional("port")));
-    b.append("/");
-    b.append(dbc.required("database"));
-    return b.toString();
-  }
-
-  @Override
-  public ScriptRunner getIndexScript() throws IOException {
-    return getScriptRunner("index_postgres.sql");
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
index 34f7dba..db68f2e 100644
--- a/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.SQLException;
@@ -36,10 +36,10 @@
   }
 
   @Override
-  public OrmException convertError(String op, SQLException err) {
+  public StorageException convertError(String op, SQLException err) {
     switch (getSQLStateInt(err)) {
       case 23505: // DUPLICATE_KEY_1
-        return new OrmDuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
+        return new DuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
 
       case 23514: // CHECK CONSTRAINT VIOLATION
       case 23503: // FOREIGN KEY CONSTRAINT VIOLATION
@@ -49,7 +49,7 @@
         if (err.getCause() == null && err.getNextException() != null) {
           err.initCause(err.getNextException());
         }
-        return new OrmException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
+        return new StorageException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
index c25b846..9e12807 100644
--- a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
+++ b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
@@ -17,12 +17,17 @@
 import static com.google.gerrit.server.project.ProjectConfig.ACCESS;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
@@ -31,22 +36,38 @@
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.StoredConfig;
 
 public class ProjectConfigSchemaUpdate extends VersionedMetaData {
+  public static class Factory {
+    private final SitePaths sitePaths;
+    private final AllProjectsName allProjectsName;
+
+    @Inject
+    Factory(SitePaths sitePaths, AllProjectsName allProjectsName) {
+      this.sitePaths = sitePaths;
+      this.allProjectsName = allProjectsName;
+    }
+
+    ProjectConfigSchemaUpdate read(MetaDataUpdate update)
+        throws IOException, ConfigInvalidException {
+      ProjectConfigSchemaUpdate r =
+          new ProjectConfigSchemaUpdate(
+              update,
+              ProjectConfig.Factory.getBaseConfig(sitePaths, allProjectsName, allProjectsName));
+      r.load(update);
+      return r;
+    }
+  }
 
   private final MetaDataUpdate update;
+  @Nullable private final StoredConfig baseConfig;
   private Config config;
   private boolean updated;
 
-  public static ProjectConfigSchemaUpdate read(MetaDataUpdate update)
-      throws IOException, ConfigInvalidException {
-    ProjectConfigSchemaUpdate r = new ProjectConfigSchemaUpdate(update);
-    r.load(update);
-    return r;
-  }
-
-  private ProjectConfigSchemaUpdate(MetaDataUpdate update) {
+  private ProjectConfigSchemaUpdate(MetaDataUpdate update, @Nullable StoredConfig baseConfig) {
     this.update = update;
+    this.baseConfig = baseConfig;
   }
 
   @Override
@@ -56,7 +77,15 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
-    config = readConfig(ProjectConfig.PROJECT_CONFIG);
+    if (baseConfig != null) {
+      baseConfig.load();
+    }
+    config = readConfig(ProjectConfig.PROJECT_CONFIG, baseConfig);
+  }
+
+  @VisibleForTesting
+  Config getConfig() {
+    return config;
   }
 
   public void removeForceFromPermission(String name) {
@@ -86,7 +115,7 @@
     return true;
   }
 
-  public void save(PersonIdent personIdent, String commitMessage) throws OrmException {
+  public void save(PersonIdent personIdent, String commitMessage) {
     if (!updated) {
       return;
     }
@@ -97,7 +126,7 @@
     try {
       commit(update);
     } catch (IOException e) {
-      throw new OrmException(e);
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java b/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java
deleted file mode 100644
index 0fbaeca..0000000
--- a/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.Database;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.name.Named;
-import javax.sql.DataSource;
-
-/** Provides the {@code Database<ReviewDb>} database handle. */
-final class ReviewDbDatabaseProvider implements Provider<Database<ReviewDb>> {
-  private final DataSource datasource;
-
-  @Inject
-  ReviewDbDatabaseProvider(@Named("ReviewDb") final DataSource ds) {
-    datasource = ds;
-  }
-
-  @Override
-  public Database<ReviewDb> get() {
-    try {
-      return new Database<>(datasource, ReviewDb.class);
-    } catch (OrmException e) {
-      throw new ProvisionException("Cannot create ReviewDb", e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/ReviewDbFactory.java b/java/com/google/gerrit/server/schema/ReviewDbFactory.java
deleted file mode 100644
index 86f5d06..0000000
--- a/java/com/google/gerrit/server/schema/ReviewDbFactory.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
-
-/**
- * Marker on {@link com.google.gwtorm.server.SchemaFactory} implementation that talks to the
- * underlying traditional {@link com.google.gerrit.reviewdb.server.ReviewDb} database.
- *
- * <p>During the migration to NoteDb, the actual {@code ReviewDb} will be a wrapper with certain
- * tables enabled/disabled; this marker goes on the low-level implementation that has all tables.
- */
-@Retention(RUNTIME)
-@BindingAnnotation
-public @interface ReviewDbFactory {}
diff --git a/java/com/google/gerrit/server/schema/SchemaCreator.java b/java/com/google/gerrit/server/schema/SchemaCreator.java
index d650dc7..b78ce73 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreator.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreator.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2018 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,273 +14,29 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.GroupUUID;
-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.GerritServerId;
-import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.db.AuditLogFormatter;
-import com.google.gerrit.server.group.db.GroupConfig;
-import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.update.RefUpdateUtil;
-import com.google.gwtorm.jdbc.JdbcExecutor;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
 import java.io.IOException;
-import java.nio.file.Path;
-import java.util.Collections;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
 
-/** Creates the current database schema and populates initial code rows. */
-public class SchemaCreator {
-  @SitePath private final Path site_path;
+/** Populates initial NoteDb schema, {@code All-Projects} configuration, etc. */
+public interface SchemaCreator {
 
-  private final GitRepositoryManager repoManager;
-  private final AllProjectsCreator allProjectsCreator;
-  private final AllUsersCreator allUsersCreator;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-  private final DataSourceType dataSourceType;
-  private final GroupIndexCollection indexCollection;
-  private final String serverId;
+  /**
+   * Create the schema, assuming it does not already exist.
+   *
+   * <p>Fails if the schema does exist.
+   *
+   * @throws IOException an error occurred.
+   * @throws ConfigInvalidException an error occurred.
+   */
+  void create() throws IOException, ConfigInvalidException;
 
-  private final Config config;
-  private final MetricMaker metricMaker;
-  private final NotesMigration migration;
-  private final AllProjectsName allProjectsName;
-
-  @Inject
-  public SchemaCreator(
-      SitePaths site,
-      GitRepositoryManager repoManager,
-      AllProjectsCreator ap,
-      AllUsersCreator auc,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent au,
-      DataSourceType dst,
-      GroupIndexCollection ic,
-      @GerritServerId String serverId,
-      @GerritServerConfig Config config,
-      MetricMaker metricMaker,
-      NotesMigration migration,
-      AllProjectsName apName) {
-    this(
-        site.site_path,
-        repoManager,
-        ap,
-        auc,
-        allUsersName,
-        au,
-        dst,
-        ic,
-        serverId,
-        config,
-        metricMaker,
-        migration,
-        apName);
-  }
-
-  public SchemaCreator(
-      @SitePath Path site,
-      GitRepositoryManager repoManager,
-      AllProjectsCreator ap,
-      AllUsersCreator auc,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent au,
-      DataSourceType dst,
-      GroupIndexCollection ic,
-      String serverId,
-      Config config,
-      MetricMaker metricMaker,
-      NotesMigration migration,
-      AllProjectsName apName) {
-    site_path = site;
-    this.repoManager = repoManager;
-    allProjectsCreator = ap;
-    allUsersCreator = auc;
-    this.allUsersName = allUsersName;
-    serverUser = au;
-    dataSourceType = dst;
-    indexCollection = ic;
-    this.serverId = serverId;
-
-    this.config = config;
-    this.allProjectsName = apName;
-    this.migration = migration;
-    this.metricMaker = metricMaker;
-  }
-
-  public void create(ReviewDb db) throws OrmException, IOException, ConfigInvalidException {
-    final JdbcSchema jdbc = (JdbcSchema) db;
-    try (JdbcExecutor e = new JdbcExecutor(jdbc)) {
-      jdbc.updateSchema(e);
-    }
-
-    final CurrentSchemaVersion sVer = CurrentSchemaVersion.create();
-    sVer.versionNbr = SchemaVersion.getBinaryVersion();
-    db.schemaVersion().insert(Collections.singleton(sVer));
-
-    GroupReference admins = createGroupReference("Administrators");
-    GroupReference batchUsers = createGroupReference("Non-Interactive Users");
-
-    initSystemConfig(db);
-    allProjectsCreator.setAdministrators(admins).setBatchUsers(batchUsers).create();
-    // We have to create the All-Users repository before we can use it to store the groups in it.
-    allUsersCreator.setAdministrators(admins).create();
-
-    // Don't rely on injection to construct Sequences, as it requires ReviewDb.
-    Sequences seqs =
-        new Sequences(
-            config,
-            () -> db,
-            migration,
-            repoManager,
-            GitReferenceUpdated.DISABLED,
-            allProjectsName,
-            allUsersName,
-            metricMaker);
-    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      createAdminsGroup(seqs, allUsersRepo, admins);
-      createBatchUsersGroup(seqs, allUsersRepo, batchUsers, admins.getUUID());
-    }
-
-    dataSourceType.getIndexScript().run(db);
-  }
-
-  private void createAdminsGroup(
-      Sequences seqs, Repository allUsersRepo, GroupReference groupReference)
-      throws OrmException, IOException, ConfigInvalidException {
-    InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription("Gerrit Site Administrators").build();
-
-    createGroup(allUsersRepo, groupCreation, groupUpdate);
-  }
-
-  private void createBatchUsersGroup(
-      Sequences seqs,
-      Repository allUsersRepo,
-      GroupReference groupReference,
-      AccountGroup.UUID adminsGroupUuid)
-      throws OrmException, IOException, ConfigInvalidException {
-    InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
-            .setDescription("Users who perform batch actions on Gerrit")
-            .setOwnerGroupUUID(adminsGroupUuid)
-            .build();
-
-    createGroup(allUsersRepo, groupCreation, groupUpdate);
-  }
-
-  private void createGroup(
-      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
-      throws OrmException, ConfigInvalidException, IOException {
-    InternalGroup createdGroup = createGroupInNoteDb(allUsersRepo, groupCreation, groupUpdate);
-    index(createdGroup);
-  }
-
-  private InternalGroup createGroupInNoteDb(
-      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
-      throws ConfigInvalidException, IOException, OrmDuplicateKeyException {
-    // This method is only executed on a new server which doesn't have any accounts or groups.
-    AuditLogFormatter auditLogFormatter =
-        AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), serverId);
-
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
-    GroupNameNotes groupNameNotes =
-        GroupNameNotes.forNewGroup(allUsersRepo, groupCreation.getGroupUUID(), groupName);
-
-    commit(allUsersRepo, groupConfig, groupNameNotes);
-
-    return groupConfig
-        .getLoadedGroup()
-        .orElseThrow(() -> new IllegalStateException("Created group wasn't automatically loaded"));
-  }
-
-  private void commit(
-      Repository allUsersRepo, GroupConfig groupConfig, GroupNameNotes groupNameNotes)
-      throws IOException {
-    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(allUsersRepo, batchRefUpdate)) {
-      groupConfig.commit(metaDataUpdate);
-    }
-    // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(allUsersRepo, batchRefUpdate)) {
-      groupNameNotes.commit(metaDataUpdate);
-    }
-    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
-  }
-
-  private MetaDataUpdate createMetaDataUpdate(
-      Repository allUsersRepo, @Nullable BatchRefUpdate batchRefUpdate) {
-    MetaDataUpdate metaDataUpdate =
-        new MetaDataUpdate(
-            GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo, batchRefUpdate);
-    metaDataUpdate.getCommitBuilder().setAuthor(serverUser);
-    metaDataUpdate.getCommitBuilder().setCommitter(serverUser);
-    return metaDataUpdate;
-  }
-
-  private void index(InternalGroup group) throws IOException {
-    for (GroupIndex groupIndex : indexCollection.getWriteIndexes()) {
-      groupIndex.replace(group);
-    }
-  }
-
-  private GroupReference createGroupReference(String name) {
-    AccountGroup.UUID groupUuid = GroupUUID.make(name, serverUser);
-    return new GroupReference(groupUuid, name);
-  }
-
-  private InternalGroupCreation getGroupCreation(Sequences seqs, GroupReference groupReference)
-      throws OrmException {
-    int next = seqs.nextGroupId();
-    return InternalGroupCreation.builder()
-        .setNameKey(new AccountGroup.NameKey(groupReference.getName()))
-        .setId(new AccountGroup.Id(next))
-        .setGroupUUID(groupReference.getUUID())
-        .build();
-  }
-
-  private SystemConfig initSystemConfig(ReviewDb db) throws OrmException {
-    SystemConfig s = SystemConfig.create();
-    try {
-      s.sitePath = site_path.toRealPath().normalize().toString();
-    } catch (IOException e) {
-      s.sitePath = site_path.toAbsolutePath().normalize().toString();
-    }
-    db.systemConfig().insert(Collections.singleton(s));
-    return s;
-  }
+  /**
+   * Create the schema only if it does not already exist.
+   *
+   * <p>Succeeds if the schema does exist.
+   *
+   * @throws IOException an error occurred.
+   * @throws ConfigInvalidException an error occurred.
+   */
+  void ensureCreated() throws IOException, ConfigInvalidException;
 }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
new file mode 100644
index 0000000..e7f3897
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -0,0 +1,226 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.AuditLogFormatter;
+import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupNameNotes;
+import com.google.gerrit.server.group.db.InternalGroupCreation;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+// TODO(dborowitz): The current NoteDb implementation mirrors the old ReviewDb code: this class is
+// called to create the site early on in NoteDbSchemaUpdater#update. This logic is a little
+// confusing and could stand to be reworked. Another smell is that this is an interface only for
+// testing purposes.
+public class SchemaCreatorImpl implements SchemaCreator {
+  private final GitRepositoryManager repoManager;
+  private final AllProjectsCreator allProjectsCreator;
+  private final AllUsersCreator allUsersCreator;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+  private final GroupIndexCollection indexCollection;
+  private final String serverId;
+
+  private final Config config;
+  private final MetricMaker metricMaker;
+  private final AllProjectsName allProjectsName;
+
+  @Inject
+  public SchemaCreatorImpl(
+      GitRepositoryManager repoManager,
+      AllProjectsCreator ap,
+      AllUsersCreator auc,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent au,
+      GroupIndexCollection ic,
+      String serverId,
+      Config config,
+      MetricMaker metricMaker,
+      AllProjectsName apName) {
+    this.repoManager = repoManager;
+    allProjectsCreator = ap;
+    allUsersCreator = auc;
+    this.allUsersName = allUsersName;
+    serverUser = au;
+    indexCollection = ic;
+    this.serverId = serverId;
+
+    this.config = config;
+    this.allProjectsName = apName;
+    this.metricMaker = metricMaker;
+  }
+
+  @Override
+  public void create() throws IOException, ConfigInvalidException {
+    GroupReference admins = createGroupReference("Administrators");
+    GroupReference batchUsers = createGroupReference("Non-Interactive Users");
+
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builder().administratorsGroup(admins).batchUsersGroup(batchUsers).build();
+    allProjectsCreator.create(allProjectsInput);
+    // We have to create the All-Users repository before we can use it to store the groups in it.
+    allUsersCreator.setAdministrators(admins).create();
+
+    // Don't rely on injection to construct Sequences, as the default GitReferenceUpdated has a
+    // thick dependency stack which may not all be available at schema creation time.
+    Sequences seqs =
+        new Sequences(
+            config,
+            repoManager,
+            GitReferenceUpdated.DISABLED,
+            allProjectsName,
+            allUsersName,
+            metricMaker);
+    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+      createAdminsGroup(seqs, allUsersRepo, admins);
+      createBatchUsersGroup(seqs, allUsersRepo, batchUsers, admins.getUUID());
+    }
+  }
+
+  @Override
+  public void ensureCreated() throws IOException, ConfigInvalidException {
+    try {
+      repoManager.openRepository(allProjectsName).close();
+    } catch (RepositoryNotFoundException e) {
+      create();
+    }
+  }
+
+  private void createAdminsGroup(
+      Sequences seqs, Repository allUsersRepo, GroupReference groupReference)
+      throws IOException, ConfigInvalidException {
+    InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setDescription("Gerrit Site Administrators").build();
+
+    createGroup(allUsersRepo, groupCreation, groupUpdate);
+  }
+
+  private void createBatchUsersGroup(
+      Sequences seqs,
+      Repository allUsersRepo,
+      GroupReference groupReference,
+      AccountGroup.UUID adminsGroupUuid)
+      throws IOException, ConfigInvalidException {
+    InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder()
+            .setDescription("Users who perform batch actions on Gerrit")
+            .setOwnerGroupUUID(adminsGroupUuid)
+            .build();
+
+    createGroup(allUsersRepo, groupCreation, groupUpdate);
+  }
+
+  private void createGroup(
+      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      throws ConfigInvalidException, IOException {
+    InternalGroup createdGroup = createGroupInNoteDb(allUsersRepo, groupCreation, groupUpdate);
+    index(createdGroup);
+  }
+
+  private InternalGroup createGroupInNoteDb(
+      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      throws ConfigInvalidException, IOException, DuplicateKeyException {
+    // This method is only executed on a new server which doesn't have any accounts or groups.
+    AuditLogFormatter auditLogFormatter =
+        AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), serverId);
+
+    GroupConfig groupConfig =
+        GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
+    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+
+    AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+    GroupNameNotes groupNameNotes =
+        GroupNameNotes.forNewGroup(
+            allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
+
+    commit(allUsersRepo, groupConfig, groupNameNotes);
+
+    return groupConfig
+        .getLoadedGroup()
+        .orElseThrow(() -> new IllegalStateException("Created group wasn't automatically loaded"));
+  }
+
+  private void commit(
+      Repository allUsersRepo, GroupConfig groupConfig, GroupNameNotes groupNameNotes)
+      throws IOException {
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(allUsersRepo, batchRefUpdate)) {
+      groupConfig.commit(metaDataUpdate);
+    }
+    // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(allUsersRepo, batchRefUpdate)) {
+      groupNameNotes.commit(metaDataUpdate);
+    }
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+  }
+
+  private MetaDataUpdate createMetaDataUpdate(
+      Repository allUsersRepo, @Nullable BatchRefUpdate batchRefUpdate) {
+    MetaDataUpdate metaDataUpdate =
+        new MetaDataUpdate(
+            GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo, batchRefUpdate);
+    metaDataUpdate.getCommitBuilder().setAuthor(serverUser);
+    metaDataUpdate.getCommitBuilder().setCommitter(serverUser);
+    return metaDataUpdate;
+  }
+
+  private void index(InternalGroup group) {
+    for (GroupIndex groupIndex : indexCollection.getWriteIndexes()) {
+      groupIndex.replace(group);
+    }
+  }
+
+  private GroupReference createGroupReference(String name) {
+    AccountGroup.UUID groupUuid = GroupUUID.make(name, serverUser);
+    return new GroupReference(groupUuid, name);
+  }
+
+  private InternalGroupCreation getGroupCreation(Sequences seqs, GroupReference groupReference) {
+    int next = seqs.nextGroupId();
+    return InternalGroupCreation.builder()
+        .setNameKey(AccountGroup.nameKey(groupReference.getName()))
+        .setId(AccountGroup.id(next))
+        .setGroupUUID(groupReference.getUUID())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/SchemaModule.java b/java/com/google/gerrit/server/schema/SchemaModule.java
index 9ce19fe..ff2073d 100644
--- a/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -27,9 +27,10 @@
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
 import org.eclipse.jgit.lib.PersonIdent;
 
-/** Validate the schema and connect to Git. */
+/** Bindings for low-level Gerrit schema data. */
 public class SchemaModule extends FactoryModule {
   @Override
   protected void configure() {
@@ -49,5 +50,11 @@
         .annotatedWith(GerritServerId.class)
         .toProvider(GerritServerIdProvider.class)
         .in(SINGLETON);
+
+    // It feels wrong to have this binding in a seemingly unrelated module, but it's a dependency of
+    // SchemaCreatorImpl, so it's needed.
+    // TODO(dborowitz): Is there any way to untangle this?
+    bind(GroupIndexCollection.class);
+    bind(SchemaCreator.class).to(SchemaCreatorImpl.class);
   }
 }
diff --git a/java/com/google/gerrit/server/schema/SchemaUpdater.java b/java/com/google/gerrit/server/schema/SchemaUpdater.java
deleted file mode 100644
index 266fbaa..0000000
--- a/java/com/google/gerrit/server/schema/SchemaUpdater.java
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.Provider;
-import com.google.inject.Stage;
-import java.io.IOException;
-import java.sql.SQLException;
-import java.util.Collections;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-
-/** Creates or updates the current database schema. */
-public class SchemaUpdater {
-  private final SchemaFactory<ReviewDb> schema;
-  private final SitePaths site;
-  private final SchemaCreator creator;
-  private final Provider<SchemaVersion> updater;
-
-  @Inject
-  SchemaUpdater(
-      @ReviewDbFactory SchemaFactory<ReviewDb> schema,
-      SitePaths site,
-      SchemaCreator creator,
-      Injector parent) {
-    this.schema = schema;
-    this.site = site;
-    this.creator = creator;
-    this.updater = buildInjector(parent).getProvider(SchemaVersion.class);
-  }
-
-  private static Injector buildInjector(Injector parent) {
-    // Use DEVELOPMENT mode to allow lazy initialization of the
-    // graph. This avoids touching ancient schema versions that
-    // are behind this installation's current version.
-    return Guice.createInjector(
-        Stage.DEVELOPMENT,
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(SchemaVersion.class).to(SchemaVersion.C);
-
-            for (Key<?> k :
-                new Key<?>[] {
-                  Key.get(PersonIdent.class, GerritPersonIdent.class),
-                  Key.get(String.class, AnonymousCowardName.class),
-                  Key.get(Config.class, GerritServerConfig.class),
-                }) {
-              rebind(parent, k);
-            }
-
-            for (Class<?> c :
-                new Class<?>[] {
-                  AllProjectsName.class,
-                  AllUsersCreator.class,
-                  AllUsersName.class,
-                  GitRepositoryManager.class,
-                  SitePaths.class,
-                  SystemGroupBackend.class,
-                }) {
-              rebind(parent, Key.get(c));
-            }
-          }
-
-          private <T> void rebind(Injector parent, Key<T> c) {
-            bind(c).toProvider(parent.getProvider(c));
-          }
-        });
-  }
-
-  public void update(UpdateUI ui) throws OrmException {
-    try (ReviewDb db = ReviewDbUtil.unwrapDb(schema.open())) {
-
-      final SchemaVersion u = updater.get();
-      final CurrentSchemaVersion version = getSchemaVersion(db);
-      if (version == null) {
-        try {
-          creator.create(db);
-        } catch (IOException | ConfigInvalidException e) {
-          throw new OrmException("Cannot initialize schema", e);
-        }
-
-      } else {
-        try {
-          u.check(ui, version, db);
-        } catch (SQLException e) {
-          throw new OrmException("Cannot upgrade schema", e);
-        }
-
-        updateSystemConfig(db);
-      }
-    }
-  }
-
-  @VisibleForTesting
-  public SchemaVersion getLatestSchemaVersion() {
-    return updater.get();
-  }
-
-  private CurrentSchemaVersion getSchemaVersion(ReviewDb db) {
-    try {
-      return db.schemaVersion().get(new CurrentSchemaVersion.Key());
-    } catch (OrmException e) {
-      return null;
-    }
-  }
-
-  private void updateSystemConfig(ReviewDb db) throws OrmException {
-    final SystemConfig sc = db.systemConfig().get(new SystemConfig.Key());
-    if (sc == null) {
-      throw new OrmException("No record in system_config table");
-    }
-    try {
-      sc.sitePath = site.site_path.toRealPath().normalize().toString();
-    } catch (IOException e) {
-      sc.sitePath = site.site_path.toAbsolutePath().normalize().toString();
-    }
-    db.systemConfig().update(Collections.singleton(sc));
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/SchemaVersion.java b/java/com/google/gerrit/server/schema/SchemaVersion.java
deleted file mode 100644
index e8a59d0..0000000
--- a/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ /dev/null
@@ -1,214 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Stopwatch;
-import com.google.common.collect.Lists;
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcExecutor;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Provider;
-import java.sql.PreparedStatement;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-/** A version of the database schema. */
-public abstract class SchemaVersion {
-  /** The current schema version. */
-  public static final Class<Schema_168> C = Schema_168.class;
-
-  public static int getBinaryVersion() {
-    return guessVersion(C);
-  }
-
-  private final Provider<? extends SchemaVersion> prior;
-  private final int versionNbr;
-
-  protected SchemaVersion(Provider<? extends SchemaVersion> prior) {
-    this.prior = prior;
-    this.versionNbr = guessVersion(getClass());
-  }
-
-  public static int guessVersion(Class<?> c) {
-    String n = c.getName();
-    n = n.substring(n.lastIndexOf('_') + 1);
-    while (n.startsWith("0")) {
-      n = n.substring(1);
-    }
-    return Integer.parseInt(n);
-  }
-
-  /** @return the {@link CurrentSchemaVersion#versionNbr} this step targets. */
-  public final int getVersionNbr() {
-    return versionNbr;
-  }
-
-  @VisibleForTesting
-  public final SchemaVersion getPrior() {
-    return prior.get();
-  }
-
-  public final void check(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
-      throws OrmException, SQLException {
-    if (curr.versionNbr == versionNbr) {
-      // Nothing to do, we are at the correct schema.
-    } else if (curr.versionNbr > versionNbr) {
-      throw new OrmException(
-          "Cannot downgrade database schema from version "
-              + curr.versionNbr
-              + " to "
-              + versionNbr
-              + ".");
-    } else {
-      upgradeFrom(ui, curr, db);
-    }
-  }
-
-  /** Runs check on the prior schema version, and then upgrades. */
-  private void upgradeFrom(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
-      throws OrmException, SQLException {
-    List<SchemaVersion> pending = pending(curr.versionNbr);
-    updateSchema(pending, ui, db);
-    migrateData(pending, ui, curr, db);
-
-    JdbcSchema s = (JdbcSchema) db;
-    final List<String> pruneList = new ArrayList<>();
-    s.pruneSchema(
-        new StatementExecutor() {
-          @Override
-          public void execute(String sql) {
-            pruneList.add(sql);
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        });
-
-    try (JdbcExecutor e = new JdbcExecutor(s)) {
-      if (!pruneList.isEmpty()) {
-        ui.pruneSchema(e, pruneList);
-      }
-    }
-  }
-
-  private List<SchemaVersion> pending(int curr) {
-    List<SchemaVersion> r = Lists.newArrayListWithCapacity(versionNbr - curr);
-    for (SchemaVersion v = this; curr < v.getVersionNbr(); v = v.prior.get()) {
-      r.add(v);
-    }
-    Collections.reverse(r);
-    return r;
-  }
-
-  private void updateSchema(List<SchemaVersion> pending, UpdateUI ui, ReviewDb db)
-      throws OrmException, SQLException {
-    for (SchemaVersion v : pending) {
-      ui.message(String.format("Upgrading schema to %d ...", v.getVersionNbr()));
-      v.preUpdateSchema(db);
-    }
-
-    JdbcSchema s = (JdbcSchema) db;
-    try (JdbcExecutor e = new JdbcExecutor(s)) {
-      s.updateSchema(e);
-    }
-  }
-
-  /**
-   * Invoked before updateSchema adds new columns/tables.
-   *
-   * @param db open database handle.
-   * @throws OrmException if a Gerrit-specific exception occurred.
-   * @throws SQLException if an underlying SQL exception occurred.
-   */
-  protected void preUpdateSchema(ReviewDb db) throws OrmException, SQLException {}
-
-  private void migrateData(
-      List<SchemaVersion> pending, UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
-      throws OrmException, SQLException {
-    for (SchemaVersion v : pending) {
-      Stopwatch sw = Stopwatch.createStarted();
-      ui.message(String.format("Migrating data to schema %d ...", v.getVersionNbr()));
-      v.migrateData(db, ui);
-      v.finish(curr, db);
-      ui.message(String.format("\t> Done (%.3f s)", sw.elapsed(TimeUnit.MILLISECONDS) / 1000d));
-    }
-  }
-
-  /**
-   * Invoked between updateSchema (adds new columns/tables) and pruneSchema (removes deleted
-   * columns/tables).
-   *
-   * @param db open database handle.
-   * @param ui interface for interacting with the user.
-   * @throws OrmException if a Gerrit-specific exception occurred.
-   * @throws SQLException if an underlying SQL exception occurred.
-   */
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {}
-
-  /** Mark the current schema version. */
-  protected void finish(CurrentSchemaVersion curr, ReviewDb db) throws OrmException {
-    curr.versionNbr = versionNbr;
-    db.schemaVersion().update(Collections.singleton(curr));
-  }
-
-  /** Rename an existing table. */
-  protected static void renameTable(ReviewDb db, String from, String to) throws OrmException {
-    JdbcSchema s = (JdbcSchema) db;
-    try (JdbcExecutor e = new JdbcExecutor(s)) {
-      s.renameTable(e, from, to);
-    }
-  }
-
-  /** Rename an existing column. */
-  protected static void renameColumn(ReviewDb db, String table, String from, String to)
-      throws OrmException {
-    JdbcSchema s = (JdbcSchema) db;
-    try (JdbcExecutor e = new JdbcExecutor(s)) {
-      s.renameColumn(e, table, from, to);
-    }
-  }
-
-  /** Execute an SQL statement. */
-  protected static void execute(ReviewDb db, String sql) throws SQLException {
-    try (Statement s = newStatement(db)) {
-      s.execute(sql);
-    }
-  }
-
-  /** Open a new single statement. */
-  protected static Statement newStatement(ReviewDb db) throws SQLException {
-    return ((JdbcSchema) db).getConnection().createStatement();
-  }
-
-  /** Open a new prepared statement. */
-  protected static PreparedStatement prepareStatement(ReviewDb db, String sql) throws SQLException {
-    return ((JdbcSchema) db).getConnection().prepareStatement(sql);
-  }
-
-  /** Open a new statement executor. */
-  protected static JdbcExecutor newExecutor(ReviewDb db) throws OrmException {
-    return new JdbcExecutor(((JdbcSchema) db).getConnection());
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/SchemaVersionCheck.java b/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
deleted file mode 100644
index bdc15f4..0000000
--- a/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.ProvisionException;
-
-/** Validates the current schema version. */
-public class SchemaVersionCheck implements LifecycleListener {
-  public static Module module() {
-    return new LifecycleModule() {
-      @Override
-      protected void configure() {
-        listener().to(SchemaVersionCheck.class);
-      }
-    };
-  }
-
-  private final SchemaFactory<ReviewDb> schema;
-  private final SitePaths site;
-
-  @Inject
-  public SchemaVersionCheck(SchemaFactory<ReviewDb> schemaFactory, SitePaths site) {
-    this.schema = schemaFactory;
-    this.site = site;
-  }
-
-  @Override
-  public void start() {
-    try (ReviewDb db = schema.open()) {
-      final CurrentSchemaVersion currentVer = getSchemaVersion(db);
-      final int expectedVer = SchemaVersion.getBinaryVersion();
-
-      if (currentVer == null) {
-        throw new ProvisionException(
-            "Schema not yet initialized."
-                + "  Run init to initialize the schema:\n"
-                + "$ java -jar gerrit.war init -d "
-                + site.site_path.toAbsolutePath());
-      }
-      if (currentVer.versionNbr < expectedVer) {
-        throw new ProvisionException(
-            "Unsupported schema version "
-                + currentVer.versionNbr
-                + "; expected schema version "
-                + expectedVer
-                + ".  Run init to upgrade:\n"
-                + "$ java -jar "
-                + site.gerrit_war.toAbsolutePath()
-                + " init -d "
-                + site.site_path.toAbsolutePath());
-      } else if (currentVer.versionNbr > expectedVer) {
-        throw new ProvisionException(
-            "Unsupported schema version "
-                + currentVer.versionNbr
-                + "; expected schema version "
-                + expectedVer
-                + ". Downgrade is not supported.");
-      }
-    } catch (OrmException e) {
-      throw new ProvisionException("Cannot read schema_version", e);
-    }
-  }
-
-  @Override
-  public void stop() {}
-
-  private CurrentSchemaVersion getSchemaVersion(ReviewDb db) {
-    try {
-      return db.schemaVersion().get(new CurrentSchemaVersion.Key());
-    } catch (OrmException e) {
-      return null;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_100.java b/java/com/google/gerrit/server/schema/Schema_100.java
deleted file mode 100644
index 0902194..0000000
--- a/java/com/google/gerrit/server/schema/Schema_100.java
+++ /dev/null
@@ -1,27 +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.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_100 extends SchemaVersion {
-  @Inject
-  Schema_100(Provider<Schema_99> prior) {
-    super(prior);
-  }
-
-  // No database migration; merges are rechecked on reindex.
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_101.java b/java/com/google/gerrit/server/schema/Schema_101.java
deleted file mode 100644
index ccbb2de..0000000
--- a/java/com/google/gerrit/server/schema/Schema_101.java
+++ /dev/null
@@ -1,144 +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.schema;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcExecutor;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.schema.ColumnModel;
-import com.google.gwtorm.schema.RelationModel;
-import com.google.gwtorm.schema.java.JavaSchemaModel;
-import com.google.gwtorm.schema.sql.DialectPostgreSQL;
-import com.google.gwtorm.schema.sql.SqlDialect;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.Connection;
-import java.sql.DatabaseMetaData;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.TreeMap;
-
-public class Schema_101 extends SchemaVersion {
-
-  private static class PrimaryKey {
-    String oldNameInDb;
-    List<String> cols;
-  }
-
-  private Connection conn;
-  private SqlDialect dialect;
-
-  @Inject
-  Schema_101(Provider<Schema_100> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    conn = ((JdbcSchema) db).getConnection();
-    dialect = ((JdbcSchema) db).getDialect();
-    Map<String, PrimaryKey> corrections = findPKUpdates();
-    if (corrections.isEmpty()) {
-      return;
-    }
-
-    ui.message("Wrong Primary Key Column Order Detected");
-    ui.message("The following tables are affected:");
-    ui.message(Joiner.on(", ").join(corrections.keySet()));
-    ui.message("fixing primary keys...");
-    try (JdbcExecutor executor = new JdbcExecutor(conn)) {
-      for (Map.Entry<String, PrimaryKey> c : corrections.entrySet()) {
-        ui.message(String.format("  table: %s ... ", c.getKey()));
-        recreatePK(executor, c.getKey(), c.getValue(), ui);
-        ui.message("done");
-      }
-      ui.message("done");
-    }
-  }
-
-  private Map<String, PrimaryKey> findPKUpdates() throws OrmException, SQLException {
-    Map<String, PrimaryKey> corrections = new TreeMap<>();
-    DatabaseMetaData meta = conn.getMetaData();
-    JavaSchemaModel jsm = new JavaSchemaModel(ReviewDb.class);
-    for (RelationModel rm : jsm.getRelations()) {
-      String tableName = rm.getRelationName();
-      List<String> expectedPKCols = relationPK(rm);
-      PrimaryKey actualPK = dbTablePK(meta, tableName);
-      if (!expectedPKCols.equals(actualPK.cols)) {
-        actualPK.cols = expectedPKCols;
-        corrections.put(tableName, actualPK);
-      }
-    }
-    return corrections;
-  }
-
-  private List<String> relationPK(RelationModel rm) {
-    Collection<ColumnModel> cols = rm.getPrimaryKeyColumns();
-    List<String> pk = new ArrayList<>(cols.size());
-    for (ColumnModel cm : cols) {
-      pk.add(cm.getColumnName().toLowerCase(Locale.US));
-    }
-    return pk;
-  }
-
-  private PrimaryKey dbTablePK(DatabaseMetaData meta, String tableName) throws SQLException {
-    if (meta.storesUpperCaseIdentifiers()) {
-      tableName = tableName.toUpperCase();
-    } else if (meta.storesLowerCaseIdentifiers()) {
-      tableName = tableName.toLowerCase();
-    }
-
-    try (ResultSet cols = meta.getPrimaryKeys(null, null, tableName)) {
-      PrimaryKey pk = new PrimaryKey();
-      Map<Short, String> seqToName = new TreeMap<>();
-      while (cols.next()) {
-        seqToName.put(cols.getShort("KEY_SEQ"), cols.getString("COLUMN_NAME"));
-        if (pk.oldNameInDb == null) {
-          pk.oldNameInDb = cols.getString("PK_NAME");
-        }
-      }
-
-      pk.cols = new ArrayList<>(seqToName.size());
-      for (String name : seqToName.values()) {
-        pk.cols.add(name.toLowerCase(Locale.US));
-      }
-      return pk;
-    }
-  }
-
-  private void recreatePK(StatementExecutor executor, String tableName, PrimaryKey pk, UpdateUI ui)
-      throws OrmException {
-    if (pk.oldNameInDb == null) {
-      ui.message(String.format("warning: primary key for table %s didn't exist ... ", tableName));
-    } else {
-      if (dialect instanceof DialectPostgreSQL) {
-        // postgresql doesn't support the ALTER TABLE foo DROP PRIMARY KEY form
-        executor.execute("ALTER TABLE " + tableName + " DROP CONSTRAINT " + pk.oldNameInDb);
-      } else {
-        executor.execute("ALTER TABLE " + tableName + " DROP PRIMARY KEY");
-      }
-    }
-    executor.execute(
-        "ALTER TABLE " + tableName + " ADD PRIMARY KEY(" + Joiner.on(",").join(pk.cols) + ")");
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_102.java b/java/com/google/gerrit/server/schema/Schema_102.java
deleted file mode 100644
index 1c1aa55..0000000
--- a/java/com/google/gerrit/server/schema/Schema_102.java
+++ /dev/null
@@ -1,70 +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.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.schema.sql.DialectPostgreSQL;
-import com.google.gwtorm.schema.sql.SqlDialect;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-import java.util.Set;
-import java.util.regex.Pattern;
-
-public class Schema_102 extends SchemaVersion {
-  @Inject
-  Schema_102(Provider<Schema_101> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    JdbcSchema schema = (JdbcSchema) db;
-    SqlDialect dialect = schema.getDialect();
-    try (StatementExecutor e = newExecutor(db)) {
-      // Drop left over indexes that were missed to be removed in schema 84.
-      // See "Delete SQL index support" commit for more details:
-      // d4ae3a16d5e1464574bd04f429a63eb9c02b3b43
-      Pattern pattern =
-          Pattern.compile("^changes_(allOpen|allClosed|byBranchClosed)$", Pattern.CASE_INSENSITIVE);
-      String table = "changes";
-      Set<String> listIndexes = dialect.listIndexes(schema.getConnection(), table);
-      for (String index : listIndexes) {
-        if (pattern.matcher(index).matches()) {
-          dialect.dropIndex(e, table, index);
-        }
-      }
-
-      dialect.dropIndex(e, table, "changes_byProjectOpen");
-      if (dialect instanceof DialectPostgreSQL) {
-        e.execute(
-            "CREATE INDEX changes_byProjectOpen"
-                + " ON "
-                + table
-                + " (dest_project_name, last_updated_on)"
-                + " WHERE open = 'Y'");
-      } else {
-        e.execute(
-            "CREATE INDEX changes_byProjectOpen"
-                + " ON "
-                + table
-                + " (open, dest_project_name, last_updated_on)");
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_103.java b/java/com/google/gerrit/server/schema/Schema_103.java
deleted file mode 100644
index 60a5213..0000000
--- a/java/com/google/gerrit/server/schema/Schema_103.java
+++ /dev/null
@@ -1,27 +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.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_103 extends SchemaVersion {
-  @Inject
-  Schema_103(Provider<Schema_102> prior) {
-    super(prior);
-  }
-
-  // Adds originalSubject column
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_104.java b/java/com/google/gerrit/server/schema/Schema_104.java
deleted file mode 100644
index bebdaca..0000000
--- a/java/com/google/gerrit/server/schema/Schema_104.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_104 extends SchemaVersion {
-  @Inject
-  Schema_104(Provider<Schema_103> prior) {
-    super(prior);
-  }
-
-  // Remove old change screen
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_105.java b/java/com/google/gerrit/server/schema/Schema_105.java
deleted file mode 100644
index dd5e71a7..0000000
--- a/java/com/google/gerrit/server/schema/Schema_105.java
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.schema.sql.SqlDialect;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-
-public class Schema_105 extends SchemaVersion {
-  private static final String TABLE = "changes";
-
-  @Inject
-  Schema_105(Provider<Schema_104> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws SQLException, OrmException {
-    JdbcSchema schema = (JdbcSchema) db;
-    SqlDialect dialect = schema.getDialect();
-
-    Map<String, OrmException> errors = new HashMap<>();
-    try (StatementExecutor e = newExecutor(db)) {
-      for (String index : listChangesIndexes(schema)) {
-        ui.message("Dropping index " + index + " on table " + TABLE);
-        try {
-          dialect.dropIndex(e, TABLE, index);
-        } catch (OrmException err) {
-          errors.put(index, err);
-        }
-      }
-    }
-
-    for (String index : listChangesIndexes(schema)) {
-      String msg = "Failed to drop index " + index;
-      OrmException err = errors.get(index);
-      if (err != null) {
-        msg += ": " + err.getMessage();
-      }
-      ui.message(msg);
-    }
-  }
-
-  private Set<String> listChangesIndexes(JdbcSchema schema) throws SQLException {
-    // List of all changes indexes ever created or dropped, found with the
-    // following command:
-    //   find g* -name \*.sql | xargs git log -i -p -S' index changes_' | grep -io ' index
-    // changes_\w*' | cut -d' ' -f3 | tr A-Z a-z | sort -u
-    // Used rather than listIndexes as we're not sure whether it might include
-    // primary key indexes.
-    Set<String> allChanges =
-        ImmutableSet.of(
-            "changes_allclosed",
-            "changes_allopen",
-            "changes_bybranchclosed",
-            "changes_byownerclosed",
-            "changes_byowneropen",
-            "changes_byproject",
-            "changes_byprojectopen",
-            "changes_key",
-            "changes_submitted");
-    return Sets.intersection(
-        schema.getDialect().listIndexes(schema.getConnection(), TABLE), allChanges);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_106.java b/java/com/google/gerrit/server/schema/Schema_106.java
deleted file mode 100644
index 5bb3669..0000000
--- a/java/com/google/gerrit/server/schema/Schema_106.java
+++ /dev/null
@@ -1,159 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LocalDiskRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.File;
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.SortedSet;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_106 extends SchemaVersion {
-  // we can use multiple threads per CPU as we can expect that threads will be
-  // waiting for IO
-  private static final int THREADS_PER_CPU = 4;
-  private final GitRepositoryManager repoManager;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_106(
-      Provider<Schema_105> prior,
-      GitRepositoryManager repoManager,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    if (!(repoManager instanceof LocalDiskRepositoryManager)) {
-      return;
-    }
-
-    ui.message("listing all repositories ...");
-    SortedSet<Project.NameKey> repoList = repoManager.list();
-    ui.message("done");
-
-    ui.message(String.format("creating reflog files for %s branches ...", RefNames.REFS_CONFIG));
-
-    ExecutorService executorPool = createExecutor(ui, repoList.size());
-    List<Future<Void>> futures = new ArrayList<>();
-
-    for (Project.NameKey project : repoList) {
-      Callable<Void> callable = new ReflogCreator(project);
-      futures.add(executorPool.submit(callable));
-    }
-
-    executorPool.shutdown();
-    try {
-      for (Future<Void> future : futures) {
-        try {
-          future.get();
-        } catch (ExecutionException e) {
-          ui.message(e.getCause().getMessage());
-        }
-      }
-      ui.message("done");
-    } catch (InterruptedException ex) {
-      String msg =
-          String.format(
-              "Migration step 106 was interrupted. "
-                  + "Reflog created in %d of %d repositories only.",
-              countDone(futures), repoList.size());
-      ui.message(msg);
-    }
-  }
-
-  private static int countDone(List<Future<Void>> futures) {
-    int count = 0;
-    for (Future<Void> future : futures) {
-      if (future.isDone()) {
-        count++;
-      }
-    }
-
-    return count;
-  }
-
-  private ExecutorService createExecutor(UpdateUI ui, int repoCount) {
-    int procs = Runtime.getRuntime().availableProcessors();
-    int threads = Math.min(procs * THREADS_PER_CPU, repoCount);
-    ui.message(String.format("... using %d threads ...", threads));
-    return Executors.newFixedThreadPool(threads);
-  }
-
-  private class ReflogCreator implements Callable<Void> {
-    private final Project.NameKey project;
-
-    ReflogCreator(Project.NameKey project) {
-      this.project = project;
-    }
-
-    @Override
-    public Void call() throws IOException {
-      try (Repository repo = repoManager.openRepository(project)) {
-        File metaConfigLog = new File(repo.getDirectory(), "logs/" + RefNames.REFS_CONFIG);
-        if (metaConfigLog.exists()) {
-          return null;
-        }
-
-        if (!metaConfigLog.getParentFile().mkdirs() || !metaConfigLog.createNewFile()) {
-          throw new IOException();
-        }
-
-        ObjectId metaConfigId = repo.resolve(RefNames.REFS_CONFIG);
-        if (metaConfigId != null) {
-          try (PrintWriter writer = new PrintWriter(metaConfigLog, UTF_8.name())) {
-            writer.print(ObjectId.zeroId().name());
-            writer.print(" ");
-            writer.print(metaConfigId.name());
-            writer.print(" ");
-            writer.print(serverUser.toExternalString());
-            writer.print("\t");
-            writer.print("create reflog");
-            writer.println();
-          }
-        }
-        return null;
-      } catch (IOException e) {
-        throw new IOException(
-            String.format(
-                "ERROR: Failed to create reflog file for the %s branch in repository %s",
-                RefNames.REFS_CONFIG, project.get()));
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_107.java b/java/com/google/gerrit/server/schema/Schema_107.java
deleted file mode 100644
index dd8868f..0000000
--- a/java/com/google/gerrit/server/schema/Schema_107.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-import java.sql.Statement;
-
-public class Schema_107 extends SchemaVersion {
-
-  @Inject
-  Schema_107(Provider<Schema_106> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws SQLException {
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement()) {
-      stmt.executeUpdate("UPDATE accounts set mute_common_path_prefixes = 'Y'");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_108.java b/java/com/google/gerrit/server/schema/Schema_108.java
deleted file mode 100644
index dc88f8d..0000000
--- a/java/com/google/gerrit/server/schema/Schema_108.java
+++ /dev/null
@@ -1,191 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.GroupCollector;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedSet;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class Schema_108 extends SchemaVersion {
-  private final GitRepositoryManager repoManager;
-
-  @Inject
-  Schema_108(Provider<Schema_107> prior, GitRepositoryManager repoManager) {
-    super(prior);
-    this.repoManager = repoManager;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    ui.message("Listing all changes ...");
-    SetMultimap<Project.NameKey, Change.Id> openByProject = getOpenChangesByProject(db, ui);
-    ui.message("done");
-
-    ui.message("Updating groups for open changes ...");
-    int i = 0;
-    for (Map.Entry<Project.NameKey, Collection<Change.Id>> e : openByProject.asMap().entrySet()) {
-      try (Repository repo = repoManager.openRepository(e.getKey());
-          RevWalk rw = new RevWalk(repo)) {
-        updateProjectGroups(db, repo, rw, (Set<Change.Id>) e.getValue(), ui);
-      } catch (IOException | NoSuchChangeException err) {
-        throw new OrmException(err);
-      }
-      if (++i % 100 == 0) {
-        ui.message("  done " + i + " projects ...");
-      }
-    }
-    ui.message("done");
-  }
-
-  private void updateProjectGroups(
-      ReviewDb db, Repository repo, RevWalk rw, Set<Change.Id> changes, UpdateUI ui)
-      throws OrmException, IOException {
-    // Match sorting in ReceiveCommits.
-    rw.reset();
-    rw.sort(RevSort.TOPO);
-    rw.sort(RevSort.REVERSE, true);
-
-    RefDatabase refdb = repo.getRefDatabase();
-    for (Ref ref : refdb.getRefs(Constants.R_HEADS).values()) {
-      RevCommit c = maybeParseCommit(rw, ref.getObjectId(), ui);
-      if (c != null) {
-        rw.markUninteresting(c);
-      }
-    }
-
-    ListMultimap<ObjectId, Ref> changeRefsBySha =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    for (Ref ref : refdb.getRefs(RefNames.REFS_CHANGES).values()) {
-      ObjectId id = ref.getObjectId();
-      if (ref.getObjectId() == null) {
-        continue;
-      }
-      id = id.copy();
-      changeRefsBySha.put(id, ref);
-      PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-      if (psId != null && changes.contains(psId.getParentKey())) {
-        patchSetsBySha.put(id, psId);
-        RevCommit c = maybeParseCommit(rw, id, ui);
-        if (c != null) {
-          rw.markStart(c);
-        }
-      }
-    }
-
-    GroupCollector collector = GroupCollector.createForSchemaUpgradeOnly(changeRefsBySha, db);
-    RevCommit c;
-    while ((c = rw.next()) != null) {
-      collector.visit(c);
-    }
-
-    updateGroups(db, collector, patchSetsBySha);
-  }
-
-  private static void updateGroups(
-      ReviewDb db, GroupCollector collector, ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha)
-      throws OrmException {
-    Map<PatchSet.Id, PatchSet> patchSets =
-        db.patchSets().toMap(db.patchSets().get(patchSetsBySha.values()));
-    for (Map.Entry<ObjectId, Collection<String>> e : collector.getGroups().asMap().entrySet()) {
-      for (PatchSet.Id psId : patchSetsBySha.get(e.getKey())) {
-        PatchSet ps = patchSets.get(psId);
-        if (ps != null) {
-          ps.setGroups(ImmutableList.copyOf(e.getValue()));
-        }
-      }
-    }
-
-    db.patchSets().update(patchSets.values());
-  }
-
-  private SetMultimap<Project.NameKey, Change.Id> getOpenChangesByProject(ReviewDb db, UpdateUI ui)
-      throws OrmException {
-    SortedSet<NameKey> projects = repoManager.list();
-    SortedSet<NameKey> nonExistentProjects = Sets.newTreeSet();
-    SetMultimap<Project.NameKey, Change.Id> openByProject =
-        MultimapBuilder.hashKeys().hashSetValues().build();
-    for (Change c : db.changes().all()) {
-      Status status = c.getStatus();
-      if (status != null && status.isClosed()) {
-        continue;
-      }
-
-      NameKey projectKey = c.getProject();
-      if (!projects.contains(projectKey)) {
-        nonExistentProjects.add(projectKey);
-      } else {
-        // The old "submitted" state is not supported anymore
-        // (thus status is null) but it was an opened state and needs
-        // to be migrated as such
-        openByProject.put(projectKey, c.getId());
-      }
-    }
-
-    if (!nonExistentProjects.isEmpty()) {
-      ui.message("Detected open changes referring to the following non-existent projects:");
-      ui.message(Joiner.on(", ").join(nonExistentProjects));
-      ui.message(
-          "It is highly recommended to remove\n"
-              + "the obsolete open changes, comments and patch-sets from your DB.\n");
-    }
-    return openByProject;
-  }
-
-  private static RevCommit maybeParseCommit(RevWalk rw, ObjectId id, UpdateUI ui)
-      throws IOException {
-    if (id != null) {
-      try {
-        RevObject obj = rw.parseAny(id);
-        return (obj instanceof RevCommit) ? (RevCommit) obj : null;
-      } catch (MissingObjectException moe) {
-        ui.message("Missing object: " + id.getName() + "\n");
-      }
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_109.java b/java/com/google/gerrit/server/schema/Schema_109.java
deleted file mode 100644
index c5a6015..0000000
--- a/java/com/google/gerrit/server/schema/Schema_109.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_109 extends SchemaVersion {
-  @Inject
-  Schema_109(Provider<Schema_108> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    try (StatementExecutor e = newExecutor(db)) {
-      e.execute("UPDATE changes SET status = 'n', created_on = created_on WHERE status = 's'");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_110.java b/java/com/google/gerrit/server/schema/Schema_110.java
deleted file mode 100644
index 9e0f112..0000000
--- a/java/com/google/gerrit/server/schema/Schema_110.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_110 extends SchemaVersion {
-  @Inject
-  Schema_110(Provider<Schema_109> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_111.java b/java/com/google/gerrit/server/schema/Schema_111.java
deleted file mode 100644
index 223fdb6..0000000
--- a/java/com/google/gerrit/server/schema/Schema_111.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_111 extends SchemaVersion {
-  @Inject
-  Schema_111(Provider<Schema_110> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_112.java b/java/com/google/gerrit/server/schema/Schema_112.java
deleted file mode 100644
index 3e879bd..0000000
--- a/java/com/google/gerrit/server/schema/Schema_112.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_112 extends SchemaVersion {
-  @Inject
-  Schema_112(Provider<Schema_111> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_113.java b/java/com/google/gerrit/server/schema/Schema_113.java
deleted file mode 100644
index 32d655e..0000000
--- a/java/com/google/gerrit/server/schema/Schema_113.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_113 extends SchemaVersion {
-  @Inject
-  Schema_113(Provider<Schema_112> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_114.java b/java/com/google/gerrit/server/schema/Schema_114.java
deleted file mode 100644
index 85c93d2..0000000
--- a/java/com/google/gerrit/server/schema/Schema_114.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_114 extends SchemaVersion {
-  @Inject
-  Schema_114(Provider<Schema_113> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_115.java b/java/com/google/gerrit/server/schema/Schema_115.java
deleted file mode 100644
index 66b5e1c..0000000
--- a/java/com/google/gerrit/server/schema/Schema_115.java
+++ /dev/null
@@ -1,208 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-
-import com.google.common.base.Preconditions;
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.client.Theme;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.ResultSetMetaData;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class Schema_115 extends SchemaVersion {
-  private final GitRepositoryManager mgr;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_115(
-      Provider<Schema_114> prior,
-      GitRepositoryManager mgr,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.mgr = mgr;
-    this.allUsersName = allUsersName;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    Map<Account.Id, DiffPreferencesInfo> imports = new HashMap<>();
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs = stmt.executeQuery("SELECT * FROM account_diff_preferences")) {
-      Set<String> availableColumns = getColumns(rs);
-      while (rs.next()) {
-        Account.Id accountId = new Account.Id(rs.getInt("id"));
-        DiffPreferencesInfo prefs = new DiffPreferencesInfo();
-        if (availableColumns.contains("context")) {
-          prefs.context = (int) rs.getShort("context");
-        }
-        if (availableColumns.contains("expand_all_comments")) {
-          prefs.expandAllComments = toBoolean(rs.getString("expand_all_comments"));
-        }
-        if (availableColumns.contains("hide_line_numbers")) {
-          prefs.hideLineNumbers = toBoolean(rs.getString("hide_line_numbers"));
-        }
-        if (availableColumns.contains("hide_top_menu")) {
-          prefs.hideTopMenu = toBoolean(rs.getString("hide_top_menu"));
-        }
-        if (availableColumns.contains("ignore_whitespace")) {
-          // Enum with char as value
-          prefs.ignoreWhitespace = toWhitespace(rs.getString("ignore_whitespace"));
-        }
-        if (availableColumns.contains("intraline_difference")) {
-          prefs.intralineDifference = toBoolean(rs.getString("intraline_difference"));
-        }
-        if (availableColumns.contains("line_length")) {
-          prefs.lineLength = rs.getInt("line_length");
-        }
-        if (availableColumns.contains("manual_review")) {
-          prefs.manualReview = toBoolean(rs.getString("manual_review"));
-        }
-        if (availableColumns.contains("render_entire_file")) {
-          prefs.renderEntireFile = toBoolean(rs.getString("render_entire_file"));
-        }
-        if (availableColumns.contains("retain_header")) {
-          prefs.retainHeader = toBoolean(rs.getString("retain_header"));
-        }
-        if (availableColumns.contains("show_line_endings")) {
-          prefs.showLineEndings = toBoolean(rs.getString("show_line_endings"));
-        }
-        if (availableColumns.contains("show_tabs")) {
-          prefs.showTabs = toBoolean(rs.getString("show_tabs"));
-        }
-        if (availableColumns.contains("show_whitespace_errors")) {
-          prefs.showWhitespaceErrors = toBoolean(rs.getString("show_whitespace_errors"));
-        }
-        if (availableColumns.contains("skip_deleted")) {
-          prefs.skipDeleted = toBoolean(rs.getString("skip_deleted"));
-        }
-        if (availableColumns.contains("skip_uncommented")) {
-          prefs.skipUncommented = toBoolean(rs.getString("skip_uncommented"));
-        }
-        if (availableColumns.contains("syntax_highlighting")) {
-          prefs.syntaxHighlighting = toBoolean(rs.getString("syntax_highlighting"));
-        }
-        if (availableColumns.contains("tab_size")) {
-          prefs.tabSize = rs.getInt("tab_size");
-        }
-        if (availableColumns.contains("theme")) {
-          // Enum with name as values; can be null
-          prefs.theme = toTheme(rs.getString("theme"));
-        }
-        if (availableColumns.contains("hide_empty_pane")) {
-          prefs.hideEmptyPane = toBoolean(rs.getString("hide_empty_pane"));
-        }
-        if (availableColumns.contains("auto_hide_diff_table_header")) {
-          prefs.autoHideDiffTableHeader = toBoolean(rs.getString("auto_hide_diff_table_header"));
-        }
-        imports.put(accountId, prefs);
-      }
-    }
-
-    if (imports.isEmpty()) {
-      return;
-    }
-
-    try (Repository git = mgr.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(git)) {
-      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      for (Map.Entry<Account.Id, DiffPreferencesInfo> e : imports.entrySet()) {
-        try (MetaDataUpdate md =
-            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
-          md.getCommitBuilder().setAuthor(serverUser);
-          md.getCommitBuilder().setCommitter(serverUser);
-          VersionedAccountPreferences p = VersionedAccountPreferences.forUser(e.getKey());
-          p.load(md);
-          storeSection(
-              p.getConfig(),
-              UserConfigSections.DIFF,
-              null,
-              e.getValue(),
-              DiffPreferencesInfo.defaults());
-          p.commit(md);
-        }
-      }
-
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    } catch (ConfigInvalidException | IOException ex) {
-      throw new OrmException(ex);
-    }
-  }
-
-  private Set<String> getColumns(ResultSet rs) throws SQLException {
-    ResultSetMetaData metaData = rs.getMetaData();
-    int columnCount = metaData.getColumnCount();
-    Set<String> columns = new HashSet<>(columnCount);
-    for (int i = 1; i <= columnCount; i++) {
-      columns.add(metaData.getColumnLabel(i).toLowerCase());
-    }
-    return columns;
-  }
-
-  private static Theme toTheme(String v) {
-    if (v == null) {
-      return Theme.DEFAULT;
-    }
-    return Theme.valueOf(v);
-  }
-
-  private static Whitespace toWhitespace(String v) {
-    Preconditions.checkNotNull(v);
-    if (v.isEmpty()) {
-      return Whitespace.IGNORE_NONE;
-    }
-    Whitespace r = PatchListKey.WHITESPACE_TYPES.inverse().get(v.charAt(0));
-    if (r == null) {
-      throw new IllegalArgumentException("Cannot find Whitespace type for: " + v);
-    }
-    return r;
-  }
-
-  private static boolean toBoolean(String v) {
-    Preconditions.checkState(!Strings.isNullOrEmpty(v));
-    return v.equals("Y");
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_116.java b/java/com/google/gerrit/server/schema/Schema_116.java
deleted file mode 100644
index 5b018a2..0000000
--- a/java/com/google/gerrit/server/schema/Schema_116.java
+++ /dev/null
@@ -1,25 +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.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_116 extends SchemaVersion {
-  @Inject
-  Schema_116(Provider<Schema_115> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_117.java b/java/com/google/gerrit/server/schema/Schema_117.java
deleted file mode 100644
index 35e6c8a..0000000
--- a/java/com/google/gerrit/server/schema/Schema_117.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.Connection;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.Set;
-
-public class Schema_117 extends SchemaVersion {
-  @Inject
-  Schema_117(Provider<Schema_116> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void preUpdateSchema(ReviewDb db) throws OrmException, SQLException {
-    JdbcSchema schema = (JdbcSchema) db;
-    Connection connection = schema.getConnection();
-    String tableName = "patch_sets";
-    String oldColumnName = "push_certficate";
-    String newColumnName = "push_certificate";
-    Set<String> columns = schema.getDialect().listColumns(connection, tableName);
-    if (columns.contains(oldColumnName)) {
-      renameColumn(db, tableName, oldColumnName, newColumnName);
-    }
-    try (Statement stmt = schema.getConnection().createStatement()) {
-      stmt.execute("ALTER TABLE " + tableName + " MODIFY " + newColumnName + " clob");
-    } catch (SQLException e) {
-      // Ignore.  Type may have already been modified manually.
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_118.java b/java/com/google/gerrit/server/schema/Schema_118.java
deleted file mode 100644
index 8c2c740..0000000
--- a/java/com/google/gerrit/server/schema/Schema_118.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_118 extends SchemaVersion {
-  @Inject
-  Schema_118(Provider<Schema_117> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_119.java b/java/com/google/gerrit/server/schema/Schema_119.java
deleted file mode 100644
index 065f5f2..0000000
--- a/java/com/google/gerrit/server/schema/Schema_119.java
+++ /dev/null
@@ -1,233 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.ANON_GIT;
-import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.ANON_HTTP;
-import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.HTTP;
-import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.REPO_DOWNLOAD;
-import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.SSH;
-import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-
-import com.google.common.base.Preconditions;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.Connection;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class Schema_119 extends SchemaVersion {
-  private static final ImmutableMap<String, String> LEGACY_DISPLAYNAME_MAP =
-      ImmutableMap.<String, String>of(
-          "ANON_GIT", ANON_GIT,
-          "ANON_HTTP", ANON_HTTP,
-          "HTTP", HTTP,
-          "SSH", SSH,
-          "REPO_DOWNLOAD", REPO_DOWNLOAD);
-
-  private final GitRepositoryManager mgr;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_119(
-      Provider<Schema_118> prior,
-      GitRepositoryManager mgr,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.mgr = mgr;
-    this.allUsersName = allUsersName;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    JdbcSchema schema = (JdbcSchema) db;
-    Connection connection = schema.getConnection();
-    String tableName = "accounts";
-    String emailStrategy = "email_strategy";
-    Set<String> columns = schema.getDialect().listColumns(connection, tableName);
-    Map<Account.Id, GeneralPreferencesInfo> imports = new HashMap<>();
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs =
-            stmt.executeQuery(
-                "select "
-                    + "account_id, "
-                    + "maximum_page_size, "
-                    + "show_site_header, "
-                    + "use_flash_clipboard, "
-                    + "download_url, "
-                    + "download_command, "
-                    + (columns.contains(emailStrategy)
-                        ? emailStrategy + ", "
-                        : "copy_self_on_email, ")
-                    + "date_format, "
-                    + "time_format, "
-                    + "relative_date_in_change_table, "
-                    + "diff_view, "
-                    + "size_bar_in_change_table, "
-                    + "legacycid_in_change_table, "
-                    + "review_category_strategy, "
-                    + "mute_common_path_prefixes "
-                    + "from "
-                    + tableName)) {
-      while (rs.next()) {
-        GeneralPreferencesInfo p = new GeneralPreferencesInfo();
-        Account.Id accountId = new Account.Id(rs.getInt(1));
-        p.changesPerPage = (int) rs.getShort(2);
-        p.showSiteHeader = toBoolean(rs.getString(3));
-        p.useFlashClipboard = toBoolean(rs.getString(4));
-        p.downloadScheme = convertToModernNames(rs.getString(5));
-        p.downloadCommand = toDownloadCommand(rs.getString(6));
-        p.emailStrategy = toEmailStrategy(rs.getString(7), columns.contains(emailStrategy));
-        p.dateFormat = toDateFormat(rs.getString(8));
-        p.timeFormat = toTimeFormat(rs.getString(9));
-        p.relativeDateInChangeTable = toBoolean(rs.getString(10));
-        p.diffView = toDiffView(rs.getString(11));
-        p.sizeBarInChangeTable = toBoolean(rs.getString(12));
-        p.legacycidInChangeTable = toBoolean(rs.getString(13));
-        p.reviewCategoryStrategy = toReviewCategoryStrategy(rs.getString(14));
-        p.muteCommonPathPrefixes = toBoolean(rs.getString(15));
-        p.defaultBaseForMerges = GeneralPreferencesInfo.defaults().defaultBaseForMerges;
-        imports.put(accountId, p);
-      }
-    }
-
-    if (imports.isEmpty()) {
-      return;
-    }
-
-    try (Repository git = mgr.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(git)) {
-      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      for (Map.Entry<Account.Id, GeneralPreferencesInfo> e : imports.entrySet()) {
-        try (MetaDataUpdate md =
-            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
-          md.getCommitBuilder().setAuthor(serverUser);
-          md.getCommitBuilder().setCommitter(serverUser);
-          VersionedAccountPreferences p = VersionedAccountPreferences.forUser(e.getKey());
-          p.load(md);
-          storeSection(
-              p.getConfig(),
-              UserConfigSections.GENERAL,
-              null,
-              e.getValue(),
-              GeneralPreferencesInfo.defaults());
-          p.commit(md);
-        }
-      }
-
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    } catch (ConfigInvalidException | IOException ex) {
-      throw new OrmException(ex);
-    }
-  }
-
-  private String convertToModernNames(String s) {
-    return !Strings.isNullOrEmpty(s) && LEGACY_DISPLAYNAME_MAP.containsKey(s)
-        ? LEGACY_DISPLAYNAME_MAP.get(s)
-        : s;
-  }
-
-  private static DownloadCommand toDownloadCommand(String v) {
-    if (v == null) {
-      return DownloadCommand.CHECKOUT;
-    }
-    return DownloadCommand.valueOf(v);
-  }
-
-  private static DateFormat toDateFormat(String v) {
-    if (v == null) {
-      return DateFormat.STD;
-    }
-    return DateFormat.valueOf(v);
-  }
-
-  private static TimeFormat toTimeFormat(String v) {
-    if (v == null) {
-      return TimeFormat.HHMM_12;
-    }
-    return TimeFormat.valueOf(v);
-  }
-
-  private static DiffView toDiffView(String v) {
-    if (v == null) {
-      return DiffView.SIDE_BY_SIDE;
-    }
-    return DiffView.valueOf(v);
-  }
-
-  private static EmailStrategy toEmailStrategy(String v, boolean emailStrategyColumnExists)
-      throws OrmException {
-    if (v == null) {
-      return EmailStrategy.ENABLED;
-    }
-    if (emailStrategyColumnExists) {
-      return EmailStrategy.valueOf(v);
-    }
-    if (v.equals("N")) {
-      // EMAIL_STRATEGY='ENABLED' WHERE (COPY_SELF_ON_EMAIL='N')
-      return EmailStrategy.ENABLED;
-    } else if (v.equals("Y")) {
-      // EMAIL_STRATEGY='CC_ON_OWN_COMMENTS' WHERE (COPY_SELF_ON_EMAIL='Y')
-      return EmailStrategy.CC_ON_OWN_COMMENTS;
-    } else {
-      throw new OrmException("invalid value in accounts.copy_self_on_email: " + v);
-    }
-  }
-
-  private static ReviewCategoryStrategy toReviewCategoryStrategy(String v) {
-    if (v == null) {
-      return ReviewCategoryStrategy.NONE;
-    }
-    return ReviewCategoryStrategy.valueOf(v);
-  }
-
-  private static boolean toBoolean(String v) {
-    Preconditions.checkState(!Strings.isNullOrEmpty(v));
-    return v.equals("Y");
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_120.java b/java/com/google/gerrit/server/schema/Schema_120.java
deleted file mode 100644
index f2f3b99..0000000
--- a/java/com/google/gerrit/server/schema/Schema_120.java
+++ /dev/null
@@ -1,119 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.common.data.SubscribeSection;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
-
-public class Schema_120 extends SchemaVersion {
-
-  private final GitRepositoryManager mgr;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_120(
-      Provider<Schema_119> prior,
-      GitRepositoryManager mgr,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.mgr = mgr;
-    this.serverUser = serverUser;
-  }
-
-  private void allowSubmoduleSubscription(Branch.NameKey subbranch, Branch.NameKey superBranch)
-      throws OrmException {
-    try (Repository git = mgr.openRepository(subbranch.getParentKey());
-        RevWalk rw = new RevWalk(git)) {
-      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      try (MetaDataUpdate md =
-          new MetaDataUpdate(GitReferenceUpdated.DISABLED, subbranch.getParentKey(), git, bru)) {
-        md.getCommitBuilder().setAuthor(serverUser);
-        md.getCommitBuilder().setCommitter(serverUser);
-        md.setMessage("Added superproject subscription during upgrade");
-        ProjectConfig pc = ProjectConfig.read(md);
-
-        SubscribeSection s = null;
-        for (SubscribeSection s1 : pc.getSubscribeSections(subbranch)) {
-          if (s1.getProject().equals(superBranch.getParentKey())) {
-            s = s1;
-          }
-        }
-        if (s == null) {
-          s = new SubscribeSection(superBranch.getParentKey());
-          pc.addSubscribeSection(s);
-        }
-        RefSpec newRefSpec = new RefSpec(subbranch.get() + ":" + superBranch.get());
-
-        if (!s.getMatchingRefSpecs().contains(newRefSpec)) {
-          // For the migration we use only exact RefSpecs, we're not trying to
-          // generalize it.
-          s.addMatchingRefSpec(newRefSpec);
-        }
-
-        pc.commit(md);
-      }
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    } catch (ConfigInvalidException | IOException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    ui.message("Generating Superproject subscriptions table to submodule ACLs");
-
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs =
-            stmt.executeQuery(
-                "SELECT "
-                    + "super_project_project_name, "
-                    + "super_project_branch_name, "
-                    + "submodule_project_name, "
-                    + "submodule_branch_name "
-                    + "FROM submodule_subscriptions")) {
-      while (rs.next()) {
-        Project.NameKey superproject = new Project.NameKey(rs.getString(1));
-        Branch.NameKey superbranch = new Branch.NameKey(superproject, rs.getString(2));
-
-        Project.NameKey submodule = new Project.NameKey(rs.getString(3));
-        Branch.NameKey subbranch = new Branch.NameKey(submodule, rs.getString(4));
-
-        allowSubmoduleSubscription(subbranch, superbranch);
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_121.java b/java/com/google/gerrit/server/schema/Schema_121.java
deleted file mode 100644
index 31b42fb..0000000
--- a/java/com/google/gerrit/server/schema/Schema_121.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_121 extends SchemaVersion {
-  @Inject
-  Schema_121(Provider<Schema_120> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_122.java b/java/com/google/gerrit/server/schema/Schema_122.java
deleted file mode 100644
index b5b799d..0000000
--- a/java/com/google/gerrit/server/schema/Schema_122.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_122 extends SchemaVersion {
-  @Inject
-  Schema_122(Provider<Schema_121> prior) {
-    super(prior);
-  }
-
-  // Adds tag column
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_123.java b/java/com/google/gerrit/server/schema/Schema_123.java
deleted file mode 100644
index 31cfd5d..0000000
--- a/java/com/google/gerrit/server/schema/Schema_123.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.Map;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-public class Schema_123 extends SchemaVersion {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  Schema_123(
-      Provider<Schema_122> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    ListMultimap<Account.Id, Change.Id> imports =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs = stmt.executeQuery("SELECT account_id, change_id FROM starred_changes")) {
-      while (rs.next()) {
-        Account.Id accountId = new Account.Id(rs.getInt(1));
-        Change.Id changeId = new Change.Id(rs.getInt(2));
-        imports.put(accountId, changeId);
-      }
-    }
-
-    if (imports.isEmpty()) {
-      return;
-    }
-
-    try (Repository git = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(git)) {
-      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      ObjectId id = StarredChangesUtil.writeLabels(git, StarredChangesUtil.DEFAULT_LABELS);
-      for (Map.Entry<Account.Id, Change.Id> e : imports.entries()) {
-        bru.addCommand(
-            new ReceiveCommand(
-                ObjectId.zeroId(), id, RefNames.refsStarredChanges(e.getValue(), e.getKey())));
-      }
-      bru.execute(rw, new TextProgressMonitor());
-    } catch (IOException | IllegalLabelException ex) {
-      throw new OrmException(ex);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_124.java b/java/com/google/gerrit/server/schema/Schema_124.java
deleted file mode 100644
index 6164fd1..0000000
--- a/java/com/google/gerrit/server/schema/Schema_124.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static java.util.Comparator.comparing;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountSshKey;
-import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.account.VersionedAuthorizedKeys.SimpleSshKeyCreator;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class Schema_124 extends SchemaVersion {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_124(
-      Provider<Schema_123> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    ListMultimap<Account.Id, AccountSshKey> imports =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs =
-            stmt.executeQuery(
-                "SELECT "
-                    + "account_id, "
-                    + "seq, "
-                    + "ssh_public_key, "
-                    + "valid "
-                    + "FROM account_ssh_keys")) {
-      while (rs.next()) {
-        Account.Id accountId = new Account.Id(rs.getInt(1));
-        int seq = rs.getInt(2);
-        String sshPublicKey = rs.getString(3);
-        boolean valid = toBoolean(rs.getString(4));
-        AccountSshKey key = AccountSshKey.create(accountId, seq, sshPublicKey, valid);
-        imports.put(accountId, key);
-      }
-    }
-
-    if (imports.isEmpty()) {
-      return;
-    }
-
-    try (Repository git = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(git)) {
-      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-
-      for (Map.Entry<Account.Id, Collection<AccountSshKey>> e : imports.asMap().entrySet()) {
-        try (MetaDataUpdate md =
-            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
-          md.getCommitBuilder().setAuthor(serverUser);
-          md.getCommitBuilder().setCommitter(serverUser);
-
-          VersionedAuthorizedKeys authorizedKeys =
-              new VersionedAuthorizedKeys(new SimpleSshKeyCreator(), e.getKey());
-          authorizedKeys.load(md);
-          authorizedKeys.setKeys(fixInvalidSequenceNumbers(e.getValue()));
-          authorizedKeys.commit(md);
-        }
-      }
-
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    } catch (ConfigInvalidException | IOException ex) {
-      throw new OrmException(ex);
-    }
-  }
-
-  private Collection<AccountSshKey> fixInvalidSequenceNumbers(Collection<AccountSshKey> keys) {
-    Ordering<AccountSshKey> o = Ordering.from(comparing(AccountSshKey::seq));
-    List<AccountSshKey> fixedKeys = new ArrayList<>(keys);
-    AccountSshKey minKey = o.min(keys);
-    while (minKey.seq() <= 0) {
-      AccountSshKey fixedKey =
-          AccountSshKey.create(
-              minKey.accountId(), Math.max(o.max(keys).seq() + 1, 1), minKey.sshPublicKey());
-      Collections.replaceAll(fixedKeys, minKey, fixedKey);
-      minKey = o.min(fixedKeys);
-    }
-    return fixedKeys;
-  }
-
-  private static boolean toBoolean(String v) {
-    return !Strings.isNullOrEmpty(v) && v.equalsIgnoreCase("Y");
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_125.java b/java/com/google/gerrit/server/schema/Schema_125.java
deleted file mode 100644
index 7aab7c7..0000000
--- a/java/com/google/gerrit/server/schema/Schema_125.java
+++ /dev/null
@@ -1,128 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.schema.AclUtil.grant;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RefPattern;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_125 extends SchemaVersion {
-  private static final String COMMIT_MSG =
-      "Assign default permissions on user branches\n"
-          + "\n"
-          + "By default each user should be able to read and update the own user\n"
-          + "branch. Also the user should be able to approve and submit changes for\n"
-          + "the own user branch. Assign default permissions for this and remove the\n"
-          + "old exclusive read protection from the user branches.\n";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final AllProjectsName allProjectsName;
-  private final SystemGroupBackend systemGroupBackend;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_125(
-      Provider<Schema_124> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      AllProjectsName allProjectsName,
-      SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.allProjectsName = allProjectsName;
-    this.systemGroupBackend = systemGroupBackend;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    try (Repository git = repoManager.openRepository(allUsersName);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
-
-      config
-          .getAccessSection(RefNames.REFS_USERS + "*", true)
-          .remove(new Permission(Permission.READ));
-      GroupReference registered = systemGroupBackend.getGroup(REGISTERED_USERS);
-      AccessSection users =
-          config.getAccessSection(
-              RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
-      grant(config, users, Permission.READ, true, registered);
-      grant(config, users, Permission.PUSH, true, registered);
-      grant(config, users, Permission.SUBMIT, true, registered);
-
-      for (LabelType lt : getLabelTypes(config)) {
-        if ("Code-Review".equals(lt.getName()) || "Verified".equals(lt.getName())) {
-          grant(config, users, lt, lt.getMin().getValue(), lt.getMax().getValue(), registered);
-        }
-      }
-
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage(COMMIT_MSG);
-      config.commit(md);
-    } catch (ConfigInvalidException | IOException ex) {
-      throw new OrmException(ex);
-    }
-  }
-
-  private Collection<LabelType> getLabelTypes(ProjectConfig config)
-      throws IOException, ConfigInvalidException {
-    Map<String, LabelType> labelTypes = new HashMap<>(config.getLabelSections());
-    Project.NameKey parent = config.getProject().getParent(allProjectsName);
-    while (parent != null) {
-      try (Repository git = repoManager.openRepository(parent);
-          MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, parent, git)) {
-        ProjectConfig parentConfig = ProjectConfig.read(md);
-        for (LabelType lt : parentConfig.getLabelSections().values()) {
-          if (!labelTypes.containsKey(lt.getName())) {
-            labelTypes.put(lt.getName(), lt);
-          }
-        }
-        parent = parentConfig.getProject().getParent(allProjectsName);
-      }
-    }
-    return labelTypes.values();
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_126.java b/java/com/google/gerrit/server/schema/Schema_126.java
deleted file mode 100644
index 5dbda72..0000000
--- a/java/com/google/gerrit/server/schema/Schema_126.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.schema.AclUtil.grant;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RefPattern;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_126 extends SchemaVersion {
-  private static final String COMMIT_MSG = "Fix default permissions on user branches";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final SystemGroupBackend systemGroupBackend;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_126(
-      Provider<Schema_125> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.systemGroupBackend = systemGroupBackend;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    try (Repository git = repoManager.openRepository(allUsersName);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
-
-      String refsUsersShardedId = RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}";
-      config.remove(config.getAccessSection(refsUsersShardedId));
-
-      GroupReference registered = systemGroupBackend.getGroup(REGISTERED_USERS);
-      AccessSection users = config.getAccessSection(refsUsersShardedId, true);
-      grant(config, users, Permission.READ, false, true, registered);
-      grant(config, users, Permission.PUSH, false, true, registered);
-      grant(config, users, Permission.SUBMIT, false, true, registered);
-
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage(COMMIT_MSG);
-      config.commit(md);
-    } catch (ConfigInvalidException | IOException ex) {
-      throw new OrmException(ex);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_127.java b/java/com/google/gerrit/server/schema/Schema_127.java
deleted file mode 100644
index d246b75..0000000
--- a/java/com/google/gerrit/server/schema/Schema_127.java
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.config.ThreadSettingsConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.Connection;
-import java.sql.PreparedStatement;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import org.eclipse.jgit.lib.Config;
-
-public class Schema_127 extends SchemaVersion {
-  private static final int MAX_BATCH_SIZE = 1000;
-
-  private final SitePaths sitePaths;
-  private final Config cfg;
-  private final ThreadSettingsConfig threadSettingsConfig;
-
-  @Inject
-  Schema_127(
-      Provider<Schema_126> prior,
-      SitePaths sitePaths,
-      @GerritServerConfig Config cfg,
-      ThreadSettingsConfig threadSettingsConfig) {
-    super(prior);
-    this.sitePaths = sitePaths;
-    this.cfg = cfg;
-    this.threadSettingsConfig = threadSettingsConfig;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    JdbcAccountPatchReviewStore jdbcAccountPatchReviewStore =
-        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(
-            cfg, sitePaths, threadSettingsConfig);
-    jdbcAccountPatchReviewStore.dropTableIfExists();
-    jdbcAccountPatchReviewStore.createTableIfNotExists();
-    try (Connection con = jdbcAccountPatchReviewStore.getConnection();
-        PreparedStatement stmt =
-            con.prepareStatement(
-                "INSERT INTO account_patch_reviews "
-                    + "(account_id, change_id, patch_set_id, file_name) VALUES "
-                    + "(?, ?, ?, ?)")) {
-      int batchCount = 0;
-
-      try (Statement s = newStatement(db);
-          ResultSet rs = s.executeQuery("SELECT * from account_patch_reviews")) {
-        while (rs.next()) {
-          stmt.setInt(1, rs.getInt("account_id"));
-          stmt.setInt(2, rs.getInt("change_id"));
-          stmt.setInt(3, rs.getInt("patch_set_id"));
-          stmt.setString(4, rs.getString("file_name"));
-          stmt.addBatch();
-          batchCount++;
-          if (batchCount >= MAX_BATCH_SIZE) {
-            stmt.executeBatch();
-            batchCount = 0;
-          }
-        }
-      }
-      if (batchCount > 0) {
-        stmt.executeBatch();
-      }
-    } catch (SQLException e) {
-      throw jdbcAccountPatchReviewStore.convertError("insert", e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_128.java b/java/com/google/gerrit/server/schema/Schema_128.java
deleted file mode 100644
index bd6b76a..0000000
--- a/java/com/google/gerrit/server/schema/Schema_128.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.schema.AclUtil.grant;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_128 extends SchemaVersion {
-  private static final String COMMIT_MSG = "Add addPatchSet permission to all projects";
-
-  private final GitRepositoryManager repoManager;
-  private final AllProjectsName allProjectsName;
-  private final SystemGroupBackend systemGroupBackend;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_128(
-      Provider<Schema_127> prior,
-      GitRepositoryManager repoManager,
-      AllProjectsName allProjectsName,
-      SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allProjectsName = allProjectsName;
-    this.systemGroupBackend = systemGroupBackend;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    try (Repository git = repoManager.openRepository(allProjectsName);
-        MetaDataUpdate md =
-            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
-
-      GroupReference registered = systemGroupBackend.getGroup(REGISTERED_USERS);
-      AccessSection refsFor = config.getAccessSection("refs/for/*", true);
-      grant(config, refsFor, Permission.ADD_PATCH_SET, false, false, registered);
-
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage(COMMIT_MSG);
-      config.commit(md);
-    } catch (ConfigInvalidException | IOException ex) {
-      throw new OrmException(ex);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_129.java b/java/com/google/gerrit/server/schema/Schema_129.java
deleted file mode 100644
index 73ce3c3..0000000
--- a/java/com/google/gerrit/server/schema/Schema_129.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-import java.sql.Statement;
-
-public class Schema_129 extends SchemaVersion {
-
-  @Inject
-  Schema_129(Provider<Schema_128> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void preUpdateSchema(ReviewDb db) throws OrmException {
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement()) {
-      stmt.execute("ALTER TABLE patch_sets MODIFY groups clob");
-    } catch (SQLException e) {
-      // Ignore.  Type may have already been modified manually.
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_130.java b/java/com/google/gerrit/server/schema/Schema_130.java
deleted file mode 100644
index 66f2177..0000000
--- a/java/com/google/gerrit/server/schema/Schema_130.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static java.util.stream.Collectors.joining;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.SortedSet;
-import java.util.TreeSet;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_130 extends SchemaVersion {
-  private static final String COMMIT_MSG =
-      "Remove force option from 'Push Annotated Tag' permission\n"
-          + "\n"
-          + "The force option on 'Push Annotated Tag' had no effect and is no longer\n"
-          + "supported.";
-
-  private final GitRepositoryManager repoManager;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_130(
-      Provider<Schema_129> prior,
-      GitRepositoryManager repoManager,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    SortedSet<Project.NameKey> repoList = repoManager.list();
-    SortedSet<Project.NameKey> repoUpgraded = new TreeSet<>();
-    ui.message("\tMigrating " + repoList.size() + " repositories ...");
-    for (Project.NameKey projectName : repoList) {
-      try (Repository git = repoManager.openRepository(projectName);
-          MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, git)) {
-        ProjectConfigSchemaUpdate cfg = ProjectConfigSchemaUpdate.read(md);
-        cfg.removeForceFromPermission("pushTag");
-        if (cfg.isUpdated()) {
-          repoUpgraded.add(projectName);
-        }
-        cfg.save(serverUser, COMMIT_MSG);
-      } catch (ConfigInvalidException | IOException ex) {
-        throw new OrmException("Cannot migrate project " + projectName, ex);
-      }
-    }
-    ui.message("\tMigration completed:  " + repoUpgraded.size() + " repositories updated:");
-    ui.message("\t" + repoUpgraded.stream().map(Project.NameKey::get).collect(joining(" ")));
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_131.java b/java/com/google/gerrit/server/schema/Schema_131.java
deleted file mode 100644
index 3755211..0000000
--- a/java/com/google/gerrit/server/schema/Schema_131.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static java.util.stream.Collectors.joining;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.SortedSet;
-import java.util.TreeSet;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_131 extends SchemaVersion {
-  private static final String COMMIT_MSG =
-      "Rename 'Push Annotated/Signed Tag' permission to 'Create Annotated/Signed Tag'";
-
-  private final GitRepositoryManager repoManager;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_131(
-      Provider<Schema_130> prior,
-      GitRepositoryManager repoManager,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    SortedSet<Project.NameKey> repoList = repoManager.list();
-    SortedSet<Project.NameKey> repoUpgraded = new TreeSet<>();
-    ui.message("\tMigrating " + repoList.size() + " repositories ...");
-    for (Project.NameKey projectName : repoList) {
-      try (Repository git = repoManager.openRepository(projectName);
-          MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, git)) {
-        ProjectConfig config = ProjectConfig.read(md);
-        if (config.hasLegacyPermissions()) {
-          md.getCommitBuilder().setAuthor(serverUser);
-          md.getCommitBuilder().setCommitter(serverUser);
-          md.setMessage(COMMIT_MSG);
-          config.commit(md);
-          repoUpgraded.add(projectName);
-        }
-      } catch (ConfigInvalidException | IOException ex) {
-        throw new OrmException("Cannot migrate project " + projectName, ex);
-      }
-    }
-    ui.message("\tMigration completed:  " + repoUpgraded.size() + " repositories updated:");
-    ui.message("\t" + repoUpgraded.stream().map(Project.NameKey::get).collect(joining(" ")));
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_132.java b/java/com/google/gerrit/server/schema/Schema_132.java
deleted file mode 100644
index 7c1cde8..0000000
--- a/java/com/google/gerrit/server/schema/Schema_132.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_132 extends SchemaVersion {
-  @Inject
-  Schema_132(Provider<Schema_131> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_133.java b/java/com/google/gerrit/server/schema/Schema_133.java
deleted file mode 100644
index 31d330b..0000000
--- a/java/com/google/gerrit/server/schema/Schema_133.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_133 extends SchemaVersion {
-  @Inject
-  Schema_133(Provider<Schema_132> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_134.java b/java/com/google/gerrit/server/schema/Schema_134.java
deleted file mode 100644
index fa01ff3..0000000
--- a/java/com/google/gerrit/server/schema/Schema_134.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_134 extends SchemaVersion {
-  @Inject
-  Schema_134(Provider<Schema_133> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_135.java b/java/com/google/gerrit/server/schema/Schema_135.java
deleted file mode 100644
index 66224c2..0000000
--- a/java/com/google/gerrit/server/schema/Schema_135.java
+++ /dev/null
@@ -1,101 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.Set;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_135 extends SchemaVersion {
-  private static final String COMMIT_MSG =
-      "Allow admins and project owners to create refs/meta/config";
-
-  private final GitRepositoryManager repoManager;
-  private final AllProjectsName allProjectsName;
-  private final SystemGroupBackend systemGroupBackend;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_135(
-      Provider<Schema_134> prior,
-      GitRepositoryManager repoManager,
-      AllProjectsName allProjectsName,
-      SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allProjectsName = allProjectsName;
-    this.systemGroupBackend = systemGroupBackend;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    try (Repository git = repoManager.openRepository(allProjectsName);
-        MetaDataUpdate md =
-            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
-
-      AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
-      Permission createRefsMetaConfigPermission = meta.getPermission(Permission.CREATE, true);
-
-      Set<GroupReference> groups =
-          Stream.concat(
-                  config
-                      .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-                      .getPermission(GlobalCapability.ADMINISTRATE_SERVER, true)
-                      .getRules()
-                      .stream()
-                      .map(PermissionRule::getGroup),
-                  Stream.of(systemGroupBackend.getGroup(PROJECT_OWNERS)))
-              .filter(g -> createRefsMetaConfigPermission.getRule(g) == null)
-              .collect(toSet());
-
-      for (GroupReference group : groups) {
-        createRefsMetaConfigPermission.add(new PermissionRule(config.resolve(group)));
-      }
-
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage(COMMIT_MSG);
-      config.commit(md);
-    } catch (ConfigInvalidException | IOException ex) {
-      throw new OrmException(ex);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_136.java b/java/com/google/gerrit/server/schema/Schema_136.java
deleted file mode 100644
index a4b1c82..0000000
--- a/java/com/google/gerrit/server/schema/Schema_136.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_136 extends SchemaVersion {
-  @Inject
-  Schema_136(Provider<Schema_135> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_137.java b/java/com/google/gerrit/server/schema/Schema_137.java
deleted file mode 100644
index 1b4102f..0000000
--- a/java/com/google/gerrit/server/schema/Schema_137.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/* change the type of SystemConfig#sitePath to CLOB */
-public class Schema_137 extends SchemaVersion {
-  @Inject
-  Schema_137(Provider<Schema_136> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_138.java b/java/com/google/gerrit/server/schema/Schema_138.java
deleted file mode 100644
index f824ee1..0000000
--- a/java/com/google/gerrit/server/schema/Schema_138.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/* Add resolved field to PatchLineComment */
-public class Schema_138 extends SchemaVersion {
-  @Inject
-  Schema_138(Provider<Schema_137> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_139.java b/java/com/google/gerrit/server/schema/Schema_139.java
deleted file mode 100644
index 1d90305..0000000
--- a/java/com/google/gerrit/server/schema/Schema_139.java
+++ /dev/null
@@ -1,211 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Preconditions;
-import com.google.common.base.Strings;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountConfig;
-import com.google.gerrit.server.account.InternalAccountUpdate;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.Collection;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class Schema_139 extends SchemaVersion {
-  private static final String MSG = "Migrate project watches to git";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_139(
-      Provider<Schema_138> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    ListMultimap<Account.Id, ProjectWatch> imports =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs =
-            stmt.executeQuery(
-                "SELECT "
-                    + "account_id, "
-                    + "project_name, "
-                    + "filter, "
-                    + "notify_abandoned_changes, "
-                    + "notify_all_comments, "
-                    + "notify_new_changes, "
-                    + "notify_new_patch_sets, "
-                    + "notify_submitted_changes "
-                    + "FROM account_project_watches")) {
-      while (rs.next()) {
-        Account.Id accountId = new Account.Id(rs.getInt(1));
-        ProjectWatch.Builder b =
-            ProjectWatch.builder()
-                .project(new Project.NameKey(rs.getString(2)))
-                .filter(rs.getString(3))
-                .notifyAbandonedChanges(toBoolean(rs.getString(4)))
-                .notifyAllComments(toBoolean(rs.getString(5)))
-                .notifyNewChanges(toBoolean(rs.getString(6)))
-                .notifyNewPatchSets(toBoolean(rs.getString(7)))
-                .notifySubmittedChanges(toBoolean(rs.getString(8)));
-        imports.put(accountId, b.build());
-      }
-    }
-
-    if (imports.isEmpty()) {
-      return;
-    }
-
-    try (Repository git = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(git)) {
-      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      bru.setRefLogIdent(serverUser);
-      bru.setRefLogMessage(MSG, false);
-
-      for (Map.Entry<Account.Id, Collection<ProjectWatch>> e : imports.asMap().entrySet()) {
-        Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
-        for (ProjectWatch projectWatch : e.getValue()) {
-          ProjectWatchKey key =
-              ProjectWatchKey.create(projectWatch.project(), projectWatch.filter());
-          if (projectWatches.containsKey(key)) {
-            throw new OrmDuplicateKeyException(
-                "Duplicate key for watched project: " + key.toString());
-          }
-          Set<NotifyType> notifyValues = EnumSet.noneOf(NotifyType.class);
-          if (projectWatch.notifyAbandonedChanges()) {
-            notifyValues.add(NotifyType.ABANDONED_CHANGES);
-          }
-          if (projectWatch.notifyAllComments()) {
-            notifyValues.add(NotifyType.ALL_COMMENTS);
-          }
-          if (projectWatch.notifyNewChanges()) {
-            notifyValues.add(NotifyType.NEW_CHANGES);
-          }
-          if (projectWatch.notifyNewPatchSets()) {
-            notifyValues.add(NotifyType.NEW_PATCHSETS);
-          }
-          if (projectWatch.notifySubmittedChanges()) {
-            notifyValues.add(NotifyType.SUBMITTED_CHANGES);
-          }
-          projectWatches.put(key, notifyValues);
-        }
-
-        try (MetaDataUpdate md =
-            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
-          md.getCommitBuilder().setAuthor(serverUser);
-          md.getCommitBuilder().setCommitter(serverUser);
-          md.setMessage(MSG);
-
-          AccountConfig accountConfig = new AccountConfig(e.getKey(), git);
-          accountConfig.load(md);
-          accountConfig.setAccountUpdate(
-              InternalAccountUpdate.builder()
-                  .deleteProjectWatches(accountConfig.getProjectWatches().keySet())
-                  .updateProjectWatches(projectWatches)
-                  .build());
-          accountConfig.commit(md);
-        }
-      }
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    } catch (IOException | ConfigInvalidException ex) {
-      throw new OrmException(ex);
-    }
-  }
-
-  @AutoValue
-  abstract static class ProjectWatch {
-    abstract Project.NameKey project();
-
-    abstract @Nullable String filter();
-
-    abstract boolean notifyAbandonedChanges();
-
-    abstract boolean notifyAllComments();
-
-    abstract boolean notifyNewChanges();
-
-    abstract boolean notifyNewPatchSets();
-
-    abstract boolean notifySubmittedChanges();
-
-    static Builder builder() {
-      return new AutoValue_Schema_139_ProjectWatch.Builder();
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder project(Project.NameKey project);
-
-      abstract Builder filter(@Nullable String filter);
-
-      abstract Builder notifyAbandonedChanges(boolean notifyAbandonedChanges);
-
-      abstract Builder notifyAllComments(boolean notifyAllComments);
-
-      abstract Builder notifyNewChanges(boolean notifyNewChanges);
-
-      abstract Builder notifyNewPatchSets(boolean notifyNewPatchSets);
-
-      abstract Builder notifySubmittedChanges(boolean notifySubmittedChanges);
-
-      abstract ProjectWatch build();
-    }
-  }
-
-  private static boolean toBoolean(String v) {
-    Preconditions.checkState(!Strings.isNullOrEmpty(v));
-    return v.equals("Y");
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_140.java b/java/com/google/gerrit/server/schema/Schema_140.java
deleted file mode 100644
index bdc5f55..0000000
--- a/java/com/google/gerrit/server/schema/Schema_140.java
+++ /dev/null
@@ -1,26 +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.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Remove ChangeMessage sequence. */
-public class Schema_140 extends SchemaVersion {
-  @Inject
-  Schema_140(Provider<Schema_139> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_141.java b/java/com/google/gerrit/server/schema/Schema_141.java
deleted file mode 100644
index c081ea9..0000000
--- a/java/com/google/gerrit/server/schema/Schema_141.java
+++ /dev/null
@@ -1,26 +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.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Add status field to account. */
-public class Schema_141 extends SchemaVersion {
-  @Inject
-  Schema_141(Provider<Schema_140> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_142.java b/java/com/google/gerrit/server/schema/Schema_142.java
deleted file mode 100644
index e67ae2f..0000000
--- a/java/com/google/gerrit/server/schema/Schema_142.java
+++ /dev/null
@@ -1,77 +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.server.schema;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.HashedPassword;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.PreparedStatement;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-
-public class Schema_142 extends SchemaVersion {
-  private static final int MAX_BATCH_SIZE = 1000;
-
-  @Inject
-  Schema_142(Provider<Schema_141> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (PreparedStatement updateStmt =
-        ((JdbcSchema) db)
-            .getConnection()
-            .prepareStatement(
-                "UPDATE account_external_ids " + "SET password = ? " + "WHERE external_id = ?")) {
-      int batchCount = 0;
-
-      try (Statement stmt = newStatement(db);
-          ResultSet rs =
-              stmt.executeQuery("SELECT external_id, password FROM account_external_ids")) {
-        while (rs.next()) {
-          String externalId = rs.getString("external_id");
-          String password = rs.getString("password");
-          if (!ExternalId.Key.parse(externalId).isScheme(SCHEME_USERNAME)
-              || Strings.isNullOrEmpty(password)) {
-            continue;
-          }
-
-          HashedPassword hashed = HashedPassword.fromPassword(password);
-          updateStmt.setString(1, hashed.encode());
-          updateStmt.setString(2, externalId);
-          updateStmt.addBatch();
-          batchCount++;
-          if (batchCount >= MAX_BATCH_SIZE) {
-            updateStmt.executeBatch();
-            batchCount = 0;
-          }
-        }
-      }
-
-      if (batchCount > 0) {
-        updateStmt.executeBatch();
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_143.java b/java/com/google/gerrit/server/schema/Schema_143.java
deleted file mode 100644
index b190b29..0000000
--- a/java/com/google/gerrit/server/schema/Schema_143.java
+++ /dev/null
@@ -1,26 +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.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Add isPrivate field to change. */
-public class Schema_143 extends SchemaVersion {
-  @Inject
-  Schema_143(Provider<Schema_142> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_144.java b/java/com/google/gerrit/server/schema/Schema_144.java
deleted file mode 100644
index f1c9745..0000000
--- a/java/com/google/gerrit/server/schema/Schema_144.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-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.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_144 extends SchemaVersion {
-  private static final String COMMIT_MSG = "Import external IDs from ReviewDb";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverIdent;
-
-  @Inject
-  Schema_144(
-      Provider<Schema_143> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverIdent) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverIdent = serverIdent;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    Set<ExternalId> toAdd = new HashSet<>();
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs =
-            stmt.executeQuery(
-                "SELECT "
-                    + "account_id, "
-                    + "email_address, "
-                    + "password, "
-                    + "external_id "
-                    + "FROM account_external_ids")) {
-      while (rs.next()) {
-        Account.Id accountId = new Account.Id(rs.getInt(1));
-        String email = rs.getString(2);
-        String password = rs.getString(3);
-        String externalId = rs.getString(4);
-
-        toAdd.add(ExternalId.create(ExternalId.Key.parse(externalId), accountId, email, password));
-      }
-    }
-
-    try {
-      try (Repository repo = repoManager.openRepository(allUsersName)) {
-        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
-        extIdNotes.upsert(toAdd);
-        try (MetaDataUpdate metaDataUpdate =
-            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo)) {
-          metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
-          metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
-          metaDataUpdate.getCommitBuilder().setMessage(COMMIT_MSG);
-          extIdNotes.commit(metaDataUpdate);
-        }
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException("Failed to migrate external IDs to NoteDb", e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_145.java b/java/com/google/gerrit/server/schema/Schema_145.java
deleted file mode 100644
index 6ccb5d8..0000000
--- a/java/com/google/gerrit/server/schema/Schema_145.java
+++ /dev/null
@@ -1,50 +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.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.schema.sql.SqlDialect;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-
-/** Create account_external_ids_byEmail index. */
-public class Schema_145 extends SchemaVersion {
-
-  @Inject
-  Schema_145(Provider<Schema_144> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    JdbcSchema schema = (JdbcSchema) db;
-    SqlDialect dialect = schema.getDialect();
-    try (StatementExecutor e = newExecutor(db)) {
-      try {
-        dialect.dropIndex(e, "account_external_ids", "account_external_ids_byEmail");
-      } catch (OrmException ex) {
-        // Ignore.  The index did not exist.
-      }
-      e.execute(
-          "CREATE INDEX account_external_ids_byEmail"
-              + " ON account_external_ids"
-              + " (email_address)");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_146.java b/java/com/google/gerrit/server/schema/Schema_146.java
deleted file mode 100644
index 503ed7b..0000000
--- a/java/com/google/gerrit/server/schema/Schema_146.java
+++ /dev/null
@@ -1,196 +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.server.schema;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.sql.Timestamp;
-import java.util.HashMap;
-import java.util.Map;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Constants;
-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.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Make sure that for every account a user branch exists that has an initial empty commit with the
- * registration date as commit time.
- *
- * <p>For accounts that don't have a user branch yet the user branch is created with an initial
- * empty commit that has the registration date as commit time.
- *
- * <p>For accounts that already have a user branch the user branch is rewritten and an initial empty
- * commit with the registration date as commit time is inserted (if such a commit doesn't exist
- * yet).
- */
-public class Schema_146 extends SchemaVersion {
-  private static final String CREATE_ACCOUNT_MSG = "Create Account";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverIdent;
-
-  @Inject
-  Schema_146(
-      Provider<Schema_145> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverIdent) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverIdent = serverIdent;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter oi = repo.newObjectInserter()) {
-      ObjectId emptyTree = emptyTree(oi);
-
-      for (Map.Entry<Account.Id, Timestamp> e : scanAccounts(db).entrySet()) {
-        String refName = RefNames.refsUsers(e.getKey());
-        Ref ref = repo.exactRef(refName);
-        if (ref != null) {
-          rewriteUserBranch(repo, rw, oi, emptyTree, ref, e.getValue());
-        } else {
-          createUserBranch(repo, oi, emptyTree, e.getKey(), e.getValue());
-        }
-      }
-    } catch (IOException e) {
-      throw new OrmException("Failed to rewrite user branches.", e);
-    }
-  }
-
-  private void rewriteUserBranch(
-      Repository repo,
-      RevWalk rw,
-      ObjectInserter oi,
-      ObjectId emptyTree,
-      Ref ref,
-      Timestamp registeredOn)
-      throws IOException {
-    ObjectId current = createInitialEmptyCommit(oi, emptyTree, registeredOn);
-
-    rw.reset();
-    rw.sort(RevSort.TOPO);
-    rw.sort(RevSort.REVERSE, true);
-    rw.markStart(rw.parseCommit(ref.getObjectId()));
-
-    RevCommit c;
-    while ((c = rw.next()) != null) {
-      if (isInitialEmptyCommit(emptyTree, c)) {
-        return;
-      }
-
-      CommitBuilder cb = new CommitBuilder();
-      cb.setParentId(current);
-      cb.setTreeId(c.getTree());
-      cb.setAuthor(c.getAuthorIdent());
-      cb.setCommitter(c.getCommitterIdent());
-      cb.setMessage(c.getFullMessage());
-      cb.setEncoding(c.getEncoding());
-      current = oi.insert(cb);
-    }
-
-    oi.flush();
-
-    RefUpdate ru = repo.updateRef(ref.getName());
-    ru.setExpectedOldObjectId(ref.getObjectId());
-    ru.setNewObjectId(current);
-    ru.setForceUpdate(true);
-    ru.setRefLogIdent(serverIdent);
-    ru.setRefLogMessage(getClass().getSimpleName(), true);
-    Result result = ru.update();
-    if (result != Result.FORCED) {
-      throw new IOException(
-          String.format("Failed to update ref %s: %s", ref.getName(), result.name()));
-    }
-  }
-
-  public void createUserBranch(
-      Repository repo,
-      ObjectInserter oi,
-      ObjectId emptyTree,
-      Account.Id accountId,
-      Timestamp registeredOn)
-      throws IOException {
-    ObjectId id = createInitialEmptyCommit(oi, emptyTree, registeredOn);
-
-    String refName = RefNames.refsUsers(accountId);
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setExpectedOldObjectId(ObjectId.zeroId());
-    ru.setNewObjectId(id);
-    ru.setRefLogIdent(serverIdent);
-    ru.setRefLogMessage(CREATE_ACCOUNT_MSG, false);
-    Result result = ru.update();
-    if (result != Result.NEW) {
-      throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
-    }
-  }
-
-  private ObjectId createInitialEmptyCommit(
-      ObjectInserter oi, ObjectId emptyTree, Timestamp registrationDate) throws IOException {
-    PersonIdent ident = new PersonIdent(serverIdent, registrationDate);
-
-    CommitBuilder cb = new CommitBuilder();
-    cb.setTreeId(emptyTree);
-    cb.setCommitter(ident);
-    cb.setAuthor(ident);
-    cb.setMessage(CREATE_ACCOUNT_MSG);
-    return oi.insert(cb);
-  }
-
-  private boolean isInitialEmptyCommit(ObjectId emptyTree, RevCommit c) {
-    return c.getParentCount() == 0
-        && c.getTree().equals(emptyTree)
-        && c.getShortMessage().equals(CREATE_ACCOUNT_MSG);
-  }
-
-  private static ObjectId emptyTree(ObjectInserter oi) throws IOException {
-    return oi.insert(Constants.OBJ_TREE, new byte[] {});
-  }
-
-  private Map<Account.Id, Timestamp> scanAccounts(ReviewDb db) throws SQLException {
-    try (Statement stmt = newStatement(db);
-        ResultSet rs = stmt.executeQuery("SELECT account_id, registered_on FROM accounts")) {
-      HashMap<Account.Id, Timestamp> m = new HashMap<>();
-      while (rs.next()) {
-        m.put(new Account.Id(rs.getInt(1)), rs.getTimestamp(2));
-      }
-      return m;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_147.java b/java/com/google/gerrit/server/schema/Schema_147.java
deleted file mode 100644
index fd85463..0000000
--- a/java/com/google/gerrit/server/schema/Schema_147.java
+++ /dev/null
@@ -1,101 +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.server.schema;
-
-import static java.util.stream.Collectors.toSet;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.HashSet;
-import java.util.Objects;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-
-/** Delete user branches for which no account exists. */
-public class Schema_147 extends SchemaVersion {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  Schema_147(
-      Provider<Schema_146> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      Set<Account.Id> accountIdsFromReviewDb = scanAccounts(db);
-      Set<Account.Id> accountIdsFromUserBranches =
-          repo.getRefDatabase()
-              .getRefs(RefNames.REFS_USERS)
-              .values()
-              .stream()
-              .map(r -> Account.Id.fromRef(r.getName()))
-              .filter(Objects::nonNull)
-              .collect(toSet());
-      accountIdsFromUserBranches.removeAll(accountIdsFromReviewDb);
-      for (Account.Id accountId : accountIdsFromUserBranches) {
-        deleteUserBranch(repo, accountId);
-      }
-    } catch (IOException e) {
-      throw new OrmException("Failed to delete user branches for non-existing accounts.", e);
-    }
-  }
-
-  private Set<Account.Id> scanAccounts(ReviewDb db) throws SQLException {
-    try (Statement stmt = newStatement(db);
-        ResultSet rs = stmt.executeQuery("SELECT account_id FROM accounts")) {
-      Set<Account.Id> ids = new HashSet<>();
-      while (rs.next()) {
-        ids.add(new Account.Id(rs.getInt(1)));
-      }
-      return ids;
-    }
-  }
-
-  private void deleteUserBranch(Repository allUsersRepo, Account.Id accountId) throws IOException {
-    String refName = RefNames.refsUsers(accountId);
-    Ref ref = allUsersRepo.exactRef(refName);
-    if (ref == null) {
-      return;
-    }
-
-    RefUpdate ru = allUsersRepo.updateRef(refName);
-    ru.setExpectedOldObjectId(ref.getObjectId());
-    ru.setNewObjectId(ObjectId.zeroId());
-    ru.setForceUpdate(true);
-    Result result = ru.delete();
-    if (result != Result.FORCED) {
-      throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_148.java b/java/com/google/gerrit/server/schema/Schema_148.java
deleted file mode 100644
index 98d4909..0000000
--- a/java/com/google/gerrit/server/schema/Schema_148.java
+++ /dev/null
@@ -1,82 +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.server.schema;
-
-import com.google.common.primitives.Ints;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-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.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.SQLException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_148 extends SchemaVersion {
-  private static final String COMMIT_MSG = "Make account IDs of external IDs human-readable";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_148(
-      Provider<Schema_147> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
-      for (ExternalId extId : extIdNotes.all()) {
-        if (needsUpdate(extId)) {
-          extIdNotes.upsert(extId);
-        }
-      }
-
-      try (MetaDataUpdate metaDataUpdate =
-          new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo)) {
-        metaDataUpdate.getCommitBuilder().setAuthor(serverUser);
-        metaDataUpdate.getCommitBuilder().setCommitter(serverUser);
-        metaDataUpdate.getCommitBuilder().setMessage(COMMIT_MSG);
-        extIdNotes.commit(metaDataUpdate);
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException("Failed to update external IDs", e);
-    }
-  }
-
-  private static boolean needsUpdate(ExternalId extId) {
-    Config cfg = new Config();
-    cfg.setInt("externalId", extId.key().get(), "accountId", extId.accountId().get());
-    return Ints.tryParse(cfg.getString("externalId", extId.key().get(), "accountId")) == null;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_149.java b/java/com/google/gerrit/server/schema/Schema_149.java
deleted file mode 100644
index f1ccaa6..0000000
--- a/java/com/google/gerrit/server/schema/Schema_149.java
+++ /dev/null
@@ -1,26 +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.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Add workInProgress field to change. */
-public class Schema_149 extends SchemaVersion {
-  @Inject
-  Schema_149(Provider<Schema_148> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_150.java b/java/com/google/gerrit/server/schema/Schema_150.java
deleted file mode 100644
index 456a01a..0000000
--- a/java/com/google/gerrit/server/schema/Schema_150.java
+++ /dev/null
@@ -1,26 +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.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Drop ACCOUNT_EXTERNAL_IDS table. */
-public class Schema_150 extends SchemaVersion {
-  @Inject
-  Schema_150(Provider<Schema_149> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_151.java b/java/com/google/gerrit/server/schema/Schema_151.java
deleted file mode 100644
index 7d12e58..0000000
--- a/java/com/google/gerrit/server/schema/Schema_151.java
+++ /dev/null
@@ -1,80 +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.server.schema;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.PreparedStatement;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Optional;
-
-/** A schema which adds the 'created on' field to groups. */
-public class Schema_151 extends SchemaVersion {
-  @Inject
-  protected Schema_151(Provider<Schema_150> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (PreparedStatement groupUpdate =
-            prepareStatement(db, "UPDATE account_groups SET created_on = ? WHERE group_id = ?");
-        PreparedStatement addedOnRetrieval =
-            prepareStatement(
-                db,
-                "SELECT added_on FROM account_group_members_audit WHERE group_id = ?"
-                    + " ORDER BY added_on ASC")) {
-      List<AccountGroup.Id> accountGroups = getAllGroupIds(db);
-      for (AccountGroup.Id groupId : accountGroups) {
-        Optional<Timestamp> firstTimeMentioned = getFirstTimeMentioned(addedOnRetrieval, groupId);
-        Timestamp createdOn = firstTimeMentioned.orElseGet(AccountGroup::auditCreationInstantTs);
-
-        groupUpdate.setTimestamp(1, createdOn);
-        groupUpdate.setInt(2, groupId.get());
-        groupUpdate.executeUpdate();
-      }
-    }
-  }
-
-  private static Optional<Timestamp> getFirstTimeMentioned(
-      PreparedStatement addedOnRetrieval, AccountGroup.Id groupId) throws SQLException {
-    addedOnRetrieval.setInt(1, groupId.get());
-    try (ResultSet resultSet = addedOnRetrieval.executeQuery()) {
-      if (resultSet.first()) {
-        return Optional.of(resultSet.getTimestamp(1));
-      }
-    }
-    return Optional.empty();
-  }
-
-  private static List<AccountGroup.Id> getAllGroupIds(ReviewDb db) throws SQLException {
-    try (Statement stmt = newStatement(db);
-        ResultSet rs = stmt.executeQuery("SELECT group_id FROM account_groups")) {
-      List<AccountGroup.Id> groupIds = new ArrayList<>();
-      while (rs.next()) {
-        groupIds.add(new AccountGroup.Id(rs.getInt(1)));
-      }
-      return groupIds;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_152.java b/java/com/google/gerrit/server/schema/Schema_152.java
deleted file mode 100644
index c5150a0..0000000
--- a/java/com/google/gerrit/server/schema/Schema_152.java
+++ /dev/null
@@ -1,43 +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.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.schema.sql.SqlDialect;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-
-/** Drop unused indexes from accounts table. */
-public class Schema_152 extends SchemaVersion {
-  @Inject
-  Schema_152(Provider<Schema_151> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    JdbcSchema schema = (JdbcSchema) db;
-    SqlDialect dialect = schema.getDialect();
-    try (StatementExecutor e = newExecutor(db)) {
-      dialect.dropIndex(e, "accounts", "accounts_byFullName");
-    } catch (OrmException ex) {
-      // Ignore. The index did not exist.
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_153.java b/java/com/google/gerrit/server/schema/Schema_153.java
deleted file mode 100644
index 28aeb17..0000000
--- a/java/com/google/gerrit/server/schema/Schema_153.java
+++ /dev/null
@@ -1,41 +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.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Add reviewStarted field to change. */
-public class Schema_153 extends SchemaVersion {
-  @Inject
-  Schema_153(Provider<Schema_152> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    try (StatementExecutor e = newExecutor(db)) {
-      // Initialize review_started to a sensible default value according to
-      // whether change is currently WIP. No migration is needed in NoteDb,
-      // where the value of review_started is always derived from the history
-      // of assignments to work_in_progress.
-      e.execute(
-          "UPDATE changes SET review_started = 'Y', created_on = created_on WHERE work_in_progress = 'N'");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_154.java b/java/com/google/gerrit/server/schema/Schema_154.java
deleted file mode 100644
index 8dfd356..0000000
--- a/java/com/google/gerrit/server/schema/Schema_154.java
+++ /dev/null
@@ -1,149 +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.server.schema;
-
-import static java.util.stream.Collectors.toMap;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountConfig;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.Connection;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-
-/** Migrate accounts to NoteDb. */
-public class Schema_154 extends SchemaVersion {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final String TABLE = "accounts";
-  private static final ImmutableMap<String, AccountSetter> ACCOUNT_FIELDS_MAP =
-      ImmutableMap.<String, AccountSetter>builder()
-          .put("full_name", (a, rs, field) -> a.setFullName(rs.getString(field)))
-          .put("preferred_email", (a, rs, field) -> a.setPreferredEmail(rs.getString(field)))
-          .put("status", (a, rs, field) -> a.setStatus(rs.getString(field)))
-          .put("inactive", (a, rs, field) -> a.setActive(rs.getString(field).equals("N")))
-          .build();
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final Provider<PersonIdent> serverIdent;
-
-  @Inject
-  Schema_154(
-      Provider<Schema_153> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverIdent = serverIdent;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try {
-      try (Repository repo = repoManager.openRepository(allUsersName)) {
-        ProgressMonitor pm = new TextProgressMonitor();
-        pm.beginTask("Collecting accounts", ProgressMonitor.UNKNOWN);
-        Set<Account> accounts = scanAccounts(db, pm);
-        pm.endTask();
-        pm.beginTask("Migrating accounts to NoteDb", accounts.size());
-        for (Account account : accounts) {
-          updateAccountInNoteDb(repo, account);
-          pm.update(1);
-        }
-        pm.endTask();
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException("Migrating accounts to NoteDb failed", e);
-    }
-  }
-
-  private Set<Account> scanAccounts(ReviewDb db, ProgressMonitor pm) throws SQLException {
-    Map<String, AccountSetter> fields = getFields(db);
-    if (fields.isEmpty()) {
-      logger.atWarning().log("Only account_id and registered_on fields are migrated for accounts");
-    }
-
-    List<String> queryFields = new ArrayList<>();
-    queryFields.add("account_id");
-    queryFields.add("registered_on");
-    queryFields.addAll(fields.keySet());
-    String query = "SELECT " + String.join(", ", queryFields) + String.format(" FROM %s", TABLE);
-    try (Statement stmt = newStatement(db);
-        ResultSet rs = stmt.executeQuery(query)) {
-      Set<Account> s = new HashSet<>();
-      while (rs.next()) {
-        Account a = new Account(new Account.Id(rs.getInt(1)), rs.getTimestamp(2));
-        for (Map.Entry<String, AccountSetter> field : fields.entrySet()) {
-          field.getValue().set(a, rs, field.getKey());
-        }
-        s.add(a);
-        pm.update(1);
-      }
-      return s;
-    }
-  }
-
-  private Map<String, AccountSetter> getFields(ReviewDb db) throws SQLException {
-    JdbcSchema schema = (JdbcSchema) db;
-    Connection connection = schema.getConnection();
-    Set<String> columns = schema.getDialect().listColumns(connection, TABLE);
-    return ACCOUNT_FIELDS_MAP
-        .entrySet()
-        .stream()
-        .filter(e -> columns.contains(e.getKey()))
-        .collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
-  }
-
-  private void updateAccountInNoteDb(Repository allUsersRepo, Account account)
-      throws IOException, ConfigInvalidException {
-    MetaDataUpdate md =
-        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo);
-    PersonIdent ident = serverIdent.get();
-    md.getCommitBuilder().setAuthor(ident);
-    md.getCommitBuilder().setCommitter(ident);
-    new AccountConfig(account.getId(), allUsersRepo).load().setAccount(account).commit(md);
-  }
-
-  @FunctionalInterface
-  private interface AccountSetter {
-    void set(Account a, ResultSet rs, String field) throws SQLException;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_155.java b/java/com/google/gerrit/server/schema/Schema_155.java
deleted file mode 100644
index ec16e06..0000000
--- a/java/com/google/gerrit/server/schema/Schema_155.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-
-/** Create account sequence in NoteDb */
-public class Schema_155 extends SchemaVersion {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  Schema_155(
-      Provider<Schema_154> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    @SuppressWarnings("deprecation")
-    RepoSequence.Seed accountSeed = db::nextAccountId;
-    RepoSequence accountSeq =
-        new RepoSequence(
-            repoManager,
-            GitReferenceUpdated.DISABLED,
-            allUsersName,
-            Sequences.NAME_ACCOUNTS,
-            accountSeed,
-            1);
-
-    // consume one account ID to ensure that the account sequence is initialized in NoteDb
-    accountSeq.next();
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_156.java b/java/com/google/gerrit/server/schema/Schema_156.java
deleted file mode 100644
index fd8fc00..0000000
--- a/java/com/google/gerrit/server/schema/Schema_156.java
+++ /dev/null
@@ -1,26 +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.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Add revertOf field to change. */
-public class Schema_156 extends SchemaVersion {
-  @Inject
-  Schema_156(Provider<Schema_155> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_157.java b/java/com/google/gerrit/server/schema/Schema_157.java
deleted file mode 100644
index f5c5b59..0000000
--- a/java/com/google/gerrit/server/schema/Schema_157.java
+++ /dev/null
@@ -1,43 +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.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.schema.sql.SqlDialect;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-
-/** Drop unused indexes from accounts table. */
-public class Schema_157 extends SchemaVersion {
-  @Inject
-  Schema_157(Provider<Schema_156> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    JdbcSchema schema = (JdbcSchema) db;
-    SqlDialect dialect = schema.getDialect();
-    try (StatementExecutor e = newExecutor(db)) {
-      dialect.dropIndex(e, "accounts", "accounts_byPreferredEmail");
-    } catch (OrmException ex) {
-      // Ignore. The index did not exist.
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_158.java b/java/com/google/gerrit/server/schema/Schema_158.java
deleted file mode 100644
index ea85444..0000000
--- a/java/com/google/gerrit/server/schema/Schema_158.java
+++ /dev/null
@@ -1,26 +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.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Drop ACCOUNTS table. */
-public class Schema_158 extends SchemaVersion {
-  @Inject
-  Schema_158(Provider<Schema_157> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_159.java b/java/com/google/gerrit/server/schema/Schema_159.java
deleted file mode 100644
index f97453e..0000000
--- a/java/com/google/gerrit/server/schema/Schema_159.java
+++ /dev/null
@@ -1,67 +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.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Migrate draft changes to private or wip changes. */
-public class Schema_159 extends SchemaVersion {
-
-  private static enum DraftWorkflowMigrationStrategy {
-    PRIVATE,
-    WORK_IN_PROGRESS
-  }
-
-  @Inject
-  Schema_159(Provider<Schema_158> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    DraftWorkflowMigrationStrategy strategy = DraftWorkflowMigrationStrategy.WORK_IN_PROGRESS;
-    if (ui.yesno(false, "Migrate draft changes to private changes (default is work-in-progress)")) {
-      strategy = DraftWorkflowMigrationStrategy.PRIVATE;
-    }
-    ui.message(
-        String.format("Replace draft changes with %s changes ...", strategy.name().toLowerCase()));
-    try (StatementExecutor e = newExecutor(db)) {
-      String column =
-          strategy == DraftWorkflowMigrationStrategy.PRIVATE ? "is_private" : "work_in_progress";
-      // Mark changes private/WIP and NEW if either:
-      // * they have status DRAFT
-      // * they have status NEW and have any draft patch sets
-      e.execute(
-          String.format(
-              "UPDATE changes "
-                  + "SET %s = 'Y', "
-                  + "    status = 'n', "
-                  + "    created_on = created_on "
-                  + "WHERE status = 'd' "
-                  + "  OR (status = 'n' "
-                  + "      AND EXISTS "
-                  + "        (SELECT * "
-                  + "         FROM patch_sets "
-                  + "         WHERE patch_sets.change_id = changes.change_id "
-                  + "           AND patch_sets.draft = 'Y')) ",
-              column));
-    }
-    ui.message("done");
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_160.java b/java/com/google/gerrit/server/schema/Schema_160.java
deleted file mode 100644
index 99ca465..0000000
--- a/java/com/google/gerrit/server/schema/Schema_160.java
+++ /dev/null
@@ -1,152 +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.server.schema;
-
-import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
-import static com.google.gerrit.server.git.UserConfigSections.MY;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-
-/**
- * Remove "My Drafts" menu items for all users and server-wide default preferences.
- *
- * <p>Since draft changes no longer exist, these menu items are obsolete.
- *
- * <p>Only matches menu items (with any name) where the URL exactly matches one of the following,
- * with or without leading {@code #}:
- *
- * <ul>
- *   <li>/q/is:draft
- *   <li>/q/owner:self+is:draft
- * </ul>
- *
- * In particular, this includes the <a
- * href="https://gerrit.googlesource.com/gerrit/+/v2.14.4/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java#144">default
- * from version 2.14 and earlier</a>.
- *
- * <p>Other menus containing {@code is:draft} in other positions are not affected; this is still a
- * valid predicate that matches no changes.
- */
-public class Schema_160 extends SchemaVersion {
-  @VisibleForTesting static final ImmutableList<String> DEFAULT_DRAFT_ITEMS;
-
-  static {
-    String ownerSelfIsDraft = "/q/owner:self+is:draft";
-    String isDraft = "/q/is:draft";
-    DEFAULT_DRAFT_ITEMS =
-        ImmutableList.of(ownerSelfIsDraft, '#' + ownerSelfIsDraft, isDraft, '#' + isDraft);
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final Provider<PersonIdent> serverIdent;
-
-  @Inject
-  Schema_160(
-      Provider<Schema_159> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverIdent = serverIdent;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    try {
-      try (Repository repo = repoManager.openRepository(allUsersName)) {
-        ProgressMonitor pm = new TextProgressMonitor();
-        pm.beginTask("Removing \"My Drafts\" menu items", ProgressMonitor.UNKNOWN);
-        for (Account.Id id : (Iterable<Account.Id>) Accounts.readUserRefs(repo)::iterator) {
-          removeMyDrafts(repo, RefNames.refsUsers(id), pm);
-        }
-        removeMyDrafts(repo, RefNames.REFS_USERS_DEFAULT, pm);
-        pm.endTask();
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException("Removing \"My Drafts\" menu items failed", e);
-    }
-  }
-
-  private void removeMyDrafts(Repository repo, String ref, ProgressMonitor pm)
-      throws IOException, ConfigInvalidException {
-    MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo);
-    PersonIdent ident = serverIdent.get();
-    md.getCommitBuilder().setAuthor(ident);
-    md.getCommitBuilder().setCommitter(ident);
-    Prefs prefs = new Prefs(ref);
-    prefs.load(repo);
-    prefs.removeMyDrafts();
-    prefs.commit(md);
-    if (prefs.dirty()) {
-      pm.update(1);
-    }
-  }
-
-  private static class Prefs extends VersionedAccountPreferences {
-    private boolean dirty;
-
-    Prefs(String ref) {
-      super(ref);
-    }
-
-    @Override
-    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-      if (!dirty) {
-        return false;
-      }
-      commit.setMessage("Remove \"My Drafts\" menu items");
-      return super.onSave(commit);
-    }
-
-    void removeMyDrafts() {
-      Config cfg = getConfig();
-      for (String item : cfg.getSubsections(MY)) {
-        String value = cfg.getString(MY, item, KEY_URL);
-        if (DEFAULT_DRAFT_ITEMS.contains(value)) {
-          cfg.unsetSection(MY, item);
-          dirty = true;
-        }
-      }
-    }
-
-    boolean dirty() {
-      return dirty;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_161.java b/java/com/google/gerrit/server/schema/Schema_161.java
deleted file mode 100644
index febe80e..0000000
--- a/java/com/google/gerrit/server/schema/Schema_161.java
+++ /dev/null
@@ -1,114 +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.server.schema;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.primitives.Ints;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.StarredChangesUtil.StarRef;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-public class Schema_161 extends SchemaVersion {
-  private static final String MUTE_LABEL = "mute";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  Schema_161(
-      Provider<Schema_160> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    try (Repository git = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(git)) {
-      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      bru.setAllowNonFastForwards(true);
-
-      for (Ref ref : git.getRefDatabase().getRefs(RefNames.REFS_STARRED_CHANGES).values()) {
-        StarRef starRef = StarredChangesUtil.readLabels(git, ref.getName());
-
-        Set<Integer> mutedPatchSets =
-            StarredChangesUtil.getStarredPatchSets(starRef.labels(), MUTE_LABEL);
-        if (mutedPatchSets.isEmpty()) {
-          continue;
-        }
-
-        Set<Integer> reviewedPatchSets =
-            StarredChangesUtil.getStarredPatchSets(
-                starRef.labels(), StarredChangesUtil.REVIEWED_LABEL);
-        Set<Integer> unreviewedPatchSets =
-            StarredChangesUtil.getStarredPatchSets(
-                starRef.labels(), StarredChangesUtil.UNREVIEWED_LABEL);
-
-        List<String> newLabels =
-            starRef
-                .labels()
-                .stream()
-                .map(
-                    l -> {
-                      if (l.startsWith(MUTE_LABEL)) {
-                        Integer mutedPatchSet = Ints.tryParse(l.substring(MUTE_LABEL.length() + 1));
-                        if (mutedPatchSet == null) {
-                          // unexpected format of mute label, must be a label that was manually
-                          // set, just leave it alone
-                          return l;
-                        }
-                        if (!reviewedPatchSets.contains(mutedPatchSet)
-                            && !unreviewedPatchSets.contains(mutedPatchSet)) {
-                          // convert mute label to reviewed label
-                          return StarredChangesUtil.REVIEWED_LABEL + "/" + mutedPatchSet;
-                        }
-                        // else patch set is muted but has either reviewed or unreviewed label
-                        // -> just drop the mute label
-                        return null;
-                      }
-                      return l;
-                    })
-                .filter(Objects::nonNull)
-                .collect(toList());
-
-        ObjectId id = StarredChangesUtil.writeLabels(git, newLabels);
-        bru.addCommand(new ReceiveCommand(ref.getTarget().getObjectId(), id, ref.getName()));
-      }
-      bru.execute(rw, new TextProgressMonitor());
-    } catch (IOException | IllegalLabelException ex) {
-      throw new OrmException(ex);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_162.java b/java/com/google/gerrit/server/schema/Schema_162.java
deleted file mode 100644
index 7406bc6..0000000
--- a/java/com/google/gerrit/server/schema/Schema_162.java
+++ /dev/null
@@ -1,71 +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.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-public class Schema_162 extends SchemaVersion {
-  private final GitRepositoryManager repoManager;
-  private final AllProjectsName allProjectsName;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_162(
-      Provider<Schema_161> prior,
-      GitRepositoryManager repoManager,
-      AllProjectsName allProjectsName,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allProjectsName = allProjectsName;
-    this.allUsersName = allUsersName;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    try (Repository git = repoManager.openRepository(allUsersName);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig cfg = ProjectConfig.read(md);
-      if (allProjectsName.equals(cfg.getProject().getParent(allProjectsName))) {
-        return;
-      }
-      cfg.getProject().setParentName(allProjectsName);
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage(
-          String.format("Make %s inherit from %s", allUsersName.get(), allProjectsName.get()));
-      cfg.commit(md);
-    } catch (ConfigInvalidException | IOException ex) {
-      throw new OrmException(ex);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_163.java b/java/com/google/gerrit/server/schema/Schema_163.java
deleted file mode 100644
index ae05774..0000000
--- a/java/com/google/gerrit/server/schema/Schema_163.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-
-/** Create group sequence in NoteDb */
-public class Schema_163 extends SchemaVersion {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  Schema_163(
-      Provider<Schema_162> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    @SuppressWarnings("deprecation")
-    RepoSequence.Seed groupSeed = db::nextAccountGroupId;
-    RepoSequence groupSeq =
-        new RepoSequence(
-            repoManager,
-            GitReferenceUpdated.DISABLED,
-            allUsersName,
-            Sequences.NAME_GROUPS,
-            groupSeed,
-            1);
-
-    // consume one account ID to ensure that the group sequence is initialized in NoteDb
-    groupSeq.next();
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_164.java b/java/com/google/gerrit/server/schema/Schema_164.java
deleted file mode 100644
index 8525478..0000000
--- a/java/com/google/gerrit/server/schema/Schema_164.java
+++ /dev/null
@@ -1,85 +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.server.schema;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.schema.AclUtil.grant;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.SQLException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-/** Grant read on group branches */
-public class Schema_164 extends SchemaVersion {
-  private static final String COMMIT_MSG = "Grant read permissions on group branches";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final SystemGroupBackend systemGroupBackend;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_164(
-      Provider<Schema_163> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.systemGroupBackend = systemGroupBackend;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (Repository git = repoManager.openRepository(allUsersName);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage(COMMIT_MSG);
-
-      ProjectConfig config = ProjectConfig.read(md);
-      AccessSection groups = config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
-      grant(
-          config,
-          groups,
-          Permission.READ,
-          false,
-          true,
-          systemGroupBackend.getGroup(REGISTERED_USERS));
-      config.commit(md);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException("Failed to grant read permissions on group branches", e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_165.java b/java/com/google/gerrit/server/schema/Schema_165.java
deleted file mode 100644
index cd6da55..0000000
--- a/java/com/google/gerrit/server/schema/Schema_165.java
+++ /dev/null
@@ -1,142 +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.server.schema;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.RefPattern;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.SQLException;
-import java.util.Optional;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-/** Make default Label-Code-Review permission on user branches exclusive. */
-public class Schema_165 extends SchemaVersion {
-  private static final String COMMIT_MSG =
-      "Make default Label-Code-Review permission on user branches exclusive";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final SystemGroupBackend systemGroupBackend;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_165(
-      Provider<Schema_164> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.systemGroupBackend = systemGroupBackend;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (Repository git = repoManager.openRepository(allUsersName);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
-      Optional<Permission> permission = findDefaultPermission(config);
-      if (!permission.isPresent()) {
-        // the default permission was not found, hence it cannot be fixed
-        return;
-      }
-
-      permission.get().setExclusiveGroup(true);
-
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage(COMMIT_MSG);
-      config.commit(md);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException(
-          "Failed to make default Label-Code-Review permission on user branches exclusive", e);
-    }
-  }
-
-  /**
-   * Searches for the default "Label-Code-Review" permission on the user branch and returns it if it
-   * was found. If it was not found (e.g. because it was removed or modified) {@link
-   * Optional#empty()} is returned.
-   */
-  private Optional<Permission> findDefaultPermission(ProjectConfig config) {
-    AccessSection users =
-        config.getAccessSection(
-            RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", false);
-    if (users == null) {
-      // default permission was removed
-      return Optional.empty();
-    }
-
-    Permission permission = users.getPermission(Permission.LABEL + "Code-Review", false);
-    return isDefaultPermissionUntouched(permission) ? Optional.of(permission) : Optional.empty();
-  }
-
-  /**
-   * Checks whether the given permission matches the default "Label-Code-Review" permission on the
-   * user branch that was initially setup by {@link AllUsersCreator}.
-   */
-  private boolean isDefaultPermissionUntouched(Permission permission) {
-    if (permission == null) {
-      // default permission was removed
-      return false;
-    } else if (permission.getExclusiveGroup()) {
-      // default permission was modified
-      return false;
-    }
-
-    if (permission.getRules().size() != 1) {
-      // default permission was modified
-      return false;
-    }
-
-    PermissionRule rule = permission.getRule(systemGroupBackend.getGroup(REGISTERED_USERS));
-    if (rule == null) {
-      // default permission was removed
-      return false;
-    }
-
-    if (rule.getAction() != Action.ALLOW
-        || rule.getForce()
-        || rule.getMin() != -2
-        || rule.getMax() != 2) {
-      // default permission was modified
-      return false;
-    }
-
-    return true;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_166.java b/java/com/google/gerrit/server/schema/Schema_166.java
deleted file mode 100644
index aa6f4e6..0000000
--- a/java/com/google/gerrit/server/schema/Schema_166.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// 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.schema;
-
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.SQLException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-
-/** Set HEAD for All-Users to refs/meta/config. */
-public class Schema_166 extends SchemaVersion {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  Schema_166(
-      Provider<Schema_165> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (Repository git = repoManager.openRepository(allUsersName)) {
-      RefUpdate u = git.updateRef(Constants.HEAD);
-      u.link(RefNames.REFS_CONFIG);
-    } catch (IOException e) {
-      throw new OrmException(String.format("Failed to update HEAD for %s", allUsersName.get()), e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_167.java b/java/com/google/gerrit/server/schema/Schema_167.java
deleted file mode 100644
index ba93751..0000000
--- a/java/com/google/gerrit/server/schema/Schema_167.java
+++ /dev/null
@@ -1,284 +0,0 @@
-// 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.schema;
-
-import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
-import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
-import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountConfig;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerIdProvider;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.group.db.AuditLogFormatter;
-import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.update.RefUpdateUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-/** Migrate groups from ReviewDb to NoteDb. */
-public class Schema_167 extends SchemaVersion {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final Config gerritConfig;
-  private final SitePaths sitePaths;
-  private final PersonIdent serverIdent;
-  private final SystemGroupBackend systemGroupBackend;
-
-  @Inject
-  protected Schema_167(
-      Provider<Schema_166> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritServerConfig Config gerritConfig,
-      SitePaths sitePaths,
-      @GerritPersonIdent PersonIdent serverIdent,
-      SystemGroupBackend systemGroupBackend) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.gerritConfig = gerritConfig;
-    this.sitePaths = sitePaths;
-    this.serverIdent = serverIdent;
-    this.systemGroupBackend = systemGroupBackend;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    if (gerritConfig.getBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, false)) {
-      // Groups in ReviewDb have already been disabled, nothing to do.
-      return;
-    }
-
-    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      List<GroupReference> allGroupReferences = readGroupReferencesFromReviewDb(db);
-
-      BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-      writeAllGroupNamesToNoteDb(allUsersRepo, allGroupReferences, batchRefUpdate);
-
-      GroupRebuilder groupRebuilder = createGroupRebuilder(db, allUsersRepo);
-      for (GroupReference groupReference : allGroupReferences) {
-        migrateOneGroupToNoteDb(
-            db, allUsersRepo, groupRebuilder, groupReference.getUUID(), batchRefUpdate);
-      }
-
-      RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException(
-          String.format("Failed to migrate groups to NoteDb for %s", allUsersName.get()), e);
-    }
-  }
-
-  private List<GroupReference> readGroupReferencesFromReviewDb(ReviewDb db) throws SQLException {
-    try (Statement stmt = ReviewDbWrapper.unwrapJbdcSchema(db).getConnection().createStatement();
-        ResultSet rs = stmt.executeQuery("SELECT group_uuid, name FROM account_groups")) {
-      List<GroupReference> allGroupReferences = new ArrayList<>();
-      while (rs.next()) {
-        AccountGroup.UUID groupUuid = new AccountGroup.UUID(rs.getString(1));
-        String groupName = rs.getString(2);
-        allGroupReferences.add(new GroupReference(groupUuid, groupName));
-      }
-      return allGroupReferences;
-    }
-  }
-
-  private void writeAllGroupNamesToNoteDb(
-      Repository allUsersRepo,
-      List<GroupReference> allGroupReferences,
-      BatchRefUpdate batchRefUpdate)
-      throws IOException {
-    try (ObjectInserter inserter = allUsersRepo.newObjectInserter()) {
-      GroupNameNotes.updateAllGroups(
-          allUsersRepo, inserter, batchRefUpdate, allGroupReferences, serverIdent);
-      inserter.flush();
-    }
-  }
-
-  private GroupRebuilder createGroupRebuilder(ReviewDb db, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    AuditLogFormatter auditLogFormatter =
-        createAuditLogFormatter(db, allUsersRepo, gerritConfig, sitePaths);
-    return new GroupRebuilder(serverIdent, allUsersName, auditLogFormatter);
-  }
-
-  private AuditLogFormatter createAuditLogFormatter(
-      ReviewDb db, Repository allUsersRepo, Config gerritConfig, SitePaths sitePaths)
-      throws IOException, ConfigInvalidException {
-    String serverId = new GerritServerIdProvider(gerritConfig, sitePaths).get();
-    SimpleInMemoryAccountCache accountCache = new SimpleInMemoryAccountCache(allUsersRepo);
-    SimpleInMemoryGroupCache groupCache = new SimpleInMemoryGroupCache(db);
-    return AuditLogFormatter.create(
-        accountCache::get,
-        uuid -> {
-          if (systemGroupBackend.handles(uuid)) {
-            return Optional.ofNullable(systemGroupBackend.get(uuid));
-          }
-          return groupCache.get(uuid);
-        },
-        serverId);
-  }
-
-  private static void migrateOneGroupToNoteDb(
-      ReviewDb db,
-      Repository allUsersRepo,
-      GroupRebuilder rebuilder,
-      AccountGroup.UUID uuid,
-      BatchRefUpdate batchRefUpdate)
-      throws ConfigInvalidException, IOException, OrmException {
-    GroupBundle reviewDbBundle = GroupBundle.Factory.fromReviewDb(db, uuid);
-    RefUpdateUtil.deleteChecked(allUsersRepo, RefNames.refsGroups(uuid));
-    rebuilder.rebuild(allUsersRepo, reviewDbBundle, batchRefUpdate);
-  }
-
-  // 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 Repository allUsersRepo;
-    private Map<Account.Id, Optional<Account>> accounts = new HashMap<>();
-
-    public SimpleInMemoryAccountCache(Repository allUsersRepo) {
-      this.allUsersRepo = allUsersRepo;
-    }
-
-    public Optional<Account> get(Account.Id accountId) {
-      accounts.computeIfAbsent(accountId, this::load);
-      return accounts.get(accountId);
-    }
-
-    private Optional<Account> load(Account.Id accountId) {
-      try {
-        AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepo).load();
-        return accountConfig.getLoadedAccount();
-      } catch (IOException | ConfigInvalidException ignored) {
-        logger.atWarning().withCause(ignored).log(
-            "Failed to load account %s."
-                + " Cannot get account name for group audit log commit messages.",
-            accountId.get());
-        return Optional.empty();
-      }
-    }
-  }
-
-  // The regular GroupBackends (especially external GroupBackends) and our internal group cache
-  // aren't available during init. -> Use a simple replacement which tries to look up only internal
-  // groups and which loads every internal group only once from disc. (There's no way we can look up
-  // external groups during init. As we need those groups only for cosmetic aspects in
-  // AuditLogFormatter, it's safe to exclude them.)
-  private static class SimpleInMemoryGroupCache {
-    private final ReviewDb db;
-    private Map<AccountGroup.UUID, Optional<GroupDescription.Basic>> groups = new HashMap<>();
-
-    public SimpleInMemoryGroupCache(ReviewDb db) {
-      this.db = db;
-    }
-
-    public Optional<GroupDescription.Basic> get(AccountGroup.UUID groupUuid) {
-      groups.computeIfAbsent(groupUuid, this::load);
-      return groups.get(groupUuid);
-    }
-
-    private Optional<GroupDescription.Basic> load(AccountGroup.UUID groupUuid) {
-      if (!AccountGroup.isInternalGroup(groupUuid)) {
-        return Optional.empty();
-      }
-
-      List<GroupDescription.Basic> groupDescriptions = getGroupDescriptions(groupUuid);
-      if (groupDescriptions.size() == 1) {
-        return Optional.of(Iterables.getOnlyElement(groupDescriptions));
-      }
-      return Optional.empty();
-    }
-
-    private List<GroupDescription.Basic> getGroupDescriptions(AccountGroup.UUID groupUuid) {
-      try (Statement stmt = ReviewDbWrapper.unwrapJbdcSchema(db).getConnection().createStatement();
-          ResultSet rs =
-              stmt.executeQuery(
-                  "SELECT name FROM account_groups where group_uuid = '" + groupUuid + "'")) {
-        List<GroupDescription.Basic> groupDescriptions = new ArrayList<>();
-        while (rs.next()) {
-          String groupName = rs.getString(1);
-          groupDescriptions.add(toGroupDescription(groupUuid, groupName));
-        }
-        return groupDescriptions;
-      } catch (SQLException ignored) {
-        logger.atWarning().withCause(ignored).log(
-            "Failed to load group %s."
-                + " Cannot get group name for group audit log commit messages.",
-            groupUuid.get());
-        return ImmutableList.of();
-      }
-    }
-
-    private static GroupDescription.Basic toGroupDescription(
-        AccountGroup.UUID groupUuid, String groupName) {
-      return new GroupDescription.Basic() {
-        @Override
-        public AccountGroup.UUID getGroupUUID() {
-          return groupUuid;
-        }
-
-        @Override
-        public String getName() {
-          return groupName;
-        }
-
-        @Nullable
-        @Override
-        public String getEmailAddress() {
-          return null;
-        }
-
-        @Nullable
-        @Override
-        public String getUrl() {
-          return null;
-        }
-      };
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_168.java b/java/com/google/gerrit/server/schema/Schema_168.java
deleted file mode 100644
index 3ea8468..0000000
--- a/java/com/google/gerrit/server/schema/Schema_168.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// 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.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-/** Drop group tables. */
-public class Schema_168 extends SchemaVersion {
-  @Inject
-  Schema_168(Provider<Schema_167> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_180.java b/java/com/google/gerrit/server/schema/Schema_180.java
new file mode 100644
index 0000000..6912b3e
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_180.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.server.schema;
+
+public class Schema_180 implements NoteDbSchemaVersion {
+  @Override
+  public void upgrade(Arguments args, UpdateUI ui) {
+    // Do nothing; only used to populate the version ref, which is done by the caller.
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/Schema_181.java b/java/com/google/gerrit/server/schema/Schema_181.java
new file mode 100644
index 0000000..3054ad3
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_181.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.gpg.PublicKeyStore;
+import org.eclipse.jgit.lib.Repository;
+
+public class Schema_181 implements NoteDbSchemaVersion {
+  @Override
+  public void upgrade(Arguments args, UpdateUI ui) throws Exception {
+    ui.message("Rebuild GPGP note map to build subkey to master key map");
+    try (Repository repo = args.repoManager.openRepository(args.allUsers);
+        PublicKeyStore store = new PublicKeyStore(repo)) {
+      store.rebuildSubkeyMasterKeyMap();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/Schema_83.java b/java/com/google/gerrit/server/schema/Schema_83.java
deleted file mode 100644
index decbfb1..0000000
--- a/java/com/google/gerrit/server/schema/Schema_83.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-
-public class Schema_83 extends SchemaVersion {
-
-  @Inject
-  Schema_83() {
-    super(
-        new Provider<SchemaVersion>() {
-          @Override
-          public SchemaVersion get() {
-            throw new ProvisionException("Upgrade first to 2.8 or 2.9");
-          }
-        });
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_84.java b/java/com/google/gerrit/server/schema/Schema_84.java
deleted file mode 100644
index c96f650..0000000
--- a/java/com/google/gerrit/server/schema/Schema_84.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_84 extends SchemaVersion {
-
-  @Inject
-  Schema_84(Provider<Schema_83> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_85.java b/java/com/google/gerrit/server/schema/Schema_85.java
deleted file mode 100644
index e24e67c..0000000
--- a/java/com/google/gerrit/server/schema/Schema_85.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_85 extends SchemaVersion {
-  @Inject
-  Schema_85(Provider<Schema_84> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_86.java b/java/com/google/gerrit/server/schema/Schema_86.java
deleted file mode 100644
index d758189..0000000
--- a/java/com/google/gerrit/server/schema/Schema_86.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_86 extends SchemaVersion {
-  @Inject
-  Schema_86(Provider<Schema_85> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_87.java b/java/com/google/gerrit/server/schema/Schema_87.java
deleted file mode 100644
index 8a3ea08..0000000
--- a/java/com/google/gerrit/server/schema/Schema_87.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.PreparedStatement;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.HashSet;
-import java.util.Optional;
-import java.util.Set;
-
-public class Schema_87 extends SchemaVersion {
-  @Inject
-  Schema_87(Provider<Schema_86> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (PreparedStatement uuidRetrieval =
-            prepareStatement(db, "SELECT group_uuid FROM account_groups WHERE group_id = ?");
-        PreparedStatement groupDeletion =
-            prepareStatement(db, "DELETE FROM account_groups WHERE group_id = ?");
-        PreparedStatement groupNameDeletion =
-            prepareStatement(db, "DELETE FROM account_group_names WHERE group_id = ?")) {
-      for (AccountGroup.Id id : scanSystemGroups(db)) {
-        Optional<AccountGroup.UUID> groupUuid = getUuid(uuidRetrieval, id);
-        if (groupUuid.filter(SystemGroupBackend::isSystemGroup).isPresent()) {
-          groupDeletion.setInt(1, id.get());
-          groupDeletion.executeUpdate();
-
-          groupNameDeletion.setInt(1, id.get());
-          groupNameDeletion.executeUpdate();
-        }
-      }
-    }
-  }
-
-  private static Optional<AccountGroup.UUID> getUuid(
-      PreparedStatement uuidRetrieval, AccountGroup.Id id) throws SQLException {
-    uuidRetrieval.setInt(1, id.get());
-    try (ResultSet uuidResults = uuidRetrieval.executeQuery()) {
-      if (uuidResults.first()) {
-        Optional.of(new AccountGroup.UUID(uuidResults.getString(1)));
-      }
-    }
-    return Optional.empty();
-  }
-
-  private static Set<AccountGroup.Id> scanSystemGroups(ReviewDb db) throws SQLException {
-    try (Statement stmt = newStatement(db);
-        ResultSet rs =
-            stmt.executeQuery("SELECT group_id FROM account_groups WHERE group_type = 'SYSTEM'")) {
-      Set<AccountGroup.Id> ids = new HashSet<>();
-      while (rs.next()) {
-        ids.add(new AccountGroup.Id(rs.getInt(1)));
-      }
-      return ids;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_88.java b/java/com/google/gerrit/server/schema/Schema_88.java
deleted file mode 100644
index 0a7f14c..0000000
--- a/java/com/google/gerrit/server/schema/Schema_88.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_88 extends SchemaVersion {
-  @Inject
-  Schema_88(Provider<Schema_87> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_89.java b/java/com/google/gerrit/server/schema/Schema_89.java
deleted file mode 100644
index de84993..0000000
--- a/java/com/google/gerrit/server/schema/Schema_89.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.schema.sql.SqlDialect;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-
-public class Schema_89 extends SchemaVersion {
-  @Inject
-  Schema_89(Provider<Schema_88> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    SqlDialect dialect = ((JdbcSchema) db).getDialect();
-    try (StatementExecutor e = newExecutor(db)) {
-      dialect.dropIndex(e, "patch_set_approvals", "patch_set_approvals_openByUser");
-      dialect.dropIndex(e, "patch_set_approvals", "patch_set_approvals_closedByU");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_90.java b/java/com/google/gerrit/server/schema/Schema_90.java
deleted file mode 100644
index d8f02ae..0000000
--- a/java/com/google/gerrit/server/schema/Schema_90.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-import java.sql.Statement;
-
-public class Schema_90 extends SchemaVersion {
-  @Inject
-  Schema_90(Provider<Schema_89> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws SQLException {
-    try (Statement stmt = newStatement(db)) {
-      stmt.executeUpdate("UPDATE accounts set size_bar_in_change_table = 'Y'");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_91.java b/java/com/google/gerrit/server/schema/Schema_91.java
deleted file mode 100644
index 173793e..0000000
--- a/java/com/google/gerrit/server/schema/Schema_91.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_91 extends SchemaVersion {
-  @Inject
-  Schema_91(Provider<Schema_90> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_92.java b/java/com/google/gerrit/server/schema/Schema_92.java
deleted file mode 100644
index 5f5c141..0000000
--- a/java/com/google/gerrit/server/schema/Schema_92.java
+++ /dev/null
@@ -1,25 +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.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_92 extends SchemaVersion {
-  @Inject
-  Schema_92(Provider<Schema_91> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_93.java b/java/com/google/gerrit/server/schema/Schema_93.java
deleted file mode 100644
index 3132aa4..0000000
--- a/java/com/google/gerrit/server/schema/Schema_93.java
+++ /dev/null
@@ -1,25 +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.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_93 extends SchemaVersion {
-  @Inject
-  Schema_93(Provider<Schema_92> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_94.java b/java/com/google/gerrit/server/schema/Schema_94.java
deleted file mode 100644
index d4a189f..0000000
--- a/java/com/google/gerrit/server/schema/Schema_94.java
+++ /dev/null
@@ -1,35 +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.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-import java.sql.Statement;
-
-public class Schema_94 extends SchemaVersion {
-  @Inject
-  Schema_94(Provider<Schema_93> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws SQLException {
-    try (Statement stmt = newStatement(db)) {
-      stmt.execute("CREATE INDEX patch_sets_byRevision ON patch_sets (revision)");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_95.java b/java/com/google/gerrit/server/schema/Schema_95.java
deleted file mode 100644
index 0ce0294..0000000
--- a/java/com/google/gerrit/server/schema/Schema_95.java
+++ /dev/null
@@ -1,42 +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.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.SQLException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-public class Schema_95 extends SchemaVersion {
-  private final AllUsersCreator allUsersCreator;
-
-  @Inject
-  Schema_95(Provider<Schema_94> prior, AllUsersCreator allUsersCreator) {
-    super(prior);
-    this.allUsersCreator = allUsersCreator;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try {
-      allUsersCreator.create();
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException(e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_96.java b/java/com/google/gerrit/server/schema/Schema_96.java
deleted file mode 100644
index bf19213..0000000
--- a/java/com/google/gerrit/server/schema/Schema_96.java
+++ /dev/null
@@ -1,25 +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.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_96 extends SchemaVersion {
-  @Inject
-  Schema_96(Provider<Schema_95> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_97.java b/java/com/google/gerrit/server/schema/Schema_97.java
deleted file mode 100644
index 0670377..0000000
--- a/java/com/google/gerrit/server/schema/Schema_97.java
+++ /dev/null
@@ -1,25 +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.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_97 extends SchemaVersion {
-  @Inject
-  Schema_97(Provider<Schema_96> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_98.java b/java/com/google/gerrit/server/schema/Schema_98.java
deleted file mode 100644
index eec3c9f..0000000
--- a/java/com/google/gerrit/server/schema/Schema_98.java
+++ /dev/null
@@ -1,39 +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.schema;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.SQLException;
-import java.sql.Statement;
-
-public class Schema_98 extends SchemaVersion {
-  @Inject
-  Schema_98(Provider<Schema_97> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws SQLException {
-    ui.message("Migrate user preference showUserInReview to reviewCategoryStrategy");
-    try (Statement stmt = newStatement(db)) {
-      stmt.executeUpdate(
-          "UPDATE accounts SET "
-              + "REVIEW_CATEGORY_STRATEGY='NAME' "
-              + "WHERE (SHOW_USER_IN_REVIEW='Y')");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_99.java b/java/com/google/gerrit/server/schema/Schema_99.java
deleted file mode 100644
index b7fab7f..0000000
--- a/java/com/google/gerrit/server/schema/Schema_99.java
+++ /dev/null
@@ -1,25 +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.schema;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class Schema_99 extends SchemaVersion {
-  @Inject
-  Schema_99(Provider<Schema_98> prior) {
-    super(prior);
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/ScriptRunner.java b/java/com/google/gerrit/server/schema/ScriptRunner.java
deleted file mode 100644
index f4cba98..0000000
--- a/java/com/google/gerrit/server/schema/ScriptRunner.java
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.CharMatcher;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.schema.sql.SqlDialect;
-import com.google.gwtorm.server.OrmException;
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.sql.Connection;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.ArrayList;
-import java.util.List;
-
-/** Parses an SQL script from a resource file and later runs it. */
-class ScriptRunner {
-  private final String name;
-  private final List<String> commands;
-
-  static final ScriptRunner NOOP =
-      new ScriptRunner(null, null) {
-        @Override
-        void run(ReviewDb db) {}
-      };
-
-  ScriptRunner(String scriptName, InputStream script) {
-    this.name = scriptName;
-    try {
-      this.commands = script != null ? parse(script) : null;
-    } catch (IOException e) {
-      throw new IllegalStateException("Cannot parse " + name, e);
-    }
-  }
-
-  void run(ReviewDb db) throws OrmException {
-    try {
-      final JdbcSchema schema = (JdbcSchema) db;
-      final Connection c = schema.getConnection();
-      final SqlDialect dialect = schema.getDialect();
-      try (Statement stmt = c.createStatement()) {
-        for (String sql : commands) {
-          try {
-            if (!dialect.isStatementDelimiterSupported()) {
-              sql = CharMatcher.is(';').trimTrailingFrom(sql);
-            }
-            stmt.execute(sql);
-          } catch (SQLException e) {
-            throw new OrmException("Error in " + name + ":\n" + sql, e);
-          }
-        }
-      }
-    } catch (SQLException e) {
-      throw new OrmException("Cannot run statements for " + name, e);
-    }
-  }
-
-  private List<String> parse(InputStream in) throws IOException {
-    try (BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8))) {
-      String delimiter = ";";
-      List<String> commands = new ArrayList<>();
-      StringBuilder buffer = new StringBuilder();
-      String line;
-      while ((line = br.readLine()) != null) {
-        if (line.isEmpty()) {
-          continue;
-        }
-        if (line.startsWith("--")) {
-          continue;
-        }
-
-        if (buffer.length() == 0 && line.toLowerCase().startsWith("delimiter ")) {
-          delimiter = line.substring("delimiter ".length()).trim();
-          continue;
-        }
-
-        if (buffer.length() > 0) {
-          buffer.append('\n');
-        }
-        buffer.append(line);
-
-        if (isDone(delimiter, line, buffer)) {
-          String cmd = buffer.toString();
-          commands.add(cmd);
-          buffer = new StringBuilder();
-        }
-      }
-      if (buffer.length() > 0) {
-        commands.add(buffer.toString());
-      }
-      return commands;
-    }
-  }
-
-  private boolean isDone(String delimiter, String line, StringBuilder buffer) {
-    if (";".equals(delimiter)) {
-      return buffer.charAt(buffer.length() - 1) == ';';
-
-    } else if (line.equals(delimiter)) {
-      buffer.setLength(buffer.length() - delimiter.length());
-      return true;
-
-    } else {
-      return false;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/UpdateUI.java b/java/com/google/gerrit/server/schema/UpdateUI.java
index 0c02607..b5a6b6e 100644
--- a/java/com/google/gerrit/server/schema/UpdateUI.java
+++ b/java/com/google/gerrit/server/schema/UpdateUI.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import java.util.List;
 import java.util.Set;
 
 public interface UpdateUI {
@@ -37,6 +34,4 @@
   String readString(String defaultValue, Set<String> allowedValues, String message);
 
   boolean isBatch();
-
-  void pruneSchema(StatementExecutor e, List<String> pruneList) throws OrmException;
 }
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
new file mode 100644
index 0000000..be56782
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+public class AllProjectsCreatorTestUtil {
+  private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_PROJECT_SECTION =
+      ImmutableList.of("[project]", "  description = Access inherited by all other projects.");
+  private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_RECEIVE_SECTION =
+      ImmutableList.of(
+          "[receive]",
+          "  requireContributorAgreement = false",
+          "  requireSignedOffBy = false",
+          "  requireChangeId = true",
+          "  enableSignedPush = false");
+  private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_SUBMIT_SECTION =
+      ImmutableList.of("[submit]", "  mergeContent = true");
+  private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION =
+      ImmutableList.of(
+          "[capability]",
+          "  administrateServer = group Administrators",
+          "  priority = batch group Non-Interactive Users",
+          "  streamEvents = group Non-Interactive Users");
+  private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_ACCESS_SECTION =
+      ImmutableList.of(
+          "[access \"refs/*\"]",
+          "  read = group Administrators",
+          "  read = group Anonymous Users",
+          "[access \"refs/for/*\"]",
+          "  addPatchSet = group Registered Users",
+          "[access \"refs/for/refs/*\"]",
+          "  push = group Registered Users",
+          "  pushMerge = group Registered Users",
+          "[access \"refs/heads/*\"]",
+          "  create = group Administrators",
+          "  create = group Project Owners",
+          "  editTopicName = +force group Administrators",
+          "  editTopicName = +force group Project Owners",
+          "  forgeAuthor = group Registered Users",
+          "  forgeCommitter = group Administrators",
+          "  forgeCommitter = group Project Owners",
+          "  label-Code-Review = -2..+2 group Administrators",
+          "  label-Code-Review = -2..+2 group Project Owners",
+          "  label-Code-Review = -1..+1 group Registered Users",
+          "  push = group Administrators",
+          "  push = group Project Owners",
+          "  submit = group Administrators",
+          "  submit = group Project Owners",
+          "[access \"refs/meta/config\"]",
+          "  exclusiveGroupPermissions = read",
+          "  create = group Administrators",
+          "  create = group Project Owners",
+          "  label-Code-Review = -2..+2 group Administrators",
+          "  label-Code-Review = -2..+2 group Project Owners",
+          "  push = group Administrators",
+          "  push = group Project Owners",
+          "  read = group Administrators",
+          "  read = group Project Owners",
+          "  submit = group Administrators",
+          "  submit = group Project Owners",
+          "[access \"refs/tags/*\"]",
+          "  create = group Administrators",
+          "  create = group Project Owners",
+          "  createSignedTag = group Administrators",
+          "  createSignedTag = group Project Owners",
+          "  createTag = group Administrators",
+          "  createTag = group Project Owners");
+  private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_LABEL_SECTION =
+      ImmutableList.of(
+          "[label \"Code-Review\"]",
+          "  function = MaxWithBlock",
+          "  defaultValue = 0",
+          "  copyMinScore = true",
+          "  copyAllScoresOnTrivialRebase = true",
+          "  value = -2 This shall not be merged",
+          "  value = -1 I would prefer this is not merged as is",
+          "  value = 0 No score",
+          "  value = +1 Looks good to me, but someone else must approve",
+          "  value = +2 Looks good to me, approved");
+
+  public static String getDefaultAllProjectsWithAllDefaultSections() {
+    return Streams.stream(
+            Iterables.concat(
+                DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
+                DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
+                DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION))
+        .collect(Collectors.joining("\n"));
+  }
+
+  public static String getAllProjectsWithoutDefaultAcls() {
+    return Streams.stream(
+            Iterables.concat(
+                DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
+                DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION))
+        .collect(Collectors.joining("\n"));
+  }
+
+  // Loads the "project.config" from the All-Projects repo.
+  public static Config readAllProjectsConfig(
+      GitRepositoryManager repoManager, AllProjectsName allProjectsName)
+      throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allProjectsName)) {
+      Ref configRef = repo.exactRef(RefNames.REFS_CONFIG);
+      return new BlobBasedConfig(null, repo, configRef.getObjectId(), "project.config");
+    }
+  }
+
+  public static void assertTwoConfigsEquivalent(Config config1, Config config2) {
+    Set<String> sections1 = config1.getSections();
+    Set<String> sections2 = config2.getSections();
+    assertThat(sections1).containsExactlyElementsIn(sections2);
+
+    sections1.forEach(s -> assertSectionEquivalent(config1, config2, s));
+  }
+
+  public static void assertSectionEquivalent(Config config1, Config config2, String section) {
+    assertSubsectionEquivalent(config1, config2, section, null);
+
+    Set<String> subsections1 = config1.getSubsections(section);
+    Set<String> subsections2 = config2.getSubsections(section);
+    assertWithMessage("section \"%s\"", section)
+        .that(subsections1)
+        .containsExactlyElementsIn(subsections2);
+
+    subsections1.forEach(s -> assertSubsectionEquivalent(config1, config2, section, s));
+  }
+
+  private static void assertSubsectionEquivalent(
+      Config config1, Config config2, String section, String subsection) {
+    Set<String> subsectionNames1 = config1.getNames(section, subsection);
+    Set<String> subsectionNames2 = config2.getNames(section, subsection);
+    String name = String.format("subsection \"%s\" of section \"%s\"", subsection, section);
+    assertWithMessage(name).that(subsectionNames1).containsExactlyElementsIn(subsectionNames2);
+
+    subsectionNames1.forEach(
+        n ->
+            assertWithMessage(name)
+                .that(config1.getStringList(section, subsection, n))
+                .asList()
+                .containsExactlyElementsIn(config2.getStringList(section, subsection, n)));
+  }
+
+  private AllProjectsCreatorTestUtil() {}
+}
diff --git a/java/com/google/gerrit/server/schema/testing/BUILD b/java/com/google/gerrit/server/schema/testing/BUILD
new file mode 100644
index 0000000..c520f43
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/testing/BUILD
@@ -0,0 +1,14 @@
+package(default_visibility = ["//visibility:public"])
+
+java_library(
+    name = "testing",
+    testonly = True,
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/server/securestore/SecureStoreClassName.java b/java/com/google/gerrit/server/securestore/SecureStoreClassName.java
index 0247fc1..2989af0 100644
--- a/java/com/google/gerrit/server/securestore/SecureStoreClassName.java
+++ b/java/com/google/gerrit/server/securestore/SecureStoreClassName.java
@@ -1,3 +1,17 @@
+// 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.securestore;
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
diff --git a/java/com/google/gerrit/server/securestore/testing/BUILD b/java/com/google/gerrit/server/securestore/testing/BUILD
new file mode 100644
index 0000000..9b76b9e
--- /dev/null
+++ b/java/com/google/gerrit/server/securestore/testing/BUILD
@@ -0,0 +1,11 @@
+package(default_testonly = True)
+
+java_library(
+    name = "testing",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/securestore/testing/InMemorySecureStore.java b/java/com/google/gerrit/server/securestore/testing/InMemorySecureStore.java
new file mode 100644
index 0000000..23894c1
--- /dev/null
+++ b/java/com/google/gerrit/server/securestore/testing/InMemorySecureStore.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.securestore.testing;
+
+import com.google.gerrit.server.securestore.SecureStore;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+
+public class InMemorySecureStore extends SecureStore {
+  private final Config cfg = new Config();
+
+  @Override
+  public String[] getList(String section, String subsection, String name) {
+    return cfg.getStringList(section, subsection, name);
+  }
+
+  @Override
+  public String[] getListForPlugin(
+      String pluginName, String section, String subsection, String name) {
+    throw new UnsupportedOperationException("not used by tests");
+  }
+
+  @Override
+  public void setList(String section, String subsection, String name, List<String> values) {
+    cfg.setStringList(section, subsection, name, values);
+  }
+
+  @Override
+  public void unset(String section, String subsection, String name) {
+    cfg.unset(section, subsection, name);
+  }
+
+  @Override
+  public Iterable<EntryKey> list() {
+    throw new UnsupportedOperationException("not used by tests");
+  }
+
+  @Override
+  public boolean isOutdated() {
+    throw new UnsupportedOperationException("not used by tests");
+  }
+
+  @Override
+  public void reload() {
+    throw new UnsupportedOperationException("not used by tests");
+  }
+}
diff --git a/java/com/google/gerrit/server/ssh/NoSshKeyCache.java b/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
index 387242c..74bb50c 100644
--- a/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
+++ b/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.ssh;
 
-import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.exceptions.InvalidSshKeyException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.inject.AbstractModule;
diff --git a/java/com/google/gerrit/server/ssh/SshKeyCreator.java b/java/com/google/gerrit/server/ssh/SshKeyCreator.java
index 55ba5ed..beaf1ba 100644
--- a/java/com/google/gerrit/server/ssh/SshKeyCreator.java
+++ b/java/com/google/gerrit/server/ssh/SshKeyCreator.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.ssh;
 
-import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.exceptions.InvalidSshKeyException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 
diff --git a/java/com/google/gerrit/server/submit/ChangeSet.java b/java/com/google/gerrit/server/submit/ChangeSet.java
index 8c94a21..b6dbbb6 100644
--- a/java/com/google/gerrit/server/submit/ChangeSet.java
+++ b/java/com/google/gerrit/server/submit/ChangeSet.java
@@ -20,11 +20,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
 import java.util.Collection;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -61,14 +60,12 @@
   }
 
   public ChangeSet(Iterable<ChangeData> changes, Iterable<ChangeData> hiddenChanges) {
-    changeData = index(changes, ImmutableList.<Change.Id>of());
+    changeData = index(changes, ImmutableList.of());
     nonVisibleChanges = index(hiddenChanges, changeData.keySet());
   }
 
   public ChangeSet(ChangeData change, boolean visible) {
-    this(
-        visible ? ImmutableList.of(change) : ImmutableList.<ChangeData>of(),
-        ImmutableList.of(change));
+    this(visible ? ImmutableList.of(change) : ImmutableList.of(), ImmutableList.of(change));
   }
 
   public ImmutableSet<Change.Id> ids() {
@@ -79,8 +76,8 @@
     return changeData;
   }
 
-  public ListMultimap<Branch.NameKey, ChangeData> changesByBranch() throws OrmException {
-    ListMultimap<Branch.NameKey, ChangeData> ret =
+  public ListMultimap<BranchNameKey, ChangeData> changesByBranch() {
+    ListMultimap<BranchNameKey, ChangeData> ret =
         MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeData cd : changeData.values()) {
       ret.put(cd.change().getDest(), cd);
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index e0b10f0..4d57591 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.submit;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
 import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -90,14 +90,14 @@
 
     @Override
     protected void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, IOException, OrmException {
+        throws IntegrationException, IOException, MethodNotAllowedException {
       // If there is only one parent, a cherry-pick can be done by taking the
       // delta relative to that one parent and redoing that on the current merge
       // tip.
       args.rw.parseBody(toMerge);
       psId =
-          ChangeUtil.nextPatchSetIdFromChangeRefsMap(
-              ctx.getRepoView().getRefs(getId().toRefPrefix()),
+          ChangeUtil.nextPatchSetIdFromChangeRefs(
+              ctx.getRepoView().getRefs(getId().toRefPrefix()).keySet(),
               toMerge.change().currentPatchSetId());
       RevCommit mergeTip = args.mergeTip.getCurrentTip();
       args.rw.parseBody(mergeTip);
@@ -116,6 +116,7 @@
                 cherryPickCmtMsg,
                 args.rw,
                 0,
+                false,
                 false);
       } catch (MergeConflictException mce) {
         // Keep going in the case of a single merge failure; the goal is to
@@ -144,24 +145,24 @@
     }
 
     @Override
-    public PatchSet updateChangeImpl(ChangeContext ctx)
-        throws OrmException, NoSuchChangeException, IOException {
+    public PatchSet updateChangeImpl(ChangeContext ctx) throws NoSuchChangeException, IOException {
       if (newCommit == null && toMerge.getStatusCode() == SKIPPED_IDENTICAL_TREE) {
         return null;
       }
-      checkNotNull(
+      requireNonNull(
           newCommit,
-          "no new commit produced by CherryPick of %s, expected to fail fast",
-          toMerge.change().getId());
-      PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+          () ->
+              String.format(
+                  "no new commit produced by CherryPick of %s, expected to fail fast",
+                  toMerge.change().getId()));
+      PatchSet prevPs = args.psUtil.current(ctx.getNotes());
       PatchSet newPs =
           args.psUtil.insert(
-              ctx.getDb(),
               ctx.getRevWalk(),
               ctx.getUpdate(psId),
               psId,
               newCommit,
-              prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
+              prevPs != null ? prevPs.groups() : ImmutableList.of(),
               null,
               null);
       ctx.getChange().setCurrentPatchSet(patchSetInfo);
diff --git a/java/com/google/gerrit/server/submit/CommitMergeStatus.java b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
index 6d7a139..2ca0ec5 100644
--- a/java/com/google/gerrit/server/submit/CommitMergeStatus.java
+++ b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
@@ -14,6 +14,17 @@
 
 package com.google.gerrit.server.submit;
 
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Provider;
+import java.util.List;
+import java.util.Optional;
+
 /**
  * Status codes set on {@link com.google.gerrit.server.git.CodeReviewCommit}s by {@link
  * SubmitStrategy} implementations.
@@ -38,9 +49,10 @@
           + "Please rebase the change locally and upload the rebased commit for review."),
 
   SKIPPED_IDENTICAL_TREE(
-      "Marking change merged without cherry-picking to branch, as the resulting commit would be empty."),
+      "Marking change merged without cherry-picking to branch, as the resulting commit would be"
+          + " empty."),
 
-  MISSING_DEPENDENCY(""),
+  MISSING_DEPENDENCY("Depends on change that was not submitted."),
 
   MANUAL_RECURSIVE_MERGE(
       "The change requires a local merge to resolve.\n"
@@ -67,13 +79,55 @@
           + "\n"
           + "Project policy requires all commits to contain modifications to at least one file.");
 
-  private final String message;
+  private final String description;
 
-  CommitMergeStatus(String message) {
-    this.message = message;
+  CommitMergeStatus(String description) {
+    this.description = description;
   }
 
-  public String getMessage() {
-    return message;
+  public String getDescription() {
+    return description;
+  }
+
+  public static String createMissingDependencyMessage(
+      @Nullable CurrentUser caller,
+      Provider<InternalChangeQuery> queryProvider,
+      String commit,
+      String otherCommit) {
+    List<ChangeData> changes = queryProvider.get().enforceVisibility(true).byCommit(otherCommit);
+
+    if (changes.isEmpty()) {
+      return String.format(
+          "Commit %s depends on commit %s which cannot be merged."
+              + " Is the change of this commit not visible to '%s' or was it deleted?",
+          commit, otherCommit, caller != null ? caller.getLoggableName() : "<user-not-available>");
+    } else if (changes.size() == 1) {
+      ChangeData cd = changes.get(0);
+      if (cd.currentPatchSet().commitId().name().equals(otherCommit)) {
+        return String.format(
+            "Commit %s depends on commit %s of change %d which cannot be merged.",
+            commit, otherCommit, cd.getId().get());
+      }
+      Optional<PatchSet> patchSet =
+          cd.patchSets().stream().filter(ps -> ps.commitId().name().equals(otherCommit)).findAny();
+      if (patchSet.isPresent()) {
+        return String.format(
+            "Commit %s depends on commit %s, which is outdated patch set %d of change %d."
+                + " The latest patch set is %d.",
+            commit,
+            otherCommit,
+            patchSet.get().id().get(),
+            cd.getId().get(),
+            cd.currentPatchSet().id().get());
+      }
+      // should not happen, fall-back to default message
+      return String.format(
+          "Commit %s depends on commit %s of change %d which cannot be merged.",
+          commit, otherCommit, cd.getId().get());
+    } else {
+      return String.format(
+          "Commit %s depends on commit %s of changes %s which cannot be merged.",
+          commit, otherCommit, changes.stream().map(cd -> cd.getId().get()).collect(toSet()));
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index a6b73447..a1f56eb 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -14,27 +14,20 @@
 
 package com.google.gerrit.server.submit;
 
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
 import com.google.inject.assistedinject.Assisted;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
@@ -47,46 +40,37 @@
         Project.NameKey project,
         Change.Id changeId,
         Account.Id submitter,
-        NotifyHandling notifyHandling,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify);
+        NotifyResolver.Result notify);
   }
 
   private final ExecutorService sendEmailsExecutor;
   private final MergedSender.Factory mergedSenderFactory;
-  private final SchemaFactory<ReviewDb> schemaFactory;
   private final ThreadLocalRequestContext requestContext;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
   private final Project.NameKey project;
   private final Change.Id changeId;
   private final Account.Id submitter;
-  private final NotifyHandling notifyHandling;
-  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-
-  private ReviewDb db;
+  private final NotifyResolver.Result notify;
 
   @Inject
   EmailMerge(
       @SendEmailExecutor ExecutorService executor,
       MergedSender.Factory mergedSenderFactory,
-      SchemaFactory<ReviewDb> schemaFactory,
       ThreadLocalRequestContext requestContext,
       IdentifiedUser.GenericFactory identifiedUserFactory,
       @Assisted Project.NameKey project,
       @Assisted Change.Id changeId,
       @Assisted @Nullable Account.Id submitter,
-      @Assisted NotifyHandling notifyHandling,
-      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+      @Assisted NotifyResolver.Result notify) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
-    this.schemaFactory = schemaFactory;
     this.requestContext = requestContext;
     this.identifiedUserFactory = identifiedUserFactory;
     this.project = project;
     this.changeId = changeId;
     this.submitter = submitter;
-    this.notifyHandling = notifyHandling;
-    this.accountsToNotify = accountsToNotify;
+    this.notify = notify;
   }
 
   void sendAsync() {
@@ -102,17 +86,12 @@
       if (submitter != null) {
         cm.setFrom(submitter);
       }
-      cm.setNotify(notifyHandling);
-      cm.setAccountsToNotify(accountsToNotify);
+      cm.setNotify(notify);
       cm.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", changeId);
     } finally {
       requestContext.setContext(old);
-      if (db != null) {
-        db.close();
-        db = null;
-      }
     }
   }
 
@@ -128,21 +107,4 @@
     }
     throw new OutOfScopeException("No user on email thread");
   }
-
-  @Override
-  public Provider<ReviewDb> getReviewDbProvider() {
-    return new Provider<ReviewDb>() {
-      @Override
-      public ReviewDb get() {
-        if (db == null) {
-          try {
-            db = schemaFactory.open();
-          } catch (OrmException e) {
-            throw new ProvisionException("Cannot open ReviewDb", e);
-          }
-        }
-        return db;
-      }
-    };
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/FastForwardOp.java b/java/com/google/gerrit/server/submit/FastForwardOp.java
index f1749f4..08f5abb 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOp.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOp.java
@@ -28,6 +28,7 @@
   @Override
   protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
     if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+        && toMerge.getParentCount() > 0
         && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
       toMerge.setStatusCode(EMPTY_COMMIT);
       return;
diff --git a/java/com/google/gerrit/server/submit/GitModules.java b/java/com/google/gerrit/server/submit/GitModules.java
index 00ce7b2..1770c4a 100644
--- a/java/com/google/gerrit/server/submit/GitModules.java
+++ b/java/com/google/gerrit/server/submit/GitModules.java
@@ -16,14 +16,13 @@
 
 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.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gerrit.server.util.SubmoduleSectionParser;
+import com.google.gerrit.server.util.git.SubmoduleSectionParser;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -46,68 +45,57 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    GitModules create(Branch.NameKey project, MergeOpRepoManager m);
+    GitModules create(BranchNameKey project, MergeOpRepoManager m);
   }
 
   private static final String GIT_MODULES = ".gitmodules";
 
-  private final RequestId submissionId;
-  Set<SubmoduleSubscription> subscriptions;
+  private Set<SubmoduleSubscription> subscriptions;
 
   @Inject
   GitModules(
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
-      @Assisted Branch.NameKey branch,
+      @Assisted BranchNameKey branch,
       @Assisted MergeOpRepoManager orm)
       throws IOException {
-    this.submissionId = orm.getSubmissionId();
-    Project.NameKey project = branch.getParentKey();
-    logDebug("Loading .gitmodules of %s for project %s", branch, project);
+    Project.NameKey project = 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());
+      ObjectId id = or.repo.resolve(branch.branch());
       if (id == null) {
-        throw new IOException("Cannot open branch " + branch.get());
+        throw new IOException("Cannot open branch " + branch.branch());
       }
       RevCommit commit = or.rw.parseCommit(id);
 
       try (TreeWalk tw = TreeWalk.forPath(or.repo, GIT_MODULES, commit.getTree())) {
         if (tw == null || (tw.getRawMode(0) & FileMode.TYPE_MASK) != FileMode.TYPE_FILE) {
           subscriptions = Collections.emptySet();
-          logDebug("The .gitmodules file doesn't exist in %s", branch);
+          logger.atFine().log("The .gitmodules file doesn't exist in %s", branch);
           return;
         }
       }
-      BlobBasedConfig bbc;
+      BlobBasedConfig config;
       try {
-        bbc = new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
+        config = new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
       } catch (ConfigInvalidException e) {
         throw new IOException(
-            "Could not read .gitmodules of super project: " + branch.getParentKey(), e);
+            "Could not read .gitmodules of super project: " + branch.project(), e);
       }
-      subscriptions = new SubmoduleSectionParser(bbc, canonicalWebUrl, branch).parseAllSections();
+      subscriptions =
+          new SubmoduleSectionParser(config, canonicalWebUrl, branch).parseAllSections();
     } catch (NoSuchProjectException e) {
       throw new IOException(e);
     }
   }
 
-  public Collection<SubmoduleSubscription> subscribedTo(Branch.NameKey src) {
-    logDebug("Checking for a subscription of %s", src);
+  Collection<SubmoduleSubscription> subscribedTo(BranchNameKey src) {
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     for (SubmoduleSubscription s : subscriptions) {
       if (s.getSubmodule().equals(src)) {
-        logDebug("Found %s", s);
         ret.add(s);
       }
     }
     return ret;
   }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(submissionId + msg, arg1, arg2);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 06f57b5..bb6a2e5 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -23,11 +23,12 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,9 +37,9 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -52,7 +53,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -74,51 +74,54 @@
 
   @AutoValue
   abstract static class QueryKey {
-    private static QueryKey create(Branch.NameKey branch, Iterable<String> hashes) {
+    private static QueryKey create(BranchNameKey branch, Iterable<String> hashes) {
       return new AutoValue_LocalMergeSuperSetComputation_QueryKey(
           branch, ImmutableSet.copyOf(hashes));
     }
 
-    abstract Branch.NameKey branch();
+    abstract BranchNameKey branch();
 
     abstract ImmutableSet<String> hashes();
   }
 
   private final PermissionBackend permissionBackend;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final Map<QueryKey, List<ChangeData>> queryCache;
-  private final Map<Branch.NameKey, Optional<RevCommit>> heads;
+  private final Map<QueryKey, ImmutableList<ChangeData>> queryCache;
+  private final Map<BranchNameKey, Optional<RevCommit>> heads;
   private final ProjectCache projectCache;
+  private final ChangeIsVisibleToPredicate changeIsVisibleToPredicate;
 
   @Inject
   LocalMergeSuperSetComputation(
       PermissionBackend permissionBackend,
       Provider<InternalChangeQuery> queryProvider,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ChangeIsVisibleToPredicate changeIsVisibleToPredicate) {
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.queryProvider = queryProvider;
     this.queryCache = new HashMap<>();
     this.heads = new HashMap<>();
+    this.changeIsVisibleToPredicate = changeIsVisibleToPredicate;
   }
 
   @Override
   public ChangeSet completeWithoutTopic(
-      ReviewDb db, MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user)
-      throws OrmException, IOException, PermissionBackendException {
+      MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user)
+      throws IOException, PermissionBackendException {
     Collection<ChangeData> visibleChanges = new ArrayList<>();
     Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
 
     // For each target branch we run a separate rev walk to find open changes
     // reachable from changes already in the merge super set.
-    ImmutableListMultimap<Branch.NameKey, ChangeData> bc =
+    ImmutableListMultimap<BranchNameKey, ChangeData> bc =
         byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges()));
-    for (Branch.NameKey b : bc.keySet()) {
-      OpenRepo or = getRepo(orm, b.getParentKey());
+    for (BranchNameKey b : bc.keySet()) {
+      OpenRepo or = getRepo(orm, b.project());
       List<RevCommit> visibleCommits = new ArrayList<>();
       List<RevCommit> nonVisibleCommits = new ArrayList<>();
       for (ChangeData cd : bc.get(b)) {
-        boolean visible = isVisible(db, changeSet, cd, user);
+        boolean visible = isVisible(changeSet, cd, user);
 
         if (submitType(cd) == SubmitType.CHERRY_PICK) {
           if (visible) {
@@ -131,8 +134,7 @@
         }
 
         // Get the underlying git commit object
-        String objIdStr = cd.currentPatchSet().getRevision().get();
-        RevCommit commit = or.rw.parseCommit(ObjectId.fromString(objIdStr));
+        RevCommit commit = or.rw.parseCommit(cd.currentPatchSet().commitId());
 
         // Always include the input, even if merged. This allows
         // SubmitStrategyOp to correct the situation later, assuming it gets
@@ -146,18 +148,19 @@
 
       Set<String> visibleHashes =
           walkChangesByHashes(visibleCommits, Collections.emptySet(), or, b);
-      Iterables.addAll(visibleChanges, byCommitsOnBranchNotMerged(or, db, b, visibleHashes));
-
       Set<String> nonVisibleHashes = walkChangesByHashes(nonVisibleCommits, visibleHashes, or, b);
-      Iterables.addAll(nonVisibleChanges, byCommitsOnBranchNotMerged(or, db, b, nonVisibleHashes));
+
+      ChangeSet partialSet = byCommitsOnBranchNotMerged(or, b, visibleHashes, nonVisibleHashes);
+      Iterables.addAll(visibleChanges, partialSet.changes());
+      Iterables.addAll(nonVisibleChanges, partialSet.nonVisibleChanges());
     }
 
     return new ChangeSet(visibleChanges, nonVisibleChanges);
   }
 
-  private static ImmutableListMultimap<Branch.NameKey, ChangeData> byBranch(
-      Iterable<ChangeData> changes) throws OrmException {
-    ImmutableListMultimap.Builder<Branch.NameKey, ChangeData> builder =
+  private static ImmutableListMultimap<BranchNameKey, ChangeData> byBranch(
+      Iterable<ChangeData> changes) {
+    ImmutableListMultimap.Builder<BranchNameKey, ChangeData> builder =
         ImmutableListMultimap.builder();
     for (ChangeData cd : changes) {
       builder.put(cd.change().getDest(), cd);
@@ -175,24 +178,29 @@
     }
   }
 
-  private boolean isVisible(ReviewDb db, ChangeSet changeSet, ChangeData cd, CurrentUser user)
+  private boolean isVisible(ChangeSet changeSet, ChangeData cd, CurrentUser user)
       throws PermissionBackendException, IOException {
     ProjectState projectState = projectCache.checkedGet(cd.project());
     boolean visible =
         changeSet.ids().contains(cd.getId())
             && (projectState != null)
             && projectState.statePermitsRead();
-    if (visible
-        && !permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ)) {
+    if (!visible) {
+      return false;
+    }
+
+    try {
+      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
       // We thought the change was visible, but it isn't.
       // This can happen if the ACL changes during the
       // completeChangeSet computation, for example.
-      visible = false;
+      return false;
     }
-    return visible;
   }
 
-  private SubmitType submitType(ChangeData cd) throws OrmException {
+  private SubmitType submitType(ChangeData cd) {
     SubmitTypeRecord str = cd.submitTypeRecord();
     if (!str.isOk()) {
       logErrorAndThrow("Failed to get submit type for " + cd.getId() + ": " + str.errorMessage);
@@ -200,30 +208,42 @@
     return str.type;
   }
 
-  private List<ChangeData> byCommitsOnBranchNotMerged(
-      OpenRepo or, ReviewDb db, Branch.NameKey branch, Set<String> hashes)
-      throws OrmException, IOException {
+  private ChangeSet byCommitsOnBranchNotMerged(
+      OpenRepo or, BranchNameKey branch, Set<String> visibleHashes, Set<String> nonVisibleHashes)
+      throws IOException {
+    List<ChangeData> potentiallyVisibleChanges =
+        byCommitsOnBranchNotMerged(or, branch, visibleHashes);
+    List<ChangeData> invisibleChanges =
+        new ArrayList<>(byCommitsOnBranchNotMerged(or, branch, nonVisibleHashes));
+    List<ChangeData> visibleChanges = new ArrayList<>(potentiallyVisibleChanges.size());
+    for (ChangeData cd : potentiallyVisibleChanges) {
+      if (changeIsVisibleToPredicate.match(cd)) {
+        visibleChanges.add(cd);
+      } else {
+        invisibleChanges.add(cd);
+      }
+    }
+    return new ChangeSet(visibleChanges, invisibleChanges);
+  }
+
+  private ImmutableList<ChangeData> byCommitsOnBranchNotMerged(
+      OpenRepo or, BranchNameKey branch, Set<String> hashes) throws IOException {
     if (hashes.isEmpty()) {
       return ImmutableList.of();
     }
     QueryKey k = QueryKey.create(branch, hashes);
-    List<ChangeData> cached = queryCache.get(k);
-    if (cached != null) {
-      return cached;
+    if (queryCache.containsKey(k)) {
+      return queryCache.get(k);
     }
-
-    List<ChangeData> result = new ArrayList<>();
-    Iterable<ChangeData> destChanges =
-        queryProvider.get().byCommitsOnBranchNotMerged(or.repo, db, branch, hashes);
-    for (ChangeData chd : destChanges) {
-      result.add(chd);
-    }
+    ImmutableList<ChangeData> result =
+        ImmutableList.copyOf(
+            queryProvider.get().byCommitsOnBranchNotMerged(or.repo, branch, hashes));
     queryCache.put(k, result);
     return result;
   }
 
   private Set<String> walkChangesByHashes(
-      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, Branch.NameKey b)
+      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, BranchNameKey b)
       throws IOException {
     Set<String> destHashes = new HashSet<>();
     or.rw.reset();
@@ -247,10 +267,10 @@
     return destHashes;
   }
 
-  private void markHeadUninteresting(OpenRepo or, Branch.NameKey b) throws IOException {
+  private void markHeadUninteresting(OpenRepo or, BranchNameKey b) throws IOException {
     Optional<RevCommit> head = heads.get(b);
     if (head == null) {
-      Ref ref = or.repo.getRefDatabase().exactRef(b.get());
+      Ref ref = or.repo.getRefDatabase().exactRef(b.branch());
       head = ref != null ? Optional.of(or.rw.parseCommit(ref.getObjectId())) : Optional.empty();
       heads.put(b, head);
     }
@@ -259,8 +279,8 @@
     }
   }
 
-  private void logErrorAndThrow(String msg) throws OrmException {
+  private void logErrorAndThrow(String msg) {
     logger.atSevere().log(msg);
-    throw new OrmException(msg);
+    throw new StorageException(msg);
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index dedf764..6c3d48b 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.submit;
 
+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 java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
 
 import com.github.rholder.retry.Attempt;
 import com.github.rholder.retry.RetryListener;
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
@@ -33,35 +33,37 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.validators.MergeValidationException;
 import com.google.gerrit.server.git.validators.MergeValidators;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -76,8 +78,7 @@
 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.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -118,16 +119,16 @@
 
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
-    private final ImmutableSetMultimap<Branch.NameKey, Change.Id> byBranch;
+    private final ImmutableSetMultimap<BranchNameKey, Change.Id> byBranch;
     private final Map<Change.Id, CodeReviewCommit> commits;
     private final ListMultimap<Change.Id, String> problems;
     private final boolean allowClosed;
 
-    private CommitStatus(ChangeSet cs, boolean allowClosed) throws OrmException {
+    private CommitStatus(ChangeSet cs, boolean allowClosed) {
       checkArgument(
           !cs.furtherHiddenChanges(), "CommitStatus must not be called with hidden changes");
       changes = cs.changesById();
-      ImmutableSetMultimap.Builder<Branch.NameKey, Change.Id> bb = ImmutableSetMultimap.builder();
+      ImmutableSetMultimap.Builder<BranchNameKey, Change.Id> bb = ImmutableSetMultimap.builder();
       for (ChangeData cd : cs.changes()) {
         bb.put(cd.change().getDest(), cd.getId());
       }
@@ -141,7 +142,7 @@
       return changes.keySet();
     }
 
-    public ImmutableSet<Change.Id> getChangeIds(Branch.NameKey branch) {
+    public ImmutableSet<Change.Id> getChangeIds(BranchNameKey branch) {
       return byBranch.get(branch);
     }
 
@@ -172,10 +173,6 @@
       return problems.isEmpty();
     }
 
-    public ImmutableListMultimap<Change.Id, String> getProblems() {
-      return ImmutableListMultimap.copyOf(problems);
-    }
-
     public List<SubmitRecord> getSubmitRecords(Change.Id id) {
       // Use the cached submit records from the original ChangeData in the input
       // ChangeSet, which were checked earlier in the integrate process. Even in
@@ -185,8 +182,8 @@
       //
       // However, do NOT expose that ChangeData directly, as it is way out of
       // date by this point.
-      ChangeData cd = checkNotNull(changes.get(id), "ChangeData for %s", id);
-      return checkNotNull(
+      ChangeData cd = requireNonNull(changes.get(id), () -> String.format("ChangeData for %s", id));
+      return requireNonNull(
           cd.getSubmitRecords(submitRuleOptions(allowClosed)),
           "getSubmitRecord only valid after submit rules are evalutated");
     }
@@ -232,7 +229,7 @@
   private final SubmitStrategyFactory submitStrategyFactory;
   private final SubmoduleOp.Factory subOpFactory;
   private final Provider<MergeOpRepoManager> ormProvider;
-  private final NotifyUtil notifyUtil;
+  private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
 
@@ -242,9 +239,8 @@
 
   private MergeOpRepoManager orm;
   private CommitStatus commitStatus;
-  private ReviewDb db;
   private SubmitInput submitInput;
-  private ListMultimap<RecipientType, Account.Id> accountsToNotify;
+  private NotifyResolver.Result notify;
   private Set<Project.NameKey> allProjects;
   private boolean dryrun;
   private TopicMetrics topicMetrics;
@@ -260,7 +256,7 @@
       SubmitStrategyFactory submitStrategyFactory,
       SubmoduleOp.Factory subOpFactory,
       Provider<MergeOpRepoManager> ormProvider,
-      NotifyUtil notifyUtil,
+      NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
       ChangeData.Factory changeDataFactory) {
@@ -273,7 +269,7 @@
     this.submitStrategyFactory = submitStrategyFactory;
     this.subOpFactory = subOpFactory;
     this.ormProvider = ormProvider;
-    this.notifyUtil = notifyUtil;
+    this.notifyResolver = notifyResolver;
     this.retryHelper = retryHelper;
     this.topicMetrics = topicMetrics;
     this.changeDataFactory = changeDataFactory;
@@ -287,7 +283,7 @@
   }
 
   public static void checkSubmitRule(ChangeData cd, boolean allowClosed)
-      throws ResourceConflictException, OrmException {
+      throws ResourceConflictException {
     PatchSet patchSet = cd.currentPatchSet();
     if (patchSet == null) {
       throw new ResourceConflictException("missing current patch set for change " + cd.getId());
@@ -300,7 +296,7 @@
       throw new IllegalStateException(
           String.format(
               "SubmitRuleEvaluator.evaluate for change %s returned empty list for %s in %s",
-              cd.getId(), patchSet.getId(), cd.change().getProject().get()));
+              cd.getId(), patchSet.id(), cd.change().getProject().get()));
     }
 
     for (SubmitRecord record : results) {
@@ -315,14 +311,14 @@
           throw new ResourceConflictException("submit rule error: " + record.errorMessage);
 
         case NOT_READY:
-          throw new ResourceConflictException(describeLabels(cd, record.labels));
+          throw new ResourceConflictException(describeNotReady(cd, record));
 
         case FORCED:
         default:
           throw new IllegalStateException(
               String.format(
                   "Unexpected SubmitRecord status %s for %s in %s",
-                  record.status, patchSet.getId().getId(), cd.change().getProject().get()));
+                  record.status, patchSet.id().getId(), cd.change().getProject().get()));
       }
     }
     throw new IllegalStateException();
@@ -336,8 +332,20 @@
     return cd.submitRecords(submitRuleOptions(allowClosed));
   }
 
-  private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels)
-      throws OrmException {
+  private static String describeNotReady(ChangeData cd, SubmitRecord record) {
+    List<String> blockingConditions = new ArrayList<>();
+    if (record.labels != null) {
+      blockingConditions.add(describeLabels(cd, record.labels));
+    }
+    if (record.requirements != null) {
+      record.requirements.stream()
+          .map(SubmitRequirement::fallbackText)
+          .forEach(blockingConditions::add);
+    }
+    return Joiner.on("; ").join(blockingConditions);
+  }
+
+  private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels) {
     List<String> labelResults = new ArrayList<>();
     for (SubmitRecord.Label lbl : labels) {
       switch (lbl.status) {
@@ -373,12 +381,10 @@
         !cs.furtherHiddenChanges(), "checkSubmitRulesAndState called for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
       try {
-        Change.Status status = cd.change().getStatus();
-        if (status != Change.Status.NEW) {
-          if (!(status == Change.Status.MERGED && allowMerged)) {
+        if (!cd.change().isNew()) {
+          if (!(cd.change().isMerged() && allowMerged)) {
             commitStatus.problem(
-                cd.getId(),
-                "Change " + cd.getId() + " is " + cd.change().getStatus().toString().toLowerCase());
+                cd.getId(), "Change " + cd.getId() + " is " + ChangeUtil.status(cd.change()));
           }
         } else if (cd.change().isWorkInProgress()) {
           commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
@@ -387,7 +393,7 @@
         }
       } catch (ResourceConflictException e) {
         commitStatus.problem(cd.getId(), e.getMessage());
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         String msg = "Error checking submit rules for change";
         logger.atWarning().withCause(e).log("%s %s", msg, cd.getId());
         commitStatus.problem(cd.getId(), msg);
@@ -415,102 +421,107 @@
    * topic or via superproject subscriptions. All affected changes are integrated using the projects
    * integration strategy.
    *
-   * @param db the review database.
    * @param change the change to be merged.
    * @param caller the identity of the caller
    * @param checkSubmitRules whether the prolog submit rules should be evaluated
    * @param submitInput parameters regarding the merge
-   * @throws OrmException an error occurred reading or writing the database.
    * @throws RestApiException if an error occurred.
    * @throws PermissionBackendException if permissions can't be checked
    * @throws IOException an error occurred reading from NoteDb.
    */
   public void merge(
-      ReviewDb db,
       Change change,
       IdentifiedUser caller,
       boolean checkSubmitRules,
       SubmitInput submitInput,
       boolean dryrun)
-      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
           PermissionBackendException {
     this.submitInput = submitInput;
-    this.accountsToNotify = notifyUtil.resolveAccounts(submitInput.notifyDetails);
+    this.notify =
+        notifyResolver.resolve(
+            firstNonNull(submitInput.notify, NotifyHandling.ALL), submitInput.notifyDetails);
     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(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()))
+                .caller(getClass())
+                .retryWithTrace(t -> !(t instanceof RestApiException))
+                .build());
+
+        if (projects > 1) {
+          topicMetrics.topicSubmissionsCompleted.increment();
+        }
+      } catch (IOException e) {
+        // Anything before the merge attempt is an error
+        throw new StorageException(e);
       }
-      logDebug("Calculated to merge %s", indexBackedChangeSet);
-
-      // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
-      ChangeSet cs = reloadChanges(indexBackedChangeSet);
-
-      // Count cross-project submissions outside of the retry loop. The chance of a single project
-      // failing increases with the number of projects, so the failure count would be inflated if
-      // this metric were incremented inside of integrateIntoHistory.
-      int projects = cs.projects().size();
-      if (projects > 1) {
-        topicMetrics.topicSubmissions.increment();
-      }
-
-      RetryTracker retryTracker = new RetryTracker();
-      retryHelper.execute(
-          updateFactory -> {
-            long attempt = retryTracker.lastAttemptNumber + 1;
-            boolean isRetry = attempt > 1;
-            if (isRetry) {
-              logDebug("Retrying, attempt #%d; skipping merged changes", attempt);
-              this.ts = TimeUtil.nowTs();
-              openRepoManager();
-            }
-            this.commitStatus = new CommitStatus(cs, isRetry);
-            if (checkSubmitRules) {
-              logDebug("Checking submit rules and state");
-              checkSubmitRulesAndState(cs, isRetry);
-            } else {
-              logDebug("Bypassing submit rules");
-              bypassSubmitRules(cs, isRetry);
-            }
-            try {
-              integrateIntoHistory(cs);
-            } catch (IntegrationException e) {
-              logError("Error from integrateIntoHistory", e);
-              throw new ResourceConflictException(e.getMessage(), e);
-            }
-            return null;
-          },
-          RetryHelper.options()
-              .listener(retryTracker)
-              // Up to the entire submit operation is retried, including possibly many projects.
-              // Multiply the timeout by the number of projects we're actually attempting to submit.
-              .timeout(
-                  retryHelper
-                      .getDefaultTimeout(ActionType.CHANGE_UPDATE)
-                      .multipliedBy(cs.projects().size()))
-              .build());
-
-      if (projects > 1) {
-        topicMetrics.topicSubmissionsCompleted.increment();
-      }
-    } catch (IOException e) {
-      // Anything before the merge attempt is an error
-      throw new OrmException(e);
     }
   }
 
@@ -519,18 +530,16 @@
       orm.close();
     }
     orm = ormProvider.get();
-    orm.setContext(db, ts, caller, submissionId);
+    orm.setContext(ts, caller, notify);
   }
 
   private ChangeSet reloadChanges(ChangeSet changeSet) {
     List<ChangeData> visible = new ArrayList<>(changeSet.changes().size());
     List<ChangeData> nonVisible = new ArrayList<>(changeSet.nonVisibleChanges().size());
-    changeSet
-        .changes()
-        .forEach(c -> visible.add(changeDataFactory.create(db, c.project(), c.getId())));
+    changeSet.changes().forEach(c -> visible.add(changeDataFactory.create(c.project(), c.getId())));
     changeSet
         .nonVisibleChanges()
-        .forEach(c -> nonVisible.add(changeDataFactory.create(db, c.project(), c.getId())));
+        .forEach(c -> nonVisible.add(changeDataFactory.create(c.project(), c.getId())));
     return new ChangeSet(visible, nonVisible);
   }
 
@@ -565,19 +574,19 @@
   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);
-    Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
+    logger.atFine().log("Beginning merge attempt on %s", cs);
+    Map<BranchNameKey, BranchBatch> toSubmit = new HashMap<>();
 
-    ListMultimap<Branch.NameKey, ChangeData> cbb;
+    ListMultimap<BranchNameKey, ChangeData> cbb;
     try {
       cbb = cs.changesByBranch();
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       throw new IntegrationException("Error reading changes to submit", e);
     }
-    Set<Branch.NameKey> branches = cbb.keySet();
+    Set<BranchNameKey> branches = cbb.keySet();
 
-    for (Branch.NameKey branch : branches) {
-      OpenRepo or = openRepo(branch.getParentKey());
+    for (BranchNameKey branch : branches) {
+      OpenRepo or = openRepo(branch.project());
       if (or != null) {
         toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
       }
@@ -590,10 +599,9 @@
       SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
       List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
       this.allProjects = submoduleOp.getProjectsInOrder();
-      batchUpdateFactory.execute(
+      BatchUpdate.execute(
           orm.batchUpdates(allProjects),
           new SubmitStrategyListener(submitInput, strategies, commitStatus),
-          submissionId,
           dryrun);
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(e.getMessage());
@@ -635,27 +643,26 @@
   }
 
   private List<SubmitStrategy> getSubmitStrategies(
-      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
+      Map<BranchNameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
       throws IntegrationException, NoSuchProjectException, IOException {
     List<SubmitStrategy> strategies = new ArrayList<>();
-    Set<Branch.NameKey> allBranches = submoduleOp.getBranchesInOrder();
+    Set<BranchNameKey> allBranches = submoduleOp.getBranchesInOrder();
     Set<CodeReviewCommit> allCommits =
         toSubmit.values().stream().map(BranchBatch::commits).flatMap(Set::stream).collect(toSet());
-    for (Branch.NameKey branch : allBranches) {
-      OpenRepo or = orm.getRepo(branch.getParentKey());
+    for (BranchNameKey branch : allBranches) {
+      OpenRepo or = orm.getRepo(branch.project());
       if (toSubmit.containsKey(branch)) {
         BranchBatch submitting = toSubmit.get(branch);
+        logger.atFine().log("adding ops for branch batch %s", submitting);
         OpenBranch ob = or.getBranch(branch);
-        checkNotNull(
+        requireNonNull(
             submitting.submitType(),
-            "null submit type for %s; expected to previously fail fast",
-            submitting);
+            String.format("null submit type for %s; expected to previously fail fast", submitting));
         Set<CodeReviewCommit> commitsToSubmit = submitting.commits();
         ob.mergeTip = new MergeTip(ob.oldTip, commitsToSubmit);
         SubmitStrategy strategy =
             submitStrategyFactory.create(
                 submitting.submitType(),
-                db,
                 or.rw,
                 or.canMergeFlag,
                 getAlreadyAccepted(or, ob.oldTip),
@@ -666,7 +673,6 @@
                 commitStatus,
                 submissionId,
                 submitInput,
-                accountsToNotify,
                 submoduleOp,
                 dryrun);
         strategies.add(strategy);
@@ -693,7 +699,7 @@
     }
 
     try {
-      for (Ref r : or.repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
+      for (Ref r : or.repo.getRefDatabase().getRefsByPrefix(Constants.R_HEADS)) {
         try {
           CodeReviewCommit aac = or.rw.parseCommit(r.getObjectId());
           if (!commitStatus.commits.values().contains(aac)) {
@@ -707,7 +713,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: %s", alreadyAccepted.size(), alreadyAccepted);
     return alreadyAccepted;
   }
 
@@ -721,7 +727,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);
 
@@ -736,7 +742,7 @@
         notes = cd.notes();
         chg = cd.change();
         st = getSubmitType(cd);
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         commitStatus.logProblem(changeId, e);
         continue;
       }
@@ -759,47 +765,53 @@
       }
       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;
       }
 
       PatchSet ps;
-      Branch.NameKey destBranch = chg.getDest();
+      BranchNameKey destBranch = chg.getDest();
       try {
         ps = cd.currentPatchSet();
-      } catch (OrmException e) {
+      } catch (StorageException e) {
         commitStatus.logProblem(changeId, e);
         continue;
       }
-      if (ps == null || ps.getRevision() == null || ps.getRevision().get() == null) {
-        commitStatus.logProblem(changeId, "Missing patch set or revision on change");
+      if (ps == null) {
+        commitStatus.logProblem(changeId, "Missing patch set on change");
         continue;
       }
 
-      String idstr = ps.getRevision().get();
-      ObjectId id;
-      try {
-        id = ObjectId.fromString(idstr);
-      } catch (IllegalArgumentException e) {
-        commitStatus.logProblem(changeId, e);
-        continue;
-      }
+      ObjectId id = ps.commitId();
+      if (!revisions.containsEntry(id, ps.id())) {
+        if (revisions.containsValue(ps.id())) {
+          // TODO This is actually an error, the patch set ref exists but points to a revision that
+          // is different from the revision that we have stored for the patch set in the change
+          // meta data.
+          commitStatus.logProblem(
+              changeId,
+              "Revision "
+                  + id.name()
+                  + " of patch set "
+                  + ps.number()
+                  + " does not match the revision of the patch set ref "
+                  + ps.id().toRefName());
+          continue;
+        }
 
-      if (!revisions.containsEntry(id, ps.getId())) {
-        // TODO this is actually an error, the branch is gone but we
-        // want to merge the issue. We can't safely do that if the
-        // tip is not reachable.
-        //
+        // The patch set ref is not found but we want to merge the change. We can't safely do that
+        // if the patch set ref is missing. In a multi-master setup this can indicate a replication
+        // lag (e.g. the change meta data was already replicated, but the replication of the patch
+        // set ref is still pending).
         commitStatus.logProblem(
             changeId,
-            "Revision "
-                + idstr
-                + " of patch set "
-                + ps.getPatchSetId()
-                + " does not match "
-                + ps.getId().toRefName()
-                + " for change");
+            "Patch set ref "
+                + ps.id().toRefName()
+                + " not found. Expected patch set ref of "
+                + ps.number()
+                + " to point to revision "
+                + id.name());
         continue;
       }
 
@@ -812,13 +824,12 @@
       }
 
       commit.setNotes(notes);
-      commit.setPatchsetId(ps.getId());
+      commit.setPatchsetId(ps.id());
       commitStatus.put(commit);
 
       MergeValidators mergeValidators = mergeValidatorsFactory.create();
       try {
-        mergeValidators.validatePreMerge(
-            or.repo, commit, or.project, destBranch, ps.getId(), caller);
+        mergeValidators.validatePreMerge(or.repo, commit, or.project, destBranch, ps.id(), caller);
       } catch (MergeValidationException mve) {
         commitStatus.problem(changeId, mve.getMessage());
         continue;
@@ -826,7 +837,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);
   }
 
@@ -850,7 +861,7 @@
         revisions.put(e.getValue().getObjectId(), PatchSet.Id.fromRef(e.getKey()));
       }
       return revisions;
-    } catch (IOException | OrmException e) {
+    } catch (IOException | StorageException e) {
       throw new IntegrationException("Failed to validate changes", e);
     }
   }
@@ -864,7 +875,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);
@@ -876,15 +887,14 @@
     try {
       for (ChangeData cd : queryProvider.get().byProjectOpen(destProject)) {
         try (BatchUpdate bu =
-            batchUpdateFactory.create(db, destProject, internalUserFactory.create(), ts)) {
-          bu.setRequestId(submissionId);
+            batchUpdateFactory.create(destProject, internalUserFactory.create(), ts)) {
           bu.addOp(
               cd.getId(),
               new BatchUpdateOp() {
                 @Override
-                public boolean updateChange(ChangeContext ctx) throws OrmException {
+                public boolean updateChange(ChangeContext ctx) {
                   Change change = ctx.getChange();
-                  if (!change.getStatus().isOpen()) {
+                  if (!change.isNew()) {
                     return false;
                   }
 
@@ -897,8 +907,7 @@
                           change.getLastUpdatedOn(),
                           ChangeMessagesUtil.TAG_MERGED,
                           "Project was deleted.");
-                  cmUtil.addChangeMessage(
-                      ctx.getDb(), ctx.getUpdate(change.currentPatchSetId()), msg);
+                  cmUtil.addChangeMessage(ctx.getUpdate(change.currentPatchSetId()), msg);
 
                   return true;
                 }
@@ -906,12 +915,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);
+    } catch (StorageException e) {
+      logger.atWarning().withCause(e).log(
+          "Cannot abandon changes for deleted project %s", destProject);
     }
   }
 
@@ -935,28 +946,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..d985b7f 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.submit;
 
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.Maps;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -31,7 +32,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;
@@ -67,7 +67,7 @@
     BatchUpdate update;
 
     private final ObjectReader reader;
-    private final Map<Branch.NameKey, OpenBranch> branches;
+    private final Map<BranchNameKey, OpenBranch> branches;
 
     private OpenRepo(Repository repo, ProjectState project) {
       this.repo = repo;
@@ -84,7 +84,7 @@
       branches = Maps.newHashMapWithExpectedSize(1);
     }
 
-    OpenBranch getBranch(Branch.NameKey branch) throws IntegrationException {
+    OpenBranch getBranch(BranchNameKey branch) throws IntegrationException {
       OpenBranch ob = branches.get(branch);
       if (ob == null) {
         ob = new OpenBranch(this, branch);
@@ -106,13 +106,13 @@
     }
 
     public BatchUpdate getUpdate() {
-      checkState(db != null, "call setContext before getUpdate");
+      checkState(caller != null, "call setContext before getUpdate");
       if (update == null) {
         update =
             batchUpdateFactory
-                .create(db, getProjectName(), caller, ts)
+                .create(getProjectName(), caller, ts)
                 .setRepository(repo, rw, ins)
-                .setRequestId(submissionId)
+                .setNotify(notify)
                 .setOnSubmitValidators(onSubmitValidatorsFactory.create());
       }
       return update;
@@ -134,13 +134,13 @@
     final CodeReviewCommit oldTip;
     MergeTip mergeTip;
 
-    OpenBranch(OpenRepo or, Branch.NameKey name) throws IntegrationException {
+    OpenBranch(OpenRepo or, BranchNameKey name) throws IntegrationException {
       try {
-        update = or.repo.updateRef(name.get());
+        update = or.repo.updateRef(name.branch());
         if (update.getOldObjectId() != null) {
           oldTip = or.rw.parseCommit(update.getOldObjectId());
-        } else if (Objects.equals(or.repo.getFullBranch(), name.get())
-            || Objects.equals(RefNames.REFS_CONFIG, name.get())) {
+        } else if (Objects.equals(or.repo.getFullBranch(), name.branch())
+            || Objects.equals(RefNames.REFS_CONFIG, name.branch())) {
           oldTip = null;
           update.setExpectedOldObjectId(ObjectId.zeroId());
         } else {
@@ -159,10 +159,9 @@
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
 
-  private ReviewDb db;
   private Timestamp ts;
   private IdentifiedUser caller;
-  private RequestId submissionId;
+  private NotifyResolver.Result notify;
 
   @Inject
   MergeOpRepoManager(
@@ -178,15 +177,10 @@
     openRepos = new HashMap<>();
   }
 
-  public void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller, RequestId submissionId) {
-    this.db = db;
-    this.ts = ts;
-    this.caller = caller;
-    this.submissionId = submissionId;
-  }
-
-  public RequestId getSubmissionId() {
-    return submissionId;
+  public void setContext(Timestamp ts, IdentifiedUser caller, NotifyResolver.Result notify) {
+    this.ts = requireNonNull(ts);
+    this.caller = requireNonNull(caller);
+    this.notify = requireNonNull(notify);
   }
 
   public OpenRepo getRepo(Project.NameKey project) throws NoSuchProjectException, IOException {
@@ -211,7 +205,7 @@
       throws NoSuchProjectException, IOException {
     List<BatchUpdate> updates = new ArrayList<>(projects.size());
     for (Project.NameKey project : projects) {
-      updates.add(getRepo(project).getUpdate().setRefLogMessage("merged"));
+      updates.add(getRepo(project).getUpdate().setNotify(notify).setRefLogMessage("merged"));
     }
     return updates;
   }
diff --git a/java/com/google/gerrit/server/submit/MergeSorter.java b/java/com/google/gerrit/server/submit/MergeSorter.java
index 8b9b1cc..f2f6537 100644
--- a/java/com/google/gerrit/server/submit/MergeSorter.java
+++ b/java/com/google/gerrit/server/submit/MergeSorter.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashSet;
@@ -26,19 +30,25 @@
 import org.eclipse.jgit.revwalk.RevFlag;
 
 public class MergeSorter {
+  @Nullable private final CurrentUser caller;
   private final CodeReviewRevWalk rw;
   private final RevFlag canMergeFlag;
   private final Set<RevCommit> accepted;
+  private final Provider<InternalChangeQuery> queryProvider;
   private final Set<CodeReviewCommit> incoming;
 
   public MergeSorter(
+      @Nullable CurrentUser caller,
       CodeReviewRevWalk rw,
       Set<RevCommit> alreadyAccepted,
       RevFlag canMergeFlag,
+      Provider<InternalChangeQuery> queryProvider,
       Set<CodeReviewCommit> incoming) {
+    this.caller = caller;
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
     this.accepted = alreadyAccepted;
+    this.queryProvider = queryProvider;
     this.incoming = incoming;
   }
 
@@ -63,6 +73,9 @@
           // aren't permitted to merge at this time. Drop n.
           //
           n.setStatusCode(CommitMergeStatus.MISSING_DEPENDENCY);
+          n.setStatusMessage(
+              CommitMergeStatus.createMissingDependencyMessage(
+                  caller, queryProvider, n.name(), c.name()));
           break;
         }
         contents.add(c);
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 3e9f068..d182f24 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -14,24 +14,25 @@
 
 package com.google.gerrit.server.submit;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginContext;
 import com.google.gerrit.server.project.ProjectCache;
 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.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -85,13 +86,13 @@
 
   public MergeSuperSet setMergeOpRepoManager(MergeOpRepoManager orm) {
     checkState(this.orm == null);
-    this.orm = checkNotNull(orm);
+    this.orm = requireNonNull(orm);
     closeOrm = false;
     return this;
   }
 
-  public ChangeSet completeChangeSet(ReviewDb db, Change change, CurrentUser user)
-      throws IOException, OrmException, PermissionBackendException {
+  public ChangeSet completeChangeSet(Change change, CurrentUser user)
+      throws IOException, PermissionBackendException {
     try {
       if (orm == null) {
         orm = repoManagerProvider.get();
@@ -100,21 +101,28 @@
       List<ChangeData> cds = queryProvider.get().byLegacyChangeId(change.getId());
       checkState(cds.size() == 1, "Expected exactly one ChangeData, got " + cds.size());
       ChangeData cd = Iterables.getFirst(cds, null);
-      ProjectState projectState = projectCache.checkedGet(cd.project());
-      ChangeSet changeSet =
-          new ChangeSet(
-              cd,
-              projectState != null
-                  && projectState.statePermitsRead()
-                  && permissionBackend
-                      .user(user)
-                      .change(cd)
-                      .database(db)
-                      .test(ChangePermission.READ));
-      if (wholeTopicEnabled(cfg)) {
-        return completeChangeSetIncludingTopics(db, changeSet, user);
+
+      boolean visible = false;
+      if (cd != null) {
+        ProjectState projectState = projectCache.checkedGet(cd.project());
+
+        if (projectState.statePermitsRead()) {
+          try {
+            permissionBackend.user(user).change(cd).check(ChangePermission.READ);
+            visible = true;
+          } catch (AuthException e) {
+            // Do nothing.
+          }
+        }
       }
-      return mergeSuperSetComputation.get().completeWithoutTopic(db, orm, changeSet, user);
+
+      ChangeSet changeSet = new ChangeSet(cd, visible);
+      if (wholeTopicEnabled(cfg)) {
+        return completeChangeSetIncludingTopics(changeSet, user);
+      }
+      try (TraceContext traceContext = PluginContext.newTrace(mergeSuperSetComputation)) {
+        return mergeSuperSetComputation.get().completeWithoutTopic(orm, changeSet, user);
+      }
     } finally {
       if (closeOrm && orm != null) {
         orm.close();
@@ -127,9 +135,8 @@
    * Completes {@code changeSet} with any additional changes from its topics
    *
    * <p>{@link #completeChangeSetIncludingTopics} calls this repeatedly, alternating with {@link
-   * MergeSuperSetComputation#completeWithoutTopic(ReviewDb, MergeOpRepoManager, ChangeSet,
-   * CurrentUser)}, to discover what additional changes should be submitted with a change until the
-   * set stops growing.
+   * MergeSuperSetComputation#completeWithoutTopic(MergeOpRepoManager, ChangeSet, CurrentUser)}, to
+   * discover what additional changes should be submitted with a change until the set stops growing.
    *
    * <p>{@code topicsSeen} and {@code visibleTopicsSeen} keep track of topics already explored to
    * avoid wasted work.
@@ -137,12 +144,8 @@
    * @return the resulting larger {@link ChangeSet}
    */
   private ChangeSet topicClosure(
-      ReviewDb db,
-      ChangeSet changeSet,
-      CurrentUser user,
-      Set<String> topicsSeen,
-      Set<String> visibleTopicsSeen)
-      throws OrmException, PermissionBackendException, IOException {
+      ChangeSet changeSet, CurrentUser user, Set<String> topicsSeen, Set<String> visibleTopicsSeen)
+      throws PermissionBackendException, IOException {
     List<ChangeData> visibleChanges = new ArrayList<>();
     List<ChangeData> nonVisibleChanges = new ArrayList<>();
 
@@ -153,7 +156,7 @@
         continue;
       }
       for (ChangeData topicCd : byTopicOpen(topic)) {
-        if (canRead(db, user, topicCd)) {
+        if (canRead(user, topicCd)) {
           visibleChanges.add(topicCd);
         } else {
           nonVisibleChanges.add(topicCd);
@@ -176,35 +179,43 @@
     return new ChangeSet(visibleChanges, nonVisibleChanges);
   }
 
-  private ChangeSet completeChangeSetIncludingTopics(
-      ReviewDb db, ChangeSet changeSet, CurrentUser user)
-      throws IOException, OrmException, PermissionBackendException {
+  private ChangeSet completeChangeSetIncludingTopics(ChangeSet changeSet, CurrentUser user)
+      throws IOException, PermissionBackendException {
     Set<String> topicsSeen = new HashSet<>();
     Set<String> visibleTopicsSeen = new HashSet<>();
     int oldSeen;
-    int seen = 0;
+    int seen;
 
-    changeSet = topicClosure(db, changeSet, user, topicsSeen, visibleTopicsSeen);
+    changeSet = topicClosure(changeSet, user, topicsSeen, visibleTopicsSeen);
     seen = topicsSeen.size() + visibleTopicsSeen.size();
 
     do {
       oldSeen = seen;
-      changeSet = mergeSuperSetComputation.get().completeWithoutTopic(db, orm, changeSet, user);
-      changeSet = topicClosure(db, changeSet, user, topicsSeen, visibleTopicsSeen);
+      try (TraceContext traceContext = PluginContext.newTrace(mergeSuperSetComputation)) {
+        changeSet = mergeSuperSetComputation.get().completeWithoutTopic(orm, changeSet, user);
+      }
+      changeSet = topicClosure(changeSet, user, topicsSeen, visibleTopicsSeen);
       seen = topicsSeen.size() + visibleTopicsSeen.size();
     } while (seen != oldSeen);
     return changeSet;
   }
 
-  private List<ChangeData> byTopicOpen(String topic) throws OrmException {
+  private List<ChangeData> byTopicOpen(String topic) {
     return queryProvider.get().byTopicOpen(topic);
   }
 
-  private boolean canRead(ReviewDb db, CurrentUser user, ChangeData cd)
+  private boolean canRead(CurrentUser user, ChangeData cd)
       throws PermissionBackendException, IOException {
     ProjectState projectState = projectCache.checkedGet(cd.project());
-    return projectState != null
-        && projectState.statePermitsRead()
-        && permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ);
+    if (projectState == null || !projectState.statePermitsRead()) {
+      return false;
+    }
+
+    try {
+      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/MergeSuperSetComputation.java
index dd9ad9b..99239e3 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSetComputation.java
@@ -15,10 +15,8 @@
 package com.google.gerrit.server.submit;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 
 /**
@@ -41,13 +39,11 @@
    * <p>This method is invoked iteratively while new changes to be submitted together are discovered
    * by expanding the topics of the changes. This method must not do any topic expansion on its own.
    *
-   * @param db {@link ReviewDb} instance
    * @param orm {@link MergeOpRepoManager} that should be used to access repositories
    * @param changeSet A set of changes for which it is known that they should be submitted together
    * @param user The user for which the visibility checks should be performed
    * @return the completed set of changes that should be submitted together
    */
-  ChangeSet completeWithoutTopic(
-      ReviewDb db, MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user)
-      throws OrmException, IOException, PermissionBackendException;
+  ChangeSet completeWithoutTopic(MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user)
+      throws IOException, PermissionBackendException;
 }
diff --git a/java/com/google/gerrit/server/submit/RebaseSorter.java b/java/com/google/gerrit/server/submit/RebaseSorter.java
index 7ec8b0e..21ab6b7 100644
--- a/java/com/google/gerrit/server/submit/RebaseSorter.java
+++ b/java/com/google/gerrit/server/submit/RebaseSorter.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.submit;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -37,6 +37,7 @@
 public class RebaseSorter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final CurrentUser caller;
   private final CodeReviewRevWalk rw;
   private final RevFlag canMergeFlag;
   private final RevCommit initialTip;
@@ -45,12 +46,14 @@
   private final Set<CodeReviewCommit> incoming;
 
   public RebaseSorter(
+      CurrentUser caller,
       CodeReviewRevWalk rw,
       RevCommit initialTip,
       Set<RevCommit> alreadyAccepted,
       RevFlag canMergeFlag,
       Provider<InternalChangeQuery> queryProvider,
       Set<CodeReviewCommit> incoming) {
+    this.caller = caller;
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
     this.initialTip = initialTip;
@@ -82,6 +85,9 @@
             // aren't permitted to merge at this time. Drop n.
             //
             n.setStatusCode(CommitMergeStatus.MISSING_DEPENDENCY);
+            n.setStatusMessage(
+                CommitMergeStatus.createMissingDependencyMessage(
+                    caller, queryProvider, n.name(), c.name()));
           }
           // Stop RevWalk because c is either a merged commit or a missing
           // dependency. Not need to walk further.
@@ -102,7 +108,7 @@
     return sorted;
   }
 
-  private boolean isAlreadyMerged(CodeReviewCommit commit, Branch.NameKey dest) throws IOException {
+  private boolean isAlreadyMerged(CodeReviewCommit commit, BranchNameKey dest) throws IOException {
     try (CodeReviewRevWalk mirw = CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
       mirw.reset();
       mirw.markStart(commit);
@@ -118,15 +124,14 @@
       // check if the commit associated change is merged in the same branch
       List<ChangeData> changes = queryProvider.get().byCommit(commit);
       for (ChangeData change : changes) {
-        if (change.change().getStatus() == Status.MERGED
-            && change.change().getDest().equals(dest)) {
+        if (change.change().isMerged() && change.change().getDest().equals(dest)) {
           logger.atFine().log(
               "Dependency %s associated with merged change %s.", commit.getName(), change.getId());
           return true;
         }
       }
       return false;
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       throw new IOException(e);
     }
   }
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index b2b54ae..b8fb067 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -34,7 +35,6 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -59,7 +59,7 @@
     List<CodeReviewCommit> sorted;
     try {
       sorted = args.rebaseSorter.sort(toMerge);
-    } catch (IOException e) {
+    } catch (IOException | StorageException e) {
       throw new IntegrationException("Commit sorting failed", e);
     }
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
@@ -119,7 +119,7 @@
     @Override
     public void updateRepoImpl(RepoContext ctx)
         throws IntegrationException, InvalidChangeOperationException, RestApiException, IOException,
-            OrmException, PermissionBackendException {
+            PermissionBackendException {
       if (args.mergeUtil.canFastForward(
           args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
         if (!rebaseAlways) {
@@ -137,8 +137,8 @@
         // RebaseAlways means we modify commit message.
         args.rw.parseBody(toMerge);
         newPatchSetId =
-            ChangeUtil.nextPatchSetIdFromChangeRefsMap(
-                ctx.getRepoView().getRefs(getId().toRefPrefix()),
+            ChangeUtil.nextPatchSetIdFromChangeRefs(
+                ctx.getRepoView().getRefs(getId().toRefPrefix()).keySet(),
                 toMerge.change().currentPatchSetId());
         RevCommit mergeTip = args.mergeTip.getCurrentTip();
         args.rw.parseBody(mergeTip);
@@ -156,7 +156,8 @@
                   cherryPickCmtMsg,
                   args.rw,
                   0,
-                  true);
+                  true,
+                  false);
         } catch (MergeConflictException mce) {
           // Unlike in Cherry-pick case, this should never happen.
           toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
@@ -169,14 +170,13 @@
         ctx.addRefUpdate(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName());
       } else {
         // Stale read of patch set is ok; see comments in RebaseChangeOp.
-        PatchSet origPs = args.psUtil.get(ctx.getDb(), toMerge.getNotes(), toMerge.getPatchsetId());
+        PatchSet origPs = args.psUtil.get(toMerge.getNotes(), toMerge.getPatchsetId());
         rebaseOp =
             args.rebaseFactory
                 .create(toMerge.notes(), origPs, args.mergeTip.getCurrentTip())
                 .setFireRevisionCreated(false)
                 // Bypass approval copier since SubmitStrategyOp copy all approvals
                 // later anyway.
-                .setCopyApprovals(false)
                 .setValidate(false)
                 .setCheckAddPatchSetPermission(false)
                 // RebaseAlways should set always modify commit message like
@@ -185,6 +185,7 @@
                 // Do not post message after inserting new patchset because there
                 // will be one about change being merged already.
                 .setPostMessage(false)
+                .setSendEmail(false)
                 .setMatchAuthorToCommitterDate(
                     args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE));
         try {
@@ -213,7 +214,7 @@
 
     @Override
     public PatchSet updateChangeImpl(ChangeContext ctx)
-        throws NoSuchChangeException, ResourceConflictException, OrmException, IOException {
+        throws NoSuchChangeException, ResourceConflictException, IOException {
       if (newCommit == null) {
         checkState(!rebaseAlways, "RebaseAlways must never fast forward");
         // otherwise, took the fast-forward option, nothing to do.
@@ -226,15 +227,14 @@
         newPs = rebaseOp.getPatchSet();
       } else {
         // CherryPick
-        PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+        PatchSet prevPs = args.psUtil.current(ctx.getNotes());
         newPs =
             args.psUtil.insert(
-                ctx.getDb(),
                 ctx.getRevWalk(),
                 ctx.getUpdate(newPatchSetId),
                 newPatchSetId,
                 newCommit,
-                prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
+                prevPs != null ? prevPs.groups() : ImmutableList.of(),
                 null,
                 null);
       }
@@ -246,7 +246,7 @@
     }
 
     @Override
-    public void postUpdateImpl(Context ctx) throws OrmException {
+    public void postUpdateImpl(Context ctx) {
       if (rebaseOp != null) {
         rebaseOp.postUpdate(ctx);
       }
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index 055e3cc..3a59a45 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -19,15 +19,19 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.project.NoSuchProjectException;
 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.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashSet;
@@ -63,8 +67,8 @@
 
   public static Set<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
     return Streams.concat(
-            repo.getRefDatabase().getRefs(Constants.R_HEADS).values().stream(),
-            repo.getRefDatabase().getRefs(Constants.R_TAGS).values().stream())
+            repo.getRefDatabase().getRefsByPrefix(Constants.R_HEADS).stream(),
+            repo.getRefDatabase().getRefsByPrefix(Constants.R_TAGS).stream())
         .map(Ref::getObjectId)
         .filter(Objects::nonNull)
         .collect(toSet());
@@ -91,18 +95,24 @@
 
   private final ProjectCache projectCache;
   private final MergeUtil.Factory mergeUtilFactory;
+  private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
-  SubmitDryRun(ProjectCache projectCache, MergeUtil.Factory mergeUtilFactory) {
+  SubmitDryRun(
+      ProjectCache projectCache,
+      MergeUtil.Factory mergeUtilFactory,
+      Provider<InternalChangeQuery> queryProvider) {
     this.projectCache = projectCache;
     this.mergeUtilFactory = mergeUtilFactory;
+    this.queryProvider = queryProvider;
   }
 
   public boolean run(
+      @Nullable CurrentUser caller,
       SubmitType submitType,
       Repository repo,
       CodeReviewRevWalk rw,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       ObjectId tip,
       ObjectId toMerge,
       Set<RevCommit> alreadyAccepted)
@@ -116,7 +126,13 @@
             repo,
             rw,
             mergeUtilFactory.create(getProject(destBranch)),
-            new MergeSorter(rw, alreadyAccepted, canMerge, ImmutableSet.of(toMergeCommit)));
+            new MergeSorter(
+                caller,
+                rw,
+                alreadyAccepted,
+                canMerge,
+                queryProvider,
+                ImmutableSet.of(toMergeCommit)));
 
     switch (submitType) {
       case CHERRY_PICK:
@@ -139,10 +155,10 @@
     }
   }
 
-  private ProjectState getProject(Branch.NameKey branch) throws NoSuchProjectException {
-    ProjectState p = projectCache.get(branch.getParentKey());
+  private ProjectState getProject(BranchNameKey branch) throws NoSuchProjectException {
+    ProjectState p = projectCache.get(branch.project());
     if (p == null) {
-      throw new NoSuchProjectException(branch.getParentKey());
+      throw new NoSuchProjectException(branch.project());
     }
     return p;
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 7b7ae48..73cbc8f 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -14,18 +14,14 @@
 
 package com.google.gerrit.server.submit;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -34,6 +30,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -43,13 +40,14 @@
 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.ProjectConfig;
 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;
@@ -84,18 +82,16 @@
     interface Factory {
       Arguments create(
           SubmitType submitType,
-          Branch.NameKey destBranch,
+          BranchNameKey destBranch,
           CommitStatus commitStatus,
           CodeReviewRevWalk rw,
           IdentifiedUser caller,
           MergeTip mergeTip,
           RevFlag canMergeFlag,
-          ReviewDb db,
           Set<RevCommit> alreadyAccepted,
           Set<CodeReviewCommit> incoming,
           RequestId submissionId,
           SubmitInput submitInput,
-          ListMultimap<RecipientType, Account.Id> accountsToNotify,
           SubmoduleOp submoduleOp,
           boolean dryrun);
     }
@@ -115,19 +111,19 @@
     final OnSubmitValidators.Factory onSubmitValidatorsFactory;
     final TagCache tagCache;
     final Provider<InternalChangeQuery> queryProvider;
+    final ProjectConfig.Factory projectConfigFactory;
+    final SetPrivateOp.Factory setPrivateOpFactory;
 
-    final Branch.NameKey destBranch;
+    final BranchNameKey destBranch;
     final CodeReviewRevWalk rw;
     final CommitStatus commitStatus;
     final IdentifiedUser caller;
     final MergeTip mergeTip;
     final RevFlag canMergeFlag;
-    final ReviewDb db;
     final Set<RevCommit> alreadyAccepted;
     final RequestId submissionId;
     final SubmitType submitType;
     final SubmitInput submitInput;
-    final ListMultimap<RecipientType, Account.Id> accountsToNotify;
     final SubmoduleOp submoduleOp;
 
     final ProjectState project;
@@ -154,19 +150,19 @@
         OnSubmitValidators.Factory onSubmitValidatorsFactory,
         TagCache tagCache,
         Provider<InternalChangeQuery> queryProvider,
-        @Assisted Branch.NameKey destBranch,
+        ProjectConfig.Factory projectConfigFactory,
+        SetPrivateOp.Factory setPrivateOpFactory,
+        @Assisted BranchNameKey destBranch,
         @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
         @Assisted IdentifiedUser caller,
         @Assisted MergeTip mergeTip,
         @Assisted RevFlag canMergeFlag,
-        @Assisted ReviewDb db,
         @Assisted Set<RevCommit> alreadyAccepted,
         @Assisted Set<CodeReviewCommit> incoming,
         @Assisted RequestId submissionId,
         @Assisted SubmitType submitType,
         @Assisted SubmitInput submitInput,
-        @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify,
         @Assisted SubmoduleOp submoduleOp,
         @Assisted boolean dryrun) {
       this.accountCache = accountCache;
@@ -176,12 +172,14 @@
       this.repoManager = repoManager;
       this.cmUtil = cmUtil;
       this.labelNormalizer = labelNormalizer;
+      this.projectConfigFactory = projectConfigFactory;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.psUtil = psUtil;
       this.projectCache = projectCache;
       this.rebaseFactory = rebaseFactory;
       this.tagCache = tagCache;
       this.queryProvider = queryProvider;
+      this.setPrivateOpFactory = setPrivateOpFactory;
 
       this.serverIdent = serverIdent;
       this.destBranch = destBranch;
@@ -190,24 +188,28 @@
       this.caller = caller;
       this.mergeTip = mergeTip;
       this.canMergeFlag = canMergeFlag;
-      this.db = db;
       this.alreadyAccepted = alreadyAccepted;
       this.submissionId = submissionId;
       this.submitType = submitType;
       this.submitInput = submitInput;
-      this.accountsToNotify = accountsToNotify;
       this.submoduleOp = submoduleOp;
       this.dryrun = dryrun;
 
       this.project =
-          checkNotNull(
-              projectCache.get(destBranch.getParentKey()),
-              "project not found: %s",
-              destBranch.getParentKey());
-      this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag, incoming);
+          requireNonNull(
+              projectCache.get(destBranch.project()),
+              () -> String.format("project not found: %s", destBranch.project()));
+      this.mergeSorter =
+          new MergeSorter(caller, rw, alreadyAccepted, canMergeFlag, queryProvider, incoming);
       this.rebaseSorter =
           new RebaseSorter(
-              rw, mergeTip.getInitialTip(), alreadyAccepted, canMergeFlag, queryProvider, incoming);
+              caller,
+              rw,
+              mergeTip.getInitialTip(),
+              alreadyAccepted,
+              canMergeFlag,
+              queryProvider,
+              incoming);
       this.mergeUtil = mergeUtilFactory.create(project);
       this.onSubmitValidatorsFactory = onSubmitValidatorsFactory;
     }
@@ -216,7 +218,7 @@
   final Arguments args;
 
   SubmitStrategy(Arguments args) {
-    this.args = checkNotNull(args);
+    this.args = requireNonNull(args);
   }
 
   /**
@@ -246,12 +248,14 @@
     Collections.reverse(difference);
     for (CodeReviewCommit c : difference) {
       Change.Id id = c.change().getId();
+      bu.addOp(id, args.setPrivateOpFactory.create(false, null));
       bu.addOp(id, new ImplicitIntegrateOp(args, c));
       maybeAddTestHelperOp(bu, id);
     }
 
     // Then ops for explicitly merged changes
     for (SubmitStrategyOp op : ops) {
+      bu.addOp(op.getId(), args.setPrivateOpFactory.create(false, null));
       bu.addOp(op.getId(), op);
       maybeAddTestHelperOp(bu, op.getId());
     }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 2cb0744..e2e4991 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -14,20 +14,16 @@
 
 package com.google.gerrit.server.submit;
 
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.IdentifiedUser;
 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;
@@ -48,18 +44,16 @@
 
   public SubmitStrategy create(
       SubmitType submitType,
-      ReviewDb db,
       CodeReviewRevWalk rw,
       RevFlag canMergeFlag,
       Set<RevCommit> alreadyAccepted,
       Set<CodeReviewCommit> incoming,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       IdentifiedUser caller,
       MergeTip mergeTip,
       CommitStatus commitStatus,
       RequestId submissionId,
       SubmitInput submitInput,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
       SubmoduleOp submoduleOp,
       boolean dryrun)
       throws IntegrationException {
@@ -72,12 +66,10 @@
             caller,
             mergeTip,
             canMergeFlag,
-            db,
             alreadyAccepted,
             incoming,
             submissionId,
             submitInput,
-            accountsToNotify,
             submoduleOp,
             dryrun);
     switch (submitType) {
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
index 59b6403..3d6aa55 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
@@ -30,6 +31,8 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 public class SubmitStrategyListener implements BatchUpdateListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final Collection<SubmitStrategy> strategies;
   private final CommitStatus commitStatus;
   private final boolean failAfterRefUpdates;
@@ -96,7 +99,7 @@
           args.rw,
           args.canMergeFlag,
           args.mergeTip.getCurrentTip(),
-          initialTip == null ? ImmutableSet.<RevCommit>of() : ImmutableSet.of(initialTip));
+          initialTip == null ? ImmutableSet.of() : ImmutableSet.of(initialTip));
     }
   }
 
@@ -106,9 +109,17 @@
       CodeReviewCommit commit = commitStatus.get(id);
       CommitMergeStatus s = commit != null ? commit.getStatusCode() : null;
       if (s == null) {
+        logger.atSevere().log("change %d: change not processed by merge strategy", id.get());
         commitStatus.problem(id, "internal error: change not processed by merge strategy");
         continue;
       }
+      if (commit.getStatusMessage().isPresent()) {
+        logger.atFine().log(
+            "change %d: Status for commit %s is %s. %s",
+            id.get(), commit.name(), s, commit.getStatusMessage().get());
+      } else {
+        logger.atFine().log("change %d: Status for commit %s is %s.", id.get(), commit.name(), s);
+      }
       switch (s) {
         case CLEAN_MERGE:
         case CLEAN_REBASE:
@@ -118,7 +129,7 @@
 
         case ALREADY_MERGED:
           // Already an ancestor of tip.
-          alreadyMerged.add(commit.getPatchsetId().getParentKey());
+          alreadyMerged.add(commit.getPatchsetId().changeId());
           break;
 
         case PATH_CONFLICT:
@@ -128,13 +139,14 @@
         case CANNOT_REBASE_ROOT:
         case NOT_FAST_FORWARD:
         case EMPTY_COMMIT:
+        case MISSING_DEPENDENCY:
           // TODO(dborowitz): Reformat these messages to be more appropriate for
           // short problem descriptions.
-          commitStatus.problem(id, CharMatcher.is('\n').collapseFrom(s.getMessage(), ' '));
-          break;
-
-        case MISSING_DEPENDENCY:
-          commitStatus.problem(id, "depends on change that was not submitted");
+          String message = s.getDescription();
+          if (commit.getStatusMessage().isPresent()) {
+            message += " " + commit.getStatusMessage().get();
+          }
+          commitStatus.problem(id, CharMatcher.is('\n').collapseFrom(message, ' '));
           break;
 
         default:
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 62dabae..ce6862f 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -15,17 +15,16 @@
 package com.google.gerrit.server.submit;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
 
-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.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.LabelId;
@@ -33,8 +32,6 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -51,10 +48,8 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
-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;
@@ -93,17 +88,18 @@
     return toMerge;
   }
 
-  protected final Branch.NameKey getDest() {
+  protected final BranchNameKey getDest() {
     return toMerge.change().getDest();
   }
 
   protected final Project.NameKey getProject() {
-    return getDest().getParentKey();
+    return getDest().project();
   }
 
   @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,36 +113,37 @@
     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);
 
     // Needed by postUpdate, at which point mergeTip will have advanced further,
     // so it's easier to just snapshot the command.
     command =
-        new ReceiveCommand(firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().get());
+        new ReceiveCommand(
+            firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().branch());
     ctx.addRefUpdate(command);
     args.submoduleOp.addBranchTip(getDest(), tipAfter);
   }
 
   private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit)
       throws IntegrationException {
-    String refName = getDest().get();
+    String refName = getDest().branch();
     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());
+        ProjectConfig cfg = args.projectConfigFactory.create(getProject());
         cfg.load(ctx.getRevWalk(), commit);
       } catch (Exception e) {
         throw new IntegrationException(
@@ -184,9 +181,7 @@
         continue; // Bogus ref, can't be merged into tip so we don't care.
       }
     }
-    Collections.sort(
-        commits,
-        ReviewDbUtil.intKeyOrdering().reverse().onResultOf(CodeReviewCommit::getPatchsetId));
+    commits.sort(comparing((CodeReviewCommit c) -> c.getPatchsetId().get()).reversed());
     CodeReviewCommit result = MergeUtil.findAnyMergedInto(rw, commits, tip);
     if (result == null) {
       return null;
@@ -216,21 +211,20 @@
 
   @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;
 
-    if (ctx.getChange().getStatus() == Change.Status.MERGED) {
+    if (ctx.getChange().isMerged()) {
       // 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;
@@ -239,10 +233,10 @@
     if (alreadyMergedCommit != null) {
       alreadyMergedCommit.setNotes(ctx.getNotes());
       mergedPatchSet = getOrCreateAlreadyMergedPatchSet(ctx);
-      newPsId = mergedPatchSet.getId();
     } else {
       PatchSet newPatchSet = updateChangeImpl(ctx);
-      newPsId = checkNotNull(ctx.getChange().currentPatchSetId());
+      PatchSet.Id oldPsId = requireNonNull(toMerge.getPatchsetId());
+      PatchSet.Id newPsId = requireNonNull(ctx.getChange().currentPatchSetId());
       if (newPatchSet == null) {
         checkState(
             oldPsId.equals(newPsId),
@@ -253,12 +247,11 @@
         // Ok to use stale notes to get the old patch set, which didn't change
         // during the submit strategy.
         mergedPatchSet =
-            checkNotNull(
-                args.psUtil.get(ctx.getDb(), ctx.getNotes(), oldPsId),
-                "missing old patch set %s",
-                oldPsId);
+            requireNonNull(
+                args.psUtil.get(ctx.getNotes(), oldPsId),
+                () -> String.format("missing old patch set %s", oldPsId));
       } else {
-        PatchSet.Id n = newPatchSet.getId();
+        PatchSet.Id n = newPatchSet.id();
         checkState(
             !n.equals(oldPsId) && n.equals(newPsId),
             "current patch was %s and is now %s, but updateChangeImpl returned"
@@ -273,10 +266,12 @@
     Change c = ctx.getChange();
     Change.Id id = c.getId();
     CodeReviewCommit commit = args.commitStatus.get(id);
-    checkNotNull(commit, "missing commit for change " + id);
+    requireNonNull(commit, () -> String.format("missing commit for change %s", 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);
+    requireNonNull(
+        s,
+        () -> String.format("status not set for change %s; expected to previously fail fast", id));
+    logger.atFine().log("Status of change %s (%s) on %s: %s", id, commit.name(), c.getDest(), s);
     setApproval(ctx, args.caller);
 
     mergeResultRev =
@@ -288,7 +283,7 @@
             : alreadyMergedCommit;
     try {
       setMerged(ctx, message(ctx, commit, s));
-    } catch (OrmException err) {
+    } catch (StorageException err) {
       String msg = "Error updating change status for " + id;
       logger.atSevere().withCause(err).log(msg);
       args.commitStatus.logProblem(id, msg);
@@ -299,44 +294,35 @@
     return true;
   }
 
-  private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx)
-      throws IOException, OrmException {
+  private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx) throws IOException {
     PatchSet.Id psId = alreadyMergedCommit.getPatchsetId();
-    logDebug("Fixing up already-merged patch set %s", psId);
-    PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+    logger.atFine().log("Fixing up already-merged patch set %s", psId);
+    PatchSet prevPs = args.psUtil.current(ctx.getNotes());
     ctx.getRevWalk().parseBody(alreadyMergedCommit);
     ctx.getChange()
         .setCurrentPatchSet(
             psId, alreadyMergedCommit.getShortMessage(), ctx.getChange().getOriginalSubject());
-    PatchSet existing = args.psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+    PatchSet existing = args.psUtil.get(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
     // a patch set ref. Fix up the database. Note that this uses the current
     // user as the uploader, which is as good a guess as any.
     List<String> groups =
-        prevPs != null ? prevPs.getGroups() : GroupCollector.getDefaultGroups(alreadyMergedCommit);
+        prevPs != null ? prevPs.groups() : GroupCollector.getDefaultGroups(alreadyMergedCommit);
     return args.psUtil.insert(
-        ctx.getDb(),
-        ctx.getRevWalk(),
-        ctx.getUpdate(psId),
-        psId,
-        alreadyMergedCommit,
-        groups,
-        null,
-        null);
+        ctx.getRevWalk(), ctx.getUpdate(psId), psId, alreadyMergedCommit, groups, null, null);
   }
 
-  private void setApproval(ChangeContext ctx, IdentifiedUser user)
-      throws OrmException, IOException {
+  private void setApproval(ChangeContext ctx, IdentifiedUser user) throws IOException {
     Change.Id id = ctx.getChange().getId();
     List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
     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);
@@ -346,29 +332,25 @@
     // If the submit strategy created a new revision (rebase, cherry-pick), copy
     // approvals as well.
     if (!newPsId.equals(oldPsId)) {
-      saveApprovals(normalized, ctx, newPsUpdate, true);
-      submitter = convertPatchSet(newPsId).apply(submitter);
+      saveApprovals(normalized, newPsUpdate, true);
+      submitter = submitter.copyWithPatchSet(newPsId);
     }
   }
 
   private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
-      throws OrmException, IOException {
+      throws IOException {
     PatchSet.Id psId = update.getPatchSetId();
     Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
     for (PatchSetApproval psa :
         args.approvalsUtil.byPatchSet(
-            ctx.getDb(),
-            ctx.getNotes(),
-            ctx.getUser(),
-            psId,
-            ctx.getRevWalk(),
-            ctx.getRepoView().getConfig())) {
-      byKey.put(psa.getKey(), psa);
+            ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+      byKey.put(psa.key(), psa);
     }
 
     submitter =
-        ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
-    byKey.put(submitter.getKey(), submitter);
+        ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen())
+            .build();
+    byKey.put(submitter.key(), submitter);
 
     // Flatten out existing approvals for this patch set based upon the current
     // permissions. Once the change is closed the approvals are not updated at
@@ -376,77 +358,44 @@
     // was added. So we need to make sure votes are accurate now. This way if
     // permissions get modified in the future, historical records stay accurate.
     LabelNormalizer.Result normalized =
-        args.labelNormalizer.normalize(ctx.getNotes(), ctx.getUser(), byKey.values());
-    update.putApproval(submitter.getLabel(), submitter.getValue());
-    saveApprovals(normalized, ctx, update, false);
+        args.labelNormalizer.normalize(ctx.getNotes(), byKey.values());
+    update.putApproval(submitter.label(), submitter.value());
+    saveApprovals(normalized, update, false);
     return normalized;
   }
 
   private void saveApprovals(
-      LabelNormalizer.Result normalized,
-      ChangeContext ctx,
-      ChangeUpdate update,
-      boolean includeUnchanged)
-      throws OrmException {
-    PatchSet.Id psId = update.getPatchSetId();
-    ctx.getDb().patchSetApprovals().upsert(convertPatchSet(normalized.getNormalized(), psId));
-    ctx.getDb().patchSetApprovals().upsert(zero(convertPatchSet(normalized.deleted(), psId)));
+      LabelNormalizer.Result normalized, ChangeUpdate update, boolean includeUnchanged) {
     for (PatchSetApproval psa : normalized.updated()) {
-      update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
+      update.putApprovalFor(psa.accountId(), psa.label(), psa.value());
     }
     for (PatchSetApproval psa : normalized.deleted()) {
-      update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
+      update.removeApprovalFor(psa.accountId(), psa.label());
     }
 
     // TODO(dborowitz): Don't use a label in NoteDb; just check when status
     // change happened.
     for (PatchSetApproval psa : normalized.unchanged()) {
       if (includeUnchanged || psa.isLegacySubmit()) {
-        logDebug("Adding submit label %s", psa);
-        update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
+        logger.atFine().log("Adding submit label %s", psa);
+        update.putApprovalFor(psa.accountId(), psa.label(), psa.value());
       }
     }
   }
 
-  private static Function<PatchSetApproval, PatchSetApproval> convertPatchSet(
-      final PatchSet.Id psId) {
-    return psa -> {
-      if (psa.getPatchSetId().equals(psId)) {
-        return psa;
-      }
-      return new PatchSetApproval(psId, psa);
-    };
-  }
-
-  private static Iterable<PatchSetApproval> convertPatchSet(
-      Iterable<PatchSetApproval> approvals, PatchSet.Id psId) {
-    return Iterables.transform(approvals, convertPatchSet(psId));
-  }
-
-  private static Iterable<PatchSetApproval> zero(Iterable<PatchSetApproval> approvals) {
-    return Iterables.transform(
-        approvals,
-        a -> {
-          PatchSetApproval copy = new PatchSetApproval(a.getPatchSetId(), a);
-          copy.setValue((short) 0);
-          return copy;
-        });
-  }
-
   private String getByAccountName() {
-    checkNotNull(submitter, "getByAccountName called before submitter populated");
+    requireNonNull(submitter, "getByAccountName called before submitter populated");
     Optional<Account> account =
-        args.accountCache.get(submitter.getAccountId()).map(AccountState::getAccount);
-    if (account.isPresent() && account.get().getFullName() != null) {
-      return " by " + account.get().getFullName();
+        args.accountCache.get(submitter.accountId()).map(AccountState::getAccount);
+    if (account.isPresent() && account.get().fullName() != null) {
+      return " by " + account.get().fullName();
     }
     return "";
   }
 
-  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
-      throws OrmException {
-    checkNotNull(s, "CommitMergeStatus may not be null");
-    String txt = s.getMessage();
+  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s) {
+    requireNonNull(s, "CommitMergeStatus may not be null");
+    String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
       return message(ctx, commit.getPatchsetId(), txt + getByAccountName());
     } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
@@ -490,10 +439,9 @@
         psId, ctx.getUser(), ctx.getWhen(), body, ChangeMessagesUtil.TAG_MERGED);
   }
 
-  private void setMerged(ChangeContext ctx, ChangeMessage msg) throws OrmException {
+  private void setMerged(ChangeContext ctx, ChangeMessage msg) {
     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());
 
@@ -501,7 +449,7 @@
     // which is not the user from the update context. addMergedMessage was able
     // to do this in the past.
     if (msg != null) {
-      args.cmUtil.addChangeMessage(db, ctx.getUpdate(msg.getPatchSetId()), msg);
+      args.cmUtil.addChangeMessage(ctx.getUpdate(msg.getPatchSetId()), msg);
     }
   }
 
@@ -516,7 +464,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);
@@ -526,7 +474,7 @@
           getProject(), command.getRefName(), command.getOldId(), command.getNewId());
       // TODO(dborowitz): Move to BatchUpdate? Would also allow us to run once
       // per project even if multiple changes to refs/meta/config are submitted.
-      if (RefNames.REFS_CONFIG.equals(getDest().get())) {
+      if (RefNames.REFS_CONFIG.equals(getDest().branch())) {
         args.projectCache.evict(getProject());
         ProjectState p = args.projectCache.get(getProject());
         try (Repository git = args.repoManager.openRepository(getProject())) {
@@ -541,12 +489,7 @@
     // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
-          .create(
-              ctx.getProject(),
-              getId(),
-              submitter.getAccountId(),
-              args.submitInput.notify,
-              args.accountsToNotify)
+          .create(ctx.getProject(), getId(), submitter.accountId(), ctx.getNotify(getId()))
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
@@ -555,7 +498,7 @@
       args.changeMerged.fire(
           updatedChange,
           mergedPatchSet,
-          args.accountCache.get(submitter.getAccountId()).orElse(null),
+          args.accountCache.get(submitter.accountId()).orElse(null),
           args.mergeTip.getCurrentTip().name(),
           ctx.getWhen());
     }
@@ -600,37 +543,4 @@
           "cannot update gitlink for the commit at branch: " + args.destBranch);
     }
   }
-
-  protected final void logDebug(String msg) {
-    logger.atFine().log(this.args.submissionId + msg);
-  }
-
-  protected final void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(this.args.submissionId + msg, arg);
-  }
-
-  protected final void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(this.args.submissionId + msg, arg1, arg2);
-  }
-
-  protected final void logDebug(
-      String msg,
-      @Nullable Object arg1,
-      @Nullable Object arg2,
-      @Nullable Object arg3,
-      @Nullable Object arg4) {
-    logger.atFine().log(this.args.submissionId + msg, arg1, arg2, arg3, arg4);
-  }
-
-  protected final void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", args.submissionId, msg);
-  }
-
-  protected void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", args.submissionId, msg);
-  }
-
-  protected void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 7e9fa6a..8ad063a 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -15,15 +15,16 @@
 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.UsedAt;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
@@ -78,9 +79,9 @@
 
   /** Only used for branches without code review changes */
   public class GitlinkOp implements RepoOnlyOp {
-    private final Branch.NameKey branch;
+    private final BranchNameKey branch;
 
-    GitlinkOp(Branch.NameKey branch) {
+    GitlinkOp(BranchNameKey branch) {
       this.branch = branch;
     }
 
@@ -88,7 +89,7 @@
     public void updateRepo(RepoContext ctx) throws Exception {
       CodeReviewCommit c = composeGitlinksCommit(branch);
       if (c != null) {
-        ctx.addRefUpdate(c.getParent(0), c, branch.get());
+        ctx.addRefUpdate(c.getParent(0), c, branch.branch());
         addBranchTip(branch, c);
       }
     }
@@ -100,72 +101,74 @@
     private final Provider<PersonIdent> serverIdent;
     private final Config cfg;
     private final ProjectCache projectCache;
-    private final BatchUpdate.Factory batchUpdateFactory;
 
     @Inject
     Factory(
         GitModules.Factory gitmodulesFactory,
         @GerritPersonIdent Provider<PersonIdent> serverIdent,
         @GerritServerConfig Config cfg,
-        ProjectCache projectCache,
-        BatchUpdate.Factory batchUpdateFactory) {
+        ProjectCache projectCache) {
       this.gitmodulesFactory = gitmodulesFactory;
       this.serverIdent = serverIdent;
       this.cfg = cfg;
       this.projectCache = projectCache;
-      this.batchUpdateFactory = batchUpdateFactory;
     }
 
-    public SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm)
+    public SubmoduleOp create(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleException {
       return new SubmoduleOp(
-          gitmodulesFactory,
-          serverIdent.get(),
-          cfg,
-          projectCache,
-          batchUpdateFactory,
-          updatedBranches,
-          orm);
+          gitmodulesFactory, serverIdent.get(), cfg, projectCache, updatedBranches, orm);
     }
   }
 
   private final GitModules.Factory gitmodulesFactory;
   private final PersonIdent myIdent;
   private final ProjectCache projectCache;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final VerboseSuperprojectUpdate verboseSuperProject;
   private final boolean enableSuperProjectSubscriptions;
   private final long maxCombinedCommitMessageSize;
   private final long maxCommitMessages;
   private final MergeOpRepoManager orm;
-  private final Map<Branch.NameKey, GitModules> branchGitModules;
+  private final Map<BranchNameKey, GitModules> branchGitModules;
 
-  // always update-to-current branch tips during submit process
-  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
-  private final Set<Branch.NameKey> affectedBranches;
-  // sorted version of affectedBranches
-  private final ImmutableSet<Branch.NameKey> sortedBranches;
-  // map of superproject branch and its submodule subscriptions
-  private final SetMultimap<Branch.NameKey, SubmoduleSubscription> targets;
-  // map of superproject and its branches which has submodule subscriptions
-  private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
+  /** Branches updated as part of the enclosing submit or push batch. */
+  private final ImmutableSet<BranchNameKey> 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<BranchNameKey, CodeReviewCommit> branchTips;
+
+  /**
+   * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
+   * which are subscribed to by some superproject.
+   */
+  private final Set<BranchNameKey> affectedBranches;
+
+  /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
+  private final ImmutableSet<BranchNameKey> sortedBranches;
+
+  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
+  private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+
+  /**
+   * Multimap of superproject name to all branch names within that superproject which have submodule
+   * subscriptions.
+   */
+  private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
 
   private SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
       PersonIdent myIdent,
       Config cfg,
       ProjectCache projectCache,
-      BatchUpdate.Factory batchUpdateFactory,
-      Set<Branch.NameKey> updatedBranches,
+      Set<BranchNameKey> updatedBranches,
       MergeOpRepoManager orm)
       throws SubmoduleException {
     this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
     this.projectCache = projectCache;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.verboseSuperProject =
         cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
     this.enableSuperProjectSubscriptions =
@@ -174,29 +177,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<BranchNameKey> calculateSubscriptionMaps() throws SubmoduleException {
     if (!enableSuperProjectSubscriptions) {
-      logDebug("Updating superprojects disabled");
+      logger.atFine().log("Updating superprojects disabled");
       return null;
     }
 
-    logDebug("Calculating superprojects - submodules map");
-    LinkedHashSet<Branch.NameKey> allVisited = new LinkedHashSet<>();
-    for (Branch.NameKey updatedBranch : updatedBranches) {
+    logger.atFine().log("Calculating superprojects - submodules map");
+    LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
+    for (BranchNameKey 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
@@ -209,11 +240,11 @@
   }
 
   private void searchForSuperprojects(
-      Branch.NameKey current,
-      LinkedHashSet<Branch.NameKey> currentVisited,
-      LinkedHashSet<Branch.NameKey> allVisited)
+      BranchNameKey current,
+      LinkedHashSet<BranchNameKey> currentVisited,
+      LinkedHashSet<BranchNameKey> allVisited)
       throws SubmoduleException {
-    logDebug("Now processing %s", current);
+    logger.atFine().log("Now processing %s", current);
 
     if (currentVisited.contains(current)) {
       throw new SubmoduleException(
@@ -230,10 +261,10 @@
       Collection<SubmoduleSubscription> subscriptions =
           superProjectSubscriptionsForSubmoduleBranch(current);
       for (SubmoduleSubscription sub : subscriptions) {
-        Branch.NameKey superBranch = sub.getSuperProject();
+        BranchNameKey superBranch = sub.getSuperProject();
         searchForSuperprojects(superBranch, currentVisited, allVisited);
         targets.put(superBranch, sub);
-        branchesByProject.put(superBranch.getParentKey(), superBranch);
+        branchesByProject.put(superBranch.project(), superBranch);
         affectedBranches.add(superBranch);
         affectedBranches.add(sub.getSubmodule());
       }
@@ -272,31 +303,33 @@
     return sb.toString();
   }
 
-  private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src, SubscribeSection s)
+  private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
       throws IOException {
-    Collection<Branch.NameKey> ret = new HashSet<>();
-    logDebug("Inspecting SubscribeSection %s", s);
+    Collection<BranchNameKey> ret = new HashSet<>();
+    logger.atFine().log("Inspecting SubscribeSection %s", s);
     for (RefSpec r : s.getMatchingRefSpecs()) {
-      logDebug("Inspecting [matching] ref %s", r);
-      if (!r.matchSource(src.get())) {
+      logger.atFine().log("Inspecting [matching] ref %s", r);
+      if (!r.matchSource(src.branch())) {
         continue;
       }
       if (r.isWildcard()) {
         // refs/heads/*[:refs/somewhere/*]
-        ret.add(new Branch.NameKey(s.getProject(), r.expandFromSource(src.get()).getDestination()));
+        ret.add(
+            BranchNameKey.create(
+                s.getProject(), r.expandFromSource(src.branch()).getDestination()));
       } else {
         // e.g. refs/heads/master[:refs/heads/stable]
         String dest = r.getDestination();
         if (dest == null) {
           dest = r.getSource();
         }
-        ret.add(new Branch.NameKey(s.getProject(), dest));
+        ret.add(BranchNameKey.create(s.getProject(), dest));
       }
     }
 
     for (RefSpec r : s.getMultiMatchRefSpecs()) {
-      logDebug("Inspecting [all] ref %s", r);
-      if (!r.matchSource(src.get())) {
+      logger.atFine().log("Inspecting [all] ref %s", r);
+      if (!r.matchSource(src.branch())) {
         continue;
       }
       OpenRepo or;
@@ -309,39 +342,40 @@
         continue;
       }
 
-      for (Ref ref : or.repo.getRefDatabase().getRefs(RefNames.REFS_HEADS).values()) {
+      for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
         if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
           continue;
         }
-        Branch.NameKey b = new Branch.NameKey(s.getProject(), ref.getName());
+        BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
         if (!ret.contains(b)) {
           ret.add(b);
         }
       }
     }
-    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);
+      BranchNameKey srcBranch) throws IOException {
+    logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
-    Project.NameKey srcProject = srcBranch.getParentKey();
+    Project.NameKey srcProject = srcBranch.project();
     for (SubscribeSection s : projectCache.get(srcProject).getSubscribeSections(srcBranch)) {
-      logDebug("Checking subscribe section %s", s);
-      Collection<Branch.NameKey> branches = getDestinationBranches(srcBranch, s);
-      for (Branch.NameKey targetBranch : branches) {
-        Project.NameKey targetProject = targetBranch.getParentKey();
+      logger.atFine().log("Checking subscribe section %s", s);
+      Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
+      for (BranchNameKey targetBranch : branches) {
+        Project.NameKey targetProject = targetBranch.project();
         try {
           OpenRepo or = orm.getRepo(targetProject);
-          ObjectId id = or.repo.resolve(targetBranch.get());
+          ObjectId id = or.repo.resolve(targetBranch.branch());
           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 +387,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;
   }
 
@@ -371,24 +405,23 @@
           superProjects.add(project);
           // get a new BatchUpdate for the super project
           OpenRepo or = orm.getRepo(project);
-          for (Branch.NameKey branch : branchesByProject.get(project)) {
+          for (BranchNameKey branch : branchesByProject.get(project)) {
             addOp(or.getUpdate(), branch);
           }
         }
       }
-      batchUpdateFactory.execute(
-          orm.batchUpdates(superProjects), BatchUpdateListener.NONE, orm.getSubmissionId(), false);
+      BatchUpdate.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(BranchNameKey subscriber)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.getRepo(subscriber.getParentKey());
+      or = orm.getRepo(subscriber.project());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
@@ -397,7 +430,7 @@
     if (branchTips.containsKey(subscriber)) {
       currentCommit = branchTips.get(subscriber);
     } else {
-      Ref r = or.repo.exactRef(subscriber.get());
+      Ref r = or.repo.exactRef(subscriber.branch());
       if (r == null) {
         throw new SubmoduleException(
             "The branch was probably deleted from the subscriber repository");
@@ -406,14 +439,16 @@
       addBranchTip(subscriber, currentCommit);
     }
 
-    StringBuilder msgbuf = new StringBuilder("");
+    StringBuilder msgbuf = new StringBuilder();
     PersonIdent author = null;
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
     int count = 0;
 
-    List<SubmoduleSubscription> subscriptions = new ArrayList<>(targets.get(subscriber));
-    Collections.sort(subscriptions, comparing(SubmoduleSubscription::getPath));
+    List<SubmoduleSubscription> subscriptions =
+        targets.get(subscriber).stream()
+            .sorted(comparing(SubmoduleSubscription::getPath))
+            .collect(toList());
     for (SubmoduleSubscription s : subscriptions) {
       if (count > 0) {
         msgbuf.append("\n\n");
@@ -421,9 +456,11 @@
       RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
       count++;
       if (newCommit != null) {
+        PersonIdent newCommitAuthor = newCommit.getAuthorIdent();
         if (author == null) {
-          author = newCommit.getAuthorIdent();
-        } else if (!author.equals(newCommit.getAuthorIdent())) {
+          author = new PersonIdent(newCommitAuthor, myIdent.getWhen());
+        } else if (!author.getName().equals(newCommitAuthor.getName())
+            || !author.getEmailAddress().equals(newCommitAuthor.getEmailAddress())) {
           author = myIdent;
         }
       }
@@ -450,17 +487,16 @@
   }
 
   /** Amend an existing commit with gitlink updates */
-  public CodeReviewCommit composeGitlinksCommit(
-      Branch.NameKey subscriber, CodeReviewCommit currentCommit)
+  CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.getRepo(subscriber.getParentKey());
+      or = orm.getRepo(subscriber.project());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
 
-    StringBuilder msgbuf = new StringBuilder("");
+    StringBuilder msgbuf = new StringBuilder();
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
     for (SubmoduleSubscription s : targets.get(subscriber)) {
@@ -494,9 +530,10 @@
   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());
+      subOr = orm.getRepo(s.getSubmodule().project());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access submodule", e);
     }
@@ -509,19 +546,39 @@
             "Requested to update gitlink "
                 + s.getPath()
                 + " in "
-                + s.getSubmodule().getParentKey().get()
+                + s.getSubmodule().project().get()
                 + " but entry "
                 + "doesn't have gitlink file mode.";
         throw new SubmoduleException(errMsg);
       }
-      oldCommit = subOr.rw.parseCommit(dce.getObjectId());
+      // Parse the current gitlink entry commit in the subproject repo. This is used to add a
+      // shortlog for this submodule to the commit message in the superproject.
+      //
+      // Even if we don't strictly speaking need that commit message, parsing the commit is a sanity
+      // check that the old gitlink is a commit that actually exists. If not, then there is an
+      // inconsistency between the superproject and subproject state, and we don't want to risk
+      // making things worse by updating the gitlink to something else.
+      try {
+        oldCommit = subOr.rw.parseCommit(dce.getObjectId());
+      } catch (IOException e) {
+        // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
+        // proceed, it will just skip this gitlink update.
+        logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
+        return null;
+      }
     }
 
     final CodeReviewCommit newCommit;
     if (branchTips.containsKey(s.getSubmodule())) {
+      // This submodule's branch was updated as part of this specific submit batch: update the
+      // gitlink to point to the new commit from the batch.
       newCommit = branchTips.get(s.getSubmodule());
     } else {
-      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().get());
+      // For whatever reason, this submodule was not updated as part of this submit batch, but the
+      // superproject is still subscribed to this branch. Re-read the ref to see if anything has
+      // changed since the last time the gitlink was updated, and roll that update into the same
+      // commit as all other submodule updates.
+      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().branch());
       if (ref == null) {
         ed.add(new DeletePath(s.getPath()));
         return null;
@@ -560,9 +617,10 @@
     msgbuf.append("* Update ");
     msgbuf.append(s.getPath());
     msgbuf.append(" from branch '");
-    msgbuf.append(s.getSubmodule().getShortName());
+    msgbuf.append(s.getSubmodule().shortName());
     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) {
@@ -588,7 +646,7 @@
         int newSize = msgbuf.length() + bullet.length() + message.length();
         if (++numMessages > maxCommitMessages
             || newSize > maxCombinedCommitMessageSize
-            || iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize) {
+            || (iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize)) {
           msgbuf.append(ellipsis);
           break;
         }
@@ -613,14 +671,14 @@
     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);
     }
 
-    for (Branch.NameKey branch : updatedBranches) {
-      projects.add(branch.getParentKey());
+    for (BranchNameKey branch : updatedBranches) {
+      projects.add(branch.project());
     }
     return ImmutableSet.copyOf(projects);
   }
@@ -641,10 +699,10 @@
 
     current.add(project);
     Set<Project.NameKey> subprojects = new HashSet<>();
-    for (Branch.NameKey branch : branchesByProject.get(project)) {
+    for (BranchNameKey branch : branchesByProject.get(project)) {
       Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
       for (SubmoduleSubscription s : subscriptions) {
-        subprojects.add(s.getSubmodule().getParentKey());
+        subprojects.add(s.getSubmodule().project());
       }
     }
 
@@ -656,8 +714,8 @@
     projects.add(project);
   }
 
-  public ImmutableSet<Branch.NameKey> getBranchesInOrder() {
-    LinkedHashSet<Branch.NameKey> branches = new LinkedHashSet<>();
+  ImmutableSet<BranchNameKey> getBranchesInOrder() {
+    LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
     if (sortedBranches != null) {
       branches.addAll(sortedBranches);
     }
@@ -665,27 +723,15 @@
     return ImmutableSet.copyOf(branches);
   }
 
-  public boolean hasSubscription(Branch.NameKey branch) {
+  boolean hasSubscription(BranchNameKey branch) {
     return targets.containsKey(branch);
   }
 
-  public void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
+  void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
     branchTips.put(branch, tip);
   }
 
-  public void addOp(BatchUpdate bu, Branch.NameKey branch) {
+  void addOp(BatchUpdate bu, BranchNameKey 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..6dee241 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.server.update;
 
 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.collect.ImmutableMultiset.toImmutableMultiset;
+import static com.google.common.flogger.LazyArgs.lazy;
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
@@ -26,27 +29,38 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multiset;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
+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.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
-import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.RequestId;
+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.notedb.TooManyUpdatesException;
 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;
+import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -56,6 +70,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.TimeZone;
+import java.util.TreeMap;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -65,152 +80,120 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
- * Helper for a set of updates that should be applied for a site.
+ * Helper for a set of change updates that should be applied to the NoteDb database.
  *
  * <p>An update operation can be divided into three phases:
  *
  * <ol>
  *   <li>Git reference updates
- *   <li>Database updates
+ *   <li>Review metadata updates
  *   <li>Post-update steps
  *   <li>
  * </ol>
  *
  * A single conceptual operation, such as a REST API call or a merge operation, may make multiple
  * changes at each step, which all need to be serialized relative to each other. Moreover, for
- * consistency, <em>all</em> git ref updates must be performed before <em>any</em> database updates,
- * since database updates might refer to newly-created patch set refs. And all post-update steps,
- * such as hooks, should run only after all storage mutations have completed.
+ * consistency, the git ref updates must be visible to the review metadata updates, since for
+ * example the metadata might refer to newly-created patch set refs. In NoteDb, this is accomplished
+ * by combining these two phases into a single {@link BatchRefUpdate}.
  *
- * <p>Depending on the backend used, each step might support batching, for example in a {@code
- * BatchRefUpdate} or one or more database transactions. All operations in one phase must complete
- * successfully before proceeding to the next phase.
+ * <p>Similarly, all post-update steps, such as sending email, must run only after all storage
+ * mutations have completed.
  */
-public abstract class BatchUpdate implements AutoCloseable {
+public class BatchUpdate implements AutoCloseable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static Module module() {
     return new FactoryModule() {
       @Override
       public void configure() {
-        factory(ReviewDbBatchUpdate.AssistedFactory.class);
-        factory(NoteDbBatchUpdate.AssistedFactory.class);
+        factory(BatchUpdate.Factory.class);
       }
     };
   }
 
-  @Singleton
-  public static class Factory {
-    private final NotesMigration migration;
-    private final ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory;
-    private final NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory;
+  // TODO(dborowitz): Make this package-private to force all callers to use RetryHelper.
+  public interface Factory {
+    BatchUpdate create(Project.NameKey project, CurrentUser user, Timestamp when);
+  }
 
-    // TODO(dborowitz): Make this non-injectable to force all callers to use RetryHelper.
-    @Inject
-    Factory(
-        NotesMigration migration,
-        ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
-        NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory) {
-      this.migration = migration;
-      this.reviewDbBatchUpdateFactory = reviewDbBatchUpdateFactory;
-      this.noteDbBatchUpdateFactory = noteDbBatchUpdateFactory;
+  public static void execute(
+      Collection<BatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
+      throws UpdateException, RestApiException {
+    requireNonNull(listener);
+    if (updates.isEmpty()) {
+      return;
     }
 
-    public BatchUpdate create(
-        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when) {
-      if (migration.disableChangeReviewDb()) {
-        return noteDbBatchUpdateFactory.create(db, project, user, when);
+    checkDifferentProject(updates);
+
+    try {
+      List<ListenableFuture<?>> indexFutures = new ArrayList<>();
+      List<ChangesHandle> handles = new ArrayList<>(updates.size());
+      try {
+        for (BatchUpdate u : updates) {
+          u.executeUpdateRepo();
+        }
+        listener.afterUpdateRepos();
+        for (BatchUpdate u : updates) {
+          handles.add(u.executeChangeOps(dryrun));
+        }
+        for (ChangesHandle h : handles) {
+          h.execute();
+          indexFutures.addAll(h.startIndexFutures());
+        }
+        listener.afterUpdateRefs();
+        listener.afterUpdateChanges();
+      } finally {
+        for (ChangesHandle h : handles) {
+          h.close();
+        }
       }
-      return reviewDbBatchUpdateFactory.create(db, project, user, when);
-    }
 
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    public void execute(
-        Collection<BatchUpdate> updates,
-        BatchUpdateListener listener,
-        @Nullable RequestId requestId,
-        boolean dryRun)
-        throws UpdateException, RestApiException {
-      checkNotNull(listener);
-      checkDifferentProject(updates);
-      // It's safe to downcast all members of the input collection in this case, because the only
-      // way a caller could have gotten any BatchUpdates in the first place is to call the create
-      // method above, which always returns instances of the type we expect. Just to be safe,
-      // copy them into an ImmutableList so there is no chance the callee can pollute the input
-      // collection.
-      if (migration.disableChangeReviewDb()) {
-        ImmutableList<NoteDbBatchUpdate> noteDbUpdates =
-            (ImmutableList) ImmutableList.copyOf(updates);
-        NoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
-      } else {
-        ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
-            (ImmutableList) ImmutableList.copyOf(updates);
-        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+      ((ListenableFuture<?>) Futures.allAsList(indexFutures)).get();
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates.stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
+      if (!dryrun) {
+        for (BatchUpdate u : updates) {
+          u.executePostOps();
+        }
       }
-    }
-
-    private static void checkDifferentProject(Collection<BatchUpdate> updates) {
-      Multiset<Project.NameKey> projectCounts =
-          updates.stream().map(u -> u.project).collect(toImmutableMultiset());
-      checkArgument(
-          projectCounts.entrySet().size() == updates.size(),
-          "updates must all be for different projects, got: %s",
-          projectCounts);
+    } catch (Exception e) {
+      wrapAndThrowException(e);
     }
   }
 
-  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) {
-      if (o == null) {
-        o = u.order;
-      } else if (u.order != o) {
-        throw new IllegalArgumentException("cannot mix execution orders");
-      }
-    }
-    if (o != Order.REPO_BEFORE_DB) {
-      checkArgument(
-          listener == BatchUpdateListener.NONE,
-          "BatchUpdateListener not supported for order %s",
-          o);
-    }
-    return o;
-  }
-
-  static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
-    checkArgument(!updates.isEmpty());
-    Boolean p = null;
-    for (BatchUpdate u : updates) {
-      if (p == null) {
-        p = u.updateChangesInParallel;
-      } else if (u.updateChangesInParallel != p) {
-        throw new IllegalArgumentException("cannot mix parallel and non-parallel operations");
-      }
-    }
-    // Properly implementing this would involve hoisting the parallel loop up
-    // even further. As of this writing, the only user is ReceiveCommits,
-    // which only executes a single BatchUpdate at a time. So bail for now.
+  private static void checkDifferentProject(Collection<BatchUpdate> updates) {
+    Multiset<Project.NameKey> projectCounts =
+        updates.stream().map(u -> u.project).collect(toImmutableMultiset());
     checkArgument(
-        !p || updates.size() <= 1,
-        "cannot execute ChangeOps in parallel with more than 1 BatchUpdate");
-    return p;
+        projectCounts.entrySet().size() == updates.size(),
+        "updates must all be for different projects, got: %s",
+        projectCounts);
   }
 
-  static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+  private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+    // Convert common non-REST exception types with user-visible messages to corresponding REST
+    // exception types.
+    if (e instanceof InvalidChangeOperationException || e instanceof TooManyUpdatesException) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } else if (e instanceof NoSuchChangeException
+        || e instanceof NoSuchRefException
+        || e instanceof NoSuchProjectException) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } else if (e instanceof CommentsRejectedException) {
+      // SC_BAD_REQUEST is not ideal because it's not a syntactic error, but there is no better
+      // status code and it's isolated in monitoring.
+      throw new BadRequestException(e.getMessage(), e);
+    }
+
     Throwables.throwIfUnchecked(e);
 
     // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
@@ -218,54 +201,150 @@
     Throwables.throwIfInstanceOf(e, UpdateException.class);
     Throwables.throwIfInstanceOf(e, RestApiException.class);
 
-    // Convert other common non-REST exception types with user-visible messages to corresponding
-    // REST exception types
-    if (e instanceof InvalidChangeOperationException) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } else if (e instanceof NoSuchChangeException
-        || e instanceof NoSuchRefException
-        || e instanceof NoSuchProjectException) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    }
-
     // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
     throw new UpdateException(e);
   }
 
-  protected GitRepositoryManager repoManager;
+  class ContextImpl implements Context {
+    @Override
+    public RepoView getRepoView() throws IOException {
+      return BatchUpdate.this.getRepoView();
+    }
 
-  protected final Project.NameKey project;
-  protected final CurrentUser user;
-  protected final Timestamp when;
-  protected final TimeZone tz;
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return getRepoView().getRevWalk();
+    }
 
-  protected final ListMultimap<Change.Id, BatchUpdateOp> ops =
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public NotifyResolver.Result getNotify(Change.Id changeId) {
+      NotifyHandling notifyHandling = perChangeNotifyHandling.get(changeId);
+      return notifyHandling != null ? notify.withHandling(notifyHandling) : notify;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return getRepoView().getInserterWrapper();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      getRepoView().getCommands().add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeNotes notes;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+
+    private boolean deleted;
+
+    ChangeContextImpl(ChangeNotes notes) {
+      this.notes = requireNonNull(notes);
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(notes, user, when);
+        if (newChanges.containsKey(notes.getChangeId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeNotes getNotes() {
+      return notes;
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  /** Per-change result status from {@link #executeChangeOps}. */
+  private enum ChangeResult {
+    SKIPPED,
+    UPSERTED,
+    DELETED
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeIndexer indexer;
+  private final GitReferenceUpdated gitRefUpdated;
+
+  private final Project.NameKey project;
+  private final CurrentUser user;
+  private final Timestamp when;
+  private final TimeZone tz;
+
+  private final ListMultimap<Change.Id, BatchUpdateOp> ops =
       MultimapBuilder.linkedHashKeys().arrayListValues().build();
-  protected final Map<Change.Id, Change> newChanges = new HashMap<>();
-  protected final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
+  private final Map<Change.Id, Change> newChanges = new HashMap<>();
+  private final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
+  private final Map<Change.Id, NotifyHandling> perChangeNotifyHandling = new HashMap<>();
 
-  protected RepoView repoView;
-  protected BatchRefUpdate batchRefUpdate;
-  protected Order order;
-  protected OnSubmitValidators onSubmitValidators;
-  protected RequestId requestId;
-  protected PushCertificate pushCert;
-  protected String refLogMessage;
+  private RepoView repoView;
+  private BatchRefUpdate batchRefUpdate;
+  private OnSubmitValidators onSubmitValidators;
+  private PushCertificate pushCert;
+  private String refLogMessage;
+  private NotifyResolver.Result notify = NotifyResolver.Result.all();
 
-  private boolean updateChangesInParallel;
-
-  protected BatchUpdate(
+  @Inject
+  BatchUpdate(
       GitRepositoryManager repoManager,
-      PersonIdent serverIdent,
-      Project.NameKey project,
-      CurrentUser user,
-      Timestamp when) {
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeUpdate.Factory changeUpdateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeIndexer indexer,
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
     this.repoManager = repoManager;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.indexer = indexer;
+    this.gitRefUpdated = gitRefUpdated;
     this.project = project;
     this.user = user;
     this.when = when;
     tz = serverIdent.getTimeZone();
-    order = Order.REPO_BEFORE_DB;
   }
 
   @Override
@@ -275,20 +354,14 @@
     }
   }
 
-  public abstract void execute(BatchUpdateListener listener)
-      throws UpdateException, RestApiException;
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, false);
+  }
 
   public void execute() throws UpdateException, RestApiException {
     execute(BatchUpdateListener.NONE);
   }
 
-  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);
@@ -305,8 +378,29 @@
     return this;
   }
 
-  public BatchUpdate setOrder(Order order) {
-    this.order = order;
+  /**
+   * Set the default notification settings for all changes in the batch.
+   *
+   * @param notify notification settings.
+   * @return this.
+   */
+  public BatchUpdate setNotify(NotifyResolver.Result notify) {
+    this.notify = requireNonNull(notify);
+    return this;
+  }
+
+  /**
+   * Override the {@link NotifyHandling} on a per-change basis.
+   *
+   * <p>Only the handling enum can be overridden; all changes share the same value for {@link
+   * com.google.gerrit.server.change.NotifyResolver.Result#accounts()}.
+   *
+   * @param changeId change ID.
+   * @param notifyHandling notify handling.
+   * @return this.
+   */
+  public BatchUpdate setNotifyHandling(Change.Id changeId, NotifyHandling notifyHandling) {
+    this.perChangeNotifyHandling.put(changeId, requireNonNull(notifyHandling));
     return this;
   }
 
@@ -319,51 +413,30 @@
     return this;
   }
 
-  /**
-   * Execute {@link BatchUpdateOp#updateChange(ChangeContext)} in parallel for each change.
-   *
-   * <p>This improves performance of writing to multiple changes in separate ReviewDb transactions.
-   * When only NoteDb is used, updates to all changes are written in a single batch ref update, so
-   * parallelization is not used and this option is ignored.
-   */
-  public BatchUpdate updateChangesInParallel() {
-    this.updateChangesInParallel = true;
-    return this;
-  }
-
-  protected void initRepository() throws IOException {
+  private void initRepository() throws IOException {
     if (repoView == null) {
       repoView = new RepoView(repoManager, project);
     }
   }
 
-  protected RepoView getRepoView() throws IOException {
+  private RepoView getRepoView() throws IOException {
     initRepository();
     return repoView;
   }
 
-  protected CurrentUser getUser() {
-    return user;
-  }
-
-  protected Optional<AccountState> getAccount() {
+  private Optional<AccountState> getAccount() {
     return user.isIdentifiedUser()
         ? Optional.of(user.asIdentifiedUser().state())
         : Optional.empty();
   }
 
-  protected RevWalk getRevWalk() throws IOException {
-    initRepository();
-    return repoView.getRevWalk();
-  }
-
   public Map<String, ReceiveCommand> getRefUpdates() {
     return repoView != null ? repoView.getCommands().getCommands() : ImmutableMap.of();
   }
 
   public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
     checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
-    checkNotNull(op);
+    requireNonNull(op);
     ops.put(id, op);
     return this;
   }
@@ -375,7 +448,7 @@
   }
 
   public BatchUpdate insertChange(InsertChangeOp op) throws IOException {
-    Context ctx = newContext();
+    Context ctx = new ContextImpl();
     Change c = op.createChange(ctx);
     checkArgument(
         !newChanges.containsKey(c.getId()), "only one op allowed to create change %s", c.getId());
@@ -384,39 +457,185 @@
     return this;
   }
 
-  protected 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);
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on %d ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on %d RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
     }
   }
 
-  protected 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);
+  private class ChangesHandle implements AutoCloseable {
+    private final NoteDbUpdateManager manager;
+    private final boolean dryrun;
+    private final Map<Change.Id, ChangeResult> results;
+
+    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
+      this.manager = manager;
+      this.dryrun = dryrun;
+      results = new HashMap<>();
+    }
+
+    @Override
+    public void close() {
+      manager.close();
+    }
+
+    void setResult(Change.Id id, ChangeResult result) {
+      ChangeResult old = results.putIfAbsent(id, result);
+      checkArgument(old == null, "result for change %s already set: %s", id, old);
+    }
+
+    void execute() throws IOException {
+      BatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
+    }
+
+    List<ListenableFuture<?>> startIndexFutures() {
+      if (dryrun) {
+        return ImmutableList.of();
+      }
+      logDebug("Reindexing %d changes", results.size());
+      List<ListenableFuture<?>> indexFutures = new ArrayList<>(results.size());
+      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
+        Change.Id id = e.getKey();
+        switch (e.getValue()) {
+          case UPSERTED:
+            indexFutures.add(indexer.indexAsync(project, id));
+            break;
+          case DELETED:
+            indexFutures.add(indexer.deleteAsync(id));
+            break;
+          case SKIPPED:
+            break;
+          default:
+            throw new IllegalStateException("unexpected result: " + e.getValue());
+        }
+      }
+      return indexFutures;
     }
   }
 
-  protected 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);
+  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
+    logDebug("Executing change ops");
+    initRepository();
+    Repository repo = repoView.getRepository();
+    checkState(
+        repo.getRefDatabase().performsAtomicTransactions(),
+        "cannot use NoteDb with a repository that does not support atomic batch ref updates: %s",
+        repo);
+
+    ChangesHandle handle =
+        new ChangesHandle(
+            updateManagerFactory
+                .create(project)
+                .setChangeRepo(
+                    repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
+            dryrun);
+    if (user.isIdentifiedUser()) {
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+    }
+    handle.manager.setRefLogMessage(refLogMessage);
+    handle.manager.setPushCertificate(pushCert);
+    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+      Change.Id id = e.getKey();
+      ChangeContextImpl ctx = newChangeContext(id);
+      boolean dirty = false;
+      logDebug(
+          "Applying %d ops for change %s: %s",
+          e.getValue().size(),
+          id,
+          lazy(() -> e.getValue().stream().map(op -> op.getClass().getName()).collect(toSet())));
+      for (BatchUpdateOp op : e.getValue()) {
+        dirty |= op.updateChange(ctx);
+      }
+      if (!dirty) {
+        logDebug("No ops reported dirty, short-circuiting");
+        handle.setResult(id, ChangeResult.SKIPPED);
+        continue;
+      }
+      ctx.updates.values().forEach(handle.manager::add);
+      if (ctx.deleted) {
+        logDebug("Change %s was deleted", id);
+        handle.manager.deleteChange(id);
+        handle.setResult(id, ChangeResult.DELETED);
+      } else {
+        handle.setResult(id, ChangeResult.UPSERTED);
+      }
+    }
+    return handle;
+  }
+
+  private ChangeContextImpl newChangeContext(Change.Id id) {
+    logDebug("Opening change %s for update", id);
+    Change c = newChanges.get(id);
+    boolean isNew = c != null;
+    if (!isNew) {
+      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
+      // existence and populating columns from the parsed notes state.
+      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
+      c = ChangeNotes.Factory.newChange(project, id);
+    } else {
+      logDebug("Change %s is new", id);
+    }
+    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+    return new ChangeContextImpl(notes);
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
     }
   }
 
-  protected void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
+  private 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, arg1, arg2);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg);
+    }
+  }
+
+  private 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.isSet()) {
+      logger.atFine().log(msg, arg);
+    }
+  }
+
+  private static void logDebug(
+      String msg, @Nullable Object arg1, @Nullable Object arg2, @Nullable Object arg3) {
+    // 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.isSet()) {
+      logger.atFine().log(msg, arg1, arg2, arg3);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/update/BatchUpdateListener.java b/java/com/google/gerrit/server/update/BatchUpdateListener.java
index 847a7ca..765bba1 100644
--- a/java/com/google/gerrit/server/update/BatchUpdateListener.java
+++ b/java/com/google/gerrit/server/update/BatchUpdateListener.java
@@ -19,8 +19,6 @@
  *
  * <p>When used during execution of multiple batch updates, the {@code after*} methods are called
  * after that phase has been completed for <em>all</em> updates.
- *
- * <p>Listeners are only supported for the {@link Order#REPO_BEFORE_DB} order.
  */
 public interface BatchUpdateListener {
   public static final BatchUpdateListener NONE = new BatchUpdateListener() {};
diff --git a/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java b/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java
deleted file mode 100644
index 7d99b44..0000000
--- a/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ChangeAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gwtorm.server.AtomicUpdate;
-
-public class BatchUpdateReviewDb extends ReviewDbWrapper {
-  private final ChangeAccess changesWrapper;
-
-  BatchUpdateReviewDb(ReviewDb delegate) {
-    super(delegate);
-    changesWrapper = new BatchUpdateChanges(delegate.changes());
-  }
-
-  /** @return the underlying delegate. Supports BatchUpdateReviewDb too. */
-  public static ReviewDb unwrap(ReviewDb db) {
-    if (db instanceof BatchUpdateReviewDb) {
-      db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-    }
-    return ReviewDbUtil.unwrapDb(db);
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    return changesWrapper;
-  }
-
-  @Override
-  public void commit() {
-    throw new UnsupportedOperationException(
-        "do not call commit; BatchUpdate always manages transactions");
-  }
-
-  @Override
-  public void rollback() {
-    throw new UnsupportedOperationException(
-        "do not call rollback; BatchUpdate always manages transactions");
-  }
-
-  private static class BatchUpdateChanges extends ChangeAccessWrapper {
-    private BatchUpdateChanges(ChangeAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public void insert(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call insert; change is automatically inserted");
-    }
-
-    @Override
-    public void upsert(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call upsert; existing changes are updated automatically,"
-              + " or use InsertChangeOp for insertion");
-    }
-
-    @Override
-    public void update(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call update; change is updated automatically");
-    }
-
-    @Override
-    public void beginTransaction(Change.Id key) {
-      throw new UnsupportedOperationException("updateChange is always called within a transaction");
-    }
-
-    @Override
-    public void deleteKeys(Iterable<Change.Id> keys) {
-      throw new UnsupportedOperationException(
-          "do not call deleteKeys; use ChangeContext#deleteChange()");
-    }
-
-    @Override
-    public void delete(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call delete; use ChangeContext#deleteChange()");
-    }
-
-    @Override
-    public Change atomicUpdate(Change.Id key, AtomicUpdate<Change> update) {
-      throw new UnsupportedOperationException(
-          "do not call atomicUpdate; updateChange is always called within a transaction");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/update/ChainedReceiveCommands.java b/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
index f5f8b1d..c223aec 100644
--- a/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
+++ b/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.update;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.server.git.RefCache;
 import com.google.gerrit.server.git.RepoRefCache;
@@ -45,7 +45,7 @@
   }
 
   public ChainedReceiveCommands(RepoRefCache refCache) {
-    this.refCache = checkNotNull(refCache);
+    this.refCache = requireNonNull(refCache);
   }
 
   public RepoRefCache getRepoRefCache() {
diff --git a/java/com/google/gerrit/server/update/ChangeContext.java b/java/com/google/gerrit/server/update/ChangeContext.java
index f017580..28674fc 100644
--- a/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/java/com/google/gerrit/server/update/ChangeContext.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.update;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -53,16 +53,6 @@
   ChangeNotes getNotes();
 
   /**
-   * Don't bump the value of {@link Change#getLastUpdatedOn()}.
-   *
-   * <p>If called, don't bump the timestamp before storing to ReviewDb. Only has an effect in
-   * ReviewDb, and the only usage should be to match the behavior of NoteDb. Specifically, in NoteDb
-   * the timestamp is updated if and only if the change meta graph is updated, and is not updated
-   * when only drafts are modified.
-   */
-  void dontBumpLastUpdatedOn();
-
-  /**
    * Instruct {@link BatchUpdate} to delete this change.
    *
    * <p>If called, all other updates are ignored.
@@ -71,6 +61,6 @@
 
   /** @return change corresponding to {@link #getNotes()}. */
   default Change getChange() {
-    return checkNotNull(getNotes().getChange());
+    return requireNonNull(getNotes().getChange());
   }
 }
diff --git a/java/com/google/gerrit/server/update/CommentsRejectedException.java b/java/com/google/gerrit/server/update/CommentsRejectedException.java
new file mode 100644
index 0000000..6b0c04d
--- /dev/null
+++ b/java/com/google/gerrit/server/update/CommentsRejectedException.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import java.util.Collection;
+import java.util.stream.Collectors;
+
+/** Thrown when comment validation rejected a comment, preventing it from being published. */
+public class CommentsRejectedException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  private final ImmutableList<CommentValidationFailure> commentValidationFailures;
+
+  public CommentsRejectedException(Collection<CommentValidationFailure> commentValidationFailures) {
+    this.commentValidationFailures = ImmutableList.copyOf(commentValidationFailures);
+  }
+
+  @Override
+  public String getMessage() {
+    return "One or more comments were rejected in validation: "
+        + commentValidationFailures.stream()
+            .map(CommentValidationFailure::getMessage)
+            .collect(Collectors.joining("; "));
+  }
+
+  /**
+   * Returns the validation failures that caused this exception. By contract this list is never
+   * empty.
+   */
+  public ImmutableList<CommentValidationFailure> getCommentValidationFailures() {
+    return commentValidationFailures;
+  }
+}
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index b22968a7..8704cf0 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -14,14 +14,15 @@
 
 package com.google.gerrit.server.update;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.change.NotifyResolver;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.TimeZone;
@@ -77,16 +78,6 @@
   TimeZone getTimeZone();
 
   /**
-   * Get the ReviewDb database.
-   *
-   * <p>Callers should not manage transactions or call mutating methods on the Changes table.
-   * Mutations on other tables (including other entities in the change entity group) are fine.
-   *
-   * @return open database instance.
-   */
-  ReviewDb getDb();
-
-  /**
    * Get the user performing the update.
    *
    * <p>In the current implementation, this is always an {@link IdentifiedUser} or {@link
@@ -97,11 +88,16 @@
   CurrentUser getUser();
 
   /**
-   * Get the order in which operations are executed in this update.
+   * Get the notification settings configured by the caller.
    *
-   * @return order of operations.
+   * <p>If there are multiple changes in a batch, they may have different settings. For example, WIP
+   * changes may have reduced {@code NotifyHandling} levels, and may be in a batch with non-WIP
+   * changes.
+   *
+   * @param changeId change ID
+   * @return notification settings.
    */
-  Order getOrder();
+  NotifyResolver.Result getNotify(Change.Id changeId);
 
   /**
    * Get the identified user performing the update.
@@ -112,7 +108,7 @@
    * @return user.
    */
   default IdentifiedUser getIdentifiedUser() {
-    return checkNotNull(getUser()).asIdentifiedUser();
+    return requireNonNull(getUser()).asIdentifiedUser();
   }
 
   /**
diff --git a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
deleted file mode 100644
index 8612fac..0000000
--- a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
+++ /dev/null
@@ -1,457 +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.server.update;
-
-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 java.util.Comparator.comparing;
-
-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;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-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;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.TimeZone;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/**
- * {@link BatchUpdate} implementation using only NoteDb that updates code refs and meta refs in a
- * single {@link org.eclipse.jgit.lib.BatchRefUpdate}.
- *
- * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not
- * consulted during updates.
- */
-public class NoteDbBatchUpdate extends BatchUpdate {
-  public interface AssistedFactory {
-    NoteDbBatchUpdate create(
-        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
-  }
-
-  static void execute(
-      ImmutableList<NoteDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
-      throws UpdateException, RestApiException {
-    if (updates.isEmpty()) {
-      return;
-    }
-    setRequestIds(updates, requestId);
-
-    try {
-      @SuppressWarnings("deprecation")
-      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
-          new ArrayList<>();
-      List<ChangesHandle> handles = new ArrayList<>(updates.size());
-      Order order = getOrder(updates, listener);
-      try {
-        switch (order) {
-          case REPO_BEFORE_DB:
-            for (NoteDbBatchUpdate u : updates) {
-              u.executeUpdateRepo();
-            }
-            listener.afterUpdateRepos();
-            for (NoteDbBatchUpdate u : updates) {
-              handles.add(u.executeChangeOps(dryrun));
-            }
-            for (ChangesHandle h : handles) {
-              h.execute();
-              indexFutures.addAll(h.startIndexFutures());
-            }
-            listener.afterUpdateRefs();
-            listener.afterUpdateChanges();
-            break;
-
-          case DB_BEFORE_REPO:
-            // Call updateChange for each op before updateRepo, but defer executing the
-            // NoteDbUpdateManager until after calling updateRepo. They share an inserter and
-            // BatchRefUpdate, so it will all execute as a single batch. But we have to let
-            // NoteDbUpdateManager actually execute the update, since it has to interleave it
-            // properly with All-Users updates.
-            //
-            // TODO(dborowitz): This may still result in multiple updates to All-Users, but that's
-            // currently not a big deal because multi-change batches generally aren't affecting
-            // drafts anyway.
-            for (NoteDbBatchUpdate u : updates) {
-              handles.add(u.executeChangeOps(dryrun));
-            }
-            for (NoteDbBatchUpdate u : updates) {
-              u.executeUpdateRepo();
-            }
-            for (ChangesHandle h : handles) {
-              // TODO(dborowitz): This isn't quite good enough: in theory updateRepo may want to
-              // see the results of change meta commands, but they aren't actually added to the
-              // BatchUpdate until the body of execute. To fix this, execute needs to be split up
-              // into a method that returns a BatchRefUpdate before execution. Not a big deal at the
-              // moment, because this order is only used for deleting changes, and those updateRepo
-              // implementations definitely don't need to observe the updated change meta refs.
-              h.execute();
-              indexFutures.addAll(h.startIndexFutures());
-            }
-            break;
-          default:
-            throw new IllegalStateException("invalid execution order: " + order);
-        }
-      } finally {
-        for (ChangesHandle h : handles) {
-          h.close();
-        }
-      }
-
-      ChangeIndexer.allAsList(indexFutures).get();
-
-      // Fire ref update events only after all mutations are finished, since callers may assume a
-      // patch set ref being created means the change was created, or a branch advancing meaning
-      // some changes were closed.
-      updates
-          .stream()
-          .filter(u -> u.batchRefUpdate != null)
-          .forEach(
-              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
-
-      if (!dryrun) {
-        for (NoteDbBatchUpdate u : updates) {
-          u.executePostOps();
-        }
-      }
-    } catch (Exception e) {
-      wrapAndThrowException(e);
-    }
-  }
-
-  class ContextImpl implements Context {
-    @Override
-    public RepoView getRepoView() throws IOException {
-      return NoteDbBatchUpdate.this.getRepoView();
-    }
-
-    @Override
-    public RevWalk getRevWalk() throws IOException {
-      return getRepoView().getRevWalk();
-    }
-
-    @Override
-    public Project.NameKey getProject() {
-      return project;
-    }
-
-    @Override
-    public Timestamp getWhen() {
-      return when;
-    }
-
-    @Override
-    public TimeZone getTimeZone() {
-      return tz;
-    }
-
-    @Override
-    public ReviewDb getDb() {
-      return db;
-    }
-
-    @Override
-    public CurrentUser getUser() {
-      return user;
-    }
-
-    @Override
-    public Order getOrder() {
-      return order;
-    }
-  }
-
-  private class RepoContextImpl extends ContextImpl implements RepoContext {
-    @Override
-    public ObjectInserter getInserter() throws IOException {
-      return getRepoView().getInserterWrapper();
-    }
-
-    @Override
-    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
-      getRepoView().getCommands().add(cmd);
-    }
-  }
-
-  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
-    private final ChangeNotes notes;
-    private final Map<PatchSet.Id, ChangeUpdate> updates;
-
-    private boolean deleted;
-
-    protected ChangeContextImpl(ChangeNotes notes) {
-      this.notes = checkNotNull(notes);
-      updates = new TreeMap<>(comparing(PatchSet.Id::get));
-    }
-
-    @Override
-    public ChangeUpdate getUpdate(PatchSet.Id psId) {
-      ChangeUpdate u = updates.get(psId);
-      if (u == null) {
-        u = changeUpdateFactory.create(notes, user, when);
-        if (newChanges.containsKey(notes.getChangeId())) {
-          u.setAllowWriteToNewRef(true);
-        }
-        u.setPatchSetId(psId);
-        updates.put(psId, u);
-      }
-      return u;
-    }
-
-    @Override
-    public ChangeNotes getNotes() {
-      return notes;
-    }
-
-    @Override
-    public void dontBumpLastUpdatedOn() {
-      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
-      // change meta ref.
-    }
-
-    @Override
-    public void deleteChange() {
-      deleted = true;
-    }
-  }
-
-  /** Per-change result status from {@link #executeChangeOps}. */
-  private enum ChangeResult {
-    SKIPPED,
-    UPSERTED,
-    DELETED;
-  }
-
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeUpdate.Factory changeUpdateFactory;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final ChangeIndexer indexer;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ReviewDb db;
-
-  @Inject
-  NoteDbBatchUpdate(
-      GitRepositoryManager repoManager,
-      @GerritPersonIdent PersonIdent serverIdent,
-      ChangeNotes.Factory changeNotesFactory,
-      ChangeUpdate.Factory changeUpdateFactory,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeIndexer indexer,
-      GitReferenceUpdated gitRefUpdated,
-      @Assisted ReviewDb db,
-      @Assisted Project.NameKey project,
-      @Assisted CurrentUser user,
-      @Assisted Timestamp when) {
-    super(repoManager, serverIdent, project, user, when);
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeUpdateFactory = changeUpdateFactory;
-    this.updateManagerFactory = updateManagerFactory;
-    this.indexer = indexer;
-    this.gitRefUpdated = gitRefUpdated;
-    this.db = db;
-  }
-
-  @Override
-  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
-  }
-
-  @Override
-  protected Context newContext() {
-    return new ContextImpl();
-  }
-
-  private void executeUpdateRepo() throws UpdateException, RestApiException {
-    try {
-      logDebug("Executing updateRepo on %d ops", ops.size());
-      RepoContextImpl ctx = new RepoContextImpl();
-      for (BatchUpdateOp op : ops.values()) {
-        op.updateRepo(ctx);
-      }
-
-      logDebug("Executing updateRepo on %d RepoOnlyOps", repoOnlyOps.size());
-      for (RepoOnlyOp op : repoOnlyOps) {
-        op.updateRepo(ctx);
-      }
-
-      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
-        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
-        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
-        // first update's executeRefUpdates has finished, hence after first repo's refs have been
-        // updated, which is too late.
-        onSubmitValidators.validate(
-            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
-      }
-    } catch (Exception e) {
-      Throwables.throwIfInstanceOf(e, RestApiException.class);
-      throw new UpdateException(e);
-    }
-  }
-
-  private class ChangesHandle implements AutoCloseable {
-    private final NoteDbUpdateManager manager;
-    private final boolean dryrun;
-    private final Map<Change.Id, ChangeResult> results;
-
-    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
-      this.manager = manager;
-      this.dryrun = dryrun;
-      results = new HashMap<>();
-    }
-
-    @Override
-    public void close() {
-      manager.close();
-    }
-
-    void setResult(Change.Id id, ChangeResult result) {
-      ChangeResult old = results.putIfAbsent(id, result);
-      checkArgument(old == null, "result for change %s already set: %s", id, old);
-    }
-
-    void execute() throws OrmException, IOException {
-      NoteDbBatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
-    }
-
-    @SuppressWarnings("deprecation")
-    List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> startIndexFutures() {
-      if (dryrun) {
-        return ImmutableList.of();
-      }
-      logDebug("Reindexing %d changes", results.size());
-      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
-          new ArrayList<>(results.size());
-      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
-        Change.Id id = e.getKey();
-        switch (e.getValue()) {
-          case UPSERTED:
-            indexFutures.add(indexer.indexAsync(project, id));
-            break;
-          case DELETED:
-            indexFutures.add(indexer.deleteAsync(id));
-            break;
-          case SKIPPED:
-            break;
-          default:
-            throw new IllegalStateException("unexpected result: " + e.getValue());
-        }
-      }
-      return indexFutures;
-    }
-  }
-
-  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
-    logDebug("Executing change ops");
-    initRepository();
-    Repository repo = repoView.getRepository();
-    checkState(
-        repo.getRefDatabase().performsAtomicTransactions(),
-        "cannot use NoteDb with a repository that does not support atomic batch ref updates: %s",
-        repo);
-
-    ChangesHandle handle =
-        new ChangesHandle(
-            updateManagerFactory
-                .create(project)
-                .setChangeRepo(
-                    repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
-            dryrun);
-    if (user.isIdentifiedUser()) {
-      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
-    }
-    handle.manager.setRefLogMessage(refLogMessage);
-    handle.manager.setPushCertificate(pushCert);
-    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
-      Change.Id id = e.getKey();
-      ChangeContextImpl ctx = newChangeContext(id);
-      boolean dirty = false;
-      logDebug("Applying %d ops for change %s", e.getValue().size(), id);
-      for (BatchUpdateOp op : e.getValue()) {
-        dirty |= op.updateChange(ctx);
-      }
-      if (!dirty) {
-        logDebug("No ops reported dirty, short-circuiting");
-        handle.setResult(id, ChangeResult.SKIPPED);
-        continue;
-      }
-      for (ChangeUpdate u : ctx.updates.values()) {
-        handle.manager.add(u);
-      }
-      if (ctx.deleted) {
-        logDebug("Change %s was deleted", id);
-        handle.manager.deleteChange(id);
-        handle.setResult(id, ChangeResult.DELETED);
-      } else {
-        handle.setResult(id, ChangeResult.UPSERTED);
-      }
-    }
-    return handle;
-  }
-
-  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
-    logDebug("Opening change %s for update", id);
-    Change c = newChanges.get(id);
-    boolean isNew = c != null;
-    if (!isNew) {
-      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
-      // existence and populating columns from the parsed notes state.
-      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
-      c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
-    } else {
-      logDebug("Change %s is new", id);
-    }
-    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
-    return new ChangeContextImpl(notes);
-  }
-
-  private void executePostOps() throws Exception {
-    ContextImpl ctx = new ContextImpl();
-    for (BatchUpdateOp op : ops.values()) {
-      op.postUpdate(ctx);
-    }
-
-    for (RepoOnlyOp op : repoOnlyOps) {
-      op.postUpdate(ctx);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/update/Order.java b/java/com/google/gerrit/server/update/Order.java
deleted file mode 100644
index fb64b7b..0000000
--- a/java/com/google/gerrit/server/update/Order.java
+++ /dev/null
@@ -1,34 +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.server.update;
-
-/** Order of execution of the various phases of a {@link BatchUpdate}. */
-public enum Order {
-  /**
-   * Update the repository and execute all ref updates before touching the database.
-   *
-   * <p>The default and most common, as Gerrit does not behave well when a patch set has no
-   * corresponding ref in the repo.
-   */
-  REPO_BEFORE_DB,
-
-  /**
-   * Update the database before touching the repository.
-   *
-   * <p>Generally only used when deleting patch sets, which should be deleted first from the
-   * database (for the same reason as above.)
-   */
-  DB_BEFORE_REPO;
-}
diff --git a/java/com/google/gerrit/server/update/RefUpdateUtil.java b/java/com/google/gerrit/server/update/RefUpdateUtil.java
deleted file mode 100644
index 3e33677..0000000
--- a/java/com/google/gerrit/server/update/RefUpdateUtil.java
+++ /dev/null
@@ -1,152 +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.server.update;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.server.git.LockFailureException;
-import java.io.IOException;
-import org.eclipse.jgit.internal.JGitText;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/** Static utilities for working with JGit's ref update APIs. */
-public class RefUpdateUtil {
-  /**
-   * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
-   *
-   * <p>Creates a new {@link RevWalk} used only for this operation.
-   *
-   * @param bru batch update; should already have been executed.
-   * @param repo repository that created {@code bru}.
-   * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
-   *     #checkResults(BatchRefUpdate)} for details.
-   * @throws IOException if any result was not {@code OK}.
-   */
-  public static void executeChecked(BatchRefUpdate bru, Repository repo) throws IOException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      executeChecked(bru, rw);
-    }
-  }
-
-  /**
-   * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
-   *
-   * @param bru batch update; should already have been executed.
-   * @param rw walk for executing the update.
-   * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
-   *     #checkResults(BatchRefUpdate)} for details.
-   * @throws IOException if any result was not {@code OK}.
-   */
-  public static void executeChecked(BatchRefUpdate bru, RevWalk rw) throws IOException {
-    bru.execute(rw, NullProgressMonitor.INSTANCE);
-    checkResults(bru);
-  }
-
-  /**
-   * Check results of all commands in the update batch, reducing to a single exception if there was
-   * a failure.
-   *
-   * <p>Throws {@link LockFailureException} if at least one command failed with {@code
-   * LOCK_FAILURE}, and the entire transaction was aborted, i.e. any non-{@code LOCK_FAILURE}
-   * results, if there were any, failed with "transaction aborted".
-   *
-   * <p>In particular, if the underlying ref database does not {@link
-   * org.eclipse.jgit.lib.RefDatabase#performsAtomicTransactions() perform atomic transactions},
-   * then a combination of {@code LOCK_FAILURE} on one ref and {@code OK} or another result on other
-   * refs will <em>not</em> throw {@code LockFailureException}.
-   *
-   * @param bru batch update; should already have been executed.
-   * @throws LockFailureException if the transaction was aborted due to lock failure.
-   * @throws IOException if any result was not {@code OK}.
-   */
-  @VisibleForTesting
-  static void checkResults(BatchRefUpdate bru) throws IOException {
-    if (bru.getCommands().isEmpty()) {
-      return;
-    }
-
-    int lockFailure = 0;
-    int aborted = 0;
-    int failure = 0;
-
-    for (ReceiveCommand cmd : bru.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        failure++;
-      }
-      if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
-        lockFailure++;
-      } else if (cmd.getResult() == ReceiveCommand.Result.REJECTED_OTHER_REASON
-          && JGitText.get().transactionAborted.equals(cmd.getMessage())) {
-        aborted++;
-      }
-    }
-
-    if (lockFailure + aborted == bru.getCommands().size()) {
-      throw new LockFailureException("Update aborted with one or more lock failures: " + bru, bru);
-    } else if (failure > 0) {
-      throw new IOException("Update failed: " + bru);
-    }
-  }
-
-  /**
-   * Delete a single ref, throwing a checked exception on failure.
-   *
-   * <p>Does not require that the ref have any particular old value. Succeeds as a no-op if the ref
-   * did not exist.
-   *
-   * @param repo repository.
-   * @param refName ref name to delete.
-   * @throws LockFailureException if a low-level lock failure (e.g. compare-and-swap failure)
-   *     occurs.
-   * @throws IOException if an error occurred.
-   */
-  public static void deleteChecked(Repository repo, String refName) throws IOException {
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setForceUpdate(true);
-    switch (ru.delete()) {
-      case FORCED:
-        // Ref was deleted.
-        return;
-
-      case NEW:
-        // Ref didn't exist (yes, really).
-        return;
-
-      case LOCK_FAILURE:
-        throw new LockFailureException("Failed to delete " + refName + ": " + ru.getResult(), ru);
-
-        // Not really failures, but should not be the result of a deletion, so the best option is to
-        // throw.
-      case NO_CHANGE:
-      case FAST_FORWARD:
-      case RENAMED:
-      case NOT_ATTEMPTED:
-
-      case IO_FAILURE:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        throw new IOException("Failed to delete " + refName + ": " + ru.getResult());
-    }
-  }
-
-  private RefUpdateUtil() {}
-}
diff --git a/java/com/google/gerrit/server/update/RepoView.java b/java/com/google/gerrit/server/update/RepoView.java
index 8839dbe..73dd12f 100644
--- a/java/com/google/gerrit/server/update/RepoView.java
+++ b/java/com/google/gerrit/server/update/RepoView.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.update;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toMap;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import java.io.IOException;
-import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
@@ -41,8 +40,7 @@
  *
  * <p>Second, the read methods take into account any pending operations on the repository that
  * implementations have staged using the write methods on {@link RepoContext}. Callers do not have
- * to worry about whether operations have been performed yet, and the implementation details may
- * differ between ReviewDb and NoteDb, but callers just don't need to care.
+ * to worry about whether operations have been performed yet.
  */
 public class RepoView {
   private final Repository repo;
@@ -67,9 +65,9 @@
         "expected RevWalk %s to be created by ObjectInserter %s",
         rw,
         inserter);
-    this.repo = checkNotNull(repo);
-    this.rw = checkNotNull(rw);
-    this.inserter = checkNotNull(inserter);
+    this.repo = requireNonNull(repo);
+    this.rw = requireNonNull(rw);
+    this.inserter = requireNonNull(inserter);
     inserterWrapper = new NonFlushingInserter(inserter);
     commands = new ChainedReceiveCommands(repo);
     closeRepo = false;
@@ -133,14 +131,13 @@
    *
    * @param prefix ref prefix; must end in '/' or else be empty.
    * @return a map of ref suffixes to SHA-1s. The refs are all under {@code prefix} and have the
-   *     prefix stripped; this matches the behavior of {@link
-   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)}.
+   *     prefix stripped.
    * @throws IOException if an error occurred.
    */
   public Map<String, ObjectId> getRefs(String prefix) throws IOException {
     Map<String, ObjectId> result =
-        new HashMap<>(
-            Maps.transformValues(repo.getRefDatabase().getRefs(prefix), Ref::getObjectId));
+        repo.getRefDatabase().getRefsByPrefix(prefix).stream()
+            .collect(toMap(r -> r.getName().substring(prefix.length()), Ref::getObjectId));
 
     // First, overwrite any cached reads from the underlying RepoRefCache. If any of these differ,
     // it's because a ref was updated after the RepoRefCache read it. It feels a little odd to
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 132c04b..f50f857 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -28,30 +28,35 @@
 import com.github.rholder.retry.WaitStrategy;
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-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.git.LockFailureException;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.Histogram1;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.LockFailureException;
-import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import java.util.function.Consumer;
+import java.util.function.Predicate;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class RetryHelper {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @FunctionalInterface
   public interface ChangeAction<T> {
     T call(BatchUpdate.Factory batchUpdateFactory) throws Exception;
@@ -66,7 +71,8 @@
     ACCOUNT_UPDATE,
     CHANGE_UPDATE,
     GROUP_UPDATE,
-    INDEX_QUERY
+    INDEX_QUERY,
+    PLUGIN_UPDATE
   }
 
   /**
@@ -91,12 +97,20 @@
     @Nullable
     abstract Duration timeout();
 
+    abstract Optional<Class<?>> caller();
+
+    abstract Optional<Predicate<Throwable>> retryWithTrace();
+
     @AutoValue.Builder
     public abstract static class Builder {
       public abstract Builder listener(RetryListener listener);
 
       public abstract Builder timeout(Duration timeout);
 
+      public abstract Builder caller(Class<?> caller);
+
+      public abstract Builder retryWithTrace(Predicate<Throwable> exceptionPredicate);
+
       public abstract Options build();
     }
   }
@@ -104,21 +118,22 @@
   @VisibleForTesting
   @Singleton
   public static class Metrics {
-    final Histogram1<ActionType> attemptCounts;
+    final Counter1<ActionType> attemptCounts;
     final Counter1<ActionType> timeoutCount;
 
     @Inject
     Metrics(MetricMaker metricMaker) {
-      Field<ActionType> view = Field.ofEnum(ActionType.class, "action_type");
+      Field<ActionType> actionTypeField =
+          Field.ofEnum(ActionType.class, "action_type", Metadata.Builder::actionType).build();
       attemptCounts =
-          metricMaker.newHistogram(
-              "action/retry_attempt_counts",
+          metricMaker.newCounter(
+              "action/retry_attempt_count",
               new Description(
-                      "Distribution of number of attempts made by RetryHelper to execute an action"
-                          + " (1 == single attempt, no retry)")
+                      "Number of retry attempts made by RetryHelper to execute an action"
+                          + " (0 == single attempt, no retry)")
                   .setCumulative()
                   .setUnit("attempts"),
-              view);
+              actionTypeField);
       timeoutCount =
           metricMaker.newCounter(
               "action/retry_timeout_count",
@@ -126,7 +141,7 @@
                       "Number of action executions of RetryHelper that ultimately timed out")
                   .setCumulative()
                   .setUnit("timeouts"),
-              view);
+              actionTypeField);
     }
   }
 
@@ -138,35 +153,26 @@
     return options().build();
   }
 
-  private final NotesMigration migration;
   private final Metrics metrics;
   private final BatchUpdate.Factory updateFactory;
   private final Map<ActionType, Duration> defaultTimeouts;
   private final WaitStrategy waitStrategy;
   @Nullable private final Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup;
+  private final boolean retryWithTraceOnFailure;
 
   @Inject
-  RetryHelper(
-      @GerritServerConfig Config cfg,
-      Metrics metrics,
-      NotesMigration migration,
-      ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
-      NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory) {
-    this(cfg, metrics, migration, reviewDbBatchUpdateFactory, noteDbBatchUpdateFactory, null);
+  RetryHelper(@GerritServerConfig Config cfg, Metrics metrics, BatchUpdate.Factory updateFactory) {
+    this(cfg, metrics, updateFactory, null);
   }
 
   @VisibleForTesting
   public RetryHelper(
       @GerritServerConfig Config cfg,
       Metrics metrics,
-      NotesMigration migration,
-      ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
-      NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory,
+      BatchUpdate.Factory updateFactory,
       @Nullable Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup) {
     this.metrics = metrics;
-    this.migration = migration;
-    this.updateFactory =
-        new BatchUpdate.Factory(migration, reviewDbBatchUpdateFactory, noteDbBatchUpdateFactory);
+    this.updateFactory = updateFactory;
 
     Duration defaultTimeout =
         Duration.ofMillis(
@@ -192,6 +198,7 @@
                 MILLISECONDS),
             WaitStrategies.randomWait(50, MILLISECONDS));
     this.overwriteDefaultRetryerStrategySetup = overwriteDefaultRetryerStrategySetup;
+    this.retryWithTraceOnFailure = cfg.getBoolean("retry", "retryWithTraceOnFailure", false);
   }
 
   public Duration getDefaultTimeout(ActionType actionType) {
@@ -226,16 +233,6 @@
   public <T> T execute(ChangeAction<T> changeAction, Options opts)
       throws RestApiException, UpdateException {
     try {
-      if (!migration.disableChangeReviewDb()) {
-        // Either we aren't full-NoteDb, or the underlying ref storage doesn't support atomic
-        // transactions. Either way, retrying a partially-failed operation is not idempotent, so
-        // don't do it automatically. Let the end user decide whether they want to retry.
-        return executeWithTimeoutCount(
-            ActionType.CHANGE_UPDATE,
-            () -> changeAction.call(updateFactory),
-            RetryerBuilder.<T>newBuilder().build());
-      }
-
       return execute(
           ActionType.CHANGE_UPDATE,
           () -> changeAction.call(updateFactory),
@@ -272,12 +269,42 @@
       Predicate<Throwable> exceptionPredicate)
       throws Throwable {
     MetricListener listener = new MetricListener();
-    try {
-      RetryerBuilder<T> retryerBuilder = createRetryerBuilder(actionType, opts, exceptionPredicate);
+    try (TraceContext traceContext = TraceContext.open()) {
+      RetryerBuilder<T> retryerBuilder =
+          createRetryerBuilder(
+              actionType,
+              opts,
+              t -> {
+                // exceptionPredicate checks for temporary errors for which the operation should be
+                // retried (e.g. LockFailure). The retry has good chances to succeed.
+                if (exceptionPredicate.test(t)) {
+                  return true;
+                }
+
+                // A non-recoverable failure occurred. Check if we should retry to capture a trace
+                // of the failure. If a trace was already done there is no need to retry.
+                if (retryWithTraceOnFailure
+                    && opts.retryWithTrace().isPresent()
+                    && opts.retryWithTrace().get().test(t)
+                    && !traceContext.isTracing()) {
+                  traceContext
+                      .addTag(RequestId.Type.TRACE_ID, "retry-on-failure-" + new RequestId())
+                      .forceLogging();
+                  logger.atFine().withCause(t).log(
+                      "%s failed, retry with tracing enabled",
+                      opts.caller().map(Class::getSimpleName).orElse("N/A"));
+                  return true;
+                }
+
+                return false;
+              });
       retryerBuilder.withRetryListener(listener);
       return executeWithTimeoutCount(actionType, action, retryerBuilder.build());
     } finally {
-      metrics.attemptCounts.record(actionType, listener.getAttemptCount());
+      if (listener.getAttemptCount() > 1) {
+        logger.atFine().log("%s was attempted %d times", actionType, listener.getAttemptCount());
+        metrics.attemptCounts.incrementBy(actionType, listener.getAttemptCount() - 1);
+      }
     }
   }
 
@@ -309,7 +336,7 @@
   private <O> RetryerBuilder<O> createRetryerBuilder(
       ActionType actionType, Options opts, Predicate<Throwable> exceptionPredicate) {
     RetryerBuilder<O> retryerBuilder =
-        RetryerBuilder.<O>newBuilder().retryIfException(exceptionPredicate);
+        RetryerBuilder.<O>newBuilder().retryIfException(exceptionPredicate::test);
     if (opts.listener() != null) {
       retryerBuilder.withRetryListener(opts.listener());
     }
diff --git a/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java b/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
new file mode 100644
index 0000000..9ed5a67
--- /dev/null
+++ b/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.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.update;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestResource;
+
+public abstract class RetryingRestCollectionModifyView<
+        P extends RestResource, C extends RestResource, I, O>
+    implements RestCollectionModifyView<P, C, I> {
+  private final RetryHelper retryHelper;
+
+  protected RetryingRestCollectionModifyView(RetryHelper retryHelper) {
+    this.retryHelper = retryHelper;
+  }
+
+  @Override
+  public final Response<O> apply(P parentResource, I input)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    RetryHelper.Options retryOptions =
+        RetryHelper.options()
+            .caller(getClass())
+            .retryWithTrace(t -> !(t instanceof RestApiException))
+            .build();
+    return retryHelper.execute(
+        (updateFactory) -> applyImpl(updateFactory, parentResource, input), retryOptions);
+  }
+
+  protected abstract Response<O> applyImpl(
+      BatchUpdate.Factory updateFactory, P parentResource, I input) throws Exception;
+}
diff --git a/java/com/google/gerrit/server/update/RetryingRestModifyView.java b/java/com/google/gerrit/server/update/RetryingRestModifyView.java
index e2f4a02..20cf0c9 100644
--- a/java/com/google/gerrit/server/update/RetryingRestModifyView.java
+++ b/java/com/google/gerrit/server/update/RetryingRestModifyView.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.update;
 
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestResource;
 
@@ -26,10 +28,16 @@
   }
 
   @Override
-  public final O apply(R resource, I input) throws Exception {
-    return retryHelper.execute((updateFactory) -> applyImpl(updateFactory, resource, input));
+  public final Response<O> apply(R resource, I input) throws Exception {
+    RetryHelper.Options retryOptions =
+        RetryHelper.options()
+            .caller(getClass())
+            .retryWithTrace(t -> !(t instanceof RestApiException))
+            .build();
+    return retryHelper.execute(
+        (updateFactory) -> applyImpl(updateFactory, resource, input), retryOptions);
   }
 
-  protected abstract O applyImpl(BatchUpdate.Factory updateFactory, R resource, I input)
+  protected abstract Response<O> applyImpl(BatchUpdate.Factory updateFactory, R resource, I input)
       throws Exception;
 }
diff --git a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
deleted file mode 100644
index 3c6f6fd..0000000
--- a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ /dev/null
@@ -1,853 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Comparator.comparing;
-import static java.util.concurrent.TimeUnit.NANOSECONDS;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Stopwatch;
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.util.concurrent.Futures;
-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;
-import com.google.gerrit.metrics.Description.Units;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.ChangeUpdateExecutor;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-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.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-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;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-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.TimeZone;
-import java.util.TreeMap;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/**
- * {@link BatchUpdate} implementation that supports mixed ReviewDb/NoteDb operations, depending on
- * the migration state specified in {@link NotesMigration}.
- *
- * <p>When performing change updates in a mixed ReviewDb/NoteDb environment with ReviewDb primary,
- * the order of operations is very subtle:
- *
- * <ol>
- *   <li>Stage NoteDb updates to get the new NoteDb state, but do not write to the repo.
- *   <li>Write the new state in the Change entity, and commit this to ReviewDb.
- *   <li>Update NoteDb, ignoring any write failures.
- * </ol>
- *
- * The implementation in this class is well-tested, and it is strongly recommended that you not
- * attempt to reimplement this logic. Use {@code BatchUpdate} if at all possible.
- */
-public class ReviewDbBatchUpdate extends BatchUpdate {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface AssistedFactory {
-    ReviewDbBatchUpdate create(
-        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
-  }
-
-  class ContextImpl implements Context {
-    @Override
-    public RepoView getRepoView() throws IOException {
-      return ReviewDbBatchUpdate.this.getRepoView();
-    }
-
-    @Override
-    public RevWalk getRevWalk() throws IOException {
-      return getRepoView().getRevWalk();
-    }
-
-    @Override
-    public Project.NameKey getProject() {
-      return project;
-    }
-
-    @Override
-    public Timestamp getWhen() {
-      return when;
-    }
-
-    @Override
-    public TimeZone getTimeZone() {
-      return tz;
-    }
-
-    @Override
-    public ReviewDb getDb() {
-      return db;
-    }
-
-    @Override
-    public CurrentUser getUser() {
-      return user;
-    }
-
-    @Override
-    public Order getOrder() {
-      return order;
-    }
-  }
-
-  private class RepoContextImpl extends ContextImpl implements RepoContext {
-    @Override
-    public ObjectInserter getInserter() throws IOException {
-      return getRepoView().getInserterWrapper();
-    }
-
-    @Override
-    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
-      initRepository();
-      repoView.getCommands().add(cmd);
-    }
-  }
-
-  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
-    private final ChangeNotes notes;
-    private final Map<PatchSet.Id, ChangeUpdate> updates;
-    private final ReviewDbWrapper dbWrapper;
-    private final Repository threadLocalRepo;
-    private final RevWalk threadLocalRevWalk;
-
-    private boolean deleted;
-    private boolean bumpLastUpdatedOn = true;
-
-    protected ChangeContextImpl(
-        ChangeNotes notes, ReviewDbWrapper dbWrapper, Repository repo, RevWalk rw) {
-      this.notes = checkNotNull(notes);
-      this.dbWrapper = dbWrapper;
-      this.threadLocalRepo = repo;
-      this.threadLocalRevWalk = rw;
-      updates = new TreeMap<>(comparing(PatchSet.Id::get));
-    }
-
-    @Override
-    public ReviewDb getDb() {
-      checkNotNull(dbWrapper);
-      return dbWrapper;
-    }
-
-    @Override
-    public RevWalk getRevWalk() {
-      return threadLocalRevWalk;
-    }
-
-    @Override
-    public ChangeUpdate getUpdate(PatchSet.Id psId) {
-      ChangeUpdate u = updates.get(psId);
-      if (u == null) {
-        u = changeUpdateFactory.create(notes, user, when);
-        if (newChanges.containsKey(notes.getChangeId())) {
-          u.setAllowWriteToNewRef(true);
-        }
-        u.setPatchSetId(psId);
-        updates.put(psId, u);
-      }
-      return u;
-    }
-
-    @Override
-    public ChangeNotes getNotes() {
-      return notes;
-    }
-
-    @Override
-    public void dontBumpLastUpdatedOn() {
-      bumpLastUpdatedOn = false;
-    }
-
-    @Override
-    public void deleteChange() {
-      deleted = true;
-    }
-  }
-
-  @Singleton
-  private static class Metrics {
-    final Timer1<Boolean> executeChangeOpsLatency;
-
-    @Inject
-    Metrics(MetricMaker metricMaker) {
-      executeChangeOpsLatency =
-          metricMaker.newTimer(
-              "batch_update/execute_change_ops",
-              new Description("BatchUpdate change update latency, excluding reindexing")
-                  .setCumulative()
-                  .setUnit(Units.MILLISECONDS),
-              Field.ofBoolean("success"));
-    }
-  }
-
-  static void execute(
-      ImmutableList<ReviewDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
-      throws UpdateException, RestApiException {
-    if (updates.isEmpty()) {
-      return;
-    }
-    setRequestIds(updates, requestId);
-    try {
-      Order order = getOrder(updates, listener);
-      boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
-      switch (order) {
-        case REPO_BEFORE_DB:
-          for (ReviewDbBatchUpdate u : updates) {
-            u.executeUpdateRepo();
-          }
-          listener.afterUpdateRepos();
-          for (ReviewDbBatchUpdate u : updates) {
-            u.executeRefUpdates(dryrun);
-          }
-          listener.afterUpdateRefs();
-          for (ReviewDbBatchUpdate u : updates) {
-            u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
-          }
-          listener.afterUpdateChanges();
-          break;
-        case DB_BEFORE_REPO:
-          for (ReviewDbBatchUpdate u : updates) {
-            u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
-          }
-          for (ReviewDbBatchUpdate u : updates) {
-            u.executeUpdateRepo();
-          }
-          for (ReviewDbBatchUpdate u : updates) {
-            u.executeRefUpdates(dryrun);
-          }
-          break;
-        default:
-          throw new IllegalStateException("invalid execution order: " + order);
-      }
-
-      ChangeIndexer.allAsList(
-              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
-          .get();
-
-      // Fire ref update events only after all mutations are finished, since callers may assume a
-      // patch set ref being created means the change was created, or a branch advancing meaning
-      // some changes were closed.
-      updates
-          .stream()
-          .filter(u -> u.batchRefUpdate != null)
-          .forEach(
-              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
-
-      if (!dryrun) {
-        for (ReviewDbBatchUpdate u : updates) {
-          u.executePostOps();
-        }
-      }
-    } catch (Exception e) {
-      wrapAndThrowException(e);
-    }
-  }
-
-  private final AllUsersName allUsers;
-  private final ChangeIndexer indexer;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeUpdate.Factory changeUpdateFactory;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ListeningExecutorService changeUpdateExector;
-  private final Metrics metrics;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final NotesMigration notesMigration;
-  private final ReviewDb db;
-  private final SchemaFactory<ReviewDb> schemaFactory;
-  private final long skewMs;
-
-  @SuppressWarnings("deprecation")
-  private final List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
-      new ArrayList<>();
-
-  @Inject
-  ReviewDbBatchUpdate(
-      @GerritServerConfig Config cfg,
-      AllUsersName allUsers,
-      ChangeIndexer indexer,
-      ChangeNotes.Factory changeNotesFactory,
-      @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
-      ChangeUpdate.Factory changeUpdateFactory,
-      @GerritPersonIdent PersonIdent serverIdent,
-      GitReferenceUpdated gitRefUpdated,
-      GitRepositoryManager repoManager,
-      Metrics metrics,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      NotesMigration notesMigration,
-      SchemaFactory<ReviewDb> schemaFactory,
-      @Assisted ReviewDb db,
-      @Assisted Project.NameKey project,
-      @Assisted CurrentUser user,
-      @Assisted Timestamp when) {
-    super(repoManager, serverIdent, project, user, when);
-    this.allUsers = allUsers;
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeUpdateExector = changeUpdateExector;
-    this.changeUpdateFactory = changeUpdateFactory;
-    this.gitRefUpdated = gitRefUpdated;
-    this.indexer = indexer;
-    this.metrics = metrics;
-    this.notesMigration = notesMigration;
-    this.schemaFactory = schemaFactory;
-    this.updateManagerFactory = updateManagerFactory;
-    this.db = db;
-    skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
-  }
-
-  @Override
-  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
-  }
-
-  @Override
-  protected Context newContext() {
-    return new ContextImpl();
-  }
-
-  private void executeUpdateRepo() throws UpdateException, RestApiException {
-    try {
-      logDebug("Executing updateRepo on %d ops", ops.size());
-      RepoContextImpl ctx = new RepoContextImpl();
-      for (BatchUpdateOp op : ops.values()) {
-        op.updateRepo(ctx);
-      }
-
-      logDebug("Executing updateRepo on %d RepoOnlyOps", repoOnlyOps.size());
-      for (RepoOnlyOp op : repoOnlyOps) {
-        op.updateRepo(ctx);
-      }
-
-      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
-        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
-        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
-        // first update's executeRefUpdates has finished, hence after first repo's refs have been
-        // updated, which is too late.
-        onSubmitValidators.validate(
-            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
-      }
-
-      if (repoView != null) {
-        logDebug("Flushing inserter");
-        repoView.getInserter().flush();
-      } else {
-        logDebug("No objects to flush");
-      }
-    } catch (Exception e) {
-      Throwables.throwIfInstanceOf(e, RestApiException.class);
-      throw new UpdateException(e);
-    }
-  }
-
-  private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
-    if (getRefUpdates().isEmpty()) {
-      logDebug("No ref updates to execute");
-      return;
-    }
-    // May not be opened if the caller added ref updates but no new objects.
-    // TODO(dborowitz): Really?
-    initRepository();
-    batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
-    batchRefUpdate.setPushCertificate(pushCert);
-    batchRefUpdate.setRefLogMessage(refLogMessage, true);
-    batchRefUpdate.setAllowNonFastForwards(true);
-    repoView.getCommands().addTo(batchRefUpdate);
-    if (user.isIdentifiedUser()) {
-      batchRefUpdate.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
-    }
-    logDebug("Executing batch of %d ref updates", batchRefUpdate.getCommands().size());
-    if (dryrun) {
-      return;
-    }
-
-    // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one
-    // that might have access to unflushed objects.
-    try (RevWalk updateRw = new RevWalk(repoView.getRepository())) {
-      batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE);
-    }
-    boolean ok = true;
-    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        ok = false;
-        break;
-      }
-    }
-    if (!ok) {
-      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
-    }
-  }
-
-  private List<ChangeTask> executeChangeOps(boolean parallel, boolean dryrun)
-      throws UpdateException, RestApiException {
-    List<ChangeTask> tasks;
-    boolean success = false;
-    Stopwatch sw = Stopwatch.createStarted();
-    try {
-      logDebug("Executing change ops (parallel? %s)", parallel);
-      ListeningExecutorService executor =
-          parallel ? changeUpdateExector : MoreExecutors.newDirectExecutorService();
-
-      tasks = new ArrayList<>(ops.keySet().size());
-      try {
-        if (notesMigration.commitChangeWrites() && repoView != null) {
-          // A NoteDb change may have been rebuilt since the repo was originally
-          // opened, so make sure we see that.
-          logDebug("Preemptively scanning for repo changes");
-          repoView.getRepository().scanForRepoChanges();
-        }
-        if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
-          // Fail fast before attempting any writes if changes are read-only, as
-          // this is a programmer error.
-          logDebug("Failing early due to read-only Changes table");
-          throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
-        }
-        List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
-        for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
-          ChangeTask task =
-              new ChangeTask(e.getKey(), e.getValue(), Thread.currentThread(), dryrun);
-          tasks.add(task);
-          if (!parallel) {
-            logDebug("Direct execution of task for ops: %s", ops);
-          }
-          futures.add(executor.submit(task));
-        }
-        if (parallel) {
-          logDebug(
-              "Waiting on futures for %d ops spanning %d changes", ops.size(), ops.keySet().size());
-        }
-        Futures.allAsList(futures).get();
-
-        if (notesMigration.commitChangeWrites()) {
-          if (!dryrun) {
-            executeNoteDbUpdates(tasks);
-          }
-        }
-        success = true;
-      } catch (ExecutionException | InterruptedException e) {
-        Throwables.throwIfInstanceOf(e.getCause(), UpdateException.class);
-        Throwables.throwIfInstanceOf(e.getCause(), RestApiException.class);
-        throw new UpdateException(e);
-      } catch (OrmException | IOException e) {
-        throw new UpdateException(e);
-      }
-    } finally {
-      metrics.executeChangeOpsLatency.record(success, sw.elapsed(NANOSECONDS), NANOSECONDS);
-    }
-    return tasks;
-  }
-
-  private void reindexChanges(List<ChangeTask> tasks) {
-    // Reindex changes.
-    for (ChangeTask task : tasks) {
-      if (task.deleted) {
-        indexFutures.add(indexer.deleteAsync(task.id));
-      } else if (task.dirty) {
-        indexFutures.add(indexer.indexAsync(project, task.id));
-      }
-    }
-  }
-
-  private void executeNoteDbUpdates(List<ChangeTask> tasks)
-      throws ResourceConflictException, IOException {
-    // Aggregate together all NoteDb ref updates from the ops we executed,
-    // possibly in parallel. Each task had its own NoteDbUpdateManager instance
-    // with its own thread-local copy of the repo(s), but each of those was just
-    // used for staging updates and was never executed.
-    //
-    // Use a new BatchRefUpdate as the original batchRefUpdate field is intended
-    // for use only by the updateRepo phase.
-    //
-    // See the comments in NoteDbUpdateManager#execute() for why we execute the
-    // updates on the change repo first.
-    logDebug("Executing NoteDb updates for %d changes", tasks.size());
-    try {
-      initRepository();
-      BatchRefUpdate changeRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
-      boolean hasAllUsersCommands = false;
-      try (ObjectInserter ins = repoView.getRepository().newObjectInserter()) {
-        int objs = 0;
-        for (ChangeTask task : tasks) {
-          if (task.noteDbResult == null) {
-            logDebug("No-op update to %s", task.id);
-            continue;
-          }
-          for (ReceiveCommand cmd : task.noteDbResult.changeCommands()) {
-            changeRefUpdate.addCommand(cmd);
-          }
-          for (InsertedObject obj : task.noteDbResult.changeObjects()) {
-            objs++;
-            ins.insert(obj.type(), obj.data().toByteArray());
-          }
-          hasAllUsersCommands |= !task.noteDbResult.allUsersCommands().isEmpty();
-        }
-        logDebug(
-            "Collected %d objects and %d ref updates to change repo",
-            objs, changeRefUpdate.getCommands().size());
-        executeNoteDbUpdate(getRevWalk(), ins, changeRefUpdate);
-      }
-
-      if (hasAllUsersCommands) {
-        try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-            RevWalk allUsersRw = new RevWalk(allUsersRepo);
-            ObjectInserter allUsersIns = allUsersRepo.newObjectInserter()) {
-          int objs = 0;
-          BatchRefUpdate allUsersRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-          for (ChangeTask task : tasks) {
-            for (ReceiveCommand cmd : task.noteDbResult.allUsersCommands()) {
-              allUsersRefUpdate.addCommand(cmd);
-            }
-            for (InsertedObject obj : task.noteDbResult.allUsersObjects()) {
-              allUsersIns.insert(obj.type(), obj.data().toByteArray());
-            }
-          }
-          logDebug(
-              "Collected %d objects and %d ref updates to All-Users",
-              objs, allUsersRefUpdate.getCommands().size());
-          executeNoteDbUpdate(allUsersRw, allUsersIns, allUsersRefUpdate);
-        }
-      } else {
-        logDebug("No All-Users updates");
-      }
-    } catch (IOException e) {
-      if (tasks.stream().allMatch(t -> t.storage == PrimaryStorage.REVIEW_DB)) {
-        // Ignore all errors trying to update NoteDb at this point. We've already written the
-        // NoteDbChangeStates to ReviewDb, which means if any state is out of date it will be
-        // rebuilt the next time it is needed.
-        //
-        // Always log even without RequestId.
-        logger.atFine().withCause(e).log("Ignoring NoteDb update error after ReviewDb write");
-
-        // Otherwise, we can't prove it's safe to ignore the error, either because some change had
-        // NOTE_DB primary, or a task failed before determining the primary storage.
-      } else if (e instanceof LockFailureException) {
-        // LOCK_FAILURE is a special case indicating there was a conflicting write to a meta ref,
-        // although it happened too late for us to produce anything but a generic error message.
-        throw new ResourceConflictException("Updating change failed due to conflicting write", e);
-      }
-      throw e;
-    }
-  }
-
-  private void executeNoteDbUpdate(RevWalk rw, ObjectInserter ins, BatchRefUpdate bru)
-      throws IOException {
-    if (bru.getCommands().isEmpty()) {
-      logDebug("No commands, skipping flush and ref update");
-      return;
-    }
-    ins.flush();
-    bru.setAllowNonFastForwards(true);
-    bru.execute(rw, NullProgressMonitor.INSTANCE);
-    for (ReceiveCommand cmd : bru.getCommands()) {
-      // TODO(dborowitz): LOCK_FAILURE for NoteDb primary should be retried.
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("Update failed: " + bru);
-      }
-    }
-  }
-
-  private class ChangeTask implements Callable<Void> {
-    final Change.Id id;
-    private final Collection<BatchUpdateOp> changeOps;
-    private final Thread mainThread;
-    private final boolean dryrun;
-
-    PrimaryStorage storage;
-    NoteDbUpdateManager.StagedResult noteDbResult;
-    boolean dirty;
-    boolean deleted;
-    private String taskId;
-
-    private ChangeTask(
-        Change.Id id, Collection<BatchUpdateOp> changeOps, Thread mainThread, boolean dryrun) {
-      this.id = id;
-      this.changeOps = changeOps;
-      this.mainThread = mainThread;
-      this.dryrun = dryrun;
-    }
-
-    @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);
-        }
-      } 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;
-    }
-
-    private void call(ReviewDb db, Repository repo, RevWalk rw) throws Exception {
-      @SuppressWarnings("resource") // Not always opened.
-      NoteDbUpdateManager updateManager = null;
-      try {
-        db.changes().beginTransaction(id);
-        try {
-          ChangeContextImpl ctx = newChangeContext(db, repo, rw, id);
-          NoteDbChangeState oldState = NoteDbChangeState.parse(ctx.getChange());
-          NoteDbChangeState.checkNotReadOnly(oldState, skewMs);
-
-          storage = PrimaryStorage.of(oldState);
-          if (storage == PrimaryStorage.NOTE_DB && !notesMigration.readChanges()) {
-            throw new OrmException("must have NoteDb enabled to update change " + id);
-          }
-
-          // Call updateChange on each op.
-          logDebug("Calling updateChange on %s ops", changeOps.size());
-          for (BatchUpdateOp op : changeOps) {
-            dirty |= op.updateChange(ctx);
-          }
-          if (!dirty) {
-            logDebug("No ops reported dirty, short-circuiting");
-            return;
-          }
-          deleted = ctx.deleted;
-          if (deleted) {
-            logDebug("Change was deleted");
-          }
-
-          // Stage the NoteDb update and store its state in the Change.
-          if (notesMigration.commitChangeWrites()) {
-            updateManager = stageNoteDbUpdate(ctx, deleted);
-          }
-
-          if (storage == PrimaryStorage.REVIEW_DB) {
-            // If primary storage of this change is in ReviewDb, bump
-            // lastUpdatedOn or rowVersion and commit. Otherwise, don't waste
-            // time updating ReviewDb at all.
-            Iterable<Change> cs = changesToUpdate(ctx);
-            if (isNewChange(id)) {
-              // Insert rather than upsert in case of a race on change IDs.
-              logDebug("Inserting change");
-              db.changes().insert(cs);
-            } else if (deleted) {
-              logDebug("Deleting change");
-              db.changes().delete(cs);
-            } else {
-              logDebug("Updating change");
-              db.changes().update(cs);
-            }
-            if (!dryrun) {
-              db.commit();
-            }
-          } else {
-            logDebug("Skipping ReviewDb write since primary storage is %s", storage);
-          }
-        } finally {
-          db.rollback();
-        }
-
-        // Do not execute the NoteDbUpdateManager, as we don't want too much
-        // contention on the underlying repo, and we would rather use a single
-        // ObjectInserter/BatchRefUpdate later.
-        //
-        // TODO(dborowitz): May or may not be worth trying to batch together
-        // flushed inserters as well.
-        if (storage == PrimaryStorage.NOTE_DB) {
-          // Should have failed above if NoteDb is disabled.
-          checkState(notesMigration.commitChangeWrites());
-          noteDbResult = updateManager.stage().get(id);
-        } else if (notesMigration.commitChangeWrites()) {
-          try {
-            noteDbResult = updateManager.stage().get(id);
-          } catch (IOException ex) {
-            // Ignore all errors trying to update NoteDb at this point. We've
-            // already written the NoteDbChangeState to ReviewDb, which means
-            // if the state is out of date it will be rebuilt the next time it
-            // is needed.
-            logger.atFine().withCause(ex).log("Ignoring NoteDb update error after ReviewDb write");
-          }
-        }
-      } catch (Exception e) {
-        logDebug("Error updating change (should be rethrown)", e);
-        Throwables.propagateIfPossible(e, RestApiException.class);
-        throw new UpdateException(e);
-      } finally {
-        if (updateManager != null) {
-          updateManager.close();
-        }
-      }
-    }
-
-    private ChangeContextImpl newChangeContext(
-        ReviewDb db, Repository repo, RevWalk rw, Change.Id id) throws OrmException {
-      Change c = newChanges.get(id);
-      boolean isNew = c != null;
-      if (isNew) {
-        // New change: populate noteDbState.
-        checkState(c.getNoteDbState() == null, "noteDbState should not be filled in by callers");
-        if (notesMigration.changePrimaryStorage() == PrimaryStorage.NOTE_DB) {
-          c.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-        }
-      } else {
-        // Existing change.
-        c = ChangeNotes.readOneReviewDbChange(db, id);
-        if (c == null) {
-          // Not in ReviewDb, but new changes are created with default primary
-          // storage as NOTE_DB, so we can assume that a missing change is
-          // NoteDb primary. Pass a synthetic change into ChangeNotes.Factory,
-          // which lets ChangeNotes take care of the existence check.
-          //
-          // TODO(dborowitz): This assumption is potentially risky, because
-          // it means once we turn this option on and start creating changes
-          // without writing anything to ReviewDb, we can't turn this option
-          // back off without making those changes inaccessible. The problem
-          // is we have no way of distinguishing a change that only exists in
-          // NoteDb because it only ever existed in NoteDb, from a change that
-          // only exists in NoteDb because it used to exist in ReviewDb and
-          // deleting from ReviewDb succeeded but deleting from NoteDb failed.
-          //
-          // TODO(dborowitz): We actually still have that problem anyway. Maybe
-          // we need a cutoff timestamp? Or maybe we need to start leaving
-          // tombstones in ReviewDb?
-          c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
-        }
-        NoteDbChangeState.checkNotReadOnly(c, skewMs);
-      }
-      ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
-      return new ChangeContextImpl(notes, new BatchUpdateReviewDb(db), repo, rw);
-    }
-
-    private NoteDbUpdateManager stageNoteDbUpdate(ChangeContextImpl ctx, boolean deleted)
-        throws OrmException, IOException {
-      logDebug("Staging NoteDb update");
-      NoteDbUpdateManager updateManager =
-          updateManagerFactory
-              .create(ctx.getProject())
-              .setChangeRepo(
-                  ctx.threadLocalRepo,
-                  ctx.threadLocalRevWalk,
-                  null,
-                  new ChainedReceiveCommands(ctx.threadLocalRepo));
-      if (ctx.getUser().isIdentifiedUser()) {
-        updateManager.setRefLogIdent(
-            ctx.getUser().asIdentifiedUser().newRefLogIdent(ctx.getWhen(), tz));
-      }
-      for (ChangeUpdate u : ctx.updates.values()) {
-        updateManager.add(u);
-      }
-
-      Change c = ctx.getChange();
-      if (deleted) {
-        updateManager.deleteChange(c.getId());
-      }
-      try {
-        updateManager.stageAndApplyDelta(c);
-      } catch (MismatchedStateException ex) {
-        // Refused to apply update because NoteDb was out of sync, which can
-        // only happen if ReviewDb is the primary storage for this change.
-        //
-        // Go ahead with this ReviewDb update; it's still out of sync, but this
-        // is no worse than before, and it will eventually get rebuilt.
-        logDebug("Ignoring MismatchedStateException while staging");
-      }
-
-      return updateManager;
-    }
-
-    private boolean isNewChange(Change.Id id) {
-      return newChanges.containsKey(id);
-    }
-
-    private void logDebug(String msg, Throwable t) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, t);
-    }
-
-    private void logDebug(String msg, Object... args) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, args);
-    }
-  }
-
-  private static Iterable<Change> changesToUpdate(ChangeContextImpl ctx) {
-    Change c = ctx.getChange();
-    if (ctx.bumpLastUpdatedOn && c.getLastUpdatedOn().before(ctx.getWhen())) {
-      c.setLastUpdatedOn(ctx.getWhen());
-    }
-    return Collections.singleton(c);
-  }
-
-  private void executePostOps() throws Exception {
-    ContextImpl ctx = new ContextImpl();
-    for (BatchUpdateOp op : ops.values()) {
-      op.postUpdate(ctx);
-    }
-
-    for (RepoOnlyOp op : repoOnlyOps) {
-      op.postUpdate(ctx);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/util/CommitMessageUtil.java b/java/com/google/gerrit/server/util/CommitMessageUtil.java
index fa55597..e984f46 100644
--- a/java/com/google/gerrit/server/util/CommitMessageUtil.java
+++ b/java/com/google/gerrit/server/util/CommitMessageUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.util;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 
 /** Utility functions to manipulate commit messages. */
@@ -23,18 +24,22 @@
   private CommitMessageUtil() {}
 
   /**
-   * Checks for null or empty commit messages and appends a newline character to the commit message.
+   * Checks for invalid (empty or containing \0) commit messages and appends a newline character to
+   * the commit message.
    *
    * @throws BadRequestException if the commit message is null or empty
    * @returns the trimmed message with a trailing newline character
    */
-  public static String checkAndSanitizeCommitMessage(String commitMessage)
+  public static String checkAndSanitizeCommitMessage(@Nullable String commitMessage)
       throws BadRequestException {
-    String wellFormedMessage = Strings.nullToEmpty(commitMessage).trim();
-    if (wellFormedMessage.isEmpty()) {
+    String trimmed = Strings.nullToEmpty(commitMessage).trim();
+    if (trimmed.isEmpty()) {
       throw new BadRequestException("Commit message cannot be null or empty");
     }
-    wellFormedMessage = wellFormedMessage + "\n";
-    return wellFormedMessage;
+    if (trimmed.indexOf(0) >= 0) {
+      throw new BadRequestException("Commit message cannot have NUL character");
+    }
+    trimmed = trimmed + "\n";
+    return trimmed;
   }
 }
diff --git a/java/com/google/gerrit/server/util/FallbackRequestContext.java b/java/com/google/gerrit/server/util/FallbackRequestContext.java
index de1555f..7a40dbf 100644
--- a/java/com/google/gerrit/server/util/FallbackRequestContext.java
+++ b/java/com/google/gerrit/server/util/FallbackRequestContext.java
@@ -14,12 +14,9 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 
 /**
@@ -40,14 +37,4 @@
   public CurrentUser getUser() {
     return user;
   }
-
-  @Override
-  public Provider<ReviewDb> getReviewDbProvider() {
-    return new Provider<ReviewDb>() {
-      @Override
-      public ReviewDb get() {
-        throw new ProvisionException("Automatic ReviewDb only available in request scope");
-      }
-    };
-  }
 }
diff --git a/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java b/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
index 6dd5543..99dd8bf 100644
--- a/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
+++ b/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -41,9 +40,8 @@
   GuiceRequestScopePropagator(
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
       @RemotePeer Provider<SocketAddress> remotePeerProvider,
-      ThreadLocalRequestContext local,
-      Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
-    super(ServletScopes.REQUEST, local, dbProviderProvider);
+      ThreadLocalRequestContext local) {
+    super(ServletScopes.REQUEST, local);
     this.url = urlProvider != null ? urlProvider.get() : null;
     this.peer = remotePeerProvider.get();
   }
diff --git a/java/com/google/gerrit/server/util/HostPlatform.java b/java/com/google/gerrit/server/util/HostPlatform.java
deleted file mode 100644
index 066bd4b..0000000
--- a/java/com/google/gerrit/server/util/HostPlatform.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import java.security.AccessController;
-import java.security.PrivilegedAction;
-
-public final class HostPlatform {
-  private static final boolean win32 = compute("windows");
-  private static final boolean mac = compute("mac");
-
-  /** @return true if this JVM is running on a Windows platform. */
-  public static boolean isWin32() {
-    return win32;
-  }
-
-  public static boolean isMac() {
-    return mac;
-  }
-
-  private static boolean compute(String platform) {
-    final String osDotName =
-        AccessController.doPrivileged(
-            new PrivilegedAction<String>() {
-              @Override
-              public String run() {
-                return System.getProperty("os.name");
-              }
-            });
-    return osDotName != null && osDotName.toLowerCase().contains(platform);
-  }
-
-  private HostPlatform() {}
-}
diff --git a/java/com/google/gerrit/server/util/IdGenerator.java b/java/com/google/gerrit/server/util/IdGenerator.java
index a9a22d9..d4c2dc4 100644
--- a/java/com/google/gerrit/server/util/IdGenerator.java
+++ b/java/com/google/gerrit/server/util/IdGenerator.java
@@ -22,16 +22,6 @@
 /** Simple class to produce 4 billion keys randomly distributed. */
 @Singleton
 public class IdGenerator {
-  /** Format an id created by this class as a hex string. */
-  public static String format(int id) {
-    final char[] r = new char[8];
-    for (int p = 7; 0 <= p; p--) {
-      final int h = id & 0xf;
-      r[p] = h < 10 ? (char) ('0' + h) : (char) ('a' + (h - 10));
-      id >>= 4;
-    }
-    return new String(r);
-  }
 
   private final AtomicInteger gen;
 
@@ -55,8 +45,8 @@
   public static int mix(int salt, int in) {
     short v0 = hi16(in);
     short v1 = lo16(in);
-    v0 += ((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1;
-    v1 += ((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3;
+    v0 += (short) (((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1);
+    v1 += (short) (((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3);
     return result(v0, v1);
   }
 
@@ -64,8 +54,8 @@
   static int unmix(int in) {
     short v0 = hi16(in);
     short v1 = lo16(in);
-    v1 -= ((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3;
-    v0 -= ((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1;
+    v1 -= (short) (((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3);
+    v0 -= (short) (((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1);
     return result(v0, v1);
   }
 
diff --git a/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
index a840e87..a03c1f2 100644
--- a/java/com/google/gerrit/server/util/LabelVote.java
+++ b/java/com/google/gerrit/server/util/LabelVote.java
@@ -85,7 +85,7 @@
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     return format();
   }
 }
diff --git a/java/com/google/gerrit/server/util/MagicBranch.java b/java/com/google/gerrit/server/util/MagicBranch.java
index e7d00f0..4e41be0 100644
--- a/java/com/google/gerrit/server/util/MagicBranch.java
+++ b/java/com/google/gerrit/server/util/MagicBranch.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.reviewdb.client.Project;
 import java.io.IOException;
-import java.util.Map;
+import java.util.List;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -28,25 +28,19 @@
   public static final String NEW_CHANGE = "refs/for/";
   // TODO(xchangcheng): remove after 'repo' supports private/wip changes.
   public static final String NEW_DRAFT_CHANGE = "refs/drafts/";
-  // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
-  public static final String NEW_PUBLISH_CHANGE = "refs/publish/";
 
   /** Extracts the destination from a ref name */
   public static String getDestBranchName(String refName) {
     String magicBranch = NEW_CHANGE;
     if (refName.startsWith(NEW_DRAFT_CHANGE)) {
       magicBranch = NEW_DRAFT_CHANGE;
-    } else if (refName.startsWith(NEW_PUBLISH_CHANGE)) {
-      magicBranch = NEW_PUBLISH_CHANGE;
     }
     return refName.substring(magicBranch.length());
   }
 
   /** Checks if the supplied ref name is a magic branch */
   public static boolean isMagicBranch(String refName) {
-    return refName.startsWith(NEW_DRAFT_CHANGE)
-        || refName.startsWith(NEW_PUBLISH_CHANGE)
-        || refName.startsWith(NEW_CHANGE);
+    return refName.startsWith(NEW_DRAFT_CHANGE) || refName.startsWith(NEW_CHANGE);
   }
 
   /** Returns the ref name prefix for a magic branch, {@code null} if the branch is not magic */
@@ -54,9 +48,6 @@
     if (refName.startsWith(NEW_DRAFT_CHANGE)) {
       return NEW_DRAFT_CHANGE;
     }
-    if (refName.startsWith(NEW_PUBLISH_CHANGE)) {
-      return NEW_PUBLISH_CHANGE;
-    }
     if (refName.startsWith(NEW_CHANGE)) {
       return NEW_CHANGE;
     }
@@ -80,18 +71,13 @@
     if (result != Capable.OK) {
       return result;
     }
-    result = checkMagicBranchRef(NEW_PUBLISH_CHANGE, repo, project);
-    if (result != Capable.OK) {
-      return result;
-    }
-
     return Capable.OK;
   }
 
   private static Capable checkMagicBranchRef(String branchName, Repository repo, Project project) {
-    Map<String, Ref> blockingFors;
+    List<Ref> blockingFors;
     try {
-      blockingFors = repo.getRefDatabase().getRefs(branchName);
+      blockingFors = repo.getRefDatabase().getRefsByPrefix(branchName);
     } catch (IOException err) {
       String projName = project.getName();
       logger.atWarning().withCause(err).log("Cannot scan refs in '%s'", projName);
@@ -101,7 +87,7 @@
       String projName = project.getName();
       logger.atSevere().log(
           "Repository '%s' needs the following refs removed to receive changes: %s",
-          projName, blockingFors.keySet());
+          projName, blockingFors);
       return new Capable("One or more " + branchName + " names blocks change upload");
     }
 
diff --git a/java/com/google/gerrit/server/util/ManualRequestContext.java b/java/com/google/gerrit/server/util/ManualRequestContext.java
index 620a2bc..7790b5f 100644
--- a/java/com/google/gerrit/server/util/ManualRequestContext.java
+++ b/java/com/google/gerrit/server/util/ManualRequestContext.java
@@ -14,35 +14,23 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 
 /** Closeable version of a {@link RequestContext} with manually-specified providers. */
 public class ManualRequestContext implements RequestContext, AutoCloseable {
   private final Provider<CurrentUser> userProvider;
-  private final Provider<ReviewDb> db;
   private final ThreadLocalRequestContext requestContext;
   private final RequestContext old;
 
-  public ManualRequestContext(
-      CurrentUser user,
-      SchemaFactory<ReviewDb> schemaFactory,
-      ThreadLocalRequestContext requestContext)
-      throws OrmException {
-    this(Providers.of(user), schemaFactory, requestContext);
+  public ManualRequestContext(CurrentUser user, ThreadLocalRequestContext requestContext) {
+    this(Providers.of(user), requestContext);
   }
 
   public ManualRequestContext(
-      Provider<CurrentUser> userProvider,
-      SchemaFactory<ReviewDb> schemaFactory,
-      ThreadLocalRequestContext requestContext)
-      throws OrmException {
+      Provider<CurrentUser> userProvider, ThreadLocalRequestContext requestContext) {
     this.userProvider = userProvider;
-    this.db = Providers.of(schemaFactory.open());
     this.requestContext = requestContext;
     old = requestContext.setContext(this);
   }
@@ -53,13 +41,7 @@
   }
 
   @Override
-  public Provider<ReviewDb> getReviewDbProvider() {
-    return db;
-  }
-
-  @Override
   public void close() {
     requestContext.setContext(old);
-    db.get().close();
   }
 }
diff --git a/java/com/google/gerrit/server/util/MostSpecificComparator.java b/java/com/google/gerrit/server/util/MostSpecificComparator.java
index f243726..b22617c 100644
--- a/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.server.project.RefPattern;
 import java.util.Comparator;
 import org.apache.commons.lang.StringUtils;
@@ -40,7 +40,7 @@
  * are infinite, but refs/heads/[a-zA-Z]* has more transitions, which after all turns it more
  * specific.
  */
-public final class MostSpecificComparator implements Comparator<RefConfigSection> {
+public final class MostSpecificComparator implements Comparator<AccessSection> {
   private final String refName;
 
   public MostSpecificComparator(String refName) {
@@ -48,7 +48,7 @@
   }
 
   @Override
-  public int compare(RefConfigSection a, RefConfigSection b) {
+  public int compare(AccessSection a, AccessSection b) {
     return compare(a.getName(), b.getName());
   }
 
diff --git a/java/com/google/gerrit/server/util/OneOffRequestContext.java b/java/com/google/gerrit/server/util/OneOffRequestContext.java
index 28be669..1788343 100644
--- a/java/com/google/gerrit/server/util/OneOffRequestContext.java
+++ b/java/com/google/gerrit/server/util/OneOffRequestContext.java
@@ -15,48 +15,38 @@
 package com.google.gerrit.server.util;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 /**
  * Helper to create one-off request contexts.
  *
- * <p>Each call to {@link #open()} opens a new {@link ReviewDb}, so this class should only be used
- * in a bounded try/finally block.
- *
  * <p>The user in the request context is {@link InternalUser} or the {@link IdentifiedUser}
  * associated to the userId passed as parameter.
  */
 @Singleton
 public class OneOffRequestContext {
   private final InternalUser.Factory userFactory;
-  private final SchemaFactory<ReviewDb> schemaFactory;
   private final ThreadLocalRequestContext requestContext;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
   @Inject
   OneOffRequestContext(
       InternalUser.Factory userFactory,
-      SchemaFactory<ReviewDb> schemaFactory,
       ThreadLocalRequestContext requestContext,
       IdentifiedUser.GenericFactory identifiedUserFactory) {
     this.userFactory = userFactory;
-    this.schemaFactory = schemaFactory;
     this.requestContext = requestContext;
     this.identifiedUserFactory = identifiedUserFactory;
   }
 
-  public ManualRequestContext open() throws OrmException {
-    return new ManualRequestContext(userFactory.create(), schemaFactory, requestContext);
+  public ManualRequestContext open() {
+    return new ManualRequestContext(userFactory.create(), requestContext);
   }
 
-  public ManualRequestContext openAs(Account.Id userId) throws OrmException {
-    return new ManualRequestContext(
-        identifiedUserFactory.create(userId), schemaFactory, requestContext);
+  public ManualRequestContext openAs(Account.Id userId) {
+    return new ManualRequestContext(identifiedUserFactory.create(userId), requestContext);
   }
 }
diff --git a/java/com/google/gerrit/server/util/PluginLogFile.java b/java/com/google/gerrit/server/util/PluginLogFile.java
index 351fbd4..de8b3aa 100644
--- a/java/com/google/gerrit/server/util/PluginLogFile.java
+++ b/java/com/google/gerrit/server/util/PluginLogFile.java
@@ -38,7 +38,7 @@
 
   @Override
   public void start() {
-    AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout);
+    AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout, true, true);
     Logger logger = LogManager.getLogger(logName);
     logger.removeAppender(logName);
     logger.addAppender(asyncAppender);
diff --git a/java/com/google/gerrit/server/util/PluginRequestContext.java b/java/com/google/gerrit/server/util/PluginRequestContext.java
index 3f3f647..ec15786 100644
--- a/java/com/google/gerrit/server/util/PluginRequestContext.java
+++ b/java/com/google/gerrit/server/util/PluginRequestContext.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PluginUser;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
 
 /** RequestContext active while plugins load or unload. */
 public class PluginRequestContext implements RequestContext {
@@ -32,14 +29,4 @@
   public CurrentUser getUser() {
     return user;
   }
-
-  @Override
-  public Provider<ReviewDb> getReviewDbProvider() {
-    return new Provider<ReviewDb>() {
-      @Override
-      public ReviewDb get() {
-        throw new ProvisionException("Automatic ReviewDb only available in request scope");
-      }
-    };
-  }
 }
diff --git a/java/com/google/gerrit/server/util/RegexListSearcher.java b/java/com/google/gerrit/server/util/RegexListSearcher.java
deleted file mode 100644
index aea1d5c..0000000
--- a/java/com/google/gerrit/server/util/RegexListSearcher.java
+++ /dev/null
@@ -1,100 +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.base.Preconditions.checkNotNull;
-
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Chars;
-import dk.brics.automaton.Automaton;
-import dk.brics.automaton.RegExp;
-import dk.brics.automaton.RunAutomaton;
-import java.util.Collections;
-import java.util.List;
-import java.util.function.Function;
-import java.util.stream.Stream;
-
-/** Helper to search sorted lists for elements matching a {@link RegExp}. */
-public final class RegexListSearcher<T> {
-  public static RegexListSearcher<String> ofStrings(String re) {
-    return new RegexListSearcher<>(re, Function.identity());
-  }
-
-  private final RunAutomaton pattern;
-  private final Function<T, String> toStringFunc;
-
-  private final String prefixBegin;
-  private final String prefixEnd;
-  private final int prefixLen;
-  private final boolean prefixOnly;
-
-  public RegexListSearcher(String re, Function<T, String> toStringFunc) {
-    this.toStringFunc = checkNotNull(toStringFunc);
-
-    if (re.startsWith("^")) {
-      re = re.substring(1);
-    }
-
-    if (re.endsWith("$") && !re.endsWith("\\$")) {
-      re = re.substring(0, re.length() - 1);
-    }
-
-    Automaton automaton = new RegExp(re).toAutomaton();
-    prefixBegin = automaton.getCommonPrefix();
-    prefixLen = prefixBegin.length();
-
-    if (0 < prefixLen) {
-      char max = Chars.checkedCast(prefixBegin.charAt(prefixLen - 1) + 1);
-      prefixEnd = prefixBegin.substring(0, prefixLen - 1) + max;
-      prefixOnly = re.equals(prefixBegin + ".*");
-    } else {
-      prefixEnd = "";
-      prefixOnly = false;
-    }
-
-    pattern = prefixOnly ? null : new RunAutomaton(automaton);
-  }
-
-  public Stream<T> search(List<T> list) {
-    checkNotNull(list);
-    int begin;
-    int end;
-
-    if (0 < prefixLen) {
-      // Assumes many consecutive elements may have the same prefix, so the cost of two binary
-      // searches is less than iterating linearly and running the regexp find the endpoints.
-      List<String> strings = Lists.transform(list, toStringFunc::apply);
-      begin = find(strings, prefixBegin);
-      end = find(strings, prefixEnd);
-    } else {
-      begin = 0;
-      end = list.size();
-    }
-    if (begin >= end) {
-      return Stream.empty();
-    }
-
-    Stream<T> result = list.subList(begin, end).stream();
-    if (!prefixOnly) {
-      result = result.filter(x -> pattern.run(toStringFunc.apply(x)));
-    }
-    return result;
-  }
-
-  private static int find(List<String> list, String p) {
-    int r = Collections.binarySearch(list, p);
-    return r < 0 ? -(r + 1) : r;
-  }
-}
diff --git a/java/com/google/gerrit/server/util/RequestContext.java b/java/com/google/gerrit/server/util/RequestContext.java
index 37fd7bc..04ee5c6 100644
--- a/java/com/google/gerrit/server/util/RequestContext.java
+++ b/java/com/google/gerrit/server/util/RequestContext.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.inject.Provider;
 
 /**
  * The RequestContext is an interface exposing the fields that are needed by the GerritGlobalModule
@@ -24,6 +22,4 @@
  */
 public interface RequestContext {
   CurrentUser getUser();
-
-  Provider<ReviewDb> getReviewDbProvider();
 }
diff --git a/java/com/google/gerrit/server/util/RequestId.java b/java/com/google/gerrit/server/util/RequestId.java
deleted file mode 100644
index 8e8db12..0000000
--- a/java/com/google/gerrit/server/util/RequestId.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-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;
-
-/** Unique identifier for an end-user request, used in logs and similar. */
-public class RequestId {
-  private static final String MACHINE_ID;
-
-  static {
-    String id;
-    try {
-      id = InetAddress.getLocalHost().getHostAddress();
-    } catch (UnknownHostException e) {
-      id = "unknown";
-    }
-    MACHINE_ID = id;
-  }
-
-  public static RequestId forChange(Change c) {
-    return new RequestId(c.getId().toString());
-  }
-
-  public static RequestId forProject(Project.NameKey p) {
-    return new RequestId(p.toString());
-  }
-
-  private final String str;
-
-  private RequestId(String resourceId) {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
-    str =
-        "["
-            + resourceId
-            + "-"
-            + TimeUtil.nowTs().getTime()
-            + "-"
-            + h.hash().toString().substring(0, 8)
-            + "]";
-  }
-
-  @Override
-  public String toString() {
-    return str;
-  }
-
-  public String toStringForStorage() {
-    return str.substring(1, str.length() - 1);
-  }
-}
diff --git a/java/com/google/gerrit/server/util/RequestScopePropagator.java b/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 9c83549..789b9b1 100644
--- a/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -14,17 +14,13 @@
 
 package com.google.gerrit.server.util;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Throwables;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.RequestCleanup;
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.gerrit.server.git.ProjectRunnable;
 import com.google.inject.Key;
-import com.google.inject.Provider;
 import com.google.inject.Scope;
 import com.google.inject.servlet.ServletScopes;
 import java.util.concurrent.Callable;
@@ -47,15 +43,10 @@
 
   private final Scope scope;
   private final ThreadLocalRequestContext local;
-  private final Provider<RequestScopedReviewDbProvider> dbProviderProvider;
 
-  protected RequestScopePropagator(
-      Scope scope,
-      ThreadLocalRequestContext local,
-      Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
+  protected RequestScopePropagator(Scope scope, ThreadLocalRequestContext local) {
     this.scope = scope;
     this.local = local;
-    this.dbProviderProvider = dbProviderProvider;
   }
 
   /**
@@ -83,7 +74,7 @@
    */
   @SuppressWarnings("javadoc") // See GuiceRequestScopePropagator#wrapImpl
   public final <T> Callable<T> wrap(Callable<T> callable) {
-    final RequestContext callerContext = checkNotNull(local.getContext());
+    final RequestContext callerContext = requireNonNull(local.getContext());
     final Callable<T> wrapped = wrapImpl(context(callerContext, cleanup(callable)));
     return new Callable<T>() {
       @Override
@@ -174,19 +165,7 @@
 
   protected <T> Callable<T> context(RequestContext context, Callable<T> callable) {
     return () -> {
-      RequestContext old =
-          local.setContext(
-              new RequestContext() {
-                @Override
-                public CurrentUser getUser() {
-                  return context.getUser();
-                }
-
-                @Override
-                public Provider<ReviewDb> getReviewDbProvider() {
-                  return dbProviderProvider.get();
-                }
-              });
+      RequestContext old = local.setContext(context::getUser);
       try {
         return callable.call();
       } finally {
@@ -198,16 +177,7 @@
   protected <T> Callable<T> cleanup(Callable<T> callable) {
     return () -> {
       RequestCleanup cleanup =
-          scope
-              .scope(
-                  Key.get(RequestCleanup.class),
-                  new Provider<RequestCleanup>() {
-                    @Override
-                    public RequestCleanup get() {
-                      return new RequestCleanup();
-                    }
-                  })
-              .get();
+          scope.scope(Key.get(RequestCleanup.class), RequestCleanup::new).get();
       try {
         return callable.call();
       } finally {
diff --git a/java/com/google/gerrit/server/util/ServerRequestContext.java b/java/com/google/gerrit/server/util/ServerRequestContext.java
index af903c4..037645d 100644
--- a/java/com/google/gerrit/server/util/ServerRequestContext.java
+++ b/java/com/google/gerrit/server/util/ServerRequestContext.java
@@ -14,12 +14,9 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
 
 /** RequestContext with an InternalUser making the internals visible. */
 public class ServerRequestContext implements RequestContext {
@@ -34,14 +31,4 @@
   public CurrentUser getUser() {
     return user;
   }
-
-  @Override
-  public Provider<ReviewDb> getReviewDbProvider() {
-    return new Provider<ReviewDb>() {
-      @Override
-      public ReviewDb get() {
-        throw new ProvisionException("Automatic ReviewDb only available in request scope");
-      }
-    };
-  }
 }
diff --git a/java/com/google/gerrit/server/util/SubmoduleSectionParser.java b/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
deleted file mode 100644
index 6de2fef..0000000
--- a/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-
-/**
- * It parses from a configuration file submodule sections.
- *
- * <p>Example of submodule sections:
- *
- * <pre>
- * [submodule "project-a"]
- *     url = http://localhost/a
- *     path = a
- *     branch = .
- *
- * [submodule "project-b"]
- *     url = http://localhost/b
- *     path = b
- *     branch = refs/heads/test
- * </pre>
- */
-public class SubmoduleSectionParser {
-
-  private final Config bbc;
-  private final String canonicalWebUrl;
-  private final Branch.NameKey superProjectBranch;
-
-  public SubmoduleSectionParser(
-      Config bbc, String canonicalWebUrl, Branch.NameKey superProjectBranch) {
-    this.bbc = bbc;
-    this.canonicalWebUrl = canonicalWebUrl;
-    this.superProjectBranch = superProjectBranch;
-  }
-
-  public Set<SubmoduleSubscription> parseAllSections() {
-    Set<SubmoduleSubscription> parsedSubscriptions = new HashSet<>();
-    for (String id : bbc.getSubsections("submodule")) {
-      final SubmoduleSubscription subscription = parse(id);
-      if (subscription != null) {
-        parsedSubscriptions.add(subscription);
-      }
-    }
-    return parsedSubscriptions;
-  }
-
-  private SubmoduleSubscription parse(String id) {
-    final String url = bbc.getString("submodule", id, "url");
-    final String path = bbc.getString("submodule", id, "path");
-    String branch = bbc.getString("submodule", id, "branch");
-
-    try {
-      if (url != null
-          && url.length() > 0
-          && path != null
-          && path.length() > 0
-          && branch != null
-          && branch.length() > 0) {
-        // All required fields filled.
-        String project;
-
-        if (branch.equals(".")) {
-          branch = superProjectBranch.get();
-        }
-
-        // relative URL
-        if (url.startsWith("../")) {
-          // prefix with a slash for easier relative path walks
-          project = '/' + superProjectBranch.getParentKey().get();
-          String hostPart = url;
-          while (hostPart.startsWith("../")) {
-            int lastSlash = project.lastIndexOf('/');
-            if (lastSlash < 0) {
-              // too many levels up, ignore for now
-              return null;
-            }
-            project = project.substring(0, lastSlash);
-            hostPart = hostPart.substring(3);
-          }
-          project = project + "/" + hostPart;
-
-          // remove leading '/'
-          project = project.substring(1);
-        } else {
-          // It is actually an URI. It could be ssh://localhost/project-a.
-          URI targetServerURI = new URI(url);
-          URI thisServerURI = new URI(canonicalWebUrl);
-          String thisHost = thisServerURI.getHost();
-          String targetHost = targetServerURI.getHost();
-          if (thisHost == null || targetHost == null || !targetHost.equalsIgnoreCase(thisHost)) {
-            return null;
-          }
-          String p1 = targetServerURI.getPath();
-          String p2 = thisServerURI.getPath();
-          if (!p1.startsWith(p2)) {
-            // When we are running the server at
-            // http://server/my-gerrit/ but the subscription is for
-            // http://server/other-teams-gerrit/
-            return null;
-          }
-          // skip common part
-          project = p1.substring(p2.length());
-        }
-
-        while (project.startsWith("/")) {
-          project = project.substring(1);
-        }
-
-        if (project.endsWith(Constants.DOT_GIT_EXT)) {
-          project =
-              project.substring(
-                  0, //
-                  project.length() - Constants.DOT_GIT_EXT.length());
-        }
-        Project.NameKey projectKey = new Project.NameKey(project);
-        return new SubmoduleSubscription(
-            superProjectBranch, new Branch.NameKey(projectKey, branch), path);
-      }
-    } catch (URISyntaxException e) {
-      // Error in url syntax (in fact it is uri syntax)
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/util/SystemLog.java b/java/com/google/gerrit/server/util/SystemLog.java
index 224a6d9..ec2f386 100644
--- a/java/com/google/gerrit/server/util/SystemLog.java
+++ b/java/com/google/gerrit/server/util/SystemLog.java
@@ -77,13 +77,18 @@
   }
 
   private AsyncAppender createAsyncAppender(String name, Layout layout, boolean rotate) {
+    return createAsyncAppender(name, layout, rotate, false);
+  }
+
+  public AsyncAppender createAsyncAppender(
+      String name, Layout layout, boolean rotate, boolean forPlugin) {
     AsyncAppender async = new AsyncAppender();
     async.setName(name);
     async.setBlocking(true);
     async.setBufferSize(asyncLoggingBufferSize);
     async.setLocationInfo(false);
 
-    if (shouldConfigure()) {
+    if (forPlugin || shouldConfigure()) {
       async.addAppender(createAppender(site.logs_dir, name, layout, rotate));
     } else {
       Appender appender = LogManager.getLogger(name).getAppender(name);
diff --git a/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java b/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
index e065c6b..afd699c 100644
--- a/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
+++ b/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
@@ -16,8 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.NotSignedInException;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.AbstractModule;
@@ -63,11 +62,6 @@
         }
         throw new ProvisionException(NotSignedInException.MESSAGE, new NotSignedInException());
       }
-
-      @Provides
-      ReviewDb provideReviewDb(RequestContext ctx) {
-        return ctx.getReviewDbProvider().get();
-      }
     };
   }
 
diff --git a/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java b/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
index 90fb994..92f5488 100644
--- a/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
+++ b/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
 import com.google.inject.Scope;
 import java.util.concurrent.Callable;
 
@@ -31,11 +29,8 @@
   private final ThreadLocal<C> threadLocal;
 
   protected ThreadLocalRequestScopePropagator(
-      Scope scope,
-      ThreadLocal<C> threadLocal,
-      ThreadLocalRequestContext local,
-      Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
-    super(scope, local, dbProviderProvider);
+      Scope scope, ThreadLocal<C> threadLocal, ThreadLocalRequestContext local) {
+    super(scope, local);
     this.threadLocal = threadLocal;
   }
 
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
new file mode 100644
index 0000000..81ca9cd
--- /dev/null
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -0,0 +1,9 @@
+java_library(
+    name = "git",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
new file mode 100644
index 0000000..433a5f1
--- /dev/null
+++ b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
@@ -0,0 +1,145 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util.git;
+
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+
+/**
+ * It parses from a configuration file submodule sections.
+ *
+ * <p>Example of submodule sections:
+ *
+ * <pre>
+ * [submodule "project-a"]
+ *     url = http://localhost/a
+ *     path = a
+ *     branch = .
+ *
+ * [submodule "project-b"]
+ *     url = http://localhost/b
+ *     path = b
+ *     branch = refs/heads/test
+ * </pre>
+ */
+public class SubmoduleSectionParser {
+
+  private final Config config;
+  private final String canonicalWebUrl;
+  private final BranchNameKey superProjectBranch;
+
+  public SubmoduleSectionParser(
+      Config config, String canonicalWebUrl, BranchNameKey superProjectBranch) {
+    this.config = config;
+    this.canonicalWebUrl = canonicalWebUrl;
+    this.superProjectBranch = superProjectBranch;
+  }
+
+  public Set<SubmoduleSubscription> parseAllSections() {
+    Set<SubmoduleSubscription> parsedSubscriptions = new HashSet<>();
+    for (String id : config.getSubsections("submodule")) {
+      final SubmoduleSubscription subscription = parse(id);
+      if (subscription != null) {
+        parsedSubscriptions.add(subscription);
+      }
+    }
+    return parsedSubscriptions;
+  }
+
+  private SubmoduleSubscription parse(String id) {
+    final String url = config.getString("submodule", id, "url");
+    final String path = config.getString("submodule", id, "path");
+    String branch = config.getString("submodule", id, "branch");
+
+    try {
+      if (url != null
+          && url.length() > 0
+          && path != null
+          && path.length() > 0
+          && branch != null
+          && branch.length() > 0) {
+        // All required fields filled.
+        String project;
+
+        if (branch.equals(".")) {
+          branch = superProjectBranch.branch();
+        }
+
+        // relative URL
+        if (url.startsWith("../")) {
+          // prefix with a slash for easier relative path walks
+          project = '/' + superProjectBranch.project().get();
+          String hostPart = url;
+          while (hostPart.startsWith("../")) {
+            int lastSlash = project.lastIndexOf('/');
+            if (lastSlash < 0) {
+              // too many levels up, ignore for now
+              return null;
+            }
+            project = project.substring(0, lastSlash);
+            hostPart = hostPart.substring(3);
+          }
+          project = project + "/" + hostPart;
+
+          // remove leading '/'
+          project = project.substring(1);
+        } else {
+          // It is actually an URI. It could be ssh://localhost/project-a.
+          URI targetServerURI = new URI(url);
+          URI thisServerURI = new URI(canonicalWebUrl);
+          String thisHost = thisServerURI.getHost();
+          String targetHost = targetServerURI.getHost();
+          if (thisHost == null || targetHost == null || !targetHost.equalsIgnoreCase(thisHost)) {
+            return null;
+          }
+          String p1 = targetServerURI.getPath();
+          String p2 = thisServerURI.getPath();
+          if (!p1.startsWith(p2)) {
+            // When we are running the server at
+            // http://server/my-gerrit/ but the subscription is for
+            // http://server/other-teams-gerrit/
+            return null;
+          }
+          // skip common part
+          project = p1.substring(p2.length());
+        }
+
+        while (project.startsWith("/")) {
+          project = project.substring(1);
+        }
+
+        if (project.endsWith(Constants.DOT_GIT_EXT)) {
+          project =
+              project.substring(
+                  0, //
+                  project.length() - Constants.DOT_GIT_EXT.length());
+        }
+        Project.NameKey projectKey = Project.nameKey(project);
+        return new SubmoduleSubscription(
+            superProjectBranch, BranchNameKey.create(projectKey, branch), path);
+      }
+    } catch (URISyntaxException e) {
+      // Error in url syntax (in fact it is uri syntax)
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/util/time/BUILD b/java/com/google/gerrit/server/util/time/BUILD
new file mode 100644
index 0000000..c7cd89e
--- /dev/null
+++ b/java/com/google/gerrit/server/util/time/BUILD
@@ -0,0 +1,10 @@
+java_library(
+    name = "time",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/util/time/TimeUtil.java b/java/com/google/gerrit/server/util/time/TimeUtil.java
new file mode 100644
index 0000000..b9d2d8a
--- /dev/null
+++ b/java/com/google/gerrit/server/util/time/TimeUtil.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.time;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.function.LongSupplier;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+
+/** Static utility methods for dealing with dates and times. */
+public class TimeUtil {
+  private static final LongSupplier SYSTEM_CURRENT_MILLIS_SUPPLIER = System::currentTimeMillis;
+
+  private static volatile LongSupplier currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
+
+  public static long nowMs() {
+    // We should rather use Instant.now(Clock).toEpochMilli() instead but this would require some
+    // changes in our testing code as we wouldn't have clock steps anymore.
+    return currentMillisSupplier.getAsLong();
+  }
+
+  public static Instant now() {
+    return Instant.ofEpochMilli(nowMs());
+  }
+
+  public static Timestamp nowTs() {
+    return new Timestamp(nowMs());
+  }
+
+  /**
+   * Returns the magic timestamp representing no specific time.
+   *
+   * <p>This "null object" is helpful in contexts where using {@code null} directly is not possible.
+   */
+  @UsedAt(Project.PLUGIN_CHECKS)
+  public static Timestamp never() {
+    // Always create a new object as timestamps are mutable.
+    return new Timestamp(0);
+  }
+
+  public static Timestamp truncateToSecond(Timestamp t) {
+    return new Timestamp((t.getTime() / 1000) * 1000);
+  }
+
+  @VisibleForTesting
+  public static void setCurrentMillisSupplier(LongSupplier customCurrentMillisSupplier) {
+    currentMillisSupplier = customCurrentMillisSupplier;
+
+    SystemReader oldSystemReader = SystemReader.getInstance();
+    if (!(oldSystemReader instanceof GerritSystemReader)) {
+      SystemReader.setInstance(new GerritSystemReader(oldSystemReader));
+    }
+  }
+
+  @VisibleForTesting
+  public static void resetCurrentMillisSupplier() {
+    currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
+    SystemReader.setInstance(null);
+  }
+
+  private static class GerritSystemReader extends SystemReader {
+    SystemReader delegate;
+
+    GerritSystemReader(SystemReader delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getHostname() {
+      return delegate.getHostname();
+    }
+
+    @Override
+    public String getenv(String variable) {
+      return delegate.getenv(variable);
+    }
+
+    @Override
+    public String getProperty(String key) {
+      return delegate.getProperty(key);
+    }
+
+    @Override
+    public FileBasedConfig openUserConfig(Config parent, FS fs) {
+      return delegate.openUserConfig(parent, fs);
+    }
+
+    @Override
+    public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+      return delegate.openSystemConfig(parent, fs);
+    }
+
+    @Override
+    public long getCurrentTime() {
+      return currentMillisSupplier.getAsLong();
+    }
+
+    @Override
+    public int getTimezone(long when) {
+      return delegate.getTimezone(when);
+    }
+  }
+
+  private TimeUtil() {}
+}
diff --git a/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java b/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
index 9f152a5..996ad87 100644
--- a/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
+++ b/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 import java.util.Map;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 710b3dc..c49ae82 100644
--- a/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -65,9 +65,11 @@
 
             @Override
             public Project.NameKey getProjectName() {
-              return projectState.getNameKey();
+              Project project = projectState.getProject();
+              return project.getNameKey();
             }
-          });
+          },
+          AccessPath.GIT);
     } finally {
       sshScope.set(old);
     }
@@ -79,7 +81,6 @@
             session,
             session.getRemoteAddress(),
             userFactory.create(session.getRemoteAddress(), user.getAccountId()));
-    n.setAccessPath(AccessPath.GIT);
     return n;
   }
 
diff --git a/java/com/google/gerrit/sshd/AliasCommand.java b/java/com/google/gerrit/sshd/AliasCommand.java
index cb61651..567cf00 100644
--- a/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/java/com/google/gerrit/sshd/AliasCommand.java
@@ -26,8 +26,8 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
-import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.command.Command;
 
 /** Command that executes some other command. */
 public class AliasCommand extends BaseCommand {
diff --git a/java/com/google/gerrit/sshd/AliasCommandProvider.java b/java/com/google/gerrit/sshd/AliasCommandProvider.java
index 085b6d6..58e2559 100644
--- a/java/com/google/gerrit/sshd/AliasCommandProvider.java
+++ b/java/com/google/gerrit/sshd/AliasCommandProvider.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import org.apache.sshd.server.Command;
+import org.apache.sshd.server.command.Command;
 
 /** Resolves an alias to another command. */
 public class AliasCommandProvider implements Provider<Command> {
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 6c810a3..3a69554 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -5,23 +5,27 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
-        "//java/org/eclipse/jgit:server",
         "//lib:args4j",
         "//lib:gson",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:jsch",
         "//lib:servlet-api-3_1",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index dae9016..2081967 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -20,11 +20,11 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.EndOfOptionsHandler;
@@ -54,9 +55,9 @@
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.common.SshException;
-import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
+import org.apache.sshd.server.command.Command;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.Option;
@@ -270,16 +271,18 @@
    *   public void run() throws Exception {
    *     runImp();
    *   }
-   * });
+   * },
+   * accessPath);
    * </pre>
    *
    * <p>If the function throws an exception, it is translated to a simple message for the client, a
    * non-zero exit code, and the stack trace is logged.
    *
    * @param thunk the runnable to execute on the thread, performing the command's logic.
+   * @param accessPath the path used by the end user for running the SSH command
    */
-  protected void startThread(CommandRunnable thunk) {
-    final TaskThunk tt = new TaskThunk(thunk);
+  protected void startThread(final CommandRunnable thunk, AccessPath accessPath) {
+    final TaskThunk tt = new TaskThunk(thunk, accessPath);
 
     if (isAdminHighPriorityCommand()) {
       // Admin commands should not block the main work threads (there
@@ -417,11 +420,14 @@
   private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
     private final CommandRunnable thunk;
     private final String taskName;
+    private final AccessPath accessPath;
+
     private Project.NameKey projectName;
 
-    private TaskThunk(CommandRunnable thunk) {
+    private TaskThunk(final CommandRunnable thunk, AccessPath accessPath) {
       this.thunk = thunk;
       this.taskName = getTaskName();
+      this.accessPath = accessPath;
     }
 
     @Override
@@ -442,6 +448,7 @@
         final Thread thisThread = Thread.currentThread();
         final String thisName = thisThread.getName();
         int rc = 0;
+        context.getSession().setAccessPath(accessPath);
         final Context old = sshScope.set(context);
         try {
           context.started = TimeUtil.nowMs();
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 9fc4cda..dc838f2 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -40,7 +39,6 @@
 public class ChangeArgumentParser {
   private final ChangesCollection changesCollection;
   private final ChangeFinder changeFinder;
-  private final ReviewDb db;
   private final ChangeNotes.Factory changeNotesFactory;
   private final PermissionBackend permissionBackend;
 
@@ -48,33 +46,31 @@
   ChangeArgumentParser(
       ChangesCollection changesCollection,
       ChangeFinder changeFinder,
-      ReviewDb db,
       ChangeNotes.Factory changeNotesFactory,
       PermissionBackend permissionBackend) {
     this.changesCollection = changesCollection;
     this.changeFinder = changeFinder;
-    this.db = db;
     this.changeNotesFactory = changeNotesFactory;
     this.permissionBackend = permissionBackend;
   }
 
   public void addChange(String id, Map<Change.Id, ChangeResource> changes)
-      throws UnloggedFailure, OrmException, PermissionBackendException, IOException {
+      throws UnloggedFailure, PermissionBackendException, IOException {
     addChange(id, changes, null);
   }
 
   public void addChange(
-      String id, Map<Change.Id, ChangeResource> changes, ProjectState projectState)
-      throws UnloggedFailure, OrmException, PermissionBackendException, IOException {
+      String id, Map<Change.Id, ChangeResource> changes, @Nullable ProjectState projectState)
+      throws UnloggedFailure, PermissionBackendException, IOException {
     addChange(id, changes, projectState, true);
   }
 
   public void addChange(
       String id,
       Map<Change.Id, ChangeResource> changes,
-      ProjectState projectState,
+      @Nullable ProjectState projectState,
       boolean useIndex)
-      throws UnloggedFailure, OrmException, PermissionBackendException, IOException {
+      throws UnloggedFailure, PermissionBackendException, IOException {
     List<ChangeNotes> matched = useIndex ? changeFinder.find(id) : changeFromNotesFactory(id);
     List<ChangeNotes> toAdd = new ArrayList<>(changes.size());
     boolean canMaintainServer;
@@ -86,15 +82,22 @@
     }
     for (ChangeNotes notes : matched) {
       if (!changes.containsKey(notes.getChangeId())
-          && inProject(projectState, notes.getProjectName())
-          && (canMaintainServer
-              || (permissionBackend
-                      .currentUser()
-                      .change(notes)
-                      .database(db)
-                      .test(ChangePermission.READ)
-                  && projectState.statePermitsRead()))) {
-        toAdd.add(notes);
+          && inProject(projectState, notes.getProjectName())) {
+        if (canMaintainServer) {
+          toAdd.add(notes);
+          continue;
+        }
+
+        if (projectState != null && !projectState.statePermitsRead()) {
+          continue;
+        }
+
+        try {
+          permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
+          toAdd.add(notes);
+        } catch (AuthException e) {
+          // Do nothing.
+        }
       }
     }
 
@@ -113,13 +116,13 @@
     changes.put(cId, changeResource);
   }
 
-  private List<ChangeNotes> changeFromNotesFactory(String id) throws OrmException, UnloggedFailure {
-    return changeNotesFactory.create(db, parseId(id));
+  private List<ChangeNotes> changeFromNotesFactory(String id) throws UnloggedFailure {
+    return changeNotesFactory.create(parseId(id));
   }
 
   private List<Change.Id> parseId(String id) throws UnloggedFailure {
     try {
-      return Arrays.asList(new Change.Id(Integer.parseInt(id)));
+      return Arrays.asList(Change.id(Integer.parseInt(id)));
     } catch (NumberFormatException e) {
       throw new UnloggedFailure(2, "Invalid change ID " + id, e);
     }
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 3eef4d6..245dd60 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -19,11 +19,10 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
-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;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -38,11 +37,11 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.CommandFactory;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
 import org.apache.sshd.server.SessionAware;
+import org.apache.sshd.server.command.Command;
+import org.apache.sshd.server.command.CommandFactory;
 import org.apache.sshd.server.session.ServerSession;
 import org.eclipse.jgit.lib.Config;
 
@@ -56,7 +55,6 @@
   private final SshScope sshScope;
   private final ScheduledExecutorService startExecutor;
   private final ExecutorService destroyExecutor;
-  private final SchemaFactory<ReviewDb> schemaFactory;
   private final DynamicItem<SshCreateCommandInterceptor> createCommandInterceptor;
 
   @Inject
@@ -66,22 +64,21 @@
       WorkQueue workQueue,
       SshLog l,
       SshScope s,
-      SchemaFactory<ReviewDb> sf,
       DynamicItem<SshCreateCommandInterceptor> i) {
     dispatcher = d;
     log = l;
     sshScope = s;
-    schemaFactory = sf;
     createCommandInterceptor = i;
 
     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
@@ -94,16 +91,13 @@
 
   @Override
   public CommandFactory get() {
-    return new CommandFactory() {
-      @Override
-      public Command createCommand(String requestCommand) {
-        String c = requestCommand;
-        SshCreateCommandInterceptor interceptor = createCommandInterceptor.get();
-        if (interceptor != null) {
-          c = interceptor.intercept(c);
-        }
-        return new Trampoline(c);
+    return requestCommand -> {
+      String c = requestCommand;
+      SshCreateCommandInterceptor interceptor = createCommandInterceptor.get();
+      if (interceptor != null) {
+        c = interceptor.intercept(c);
       }
+      return new Trampoline(c);
     };
   }
 
@@ -150,7 +144,7 @@
     @Override
     public void setSession(ServerSession session) {
       final SshSession s = session.getAttribute(SshSession.KEY);
-      this.ctx = sshScope.newContext(schemaFactory, s, commandLine);
+      this.ctx = sshScope.newContext(s, commandLine);
     }
 
     @Override
diff --git a/java/com/google/gerrit/sshd/CommandModule.java b/java/com/google/gerrit/sshd/CommandModule.java
index 93aab0b..ac07056 100644
--- a/java/com/google/gerrit/sshd/CommandModule.java
+++ b/java/com/google/gerrit/sshd/CommandModule.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.inject.binder.LinkedBindingBuilder;
-import org.apache.sshd.server.Command;
+import org.apache.sshd.server.command.Command;
 
 /** Module to register commands in the SSH daemon. */
 public abstract class CommandModule extends LifecycleModule {
diff --git a/java/com/google/gerrit/sshd/CommandProvider.java b/java/com/google/gerrit/sshd/CommandProvider.java
index 61c36cb..cf2e84c 100644
--- a/java/com/google/gerrit/sshd/CommandProvider.java
+++ b/java/com/google/gerrit/sshd/CommandProvider.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.inject.Provider;
-import org.apache.sshd.server.Command;
+import org.apache.sshd.server.command.Command;
 
 final class CommandProvider {
 
diff --git a/java/com/google/gerrit/sshd/Commands.java b/java/com/google/gerrit/sshd/Commands.java
index 43d2c50..b6d3401 100644
--- a/java/com/google/gerrit/sshd/Commands.java
+++ b/java/com/google/gerrit/sshd/Commands.java
@@ -17,7 +17,7 @@
 import com.google.auto.value.AutoAnnotation;
 import com.google.inject.Key;
 import java.lang.annotation.Annotation;
-import org.apache.sshd.server.Command;
+import org.apache.sshd.server.command.Command;
 
 /** Utilities to support {@link CommandName} construction. */
 public class Commands {
diff --git a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index be17219..1e32e1b 100644
--- a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.sshd;
 
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Preconditions;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FileUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -101,7 +101,7 @@
   @Override
   public boolean authenticate(String username, PublicKey suppliedKey, ServerSession session) {
     SshSession sd = session.getAttribute(SshSession.KEY);
-    Preconditions.checkState(sd.getUser() == null);
+    checkState(sd.getUser() == null);
     if (PeerDaemonUser.USER_NAME.equals(username)) {
       if (myHostKeys.contains(suppliedKey) || getPeerKeys().contains(suppliedKey)) {
         PeerDaemonUser user = peerFactory.create(sd.getRemoteAddress());
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 490dd52..68962db 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.Atomics;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.args4j.SubcommandHandler;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -31,8 +32,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
-import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.command.Command;
 import org.kohsuke.args4j.Argument;
 
 /** Command that dispatches to a subcommand from its command table. */
@@ -44,6 +45,7 @@
   private final PermissionBackend permissionBackend;
   private final Map<String, CommandProvider> commands;
   private final AtomicReference<Command> atomicCmd;
+  private final DynamicSet<SshExecuteCommandInterceptor> commandInterceptors;
 
   @Argument(index = 0, required = false, metaVar = "COMMAND", handler = SubcommandHandler.class)
   private String commandName;
@@ -52,10 +54,14 @@
   private List<String> args = new ArrayList<>();
 
   @Inject
-  DispatchCommand(PermissionBackend permissionBackend, @Assisted Map<String, CommandProvider> all) {
+  DispatchCommand(
+      PermissionBackend permissionBackend,
+      DynamicSet<SshExecuteCommandInterceptor> commandInterceptors,
+      @Assisted Map<String, CommandProvider> all) {
     this.permissionBackend = permissionBackend;
     commands = all;
     atomicCmd = Atomics.newReference();
+    this.commandInterceptors = commandInterceptors;
   }
 
   Map<String, CommandProvider> getMap() {
@@ -84,19 +90,29 @@
 
       final Command cmd = p.getProvider().get();
       checkRequiresCapability(cmd);
+      String actualCommandName = commandName;
       if (cmd instanceof BaseCommand) {
         final BaseCommand bc = (BaseCommand) cmd;
-        if (getName().isEmpty()) {
-          bc.setName(commandName);
-        } else {
-          bc.setName(getName() + " " + commandName);
+        if (!getName().isEmpty()) {
+          actualCommandName = getName() + " " + commandName;
         }
+        bc.setName(actualCommandName);
         bc.setArguments(args.toArray(new String[args.size()]));
 
       } else if (!args.isEmpty()) {
         throw die(commandName + " does not take arguments");
       }
 
+      for (SshExecuteCommandInterceptor commandInterceptor : commandInterceptors) {
+        if (!commandInterceptor.accept(actualCommandName, args)) {
+          throw new UnloggedFailure(
+              126,
+              String.format(
+                  "blocked by %s, contact gerrit administrators for more details",
+                  commandInterceptor.name()));
+        }
+      }
+
       provideStateTo(cmd);
       atomicCmd.set(cmd);
       cmd.start(env);
diff --git a/java/com/google/gerrit/sshd/DispatchCommandProvider.java b/java/com/google/gerrit/sshd/DispatchCommandProvider.java
index c782d2f..2a65ed0 100644
--- a/java/com/google/gerrit/sshd/DispatchCommandProvider.java
+++ b/java/com/google/gerrit/sshd/DispatchCommandProvider.java
@@ -24,7 +24,7 @@
 import java.lang.annotation.Annotation;
 import java.util.List;
 import java.util.concurrent.ConcurrentMap;
-import org.apache.sshd.server.Command;
+import org.apache.sshd.server.command.Command;
 
 /** Creates DispatchCommand using commands registered by {@link CommandModule}. */
 public class DispatchCommandProvider implements Provider<DispatchCommand> {
@@ -50,24 +50,14 @@
     if (m.putIfAbsent(name.value(), commandProvider) != null) {
       throw new IllegalArgumentException(name.value() + " exists");
     }
-    return new RegistrationHandle() {
-      @Override
-      public void remove() {
-        m.remove(name.value(), commandProvider);
-      }
-    };
+    return () -> m.remove(name.value(), commandProvider);
   }
 
   public RegistrationHandle replace(CommandName name, Provider<Command> cmd) {
     final ConcurrentMap<String, CommandProvider> m = getMap();
     final CommandProvider commandProvider = new CommandProvider(cmd, null);
     m.put(name.value(), commandProvider);
-    return new RegistrationHandle() {
-      @Override
-      public void remove() {
-        m.remove(name.value(), commandProvider);
-      }
-    };
+    return () -> m.remove(name.value(), commandProvider);
   }
 
   ConcurrentMap<String, CommandProvider> getMap() {
diff --git a/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java b/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
index adb5085..01a8cb6 100644
--- a/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
+++ b/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
@@ -77,6 +77,6 @@
         sshScope,
         sshLog,
         sd,
-        SshUtil.createUser(sd, userFactory, account.get().getId()));
+        SshUtil.createUser(sd, userFactory, account.get().id()));
   }
 }
diff --git a/java/com/google/gerrit/sshd/NoShell.java b/java/com/google/gerrit/sshd/NoShell.java
index ba0dcba..d61d6f7 100644
--- a/java/com/google/gerrit/sshd/NoShell.java
+++ b/java/com/google/gerrit/sshd/NoShell.java
@@ -15,13 +15,11 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.sshd.SshScope.Context;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -30,10 +28,10 @@
 import java.net.MalformedURLException;
 import java.net.URL;
 import org.apache.sshd.common.Factory;
-import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
 import org.apache.sshd.server.SessionAware;
+import org.apache.sshd.server.command.Command;
 import org.apache.sshd.server.session.ServerSession;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.util.SystemReader;
@@ -59,7 +57,6 @@
 
   static class SendMessage implements Command, SessionAware {
     private final Provider<MessageFactory> messageFactory;
-    private final SchemaFactory<ReviewDb> schemaFactory;
     private final SshScope sshScope;
 
     private InputStream in;
@@ -69,10 +66,8 @@
     private Context context;
 
     @Inject
-    SendMessage(
-        Provider<MessageFactory> messageFactory, SchemaFactory<ReviewDb> sf, SshScope sshScope) {
+    SendMessage(Provider<MessageFactory> messageFactory, SshScope sshScope) {
       this.messageFactory = messageFactory;
-      this.schemaFactory = sf;
       this.sshScope = sshScope;
     }
 
@@ -99,7 +94,7 @@
     @Override
     public void setSession(ServerSession session) {
       SshSession s = session.getAttribute(SshSession.KEY);
-      this.context = sshScope.newContext(schemaFactory, s, "");
+      this.context = sshScope.newContext(s, "");
     }
 
     @Override
@@ -150,7 +145,7 @@
       msg.append("\r\n");
 
       Account account = user.getAccount();
-      String name = account.getFullName();
+      String name = account.fullName();
       if (name == null || name.isEmpty()) {
         name = user.getUserName().orElse(anonymousCowardName);
       }
diff --git a/java/com/google/gerrit/sshd/PluginCommandModule.java b/java/com/google/gerrit/sshd/PluginCommandModule.java
index b0116e4..f0dc17a 100644
--- a/java/com/google/gerrit/sshd/PluginCommandModule.java
+++ b/java/com/google/gerrit/sshd/PluginCommandModule.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.common.base.Preconditions;
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.inject.Inject;
 import com.google.inject.binder.LinkedBindingBuilder;
-import org.apache.sshd.server.Command;
+import org.apache.sshd.server.command.Command;
 
 public abstract class PluginCommandModule extends CommandModule {
   private CommandName command;
@@ -30,7 +31,7 @@
 
   @Override
   protected final void configure() {
-    Preconditions.checkState(command != null, "@PluginName must be provided");
+    checkState(command != null, "@PluginName must be provided");
     bind(Commands.key(command)).toProvider(new DispatchCommandProvider(command));
     configureCommands();
   }
diff --git a/java/com/google/gerrit/sshd/SingleCommandPluginModule.java b/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
index 079661a..edc797c 100644
--- a/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
+++ b/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.common.base.Preconditions;
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.inject.Inject;
 import com.google.inject.binder.LinkedBindingBuilder;
-import org.apache.sshd.server.Command;
+import org.apache.sshd.server.command.Command;
 
 /**
  * Binds one SSH command to the plugin name itself.
@@ -35,7 +36,7 @@
 
   @Override
   protected final void configure() {
-    Preconditions.checkState(command != null, "@PluginName must be provided");
+    checkState(command != null, "@PluginName must be provided");
     configure(bind(Commands.key(command)));
   }
 
diff --git a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
index 0fdde81..5b6d8f9 100644
--- a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
+++ b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.sshd;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.plugins.AutoRegisterUtil.calculateBindAnnotation;
 
-import com.google.common.base.Preconditions;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.annotations.Export;
@@ -28,7 +28,7 @@
 import java.lang.annotation.Annotation;
 import java.util.HashMap;
 import java.util.Map;
-import org.apache.sshd.server.Command;
+import org.apache.sshd.server.command.Command;
 
 class SshAutoRegisterModuleGenerator extends AbstractModule implements ModuleGenerator {
   private final Map<String, Class<Command>> commands = new HashMap<>();
@@ -61,7 +61,7 @@
   @SuppressWarnings("unchecked")
   @Override
   public void export(Export export, Class<?> type) throws InvalidPluginException {
-    Preconditions.checkState(command != null, "pluginName must be provided");
+    checkState(command != null, "pluginName must be provided");
     if (Command.class.isAssignableFrom(type)) {
       Class<Command> old = commands.get(export.value());
       if (old != null) {
@@ -86,7 +86,7 @@
 
   @Override
   public Module create() throws InvalidPluginException {
-    Preconditions.checkState(command != null, "pluginName must be provided");
+    checkState(command != null, "pluginName must be provided");
     return !commands.isEmpty() ? this : null;
   }
 }
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index 3e42ebe..2590188 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,32 +14,67 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.PerformanceLogContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.PrintWriter;
 import org.apache.sshd.server.Environment;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
 
 public abstract class SshCommand extends BaseCommand {
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+  @Inject private PluginSetContext<RequestListener> requestListeners;
+  @Inject @GerritServerConfig private Config config;
+
+  @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;
 
   @Override
   public void start(Environment env) throws IOException {
     startThread(
-        new CommandRunnable() {
-          @Override
-          public void run() throws Exception {
-            parseCommandLine();
-            stdout = toPrintWriter(out);
-            stderr = toPrintWriter(err);
-            try {
-              SshCommand.this.run();
-            } finally {
-              stdout.flush();
-              stderr.flush();
-            }
+        () -> {
+          parseCommandLine();
+          stdout = toPrintWriter(out);
+          stderr = toPrintWriter(err);
+          try (TraceContext traceContext = enableTracing();
+              PerformanceLogContext performanceLogContext =
+                  new PerformanceLogContext(config, performanceLoggers)) {
+            RequestInfo requestInfo =
+                RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
+            requestListeners.runEach(l -> l.onRequest(requestInfo));
+            SshCommand.this.run();
+          } finally {
+            stdout.flush();
+            stderr.flush();
           }
-        });
+        },
+        AccessPath.SSH_COMMAND);
   }
 
   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/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index 688c573..d703316 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -66,14 +66,10 @@
 import org.apache.mina.transport.socket.SocketSessionConfig;
 import org.apache.sshd.common.BaseBuilder;
 import org.apache.sshd.common.NamedFactory;
-import org.apache.sshd.common.channel.RequestHandler;
 import org.apache.sshd.common.cipher.Cipher;
 import org.apache.sshd.common.compression.BuiltinCompressions;
 import org.apache.sshd.common.compression.Compression;
-import org.apache.sshd.common.file.FileSystemFactory;
 import org.apache.sshd.common.forward.DefaultForwarderFactory;
-import org.apache.sshd.common.future.CloseFuture;
-import org.apache.sshd.common.future.SshFutureListener;
 import org.apache.sshd.common.io.AbstractIoServiceFactory;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoServiceFactory;
@@ -87,14 +83,11 @@
 import org.apache.sshd.common.mac.Mac;
 import org.apache.sshd.common.random.Random;
 import org.apache.sshd.common.random.SingletonRandomFactory;
-import org.apache.sshd.common.session.ConnectionService;
 import org.apache.sshd.common.session.Session;
 import org.apache.sshd.common.util.buffer.Buffer;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.apache.sshd.common.util.security.SecurityUtils;
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.CommandFactory;
 import org.apache.sshd.server.ServerBuilder;
 import org.apache.sshd.server.SshServer;
 import org.apache.sshd.server.auth.UserAuth;
@@ -102,6 +95,7 @@
 import org.apache.sshd.server.auth.gss.UserAuthGSSFactory;
 import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
 import org.apache.sshd.server.auth.pubkey.UserAuthPublicKeyFactory;
+import org.apache.sshd.server.command.CommandFactory;
 import org.apache.sshd.server.forward.ForwardingFilter;
 import org.apache.sshd.server.global.CancelTcpipForwardHandler;
 import org.apache.sshd.server.global.KeepAliveHandler;
@@ -274,14 +268,11 @@
             // Log a session close without authentication as a failure.
             //
             s.addCloseFutureListener(
-                new SshFutureListener<CloseFuture>() {
-                  @Override
-                  public void operationComplete(CloseFuture future) {
-                    connected.decrementAndGet();
-                    if (sd.isAuthenticationError()) {
-                      authFailures.increment();
-                      sshLog.onAuthFail(sd);
-                    }
+                future -> {
+                  connected.decrementAndGet();
+                  if (sd.isAuthenticationError()) {
+                    authFailures.increment();
+                    sshLog.onAuthFail(sd);
                   }
                 });
             return s;
@@ -293,7 +284,7 @@
           }
         });
     setGlobalRequestHandlers(
-        Arrays.<RequestHandler<ConnectionService>>asList(
+        Arrays.asList(
             new KeepAliveHandler(),
             new NoMoreSessionsHandler(),
             new TcpipForwardHandler(),
@@ -654,7 +645,7 @@
   }
 
   private void initSubsystems() {
-    setSubsystemFactories(Collections.<NamedFactory<Command>>emptyList());
+    setSubsystemFactories(Collections.emptyList());
   }
 
   private void initUserAuth(
@@ -721,10 +712,8 @@
 
   private void initFileSystemFactory() {
     setFileSystemFactory(
-        new FileSystemFactory() {
-          @Override
-          public FileSystem createFileSystem(Session session) throws IOException {
-            return new FileSystem() {
+        session ->
+            new FileSystem() {
               @Override
               public void close() throws IOException {}
 
@@ -782,8 +771,6 @@
               public Set<String> supportedFileAttributeViews() {
                 return null;
               }
-            };
-          }
-        });
+            });
   }
 }
diff --git a/java/com/google/gerrit/sshd/SshExecuteCommandInterceptor.java b/java/com/google/gerrit/sshd/SshExecuteCommandInterceptor.java
new file mode 100644
index 0000000..ee60670
--- /dev/null
+++ b/java/com/google/gerrit/sshd/SshExecuteCommandInterceptor.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.List;
+
+@ExtensionPoint
+public interface SshExecuteCommandInterceptor {
+
+  /**
+   * Check the command and return false if this command must not be run.
+   *
+   * @param command the command
+   * @param arguments the list of arguments
+   * @return whether or not this command with these arguments can be executed
+   */
+  boolean accept(String command, List<String> arguments);
+
+  default String name() {
+    return this.getClass().getSimpleName();
+  }
+}
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index b573062..773c25b 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -24,6 +24,9 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.server.ssh.SshKeyCreator;
 import com.google.inject.Inject;
@@ -86,6 +89,7 @@
   @Override
   public void evict(String username) {
     if (username != null) {
+      logger.atFine().log("Evict SSH key for username %s", username);
       cache.invalidate(username);
     }
   }
@@ -102,22 +106,28 @@
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
-      Optional<ExternalId> user = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
-      if (!user.isPresent()) {
-        return NO_SUCH_USER;
-      }
-
-      List<SshKeyCacheEntry> kl = new ArrayList<>(4);
-      for (AccountSshKey k : authorizedKeys.getKeys(user.get().accountId())) {
-        if (k.valid()) {
-          add(kl, k);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading SSH keys for account with username",
+              Metadata.builder().username(username).build())) {
+        Optional<ExternalId> user =
+            externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
+        if (!user.isPresent()) {
+          return NO_SUCH_USER;
         }
-      }
 
-      if (kl.isEmpty()) {
-        return NO_KEYS;
+        List<SshKeyCacheEntry> kl = new ArrayList<>(4);
+        for (AccountSshKey k : authorizedKeys.getKeys(user.get().accountId())) {
+          if (k.valid()) {
+            add(kl, k);
+          }
+        }
+
+        if (kl.isEmpty()) {
+          return NO_KEYS;
+        }
+        return Collections.unmodifiableList(kl);
       }
-      return Collections.unmodifiableList(kl);
     }
 
     private void add(List<SshKeyCacheEntry> kl, AccountSshKey k) {
diff --git a/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java b/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
index d89f9e0..da0ec1d 100644
--- a/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.exceptions.InvalidSshKeyException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.ssh.SshKeyCreator;
diff --git a/java/com/google/gerrit/sshd/SshLog.java b/java/com/google/gerrit/sshd/SshLog.java
index b6c2d19..5fb75c8 100644
--- a/java/com/google/gerrit/sshd/SshLog.java
+++ b/java/com/google/gerrit/sshd/SshLog.java
@@ -15,26 +15,27 @@
 package com.google.gerrit.sshd;
 
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
-import com.google.gerrit.server.audit.AuditService;
 import com.google.gerrit.server.audit.SshAuditEvent;
 import com.google.gerrit.server.config.ConfigKey;
 import com.google.gerrit.server.config.ConfigUpdatedEvent;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.util.IdGenerator;
+import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.ioutil.HexFormat;
 import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.Collections;
-import java.util.List;
 import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Level;
 import org.apache.log4j.Logger;
@@ -56,7 +57,7 @@
   private final Provider<SshSession> session;
   private final Provider<Context> context;
   private volatile AsyncAppender async;
-  private final AuditService auditService;
+  private final GroupAuditService auditService;
   private final SystemLog systemLog;
 
   private final Object lock = new Object();
@@ -67,7 +68,7 @@
       final Provider<Context> context,
       SystemLog systemLog,
       @GerritServerConfig Config config,
-      AuditService auditService) {
+      GroupAuditService auditService) {
     this.session = session;
     this.context = context;
     this.auditService = auditService;
@@ -277,7 +278,7 @@
   }
 
   private static String id(int id) {
-    return IdGenerator.format(id);
+    return HexFormat.fromInt(id);
   }
 
   void audit(Context ctx, Object result, String cmd) {
@@ -298,7 +299,7 @@
       created = TimeUtil.nowMs();
     } else {
       SshSession session = ctx.getSession();
-      sessionId = IdGenerator.format(session.getSessionId());
+      sessionId = HexFormat.fromInt(session.getSessionId());
       currentUser = session.getUser();
       created = ctx.created;
     }
@@ -318,21 +319,22 @@
   }
 
   @Override
-  public List<ConfigUpdatedEvent.Update> configUpdated(ConfigUpdatedEvent event) {
+  public Multimap<UpdateResult, ConfigUpdateEntry> configUpdated(ConfigUpdatedEvent event) {
     ConfigKey sshdRequestLog = ConfigKey.create("sshd", "requestLog");
     if (!event.isValueUpdated(sshdRequestLog)) {
-      return Collections.emptyList();
+      return ConfigUpdatedEvent.NO_UPDATES;
     }
-
-    boolean enabled = event.getNewConfig().getBoolean("sshd", "requestLog", true);
     boolean stateUpdated;
-    if (enabled) {
-      stateUpdated = enableLogging();
-    } else {
-      stateUpdated = disableLogging();
+    try {
+      boolean enabled = event.getNewConfig().getBoolean("sshd", "requestLog", true);
+      if (enabled) {
+        stateUpdated = enableLogging();
+      } else {
+        stateUpdated = disableLogging();
+      }
+      return stateUpdated ? event.accept(sshdRequestLog) : ConfigUpdatedEvent.NO_UPDATES;
+    } catch (IllegalArgumentException iae) {
+      return event.reject(sshdRequestLog);
     }
-    return stateUpdated
-        ? Collections.singletonList(event.accept(sshdRequestLog))
-        : Collections.emptyList();
   }
 }
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index f047017..e4aa14c 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -20,10 +20,8 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.GerritConfigListener;
@@ -36,7 +34,6 @@
 import com.google.gerrit.server.plugins.StartPluginListener;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.gerrit.sshd.commands.QueryShell;
 import com.google.inject.Inject;
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.servlet.RequestScoped;
@@ -45,9 +42,9 @@
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
-import org.apache.sshd.server.CommandFactory;
 import org.apache.sshd.server.auth.gss.GSSAuthenticator;
 import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
+import org.apache.sshd.server.command.CommandFactory;
 import org.eclipse.jgit.lib.Config;
 
 /** Configures standard dependencies for {@link SshDaemon}. */
@@ -77,7 +74,6 @@
 
     bind(SshInfo.class).to(SshDaemon.class).in(SINGLETON);
     factory(DispatchCommand.Factory.class);
-    factory(QueryShell.Factory.class);
     factory(PeerDaemonUser.Factory.class);
 
     bind(DispatchCommandProvider.class)
@@ -104,8 +100,8 @@
         .annotatedWith(UniqueAnnotations.create())
         .to(SshPluginStarterCallback.class);
 
-    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicItem.itemOf(binder(), SshCreateCommandInterceptor.class);
+    DynamicSet.setOf(binder(), SshExecuteCommandInterceptor.class);
 
     listener().toInstance(registerInParentInjectors());
     listener().to(SshLog.class);
diff --git a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index e9a095f..6e8590c 100644
--- a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -24,7 +24,7 @@
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import org.apache.sshd.server.Command;
+import org.apache.sshd.server.command.Command;
 
 @Singleton
 class SshPluginStarterCallback implements StartPluginListener, ReloadPluginListener {
diff --git a/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
index 2659831..340b910 100644
--- a/java/com/google/gerrit/sshd/SshScope.java
+++ b/java/com/google/gerrit/sshd/SshScope.java
@@ -14,22 +14,18 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestCleanup;
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestScopePropagator;
-import com.google.gwtorm.server.SchemaFactory;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.OutOfScopeException;
 import com.google.inject.Provider;
 import com.google.inject.Scope;
-import com.google.inject.util.Providers;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -37,13 +33,9 @@
 public class SshScope {
   private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
 
-  private static final Key<RequestScopedReviewDbProvider> DB_KEY =
-      Key.get(RequestScopedReviewDbProvider.class);
-
   class Context implements RequestContext {
     private final RequestCleanup cleanup = new RequestCleanup();
     private final Map<Key<?>, Object> map = new HashMap<>();
-    private final SchemaFactory<ReviewDb> schemaFactory;
     private final SshSession session;
     private final String commandLine;
 
@@ -51,17 +43,15 @@
     volatile long started;
     volatile long finished;
 
-    private Context(SchemaFactory<ReviewDb> sf, SshSession s, String c, long at) {
-      schemaFactory = sf;
+    private Context(SshSession s, String c, long at) {
       session = s;
       commandLine = c;
       created = started = finished = at;
       map.put(RC_KEY, cleanup);
-      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
     }
 
     private Context(Context p, SshSession s, String c) {
-      this(p.schemaFactory, s, c, p.created);
+      this(s, c, p.created);
       started = p.started;
       finished = p.finished;
     }
@@ -85,11 +75,6 @@
       return user;
     }
 
-    @Override
-    public Provider<ReviewDb> getReviewDbProvider() {
-      return (RequestScopedReviewDbProvider) map.get(DB_KEY);
-    }
-
     synchronized <T> T get(Key<T> key, Provider<T> creator) {
       @SuppressWarnings("unchecked")
       T t = (T) map.get(key);
@@ -125,11 +110,8 @@
     private final SshScope sshScope;
 
     @Inject
-    Propagator(
-        SshScope sshScope,
-        ThreadLocalRequestContext local,
-        Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
-      super(REQUEST, current, local, dbProviderProvider);
+    Propagator(SshScope sshScope, ThreadLocalRequestContext local) {
+      super(REQUEST, current, local);
       this.sshScope = sshScope;
     }
 
@@ -160,8 +142,8 @@
     this.userFactory = userFactory;
   }
 
-  Context newContext(SchemaFactory<ReviewDb> sf, SshSession s, String cmd) {
-    return new Context(sf, s, cmd, TimeUtil.nowMs());
+  Context newContext(SshSession s, String cmd) {
+    return new Context(s, cmd, TimeUtil.nowMs());
   }
 
   private Context newContinuingContext(Context ctx) {
diff --git a/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
index ce35422..670d64c 100644
--- a/java/com/google/gerrit/sshd/SshUtil.java
+++ b/java/com/google/gerrit/sshd/SshUtil.java
@@ -30,8 +30,6 @@
 import java.security.spec.InvalidKeySpecException;
 import org.apache.commons.codec.binary.Base64;
 import org.apache.sshd.common.SshException;
-import org.apache.sshd.common.future.CloseFuture;
-import org.apache.sshd.common.future.SshFutureListener;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.server.session.ServerSession;
@@ -127,7 +125,7 @@
       // session, record a login event in the log and add
       // a close listener to record a logout event.
       //
-      Context ctx = sshScope.newContext(null, sd, null);
+      Context ctx = sshScope.newContext(sd, null);
       Context old = sshScope.set(ctx);
       try {
         sshLog.onLogin();
@@ -136,16 +134,13 @@
       }
 
       session.addCloseFutureListener(
-          new SshFutureListener<CloseFuture>() {
-            @Override
-            public void operationComplete(CloseFuture future) {
-              final Context ctx = sshScope.newContext(null, sd, null);
-              final Context old = sshScope.set(ctx);
-              try {
-                sshLog.onLogout();
-              } finally {
-                sshScope.set(old);
-              }
+          future -> {
+            final Context ctx1 = sshScope.newContext(sd, null);
+            final Context old1 = sshScope.set(ctx1);
+            try {
+              sshLog.onLogout();
+            } finally {
+              sshScope.set(old1);
             }
           });
     }
diff --git a/java/com/google/gerrit/sshd/SuExec.java b/java/com/google/gerrit/sshd/SuExec.java
index 54371c1..7053a0d 100644
--- a/java/com/google/gerrit/sshd/SuExec.java
+++ b/java/com/google/gerrit/sshd/SuExec.java
@@ -34,8 +34,8 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
-import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.command.Command;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
diff --git a/java/com/google/gerrit/sshd/commands/AdminQueryShell.java b/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
deleted file mode 100644
index c520e79..0000000
--- a/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.restapi.AuthException;
-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.sshd.AdminHighPriorityCommand;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import org.kohsuke.args4j.Option;
-
-/** Opens a query processor. */
-@AdminHighPriorityCommand
-@RequiresCapability(GlobalCapability.ACCESS_DATABASE)
-@CommandMetaData(name = "gsql", description = "Administrative interface to active database")
-final class AdminQueryShell extends SshCommand {
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private QueryShell.Factory factory;
-
-  @Option(name = "--format", usage = "Set output format")
-  private QueryShell.OutputFormat format = QueryShell.OutputFormat.PRETTY;
-
-  @Option(name = "-c", metaVar = "SQL QUERY", usage = "Query to execute")
-  private String query;
-
-  @Override
-  protected void run() throws Failure {
-    try {
-      permissionBackend.currentUser().check(GlobalPermission.ACCESS_DATABASE);
-    } catch (AuthException err) {
-      throw die(err.getMessage());
-    } catch (PermissionBackendException e) {
-      throw new Failure(1, "unavailable", e);
-    }
-
-    QueryShell shell = factory.create(in, out);
-    shell.setOutputFormat(format);
-    if (query != null) {
-      shell.execute(query);
-    } else {
-      shell.run();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/java/com/google/gerrit/sshd/commands/AdminSetParent.java
deleted file mode 100644
index 67ed098..0000000
--- a/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ /dev/null
@@ -1,233 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License
-
-package com.google.gerrit.sshd.commands;
-
-import static 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.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-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.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();
-
-  @Option(
-      name = "--parent",
-      aliases = {"-p"},
-      metaVar = "NAME",
-      usage = "new parent project")
-  private ProjectState newParent;
-
-  @Option(
-      name = "--children-of",
-      metaVar = "NAME",
-      usage = "parent project for which the child projects should be reparented")
-  private ProjectState oldParent;
-
-  @Option(
-      name = "--exclude",
-      metaVar = "NAME",
-      usage = "child project of old parent project which should not be reparented")
-  private List<ProjectState> excludedChildren = new ArrayList<>();
-
-  @Argument(
-      index = 0,
-      required = false,
-      multiValued = true,
-      metaVar = "NAME",
-      usage = "projects to modify")
-  private List<ProjectState> children = new ArrayList<>();
-
-  @Inject private ProjectCache projectCache;
-
-  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
-
-  @Inject private AllProjectsName allProjectsName;
-
-  @Inject private ListChildProjects listChildProjects;
-
-  private Project.NameKey newParentKey;
-
-  @Override
-  protected void run() throws Failure {
-    if (oldParent == null && children.isEmpty()) {
-      throw die(
-          "child projects have to be specified as "
-              + "arguments or the --children-of option has to be set");
-    }
-    if (oldParent == null && !excludedChildren.isEmpty()) {
-      throw die("--exclude can only be used together with --children-of");
-    }
-
-    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 =
-        children.stream().map(ProjectState::getNameKey).collect(toList());
-    if (oldParent != null) {
-      try {
-        childProjects.addAll(getChildrenForReparenting(oldParent));
-      } catch (PermissionBackendException e) {
-        throw new Failure(1, "permissions unavailable", e);
-      } catch (RestApiException e) {
-        throw new Failure(1, "failure in request", e);
-      }
-    }
-
-    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");
-      }
-
-      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");
-      }
-    }
-
-    if (err.length() > 0) {
-      while (err.charAt(err.length() - 1) == '\n') {
-        err.setLength(err.length() - 1);
-      }
-      throw die(err.toString());
-    }
-  }
-
-  /**
-   * Returns the children of the specified parent project that should be reparented. The returned
-   * list of child projects does not contain projects that were specified to be excluded from
-   * reparenting.
-   */
-  private List<Project.NameKey> getChildrenForReparenting(ProjectState parent)
-      throws PermissionBackendException, RestApiException {
-    final List<Project.NameKey> childProjects = new ArrayList<>();
-    final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
-    for (ProjectState excludedChild : excludedChildren) {
-      excluded.add(excludedChild.getProject().getNameKey());
-    }
-    final List<Project.NameKey> automaticallyExcluded = new ArrayList<>(excludedChildren.size());
-    if (newParentKey != null) {
-      automaticallyExcluded.addAll(getAllParents(newParentKey));
-    }
-    for (ProjectInfo child : listChildProjects.apply(new ProjectResource(parent, user))) {
-      final Project.NameKey childName = new Project.NameKey(child.name);
-      if (!excluded.contains(childName)) {
-        if (!automaticallyExcluded.contains(childName)) {
-          childProjects.add(childName);
-        } else {
-          stdout.println(
-              "Automatically excluded '"
-                  + childName
-                  + "' "
-                  + "from reparenting because it is in the parent "
-                  + "line of the new parent '"
-                  + newParentKey
-                  + "'.");
-        }
-      }
-    }
-    return childProjects;
-  }
-
-  private Set<Project.NameKey> getAllParents(Project.NameKey projectName) {
-    ProjectState ps = projectCache.get(projectName);
-    if (ps == null) {
-      return Collections.emptySet();
-    }
-    return ps.parents().transform(ProjectState::getNameKey).toSet();
-  }
-}
diff --git a/java/com/google/gerrit/sshd/commands/ApproveOption.java b/java/com/google/gerrit/sshd/commands/ApproveOption.java
deleted file mode 100644
index 633eaa0..0000000
--- a/java/com/google/gerrit/sshd/commands/ApproveOption.java
+++ /dev/null
@@ -1,158 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import java.lang.annotation.Annotation;
-import java.lang.reflect.AnnotatedElement;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.CmdLineParser;
-import org.kohsuke.args4j.Option;
-import org.kohsuke.args4j.OptionDef;
-import org.kohsuke.args4j.spi.FieldSetter;
-import org.kohsuke.args4j.spi.OneArgumentOptionHandler;
-import org.kohsuke.args4j.spi.OptionHandler;
-import org.kohsuke.args4j.spi.Setter;
-
-final class ApproveOption implements Option, Setter<Short> {
-  private final String name;
-  private final String usage;
-  private final LabelType type;
-
-  private Short value;
-
-  ApproveOption(String name, String usage, LabelType type) {
-    this.name = name;
-    this.usage = usage;
-    this.type = type;
-  }
-
-  @Override
-  public String[] aliases() {
-    return new String[0];
-  }
-
-  @Override
-  public String[] depends() {
-    return new String[] {};
-  }
-
-  @Override
-  public boolean hidden() {
-    return false;
-  }
-
-  @Override
-  public Class<? extends OptionHandler<Short>> handler() {
-    return Handler.class;
-  }
-
-  @Override
-  public String metaVar() {
-    return "N";
-  }
-
-  @Override
-  public String name() {
-    return name;
-  }
-
-  @Override
-  public boolean required() {
-    return false;
-  }
-
-  @Override
-  public String usage() {
-    return usage;
-  }
-
-  public Short value() {
-    return value;
-  }
-
-  @Override
-  public Class<? extends Annotation> annotationType() {
-    return null;
-  }
-
-  @Override
-  public FieldSetter asFieldSetter() {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public AnnotatedElement asAnnotatedElement() {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public void addValue(Short val) {
-    this.value = val;
-  }
-
-  @Override
-  public Class<Short> getType() {
-    return Short.class;
-  }
-
-  @Override
-  public boolean isMultiValued() {
-    return false;
-  }
-
-  String getLabelName() {
-    return type.getName();
-  }
-
-  public static class Handler extends OneArgumentOptionHandler<Short> {
-    private final ApproveOption cmdOption;
-
-    // CS IGNORE RedundantModifier FOR NEXT 1 LINES. REASON: needed by org.kohsuke.args4j.Option
-    public Handler(CmdLineParser parser, OptionDef option, Setter<Short> setter) {
-      super(parser, option, setter);
-      this.cmdOption = (ApproveOption) setter;
-    }
-
-    @Override
-    protected Short parse(String token) throws NumberFormatException, CmdLineException {
-      String argument = token;
-      if (argument.startsWith("+")) {
-        argument = argument.substring(1);
-      }
-
-      final short value = Short.parseShort(argument);
-      final LabelValue min = cmdOption.type.getMin();
-      final LabelValue max = cmdOption.type.getMax();
-
-      if (value < min.getValue() || value > max.getValue()) {
-        final String name = cmdOption.name();
-        final String e =
-            "\""
-                + token
-                + "\" must be in range "
-                + min.formatValue()
-                + ".."
-                + max.formatValue()
-                + " for \""
-                + name
-                + "\"";
-        throw new CmdLineException(owner, e);
-      }
-      return value;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
index 415ac4c..ee6f635 100644
--- a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -68,7 +68,7 @@
           BanCommitInput.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
       input.reason = reason;
 
-      BanResultInfo r = banCommit.apply(new ProjectResource(projectState, user), input);
+      BanResultInfo r = banCommit.apply(new ProjectResource(projectState, user), input).value();
       printCommits(r.newlyBanned, "The following commits were banned");
       printCommits(r.alreadyBanned, "The following commits were already banned");
       printCommits(r.ignored, "The following ids do not represent commits and were ignored");
diff --git a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
index affb919..d70c153 100644
--- a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.Revisions;
diff --git a/java/com/google/gerrit/sshd/commands/CloseConnection.java b/java/com/google/gerrit/sshd/commands/CloseConnection.java
index a38461d..60a878a 100644
--- a/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -53,7 +53,7 @@
       required = true,
       metaVar = "SESSION_ID",
       usage = "List of SSH session IDs to be closed")
-  private final List<String> sessionIds = new ArrayList<>();
+  private List<String> sessionIds = new ArrayList<>();
 
   @Option(name = "--wait", usage = "wait for connection to close before exiting")
   private boolean wait;
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 9dc9a50..8875f07 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -20,13 +20,14 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.account.CreateAccount;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -66,10 +67,11 @@
   @Argument(index = 0, required = true, metaVar = "USERNAME", usage = "name of the user account")
   private String username;
 
-  @Inject private CreateAccount.Factory createAccountFactory;
+  @Inject private CreateAccount createAccount;
 
   @Override
-  protected void run() throws OrmException, IOException, ConfigInvalidException, UnloggedFailure {
+  protected void run()
+      throws IOException, ConfigInvalidException, UnloggedFailure, PermissionBackendException {
     AccountInput input = new AccountInput();
     input.username = username;
     input.email = email;
@@ -78,7 +80,7 @@
     input.httpPassword = httpPassword;
     input.groups = Lists.transform(groups, AccountGroup.Id::toString);
     try {
-      createAccountFactory.create(username).apply(TopLevelResource.INSTANCE, input);
+      createAccount.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(username), input);
     } catch (RestApiException e) {
       throw die(e.getMessage());
     }
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 5a83b01..913365b 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -26,13 +26,13 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddMembers;
 import com.google.gerrit.server.restapi.group.AddSubgroups;
 import com.google.gerrit.server.restapi.group.CreateGroup;
 import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.HashSet;
@@ -91,7 +91,7 @@
     initialGroups.add(id);
   }
 
-  @Inject private CreateGroup.Factory createGroupFactory;
+  @Inject private CreateGroup createGroup;
 
   @Inject private GroupsCollection groups;
 
@@ -100,7 +100,8 @@
   @Inject private AddSubgroups addSubgroups;
 
   @Override
-  protected void run() throws Failure, OrmException, IOException, ConfigInvalidException {
+  protected void run()
+      throws Failure, IOException, ConfigInvalidException, PermissionBackendException {
     try {
       GroupResource rsrc = createGroup();
 
@@ -117,7 +118,7 @@
   }
 
   private GroupResource createGroup()
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     GroupInput input = new GroupInput();
     input.description = groupDescription;
     input.visibleToAll = visibleToAll;
@@ -126,12 +127,15 @@
       input.ownerId = String.valueOf(ownerGroupId.get());
     }
 
-    GroupInfo group = createGroupFactory.create(groupName).apply(TopLevelResource.INSTANCE, input);
+    GroupInfo group =
+        createGroup
+            .apply(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName), input)
+            .value();
     return groups.parse(TopLevelResource.INSTANCE, IdString.fromUrl(group.id));
   }
 
   private void addMembers(GroupResource rsrc)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     AddMembers.Input input =
         AddMembers.Input.fromMembers(
             initialMembers.stream().map(Object::toString).collect(toList()));
@@ -139,7 +143,7 @@
   }
 
   private void addSubgroups(GroupResource rsrc)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     AddSubgroups.Input input =
         AddSubgroups.Input.fromGroups(
             initialGroups.stream().map(AccountGroup.UUID::get).collect(toList()));
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 0e36d53..87b6f02 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -90,7 +90,6 @@
     command(gerrit, CreateGroupCommand.class);
     command(gerrit, CreateProjectCommand.class);
     command(gerrit, SetHeadCommand.class);
-    command(gerrit, AdminQueryShell.class);
 
     if (slaveMode) {
       command("git-receive-pack").to(ReceiveSlaveMode.class);
@@ -114,7 +113,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/IndexChangesCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index fad74f5..5aa2ec8 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
@@ -22,7 +23,6 @@
 import com.google.gerrit.sshd.ChangeArgumentParser;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.LinkedHashMap;
@@ -44,7 +44,7 @@
   void addChange(String token) {
     try {
       changeArgumentParser.addChange(token, changes, null, false);
-    } catch (UnloggedFailure | OrmException | PermissionBackendException | IOException e) {
+    } catch (UnloggedFailure | StorageException | PermissionBackendException | IOException e) {
       writeError("warning", e.getMessage());
     }
   }
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
new file mode 100644
index 0000000..56b00a5
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
@@ -0,0 +1,60 @@
+// 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.sshd.commands;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.IndexChanges;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Argument;
+
+@RequiresAnyCapability({MAINTAIN_SERVER})
+@CommandMetaData(name = "changes-in-project", description = "Index changes of a project")
+final class IndexChangesInProjectCommand extends SshCommand {
+
+  @Inject private IndexChanges index;
+
+  @Argument(
+      index = 0,
+      required = true,
+      multiValued = true,
+      metaVar = "PROJECT",
+      usage = "projects for which the changes should be indexed")
+  private List<ProjectState> projects = new ArrayList<>();
+
+  @Override
+  protected void run() throws UnloggedFailure, Failure, Exception {
+    if (projects.isEmpty()) {
+      throw die("needs at least one project as command arguments");
+    }
+    projects.stream().forEach(this::index);
+  }
+
+  private void index(ProjectState projectState) {
+    try {
+      index.apply(new ProjectResource(projectState, user), null);
+    } catch (Exception e) {
+      writeError(
+          "error", String.format("Unable to index %s: %s", projectState.getName(), e.getMessage()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
index 599c9dc..332ed69 100644
--- a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
+++ b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
@@ -40,6 +40,6 @@
       command(index, IndexStartCommand.class);
     }
     command(index, IndexChangesCommand.class);
-    command(index, IndexProjectCommand.class);
+    command(index, IndexChangesInProjectCommand.class);
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
deleted file mode 100644
index 407bbd0..0000000
--- a/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
+++ /dev/null
@@ -1,60 +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.sshd.commands;
-
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.project.Index;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
-import org.kohsuke.args4j.Argument;
-
-@RequiresAnyCapability({MAINTAIN_SERVER})
-@CommandMetaData(name = "project", description = "Index changes of a project")
-final class IndexProjectCommand extends SshCommand {
-
-  @Inject private Index index;
-
-  @Argument(
-      index = 0,
-      required = true,
-      multiValued = true,
-      metaVar = "PROJECT",
-      usage = "projects for which the changes should be indexed")
-  private List<ProjectState> projects = new ArrayList<>();
-
-  @Override
-  protected void run() throws UnloggedFailure, Failure, Exception {
-    if (projects.isEmpty()) {
-      throw die("needs at least one project as command arguments");
-    }
-    projects.stream().forEach(this::index);
-  }
-
-  private void index(ProjectState projectState) {
-    try {
-      index.apply(new ProjectResource(projectState, user), null);
-    } catch (Exception e) {
-      writeError(
-          "error", String.format("Unable to index %s: %s", projectState.getName(), e.getMessage()));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/sshd/commands/KillCommand.java b/java/com/google/gerrit/sshd/commands/KillCommand.java
index ef12f5f..df74f86 100644
--- a/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -43,7 +43,7 @@
   @Inject private DeleteTask deleteTask;
 
   @Argument(index = 0, multiValued = true, required = true, metaVar = "ID")
-  private final List<String> taskIds = new ArrayList<>();
+  private List<String> taskIds = new ArrayList<>();
 
   @Override
   protected void run() {
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index f3ba308..87923f6 100644
--- a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -62,7 +62,7 @@
       if (verboseOutput) {
         Optional<InternalGroup> group =
             info.ownerId != null
-                ? groupCache.get(new AccountGroup.UUID(Url.decode(info.ownerId)))
+                ? groupCache.get(AccountGroup.uuid(Url.decode(info.ownerId)))
                 : Optional.empty();
 
         formatter.addColumn(Url.decode(info.id));
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index 2d6b1b3..38feecf 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.ioutil.ColumnFormatter;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.ListMembers;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.PrintWriter;
 import java.util.List;
@@ -68,8 +68,8 @@
       this.groupCache = groupCache;
     }
 
-    void display(PrintWriter writer) throws OrmException {
-      Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(name));
+    void display(PrintWriter writer) throws PermissionBackendException {
+      Optional<InternalGroup> group = groupCache.get(AccountGroup.nameKey(name));
       String errorText = "Group not found or not visible\n";
 
       if (!group.isPresent()) {
diff --git a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index d04e2d3..9f2ffa9 100644
--- a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -41,6 +41,6 @@
         throw die("--tree and --description options are not compatible.");
       }
     }
-    impl.display(out);
+    impl.displayToStream(out);
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index 781679d..ae3d59e 100644
--- a/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.sshd.commands;
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
-import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Map;
@@ -42,7 +42,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Option;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@RequiresCapability(GlobalCapability.READ_AS)
 @CommandMetaData(
     name = "ls-user-refs",
     description = "List refs visible to a specific user",
@@ -74,27 +74,26 @@
 
   @Override
   protected void run() throws Failure {
-    Account userAccount;
+    Account.Id userAccountId;
     try {
-      userAccount = accountResolver.find(userName);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw die(e);
-    }
-    if (userAccount == null) {
-      stdout.print("No single user could be found when searching for: " + userName + '\n');
+      userAccountId = accountResolver.resolve(userName).asUnique().getAccount().id();
+    } catch (UnprocessableEntityException e) {
+      stdout.println(e.getMessage());
       stdout.flush();
       return;
+    } catch (StorageException | IOException | ConfigInvalidException e) {
+      throw die(e);
     }
 
     Project.NameKey projectName = projectState.getNameKey();
     try (Repository repo = repoManager.openRepository(projectName);
-        ManualRequestContext ctx = requestContext.openAs(userAccount.getId())) {
+        ManualRequestContext ctx = requestContext.openAs(userAccountId)) {
       try {
         Map<String, Ref> refsMap =
             permissionBackend
-                .user(user)
+                .user(ctx.getUser())
                 .project(projectName)
-                .filter(repo.getRefDatabase().getRefs(ALL), repo, RefFilterOptions.defaults());
+                .filter(repo.getRefDatabase().getRefs(), repo, RefFilterOptions.defaults());
 
         for (String ref : refsMap.keySet()) {
           if (!onlyRefsHeads || ref.startsWith(RefNames.REFS_HEADS)) {
@@ -106,7 +105,7 @@
       }
     } catch (RepositoryNotFoundException e) {
       throw die("'" + projectName + "': not a git archive");
-    } catch (IOException | OrmException e) {
+    } catch (IOException e) {
       throw die("Error opening: '" + projectName);
     }
   }
diff --git a/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
index 9fcd201..377e1ac 100644
--- a/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -15,20 +15,18 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchChangeException;
 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.sshd.BaseCommand.UnloggedFailure;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -37,7 +35,6 @@
 
 @Singleton
 public class PatchSetParser {
-  private final Provider<ReviewDb> db;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
@@ -45,12 +42,10 @@
 
   @Inject
   PatchSetParser(
-      Provider<ReviewDb> db,
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
       PatchSetUtil psUtil,
       ChangeFinder changeFinder) {
-    this.db = db;
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
@@ -58,10 +53,10 @@
   }
 
   public PatchSet parsePatchSet(String token, ProjectState projectState, String branch)
-      throws UnloggedFailure, OrmException {
+      throws UnloggedFailure {
     // By commit?
     //
-    if (token.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) {
+    if (token.matches("^([0-9a-fA-F]{4," + ObjectIds.STR_LEN + "})$")) {
       InternalChangeQuery query = queryProvider.get();
       List<ChangeData> cds;
       if (projectState != null) {
@@ -81,7 +76,7 @@
           continue;
         }
         for (PatchSet ps : cd.patchSets()) {
-          if (ps.getRevision().matches(token)) {
+          if (ObjectIds.matchesAbbreviation(ps.commitId(), token)) {
             matches.add(ps);
           }
         }
@@ -106,8 +101,8 @@
       } catch (IllegalArgumentException e) {
         throw error("\"" + token + "\" is not a valid patch set");
       }
-      ChangeNotes notes = getNotes(projectState, patchSetId.getParentKey());
-      PatchSet patchSet = psUtil.get(db.get(), notes, patchSetId);
+      ChangeNotes notes = getNotes(projectState, patchSetId.changeId());
+      PatchSet patchSet = psUtil.get(notes, patchSetId);
       if (patchSet == null) {
         throw error("\"" + token + "\" no such patch set");
       }
@@ -127,13 +122,13 @@
   }
 
   private ChangeNotes getNotes(@Nullable ProjectState projectState, Change.Id changeId)
-      throws OrmException, UnloggedFailure {
+      throws UnloggedFailure {
     if (projectState != null) {
-      return notesFactory.create(db.get(), projectState.getNameKey(), changeId);
+      return notesFactory.create(projectState.getNameKey(), changeId);
     }
     try {
       ChangeNotes notes = changeFinder.findOne(changeId);
-      return notesFactory.create(db.get(), notes.getProjectName(), changeId);
+      return notesFactory.create(notes.getProjectName(), changeId);
     } catch (NoSuchChangeException e) {
       throw error("\"" + changeId + "\" no such change");
     }
@@ -152,7 +147,7 @@
       // No --branch option, so they want every branch.
       return true;
     }
-    return change.getDest().get().equals(branch);
+    return change.getDest().branch().equals(branch);
   }
 
   public static UnloggedFailure error(String msg) {
diff --git a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
index ceb5f07..e5dad7e 100644
--- a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.plugins.ListPlugins;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -41,7 +41,7 @@
 
   @Override
   public void run() throws Exception {
-    Map<String, PluginInfo> output = list.apply(TopLevelResource.INSTANCE);
+    Map<String, PluginInfo> output = list.apply(TopLevelResource.INSTANCE).value();
 
     if (format.isJson()) {
       format
diff --git a/java/com/google/gerrit/sshd/commands/Query.java b/java/com/google/gerrit/sshd/commands/Query.java
index 3fe0396..78485d3 100644
--- a/java/com/google/gerrit/sshd/commands/Query.java
+++ b/java/com/google/gerrit/sshd/commands/Query.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.query.change.OutputStreamQuery;
 import com.google.gerrit.server.query.change.OutputStreamQuery.OutputFormat;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -24,7 +25,7 @@
 import org.kohsuke.args4j.Option;
 
 @CommandMetaData(name = "query", description = "Query the change database")
-public class Query extends SshCommand {
+public class Query extends SshCommand implements DynamicOptions.BeanReceiver {
   @Inject private OutputStreamQuery processor;
 
   @Option(name = "--format", metaVar = "FMT", usage = "Output display format")
@@ -90,6 +91,11 @@
     processor.setStart(start);
   }
 
+  @Option(name = "--no-limit", usage = "Return all results, overriding the default limit")
+  void setNoLimit(boolean on) {
+    processor.setNoLimit(on);
+  }
+
   @Argument(
       index = 0,
       required = true,
@@ -104,6 +110,11 @@
   }
 
   @Override
+  public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+    processor.setDynamicBean(plugin, dynamicBean);
+  }
+
+  @Override
   protected void parseCommandLine() throws UnloggedFailure {
     processor.setOutput(out, OutputFormat.TEXT);
     super.parseCommandLine();
diff --git a/java/com/google/gerrit/sshd/commands/QueryShell.java b/java/com/google/gerrit/sshd/commands/QueryShell.java
deleted file mode 100644
index 9651f39..0000000
--- a/java/com/google/gerrit/sshd/commands/QueryShell.java
+++ /dev/null
@@ -1,781 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.Version;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonObject;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.sql.Connection;
-import java.sql.DatabaseMetaData;
-import java.sql.ResultSet;
-import java.sql.ResultSetMetaData;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-
-/** Simple interactive SQL query tool. */
-public class QueryShell {
-  public interface Factory {
-    QueryShell create(@Assisted InputStream in, @Assisted OutputStream out);
-  }
-
-  public enum OutputFormat {
-    PRETTY,
-    JSON,
-    JSON_SINGLE
-  }
-
-  private final BufferedReader in;
-  private final PrintWriter out;
-  private final SchemaFactory<ReviewDb> dbFactory;
-  private OutputFormat outputFormat = OutputFormat.PRETTY;
-
-  private ReviewDb db;
-  private Connection connection;
-  private Statement statement;
-
-  @Inject
-  QueryShell(
-      @ReviewDbFactory final SchemaFactory<ReviewDb> dbFactory,
-      @Assisted final InputStream in,
-      @Assisted final OutputStream out) {
-    this.dbFactory = dbFactory;
-    this.in = new BufferedReader(new InputStreamReader(in, UTF_8));
-    this.out = new PrintWriter(new OutputStreamWriter(out, UTF_8));
-  }
-
-  public void setOutputFormat(OutputFormat fmt) {
-    outputFormat = fmt;
-  }
-
-  public void run() {
-    try {
-      db = ReviewDbUtil.unwrapDb(dbFactory.open());
-      try {
-        connection = ((JdbcSchema) db).getConnection();
-        connection.setAutoCommit(true);
-
-        statement = connection.createStatement();
-        try {
-          showBanner();
-          readEvalPrintLoop();
-        } finally {
-          statement.close();
-          statement = null;
-        }
-      } finally {
-        db.close();
-        db = null;
-      }
-    } catch (OrmException | SQLException err) {
-      out.println("fatal: Cannot open connection: " + err.getMessage());
-    } finally {
-      out.flush();
-    }
-  }
-
-  public void execute(String query) {
-    try {
-      db = dbFactory.open();
-      try {
-        connection = ((JdbcSchema) db).getConnection();
-        connection.setAutoCommit(true);
-
-        statement = connection.createStatement();
-        try {
-          executeStatement(query);
-        } finally {
-          statement.close();
-          statement = null;
-        }
-      } finally {
-        db.close();
-        db = null;
-      }
-    } catch (OrmException | SQLException err) {
-      out.println("fatal: Cannot open connection: " + err.getMessage());
-    } finally {
-      out.flush();
-    }
-  }
-
-  private void readEvalPrintLoop() {
-    final StringBuilder buffer = new StringBuilder();
-    boolean executed = false;
-    for (; ; ) {
-      if (outputFormat == OutputFormat.PRETTY) {
-        print(buffer.length() == 0 || executed ? "gerrit> " : "     -> ");
-      }
-      String line = readLine();
-      if (line == null) {
-        return;
-      }
-
-      if (line.startsWith("\\")) {
-        // Shell command, check the various cases we recognize
-        //
-        line = line.substring(1);
-        if (line.equals("h") || line.equals("?")) {
-          showHelp();
-
-        } else if (line.equals("q")) {
-          if (outputFormat == OutputFormat.PRETTY) {
-            println("Bye");
-          }
-          return;
-
-        } else if (line.equals("r")) {
-          buffer.setLength(0);
-          executed = false;
-
-        } else if (line.equals("p")) {
-          println(buffer.toString());
-
-        } else if (line.equals("g")) {
-          if (buffer.length() > 0) {
-            executeStatement(buffer.toString());
-            executed = true;
-          }
-
-        } else if (line.equals("d")) {
-          listTables();
-
-        } else if (line.startsWith("d ")) {
-          showTable(line.substring(2).trim());
-
-        } else {
-          final String msg = "'\\" + line + "' not supported";
-          switch (outputFormat) {
-            case JSON_SINGLE:
-            case JSON:
-              {
-                final JsonObject err = new JsonObject();
-                err.addProperty("type", "error");
-                err.addProperty("message", msg);
-                println(err.toString());
-                break;
-              }
-            case PRETTY:
-            default:
-              println("ERROR: " + msg);
-              println("");
-              showHelp();
-              break;
-          }
-        }
-        continue;
-      }
-
-      if (executed) {
-        buffer.setLength(0);
-        executed = false;
-      }
-      if (buffer.length() > 0) {
-        buffer.append('\n');
-      }
-      buffer.append(line);
-
-      if (buffer.length() > 0 && buffer.charAt(buffer.length() - 1) == ';') {
-        executeStatement(buffer.toString());
-        executed = true;
-      }
-    }
-  }
-
-  private void listTables() {
-    final DatabaseMetaData meta;
-    try {
-      meta = connection.getMetaData();
-    } catch (SQLException e) {
-      error(e);
-      return;
-    }
-
-    final String[] types = {"TABLE", "VIEW"};
-    try (ResultSet rs = meta.getTables(null, null, null, types)) {
-      if (outputFormat == OutputFormat.PRETTY) {
-        println("                     List of relations");
-      }
-      showResultSet(
-          rs,
-          false,
-          0,
-          Identity.create(rs, "TABLE_SCHEM"),
-          Identity.create(rs, "TABLE_NAME"),
-          Identity.create(rs, "TABLE_TYPE"));
-    } catch (SQLException e) {
-      error(e);
-    }
-
-    println("");
-  }
-
-  private void showTable(String tableName) {
-    final DatabaseMetaData meta;
-    try {
-      meta = connection.getMetaData();
-
-      if (meta.storesUpperCaseIdentifiers()) {
-        tableName = tableName.toUpperCase();
-      } else if (meta.storesLowerCaseIdentifiers()) {
-        tableName = tableName.toLowerCase();
-      }
-    } catch (SQLException e) {
-      error(e);
-      return;
-    }
-
-    try (ResultSet rs = meta.getColumns(null, null, tableName, null)) {
-      if (!rs.next()) {
-        throw new SQLException("Table " + tableName + " not found");
-      }
-
-      if (outputFormat == OutputFormat.PRETTY) {
-        println("                     Table " + tableName);
-      }
-      showResultSet(
-          rs,
-          true,
-          0,
-          Identity.create(rs, "COLUMN_NAME"),
-          new Function("TYPE") {
-            @Override
-            String apply(ResultSet rs) throws SQLException {
-              String type = rs.getString("TYPE_NAME");
-              switch (rs.getInt("DATA_TYPE")) {
-                case java.sql.Types.CHAR:
-                case java.sql.Types.VARCHAR:
-                  type += "(" + rs.getInt("COLUMN_SIZE") + ")";
-                  break;
-              }
-
-              String def = rs.getString("COLUMN_DEF");
-              if (def != null && !def.isEmpty()) {
-                type += " DEFAULT " + def;
-              }
-
-              int nullable = rs.getInt("NULLABLE");
-              if (nullable == DatabaseMetaData.columnNoNulls) {
-                type += " NOT NULL";
-              }
-              return type;
-            }
-          });
-    } catch (SQLException e) {
-      error(e);
-      return;
-    }
-
-    try (ResultSet rs = meta.getIndexInfo(null, null, tableName, false, true)) {
-      Map<String, IndexInfo> indexes = new TreeMap<>();
-      while (rs.next()) {
-        final String indexName = rs.getString("INDEX_NAME");
-        IndexInfo def = indexes.get(indexName);
-        if (def == null) {
-          def = new IndexInfo();
-          def.name = indexName;
-          indexes.put(indexName, def);
-        }
-
-        if (!rs.getBoolean("NON_UNIQUE")) {
-          def.unique = true;
-        }
-
-        final int pos = rs.getInt("ORDINAL_POSITION");
-        final String col = rs.getString("COLUMN_NAME");
-        String desc = rs.getString("ASC_OR_DESC");
-        if ("D".equals(desc)) {
-          desc = " DESC";
-        } else {
-          desc = "";
-        }
-        def.addColumn(pos, col + desc);
-
-        String filter = rs.getString("FILTER_CONDITION");
-        if (filter != null && !filter.isEmpty()) {
-          def.filter.append(filter);
-        }
-      }
-
-      if (outputFormat == OutputFormat.PRETTY) {
-        println("");
-        println("Indexes on " + tableName + ":");
-        for (IndexInfo def : indexes.values()) {
-          println("  " + def);
-        }
-      }
-    } catch (SQLException e) {
-      error(e);
-      return;
-    }
-
-    println("");
-  }
-
-  private void executeStatement(String sql) {
-    final long start = TimeUtil.nowMs();
-    final boolean hasResultSet;
-    try {
-      hasResultSet = statement.execute(sql);
-    } catch (SQLException e) {
-      error(e);
-      return;
-    }
-
-    try {
-      if (hasResultSet) {
-        try (ResultSet rs = statement.getResultSet()) {
-          showResultSet(rs, false, start);
-        }
-
-      } else {
-        final int updateCount = statement.getUpdateCount();
-        final long ms = TimeUtil.nowMs() - start;
-        switch (outputFormat) {
-          case JSON_SINGLE:
-          case JSON:
-            {
-              final JsonObject tail = new JsonObject();
-              tail.addProperty("type", "update-stats");
-              tail.addProperty("rowCount", updateCount);
-              tail.addProperty("runTimeMilliseconds", ms);
-              println(tail.toString());
-              break;
-            }
-
-          case PRETTY:
-          default:
-            println("UPDATE " + updateCount + "; " + ms + " ms");
-            break;
-        }
-      }
-    } catch (SQLException e) {
-      error(e);
-    }
-  }
-
-  /**
-   * Outputs a result set to stdout.
-   *
-   * @param rs ResultSet to show.
-   * @param alreadyOnRow true if rs is already on the first row. false otherwise.
-   * @param start Timestamp in milliseconds when executing the statement started. This timestamp is
-   *     used to compute statistics about the statement. If no statistics should be shown, set it to
-   *     0.
-   * @param show Functions to map columns
-   * @throws SQLException
-   */
-  private void showResultSet(ResultSet rs, boolean alreadyOnRow, long start, Function... show)
-      throws SQLException {
-    switch (outputFormat) {
-      case JSON_SINGLE:
-      case JSON:
-        showResultSetJson(rs, alreadyOnRow, start, show);
-        break;
-      case PRETTY:
-      default:
-        showResultSetPretty(rs, alreadyOnRow, start, show);
-        break;
-    }
-  }
-
-  /**
-   * Outputs a result set to stdout in Json format.
-   *
-   * @param rs ResultSet to show.
-   * @param alreadyOnRow true if rs is already on the first row. false otherwise.
-   * @param start Timestamp in milliseconds when executing the statement started. This timestamp is
-   *     used to compute statistics about the statement. If no statistics should be shown, set it to
-   *     0.
-   * @param show Functions to map columns
-   * @throws SQLException
-   */
-  private void showResultSetJson(
-      final ResultSet rs, boolean alreadyOnRow, long start, Function... show) throws SQLException {
-    JsonArray collector = new JsonArray();
-    final ResultSetMetaData meta = rs.getMetaData();
-    final Function[] columnMap;
-    if (show != null && 0 < show.length) {
-      columnMap = show;
-
-    } else {
-      final int colCnt = meta.getColumnCount();
-      columnMap = new Function[colCnt];
-      for (int colId = 0; colId < colCnt; colId++) {
-        final int p = colId + 1;
-        final String name = meta.getColumnLabel(p);
-        columnMap[colId] = new Identity(p, name);
-      }
-    }
-
-    int rowCnt = 0;
-    while (alreadyOnRow || rs.next()) {
-      final JsonObject row = new JsonObject();
-      final JsonObject cols = new JsonObject();
-      for (Function function : columnMap) {
-        String v = function.apply(rs);
-        if (v == null) {
-          continue;
-        }
-        cols.addProperty(function.name.toLowerCase(), v);
-      }
-      row.addProperty("type", "row");
-      row.add("columns", cols);
-      switch (outputFormat) {
-        case JSON:
-          println(row.toString());
-          break;
-        case JSON_SINGLE:
-          collector.add(row);
-          break;
-        case PRETTY:
-        default:
-          final JsonObject obj = new JsonObject();
-          obj.addProperty("type", "error");
-          obj.addProperty("message", "Unsupported Json variant");
-          println(obj.toString());
-          return;
-      }
-      alreadyOnRow = false;
-      rowCnt++;
-    }
-
-    JsonObject tail = null;
-    if (start != 0) {
-      tail = new JsonObject();
-      tail.addProperty("type", "query-stats");
-      tail.addProperty("rowCount", rowCnt);
-      final long ms = TimeUtil.nowMs() - start;
-      tail.addProperty("runTimeMilliseconds", ms);
-    }
-
-    switch (outputFormat) {
-      case JSON:
-        if (tail != null) {
-          println(tail.toString());
-        }
-        break;
-      case JSON_SINGLE:
-        if (tail != null) {
-          collector.add(tail);
-        }
-        println(collector.toString());
-        break;
-      case PRETTY:
-      default:
-        final JsonObject obj = new JsonObject();
-        obj.addProperty("type", "error");
-        obj.addProperty("message", "Unsupported Json variant");
-        println(obj.toString());
-    }
-  }
-
-  /**
-   * Outputs a result set to stdout in plain text format.
-   *
-   * @param rs ResultSet to show.
-   * @param alreadyOnRow true if rs is already on the first row. false otherwise.
-   * @param start Timestamp in milliseconds when executing the statement started. This timestamp is
-   *     used to compute statistics about the statement. If no statistics should be shown, set it to
-   *     0.
-   * @param show Functions to map columns
-   * @throws SQLException
-   */
-  private void showResultSetPretty(
-      final ResultSet rs, boolean alreadyOnRow, long start, Function... show) throws SQLException {
-    final ResultSetMetaData meta = rs.getMetaData();
-
-    final Function[] columnMap;
-    if (show != null && 0 < show.length) {
-      columnMap = show;
-
-    } else {
-      final int colCnt = meta.getColumnCount();
-      columnMap = new Function[colCnt];
-      for (int colId = 0; colId < colCnt; colId++) {
-        final int p = colId + 1;
-        final String name = meta.getColumnLabel(p);
-        columnMap[colId] = new Identity(p, name);
-      }
-    }
-
-    final int colCnt = columnMap.length;
-    final int[] widths = new int[colCnt];
-    for (int c = 0; c < colCnt; c++) {
-      widths[c] = columnMap[c].name.length();
-    }
-
-    final List<String[]> rows = new ArrayList<>();
-    while (alreadyOnRow || rs.next()) {
-      final String[] row = new String[columnMap.length];
-      for (int c = 0; c < colCnt; c++) {
-        row[c] = columnMap[c].apply(rs);
-        if (row[c] == null) {
-          row[c] = "NULL";
-        }
-        widths[c] = Math.max(widths[c], row[c].length());
-      }
-      rows.add(row);
-      alreadyOnRow = false;
-    }
-
-    final StringBuilder b = new StringBuilder();
-    for (int c = 0; c < colCnt; c++) {
-      if (0 < c) {
-        b.append(" | ");
-      }
-
-      String n = columnMap[c].name;
-      if (widths[c] < n.length()) {
-        n = n.substring(0, widths[c]);
-      }
-      b.append(n);
-
-      if (c < colCnt - 1) {
-        for (int pad = n.length(); pad < widths[c]; pad++) {
-          b.append(' ');
-        }
-      }
-    }
-    println(" " + b.toString());
-
-    b.setLength(0);
-    for (int c = 0; c < colCnt; c++) {
-      if (0 < c) {
-        b.append("-+-");
-      }
-      for (int pad = 0; pad < widths[c]; pad++) {
-        b.append('-');
-      }
-    }
-    println(" " + b.toString());
-
-    boolean dataTruncated = false;
-    for (String[] row : rows) {
-      b.setLength(0);
-      b.append(' ');
-
-      for (int c = 0; c < colCnt; c++) {
-        final int max = widths[c];
-        if (0 < c) {
-          b.append(" | ");
-        }
-
-        String s = row[c];
-        if (1 < colCnt && max < s.length()) {
-          s = s.substring(0, max);
-          dataTruncated = true;
-        }
-        b.append(s);
-
-        if (c < colCnt - 1) {
-          for (int pad = s.length(); pad < max; pad++) {
-            b.append(' ');
-          }
-        }
-      }
-      println(b.toString());
-    }
-
-    if (dataTruncated) {
-      warning("some column data was truncated");
-    }
-
-    if (start != 0) {
-      final int rowCount = rows.size();
-      final long ms = TimeUtil.nowMs() - start;
-      println("(" + rowCount + (rowCount == 1 ? " row" : " rows") + "; " + ms + " ms)");
-    }
-  }
-
-  private void warning(String msg) {
-    switch (outputFormat) {
-      case JSON_SINGLE:
-      case JSON:
-        {
-          final JsonObject obj = new JsonObject();
-          obj.addProperty("type", "warning");
-          obj.addProperty("message", msg);
-          println(obj.toString());
-          break;
-        }
-
-      case PRETTY:
-      default:
-        println("WARNING: " + msg);
-        break;
-    }
-  }
-
-  private void error(SQLException err) {
-    switch (outputFormat) {
-      case JSON_SINGLE:
-      case JSON:
-        {
-          final JsonObject obj = new JsonObject();
-          obj.addProperty("type", "error");
-          obj.addProperty("message", err.getMessage());
-          println(obj.toString());
-          break;
-        }
-
-      case PRETTY:
-      default:
-        println("ERROR: " + err.getMessage());
-        break;
-    }
-  }
-
-  private void print(String s) {
-    out.print(s);
-    out.flush();
-  }
-
-  private void println(String s) {
-    out.print(s);
-    out.print('\n');
-    out.flush();
-  }
-
-  private String readLine() {
-    try {
-      return in.readLine();
-    } catch (IOException e) {
-      return null;
-    }
-  }
-
-  private void showBanner() {
-    if (outputFormat == OutputFormat.PRETTY) {
-      println("Welcome to Gerrit Code Review " + Version.getVersion());
-      try {
-        print("(");
-        print(connection.getMetaData().getDatabaseProductName());
-        print(" ");
-        print(connection.getMetaData().getDatabaseProductVersion());
-        println(")");
-      } catch (SQLException err) {
-        error(err);
-      }
-      println("");
-      println("Type '\\h' for help.  Type '\\r' to clear the buffer.");
-      println("");
-    }
-  }
-
-  private void showHelp() {
-    final StringBuilder help = new StringBuilder();
-    help.append("General\n");
-    help.append("  \\q        quit\n");
-
-    help.append("\n");
-    help.append("Query Buffer\n");
-    help.append("  \\g        execute the query buffer\n");
-    help.append("  \\p        display the current buffer\n");
-    help.append("  \\r        clear the query buffer\n");
-
-    help.append("\n");
-    help.append("Informational\n");
-    help.append("  \\d        list all tables\n");
-    help.append("  \\d NAME   describe table\n");
-
-    help.append("\n");
-    print(help.toString());
-  }
-
-  private abstract static class Function {
-    final String name;
-
-    Function(String name) {
-      this.name = name;
-    }
-
-    abstract String apply(ResultSet rs) throws SQLException;
-  }
-
-  private static class Identity extends Function {
-    static Identity create(ResultSet rs, String name) throws SQLException {
-      return new Identity(rs.findColumn(name), name);
-    }
-
-    final int colId;
-
-    Identity(int colId, String name) {
-      super(name);
-      this.colId = colId;
-    }
-
-    @Override
-    String apply(ResultSet rs) throws SQLException {
-      return rs.getString(colId);
-    }
-  }
-
-  private static class IndexInfo {
-    String name;
-    boolean unique;
-    final Map<Integer, String> columns = new TreeMap<>();
-    final StringBuilder filter = new StringBuilder();
-
-    void addColumn(int pos, String column) {
-      columns.put(Integer.valueOf(pos), column);
-    }
-
-    @Override
-    public String toString() {
-      final StringBuilder r = new StringBuilder();
-      r.append(name);
-      if (unique) {
-        r.append(" UNIQUE");
-      }
-      r.append(" (");
-      boolean first = true;
-      for (String c : columns.values()) {
-        if (!first) {
-          r.append(", ");
-        }
-        r.append(c);
-        first = false;
-      }
-      r.append(")");
-      if (filter.length() > 0) {
-        r.append(" WHERE ");
-        r.append(filter);
-      }
-      return r.toString();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
index 6c8dcd6..d9fd5d3 100644
--- a/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -19,6 +19,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
@@ -38,7 +39,6 @@
 import org.eclipse.jgit.errors.TooLargeObjectInPackException;
 import org.eclipse.jgit.errors.UnpackException;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
 import org.eclipse.jgit.transport.ReceivePack;
 import org.kohsuke.args4j.Option;
@@ -89,7 +89,7 @@
       throw new Failure(1, "fatal: unable to check permissions " + e);
     }
 
-    AsyncReceiveCommits arc = factory.create(projectState, currentUser, repo, null, reviewers);
+    AsyncReceiveCommits arc = factory.create(projectState, currentUser, repo, null);
 
     try {
       Capable r = arc.canUpload();
@@ -142,15 +142,15 @@
         msg.append("  Visible references (").append(adv.size()).append("):\n");
         for (Ref ref : adv.values()) {
           msg.append("  - ")
-              .append(ref.getObjectId().abbreviate(8).name())
+              .append(abbreviateName(ref, rp))
               .append(" ")
               .append(ref.getName())
               .append("\n");
         }
 
-        Map<String, Ref> allRefs = rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
+        List<Ref> allRefs = rp.getRepository().getRefDatabase().getRefs();
         List<Ref> hidden = new ArrayList<>();
-        for (Ref ref : allRefs.values()) {
+        for (Ref ref : allRefs) {
           if (!adv.containsKey(ref.getName())) {
             hidden.add(ref);
           }
@@ -159,7 +159,7 @@
         msg.append("  Hidden references (").append(hidden.size()).append("):\n");
         for (Ref ref : hidden) {
           msg.append("  - ")
-              .append(ref.getObjectId().abbreviate(8).name())
+              .append(abbreviateName(ref, rp))
               .append(" ")
               .append(ref.getName())
               .append("\n");
@@ -170,4 +170,8 @@
       throw new Failure(128, "fatal: Unpack error, check server log", detail);
     }
   }
+
+  private String abbreviateName(Ref ref, ReceivePack rp) throws IOException {
+    return ObjectIds.abbreviateName(ref.getObjectId(), rp.getRevWalk().getObjectReader());
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ReloadConfig.java b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
index 1b21230..cbe3c57 100644
--- a/java/com/google/gerrit/sshd/commands/ReloadConfig.java
+++ b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
@@ -16,16 +16,15 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
+import com.google.common.collect.Multimap;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.config.ConfigUpdatedEvent;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
 import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
 import com.google.gerrit.server.config.GerritServerConfigReloader;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import java.util.List;
-import java.util.stream.Collectors;
 
 /** Issues a reload of gerrit.config. */
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
@@ -39,31 +38,16 @@
 
   @Override
   protected void run() throws Failure {
-    List<ConfigUpdatedEvent.Update> updates = gerritServerConfigReloader.reloadConfig();
+    Multimap<UpdateResult, ConfigUpdateEntry> updates = gerritServerConfigReloader.reloadConfig();
     if (updates.isEmpty()) {
       stdout.println("No config entries updated!");
       return;
     }
 
     // Print out UpdateResult.{ACCEPTED|REJECTED} entries grouped by their type
-    for (UpdateResult updateResult : UpdateResult.values()) {
-      List<ConfigUpdatedEvent.Update> filteredUpdates = filterUpdates(updates, updateResult);
-      if (filteredUpdates.isEmpty()) {
-        continue;
-      }
-      stdout.println(updateResult.toString() + " configuration changes:");
-      filteredUpdates
-          .stream()
-          .flatMap(update -> update.getConfigUpdates().stream())
-          .forEach(cfgEntry -> stdout.println(cfgEntry.toString()));
+    for (UpdateResult result : updates.keySet()) {
+      stdout.println(result.toString() + " configuration changes:");
+      updates.get(result).forEach(cfgEntry -> stdout.println(cfgEntry.toString()));
     }
   }
-
-  public static List<ConfigUpdatedEvent.Update> filterUpdates(
-      List<ConfigUpdatedEvent.Update> updates, UpdateResult result) {
-    return updates
-        .stream()
-        .filter(update -> update.getResult() == result)
-        .collect(Collectors.toList());
-  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index d5b44b5..2c54e4a 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -14,13 +14,16 @@
 
 package com.google.gerrit.sshd.commands;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.CharStreams;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -30,8 +33,8 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -40,20 +43,26 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.util.cli.CmdLineParser;
+import com.google.gerrit.util.cli.OptionUtil;
 import com.google.gson.JsonSyntaxException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.InputStreamReader;
-import java.util.ArrayList;
+import java.lang.reflect.AnnotatedElement;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.List;
+import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
 import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.FieldSetter;
+import org.kohsuke.args4j.spi.OneArgumentOptionHandler;
+import org.kohsuke.args4j.spi.Setter;
 
 @CommandMetaData(name = "review", description = "Apply reviews to one or more patch sets")
 public class ReviewCommand extends SshCommand {
@@ -61,10 +70,8 @@
 
   @Override
   protected final CmdLineParser newCmdLineParser(Object options) {
-    final CmdLineParser parser = super.newCmdLineParser(options);
-    for (ApproveOption c : optionList) {
-      parser.addOption(c, c);
-    }
+    CmdLineParser parser = super.newCmdLineParser(options);
+    optionMap.forEach((o, s) -> parser.addOption(s, o));
     return parser;
   }
 
@@ -82,7 +89,7 @@
       patchSets.add(ps);
     } catch (UnloggedFailure e) {
       throw new IllegalArgumentException(e.getMessage(), e);
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       throw new IllegalArgumentException("database error", e);
     }
   }
@@ -154,7 +161,7 @@
 
   @Inject private PatchSetParser psParser;
 
-  private List<ApproveOption> optionList;
+  private Map<Option, LabelSetter> optionMap;
   private Map<String, Short> customLabels;
 
   @Override
@@ -220,11 +227,11 @@
         writeError("error", e.getMessage() + "\n");
       } catch (NoSuchChangeException e) {
         ok = false;
-        writeError("error", "no such change " + patchSet.getId().getParentKey().get());
+        writeError("error", "no such change " + patchSet.id().changeId().get());
       } catch (Exception e) {
         ok = false;
-        writeError("fatal", "internal server error while reviewing " + patchSet.getId() + "\n");
-        logger.atSevere().withCause(e).log("internal error while reviewing %s", patchSet.getId());
+        writeError("fatal", "internal server error while reviewing " + patchSet.id() + "\n");
+        logger.atSevere().withCause(e).log("internal error while reviewing %s", patchSet.id());
       }
     }
 
@@ -235,8 +242,8 @@
 
   private void applyReview(PatchSet patchSet, ReviewInput review) throws RestApiException {
     gApi.changes()
-        .id(patchSet.getId().getParentKey().get())
-        .revision(patchSet.getRevision().get())
+        .id(patchSet.id().changeId().get())
+        .revision(patchSet.commitId().name())
         .review(review);
   }
 
@@ -250,9 +257,6 @@
   }
 
   private void reviewPatchSet(PatchSet patchSet) throws Exception {
-    if (notify == null) {
-      notify = NotifyHandling.ALL;
-    }
 
     ReviewInput review = new ReviewInput();
     review.message = Strings.emptyToNull(changeComment);
@@ -260,11 +264,8 @@
     review.notify = notify;
     review.labels = new TreeMap<>();
     review.drafts = ReviewInput.DraftHandling.PUBLISH;
-    for (ApproveOption ao : optionList) {
-      Short v = ao.value();
-      if (v != null) {
-        review.labels.put(ao.getLabelName(), v);
-      }
+    for (LabelSetter setter : optionMap.values()) {
+      setter.getValue().ifPresent(v -> review.labels.put(setter.getLabelName(), v));
     }
     review.labels.putAll(customLabels);
 
@@ -309,16 +310,16 @@
   }
 
   private ChangeApi changeApi(PatchSet patchSet) throws RestApiException {
-    return gApi.changes().id(patchSet.getId().getParentKey().get());
+    return gApi.changes().id(patchSet.id().changeId().get());
   }
 
   private RevisionApi revisionApi(PatchSet patchSet) throws RestApiException {
-    return changeApi(patchSet).revision(patchSet.getRevision().get());
+    return changeApi(patchSet).revision(patchSet.commitId().name());
   }
 
   @Override
   protected void parseCommandLine() throws UnloggedFailure {
-    optionList = new ArrayList<>();
+    optionMap = new LinkedHashMap<>();
     customLabels = new HashMap<>();
 
     ProjectState allProjectsState;
@@ -335,10 +336,111 @@
         usage.append(v.format()).append("\n");
       }
 
-      final String name = "--" + type.getName().toLowerCase();
-      optionList.add(new ApproveOption(name, usage.toString(), type));
+      optionMap.put(newApproveOption(type, usage.toString()), new LabelSetter(type));
     }
 
     super.parseCommandLine();
   }
+
+  private static String asOptionName(LabelType type) {
+    return "--" + type.getName().toLowerCase();
+  }
+
+  private static Option newApproveOption(LabelType type, String usage) {
+    return OptionUtil.newOption(
+        asOptionName(type),
+        new String[0],
+        usage,
+        "N",
+        false,
+        false,
+        false,
+        LabelHandler.class,
+        new String[0],
+        new String[0]);
+  }
+
+  private static class LabelSetter implements Setter<Short> {
+    private final LabelType type;
+    private Optional<Short> value;
+
+    LabelSetter(LabelType type) {
+      this.type = requireNonNull(type);
+      this.value = Optional.empty();
+    }
+
+    Optional<Short> getValue() {
+      return value;
+    }
+
+    LabelType getLabelType() {
+      return type;
+    }
+
+    String getLabelName() {
+      return type.getName();
+    }
+
+    @Override
+    public void addValue(Short value) {
+      this.value = Optional.of(value);
+    }
+
+    @Override
+    public Class<Short> getType() {
+      return Short.class;
+    }
+
+    @Override
+    public boolean isMultiValued() {
+      return false;
+    }
+
+    @Override
+    public FieldSetter asFieldSetter() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public AnnotatedElement asAnnotatedElement() {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  public static class LabelHandler extends OneArgumentOptionHandler<Short> {
+    private final LabelType type;
+
+    public LabelHandler(
+        org.kohsuke.args4j.CmdLineParser parser, OptionDef option, Setter<Short> setter) {
+      super(parser, option, setter);
+      this.type = ((LabelSetter) setter).getLabelType();
+    }
+
+    @Override
+    protected Short parse(String token) throws NumberFormatException, CmdLineException {
+      String argument = token;
+      if (argument.startsWith("+")) {
+        argument = argument.substring(1);
+      }
+
+      short value = Short.parseShort(argument);
+      LabelValue min = type.getMin();
+      LabelValue max = type.getMax();
+
+      if (value < min.getValue() || value > max.getValue()) {
+        String e =
+            "\""
+                + token
+                + "\" must be in range "
+                + min.formatValue()
+                + ".."
+                + max.formatValue()
+                + " for \""
+                + asOptionName(type)
+                + "\"";
+        throw new CmdLineException(owner, localizable(e));
+      }
+      return value;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ScpCommand.java b/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 89a09ef..5122b35 100644
--- a/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -25,8 +25,8 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.tools.ToolsCatalog;
-import com.google.gerrit.server.tools.ToolsCatalog.Entry;
 import com.google.gerrit.sshd.BaseCommand;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
@@ -82,7 +82,7 @@
 
   @Override
   public void start(Environment env) {
-    startThread(this::runImp);
+    startThread(this::runImp, AccessPath.SSH_COMMAND);
   }
 
   private void runImp() {
@@ -103,14 +103,14 @@
           root = "";
         }
 
-        final Entry ent = toc.get(root);
+        final ToolsCatalog.Entry ent = toc.get(root);
         if (ent == null) {
           throw new IOException(root + " not found");
 
-        } else if (Entry.Type.FILE == ent.getType()) {
+        } else if (ToolsCatalog.Entry.Type.FILE == ent.getType()) {
           readFile(ent);
 
-        } else if (Entry.Type.DIR == ent.getType()) {
+        } else if (ToolsCatalog.Entry.Type.DIR == ent.getType()) {
           if (!opt_r) {
             throw new IOException(root + " not a regular file");
           }
@@ -155,7 +155,7 @@
     }
   }
 
-  private void readFile(Entry ent) throws IOException {
+  private void readFile(ToolsCatalog.Entry ent) throws IOException {
     byte[] data = ent.getBytes();
     if (data == null) {
       throw new FileNotFoundException(ent.getPath());
@@ -169,12 +169,12 @@
     readAck();
   }
 
-  private void readDir(Entry dir) throws IOException {
+  private void readDir(ToolsCatalog.Entry dir) throws IOException {
     header(dir, 0);
     readAck();
 
-    for (Entry e : dir.getChildren()) {
-      if (Entry.Type.DIR == e.getType()) {
+    for (ToolsCatalog.Entry e : dir.getChildren()) {
+      if (ToolsCatalog.Entry.Type.DIR == e.getType()) {
         readDir(e);
       } else {
         readFile(e);
@@ -186,7 +186,8 @@
     readAck();
   }
 
-  private void header(Entry dir, int len) throws IOException, UnsupportedEncodingException {
+  private void header(ToolsCatalog.Entry dir, int len)
+      throws IOException, UnsupportedEncodingException {
     final StringBuilder buf = new StringBuilder();
     switch (dir.getType()) {
       case DIR:
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 379fc68..7509ac9 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -18,9 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.SshKeyInput;
 import com.google.gerrit.extensions.common.EmailInfo;
@@ -29,12 +27,17 @@
 import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.account.AddSshKey;
 import com.google.gerrit.server.restapi.account.CreateEmail;
@@ -49,8 +52,8 @@
 import com.google.gerrit.server.restapi.account.PutPreferred;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStreamReader;
@@ -65,7 +68,6 @@
 
 /** Set a user's account settings. * */
 @CommandMetaData(name = "set-account", description = "Change an account's settings")
-@RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 final class SetAccountCommand extends SshCommand {
 
   @Argument(
@@ -117,9 +119,12 @@
   @Option(name = "--clear-http-password", usage = "clear HTTP password for the account")
   private boolean clearHttpPassword;
 
+  @Option(name = "--generate-http-password", usage = "generate a new HTTP password for the account")
+  private boolean generateHttpPassword;
+
   @Inject private IdentifiedUser.GenericFactory genericUserFactory;
 
-  @Inject private CreateEmail.Factory createEmailFactory;
+  @Inject private CreateEmail createEmail;
 
   @Inject private GetEmails getEmails;
 
@@ -141,20 +146,54 @@
 
   @Inject private DeleteSshKey deleteSshKey;
 
+  @Inject private PermissionBackend permissionBackend;
+
+  @Inject private Provider<CurrentUser> userProvider;
+
   private AccountResource rsrc;
 
   @Override
   public void run() throws Exception {
+    user = genericUserFactory.create(id);
+
     validate();
     setAccount();
   }
 
   private void validate() throws UnloggedFailure {
-    if (active && inactive) {
-      throw die("--active and --inactive options are mutually exclusive.");
+    PermissionBackend.WithUser userPermission = permissionBackend.user(userProvider.get());
+
+    boolean isAdmin = userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+    boolean canModifyAccount =
+        isAdmin || userPermission.testOrFalse(GlobalPermission.MODIFY_ACCOUNT);
+
+    if (!user.hasSameAccountId(userProvider.get()) && !canModifyAccount) {
+      throw die(
+          "Setting another user's account information requries 'modify account' or 'administrate server' capabilities.");
     }
-    if (clearHttpPassword && !Strings.isNullOrEmpty(httpPassword)) {
-      throw die("--http-password and --clear-http-password options are mutually exclusive.");
+    if (active || inactive) {
+      if (!canModifyAccount) {
+        throw die(
+            "--active and --inactive require 'modify account' or 'administrate server' capabilities.");
+      }
+      if (active && inactive) {
+        throw die("--active and --inactive options are mutually exclusive.");
+      }
+    }
+
+    if (generateHttpPassword && clearHttpPassword) {
+      throw die("--generate-http-password and --clear-http-password are mutually exclusive.");
+    }
+    if (!Strings.isNullOrEmpty(httpPassword)) { // gave --http-password
+      if (!isAdmin) {
+        throw die("--http-password requires 'administrate server' capabilities.");
+      }
+      if (generateHttpPassword) {
+        throw die("--http-password and --generate-http-password options are mutually exclusive.");
+      }
+      if (clearHttpPassword) {
+        throw die("--http-password and --clear-http-password options are mutually exclusive.");
+      }
     }
     if (addSshKeys.contains("-") && deleteSshKeys.contains("-")) {
       throw die("Only one option may use the stdin");
@@ -173,8 +212,7 @@
   }
 
   private void setAccount()
-      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException,
-          PermissionBackendException {
+      throws IOException, UnloggedFailure, ConfigInvalidException, PermissionBackendException {
     user = genericUserFactory.create(id);
     rsrc = new AccountResource(user.asIdentifiedUser());
     try {
@@ -196,10 +234,16 @@
         putName.apply(rsrc, in);
       }
 
-      if (httpPassword != null || clearHttpPassword) {
+      if (httpPassword != null || clearHttpPassword || generateHttpPassword) {
         HttpPasswordInput in = new HttpPasswordInput();
         in.httpPassword = httpPassword;
-        putHttpPassword.apply(rsrc, in);
+        if (generateHttpPassword) {
+          in.generate = true;
+        }
+        Response<String> resp = putHttpPassword.apply(rsrc, in);
+        if (generateHttpPassword) {
+          stdout.print("New password: " + resp.value() + "\n");
+        }
       }
 
       if (active) {
@@ -227,8 +271,7 @@
   }
 
   private void addSshKeys(List<String> sshKeys)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     for (String sshKey : sshKeys) {
       SshKeyInput in = new SshKeyInput();
       in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "plain/text");
@@ -237,9 +280,9 @@
   }
 
   private void deleteSshKeys(List<String> sshKeys)
-      throws RestApiException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException, PermissionBackendException {
-    List<SshKeyInfo> infos = getSshKeys.apply(rsrc);
+      throws RestApiException, RepositoryNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    List<SshKeyInfo> infos = getSshKeys.apply(rsrc).value();
     if (sshKeys.contains("ALL")) {
       for (SshKeyInfo i : infos) {
         deleteSshKey(i);
@@ -256,30 +299,29 @@
   }
 
   private void deleteSshKey(SshKeyInfo i)
-      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException, PermissionBackendException {
+      throws AuthException, RepositoryNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     AccountSshKey sshKey = AccountSshKey.create(user.getAccountId(), i.seq, i.sshPublicKey);
     deleteSshKey.apply(new AccountResource.SshKey(user.asIdentifiedUser(), sshKey), null);
   }
 
   private void addEmail(String email)
-      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException,
+      throws UnloggedFailure, RestApiException, IOException, ConfigInvalidException,
           PermissionBackendException {
     EmailInput in = new EmailInput();
     in.email = email;
     in.noConfirmation = true;
     try {
-      createEmailFactory.create(email).apply(rsrc, in);
+      createEmail.apply(rsrc, IdString.fromDecoded(email), in);
     } catch (EmailException e) {
       throw die(e.getMessage());
     }
   }
 
   private void deleteEmail(String email)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (email.equals("ALL")) {
-      List<EmailInfo> emails = getEmails.apply(rsrc);
+      List<EmailInfo> emails = getEmails.apply(rsrc).value();
       for (EmailInfo e : emails) {
         deleteEmail.apply(new AccountResource.Email(user.asIdentifiedUser(), e.email), new Input());
       }
@@ -289,9 +331,8 @@
   }
 
   private void putPreferred(String email)
-      throws RestApiException, OrmException, IOException, PermissionBackendException,
-          ConfigInvalidException {
-    for (EmailInfo e : getEmails.apply(rsrc)) {
+      throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException {
+    for (EmailInfo e : getEmails.apply(rsrc).value()) {
       if (e.email.equals(email)) {
         putPreferred.apply(new AccountResource.Email(user.asIdentifiedUser(), email), null);
         return;
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 9d7f2d9..578f6fe 100644
--- a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -132,8 +132,7 @@
       String action, GroupResource group, List<Account.Id> accountIdList)
       throws UnsupportedEncodingException, IOException {
     String names =
-        accountIdList
-            .stream()
+        accountIdList.stream()
             .map(
                 accountId -> {
                   Optional<AccountState> accountState = accountCache.get(accountId);
@@ -141,7 +140,7 @@
                     return "n/a";
                   }
                   return MoreObjects.firstNonNull(
-                      accountState.get().getAccount().getPreferredEmail(), "n/a");
+                      accountState.get().getAccount().preferredEmail(), "n/a");
                 })
             .collect(joining(", "));
     out.write(
@@ -152,8 +151,7 @@
       String action, GroupResource group, List<AccountGroup.UUID> groupUuidList)
       throws UnsupportedEncodingException, IOException {
     String names =
-        groupUuidList
-            .stream()
+        groupUuidList.stream()
             .map(uuid -> groupCache.get(uuid).map(InternalGroup::getName))
             .flatMap(Streams::stream)
             .collect(joining(", "));
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
new file mode 100644
index 0000000..449d419
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -0,0 +1,189 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.sshd.commands;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.exceptions.StorageException;
+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.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+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.List;
+import java.util.Set;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(
+    name = "set-project-parent",
+    description = "Change the project permissions are inherited from")
+final class SetParentCommand extends SshCommand {
+  @Option(
+      name = "--parent",
+      aliases = {"-p"},
+      metaVar = "NAME",
+      usage = "new parent project")
+  private ProjectState newParent;
+
+  @Option(
+      name = "--children-of",
+      metaVar = "NAME",
+      usage = "parent project for which the child projects should be reparented")
+  private ProjectState oldParent;
+
+  @Option(
+      name = "--exclude",
+      metaVar = "NAME",
+      usage = "child project of old parent project which should not be reparented")
+  private List<ProjectState> excludedChildren = new ArrayList<>();
+
+  @Argument(
+      index = 0,
+      required = false,
+      multiValued = true,
+      metaVar = "NAME",
+      usage = "projects to modify")
+  private List<ProjectState> children = new ArrayList<>();
+
+  @Inject private ProjectCache projectCache;
+
+  @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()) {
+      throw die(
+          "child projects have to be specified as "
+              + "arguments or the --children-of option has to be set");
+    }
+    if (oldParent == null && !excludedChildren.isEmpty()) {
+      throw die("--exclude can only be used together with --children-of");
+    }
+
+    final StringBuilder err = new StringBuilder();
+
+    if (newParent != null) {
+      newParentKey = newParent.getProject().getNameKey();
+    }
+
+    final List<Project.NameKey> childProjects =
+        children.stream().map(ProjectState::getNameKey).collect(toList());
+    if (oldParent != null) {
+      try {
+        childProjects.addAll(getChildrenForReparenting(oldParent));
+      } catch (PermissionBackendException e) {
+        throw new Failure(1, "permissions unavailable", e);
+      } catch (StorageException | RestApiException e) {
+        throw new Failure(1, "failure in request", e);
+      }
+    }
+
+    for (Project.NameKey nameKey : childProjects) {
+      final String name = nameKey.get();
+      ProjectState project = projectCache.get(nameKey);
+      try {
+        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);
+      }
+    }
+
+    if (err.length() > 0) {
+      while (err.charAt(err.length() - 1) == '\n') {
+        err.setLength(err.length() - 1);
+      }
+      throw die(err.toString());
+    }
+  }
+
+  /**
+   * Returns the children of the specified parent project that should be reparented. The returned
+   * list of child projects does not contain projects that were specified to be excluded from
+   * reparenting.
+   */
+  private List<Project.NameKey> getChildrenForReparenting(ProjectState parent)
+      throws PermissionBackendException, RestApiException {
+    final List<Project.NameKey> childProjects = new ArrayList<>();
+    final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
+    for (ProjectState excludedChild : excludedChildren) {
+      excluded.add(excludedChild.getProject().getNameKey());
+    }
+    final List<Project.NameKey> automaticallyExcluded = new ArrayList<>(excludedChildren.size());
+    if (newParentKey != null) {
+      automaticallyExcluded.addAll(getAllParents(newParentKey));
+    }
+    for (ProjectInfo child : listChildProjects.apply(new ProjectResource(parent, user)).value()) {
+      final Project.NameKey childName = Project.nameKey(child.name);
+      if (!excluded.contains(childName)) {
+        if (!automaticallyExcluded.contains(childName)) {
+          childProjects.add(childName);
+        } else {
+          stdout.println(
+              "Automatically excluded '"
+                  + childName
+                  + "' "
+                  + "from reparenting because it is in the parent "
+                  + "line of the new parent '"
+                  + newParentKey
+                  + "'.");
+        }
+      }
+    }
+    return childProjects;
+  }
+
+  private Set<Project.NameKey> getAllParents(Project.NameKey projectName) {
+    ProjectState ps = projectCache.get(projectName);
+    if (ps == null) {
+      return Collections.emptySet();
+    }
+    return ps.parents().transform(ProjectState::getNameKey).toSet();
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 1e177a1..8c9fc9f 100644
--- a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -123,7 +123,7 @@
       name = "--project-state",
       aliases = {"--ps"},
       usage = "project's visibility state")
-  private ProjectState state;
+  private com.google.gerrit.extensions.client.ProjectState state;
 
   @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
   private String maxObjectSizeLimit;
@@ -138,7 +138,7 @@
     configInput.useContentMerge = contentMerge;
     configInput.useContributorAgreements = contributorAgreements;
     configInput.useSignedOffBy = signedOffBy;
-    configInput.state = state.getProject().getState();
+    configInput.state = state;
     configInput.maxObjectSizeLimit = maxObjectSizeLimit;
     // Description is different to other parameters, null won't result in
     // keeping the existing description, it would delete it.
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index a4a8ea8..b418377 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -29,7 +30,6 @@
 import com.google.gerrit.sshd.ChangeArgumentParser;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -75,7 +75,7 @@
       changeArgumentParser.addChange(token, changes, projectState);
     } catch (IOException | UnloggedFailure e) {
       throw new IllegalArgumentException(e.getMessage(), e);
-    } catch (OrmException e) {
+    } catch (StorageException e) {
       throw new IllegalArgumentException("database is down", e);
     } catch (PermissionBackendException e) {
       throw new IllegalArgumentException("can't check permissions", e);
@@ -141,7 +141,7 @@
       input.confirmed = true;
       String error;
       try {
-        error = postReviewers.apply(changeRsrc, input).error;
+        error = postReviewers.apply(changeRsrc, input).value().error;
       } catch (Exception e) {
         error = String.format("could not add %s: %s", reviewer, e.getMessage());
       }
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 3c95884..c19e790 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.events.LifecycleListener;
@@ -38,6 +37,7 @@
 import com.google.gerrit.server.restapi.config.ListCaches;
 import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
@@ -47,7 +47,6 @@
 import java.util.Collection;
 import java.util.Date;
 import java.util.Map;
-import java.util.Map.Entry;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
 import org.apache.sshd.common.io.mina.MinaSession;
@@ -179,7 +178,8 @@
     if (showJvm) {
       sshSummary();
 
-      SummaryInfo summary = getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
+      SummaryInfo summary =
+          getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource()).value();
       taskSummary(summary.taskSummary);
       memSummary(summary.memSummary);
       threadSummary(summary.threadSummary);
@@ -267,7 +267,7 @@
         stdout.print(String.format(" %14s", s.name()));
       }
       stdout.print('\n');
-      for (Entry<String, Map<Thread.State, Integer>> e : threadSummary.counts.entrySet()) {
+      for (Map.Entry<String, Map<Thread.State, Integer>> e : threadSummary.counts.entrySet()) {
         stdout.print(String.format("  %-22s", e.getKey()));
         for (Thread.State s : Thread.State.values()) {
           stdout.print(String.format(" %14d", nullToZero(e.getValue().get(s))));
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index b30799b..231bcf6 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -15,14 +15,16 @@
 package com.google.gerrit.sshd.commands;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.gerrit.common.TimeUtil;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.util.IdGenerator;
+import com.google.gerrit.server.ioutil.HexFormat;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
@@ -33,11 +35,7 @@
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.Date;
-import java.util.List;
 import java.util.Optional;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -92,25 +90,24 @@
       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;
 
@@ -151,7 +148,7 @@
     }
 
     stdout.print("--\n");
-    stdout.print("SSHD Backend: " + getBackend() + "\n");
+    stdout.print(String.format(" %d connections; SSHD Backend: %s\n", list.size(), getBackend()));
   }
 
   private String getBackend() {
@@ -168,7 +165,7 @@
   }
 
   private static String id(SshSession sd) {
-    return sd != null ? IdGenerator.format(sd.getSessionId()) : "";
+    return sd != null ? HexFormat.fromInt(sd.getSessionId()) : "";
   }
 
   private static String time(long now, long time) {
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 5d7fdbf..57562a7 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -19,7 +19,6 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.ConfigResource;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.config.ListTasks;
 import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -94,7 +94,7 @@
 
     List<TaskInfo> tasks;
     try {
-      tasks = listTasks.apply(new ConfigResource());
+      tasks = listTasks.apply(new ConfigResource()).value();
     } catch (AuthException e) {
       throw die(e);
     } catch (PermissionBackendException e) {
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index c97372c..c680d30 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -16,26 +16,22 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Supplier;
 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.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventGson;
 import com.google.gerrit.server.events.EventTypes;
-import com.google.gerrit.server.events.ProjectNameKeySerializer;
-import com.google.gerrit.server.events.SupplierSerializer;
 import com.google.gerrit.server.events.UserScopedEventListener;
 import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
 import com.google.gerrit.sshd.BaseCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.StreamCommandExecutor;
 import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.PrintWriter;
@@ -49,7 +45,7 @@
 
 @RequiresCapability(GlobalCapability.STREAM_EVENTS)
 @CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time")
-final class StreamEvents extends BaseCommand {
+public final class StreamEvents extends BaseCommand {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   /** Maximum number of events that may be queued up for each connection. */
@@ -71,11 +67,11 @@
 
   @Inject @StreamCommandExecutor private ScheduledThreadPoolExecutor pool;
 
+  @Inject @EventGson private Gson gson;
+
   /** Queue of events to stream to the connected user. */
   private final LinkedBlockingQueue<Event> queue = new LinkedBlockingQueue<>(MAX_EVENTS);
 
-  private Gson gson;
-
   private RegistrationHandle eventListenerRegistration;
 
   /** Special event to notify clients they missed other events. */
@@ -91,29 +87,6 @@
     EventTypes.register(DroppedOutputEvent.TYPE, DroppedOutputEvent.class);
   }
 
-  private final CancelableRunnable writer =
-      new CancelableRunnable() {
-        @Override
-        public void run() {
-          writeEvents();
-        }
-
-        @Override
-        public void cancel() {
-          onExit(0);
-        }
-
-        @Override
-        public String toString() {
-          StringBuilder b = new StringBuilder();
-          b.append("Stream Events");
-          if (currentUser.getUserName().isPresent()) {
-            b.append(" (").append(currentUser.getUserName().get()).append(")");
-          }
-          return b.toString();
-        }
-      };
-
   /** True if {@link DroppedOutputEvent} needs to be sent. */
   private volatile boolean dropped;
 
@@ -131,8 +104,6 @@
    */
   private Future<?> task;
 
-  private PrintWriter stdout;
-
   @Override
   public void start(Environment env) throws IOException {
     try {
@@ -148,14 +119,38 @@
       return;
     }
 
-    stdout = toPrintWriter(out);
+    PrintWriter stdout = toPrintWriter(out);
+    CancelableRunnable writer =
+        new CancelableRunnable() {
+          @Override
+          public void run() {
+            writeEvents(this, stdout);
+          }
+
+          @Override
+          public void cancel() {
+            onExit(0);
+          }
+
+          @Override
+          public String toString() {
+            StringBuilder b = new StringBuilder();
+            b.append("Stream Events");
+            if (currentUser.getUserName().isPresent()) {
+              b.append(" (").append(currentUser.getUserName().get()).append(")");
+            }
+            return b.toString();
+          }
+        };
+
     eventListenerRegistration =
         eventListeners.add(
+            "gerrit",
             new UserScopedEventListener() {
               @Override
               public void onEvent(Event event) {
                 if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
-                  offer(event);
+                  offer(writer, event);
                 }
               }
 
@@ -164,12 +159,6 @@
                 return currentUser;
               }
             });
-
-    gson =
-        new GsonBuilder()
-            .registerTypeAdapter(Supplier.class, new SupplierSerializer())
-            .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeySerializer())
-            .create();
   }
 
   private void removeEventListenerRegistration() {
@@ -208,7 +197,7 @@
     }
   }
 
-  private void offer(Event event) {
+  private void offer(CancelableRunnable writer, Event event) {
     synchronized (taskLock) {
       if (!queue.offer(event)) {
         dropped = true;
@@ -230,7 +219,7 @@
     }
   }
 
-  private void writeEvents() {
+  private void writeEvents(CancelableRunnable writer, PrintWriter stdout) {
     int processed = 0;
 
     while (processed < BATCH_SIZE) {
@@ -240,13 +229,13 @@
         // accepting output. Either way terminate this instance.
         //
         removeEventListenerRegistration();
-        flush();
+        flush(stdout);
         onExit(0);
         return;
       }
 
       if (dropped) {
-        write(new DroppedOutputEvent());
+        write(stdout, new DroppedOutputEvent());
         dropped = false;
       }
 
@@ -255,11 +244,11 @@
         break;
       }
 
-      write(event);
+      write(stdout, event);
       processed++;
     }
 
-    flush();
+    flush(stdout);
 
     if (BATCH_SIZE <= processed) {
       // We processed the limit, but more might remain in the queue.
@@ -272,7 +261,7 @@
     }
   }
 
-  private void write(Object message) {
+  private void write(PrintWriter stdout, Object message) {
     String msg = null;
     try {
       msg = gson.toJson(message) + "\n";
@@ -286,7 +275,7 @@
     }
   }
 
-  private void flush() {
+  private void flush(PrintWriter stdout) {
     synchronized (stdout) {
       stdout.flush();
     }
diff --git a/java/com/google/gerrit/sshd/commands/Upload.java b/java/com/google/gerrit/sshd/commands/Upload.java
index 24a6975..a22cdaf 100644
--- a/java/com/google/gerrit/sshd/commands/Upload.java
+++ b/java/com/google/gerrit/sshd/commands/Upload.java
@@ -17,15 +17,19 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.RequestInfo;
+import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.UploadPackInitializer;
 import com.google.gerrit.server.git.validators.UploadValidationException;
 import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
@@ -43,6 +47,7 @@
   @Inject private DynamicSet<PreUploadHook> preUploadHooks;
   @Inject private DynamicSet<PostUploadHook> postUploadHooks;
   @Inject private DynamicSet<UploadPackInitializer> uploadPackInitializers;
+  @Inject private PluginSetContext<RequestListener> requestListeners;
   @Inject private UploadValidators.Factory uploadValidatorsFactory;
   @Inject private SshSession session;
   @Inject private PermissionBackend permissionBackend;
@@ -73,7 +78,13 @@
     for (UploadPackInitializer initializer : uploadPackInitializers) {
       initializer.init(projectState.getNameKey(), up);
     }
-    try {
+    try (TraceContext traceContext = TraceContext.open()) {
+      RequestInfo requestInfo =
+          RequestInfo.builder(RequestInfo.RequestType.GIT_UPLOAD, user, traceContext)
+              .project(projectState.getNameKey())
+              .build();
+      requestListeners.runEach(l -> l.onRequest(requestInfo));
+
       up.upload(in, out, err);
       session.setPeerAgent(up.getPeerUserAgent());
     } catch (UploadValidationException e) {
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 91b190f..a58e472 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.sshd.commands;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableMap;
@@ -48,6 +48,7 @@
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.ParserProperties;
 
 /** Allows getting archives for Git repositories over SSH using the Git upload-archive protocol. */
 public class UploadArchive extends AbstractGitCommand {
@@ -138,7 +139,7 @@
     PacketLineIn packetIn = new PacketLineIn(in);
     for (; ; ) {
       String s = packetIn.readString();
-      if (s == PacketLineIn.END) {
+      if (isPacketLineEnd(s)) {
         break;
       }
       if (!s.startsWith(argCmd)) {
@@ -151,7 +152,8 @@
 
     try {
       // Parse them into the 'options' field
-      CmdLineParser parser = new CmdLineParser(options);
+      CmdLineParser parser =
+          new CmdLineParser(options, ParserProperties.defaults().withAtSyntax(false));
       parser.parseArgument(args);
       if (options.path == null || Arrays.asList(".").equals(options.path)) {
         options.path = Collections.emptyList();
@@ -161,6 +163,12 @@
     }
   }
 
+  // JGit API depends on reference equality with sentinel.
+  @SuppressWarnings({"ReferenceEquality", "StringEquality"})
+  private static boolean isPacketLineEnd(String s) {
+    return s == PacketLineIn.END;
+  }
+
   @Override
   protected void runImpl() throws IOException, PermissionBackendException, Failure {
     PacketLineOut packetOut = new PacketLineOut(out);
@@ -236,7 +244,7 @@
                   options.level9)
               .indexOf(true);
       if (value >= 0) {
-        return ImmutableMap.<String, Object>of("level", Integer.valueOf(value));
+        return ImmutableMap.of("level", Integer.valueOf(value));
       }
     }
     return Collections.emptyMap();
@@ -244,7 +252,7 @@
 
   private boolean canRead(ObjectId revId) throws IOException, PermissionBackendException {
     ProjectState projectState = projectCache.get(projectName);
-    checkNotNull(projectState, "Failed to load project %s", projectName);
+    requireNonNull(projectState, () -> String.format("Failed to load project %s", projectName));
 
     if (!projectState.statePermitsRead()) {
       return false;
diff --git a/java/com/google/gerrit/testing/AssertableExecutorService.java b/java/com/google/gerrit/testing/AssertableExecutorService.java
new file mode 100644
index 0000000..18ac2e9
--- /dev/null
+++ b/java/com/google/gerrit/testing/AssertableExecutorService.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.testing;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.util.concurrent.ForwardingExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Forwards all calls to a direct executor making it so that the submitted {@link Runnable}s run
+ * synchronously. Holds a count of the number of tasks that were executed.
+ */
+public class AssertableExecutorService extends ForwardingExecutorService {
+
+  private final ExecutorService delegate = MoreExecutors.newDirectExecutorService();
+  private final AtomicInteger numInteractions = new AtomicInteger();
+
+  @Override
+  protected ExecutorService delegate() {
+    return delegate;
+  }
+
+  @Override
+  public <T> Future<T> submit(Callable<T> task) {
+    numInteractions.incrementAndGet();
+    return super.submit(task);
+  }
+
+  @Override
+  public Future<?> submit(Runnable task) {
+    numInteractions.incrementAndGet();
+    return super.submit(task);
+  }
+
+  @Override
+  public <T> Future<T> submit(Runnable task, T result) {
+    numInteractions.incrementAndGet();
+    return super.submit(task, result);
+  }
+
+  /** Asserts and resets the number of executions this executor observed. */
+  public void assertInteractions(int expectedNumInteractions) {
+    assertWithMessage("expectedRunnablesSubmittedOnExecutor")
+        .that(numInteractions.get())
+        .isEqualTo(expectedNumInteractions);
+    numInteractions.set(0);
+  }
+}
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 43aa978..27065aa 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -1,7 +1,10 @@
 java_library(
     name = "gerrit-test-util",
-    testonly = 1,
-    srcs = glob(["**/*.java"]),
+    testonly = True,
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ["AssertableExecutorService.java"],
+    ),
     visibility = ["//visibility:public"],
     exports = [
         "//lib/easymock",
@@ -12,25 +15,30 @@
         "//lib/powermock:powermock-module-junit4-common",
     ],
     deps = [
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/pgm/init",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:h2",
         "//lib:junit",
         "//lib/auto:auto-value",
@@ -43,3 +51,15 @@
         "//lib/truth",
     ],
 )
+
+java_library(
+    # This can't be part of gerrit-test-util because of https://github.com/google/guava/issues/2837
+    name = "assertable-executor",
+    testonly = True,
+    srcs = ["AssertableExecutorService.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
index b0229c3..9e45b7c 100644
--- a/java/com/google/gerrit/testing/ConfigSuite.java
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.gerrit.server.logging.LoggingContext;
 import java.lang.annotation.Annotation;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
@@ -105,11 +106,13 @@
  */
 public class ConfigSuite extends Suite {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
 
   static {
     System.setProperty(
         FLOGGER_BACKEND_PROPERTY,
         "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance");
+    System.setProperty(FLOGGER_LOGGING_CONTEXT, LoggingContext.class.getName() + "#getInstance");
   }
 
   public static final String DEFAULT = "default";
@@ -156,7 +159,7 @@
 
     @Override
     public Object createTest() throws Exception {
-      Object test = getTestClass().getJavaClass().newInstance();
+      Object test = getTestClass().getJavaClass().getDeclaredConstructor().newInstance();
       parameterField.set(test, new org.eclipse.jgit.lib.Config(cfg));
       if (nameField != null) {
         nameField.set(test, name);
diff --git a/java/com/google/gerrit/testing/DisabledReviewDb.java b/java/com/google/gerrit/testing/DisabledReviewDb.java
deleted file mode 100644
index d902e11..0000000
--- a/java/com/google/gerrit/testing/DisabledReviewDb.java
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testing;
-
-import com.google.gerrit.reviewdb.server.ChangeAccess;
-import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
-import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
-import com.google.gerrit.reviewdb.server.PatchSetAccess;
-import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.SchemaVersionAccess;
-import com.google.gerrit.reviewdb.server.SystemConfigAccess;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.StatementExecutor;
-
-/** ReviewDb that is disabled for testing. */
-public class DisabledReviewDb implements ReviewDb {
-  public static class Disabled extends RuntimeException {
-    private static final long serialVersionUID = 1L;
-
-    private Disabled() {
-      super("ReviewDb is disabled for this test");
-    }
-  }
-
-  @Override
-  public void close() {
-    // Do nothing.
-  }
-
-  @Override
-  public void commit() {
-    throw new Disabled();
-  }
-
-  @Override
-  public void rollback() {
-    throw new Disabled();
-  }
-
-  @Override
-  public void updateSchema(StatementExecutor e) {
-    throw new Disabled();
-  }
-
-  @Override
-  public void pruneSchema(StatementExecutor e) {
-    throw new Disabled();
-  }
-
-  @Override
-  public Access<?, ?>[] allRelations() {
-    throw new Disabled();
-  }
-
-  @Override
-  public SchemaVersionAccess schemaVersion() {
-    throw new Disabled();
-  }
-
-  @Override
-  public SystemConfigAccess systemConfig() {
-    throw new Disabled();
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    throw new Disabled();
-  }
-
-  @Override
-  public PatchSetApprovalAccess patchSetApprovals() {
-    throw new Disabled();
-  }
-
-  @Override
-  public ChangeMessageAccess changeMessages() {
-    throw new Disabled();
-  }
-
-  @Override
-  public PatchSetAccess patchSets() {
-    throw new Disabled();
-  }
-
-  @Override
-  public PatchLineCommentAccess patchComments() {
-    throw new Disabled();
-  }
-
-  @Override
-  public int nextAccountId() {
-    throw new Disabled();
-  }
-
-  @Override
-  public int nextAccountGroupId() {
-    throw new Disabled();
-  }
-
-  @Override
-  public int nextChangeId() {
-    throw new Disabled();
-  }
-}
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index 224a5bf..1850182 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -17,12 +17,10 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
@@ -42,7 +40,10 @@
     if (state != null) {
       return state;
     }
-    return newState(new Account(accountId, TimeUtil.nowTs()));
+    return newState(
+        Account.builder(accountId, TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build());
   }
 
   @Override
@@ -74,10 +75,10 @@
 
   public synchronized void put(Account account) {
     AccountState state = newState(account);
-    byId.put(account.getId(), state);
+    byId.put(account.id(), state);
   }
 
   private static AccountState newState(Account account) {
-    return AccountState.forAccount(new AllUsersName(AllUsersNameProvider.DEFAULT), account);
+    return AccountState.forAccount(account);
   }
 }
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index 28946dc..a60995b 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -21,11 +21,11 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
-import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -87,7 +87,7 @@
   @Inject
   FakeEmailSender(WorkQueue workQueue) {
     this.workQueue = workQueue;
-    messages = Collections.synchronizedList(new ArrayList<Message>());
+    messages = Collections.synchronizedList(new ArrayList<>());
     messagesRead = 0;
   }
 
@@ -150,8 +150,7 @@
   public List<Message> getMessages(String changeId, String type) {
     final String idFooter = "\n" + MailHeader.CHANGE_ID.withDelimiter() + changeId + "\n";
     final String typeFooter = "\n" + MailHeader.MESSAGE_TYPE.withDelimiter() + type + "\n";
-    return getMessages()
-        .stream()
+    return getMessages().stream()
         .filter(in -> in.body().contains(idFooter) && in.body().contains(typeFooter))
         .collect(toList());
   }
diff --git a/java/com/google/gerrit/testing/GerritBaseTests.java b/java/com/google/gerrit/testing/GerritBaseTests.java
deleted file mode 100644
index 01fb85d..0000000
--- a/java/com/google/gerrit/testing/GerritBaseTests.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testing;
-
-import com.google.common.base.CharMatcher;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.rules.ExpectedException;
-import org.junit.rules.TestName;
-
-@Ignore
-public abstract class GerritBaseTests {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
-  @Rule public ExpectedException exception = ExpectedException.none();
-  @Rule public final TestName testName = new TestName();
-
-  protected String getSanitizedMethodName() {
-    String name = testName.getMethodName().toLowerCase();
-    name =
-        CharMatcher.inRange('a', 'z')
-            .or(CharMatcher.inRange('A', 'Z'))
-            .or(CharMatcher.inRange('0', '9'))
-            .negate()
-            .replaceFrom(name, '_');
-    name = CharMatcher.is('_').trimTrailingFrom(name);
-    return name;
-  }
-}
diff --git a/java/com/google/gerrit/testing/GerritJUnit.java b/java/com/google/gerrit/testing/GerritJUnit.java
new file mode 100644
index 0000000..0771c39
--- /dev/null
+++ b/java/com/google/gerrit/testing/GerritJUnit.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.testing;
+
+/** Static JUnit utility methods. */
+public class GerritJUnit {
+  /**
+   * Assert that an exception is thrown by a block of code.
+   *
+   * <p>This method is source-compatible with <a
+   * href="https://junit.org/junit4/javadoc/latest/org/junit/Assert.html#assertThrows(java.lang.Class,%20org.junit.function.ThrowingRunnable)">JUnit
+   * 4.13 beta</a>.
+   *
+   * <p>This construction is recommended by the Truth team for use in conjunction with asserting
+   * over a {@code ThrowableSubject} on the return type:
+   *
+   * <pre>
+   *   MyException e = assertThrows(MyException.class, () -> doSomething(foo));
+   *   assertThat(e).isInstanceOf(MySubException.class);
+   *   assertThat(e).hasMessageThat().contains("sub-exception occurred");
+   * </pre>
+   *
+   * @param throwableClass expected exception type.
+   * @param runnable runnable containing arbitrary code.
+   * @return exception that was thrown.
+   */
+  public static <T extends Throwable> T assertThrows(
+      Class<T> throwableClass, ThrowingRunnable runnable) {
+    try {
+      runnable.run();
+    } catch (Throwable t) {
+      if (!throwableClass.isInstance(t)) {
+        throw new AssertionError(
+            "expected "
+                + throwableClass.getName()
+                + " but "
+                + t.getClass().getName()
+                + " was thrown",
+            t);
+      }
+      @SuppressWarnings("unchecked")
+      T toReturn = (T) t;
+      return toReturn;
+    }
+    throw new AssertionError(
+        "expected " + throwableClass.getName() + " but no exception was thrown");
+  }
+
+  @FunctionalInterface
+  public interface ThrowingRunnable {
+    void run() throws Throwable;
+  }
+
+  private GerritJUnit() {}
+}
diff --git a/java/com/google/gerrit/testing/GerritServerTests.java b/java/com/google/gerrit/testing/GerritServerTests.java
index 69806e1..ad985b6 100644
--- a/java/com/google/gerrit/testing/GerritServerTests.java
+++ b/java/com/google/gerrit/testing/GerritServerTests.java
@@ -14,46 +14,25 @@
 
 package com.google.gerrit.testing;
 
-import com.google.gerrit.server.notedb.MutableNotesMigration;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Rule;
 import org.junit.rules.TestRule;
-import org.junit.runner.Description;
 import org.junit.runner.RunWith;
 import org.junit.runners.model.Statement;
 
 @RunWith(ConfigSuite.class)
-public class GerritServerTests extends GerritBaseTests {
+public class GerritServerTests {
   @ConfigSuite.Parameter public Config config;
 
   @ConfigSuite.Name private String configName;
 
-  protected MutableNotesMigration notesMigration;
-
   @Rule
   public TestRule testRunner =
-      new TestRule() {
-        @Override
-        public Statement apply(Statement base, Description description) {
-          return new Statement() {
+      (base, description) ->
+          new Statement() {
             @Override
             public void evaluate() throws Throwable {
-              beforeTest();
-              try {
-                base.evaluate();
-              } finally {
-                afterTest();
-              }
+              base.evaluate();
             }
           };
-        }
-      };
-
-  public void beforeTest() throws Exception {
-    notesMigration = NoteDbMode.newNotesMigrationFromEnv();
-  }
-
-  public void afterTest() {
-    NoteDbMode.resetFromEnv(notesMigration);
-  }
 }
diff --git a/java/com/google/gerrit/testing/GerritTestName.java b/java/com/google/gerrit/testing/GerritTestName.java
new file mode 100644
index 0000000..d003289
--- /dev/null
+++ b/java/com/google/gerrit/testing/GerritTestName.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.testing;
+
+import com.google.common.base.CharMatcher;
+import org.junit.rules.TestName;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+public class GerritTestName implements TestRule {
+  private final TestName delegate = new TestName();
+
+  public String getSanitizedMethodName() {
+    String name = delegate.getMethodName().toLowerCase();
+    name =
+        CharMatcher.inRange('a', 'z')
+            .or(CharMatcher.inRange('A', 'Z'))
+            .or(CharMatcher.inRange('0', '9'))
+            .negate()
+            .replaceFrom(name, '_');
+    name = CharMatcher.is('_').trimTrailingFrom(name);
+    return name;
+  }
+
+  @Override
+  public Statement apply(Statement base, Description description) {
+    return delegate.apply(base, description);
+  }
+}
diff --git a/java/com/google/gerrit/testing/InMemoryDatabase.java b/java/com/google/gerrit/testing/InMemoryDatabase.java
deleted file mode 100644
index a3d7c17..0000000
--- a/java/com/google/gerrit/testing/InMemoryDatabase.java
+++ /dev/null
@@ -1,201 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testing;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
-import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.schema.SchemaVersion;
-import com.google.gwtorm.jdbc.Database;
-import com.google.gwtorm.jdbc.SimpleDataSource;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import java.io.IOException;
-import java.sql.Connection;
-import java.sql.SQLException;
-import java.util.Properties;
-import javax.sql.DataSource;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-/**
- * An in-memory test instance of {@link ReviewDb} database.
- *
- * <p>Test classes should create one instance of this class for each unique test database they want
- * to use. When the tests needing this instance are complete, ensure that {@link
- * #drop(InMemoryDatabase)} is called to free the resources so the JVM running the unit tests
- * doesn't run out of heap space.
- */
-public class InMemoryDatabase implements SchemaFactory<ReviewDb> {
-  public static InMemoryDatabase newDatabase(LifecycleManager lifecycle) {
-    Injector injector = Guice.createInjector(new InMemoryModule());
-    lifecycle.add(injector);
-    return injector.getInstance(InMemoryDatabase.class);
-  }
-
-  /** Drop the database from memory; does nothing if the instance was null. */
-  public static void drop(InMemoryDatabase db) {
-    if (db != null) {
-      db.dbInstance.drop();
-    }
-  }
-
-  private final SchemaCreator schemaCreator;
-  private final Instance dbInstance;
-
-  private boolean created;
-
-  @Inject
-  InMemoryDatabase(Injector injector) throws OrmException {
-    Injector childInjector =
-        injector.createChildInjector(
-            new AbstractModule() {
-              @Override
-              protected void configure() {
-                switch (IndexModule.getIndexType(injector)) {
-                  case LUCENE:
-                    install(new LuceneIndexModuleOnInit());
-                    break;
-                  case ELASTICSEARCH:
-                    install(new ElasticIndexModuleOnInit());
-                    break;
-                  default:
-                    throw new IllegalStateException("unsupported index.type");
-                }
-              }
-            });
-    this.schemaCreator = childInjector.getInstance(SchemaCreator.class);
-    Instance dbInstanceFromInjector = childInjector.getInstance(Instance.class);
-    if (dbInstanceFromInjector != null) {
-      this.dbInstance = dbInstanceFromInjector;
-      this.created = true;
-    } else {
-      this.dbInstance = new Instance();
-    }
-  }
-
-  InMemoryDatabase(SchemaCreator schemaCreator) throws OrmException {
-    this.schemaCreator = schemaCreator;
-    this.dbInstance = new Instance();
-  }
-
-  public Instance getDbInstance() {
-    return dbInstance;
-  }
-
-  public Database<ReviewDb> getDatabase() {
-    return dbInstance.database;
-  }
-
-  @Override
-  public ReviewDb open() throws OrmException {
-    return getDatabase().open();
-  }
-
-  /** Ensure the database schema has been created and initialized. */
-  public InMemoryDatabase create() throws OrmException {
-    if (!created) {
-      created = true;
-      try (ReviewDb c = open()) {
-        schemaCreator.create(c);
-      } catch (IOException | ConfigInvalidException e) {
-        throw new OrmException("Cannot create in-memory database", e);
-      }
-    }
-    return this;
-  }
-
-  public SystemConfig getSystemConfig() throws OrmException {
-    try (ReviewDb c = open()) {
-      return c.systemConfig().get(new SystemConfig.Key());
-    }
-  }
-
-  public CurrentSchemaVersion getSchemaVersion() throws OrmException {
-    try (ReviewDb c = open()) {
-      return c.schemaVersion().get(new CurrentSchemaVersion.Key());
-    }
-  }
-
-  public void assertSchemaVersion() throws OrmException {
-    assertThat(getSchemaVersion().versionNbr).isEqualTo(SchemaVersion.getBinaryVersion());
-  }
-
-  public static class Instance {
-    private static int dbCnt;
-
-    private Connection openHandle;
-    private Database<ReviewDb> database;
-    private boolean keepOpen;
-
-    private static synchronized DataSource newDataSource() throws SQLException {
-      final Properties p = new Properties();
-      p.setProperty("driver", org.h2.Driver.class.getName());
-      p.setProperty("url", "jdbc:h2:mem:Test_" + (++dbCnt));
-      return new SimpleDataSource(p);
-    }
-
-    private Instance() throws OrmException {
-      try {
-        DataSource dataSource = newDataSource();
-
-        // Open one connection. This will peg the database into memory
-        // until someone calls drop on us, allowing subsequent connections
-        // opened against the same URL to go to the same set of tables.
-        //
-        openHandle = dataSource.getConnection();
-
-        // Build the access layer around the connection factory.
-        //
-        database = new Database<>(dataSource, ReviewDb.class);
-
-      } catch (SQLException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    public void setKeepOpen(boolean keepOpen) {
-      this.keepOpen = keepOpen;
-    }
-
-    /** Drop this database from memory so it no longer exists. */
-    public void drop() {
-      if (keepOpen) {
-        return;
-      }
-
-      if (openHandle != null) {
-        try {
-          openHandle.close();
-        } catch (SQLException e) {
-          System.err.println("WARNING: Cannot close database connection");
-          e.printStackTrace(System.err);
-        }
-        openHandle = null;
-        database = null;
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/testing/InMemoryH2Type.java b/java/com/google/gerrit/testing/InMemoryH2Type.java
deleted file mode 100644
index ae3bf36..0000000
--- a/java/com/google/gerrit/testing/InMemoryH2Type.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testing;
-
-import com.google.gerrit.server.schema.BaseDataSourceType;
-
-public class InMemoryH2Type extends BaseDataSourceType {
-
-  protected InMemoryH2Type() {
-    super(null);
-  }
-
-  @Override
-  public String getUrl() {
-    // not used
-    throw new UnsupportedOperationException();
-  }
-}
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b3f6222..5f1826b 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -20,6 +20,8 @@
 import com.google.common.base.Strings;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
@@ -28,13 +30,13 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -46,6 +48,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.ChangeUpdateExecutor;
+import com.google.gerrit.server.config.DefaultUrlFormatter;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
 import com.google.gerrit.server.config.GerritOptions;
@@ -68,38 +71,29 @@
 import com.google.gerrit.server.index.change.AllChangesIndexer;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.group.AllGroupsIndexer;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.ServerInformationImpl;
 import com.google.gerrit.server.project.DefaultProjectNameLockManager;
 import com.google.gerrit.server.restapi.RestApiModule;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
-import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
-import com.google.gerrit.server.schema.ReviewDbFactory;
+import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.schema.SchemaCreatorImpl;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.ssh.NoSshKeyCache;
 import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Provides;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
 import com.google.inject.servlet.RequestScoped;
 import com.google.inject.util.Providers;
 import java.lang.reflect.InvocationTargetException;
@@ -120,6 +114,8 @@
   }
 
   public static void setDefaults(Config cfg) {
+    cfg.setString(
+        "accountPatchReviewDb", null, "url", JdbcAccountPatchReviewStore.TEST_IN_MEMORY_URL);
     cfg.setEnum("auth", null, "type", AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT);
     cfg.setString("gerrit", null, "allProjects", "Test-Projects");
     cfg.setString("gerrit", null, "basePath", "git");
@@ -135,15 +131,13 @@
   }
 
   private final Config cfg;
-  private final MutableNotesMigration notesMigration;
 
   public InMemoryModule() {
-    this(newDefaultConfig(), NoteDbMode.newNotesMigrationFromEnv());
+    this(newDefaultConfig());
   }
 
-  public InMemoryModule(Config cfg, MutableNotesMigration notesMigration) {
+  public InMemoryModule(Config cfg) {
     this.cfg = cfg;
-    this.notesMigration = notesMigration;
   }
 
   public void inject(Object instance) {
@@ -178,40 +172,25 @@
     install(new DefaultPermissionBackendModule());
     install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
+    install(new AuditModule());
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
-    // TODO(dborowitz): Use Jimfs. The biggest blocker is that JGit does not support Path-based
-    // Configs, only FileBasedConfig.
+    // It would be nice to use Jimfs for the SitePath, but the biggest blocker is that JGit does not
+    // support Path-based Configs, only FileBasedConfig.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
-    bind(GerritOptions.class).toInstance(new GerritOptions(cfg, false, false, false));
-    bind(PersonIdent.class)
-        .annotatedWith(GerritPersonIdent.class)
-        .toProvider(GerritPersonIdentProvider.class);
-    bind(String.class)
-        .annotatedWith(AnonymousCowardName.class)
-        .toProvider(AnonymousCowardNameProvider.class);
+    bind(GerritOptions.class).toInstance(new GerritOptions(false, false, false));
 
-    bind(AllProjectsName.class).toProvider(AllProjectsNameProvider.class);
-    bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
     bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
     bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
-    bind(MutableNotesMigration.class).toInstance(notesMigration);
-    bind(NotesMigration.class).to(MutableNotesMigration.class);
     bind(ListeningExecutorService.class)
         .annotatedWith(ChangeUpdateExecutor.class)
         .toInstance(MoreExecutors.newDirectExecutorService());
-    bind(DataSourceType.class).to(InMemoryH2Type.class);
-    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
     bind(SecureStore.class).to(DefaultSecureStore.class);
 
-    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
-        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
-    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
-    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
-
+    install(new InMemorySchemaModule());
     install(NoSshKeyCache.module());
     install(new GerritInstanceNameModule());
     install(
@@ -221,6 +200,7 @@
             return CanonicalWebUrlProvider.class;
           }
         });
+    install(new DefaultUrlFormatter.Module());
     // Replacement of DiffExecutorModule to not use thread pool in the tests
     install(
         new AbstractModule() {
@@ -239,35 +219,60 @@
     install(new FakeEmailSender.Module());
     install(new SignedTokenEmailTokenVerifier.Module());
     install(new GpgModule(cfg));
-    install(new InMemoryAccountPatchReviewStore.Module());
     install(new LocalMergeSuperSetComputation.Module());
 
     bind(AllAccountsIndexer.class).toProvider(Providers.of(null));
     bind(AllChangesIndexer.class).toProvider(Providers.of(null));
     bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
 
-    IndexType indexType = null;
-    try {
-      indexType = cfg.getEnum("index", null, "type", IndexType.LUCENE);
-    } catch (IllegalArgumentException e) {
-      // Custom index type, caller must provide their own module.
-    }
-    if (indexType != null) {
-      switch (indexType) {
-        case LUCENE:
-          install(luceneIndexModule());
-          break;
-        case ELASTICSEARCH:
-          install(elasticIndexModule());
-          break;
-        default:
-          throw new ProvisionException("index type unsupported in tests: " + indexType);
-      }
+    IndexType indexType = new IndexType(cfg.getString("index", null, "type"));
+    // For custom index types, callers must provide their own module.
+    if (indexType.isLucene()) {
+      install(luceneIndexModule());
+    } else if (indexType.isElasticsearch()) {
+      install(elasticIndexModule());
     }
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
     install(new RestApiModule());
     install(new DefaultProjectNameLockManager.Module());
+
+    bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
+  }
+
+  /** Copy of SchemaModule with a slightly different server ID provider. */
+  // TODO(dborowitz): Better code sharing.
+  private class InMemorySchemaModule extends FactoryModule {
+    @Override
+    public void configure() {
+      bind(PersonIdent.class)
+          .annotatedWith(GerritPersonIdent.class)
+          .toProvider(GerritPersonIdentProvider.class);
+
+      bind(AllProjectsName.class).toProvider(AllProjectsNameProvider.class).in(SINGLETON);
+
+      bind(AllUsersName.class).toProvider(AllUsersNameProvider.class).in(SINGLETON);
+
+      bind(String.class)
+          .annotatedWith(AnonymousCowardName.class)
+          .toProvider(AnonymousCowardNameProvider.class);
+
+      bind(GroupIndexCollection.class);
+      bind(SchemaCreator.class).to(SchemaCreatorImpl.class);
+    }
+
+    @Provides
+    @Singleton
+    @GerritServerId
+    public String createServerId() {
+      String serverId =
+          cfg.getString(GerritServerIdProvider.SECTION, null, GerritServerIdProvider.KEY);
+      if (!Strings.isNullOrEmpty(serverId)) {
+        return serverId;
+      }
+
+      return "gerrit";
+    }
   }
 
   @Provides
@@ -284,25 +289,6 @@
     return queues.createQueue(2, "FanOut");
   }
 
-  @Provides
-  @Singleton
-  @GerritServerId
-  public String createServerId() {
-    String serverId =
-        cfg.getString(GerritServerIdProvider.SECTION, null, GerritServerIdProvider.KEY);
-    if (!Strings.isNullOrEmpty(serverId)) {
-      return serverId;
-    }
-
-    return "gerrit";
-  }
-
-  @Provides
-  @Singleton
-  InMemoryDatabase getInMemoryDatabase(SchemaCreator schemaCreator) throws OrmException {
-    return new InMemoryDatabase(schemaCreator);
-  }
-
   private Module luceneIndexModule() {
     return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
   }
@@ -325,9 +311,7 @@
         | IllegalAccessException
         | InvocationTargetException e) {
       e.printStackTrace();
-      ProvisionException pe = new ProvisionException(e.getMessage());
-      pe.initCause(e);
-      throw pe;
+      throw new ProvisionException(e.getMessage(), e);
     }
   }
 
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index e44d8d38..fd9818a 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -103,7 +103,7 @@
   public synchronized SortedSet<Project.NameKey> list() {
     SortedSet<Project.NameKey> names = Sets.newTreeSet();
     for (DfsRepository repo : repos.values()) {
-      names.add(new Project.NameKey(repo.getDescription().getRepositoryName()));
+      names.add(Project.nameKey(repo.getDescription().getRepositoryName()));
     }
     return ImmutableSortedSet.copyOf(names);
   }
diff --git a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
index cebd139..05672aa 100644
--- a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
+++ b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
@@ -16,20 +16,15 @@
 
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 import org.eclipse.jgit.lib.Config;
 import org.junit.rules.MethodRule;
 import org.junit.runners.model.FrameworkMethod;
@@ -39,7 +34,7 @@
  * An in-memory test environment for integration tests.
  *
  * <p>This test environment emulates the internals of a Gerrit server without starting a Gerrit
- * site. ReviewDb as well as NoteDb are represented by in-memory representations.
+ * site. Git repositories, including NoteDb, are stored in memory.
  *
  * <p>Each test is executed with a fresh and clean test environment. Hence, modifications applied in
  * one test don't carry over to subsequent ones.
@@ -49,13 +44,9 @@
 
   @Inject private AccountManager accountManager;
   @Inject private IdentifiedUser.GenericFactory userFactory;
-  @Inject private SchemaFactory<ReviewDb> schemaFactory;
   @Inject private SchemaCreator schemaCreator;
   @Inject private ThreadLocalRequestContext requestContext;
-  // Only for use in setting up/tearing down injector.
-  @Inject private InMemoryDatabase inMemoryDatabase;
 
-  private ReviewDb db;
   private LifecycleManager lifecycle;
 
   /** Create a test environment using an empty base config. */
@@ -92,40 +83,26 @@
 
   public void setApiUser(Account.Id id) {
     IdentifiedUser user = userFactory.create(id);
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
+    requestContext.setContext(() -> user);
   }
 
   private void setUp(Object target) throws Exception {
     Config cfg = configProvider.get();
     InMemoryModule.setDefaults(cfg);
 
-    Injector injector =
-        Guice.createInjector(new InMemoryModule(cfg, NoteDbMode.newNotesMigrationFromEnv()));
+    Injector injector = Guice.createInjector(new InMemoryModule(cfg));
     injector.injectMembers(this);
     lifecycle = new LifecycleManager();
     lifecycle.add(injector);
     lifecycle.start();
 
-    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
-      schemaCreator.create(underlyingDb);
-    }
-    db = schemaFactory.open();
+    schemaCreator.create();
 
     // The first user is added to the "Administrators" group. See AccountManager#create().
     setApiUser(accountManager.authenticate(AuthRequest.forUser("admin")).getAccountId());
 
-    // Inject target members after setting API user, so it can @Inject a ReviewDb if it wants.
+    // Inject target members after setting API user, so it can @Inject request-scoped objects if it
+    // wants.
     injector.injectMembers(target);
   }
 
@@ -136,9 +113,5 @@
     if (requestContext != null) {
       requestContext.setContext(null);
     }
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(inMemoryDatabase);
   }
 }
diff --git a/java/com/google/gerrit/testing/IndexVersions.java b/java/com/google/gerrit/testing/IndexVersions.java
index fde93b2..3281ffc 100644
--- a/java/com/google/gerrit/testing/IndexVersions.java
+++ b/java/com/google/gerrit/testing/IndexVersions.java
@@ -131,8 +131,7 @@
       List<Integer> schemaVersions,
       String testSuiteNamePrefix,
       Config baseConfig) {
-    return schemaVersions
-        .stream()
+    return schemaVersions.stream()
         .collect(
             toMap(
                 i -> testSuiteNamePrefix + i,
diff --git a/java/com/google/gerrit/testing/NoteDbChecker.java b/java/com/google/gerrit/testing/NoteDbChecker.java
deleted file mode 100644
index 1dc8ee2..0000000
--- a/java/com/google/gerrit/testing/NoteDbChecker.java
+++ /dev/null
@@ -1,225 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testing;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.flogger.FluentLogger;
-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.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.runner.Description;
-
-@Singleton
-public class NoteDbChecker {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final Provider<ReviewDb> dbProvider;
-  private final GitRepositoryManager repoManager;
-  private final MutableNotesMigration notesMigration;
-  private final ChangeBundleReader bundleReader;
-  private final ChangeNotes.Factory notesFactory;
-  private final ChangeRebuilder changeRebuilder;
-  private final CommentsUtil commentsUtil;
-
-  @Inject
-  NoteDbChecker(
-      Provider<ReviewDb> dbProvider,
-      GitRepositoryManager repoManager,
-      MutableNotesMigration notesMigration,
-      ChangeBundleReader bundleReader,
-      ChangeNotes.Factory notesFactory,
-      ChangeRebuilder changeRebuilder,
-      CommentsUtil commentsUtil) {
-    this.dbProvider = dbProvider;
-    this.repoManager = repoManager;
-    this.bundleReader = bundleReader;
-    this.notesMigration = notesMigration;
-    this.notesFactory = notesFactory;
-    this.changeRebuilder = changeRebuilder;
-    this.commentsUtil = commentsUtil;
-  }
-
-  public void rebuildAndCheckAllChanges() throws Exception {
-    rebuildAndCheckChanges(
-        getUnwrappedDb().changes().all().toList().stream().map(Change::getId),
-        ImmutableListMultimap.of());
-  }
-
-  public void rebuildAndCheckChanges(Change.Id... changeIds) throws Exception {
-    rebuildAndCheckChanges(Arrays.stream(changeIds), ImmutableListMultimap.of());
-  }
-
-  private void rebuildAndCheckChanges(
-      Stream<Change.Id> changeIds, ListMultimap<Change.Id, String> expectedDiffs) throws Exception {
-    ReviewDb db = getUnwrappedDb();
-
-    List<ChangeBundle> allExpected = readExpected(changeIds);
-
-    boolean oldWrite = notesMigration.rawWriteChangesSetting();
-    boolean oldRead = notesMigration.readChanges();
-    try {
-      notesMigration.setWriteChanges(true);
-      notesMigration.setReadChanges(true);
-      List<String> msgs = new ArrayList<>();
-      for (ChangeBundle expected : allExpected) {
-        Change c = expected.getChange();
-        try {
-          changeRebuilder.rebuild(db, c.getId());
-        } catch (RepositoryNotFoundException e) {
-          msgs.add("Repository not found for change, cannot convert: " + c);
-        }
-      }
-
-      checkActual(allExpected, expectedDiffs, msgs);
-    } finally {
-      notesMigration.setReadChanges(oldRead);
-      notesMigration.setWriteChanges(oldWrite);
-    }
-  }
-
-  public void checkChanges(Change.Id... changeIds) throws Exception {
-    checkActual(
-        readExpected(Arrays.stream(changeIds)), ImmutableListMultimap.of(), new ArrayList<>());
-  }
-
-  public void rebuildAndCheckChange(Change.Id changeId, String... expectedDiff) throws Exception {
-    ImmutableListMultimap.Builder<Change.Id, String> b = ImmutableListMultimap.builder();
-    b.putAll(changeId, Arrays.asList(expectedDiff));
-    rebuildAndCheckChanges(Stream.of(changeId), b.build());
-  }
-
-  public void assertNoChangeRef(Project.NameKey project, Change.Id changeId) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef(RefNames.changeMetaRef(changeId))).isNull();
-    }
-  }
-
-  public void assertNoReviewDbChanges(Description desc) throws Exception {
-    ReviewDb db = getUnwrappedDb();
-    assertThat(db.changes().all().toList()).named("Changes in " + desc.getTestClass()).isEmpty();
-    assertThat(db.changeMessages().all().toList())
-        .named("ChangeMessages in " + desc.getTestClass())
-        .isEmpty();
-    assertThat(db.patchSets().all().toList())
-        .named("PatchSets in " + desc.getTestClass())
-        .isEmpty();
-    assertThat(db.patchSetApprovals().all().toList())
-        .named("PatchSetApprovals in " + desc.getTestClass())
-        .isEmpty();
-    assertThat(db.patchComments().all().toList())
-        .named("PatchLineComments in " + desc.getTestClass())
-        .isEmpty();
-  }
-
-  private List<ChangeBundle> readExpected(Stream<Change.Id> changeIds) throws Exception {
-    boolean old = notesMigration.readChanges();
-    try {
-      notesMigration.setReadChanges(false);
-      return changeIds
-          .sorted(comparing(IntKey::get))
-          .map(this::readBundleUnchecked)
-          .collect(toList());
-    } finally {
-      notesMigration.setReadChanges(old);
-    }
-  }
-
-  private ChangeBundle readBundleUnchecked(Change.Id id) {
-    try {
-      return bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    } catch (OrmException e) {
-      throw new OrmRuntimeException(e);
-    }
-  }
-
-  private void checkActual(
-      List<ChangeBundle> allExpected,
-      ListMultimap<Change.Id, String> expectedDiffs,
-      List<String> msgs)
-      throws Exception {
-    ReviewDb db = getUnwrappedDb();
-    boolean oldRead = notesMigration.readChanges();
-    boolean oldWrite = notesMigration.rawWriteChangesSetting();
-    try {
-      notesMigration.setWriteChanges(true);
-      notesMigration.setReadChanges(true);
-      for (ChangeBundle expected : allExpected) {
-        Change c = expected.getChange();
-        ChangeBundle actual;
-        try {
-          actual =
-              ChangeBundle.fromNotes(
-                  commentsUtil, notesFactory.create(db, c.getProject(), c.getId()));
-        } catch (Throwable t) {
-          String msg = "Error converting change: " + c;
-          msgs.add(msg);
-          logger.atSevere().withCause(t).log(msg);
-          continue;
-        }
-        List<String> diff = expected.differencesFrom(actual);
-        List<String> expectedDiff = expectedDiffs.get(c.getId());
-        if (!diff.equals(expectedDiff)) {
-          msgs.add("Differences between ReviewDb and NoteDb for " + c + ":");
-          msgs.addAll(diff);
-          if (!expectedDiff.isEmpty()) {
-            msgs.add("Expected differences:");
-            msgs.addAll(expectedDiff);
-          }
-          msgs.add("");
-        } else {
-          System.err.println("NoteDb conversion of change " + c.getId() + " successful");
-        }
-      }
-    } finally {
-      notesMigration.setReadChanges(oldRead);
-      notesMigration.setWriteChanges(oldWrite);
-    }
-    if (!msgs.isEmpty()) {
-      throw new AssertionError(Joiner.on('\n').join(msgs));
-    }
-  }
-
-  private ReviewDb getUnwrappedDb() {
-    ReviewDb db = dbProvider.get();
-    return ReviewDbUtil.unwrapDb(db);
-  }
-}
diff --git a/java/com/google/gerrit/testing/NoteDbMode.java b/java/com/google/gerrit/testing/NoteDbMode.java
deleted file mode 100644
index d4a7c7e..0000000
--- a/java/com/google/gerrit/testing/NoteDbMode.java
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testing;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Enums;
-import com.google.common.base.Strings;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-
-public enum NoteDbMode {
-  /** NoteDb is disabled. */
-  OFF(NotesMigrationState.REVIEW_DB),
-
-  /** Writing data to NoteDb is enabled. */
-  WRITE(NotesMigrationState.WRITE),
-
-  /** Reading and writing all data to NoteDb is enabled. */
-  READ_WRITE(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY),
-
-  /** Changes are created with their primary storage as NoteDb. */
-  PRIMARY(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY),
-
-  /** All change tables are entirely disabled, and code/meta ref updates are fused. */
-  ON(NotesMigrationState.NOTE_DB),
-
-  /**
-   * Run tests with NoteDb disabled, then convert ReviewDb to NoteDb and check that the results
-   * match.
-   */
-  CHECK(NotesMigrationState.REVIEW_DB);
-
-  private static final String ENV_VAR = "GERRIT_NOTEDB";
-  private static final String SYS_PROP = "gerrit.notedb";
-
-  public static NoteDbMode get() {
-    String value = System.getenv(ENV_VAR);
-    if (Strings.isNullOrEmpty(value)) {
-      value = System.getProperty(SYS_PROP);
-    }
-    if (Strings.isNullOrEmpty(value)) {
-      return OFF;
-    }
-    value = value.toUpperCase().replace("-", "_");
-    NoteDbMode mode = Enums.getIfPresent(NoteDbMode.class, value).orNull();
-    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
-      checkArgument(
-          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
-    } else {
-      checkArgument(
-          mode != null,
-          "Invalid value for system property %s: %s",
-          SYS_PROP,
-          System.getProperty(SYS_PROP));
-    }
-    return mode;
-  }
-
-  public static MutableNotesMigration newNotesMigrationFromEnv() {
-    MutableNotesMigration m = MutableNotesMigration.newDisabled();
-    resetFromEnv(m);
-    return m;
-  }
-
-  public static void resetFromEnv(MutableNotesMigration migration) {
-    migration.setFrom(get().state);
-  }
-
-  private final NotesMigrationState state;
-
-  private NoteDbMode(NotesMigrationState state) {
-    this.state = state;
-  }
-}
diff --git a/java/com/google/gerrit/testing/TempFileUtil.java b/java/com/google/gerrit/testing/TempFileUtil.java
deleted file mode 100644
index c42bd74..0000000
--- a/java/com/google/gerrit/testing/TempFileUtil.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.testing;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-public class TempFileUtil {
-  private static List<File> allDirsCreated = new ArrayList<>();
-
-  public static synchronized File createTempDirectory() throws IOException {
-    File tmp = File.createTempFile("gerrit_test_", "").getCanonicalFile();
-    if (!tmp.delete() || !tmp.mkdir()) {
-      throw new IOException("Cannot create " + tmp.getPath());
-    }
-    allDirsCreated.add(tmp);
-    return tmp;
-  }
-
-  public static synchronized void cleanup() throws IOException {
-    for (File dir : allDirsCreated) {
-      recursivelyDelete(dir);
-    }
-    allDirsCreated.clear();
-  }
-
-  public static void recursivelyDelete(File dir) throws IOException {
-    if (!dir.getPath().equals(dir.getCanonicalPath())) {
-      // Directory symlink reaching outside of temporary space.
-      return;
-    }
-    File[] contents = dir.listFiles();
-    if (contents != null) {
-      for (File d : contents) {
-        if (d.isDirectory()) {
-          recursivelyDelete(d);
-        } else {
-          deleteNowOrOnExit(d);
-        }
-      }
-    }
-    deleteNowOrOnExit(dir);
-  }
-
-  private static void deleteNowOrOnExit(File dir) {
-    if (!dir.delete()) {
-      dir.deleteOnExit();
-    }
-  }
-
-  private TempFileUtil() {}
-}
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index 31ef805..3c2d1f7 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -17,21 +17,19 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 
 import com.google.common.collect.Ordering;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Injector;
 import java.util.TimeZone;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -53,13 +51,13 @@
   }
 
   public static Change newChange(Project.NameKey project, Account.Id userId, int id) {
-    Change.Id changeId = new Change.Id(id);
+    Change.Id changeId = Change.id(id);
     Change c =
         new Change(
-            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
+            Change.key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
             changeId,
             userId,
-            new Branch.NameKey(project, "master"),
+            BranchNameKey.create(project, "master"),
             TimeUtil.nowTs());
     incrementPatchSet(c);
     return c;
@@ -70,15 +68,16 @@
   }
 
   public static PatchSet newPatchSet(PatchSet.Id id, String revision, Account.Id userId) {
-    PatchSet ps = new PatchSet(id);
-    ps.setRevision(new RevId(revision));
-    ps.setUploader(userId);
-    ps.setCreatedOn(TimeUtil.nowTs());
-    return ps;
+    return PatchSet.builder()
+        .id(id)
+        .commitId(ObjectId.fromString(revision))
+        .uploader(userId)
+        .createdOn(TimeUtil.nowTs())
+        .build();
   }
 
-  public static ChangeUpdate newUpdate(Injector injector, Change c, CurrentUser user)
-      throws Exception {
+  public static ChangeUpdate newUpdate(
+      Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
     injector =
         injector.createChildInjector(
             new FactoryModule() {
@@ -91,23 +90,24 @@
         injector
             .getInstance(ChangeUpdate.Factory.class)
             .create(
-                new ChangeNotes(injector.getInstance(AbstractChangeNotes.Args.class), c).load(),
+                new ChangeNotes(
+                        injector.getInstance(AbstractChangeNotes.Args.class), c, shouldExist, null)
+                    .load(),
                 user,
                 TimeUtil.nowTs(),
-                Ordering.<String>natural());
+                Ordering.natural());
 
     ChangeNotes notes = update.getNotes();
     boolean hasPatchSets = notes.getPatchSets() != null && !notes.getPatchSets().isEmpty();
-    NotesMigration migration = injector.getInstance(NotesMigration.class);
-    if (hasPatchSets || !migration.readChanges()) {
+    if (hasPatchSets) {
       return update;
     }
 
     // Change doesn't exist yet. NoteDb requires that there be a commit for the
     // first patch set, so create one.
     GitRepositoryManager repoManager = injector.getInstance(GitRepositoryManager.class);
-    try (Repository repo = repoManager.openRepository(c.getProject())) {
-      TestRepository<Repository> tr = new TestRepository<>(repo);
+    try (Repository repo = repoManager.openRepository(c.getProject());
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
       PersonIdent ident =
           user.asIdentifiedUser().newCommitterIdent(update.getWhen(), TimeZone.getDefault());
       TestRepository<Repository>.CommitBuilder cb =
@@ -115,11 +115,11 @@
               .author(ident)
               .committer(ident)
               .message(firstNonNull(c.getSubject(), "Test change"));
-      Ref parent = repo.exactRef(c.getDest().get());
+      Ref parent = repo.exactRef(c.getDest().branch());
       if (parent != null) {
         cb.parent(tr.getRevWalk().parseCommit(parent.getObjectId()));
       }
-      update.setBranch(c.getDest().get());
+      update.setBranch(c.getDest().branch());
       update.setChangeId(c.getKey().get());
       update.setCommit(tr.getRevWalk(), cb.create());
       return update;
@@ -129,7 +129,7 @@
   public static void incrementPatchSet(Change change) {
     PatchSet.Id curr = change.currentPatchSetId();
     PatchSetInfo ps =
-        new PatchSetInfo(new PatchSet.Id(change.getId(), curr != null ? curr.get() + 1 : 1));
+        new PatchSetInfo(PatchSet.id(change.getId(), curr != null ? curr.get() + 1 : 1));
     ps.setSubject("Change subject");
     change.setCurrentPatchSet(ps);
   }
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
new file mode 100644
index 0000000..b72cca7
--- /dev/null
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.testing;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.inject.Inject;
+import java.util.Collection;
+
+/** Test helper for dealing with comments/drafts. */
+public class TestCommentHelper {
+  private final GerritApi gApi;
+
+  @Inject
+  public TestCommentHelper(GerritApi gerritApi) {
+    gApi = gerritApi;
+  }
+
+  public DraftInput newDraft(String message) {
+    return populate(new DraftInput(), "file", message);
+  }
+
+  public DraftInput newDraft(String path, Side side, int line, String message) {
+    DraftInput d = new DraftInput();
+    return populate(d, path, side, line, message);
+  }
+
+  public void addDraft(String changeId, String revId, DraftInput in) throws Exception {
+    gApi.changes().id(changeId).revision(revId).createDraft(in).get();
+  }
+
+  public Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).comments().values().stream()
+        .flatMap(Collection::stream)
+        .collect(toList());
+  }
+
+  public static <C extends Comment> C populate(C c, String path, String message) {
+    return populate(c, path, createLineRange(), message);
+  }
+
+  private static <C extends Comment> C populate(C c, String path, Range range, String message) {
+    int line = range.startLine;
+    c.path = path;
+    c.side = Side.REVISION;
+    c.parent = null;
+    c.line = line != 0 ? line : null;
+    c.message = message;
+    c.unresolved = false;
+    if (line != 0) c.range = range;
+    return c;
+  }
+
+  private static <C extends Comment> C populate(
+      C c, String path, Side side, Range range, String message) {
+    int line = range.startLine;
+    c.path = path;
+    c.side = side;
+    c.parent = null;
+    c.line = line != 0 ? line : null;
+    c.message = message;
+    c.unresolved = false;
+    if (line != 0) c.range = range;
+    return c;
+  }
+
+  private static <C extends Comment> C populate(
+      C c, String path, Side side, int line, String message) {
+    return populate(c, path, side, createLineRange(line), message);
+  }
+
+  private static Range createLineRange() {
+    Range range = new Range();
+    range.startLine = 0;
+    range.startCharacter = 1;
+    range.endLine = 0;
+    range.endCharacter = 5;
+    return range;
+  }
+
+  private static Range createLineRange(int line) {
+    Range range = new Range();
+    range.startLine = line;
+    range.startCharacter = 1;
+    range.endLine = line;
+    range.endCharacter = 5;
+    return range;
+  }
+}
diff --git a/java/com/google/gerrit/testing/TestTimeUtil.java b/java/com/google/gerrit/testing/TestTimeUtil.java
index 1233810..2020e5d 100644
--- a/java/com/google/gerrit/testing/TestTimeUtil.java
+++ b/java/com/google/gerrit/testing/TestTimeUtil.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.time.LocalDateTime;
@@ -51,7 +51,7 @@
   }
 
   /**
-   * Set the clock step used by {@link com.google.gerrit.common.TimeUtil}.
+   * Set the clock step used by {@link com.google.gerrit.server.util.time.TimeUtil}.
    *
    * @param clockStep amount to increment clock by on each lookup.
    * @param clockStepUnit time unit for {@code clockStep}.
@@ -118,6 +118,15 @@
     clockMs.addAndGet(clockStepUnit.toMillis(clockStep));
   }
 
+  /**
+   * Returns the current timestamp.
+   *
+   * @return current timestamp
+   */
+  public static synchronized Timestamp getCurrentTimestamp() {
+    return new Timestamp(clockMs.get());
+  }
+
   /** Reset the clock to use the actual system clock. */
   public static synchronized void useSystemTime() {
     clockMs = null;
diff --git a/java/com/google/gerrit/testing/TestUpdateUI.java b/java/com/google/gerrit/testing/TestUpdateUI.java
index f36fc7e..76671fb 100644
--- a/java/com/google/gerrit/testing/TestUpdateUI.java
+++ b/java/com/google/gerrit/testing/TestUpdateUI.java
@@ -15,9 +15,6 @@
 package com.google.gerrit.testing;
 
 import com.google.gerrit.server.schema.UpdateUI;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import java.util.List;
 import java.util.Set;
 
 public class TestUpdateUI implements UpdateUI {
@@ -41,11 +38,4 @@
   public boolean isBatch() {
     return true;
   }
-
-  @Override
-  public void pruneSchema(StatementExecutor e, List<String> pruneList) throws OrmException {
-    for (String sql : pruneList) {
-      e.execute(sql);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/truth/BUILD b/java/com/google/gerrit/truth/BUILD
index 719ddce..4727da1 100644
--- a/java/com/google/gerrit/truth/BUILD
+++ b/java/com/google/gerrit/truth/BUILD
@@ -1,10 +1,12 @@
 java_library(
     name = "truth",
-    testonly = 1,
+    testonly = True,
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/truth/CacheStatsSubject.java b/java/com/google/gerrit/truth/CacheStatsSubject.java
new file mode 100644
index 0000000..ff94334
--- /dev/null
+++ b/java/com/google/gerrit/truth/CacheStatsSubject.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.truth;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.cache.CacheStats;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+
+@UsedAt(Project.PLUGINS_ALL)
+public class CacheStatsSubject extends Subject {
+  public static CacheStatsSubject assertThat(CacheStats stats) {
+    return assertAbout(CacheStatsSubject::new).that(stats);
+  }
+
+  public static CacheStats cloneStats(CacheStats other) {
+    return new CacheStats(
+        other.hitCount(),
+        other.missCount(),
+        other.loadSuccessCount(),
+        other.loadExceptionCount(),
+        other.totalLoadTime(),
+        other.evictionCount());
+  }
+
+  private final CacheStats stats;
+  private CacheStats start = new CacheStats(0, 0, 0, 0, 0, 0);
+
+  private CacheStatsSubject(FailureMetadata failureMetadata, CacheStats stats) {
+    super(failureMetadata, stats);
+    this.stats = stats;
+  }
+
+  public CacheStatsSubject since(CacheStats start) {
+    this.start = requireNonNull(start);
+    return this;
+  }
+
+  public void hasHitCount(int expectedHitCount) {
+    isNotNull();
+    check("hitCount()").that(stats.minus(start).hitCount()).isEqualTo(expectedHitCount);
+  }
+
+  public void hasMissCount(int expectedMissCount) {
+    isNotNull();
+    check("missCount()").that(stats.minus(start).missCount()).isEqualTo(expectedMissCount);
+  }
+}
diff --git a/java/com/google/gerrit/truth/ConfigSubject.java b/java/com/google/gerrit/truth/ConfigSubject.java
new file mode 100644
index 0000000..dd55b71
--- /dev/null
+++ b/java/com/google/gerrit/truth/ConfigSubject.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.truth;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.truth.BooleanSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.LongSubject;
+import com.google.common.truth.MultimapSubject;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.common.Nullable;
+import java.util.Arrays;
+import org.eclipse.jgit.lib.Config;
+
+public class ConfigSubject extends Subject {
+  public static ConfigSubject assertThat(Config config) {
+    return assertAbout(ConfigSubject::new).that(config);
+  }
+
+  private final Config config;
+
+  private ConfigSubject(FailureMetadata metadata, Config actual) {
+    super(metadata, actual);
+    this.config = actual;
+  }
+
+  public IterableSubject sections() {
+    isNotNull();
+    return check("getSections()").that(config.getSections());
+  }
+
+  public IterableSubject subsections(String section) {
+    requireNonNull(section);
+    isNotNull();
+    return check("getSubsections(%s)", section).that(config.getSubsections(section));
+  }
+
+  public MultimapSubject sectionValues(String section) {
+    requireNonNull(section);
+    return sectionValuesImpl(section, null);
+  }
+
+  public MultimapSubject subsectionValues(String section, String subsection) {
+    requireNonNull(section);
+    requireNonNull(subsection);
+    return sectionValuesImpl(section, subsection);
+  }
+
+  private MultimapSubject sectionValuesImpl(String section, @Nullable String subsection) {
+    isNotNull();
+    ImmutableListMultimap.Builder<String, String> b = ImmutableListMultimap.builder();
+    config
+        .getNames(section, subsection, true)
+        .forEach(
+            n ->
+                Arrays.stream(config.getStringList(section, subsection, n))
+                    .forEach(v -> b.put(n, v)));
+    return check("getSection(%s, %s)", section, subsection).that(b.build());
+  }
+
+  public void isEmpty() {
+    sections().isEmpty();
+  }
+
+  public StringSubject text() {
+    isNotNull();
+    return check("toText()").that(config.toText());
+  }
+
+  public IterableSubject stringValues(String section, @Nullable String subsection, String name) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getStringList(%s, %s, %s)", section, subsection, name)
+        .that(Arrays.asList(config.getStringList(section, subsection, name)));
+  }
+
+  public StringSubject stringValue(String section, @Nullable String subsection, String name) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getString(%s, %s, %s)", section, subsection, name)
+        .that(config.getString(section, subsection, name));
+  }
+
+  public IntegerSubject intValue(
+      String section, @Nullable String subsection, String name, int defaultValue) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getInt(%s, %s, %s, %s)", section, subsection, name, defaultValue)
+        .that(config.getInt(section, subsection, name, defaultValue));
+  }
+
+  public LongSubject longValue(String section, String subsection, String name, long defaultValue) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getLong(%s, %s, %s, %s)", section, subsection, name, defaultValue)
+        .that(config.getLong(section, subsection, name, defaultValue));
+  }
+
+  public BooleanSubject booleanValue(
+      String section, String subsection, String name, boolean defaultValue) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getBoolean(%s, %s, %s, %s)", section, subsection, name, defaultValue)
+        .that(config.getBoolean(section, subsection, name, defaultValue));
+  }
+}
diff --git a/java/com/google/gerrit/truth/ListSubject.java b/java/com/google/gerrit/truth/ListSubject.java
index cccf51b..9f93964 100644
--- a/java/com/google/gerrit/truth/ListSubject.java
+++ b/java/com/google/gerrit/truth/ListSubject.java
@@ -15,82 +15,77 @@
 package com.google.gerrit.truth;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
 
+import com.google.common.collect.Iterables;
+import com.google.common.truth.CustomSubjectBuilder;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IterableSubject;
+import com.google.common.truth.StandardSubjectBuilder;
 import com.google.common.truth.Subject;
 import java.util.List;
-import java.util.function.Function;
+import java.util.function.BiFunction;
 
-public class ListSubject<S extends Subject<S, E>, E> extends IterableSubject {
+public class ListSubject<S extends Subject, E> extends IterableSubject {
 
-  private final Function<E, S> elementAssertThatFunction;
+  private final List<E> list;
+  private final BiFunction<StandardSubjectBuilder, ? super E, ? extends S> elementSubjectCreator;
 
-  @SuppressWarnings("unchecked")
-  public static <S extends Subject<S, E>, E> ListSubject<S, E> assertThat(
-      List<E> list, Function<E, S> elementAssertThatFunction) {
-    // The ListSubjectFactory always returns ListSubjects. -> Casting is appropriate.
-    return (ListSubject<S, E>)
-        assertAbout(new ListSubjectFactory<>(elementAssertThatFunction)).that(list);
+  public static <S extends Subject, E> ListSubject<S, E> assertThat(
+      List<E> list, Subject.Factory<? extends S, ? super E> subjectFactory) {
+    return assertAbout(elements()).thatCustom(list, subjectFactory);
+  }
+
+  public static CustomSubjectBuilder.Factory<ListSubjectBuilder> elements() {
+    return ListSubjectBuilder::new;
   }
 
   private ListSubject(
-      FailureMetadata failureMetadata, List<E> list, Function<E, S> elementAssertThatFunction) {
+      FailureMetadata failureMetadata,
+      List<E> list,
+      BiFunction<StandardSubjectBuilder, ? super E, ? extends S> elementSubjectCreator) {
     super(failureMetadata, list);
-    this.elementAssertThatFunction = elementAssertThatFunction;
+    this.list = list;
+    this.elementSubjectCreator = elementSubjectCreator;
   }
 
   public S element(int index) {
     checkArgument(index >= 0, "index(%s) must be >= 0", index);
     isNotNull();
-    List<E> list = getActualList();
     if (index >= list.size()) {
-      fail("has an element at index " + index);
+      failWithoutActual(fact("expected to have element at index", index));
     }
-    return elementAssertThatFunction.apply(list.get(index));
+    return elementSubjectCreator.apply(check("element(%s)", index), list.get(index));
   }
 
   public S onlyElement() {
     isNotNull();
     hasSize(1);
-    return element(0);
+    return elementSubjectCreator.apply(check("onlyElement()"), Iterables.getOnlyElement(list));
   }
 
   public S lastElement() {
     isNotNull();
     isNotEmpty();
-    List<E> list = getActualList();
-    return element(list.size() - 1);
+    return elementSubjectCreator.apply(check("lastElement()"), Iterables.getLast(list));
   }
 
-  @SuppressWarnings("unchecked")
-  private List<E> getActualList() {
-    // The constructor only accepts lists. -> Casting is appropriate.
-    return (List<E>) actual();
-  }
+  public static class ListSubjectBuilder extends CustomSubjectBuilder {
 
-  @SuppressWarnings("unchecked")
-  @Override
-  public ListSubject<S, E> named(String s, Object... objects) {
-    // This object is returned which is of type ListSubject. -> Casting is appropriate.
-    return (ListSubject<S, E>) super.named(s, objects);
-  }
-
-  private static class ListSubjectFactory<S extends Subject<S, T>, T>
-      implements Subject.Factory<IterableSubject, Iterable<?>> {
-
-    private Function<T, S> elementAssertThatFunction;
-
-    ListSubjectFactory(Function<T, S> elementAssertThatFunction) {
-      this.elementAssertThatFunction = elementAssertThatFunction;
+    ListSubjectBuilder(FailureMetadata failureMetadata) {
+      super(failureMetadata);
     }
 
-    @SuppressWarnings("unchecked")
-    @Override
-    public ListSubject<S, T> createSubject(FailureMetadata failureMetadata, Iterable<?> objects) {
-      // The constructor of ListSubject only accepts lists. -> Casting is appropriate.
-      return new ListSubject<>(failureMetadata, (List<T>) objects, elementAssertThatFunction);
+    public <S extends Subject, E> ListSubject<S, E> thatCustom(
+        List<E> list, Subject.Factory<? extends S, ? super E> subjectFactory) {
+      return that(list, (builder, element) -> builder.about(subjectFactory).that(element));
+    }
+
+    public <S extends Subject, E> ListSubject<S, E> that(
+        List<E> list,
+        BiFunction<StandardSubjectBuilder, ? super E, ? extends S> elementSubjectCreator) {
+      return new ListSubject<>(metadata(), list, elementSubjectCreator);
     }
   }
 }
diff --git a/java/com/google/gerrit/truth/OptionalSubject.java b/java/com/google/gerrit/truth/OptionalSubject.java
index f24b5da..2023765 100644
--- a/java/com/google/gerrit/truth/OptionalSubject.java
+++ b/java/com/google/gerrit/truth/OptionalSubject.java
@@ -14,58 +14,54 @@
 
 package com.google.gerrit.truth;
 
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.DefaultSubject;
+import com.google.common.truth.CustomSubjectBuilder;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StandardSubjectBuilder;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import java.util.Optional;
-import java.util.function.Function;
+import java.util.function.BiFunction;
 
-public class OptionalSubject<S extends Subject<S, ? super T>, T>
-    extends Subject<OptionalSubject<S, T>, Optional<T>> {
+public class OptionalSubject<S extends Subject, T> extends Subject {
 
-  private final Function<? super T, ? extends S> valueAssertThatFunction;
+  private final Optional<T> optional;
+  private final BiFunction<StandardSubjectBuilder, ? super T, ? extends S> valueSubjectCreator;
 
-  public static <S extends Subject<S, ? super T>, T> OptionalSubject<S, T> assertThat(
-      Optional<T> optional, Function<? super T, ? extends S> elementAssertThatFunction) {
-    OptionalSubjectFactory<S, T> optionalSubjectFactory =
-        new OptionalSubjectFactory<>(elementAssertThatFunction);
-    return assertAbout(optionalSubjectFactory).that(optional);
+  public static <S extends Subject, T> OptionalSubject<S, T> assertThat(
+      Optional<T> optional, Subject.Factory<? extends S, ? super T> valueSubjectFactory) {
+    return assertAbout(optionals()).thatCustom(optional, valueSubjectFactory);
   }
 
-  public static OptionalSubject<DefaultSubject, ?> assertThat(Optional<?> optional) {
-    // Unfortunately, we need to cast to DefaultSubject as Truth.assertThat()
-    // only returns Subject<DefaultSubject, Object>. There shouldn't be a way
-    // for that method not to return a DefaultSubject because the generic type
-    // definitions of a Subject are quite strict.
-    Function<Object, DefaultSubject> valueAssertThatFunction =
-        value -> (DefaultSubject) Truth.assertThat(value);
-    return assertThat(optional, valueAssertThatFunction);
+  public static OptionalSubject<Subject, ?> assertThat(Optional<?> optional) {
+    return assertAbout(optionals()).that(optional);
+  }
+
+  public static CustomSubjectBuilder.Factory<OptionalSubjectBuilder> optionals() {
+    return OptionalSubjectBuilder::new;
   }
 
   private OptionalSubject(
       FailureMetadata failureMetadata,
       Optional<T> optional,
-      Function<? super T, ? extends S> valueAssertThatFunction) {
+      BiFunction<StandardSubjectBuilder, ? super T, ? extends S> valueSubjectCreator) {
     super(failureMetadata, optional);
-    this.valueAssertThatFunction = valueAssertThatFunction;
+    this.optional = optional;
+    this.valueSubjectCreator = valueSubjectCreator;
   }
 
   public void isPresent() {
     isNotNull();
-    Optional<T> optional = actual();
     if (!optional.isPresent()) {
-      fail("has a value");
+      failWithoutActual(fact("expected to have", "value"));
     }
   }
 
   public void isAbsent() {
     isNotNull();
-    Optional<T> optional = actual();
     if (optional.isPresent()) {
-      fail("does not have a value");
+      failWithoutActual(fact("expected not to have", "value"));
     }
   }
 
@@ -76,23 +72,28 @@
   public S value() {
     isNotNull();
     isPresent();
-    Optional<T> optional = actual();
-    return valueAssertThatFunction.apply(optional.get());
+    return valueSubjectCreator.apply(check("value()"), optional.get());
   }
 
-  private static class OptionalSubjectFactory<S extends Subject<S, ? super T>, T>
-      implements Subject.Factory<OptionalSubject<S, T>, Optional<T>> {
+  public static class OptionalSubjectBuilder extends CustomSubjectBuilder {
 
-    private Function<? super T, ? extends S> valueAssertThatFunction;
-
-    OptionalSubjectFactory(Function<? super T, ? extends S> valueAssertThatFunction) {
-      this.valueAssertThatFunction = valueAssertThatFunction;
+    OptionalSubjectBuilder(FailureMetadata failureMetadata) {
+      super(failureMetadata);
     }
 
-    @Override
-    public OptionalSubject<S, T> createSubject(
-        FailureMetadata failureMetadata, Optional<T> optional) {
-      return new OptionalSubject<>(failureMetadata, optional, valueAssertThatFunction);
+    public <S extends Subject, T> OptionalSubject<S, T> thatCustom(
+        Optional<T> optional, Subject.Factory<? extends S, ? super T> valueSubjectFactory) {
+      return that(optional, (builder, value) -> builder.about(valueSubjectFactory).that(value));
+    }
+
+    public OptionalSubject<Subject, ?> that(Optional<?> optional) {
+      return that(optional, StandardSubjectBuilder::that);
+    }
+
+    public <S extends Subject, T> OptionalSubject<S, T> that(
+        Optional<T> optional,
+        BiFunction<StandardSubjectBuilder, ? super T, ? extends S> valueSubjectCreator) {
+      return new OptionalSubject<>(metadata(), optional, valueSubjectCreator);
     }
   }
 }
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index 91c14f7..b9b9bba 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -7,6 +7,8 @@
         "//java/com/google/gerrit/common:server",
         "//lib:args4j",
         "//lib:guava",
+        "//lib/auto:auto-value-annotations",
+        "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
     ],
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index f2d07ed..1c430fc 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -34,21 +34,26 @@
 
 package com.google.gerrit.util.cli;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.util.cli.Localizable.localizable;
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.base.Strings;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
+import com.google.common.flogger.FluentLogger;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.StringWriter;
 import java.io.Writer;
-import java.lang.annotation.Annotation;
-import java.lang.reflect.AnnotatedElement;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.ResourceBundle;
@@ -59,9 +64,9 @@
 import org.kohsuke.args4j.NamedOptionDef;
 import org.kohsuke.args4j.Option;
 import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.ParserProperties;
 import org.kohsuke.args4j.spi.BooleanOptionHandler;
 import org.kohsuke.args4j.spi.EnumOptionHandler;
-import org.kohsuke.args4j.spi.FieldSetter;
 import org.kohsuke.args4j.spi.MethodSetter;
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Setter;
@@ -75,10 +80,95 @@
  * 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);
   }
 
+  /**
+   * This may be used by an option handler during parsing to "call" additional parameters simulating
+   * as if they had been passed from the command line originally.
+   *
+   * <p>To call additional parameters from within an option handler, instantiate this class with the
+   * parameters and then call callParameters() with the additional parameters to be parsed.
+   * OptionHandlers may optionally pass this class to other methods which may then both
+   * parse/consume more parameters and call additional parameters.
+   */
+  public static class Parameters implements org.kohsuke.args4j.spi.Parameters {
+    protected final String[] args;
+    protected MyParser parser;
+    protected int consumed = 0;
+
+    public Parameters(org.kohsuke.args4j.spi.Parameters args, MyParser parser)
+        throws CmdLineException {
+      this.args = new String[args.size()];
+      for (int i = 0; i < args.size(); i++) {
+        this.args[i] = args.getParameter(i);
+      }
+      this.parser = parser;
+    }
+
+    public Parameters(String[] args, MyParser parser) {
+      this.args = args;
+      this.parser = parser;
+    }
+
+    @Override
+    public String getParameter(int idx) throws CmdLineException {
+      return args[idx];
+    }
+
+    /**
+     * get and consume (consider parsed) a parameter
+     *
+     * @return the consumed parameter
+     */
+    public String consumeParameter() throws CmdLineException {
+      return getParameter(consumed++);
+    }
+
+    @Override
+    public int size() {
+      return args.length;
+    }
+
+    /**
+     * Add 'count' to the value of parsed parameters. May be called more than once.
+     *
+     * @param count How many parameters were just parsed.
+     */
+    public void consume(int count) {
+      consumed += count;
+    }
+
+    /**
+     * Reports handlers how many parameters were parsed
+     *
+     * @return the count of parsed parameters
+     */
+    public int getConsumed() {
+      return consumed;
+    }
+
+    /**
+     * Use during parsing to call additional parameters simulating as if they had been passed from
+     * the command line originally.
+     *
+     * @param args A variable amount of parameters to call immediately
+     *     <p>The parameters will be parsed immediately, before the remaining parameter will be
+     *     parsed.
+     *     <p>Note: Since this is done outside of the arg4j parsing loop, it will not match exactly
+     *     what would happen if they were actually passed from the command line, but it will be
+     *     pretty close. If this were moved to args4j, the interface could be the same and it could
+     *     match exactly the behavior as if passed from the command line originally.
+     */
+    public void callParameters(String... args) throws CmdLineException {
+      Parameters impl = new Parameters(Arrays.copyOfRange(args, 1, args.length), parser);
+      parser.findOptionByName(args[0]).parseArguments(impl);
+    }
+  }
+
   private final OptionHandlers handlers;
   private final MyParser parser;
 
@@ -194,7 +284,7 @@
   }
 
   public boolean wasHelpRequestedByOption() {
-    return parser.help.value;
+    return parser.help;
   }
 
   public void parseArgument(String... args) throws CmdLineException {
@@ -222,22 +312,13 @@
     parser.parseArgument(tmp.toArray(new String[tmp.size()]));
   }
 
-  public void parseOptionMap(Map<String, String[]> parameters) throws CmdLineException {
-    ListMultimap<String, String> map = MultimapBuilder.hashKeys().arrayListValues().build();
-    for (Map.Entry<String, String[]> ent : parameters.entrySet()) {
-      for (String val : ent.getValue()) {
-        map.put(ent.getKey(), val);
-      }
-    }
-    parseOptionMap(map);
-  }
-
   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);
 
-      if (isBoolean(name)) {
+      if (isBooleanOption(name)) {
         boolean on = false;
         for (String value : params.get(key)) {
           on = toBoolean(key, value);
@@ -255,14 +336,18 @@
     parser.parseArgument(tmp.toArray(new String[tmp.size()]));
   }
 
-  public boolean isBoolean(String name) {
-    return findHandler(makeOption(name)) instanceof BooleanOptionHandler;
-  }
-
   public void parseWithPrefix(String prefix, Object bean) {
     parser.parseWithPrefix(prefix, bean);
   }
 
+  public void drainOptionQueue() {
+    parser.addOptionsWithMetRequirements();
+  }
+
+  private boolean isBooleanOption(String name) {
+    return findHandler(makeOption(name)) instanceof BooleanOptionHandler;
+  }
+
   private String makeOption(String name) {
     if (!name.startsWith("-")) {
       if (name.length() == 1) {
@@ -320,85 +405,85 @@
       return false;
     }
 
-    throw new CmdLineException(parser, String.format("invalid boolean \"%s=%s\"", name, value));
+    throw new CmdLineException(parser, localizable("invalid boolean \"%s=%s\""), name, value);
   }
 
-  private static class PrefixedOption implements Option {
-    String prefix;
-    Option o;
-
-    PrefixedOption(String prefix, Option o) {
-      this.prefix = prefix;
-      this.o = o;
-    }
-
-    @Override
-    public String name() {
-      return getPrefixedName(prefix, o.name());
-    }
-
-    @Override
-    public String[] aliases() {
-      String[] prefixedAliases = new String[o.aliases().length];
-      for (int i = 0; i < prefixedAliases.length; i++) {
-        prefixedAliases[i] = getPrefixedName(prefix, o.aliases()[i]);
-      }
-      return prefixedAliases;
-    }
-
-    @Override
-    public String usage() {
-      return o.usage();
-    }
-
-    @Override
-    public String metaVar() {
-      return o.metaVar();
-    }
-
-    @Override
-    public boolean required() {
-      return o.required();
-    }
-
-    @Override
-    public boolean hidden() {
-      return o.hidden();
-    }
-
-    @SuppressWarnings("rawtypes")
-    @Override
-    public Class<? extends OptionHandler> handler() {
-      return o.handler();
-    }
-
-    @Override
-    public String[] depends() {
-      return o.depends();
-    }
-
-    @Override
-    public Class<? extends Annotation> annotationType() {
-      return o.annotationType();
-    }
-
-    private static String getPrefixedName(String prefix, String name) {
-      return prefix + name;
-    }
+  private static Option newPrefixedOption(String prefix, Option o) {
+    requireNonNull(prefix);
+    checkArgument(o.name().startsWith("-"), "Option name must start with '-': %s", o);
+    String[] aliases = Arrays.stream(o.aliases()).map(prefix::concat).toArray(String[]::new);
+    return OptionUtil.newOption(
+        prefix + o.name(),
+        aliases,
+        o.usage(),
+        o.metaVar(),
+        o.required(),
+        false,
+        o.hidden(),
+        o.handler(),
+        o.depends(),
+        new String[0]);
   }
 
-  private class MyParser extends org.kohsuke.args4j.CmdLineParser {
+  public class MyParser extends org.kohsuke.args4j.CmdLineParser {
+    boolean help;
+
     @SuppressWarnings("rawtypes")
     private List<OptionHandler> optionsList;
 
-    private HelpOption help;
+    private Map<String, QueuedOption> queuedOptionsByName = new LinkedHashMap<>();
+
+    private class QueuedOption {
+      public final Option option;
+
+      @SuppressWarnings("rawtypes")
+      public final Setter setter;
+
+      public final String[] requiredOptions;
+
+      private QueuedOption(
+          Option option,
+          @SuppressWarnings("rawtypes") Setter setter,
+          RequiresOptions requiresOptions) {
+        this.option = option;
+        this.setter = setter;
+        this.requiredOptions = requiresOptions != null ? requiresOptions.value() : new String[0];
+      }
+    }
 
     MyParser(Object bean) {
-      super(bean);
+      super(bean, ParserProperties.defaults().withAtSyntax(false));
       parseAdditionalOptions(bean, new HashSet<>());
+      addOptionsWithMetRequirements();
       ensureOptionsInitialized();
     }
 
+    public int addOptionsWithMetRequirements() {
+      int count = 0;
+      for (Iterator<Map.Entry<String, QueuedOption>> it = queuedOptionsByName.entrySet().iterator();
+          it.hasNext(); ) {
+        QueuedOption queuedOption = it.next().getValue();
+        if (hasAllRequiredOptions(queuedOption)) {
+          addOption(queuedOption.setter, queuedOption.option);
+          it.remove();
+          count++;
+        }
+      }
+      if (count > 0) {
+        count += addOptionsWithMetRequirements();
+      }
+      return count;
+    }
+
+    private boolean hasAllRequiredOptions(QueuedOption queuedOption) {
+      for (String name : queuedOption.requiredOptions) {
+        if (findOptionByName(name) == null) {
+          return false;
+        }
+      }
+      return true;
+    }
+
     // NOTE: Argument annotations on bean are ignored.
     public void parseWithPrefix(String prefix, Object bean) {
       parseWithPrefix(prefix, bean, new HashSet<>());
@@ -413,13 +498,19 @@
         for (Method m : c.getDeclaredMethods()) {
           Option o = m.getAnnotation(Option.class);
           if (o != null) {
-            addOption(new MethodSetter(this, bean, m), new PrefixedOption(prefix, o));
+            queueOption(
+                newPrefixedOption(prefix, o),
+                new MethodSetter(this, bean, m),
+                m.getAnnotation(RequiresOptions.class));
           }
         }
         for (Field f : c.getDeclaredFields()) {
           Option o = f.getAnnotation(Option.class);
           if (o != null) {
-            addOption(Setters.create(f, bean), new PrefixedOption(prefix, o));
+            queueOption(
+                newPrefixedOption(prefix, o),
+                Setters.create(f, bean),
+                f.getAnnotation(RequiresOptions.class));
           }
           if (f.isAnnotationPresent(Options.class)) {
             try {
@@ -437,7 +528,7 @@
       for (Class<?> c = bean.getClass(); c != null; c = c.getSuperclass()) {
         for (Field f : c.getDeclaredFields()) {
           if (f.isAnnotationPresent(Options.class)) {
-            Object additionalBean = null;
+            Object additionalBean;
             try {
               additionalBean = f.get(bean);
             } catch (IllegalAccessException e) {
@@ -463,6 +554,41 @@
       return add(super.createOptionHandler(option, setter));
     }
 
+    /**
+     * Finds a registered {@code OptionHandler} by its name or its alias.
+     *
+     * @param name name
+     * @return the {@code OptionHandler} or {@code null}
+     *     <p>Note: this is cut & pasted from the parent class in arg4j, it was private and it
+     *     needed to be exposed.
+     */
+    @SuppressWarnings("rawtypes")
+    public OptionHandler findOptionByName(String name) {
+      for (OptionHandler h : optionsList) {
+        NamedOptionDef option = (NamedOptionDef) h.option;
+        if (name.equals(option.name())) {
+          return h;
+        }
+        for (String alias : option.aliases()) {
+          if (name.equals(alias)) {
+            return h;
+          }
+        }
+      }
+      return null;
+    }
+
+    private void queueOption(
+        Option option,
+        @SuppressWarnings("rawtypes") Setter setter,
+        RequiresOptions requiresOptions) {
+      if (queuedOptionsByName.put(option.name(), new QueuedOption(option, setter, requiresOptions))
+          != null) {
+        throw new IllegalAnnotationError(
+            "Option name " + option.name() + " is used more than once");
+      }
+    }
+
     @SuppressWarnings("rawtypes")
     private OptionHandler add(OptionHandler handler) {
       ensureOptionsInitialized();
@@ -472,12 +598,33 @@
 
     private void ensureOptionsInitialized() {
       if (optionsList == null) {
-        help = new HelpOption();
         optionsList = new ArrayList<>();
-        addOption(help, help);
+        addOption(newHelpSetter(), newHelpOption());
       }
     }
 
+    private Setter<?> newHelpSetter() {
+      try {
+        return Setters.create(getClass().getDeclaredField("help"), this);
+      } catch (NoSuchFieldException e) {
+        throw new IllegalStateException(e);
+      }
+    }
+
+    private Option newHelpOption() {
+      return OptionUtil.newOption(
+          "--help",
+          new String[] {"-h"},
+          "display this help text",
+          "",
+          false,
+          false,
+          false,
+          BooleanOptionHandler.class,
+          new String[0],
+          new String[0]);
+    }
+
     private boolean isHandlerSpecified(OptionDef option) {
       return option.handler() != OptionHandler.class;
     }
@@ -491,81 +638,7 @@
     }
   }
 
-  private static class HelpOption implements Option, Setter<Boolean> {
-    private boolean value;
-
-    @Override
-    public String name() {
-      return "--help";
-    }
-
-    @Override
-    public String[] aliases() {
-      return new String[] {"-h"};
-    }
-
-    @Override
-    public String[] depends() {
-      return new String[] {};
-    }
-
-    @Override
-    public boolean hidden() {
-      return false;
-    }
-
-    @Override
-    public String usage() {
-      return "display this help text";
-    }
-
-    @Override
-    public void addValue(Boolean val) {
-      value = val;
-    }
-
-    @Override
-    public Class<? extends OptionHandler<Boolean>> handler() {
-      return BooleanOptionHandler.class;
-    }
-
-    @Override
-    public String metaVar() {
-      return "";
-    }
-
-    @Override
-    public boolean required() {
-      return false;
-    }
-
-    @Override
-    public Class<? extends Annotation> annotationType() {
-      return Option.class;
-    }
-
-    @Override
-    public FieldSetter asFieldSetter() {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public AnnotatedElement asAnnotatedElement() {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public Class<Boolean> getType() {
-      return Boolean.class;
-    }
-
-    @Override
-    public boolean isMultiValued() {
-      return false;
-    }
-  }
-
   public CmdLineException reject(String message) {
-    return new CmdLineException(parser, message);
+    return new CmdLineException(parser, localizable(message));
   }
 }
diff --git a/java/com/google/gerrit/util/cli/Localizable.java b/java/com/google/gerrit/util/cli/Localizable.java
new file mode 100644
index 0000000..33989d3
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/Localizable.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.util.cli;
+
+import java.util.Locale;
+
+public class Localizable implements org.kohsuke.args4j.Localizable {
+  private final String format;
+
+  @Override
+  public String formatWithLocale(Locale locale, Object... args) {
+    return String.format(locale, format, args);
+  }
+
+  @Override
+  public String format(Object... args) {
+    return String.format(format, args);
+  }
+
+  private Localizable(String format) {
+    this.format = format;
+  }
+
+  public static Localizable localizable(String format) {
+    return new Localizable(format);
+  }
+}
diff --git a/java/com/google/gerrit/util/cli/OptionHandlers.java b/java/com/google/gerrit/util/cli/OptionHandlers.java
index 84a0809..9547410 100644
--- a/java/com/google/gerrit/util/cli/OptionHandlers.java
+++ b/java/com/google/gerrit/util/cli/OptionHandlers.java
@@ -24,7 +24,7 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.ParameterizedType;
-import java.util.Map.Entry;
+import java.util.Map;
 
 @Singleton
 public class OptionHandlers {
@@ -53,7 +53,7 @@
   private static ImmutableMap<Class<?>, Provider<OptionHandlerFactory<?>>> build(Injector i) {
     ImmutableMap.Builder<Class<?>, Provider<OptionHandlerFactory<?>>> map = ImmutableMap.builder();
     for (; i != null; i = i.getParent()) {
-      for (Entry<Key<?>, Binding<?>> e : i.getBindings().entrySet()) {
+      for (Map.Entry<Key<?>, Binding<?>> e : i.getBindings().entrySet()) {
         TypeLiteral<?> type = e.getKey().getTypeLiteral();
         if (type.getRawType() == OptionHandlerFactory.class
             && e.getKey().getAnnotation() == null
diff --git a/java/com/google/gerrit/util/cli/OptionUtil.java b/java/com/google/gerrit/util/cli/OptionUtil.java
new file mode 100644
index 0000000..1125a0d
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/OptionUtil.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.util.cli;
+
+import com.google.auto.value.AutoAnnotation;
+import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.spi.OptionHandler;
+
+/** Utilities to support creating new {@link Option} instances. */
+public class OptionUtil {
+  @AutoAnnotation
+  @SuppressWarnings("rawtypes")
+  public static Option newOption(
+      String name,
+      String[] aliases,
+      String usage,
+      String metaVar,
+      boolean required,
+      boolean help,
+      boolean hidden,
+      Class<? extends OptionHandler> handler,
+      String[] depends,
+      String[] forbids) {
+    return new AutoAnnotation_OptionUtil_newOption(
+        name, aliases, usage, metaVar, required, help, hidden, handler, depends, forbids);
+  }
+}
diff --git a/java/com/google/gerrit/util/cli/RequiresOptions.java b/java/com/google/gerrit/util/cli/RequiresOptions.java
new file mode 100644
index 0000000..de6ba44
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/RequiresOptions.java
@@ -0,0 +1,45 @@
+// 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.util.cli;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a field/setter annotated with {@literal @}Option as having a dependency on multiple other
+ * command line option.
+ *
+ * <p>If any of the required command line options are not present, the {@literal @}Option will be
+ * ignored.
+ *
+ * <p>For example:
+ *
+ * <pre>
+ *   {@literal @}RequiresOptions({"--help", "--usage"})
+ *   {@literal @}Option(name = "--help-as-json",
+ *           usage = "display help text in json format")
+ *   public boolean displayHelpAsJson;
+ * </pre>
+ */
+@Retention(RUNTIME)
+@Target({FIELD, METHOD, PARAMETER})
+public @interface RequiresOptions {
+  String[] value();
+}
diff --git a/java/com/google/gerrit/util/http/RequestUtil.java b/java/com/google/gerrit/util/http/RequestUtil.java
index 2a359ca..92d9967 100644
--- a/java/com/google/gerrit/util/http/RequestUtil.java
+++ b/java/com/google/gerrit/util/http/RequestUtil.java
@@ -56,5 +56,43 @@
     return pathInfo;
   }
 
+  /**
+   * Trims leading '/' and 'a/'. Removes the context path, but keeps the servlet path. Removes all
+   * IDs from the rest of the URI.
+   *
+   * <p>The returned string is a good fit for cases where one wants the full context of the request
+   * without any identifiable data. For example: Logging or quota checks.
+   *
+   * <p>Examples:
+   *
+   * <ul>
+   *   <li>/a/accounts/self/detail => /accounts/detail
+   *   <li>/changes/123/revisions/current/detail => /changes/revisions/detail
+   *   <li>/changes/ => /changes
+   * </ul>
+   */
+  public static String getRestPathWithoutIds(HttpServletRequest req) {
+    String encodedPathInfo = req.getRequestURI().substring(req.getContextPath().length());
+    if (encodedPathInfo.startsWith("/")) {
+      encodedPathInfo = encodedPathInfo.substring(1);
+    }
+    if (encodedPathInfo.startsWith("a/")) {
+      encodedPathInfo = encodedPathInfo.substring(2);
+    }
+
+    String[] parts = encodedPathInfo.split("/");
+    StringBuilder result = new StringBuilder(parts.length);
+    for (int i = 0; i < parts.length; i = i + 2) {
+      result.append("/");
+      result.append(parts[i]);
+    }
+    return result.toString();
+  }
+
+  public static boolean acceptsGzipEncoding(HttpServletRequest request) {
+    String accepts = request.getHeader("Accept-Encoding");
+    return accepts != null && accepts.indexOf("gzip") != -1;
+  }
+
   private RequestUtil() {}
 }
diff --git a/java/com/google/gwtexpui/clippy/BUILD b/java/com/google/gwtexpui/clippy/BUILD
deleted file mode 100644
index 80b6767..0000000
--- a/java/com/google/gwtexpui/clippy/BUILD
+++ /dev/null
@@ -1,23 +0,0 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-gwt_module(
-    name = "clippy",
-    srcs = glob(["client/*.java"]),
-    data = [
-        "//lib:LICENSE-clippy",
-        "//lib:LICENSE-silk_icons",
-    ],
-    gwt_xml = "Clippy.gwt.xml",
-    resources = [
-        "client/CopyableLabelText.properties",
-        "client/clippy.css",
-        "client/clippy.swf",
-        "client/page_white_copy.png",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gwtexpui/safehtml",
-        "//java/com/google/gwtexpui/user:agent",
-        "//lib/gwt:user-neverlink",
-    ],
-)
diff --git a/java/com/google/gwtexpui/clippy/Clippy.gwt.xml b/java/com/google/gwtexpui/clippy/Clippy.gwt.xml
deleted file mode 100644
index 0e9b072..0000000
--- a/java/com/google/gwtexpui/clippy/Clippy.gwt.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name='com.google.gwt.resources.Resources'/>
-  <inherits name="com.google.gwtexpui.safehtml.SafeHtml"/>
-  <inherits name="com.google.gwtexpui.user.User"/>
-</module>
diff --git a/java/com/google/gwtexpui/clippy/client/ClippyCss.java b/java/com/google/gwtexpui/clippy/client/ClippyCss.java
deleted file mode 100644
index 0d340ff..0000000
--- a/java/com/google/gwtexpui/clippy/client/ClippyCss.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.clippy.client;
-
-import com.google.gwt.resources.client.CssResource;
-
-public interface ClippyCss extends CssResource {
-  String label();
-
-  String copier();
-
-  String swf();
-}
diff --git a/java/com/google/gwtexpui/clippy/client/ClippyResources.java b/java/com/google/gwtexpui/clippy/client/ClippyResources.java
deleted file mode 100644
index a97b392..0000000
--- a/java/com/google/gwtexpui/clippy/client/ClippyResources.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.clippy.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.DataResource;
-import com.google.gwt.resources.client.DataResource.DoNotEmbed;
-import com.google.gwt.resources.client.ImageResource;
-
-public interface ClippyResources extends ClientBundle {
-  ClippyResources I = GWT.create(ClippyResources.class);
-
-  @Source("clippy.css")
-  ClippyCss css();
-
-  @Source("clippy.swf")
-  @DoNotEmbed
-  DataResource swf();
-
-  @Source("page_white_copy.png")
-  ImageResource clipboard();
-}
diff --git a/java/com/google/gwtexpui/clippy/client/CopyableLabel.java b/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
deleted file mode 100644
index 7b70c6a..0000000
--- a/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
+++ /dev/null
@@ -1,315 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.clippy.client;
-
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Display;
-import com.google.gwt.event.dom.client.BlurEvent;
-import com.google.gwt.event.dom.client.BlurHandler;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.dom.client.KeyUpEvent;
-import com.google.gwt.event.dom.client.KeyUpHandler;
-import com.google.gwt.event.dom.client.MouseOutEvent;
-import com.google.gwt.event.dom.client.MouseOutHandler;
-import com.google.gwt.http.client.URL;
-import com.google.gwt.user.client.Command;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HasText;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.TextBox;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import com.google.gwtexpui.user.client.Tooltip;
-import com.google.gwtexpui.user.client.UserAgent;
-
-/**
- * Label which permits the user to easily copy the complete content.
- *
- * <p>If the Flash plugin is available a "movie" is embedded that provides one-click copying of the
- * content onto the system clipboard. The label (if visible) can also be clicked, switching from a
- * label to an input box, allowing the user to copy the text with a keyboard shortcut.
- */
-public class CopyableLabel extends Composite implements HasText {
-  private static final int SWF_WIDTH = 110;
-  private static final int SWF_HEIGHT = 14;
-  private static boolean flashEnabled = true;
-
-  static {
-    ClippyResources.I.css().ensureInjected();
-  }
-
-  public static boolean isFlashEnabled() {
-    return flashEnabled;
-  }
-
-  public static void setFlashEnabled(boolean on) {
-    flashEnabled = on;
-  }
-
-  private static String swfUrl() {
-    return ClippyResources.I.swf().getSafeUri().asString();
-  }
-
-  private final FlowPanel content;
-  private String text;
-  private int visibleLen;
-  private Label textLabel;
-  private TextBox textBox;
-  private Button copier;
-  private Element swf;
-
-  public CopyableLabel() {
-    this("");
-  }
-
-  /**
-   * Create a new label
-   *
-   * @param str initial content
-   */
-  public CopyableLabel(String str) {
-    this(str, true);
-  }
-
-  /**
-   * Create a new label
-   *
-   * @param str initial content
-   * @param showLabel if true, the content is shown, if false it is hidden from view and only the
-   *     copy icon is displayed.
-   */
-  public CopyableLabel(String str, boolean showLabel) {
-    content = new FlowPanel();
-    initWidget(content);
-
-    text = str;
-    visibleLen = text.length();
-
-    if (showLabel) {
-      textLabel = new InlineLabel(getText());
-      textLabel.setStyleName(ClippyResources.I.css().label());
-      textLabel.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              showTextBox();
-            }
-          });
-      content.add(textLabel);
-    }
-
-    if (UserAgent.hasJavaScriptClipboard()) {
-      copier =
-          new Button(
-              new SafeHtmlBuilder()
-                  .openElement("img")
-                  .setAttribute("src", ClippyResources.I.clipboard().getSafeUri().asString())
-                  .setWidth(14)
-                  .setHeight(14)
-                  .closeSelf());
-      copier.setStyleName(ClippyResources.I.css().copier());
-      Tooltip.addStyle(copier);
-      Tooltip.setLabel(copier, CopyableLabelText.I.tooltip());
-      copier.addClickHandler(
-          new ClickHandler() {
-            @Override
-            public void onClick(ClickEvent event) {
-              copy();
-            }
-          });
-      copier.addMouseOutHandler(
-          new MouseOutHandler() {
-            @Override
-            public void onMouseOut(MouseOutEvent event) {
-              Tooltip.setLabel(copier, CopyableLabelText.I.tooltip());
-            }
-          });
-
-      FlowPanel p = new FlowPanel();
-      p.getElement().getStyle().setDisplay(Display.INLINE_BLOCK);
-      p.add(copier);
-      content.add(p);
-    } else {
-      embedMovie();
-    }
-  }
-
-  /**
-   * Change the text which is displayed in the clickable label.
-   *
-   * @param text the new preview text, should be shorter than the original text which would be
-   *     copied to the clipboard.
-   */
-  public void setPreviewText(String text) {
-    if (textLabel != null) {
-      textLabel.setText(text);
-    }
-  }
-
-  private void embedMovie() {
-    if (copier == null && flashEnabled && !text.isEmpty() && UserAgent.Flash.isInstalled()) {
-      final String flashVars = "text=" + URL.encodeQueryString(getText());
-      final SafeHtmlBuilder h = new SafeHtmlBuilder();
-
-      h.openElement("div");
-      h.setStyleName(ClippyResources.I.css().swf());
-
-      h.openElement("object");
-      h.setWidth(SWF_WIDTH);
-      h.setHeight(SWF_HEIGHT);
-      h.setAttribute("classid", "clsid:d27cdb6e-ae6d-11cf-96b8-444553540000");
-      h.paramElement("movie", swfUrl());
-      h.paramElement("FlashVars", flashVars);
-
-      h.openElement("embed");
-      h.setWidth(SWF_WIDTH);
-      h.setHeight(SWF_HEIGHT);
-      h.setAttribute("wmode", "transparent");
-      h.setAttribute("type", "application/x-shockwave-flash");
-      h.setAttribute("src", swfUrl());
-      h.setAttribute("FlashVars", flashVars);
-      h.closeSelf();
-
-      h.closeElement("object");
-      h.closeElement("div");
-
-      if (swf != null) {
-        getElement().removeChild(swf);
-      }
-      DOM.appendChild(getElement(), swf = SafeHtml.parse(h));
-    }
-  }
-
-  @Override
-  public String getText() {
-    return text;
-  }
-
-  @Override
-  public void setText(String newText) {
-    text = newText;
-    visibleLen = newText.length();
-
-    if (textLabel != null) {
-      textLabel.setText(getText());
-    }
-    if (textBox != null) {
-      textBox.setText(getText());
-      textBox.selectAll();
-    }
-    embedMovie();
-  }
-
-  private void showTextBox() {
-    if (textBox == null) {
-      textBox = new TextBox();
-      textBox.setText(getText());
-      textBox.setVisibleLength(visibleLen);
-      textBox.setReadOnly(true);
-      textBox.addKeyPressHandler(
-          new KeyPressHandler() {
-            @Override
-            public void onKeyPress(KeyPressEvent event) {
-              if (event.isControlKeyDown() || event.isMetaKeyDown()) {
-                switch (event.getCharCode()) {
-                  case 'c':
-                  case 'x':
-                    textBox.addKeyUpHandler(
-                        new KeyUpHandler() {
-                          @Override
-                          public void onKeyUp(KeyUpEvent event) {
-                            Scheduler.get()
-                                .scheduleDeferred(
-                                    new Command() {
-                                      @Override
-                                      public void execute() {
-                                        hideTextBox();
-                                      }
-                                    });
-                          }
-                        });
-                    break;
-                }
-              }
-            }
-          });
-      textBox.addBlurHandler(
-          new BlurHandler() {
-            @Override
-            public void onBlur(BlurEvent event) {
-              hideTextBox();
-            }
-          });
-      content.insert(textBox, 1);
-    }
-
-    textLabel.setVisible(false);
-    textBox.setVisible(true);
-    Scheduler.get()
-        .scheduleDeferred(
-            new Command() {
-              @Override
-              public void execute() {
-                textBox.selectAll();
-                textBox.setFocus(true);
-              }
-            });
-  }
-
-  private void hideTextBox() {
-    if (textBox != null) {
-      textBox.removeFromParent();
-      textBox = null;
-    }
-    textLabel.setVisible(true);
-  }
-
-  private void copy() {
-    TextBox t = new TextBox();
-    try {
-      t.setText(getText());
-      content.add(t);
-      t.setFocus(true);
-      t.selectAll();
-
-      boolean ok = execCommand("copy");
-      Tooltip.setLabel(copier, ok ? CopyableLabelText.I.copied() : CopyableLabelText.I.failed());
-      if (!ok) {
-        // Disable JavaScript clipboard and try flash movie in another instance.
-        UserAgent.disableJavaScriptClipboard();
-      }
-    } finally {
-      t.removeFromParent();
-    }
-  }
-
-  private static boolean execCommand(String command) {
-    try {
-      return nativeExec(command);
-    } catch (Exception e) {
-      return false;
-    }
-  }
-
-  private static native boolean nativeExec(String c) /*-{ return !! $doc.execCommand(c) }-*/;
-}
diff --git a/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java b/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
deleted file mode 100644
index ff36541..0000000
--- a/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.clippy.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.i18n.client.Constants;
-
-interface CopyableLabelText extends Constants {
-  CopyableLabelText I = GWT.create(CopyableLabelText.class);
-
-  String tooltip();
-
-  String copied();
-
-  String failed();
-}
diff --git a/java/com/google/gwtexpui/clippy/client/CopyableLabelText.properties b/java/com/google/gwtexpui/clippy/client/CopyableLabelText.properties
deleted file mode 100644
index cf93bfa..0000000
--- a/java/com/google/gwtexpui/clippy/client/CopyableLabelText.properties
+++ /dev/null
@@ -1,3 +0,0 @@
-tooltip = Copy to clipboard
-copied = Copied
-failed = Failed !
diff --git a/java/com/google/gwtexpui/clippy/client/clippy.css b/java/com/google/gwtexpui/clippy/client/clippy.css
deleted file mode 100644
index b25e006..0000000
--- a/java/com/google/gwtexpui/clippy/client/clippy.css
+++ /dev/null
@@ -1,39 +0,0 @@
-/* Copyright (C) 2009 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-.label {
-  vertical-align: top;
-}
-.swf, .copier {
-  margin-left: 5px;
-  height: 14px;
-  width: 14px;
-}
-.swf {
-  display: inline-block !important;
-  overflow: hidden;
-}
-.copier {
-  display: inline-block;
-  font-size: 12px;
-  vertical-align: top;
-  padding: 0;
-  border: 0;
-  background-color: inherit;
-  cursor: pointer;
-}
-.copier:focus {
-  outline: none;
-}
diff --git a/java/com/google/gwtexpui/clippy/client/clippy.swf b/java/com/google/gwtexpui/clippy/client/clippy.swf
deleted file mode 100644
index e46886c..0000000
--- a/java/com/google/gwtexpui/clippy/client/clippy.swf
+++ /dev/null
Binary files differ
diff --git a/java/com/google/gwtexpui/clippy/client/page_white_copy.png b/java/com/google/gwtexpui/clippy/client/page_white_copy.png
deleted file mode 100644
index a9f31a2..0000000
--- a/java/com/google/gwtexpui/clippy/client/page_white_copy.png
+++ /dev/null
Binary files differ
diff --git a/java/com/google/gwtexpui/css/BUILD b/java/com/google/gwtexpui/css/BUILD
deleted file mode 100644
index 6c2fc71..0000000
--- a/java/com/google/gwtexpui/css/BUILD
+++ /dev/null
@@ -1,9 +0,0 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-java_library(
-    name = "css",
-    srcs = glob(["rebind/*.java"]),
-    resources = ["CSS.gwt.xml"],
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:dev"],
-)
diff --git a/java/com/google/gwtexpui/css/CSS.gwt.xml b/java/com/google/gwtexpui/css/CSS.gwt.xml
deleted file mode 100644
index b385987..0000000
--- a/java/com/google/gwtexpui/css/CSS.gwt.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <define-linker name='cachecss' class='com.google.gwtexpui.css.rebind.CssLinker'/>
-  <add-linker name='cachecss'/>
-</module>
diff --git a/java/com/google/gwtexpui/css/rebind/CssLinker.java b/java/com/google/gwtexpui/css/rebind/CssLinker.java
deleted file mode 100644
index 6ef5d7b..0000000
--- a/java/com/google/gwtexpui/css/rebind/CssLinker.java
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.css.rebind;
-
-import com.google.gwt.core.ext.LinkerContext;
-import com.google.gwt.core.ext.TreeLogger;
-import com.google.gwt.core.ext.UnableToCompleteException;
-import com.google.gwt.core.ext.linker.AbstractLinker;
-import com.google.gwt.core.ext.linker.Artifact;
-import com.google.gwt.core.ext.linker.ArtifactSet;
-import com.google.gwt.core.ext.linker.LinkerOrder;
-import com.google.gwt.core.ext.linker.PublicResource;
-import com.google.gwt.core.ext.linker.impl.StandardLinkerContext;
-import com.google.gwt.core.ext.linker.impl.StandardStylesheetReference;
-import com.google.gwt.dev.util.Util;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.HashMap;
-
-@LinkerOrder(LinkerOrder.Order.PRE)
-public class CssLinker extends AbstractLinker {
-  @Override
-  public String getDescription() {
-    return "CssLinker";
-  }
-
-  @Override
-  public ArtifactSet link(final TreeLogger logger, LinkerContext context, ArtifactSet artifacts)
-      throws UnableToCompleteException {
-    final ArtifactSet returnTo = new ArtifactSet();
-    int index = 0;
-
-    final HashMap<String, PublicResource> css = new HashMap<>();
-
-    for (StandardStylesheetReference ssr :
-        artifacts.<StandardStylesheetReference>find(StandardStylesheetReference.class)) {
-      css.put(ssr.getSrc(), null);
-    }
-    for (PublicResource pr : artifacts.<PublicResource>find(PublicResource.class)) {
-      if (css.containsKey(pr.getPartialPath())) {
-        css.put(pr.getPartialPath(), new CssPubRsrc(name(logger, pr), pr));
-      }
-    }
-
-    for (Artifact<?> a : artifacts) {
-      if (a instanceof PublicResource) {
-        final PublicResource r = (PublicResource) a;
-        if (css.containsKey(r.getPartialPath())) {
-          a = css.get(r.getPartialPath());
-        }
-      } else if (a instanceof StandardStylesheetReference) {
-        final StandardStylesheetReference r = (StandardStylesheetReference) a;
-        final PublicResource p = css.get(r.getSrc());
-        a = new StandardStylesheetReference(p.getPartialPath(), index);
-      }
-
-      returnTo.add(a);
-      index++;
-    }
-    return returnTo;
-  }
-
-  private String name(TreeLogger logger, PublicResource r) throws UnableToCompleteException {
-    byte[] out;
-    try (ByteArrayOutputStream tmp = new ByteArrayOutputStream();
-        InputStream in = r.getContents(logger)) {
-      final byte[] buf = new byte[2048];
-      int n;
-      while ((n = in.read(buf)) >= 0) {
-        tmp.write(buf, 0, n);
-      }
-      out = tmp.toByteArray();
-    } catch (IOException e) {
-      final UnableToCompleteException ute = new UnableToCompleteException();
-      ute.initCause(e);
-      throw ute;
-    }
-
-    String base = r.getPartialPath();
-    final int s = base.lastIndexOf('/');
-    if (0 < s) {
-      base = base.substring(0, s + 1);
-    } else {
-      base = "";
-    }
-    return base + Util.computeStrongName(out) + ".cache.css";
-  }
-
-  private static class CssPubRsrc extends PublicResource {
-    private static final long serialVersionUID = 1L;
-    private final PublicResource src;
-
-    CssPubRsrc(String partialPath, PublicResource r) {
-      super(StandardLinkerContext.class, partialPath);
-      src = r;
-    }
-
-    @Override
-    public InputStream getContents(TreeLogger logger) throws UnableToCompleteException {
-      return src.getContents(logger);
-    }
-
-    @Override
-    public long getLastModified() {
-      return src.getLastModified();
-    }
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/BUILD b/java/com/google/gwtexpui/globalkey/BUILD
deleted file mode 100644
index c637194..0000000
--- a/java/com/google/gwtexpui/globalkey/BUILD
+++ /dev/null
@@ -1,17 +0,0 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-gwt_module(
-    name = "globalkey",
-    srcs = glob(["client/*.java"]),
-    gwt_xml = "GlobalKey.gwt.xml",
-    resources = [
-        "client/KeyConstants.properties",
-        "client/key.css",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gwtexpui/safehtml",
-        "//java/com/google/gwtexpui/user:agent",
-        "//lib/gwt:user",
-    ],
-)
diff --git a/java/com/google/gwtexpui/globalkey/GlobalKey.gwt.xml b/java/com/google/gwtexpui/globalkey/GlobalKey.gwt.xml
deleted file mode 100644
index 771050f..0000000
--- a/java/com/google/gwtexpui/globalkey/GlobalKey.gwt.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name='com.google.gwt.resources.Resources'/>
-  <inherits name='com.google.gwtexpui.user.User'/>
-  <inherits name='com.google.gwtexpui.safehtml.SafeHtml'/>
-</module>
diff --git a/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java b/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java
deleted file mode 100644
index 5a4f6aa..0000000
--- a/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.event.dom.client.KeyPressEvent;
-
-public final class CompoundKeyCommand extends KeyCommand {
-  final KeyCommandSet set;
-
-  public CompoundKeyCommand(int mask, char key, String help, KeyCommandSet s) {
-    super(mask, key, help);
-    set = s;
-  }
-
-  public CompoundKeyCommand(int mask, int key, String help, KeyCommandSet s) {
-    super(mask, key, help);
-    set = s;
-  }
-
-  public KeyCommandSet getSet() {
-    return set;
-  }
-
-  @Override
-  public void onKeyPress(KeyPressEvent event) {
-    GlobalKey.temporaryWithTimeout(set);
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/DocWidget.java b/java/com/google/gwtexpui/globalkey/client/DocWidget.java
deleted file mode 100644
index 320010e..0000000
--- a/java/com/google/gwtexpui/globalkey/client/DocWidget.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.dom.client.Document;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Node;
-import com.google.gwt.event.dom.client.HasKeyPressHandlers;
-import com.google.gwt.event.dom.client.HasMouseMoveHandlers;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.dom.client.MouseMoveEvent;
-import com.google.gwt.event.dom.client.MouseMoveHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.ui.RootPanel;
-import com.google.gwt.user.client.ui.Widget;
-
-public class DocWidget extends Widget implements HasKeyPressHandlers, HasMouseMoveHandlers {
-  private static DocWidget me;
-
-  public static DocWidget get() {
-    if (me == null) {
-      me = new DocWidget();
-    }
-    return me;
-  }
-
-  private DocWidget() {
-    setElement((Element) docnode());
-    onAttach();
-    RootPanel.detachOnWindowClose(this);
-  }
-
-  @Override
-  public HandlerRegistration addKeyPressHandler(KeyPressHandler handler) {
-    return addDomHandler(handler, KeyPressEvent.getType());
-  }
-
-  @Override
-  public HandlerRegistration addMouseMoveHandler(MouseMoveHandler handler) {
-    return addDomHandler(handler, MouseMoveEvent.getType());
-  }
-
-  private static Node docnode() {
-    return Document.get();
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/GlobalKey.java b/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
deleted file mode 100644
index cbaca61..0000000
--- a/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
+++ /dev/null
@@ -1,191 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.event.dom.client.DomEvent;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.Timer;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
-
-public class GlobalKey {
-  public static final KeyPressHandler STOP_PROPAGATION = DomEvent::stopPropagation;
-
-  private static State global;
-  static State active;
-  private static CloseHandler<PopupPanel> restoreGlobal;
-  private static Timer restoreTimer;
-
-  static {
-    KeyResources.I.css().ensureInjected();
-  }
-
-  private static void initEvents() {
-    if (active == null) {
-      DocWidget.get()
-          .addKeyPressHandler(
-              new KeyPressHandler() {
-                @Override
-                public void onKeyPress(KeyPressEvent event) {
-                  final KeyCommandSet s = active.live;
-                  if (s != active.all) {
-                    active.live = active.all;
-                    restoreTimer.cancel();
-                  }
-                  s.onKeyPress(event);
-                }
-              });
-
-      restoreTimer =
-          new Timer() {
-            @Override
-            public void run() {
-              active.live = active.all;
-            }
-          };
-
-      global = new State(null);
-      active = global;
-    }
-  }
-
-  private static void initDialog() {
-    if (restoreGlobal == null) {
-      restoreGlobal =
-          new CloseHandler<PopupPanel>() {
-            @Override
-            public void onClose(CloseEvent<PopupPanel> event) {
-              active = global;
-            }
-          };
-    }
-  }
-
-  static void temporaryWithTimeout(KeyCommandSet s) {
-    active.live = s;
-    restoreTimer.schedule(250);
-  }
-
-  public static void dialog(PopupPanel panel) {
-    initEvents();
-    initDialog();
-    assert panel.isShowing();
-    assert active == global;
-    active = new State(panel);
-    active.add(new HidePopupPanelCommand(0, KeyCodes.KEY_ESCAPE, panel));
-    panel.addCloseHandler(restoreGlobal);
-    panel.addDomHandler(
-        new KeyDownHandler() {
-          @Override
-          public void onKeyDown(KeyDownEvent event) {
-            if (event.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
-              panel.hide();
-            }
-          }
-        },
-        KeyDownEvent.getType());
-  }
-
-  public static HandlerRegistration addApplication(Widget widget, KeyCommand appKey) {
-    initEvents();
-    final State state = stateFor(widget);
-    state.add(appKey);
-    return new HandlerRegistration() {
-      @Override
-      public void removeHandler() {
-        state.remove(appKey);
-      }
-    };
-  }
-
-  public static HandlerRegistration add(Widget widget, KeyCommandSet cmdSet) {
-    initEvents();
-    final State state = stateFor(widget);
-    state.add(cmdSet);
-    return new HandlerRegistration() {
-      @Override
-      public void removeHandler() {
-        state.remove(cmdSet);
-      }
-    };
-  }
-
-  private static State stateFor(Widget w) {
-    while (w != null) {
-      if (w == active.root) {
-        return active;
-      }
-      w = w.getParent();
-    }
-    return global;
-  }
-
-  public static void filter(KeyCommandFilter filter) {
-    active.filter(filter);
-    if (active != global) {
-      global.filter(filter);
-    }
-  }
-
-  private GlobalKey() {}
-
-  static class State {
-    final Widget root;
-    final KeyCommandSet app;
-    final KeyCommandSet all;
-    KeyCommandSet live;
-
-    State(Widget r) {
-      root = r;
-
-      app = new KeyCommandSet(KeyConstants.I.applicationSection());
-      app.add(ShowHelpCommand.INSTANCE);
-
-      all = new KeyCommandSet();
-      all.add(app);
-
-      live = all;
-    }
-
-    void add(KeyCommand k) {
-      app.add(k);
-      all.add(k);
-    }
-
-    void remove(KeyCommand k) {
-      app.remove(k);
-      all.remove(k);
-    }
-
-    void add(KeyCommandSet s) {
-      all.add(s);
-    }
-
-    void remove(KeyCommandSet s) {
-      all.remove(s);
-    }
-
-    void filter(KeyCommandFilter f) {
-      all.filter(f);
-    }
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java b/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java
deleted file mode 100644
index 8222f8b..0000000
--- a/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.user.client.ui.PopupPanel;
-
-/** Hides the given popup panel when invoked. */
-public class HidePopupPanelCommand extends KeyCommand {
-  private final PopupPanel panel;
-
-  public HidePopupPanelCommand(int mask, int key, PopupPanel panel) {
-    super(mask, key, KeyConstants.I.closeCurrentDialog());
-    this.panel = panel;
-  }
-
-  @Override
-  public void onKeyPress(KeyPressEvent event) {
-    panel.hide();
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyCommand.java b/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
deleted file mode 100644
index f1c92e0..0000000
--- a/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-public abstract class KeyCommand implements KeyPressHandler {
-  public static final int M_CTRL = 1 << 16;
-  public static final int M_ALT = 2 << 16;
-  public static final int M_META = 4 << 16;
-  public static final int M_SHIFT = 8 << 16;
-
-  public static boolean same(KeyCommand a, KeyCommand b) {
-    return a.getClass() == b.getClass() && a.helpText.equals(b.helpText) && a.sibling == b.sibling;
-  }
-
-  final int keyMask;
-  private final String helpText;
-  KeyCommand sibling;
-
-  public KeyCommand(int mask, int key, String help) {
-    this(mask, (char) key, help);
-  }
-
-  public KeyCommand(int mask, char key, String help) {
-    assert help != null;
-    keyMask = mask | key;
-    helpText = help;
-  }
-
-  public String getHelpText() {
-    return helpText;
-  }
-
-  SafeHtml describeKeyStroke() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-
-    if ((keyMask & M_CTRL) == M_CTRL) {
-      modifier(b, KeyConstants.I.keyCtrl());
-    }
-    if ((keyMask & M_ALT) == M_ALT) {
-      modifier(b, KeyConstants.I.keyAlt());
-    }
-    if ((keyMask & M_META) == M_META) {
-      modifier(b, KeyConstants.I.keyMeta());
-    }
-    if ((keyMask & M_SHIFT) == M_SHIFT) {
-      modifier(b, KeyConstants.I.keyShift());
-    }
-
-    final char c = (char) (keyMask & 0xffff);
-    switch (c) {
-      case KeyCodes.KEY_ENTER:
-        namedKey(b, KeyConstants.I.keyEnter());
-        break;
-      case KeyCodes.KEY_ESCAPE:
-        namedKey(b, KeyConstants.I.keyEsc());
-        break;
-      case KeyCodes.KEY_LEFT:
-        namedKey(b, KeyConstants.I.keyLeft());
-        break;
-      case KeyCodes.KEY_RIGHT:
-        namedKey(b, KeyConstants.I.keyRight());
-        break;
-      default:
-        b.openSpan();
-        b.setStyleName(KeyResources.I.css().helpKey());
-        b.append(String.valueOf(c));
-        b.closeSpan();
-        break;
-    }
-
-    return b;
-  }
-
-  private void modifier(SafeHtmlBuilder b, String name) {
-    namedKey(b, name);
-    b.append(" + ");
-  }
-
-  private void namedKey(SafeHtmlBuilder b, String name) {
-    b.append('<');
-    b.openSpan();
-    b.setStyleName(KeyResources.I.css().helpKey());
-    b.append(name);
-    b.closeSpan();
-    b.append(">");
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java b/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
deleted file mode 100644
index 4b67260..0000000
--- a/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-public interface KeyCommandFilter {
-  boolean include(KeyCommand key);
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java b/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
deleted file mode 100644
index 90aa419..0000000
--- a/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-
-public class KeyCommandSet implements KeyPressHandler {
-  private final Map<Integer, KeyCommand> map;
-  private List<KeyCommandSet> sets;
-  private String name;
-
-  public KeyCommandSet() {
-    this("");
-  }
-
-  public KeyCommandSet(String setName) {
-    map = new HashMap<>();
-    name = setName;
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String setName) {
-    assert setName != null;
-    name = setName;
-  }
-
-  public boolean isEmpty() {
-    return map.isEmpty();
-  }
-
-  public void add(KeyCommand a, KeyCommand b) {
-    add(a);
-    add(b);
-    pair(a, b);
-  }
-
-  public void pair(KeyCommand a, KeyCommand b) {
-    a.sibling = b;
-    b.sibling = a;
-  }
-
-  public void add(KeyCommand k) {
-    assert !map.containsKey(k.keyMask)
-        : "Key " + k.describeKeyStroke().asString() + " already registered";
-    if (!map.containsKey(k.keyMask)) {
-      map.put(k.keyMask, k);
-    }
-  }
-
-  public void remove(KeyCommand k) {
-    assert map.get(k.keyMask) == k;
-    map.remove(k.keyMask);
-  }
-
-  public void add(KeyCommandSet set) {
-    if (sets == null) {
-      sets = new ArrayList<>();
-    }
-    assert !sets.contains(set);
-    sets.add(set);
-    for (KeyCommand k : set.map.values()) {
-      add(k);
-    }
-  }
-
-  public void remove(KeyCommandSet set) {
-    assert sets != null;
-    assert sets.contains(set);
-    sets.remove(set);
-    for (KeyCommand k : set.map.values()) {
-      remove(k);
-    }
-  }
-
-  public void filter(KeyCommandFilter filter) {
-    if (sets != null) {
-      for (KeyCommandSet s : sets) {
-        s.filter(filter);
-      }
-    }
-    for (Iterator<KeyCommand> i = map.values().iterator(); i.hasNext(); ) {
-      final KeyCommand kc = i.next();
-      if (!filter.include(kc)) {
-        i.remove();
-      } else if (kc instanceof CompoundKeyCommand) {
-        ((CompoundKeyCommand) kc).set.filter(filter);
-      }
-    }
-  }
-
-  public Collection<KeyCommand> getKeys() {
-    return map.values();
-  }
-
-  public Collection<KeyCommandSet> getSets() {
-    return sets != null ? sets : Collections.<KeyCommandSet>emptyList();
-  }
-
-  @Override
-  public void onKeyPress(KeyPressEvent event) {
-    final KeyCommand k = map.get(toMask(event));
-    if (k != null) {
-      event.preventDefault();
-      event.stopPropagation();
-      k.onKeyPress(event);
-    }
-  }
-
-  static int toMask(KeyPressEvent event) {
-    int mask = event.getUnicodeCharCode();
-    if (mask == 0) {
-      mask = event.getNativeEvent().getKeyCode();
-    }
-    if (event.isControlKeyDown()) {
-      mask |= KeyCommand.M_CTRL;
-    }
-    if (event.isMetaKeyDown()) {
-      mask |= KeyCommand.M_META;
-    }
-    return mask;
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyConstants.java b/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
deleted file mode 100644
index 209b170..0000000
--- a/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.i18n.client.Constants;
-
-public interface KeyConstants extends Constants {
-  KeyConstants I = GWT.create(KeyConstants.class);
-
-  String applicationSection();
-
-  String showHelp();
-
-  String closeCurrentDialog();
-
-  String keyboardShortcuts();
-
-  String closeButton();
-
-  String orOtherKey();
-
-  String thenOtherKey();
-
-  String keyCtrl();
-
-  String keyAlt();
-
-  String keyMeta();
-
-  String keyShift();
-
-  String keyEnter();
-
-  String keyEsc();
-
-  String keyLeft();
-
-  String keyRight();
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyConstants.properties b/java/com/google/gwtexpui/globalkey/client/KeyConstants.properties
deleted file mode 100644
index 76a0318..0000000
--- a/java/com/google/gwtexpui/globalkey/client/KeyConstants.properties
+++ /dev/null
@@ -1,17 +0,0 @@
-applicationSection = Application
-showHelp = Open shortcut help
-closeCurrentDialog = Close current dialog
-
-keyboardShortcuts = Keyboard Shortcuts
-closeButton = Close
-orOtherKey = or
-thenOtherKey = then
-
-keyCtrl = Ctrl
-keyAlt = Alt
-keyMeta = Meta
-keyShift = Shift
-keyEnter = Enter
-keyEsc = Esc
-keyLeft = Left
-keyRight = Right
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyCss.java b/java/com/google/gwtexpui/globalkey/client/KeyCss.java
deleted file mode 100644
index 658af57..0000000
--- a/java/com/google/gwtexpui/globalkey/client/KeyCss.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.resources.client.CssResource;
-
-public interface KeyCss extends CssResource {
-  String helpPopup();
-
-  String helpHeader();
-
-  String helpHeaderGlue();
-
-  String helpTable();
-
-  String helpTableGlue();
-
-  String helpGroup();
-
-  String helpKeyStroke();
-
-  String helpSeparator();
-
-  String helpKey();
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java b/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
deleted file mode 100644
index 1318125..0000000
--- a/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
+++ /dev/null
@@ -1,242 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.FocusPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.HasHorizontalAlignment;
-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;
-import java.util.Map;
-
-public class KeyHelpPopup extends PopupPanel implements KeyPressHandler, KeyDownHandler {
-  private final FocusPanel focus;
-
-  public KeyHelpPopup() {
-    super(true /* autohide */, true /* modal */);
-    setStyleName(KeyResources.I.css().helpPopup());
-
-    final Anchor closer = new Anchor(KeyConstants.I.closeButton());
-    closer.addClickHandler(
-        new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            hide();
-          }
-        });
-
-    final Grid header = new Grid(1, 3);
-    header.setStyleName(KeyResources.I.css().helpHeader());
-    header.setText(0, 0, KeyConstants.I.keyboardShortcuts());
-    header.setWidget(0, 2, closer);
-
-    final CellFormatter fmt = header.getCellFormatter();
-    fmt.addStyleName(0, 1, KeyResources.I.css().helpHeaderGlue());
-    fmt.setHorizontalAlignment(0, 2, HasHorizontalAlignment.ALIGN_RIGHT);
-
-    final Grid lists = new Grid(0, 7);
-    lists.setStyleName(KeyResources.I.css().helpTable());
-    populate(lists);
-    lists.getCellFormatter().addStyleName(0, 3, KeyResources.I.css().helpTableGlue());
-
-    final FlowPanel body = new FlowPanel();
-    body.add(header);
-    body.getElement().appendChild(DOM.createElement("hr"));
-    body.add(lists);
-
-    focus = new FocusPanel(body);
-    focus.getElement().getStyle().setProperty("outline", "0px");
-    focus.getElement().setAttribute("hideFocus", "true");
-    focus.addKeyPressHandler(this);
-    focus.addKeyDownHandler(this);
-    add(focus);
-  }
-
-  @Override
-  public void setVisible(boolean show) {
-    super.setVisible(show);
-    if (show) {
-      focus.setFocus(true);
-    }
-  }
-
-  @Override
-  public void onKeyPress(KeyPressEvent event) {
-    if (KeyCommandSet.toMask(event) == ShowHelpCommand.INSTANCE.keyMask) {
-      // Block the '?' key from triggering us to show right after
-      // we just hide ourselves.
-      //
-      event.stopPropagation();
-      event.preventDefault();
-    }
-    hide();
-  }
-
-  @Override
-  public void onKeyDown(KeyDownEvent event) {
-    if (event.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
-      hide();
-    }
-  }
-
-  private void populate(Grid lists) {
-    int[] end = new int[5];
-    int column = 0;
-    for (KeyCommandSet set : combinedSetsByName()) {
-      int row = end[column];
-      row = formatGroup(lists, row, column, set);
-      end[column] = row;
-      if (column == 0) {
-        column = 4;
-      } else {
-        column = 0;
-      }
-    }
-  }
-
-  /**
-   * @return an ordered collection of KeyCommandSet, combining sets which share the same name, so
-   *     that each set name appears at most once.
-   */
-  private static Collection<KeyCommandSet> combinedSetsByName() {
-    LinkedHashMap<String, KeyCommandSet> byName = new LinkedHashMap<>();
-    for (KeyCommandSet set : GlobalKey.active.all.getSets()) {
-      KeyCommandSet v = byName.get(set.getName());
-      if (v == null) {
-        v = new KeyCommandSet(set.getName());
-        byName.put(v.getName(), v);
-      }
-      v.add(set);
-    }
-    return byName.values();
-  }
-
-  private int formatGroup(Grid lists, int row, int col, KeyCommandSet set) {
-    if (set.isEmpty()) {
-      return row;
-    }
-
-    if (lists.getRowCount() < row + 1) {
-      lists.resizeRows(row + 1);
-    }
-    lists.setText(row, col + 2, set.getName());
-    lists.getCellFormatter().addStyleName(row, col + 2, KeyResources.I.css().helpGroup());
-    row++;
-
-    return formatKeys(lists, row, col, set, null);
-  }
-
-  private int formatKeys(final Grid lists, int row, int col, KeyCommandSet set, SafeHtml prefix) {
-    final CellFormatter fmt = lists.getCellFormatter();
-    final List<KeyCommand> keys = sort(set);
-    if (lists.getRowCount() < row + keys.size()) {
-      lists.resizeRows(row + keys.size());
-    }
-
-    Map<KeyCommand, Integer> rows = new HashMap<>();
-    FORMAT_KEYS:
-    for (int i = 0; i < keys.size(); i++) {
-      final KeyCommand k = keys.get(i);
-      if (rows.containsKey(k)) {
-        continue;
-      }
-
-      if (k instanceof CompoundKeyCommand) {
-        final SafeHtmlBuilder b = new SafeHtmlBuilder();
-        b.append(k.describeKeyStroke());
-        row = formatKeys(lists, row, col, ((CompoundKeyCommand) k).getSet(), b);
-        continue;
-      }
-
-      for (int prior = 0; prior < i; prior++) {
-        if (KeyCommand.same(keys.get(prior), k)) {
-          final int r = rows.get(keys.get(prior));
-          final SafeHtmlBuilder b = new SafeHtmlBuilder();
-          b.append(SafeHtml.get(lists, r, col + 0));
-          b.append(" ");
-          b.append(KeyConstants.I.orOtherKey());
-          b.append(" ");
-          if (prefix != null) {
-            b.append(prefix);
-            b.append(" ");
-            b.append(KeyConstants.I.thenOtherKey());
-            b.append(" ");
-          }
-          b.append(k.describeKeyStroke());
-          SafeHtml.set(lists, r, col + 0, b);
-          rows.put(k, r);
-          continue FORMAT_KEYS;
-        }
-      }
-
-      SafeHtmlBuilder b = new SafeHtmlBuilder();
-      String t = k.getHelpText();
-      if (prefix != null) {
-        b.append(prefix);
-        b.append(" ");
-        b.append(KeyConstants.I.thenOtherKey());
-        b.append(" ");
-      }
-      b.append(k.describeKeyStroke());
-      if (k.sibling != null) {
-        b.append(" / ").append(k.sibling.describeKeyStroke());
-        t += " / " + k.sibling.getHelpText();
-        rows.put(k.sibling, row);
-      }
-      SafeHtml.set(lists, row, col + 0, b);
-      lists.setText(row, col + 1, ":");
-      lists.setText(row, col + 2, t);
-      rows.put(k, row);
-
-      fmt.addStyleName(row, col + 0, KeyResources.I.css().helpKeyStroke());
-      fmt.addStyleName(row, col + 1, KeyResources.I.css().helpSeparator());
-      row++;
-    }
-
-    return row;
-  }
-
-  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;
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyResources.java b/java/com/google/gwtexpui/globalkey/client/KeyResources.java
deleted file mode 100644
index 562e12d..0000000
--- a/java/com/google/gwtexpui/globalkey/client/KeyResources.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-
-public interface KeyResources extends ClientBundle {
-  KeyResources I = GWT.create(KeyResources.class);
-
-  @Source("key.css")
-  KeyCss css();
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/NpTextArea.java b/java/com/google/gwtexpui/globalkey/client/NpTextArea.java
deleted file mode 100644
index fd0da74..0000000
--- a/java/com/google/gwtexpui/globalkey/client/NpTextArea.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.user.client.ui.TextArea;
-
-public class NpTextArea extends TextArea {
-  public NpTextArea() {
-    addKeyPressHandler(GlobalKey.STOP_PROPAGATION);
-  }
-
-  public void setSpellCheck(boolean spell) {
-    getElement().setPropertyBoolean("spellcheck", spell);
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/NpTextBox.java b/java/com/google/gwtexpui/globalkey/client/NpTextBox.java
deleted file mode 100644
index 1392675..0000000
--- a/java/com/google/gwtexpui/globalkey/client/NpTextBox.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.user.client.ui.TextBox;
-
-public class NpTextBox extends TextBox {
-  public NpTextBox() {
-    addKeyPressHandler(GlobalKey.STOP_PROPAGATION);
-  }
-
-  public NpTextBox(Element element) {
-    super(element);
-    addKeyPressHandler(GlobalKey.STOP_PROPAGATION);
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java b/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
deleted file mode 100644
index 08217f4..0000000
--- a/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.globalkey.client;
-
-import com.google.gwt.event.dom.client.FocusEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.logical.shared.CloseEvent;
-import com.google.gwt.event.logical.shared.CloseHandler;
-import com.google.gwt.event.shared.EventBus;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.event.shared.SimpleEventBus;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.PopupPanel.PositionCallback;
-
-public class ShowHelpCommand extends KeyCommand {
-  public static final ShowHelpCommand INSTANCE = new ShowHelpCommand();
-  private static final EventBus BUS = new SimpleEventBus();
-  private static KeyHelpPopup current;
-
-  public static HandlerRegistration addFocusHandler(FocusHandler fh) {
-    return BUS.addHandler(FocusEvent.getType(), fh);
-  }
-
-  public ShowHelpCommand() {
-    super(0, '?', KeyConstants.I.showHelp());
-  }
-
-  @Override
-  public void onKeyPress(KeyPressEvent event) {
-    if (current != null) {
-      // Already open? Close the dialog.
-      //
-      current.hide();
-      return;
-    }
-
-    final KeyHelpPopup help = new KeyHelpPopup();
-    help.addCloseHandler(
-        new CloseHandler<PopupPanel>() {
-          @Override
-          public void onClose(CloseEvent<PopupPanel> event) {
-            current = null;
-            BUS.fireEvent(new FocusEvent() {});
-          }
-        });
-    current = help;
-    help.setPopupPositionAndShow(
-        new PositionCallback() {
-          @Override
-          public void setPosition(int pWidth, int pHeight) {
-            final int left = (Window.getClientWidth() - pWidth) >> 1;
-            final int wLeft = Window.getScrollLeft();
-            final int wTop = Window.getScrollTop();
-            help.setPopupPosition(wLeft + left, wTop + 50);
-          }
-        });
-  }
-}
diff --git a/java/com/google/gwtexpui/globalkey/client/key.css b/java/com/google/gwtexpui/globalkey/client/key.css
deleted file mode 100644
index 755e686..0000000
--- a/java/com/google/gwtexpui/globalkey/client/key.css
+++ /dev/null
@@ -1,99 +0,0 @@
-/* Copyright (C) 2009 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-@external .popupContent;
-
-.helpPopup {
-  background: #000000 none repeat scroll 0 50%;
-  color: #ffffff;
-  font-family: arial,sans-serif;
-  font-weight: bold;
-  overflow: hidden;
-  text-align: left;
-  text-shadow: 1px 1px 7px #000000;
-  width: 92%;
-  z-index: 1002;
-  opacity: 0.85;
-  border-radius: 10px;
- }
-
-@if user.agent safari {
-  .helpPopup {
-    \-webkit-border-radius: 10px;
-  }
-}
-@if user.agent gecko1_8 {
-  .helpPopup {
-    \-moz-border-radius: 10px;
-  }
-}
-
-.helpPopup .popupContent {
-  margin: 10px;
-}
-
-.helpPopup hr {
-  width: 100%;
-}
-
-.helpHeader {
-  width: 100%;
-}
-
-.helpHeader td {
-  white-space: nowrap;
-  color: #ffffff;
-}
-
-.helpHeader a,
-.helpHeader a:visited,
-.helpHeader a:hover {
-  color: #dddd00;
-}
-
-.helpHeaderGlue {
-  width: 100%;
-}
-
-.helpTable {
-  width: 90%;
-}
-.helpTable td {
-  vertical-align: top;
-  white-space: nowrap;
-}
-
-.helpTableGlue {
-  width: 25px;
-}
-
-.helpGroup {
-  color: #dddd00;
-  text-align: left;
-}
-
-.helpKeyStroke {
-  text-align: right;
-}
-
-.helpSeparator {
-  width: 0.5em;
-  text-align: center;
-  font-weight: bold;
-}
-
-.helpKey {
-  color: #dddd00;
-}
diff --git a/java/com/google/gwtexpui/progress/BUILD b/java/com/google/gwtexpui/progress/BUILD
deleted file mode 100644
index 74caa57..0000000
--- a/java/com/google/gwtexpui/progress/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-gwt_module(
-    name = "progress",
-    srcs = glob(["client/*.java"]),
-    gwt_xml = "Progress.gwt.xml",
-    resources = ["client/progress.css"],
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:user"],
-)
diff --git a/java/com/google/gwtexpui/progress/Progress.gwt.xml b/java/com/google/gwtexpui/progress/Progress.gwt.xml
deleted file mode 100644
index 0df8928..0000000
--- a/java/com/google/gwtexpui/progress/Progress.gwt.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name='com.google.gwt.resources.Resources'/>
-  <inherits name="com.google.gwt.user.User"/>
-</module>
diff --git a/java/com/google/gwtexpui/progress/client/ProgressBar.java b/java/com/google/gwtexpui/progress/client/ProgressBar.java
deleted file mode 100644
index f133e4d..0000000
--- a/java/com/google/gwtexpui/progress/client/ProgressBar.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.progress.client;
-
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Label;
-
-/**
- * A simple progress bar with a text label.
- *
- * <p>The bar is 200 pixels wide and 20 pixels high. To keep the implementation simple and
- * lightweight this dimensions are fixed and shouldn't be modified by style overrides in client code
- * or CSS.
- */
-public class ProgressBar extends Composite {
-  static {
-    ProgressResources.I.css().ensureInjected();
-  }
-
-  private final String callerText;
-  private final Label bar;
-  private final Label msg;
-  private int value;
-
-  /** Create a bar with no message text. */
-  public ProgressBar() {
-    this("");
-  }
-
-  /** Create a bar displaying the specified message. */
-  public ProgressBar(String text) {
-    if (text == null || text.length() == 0) {
-      callerText = "";
-    } else {
-      callerText = text + " ";
-    }
-
-    final FlowPanel body = new FlowPanel();
-    body.setStyleName(ProgressResources.I.css().container());
-
-    msg = new Label(callerText);
-    msg.setStyleName(ProgressResources.I.css().text());
-    body.add(msg);
-
-    bar = new Label("");
-    bar.setStyleName(ProgressResources.I.css().bar());
-    body.add(bar);
-
-    initWidget(body);
-  }
-
-  /** @return the current value of the progress meter. */
-  public int getValue() {
-    return value;
-  }
-
-  /** Update the bar's percent completion. */
-  public void setValue(int pComplete) {
-    assert 0 <= pComplete && pComplete <= 100;
-    value = pComplete;
-    bar.setWidth(2 * pComplete + "px");
-    msg.setText(callerText + pComplete + "%");
-  }
-}
diff --git a/java/com/google/gwtexpui/progress/client/ProgressCss.java b/java/com/google/gwtexpui/progress/client/ProgressCss.java
deleted file mode 100644
index ec27490..0000000
--- a/java/com/google/gwtexpui/progress/client/ProgressCss.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.progress.client;
-
-import com.google.gwt.resources.client.CssResource;
-
-public interface ProgressCss extends CssResource {
-  String container();
-
-  String text();
-
-  String bar();
-}
diff --git a/java/com/google/gwtexpui/progress/client/ProgressResources.java b/java/com/google/gwtexpui/progress/client/ProgressResources.java
deleted file mode 100644
index 6bcf2c4..0000000
--- a/java/com/google/gwtexpui/progress/client/ProgressResources.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.progress.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-
-public interface ProgressResources extends ClientBundle {
-  ProgressResources I = GWT.create(ProgressResources.class);
-
-  @Source("progress.css")
-  ProgressCss css();
-}
diff --git a/java/com/google/gwtexpui/progress/client/progress.css b/java/com/google/gwtexpui/progress/client/progress.css
deleted file mode 100644
index 683396e..0000000
--- a/java/com/google/gwtexpui/progress/client/progress.css
+++ /dev/null
@@ -1,43 +0,0 @@
-/* Copyright (C) 2009 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-.container {
-  position: relative;
-  border: 1px solid #6B90DA;
-  height: 20px;
-  width: 200px;
-}
-
-.text {
-  position: absolute;
-  bottom: 0;
-  left: 0;
-  z-index: 2;
-  width: 200px;
-  padding-bottom: 3px;
-  text-align: center;
-  font-weight: bold;
-  font-style: italic;
-  font-size: smaller;
-}
-
-.bar {
-  background: #F0F7F9;
-  border-right: 1px solid #D0D7D9;
-  position: absolute;
-  top: 0;
-  left: 0;
-  height: 20px;
-}
diff --git a/java/com/google/gwtexpui/safehtml/BUILD b/java/com/google/gwtexpui/safehtml/BUILD
deleted file mode 100644
index af85c33..0000000
--- a/java/com/google/gwtexpui/safehtml/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-gwt_module(
-    name = "safehtml",
-    srcs = glob(["client/*.java"]),
-    gwt_xml = "SafeHtml.gwt.xml",
-    resources = ["client/safehtml.css"],
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:user"],
-)
diff --git a/java/com/google/gwtexpui/safehtml/SafeHtml.gwt.xml b/java/com/google/gwtexpui/safehtml/SafeHtml.gwt.xml
deleted file mode 100644
index 0df8928..0000000
--- a/java/com/google/gwtexpui/safehtml/SafeHtml.gwt.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name='com.google.gwt.resources.Resources'/>
-  <inherits name="com.google.gwt.user.User"/>
-</module>
diff --git a/java/com/google/gwtexpui/safehtml/client/AttMap.java b/java/com/google/gwtexpui/safehtml/client/AttMap.java
deleted file mode 100644
index c93a78b..0000000
--- a/java/com/google/gwtexpui/safehtml/client/AttMap.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-
-/** Lightweight map of names/values for element attribute construction. */
-class AttMap {
-  private static final Tag ANY = new AnyTag();
-  private static final HashMap<String, Tag> TAGS;
-
-  static {
-    final Tag src = new SrcTag();
-    TAGS = new HashMap<>();
-    TAGS.put("a", new AnchorTag());
-    TAGS.put("form", new FormTag());
-    TAGS.put("img", src);
-    TAGS.put("script", src);
-    TAGS.put("frame", src);
-  }
-
-  private final ArrayList<String> names = new ArrayList<>();
-  private final ArrayList<String> values = new ArrayList<>();
-
-  private Tag tag = ANY;
-  private int live;
-
-  void reset(String tagName) {
-    tag = TAGS.get(tagName.toLowerCase());
-    if (tag == null) {
-      tag = ANY;
-    }
-    live = 0;
-  }
-
-  void onto(Buffer raw, SafeHtmlBuilder esc) {
-    for (int i = 0; i < live; i++) {
-      final String v = values.get(i);
-      if (v.length() > 0) {
-        raw.append(" ");
-        raw.append(names.get(i));
-        raw.append("=\"");
-        esc.append(v);
-        raw.append("\"");
-      }
-    }
-  }
-
-  String get(String name) {
-    name = name.toLowerCase();
-
-    for (int i = 0; i < live; i++) {
-      if (name.equals(names.get(i))) {
-        return values.get(i);
-      }
-    }
-    return "";
-  }
-
-  void set(String name, String value) {
-    name = name.toLowerCase();
-    tag.assertSafe(name, value);
-
-    for (int i = 0; i < live; i++) {
-      if (name.equals(names.get(i))) {
-        values.set(i, value);
-        return;
-      }
-    }
-
-    final int i = live++;
-    if (names.size() < live) {
-      names.add(name);
-      values.add(value);
-    } else {
-      names.set(i, name);
-      values.set(i, value);
-    }
-  }
-
-  private static void assertNotJavascriptUrl(String value) {
-    if (value.startsWith("#")) {
-      // common in GWT, and safe, so bypass further checks
-
-    } else if (value.trim().toLowerCase().startsWith("javascript:")) {
-      // possibly unsafe, we could have random user code here
-      // we can't tell if its safe or not so we refuse to accept
-      //
-      throw new RuntimeException("javascript unsafe in href: " + value);
-    }
-  }
-
-  private interface Tag {
-    void assertSafe(String name, String value);
-  }
-
-  private static class AnyTag implements Tag {
-    @Override
-    public void assertSafe(String name, String value) {}
-  }
-
-  private static class AnchorTag implements Tag {
-    @Override
-    public void assertSafe(String name, String value) {
-      if ("href".equals(name)) {
-        assertNotJavascriptUrl(value);
-      }
-    }
-  }
-
-  private static class FormTag implements Tag {
-    @Override
-    public void assertSafe(String name, String value) {
-      if ("action".equals(name)) {
-        assertNotJavascriptUrl(value);
-      }
-    }
-  }
-
-  private static class SrcTag implements Tag {
-    @Override
-    public void assertSafe(String name, String value) {
-      if ("src".equals(name)) {
-        assertNotJavascriptUrl(value);
-      }
-    }
-  }
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/Buffer.java b/java/com/google/gwtexpui/safehtml/client/Buffer.java
deleted file mode 100644
index 12389b4..0000000
--- a/java/com/google/gwtexpui/safehtml/client/Buffer.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-interface Buffer {
-  void append(boolean v);
-
-  void append(char v);
-
-  void append(int v);
-
-  void append(long v);
-
-  void append(float v);
-
-  void append(double v);
-
-  void append(String v);
-
-  @Override
-  String toString();
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/BufferDirect.java b/java/com/google/gwtexpui/safehtml/client/BufferDirect.java
deleted file mode 100644
index c6e1d30..0000000
--- a/java/com/google/gwtexpui/safehtml/client/BufferDirect.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-final class BufferDirect implements Buffer {
-  private final StringBuilder strbuf = new StringBuilder();
-
-  boolean isEmpty() {
-    return strbuf.length() == 0;
-  }
-
-  @Override
-  public void append(boolean v) {
-    strbuf.append(v);
-  }
-
-  @Override
-  public void append(char v) {
-    strbuf.append(v);
-  }
-
-  @Override
-  public void append(int v) {
-    strbuf.append(v);
-  }
-
-  @Override
-  public void append(long v) {
-    strbuf.append(v);
-  }
-
-  @Override
-  public void append(float v) {
-    strbuf.append(v);
-  }
-
-  @Override
-  public void append(double v) {
-    strbuf.append(v);
-  }
-
-  @Override
-  public void append(String v) {
-    strbuf.append(v);
-  }
-
-  @Override
-  public String toString() {
-    return strbuf.toString();
-  }
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java b/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java
deleted file mode 100644
index bdd9801..0000000
--- a/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-final class BufferSealElement implements Buffer {
-  private final SafeHtmlBuilder shb;
-
-  BufferSealElement(SafeHtmlBuilder safeHtmlBuilder) {
-    shb = safeHtmlBuilder;
-  }
-
-  @Override
-  public void append(boolean v) {
-    shb.sealElement().append(v);
-  }
-
-  @Override
-  public void append(char v) {
-    shb.sealElement().append(v);
-  }
-
-  @Override
-  public void append(double v) {
-    shb.sealElement().append(v);
-  }
-
-  @Override
-  public void append(float v) {
-    shb.sealElement().append(v);
-  }
-
-  @Override
-  public void append(int v) {
-    shb.sealElement().append(v);
-  }
-
-  @Override
-  public void append(long v) {
-    shb.sealElement().append(v);
-  }
-
-  @Override
-  public void append(String v) {
-    shb.sealElement().append(v);
-  }
-
-  @Override
-  public String toString() {
-    return shb.sealElement().toString();
-  }
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/FindReplace.java b/java/com/google/gwtexpui/safehtml/client/FindReplace.java
deleted file mode 100644
index 4fb3246..0000000
--- a/java/com/google/gwtexpui/safehtml/client/FindReplace.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.gwtexpui.safehtml.client;
-
-import com.google.gwt.regexp.shared.RegExp;
-
-/** A Find/Replace pair used against the {@link SafeHtml} block of text. */
-public interface FindReplace {
-  /** @return regular expression to match substrings with; should be treated as immutable. */
-  RegExp pattern();
-
-  /**
-   * Find and replace a single instance of this pattern in an input.
-   *
-   * <p><b>WARNING:</b> No XSS sanitization is done on the return value of this method, e.g. this
-   * value may be passed directly to {@link SafeHtml#replaceAll(String, String)}. Implementations
-   * must sanitize output appropriately.
-   *
-   * @param input input string.
-   * @return result of regular expression replacement.
-   * @throws IllegalArgumentException if the input could not be safely sanitized.
-   */
-  String replace(String input);
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java b/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
deleted file mode 100644
index ef80cdb..0000000
--- a/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
+++ /dev/null
@@ -1,157 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-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;
-
-/**
- * A suggestion oracle that tries to highlight the matched text.
- *
- * <p>Suggestions supplied by the implementation of {@link #onRequestSuggestions(Request, Callback)}
- * are modified to wrap all occurrences of the {@link
- * com.google.gwt.user.client.ui.SuggestOracle.Request#getQuery()} substring in HTML {@code
- * &lt;strong&gt;} tags, so they can be emphasized to the user.
- */
-public abstract class HighlightSuggestOracle extends SuggestOracle {
-  private static String escape(String ds) {
-    return new SafeHtmlBuilder().append(ds).asString();
-  }
-
-  @Override
-  public final boolean isDisplayStringHTML() {
-    return true;
-  }
-
-  @Override
-  public final void requestSuggestions(Request request, Callback cb) {
-    onRequestSuggestions(
-        request,
-        new Callback() {
-          @Override
-          public void onSuggestionsReady(Request request, Response response) {
-            final String qpat = getQueryPattern(request.getQuery());
-            final boolean html = isHTML();
-            final ArrayList<Suggestion> r = new ArrayList<>();
-            for (Suggestion s : response.getSuggestions()) {
-              r.add(new BoldSuggestion(qpat, s, html));
-            }
-            cb.onSuggestionsReady(request, new Response(r));
-          }
-        });
-  }
-
-  protected String getQueryPattern(String query) {
-    return query;
-  }
-
-  /**
-   * @return true if {@link
-   *     com.google.gwt.user.client.ui.SuggestOracle.Suggestion#getDisplayString()} returns HTML;
-   *     false if the text must be escaped before evaluating in an HTML like context.
-   */
-  protected boolean isHTML() {
-    return false;
-  }
-
-  /** Compute the suggestions and return them for display. */
-  protected abstract void onRequestSuggestions(Request request, Callback done);
-
-  private static class BoldSuggestion implements Suggestion {
-    private final Suggestion suggestion;
-    private final String displayString;
-
-    BoldSuggestion(String qstr, Suggestion s, boolean html) {
-      suggestion = s;
-
-      String ds = s.getDisplayString();
-      if (!html) {
-        ds = escape(ds);
-      }
-
-      if (qstr != null && !qstr.isEmpty()) {
-        StringBuilder pattern = new StringBuilder();
-        for (String qterm : splitQuery(qstr)) {
-          qterm = escape(qterm);
-          // We now surround qstr by <strong>. But the chosen approach is not too
-          // smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
-          // escapes (e.g.: "Tim &lt;email@example.org&gt;"). Those escapes will
-          // get <strong>-ed as well (e.g.: "&lt;" -> "&<strong>l</strong>t;"). But
-          // as repairing those mangled escapes is easier than not mangling them in
-          // the first place, we repair them afterwards.
-          if (pattern.length() > 0) {
-            pattern.append("|");
-          }
-          pattern.append(qterm);
-        }
-
-        ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
-
-        // Repairing <strong>-ed escapes.
-        ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
-      }
-
-      displayString = ds;
-    }
-
-    /**
-     * Split the query by whitespace and filter out query terms which are substrings of other query
-     * 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> result = new ArrayList<>();
-      for (String s : queryTerms) {
-        boolean add = true;
-        for (String queryTerm : result) {
-          if (queryTerm.toLowerCase().contains(s.toLowerCase())) {
-            add = false;
-            break;
-          }
-        }
-        if (add) {
-          result.add(s);
-        }
-      }
-      return result;
-    }
-
-    private static native String sgi(String inString, String pat, String newHtml)
-        /*-{ return inString.replace(RegExp(pat, 'gi'), newHtml); }-*/ ;
-
-    @Override
-    public String getDisplayString() {
-      return displayString;
-    }
-
-    @Override
-    public String getReplacementString() {
-      return suggestion.getReplacementString();
-    }
-  }
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java b/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java
deleted file mode 100644
index cf0e51d..0000000
--- a/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.gwtexpui.safehtml.client;
-
-import com.google.gwt.regexp.shared.RegExp;
-
-/**
- * A Find/Replace pair whose replacement string is a link.
- *
- * <p>It is safe to pass arbitrary user-provided links to this class. Links are sanitized as
- * follows:
- *
- * <ul>
- *   <li>Only http(s) and mailto links are supported; any other scheme results in an {@link
- *       IllegalArgumentException} from {@link #replace(String)}.
- *   <li>Special characters in the link after regex replacement are escaped with {@link
- *       SafeHtmlBuilder}.
- * </ul>
- */
-public class LinkFindReplace implements FindReplace {
-  public static boolean hasValidScheme(String link) {
-    int colon = link.indexOf(':');
-    if (colon < 0) {
-      return true;
-    }
-    String scheme = link.substring(0, colon);
-    return "http".equalsIgnoreCase(scheme)
-        || "https".equalsIgnoreCase(scheme)
-        || "mailto".equalsIgnoreCase(scheme);
-  }
-
-  private RegExp pat;
-  private String link;
-
-  protected LinkFindReplace() {}
-
-  /**
-   * @param find regular expression pattern to match substrings with.
-   * @param link replacement link href. Capture groups within {@code find} can be referenced with
-   *     {@code $<i>n</i>}.
-   */
-  public LinkFindReplace(String find, String link) {
-    this.pat = RegExp.compile(find);
-    this.link = link;
-  }
-
-  @Override
-  public RegExp pattern() {
-    return pat;
-  }
-
-  @Override
-  public String replace(String input) {
-    String href = pat.replace(input, link);
-    if (!hasValidScheme(href)) {
-      throw new IllegalArgumentException("Invalid scheme (" + toString() + "): " + href);
-    }
-    return new SafeHtmlBuilder()
-        .openAnchor()
-        .setAttribute("href", href)
-        .append(SafeHtml.asis(input))
-        .closeAnchor()
-        .asString();
-  }
-
-  @Override
-  public String toString() {
-    return "find = " + pat.getSource() + ", link = " + link;
-  }
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java b/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java
deleted file mode 100644
index dc39af6..0000000
--- a/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import com.google.gwt.regexp.shared.RegExp;
-
-/**
- * A Find/Replace pair whose replacement string is arbitrary HTML.
- *
- * <p><b>WARNING:</b> This class is not safe used with user-provided patterns.
- */
-public class RawFindReplace implements FindReplace {
-  private RegExp pat;
-  private String replace;
-
-  protected RawFindReplace() {}
-
-  /**
-   * @param find regular expression pattern to match substrings with.
-   * @param replace replacement expression. Capture groups within {@code find} can be referenced
-   *     with {@code $<i>n</i>}.
-   */
-  public RawFindReplace(String find, String replace) {
-    this.pat = RegExp.compile(find);
-    this.replace = replace;
-  }
-
-  @Override
-  public RegExp pattern() {
-    return pat;
-  }
-
-  @Override
-  public String replace(String input) {
-    return pat.replace(input, replace);
-  }
-
-  @Override
-  public String toString() {
-    return "find = " + pat.getSource() + ", replace = " + replace;
-  }
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/SafeHtml.java b/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
deleted file mode 100644
index 2a1ddc0..0000000
--- a/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
+++ /dev/null
@@ -1,334 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.regexp.shared.MatchResult;
-import com.google.gwt.regexp.shared.RegExp;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.HTMLTable;
-import com.google.gwt.user.client.ui.HasHTML;
-import com.google.gwt.user.client.ui.InlineHTML;
-import com.google.gwt.user.client.ui.Widget;
-import java.util.Iterator;
-import java.util.List;
-
-/** Immutable string safely placed as HTML without further escaping. */
-@SuppressWarnings("serial")
-public abstract class SafeHtml implements com.google.gwt.safehtml.shared.SafeHtml {
-  public static final SafeHtmlResources RESOURCES;
-
-  static {
-    if (GWT.isClient()) {
-      RESOURCES = GWT.create(SafeHtmlResources.class);
-      RESOURCES.css().ensureInjected();
-
-    } else {
-      RESOURCES =
-          new SafeHtmlResources() {
-            @Override
-            public SafeHtmlCss css() {
-              return new SafeHtmlCss() {
-                @Override
-                public String wikiList() {
-                  return "wikiList";
-                }
-
-                @Override
-                public String wikiPreFormat() {
-                  return "wikiPreFormat";
-                }
-
-                @Override
-                public String wikiQuote() {
-                  return "wikiQuote";
-                }
-
-                @Override
-                public boolean ensureInjected() {
-                  return false;
-                }
-
-                @Override
-                public String getName() {
-                  return null;
-                }
-
-                @Override
-                public String getText() {
-                  return null;
-                }
-              };
-            }
-          };
-    }
-  }
-
-  /** @return the existing HTML property of a widget. */
-  public static SafeHtml get(HasHTML t) {
-    return new SafeHtmlString(t.getHTML());
-  }
-
-  /** @return the existing HTML text, wrapped in a safe buffer. */
-  public static SafeHtml asis(String htmlText) {
-    return new SafeHtmlString(htmlText);
-  }
-
-  /** Set the HTML property of a widget. */
-  public static <T extends HasHTML> T set(T e, SafeHtml str) {
-    e.setHTML(str.asString());
-    return e;
-  }
-
-  /** @return the existing inner HTML of any element. */
-  public static SafeHtml get(Element e) {
-    return new SafeHtmlString(e.getInnerHTML());
-  }
-
-  /** Set the inner HTML of any element. */
-  public static Element setInnerHTML(Element e, SafeHtml str) {
-    e.setInnerHTML(str.asString());
-    return e;
-  }
-
-  /** @return the existing inner HTML of a table cell. */
-  public static SafeHtml get(HTMLTable t, int row, int col) {
-    return new SafeHtmlString(t.getHTML(row, col));
-  }
-
-  /** Set the inner HTML of a table cell. */
-  public static <T extends HTMLTable> T set(final T t, int row, int col, SafeHtml str) {
-    t.setHTML(row, col, str.asString());
-    return t;
-  }
-
-  /** Parse an HTML block and return the first (typically root) element. */
-  public static Element parse(SafeHtml html) {
-    Element e = DOM.createDiv();
-    setInnerHTML(e, html);
-    return DOM.getFirstChild(e);
-  }
-
-  /** Convert bare http:// and https:// URLs into &lt;a href&gt; tags. */
-  public SafeHtml linkify() {
-    final String part = "(?:[a-zA-Z0-9$_+!*'%;:@=?#/~-]|&(?!lt;|gt;)|[.,](?!(?:\\s|$)))";
-    return replaceAll(
-        "(https?://" + part + "{2,}(?:[(]" + part + "*[)])*" + part + "*)",
-        "<a href=\"$1\" target=\"_blank\" rel=\"nofollow\">$1</a>");
-  }
-
-  /**
-   * Apply {@link #linkify()}, and "\n\n" to &lt;p&gt;.
-   *
-   * <p>Lines that start with whitespace are assumed to be preformatted, and are formatted by the
-   * {@link SafeHtmlCss#wikiPreFormat()} CSS class.
-   */
-  public SafeHtml wikify() {
-    final SafeHtmlBuilder r = new SafeHtmlBuilder();
-    for (String p : linkify().asString().split("\n\n")) {
-      if (isQuote(p)) {
-        wikifyQuote(r, p);
-
-      } else if (isPreFormat(p)) {
-        r.openElement("p");
-        for (String line : p.split("\n")) {
-          r.openSpan();
-          r.setStyleName(RESOURCES.css().wikiPreFormat());
-          r.append(asis(line));
-          r.closeSpan();
-          r.br();
-        }
-        r.closeElement("p");
-
-      } else if (isList(p)) {
-        wikifyList(r, p);
-
-      } else {
-        r.openElement("p");
-        r.append(asis(p));
-        r.closeElement("p");
-      }
-    }
-    return r.toSafeHtml();
-  }
-
-  private void wikifyList(SafeHtmlBuilder r, String p) {
-    boolean in_ul = false;
-    boolean in_p = false;
-    for (String line : p.split("\n")) {
-      if (line.startsWith("-") || line.startsWith("*")) {
-        if (!in_ul) {
-          if (in_p) {
-            in_p = false;
-            r.closeElement("p");
-          }
-
-          in_ul = true;
-          r.openElement("ul");
-          r.setStyleName(RESOURCES.css().wikiList());
-        }
-        line = line.substring(1).trim();
-
-      } else if (!in_ul) {
-        if (!in_p) {
-          in_p = true;
-          r.openElement("p");
-        } else {
-          r.append(' ');
-        }
-        r.append(asis(line));
-        continue;
-      }
-
-      r.openElement("li");
-      r.append(asis(line));
-      r.closeElement("li");
-    }
-
-    if (in_ul) {
-      r.closeElement("ul");
-    } else if (in_p) {
-      r.closeElement("p");
-    }
-  }
-
-  private void wikifyQuote(SafeHtmlBuilder r, String p) {
-    r.openElement("blockquote");
-    r.setStyleName(RESOURCES.css().wikiQuote());
-    if (p.startsWith("&gt; ")) {
-      p = p.substring(5);
-    } else if (p.startsWith(" &gt; ")) {
-      p = p.substring(6);
-    }
-    p = p.replaceAll("\\n ?&gt; ", "\n");
-    for (String e : p.split("\n\n")) {
-      if (isQuote(e)) {
-        SafeHtmlBuilder b = new SafeHtmlBuilder();
-        wikifyQuote(b, e);
-        r.append(b);
-      } else {
-        r.append(asis(e));
-      }
-    }
-    r.closeElement("blockquote");
-  }
-
-  private static boolean isQuote(String p) {
-    return p.startsWith("&gt; ") || p.startsWith(" &gt; ");
-  }
-
-  private static boolean isPreFormat(String p) {
-    return p.contains("\n ") || p.contains("\n\t") || p.startsWith(" ") || p.startsWith("\t");
-  }
-
-  private static boolean isList(String p) {
-    return p.contains("\n- ") || p.contains("\n* ") || p.startsWith("- ") || p.startsWith("* ");
-  }
-
-  /**
-   * Replace first occurrence of {@code regex} with {@code repl} .
-   *
-   * <p><b>WARNING:</b> This replacement is being performed against an otherwise safe HTML string.
-   * The caller must ensure that the replacement does not introduce cross-site scripting attack
-   * entry points.
-   *
-   * @param regex regular expression pattern to match the substring with.
-   * @param repl replacement expression. Capture groups within {@code regex} can be referenced with
-   *     {@code $<i>n</i>}.
-   * @return a new string, after the replacement has been made.
-   */
-  public SafeHtml replaceFirst(String regex, String repl) {
-    return new SafeHtmlString(asString().replaceFirst(regex, repl));
-  }
-
-  /**
-   * Replace each occurrence of {@code regex} with {@code repl} .
-   *
-   * <p><b>WARNING:</b> This replacement is being performed against an otherwise safe HTML string.
-   * The caller must ensure that the replacement does not introduce cross-site scripting attack
-   * entry points.
-   *
-   * @param regex regular expression pattern to match substrings with.
-   * @param repl replacement expression. Capture groups within {@code regex} can be referenced with
-   *     {@code $<i>n</i>}.
-   * @return a new string, after the replacements have been made.
-   */
-  public SafeHtml replaceAll(String regex, String repl) {
-    return new SafeHtmlString(asString().replaceAll(regex, repl));
-  }
-
-  /**
-   * Replace all find/replace pairs in the list in a single pass.
-   *
-   * @param findReplaceList find/replace pairs to use.
-   * @return a new string, after the replacements have been made.
-   */
-  public <T> SafeHtml replaceAll(List<? extends FindReplace> findReplaceList) {
-    if (findReplaceList == null || findReplaceList.isEmpty()) {
-      return this;
-    }
-
-    StringBuilder pat = new StringBuilder();
-    Iterator<? extends FindReplace> it = findReplaceList.iterator();
-    while (it.hasNext()) {
-      FindReplace fr = it.next();
-      pat.append(fr.pattern().getSource());
-      if (it.hasNext()) {
-        pat.append('|');
-      }
-    }
-
-    StringBuilder result = new StringBuilder();
-    RegExp re = RegExp.compile(pat.toString(), "g");
-    String orig = asString();
-    int index = 0;
-    MatchResult mat;
-    while ((mat = re.exec(orig)) != null) {
-      String g = mat.getGroup(0);
-      // Re-run each candidate to find which one matched.
-      for (FindReplace fr : findReplaceList) {
-        if (fr.pattern().test(g)) {
-          try {
-            String repl = fr.replace(g);
-            result.append(orig.substring(index, mat.getIndex()));
-            result.append(repl);
-          } catch (IllegalArgumentException e) {
-            continue;
-          }
-          index = mat.getIndex() + g.length();
-          break;
-        }
-      }
-    }
-    result.append(orig.substring(index, orig.length()));
-    return asis(result.toString());
-  }
-
-  /** @return a GWT block display widget displaying this HTML. */
-  public Widget toBlockWidget() {
-    return new HTML(asString());
-  }
-
-  /** @return a GWT inline display widget displaying this HTML. */
-  public Widget toInlineWidget() {
-    return new InlineHTML(asString());
-  }
-
-  /** @return a clean HTML string safe for inclusion in any context. */
-  @Override
-  public abstract String asString();
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java b/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
deleted file mode 100644
index a926906..0000000
--- a/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
+++ /dev/null
@@ -1,424 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import com.google.gwt.core.client.GWT;
-
-/** Safely constructs a {@link SafeHtml}, escaping user provided content. */
-@SuppressWarnings("serial")
-public class SafeHtmlBuilder extends SafeHtml {
-  private static final Impl impl;
-
-  static {
-    if (GWT.isClient()) {
-      impl = new ClientImpl();
-    } else {
-      impl = new ServerImpl();
-    }
-  }
-
-  private final BufferDirect dBuf;
-  private Buffer cb;
-
-  private BufferSealElement sBuf;
-  private AttMap att;
-
-  public SafeHtmlBuilder() {
-    cb = dBuf = new BufferDirect();
-  }
-
-  /** @return true if this builder has not had an append occur yet. */
-  public boolean isEmpty() {
-    return dBuf.isEmpty();
-  }
-
-  /** @return true if this builder has content appended into it. */
-  public boolean hasContent() {
-    return !isEmpty();
-  }
-
-  public SafeHtmlBuilder append(boolean in) {
-    cb.append(in);
-    return this;
-  }
-
-  public SafeHtmlBuilder append(char in) {
-    switch (in) {
-      case '&':
-        cb.append("&amp;");
-        break;
-
-      case '>':
-        cb.append("&gt;");
-        break;
-
-      case '<':
-        cb.append("&lt;");
-        break;
-
-      case '"':
-        cb.append("&quot;");
-        break;
-
-      case '\'':
-        cb.append("&#39;");
-        break;
-
-      default:
-        cb.append(in);
-        break;
-    }
-    return this;
-  }
-
-  public SafeHtmlBuilder append(int in) {
-    cb.append(in);
-    return this;
-  }
-
-  public SafeHtmlBuilder append(long in) {
-    cb.append(in);
-    return this;
-  }
-
-  public SafeHtmlBuilder append(float in) {
-    cb.append(in);
-    return this;
-  }
-
-  public SafeHtmlBuilder append(double in) {
-    cb.append(in);
-    return this;
-  }
-
-  /** Append already safe HTML as-is, avoiding double escaping. */
-  public SafeHtmlBuilder append(com.google.gwt.safehtml.shared.SafeHtml in) {
-    if (in != null) {
-      cb.append(in.asString());
-    }
-    return this;
-  }
-
-  /** Append already safe HTML as-is, avoiding double escaping. */
-  public SafeHtmlBuilder append(SafeHtml in) {
-    if (in != null) {
-      cb.append(in.asString());
-    }
-    return this;
-  }
-
-  /** Append the string, escaping unsafe characters. */
-  public SafeHtmlBuilder append(String in) {
-    if (in != null) {
-      impl.escapeStr(this, in);
-    }
-    return this;
-  }
-
-  /** Append the string, escaping unsafe characters. */
-  public SafeHtmlBuilder append(StringBuilder in) {
-    if (in != null) {
-      append(in.toString());
-    }
-    return this;
-  }
-
-  /** Append the string, escaping unsafe characters. */
-  public SafeHtmlBuilder append(StringBuffer in) {
-    if (in != null) {
-      append(in.toString());
-    }
-    return this;
-  }
-
-  /** Append the result of toString(), escaping unsafe characters. */
-  public SafeHtmlBuilder append(Object in) {
-    if (in != null) {
-      append(in.toString());
-    }
-    return this;
-  }
-
-  /** Append the string, escaping unsafe characters. */
-  public SafeHtmlBuilder append(CharSequence in) {
-    if (in != null) {
-      escapeCS(this, in);
-    }
-    return this;
-  }
-
-  /**
-   * Open an element, appending "{@code <tagName>}" to the buffer.
-   *
-   * <p>After the element is open the attributes may be manipulated until the next {@code append},
-   * {@code openElement}, {@code closeSelf} or {@code closeElement} call.
-   *
-   * @param tagName name of the HTML element to open.
-   */
-  public SafeHtmlBuilder openElement(String tagName) {
-    assert isElementName(tagName);
-    cb.append("<");
-    cb.append(tagName);
-    if (sBuf == null) {
-      att = new AttMap();
-      sBuf = new BufferSealElement(this);
-    }
-    att.reset(tagName);
-    cb = sBuf;
-    return this;
-  }
-
-  /**
-   * Get an attribute of the last opened element.
-   *
-   * @param name name of the attribute to read.
-   * @return the attribute value, as a string. The empty string if the attribute has not been
-   *     assigned a value. The returned string is the raw (unescaped) value.
-   */
-  public String getAttribute(String name) {
-    assert isAttributeName(name);
-    assert cb == sBuf;
-    return att.get(name);
-  }
-
-  /**
-   * Set an attribute of the last opened element.
-   *
-   * @param name name of the attribute to set.
-   * @param value value to assign; any existing value is replaced. The value is escaped (if
-   *     necessary) during the assignment.
-   */
-  public SafeHtmlBuilder setAttribute(String name, String value) {
-    assert isAttributeName(name);
-    assert cb == sBuf;
-    att.set(name, value != null ? value : "");
-    return this;
-  }
-
-  /**
-   * Set an attribute of the last opened element.
-   *
-   * @param name name of the attribute to set.
-   * @param value value to assign, any existing value is replaced.
-   */
-  public SafeHtmlBuilder setAttribute(String name, int value) {
-    return setAttribute(name, String.valueOf(value));
-  }
-
-  /**
-   * Append a new value into a whitespace delimited attribute.
-   *
-   * <p>If the attribute is not yet assigned, this method sets the attribute. If the attribute is
-   * already assigned, the new value is appended onto the end, after appending a single space to
-   * delimit the values.
-   *
-   * @param name name of the attribute to append onto.
-   * @param value additional value to append.
-   */
-  public SafeHtmlBuilder appendAttribute(String name, String value) {
-    if (value != null && value.length() > 0) {
-      final String e = getAttribute(name);
-      return setAttribute(name, e.length() > 0 ? e + " " + value : value);
-    }
-    return this;
-  }
-
-  /** Set the height attribute of the current element. */
-  public SafeHtmlBuilder setHeight(int height) {
-    return setAttribute("height", height);
-  }
-
-  /** Set the width attribute of the current element. */
-  public SafeHtmlBuilder setWidth(int width) {
-    return setAttribute("width", width);
-  }
-
-  /** Set the CSS class name for this element. */
-  public SafeHtmlBuilder setStyleName(String style) {
-    assert isCssName(style);
-    return setAttribute("class", style);
-  }
-
-  /**
-   * Add an additional CSS class name to this element.
-   *
-   * <p>If no CSS class name has been specified yet, this method initializes it to the single name.
-   */
-  public SafeHtmlBuilder addStyleName(String style) {
-    assert isCssName(style);
-    return appendAttribute("class", style);
-  }
-
-  private void sealElement0() {
-    assert cb == sBuf;
-    cb = dBuf;
-    att.onto(cb, this);
-  }
-
-  Buffer sealElement() {
-    sealElement0();
-    cb.append(">");
-    return cb;
-  }
-
-  /** Close the current element with a self closing suffix ("/ &gt;"). */
-  public SafeHtmlBuilder closeSelf() {
-    sealElement0();
-    cb.append(" />");
-    return this;
-  }
-
-  /** Append a closing tag for the named element. */
-  public SafeHtmlBuilder closeElement(String name) {
-    assert isElementName(name);
-    cb.append("</");
-    cb.append(name);
-    cb.append(">");
-    return this;
-  }
-
-  /** Append "&amp;nbsp;" - a non-breaking space, useful in empty table cells. */
-  public SafeHtmlBuilder nbsp() {
-    cb.append("&nbsp;");
-    return this;
-  }
-
-  /** Append "&lt;br /&gt;" - a line break with no attributes */
-  public SafeHtmlBuilder br() {
-    cb.append("<br />");
-    return this;
-  }
-
-  /** Append "&lt;tr&gt;"; attributes may be set if needed */
-  public SafeHtmlBuilder openTr() {
-    return openElement("tr");
-  }
-
-  /** Append "&lt;/tr&gt;" */
-  public SafeHtmlBuilder closeTr() {
-    return closeElement("tr");
-  }
-
-  /** Append "&lt;td&gt;"; attributes may be set if needed */
-  public SafeHtmlBuilder openTd() {
-    return openElement("td");
-  }
-
-  /** Append "&lt;/td&gt;" */
-  public SafeHtmlBuilder closeTd() {
-    return closeElement("td");
-  }
-
-  /** Append "&lt;th&gt;"; attributes may be set if needed */
-  public SafeHtmlBuilder openTh() {
-    return openElement("th");
-  }
-
-  /** Append "&lt;/th&gt;" */
-  public SafeHtmlBuilder closeTh() {
-    return closeElement("th");
-  }
-
-  /** Append "&lt;div&gt;"; attributes may be set if needed */
-  public SafeHtmlBuilder openDiv() {
-    return openElement("div");
-  }
-
-  /** Append "&lt;/div&gt;" */
-  public SafeHtmlBuilder closeDiv() {
-    return closeElement("div");
-  }
-
-  /** Append "&lt;span&gt;"; attributes may be set if needed */
-  public SafeHtmlBuilder openSpan() {
-    return openElement("span");
-  }
-
-  /** Append "&lt;/span&gt;" */
-  public SafeHtmlBuilder closeSpan() {
-    return closeElement("span");
-  }
-
-  /** Append "&lt;a&gt;"; attributes may be set if needed */
-  public SafeHtmlBuilder openAnchor() {
-    return openElement("a");
-  }
-
-  /** Append "&lt;/a&gt;" */
-  public SafeHtmlBuilder closeAnchor() {
-    return closeElement("a");
-  }
-
-  /** Append "&lt;param name=... value=... /&gt;". */
-  public SafeHtmlBuilder paramElement(String name, String value) {
-    openElement("param");
-    setAttribute("name", name);
-    setAttribute("value", value);
-    return closeSelf();
-  }
-
-  /** @return an immutable {@link SafeHtml} representation of the buffer. */
-  public SafeHtml toSafeHtml() {
-    return new SafeHtmlString(asString());
-  }
-
-  @Override
-  public String asString() {
-    return cb.toString();
-  }
-
-  private static void escapeCS(SafeHtmlBuilder b, CharSequence in) {
-    for (int i = 0; i < in.length(); i++) {
-      b.append(in.charAt(i));
-    }
-  }
-
-  private static boolean isElementName(String name) {
-    return name.matches("^[a-zA-Z][a-zA-Z0-9_-]*$");
-  }
-
-  private static boolean isAttributeName(String name) {
-    return isElementName(name);
-  }
-
-  private static boolean isCssName(String name) {
-    return isElementName(name);
-  }
-
-  private abstract static class Impl {
-    abstract void escapeStr(SafeHtmlBuilder b, String in);
-  }
-
-  private static class ServerImpl extends Impl {
-    @Override
-    void escapeStr(SafeHtmlBuilder b, String in) {
-      SafeHtmlBuilder.escapeCS(b, in);
-    }
-  }
-
-  private static class ClientImpl extends Impl {
-    @Override
-    void escapeStr(SafeHtmlBuilder b, String in) {
-      b.cb.append(escape(in));
-    }
-
-    private static native String escape(String src) /*-{ return src.replace(/&/g,'&amp;')
-                   .replace(/>/g,'&gt;')
-                   .replace(/</g,'&lt;')
-                   .replace(/"/g,'&quot;')
-                   .replace(/'/g,'&#39;');
-     }-*/;
-  }
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java b/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java
deleted file mode 100644
index f4b1c77..0000000
--- a/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import com.google.gwt.resources.client.CssResource;
-
-public interface SafeHtmlCss extends CssResource {
-  String wikiList();
-
-  String wikiPreFormat();
-
-  String wikiQuote();
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/SafeHtmlResources.java b/java/com/google/gwtexpui/safehtml/client/SafeHtmlResources.java
deleted file mode 100644
index e3f5724..0000000
--- a/java/com/google/gwtexpui/safehtml/client/SafeHtmlResources.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import com.google.gwt.resources.client.ClientBundle;
-
-public interface SafeHtmlResources extends ClientBundle {
-  @Source("safehtml.css")
-  SafeHtmlCss css();
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java b/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
deleted file mode 100644
index 889509a..0000000
--- a/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-@SuppressWarnings("serial")
-class SafeHtmlString extends SafeHtml {
-  private final String html;
-
-  SafeHtmlString(String h) {
-    html = h;
-  }
-
-  @Override
-  public String asString() {
-    return html;
-  }
-}
diff --git a/java/com/google/gwtexpui/safehtml/client/safehtml.css b/java/com/google/gwtexpui/safehtml/client/safehtml.css
deleted file mode 100644
index 163c548..0000000
--- a/java/com/google/gwtexpui/safehtml/client/safehtml.css
+++ /dev/null
@@ -1,29 +0,0 @@
-/* Copyright (C) 2009 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-.wikiList {
-}
-
-.wikiPreFormat {
-  white-space: pre;
-  font-family: 'Lucida Console', 'Lucida Sans Typewriter', Monaco, monospace;
-  font-size: small;
-}
-
-.wikiQuote {
-  margin-left: 0;
-  border-left: 1px solid #888;
-  padding-left: 5px;
-}
diff --git a/java/com/google/gwtexpui/user/BUILD b/java/com/google/gwtexpui/user/BUILD
deleted file mode 100644
index 813f433..0000000
--- a/java/com/google/gwtexpui/user/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-gwt_module(
-    name = "agent",
-    srcs = glob(["client/*.java"]),
-    gwt_xml = "User.gwt.xml",
-    resources = ["client/tooltip.css"],
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:user"],
-)
diff --git a/java/com/google/gwtexpui/user/User.gwt.xml b/java/com/google/gwtexpui/user/User.gwt.xml
deleted file mode 100644
index f4f8d51..0000000
--- a/java/com/google/gwtexpui/user/User.gwt.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name="com.google.gwt.user.User"/>
-</module>
diff --git a/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java b/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
deleted file mode 100644
index fdaf861..0000000
--- a/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.gwtexpui.user.client;
-
-import com.google.gwt.event.logical.shared.ResizeEvent;
-import com.google.gwt.event.logical.shared.ResizeHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.DialogBox;
-
-/** A DialogBox that automatically re-centers itself if the window changes */
-public class AutoCenterDialogBox extends DialogBox {
-  private HandlerRegistration recenter;
-
-  public AutoCenterDialogBox() {
-    this(false);
-  }
-
-  public AutoCenterDialogBox(boolean autoHide) {
-    this(autoHide, true);
-  }
-
-  public AutoCenterDialogBox(boolean autoHide, boolean modal) {
-    super(autoHide, modal);
-  }
-
-  @Override
-  public void show() {
-    if (recenter == null) {
-      recenter =
-          Window.addResizeHandler(
-              new ResizeHandler() {
-                @Override
-                public void onResize(ResizeEvent event) {
-                  final int w = event.getWidth();
-                  final int h = event.getHeight();
-                  AutoCenterDialogBox.this.onResize(w, h);
-                }
-              });
-    }
-    super.show();
-  }
-
-  @Override
-  protected void onUnload() {
-    if (recenter != null) {
-      recenter.removeHandler();
-      recenter = null;
-    }
-    super.onUnload();
-  }
-
-  /**
-   * Invoked when the outer browser window resizes.
-   *
-   * <p>Subclasses may override (but should ensure they still call super.onResize) to implement
-   * custom logic when a window resize occurs.
-   *
-   * @param width new browser window width
-   * @param height new browser window height
-   */
-  protected void onResize(int width, int height) {
-    if (isAttached()) {
-      center();
-    }
-  }
-}
diff --git a/java/com/google/gwtexpui/user/client/Tooltip.java b/java/com/google/gwtexpui/user/client/Tooltip.java
deleted file mode 100644
index b5ce046..0000000
--- a/java/com/google/gwtexpui/user/client/Tooltip.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.user.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.user.client.ui.UIObject;
-
-/** Displays custom tooltip message below an element. */
-public class Tooltip {
-  interface Resources extends ClientBundle {
-    Resources I = GWT.create(Resources.class);
-
-    @Source("tooltip.css")
-    Css css();
-  }
-
-  interface Css extends CssResource {
-    String tooltip();
-  }
-
-  static {
-    Resources.I.css().ensureInjected();
-  }
-
-  /**
-   * Add required supporting style to enable custom tooltip rendering.
-   *
-   * @param o widget whose element should display a tooltip on hover.
-   */
-  public static void addStyle(UIObject o) {
-    addStyle(o.getElement());
-  }
-
-  /**
-   * Add required supporting style to enable custom tooltip rendering.
-   *
-   * @param e element that should display a tooltip on hover.
-   */
-  public static void addStyle(Element e) {
-    e.addClassName(Resources.I.css().tooltip());
-  }
-
-  /**
-   * Set the text displayed on hover.
-   *
-   * @param o widget whose hover text is being set.
-   * @param text message to display on hover.
-   */
-  public static void setLabel(UIObject o, String text) {
-    setLabel(o.getElement(), text);
-  }
-
-  /**
-   * Set the text displayed on hover.
-   *
-   * @param e element whose hover text is being set.
-   * @param text message to display on hover.
-   */
-  public static void setLabel(Element e, String text) {
-    e.setAttribute("aria-label", text != null ? text : "");
-  }
-
-  private Tooltip() {}
-}
diff --git a/java/com/google/gwtexpui/user/client/UserAgent.java b/java/com/google/gwtexpui/user/client/UserAgent.java
deleted file mode 100644
index 1660a62..0000000
--- a/java/com/google/gwtexpui/user/client/UserAgent.java
+++ /dev/null
@@ -1,168 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.user.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.user.client.Window;
-
-/**
- * User agent feature tests we don't create permutations for.
- *
- * <p>Some features aren't worth creating full permutations in GWT for, as each new boolean
- * permutation (only two settings) doubles the compile time required. If the setting only affects a
- * couple of lines of JavaScript code, the slightly larger cache files for user agents that lack the
- * functionality requested is trivial compared to the time developers lose building their
- * application.
- */
-public class UserAgent {
-  private static boolean jsClip = guessJavaScriptClipboard();
-
-  public static boolean hasJavaScriptClipboard() {
-    return jsClip;
-  }
-
-  public static void disableJavaScriptClipboard() {
-    jsClip = false;
-  }
-
-  private static native boolean nativeHasCopy()
-      /*-{ return $doc['queryCommandSupported'] && $doc.queryCommandSupported('copy') }-*/ ;
-
-  private static boolean guessJavaScriptClipboard() {
-    String ua = Window.Navigator.getUserAgent();
-    int chrome = major(ua, "Chrome/");
-    if (chrome > 0) {
-      return 42 <= chrome;
-    }
-
-    int ff = major(ua, "Firefox/");
-    if (ff > 0) {
-      return 41 <= ff;
-    }
-
-    int opera = major(ua, "OPR/");
-    if (opera > 0) {
-      return 29 <= opera;
-    }
-
-    int msie = major(ua, "MSIE ");
-    if (msie > 0) {
-      return 9 <= msie;
-    }
-
-    if (nativeHasCopy()) {
-      // Firefox 39.0 lies and says it supports copy, then fails.
-      // So we try this after the browser specific test above.
-      return true;
-    }
-
-    // Safari is not planning to support document.execCommand('copy').
-    // Assume the browser does not have the feature.
-    return false;
-  }
-
-  private static int major(String ua, String product) {
-    int entry = ua.indexOf(product);
-    if (entry >= 0) {
-      String s = ua.substring(entry + product.length());
-      String p = s.split("[ /;,.)]", 2)[0];
-      try {
-        return Integer.parseInt(p);
-      } catch (NumberFormatException nan) {
-        // Ignored
-      }
-    }
-    return -1;
-  }
-
-  public static class Flash {
-    private static boolean checked;
-    private static boolean installed;
-
-    /**
-     * Does the browser have ShockwaveFlash plugin installed?
-     *
-     * <p>This method may still return true if the user has disabled Flash or set the plugin to
-     * "click to run".
-     */
-    public static boolean isInstalled() {
-      if (!checked) {
-        installed = hasFlash();
-        checked = true;
-      }
-      return installed;
-    }
-
-    private static native boolean hasFlash() /*-{
-      if (navigator.plugins && navigator.plugins.length) {
-        if (navigator.plugins['Shockwave Flash'])     return true;
-        if (navigator.plugins['Shockwave Flash 2.0']) return true;
-
-      } else if (navigator.mimeTypes && navigator.mimeTypes.length) {
-        var mimeType = navigator.mimeTypes['application/x-shockwave-flash'];
-        if (mimeType && mimeType.enabledPlugin) return true;
-
-      } else {
-        try { new ActiveXObject('ShockwaveFlash.ShockwaveFlash.7'); return true; } catch (e) {}
-        try { new ActiveXObject('ShockwaveFlash.ShockwaveFlash.6'); return true; } catch (e) {}
-        try { new ActiveXObject('ShockwaveFlash.ShockwaveFlash');   return true; } catch (e) {}
-      }
-      return false;
-    }-*/;
-  }
-
-  /**
-   * Test for and disallow running this application in an &lt;iframe&gt;.
-   *
-   * <p>If the application is running within an iframe this method requests a browser generated
-   * redirect to pop the application out of the iframe into the top level window, and then aborts
-   * execution by throwing an exception. This is call should be placed early within the module's
-   * onLoad() method, before any real UI can be initialized that an attacking site could try to snip
-   * out and present in a confusing context.
-   *
-   * <p>If the break out works, execution will restart automatically in a proper top level window,
-   * where the script has full control over the display. If the break out fails, execution will
-   * abort and stop immediately, preventing UI widgets from being created, leaving the user with an
-   * empty frame.
-   */
-  public static void assertNotInIFrame() {
-    if (GWT.isScript() && amInsideIFrame()) {
-      bustOutOfIFrame(Window.Location.getHref());
-      throw new RuntimeException();
-    }
-  }
-
-  private static native boolean amInsideIFrame() /*-{ return top.location != $wnd.location; }-*/;
-
-  private static native void bustOutOfIFrame(String newloc) /*-{ top.location.href = newloc }-*/;
-
-  /**
-   * Test if Gerrit is running on a mobile browser. This check could be incomplete, but should cover
-   * most cases. Regexes shamelessly borrowed from CodeMirror.
-   */
-  public static native boolean isMobile() /*-{
-    var ua = $wnd.navigator.userAgent;
-    var ios = /AppleWebKit/.test(ua) && /Mobile\/\w+/.test(ua);
-    return ios
-        || /Android|webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(ua);
-  }-*/;
-
-  /** Check if the height of the browser view is greater than its width. */
-  public static boolean isPortrait() {
-    return Window.getClientHeight() > Window.getClientWidth();
-  }
-
-  private UserAgent() {}
-}
diff --git a/java/com/google/gwtexpui/user/client/View.java b/java/com/google/gwtexpui/user/client/View.java
deleted file mode 100644
index b15d2fd..0000000
--- a/java/com/google/gwtexpui/user/client/View.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.user.client;
-
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.Widget;
-
-/**
- * Widget to display within a {@link ViewSite}.
- *
- * <p>Implementations must override {@code protected void onLoad()} and arrange for {@link
- * #display()} to be invoked once the DOM within the view is consistent for presentation to the
- * user. Typically this means that the subclass can start RPCs within {@code onLoad()} and then
- * invoke {@code display()} from within the AsyncCallback's {@code onSuccess(Object)} method.
- */
-public abstract class View extends Composite {
-  ViewSite<? extends View> site;
-
-  @Override
-  protected void onUnload() {
-    site = null;
-    super.onUnload();
-  }
-
-  /** true if this is the current view of its parent view site */
-  public final boolean isCurrentView() {
-    Widget p = getParent();
-    while (p != null) {
-      if (p instanceof ViewSite<?>) {
-        return ((ViewSite<?>) p).getView() == this;
-      }
-      p = p.getParent();
-    }
-    return false;
-  }
-
-  /** Replace the current view in the parent ViewSite with this view. */
-  public final void display() {
-    if (site != null) {
-      site.swap(this);
-    }
-  }
-}
diff --git a/java/com/google/gwtexpui/user/client/ViewSite.java b/java/com/google/gwtexpui/user/client/ViewSite.java
deleted file mode 100644
index 4614546..0000000
--- a/java/com/google/gwtexpui/user/client/ViewSite.java
+++ /dev/null
@@ -1,84 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.user.client;
-
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.SimplePanel;
-
-/**
- * Hosts a single {@link View}.
- *
- * <p>View instances are attached inside of an invisible DOM node, permitting their {@code onLoad()}
- * method to be invoked and to update the DOM prior to the elements being made visible in the UI.
- *
- * <p>Complaint View instances must invoke {@link View#display()} once the DOM is ready for
- * presentation.
- */
-public class ViewSite<V extends View> extends Composite {
-  private final FlowPanel main;
-  private SimplePanel current;
-  private SimplePanel next;
-
-  public ViewSite() {
-    main = new FlowPanel();
-    initWidget(main);
-  }
-
-  /** Get the current view; null if there is no view being displayed. */
-  @SuppressWarnings("unchecked")
-  public V getView() {
-    return current != null ? (V) current.getWidget() : null;
-  }
-
-  /**
-   * Set the next view to display.
-   *
-   * <p>The view will be attached to the DOM tree within a hidden container, permitting its {@code
-   * onLoad()} method to execute and update the DOM without the user seeing the result.
-   *
-   * @param view the next view to display.
-   */
-  public void setView(V view) {
-    if (next != null) {
-      main.remove(next);
-    }
-    view.site = this;
-    next = new SimplePanel();
-    next.setVisible(false);
-    main.add(next);
-    next.add(view);
-  }
-
-  /**
-   * Invoked after the view becomes the current view and has been made visible.
-   *
-   * @param view the view being displayed.
-   */
-  protected void onShowView(V view) {}
-
-  @SuppressWarnings("unchecked")
-  final void swap(View v) {
-    if (next != null && next.getWidget() == v) {
-      if (current != null) {
-        main.remove(current);
-      }
-      current = next;
-      next = null;
-      current.setVisible(true);
-      onShowView((V) v);
-    }
-  }
-}
diff --git a/java/com/google/gwtexpui/user/client/tooltip.css b/java/com/google/gwtexpui/user/client/tooltip.css
deleted file mode 100644
index 1aeb015..0000000
--- a/java/com/google/gwtexpui/user/client/tooltip.css
+++ /dev/null
@@ -1,54 +0,0 @@
-/* Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-.tooltip {
-  position: relative;
-}
-
-.tooltip:hover:before {
-  position: absolute;
-  z-index: 51;
-  border: solid;
-  border-color: #333 transparent;
-  border-width: 0 4px 4px 4px;
-  pointer-events: none;
-  content: "";
-
-  top: auto;
-  right: 50%;
-  bottom: -5px;
-  margin-right: -5px;
-}
-
-.tooltip:hover:after {
-  position: absolute;
-  z-index: 50;
-  font: normal normal 11px/1.5 Helvetica, arial, sans-serif;
-  text-align: center;
-  white-space: pre;
-  pointer-events: none;
-  background: rgba(0,0,0,.7);
-  color: #fff;
-  border-radius: 3px;
-  padding: 5px;
-  content: attr(aria-label);
-
-  top: 100%;
-  right: 50%;
-  margin-top: 5px;
-  -webkit-transform: translateX(50%);
-  -ms-transform: translateX(50%);
-  transform: translateX(50%)
-}
diff --git a/java/gerrit/AbstractCommitUserIdentityPredicate.java b/java/gerrit/AbstractCommitUserIdentityPredicate.java
index c2aaa76..1bfc95c 100644
--- a/java/gerrit/AbstractCommitUserIdentityPredicate.java
+++ b/java/gerrit/AbstractCommitUserIdentityPredicate.java
@@ -14,9 +14,12 @@
 
 package gerrit;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.UserIdentity;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.rules.PrologEnvironment;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
@@ -24,6 +27,8 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import java.io.IOException;
+import org.eclipse.jgit.lib.PersonIdent;
 
 abstract class AbstractCommitUserIdentityPredicate extends Predicate.P3 {
   private static final SymbolTerm user = SymbolTerm.intern("user", 1);
@@ -36,7 +41,7 @@
     cont = n;
   }
 
-  protected Operation exec(Prolog engine, UserIdentity userId) throws PrologException {
+  protected Operation exec(Prolog engine, PersonIdent userId) throws PrologException {
     engine.setB0();
     Term a1 = arg1.dereference();
     Term a2 = arg2.dereference();
@@ -46,7 +51,18 @@
     Term nameTerm = Prolog.Nil;
     Term emailTerm = Prolog.Nil;
 
-    Account.Id id = userId.getAccount();
+    PrologEnvironment env = (PrologEnvironment) engine.control;
+    Emails emails = env.getArgs().getEmails();
+    Account.Id id = null;
+    try {
+      ImmutableSet<Account.Id> ids = emails.getAccountForExternal(userId.getEmailAddress());
+      if (ids.size() == 1) {
+        id = ids.iterator().next();
+      }
+    } catch (IOException e) {
+      throw new SystemException(e.getMessage());
+    }
+
     if (id == null) {
       idTerm = anonymous;
     } else {
@@ -58,7 +74,7 @@
       nameTerm = SymbolTerm.create(name);
     }
 
-    String email = userId.getEmail();
+    String email = userId.getEmailAddress();
     if (email != null && !email.equals("")) {
       emailTerm = SymbolTerm.create(email);
     }
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index 4644af87..f416f11 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -4,12 +4,13 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//lib:gwtorm",
         "//lib/flogger:api",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/prolog:runtime",
+        "@guava//jar",
     ],
 )
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 1d0ba8a..d491c0e 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -7,8 +7,6 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.StoredValues;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
@@ -36,27 +34,21 @@
     Term a1 = arg1.dereference();
 
     Term listHead = Prolog.Nil;
-    try {
-      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
-      LabelTypes types = cd.getLabelTypes();
+    ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
+    LabelTypes types = cd.getLabelTypes();
 
-      for (PatchSetApproval a : cd.currentApprovals()) {
-        LabelType t = types.byLabel(a.getLabelId());
-        if (t == null) {
-          continue;
-        }
-
-        StructureTerm labelTerm =
-            new StructureTerm(
-                sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.getValue()));
-
-        StructureTerm userTerm =
-            new StructureTerm(sym_user, new IntegerTerm(a.getAccountId().get()));
-
-        listHead = new ListTerm(new StructureTerm(sym_commit_label, labelTerm, userTerm), listHead);
+    for (PatchSetApproval a : cd.currentApprovals()) {
+      LabelType t = types.byLabel(a.labelId());
+      if (t == null) {
+        continue;
       }
-    } catch (OrmException err) {
-      throw new JavaException(this, 1, err);
+
+      StructureTerm labelTerm =
+          new StructureTerm(sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.value()));
+
+      StructureTerm userTerm = new StructureTerm(sym_user, new IntegerTerm(a.accountId().get()));
+
+      listHead = new ListTerm(new StructureTerm(sym_commit_label, labelTerm, userTerm), listHead);
     }
 
     if (!a1.unify(listHead, engine.trail)) {
diff --git a/java/gerrit/PRED_change_branch_1.java b/java/gerrit/PRED_change_branch_1.java
index 0a7bb74..aef00f2 100644
--- a/java/gerrit/PRED_change_branch_1.java
+++ b/java/gerrit/PRED_change_branch_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -34,9 +34,9 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    Branch.NameKey name = StoredValues.getChange(engine).getDest();
+    BranchNameKey name = StoredValues.getChange(engine).getDest();
 
-    if (!a1.unify(SymbolTerm.create(name.get()), engine.trail)) {
+    if (!a1.unify(SymbolTerm.create(name.branch()), engine.trail)) {
       return engine.fail();
     }
     return cont;
diff --git a/java/gerrit/PRED_commit_author_3.java b/java/gerrit/PRED_commit_author_3.java
index a876b5e..998b30e 100644
--- a/java/gerrit/PRED_commit_author_3.java
+++ b/java/gerrit/PRED_commit_author_3.java
@@ -14,13 +14,12 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 public class PRED_commit_author_3 extends AbstractCommitUserIdentityPredicate {
   public PRED_commit_author_3(Term a1, Term a2, Term a3, Operation n) {
@@ -29,8 +28,7 @@
 
   @Override
   public Operation exec(Prolog engine) throws PrologException {
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
-    UserIdentity author = psInfo.getAuthor();
-    return exec(engine, author);
+    RevCommit revCommit = StoredValues.COMMIT.get(engine);
+    return exec(engine, revCommit.getAuthorIdent());
   }
 }
diff --git a/java/gerrit/PRED_commit_committer_3.java b/java/gerrit/PRED_commit_committer_3.java
index b24b004..293d8ce 100644
--- a/java/gerrit/PRED_commit_committer_3.java
+++ b/java/gerrit/PRED_commit_committer_3.java
@@ -14,13 +14,12 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 public class PRED_commit_committer_3 extends AbstractCommitUserIdentityPredicate {
   public PRED_commit_committer_3(Term a1, Term a2, Term a3, Operation n) {
@@ -29,8 +28,7 @@
 
   @Override
   public Operation exec(Prolog engine) throws PrologException {
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
-    UserIdentity committer = psInfo.getCommitter();
-    return exec(engine, committer);
+    RevCommit revCommit = StoredValues.COMMIT.get(engine);
+    return exec(engine, revCommit.getCommitterIdent());
   }
 }
diff --git a/java/gerrit/PRED_commit_delta_4.java b/java/gerrit/PRED_commit_delta_4.java
index 7c26632..d2634ea 100644
--- a/java/gerrit/PRED_commit_delta_4.java
+++ b/java/gerrit/PRED_commit_delta_4.java
@@ -102,7 +102,7 @@
         String oldName = patch.getOldName();
         Patch.ChangeType changeType = patch.getChangeType();
 
-        if (newName.equals("/COMMIT_MSG")) {
+        if (Patch.isMagic(newName)) {
           continue;
         }
 
diff --git a/java/gerrit/PRED_commit_edits_2.java b/java/gerrit/PRED_commit_edits_2.java
index c196026..6ca5338 100644
--- a/java/gerrit/PRED_commit_edits_2.java
+++ b/java/gerrit/PRED_commit_edits_2.java
@@ -14,6 +14,7 @@
 
 package gerrit;
 
+import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.Text;
@@ -90,17 +91,13 @@
         String newName = entry.getNewName();
         String oldName = entry.getOldName();
 
-        if (newName.equals("/COMMIT_MSG")) {
+        if (Patch.isMagic(newName)) {
           continue;
         }
 
         if (fileRegex.matcher(newName).find()
             || (oldName != null && fileRegex.matcher(oldName).find())) {
-          // This cast still seems to be needed on JDK 8 as workaround for:
-          // https://bugs.openjdk.java.net/browse/JDK-8039214
-          @SuppressWarnings("cast")
-          List<Edit> edits = (List<Edit>) entry.getEdits();
-
+          List<Edit> edits = entry.getEdits();
           if (edits.isEmpty()) {
             continue;
           }
diff --git a/java/gerrit/PRED_commit_message_1.java b/java/gerrit/PRED_commit_message_1.java
index 05bb4bb..eb996d6 100644
--- a/java/gerrit/PRED_commit_message_1.java
+++ b/java/gerrit/PRED_commit_message_1.java
@@ -21,6 +21,7 @@
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
  * Returns the commit message as a symbol
@@ -40,7 +41,8 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    String commitMessage = StoredValues.COMMIT_MESSAGE.get(engine);
+    RevCommit revCommit = StoredValues.COMMIT.get(engine);
+    String commitMessage = revCommit.getFullMessage();
 
     SymbolTerm msg = SymbolTerm.create(commitMessage);
     if (!a1.unify(msg, engine.trail)) {
diff --git a/java/gerrit/PRED_get_legacy_label_types_1.java b/java/gerrit/PRED_get_legacy_label_types_1.java
index ef79e05..2f0c1ea 100644
--- a/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -17,8 +17,6 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.server.rules.StoredValues;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
@@ -53,12 +51,7 @@
   public Operation exec(Prolog engine) throws PrologException {
     engine.setB0();
     Term a1 = arg1.dereference();
-    List<LabelType> list;
-    try {
-      list = StoredValues.CHANGE_DATA.get(engine).getLabelTypes().getLabelTypes();
-    } catch (OrmException err) {
-      throw new JavaException(this, 1, err);
-    }
+    List<LabelType> list = StoredValues.CHANGE_DATA.get(engine).getLabelTypes().getLabelTypes();
     Term head = Prolog.Nil;
     for (int idx = list.size() - 1; 0 <= idx; idx--) {
       head = new ListTerm(export(list.get(idx)), head);
diff --git a/java/gerrit/PRED_pure_revert_1.java b/java/gerrit/PRED_pure_revert_1.java
index 95a0729..6300a668 100644
--- a/java/gerrit/PRED_pure_revert_1.java
+++ b/java/gerrit/PRED_pure_revert_1.java
@@ -15,8 +15,6 @@
 package gerrit;
 
 import com.google.gerrit.server.rules.StoredValues;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -36,12 +34,7 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    Boolean isPureRevert;
-    try {
-      isPureRevert = StoredValues.CHANGE_DATA.get(engine).isPureRevert();
-    } catch (OrmException e) {
-      throw new JavaException(this, 1, e);
-    }
+    Boolean isPureRevert = StoredValues.CHANGE_DATA.get(engine).isPureRevert();
     if (!a1.unify(new IntegerTerm(Boolean.TRUE.equals(isPureRevert) ? 1 : 0), engine.trail)) {
       return engine.fail();
     }
diff --git a/java/gerrit/PRED_unresolved_comments_count_1.java b/java/gerrit/PRED_unresolved_comments_count_1.java
index 5ed1525..d4abcc54 100644
--- a/java/gerrit/PRED_unresolved_comments_count_1.java
+++ b/java/gerrit/PRED_unresolved_comments_count_1.java
@@ -15,8 +15,6 @@
 package gerrit;
 
 import com.google.gerrit.server.rules.StoredValues;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -35,13 +33,9 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    try {
-      Integer count = StoredValues.CHANGE_DATA.get(engine).unresolvedCommentCount();
-      if (!a1.unify(new IntegerTerm(count != null ? count : 0), engine.trail)) {
-        return engine.fail();
-      }
-    } catch (OrmException err) {
-      throw new JavaException(this, 1, err);
+    Integer count = StoredValues.CHANGE_DATA.get(engine).unresolvedCommentCount();
+    if (!a1.unify(new IntegerTerm(count != null ? count : 0), engine.trail)) {
+      return engine.fail();
     }
     return cont;
   }
diff --git a/java/gerrit/PRED_uploader_1.java b/java/gerrit/PRED_uploader_1.java
index 029b84a..feb8302 100644
--- a/java/gerrit/PRED_uploader_1.java
+++ b/java/gerrit/PRED_uploader_1.java
@@ -50,7 +50,7 @@
       return engine.fail();
     }
 
-    Account.Id uploaderId = patchSet.getUploader();
+    Account.Id uploaderId = patchSet.uploader();
 
     if (!a1.unify(new StructureTerm(user, new IntegerTerm(uploaderId.get())), engine.trail)) {
       return engine.fail();
diff --git a/java/org/eclipse/jgit/BUILD b/java/org/eclipse/jgit/BUILD
deleted file mode 100644
index 95fef28..0000000
--- a/java/org/eclipse/jgit/BUILD
+++ /dev/null
@@ -1,48 +0,0 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-gwt_module(
-    name = "client",
-    srcs = [
-        "diff/Edit_JsonSerializer.java",
-        "diff/ReplaceEdit.java",
-    ],
-    gwt_xml = "JGit.gwt.xml",
-    visibility = ["//visibility:public"],
-    deps = [
-        ":Edit",
-        "//lib:gwtjsonrpc",
-        "//lib/gwt:user",
-    ],
-)
-
-gwt_module(
-    name = "Edit",
-    srcs = [":jgit_edit_src"],
-    visibility = ["//visibility:public"],
-)
-
-genrule2(
-    name = "jgit_edit_src",
-    outs = ["edit.srcjar"],
-    cmd = " && ".join([
-        "unzip -qd $$TMP $(location //lib/jgit/org.eclipse.jgit:jgit-source) " +
-        "org/eclipse/jgit/diff/Edit.java",
-        "cd $$TMP",
-        "zip -Dq $$ROOT/$@ org/eclipse/jgit/diff/Edit.java",
-    ]),
-    tools = ["//lib/jgit/org.eclipse.jgit:jgit-source"],
-)
-
-java_library(
-    name = "server",
-    srcs = [
-        "diff/EditDeserializer.java",
-        "diff/ReplaceEdit.java",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//lib:gson",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
diff --git a/java/org/eclipse/jgit/JGit.gwt.xml b/java/org/eclipse/jgit/JGit.gwt.xml
deleted file mode 100644
index 5aeb936..0000000
--- a/java/org/eclipse/jgit/JGit.gwt.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <source path='diff' includes='
-      Edit.java
-      Edit_JsonSerializer.java
-      ReplaceEdit.java
-    '/>
-</module>
diff --git a/java/org/eclipse/jgit/diff/EditDeserializer.java b/java/org/eclipse/jgit/diff/EditDeserializer.java
deleted file mode 100644
index 9435979..0000000
--- a/java/org/eclipse/jgit/diff/EditDeserializer.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package org.eclipse.jgit.diff;
-
-import com.google.gson.JsonArray;
-import com.google.gson.JsonDeserializationContext;
-import com.google.gson.JsonDeserializer;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonNull;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonPrimitive;
-import com.google.gson.JsonSerializationContext;
-import com.google.gson.JsonSerializer;
-import java.lang.reflect.Type;
-import java.util.ArrayList;
-import java.util.List;
-
-public class EditDeserializer implements JsonDeserializer<Edit>, JsonSerializer<Edit> {
-  @Override
-  public Edit deserialize(final JsonElement json, Type typeOfT, JsonDeserializationContext context)
-      throws JsonParseException {
-    if (json.isJsonNull()) {
-      return null;
-    }
-    if (!json.isJsonArray()) {
-      throw new JsonParseException("Expected array for Edit type");
-    }
-
-    final JsonArray o = (JsonArray) json;
-    final int cnt = o.size();
-    if (cnt < 4 || cnt % 4 != 0) {
-      throw new JsonParseException("Expected array of 4 for Edit type");
-    }
-
-    if (4 == cnt) {
-      return new Edit(get(o, 0), get(o, 1), get(o, 2), get(o, 3));
-    }
-
-    List<Edit> l = new ArrayList<>((cnt / 4) - 1);
-    for (int i = 4; i < cnt; ) {
-      int as = get(o, i++);
-      int ae = get(o, i++);
-      int bs = get(o, i++);
-      int be = get(o, i++);
-      l.add(new Edit(as, ae, bs, be));
-    }
-    return new ReplaceEdit(get(o, 0), get(o, 1), get(o, 2), get(o, 3), l);
-  }
-
-  private static int get(JsonArray a, int idx) throws JsonParseException {
-    final JsonElement v = a.get(idx);
-    if (!v.isJsonPrimitive()) {
-      throw new JsonParseException("Expected array of 4 for Edit type");
-    }
-    final JsonPrimitive p = (JsonPrimitive) v;
-    if (!p.isNumber()) {
-      throw new JsonParseException("Expected array of 4 for Edit type");
-    }
-    return p.getAsInt();
-  }
-
-  @Override
-  public JsonElement serialize(final Edit src, Type typeOfSrc, JsonSerializationContext context) {
-    if (src == null) {
-      return JsonNull.INSTANCE;
-    }
-    final JsonArray a = new JsonArray();
-    add(a, src);
-    if (src instanceof ReplaceEdit) {
-      for (Edit e : ((ReplaceEdit) src).getInternalEdits()) {
-        add(a, e);
-      }
-    }
-    return a;
-  }
-
-  private void add(JsonArray a, Edit src) {
-    a.add(new JsonPrimitive(src.getBeginA()));
-    a.add(new JsonPrimitive(src.getEndA()));
-    a.add(new JsonPrimitive(src.getBeginB()));
-    a.add(new JsonPrimitive(src.getEndB()));
-  }
-}
diff --git a/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java b/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
deleted file mode 100644
index 184cb36..0000000
--- a/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package org.eclipse.jgit.diff;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwtjsonrpc.client.impl.JsonSerializer;
-import java.util.ArrayList;
-import java.util.List;
-
-public class Edit_JsonSerializer extends JsonSerializer<Edit> {
-  public static final Edit_JsonSerializer INSTANCE = new Edit_JsonSerializer();
-
-  @Override
-  public Edit fromJson(Object jso) {
-    if (jso == null) {
-      return null;
-    }
-
-    final JavaScriptObject o = (JavaScriptObject) jso;
-    final int cnt = length(o);
-    if (4 == cnt) {
-      return new Edit(get(o, 0), get(o, 1), get(o, 2), get(o, 3));
-    }
-
-    List<Edit> l = new ArrayList<>((cnt / 4) - 1);
-    for (int i = 4; i < cnt; ) {
-      int as = get(o, i++);
-      int ae = get(o, i++);
-      int bs = get(o, i++);
-      int be = get(o, i++);
-      l.add(new Edit(as, ae, bs, be));
-    }
-    return new ReplaceEdit(get(o, 0), get(o, 1), get(o, 2), get(o, 3), l);
-  }
-
-  @Override
-  public void printJson(StringBuilder sb, Edit o) {
-    sb.append('[');
-    append(sb, o);
-    if (o instanceof ReplaceEdit) {
-      for (Edit e : ((ReplaceEdit) o).getInternalEdits()) {
-        sb.append(',');
-        append(sb, e);
-      }
-    }
-    sb.append(']');
-  }
-
-  private void append(StringBuilder sb, Edit o) {
-    sb.append(o.getBeginA());
-    sb.append(',');
-    sb.append(o.getEndA());
-    sb.append(',');
-    sb.append(o.getBeginB());
-    sb.append(',');
-    sb.append(o.getEndB());
-  }
-
-  private static native int length(JavaScriptObject jso) /*-{ return jso.length; }-*/;
-
-  private static native int get(JavaScriptObject jso, int idx) /*-{ return jso[idx]; }-*/;
-}
diff --git a/java/org/eclipse/jgit/diff/ReplaceEdit.java b/java/org/eclipse/jgit/diff/ReplaceEdit.java
deleted file mode 100644
index 46681c6..0000000
--- a/java/org/eclipse/jgit/diff/ReplaceEdit.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 org.eclipse.jgit.diff;
-
-import java.util.List;
-
-public class ReplaceEdit extends Edit {
-  private List<Edit> internalEdit;
-
-  public ReplaceEdit(int as, int ae, int bs, int be, List<Edit> internal) {
-    super(as, ae, bs, be);
-    internalEdit = internal;
-  }
-
-  public ReplaceEdit(Edit orig, List<Edit> internal) {
-    super(orig.getBeginA(), orig.getEndA(), orig.getBeginB(), orig.getEndB());
-    internalEdit = internal;
-  }
-
-  public List<Edit> getInternalEdits() {
-    return internalEdit;
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/BUILD b/javatests/com/google/gerrit/acceptance/BUILD
index 9246abb..405610b 100644
--- a/javatests/com/google/gerrit/acceptance/BUILD
+++ b/javatests/com/google/gerrit/acceptance/BUILD
@@ -5,6 +5,9 @@
     srcs = glob(["**/*.java"]),
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/truth",
         "//lib:guava",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java b/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
index 49c23e3..3d17de0 100644
--- a/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
+++ b/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -111,7 +112,7 @@
   }
 
   private void assertConfig(MergeableFileBasedConfig cfg, String expected) throws Exception {
-    assertThat(cfg.toText()).isEqualTo(expected);
+    assertThat(cfg).text().isEqualTo(expected);
     cfg.save();
     assertThat(new String(Files.readAllBytes(cfg.getFile().toPath()), UTF_8)).isEqualTo(expected);
   }
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 1844ec6..2b30fe9 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -18,7 +18,6 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
@@ -31,7 +30,7 @@
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
@@ -50,7 +49,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ProjectResetterTest extends GerritBaseTests {
+public class ProjectResetterTest {
   private InMemoryRepositoryManager repoManager;
   private Project.NameKey project;
   private Repository repo;
@@ -58,7 +57,7 @@
   @Before
   public void setUp() throws Exception {
     repoManager = new InMemoryRepositoryManager();
-    project = new Project.NameKey("foo");
+    project = Project.nameKey("foo");
     repo = repoManager.createRepository(project);
   }
 
@@ -135,7 +134,7 @@
 
   @Test
   public void onlyResetMatchingRefsMultipleProjects() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
     Ref matchingRefProject1 = createRef("refs/foo/test");
@@ -170,7 +169,7 @@
 
   @Test
   public void onlyDeleteNewlyCreatedMatchingRefsMultipleProjects() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
     Ref matchingRefProject1;
@@ -216,7 +215,7 @@
 
   @Test
   public void projectEvictionIfRefsMetaConfigIsReset() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
     Ref metaConfig = createRef(repo2, RefNames.REFS_CONFIG);
 
@@ -239,7 +238,7 @@
 
   @Test
   public void projectEvictionIfRefsMetaConfigIsDeleted() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
     ProjectCache projectCache = EasyMock.createNiceMock(ProjectCache.class);
@@ -259,8 +258,8 @@
 
   @Test
   public void accountEvictionIfUserBranchIsReset() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     Ref userBranch = createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
@@ -275,7 +274,7 @@
     EasyMock.replay(accountIndexer);
 
     // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(2)));
+    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(2)));
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -289,8 +288,8 @@
 
   @Test
   public void accountEvictionIfUserBranchIsDeleted() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
     AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
@@ -307,7 +306,7 @@
         builder(null, accountCache, accountIndexer, null, null, null, null)
             .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       // Non-user branch because it's not in All-Users.
-      createRef(RefNames.refsUsers(new Account.Id(2)));
+      createRef(RefNames.refsUsers(Account.id(2)));
 
       createRef(allUsersRepo, RefNames.refsUsers(accountId));
     }
@@ -317,13 +316,13 @@
 
   @Test
   public void accountEvictionIfExternalIdsBranchIsReset() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     Ref externalIds = createRef(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
     createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
-    Account.Id accountId2 = new Account.Id(2);
+    Account.Id accountId2 = Account.id(2);
 
     AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
     accountCache.evict(accountId);
@@ -340,7 +339,7 @@
     EasyMock.replay(accountIndexer);
 
     // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
+    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -355,12 +354,12 @@
 
   @Test
   public void accountEvictionIfExternalIdsBranchIsDeleted() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
-    Account.Id accountId2 = new Account.Id(2);
+    Account.Id accountId2 = Account.id(2);
 
     AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
     accountCache.evict(accountId);
@@ -377,7 +376,7 @@
     EasyMock.replay(accountIndexer);
 
     // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
+    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -392,8 +391,8 @@
 
   @Test
   public void accountEvictionFromAccountCreatorIfUserBranchIsDeleted() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
     AccountCreator accountCreator = EasyMock.createNiceMock(AccountCreator.class);
@@ -412,10 +411,10 @@
 
   @Test
   public void groupEviction() throws Exception {
-    AccountGroup.UUID uuid1 = new AccountGroup.UUID("abcd1");
-    AccountGroup.UUID uuid2 = new AccountGroup.UUID("abcd2");
-    AccountGroup.UUID uuid3 = new AccountGroup.UUID("abcd3");
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    AccountGroup.UUID uuid1 = AccountGroup.uuid("abcd1");
+    AccountGroup.UUID uuid2 = AccountGroup.uuid("abcd2");
+    AccountGroup.UUID uuid3 = AccountGroup.uuid("abcd3");
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
     GroupCache cache = EasyMock.createNiceMock(GroupCache.class);
diff --git a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
index 3c7b966..fc42474 100644
--- a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
+++ b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
@@ -30,7 +30,7 @@
   @Inject private UniversalGroupBackend universalGroupBackend;
 
   private final TestGroupBackend testGroupBackend = new TestGroupBackend();
-  private final AccountGroup.UUID testUUID = new AccountGroup.UUID("testbackend:test");
+  private final AccountGroup.UUID testUUID = AccountGroup.uuid("testbackend:test");
 
   @Test
   public void handlesTestGroup() throws Exception {
@@ -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 {
@@ -49,7 +49,7 @@
 
   @Test
   public void doesNotHandleLDAP() throws Exception {
-    assertThat(testGroupBackend.handles(new AccountGroup.UUID("ldap:1234"))).isFalse();
+    assertThat(testGroupBackend.handles(AccountGroup.uuid("ldap:1234"))).isFalse();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 1d0f3ac..6990ab5 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -14,12 +14,18 @@
 
 package com.google.gerrit.acceptance.api.accounts;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.gpg.testing.TestKeys.allValidKeys;
@@ -31,7 +37,10 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -50,28 +59,37 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -86,14 +104,17 @@
 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.git.LockFailureException;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountState;
@@ -106,23 +127,23 @@
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.StalenessChecker;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.name.Named;
@@ -182,36 +203,26 @@
     return cfg;
   }
 
-  @Inject private Provider<PublicKeyStore> publicKeyStoreProvider;
-
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
-
-  @Inject private ExternalIds externalIds;
-
+  @Inject private AccountIndexer accountIndexer;
   @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
-
   @Inject private DynamicSet<GitReferenceUpdatedListener> refUpdateListeners;
-
-  @Inject private Sequences seq;
-
+  @Inject private ExternalIdNotes.Factory extIdNotesFactory;
+  @Inject private ExternalIds externalIds;
+  @Inject private GitReferenceUpdated gitReferenceUpdated;
+  @Inject private ProjectOperations projectOperations;
   @Inject private Provider<InternalAccountQuery> accountQueryProvider;
+  @Inject private Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+  @Inject private Provider<PublicKeyStore> publicKeyStoreProvider;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private RetryHelper.Metrics retryMetrics;
+  @Inject private Sequences seq;
+  @Inject private StalenessChecker stalenessChecker;
+  @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
+  @Inject private PermissionBackend permissionBackend;
 
   @Inject protected Emails emails;
 
-  @Inject private StalenessChecker stalenessChecker;
-
-  @Inject private AccountIndexer accountIndexer;
-
-  @Inject private GitReferenceUpdated gitReferenceUpdated;
-
-  @Inject private RetryHelper.Metrics retryMetrics;
-
-  @Inject private Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
-
-  @Inject private ExternalIdNotes.Factory extIdNotesFactory;
-
-  @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
-
   @Inject
   @Named("accounts")
   private LoadingCache<Account.Id, Optional<AccountState>> accountsCache;
@@ -221,6 +232,8 @@
   @Inject
   private DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners;
 
+  @Inject protected GroupOperations groupOperations;
+
   private AccountIndexedCounter accountIndexedCounter;
   private RegistrationHandle accountIndexEventCounterHandle;
   private RefUpdateCounter refUpdateCounter;
@@ -229,7 +242,7 @@
   @Before
   public void addAccountIndexEventCounter() {
     accountIndexedCounter = new AccountIndexedCounter();
-    accountIndexEventCounterHandle = accountIndexedListeners.add(accountIndexedCounter);
+    accountIndexEventCounterHandle = accountIndexedListeners.add("gerrit", accountIndexedCounter);
   }
 
   @After
@@ -242,7 +255,7 @@
   @Before
   public void addRefUpdateCounter() {
     refUpdateCounter = new RefUpdateCounter();
-    refUpdateCounterHandle = refUpdateListeners.add(refUpdateCounter);
+    refUpdateCounterHandle = refUpdateListeners.add("gerrit", refUpdateCounter);
   }
 
   @After
@@ -300,7 +313,7 @@
 
   @Test
   public void createByAccountCreator() throws Exception {
-    Account.Id accountId = createByAccountCreator(2); // account creation + external ID creation
+    Account.Id accountId = createByAccountCreator(1);
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
         RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
@@ -310,19 +323,19 @@
   private Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
     String name = "foo";
     TestAccount foo = accountCreator.create(name);
-    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    AccountInfo info = gApi.accounts().id(foo.id().get()).get();
     assertThat(info.username).isEqualTo(name);
     assertThat(info.name).isEqualTo(name);
     accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
-    assertUserBranch(foo.getId(), name, null);
-    return foo.getId();
+    assertUserBranch(foo.id(), name, null);
+    return foo.id();
   }
 
   @Test
   public void createAnonymousCowardByAccountCreator() throws Exception {
     TestAccount anonymousCoward = accountCreator.create();
     accountIndexedCounter.assertReindexOf(anonymousCoward);
-    assertUserBranchWithoutAccountConfig(anonymousCoward.getId());
+    assertUserBranchWithoutAccountConfig(anonymousCoward.id());
   }
 
   @Test
@@ -338,8 +351,8 @@
     assertThat(accountInfo.email).isEqualTo(input.email);
     assertThat(accountInfo.status).isNull();
 
-    Account.Id accountId = new Account.Id(accountInfo._accountId);
-    accountIndexedCounter.assertReindexOf(accountId, 2); // account creation + external ID creation
+    Account.Id accountId = Account.id(accountInfo._accountId);
+    accountIndexedCounter.assertReindexOf(accountId, 1);
     assertThat(externalIds.byAccount(accountId))
         .containsExactly(
             ExternalId.createUsername(input.username, accountId, null),
@@ -349,28 +362,30 @@
   @Test
   public void createAccountUsernameAlreadyTaken() throws Exception {
     AccountInput input = new AccountInput();
-    input.username = admin.username;
+    input.username = admin.username();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("username '" + admin.username + "' already exists");
-    gApi.accounts().create(input);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.accounts().create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("username '" + admin.username() + "' already exists");
   }
 
   @Test
   public void createAccountEmailAlreadyTaken() throws Exception {
     AccountInput input = new AccountInput();
     input.username = "foo";
-    input.email = admin.email;
+    input.email = admin.email();
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("email '" + admin.email + "' already exists");
-    gApi.accounts().create(input);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> gApi.accounts().create(input));
+    assertThat(thrown).hasMessageThat().contains("email '" + admin.email() + "' already exists");
   }
 
   @Test
   public void commitMessageOnAccountUpdates() throws Exception {
     AccountsUpdate au = accountsUpdateProvider.get();
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     au.insert("Create Test Account", accountId, u -> {});
     assertLastCommitMessageOfUserBranch(accountId, "Create Test Account");
 
@@ -392,7 +407,7 @@
   public void createAtomically() throws Exception {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
     try {
-      Account.Id accountId = new Account.Id(seq.nextAccountId());
+      Account.Id accountId = Account.id(seq.nextAccountId());
       String fullName = "Foo";
       ExternalId extId = ExternalId.createEmail(accountId, "foo@example.com");
       AccountState accountState =
@@ -402,7 +417,7 @@
                   "Create Account Atomically",
                   accountId,
                   u -> u.setFullName(fullName).addExternalId(extId));
-      assertThat(accountState.getAccount().getFullName()).isEqualTo(fullName);
+      assertThat(accountState.getAccount().fullName()).isEqualTo(fullName);
 
       AccountInfo info = gApi.accounts().id(accountId.get()).get();
       assertThat(info.name).isEqualTo(fullName);
@@ -410,8 +425,10 @@
       List<EmailInfo> emails = gApi.accounts().id(accountId.get()).getEmails();
       assertThat(emails.stream().map(e -> e.email).collect(toSet())).containsExactly(extId.email());
 
-      RevCommit commitUserBranch = getRemoteHead(allUsers, RefNames.refsUsers(accountId));
-      RevCommit commitRefsMetaExternalIds = getRemoteHead(allUsers, RefNames.REFS_EXTERNAL_IDS);
+      RevCommit commitUserBranch =
+          projectOperations.project(allUsers).getHead(RefNames.refsUsers(accountId));
+      RevCommit commitRefsMetaExternalIds =
+          projectOperations.project(allUsers).getHead(RefNames.REFS_EXTERNAL_IDS);
       assertThat(commitUserBranch.getCommitTime())
           .isEqualTo(commitRefsMetaExternalIds.getCommitTime());
     } finally {
@@ -421,7 +438,7 @@
 
   @Test
   public void updateNonExistingAccount() throws Exception {
-    Account.Id nonExistingAccountId = new Account.Id(999999);
+    Account.Id nonExistingAccountId = Account.id(999999);
     AtomicBoolean consumerCalled = new AtomicBoolean();
     Optional<AccountState> accountState =
         accountsUpdateProvider
@@ -435,18 +452,18 @@
   @Test
   public void updateAccountWithoutAccountConfigNoteDb() throws Exception {
     TestAccount anonymousCoward = accountCreator.create();
-    assertUserBranchWithoutAccountConfig(anonymousCoward.getId());
+    assertUserBranchWithoutAccountConfig(anonymousCoward.id());
 
     String status = "OOO";
     Optional<AccountState> accountState =
         accountsUpdateProvider
             .get()
-            .update("Set status", anonymousCoward.getId(), u -> u.setStatus(status));
+            .update("Set status", anonymousCoward.id(), u -> u.setStatus(status));
     assertThat(accountState).isPresent();
     Account account = accountState.get().getAccount();
-    assertThat(account.getFullName()).isNull();
-    assertThat(account.getStatus()).isEqualTo(status);
-    assertUserBranch(anonymousCoward.getId(), null, status);
+    assertThat(account.fullName()).isNull();
+    assertThat(account.status()).isEqualTo(status);
+    assertUserBranch(anonymousCoward.id(), null, status);
   }
 
   private void assertUserBranchWithoutAccountConfig(Account.Id accountId) throws Exception {
@@ -462,8 +479,8 @@
       assertThat(ref).isNotNull();
       RevCommit c = rw.parseCommit(ref.getObjectId());
       long timestampDiffMs =
-          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).getRegisteredOn().getTime());
-      assertThat(timestampDiffMs).isAtMost(ChangeRebuilderImpl.MAX_WINDOW_MS);
+          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().getTime());
+      assertThat(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
 
       // Check the 'account.config' file.
       try (TreeWalk tw = TreeWalk.forPath(or, AccountProperties.ACCOUNT_CONFIG, c.getTree())) {
@@ -471,10 +488,11 @@
           assertThat(tw).isNotNull();
           Config cfg = new Config();
           cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
-          assertThat(
-                  cfg.getString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_FULL_NAME))
+          assertThat(cfg)
+              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
               .isEqualTo(name);
-          assertThat(cfg.getString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS))
+          assertThat(cfg)
+              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS)
               .isEqualTo(status);
         } else {
           // No account properties were set, hence an 'account.config' file was not created.
@@ -513,28 +531,41 @@
 
   @Test
   public void active() throws Exception {
+    int id = gApi.accounts().id("user").get()._accountId;
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
     gApi.accounts().id("user").setActive(false);
-    assertThat(gApi.accounts().id("user").getActive()).isFalse();
     accountIndexedCounter.assertReindexOf(user);
 
-    gApi.accounts().id("user").setActive(true);
+    // Inactive users may only be resolved by ID.
+    ResourceNotFoundException thrown =
+        assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("user"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Account 'user' only matches inactive accounts. To use an inactive account, retry"
+                + " with one of the following exact account IDs:\n"
+                + id
+                + ": User <user@example.com>");
+    assertThat(gApi.accounts().id(id).getActive()).isFalse();
+
+    gApi.accounts().id(id).setActive(true);
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
     accountIndexedCounter.assertReindexOf(user);
   }
 
   @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 {
-                String preferredEmail = account.getAccount().getPreferredEmail();
+                String preferredEmail = account.getAccount().preferredEmail();
                 if (preferredEmail == null || !preferredEmail.endsWith("@activatable.com")) {
                   throw new ValidationException("not allowed to active account");
                 }
@@ -542,7 +573,7 @@
 
               @Override
               public void validateDeactivation(AccountState account) throws ValidationException {
-                String preferredEmail = account.getAccount().getPreferredEmail();
+                String preferredEmail = account.getAccount().preferredEmail();
                 if (preferredEmail == null || !preferredEmail.endsWith("@deactivatable.com")) {
                   throw new ValidationException("not allowed to deactive account");
                 }
@@ -551,62 +582,53 @@
     try {
       /* Test account that can be activated, but not deactivated */
       // Deactivate account that is already inactive
-      try {
-        gApi.accounts().id(activatableAccount.accountId().get()).setActive(false);
-        fail("Expected exception");
-      } catch (ResourceConflictException e) {
-        assertThat(e.getMessage()).isEqualTo("account not active");
-      }
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active())
-          .isFalse();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
+      assertThat(thrown).hasMessageThat().isEqualTo("account not active");
+      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);
-        fail("Expected exception");
-      } catch (ResourceConflictException e) {
-        assertThat(e.getMessage()).isEqualTo("not allowed to deactive account");
-      }
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
+      assertThat(thrown).hasMessageThat().isEqualTo("not allowed to deactive account");
+      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);
-        fail("Expected exception");
-      } catch (ResourceConflictException e) {
-        assertThat(e.getMessage()).isEqualTo("account not active");
-      }
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(false));
+      assertThat(thrown).hasMessageThat().isEqualTo("account not active");
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
 
       // Try activating account that cannot be activated
-      try {
-        gApi.accounts().id(deactivatableAccount.accountId().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();
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(true));
+      assertThat(thrown).hasMessageThat().isEqualTo("not allowed to active account");
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
     } finally {
       registrationHandle.remove();
     }
@@ -614,23 +636,23 @@
 
   @Test
   public void deactivateSelf() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot deactivate own account");
-    gApi.accounts().self().setActive(false);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.accounts().self().setActive(false));
+    assertThat(thrown).hasMessageThat().contains("cannot deactivate own account");
   }
 
   @Test
   public void deactivateNotActive() throws Exception {
+    int id = gApi.accounts().id("user").get()._accountId;
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
     gApi.accounts().id("user").setActive(false);
-    assertThat(gApi.accounts().id("user").getActive()).isFalse();
-    try {
-      gApi.accounts().id("user").setActive(false);
-      fail("Expected exception");
-    } catch (ResourceConflictException e) {
-      assertThat(e.getMessage()).isEqualTo("account not active");
-    }
-    gApi.accounts().id("user").setActive(true);
+    assertThat(gApi.accounts().id(id).getActive()).isFalse();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.accounts().id(id).setActive(false));
+    assertThat(thrown).hasMessageThat().isEqualTo("account not active");
+    gApi.accounts().id(id).setActive(true);
   }
 
   @Test
@@ -645,7 +667,7 @@
     assertThat(change.stars).contains(DEFAULT_LABEL);
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+            allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
     gApi.accounts().self().unstarChange(triplet);
     change = info(triplet);
@@ -653,7 +675,7 @@
     assertThat(change.stars).isNull();
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+            allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
     accountIndexedCounter.assertNoReindex();
   }
@@ -684,7 +706,7 @@
     assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+            allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
     gApi.accounts()
         .self()
@@ -703,28 +725,36 @@
     assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+            allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
     accountIndexedCounter.assertNoReindex();
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to get stars of another account");
-    gApi.accounts().id(Integer.toString((admin.id.get()))).getStars(triplet);
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().id(Integer.toString((admin.id().get()))).getStars(triplet));
+    assertThat(thrown).hasMessageThat().contains("not allowed to get stars of another account");
   }
 
   @Test
   public void starWithInvalidLabels() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid labels: another invalid label, invalid label");
-    gApi.accounts()
-        .self()
-        .setStars(
-            triplet,
-            new StarsInput(
-                ImmutableSet.of(DEFAULT_LABEL, "invalid label", "blue", "another invalid label")));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        triplet,
+                        new StarsInput(
+                            ImmutableSet.of(
+                                DEFAULT_LABEL, "invalid label", "blue", "another invalid label"))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("invalid labels: another invalid label, invalid label");
   }
 
   @Test
@@ -742,17 +772,24 @@
   public void starWithDefaultAndIgnoreLabel() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + DEFAULT_LABEL
-            + " and "
-            + IGNORE_LABEL
-            + " are mutually exclusive."
-            + " Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL)));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        triplet,
+                        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + DEFAULT_LABEL
+                + " and "
+                + IGNORE_LABEL
+                + " are mutually exclusive."
+                + " Only one of them can be set.");
   }
 
   @Test
@@ -763,22 +800,22 @@
     PushOneCommit.Result r = createChange();
 
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     in = new AddReviewerInput();
-    in.reviewer = user2.email;
+    in.reviewer = user2.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
 
     sender.clear();
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(r.getChangeId()).abandon();
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+    assertThat(messages.get(0).rcpt()).containsExactly(user2.getEmailAddress());
     accountIndexedCounter.assertNoReindex();
   }
 
@@ -786,20 +823,20 @@
   public void addReviewerToIgnoredChange() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
 
     sender.clear();
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
 
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message message = messages.get(0);
-    assertThat(message.rcpt()).containsExactly(user.emailAddress);
-    assertMailReplyTo(message, admin.email);
+    assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+    assertMailReplyTo(message, admin.email());
     accountIndexedCounter.assertNoReindex();
   }
 
@@ -819,19 +856,71 @@
   }
 
   @Test
+  public void getOwnDetail() throws Exception {
+    String email = "preferred@example.com";
+    String name = "Foo";
+    String username = name("foo");
+    TestAccount foo = accountCreator.create(username, email, name);
+    String secondaryEmail = "secondary@example.com";
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
+
+    String status = "OOO";
+    gApi.accounts().id(foo.id().get()).setStatus(status);
+
+    requestScopeOperations.setApiUser(foo.id());
+    AccountDetailInfo detail = gApi.accounts().id(foo.id().get()).detail();
+    assertThat(detail._accountId).isEqualTo(foo.id().get());
+    assertThat(detail.name).isEqualTo(name);
+    assertThat(detail.username).isEqualTo(username);
+    assertThat(detail.email).isEqualTo(email);
+    assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
+    assertThat(detail.status).isEqualTo(status);
+    assertThat(detail.registeredOn).isEqualTo(getAccount(foo.id()).registeredOn());
+    assertThat(detail.inactive).isNull();
+    assertThat(detail._moreAccounts).isNull();
+  }
+
+  @Test
+  public void detailOfOtherAccountDoesntIncludeSecondaryEmailsWithoutModifyAccount()
+      throws Exception {
+    String email = "preferred@example.com";
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    String secondaryEmail = "secondary@example.com";
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
+
+    requestScopeOperations.setApiUser(user.id());
+    AccountDetailInfo detail = gApi.accounts().id(foo.id().get()).detail();
+    assertThat(detail.secondaryEmails).isNull();
+  }
+
+  @Test
+  public void detailOfOtherAccountIncludeSecondaryEmailsWithModifyAccount() throws Exception {
+    String email = "preferred@example.com";
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    String secondaryEmail = "secondary@example.com";
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
+
+    AccountDetailInfo detail = gApi.accounts().id(foo.id().get()).detail();
+    assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
+  }
+
+  @Test
   public void getOwnEmails() throws Exception {
     String email = "preferred@example.com";
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
 
-    setApiUser(foo);
+    requestScopeOperations.setApiUser(foo.id());
     assertThat(getEmails()).containsExactly(email);
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     String secondaryEmail = "secondary@example.com";
     EmailInput input = newEmailInput(secondaryEmail);
-    gApi.accounts().id(foo.id.hashCode()).addEmail(input);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
 
-    setApiUser(foo);
+    requestScopeOperations.setApiUser(foo.id());
     assertThat(getEmails()).containsExactly(email, secondaryEmail);
   }
 
@@ -840,10 +929,10 @@
     String email = "preferred2@example.com";
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(foo.id.get()).getEmails();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.accounts().id(foo.id().get()).getEmails());
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
@@ -852,13 +941,10 @@
     String secondaryEmail = "secondary3@example.com";
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
     EmailInput input = newEmailInput(secondaryEmail);
-    gApi.accounts().id(foo.id.hashCode()).addEmail(input);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
 
     assertThat(
-            gApi.accounts()
-                .id(foo.id.get())
-                .getEmails()
-                .stream()
+            gApi.accounts().id(foo.id().get()).getEmails().stream()
                 .map(e -> e.email)
                 .collect(toSet()))
         .containsExactly(email, secondaryEmail);
@@ -875,8 +961,8 @@
       accountIndexedCounter.assertReindexOf(admin);
     }
 
-    resetCurrentApiUser();
-    assertThat(getEmails()).containsAllIn(emails);
+    requestScopeOperations.resetCurrentApiUser();
+    assertThat(getEmails()).containsAtLeastElementsIn(emails);
   }
 
   @Test
@@ -896,12 +982,9 @@
             "new.email@example.africa");
     for (String email : emails) {
       EmailInput input = newEmailInput(email);
-      try {
-        gApi.accounts().self().addEmail(input);
-        fail("Expected BadRequestException for invalid email address: " + email);
-      } catch (BadRequestException e) {
-        assertThat(e).hasMessageThat().isEqualTo("invalid email address");
-      }
+      BadRequestException thrown =
+          assertThrows(BadRequestException.class, () -> gApi.accounts().self().addEmail(input));
+      assertWithMessage(email).that(thrown).hasMessageThat().isEqualTo("invalid email address");
     }
     accountIndexedCounter.assertNoReindex();
   }
@@ -910,9 +993,8 @@
   public void cannotAddNonConfirmedEmailWithoutModifyAccountPermission() throws Exception {
     TestAccount account = accountCreator.create(name("user"));
     EmailInput input = newEmailInput("test@test.com");
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.accounts().id(account.username).addEmail(input);
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(AuthException.class, () -> gApi.accounts().id(account.username()).addEmail(input));
   }
 
   @Test
@@ -920,9 +1002,13 @@
     String email = "new.email@example.com";
     EmailInput input = newEmailInput(email);
     gApi.accounts().self().addEmail(input);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Identity 'mailto:" + email + "' in use by another account");
-    gApi.accounts().id(user.username).addEmail(input);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.accounts().id(user.username()).addEmail(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Identity 'mailto:" + email + "' in use by another account");
   }
 
   @Test
@@ -945,7 +1031,7 @@
       value = "HsOc6l+2lhS9G7sE/RsnS7Z6GJjdRDX14co=")
   public void addEmailToBeConfirmedToOwnAccount() throws Exception {
     TestAccount user = accountCreator.create();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
 
     String email = "self@example.com";
     EmailInput input = newEmailInput(email, false);
@@ -956,11 +1042,16 @@
   public void cannotAddEmailToBeConfirmedToOtherAccountWithoutModifyAccountPermission()
       throws Exception {
     TestAccount user = accountCreator.create();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.id.get()).addEmail(newEmailInput("foo@example.com", false));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.accounts()
+                    .id(admin.id().get())
+                    .addEmail(newEmailInput("foo@example.com", false)));
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
@@ -970,7 +1061,24 @@
   public void addEmailToBeConfirmedToOtherAccount() throws Exception {
     TestAccount user = accountCreator.create();
     String email = "me@example.com";
-    gApi.accounts().id(user.id.get()).addEmail(newEmailInput(email, false));
+    gApi.accounts().id(user.id().get()).addEmail(newEmailInput(email, false));
+  }
+
+  @Test
+  public void addEmailAndSetPreferred() throws Exception {
+    String email = "foo.bar@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = true;
+    input.preferred = true;
+    gApi.accounts().self().addEmail(input);
+
+    // Account is reindexed twice; once on adding the new email,
+    // and then again on setting the email preferred.
+    accountIndexedCounter.assertReindexOf(admin, 2);
+
+    String preferred = gApi.accounts().self().get().email;
+    assertThat(preferred).isEqualTo(email);
   }
 
   @Test
@@ -979,18 +1087,65 @@
     EmailInput input = newEmailInput(email);
     gApi.accounts().self().addEmail(input);
 
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
     assertThat(getEmails()).contains(email);
 
     accountIndexedCounter.clear();
     gApi.accounts().self().deleteEmail(input.email);
     accountIndexedCounter.assertReindexOf(admin);
 
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
     assertThat(getEmails()).doesNotContain(email);
   }
 
   @Test
+  public void deletePreferredEmail() throws Exception {
+    String previous = gApi.accounts().self().get().email;
+    String email = "foo.bar.baz@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = true;
+    input.preferred = true;
+    gApi.accounts().self().addEmail(input);
+
+    // Account is reindexed twice; once on adding the new email,
+    // and then again on setting the email preferred.
+    accountIndexedCounter.assertReindexOf(admin, 2);
+
+    // The new preferred email is set
+    assertThat(gApi.accounts().self().get().email).isEqualTo(email);
+
+    accountIndexedCounter.clear();
+    gApi.accounts().self().deleteEmail(input.email);
+    accountIndexedCounter.assertReindexOf(admin);
+
+    requestScopeOperations.resetCurrentApiUser();
+    assertThat(getEmails()).containsExactly(previous);
+    assertThat(gApi.accounts().self().get().email).isNull();
+  }
+
+  @Test
+  @Sandboxed
+  public void deleteAllEmails() throws Exception {
+    EmailInput input = new EmailInput();
+    input.email = "foo.bar@example.com";
+    input.noConfirmation = true;
+    gApi.accounts().self().addEmail(input);
+
+    requestScopeOperations.resetCurrentApiUser();
+    Set<String> allEmails = getEmails();
+    assertThat(allEmails).hasSize(2);
+
+    for (String email : allEmails) {
+      gApi.accounts().self().deleteEmail(email);
+    }
+
+    requestScopeOperations.resetCurrentApiUser();
+    assertThat(getEmails()).isEmpty();
+    assertThat(gApi.accounts().self().get().email).isNull();
+  }
+
+  @Test
   public void deleteEmailFromCustomExternalIdSchemes() throws Exception {
     String email = "foo.bar@example.com";
     String extId1 = "foo:bar";
@@ -999,24 +1154,25 @@
         .get()
         .update(
             "Add External IDs",
-            admin.id,
+            admin.id(),
             u ->
                 u.addExternalId(
-                        ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id, email))
+                        ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id(), email))
                     .addExternalId(
-                        ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email)));
+                        ExternalId.createWithEmail(
+                            ExternalId.Key.parse(extId2), admin.id(), email)));
     accountIndexedCounter.assertReindexOf(admin);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
-        .containsAllOf(extId1, extId2);
+        .containsAtLeast(extId1, extId2);
 
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
     assertThat(getEmails()).contains(email);
 
     gApi.accounts().self().deleteEmail(email);
     accountIndexedCounter.assertReindexOf(admin);
 
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
     assertThat(getEmails()).doesNotContain(email);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
@@ -1029,30 +1185,32 @@
     EmailInput input = new EmailInput();
     input.email = email;
     input.noConfirmation = true;
-    gApi.accounts().id(user.id.get()).addEmail(input);
+    gApi.accounts().id(user.id().get()).addEmail(input);
     accountIndexedCounter.assertReindexOf(user);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(getEmails()).contains(email);
 
     // admin can delete email of user
-    setApiUser(admin);
-    gApi.accounts().id(user.id.get()).deleteEmail(email);
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.accounts().id(user.id().get()).deleteEmail(email);
     accountIndexedCounter.assertReindexOf(user);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(getEmails()).doesNotContain(email);
 
     // user cannot delete email of admin
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.id.get()).deleteEmail(admin.email);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().id(admin.id().get()).deleteEmail(admin.email()));
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
   public void lookUpByEmail() throws Exception {
     // exact match with scheme "mailto:"
-    assertEmail(emails.getAccountFor(admin.email), admin);
+    assertEmail(emails.getAccountFor(admin.email()), admin);
 
     // exact match with other scheme
     String email = "foo.bar@example.com";
@@ -1060,26 +1218,28 @@
         .get()
         .update(
             "Add Email",
-            admin.id,
+            admin.id(),
             u ->
                 u.addExternalId(
-                    ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email)));
+                    ExternalId.createWithEmail(
+                        ExternalId.Key.parse("foo:bar"), admin.id(), email)));
     assertEmail(emails.getAccountFor(email), admin);
 
     // wrong case doesn't match
-    assertThat(emails.getAccountFor(admin.email.toUpperCase(Locale.US))).isEmpty();
+    assertThat(emails.getAccountFor(admin.email().toUpperCase(Locale.US))).isEmpty();
 
     // prefix doesn't match
-    assertThat(emails.getAccountFor(admin.email.substring(0, admin.email.indexOf('@')))).isEmpty();
+    assertThat(emails.getAccountFor(admin.email().substring(0, admin.email().indexOf('@'))))
+        .isEmpty();
 
     // non-existing doesn't match
     assertThat(emails.getAccountFor("non-existing@example.com")).isEmpty();
 
     // lookup several accounts by email at once
     ImmutableSetMultimap<String, Account.Id> byEmails =
-        emails.getAccountsFor(admin.email, user.email);
-    assertEmail(byEmails.get(admin.email), admin);
-    assertEmail(byEmails.get(user.email), user);
+        emails.getAccountsFor(admin.email(), user.email());
+    assertEmail(byEmails.get(admin.email()), admin);
+    assertEmail(byEmails.get(user.email()), user);
   }
 
   @Test
@@ -1090,12 +1250,12 @@
     TestAccount foo = accountCreator.create(name("foo"));
     accountsUpdateProvider
         .get()
-        .update("Set Preferred Email", foo.id, u -> u.setPreferredEmail(prefEmail));
+        .update("Set Preferred Email", foo.id(), u -> u.setPreferredEmail(prefEmail));
 
     // verify that the account is still found when using the preferred email to lookup the account
     ImmutableSet<Account.Id> accountsByPrefEmail = emails.getAccountFor(prefEmail);
     assertThat(accountsByPrefEmail).hasSize(1);
-    assertThat(Iterables.getOnlyElement(accountsByPrefEmail)).isEqualTo(foo.id);
+    assertThat(Iterables.getOnlyElement(accountsByPrefEmail)).isEqualTo(foo.id());
 
     // look up by email prefix doesn't find the account
     accountsByPrefEmail = emails.getAccountFor(prefix);
@@ -1124,11 +1284,42 @@
   }
 
   @Test
+  public void setName() throws Exception {
+    gApi.accounts().self().setName("Admin McAdminface");
+    assertThat(gApi.accounts().self().get().name).isEqualTo("Admin McAdminface");
+  }
+
+  @Test
+  public void adminCanSetNameOfOtherUser() throws Exception {
+    gApi.accounts().id(user.username()).setName("User McUserface");
+    assertThat(gApi.accounts().id(user.username()).get().name).isEqualTo("User McUserface");
+  }
+
+  @Test
+  public void userCannotSetNameOfOtherUser() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class,
+        () -> gApi.accounts().id(admin.username()).setName("Admin McAdminface"));
+  }
+
+  @Test
+  @Sandboxed
+  public void userCanSetNameOfOtherUserWithModifyAccountPermission() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
+        .update();
+    gApi.accounts().id(admin.username()).setName("Admin McAdminface");
+    assertThat(gApi.accounts().id(admin.username()).get().name).isEqualTo("Admin McAdminface");
+  }
+
+  @Test
   public void fetchUserBranch() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
-    String userRefName = RefNames.refsUsers(user.id);
+    String userRefName = RefNames.refsUsers(user.id());
 
     // remove default READ permissions
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
@@ -1139,23 +1330,24 @@
     }
 
     // deny READ permission that is inherited from All-Projects
-    deny(allUsers, RefNames.REFS + "*", Permission.READ, ANONYMOUS_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(deny(Permission.READ).ref(RefNames.REFS + "*").group(ANONYMOUS_USERS))
+        .update();
 
     // fetching user branch without READ permission fails
-    try {
-      fetch(allUsersRepo, userRefName + ":userRef");
-      fail("user branch is visible although no READ permission is granted");
-    } catch (TransportException e) {
-      // expected because no READ granted on user branch
-    }
+    assertThrows(TransportException.class, () -> fetch(allUsersRepo, userRefName + ":userRef"));
 
     // allow each user to read its own user branch
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.READ,
-        false,
-        REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.READ)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS))
+        .update();
 
     // fetch user branch using refs/users/YY/XXXXXXX
     fetch(allUsersRepo, userRefName + ":userRef");
@@ -1171,46 +1363,63 @@
     accountIndexedCounter.assertNoReindex();
 
     // fetching user branch of another user fails
-    String otherUserRefName = RefNames.refsUsers(admin.id);
-    exception.expect(TransportException.class);
-    exception.expectMessage("Remote does not have " + otherUserRefName + " available for fetch.");
-    fetch(allUsersRepo, otherUserRefName + ":otherUserRef");
+    String otherUserRefName = RefNames.refsUsers(admin.id());
+    TransportException thrown =
+        assertThrows(
+            TransportException.class,
+            () -> fetch(allUsersRepo, otherUserRefName + ":otherUserRef"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Remote does not have " + otherUserRefName + " available for fetch.");
+  }
+
+  @Test
+  public void refsUsersSelfIsAdvertised() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      assertThat(
+              permissionBackend
+                  .currentUser()
+                  .project(allUsers)
+                  .filter(ImmutableList.of(), allUsersRepo, RefFilterOptions.defaults())
+                  .keySet())
+          .containsExactly(RefNames.REFS_USERS_SELF);
+    }
   }
 
   @Test
   public void pushToUserBranch() throws Exception {
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
     allUsersRepo.reset("userRef");
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
-    push.to(RefNames.refsUsers(admin.id)).assertOkStatus();
+    PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
+    push.to(RefNames.refsUsers(admin.id())).assertOkStatus();
     accountIndexedCounter.assertReindexOf(admin);
 
-    push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    push = pushFactory.create(admin.newIdent(), allUsersRepo);
     push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
     accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
   public void pushToUserBranchForReview() throws Exception {
-    String userRefName = RefNames.refsUsers(admin.id);
+    String userRefName = RefNames.refsUsers(admin.id());
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRefName + ":userRef");
     allUsersRepo.reset("userRef");
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
     PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
     accountIndexedCounter.assertReindexOf(admin);
 
-    push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    push = pushFactory.create(admin.newIdent(), allUsersRepo);
     r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
     accountIndexedCounter.assertReindexOf(admin);
@@ -1218,7 +1427,7 @@
 
   @Test
   public void pushAccountConfigToUserBranchForReviewAndSubmit() throws Exception {
-    String userRef = RefNames.refsUsers(admin.id);
+    String userRef = RefNames.refsUsers(admin.id());
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
@@ -1229,8 +1438,7 @@
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountProperties.ACCOUNT_CONFIG,
@@ -1238,15 +1446,15 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
     accountIndexedCounter.assertReindexOf(admin);
 
     AccountInfo info = gApi.accounts().self().get();
-    assertThat(info.email).isEqualTo(admin.email);
-    assertThat(info.name).isEqualTo(admin.fullName);
+    assertThat(info.email).isEqualTo(admin.email());
+    assertThat(info.name).isEqualTo(admin.fullName());
     assertThat(info.status).isEqualTo("out-of-office");
   }
 
@@ -1254,7 +1462,7 @@
   public void pushAccountConfigWithPrefEmailThatDoesNotExistAsExtIdToUserBranchForReviewAndSubmit()
       throws Exception {
     TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-    String userRef = RefNames.refsUsers(foo.id);
+    String userRef = RefNames.refsUsers(foo.id());
     accountIndexedCounter.clear();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
@@ -1268,8 +1476,7 @@
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                foo.getIdent(),
+                foo.newIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountProperties.ACCOUNT_CONFIG,
@@ -1277,9 +1484,9 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
-    setApiUser(foo);
+    requestScopeOperations.setApiUser(foo.id());
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
 
@@ -1287,13 +1494,13 @@
 
     AccountInfo info = gApi.accounts().self().get();
     assertThat(info.email).isEqualTo(email);
-    assertThat(info.name).isEqualTo(foo.fullName);
+    assertThat(info.name).isEqualTo(foo.fullName());
   }
 
   @Test
   public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfConfigIsInvalid()
       throws Exception {
-    String userRef = RefNames.refsUsers(admin.id);
+    String userRef = RefNames.refsUsers(admin.id());
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
@@ -1301,8 +1508,7 @@
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountProperties.ACCOUNT_CONFIG,
@@ -1310,26 +1516,30 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "invalid account configuration: commit '%s' has an invalid '%s' file for account '%s':"
-                + " Invalid config file %s in commit %s",
-            r.getCommit().name(),
-            AccountProperties.ACCOUNT_CONFIG,
-            admin.id,
-            AccountProperties.ACCOUNT_CONFIG,
-            r.getCommit().name()));
-    gApi.changes().id(r.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "invalid account configuration: commit '%s' has an invalid '%s' file for account"
+                    + " '%s': Invalid config file %s in commit %s",
+                r.getCommit().name(),
+                AccountProperties.ACCOUNT_CONFIG,
+                admin.id(),
+                AccountProperties.ACCOUNT_CONFIG,
+                r.getCommit().name()));
   }
 
   @Test
   public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfPreferredEmailIsInvalid()
       throws Exception {
-    String userRef = RefNames.refsUsers(admin.id);
+    String userRef = RefNames.refsUsers(admin.id());
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
@@ -1341,8 +1551,7 @@
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountProperties.ACCOUNT_CONFIG,
@@ -1350,21 +1559,25 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "invalid account configuration: invalid preferred email '%s' for account '%s'",
-            noEmail, admin.id));
-    gApi.changes().id(r.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "invalid account configuration: invalid preferred email '%s' for account '%s'",
+                noEmail, admin.id()));
   }
 
   @Test
   public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfOwnAccountIsDeactivated()
       throws Exception {
-    String userRef = RefNames.refsUsers(admin.id);
+    String userRef = RefNames.refsUsers(admin.id());
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
@@ -1375,8 +1588,7 @@
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountProperties.ACCOUNT_CONFIG,
@@ -1384,26 +1596,37 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("invalid account configuration: cannot deactivate own account");
-    gApi.changes().id(r.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("invalid account configuration: cannot deactivate own account");
   }
 
   @Test
   public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestAccount foo = accountCreator.create(name("foo"));
-    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
-    String userRef = RefNames.refsUsers(foo.id);
+    assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
+    String userRef = RefNames.refsUsers(foo.id());
     accountIndexedCounter.clear();
 
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
-    grantLabel("Code-Review", -2, 2, allUsers, userRef, false, adminGroupUuid(), false);
-    grant(allUsers, userRef, Permission.SUBMIT, false, adminGroupUuid());
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+        .add(allowLabel("Code-Review").ref(userRef).group(adminGroupUuid()).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref(userRef).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
@@ -1415,8 +1638,7 @@
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountProperties.ACCOUNT_CONFIG,
@@ -1424,19 +1646,19 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
     accountIndexedCounter.assertReindexOf(foo);
 
-    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isFalse();
+    assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
   }
 
   @Test
   public void pushWatchConfigToUserBranch() throws Exception {
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
     allUsersRepo.reset("userRef");
 
     Config wc = new Config();
@@ -1447,8 +1669,7 @@
         ProjectWatches.NotifyValue.create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             allUsersRepo,
             "Add project watch",
             ProjectWatches.WATCH_CONFIG,
@@ -1461,8 +1682,7 @@
         ProjectWatches.PROJECT, project.get(), ProjectWatches.KEY_NOTIFY, invalidNotifyValue);
     push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             allUsersRepo,
             "Add invalid project watch",
             ProjectWatches.WATCH_CONFIG,
@@ -1472,17 +1692,17 @@
     r.assertMessage(
         String.format(
             "%s: Invalid project watch of account %d for project %s: %s",
-            ProjectWatches.WATCH_CONFIG, admin.getId().get(), project.get(), invalidNotifyValue));
+            ProjectWatches.WATCH_CONFIG, admin.id().get(), project.get(), invalidNotifyValue));
   }
 
   @Test
   public void pushAccountConfigToUserBranch() throws Exception {
     TestAccount oooUser = accountCreator.create("away", "away@mail.invalid", "Ambrose Way");
-    setApiUser(oooUser);
+    requestScopeOperations.setApiUser(oooUser.id());
 
     // Must clone as oooUser to ensure the push is allowed.
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, oooUser);
-    fetch(allUsersRepo, RefNames.refsUsers(oooUser.id) + ":userRef");
+    fetch(allUsersRepo, RefNames.refsUsers(oooUser.id()) + ":userRef");
     allUsersRepo.reset("userRef");
 
     Config ac = getAccountConfig(allUsersRepo);
@@ -1491,34 +1711,32 @@
     accountIndexedCounter.clear();
     pushFactory
         .create(
-            db,
-            oooUser.getIdent(),
+            oooUser.newIdent(),
             allUsersRepo,
             "Update account config",
             AccountProperties.ACCOUNT_CONFIG,
             ac.toText())
-        .to(RefNames.refsUsers(oooUser.id))
+        .to(RefNames.refsUsers(oooUser.id()))
         .assertOkStatus();
 
     accountIndexedCounter.assertReindexOf(oooUser);
 
     AccountInfo info = gApi.accounts().self().get();
-    assertThat(info.email).isEqualTo(oooUser.email);
-    assertThat(info.name).isEqualTo(oooUser.fullName);
+    assertThat(info.email).isEqualTo(oooUser.email());
+    assertThat(info.name).isEqualTo(oooUser.fullName());
     assertThat(info.status).isEqualTo("out-of-office");
   }
 
   @Test
   public void pushAccountConfigToUserBranchIsRejectedIfConfigIsInvalid() throws Exception {
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
     allUsersRepo.reset("userRef");
 
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountProperties.ACCOUNT_CONFIG,
@@ -1531,7 +1749,7 @@
                 + " Invalid config file %s in commit %s",
             r.getCommit().name(),
             AccountProperties.ACCOUNT_CONFIG,
-            admin.id,
+            admin.id(),
             AccountProperties.ACCOUNT_CONFIG,
             r.getCommit().name()));
     accountIndexedCounter.assertNoReindex();
@@ -1540,7 +1758,7 @@
   @Test
   public void pushAccountConfigToUserBranchIsRejectedIfPreferredEmailIsInvalid() throws Exception {
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
     allUsersRepo.reset("userRef");
 
     String noEmail = "no.email";
@@ -1550,8 +1768,7 @@
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountProperties.ACCOUNT_CONFIG,
@@ -1559,22 +1776,26 @@
             .to(RefNames.REFS_USERS_SELF);
     r.assertErrorStatus("invalid account configuration");
     r.assertMessage(
-        String.format("invalid preferred email '%s' for account '%s'", noEmail, admin.id));
+        String.format("invalid preferred email '%s' for account '%s'", noEmail, admin.id()));
     accountIndexedCounter.assertNoReindex();
   }
 
   @Test
   public void pushAccountConfigToUserBranchInvalidPreferredEmailButNotChanged() throws Exception {
     TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-    String userRef = RefNames.refsUsers(foo.id);
+    String userRef = RefNames.refsUsers(foo.id());
 
     String noEmail = "no.email";
     accountsUpdateProvider
         .get()
-        .update("Set Preferred Email", foo.id, u -> u.setPreferredEmail(noEmail));
+        .update("Set Preferred Email", foo.id(), u -> u.setPreferredEmail(noEmail));
     accountIndexedCounter.clear();
 
-    grant(allUsers, userRef, Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(userRef).group(REGISTERED_USERS))
+        .update();
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
@@ -1585,8 +1806,7 @@
 
     pushFactory
         .create(
-            db,
-            foo.getIdent(),
+            foo.newIdent(),
             allUsersRepo,
             "Update account config",
             AccountProperties.ACCOUNT_CONFIG,
@@ -1595,19 +1815,23 @@
         .assertOkStatus();
     accountIndexedCounter.assertReindexOf(foo);
 
-    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    AccountInfo info = gApi.accounts().id(foo.id().get()).get();
     assertThat(info.email).isEqualTo(noEmail);
-    assertThat(info.name).isEqualTo(foo.fullName);
+    assertThat(info.name).isEqualTo(foo.fullName());
     assertThat(info.status).isEqualTo(status);
   }
 
   @Test
   public void pushAccountConfigToUserBranchIfPreferredEmailDoesNotExistAsExtId() throws Exception {
     TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-    String userRef = RefNames.refsUsers(foo.id);
+    String userRef = RefNames.refsUsers(foo.id());
     accountIndexedCounter.clear();
 
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
     fetch(allUsersRepo, userRef + ":userRef");
@@ -1619,8 +1843,7 @@
 
     pushFactory
         .create(
-            db,
-            foo.getIdent(),
+            foo.newIdent(),
             allUsersRepo,
             "Update account config",
             AccountProperties.ACCOUNT_CONFIG,
@@ -1629,15 +1852,15 @@
         .assertOkStatus();
     accountIndexedCounter.assertReindexOf(foo);
 
-    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    AccountInfo info = gApi.accounts().id(foo.id().get()).get();
     assertThat(info.email).isEqualTo(email);
-    assertThat(info.name).isEqualTo(foo.fullName);
+    assertThat(info.name).isEqualTo(foo.fullName());
   }
 
   @Test
   public void pushAccountConfigToUserBranchIsRejectedIfOwnAccountIsDeactivated() throws Exception {
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
     allUsersRepo.reset("userRef");
 
     Config ac = getAccountConfig(allUsersRepo);
@@ -1646,8 +1869,7 @@
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountProperties.ACCOUNT_CONFIG,
@@ -1660,14 +1882,21 @@
 
   @Test
   public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestAccount foo = accountCreator.create(name("foo"));
-    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
-    String userRef = RefNames.refsUsers(foo.id);
+    assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
+    String userRef = RefNames.refsUsers(foo.id());
     accountIndexedCounter.clear();
 
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
@@ -1678,8 +1907,7 @@
 
     pushFactory
         .create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             allUsersRepo,
             "Update account config",
             AccountProperties.ACCOUNT_CONFIG,
@@ -1688,17 +1916,21 @@
         .assertOkStatus();
     accountIndexedCounter.assertReindexOf(foo);
 
-    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isFalse();
+    assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
   }
 
   @Test
   public void cannotCreateUserBranch() throws Exception {
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
 
-    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
+    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef);
+    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
     r.assertErrorStatus();
     assertThat(r.getMessage()).contains("Not allowed to create user branch.");
 
@@ -1709,13 +1941,20 @@
 
   @Test
   public void createUserBranchWithAccessDatabaseCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
 
-    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
+    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef).assertOkStatus();
+    pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef).assertOkStatus();
 
     try (Repository repo = repoManager.openRepository(allUsers)) {
       assertThat(repo.exactRef(userRef)).isNotNull();
@@ -1725,13 +1964,20 @@
   @Test
   public void cannotCreateNonUserBranchUnderRefsUsersWithAccessDatabaseCapability()
       throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
 
     String userRef = RefNames.REFS_USERS + "foo";
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef);
+    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
     r.assertErrorStatus();
     assertThat(r.getMessage()).contains("Not allowed to create non-user branch under refs/users/.");
 
@@ -1746,12 +1992,16 @@
       assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNull();
     }
 
-    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.PUSH);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     pushFactory
-        .create(db, admin.getIdent(), allUsersRepo)
+        .create(admin.newIdent(), allUsersRepo)
         .to(RefNames.REFS_USERS_DEFAULT)
         .assertOkStatus();
 
@@ -1762,15 +2012,18 @@
 
   @Test
   public void cannotDeleteUserBranch() throws Exception {
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.DELETE,
-        true,
-        REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.DELETE)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS)
+                .force(true))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    String userRef = RefNames.refsUsers(admin.id);
+    String userRef = RefNames.refsUsers(admin.id());
     PushResult r = deleteRef(allUsersRepo, userRef);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
     assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
@@ -1783,16 +2036,22 @@
 
   @Test
   public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.DELETE,
-        true,
-        REGISTERED_USERS);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.DELETE)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS)
+                .force(true))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    String userRef = RefNames.refsUsers(admin.id);
+    String userRef = RefNames.refsUsers(admin.id());
     PushResult r = deleteRef(allUsersRepo, userRef);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
     assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
@@ -1801,8 +2060,8 @@
       assertThat(repo.exactRef(userRef)).isNull();
     }
 
-    assertThat(accountCache.get(admin.id)).isEmpty();
-    assertThat(accountQueryProvider.get().byDefault(admin.id.toString())).isEmpty();
+    assertThat(accountCache.get(admin.id())).isEmpty();
+    assertThat(accountQueryProvider.get().byDefault(admin.id().toString())).isEmpty();
   }
 
   @Test
@@ -1811,13 +2070,27 @@
     String id = key.getKeyIdString();
     addExternalIdEmail(admin, "test1@example.com");
 
+    sender.clear();
     assertKeyMapContains(key, addGpgKey(key.getPublicKeyArmored()));
     assertKeys(key);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new GPG keys have been added");
 
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(id);
-    gApi.accounts().self().gpgKey(id).get();
+    requestScopeOperations.setApiUser(user.id());
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.accounts().self().gpgKey(id).get());
+    assertThat(thrown).hasMessageThat().contains(id);
+  }
+
+  @Test
+  public void adminCannotAddGpgKeyToOtherAccount() throws Exception {
+    TestKey key = validKeyWithoutExpiration();
+    addExternalIdEmail(user, "test1@example.com");
+
+    sender.clear();
+    requestScopeOperations.setApiUser(admin.id());
+    assertThrows(ResourceNotFoundException.class, () -> addGpgKey(user, key.getPublicKeyArmored()));
   }
 
   @Test
@@ -1827,14 +2100,21 @@
     String id = key.getKeyIdString();
     PGPPublicKey pk = key.getPublicKey();
 
+    sender.clear();
     GpgKeyInfo info = addGpgKey(armor(pk)).get(id);
     assertThat(info.userIds).hasSize(2);
     assertIteratorSize(2, getOnlyKeyFromStore(key).getUserIDs());
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new GPG keys have been added");
 
     pk = PGPPublicKey.removeCertification(pk, "foo:myId");
+    sender.clear();
     info = addGpgKeyNoReindex(armor(pk)).get(id);
     assertThat(info.userIds).hasSize(1);
     assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
+    // TODO: Issue 10769: Adding an already existing key should not result in a notification email
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new GPG keys have been added");
   }
 
   @Test
@@ -1845,17 +2125,18 @@
         .get()
         .update(
             "Add External ID",
-            user.getId(),
-            u -> u.addExternalId(ExternalId.create("foo", "myId", user.getId())));
+            user.id(),
+            u -> u.addExternalId(ExternalId.create("foo", "myId", user.id())));
     accountIndexedCounter.assertReindexOf(user);
 
     TestKey key = validKeyWithSecondUserId();
     addGpgKey(key.getPublicKeyArmored());
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("GPG key already associated with another account");
-    addGpgKey(key.getPublicKeyArmored());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> addGpgKey(user, key.getPublicKeyArmored()));
+    assertThat(thrown).hasMessageThat().contains("GPG key already associated with another account");
   }
 
   @Test
@@ -1866,7 +2147,7 @@
       addExternalIdEmail(admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
       toAdd.add(key.getPublicKeyArmored());
     }
-    gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.<String>of());
+    gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.of());
     assertKeys(keys);
     accountIndexedCounter.assertReindexOf(admin);
   }
@@ -1879,13 +2160,17 @@
     addGpgKey(key.getPublicKeyArmored());
     assertKeys(key);
 
+    sender.clear();
     gApi.accounts().self().gpgKey(id).delete();
     accountIndexedCounter.assertReindexOf(admin);
     assertKeys();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("GPG keys have been deleted");
 
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(id);
-    gApi.accounts().self().gpgKey(id).get();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.accounts().self().gpgKey(id).get());
+    assertThat(thrown).hasMessageThat().contains(id);
   }
 
   @Test
@@ -1919,22 +2204,25 @@
     assertKeys(key2, key5);
     accountIndexedCounter.assertReindexOf(admin);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
-    infos =
-        gApi.accounts()
-            .self()
-            .putGpgKeys(
-                ImmutableList.of(key2.getPublicKeyArmored()),
-                ImmutableList.of(key2.getKeyIdString()));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .putGpgKeys(
+                        ImmutableList.of(key2.getPublicKeyArmored()),
+                        ImmutableList.of(key2.getKeyIdString())));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
   }
 
   @Test
   public void addMalformedGpgKey() throws Exception {
     String key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ntest\n-----END PGP PUBLIC KEY BLOCK-----";
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Failed to parse GPG keys");
-    addGpgKey(key);
+    BadRequestException thrown = assertThrows(BadRequestException.class, () -> addGpgKey(key));
+    assertThat(thrown).hasMessageThat().contains("Failed to parse GPG keys");
   }
 
   @Test
@@ -1946,34 +2234,45 @@
     assertSequenceNumbers(info);
     SshKeyInfo key = info.get(0);
     KeyPair keyPair = sshKeys.getKeyPair(admin);
-    String inital = TestSshKeys.publicKey(keyPair, admin.email);
-    assertThat(key.sshPublicKey).isEqualTo(inital);
+    String initial = TestSshKeys.publicKey(keyPair, admin.email());
+    assertThat(key.sshPublicKey).isEqualTo(initial);
     accountIndexedCounter.assertNoReindex();
 
     // Add a new key
-    String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email);
+    sender.clear();
+    String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
     gApi.accounts().self().addSshKey(newKey);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
     accountIndexedCounter.assertReindexOf(admin);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
 
     // Add an existing key (the request succeeds, but the key isn't added again)
-    gApi.accounts().self().addSshKey(inital);
+    sender.clear();
+    gApi.accounts().self().addSshKey(initial);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
     accountIndexedCounter.assertNoReindex();
+    // TODO: Issue 10769: Adding an already existing key should not result in a notification email
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
 
     // Add another new key
-    String newKey2 = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email);
+    sender.clear();
+    String newKey2 = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
     gApi.accounts().self().addSshKey(newKey2);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(3);
     assertSequenceNumbers(info);
     accountIndexedCounter.assertReindexOf(admin);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
 
     // Delete second key
+    sender.clear();
     gApi.accounts().self().deleteSshKey(2);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
@@ -1981,9 +2280,12 @@
     assertThat(info.get(1).seq).isEqualTo(3);
     accountIndexedCounter.assertReindexOf(admin);
 
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("SSH keys have been deleted");
+
     // Mark first key as invalid
     assertThat(info.get(0).valid).isTrue();
-    authorizedKeys.markKeyInvalid(admin.id, 1);
+    authorizedKeys.markKeyInvalid(admin.id(), 1);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertThat(info.get(0).seq).isEqualTo(1);
@@ -1992,29 +2294,89 @@
     accountIndexedCounter.assertReindexOf(admin);
   }
 
+  @Test
+  @UseSsh
+  public void adminCanAddOrRemoveSshKeyOnOtherAccount() throws Exception {
+    // The test account should initially have exactly one ssh key
+    List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(1);
+    assertSequenceNumbers(info);
+    SshKeyInfo key = info.get(0);
+    KeyPair keyPair = sshKeys.getKeyPair(admin);
+    String initial = TestSshKeys.publicKey(keyPair, admin.email());
+    assertThat(key.sshPublicKey).isEqualTo(initial);
+    accountIndexedCounter.assertNoReindex();
+
+    // Add a new key
+    sender.clear();
+    String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), user.email());
+    gApi.accounts().id(user.username()).addSshKey(newKey);
+    info = gApi.accounts().id(user.username()).listSshKeys();
+    assertThat(info).hasSize(2);
+    assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(user);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(message.body()).contains("new SSH keys have been added");
+
+    // Delete key
+    sender.clear();
+    gApi.accounts().id(user.username()).deleteSshKey(1);
+    info = gApi.accounts().id(user.username()).listSshKeys();
+    assertThat(info).hasSize(1);
+    accountIndexedCounter.assertReindexOf(user);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    message = sender.getMessages().get(0);
+    assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(message.body()).contains("SSH keys have been deleted");
+  }
+
+  @Test
+  @UseSsh
+  public void userCannotAddSshKeyToOtherAccount() throws Exception {
+    String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).addSshKey(newKey));
+  }
+
+  @Test
+  @UseSsh
+  public void userCannotDeleteSshKeyOfOtherAccount() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.accounts().id(admin.username()).deleteSshKey(0));
+  }
+
   // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
   @Test
   public void reindexPermissions() throws Exception {
     // admin can reindex any account
-    setApiUser(admin);
-    gApi.accounts().id(user.username).index();
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.accounts().id(user.username()).index();
     accountIndexedCounter.assertReindexOf(user);
 
     // user can reindex own account
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.accounts().self().index();
     accountIndexedCounter.assertReindexOf(user);
 
     // user cannot reindex any account
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.username).index();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).index());
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
   public void checkConsistency() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    resetCurrentApiUser();
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.resetCurrentApiUser();
 
     // Create an account with a preferred email.
     String username = name("foo");
@@ -2034,13 +2396,13 @@
         .get()
         .update(
             "Delete External ID",
-            account.getId(),
-            u -> u.deleteExternalId(ExternalId.createEmail(account.getId(), email)));
+            account.id(),
+            u -> u.deleteExternalId(ExternalId.createEmail(account.id(), email)));
     expectedProblems.add(
         new ConsistencyProblemInfo(
             ConsistencyProblemInfo.Status.ERROR,
             "Account '"
-                + account.getId().get()
+                + account.id().get()
                 + "' has no external ID for its preferred email '"
                 + email
                 + "'"));
@@ -2056,11 +2418,11 @@
     assertThat(accountQueryProvider.get().byDefault(name)).isEmpty();
 
     TestAccount foo1 = accountCreator.create(name + "-1");
-    assertThat(gApi.accounts().id(foo1.username).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    gApi.accounts().id(foo2.username).setActive(false);
-    assertThat(gApi.accounts().id(foo2.username).getActive()).isFalse();
+    gApi.accounts().id(foo2.username()).setActive(false);
+    assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
 
     assertThat(accountQueryProvider.get().byDefault(name)).hasSize(2);
   }
@@ -2068,22 +2430,22 @@
   @Test
   public void checkMetaId() throws Exception {
     // metaId is set when account is loaded
-    assertThat(accounts.get(admin.getId()).get().getAccount().getMetaId())
-        .isEqualTo(getMetaId(admin.getId()));
+    assertThat(accounts.get(admin.id()).get().getAccount().metaId())
+        .isEqualTo(getMetaId(admin.id()));
 
     // metaId is set when account is created
     AccountsUpdate au = accountsUpdateProvider.get();
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     AccountState accountState = au.insert("Create Test Account", accountId, u -> {});
-    assertThat(accountState.getAccount().getMetaId()).isEqualTo(getMetaId(accountId));
+    assertThat(accountState.getAccount().metaId()).isEqualTo(getMetaId(accountId));
 
     // metaId is set when account is updated
     Optional<AccountState> updatedAccountState =
         au.update("Set Full Name", accountId, u -> u.setFullName("foo"));
     assertThat(updatedAccountState).isPresent();
     Account updatedAccount = updatedAccountState.get().getAccount();
-    assertThat(accountState.getAccount().getMetaId()).isNotEqualTo(updatedAccount.getMetaId());
-    assertThat(updatedAccount.getMetaId()).isEqualTo(getMetaId(accountId));
+    assertThat(accountState.getAccount().metaId()).isNotEqualTo(updatedAccount.metaId());
+    assertThat(updatedAccount.metaId()).isEqualTo(getMetaId(accountId));
   }
 
   private EmailInput newEmailInput(String email, boolean noConfirmation) {
@@ -2108,17 +2470,48 @@
 
   @Test
   public void allGroupsForAnAdminAccountCanBeRetrieved() throws Exception {
-    List<GroupInfo> groups = gApi.accounts().id(admin.username).getGroups();
+    List<GroupInfo> groups = gApi.accounts().id(admin.username()).getGroups();
     assertThat(groups)
         .comparingElementsUsing(getGroupToNameCorrespondence())
         .containsExactly("Anonymous Users", "Registered Users", "Administrators");
   }
 
   @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) {
+      BadRequestException thrown =
+          assertThrows(BadRequestException.class, () -> gApi.accounts().create(name));
+      assertThat(thrown).hasMessageThat().isEqualTo(String.format("Invalid username '%s'", name));
+    }
+  }
+
+  @Test
   public void allGroupsForAUserAccountCanBeRetrieved() throws Exception {
     String username = name("user1");
     accountOperations.newAccount().username(username).create();
-    String group = createGroup("group");
+    AccountGroup.UUID groupID = groupOperations.newGroup().name("group").create();
+    String group = groupOperations.group(groupID).get().name();
+
     gApi.groups().id(group).addMembers(username);
 
     List<GroupInfo> allGroups = gApi.accounts().id(username).getGroups();
@@ -2162,17 +2555,12 @@
         new AccountsUpdate(
             repoManager,
             gitReferenceUpdated,
-            null,
+            Optional.empty(),
             allUsers,
             externalIds,
             metaDataUpdateInternalFactory,
             new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                r -> r.withBlockStrategy(noSleepBlockStrategy)),
+                cfg, retryMetrics, null, r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
             ident,
@@ -2181,28 +2569,28 @@
                 try {
                   accountsUpdateProvider
                       .get()
-                      .update("Set Status", admin.id, u -> u.setStatus(status));
-                } catch (IOException | ConfigInvalidException | OrmException e) {
+                      .update("Set Status", admin.id(), u -> u.setStatus(status));
+                } catch (IOException | ConfigInvalidException | StorageException e) {
                   // Ignore, the successful update of the account is asserted later
                 }
               }
             },
             Runnables.doNothing());
     assertThat(doneBgUpdate.get()).isFalse();
-    AccountInfo accountInfo = gApi.accounts().id(admin.id.get()).get();
+    AccountInfo accountInfo = gApi.accounts().id(admin.id().get()).get();
     assertThat(accountInfo.status).isNull();
     assertThat(accountInfo.name).isNotEqualTo(fullName);
 
     Optional<AccountState> updatedAccountState =
-        update.update("Set Full Name", admin.id, u -> u.setFullName(fullName));
+        update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName));
     assertThat(doneBgUpdate.get()).isTrue();
 
     assertThat(updatedAccountState).isPresent();
     Account updatedAccount = updatedAccountState.get().getAccount();
-    assertThat(updatedAccount.getStatus()).isEqualTo(status);
-    assertThat(updatedAccount.getFullName()).isEqualTo(fullName);
+    assertThat(updatedAccount.status()).isEqualTo(status);
+    assertThat(updatedAccount.fullName()).isEqualTo(fullName);
 
-    accountInfo = gApi.accounts().id(admin.id.get()).get();
+    accountInfo = gApi.accounts().id(admin.id().get()).get();
     assertThat(accountInfo.status).isEqualTo(status);
     assertThat(accountInfo.name).isEqualTo(fullName);
   }
@@ -2217,7 +2605,7 @@
         new AccountsUpdate(
             repoManager,
             gitReferenceUpdated,
-            null,
+            Optional.empty(),
             allUsers,
             externalIds,
             metaDataUpdateInternalFactory,
@@ -2225,8 +2613,6 @@
                 cfg,
                 retryMetrics,
                 null,
-                null,
-                null,
                 r ->
                     r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
                         .withBlockStrategy(noSleepBlockStrategy)),
@@ -2239,38 +2625,35 @@
                     .get()
                     .update(
                         "Set Status",
-                        admin.id,
+                        admin.id(),
                         u -> u.setStatus(status.get(bgCounter.getAndAdd(1))));
-              } catch (IOException | ConfigInvalidException | OrmException e) {
+              } catch (IOException | ConfigInvalidException | StorageException e) {
                 // Ignore, the expected exception is asserted later
               }
             },
             Runnables.doNothing());
     assertThat(bgCounter.get()).isEqualTo(0);
-    AccountInfo accountInfo = gApi.accounts().id(admin.id.get()).get();
+    AccountInfo accountInfo = gApi.accounts().id(admin.id().get()).get();
     assertThat(accountInfo.status).isNull();
     assertThat(accountInfo.name).isNotEqualTo(fullName);
 
-    try {
-      update.update("Set Full Name", admin.id, u -> u.setFullName(fullName));
-      fail("expected LockFailureException");
-    } catch (LockFailureException e) {
-      // Ignore, expected
-    }
+    assertThrows(
+        LockFailureException.class,
+        () -> update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName)));
     assertThat(bgCounter.get()).isEqualTo(status.size());
 
-    Account updatedAccount = accounts.get(admin.id).get().getAccount();
-    assertThat(updatedAccount.getStatus()).isEqualTo(Iterables.getLast(status));
-    assertThat(updatedAccount.getFullName()).isEqualTo(admin.fullName);
+    Account updatedAccount = accounts.get(admin.id()).get().getAccount();
+    assertThat(updatedAccount.status()).isEqualTo(Iterables.getLast(status));
+    assertThat(updatedAccount.fullName()).isEqualTo(admin.fullName());
 
-    accountInfo = gApi.accounts().id(admin.id.get()).get();
+    accountInfo = gApi.accounts().id(admin.id().get()).get();
     assertThat(accountInfo.status).isEqualTo(Iterables.getLast(status));
-    assertThat(accountInfo.name).isEqualTo(admin.fullName);
+    assertThat(accountInfo.name).isEqualTo(admin.fullName());
   }
 
   @Test
   public void atomicReadMofifyWrite() throws Exception {
-    gApi.accounts().id(admin.id.get()).setStatus("A-1");
+    gApi.accounts().id(admin.id().get()).setStatus("A-1");
 
     AtomicInteger bgCounterA1 = new AtomicInteger(0);
     AtomicInteger bgCounterA2 = new AtomicInteger(0);
@@ -2279,17 +2662,12 @@
         new AccountsUpdate(
             repoManager,
             gitReferenceUpdated,
-            null,
+            Optional.empty(),
             allUsers,
             externalIds,
             metaDataUpdateInternalFactory,
             new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                r -> r.withBlockStrategy(noSleepBlockStrategy)),
+                cfg, retryMetrics, null, r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
             ident,
@@ -2298,26 +2676,26 @@
               try {
                 accountsUpdateProvider
                     .get()
-                    .update("Set Status", admin.id, u -> u.setStatus("A-2"));
-              } catch (IOException | ConfigInvalidException | OrmException e) {
+                    .update("Set Status", admin.id(), u -> u.setStatus("A-2"));
+              } catch (IOException | ConfigInvalidException | StorageException e) {
                 // Ignore, the expected exception is asserted later
               }
             });
     assertThat(bgCounterA1.get()).isEqualTo(0);
     assertThat(bgCounterA2.get()).isEqualTo(0);
-    assertThat(gApi.accounts().id(admin.id.get()).get().status).isEqualTo("A-1");
+    assertThat(gApi.accounts().id(admin.id().get()).get().status).isEqualTo("A-1");
 
     Optional<AccountState> updatedAccountState =
         update.update(
             "Set Status",
-            admin.id,
+            admin.id(),
             (a, u) -> {
-              if ("A-1".equals(a.getAccount().getStatus())) {
+              if ("A-1".equals(a.getAccount().status())) {
                 bgCounterA1.getAndIncrement();
                 u.setStatus("B-1");
               }
 
-              if ("A-2".equals(a.getAccount().getStatus())) {
+              if ("A-2".equals(a.getAccount().status())) {
                 bgCounterA2.getAndIncrement();
                 u.setStatus("B-2");
               }
@@ -2327,16 +2705,19 @@
     assertThat(bgCounterA2.get()).isEqualTo(1);
 
     assertThat(updatedAccountState).isPresent();
-    assertThat(updatedAccountState.get().getAccount().getStatus()).isEqualTo("B-2");
-    assertThat(accounts.get(admin.id).get().getAccount().getStatus()).isEqualTo("B-2");
-    assertThat(gApi.accounts().id(admin.id.get()).get().status).isEqualTo("B-2");
+    assertThat(updatedAccountState.get().getAccount().status()).isEqualTo("B-2");
+    assertThat(accounts.get(admin.id()).get().getAccount().status()).isEqualTo("B-2");
+    assertThat(gApi.accounts().id(admin.id().get()).get().status).isEqualTo("B-2");
   }
 
   @Test
   public void atomicReadMofifyWriteExternalIds() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId extIdA1 = ExternalId.create("foo", "A-1", accountId);
     accountsUpdateProvider
         .get()
@@ -2350,17 +2731,12 @@
         new AccountsUpdate(
             repoManager,
             gitReferenceUpdated,
-            null,
+            Optional.empty(),
             allUsers,
             externalIds,
             metaDataUpdateInternalFactory,
             new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                r -> r.withBlockStrategy(noSleepBlockStrategy)),
+                cfg, retryMetrics, null, r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
             ident,
@@ -2373,17 +2749,14 @@
                         "Update External ID",
                         accountId,
                         u -> u.replaceExternalId(extIdA1, extIdA2));
-              } catch (IOException | ConfigInvalidException | OrmException e) {
+              } catch (IOException | ConfigInvalidException | StorageException e) {
                 // Ignore, the expected exception is asserted later
               }
             });
     assertThat(bgCounterA1.get()).isEqualTo(0);
     assertThat(bgCounterA2.get()).isEqualTo(0);
     assertThat(
-            gApi.accounts()
-                .id(accountId.get())
-                .getExternalIds()
-                .stream()
+            gApi.accounts().id(accountId.get()).getExternalIds().stream()
                 .map(i -> i.identity)
                 .collect(toSet()))
         .containsExactly(extIdA1.key().get());
@@ -2413,10 +2786,7 @@
     assertThat(updatedAccount.get().getExternalIds()).containsExactly(extIdB2);
     assertThat(accounts.get(accountId).get().getExternalIds()).containsExactly(extIdB2);
     assertThat(
-            gApi.accounts()
-                .id(accountId.get())
-                .getExternalIds()
-                .stream()
+            gApi.accounts().id(accountId.get()).getExternalIds().stream()
                 .map(i -> i.identity)
                 .collect(toSet()))
         .containsExactly(extIdB2.key().get());
@@ -2426,7 +2796,7 @@
   public void stalenessChecker() throws Exception {
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
-    Account.Id accountId = new Account.Id(accountInfo._accountId);
+    Account.Id accountId = Account.id(accountInfo._accountId);
     assertThat(stalenessChecker.isStale(accountId)).isFalse();
 
     // Manually updating the user ref makes the index document stale.
@@ -2455,7 +2825,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));
@@ -2502,19 +2872,262 @@
     assertThat(stalenessChecker.isStale(accountId)).isFalse();
   }
 
-  private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
-    return new Correspondence<GroupInfo, String>() {
-      @Override
-      public boolean compare(GroupInfo actualGroup, String expectedName) {
-        String groupName = actualGroup == null ? null : actualGroup.name;
-        return Objects.equals(groupName, expectedName);
-      }
+  @Test
+  public void deleteAllDraftComments() throws Exception {
+    try {
+      TestTimeUtil.resetWithClockStep(1, SECONDS);
+      Project.NameKey project2 = projectOperations.newProject().create();
+      PushOneCommit.Result r1 = createChange();
 
-      @Override
-      public String toString() {
-        return "has name";
+      TestRepository<?> tr2 = cloneProject(project2);
+      PushOneCommit.Result r2 =
+          createChange(
+              tr2,
+              "refs/heads/master",
+              "Change in project2",
+              PushOneCommit.FILE_NAME,
+              "content2",
+              null);
+
+      // Create 2 drafts each on both changes for user.
+      requestScopeOperations.setApiUser(user.id());
+      createDraft(r1, PushOneCommit.FILE_NAME, "draft 1a");
+      createDraft(r1, PushOneCommit.FILE_NAME, "draft 1b");
+      createDraft(r2, PushOneCommit.FILE_NAME, "draft 2a");
+      createDraft(r2, PushOneCommit.FILE_NAME, "draft 2b");
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(2);
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(2);
+
+      // Create 1 draft on first change for admin.
+      requestScopeOperations.setApiUser(admin.id());
+      createDraft(r1, PushOneCommit.FILE_NAME, "admin draft");
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
+
+      // Delete user's draft comments; leave admin's alone.
+      requestScopeOperations.setApiUser(user.id());
+      List<DeletedDraftCommentInfo> result =
+          gApi.accounts().self().deleteDraftComments(new DeleteDraftCommentsInput());
+
+      // Results are ordered according to the change search, most recently updated first.
+      assertThat(result).hasSize(2);
+      DeletedDraftCommentInfo del2 = result.get(0);
+      assertThat(del2.change.changeId).isEqualTo(r2.getChangeId());
+      assertThat(del2.deleted.stream().map(c -> c.message)).containsExactly("draft 2a", "draft 2b");
+      DeletedDraftCommentInfo del1 = result.get(1);
+      assertThat(del1.change.changeId).isEqualTo(r1.getChangeId());
+      assertThat(del1.deleted.stream().map(c -> c.message)).containsExactly("draft 1a", "draft 1b");
+
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).isEmpty();
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).isEmpty();
+
+      requestScopeOperations.setApiUser(admin.id());
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
+    } finally {
+      cleanUpDrafts();
+    }
+  }
+
+  @Test
+  public void deleteDraftCommentsByQuery() throws Exception {
+    try {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+
+      createDraft(r1, PushOneCommit.FILE_NAME, "draft a");
+      createDraft(r2, PushOneCommit.FILE_NAME, "draft b");
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
+
+      List<DeletedDraftCommentInfo> result =
+          gApi.accounts()
+              .self()
+              .deleteDraftComments(new DeleteDraftCommentsInput("change:" + r1.getChangeId()));
+      assertThat(result).hasSize(1);
+      assertThat(result.get(0).change.changeId).isEqualTo(r1.getChangeId());
+      assertThat(result.get(0).deleted.stream().map(c -> c.message)).containsExactly("draft a");
+
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).isEmpty();
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
+    } finally {
+      cleanUpDrafts();
+    }
+  }
+
+  @Test
+  public void deleteOtherUsersDraftCommentsDisallowed() throws Exception {
+    try {
+      PushOneCommit.Result r = createChange();
+      requestScopeOperations.setApiUser(user.id());
+      createDraft(r, PushOneCommit.FILE_NAME, "draft");
+      requestScopeOperations.setApiUser(admin.id());
+      AuthException thrown =
+          assertThrows(
+              AuthException.class,
+              () ->
+                  gApi.accounts()
+                      .id(user.id().get())
+                      .deleteDraftComments(new DeleteDraftCommentsInput()));
+      assertThat(thrown).hasMessageThat().isEqualTo("Cannot delete drafts of other user");
+    } finally {
+      cleanUpDrafts();
+    }
+  }
+
+  @Test
+  public void deleteDraftCommentsSkipsInvisibleChanges() throws Exception {
+    try {
+      createBranch(BranchNameKey.create(project, "secret"));
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange("refs/for/secret");
+
+      requestScopeOperations.setApiUser(user.id());
+      createDraft(r1, PushOneCommit.FILE_NAME, "draft a");
+      createDraft(r2, PushOneCommit.FILE_NAME, "draft b");
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(block(Permission.READ).ref("refs/heads/secret").group(REGISTERED_USERS))
+          .update();
+      List<DeletedDraftCommentInfo> result =
+          gApi.accounts().self().deleteDraftComments(new DeleteDraftCommentsInput());
+      assertThat(result).hasSize(1);
+      assertThat(result.get(0).change.changeId).isEqualTo(r1.getChangeId());
+      assertThat(result.get(0).deleted.stream().map(c -> c.message)).containsExactly("draft a");
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.READ).ref("refs/heads/secret"))
+          .update();
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).isEmpty();
+      // Draft still exists since change wasn't visible when drafts where deleted.
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
+    } finally {
+      cleanUpDrafts();
+    }
+  }
+
+  @Test
+  public void userCanGenerateNewHttpPassword() throws Exception {
+    sender.clear();
+    String newPassword = gApi.accounts().self().generateHttpPassword();
+    assertThat(newPassword).isNotNull();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
+  }
+
+  @Test
+  public void adminCanGenerateNewHttpPasswordForUser() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    sender.clear();
+    String newPassword = gApi.accounts().id(user.username()).generateHttpPassword();
+    assertThat(newPassword).isNotNull();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
+  }
+
+  @Test
+  public void userCannotGenerateNewHttpPasswordForOtherUser() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.accounts().id(admin.username()).generateHttpPassword());
+  }
+
+  @Test
+  public void userCannotExplicitlySetHttpPassword() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.accounts().self().setHttpPassword("my-new-password"));
+  }
+
+  @Test
+  public void userCannotExplicitlySetHttpPasswordForOtherUser() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class,
+        () -> gApi.accounts().id(admin.username()).setHttpPassword("my-new-password"));
+  }
+
+  @Test
+  public void userCanRemoveHttpPassword() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    sender.clear();
+    assertThat(gApi.accounts().self().setHttpPassword(null)).isNull();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was deleted");
+  }
+
+  @Test
+  public void userCannotRemoveHttpPasswordForOtherUser() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.accounts().id(admin.username()).setHttpPassword(null));
+  }
+
+  @Test
+  public void adminCanExplicitlySetHttpPasswordForUser() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    String httpPassword = "new-password-for-user";
+    sender.clear();
+    assertThat(gApi.accounts().id(user.username()).setHttpPassword(httpPassword))
+        .isEqualTo(httpPassword);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
+  }
+
+  @Test
+  public void adminCanRemoveHttpPasswordForUser() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    sender.clear();
+    assertThat(gApi.accounts().id(user.username()).setHttpPassword(null)).isNull();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was deleted");
+  }
+
+  @Test
+  public void cannotGenerateHttpPasswordWhenUsernameIsNotSet() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    int userId = accountCreator.create().id().get();
+    assertThat(gApi.accounts().id(userId).get().username).isNull();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.accounts().id(userId).generateHttpPassword());
+    assertThat(thrown).hasMessageThat().contains("username");
+  }
+
+  private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
+    DraftInput in = new DraftInput();
+    in.path = path;
+    in.line = 1;
+    in.message = message;
+    gApi.changes().id(r.getChangeId()).current().createDraft(in);
+  }
+
+  private void cleanUpDrafts() throws Exception {
+    for (TestAccount testAccount : accountCreator.getAll()) {
+      requestScopeOperations.setApiUser(testAccount.id());
+      for (ChangeInfo changeInfo : gApi.changes().query("has:draft").get()) {
+        for (CommentInfo c :
+            gApi.changes().id(changeInfo.id).drafts().values().stream()
+                .flatMap(List::stream)
+                .collect(toImmutableList())) {
+          gApi.changes().id(changeInfo.id).revision(c.patchSet).draft(c.id).delete();
+        }
       }
-    };
+    }
+  }
+
+  private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
+    return Correspondence.from(
+        (actualGroup, expectedName) -> {
+          String groupName = actualGroup == null ? null : actualGroup.name;
+          return Objects.equals(groupName, expectedName);
+        },
+        "has name");
   }
 
   private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
@@ -2561,8 +3174,8 @@
     // Check via API.
     FluentIterable<TestKey> expected = FluentIterable.from(expectedKeys);
     Map<String, GpgKeyInfo> keyMap = gApi.accounts().self().listGpgKeys();
-    assertThat(keyMap.keySet())
-        .named("keys returned by listGpgKeys()")
+    assertWithMessage("keys returned by listGpgKeys()")
+        .that(keyMap.keySet())
         .containsExactlyElementsIn(expected.transform(TestKey::getKeyIdString));
 
     for (TestKey key : expected) {
@@ -2581,12 +3194,12 @@
     Iterable<String> expectedFps =
         expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
     Iterable<String> actualFps =
-        externalIds
-            .byAccount(currAccountId, SCHEME_GPGKEY)
-            .stream()
+        externalIds.byAccount(currAccountId, SCHEME_GPGKEY).stream()
             .map(e -> e.key().id())
             .collect(toSet());
-    assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
+    assertWithMessage("external IDs in database")
+        .that(actualFps)
+        .containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
     for (TestKey key : expected) {
@@ -2596,40 +3209,48 @@
 
   private static void assertKeyEquals(TestKey expected, GpgKeyInfo actual) {
     String id = expected.getKeyIdString();
-    assertThat(actual.id).named(id).isEqualTo(id);
-    assertThat(actual.fingerprint)
-        .named(id)
+    assertWithMessage(id).that(actual.id).isEqualTo(id);
+    assertWithMessage(id)
+        .that(actual.fingerprint)
         .isEqualTo(Fingerprint.toString(expected.getPublicKey().getFingerprint()));
     List<String> userIds = ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
-    assertThat(actual.userIds).named(id).containsExactlyElementsIn(userIds);
-    assertThat(actual.key).named(id).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
+    assertWithMessage(id).that(actual.userIds).containsExactlyElementsIn(userIds);
+    String key = actual.key;
+    assertWithMessage(id).that(key).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
+    assertWithMessage(id).that(key).endsWith("-----END PGP PUBLIC KEY BLOCK-----\n");
     assertThat(actual.status).isEqualTo(GpgKeyInfo.Status.TRUSTED);
     assertThat(actual.problems).isEmpty();
   }
 
   private void addExternalIdEmail(TestAccount account, String email) throws Exception {
-    checkNotNull(email);
+    requireNonNull(email);
     accountsUpdateProvider
         .get()
         .update(
             "Add Email",
-            account.getId(),
+            account.id(),
             u ->
                 u.addExternalId(
-                    ExternalId.createWithEmail(name("test"), email, account.getId(), email)));
+                    ExternalId.createWithEmail(name("test"), email, account.id(), email)));
     accountIndexedCounter.assertReindexOf(account);
-    setApiUser(account);
+    requestScopeOperations.setApiUser(account.id());
   }
 
   private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
+    return addGpgKey(admin, armored);
+  }
+
+  private Map<String, GpgKeyInfo> addGpgKey(TestAccount account, String armored) throws Exception {
     Map<String, GpgKeyInfo> gpgKeys =
-        gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
-    accountIndexedCounter.assertReindexOf(gApi.accounts().self().get());
+        gApi.accounts()
+            .id(account.username())
+            .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username()).get());
     return gpgKeys;
   }
 
   private Map<String, GpgKeyInfo> addGpgKeyNoReindex(String armored) throws Exception {
-    return gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    return gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.of());
   }
 
   private void assertUser(AccountInfo info, TestAccount account) throws Exception {
@@ -2638,9 +3259,9 @@
 
   private void assertUser(AccountInfo info, TestAccount account, @Nullable String expectedStatus)
       throws Exception {
-    assertThat(info.name).isEqualTo(account.fullName);
-    assertThat(info.email).isEqualTo(account.email);
-    assertThat(info.username).isEqualTo(account.username);
+    assertThat(info.name).isEqualTo(account.fullName());
+    assertThat(info.email).isEqualTo(account.email());
+    assertThat(info.username).isEqualTo(account.username());
     assertThat(info.status).isEqualTo(expectedStatus);
   }
 
@@ -2650,7 +3271,7 @@
 
   private void assertEmail(Set<Account.Id> accounts, TestAccount expectedAccount) {
     assertThat(accounts).hasSize(1);
-    assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.getId());
+    assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.id());
   }
 
   private Config getAccountConfig(TestRepository<?> allUsersRepo) throws Exception {
@@ -2659,7 +3280,7 @@
         TreeWalk.forPath(
             allUsersRepo.getRepository(),
             AccountProperties.ACCOUNT_CONFIG,
-            getHead(allUsersRepo.getRepository()).getTree())) {
+            getHead(allUsersRepo.getRepository(), "HEAD").getTree())) {
       assertThat(tw).isNotNull();
       ac.fromText(
           new String(
@@ -2686,31 +3307,26 @@
       countsByAccount.clear();
     }
 
-    long getCount(Account.Id accountId) {
-      return countsByAccount.get(accountId.get());
-    }
-
     void assertReindexOf(TestAccount testAccount) {
       assertReindexOf(testAccount, 1);
     }
 
     void assertReindexOf(AccountInfo accountInfo) {
-      assertReindexOf(new Account.Id(accountInfo._accountId), 1);
+      assertReindexOf(Account.id(accountInfo._accountId), 1);
     }
 
-    void assertReindexOf(TestAccount testAccount, int expectedCount) {
-      assertThat(getCount(testAccount.id)).isEqualTo(expectedCount);
-      assertThat(countsByAccount).hasSize(1);
+    void assertReindexOf(TestAccount testAccount, long expectedCount) {
+      assertThat(countsByAccount.asMap()).containsExactly(testAccount.id().get(), expectedCount);
       clear();
     }
 
-    void assertReindexOf(Account.Id accountId, int expectedCount) {
-      assertThat(getCount(accountId)).isEqualTo(expectedCount);
+    void assertReindexOf(Account.Id accountId, long expectedCount) {
+      assertThat(countsByAccount.asMap()).containsEntry(accountId.get(), expectedCount);
       countsByAccount.remove(accountId.get());
     }
 
     void assertNoReindex() {
-      assertThat(countsByAccount).isEmpty();
+      assertThat(countsByAccount.asMap()).isEmpty();
     }
   }
 
@@ -2734,23 +3350,17 @@
       countsByProjectRefs.clear();
     }
 
-    long getCount(String projectRef) {
-      return countsByProjectRefs.get(projectRef);
-    }
-
     void assertRefUpdateFor(String... projectRefs) {
-      Map<String, Integer> expectedRefUpdateCounts = new HashMap<>();
+      Map<String, Long> expectedRefUpdateCounts = new HashMap<>();
       for (String projectRef : projectRefs) {
-        expectedRefUpdateCounts.put(projectRef, 1);
+        expectedRefUpdateCounts.put(projectRef, 1L);
       }
       assertRefUpdateFor(expectedRefUpdateCounts);
     }
 
-    void assertRefUpdateFor(Map<String, Integer> expectedProjectRefUpdateCounts) {
-      for (Map.Entry<String, Integer> e : expectedProjectRefUpdateCounts.entrySet()) {
-        assertThat(getCount(e.getKey())).isEqualTo(e.getValue());
-      }
-      assertThat(countsByProjectRefs).hasSize(expectedProjectRefUpdateCounts.size());
+    void assertRefUpdateFor(Map<String, Long> expectedProjectRefUpdateCounts) {
+      assertThat(countsByProjectRefs.asMap())
+          .containsExactlyEntriesIn(expectedProjectRefUpdateCounts);
       clear();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index ef8451d..75a727d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -66,7 +66,7 @@
     List<AccountState> matchedAccountStates =
         accountQueryProvider.get().byPreferredEmail(preferredEmail);
     assertThat(matchedAccountStates).hasSize(1);
-    assertThat(matchedAccountStates.get(0).getAccount().getId()).isEqualTo(accountId);
+    assertThat(matchedAccountStates.get(0).getAccount().id()).isEqualTo(accountId);
   }
 
   @Test
@@ -82,7 +82,7 @@
     List<AccountState> matchedAccountStates =
         accountQueryProvider.get().byPreferredEmail(preferredEmail);
     assertThat(matchedAccountStates).hasSize(1);
-    assertThat(matchedAccountStates.get(0).getAccount().getId()).isEqualTo(accountId);
+    assertThat(matchedAccountStates.get(0).getAccount().id()).isEqualTo(accountId);
   }
 
   @Test
@@ -91,10 +91,10 @@
     loadAccountToCache(accountId);
     String status = "ooo";
     updateAccountWithoutCacheOrIndex(accountId, newAccountUpdate().setStatus(status).build());
-    assertThat(accountCache.get(accountId).get().getAccount().getStatus()).isNull();
+    assertThat(accountCache.get(accountId).get().getAccount().status()).isNull();
 
     accountIndexer.index(accountId);
-    assertThat(accountCache.get(accountId).get().getAccount().getStatus()).isEqualTo(status);
+    assertThat(accountCache.get(accountId).get().getAccount().status()).isEqualTo(status);
   }
 
   @Test
@@ -109,7 +109,7 @@
     List<AccountState> matchedAccountStates =
         accountQueryProvider.get().byPreferredEmail(preferredEmail);
     assertThat(matchedAccountStates).hasSize(1);
-    assertThat(matchedAccountStates.get(0).getAccount().getId()).isEqualTo(accountId);
+    assertThat(matchedAccountStates.get(0).getAccount().id()).isEqualTo(accountId);
   }
 
   @Test
@@ -136,7 +136,7 @@
 
   private Account.Id createAccount(String name) throws RestApiException {
     AccountInfo account = gApi.accounts().create(name).get();
-    return new Account.Id(account._accountId);
+    return Account.id(account._accountId);
   }
 
   private void reloadAccountToCache(Account.Id accountId) {
@@ -162,7 +162,7 @@
       md.getCommitBuilder().setAuthor(ident);
       md.getCommitBuilder().setCommitter(ident);
 
-      AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepo).load();
+      AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
       accountConfig.setAccountUpdate(accountUpdate);
       accountConfig.commit(md);
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index ed5459d..c48ee9d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.acceptance.api.accounts;
 
+import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -33,6 +35,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Repository;
@@ -122,7 +125,7 @@
   @Test
   public void authenticateWithEmail() throws Exception {
     String email = "foo@example.com";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key mailtoExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_MAILTO, email);
     accountsUpdate.insert(
         "Create Test Account",
@@ -137,7 +140,7 @@
   @Test
   public void authenticateWithUsername() throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -152,7 +155,7 @@
   @Test
   public void authenticateWithExternalUser() throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -168,7 +171,7 @@
   public void authenticateWithUsernameAndUpdateEmail() throws Exception {
     String username = "foo";
     String email = "foo@example.com";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -189,29 +192,32 @@
 
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
-    assertThat(accountState.get().getAccount().getPreferredEmail()).isEqualTo(newEmail);
+    assertThat(accountState.get().getAccount().preferredEmail()).isEqualTo(newEmail);
   }
 
   @Test
-  public void authenticateWhenUsernameExtIdAlreadyExists() throws Exception {
+  public void authenticateWithUsernameAndUpdateDisplayName() throws Exception {
     String username = "foo";
+    String email = "foo@example.com";
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
-    ExternalId.Key usernameExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_USERNAME, username);
-    assertNoSuchExternalIds(gerritExtIdKey, usernameExtIdKey);
-
-    // Create account with SCHEME_USERNAME external ID, but no SCHEME_GERRIT external ID.
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.setFullName("Foo").addExternalId(ExternalId.create(usernameExtIdKey, accountId)));
+        u ->
+            u.setFullName("Initial Name")
+                .setPreferredEmail(email)
+                .addExternalId(ExternalId.createWithEmail(gerritExtIdKey, accountId, email)));
 
     AuthRequest who = AuthRequest.forUser(username);
+    String newName = "Updated Name";
+    who.setDisplayName(newName);
     AuthResult authResult = accountManager.authenticate(who);
-
-    // Expect that the missing SCHEME_GERRIT external ID was created.
     assertAuthResultForExistingAccount(authResult, accountId, gerritExtIdKey);
-    assertExternalIdsWithoutEmail(gerritExtIdKey, usernameExtIdKey);
+
+    Optional<AccountState> accountState = accounts.get(accountId);
+    assertThat(accountState).isPresent();
+    assertThat(accountState.get().getAccount().fullName()).isEqualTo(newName);
   }
 
   @Test
@@ -221,7 +227,7 @@
     assertNoSuchExternalIds(gerritExtIdKey);
 
     // Create orphaned SCHEME_GERRIT external ID.
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId gerritExtId = ExternalId.create(gerritExtIdKey, accountId);
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
@@ -231,15 +237,15 @@
     }
 
     AuthRequest who = AuthRequest.forUser(username);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Authentication error, account not found");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().contains("Authentication error, account not found");
   }
 
   @Test
   public void cannotAuthenticateWithInactiveAccount() throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -247,16 +253,16 @@
         u -> u.setActive(false).addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
 
     AuthRequest who = AuthRequest.forUser(username);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Authentication error, account inactive");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().contains("Authentication error, account inactive");
   }
 
   @Test
   public void cannotActivateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsDisabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -266,9 +272,9 @@
     AuthRequest who = AuthRequest.forUser(username);
     who.setActive(true);
     who.setAuthProvidesAccountActiveStatus(true);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Authentication error, account inactive");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().contains("Authentication error, account inactive");
   }
 
   @Test
@@ -276,7 +282,7 @@
   public void activateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsEnabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -297,7 +303,7 @@
   public void cannotDeactivateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsDisabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -319,7 +325,7 @@
   public void deactivateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsEnabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -329,12 +335,9 @@
     AuthRequest who = AuthRequest.forUser(username);
     who.setActive(false);
     who.setAuthProvidesAccountActiveStatus(true);
-    try {
-      accountManager.authenticate(who);
-      fail("Expected AccountException");
-    } catch (AccountException e) {
-      assertThat(e).hasMessageThat().isEqualTo("Authentication error, account inactive");
-    }
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().isEqualTo("Authentication error, account inactive");
 
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
@@ -347,7 +350,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -357,9 +360,11 @@
     // Try to authenticate with this email to create a new account with a SCHEME_MAILTO external ID.
     // Expect that this fails because the email is already assigned to the other account.
     AuthRequest who = AuthRequest.forEmail(email);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Email 'foo@example.com' in use by another account");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
   }
 
   @Test
@@ -368,7 +373,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -379,9 +384,11 @@
     // Expect that this fails because the email is already assigned to the other account.
     AuthRequest who = AuthRequest.forUser("bar");
     who.setEmailAddress(email);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Email 'foo@example.com' in use by another account");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
   }
 
   @Test
@@ -391,7 +398,7 @@
 
     // Create an account with a SCHEME_GERRIT external ID and an email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -401,7 +408,7 @@
                 .addExternalId(ExternalId.createWithEmail(gerritExtIdKey, accountId, email)));
 
     // Create another account with an SCHEME_EXTERNAL external ID that occupies the new email.
-    Account.Id accountId2 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId2 = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, "bar");
     accountsUpdate.insert(
         "Create Test Account",
@@ -412,12 +419,11 @@
     // Expect that this fails because the new email is already assigned to the other account.
     AuthRequest who = AuthRequest.forUser(username);
     who.setEmailAddress(newEmail);
-    try {
-      accountManager.authenticate(who);
-      fail("Expected AccountException");
-    } catch (AccountException e) {
-      assertThat(e).hasMessageThat().isEqualTo("Email 'bar@example.com' in use by another account");
-    }
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Email 'bar@example.com' in use by another account");
 
     // Verify that the email in the external ID was not updated.
     Optional<ExternalId> gerritExtId = externalIds.get(gerritExtIdKey);
@@ -427,14 +433,14 @@
     // Verify that the preferred email was not updated.
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
-    assertThat(accountState.get().getAccount().getPreferredEmail()).isEqualTo(email);
+    assertThat(accountState.get().getAccount().preferredEmail()).isEqualTo(email);
   }
 
   @Test
   public void linkNewExternalId() throws Exception {
     // Create an account with a SCHEME_GERRIT external ID and no email
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -458,7 +464,7 @@
   public void updateExternalIdOnLink() throws Exception {
     // Create an account with a SCHEME_GERRIT external ID and no email
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -481,7 +487,7 @@
   public void cannotLinkExternalIdThatIsAlreadyUsed() throws Exception {
     // Create an account with a SCHEME_EXTERNAL external ID
     String username1 = "foo";
-    Account.Id accountId1 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId1 = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey1 = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username1);
     accountsUpdate.insert(
         "Create Test Account",
@@ -490,7 +496,7 @@
 
     // Create another account with a SCHEME_EXTERNAL external ID
     String username2 = "bar";
-    Account.Id accountId2 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId2 = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey2 = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username2);
     accountsUpdate.insert(
         "Create Test Account",
@@ -500,9 +506,11 @@
     // Try to link external ID of the first account to the second account.
     // Expect that this fails because the external ID is already assigned to the first account.
     AuthRequest who = AuthRequest.forExternalUser(username1);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Identity 'external:foo' in use by another account");
-    accountManager.link(accountId2, who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.link(accountId2, who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Identity 'external:foo' in use by another account");
   }
 
   @Test
@@ -511,7 +519,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -520,7 +528,7 @@
 
     // Create another account with a SCHEME_GERRIT external ID and no email
     String username2 = "foo";
-    Account.Id accountId2 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId2 = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username2);
     accountsUpdate.insert(
         "Create Test Account",
@@ -530,14 +538,19 @@
     // Try to link the email to the second account (via a new MAILTO external ID) and expect that
     // this fails because the email is already assigned to the first account.
     AuthRequest who = AuthRequest.forEmail(email);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Email 'foo@example.com' in use by another account");
-    accountManager.link(accountId, who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.link(accountId, who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
   }
 
   private void assertNoSuchExternalIds(ExternalId.Key... extIdKeys) throws Exception {
     for (ExternalId.Key extIdKey : extIdKeys) {
-      assertThat(externalIds.get(extIdKey)).named(extIdKey.get()).isEmpty();
+      assertWithMessage(extIdKey.get())
+          .about(optionals())
+          .that(externalIds.get(extIdKey))
+          .isEmpty();
     }
   }
 
@@ -558,13 +571,15 @@
       @Nullable String expectedEmail)
       throws Exception {
     Optional<ExternalId> extId = externalIds.get(extIdKey);
-    assertThat(extId).named(extIdKey.get()).isPresent();
+    assertWithMessage(extIdKey.get()).about(optionals()).that(extId).isPresent();
     if (expectedAccountId != null) {
-      assertThat(extId.get().accountId())
-          .named("account ID of " + extIdKey.get())
+      assertWithMessage("account ID of " + extIdKey.get())
+          .that(extId.get().accountId())
           .isEqualTo(expectedAccountId);
     }
-    assertThat(extId.get().email()).named("email of " + extIdKey.get()).isEqualTo(expectedEmail);
+    assertWithMessage("email of " + extIdKey.get())
+        .that(extId.get().email())
+        .isEqualTo(expectedEmail);
   }
 
   private void assertAuthResultForNewAccount(
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index c3a0dc9..6dc8fc5 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -16,13 +16,24 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -34,8 +45,14 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
@@ -46,6 +63,47 @@
 public class AgreementsIT extends AbstractDaemonTest {
   private ContributorAgreement caAutoVerify;
   private ContributorAgreement caNoAutoVerify;
+  @Inject private GroupOperations groupOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+      config.getProject().setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, value);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
+      throws Exception {
+    ContributorAgreement ca;
+    String name = autoVerify ? "cla-test-group" : "cla-test-no-auto-verify-group";
+    AccountGroup.UUID g = groupOperations.newGroup().name(name).create();
+    GroupApi groupApi = gApi.groups().id(g.get());
+    groupApi.description("CLA test group");
+    InternalGroup caGroup = group(AccountGroup.uuid(groupApi.detail().id));
+    GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
+    PermissionRule rule = new PermissionRule(groupRef);
+    rule.setAction(PermissionRule.Action.ALLOW);
+    if (autoVerify) {
+      ca = new ContributorAgreement("cla-test");
+      ca.setAutoVerify(groupRef);
+      ca.setAccepted(ImmutableList.of(rule));
+    } else {
+      ca = new ContributorAgreement("cla-test-no-auto-verify");
+    }
+    ca.setDescription("description");
+    ca.setAgreementUrl("agreement-url");
+    ca.setAccepted(ImmutableList.of(rule));
+    ca.setExcludeProjectsRegexes(ImmutableList.of("ExcludedProject"));
+
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig().replace(ca);
+      u.save();
+      return ca;
+    }
+  }
 
   @ConfigSuite.Config
   public static Config enableAgreementsConfig() {
@@ -68,7 +126,7 @@
   public void setUp() throws Exception {
     caAutoVerify = configureContributorAgreement(true);
     caNoAutoVerify = configureContributorAgreement(false);
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
   }
 
   @Test
@@ -88,17 +146,21 @@
   @Test
   public void signNonExistingAgreement() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("contributor agreement not found");
-    gApi.accounts().self().signAgreement("does-not-exist");
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.accounts().self().signAgreement("does-not-exist"));
+    assertThat(thrown).hasMessageThat().contains("contributor agreement not found");
   }
 
   @Test
   public void signAgreementNoAutoVerify() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot enter a non-autoVerify agreement");
-    gApi.accounts().self().signAgreement(caNoAutoVerify.getName());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().signAgreement(caNoAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("cannot enter a non-autoVerify agreement");
   }
 
   @Test
@@ -113,7 +175,7 @@
     gApi.accounts().self().signAgreement(caAutoVerify.getName());
 
     // Explicitly reset the user to force a new request context
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
 
     // Verify that the agreement was signed
     result = gApi.accounts().self().listAgreements();
@@ -131,33 +193,40 @@
   public void signAgreementAsOtherUser() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
     assertThat(gApi.accounts().self().get().name).isNotEqualTo("admin");
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to enter contributor agreement");
-    gApi.accounts().id("admin").signAgreement(caAutoVerify.getName());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().id("admin").signAgreement(caAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("not allowed to enter contributor agreement");
   }
 
   @Test
   public void signAgreementAnonymous() throws Exception {
-    setApiUserAnonymous();
-    exception.expect(AuthException.class);
-    exception.expectMessage("Authentication required");
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().self().signAgreement(caAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
 
   @Test
   public void agreementsDisabledSign() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isFalse();
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.accounts().self().signAgreement(caAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("contributor agreements disabled");
   }
 
   @Test
   public void agreementsDisabledList() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isFalse();
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().listAgreements();
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class, () -> gApi.accounts().self().listAgreements());
+    assertThat(thrown).hasMessageThat().contains("contributor agreements disabled");
   }
 
   @Test
@@ -169,15 +238,37 @@
     ChangeInfo change = gApi.changes().create(newChangeInput()).get();
 
     // Approve and submit it
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
     gApi.changes().id(change.changeId).current().submit(new SubmitInput());
 
     // Revert is not allowed when CLA is required but not signed
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     setUseContributorAgreements(InheritableBoolean.TRUE);
-    exception.expect(AuthException.class);
-    exception.expectMessage("A Contributor Agreement must be completed");
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(change.changeId).revert());
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
+  }
+
+  @Test
+  public void revertExcludedProjectChangeWithoutCLA() throws Exception {
+    // Contributor agreements configured with excludeProjects = ExcludedProject
+    // in AbstractDaemonTest.configureContributorAgreement(...)
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    // Project name includes test method name which contains ExcludedProject
+    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
+
+    // Approve and submit it
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
+
+    // Revert in excluded project is allowed even when CLA is required but not signed
+    requestScopeOperations.setApiUser(user.id());
+    setUseContributorAgreements(InheritableBoolean.TRUE);
     gApi.changes().id(change.changeId).revert();
   }
 
@@ -186,7 +277,7 @@
     assume().that(isContributorAgreementsEnabled()).isTrue();
 
     // Create a new branch
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     BranchInfo dest =
         gApi.projects()
             .name(project.get())
@@ -203,14 +294,15 @@
     gApi.changes().id(change.changeId).current().submit(new SubmitInput());
 
     // Cherry-pick is not allowed when CLA is required but not signed
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     setUseContributorAgreements(InheritableBoolean.TRUE);
     CherryPickInput in = new CherryPickInput();
     in.destination = dest.ref;
     in.message = change.subject;
-    exception.expect(AuthException.class);
-    exception.expectMessage("A Contributor Agreement must be completed");
-    gApi.changes().id(change.changeId).current().cherryPick(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(change.changeId).current().cherryPick(in));
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
   }
 
   @Test
@@ -223,23 +315,31 @@
 
     // Create a change is not allowed when CLA is required but not signed
     setUseContributorAgreements(InheritableBoolean.TRUE);
-    try {
-      gApi.changes().create(newChangeInput());
-      fail("Expected AuthException");
-    } catch (AuthException e) {
-      assertThat(e.getMessage()).contains("A Contributor Agreement must be completed");
-    }
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().create(newChangeInput()));
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
 
     // Sign the agreement
     gApi.accounts().self().signAgreement(caAutoVerify.getName());
 
     // Explicitly reset the user to force a new request context
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
 
     // Create a change succeeds after signing the agreement
     gApi.changes().create(newChangeInput());
   }
 
+  @Test
+  public void createExcludedProjectChangeIgnoresCLA() throws Exception {
+    // Contributor agreements configured with excludeProjects = ExcludedProject
+    // in AbstractDaemonTest.configureContributorAgreement(...)
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change in excluded project is allowed even when CLA is required but not signed.
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    gApi.changes().create(newChangeInput());
+  }
+
   private void assertAgreement(AgreementInfo info, ContributorAgreement ca) {
     assertThat(info.name).isEqualTo(ca.getName());
     assertThat(info.description).isEqualTo(ca.getDescription());
@@ -258,4 +358,33 @@
     in.project = project.get();
     return in;
   }
+
+  @Test
+  @GerritConfig(name = "auth.contributorAgreements", value = "true")
+  public void anonymousAccessServerInfoEvenWithCLAs() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    gApi.config().server().getInfo();
+  }
+
+  @Test
+  public void publishEditRestWithoutCLA() throws Exception {
+    String filename = "foo";
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "subject1", filename, "contentold");
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    String changeId = result.getChangeId();
+
+    gApi.changes().id(changeId).edit().create();
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyFile(filename, RawInputUtil.create("newcontent".getBytes(UTF_8)));
+
+    String url = "/changes/" + changeId + "/edit:publish";
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    userRestSession.post(url).assertForbidden();
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    userRestSession.post(url).assertNoContent();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
index b52efe7..2167d27 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -8,4 +8,9 @@
         "noci",
         "no_windows",
     ],
+    deps = [
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/mail",
+        "//java/com/google/gerrit/server/util/time",
+    ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
index ba340eb..d7e765b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
@@ -29,7 +29,7 @@
   @Test
   public void getDiffPreferences() throws Exception {
     DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
-    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
     assertPrefs(o, d);
   }
 
@@ -64,13 +64,13 @@
     i.matchBrackets ^= true;
     i.lineWrapping ^= true;
 
-    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
     assertPrefs(o, i);
 
     // Partially fill input record
     i = new DiffPreferencesInfo();
     i.tabSize = 42;
-    DiffPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    DiffPreferencesInfo a = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
     assertPrefs(a, o, "tabSize");
     assertThat(a.tabSize).isEqualTo(42);
   }
@@ -87,7 +87,7 @@
     update.fontSize = newFontSize;
     gApi.config().server().setDefaultDiffPreferences(update);
 
-    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
 
     // assert configured defaults
     assertThat(o.lineLength).isEqualTo(newLineLength);
@@ -106,29 +106,29 @@
     update.lineLength = configuredDefaultLineLength;
     gApi.config().server().setDefaultDiffPreferences(update);
 
-    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
     assertThat(o.lineLength).isEqualTo(configuredDefaultLineLength);
     assertPrefs(o, d, "lineLength");
 
     int newLineLength = configuredDefaultLineLength + 10;
     DiffPreferencesInfo i = new DiffPreferencesInfo();
     i.lineLength = newLineLength;
-    DiffPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    DiffPreferencesInfo a = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
     assertThat(a.lineLength).isEqualTo(newLineLength);
     assertPrefs(a, d, "lineLength");
 
-    a = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    a = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
     assertThat(a.lineLength).isEqualTo(newLineLength);
     assertPrefs(a, d, "lineLength");
 
     // overwrite the configured default with original hard-coded default
     i = new DiffPreferencesInfo();
     i.lineLength = d.lineLength;
-    a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    a = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
     assertThat(a.lineLength).isEqualTo(d.lineLength);
     assertPrefs(a, d, "lineLength");
 
-    a = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    a = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
     assertThat(a.lineLength).isEqualTo(d.lineLength);
     assertPrefs(a, d, "lineLength");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
index c1d9bcb..00d1733 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
@@ -27,7 +27,7 @@
 public class EditPreferencesIT extends AbstractDaemonTest {
   @Test
   public void getSetEditPreferences() throws Exception {
-    EditPreferencesInfo out = gApi.accounts().id(admin.getId().toString()).getEditPreferences();
+    EditPreferencesInfo out = gApi.accounts().id(admin.id().toString()).getEditPreferences();
 
     assertThat(out.lineLength).isEqualTo(100);
     assertThat(out.indentUnit).isEqualTo(2);
@@ -64,7 +64,7 @@
     out.theme = Theme.TWILIGHT;
     out.keyMapType = KeyMapType.EMACS;
 
-    EditPreferencesInfo info = gApi.accounts().id(admin.getId().toString()).setEditPreferences(out);
+    EditPreferencesInfo info = gApi.accounts().id(admin.id().toString()).setEditPreferences(out);
 
     assertEditPreferences(info, out);
 
@@ -72,7 +72,7 @@
     EditPreferencesInfo in = new EditPreferencesInfo();
     in.tabSize = 42;
 
-    info = gApi.accounts().id(admin.getId().toString()).setEditPreferences(in);
+    info = gApi.accounts().id(admin.id().toString()).setEditPreferences(in);
 
     out.tabSize = in.tabSize;
     assertEditPreferences(info, out);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 946e15c..12266c9 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -30,7 +31,13 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
 import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.inject.Inject;
+import com.google.inject.util.Providers;
 import java.util.ArrayList;
 import java.util.HashMap;
 import org.junit.Before;
@@ -38,6 +45,8 @@
 
 @NoHttpd
 public class GeneralPreferencesIT extends AbstractDaemonTest {
+  @Inject private DynamicMap<DownloadScheme> downloadSchemes;
+
   private TestAccount user42;
 
   @Before
@@ -48,7 +57,7 @@
 
   @Test
   public void getAndSetPreferences() throws Exception {
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id.toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).getPreferences();
     assertPrefs(o, GeneralPreferencesInfo.defaults(), "my", "changeTable");
     assertThat(o.my)
         .containsExactly(
@@ -88,7 +97,7 @@
     i.urlAliases = new HashMap<>();
     i.urlAliases.put("foo", "bar");
 
-    o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+    o = gApi.accounts().id(user42.id().toString()).setPreferences(i);
     assertPrefs(o, i, "my");
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
@@ -102,7 +111,7 @@
     update.changesPerPage = newChangesPerPage;
     gApi.config().server().setDefaultPreferences(update);
 
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).getPreferences();
 
     // assert configured defaults
     assertThat(o.changesPerPage).isEqualTo(newChangesPerPage);
@@ -119,29 +128,29 @@
     update.changesPerPage = configuredChangesPerPage;
     gApi.config().server().setDefaultPreferences(update);
 
-    GeneralPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getPreferences();
     assertThat(o.changesPerPage).isEqualTo(configuredChangesPerPage);
     assertPrefs(o, d, "my", "changeTable", "changesPerPage");
 
     int newChangesPerPage = configuredChangesPerPage * 2;
     GeneralPreferencesInfo i = new GeneralPreferencesInfo();
     i.changesPerPage = newChangesPerPage;
-    GeneralPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+    GeneralPreferencesInfo a = gApi.accounts().id(admin.id().toString()).setPreferences(i);
     assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
 
-    a = gApi.accounts().id(admin.getId().toString()).getPreferences();
+    a = gApi.accounts().id(admin.id().toString()).getPreferences();
     assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
 
     // overwrite the configured default with original hard-coded default
     i = new GeneralPreferencesInfo();
     i.changesPerPage = d.changesPerPage;
-    a = gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+    a = gApi.accounts().id(admin.id().toString()).setPreferences(i);
     assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
 
-    a = gApi.accounts().id(admin.getId().toString()).getPreferences();
+    a = gApi.accounts().id(admin.id().toString()).getPreferences();
     assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
   }
@@ -152,9 +161,11 @@
     i.my = new ArrayList<>();
     i.my.add(new MenuItem(null, "url"));
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("name for menu item is required");
-    gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+    assertThat(thrown).hasMessageThat().contains("name for menu item is required");
   }
 
   @Test
@@ -163,9 +174,11 @@
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", null));
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("URL for menu item is required");
-    gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+    assertThat(thrown).hasMessageThat().contains("URL for menu item is required");
   }
 
   @Test
@@ -174,7 +187,73 @@
     i.my = new ArrayList<>();
     i.my.add(new MenuItem(" name\t", " url\t", " _blank\t", " id\t"));
 
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).setPreferences(i);
     assertThat(o.my).containsExactly(new MenuItem("name", "url", "_blank", "id"));
   }
+
+  @Test
+  public void rejectUnsupportedDownloadScheme() throws Exception {
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+    i.downloadScheme = "foo";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Unsupported download scheme: " + i.downloadScheme);
+  }
+
+  @Test
+  public void setDownloadScheme() throws Exception {
+    String schemeName = "foo";
+    RegistrationHandle registrationHandle =
+        ((PrivateInternals_DynamicMapImpl<DownloadScheme>) downloadSchemes)
+            .put("myPlugin", schemeName, Providers.of(new TestDownloadScheme()));
+    try {
+      GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+      i.downloadScheme = schemeName;
+
+      GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).setPreferences(i);
+      assertThat(o.downloadScheme).isEqualTo(schemeName);
+
+      o = gApi.accounts().id(user42.id().toString()).getPreferences();
+      assertThat(o.downloadScheme).isEqualTo(schemeName);
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
+  @Test
+  public void unsupportedDownloadSchemeIsNotReturned() throws Exception {
+    // Set a download scheme and unregister the plugin that provides this download scheme so that it
+    // becomes unsupported.
+    setDownloadScheme();
+
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).getPreferences();
+    assertThat(o.downloadScheme).isNull();
+  }
+
+  private static class TestDownloadScheme extends DownloadScheme {
+    @Override
+    public String getUrl(String project) {
+      return "http://foo/" + project;
+    }
+
+    @Override
+    public boolean isAuthRequired() {
+      return false;
+    }
+
+    @Override
+    public boolean isAuthSupported() {
+      return false;
+    }
+
+    @Override
+    public boolean isEnabled() {
+      return true;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index 05eca2a..87f9a2d5 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
@@ -26,6 +28,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -34,6 +38,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.AbandonUtil;
+import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
@@ -44,6 +49,9 @@
 
 public class AbandonIT extends AbstractDaemonTest {
   @Inject private AbandonUtil abandonUtil;
+  @Inject private ChangeCleanupConfig cleanupConfig;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void abandon() throws Exception {
@@ -55,9 +63,9 @@
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
     assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is abandoned");
-    gApi.changes().id(changeId).abandon();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).abandon());
+    assertThat(thrown).hasMessageThat().contains("change is abandoned");
   }
 
   @Test
@@ -85,17 +93,23 @@
     String project2Name = name("Project2");
     gApi.projects().create(project1Name);
     gApi.projects().create(project2Name);
-    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
-    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
+    TestRepository<InMemoryRepository> project1 = cloneProject(Project.nameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(Project.nameKey(project2Name));
 
     CurrentUser user = atrScope.get().getUser();
     PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
     PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
     List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
-    batchAbandon.batchAbandon(batchUpdateFactory, new Project.NameKey(project1Name), user, list);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                batchAbandon.batchAbandon(
+                    batchUpdateFactory, Project.nameKey(project1Name), user, list));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
   }
 
   @Test
@@ -123,14 +137,39 @@
   }
 
   @Test
+  public void changeCleanupConfigDefaultAbandonMessage() throws Exception {
+    assertThat(cleanupConfig.getAbandonMessage())
+        .startsWith(
+            "Auto-Abandoned due to inactivity, see "
+                + canonicalWebUrl.get()
+                + "Documentation/user-change-cleanup.html#auto-abandon");
+  }
+
+  @Test
+  @GerritConfig(name = "changeCleanup.abandonMessage", value = "XX ${URL} XX")
+  public void changeCleanupConfigCustomAbandonMessageWithUrlReplacement() throws Exception {
+    assertThat(cleanupConfig.getAbandonMessage())
+        .isEqualTo(
+            "XX "
+                + canonicalWebUrl.get()
+                + "Documentation/user-change-cleanup.html#auto-abandon XX");
+  }
+
+  @Test
+  @GerritConfig(name = "changeCleanup.abandonMessage", value = "XX YYY XX")
+  public void changeCleanupConfigCustomAbandonMessageWithoutUrlReplacement() throws Exception {
+    assertThat(cleanupConfig.getAbandonMessage()).isEqualTo("XX YYY XX");
+  }
+
+  @Test
   public void abandonNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("abandon not permitted");
-    gApi.changes().id(changeId).abandon();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).abandon());
+    assertThat(thrown).hasMessageThat().contains("abandon not permitted");
   }
 
   @Test
@@ -138,8 +177,12 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
-    setApiUser(user);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).abandon();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
     gApi.changes().id(changeId).restore();
@@ -159,9 +202,9 @@
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
     assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is new");
-    gApi.changes().id(changeId).restore();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).restore());
+    assertThat(thrown).hasMessageThat().contains("change is new");
   }
 
   @Test
@@ -170,11 +213,11 @@
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
     gApi.changes().id(changeId).abandon();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
-    exception.expect(AuthException.class);
-    exception.expectMessage("restore not permitted");
-    gApi.changes().id(changeId).restore();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).restore());
+    assertThat(thrown).hasMessageThat().contains("restore not permitted");
   }
 
   private List<Integer> toChangeNumbers(List<ChangeInfo> changes) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/BUILD b/javatests/com/google/gerrit/acceptance/api/change/BUILD
index fd6c6d1..9279488 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/change/BUILD
@@ -7,4 +7,5 @@
         "api",
         "noci",
     ],
+    deps = ["//java/com/google/gerrit/server/util/time"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 3a3a6be..be4162e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -15,13 +15,20 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
@@ -40,57 +47,70 @@
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
+import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.CacheStatsSubject.assertThat;
+import static com.google.gerrit.truth.CacheStatsSubject.cloneStats;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheStats;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.ChangeIndexedCounter;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
-import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.Comment.Range;
@@ -123,40 +143,54 @@
 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.index.IndexConfig;
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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 com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.patch.IntraLineDiff;
+import com.google.gerrit.server.patch.IntraLineDiffKey;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeOperatorFactory;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.google.inject.name.Named;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
@@ -177,11 +211,27 @@
 public class ChangeIT extends AbstractDaemonTest {
   private String systemTimeZone;
 
-  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
-
-  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
-
   @Inject private AccountOperations accountOperations;
+  @Inject private ChangeIndexCollection changeIndexCollection;
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+  @Inject private GroupOperations groupOperations;
+  @Inject private IndexConfig indexConfig;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private DynamicSet<ChangeETagComputation> changeETagComputations;
+
+  @Inject
+  @Named("diff")
+  private Cache<PatchListKey, PatchList> fileCache;
+
+  @Inject
+  @Named("diff_intraline")
+  private Cache<IntraLineDiffKey, IntraLineDiff> intraCache;
+
+  @Inject
+  @Named("diff_summary")
+  private Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
 
   private ChangeIndexedCounter changeIndexedCounter;
   private RegistrationHandle changeIndexedCounterHandle;
@@ -200,7 +250,7 @@
   @Before
   public void addChangeIndexedCounter() {
     changeIndexedCounter = new ChangeIndexedCounter();
-    changeIndexedCounterHandle = changeIndexedListeners.add(changeIndexedCounter);
+    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
   }
 
   @After
@@ -211,16 +261,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();
@@ -236,7 +276,7 @@
     assertThat(c.created).isEqualTo(c.updated);
     assertThat(c._number).isEqualTo(r.getChange().getId().get());
 
-    assertThat(c.owner._accountId).isEqualTo(admin.getId().get());
+    assertThat(c.owner._accountId).isEqualTo(admin.id().get());
     assertThat(c.owner.name).isNull();
     assertThat(c.owner.email).isNull();
     assertThat(c.owner.username).isNull();
@@ -244,6 +284,48 @@
   }
 
   @Test
+  public void diffStatShouldComputeInsertionsAndDeletions() throws Exception {
+    String fileName = "a_new_file.txt";
+    String fileContent = "First line\nSecond line\n";
+    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+    String triplet = project.get() + "~master~" + result.getChangeId();
+    ChangeInfo change = gApi.changes().id(triplet).get();
+    assertThat(change.insertions).isNotNull();
+    assertThat(change.deletions).isNotNull();
+  }
+
+  @Test
+  public void diffStatShouldSkipInsertionsAndDeletions() throws Exception {
+    String fileName = "a_new_file.txt";
+    String fileContent = "First line\nSecond line\n";
+    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+    String triplet = project.get() + "~master~" + result.getChangeId();
+    ChangeInfo change =
+        gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
+    assertThat(change.insertions).isNull();
+    assertThat(change.deletions).isNull();
+  }
+
+  @Test
+  public void skipDiffstatOptionAvoidsAllDiffComputations() throws Exception {
+    String fileName = "a_new_file.txt";
+    String fileContent = "First line\nSecond line\n";
+    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+    String triplet = project.get() + "~master~" + result.getChangeId();
+    CacheStats startPatch = cloneStats(fileCache.stats());
+    CacheStats startIntra = cloneStats(intraCache.stats());
+    CacheStats startSummary = cloneStats(diffSummaryCache.stats());
+    gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
+
+    assertThat(fileCache.stats()).since(startPatch).hasMissCount(0);
+    assertThat(fileCache.stats()).since(startPatch).hasHitCount(0);
+    assertThat(intraCache.stats()).since(startIntra).hasMissCount(0);
+    assertThat(intraCache.stats()).since(startIntra).hasHitCount(0);
+    assertThat(diffSummaryCache.stats()).since(startSummary).hasMissCount(0);
+    assertThat(diffSummaryCache.stats()).since(startSummary).hasHitCount(0);
+  }
+
+  @Test
   public void skipMergeable() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -256,158 +338,11 @@
   }
 
   @Test
-  public void setPrivateByOwner() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    setApiUser(user);
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-
-    gApi.changes().id(changeId).setPrivate(true, null);
-    ChangeInfo info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isTrue();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private");
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
-
-    gApi.changes().id(changeId).setPrivate(false, null);
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isNull();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private");
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
-
-    String msg = "This is a security fix that must not be public.";
-    gApi.changes().id(changeId).setPrivate(true, msg);
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isTrue();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private\n\n" + msg);
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
-
-    msg = "After this security fix has been released we can make it public now.";
-    gApi.changes().id(changeId).setPrivate(false, msg);
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isNull();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private\n\n" + msg);
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
-  }
-
-  @Test
-  public void administratorCanSetUserChangePrivate() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-
-    gApi.changes().id(changeId).setPrivate(true, null);
-    setApiUser(user);
-    ChangeInfo info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isTrue();
-  }
-
-  @Test
-  public void cannotSetOtherUsersChangePrivate() throws Exception {
-    PushOneCommit.Result result = createChange();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to mark private");
-    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
-  }
-
-  @Test
-  public void accessPrivate() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    setApiUser(user);
-    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
-    // Owner can always access its private changes.
-    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
-
-    // Add admin as a reviewer.
-    gApi.changes().id(result.getChangeId()).addReviewer(admin.getId().toString());
-
-    // This change should be visible for admin as a reviewer.
-    setApiUser(admin);
-    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
-
-    // Remove admin from reviewers.
-    gApi.changes().id(result.getChangeId()).reviewer(admin.getId().toString()).remove();
-
-    // This change should not be visible for admin anymore.
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + result.getChangeId());
-    gApi.changes().id(result.getChangeId());
-  }
-
-  @Test
-  public void privateChangeOfOtherUserCanBeAccessedWithPermission() throws Exception {
-    PushOneCommit.Result result = createChange();
-    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
-
-    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
-    setApiUser(user);
-    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
-  }
-
-  @Test
-  public void administratorCanUnmarkPrivateAfterMerging() throws Exception {
-    PushOneCommit.Result result = createChange();
-    String changeId = result.getChangeId();
-    gApi.changes().id(changeId).setPrivate(true, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
-    merge(result);
-    gApi.changes().id(changeId).setPrivate(false, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-  }
-
-  @Test
-  public void administratorCanMarkPrivateAfterMerging() throws Exception {
-    PushOneCommit.Result result = createChange();
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-    merge(result);
-    gApi.changes().id(changeId).setPrivate(true, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
-  }
-
-  @Test
-  public void ownerCannotMarkPrivateAfterMerging() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-
-    merge(result);
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to mark private");
-    gApi.changes().id(changeId).setPrivate(true, null);
-  }
-
-  @Test
-  public void ownerCanUnmarkPrivateAfterMerging() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-    gApi.changes().id(changeId).addReviewer(admin.getId().toString());
-    gApi.changes().id(changeId).setPrivate(true, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
-
-    merge(result);
-
-    setApiUser(user);
-    gApi.changes().id(changeId).setPrivate(false, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+  @GerritConfig(name = "change.api.excludeMergeableInChangeInfo", value = "true")
+  public void excludeMergeableInChangeInfo() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertThat(c.mergeable).isNull();
   }
 
   @Test
@@ -415,70 +350,88 @@
     PushOneCommit.Result rwip = createChange();
     String changeId = rwip.getChangeId();
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to set work in progress");
-    gApi.changes().id(changeId).setWorkInProgress();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setWorkInProgress());
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
   public void setWorkInProgressAllowedAsAdmin() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     String changeId =
         gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(changeId).setWorkInProgress();
     assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
   }
 
   @Test
   public void setWorkInProgressAllowedAsProjectOwner() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     String changeId =
         gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
 
     com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
-    setApiUser(user2);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user2.id());
     gApi.changes().id(changeId).setWorkInProgress();
     assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
   }
 
   @Test
+  public void createWipChangeWithWorkInProgressByDefaultForProject() throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.workInProgressByDefault = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(input);
+    String changeId =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
+  }
+
+  @Test
   public void setReadyForReviewNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result rready = createChange();
     String changeId = rready.getChangeId();
     gApi.changes().id(changeId).setWorkInProgress();
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to set ready for review");
-    gApi.changes().id(changeId).setReadyForReview();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setReadyForReview());
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
   public void setReadyForReviewAllowedAsAdmin() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     String changeId =
         gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
     gApi.changes().id(changeId).setWorkInProgress();
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(changeId).setReadyForReview();
     assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
   }
 
   @Test
   public void setReadyForReviewAllowedAsProjectOwner() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     String changeId =
         gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
     gApi.changes().id(changeId).setWorkInProgress();
 
     com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
-    setApiUser(user2);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user2.id());
     gApi.changes().id(changeId).setReadyForReview();
     assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
   }
@@ -497,8 +450,6 @@
 
   @Test
   public void pendingReviewersInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
@@ -553,7 +504,7 @@
         ais -> ais.stream().map(ai -> ai.email).collect(toSet());
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
         .containsExactly(
-            admin.email, email1, email2, "byemail1@example.com", "byemail2@example.com");
+            admin.email(), email1, email2, "byemail1@example.com", "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
         .containsExactly(email3, email4, "byemail3@example.com", "byemail4@example.com");
     assertThat(info.pendingReviewers.get(REMOVED)).isNull();
@@ -565,7 +516,7 @@
     gApi.changes().id(changeId).reviewer("byemail3@example.com").remove();
     info = gApi.changes().id(changeId).get();
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(admin.email, email2, "byemail2@example.com");
+        .containsExactly(admin.email(), email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
         .containsExactly(email4, "byemail4@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
@@ -576,7 +527,7 @@
     gApi.changes().id(changeId).revision("current").review(in);
     info = gApi.changes().id(changeId).get();
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(admin.email, email1, email2, "byemail2@example.com");
+        .containsExactly(admin.email(), email1, email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
         .containsExactly(email4, "byemail4@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
@@ -587,7 +538,7 @@
     info = gApi.changes().id(changeId).get();
     assertThat(info.pendingReviewers).isEmpty();
     assertThat(toEmails.apply(info.reviewers.get(REVIEWER)))
-        .containsExactly(admin.email, email1, email2, "byemail2@example.com");
+        .containsExactly(admin.email(), email1, email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.reviewers.get(CC)))
         .containsExactly(email4, "byemail4@example.com");
     assertThat(info.reviewers.get(REMOVED)).isNull();
@@ -632,6 +583,39 @@
   }
 
   @Test
+  public void toggleWorkInProgressStateByNonOwnerWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String refactor = "Needs some refactoring";
+    String ptal = "PTAL";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).setWorkInProgress(refactor);
+
+    ChangeInfo info = gApi.changes().id(changeId).get();
+
+    assertThat(info.workInProgress).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).contains(refactor);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
+
+    gApi.changes().id(changeId).setReadyForReview(ptal);
+
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.workInProgress).isNull();
+    assertThat(Iterables.getLast(info.messages).message).contains(ptal);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
+  }
+
+  @Test
   public void reviewAndStartReview() throws Exception {
     PushOneCommit.Result r = createWorkInProgressChange();
     r.assertOkStatus();
@@ -648,7 +632,6 @@
   @Test
   public void reviewAndMoveToWorkInProgress() throws Exception {
     PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
 
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
@@ -662,24 +645,25 @@
   @Test
   public void reviewAndSetWorkInProgressAndAddReviewerAndVote() throws Exception {
     PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
 
     ReviewInput in =
-        ReviewInput.approve().reviewer(user.email).label("Code-Review", 1).setWorkInProgress(true);
+        ReviewInput.approve()
+            .reviewer(user.email())
+            .label("Code-Review", 1)
+            .setWorkInProgress(true);
     gApi.changes().id(r.getChangeId()).revision("current").review(in);
 
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
     assertThat(info.workInProgress).isTrue();
     assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
-        .containsExactly(admin.id.get(), user.id.get());
-    assertThat(info.labels.get("Code-Review").recommended._accountId).isEqualTo(admin.id.get());
+        .containsExactly(admin.id().get(), user.id().get());
+    assertThat(info.labels.get("Code-Review").recommended._accountId).isEqualTo(admin.id().get());
   }
 
   @Test
   public void reviewWithWorkInProgressAndReadyReturnsError() throws Exception {
     PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
     ReviewInput in = ReviewInput.noScore();
     in.ready = true;
     in.workInProgress = true;
@@ -688,13 +672,73 @@
   }
 
   @Test
+  @TestProjectInput(cloneAs = "user")
+  public void reviewWithWorkInProgressChangeOwner() throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id());
+
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    gApi.changes().id(r.getChangeId()).current().review(in);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void reviewWithWithWorkInProgressAdmin() throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id());
+
+    requestScopeOperations.setApiUser(admin.id());
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    gApi.changes().id(r.getChangeId()).current().review(in);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
   public void reviewWithWorkInProgressByNonOwnerReturnsError() throws Exception {
     PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    setApiUser(user);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
-    assertThat(result.error).isEqualTo(PostReview.ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS);
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
+  }
+
+  @Test
+  public void reviewWithWorkInProgressByNonOwnerWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(in);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
+  public void reviewWithReadyByNonOwnerReturnsError() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = ReviewInput.noScore().setReady(true);
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
@@ -709,8 +753,7 @@
 
     PushOneCommit push2 =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             PushOneCommit.FILE_NAME,
@@ -719,9 +762,9 @@
     PushOneCommit.Result r2 = push2.to("refs/for/other");
     assertThat(r2.getChangeId()).isEqualTo(changeId);
 
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Multiple changes found for " + changeId);
-    gApi.changes().id(changeId).get();
+    ResourceNotFoundException thrown =
+        assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(changeId).get());
+    assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + changeId);
   }
 
   @Test
@@ -751,7 +794,7 @@
   @Test
   public void revertNotifications() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
@@ -767,7 +810,7 @@
   @Test
   public void suppressRevertNotifications() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
@@ -784,33 +827,28 @@
     PushOneCommit.Result r = createChange();
 
     ReviewInput in = ReviewInput.approve();
-    in.reviewer(user.email);
-    in.reviewer(accountCreator.user2().email, ReviewerState.CC, true);
+    in.reviewer(user.email());
+    in.reviewer(accountCreator.user2().email(), ReviewerState.CC, true);
     // Add user as reviewer that will create the revert
-    in.reviewer(accountCreator.admin2().email);
+    in.reviewer(accountCreator.admin2().email());
 
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     // expect both the original reviewers and CCs to be preserved
     // original owner should be added as reviewer, user requesting the revert (new owner) removed
-    setApiUser(accountCreator.admin2());
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
     Map<ReviewerState, Collection<AccountInfo>> result =
         gApi.changes().id(r.getChangeId()).revert().get().reviewers;
     assertThat(result).containsKey(ReviewerState.REVIEWER);
 
     List<Integer> reviewers =
         result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
-    if (notesMigration.readChanges()) {
-      assertThat(result).containsKey(ReviewerState.CC);
-      List<Integer> ccs =
-          result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
-      assertThat(ccs).containsExactly(accountCreator.user2().id.get());
-      assertThat(reviewers).containsExactly(user.id.get(), admin.id.get());
-    } else {
-      assertThat(reviewers)
-          .containsExactly(user.id.get(), admin.id.get(), accountCreator.user2().id.get());
-    }
+    assertThat(result).containsKey(ReviewerState.CC);
+    List<Integer> ccs =
+        result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+    assertThat(ccs).containsExactly(accountCreator.user2().id().get());
+    assertThat(reviewers).containsExactly(user.id().get(), admin.id().get());
   }
 
   @Test
@@ -820,9 +858,10 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cannot revert initial commit");
-    gApi.changes().id(r.getChangeId()).revert();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(r.getChangeId()).revert());
+    assertThat(thrown).hasMessageThat().contains("Cannot revert initial commit");
   }
 
   @FunctionalInterface
@@ -865,8 +904,8 @@
     // ...and the committer and description should be correct
     ChangeInfo info = gApi.changes().id(changeId).get(CURRENT_REVISION, CURRENT_COMMIT);
     GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
-    assertThat(committer.name).isEqualTo(admin.fullName);
-    assertThat(committer.email).isEqualTo(admin.email);
+    assertThat(committer.name).isEqualTo(admin.fullName());
+    assertThat(committer.email).isEqualTo(admin.email());
     String description = info.revisions.get(info.currentRevision).description;
     assertThat(description).isEqualTo("Rebase");
 
@@ -876,21 +915,11 @@
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(1);
 
-    if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
-      // Ensure record was actually copied under ReviewDb
-      List<PatchSetApproval> psas =
-          unwrapDb(db)
-              .patchSetApprovals()
-              .byPatchSet(new PatchSet.Id(new Change.Id(c2._number), 2))
-              .toList();
-      assertThat(psas).hasSize(1);
-      assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
-    }
-
     // Rebasing the second change again should fail
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already up to date");
-    gApi.changes().id(changeId).current().rebase();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().rebase());
+    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
   }
 
   @Test
@@ -904,13 +933,78 @@
     RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
     assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
 
+    Change.Id id1 = r1.getChange().getId();
     RebaseInput in = new RebaseInput();
-    in.base = Integer.toString(r1.getChange().getId().get());
+    in.base = id1.toString();
     gApi.changes().id(r2.getChangeId()).rebase(in);
 
+    Change.Id id2 = r2.getChange().getId();
     ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
     ri2 = ci2.revisions.get(ci2.currentRevision);
     assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+    List<RelatedChangeAndCommitInfo> related =
+        gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
+    assertThat(related).hasSize(2);
+    assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
+    assertThat(related.get(0)._revisionNumber).isEqualTo(2);
+    assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
+    assertThat(related.get(1)._revisionNumber).isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseOnClosedChange() throws Exception {
+    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+    PushOneCommit.Result r1 = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+    // Submit first change.
+    Change.Id id1 = r1.getChange().getId();
+    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id1.get()).current().submit();
+
+    // Rebase second change on first change.
+    RebaseInput in = new RebaseInput();
+    in.base = id1.toString();
+    gApi.changes().id(r2.getChangeId()).rebase(in);
+
+    Change.Id id2 = r2.getChange().getId();
+    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+    ri2 = ci2.revisions.get(ci2.currentRevision);
+    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+    assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
+  }
+
+  @Test
+  public void rebaseFromRelationChainToClosedChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    testRepo.reset("HEAD~1");
+
+    createChange();
+    PushOneCommit.Result r3 = createChange();
+
+    // Submit first change.
+    Change.Id id1 = r1.getChange().getId();
+    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id1.get()).current().submit();
+
+    // Rebase third change on first change.
+    RebaseInput in = new RebaseInput();
+    in.base = id1.toString();
+    gApi.changes().id(r3.getChangeId()).rebase(in);
+
+    Change.Id id3 = r3.getChange().getId();
+    ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+    RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
+    assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+    assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
   }
 
   @Test
@@ -927,10 +1021,10 @@
 
     // Rebase the second
     String changeId = r2.getChangeId();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
+    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
   }
 
   @Test
@@ -945,11 +1039,15 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     // Rebase the second
     String changeId = r2.getChangeId();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).rebase();
   }
 
@@ -965,15 +1063,19 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
-    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Rebase the second
     String changeId = r2.getChangeId();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
+    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
   }
 
   @Test
@@ -988,90 +1090,159 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Rebase the second
     String changeId = r2.getChangeId();
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
+    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
   }
 
   @Test
   public void deleteNewChangeAsAdmin() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
-
-    gApi.changes().id(changeId).delete();
-
-    assertThat(query(changeId)).isEmpty();
+    deleteChangeAsUser(admin, admin);
   }
 
   @Test
   @TestProjectInput(cloneAs = "user")
   public void deleteNewChangeAsNormalUser() throws Exception {
     PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     String changeId = changeResult.getChangeId();
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteChangeAsUserWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+  public void deleteNewChangeAsUserWithDeleteChangesPermissionForGroup() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    deleteChangeAsUser(admin, user);
+  }
 
+  @Test
+  public void deleteNewChangeAsUserWithDeleteChangesPermissionForProjectOwners() throws Exception {
+    GroupApi groupApi = gApi.groups().create(name("delete-change"));
+    groupApi.addMembers("user");
+
+    Project.NameKey nameKey = Project.nameKey(name("delete-change"));
+    ProjectInput in = new ProjectInput();
+    in.name = nameKey.get();
+    in.owners = Lists.newArrayListWithCapacity(1);
+    in.owners.add(groupApi.name());
+    in.createEmptyCommit = true;
+    gApi.projects().create(in);
+
+    projectOperations
+        .project(nameKey)
+        .forUpdate()
+        .add(allow(Permission.DELETE_CHANGES).ref("refs/*").group(PROJECT_OWNERS))
+        .update();
+
+    deleteChangeAsUser(nameKey, admin, user);
+  }
+
+  @Test
+  public void deleteChangeAsUserWithDeleteOwnChangesPermissionForGroup() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    deleteChangeAsUser(user, user);
+  }
+
+  @Test
+  public void deleteChangeAsUserWithDeleteOwnChangesPermissionForOwners() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(CHANGE_OWNER))
+        .update();
+    deleteChangeAsUser(user, user);
+  }
+
+  private void deleteChangeAsUser(
+      com.google.gerrit.acceptance.TestAccount owner,
+      com.google.gerrit.acceptance.TestAccount deleteAs)
+      throws Exception {
+    deleteChangeAsUser(project, owner, deleteAs);
+  }
+
+  private void deleteChangeAsUser(
+      Project.NameKey projectName,
+      com.google.gerrit.acceptance.TestAccount owner,
+      com.google.gerrit.acceptance.TestAccount deleteAs)
+      throws Exception {
     try {
-      PushOneCommit.Result changeResult =
-          pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-      String changeId = changeResult.getChangeId();
-      int id = changeResult.getChange().getId().id;
-      RevCommit commit = changeResult.getCommit();
+      requestScopeOperations.setApiUser(owner.id());
+      ChangeInput in = new ChangeInput();
+      in.project = projectName.get();
+      in.branch = "refs/heads/master";
+      in.subject = "test";
+      ChangeInfo changeInfo = gApi.changes().create(in).get();
+      String changeId = changeInfo.changeId;
+      int id = changeInfo._number;
+      String commit = changeInfo.currentRevision;
 
-      setApiUser(user);
+      assertThat(gApi.changes().id(changeId).info().owner._accountId).isEqualTo(owner.id().get());
+
+      requestScopeOperations.setApiUser(deleteAs.id());
       gApi.changes().id(changeId).delete();
 
       assertThat(query(changeId)).isEmpty();
 
-      String ref = new Change.Id(id).toRefPrefix() + "1";
-      eventRecorder.assertRefUpdatedEvents(project.get(), ref, null, commit, commit, null);
+      String ref = Change.id(id).toRefPrefix() + "1";
+      eventRecorder.assertRefUpdatedEvents(projectName.get(), ref, null, commit, commit, null);
+      eventRecorder.assertChangeDeletedEvents(changeId, deleteAs.email());
     } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.DELETE_OWN_CHANGES).ref("refs/*"))
+          .remove(permissionKey(Permission.DELETE_CHANGES).ref("refs/*"))
+          .update();
     }
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
   public void deleteNewChangeOfAnotherUserAsAdmin() throws Exception {
-    PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    changeResult.assertOkStatus();
-    String changeId = changeResult.getChangeId();
-
-    setApiUser(admin);
-    gApi.changes().id(changeId).delete();
-
-    assertThat(query(changeId)).isEmpty();
+    deleteChangeAsUser(user, admin);
   }
 
   @Test
   public void deleteNewChangeOfAnotherUserWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     try {
       PushOneCommit.Result changeResult = createChange();
       String changeId = changeResult.getChangeId();
 
-      setApiUser(user);
-      exception.expect(AuthException.class);
-      exception.expectMessage("delete not permitted");
-      gApi.changes().id(changeId).delete();
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown =
+          assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+      assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.DELETE_OWN_CHANGES).ref("refs/*"))
+          .update();
     }
   }
 
@@ -1090,22 +1261,22 @@
   @TestProjectInput(cloneAs = "user")
   public void deleteAbandonedChangeAsNormalUser() throws Exception {
     PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     String changeId = changeResult.getChangeId();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).abandon();
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
   @TestProjectInput(cloneAs = "user")
   public void deleteAbandonedChangeOfAnotherUserAsAdmin() throws Exception {
     PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     String changeId = changeResult.getChangeId();
 
     gApi.changes().id(changeId).abandon();
@@ -1122,29 +1293,37 @@
 
     merge(changeResult);
 
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
   @TestProjectInput(cloneAs = "user")
   public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     try {
       PushOneCommit.Result changeResult =
-          pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+          pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
       String changeId = changeResult.getChangeId();
 
       merge(changeResult);
 
-      setApiUser(user);
-      exception.expect(MethodNotAllowedException.class);
-      exception.expectMessage("delete not permitted");
-      gApi.changes().id(changeId).delete();
+      requestScopeOperations.setApiUser(user.id());
+      MethodNotAllowedException thrown =
+          assertThrows(MethodNotAllowedException.class, () -> gApi.changes().id(changeId).delete());
+      assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.DELETE_OWN_CHANGES).ref("refs/*"))
+          .update();
     }
   }
 
@@ -1157,40 +1336,90 @@
     merge(changeResult);
     setChangeStatus(id, Change.Status.NEW);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Cannot delete change %s: patch set 1 is already merged", id));
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Cannot delete change %s: patch set 1 is already merged", id));
+  }
+
+  @Test
+  public void deleteChangeUpdatesIndex() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    ChangeIndex idx = changeIndexCollection.getSearchIndex();
+
+    Optional<ChangeData> result =
+        idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
+
+    assertThat(result.isPresent()).isTrue();
     gApi.changes().id(changeId).delete();
+    result = idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
+    assertThat(result.isPresent()).isFalse();
+  }
+
+  @Test
+  public void deleteChangeRemovesDraftComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    DraftInput dri = new DraftInput();
+    dri.message = "hello";
+    dri.path = "a.txt";
+    dri.line = 1;
+
+    gApi.changes().id(r.getChangeId()).current().createDraft(dri);
+    Change.Id num = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftComments(num, user.id())))
+          .isNotEmpty();
+    }
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    gApi.changes().id(r.getChangeId()).delete();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftComments(num, user.id())))
+          .isEmpty();
+    }
   }
 
   @Test
   public void rebaseUpToDateChange() throws Exception {
     PushOneCommit.Result r = createChange();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already up to date");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase());
+    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
   }
 
   @Test
   public void rebaseConflict() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
 
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             PushOneCommit.FILE_NAME,
             "other content",
             "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
-    r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    exception.expect(ResourceConflictException.class);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
+    PushOneCommit.Result r2 = push.to("refs/for/master");
+    r2.assertOkStatus();
+    assertThrows(
+        ResourceConflictException.class,
+        () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
   }
 
   @Test
@@ -1204,23 +1433,23 @@
     ri.base = "";
     gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
     PatchSet ps3 = r3.getPatchSet();
-    assertThat(ps3.getId().get()).isEqualTo(2);
+    assertThat(ps3.id().get()).isEqualTo(2);
 
     // rebase r2 onto r3 (referenced by ref)
-    ri.base = ps3.getId().toRefName();
+    ri.base = ps3.id().toRefName();
     gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
     PatchSet ps2 = r2.getPatchSet();
-    assertThat(ps2.getId().get()).isEqualTo(2);
+    assertThat(ps2.id().get()).isEqualTo(2);
 
     // rebase r1 onto r2 (referenced by commit)
-    ri.base = ps2.getRevision().get();
+    ri.base = ps2.commitId().name();
     gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
     PatchSet ps1 = r1.getPatchSet();
-    assertThat(ps1.getId().get()).isEqualTo(2);
+    assertThat(ps1.id().get()).isEqualTo(2);
 
     // rebase r1 onto r3 (referenced by change number)
     ri.base = String.valueOf(r3.getChange().getId().get());
-    gApi.changes().id(r1.getChangeId()).revision(ps1.getRevision().get()).rebase(ri);
+    gApi.changes().id(r1.getChangeId()).revision(ps1.commitId().name()).rebase(ri);
     assertThat(r1.getPatchSetId().get()).isEqualTo(3);
   }
 
@@ -1235,9 +1464,11 @@
         "base change "
             + r2.getChangeId()
             + " is a descendant of the current change - recursion not allowed";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(expectedMessage);
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri));
+    assertThat(thrown).hasMessageThat().contains(expectedMessage);
   }
 
   @Test
@@ -1249,9 +1480,11 @@
     ChangeInfo info = info(changeId);
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is abandoned");
-    gApi.changes().id(changeId).revision(r.getCommit().name()).rebase();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(r.getCommit().name()).rebase());
+    assertThat(thrown).hasMessageThat().contains("change is abandoned");
   }
 
   @Test
@@ -1271,9 +1504,11 @@
     RebaseInput ri = new RebaseInput();
     ri.base = r.getCommit().name();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("base change is abandoned: " + changeId);
-    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri));
+    assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
   }
 
   @Test
@@ -1283,9 +1518,11 @@
     String commit = r.getCommit().name();
     RebaseInput ri = new RebaseInput();
     ri.base = commit;
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot rebase change onto itself");
-    gApi.changes().id(changeId).revision(commit).rebase(ri);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(commit).rebase(ri));
+    assertThat(thrown).hasMessageThat().contains("cannot rebase change onto itself");
   }
 
   @Test
@@ -1336,62 +1573,60 @@
   @Test
   public void pushCommitOfOtherUser() throws Exception {
     // admin pushes commit of user
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
     PushOneCommit.Result result = push.to("refs/for/master");
     result.assertOkStatus();
 
     ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    assertThat(change.owner._accountId).isEqualTo(admin.id().get());
     CommitInfo commit = change.revisions.get(change.currentRevision).commit;
-    assertThat(commit.author.email).isEqualTo(user.email);
-    assertThat(commit.committer.email).isEqualTo(user.email);
+    assertThat(commit.author.email).isEqualTo(user.email());
+    assertThat(commit.committer.email).isEqualTo(user.email());
 
     // check that the author/committer was added as reviewer
     Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
     assertThat(change.reviewers.get(CC)).isNull();
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review");
+    assertThat(m.from().getName()).isEqualTo("Administrator (Code Review)");
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.body()).contains("I'd like you to do a code review");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailReplyTo(m, admin.email);
+    assertMailReplyTo(m, admin.email());
   }
 
   @Test
   public void pushCommitOfOtherUserThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
-    Project.NameKey p = createProject("p");
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    Project.NameKey p = projectOperations.newProject().create();
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // admin pushes commit of user
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), repo);
+    PushOneCommit push = pushFactory.create(user.newIdent(), repo);
     PushOneCommit.Result result = push.to("refs/for/master");
     result.assertOkStatus();
 
     ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    assertThat(change.owner._accountId).isEqualTo(admin.id().get());
     CommitInfo commit = change.revisions.get(change.currentRevision).commit;
-    assertThat(commit.author.email).isEqualTo(user.email);
-    assertThat(commit.committer.email).isEqualTo(user.email);
+    assertThat(commit.author.email).isEqualTo(user.email());
+    assertThat(commit.committer.email).isEqualTo(user.email());
 
     // check the user cannot see the change
-    setApiUser(user);
-    try {
-      gApi.changes().id(result.getChangeId()).get();
-      fail("Expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      // Expected.
-    }
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()).get());
 
     // check that the author/committer was NOT added as reviewer (he can't see
     // the change)
@@ -1405,14 +1640,13 @@
     // admin pushes commit that references 'user' in a footer
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT
                 + "\n\n"
                 + FooterConstants.REVIEWED_BY.getName()
                 + ": "
-                + user.getIdent().toExternalString(),
+                + user.newIdent().toExternalString(),
             PushOneCommit.FILE_NAME,
             PushOneCommit.FILE_CONTENT);
     PushOneCommit.Result result = push.to("refs/for/master");
@@ -1423,57 +1657,53 @@
     Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
     assertThat(change.reviewers.get(CC)).isNull();
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.body()).contains("Hello " + user.fullName() + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailReplyTo(m, admin.email);
+    assertMailReplyTo(m, admin.email());
   }
 
   @Test
   public void pushCommitWithFooterOfOtherUserThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
-    Project.NameKey p = createProject("p");
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    Project.NameKey p = projectOperations.newProject().create();
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // admin pushes commit that references 'user' in a footer
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             repo,
             PushOneCommit.SUBJECT
                 + "\n\n"
                 + FooterConstants.REVIEWED_BY.getName()
                 + ": "
-                + user.getIdent().toExternalString(),
+                + user.newIdent().toExternalString(),
             PushOneCommit.FILE_NAME,
             PushOneCommit.FILE_CONTENT);
     PushOneCommit.Result result = push.to("refs/for/master");
     result.assertOkStatus();
 
     // check that 'user' cannot see the change
-    setApiUser(user);
-    try {
-      gApi.changes().id(result.getChangeId()).get();
-      fail("Expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      // Expected.
-    }
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()).get());
 
     // check that 'user' was NOT added as cc ('user' can't see the change)
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
     assertThat(change.reviewers.get(REVIEWER)).isNull();
     assertThat(change.reviewers.get(CC)).isNull();
@@ -1483,35 +1713,32 @@
   @Test
   public void addReviewerThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
-    Project.NameKey p = createProject("p");
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    Project.NameKey p = projectOperations.newProject().create();
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // create change
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
     PushOneCommit.Result result = push.to("refs/for/master");
     result.assertOkStatus();
 
     // check the user cannot see the change
-    setApiUser(user);
-    try {
-      gApi.changes().id(result.getChangeId()).get();
-      fail("Expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      // Expected.
-    }
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()).get());
 
     // try to add user as reviewer
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
-    assertThat(r.input).isEqualTo(user.email);
+    assertThat(r.input).isEqualTo(user.email());
     assertThat(r.error).contains("does not have permission to see this change");
     assertThat(r.reviewers).isNull();
   }
@@ -1521,21 +1748,49 @@
     PushOneCommit.Result result = createChange();
 
     String username = name("new-user");
-    gApi.accounts().create(username).setActive(false);
+    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = username;
     AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
-    assertThat(r.input).isEqualTo(username);
-    assertThat(r.error).contains("identifies an inactive account");
+    assertThat(r.input).isEqualTo(in.reviewer);
+    assertThat(r.error)
+        .isEqualTo(
+            "Account '"
+                + username
+                + "' only matches inactive accounts. To use an inactive account, retry with one of"
+                + " the following exact account IDs:\n"
+                + id
+                + ": Name of user not set ("
+                + id
+                + ")\n"
+                + username
+                + " does not identify a registered user or group");
     assertThat(r.reviewers).isNull();
   }
 
   @Test
-  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void addReviewerThatIsInactiveById() throws Exception {
+    PushOneCommit.Result result = createChange();
 
+    String username = name("new-user");
+    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = Integer.toString(id.get());
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(in.reviewer);
+    assertThat(r.error).isNull();
+    assertThat(r.reviewers).hasSize(1);
+    ReviewerInfo reviewer = r.reviewers.get(0);
+    assertThat(reviewer._accountId).isEqualTo(id.get());
+    assertThat(reviewer.username).isEqualTo(username);
+  }
+
+  @Test
+  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
@@ -1543,7 +1798,7 @@
     PushOneCommit.Result result = createChange();
 
     String username = "user@domain.com";
-    gApi.accounts().create(username).setActive(false);
+    accountOperations.newAccount().username(username).inactive().create();
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = username;
@@ -1566,17 +1821,17 @@
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.body()).contains("Hello " + user.fullName() + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailReplyTo(m, admin.email);
+    assertMailReplyTo(m, admin.email());
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     // When NoteDb is enabled adding a reviewer records that user as reviewer
@@ -1586,7 +1841,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -1595,15 +1850,38 @@
 
     // Change status of reviewer and ensure ETag is updated.
     oldETag = rsrc.getETag();
-    accountOperations.account(user.id).forUpdate().status("new status").update();
+    accountOperations.account(user.id()).forUpdate().status("new status").update();
     rsrc = parseResource(r);
     assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
   }
 
   @Test
+  public void listReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers()).hasSize(1);
+
+    String username1 = name("user1");
+    String email1 = username1 + "@example.com";
+    accountOperations
+        .newAccount()
+        .username(username1)
+        .preferredEmail(email1)
+        .fullname("User 1")
+        .create();
+    in.reviewer = email1;
+    in.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a.username))
+        .containsExactly(user.username(), username1);
+  }
+
+  @Test
   public void notificationsForAddedWorkInProgressReviewers() throws Exception {
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     ReviewInput batchIn = new ReviewInput();
     batchIn.reviewers = ImmutableList.of(in);
 
@@ -1645,17 +1923,17 @@
     // 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")
             .preferredEmail(email)
             .fullname(fullname)
             .create();
-    String testGroup = createGroupWithRealName("ab");
+    String testGroup = groupOperations.newGroup().name("ab").create().get();
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
-    groupApi.addMembers(user.fullName);
+    groupApi.addMembers(user.fullName());
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = "abc";
@@ -1678,7 +1956,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);
@@ -1705,7 +1983,7 @@
 
     String myGroupUserEmail = "lee@test.com";
     String myGroupUserFullname = "lee";
-    TestAccount myGroupUser =
+    Account.Id accountIdOfGroupUser =
         accountOperations
             .newAccount()
             .username("lee")
@@ -1713,7 +1991,7 @@
             .fullname(myGroupUserFullname)
             .create();
 
-    String testGroup = createGroupWithRealName("kobe");
+    String testGroup = groupOperations.newGroup().name("kobe").create().get();
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
     groupApi.addMembers(myGroupUserFullname);
@@ -1742,7 +2020,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);
@@ -1751,26 +2029,6 @@
   }
 
   @Test
-  public void addReviewerWithNoteDbWhenDummyApprovalInReviewDbExists() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    assume().that(notesMigration.changePrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-
-    PushOneCommit.Result r = createChange();
-
-    // insert dummy approval in ReviewDb
-    PatchSetApproval psa =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(r.getPatchSetId(), user.id, new LabelId("Code-Review")),
-            (short) 0,
-            TimeUtil.nowTs());
-    db.patchSetApprovals().insert(Collections.singleton(psa));
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-  }
-
-  @Test
   public void addSelfAsReviewer() throws Exception {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
     PushOneCommit.Result r = createChange();
@@ -1779,8 +2037,8 @@
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    setApiUser(user);
+    in.reviewer = user.email();
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     // There should be no email notification when adding self
@@ -1794,7 +2052,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -1810,15 +2068,15 @@
   @Test
   public void implicitlyCcOnNonVotingReviewForUserWithoutUserNamePgStyle() throws Exception {
     com.google.gerrit.acceptance.TestAccount accountWithoutUsername = accountCreator.create();
-    assertThat(accountWithoutUsername.username).isNull();
+    assertThat(accountWithoutUsername.username()).isNull();
     testImplicitlyCcOnNonVotingReviewPgStyle(accountWithoutUsername);
   }
 
   private void testImplicitlyCcOnNonVotingReviewPgStyle(
       com.google.gerrit.acceptance.TestAccount testAccount) throws Exception {
     PushOneCommit.Result r = createChange();
-    setApiUser(testAccount);
-    assertThat(getReviewerState(r.getChangeId(), testAccount.id)).isEmpty();
+    requestScopeOperations.setApiUser(testAccount.id());
+    assertThat(getReviewerState(r.getChangeId(), testAccount.id())).isEmpty();
 
     // Exact request format made by PG UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
     ReviewInput in = new ReviewInput();
@@ -1828,45 +2086,13 @@
     in.reviewers = ImmutableList.of();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
 
-    // If we're not reading from NoteDb, then the CCed user will be returned in the REVIEWER state.
-    assertThat(getReviewerState(r.getChangeId(), testAccount.id))
-        .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
-  }
-
-  @Test
-  public void implicitlyCcOnNonVotingReviewGwtStyle() throws Exception {
-    testImplicitlyCcOnNonVotingReviewGwtStyle(user);
-  }
-
-  @Test
-  public void implicitlyCcOnNonVotingReviewForUserWithoutUserNameGwtStyle() throws Exception {
-    com.google.gerrit.acceptance.TestAccount accountWithoutUsername = accountCreator.create();
-    assertThat(accountWithoutUsername.username).isNull();
-    testImplicitlyCcOnNonVotingReviewGwtStyle(accountWithoutUsername);
-  }
-
-  private void testImplicitlyCcOnNonVotingReviewGwtStyle(
-      com.google.gerrit.acceptance.TestAccount testAccount) throws Exception {
-    PushOneCommit.Result r = createChange();
-    setApiUser(testAccount);
-    assertThat(getReviewerState(r.getChangeId(), testAccount.id)).isEmpty();
-
-    // Exact request format made by GWT UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
-    ReviewInput in = new ReviewInput();
-    in.labels = ImmutableMap.of("Code-Review", (short) 0);
-    in.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
-    in.message = "comment";
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-
-    // If we're not reading from NoteDb, then the CCed user will be returned in the REVIEWER state.
-    assertThat(getReviewerState(r.getChangeId(), testAccount.id))
-        .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
+    assertThat(getReviewerState(r.getChangeId(), testAccount.id())).hasValue(CC);
   }
 
   @Test
   public void implicitlyAddReviewerOnVotingReview() throws Exception {
     PushOneCommit.Result r = createChange();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
@@ -1874,24 +2100,23 @@
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     assertThat(c.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
-        .containsExactly(user.id.get());
+        .containsExactly(user.id().get());
 
     // Further test: remove the vote, then comment again. The user should be
     // implicitly re-added to the ReviewerSet, as a CC if we're using NoteDb.
-    setApiUser(admin);
-    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).remove();
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).remove();
     c = gApi.changes().id(r.getChangeId()).get();
     assertThat(c.reviewers.values()).isEmpty();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
         .review(new ReviewInput().message("hi"));
     c = gApi.changes().id(r.getChangeId()).get();
-    ReviewerState state = notesMigration.readChanges() ? CC : REVIEWER;
-    assertThat(c.reviewers.get(state).stream().map(ai -> ai._accountId).collect(toList()))
-        .containsExactly(user.id.get());
+    assertThat(c.reviewers.get(CC).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(user.id().get());
   }
 
   @Test
@@ -1903,19 +2128,19 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(admin.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(admin.id().get());
     assertThat(c.reviewers).doesNotContainKey(CC);
 
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     c = gApi.changes().id(r.getChangeId()).get();
     reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).hasSize(2);
     Iterator<AccountInfo> reviewerIt = reviewers.iterator();
-    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
-    assertThat(reviewerIt.next()._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.id().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(user.id().get());
     assertThat(c.reviewers).doesNotContainKey(CC);
   }
 
@@ -1925,17 +2150,63 @@
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
 
-    gApi.accounts().id(admin.id.get()).setStatus("new status");
+    accountOperations.account(admin.id()).forUpdate().status("new status").update();
     rsrc = parseResource(r);
     assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
   }
 
   @Test
+  public void pluginCanContributeToETagComputation() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String oldETag = parseResource(r).getETag();
+
+    RegistrationHandle registrationHandle = changeETagComputations.add("gerrit", (p, id) -> "foo");
+    try {
+      assertThat(parseResource(r).getETag()).isNotEqualTo(oldETag);
+    } finally {
+      registrationHandle.remove();
+    }
+
+    assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
+  }
+
+  @Test
+  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String oldETag = parseResource(r).getETag();
+
+    RegistrationHandle registrationHandle = changeETagComputations.add("gerrit", (p, id) -> null);
+    try {
+      assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
+  @Test
+  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String oldETag = parseResource(r).getETag();
+
+    RegistrationHandle registrationHandle =
+        changeETagComputations.add(
+            "gerrit",
+            (p, id) -> {
+              throw new StorageException("exception during test");
+            });
+    try {
+      assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
+  @Test
   public void emailNotificationForFileLevelComment() throws Exception {
     String changeId = createChange().getChangeId();
 
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
     sender.clear();
 
@@ -1950,7 +2221,7 @@
 
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
   }
 
   @Test
@@ -1971,8 +2242,8 @@
     comment.message = "comment 1";
     review.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
 
-    exception.expect(BadRequestException.class);
-    gApi.changes().id(changeId).current().review(review);
+    assertThrows(
+        BadRequestException.class, () -> gApi.changes().id(changeId).current().review(review));
   }
 
   @Test
@@ -1981,15 +2252,15 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     Map<String, Short> m =
-        gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).votes();
+        gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).votes();
 
     assertThat(m).hasSize(1);
     assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.dislike());
 
-    m = gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).votes();
+    m = gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).votes();
 
     assertThat(m).hasSize(1);
     assertThat(m).containsEntry("Code-Review", Short.valueOf((short) -1));
@@ -1997,53 +2268,51 @@
 
   @Test
   public void removeReviewerNoVotes() throws Exception {
+    LabelType verified =
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType verified =
-          category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      String heads = RefNames.REFS_HEADS + "*";
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.verified().getName()),
-          -1,
-          1,
-          registeredUsers,
-          heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(verified.getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    gApi.changes().id(changeId).addReviewer(user.getId().toString());
+    gApi.changes().id(changeId).addReviewer(user.id().toString());
 
-    // ReviewerState will vary between ReviewDb and NoteDb; we just care that it
-    // shows up somewhere.
-    Iterable<AccountInfo> reviewers =
-        Iterables.concat(gApi.changes().id(changeId).get().reviewers.values());
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    ChangeInfo c = gApi.changes().id(changeId).get(ListChangesOption.DETAILED_LABELS);
+    assertThat(getReviewers(c.reviewers.get(CC))).isEmpty();
+    assertThat(getReviewers(c.reviewers.get(REVIEWER))).containsExactly(user.id());
 
     sender.clear();
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
     assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
 
     assertThat(sender.getMessages()).hasSize(1);
     Message message = sender.getMessages().get(0);
-    assertThat(message.body()).contains("Removed reviewer " + user.fullName + ".");
+    assertThat(message.body()).contains("Removed reviewer " + user.fullName() + ".");
     assertThat(message.body()).doesNotContain("with the following votes");
 
     // Make sure the reviewer can still be added again.
-    gApi.changes().id(changeId).addReviewer(user.getId().toString());
-    reviewers = Iterables.concat(gApi.changes().id(changeId).get().reviewers.values());
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    gApi.changes().id(changeId).addReviewer(user.id().toString());
+    c = gApi.changes().id(changeId).get();
+    assertThat(getReviewers(c.reviewers.get(CC))).isEmpty();
+    assertThat(getReviewers(c.reviewers.get(REVIEWER))).containsExactly(user.id());
 
     // Remove again, and then try to remove once more to verify 404 is
     // returned.
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).reviewer(user.id().toString()).remove());
   }
 
   @Test
@@ -2061,30 +2330,30 @@
     String changeId = r.getChangeId();
     gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.recommend());
 
     Collection<AccountInfo> reviewers = gApi.changes().id(changeId).get().reviewers.get(REVIEWER);
 
     assertThat(reviewers).hasSize(2);
     Iterator<AccountInfo> reviewerIt = reviewers.iterator();
-    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
-    assertThat(reviewerIt.next()._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.id().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(user.id().get());
 
     sender.clear();
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     DeleteReviewerInput input = new DeleteReviewerInput();
     if (!notify) {
       input.notify = NotifyHandling.NONE;
     }
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove(input);
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove(input);
 
     if (notify) {
       assertThat(sender.getMessages()).hasSize(1);
       Message message = sender.getMessages().get(0);
       assertThat(message.body())
-          .contains("Removed reviewer " + user.fullName + " with the following votes");
-      assertThat(message.body()).contains("* Code-Review+1 by " + user.fullName);
+          .contains("Removed reviewer " + user.fullName() + " with the following votes");
+      assertThat(message.body()).contains("* Code-Review+1 by " + user.fullName());
     } else {
       assertThat(sender.getMessages()).isEmpty();
     }
@@ -2092,9 +2361,9 @@
     reviewers = gApi.changes().id(changeId).get().reviewers.get(REVIEWER);
     assertThat(reviewers).hasSize(1);
     reviewerIt = reviewers.iterator();
-    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.id().get());
 
-    eventRecorder.assertReviewerDeletedEvents(changeId, user.email);
+    eventRecorder.assertReviewerDeletedEvents(changeId, user.email());
   }
 
   @Test
@@ -2103,10 +2372,68 @@
     String changeId = r.getChangeId();
     gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).remove();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  @Test
+  public void removeReviewerSelfFromMergedChangeNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    requestScopeOperations.setApiUser(admin.id());
+    approve(changeId);
+    gApi.changes().id(changeId).revision(r.getCommit().name()).submit();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer("self").remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  @Test
+  public void removeReviewerSelfFromAbandonedChangePermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(changeId).abandon();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).reviewer("self").remove();
+    eventRecorder.assertReviewerDeletedEvents(changeId, user.email());
+  }
+
+  @Test
+  public void removeOtherReviewerFromAbandonedChangeNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    requestScopeOperations.setApiUser(admin.id());
+    approve(changeId);
+    gApi.changes().id(changeId).abandon();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -2114,23 +2441,23 @@
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     recommend(r.getChangeId());
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     sender.clear();
-    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote("Code-Review");
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote("Code-Review");
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message msg = messages.get(0);
-    assertThat(msg.rcpt()).containsExactly(user.emailAddress);
-    assertThat(msg.body()).contains(admin.fullName + " has removed a vote on this change.\n");
+    assertThat(msg.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(msg.body()).contains(admin.fullName() + " has removed a vote from this change.");
     assertThat(msg.body())
-        .contains("Removed Code-Review+1 by " + user.fullName + " <" + user.email + ">\n");
+        .contains("Removed Code-Review+1 by " + user.fullName() + " <" + user.email() + ">\n");
 
     Map<String, Short> m =
-        gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).votes();
+        gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).votes();
 
     // Dummy 0 approval on the change to block vote copying to this patch set.
     assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
@@ -2138,10 +2465,10 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
-    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
+    assertThat(message.author._accountId).isEqualTo(admin.id().get());
     assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+        .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
 
   @Test
@@ -2149,15 +2476,15 @@
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     recommend(r.getChangeId());
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     sender.clear();
     DeleteVoteInput in = new DeleteVoteInput();
     in.label = "Code-Review";
     in.notify = NotifyHandling.NONE;
-    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
     assertThat(sender.getMessages()).isEmpty();
   }
 
@@ -2172,41 +2499,40 @@
 
     // notify unrelated account as TO
     String email = "user2@example.com";
-    TestAccount user2 =
-        accountOperations
-            .newAccount()
-            .username("user2")
-            .preferredEmail(email)
-            .fullname("User2")
-            .create();
-    setApiUser(user);
+    accountOperations
+        .newAccount()
+        .username("user2")
+        .preferredEmail(email)
+        .fullname("User2")
+        .create();
+    requestScopeOperations.setApiUser(user.id());
     recommend(r.getChangeId());
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     sender.clear();
     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);
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
+    assertNotifyTo(email, "User2");
 
     // notify unrelated account as CC
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     recommend(r.getChangeId());
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     sender.clear();
     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);
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
+    assertNotifyCc(email, "User2");
 
     // notify unrelated account as BCC
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     recommend(r.getChangeId());
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     sender.clear();
     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);
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
+    assertNotifyBcc(email, "User2");
   }
 
   @Test
@@ -2214,25 +2540,33 @@
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete vote not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).deleteVote("Code-Review");
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .reviewer(admin.id().toString())
+                    .deleteVote("Code-Review"));
+    assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
   }
 
   @Test
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
     LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      String heads = "refs/heads/*";
-      AccountGroup.UUID owners = systemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
-      AccountGroup.UUID registered = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, owners, heads);
-      Util.allow(u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, registered, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(CHANGE_OWNER).range(-1, 1))
+        .add(allowLabel("Code-Review").ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     // Set Code-Review+2 and Verified+1 as admin (change owner)
     PushOneCommit.Result r = createChange();
@@ -2245,31 +2579,31 @@
     // Reviewers should only be "admin"
     ChangeInfo c = gApi.changes().id(changeId).get();
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
+        .containsExactlyElementsIn(ImmutableSet.of(admin.id()));
     assertThat(c.reviewers.get(CC)).isNull();
 
     // Add the user as reviewer
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
     c = gApi.changes().id(changeId).get();
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+        .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
 
     // Approve the change as user, then remove the approval
     // (only to confirm that the user does have Code-Review+2 permission)
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).revision(commit).review(ReviewInput.approve());
     gApi.changes().id(changeId).revision(commit).review(ReviewInput.noScore());
 
     // Submit the change
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(changeId).revision(commit).submit();
 
     // User should still be on the change
     c = gApi.changes().id(changeId).get();
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+        .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
 
   @Test
@@ -2280,7 +2614,7 @@
     in.project = project.get();
     ChangeInfo info = gApi.changes().create(in).get();
     assertThat(info.project).isEqualTo(in.project);
-    assertThat(info.branch).isEqualTo(in.branch);
+    assertThat(RefNames.fullName(info.branch)).isEqualTo(RefNames.fullName(in.branch));
     assertThat(info.subject).isEqualTo(in.subject);
     assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1.");
   }
@@ -2327,6 +2661,24 @@
   }
 
   @Test
+  public void queryChangesNoLimit() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.QUERY_LIMIT)
+                .group(SystemGroupBackend.REGISTERED_USERS)
+                .range(0, 2))
+        .update();
+    for (int i = 0; i < 3; i++) {
+      createChange();
+    }
+    List<ChangeInfo> resultsWithDefaultLimit = gApi.changes().query().get();
+    List<ChangeInfo> resultsWithNoLimit = gApi.changes().query().withNoLimit().get();
+    assertThat(resultsWithDefaultLimit).hasSize(2);
+    assertThat(resultsWithNoLimit.size()).isAtLeast(3);
+  }
+
+  @Test
   public void queryChangesStart() throws Exception {
     PushOneCommit.Result r1 = createChange();
     createChange();
@@ -2369,7 +2721,7 @@
     RevisionInfo rev = Iterables.getOnlyElement(result.revisions.values());
     assertThat(rev._number).isEqualTo(r.getPatchSetId().get());
     assertThat(rev.created).isNotNull();
-    assertThat(rev.uploader._accountId).isEqualTo(admin.getId().get());
+    assertThat(rev.uploader._accountId).isEqualTo(admin.id().get());
     assertThat(rev.ref).isEqualTo(r.getPatchSetId().toRefName());
     assertThat(rev.actions).isNotEmpty();
   }
@@ -2380,18 +2732,59 @@
     assertThat(
             Iterables.getOnlyElement(query("project:{" + project.get() + "} owner:self")).changeId)
         .isEqualTo(r.getChangeId());
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(query("owner:self project:{" + project.get() + "}")).isEmpty();
   }
 
+  private static class OperatorModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(ChangeOperatorFactory.class)
+          .annotatedWith(Exports.named("mytopic"))
+          .toInstance((cqb, value) -> new MyTopicPredicate(value));
+    }
+
+    private static class MyTopicPredicate extends PostFilterPredicate<ChangeData> {
+      MyTopicPredicate(String value) {
+        super("mytopic", value);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) {
+        return Objects.equals(cd.change().getTopic(), value);
+      }
+
+      @Override
+      public int getCost() {
+        return 2;
+      }
+    }
+  }
+
+  @Test
+  public void queryChangesPluginOperator() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String query = "mytopic_myplugin:foo";
+    String expectedMessage = "Unsupported operator mytopic_myplugin:foo";
+    assertThatQueryException(query).hasMessageThat().isEqualTo(expectedMessage);
+
+    try (AutoCloseable ignored = installPlugin("myplugin", OperatorModule.class)) {
+      assertThat(query(query)).isEmpty();
+      gApi.changes().id(r.getChangeId()).topic("foo");
+      assertThat(query(query).stream().map(i -> i.changeId)).containsExactly(r.getChangeId());
+    }
+
+    assertThatQueryException(query).hasMessageThat().isEqualTo(expectedMessage);
+  }
+
   @Test
   public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
     PushOneCommit.Result r = createChange();
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(get(r.getChangeId(), REVIEWED).reviewed).isNull();
 
     revision(r).review(ReviewInput.recommend());
@@ -2412,18 +2805,23 @@
   public void editTopicWithoutPermissionNotAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit topic name not permitted");
-    gApi.changes().id(r.getChangeId()).topic("mytopic");
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).topic("mytopic"));
+    assertThat(thrown).hasMessageThat().contains("edit topic name not permitted");
   }
 
   @Test
   public void editTopicWithPermissionAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
-    grant(project, "refs/heads/master", Permission.EDIT_TOPIC_NAME, false, REGISTERED_USERS);
-    setApiUser(user);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.EDIT_TOPIC_NAME).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).topic("mytopic");
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
   }
@@ -2467,18 +2865,24 @@
   public void submitNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit not permitted");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit());
+    assertThat(thrown).hasMessageThat().contains("submit not permitted");
   }
 
   @Test
   public void submitAllowedWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    grant(project, "refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
-    setApiUser(user);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
     assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
   }
@@ -2493,29 +2897,30 @@
   @Test
   public void commitFooters() throws Exception {
     LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     LabelType custom1 =
-        category("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+        label("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     LabelType custom2 =
-        category("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+        label("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
       u.getConfig().getLabelSections().put(custom1.getName(), custom1);
       u.getConfig().getLabelSections().put(custom2.getName(), custom2);
-      String heads = "refs/heads/*";
-      AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(u.getConfig(), Permission.forLabel("Verified"), -1, 1, anon, heads);
-      Util.allow(u.getConfig(), Permission.forLabel("Custom1"), -1, 1, anon, heads);
-      Util.allow(u.getConfig(), Permission.forLabel("Custom2"), -1, 1, anon, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(custom1.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(custom2.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .update();
 
     PushOneCommit.Result r1 = createChange();
     r1.assertOkStatus();
     PushOneCommit.Result r2 =
         pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
             .to("refs/for/master");
     r2.assertOkStatus();
 
@@ -2543,7 +2948,12 @@
     List<String> expectedFooters =
         Arrays.asList(
             "Change-Id: " + r2.getChangeId(),
-            "Reviewed-on: " + canonicalWebUrl.get() + r2.getChange().getId(),
+            "Reviewed-on: "
+                + canonicalWebUrl.get()
+                + "c/"
+                + project.get()
+                + "/+/"
+                + r2.getChange().getId(),
             "Reviewed-by: Administrator <admin@example.com>",
             "Custom2: Administrator <admin@example.com>",
             "Tested-by: Administrator <admin@example.com>");
@@ -2556,16 +2966,10 @@
     PushOneCommit.Result change = createChange();
     RegistrationHandle handle =
         changeMessageModifiers.add(
-            new ChangeMessageModifier() {
-              @Override
-              public String onSubmit(
-                  String newCommitMessage,
-                  RevCommit original,
-                  RevCommit mergeTip,
-                  Branch.NameKey destination) {
-                assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
-                return newCommitMessage + "Custom: " + destination.get();
-              }
+            "gerrit",
+            (newCommitMessage, original, mergeTip, destination) -> {
+              assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
+              return newCommitMessage + "Custom: " + destination.branch();
             });
     ChangeInfo actual;
     try {
@@ -2584,14 +2988,19 @@
     List<String> expectedFooters =
         Arrays.asList(
             "Change-Id: " + change.getChangeId(),
-            "Reviewed-on: " + canonicalWebUrl.get() + change.getChange().getId(),
+            "Reviewed-on: "
+                + canonicalWebUrl.get()
+                + "c/"
+                + project.get()
+                + "/+/"
+                + change.getChange().getId(),
             "Custom: refs/heads/master");
     assertThat(footers).containsExactlyElementsIn(expectedFooters);
   }
 
   @Test
   public void defaultSearchDoesNotTouchDatabase() throws Exception {
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     PushOneCommit.Result r1 = createChange();
     gApi.changes()
         .id(r1.getChangeId())
@@ -2601,9 +3010,8 @@
 
     createChange();
 
-    setApiUser(user);
-    AcceptanceTestRequestScope.Context ctx = disableDb();
-    try {
+    requestScopeOperations.setApiUser(user.id());
+    try (AutoCloseable ignored = disableNoteDb()) {
       assertThat(
               gApi.changes()
                   .query()
@@ -2614,8 +3022,6 @@
                   .withOption(REVIEWED)
                   .get())
           .hasSize(2);
-    } finally {
-      enableDb(ctx);
     }
   }
 
@@ -2623,24 +3029,25 @@
   public void votable() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
-    gApi.changes().id(triplet).addReviewer(user.username);
+    gApi.changes().id(triplet).addReviewer(user.username());
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     LabelInfo codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval._accountId).isEqualTo(user.id().get());
     assertThat(approval.value).isEqualTo(0);
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.blockLabel(u.getConfig(), "Code-Review", REGISTERED_USERS, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval._accountId).isEqualTo(user.id().get());
     assertThat(approval.value).isNull();
   }
 
@@ -2668,7 +3075,7 @@
 
   @Test
   public void anonymousRestApi() throws Exception {
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     PushOneCommit.Result r = createChange();
 
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
@@ -2680,32 +3087,27 @@
 
     info = gApi.changes().id(info._number).get();
     assertThat(info.changeId).isEqualTo(r.getChangeId());
-
-    exception.expect(AuthException.class);
-    gApi.changes().id(triplet).current().review(ReviewInput.approve());
+    assertThrows(
+        AuthException.class,
+        () -> gApi.changes().id(triplet).current().review(ReviewInput.approve()));
   }
 
   @Test
   public void noteDbCommitsOnPatchSetCreation() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     PushOneCommit.Result r = createChange();
     pushFactory
-        .create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "4711", r.getChangeId())
+        .create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "4711", r.getChangeId())
         .to("refs/for/master")
         .assertOkStatus();
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commitPatchSetCreation =
-          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+          rw.parseCommit(repo.exactRef(changeMetaRef(Change.id(c._number))).getObjectId());
 
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
-          changeNoteUtil
-              .getLegacyChangeNoteWrite()
-              .newIdent(getAccount(admin.id), c.updated, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id()), c.updated, serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -2714,9 +3116,7 @@
       RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
       expectedAuthor =
-          changeNoteUtil
-              .getLegacyChangeNoteWrite()
-              .newIdent(getAccount(admin.id), c.created, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id()), c.created, serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
@@ -2733,7 +3133,7 @@
     in.newBranch = true;
     ChangeInfo info = gApi.changes().create(in).get();
     assertThat(info.project).isEqualTo(in.project);
-    assertThat(info.branch).isEqualTo(in.branch);
+    assertThat(RefNames.fullName(info.branch)).isEqualTo(RefNames.fullName(in.branch));
     assertThat(info.subject).isEqualTo(in.subject);
     assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1.");
   }
@@ -2746,34 +3146,37 @@
     in.project = project.get();
     in.newBranch = true;
 
-    exception.expect(ResourceConflictException.class);
-    gApi.changes().create(in).get();
+    assertThrows(ResourceConflictException.class, () -> gApi.changes().create(in).get());
   }
 
   @Test
   public void createNewPatchSetWithoutPermission() throws Exception {
     // Create new project with clean permissions
-    Project.NameKey p = createProject("addPatchSet1");
+    Project.NameKey p = projectOperations.newProject().create();
 
     // Clone separate repositories of the same project as admin and as user
     TestRepository<InMemoryRepository> adminTestRepo = cloneProject(p, admin);
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Create change as admin
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
     // Fetch change
-    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().refName() + ":ps");
     userTestRepo.reset("ps");
 
     // Amend change as user
     PushOneCommit.Result r2 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
-    r2.assertErrorStatus("cannot add patch set to " + r1.getChange().getId().id + ".");
+    r2.assertErrorStatus("cannot add patch set to " + r1.getChange().getId().get() + ".");
   }
 
   @Test
@@ -2783,12 +3186,12 @@
     TestRepository<?> userTestRepo = cloneProject(project, user);
 
     // Create change as admin
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
     // Fetch change
-    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().refName() + ":ps");
     userTestRepo.reset("ps");
 
     // Amend change as user
@@ -2799,20 +3202,24 @@
   @Test
   public void createNewPatchSetAsOwnerWithoutPermission() throws Exception {
     // Create new project with clean permissions
-    Project.NameKey p = createProject("addPatchSet2");
+    Project.NameKey p = projectOperations.newProject().create();
     // Clone separate repositories of the same project as admin and as user
     TestRepository<?> adminTestRepo = cloneProject(project, admin);
 
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Create change as admin
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
     // Fetch change
-    GitUtil.fetch(adminTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(adminTestRepo, r1.getPatchSet().refName() + ":ps");
     adminTestRepo.reset("ps");
 
     // Amend change as admin
@@ -2839,7 +3246,7 @@
     createBranch("dev");
     PushOneCommit.Result changeA =
         pushFactory
-            .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
             .to("refs/heads/dev");
     changeA.assertOkStatus();
     MergeInput mergeInput = new MergeInput();
@@ -2875,7 +3282,7 @@
     createBranch("dev");
     PushOneCommit.Result changeA =
         pushFactory
-            .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
             .to("refs/heads/dev");
     changeA.assertOkStatus();
     MergeInput mergeInput = new MergeInput();
@@ -2898,7 +3305,7 @@
 
   @Test
   public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch("foo");
     createBranch("bar");
 
@@ -2911,18 +3318,23 @@
     gApi.changes().id(baseChange).setPrivate(true, "set private");
 
     // Create the destination change on 'master' branch.
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     testRepo.reset(initialHead);
     String changeId = createChange().getChangeId();
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Read not permitted for " + baseChange);
-    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
+    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
   }
 
   @Test
   public void createMergePatchSetBaseOnChange() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch("foo");
     createBranch("bar");
 
@@ -2970,15 +3382,18 @@
 
     // add new label and assert that it's returned for existing changes
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    LabelType verified = Util.verified();
+    LabelType verified = TestLabels.verified();
     String heads = RefNames.REFS_HEADS + "*";
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      Util.allow(
-          u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
@@ -2996,9 +3411,16 @@
       // remove label and assert that it's no longer returned for existing
       // changes, even if there is an approval for it
       u.getConfig().getLabelSections().remove(verified.getName());
-      Util.remove(u.getConfig(), Permission.forLabel(verified.getName()), registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(
+            permissionKey(Permission.forLabel(verified.getName()))
+                .ref(heads)
+                .group(registeredUsers))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
@@ -3025,17 +3447,20 @@
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
     assertPermitted(change, "Code-Review", 2);
 
-    LabelType verified = Util.verified();
+    LabelType verified = TestLabels.verified();
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
 
     // add new label and assert that it's returned for existing changes
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      Util.allow(
-          u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
@@ -3049,8 +3474,7 @@
     testRepo.reset("config");
     PushOneCommit push2 =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             "Ignore Verified",
             "rules.pl",
@@ -3078,9 +3502,13 @@
     // changes, even if there is an approval for it
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().remove(verified.getName());
-      Util.remove(u.getConfig(), Permission.forLabel(verified.getName()), registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(verified.getName()).ref(heads).group(registeredUsers))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
@@ -3089,15 +3517,50 @@
   }
 
   @Test
+  public void notifyConfigForDirectoryTriggersEmail() throws Exception {
+    // Configure notifications on project level.
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Configure Notifications",
+            "project.config",
+            "[notify \"my=notify-config\"]\n"
+                + "  email = foo@test.com\n"
+                + "  filter = dir:\\\"foo/bar/baz\\\"");
+    push.to(RefNames.REFS_CONFIG);
+    testRepo.reset(oldHead);
+
+    // Push a change that matches the filter.
+    sender.clear();
+    push =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "Test change", "foo/bar/baz/test.txt", "some content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(Address.parse("foo@test.com"));
+
+    // Comment on the change.
+    sender.clear();
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "some message";
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(Address.parse("foo@test.com"));
+  }
+
+  @Test
   public void checkLabelsForMergedChangeWithNonAuthorCodeReview() throws Exception {
     // Configure Non-Author-Code-Review
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
     testRepo.reset("config");
     PushOneCommit push2 =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             "Configure Non-Author-Code-Review",
             "rules.pl",
@@ -3117,27 +3580,25 @@
     push2.to(RefNames.REFS_CONFIG);
     testRepo.reset(oldHead);
 
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
 
     // Allow user to approve
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.codeReview().getName()),
-          -2,
-          2,
-          registeredUsers,
-          heads);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(heads)
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
 
     PushOneCommit.Result r = createChange();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
@@ -3151,7 +3612,7 @@
   public void checkLabelsForAutoClosedChange() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result result = push.to("refs/heads/master");
     result.assertOkStatus();
 
@@ -3170,34 +3631,33 @@
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
 
-    gApi.changes().id(triplet).addReviewer(user.username);
+    gApi.changes().id(triplet).addReviewer(user.username());
 
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     LabelInfo codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval._accountId).isEqualTo(user.id().get());
     assertThat(approval.permittedVotingRange).isNotNull();
     // default values
     assertThat(approval.permittedVotingRange.min).isEqualTo(-1);
     assertThat(approval.permittedVotingRange.max).isEqualTo(1);
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel("Code-Review"),
-          minPermittedValue,
-          maxPermittedValue,
-          REGISTERED_USERS,
-          heads);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Code-Review")
+                .ref(heads)
+                .group(REGISTERED_USERS)
+                .range(minPermittedValue, maxPermittedValue))
+        .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval._accountId).isEqualTo(user.id().get());
     assertThat(approval.permittedVotingRange).isNotNull();
     assertThat(approval.permittedVotingRange.min).isEqualTo(minPermittedValue);
     assertThat(approval.permittedVotingRange.max).isEqualTo(maxPermittedValue);
@@ -3205,21 +3665,22 @@
 
   @Test
   public void maxPermittedValueBlocked() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.blockLabel(u.getConfig(), "Code-Review", REGISTERED_USERS, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
 
-    gApi.changes().id(triplet).addReviewer(user.username);
+    gApi.changes().id(triplet).addReviewer(user.username());
 
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     LabelInfo codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval._accountId).isEqualTo(user.id().get());
     assertThat(approval.permittedVotingRange).isNull();
   }
 
@@ -3231,7 +3692,8 @@
     ReviewInput input = ReviewInput.approve().label("Code-Style", 1);
     gApi.changes().id(changeId).current().review(input);
 
-    Map<String, Short> votes = gApi.changes().id(changeId).current().reviewer(admin.email).votes();
+    Map<String, Short> votes =
+        gApi.changes().id(changeId).current().reviewer(admin.email()).votes();
     assertThat(votes.keySet()).containsExactly("Code-Review");
     assertThat(votes.values()).containsExactly((short) 2);
   }
@@ -3244,13 +3706,9 @@
     ReviewInput input = new ReviewInput().label("Code-Review", 3);
     gApi.changes().id(changeId).current().review(input);
 
-    Map<String, Short> votes = gApi.changes().id(changeId).current().reviewer(admin.email).votes();
-    if (!notesMigration.readChanges()) {
-      assertThat(votes.keySet()).containsExactly("Code-Review");
-      assertThat(votes.values()).containsExactly((short) 0);
-    } else {
-      assertThat(votes).isEmpty();
-    }
+    Map<String, Short> votes =
+        gApi.changes().id(changeId).current().reviewer(admin.email()).votes();
+    assertThat(votes).isEmpty();
   }
 
   @Test
@@ -3259,9 +3717,10 @@
     String changeId = createChange().getChangeId();
     ReviewInput in = new ReviewInput().label("Code-Style", 1);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Code-Style\" is not a configured label");
-    gApi.changes().id(changeId).current().review(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("label \"Code-Style\" is not a configured label");
   }
 
   @Test
@@ -3270,9 +3729,10 @@
     String changeId = createChange().getChangeId();
     ReviewInput in = new ReviewInput().label("Code-Review", 3);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Code-Review\": 3 is not a valid value");
-    gApi.changes().id(changeId).current().review(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("label \"Code-Review\": 3 is not a valid value");
   }
 
   @Test
@@ -3288,22 +3748,26 @@
             + "U > 0,"
             + "R = label('All-Comments-Resolved', need(_)). \n\n");
 
-    String oldHead = getRemoteHead().name();
+    String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result1 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
     PushOneCommit.Result result2 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
 
     addComment(result1, "comment 1", true, false, null);
     addComment(result2, "comment 2", true, true, null);
 
     gApi.changes().id(result1.getChangeId()).current().submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs All-Comments-Resolved");
-    gApi.changes().id(result2.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(result2.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to submit 1 change due to the following problems");
+    assertThat(thrown).hasMessageThat().contains("needs All-Comments-Resolved");
   }
 
   @Test
@@ -3311,20 +3775,22 @@
     addPureRevertSubmitRule();
 
     // Create a change that is not a revert of another change
-    PushOneCommit.Result r1 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     approve(r1.getChangeId());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs Is-Pure-Revert");
-    gApi.changes().id(r1.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r1.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to submit 1 change due to the following problems");
+    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
   }
 
   @Test
   public void pureRevertFactBlocksSubmissionOfNonPureReverts() throws Exception {
-    PushOneCommit.Result r1 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     merge(r1);
 
     addPureRevertSubmitRule();
@@ -3334,17 +3800,19 @@
     amendChange(revertId);
     approve(revertId);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs Is-Pure-Revert");
-    gApi.changes().id(revertId).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(revertId).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to submit 1 change due to the following problems");
+    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
   }
 
   @Test
   public void pureRevertFactAllowsSubmissionOfPureReverts() throws Exception {
     // Create a change that we can later revert
-    PushOneCommit.Result r1 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     merge(r1);
 
     addPureRevertSubmitRule();
@@ -3365,9 +3833,9 @@
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
 
     for (com.google.gerrit.acceptance.TestAccount acc : ImmutableList.of(admin, user)) {
-      setApiUser(acc);
+      requestScopeOperations.setApiUser(acc.id());
       String newMessage =
-          "modified commit by " + acc.username + "\n\nChange-Id: " + r.getChangeId() + "\n";
+          "modified commit by " + acc.username() + "\n\nChange-Id: " + r.getChangeId() + "\n";
       gApi.changes().id(r.getChangeId()).setMessage(newMessage);
       RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
       assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
@@ -3384,7 +3852,7 @@
 
     // Move the change to WIP and edit the commit message again, to observe a
     // different tag. Must switch to change owner to move into WIP.
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(r.getChangeId()).setWorkInProgress();
     String newMessage = "modified commit in WIP change\n\nChange-Id: " + r.getChangeId() + "\n";
     gApi.changes().id(r.getChangeId()).setMessage(newMessage);
@@ -3412,13 +3880,28 @@
   }
 
   @Test
-  public void changeCommitMessageWithNoChangeIdFails() throws Exception {
+  public void changeCommitMessageWithNoChangeIdRetainsChangeID() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("missing Change-Id footer");
     gApi.changes().id(r.getChangeId()).setMessage("modified commit\n");
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("modified commit\n\nChange-Id: " + r.getChangeId() + "\n");
+  }
+
+  @Test
+  public void changeCommitMessageNullNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .setMessage("test\0commit\n\nChange-Id: " + r.getChangeId() + "\n"));
+    assertThat(thrown).hasMessageThat().contains("NUL character");
   }
 
   @Test
@@ -3427,28 +3910,37 @@
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("wrong Change-Id footer");
-    gApi.changes()
-        .id(r.getChangeId())
-        .setMessage("modified commit\n\nChange-Id: " + otherChange.getChangeId() + "\n");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .setMessage(
+                        "modified commit\n\nChange-Id: " + otherChange.getChangeId() + "\n"));
+    assertThat(thrown).hasMessageThat().contains("wrong Change-Id footer");
   }
 
   @Test
   public void changeCommitMessageWithoutPermissionFails() throws Exception {
     // Create new project with clean permissions
-    Project.NameKey p = createProject("addPatchSetEdit");
+    Project.NameKey p = projectOperations.newProject().create();
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
     // Create change as user
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
+    PushOneCommit push = pushFactory.create(user.newIdent(), userTestRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     // Try to change the commit message
-    exception.expect(AuthException.class);
-    exception.expectMessage("modifying commit message not permitted");
-    gApi.changes().id(r.getChangeId()).setMessage("foo");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).setMessage("foo"));
+    assertThat(thrown).hasMessageThat().contains("modifying commit message not permitted");
   }
 
   @Test
@@ -3456,9 +3948,11 @@
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("new and existing commit message are the same");
-    gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId()));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId())));
+    assertThat(thrown).hasMessageThat().contains("new and existing commit message are the same");
   }
 
   @Test
@@ -3472,7 +3966,7 @@
     String subject = "A happy change " + smile;
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, subject, FILE_NAME, FILE_CONTENT)
+            .create(admin.newIdent(), testRepo, subject, FILE_NAME, FILE_CONTENT)
             .to("refs/for/master");
     r.assertOkStatus();
     String id = r.getChangeId();
@@ -3508,7 +4002,8 @@
     assertThat(
             gApi.changes()
                 .id(revertId)
-                .pureRevert(getRemoteHead().toObjectId().name())
+                .pureRevert(
+                    projectOperations.project(project).getHead("master").toObjectId().name())
                 .isPureRevert)
         .isTrue();
   }
@@ -3531,7 +4026,7 @@
   public void pureRevertParameterTakesPrecedence() throws Exception {
     PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
     merge(r1);
-    String oldHead = getRemoteHead().toObjectId().name();
+    String oldHead = projectOperations.project(project).getHead("master").toObjectId().name();
 
     PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content2");
     merge(r2);
@@ -3546,9 +4041,11 @@
     PushOneCommit.Result r1 = createChange();
     merge(r1);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid object ID");
-    gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id");
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id"));
+    assertThat(thrown).hasMessageThat().contains("invalid object ID");
   }
 
   @Test
@@ -3572,7 +4069,8 @@
     // Create an initial commit to serve as claimed original
     PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
     merge(r1);
-    String claimedOriginal = getRemoteHead().toObjectId().name();
+    String claimedOriginal =
+        projectOperations.project(project).getHead("master").toObjectId().name();
 
     // Change contents of the file to provoke a conflict
     merge(createChange("commit message", "a.txt", "content2"));
@@ -3593,9 +4091,11 @@
 
   @Test
   public void pureRevertThrowsExceptionWhenChangeIsNotARevertAndNoIdProvided() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("no ID was provided and change isn't a revert");
-    gApi.changes().id(createChange().getChangeId()).pureRevert();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(createChange().getChangeId()).pureRevert());
+    assertThat(thrown).hasMessageThat().contains("revertOf not set");
   }
 
   @Test
@@ -3603,9 +4103,9 @@
     String changeId = createChange().getChangeId();
     String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("topic length exceeds the limit");
-    gApi.changes().id(changeId).topic(topic);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).topic(topic));
+    assertThat(thrown).hasMessageThat().contains("topic length exceeds the limit");
   }
 
   @Test
@@ -3622,15 +4122,15 @@
 
   public void submittableAfterLosingPermissions(String label) throws Exception {
     String codeReviewLabel = "Code-Review";
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.forLabel(label), -1, +1, registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(codeReviewLabel), -2, +2, registered, "refs/heads/*");
-      u.save();
-    }
+    AccountGroup.UUID registered = REGISTERED_USERS;
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label).ref("refs/heads/*").group(registered).range(-1, +1))
+        .add(allowLabel(codeReviewLabel).ref("refs/heads/*").group(registered).range(-2, +2))
+        .update();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
 
@@ -3644,34 +4144,32 @@
     input.label(label, 1);
     gApi.changes().id(changeId).current().review(input);
 
-    assertThat(gApi.changes().id(changeId).current().reviewer(user.email).votes().keySet())
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes().keySet())
         .containsExactly(codeReviewLabel, label);
-    assertThat(gApi.changes().id(changeId).current().reviewer(user.email).votes().values())
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes().values())
         .containsExactly((short) 2, (short) 1);
     assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
 
-    setApiUser(admin);
-    // Remove user's permission for 'Label'.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.remove(u.getConfig(), Permission.forLabel(label), registered, "refs/heads/*");
-      // Update user's permitted range for 'Code-Review' to be -1...+1.
-      Util.remove(u.getConfig(), Permission.forLabel(codeReviewLabel), registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(codeReviewLabel), -1, +1, registered, "refs/heads/*");
-      u.save();
-    }
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(label).ref("refs/heads/*").group(registered))
+        .remove(labelPermissionKey(codeReviewLabel).ref("refs/heads/*").group(registered))
+        .add(allowLabel(codeReviewLabel).ref("refs/heads/*").group(registered).range(-1, +1))
+        .update();
 
     // Verify user's new permitted range.
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     change = gApi.changes().id(changeId).get();
     assertPermitted(change, label);
     assertPermitted(change, codeReviewLabel, -1, 0, 1);
 
-    assertThat(gApi.changes().id(changeId).current().reviewer(user.email).votes().values())
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes().values())
         .containsExactly((short) 2, (short) 1);
     assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(changeId).current().submit();
   }
 
@@ -3700,7 +4198,10 @@
   }
 
   private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+    if (r == null) {
+      return ImmutableList.of();
+    }
+    return Iterables.transform(r, a -> Account.id(a._accountId));
   }
 
   private ChangeResource parseResource(PushOneCommit.Result r) throws Exception {
@@ -3711,19 +4212,17 @@
       throws Exception {
     ChangeInfo c = gApi.changes().id(changeId).get(DETAILED_LABELS);
     Set<ReviewerState> states =
-        c.reviewers
-            .entrySet()
-            .stream()
+        c.reviewers.entrySet().stream()
             .filter(e -> e.getValue().stream().anyMatch(a -> a._accountId == accountId.get()))
             .map(Map.Entry::getKey)
             .collect(toSet());
-    assertThat(states.size()).named(states.toString()).isAtMost(1);
+    assertWithMessage(states.toString()).that(states.size()).isAtMost(1);
     return states.stream().findFirst();
   }
 
   private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
     try (BatchUpdate batchUpdate =
-        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.nowTs())) {
       batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
       batchUpdate.execute();
     }
@@ -3768,13 +4267,13 @@
   }
 
   private void modifySubmitRules(String newContent) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> testRepo = new TestRepository<>((InMemoryRepository) repo);
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
       testRepo
           .branch(RefNames.REFS_CONFIG)
           .commit()
-          .author(admin.getIdent())
-          .committer(admin.getIdent())
+          .author(admin.newIdent())
+          .committer(admin.newIdent())
           .add("rules.pl", newContent)
           .message("Modify rules.pl")
           .create();
@@ -3788,8 +4287,7 @@
   public void trackingIds() throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT + "\n\n" + "Bug:JRA001",
             PushOneCommit.FILE_NAME,
@@ -3838,25 +4336,41 @@
     PushOneCommit.Result r = createChange();
 
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     in = new AddReviewerInput();
     in.reviewer = email;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).ignore(true);
     assertThat(gApi.changes().id(r.getChangeId()).ignored()).isTrue();
 
+    // New patch set notification is not sent to users ignoring the change
     sender.clear();
-    setApiUser(admin);
-    gApi.changes().id(r.getChangeId()).abandon();
+    requestScopeOperations.setApiUser(admin.id());
+    amendChange(r.getChangeId());
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(new Address(fullname, email));
+    Address address = new Address(fullname, email);
+    assertThat(messages.get(0).rcpt()).containsExactly(address);
 
-    setApiUser(user);
+    // Review notification is not sent to users ignoring the change
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(messages.get(0).rcpt()).containsExactly(address);
+
+    // Abandoned notification is not sent to users ignoring the change
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).abandon();
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(messages.get(0).rcpt()).containsExactly(address);
+
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).ignore(false);
     assertThat(gApi.changes().id(r.getChangeId()).ignored()).isFalse();
   }
@@ -3865,45 +4379,51 @@
   public void cannotIgnoreOwnChange() throws Exception {
     String changeId = createChange().getChangeId();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot ignore own change");
-    gApi.changes().id(changeId).ignore(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).ignore(true));
+    assertThat(thrown).hasMessageThat().contains("cannot ignore own change");
   }
 
   @Test
   public void cannotIgnoreStarredChange() throws Exception {
     String changeId = createChange().getChangeId();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.accounts().self().starChange(changeId);
     assertThat(gApi.changes().id(changeId).get().starred).isTrue();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.DEFAULT_LABEL
-            + " and "
-            + StarredChangesUtil.IGNORE_LABEL
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.changes().id(changeId).ignore(true);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).ignore(true));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.DEFAULT_LABEL
+                + " and "
+                + StarredChangesUtil.IGNORE_LABEL
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
   public void cannotStarIgnoredChange() throws Exception {
     String changeId = createChange().getChangeId();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).ignore(true);
     assertThat(gApi.changes().id(changeId).ignored()).isTrue();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.DEFAULT_LABEL
-            + " and "
-            + StarredChangesUtil.IGNORE_LABEL
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts().self().starChange(changeId);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.accounts().self().starChange(changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.DEFAULT_LABEL
+                + " and "
+                + StarredChangesUtil.IGNORE_LABEL
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
@@ -3913,81 +4433,94 @@
     PushOneCommit.Result r = createChange();
 
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
     gApi.changes().id(r.getChangeId()).markAsReviewed(true);
     assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isTrue();
 
-    setApiUser(user2);
+    requestScopeOperations.setApiUser(user2.id());
     sender.clear();
     amendChange(r.getChangeId());
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(user.emailAddress);
+    assertThat(messages.get(0).rcpt()).containsExactly(user.getEmailAddress());
   }
 
   @Test
   public void cannotSetUnreviewedLabelForPatchSetThatAlreadyHasReviewedLabel() throws Exception {
     String changeId = createChange().getChangeId();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).markAsReviewed(true);
     assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.REVIEWED_LABEL
-            + "/"
-            + 1
-            + " and "
-            + StarredChangesUtil.UNREVIEWED_LABEL
-            + "/"
-            + 1
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(
-            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1")));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        changeId,
+                        new StarsInput(
+                            ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1"))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.REVIEWED_LABEL
+                + "/"
+                + 1
+                + " and "
+                + StarredChangesUtil.UNREVIEWED_LABEL
+                + "/"
+                + 1
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
   public void cannotSetReviewedLabelForPatchSetThatAlreadyHasUnreviewedLabel() throws Exception {
     String changeId = createChange().getChangeId();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).markAsReviewed(false);
     assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.REVIEWED_LABEL
-            + "/"
-            + 1
-            + " and "
-            + StarredChangesUtil.UNREVIEWED_LABEL
-            + "/"
-            + 1
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(
-            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1")));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        changeId,
+                        new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1"))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.REVIEWED_LABEL
+                + "/"
+                + 1
+                + " and "
+                + StarredChangesUtil.UNREVIEWED_LABEL
+                + "/"
+                + 1
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
   public void setReviewedAndUnreviewedLabelsForDifferentPatchSets() throws Exception {
     String changeId = createChange().getChangeId();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).markAsReviewed(true);
     assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
 
@@ -4009,59 +4542,56 @@
 
     // label cannot contain whitespace
     String invalidLabel = "invalid label";
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid labels: " + invalidLabel);
-    gApi.accounts().self().setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel)));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel))));
+    assertThat(thrown).hasMessageThat().contains("invalid labels: " + invalidLabel);
   }
 
   @Test
   public void changeDetailsDoesNotRequireIndex() throws Exception {
+    // This set of options must be kept in sync with gr-rest-api-interface.js
+    Set<ListChangesOption> options =
+        ImmutableSet.of(
+            ListChangesOption.ALL_COMMITS,
+            ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.CHANGE_ACTIONS,
+            ListChangesOption.CURRENT_ACTIONS,
+            ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.DOWNLOAD_COMMANDS,
+            ListChangesOption.MESSAGES,
+            ListChangesOption.SUBMITTABLE,
+            ListChangesOption.WEB_LINKS,
+            ListChangesOption.SKIP_MERGEABLE,
+            ListChangesOption.SKIP_DIFFSTAT);
+
     PushOneCommit.Result change = createChange();
-    int number = gApi.changes().id(change.getChangeId()).get()._number;
+    int number = gApi.changes().id(change.getChangeId()).get(options)._number;
 
-    try (AutoCloseable ctx = disableChangeIndex()) {
-      assertThat(gApi.changes().id(project.get(), number).get(ImmutableSet.of()).changeId)
+    try (AutoCloseable ignored = disableChangeIndex()) {
+      assertThat(gApi.changes().id(project.get(), number).get().changeId)
           .isEqualTo(change.getChangeId());
     }
   }
 
-  private static class ChangeIndexedCounter implements ChangeIndexedListener {
-    private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
-
-    @Override
-    public void onChangeIndexed(String projectName, int id) {
-      countsByChange.incrementAndGet(id);
-    }
-
-    @Override
-    public void onChangeDeleted(int id) {
-      countsByChange.incrementAndGet(id);
-    }
-
-    void clear() {
-      countsByChange.clear();
-    }
-
-    long getCount(ChangeInfo info) {
-      return countsByChange.get(info._number);
-    }
-
-    void assertReindexOf(ChangeInfo info) {
-      assertReindexOf(info, 1);
-    }
-
-    void assertReindexOf(ChangeInfo info, int expectedCount) {
-      assertThat(getCount(info)).isEqualTo(expectedCount);
-      assertThat(countsByChange).hasSize(1);
-      clear();
-    }
-  }
-
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
 
   private BranchApi createBranch(String branch) throws Exception {
-    return createBranch(new Branch.NameKey(project, branch));
+    return createBranch(BranchNameKey.create(project, branch));
+  }
+
+  private ThrowableSubject assertThatQueryException(String query) throws Exception {
+    try {
+      query(query);
+    } catch (BadRequestException e) {
+      return assertThat(e);
+    }
+    throw new AssertionError("expected BadRequestException");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
index fe7da66..789a7c7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -15,22 +15,26 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class ChangeIdIT extends AbstractDaemonTest {
   private ChangeInfo changeInfo;
+  @Inject private ProjectOperations projectOperations;
 
   @Before
   public void setup() throws Exception {
@@ -45,7 +49,7 @@
 
   @Test
   public void projectChangeNumberReturnsChangeWhenProjectContainsSlashes() throws Exception {
-    Project.NameKey p = createProject("foo/bar");
+    Project.NameKey p = projectOperations.newProject().create();
     ChangeInfo ci = gApi.changes().create(new ChangeInput(p.get(), "master", "msg")).get();
     ChangeApi cApi = gApi.changes().id(p.get(), ci._number);
     assertThat(cApi.get().changeId).isEqualTo(ci.changeId);
@@ -53,16 +57,22 @@
 
   @Test
   public void wrongProjectInProjectChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: unknown~" + changeInfo._number);
-    gApi.changes().id("unknown", changeInfo._number);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id("unknown", changeInfo._number));
+    assertThat(thrown).hasMessageThat().contains("Not found: unknown~" + changeInfo._number);
   }
 
   @Test
   public void wrongIdInProjectChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + project.get() + "~" + Integer.MAX_VALUE);
-    gApi.changes().id(project.get(), Integer.MAX_VALUE);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(project.get(), Integer.MAX_VALUE));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: " + project.get() + "~" + Integer.MAX_VALUE);
   }
 
   @Test
@@ -73,8 +83,7 @@
 
   @Test
   public void wrongChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(Integer.MAX_VALUE);
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(Integer.MAX_VALUE));
   }
 
   @Test
@@ -85,25 +94,36 @@
 
   @Test
   public void wrongProjectInTripletChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: unknown~" + changeInfo.branch + "~" + changeInfo.changeId);
-    gApi.changes().id("unknown", changeInfo.branch, changeInfo.changeId);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id("unknown", changeInfo.branch, changeInfo.changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: unknown~" + changeInfo.branch + "~" + changeInfo.changeId);
   }
 
   @Test
   public void wrongBranchInTripletChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + project.get() + "~unknown~" + changeInfo.changeId);
-    gApi.changes().id(project.get(), "unknown", changeInfo.changeId);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(project.get(), "unknown", changeInfo.changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: " + project.get() + "~unknown~" + changeInfo.changeId);
   }
 
   @Test
   public void wrongIdInTripletChangeIdReturnsNotFound() throws Exception {
     String unknownId = "I1234567890";
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(
-        "Not found: " + project.get() + "~" + changeInfo.branch + "~" + unknownId);
-    gApi.changes().id(project.get(), changeInfo.branch, unknownId);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(project.get(), changeInfo.branch, unknownId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: " + project.get() + "~" + changeInfo.branch + "~" + unknownId);
   }
 
   @Test
@@ -118,8 +138,7 @@
 
   @Test
   public void wrongChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id("I1234567890");
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id("I1234567890"));
   }
 
   @Test
@@ -136,11 +155,13 @@
     // IHash throws
     ChangeInfo ci =
         gApi.changes().create(new ChangeInput(project.get(), "master", "different message")).get();
-    exception.expect(DeprecatedIdentifierException.class);
-    exception.expectMessage(
-        "The provided change identifier "
-            + ci.changeId
-            + " is deprecated. Use 'project~changeNumber' instead.");
-    gApi.changes().id(ci.changeId);
+    DeprecatedIdentifierException thrown =
+        assertThrows(DeprecatedIdentifierException.class, () -> gApi.changes().id(ci.changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The provided change identifier "
+                + ci.changeId
+                + " is deprecated. Use 'project~changeNumber' instead.");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
index ffb8b34..57d7ece 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
@@ -15,25 +15,29 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class DisablePrivateChangesIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   public void createPrivateChangeWithDisablePrivateChangesTrue() throws Exception {
     ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
     input.isPrivate = true;
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("private changes are disabled");
-    gApi.changes().create(input);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().create(input));
+    assertThat(thrown).hasMessageThat().contains("private changes are disabled");
   }
 
   @Test
@@ -54,7 +58,7 @@
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   public void pushPrivatesWithDisablePrivateChangesTrue() throws Exception {
     PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%private");
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%private");
     result.assertErrorStatus();
   }
 
@@ -62,13 +66,13 @@
   @GerritConfig(name = "change.allowDrafts", value = "true")
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   public void pushDraftsWithDisablePrivateChangesTrue() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%draft");
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%draft");
     result.assertErrorStatus();
 
     testRepo.reset(initialHead);
-    result = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    result = pushFactory.create(admin.newIdent(), testRepo).to("refs/drafts/master");
     result.assertErrorStatus();
   }
 
@@ -76,7 +80,7 @@
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   public void pushWithDisablePrivateChangesTrue() throws Exception {
     PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
     result.assertOkStatus();
     assertThat(result.getChange().change().isPrivate()).isFalse();
   }
@@ -85,20 +89,20 @@
   @GerritConfig(name = "change.allowDrafts", value = "true")
   public void pushPrivatesWithDisablePrivateChangesFalse() throws Exception {
     PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%private");
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%private");
     assertThat(result.getChange().change().isPrivate()).isTrue();
   }
 
   @Test
   @GerritConfig(name = "change.allowDrafts", value = "true")
   public void pushDraftsWithDisablePrivateChangesFalse() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%draft");
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%draft");
     assertThat(result.getChange().change().isPrivate()).isTrue();
 
     testRepo.reset(initialHead);
-    result = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    result = pushFactory.create(admin.newIdent(), testRepo).to("refs/drafts/master");
     assertThat(result.getChange().change().isPrivate()).isTrue();
   }
 
@@ -107,9 +111,11 @@
   public void setPrivateWithDisablePrivateChangesTrue() throws Exception {
     PushOneCommit.Result result = createChange();
 
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("private changes are disabled");
-    gApi.changes().id(result.getChangeId()).setPrivate(true, "set private");
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.changes().id(result.getChangeId()).setPrivate(true, "set private"));
+    assertThat(thrown).hasMessageThat().contains("private changes are disabled");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java b/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
index 1acd71c..c5765da 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
@@ -55,8 +57,7 @@
     PushOneCommit.Result gp1 =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 testRepo,
                 "grand parent 1",
                 ImmutableMap.of("foo", "foo-1.1", "bar", "bar-1.1"))
@@ -66,8 +67,7 @@
     PushOneCommit.Result p1 =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 testRepo,
                 "parent 1",
                 ImmutableMap.of("foo", "foo-1.2", "bar", "bar-1.2"))
@@ -80,8 +80,7 @@
     PushOneCommit.Result gp2 =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 testRepo,
                 "grand parent 2",
                 ImmutableMap.of("foo", "foo-2.1", "bar", "bar-2.1"))
@@ -91,8 +90,7 @@
     PushOneCommit.Result p2 =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 testRepo,
                 "parent 2",
                 ImmutableMap.of("foo", "foo-2.2", "bar", "bar-2.2"))
@@ -101,11 +99,7 @@
 
     PushOneCommit m =
         pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "merge",
-            ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
     m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
     PushOneCommit.Result result = m.to("refs/for/master");
     result.assertOkStatus();
@@ -160,18 +154,26 @@
   public void editMergeList() throws Exception {
     gApi.changes().id(changeId).edit().create();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Invalid path: " + MERGE_LIST);
-    gApi.changes().id(changeId).edit().modifyFile(MERGE_LIST, RawInputUtil.create("new content"));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .edit()
+                    .modifyFile(MERGE_LIST, RawInputUtil.create("new content")));
+    assertThat(thrown).hasMessageThat().contains("Invalid path: " + MERGE_LIST);
   }
 
   @Test
   public void deleteMergeList() throws Exception {
     gApi.changes().id(changeId).edit().create();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("no changes were made");
-    gApi.changes().id(changeId).edit().deleteFile(MERGE_LIST);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).edit().deleteFile(MERGE_LIST));
+    assertThat(thrown).hasMessageThat().contains("no changes were made");
   }
 
   private String getMergeListContent(RevCommit... commits) {
@@ -179,7 +181,7 @@
     for (RevCommit c : commits) {
       mergeList
           .append("* ")
-          .append(c.abbreviate(8).name())
+          .append(abbreviateName(c, 8))
           .append(" ")
           .append(c.getShortMessage())
           .append("\n");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
new file mode 100644
index 0000000..d5089ff
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.inject.AbstractModule;
+import org.junit.Test;
+
+@NoHttpd
+public class PluginFieldsIT extends AbstractPluginFieldsTest {
+  // No tests for /detail via the extension API, since the extension API doesn't have that method.
+
+  @Test
+  public void queryChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
+  }
+
+  @Test
+  public void getChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
+  }
+
+  @Test
+  public void queryChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
+  }
+
+  @Test
+  public void getChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
+  }
+
+  @Test
+  public void queryChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
+        (id, opts) ->
+            pluginInfoFromSingletonList(
+                gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
+  }
+
+  @Test
+  public void getChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get()),
+        (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
+  }
+
+  static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(ChangeAttributeFactory.class)
+          .annotatedWith(Exports.named("simple"))
+          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
+    }
+  }
+
+  @Test
+  public void getChangeWithSimpleAttributeWithExplicitExport() throws Exception {
+    // For backwards compatibility with old plugins, allow modules to bind into the
+    // DynamicSet<ChangeAttributeFactory> as if it were a DynamicMap. We only need one variant of
+    // this test to prove that the mapping works.
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()),
+        SimpleAttributeWithExplicitExportModule.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
new file mode 100644
index 0000000..ebed15c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -0,0 +1,288 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.testing.TestCommentHelper;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.sql.Timestamp;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for comment validation in {@link PostReview}. */
+public class PostReviewIT extends AbstractDaemonTest {
+  @Inject private CommentValidator mockCommentValidator;
+  @Inject private TestCommentHelper testCommentHelper;
+
+  private static final String COMMENT_TEXT = "The comment text";
+
+  private Capture<ImmutableList<CommentForValidation>> capture = new Capture<>();
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        CommentValidator mockCommentValidator = EasyMock.createMock(CommentValidator.class);
+        bind(CommentValidator.class)
+            .annotatedWith(Exports.named(mockCommentValidator.getClass()))
+            .toInstance(mockCommentValidator);
+        bind(CommentValidator.class).toInstance(mockCommentValidator);
+      }
+    };
+  }
+
+  @Before
+  public void resetMock() {
+    EasyMock.reset(mockCommentValidator);
+  }
+
+  @After
+  public void verifyMock() {
+    EasyMock.verify(mockCommentValidator);
+  }
+
+  @Test
+  public void validateCommentsInInput_commentOK() throws Exception {
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of());
+    EasyMock.replay(mockCommentValidator);
+
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = newComment(r.getChange().currentFilePaths().get(0));
+    comment.updated = new Timestamp(0);
+    input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment));
+
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
+  }
+
+  @Test
+  public void validateCommentsInInput_commentRejected() throws Exception {
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentType.FILE_COMMENT, COMMENT_TEXT);
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(CommentType.FILE_COMMENT, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    EasyMock.replay(mockCommentValidator);
+
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = newComment(r.getChange().currentFilePaths().get(0));
+    comment.updated = new Timestamp(0);
+    input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment));
+
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+    BadRequestException badRequestException =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(input));
+    assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
+    assertThat(
+            Iterables.getOnlyElement(
+                    ((CommentsRejectedException) badRequestException.getCause())
+                        .getCommentValidationFailures())
+                .getComment()
+                .getText())
+        .isEqualTo(COMMENT_TEXT);
+    assertThat(badRequestException.getCause()).hasMessageThat().contains("Oh no!");
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void validateDrafts_draftOK() throws Exception {
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.INLINE_COMMENT, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of());
+    EasyMock.replay(mockCommentValidator);
+
+    PushOneCommit.Result r = createChange();
+
+    DraftInput draft =
+        testCommentHelper.newDraft(
+            r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().getName()).createDraft(draft).get();
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+
+    ReviewInput input = new ReviewInput();
+    input.drafts = DraftHandling.PUBLISH;
+
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
+  }
+
+  @Test
+  public void validateDrafts_draftRejected() throws Exception {
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentType.INLINE_COMMENT, COMMENT_TEXT);
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.INLINE_COMMENT, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    EasyMock.replay(mockCommentValidator);
+    PushOneCommit.Result r = createChange();
+
+    DraftInput draft =
+        testCommentHelper.newDraft(
+            r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
+    testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draft);
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+
+    ReviewInput input = new ReviewInput();
+    input.drafts = DraftHandling.PUBLISH;
+    BadRequestException badRequestException =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(input));
+    assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
+    assertThat(
+            Iterables.getOnlyElement(
+                    ((CommentsRejectedException) badRequestException.getCause())
+                        .getCommentValidationFailures())
+                .getComment()
+                .getText())
+        .isEqualTo(draft.message);
+    assertThat(badRequestException.getCause()).hasMessageThat().contains("Oh no!");
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void validateDrafts_inlineVsFileComments_allOK() throws Exception {
+    PushOneCommit.Result r = createChange();
+    DraftInput draftInline =
+        testCommentHelper.newDraft(
+            r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
+    testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draftInline);
+    DraftInput draftFile = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draftFile);
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
+
+    EasyMock.expect(mockCommentValidator.validateComments(EasyMock.capture(capture)))
+        .andReturn(ImmutableList.of());
+    EasyMock.replay(mockCommentValidator);
+
+    ReviewInput input = new ReviewInput();
+    input.drafts = DraftHandling.PUBLISH;
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(2);
+
+    assertThat(capture.getValues()).hasSize(1);
+    assertThat(capture.getValue())
+        .containsExactly(
+            CommentForValidation.create(
+                CommentForValidation.CommentType.INLINE_COMMENT, draftInline.message),
+            CommentForValidation.create(
+                CommentForValidation.CommentType.FILE_COMMENT, draftFile.message));
+  }
+
+  @Test
+  public void validateCommentsInChangeMessage_messageOK() throws Exception {
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(CommentType.CHANGE_MESSAGE, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of());
+    EasyMock.replay(mockCommentValidator);
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
+    int numMessages = gApi.changes().id(r.getChangeId()).get().messages.size();
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(gApi.changes().id(r.getChangeId()).get().messages).hasSize(numMessages + 1);
+    ChangeMessageInfo message =
+        Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(message.message).contains(COMMENT_TEXT);
+  }
+
+  @Test
+  public void validateCommentsInChangeMessage_messageRejected() throws Exception {
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentType.CHANGE_MESSAGE, COMMENT_TEXT);
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(CommentType.CHANGE_MESSAGE, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    EasyMock.replay(mockCommentValidator);
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
+    assertThat(gApi.changes().id(r.getChangeId()).get().messages)
+        .hasSize(1); // From the initial commit.
+    BadRequestException badRequestException =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(input));
+    assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
+    assertThat(
+            Iterables.getOnlyElement(
+                    ((CommentsRejectedException) badRequestException.getCause())
+                        .getCommentValidationFailures())
+                .getComment()
+                .getText())
+        .isEqualTo(COMMENT_TEXT);
+    assertThat(badRequestException.getCause()).hasMessageThat().contains("Oh no!");
+    assertThat(gApi.changes().id(r.getChangeId()).get().messages)
+        .hasSize(1); // Unchanged from before.
+    ChangeMessageInfo message =
+        Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(message.message).doesNotContain(COMMENT_TEXT);
+  }
+
+  private static CommentInput newComment(String path) {
+    return TestCommentHelper.populate(new CommentInput(), path, PostReviewIT.COMMENT_TEXT);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
new file mode 100644
index 0000000..59237cb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -0,0 +1,272 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class PrivateChangeIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void setPrivateByOwner() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(user.newIdent(), userRepo).to("refs/for/master");
+
+    requestScopeOperations.setApiUser(user.id());
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    gApi.changes().id(changeId).setPrivate(true, null);
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
+
+    gApi.changes().id(changeId).setPrivate(false, null);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+
+    String msg = "This is a security fix that must not be public.";
+    gApi.changes().id(changeId).setPrivate(true, msg);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private\n\n" + msg);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
+
+    msg = "After this security fix has been released we can make it public now.";
+    gApi.changes().id(changeId).setPrivate(false, msg);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private\n\n" + msg);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+  }
+
+  @Test
+  public void cannotSetMergedChangePrivate() throws Exception {
+    PushOneCommit.Result result = createChange();
+    approve(result.getChangeId());
+    merge(result);
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).setPrivate(true));
+    assertThat(thrown).hasMessageThat().contains("cannot set merged change to private");
+  }
+
+  @Test
+  public void cannotSetAbandonedChangePrivate() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+
+    gApi.changes().id(changeId).abandon();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).setPrivate(true));
+    assertThat(thrown).hasMessageThat().contains("cannot set abandoned change to private");
+  }
+
+  @Test
+  public void administratorCanSetUserChangePrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(user.newIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    gApi.changes().id(changeId).setPrivate(true, null);
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+  }
+
+  @Test
+  public void cannotSetOtherUsersChangePrivate() throws Exception {
+    PushOneCommit.Result result = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(result.getChangeId()).setPrivate(true, null));
+    assertThat(thrown).hasMessageThat().contains("not allowed to mark private");
+  }
+
+  @Test
+  public void accessPrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(user.newIdent(), userRepo).to("refs/for/master");
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+    // Owner can always access its private changes.
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Add admin as a reviewer.
+    gApi.changes().id(result.getChangeId()).addReviewer(admin.id().toString());
+
+    // This change should be visible for admin as a reviewer.
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Remove admin from reviewers.
+    gApi.changes().id(result.getChangeId()).reviewer(admin.id().toString()).remove();
+
+    // This change should not be visible for admin anymore.
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()));
+    assertThat(thrown).hasMessageThat().contains("Not found: " + result.getChangeId());
+  }
+
+  @Test
+  public void privateChangeOfOtherUserCanBeAccessedWithPermission() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+  }
+
+  @Test
+  public void administratorCanUnmarkPrivateAfterMerging() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    merge(result);
+    markMergedChangePrivate(Change.id(gApi.changes().id(changeId).get()._number));
+
+    gApi.changes().id(changeId).setPrivate(false, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void ownerCannotMarkPrivateAfterMerging() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(user.newIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    merge(result);
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setPrivate(true, null));
+    assertThat(thrown).hasMessageThat().contains("not allowed to mark private");
+  }
+
+  @Test
+  public void mergingPrivateChangePublishesIt() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).setPrivate(true);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    approve(result.getChangeId());
+    merge(result);
+
+    assertThat(gApi.changes().id(result.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void ownerCanUnmarkPrivateAfterMerging() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(user.newIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).addReviewer(admin.id().toString());
+    merge(result);
+    markMergedChangePrivate(Change.id(gApi.changes().id(changeId).get()._number));
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).setPrivate(false, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void mergingPrivateChangeThroughGitPublishesIt() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).setPrivate(true);
+
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/heads/master");
+    result.assertOkStatus();
+
+    assertThat(gApi.changes().id(r.getChangeId()).get().isPrivate).isNull();
+  }
+
+  private void markMergedChangePrivate(Change.Id changeId) throws Exception {
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            project, identifiedUserFactory.create(admin.id()), TimeUtil.nowTs())) {
+      u.addOp(
+              changeId,
+              new BatchUpdateOp() {
+                @Override
+                public boolean updateChange(ChangeContext ctx) {
+                  ctx.getChange().setPrivate(true);
+                  ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+                  ctx.getChange().setPrivate(true);
+                  ctx.getChange().setLastUpdatedOn(ctx.getWhen());
+                  update.setPrivate(true);
+                  return true;
+                }
+              })
+          .execute();
+    }
+    assertThat(gApi.changes().id(changeId.get()).get().isPrivate).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index b23b2bf..20be0a7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
 import static com.google.gerrit.extensions.client.ChangeKind.NO_CHANGE;
 import static com.google.gerrit.extensions.client.ChangeKind.NO_CODE_CHANGE;
@@ -24,8 +26,8 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
@@ -34,8 +36,9 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
@@ -43,9 +46,9 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Set;
@@ -58,6 +61,8 @@
 
 @NoHttpd
 public class StickyApprovalsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
   public void setup() throws Exception {
@@ -65,7 +70,7 @@
       // Overwrite "Code-Review" label that is inherited from All-Projects.
       // This way changes to the "Code Review" label don't affect other tests.
       LabelType codeReview =
-          category(
+          label(
               "Code-Review",
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
@@ -76,28 +81,26 @@
       u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
 
       LabelType verified =
-          category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+          label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       verified.setCopyAllScoresIfNoChange(false);
       u.getConfig().getLabelSections().put(verified.getName(), verified);
 
-      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      String heads = RefNames.REFS_HEADS + "*";
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.codeReview().getName()),
-          -2,
-          2,
-          registeredUsers,
-          heads);
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.verified().getName()),
-          -1,
-          1,
-          registeredUsers,
-          heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
   }
 
   @Test
@@ -115,7 +118,7 @@
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, -1, 1);
@@ -137,7 +140,7 @@
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, 2, 1);
@@ -174,7 +177,7 @@
     assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
 
     // check that votes are sticky when trivial rebase is done by cherry-pick
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     changeId = createChange().getChangeId();
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -185,7 +188,7 @@
     assertVotes(c, user, -2, 0);
 
     // check that votes are not sticky when rework is done by cherry-pick
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     changeId = createChange().getChangeId();
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -257,7 +260,7 @@
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, 2, 1);
@@ -365,7 +368,7 @@
 
   private void assertNotSticky(Set<ChangeKind> changeKinds) throws Exception {
     for (ChangeKind changeKind : changeKinds) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, +2, 1);
@@ -410,7 +413,7 @@
         noChange(changeId);
         return;
       default:
-        fail("unexpected change kind: " + changeKind);
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
     }
   }
 
@@ -419,8 +422,8 @@
         testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
     commitBuilder
         .message("New subject " + System.nanoTime())
-        .author(admin.getIdent())
-        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+        .author(admin.newIdent())
+        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
     commitBuilder.create();
     GitUtil.pushHead(testRepo, "refs/for/master", false);
     assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
@@ -434,8 +437,8 @@
         testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
     commitBuilder
         .message(commitMessage)
-        .author(admin.getIdent())
-        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+        .author(admin.newIdent())
+        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
     commitBuilder.create();
     GitUtil.pushHead(testRepo, "refs/for/master", false);
     assertThat(getChangeKind(changeId)).isEqualTo(NO_CHANGE);
@@ -444,8 +447,7 @@
   private void rework(String changeId) throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             PushOneCommit.FILE_NAME,
@@ -456,12 +458,11 @@
   }
 
   private void trivialRebase(String changeId) throws Exception {
-    setApiUser(admin);
-    testRepo.reset(getRemoteHead());
+    requestScopeOperations.setApiUser(admin.id());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             "Other Change",
             "a" + System.nanoTime() + ".txt",
@@ -487,7 +488,7 @@
 
     testRepo.reset(parent1.getCommit());
 
-    PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo);
     merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
     PushOneCommit.Result result = merge.to("refs/for/master");
     result.assertOkStatus();
@@ -504,7 +505,7 @@
     testRepo.reset(parent1);
     PushOneCommit.Result newParent1 = createChange("new parent 1", "p1-1.txt", "content 1-1");
 
-    PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo, changeId);
+    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo, changeId);
     merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
     PushOneCommit.Result result = merge.to("refs/for/master");
     result.assertOkStatus();
@@ -521,15 +522,14 @@
       case NO_CHANGE:
       case MERGE_FIRST_PARENT_UPDATE:
       default:
-        fail("unexpected change kind: " + changeKind);
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
     }
 
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 testRepo,
                 PushOneCommit.SUBJECT,
                 "other.txt",
@@ -556,21 +556,21 @@
   }
 
   private void vote(TestAccount user, String changeId, String label, int vote) throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).current().review(new ReviewInput().label(label, vote));
   }
 
   private void vote(TestAccount user, String changeId, int codeReviewVote, int verifiedVote)
       throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     ReviewInput in =
         new ReviewInput().label("Code-Review", codeReviewVote).label("Verified", verifiedVote);
     gApi.changes().id(changeId).current().review(in);
   }
 
   private void deleteVote(TestAccount user, String changeId, String label) throws Exception {
-    setApiUser(user);
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).deleteVote(label);
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).reviewer(user.id().toString()).deleteVote(label);
   }
 
   private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote) {
@@ -588,7 +588,7 @@
     Integer vote = 0;
     if (c.labels.get(label) != null && c.labels.get(label).all != null) {
       for (ApprovalInfo approval : c.labels.get(label).all) {
-        if (approval._accountId == user.id.get()) {
+        if (approval._accountId == user.id().get()) {
           vote = approval.value;
           break;
         }
@@ -599,6 +599,6 @@
     if (changeKind != null) {
       name += "; changeKind = " + changeKind.name();
     }
-    assertThat(vote).named(name).isEqualTo(expectedVote);
+    assertWithMessage(name).that(vote).isEqualTo(expectedVote);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index f73a59c..62600b2 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
 import static com.google.gerrit.extensions.client.SubmitType.REBASE_ALWAYS;
 import static com.google.gerrit.extensions.client.SubmitType.REBASE_IF_NECESSARY;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -29,6 +30,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -136,8 +138,7 @@
   private PushOneCommit.Result createChange(String dest, String subject) throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             subject,
             "file" + fileCounter.incrementAndGet(),
@@ -237,22 +238,51 @@
     gApi.changes().id(r1.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.approve());
 
-    try {
-      gApi.changes().id(r2.getChangeId()).current().submit();
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              "Failed to submit 2 changes due to the following problems:\n"
-                  + "Change "
-                  + r1.getChange().getId()
-                  + ": Change has submit type "
-                  + "CHERRY_PICK, but previously chose submit type MERGE_IF_NECESSARY "
-                  + "from change "
-                  + r2.getChange().getId()
-                  + " in the same batch");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r2.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Failed to submit 2 changes due to the following problems:\n"
+                + "Change "
+                + r1.getChange().getId()
+                + ": Change has submit type "
+                + "CHERRY_PICK, but previously chose submit type MERGE_IF_NECESSARY "
+                + "from change "
+                + r2.getChange().getId()
+                + " in the same batch");
+  }
+
+  @Test
+  public void invalidSubmitRuleWithNoRulesInProject() throws Exception {
+    String changeId = createChange("master", "change 1").getChangeId();
+
+    TestSubmitRuleInput in = new TestSubmitRuleInput();
+    in.rule = "invalid prolog rule";
+    // We have no rules.pl by default. The fact that the default rules are showing up here is a bug.
+    List<TestSubmitRuleInfo> response = gApi.changes().id(changeId).current().testSubmitRule(in);
+    assertThat(response).containsExactly(invalidPrologRuleInfo());
+  }
+
+  @Test
+  public void invalidSubmitRuleWithRulesInProject() throws Exception {
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    String changeId = createChange("master", "change 1").getChangeId();
+
+    TestSubmitRuleInput in = new TestSubmitRuleInput();
+    in.rule = "invalid prolog rule";
+    List<TestSubmitRuleInfo> response = gApi.changes().id(changeId).current().testSubmitRule(in);
+    assertThat(response).containsExactly(invalidPrologRuleInfo());
+  }
+
+  private static TestSubmitRuleInfo invalidPrologRuleInfo() {
+    TestSubmitRuleInfo info = new TestSubmitRuleInfo();
+    info.status = "RULE_ERROR";
+    info.errorMessage = "operator expected after expression at: invalid prolog rule end_of_file.";
+    return info;
   }
 
   private List<RevCommit> log(String commitish, int n) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
index fd08838..70aa557 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.config;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -37,7 +37,7 @@
     DiffPreferencesInfo update = new DiffPreferencesInfo();
     update.lineLength = newLineLength;
     DiffPreferencesInfo result = gApi.config().server().setDefaultDiffPreferences(update);
-    assertThat(result.lineLength).named("lineLength").isEqualTo(newLineLength);
+    assertWithMessage("lineLength").that(result.lineLength).isEqualTo(newLineLength);
 
     result = gApi.config().server().getDefaultDiffPreferences();
     DiffPreferencesInfo expected = DiffPreferencesInfo.defaults();
diff --git a/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
index e89aa3d..02f1ec3 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.config;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -37,7 +37,7 @@
     EditPreferencesInfo update = new EditPreferencesInfo();
     update.lineLength = newLineLength;
     EditPreferencesInfo result = gApi.config().server().setDefaultEditPreferences(update);
-    assertThat(result.lineLength).named("lineLength").isEqualTo(newLineLength);
+    assertWithMessage("lineLength").that(result.lineLength).isEqualTo(newLineLength);
 
     result = gApi.config().server().getDefaultEditPreferences();
     EditPreferencesInfo expected = EditPreferencesInfo.defaults();
diff --git a/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
index c606982..221e171 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.config;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -36,7 +36,7 @@
     GeneralPreferencesInfo update = new GeneralPreferencesInfo();
     update.signedOffBy = newSignedOffBy;
     GeneralPreferencesInfo result = gApi.config().server().setDefaultPreferences(update);
-    assertThat(result.signedOffBy).named("signedOffBy").isEqualTo(newSignedOffBy);
+    assertWithMessage("signedOffBy").that(result.signedOffBy).isEqualTo(newSignedOffBy);
 
     result = gApi.config().server().getDefaultPreferences();
     GeneralPreferencesInfo expected = GeneralPreferencesInfo.defaults();
diff --git a/javatests/com/google/gerrit/acceptance/api/config/TopMenusIT.java b/javatests/com/google/gerrit/acceptance/api/config/TopMenusIT.java
new file mode 100644
index 0000000..b6d2712
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/config/TopMenusIT.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.TopMenu;
+import com.google.gerrit.extensions.webui.TopMenu.MenuEntry;
+import com.google.inject.AbstractModule;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Test;
+
+@TestPlugin(
+    name = "test-topmenus",
+    sysModule = "com.google.gerrit.acceptance.api.config.TopMenusIT$Module")
+public class TopMenusIT extends LightweightPluginDaemonTest {
+
+  static final TopMenu.MenuEntry TEST_MENU_ENTRY =
+      new TopMenu.MenuEntry("MyMenu", Collections.emptyList());
+
+  public static class Module extends AbstractModule {
+
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), TopMenu.class).to(TopMenuTest.class);
+    }
+  }
+
+  public static class TopMenuTest implements TopMenu {
+
+    @Override
+    public List<MenuEntry> getEntries() {
+      return Arrays.asList(TEST_MENU_ENTRY);
+    }
+  }
+
+  @Test
+  public void topMenuShouldReturnOneEntry() throws RestApiException {
+    List<MenuEntry> topMenuItems = gApi.config().server().topMenus();
+    assertThat(topMenuItems).containsExactly(TEST_MENU_ENTRY);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
index a0b70cc..a12342a 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -8,6 +8,7 @@
         ":util",
         "//java/com/google/gerrit/server/group/db/testing",
         "//java/com/google/gerrit/server/group/testing",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/truth",
         "//javatests/com/google/gerrit/acceptance/rest/account:util",
     ],
@@ -20,7 +21,6 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//lib:gwtorm",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index a664869..20f7e33 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.acceptance.api.group;
 
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
 import static com.google.gerrit.truth.ListSubject.assertThat;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -35,7 +36,6 @@
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -57,7 +57,7 @@
   @Test
   public void indexingUpdatesTheIndex() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("users");
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
         newGroupUpdate()
@@ -74,7 +74,7 @@
   public void indexCannotBeCorruptedByStaleCache() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("verifiers");
     loadGroupToCache(groupUuid);
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
         newGroupUpdate()
@@ -102,7 +102,7 @@
   @Test
   public void reindexingStaleGroupUpdatesTheIndex() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("users");
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
         newGroupUpdate()
@@ -139,7 +139,7 @@
 
   private AccountGroup.UUID createGroup(String name) throws RestApiException {
     GroupInfo group = gApi.groups().create(name).get();
-    return new AccountGroup.UUID(group.id);
+    return AccountGroup.uuid(group.id);
   }
 
   private void reloadGroupToCache(AccountGroup.UUID groupUuid) {
@@ -157,17 +157,17 @@
 
   private void updateGroupWithoutCacheOrIndex(
       AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
-      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+      throws NoSuchGroupException, IOException, ConfigInvalidException {
     groupsUpdate.updateGroupInNoteDb(groupUuid, groupUpdate);
   }
 
   private static OptionalSubject<InternalGroupSubject, InternalGroup> assertThatGroup(
       Optional<InternalGroup> updatedGroup) {
-    return assertThat(updatedGroup, InternalGroupSubject::assertThat);
+    return assertThat(updatedGroup, internalGroups());
   }
 
   private static ListSubject<InternalGroupSubject, InternalGroup> assertThatGroups(
       List<InternalGroup> parentGroups) {
-    return assertThat(parentGroups, InternalGroupSubject::assertThat);
+    return assertThat(parentGroups, internalGroups());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
index 87a566e..966c056 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
@@ -15,11 +15,15 @@
 package com.google.gerrit.acceptance.api.group;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -30,6 +34,7 @@
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.group.db.testing.GroupTestUtil;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.RefRename;
@@ -47,6 +52,9 @@
 @Sandboxed
 @NoHttpd
 public class GroupsConsistencyIT extends AbstractDaemonTest {
+
+  @Inject protected GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   private GroupInfo gAdmin;
   private GroupInfo g1;
   private GroupInfo g2;
@@ -55,13 +63,16 @@
 
   @Before
   public void basicSetup() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
-    String name1 = createGroup("g1");
-    String name2 = createGroup("g2");
+    String name1 = groupOperations.newGroup().name("g1").create().get();
+    String name2 = groupOperations.newGroup().name("g2").create().get();
 
-    gApi.groups().id(name1).addMembers(user.fullName);
-    gApi.groups().id(name2).addMembers(admin.fullName);
+    gApi.groups().id(name1).addMembers(user.fullName());
+    gApi.groups().id(name2).addMembers(admin.fullName());
     gApi.groups().id(name1).addGroups(name2);
 
     this.g1 = gApi.groups().id(name1).detail();
@@ -90,7 +101,7 @@
   public void missingGroupRef() throws Exception {
 
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      RefUpdate ru = repo.updateRef(RefNames.refsGroups(new AccountGroup.UUID(g1.id)));
+      RefUpdate ru = repo.updateRef(RefNames.refsGroups(AccountGroup.uuid(g1.id)));
       ru.setForceUpdate(true);
       RefUpdate.Result result = ru.delete();
       assertThat(result).isEqualTo(Result.FORCED);
@@ -105,7 +116,7 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefRename ru =
           repo.renameRef(
-              RefNames.refsGroups(new AccountGroup.UUID(g1.id)), RefNames.REFS_GROUPS + BOGUS_UUID);
+              RefNames.refsGroups(AccountGroup.uuid(g1.id)), RefNames.REFS_GROUPS + BOGUS_UUID);
       RefUpdate.Result result = ru.rename();
       assertThat(result).isEqualTo(Result.RENAMED);
     }
@@ -119,8 +130,8 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefRename ru =
           repo.renameRef(
-              RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-              RefNames.refsGroups(new AccountGroup.UUID(BOGUS_UUID)));
+              RefNames.refsGroups(AccountGroup.uuid(g1.id)),
+              RefNames.refsGroups(AccountGroup.uuid(BOGUS_UUID)));
       RefUpdate.Result result = ru.rename();
       assertThat(result).isEqualTo(Result.RENAMED);
     }
@@ -131,7 +142,7 @@
   @Test
   public void groupRefDoesNotParse() throws Exception {
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)),
         GroupConfig.GROUP_CONFIG_FILE,
         "[this is not valid\n");
     assertError("does not parse");
@@ -141,7 +152,7 @@
   public void nameRefDoesNotParse() throws Exception {
     updateGroupFile(
         RefNames.REFS_GROUPNAMES,
-        GroupNameNotes.getNoteKey(new AccountGroup.NameKey(g1.name)).getName(),
+        GroupNameNotes.getNoteKey(AccountGroup.nameKey(g1.name)).getName(),
         "[this is not valid\n");
     assertError("does not parse");
   }
@@ -154,9 +165,7 @@
     cfg.setString("group", null, "ownerGroupUuid", gAdmin.id);
 
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-        GroupConfig.GROUP_CONFIG_FILE,
-        cfg.toText());
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)), GroupConfig.GROUP_CONFIG_FILE, cfg.toText());
     assertError("inconsistent name");
   }
 
@@ -168,9 +177,7 @@
     cfg.setString("group", null, "ownerGroupUuid", gAdmin.id);
 
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-        GroupConfig.GROUP_CONFIG_FILE,
-        cfg.toText());
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)), GroupConfig.GROUP_CONFIG_FILE, cfg.toText());
     assertError("shared group id");
   }
 
@@ -182,9 +189,7 @@
     cfg.setString("group", null, "ownerGroupUuid", BOGUS_UUID);
 
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-        GroupConfig.GROUP_CONFIG_FILE,
-        cfg.toText());
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)), GroupConfig.GROUP_CONFIG_FILE, cfg.toText());
     assertError("nonexistent owner group");
   }
 
@@ -197,28 +202,27 @@
 
     updateGroupFile(
         RefNames.REFS_GROUPNAMES,
-        GroupNameNotes.getNoteKey(new AccountGroup.NameKey(bogusName)).getName(),
+        GroupNameNotes.getNoteKey(AccountGroup.nameKey(bogusName)).getName(),
         config.toText());
     assertError("entry missing as group ref");
   }
 
   @Test
   public void nonexistentMember() throws Exception {
-    updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "members", "314159265\n");
+    updateGroupFile(RefNames.refsGroups(AccountGroup.uuid(g1.id)), "members", "314159265\n");
     assertError("nonexistent member 314159265");
   }
 
   @Test
   public void nonexistentSubgroup() throws Exception {
-    updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", BOGUS_UUID + "\n");
+    updateGroupFile(RefNames.refsGroups(AccountGroup.uuid(g1.id)), "subgroups", BOGUS_UUID + "\n");
     assertError("has nonexistent subgroup");
   }
 
   @Test
   public void cyclicSubgroup() throws Exception {
-    updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", g1.id + "\n");
-    assertWarning("cyclic");
+    updateGroupFile(RefNames.refsGroups(AccountGroup.uuid(g1.id)), "subgroups", g1.id + "\n");
+    assertWarning("cycle");
   }
 
   private void assertError(String msg) throws Exception {
@@ -248,7 +252,8 @@
       }
     }
 
-    fail(String.format("could not find %s substring '%s' in %s", want, msg, problems));
+    assertWithMessage(String.format("could not find %s substring '%s' in %s", want, msg, problems))
+        .fail();
   }
 
   private void updateGroupFile(String refName, String fileName, String content) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 4e3f048..d908ee5f 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -16,17 +16,24 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.api.group.GroupAssert.assertGroupInfo;
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfos;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Correspondence;
 import com.google.common.util.concurrent.AtomicLongMap;
@@ -39,8 +46,10 @@
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
@@ -68,7 +77,6 @@
 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.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.group.InternalGroup;
@@ -81,21 +89,23 @@
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.StalenessChecker;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 import java.sql.Timestamp;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -116,16 +126,19 @@
 
 @NoHttpd
 public class GroupsIT extends AbstractDaemonTest {
-  @Inject private Groups groups;
   @Inject @ServerInitiated private GroupsUpdate groupsUpdate;
+  @Inject private AccountOperations accountOperations;
+  @Inject private DynamicSet<GroupIndexedListener> groupIndexedListeners;
   @Inject private GroupIncludeCache groupIncludeCache;
-  @Inject private StalenessChecker stalenessChecker;
   @Inject private GroupIndexer groupIndexer;
+  @Inject private GroupOperations groupOperations;
+  @Inject private Groups groups;
   @Inject private GroupsConsistencyChecker consistencyChecker;
   @Inject private PeriodicGroupIndexer slaveGroupIndexer;
-  @Inject private DynamicSet<GroupIndexedListener> groupIndexedListeners;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private Sequences seq;
-  @Inject private AccountOperations accountOperations;
+  @Inject private StalenessChecker stalenessChecker;
 
   @Before
   public void setTimeForTesting() {
@@ -160,51 +173,62 @@
 
   @Test
   public void addToNonExistingGroup_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").addMembers("admin");
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.groups().id("non-existing").addMembers("admin"));
   }
 
   @Test
   public void removeFromNonExistingGroup_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").removeMembers("admin");
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.groups().id("non-existing").removeMembers("admin"));
   }
 
   @Test
   public void addRemoveMember() throws Exception {
-    String g = createGroup("users");
-    gApi.groups().id(g).addMembers("user");
-    assertMembers(g, user);
+    AccountGroup.UUID group = groupOperations.newGroup().create();
 
-    gApi.groups().id(g).removeMembers("user");
-    assertNoMembers(g);
+    gApi.groups().id(group.get()).addMembers("user");
+    assertMembers(group.get(), user);
+
+    gApi.groups().id(group.get()).removeMembers("user");
+    ImmutableSet<Account.Id> members = groupOperations.group(group).get().members();
+    assertThat(members).isEmpty();
   }
 
   @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());
-    String groupName = createGroup("users");
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(gApi.groups().id(groupName).get().id);
+    groupIncludeCache.getGroupsWithMember(accountId);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
 
-    gApi.groups().id(groupName).addMembers(username);
+    gApi.groups().id(groupUuid.get()).addMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterAddition =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+        groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groupsWithMemberAfterAddition).contains(groupUuid);
 
-    gApi.groups().id(groupName).removeMembers(username);
+    gApi.groups().id(groupUuid.get()).removeMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterRemoval =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+        groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groupsWithMemberAfterRemoval).doesNotContain(groupUuid);
   }
 
   @Test
+  public void cachedGroupByNameIsUpdatedOnCreation() throws Exception {
+    String newGroupName = name("newGroup");
+    AccountGroup.NameKey nameKey = AccountGroup.nameKey(newGroupName);
+    assertThat(groupCache.get(nameKey)).isEmpty();
+    gApi.groups().create(newGroupName);
+    assertThat(groupCache.get(nameKey)).isPresent();
+  }
+
+  @Test
   public void addExistingMember_OK() throws Exception {
     String g = "Administrators";
     assertMembers(g, admin);
@@ -214,22 +238,23 @@
 
   @Test
   public void addNonExistingMember_UnprocessableEntity() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id("Administrators").addMembers("non-existing");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.groups().id("Administrators").addMembers("non-existing"));
   }
 
   @Test
   public void addMultipleMembers() throws Exception {
-    String g = createGroup("users");
+    AccountGroup.UUID group = groupOperations.newGroup().create();
 
     String u1 = name("u1");
     accountOperations.newAccount().username(u1).create();
     String u2 = name("u2");
     accountOperations.newAccount().username(u2).create();
 
-    gApi.groups().id(g).addMembers(u1, u2);
+    gApi.groups().id(group.get()).addMembers(u1, u2);
 
-    List<AccountInfo> members = gApi.groups().id(g).members();
+    List<AccountInfo> members = gApi.groups().id(group.get()).members();
     assertThat(members)
         .comparingElementsUsing(getAccountToUsernameCorrespondence())
         .containsExactly(u1, u2);
@@ -237,13 +262,13 @@
 
   @Test
   public void membersWithAtSignInUsernameCanBeAdded() throws Exception {
-    String g = createGroup("users");
+    AccountGroup.UUID group = groupOperations.newGroup().create();
     String usernameWithAt = name("u1@something");
     accountOperations.newAccount().username(usernameWithAt).create();
 
-    gApi.groups().id(g).addMembers(usernameWithAt);
+    gApi.groups().id(group.get()).addMembers(usernameWithAt);
 
-    List<AccountInfo> members = gApi.groups().id(g).members();
+    List<AccountInfo> members = gApi.groups().id(group.get()).members();
     assertThat(members)
         .comparingElementsUsing(getAccountToUsernameCorrespondence())
         .containsExactly(usernameWithAt);
@@ -251,7 +276,7 @@
 
   @Test
   public void membersWithAtSignInUsernameAreNotConfusedWithSimilarUsernames() throws Exception {
-    String g = createGroup("users");
+    AccountGroup.UUID group = groupOperations.newGroup().create();
     String usernameWithAt = name("u1@something");
     accountOperations.newAccount().username(usernameWithAt).create();
     String usernameWithoutAt = name("u1something");
@@ -262,10 +287,10 @@
     accountOperations.newAccount().username(usernameOnlySuffix).create();
 
     gApi.groups()
-        .id(g)
+        .id(group.get())
         .addMembers(usernameWithAt, usernameWithoutAt, usernameOnlyPrefix, usernameOnlySuffix);
 
-    List<AccountInfo> members = gApi.groups().id(g).members();
+    List<AccountInfo> members = gApi.groups().id(group.get()).members();
     assertThat(members)
         .comparingElementsUsing(getAccountToUsernameCorrespondence())
         .containsExactly(usernameWithAt, usernameWithoutAt, usernameOnlyPrefix, usernameOnlySuffix);
@@ -273,52 +298,54 @@
 
   @Test
   public void includeRemoveGroup() throws Exception {
-    String p = createGroup("parent");
-    String g = createGroup("newGroup");
-    gApi.groups().id(p).addGroups(g);
-    assertIncludes(p, g);
+    AccountGroup.UUID parent = groupOperations.newGroup().create();
+    AccountGroup.UUID group = groupOperations.newGroup().create();
+    gApi.groups().id(parent.get()).addGroups(group.get());
+    assertThat(groupOperations.group(parent).get().subgroups()).containsExactly(group);
 
-    gApi.groups().id(p).removeGroups(g);
-    assertNoIncludes(p);
+    gApi.groups().id(parent.get()).removeGroups(group.get());
+    assertThat(groupOperations.group(parent).get().subgroups()).isEmpty();
   }
 
   @Test
   public void includeExternalGroup() throws Exception {
-    String g = createGroup("group");
+    AccountGroup.UUID group = groupOperations.newGroup().create();
     String subgroupUuid = SystemGroupBackend.REGISTERED_USERS.get();
-    gApi.groups().id(g).addGroups(subgroupUuid);
+    gApi.groups().id(group.get()).addGroups(subgroupUuid);
 
-    List<GroupInfo> subgroups = gApi.groups().id(g).includedGroups();
+    List<GroupInfo> subgroups = gApi.groups().id(group.get()).includedGroups();
     assertThat(subgroups).hasSize(1);
     assertThat(subgroups.get(0).id).isEqualTo(subgroupUuid.replace(":", "%3A"));
     assertThat(subgroups.get(0).name).isEqualTo("Registered Users");
     assertThat(subgroups.get(0).groupId).isNull();
 
-    List<? extends GroupAuditEventInfo> auditEvents = gApi.groups().id(g).auditLog();
+    List<? extends GroupAuditEventInfo> auditEvents = gApi.groups().id(group.get()).auditLog();
     assertThat(auditEvents).hasSize(1);
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id, "Registered Users");
+    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id(), "Registered Users");
   }
 
   @Test
   public void includeExistingGroup_OK() throws Exception {
-    String p = createGroup("parent");
-    String g = createGroup("newGroup");
-    gApi.groups().id(p).addGroups(g);
-    assertIncludes(p, g);
-    gApi.groups().id(p).addGroups(g);
-    assertIncludes(p, g);
+    AccountGroup.UUID parent = groupOperations.newGroup().create();
+    AccountGroup.UUID group = groupOperations.newGroup().create();
+    groupOperations.group(parent).forUpdate().addSubgroup(group);
+
+    gApi.groups().id(parent.get()).addGroups(group.get());
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(parent).get().subgroups();
+    assertThat(subgroups).containsExactly(group);
   }
 
   @Test
   public void addMultipleIncludes() throws Exception {
-    String p = createGroup("parent");
-    String g1 = createGroup("newGroup1");
-    String g2 = createGroup("newGroup2");
-    List<String> groups = new ArrayList<>();
-    groups.add(g1);
-    groups.add(g2);
-    gApi.groups().id(p).addGroups(g1, g2);
-    assertIncludes(p, g1, g2);
+    AccountGroup.UUID parent = groupOperations.newGroup().create();
+    AccountGroup.UUID group1 = groupOperations.newGroup().create();
+    AccountGroup.UUID group2 = groupOperations.newGroup().create();
+
+    gApi.groups().id(parent.get()).addGroups(group1.get(), group2.get());
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(parent).get().subgroups();
+    assertThat(subgroups).containsExactly(group1, group2);
   }
 
   @Test
@@ -332,9 +359,9 @@
   public void createDuplicateInternalGroupCaseSensitiveName_Conflict() throws Exception {
     String dupGroupName = name("dupGroup");
     gApi.groups().create(dupGroupName);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group '" + dupGroupName + "' already exists");
-    gApi.groups().create(dupGroupName);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(dupGroupName));
+    assertThat(thrown).hasMessageThat().contains("group '" + dupGroupName + "' already exists");
   }
 
   @Test
@@ -350,33 +377,34 @@
   @Test
   public void createDuplicateSystemGroupCaseSensitiveName_Conflict() throws Exception {
     String newGroupName = "Registered Users";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'Registered Users' already exists");
-    gApi.groups().create(newGroupName);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(newGroupName));
+    assertThat(thrown).hasMessageThat().contains("group 'Registered Users' already exists");
   }
 
   @Test
   public void createDuplicateSystemGroupCaseInsensitiveName_Conflict() throws Exception {
     String newGroupName = "registered users";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'Registered Users' already exists");
-    gApi.groups().create(newGroupName);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(newGroupName));
+    assertThat(thrown).hasMessageThat().contains("group 'Registered Users' already exists");
   }
 
   @Test
   @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
   public void createGroupWithConfiguredNameOfSystemGroup_Conflict() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'All Users' already exists");
-    gApi.groups().create("all users");
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create("all users"));
+    assertThat(thrown).hasMessageThat().contains("group 'All Users' already exists");
   }
 
   @Test
   @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
   public void createGroupWithDefaultNameOfSystemGroup_Conflict() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group name 'Anonymous Users' is reserved");
-    gApi.groups().create("anonymous users");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.groups().create("anonymous users"));
+    assertThat(thrown).hasMessageThat().contains("group name 'Anonymous Users' is reserved");
   }
 
   @Test
@@ -394,9 +422,8 @@
 
   @Test
   public void createGroupWithoutCapability_Forbidden() throws Exception {
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.groups().create(name("newGroup"));
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(AuthException.class, () -> gApi.groups().create(name("newGroup")));
   }
 
   @Test
@@ -411,20 +438,18 @@
 
   @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());
-    assertThat(groups).containsExactly(new AccountGroup.UUID(group.id));
+    Collection<AccountGroup.UUID> groups = groupIncludeCache.getGroupsWithMember(accountId);
+    assertThat(groups).containsExactly(AccountGroup.uuid(group.id));
   }
 
   @Test
@@ -464,8 +489,7 @@
   @Test
   @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
   public void getSystemGroupByDefaultName_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("Anonymous-Users").get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id("Anonymous-Users").get());
   }
 
   @Test
@@ -481,8 +505,7 @@
     String name = name("Users");
     gApi.groups().create(name).get();
 
-    exception.expect(ResourceConflictException.class);
-    gApi.groups().create(name);
+    assertThrows(ResourceConflictException.class, () -> gApi.groups().create(name));
   }
 
   @Test
@@ -511,9 +534,7 @@
 
     String name2 = name("Name2");
     gApi.groups().create(name2);
-
-    exception.expect(ResourceConflictException.class);
-    gApi.groups().id(group1.id).name(name2);
+    assertThrows(ResourceConflictException.class, () -> gApi.groups().id(group1.id).name(name2));
   }
 
   @Test
@@ -537,8 +558,7 @@
     gApi.groups().id(group.id).name(newName);
 
     assertGroupDoesNotExist(name);
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id(name).get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id(name).get());
   }
 
   @Test
@@ -609,158 +629,165 @@
     assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(adminUUID);
 
     // set non existing owner
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id(name).owner("Non-Existing Group");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.groups().id(name).owner("Non-Existing Group"));
   }
 
   @Test
   public void listNonExistingGroupIncludes_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").includedGroups();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.groups().id("non-existing").includedGroups());
   }
 
   @Test
   public void listEmptyGroupIncludes() throws Exception {
-    String gx = createGroup("gx");
-    assertThat(gApi.groups().id(gx).includedGroups()).isEmpty();
+    AccountGroup.UUID gx = groupOperations.newGroup().create();
+    assertThat(gApi.groups().id(gx.get()).includedGroups()).isEmpty();
   }
 
   @Test
   public void includeNonExistingGroup() throws Exception {
-    String gx = createGroup("gx");
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id(gx).addGroups("non-existing");
+    AccountGroup.UUID gx = groupOperations.newGroup().create();
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.groups().id(gx.get()).addGroups("non-existing"));
   }
 
   @Test
   public void listNonEmptyGroupIncludes() throws Exception {
-    String gx = createGroup("gx");
-    String gy = createGroup("gy");
-    String gz = createGroup("gz");
-    gApi.groups().id(gx).addGroups(gy);
-    gApi.groups().id(gx).addGroups(gz);
-    assertIncludes(gApi.groups().id(gx).includedGroups(), gy, gz);
+    AccountGroup.UUID gz = groupOperations.newGroup().create();
+    AccountGroup.UUID gy = groupOperations.newGroup().create();
+    AccountGroup.UUID gx = groupOperations.newGroup().subgroups(gy, gz).create();
+
+    List<GroupInfo> includes = gApi.groups().id(gx.get()).includedGroups();
+
+    String gyName = groupOperations.group(gy).get().name();
+    String gzName = groupOperations.group(gz).get().name();
+    assertIncludes(includes, gyName, gzName);
   }
 
   @Test
   public void listOneIncludeMember() throws Exception {
-    String gx = createGroup("gx");
-    String gy = createGroup("gy");
-    gApi.groups().id(gx).addGroups(gy);
-    assertIncludes(gApi.groups().id(gx).includedGroups(), gy);
+    AccountGroup.UUID gy = groupOperations.newGroup().create();
+    AccountGroup.UUID gx = groupOperations.newGroup().subgroups(gy).create();
+
+    List<GroupInfo> includes = gApi.groups().id(gx.get()).includedGroups();
+
+    String gyName = groupOperations.group(gy).get().name();
+    assertIncludes(includes, gyName);
   }
 
   @Test
   public void listNonExistingGroupMembers_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").members();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id("non-existing").members());
   }
 
   @Test
   public void listEmptyGroupMembers() throws Exception {
-    String group = createGroup("empty");
-    assertThat(gApi.groups().id(group).members()).isEmpty();
+    AccountGroup.UUID group = groupOperations.newGroup().create();
+    assertThat(gApi.groups().id(group.get()).members()).isEmpty();
   }
 
   @Test
   public void listNonEmptyGroupMembers() throws Exception {
-    String group = createGroup("group");
+    AccountGroup.UUID group = groupOperations.newGroup().create();
     String user1 = name("user1");
     accountOperations.newAccount().username(user1).create();
     String user2 = name("user2");
     accountOperations.newAccount().username(user2).create();
-    gApi.groups().id(group).addMembers(user1, user2);
+    gApi.groups().id(group.get()).addMembers(user1, user2);
 
-    assertMembers(gApi.groups().id(group).members(), user1, user2);
+    assertMembers(gApi.groups().id(group.get()).members(), user1, user2);
   }
 
   @Test
   public void listOneGroupMember() throws Exception {
-    String group = createGroup("group");
+    AccountGroup.UUID group = groupOperations.newGroup().create();
     String user = name("user1");
     accountOperations.newAccount().username(user).create();
-    gApi.groups().id(group).addMembers(user);
+    gApi.groups().id(group.get()).addMembers(user);
 
-    assertMembers(gApi.groups().id(group).members(), user);
+    assertMembers(gApi.groups().id(group.get()).members(), user);
   }
 
   @Test
   public void listGroupMembersRecursively() throws Exception {
-    String gx = createGroup("gx");
+    AccountGroup.UUID gx = groupOperations.newGroup().create();
     String ux = name("ux");
     accountOperations.newAccount().username(ux).create();
-    gApi.groups().id(gx).addMembers(ux);
+    gApi.groups().id(gx.get()).addMembers(ux);
 
-    String gy = createGroup("gy");
+    AccountGroup.UUID gy = groupOperations.newGroup().create();
     String uy = name("uy");
     accountOperations.newAccount().username(uy).create();
-    gApi.groups().id(gy).addMembers(uy);
+    gApi.groups().id(gy.get()).addMembers(uy);
 
-    String gz = createGroup("gz");
+    AccountGroup.UUID gz = groupOperations.newGroup().create();
     String uz = name("uz");
     accountOperations.newAccount().username(uz).create();
-    gApi.groups().id(gz).addMembers(uz);
+    gApi.groups().id(gz.get()).addMembers(uz);
 
-    gApi.groups().id(gx).addGroups(gy);
-    gApi.groups().id(gy).addGroups(gz);
-    assertMembers(gApi.groups().id(gx).members(), ux);
-    assertMembers(gApi.groups().id(gx).members(true), ux, uy, uz);
+    gApi.groups().id(gx.get()).addGroups(gy.get());
+    gApi.groups().id(gy.get()).addGroups(gz.get());
+    assertMembers(gApi.groups().id(gx.get()).members(), ux);
+    assertMembers(gApi.groups().id(gx.get()).members(true), ux, uy, uz);
   }
 
   @Test
   public void usersSeeTheirDirectMembershipWhenListingMembersRecursively() throws Exception {
-    String group = createGroup("group");
-    gApi.groups().id(group).addMembers(user.username);
+    AccountGroup.UUID group = groupOperations.newGroup().create();
+    gApi.groups().id(group.get()).addMembers(user.username());
 
-    setApiUser(user);
-    assertMembers(gApi.groups().id(group).members(true), user.fullName);
+    requestScopeOperations.setApiUser(user.id());
+    assertMembers(gApi.groups().id(group.get()).members(true), user.fullName());
   }
 
   @Test
   public void usersDoNotSeeTheirIndirectMembershipWhenListingMembersRecursively() throws Exception {
-    String group1 = createGroup("group1");
-    String group2 = createGroup("group2");
-    gApi.groups().id(group1).addGroups(group2);
-    gApi.groups().id(group2).addMembers(user.username);
+    AccountGroup.UUID group1 = groupOperations.newGroup().ownerGroupUuid(adminGroupUuid()).create();
+    AccountGroup.UUID group2 = groupOperations.newGroup().ownerGroupUuid(adminGroupUuid()).create();
+    gApi.groups().id(group1.get()).addGroups(group2.get());
+    gApi.groups().id(group2.get()).addMembers(user.username());
 
-    setApiUser(user);
-    List<AccountInfo> listedMembers = gApi.groups().id(group1).members(true);
+    requestScopeOperations.setApiUser(user.id());
+    List<AccountInfo> listedMembers = gApi.groups().id(group1.get()).members(true);
 
     assertMembers(listedMembers);
   }
 
   @Test
   public void adminsSeeTheirIndirectMembershipWhenListingMembersRecursively() throws Exception {
-    String ownerGroup = createGroup("ownerGroup", null);
-    String group1 = createGroup("group1", ownerGroup);
-    String group2 = createGroup("group2", ownerGroup);
-    gApi.groups().id(group1).addGroups(group2);
-    gApi.groups().id(group2).addMembers(admin.username);
+    AccountGroup.UUID ownerGroup = groupOperations.newGroup().create();
+    AccountGroup.UUID group1 = groupOperations.newGroup().ownerGroupUuid(ownerGroup).create();
+    AccountGroup.UUID group2 = groupOperations.newGroup().ownerGroupUuid(ownerGroup).create();
+    gApi.groups().id(group1.get()).addGroups(group2.get());
+    gApi.groups().id(group2.get()).addMembers(admin.username());
 
-    List<AccountInfo> listedMembers = gApi.groups().id(group1).members(true);
+    List<AccountInfo> listedMembers = gApi.groups().id(group1.get()).members(true);
 
-    assertMembers(listedMembers, admin.fullName);
+    assertMembers(listedMembers, admin.fullName());
   }
 
   @Test
   public void ownersSeeTheirIndirectMembershipWhenListingMembersRecursively() throws Exception {
-    String ownerGroup = createGroup("ownerGroup", null);
-    String group1 = createGroup("group1", ownerGroup);
-    String group2 = createGroup("group2", ownerGroup);
-    gApi.groups().id(group1).addGroups(group2);
-    gApi.groups().id(ownerGroup).addMembers(user.username);
-    gApi.groups().id(group2).addMembers(user.username);
+    AccountGroup.UUID ownerGroup = groupOperations.newGroup().create();
+    AccountGroup.UUID group1 = groupOperations.newGroup().ownerGroupUuid(ownerGroup).create();
+    AccountGroup.UUID group2 = groupOperations.newGroup().ownerGroupUuid(ownerGroup).create();
+    gApi.groups().id(group1.get()).addGroups(group2.get());
+    gApi.groups().id(ownerGroup.get()).addMembers(user.username());
+    gApi.groups().id(group2.get()).addMembers(user.username());
 
-    setApiUser(user);
-    List<AccountInfo> listedMembers = gApi.groups().id(group1).members(true);
+    requestScopeOperations.setApiUser(user.id());
+    List<AccountInfo> listedMembers = gApi.groups().id(group1.get()).members(true);
 
-    assertMembers(listedMembers, user.fullName);
+    assertMembers(listedMembers, user.fullName());
   }
 
   @Test
   public void defaultGroupsCreated() throws Exception {
     Iterable<String> names = gApi.groups().list().getAsMap().keySet();
-    assertThat(names).containsAllOf("Administrators", "Non-Interactive Users").inOrder();
+    assertThat(names).containsAtLeast("Administrators", "Non-Interactive Users").inOrder();
   }
 
   @Test
@@ -775,18 +802,21 @@
 
   @Test
   public void getGroupsByOwner() throws Exception {
-    String parent = createGroup("test-parent");
-    List<String> children =
-        Arrays.asList(createGroup("test-child1", parent), createGroup("test-child2", parent));
+    AccountGroup.UUID parent = groupOperations.newGroup().ownerGroupUuid(adminGroupUuid()).create();
+    List<AccountGroup.UUID> children =
+        Arrays.asList(
+            groupOperations.newGroup().ownerGroupUuid(parent).create(),
+            groupOperations.newGroup().ownerGroupUuid(parent).create());
 
     // By UUID
-    List<GroupInfo> owned = gApi.groups().list().withOwnedBy(groupUuid(parent).get()).get();
-    assertThat(owned.stream().map(g -> g.name).collect(toList()))
+    List<GroupInfo> owned = gApi.groups().list().withOwnedBy(parent.get()).get();
+    assertThat(owned.stream().map(g -> AccountGroup.uuid(g.id)).collect(toList()))
         .containsExactlyElementsIn(children);
 
     // By name
-    owned = gApi.groups().list().withOwnedBy(parent).get();
-    assertThat(owned.stream().map(g -> g.name).collect(toList()))
+    String parentName = groupOperations.group(parent).get().name();
+    owned = gApi.groups().list().withOwnedBy(parentName).get();
+    assertThat(owned.stream().map(g -> AccountGroup.uuid(g.id)).collect(toList()))
         .containsExactlyElementsIn(children);
 
     // By group that does not own any others
@@ -794,9 +824,11 @@
     assertThat(owned).isEmpty();
 
     // By non-existing group
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Group Not Found: does-not-exist");
-    gApi.groups().list().withOwnedBy("does-not-exist").get();
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.groups().list().withOwnedBy("does-not-exist").get());
+    assertThat(thrown).hasMessageThat().contains("Group Not Found: does-not-exist");
   }
 
   @Test
@@ -809,13 +841,13 @@
     in.ownerId = adminGroupUuid().get();
     gApi.groups().create(in);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(gApi.groups().list().getAsMap()).doesNotContainKey(newGroupName);
 
-    setApiUser(admin);
-    gApi.groups().id(newGroupName).addMembers(user.username);
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.groups().id(newGroupName).addMembers(user.username());
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(gApi.groups().list().getAsMap()).containsKey(newGroupName);
   }
 
@@ -888,41 +920,41 @@
     GroupApi g = gApi.groups().create(name("group"));
     List<? extends GroupAuditEventInfo> auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(1);
-    assertMemberAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id, admin.id);
+    assertMemberAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id(), admin.id());
 
-    g.addMembers(user.username);
+    g.addMembers(user.username());
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(2);
-    assertMemberAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id, user.id);
+    assertMemberAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id(), user.id());
 
-    g.removeMembers(user.username);
+    g.removeMembers(user.username());
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(3);
-    assertMemberAuditEvent(auditEvents.get(0), Type.REMOVE_USER, admin.id, user.id);
+    assertMemberAuditEvent(auditEvents.get(0), Type.REMOVE_USER, admin.id(), user.id());
 
     String otherGroup = name("otherGroup");
     gApi.groups().create(otherGroup);
     g.addGroups(otherGroup);
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(4);
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id, otherGroup);
+    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id(), otherGroup);
 
     g.removeGroups(otherGroup);
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(5);
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.REMOVE_GROUP, admin.id, otherGroup);
+    assertSubgroupAuditEvent(auditEvents.get(0), Type.REMOVE_GROUP, admin.id(), otherGroup);
 
     // Add a removed member back again.
-    g.addMembers(user.username);
+    g.addMembers(user.username());
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(6);
-    assertMemberAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id, user.id);
+    assertMemberAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id(), user.id());
 
     // Add a removed group back again.
     g.addGroups(otherGroup);
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(7);
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id, otherGroup);
+    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id(), otherGroup);
 
     Timestamp lastDate = null;
     for (GroupAuditEventInfo auditEvent : auditEvents) {
@@ -934,8 +966,8 @@
   }
 
   /**
-   * @Sandboxed is used by this test because it deletes a group reference which introduces an
-   * inconsistency for the group storage. Once group deletion is supported, this test should be
+   * {@code @Sandboxed} is used by this test because it deletes a group reference which introduces
+   * an inconsistency for the group storage. Once group deletion is supported, this test should be
    * updated to use the API instead.
    */
   @Test
@@ -954,11 +986,11 @@
     List<? extends GroupAuditEventInfo> auditEvents = gApi.groups().id(parentGroup.id).auditLog();
     assertThat(auditEvents).hasSize(2);
     // Verify the unavailable subgroup's name is null.
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id, null);
+    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id(), null);
   }
 
   private void deleteGroupRef(String groupId) throws Exception {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(groupId);
+    AccountGroup.UUID uuid = AccountGroup.uuid(groupId);
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefUpdate ru = repo.updateRef(RefNames.refsGroups(uuid));
       ru.setForceUpdate(true);
@@ -970,11 +1002,7 @@
     gApi.groups().id(uuid.get()).index();
 
     // Verify "sub-group" has been deleted.
-    try {
-      gApi.groups().id(uuid.get()).get();
-      fail("expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-    }
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id(uuid.get()).get());
   }
 
   // reindex is tested by {@link AbstractQueryGroupsTest#reindex}
@@ -983,24 +1011,23 @@
     TestAccount groupOwner = accountCreator.user2();
     GroupInput in = new GroupInput();
     in.name = name("group");
-    in.members =
-        Collections.singleton(groupOwner).stream().map(u -> u.id.toString()).collect(toList());
+    in.members = Stream.of(groupOwner).map(u -> u.id().toString()).collect(toList());
     in.visibleToAll = true;
     GroupInfo group = gApi.groups().create(in).get();
 
     // admin can reindex any group
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.groups().id(group.id).index();
 
     // group owner can reindex own group (group is owned by itself)
-    setApiUser(groupOwner);
+    requestScopeOperations.setApiUser(groupOwner.id());
     gApi.groups().id(group.id).index();
 
     // user cannot reindex any group
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to index group");
-    gApi.groups().id(group.id).index();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.groups().id(group.id).index());
+    assertThat(thrown).hasMessageThat().contains("not allowed to index group");
   }
 
   @Test
@@ -1012,8 +1039,7 @@
   @Test
   public void pushToDeletedGroupBranchIsRejectedForAllUsersRepo() throws Exception {
     String groupRef =
-        RefNames.refsDeletedGroups(
-            new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+        RefNames.refsDeletedGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(allUsers, groupRef);
     assertPushToGroupBranch(allUsers, groupRef, "group update not allowed");
   }
@@ -1021,25 +1047,27 @@
   @Test
   public void pushToGroupNamesBranchIsRejectedForAllUsersRepo() throws Exception {
     // refs/meta/group-names isn't usually available for fetch, so grant ACCESS_DATABASE
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     assertPushToGroupBranch(allUsers, RefNames.REFS_GROUPNAMES, "group update not allowed");
   }
 
   @Test
   public void pushToGroupsBranchForNonAllUsersRepo() throws Exception {
-    assertCreateGroupBranch(project, null);
+    assertCreateGroupBranch(project);
     String groupRef =
-        RefNames.refsGroups(new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+        RefNames.refsGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(project, groupRef);
     assertPushToGroupBranch(project, groupRef, null);
   }
 
   @Test
   public void pushToDeletedGroupsBranchForNonAllUsersRepo() throws Exception {
-    assertCreateGroupBranch(project, null);
+    assertCreateGroupBranch(project);
     String groupRef =
-        RefNames.refsDeletedGroups(
-            new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+        RefNames.refsDeletedGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(project, groupRef);
     assertPushToGroupBranch(project, groupRef, null);
   }
@@ -1052,11 +1080,18 @@
 
   private void assertPushToGroupBranch(
       Project.NameKey project, String groupRefName, String expectedErrorOnUpdate) throws Exception {
-    grant(project, RefNames.REFS_GROUPS + "*", Permission.CREATE, false, REGISTERED_USERS);
-    grant(project, RefNames.REFS_GROUPS + "*", Permission.PUSH, false, REGISTERED_USERS);
-    grant(project, RefNames.REFS_DELETED_GROUPS + "*", Permission.CREATE, false, REGISTERED_USERS);
-    grant(project, RefNames.REFS_DELETED_GROUPS + "*", Permission.PUSH, false, REGISTERED_USERS);
-    grant(project, RefNames.REFS_GROUPNAMES, Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(
+            allow(Permission.CREATE)
+                .ref(RefNames.REFS_DELETED_GROUPS + "*")
+                .group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_DELETED_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPNAMES).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(project);
 
@@ -1065,7 +1100,7 @@
     repo.reset("groupRef");
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), repo, "Update group", "arbitraryFile.txt", "some content")
+            .create(admin.newIdent(), repo, "Update group", "arbitraryFile.txt", "some content")
             .to(groupRefName);
     if (expectedErrorOnUpdate != null) {
       r.assertErrorStatus(expectedErrorOnUpdate);
@@ -1074,31 +1109,30 @@
     }
   }
 
-  private void assertCreateGroupBranch(Project.NameKey project, String expectedErrorOnCreate)
-      throws Exception {
-    grant(project, RefNames.REFS_GROUPS + "*", Permission.CREATE, false, REGISTERED_USERS);
-    grant(project, RefNames.REFS_GROUPS + "*", Permission.PUSH, false, REGISTERED_USERS);
+  private void assertCreateGroupBranch(Project.NameKey project) throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
     TestRepository<InMemoryRepository> repo = cloneProject(project);
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), repo, "Update group", "arbitraryFile.txt", "some content")
+            .create(admin.newIdent(), repo, "Update group", "arbitraryFile.txt", "some content")
             .setParents(ImmutableList.of())
             .to(RefNames.REFS_GROUPS + name("bar"));
-    if (expectedErrorOnCreate != null) {
-      r.assertErrorStatus(expectedErrorOnCreate);
-    } else {
-      r.assertOkStatus();
-    }
+    r.assertOkStatus();
   }
 
   @Test
-  public void pushToGroupBranchForReviewForAllUsersRepoIsRejectedOnSubmit() throws Exception {
+  public void pushToGroupBranchForReviewForAllUsersRepoIsRejectedOnSubmit() throws Throwable {
     pushToGroupBranchForReviewAndSubmit(
         allUsers, RefNames.refsGroups(adminGroupUuid()), "group update not allowed");
   }
 
   @Test
-  public void pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit() throws Exception {
+  public void pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit() throws Throwable {
     String groupRef = RefNames.refsGroups(adminGroupUuid());
     createBranch(project, groupRef);
     pushToGroupBranchForReviewAndSubmit(project, groupRef, null);
@@ -1123,7 +1157,7 @@
 
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), repo, "Subject", "project.config", config)
+            .create(admin.newIdent(), repo, "Subject", "project.config", config)
             .to(RefNames.REFS_CONFIG);
     r.assertErrorStatus("invalid project configuration");
     r.assertMessage("All-Users must inherit from All-Projects");
@@ -1132,14 +1166,14 @@
   @Test
   public void cannotCreateGroupBranch() throws Exception {
     testCannotCreateGroupBranch(
-        RefNames.REFS_GROUPS + "*", RefNames.refsGroups(new AccountGroup.UUID(name("foo"))));
+        RefNames.REFS_GROUPS + "*", RefNames.refsGroups(AccountGroup.uuid(name("foo"))));
   }
 
   @Test
   public void cannotCreateDeletedGroupBranch() throws Exception {
     testCannotCreateGroupBranch(
         RefNames.REFS_DELETED_GROUPS + "*",
-        RefNames.refsDeletedGroups(new AccountGroup.UUID(name("foo"))));
+        RefNames.refsDeletedGroups(AccountGroup.uuid(name("foo"))));
   }
 
   @Test
@@ -1162,18 +1196,25 @@
       }
 
       // refs/meta/group-names is only visible with ACCESS_DATABASE
-      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+      projectOperations
+          .allProjectsForUpdate()
+          .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+          .update();
 
       testCannotCreateGroupBranch(RefNames.REFS_GROUPNAMES, RefNames.REFS_GROUPNAMES);
     }
   }
 
   private void testCannotCreateGroupBranch(String refPattern, String groupRef) throws Exception {
-    grant(allUsers, refPattern, Permission.CREATE);
-    grant(allUsers, refPattern, Permission.PUSH);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(refPattern).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(refPattern).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(groupRef);
+    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(groupRef);
     r.assertErrorStatus();
     assertThat(r.getMessage()).contains("Not allowed to create group branch.");
 
@@ -1189,7 +1230,7 @@
 
   @Test
   public void cannotDeleteDeletedGroupBranch() throws Exception {
-    String groupRef = RefNames.refsDeletedGroups(new AccountGroup.UUID(name("foo")));
+    String groupRef = RefNames.refsDeletedGroups(AccountGroup.uuid(name("foo")));
     createBranch(allUsers, groupRef);
     testCannotDeleteGroupBranch(RefNames.REFS_DELETED_GROUPS + "*", groupRef);
   }
@@ -1197,13 +1238,20 @@
   @Test
   public void cannotDeleteGroupNamesBranch() throws Exception {
     // refs/meta/group-names is only visible with ACCESS_DATABASE
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     testCannotDeleteGroupBranch(RefNames.REFS_GROUPNAMES, RefNames.REFS_GROUPNAMES);
   }
 
   private void testCannotDeleteGroupBranch(String refPattern, String groupRef) throws Exception {
-    grant(allUsers, refPattern, Permission.DELETE, true, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref(refPattern).group(REGISTERED_USERS).force(true))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     PushResult r = deleteRef(allUsersRepo, groupRef);
@@ -1227,7 +1275,7 @@
   public void stalenessChecker() throws Exception {
     // Newly created group is not stale
     GroupInfo groupInfo = gApi.groups().create(name("foo")).get();
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(groupInfo.id);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid(groupInfo.id);
     assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
 
     // Manual update makes index document stale
@@ -1272,12 +1320,12 @@
   public void groupsOfUserCanBeListedInSlaveMode() throws Exception {
     GroupInput groupInput = new GroupInput();
     groupInput.name = name("contributors");
-    groupInput.members = ImmutableList.of(user.username);
+    groupInput.members = ImmutableList.of(user.username());
     gApi.groups().create(groupInput).get();
     restartAsSlave();
 
-    setApiUser(user);
-    List<GroupInfo> groups = gApi.groups().list().withUser(user.username).get();
+    requestScopeOperations.setApiUser(user.id());
+    List<GroupInfo> groups = gApi.groups().list().withUser(user.username()).get();
     ImmutableList<String> groupNames =
         groups.stream().map(group -> group.name).collect(toImmutableList());
     assertThat(groupNames).contains(groupInput.name);
@@ -1298,7 +1346,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.
@@ -1308,12 +1356,12 @@
       // Create a group without updating the cache or index,
       // then run the reindexer -> only the new group is reindexed.
       String groupName = "foo";
-      AccountGroup.UUID groupUuid = new AccountGroup.UUID(groupName + "-UUID");
+      AccountGroup.UUID groupUuid = AccountGroup.uuid(groupName + "-UUID");
       groupsUpdate.createGroupInNoteDb(
           InternalGroupCreation.builder()
               .setGroupUUID(groupUuid)
-              .setNameKey(new AccountGroup.NameKey(groupName))
-              .setId(new AccountGroup.Id(seq.nextGroupId()))
+              .setNameKey(AccountGroup.nameKey(groupName))
+              .setId(AccountGroup.id(seq.nextGroupId()))
               .build(),
           InternalGroupUpdate.builder().build());
       slaveGroupIndexer.run();
@@ -1355,7 +1403,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();
@@ -1366,18 +1414,12 @@
   }
 
   private static Correspondence<AccountInfo, String> getAccountToUsernameCorrespondence() {
-    return new Correspondence<AccountInfo, String>() {
-      @Override
-      public boolean compare(AccountInfo actualAccount, String expectedName) {
-        String username = actualAccount == null ? null : actualAccount.username;
-        return Objects.equals(username, expectedName);
-      }
-
-      @Override
-      public String toString() {
-        return "has username";
-      }
-    };
+    return Correspondence.from(
+        (actualAccount, expectedName) -> {
+          String username = actualAccount == null ? null : actualAccount.username;
+          return Objects.equals(username, expectedName);
+        },
+        "has username");
   }
 
   private void assertStaleGroupAndReindex(AccountGroup.UUID groupUuid) throws IOException {
@@ -1391,10 +1433,17 @@
   }
 
   private void pushToGroupBranchForReviewAndSubmit(
-      Project.NameKey project, String groupRef, String expectedError) throws Exception {
-    grantLabel(
-        "Code-Review", -2, 2, project, RefNames.REFS_GROUPS + "*", false, REGISTERED_USERS, false);
-    grant(project, RefNames.REFS_GROUPS + "*", Permission.SUBMIT, false, REGISTERED_USERS);
+      Project.NameKey project, String groupRef, String expectedError) throws Throwable {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Code-Review")
+                .ref(RefNames.REFS_GROUPS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(project);
     fetch(repo, groupRef + ":groupRef");
@@ -1402,18 +1451,19 @@
 
     PushOneCommit.Result r =
         pushFactory
-            .create(
-                db, admin.getIdent(), repo, "Update group config", "group.config", "some content")
+            .create(admin.newIdent(), repo, "Update group config", "group.config", "some content")
             .to(MagicBranch.NEW_CHANGE + groupRef);
     r.assertOkStatus();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(groupRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(groupRef);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
 
+    ThrowingRunnable submit = () -> gApi.changes().id(r.getChangeId()).current().submit();
     if (expectedError != null) {
-      exception.expect(ResourceConflictException.class);
-      exception.expectMessage("group update not allowed");
+      Throwable thrown = assertThrows(ResourceConflictException.class, submit);
+      assertThat(thrown).hasMessageThat().contains("group update not allowed");
+    } else {
+      submit.run();
     }
-    gApi.changes().id(r.getChangeId()).current().submit();
   }
 
   private void createBranch(Project.NameKey project, String ref) throws IOException {
@@ -1477,7 +1527,7 @@
   private void assertMembers(String group, TestAccount... expectedMembers) throws Exception {
     assertMembers(
         gApi.groups().id(group).members(),
-        TestAccount.names(expectedMembers).stream().toArray(String[]::new));
+        TestAccount.names(expectedMembers).toArray(new String[0]));
     assertAccountInfos(Arrays.asList(expectedMembers), gApi.groups().id(group).members());
   }
 
@@ -1487,31 +1537,14 @@
         .inOrder();
   }
 
-  private void assertNoMembers(String group) throws Exception {
-    assertThat(gApi.groups().id(group).members()).isEmpty();
-  }
-
-  private void assertIncludes(String group, String... expectedNames) throws Exception {
-    assertIncludes(gApi.groups().id(group).includedGroups(), expectedNames);
-  }
-
-  private static void assertIncludes(Iterable<GroupInfo> includes, String... expectedNames) {
-    assertThat(Iterables.transform(includes, i -> i.name))
-        .containsExactlyElementsIn(Arrays.asList(expectedNames))
-        .inOrder();
-  }
-
-  private void assertNoIncludes(String group) throws Exception {
-    assertThat(gApi.groups().id(group).includedGroups()).isEmpty();
+  private static void assertIncludes(List<GroupInfo> includes, String... expectedNames) {
+    List<String> names = includes.stream().map(i -> i.name).collect(toImmutableList());
+    assertThat(names).containsExactlyElementsIn(Arrays.asList(expectedNames));
+    assertThat(names).isInOrder();
   }
 
   private void assertBadRequest(ListRequest req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 
   @Target({METHOD})
@@ -1531,24 +1564,18 @@
       countsByGroup.clear();
     }
 
-    long getCount(AccountGroup.UUID groupUuid) {
-      return countsByGroup.get(groupUuid.get());
-    }
-
     void assertReindexOf(AccountGroup.UUID groupUuid) {
       assertReindexOf(ImmutableList.of(groupUuid));
     }
 
     void assertReindexOf(List<AccountGroup.UUID> groupUuids) {
-      for (AccountGroup.UUID groupUuid : groupUuids) {
-        assertThat(getCount(groupUuid)).named(groupUuid.get()).isEqualTo(1);
-      }
-      assertThat(countsByGroup).hasSize(groupUuids.size());
+      Map<String, Long> expected = groupUuids.stream().collect(toMap(u -> u.get(), u -> 1L));
+      assertThat(countsByGroup.asMap()).containsExactlyEntriesIn(expected);
       clear();
     }
 
     void assertNoReindex() {
-      assertThat(countsByGroup).isEmpty();
+      assertThat(countsByGroup.asMap()).isEmpty();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index 7056312..aba7d18 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.acceptance.api.group;
 
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.ServerInitiated;
@@ -27,7 +29,6 @@
 import com.google.gerrit.server.group.db.InternalGroupCreation;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -36,12 +37,9 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class GroupsUpdateIT {
   @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
   @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdateProvider;
   @Inject private Groups groups;
 
@@ -56,7 +54,7 @@
     createGroup(groupCreation, groupUpdate);
 
     Stream<String> allGroupNames = getAllGroupNames();
-    assertThat(allGroupNames).containsAllOf("users", "verifiers");
+    assertThat(allGroupNames).containsAtLeast("users", "verifiers");
   }
 
   @Test
@@ -65,23 +63,23 @@
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("contributors"))
+            .setName(AccountGroup.nameKey("contributors"))
             .setMemberModification(
                 new CreateAnotherGroupOnceAsSideEffectOfMemberModification("verifiers"))
             .build();
-    updateGroup(new AccountGroup.UUID("users-UUID"), groupUpdate);
+    updateGroup(AccountGroup.uuid("users-UUID"), groupUpdate);
 
     Stream<String> allGroupNames = getAllGroupNames();
-    assertThat(allGroupNames).containsAllOf("contributors", "verifiers");
+    assertThat(allGroupNames).containsAtLeast("contributors", "verifiers");
   }
 
   @Test
   public void groupUpdateFailsWithExceptionForNotExistingGroup() throws Exception {
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setDescription("A description for the group").build();
-
-    expectedException.expect(NoSuchGroupException.class);
-    updateGroup(new AccountGroup.UUID("nonexistent-group-UUID"), groupUpdate);
+    assertThrows(
+        NoSuchGroupException.class,
+        () -> updateGroup(AccountGroup.uuid("nonexistent-group-UUID"), groupUpdate));
   }
 
   private void createGroup(String groupName, String groupUuid) throws Exception {
@@ -92,7 +90,7 @@
   }
 
   private void createGroup(InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException {
     groupsUpdateProvider.get().createGroup(groupCreation, groupUpdate);
   }
 
@@ -107,9 +105,9 @@
 
   private static InternalGroupCreation getGroupCreation(String groupName, String groupUuid) {
     return InternalGroupCreation.builder()
-        .setGroupUUID(new AccountGroup.UUID(groupUuid))
-        .setNameKey(new AccountGroup.NameKey(groupName))
-        .setId(new AccountGroup.Id(Math.abs(groupName.hashCode())))
+        .setGroupUUID(AccountGroup.uuid(groupUuid))
+        .setNameKey(AccountGroup.nameKey(groupName))
+        .setId(AccountGroup.id(Math.abs(groupName.hashCode())))
         .build();
   }
 
@@ -138,7 +136,7 @@
       InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().build();
       try {
         groupsUpdateProvider.get().createGroup(groupCreation, groupUpdate);
-      } catch (OrmException | IOException | ConfigInvalidException e) {
+      } catch (StorageException | IOException | ConfigInvalidException e) {
         throw new IllegalStateException(e);
       }
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index ee0463e..a120eac 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.plugin;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -22,10 +23,11 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.api.plugins.PluginApi;
 import com.google.gerrit.extensions.api.plugins.Plugins.ListRequest;
-import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -33,6 +35,8 @@
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.plugins.MandatoryPluginsCollection;
+import com.google.inject.Inject;
 import java.util.List;
 import org.junit.Test;
 
@@ -49,6 +53,9 @@
       ImmutableList.of(
           "plugin-a.js", "plugin-b.html", "plugin-c.js", "plugin-d.html", "plugin_e.js");
 
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private MandatoryPluginsCollection mandatoryPluginsCollection;
+
   @Test
   @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
   public void pluginManagement() throws Exception {
@@ -94,7 +101,13 @@
     assertBadRequest(list().regex(".*in-b").prefix("a"));
     assertBadRequest(list().substring(".*in-b").prefix("a"));
 
-    // Disable
+    // Disable mandatory
+    mandatoryPluginsCollection.add("plugin_e");
+    assertThrows(MethodNotAllowedException.class, () -> gApi.plugins().name("plugin_e").disable());
+    api = gApi.plugins().name("plugin_e");
+    assertThat(api.get().disabled).isNull();
+
+    // Disable non-mandatory
     api = gApi.plugins().name("plugin-a");
     api.disable();
     api = gApi.plugins().name("plugin-a");
@@ -108,27 +121,35 @@
     assertThat(api.get().disabled).isNull();
     assertPlugins(list().get(), PLUGINS);
 
+    // Using deprecated input
+    deprecatedInput();
+
     // Non-admin cannot disable
-    setApiUser(user);
-    try {
-      gApi.plugins().name("plugin-a").disable();
-      fail("Expected AuthException");
-    } catch (AuthException expected) {
-      // Expected
-    }
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(AuthException.class, () -> gApi.plugins().name("plugin-a").disable());
+  }
+
+  @SuppressWarnings("deprecation")
+  private void deprecatedInput() throws Exception {
+    com.google.gerrit.extensions.common.InstallPluginInput input =
+        new com.google.gerrit.extensions.common.InstallPluginInput();
+    input.raw = JS_PLUGIN_CONTENT;
+    gApi.plugins().install("legacy.html", input);
+    gApi.plugins().name("legacy").get();
   }
 
   @Test
   public void installNotAllowed() throws Exception {
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("remote plugin administration is disabled");
-    gApi.plugins().install("test.js", new InstallPluginInput());
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.plugins().install("test.js", new InstallPluginInput()));
+    assertThat(thrown).hasMessageThat().contains("remote plugin administration is disabled");
   }
 
   @Test
   public void getNonExistingThrowsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.plugins().name("does-not-exist");
+    assertThrows(ResourceNotFoundException.class, () -> gApi.plugins().name("does-not-exist"));
   }
 
   private ListRequest list() throws RestApiException {
@@ -154,11 +175,6 @@
   }
 
   private void assertBadRequest(ListRequest req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
new file mode 100644
index 0000000..7eb3680
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.plugin;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.server.plugins.MissingMandatoryPluginsException;
+import org.junit.Test;
+import org.junit.runner.Description;
+
+@NoHttpd
+public class PluginLoaderIT extends AbstractDaemonTest {
+
+  Description testDescription;
+
+  @Override
+  protected void beforeTest(Description description) throws Exception {
+    this.testDescription = description;
+  }
+
+  @Override
+  protected void afterTest() throws Exception {}
+
+  @Test(expected = MissingMandatoryPluginsException.class)
+  @GerritConfig(name = "plugins.mandatory", value = "my-mandatory-plugin")
+  public void shouldFailToStartGerritWhenMandatoryPluginsAreMissing() throws Exception {
+    super.beforeTest(testDescription);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/BUILD b/javatests/com/google/gerrit/acceptance/api/project/BUILD
index 768c20b..97c6f33 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/project/BUILD
@@ -4,4 +4,5 @@
     srcs = glob(["*IT.java"]),
     group = "api_project",
     labels = ["api"],
+    deps = ["//java/com/google/gerrit/index/project"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 2b1416a..b7d6627 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -15,21 +15,29 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 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;
@@ -38,98 +46,101 @@
 import org.junit.Test;
 
 public class CheckAccessIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @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"));
+    normalProject = projectOperations.newProject().create();
+    secretProject = projectOperations.newProject().create();
+    secretRefProject = projectOperations.newProject().create();
+    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");
+    projectOperations
+        .project(secretProject)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(privilegedGroupUuid))
+        .add(block(Permission.READ).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
 
-    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroup.getGroupUUID());
-    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());
-    block(
-        secretRefProject,
-        "refs/heads/secret/*",
-        Permission.READ,
-        SystemGroupBackend.REGISTERED_USERS);
-    grant(
-        secretRefProject,
-        "refs/heads/*",
-        Permission.READ,
-        false,
-        SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(secretRefProject)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/secret/*").group(privilegedGroupUuid))
+        .add(
+            block(Permission.READ)
+                .ref("refs/heads/secret/*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
 
     // Ref permission
-    grant(
-        normalProject,
-        "refs/*",
-        Permission.VIEW_PRIVATE_CHANGES,
-        false,
-        privilegedGroup.getGroupUUID());
-    grant(normalProject, "refs/*", Permission.FORGE_SERVER, false, privilegedGroup.getGroupUUID());
+    projectOperations
+        .project(normalProject)
+        .forUpdate()
+        .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(privilegedGroupUuid))
+        .add(allow(Permission.FORGE_SERVER).ref("refs/*").group(privilegedGroupUuid))
+        .update();
   }
 
   @Test
   public void emptyInput() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("input requires 'account'");
-    gApi.projects().name(normalProject.get()).checkAccess(new AccessCheckInput());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(new AccessCheckInput()));
+    assertThat(thrown).hasMessageThat().contains("input requires 'account'");
   }
 
   @Test
   public void nonexistentPermission() throws Exception {
     AccessCheckInput in = new AccessCheckInput();
-    in.account = user.email;
+    in.account = user.email();
     in.permission = "notapermission";
     in.ref = "refs/heads/master";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("not recognized");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("not recognized");
   }
 
   @Test
   public void permissionLacksRef() throws Exception {
     AccessCheckInput in = new AccessCheckInput();
-    in.account = user.email;
+    in.account = user.email();
     in.permission = "forge_author";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("must set 'ref'");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("must set 'ref'");
   }
 
   @Test
   public void changePermission() throws Exception {
     AccessCheckInput in = new AccessCheckInput();
-    in.account = user.email;
+    in.account = user.email();
     in.permission = "rebase";
     in.ref = "refs/heads/master";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("recognized as ref permission");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("recognized as ref permission");
   }
 
   @Test
@@ -139,9 +150,11 @@
     in.permission = "rebase";
     in.ref = "refs/heads/master";
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("cannot find account doesnotexist@invalid.com");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("Account 'doesnotexist@invalid.com' not found");
   }
 
   private static class TestCase {
@@ -190,7 +203,7 @@
                 + normalProject.get()
                 + "/check.access"
                 + "?ref=refs/heads/master&perm=viewPrivateChanges&account="
-                + user.email);
+                + user.email());
     rep.assertOK();
     assertThat(rep.getEntityContent()).contains("403");
   }
@@ -200,28 +213,28 @@
     List<TestCase> inputs =
         ImmutableList.of(
             TestCase.projectRefPerm(
-                user.email,
+                user.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
                 403),
-            TestCase.project(user.email, normalProject.get(), 200),
-            TestCase.project(user.email, secretProject.get(), 403),
+            TestCase.project(user.email(), normalProject.get(), 200),
+            TestCase.project(user.email(), secretProject.get(), 403),
             TestCase.projectRef(
-                user.email, secretRefProject.get(), "refs/heads/secret/master", 403),
+                user.email(), secretRefProject.get(), "refs/heads/secret/master", 403),
             TestCase.projectRef(
-                privilegedUser.email, secretRefProject.get(), "refs/heads/secret/master", 200),
-            TestCase.projectRef(privilegedUser.email, normalProject.get(), null, 200),
-            TestCase.projectRef(privilegedUser.email, secretProject.get(), null, 200),
-            TestCase.projectRef(privilegedUser.email, secretProject.get(), null, 200),
+                privilegedUser.email(), secretRefProject.get(), "refs/heads/secret/master", 200),
+            TestCase.projectRef(privilegedUser.email(), normalProject.get(), null, 200),
+            TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
+            TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
             TestCase.projectRefPerm(
-                privilegedUser.email,
+                privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
                 200),
             TestCase.projectRefPerm(
-                privilegedUser.email,
+                privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.FORGE_SERVER,
@@ -234,13 +247,16 @@
       try {
         info = gApi.projects().name(tc.project).checkAccess(tc.input);
       } catch (RestApiException e) {
-        fail(String.format("check.access(%s, %s): exception %s", tc.project, in, e));
+        assertWithMessage(String.format("check.access(%s, %s): exception %s", tc.project, in, e))
+            .fail();
       }
 
       int want = tc.want;
       if (want != info.status) {
-        fail(
-            String.format("check.access(%s, %s) = %d, want %d", tc.project, in, info.status, want));
+        assertWithMessage(
+                String.format(
+                    "check.access(%s, %s) = %d, want %d", tc.project, in, info.status, want))
+            .fail();
       }
 
       switch (want) {
@@ -256,7 +272,7 @@
           assertThat(info.message).isNull();
           break;
         default:
-          fail(String.format("unknown code %d", want));
+          assertWithMessage(String.format("unknown code %d", want)).fail();
       }
     }
   }
@@ -269,7 +285,7 @@
       assertThat(u.delete()).isEqualTo(Result.FORCED);
     }
     AccessCheckInput input = new AccessCheckInput();
-    input.account = privilegedUser.email;
+    input.account = privilegedUser.email();
 
     AccessCheckInfo info = gApi.projects().name(normalProject.get()).checkAccess(input);
     assertThat(info.status).isEqualTo(200);
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..27dd16a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
@@ -0,0 +1,305 @@
+// 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 com.google.gerrit.testing.GerritJUnit.assertThrows;
+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().branch();
+
+    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().branch();
+
+    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().branch();
+
+    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().branch();
+
+    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().branch();
+
+    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();
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(project.get()).check(input));
+    assertThat(thrown).hasMessageThat().contains("branch is required");
+  }
+
+  @Test
+  public void nonExistingBranch() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("non-existing");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).check(input));
+    assertThat(thrown).hasMessageThat().contains("branch 'non-existing' not found");
+  }
+
+  @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;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(project.get()).check(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "max commits can at most be set to "
+                + ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT);
+  }
+
+  private RevCommit pushCommitWithoutChangeIdForReview() throws Exception {
+    setRequireChangeId(InheritableBoolean.FALSE);
+    RevCommit commit =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .message("A change")
+            .insertChangeId()
+            .author(admin.newIdent())
+            .committer(new PersonIdent(admin.newIdent(), 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/CommitIncludedInIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
new file mode 100644
index 0000000..749176b8f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
@@ -0,0 +1,75 @@
+// 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.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+@NoHttpd
+public class CommitIncludedInIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void includedInOpenChange() throws Exception {
+    Result result = createChange();
+    assertThat(getIncludedIn(result.getCommit().getId()).branches).isEmpty();
+    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
+  }
+
+  @Test
+  public void includedInMergedChange() throws Exception {
+    Result result = createChange();
+    gApi.changes()
+        .id(result.getChangeId())
+        .revision(result.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+
+    assertThat(getIncludedIn(result.getCommit().getId()).branches).containsExactly("master");
+    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .update();
+    gApi.projects().name(result.getChange().project().get()).tag("test-tag").create(new TagInput());
+
+    assertThat(getIncludedIn(result.getCommit().getId()).tags).containsExactly("test-tag");
+
+    createBranch(BranchNameKey.create(project, "test-branch"));
+
+    assertThat(getIncludedIn(result.getCommit().getId()).branches)
+        .containsExactly("master", "test-branch");
+  }
+
+  private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
+    return gApi.projects().name(project.get()).commit(id.name()).includedIn();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index e51a069..6442645 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -15,12 +15,15 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
@@ -31,6 +34,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.restapi.project.DashboardsCollection;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -40,21 +44,25 @@
 
 @NoHttpd
 public class DashboardIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   @Before
   public void setup() throws Exception {
-    allow("refs/meta/dashboards/*", Permission.CREATE, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/meta/dashboards/*").group(REGISTERED_USERS))
+        .update();
   }
 
   @Test
   public void defaultDashboardDoesNotExist() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    project().defaultDashboard().get();
+    assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
   }
 
   @Test
   public void dashboardDoesNotExist() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    project().dashboard("my:dashboard").get();
+    assertThrows(ResourceNotFoundException.class, () -> project().dashboard("my:dashboard").get());
   }
 
   @Test
@@ -110,8 +118,7 @@
     project().removeDefaultDashboard();
     assertThat(project().dashboard(info.id).get().isDefault).isNull();
 
-    exception.expect(ResourceNotFoundException.class);
-    project().defaultDashboard().get();
+    assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
   }
 
   @Test
@@ -133,9 +140,9 @@
   @Test
   public void cannotGetDashboardWithInheritedForNonDefault() throws Exception {
     DashboardInfo info = createTestDashboard();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("inherited flag can only be used with default");
-    project().dashboard(info.id).get(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().dashboard(info.id).get(true));
+    assertThat(thrown).hasMessageThat().contains("inherited flag can only be used with default");
   }
 
   private void assertDashboardInfo(DashboardInfo actual, DashboardInfo expected) throws Exception {
@@ -192,9 +199,9 @@
         throw e;
       }
     }
-    try (Repository r = repoManager.openRepository(project)) {
-      TestRepository<Repository>.CommitBuilder cb =
-          new TestRepository<>(r).branch(canonicalRef).commit();
+    try (Repository r = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(r)) {
+      TestRepository<Repository>.CommitBuilder cb = tr.branch(canonicalRef).commit();
       StringBuilder content = new StringBuilder("[dashboard]\n");
       if (info.title != null) {
         content.append("title = ").append(info.title).append("\n");
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index b4a05fc..a9c7d99 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -15,16 +15,31 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+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 com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
@@ -39,8 +54,14 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.project.CommentLinkInfoImpl;
 import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
@@ -50,7 +71,20 @@
 
 @NoHttpd
 public class ProjectIT extends AbstractDaemonTest {
+  private static final String BUGZILLA = "bugzilla";
+  private static final String BUGZILLA_LINK = "http://bugzilla.example.com/?id=$2";
+  private static final String BUGZILLA_MATCH = "(bug\\\\s+#?)(\\\\d+)";
+  private static final String JIRA = "jira";
+  private static final String JIRA_LINK = "http://jira.example.com/?id=$2";
+  private static final String JIRA_MATCH = "(jira\\\\s+#?)(\\\\d+)";
+
   @Inject private DynamicSet<ProjectIndexedListener> projectIndexedListeners;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Inject
+  @IndexExecutor(BATCH)
+  private ListeningExecutorService executor;
 
   private ProjectIndexedCounter projectIndexedCounter;
   private RegistrationHandle projectIndexedCounterHandle;
@@ -58,7 +92,7 @@
   @Before
   public void addProjectIndexedCounter() {
     projectIndexedCounter = new ProjectIndexedCounter();
-    projectIndexedCounterHandle = projectIndexedListeners.add(projectIndexedCounter);
+    projectIndexedCounterHandle = projectIndexedListeners.add("gerrit", projectIndexedCounter);
   }
 
   @After
@@ -134,17 +168,17 @@
   public void createProjectWithMismatchedInput() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("foo");
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("name must match input.name");
-    gApi.projects().name("bar").create(in);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.projects().name("bar").create(in));
+    assertThat(thrown).hasMessageThat().contains("name must match input.name");
   }
 
   @Test
   public void createProjectNoNameInInput() throws Exception {
     ProjectInput in = new ProjectInput();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("input.name is required");
-    gApi.projects().create(in);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("input.name is required");
   }
 
   @Test
@@ -152,9 +186,9 @@
     ProjectInput in = new ProjectInput();
     in.name = name("baz");
     gApi.projects().create(in);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Project already exists");
-    gApi.projects().create(in);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("Project already exists");
   }
 
   @Test
@@ -163,9 +197,9 @@
     in.name = name("baz");
     in.parent = "non-existing";
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Project Not Found: " + in.parent);
-    gApi.projects().create(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("Project Not Found: " + in.parent);
   }
 
   @Test
@@ -174,9 +208,9 @@
     in.name = name("baz");
     in.parent = in.name;
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Project Not Found: " + in.parent);
-    gApi.projects().create(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("Project Not Found: " + in.parent);
   }
 
   @Test
@@ -184,30 +218,36 @@
     ProjectInput in = new ProjectInput();
     in.name = name("foo");
     in.parent = allUsers.get();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("Cannot inherit from '%s' project", allUsers.get()));
-    gApi.projects().create(in);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Cannot inherit from '%s' project", allUsers.get()));
   }
 
   @Test
   public void createAndDeleteBranch() throws Exception {
-    assertThat(getRemoteHead(project.get(), "foo")).isNull();
+    assertThat(hasHead(project, "foo")).isFalse();
 
     gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
     assertThat(getRemoteHead(project.get(), "foo")).isNotNull();
     projectIndexedCounter.assertNoReindex();
 
     gApi.projects().name(project.get()).branch("foo").delete();
-    assertThat(getRemoteHead(project.get(), "foo")).isNull();
+    assertThat(hasHead(project, "foo")).isFalse();
     projectIndexedCounter.assertNoReindex();
   }
 
   @Test
   public void createAndDeleteBranchByPush() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     projectIndexedCounter.clear();
 
-    assertThat(getRemoteHead(project.get(), "foo")).isNull();
+    assertThat(hasHead(project, "foo")).isFalse();
 
     PushOneCommit.Result r = pushTo("refs/heads/foo");
     r.assertOkStatus();
@@ -216,20 +256,20 @@
 
     PushResult r2 = GitUtil.pushOne(testRepo, null, "refs/heads/foo", false, true, null);
     assertThat(r2.getRemoteUpdate("refs/heads/foo").getStatus()).isEqualTo(Status.OK);
-    assertThat(getRemoteHead(project.get(), "foo")).isNull();
+    assertThat(hasHead(project, "foo")).isFalse();
     projectIndexedCounter.assertNoReindex();
   }
 
   @Test
   public void descriptionChangeCausesRefUpdate() throws Exception {
-    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit initialHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
     assertThat(gApi.projects().name(project.get()).description()).isEmpty();
     DescriptionInput in = new DescriptionInput();
     in.description = "new project description";
     gApi.projects().name(project.get()).description(in);
     assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
 
-    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
         project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
   }
@@ -248,7 +288,7 @@
 
   @Test
   public void configChangeCausesRefUpdate() throws Exception {
-    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit initialHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
 
     ConfigInfo info = gApi.projects().name(project.get()).config();
     assertThat(info.defaultSubmitType.value).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
@@ -259,7 +299,7 @@
     info = gApi.projects().name(project.get()).config();
     assertThat(info.defaultSubmitType.value).isEqualTo(SubmitType.CHERRY_PICK);
 
-    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
         project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
   }
@@ -293,11 +333,11 @@
   @Test
   public void setPartialConfig() throws Exception {
     ConfigInput input = createTestConfigInput();
-    ConfigInfo info = gApi.projects().name(project.get()).config(input);
+    gApi.projects().name(project.get()).config(input);
 
     ConfigInput partialInput = new ConfigInput();
     partialInput.useContributorAgreements = InheritableBoolean.FALSE;
-    info = gApi.projects().name(project.get()).config(partialInput);
+    ConfigInfo info = gApi.projects().name(project.get()).config(partialInput);
 
     assertThat(info.description).isNull();
     assertThat(info.useContributorAgreements.configuredValue)
@@ -322,10 +362,10 @@
   @Test
   public void nonOwnerCannotSetConfig() throws Exception {
     ConfigInput input = createTestConfigInput();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("write refs/meta/config not permitted");
-    gApi.projects().name(project.get()).config(input);
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).config(input));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
   }
 
   @Test
@@ -341,8 +381,9 @@
 
   @Test
   public void setHeadToNonexistentBranch() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    gApi.projects().name(project.get()).head("does-not-exist");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.projects().name(project.get()).head("does-not-exist"));
   }
 
   @Test
@@ -357,10 +398,10 @@
   @Test
   public void setHeadNotAllowed() throws Exception {
     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");
-    gApi.projects().name(project.get()).head("test");
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).head("test"));
+    assertThat(thrown).hasMessageThat().contains("not permitted: set HEAD on refs/heads/test");
   }
 
   @Test
@@ -381,6 +422,260 @@
     }
   }
 
+  @Test
+  public void nonActiveProjectCanBeMadeActiveByHostAdmin() throws Exception {
+    // ACTIVE => HIDDEN
+    ConfigInput ci1 = new ConfigInput();
+    ci1.state = ProjectState.HIDDEN;
+    gApi.projects().name(project.get()).config(ci1);
+    assertThat(gApi.projects().name(project.get()).config().state).isEqualTo(ProjectState.HIDDEN);
+
+    // Revoke OWNER permission for admin and block them from reading the project's refs
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            block(Permission.OWNER)
+                .ref(RefNames.REFS + "*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .add(
+            block(Permission.READ)
+                .ref(RefNames.REFS + "*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
+
+    // HIDDEN => ACTIVE
+    ConfigInput ci2 = new ConfigInput();
+    ci2.state = ProjectState.ACTIVE;
+    gApi.projects().name(project.get()).config(ci2);
+    // ACTIVE is represented as null in the API
+    assertThat(gApi.projects().name(project.get()).config().state).isNull();
+  }
+
+  @Test
+  public void reindexProject() throws Exception {
+    projectOperations.newProject().parent(project).create();
+    projectIndexedCounter.clear();
+
+    gApi.projects().name(allProjects.get()).index(false);
+    projectIndexedCounter.assertReindexOf(allProjects.get());
+  }
+
+  @Test
+  public void reindexProjectWithChildren() throws Exception {
+    Project.NameKey middle = projectOperations.newProject().parent(project).create();
+    Project.NameKey leave = projectOperations.newProject().parent(middle).create();
+    projectIndexedCounter.clear();
+
+    gApi.projects().name(project.get()).index(true);
+    projectIndexedCounter.assertReindexExactly(
+        ImmutableMap.of(project.get(), 1L, middle.get(), 1L, leave.get(), 1L));
+  }
+
+  @Test
+  public void maxObjectSizeIsNotSetByDefault() throws Exception {
+    ConfigInfo info = getConfig();
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeCanBeSetAndCleared() throws Exception {
+    // Set a value
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    // Clear the value
+    info = setMaxObjectSize("0");
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeIsInheritedFromParentProject() throws Exception {
+    Project.NameKey child = projectOperations.newProject().parent(project).create();
+
+    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 = projectOperations.newProject().parent(project).create();
+
+    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 = projectOperations.newProject().parent(project).create();
+
+    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 = projectOperations.newProject().parent(project).create();
+
+    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 = projectOperations.newProject().parent(project).create();
+
+    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 = projectOperations.newProject().parent(project).create();
+
+    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 = projectOperations.newProject().parent(project).create();
+
+    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 = projectOperations.newProject().parent(project).create();
+
+    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 {
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> setMaxObjectSize("100 foo"));
+    assertThat(thrown).hasMessageThat().contains("100 foo");
+  }
+
+  @Test
+  public void noCommentlinksByDefault() throws Exception {
+    assertThat(getConfig().commentlinks).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "commentlink.bugzilla.match", value = BUGZILLA_MATCH)
+  @GerritConfig(name = "commentlink.bugzilla.link", value = BUGZILLA_LINK)
+  @GerritConfig(name = "commentlink.jira.match", value = JIRA_MATCH)
+  @GerritConfig(name = "commentlink.jira.link", value = JIRA_LINK)
+  public void projectConfigUsesCommentlinksFromGlobalConfig() throws Exception {
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
+    return new CommentLinkInfoImpl(name, match, link, null /*html*/, null /*enabled*/);
+  }
+
+  private void assertCommentLinks(ConfigInfo actual, Map<String, CommentLinkInfo> expected) {
+    assertThat(actual.commentlinks).containsExactlyEntriesIn(expected);
+  }
+
+  private ConfigInfo setConfig(Project.NameKey name, ConfigInput input) throws Exception {
+    return gApi.projects().name(name.get()).config(input);
+  }
+
+  private ConfigInfo getConfig(Project.NameKey name) throws Exception {
+    return gApi.projects().name(name.get()).config();
+  }
+
+  private ConfigInfo getConfig() throws Exception {
+    return getConfig(project);
+  }
+
   private ConfigInput createTestConfigInput() {
     ConfigInput input = new ConfigInput();
     input.description = "some description";
@@ -398,6 +693,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();
 
@@ -410,22 +715,31 @@
       countsByProject.clear();
     }
 
-    long getCount(String projectName) {
-      return countsByProject.get(projectName);
-    }
-
     void assertReindexOf(String projectName) {
       assertReindexOf(projectName, 1);
     }
 
-    void assertReindexOf(String projectName, int expectedCount) {
-      assertThat(getCount(projectName)).isEqualTo(expectedCount);
-      assertThat(countsByProject).hasSize(1);
+    void assertReindexOf(String projectName, long expectedCount) {
+      assertThat(countsByProject.asMap()).containsExactly(projectName, expectedCount);
       clear();
     }
 
     void assertNoReindex() {
-      assertThat(countsByProject).isEmpty();
+      assertThat(countsByProject.asMap()).isEmpty();
     }
+
+    void assertReindexExactly(ImmutableMap<String, Long> expected) {
+      assertThat(countsByProject.asMap()).containsExactlyEntriesIn(expected);
+      clear();
+    }
+  }
+
+  @Nullable
+  protected RevCommit getRemoteHead(String project, String branch) throws Exception {
+    return projectOperations.project(Project.nameKey(project)).getHead(branch);
+  }
+
+  boolean hasHead(Project.NameKey k, String b) {
+    return projectOperations.project(k).hasHead(b);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
new file mode 100644
index 0000000..5892536
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.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.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.project.ProjectField;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.index.project.ProjectIndexer;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.index.project.StalenessChecker;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Ref;
+import org.junit.Test;
+
+public class ProjectIndexerIT extends AbstractDaemonTest {
+  @Inject private ProjectIndexer projectIndexer;
+  @Inject private ProjectIndexCollection indexes;
+  @Inject private IndexConfig indexConfig;
+  @Inject private StalenessChecker stalenessChecker;
+  @Inject private ProjectOperations projectOperations;
+
+  private static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+
+  @Test
+  public void indexProject_indexesRefStateOfProjectAndParents() throws Exception {
+    projectIndexer.index(project);
+    ProjectIndex i = indexes.getSearchIndex();
+    assertThat(i.getSchema().hasField(ProjectField.REF_STATE)).isTrue();
+
+    Optional<FieldBundle> result =
+        i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
+
+    assertThat(result.isPresent()).isTrue();
+    Iterable<byte[]> refState = result.get().getValue(ProjectField.REF_STATE);
+    assertThat(refState).isNotEmpty();
+
+    Map<Project.NameKey, Collection<RefState>> states = RefState.parseStates(refState).asMap();
+
+    fetch(testRepo, "refs/meta/config:refs/meta/config");
+    Ref projectConfigRef = testRepo.getRepository().exactRef("refs/meta/config");
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+    fetch(allProjectsRepo, "refs/meta/config:refs/meta/config");
+    Ref allProjectConfigRef = allProjectsRepo.getRepository().exactRef("refs/meta/config");
+    assertThat(states)
+        .containsExactly(
+            project,
+            ImmutableSet.of(RefState.of(projectConfigRef)),
+            allProjects,
+            ImmutableSet.of(RefState.of(allProjectConfigRef)));
+  }
+
+  @Test
+  public void stalenessChecker_currentProject_notStale() throws Exception {
+    assertThat(stalenessChecker.isStale(project)).isFalse();
+  }
+
+  @Test
+  public void stalenessChecker_currentProjectUpdates_isStale() throws Exception {
+    updateProjectConfigWithoutIndexUpdate(project);
+    assertThat(stalenessChecker.isStale(project)).isTrue();
+  }
+
+  @Test
+  public void stalenessChecker_parentProjectUpdates_isStale() throws Exception {
+    updateProjectConfigWithoutIndexUpdate(allProjects);
+    assertThat(stalenessChecker.isStale(project)).isTrue();
+  }
+
+  @Test
+  public void stalenessChecker_hierarchyChange_isStale() throws Exception {
+    Project.NameKey p1 = projectOperations.newProject().create();
+    Project.NameKey p2 = projectOperations.newProject().create();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getProject().setParentName(p1);
+      u.save();
+    }
+    assertThat(stalenessChecker.isStale(project)).isFalse();
+
+    updateProjectConfigWithoutIndexUpdate(p1, c -> c.getProject().setParentName(p2));
+    assertThat(stalenessChecker.isStale(project)).isTrue();
+  }
+
+  private void updateProjectConfigWithoutIndexUpdate(Project.NameKey project) throws Exception {
+    updateProjectConfigWithoutIndexUpdate(
+        project, c -> c.getProject().setDescription("making it stale"));
+  }
+
+  private void updateProjectConfigWithoutIndexUpdate(
+      Project.NameKey project, Consumer<ProjectConfig> update) throws Exception {
+    assertThrows(
+        UnsupportedOperationException.class,
+        () -> {
+          try (AutoCloseable ignored = disableProjectIndex()) {
+            try (ProjectConfigUpdate u = updateProject(project)) {
+              update.accept(u.getConfig());
+              u.save();
+            }
+          }
+        });
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index ec4f327..82eef1d 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -15,31 +15,77 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+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 com.google.inject.Inject;
 import org.junit.Test;
 
 @NoHttpd
 public class SetParentIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void setParentNotAllowed() throws Exception {
-    String parent = createProject("parent", null, true).get();
-    setApiUser(user);
-    exception.expect(AuthException.class);
+    String parent = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).parent(parent));
+  }
+
+  @Test
+  @GerritConfig(name = "receive.allowProjectOwnersToChangeParent", value = "true")
+  public void setParentNotAllowedForNonOwners() throws Exception {
+    String parent = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).parent(parent));
+  }
+
+  @Test
+  @GerritConfig(name = "receive.allowProjectOwnersToChangeParent", value = "true")
+  public void setParentAllowedByAdminWhenAllowProjectOwnersEnabled() throws Exception {
+    String parent = projectOperations.newProject().create().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 = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
+    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();
+    String parent = projectOperations.newProject().create().get();
 
     gApi.projects().name(project.get()).parent(parent);
     assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
@@ -53,57 +99,74 @@
 
   @Test
   public void setParentForAllProjectsNotAllowed() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot set parent of " + AllProjectsNameProvider.DEFAULT);
-    gApi.projects().name(allProjects.get()).parent(project.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(allProjects.get()).parent(project.get()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("cannot set parent of " + AllProjectsNameProvider.DEFAULT);
   }
 
   @Test
   public void setParentToSelfNotAllowed() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot set parent to self");
-    gApi.projects().name(project.get()).parent(project.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(project.get()));
+    assertThat(thrown).hasMessageThat().contains("cannot set parent to self");
   }
 
   @Test
   public void setParentToOwnChildNotAllowed() throws Exception {
-    String child = createProject("child", project, true).get();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cycle exists between");
-    gApi.projects().name(project.get()).parent(child);
+    String child = projectOperations.newProject().parent(project).create().get();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(child));
+    assertThat(thrown).hasMessageThat().contains("cycle exists between");
   }
 
   @Test
   public void setParentToGrandchildNotAllowed() throws Exception {
-    Project.NameKey child = createProject("child", project, true);
-    String grandchild = createProject("grandchild", child, true).get();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cycle exists between");
-    gApi.projects().name(project.get()).parent(grandchild);
+    Project.NameKey child = projectOperations.newProject().parent(project).create();
+    String grandchild = projectOperations.newProject().parent(child).create().get();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(grandchild));
+    assertThat(thrown).hasMessageThat().contains("cycle exists between");
   }
 
   @Test
   public void setParentToNonexistentProject() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    gApi.projects().name(project.get()).parent("non-existing");
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).parent("non-existing"));
+    assertThat(thrown).hasMessageThat().contains("not found");
   }
 
   @Test
   public void setParentToAllUsersNotAllowed() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("Cannot inherit from '%s' project", allUsers.get()));
-    gApi.projects().name(project.get()).parent(allUsers.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(allUsers.get()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Cannot inherit from '%s' project", allUsers.get()));
   }
 
   @Test
   public void setParentForAllUsersMustBeAllProjects() throws Exception {
     gApi.projects().name(allUsers.get()).parent(allProjects.get());
 
-    String parent = createProject("parent", null, true).get();
+    String parent = projectOperations.newProject().create().get();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("All-Users must inherit from All-Projects");
-    gApi.projects().name(allUsers.get()).parent(parent);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allUsers.get()).parent(parent));
+    assertThat(thrown).hasMessageThat().contains("All-Users must inherit from All-Projects");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 4c8f53f..aaa698a 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.FileInfoSubject.assertThat;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toMap;
@@ -32,6 +33,10 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.FileInfo;
@@ -112,16 +117,115 @@
   }
 
   @Test
-  public void diffDeletedFile() throws Exception {
+  public void deletedFileIsIncludedInDiff() throws Exception {
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
     gApi.changes().id(changeId).edit().publish();
 
     Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
     assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
 
-    DiffInfo diff = getDiffRequest(changeId, CURRENT, FILE_NAME).get();
-    assertThat(diff.metaA.lines).isEqualTo(100);
-    assertThat(diff.metaB).isNull();
+  @Test
+  public void numberOfLinesInDiffOfDeletedFileWithoutNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
+    assertThat(diffInfo).metaB().isNull();
+  }
+
+  @Test
+  public void numberOfLinesInFileInfoOfDeletedFileWithoutNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(filePath)).linesInserted().isNull();
+    assertThat(changedFiles.get(filePath)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void numberOfLinesInDiffOfDeletedFileWithNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diffInfo).metaB().isNull();
+  }
+
+  @Test
+  public void numberOfLinesInFileInfoOfDeletedFileWithNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(filePath)).linesInserted().isNull();
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(filePath)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void deletedFileWithoutNewlineAtEndResultsInOneDiffEntry() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .linesOfA()
+        .containsExactly("Line 1", "Line 2", "Line 3");
+  }
+
+  @Test
+  public void deletedFileWithNewlineAtEndResultsInOneDiffEntry() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .linesOfA()
+        .containsExactly("Line 1", "Line 2", "Line 3", "");
   }
 
   @Test
@@ -136,6 +240,91 @@
   }
 
   @Test
+  public void numberOfLinesInDiffOfAddedFileWithoutNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).metaA().isNull();
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
+  }
+
+  @Test
+  public void numberOfLinesInFileInfoOfAddedFileWithoutNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(filePath)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(filePath)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void numberOfLinesInDiffOfAddedFileWithNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).metaA().isNull();
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
+  }
+
+  @Test
+  public void numberOfLinesInFileInfoOfAddedFileWithNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(filePath)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(filePath)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void addedFileWithoutNewlineAtEndResultsInOneDiffEntry() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .linesOfB()
+        .containsExactly("Line 1", "Line 2", "Line 3");
+  }
+
+  @Test
+  public void addedFileWithNewlineAtEndResultsInOneDiffEntry() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .linesOfB()
+        .containsExactly("Line 1", "Line 2", "Line 3", "");
+  }
+
+  @Test
   public void renamedFileIsIncludedInDiff() throws Exception {
     String newFilePath = "a_new_file.txt";
     gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
@@ -193,11 +382,11 @@
 
     // automerge
     diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").get();
-    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaA.lines).isEqualTo(6);
     assertThat(diff.metaB.lines).isEqualTo(1);
 
     diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").get();
-    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaA.lines).isEqualTo(6);
     assertThat(diff.metaB.lines).isEqualTo(1);
 
     // parent 1
@@ -212,6 +401,596 @@
   }
 
   @Test
+  public void diffOfUnmodifiedFileMarksAllLinesAsCommon() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().modifyCommitMessage("An unchanged patchset");
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .commonLines()
+        .containsExactly("Line 1", "Line 2", "Line 3", "")
+        .inOrder();
+    assertThat(diffInfo).content().onlyElement().linesOfA().isNull();
+    assertThat(diffInfo).content().onlyElement().linesOfB().isNull();
+  }
+
+  @Test
+  public void diffOfUnmodifiedFileWithNewlineAtEndHasEmptyLineAtEnd() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().modifyCommitMessage("An unchanged patchset");
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().onlyElement().commonLines().lastElement().isEqualTo("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
+  }
+
+  @Test
+  public void diffOfUnmodifiedFileWithoutNewlineAtEndEndsWithLastLineContent() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().modifyCommitMessage("An unchanged patchset");
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().onlyElement().commonLines().lastElement().isEqualTo("Line 3");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
+  }
+
+  @Test
+  public void diffOfModifiedFileWithNewlineAtEndHasEmptyLineAtEnd() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().lastElement().commonLines().lastElement().isEqualTo("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
+  }
+
+  @Test
+  public void diffOfModifiedFileWithoutNewlineAtEndEndsWithLastLineContent() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().lastElement().commonLines().lastElement().isEqualTo("Line 3");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
+  }
+
+  @Test
+  public void diffOfModifiedLastLineWithNewlineAtEndHasEmptyLineAtEnd() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3\n", "Line three\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().lastElement().commonLines().lastElement().isEqualTo("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
+  }
+
+  @Test
+  public void diffOfModifiedLastLineWithoutNewlineAtEndEndsWithLastLineContent() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3", "Line three"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().lastElement().linesOfA().containsExactly("Line 3");
+    assertThat(diffInfo).content().lastElement().linesOfB().containsExactly("Line three");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
+  }
+
+  @Test
+  public void addedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsConsidered() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .withBase(previousPatchSetId)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101", "");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
+  }
+
+  @Test
+  public void addedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsIgnored() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_ALL)
+            .withBase(previousPatchSetId)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().isNull();
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
+  }
+
+  @Test
+  public void addedNewlineAtEndOfFileMeansOneModifiedLine() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void addedLastLineWithoutNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .withBase(previousPatchSetId)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101", "Line 102");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
+  }
+
+  @Test
+  public void addedLastLineWithoutNewlineBeforeAndAfterwardsMeansTwoModifiedLines()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void addedLastLineWithoutNewlineBeforeButWithOneAfterwardsIsMarkedInDiff()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .withBase(previousPatchSetId)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line 101", "Line 102", "");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(103);
+  }
+
+  @Test
+  public void addedLastLineWithoutNewlineBeforeButWithOneAfterwardsMeansTwoModifiedLines()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102\n"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void addedLastLineWithNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().isNull();
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101");
+    assertThat(diffInfo).content().element(2).commonLines().containsExactly("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
+  }
+
+  @Test
+  public void addedLastLineWithNewlineBeforeAndAfterwardsMeansOneInsertedLine() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101\n"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void addedLastLineWithNewlineBeforeButWithoutOneAfterwardsIsMarkedInDiff()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(101);
+  }
+
+  @Test
+  public void addedLastLineWithNewlineBeforeButWithoutOneAfterwardsMeansOneInsertedLine()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void hunkForModifiedLastLineIsCombinedWithHunkForAddedNewlineAtEnd() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 101", "Line one oh one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line one oh one", "");
+  }
+
+  @Test
+  public void intralineEditsForModifiedLastLineArePreservedWhenNewlineIsAlsoAddedAtEnd()
+      throws Exception {
+    assume().that(intraline).isTrue();
+
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 101", "Line one oh one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .intralineEditsOfA()
+        .containsExactly(ImmutableList.of(5, 3));
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .intralineEditsOfB()
+        .containsExactly(ImmutableList.of(5, 11));
+  }
+
+  @Test
+  public void hunkForModifiedSecondToLastLineIsNotCombinedWithHunkForAddedNewlineAtEnd()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n").concat("\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(3).linesOfA().isNull();
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("");
+  }
+
+  @Test
+  public void deletedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsConsidered() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(initialPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100", "");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 100");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
+  }
+
+  @Test
+  public void deletedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsIgnored() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(initialPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_ALL)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("");
+    assertThat(diffInfo).content().element(1).linesOfB().isNull();
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
+  }
+
+  @Test
+  public void deletedNewlineAtEndOfFileMeansOneModifiedLine() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void deletedLastLineWithoutNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100", ""));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 99", "Line 100");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 99");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(100);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(99);
+  }
+
+  @Test
+  public void deletedLastLineWithoutNewlineBeforeAndAfterwardsMeansTwoModifiedLines()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100", ""));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void deletedLastLineWithoutNewlineBeforeButWithOneAfterwardsIsMarkedInDiff()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100", ""));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(100);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
+  }
+
+  @Test
+  public void deletedLastLineWithoutNewlineBeforeButWithOneAfterwardsMeansOneDeletedLine()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100", ""));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void deletedLastLineWithNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", ""));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(initialPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(1).linesOfB().isNull();
+    assertThat(diffInfo).content().element(2).commonLines().containsExactly("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
+  }
+
+  @Test
+  public void deletedLastLineWithNewlineBeforeAndAfterwardsMeansOneDeletedLine() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", ""));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void deletedLastLineWithNewlineBeforeButWithoutOneAfterwardsIsMarkedInDiff()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100\n", ""));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(initialPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 99", "Line 100", "");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 99");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(99);
+  }
+
+  @Test
+  public void deletedLastLineWithNewlineBeforeButWithoutOneAfterwardsMeansTwoModifiedLines()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100\n", ""));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void hunkForModifiedLastLineIsCombinedWithHunkForDeletedNewlineAtEnd() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line one hundred"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100", "");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line one hundred");
+  }
+
+  @Test
+  public void intralineEditsForModifiedLastLineArePreservedWhenNewlineIsAlsoDeletedAtEnd()
+      throws Exception {
+    assume().that(intraline).isTrue();
+
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line one hundred"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .intralineEditsOfA()
+        .containsExactly(ImmutableList.of(5, 4));
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .intralineEditsOfB()
+        .containsExactly(ImmutableList.of(5, 11));
+  }
+
+  @Test
+  public void hunkForModifiedSecondToLastLineIsNotCombinedWithHunkForDeletedNewlineAtEnd()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        fileContent ->
+            fileContent
+                .replace("Line 99\n", "Line ninety-nine\n")
+                .replace("Line 100\n", "Line 100"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 99");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line ninety-nine");
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("");
+    assertThat(diffInfo).content().element(3).linesOfB().isNull();
+  }
+
+  @Test
   public void addedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
     ObjectId commit2 = addCommit(commit1, "file_added_in_another_commit.txt", "Some file content");
 
@@ -366,6 +1145,102 @@
   }
 
   @Test
+  public void singleHunkAtBeginningIsFollowedByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(0).linesOfB().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .commonLines()
+        .containsExactly("Line 2", "Line 3", "Line 4", "Line 5", "")
+        .inOrder();
+  }
+
+  @Test
+  public void singleHunkAtEndIsPrecededByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 5\n", "Line five\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("Line 1", "Line 2", "Line 3", "Line 4")
+        .inOrder();
+    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
+  }
+
+  @Test
+  public void singleHunkInTheMiddleIsSurroundedByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3\n", "Line three\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("Line 1", "Line 2")
+        .inOrder();
+    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 4", "Line 5", "Line 6", "")
+        .inOrder();
+  }
+
+  @Test
+  public void twoHunksAreSeparatedByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId,
+        filePath,
+        content -> content.replace("Line 2\n", "Line two\n").replace("Line 5\n", "Line five\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 3", "Line 4")
+        .inOrder();
+    assertThat(diffInfo).content().element(3).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(3).linesOfB().isNotEmpty();
+  }
+
+  @Test
   public void rebaseHunksAtStartOfFileAreIdentified() throws Exception {
     String newFileContent =
         FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
@@ -381,15 +1256,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(2).isDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(4).isNotDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -412,15 +1287,15 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(49);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(5).isDueToRebase();
@@ -450,15 +1325,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(2).isDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 45");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty five");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(54);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(6).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(6).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(6).isNotDueToRebase();
@@ -489,11 +1364,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(41);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
@@ -522,7 +1397,7 @@
     assertThat(diffInfo).content().element(0).linesOfA().isNull();
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line zero");
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10");
     assertThat(diffInfo)
         .content()
@@ -530,11 +1405,11 @@
         .linesOfB()
         .containsExactly("Line ten", "Line ten and a half");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -562,11 +1437,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(37);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
@@ -595,15 +1470,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().isNull();
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10", "Line 11");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line ten");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(28);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -623,7 +1498,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo)
         .content()
@@ -631,7 +1506,7 @@
         .linesOfB()
         .containsExactly("Line modified after rebase");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -655,7 +1530,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 39", "Line 40");
     assertThat(diffInfo)
         .content()
@@ -663,7 +1538,7 @@
         .linesOfB()
         .containsExactly("Line thirty nine", "Line forty");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -687,7 +1562,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
     assertThat(diffInfo)
         .content()
@@ -695,7 +1570,7 @@
         .linesOfB()
         .containsExactly("Line forty one", "Line forty two");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(58);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -717,7 +1592,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo)
         .content()
         .element(1)
@@ -729,7 +1604,7 @@
         .linesOfB()
         .containsExactly("Line thirty nine", "A different line forty", "Line forty one");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -755,7 +1630,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
     assertThat(diffInfo)
         .content()
@@ -763,11 +1638,11 @@
         .linesOfB()
         .containsExactly("Line forty one", "Line forty two", "Line forty two and a half");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(17);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -791,15 +1666,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 3");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line three");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(95);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -834,15 +1709,15 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(7);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 10");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line ten");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(90);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -879,19 +1754,19 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line seven");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 7");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 9");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line nine");
     assertThat(diffInfo).content().element(5).isNotDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 18", "Line 19");
     assertThat(diffInfo)
         .content()
@@ -899,15 +1774,15 @@
         .linesOfB()
         .containsExactly("Line eighteen", "Line nineteen");
     assertThat(diffInfo).content().element(7).isDueToRebase();
-    assertThat(diffInfo).content().element(8).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(8).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(9).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(9).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(9).isDueToRebase();
-    assertThat(diffInfo).content().element(10).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(10).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(11).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(11).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(11).isNotDueToRebase();
-    assertThat(diffInfo).content().element(12).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(12).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -931,7 +1806,7 @@
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
     assertThat(diffInfo).changeType().isEqualTo(ChangeType.DELETED);
-    assertThat(diffInfo).content().element(0).linesOfA().hasSize(100);
+    assertThat(diffInfo).content().element(0).linesOfA().hasSize(101);
     assertThat(diffInfo).content().element(0).linesOfB().isNull();
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
 
@@ -954,7 +1829,7 @@
         getDiffRequest(changeId, CURRENT, newFilePath).withBase(initialPatchSetId).get();
     assertThat(diffInfo).changeType().isEqualTo(ChangeType.ADDED);
     assertThat(diffInfo).content().element(0).linesOfA().isNull();
-    assertThat(diffInfo).content().element(0).linesOfB().hasSize(3);
+    assertThat(diffInfo).content().element(0).linesOfB().hasSize(4);
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
 
     Map<String, FileInfo> changedFiles =
@@ -980,11 +1855,11 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -1015,15 +1890,15 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1059,11 +1934,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, newFilePath2).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1096,11 +1971,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, newFilePath3).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1125,19 +2000,19 @@
 
     DiffInfo renamedFileDiffInfo =
         getDiffRequest(changeId, CURRENT, renamedFileName).withBase(initialPatchSetId).get();
-    assertThat(renamedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(renamedFileDiffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(renamedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(renamedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(renamedFileDiffInfo).content().element(1).isDueToRebase();
-    assertThat(renamedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(renamedFileDiffInfo).content().element(2).commonLines().isNotEmpty();
 
     DiffInfo copiedFileDiffInfo =
         getDiffRequest(changeId, CURRENT, copyFileName).withBase(initialPatchSetId).get();
-    assertThat(copiedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(copiedFileDiffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(copiedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(copiedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(copiedFileDiffInfo).content().element(1).isDueToRebase();
-    assertThat(copiedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(copiedFileDiffInfo).content().element(2).commonLines().isNotEmpty();
   }
 
   /*
@@ -1168,23 +2043,23 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 20");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line twenty");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 35");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line thirty five");
     assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(24);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(7).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(7).isDueToRebase();
-    assertThat(diffInfo).content().element(8).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(8).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1239,19 +2114,19 @@
     String currentPatchSetId = gApi.changes().id(changeId).get().currentRevision;
     DiffInfo diffInfo =
         getDiffRequest(changeId, initialPatchSetId, FILE_NAME).withBase(currentPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line twenty");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 20");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().isNull();
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line 35");
     assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(65);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).revision(initialPatchSetId).files(currentPatchSetId);
@@ -1282,7 +2157,7 @@
         .intralineEditsOfB()
         .containsExactly(ImmutableList.of(5, 3));
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(99);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1312,11 +2187,11 @@
         .intralineEditsOfB()
         .containsExactly(ImmutableList.of(5, 3));
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1335,7 +2210,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
     assertThat(diffInfo)
         .content()
@@ -1343,7 +2218,7 @@
         .linesOfB()
         .containsExactly("Line four", "{", "Line six");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(94);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1372,19 +2247,19 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(13);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 20");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line twenty");
     assertThat(diffInfo).content().element(5).isNotDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(80);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1411,19 +2286,19 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 8");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line eight");
     assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(92);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1452,7 +2327,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
     assertThat(diffInfo)
         .content()
@@ -1460,11 +2335,11 @@
         .linesOfB()
         .containsExactly("Line four", "{", "Line six");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 8");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line eight!");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(92);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1472,6 +2347,166 @@
     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()
+        .containsAtLeast("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()
+        .containsAtLeast("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();
+  }
+
+  // This behavior is likely a bug. A fix might not be easy as it might break syntax highlighting.
+  // TODO: Fix this issue or remove the broken parameter (at least in the documentation).
+  @Test
+  public void contextParameterIsIgnored() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, content -> content.replace("Line 20\n", "Line twenty\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(initialPatchSetId)
+            .withContext(5)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(19);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 20");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line twenty");
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(81);
+  }
+
+  // This behavior is likely a bug. A fix might not be easy as it might break syntax highlighting.
+  // TODO: Fix this issue or remove the broken parameter (at least in the documentation).
+  @Test
+  public void contextParameterIsIgnoredForUnmodifiedFileWithComment() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, content -> content.replace("Line 20\n", "Line twenty\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    CommentInput comment = createCommentInput(20, 0, 21, 0, "Should be 'Line 20'.");
+    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(5)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(101);
+  }
+
+  @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 =
@@ -1487,7 +2522,7 @@
 
       RevCommit parentCommit = c.getParents()[0];
       String parentCommitId =
-          testRepo.getRevWalk().getObjectReader().abbreviate(parentCommit.getId(), 8).name();
+          abbreviateName(parentCommit, 8, testRepo.getRevWalk().getObjectReader());
       headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
 
       SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
@@ -1527,7 +2562,7 @@
       throws Exception {
     testRepo.reset(parentCommit);
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Adjust files of repo", files);
+        pushFactory.create(admin.newIdent(), testRepo, "Adjust files of repo", files);
     PushOneCommit.Result result = push.to("refs/for/master");
     return result.getCommit();
   }
@@ -1551,7 +2586,7 @@
         Arrays.stream(removedFilePaths)
             .collect(toMap(Function.identity(), path -> "Irrelevant content"));
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Remove files from repo", files);
+        pushFactory.create(admin.newIdent(), testRepo, "Remove files from repo", files);
     PushOneCommit.Result result = push.rm("refs/for/master");
     return result.getCommit();
   }
@@ -1570,7 +2605,7 @@
 
   private Result createEmptyChange() throws Exception {
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Test change", ImmutableMap.of());
+        pushFactory.create(admin.newIdent(), testRepo, "Test change", ImmutableMap.of());
     return push.to("refs/for/master");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 8a3d0f3..736c127 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -21,11 +21,14 @@
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
@@ -35,11 +38,14 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Iterators;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -59,6 +65,7 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.FileInfo;
@@ -80,7 +87,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.change.RevisionResource;
@@ -110,10 +117,11 @@
 import org.junit.Test;
 
 public class RevisionIT extends AbstractDaemonTest {
-
-  @Inject private GetRevisionActions getRevisionActions;
-  @Inject private DynamicSet<PatchSetWebLink> patchSetLinks;
   @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+  @Inject private DynamicSet<PatchSetWebLink> patchSetLinks;
+  @Inject private GetRevisionActions getRevisionActions;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void reviewTriplet() throws Exception {
@@ -176,14 +184,13 @@
     assertThat(approval.postSubmit).isNull();
 
     // Reducing vote is not allowed.
-    try {
-      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
-      fail("expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().review(ReviewInput.dislike()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
@@ -196,14 +203,13 @@
     assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 2);
 
     // Decreasing to previous post-submit vote is still not allowed.
-    try {
-      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
-      fail("expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
-    }
+    thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().review(ReviewInput.dislike()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(2);
     assertThat(approval.postSubmit).isTrue();
@@ -214,42 +220,32 @@
     PushOneCommit.Result r = createChange();
     String changeId = project.get() + "~master~" + r.getChangeId();
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     revision(r).review(ReviewInput.approve());
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     revision(r).review(ReviewInput.recommend());
 
-    setApiUser(admin);
-    gApi.changes().id(changeId).reviewer(user.username).deleteVote("Code-Review");
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(changeId).reviewer(user.username()).deleteVote("Code-Review");
     Optional<ApprovalInfo> crUser =
-        get(changeId, DETAILED_LABELS)
-            .labels
-            .get("Code-Review")
-            .all
-            .stream()
-            .filter(a -> a._accountId == user.id.get())
+        get(changeId, DETAILED_LABELS).labels.get("Code-Review").all.stream()
+            .filter(a -> a._accountId == user.id().get())
             .findFirst();
     assertThat(crUser).isPresent();
     assertThat(crUser.get().value).isEqualTo(0);
 
     revision(r).submit();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     ReviewInput in = new ReviewInput();
     in.label("Code-Review", 1);
     in.message = "Still LGTM";
     revision(r).review(in);
 
     ApprovalInfo cr =
-        gApi.changes()
-            .id(changeId)
-            .get(DETAILED_LABELS)
-            .labels
-            .get("Code-Review")
-            .all
-            .stream()
-            .filter(a -> a._accountId == user.getId().get())
+        gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
+            .filter(a -> a._accountId == user.id().get())
             .findFirst()
             .get();
     assertThat(cr.postSubmit).isTrue();
@@ -265,9 +261,11 @@
     ReviewInput in = new ReviewInput();
     in.label("Code-Review", 0);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cannot reduce vote on labels for closed change: Code-Review");
-    revision(r).review(in);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision(r).review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot reduce vote on labels for closed change: Code-Review");
   }
 
   @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
@@ -283,28 +281,36 @@
     PatchSetApproval psa =
         Iterators.getOnlyElement(
             cd.currentApprovals().stream().filter(a -> !a.isLegacySubmit()).iterator());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(2);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo(2);
-    assertThat(psa.isPostSubmit()).isFalse();
+    assertThat(psa.patchSetId().get()).isEqualTo(2);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.value()).isEqualTo(2);
+    assertThat(psa.postSubmit()).isFalse();
   }
 
   @Test
   public void voteOnAbandonedChange() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).abandon();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is closed");
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject()));
+    assertThat(thrown).hasMessageThat().contains("change is closed");
   }
 
   @Test
   public void voteNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("is restricted");
-    gApi.changes().id(r.getChange().getId().get()).current().review(ReviewInput.approve());
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChange().getId().get())
+                    .current()
+                    .review(ReviewInput.approve()));
+    assertThat(thrown).hasMessageThat().contains("is restricted");
   }
 
   @Test
@@ -317,27 +323,16 @@
     ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
 
     assertThat(orig.get().messages).hasSize(1);
-    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    CherryPickChangeInfo changeInfo = orig.revision(r.getCommit().name()).cherryPickAsInfo(in);
+    assertThat(changeInfo.containsGitConflicts).isNull();
+    assertThat(changeInfo.workInProgress).isNull();
+    ChangeApi cherry = gApi.changes().id(changeInfo._number);
 
-    Collection<ChangeMessageInfo> messages =
-        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
-    assertThat(messages).hasSize(2);
-
-    String cherryPickedRevision = cherry.get().currentRevision;
-    String expectedMessage =
-        String.format(
-            "Patch Set 1: Cherry Picked\n\n"
-                + "This patchset was cherry picked to branch %s as commit %s",
-            in.destination, cherryPickedRevision);
-
-    Iterator<ChangeMessageInfo> origIt = messages.iterator();
-    origIt.next();
-    assertThat(origIt.next().message).isEqualTo(expectedMessage);
-
-    assertThat(cherry.get().messages).hasSize(1);
-    Iterator<ChangeMessageInfo> cherryIt = cherry.get().messages.iterator();
-    expectedMessage = "Patch Set 1: Cherry Picked from branch master.";
-    assertThat(cherryIt.next().message).isEqualTo(expectedMessage);
+    ChangeInfo cherryPickChangeInfoWithDetails = cherry.get();
+    assertThat(cherryPickChangeInfoWithDetails.workInProgress).isNull();
+    assertThat(cherryPickChangeInfoWithDetails.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> cherryIt = cherryPickChangeInfoWithDetails.messages.iterator();
+    assertThat(cherryIt.next().message).isEqualTo("Patch Set 1: Cherry Picked from branch master.");
 
     assertThat(cherry.get().subject).contains(in.message);
     assertThat(cherry.get().topic).isEqualTo("someTopic-foo");
@@ -368,7 +363,7 @@
   }
 
   @Test
-  public void cherryPickwithNoTopic() throws Exception {
+  public void cherryPickWithNoTopic() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
@@ -383,6 +378,19 @@
   }
 
   @Test
+  public void cherryPickWorkInProgressChange() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%wip");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "cherry pick message";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().workInProgress).isTrue();
+  }
+
+  @Test
   public void cherryPickToSameBranch() throws Exception {
     PushOneCommit.Result r = createChange();
     CherryPickInput in = new CherryPickInput();
@@ -416,7 +424,7 @@
     String subject = "Test change\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     PushOneCommit push =
         pushFactory.create(
-            db, admin.getIdent(), testRepo, subject, "another_file.txt", "another content");
+            admin.newIdent(), testRepo, subject, "another_file.txt", "another content");
     PushOneCommit.Result r2 = push.to("refs/for/master");
 
     // Change 2's parent should be change 1
@@ -454,17 +462,15 @@
     assertThat(orig.get().messages).hasSize(1);
     ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
 
-    Collection<ChangeMessageInfo> messages =
-        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
-    assertThat(messages).hasSize(2);
-
     assertThat(cherry.get().subject).contains(in.message);
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cherry pick failed: identical tree");
-    orig.revision(r.getCommit().name()).cherryPick(in);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> orig.revision(r.getCommit().name()).cherryPick(in));
+    assertThat(thrown).hasMessageThat().contains("Cherry pick failed: identical tree");
   }
 
   @Test
@@ -477,8 +483,7 @@
 
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             PushOneCommit.FILE_NAME,
@@ -489,16 +494,112 @@
     ChangeApi orig = gApi.changes().id(triplet);
     assertThat(orig.get().messages).hasSize(1);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cherry pick failed: merge conflict");
-    orig.revision(r.getCommit().name()).cherryPick(in);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> orig.revision(r.getCommit().name()).cherryPick(in));
+    assertThat(thrown).hasMessageThat().contains("Cherry pick failed: merge conflict");
+  }
+
+  @Test
+  public void cherryPickConflictWithAllowConflicts() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    // Create a branch and push a commit to it (by-passing review)
+    String destBranch = "foo";
+    gApi.projects().name(project.get()).branch(destBranch).create(new BranchInput());
+    String destContent = "some content";
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            ImmutableMap.of(PushOneCommit.FILE_NAME, destContent, "foo.txt", "foo"));
+    push.to("refs/heads/" + destBranch);
+
+    // Create a change on master with a commit that conflicts with the commit on the other branch.
+    testRepo.reset(initial);
+    String changeContent = "another content";
+    push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            ImmutableMap.of(PushOneCommit.FILE_NAME, changeContent, "bar.txt", "bar"));
+    PushOneCommit.Result r = push.to("refs/for/master%topic=someTopic");
+
+    // Verify before the cherry-pick that the change has exactly 1 message.
+    ChangeApi changeApi = gApi.changes().id(r.getChange().getId().get());
+    assertThat(changeApi.get().messages).hasSize(1);
+
+    // Cherry-pick the change to the other branch, that should fail with a conflict.
+    CherryPickInput in = new CherryPickInput();
+    in.destination = destBranch;
+    in.message = "Cherry-Pick";
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in));
+    assertThat(thrown).hasMessageThat().isEqualTo("Cherry pick failed: merge conflict");
+
+    // Cherry-pick with auto merge should succeed.
+    in.allowConflicts = true;
+    CherryPickChangeInfo cherryPickChange =
+        changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in);
+    assertThat(cherryPickChange.containsGitConflicts).isTrue();
+    assertThat(cherryPickChange.workInProgress).isTrue();
+
+    // Verify that subject and topic on the cherry-pick change have been correctly populated.
+    assertThat(cherryPickChange.subject).contains(in.message);
+    assertThat(cherryPickChange.topic).isEqualTo("someTopic-" + destBranch);
+
+    // Verify that the file content in the cherry-pick change is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin =
+        gApi.changes()
+            .id(cherryPickChange._number)
+            .current()
+            .file(PushOneCommit.FILE_NAME)
+            .content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String destSha1 = abbreviateName(projectOperations.project(project).getHead(destBranch), 6);
+    String changeSha1 = abbreviateName(r.getCommit(), 6);
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< HEAD   ("
+                + destSha1
+                + " test commit)\n"
+                + destContent
+                + "\n"
+                + "=======\n"
+                + changeContent
+                + "\n"
+                + ">>>>>>> CHANGE ("
+                + changeSha1
+                + " test commit)\n");
+
+    // Get details of cherry-pick change.
+    ChangeInfo cherryPickChangeWithDetails = gApi.changes().id(cherryPickChange._number).get();
+    assertThat(cherryPickChangeWithDetails.workInProgress).isTrue();
+
+    // Verify that a message has been posted on the cherry-pick change.
+    assertThat(cherryPickChangeWithDetails.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> cherryIt = cherryPickChangeWithDetails.messages.iterator();
+    assertThat(cherryIt.next().message)
+        .isEqualTo(
+            "Patch Set 1: Cherry Picked from branch master.\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + PushOneCommit.FILE_NAME);
   }
 
   @Test
   public void cherryPickToExistingChange() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "a")
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
             .to("refs/for/master");
     String t1 = project.get() + "~master~" + r1.getChangeId();
 
@@ -508,7 +609,7 @@
 
     PushOneCommit.Result r2 =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
             .to("refs/for/foo");
     String t2 = project.get() + "~foo~" + r2.getChangeId();
     gApi.changes().id(t2).abandon();
@@ -516,16 +617,15 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
     in.message = r1.getCommit().getFullMessage();
-    try {
-      gApi.changes().id(t1).current().cherryPick(in);
-      fail("expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e.getMessage())
-          .isEqualTo(
-              "Cannot create new patch set of change "
-                  + info(t2)._number
-                  + " because it is abandoned");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(t1).current().cherryPick(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Cannot create new patch set of change "
+                + info(t2)._number
+                + " because it is abandoned");
 
     gApi.changes().id(t2).restore();
     gApi.changes().id(t1).current().cherryPick(in);
@@ -541,7 +641,7 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
@@ -568,7 +668,7 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
@@ -596,17 +696,24 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
     cherryPickInput.message = "Cherry-pick a merge commit to another branch";
     cherryPickInput.parent = 0;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "Cherry Pick: Parent 0 does not exist. Please specify a parent in range [1, 2].");
-    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(mergeChangeResult.getChangeId())
+                    .current()
+                    .cherryPick(cherryPickInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cherry Pick: Parent 0 does not exist. Please specify a parent in range [1, 2].");
   }
 
   @Test
@@ -617,24 +724,31 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
     cherryPickInput.message = "Cherry-pick a merge commit to another branch";
     cherryPickInput.parent = 3;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "Cherry Pick: Parent 3 does not exist. Please specify a parent in range [1, 2].");
-    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(mergeChangeResult.getChangeId())
+                    .current()
+                    .cherryPick(cherryPickInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cherry Pick: Parent 3 does not exist. Please specify a parent in range [1, 2].");
   }
 
   @Test
   public void cherryPickNotify() throws Exception {
-    createBranch(new Branch.NameKey(project, "branch-1"));
-    createBranch(new Branch.NameKey(project, "branch-2"));
-    createBranch(new Branch.NameKey(project, "branch-3"));
+    createBranch(BranchNameKey.create(project, "branch-1"));
+    createBranch(BranchNameKey.create(project, "branch-2"));
+    createBranch(BranchNameKey.create(project, "branch-3"));
 
     // Creates a change for 'admin'.
     PushOneCommit.Result result = createChange();
@@ -642,7 +756,7 @@
 
     // 'user' cherry-picks the change to a new branch, the source change's author/committer('admin')
     // will be added as a reviewer of the newly created change.
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     CherryPickInput input = new CherryPickInput();
     input.message = "it goes to a new branch";
 
@@ -651,7 +765,7 @@
     input.notify = NotifyHandling.ALL;
     sender.clear();
     gApi.changes().id(changeId).current().cherryPick(input);
-    assertNotifyCc(admin);
+    assertNotifyTo(admin);
 
     // Disable the notification. 'admin' as a reviewer should not be notified any more.
     input.destination = "branch-2";
@@ -665,7 +779,7 @@
     input.destination = "branch-3";
     input.notify = NotifyHandling.NONE;
     input.notifyDetails =
-        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email())));
     sender.clear();
     gApi.changes().id(changeId).current().cherryPick(input);
     assertNotifyTo(userToNotify);
@@ -673,18 +787,18 @@
 
   @Test
   public void cherryPickKeepReviewers() throws Exception {
-    createBranch(new Branch.NameKey(project, "stable"));
+    createBranch(BranchNameKey.create(project, "stable"));
 
     // Change is created by 'admin'.
     PushOneCommit.Result r = createChange();
     // Change is approved by 'admin2'. Change is CC'd to 'user'.
-    setApiUser(accountCreator.admin2());
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
     ReviewInput in = ReviewInput.approve();
-    in.reviewer(user.email, ReviewerState.CC, true);
+    in.reviewer(user.email(), ReviewerState.CC, true);
     gApi.changes().id(r.getChangeId()).current().review(in);
 
     // Change is cherrypicked by 'user2'.
-    setApiUser(accountCreator.user2());
+    requestScopeOperations.setApiUser(accountCreator.user2().id());
     CherryPickInput cin = new CherryPickInput();
     cin.message = "this need to go to stable";
     cin.destination = "stable";
@@ -698,21 +812,16 @@
     assertThat(result).containsKey(ReviewerState.REVIEWER);
     List<Integer> reviewers =
         result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
-    if (notesMigration.readChanges()) {
-      assertThat(result).containsKey(ReviewerState.CC);
-      List<Integer> ccs =
-          result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
-      assertThat(ccs).containsExactly(user.id.get());
-      assertThat(reviewers).containsExactly(admin.id.get(), accountCreator.admin2().id.get());
-    } else {
-      assertThat(reviewers)
-          .containsExactly(user.id.get(), admin.id.get(), accountCreator.admin2().id.get());
-    }
+    assertThat(result).containsKey(ReviewerState.CC);
+    List<Integer> ccs =
+        result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+    assertThat(ccs).containsExactly(user.id().get());
+    assertThat(reviewers).containsExactly(admin.id().get(), accountCreator.admin2().id().get());
   }
 
   @Test
   public void cherryPickToMergedChangeRevision() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
     dstChange.assertOkStatus();
@@ -736,7 +845,7 @@
 
   @Test
   public void cherryPickToOpenChangeRevision() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
     dstChange.assertOkStatus();
@@ -754,7 +863,7 @@
 
   @Test
   public void cherryPickToNonVisibleChangeFails() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
     dstChange.assertOkStatus();
@@ -768,11 +877,14 @@
     input.base = dstChange.getCommit().name();
     input.message = srcChange.getCommit().getFullMessage();
 
-    setApiUser(user);
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage(
-        String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
-    gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+    requestScopeOperations.setApiUser(user.id());
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
   }
 
   @Test
@@ -786,12 +898,16 @@
     input.base = change2.getCommit().name();
     input.message = change1.getCommit().getFullMessage();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "Change %s with commit %s is %s",
-            change2.getChange().getId().get(), input.base, ChangeStatus.ABANDONED));
-    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(change1.getChangeId()).current().cherryPick(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "Change %s with commit %s is abandoned",
+                change2.getChange().getId().get(), input.base));
   }
 
   @Test
@@ -803,9 +919,13 @@
     input.base = "invalid-sha1";
     input.message = change1.getCommit().getFullMessage();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(String.format("Base %s doesn't represent a valid SHA-1", input.base));
-    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(change1.getChangeId()).current().cherryPick(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Base %s doesn't represent a valid SHA-1", input.base));
   }
 
   @Test
@@ -828,11 +948,11 @@
 
   @Test
   public void canRebase() throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     merge(r1);
 
-    push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r2 = push.to("refs/for/master");
     boolean canRebase =
         gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).canRebase();
@@ -840,7 +960,7 @@
     merge(r2);
 
     testRepo.reset(r1.getCommit());
-    push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r3 = push.to("refs/for/master");
 
     canRebase = gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).canRebase();
@@ -849,7 +969,7 @@
 
   @Test
   public void setUnsetReviewedFlag() throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
 
     gApi.changes().id(r.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, true);
@@ -863,13 +983,27 @@
   }
 
   @Test
+  public void setUnsetReviewedFlagByFileApi() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    gApi.changes().id(r.getChangeId()).current().file(PushOneCommit.FILE_NAME).setReviewed(true);
+
+    assertThat(Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).current().reviewed()))
+        .isEqualTo(PushOneCommit.FILE_NAME);
+
+    gApi.changes().id(r.getChangeId()).current().file(PushOneCommit.FILE_NAME).setReviewed(false);
+
+    assertThat(gApi.changes().id(r.getChangeId()).current().reviewed()).isEmpty();
+  }
+
+  @Test
   public void mergeable() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
     PushOneCommit push1 =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             PushOneCommit.FILE_NAME,
@@ -884,8 +1018,7 @@
 
     PushOneCommit push2 =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             PushOneCommit.FILE_NAME,
@@ -901,8 +1034,8 @@
 
     // Make the same change in a separate commit and update server HEAD behind Gerrit's back, which
     // will not reindex any open changes.
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
       String ref = "refs/heads/master";
       assertThat(repo.exactRef(ref).getObjectId()).isEqualTo(r1.getCommit());
       tr.update(ref, tr.getRevWalk().parseCommit(initial));
@@ -920,6 +1053,7 @@
     CountDownLatch reindexed = new CountDownLatch(1);
     RegistrationHandle handle =
         changeIndexedListeners.add(
+            "gerrit",
             new ChangeIndexedListener() {
               @Override
               public void onChangeIndexed(String projectName, int id) {
@@ -1003,7 +1137,7 @@
   public void queryRevisionFiles() throws Exception {
     Map<String, String> files = ImmutableMap.of("file1.txt", "content 1", "file2.txt", "content 2");
     PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), testRepo, SUBJECT, files).to("refs/for/master");
+        pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
     result.assertOkStatus();
     String changeId = result.getChangeId();
 
@@ -1035,18 +1169,28 @@
   public void setDescriptionNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     assertDescription(r, "");
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit description not permitted");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .revision(r.getCommit().name())
+                    .description("test"));
+    assertThat(thrown).hasMessageThat().contains("edit description not permitted");
   }
 
   @Test
   public void setDescriptionAllowedWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     assertDescription(r, "");
-    grant(project, "refs/heads/master", Permission.OWNER, false, REGISTERED_USERS);
-    setApiUser(user);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
     assertDescription(r, "test");
   }
@@ -1085,13 +1229,7 @@
   public void commit() throws Exception {
     WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
     RegistrationHandle handle =
-        patchSetLinks.add(
-            new PatchSetWebLink() {
-              @Override
-              public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
-                return expectedWebLinkInfo;
-              }
-            });
+        patchSetLinks.add("gerrit", (projectName, commit) -> expectedWebLinkInfo);
 
     try {
       PushOneCommit.Result r = createChange();
@@ -1176,7 +1314,7 @@
                 .get()
                 .author
                 .email)
-        .isEqualTo(admin.email);
+        .isEqualTo(admin.email());
 
     draftApi.delete();
     assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).drafts())
@@ -1202,7 +1340,7 @@
     assertThat(out).hasSize(1);
     CommentInfo comment = Iterables.getOnlyElement(out.get(FILE_NAME));
     assertThat(comment.message).isEqualTo(in.message);
-    assertThat(comment.author.email).isEqualTo(admin.email);
+    assertThat(comment.author.email).isEqualTo(admin.email());
     assertThat(comment.path).isNull();
 
     List<CommentInfo> list =
@@ -1227,8 +1365,8 @@
 
   @Test
   public void commentOnNonExistingFile() throws Exception {
-    PushOneCommit.Result r = createChange();
-    r = updateChange(r, "new content");
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = updateChange(r1, "new content");
     CommentInput in = new CommentInput();
     in.line = 1;
     in.message = "nit: trailing whitespace";
@@ -1239,10 +1377,14 @@
     reviewInput.comments = comments;
     reviewInput.message = "comment test";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format("not found in revision %d,1", r.getChange().change().getId().id));
-    gApi.changes().id(r.getChangeId()).revision(1).review(reviewInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r2.getChangeId()).revision(1).review(reviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format("not found in revision %d,1", r2.getChange().change().getId().get()));
   }
 
   @Test
@@ -1270,9 +1412,11 @@
     String res = new String(os.toByteArray(), UTF_8);
     assertThat(res).isEqualTo(PATCH_FILE_ONLY);
 
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("File not found: nonexistent-file.");
-    changeApi.revision(r.getCommit().name()).patch("nonexistent-file");
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> changeApi.revision(r.getCommit().name()).patch("nonexistent-file"));
+    assertThat(thrown).hasMessageThat().contains("File not found: nonexistent-file.");
   }
 
   @Test
@@ -1303,7 +1447,7 @@
     oldETag = checkETag(getRevisionActions, r2, oldETag);
 
     current(r2).submit();
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
+    checkETag(getRevisionActions, r2, oldETag);
   }
 
   @Test
@@ -1315,18 +1459,21 @@
     amendChange(r.getChangeId());
 
     // code-review
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     recommend(r.getChangeId());
 
     // check if it's blocked to delete a vote on a non-current patch set.
-    setApiUser(admin);
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("Cannot access on non-current patch set");
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().getName())
-        .reviewer(user.getId().toString())
-        .deleteVote("Code-Review");
+    requestScopeOperations.setApiUser(admin.id());
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .revision(r.getCommit().getName())
+                    .reviewer(user.id().toString())
+                    .deleteVote("Code-Review"));
+    assertThat(thrown).hasMessageThat().contains("Cannot access on non-current patch set");
   }
 
   @Test
@@ -1338,27 +1485,68 @@
     amendChange(r.getChangeId());
 
     // code-review
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     recommend(r.getChangeId());
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes()
         .id(r.getChangeId())
         .current()
-        .reviewer(user.getId().toString())
+        .reviewer(user.id().toString())
         .deleteVote("Code-Review");
 
     Map<String, Short> m =
-        gApi.changes().id(r.getChangeId()).current().reviewer(user.getId().toString()).votes();
+        gApi.changes().id(r.getChangeId()).current().reviewer(user.id().toString()).votes();
 
     assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     ChangeMessageInfo message = Iterables.getLast(c.messages);
-    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
+    assertThat(message.author._accountId).isEqualTo(admin.id().get());
     assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
     assertThat(getReviewers(c.reviewers.get(ReviewerState.REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+        .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
+  }
+
+  @Test
+  public void listVotesByRevision() throws Exception {
+    // Create patch set 1 and vote on it
+    String changeId = createChange().getChangeId();
+    ListMultimap<String, ApprovalInfo> votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes).isEmpty();
+    recommend(changeId);
+    votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    List<ApprovalInfo> approvals = votes.get("Code-Review");
+    assertThat(approvals).hasSize(1);
+    ApprovalInfo approval = approvals.get(0);
+    assertThat(approval._accountId).isEqualTo(admin.id().get());
+    assertThat(approval.email).isEqualTo(admin.email());
+    assertThat(approval.username).isEqualTo(admin.username());
+
+    // Also vote on it with another user
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+
+    // Patch set 1 has 2 votes on Code-Review
+    requestScopeOperations.setApiUser(admin.id());
+    votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    approvals = votes.get("Code-Review");
+    assertThat(approvals).hasSize(2);
+    assertThat(approvals.stream().map(a -> a._accountId))
+        .containsExactlyElementsIn(ImmutableList.of(admin.id().get(), user.id().get()));
+
+    // Create a new patch set which does not have any votes
+    amendChange(changeId);
+    votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes).isEmpty();
+
+    // Votes are still returned for ps 1
+    votes = gApi.changes().id(changeId).revision(1).votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    approvals = votes.get("Code-Review");
+    assertThat(approvals).hasSize(2);
   }
 
   private static void assertCherryPickResult(
@@ -1375,7 +1563,7 @@
       throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            db, admin.getIdent(), testRepo, "test commit", "a.txt", content, r.getChangeId());
+            admin.newIdent(), testRepo, "test commit", "a.txt", content, r.getChangeId());
     return push.to("refs/for/master");
   }
 
@@ -1392,28 +1580,27 @@
 
   private PushOneCommit.Result createCherryPickableMerge(
       String parent1FileName, String parent2FileName) throws Exception {
-    RevCommit initialCommit = getHead(repo());
+    RevCommit initialCommit = getHead(repo(), "HEAD");
 
     String branchAName = "branchA";
-    createBranch(new Branch.NameKey(project, branchAName));
+    createBranch(BranchNameKey.create(project, branchAName));
     String branchBName = "branchB";
-    createBranch(new Branch.NameKey(project, branchBName));
+    createBranch(BranchNameKey.create(project, branchBName));
 
     PushOneCommit.Result changeAResult =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "change a", parent1FileName, "Content of a")
+            .create(admin.newIdent(), testRepo, "change a", parent1FileName, "Content of a")
             .to("refs/for/" + branchAName);
 
     testRepo.reset(initialCommit);
     PushOneCommit.Result changeBResult =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "change b", parent2FileName, "Content of b")
+            .create(admin.newIdent(), testRepo, "change b", parent2FileName, "Content of b")
             .to("refs/for/" + branchBName);
 
     PushOneCommit pushableMergeCommit =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             "merge",
             ImmutableMap.of(parent1FileName, "Content of a", parent2FileName, "Content of b"));
@@ -1433,6 +1620,6 @@
   }
 
   private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+    return Iterables.transform(r, a -> Account.id(a._accountId));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index cd20765..62a7037 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -15,17 +15,16 @@
 package com.google.gerrit.acceptance.api.revision;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThatList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 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.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -38,7 +37,6 @@
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -70,8 +68,7 @@
   public void setUp() throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             "Provide files which can be used for fixes",
             ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
@@ -85,8 +82,6 @@
 
   @Test
   public void retrievingRobotCommentsBeforeAddingAnyDoesNotRaiseAnException() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     Map<String, List<RobotCommentInfo>> robotComments =
         gApi.changes().id(changeId).current().robotComments();
 
@@ -96,8 +91,6 @@
 
   @Test
   public void addedRobotCommentsCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     RobotCommentInput in = createRobotCommentInput();
     addRobotComment(changeId, in);
 
@@ -110,12 +103,10 @@
 
   @Test
   public void addedRobotCommentsCanBeRetrievedByChange() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     RobotCommentInput in = createRobotCommentInput();
     addRobotComment(changeId, in);
 
-    pushFactory.create(db, admin.getIdent(), testRepo, changeId).to("refs/for/master");
+    pushFactory.create(admin.newIdent(), testRepo, changeId).to("refs/for/master");
 
     RobotCommentInput in2 = createRobotCommentInput();
     addRobotComment(changeId, in2);
@@ -133,8 +124,6 @@
 
   @Test
   public void robotCommentsCanBeRetrievedAsList() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     RobotCommentInput robotCommentInput = createRobotCommentInput();
     addRobotComment(changeId, robotCommentInput);
 
@@ -148,8 +137,6 @@
 
   @Test
   public void specificRobotCommentCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     RobotCommentInput robotCommentInput = createRobotCommentInput();
     addRobotComment(changeId, robotCommentInput);
 
@@ -163,8 +150,6 @@
 
   @Test
   public void robotCommentWithoutOptionalFieldsCanBeAdded() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
     addRobotComment(changeId, in);
 
@@ -176,21 +161,18 @@
 
   @Test
   public void hugeRobotCommentIsRejected() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     int defaultSizeLimit = 1024 * 1024;
     int sizeOfRest = 451;
     fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest + 1);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("limit");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("limit");
   }
 
   @Test
   public void reasonablyLargeRobotCommentIsAccepted() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     int defaultSizeLimit = 1024 * 1024;
     int sizeOfRest = 451;
     fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest);
@@ -204,21 +186,18 @@
   @Test
   @GerritConfig(name = "change.robotCommentSizeLimit", value = "10k")
   public void maximumAllowedSizeOfRobotCommentCanBeAdjusted() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     int sizeLimit = 10 * 1024;
     fixReplacementInfo.replacement = getStringFor(sizeLimit);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("limit");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("limit");
   }
 
   @Test
   @GerritConfig(name = "change.robotCommentSizeLimit", value = "0")
   public void zeroForMaximumAllowedSizeOfRobotCommentRemovesRestriction() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     int defaultSizeLimit = 1024 * 1024;
     fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
 
@@ -232,8 +211,6 @@
   @GerritConfig(name = "change.robotCommentSizeLimit", value = "-1")
   public void negativeValueForMaximumAllowedSizeOfRobotCommentRemovesRestriction()
       throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     int defaultSizeLimit = 1024 * 1024;
     fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
 
@@ -245,8 +222,6 @@
 
   @Test
   public void addedFixSuggestionCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -255,8 +230,6 @@
 
   @Test
   public void fixIdIsGeneratedForFixSuggestion() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -270,8 +243,6 @@
 
   @Test
   public void descriptionOfFixSuggestionIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -284,22 +255,21 @@
 
   @Test
   public void descriptionOfFixSuggestionIsMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixSuggestionInfo.description = null;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A description is required for the suggested fix of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A description is required for the suggested fix of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
   public void addedFixReplacementCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -312,23 +282,22 @@
 
   @Test
   public void fixReplacementsAreMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixSuggestionInfo.replacements = Collections.emptyList();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "At least one replacement is required"
-                + " for the suggested fix of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "At least one replacement is required"
+                    + " for the suggested fix of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
   public void pathOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -343,22 +312,21 @@
 
   @Test
   public void pathOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.path = null;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A file path must be given for the replacement of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A file path must be given for the replacement of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
   public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -373,33 +341,31 @@
 
   @Test
   public void rangeOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.range = null;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A range must be given for the replacement of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A range must be given for the replacement of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
   public void rangeOfFixReplacementNeedsToBeValid() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.range = createRange(13, 9, 5, 10);
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Range (13:9 - 5:10)");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("Range (13:9 - 5:10)");
   }
 
   @Test
   public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap()
       throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 1);
@@ -414,16 +380,15 @@
         createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
     withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("overlap");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("overlap");
   }
 
   @Test
   public void rangesOfFixReplacementsOfSameFixSuggestionForDifferentFileMayOverlap()
       throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 1);
@@ -447,8 +412,6 @@
   @Test
   public void rangesOfFixReplacementsOfDifferentFixSuggestionsForSameFileMayOverlap()
       throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 1);
@@ -472,8 +435,6 @@
 
   @Test
   public void fixReplacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -501,8 +462,6 @@
 
   @Test
   public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -517,23 +476,22 @@
 
   @Test
   public void replacementStringOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.replacement = null;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A content for replacement must be "
-                + "indicated for the replacement of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A content for replacement must be "
+                    + "indicated for the replacement of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
   public void fixWithinALineCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -557,8 +515,6 @@
 
   @Test
   public void fixSpanningMultipleLinesCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content\n5";
     fixReplacementInfo.range = createRange(3, 2, 5, 3);
@@ -581,8 +537,6 @@
 
   @Test
   public void fixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -615,8 +569,6 @@
 
   @Test
   public void twoFixesOnSameFileCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -650,8 +602,6 @@
 
   @Test
   public void twoConflictingFixesOnSameFileCannotBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 1);
@@ -672,15 +622,15 @@
 
     List<String> fixIds = getFixIds(robotCommentInfos);
     gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("merge");
-    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixIds.get(1)));
+    assertThat(thrown).hasMessageThat().contains("merge");
   }
 
   @Test
   public void twoFixesOfSameRobotCommentCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -714,8 +664,6 @@
 
   @Test
   public void fixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.path = FILE_NAME2;
     fixReplacementInfo.range = createRange(2, 0, 3, 0);
     fixReplacementInfo.replacement = "Modified content\n";
@@ -736,8 +684,6 @@
 
   @Test
   public void fixInvolvingTwoFilesCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -775,8 +721,6 @@
 
   @Test
   public void fixReferringToNonExistentFileCannotBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.path = "a_non_existent_file.txt";
     fixReplacementInfo.range = createRange(1, 0, 2, 0);
     fixReplacementInfo.replacement = "Modified content\n";
@@ -786,14 +730,13 @@
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
 
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).current().applyFix(fixId);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(fixId));
   }
 
   @Test
   public void fixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -808,15 +751,15 @@
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("current");
-    gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId));
+    assertThat(thrown).hasMessageThat().contains("current");
   }
 
   @Test
   public void fixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     // Create an empty change edit.
     gApi.changes().id(changeId).edit().create();
 
@@ -849,8 +792,6 @@
   @Test
   public void fixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
       throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     // Create an empty change edit.
     gApi.changes().id(changeId).edit().create();
 
@@ -867,15 +808,15 @@
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("based");
-    gApi.changes().id(changeId).current().applyFix(fixId);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixId));
+    assertThat(thrown).hasMessageThat().contains("based");
   }
 
   @Test
   public void fixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     String changeEditCommitMessage = "This is the commit message of the change edit.\n";
     gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
 
@@ -897,8 +838,6 @@
 
   @Test
   public void applyingFixTwiceIsIdempotent() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -922,8 +861,6 @@
 
   @Test
   public void nonExistentFixCannotBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -935,14 +872,13 @@
     String fixId = Iterables.getOnlyElement(fixIds);
     String nonExistentFixId = fixId + "_non-existent";
 
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).current().applyFix(nonExistentFixId);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(nonExistentFixId));
   }
 
   @Test
   public void applyingFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -964,8 +900,6 @@
 
   @Test
   public void applyingFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     gApi.changes().id(changeId).edit().create();
 
     fixReplacementInfo.path = FILE_NAME;
@@ -989,7 +923,6 @@
 
   @Test
   public void createdChangeEditIsBasedOnCurrentPatchSet() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     String currentRevision = gApi.changes().id(changeId).get().currentRevision;
 
     fixReplacementInfo.path = FILE_NAME;
@@ -1008,43 +941,22 @@
   }
 
   @Test
-  public void robotCommentsNotSupportedWithoutNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-
-    RobotCommentInput in = createRobotCommentInput();
-    ReviewInput reviewInput = new ReviewInput();
-    Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
-    robotComments.put(in.path, ImmutableList.of(in));
-    reviewInput.robotComments = robotComments;
-    reviewInput.message = "comment test";
-
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("robot comments not supported");
-    gApi.changes().id(changeId).current().review(reviewInput);
-  }
-
-  @Test
-  public void queryChangesWithUnresolvedCommentCount() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
+  public void queryChangesWithCommentCounts() throws Exception {
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 =
         pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
             .to("refs/for/master");
 
     addRobotComment(r2.getChangeId(), createRobotCommentInputWithMandatoryFields());
 
-    AcceptanceTestRequestScope.Context ctx = disableDb();
-    try {
+    try (AutoCloseable ignored = disableNoteDb()) {
       ChangeInfo result = Iterables.getOnlyElement(query(r2.getChangeId()));
       // currently, we create all robot comments as 'resolved' by default.
       // if we allow users to resolve a robot comment, then this test should
       // be modified.
       assertThat(result.unresolvedCommentCount).isEqualTo(0);
-    } finally {
-      enableDb(ctx);
+      assertThat(result.totalCommentCount).isEqualTo(1);
     }
   }
 
@@ -1122,7 +1034,7 @@
     assertThat(c.line).isEqualTo(expected.line);
     assertThat(c.message).isEqualTo(expected.message);
 
-    assertThat(c.author.email).isEqualTo(admin.email);
+    assertThat(c.author.email).isEqualTo(admin.email());
 
     if (expectPath) {
       assertThat(c.path).isEqualTo(expected.path);
@@ -1140,8 +1052,7 @@
 
   private static List<String> getFixIds(List<RobotCommentInfo> robotComments) {
     assertThatList(robotComments).isNotNull();
-    return robotComments
-        .stream()
+    return robotComments.stream()
         .map(robotCommentInfo -> robotCommentInfo.fixSuggestions)
         .filter(Objects::nonNull)
         .flatMap(List::stream)
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 91a1278..070b98c 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -16,13 +16,16 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.restapi.testing.BinaryResultSubject.assertThat;
+import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
@@ -34,6 +37,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
@@ -41,7 +46,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -51,19 +56,16 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.restapi.change.ChangeEdits.EditMessage;
 import com.google.gerrit.server.restapi.change.ChangeEdits.Post;
 import com.google.gerrit.server.restapi.change.ChangeEdits.Put;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gson.reflect.TypeToken;
 import com.google.gson.stream.JsonReader;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -77,7 +79,6 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
@@ -93,7 +94,8 @@
   private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
   private static final byte[] CONTENT_NEW2 = CONTENT_NEW2_STR.getBytes(UTF_8);
 
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private String changeId;
   private String changeId2;
@@ -111,17 +113,11 @@
 
   @Before
   public void setUp() throws Exception {
-    db = reviewDbProvider.open();
-    changeId = newChange(admin.getIdent());
+    changeId = newChange(admin.newIdent());
     ps = getCurrentPatchSet(changeId);
     assertThat(ps).isNotNull();
-    amendChange(admin.getIdent(), changeId);
-    changeId2 = newChange2(admin.getIdent());
-  }
-
-  @After
-  public void cleanup() {
-    db.close();
+    amendChange(admin.newIdent(), changeId);
+    changeId2 = newChange2(admin.newIdent());
   }
 
   @Test
@@ -145,7 +141,7 @@
   @Test
   public void deleteEditOfOlderPatchSet() throws Exception {
     createArbitraryEditFor(changeId2);
-    amendChange(admin.getIdent(), changeId2);
+    amendChange(admin.newIdent(), changeId2);
 
     gApi.changes().id(changeId2).edit().delete();
     assertThat(getEdit(changeId2)).isAbsent();
@@ -193,7 +189,7 @@
     adminRestSession.post(urlPublish(changeId)).assertNoContent();
     assertThat(getEdit(changeId)).isAbsent();
     PatchSet newCurrentPatchSet = getCurrentPatchSet(changeId);
-    assertThat(newCurrentPatchSet.getId()).isNotEqualTo(oldCurrentPatchSet.getId());
+    assertThat(newCurrentPatchSet.id()).isNotEqualTo(oldCurrentPatchSet.id());
     assertChangeMessages(
         changeId,
         ImmutableList.of(
@@ -205,7 +201,7 @@
   @Test
   public void publishEditNotifyRest() throws Exception {
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
     createArbitraryEditFor(changeId);
@@ -220,7 +216,7 @@
   @Test
   public void publishEditWithDefaultNotify() throws Exception {
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
     createArbitraryEditFor(changeId);
@@ -238,30 +234,21 @@
   }
 
   @Test
-  public void publishEditRestWithoutCLA() throws Exception {
-    createArbitraryEditFor(changeId);
-    setUseContributorAgreements(InheritableBoolean.TRUE);
-    adminRestSession.post(urlPublish(changeId)).assertForbidden();
-    setUseContributorAgreements(InheritableBoolean.FALSE);
-    adminRestSession.post(urlPublish(changeId)).assertNoContent();
-  }
-
-  @Test
   public void rebaseEdit() throws Exception {
     PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
     createEmptyEditFor(changeId2);
     gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    amendChange(admin.getIdent(), changeId2);
+    amendChange(admin.newIdent(), changeId2);
     PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
 
     Optional<EditInfo> originalEdit = getEdit(changeId2);
-    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
     Timestamp beforeRebase = originalEdit.get().commit.committer.date;
     gApi.changes().id(changeId2).edit().rebase();
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
     Optional<EditInfo> rebasedEdit = getEdit(changeId2);
-    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.commitId().name());
     assertThat(rebasedEdit).value().commit().committer().date().isNotEqualTo(beforeRebase);
   }
 
@@ -270,17 +257,17 @@
     PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
     createEmptyEditFor(changeId2);
     gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    amendChange(admin.getIdent(), changeId2);
+    amendChange(admin.newIdent(), changeId2);
     PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
 
     Optional<EditInfo> originalEdit = getEdit(changeId2);
-    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
     Timestamp beforeRebase = originalEdit.get().commit.committer.date;
     adminRestSession.post(urlRebase(changeId2)).assertNoContent();
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
     Optional<EditInfo> rebasedEdit = getEdit(changeId2);
-    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.commitId().name());
     assertThat(rebasedEdit).value().commit().committer().date().isNotEqualTo(beforeRebase);
   }
 
@@ -290,11 +277,10 @@
     createEmptyEditFor(changeId2);
     gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
     Optional<EditInfo> edit = getEdit(changeId2);
-    assertThat(edit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(edit).value().baseRevision().isEqualTo(currentPatchSet.commitId().name());
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             FILE_NAME,
@@ -318,7 +304,7 @@
   public void updateRootCommitMessage() throws Exception {
     // Re-clone empty repo; TestRepository doesn't let us reset to unborn head.
     testRepo = cloneProject(project);
-    changeId = newChange(admin.getIdent());
+    changeId = newChange(admin.newIdent());
 
     createEmptyEditFor(changeId);
     Optional<EditInfo> edit = getEdit(changeId);
@@ -335,9 +321,13 @@
     createEmptyEditFor(changeId);
     String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("New commit message cannot be same as existing commit message");
-    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("New commit message cannot be same as existing commit message");
   }
 
   @Test
@@ -345,9 +335,13 @@
     createEmptyEditFor(changeId);
     String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("New commit message cannot be same as existing commit message");
-    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage + "\n\n");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage + "\n\n"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("New commit message cannot be same as existing commit message");
   }
 
   @Test
@@ -398,7 +392,7 @@
     r = adminRestSession.getJsonAccept(urlEditMessage(changeId, true));
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.commitId().name()));
       assertThat(readContentFromJson(r)).isEqualTo(commit.getFullMessage());
     }
 
@@ -417,7 +411,9 @@
   public void retrieveEdit() throws Exception {
     adminRestSession.get(urlEdit(changeId)).assertNoContent();
     createArbitraryEditFor(changeId);
-    EditInfo editInfo = getEditInfo(changeId, false);
+    Optional<EditInfo> maybeEditInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(maybeEditInfo).isPresent();
+    EditInfo editInfo = maybeEditInfo.get();
     ChangeInfo changeInfo = get(changeId, CURRENT_REVISION, CURRENT_COMMIT);
     assertThat(editInfo.commit.commit).isNotEqualTo(changeInfo.currentRevision);
     assertThat(editInfo).commit().parents().hasSize(1);
@@ -431,11 +427,7 @@
   @Test
   public void retrieveFilesInEdit() throws Exception {
     createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-
-    EditInfo info = getEditInfo(changeId, true);
-    assertThat(info.files).isNotNull();
-    assertThat(info.files.keySet()).containsExactly(Patch.COMMIT_MSG, FILE_NAME, FILE_NAME2);
+    assertFiles(changeId, ImmutableList.of(COMMIT_MSG, FILE_NAME, FILE_NAME2));
   }
 
   @Test
@@ -576,8 +568,10 @@
   @Test
   public void addNewFile() throws Exception {
     createEmptyEditFor(changeId);
+    assertFiles(changeId, ImmutableList.of(COMMIT_MSG, FILE_NAME, FILE_NAME2));
     gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
     ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
+    assertFiles(changeId, ImmutableList.of(COMMIT_MSG, FILE_NAME, FILE_NAME2, FILE_NAME3));
   }
 
   @Test
@@ -592,16 +586,22 @@
   @Test
   public void writeNoChanges() throws Exception {
     createEmptyEditFor(changeId);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("no changes were made");
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_OLD));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .edit()
+                    .modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_OLD)));
+    assertThat(thrown).hasMessageThat().contains("no changes were made");
   }
 
   @Test
   public void editCommitMessageCopiesLabelScores() throws Exception {
     String cr = "Code-Review";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = Util.codeReview();
+      LabelType codeReview = TestLabels.codeReview();
       codeReview.setCopyAllScoresIfNoCodeChange(true);
       u.getConfig().getLabelSections().put(cr, codeReview);
       u.save();
@@ -644,11 +644,11 @@
     gApi.changes().id(changeId2).edit().publish(publishInput);
     assertThat(queryEdits()).isEmpty();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     createEmptyEditFor(changeId);
     assertThat(queryEdits()).hasSize(1);
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     assertThat(queryEdits()).isEmpty();
   }
 
@@ -689,21 +689,24 @@
   @Test
   public void createEditWithoutPushPatchSetPermission() throws Exception {
     // Create new project with clean permissions
-    Project.NameKey p = createProject("addPatchSetEdit");
+    Project.NameKey p = projectOperations.newProject().create();
     // Clone repository as user
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Create change as user
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
+    PushOneCommit push = pushFactory.create(user.newIdent(), userTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
     // Try to create edit as admin
-    exception.expect(AuthException.class);
-    createEmptyEditFor(r1.getChangeId());
+    assertThrows(AuthException.class, () -> createEmptyEditFor(r1.getChangeId()));
   }
 
   @Test
@@ -712,9 +715,11 @@
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     gApi.changes().id(changeId).current().submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("change %s is merged", change._number));
-    createArbitraryEditFor(changeId);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> createArbitraryEditFor(changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("change %s is merged", change._number));
   }
 
   @Test
@@ -722,9 +727,11 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     gApi.changes().id(changeId).abandon();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("change %s is abandoned", change._number));
-    createArbitraryEditFor(changeId);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> createArbitraryEditFor(changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("change %s is abandoned", change._number));
   }
 
   private void createArbitraryEditFor(String changeId) throws Exception {
@@ -752,14 +759,13 @@
   private String newChange(PersonIdent ident) throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
+            ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
     return push.to("refs/for/master").getChangeId();
   }
 
   private String amendChange(PersonIdent ident, String changeId) throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            db,
             ident,
             testRepo,
             PushOneCommit.SUBJECT,
@@ -772,7 +778,7 @@
   private String newChange2(PersonIdent ident) throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
+            ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
     return push.rm("refs/for/master").getChangeId();
   }
 
@@ -785,6 +791,19 @@
     assertThat(fileContent).value().bytes().isEqualTo(expectedFileBytes);
   }
 
+  private void assertFiles(String changeId, List<String> expected) throws Exception {
+    Optional<EditInfo> info =
+        gApi.changes()
+            .id(changeId)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .get();
+    assertThat(info).isPresent();
+    assertThat(info.get().files).isNotNull();
+    assertThat(info.get().files.keySet()).containsExactlyElementsIn(expected);
+  }
+
   private String urlEdit(String changeId) {
     return "/changes/" + changeId + "/edit";
   }
@@ -801,10 +820,6 @@
     return urlEdit(changeId) + "/" + fileName + (base ? "?base" : "");
   }
 
-  private String urlGetFiles(String changeId) {
-    return urlEdit(changeId) + "?list";
-  }
-
   private String urlRevisionFiles(String changeId, String revisionId) {
     return "/changes/" + changeId + "/revisions/" + revisionId + "/files";
   }
@@ -839,11 +854,6 @@
         + "/diff?context=ALL&intraline";
   }
 
-  private EditInfo getEditInfo(String changeId, boolean files) throws Exception {
-    RestResponse r = adminRestSession.get(files ? urlGetFiles(changeId) : urlEdit(changeId));
-    return readContentFromJson(r, EditInfo.class);
-  }
-
   private <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
     r.assertOK();
     try (JsonReader jsonReader = new JsonReader(r.getReader())) {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index cfa7ec4..f82dcd7 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -14,14 +14,20 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.GitUtil.pushOne;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
@@ -32,8 +38,9 @@
 import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static java.util.Comparator.comparing;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
@@ -48,15 +55,20 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.SkipProjectClone;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -64,6 +76,7 @@
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -71,6 +84,10 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.testing.EditInfoSubject;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.Change;
@@ -78,19 +95,21 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
 import com.google.gerrit.server.git.receive.ReceiveConstants;
-import com.google.gerrit.server.git.validators.CommitValidators.ChangeIdValidator;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.EnumSet;
 import java.util.HashMap;
@@ -98,11 +117,14 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
 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;
@@ -117,15 +139,27 @@
 import org.junit.BeforeClass;
 import org.junit.Test;
 
+@SkipProjectClone
 public abstract class AbstractPushForReview extends AbstractDaemonTest {
   protected enum Protocol {
-    // TODO(dborowitz): TEST.
+    // Only test protocols which are actually served by the Gerrit server, since each separate test
+    // class is large and slow.
+    //
+    // This list excludes the test InProcessProtocol, which is used by large numbers of other
+    // acceptance tests. Small tests of InProcessProtocol are still possible, without incurring a
+    // new large slow test.
     SSH,
     HTTP
   }
 
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private static String NEW_CHANGE_INDICATOR = " [NEW]";
   private LabelType patchSetLock;
 
+  @Inject private DynamicSet<CommitValidationListener> commitValidators;
+
   @BeforeClass
   public static void setTimeForTesting() {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -139,27 +173,32 @@
   @Before
   public void setUpPatchSetLock() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      patchSetLock = Util.patchSetLock();
+      patchSetLock = TestLabels.patchSetLock();
       u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
-      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(patchSetLock.getName()),
-          0,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
       u.save();
     }
-    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(patchSetLock.getName())
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .range(0, 1))
+        .add(
+            allowLabel(patchSetLock.getName())
+                .ref("refs/heads/*")
+                .group(adminGroupUuid())
+                .range(0, 1))
+        .update();
   }
 
   @After
   public void resetPublishCommentOnPushOption() throws Exception {
-    setApiUser(admin);
-    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    requestScopeOperations.setApiUser(admin.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id().get()).getPreferences();
     prefs.publishCommentsOnPush = false;
-    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+    gApi.accounts().id(admin.id().get()).setPreferences(prefs);
   }
 
   protected void selectProtocol(Protocol p) throws Exception {
@@ -212,6 +251,87 @@
   }
 
   @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void pushInitialCommitSeriesForMasterBranch() throws Exception {
+    testPushInitialCommitSeriesForMasterBranch();
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void pushInitialCommitSeriesForMasterBranchWithCreateNewChangeForAllNotInTarget()
+      throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushInitialCommitSeriesForMasterBranch();
+  }
+
+  private void testPushInitialCommitSeriesForMasterBranch() throws Exception {
+    RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    RevCommit c2 = testRepo.commit().parent(c).message("Second commit").insertChangeId().create();
+    String id2 = GitUtil.getChangeId(testRepo, c2).get();
+    testRepo.reset(c2);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    ChangeInfo change = gApi.changes().id(id).info();
+    assertThat(change.branch).isEqualTo("master");
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+
+    ChangeInfo change2 = gApi.changes().id(id2).info();
+    assertThat(change2.branch).isEqualTo("master");
+    assertThat(change2.status).isEqualTo(ChangeStatus.NEW);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("master")).isNull();
+    }
+
+    gApi.changes().id(change.id).current().review(ReviewInput.approve());
+    gApi.changes().id(change.id).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("master")).isEqualTo(c);
+    }
+
+    gApi.changes().id(change2.id).current().review(ReviewInput.approve());
+    gApi.changes().id(change2.id).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("master")).isEqualTo(c2);
+    }
+  }
+
+  @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
+  @GerritConfig(name = "receive.enableSignedPush", value = "true")
+  @TestProjectInput(
+      enableSignedPush = InheritableBoolean.TRUE,
+      requireSignedPush = InheritableBoolean.TRUE)
+  public void nonSignedPushRejectedWhenSignPushRequired() throws Exception {
+    pushTo("refs/for/master").assertErrorStatus("push cert error");
+  }
+
+  @Test
   public void pushInitialCommitForRefsMetaConfigBranch() throws Exception {
     // delete refs/meta/config
     try (Repository repo = repoManager.openRepository(project);
@@ -226,8 +346,8 @@
         testRepo
             .commit()
             .message("Initial commit")
-            .author(admin.getIdent())
-            .committer(admin.getIdent())
+            .author(admin.newIdent())
+            .committer(admin.newIdent())
             .insertChangeId()
             .create();
     String id = GitUtil.getChangeId(testRepo, c).get();
@@ -259,8 +379,8 @@
         testRepo
             .commit()
             .message("Initial commit")
-            .author(admin.getIdent())
-            .committer(admin.getIdent())
+            .author(admin.newIdent())
+            .committer(admin.newIdent())
             .insertChangeId()
             .create();
     testRepo.reset(c);
@@ -283,7 +403,7 @@
     r1.assertOkStatus();
     r1.assertChange(Change.Status.NEW, null);
     r1.assertMessage(
-        "New changes:\n  " + url + id1 + " " + r1.getCommit().getShortMessage() + "\n");
+        url + id1 + " " + r1.getCommit().getShortMessage() + NEW_CHANGE_INDICATOR + "\n");
 
     testRepo.reset(initialHead);
     String newMsg = r1.getCommit().getShortMessage() + " v2";
@@ -295,25 +415,25 @@
         .create();
     PushOneCommit.Result r2 =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "another commit", "b.txt", "bbb")
+            .create(admin.newIdent(), testRepo, "another commit", "b.txt", "bbb")
             .to("refs/for/master");
     Change.Id id2 = r2.getChange().getId();
     r2.assertOkStatus();
     r2.assertChange(Change.Status.NEW, null);
     r2.assertMessage(
-        "New changes:\n"
-            + "  "
-            + url
-            + id2
-            + " another commit\n"
+        "success\n"
             + "\n"
-            + "\n"
-            + "Updated changes:\n"
             + "  "
             + url
             + id1
             + " "
             + newMsg
+            + "\n"
+            + "  "
+            + url
+            + id2
+            + " another commit"
+            + NEW_CHANGE_INDICATOR
             + "\n");
   }
 
@@ -339,6 +459,20 @@
   }
 
   @Test
+  public void pushWithoutChangeIdDeprecated() throws Exception {
+    setRequireChangeId(InheritableBoolean.FALSE);
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .message("A change")
+        .author(admin.newIdent())
+        .committer(new PersonIdent(admin.newIdent(), 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");
@@ -371,6 +505,7 @@
     PushOneCommit.Result r = pushTo("refs/for/master/" + topic);
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, topic);
+    r.assertMessage("deprecated topic syntax");
 
     // specify topic as option
     r = pushTo("refs/for/master%topic=" + topic);
@@ -384,7 +519,7 @@
     List<String> pushOptions = new ArrayList<>();
     pushOptions.add(topicOption);
 
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     push.setPushOptions(pushOptions);
     PushOneCommit.Result r = push.to("refs/for/master");
 
@@ -417,11 +552,11 @@
     pwi.filter = "*";
     pwi.notifyNewChanges = true;
     projectsToWatch.add(pwi);
-    setApiUser(user3);
+    requestScopeOperations.setApiUser(user3.id());
     gApi.accounts().self().setWatchedProjects(projectsToWatch);
 
     TestAccount user2 = accountCreator.user2();
-    String pushSpec = "refs/for/master%reviewer=" + user.email + ",cc=" + user2.email;
+    String pushSpec = "refs/for/master%reviewer=" + user.email() + ",cc=" + user2.email();
 
     sender.clear();
     PushOneCommit.Result r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE);
@@ -439,43 +574,44 @@
     r.assertOkStatus();
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
 
     sender.clear();
     r = pushTo(pushSpec + ",notify=" + NotifyHandling.ALL);
     r.assertOkStatus();
     assertThat(sender.getMessages()).hasSize(1);
     m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress, user2.emailAddress, user3.emailAddress);
+    assertThat(m.rcpt())
+        .containsExactly(user.getEmailAddress(), user2.getEmailAddress(), user3.getEmailAddress());
 
     sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + user3.email);
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + user3.email());
     r.assertOkStatus();
     assertNotifyTo(user3);
 
     sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + user3.email);
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + user3.email());
     r.assertOkStatus();
     assertNotifyCc(user3);
 
     sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + user3.email);
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + user3.email());
     r.assertOkStatus();
     assertNotifyBcc(user3);
 
     // request that sender gets notified as TO, CC and BCC, email should be sent
     // even if the sender is the only recipient
     sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + admin.email);
+    pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + admin.email());
     assertNotifyTo(admin);
 
     sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + admin.email);
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + admin.email());
     r.assertOkStatus();
     assertNotifyCc(admin);
 
     sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + admin.email);
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + admin.email());
     r.assertOkStatus();
     assertNotifyBcc(admin);
   }
@@ -484,7 +620,7 @@
   public void pushForMasterWithCc() throws Exception {
     // cc one user
     String topic = "my/topic";
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%cc=" + user.email);
+    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%cc=" + user.email());
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, topic, ImmutableList.of(), ImmutableList.of(user));
 
@@ -494,11 +630,11 @@
             "refs/for/master/"
                 + topic
                 + "%cc="
-                + admin.email
+                + admin.email()
                 + ",cc="
-                + user.email
+                + user.email()
                 + ",cc="
-                + accountCreator.user2().email);
+                + accountCreator.user2().email());
     r.assertOkStatus();
     // Check that admin isn't CC'd as they own the change
     r.assertChange(
@@ -514,19 +650,56 @@
             "refs/for/master/"
                 + topic
                 + "%cc="
-                + admin.email
+                + admin.email()
                 + ",cc="
                 + nonExistingEmail
                 + ",cc="
-                + user.email);
-    r.assertErrorStatus("user \"" + nonExistingEmail + "\" not found");
+                + user.email());
+    r.assertErrorStatus(nonExistingEmail + " does not identify a registered user or group");
+  }
+
+  @Test
+  public void pushForMasterWithCcByEmail() throws Exception {
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result r =
+        pushTo("refs/for/master%cc=non.existing.1@example.com,cc=non.existing.2@example.com");
+    r.assertOkStatus();
+
+    ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS);
+    ImmutableList<AccountInfo> ccs =
+        firstNonNull(ci.reviewers.get(ReviewerState.CC), ImmutableList.<AccountInfo>of()).stream()
+            .sorted(comparing((AccountInfo a) -> a.email))
+            .collect(toImmutableList());
+    assertThat(ccs).hasSize(2);
+    assertThat(ccs.get(0).email).isEqualTo("non.existing.1@example.com");
+    assertThat(ccs.get(0)._accountId).isNull();
+    assertThat(ccs.get(1).email).isEqualTo("non.existing.2@example.com");
+    assertThat(ccs.get(1)._accountId).isNull();
+  }
+
+  @Test
+  public void pushForMasterWithCcGroup() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    String group = name("group");
+    GroupInput gin = new GroupInput();
+    gin.name = group;
+    gin.members = ImmutableList.of(user.username(), user2.username());
+    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerAdder.
+    gApi.groups().create(gin);
+
+    PushOneCommit.Result r = pushTo("refs/for/master%cc=" + group);
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null, ImmutableList.of(), ImmutableList.of(user, user2));
   }
 
   @Test
   public void pushForMasterWithReviewer() throws Exception {
     // add one reviewer
     String topic = "my/topic";
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%r=" + user.email);
+    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%r=" + user.email());
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, topic, user);
 
@@ -538,11 +711,11 @@
             "refs/for/master/"
                 + topic
                 + "%r="
-                + admin.email
+                + admin.email()
                 + ",r="
-                + user.email
+                + user.email()
                 + ",r="
-                + user2.email);
+                + user2.email());
     r.assertOkStatus();
     // admin is the owner of the change and should not appear as reviewer
     r.assertChange(Change.Status.NEW, topic, user, user2);
@@ -554,12 +727,50 @@
             "refs/for/master/"
                 + topic
                 + "%r="
-                + admin.email
+                + admin.email()
                 + ",r="
                 + nonExistingEmail
                 + ",r="
-                + user.email);
-    r.assertErrorStatus("user \"" + nonExistingEmail + "\" not found");
+                + user.email());
+    r.assertErrorStatus(nonExistingEmail + " does not identify a registered user or group");
+  }
+
+  @Test
+  public void pushForMasterWithReviewerByEmail() throws Exception {
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result r =
+        pushTo("refs/for/master%r=non.existing.1@example.com,r=non.existing.2@example.com");
+    r.assertOkStatus();
+
+    ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS);
+    ImmutableList<AccountInfo> reviewers =
+        firstNonNull(ci.reviewers.get(ReviewerState.REVIEWER), ImmutableList.<AccountInfo>of())
+            .stream()
+            .sorted(comparing((AccountInfo a) -> a.email))
+            .collect(toImmutableList());
+    assertThat(reviewers).hasSize(2);
+    assertThat(reviewers.get(0).email).isEqualTo("non.existing.1@example.com");
+    assertThat(reviewers.get(0)._accountId).isNull();
+    assertThat(reviewers.get(1).email).isEqualTo("non.existing.2@example.com");
+    assertThat(reviewers.get(1)._accountId).isNull();
+  }
+
+  @Test
+  public void pushForMasterWithReviewerGroup() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    String group = name("group");
+    GroupInput gin = new GroupInput();
+    gin.name = group;
+    gin.members = ImmutableList.of(user.username(), user2.username());
+    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerAdder.
+    gApi.groups().create(gin);
+
+    PushOneCommit.Result r = pushTo("refs/for/master%r=" + group);
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null, ImmutableList.of(user, user2), ImmutableList.of());
   }
 
   @Test
@@ -609,36 +820,44 @@
     assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
 
     // Pushing a new patch set without --wip doesn't remove the wip flag from the change.
-    r = amendChange(r.getChangeId(), "refs/for/master");
+    String changeId = r.getChangeId();
+    r = amendChange(changeId, "refs/for/master");
     r.assertOkStatus();
     r.assertMessage(" [WIP]");
     assertThat(r.getChange().change().isWorkInProgress()).isTrue();
     assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
 
     // Remove the wip flag from the change.
-    r = amendChange(r.getChangeId(), "refs/for/master%ready");
+    r = amendChange(changeId, "refs/for/master%ready");
     r.assertOkStatus();
     r.assertNotMessage(" [WIP]");
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
     assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
 
     // Normal push: wip flag is not added back.
-    r = amendChange(r.getChangeId(), "refs/for/master");
+    r = amendChange(changeId, "refs/for/master");
     r.assertOkStatus();
     r.assertNotMessage(" [WIP]");
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
     assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
 
     // Make the change work-in-progress again.
-    r = amendChange(r.getChangeId(), "refs/for/master%wip");
+    r = amendChange(changeId, "refs/for/master%wip");
     r.assertOkStatus();
     r.assertMessage(" [WIP]");
     assertThat(r.getChange().change().isWorkInProgress()).isTrue();
     assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
 
     // Can't use --wip and --ready together.
-    r = amendChange(r.getChangeId(), "refs/for/master%wip,ready");
+    r = amendChange(changeId, "refs/for/master%wip,ready");
     r.assertErrorStatus();
+
+    // Pushing directly to the branch removes the work-in-progress flag
+    String master = "refs/heads/master";
+    assertPushOk(pushHead(testRepo, master, false), master);
+    ChangeInfo result = Iterables.getOnlyElement(gApi.changes().query(changeId).get());
+    assertThat(result.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(result.workInProgress).isNull();
   }
 
   private void assertUploadTag(ChangeData cd, String expectedTag) throws Exception {
@@ -651,13 +870,13 @@
   public void pushWorkInProgressChangeWhenNotOwner() throws Exception {
     TestRepository<?> userRepo = cloneProject(project, user);
     PushOneCommit.Result r =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master%wip");
+        pushFactory.create(user.newIdent(), userRepo).to("refs/for/master%wip");
     r.assertOkStatus();
-    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id);
+    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id());
     assertThat(r.getChange().change().isWorkInProgress()).isTrue();
 
     // Admin user trying to move from WIP to ready should succeed.
-    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(testRepo, r.getPatchSet().refName() + ":ps");
     testRepo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%ready", user, testRepo);
     r.assertOkStatus();
@@ -668,12 +887,12 @@
     assertThat(r.getChange().change().isWorkInProgress()).isTrue();
 
     // Push as change owner to move change from WIP to ready.
-    r = pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master%ready");
+    r = pushFactory.create(user.newIdent(), userRepo).to("refs/for/master%ready");
     r.assertOkStatus();
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
 
     // Admin user trying to move from ready to WIP should succeed.
-    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(testRepo, r.getPatchSet().refName() + ":ps");
     testRepo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
     r.assertOkStatus();
@@ -684,16 +903,26 @@
 
     // Non owner, non admin and non project owner cannot flip wip bit:
     TestAccount user2 = accountCreator.user2();
-    grant(
-        project, "refs/*", Permission.FORGE_COMMITTER, false, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.FORGE_COMMITTER)
+                .ref("refs/*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     TestRepository<?> user2Repo = cloneProject(project, user2);
-    GitUtil.fetch(user2Repo, r.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(user2Repo, r.getPatchSet().refName() + ":ps");
     user2Repo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
     r.assertErrorStatus(ReceiveConstants.ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
 
     // Project owner trying to move from WIP to ready should succeed.
-    allow("refs/*", Permission.OWNER, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
     r.assertOkStatus();
   }
@@ -713,8 +942,7 @@
     assertThat(edit).isPresent();
     EditInfo editInfo = edit.get();
     r.assertMessage(
-        "Updated Changes:\n  "
-            + canonicalWebUrl.get()
+        canonicalWebUrl.get()
             + "c/"
             + project.get()
             + "/+/"
@@ -750,8 +978,7 @@
     enableCreateNewChangeForAllNotInTarget();
 
     PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     // %2C is comma; the value below tests that percent decoding happens after splitting.
     // All three ways of representing space ("%20", "+", and "_" are also exercised.
     PushOneCommit.Result r = push.to("refs/for/master/%m=my_test%20+_message%2Cm=");
@@ -759,8 +986,7 @@
 
     push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             "b.txt",
@@ -837,8 +1063,7 @@
 
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             "b.txt",
@@ -859,8 +1084,7 @@
 
     push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             "c.txt",
@@ -878,8 +1102,7 @@
 
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             "b.txt",
@@ -901,6 +1124,55 @@
     assertThat(cr.all.get(0).value).isEqualTo(2);
   }
 
+  @Test
+  public void pushForMasterWithForgedAuthorAndCommitter() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        commitBuilder()
+            .author(user.newIdent())
+            .committer(user2.newIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+    // Push commit as "Admnistrator".
+    pushHead(testRepo, "refs/for/master");
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(changeId, ReviewerState.REVIEWER))
+        .containsExactly(user.email(), user2.email());
+
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt())
+        .containsExactly(user.getEmailAddress(), user2.getEmailAddress());
+  }
+
+  @Test
+  public void pushNewPatchSetForMasterWithForgedAuthorAndCommitter() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    // First patch set has author and committer matching change owner.
+    PushOneCommit.Result r = pushTo("refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+    amendBuilder()
+        .author(user.newIdent())
+        .committer(user2.newIdent())
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+        .create();
+    pushHead(testRepo, "refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER))
+        .containsExactly(user.email(), user2.email());
+
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt())
+        .containsExactly(user.getEmailAddress(), user2.getEmailAddress());
+  }
+
   /**
    * There was a bug that allowed a user with Forge Committer Identity access right to upload a
    * commit and put *votes on behalf of another user* on it. This test checks that this is not
@@ -916,8 +1188,8 @@
     // Create a commit with "User" as author and committer
     RevCommit c =
         commitBuilder()
-            .author(user.getIdent())
-            .committer(user.getIdent())
+            .author(user.newIdent())
+            .committer(user.newIdent())
             .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
             .message(PushOneCommit.SUBJECT)
             .create();
@@ -936,11 +1208,11 @@
         get(GitUtil.getChangeId(testRepo, c).get(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get("Code-Review");
     assertThat(cr.all).hasSize(2);
-    int indexAdmin = admin.fullName.equals(cr.all.get(0).name) ? 0 : 1;
+    int indexAdmin = admin.fullName().equals(cr.all.get(0).name) ? 0 : 1;
     int indexUser = indexAdmin == 0 ? 1 : 0;
-    assertThat(cr.all.get(indexAdmin).name).isEqualTo(admin.fullName);
+    assertThat(cr.all.get(indexAdmin).name).isEqualTo(admin.fullName());
     assertThat(cr.all.get(indexAdmin).value.intValue()).isEqualTo(1);
-    assertThat(cr.all.get(indexUser).name).isEqualTo(user.fullName);
+    assertThat(cr.all.get(indexUser).name).isEqualTo(user.fullName());
     assertThat(cr.all.get(indexUser).value.intValue()).isEqualTo(0);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 1: Code-Review+1.");
@@ -951,19 +1223,22 @@
   @Test
   public void pushWithMultipleApprovals() throws Exception {
     LabelType Q =
-        category("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+        label("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.forLabel("Custom-Label"), -1, 1, anon, heads);
       u.getConfig().getLabelSections().put(Q.getName(), Q);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Custom-Label").ref(heads).group(ANONYMOUS_USERS).range(-1, 1))
+        .update();
 
     RevCommit c =
         commitBuilder()
-            .author(admin.getIdent())
-            .committer(admin.getIdent())
+            .author(admin.newIdent())
+            .committer(admin.newIdent())
             .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
             .message(PushOneCommit.SUBJECT)
             .create();
@@ -980,48 +1255,12 @@
   }
 
   @Test
-  @GerritConfig(name = "receive.allowPushToRefsChanges", value = "true")
-  public void pushToRefsChangesAllowed() throws Exception {
-    PushOneCommit.Result r = pushOneCommitToRefsChanges();
-    r.assertOkStatus();
-  }
-
-  @Test
-  public void pushNewPatchsetToRefsChanges() throws Exception {
-    PushOneCommit.Result r = pushOneCommitToRefsChanges();
-    r.assertErrorStatus("upload to refs/changes not allowed");
-  }
-
-  @Test
-  @GerritConfig(name = "receive.allowPushToRefsChanges", value = "false")
-  public void pushToRefsChangesNotAllowed() throws Exception {
-    PushOneCommit.Result r = pushOneCommitToRefsChanges();
-    r.assertErrorStatus("upload to refs/changes not allowed");
-  }
-
-  private PushOneCommit.Result pushOneCommitToRefsChanges() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    return push.to("refs/changes/" + r.getChange().change().getId().get());
-  }
-
-  @Test
   public void pushNewPatchsetToPatchSetLockedChange() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             "b.txt",
@@ -1053,9 +1292,6 @@
 
   @Test
   public void pushForMasterWithHashtags() throws Exception {
-    // Hashtags only work when reading from NoteDB is enabled
-    assume().that(notesMigration.readChanges()).isTrue();
-
     // specify a single hashtag as option
     String hashtag1 = "tag1";
     Set<String> expected = ImmutableSet.of(hashtag1);
@@ -1070,8 +1306,7 @@
     String hashtag2 = "tag2";
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             "b.txt",
@@ -1086,9 +1321,6 @@
 
   @Test
   public void pushForMasterWithMultipleHashtags() throws Exception {
-    // Hashtags only work when reading from NoteDB is enabled
-    assume().that(notesMigration.readChanges()).isTrue();
-
     // specify multiple hashtags as options
     String hashtag1 = "tag1";
     String hashtag2 = "tag2";
@@ -1106,8 +1338,7 @@
     String hashtag4 = "tag4";
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             "b.txt",
@@ -1121,31 +1352,26 @@
   }
 
   @Test
-  public void pushForMasterWithHashtagsNoteDbDisabled() throws Exception {
-    // Push with hashtags should fail when reading from NoteDb is disabled.
-    assume().that(notesMigration.readChanges()).isFalse();
-    PushOneCommit.Result r = pushTo("refs/for/master%hashtag=tag1");
-    r.assertErrorStatus("cannot add hashtags; noteDb is disabled");
-  }
-
-  @Test
   public void pushCommitUsingSignedOffBy() throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+            admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
 
     setUseSignedOffBy(InheritableBoolean.TRUE);
-    block(project, "refs/heads/master", Permission.FORGE_COMMITTER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT
-                + String.format("\n\nSigned-off-by: %s <%s>", admin.fullName, admin.email),
+                + String.format("\n\nSigned-off-by: %s <%s>", admin.fullName(), admin.email()),
             "b.txt",
             "anotherContent");
     r = push.to("refs/for/master");
@@ -1153,9 +1379,9 @@
 
     push =
         pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+            admin.newIdent(), 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
@@ -1163,14 +1389,13 @@
     enableCreateNewChangeForAllNotInTarget();
 
     PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
 
     push =
         pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+            admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     r = push.to("refs/for/master");
     r.assertOkStatus();
 
@@ -1188,19 +1413,18 @@
 
     // create a change as admin
     PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     RevCommit commitChange1 = r.getCommit();
 
     // create a second change as user (depends on the change from admin)
     TestRepository<?> userRepo = cloneProject(project, user);
-    GitUtil.fetch(userRepo, r.getPatchSet().getRefName() + ":change");
+    GitUtil.fetch(userRepo, r.getPatchSet().refName() + ":change");
     userRepo.reset("change");
     push =
         pushFactory.create(
-            db, user.getIdent(), userRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+            user.newIdent(), userRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     r = push.to("refs/for/master");
     r.assertOkStatus();
 
@@ -1210,7 +1434,11 @@
 
   @Test
   public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result rBase = pushTo("refs/heads/master");
     rBase.assertOkStatus();
 
@@ -1218,7 +1446,7 @@
 
     PushOneCommit push =
         pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+            admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
 
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
@@ -1228,30 +1456,22 @@
 
     // 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);
   }
 
   @Test
   public void pushSameCommitTwice() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .getProject()
-          .setBooleanConfig(
-              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
-              InheritableBoolean.TRUE);
-      u.save();
-    }
+    enableCreateNewChangeForAllNotInTarget();
 
     PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
 
     push =
         pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+            admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     r = push.to("refs/for/master");
     r.assertOkStatus();
 
@@ -1263,24 +1483,16 @@
 
   @Test
   public void pushSameCommitTwiceWhenIndexFailed() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .getProject()
-          .setBooleanConfig(
-              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
-              InheritableBoolean.TRUE);
-      u.save();
-    }
+    enableCreateNewChangeForAllNotInTarget();
 
     PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
 
     push =
         pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+            admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     r = push.to("refs/for/master");
     r.assertOkStatus();
 
@@ -1322,8 +1534,8 @@
 
     // Check that a change was created for each.
     for (RevCommit c : commits) {
-      assertThat(byCommit(c).change().getSubject())
-          .named("change for " + c.name())
+      assertWithMessage("change for " + c.name())
+          .that(byCommit(c).change().getSubject())
           .isEqualTo(c.getShortMessage());
     }
 
@@ -1335,9 +1547,9 @@
       RevCommit c2 = commits2.get(i);
       String name = "change for " + c2.name();
       ChangeData cd = byCommit(c);
-      assertThat(cd.change().getSubject()).named(name).isEqualTo(c2.getShortMessage());
-      assertThat(getPatchSetRevisions(cd))
-          .named(name)
+      assertWithMessage(name).that(cd.change().getSubject()).isEqualTo(c2.getShortMessage());
+      assertWithMessage(name)
+          .that(getPatchSetRevisions(cd))
           .containsExactlyEntriesIn(ImmutableMap.of(1, c.name(), 2, c2.name()));
     }
 
@@ -1359,33 +1571,51 @@
   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);
   }
 
   @Test
-  @GerritConfig(name = "receive.allowPushToRefsChanges", value = "true")
-  public void testPushWithChangedChangeId() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
+  public void pushWithChangeIdAboveFooter() throws Exception {
+    testPushWithChangeIdAboveFooter();
+  }
+
+  @Test
+  public void pushWithChangeIdAboveFooterWithCreateNewChangeForAllNotInTarget() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushWithChangeIdAboveFooter();
+  }
+
+  private void testPushWithChangeIdAboveFooter() throws Exception {
+    RevCommit c =
+        createCommit(
             testRepo,
             PushOneCommit.SUBJECT
                 + "\n\n"
-                + "Change-Id: I55eab7c7a76e95005fa9cc469aa8f9fc16da9eba\n",
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    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()));
+                + "Change-Id: Ied70ea827f5bf968f1f6aaee6594e07c846d217a\n\n"
+                + "More text, uh oh.\n");
+    assertThat(GitUtil.getChangeId(testRepo, c)).isEmpty();
+    pushForReviewRejected(testRepo, "Change-Id must be in message footer");
+
+    setRequireChangeId(InheritableBoolean.FALSE);
+    pushForReviewRejected(testRepo, "Change-Id must be in message footer");
+  }
+
+  @Test
+  public void errorMessageFormat() throws Exception {
+    RevCommit c = createCommit(testRepo, "Message without Change-Id");
+    assertThat(GitUtil.getChangeId(testRepo, c)).isEmpty();
+    String ref = "refs/for/master";
+    PushResult r = pushHead(testRepo, ref);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(ref);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    String reason =
+        String.format("commit %s: missing Change-Id in message footer", abbreviateName(c));
+    assertThat(refUpdate.getMessage()).isEqualTo(reason);
+
+    assertThat(r.getMessages()).contains("\nERROR: " + reason);
   }
 
   @Test
@@ -1406,10 +1636,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 +1655,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,26 +1679,25 @@
         "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
   public void pushCommitWithSameChangeIdAsPredecessorChange() throws Exception {
     PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     RevCommit commitChange1 = r.getCommit();
@@ -1547,8 +1776,8 @@
     Change.Id id2 = r2.getChange().getId();
 
     // Merge change 1 behind Gerrit's back.
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<?> tr = new TestRepository<>(repo)) {
       tr.branch("refs/heads/master").update(r1.getCommit());
     }
 
@@ -1565,45 +1794,30 @@
   }
 
   @Test
-  @GerritConfig(name = "receive.allowPushToRefsChanges", value = "true")
-  public void accidentallyPushNewPatchSetDirectlyToBranchAndRecoverByPushingToRefsChanges()
-      throws Exception {
-    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
-    ChangeData cd = byChangeId(id);
-    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
-
-    String r = "refs/changes/" + id;
-    assertPushOk(pushHead(testRepo, r, false), r);
-
-    // Added a new patch set and auto-closed the change.
-    cd = byChangeId(id);
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(getPatchSetRevisions(cd))
-        .containsExactlyEntriesIn(
-            ImmutableMap.of(1, ps1Rev, 2, testRepo.getRepository().resolve("HEAD").name()));
-  }
-
-  @Test
   public void accidentallyPushNewPatchSetDirectlyToBranchAndCantRecoverByPushingToRefsFor()
       throws Exception {
     Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
     ChangeData cd = byChangeId(id);
-    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).commitId().name();
 
     String r = "refs/for/master";
     assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
 
     // Change not updated.
     cd = byChangeId(id);
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(cd.change().isNew()).isTrue();
     assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(ImmutableMap.of(1, ps1Rev));
   }
 
   @Test
   public void forcePushAbandonedChange() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     PushOneCommit push1 =
-        pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r = push1.to("refs/for/master");
     r.assertOkStatus();
 
@@ -1627,12 +1841,12 @@
     Change c = r.getChange().change();
 
     RevCommit ps2Commit;
-    try (Repository repo = repoManager.openRepository(project)) {
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<?> tr = new TestRepository<>(repo)) {
       // Create a new patch set of the change directly in Gerrit's repository,
       // without pushing it. In reality it's more likely that the client would
       // create and push this behind Gerrit's back (e.g. an admin accidentally
       // using direct ssh access to the repo), but that's harder to do in tests.
-      TestRepository<?> tr = new TestRepository<>(repo);
       ps2Commit =
           tr.branch("refs/heads/master")
               .commit()
@@ -1645,7 +1859,7 @@
     testRepo.reset(ps2Commit);
 
     ChangeData cd = byCommit(ps1Commit);
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(cd.change().isNew()).isTrue();
     assertThat(getPatchSetRevisions(cd))
         .containsExactlyEntriesIn(ImmutableMap.of(1, ps1Commit.name()));
     return c.getId();
@@ -1653,12 +1867,12 @@
 
   @Test
   public void pushWithEmailInFooter() throws Exception {
-    pushWithReviewerInFooter(user.emailAddress.toString(), user);
+    pushWithReviewerInFooter(user.getEmailAddress().toString(), user);
   }
 
   @Test
   public void pushWithNameInFooter() throws Exception {
-    pushWithReviewerInFooter(user.fullName, user);
+    pushWithReviewerInFooter(user.fullName(), user);
   }
 
   @Test
@@ -1674,7 +1888,7 @@
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = Util.codeReview();
+      LabelType codeReview = TestLabels.codeReview();
       codeReview.setCopyMaxScore(true);
       u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
       u.save();
@@ -1684,8 +1898,7 @@
     r.assertOkStatus();
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             "b.txt",
@@ -1698,7 +1911,11 @@
   @Test
   public void createChangeForMergedCommit() throws Exception {
     String master = "refs/heads/master";
-    grant(project, master, Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(adminGroupUuid()).force(true))
+        .update();
 
     // Update master with a direct push.
     RevCommit c1 = testRepo.commit().message("Non-change 1").create();
@@ -1749,8 +1966,8 @@
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
 
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
       tr.branch("refs/heads/branch").commit().message("Initial commit on branch").create();
     }
 
@@ -1797,7 +2014,11 @@
   @Test
   public void mergedOptionWithExistingChangeInsertsPatchSet() throws Exception {
     String master = "refs/heads/master";
-    grant(project, master, Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(adminGroupUuid()).force(true))
+        .update();
 
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
@@ -1808,8 +2029,8 @@
     // expecting the change to be auto-closed, but the change metadata update
     // fails.
     ObjectId c2;
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
       RevCommit commit2 =
           tr.amend(c1).message("New subject").insertChangeId(r.getChangeId().substring(1)).create();
       c2 = commit2.copy();
@@ -1842,7 +2063,7 @@
 
     assertThat(getPublishedComments(r.getChangeId())).isEmpty();
 
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     sender.clear();
     amendChange(r.getChangeId(), "refs/for/master%publish-comments");
 
@@ -1853,9 +2074,7 @@
     assertThat(getLastMessage(r.getChangeId())).isEqualTo("Uploaded patch set 3.\n\n(3 comments)");
 
     List<String> messages =
-        sender
-            .getMessages()
-            .stream()
+        sender.getMessages().stream()
             .map(Message::body)
             .sorted(Comparator.comparingInt(m -> m.contains("reexamine") ? 0 : 1))
             .collect(toList());
@@ -1937,7 +2156,7 @@
     assertThat(getPublishedComments(id1)).isEmpty();
     assertThat(getPublishedComments(id2)).isEmpty();
 
-    r2 = amendChange(id2, "refs/for/master%publish-comments");
+    amendChange(id2, "refs/for/master%publish-comments");
 
     assertThat(getPublishedComments(id1)).isEmpty();
     assertThat(gApi.changes().id(id1).drafts()).hasSize(1);
@@ -1958,9 +2177,9 @@
 
     assertThat(getPublishedComments(r.getChangeId())).isEmpty();
 
-    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id().get()).getPreferences();
     prefs.publishCommentsOnPush = true;
-    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+    gApi.accounts().id(admin.id().get()).setPreferences(prefs);
 
     r = amendChange(r.getChangeId());
     assertThat(getPublishedComments(r.getChangeId()).stream().map(c -> c.message))
@@ -1972,9 +2191,9 @@
     PushOneCommit.Result r = createChange();
     addDraft(r.getChangeId(), r.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
 
-    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id().get()).getPreferences();
     prefs.publishCommentsOnPush = true;
-    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+    gApi.accounts().id(admin.id().get()).setPreferences(prefs);
 
     r = amendChange(r.getChangeId(), "refs/for/master%no-publish-comments");
 
@@ -2031,13 +2250,42 @@
   @GerritConfig(name = "receive.maxBatchCommits", value = "2")
   @Test
   public void maxBatchCommits() throws Exception {
+    testMaxBatchCommits();
+  }
+
+  @GerritConfig(name = "receive.maxBatchCommits", value = "2")
+  @Test
+  public void maxBatchCommitsWithDefaultValidator() throws Exception {
+    TestValidator validator = new TestValidator();
+    RegistrationHandle handle = commitValidators.add("test-validator", validator);
+    try {
+      testMaxBatchCommits();
+    } finally {
+      handle.remove();
+    }
+  }
+
+  @GerritConfig(name = "receive.maxBatchCommits", value = "2")
+  @Test
+  public void maxBatchCommitsWithValidateAllCommitsValidator() throws Exception {
+    TestValidator validator = new TestValidator(true);
+    RegistrationHandle handle = commitValidators.add("test-validator", validator);
+    try {
+      testMaxBatchCommits();
+    } finally {
+      handle.remove();
+    }
+  }
+
+  private void testMaxBatchCommits() throws Exception {
     List<RevCommit> commits = new ArrayList<>();
     commits.addAll(initChanges(2));
     String master = "refs/heads/master";
     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 =
@@ -2051,14 +2299,86 @@
         .isEqualTo(Iterables.getLast(commits).name());
   }
 
+  private static class TestValidator implements CommitValidationListener {
+    private final AtomicInteger count = new AtomicInteger();
+    private final boolean validateAll;
+
+    TestValidator(boolean validateAll) {
+      this.validateAll = validateAll;
+    }
+
+    TestValidator() {
+      this(false);
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) {
+      count.incrementAndGet();
+      return Collections.emptyList();
+    }
+
+    @Override
+    public boolean shouldValidateAllCommits() {
+      return validateAll;
+    }
+
+    public int count() {
+      return count.get();
+    }
+  }
+
   @Test
-  public void pushToPublishMagicBranchIsAllowed() throws Exception {
-    // Push to "refs/publish/*" will be a synonym of "refs/for/*".
-    createChange("refs/publish/master");
-    PushOneCommit.Result result = pushTo("refs/publish/master");
-    result.assertOkStatus();
-    assertThat(result.getMessage())
-        .endsWith("Pushing to refs/publish/* is deprecated, use refs/for/* instead.\n");
+  public void skipValidation() throws Exception {
+    String master = "refs/heads/master";
+    TestValidator validator = new TestValidator();
+    RegistrationHandle handle = commitValidators.add("test-validator", validator);
+    RegistrationHandle handle2 = null;
+
+    try {
+      // Validation listener is called on normal push
+      PushOneCommit push =
+          pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r = push.to(master);
+      r.assertOkStatus();
+      assertThat(validator.count()).isEqualTo(1);
+
+      // Push is rejected and validation listener is not called when not allowed
+      // to use skip option
+      PushOneCommit push2 =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push2.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+      r = push2.to(master);
+      r.assertErrorStatus("not permitted: skip validation");
+      assertThat(validator.count()).isEqualTo(1);
+
+      // Validation listener is not called when skip option is used
+      grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
+      PushOneCommit push3 =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push3.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+      r = push3.to(master);
+      r.assertOkStatus();
+      assertThat(validator.count()).isEqualTo(1);
+
+      // Validation listener that needs to validate all commits gets called even
+      // when the skip option is used.
+      TestValidator validator2 = new TestValidator(true);
+      handle2 = commitValidators.add("test-validator-2", validator2);
+      PushOneCommit push4 =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push4.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+      r = push4.to(master);
+      r.assertOkStatus();
+      // First listener was not called; its count remains the same.
+      assertThat(validator.count()).isEqualTo(1);
+      // Second listener was called.
+      assertThat(validator2.count()).isEqualTo(1);
+    } finally {
+      handle.remove();
+      if (handle2 != null) {
+        handle2.remove();
+      }
+    }
   }
 
   @Test
@@ -2077,12 +2397,19 @@
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
     assertPushRejected(pr, ref, "NoteDb update requires access database permission");
 
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     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);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/changes/*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref("refs/changes/*").group(adminGroupUuid()))
+        .update();
     grantSkipValidation(project, "refs/changes/*", REGISTERED_USERS);
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
     assertPushOk(pr, ref);
@@ -2101,11 +2428,184 @@
                 .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");
+  }
+
+  @Test
+  public void pushCommitsWithSameTreeNoChanges() throws Exception {
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("Foo")
+            .parent(getHead(testRepo.getRepository(), "HEAD"))
+            .insertChangeId()
+            .create();
+    testRepo.reset(c);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    RevCommit amended = testRepo.amend(c).create();
+    testRepo.reset(amended);
+
+    pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+    assertThat(pr.getMessages())
+        .contains(
+            "warning: no changes between prior commit "
+                + abbreviateName(c)
+                + " and new commit "
+                + abbreviateName(amended));
+  }
+
+  @Test
+  public void pushCommitsWithSameTreeNoFilesChangedMessageUpdated() throws Exception {
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("Foo")
+            .parent(getHead(testRepo.getRepository(), "HEAD"))
+            .insertChangeId()
+            .create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    RevCommit amended =
+        testRepo.amend(c).message("Foo Bar").insertChangeId(id.substring(1)).create();
+    testRepo.reset(amended);
+
+    pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+    assertThat(pr.getMessages())
+        .contains("warning: " + abbreviateName(amended) + ": no files changed, message updated");
+  }
+
+  @Test
+  public void pushCommitsWithSameTreeNoFilesChangedAuthorChanged() throws Exception {
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("Foo")
+            .parent(getHead(testRepo.getRepository(), "HEAD"))
+            .insertChangeId()
+            .create();
+    testRepo.reset(c);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    RevCommit amended = testRepo.amend(c).author(user.newIdent()).create();
+    testRepo.reset(amended);
+
+    pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+    assertThat(pr.getMessages())
+        .contains("warning: " + abbreviateName(amended) + ": no files changed, author changed");
+  }
+
+  @Test
+  public void pushCommitsWithSameTreeNoFilesChangedWasRebased() throws Exception {
+    RevCommit head = getHead(testRepo.getRepository(), "HEAD");
+    RevCommit c = testRepo.commit().message("Foo").parent(head).insertChangeId().create();
+    testRepo.reset(c);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    testRepo.reset(head);
+    RevCommit newBase = testRepo.commit().message("Base").parent(head).insertChangeId().create();
+    testRepo.reset(newBase);
+
+    pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    testRepo.reset(c);
+    RevCommit amended = testRepo.amend(c).parent(newBase).create();
+    testRepo.reset(amended);
+
+    pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+    assertThat(pr.getMessages())
+        .contains("warning: " + abbreviateName(amended) + ": no files changed, was rebased");
+  }
+
+  @Test
+  public void sequentialCommitMessages() throws Exception {
+    String url = canonicalWebUrl.get() + "c/" + project.get() + "/+/";
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+
+    PushOneCommit.Result r1 = pushTo("refs/for/master");
+    Change.Id id1 = r1.getChange().getId();
+    r1.assertOkStatus();
+    r1.assertChange(Change.Status.NEW, null);
+    r1.assertMessage(
+        url + id1 + " " + r1.getCommit().getShortMessage() + NEW_CHANGE_INDICATOR + "\n");
+
+    PushOneCommit.Result r2 = pushTo("refs/for/master");
+    Change.Id id2 = r2.getChange().getId();
+    r2.assertOkStatus();
+    r2.assertChange(Change.Status.NEW, null);
+    r2.assertMessage(
+        url + id2 + " " + r2.getCommit().getShortMessage() + NEW_CHANGE_INDICATOR + "\n");
+
+    testRepo.reset(initialHead);
+
+    // rearrange the commit so that change no. 2 is the parent of change no. 1
+    String r1Message = "Position 2";
+    String r2Message = "Position 1";
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .message(r2Message)
+        .insertChangeId(r2.getChangeId().substring(1))
+        .create();
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .message(r1Message)
+        .insertChangeId(r1.getChangeId().substring(1))
+        .create();
+
+    PushOneCommit.Result r3 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "another commit", "b.txt", "bbb")
+            .to("refs/for/master");
+    Change.Id id3 = r3.getChange().getId();
+    r3.assertOkStatus();
+    r3.assertChange(Change.Status.NEW, null);
+    // should display commit r2, r1, r3 in that order.
+    r3.assertMessage(
+        "success\n"
+            + "\n"
+            + "  "
+            + url
+            + id2
+            + " "
+            + r2Message
+            + "\n"
+            + "  "
+            + url
+            + id1
+            + " "
+            + r1Message
+            + "\n"
+            + "  "
+            + url
+            + id3
+            + " another commit"
+            + NEW_CHANGE_INDICATOR
+            + "\n");
   }
 
   private DraftInput newDraft(String path, int line, String message) {
@@ -2123,11 +2623,7 @@
   }
 
   private Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
-    return gApi.changes()
-        .id(changeId)
-        .comments()
-        .values()
-        .stream()
+    return gApi.changes().id(changeId).comments().values().stream()
         .flatMap(Collection::stream)
         .collect(toList());
   }
@@ -2142,7 +2638,7 @@
     assertThat(ci.reviewers).isNotNull();
     assertThat(ci.reviewers.keySet()).containsExactly(ReviewerState.REVIEWER);
     assertThat(ci.reviewers.get(ReviewerState.REVIEWER).iterator().next().email)
-        .isEqualTo(reviewer.email);
+        .isEqualTo(reviewer.email());
   }
 
   private void pushWithReviewerInFooter(String nameEmail, TestAccount expectedReviewer)
@@ -2156,10 +2652,11 @@
       ChangeData cd = byCommit(c);
       String name = "reviewers for " + (i + 1);
       if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.getId());
-        gApi.changes().id(cd.getId().get()).reviewer(expectedReviewer.getId().toString()).remove();
+        assertWithMessage(name).that(cd.reviewers().all()).containsExactly(expectedReviewer.id());
+        // Remove reviewer from PS1 so we can test adding this same reviewer on PS2 below.
+        gApi.changes().id(cd.getId().get()).reviewer(expectedReviewer.id().toString()).remove();
       }
-      assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+      assertWithMessage(name).that(byCommit(c).reviewers().all()).isEmpty();
     }
 
     List<RevCommit> commits2 = amendChanges(initialHead, commits, r);
@@ -2168,9 +2665,9 @@
       ChangeData cd = byCommit(c);
       String name = "reviewers for " + (i + 1);
       if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.getId());
+        assertWithMessage(name).that(cd.reviewers().all()).containsExactly(expectedReviewer.id());
       } else {
-        assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+        assertWithMessage(name).that(byCommit(c).reviewers().all()).isEmpty();
       }
     }
   }
@@ -2237,20 +2734,20 @@
   private static Map<Integer, String> getPatchSetRevisions(ChangeData cd) throws Exception {
     Map<Integer, String> revisions = new HashMap<>();
     for (PatchSet ps : cd.patchSets()) {
-      revisions.put(ps.getPatchSetId(), ps.getRevision().get());
+      revisions.put(ps.number(), ps.commitId().name());
     }
     return revisions;
   }
 
   private ChangeData byCommit(ObjectId id) throws Exception {
     List<ChangeData> cds = queryProvider.get().byCommit(id);
-    assertThat(cds).named("change for " + id.name()).hasSize(1);
+    assertWithMessage("change for " + id.name()).that(cds).hasSize(1);
     return cds.get(0);
   }
 
   private ChangeData byChangeId(Change.Id id) throws Exception {
     List<ChangeData> cds = queryProvider.get().byLegacyChangeId(id);
-    assertThat(cds).named("change " + id).hasSize(1);
+    assertWithMessage("change " + id).that(cds).hasSize(1);
     return cds.get(0);
   }
 
@@ -2278,16 +2775,34 @@
   private void grantSkipValidation(Project.NameKey project, String ref, AccountGroup.UUID groupUuid)
       throws Exception {
     // See SKIP_VALIDATION implementation in default permission backend.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.FORGE_AUTHOR, groupUuid, ref);
-      Util.allow(u.getConfig(), Permission.FORGE_COMMITTER, groupUuid, ref);
-      Util.allow(u.getConfig(), Permission.FORGE_SERVER, groupUuid, ref);
-      Util.allow(u.getConfig(), Permission.PUSH_MERGE, groupUuid, "refs/for/" + ref);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref(ref).group(groupUuid))
+        .add(allow(Permission.FORGE_COMMITTER).ref(ref).group(groupUuid))
+        .add(allow(Permission.FORGE_SERVER).ref(ref).group(groupUuid))
+        .add(allow(Permission.PUSH_MERGE).ref("refs/for/" + ref).group(groupUuid))
+        .update();
   }
 
   private PushOneCommit.Result amendChange(String changeId, String ref) throws Exception {
     return amendChange(changeId, ref, admin, testRepo);
   }
+
+  private String getOwnerEmail(String changeId) throws Exception {
+    return get(changeId, DETAILED_ACCOUNTS).owner.email;
+  }
+
+  private ImmutableList<String> getReviewerEmails(String changeId, ReviewerState state)
+      throws Exception {
+    Collection<AccountInfo> infos =
+        get(changeId, DETAILED_LABELS, DETAILED_ACCOUNTS).reviewers.get(state);
+    return infos != null
+        ? infos.stream().map(a -> a.email).collect(toImmutableList())
+        : ImmutableList.of();
+  }
+
+  private String abbreviateName(AnyObjectId id) throws Exception {
+    return ObjectIds.abbreviateName(id, testRepo.getRevWalk().getObjectReader());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 62138ca..c35b891 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -15,24 +15,39 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
 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;
@@ -41,9 +56,17 @@
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.junit.Before;
 
 public abstract class AbstractSubmoduleSubscription extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
+  protected TestRepository<?> superRepo;
+  protected Project.NameKey superKey;
+  protected TestRepository<?> subRepo;
+  protected Project.NameKey subKey;
+
   protected SubmitType getSubmitType() {
     return cfg.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
   }
@@ -83,34 +106,31 @@
     return cfg;
   }
 
-  protected TestRepository<?> createProjectWithPush(
-      String name,
-      @Nullable Project.NameKey parent,
-      boolean createEmptyCommit,
-      SubmitType submitType)
-      throws Exception {
-    Project.NameKey project = createProject(name, parent, createEmptyCommit, submitType);
-    grant(project, "refs/heads/*", Permission.PUSH);
-    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
-    return cloneProject(project);
+  protected void grantPush(Project.NameKey project) throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
   }
 
-  protected TestRepository<?> createProjectWithPush(String name, @Nullable Project.NameKey parent)
-      throws Exception {
-    return createProjectWithPush(name, parent, true, getSubmitType());
-  }
-
-  protected TestRepository<?> createProjectWithPush(String name, boolean createEmptyCommit)
-      throws Exception {
-    return createProjectWithPush(name, null, createEmptyCommit, getSubmitType());
-  }
-
-  protected TestRepository<?> createProjectWithPush(String name) throws Exception {
-    return createProjectWithPush(name, null, true, getSubmitType());
+  protected Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
+    Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
+    grantPush(project);
+    return project;
   }
 
   private static AtomicInteger contentCounter = new AtomicInteger(0);
 
+  @Before
+  public void setUp() throws Exception {
+    superKey = createProjectForPush(getSubmitType());
+    subKey = createProjectForPush(getSubmitType());
+    superRepo = cloneProject(superKey);
+    subRepo = cloneProject(subKey);
+  }
+
   protected ObjectId pushChangeTo(
       TestRepository<?> repo, String ref, String file, String content, String message, String topic)
       throws Exception {
@@ -169,19 +189,27 @@
     return Iterables.getLast(res).getRemoteUpdate(remoteBranch).getNewObjectId();
   }
 
-  protected void allowSubmoduleSubscription(
-      String submodule, String subBranch, String superproject, String superBranch, boolean match)
+  protected void allowMatchingSubmoduleSubscription(
+      Project.NameKey submodule, String subBranch, Project.NameKey superproject, String superBranch)
       throws Exception {
-    Project.NameKey sub = new Project.NameKey(name(submodule));
-    Project.NameKey superName = new Project.NameKey(name(superproject));
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(sub)) {
+    allowSubmoduleSubscription(submodule, subBranch, superproject, superBranch, true);
+  }
+
+  protected void allowSubmoduleSubscription(
+      Project.NameKey submodule,
+      String subBranch,
+      Project.NameKey superproject,
+      String superBranch,
+      boolean match)
+      throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(submodule)) {
       md.setMessage("Added superproject subscription");
       SubscribeSection s;
-      ProjectConfig pc = ProjectConfig.read(md);
-      if (pc.getSubscribeSections().containsKey(superName)) {
-        s = pc.getSubscribeSections().get(superName);
+      ProjectConfig pc = projectConfigFactory.read(md);
+      if (pc.getSubscribeSections().containsKey(superproject)) {
+        s = pc.getSubscribeSections().get(superproject);
       } else {
-        s = new SubscribeSection(superName);
+        s = new SubscribeSection(superproject);
       }
       String refspec;
       if (superBranch == null) {
@@ -202,14 +230,11 @@
     }
   }
 
-  protected void allowMatchingSubmoduleSubscription(
-      String submodule, String subBranch, String superproject, String superBranch)
-      throws Exception {
-    allowSubmoduleSubscription(submodule, subBranch, superproject, superBranch, true);
-  }
-
   protected void createSubmoduleSubscription(
-      TestRepository<?> repo, String branch, String subscribeToRepo, String subscribeToBranch)
+      TestRepository<?> repo,
+      String branch,
+      Project.NameKey subscribeToRepo,
+      String subscribeToBranch)
       throws Exception {
     Config config = new Config();
     prepareSubmoduleConfigEntry(config, subscribeToRepo, subscribeToBranch);
@@ -220,7 +245,7 @@
       TestRepository<?> repo,
       String branch,
       String subscribeToRepoPrefix,
-      String subscribeToRepo,
+      Project.NameKey subscribeToRepo,
       String subscribeToBranch)
       throws Exception {
     Config config = new Config();
@@ -232,19 +257,18 @@
   protected void prepareRelativeSubmoduleConfigEntry(
       Config config,
       String subscribeToRepoPrefix,
-      String subscribeToRepo,
+      Project.NameKey subscribeToRepo,
       String subscribeToBranch) {
-    subscribeToRepo = name(subscribeToRepo);
-    String url = subscribeToRepoPrefix + subscribeToRepo;
-    config.setString("submodule", subscribeToRepo, "path", subscribeToRepo);
-    config.setString("submodule", subscribeToRepo, "url", url);
+    String url = subscribeToRepoPrefix + subscribeToRepo.get();
+    config.setString("submodule", subscribeToRepo.get(), "path", subscribeToRepo.get());
+    config.setString("submodule", subscribeToRepo.get(), "url", url);
     if (subscribeToBranch != null) {
-      config.setString("submodule", subscribeToRepo, "branch", subscribeToBranch);
+      config.setString("submodule", subscribeToRepo.get(), "branch", subscribeToBranch);
     }
   }
 
   protected void prepareSubmoduleConfigEntry(
-      Config config, String subscribeToRepo, String subscribeToBranch) {
+      Config config, Project.NameKey subscribeToRepo, String subscribeToBranch) {
     // The submodule subscription module checks for gerrit.canonicalWebUrl to
     // detect if it's configured for automatic updates. It doesn't matter if
     // it serves from that URL.
@@ -252,17 +276,18 @@
   }
 
   protected void prepareSubmoduleConfigEntry(
-      Config config, String subscribeToRepo, String subscribeToRepoPath, String subscribeToBranch) {
-    subscribeToRepo = name(subscribeToRepo);
-    subscribeToRepoPath = name(subscribeToRepoPath);
+      Config config,
+      Project.NameKey subscribeToRepo,
+      Project.NameKey subscribeToRepoPath,
+      String subscribeToBranch) {
     // The submodule subscription module checks for gerrit.canonicalWebUrl to
     // detect if it's configured for automatic updates. It doesn't matter if
     // it serves from that URL.
     String url = cfg.getString("gerrit", null, "canonicalWebUrl") + "/" + subscribeToRepo;
-    config.setString("submodule", subscribeToRepoPath, "path", subscribeToRepoPath);
-    config.setString("submodule", subscribeToRepoPath, "url", url);
+    config.setString("submodule", subscribeToRepoPath.get(), "path", subscribeToRepoPath.get());
+    config.setString("submodule", subscribeToRepoPath.get(), "url", url);
     if (subscribeToBranch != null) {
-      config.setString("submodule", subscribeToRepoPath, "branch", subscribeToBranch);
+      config.setString("submodule", subscribeToRepoPath.get(), "branch", subscribeToBranch);
     }
   }
 
@@ -286,12 +311,11 @@
   protected void expectToHaveSubmoduleState(
       TestRepository<?> repo,
       String branch,
-      String submodule,
+      Project.NameKey submodule,
       TestRepository<?> subRepo,
       String subBranch)
       throws Exception {
 
-    submodule = name(submodule);
     ObjectId commitId =
         repo.git()
             .fetch()
@@ -314,16 +338,14 @@
     rw.parseBody(c.getTree());
 
     RevTree tree = c.getTree();
-    RevObject actualId = repo.get(tree, submodule);
+    RevObject actualId = repo.get(tree, submodule.get());
 
     assertThat(actualId).isEqualTo(subHead);
   }
 
   protected void expectToHaveSubmoduleState(
-      TestRepository<?> repo, String branch, String submodule, ObjectId expectedId)
+      TestRepository<?> repo, String branch, Project.NameKey submodule, ObjectId expectedId)
       throws Exception {
-
-    submodule = name(submodule);
     ObjectId commitId =
         repo.git()
             .fetch()
@@ -337,7 +359,7 @@
     rw.parseBody(c.getTree());
 
     RevTree tree = c.getTree();
-    RevObject actualId = repo.get(tree, submodule);
+    RevObject actualId = repo.get(tree, submodule.get());
 
     assertThat(actualId).isEqualTo(expectedId);
   }
@@ -396,10 +418,8 @@
     assertThat(actualId).isEqualTo(expectedId);
   }
 
-  protected boolean hasSubmodule(TestRepository<?> repo, String branch, String submodule)
+  protected boolean hasSubmodule(TestRepository<?> repo, String branch, Project.NameKey submodule)
       throws Exception {
-
-    submodule = name(submodule);
     Ref branchTip =
         repo.git().fetch().setRemote("origin").call().getAdvertisedRef("refs/heads/" + branch);
     if (branchTip == null) {
@@ -414,7 +434,7 @@
 
     RevTree tree = c.getTree();
     try {
-      repo.get(tree, submodule);
+      repo.get(tree, submodule.get());
       return true;
     } catch (AssertionError e) {
       return false;
@@ -434,6 +454,64 @@
 
     RevWalk rw = repo.getRevWalk();
     RevCommit c = rw.parseCommit(commitId);
-    assertThat(c.getFullMessage()).isEqualTo(expectedMessage);
+    String msg = c.getFullMessage();
+    assertThat(msg).isEqualTo(expectedMessage);
+  }
+
+  protected PersonIdent getAuthor(TestRepository<?> repo, String branch) throws Exception {
+    ObjectId commitId =
+        repo.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/" + branch)
+            .getObjectId();
+
+    RevWalk rw = repo.getRevWalk();
+    RevCommit c = rw.parseCommit(commitId);
+    return c.getAuthorIdent();
+  }
+
+  protected void directUpdateSubmodule(
+      Project.NameKey project, String refName, Project.NameKey path, AnyObjectId id)
+      throws Exception {
+    try (Repository serverRepo = repoManager.openRepository(project);
+        ObjectInserter ins = serverRepo.newObjectInserter();
+        RevWalk rw = new RevWalk(serverRepo)) {
+      Ref ref = serverRepo.exactRef(refName);
+      assertWithMessage(refName).that(ref).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.get()) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.GITLINK);
+              ent.setObjectId(id);
+            }
+          });
+      e.finish();
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.addParentId(oldCommitId);
+      cb.setTreeId(dc.writeTree(ins));
+      PersonIdent ident = serverIdent.get();
+      cb.setAuthor(ident);
+      cb.setCommitter(ident);
+      cb.setMessage("Direct update submodule " + path);
+      ObjectId newCommitId = ins.insert(cb);
+      ins.flush();
+
+      RefUpdate ru = serverRepo.updateRef(refName);
+      ru.setExpectedOldObjectId(oldCommitId);
+      ru.setNewObjectId(newCommitId);
+      assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index 7a6a3c4..0de307a 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -1,27 +1,32 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "git",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = ["git"],
     deps = [
         ":push_for_review",
         ":submodule_util",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/server/git/receive/testing",
+        "//lib/commons:lang",
     ],
-)
+) for f in glob(["*IT.java"])]
 
 java_library(
     name = "push_for_review",
-    testonly = 1,
+    testonly = True,
     srcs = ["AbstractPushForReview.java"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/mail",
     ],
 )
 
 java_library(
     name = "submodule_util",
-    testonly = 1,
+    testonly = True,
     srcs = ["AbstractSubmoduleSubscription.java"],
     deps = ["//java/com/google/gerrit/acceptance:lib"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
index 87ac022..5ec7b0e 100644
--- a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
@@ -23,8 +24,10 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.PushResult;
@@ -33,12 +36,13 @@
 
 @NoHttpd
 public class ForcePushIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void forcePushNotAllowed() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
     PushOneCommit push1 =
-        pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
     r1.assertOkStatus();
 
@@ -48,18 +52,22 @@
     assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
 
     PushOneCommit push2 =
-        pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
+        pushFactory.create(admin.newIdent(), 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
   public void forcePushAllowed() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     PushOneCommit push1 =
-        pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
     r1.assertOkStatus();
 
@@ -69,7 +77,7 @@
     assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
 
     PushOneCommit push2 =
-        pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
     push2.setForce(true);
     PushOneCommit.Result r2 = push2.to("refs/heads/master");
     r2.assertOkStatus();
@@ -82,19 +90,31 @@
 
   @Test
   public void deleteNotAllowedWithOnlyPushPermission() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, false);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()))
+        .update();
     assertDeleteRef(REJECTED_OTHER_REASON);
   }
 
   @Test
   public void deleteAllowedWithForcePushPermission() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     assertDeleteRef(OK);
   }
 
   @Test
   public void deleteAllowedWithDeletePermission() throws Exception {
-    grant(project, "refs/*", Permission.DELETE, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     assertDeleteRef(OK);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java b/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
new file mode 100644
index 0000000..e2aa666
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.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.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Test;
+
+public class GitmodulesIT extends AbstractDaemonTest {
+  @Test
+  public void invalidSubmoduleURLIsRejected() throws Exception {
+    pushGitmodules("name", "-invalid-url", "path", "Invalid submodule URL");
+  }
+
+  @Test
+  public void invalidSubmodulePathIsRejected() throws Exception {
+    pushGitmodules("name", "http://somewhere", "-invalid-path", "Invalid submodule path");
+  }
+
+  @Test
+  public void invalidSubmoduleNameIsRejected() throws Exception {
+    pushGitmodules("-invalid-name", "http://somewhere", "path", "Invalid submodule name");
+  }
+
+  private void pushGitmodules(String name, String url, String path, String expectedErrorMessage)
+      throws Exception {
+    Config config = new Config();
+    config.setString("submodule", name, "url", url);
+    config.setString("submodule", name, "path", path);
+    TestRepository<?> repo = cloneProject(project);
+    repo.branch("HEAD")
+        .commit()
+        .insertChangeId()
+        .message("subject: adding new subscription")
+        .add(".gitmodules", config.toText())
+        .create();
+
+    TransportException thrown =
+        assertThrows(
+            TransportException.class,
+            () ->
+                repo.git()
+                    .push()
+                    .setRemote("origin")
+                    .setRefSpecs(new RefSpec("HEAD:refs/for/master"))
+                    .call());
+    assertThat(thrown).hasMessageThat().contains(expectedErrorMessage);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java b/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
index ed17c38..6b7adf1 100644
--- a/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
@@ -14,15 +14,83 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.FakeGroupAuditService;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.audit.HttpAuditEvent;
+import com.google.inject.Inject;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
 import org.junit.Before;
+import org.junit.Test;
 
 public class HttpPushForReviewIT extends AbstractPushForReview {
+  @Inject private FakeGroupAuditService auditService;
+
   @Before
   public void selectHttpUrl() throws Exception {
     CredentialsProvider.setDefault(
-        new UsernamePasswordCredentialsProvider(admin.username, admin.httpPassword));
+        new UsernamePasswordCredentialsProvider(admin.username(), admin.httpPassword()));
     selectProtocol(Protocol.HTTP);
+    // Don't clear audit events here, since we can't guarantee all test setup has run yet.
+  }
+
+  @Test
+  public void receivePackAuditEventLog() throws Exception {
+    auditService.drainHttpAuditEvents();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master"))
+        .call();
+
+    ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
+    assertThat(auditEvents).hasSize(2);
+
+    HttpAuditEvent lsRemote = auditEvents.get(0);
+    assertThat(lsRemote.who.getAccountId()).isEqualTo(admin.id());
+    assertThat(lsRemote.what).endsWith("/info/refs?service=git-receive-pack");
+    assertThat(lsRemote.params).containsExactly("service", "git-receive-pack");
+    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+
+    HttpAuditEvent receivePack = auditEvents.get(1);
+    assertThat(receivePack.who.getAccountId()).isEqualTo(admin.id());
+    assertThat(receivePack.what).endsWith("/git-receive-pack");
+    assertThat(receivePack.params).isEmpty();
+    assertThat(receivePack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+  }
+
+  @Test
+  public void uploadPackAuditEventLog() throws Exception {
+    auditService.drainHttpAuditEvents();
+    // testRepo is already a clone. Make a server-side change so we have something to fetch.
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch("master").commit().create();
+    }
+    testRepo.git().fetch().call();
+
+    ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
+    assertThat(auditEvents).hasSize(2);
+
+    HttpAuditEvent lsRemote = auditEvents.get(0);
+    // Repo URL doesn't include /a, so fetching doesn't cause authentication.
+    assertThat(lsRemote.who).isInstanceOf(AnonymousUser.class);
+    assertThat(lsRemote.what).endsWith("/info/refs?service=git-upload-pack");
+    assertThat(lsRemote.params).containsExactly("service", "git-upload-pack");
+    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+
+    HttpAuditEvent uploadPack = auditEvents.get(1);
+    assertThat(lsRemote.who).isInstanceOf(AnonymousUser.class);
+    assertThat(uploadPack.what).endsWith("/git-upload-pack");
+    assertThat(uploadPack.params).isEmpty();
+    assertThat(uploadPack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
index 954ca8b..7a4a5c5 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -76,8 +77,9 @@
     assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
   }
 
-  private static String implicitMergeOf(ObjectId commit) {
-    return "implicit merge of " + commit.abbreviate(7).name();
+  private String implicitMergeOf(ObjectId commit) throws Exception {
+    return "implicit merge of "
+        + ObjectIds.abbreviateName(commit, testRepo.getRevWalk().getObjectReader());
   }
 
   private void setRejectImplicitMerges() throws Exception {
@@ -91,8 +93,7 @@
 
   private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
       throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
     return push.to(ref);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index b362a36..66af8a4 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.git.testing.PushResultSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
@@ -24,18 +24,18 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.inject.Inject;
 import java.util.Arrays;
 import java.util.function.Consumer;
 import org.eclipse.jgit.api.PushCommand;
@@ -46,17 +46,19 @@
 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;
 
 public class PushPermissionsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Before
   public void setUp() throws Exception {
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
       ProjectConfig cfg = u.getConfig();
-      cfg.getProject()
-          .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
 
       // Remove push-related permissions, so they can be added back individually by test methods.
       removeAllBranchPermissions(
@@ -68,13 +70,26 @@
           Permission.PUSH_MERGE,
           Permission.SUBMIT);
       removeAllGlobalCapabilities(cfg, GlobalCapability.ADMINISTRATE_SERVER);
-
-      // Include some auxiliary permissions.
-      Util.allow(cfg, Permission.FORGE_AUTHOR, REGISTERED_USERS, "refs/*");
-      Util.allow(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, "refs/*");
-
       u.save();
     }
+
+    // Include some auxiliary permissions.
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  @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
@@ -83,15 +98,13 @@
     PushResult r = push("HEAD:refs/heads/master");
     assertThat(r)
         .onlyRef("refs/heads/master")
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branch refs/heads/master:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/heads/master:",
             "To push into this reference you need 'Push' rights.",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -99,24 +112,25 @@
   public void nonFastForwardUpdateDenied() throws Exception {
     ObjectId commit = testRepo.commit().create();
     PushResult r = push("+" + commit.name() + ":refs/heads/master");
-    assertThat(r).onlyRef("refs/heads/master").isRejected("need 'Force Push' privilege.");
-    assertThat(r).hasNoMessages();
-    // TODO(dborowitz): Why does this not mention refs?
-    assertThat(r).hasProcessed(ImmutableMap.of());
+    assertThat(r)
+        .onlyRef("refs/heads/master")
+        .isRejected("prohibited by Gerrit: not permitted: force update");
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
   @Test
   public void deleteDenied() throws Exception {
     PushResult r = push(":refs/heads/master");
-    assertThat(r).onlyRef("refs/heads/master").isRejected("cannot delete references");
+    assertThat(r)
+        .onlyRef("refs/heads/master")
+        .isRejected("prohibited by Gerrit: not permitted: delete");
     assertThat(r)
         .hasMessages(
-            "Branch refs/heads/master:",
+            "error: branch refs/heads/master:",
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -126,37 +140,35 @@
     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));
   }
 
   @Test
   public void groupRefsByMessage() throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
       tr.branch("foo").commit().create();
       tr.branch("bar").commit().create();
     }
 
     testRepo.branch("HEAD").commit().create();
     PushResult r = push(":refs/heads/foo", ":refs/heads/bar", "HEAD:refs/heads/master");
-    assertThat(r).ref("refs/heads/foo").isRejected("cannot delete references");
-    assertThat(r).ref("refs/heads/bar").isRejected("cannot delete references");
+    assertThat(r).ref("refs/heads/foo").isRejected("prohibited by Gerrit: not permitted: delete");
+    assertThat(r).ref("refs/heads/bar").isRejected("prohibited by Gerrit: not permitted: delete");
     assertThat(r)
         .ref("refs/heads/master")
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branches refs/heads/foo, refs/heads/bar:",
+            "error: branches refs/heads/foo, refs/heads/bar:",
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
-            "Branch refs/heads/master:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/heads/master:",
             "To push into this reference you need 'Push' rights.",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
   }
 
   @Test
@@ -178,7 +190,11 @@
 
   @Test
   public void refsMetaConfigUpdateRequiresProjectOwner() throws Exception {
-    grant(project, "refs/meta/config", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
 
     forceFetch("refs/meta/config");
     ObjectId commit = testRepo.branch("refs/meta/config").commit().create();
@@ -188,19 +204,21 @@
         // ReceiveCommits theoretically has a different message when a WRITE_CONFIG check fails, but
         // it never gets there, since DefaultPermissionBackend special-cases refs/meta/config and
         // denies UPDATE if the user is not a project owner.
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branch refs/meta/config:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/meta/config:",
             "Configuration changes can only be pushed by project owners",
             "who also have 'Push' rights on refs/meta/config",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
 
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Re-fetch refs/meta/config from the server because the grant changed it, and we want a
     // fast-forward.
@@ -216,46 +234,54 @@
     PushResult r = push("HEAD:refs/for/master");
     assertThat(r)
         .onlyRef("refs/for/master")
-        .isRejected("create change not permitted for refs/heads/master");
+        .isRejected("prohibited by Gerrit: not permitted: create change on refs/heads/master");
     assertThat(r)
-        .hasMessages(
-            "Branch refs/heads/master:",
-            "You need 'Push' rights to upload code review requests.",
-            "Verify that you are pushing to the right branch.",
-            "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+        .containsMessages(
+            "error: branch refs/for/master:",
+            "You need 'Create Change' rights to upload code review requests.",
+            "Verify that you are pushing to the right branch.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
   @Test
   public void updateBySubmitDenied() throws Exception {
-    grant(project, "refs/for/refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/for/refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ObjectId commit = testRepo.branch("HEAD").commit().create();
+    ObjectId commit =
+        testRepo.branch("HEAD").commit().message("test commit").insertChangeId().create();
     assertThat(push("HEAD:refs/for/master")).onlyRef("refs/for/master").isOk();
     gApi.changes().id(commit.name()).current().review(ReviewInput.approve());
 
     PushResult r = push("HEAD:refs/for/master%submit");
     assertThat(r)
         .onlyRef("refs/for/master%submit")
-        .isRejected("update by submit not permitted for refs/heads/master");
-    assertThat(r).hasNoMessages();
+        .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));
   }
 
   @Test
   public void addPatchSetDenied() throws Exception {
-    grant(project, "refs/for/refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
-    setApiUser(user);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/for/refs/heads/*").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
     ChangeInput ci = new ChangeInput();
     ci.project = project.get();
     ci.branch = "master";
     ci.subject = "A change";
-    Change.Id id = new Change.Id(gApi.changes().create(ci).get()._number);
+    Change.Id id = Change.id(gApi.changes().create(ci).get()._number);
 
-    setApiUser(admin);
-    ObjectId ps1Id = forceFetch(new PatchSet.Id(id, 1).toRefName());
+    requestScopeOperations.setApiUser(admin.id());
+    ObjectId ps1Id = forceFetch(PatchSet.id(id, 1).toRefName());
     ObjectId ps2Id = testRepo.amend(ps1Id).add("file", "content").create();
     PushResult r = push(ps2Id.name() + ":refs/for/master");
     assertThat(r)
@@ -267,21 +293,32 @@
 
   @Test
   public void skipValidationDenied() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
     testRepo.branch("HEAD").commit().create();
     PushResult r =
         push(c -> c.setPushOptions(ImmutableList.of("skip-validation")), "HEAD:refs/heads/master");
     assertThat(r)
         .onlyRef("refs/heads/master")
-        .isRejected("skip validation not permitted for refs/heads/master");
-    assertThat(r).hasNoMessages();
+        .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));
   }
 
   @Test
   public void accessDatabaseForNoteDbDenied() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
     testRepo.branch("HEAD").commit().create();
     PushResult r =
@@ -298,8 +335,12 @@
 
   @Test
   public void administrateServerForUpdateParentDenied() throws Exception {
-    grant(project, "refs/meta/config", Permission.PUSH, false, REGISTERED_USERS);
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     String project2 = name("project2");
     gApi.projects().create(project2);
@@ -320,8 +361,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    cfg.getAccessSections()
-        .stream()
+    cfg.getAccessSections().stream()
         .filter(
             s ->
                 s.getName().startsWith("refs/heads/")
@@ -336,8 +376,7 @@
             c ->
                 cfg.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
                     .getPermission(c, true)
-                    .getRules()
-                    .clear());
+                    .clearRules());
   }
 
   private PushResult push(String... refSpecs) throws Exception {
@@ -376,7 +415,7 @@
       case REJECTED_OTHER_REASON:
       case RENAMED:
       default:
-        assert_().fail("fetch failed to update local %s: %s", ref, u.getResult());
+        assertWithMessage("fetch failed to update local %s: %s", ref, u.getResult()).fail();
         break;
     }
     return u.getNewObjectId();
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 1de9d29..88fc557 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -18,18 +18,23 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
 
-import com.google.common.base.Predicates;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.ProjectResetter;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -44,55 +49,74 @@
 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.Sequences;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHook;
+import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
+import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testing.NoteDbMode;
-import com.google.gerrit.testing.TestChanges;
+import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 import java.util.function.Predicate;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 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.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.ReceivePack;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class RefAdvertisementIT extends AbstractDaemonTest {
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private ChangeNoteUtil noteUtil;
-  @Inject @AnonymousCowardName private String anonymousCowardName;
   @Inject private AllUsersName allUsersName;
+  @Inject private ChangeNoteUtil noteUtil;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private AccountGroup.UUID admins;
   private AccountGroup.UUID nonInteractiveUsers;
 
-  private ChangeData c1;
-  private ChangeData c2;
-  private ChangeData c3;
-  private ChangeData c4;
-  private String r1;
-  private String r2;
-  private String r3;
-  private String r4;
+  private RevCommit rcMaster;
+  private RevCommit rcBranch;
+
+  private ChangeData cd1;
+  private String psRef1;
+  private String metaRef1;
+
+  private ChangeData cd2;
+  private String psRef2;
+  private String metaRef2;
+
+  private ChangeData cd3;
+  private String psRef3;
+  private String metaRef3;
+
+  private ChangeData cd4;
+  private String psRef4;
+  private String metaRef4;
+
+  @ConfigSuite.Config
+  public static Config enableFullRefEvaluation() {
+    Config cfg = new Config();
+    cfg.setBoolean("auth", null, "skipFullRefEvaluationIfAllRefsAreVisible", false);
+    return cfg;
+  }
 
   @Before
   public void setUp() throws Exception {
@@ -102,19 +126,21 @@
     setUpChanges();
   }
 
+  // This method is idempotent, so it is safe to call it on every test setup.
   private void setUpPermissions() throws Exception {
-    // Remove read permissions for all users besides admin. This method is idempotent, so is safe
-    // to call on every test setup.
+    // Remove read permissions for all users besides admin.
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
       for (AccessSection sec : u.getConfig().getAccessSections()) {
         sec.removePermission(Permission.READ);
       }
-      Util.allow(u.getConfig(), Permission.READ, admins, "refs/*");
       u.save();
     }
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(admins))
+        .update();
 
-    // Remove all read permissions on All-Users. This method is idempotent, so is safe to call on
-    // every test setup.
+    // Remove all read permissions on All-Users.
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
       for (AccessSection sec : u.getConfig().getAccessSections()) {
         sec.removePermission(Permission.READ);
@@ -123,343 +149,956 @@
     }
   }
 
-  private static String changeRefPrefix(Change.Id id) {
-    String ps = new PatchSet.Id(id, 1).toRefName();
-    return ps.substring(0, ps.length() - 1);
-  }
-
+  // Building the following:
+  //   rcMaster (c1 master master-tag) <-- rcBranch (c2 branch branch-tag)
+  //      \                                    \
+  //    (c3_open)                            (c4_open)
+  //
   private void setUpChanges() throws Exception {
     gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
     // First 2 changes are merged, which means the tags pointing to them are
     // visible.
-    allow("refs/for/refs/heads/*", Permission.SUBMIT, admins);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(admins))
+        .update();
+
+    //   rcMaster (c1 master)
     PushOneCommit.Result mr =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%submit");
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%submit");
     mr.assertOkStatus();
-    c1 = mr.getChange();
-    r1 = changeRefPrefix(c1.getId());
+    cd1 = mr.getChange();
+    rcMaster = mr.getCommit();
+    psRef1 = cd1.currentPatchSet().id().toRefName();
+    metaRef1 = RefNames.changeMetaRef(cd1.getId());
+
+    //   rcMaster (c1 master) <-- rcBranch (c2 branch)
     PushOneCommit.Result br =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/branch%submit");
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/branch%submit");
     br.assertOkStatus();
-    c2 = br.getChange();
-    r2 = changeRefPrefix(c2.getId());
+    cd2 = br.getChange();
+    rcBranch = br.getCommit();
+    psRef2 = cd2.currentPatchSet().id().toRefName();
+    metaRef2 = RefNames.changeMetaRef(cd2.getId());
 
     // Second 2 changes are unmerged.
-    mr = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
+    //   rcMaster (c1 master) <-- rcBranch (c2 branch)
+    //      \
+    //    (c3_open)
+    //
+    mr = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
     mr.assertOkStatus();
-    c3 = mr.getChange();
-    r3 = changeRefPrefix(c3.getId());
-    br = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/branch");
+    cd3 = mr.getChange();
+    psRef3 = cd3.currentPatchSet().id().toRefName();
+    metaRef3 = RefNames.changeMetaRef(cd3.getId());
+
+    //   rcMaster (c1 master) <-- rcBranch (c2 branch)
+    //      \                        \
+    //     (c3_open)                (c4_open)
+    br = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/branch");
     br.assertOkStatus();
-    c4 = br.getChange();
-    r4 = changeRefPrefix(c4.getId());
+    cd4 = br.getChange();
+    psRef4 = cd4.currentPatchSet().id().toRefName();
+    metaRef4 = RefNames.changeMetaRef(cd4.getId());
 
     try (Repository repo = repoManager.openRepository(project)) {
-      // master-tag -> master
+      //   rcMaster (c1 master master-tag) <-- rcBranch (c2 branch)
+      //       \                                  \
+      //     (c3_open)                          (c4_open)
       RefUpdate mtu = repo.updateRef("refs/tags/master-tag");
       mtu.setExpectedOldObjectId(ObjectId.zeroId());
       mtu.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
       assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW);
 
-      // branch-tag -> branch
+      //   rcMaster (c1 master master-tag) <-- rcBranch (c2 branch branch-tag)
+      //       \                                  \
+      //     (c3_open)                          (c4_open)
       RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
       btu.setExpectedOldObjectId(ObjectId.zeroId());
       btu.setNewObjectId(repo.exactRef("refs/heads/branch").getObjectId());
       assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+
+      // Create a tag for the tree of the commit on 'master'
+      // tree-tag -> master.tree
+      RefUpdate ttu = repo.updateRef("refs/tags/tree-tag");
+      ttu.setExpectedOldObjectId(ObjectId.zeroId());
+      ttu.setNewObjectId(rcMaster.getTree().toObjectId());
+      assertThat(ttu.update()).isEqualTo(RefUpdate.Result.NEW);
     }
   }
 
   @Test
+  @GerritConfig(name = "auth.skipFullRefEvaluationIfAllRefsAreVisible", value = "false")
   public void uploadPackAllRefsVisibleNoRefsMetaConfig() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.READ, admins, RefNames.REFS_CONFIG);
-      Util.doNotInherit(u.getConfig(), Permission.READ, RefNames.REFS_CONFIG);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(admins))
+        .setExclusiveGroup(permissionKey(Permission.READ).ref(RefNames.REFS_CONFIG), true)
+        .update();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
         "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        r4 + "1",
-        r4 + "meta",
+        psRef1,
+        metaRef1,
+        psRef2,
+        metaRef2,
+        psRef3,
+        metaRef3,
+        psRef4,
+        metaRef4,
         "refs/heads/branch",
         "refs/heads/master",
         "refs/tags/branch-tag",
         "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
+  }
+
+  @Test
+  @GerritConfig(name = "auth.skipFullRefEvaluationIfAllRefsAreVisible", value = "true")
+  public void uploadPackAllRefsVisibleNoRefsMetaConfigSkipFullRefEval() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(admins))
+        .setExclusiveGroup(permissionKey(Permission.READ).ref(RefNames.REFS_CONFIG), true)
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertUploadPackRefs(
+        "HEAD",
+        psRef1,
+        metaRef1,
+        psRef2,
+        metaRef2,
+        psRef3,
+        metaRef3,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/heads/master",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag",
+        "refs/tags/tree-tag");
   }
 
   @Test
   public void uploadPackAllRefsVisibleWithRefsMetaConfig() throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
-    allow(RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
 
     assertUploadPackRefs(
         "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        r4 + "1",
-        r4 + "meta",
+        psRef1,
+        metaRef1,
+        psRef2,
+        metaRef2,
+        psRef3,
+        metaRef3,
+        psRef4,
+        metaRef4,
         "refs/heads/branch",
         "refs/heads/master",
         RefNames.REFS_CONFIG,
         "refs/tags/branch-tag",
-        "refs/tags/master-tag");
+        "refs/tags/master-tag",
+        "refs/tags/tree-tag");
+  }
+
+  @Test
+  public void grantReadOnRefsTagsIsNoOp() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertUploadPackRefs(); // We expect no refs returned
   }
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleIncludingHead() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(deny(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        "refs/heads/master",
-        "refs/tags/master-tag");
+        "HEAD", psRef1, metaRef1, psRef3, metaRef3, "refs/heads/master", "refs/tags/master-tag");
+    // tree-tag is not visible because we don't look at trees reachable from
+    // refs
   }
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleNotIncludingHead() throws Exception {
-    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
-        r2 + "1",
-        r2 + "meta",
-        r4 + "1",
-        r4 + "meta",
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
         "refs/heads/branch",
         "refs/tags/branch-tag",
         // master branch is not visible but master-tag is reachable from branch
         // (since PushOneCommit always bases changes on each other).
         "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-
-    Change c = notesFactory.createChecked(db, project, c3.getId()).getChange();
-    String changeId = c.getKey().get();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     // Admin's edit is not visible.
-    setApiUser(admin);
-    gApi.changes().id(changeId).edit().create();
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(cd3.getId().get()).edit().create();
 
     // User's edit is visible.
-    setApiUser(user);
-    gApi.changes().id(changeId).edit().create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(cd3.getId().get()).edit().create();
 
     assertUploadPackRefs(
         "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r3 + "1",
-        r3 + "meta",
+        psRef1,
+        metaRef1,
+        psRef3,
+        metaRef3,
         "refs/heads/master",
         "refs/tags/master-tag",
-        "refs/tags/branch-tag",
-        "refs/users/01/1000001/edit-" + c3.getId() + "/1");
+        "refs/users/01/1000001/edit-" + cd3.getId() + "/1");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
   public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
-
-    Change change3 = notesFactory.createChecked(db, project, c3.getId()).getChange();
-    String changeId3 = change3.getKey().get();
-    Change change4 = notesFactory.createChecked(db, project, c4.getId()).getChange();
-    String changeId4 = change4.getKey().get();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Admin's edit on change3 is visible.
-    setApiUser(admin);
-    gApi.changes().id(changeId3).edit().create();
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(cd3.getId().get()).edit().create();
 
     // Admin's edit on change4 is not visible since user cannot see the change.
-    gApi.changes().id(changeId4).edit().create();
+    gApi.changes().id(cd4.getId().get()).edit().create();
 
     // User's edit is visible.
-    setApiUser(user);
-    gApi.changes().id(changeId3).edit().create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(cd3.getId().get()).edit().create();
 
     assertUploadPackRefs(
         "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r3 + "1",
-        r3 + "meta",
+        psRef1,
+        metaRef1,
+        psRef3,
+        metaRef3,
         "refs/heads/master",
         "refs/tags/master-tag",
-        "refs/tags/branch-tag",
-        "refs/users/00/1000000/edit-" + c3.getId() + "/1",
-        "refs/users/01/1000001/edit-" + c3.getId() + "/1");
+        "refs/users/00/1000000/edit-" + cd3.getId() + "/1",
+        "refs/users/01/1000001/edit-" + cd3.getId() + "/1");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
   public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
 
-    String changeId = c3.change().getKey().get();
-    setApiUser(admin);
-    gApi.changes().id(changeId).edit().create();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(cd3.getId().get()).edit().create();
+    requestScopeOperations.setApiUser(user.id());
 
     assertUploadPackRefs(
         // Change 1 is visible due to accessDatabase capability, even though
         // refs/heads/master is not.
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        r4 + "1",
-        r4 + "meta",
+        psRef1,
+        metaRef1,
+        psRef2,
+        metaRef2,
+        psRef3,
+        metaRef3,
+        psRef4,
+        metaRef4,
         "refs/heads/branch",
         "refs/tags/branch-tag",
         // See comment in subsetOfBranchesVisibleNotIncludingHead.
         "refs/tags/master-tag",
         // All edits are visible due to accessDatabase capability.
-        "refs/users/00/1000000/edit-" + c3.getId() + "/1");
+        "refs/users/00/1000000/edit-" + cd3.getId() + "/1");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
-  public void uploadPackNoSearchingChangeCacheImpl() throws Exception {
-    allow("refs/heads/*", Permission.READ, REGISTERED_USERS);
+  public void uploadPackNoSearchingChangeCacheImplMaster() throws Exception {
+    uploadPackNoSearchingChangeCacheImpl();
+  }
 
-    setApiUser(user);
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertRefs(
-          repo,
-          permissionBackend.user(user(user)).project(project),
-          // Can't use stored values from the index so DB must be enabled.
-          false,
-          "HEAD",
-          r1 + "1",
-          r1 + "meta",
-          r2 + "1",
-          r2 + "meta",
-          r3 + "1",
-          r3 + "meta",
-          r4 + "1",
-          r4 + "meta",
-          "refs/heads/branch",
-          "refs/heads/master",
-          "refs/tags/branch-tag",
-          "refs/tags/master-tag");
-    }
+  @Test
+  @GerritConfig(name = "container.slave", value = "true")
+  public void uploadPackNoSearchingChangeCacheImplSlave() throws Exception {
+    uploadPackNoSearchingChangeCacheImpl();
+  }
+
+  private void uploadPackNoSearchingChangeCacheImpl() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertRefs(
+        project,
+        user,
+        // Can't use stored values from the index so DB must be enabled.
+        false,
+        "HEAD",
+        psRef1,
+        metaRef1,
+        psRef2,
+        metaRef2,
+        psRef3,
+        metaRef3,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/heads/master",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
   }
 
   @Test
   public void uploadPackSequencesWithAccessDatabase() throws Exception {
-    assume().that(notesMigration.readChangeSequence()).isTrue();
-    try (Repository repo = repoManager.openRepository(allProjects)) {
-      assertRefs(repo, newFilter(allProjects, user), true);
+    assertRefs(allProjects, user, true);
 
-      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-      assertRefs(repo, newFilter(allProjects, user), true, "refs/sequences/changes");
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    assertRefs(allProjects, user, true, "refs/sequences/changes");
+  }
+
+  @Test
+  public void uploadPackAllRefsAreVisibleOrphanedTag() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    // Delete the pending change on 'branch' and 'branch' itself so that the tag gets orphaned
+    gApi.changes().id(cd4.getId().get()).delete();
+    gApi.projects().name(project.get()).branch("refs/heads/branch").delete();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertUploadPackRefs(
+        "HEAD",
+        "refs/meta/config",
+        psRef1,
+        metaRef1,
+        psRef2,
+        metaRef2,
+        psRef3,
+        metaRef3,
+        "refs/heads/master",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag",
+        "refs/tags/tree-tag");
+  }
+
+  @Test
+  public void uploadPackSubsetRefsVisibleOrphanedTagInvisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+    // Create a tag for the pending change on 'branch' so that the tag is orphaned
+    try (Repository repo = repoManager.openRepository(project)) {
+      // change4-tag -> psRef4
+      RefUpdate ctu = repo.updateRef("refs/tags/change4-tag");
+      ctu.setExpectedOldObjectId(ObjectId.zeroId());
+      ctu.setNewObjectId(repo.exactRef(psRef4).getObjectId());
+      assertThat(ctu.update()).isEqualTo(RefUpdate.Result.NEW);
     }
+
+    requestScopeOperations.setApiUser(user.id());
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcMaster (c1 master)
+  // second ls-remote: rcMaster (c1 master) <- newchange1 (master-newtag)
+  @Test
+  public void uploadPackNewCommitOrphanTagInvisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // rcMaster (c1 master)
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      PushOneCommit.Result r =
+          pushFactory.create(admin.newIdent(), testRepo).setParent(rcMaster).to("refs/for/master");
+      r.assertOkStatus();
+
+      // rcMaster (c1 master) <- newchange1 (master-newtag)
+      RefUpdate btu = repo.updateRef("refs/tags/master-newtag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(r.getCommit());
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2) <- newcommit1                 <- newcommit2 (branch)
+  // second ls-remote: rcBranch (c2) <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+  @Test
+  public void uploadPackNewReachableTagVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // c2 <- newcommit1 (branch)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/heads/branch");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      // c2 <- newcommit1 <- newcommit2 (branch)
+      r = pushFactory.create(admin.newIdent(), testRepo).setParent(tagRc).to("refs/heads/branch");
+      r.assertOkStatus();
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          // See comment in subsetOfBranchesVisibleNotIncludingHead.
+          "refs/tags/master-tag");
+
+      // c2 <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+      RefUpdate btu = repo.updateRef("refs/tags/branch-newtag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(tagRc);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/branch-newtag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2) <- newcommit1 (branch)
+  // second ls-remote: rcBranch (c2) <- newcommit1                 <- newcommit2 (branch)
+  // third  ls-remote: rcBranch (c2) <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+  @Test
+  public void uploadPackBranchFFNewTagOldBranchVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2) <- newcommit1 (branch)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/heads/branch");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          // See comment in subsetOfBranchesVisibleNotIncludingHead.
+          "refs/tags/master-tag");
+
+      // rcBranch (c2) <- newcommit1 <- newcommit2 (branch)
+      r = pushFactory.create(admin.newIdent(), testRepo).setParent(tagRc).to("refs/heads/branch");
+      r.assertOkStatus();
+
+      // rcBranch (c2) <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+      RefUpdate btu = repo.updateRef("refs/tags/branch-newtag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(tagRc);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/branch-newtag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2)        <- newcommit1 (branch-oldtag) <- newcommit2 (branch)
+  // second ls-remote: rcBranch (c2 branch) <- newcommit1 (branch-oldtag)
+  @Test
+  public void uploadPackBranchRewindMakeTagUnreachableInVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2) <- newcommit1 (branch)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/heads/branch");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      // rcBranch (c2) <- newcommit1 <- newcommit2 (branch)
+      r = pushFactory.create(admin.newIdent(), testRepo).setParent(tagRc).to("refs/heads/branch");
+      r.assertOkStatus();
+      RevCommit bRc = r.getCommit();
+
+      // rcBranch (c2) <- newcommit1 (branch-oldtag) <- newcommit2 (branch)
+      RefUpdate btu = repo.updateRef("refs/tags/branch-oldtag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(tagRc);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          "refs/tags/branch-oldtag",
+          // See comment in subsetOfBranchesVisibleNotIncludingHead.
+          "refs/tags/master-tag");
+
+      // rcBranch (c2 branch) <- newcommit1 (branch-oldtag) <- newcommit2
+      btu = repo.updateRef("refs/heads/branch");
+      btu.setExpectedOldObjectId(bRc);
+      btu.setNewObjectId(rcBranch);
+      btu.setForceUpdate(true);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch) <- newcommit1 (new-tag)
+  // second ls-remote: rcBranch (c2 branch) <- newcommit1 (new-tag) <- newcommit2 (new-branch)
+  @Test
+  public void uploadPackCreateBranchTagReachableVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/new-branch").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2 branch) <- newcommit1 (branch-newtag)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/tags/new-tag");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      assertUploadPackRefs();
+
+      // rcBranch (c2) <- newcommit1 (branch-newtag) <- newcommit2 (branch)
+      r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(tagRc)
+              .to("refs/heads/new-branch");
+      r.assertOkStatus();
+    }
+
+    assertUploadPackRefs(
+        "refs/heads/new-branch",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag",
+        "refs/tags/new-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch)               <- newcommit1 (updated-tag)
+  // second ls-remote: rcBranch (c2 branch updated-tag)
+  @Test
+  public void uploadPackTagUpdatedReachableVisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2 branch) <- newcommit1 (updated-tag)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/tags/updated-tag");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          "refs/tags/master-tag");
+
+      // rcBranch (c2 branch updated-tag)
+      RefUpdate btu = repo.updateRef("refs/tags/updated-tag");
+      btu.setExpectedOldObjectId(tagRc);
+      btu.setNewObjectId(rcBranch);
+      btu.setForceUpdate(true);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag",
+        "refs/tags/updated-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch updated-tag)
+  // second ls-remote: rcBranch (c2 branch)             <- newcommit1 (updated-tag)
+  @Test
+  public void uploadPackTagUpdatedUnreachableInvisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2 branch updated-tag)
+      RefUpdate btu = repo.updateRef("refs/tags/updated-tag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(rcBranch);
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+
+      assertUploadPackRefs(
+          psRef2,
+          metaRef2,
+          psRef4,
+          metaRef4,
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          "refs/tags/master-tag",
+          "refs/tags/updated-tag");
+
+      // rcBranch (c2 branch) <- newcommit1 (updated-tag)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/tags/updated-tag");
+      r.assertOkStatus();
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch branch-tag)
+  // second ls-remote: rcBranch (c2 branch)
+  @Test
+  public void uploadPackTagDeleted() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .add(allow(Permission.DELETE).ref("refs/tags/branch-tag").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/branch-tag").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // rcBranch (c2 branch branch-tag)
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+
+    // rcBranch (c2 branch)
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
+      btu.setExpectedOldObjectId(rcBranch);
+      btu.setNewObjectId(ObjectId.zeroId());
+      btu.setForceUpdate(true);
+      assertThat(btu.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+
+    assertUploadPackRefs(
+        psRef2, metaRef2, psRef4, metaRef4, "refs/heads/branch", "refs/tags/master-tag");
+  }
+
+  // first  ls-remote: rcBranch (c2 branch) <- newcommit1 (new-tag) <- newcommit2 (new-branch)
+  // second ls-remote: rcBranch (c2 branch) <- newcommit1 (new-tag)
+  @Test
+  public void uploadPackBranchDeleteTagUnreachableInvisible() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/new-branch").group(REGISTERED_USERS))
+        .add(allow(Permission.DELETE).ref("refs/heads/new-branch").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (branch) <- newcommit1 (new-tag)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/tags/new-tag");
+      r.assertOkStatus();
+      RevCommit tagRc = r.getCommit();
+
+      // rcBranch (c2 branch) <- newcommit1 (new-tag) <- newcommit2 (new-branch)
+      r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(tagRc)
+              .to("refs/heads/new-branch");
+      r.assertOkStatus();
+    }
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/heads/new-branch",
+        "refs/tags/new-tag",
+        "refs/tags/master-tag");
+
+    // rcBranch (c2 branch) <- newcommit1 (new-tag)
+    gApi.projects().name(project.get()).branch("refs/heads/new-branch").delete();
+
+    assertUploadPackRefs(
+        psRef2,
+        metaRef2,
+        psRef4,
+        metaRef4,
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
   }
 
   @Test
   public void receivePackListsOpenChangesAsAdditionalHaves() throws Exception {
-    ReceiveCommitsAdvertiseRefsHook.Result r = getReceivePackRefs();
+    TestRefAdvertiser.Result r = getReceivePackRefs(admin);
     assertThat(r.allRefs().keySet())
         .containsExactly(
-            // meta refs are excluded even when NoteDb is enabled.
-            "HEAD",
+            // meta refs are excluded
             "refs/heads/branch",
             "refs/heads/master",
             "refs/meta/config",
             "refs/tags/branch-tag",
-            "refs/tags/master-tag");
-    assertThat(r.additionalHaves()).containsExactly(obj(c3, 1), obj(c4, 1));
+            "refs/tags/master-tag",
+            "refs/tags/tree-tag");
+    assertThat(r.additionalHaves()).containsExactly(obj(cd3, 1), obj(cd4, 1));
   }
 
   @Test
   public void receivePackRespectsVisibilityOfOpenChanges() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
-    setApiUser(user);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(deny(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
 
-    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 1));
+    assertThat(getReceivePackRefs(user).additionalHaves()).containsExactly(obj(cd3, 1));
   }
 
   @Test
   public void receivePackListsOnlyLatestPatchSet() throws Exception {
-    testRepo.reset(obj(c3, 1));
-    PushOneCommit.Result r = amendChange(c3.change().getKey().get());
+    testRepo.reset(obj(cd3, 1));
+    PushOneCommit.Result r = amendChange(cd3.change().getKey().get());
     r.assertOkStatus();
-    c3 = r.getChange();
-    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 2), obj(c4, 1));
+    cd3 = r.getChange();
+    assertThat(getReceivePackRefs(admin).additionalHaves())
+        .containsExactly(obj(cd3, 2), obj(cd4, 1));
   }
 
   @Test
   public void receivePackOmitsMissingObject() throws Exception {
     String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
       String subject = "Subject for missing commit";
-      Change c = new Change(c3.change());
-      PatchSet.Id psId = new PatchSet.Id(c3.getId(), 2);
+      Change c = new Change(cd3.change());
+      PatchSet.Id psId = PatchSet.id(cd3.getId(), 2);
       c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
 
-      if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
-        PatchSet ps = TestChanges.newPatchSet(psId, rev, admin.getId());
-        db.patchSets().insert(Collections.singleton(ps));
-        db.changes().update(Collections.singleton(c));
-      }
-
-      if (notesMigration.commitChangeWrites()) {
-        PersonIdent committer = serverIdent.get();
-        PersonIdent author =
-            noteUtil
-                .getLegacyChangeNoteWrite()
-                .newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
-        tr.branch(RefNames.changeMetaRef(c3.getId()))
-            .commit()
-            .author(author)
-            .committer(committer)
-            .message(
-                "Update patch set "
-                    + psId.get()
-                    + "\n"
-                    + "\n"
-                    + "Patch-set: "
-                    + psId.get()
-                    + "\n"
-                    + "Commit: "
-                    + rev
-                    + "\n"
-                    + "Subject: "
-                    + subject
-                    + "\n")
-            .create();
-      }
-      indexer.index(db, c.getProject(), c.getId());
+      PersonIdent committer = serverIdent.get();
+      PersonIdent author =
+          noteUtil.newIdent(getAccount(admin.id()), committer.getWhen(), committer);
+      tr.branch(RefNames.changeMetaRef(cd3.getId()))
+          .commit()
+          .author(author)
+          .committer(committer)
+          .message(
+              "Update patch set "
+                  + psId.get()
+                  + "\n"
+                  + "\n"
+                  + "Patch-set: "
+                  + psId.get()
+                  + "\n"
+                  + "Commit: "
+                  + rev
+                  + "\n"
+                  + "Subject: "
+                  + subject
+                  + "\n")
+          .create();
+      indexer.index(c.getProject(), c.getId());
     }
 
-    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c4, 1));
+    assertThat(getReceivePackRefs(admin).additionalHaves()).containsExactly(obj(cd4, 1));
   }
 
   @Test
@@ -472,75 +1111,66 @@
 
   @Test
   public void advertisedReferencesOmitUserBranchesOfOtherUsers() throws Exception {
-    allow(allUsersName, RefNames.REFS_USERS + "*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .update();
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
       assertThat(getUserRefs(git))
-          .containsExactly(RefNames.REFS_USERS_SELF, RefNames.refsUsers(user.id));
+          .containsExactly(RefNames.REFS_USERS_SELF, RefNames.refsUsers(user.id()));
     }
   }
 
   @Test
   public void advertisedReferencesIncludeAllUserBranchesWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
       assertThat(getUserRefs(git))
           .containsExactly(
-              RefNames.REFS_USERS_SELF, RefNames.refsUsers(user.id), RefNames.refsUsers(admin.id));
+              RefNames.REFS_USERS_SELF,
+              RefNames.refsUsers(user.id()),
+              RefNames.refsUsers(admin.id()));
     }
   }
 
   @Test
-  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void advertisedReferencesDontShowGroupBranchToOwnerWithoutRead() throws Exception {
-    try (ProjectResetter resetter = resetGroups()) {
-      createSelfOwnedGroup("Foos", user);
-      TestRepository<?> userTestRepository = cloneProject(allUsers, user);
-      try (Git git = userTestRepository.git()) {
-        assertThat(getGroupRefs(git)).isEmpty();
-      }
+    createSelfOwnedGroup("Foos", user);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getGroupRefs(git)).isEmpty();
     }
   }
 
   @Test
-  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void advertisedReferencesOmitGroupBranchesOfNonOwnedGroups() throws Exception {
-    try (ProjectResetter resetter = resetGroups()) {
-      allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
-      AccountGroup.UUID users = createGroup("Users", admins, user);
-      AccountGroup.UUID foos = createGroup("Foos", users);
-      AccountGroup.UUID bars = createSelfOwnedGroup("Bars", user);
-      TestRepository<?> userTestRepository = cloneProject(allUsers, user);
-      try (Git git = userTestRepository.git()) {
-        assertThat(getGroupRefs(git))
-            .containsExactly(RefNames.refsGroups(foos), RefNames.refsGroups(bars));
-      }
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
+    AccountGroup.UUID users = createGroup("Users", admins, user);
+    AccountGroup.UUID foos = createGroup("Foos", users);
+    AccountGroup.UUID bars = createSelfOwnedGroup("Bars", user);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getGroupRefs(git))
+          .containsExactly(RefNames.refsGroups(foos), RefNames.refsGroups(bars));
     }
   }
 
   @Test
-  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void advertisedReferencesIncludeAllGroupBranchesWithAccessDatabase() throws Exception {
-    try (ProjectResetter resetter = resetGroups()) {
-      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-      AccountGroup.UUID users = createGroup("Users", admins);
-      TestRepository<?> userTestRepository = cloneProject(allUsers, user);
-      try (Git git = userTestRepository.git()) {
-        assertThat(getGroupRefs(git))
-            .containsExactly(
-                RefNames.refsGroups(admins),
-                RefNames.refsGroups(nonInteractiveUsers),
-                RefNames.refsGroups(users));
-      }
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "noteDb.groups.write", value = "true")
-  public void advertisedReferencesIncludeAllGroupBranchesForAdmins() throws Exception {
-    allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ADMINISTRATE_SERVER);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     AccountGroup.UUID users = createGroup("Users", admins);
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
@@ -553,9 +1183,34 @@
   }
 
   @Test
-  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesIncludeAllGroupBranchesForAdmins() throws Exception {
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
+    AccountGroup.UUID users = createGroup("Users", admins);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getGroupRefs(git))
+          .containsExactly(
+              RefNames.refsGroups(admins),
+              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.refsGroups(users));
+    }
+  }
+
+  @Test
   public void advertisedReferencesOmitNoteDbNotesBranches() throws Exception {
-    allow(allUsersName, RefNames.REFS + "*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
       assertThat(getRefs(git)).containsNoneOf(RefNames.REFS_EXTERNAL_IDS, RefNames.REFS_GROUPNAMES);
@@ -564,46 +1219,83 @@
 
   @Test
   public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      String change3RefName = c3.currentPatchSet().getRefName();
+      String change3RefName = cd3.currentPatchSet().refName();
       assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
-      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
+      gApi.changes().id(cd3.getId().get()).setPrivate(true, null);
       assertThat(getRefs(git)).doesNotContain(change3RefName);
     }
   }
 
   @Test
   public void advertisedReferencesIncludePrivateChangesWhenAllRefsMayBeRead() throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    assume()
+        .that(baseConfig.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true))
+        .isTrue();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      String change3RefName = c3.currentPatchSet().getRefName();
+      String change3RefName = cd3.currentPatchSet().refName();
       assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
-      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
+      gApi.changes().id(cd3.getId().get()).setPrivate(true, null);
       assertThat(getRefs(git)).contains(change3RefName);
     }
   }
 
   @Test
+  @GerritConfig(name = "auth.skipFullRefEvaluationIfAllRefsAreVisible", value = "false")
+  public void advertisedReferencesOmitPrivateChangesOfOtherUsersWhenShortcutDisabled()
+      throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      String change3RefName = cd3.currentPatchSet().refName();
+      assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
+
+      gApi.changes().id(cd3.getId().get()).setPrivate(true, null);
+      assertThat(getRefs(git)).doesNotContain(change3RefName);
+    }
+  }
+
+  @Test
   public void advertisedReferencesOmitDraftCommentRefsOfOtherUsers() throws Exception {
-    assume().that(notesMigration.commitChangeWrites()).isTrue();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
-    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
-    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
-
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     DraftInput draftInput = new DraftInput();
     draftInput.line = 1;
     draftInput.message = "nit: trailing whitespace";
     draftInput.path = Patch.COMMIT_MSG;
-    gApi.changes().id(c3.getId().get()).current().createDraft(draftInput);
-    String draftCommentRef = RefNames.refsDraftComments(c3.getId(), user.id);
+    gApi.changes().id(cd3.getId().get()).current().createDraft(draftInput);
+    String draftCommentRef = RefNames.refsDraftComments(cd3.getId(), user.id());
 
     // user can see the draft comment ref of the own draft comment
     assertThat(lsRemote(allUsersName, user)).contains(draftCommentRef);
@@ -614,14 +1306,20 @@
 
   @Test
   public void advertisedReferencesOmitStarredChangesRefsOfOtherUsers() throws Exception {
-    assume().that(notesMigration.commitChangeWrites()).isTrue();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
-    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
-    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
-
-    setApiUser(user);
-    gApi.accounts().self().starChange(c3.getId().toString());
-    String starredChangesRef = RefNames.refsStarredChanges(c3.getId(), user.id);
+    requestScopeOperations.setApiUser(user.id());
+    gApi.accounts().self().starChange(cd3.getId().toString());
+    String starredChangesRef = RefNames.refsStarredChanges(cd3.getId(), user.id());
 
     // user can see the starred changes ref of the own star
     assertThat(lsRemote(allUsersName, user)).contains(starredChangesRef);
@@ -631,24 +1329,26 @@
   }
 
   @Test
-  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void hideMetadata() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     // create change
     TestRepository<?> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userRef");
     allUsersRepo.reset("userRef");
     PushOneCommit.Result mr =
         pushFactory
-            .create(db, admin.getIdent(), allUsersRepo)
+            .create(admin.newIdent(), allUsersRepo)
             .to("refs/for/" + RefNames.REFS_USERS_SELF);
     mr.assertOkStatus();
 
     List<String> expectedNonMetaRefs =
         ImmutableList.of(
             RefNames.REFS_USERS_SELF,
-            RefNames.refsUsers(admin.id),
-            RefNames.refsUsers(user.id),
+            RefNames.refsUsers(admin.id()),
+            RefNames.refsUsers(user.id()),
             RefNames.REFS_EXTERNAL_IDS,
             RefNames.REFS_GROUPNAMES,
             RefNames.refsGroups(admins),
@@ -660,15 +1360,13 @@
 
     List<String> expectedMetaRefs =
         new ArrayList<>(ImmutableList.of(mr.getPatchSetId().toRefName()));
-    if (NoteDbMode.get() != NoteDbMode.OFF) {
-      expectedMetaRefs.add(changeRefPrefix(mr.getChange().getId()) + "meta");
-    }
+    expectedMetaRefs.add(RefNames.changeMetaRef(mr.getChange().getId()));
 
     List<String> expectedAllRefs = new ArrayList<>(expectedNonMetaRefs);
     expectedAllRefs.addAll(expectedMetaRefs);
 
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      Map<String, Ref> all = repo.getAllRefs();
+      Map<String, Ref> all = getAllRefs(repo);
 
       PermissionBackend.ForProject forProject = newFilter(allUsers, admin);
       assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
@@ -681,6 +1379,22 @@
     }
   }
 
+  @Test
+  public void fetchSingleChangeWithoutIndexAccess() throws Exception {
+    PushOneCommit.Result change = createChange();
+    String patchSetRef = change.getPatchSetId().toRefName();
+    try (AutoCloseable ignored = disableChangeIndex();
+        Repository repo = repoManager.openRepository(project)) {
+      Map<String, Ref> singleRef = ImmutableMap.of(patchSetRef, repo.exactRef(patchSetRef));
+      Map<String, Ref> filteredRefs =
+          permissionBackend
+              .user(user(admin))
+              .project(project)
+              .filter(singleRef, repo, RefFilterOptions.defaults());
+      assertThat(filteredRefs).isEqualTo(singleRef);
+    }
+  }
+
   private List<String> lsRemote(Project.NameKey p, TestAccount a) throws Exception {
     TestRepository<?> testRepository = cloneProject(p, a);
     try (Git git = testRepository.git()) {
@@ -689,7 +1403,7 @@
   }
 
   private List<String> getRefs(Git git) throws Exception {
-    return getRefs(git, Predicates.alwaysTrue());
+    return getRefs(git, x -> true);
   }
 
   private List<String> getUserRefs(Git git) throws Exception {
@@ -705,52 +1419,41 @@
   }
 
   /**
-   * Assert that refs seen by a non-admin user match expected.
+   * Assert that refs seen by a non-admin user match the expected refs.
    *
-   * @param expectedWithMeta expected refs, in order. If NoteDb is disabled by the configuration,
-   *     any NoteDb refs (i.e. ending in "/meta") are removed from the expected list before
-   *     comparing to the actual results.
+   * @param expectedRefs expected refs.
    * @throws Exception
    */
-  private void assertUploadPackRefs(String... expectedWithMeta) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertRefs(repo, permissionBackend.user(user(user)).project(project), true, expectedWithMeta);
-    }
+  private void assertUploadPackRefs(String... expectedRefs) throws Exception {
+    assertRefs(project, user, true, expectedRefs);
   }
 
   private void assertRefs(
-      Repository repo,
-      PermissionBackend.ForProject forProject,
-      boolean disableDb,
-      String... expectedWithMeta)
+      Project.NameKey project, TestAccount user, boolean disableDb, String... expectedRefs)
       throws Exception {
-    List<String> expected = new ArrayList<>(expectedWithMeta.length);
-    for (String r : expectedWithMeta) {
-      if (notesMigration.commitChangeWrites() || !r.endsWith(RefNames.META_SUFFIX)) {
-        expected.add(r);
-      }
-    }
-
-    AcceptanceTestRequestScope.Context ctx = null;
+    AutoCloseable ctx = null;
     if (disableDb) {
-      ctx = disableDb();
+      ctx = disableNoteDb();
     }
     try {
-      Map<String, Ref> all = repo.getAllRefs();
-      assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
-          .containsExactlyElementsIn(expected);
+      assertThat(lsRemote(project, user)).containsExactlyElementsIn(expectedRefs);
     } finally {
       if (disableDb) {
-        enableDb(ctx);
+        ctx.close();
       }
     }
   }
 
-  private ReceiveCommitsAdvertiseRefsHook.Result getReceivePackRefs() throws Exception {
-    ReceiveCommitsAdvertiseRefsHook hook =
-        new ReceiveCommitsAdvertiseRefsHook(queryProvider, project);
+  private TestRefAdvertiser.Result getReceivePackRefs(TestAccount u) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      return hook.advertiseRefs(repo.getAllRefs());
+      AdvertiseRefsHook adv =
+          ReceiveCommitsAdvertiseRefsHookChain.createForTest(
+              newFilter(project, u), queryProvider, project);
+      ReceivePack rp = new ReceivePack(repo);
+      rp.setAdvertiseRefsHook(adv);
+      TestRefAdvertiser advertiser = new TestRefAdvertiser(repo);
+      rp.sendAdvertisedRefs(advertiser);
+      return advertiser.result();
     }
   }
 
@@ -759,10 +1462,10 @@
   }
 
   private static ObjectId obj(ChangeData cd, int psNum) throws Exception {
-    PatchSet.Id psId = new PatchSet.Id(cd.getId(), psNum);
+    PatchSet.Id psId = PatchSet.id(cd.getId(), psNum);
     PatchSet ps = cd.patchSet(psId);
     assertWithMessage("%s not found in %s", psId, cd.patchSets()).that(ps).isNotNull();
-    return ObjectId.fromString(ps.getRevision().get());
+    return ps.commitId();
   }
 
   private AccountGroup.UUID createSelfOwnedGroup(String name, TestAccount... members)
@@ -777,22 +1480,12 @@
     groupInput.name = name(name);
     groupInput.ownerId = ownerGroup != null ? ownerGroup.get() : null;
     groupInput.members =
-        Arrays.stream(members).map(m -> String.valueOf(m.id.get())).collect(toList());
-    return new AccountGroup.UUID(gApi.groups().create(groupInput).get().id);
+        Arrays.stream(members).map(m -> String.valueOf(m.id().get())).collect(toList());
+    return AccountGroup.uuid(gApi.groups().create(groupInput).get().id);
   }
 
-  /**
-   * Create a resetter to reset the group branches in All-Users. This makes the group data between
-   * ReviewDb and NoteDb inconsistent, but in the context of this test class we only care about refs
-   * and hence this is not an issue. Once groups are no longer in ReviewDb and {@link
-   * AbstractDaemonTest#resetProjects} takes care to reset group branches we no longer need this
-   * method.
-   */
-  private ProjectResetter resetGroups() throws IOException {
-    return projectResetter
-        .builder()
-        .build(
-            new ProjectResetter.Config()
-                .reset(allUsers, RefNames.REFS_GROUPS + "*", RefNames.REFS_GROUPNAMES));
+  private static Map<String, Ref> getAllRefs(Repository repo) throws IOException {
+    return repo.getRefDatabase().getRefs().stream()
+        .collect(toMap(Ref::getName, Function.identity()));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
new file mode 100644
index 0000000..640f65e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.CREATE;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE_NONFASTFORWARD;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.events.RefReceivedEvent;
+import com.google.gerrit.server.git.validators.RefOperationValidationListener;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.Test;
+
+public class RefOperationValidationIT extends AbstractDaemonTest {
+  private static final String TEST_REF = "refs/heads/protected";
+
+  @Inject DynamicSet<RefOperationValidationListener> validators;
+  @Inject private ProjectOperations projectOperations;
+
+  private class TestRefValidator implements RefOperationValidationListener, AutoCloseable {
+    private final ReceiveCommand.Type rejectType;
+    private final String rejectRef;
+    private final RegistrationHandle handle;
+
+    public TestRefValidator(ReceiveCommand.Type rejectType) {
+      this.rejectType = rejectType;
+      this.rejectRef = TEST_REF;
+      this.handle = validators.add("test-" + rejectType.name(), this);
+    }
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+        throws ValidationException {
+      if (refEvent.getRefName().equals(rejectRef)
+          && refEvent.command.getType().equals(rejectType)) {
+        throw new ValidationException(rejectType.name());
+      }
+      return Collections.emptyList();
+    }
+
+    @Override
+    public void close() throws Exception {
+      handle.remove();
+    }
+  }
+
+  @Test
+  public void rejectRefCreation() throws Exception {
+    try (TestRefValidator validator = new TestRefValidator(CREATE)) {
+      RestApiException expected =
+          assertThrows(
+              RestApiException.class,
+              () -> gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput()));
+      assertThat(expected).hasMessageThat().contains(CREATE.name());
+    }
+  }
+
+  private void grant(String permission) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(permission).ref("refs/*").group(REGISTERED_USERS).force(true))
+        .update();
+  }
+
+  @Test
+  public void rejectRefCreationByPush() throws Exception {
+    try (TestRefValidator validator = new TestRefValidator(CREATE)) {
+      grant(Permission.PUSH);
+      PushOneCommit push1 =
+          pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r1 = push1.to("refs/heads/master");
+      r1.assertOkStatus();
+      PushOneCommit.Result r2 = push1.to(TEST_REF);
+      r2.assertErrorStatus(CREATE.name());
+    }
+  }
+
+  @Test
+  public void rejectRefDeletion() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+    try (TestRefValidator validator = new TestRefValidator(DELETE)) {
+      RestApiException expected =
+          assertThrows(
+              RestApiException.class,
+              () -> gApi.projects().name(project.get()).branch(TEST_REF).delete());
+      assertThat(expected).hasMessageThat().contains(DELETE.name());
+    }
+  }
+
+  @Test
+  public void rejectRefDeletionByPush() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+    grant(Permission.DELETE);
+    try (TestRefValidator validator = new TestRefValidator(DELETE)) {
+      PushResult result = deleteRef(testRepo, TEST_REF);
+      RemoteRefUpdate refUpdate = result.getRemoteUpdate(TEST_REF);
+      assertThat(refUpdate.getMessage()).contains(DELETE.name());
+    }
+  }
+
+  @Test
+  public void rejectRefUpdateFastForward() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+    try (TestRefValidator validator = new TestRefValidator(UPDATE)) {
+      grant(Permission.PUSH);
+      PushOneCommit push1 =
+          pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r1 = push1.to(TEST_REF);
+      r1.assertErrorStatus(UPDATE.name());
+    }
+  }
+
+  @Test
+  public void rejectRefUpdateNonFastForward() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+    try (TestRefValidator validator = new TestRefValidator(UPDATE_NONFASTFORWARD)) {
+      ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+      grant(Permission.PUSH);
+      PushOneCommit push1 =
+          pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r1 = push1.to(TEST_REF);
+      r1.assertOkStatus();
+
+      // Reset HEAD to initial so the new change is a non-fast forward
+      RefUpdate ru = repo().updateRef(HEAD);
+      ru.setNewObjectId(initial);
+      assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+
+      PushOneCommit push2 =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push2.setForce(true);
+      PushOneCommit.Result r2 = push2.to(TEST_REF);
+      r2.assertErrorStatus(UPDATE_NONFASTFORWARD.name());
+    }
+  }
+
+  @Test
+  public void rejectRefUpdateNonFastForwardToExistingCommit() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+
+    try (TestRefValidator validator = new TestRefValidator(UPDATE_NONFASTFORWARD)) {
+      grant(Permission.PUSH);
+      PushOneCommit push1 =
+          pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r1 = push1.to("refs/heads/master");
+      r1.assertOkStatus();
+      ObjectId push1Id = r1.getCommit();
+
+      PushOneCommit push2 =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      PushOneCommit.Result r2 = push2.to("refs/heads/master");
+      r2.assertOkStatus();
+      ObjectId push2Id = r2.getCommit();
+
+      RefUpdate ru = repo().updateRef(HEAD);
+      ru.setNewObjectId(push1Id);
+      assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+
+      PushOneCommit push3 =
+          pushFactory.create(admin.newIdent(), testRepo, "change3", "c.txt", "content");
+      PushOneCommit.Result r3 = push3.to(TEST_REF);
+      r3.assertOkStatus();
+
+      ru = repo().updateRef(HEAD);
+      ru.setNewObjectId(push2Id);
+      assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+
+      PushOneCommit push4 =
+          pushFactory.create(admin.newIdent(), testRepo, "change4", "d.txt", "content");
+      push4.setForce(true);
+      PushOneCommit.Result r4 = push4.to(TEST_REF);
+      r4.assertErrorStatus(UPDATE_NONFASTFORWARD.name());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 0705dca..c7f3abe 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -17,31 +17,39 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 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.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
+import java.util.List;
 import org.eclipse.jgit.api.errors.GitAPIException;
 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;
@@ -50,10 +58,15 @@
 @NoHttpd
 public class SubmitOnPushIT extends AbstractDaemonTest {
   @Inject private ApprovalsUtil approvalsUtil;
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void submitOnPush() throws Exception {
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r = pushTo("refs/for/master%submit");
     r.assertOkStatus();
     r.assertChange(Change.Status.MERGED, null, admin);
@@ -62,40 +75,12 @@
   }
 
   @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);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/meta/config").group(adminGroupUuid()))
+        .update();
 
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
     testRepo.reset(RefNames.REFS_CONFIG);
@@ -113,7 +98,11 @@
     push("refs/heads/master", "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "a.txt", "other content");
     r.assertErrorStatus();
@@ -129,7 +118,11 @@
     push(master, "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "b.txt", "other content");
     r.assertOkStatus();
@@ -142,7 +135,11 @@
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
 
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     r =
         push(
             "refs/for/master%submit",
@@ -158,7 +155,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 +167,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
@@ -182,7 +179,11 @@
 
   @Test
   public void mergeOnPushToBranch() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
     r.assertOkStatus();
@@ -191,15 +192,32 @@
     assertCommit(project, "refs/heads/master");
 
     ChangeData cd =
-        Iterables.getOnlyElement(queryProvider.get().byKey(new Change.Key(r.getChangeId())));
+        Iterables.getOnlyElement(queryProvider.get().byKey(Change.key(r.getChangeId())));
     RevCommit c = r.getCommit();
-    PatchSet.Id psId = cd.currentPatchSet().getId();
+    PatchSet.Id psId = cd.currentPatchSet().id();
     assertThat(psId.get()).isEqualTo(1);
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(cd.change().isMerged()).isTrue();
     assertSubmitApproval(psId);
 
     assertThat(cd.patchSets()).hasSize(1);
-    assertThat(cd.patchSet(psId).getRevision().get()).isEqualTo(c.name());
+    assertThat(cd.patchSet(psId).commitId()).isEqualTo(c);
+  }
+
+  @Test
+  public void correctNewRevOnMergeByPushToBranch() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
+    push("refs/for/master", PushOneCommit.SUBJECT, "one.txt", "One");
+    PushOneCommit.Result r = push("refs/for/master", PushOneCommit.SUBJECT, "two.txt", "Two");
+    startEventRecorder();
+    git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
+    List<ChangeMergedEvent> changeMergedEvents =
+        eventRecorder.getChangeMergedEvents(project.get(), "refs/heads/master", 2);
+    assertThat(changeMergedEvents.get(0).newRev).isEqualTo(r.getPatchSet().commitId().name());
+    assertThat(changeMergedEvents.get(1).newRev).isEqualTo(r.getPatchSet().commitId().name());
   }
 
   @Test
@@ -207,10 +225,14 @@
     enableCreateNewChangeForAllNotInTarget();
     String master = "refs/heads/master";
     String other = "refs/heads/other";
-    grant(project, master, Permission.PUSH);
-    grant(project, other, Permission.CREATE);
-    grant(project, other, Permission.PUSH);
-    RevCommit masterRev = getRemoteHead();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(adminGroupUuid()))
+        .add(allow(Permission.CREATE).ref(other).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(other).group(adminGroupUuid()))
+        .update();
+    RevCommit masterRev = projectOperations.project(project).getHead("master");
     pushCommitTo(masterRev, other);
     PushOneCommit.Result r = createChange();
     r.assertOkStatus();
@@ -218,8 +240,8 @@
     pushCommitTo(commit, master);
     assertCommit(project, master);
     ChangeData cd =
-        Iterables.getOnlyElement(queryProvider.get().byKey(new Change.Key(r.getChangeId())));
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+        Iterables.getOnlyElement(queryProvider.get().byKey(Change.key(r.getChangeId())));
+    assertThat(cd.change().isMerged()).isTrue();
 
     RemoteRefUpdate.Status status = pushCommitTo(commit, "refs/for/other");
     assertThat(status).isEqualTo(RemoteRefUpdate.Status.OK);
@@ -227,9 +249,9 @@
     pushCommitTo(commit, other);
     assertCommit(project, other);
 
-    for (ChangeData c : queryProvider.get().byKey(new Change.Key(r.getChangeId()))) {
-      if (c.change().getDest().get().equals(other)) {
-        assertThat(c.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    for (ChangeData c : queryProvider.get().byKey(Change.key(r.getChangeId()))) {
+      if (c.change().getDest().branch().equals(other)) {
+        assertThat(c.change().isMerged()).isTrue();
       }
     }
   }
@@ -244,7 +266,11 @@
 
   @Test
   public void mergeOnPushToBranchWithNewPatchset() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -253,8 +279,7 @@
 
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             PushOneCommit.SUBJECT,
             "b.txt",
@@ -266,20 +291,24 @@
 
     ChangeData cd = r.getChange();
     RevCommit c2 = r.getCommit();
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(cd.change().isMerged()).isTrue();
     PatchSet.Id psId2 = cd.change().currentPatchSetId();
     assertThat(psId2.get()).isEqualTo(2);
     assertCommit(project, "refs/heads/master");
     assertSubmitApproval(psId2);
 
     assertThat(cd.patchSets()).hasSize(2);
-    assertThat(cd.patchSet(psId1).getRevision().get()).isEqualTo(c1.name());
-    assertThat(cd.patchSet(psId2).getRevision().get()).isEqualTo(c2.name());
+    assertThat(cd.patchSet(psId1).commitId()).isEqualTo(c1);
+    assertThat(cd.patchSet(psId2).commitId()).isEqualTo(c2);
   }
 
   @Test
   public void mergeOnPushToBranchWithOldPatchset() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -290,26 +319,30 @@
     r = amendChange(changeId);
     ChangeData cd = r.getChange();
     PatchSet.Id psId2 = cd.change().currentPatchSetId();
-    assertThat(psId2.getParentKey()).isEqualTo(psId1.getParentKey());
+    assertThat(psId2.changeId()).isEqualTo(psId1.changeId());
     assertThat(psId2.get()).isEqualTo(2);
 
     testRepo.reset(c1);
     assertPushOk(pushHead(testRepo, "refs/heads/master", false), "refs/heads/master");
 
-    cd = changeDataFactory.create(db, project, psId1.getParentKey());
+    cd = changeDataFactory.create(project, psId1.changeId());
     Change c = cd.change();
-    assertThat(c.getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(c.isMerged()).isTrue();
     assertThat(c.currentPatchSetId()).isEqualTo(psId1);
-    assertThat(cd.patchSets().stream().map(PatchSet::getId).collect(toList()))
+    assertThat(cd.patchSets().stream().map(PatchSet::id).collect(toList()))
         .containsExactly(psId1, psId2);
   }
 
   @Test
   public void mergeMultipleOnPushToBranchWithNewPatchset() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
 
     // Create 2 changes.
-    ObjectId initialHead = getRemoteHead();
+    ObjectId initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result r1 = createChange("Change 1", "a", "a");
     r1.assertOkStatus();
     PushOneCommit.Result r2 = createChange("Change 2", "b", "b");
@@ -337,30 +370,99 @@
     assertPushOk(pushHead(testRepo, "refs/heads/master", false), "refs/heads/master");
 
     ChangeData cd2 = r2.getChange();
-    assertThat(cd2.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(cd2.change().isMerged()).isTrue();
     PatchSet.Id psId2_2 = cd2.change().currentPatchSetId();
     assertThat(psId2_2.get()).isEqualTo(2);
-    assertThat(cd2.patchSet(psId2_1).getRevision().get()).isEqualTo(c2_1.name());
-    assertThat(cd2.patchSet(psId2_2).getRevision().get()).isEqualTo(c2_2.name());
+    assertThat(cd2.patchSet(psId2_1).commitId()).isEqualTo(c2_1);
+    assertThat(cd2.patchSet(psId2_2).commitId()).isEqualTo(c2_2);
 
     ChangeData cd1 = r1.getChange();
-    assertThat(cd1.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(cd1.change().isMerged()).isTrue();
     PatchSet.Id psId1_2 = cd1.change().currentPatchSetId();
     assertThat(psId1_2.get()).isEqualTo(2);
-    assertThat(cd1.patchSet(psId1_1).getRevision().get()).isEqualTo(c1_1.name());
-    assertThat(cd1.patchSet(psId1_2).getRevision().get()).isEqualTo(c1_2.name());
+    assertThat(cd1.patchSet(psId1_1).commitId()).isEqualTo(c1_1);
+    assertThat(cd1.patchSet(psId1_2).commitId()).isEqualTo(c1_2);
+  }
+
+  @Test
+  public void pushForSubmitWithNotifyOption() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
+
+    TestAccount user = accountCreator.user();
+    String pushSpec = "refs/for/master%reviewer=" + user.email();
+    sender.clear();
+
+    PushOneCommit.Result result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE);
+    result.assertOkStatus();
+    assertThat(sender.getMessages()).isEmpty();
+
+    sender.clear();
+    result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.OWNER);
+    result.assertOkStatus();
+    assertThat(sender.getMessages()).isEmpty();
+
+    sender.clear();
+    result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.OWNER_REVIEWERS);
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
+
+    sender.clear();
+    result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.ALL);
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
+
+    sender.clear();
+    result = pushTo(pushSpec + ",submit"); // default is notify = ALL
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
+  }
+
+  @Test
+  public void pushForSubmitWithNotifyingUsersExplicitly() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
+
+    TestAccount user = accountCreator.user();
+    String pushSpec = "refs/for/master%reviewer=" + user.email() + ",cc=" + user.email();
+
+    TestAccount user2 = accountCreator.user2();
+
+    sender.clear();
+    PushOneCommit.Result result =
+        pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-to=" + user2.email());
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.TO);
+
+    sender.clear();
+    result =
+        pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-cc=" + user2.email());
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.CC);
+
+    sender.clear();
+    result =
+        pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-bcc=" + user2.email());
+    result.assertOkStatus();
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.BCC);
   }
 
   private PatchSetApproval getSubmitter(PatchSet.Id patchSetId) throws Exception {
-    ChangeNotes notes = notesFactory.createChecked(db, project, patchSetId.getParentKey()).load();
-    return approvalsUtil.getSubmitter(db, notes, patchSetId);
+    ChangeNotes notes = notesFactory.createChecked(project, patchSetId.changeId()).load();
+    return approvalsUtil.getSubmitter(notes, patchSetId);
   }
 
   private void assertSubmitApproval(PatchSet.Id patchSetId) throws Exception {
     PatchSetApproval a = getSubmitter(patchSetId);
     assertThat(a.isLegacySubmit()).isTrue();
-    assertThat(a.getValue()).isEqualTo((short) 1);
-    assertThat(a.getAccountId()).isEqualTo(admin.id);
+    assertThat(a.value()).isEqualTo((short) 1);
+    assertThat(a.accountId()).isEqualTo(admin.id());
   }
 
   private void assertCommit(Project.NameKey project, String branch) throws Exception {
@@ -368,8 +470,8 @@
         RevWalk rw = new RevWalk(r)) {
       RevCommit c = rw.parseCommit(r.exactRef(branch).getObjectId());
       assertThat(c.getShortMessage()).isEqualTo(PushOneCommit.SUBJECT);
-      assertThat(c.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email);
-      assertThat(c.getCommitterIdent().getEmailAddress()).isEqualTo(admin.email);
+      assertThat(c.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email());
+      assertThat(c.getCommitterIdent().getEmailAddress()).isEqualTo(admin.email());
     }
   }
 
@@ -379,41 +481,15 @@
       RevCommit c = rw.parseCommit(r.exactRef(branch).getObjectId());
       assertThat(c.getParentCount()).isEqualTo(2);
       assertThat(c.getShortMessage()).isEqualTo("Merge \"" + subject + "\"");
-      assertThat(c.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email);
+      assertThat(c.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email());
       assertThat(c.getCommitterIdent().getEmailAddress())
           .isEqualTo(serverIdent.get().getEmailAddress());
     }
   }
 
-  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 =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
     return push.to(ref);
   }
 
@@ -421,7 +497,48 @@
       String ref, String subject, String fileName, String content, String changeId)
       throws Exception {
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content, changeId);
+        pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content, changeId);
     return push.to(ref);
   }
+
+  /**
+   * Makes sure that two emails are sent: one for the change creation, and one for the submit.
+   *
+   * @param expected The account expected to receive message.
+   * @param expectedRecipientType The notification's type: To/Cc/Bcc. if {@code null} then it is not
+   *     needed to check the recipientType. It is meant for -notify without other flags like
+   *     notify-cc, notify-to, and notify-bcc. With the -notify flag, the message can sometimes be
+   *     sent as "To" and sometimes can be sent as "Cc".
+   */
+  private void assertThatEmailsForChangeCreationAndSubmitWereSent(
+      TestAccount expected, @Nullable RecipientType expectedRecipientType) {
+    String expectedEmail = expected.email();
+    String expectedFullName = expected.fullName();
+    Address expectedAddress = new Address(expectedFullName, expectedEmail);
+    assertThat(sender.getMessages()).hasSize(2);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.body().contains("review")).isTrue();
+    assertAddress(message, expectedAddress, expectedRecipientType);
+    message = sender.getMessages().get(1);
+    assertThat(message.rcpt()).containsExactly(expectedAddress);
+    assertAddress(message, expectedAddress, expectedRecipientType);
+    assertThat(message.body().contains("submitted")).isTrue();
+  }
+
+  private void assertAddress(
+      Message message, Address expectedAddress, @Nullable RecipientType expectedRecipientType) {
+    assertThat(message.rcpt()).containsExactly(expectedAddress);
+    if (expectedRecipientType != null
+        && expectedRecipientType
+            != RecipientType.BCC) { // When Bcc, it does not appear in the header.
+      String expectedRecipientTypeString = "To";
+      if (expectedRecipientType == RecipientType.CC) {
+        expectedRecipientTypeString = "Cc";
+      }
+      assertThat(
+              ((EmailHeader.AddressList) message.headers().get(expectedRecipientTypeString))
+                  .getAddressList())
+          .containsExactly(expectedAddress);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
deleted file mode 100644
index d0225c7..0000000
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
+++ /dev/null
@@ -1,426 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.server.util.SubmoduleSectionParser;
-import java.util.Set;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Test;
-
-public class SubmoduleSectionParserIT extends AbstractDaemonTest {
-  private static final String THIS_SERVER = "http://localhost/";
-
-  @Test
-  public void followMasterBranch() throws Exception {
-    Project.NameKey p = createProject("a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]\n"
-            + "path = localpath-to-a\n"
-            + "url = ssh://localhost/"
-            + p.get()
-            + "\n"
-            + "branch = master\n");
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(
-                targetBranch, new Branch.NameKey(p, "master"), "localpath-to-a"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void followMatchingBranch() throws Exception {
-    Project.NameKey p = createProject("a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]\n"
-            + "path = a\n"
-            + "url = ssh://localhost/"
-            + p.get()
-            + "\n"
-            + "branch = .\n");
-
-    Branch.NameKey targetBranch1 = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res1 =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch1).parseAllSections();
-
-    Set<SubmoduleSubscription> expected1 =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch1, new Branch.NameKey(p, "master"), "a"));
-
-    assertThat(res1).containsExactlyElementsIn(expected1);
-
-    Branch.NameKey targetBranch2 = new Branch.NameKey(new Project.NameKey("project"), "somebranch");
-
-    Set<SubmoduleSubscription> res2 =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch2).parseAllSections();
-
-    Set<SubmoduleSubscription> expected2 =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch2, new Branch.NameKey(p, "somebranch"), "a"));
-
-    assertThat(res2).containsExactlyElementsIn(expected2);
-  }
-
-  @Test
-  public void followAnotherBranch() throws Exception {
-    Project.NameKey p = createProject("a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]\n"
-            + "path = a\n"
-            + "url = ssh://localhost/"
-            + p.get()
-            + "\n"
-            + "branch = anotherbranch\n");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "anotherbranch"), "a"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void withAnotherURI() throws Exception {
-    Project.NameKey p = createProject("a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]\n"
-            + "path = a\n"
-            + "url = http://localhost:80/"
-            + p.get()
-            + "\n"
-            + "branch = master\n");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void withSlashesInProjectName() throws Exception {
-    Project.NameKey p = createProject("project/with/slashes/a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"project/with/slashes/a\"]\n"
-            + "path = a\n"
-            + "url = http://localhost:80/"
-            + p.get()
-            + "\n"
-            + "branch = master\n");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void withSlashesInPath() throws Exception {
-    Project.NameKey p = createProject("a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]\n"
-            + "path = a/b/c/d/e\n"
-            + "url = http://localhost:80/"
-            + p.get()
-            + "\n"
-            + "branch = master\n");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a/b/c/d/e"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void withMoreSections() throws Exception {
-    Project.NameKey p1 = createProject("a");
-    Project.NameKey p2 = createProject("b");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]\n"
-            + "     path = a\n"
-            + "     url = ssh://localhost/"
-            + p1.get()
-            + "\n"
-            + "     branch = .\n"
-            + "[submodule \"b\"]\n"
-            + "		path = b\n"
-            + "		url = http://localhost:80/"
-            + p2.get()
-            + "\n"
-            + "		branch = master\n");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"),
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p2, "master"), "b"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void withSubProjectFound() throws Exception {
-    Project.NameKey p1 = createProject("a/b");
-    Project.NameKey p2 = createProject("b");
-    Config cfg = new Config();
-    cfg.fromText(
-        "\n"
-            + "[submodule \"a/b\"]\n"
-            + "path = a/b\n"
-            + "url = ssh://localhost/"
-            + p1.get()
-            + "\n"
-            + "branch = .\n"
-            + "[submodule \"b\"]\n"
-            + "path = b\n"
-            + "url = http://localhost/"
-            + p2.get()
-            + "\n"
-            + "branch = .\n");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p2, "master"), "b"),
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a/b"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void withAnInvalidSection() throws Exception {
-    Project.NameKey p1 = createProject("a");
-    Project.NameKey p2 = createProject("b");
-    Project.NameKey p3 = createProject("d");
-    Project.NameKey p4 = createProject("e");
-    Config cfg = new Config();
-    cfg.fromText(
-        "\n"
-            + "[submodule \"a\"]\n"
-            + "    path = a\n"
-            + "    url = ssh://localhost/"
-            + p1.get()
-            + "\n"
-            + "    branch = .\n"
-            + "[submodule \"b\"]\n"
-            // path missing
-            + "    url = http://localhost:80/"
-            + p2.get()
-            + "\n"
-            + "    branch = master\n"
-            + "[submodule \"c\"]\n"
-            + "    path = c\n"
-            // url missing
-            + "    branch = .\n"
-            + "[submodule \"d\"]\n"
-            + "    path = d-parent/the-d-folder\n"
-            + "    url = ssh://localhost/"
-            + p3.get()
-            + "\n"
-            // branch missing
-            + "[submodule \"e\"]\n"
-            + "    path = e\n"
-            + "    url = ssh://localhost/"
-            + p4.get()
-            + "\n"
-            + "    branch = refs/heads/master\n");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"),
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p4, "master"), "e"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void withSectionOfNonexistingProject() throws Exception {
-    Config cfg = new Config();
-    cfg.fromText(
-        "\n"
-            + "[submodule \"a\"]\n"
-            + "path = a\n"
-            + "url = ssh://non-localhost/a\n"
-            // Project "a" doesn't exist
-            + "branch = .\\n");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    assertThat(res).isEmpty();
-  }
-
-  @Test
-  public void withSectionToOtherServer() throws Exception {
-    Project.NameKey p1 = createProject("a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]"
-            + "path = a"
-            + "url = ssh://non-localhost/"
-            + p1.get()
-            + "\n"
-            + "branch = .");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    assertThat(res).isEmpty();
-  }
-
-  @Test
-  public void withRelativeURI() throws Exception {
-    Project.NameKey p1 = createProject("a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]\n"
-            + "path = a\n"
-            + "url = ../"
-            + p1.get()
-            + "\n"
-            + "branch = master\n");
-
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void withDeepRelativeURI() throws Exception {
-    Project.NameKey p1 = createProject("a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]\n"
-            + "path = a\n"
-            + "url = ../../"
-            + p1.get()
-            + "\n"
-            + "branch = master\n");
-
-    Branch.NameKey targetBranch =
-        new Branch.NameKey(new Project.NameKey("nested/project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void withOverlyDeepRelativeURI() throws Exception {
-    Project.NameKey p1 = createProject("nested/a");
-    Config cfg = new Config();
-    cfg.fromText(
-        ""
-            + "[submodule \"a\"]\n"
-            + "path = a\n"
-            + "url = ../../"
-            + p1.get()
-            + "\n"
-            + "branch = master\n");
-
-    Branch.NameKey targetBranch =
-        new Branch.NameKey(new Project.NameKey("nested/project"), "master");
-
-    Set<SubmoduleSubscription> res =
-        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
-
-    Set<SubmoduleSubscription> expected =
-        Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
-
-    assertThat(res).containsExactlyElementsIn(expected);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 81ee3a0..e7501e7 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -16,15 +16,23 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.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;
@@ -39,201 +47,174 @@
     return submitWholeTopicEnabledConfig();
   }
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
   public void testSubscriptionWithoutGlobalServerSetting() 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");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isFalse();
   }
 
   @Test
   public void subscriptionWithoutSpecificSubscription() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isFalse();
   }
 
   @Test
   public void subscriptionToEmptyRepo() 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");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     pushChangeTo(subRepo, "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEAD);
   }
 
   @Test
   public void subscriptionToExistingRepo() 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");
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEAD);
   }
 
   @Test
   public void subscriptionWildcardACLForSingleBranch() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
     // master is allowed to be subscribed to master branch only:
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", null);
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, null);
     // create 'branch':
     pushChangeTo(superRepo, "branch");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
+    createSubmoduleSubscription(superRepo, "branch", subKey, "master");
 
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
-    assertThat(hasSubmodule(superRepo, "branch", "subscribed-to-project")).isFalse();
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEAD);
+    assertThat(hasSubmodule(superRepo, "branch", subKey)).isFalse();
   }
 
   @Test
   public void subscriptionWildcardACLForMissingProject() throws Exception {
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
     allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "not-existing-super-project", "refs/heads/*");
+        subKey, "refs/heads/*", Project.nameKey("not-existing-super-project"), "refs/heads/*");
     pushChangeTo(subRepo, "master");
   }
 
   @Test
   public void subscriptionWildcardACLForMissingBranch() throws Exception {
-    createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/*", superKey, "refs/heads/*");
     pushChangeTo(subRepo, "foo");
   }
 
   @Test
   public void subscriptionWildcardACLForMissingGitmodules() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/*", superKey, "refs/heads/*");
     pushChangeTo(superRepo, "master");
     pushChangeTo(subRepo, "master");
   }
 
   @Test
   public void subscriptionWildcardACLOneOnOneMapping() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
     // any branch is allowed to be subscribed to the same superprojects branch:
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/*", superKey, "refs/heads/*");
 
     // create 'branch' in both repos:
     pushChangeTo(superRepo, "branch");
     pushChangeTo(subRepo, "branch");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "branch");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
+    createSubmoduleSubscription(superRepo, "branch", subKey, "branch");
 
     ObjectId subHEAD1 = pushChangeTo(subRepo, "master");
     ObjectId subHEAD2 = pushChangeTo(subRepo, "branch");
 
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD1);
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD2);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEAD1);
+    expectToHaveSubmoduleState(superRepo, "branch", subKey, subHEAD2);
 
     // Now test that cross subscriptions do not work:
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "branch");
+    createSubmoduleSubscription(superRepo, "master", subKey, "branch");
     ObjectId subHEAD3 = pushChangeTo(subRepo, "branch");
 
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD1);
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD3);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEAD1);
+    expectToHaveSubmoduleState(superRepo, "branch", subKey, subHEAD3);
   }
 
   @Test
   public void subscriptionWildcardACLForManyBranches() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
     // Any branch is allowed to be subscribed to any superproject branch:
-    allowSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "super-project", null, false);
+    allowSubmoduleSubscription(subKey, "refs/heads/*", superKey, null, false);
     pushChangeTo(superRepo, "branch");
     pushChangeTo(subRepo, "another-branch");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "another-branch");
+    createSubmoduleSubscription(superRepo, "branch", subKey, "another-branch");
     ObjectId subHEAD = pushChangeTo(subRepo, "another-branch");
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
+    expectToHaveSubmoduleState(superRepo, "branch", subKey, subHEAD);
   }
 
   @Test
   public void subscriptionWildcardACLOneToManyBranches() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
     // Any branch is allowed to be subscribed to any superproject branch:
-    allowSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/*", false);
+    allowSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/*", false);
     pushChangeTo(superRepo, "branch");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "branch", subKey, "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
+    expectToHaveSubmoduleState(superRepo, "branch", subKey, subHEAD);
 
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "branch");
+    createSubmoduleSubscription(superRepo, "branch", subKey, "branch");
     pushChangeTo(subRepo, "branch");
 
     // no change expected, as only master is subscribed:
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
+    expectToHaveSubmoduleState(superRepo, "branch", subKey, subHEAD);
   }
 
   @Test
   @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "false")
   public void testSubmoduleShortCommitMessage() 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");
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
 
     // The first update doesn't include any commit messages
     ObjectId subRepoId = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subRepoId);
     expectToHaveCommitMessage(superRepo, "master", "Update git submodules\n\n");
 
     // Any following update also has a short message
     subRepoId = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subRepoId);
     expectToHaveCommitMessage(superRepo, "master", "Update git submodules\n\n");
   }
 
   @Test
   @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
   public void testSubmoduleSubjectCommitMessage() 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");
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
     // The first update doesn't include the rev log
@@ -243,7 +224,7 @@
         "master",
         "Update git submodules\n\n"
             + "* Update "
-            + name("subscribed-to-project")
+            + subKey.get()
             + " from branch 'master'\n  to "
             + subHEAD.getName());
 
@@ -256,7 +237,7 @@
         "master",
         "Update git submodules\n\n"
             + "* Update "
-            + name("subscribed-to-project")
+            + subKey.get()
             + " from branch 'master'\n  to "
             + subHEAD.getName()
             + "\n  - "
@@ -265,13 +246,11 @@
 
   @Test
   public void submoduleCommitMessage() 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");
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
     // The first update doesn't include the rev log
@@ -281,7 +260,7 @@
         "master",
         "Update git submodules\n\n"
             + "* Update "
-            + name("subscribed-to-project")
+            + subKey.get()
             + " from branch 'master'\n  to "
             + subHEAD.getName());
 
@@ -294,7 +273,7 @@
         "master",
         "Update git submodules\n\n"
             + "* Update "
-            + name("subscribed-to-project")
+            + subKey.get()
             + " from branch 'master'\n  to "
             + subHEAD.getName()
             + "\n  - "
@@ -303,171 +282,151 @@
 
   @Test
   public void subscriptionUnsubscribe() 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");
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
 
     pushChangeTo(subRepo, "master");
     ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
 
     deleteAllSubscriptions(superRepo, "master");
-    expectToHaveSubmoduleState(
-        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEADbeforeUnsubscribing);
 
     pushChangeTo(superRepo, "refs/heads/master", "commit after unsubscribe", "");
     pushChangeTo(subRepo, "refs/heads/master", "commit after unsubscribe", "");
-    expectToHaveSubmoduleState(
-        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEADbeforeUnsubscribing);
   }
 
   @Test
   public void subscriptionUnsubscribeByDeletingGitModules() 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");
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
 
     pushChangeTo(subRepo, "master");
     ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
 
     deleteGitModulesFile(superRepo, "master");
-    expectToHaveSubmoduleState(
-        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEADbeforeUnsubscribing);
 
     pushChangeTo(superRepo, "refs/heads/master", "commit after unsubscribe", "");
     pushChangeTo(subRepo, "refs/heads/master", "commit after unsubscribe", "");
-    expectToHaveSubmoduleState(
-        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEADbeforeUnsubscribing);
   }
 
   @Test
   public void subscriptionToDifferentBranches() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/foo", "super-project", "refs/heads/master");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "foo");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/foo", superKey, "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", subKey, "foo");
     ObjectId subFoo = pushChangeTo(subRepo, "foo");
     pushChangeTo(subRepo, "master");
 
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subFoo);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subFoo);
   }
 
   @Test
   public void branchCircularSubscription() 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");
-    allowMatchingSubmoduleSubscription(
-        "super-project", "refs/heads/master", "subscribed-to-project", "refs/heads/master");
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(superKey, "refs/heads/master", subKey, "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
     pushChangeTo(superRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(subRepo, "master", "super-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
+    createSubmoduleSubscription(subRepo, "master", superKey, "master");
 
     pushChangeTo(subRepo, "master");
     pushChangeTo(superRepo, "master");
 
-    assertThat(hasSubmodule(subRepo, "master", "super-project")).isFalse();
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(subRepo, "master", superKey)).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isFalse();
   }
 
   @Test
   public void projectCircularSubscription() 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");
-    allowMatchingSubmoduleSubscription(
-        "super-project", "refs/heads/dev", "subscribed-to-project", "refs/heads/dev");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(superKey, "refs/heads/dev", subKey, "refs/heads/dev");
 
     pushChangeTo(subRepo, "master");
     pushChangeTo(superRepo, "master");
     pushChangeTo(subRepo, "dev");
     pushChangeTo(superRepo, "dev");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
+    createSubmoduleSubscription(subRepo, "dev", superKey, "dev");
 
     ObjectId subMasterHead = pushChangeTo(subRepo, "master");
     ObjectId superDevHead = pushChangeTo(superRepo, "dev");
 
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
-    assertThat(hasSubmodule(subRepo, "dev", "super-project")).isTrue();
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subMasterHead);
-    expectToHaveSubmoduleState(subRepo, "dev", "super-project", superDevHead);
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isTrue();
+    assertThat(hasSubmodule(subRepo, "dev", superKey)).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subMasterHead);
+    expectToHaveSubmoduleState(subRepo, "dev", superKey, superDevHead);
   }
 
   @Test
   public void subscriptionFailOnMissingACL() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isFalse();
   }
 
   @Test
   public void subscriptionFailOnWrongProjectACL() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
     allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "wrong-super-project", "refs/heads/master");
+        subKey, "refs/heads/master", Project.nameKey("wrong-super-project"), "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isFalse();
   }
 
   @Test
   public void subscriptionFailOnWrongBranchACL() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
     allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/wrong-branch");
+        subKey, "refs/heads/master", superKey, "refs/heads/wrong-branch");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isFalse();
   }
 
   @Test
   public void subscriptionInheritACL() throws Exception {
-    createProjectWithPush("config-repo");
-    createProjectWithPush("config-repo2", new Project.NameKey(name("config-repo")));
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo =
-        createProjectWithPush("subscribed-to-project", new Project.NameKey(name("config-repo2")));
-    allowMatchingSubmoduleSubscription(
-        "config-repo", "refs/heads/*", "super-project", "refs/heads/*");
+    Project.NameKey configKey = projectOperations.newProject().submitType(getSubmitType()).create();
+    grantPush(configKey);
+    Project.NameKey config2Key =
+        projectOperations.newProject().parent(configKey).submitType(getSubmitType()).create();
+    grantPush(config2Key);
+    cloneProject(config2Key);
+
+    subKey = projectOperations.newProject().parent(config2Key).submitType(getSubmitType()).create();
+    grantPush(subKey);
+    subRepo = cloneProject(subKey);
+
+    allowMatchingSubmoduleSubscription(configKey, "refs/heads/*", superKey, "refs/heads/*");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subHEAD);
   }
 
   @Test
   public void allowedButNotSubscribed() 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");
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
     subRepo
@@ -485,24 +444,22 @@
     assertThat(r.getRemoteUpdate("refs/heads/master").getStatus())
         .isEqualTo(RemoteRefUpdate.Status.OK);
 
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", subKey)).isFalse();
   }
 
   @Test
   public void subscriptionDeepRelative() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("nested/subscribed-to-project");
+    Project.NameKey nest = createProjectForPush(getSubmitType());
+    TestRepository<?> subRepo = cloneProject(nest);
     // master is allowed to be subscribed to any superprojects branch:
-    allowMatchingSubmoduleSubscription(
-        "nested/subscribed-to-project", "refs/heads/master", "super-project", null);
+    allowMatchingSubmoduleSubscription(nest, "refs/heads/master", superKey, null);
 
     pushChangeTo(subRepo, "master");
-    createRelativeSubmoduleSubscription(
-        superRepo, "master", "../", "nested/subscribed-to-project", "master");
+    createRelativeSubmoduleSubscription(superRepo, "master", "../", nest, "master");
 
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
-    expectToHaveSubmoduleState(superRepo, "master", "nested/subscribed-to-project", subHEAD);
+    expectToHaveSubmoduleState(superRepo, "master", nest, subHEAD);
   }
 
   @Test
@@ -514,20 +471,199 @@
 
   @Test
   @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
-  @GerritConfig(name = "submodule.maxCombinedCommitMessageSize", value = "220")
+  // The value 110 must tuned to the test environment, and is sensitive to the
+  // length of the uniquified repository name.
+  @GerritConfig(name = "submodule.maxCombinedCommitMessageSize", value = "110")
   public void submoduleSubjectCommitMessageSizeLimit() throws Exception {
     assume().that(isSubmitWholeTopicEnabled()).isFalse();
     testSubmoduleSubjectCommitMessageAndExpectTruncation();
   }
 
+  @Test
+  public void superRepoCommitHasSameAuthorAsSubmoduleCommit() throws Exception {
+    // Make sure that the commit is created at an earlier timestamp than the submit timestamp.
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    try {
+      allowMatchingSubmoduleSubscription(
+          subKey, "refs/heads/master", superKey, "refs/heads/master");
+      createSubmoduleSubscription(superRepo, "master", subKey, "master");
+
+      PushOneCommit.Result pushResult =
+          createChange(subRepo, "refs/heads/master", "Change", "a.txt", "some content", null);
+      approve(pushResult.getChangeId());
+      gApi.changes().id(pushResult.getChangeId()).current().submit();
+
+      // Expect that the author name/email is preserved for the superRepo commit, but a new author
+      // timestamp is used.
+      PersonIdent authorIdent = getAuthor(superRepo, "master");
+      assertThat(authorIdent.getName()).isEqualTo(admin.fullName());
+      assertThat(authorIdent.getEmailAddress()).isEqualTo(admin.email());
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult.getCommit().getAuthorIdent().getWhen());
+    } finally {
+      TestTimeUtil.useSystemTime();
+    }
+  }
+
+  @Test
+  public void superRepoCommitHasSameAuthorAsSubmoduleCommits() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    // Make sure that the commits are created at different timestamps and that the submit timestamp
+    // is afterwards.
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    try {
+
+      Project.NameKey proj2 = createProjectForPush(getSubmitType());
+
+      TestRepository<?> subRepo2 = cloneProject(proj2);
+      allowMatchingSubmoduleSubscription(
+          subKey, "refs/heads/master", superKey, "refs/heads/master");
+      allowMatchingSubmoduleSubscription(proj2, "refs/heads/master", superKey, "refs/heads/master");
+
+      Config config = new Config();
+      prepareSubmoduleConfigEntry(config, subKey, subKey, "master");
+      prepareSubmoduleConfigEntry(config, proj2, proj2, "master");
+      pushSubmoduleConfig(superRepo, "master", config);
+
+      String topic = "foo";
+
+      PushOneCommit.Result pushResult1 =
+          createChange(subRepo, "refs/heads/master", "Change 1", "a.txt", "some content", topic);
+      approve(pushResult1.getChangeId());
+
+      PushOneCommit.Result pushResult2 =
+          createChange(subRepo2, "refs/heads/master", "Change 2", "b.txt", "other content", topic);
+      approve(pushResult2.getChangeId());
+
+      // Submit the topic, 2 changes with the same author.
+      gApi.changes().id(pushResult1.getChangeId()).current().submit();
+
+      // Expect that the author name/email is preserved for the superRepo commit, but a new author
+      // timestamp is used.
+      PersonIdent authorIdent = getAuthor(superRepo, "master");
+      assertThat(authorIdent.getName()).isEqualTo(admin.fullName());
+      assertThat(authorIdent.getEmailAddress()).isEqualTo(admin.email());
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult1.getCommit().getAuthorIdent().getWhen());
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult2.getCommit().getAuthorIdent().getWhen());
+    } finally {
+      TestTimeUtil.useSystemTime();
+    }
+  }
+
+  @Test
+  public void superRepoCommitHasGerritAsAuthorIfAuthorsOfSubmoduleCommitsDiffer() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    // Make sure that the commits are created at different timestamps and that the submit timestamp
+    // is afterwards.
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    try {
+      Project.NameKey proj2 = createProjectForPush(getSubmitType());
+      TestRepository<InMemoryRepository> repo2 = cloneProject(proj2, user);
+
+      allowMatchingSubmoduleSubscription(
+          subKey, "refs/heads/master", superKey, "refs/heads/master");
+      allowMatchingSubmoduleSubscription(proj2, "refs/heads/master", superKey, "refs/heads/master");
+
+      Config config = new Config();
+      prepareSubmoduleConfigEntry(config, subKey, subKey, "master");
+      prepareSubmoduleConfigEntry(config, proj2, proj2, "master");
+      pushSubmoduleConfig(superRepo, "master", config);
+
+      String topic = "foo";
+
+      // Create change as admin.
+      PushOneCommit.Result pushResult1 =
+          createChange(subRepo, "refs/heads/master", "Change 1", "a.txt", "some content", topic);
+      approve(pushResult1.getChangeId());
+
+      // Create change as user.
+      PushOneCommit push =
+          pushFactory.create(user.newIdent(), repo2, "Change 2", "b.txt", "other content");
+      PushOneCommit.Result pushResult2 = push.to("refs/for/master/" + name(topic));
+      approve(pushResult2.getChangeId());
+
+      // Submit the topic, 2 changes with the different author.
+      gApi.changes().id(pushResult1.getChangeId()).current().submit();
+
+      // Expect that the Gerrit server identity is chosen as author for the superRepo commit and a
+      // new author timestamp is used.
+      PersonIdent authorIdent = getAuthor(superRepo, "master");
+      assertThat(authorIdent.getName()).isEqualTo(serverIdent.get().getName());
+      assertThat(authorIdent.getEmailAddress()).isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult1.getCommit().getAuthorIdent().getWhen());
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult2.getCommit().getAuthorIdent().getWhen());
+    } finally {
+      TestTimeUtil.useSystemTime();
+    }
+  }
+
+  @Test
+  public void updateOnlyRelevantSubmodules() throws Exception {
+    Project.NameKey subkey1 = createProjectForPush(getSubmitType());
+    Project.NameKey subkey2 = createProjectForPush(getSubmitType());
+    TestRepository<?> subRepo1 = cloneProject(subkey1);
+    TestRepository<?> subRepo2 = cloneProject(subkey2);
+
+    allowMatchingSubmoduleSubscription(subkey1, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(subkey2, "refs/heads/master", superKey, "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, subkey1, "master");
+    prepareSubmoduleConfigEntry(config, subkey2, "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    // Push once to initialize submodules.
+    ObjectId subTip2 = pushChangeTo(subRepo2, "master");
+    ObjectId subTip1 = pushChangeTo(subRepo1, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", subkey1, subTip1);
+    expectToHaveSubmoduleState(superRepo, "master", subkey2, subTip2);
+
+    directUpdateRef(subkey2, "refs/heads/master");
+    subTip1 = pushChangeTo(subRepo1, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", subkey1, subTip1);
+    expectToHaveSubmoduleState(superRepo, "master", subkey2, subTip2);
+  }
+
+  @Test
+  public void skipUpdatingBrokenGitlinkPointer() throws Exception {
+
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
+
+    // Push once to initialize submodule.
+    ObjectId subTip = pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subTip);
+
+    // Write an invalid SHA-1 directly to the gitlink.
+    ObjectId badId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    directUpdateSubmodule(superKey, "refs/heads/master", subKey, badId);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, badId);
+
+    // Push succeeds, but gitlink update is skipped.
+    pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", subKey, badId);
+  }
+
+  private ObjectId directUpdateRef(Project.NameKey project, String ref) throws Exception {
+    try (Repository serverRepo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(serverRepo)) {
+      return tr.branch(ref).commit().create().copy();
+    }
+  }
+
   private void testSubmoduleSubjectCommitMessageAndExpectTruncation() 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");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
     // The first update doesn't include the rev log, so we ignore it
     pushChangeTo(subRepo, "master");
 
@@ -540,6 +676,6 @@
         "master",
         String.format(
             "Update git submodules\n\n* Update %s from branch 'master'\n  to %s\n  - %s\n\n[...]",
-            name("subscribed-to-project"), subHEAD.getName(), subCommitMsg.getShortMessage()));
+            subKey.get(), subHEAD.getName(), subCommitMsg.getShortMessage()));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 2812c86..eef6e33 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -16,20 +16,27 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
 import java.util.ArrayDeque;
 import java.util.Map;
+import org.apache.commons.lang.RandomStringUtils;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -66,14 +73,12 @@
     return submitByRebaseIfNecessaryConfig();
   }
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void subscriptionUpdateOfManyChanges() 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");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
 
     ObjectId subHEAD =
         subRepo
@@ -139,7 +144,7 @@
     gApi.changes().id(id2).current().review(ReviewInput.approve());
     gApi.changes().id(id3).current().review(ReviewInput.approve());
 
-    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
+    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
     gApi.changes().id(id1).current().submit();
     ObjectId subRepoId =
         subRepo
@@ -150,15 +155,13 @@
             .getAdvertisedRef("refs/heads/master")
             .getObjectId();
 
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subRepoId);
 
     // As the submodules have changed commits, the superproject tree will be
     // different, so we cannot directly compare the trees here, so make
     // assumptions only about the changed branches:
-    Project.NameKey p1 = new Project.NameKey(name("super-project"));
-    Project.NameKey p2 = new Project.NameKey(name("subscribed-to-project"));
-    assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
-    assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
+    assertThat(preview).containsKey(BranchNameKey.create(superKey, "refs/heads/master"));
+    assertThat(preview).containsKey(BranchNameKey.create(subKey, "refs/heads/master"));
 
     if ((getSubmitType() == SubmitType.CHERRY_PICK)
         || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
@@ -176,12 +179,9 @@
 
   @Test
   public void subscriptionUpdateIncludingChangeInSuperproject() 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");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
 
     ObjectId subHEAD =
         subRepo
@@ -274,71 +274,69 @@
             .getAdvertisedRef("refs/heads/master")
             .getObjectId();
 
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subRepoId);
   }
 
   @Test
   public void updateManySubmodules() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> sub1 = createProjectWithPush("sub1");
-    TestRepository<?> sub2 = createProjectWithPush("sub2");
-    TestRepository<?> sub3 = createProjectWithPush("sub3");
+    final int NUM = 3;
+    Project.NameKey subKey[] = new Project.NameKey[NUM];
+    TestRepository<?> sub[] = new TestRepository[NUM];
+    String prefix = RandomStringUtils.randomAlphabetic(8);
+    for (int i = 0; i < subKey.length; i++) {
+      subKey[i] =
+          projectOperations
+              .newProject()
+              .name(prefix + "sub" + i)
+              .submitType(getSubmitType())
+              .create();
+      projectOperations
+          .project(subKey[i])
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+          .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+          .update();
+      sub[i] = cloneProject(subKey[i]);
+    }
 
-    allowMatchingSubmoduleSubscription(
-        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "sub3", "refs/heads/master", "super-project", "refs/heads/master");
+    for (int i = 0; i < subKey.length; i++) {
+      allowMatchingSubmoduleSubscription(
+          subKey[i], "refs/heads/master", superKey, "refs/heads/master");
+    }
 
     Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "sub1", "master");
-    prepareSubmoduleConfigEntry(config, "sub2", "master");
-    prepareSubmoduleConfigEntry(config, "sub3", "master");
+    for (int i = 0; i < subKey.length; i++) {
+      prepareSubmoduleConfigEntry(config, subKey[i], "master");
+    }
     pushSubmoduleConfig(superRepo, "master", config);
 
     ObjectId superPreviousId = pushChangeTo(superRepo, "master");
 
-    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", "same-topic");
-    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", "same-topic");
-    ObjectId sub3Id = pushChangeTo(sub3, "refs/for/master", "some message", "same-topic");
+    ObjectId subId[] = new ObjectId[NUM];
 
-    approve(getChangeId(sub1, sub1Id).get());
-    approve(getChangeId(sub2, sub2Id).get());
-    approve(getChangeId(sub3, sub3Id).get());
+    for (int i = 0; i < sub.length; i++) {
+      subId[i] = pushChangeTo(sub[i], "refs/for/master", "some message", "same-topic");
+      approve(getChangeId(sub[i], subId[i]).get());
+    }
 
-    gApi.changes().id(getChangeId(sub1, sub1Id).get()).current().submit();
+    gApi.changes().id(getChangeId(sub[0], subId[0]).get()).current().submit();
 
-    expectToHaveSubmoduleState(superRepo, "master", "sub1", sub1, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub3", sub3, "master");
+    for (int i = 0; i < sub.length; i++) {
+      expectToHaveSubmoduleState(superRepo, "master", subKey[i], sub[i], "master");
+    }
 
-    String sub1HEAD =
-        sub1.git()
-            .fetch()
-            .setRemote("origin")
-            .call()
-            .getAdvertisedRef("refs/heads/master")
-            .getObjectId()
-            .name();
-
-    String sub2HEAD =
-        sub2.git()
-            .fetch()
-            .setRemote("origin")
-            .call()
-            .getAdvertisedRef("refs/heads/master")
-            .getObjectId()
-            .name();
-
-    String sub3HEAD =
-        sub3.git()
-            .fetch()
-            .setRemote("origin")
-            .call()
-            .getAdvertisedRef("refs/heads/master")
-            .getObjectId()
-            .name();
+    String heads[] = new String[NUM];
+    for (int i = 0; i < heads.length; i++) {
+      heads[i] =
+          sub[i]
+              .git()
+              .fetch()
+              .setRemote("origin")
+              .call()
+              .getAdvertisedRef("refs/heads/master")
+              .getObjectId()
+              .name();
+    }
 
     if (getSubmitType() == SubmitType.MERGE_IF_NECESSARY) {
       expectToHaveCommitMessage(
@@ -346,17 +344,17 @@
           "master",
           "Update git submodules\n\n"
               + "* Update "
-              + name("sub1")
+              + subKey[0].get()
               + " from branch 'master'\n  to "
-              + sub1HEAD
+              + heads[0]
               + "\n\n* Update "
-              + name("sub2")
+              + subKey[1].get()
               + " from branch 'master'\n  to "
-              + sub2HEAD
+              + heads[1]
               + "\n\n* Update "
-              + name("sub3")
+              + subKey[2].get()
               + " from branch 'master'\n  to "
-              + sub3HEAD);
+              + heads[2]);
     }
 
     superRepo
@@ -374,73 +372,98 @@
 
   @Test
   public void doNotUseFastForward() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
-    TestRepository<?> sub = createProjectWithPush("sub", false);
+    // like setup, but without empty commit
+    superKey =
+        projectOperations
+            .newProject()
+            .submitType(getSubmitType())
+            .createEmptyCommit(false)
+            .create();
+    grantPush(superKey);
+    subKey =
+        projectOperations
+            .newProject()
+            .submitType(getSubmitType())
+            .createEmptyCommit(false)
+            .create();
+    grantPush(subKey);
+    superRepo = cloneProject(superKey);
+    subRepo = cloneProject(subKey);
 
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
-    createSubmoduleSubscription(superRepo, "master", "sub", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
 
-    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+    ObjectId subId = pushChangeTo(subRepo, "refs/for/master", "some message", "same-topic");
 
     ObjectId superId = pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
 
-    String subChangeId = getChangeId(sub, subId).get();
+    String subChangeId = getChangeId(subRepo, subId).get();
     approve(subChangeId);
     approve(getChangeId(superRepo, superId).get());
 
     gApi.changes().id(subChangeId).current().submit();
 
-    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
-    RevCommit superHead = getRemoteHead(name("super-project"), "master");
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subRepo, "master");
+    RevCommit superHead = projectOperations.project(superKey).getHead("master");
     assertThat(superHead.getShortMessage()).contains("some message");
     assertThat(superHead.getId()).isNotEqualTo(superId);
   }
 
   @Test
   public void useFastForwardWhenNoSubmodule() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
-    TestRepository<?> sub = createProjectWithPush("sub", false);
+    // like setup, but without empty commit
+    superKey =
+        projectOperations
+            .newProject()
+            .submitType(getSubmitType())
+            .createEmptyCommit(false)
+            .create();
+    grantPush(superKey);
+    subKey =
+        projectOperations
+            .newProject()
+            .submitType(getSubmitType())
+            .createEmptyCommit(false)
+            .create();
+    grantPush(subKey);
+    superRepo = cloneProject(superKey);
+    subRepo = cloneProject(subKey);
 
-    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
-
+    ObjectId subId = pushChangeTo(subRepo, "refs/for/master", "some message", "same-topic");
     ObjectId superId = pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
 
-    String subChangeId = getChangeId(sub, subId).get();
+    String subChangeId = getChangeId(subRepo, subId).get();
     approve(subChangeId);
     approve(getChangeId(superRepo, superId).get());
 
     gApi.changes().id(subChangeId).current().submit();
 
-    RevCommit superHead = getRemoteHead(name("super-project"), "master");
+    RevCommit superHead = projectOperations.project(superKey).getHead("master");
     assertThat(superHead.getShortMessage()).isEqualTo("some message");
     assertThat(superHead.getId()).isEqualTo(superId);
   }
 
   @Test
   public void sameProjectSameBranchDifferentPaths() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> sub = createProjectWithPush("sub");
-
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
     Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "sub", "master");
-    prepareSubmoduleConfigEntry(config, "sub", "sub-copy", "master");
+    prepareSubmoduleConfigEntry(config, subKey, "master");
+    Project.NameKey copyKey = nameKey("sub-copy");
+    prepareSubmoduleConfigEntry(config, subKey, copyKey, "master");
     pushSubmoduleConfig(superRepo, "master", config);
 
     ObjectId superPreviousId = pushChangeTo(superRepo, "master");
 
-    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "");
+    ObjectId subId = pushChangeTo(subRepo, "refs/for/master", "some message", "");
 
-    approve(getChangeId(sub, subId).get());
+    approve(getChangeId(subRepo, subId).get());
 
-    gApi.changes().id(getChangeId(sub, subId).get()).current().submit();
+    gApi.changes().id(getChangeId(subRepo, subId).get()).current().submit();
 
-    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub-copy", sub, "master");
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", copyKey, subRepo, "master");
 
     superRepo
         .git()
@@ -457,37 +480,33 @@
 
   @Test
   public void sameProjectDifferentBranchDifferentPaths() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> sub = createProjectWithPush("sub");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/dev", superKey, "refs/heads/master");
 
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/dev", "super-project", "refs/heads/master");
-
-    ObjectId devHead = pushChangeTo(sub, "dev");
+    ObjectId devHead = pushChangeTo(subRepo, "dev");
     Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "sub", "sub-master", "master");
-    prepareSubmoduleConfigEntry(config, "sub", "sub-dev", "dev");
+    prepareSubmoduleConfigEntry(config, subKey, nameKey("sub-master"), "master");
+    prepareSubmoduleConfigEntry(config, subKey, nameKey("sub-dev"), "dev");
     pushSubmoduleConfig(superRepo, "master", config);
 
     ObjectId subMasterId =
-        pushChangeTo(sub, "refs/for/master", "some message", "b.txt", "content b", "same-topic");
+        pushChangeTo(
+            subRepo, "refs/for/master", "some message", "b.txt", "content b", "same-topic");
 
-    sub.reset(devHead);
+    subRepo.reset(devHead);
     ObjectId subDevId =
         pushChangeTo(
-            sub, "refs/for/dev", "some message in dev", "b.txt", "content b", "same-topic");
+            subRepo, "refs/for/dev", "some message in dev", "b.txt", "content b", "same-topic");
 
-    approve(getChangeId(sub, subMasterId).get());
-    approve(getChangeId(sub, subDevId).get());
+    approve(getChangeId(subRepo, subMasterId).get());
+    approve(getChangeId(subRepo, subDevId).get());
 
     ObjectId superPreviousId = pushChangeTo(superRepo, "master");
 
-    gApi.changes().id(getChangeId(sub, subMasterId).get()).current().submit();
+    gApi.changes().id(getChangeId(subRepo, subMasterId).get()).current().submit();
 
-    expectToHaveSubmoduleState(superRepo, "master", "sub-master", sub, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub-dev", sub, "dev");
+    expectToHaveSubmoduleState(superRepo, "master", nameKey("sub-master"), subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", nameKey("sub-dev"), subRepo, "dev");
 
     superRepo
         .git()
@@ -504,29 +523,27 @@
 
   @Test
   public void nonSubmoduleInSameTopic() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> sub = createProjectWithPush("sub");
-    TestRepository<?> standAlone = createProjectWithPush("standalone");
+    Project.NameKey standaloneKey = createProjectForPush(getSubmitType());
+    TestRepository<?> standAlone = cloneProject(standaloneKey);
 
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
 
-    createSubmoduleSubscription(superRepo, "master", "sub", "master");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
 
     ObjectId superPreviousId = pushChangeTo(superRepo, "master");
 
-    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+    ObjectId subId = pushChangeTo(subRepo, "refs/for/master", "some message", "same-topic");
     ObjectId standAloneId =
         pushChangeTo(standAlone, "refs/for/master", "some message", "same-topic");
 
-    String subChangeId = getChangeId(sub, subId).get();
+    String subChangeId = getChangeId(subRepo, subId).get();
     String standAloneChangeId = getChangeId(standAlone, standAloneId).get();
     approve(subChangeId);
     approve(standAloneChangeId);
 
     gApi.changes().id(subChangeId).current().submit();
 
-    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
+    expectToHaveSubmoduleState(superRepo, "master", subKey, subRepo, "master");
 
     ChangeStatus status = gApi.changes().id(standAloneChangeId).info().status;
     assertThat(status).isEqualTo(ChangeStatus.MERGED);
@@ -546,17 +563,18 @@
 
   @Test
   public void recursiveSubmodules() throws Exception {
-    TestRepository<?> topRepo = createProjectWithPush("top-project");
-    TestRepository<?> midRepo = createProjectWithPush("mid-project");
-    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
+    Project.NameKey topKey = createProjectForPush(getSubmitType());
+    Project.NameKey midKey = createProjectForPush(getSubmitType());
+    Project.NameKey botKey = createProjectForPush(getSubmitType());
+    TestRepository<?> topRepo = cloneProject(topKey);
+    TestRepository<?> midRepo = cloneProject(midKey);
+    TestRepository<?> bottomRepo = cloneProject(botKey);
 
-    allowMatchingSubmoduleSubscription(
-        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(midKey, "refs/heads/master", topKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(botKey, "refs/heads/master", midKey, "refs/heads/master");
 
-    createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
-    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
+    createSubmoduleSubscription(topRepo, "master", midKey, "master");
+    createSubmoduleSubscription(midRepo, "master", botKey, "master");
 
     ObjectId bottomHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
     ObjectId topHead = pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
@@ -569,27 +587,27 @@
 
     gApi.changes().id(id1).current().submit();
 
-    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
-    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
+    expectToHaveSubmoduleState(midRepo, "master", botKey, bottomRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", midKey, midRepo, "master");
   }
 
   @Test
   public void triangleSubmodules() throws Exception {
-    TestRepository<?> topRepo = createProjectWithPush("top-project");
-    TestRepository<?> midRepo = createProjectWithPush("mid-project");
-    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
+    Project.NameKey topKey = createProjectForPush(getSubmitType());
+    Project.NameKey midKey = createProjectForPush(getSubmitType());
+    Project.NameKey botKey = createProjectForPush(getSubmitType());
+    TestRepository<?> topRepo = cloneProject(topKey);
+    TestRepository<?> midRepo = cloneProject(midKey);
+    TestRepository<?> bottomRepo = cloneProject(botKey);
 
-    allowMatchingSubmoduleSubscription(
-        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "bottom-project", "refs/heads/master", "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(midKey, "refs/heads/master", topKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(botKey, "refs/heads/master", midKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(botKey, "refs/heads/master", topKey, "refs/heads/master");
 
-    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
+    createSubmoduleSubscription(midRepo, "master", botKey, "master");
     Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "bottom-project", "master");
-    prepareSubmoduleConfigEntry(config, "mid-project", "master");
+    prepareSubmoduleConfigEntry(config, botKey, "master");
+    prepareSubmoduleConfigEntry(config, midKey, "master");
     pushSubmoduleConfig(topRepo, "master", config);
 
     ObjectId bottomHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
@@ -603,65 +621,61 @@
 
     gApi.changes().id(id1).current().submit();
 
-    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
-    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
-    expectToHaveSubmoduleState(topRepo, "master", "bottom-project", bottomRepo, "master");
+    expectToHaveSubmoduleState(midRepo, "master", botKey, bottomRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", midKey, midRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", botKey, bottomRepo, "master");
   }
 
-  private String prepareBranchCircularSubscription() throws Exception {
-    TestRepository<?> topRepo = createProjectWithPush("top-project");
-    TestRepository<?> midRepo = createProjectWithPush("mid-project");
-    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
+  private void testBranchCircularSubscription(ThrowingConsumer<String> apiCall) throws Exception {
+    Project.NameKey topKey = createProjectForPush(getSubmitType());
+    Project.NameKey midKey = createProjectForPush(getSubmitType());
+    Project.NameKey botKey = createProjectForPush(getSubmitType());
+    TestRepository<?> topRepo = cloneProject(topKey);
+    TestRepository<?> midRepo = cloneProject(midKey);
+    TestRepository<?> bottomRepo = cloneProject(botKey);
 
-    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
-    createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
-    createSubmoduleSubscription(bottomRepo, "master", "top-project", "master");
+    createSubmoduleSubscription(midRepo, "master", botKey, "master");
+    createSubmoduleSubscription(topRepo, "master", midKey, "master");
+    createSubmoduleSubscription(bottomRepo, "master", topKey, "master");
 
-    allowMatchingSubmoduleSubscription(
-        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "top-project", "refs/heads/master", "bottom-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(botKey, "refs/heads/master", midKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(midKey, "refs/heads/master", topKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(topKey, "refs/heads/master", botKey, "refs/heads/master");
 
     ObjectId bottomMasterHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "");
     String changeId = getChangeId(bottomRepo, bottomMasterHead).get();
 
     approve(changeId);
-    exception.expectMessage("Branch level circular subscriptions detected");
-    exception.expectMessage("top-project,refs/heads/master");
-    exception.expectMessage("mid-project,refs/heads/master");
-    exception.expectMessage("bottom-project,refs/heads/master");
-    return changeId;
+
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> apiCall.accept(changeId));
+    assertThat(thrown).hasMessageThat().contains("Branch level circular subscriptions detected");
+    assertThat(thrown).hasMessageThat().contains(topKey.get() + ",refs/heads/master");
+    assertThat(thrown).hasMessageThat().contains(midKey.get() + ",refs/heads/master");
+    assertThat(thrown).hasMessageThat().contains(botKey.get() + ",refs/heads/master");
   }
 
   @Test
   public void branchCircularSubscription() throws Exception {
-    String changeId = prepareBranchCircularSubscription();
-    gApi.changes().id(changeId).current().submit();
+    testBranchCircularSubscription(changeId -> gApi.changes().id(changeId).current().submit());
   }
 
   @Test
   public void branchCircularSubscriptionPreview() throws Exception {
-    String changeId = prepareBranchCircularSubscription();
-    gApi.changes().id(changeId).current().submitPreview();
+    testBranchCircularSubscription(
+        changeId -> gApi.changes().id(changeId).current().submitPreview());
   }
 
   @Test
   public void projectCircularSubscriptionWholeTopic() 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");
-    allowMatchingSubmoduleSubscription(
-        "super-project", "refs/heads/dev", "subscribed-to-project", "refs/heads/dev");
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(superKey, "refs/heads/dev", subKey, "refs/heads/dev");
 
     pushChangeTo(subRepo, "dev");
     pushChangeTo(superRepo, "dev");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
+    createSubmoduleSubscription(subRepo, "dev", superKey, "dev");
 
     ObjectId subMasterHead =
         pushChangeTo(
@@ -671,16 +685,22 @@
     approve(getChangeId(subRepo, subMasterHead).get());
     approve(getChangeId(superRepo, superDevHead).get());
 
-    exception.expectMessage("Project level circular subscriptions detected");
-    exception.expectMessage("subscribed-to-project");
-    exception.expectMessage("super-project");
-    gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current().submit();
+    Throwable thrown =
+        assertThrows(
+            Throwable.class,
+            () -> gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current().submit());
+    assertThat(thrown).hasMessageThat().contains("Project level circular subscriptions detected");
+    assertThat(thrown).hasMessageThat().contains(subKey.get());
+    assertThat(thrown).hasMessageThat().contains(superKey.get());
   }
 
   @Test
   public void projectNoSubscriptionWholeTopic() throws Exception {
-    TestRepository<?> repoA = createProjectWithPush("project-a");
-    TestRepository<?> repoB = createProjectWithPush("project-b");
+    Project.NameKey keyA = createProjectForPush(getSubmitType());
+    Project.NameKey keyB = createProjectForPush(getSubmitType());
+
+    TestRepository<?> repoA = cloneProject(keyA);
+    TestRepository<?> repoB = cloneProject(keyB);
     // bootstrap the dev branch
     ObjectId a0 = pushChangeTo(repoA, "dev");
 
@@ -735,33 +755,33 @@
     approve(getChangeId(repoB, bDevHead).get());
 
     gApi.changes().id(getChangeId(repoA, aDevHead).get()).current().submit();
-    assertThat(getRemoteHead(name("project-a"), "refs/heads/master").getShortMessage())
+    assertThat(projectOperations.project(keyA).getHead("refs/heads/master").getShortMessage())
         .contains("some message in a master.txt");
-    assertThat(getRemoteHead(name("project-a"), "refs/heads/dev").getShortMessage())
+    assertThat(projectOperations.project(keyA).getHead("refs/heads/dev").getShortMessage())
         .contains("some message in a dev.txt");
-    assertThat(getRemoteHead(name("project-b"), "refs/heads/master").getShortMessage())
+    assertThat(projectOperations.project(keyB).getHead("refs/heads/master").getShortMessage())
         .contains("some message in b master.txt");
-    assertThat(getRemoteHead(name("project-b"), "refs/heads/dev").getShortMessage())
+    assertThat(projectOperations.project(keyB).getHead("refs/heads/dev").getShortMessage())
         .contains("some message in b dev.txt");
   }
 
   @Test
   public void twoProjectsMultipleBranchesWholeTopic() throws Exception {
-    TestRepository<?> repoA = createProjectWithPush("project-a");
-    TestRepository<?> repoB = createProjectWithPush("project-b");
+    Project.NameKey keyA = createProjectForPush(getSubmitType());
+    Project.NameKey keyB = createProjectForPush(getSubmitType());
+    TestRepository<?> repoA = cloneProject(keyA);
+    TestRepository<?> repoB = cloneProject(keyB);
     // bootstrap the dev branch
     pushChangeTo(repoA, "dev");
 
     // bootstrap the dev branch
     ObjectId b0 = pushChangeTo(repoB, "dev");
 
-    allowMatchingSubmoduleSubscription(
-        "project-b", "refs/heads/master", "project-a", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "project-b", "refs/heads/dev", "project-a", "refs/heads/dev");
+    allowMatchingSubmoduleSubscription(keyB, "refs/heads/master", keyA, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(keyB, "refs/heads/dev", keyA, "refs/heads/dev");
 
-    createSubmoduleSubscription(repoA, "master", "project-b", "master");
-    createSubmoduleSubscription(repoA, "dev", "project-b", "dev");
+    createSubmoduleSubscription(repoA, "master", keyB, "master");
+    createSubmoduleSubscription(repoA, "dev", keyB, "dev");
 
     // create a change for master branch in repo b
     ObjectId bHead =
@@ -788,26 +808,23 @@
     approve(getChangeId(repoB, bDevHead).get());
     gApi.changes().id(getChangeId(repoB, bHead).get()).current().submit();
 
-    expectToHaveSubmoduleState(repoA, "master", "project-b", repoB, "master");
-    expectToHaveSubmoduleState(repoA, "dev", "project-b", repoB, "dev");
+    expectToHaveSubmoduleState(repoA, "master", keyB, repoB, "master");
+    expectToHaveSubmoduleState(repoA, "dev", keyB, repoB, "dev");
   }
 
   @Test
   public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    Project.NameKey subKey1 = createProjectForPush(getSubmitType());
+    TestRepository<?> sub1 = cloneProject(subKey1);
+    Project.NameKey subKey2 = createProjectForPush(getSubmitType());
+    TestRepository<?> sub2 = cloneProject(subKey2);
 
-    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");
+    allowMatchingSubmoduleSubscription(subKey1, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(subKey2, "refs/heads/master", superKey, "refs/heads/master");
 
     Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "sub1", "master");
-    prepareSubmoduleConfigEntry(config, "sub2", "master");
+    prepareSubmoduleConfigEntry(config, subKey1, "master");
+    prepareSubmoduleConfigEntry(config, subKey2, "master");
     pushSubmoduleConfig(superRepo, "master", config);
 
     ObjectId superPreviousId = pushChangeTo(superRepo, "master");
@@ -837,23 +854,66 @@
 
     sub1.git().fetch().call();
     RevWalk rw1 = sub1.getRevWalk();
-    RevCommit master1 = rw1.parseCommit(getRemoteHead(name("sub1"), "master"));
+    RevCommit master1 = rw1.parseCommit(projectOperations.project(subKey1).getHead("master"));
     RevCommit change1Ps = parseCurrentRevision(rw1, changeId1);
     assertThat(rw1.isMergedInto(change1Ps, master1)).isTrue();
 
     sub2.git().fetch().call();
     RevWalk rw2 = sub2.getRevWalk();
-    RevCommit master2 = rw2.parseCommit(getRemoteHead(name("sub2"), "master"));
+    RevCommit master2 = rw2.parseCommit(projectOperations.project(subKey2).getHead("master"));
     RevCommit change2Ps = parseCurrentRevision(rw2, changeId2);
     assertThat(rw2.isMergedInto(change2Ps, master2)).isTrue();
 
     assertThat(input.generateLockFailures).containsExactly(false);
 
-    expectToHaveSubmoduleState(superRepo, "master", "sub1", sub1, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
+    expectToHaveSubmoduleState(superRepo, "master", subKey1, sub1, "master");
+    expectToHaveSubmoduleState(superRepo, "master", subKey2, sub2, "master");
 
     assertWithMessage("submodule subscription update should have made one commit")
         .that(superRepo.getRepository().resolve("origin/master^"))
         .isEqualTo(superPreviousId);
   }
+
+  @Test
+  public void skipUpdatingBrokenGitlinkPointer() throws Exception {
+    Project.NameKey subKey1 = createProjectForPush(getSubmitType());
+    TestRepository<?> sub1 = cloneProject(subKey1);
+    Project.NameKey subKey2 = createProjectForPush(getSubmitType());
+    TestRepository<?> sub2 = cloneProject(subKey2);
+
+    allowMatchingSubmoduleSubscription(subKey1, "refs/heads/master", superKey, "refs/heads/master");
+    allowMatchingSubmoduleSubscription(subKey2, "refs/heads/master", superKey, "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, subKey1, "master");
+    prepareSubmoduleConfigEntry(config, subKey2, "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    // Write an invalid SHA-1 directly to one of the gitlinks.
+    ObjectId badId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    directUpdateSubmodule(superKey, "refs/heads/master", subKey1, badId);
+    expectToHaveSubmoduleState(superRepo, "master", subKey1, 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", subKey1, badId);
+    expectToHaveSubmoduleState(superRepo, "master", subKey2, sub2, "master");
+  }
+
+  private Project.NameKey nameKey(String s) {
+    return Project.nameKey(name(s));
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index 0d24a5d..f8176a5 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.pgm;
 
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.StreamSubject.streams;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -35,6 +36,7 @@
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
 import java.nio.file.Files;
 import java.util.Set;
@@ -46,6 +48,9 @@
 
 @NoHttpd
 public abstract class AbstractReindexTests extends StandaloneSiteTest {
+  /** @param injector injector */
+  public abstract void configureIndex(Injector injector) throws Exception;
+
   private static final String CHANGES = ChangeSchemaDefinitions.NAME;
 
   private Project.NameKey project;
@@ -72,11 +77,7 @@
           .containsExactly(adminId.get());
       // Query group index
       assertThat(
-              gApi.groups()
-                  .query("Group")
-                  .withOption(MEMBERS)
-                  .get()
-                  .stream()
+              gApi.groups().query("Group").withOption(MEMBERS).get().stream()
                   .flatMap(g -> g.members.stream())
                   .map(a -> a._accountId))
           .containsExactly(adminId.get());
@@ -195,7 +196,7 @@
       // Updating and searching old schema version works.
       Provider<InternalChangeQuery> queryProvider =
           ctx.getInjector().getProvider(InternalChangeQuery.class);
-      assertThat(queryProvider.get().byKey(new Change.Key(changeId))).hasSize(1);
+      assertThat(queryProvider.get().byKey(Change.key(changeId))).hasSize(1);
       assertThat(queryProvider.get().byTopicOpen("topic1")).isEmpty();
 
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
@@ -223,8 +224,9 @@
   }
 
   private void setUpChange() throws Exception {
-    project = new Project.NameKey("reindex-project-test");
+    project = Project.nameKey("reindex-project-test");
     try (ServerContext ctx = startServer()) {
+      configureIndex(ctx.getInjector());
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
       gApi.projects().create(project.get());
 
@@ -254,33 +256,31 @@
   }
 
   private void assertSearchVersion(ServerContext ctx, int expected) {
-    assertThat(
+    assertWithMessage("search version")
+        .that(
             ctx.getInjector()
                 .getInstance(ChangeIndexCollection.class)
                 .getSearchIndex()
                 .getSchema()
                 .getVersion())
-        .named("search version")
         .isEqualTo(expected);
   }
 
   private void assertWriteVersions(ServerContext ctx, Integer... expected) {
-    assertThat(
-            ctx.getInjector()
-                .getInstance(ChangeIndexCollection.class)
-                .getWriteIndexes()
-                .stream()
+    assertWithMessage("write versions")
+        .about(streams())
+        .that(
+            ctx.getInjector().getInstance(ChangeIndexCollection.class).getWriteIndexes().stream()
                 .map(i -> i.getSchema().getVersion()))
-        .named("write versions")
         .containsExactlyElementsIn(ImmutableSet.copyOf(expected));
   }
 
   private void assertReady(int expectedReady) throws Exception {
     Set<Integer> allVersions = ChangeSchemaDefinitions.INSTANCE.getSchemas().keySet();
     GerritIndexStatus status = new GerritIndexStatus(sitePaths);
-    assertThat(
+    assertWithMessage("ready state for index versions")
+        .that(
             allVersions.stream().collect(toImmutableMap(v -> v, v -> status.getReady(CHANGES, v))))
-        .named("ready state for index versions")
         .isEqualTo(allVersions.stream().collect(toImmutableMap(v -> v, v -> v == expectedReady)));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index a8d5644..e0ed78a 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -13,6 +13,8 @@
     vm_args = ["-Xmx512m"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/server/schema",
     ],
 )
@@ -21,20 +23,24 @@
     srcs = ["ElasticReindexIT.java"],
     group = "elastic",
     labels = [
+        "docker",
         "elastic",
+        "exclusive",
         "pgm",
         "no_windows",
     ],
     vm_args = ["-Xmx512m"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/server/schema",
+        "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
     ],
 )
 
 java_library(
     name = "util",
-    testonly = 1,
+    testonly = True,
     srcs = [
         "AbstractReindexTests.java",
         "IndexUpgradeController.java",
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index 1aa8d54..3fca298 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2018 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,7 +14,40 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import org.junit.Ignore;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
 
-@Ignore
-public class ElasticReindexIT extends AbstractReindexTests {}
+import com.google.gerrit.elasticsearch.ElasticVersion;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+
+public class ElasticReindexIT extends AbstractReindexTests {
+
+  @ConfigSuite.Default
+  public static Config elasticsearchV5() {
+    return getConfig(ElasticVersion.V5_6);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV6() {
+    return getConfig(ElasticVersion.V6_7);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV7() {
+    return getConfig(ElasticVersion.V7_2);
+  }
+
+  @Override
+  public void configureIndex(Injector injector) throws Exception {
+    createAllIndexes(injector);
+  }
+
+  @Before
+  public void reindexFirstSinceElastic() throws Exception {
+    assertServerStartupFails();
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
new file mode 100644
index 0000000..a573e35
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import java.util.Optional;
+import org.junit.Test;
+
+@NoHttpd
+public class InitIT extends StandaloneSiteTest {
+
+  @Test
+  public void indexesAllProjectsAndAllUsers() throws Exception {
+    runGerrit("init", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+    try (ServerContext ctx = startServer()) {
+      ProjectIndexCollection projectIndex =
+          ctx.getInjector().getInstance(ProjectIndexCollection.class);
+      Project.NameKey allProjects = ctx.getInjector().getInstance(AllProjectsName.class);
+      Project.NameKey allUsers = ctx.getInjector().getInstance(AllUsersName.class);
+      QueryOptions opts =
+          QueryOptions.create(IndexConfig.createDefault(), 0, 1, ImmutableSet.of("name"));
+      Optional<ProjectData> allProjectsData = projectIndex.getSearchIndex().get(allProjects, opts);
+      assertThat(allProjectsData.isPresent()).isTrue();
+      Optional<ProjectData> allUsersData = projectIndex.getSearchIndex().get(allUsers, opts);
+      assertThat(allUsersData.isPresent()).isTrue();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
index 18d2628..223851e 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
@@ -14,4 +14,9 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-public class ReindexIT extends AbstractReindexTests {}
+import com.google.inject.Injector;
+
+public class ReindexIT extends AbstractReindexTests {
+  @Override
+  public void configureIndex(Injector injector) {}
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java b/javatests/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java
deleted file mode 100644
index 1bb23fb..0000000
--- a/javatests/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java
+++ /dev/null
@@ -1,363 +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.acceptance.pgm;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.StandaloneSiteTest;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.common.ChangeInput;
-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.server.ReviewDb;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LocalDiskRepositoryManager;
-import com.google.gerrit.server.index.GerritIndexStatus;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gerrit.testing.NoteDbMode;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Key;
-import com.google.inject.TypeLiteral;
-import java.io.File;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.stream.Stream;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.StoredConfig;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.junit.Before;
-import org.junit.Test;
-
-/**
- * Tests for NoteDb migrations where the entry point is through a program, {@code
- * migrate-to-note-db} or {@code daemon}.
- *
- * <p><strong>Note:</strong> These tests are very slow due to the repeated daemon startup. Prefer
- * adding tests to {@link com.google.gerrit.acceptance.server.notedb.OnlineNoteDbMigrationIT} if
- * possible.
- */
-@NoHttpd
-public class StandaloneNoteDbMigrationIT extends StandaloneSiteTest {
-  private StoredConfig gerritConfig;
-  private StoredConfig noteDbConfig;
-
-  private Project.NameKey project;
-  private Change.Id changeId;
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
-    gerritConfig = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
-    // Unlike in the running server, for tests, we don't stack notedb.config on gerrit.config.
-    noteDbConfig = new FileBasedConfig(sitePaths.notedb_config.toFile(), FS.detect());
-
-    // Set gc.pruneExpire=now so GC prunes all unreachable objects from All-Users, which allows us
-    // to reliably test that it behaves as expected.
-    Path cfgPath = sitePaths.site_path.resolve("git").resolve("All-Users.git").resolve("config");
-    assertWithMessage("Expected All-Users config at %s", cfgPath)
-        .that(Files.isRegularFile(cfgPath))
-        .isTrue();
-    FileBasedConfig cfg = new FileBasedConfig(cfgPath.toFile(), FS.detect());
-    cfg.setString("gc", null, "pruneExpire", "now");
-    cfg.save();
-  }
-
-  @Test
-  public void rebuildOneChangeTrialMode() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoAutoMigrateConfig(noteDbConfig);
-    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
-    setUpOneChange();
-
-    migrate("--trial");
-    assertNotesMigrationState(NotesMigrationState.READ_WRITE_NO_SEQUENCE);
-
-    try (ServerContext ctx = startServer()) {
-      GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
-      ObjectId metaId;
-      try (Repository repo = repoManager.openRepository(project)) {
-        Ref ref = repo.exactRef(RefNames.changeMetaRef(changeId));
-        assertThat(ref).isNotNull();
-        metaId = ref.getObjectId();
-      }
-
-      try (ReviewDb db = openUnderlyingReviewDb(ctx)) {
-        Change c = db.changes().get(changeId);
-        assertThat(c).isNotNull();
-        NoteDbChangeState state = NoteDbChangeState.parse(c);
-        assertThat(state).isNotNull();
-        assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-        assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
-      }
-    }
-  }
-
-  @Test
-  public void migrateOneChange() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoAutoMigrateConfig(noteDbConfig);
-    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
-    setUpOneChange();
-
-    migrate();
-    assertNotesMigrationState(NotesMigrationState.NOTE_DB);
-
-    File allUsersDir;
-    try (ServerContext ctx = startServer()) {
-      GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
-      try (Repository repo = repoManager.openRepository(project)) {
-        assertThat(repo.exactRef(RefNames.changeMetaRef(changeId))).isNotNull();
-      }
-      assertThat(repoManager).isInstanceOf(LocalDiskRepositoryManager.class);
-      try (Repository repo =
-          repoManager.openRepository(ctx.getInjector().getInstance(AllUsersName.class))) {
-        allUsersDir = repo.getDirectory();
-      }
-
-      try (ReviewDb db = openUnderlyingReviewDb(ctx)) {
-        Change c = db.changes().get(changeId);
-        assertThat(c).isNotNull();
-        NoteDbChangeState state = NoteDbChangeState.parse(c);
-        assertThat(state).isNotNull();
-        assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.NOTE_DB);
-        assertThat(state.getRefState()).isEmpty();
-
-        ChangeInput in = new ChangeInput(project.get(), "master", "NoteDb-only change");
-        in.newBranch = true;
-        GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
-        Change.Id id2 = new Change.Id(gApi.changes().create(in).info()._number);
-        assertThat(db.changes().get(id2)).isNull();
-      }
-    }
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertAutoMigrateConfig(noteDbConfig, false);
-
-    try (FileRepository repo = new FileRepository(allUsersDir)) {
-      try (Stream<Path> paths = Files.walk(repo.getObjectsDirectory().toPath())) {
-        assertThat(paths.filter(p -> !p.toString().contains("pack") && Files.isRegularFile(p)))
-            .named("loose object files in All-Users")
-            .isEmpty();
-      }
-      assertThat(repo.getObjectDatabase().getPacks()).named("packfiles in All-Users").hasSize(1);
-    }
-  }
-
-  @Test
-  public void migrationWithReindex() throws Exception {
-    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
-    setUpOneChange();
-
-    int version = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
-    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
-    assertThat(status.getReady(ChangeSchemaDefinitions.NAME, version)).isTrue();
-    status.setReady(ChangeSchemaDefinitions.NAME, version, false);
-    status.save();
-    assertServerStartupFails();
-
-    migrate();
-    assertNotesMigrationState(NotesMigrationState.NOTE_DB);
-
-    status = new GerritIndexStatus(sitePaths);
-    assertThat(status.getReady(ChangeSchemaDefinitions.NAME, version)).isTrue();
-  }
-
-  @Test
-  public void onlineMigrationViaDaemon() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoAutoMigrateConfig(noteDbConfig);
-
-    testOnlineMigration(u -> startServer(u.module(), "--migrate-to-note-db", "true"));
-
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertAutoMigrateConfig(noteDbConfig, false);
-  }
-
-  @Test
-  public void onlineMigrationViaConfig() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoAutoMigrateConfig(noteDbConfig);
-
-    testOnlineMigration(
-        u -> {
-          gerritConfig.setBoolean("noteDb", "changes", "autoMigrate", true);
-          gerritConfig.save();
-          return startServer(u.module());
-        });
-
-    // Auto-migration is turned off in notedb.config, which takes precedence, but is still on in
-    // gerrit.config. This means Puppet can continue overwriting gerrit.config without turning
-    // auto-migration back on.
-    assertAutoMigrateConfig(gerritConfig, true);
-    assertAutoMigrateConfig(noteDbConfig, false);
-  }
-
-  @Test
-  public void onlineMigrationTrialModeViaFlag() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoTrialConfig(gerritConfig);
-
-    assertNoAutoMigrateConfig(noteDbConfig);
-    assertNoTrialConfig(noteDbConfig);
-
-    testOnlineMigration(
-        u -> startServer(u.module(), "--migrate-to-note-db", "--trial"),
-        NotesMigrationState.READ_WRITE_NO_SEQUENCE);
-
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoTrialConfig(gerritConfig);
-
-    assertAutoMigrateConfig(noteDbConfig, true);
-    assertTrialConfig(noteDbConfig, true);
-  }
-
-  @Test
-  public void onlineMigrationTrialModeViaConfig() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoTrialConfig(gerritConfig);
-
-    assertNoAutoMigrateConfig(noteDbConfig);
-    assertNoTrialConfig(noteDbConfig);
-
-    testOnlineMigration(
-        u -> {
-          gerritConfig.setBoolean("noteDb", "changes", "autoMigrate", true);
-          gerritConfig.setBoolean("noteDb", "changes", "trial", true);
-          gerritConfig.save();
-          return startServer(u.module());
-        },
-        NotesMigrationState.READ_WRITE_NO_SEQUENCE);
-
-    assertAutoMigrateConfig(gerritConfig, true);
-    assertTrialConfig(gerritConfig, true);
-
-    assertAutoMigrateConfig(noteDbConfig, true);
-    assertTrialConfig(noteDbConfig, true);
-  }
-
-  @FunctionalInterface
-  private interface StartServerWithMigration {
-    ServerContext start(IndexUpgradeController u) throws Exception;
-  }
-
-  private void testOnlineMigration(StartServerWithMigration start) throws Exception {
-    testOnlineMigration(start, NotesMigrationState.NOTE_DB);
-  }
-
-  private void testOnlineMigration(
-      StartServerWithMigration start, NotesMigrationState expectedEndState) throws Exception {
-    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
-    int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
-    int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
-
-    // Before storing any changes, switch back to the previous version.
-    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
-    status.setReady(ChangeSchemaDefinitions.NAME, currVersion, false);
-    status.setReady(ChangeSchemaDefinitions.NAME, prevVersion, true);
-    status.save();
-
-    setOnlineUpgradeConfig(false);
-    setUpOneChange();
-    setOnlineUpgradeConfig(true);
-
-    IndexUpgradeController u = new IndexUpgradeController(1);
-    try (ServerContext ctx = start.start(u)) {
-      ChangeIndexCollection indexes = ctx.getInjector().getInstance(ChangeIndexCollection.class);
-      assertThat(indexes.getSearchIndex().getSchema().getVersion()).isEqualTo(prevVersion);
-
-      // Index schema upgrades happen after NoteDb migration, so waiting for those to complete
-      // should be sufficient.
-      u.runUpgrades();
-
-      assertThat(indexes.getSearchIndex().getSchema().getVersion()).isEqualTo(currVersion);
-      assertNotesMigrationState(expectedEndState);
-    }
-  }
-
-  private void setUpOneChange() throws Exception {
-    project = new Project.NameKey("project");
-    try (ServerContext ctx = startServer()) {
-      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
-      gApi.projects().create("project");
-
-      ChangeInput in = new ChangeInput(project.get(), "master", "Test change");
-      in.newBranch = true;
-      changeId = new Change.Id(gApi.changes().create(in).info()._number);
-    }
-  }
-
-  private void migrate(String... additionalArgs) throws Exception {
-    runGerrit(
-        ImmutableList.of(
-            "migrate-to-note-db", "-d", sitePaths.site_path.toString(), "--show-stack-trace"),
-        ImmutableList.copyOf(additionalArgs));
-  }
-
-  private void assertNotesMigrationState(NotesMigrationState expected) throws Exception {
-    noteDbConfig.load();
-    assertThat(NotesMigrationState.forConfig(noteDbConfig)).hasValue(expected);
-  }
-
-  private ReviewDb openUnderlyingReviewDb(ServerContext ctx) throws Exception {
-    return ctx.getInjector()
-        .getInstance(Key.get(new TypeLiteral<SchemaFactory<ReviewDb>>() {}, ReviewDbFactory.class))
-        .open();
-  }
-
-  private static void assertNoAutoMigrateConfig(StoredConfig cfg) throws Exception {
-    cfg.load();
-    assertThat(cfg.getString("noteDb", "changes", "autoMigrate")).isNull();
-  }
-
-  private static void assertAutoMigrateConfig(StoredConfig cfg, boolean expected) throws Exception {
-    cfg.load();
-    assertThat(cfg.getString("noteDb", "changes", "autoMigrate")).isNotNull();
-    assertThat(cfg.getBoolean("noteDb", "changes", "autoMigrate", false)).isEqualTo(expected);
-  }
-
-  private static void assertNoTrialConfig(StoredConfig cfg) throws Exception {
-    cfg.load();
-    assertThat(cfg.getString("noteDb", "changes", "trial")).isNull();
-  }
-
-  private static void assertTrialConfig(StoredConfig cfg, boolean expected) throws Exception {
-    cfg.load();
-    assertThat(cfg.getString("noteDb", "changes", "trial")).isNotNull();
-    assertThat(cfg.getBoolean("noteDb", "changes", "trial", false)).isEqualTo(expected);
-  }
-
-  private void setOnlineUpgradeConfig(boolean enable) throws Exception {
-    gerritConfig.load();
-    gerritConfig.setBoolean("index", null, "onlineUpgrade", enable);
-    gerritConfig.save();
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/rest/BUILD b/javatests/com/google/gerrit/acceptance/rest/BUILD
new file mode 100644
index 0000000..84887da
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/BUILD
@@ -0,0 +1,10 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_bindings_collection",
+    labels = ["rest"],
+    deps = [
+        "//java/com/google/gerrit/server/logging",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/rest/DeleteOnCollectionIT.java b/javatests/com/google/gerrit/acceptance/rest/DeleteOnCollectionIT.java
new file mode 100644
index 0000000..d891d6e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/DeleteOnCollectionIT.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.project.BranchResource.BRANCH_KIND;
+import static org.apache.http.HttpStatus.SC_NO_CONTENT;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Module;
+import org.junit.Test;
+
+public class DeleteOnCollectionIT extends AbstractDaemonTest {
+  @Override
+  public Module createModule() {
+    return new RestApiModule() {
+      @Override
+      public void configure() {
+        deleteOnCollection(BRANCH_KIND)
+            .toInstance(
+                (RestCollectionModifyView<ProjectResource, BranchResource, Object>)
+                    (parentResource, input) -> Response.none());
+      }
+    };
+  }
+
+  @Test
+  public void deleteOnChildCollection() throws Exception {
+    RestResponse response = adminRestSession.delete("/projects/" + project.get() + "/branches");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/MethodNotAllowedIT.java b/javatests/com/google/gerrit/acceptance/rest/MethodNotAllowedIT.java
new file mode 100644
index 0000000..06202b1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/MethodNotAllowedIT.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import org.apache.http.client.fluent.Request;
+import org.junit.Test;
+
+public class MethodNotAllowedIT extends AbstractDaemonTest {
+  @Test
+  public void unsupportedPostOnRootCollection() throws Exception {
+    RestResponse response = adminRestSession.post("/accounts/");
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent()).isEqualTo("Not implemented: POST /accounts/");
+  }
+
+  @Test
+  public void unsupportedPostOnChildCollection() throws Exception {
+    RestResponse response = adminRestSession.post("/accounts/self/emails");
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Not implemented: POST /accounts/self/emails");
+  }
+
+  @Test
+  public void unsupportedDeleteOnRootCollection() throws Exception {
+    RestResponse response = adminRestSession.delete("/accounts/");
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent()).isEqualTo("Not implemented: DELETE /accounts/");
+  }
+
+  @Test
+  public void unsupportedDeleteOnChildCollection() throws Exception {
+    RestResponse response = adminRestSession.delete("/accounts/self/emails");
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Not implemented: DELETE /accounts/self/emails");
+  }
+
+  @Test
+  public void unsupportedHttpMethodOnRootCollection() throws Exception {
+    Request patch = Request.Patch(adminRestSession.url() + "/a/accounts/");
+    RestResponse response = adminRestSession.execute(patch);
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent()).isEqualTo("Not implemented: PATCH /accounts/");
+  }
+
+  @Test
+  public void unsupportedHttpMethodOnChildCollection() throws Exception {
+    Request patch = Request.Patch(adminRestSession.url() + "/a/accounts/self/emails");
+    RestResponse response = adminRestSession.execute(patch);
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Not implemented: PATCH /accounts/self/emails");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/NotFoundIT.java b/javatests/com/google/gerrit/acceptance/rest/NotFoundIT.java
new file mode 100644
index 0000000..e01a461
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/NotFoundIT.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import org.junit.Test;
+
+public class NotFoundIT extends AbstractDaemonTest {
+  @Test
+  public void nonExistingRootCollection() throws Exception {
+    RestResponse response = adminRestSession.get("/non-existing/");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    assertThat(response.getEntityContent()).isEqualTo("Not Found");
+
+    response = adminRestSession.post("/non-existing/");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    assertThat(response.getEntityContent()).isEqualTo("Not Found");
+  }
+
+  @Test
+  public void nonExistingView() throws Exception {
+    RestResponse response = adminRestSession.get("/accounts/self/non-existing");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    assertThat(response.getEntityContent()).isEqualTo("Not found: non-existing");
+
+    response = adminRestSession.post("/accounts/self/non-existing");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    assertThat(response.getEntityContent()).isEqualTo("Not found: non-existing");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
new file mode 100644
index 0000000..56a9b69
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -0,0 +1,722 @@
+// 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 static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
+import static org.apache.http.HttpStatus.SC_OK;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.truth.Expect;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.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.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collection;
+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;
+
+/**
+ * This test tests the tracing of requests.
+ *
+ * <p>To verify that tracing is working we do:
+ *
+ * <ul>
+ *   <li>Register a plugin extension that we know is invoked when the request is done. Within the
+ *       implementation of this plugin extension we access the status of the thread local state in
+ *       the {@link LoggingContext} and store it locally in the plugin extension class.
+ *   <li>Do a request (e.g. REST) that triggers the plugin extension.
+ *   <li>When the plugin extension is invoked it records the current logging context.
+ *   <li>After the request is done the test verifies that logging context that was recorded by the
+ *       plugin extension has the expected state.
+ * </ul>
+ */
+public class TraceIT extends AbstractDaemonTest {
+  @Rule public final Expect expect = Expect.create();
+
+  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+  @Inject private DynamicSet<CommitValidationListener> commitValidationListeners;
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+  @Inject private DynamicSet<SubmitRule> submitRules;
+  @Inject private WorkQueue workQueue;
+
+  private TraceValidatingProjectCreationValidationListener projectCreationListener;
+  private RegistrationHandle projectCreationListenerRegistrationHandle;
+  private TraceValidatingCommitValidationListener commitValidationListener;
+  private RegistrationHandle commitValidationRegistrationHandle;
+  private TestPerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
+    projectCreationListenerRegistrationHandle =
+        projectCreationValidationListeners.add("gerrit", projectCreationListener);
+    commitValidationListener = new TraceValidatingCommitValidationListener();
+    commitValidationRegistrationHandle =
+        commitValidationListeners.add("gerrit", commitValidationListener);
+    testPerformanceLogger = new TestPerformanceLogger();
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+  }
+
+  @After
+  public void cleanup() {
+    projectCreationListenerRegistrationHandle.remove();
+    commitValidationRegistrationHandle.remove();
+    performanceLoggerRegistrationHandle.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();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new1");
+  }
+
+  @Test
+  public void restCallForChangeSetsProjectTag() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    TraceChangeIndexedListener changeIndexedListener = new TraceChangeIndexedListener();
+    RegistrationHandle registrationHandle =
+        changeIndexedListeners.add("gerrit", changeIndexedListener);
+    try {
+      RestResponse response =
+          adminRestSession.post(
+              "/changes/" + changeId + "/revisions/current/review", ReviewInput.approve());
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(changeIndexedListener.tags.get("project")).containsExactly(project.get());
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
+  @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();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new2");
+  }
+
+  @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();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new3");
+  }
+
+  @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();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new4");
+  }
+
+  @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();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new5");
+  }
+
+  @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();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new6");
+
+    // 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();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new7");
+
+    // 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();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new8");
+
+    // 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();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new9");
+  }
+
+  @Test
+  public void pushWithoutTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNull();
+    assertThat(commitValidationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+  }
+
+  @Test
+  public void pushWithTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace"));
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNotNull();
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+    assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+  }
+
+  @Test
+  public void pushWithTraceAndProvidedTraceId() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), 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();
+    assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+  }
+
+  @Test
+  public void pushForReviewWithoutTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNull();
+    assertThat(commitValidationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+  }
+
+  @Test
+  public void pushForReviewWithTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNotNull();
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+    assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+  }
+
+  @Test
+  public void pushForReviewWithTraceAndProvidedTraceId() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), 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();
+    assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+  }
+
+  @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);
+  }
+
+  @Test
+  public void performanceLoggingForRestCall() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new10");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+
+    // This assertion assumes that the server invokes the PerformanceLogger plugins before it sends
+    // the response to the client. If this assertion gets flaky it's likely that this got changed on
+    // server-side.
+    assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+  }
+
+  @Test
+  public void performanceLoggingForPush() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.performanceLogging", value = "false")
+  public void noPerformanceLoggingIfDisabled() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new11");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new12")
+  public void traceProject() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new12");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new12");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectMatchRegEx() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new13");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new13");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "foo.*")
+  public void traceProjectNoMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new13");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new13");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "][")
+  public void traceProjectInvalidRegEx() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new14");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new14");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  public void traceAccount() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new15");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new15");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000001")
+  public void traceAccountNoMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new16");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new16");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "999")
+  public void traceAccountNotFound() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new17");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new17");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "invalid")
+  public void traceAccountInvalidId() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new18");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new18");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "REST")
+  public void traceRequestType() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new19");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new19");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "SSH")
+  public void traceRequestTypeNoMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new20");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new20");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "FOO")
+  public void traceProjectInvalidRequestType() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new21");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new21");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectForAccount() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new22");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new22");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "foo.*")
+  public void traceProjectForAccountNoProjectMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new23");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000001")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectForAccountNoAccountMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new24");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new24");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*")
+  public void traceRequestUri() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new23");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*/foo")
+  public void traceRequestUriNoMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new23");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "][")
+  public void traceRequestUriInvalidRegEx() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new24");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new24");
+  }
+
+  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
+  public void autoRetryWithTrace() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+    traceSubmitRule.failOnce = true;
+    RegistrationHandle submitRuleRegistrationHandle = submitRules.add("gerrit", traceSubmitRule);
+    try {
+      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
+      assertThat(traceSubmitRule.isLoggingForced).isTrue();
+    } finally {
+      submitRuleRegistrationHandle.remove();
+    }
+  }
+
+  @Test
+  public void noAutoRetryWithTraceIfDisabled() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+    traceSubmitRule.failOnce = true;
+    RegistrationHandle submitRuleRegistrationHandle = submitRules.add("gerrit", traceSubmitRule);
+    try {
+      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(traceSubmitRule.traceId).isNull();
+      assertThat(traceSubmitRule.isLoggingForced).isNull();
+    } finally {
+      submitRuleRegistrationHandle.remove();
+    }
+  }
+
+  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;
+    ImmutableSetMultimap<String, String> tags;
+
+    @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);
+      this.tags = LoggingContext.getInstance().getTagsAsMap();
+    }
+  }
+
+  private static class TraceValidatingCommitValidationListener implements CommitValidationListener {
+    String traceId;
+    Boolean isLoggingForced;
+    ImmutableSetMultimap<String, String> tags;
+
+    @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);
+      this.tags = LoggingContext.getInstance().getTagsAsMap();
+      return ImmutableList.of();
+    }
+  }
+
+  private static class TraceChangeIndexedListener implements ChangeIndexedListener {
+    ImmutableSetMultimap<String, String> tags;
+
+    @Override
+    public void onChangeIndexed(String projectName, int id) {
+      this.tags = LoggingContext.getInstance().getTagsAsMap();
+    }
+
+    @Override
+    public void onChangeDeleted(int id) {}
+  }
+
+  private static class TraceSubmitRule implements SubmitRule {
+    String traceId;
+    Boolean isLoggingForced;
+    boolean failOnce;
+
+    @Override
+    public Collection<SubmitRecord> evaluate(ChangeData changeData, SubmitRuleOptions options) {
+      if (failOnce) {
+        failOnce = false;
+        throw new IllegalStateException("forced failure from test");
+      }
+
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+
+      SubmitRecord submitRecord = new SubmitRecord();
+      submitRecord.status = SubmitRecord.Status.OK;
+      return ImmutableList.of(submitRecord);
+    }
+  }
+
+  private static class TestPerformanceLogger implements PerformanceLogger {
+    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+
+    @Override
+    public void log(String operation, long durationMs, Metadata metadata) {
+      logEntries.add(PerformanceLogEntry.create(operation, metadata));
+    }
+
+    ImmutableList<PerformanceLogEntry> logEntries() {
+      return ImmutableList.copyOf(logEntries);
+    }
+  }
+
+  @AutoValue
+  abstract static class PerformanceLogEntry {
+    static PerformanceLogEntry create(String operation, Metadata metadata) {
+      return new AutoValue_TraceIT_PerformanceLogEntry(operation, metadata);
+    }
+
+    abstract String operation();
+
+    abstract Metadata metadata();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index 2baaef8..63e9ebf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -25,14 +25,14 @@
 public class AccountAssert {
 
   public static void assertAccountInfo(TestAccount a, AccountInfo ai) {
-    assertThat(a.id.get()).isEqualTo(ai._accountId);
-    assertThat(a.fullName).isEqualTo(ai.name);
-    assertThat(a.email).isEqualTo(ai.email);
+    assertThat(a.id().get()).isEqualTo(ai._accountId);
+    assertThat(a.fullName()).isEqualTo(ai.name);
+    assertThat(a.email()).isEqualTo(ai.email);
   }
 
   public static void assertAccountInfos(List<TestAccount> expected, List<AccountInfo> actual) {
     Iterable<Account.Id> expectedIds = TestAccount.ids(expected);
-    Iterable<Account.Id> actualIds = Iterables.transform(actual, a -> new Account.Id(a._accountId));
+    Iterable<Account.Id> actualIds = Iterables.transform(actual, a -> Account.id(a._accountId));
     assertThat(actualIds).containsExactlyElementsIn(expectedIds).inOrder();
     for (int i = 0; i < expected.size(); i++) {
       AccountAssert.assertAccountInfo(expected.get(i), actual.get(i));
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/BUILD b/javatests/com/google/gerrit/acceptance/rest/account/BUILD
index 217d716..17a6053 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/account/BUILD
@@ -4,12 +4,15 @@
     srcs = glob(["*IT.java"]),
     group = "rest_account",
     labels = ["rest"],
-    deps = [":util"],
+    deps = [
+        ":util",
+        "//java/com/google/gerrit/server/account/externalids/testing",
+    ],
 )
 
 java_library(
     name = "util",
-    testonly = 1,
+    testonly = True,
     srcs = [
         "AccountAssert.java",
         "CapabilityInfo.java",
@@ -18,7 +21,7 @@
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/reviewdb:server",
-        "//lib:gwtorm",
+        "//java/com/google/gerrit/server/account/externalids/testing",
         "//lib:junit",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
index e7ce43f..be4fde0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.acceptance.rest.account;
 
+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.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.common.data.GlobalCapability.ACCESS_DATABASE;
 import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
@@ -26,24 +29,30 @@
 import static com.google.gerrit.common.data.GlobalCapability.RUN_AS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
-import com.google.common.collect.Iterables;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 public class CapabilitiesIT extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void capabilitiesUser() throws Exception {
-    Iterable<String> all =
-        Iterables.filter(
-            GlobalCapability.getAllNames(),
-            c -> !ADMINISTRATE_SERVER.equals(c) && !PRIORITY.equals(c));
-
-    allowGlobalCapabilities(REGISTERED_USERS, all);
+    ImmutableList<String> all =
+        GlobalCapability.getAllNames().stream()
+            .filter(c -> !ADMINISTRATE_SERVER.equals(c) && !PRIORITY.equals(c))
+            .collect(toImmutableList());
+    TestProjectUpdate.Builder allowBuilder = projectOperations.allProjectsForUpdate();
+    all.forEach(c -> allowBuilder.add(allowCapability(c).group(REGISTERED_USERS)));
+    allowBuilder.update();
     try {
       RestResponse r = userRestSession.get("/accounts/self/capabilities");
       r.assertOK();
@@ -67,7 +76,9 @@
         }
       }
     } finally {
-      removeGlobalCapabilities(REGISTERED_USERS, all);
+      TestProjectUpdate.Builder removeBuilder = projectOperations.allProjectsForUpdate();
+      all.forEach(c -> removeBuilder.remove(capabilityKey(c).group(REGISTERED_USERS)));
+      removeBuilder.update();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
index 5404fdd..1ca019e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
@@ -28,6 +28,7 @@
   public boolean modifyAccount;
   public boolean priority;
   public QueryLimit queryLimit;
+  public boolean readAs;
   public boolean runAs;
   public boolean runGC;
   public boolean streamEvents;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
new file mode 100644
index 0000000..aca6c4c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import org.junit.Test;
+
+public class CreateAccountIT extends AbstractDaemonTest {
+  @Test
+  public void createAccountRestApi() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = "foo";
+    assertThat(accountCache.getByUsername(input.username)).isEmpty();
+    RestResponse r = adminRestSession.put("/accounts/" + input.username, input);
+    r.assertCreated();
+    assertThat(accountCache.getByUsername(input.username)).isPresent();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index f3fe68a..5adf46f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableMultimap;
@@ -23,6 +24,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.accounts.EmailApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.common.EmailInfo;
@@ -30,7 +32,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
@@ -43,9 +44,8 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
+import com.google.gerrit.server.config.EnableReverseDnsLookup;
 import com.google.gson.reflect.TypeToken;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.List;
@@ -54,15 +54,15 @@
 import org.junit.Test;
 
 public class EmailIT extends AbstractDaemonTest {
-  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
-  @Inject private ExternalIds externalIds;
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-  @Inject private AuthConfig authConfig;
   @Inject private @AnonymousCowardName String anonymousCowardName;
   @Inject private @CanonicalWebUrl Provider<String> canonicalUrl;
-  @Inject private @DisableReverseDnsLookup Boolean disableReverseDnsLookup;
+  @Inject private @EnableReverseDnsLookup boolean enableReverseDnsLookup;
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject private AuthConfig authConfig;
   @Inject private EmailExpander emailExpander;
+  @Inject private ExternalIds externalIds;
   @Inject private Provider<Emails> emails;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void addEmail() throws Exception {
@@ -123,7 +123,7 @@
     createEmail(email);
     assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
 
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
     gApi.accounts().self().email(email).setPreferred();
     assertThat(gApi.accounts().self().get().email).isEqualTo(email);
   }
@@ -135,14 +135,14 @@
         .get()
         .update(
             "Add External ID",
-            admin.id,
+            admin.id(),
             u ->
                 u.addExternalId(
                     ExternalId.createWithEmail(
-                        ExternalId.SCHEME_EXTERNAL, "foo", admin.id, email)));
+                        ExternalId.SCHEME_EXTERNAL, "foo", admin.id(), email)));
     assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
 
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
     gApi.accounts().self().email(email).setPreferred();
     assertThat(gApi.accounts().self().get().email).isEqualTo(email);
   }
@@ -150,16 +150,20 @@
   @Test
   public void setPreferredEmailToNonExistingEmail() throws Exception {
     String email = "non-existing@example.com";
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + email);
-    gApi.accounts().self().email(email).setPreferred();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.accounts().self().email(email).setPreferred());
+    assertThat(thrown).hasMessageThat().contains("Not found: " + email);
   }
 
   @Test
   public void setPreferredEmailToEmailOfOtherAccount() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + user.email);
-    gApi.accounts().self().email(user.email).setPreferred();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.accounts().self().email(user.email()).setPreferred());
+    assertThat(thrown).hasMessageThat().contains("Not found: " + user.email());
   }
 
   @Test
@@ -168,7 +172,7 @@
     createEmail(email);
     assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
 
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
     String emailOtherCase = email.toUpperCase();
     gApi.accounts().self().email(emailOtherCase).setPreferred();
     assertThat(gApi.accounts().self().get().email).isEqualTo(email);
@@ -182,12 +186,12 @@
     assertThat(externalIds.get(mailtoExtIdKey)).isEmpty();
     assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
 
-    Context oldCtx = createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id, email));
+    Context oldCtx = createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id(), email));
     try {
       gApi.accounts().self().email(email).setPreferred();
       Optional<ExternalId> mailtoExtId = externalIds.get(mailtoExtIdKey);
       assertThat(mailtoExtId).isPresent();
-      assertThat(mailtoExtId.get().accountId()).isEqualTo(admin.id);
+      assertThat(mailtoExtId.get().accountId()).isEqualTo(admin.id());
       assertThat(gApi.accounts().self().get().email).isEqualTo(email);
     } finally {
       atrScope.set(oldCtx);
@@ -196,15 +200,17 @@
 
   @Test
   public void setPreferredEmailToEmailFromCustomRealmThatBelongsToOtherAccount() throws Exception {
-    ExternalId mailToExtId = ExternalId.createEmail(user.id, user.email);
+    ExternalId mailToExtId = ExternalId.createEmail(user.id(), user.email());
     assertThat(externalIds.get(mailToExtId.key())).isPresent();
 
     Context oldCtx =
-        createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id, user.email));
+        createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id(), user.email()));
     try {
-      exception.expect(ResourceConflictException.class);
-      exception.expectMessage("email in use by another account");
-      gApi.accounts().self().email(user.email).setPreferred();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().self().email(user.email()).setPreferred());
+      assertThat(thrown).hasMessageThat().contains("email in use by another account");
     } finally {
       atrScope.set(oldCtx);
     }
@@ -224,7 +230,7 @@
     assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
 
     // Get email
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
     EmailApi emailApi = gApi.accounts().self().email(email);
     EmailInfo emailInfo = emailApi.get();
     assertThat(emailInfo.email).isEqualTo(email);
@@ -236,7 +242,7 @@
     assertThat(gApi.accounts().self().get().email).isEqualTo(email);
 
     // Get email again (now it's the preferred email)
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
     emailApi = gApi.accounts().self().email(email);
     emailInfo = emailApi.get();
     assertThat(emailInfo.email).isEqualTo(email);
@@ -248,10 +254,8 @@
     assertThat(getEmails()).doesNotContain(email);
 
     // Now the email is no longer found
-    resetCurrentApiUser();
-    emailApi = gApi.accounts().self().email(email);
-    exception.expect(ResourceNotFoundException.class);
-    emailApi.get();
+    requestScopeOperations.resetCurrentApiUser();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().self().email(email).get());
   }
 
   private Set<String> getEmails() throws Exception {
@@ -276,10 +280,10 @@
             realm,
             anonymousCowardName,
             canonicalUrl,
-            disableReverseDnsLookup,
+            enableReverseDnsLookup,
             accountCache,
             groupBackend);
-    return atrScope.set(atrScope.newContext(reviewDbProvider, null, userFactory.create(admin.id)));
+    return atrScope.set(atrScope.newContext(null, userFactory.create(admin.id())));
   }
 
   private class RealmWithAdditionalEmails extends DefaultRealm {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index f031729..e9e8b7f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -16,16 +16,21 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil.insertExternalIdWithEmptyNote;
+import static com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil.insertExternalIdWithInvalidConfig;
+import static com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil.insertExternalIdWithKeyThatDoesntMatchNoteId;
+import static com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil.insertExternalIdWithoutAccountId;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -33,8 +38,11 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
@@ -52,15 +60,14 @@
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.testing.ConfigSuite;
 import com.google.gson.reflect.TypeToken;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
 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;
@@ -70,13 +77,8 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -91,10 +93,26 @@
   @Inject private ExternalIds externalIds;
   @Inject private ExternalIdReader externalIdReader;
   @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @ConfigSuite.Default
+  public static Config partialCacheReloadingEnabled() {
+    Config cfg = new Config();
+    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", true);
+    return cfg;
+  }
+
+  @ConfigSuite.Config
+  public static Config partialCacheReloadingDisabled() {
+    Config cfg = new Config();
+    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", false);
+    return cfg;
+  }
 
   @Test
   public void getExternalIds() throws Exception {
-    Collection<ExternalId> expectedIds = getAccountState(user.getId()).getExternalIds();
+    Collection<ExternalId> expectedIds = getAccountState(user.id()).getExternalIds();
     List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
 
     RestResponse response = userRestSession.get("/accounts/self/external.ids");
@@ -105,27 +123,29 @@
             .fromJson(
                 response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
 
-    Collections.sort(expectedIdInfos);
-    Collections.sort(results);
     assertThat(results).containsExactlyElementsIn(expectedIdInfos);
   }
 
   @Test
   public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.accounts().id(admin.id.get()).getExternalIds();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.accounts().id(admin.id().get()).getExternalIds());
+    assertThat(thrown).hasMessageThat().contains("access database not permitted");
   }
 
   @Test
   public void getExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
-    Collection<ExternalId> expectedIds = getAccountState(admin.getId()).getExternalIds();
+    Collection<ExternalId> expectedIds = getAccountState(admin.id()).getExternalIds();
     List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
 
-    RestResponse response = userRestSession.get("/accounts/" + admin.id + "/external.ids");
+    RestResponse response = userRestSession.get("/accounts/" + admin.id() + "/external.ids");
     response.assertOK();
 
     List<AccountExternalIdInfo> results =
@@ -133,14 +153,12 @@
             .fromJson(
                 response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
 
-    Collections.sort(expectedIdInfos);
-    Collections.sort(results);
     assertThat(results).containsExactlyElementsIn(expectedIdInfos);
   }
 
   @Test
   public void deleteExternalIds() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
 
     List<String> toDelete = new ArrayList<>();
@@ -167,28 +185,39 @@
   @Test
   public void deleteExternalIdsOfOtherUserNotAllowed() throws Exception {
     List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.accounts()
-        .id(admin.id.get())
-        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.accounts()
+                    .id(admin.id().get())
+                    .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList())));
+    assertThat(thrown).hasMessageThat().contains("access database not permitted");
   }
 
   @Test
   public void deleteExternalIdOfOtherUserUnderOwnAccount_UnprocessableEntity() throws Exception {
     List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
-    setApiUser(user);
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage(String.format("External id %s does not exist", extIds.get(0).identity));
-    gApi.accounts()
-        .self()
-        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+    requestScopeOperations.setApiUser(user.id());
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList())));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("External id %s does not exist", extIds.get(0).identity));
   }
 
   @Test
   public void deleteExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
 
@@ -204,11 +233,11 @@
 
     assertThat(toDelete).hasSize(1);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     RestResponse response =
-        userRestSession.post("/accounts/" + admin.id + "/external.ids:delete", toDelete);
+        userRestSession.post("/accounts/" + admin.id() + "/external.ids:delete", toDelete);
     response.assertNoContent();
-    List<AccountExternalIdInfo> results = gApi.accounts().id(admin.id.get()).getExternalIds();
+    List<AccountExternalIdInfo> results = gApi.accounts().id(admin.id().get()).getExternalIds();
     // The external ID in WebSession will not be set for tests, resulting that
     // "mailto:user@example.com" can be deleted while "username:user" can't.
     assertThat(results).hasSize(1);
@@ -230,7 +259,7 @@
   @Test
   public void deleteExternalIds_Conflict() throws Exception {
     List<String> toDelete = new ArrayList<>();
-    String externalIdStr = "username:" + user.username;
+    String externalIdStr = "username:" + user.username();
     toDelete.add(externalIdStr);
     RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
     response.assertConflict();
@@ -251,29 +280,33 @@
 
   @Test
   public void fetchExternalIdsBranch() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
+    final TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
 
     // refs/meta/external-ids is only visible to users with the 'Access Database' capability
-    try {
-      fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-      fail("expected TransportException");
-    } catch (TransportException e) {
-      assertThat(e.getMessage())
-          .isEqualTo(
-              "Remote does not have " + RefNames.REFS_EXTERNAL_IDS + " available for fetch.");
-    }
+    TransportException thrown =
+        assertThrows(
+            TransportException.class, () -> fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Remote does not have " + RefNames.REFS_EXTERNAL_IDS + " available for fetch.");
 
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     // re-clone to get new request context, otherwise the old global capabilities are still cached
     // in the IdentifiedUser object
-    allUsersRepo = cloneProject(allUsers, user);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    TestRepository<InMemoryRepository> allUsersRepo2 = cloneProject(allUsers, user);
+    fetch(allUsersRepo2, RefNames.REFS_EXTERNAL_IDS);
   }
 
   @Test
   public void pushToExternalIdsBranch() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -298,14 +331,21 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithoutAccountId() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     insertExternalIdWithoutAccountId(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+        allUsersRepo.getRepository(),
+        allUsersRepo.getRevWalk(),
+        admin.newIdent(),
+        admin.id(),
+        "foo:bar");
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     allowPushOfExternalIds();
@@ -316,14 +356,21 @@
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithKeyThatDoesntMatchTheNoteId()
       throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     insertExternalIdWithKeyThatDoesntMatchNoteId(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+        allUsersRepo.getRepository(),
+        allUsersRepo.getRevWalk(),
+        admin.newIdent(),
+        admin.id(),
+        "foo:bar");
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     allowPushOfExternalIds();
@@ -333,14 +380,17 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithInvalidConfig() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     insertExternalIdWithInvalidConfig(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), admin.newIdent(), "foo:bar");
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     allowPushOfExternalIds();
@@ -350,14 +400,17 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithEmptyNote() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     insertExternalIdWithEmptyNote(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), admin.newIdent(), "foo:bar");
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
 
     allowPushOfExternalIds();
@@ -390,7 +443,10 @@
 
   private void testPushToExternalIdsBranchRejectsInvalidExternalId(ExternalId invalidExtId)
       throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -406,8 +462,11 @@
 
   @Test
   public void readExternalIdsWhenInvalidExternalIdsExist() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    resetCurrentApiUser();
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.resetCurrentApiUser();
 
     insertValidExternalIds();
     insertInvalidButParsableExternalIds();
@@ -427,8 +486,11 @@
 
   @Test
   public void checkConsistency() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    resetCurrentApiUser();
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.resetCurrentApiUser();
 
     insertValidExternalIds();
 
@@ -449,9 +511,11 @@
 
   @Test
   public void checkConsistencyNotAllowed() throws Exception {
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.config().server().checkConsistency(new ConsistencyCheckInput());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.config().server().checkConsistency(new ConsistencyCheckInput()));
+    assertThat(thrown).hasMessageThat().contains("access database not permitted");
   }
 
   private ConsistencyProblemInfo consistencyError(String message) {
@@ -466,7 +530,7 @@
     insertExtId(
         ExternalId.createWithPassword(
             ExternalId.Key.parse(nextId(scheme, i)),
-            admin.id,
+            admin.id(),
             "admin.other@example.com",
             "secret-password"));
     insertExtId(createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
@@ -527,7 +591,8 @@
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
       String externalId = nextId(scheme, i);
-      String noteId = insertExternalIdWithoutAccountId(repo, rw, externalId);
+      String noteId =
+          insertExternalIdWithoutAccountId(repo, rw, admin.newIdent(), admin.id(), externalId);
       expectedProblems.add(
           consistencyError(
               "Invalid external ID config for note '"
@@ -537,7 +602,9 @@
                   + ".accountId' is missing, expected account ID"));
 
       externalId = nextId(scheme, i);
-      noteId = insertExternalIdWithKeyThatDoesntMatchNoteId(repo, rw, externalId);
+      noteId =
+          insertExternalIdWithKeyThatDoesntMatchNoteId(
+              repo, rw, admin.newIdent(), admin.id(), externalId);
       expectedProblems.add(
           consistencyError(
               "Invalid external ID config for note '"
@@ -548,12 +615,12 @@
                   + noteId
                   + "'"));
 
-      noteId = insertExternalIdWithInvalidConfig(repo, rw, nextId(scheme, i));
+      noteId = insertExternalIdWithInvalidConfig(repo, rw, admin.newIdent(), nextId(scheme, i));
       expectedProblems.add(
           consistencyError(
               "Invalid external ID config for note '" + noteId + "': Invalid line in config file"));
 
-      noteId = insertExternalIdWithEmptyNote(repo, rw, nextId(scheme, i));
+      noteId = insertExternalIdWithEmptyNote(repo, rw, admin.newIdent(), nextId(scheme, i));
       expectedProblems.add(
           consistencyError(
               "Invalid external ID config for note '"
@@ -566,142 +633,29 @@
 
   private ExternalId createExternalIdWithOtherCaseEmail(String externalId) {
     return ExternalId.createWithPassword(
-        ExternalId.Key.parse(externalId), admin.id, admin.email.toUpperCase(Locale.US), "password");
-  }
-
-  private String insertExternalIdWithoutAccountId(Repository repo, RevWalk rw, String externalId)
-      throws IOException {
-    return insertExternalId(
-        repo,
-        rw,
-        (ins, noteMap) -> {
-          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
-          ObjectId noteId = extId.key().sha1();
-          Config c = new Config();
-          extId.writeToConfig(c);
-          c.unset("externalId", extId.key().get(), "accountId");
-          byte[] raw = c.toText().getBytes(UTF_8);
-          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-          noteMap.set(noteId, dataBlob);
-          return noteId;
-        });
-  }
-
-  private String insertExternalIdWithKeyThatDoesntMatchNoteId(
-      Repository repo, RevWalk rw, String externalId) throws IOException {
-    return insertExternalId(
-        repo,
-        rw,
-        (ins, noteMap) -> {
-          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
-          ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
-          Config c = new Config();
-          extId.writeToConfig(c);
-          byte[] raw = c.toText().getBytes(UTF_8);
-          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-          noteMap.set(noteId, dataBlob);
-          return noteId;
-        });
-  }
-
-  private String insertExternalIdWithInvalidConfig(Repository repo, RevWalk rw, String externalId)
-      throws IOException {
-    return insertExternalId(
-        repo,
-        rw,
-        (ins, noteMap) -> {
-          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
-          byte[] raw = "bad-config".getBytes(UTF_8);
-          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-          noteMap.set(noteId, dataBlob);
-          return noteId;
-        });
-  }
-
-  private String insertExternalIdWithEmptyNote(Repository repo, RevWalk rw, String externalId)
-      throws IOException {
-    return insertExternalId(
-        repo,
-        rw,
-        (ins, noteMap) -> {
-          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
-          byte[] raw = "".getBytes(UTF_8);
-          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-          noteMap.set(noteId, dataBlob);
-          return noteId;
-        });
-  }
-
-  private String insertExternalId(Repository repo, RevWalk rw, ExternalIdInserter extIdInserter)
-      throws IOException {
-    ObjectId rev = ExternalIdReader.readRevision(repo);
-    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = extIdInserter.addNote(ins, noteMap);
-
-      CommitBuilder cb = new CommitBuilder();
-      cb.setMessage("Update external IDs");
-      cb.setTreeId(noteMap.writeTree(ins));
-      cb.setAuthor(admin.getIdent());
-      cb.setCommitter(admin.getIdent());
-      if (!rev.equals(ObjectId.zeroId())) {
-        cb.setParentId(rev);
-      } else {
-        cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
-      }
-      if (cb.getTreeId() == null) {
-        if (rev.equals(ObjectId.zeroId())) {
-          cb.setTreeId(ins.insert(OBJ_TREE, new byte[] {})); // No parent, assume empty tree.
-        } else {
-          RevCommit p = rw.parseCommit(rev);
-          cb.setTreeId(p.getTree()); // Copy tree from parent.
-        }
-      }
-      ObjectId commitId = ins.insert(cb);
-      ins.flush();
-
-      RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
-      u.setExpectedOldObjectId(rev);
-      u.setNewObjectId(commitId);
-      RefUpdate.Result res = u.update();
-      switch (res) {
-        case NEW:
-        case FAST_FORWARD:
-        case NO_CHANGE:
-        case RENAMED:
-        case FORCED:
-          break;
-        case LOCK_FAILURE:
-        case IO_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new IOException("Updating external IDs failed with " + res);
-      }
-      return noteId.getName();
-    }
+        ExternalId.Key.parse(externalId),
+        admin.id(),
+        admin.email().toUpperCase(Locale.US),
+        "password");
   }
 
   private ExternalId createExternalIdForNonExistingAccount(String externalId) {
-    return ExternalId.create(ExternalId.Key.parse(externalId), new Account.Id(1));
+    return ExternalId.create(ExternalId.Key.parse(externalId), Account.id(1));
   }
 
   private ExternalId createExternalIdWithInvalidEmail(String externalId) {
-    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, "invalid-email");
+    return ExternalId.createWithEmail(
+        ExternalId.Key.parse(externalId), admin.id(), "invalid-email");
   }
 
   private ExternalId createExternalIdWithDuplicateEmail(String externalId) {
-    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, admin.email);
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id(), admin.email());
   }
 
   private ExternalId createExternalIdWithBadPassword(String username) {
     return ExternalId.create(
         ExternalId.Key.create(SCHEME_USERNAME, username),
-        admin.id,
+        admin.id(),
         null,
         "non-hashed-password-is-not-allowed");
   }
@@ -713,7 +667,7 @@
   @Test
   public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
     ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
-    Account.Id accountId = new Account.Id(1024 * 100);
+    Account.Id accountId = Account.id(1024 * 100);
     accountsUpdateProvider
         .get()
         .insert(
@@ -726,69 +680,72 @@
 
   @Test
   public void checkNoReloadAfterUpdate() throws Exception {
-    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id));
+    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id()));
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // insert external ID
-      ExternalId extId = ExternalId.create("foo", "bar", admin.id);
+      ExternalId extId = ExternalId.create("foo", "bar", admin.id());
       insertExtId(extId);
       expectedExtIds.add(extId);
-      assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
 
       // update external ID
       expectedExtIds.remove(extId);
-      ExternalId extId2 = ExternalId.createWithEmail("foo", "bar", admin.id, "foo.bar@example.com");
+      ExternalId extId2 =
+          ExternalId.createWithEmail("foo", "bar", admin.id(), "foo.bar@example.com");
       accountsUpdateProvider
           .get()
-          .update("Update External ID", admin.id, u -> u.updateExternalId(extId2));
+          .update("Update External ID", admin.id(), u -> u.updateExternalId(extId2));
       expectedExtIds.add(extId2);
-      assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
 
       // delete external ID
       accountsUpdateProvider
           .get()
-          .update("Delete External ID", admin.id, u -> u.deleteExternalId(extId));
+          .update("Delete External ID", admin.id(), u -> u.deleteExternalId(extId));
       expectedExtIds.remove(extId2);
-      assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
     }
   }
 
   @Test
   public void byAccountFailIfReadingExternalIdsFails() throws Exception {
+    assume().that(isPartialCacheReloadingEnabled()).isFalse();
+
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
-      insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+      insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id()));
 
-      exception.expect(IOException.class);
-      externalIds.byAccount(admin.id);
+      assertThrows(IOException.class, () -> externalIds.byAccount(admin.id()));
     }
   }
 
   @Test
   public void byEmailFailIfReadingExternalIdsFails() throws Exception {
+    assume().that(isPartialCacheReloadingEnabled()).isFalse();
+
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
-      insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+      insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id()));
 
-      exception.expect(IOException.class);
-      externalIds.byEmail(admin.email);
+      assertThrows(IOException.class, () -> externalIds.byEmail(admin.email()));
     }
   }
 
   @Test
   public void byAccountUpdateExternalIdsBehindGerritsBack() throws Exception {
-    Set<ExternalId> expectedExternalIds = new HashSet<>(externalIds.byAccount(admin.id));
-    ExternalId newExtId = ExternalId.create("foo", "bar", admin.id);
+    Set<ExternalId> expectedExternalIds = new HashSet<>(externalIds.byAccount(admin.id()));
+    ExternalId newExtId = ExternalId.create("foo", "bar", admin.id());
     insertExtIdBehindGerritsBack(newExtId);
     expectedExternalIds.add(newExtId);
-    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExternalIds);
+    assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExternalIds);
   }
 
   @Test
   public void unsetEmail() throws Exception {
-    ExternalId extId = ExternalId.createWithEmail("x", "1", user.id, "x@example.com");
+    ExternalId extId = ExternalId.createWithEmail("x", "1", user.id(), "x@example.com");
     insertExtId(extId);
 
-    ExternalId extIdWithoutEmail = ExternalId.create("x", "1", user.id);
+    ExternalId extIdWithoutEmail = ExternalId.create("x", "1", user.id());
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
@@ -802,10 +759,10 @@
   @Test
   public void unsetHttpPassword() throws Exception {
     ExternalId extId =
-        ExternalId.createWithPassword(ExternalId.Key.create("y", "1"), user.id, null, "secret");
+        ExternalId.createWithPassword(ExternalId.Key.create("y", "1"), user.id(), null, "secret");
     insertExtId(extId);
 
-    ExternalId extIdWithoutPassword = ExternalId.create("y", "1", user.id);
+    ExternalId extIdWithoutPassword = ExternalId.create("y", "1", user.id());
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
@@ -821,22 +778,22 @@
     // Insert external ID for different accounts
     TestAccount user1 = accountCreator.create("user1");
     TestAccount user2 = accountCreator.create("user2");
-    ExternalId extId1 = ExternalId.create("foo", "1", user1.id);
-    ExternalId extId2 = ExternalId.create("foo", "2", user1.id);
-    ExternalId extId3 = ExternalId.create("foo", "3", user2.id);
+    ExternalId extId1 = ExternalId.create("foo", "1", user1.id());
+    ExternalId extId2 = ExternalId.create("foo", "2", user1.id());
+    ExternalId extId3 = ExternalId.create("foo", "3", user2.id());
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
       extIdNotes.insert(ImmutableSet.of(extId1, extId2, extId3));
       RevCommit c = extIdNotes.commit(md);
       assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.getId(), "Account: " + user2.getId())
+          .containsExactly("Account: " + user1.id(), "Account: " + user2.id())
           .inOrder();
     }
 
     // Insert external ID with different emails
-    ExternalId extId4 = ExternalId.createWithEmail("foo", "4", user1.id, "foo4@example.com");
-    ExternalId extId5 = ExternalId.createWithEmail("foo", "5", user2.id, "foo5@example.com");
+    ExternalId extId4 = ExternalId.createWithEmail("foo", "4", user1.id(), "foo4@example.com");
+    ExternalId extId5 = ExternalId.createWithEmail("foo", "5", user2.id(), "foo5@example.com");
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
@@ -844,22 +801,22 @@
       RevCommit c = extIdNotes.commit(md);
       assertThat(getFooters(c))
           .containsExactly(
-              "Account: " + user1.getId(),
-              "Account: " + user2.getId(),
+              "Account: " + user1.id(),
+              "Account: " + user2.id(),
               "Email: foo4@example.com",
               "Email: foo5@example.com")
           .inOrder();
     }
 
     // Update external ID - Add Email
-    ExternalId extId1a = ExternalId.createWithEmail("foo", "1", user1.id, "foo1@example.com");
+    ExternalId extId1a = ExternalId.createWithEmail("foo", "1", user1.id(), "foo1@example.com");
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
       extIdNotes.upsert(extId1a);
       RevCommit c = extIdNotes.commit(md);
       assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.getId(), "Email: foo1@example.com")
+          .containsExactly("Account: " + user1.id(), "Email: foo1@example.com")
           .inOrder();
     }
 
@@ -870,7 +827,7 @@
       extIdNotes.upsert(extId1);
       RevCommit c = extIdNotes.commit(md);
       assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.getId(), "Email: foo1@example.com")
+          .containsExactly("Account: " + user1.id(), "Email: foo1@example.com")
           .inOrder();
     }
 
@@ -882,7 +839,7 @@
       RevCommit c = extIdNotes.commit(md);
       assertThat(getFooters(c))
           .containsExactly(
-              "Account: " + user1.getId(), "Account: " + user2.getId(), "Email: foo5@example.com")
+              "Account: " + user1.id(), "Account: " + user2.id(), "Email: foo5@example.com")
           .inOrder();
     }
 
@@ -892,7 +849,7 @@
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
       extIdNotes.delete(extId2.accountId(), extId2.key());
       RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c)).containsExactly("Account: " + user1.getId()).inOrder();
+      assertThat(getFooters(c)).containsExactly("Account: " + user1.id()).inOrder();
     }
 
     // Delete external ID by key with email
@@ -902,11 +859,15 @@
       extIdNotes.delete(extId4.accountId(), extId4.key());
       RevCommit c = extIdNotes.commit(md);
       assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.getId(), "Email: foo4@example.com")
+          .containsExactly("Account: " + user1.id(), "Email: foo4@example.com")
           .inOrder();
     }
   }
 
+  private boolean isPartialCacheReloadingEnabled() {
+    return cfg.getBoolean("cache", "external_ids_map", "enablePartialReloads", true);
+  }
+
   private void insertExtId(ExternalId extId) throws Exception {
     accountsUpdateProvider
         .get()
@@ -927,25 +888,25 @@
   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)) {
-        metaDataUpdate.getCommitBuilder().setAuthor(admin.getIdent());
-        metaDataUpdate.getCommitBuilder().setCommitter(admin.getIdent());
+        metaDataUpdate.getCommitBuilder().setAuthor(admin.newIdent());
+        metaDataUpdate.getCommitBuilder().setCommitter(admin.newIdent());
         extIdNotes.commit(metaDataUpdate);
       }
     }
   }
 
   private void addExtId(TestRepository<?> testRepo, ExternalId... extIds)
-      throws IOException, OrmDuplicateKeyException, ConfigInvalidException {
+      throws IOException, DuplicateKeyException, ConfigInvalidException {
     ExternalIdNotes extIdNotes = externalIdNotesFactory.load(testRepo.getRepository());
     extIdNotes.insert(Arrays.asList(extIds));
     try (MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, testRepo.getRepository())) {
-      metaDataUpdate.getCommitBuilder().setAuthor(admin.getIdent());
-      metaDataUpdate.getCommitBuilder().setCommitter(admin.getIdent());
+      metaDataUpdate.getCommitBuilder().setAuthor(admin.newIdent());
+      metaDataUpdate.getCommitBuilder().setCommitter(admin.newIdent());
       extIdNotes.commit(metaDataUpdate);
       extIdNotes.updateCaches();
     }
@@ -973,28 +934,22 @@
     return info;
   }
 
-  private void allowPushOfExternalIds() throws IOException, ConfigInvalidException {
-    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.READ);
-    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.PUSH);
+  private void allowPushOfExternalIds() {
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_EXTERNAL_IDS).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_EXTERNAL_IDS).group(adminGroupUuid()))
+        .update();
   }
 
   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() {
     externalIdReader.setFailOnLoad(true);
-    return new AutoCloseable() {
-      @Override
-      public void close() {
-        externalIdReader.setFailOnLoad(false);
-      }
-    };
-  }
-
-  @FunctionalInterface
-  private interface ExternalIdInserter {
-    public ObjectId addNote(ObjectInserter ins, NoteMap noteMap) throws IOException;
+    return () -> externalIdReader.setFailOnLoad(false);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index 0419933..0751a8b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -19,17 +19,17 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.restapi.account.GetDetail.AccountDetailInfo;
 import org.junit.Test;
 
 public class GetAccountDetailIT extends AbstractDaemonTest {
   @Test
   public void getDetail() throws Exception {
-    RestResponse r = adminRestSession.get("/accounts/" + admin.username + "/detail/");
+    RestResponse r = adminRestSession.get("/accounts/" + admin.username() + "/detail/");
     AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
     assertAccountInfo(admin, info);
-    Account account = getAccount(admin.getId());
-    assertThat(info.registeredOn).isEqualTo(account.getRegisteredOn());
+    Account account = getAccount(admin.id());
+    assertThat(info.registeredOn).isEqualTo(account.registeredOn());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index ed7abd2..931dace 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfo;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -24,27 +25,27 @@
 
 @NoHttpd
 public class GetAccountIT extends AbstractDaemonTest {
-  @Test(expected = ResourceNotFoundException.class)
+  @Test
   public void getNonExistingAccount_NotFound() throws Exception {
-    gApi.accounts().id("non-existing").get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("non-existing").get());
   }
 
   @Test
   public void getAccount() throws Exception {
     // by formatted string
-    testGetAccount(admin.fullName + " <" + admin.email + ">", admin);
+    testGetAccount(admin.fullName() + " <" + admin.email() + ">", admin);
 
     // by email
-    testGetAccount(admin.email, admin);
+    testGetAccount(admin.email(), admin);
 
     // by full name
-    testGetAccount(admin.fullName, admin);
+    testGetAccount(admin.fullName(), admin);
 
     // by account ID
-    testGetAccount(Integer.toString(admin.id.get()), admin);
+    testGetAccount(Integer.toString(admin.id().get()), admin);
 
     // by user name
-    testGetAccount(admin.username, admin);
+    testGetAccount(admin.username(), admin);
 
     // by 'self'
     testGetAccount("self", admin);
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index e6f61fa..1c342ee 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -15,10 +15,15 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -29,6 +34,8 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
@@ -50,7 +57,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
@@ -60,7 +66,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.AccountControl;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import org.apache.http.Header;
@@ -71,12 +77,11 @@
 
 public class ImpersonationIT extends AbstractDaemonTest {
   @Inject private AccountControl.Factory accountControlFactory;
-
   @Inject private ApprovalsUtil approvalsUtil;
-
   @Inject private ChangeMessagesUtil cmUtil;
-
   @Inject private CommentsUtil commentsUtil;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private RestSession anonRestSession;
   private TestAccount admin2;
@@ -88,7 +93,7 @@
     admin2 = accountCreator.admin2();
     GroupInput gi = new GroupInput();
     gi.name = name("New-Group");
-    gi.members = ImmutableList.of(user.id.toString());
+    gi.members = ImmutableList.of(user.id().toString());
     newGroup = gApi.groups().create(gi).get();
   }
 
@@ -104,22 +109,22 @@
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
     ReviewInput in = ReviewInput.recommend();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.message = "Message on behalf of";
     revision.review(in);
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getAccountId()).isEqualTo(user.id);
-    assertThat(psa.getValue()).isEqualTo(1);
-    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(admin.id());
 
     ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
     assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(user.id);
-    assertThat(m.getRealAuthor()).isEqualTo(admin.id);
+    assertThat(m.getAuthor()).isEqualTo(user.id());
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id());
   }
 
   @Test
@@ -129,12 +134,13 @@
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
     ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.message = "Message on behalf of";
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("label required to post review on behalf of \"" + in.onBehalfOf + '"');
-    revision.review(in);
+    AuthException thrown = assertThrows(AuthException.class, () -> revision.review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("label required to post review on behalf of \"" + in.onBehalfOf + '"');
   }
 
   @Test
@@ -144,11 +150,12 @@
 
     String changeId = createChange().getChangeId();
     ReviewInput in = new ReviewInput().label("Not-A-Label", 5);
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Not-A-Label\" is not a configured label");
-    gApi.changes().id(changeId).current().review(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("label \"Not-A-Label\" is not a configured label");
   }
 
   @Test
@@ -157,7 +164,7 @@
 
     String changeId = createChange().getChangeId();
     ReviewInput in = new ReviewInput().label("Code-Review", 1).label("Not-A-Label", 5);
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     gApi.changes().id(changeId).current().review(in);
 
     assertThat(gApi.changes().id(changeId).get().labels).doesNotContainKey("Not-A-Label");
@@ -166,7 +173,7 @@
   @Test
   public void voteOnBehalfOfLabelNotPermitted() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType verified = Util.verified();
+      LabelType verified = TestLabels.verified();
       u.getConfig().getLabelSections().put(verified.getName(), verified);
       u.save();
     }
@@ -175,13 +182,14 @@
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
     ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.label("Verified", 1);
 
-    exception.expect(AuthException.class);
-    exception.expectMessage(
-        "not permitted to modify label \"Verified\" on behalf of \"" + in.onBehalfOf + '"');
-    revision.review(in);
+    AuthException thrown = assertThrows(AuthException.class, () -> revision.review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "not permitted to modify label \"Verified\" on behalf of \"" + in.onBehalfOf + '"');
   }
 
   @Test
@@ -189,10 +197,8 @@
     testVoteOnBehalfOfWithComment();
   }
 
-  @GerritConfig(name = "notedb.writeJson", value = "true")
   @Test
   public void voteOnBehalfOfWithCommentWritingJson() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     testVoteOnBehalfOfWithComment();
   }
 
@@ -201,7 +207,7 @@
     PushOneCommit.Result r = createChange();
 
     ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
     CommentInput ci = new CommentInput();
     ci.path = Patch.COMMIT_MSG;
@@ -212,28 +218,26 @@
     gApi.changes().id(r.getChangeId()).current().review(in);
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getAccountId()).isEqualTo(user.id);
-    assertThat(psa.getValue()).isEqualTo(1);
-    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(admin.id());
 
     ChangeData cd = r.getChange();
-    Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(db, cd.notes()));
+    Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(cd.notes()));
     assertThat(c.message).isEqualTo(ci.message);
-    assertThat(c.author.getId()).isEqualTo(user.id);
-    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+    assertThat(c.author.getId()).isEqualTo(user.id());
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id());
   }
 
-  @GerritConfig(name = "notedb.writeJson", value = "true")
   @Test
   public void voteOnBehalfOfWithRobotComment() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     allowCodeReviewOnBehalfOf();
     PushOneCommit.Result r = createChange();
 
     ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
     RobotCommentInput ci = new RobotCommentInput();
     ci.robotId = "my-robot";
@@ -250,8 +254,8 @@
     assertThat(c.message).isEqualTo(ci.message);
     assertThat(c.robotId).isEqualTo(ci.robotId);
     assertThat(c.robotRunId).isEqualTo(ci.robotRunId);
-    assertThat(c.author.getId()).isEqualTo(user.id);
-    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+    assertThat(c.author.getId()).isEqualTo(user.id());
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id());
   }
 
   @Test
@@ -259,7 +263,7 @@
     allowCodeReviewOnBehalfOf();
     PushOneCommit.Result r = createChange();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     DraftInput di = new DraftInput();
     di.path = Patch.COMMIT_MSG;
     di.side = Side.REVISION;
@@ -267,15 +271,16 @@
     di.message = "message";
     gApi.changes().id(r.getChangeId()).current().createDraft(di);
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
     in.drafts = DraftHandling.PUBLISH;
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to modify other user's drafts");
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("not allowed to modify other user's drafts");
   }
 
   @Test
@@ -288,10 +293,10 @@
     in.onBehalfOf = "doesnotexist";
     in.label("Code-Review", 1);
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage("doesnotexist");
-    revision.review(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains("doesnotexist");
   }
 
   @Test
@@ -303,32 +308,34 @@
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
     ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
-    revision.review(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("on_behalf_of account " + user.id() + " cannot see change");
   }
 
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
   @Test
   public void voteOnBehalfOfInvisibleUserNotAllowed() throws Exception {
     allowCodeReviewOnBehalfOf();
-    setApiUser(accountCreator.user2());
-    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+    requestScopeOperations.setApiUser(accountCreator.user2().id());
+    assertThat(accountControlFactory.get().canSee(user.id())).isFalse();
 
     PushOneCommit.Result r = createChange();
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
     ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage(in.onBehalfOf);
-    revision.review(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains(in.onBehalfOf);
   }
 
   @Test
@@ -338,15 +345,15 @@
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
+    in.onBehalfOf = admin2.email();
     gApi.changes().id(changeId).current().submit(in);
 
     ChangeData cd = r.getChange();
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(cd.change().isMerged()).isTrue();
     PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(db, cd.notes(), cd.change().currentPatchSetId());
-    assertThat(submitter.getAccountId()).isEqualTo(admin2.id);
-    assertThat(submitter.getRealAccountId()).isEqualTo(admin.id);
+        approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
+    assertThat(submitter.accountId()).isEqualTo(admin2.id());
+    assertThat(submitter.realAccountId()).isEqualTo(admin.id());
   }
 
   @Test
@@ -357,10 +364,12 @@
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = "doesnotexist";
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage("doesnotexist");
-    gApi.changes().id(changeId).current().submit(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(changeId).current().submit(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains("doesnotexist");
   }
 
   @Test
@@ -371,10 +380,16 @@
         .current()
         .review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit on behalf of other users not permitted");
-    gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
+    in.onBehalfOf = admin2.email();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(project.get() + "~master~" + r.getChangeId())
+                    .current()
+                    .submit(in));
+    assertThat(thrown).hasMessageThat().contains("submit on behalf of other users not permitted");
   }
 
   @Test
@@ -386,44 +401,50 @@
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
-    in.onBehalfOf = user.email;
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
-    gApi.changes().id(changeId).current().submit(in);
+    in.onBehalfOf = user.email();
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(changeId).current().submit(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("on_behalf_of account " + user.id() + " cannot see change");
   }
 
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
   @Test
   public void submitOnBehalfOfInvisibleUserNotAllowed() throws Exception {
     allowSubmitOnBehalfOf();
-    setApiUser(accountCreator.user2());
-    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+    requestScopeOperations.setApiUser(accountCreator.user2().id());
+    assertThat(accountControlFactory.get().canSee(user.id())).isFalse();
 
     PushOneCommit.Result r = createChange();
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
-    in.onBehalfOf = user.email;
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage(in.onBehalfOf);
-    gApi.changes().id(changeId).current().submit(in);
+    in.onBehalfOf = user.email();
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(changeId).current().submit(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains(in.onBehalfOf);
   }
 
   @Test
   public void runAsValidUser() throws Exception {
     allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/accounts/self", runAsHeader(user.id));
+    RestResponse res = adminRestSession.getWithHeader("/accounts/self", runAsHeader(user.id()));
     res.assertOK();
     AccountInfo account = newGson().fromJson(res.getEntityContent(), AccountInfo.class);
-    assertThat(account._accountId).isEqualTo(user.id.get());
+    assertThat(account._accountId).isEqualTo(user.id().get());
   }
 
   @GerritConfig(name = "auth.enableRunAs", value = "false")
   @Test
   public void runAsDisabledByConfig() throws Exception {
     allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent())
         .isEqualTo("X-Gerrit-RunAs disabled by auth.enableRunAs = false");
@@ -431,7 +452,7 @@
 
   @Test
   public void runAsNotPermitted() throws Exception {
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
   }
@@ -439,7 +460,7 @@
   @Test
   public void runAsNeverPermittedForAnonymousUsers() throws Exception {
     allowRunAs();
-    RestResponse res = anonRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    RestResponse res = anonRestSession.getWithHeader("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
   }
@@ -457,14 +478,14 @@
     allowRunAs();
     PushOneCommit.Result r = createChange();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     DraftInput di = new DraftInput();
     di.path = Patch.COMMIT_MSG;
     di.side = Side.REVISION;
     di.line = 1;
     di.message = "inline comment";
     gApi.changes().id(r.getChangeId()).current().createDraft(di);
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
 
     // Things that aren't allowed with on_behalf_of:
     //  - no labels.
@@ -474,19 +495,21 @@
     in.drafts = DraftHandling.PUBLISH;
     RestResponse res =
         adminRestSession.postWithHeader(
-            "/changes/" + r.getChangeId() + "/revisions/current/review", in, runAsHeader(user.id));
+            "/changes/" + r.getChangeId() + "/revisions/current/review",
+            runAsHeader(user.id()),
+            in);
     res.assertOK();
 
     ChangeMessageInfo m = Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
     assertThat(m.message).endsWith(in.message);
-    assertThat(m.author._accountId).isEqualTo(user.id.get());
+    assertThat(m.author._accountId).isEqualTo(user.id().get());
 
     CommentInfo c =
         Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).comments().get(di.path));
-    assertThat(c.author._accountId).isEqualTo(user.id.get());
+    assertThat(c.author._accountId).isEqualTo(user.id().get());
     assertThat(c.message).isEqualTo(di.message);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThat(gApi.changes().id(r.getChangeId()).drafts()).isEmpty();
   }
 
@@ -502,30 +525,30 @@
 
     PushOneCommit.Result r = createChange();
     ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.message = "Message on behalf of";
 
     String endpoint = "/changes/" + r.getChangeId() + "/revisions/current/review";
-    RestResponse res = adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id));
+    RestResponse res = adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id()), in);
     res.assertForbidden();
     assertThat(res.getEntityContent())
         .isEqualTo("label required to post review on behalf of \"" + in.onBehalfOf + '"');
 
     in.label("Code-Review", 1);
-    adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id)).assertOK();
+    adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id()), in).assertOK();
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getAccountId()).isEqualTo(user.id);
-    assertThat(psa.getValue()).isEqualTo(1);
-    assertThat(psa.getRealAccountId()).isEqualTo(admin.id); // not user2
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(admin.id()); // not user2
 
     ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
     assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(user.id);
-    assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
+    assertThat(m.getAuthor()).isEqualTo(user.id());
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id()); // not user2
   }
 
   @Test
@@ -534,11 +557,11 @@
 
     PushOneCommit.Result r = createChange();
     ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
+    in.onBehalfOf = user.id().toString();
     in.message = "Message on behalf of";
     in.label("Code-Review", 1);
 
-    setApiUser(accountCreator.user2());
+    requestScopeOperations.setApiUser(accountCreator.user2().id());
     gApi.changes().id(r.getChangeId()).revision(r.getPatchSetId().getId()).review(in);
 
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get(MESSAGES);
@@ -546,58 +569,58 @@
 
     ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
     assertThat(changeMessageInfo.realAuthor).isNotNull();
-    assertThat(changeMessageInfo.realAuthor._accountId).isEqualTo(accountCreator.user2().id.get());
+    assertThat(changeMessageInfo.realAuthor._accountId)
+        .isEqualTo(accountCreator.user2().id().get());
   }
 
   private void allowCodeReviewOnBehalfOf() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReviewType = Util.codeReview();
-      String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
-      String heads = "refs/heads/*";
-      AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(u.getConfig(), forCodeReviewAs, -1, 1, uuid, heads);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .impersonation(true)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
   }
 
   private void allowSubmitOnBehalfOf() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      String heads = "refs/heads/*";
-      AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(u.getConfig(), Permission.SUBMIT_AS, uuid, heads);
-      Util.allow(u.getConfig(), Permission.SUBMIT, uuid, heads);
-      LabelType codeReviewType = Util.codeReview();
-      Util.allow(u.getConfig(), Permission.forLabel(codeReviewType.getName()), -2, 2, uuid, heads);
-      u.save();
-    }
+    String heads = "refs/heads/*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT_AS).ref(heads).group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(heads).group(REGISTERED_USERS))
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(heads)
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
   }
 
   private void blockRead(GroupInfo group) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(
-          u.getConfig(), Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(AccountGroup.uuid(group.id)))
+        .update();
   }
 
   private void allowRunAs() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      Util.allow(
-          u.getConfig(),
-          GlobalCapability.RUN_AS,
-          systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-      u.save();
-    }
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.RUN_AS).group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void removeRunAs() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      Util.remove(
-          u.getConfig(),
-          GlobalCapability.RUN_AS,
-          systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-      u.save();
-    }
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(capabilityKey(GlobalCapability.RUN_AS).group(ANONYMOUS_USERS))
+        .update();
   }
 
   private static Header runAsHeader(Object user) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index ea71281..e05d0db 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -27,7 +27,7 @@
     UsernameInput in = new UsernameInput();
     in.username = "myUsername";
     RestResponse r =
-        adminRestSession.put("/accounts/" + accountCreator.create().id.get() + "/username", in);
+        adminRestSession.put("/accounts/" + accountCreator.create().id().get() + "/username", in);
     r.assertOK();
     assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(in.username);
   }
@@ -35,9 +35,9 @@
   @Test
   public void setExisting_Conflict() throws Exception {
     UsernameInput in = new UsernameInput();
-    in.username = admin.username;
+    in.username = admin.username();
     adminRestSession
-        .put("/accounts/" + accountCreator.create().id.get() + "/username", in)
+        .put("/accounts/" + accountCreator.create().id().get() + "/username", in)
         .assertConflict();
   }
 
@@ -45,11 +45,13 @@
   public void setNew_MethodNotAllowed() throws Exception {
     UsernameInput in = new UsernameInput();
     in.username = "newUsername";
-    adminRestSession.put("/accounts/" + admin.username + "/username", in).assertMethodNotAllowed();
+    adminRestSession
+        .put("/accounts/" + admin.username() + "/username", in)
+        .assertMethodNotAllowed();
   }
 
   @Test
   public void delete_MethodNotAllowed() throws Exception {
-    adminRestSession.put("/accounts/" + admin.username + "/username").assertMethodNotAllowed();
+    adminRestSession.put("/accounts/" + admin.username() + "/username").assertMethodNotAllowed();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index bc84593..2c9107c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -15,24 +15,31 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.List;
 import org.junit.Test;
 
 public class WatchedProjectsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final String NEW_PROJECT_NAME = "newProjectAccess";
 
   @Test
   public void setAndGetWatchedProjects() throws Exception {
-    String projectName1 = createProject(NEW_PROJECT_NAME).get();
-    String projectName2 = createProject(NEW_PROJECT_NAME + "2").get();
+    String projectName1 = projectOperations.newProject().name(NEW_PROJECT_NAME).create().get();
+    String projectName2 =
+        projectOperations.newProject().name(NEW_PROJECT_NAME + "2").create().get();
 
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>(2);
 
@@ -52,13 +59,13 @@
 
     List<ProjectWatchInfo> persistedWatchedProjects =
         gApi.accounts().self().setWatchedProjects(projectsToWatch);
-    assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch).inOrder();
+    assertThat(persistedWatchedProjects).containsAtLeastElementsIn(projectsToWatch).inOrder();
   }
 
   @Test
   public void setAndDeleteWatchedProjects() throws Exception {
-    String projectName1 = createProject(NEW_PROJECT_NAME).get();
-    String projectName2 = createProject(NEW_PROJECT_NAME + "2").get();
+    String projectName1 = projectOperations.newProject().create().get();
+    String projectName2 = projectOperations.newProject().create().get();
 
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
@@ -86,12 +93,12 @@
     List<ProjectWatchInfo> persistedWatchedProjects = gApi.accounts().self().getWatchedProjects();
 
     assertThat(persistedWatchedProjects).doesNotContain(pwi);
-    assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch);
+    assertThat(persistedWatchedProjects).containsAtLeastElementsIn(projectsToWatch);
   }
 
   @Test
   public void setConflictingWatches() throws Exception {
-    String projectName = createProject(NEW_PROJECT_NAME).get();
+    String projectName = projectOperations.newProject().create().get();
 
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
@@ -108,14 +115,16 @@
     pwi.notifyNewPatchSets = true;
     projectsToWatch.add(pwi);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("duplicate entry for project " + projectName);
-    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(thrown).hasMessageThat().contains("duplicate entry for project " + projectName);
   }
 
   @Test
   public void setAndGetEmptyWatch() throws Exception {
-    String projectName = createProject(NEW_PROJECT_NAME).get();
+    String projectName = projectOperations.newProject().create().get();
 
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
@@ -125,7 +134,7 @@
 
     gApi.accounts().self().setWatchedProjects(projectsToWatch);
     List<ProjectWatchInfo> persistedWatchedProjects = gApi.accounts().self().getWatchedProjects();
-    assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch);
+    assertThat(persistedWatchedProjects).containsAtLeastElementsIn(projectsToWatch);
   }
 
   @Test
@@ -140,9 +149,9 @@
     pwi.notifyNewChanges = true;
     pwi.notifyAllComments = true;
     projectsToWatch.add(pwi);
-
-    exception.expect(UnprocessableEntityException.class);
-    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
   }
 
   @Test
@@ -150,7 +159,7 @@
     String projectName = project.get();
 
     // Let another user watch a project
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
     ProjectWatchInfo pwi = new ProjectWatchInfo();
@@ -167,7 +176,7 @@
     gApi.accounts().self().deleteWatchedProjects(d);
 
     // Check that trying to delete a non-existing watch doesn't fail
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.accounts().self().deleteWatchedProjects(d);
   }
 
@@ -176,7 +185,7 @@
     String projectName = project.get();
 
     // Let another user watch a project
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
     ProjectWatchInfo pwi = new ProjectWatchInfo();
@@ -199,7 +208,7 @@
 
     List<ProjectWatchInfo> watchedProjects = gApi.accounts().self().getWatchedProjects();
 
-    assertThat(watchedProjects).containsAllIn(projectsToWatch);
+    assertThat(watchedProjects).containsAtLeastElementsIn(projectsToWatch);
   }
 
   @Test
@@ -233,11 +242,11 @@
     List<ProjectWatchInfo> persistedWatchedProjects = gApi.accounts().self().getWatchedProjects();
 
     assertThat(persistedWatchedProjects).doesNotContain(pwi);
-    assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch);
+    assertThat(persistedWatchedProjects).containsAtLeastElementsIn(projectsToWatch);
   }
 
   @Test
   public void postWithoutBody() throws Exception {
-    adminRestSession.post("/accounts/" + admin.username + "/watched.projects").assertOK();
+    adminRestSession.post("/accounts/" + admin.username() + "/watched.projects").assertOK();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
new file mode 100644
index 0000000..8e5eaa4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
@@ -0,0 +1,195 @@
+// 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.binding;
+
+import static com.google.gerrit.acceptance.rest.util.RestApiCallHelper.execute;
+import static com.google.gerrit.acceptance.rest.util.RestCall.Method.PUT;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the accounts REST API.
+ *
+ * <p>These tests only verify that the account REST endpoints are correctly bound, they do no test
+ * the functionality of the account REST endpoints.
+ */
+public class AccountsRestApiBindingsIT extends AbstractDaemonTest {
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  /**
+   * Account REST endpoints to be tested, each URL contains a placeholder for the account
+   * identifier.
+   */
+  private static final ImmutableList<RestCall> ACCOUNT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/accounts/%s"),
+          RestCall.put("/accounts/%s"),
+          RestCall.get("/accounts/%s/detail"),
+          RestCall.get("/accounts/%s/name"),
+          RestCall.put("/accounts/%s/name"),
+          RestCall.delete("/accounts/%s/name"),
+          RestCall.get("/accounts/%s/username"),
+          RestCall.builder(PUT, "/accounts/%s/username")
+              // Changing the username is not allowed.
+              .expectedResponseCode(SC_METHOD_NOT_ALLOWED)
+              .expectedMessage("Username cannot be changed.")
+              .build(),
+          RestCall.get("/accounts/%s/active"),
+          RestCall.put("/accounts/%s/active"),
+          RestCall.delete("/accounts/%s/active"),
+          RestCall.put("/accounts/%s/password.http"),
+          RestCall.delete("/accounts/%s/password.http"),
+          RestCall.get("/accounts/%s/status"),
+          RestCall.put("/accounts/%s/status"),
+          RestCall.get("/accounts/%s/avatar"),
+          RestCall.get("/accounts/%s/avatar.change.url"),
+          RestCall.get("/accounts/%s/emails/"),
+          RestCall.put("/accounts/%s/emails/new-email@foo.com"),
+          RestCall.get("/accounts/%s/sshkeys/"),
+          RestCall.post("/accounts/%s/sshkeys/"),
+          RestCall.get("/accounts/%s/watched.projects"),
+          RestCall.post("/accounts/%s/watched.projects"),
+          RestCall.post("/accounts/%s/watched.projects:delete"),
+          RestCall.get("/accounts/%s/groups"),
+          RestCall.get("/accounts/%s/preferences"),
+          RestCall.put("/accounts/%s/preferences"),
+          RestCall.get("/accounts/%s/preferences.diff"),
+          RestCall.put("/accounts/%s/preferences.diff"),
+          RestCall.get("/accounts/%s/preferences.edit"),
+          RestCall.put("/accounts/%s/preferences.edit"),
+          RestCall.get("/accounts/%s/starred.changes"),
+          RestCall.get("/accounts/%s/stars.changes"),
+          RestCall.post("/accounts/%s/index"),
+          RestCall.get("/accounts/%s/agreements"),
+          RestCall.put("/accounts/%s/agreements"),
+          RestCall.get("/accounts/%s/external.ids"),
+          RestCall.post("/accounts/%s/external.ids:delete"),
+          RestCall.post("/accounts/%s/drafts:delete"),
+          RestCall.get("/accounts/%s/oauthtoken"),
+          RestCall.get("/accounts/%s/capabilities"),
+          RestCall.get("/accounts/%s/capabilities/viewPlugins"),
+          RestCall.get("/accounts/%s/gpgkeys"),
+          RestCall.post("/accounts/%s/gpgkeys"));
+
+  /**
+   * Email REST endpoints to be tested, each URL contains a placeholders for the account and email
+   * identifier.
+   */
+  private static final ImmutableList<RestCall> EMAIL_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/accounts/%s/emails/%s"),
+          RestCall.put("/accounts/%s/emails/%s"),
+          RestCall.put("/accounts/%s/emails/%s/preferred"),
+
+          // email deletion must be tested last
+          RestCall.delete("/accounts/%s/emails/%s"));
+
+  /**
+   * GPG key REST endpoints to be tested, each URL contains a placeholders for the account
+   * identifier and the GPG key identifier.
+   */
+  private static final ImmutableList<RestCall> GPG_KEY_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/accounts/%s/gpgkeys/%s"),
+
+          // GPG key deletion must be tested last
+          RestCall.delete("/accounts/%s/gpgkeys/%s"));
+
+  /**
+   * SSH key REST endpoints to be tested, each URL contains a placeholders for the account and SSH
+   * key identifier.
+   */
+  private static final ImmutableList<RestCall> SSH_KEY_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/accounts/%s/sshkeys/%s"),
+
+          // SSH key deletion must be tested last
+          RestCall.delete("/accounts/%s/sshkeys/%s"));
+
+  /**
+   * Star REST endpoints to be tested, each URL contains a placeholders for the account and change
+   * identifier.
+   */
+  private static final ImmutableList<RestCall> STAR_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.put("/accounts/%s/starred.changes/%s"),
+          RestCall.delete("/accounts/%s/starred.changes/%s"),
+          RestCall.get("/accounts/%s/stars.changes/%s"),
+          RestCall.post("/accounts/%s/stars.changes/%s"));
+
+  @Test
+  public void accountEndpoints() throws Exception {
+    execute(adminRestSession, ACCOUNT_ENDPOINTS, "self");
+  }
+
+  @Test
+  public void emailEndpoints() throws Exception {
+    execute(adminRestSession, EMAIL_ENDPOINTS, "self", admin.email());
+  }
+
+  @Test
+  @GerritConfig(name = "receive.enableSignedPush", value = "true")
+  public void gpgKeyEndpoints() throws Exception {
+    TestKey key = validKeyWithoutExpiration();
+    String id = key.getKeyIdString();
+
+    String email = "test1@example.com"; // email that is hard-coded in the test GPG key
+    accountsUpdateProvider
+        .get()
+        .update(
+            "Add Email",
+            admin.id(),
+            u ->
+                u.addExternalId(
+                    ExternalId.createWithEmail(name("test"), email, admin.id(), email)));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.accounts()
+        .self()
+        .putGpgKeys(ImmutableList.of(key.getPublicKeyArmored()), ImmutableList.of());
+
+    execute(adminRestSession, GPG_KEY_ENDPOINTS, "self", id);
+  }
+
+  @Test
+  @UseSsh
+  public void sshKeyEndpoints() throws Exception {
+    String sshKeySeq = Integer.toString(gApi.accounts().self().listSshKeys().size());
+    execute(adminRestSession, SSH_KEY_ENDPOINTS, "self", sshKeySeq);
+  }
+
+  @Test
+  public void starEndpoints() throws Exception {
+    ChangeInput ci = new ChangeInput(project.get(), "master", "Test change");
+    String changeId = gApi.changes().create(ci).get().id;
+    execute(adminRestSession, STAR_ENDPOINTS, "self", changeId);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/BUILD b/javatests/com/google/gerrit/acceptance/rest/binding/BUILD
new file mode 100644
index 0000000..e4242a9
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/BUILD
@@ -0,0 +1,11 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_bindings",
+    labels = ["rest"],
+    deps = [
+        "//java/com/google/gerrit/server/logging",
+        "//javatests/com/google/gerrit/acceptance/rest/util",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
new file mode 100644
index 0000000..55744cc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -0,0 +1,498 @@
+// 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.binding;
+
+import static com.google.gerrit.acceptance.rest.util.RestCall.Method.GET;
+import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThatList;
+import static java.util.stream.Collectors.toList;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.reviewdb.client.Patch;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the changes REST API.
+ *
+ * <p>These tests only verify that the change REST endpoints are correctly bound, they do no test
+ * the functionality of the change REST endpoints.
+ */
+public class ChangesRestApiBindingsIT extends AbstractDaemonTest {
+  /**
+   * Change REST endpoints to be tested, each URL contains a placeholder for the change identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s"),
+          RestCall.get("/changes/%s/detail"),
+          RestCall.get("/changes/%s/topic"),
+          RestCall.put("/changes/%s/topic"),
+          RestCall.delete("/changes/%s/topic"),
+          RestCall.get("/changes/%s/in"),
+          RestCall.get("/changes/%s/hashtags"),
+          RestCall.get("/changes/%s/comments"),
+          RestCall.get("/changes/%s/robotcomments"),
+          RestCall.get("/changes/%s/drafts"),
+          RestCall.get("/changes/%s/assignee"),
+          RestCall.get("/changes/%s/past_assignees"),
+          RestCall.put("/changes/%s/assignee"),
+          RestCall.delete("/changes/%s/assignee"),
+          RestCall.post("/changes/%s/private"),
+          RestCall.post("/changes/%s/private.delete"),
+          RestCall.delete("/changes/%s/private"),
+          RestCall.post("/changes/%s/wip"),
+          RestCall.post("/changes/%s/ready"),
+          RestCall.put("/changes/%s/ignore"),
+          RestCall.put("/changes/%s/unignore"),
+          RestCall.put("/changes/%s/reviewed"),
+          RestCall.put("/changes/%s/unreviewed"),
+          RestCall.get("/changes/%s/messages"),
+          RestCall.put("/changes/%s/message"),
+          RestCall.post("/changes/%s/merge"),
+          RestCall.post("/changes/%s/abandon"),
+          RestCall.post("/changes/%s/move"),
+          RestCall.post("/changes/%s/rebase"),
+          RestCall.post("/changes/%s/restore"),
+          RestCall.post("/changes/%s/revert"),
+          RestCall.get("/changes/%s/pure_revert"),
+          RestCall.post("/changes/%s/submit"),
+          RestCall.get("/changes/%s/submitted_together"),
+          RestCall.post("/changes/%s/index"),
+          RestCall.get("/changes/%s/check"),
+          RestCall.post("/changes/%s/check"),
+          RestCall.get("/changes/%s/reviewers"),
+          RestCall.post("/changes/%s/reviewers"),
+          RestCall.get("/changes/%s/suggest_reviewers"),
+          RestCall.builder(GET, "/changes/%s/revisions")
+              // GET /changes/<change-id>/revisions is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.get("/changes/%s/edit"),
+          RestCall.post("/changes/%s/edit"),
+          RestCall.post("/changes/%s/edit:rebase"),
+          RestCall.get("/changes/%s/edit:message"),
+          RestCall.put("/changes/%s/edit:message"),
+
+          // Publish edit and create a new edit
+          RestCall.post("/changes/%s/edit:publish"),
+          RestCall.put("/changes/%s/edit/a.txt"),
+
+          // Deletion of change edit and change must be tested last
+          RestCall.delete("/changes/%s/edit"),
+          RestCall.delete("/changes/%s"));
+
+  /**
+   * Reviewer REST endpoints to be tested, each URL contains placeholders for the change identifier
+   * and the reviewer identifier.
+   */
+  private static final ImmutableList<RestCall> REVIEWER_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/reviewers/%s"),
+          RestCall.get("/changes/%s/reviewers/%s/votes"),
+          RestCall.post("/changes/%s/reviewers/%s/delete"),
+          RestCall.delete("/changes/%s/reviewers/%s"));
+
+  /**
+   * Vote REST endpoints to be tested, each URL contains placeholders for the change identifier, the
+   * reviewer identifier and the label identifier.
+   */
+  private static final ImmutableList<RestCall> VOTE_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.post("/changes/%s/reviewers/%s/votes/%s/delete"),
+          RestCall.delete("/changes/%s/reviewers/%s/votes/%s"));
+
+  /**
+   * Revision REST endpoints to be tested, each URL contains placeholders for the change identifier
+   * and the revision identifier.
+   */
+  private static final ImmutableList<RestCall> REVISION_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/revisions/%s/actions"),
+          RestCall.post("/changes/%s/revisions/%s/cherrypick"),
+          RestCall.get("/changes/%s/revisions/%s/commit"),
+          RestCall.get("/changes/%s/revisions/%s/mergeable"),
+          RestCall.get("/changes/%s/revisions/%s/related"),
+          RestCall.get("/changes/%s/revisions/%s/review"),
+          RestCall.post("/changes/%s/revisions/%s/review"),
+          RestCall.get("/changes/%s/revisions/%s/preview_submit"),
+          RestCall.post("/changes/%s/revisions/%s/submit"),
+          RestCall.get("/changes/%s/revisions/%s/submit_type"),
+          RestCall.post("/changes/%s/revisions/%s/test.submit_rule"),
+          RestCall.post("/changes/%s/revisions/%s/test.submit_type"),
+          RestCall.post("/changes/%s/revisions/%s/rebase"),
+          RestCall.get("/changes/%s/revisions/%s/description"),
+          RestCall.put("/changes/%s/revisions/%s/description"),
+          RestCall.get("/changes/%s/revisions/%s/patch"),
+          RestCall.get("/changes/%s/revisions/%s/archive"),
+          RestCall.get("/changes/%s/revisions/%s/mergelist"),
+          RestCall.get("/changes/%s/revisions/%s/reviewers"),
+          RestCall.get("/changes/%s/revisions/%s/drafts"),
+          RestCall.put("/changes/%s/revisions/%s/drafts"),
+          RestCall.get("/changes/%s/revisions/%s/comments"),
+          RestCall.get("/changes/%s/revisions/%s/robotcomments"),
+          RestCall.builder(GET, "/changes/%s/revisions/%s/fixes")
+              // GET /changes/<change>/revisions/<revision>/fixes is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.get("/changes/%s/revisions/%s/files"));
+
+  /**
+   * Revision reviewer REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier and the reviewer identifier.
+   */
+  private static final ImmutableList<RestCall> REVISION_REVIEWER_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/revisions/%s/reviewers/%s"),
+          RestCall.get("/changes/%s/revisions/%s/reviewers/%s/votes"),
+          RestCall.post("/changes/%s/revisions/%s/reviewers/%s/delete"),
+          RestCall.delete("/changes/%s/revisions/%s/reviewers/%s"));
+
+  /**
+   * Revision vote REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier, the reviewer identifier and the label identifier.
+   */
+  private static final ImmutableList<RestCall> REVISION_VOTE_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.post("/changes/%s/revisions/%s/reviewers/%s/votes/%s/delete"),
+          RestCall.delete("/changes/%s/revisions/%s/reviewers/%s/votes/%s"));
+
+  /**
+   * Draft comment REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier and the draft comment identifier.
+   */
+  private static final ImmutableList<RestCall> DRAFT_COMMENT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/revisions/%s/drafts/%s"),
+          RestCall.put("/changes/%s/revisions/%s/drafts/%s"),
+          RestCall.delete("/changes/%s/revisions/%s/drafts/%s"));
+
+  /**
+   * Comment REST endpoints to be tested, each URL contains placeholders for the change identifier,
+   * the revision identifier and the comment identifier.
+   */
+  private static final ImmutableList<RestCall> COMMENT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/revisions/%s/comments/%s"),
+          RestCall.delete("/changes/%s/revisions/%s/comments/%s"),
+          RestCall.post("/changes/%s/revisions/%s/comments/%s/delete"));
+
+  /**
+   * Robot comment REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier and the robot comment identifier.
+   */
+  private static final ImmutableList<RestCall> ROBOT_COMMENT_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/changes/%s/revisions/%s/robotcomments/%s"));
+
+  /**
+   * Fix REST endpoints to be tested, each URL contains placeholders for the change identifier, the
+   * revision identifier and the fix identifier.
+   */
+  private static final ImmutableList<RestCall> FIX_ENDPOINTS =
+      ImmutableList.of(RestCall.post("/changes/%s/revisions/%s/fixes/%s/apply"));
+
+  /**
+   * Revision file REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier and the file identifier.
+   */
+  private static final ImmutableList<RestCall> REVISION_FILE_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.put("/changes/%s/revisions/%s/files/%s/reviewed"),
+          RestCall.delete("/changes/%s/revisions/%s/files/%s/reviewed"),
+          RestCall.get("/changes/%s/revisions/%s/files/%s/content"),
+          RestCall.get("/changes/%s/revisions/%s/files/%s/download"),
+          RestCall.get("/changes/%s/revisions/%s/files/%s/diff"),
+          RestCall.get("/changes/%s/revisions/%s/files/%s/blame"));
+
+  /**
+   * Change message REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier and the change message identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_MESSAGE_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/changes/%s/messages/%s"));
+
+  /**
+   * Change edit REST endpoints that create an edit to be tested, each URL contains placeholders for
+   * the change identifier and the change edit identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_EDIT_CREATE_ENDPOINTS =
+      ImmutableList.of(
+          // Create change edit by editing an existing file.
+          RestCall.put("/changes/%s/edit/%s"),
+
+          // Create change edit by deleting an existing file.
+          RestCall.delete("/changes/%s/edit/%s"));
+
+  /**
+   * Change edit REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier and the change edit identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_EDIT_ENDPOINTS =
+      ImmutableList.of(
+          // Calls on existing change edit.
+          RestCall.get("/changes/%s/edit/%s"),
+          RestCall.put("/changes/%s/edit/%s"),
+          RestCall.get("/changes/%s/edit/%s/meta"),
+
+          // Delete content of a file in an existing change edit.
+          RestCall.delete("/changes/%s/edit/%s"));
+
+  private static final String FILENAME = "test.txt";
+
+  @Test
+  public void changeEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).edit().create();
+    RestApiCallHelper.execute(adminRestSession, CHANGE_ENDPOINTS, changeId);
+  }
+
+  @Test
+  public void reviewerEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+
+    RestApiCallHelper.execute(
+        adminRestSession,
+        REVIEWER_ENDPOINTS,
+        () -> gApi.changes().id(changeId).addReviewer(addReviewerInput),
+        changeId,
+        addReviewerInput.reviewer);
+  }
+
+  @Test
+  public void voteEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    RestApiCallHelper.execute(
+        adminRestSession,
+        VOTE_ENDPOINTS,
+        () -> gApi.changes().id(changeId).current().review(ReviewInput.approve()),
+        changeId,
+        admin.email(),
+        "Code-Review");
+  }
+
+  @Test
+  public void revisionEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+    RestApiCallHelper.execute(adminRestSession, REVISION_ENDPOINTS, changeId, "current");
+  }
+
+  @Test
+  public void revisionReviewerEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+
+    RestApiCallHelper.execute(
+        adminRestSession,
+        REVISION_REVIEWER_ENDPOINTS,
+        () -> gApi.changes().id(changeId).addReviewer(addReviewerInput),
+        changeId,
+        "current",
+        addReviewerInput.reviewer);
+  }
+
+  @Test
+  public void revisionVoteEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    RestApiCallHelper.execute(
+        adminRestSession,
+        REVISION_VOTE_ENDPOINTS,
+        () -> gApi.changes().id(changeId).current().review(ReviewInput.approve()),
+        changeId,
+        "current",
+        admin.email(),
+        "Code-Review");
+  }
+
+  @Test
+  public void draftCommentEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    for (RestCall restCall : DRAFT_COMMENT_ENDPOINTS) {
+      DraftInput draftInput = new DraftInput();
+      draftInput.path = Patch.COMMIT_MSG;
+      draftInput.side = Side.REVISION;
+      draftInput.line = 1;
+      draftInput.message = "draft comment";
+      CommentInfo draftInfo = gApi.changes().id(changeId).current().createDraft(draftInput).get();
+
+      RestApiCallHelper.execute(adminRestSession, restCall, changeId, "current", draftInfo.id);
+    }
+  }
+
+  @Test
+  public void commentEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    for (RestCall restCall : COMMENT_ENDPOINTS) {
+      DraftInput draftInput = new DraftInput();
+      draftInput.path = Patch.COMMIT_MSG;
+      draftInput.side = Side.REVISION;
+      draftInput.line = 1;
+      draftInput.message = "draft comment";
+      CommentInfo commentInfo = gApi.changes().id(changeId).current().createDraft(draftInput).get();
+
+      ReviewInput reviewInput = new ReviewInput();
+      reviewInput.drafts = DraftHandling.PUBLISH;
+      gApi.changes().id(changeId).current().review(reviewInput);
+
+      RestApiCallHelper.execute(adminRestSession, restCall, changeId, "current", commentInfo.id);
+    }
+  }
+
+  @Test
+  public void robotCommentEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    RobotCommentInput robotCommentInput = new RobotCommentInput();
+    robotCommentInput.robotId = "happyRobot";
+    robotCommentInput.robotRunId = "1";
+    robotCommentInput.line = 1;
+    robotCommentInput.message = "nit: trailing whitespace";
+    robotCommentInput.path = Patch.COMMIT_MSG;
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.robotComments =
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
+    reviewInput.message = "robot comment test";
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    List<RobotCommentInfo> robotCommentInfos =
+        gApi.changes().id(changeId).current().robotCommentsAsList();
+    RobotCommentInfo robotCommentInfo = Iterables.getOnlyElement(robotCommentInfos);
+
+    RestApiCallHelper.execute(
+        adminRestSession, ROBOT_COMMENT_ENDPOINTS, changeId, "current", robotCommentInfo.id);
+  }
+
+  @Test
+  public void fixEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+
+    RobotCommentInput robotCommentInput = new RobotCommentInput();
+    robotCommentInput.robotId = "happyRobot";
+    robotCommentInput.robotRunId = "1";
+    robotCommentInput.line = 1;
+    robotCommentInput.message = "nit: trailing whitespace";
+    robotCommentInput.path = FILENAME;
+
+    FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+    fixReplacementInfo.path = FILENAME;
+    fixReplacementInfo.replacement = "some replacement code";
+    fixReplacementInfo.range = createRange(1, 1, 1, 2);
+
+    FixSuggestionInfo fixSuggestionInfo = new FixSuggestionInfo();
+    fixSuggestionInfo.fixId = "An ID which must be overwritten.";
+    fixSuggestionInfo.description = "A description for a suggested fix.";
+    fixSuggestionInfo.replacements = ImmutableList.of(fixReplacementInfo);
+
+    robotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.robotComments =
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
+    reviewInput.message = "robot comment test";
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    List<RobotCommentInfo> robotCommentInfos =
+        gApi.changes().id(changeId).current().robotCommentsAsList();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    RestApiCallHelper.execute(adminRestSession, FIX_ENDPOINTS, changeId, "current", fixId);
+  }
+
+  @Test
+  public void revisionFileEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+    RestApiCallHelper.execute(
+        adminRestSession, REVISION_FILE_ENDPOINTS, changeId, "current", FILENAME);
+  }
+
+  @Test
+  public void changeMessageEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // A change message is created on change creation.
+    String changeMessageId = Iterables.getOnlyElement(gApi.changes().id(changeId).messages()).id;
+
+    RestApiCallHelper.execute(
+        adminRestSession, CHANGE_MESSAGE_ENDPOINTS, changeId, changeMessageId);
+  }
+
+  @Test
+  public void changeEditCreateEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+
+    // Each of the REST calls creates the change edit newly.
+    RestApiCallHelper.execute(
+        adminRestSession,
+        CHANGE_EDIT_CREATE_ENDPOINTS,
+        () -> adminRestSession.delete("/changes/" + changeId + "/edit"),
+        changeId,
+        FILENAME);
+  }
+
+  @Test
+  public void changeEditEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+    gApi.changes().id(changeId).edit().create();
+    RestApiCallHelper.execute(adminRestSession, CHANGE_EDIT_ENDPOINTS, changeId, FILENAME);
+  }
+
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
+  private static List<String> getFixIds(List<RobotCommentInfo> robotComments) {
+    assertThatList(robotComments).isNotNull();
+    return robotComments.stream()
+        .map(robotCommentInfo -> robotCommentInfo.fixSuggestions)
+        .filter(Objects::nonNull)
+        .flatMap(List::stream)
+        .map(fixSuggestionInfo -> fixSuggestionInfo.fixId)
+        .collect(toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
new file mode 100644
index 0000000..cef599f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -0,0 +1,120 @@
+// 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.binding;
+
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.project.ProjectCacheImpl;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.Optional;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the config REST API.
+ *
+ * <p>These tests only verify that the config REST endpoints are correctly bound, they do no test
+ * the functionality of the config REST endpoints.
+ */
+public class ConfigRestApiBindingsIT extends AbstractDaemonTest {
+  /**
+   * Config REST endpoints to be tested, the URLs contain no placeholders since the only supported
+   * config identifier ('server') can be hard-coded.
+   */
+  private static final ImmutableList<RestCall> CONFIG_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/config/server/version"),
+          RestCall.get("/config/server/info"),
+          RestCall.get("/config/server/preferences"),
+          RestCall.put("/config/server/preferences"),
+          RestCall.get("/config/server/preferences.diff"),
+          RestCall.put("/config/server/preferences.diff"),
+          RestCall.get("/config/server/preferences.edit"),
+          RestCall.put("/config/server/preferences.edit"),
+          RestCall.get("/config/server/top-menus"),
+          RestCall.put("/config/server/email.confirm"),
+          RestCall.post("/config/server/check.consistency"),
+          RestCall.post("/config/server/reload"),
+          RestCall.get("/config/server/summary"),
+          RestCall.get("/config/server/capabilities"),
+          RestCall.get("/config/server/caches"),
+          RestCall.post("/config/server/caches"),
+          RestCall.get("/config/server/tasks"));
+
+  /**
+   * Cache REST endpoints to be tested, the URLs contain a placeholder for the cache identifier.
+   * Since there is only supported a single supported config identifier ('server') it can be
+   * hard-coded.
+   */
+  private static final ImmutableList<RestCall> CACHE_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/config/server/caches/%s"));
+
+  /**
+   * Task REST endpoints to be tested, the URLs contain a placeholder for the task identifier. Since
+   * there is only supported a single supported config identifier ('server') it can be hard-coded.
+   */
+  private static final ImmutableList<RestCall> TASK_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/config/server/tasks/%s"),
+
+          // Task deletion must be tested last
+          RestCall.delete("/config/server/tasks/%s"));
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void configEndpoints() throws Exception {
+    // 'Access Database' is needed for the '/config/server/check.consistency' REST endpoint
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+
+    RestApiCallHelper.execute(adminRestSession, CONFIG_ENDPOINTS);
+  }
+
+  @Test
+  public void cacheEndpoints() throws Exception {
+    RestApiCallHelper.execute(adminRestSession, CACHE_ENDPOINTS, ProjectCacheImpl.CACHE_NAME);
+  }
+
+  @Test
+  public void taskEndpoints() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
+    List<TaskInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
+    r.consume();
+
+    Optional<String> id =
+        result.stream()
+            .filter(t -> "Log File Compressor".equals(t.command))
+            .map(t -> t.id)
+            .findFirst();
+    assertThat(id).isPresent();
+
+    RestApiCallHelper.execute(adminRestSession, TASK_ENDPOINTS, id.get());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/GroupsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/GroupsRestApiBindingsIT.java
new file mode 100644
index 0000000..bb12172
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/GroupsRestApiBindingsIT.java
@@ -0,0 +1,102 @@
+// 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.binding;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the groups REST API.
+ *
+ * <p>These tests only verify that the group REST endpoints are correctly bound, they do no test the
+ * functionality of the group REST endpoints.
+ */
+public class GroupsRestApiBindingsIT extends AbstractDaemonTest {
+  /**
+   * Group REST endpoints to be tested, each URL contains a placeholder for the group identifier.
+   */
+  private static final ImmutableList<RestCall> GROUP_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/groups/%s"),
+          RestCall.put("/groups/%s"),
+          RestCall.get("/groups/%s/detail"),
+          RestCall.get("/groups/%s/name"),
+          RestCall.put("/groups/%s/name"),
+          RestCall.get("/groups/%s/description"),
+          RestCall.put("/groups/%s/description"),
+          RestCall.delete("/groups/%s/description"),
+          RestCall.get("/groups/%s/owner"),
+          RestCall.put("/groups/%s/owner"),
+          RestCall.get("/groups/%s/options"),
+          RestCall.put("/groups/%s/options"),
+          RestCall.post("/groups/%s/members"),
+          RestCall.post("/groups/%s/members.add"),
+          RestCall.post("/groups/%s/members.delete"),
+          RestCall.post("/groups/%s/groups"),
+          RestCall.post("/groups/%s/groups.add"),
+          RestCall.post("/groups/%s/groups.delete"),
+          RestCall.get("/groups/%s/log.audit"),
+          RestCall.post("/groups/%s/index"),
+          RestCall.get("/groups/%s/members"),
+          RestCall.get("/groups/%s/groups"));
+
+  /**
+   * Member REST endpoints to be tested, each URL contains placeholders for the group identifier and
+   * the member identifier.
+   */
+  private static final ImmutableList<RestCall> MEMBER_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/groups/%s/members/%s"),
+          RestCall.put("/groups/%s/members/%s"),
+
+          // Member deletion must be tested last
+          RestCall.delete("/groups/%s/members/%s"));
+
+  /**
+   * Subgroup REST endpoints to be tested, each URL contains placeholders for the group identifier
+   * and the subgroup identifier.
+   */
+  private static final ImmutableList<RestCall> SUBGROUP_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/groups/%s/groups/%s"),
+          RestCall.put("/groups/%s/groups/%s"),
+
+          // Subgroup deletion must be tested last
+          RestCall.delete("/groups/%s/groups/%s"));
+
+  @Test
+  public void groupEndpoints() throws Exception {
+    String group = gApi.groups().create("test-group").get().name;
+    RestApiCallHelper.execute(adminRestSession, GROUP_ENDPOINTS, group);
+  }
+
+  @Test
+  public void memberEndpoints() throws Exception {
+    String group = gApi.groups().create("test-group").get().name;
+    gApi.groups().id(group).addMembers(admin.email());
+    RestApiCallHelper.execute(adminRestSession, MEMBER_ENDPOINTS, group, admin.email());
+  }
+
+  @Test
+  public void subgroupEndpoints() throws Exception {
+    String group = gApi.groups().create("test-group").get().name;
+    String subgroup = gApi.groups().create("test-subgroup").get().name;
+    gApi.groups().id(group).addGroups(subgroup);
+    RestApiCallHelper.execute(adminRestSession, SUBGROUP_ENDPOINTS, group, subgroup);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
new file mode 100644
index 0000000..35be5f4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.binding;
+
+import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import org.junit.Test;
+
+/**
+ * Tests for checking plugin-provided REST API bindings nested under a core collection.
+ *
+ * <p>These tests only verify that the plugin-provided REST endpoints are correctly bound, they do
+ * not test the functionality of the plugin REST endpoints.
+ */
+public class PluginProvidedChildRestApiBindingsIT extends AbstractDaemonTest {
+
+  /** Resource to bind a child collection. */
+  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND =
+      new TypeLiteral<RestView<TestPluginResource>>() {};
+
+  private static final String PLUGIN_NAME = "my-plugin";
+
+  private static final ImmutableSet<RestCall> TEST_CALLS =
+      ImmutableSet.of(
+          // Calls that have the plugin name as part of the collection name
+          RestCall.get("/changes/%s/revisions/%s/" + PLUGIN_NAME + "~test-collection/"),
+          RestCall.get("/changes/%s/revisions/%s/" + PLUGIN_NAME + "~test-collection/1/detail"),
+          RestCall.post("/changes/%s/revisions/%s/" + PLUGIN_NAME + "~test-collection/"),
+          RestCall.post("/changes/%s/revisions/%s/" + PLUGIN_NAME + "~test-collection/1/update"),
+          // Same tests but without the plugin name as part of the collection name. This works as
+          // long as there is no core collection with the same name (which takes precedence) and no
+          // other plugin binds a collection with the same name. We highly encourage plugin authors
+          // to use the fully qualified collection name instead.
+          RestCall.get("/changes/%s/revisions/%s/test-collection/"),
+          RestCall.get("/changes/%s/revisions/%s/test-collection/1/detail"),
+          RestCall.post("/changes/%s/revisions/%s/test-collection/"),
+          RestCall.post("/changes/%s/revisions/%s/test-collection/1/update"));
+
+  /**
+   * Module for all sys bindings.
+   *
+   * <p>TODO: This should actually just move into MyPluginHttpModule. However, that doesn't work
+   * currently. This TODO is for fixing this bug.
+   */
+  static class MyPluginSysModule extends AbstractModule {
+    @Override
+    public void configure() {
+      install(
+          new RestApiModule() {
+            @Override
+            public void configure() {
+              DynamicMap.mapOf(binder(), TEST_KIND);
+              child(REVISION_KIND, "test-collection").to(TestChildCollection.class);
+
+              postOnCollection(TEST_KIND).to(TestPostOnCollection.class);
+              post(TEST_KIND, "update").to(TestPost.class);
+              get(TEST_KIND, "detail").to(TestGet.class);
+            }
+          });
+    }
+  }
+
+  static class TestPluginResource implements RestResource {}
+
+  @Singleton
+  static class TestChildCollection
+      implements ChildCollection<RevisionResource, TestPluginResource> {
+    private final DynamicMap<RestView<TestPluginResource>> views;
+
+    @Inject
+    TestChildCollection(DynamicMap<RestView<TestPluginResource>> views) {
+      this.views = views;
+    }
+
+    @Override
+    public RestView<RevisionResource> list() throws RestApiException {
+      return (RestReadView<RevisionResource>)
+          resource -> Response.ok(ImmutableList.of("one", "two"));
+    }
+
+    @Override
+    public TestPluginResource parse(RevisionResource parent, IdString id) throws Exception {
+      return new TestPluginResource();
+    }
+
+    @Override
+    public DynamicMap<RestView<TestPluginResource>> views() {
+      return views;
+    }
+  }
+
+  @Singleton
+  static class TestPostOnCollection
+      implements RestCollectionModifyView<RevisionResource, TestPluginResource, String> {
+    @Override
+    public Response<String> apply(RevisionResource parentResource, String input) throws Exception {
+      return Response.ok("test");
+    }
+  }
+
+  @Singleton
+  static class TestPost implements RestModifyView<TestPluginResource, String> {
+    @Override
+    public Response<String> apply(TestPluginResource resource, String input) throws Exception {
+      return Response.ok("test");
+    }
+  }
+
+  @Singleton
+  static class TestGet implements RestReadView<TestPluginResource> {
+    @Override
+    public Response<String> apply(TestPluginResource resource) throws Exception {
+      return Response.ok("test");
+    }
+  }
+
+  @Test
+  public void testEndpoints() throws Exception {
+    PatchSet.Id patchSetId = createChange().getPatchSetId();
+    try (AutoCloseable ignored = installPlugin(PLUGIN_NAME, MyPluginSysModule.class, null, null)) {
+      RestApiCallHelper.execute(
+          adminRestSession,
+          TEST_CALLS.asList(),
+          String.valueOf(patchSetId.changeId().get()),
+          String.valueOf(patchSetId.get()));
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
new file mode 100644
index 0000000..b447534
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
@@ -0,0 +1,207 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.binding;
+
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.acceptance.rest.util.RestCall.Method;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.servlet.ServletModule;
+import java.io.IOException;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+/**
+ * Tests for checking plugin-provided REST API bindings directly under {@code /}.
+ *
+ * <p>These tests only verify that the plugin-provided REST endpoints are correctly bound, they do
+ * not test the functionality of the plugin REST endpoints.
+ */
+public class PluginProvidedRootRestApiBindingsIT extends AbstractDaemonTest {
+
+  /** Resource to bind a child collection. */
+  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND =
+      new TypeLiteral<RestView<TestPluginResource>>() {};
+
+  private static final String PLUGIN_NAME = "my-plugin";
+
+  private static final ImmutableSet<RestCall> TEST_CALLS =
+      ImmutableSet.of(
+          RestCall.get("/plugins/" + PLUGIN_NAME + "/test-collection/"),
+          RestCall.get("/plugins/" + PLUGIN_NAME + "/test-collection/1/detail"),
+          RestCall.post("/plugins/" + PLUGIN_NAME + "/test-collection/"),
+          RestCall.post("/plugins/" + PLUGIN_NAME + "/test-collection/1/update"),
+          RestCall.builder(Method.GET, "/plugins/" + PLUGIN_NAME + "/not-found")
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build());
+
+  /** Module for all HTTP bindings. */
+  static class MyPluginHttpModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      bind(TestRootCollection.class);
+
+      install(
+          new RestApiModule() {
+            @Override
+            public void configure() {
+              DynamicMap.mapOf(binder(), TEST_KIND);
+
+              postOnCollection(TEST_KIND).to(TestPostOnCollection.class);
+              post(TEST_KIND, "update").to(TestPost.class);
+              get(TEST_KIND, "detail").to(TestGet.class);
+            }
+          });
+
+      serveRegex("/(?:a/)?test-collection/(.*)$").with(TestRestApiServlet.class);
+    }
+  }
+
+  @Singleton
+  static class TestRestApiServlet extends RestApiServlet {
+    private static final long serialVersionUID = 1L;
+
+    @Inject
+    TestRestApiServlet(RestApiServlet.Globals globals, Provider<TestRootCollection> collection) {
+      super(globals, collection);
+    }
+
+    @Override
+    public void service(ServletRequest servletRequest, ServletResponse servletResponse)
+        throws ServletException, IOException {
+      // This is...unfortunate. HttpPluginServlet (and/or ContextMapper) doesn't properly set the
+      // servlet path on the wrapped request. Based on what RestApiServlet produces for non-plugin
+      // requests, it should be:
+      //   contextPath = "/plugins/checks"
+      //   servletPath = "/checkers/"
+      //   pathInfo = checkerUuid
+      // Instead it does:
+      //   contextPath = "/plugins/checks"
+      //   servletPath = ""
+      //   pathInfo = "/checkers/" + checkerUuid
+      // This results in RestApiServlet splitting the pathInfo into ["", "checkers", checkerUuid],
+      // and it passes the "" to CheckersCollection#parse, which understandably, but unfortunately,
+      // fails.
+      //
+      // This frankly seems like a bug that should be fixed, but it would quite likely break
+      // existing plugins in confusing ways. So, we work around it by introducing our own request
+      // wrapper with the correct paths.
+      HttpServletRequest req = (HttpServletRequest) servletRequest;
+      String pathInfo = req.getPathInfo();
+      String correctServletPath = "/test-collection/";
+      String fixedPathInfo = pathInfo.substring(correctServletPath.length());
+      HttpServletRequestWrapper wrapped =
+          new HttpServletRequestWrapper(req) {
+            @Override
+            public String getServletPath() {
+              return correctServletPath;
+            }
+
+            @Override
+            public String getPathInfo() {
+              return fixedPathInfo;
+            }
+          };
+
+      super.service(wrapped, (HttpServletResponse) servletResponse);
+    }
+  }
+
+  static class TestPluginResource implements RestResource {}
+
+  @Singleton
+  static class TestRootCollection implements ChildCollection<TopLevelResource, TestPluginResource> {
+    private final DynamicMap<RestView<TestPluginResource>> views;
+
+    @Inject
+    TestRootCollection(DynamicMap<RestView<TestPluginResource>> views) {
+      this.views = views;
+    }
+
+    @Override
+    public RestView<TopLevelResource> list() throws RestApiException {
+      return (RestReadView<TopLevelResource>)
+          resource -> Response.ok(ImmutableList.of("one", "two"));
+    }
+
+    @Override
+    public TestPluginResource parse(TopLevelResource parent, IdString id) throws Exception {
+      return new TestPluginResource();
+    }
+
+    @Override
+    public DynamicMap<RestView<TestPluginResource>> views() {
+      return views;
+    }
+  }
+
+  @Singleton
+  static class TestPostOnCollection
+      implements RestCollectionModifyView<TopLevelResource, TestPluginResource, String> {
+    @Override
+    public Response<String> apply(TopLevelResource parentResource, String input) throws Exception {
+      return Response.ok("test");
+    }
+  }
+
+  @Singleton
+  static class TestPost implements RestModifyView<TestPluginResource, String> {
+    @Override
+    public Response<String> apply(TestPluginResource resource, String input) throws Exception {
+      return Response.ok("test");
+    }
+  }
+
+  @Singleton
+  static class TestGet implements RestReadView<TestPluginResource> {
+    @Override
+    public Response<String> apply(TestPluginResource resource) throws Exception {
+      return Response.ok("test");
+    }
+  }
+
+  @Test
+  public void testEndpoints() throws Exception {
+    try (AutoCloseable ignored = installPlugin(PLUGIN_NAME, null, MyPluginHttpModule.class, null)) {
+      RestApiCallHelper.execute(adminRestSession, TEST_CALLS.asList());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginsRemoteAdminRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginsRemoteAdminRestApiBindingsIT.java
new file mode 100644
index 0000000..d60148e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginsRemoteAdminRestApiBindingsIT.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.binding;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
+import com.google.gerrit.extensions.restapi.RawInput;
+import org.junit.Test;
+
+/**
+ * Tests for checking the remote administration bindings of the plugins REST API.
+ *
+ * <p>These tests only verify that the plugin REST endpoints are correctly bound, they do no test
+ * the functionality of the plugin REST endpoints.
+ */
+public class PluginsRemoteAdminRestApiBindingsIT extends AbstractDaemonTest {
+  /**
+   * Plugin REST endpoints to be tested, each URL contains a placeholder for the plugin identifier.
+   */
+  private static final ImmutableList<RestCall> PLUGIN_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.put("/plugins/%s"),
+
+          // For GET requests prefixing the view name with 'gerrit~' is required.
+          RestCall.get("/plugins/%s/gerrit~status"),
+
+          // POST (and PUT) requests don't require the 'gerrit~' prefix in front of the view name.
+          RestCall.post("/plugins/%s/gerrit~enable"),
+          RestCall.post("/plugins/%s/gerrit~disable"),
+          RestCall.post("/plugins/%s/gerrit~reload"),
+
+          // Plugin deletion must be tested last
+          RestCall.delete("/plugins/%s"));
+
+  private static final String JS_PLUGIN = "Gerrit.install(function(self){});\n";
+  private static final RawInput JS_PLUGIN_CONTENT = RawInputUtil.create(JS_PLUGIN.getBytes(UTF_8));
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void pluginEndpoints() throws Exception {
+    String pluginName = "my-plugin";
+    installPlugin(pluginName);
+    RestApiCallHelper.execute(adminRestSession, PLUGIN_ENDPOINTS, pluginName);
+  }
+
+  private void installPlugin(String pluginName) throws Exception {
+    InstallPluginInput input = new InstallPluginInput();
+    input.raw = JS_PLUGIN_CONTENT;
+    gApi.plugins().install(pluginName + ".js", input);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
new file mode 100644
index 0000000..8969386
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -0,0 +1,261 @@
+// 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.binding;
+
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.rest.util.RestCall.Method.GET;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+import static com.google.gerrit.server.restapi.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the projects REST API.
+ *
+ * <p>These tests only verify that the project REST endpoints are correctly bound, they do no test
+ * the functionality of the project REST endpoints.
+ */
+public class ProjectsRestApiBindingsIT extends AbstractDaemonTest {
+  private static final ImmutableList<RestCall> PROJECT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s"),
+          RestCall.put("/projects/%s"),
+          RestCall.get("/projects/%s/description"),
+          RestCall.put("/projects/%s/description"),
+          RestCall.delete("/projects/%s/description"),
+          RestCall.get("/projects/%s/parent"),
+          RestCall.put("/projects/%s/parent"),
+          RestCall.get("/projects/%s/config"),
+          RestCall.put("/projects/%s/config"),
+          RestCall.get("/projects/%s/HEAD"),
+          RestCall.put("/projects/%s/HEAD"),
+          RestCall.get("/projects/%s/access"),
+          RestCall.post("/projects/%s/access"),
+          RestCall.put("/projects/%s/access:review"),
+          RestCall.get("/projects/%s/check.access"),
+          RestCall.put("/projects/%s/ban"),
+          RestCall.get("/projects/%s/statistics.git"),
+          RestCall.post("/projects/%s/index"),
+          RestCall.post("/projects/%s/gc"),
+          RestCall.get("/projects/%s/children"),
+          RestCall.get("/projects/%s/branches"),
+          RestCall.post("/projects/%s/branches:delete"),
+          RestCall.put("/projects/%s/branches/new-branch"),
+          RestCall.get("/projects/%s/tags"),
+          RestCall.post("/projects/%s/tags:delete"),
+          RestCall.put("/projects/%s/tags/new-tag"),
+          RestCall.builder(GET, "/projects/%s/commits")
+              // GET /projects/<project>/branches/<branch>/commits is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.get("/projects/%s/dashboards"));
+
+  /**
+   * Child project REST endpoints to be tested, each URL contains placeholders for the parent
+   * project identifier and the child project identifier.
+   */
+  private static final ImmutableList<RestCall> CHILD_PROJECT_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/projects/%s/children/%s"));
+
+  /**
+   * Branch REST endpoints to be tested, each URL contains placeholders for the project identifier
+   * and the branch identifier.
+   */
+  private static final ImmutableList<RestCall> BRANCH_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/branches/%s"),
+          RestCall.put("/projects/%s/branches/%s"),
+          RestCall.get("/projects/%s/branches/%s/mergeable"),
+          RestCall.builder(GET, "/projects/%s/branches/%s/reflog")
+              // The tests use DfsRepository which does not support getting the reflog.
+              .expectedResponseCode(SC_METHOD_NOT_ALLOWED)
+              .expectedMessage("reflog not supported on")
+              .build(),
+          RestCall.builder(GET, "/projects/%s/branches/%s/files")
+              // GET /projects/<project>/branches/<branch>/files is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+
+          // Branch deletion must be tested last
+          RestCall.delete("/projects/%s/branches/%s"));
+
+  /**
+   * Branch file REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier, the branch identifier and the file identifier.
+   */
+  private static final ImmutableList<RestCall> BRANCH_FILE_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/projects/%s/branches/%s/files/%s/content"));
+
+  /**
+   * Dashboard REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier and the dashboard identifier.
+   */
+  private static final ImmutableList<RestCall> DASHBOARD_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/dashboards/%s"),
+          RestCall.put("/projects/%s/dashboards/%s"),
+
+          // Dashboard deletion must be tested last
+          RestCall.delete("/projects/%s/dashboards/%s"));
+
+  /**
+   * Tag REST endpoints to be tested, each URL contains placeholders for the project identifier and
+   * the tag identifier.
+   */
+  private static final ImmutableList<RestCall> TAG_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/tags/%s"),
+          RestCall.put("/projects/%s/tags/%s"),
+          RestCall.delete("/projects/%s/tags/%s"));
+
+  /**
+   * Commit REST endpoints to be tested, each URL contains placeholders for the project identifier
+   * and the commit identifier.
+   */
+  private static final ImmutableList<RestCall> COMMIT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/commits/%s"),
+          RestCall.get("/projects/%s/commits/%s/in"),
+          RestCall.get("/projects/%s/commits/%s/files"),
+          RestCall.post("/projects/%s/commits/%s/cherrypick"));
+
+  /**
+   * Commit file REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier, the commit identifier and the file identifier.
+   */
+  private static final ImmutableList<RestCall> COMMIT_FILE_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/projects/%s/commits/%s/files/%s/content"));
+
+  private static final String FILENAME = "test.txt";
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void projectEndpoints() throws Exception {
+    RestApiCallHelper.execute(adminRestSession, PROJECT_ENDPOINTS, project.get());
+  }
+
+  @Test
+  public void childProjectEndpoints() throws Exception {
+    Project.NameKey childProject = projectOperations.newProject().parent(project).create();
+    RestApiCallHelper.execute(
+        adminRestSession, CHILD_PROJECT_ENDPOINTS, project.get(), childProject.get());
+  }
+
+  @Test
+  public void branchEndpoints() throws Exception {
+    RestApiCallHelper.execute(adminRestSession, BRANCH_ENDPOINTS, project.get(), "master");
+  }
+
+  @Test
+  public void branchFileEndpoints() throws Exception {
+    createAndSubmitChange(FILENAME);
+    RestApiCallHelper.execute(
+        adminRestSession, BRANCH_FILE_ENDPOINTS, project.get(), "master", FILENAME);
+  }
+
+  @Test
+  public void dashboardEndpoints() throws Exception {
+    createDefaultDashboard();
+    RestApiCallHelper.execute(
+        adminRestSession, DASHBOARD_ENDPOINTS, project.get(), DEFAULT_DASHBOARD_NAME);
+  }
+
+  @Test
+  public void tagEndpoints() throws Exception {
+    String tag = "test-tag";
+    gApi.projects().name(project.get()).tag(tag).create(new TagInput());
+    RestApiCallHelper.execute(adminRestSession, TAG_ENDPOINTS, project.get(), tag);
+  }
+
+  @Test
+  public void commitEndpoints() throws Exception {
+    String commit = createAndSubmitChange(FILENAME);
+    RestApiCallHelper.execute(adminRestSession, COMMIT_ENDPOINTS, project.get(), commit);
+  }
+
+  @Test
+  public void commitFileEndpoints() throws Exception {
+    String commit = createAndSubmitChange(FILENAME);
+    RestApiCallHelper.execute(
+        adminRestSession, COMMIT_FILE_ENDPOINTS, project.get(), commit, FILENAME);
+  }
+
+  private String createAndSubmitChange(String filename) throws Exception {
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("A change")
+            .parent(projectOperations.project(project).getHead("master"))
+            .add(filename, "content")
+            .insertChangeId()
+            .create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    gApi.changes().id(id).current().review(ReviewInput.approve());
+    gApi.changes().id(id).current().submit();
+    return c.name();
+  }
+
+  private void createDefaultDashboard() throws Exception {
+    String dashboardRef = REFS_DASHBOARDS + "team";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/meta/*").group(adminGroupUuid()))
+        .update();
+    gApi.projects().name(project.get()).branch(dashboardRef).create(new BranchInput());
+
+    try (Repository r = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(r)) {
+      TestRepository<Repository>.CommitBuilder cb = tr.branch(dashboardRef).commit();
+      StringBuilder content = new StringBuilder("[dashboard]\n");
+      content.append("title = ").append("Open Changes").append("\n");
+      content.append("[section \"").append("open").append("\"]\n");
+      content.append("query = ").append("is:open").append("\n");
+      cb.add("overview", content.toString());
+      cb.create();
+    }
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getProject().setLocalDefaultDashboard(dashboardRef + ":overview");
+      u.save();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/RootCollectionsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/RootCollectionsRestApiBindingsIT.java
new file mode 100644
index 0000000..6d140c6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/RootCollectionsRestApiBindingsIT.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.acceptance.rest.binding;
+
+import static com.google.gerrit.acceptance.rest.util.RestApiCallHelper.execute;
+import static com.google.gerrit.acceptance.rest.util.RestCall.Method.GET;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the root REST API.
+ *
+ * <p>These tests only verify that the root REST endpoints are correctly bound, they do no test the
+ * functionality of the root REST endpoints.
+ */
+public class RootCollectionsRestApiBindingsIT extends AbstractDaemonTest {
+  /** Root REST endpoints to be tested, the URLs contain no placeholders. */
+  private static final ImmutableList<RestCall> ROOT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/access/"),
+          RestCall.get("/accounts/"),
+          RestCall.put("/accounts/new-account"),
+          RestCall.builder(GET, "/config/")
+              // GET /config/ is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.get("/changes/"),
+          RestCall.post("/changes/"),
+          RestCall.get("/groups/"),
+          RestCall.put("/groups/new-group"),
+          RestCall.get("/plugins/"),
+          RestCall.put("/plugins/new-plugin"),
+          RestCall.get("/projects/"),
+          RestCall.put("/projects/new-project"));
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void rootEndpoints() throws Exception {
+    execute(adminRestSession, ROOT_ENDPOINTS);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 61a2d84..c2df9ca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -16,17 +16,25 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.Objects.requireNonNull;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 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;
@@ -36,8 +44,9 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -60,7 +69,7 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
@@ -72,15 +81,15 @@
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.Submit;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
 import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -115,14 +124,13 @@
   }
 
   @Inject private ApprovalsUtil approvalsUtil;
-
+  @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private Submit submitHandler;
 
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-
-  @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
   private RegistrationHandle onSubmitValidatorHandle;
-
   private String systemTimeZone;
 
   @Before
@@ -138,11 +146,6 @@
   }
 
   @After
-  public void cleanup() {
-    db.close();
-  }
-
-  @After
   public void removeOnSubmitValidator() {
     if (onSubmitValidatorHandle != null) {
       onSubmitValidatorHandle.remove();
@@ -153,25 +156,26 @@
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void submitToEmptyRepo() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitToEmptyRepo() throws Throwable {
+    assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
-    RevCommit headAfterSubmitPreview = getRemoteHead();
-    assertThat(headAfterSubmitPreview).isEqualTo(initialHead);
+    assertThat(change.getCommit().getParents()).isEmpty();
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change.getCommit());
     assertTrees(project, actual);
   }
 
   @Test
-  public void submitSingleChange() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitSingleChange() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
@@ -189,13 +193,13 @@
   }
 
   @Test
-  public void submitMultipleChangesOtherMergeConflictPreview() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChangesOtherMergeConflictPreview() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
@@ -229,7 +233,7 @@
           break;
         case REBASE_IF_NECESSARY:
         case REBASE_ALWAYS:
-          String change2hash = change2.getChange().currentPatchSet().getRevision().get();
+          String change2hash = change2.getChange().currentPatchSet().commitId().name();
           assertThat(e.getMessage())
               .isEqualTo(
                   "Cannot rebase "
@@ -261,11 +265,11 @@
           break;
         case CHERRY_PICK:
         default:
-          fail("Should not reach here.");
+          assertWithMessage("Should not reach here.").fail();
           break;
       }
 
-      RevCommit headAfterSubmit = getRemoteHead();
+      RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
       assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
       assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
       assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
@@ -273,19 +277,19 @@
   }
 
   @Test
-  public void submitMultipleChangesPreview() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChangesPreview() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
     PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
     // change 2 is not approved, but we ignore labels
     approve(change3.getChangeId());
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
     Map<String, Map<String, Integer>> expected = new HashMap<>();
     expected.put(project.get(), new HashMap<>());
     expected.get(project.get()).put("refs/heads/master", 3);
 
-    assertThat(actual).containsKey(new Branch.NameKey(project, "refs/heads/master"));
+    assertThat(actual).containsKey(BranchNameKey.create(project, "refs/heads/master"));
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
       // CherryPick ignores dependencies, thus only change and destination
       // branch refs are modified.
@@ -299,7 +303,7 @@
     }
 
     // check that the submit preview did not actually submit
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
@@ -311,13 +315,17 @@
   }
 
   @Test
-  public void submitNoPermission() throws Exception {
+  public void submitNoPermission() throws Throwable {
     // create project where submit is blocked
-    Project.NameKey p = createProject("p");
-    block(p, "refs/*", Permission.SUBMIT, REGISTERED_USERS);
+    Project.NameKey p = projectOperations.newProject().create();
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
     PushOneCommit.Result result = push.to("refs/for/master");
     result.assertOkStatus();
 
@@ -325,66 +333,68 @@
   }
 
   @Test
-  public void noSelfSubmit() throws Exception {
+  public void noSelfSubmit() throws Throwable {
     // create project where submit is blocked for the change owner
-    Project.NameKey p = createProject("p");
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.block(u.getConfig(), Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    Project.NameKey p = projectOperations.newProject().create();
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
     PushOneCommit.Result result = push.to("refs/for/master");
     result.assertOkStatus();
 
     ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    assertThat(change.owner._accountId).isEqualTo(admin.id().get());
 
     submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     submit(result.getChangeId());
   }
 
   @Test
-  public void onlySelfSubmit() throws Exception {
+  public void onlySelfSubmit() throws Throwable {
     // create project where only the change owner can submit
-    Project.NameKey p = createProject("p");
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.block(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    Project.NameKey p = projectOperations.newProject().create();
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
     PushOneCommit.Result result = push.to("refs/for/master");
     result.assertOkStatus();
 
     ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    assertThat(change.owner._accountId).isEqualTo(admin.id().get());
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     submit(result.getChangeId());
   }
 
   @Test
-  public void submitWholeTopicMultipleProjects() throws Exception {
+  public void submitWholeTopicMultipleProjects() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
 
     // Create test projects
-    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
-    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
+    Project.NameKey keyA = createProjectForPush(getSubmitType());
+    TestRepository<?> repoA = cloneProject(keyA);
+    Project.NameKey keyB = createProjectForPush(getSubmitType());
+    TestRepository<?> repoB = cloneProject(keyB);
 
     // Create changes on project-a
     PushOneCommit.Result change1 =
@@ -412,20 +422,20 @@
   }
 
   @Test
-  public void submitWholeTopicMultipleBranchesOnSameProject() throws Exception {
+  public void submitWholeTopicMultipleBranchesOnSameProject() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
 
     // Create test project
-    String projectName = "project-a";
-    TestRepository<?> repoA = createProjectWithPush(projectName, null, getSubmitType());
+    Project.NameKey keyA = createProjectForPush(getSubmitType());
+    TestRepository<?> repoA = cloneProject(keyA);
 
-    RevCommit initialHead = getRemoteHead(new Project.NameKey(name(projectName)), "master");
+    RevCommit initialHead = projectOperations.project(keyA).getHead("master");
 
     // Create the dev branch on the test project
     BranchInput in = new BranchInput();
     in.revision = initialHead.name();
-    gApi.projects().name(name(projectName)).branch("dev").create(in);
+    gApi.projects().name(keyA.get()).branch("dev").create(in);
 
     // Create changes on master
     PushOneCommit.Result change1 =
@@ -454,7 +464,7 @@
   }
 
   @Test
-  public void submitWholeTopic() throws Exception {
+  public void submitWholeTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic);
@@ -485,14 +495,89 @@
     assertThat(log).hasSize(expectedCommitCount);
 
     assertThat(commitsInRepo)
-        .containsAllOf("Initial empty repository", "Change 1", "Change 2", "Change 3");
+        .containsAtLeast("Initial empty repository", "Change 1", "Change 2", "Change 3");
     if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
       assertThat(commitsInRepo).contains("Merge changes from topic \"" + expectedTopic + "\"");
     }
   }
 
   @Test
-  public void submitReusingOldTopic() throws Exception {
+  public void submitWholeTopicWithMultipleTopics() throws Throwable {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic1 = "test-topic-1";
+    String topic2 = "test-topic-2";
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic1);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "content", topic1);
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "content", topic2);
+    PushOneCommit.Result change4 = createChange("Change 4", "d.txt", "content", topic2);
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change4.getChangeId());
+    String expectedTopic1 = name(topic1);
+    String expectedTopic2 = name(topic2);
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      change1.assertChange(Change.Status.NEW, expectedTopic1, admin);
+      change2.assertChange(Change.Status.NEW, expectedTopic1, admin);
+
+    } else {
+      change1.assertChange(Change.Status.MERGED, expectedTopic1, admin);
+      change2.assertChange(Change.Status.MERGED, expectedTopic1, admin);
+    }
+
+    // Check for the exact change to have the correct submitter.
+    assertSubmitter(change4);
+    // Also check submitters for changes submitted via the topic relationship.
+    assertSubmitter(change3);
+    if (getSubmitType() != SubmitType.CHERRY_PICK) {
+      assertSubmitter(change1);
+      assertSubmitter(change2);
+    }
+
+    // Check that the repo has the expected commits
+    List<RevCommit> log = getRemoteLog();
+    List<String> commitsInRepo = log.stream().map(RevCommit::getShortMessage).collect(toList());
+    int expectedCommitCount;
+    switch (getSubmitType()) {
+      case MERGE_ALWAYS:
+        // initial commit + 4 commits + merge commit
+        expectedCommitCount = 6;
+        break;
+      case CHERRY_PICK:
+        // initial commit + 2 commits
+        expectedCommitCount = 3;
+        break;
+      case FAST_FORWARD_ONLY:
+      case INHERIT:
+      case MERGE_IF_NECESSARY:
+      case REBASE_ALWAYS:
+      case REBASE_IF_NECESSARY:
+      default:
+        // initial commit + 4 commits
+        expectedCommitCount = 5;
+        break;
+    }
+    assertThat(log).hasSize(expectedCommitCount);
+
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      assertThat(commitsInRepo).containsAtLeast("Initial empty repository", "Change 3", "Change 4");
+      assertThat(commitsInRepo).doesNotContain("Change 1");
+      assertThat(commitsInRepo).doesNotContain("Change 2");
+    } else if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
+      assertThat(commitsInRepo)
+          .contains(
+              String.format(
+                  "Merge changes from topics \"%s\", \"%s\"", expectedTopic1, expectedTopic2));
+    } else {
+      assertThat(commitsInRepo)
+          .containsAtLeast(
+              "Initial empty repository", "Change 1", "Change 2", "Change 3", "Change 4");
+    }
+  }
+
+  @Test
+  public void submitReusingOldTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     String topic = "test-topic";
@@ -523,13 +608,13 @@
   }
 
   private void assertSubmittedTogether(String changeId, Iterable<String> expected)
-      throws Exception {
+      throws Throwable {
     assertThat(gApi.changes().id(changeId).submittedTogether().stream().map(i -> i.changeId))
         .containsExactlyElementsIn(expected);
   }
 
   @Test
-  public void submitWorkInProgressChange() throws Exception {
+  public void submitWorkInProgressChange() throws Throwable {
     PushOneCommit.Result change = pushTo("refs/for/master%wip");
     Change.Id num = change.getChange().getId();
     submitWithConflict(
@@ -543,15 +628,19 @@
   }
 
   @Test
-  public void submitWithHiddenBranchInSameTopic() throws Exception {
+  public void submitWithHiddenBranchInSameTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     PushOneCommit.Result visible = createChange("refs/for/master/" + name("topic"));
     Change.Id num = visible.getChange().getId();
 
-    createBranch(new Branch.NameKey(project, "hidden"));
+    createBranch(BranchNameKey.create(project, "hidden"));
     PushOneCommit.Result hidden = createChange("refs/for/hidden/" + name("topic"));
     approve(hidden.getChangeId());
-    blockRead("refs/heads/hidden");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/hidden").group(REGISTERED_USERS))
+        .update();
 
     submit(
         visible.getChangeId(),
@@ -561,7 +650,7 @@
   }
 
   @Test
-  public void submitChangeWhenParentOfOtherBranchTip() throws Exception {
+  public void submitChangeWhenParentOfOtherBranchTip() throws Throwable {
     // Chain of two commits
     // Push both to topic-branch
     // Push the first commit for review and submit
@@ -582,13 +671,12 @@
     }
 
     PushOneCommit push1 =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+        pushFactory.create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     PushOneCommit.Result c1 = push1.to("refs/heads/topic");
     c1.assertOkStatus();
     PushOneCommit push2 =
         pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+            admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     PushOneCommit.Result c2 = push2.to("refs/heads/topic");
     c2.assertOkStatus();
 
@@ -600,7 +688,7 @@
   }
 
   @Test
-  public void submitMergeOfNonChangeBranchTip() throws Exception {
+  public void submitMergeOfNonChangeBranchTip() throws Throwable {
     // Merge a branch with commits that have not been submitted as
     // changes.
     //
@@ -610,13 +698,12 @@
     // | /
     // I   -- master
     //
-    RevCommit master = getRemoteHead(project, "master");
+    RevCommit master = projectOperations.project(project).getHead("master");
     PushOneCommit stableTip =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, "Tip of branch stable", "stable.txt", "");
+        pushFactory.create(admin.newIdent(), testRepo, "Tip of branch stable", "stable.txt", "");
     PushOneCommit.Result stable = stableTip.to("refs/heads/stable");
     PushOneCommit mergeCommit =
-        pushFactory.create(db, admin.getIdent(), testRepo, "The merge commit", "merge.txt", "");
+        pushFactory.create(admin.newIdent(), testRepo, "The merge commit", "merge.txt", "");
     mergeCommit.setParents(ImmutableList.of(master, stable.getCommit()));
     PushOneCommit.Result mergeReview = mergeCommit.to("refs/for/master");
     approve(mergeReview.getChangeId());
@@ -628,7 +715,7 @@
   }
 
   @Test
-  public void submitMergeOfNonChangeBranchNonTip() throws Exception {
+  public void submitMergeOfNonChangeBranchNonTip() throws Throwable {
     // Merge a branch with commits that have not been submitted as
     // changes.
     //
@@ -639,15 +726,15 @@
     // | /
     // I -- master
     //
-    RevCommit initial = getRemoteHead(project, "master");
+    RevCommit initial = projectOperations.project(project).getHead("master");
     // push directly to stable to S1
     PushOneCommit.Result s1 =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "new commit into stable", "stable1.txt", "")
+            .create(admin.newIdent(), testRepo, "new commit into stable", "stable1.txt", "")
             .to("refs/heads/stable");
     // move the stable tip ahead to S2
     pushFactory
-        .create(db, admin.getIdent(), testRepo, "Tip of branch stable", "stable2.txt", "")
+        .create(admin.newIdent(), testRepo, "Tip of branch stable", "stable2.txt", "")
         .to("refs/heads/stable");
 
     testRepo.reset(initial);
@@ -655,12 +742,12 @@
     // move the master ahead
     PushOneCommit.Result m =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "Move master ahead", "master.txt", "")
+            .create(admin.newIdent(), testRepo, "Move master ahead", "master.txt", "")
             .to("refs/heads/master");
 
     // create merge change
     PushOneCommit mc =
-        pushFactory.create(db, admin.getIdent(), testRepo, "The merge commit", "merge.txt", "");
+        pushFactory.create(admin.newIdent(), testRepo, "The merge commit", "merge.txt", "");
     mc.setParents(ImmutableList.of(m.getCommit(), s1.getCommit()));
     PushOneCommit.Result mergeReview = mc.to("refs/for/master");
     approve(mergeReview.getChangeId());
@@ -672,11 +759,11 @@
   }
 
   @Test
-  public void submitChangeWithCommitThatWasAlreadyMerged() throws Exception {
+  public void submitChangeWithCommitThatWasAlreadyMerged() throws Throwable {
     // create and submit a change
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     // set the status of the change back to NEW to simulate a failed submit that
     // merged the commit but failed to update the change status
@@ -685,11 +772,11 @@
     // submitting the change again should detect that the commit was already
     // merged and just fix the change status to be MERGED
     submit(change.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterSubmit);
   }
 
   @Test
-  public void submitChangesWithCommitsThatWereAlreadyMerged() throws Exception {
+  public void submitChangesWithCommitsThatWereAlreadyMerged() throws Throwable {
     // create and submit 2 changes
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
@@ -699,7 +786,7 @@
     }
     submit(change2.getChangeId());
     assertMerged(change1.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     // set the status of the changes back to NEW to simulate a failed submit that
     // merged the commits but failed to update the change status
@@ -709,11 +796,11 @@
     // merged and just fix the change status to be MERGED
     submit(change1.getChangeId());
     submit(change2.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterSubmit);
   }
 
   @Test
-  public void submitTopicWithCommitsThatWereAlreadyMerged() throws Exception {
+  public void submitTopicWithCommitsThatWereAlreadyMerged() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     // create and submit 2 changes with the same topic
@@ -723,7 +810,7 @@
     approve(change1.getChangeId());
     submit(change2.getChangeId());
     assertMerged(change1.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     // set the status of the second change back to NEW to simulate a failed
     // submit that merged the commits but failed to update the change status of
@@ -733,25 +820,22 @@
     // submitting the topic again should detect that the commits were already
     // merged and just fix the change status to be MERGED
     submit(change2.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterSubmit);
   }
 
   @Test
-  public void submitWithValidation() throws Exception {
+  public void submitWithValidation() throws Throwable {
     AtomicBoolean called = new AtomicBoolean(false);
     this.addOnSubmitValidationListener(
-        new OnSubmitValidationListener() {
-          @Override
-          public void preBranchUpdate(Arguments args) throws ValidationException {
-            called.set(true);
-            HashSet<String> refs = Sets.newHashSet(args.getCommands().keySet());
-            assertThat(refs).contains("refs/heads/master");
-            refs.remove("refs/heads/master");
-            if (!refs.isEmpty()) {
-              // Some submit strategies need to insert new patchset.
-              assertThat(refs).hasSize(1);
-              assertThat(refs.iterator().next()).startsWith(RefNames.REFS_CHANGES);
-            }
+        args -> {
+          called.set(true);
+          HashSet<String> refs = Sets.newHashSet(args.getCommands().keySet());
+          assertThat(refs).contains("refs/heads/master");
+          refs.remove("refs/heads/master");
+          if (!refs.isEmpty()) {
+            // Some submit strategies need to insert new patchset.
+            assertThat(refs).hasSize(1);
+            assertThat(refs.iterator().next()).startsWith(RefNames.REFS_CHANGES);
           }
         });
 
@@ -762,13 +846,15 @@
   }
 
   @Test
-  public void submitWithValidationMultiRepo() throws Exception {
+  public void submitWithValidationMultiRepo() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
 
     // Create test projects
-    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
-    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
+    Project.NameKey keyA = createProjectForPush(getSubmitType());
+    TestRepository<?> repoA = cloneProject(keyA);
+    Project.NameKey keyB = createProjectForPush(getSubmitType());
+    TestRepository<?> repoB = cloneProject(keyB);
 
     // Create changes on project-a
     PushOneCommit.Result change1 =
@@ -792,55 +878,50 @@
     // succeed.
     List<String> projectsCalled = new ArrayList<>(4);
     this.addOnSubmitValidationListener(
-        new OnSubmitValidationListener() {
-          @Override
-          public void preBranchUpdate(Arguments args) throws ValidationException {
-            String master = "refs/heads/master";
-            assertThat(args.getCommands()).containsKey(master);
-            ReceiveCommand cmd = args.getCommands().get(master);
-            ObjectId newMasterId = cmd.getNewId();
-            try (Repository repo = repoManager.openRepository(args.getProject())) {
-              assertThat(repo.exactRef(master).getObjectId()).isEqualTo(cmd.getOldId());
-              assertThat(args.getRef(master)).hasValue(newMasterId);
-              args.getRevWalk().parseBody(args.getRevWalk().parseCommit(newMasterId));
-            } catch (IOException e) {
-              throw new AssertionError("failed checking new ref value", e);
-            }
-            projectsCalled.add(args.getProject().get());
-            if (projectsCalled.size() == 2) {
-              throw new ValidationException("time to fail");
-            }
+        args -> {
+          String master = "refs/heads/master";
+          assertThat(args.getCommands()).containsKey(master);
+          ReceiveCommand cmd = args.getCommands().get(master);
+          ObjectId newMasterId = cmd.getNewId();
+          try (Repository repo = repoManager.openRepository(args.getProject())) {
+            assertThat(repo.exactRef(master).getObjectId()).isEqualTo(cmd.getOldId());
+            assertThat(args.getRef(master)).hasValue(newMasterId);
+            args.getRevWalk().parseBody(args.getRevWalk().parseCommit(newMasterId));
+          } catch (IOException e) {
+            throw new AssertionError("failed checking new ref value", e);
+          }
+          projectsCalled.add(args.getProject().get());
+          if (projectsCalled.size() == 2) {
+            throw new ValidationException("time to fail");
           }
         });
     submitWithConflict(change4.getChangeId(), "time to fail");
-    assertThat(projectsCalled).containsExactly(name("project-a"), name("project-b"));
+    assertThat(projectsCalled).containsExactly(keyA.get(), keyB.get());
     for (PushOneCommit.Result change : changes) {
       change.assertChange(Change.Status.NEW, name(topic), admin);
     }
 
     submit(change4.getChangeId());
-    assertThat(projectsCalled)
-        .containsExactly(
-            name("project-a"), name("project-b"), name("project-a"), name("project-b"));
+    assertThat(projectsCalled).containsExactly(keyA.get(), keyB.get(), keyA.get(), keyB.get());
     for (PushOneCommit.Result change : changes) {
       change.assertChange(Change.Status.MERGED, name(topic), admin);
     }
   }
 
   @Test
-  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
+  public void submitWithCommitAndItsMergeCommitTogether() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // Create a stable branch and bootstrap it.
     gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
     PushOneCommit push =
-        pushFactory.create(db, user.getIdent(), testRepo, "initial commit", "a.txt", "a");
+        pushFactory.create(user.newIdent(), testRepo, "initial commit", "a.txt", "a");
     PushOneCommit.Result change = push.to("refs/heads/stable");
 
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
+    RevCommit stable = projectOperations.project(project).getHead("stable");
+    RevCommit master = projectOperations.project(project).getHead("master");
 
     assertThat(master).isEqualTo(initialHead);
     assertThat(stable).isEqualTo(change.getCommit());
@@ -893,15 +974,13 @@
     assertMerged(mergeId);
     testRepo.git().fetch().call();
     RevWalk rw = testRepo.getRevWalk();
-    master = rw.parseCommit(getRemoteHead(project, "master"));
+    master = rw.parseCommit(projectOperations.project(project).getHead("master"));
     assertThat(rw.isMergedInto(merge, master)).isTrue();
     assertThat(rw.isMergedInto(fix, master)).isTrue();
   }
 
   @Test
-  public void retrySubmitSingleChangeOnLockFailure() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-
+  public void retrySubmitSingleChangeOnLockFailure() throws Throwable {
     PushOneCommit.Result change = createChange();
     String id = change.getChangeId();
     approve(id);
@@ -918,7 +997,7 @@
 
     testRepo.git().fetch().call();
     RevWalk rw = testRepo.getRevWalk();
-    RevCommit master = rw.parseCommit(getRemoteHead(project, "master"));
+    RevCommit master = rw.parseCommit(projectOperations.project(project).getHead("master"));
     RevCommit patchSet = parseCurrentRevision(rw, change.getChangeId());
     assertThat(rw.isMergedInto(patchSet, master)).isTrue();
 
@@ -926,14 +1005,15 @@
   }
 
   @Test
-  public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+  public void retrySubmitAfterTornTopicOnLockFailure() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     String topic = "test-topic";
 
-    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
-    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
+    Project.NameKey keyA = createProjectForPush(getSubmitType());
+    Project.NameKey keyB = createProjectForPush(getSubmitType());
+    TestRepository<?> repoA = cloneProject(keyA);
+    TestRepository<?> repoB = cloneProject(keyB);
 
     PushOneCommit.Result change1 =
         createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
@@ -960,13 +1040,13 @@
 
     repoA.git().fetch().call();
     RevWalk rwA = repoA.getRevWalk();
-    RevCommit masterA = rwA.parseCommit(getRemoteHead(name("project-a"), "master"));
+    RevCommit masterA = rwA.parseCommit(projectOperations.project(keyA).getHead("master"));
     RevCommit change1Ps = parseCurrentRevision(rwA, change1.getChangeId());
     assertThat(rwA.isMergedInto(change1Ps, masterA)).isTrue();
 
     repoB.git().fetch().call();
     RevWalk rwB = repoB.getRevWalk();
-    RevCommit masterB = rwB.parseCommit(getRemoteHead(name("project-b"), "master"));
+    RevCommit masterB = rwB.parseCommit(projectOperations.project(keyB).getHead("master"));
     RevCommit change2Ps = parseCurrentRevision(rwB, change2.getChangeId());
     assertThat(rwB.isMergedInto(change2Ps, masterB)).isTrue();
 
@@ -974,14 +1054,14 @@
   }
 
   @Test
-  public void authorAndCommitDateAreEqual() throws Exception {
+  public void authorAndCommitDateAreEqual() throws Throwable {
     assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     ConfigInput ci = new ConfigInput();
     ci.matchAuthorToCommitterDate = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(ci);
 
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
 
@@ -995,12 +1075,12 @@
     }
 
     submit(change2.getChangeId());
-    assertAuthorAndCommitDateEquals(getRemoteHead());
+    assertAuthorAndCommitDateEquals(projectOperations.project(project).getHead("master"));
   }
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
-  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitAllowed() throws Throwable {
     assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
@@ -1017,7 +1097,7 @@
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitNotAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitNotAllowed() throws Throwable {
     assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
@@ -1030,18 +1110,20 @@
     ChangeApi revert2 = gApi.changes().id(change.getChangeId()).revert();
     approve(revert2.id());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Change "
-            + revert2.get()._number
-            + ": Change could not be merged because the commit is empty. "
-            + "Project policy requires all commits to contain modifications to at least one file.");
-    revert2.current().submit();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revert2.current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Change "
+                + revert2.get()._number
+                + ": Change could not be merged because the commit is empty. Project policy"
+                + " requires all commits to contain modifications to at least one file.");
   }
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
-  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitAllowed() throws Throwable {
     ChangeInput ci = new ChangeInput();
     ci.subject = "Empty change";
     ci.project = project.get();
@@ -1053,7 +1135,7 @@
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitNotAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitNotAllowed() throws Throwable {
     ChangeInput ci = new ChangeInput();
     ci.subject = "Empty change";
     ci.project = project.get();
@@ -1061,24 +1143,63 @@
     ChangeApi change = gApi.changes().create(ci);
     approve(change.id());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Change "
-            + change.get()._number
-            + ": Change could not be merged because the commit is empty. "
-            + "Project policy requires all commits to contain modifications to at least one file.");
-    change.current().submit();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> change.current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Change "
+                + change.get()._number
+                + ": Change could not be merged because the commit is empty. Project policy"
+                + " requires all commits to contain modifications to at least one file.");
   }
 
-  private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
+  @Test
+  @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitNonemptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Throwable {
+    assertThat(projectOperations.project(project).hasHead("master")).isFalse();
+    PushOneCommit.Result change = createChange();
+    assertThat(change.getCommit().getParents()).isEmpty();
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    assertThat(projectOperations.project(project).hasHead("master")).isFalse();
+    assertThat(actual).hasSize(1);
+
+    submit(change.getChangeId());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitEmptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Throwable {
+    assertThat(projectOperations.project(project).hasHead("master")).isFalse();
+    PushOneCommit.Result change =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Change 1", ImmutableMap.of())
+            .to("refs/for/master");
+    change.assertOkStatus();
+    assertThat(change.getCommit().getTree()).isEqualTo(EMPTY_TREE_ID);
+
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    assertThat(projectOperations.project(project).hasHead("master")).isFalse();
+    assertThat(actual).hasSize(1);
+
+    submit(change.getChangeId());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
+  private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
-          batchUpdateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
+          batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.nowTs())) {
         bu.addOp(
             change.getChange().getId(),
             new BatchUpdateOp() {
               @Override
-              public boolean updateChange(ChangeContext ctx) throws OrmException {
+              public boolean updateChange(ChangeContext ctx) {
                 ctx.getChange().setStatus(Change.Status.NEW);
                 ctx.getUpdate(ctx.getChange().currentPatchSetId()).setStatus(Change.Status.NEW);
                 return true;
@@ -1089,7 +1210,7 @@
     }
   }
 
-  private void assertSubmitter(PushOneCommit.Result change) throws Exception {
+  private void assertSubmitter(PushOneCommit.Result change) throws Throwable {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
     Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
@@ -1112,102 +1233,86 @@
     }
   }
 
-  protected void submit(String changeId) throws Exception {
+  protected void submit(String changeId) throws Throwable {
     submit(changeId, new SubmitInput(), null, null);
   }
 
-  protected void submit(String changeId, SubmitInput input) throws Exception {
+  protected void submit(String changeId, SubmitInput input) throws Throwable {
     submit(changeId, input, null, null);
   }
 
-  protected void submitWithConflict(String changeId, String expectedError) throws Exception {
+  protected void submitWithConflict(String changeId, String expectedError) throws Throwable {
     submit(changeId, new SubmitInput(), ResourceConflictException.class, expectedError);
   }
 
   protected void submit(
       String changeId,
       SubmitInput input,
-      Class<? extends RestApiException> expectedExceptionType,
+      @Nullable Class<? extends RestApiException> expectedExceptionType,
       String expectedExceptionMsg)
-      throws Exception {
+      throws Throwable {
     approve(changeId);
     if (expectedExceptionType == null) {
       assertSubmittable(changeId);
+    } else {
+      requireNonNull(expectedExceptionMsg);
     }
-    try {
-      gApi.changes().id(changeId).current().submit(input);
-      if (expectedExceptionType != null) {
-        fail("Expected exception of type " + expectedExceptionType.getSimpleName());
-      }
-    } catch (RestApiException e) {
-      if (expectedExceptionType == null) {
-        throw e;
-      }
-      // More verbose than using assertThat and/or ExpectedException, but gives
-      // us the stack trace.
-      if (!expectedExceptionType.isAssignableFrom(e.getClass())
-          || !e.getMessage().equals(expectedExceptionMsg)) {
-        throw new AssertionError(
-            "Expected exception of type "
-                + expectedExceptionType.getSimpleName()
-                + " with message: \""
-                + expectedExceptionMsg
-                + "\" but got exception of type "
-                + e.getClass().getSimpleName()
-                + " with message \""
-                + e.getMessage()
-                + "\"",
-            e);
-      }
+    ThrowingRunnable submit = () -> gApi.changes().id(changeId).current().submit(input);
+    if (expectedExceptionType != null) {
+      RestApiException thrown = assertThrows(expectedExceptionType, submit);
+      assertThat(thrown).hasMessageThat().isEqualTo(expectedExceptionMsg);
       return;
     }
+    submit.run();
     ChangeInfo change = gApi.changes().id(changeId).info();
     assertMerged(change.changeId);
   }
 
-  protected void assertSubmittable(String changeId) throws Exception {
-    assertThat(get(changeId, SUBMITTABLE).submittable).named("submit bit on ChangeInfo").isTrue();
+  protected void assertSubmittable(String changeId) throws Throwable {
+    assertWithMessage("submit bit on ChangeInfo")
+        .that(get(changeId, SUBMITTABLE).submittable)
+        .isTrue();
     RevisionResource rsrc = parseCurrentRevisionResource(changeId);
     UiAction.Description desc = submitHandler.getDescription(rsrc);
-    assertThat(desc.isVisible()).named("visible bit on submit action").isTrue();
-    assertThat(desc.isEnabled()).named("enabled bit on submit action").isTrue();
+    assertWithMessage("visible bit on submit action").that(desc.isVisible()).isTrue();
+    assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isTrue();
   }
 
-  protected void assertChangeMergedEvents(String... expected) throws Exception {
+  protected void assertChangeMergedEvents(String... expected) throws Throwable {
     eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
   }
 
-  protected void assertRefUpdatedEvents(RevCommit... expected) throws Exception {
+  protected void assertRefUpdatedEvents(RevCommit... expected) throws Throwable {
     eventRecorder.assertRefUpdatedEvents(project.get(), "refs/heads/master", expected);
   }
 
   protected void assertCurrentRevision(String changeId, int expectedNum, ObjectId expectedId)
-      throws Exception {
+      throws Throwable {
     ChangeInfo c = get(changeId, CURRENT_REVISION);
     assertThat(c.currentRevision).isEqualTo(expectedId.name());
     assertThat(c.revisions.get(expectedId.name())._number).isEqualTo(expectedNum);
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(c.project))) {
-      String refName = new PatchSet.Id(new Change.Id(c._number), expectedNum).toRefName();
+    try (Repository repo = repoManager.openRepository(Project.nameKey(c.project))) {
+      String refName = PatchSet.id(Change.id(c._number), expectedNum).toRefName();
       Ref ref = repo.exactRef(refName);
-      assertThat(ref).named(refName).isNotNull();
+      assertWithMessage(refName).that(ref).isNotNull();
       assertThat(ref.getObjectId()).isEqualTo(expectedId);
     }
   }
 
-  protected void assertNew(String changeId) throws Exception {
+  protected void assertNew(String changeId) throws Throwable {
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
   }
 
-  protected void assertApproved(String changeId) throws Exception {
+  protected void assertApproved(String changeId) throws Throwable {
     assertApproved(changeId, admin);
   }
 
-  protected void assertApproved(String changeId, TestAccount user) throws Exception {
+  protected void assertApproved(String changeId, TestAccount user) throws Throwable {
     ChangeInfo c = get(changeId, DETAILED_LABELS);
     LabelInfo cr = c.labels.get("Code-Review");
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(2);
-    assertThat(new Account.Id(cr.all.get(0)._accountId)).isEqualTo(user.getId());
+    assertThat(Account.id(cr.all.get(0)._accountId)).isEqualTo(user.id());
   }
 
   protected void assertMerged(String changeId) throws RestApiException {
@@ -1227,40 +1332,40 @@
         .isEqualTo(commit.getCommitterIdent().getTimeZone());
   }
 
-  protected void assertSubmitter(String changeId, int psId) throws Exception {
+  protected void assertSubmitter(String changeId, int psId) throws Throwable {
     assertSubmitter(changeId, psId, admin);
   }
 
-  protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Exception {
+  protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Throwable {
     Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
-    ChangeNotes cn = notesFactory.createChecked(db, c);
+    ChangeNotes cn = notesFactory.createChecked(c);
     PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(db, cn, new PatchSet.Id(cn.getChangeId(), psId));
+        approvalsUtil.getSubmitter(cn, PatchSet.id(cn.getChangeId(), psId));
     assertThat(submitter).isNotNull();
     assertThat(submitter.isLegacySubmit()).isTrue();
-    assertThat(submitter.getAccountId()).isEqualTo(user.getId());
+    assertThat(submitter.accountId()).isEqualTo(user.id());
   }
 
-  protected void assertNoSubmitter(String changeId, int psId) throws Exception {
+  protected void assertNoSubmitter(String changeId, int psId) throws Throwable {
     Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
-    ChangeNotes cn = notesFactory.createChecked(db, c);
+    ChangeNotes cn = notesFactory.createChecked(c);
     PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(db, cn, new PatchSet.Id(cn.getChangeId(), psId));
+        approvalsUtil.getSubmitter(cn, PatchSet.id(cn.getChangeId(), psId));
     assertThat(submitter).isNull();
   }
 
   protected void assertCherryPick(TestRepository<?> testRepo, boolean contentMerge)
-      throws Exception {
+      throws Throwable {
     assertRebase(testRepo, contentMerge);
-    RevCommit remoteHead = getRemoteHead();
+    RevCommit remoteHead = projectOperations.project(project).getHead("master");
     assertThat(remoteHead.getFooterLines("Reviewed-On")).isNotEmpty();
     assertThat(remoteHead.getFooterLines("Reviewed-By")).isNotEmpty();
   }
 
-  protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge) throws Exception {
+  protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge) throws Throwable {
     Repository repo = testRepo.getRepository();
-    RevCommit localHead = getHead(repo);
-    RevCommit remoteHead = getRemoteHead();
+    RevCommit localHead = getHead(repo, "HEAD");
+    RevCommit remoteHead = projectOperations.project(project).getHead("master");
     assertThat(localHead.getId()).isNotEqualTo(remoteHead.getId());
     assertThat(remoteHead.getParentCount()).isEqualTo(1);
     if (!contentMerge) {
@@ -1269,7 +1374,7 @@
     assertThat(remoteHead.getShortMessage()).isEqualTo(localHead.getShortMessage());
   }
 
-  protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch) throws Exception {
+  protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch) throws Throwable {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.markStart(rw.parseCommit(repo.exactRef("refs/heads/" + branch).getObjectId()));
@@ -1277,22 +1382,22 @@
     }
   }
 
-  protected List<RevCommit> getRemoteLog() throws Exception {
+  protected List<RevCommit> getRemoteLog() throws Throwable {
     return getRemoteLog(project, "master");
   }
 
   protected void addOnSubmitValidationListener(OnSubmitValidationListener listener) {
     assertThat(onSubmitValidatorHandle).isNull();
-    onSubmitValidatorHandle = onSubmitValidationListeners.add(listener);
+    onSubmitValidatorHandle = onSubmitValidationListeners.add("gerrit", listener);
   }
 
-  private String getLatestDiff(Repository repo) throws Exception {
+  private String getLatestDiff(Repository repo) throws Throwable {
     ObjectId oldTreeId = repo.resolve("HEAD~1^{tree}");
     ObjectId newTreeId = repo.resolve("HEAD^{tree}");
     return getLatestDiff(repo, oldTreeId, newTreeId);
   }
 
-  private String getLatestRemoteDiff() throws Exception {
+  private String getLatestRemoteDiff() throws Throwable {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       ObjectId oldTreeId = repo.resolve("refs/heads/master~1^{tree}");
@@ -1302,7 +1407,7 @@
   }
 
   private String getLatestDiff(Repository repo, ObjectId oldTreeId, ObjectId newTreeId)
-      throws Exception {
+      throws Throwable {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     try (DiffFormatter fmt = new DiffFormatter(out)) {
       fmt.setRepository(repo);
@@ -1312,18 +1417,21 @@
     }
   }
 
-  private TestRepository<?> createProjectWithPush(
-      String name, @Nullable Project.NameKey parent, SubmitType submitType) throws Exception {
-    Project.NameKey project = createProject(name, parent, true, submitType);
-    grant(project, "refs/heads/*", Permission.PUSH);
-    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
-    return cloneProject(project);
+  // TODO(hanwen): the submodule tests have a similar method; maybe we could share code?
+  protected Project.NameKey createProjectForPush(SubmitType submitType) throws Throwable {
+    Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
+    return project;
   }
 
   protected PushOneCommit.Result createChange(
-      String subject, String fileName, String content, String topic) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
+      String subject, String fileName, String content, String topic) throws Throwable {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
     return push.to("refs/for/master/" + name(topic));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 29a81ca..a4fa84b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -17,36 +17,28 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.TestSubmitInput;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
 public abstract class AbstractSubmitByMerge extends AbstractSubmit {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
-  public void submitWithMerge() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithMerge() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getParentCount()).isEqualTo(2);
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
@@ -54,17 +46,17 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
+  public void submitWithContentMerge() throws Throwable {
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getParentCount()).isEqualTo(2);
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertThat(head.getParent(1)).isEqualTo(change3.getCommit());
@@ -72,12 +64,12 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
@@ -89,29 +81,30 @@
             + "Change could not be merged due to a path conflict. "
             + "Please rebase the change locally "
             + "and upload the rebased commit for review.");
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(oldHead);
   }
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Exception {
+  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Throwable {
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
     approve(change1.getChangeId());
     submit(change2.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change2.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change2.getCommit());
   }
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Exception {
+  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     PushOneCommit.Result change1 =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "Change 1", "a", "a")
+            .create(admin.newIdent(), testRepo, "Change 1", "a", "a")
             .to("refs/for/master/" + name("topic"));
 
-    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo, "Change 2", "b", "b");
+    PushOneCommit push2 = pushFactory.create(admin.newIdent(), testRepo, "Change 2", "b", "b");
     push2.noParents();
     PushOneCommit.Result change2 = push2.to("refs/for/master/" + name("topic"));
     change2.assertOkStatus();
@@ -119,63 +112,9 @@
     approve(change1.getChangeId());
     submit(change2.getChangeId());
 
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getParents()).hasLength(2);
     assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
     assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
   }
-
-  @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
-    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
-
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-    RevCommit afterChange1Head = getRemoteHead();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    Change.Id id2 = change2.getChange().getId();
-    TestSubmitInput failInput = new TestSubmitInput();
-    failInput.failAfterRefUpdates = true;
-    submit(
-        change2.getChangeId(),
-        failInput,
-        ResourceConflictException.class,
-        "Failing after ref updates");
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
-    ChangeInfo info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-
-    RevCommit tip;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
-      assertThat(rev1).isNotNull();
-
-      tip = rw.parseCommit(repo.exactRef("refs/heads/master").getObjectId());
-      assertThat(tip.getParentCount()).isEqualTo(2);
-      assertThat(tip.getParent(0)).isEqualTo(afterChange1Head);
-      assertThat(tip.getParent(1)).isEqualTo(change2.getCommit());
-    }
-
-    submit(change2.getChangeId(), new SubmitInput(), null, null);
-
-    // Change status and patch set entities were updated, and branch tip stayed
-    // the same.
-    info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo("Change has been successfully merged by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(tip);
-    }
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 0a92cfb..e05e0b7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -15,28 +15,28 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.TestSubmitInput;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -44,54 +44,56 @@
 import org.junit.Test;
 
 public abstract class AbstractSubmitByRebase extends AbstractSubmit {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Override
   protected abstract SubmitType getSubmitType();
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebase() throws Exception {
+  public void submitWithRebase() throws Throwable {
     submitWithRebase(admin);
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebaseWithoutAddPatchSetPermission() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.codeReview().getName()),
-          -2,
-          2,
-          REGISTERED_USERS,
-          "refs/heads/*");
-      u.save();
-    }
+  public void submitWithRebaseWithoutAddPatchSetPermission() throws Throwable {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
 
     submitWithRebase(user);
   }
 
-  private void submitWithRebase(TestAccount submitter) throws Exception {
-    setApiUser(submitter);
-    RevCommit initialHead = getRemoteHead();
+  protected ImmutableList<PushOneCommit.Result> submitWithRebase(TestAccount submitter)
+      throws Throwable {
+    requestScopeOperations.setApiUser(submitter.id());
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     assertRebase(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertApproved(change2.getChangeId(), submitter);
     assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
     assertSubmitter(change2.getChangeId(), 1, submitter);
     assertSubmitter(change2.getChangeId(), 2, submitter);
-    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
-    assertPersonEquals(submitter.getIdent(), headAfterSecondSubmit.getCommitterIdent());
+    assertPersonEquals(admin.newIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(submitter.newIdent(), headAfterSecondSubmit.getCommitterIdent());
 
     assertRefUpdatedEvents(
         initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
@@ -100,14 +102,15 @@
         headAfterFirstSubmit.name(),
         change2.getChangeId(),
         headAfterSecondSubmit.name());
+    return ImmutableList.of(change, change2);
   }
 
   @Test
-  public void submitWithRebaseMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithRebaseMultipleChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content");
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       assertCurrentRevision(change1.getChangeId(), 2, headAfterFirstSubmit);
     } else {
@@ -128,7 +131,7 @@
     assertApproved(change3.getChangeId());
     assertApproved(change4.getChangeId());
 
-    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
+    RevCommit headAfterSecondSubmit = parse(projectOperations.project(project).getHead("master"));
     assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
     assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
     assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
@@ -164,7 +167,7 @@
   }
 
   @Test
-  public void submitWithRebaseMergeCommit() throws Exception {
+  public void submitWithRebaseMergeCommit() throws Throwable {
     /*
        *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
        |\
@@ -176,11 +179,11 @@
        |/
        * Initial empty repository
     */
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
 
     PushOneCommit change2Push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Merge to master", "m.txt", "");
+        pushFactory.create(admin.newIdent(), testRepo, "Merge to master", "m.txt", "");
     change2Push.setParents(ImmutableList.of(initialHead, change1.getCommit()));
     PushOneCommit.Result change2 = change2Push.to("refs/for/master");
 
@@ -194,7 +197,7 @@
     approve(change2.getChangeId());
     submit(change2.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     assertThat(newHead.getParentCount()).isEqualTo(2);
 
     RevCommit headParent1 = parse(newHead.getParent(0).getId());
@@ -220,12 +223,12 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
@@ -233,7 +236,7 @@
         "Cannot rebase "
             + change2.getCommit().name()
             + ": The change could not be rebased due to a conflict during merge.");
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
@@ -242,81 +245,7 @@
     assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
   }
 
-  @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
-    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
-
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    Change.Id id2 = change2.getChange().getId();
-    TestSubmitInput failInput = new TestSubmitInput();
-    failInput.failAfterRefUpdates = true;
-    submit(
-        change2.getChangeId(),
-        failInput,
-        ResourceConflictException.class,
-        "Failing after ref updates");
-    RevCommit headAfterFailedSubmit = getRemoteHead();
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
-    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
-    ChangeInfo info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(getPatchSet(psId2)).isNull();
-
-    ObjectId rev2;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
-      assertThat(rev1).isNotNull();
-
-      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
-      assertThat(rev2).isNotNull();
-      assertThat(rev2).isNotEqualTo(rev1);
-      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
-
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
-    }
-
-    submit(change2.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
-
-    // Change status and patch set entities were updated, and branch tip stayed
-    // the same.
-    info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
-    PatchSet ps2 = getPatchSet(psId2);
-    assertThat(ps2).isNotNull();
-    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo(
-            "Change has been successfully rebased and submitted as "
-                + rev2.name()
-                + " by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
-    }
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(
-        change.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change2.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
-
-  protected RevCommit parse(ObjectId id) throws Exception {
+  protected RevCommit parse(ObjectId id) throws Throwable {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit c = rw.parseCommit(id);
@@ -326,8 +255,8 @@
   }
 
   @Test
-  public void submitAfterReorderOfCommits() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitAfterReorderOfCommits() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // Create two commits and push.
     RevCommit c1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
@@ -346,15 +275,15 @@
     approve(id1);
     approve(id2);
     submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
     assertChangeMergedEvents(id2, headAfterSubmit.name(), id1, headAfterSubmit.name());
   }
 
   @Test
-  public void submitChangesAfterBranchOnSecond() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitChangesAfterBranchOnSecond() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change = createChange();
     approve(change.getChangeId());
@@ -362,13 +291,13 @@
     PushOneCommit.Result change2 = createChange();
     approve(change2.getChangeId());
     Project.NameKey project = change2.getChange().change().getProject();
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    BranchNameKey branch = BranchNameKey.create(project, "branch");
     createBranchWithRevision(branch, change2.getCommit().getName());
     gApi.changes().id(change2.getChangeId()).current().submit();
     assertMerged(change2.getChangeId());
     assertMerged(change.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(this.project).getHead("master");
     assertRefUpdatedEvents(initialHead, newHead);
     assertChangeMergedEvents(
         change.getChangeId(), newHead.name(), change2.getChangeId(), newHead.name());
@@ -376,8 +305,8 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitFastForwardIdenticalTree() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitFastForwardIdenticalTree() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
 
@@ -388,18 +317,18 @@
     testRepo.reset(initialHead);
     PushOneCommit.Result change0 = createChange("Change 0", "b.txt", "b");
     submit(change0.getChangeId());
-    RevCommit headAfterChange0 = getRemoteHead();
+    RevCommit headAfterChange0 = projectOperations.project(project).getHead("master");
     assertThat(headAfterChange0.getShortMessage()).isEqualTo("Change 0");
 
     submit(change1.getChangeId());
-    RevCommit headAfterChange1 = getRemoteHead();
+    RevCommit headAfterChange1 = projectOperations.project(project).getHead("master");
     assertThat(headAfterChange1.getShortMessage()).isEqualTo("Change 1");
     assertThat(headAfterChange0).isEqualTo(headAfterChange1.getParent(0));
 
     // Do manual rebase first.
     gApi.changes().id(change2.getChangeId()).current().rebase();
     submit(change2.getChangeId());
-    RevCommit headAfterChange2 = getRemoteHead();
+    RevCommit headAfterChange2 = projectOperations.project(project).getHead("master");
     assertThat(headAfterChange2.getShortMessage()).isEqualTo("Change 2");
     assertThat(headAfterChange1).isEqualTo(headAfterChange2.getParent(0));
 
@@ -409,7 +338,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainOneByOne() throws Exception {
+  public void submitChainOneByOne() throws Throwable {
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
     submit(change1.getChangeId());
@@ -418,7 +347,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainFailsOnRework() throws Exception {
+  public void submitChainFailsOnRework() throws Throwable {
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     RevCommit headAfterChange1 = change1.getCommit();
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
@@ -426,7 +355,7 @@
     change1 =
         amendChange(change1.getChangeId(), "subject 1 amend", "fileName 2", "rework content 2");
     submit(change1.getChangeId());
-    headAfterChange1 = getRemoteHead();
+    headAfterChange1 = projectOperations.project(project).getHead("master");
 
     submitWithConflict(
         change2.getChangeId(),
@@ -434,13 +363,13 @@
             + change2.getCommit().getName()
             + ": "
             + "The change could not be rebased due to a conflict during merge.");
-    assertThat(getRemoteHead()).isEqualTo(headAfterChange1);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainOneByOneManualRebase() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitChainOneByOneManualRebase() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 171babd..ae212b6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -35,7 +37,8 @@
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeETagComputation;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -54,9 +57,10 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  @Inject private ChangeJson.Factory changeJsonFactory;
-
   @Inject private DynamicSet<ActionVisitor> actionVisitors;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private RevisionJson.Factory revisionJsonFactory;
+  @Inject private DynamicSet<ChangeETagComputation> changeETagComputations;
 
   private RegistrationHandle visitorHandle;
 
@@ -156,25 +160,25 @@
     String change = createChangeWithTopic().getChangeId();
     approve(change);
 
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     String etag1 = getETag(change);
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     approve(parent);
 
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     String etag2 = getETag(change);
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     String changeWithSameTopic = createChangeWithTopic().getChangeId();
 
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     String etag3 = getETag(change);
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     approve(changeWithSameTopic);
 
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     String etag4 = getETag(change);
 
     if (isSubmitWholeTopicEnabled()) {
@@ -193,18 +197,64 @@
     String change = createChange().getChangeId();
     approve(change);
 
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     String etag1 = getETag(change);
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     approve(parent);
 
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     String etag2 = getETag(change);
     assertThat(etag2).isEqualTo(etag1);
   }
 
   @Test
+  public void pluginCanContributeToETagComputation() throws Exception {
+    String change = createChange().getChangeId();
+    String oldETag = getETag(change);
+
+    RegistrationHandle registrationHandle = changeETagComputations.add("gerrit", (p, id) -> "foo");
+    try {
+      assertThat(getETag(change)).isNotEqualTo(oldETag);
+    } finally {
+      registrationHandle.remove();
+    }
+
+    assertThat(getETag(change)).isEqualTo(oldETag);
+  }
+
+  @Test
+  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
+    String change = createChange().getChangeId();
+    String oldETag = getETag(change);
+
+    RegistrationHandle registrationHandle = changeETagComputations.add("gerrit", (p, id) -> null);
+    try {
+      assertThat(getETag(change)).isEqualTo(oldETag);
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
+  @Test
+  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
+    String change = createChange().getChangeId();
+    String oldETag = getETag(change);
+
+    RegistrationHandle registrationHandle =
+        changeETagComputations.add(
+            "gerrit",
+            (p, id) -> {
+              throw new StorageException("exception during test");
+            });
+    try {
+      assertThat(getETag(change)).isEqualTo(oldETag);
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
+  @Test
   public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     approve(changeId);
@@ -327,11 +377,11 @@
     }
 
     Map<String, ActionInfo> origActions = origChange.actions;
-    assertThat(origActions.keySet()).containsAllOf("followup", "abandon");
+    assertThat(origActions.keySet()).containsAtLeast("followup", "abandon");
     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;
@@ -350,7 +400,7 @@
     String id = createChange().getChangeId();
     amendChange(id);
     ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
-    Change.Id changeId = new Change.Id(origChange._number);
+    Change.Id changeId = Change.id(origChange._number);
 
     class Visitor implements ActionVisitor {
       @Override
@@ -376,11 +426,11 @@
     }
 
     Map<String, ActionInfo> origActions = gApi.changes().id(id).current().actions();
-    assertThat(origActions.keySet()).containsAllOf("cherrypick", "rebase");
+    assertThat(origActions.keySet()).containsAtLeast("cherrypick", "rebase");
     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.
@@ -393,11 +443,8 @@
     visitedCurrentRevisionActionsAssertions(origActions, revisionInfo.actions);
 
     // ...via ChangeJson directly.
-    ChangeData cd = changeDataFactory.create(db, project, changeId);
-    revisionInfo =
-        changeJsonFactory
-            .create(opts)
-            .getRevisionInfo(cd, cd.patchSet(new PatchSet.Id(changeId, 1)));
+    ChangeData cd = changeDataFactory.create(project, changeId);
+    revisionJsonFactory.create(opts).getRevisionInfo(cd, cd.patchSet(PatchSet.id(changeId, 1)));
   }
 
   private void visitedCurrentRevisionActionsAssertions(
@@ -443,7 +490,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/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 69035f2..fec0d4b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -15,24 +15,28 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
 import org.eclipse.jgit.transport.RefSpec;
@@ -42,6 +46,8 @@
 
 @NoHttpd
 public class AssigneeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @BeforeClass
   public static void setTimeForTesting() {
@@ -62,66 +68,58 @@
   @Test
   public void addGetAssignee() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
-    assertThat(getAssignee(r)._accountId).isEqualTo(user.getId().get());
+    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
+    assertThat(getAssignee(r)._accountId).isEqualTo(user.id().get());
 
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
   }
 
   @Test
   public void setNewAssigneeWhenExists() throws Exception {
     PushOneCommit.Result r = createChange();
-    setAssignee(r, user.email);
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    setAssignee(r, user.email());
+    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
   }
 
   @Test
   public void getPastAssignees() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     PushOneCommit.Result r = createChange();
-    setAssignee(r, user.email);
-    setAssignee(r, admin.email);
+    setAssignee(r, user.email());
+    setAssignee(r, admin.email());
     List<AccountInfo> assignees = getPastAssignees(r);
     assertThat(assignees).hasSize(2);
     Iterator<AccountInfo> itr = assignees.iterator();
-    assertThat(itr.next()._accountId).isEqualTo(user.getId().get());
-    assertThat(itr.next()._accountId).isEqualTo(admin.getId().get());
+    assertThat(itr.next()._accountId).isEqualTo(user.id().get());
+    assertThat(itr.next()._accountId).isEqualTo(admin.id().get());
   }
 
   @Test
   public void assigneeAddedAsReviewer() throws Exception {
-    ReviewerState state;
-    // Assignee is added as CC, if back-end is reviewDb (that does not support
-    // CC) CC is stored as REVIEWER
-    if (notesMigration.readChanges()) {
-      state = ReviewerState.CC;
-    } else {
-      state = ReviewerState.REVIEWER;
-    }
+    ReviewerState state = ReviewerState.CC;
     PushOneCommit.Result r = createChange();
     Iterable<AccountInfo> reviewers = getReviewers(r, state);
     assertThat(reviewers).isNull();
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
     reviewers = getReviewers(r, state);
     assertThat(reviewers).hasSize(1);
     AccountInfo reviewer = Iterables.getFirst(reviewers, null);
-    assertThat(reviewer._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
   }
 
   @Test
   public void setAlreadyExistingAssignee() throws Exception {
     PushOneCommit.Result r = createChange();
-    setAssignee(r, user.email);
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    setAssignee(r, user.email());
+    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
   }
 
   @Test
   public void deleteAssignee() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
-    assertThat(deleteAssignee(r)._accountId).isEqualTo(user.getId().get());
+    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
+    assertThat(deleteAssignee(r)._accountId).isEqualTo(user.id().get());
     assertThat(getAssignee(r)).isNull();
   }
 
@@ -134,10 +132,26 @@
   @Test
   public void setAssigneeToInactiveUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.accounts().id(user.getId().get()).setActive(false);
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("is not active");
-    setAssignee(r, user.email);
+    gApi.accounts().id(user.id().get()).setActive(false);
+    UnresolvableAccountException thrown =
+        assertThrows(UnresolvableAccountException.class, () -> setAssignee(r, user.email()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Account '"
+                + user.email()
+                + "' only matches inactive accounts. To use an inactive account, retry with one"
+                + " of the following exact account IDs:\n"
+                + user.id()
+                + ": User <user@example.com>");
+  }
+
+  @Test
+  public void setAssigneeToInactiveUserById() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.accounts().id(user.id().get()).setActive(false);
+    setAssignee(r, user.id().toString());
+    assertThat(getAssignee(r)._accountId).isEqualTo(user.id().get());
   }
 
   @Test
@@ -145,26 +159,28 @@
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
     testRepo.reset(RefNames.REFS_CONFIG);
     PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
-    exception.expect(AuthException.class);
-    exception.expectMessage("read not permitted");
-    setAssignee(r, user.email);
+    AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
+    assertThat(thrown).hasMessageThat().contains("read not permitted");
   }
 
   @Test
   public void setAssigneeNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted");
-    setAssignee(r, user.email);
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
+    assertThat(thrown).hasMessageThat().contains("not permitted");
   }
 
   @Test
   public void setAssigneeAllowedWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
-    grant(project, "refs/heads/master", Permission.EDIT_ASSIGNEE, false, REGISTERED_USERS);
-    setApiUser(user);
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.EDIT_ASSIGNEE).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
   }
 
   private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/BUILD b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
index 6a4b4a7..9a65378 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
@@ -15,6 +15,7 @@
     labels = ["rest"],
     deps = [
         ":submit_util",
+        "//java/com/google/gerrit/mail",
     ],
 )
 
@@ -29,10 +30,11 @@
 
 java_library(
     name = "submit_util",
-    testonly = 1,
+    testonly = True,
     srcs = SUBMIT_UTIL_SRCS,
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/util/time",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
index a2ad7fc..4632731 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.reviewdb.client.Change;
 import org.junit.Test;
 
 public class ChangeIdIT extends AbstractDaemonTest {
@@ -92,6 +93,43 @@
     res.assertNotFound();
   }
 
+  @Test
+  public void changeNumberRedirects() throws Exception {
+    // This test tests a redirect that is primarily intended for the UI (though the backend doesn't
+    // really care who the caller is). The redirect rewrites a shorthand change number URL (/123) to
+    // it's canonical long form (/c/project/+/123).
+    int changeId = createChange().getChange().getId().get();
+    RestResponse res = anonymousRestSession.get("/" + changeId);
+    res.assertTemporaryRedirect("/c/" + project.get() + "/+/" + changeId + "/");
+  }
+
+  @Test
+  public void changeNumberRedirectsWithTrailingSlash() throws Exception {
+    int changeId = createChange().getChange().getId().get();
+    RestResponse res = anonymousRestSession.get("/" + changeId + "/");
+    res.assertTemporaryRedirect("/c/" + project.get() + "/+/" + changeId + "/");
+  }
+
+  @Test
+  public void changeNumberOverflowNotFound() throws Exception {
+    RestResponse res = anonymousRestSession.get("/9" + Long.MAX_VALUE);
+    res.assertNotFound();
+  }
+
+  @Test
+  public void unknownChangeNumberNotFound() throws Exception {
+    RestResponse res = anonymousRestSession.get("/10");
+    res.assertNotFound();
+  }
+
+  @Test
+  public void hiddenChangeNotFound() throws Exception {
+    Change.Id changeId = createChange().getChange().getId();
+    gApi.changes().id(changeId.get()).setPrivate(true, null);
+    RestResponse res = anonymousRestSession.get("/" + changeId.get());
+    res.assertNotFound();
+  }
+
   private static String changeDetail(String changeId) {
     return "/changes/" + changeId + "/detail";
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index 59b6e29..def1a39 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -15,20 +15,25 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 @NoHttpd
 public class ChangeIncludedInIT extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void includedInOpenChange() throws Exception {
     Result result = createChange();
@@ -49,13 +54,17 @@
         .containsExactly("master");
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags).isEmpty();
 
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .update();
     gApi.projects().name(project.get()).tag("test-tag").create(new TagInput());
 
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags)
         .containsExactly("test-tag");
 
-    createBranch(new Branch.NameKey(project.get(), "test-branch"));
+    createBranch(BranchNameKey.create(project, "test-branch"));
 
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().branches)
         .containsExactly("master", "test-branch");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index d0b01b7..8ddfa45 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -11,26 +11,48 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
+import static com.google.gerrit.server.restapi.change.DeleteChangeMessage.createNewChangeMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.util.RawParseUtils.decode;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
+import java.nio.charset.Charset;
 import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.RawParseUtils;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -38,6 +60,9 @@
 
 @RunWith(ConfigSuite.class)
 public class ChangeMessagesIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   private String systemTimeZone;
 
   @Before
@@ -111,37 +136,130 @@
   }
 
   @Test
+  public void listChangeMessagesSkippedEmpty() throws Exception {
+    // Change message 1: create a change.
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    // Will be a new commit with empty change message on the meta branch.
+    addOneReviewWithEmptyChangeMessage(changeId);
+    // Change Message 2: post a review with message "message 1".
+    addOneReview(changeId, "message");
+
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+  }
+
+  @Test
   public void getOneChangeMessage() throws Exception {
     int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
     List<ChangeMessageInfo> messages = new ArrayList<>(gApi.changes().id(changeNum).get().messages);
-
     for (ChangeMessageInfo messageInfo : messages) {
       String id = messageInfo.id;
       assertThat(gApi.changes().id(changeNum).message(id).get()).isEqualTo(messageInfo);
     }
   }
 
+  @Test
+  public void deleteCannotBeAppliedWithoutAdministrateServerCapability() throws Exception {
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> deleteOneChangeMessage(changeNum, 0, user, "spam"));
+    assertThat(thrown).hasMessageThat().isEqualTo("administrate server not permitted");
+  }
+
+  @Test
+  public void deleteCanBeAppliedWithAdministrateServerCapability() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    requestScopeOperations.setApiUser(user.id());
+    deleteOneChangeMessage(changeNum, 0, user, "spam");
+  }
+
+  @Test
+  public void deleteCannotBeAppliedWithEmptyChangeMessageUuid() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .message("")
+                    .delete(new DeleteChangeMessageInput("spam")));
+    assertThat(thrown).hasMessageThat().isEqualTo("change message  not found");
+  }
+
+  @Test
+  public void deleteCannotBeAppliedWithNonExistingChangeMessageUuid() throws Exception {
+    String changeId = createChange().getChangeId();
+    DeleteChangeMessageInput input = new DeleteChangeMessageInput();
+    String id = "8473b95934b5732ac55d26311a706c9c2bde9941";
+    input.reason = "spam";
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(changeId).message(id).delete(input));
+    assertThat(thrown).hasMessageThat().isEqualTo(String.format("change message %s not found", id));
+  }
+
+  @Test
+  public void deleteCanBeAppliedWithoutProvidingReason() throws Exception {
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    deleteOneChangeMessage(changeNum, 2, admin, "");
+  }
+
+  @Test
+  public void deleteOneChangeMessageTwice() throws Exception {
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    // Deletes the second change message twice.
+    deleteOneChangeMessage(changeNum, 1, admin, "reason 1");
+    deleteOneChangeMessage(changeNum, 1, admin, "reason 2");
+  }
+
+  @Test
+  public void deleteMultipleChangeMessages() throws Exception {
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    for (int i = 0; i < 7; ++i) {
+      deleteOneChangeMessage(changeNum, i, admin, "reason " + i);
+    }
+  }
+
   private int createOneChangeWithMultipleChangeMessagesInHistory() throws Exception {
-    setApiUser(user);
+    // Creates the following commit history on the meta branch of the test change.
+
+    requestScopeOperations.setApiUser(user.id());
     // Commit 1: create a change.
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
-    // Commit 2: post a review with message "message 1".
-    setApiUser(admin);
+    // Commit 2: post an empty change message.
+    requestScopeOperations.setApiUser(admin.id());
+    addOneReviewWithEmptyChangeMessage(changeId);
+    // Commit 3: post a review with message "message 1".
     addOneReview(changeId, "message 1");
-    // Commit 3: amend a new patch set.
-    setApiUser(user);
+    // Commit 4: amend a new patch set.
+    requestScopeOperations.setApiUser(user.id());
     amendChange(changeId);
-    // Commit 4: post a review with message "message 2".
+    // Commit 5: post a review with message "message 2".
     addOneReview(changeId, "message 2");
-    // Commit 5: amend a new patch set.
+    // Commit 6: amend a new patch set.
     amendChange(changeId);
-    // Commit 6: approve the change.
-    setApiUser(admin);
+    // Commit 7: approve the change.
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    // commit 7: submit the change.
+    // commit 8: submit the change.
     gApi.changes().id(changeId).current().submit();
 
+    // Verifies there is only 7 change messages although there are 8 commits.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(7);
+
     return result.getChange().getId().get();
   }
 
@@ -158,6 +276,147 @@
     gApi.changes().id(changeId).current().review(reviewInput);
   }
 
+  private void addOneReviewWithEmptyChangeMessage(String changeId) throws Exception {
+    gApi.changes().id(changeId).current().review(new ReviewInput());
+  }
+
+  private void deleteOneChangeMessage(
+      int changeNum, int deletedMessageIndex, TestAccount deletedBy, String reason)
+      throws Exception {
+    List<ChangeMessageInfo> messagesBeforeDeletion = gApi.changes().id(changeNum).messages();
+
+    List<CommentInfo> commentsBefore = getChangeSortedComments(changeNum);
+    List<RevCommit> commitsBefore = getChangeMetaCommitsInReverseOrder(Change.id(changeNum));
+
+    String id = messagesBeforeDeletion.get(deletedMessageIndex).id;
+    DeleteChangeMessageInput input = new DeleteChangeMessageInput(reason);
+    ChangeMessageInfo info = gApi.changes().id(changeNum).message(id).delete(input);
+
+    // Verify the return change message info is as expect.
+    assertThat(info.message).isEqualTo(createNewChangeMessage(deletedBy.fullName(), reason));
+    List<ChangeMessageInfo> messagesAfterDeletion = gApi.changes().id(changeNum).messages();
+    assertMessagesAfterDeletion(
+        messagesBeforeDeletion, messagesAfterDeletion, deletedMessageIndex, deletedBy, reason);
+    assertCommentsAfterDeletion(changeNum, commentsBefore);
+
+    // Verify change index is updated after deletion.
+    List<ChangeInfo> changes = gApi.changes().query("message removed").get();
+    assertThat(changes.stream().map(c -> c._number).collect(toSet())).contains(changeNum);
+
+    // Verifies states of commits.
+    assertMetaCommitsAfterDeletion(commitsBefore, changeNum, id, deletedBy, reason);
+  }
+
+  private void assertMessagesAfterDeletion(
+      List<ChangeMessageInfo> messagesBeforeDeletion,
+      List<ChangeMessageInfo> messagesAfterDeletion,
+      int deletedMessageIndex,
+      TestAccount deletedBy,
+      String deleteReason) {
+    assertWithMessage("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
+        .that(messagesAfterDeletion)
+        .hasSize(messagesBeforeDeletion.size());
+
+    for (int i = 0; i < messagesAfterDeletion.size(); ++i) {
+      ChangeMessageInfo before = messagesBeforeDeletion.get(i);
+      ChangeMessageInfo after = messagesAfterDeletion.get(i);
+
+      if (i < deletedMessageIndex) {
+        // The uuid of a commit message will be updated after rewriting.
+        assertThat(after.id).isEqualTo(before.id);
+      }
+
+      assertThat(after.tag).isEqualTo(before.tag);
+      assertThat(after.author).isEqualTo(before.author);
+      assertThat(after.realAuthor).isEqualTo(before.realAuthor);
+      assertThat(after._revisionNumber).isEqualTo(before._revisionNumber);
+
+      if (i == deletedMessageIndex) {
+        assertThat(after.message)
+            .isEqualTo(createNewChangeMessage(deletedBy.fullName(), deleteReason));
+      } else {
+        assertThat(after.message).isEqualTo(before.message);
+      }
+    }
+  }
+
+  private void assertMetaCommitsAfterDeletion(
+      List<RevCommit> commitsBeforeDeletion,
+      int changeNum,
+      String deletedMessageId,
+      TestAccount deletedBy,
+      String deleteReason)
+      throws Exception {
+    List<RevCommit> commitsAfterDeletion = getChangeMetaCommitsInReverseOrder(Change.id(changeNum));
+    assertThat(commitsAfterDeletion).hasSize(commitsBeforeDeletion.size());
+
+    for (int i = 0; i < commitsBeforeDeletion.size(); i++) {
+      RevCommit commitBefore = commitsBeforeDeletion.get(i);
+      RevCommit commitAfter = commitsAfterDeletion.get(i);
+      if (commitBefore.getId().getName().equals(deletedMessageId)) {
+        byte[] rawBefore = commitBefore.getRawBuffer();
+        byte[] rawAfter = commitAfter.getRawBuffer();
+        Charset encodingBefore = RawParseUtils.parseEncoding(rawBefore);
+        Charset encodingAfter = RawParseUtils.parseEncoding(rawAfter);
+        Optional<ChangeNoteUtil.CommitMessageRange> rangeBefore =
+            parseCommitMessageRange(commitBefore);
+        Optional<ChangeNoteUtil.CommitMessageRange> rangeAfter =
+            parseCommitMessageRange(commitAfter);
+        assertThat(rangeBefore.isPresent()).isTrue();
+        assertThat(rangeAfter.isPresent()).isTrue();
+
+        String subjectBefore =
+            decode(
+                encodingBefore,
+                rawBefore,
+                rangeBefore.get().subjectStart(),
+                rangeBefore.get().subjectEnd());
+        String subjectAfter =
+            decode(
+                encodingAfter,
+                rawAfter,
+                rangeAfter.get().subjectStart(),
+                rangeAfter.get().subjectEnd());
+        assertThat(subjectBefore).isEqualTo(subjectAfter);
+
+        String footersBefore =
+            decode(
+                encodingBefore,
+                rawBefore,
+                rangeBefore.get().changeMessageEnd() + 1,
+                rawBefore.length);
+        String footersAfter =
+            decode(
+                encodingAfter, rawAfter, rangeAfter.get().changeMessageEnd() + 1, rawAfter.length);
+        assertThat(footersBefore).isEqualTo(footersAfter);
+
+        String message =
+            decode(
+                encodingAfter,
+                rawAfter,
+                rangeAfter.get().changeMessageStart(),
+                rangeAfter.get().changeMessageEnd() + 1);
+        assertThat(message).isEqualTo(createNewChangeMessage(deletedBy.fullName(), deleteReason));
+      } else {
+        assertThat(commitAfter.getFullMessage()).isEqualTo(commitBefore.getFullMessage());
+      }
+
+      assertThat(commitAfter.getCommitterIdent().getName())
+          .isEqualTo(commitBefore.getCommitterIdent().getName());
+      assertThat(commitAfter.getAuthorIdent().getName())
+          .isEqualTo(commitBefore.getAuthorIdent().getName());
+      assertThat(commitAfter.getEncoding()).isEqualTo(commitBefore.getEncoding());
+      assertThat(commitAfter.getEncodingName()).isEqualTo(commitBefore.getEncodingName());
+    }
+  }
+
+  /** Verifies comments are not changed after deleting change message(s). */
+  private void assertCommentsAfterDeletion(int changeNum, List<CommentInfo> commentsBeforeDeletion)
+      throws Exception {
+    List<CommentInfo> commentsAfterDeletion = getChangeSortedComments(changeNum);
+    assertThat(commentsAfterDeletion).containsExactlyElementsIn(commentsBeforeDeletion).inOrder();
+  }
+
   private static void assertMessage(String expected, String actual) {
     assertThat(actual).isEqualTo("Patch Set 1:\n\n" + expected);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index ac0d0aa..30d99ac 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -14,28 +14,38 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ChangeOwnerIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private TestAccount user2;
 
   @Before
   public void setUp() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     user2 = accountCreator.user2();
   }
 
@@ -61,22 +71,22 @@
 
   @Test
   public void testChangeOwner_OwnerACLGrantedOnParentProject() throws Exception {
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     grantApproveToChangeOwner(project);
-    Project.NameKey child = createProject("child", project);
+    Project.NameKey child = projectOperations.newProject().parent(project).create();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     TestRepository<InMemoryRepository> childRepo = cloneProject(child, user);
     approve(user, createMyChange(childRepo));
   }
 
   @Test
   public void testChangeOwner_BlockedOnParentProject() throws Exception {
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     blockApproveForChangeOwner(project);
-    Project.NameKey child = createProject("child", project);
+    Project.NameKey child = projectOperations.newProject().parent(project).create();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     grantApproveToAll(child);
     TestRepository<InMemoryRepository> childRepo = cloneProject(child, user);
     String changeId = createMyChange(childRepo);
@@ -90,11 +100,11 @@
 
   @Test
   public void testChangeOwner_BlockedOnParentProjectAndExclusiveAllowOnChild() throws Exception {
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     blockApproveForChangeOwner(project);
-    Project.NameKey child = createProject("child", project);
+    Project.NameKey child = projectOperations.newProject().parent(project).create();
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     grantExclusiveApproveToAll(child);
     TestRepository<InMemoryRepository> childRepo = cloneProject(child, user);
     String changeId = createMyChange(childRepo);
@@ -107,7 +117,7 @@
   }
 
   private void approve(TestAccount a, String changeId) throws Exception {
-    Context old = setApiUser(a);
+    Context old = requestScopeOperations.setApiUser(a.id());
     try {
       gApi.changes().id(changeId).current().review(ReviewInput.approve());
     } finally {
@@ -116,8 +126,7 @@
   }
 
   private void assertApproveFails(TestAccount a, String changeId) throws Exception {
-    exception.expect(AuthException.class);
-    approve(a, changeId);
+    assertThrows(AuthException.class, () -> approve(a, changeId));
   }
 
   private void grantApproveToChangeOwner(Project.NameKey project) throws Exception {
@@ -134,15 +143,28 @@
 
   private void grantApprove(Project.NameKey project, AccountGroup.UUID groupUUID, boolean exclusive)
       throws Exception {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", false, groupUUID, exclusive);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(groupUUID).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/*"), exclusive)
+        .update();
   }
 
   private void blockApproveForChangeOwner(Project.NameKey project) throws Exception {
-    blockLabel("Code-Review", -2, 2, SystemGroupBackend.CHANGE_OWNER, "refs/heads/*", project);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabel("Code-Review")
+                .ref("refs/heads/*")
+                .group(SystemGroupBackend.CHANGE_OWNER)
+                .range(-2, 2))
+        .update();
   }
 
   private String createMyChange(TestRepository<InMemoryRepository> testRepo) throws Exception {
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
     return push.to("refs/for/master").getChangeId();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 84c9c03..ac00e38 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -15,31 +15,35 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import java.lang.reflect.Type;
 import java.util.List;
 import org.junit.Before;
 import org.junit.Test;
 
-@NoHttpd
 public class ChangeReviewersByEmailIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
   public void setUp() throws Exception {
@@ -50,7 +54,6 @@
 
   @Test
   public void addByEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
@@ -70,9 +73,8 @@
 
   @Test
   public void addByEmailAndById() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     AccountInfo byEmail = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
-    AccountInfo byId = new AccountInfo(user.id.get());
+    AccountInfo byId = new AccountInfo(user.id().get());
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
@@ -83,7 +85,7 @@
       gApi.changes().id(r.getChangeId()).addReviewer(inputByEmail);
 
       AddReviewerInput inputById = new AddReviewerInput();
-      inputById.reviewer = user.email;
+      inputById.reviewer = user.email();
       inputById.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(inputById);
 
@@ -95,8 +97,34 @@
   }
 
   @Test
+  public void listReviewersByEmail() throws Exception {
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      RestResponse restResponse =
+          adminRestSession.get("/changes/" + r.getChangeId() + "/reviewers/");
+      restResponse.assertOK();
+      Type type = new TypeToken<List<ReviewerInfo>>() {}.getType();
+      List<ReviewerInfo> reviewers = newGson().fromJson(restResponse.getReader(), type);
+      restResponse.consume();
+
+      assertThat(reviewers).hasSize(1);
+      ReviewerInfo reviewerInfo = Iterables.getOnlyElement(reviewers);
+      assertThat(reviewerInfo._accountId).isNull();
+      assertThat(reviewerInfo.name).isEqualTo(acc.name);
+      assertThat(reviewerInfo.email).isEqualTo(acc.email);
+    }
+  }
+
+  @Test
   public void removeByEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
@@ -116,7 +144,6 @@
 
   @Test
   public void convertFromCCToReviewer() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
 
     PushOneCommit.Result r = createChange();
@@ -138,7 +165,6 @@
 
   @Test
   public void addedReviewersGetNotified() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
@@ -158,7 +184,6 @@
 
   @Test
   public void removingReviewerTriggersNotification() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
@@ -172,9 +197,9 @@
       // Review change as user
       ReviewInput reviewInput = new ReviewInput();
       reviewInput.message = "I have a comment";
-      setApiUser(user);
+      requestScopeOperations.setApiUser(user.id());
       revision(r).review(reviewInput);
-      setApiUser(admin);
+      requestScopeOperations.setApiUser(admin.id());
 
       sender.clear();
 
@@ -184,14 +209,13 @@
       List<Message> messages = sender.getMessages();
       assertThat(messages).hasSize(1);
       assertThat(messages.get(0).rcpt())
-          .containsExactly(Address.parse(addInput.reviewer), user.emailAddress);
+          .containsExactly(Address.parse(addInput.reviewer), user.getEmailAddress());
       sender.clear();
     }
   }
 
   @Test
   public void reviewerAndCCReceiveRegularNotification() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
@@ -214,8 +238,6 @@
 
   @Test
   public void reviewerAndCCReceiveSameEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     PushOneCommit.Result r = createChange();
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       for (int i = 0; i < 10; i++) {
@@ -228,7 +250,7 @@
 
     // Also add user as a regular reviewer
     AddReviewerInput input = new AddReviewerInput();
-    input.reviewer = user.email;
+    input.reviewer = user.email();
     input.state = ReviewerState.REVIEWER;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
 
@@ -240,8 +262,6 @@
 
   @Test
   public void addingMultipleReviewersAndCCsAtOnceSendsOnlyOneEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     PushOneCommit.Result r = createChange();
     ReviewInput reviewInput = new ReviewInput();
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
@@ -258,7 +278,6 @@
 
   @Test
   public void rejectMissingEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     PushOneCommit.Result r = createChange();
 
     AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
@@ -268,7 +287,6 @@
 
   @Test
   public void rejectMalformedEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     PushOneCommit.Result r = createChange();
 
     AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
@@ -278,8 +296,6 @@
 
   @Test
   public void rejectWhenFeatureIsDisabled() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.FALSE;
     gApi.projects().name(project.get()).config(conf);
@@ -290,13 +306,14 @@
         gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
     assertThat(result.error)
         .isEqualTo(
-            "Foo Bar <foo.bar@gerritcodereview.com> does not identify a registered user or group");
+            "Account 'Foo Bar <foo.bar@gerritcodereview.com>' not found\n"
+                + "Foo Bar <foo.bar@gerritcodereview.com> does not identify a registered user or"
+                + " group");
     assertThat(result.reviewers).isNull();
   }
 
   @Test
   public void reviewersByEmailAreServedFromIndex() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
@@ -307,18 +324,49 @@
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
 
-      notesMigration.setFailOnLoadForTest(true);
-      try {
+      try (AutoCloseable ignored = disableNoteDb()) {
         ChangeInfo info =
             Iterables.getOnlyElement(
                 gApi.changes().query(r.getChangeId()).withOption(DETAILED_LABELS).get());
         assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
-      } finally {
-        notesMigration.setFailOnLoadForTest(false);
       }
     }
   }
 
+  @Test
+  public void addExistingReviewerByEmailShortCircuits() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = "nonexisting@example.com";
+    input.state = ReviewerState.REVIEWER;
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    assertThat(result.reviewers).hasSize(1);
+    ReviewerInfo info = result.reviewers.get(0);
+    assertThat(info._accountId).isNull();
+    assertThat(info.email).isEqualTo(input.reviewer);
+
+    assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).reviewers).isEmpty();
+  }
+
+  @Test
+  public void addExistingCcByEmailShortCircuits() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = "nonexisting@example.com";
+    input.state = ReviewerState.CC;
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+    assertThat(result.ccs).hasSize(1);
+    AccountInfo info = result.ccs.get(0);
+    assertThat(info._accountId).isNull();
+    assertThat(info.email).isEqualTo(input.reviewer);
+
+    assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).ccs).isEmpty();
+  }
+
   private static String toRfcAddressString(AccountInfo info) {
     return (new Address(info.name, info.email)).toString();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 129d98a..e300c91 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -15,12 +15,13 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
@@ -31,6 +32,9 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
@@ -39,6 +43,7 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -46,11 +51,12 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.restapi.change.PostReviewers;
+import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
+import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -61,19 +67,24 @@
 import org.junit.Test;
 
 public class ChangeReviewersIT extends AbstractDaemonTest {
+
+  @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void addGroupAsReviewer() throws Exception {
     // Set up two groups, one that is too large too add as reviewer, and one
     // that is too large to add without confirmation.
-    String largeGroup = createGroup("largeGroup");
-    String mediumGroup = createGroup("mediumGroup");
+    String largeGroup = groupOperations.newGroup().name("largeGroup").create().get();
+    String mediumGroup = groupOperations.newGroup().name("mediumGroup").create().get();
 
-    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
     List<TestAccount> users = createAccounts(largeGroupSize, "addGroupAsReviewer");
     List<String> largeGroupUsernames = new ArrayList<>(mediumGroupSize);
     for (TestAccount u : users) {
-      largeGroupUsernames.add(u.username);
+      largeGroupUsernames.add(u.username());
     }
     List<String> mediumGroupUsernames = largeGroupUsernames.subList(0, mediumGroupSize);
     gApi.groups()
@@ -120,39 +131,26 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     in.state = CC;
     AddReviewerResult result = addReviewer(changeId, in);
 
-    assertThat(result.input).isEqualTo(user.email);
+    assertThat(result.input).isEqualTo(user.email());
     assertThat(result.confirm).isNull();
     assertThat(result.error).isNull();
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertThat(result.reviewers).isNull();
-      assertThat(result.ccs).hasSize(1);
-      AccountInfo ai = result.ccs.get(0);
-      assertThat(ai._accountId).isEqualTo(user.id.get());
-      assertReviewers(c, CC, user);
-    } else {
-      assertThat(result.ccs).isNull();
-      assertThat(result.reviewers).hasSize(1);
-      AccountInfo ai = result.reviewers.get(0);
-      assertThat(ai._accountId).isEqualTo(user.id.get());
-      assertReviewers(c, REVIEWER, user);
-    }
+    assertThat(result.reviewers).isNull();
+    assertThat(result.ccs).hasSize(1);
+    AccountInfo ai = result.ccs.get(0);
+    assertThat(ai._accountId).isEqualTo(user.id().get());
+    assertReviewers(c, CC, user);
 
     // Verify email was sent to CCed account.
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    if (notesMigration.readChanges()) {
-      assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review.");
-    } else {
-      assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
-      assertThat(m.body()).contains("I'd like you to do a code review.");
-    }
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.body()).contains(admin.fullName() + " has uploaded this change for review.");
   }
 
   @Test
@@ -160,7 +158,7 @@
     List<TestAccount> users = createAccounts(6, "addCcGroup");
     List<String> usernames = new ArrayList<>(6);
     for (TestAccount u : users) {
-      usernames.add(u.username);
+      usernames.add(u.username());
     }
 
     List<TestAccount> firstUsers = users.subList(0, 3);
@@ -169,7 +167,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = createGroup("cc1");
+    in.reviewer = groupOperations.newGroup().name("cc1").create().get();
     in.state = CC;
     gApi.groups()
         .id(in.reviewer)
@@ -179,18 +177,9 @@
     assertThat(result.input).isEqualTo(in.reviewer);
     assertThat(result.confirm).isNull();
     assertThat(result.error).isNull();
-    if (notesMigration.readChanges()) {
-      assertThat(result.reviewers).isNull();
-    } else {
-      assertThat(result.ccs).isNull();
-    }
+    assertThat(result.reviewers).isNull();
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, CC, firstUsers);
-    } else {
-      assertReviewers(c, REVIEWER, firstUsers);
-      assertReviewers(c, CC);
-    }
+    assertReviewers(c, CC, firstUsers);
 
     // Verify emails were sent to each of the group's accounts.
     List<Message> messages = sender.getMessages();
@@ -198,51 +187,37 @@
     Message m = messages.get(0);
     List<Address> expectedAddresses = new ArrayList<>(firstUsers.size());
     for (TestAccount u : firstUsers) {
-      expectedAddresses.add(u.emailAddress);
+      expectedAddresses.add(u.getEmailAddress());
     }
     assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
 
     // CC a group that overlaps with some existing reviewers and CCed accounts.
     TestAccount reviewer =
         accountCreator.create(name("reviewer"), "addCcGroup-reviewer@example.com", "Reviewer");
-    result = addReviewer(changeId, reviewer.username);
+    result = addReviewer(changeId, reviewer.username());
     assertThat(result.error).isNull();
     sender.clear();
-    in.reviewer = createGroup("cc2");
+    in.reviewer = groupOperations.newGroup().name("cc2").create().get();
     gApi.groups().id(in.reviewer).addMembers(usernames.toArray(new String[usernames.size()]));
-    gApi.groups().id(in.reviewer).addMembers(reviewer.username);
+    gApi.groups().id(in.reviewer).addMembers(reviewer.username());
     result = addReviewer(changeId, in);
     assertThat(result.input).isEqualTo(in.reviewer);
     assertThat(result.confirm).isNull();
     assertThat(result.error).isNull();
     c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertThat(result.ccs).hasSize(3);
-      assertThat(result.reviewers).isNull();
-      assertReviewers(c, REVIEWER, reviewer);
-      assertReviewers(c, CC, users);
-    } else {
-      assertThat(result.ccs).isNull();
-      assertThat(result.reviewers).hasSize(3);
-      List<TestAccount> expectedUsers = new ArrayList<>(users.size() + 2);
-      expectedUsers.addAll(users);
-      expectedUsers.add(reviewer);
-      assertReviewers(c, REVIEWER, expectedUsers);
-    }
+    assertThat(result.ccs).hasSize(3);
+    assertThat(result.reviewers).isNull();
+    assertReviewers(c, REVIEWER, reviewer);
+    assertReviewers(c, CC, users);
 
     messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     m = messages.get(0);
     expectedAddresses = new ArrayList<>(4);
     for (int i = 0; i < 3; i++) {
-      expectedAddresses.add(users.get(users.size() - i - 1).emailAddress);
+      expectedAddresses.add(users.get(users.size() - i - 1).getEmailAddress());
     }
-    if (!notesMigration.readChanges()) {
-      for (int i = 0; i < 3; i++) {
-        expectedAddresses.add(users.get(i).emailAddress);
-      }
-    }
-    expectedAddresses.add(reviewer.emailAddress);
+    expectedAddresses.add(reviewer.getEmailAddress());
     assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
   }
 
@@ -251,17 +226,12 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     in.state = CC;
     addReviewer(changeId, in);
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER);
-      assertReviewers(c, CC, user);
-    } else {
-      assertReviewers(c, REVIEWER, user);
-      assertReviewers(c, CC);
-    }
+    assertReviewers(c, REVIEWER);
+    assertReviewers(c, CC, user);
 
     in.state = REVIEWER;
     addReviewer(changeId, in);
@@ -287,15 +257,8 @@
 
     // Verify user is added to CC list.
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER);
-      assertReviewers(c, CC, user);
-    } else {
-      // If we aren't reading from NoteDb, the user will appear as a
-      // reviewer.
-      assertReviewers(c, REVIEWER, user);
-      assertReviewers(c, CC);
-    }
+    assertReviewers(c, REVIEWER);
+    assertReviewers(c, CC, user);
   }
 
   @Test
@@ -304,7 +267,7 @@
     PushOneCommit.Result r = createChange();
 
     // user adds self as REVIEWER.
-    ReviewInput input = new ReviewInput().reviewer(user.username);
+    ReviewInput input = new ReviewInput().reviewer(user.username());
     RestResponse resp =
         userRestSession.post(
             "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
@@ -323,7 +286,7 @@
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
     ApprovalInfo approval = label.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.getId().get());
+    assertThat(approval._accountId).isEqualTo(user.id().get());
   }
 
   @Test
@@ -332,7 +295,7 @@
     PushOneCommit.Result r = createChange();
 
     // user adds self as CC.
-    ReviewInput input = new ReviewInput().reviewer(user.username, CC, false);
+    ReviewInput input = new ReviewInput().reviewer(user.username(), CC, false);
     RestResponse resp =
         userRestSession.post(
             "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
@@ -344,26 +307,13 @@
 
     // Verify reviewer state.
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER);
-      assertReviewers(c, CC, user);
-      // Verify no approvals were added.
-      assertThat(c.labels).isNotNull();
-      LabelInfo label = c.labels.get("Code-Review");
-      assertThat(label).isNotNull();
-      assertThat(label.all).isNull();
-    } else {
-      // When approvals are stored in ReviewDb, we still create a label for
-      // the reviewing user, and force them into the REVIEWER state.
-      assertReviewers(c, REVIEWER, user);
-      assertReviewers(c, CC);
-      LabelInfo label = c.labels.get("Code-Review");
-      assertThat(label).isNotNull();
-      assertThat(label.all).isNotNull();
-      assertThat(label.all).hasSize(1);
-      ApprovalInfo approval = label.all.get(0);
-      assertThat(approval._accountId).isEqualTo(user.getId().get());
-    }
+    assertReviewers(c, REVIEWER);
+    assertReviewers(c, CC, user);
+    // Verify no approvals were added.
+    assertThat(c.labels).isNotNull();
+    LabelInfo label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNull();
   }
 
   @Test
@@ -380,7 +330,7 @@
     assertThat(label.all).isNull();
 
     // Add user as REVIEWER.
-    ReviewInput input = new ReviewInput().reviewer(user.username);
+    ReviewInput input = new ReviewInput().reviewer(user.username());
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.labels).isNull();
     assertThat(result.reviewers).isNotNull();
@@ -399,8 +349,8 @@
     for (ApprovalInfo approval : label.all) {
       approvals.put(approval._accountId, approval.value);
     }
-    assertThat(approvals).containsEntry(admin.getId().get(), 0);
-    assertThat(approvals).containsEntry(user.getId().get(), 0);
+    assertThat(approvals).containsEntry(admin.id().get(), 0);
+    assertThat(approvals).containsEntry(user.id().get(), 0);
 
     // Comment as user without voting. This should delete the approval and
     // then replace it with the default value.
@@ -424,8 +374,8 @@
     for (ApprovalInfo approval : label.all) {
       approvals.put(approval._accountId, approval.value);
     }
-    assertThat(approvals).containsEntry(admin.getId().get(), 0);
-    assertThat(approvals).containsEntry(user.getId().get(), 0);
+    assertThat(approvals).containsEntry(admin.id().get(), 0);
+    assertThat(approvals).containsEntry(user.id().get(), 0);
   }
 
   @Test
@@ -433,7 +383,7 @@
     TestAccount observer = accountCreator.user2();
     PushOneCommit.Result r = createChange();
     ReviewInput input =
-        ReviewInput.approve().reviewer(user.email).reviewer(observer.email, CC, false);
+        ReviewInput.approve().reviewer(user.email()).reviewer(observer.email(), CC, false);
 
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.labels).isNotNull();
@@ -443,43 +393,37 @@
     // Verify reviewer and CC were added. If not in NoteDb read mode, both
     // parties will be returned as CCed.
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER, admin, user);
-      assertReviewers(c, CC, observer);
-    } else {
-      // In legacy mode, everyone should be a reviewer.
-      assertReviewers(c, REVIEWER, admin, user, observer);
-      assertReviewers(c, CC);
-    }
+    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, CC, observer);
 
     // Verify emails were sent to added reviewers.
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(2);
 
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
-    assertThat(m.body()).contains(admin.fullName + " has posted comments on this change.");
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress(), observer.getEmailAddress());
+    assertThat(m.body()).contains(admin.fullName() + " has posted comments on this change.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
 
     m = messages.get(1);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
-    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress(), observer.getEmailAddress());
+    assertThat(m.body()).contains("Hello " + user.fullName() + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
   }
 
   @Test
   public void reviewAndAddGroupReviewers() throws Exception {
-    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
     List<TestAccount> users = createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
     List<String> usernames = new ArrayList<>(largeGroupSize);
     for (TestAccount u : users) {
-      usernames.add(u.username);
+      usernames.add(u.username());
     }
 
-    String largeGroup = createGroup("largeGroup");
-    String mediumGroup = createGroup("mediumGroup");
+    String largeGroup = groupOperations.newGroup().name("largeGroup").create().get();
+    String mediumGroup = groupOperations.newGroup().name("mediumGroup").create().get();
     gApi.groups().id(largeGroup).addMembers(usernames.toArray(new String[largeGroupSize]));
     gApi.groups()
         .id(mediumGroup)
@@ -491,8 +435,8 @@
     // Attempt to add overly large group as reviewers.
     ReviewInput input =
         ReviewInput.approve()
-            .reviewer(user.email)
-            .reviewer(observer.email, CC, false)
+            .reviewer(user.email())
+            .reviewer(observer.email(), CC, false)
             .reviewer(largeGroup);
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
     assertThat(result.labels).isNull();
@@ -514,8 +458,8 @@
     // confirmation, as reviewers.
     input =
         ReviewInput.approve()
-            .reviewer(user.email)
-            .reviewer(observer.email, CC, false)
+            .reviewer(user.email())
+            .reviewer(observer.email(), CC, false)
             .reviewer(mediumGroup);
     result = review(r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
     assertThat(result.labels).isNull();
@@ -534,7 +478,7 @@
     assertThat(c.reviewers.get(CC)).isNull();
 
     // Retrying with confirmation should successfully approve and add reviewers/CCs.
-    input = ReviewInput.approve().reviewer(user.email).reviewer(mediumGroup, CC, true);
+    input = ReviewInput.approve().reviewer(user.email()).reviewer(mediumGroup, CC, true);
     result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.labels).isNotNull();
     assertThat(result.reviewers).isNotNull();
@@ -543,27 +487,16 @@
     c = gApi.changes().id(r.getChangeId()).get();
     assertThat(c.messages).hasSize(2);
 
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER, admin, user);
-      assertReviewers(c, CC, users.subList(0, mediumGroupSize));
-    } else {
-      // If not in NoteDb mode, then everyone is a REVIEWER.
-      List<TestAccount> expected = users.subList(0, mediumGroupSize);
-      expected.add(admin);
-      expected.add(user);
-      assertReviewers(c, REVIEWER, expected);
-      assertReviewers(c, CC);
-    }
+    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, CC, users.subList(0, mediumGroupSize));
   }
 
   @Test
   public void noteDbAddReviewerToReviewerChangeInfo() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     in.state = CC;
     addReviewer(changeId, in);
 
@@ -572,7 +505,7 @@
 
     gApi.changes().id(changeId).current().review(ReviewInput.dislike());
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     // NoteDb adds reviewer to a change on every review.
     gApi.changes().id(changeId).current().review(ReviewInput.dislike());
 
@@ -585,28 +518,28 @@
     Iterator<ReviewerUpdateInfo> it = c.reviewerUpdates.iterator();
     ReviewerUpdateInfo reviewerChange = it.next();
     assertThat(reviewerChange.state).isEqualTo(CC);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.id().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.id().get());
 
     reviewerChange = it.next();
     assertThat(reviewerChange.state).isEqualTo(REVIEWER);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.id().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.id().get());
 
     reviewerChange = it.next();
     assertThat(reviewerChange.state).isEqualTo(REMOVED);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.id().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.id().get());
   }
 
   @Test
   public void addDuplicateReviewers() throws Exception {
     PushOneCommit.Result r = createChange();
-    ReviewInput input = ReviewInput.approve().reviewer(user.email).reviewer(user.email);
+    ReviewInput input = ReviewInput.approve().reviewer(user.email()).reviewer(user.email());
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(1);
-    AddReviewerResult reviewerResult = result.reviewers.get(user.email);
+    AddReviewerResult reviewerResult = result.reviewers.get(user.email());
     assertThat(reviewerResult).isNotNull();
     assertThat(reviewerResult.confirm).isNull();
     assertThat(reviewerResult.error).isNull();
@@ -621,10 +554,10 @@
         accountCreator.create(name("user2"), emailPrefix + "user2@example.com", "User2");
     TestAccount user3 =
         accountCreator.create(name("user3"), emailPrefix + "user3@example.com", "User3");
-    String group1 = createGroup("group1");
-    String group2 = createGroup("group2");
-    gApi.groups().id(group1).addMembers(user1.username, user2.username);
-    gApi.groups().id(group2).addMembers(user2.username, user3.username);
+    String group1 = groupOperations.newGroup().name("group1").create().get();
+    String group2 = groupOperations.newGroup().name("group2").create().get();
+    gApi.groups().id(group1).addMembers(user1.username(), user2.username());
+    gApi.groups().id(group2).addMembers(user2.username(), user3.username());
 
     PushOneCommit.Result r = createChange();
     ReviewInput input = ReviewInput.approve().reviewer(group1).reviewer(group2);
@@ -639,9 +572,6 @@
     assertThat(reviewerResult.reviewers).hasSize(1);
 
     // Repeat the above for CCs
-    if (!notesMigration.readChanges()) {
-      return;
-    }
     r = createChange();
     input = ReviewInput.approve().reviewer(group1, CC, false).reviewer(group2, CC, false);
     result = review(r.getChangeId(), r.getCommit().name(), input);
@@ -674,7 +604,7 @@
   public void removingReviewerRemovesTheirVote() throws Exception {
     String crLabel = "Code-Review";
     PushOneCommit.Result r = createChange();
-    ReviewInput input = ReviewInput.approve().reviewer(admin.email);
+    ReviewInput input = ReviewInput.approve().reviewer(admin.email());
     ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(addResult.reviewers).isNotNull();
     assertThat(addResult.reviewers).hasSize(1);
@@ -689,7 +619,7 @@
     assertThat(changeLabels.get(crLabel).all).isNull();
 
     // Check that the vote is gone even after the reviewer is added back
-    addReviewer(r.getChangeId(), admin.email);
+    addReviewer(r.getChangeId(), admin.email());
     changeLabels = getChangeLabels(r.getChangeId());
     assertThat(changeLabels.get(crLabel).all).isNull();
   }
@@ -700,15 +630,15 @@
     TestAccount userToNotify = createAccounts(1, "notify-details-post-review").get(0);
 
     ReviewInput reviewInput = new ReviewInput();
-    reviewInput.reviewer(user.email, ReviewerState.REVIEWER, true);
+    reviewInput.reviewer(user.email(), ReviewerState.REVIEWER, true);
     reviewInput.notify = NotifyHandling.NONE;
     reviewInput.notifyDetails =
-        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email())));
 
     sender.clear();
     gApi.changes().id(r.getChangeId()).current().review(reviewInput);
     assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.getEmailAddress());
   }
 
   @Test
@@ -717,15 +647,15 @@
     TestAccount userToNotify = createAccounts(1, "notify-details-post-reviewers").get(0);
 
     AddReviewerInput addReviewer = new AddReviewerInput();
-    addReviewer.reviewer = user.email;
+    addReviewer.reviewer = user.email();
     addReviewer.notify = NotifyHandling.NONE;
     addReviewer.notifyDetails =
-        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email())));
 
     sender.clear();
     gApi.changes().id(r.getChangeId()).addReviewer(addReviewer);
     assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.getEmailAddress());
   }
 
   @Test
@@ -733,12 +663,14 @@
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Code-Review", 1));
-    setApiUser(newUser);
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+    requestScopeOperations.setApiUser(newUser.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -748,12 +680,16 @@
     // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
     // rather than bypassing the check because of project or ref ownership.
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
-    grant(project, RefNames.REFS + "*", Permission.REMOVE_REVIEWER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
 
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     assertThatUserIsOnlyReviewer(r.getChangeId());
-    setApiUser(newUser);
-    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+    requestScopeOperations.setApiUser(newUser.id());
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
     assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
   }
 
@@ -762,11 +698,13 @@
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
-    setApiUser(newUser);
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    requestScopeOperations.setApiUser(newUser.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -775,19 +713,53 @@
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
     AddReviewerInput input = new AddReviewerInput();
-    input.reviewer = user.email;
+    input.reviewer = user.email();
     input.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
-    setApiUser(newUser);
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+    requestScopeOperations.setApiUser(newUser.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  @Test
+  public void addExistingReviewerShortCircuits() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email();
+    input.state = ReviewerState.REVIEWER;
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    assertThat(result.reviewers).hasSize(1);
+    ReviewerInfo info = result.reviewers.get(0);
+    assertThat(info._accountId).isEqualTo(user.id().get());
+
+    assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).reviewers).isEmpty();
+  }
+
+  @Test
+  public void addExistingCcShortCircuits() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email();
+    input.state = ReviewerState.CC;
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    assertThat(result.ccs).hasSize(1);
+    AccountInfo info = result.ccs.get(0);
+    assertThat(info._accountId).isEqualTo(user.id().get());
+
+    assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).ccs).isEmpty();
   }
 
   private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
-    AccountInfo userInfo = new AccountInfo(user.fullName, user.emailAddress.getEmail());
-    userInfo._accountId = user.id.get();
-    userInfo.username = user.username;
+    AccountInfo userInfo = new AccountInfo(user.fullName(), user.getEmailAddress().getEmail());
+    userInfo._accountId = user.id().get();
+    userInfo.username = user.username();
     assertThat(gApi.changes().id(changeId).get().reviewers)
         .containsExactly(ReviewerState.REVIEWER, ImmutableList.of(userInfo));
   }
@@ -814,7 +786,7 @@
   }
 
   private RestResponse deleteReviewer(String changeId, TestAccount account) throws Exception {
-    return adminRestSession.delete("/changes/" + changeId + "/reviewers/" + account.getId().get());
+    return adminRestSession.delete("/changes/" + changeId + "/reviewers/" + account.id().get());
   }
 
   private ReviewResult review(String changeId, String revisionId, ReviewInput in) throws Exception {
@@ -858,7 +830,7 @@
     }
     List<Integer> expectedAccountIds = new ArrayList<>();
     for (TestAccount account : accounts) {
-      expectedAccountIds.add(account.getId().get());
+      expectedAccountIds.add(account.id().get());
     }
     assertThat(actualAccountIds).containsExactlyElementsIn(expectedAccountIds);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index baf56de..57c0c8c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -15,13 +15,17 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -30,29 +34,28 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ConfigChangeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Before
   public void setUp() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.OWNER, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
-      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, RefNames.REFS_CONFIG);
-      u.save();
-    }
-
-    setApiUser(user);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
     fetchRefsMetaConfig();
   }
 
@@ -71,8 +74,8 @@
   }
 
   private String testUpdateProjectConfig() throws Exception {
-    Config cfg = readProjectConfig();
-    assertThat(cfg.getString("project", null, "description")).isNull();
+    Config cfg = projectOperations.project(project).getConfig();
+    assertThat(cfg).stringValue("project", null, "description").isNull();
     String desc = "new project description";
     cfg.setString("project", null, "description", desc);
 
@@ -85,7 +88,12 @@
     assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
     assertThat(gApi.projects().name(project.get()).get().description).isEqualTo(desc);
     fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("project", null, "description")).isEqualTo(desc);
+    assertThat(
+            projectOperations
+                .project(project)
+                .getConfig()
+                .getString("project", null, "description"))
+        .isEqualTo(desc);
     String changeRev = gApi.changes().id(id).get().currentRevision;
     String branchRev =
         gApi.projects().name(project.get()).branch(RefNames.REFS_CONFIG).get().revision;
@@ -96,56 +104,56 @@
   @Test
   @TestProjectInput(cloneAs = "user")
   public void onlyAdminMayUpdateProjectParent() throws Exception {
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     ProjectInput parent = new ProjectInput();
     parent.name = name("parent");
     parent.permissionsOnly = true;
     gApi.projects().create(parent);
 
-    setApiUser(user);
-    Config cfg = readProjectConfig();
-    assertThat(cfg.getString("access", null, "inheritFrom")).isAnyOf(null, allProjects.get());
+    requestScopeOperations.setApiUser(user.id());
+    Config cfg = projectOperations.project(project).getConfig();
+    assertThat(cfg).stringValue("access", null, "inheritFrom").isAnyOf(null, allProjects.get());
     cfg.setString("access", null, "inheritFrom", parent.name);
 
     PushOneCommit.Result r = createConfigChange(cfg);
     String id = r.getChangeId();
 
     gApi.changes().id(id).current().review(ReviewInput.approve());
-    try {
-      gApi.changes().id(id).current().submit();
-      fail("expected submit to fail");
-    } catch (ResourceConflictException e) {
-      int n = gApi.changes().id(id).info()._number;
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              "Failed to submit 1 change due to the following problems:\n"
-                  + "Change "
-                  + n
-                  + ": Change contains a project configuration that"
-                  + " changes the parent project.\n"
-                  + "The change must be submitted by a Gerrit administrator.");
-    }
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(id).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Failed to submit 1 change due to the following problems:\n"
+                + "Change "
+                + gApi.changes().id(id).info()._number
+                + ": Change contains a project configuration that"
+                + " changes the parent project.\n"
+                + "The change must be submitted by a Gerrit administrator.");
 
     assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(allProjects.get());
     fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("access", null, "inheritFrom"))
+    assertThat(
+            projectOperations.project(project).getConfig().getString("access", null, "inheritFrom"))
         .isAnyOf(null, allProjects.get());
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(id).current().submit();
     assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
     assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(parent.name);
     fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("access", null, "inheritFrom")).isEqualTo(parent.name);
+    assertThat(
+            projectOperations.project(project).getConfig().getString("access", null, "inheritFrom"))
+        .isEqualTo(parent.name);
   }
 
   @Test
   public void rejectDoubleInheritance() throws Exception {
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     // Create separate projects to test the config
-    Project.NameKey parent = createProject("projectToInheritFrom");
-    Project.NameKey child = createProject("projectWithMalformedConfig");
+    Project.NameKey parent = createProjectOverAPI("projectToInheritFrom", null, true, null);
+    Project.NameKey child = createProjectOverAPI("projectWithMalformedConfig", null, true, null);
 
     String config =
         gApi.projects()
@@ -164,7 +172,7 @@
     GitUtil.fetch(childRepo, RefNames.REFS_CONFIG + ":cfg");
     childRepo.reset("cfg");
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), childRepo, "Subject", "project.config", config);
+        pushFactory.create(admin.newIdent(), childRepo, "Subject", "project.config", config);
     PushOneCommit.Result res = push.to(RefNames.REFS_CONFIG);
     res.assertErrorStatus();
     res.assertMessage("cannot inherit from multiple projects");
@@ -175,27 +183,11 @@
     testRepo.reset(RefNames.REFS_CONFIG);
   }
 
-  private Config readProjectConfig() throws Exception {
-    RevWalk rw = testRepo.getRevWalk();
-    RevTree tree = rw.parseTree(testRepo.getRepository().resolve("HEAD"));
-    RevObject obj = rw.parseAny(testRepo.get(tree, "project.config"));
-    ObjectLoader loader = rw.getObjectReader().open(obj);
-    String text = new String(loader.getCachedBytes(), UTF_8);
-    Config cfg = new Config();
-    cfg.fromText(text);
-    return cfg;
-  }
-
   private PushOneCommit.Result createConfigChange(Config cfg) throws Exception {
     PushOneCommit.Result r =
         pushFactory
             .create(
-                db,
-                user.getIdent(),
-                testRepo,
-                "Update project config",
-                "project.config",
-                cfg.toText())
+                user.newIdent(), testRepo, "Update project config", "project.config", cfg.toText())
             .to("refs/for/refs/meta/config");
     r.assertOkStatus();
     return r;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index 865c7e0a..3b26459 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -26,6 +26,7 @@
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
@@ -34,6 +35,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -44,6 +46,7 @@
 import java.util.stream.Stream;
 import org.apache.http.Header;
 import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
 import org.apache.http.client.fluent.Executor;
 import org.apache.http.client.fluent.Request;
 import org.apache.http.cookie.Cookie;
@@ -78,11 +81,11 @@
     String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
     String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
 
-    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
-    assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
-    assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
-    assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
-    assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowCred).isNull();
+    assertWithMessage(ACCESS_CONTROL_MAX_AGE).that(maxAge).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS).that(allowMethods).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNull();
   }
 
   @Test
@@ -99,6 +102,30 @@
   }
 
   @Test
+  public void originsOnNotFoundException() throws Exception {
+    String url = "/changes/999/detail";
+    check(url, true, "http://example.com", adminRestSession, 404);
+    check(url, false, "http://friendsly", adminRestSession, 404);
+  }
+
+  @Test
+  public void originsOnBadRequestException() throws Exception {
+    String url = "/config/server/caches/?format=NONSENSE";
+    check(url, true, "http://example.com", adminRestSession, 400);
+    check(url, false, "http://friendsly", adminRestSession, 400);
+  }
+
+  @Test
+  public void originsOnForbidden() throws Exception {
+    Result change = createChange();
+    // Make change private to hide it
+    gApi.changes().id(change.getChangeId()).setPrivate(true, "now private");
+    String url = "/changes/" + change.getChangeId() + "/detail";
+    check(url, true, "http://example.com", anonymousRestSession, 404);
+    check(url, false, "http://friendsly", anonymousRestSession, 404);
+  }
+
+  @Test
   public void putWithServerOriginAcceptedWithNoCorsResponseHeaders() throws Exception {
     Result change = createChange();
     String origin = adminRestSession.url();
@@ -136,7 +163,7 @@
     res.assertOK();
 
     String vary = res.getHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
+    assertWithMessage(VARY).that(vary).isNotNull();
     assertThat(Splitter.on(", ").splitToList(vary))
         .containsExactly(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS);
     checkCors(res, true, origin);
@@ -179,15 +206,15 @@
     BasicCookieStore cookies = new BasicCookieStore();
     Executor http = Executor.newInstance().cookieStore(cookies);
 
-    Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id.get());
-    HttpResponse r = http.execute(req).returnResponse();
+    Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id().get());
+    http.execute(req);
     String auth = null;
     for (Cookie c : cookies.getCookies()) {
       if ("GerritAccount".equals(c.getName())) {
         auth = c.getValue();
       }
     }
-    assertThat(auth).named("GerritAccount cookie").isNotNull();
+    assertWithMessage("GerritAccount cookie").that(auth).isNotNull();
     cookies.clear();
 
     UrlEncoded url =
@@ -202,20 +229,22 @@
     req.setHeader(ORIGIN, origin);
     req.bodyByteArray("{\"topic\":\"test-xd\"}".getBytes(StandardCharsets.US_ASCII));
 
-    r = http.execute(req).returnResponse();
+    HttpResponse r = http.execute(req).returnResponse();
     assertThat(r.getStatusLine().getStatusCode()).isEqualTo(200);
 
     Header vary = r.getFirstHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
-    assertThat(Splitter.on(", ").splitToList(vary.getValue())).named(VARY).contains(ORIGIN);
+    assertWithMessage(VARY).that(vary).isNotNull();
+    assertWithMessage(VARY).that(Splitter.on(", ").splitToList(vary.getValue())).contains(ORIGIN);
 
     Header allowOrigin = r.getFirstHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
-    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNotNull();
-    assertThat(allowOrigin.getValue()).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
+    assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isNotNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin.getValue()).isEqualTo(origin);
 
     Header allowAuth = r.getFirstHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
-    assertThat(allowAuth).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNotNull();
-    assertThat(allowAuth.getValue()).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
+    assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowAuth).isNotNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS)
+        .that(allowAuth.getValue())
+        .isEqualTo("true");
 
     checkTopic(change, "test-xd");
   }
@@ -238,7 +267,7 @@
 
   private void checkTopic(Result change, @Nullable String topic) throws RestApiException {
     ChangeInfo info = gApi.changes().id(change.getChangeId()).get();
-    StringSubject t = assertThat(info.topic).named("topic");
+    StringSubject t = assertWithMessage("topic").that(info.topic);
     if (topic != null) {
       t.isEqualTo(topic);
     } else {
@@ -247,16 +276,22 @@
   }
 
   private void check(String url, boolean accept, String origin) throws Exception {
+    check(url, accept, origin, adminRestSession, HttpStatus.SC_OK);
+  }
+
+  private void check(
+      String url, boolean accept, String origin, RestSession restSession, int httpStatusCode)
+      throws Exception {
     Header hdr = new BasicHeader(ORIGIN, origin);
-    RestResponse r = adminRestSession.getWithHeader(url, hdr);
-    r.assertOK();
+    RestResponse r = restSession.getWithHeader(url, hdr);
+    r.assertStatus(httpStatusCode);
     checkCors(r, accept, origin);
   }
 
   private void checkCors(RestResponse r, boolean accept, String origin) {
     String vary = r.getHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
-    assertThat(Splitter.on(", ").splitToList(vary)).named(VARY).contains(ORIGIN);
+    assertWithMessage(VARY).that(vary).isNotNull();
+    assertWithMessage(VARY).that(Splitter.on(", ").splitToList(vary)).contains(ORIGIN);
 
     String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
     String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
@@ -264,28 +299,28 @@
     String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
     String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
     if (accept) {
-      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
-      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
-      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isEqualTo("600");
+      assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isEqualTo(origin);
+      assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowCred).isEqualTo("true");
+      assertWithMessage(ACCESS_CONTROL_MAX_AGE).that(maxAge).isEqualTo("600");
 
-      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNotNull();
-      assertThat(Splitter.on(", ").splitToList(allowMethods))
-          .named(ACCESS_CONTROL_ALLOW_METHODS)
+      assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS).that(allowMethods).isNotNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS)
+          .that(Splitter.on(", ").splitToList(allowMethods))
           .containsExactly("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
 
-      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNotNull();
-      assertThat(Splitter.on(", ").splitToList(allowHeaders))
-          .named(ACCESS_CONTROL_ALLOW_HEADERS)
+      assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNotNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS)
+          .that(Splitter.on(", ").splitToList(allowHeaders))
           .containsExactlyElementsIn(
               Stream.of(AUTHORIZATION, CONTENT_TYPE, "X-Gerrit-Auth", "X-Requested-With")
                   .map(s -> s.toLowerCase(Locale.US))
                   .collect(ImmutableSet.toImmutableSet()));
     } else {
-      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
-      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
-      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
-      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
-      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowCred).isNull();
+      assertWithMessage(ACCESS_CONTROL_MAX_AGE).that(maxAge).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS).that(allowMethods).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNull();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index c723082..43cf655 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
@@ -28,6 +29,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -46,11 +49,13 @@
 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.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -65,6 +70,9 @@
 import org.junit.Test;
 
 public class CreateChangeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @BeforeClass
   public static void setTimeForTesting() {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -101,9 +109,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 +119,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
@@ -152,18 +158,18 @@
 
   @Test
   public void notificationsOnChangeCreation() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     watch(project.get());
 
     // check that watcher is notified
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review.");
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.body()).contains(admin.fullName() + " has uploaded this change for review.");
 
     // check that watcher is not notified if notify=NONE
     sender.clear();
@@ -182,7 +188,7 @@
       assertThat(message)
           .contains(
               String.format(
-                  "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.getIdent().getEmailAddress()));
+                  "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.newIdent().getEmailAddress()));
     } finally {
       setSignedOffByFooter(false);
     }
@@ -203,7 +209,7 @@
       assertThat(message)
           .contains(
               String.format(
-                  "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.getIdent().getEmailAddress()));
+                  "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.newIdent().getEmailAddress()));
     } finally {
       setSignedOffByFooter(false);
     }
@@ -257,7 +263,11 @@
   public void createChangeWithoutAccessToParentCommitFails() throws Exception {
     Map<String, PushOneCommit.Result> results =
         changeInTwoBranches("invisible-branch", "a.txt", "visible-branch", "b.txt");
-    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/heads/invisible-branch").group(REGISTERED_USERS))
+        .update();
 
     ChangeInput in = newChangeInput(ChangeStatus.NEW);
     in.branch = "visible-branch";
@@ -267,31 +277,17 @@
   }
 
   @Test
-  public void createChangeOnInvisibleBranchFails() throws Exception {
-    changeInTwoBranches("invisible-branch", "a.txt", "branchB", "b.txt");
-    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
-
-    ChangeInput in = newChangeInput(ChangeStatus.NEW);
-    in.branch = "invisible-branch";
-    assertCreateFails(in, ResourceNotFoundException.class, "");
-  }
-
-  @Test
   public void noteDbCommit() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
     ChangeInfo c = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit =
-          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+          rw.parseCommit(repo.exactRef(changeMetaRef(Change.id(c._number))).getObjectId());
 
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
       PersonIdent expectedAuthor =
-          changeNoteUtil
-              .getLegacyChangeNoteWrite()
-              .newIdent(getAccount(admin.id), c.created, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id()), c.created, serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
@@ -398,7 +394,7 @@
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
 
-    ObjectId remoteId = getRemoteHead();
+    ObjectId remoteId = projectOperations.project(project).getHead("master");
     assertThat(remoteId).isNotEqualTo(commitId);
 
     ChangeInput in = newMergeChangeInput("master", commitId.getName(), "");
@@ -459,6 +455,38 @@
     assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
   }
 
+  @Test
+  public void createChangeOnExistingBranchNotPermitted() throws Exception {
+    createBranch(BranchNameKey.create(project, "foo"));
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.branch = "foo";
+
+    assertCreateFails(input, ResourceNotFoundException.class, "ref refs/heads/foo not found");
+  }
+
+  @Test
+  public void createChangeOnNonExistingBranchNotPermitted() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.branch = "foo";
+    // sets this option to be true to make sure permission check happened before this option could
+    // be considered.
+    input.newBranch = true;
+
+    assertCreateFails(input, ResourceNotFoundException.class, "ref refs/heads/foo not found");
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -472,12 +500,20 @@
   private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
     ChangeInfo out = gApi.changes().create(in).get();
     assertThat(out.project).isEqualTo(in.project);
-    assertThat(out.branch).isEqualTo(in.branch);
+    assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
     assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
-    assertThat(out.isPrivate).isEqualTo(in.isPrivate);
-    assertThat(out.workInProgress).isEqualTo(in.workInProgress);
+    if (in.isPrivate) {
+      assertThat(out.isPrivate).isTrue();
+    } else {
+      assertThat(out.isPrivate).isNull();
+    }
+    if (in.workInProgress) {
+      assertThat(out.workInProgress).isTrue();
+    } else {
+      assertThat(out.workInProgress).isNull();
+    }
     assertThat(out.revisions).hasSize(1);
     assertThat(out.submitted).isNull();
     assertThat(in.status).isEqualTo(ChangeStatus.NEW);
@@ -487,19 +523,18 @@
   private void assertCreateFails(
       ChangeInput in, Class<? extends RestApiException> errType, String errSubstring)
       throws Exception {
-    exception.expect(errType);
-    exception.expectMessage(errSubstring);
-    gApi.changes().create(in);
+    Throwable thrown = assertThrows(errType, () -> gApi.changes().create(in));
+    assertThat(thrown).hasMessageThat().contains(errSubstring);
   }
 
   // TODO(davido): Expose setting of account preferences in the API
   private void setSignedOffByFooter(boolean value) throws Exception {
-    RestResponse r = adminRestSession.get("/accounts/" + admin.email + "/preferences");
+    RestResponse r = adminRestSession.get("/accounts/" + admin.email() + "/preferences");
     r.assertOK();
     GeneralPreferencesInfo i = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
     i.signedOffBy = value;
 
-    r = adminRestSession.put("/accounts/" + admin.email + "/preferences", i);
+    r = adminRestSession.put("/accounts/" + admin.email() + "/preferences", i);
     r.assertOK();
     GeneralPreferencesInfo o = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
 
@@ -509,7 +544,7 @@
       assertThat(o.signedOffBy).isNull();
     }
 
-    resetCurrentApiUser();
+    requestScopeOperations.resetCurrentApiUser();
   }
 
   private ChangeInput newMergeChangeInput(String targetBranch, String sourceRef, String strategy) {
@@ -543,24 +578,24 @@
     // create a initial commit in master
     Result initialCommit =
         pushFactory
-            .create(db, user.getIdent(), testRepo, "initial commit", "readme.txt", "initial commit")
+            .create(user.newIdent(), testRepo, "initial commit", "readme.txt", "initial commit")
             .to("refs/heads/master");
     initialCommit.assertOkStatus();
 
     // create two new branches
-    createBranch(new Branch.NameKey(project, branchA));
-    createBranch(new Branch.NameKey(project, branchB));
+    createBranch(BranchNameKey.create(project, branchA));
+    createBranch(BranchNameKey.create(project, branchB));
 
     // create a commit in branchA
     Result changeA =
         pushFactory
-            .create(db, user.getIdent(), testRepo, "change A", fileA, "A content")
+            .create(user.newIdent(), testRepo, "change A", fileA, "A content")
             .to("refs/heads/" + branchA);
     changeA.assertOkStatus();
 
     // create a commit in branchB
     PushOneCommit commitB =
-        pushFactory.create(db, user.getIdent(), testRepo, "change B", fileB, "B content");
+        pushFactory.create(user.newIdent(), testRepo, "change B", fileB, "B content");
     commitB.setParent(initialCommit.getCommit());
     Result changeB = commitB.to("refs/heads/" + branchB);
     changeB.assertOkStatus();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 7e5ebdb..ef82768 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -29,12 +30,15 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import org.junit.Test;
 
 public class DeleteVoteIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void deleteVoteOnChange() throws Exception {
     deleteVote(false);
@@ -51,7 +55,7 @@
 
     PushOneCommit.Result r2 = amendChange(r.getChangeId());
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     recommend(r.getChangeId());
 
     sender.clear();
@@ -60,7 +64,7 @@
             + r.getChangeId()
             + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
             + "/reviewers/"
-            + user.getId().toString()
+            + user.id().toString()
             + "/votes/Code-Review";
 
     RestResponse response = adminRestSession.delete(endPoint);
@@ -69,17 +73,17 @@
     List<FakeEmailSender.Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     FakeEmailSender.Message msg = messages.get(0);
-    assertThat(msg.rcpt()).containsExactly(user.emailAddress);
-    assertThat(msg.body()).contains(admin.fullName + " has removed a vote on this change.\n");
+    assertThat(msg.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(msg.body()).contains(admin.fullName() + " has removed a vote from this change.");
     assertThat(msg.body())
-        .contains("Removed Code-Review+1 by " + user.fullName + " <" + user.email + ">\n");
+        .contains("Removed Code-Review+1 by " + user.fullName() + " <" + user.email() + ">\n");
 
     endPoint =
         "/changes/"
             + r.getChangeId()
             + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
             + "/reviewers/"
-            + user.getId().toString()
+            + user.id().toString()
             + "/votes";
 
     response = adminRestSession.get(endPoint);
@@ -93,13 +97,13 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
-    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
+    assertThat(message.author._accountId).isEqualTo(admin.id().get());
     assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+        .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
 
   private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+    return Iterables.transform(r, a -> Account.id(a._accountId));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index 48a1a1e..542c6a9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.Objects.requireNonNull;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
@@ -26,23 +28,22 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
 import org.junit.AfterClass;
-import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
 @NoHttpd
 public class HashtagsIT extends AbstractDaemonTest {
-  @Before
-  public void before() {
-    assume().that(notesMigration.readChanges()).isTrue();
-  }
+  @Inject private ProjectOperations projectOperations;
 
   @BeforeClass
   public static void setTimeForTesting() {
@@ -54,6 +55,8 @@
     TestTimeUtil.useSystemTime();
   }
 
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void getNoHashtags() throws Exception {
     // Get on a change with no hashtags returns an empty list.
@@ -81,9 +84,9 @@
   public void addInvalidHashtag() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("hashtags may not contain commas");
-    addHashtags(r, "invalid,hashtag");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> addHashtags(r, "invalid,hashtag"));
+    assertThat(thrown).hasMessageThat().contains("hashtags may not contain commas");
   }
 
   @Test
@@ -260,17 +263,20 @@
   @Test
   public void addHashtagWithoutPermissionNotAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit hashtags not permitted");
-    addHashtags(r, "MyHashtag");
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown = assertThrows(AuthException.class, () -> addHashtags(r, "MyHashtag"));
+    assertThat(thrown).hasMessageThat().contains("edit hashtags not permitted");
   }
 
   @Test
   public void addHashtagWithPermissionAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
-    grant(project, "refs/heads/master", Permission.EDIT_HASHTAGS, false, REGISTERED_USERS);
-    setApiUser(user);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.EDIT_HASHTAGS).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
     addHashtags(r, "MyHashtag");
     assertThatGet(r).containsExactly("MyHashtag");
     assertMessage(r, "Hashtag added: MyHashtag");
@@ -298,7 +304,7 @@
 
   private void assertNoNewMessageSince(PushOneCommit.Result r, ChangeMessageInfo expected)
       throws Exception {
-    checkNotNull(expected);
+    requireNonNull(expected);
     ChangeMessageInfo last = getLastMessage(r);
     assertThat(last.message).isEqualTo(expected.message);
     assertThat(last.id).isEqualTo(expected.id);
@@ -307,7 +313,7 @@
   private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
     ChangeMessageInfo lastMessage =
         Iterables.getLast(gApi.changes().id(r.getChange().getId().get()).get().messages, null);
-    assertThat(lastMessage).named(lastMessage.message).isNotNull();
+    assertWithMessage(lastMessage.message).that(lastMessage).isNotNull();
     return lastMessage;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 6555fe8..e8fd295 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -15,22 +15,31 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
 public class IndexChangeIT extends AbstractDaemonTest {
+  @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void indexChange() throws Exception {
     String changeId = createChange().getChangeId();
@@ -40,7 +49,11 @@
   @Test
   public void indexChangeOnNonVisibleBranch() throws Exception {
     String changeId = createChange().getChangeId();
-    blockRead("refs/heads/master");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     userRestSession.post("/changes/" + changeId + "/index/").assertNotFound();
   }
 
@@ -48,56 +61,57 @@
   public void indexChangeAfterOwnerLosesVisibility() throws Exception {
     // Create a test group with 2 users as members
     TestAccount user2 = accountCreator.user2();
-    String group = createGroup("test");
-    gApi.groups().id(group).addMembers("admin", "user", user2.username);
+    AccountGroup.UUID groupId = groupOperations.newGroup().name("test").create();
+    String group = groupOperations.group(groupId).get().name();
+    gApi.groups().id(group).addMembers("admin", "user", user2.username());
 
     // Create a project and restrict its visibility to the group
-    Project.NameKey p = createProject("p");
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(
-          u.getConfig(),
-          Permission.READ,
-          groupCache.get(new AccountGroup.NameKey(group)).get().getGroupUUID(),
-          "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    Project.NameKey p = projectOperations.newProject().create();
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(
+            allow(Permission.READ)
+                .ref("refs/*")
+                .group(groupCache.get(AccountGroup.nameKey(group)).get().getGroupUUID()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Clone it and push a change as a regular user
     TestRepository<InMemoryRepository> repo = cloneProject(p, user);
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), repo);
+    PushOneCommit push = pushFactory.create(user.newIdent(), repo);
     PushOneCommit.Result result = push.to("refs/for/master");
     result.assertOkStatus();
-    assertThat(result.getChange().change().getOwner()).isEqualTo(user.id);
+    assertThat(result.getChange().change().getOwner()).isEqualTo(user.id());
     String changeId = result.getChangeId();
 
     // User can see the change and it is mergeable
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     List<ChangeInfo> changes = gApi.changes().query(changeId).get();
     assertThat(changes).hasSize(1);
     assertThat(changes.get(0).mergeable).isNotNull();
 
     // Other user can see the change and it is mergeable
-    setApiUser(user2);
+    requestScopeOperations.setApiUser(user2.id());
     changes = gApi.changes().query(changeId).get();
     assertThat(changes).hasSize(1);
     assertThat(changes.get(0).mergeable).isTrue();
 
     // Remove the user from the group so they can no longer see the project
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.groups().id(group).removeMembers("user");
 
     // User can no longer see the change
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     changes = gApi.changes().query(changeId).get();
     assertThat(changes).isEmpty();
 
     // Reindex the change
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(changeId).index();
 
     // Other user can still see the change and it is still mergeable
-    setApiUser(user2);
+    requestScopeOperations.setApiUser(user2.id());
     changes = gApi.changes().query(changeId).get();
     assertThat(changes).hasSize(1);
     assertThat(changes.get(0).mergeable).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
index 174280d..0db3508 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
@@ -48,8 +48,7 @@
     String subject = "Change subject";
     String fileName = "a.txt";
     PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, subject, fileName, content, baseChangeId);
+        pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content, baseChangeId);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     return r;
@@ -66,7 +65,7 @@
   public void currentRevision() throws Exception {
     ChangeInfo c = get(changeId, CURRENT_REVISION);
     assertThat(c.currentRevision).isEqualTo(commitId(2));
-    assertThat(c.revisions.keySet()).containsAllIn(ImmutableSet.of(commitId(2)));
+    assertThat(c.revisions.keySet()).containsAtLeastElementsIn(ImmutableSet.of(commitId(2)));
     assertThat(c.revisions.get(commitId(2))._number).isEqualTo(3);
   }
 
@@ -75,7 +74,7 @@
     ChangeInfo c = get(changeId, CURRENT_REVISION, MESSAGES);
     assertThat(c.revisions).hasSize(1);
     assertThat(c.currentRevision).isEqualTo(commitId(2));
-    assertThat(c.revisions.keySet()).containsAllIn(ImmutableSet.of(commitId(2)));
+    assertThat(c.revisions.keySet()).containsAtLeastElementsIn(ImmutableSet.of(commitId(2)));
     assertThat(c.revisions.get(commitId(2))._number).isEqualTo(3);
   }
 
@@ -84,7 +83,7 @@
     ChangeInfo c = get(changeId, ALL_REVISIONS);
     assertThat(c.currentRevision).isEqualTo(commitId(2));
     assertThat(c.revisions.keySet())
-        .containsAllIn(ImmutableSet.of(commitId(0), commitId(1), commitId(2)));
+        .containsAtLeastElementsIn(ImmutableSet.of(commitId(0), commitId(1), commitId(2)));
     assertThat(c.revisions.get(commitId(0))._number).isEqualTo(1);
     assertThat(c.revisions.get(commitId(1))._number).isEqualTo(2);
     assertThat(c.revisions.get(commitId(2))._number).isEqualTo(3);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index be0879a..55cff17 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -16,12 +16,18 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 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;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
@@ -29,12 +35,14 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
+import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -42,13 +50,16 @@
 
 @NoHttpd
 public class MoveChangeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void moveChangeWithShortRef() throws Exception {
     // Move change to a different branch using short ref name
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    move(r.getChangeId(), newBranch.getShortName());
+    move(r.getChangeId(), newBranch.shortName());
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
   }
 
@@ -56,9 +67,9 @@
   public void moveChangeWithFullRef() throws Exception {
     // Move change to a different branch using full ref name
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    move(r.getChangeId(), newBranch.get());
+    move(r.getChangeId(), newBranch.branch());
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
   }
 
@@ -66,10 +77,10 @@
   public void moveChangeWithMessage() throws Exception {
     // Provide a message using --message flag
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     String moveMessage = "Moving for the move test";
-    move(r.getChangeId(), newBranch.get(), moveMessage);
+    move(r.getChangeId(), newBranch.branch(), moveMessage);
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
     StringBuilder expectedMessage = new StringBuilder();
     expectedMessage.append("Change destination moved from master to moveTest");
@@ -82,49 +93,59 @@
   public void moveChangeToSameRefAsCurrent() throws Exception {
     // Move change to the branch same as change's destination
     PushOneCommit.Result r = createChange();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already destined for the specified branch");
-    move(r.getChangeId(), r.getChange().change().getDest().get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> move(r.getChangeId(), r.getChange().change().getDest().branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Change is already destined for the specified branch");
   }
 
   @Test
   public void moveChangeToSameChangeId() throws Exception {
     // Move change to a branch with existing change with same change ID
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     int changeNum = r.getChange().change().getChangeId();
-    createChange(newBranch.get(), r.getChangeId());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Destination "
-            + newBranch.getShortName()
-            + " has a different change with same change key "
-            + r.getChangeId());
-    move(changeNum, newBranch.get());
+    createChange(newBranch.branch(), r.getChangeId());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> move(changeNum, newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Destination "
+                + newBranch.shortName()
+                + " has a different change with same change key "
+                + r.getChangeId());
   }
 
   @Test
   public void moveChangeToNonExistentRef() throws Exception {
     // Move change to a non-existing branch
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "does_not_exist");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Destination " + newBranch.get() + " not found in the project");
-    move(r.getChangeId(), newBranch.get());
+    BranchNameKey newBranch =
+        BranchNameKey.create(r.getChange().change().getProject(), "does_not_exist");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Destination " + newBranch.branch() + " not found in the project");
   }
 
   @Test
   public void moveClosedChange() throws Exception {
     // Move a change which is not open
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     merge(r);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is merged");
-    move(r.getChangeId(), newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("Change is merged");
   }
 
   @Test
@@ -139,49 +160,57 @@
         .parent(r1.getCommit())
         .parent(r2.getCommit())
         .message("Move change Merge Commit")
-        .author(admin.getIdent())
-        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+        .author(admin.newIdent())
+        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
     RevCommit c = commitBuilder.create();
     pushHead(testRepo, "refs/for/master", false, false);
 
     // Try to move the merge commit to another branch
-    Branch.NameKey newBranch = new Branch.NameKey(r1.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch =
+        BranchNameKey.create(r1.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Merge commit cannot be moved");
-    move(GitUtil.getChangeId(testRepo, c).get(), newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> move(GitUtil.getChangeId(testRepo, c).get(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("Merge commit cannot be moved");
   }
 
   @Test
   public void moveChangeToBranchWithoutUploadPerms() throws Exception {
     // Move change to a destination where user doesn't have upload permissions
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
+    BranchNameKey newBranch =
+        BranchNameKey.create(r.getChange().change().getProject(), "blocked_branch");
     createBranch(newBranch);
-    block(
-        "refs/for/" + newBranch.get(),
-        Permission.PUSH,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
-    move(r.getChangeId(), newBranch.get());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/for/" + newBranch.branch()).group(REGISTERED_USERS))
+        .update();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("move not permitted");
   }
 
   @Test
   public void moveChangeFromBranchWithoutAbandonPerms() throws Exception {
     // Move change for which user does not have abandon permissions
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    block(
-        r.getChange().change().getDest().get(),
-        Permission.ABANDON,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
-    move(r.getChangeId(), newBranch.get());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            block(Permission.ABANDON)
+                .ref(r.getChange().change().getDest().branch())
+                .group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("move not permitted");
   }
 
   @Test
@@ -194,52 +223,56 @@
     int changeNum = r.getChange().change().getChangeId();
 
     // Create a branch with that same commit
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     BranchInput bi = new BranchInput();
     bi.revision = r.getCommit().name();
-    gApi.projects().name(newBranch.getParentKey().get()).branch(newBranch.get()).create(bi);
+    gApi.projects().name(newBranch.project().get()).branch(newBranch.branch()).create(bi);
 
     // Try to move the change to the branch with the same commit
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Current patchset revision is reachable from tip of " + newBranch.get());
-    move(changeNum, newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> move(changeNum, newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Current patchset revision is reachable from tip of " + newBranch.branch());
   }
 
   @Test
   public void moveChangeWithCurrentPatchSetLocked() throws Exception {
     // Move change that is locked
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
 
+    LabelType patchSetLock = TestLabels.patchSetLock();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType patchSetLock = Util.patchSetLock();
       u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
-      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(patchSetLock.getName()),
-          0,
-          1,
-          registeredUsers,
-          "refs/heads/*");
       u.save();
     }
-    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(patchSetLock.getName())
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(0, 1))
+        .update();
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("The current patch set of change %s is locked", r.getChange().getId()));
-    move(r.getChangeId(), newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format("The current patch set of change %s is locked", r.getChange().getId()));
   }
 
   @Test
   public void moveChangeOnlyKeepVetoVotes() throws Exception {
     // A vote for a label will be kept after moving if the label's function is *WithBlock and the
     // vote holds the minimum value.
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     String codeReviewLabel = "Code-Review"; // 'Code-Review' uses 'MaxWithBlock' function.
     String testLabelA = "Label-A";
@@ -249,16 +282,13 @@
     configLabel(testLabelB, LabelFunction.MAX_NO_BLOCK);
     configLabel(testLabelC, LabelFunction.NO_BLOCK);
 
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelB), -1, +1, registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelC), -1, +1, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .add(allowLabel(testLabelB).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .add(allowLabel(testLabelC).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
 
     String changeId = createChange().getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.reject());
@@ -271,9 +301,9 @@
     input.label(testLabelC, -1);
     gApi.changes().id(changeId).current().review(input);
 
-    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().keySet())
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email()).votes().keySet())
         .containsExactly(codeReviewLabel, testLabelA, testLabelB, testLabelC);
-    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().values())
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email()).votes().values())
         .containsExactly((short) -2, (short) -1, (short) -1, (short) -1);
 
     // Move the change to the 'foo' branch.
@@ -282,16 +312,63 @@
     assertThat(gApi.changes().id(changeId).get().branch).isEqualTo("foo");
 
     // 'Code-Review -2' and 'Label-A -1' will be kept.
-    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().values())
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email()).votes().values())
         .containsExactly((short) -2, (short) -1, (short) 0, (short) 0);
 
     // Move the change back to 'master'.
     move(changeId, "master");
     assertThat(gApi.changes().id(changeId).get().branch).isEqualTo("master");
-    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().values())
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email()).votes().values())
         .containsExactly((short) -2, (short) -1, (short) 0, (short) 0);
   }
 
+  @Test
+  public void moveToBranchWithoutLabel() throws Exception {
+    createBranch(BranchNameKey.create(project, "foo"));
+    String testLabelA = "Label-A";
+    configLabel(testLabelA, LabelFunction.MAX_WITH_BLOCK, Arrays.asList("refs/heads/master"));
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/master").group(REGISTERED_USERS).range(-1, +1))
+        .update();
+
+    String changeId = createChange().getChangeId();
+
+    ReviewInput input = new ReviewInput();
+    input.label(testLabelA, -1);
+    gApi.changes().id(changeId).current().review(input);
+
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email()).votes().keySet())
+        .containsExactly(testLabelA);
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email()).votes().values())
+        .containsExactly((short) -1);
+
+    move(changeId, "foo");
+
+    // TODO(dpursehouse): Assert about state of labels after move
+  }
+
+  @Test
+  public void moveNoDestinationBranchSpecified() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> move(r.getChangeId(), null));
+    assertThat(thrown).hasMessageThat().contains("destination branch is required");
+  }
+
+  @Test
+  @GerritConfig(name = "change.move", value = "false")
+  public void moveCanBeDisabledByConfig() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> move(r.getChangeId(), null));
+    assertThat(thrown).hasMessageThat().contains("move changes endpoint is disabled");
+  }
+
   private void move(int changeNum, String destination) throws RestApiException {
     gApi.changes().id(changeNum).move(destination);
   }
@@ -308,7 +385,7 @@
   }
 
   private PushOneCommit.Result createChange(String branch, String changeId) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo, changeId);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, changeId);
     PushOneCommit.Result result = push.to("refs/for/" + branch);
     result.assertOkStatus();
     return result;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
new file mode 100644
index 0000000..649c7ae
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+public class PluginFieldsIT extends AbstractPluginFieldsTest {
+  private static final Gson GSON = OutputFormat.JSON.newGson();
+
+  @Test
+  public void queryChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
+  }
+
+  @Test
+  public void getChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void getChangeDetailWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  @Test
+  public void queryChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
+  }
+
+  @Test
+  public void getChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void getChangeDetailWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  @Test
+  public void queryChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
+        (id, opts) -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id, opts))));
+  }
+
+  @Test
+  public void getChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))),
+        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
+  }
+
+  @Test
+  public void getChangeDetailWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
+        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
+  }
+
+  private String changeQueryUrl(Change.Id id) {
+    return changeQueryUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeQueryUrl(Change.Id id, ImmutableListMultimap<String, String> opts) {
+    String url = "/changes/?q=" + id;
+    String queryString = buildQueryString(opts);
+    if (!queryString.isEmpty()) {
+      url += "&" + queryString;
+    }
+    return url;
+  }
+
+  private String changeUrl(Change.Id id) {
+    return changeUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeUrl(Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return changeUrl(id, "", pluginOptions);
+  }
+
+  private String changeDetailUrl(Change.Id id) {
+    return changeDetailUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeDetailUrl(
+      Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return changeUrl(id, "/detail", pluginOptions);
+  }
+
+  private String changeUrl(
+      Change.Id id, String suffix, ImmutableListMultimap<String, String> pluginOptions) {
+    String url = "/changes/" + project + "~" + id + suffix;
+    String queryString = buildQueryString(pluginOptions);
+    if (!queryString.isEmpty()) {
+      url += "?" + queryString;
+    }
+    return url;
+  }
+
+  private static String buildQueryString(ImmutableListMultimap<String, String> opts) {
+    return Joiner.on('&').withKeyValueSeparator('=').join(opts.entries());
+  }
+
+  @Nullable
+  private static List<MyInfo> pluginInfoFromSingletonList(RestResponse res) throws Exception {
+    res.assertOK();
+    List<Map<String, Object>> changeInfos =
+        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
+    assertThat(changeInfos).hasSize(1);
+    return decodeRawPluginsList(GSON, changeInfos.get(0).get("plugins"));
+  }
+
+  @Nullable
+  private List<MyInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
+    res.assertOK();
+    Map<String, Object> changeInfo =
+        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+    return decodeRawPluginsList(GSON, changeInfo.get("plugins"));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
index 0ece00a..a6fa9fc5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
@@ -15,16 +15,19 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -34,11 +37,12 @@
 public class PrivateByDefaultIT extends AbstractDaemonTest {
   private Project.NameKey project1;
   private Project.NameKey project2;
+  @Inject private ProjectOperations projectOperations;
 
   @Before
   public void setUp() throws Exception {
-    project1 = createProject("project-1");
-    project2 = createProject("project-2", project1);
+    project1 = projectOperations.newProject().create();
+    project2 = projectOperations.newProject().parent(project1).create();
     setPrivateByDefault(project1, InheritableBoolean.FALSE);
   }
 
@@ -78,9 +82,9 @@
     setPrivateByDefault(project2, InheritableBoolean.TRUE);
 
     ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("private changes are disabled");
-    gApi.changes().create(input);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().create(input));
+    assertThat(thrown).hasMessageThat().contains("private changes are disabled");
   }
 
   @Test
@@ -118,7 +122,7 @@
 
     TestRepository<InMemoryRepository> testRepo = cloneProject(project2);
     PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%private");
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%private");
     result.assertErrorStatus();
   }
 
@@ -127,14 +131,14 @@
   public void pushDraftsWithPrivateByDefaultAndDisablePrivateChangesTrue() throws Exception {
     setPrivateByDefault(project2, InheritableBoolean.TRUE);
 
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project2).getHead("master");
     TestRepository<InMemoryRepository> testRepo = cloneProject(project2);
     PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%draft");
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%draft");
     result.assertErrorStatus();
 
     testRepo.reset(initialHead);
-    result = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    result = pushFactory.create(admin.newIdent(), testRepo).to("refs/drafts/master");
     result.assertErrorStatus();
   }
 
@@ -151,7 +155,7 @@
 
   private PushOneCommit.Result createChange(Project.NameKey proj, String ref) throws Exception {
     TestRepository<InMemoryRepository> testRepo = cloneProject(proj);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result result = push.to(ref);
     result.assertOkStatus();
     return result;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 8cd1770..16b7690 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -31,23 +31,17 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
 public class SubmitByCherryPickIT extends AbstractSubmit {
   @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -55,12 +49,12 @@
   }
 
   @Test
-  public void submitWithCherryPickIfFastForwardPossible() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithCherryPickIfFastForwardPossible() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     assertThat(newHead.getParent(0)).isEqualTo(change.getCommit().getParent(0));
 
     assertRefUpdatedEvents(initialHead, newHead);
@@ -68,24 +62,24 @@
   }
 
   @Test
-  public void submitWithCherryPick() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithCherryPick() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     assertThat(newHead.getParentCount()).isEqualTo(1);
     assertThat(newHead.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 2, newHead);
     assertSubmitter(change2.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 2);
-    assertPersonEquals(admin.getIdent(), newHead.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), newHead.getCommitterIdent());
+    assertPersonEquals(admin.newIdent(), newHead.getAuthorIdent());
+    assertPersonEquals(admin.newIdent(), newHead.getCommitterIdent());
 
     assertRefUpdatedEvents(initialHead, headAfterFirstSubmit, headAfterFirstSubmit, newHead);
     assertChangeMergedEvents(
@@ -93,20 +87,13 @@
   }
 
   @Test
-  public void changeMessageOnSubmit() throws Exception {
+  public void changeMessageOnSubmit() throws Throwable {
     PushOneCommit.Result change = createChange();
     RegistrationHandle handle =
         changeMessageModifiers.add(
-            new ChangeMessageModifier() {
-              @Override
-              public String onSubmit(
-                  String newCommitMessage,
-                  RevCommit original,
-                  RevCommit mergeTip,
-                  Branch.NameKey destination) {
-                return newCommitMessage + "Custom: " + destination.get();
-              }
-            });
+            "gerrit",
+            (newCommitMessage, original, mergeTip, destination) ->
+                newCommitMessage + "Custom: " + destination.branch());
     try {
       submit(change.getChangeId());
     } finally {
@@ -122,20 +109,20 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
 
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, true);
-    RevCommit headAfterThirdSubmit = getRemoteHead();
+    RevCommit headAfterThirdSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
@@ -160,12 +147,12 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
@@ -177,7 +164,7 @@
             + "merged due to a path conflict. Please rebase the change locally and "
             + "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(newHead);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
 
@@ -186,18 +173,18 @@
   }
 
   @Test
-  public void submitOutOfOrder() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitOutOfOrder() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "different content");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterSecondSubmit);
@@ -214,12 +201,12 @@
   }
 
   @Test
-  public void submitOutOfOrder_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitOutOfOrder_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "b.txt", "different content");
@@ -232,7 +219,7 @@
             + "merged due to a path conflict. Please rebase the change locally and "
             + "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(newHead);
     assertCurrentRevision(change3.getChangeId(), 1, change3.getCommit());
     assertNoSubmitter(change3.getChangeId(), 1);
 
@@ -241,8 +228,8 @@
   }
 
   @Test
-  public void submitMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -269,8 +256,8 @@
   }
 
   @Test
-  public void submitDependentNonConflictingChangesOutOfOrder() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitDependentNonConflictingChangesOutOfOrder() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -279,11 +266,11 @@
 
     // Submit succeeds; change2 is successfully cherry-picked onto head.
     submit(change2.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     // Submit succeeds; change is successfully cherry-picked onto head
     // (which was change2's cherry-pick).
     submit(change.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
 
     // change is the new tip.
     List<RevCommit> log = getRemoteLog();
@@ -305,8 +292,8 @@
   }
 
   @Test
-  public void submitDependentConflictingChangesOutOfOrder() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitDependentConflictingChangesOutOfOrder() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b1");
@@ -337,8 +324,8 @@
   }
 
   @Test
-  public void submitSubsetOfDependentChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitSubsetOfDependentChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -349,7 +336,7 @@
     // related to change 3 by topic or ancestor (due to cherrypicking!)
     approve(change2.getChangeId());
     submit(change3.getChangeId());
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
 
     assertNew(change.getChangeId());
     assertNew(change2.getChangeId());
@@ -360,8 +347,8 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitIdenticalTree() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitIdenticalTree() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
 
@@ -369,17 +356,18 @@
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
 
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo("Change 1");
 
     submit(change2.getChangeId(), new SubmitInput(), null, null);
 
-    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
+    assertThat(projectOperations.project(project).getHead("master"))
+        .isEqualTo(headAfterFirstSubmit);
 
     ChangeInfo info2 = get(change2.getChangeId(), MESSAGES);
     assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(Iterables.getLast(info2.messages).message)
-        .isEqualTo(CommitMergeStatus.SKIPPED_IDENTICAL_TREE.getMessage());
+        .isEqualTo(CommitMergeStatus.SKIPPED_IDENTICAL_TREE.getDescription());
 
     assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
     assertChangeMergedEvents(
@@ -388,76 +376,4 @@
         change2.getChangeId(),
         headAfterFirstSubmit.name());
   }
-
-  @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
-    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
-
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    Change.Id id2 = change2.getChange().getId();
-    TestSubmitInput failInput = new TestSubmitInput();
-    failInput.failAfterRefUpdates = true;
-    submit(
-        change2.getChangeId(),
-        failInput,
-        ResourceConflictException.class,
-        "Failing after ref updates");
-    RevCommit headAfterFailedSubmit = getRemoteHead();
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
-    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
-    ChangeInfo info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(getPatchSet(psId2)).isNull();
-
-    ObjectId rev2;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
-      assertThat(rev1).isNotNull();
-
-      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
-      assertThat(rev2).isNotNull();
-      assertThat(rev2).isNotEqualTo(rev1);
-      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
-
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
-    }
-
-    submit(change2.getChangeId());
-
-    // Change status and patch set entities were updated, and branch tip stayed
-    // the same.
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
-    info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
-    PatchSet ps2 = getPatchSet(psId2);
-    assertThat(ps2).isNotNull();
-    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo(
-            "Change has been successfully cherry-picked as " + rev2.name() + " by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
-    }
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(
-        change.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change2.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index ea8b98a..aff0cc2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -15,30 +15,24 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.inject.Inject;
 import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
 import org.junit.Test;
 
 public class SubmitByFastForwardIT extends AbstractSubmit {
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -46,11 +40,11 @@
   }
 
   @Test
-  public void submitWithFastForward() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithFastForward() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
     assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
@@ -60,8 +54,8 @@
   }
 
   @Test
-  public void submitMultipleChangesWithFastForward() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChangesWithFastForward() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change = createChange();
     PushOneCommit.Result change2 = createChange();
@@ -74,14 +68,14 @@
     approve(id2);
     submit(id3);
 
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(change3.getCommit());
     assertThat(updatedHead.getParent(0).getId()).isEqualTo(change2.getCommit());
     assertSubmitter(change.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 1);
     assertSubmitter(change3.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
+    assertPersonEquals(admin.newIdent(), updatedHead.getAuthorIdent());
+    assertPersonEquals(admin.newIdent(), updatedHead.getCommitterIdent());
     assertSubmittedTogether(id1, id3, id2, id1);
     assertSubmittedTogether(id2, id3, id2, id1);
     assertSubmittedTogether(id3, id3, id2, id1);
@@ -92,12 +86,12 @@
   }
 
   @Test
-  public void submitTwoChangesWithFastForward_missingDependency() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitTwoChangesWithFastForward_missingDependency() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
 
-    Change.Id id1 = change1.getPatchSetId().getParentKey();
+    Change.Id id1 = change1.getPatchSetId().changeId();
     submitWithConflict(
         change2.getChangeId(),
         "Failed to submit 2 changes due to the following problems:\n"
@@ -105,19 +99,19 @@
             + id1
             + ": needs Code-Review");
 
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
 
   @Test
-  public void submitFastForwardNotPossible_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitFastForwardNotPossible_Conflict() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
 
@@ -138,7 +132,8 @@
             + ": Project policy requires "
             + "all submissions to be a fast-forward. Please rebase the change "
             + "locally and upload again for review.");
-    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
+    assertThat(projectOperations.project(project).getHead("master"))
+        .isEqualTo(headAfterFirstSubmit);
     assertSubmitter(change.getChangeId(), 1);
 
     assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
@@ -146,57 +141,15 @@
   }
 
   @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
-    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+  public void submitSameCommitsAsInExperimentalBranch() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    Change.Id id = change.getChange().getId();
-    TestSubmitInput failInput = new TestSubmitInput();
-    failInput.failAfterRefUpdates = true;
-    submit(
-        change.getChangeId(),
-        failInput,
-        ResourceConflictException.class,
-        "Failing after ref updates");
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId = new PatchSet.Id(id, 1);
-    ChangeInfo info = gApi.changes().id(id.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-
-    ObjectId rev;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      rev = repo.exactRef(psId.toRefName()).getObjectId();
-      assertThat(rev).isNotNull();
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev);
-    }
-
-    submit(change.getChangeId());
-
-    // Change status was updated, and branch tip stayed the same.
-    info = gApi.changes().id(id.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo("Change has been successfully merged by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev);
-    }
-
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents(change.getChangeId(), getRemoteHead().name());
-  }
-
-  @Test
-  public void submitSameCommitsAsInExperimentalBranch() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    grant(project, "refs/heads/*", Permission.CREATE);
-    grant(project, "refs/heads/experimental", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref("refs/heads/experimental").group(adminGroupUuid()))
+        .update();
 
     RevCommit c1 = commitBuilder().add("b.txt", "1").message("commit at tip").create();
     String id1 = GitUtil.getChangeId(testRepo, c1).get();
@@ -209,9 +162,9 @@
         .isEqualTo(c1.getId());
 
     submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
-    assertThat(getRemoteHead().getId()).isEqualTo(c1.getId());
+    assertThat(projectOperations.project(project).getHead("master").getId()).isEqualTo(c1.getId());
     assertSubmitter(id1, 1);
 
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
index 4af27ab..f80bdca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
@@ -17,11 +17,14 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class SubmitByMergeAlwaysIT extends AbstractSubmitByMerge {
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -29,16 +32,16 @@
   }
 
   @Test
-  public void submitWithMergeIfFastForwardPossible() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithMergeIfFastForwardPossible() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit.getParentCount()).isEqualTo(2);
     assertThat(headAfterSubmit.getParent(0)).isEqualTo(initialHead);
     assertThat(headAfterSubmit.getParent(1)).isEqualTo(change.getCommit());
     assertSubmitter(change.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), headAfterSubmit.getAuthorIdent());
+    assertPersonEquals(admin.newIdent(), headAfterSubmit.getAuthorIdent());
     assertPersonEquals(serverIdent.get(), headAfterSubmit.getCommitterIdent());
 
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
@@ -46,8 +49,8 @@
   }
 
   @Test
-  public void submitMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // Submit a change so that the remote head advances
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -82,7 +85,7 @@
     assertThat(headAfterSecondSubmit.getParent(0).getShortMessage())
         .isEqualTo(headAfterFirstSubmit.getShortMessage());
     assertThat(headAfterSecondSubmit.getParent(0).getId()).isEqualTo(headAfterFirstSubmit.getId());
-    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(admin.newIdent(), headAfterSecondSubmit.getAuthorIdent());
     assertPersonEquals(serverIdent.get(), headAfterSecondSubmit.getCommitterIdent());
 
     assertRefUpdatedEvents(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 93b3e14..64fa5c5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -15,18 +15,33 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
 import java.io.File;
 import java.io.InputStream;
 import java.nio.file.Files;
@@ -44,6 +59,8 @@
 import org.junit.Test;
 
 public class SubmitByMergeIfNecessaryIT extends AbstractSubmitByMerge {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -51,24 +68,24 @@
   }
 
   @Test
-  public void submitWithFastForward() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithFastForward() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
     assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
+    assertPersonEquals(admin.newIdent(), updatedHead.getAuthorIdent());
+    assertPersonEquals(admin.newIdent(), updatedHead.getCommitterIdent());
 
     assertRefUpdatedEvents(initialHead, updatedHead);
     assertChangeMergedEvents(change.getChangeId(), updatedHead.name());
   }
 
   @Test
-  public void submitMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitMultipleChanges() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -88,8 +105,8 @@
     assertThat(headAfterFirstSubmit.getShortMessage())
         .isEqualTo(change2.getCommit().getShortMessage());
     assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(initialHead.getId());
-    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getCommitterIdent());
+    assertPersonEquals(admin.newIdent(), headAfterFirstSubmit.getAuthorIdent());
+    assertPersonEquals(admin.newIdent(), headAfterFirstSubmit.getCommitterIdent());
 
     // We need to merge changes 3, 4 and 5.
     approve(change3.getChangeId());
@@ -102,7 +119,7 @@
     assertThat(headAfterSecondSubmit.getParent(0).getShortMessage())
         .isEqualTo(change2.getCommit().getShortMessage());
 
-    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(admin.newIdent(), headAfterSecondSubmit.getAuthorIdent());
     assertPersonEquals(serverIdent.get(), headAfterSecondSubmit.getCommitterIdent());
 
     // First change stays untouched.
@@ -124,13 +141,13 @@
   }
 
   @Test
-  public void submitChangesAcrossRepos() throws Exception {
-    Project.NameKey p1 = createProject("project-where-we-submit");
-    Project.NameKey p2 = createProject("project-impacted-via-topic");
-    Project.NameKey p3 = createProject("project-impacted-indirectly-via-topic");
+  public void submitChangesAcrossRepos() throws Throwable {
+    Project.NameKey p1 = projectOperations.newProject().create();
+    Project.NameKey p2 = projectOperations.newProject().create();
+    Project.NameKey p3 = projectOperations.newProject().create();
 
-    RevCommit initialHead2 = getRemoteHead(p2, "master");
-    RevCommit initialHead3 = getRemoteHead(p3, "master");
+    RevCommit initialHead2 = projectOperations.project(p2).getHead("master");
+    RevCommit initialHead3 = projectOperations.project(p3).getHead("master");
 
     TestRepository<?> repo1 = cloneProject(p1);
     TestRepository<?> repo2 = cloneProject(p2);
@@ -168,7 +185,7 @@
     approve(change3.getChangeId());
 
     // get a preview before submitting:
-    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
+    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
     submit(change1b.getChangeId());
 
     RevCommit tip1 = getRemoteLog(p1, "master").get(0);
@@ -184,35 +201,35 @@
       // check that the preview matched what happened:
       assertThat(preview).hasSize(3);
 
-      assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
+      assertThat(preview).containsKey(BranchNameKey.create(p1, "refs/heads/master"));
       assertTrees(p1, preview);
 
-      assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
+      assertThat(preview).containsKey(BranchNameKey.create(p2, "refs/heads/master"));
       assertTrees(p2, preview);
 
-      assertThat(preview).containsKey(new Branch.NameKey(p3, "refs/heads/master"));
+      assertThat(preview).containsKey(BranchNameKey.create(p3, "refs/heads/master"));
       assertTrees(p3, preview);
     } else {
       assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
       assertThat(preview).hasSize(1);
-      assertThat(preview.get(new Branch.NameKey(p1, "refs/heads/master"))).isNotNull();
+      assertThat(preview.get(BranchNameKey.create(p1, "refs/heads/master"))).isNotNull();
     }
   }
 
   @Test
-  public void submitChangesAcrossReposBlocked() throws Exception {
-    Project.NameKey p1 = createProject("project-where-we-submit");
-    Project.NameKey p2 = createProject("project-impacted-via-topic");
-    Project.NameKey p3 = createProject("project-impacted-indirectly-via-topic");
+  public void submitChangesAcrossReposBlocked() throws Throwable {
+    Project.NameKey p1 = projectOperations.newProject().create();
+    Project.NameKey p2 = projectOperations.newProject().create();
+    Project.NameKey p3 = projectOperations.newProject().create();
 
     TestRepository<?> repo1 = cloneProject(p1);
     TestRepository<?> repo2 = cloneProject(p2);
     TestRepository<?> repo3 = cloneProject(p3);
 
-    RevCommit initialHead1 = getRemoteHead(p1, "master");
-    RevCommit initialHead2 = getRemoteHead(p2, "master");
-    RevCommit initialHead3 = getRemoteHead(p3, "master");
+    RevCommit initialHead1 = projectOperations.project(p1).getHead("master");
+    RevCommit initialHead2 = projectOperations.project(p2).getHead("master");
+    RevCommit initialHead3 = projectOperations.project(p3).getHead("master");
 
     PushOneCommit.Result change1a =
         createChange(
@@ -265,15 +282,12 @@
               + "and upload the rebased commit for review.";
 
       // Get a preview before submitting:
-      try (BinaryResult r = gApi.changes().id(change1b.getChangeId()).current().submitPreview()) {
-        // We cannot just use the ExpectedException infrastructure as provided
-        // by AbstractDaemonTest, as then we'd stop early and not test the
-        // actual submit.
+      RestApiException thrown =
+          assertThrows(
+              RestApiException.class,
+              () -> gApi.changes().id(change1b.getChangeId()).current().submitPreview().close());
+      assertThat(thrown.getMessage()).isEqualTo(msg);
 
-        fail("expected failure");
-      } catch (RestApiException e) {
-        assertThat(e.getMessage()).isEqualTo(msg);
-      }
       submitWithConflict(change1b.getChangeId(), msg);
     } else {
       submit(change1b.getChangeId());
@@ -301,13 +315,13 @@
   }
 
   @Test
-  public void submitWithMergedAncestorsOnOtherBranch() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithMergedAncestorsOnOtherBranch() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change1 =
         createChange(testRepo, "master", "base commit", "a.txt", "1", "");
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
 
     gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
@@ -350,12 +364,12 @@
   }
 
   @Test
-  public void submitWithOpenAncestorsOnOtherBranch() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithOpenAncestorsOnOtherBranch() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 =
         createChange(testRepo, "master", "base commit", "a.txt", "1", "");
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
 
     gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
@@ -381,9 +395,9 @@
             "3",
             "a-topic-here");
 
-    Project.NameKey p3 = createProject("project-related-to-change3");
+    Project.NameKey p3 = projectOperations.newProject().create();
     TestRepository<?> repo3 = cloneProject(p3);
-    RevCommit repo3Head = getRemoteHead(p3, "master");
+    RevCommit repo3Head = projectOperations.project(p3).getHead("master");
     PushOneCommit.Result change3b =
         createChange(
             repo3,
@@ -402,8 +416,15 @@
             + " due to the following problems:\n"
             + "Change "
             + change3a.getChange().getId()
-            + ": depends on change that"
-            + " was not submitted");
+            + ": Depends on change that"
+            + " was not submitted."
+            + " Commit "
+            + change3a.getCommit().name()
+            + " depends on commit "
+            + change2.getCommit().name()
+            + " of change "
+            + change2.getChange().getId()
+            + " which cannot be merged.");
 
     RevCommit tipbranch = getRemoteLog(project, "branch").get(0);
     assertThat(tipbranch.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
@@ -416,8 +437,8 @@
   }
 
   @Test
-  public void gerritWorkflow() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void gerritWorkflow() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // We'll setup a master and a stable branch.
     // Then we create a change to be applied to master, which is
@@ -426,8 +447,7 @@
     gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
 
     // Push a change to master
-    PushOneCommit push =
-        pushFactory.create(db, user.getIdent(), testRepo, "small fix", "a.txt", "2");
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo, "small fix", "a.txt", "2");
     PushOneCommit.Result change = push.to("refs/for/master");
     submit(change.getChangeId());
     RevCommit headAfterFirstSubmit = getRemoteLog(project, "master").get(0);
@@ -444,8 +464,8 @@
     gApi.changes().id(cherryId).current().submit();
 
     // Create the merge locally
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
+    RevCommit stable = projectOperations.project(project).getHead("stable");
+    RevCommit master = projectOperations.project(project).getHead("master");
     testRepo.git().fetch().call();
     testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
     testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
@@ -475,12 +495,11 @@
   }
 
   @Test
-  public void openChangeForTargetBranchPreventsMerge() throws Exception {
+  public void openChangeForTargetBranchPreventsMerge() throws Throwable {
     gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
 
     // Propose a change for master, but leave it open for master!
-    PushOneCommit change =
-        pushFactory.create(db, user.getIdent(), testRepo, "small fix", "a.txt", "2");
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "small fix", "a.txt", "2");
     PushOneCommit.Result change2result = change.to("refs/for/master");
 
     // Now cherry pick to stable
@@ -500,16 +519,273 @@
         change3.getChangeId(),
         "Failed to submit 1 change due to the following problems:\n"
             + "Change "
-            + change3.getPatchSetId().getParentKey().get()
-            + ": depends on change that was not submitted");
+            + change3.getPatchSetId().changeId().get()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change3.getCommit().name()
+            + " depends on commit "
+            + change2result.getCommit().name()
+            + " of change "
+            + change2result.getChange().getId()
+            + " which cannot be merged.");
 
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
 
   @Test
-  public void testPreviewSubmitTgz() throws Exception {
-    Project.NameKey p1 = createProject("project-name");
+  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
+  public void dependencyOnDeletedChangePreventsMerge() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Delete first change.
+    gApi.changes().id(changeResult.getChangeId()).delete();
+
+    // Submit is expected to fail.
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + " which cannot be merged."
+            + " Is the change of this commit not visible to '"
+            + admin.username()
+            + "' or was it deleted?");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
+  public void dependencyOnChangeForNonVisibleBranchPreventsMerge() throws Throwable {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Create a change
+    PushOneCommit change = pushFactory.create(admin.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    approve(changeResult.getChangeId());
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(admin.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Move the first change to a destination branch that is non-visible to user so that user cannot
+    // this change anymore.
+    BranchNameKey secretBranch = BranchNameKey.create(project, "secretBranch");
+    gApi.projects()
+        .name(secretBranch.project().get())
+        .branch(secretBranch.branch())
+        .create(new BranchInput());
+    gApi.changes().id(changeResult.getChangeId()).move(secretBranch.branch());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref(secretBranch.branch()).group(ANONYMOUS_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user cannot see the first change.
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(changeResult.getChangeId()).get());
+    assertThat(thrown).hasMessageThat().isEqualTo("Not found: " + changeResult.getChangeId());
+
+    // Submit is expected to fail.
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + " which cannot be merged."
+            + " Is the change of this commit not visible to '"
+            + user.username()
+            + "' or was it deleted?");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
+  public void dependencyOnHiddenChangePreventsMerge() throws Throwable {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Create a change
+    PushOneCommit change = pushFactory.create(admin.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    approve(changeResult.getChangeId());
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(admin.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+    approve(change2Result.getChangeId());
+
+    // Mark the first change private so that it's not visible to user.
+    gApi.changes().id(changeResult.getChangeId()).setPrivate(true, "nobody should see this");
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user cannot see the first change.
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(changeResult.getChangeId()).get());
+    assertThat(thrown).hasMessageThat().isEqualTo("Not found: " + changeResult.getChangeId());
+
+    // Submit is expected to fail.
+    AuthException thrown2 =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(change2Result.getChangeId()).current().submit());
+    assertThat(thrown2)
+        .hasMessageThat()
+        .isEqualTo(
+            "A change to be submitted with "
+                + change2Result.getChange().getId().get()
+                + " is not visible");
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
+  public void dependencyOnHiddenChangeUsingTopicPreventsMerge() throws Throwable {
+    // Construct a topic where a change included by topic depends on a private change that is not
+    // visible to the submitting user
+    // (c1) --- topic --- (c2b)
+    //                      |
+    //                    (c2a) <= private
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    Project.NameKey p1 = projectOperations.newProject().create();
+    Project.NameKey p2 = projectOperations.newProject().create();
+
+    projectOperations
+        .project(p1)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(p2)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    TestRepository<?> repo1 = cloneProject(p1);
+    TestRepository<?> repo2 = cloneProject(p2);
+
+    PushOneCommit.Result change1 =
+        createChange(repo1, "master", "A fresh change in repo1", "a.txt", "1", "topic-to-submit");
+    approve(change1.getChangeId());
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), repo2, "An ancestor change in repo2", "a.txt", "2");
+    PushOneCommit.Result change2a = push.to("refs/for/master");
+    approve(change2a.getChangeId());
+    PushOneCommit.Result change2b =
+        createChange(
+            repo2, "master", "A topic-linked change in repo2", "a.txt", "2", "topic-to-submit");
+    approve(change2b.getChangeId());
+
+    // Mark change2a private so that it's not visible to user.
+    gApi.changes().id(change2a.getChangeId()).setPrivate(true, "nobody should see this");
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user cannot see change2a
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.changes().id(change2a.getChangeId()).get());
+    assertThat(thrown).hasMessageThat().isEqualTo("Not found: " + change2a.getChangeId());
+
+    // Submit is expected to fail.
+    AuthException thrown2 =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(change1.getChangeId()).current().submit());
+    assertThat(thrown2)
+        .hasMessageThat()
+        .isEqualTo(
+            "A change to be submitted with "
+                + change1.getChange().getId().get()
+                + " is not visible");
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
+  public void testPreviewSubmitTgz() throws Throwable {
+    Project.NameKey p1 = projectOperations.newProject().create();
 
     TestRepository<?> repo1 = cloneProject(p1);
     PushOneCommit.Result change1 = createChange(repo1, "master", "test", "a.txt", "1", "topic");
@@ -529,11 +805,11 @@
     List<String> untarredFiles = new ArrayList<>();
     try (TarArchiveInputStream tarInputStream =
         (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) {
-      TarArchiveEntry entry = null;
+      TarArchiveEntry entry;
       while ((entry = (TarArchiveEntry) tarInputStream.getNextEntry()) != null) {
         untarredFiles.add(entry.getName());
       }
     }
-    assertThat(untarredFiles).containsExactly(name("project-name") + ".git");
+    assertThat(untarredFiles).containsExactly(p1.get() + ".git");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index e8b8fe8..1808480 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -16,25 +16,36 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import java.util.List;
+import java.util.ArrayDeque;
+import java.util.Deque;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class SubmitByRebaseAlwaysIT extends AbstractSubmitByRebase {
   @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+  @Inject private DynamicItem<UrlFormatter> urlFormatter;
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -43,27 +54,27 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithPossibleFastForward() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+  public void submitWithPossibleFastForward() throws Throwable {
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
 
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getId()).isNotEqualTo(change.getCommit());
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertApproved(change.getChangeId());
     assertCurrentRevision(change.getChangeId(), 2, head);
     assertSubmitter(change.getChangeId(), 1);
     assertSubmitter(change.getChangeId(), 2);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertPersonEquals(admin.newIdent(), head.getAuthorIdent());
+    assertPersonEquals(admin.newIdent(), head.getCommitterIdent());
     assertRefUpdatedEvents(oldHead, head);
     assertChangeMergedEvents(change.getChangeId(), head.name());
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void alwaysAddFooters() throws Exception {
+  public void alwaysAddFooters() throws Throwable {
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
 
@@ -80,52 +91,90 @@
   }
 
   @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void changeMessageOnSubmit() throws Exception {
-    PushOneCommit.Result change1 = createChange();
-    PushOneCommit.Result change2 = createChange();
+  public void rebaseInvokesChangeMessageModifiers() throws Throwable {
+    ChangeMessageModifier modifier1 =
+        (msg, orig, tip, dest) -> msg + "This-change-before-rebase: " + orig.name() + "\n";
+    ChangeMessageModifier modifier2 =
+        (msg, orig, tip, dest) -> msg + "Previous-step-tip: " + tip.name() + "\n";
+    ChangeMessageModifier modifier3 =
+        (msg, orig, tip, dest) -> msg + "Dest: " + dest.shortName() + "\n";
 
-    RegistrationHandle handle =
-        changeMessageModifiers.add(
-            new ChangeMessageModifier() {
-              @Override
-              public String onSubmit(
-                  String newCommitMessage,
-                  RevCommit original,
-                  RevCommit mergeTip,
-                  Branch.NameKey destination) {
-                List<String> custom = mergeTip.getFooterLines("Custom");
-                if (!custom.isEmpty()) {
-                  newCommitMessage += "Custom-Parent: " + custom.get(0) + "\n";
-                }
-                return newCommitMessage + "Custom: " + destination.get();
-              }
-            });
-    try {
-      // change1 is a fast-forward, but should be rebased in cherry pick style
-      // anyway, making change2 not a fast-forward, requiring a rebase.
-      approve(change1.getChangeId());
-      submit(change2.getChangeId());
-    } finally {
-      handle.remove();
+    try (AutoCloseable ignored = installChangeMessageModifiers(modifier1, modifier2, modifier3)) {
+      ImmutableList<PushOneCommit.Result> changes = submitWithRebase(admin);
+      ChangeData cd1 = changes.get(0).getChange();
+      ChangeData cd2 = changes.get(1).getChange();
+      assertThat(cd2.patchSets()).hasSize(2);
+      String change1CurrentCommit = cd1.currentPatchSet().commitId().name();
+      String change2Ps1Commit = cd2.patchSet(PatchSet.id(cd2.getId(), 1)).commitId().name();
+
+      assertThat(gApi.changes().id(cd2.getId().get()).revision(2).commit(false).message)
+          .isEqualTo(
+              "Change 2\n\n"
+                  + ("Change-Id: " + cd2.change().getKey() + "\n")
+                  + ("Reviewed-on: "
+                      + urlFormatter.get().getChangeViewUrl(project, cd2.getId()).get()
+                      + "\n")
+                  + "Reviewed-by: Administrator <admin@example.com>\n"
+                  + ("This-change-before-rebase: " + change2Ps1Commit + "\n")
+                  + ("Previous-step-tip: " + change1CurrentCommit + "\n")
+                  + "Dest: master\n");
     }
-    // ... but both changes should get custom footers.
-    assertThat(getCurrentCommit(change1).getFooterLines("Custom"))
-        .containsExactly("refs/heads/master");
-    assertThat(getCurrentCommit(change2).getFooterLines("Custom"))
-        .containsExactly("refs/heads/master");
-    assertThat(getCurrentCommit(change2).getFooterLines("Custom-Parent"))
-        .containsExactly("refs/heads/master");
   }
 
-  private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Exception {
+  @Test
+  public void failingChangeMessageModifierShortCircuits() throws Throwable {
+    ChangeMessageModifier modifier1 =
+        (msg, orig, tip, dest) -> {
+          throw new IllegalStateException("boom");
+        };
+    ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
+    try (AutoCloseable ignored = installChangeMessageModifiers(modifier1, modifier2)) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> submitWithRebase());
+      Throwable cause = Throwables.getRootCause(thrown);
+      assertThat(cause).isInstanceOf(RuntimeException.class);
+      assertThat(cause).hasMessageThat().isEqualTo("boom");
+    }
+  }
+
+  @Test
+  public void changeMessageModifierReturningNullShortCircuits() throws Throwable {
+    ChangeMessageModifier modifier1 = (msg, orig, tip, dest) -> null;
+    ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
+    try (AutoCloseable ignored = installChangeMessageModifiers(modifier1, modifier2)) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> submitWithRebase());
+      Throwable cause = Throwables.getRootCause(thrown);
+      assertThat(cause).isInstanceOf(RuntimeException.class);
+      assertThat(cause)
+          .hasMessageThat()
+          .isEqualTo(
+              modifier1.getClass().getName()
+                  + ".onSubmit from plugin modifier-1 returned null instead of new commit"
+                  + " message");
+    }
+  }
+
+  private AutoCloseable installChangeMessageModifiers(ChangeMessageModifier... modifiers) {
+    Deque<RegistrationHandle> handles = new ArrayDeque<>(modifiers.length);
+    for (int i = 0; i < modifiers.length; i++) {
+      handles.push(changeMessageModifiers.add("modifier-" + (i + 1), modifiers[i]));
+    }
+    return () -> {
+      while (!handles.isEmpty()) {
+        handles.pop().remove();
+      }
+    };
+  }
+
+  private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Throwable {
     RevCommit c = getCurrentCommit(change);
     assertThat(c.getFooterLines(FooterConstants.CHANGE_ID)).isNotEmpty();
     assertThat(c.getFooterLines(FooterConstants.REVIEWED_BY)).isNotEmpty();
     assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).isNotEmpty();
   }
 
-  private RevCommit getCurrentCommit(PushOneCommit.Result change) throws Exception {
+  private RevCommit getCurrentCommit(PushOneCommit.Result change) throws Throwable {
     testRepo.git().fetch().setRemote("origin").call();
     ChangeInfo info = get(change.getChangeId(), CURRENT_REVISION);
     RevCommit c = testRepo.getRevWalk().parseCommit(ObjectId.fromString(info.currentRevision));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
index 19f1706..01b58ee 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
@@ -18,12 +18,15 @@
 
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class SubmitByRebaseIfNecessaryIT extends AbstractSubmitByRebase {
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -32,38 +35,38 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithFastForward() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+  public void submitWithFastForward() throws Throwable {
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getId()).isEqualTo(change.getCommit());
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertApproved(change.getChangeId());
     assertCurrentRevision(change.getChangeId(), 1, head);
     assertSubmitter(change.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertPersonEquals(admin.newIdent(), head.getAuthorIdent());
+    assertPersonEquals(admin.newIdent(), head.getCommitterIdent());
     assertRefUpdatedEvents(oldHead, head);
     assertChangeMergedEvents(change.getChangeId(), head.name());
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+  public void submitWithContentMerge() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertRebase(testRepo, true);
-    RevCommit headAfterThirdSubmit = getRemoteHead();
+    RevCommit headAfterThirdSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index e9ac07a..78349f5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.submit.ChangeSet;
 import com.google.gerrit.server.submit.MergeSuperSet;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -187,8 +186,8 @@
     String project2Name = name("Project2");
     gApi.projects().create(project1Name);
     gApi.projects().create(project2Name);
-    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
-    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
+    TestRepository<InMemoryRepository> project1 = cloneProject(Project.nameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(Project.nameKey(project2Name));
 
     PushOneCommit.Result a = createChange(project1, "A");
     PushOneCommit.Result b =
@@ -305,9 +304,9 @@
   }
 
   private void assertChangeSetMergeable(ChangeData change, boolean expected)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException, OrmException,
+      throws MissingObjectException, IncorrectObjectTypeException, IOException,
           PermissionBackendException {
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, change.change(), user(admin));
+    ChangeSet cs = mergeSuperSet.get().completeChangeSet(change.change(), user(admin));
     assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
@@ -333,7 +332,7 @@
       List<RevCommit> parents,
       String ref)
       throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), repo, subject, fileName, content);
 
     if (!parents.isEmpty()) {
       push.setParents(parents);
@@ -351,7 +350,7 @@
 
   private PushOneCommit.Result createChange(TestRepository<?> repo, String subject)
       throws Exception {
-    return createChange(repo, subject, "x", "x", new ArrayList<RevCommit>(), "refs/for/master");
+    return createChange(repo, subject, "x", "x", new ArrayList<>(), "refs/for/master");
   }
 
   private PushOneCommit.Result createChange(
@@ -366,8 +365,7 @@
 
   @Override
   protected PushOneCommit.Result createChange(String subject) throws Exception {
-    return createChange(
-        testRepo, subject, "", "", Collections.<RevCommit>emptyList(), "refs/for/master");
+    return createChange(testRepo, subject, "", "", Collections.emptyList(), "refs/for/master");
   }
 
   private PushOneCommit.Result createChange(String subject, List<RevCommit> parents)
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 7657e2e..5401a2c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -14,7 +14,12 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static java.util.stream.Collectors.toList;
 
@@ -23,30 +28,35 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
+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.server.group.InternalGroup;
-import com.google.gerrit.server.restapi.group.CreateGroup;
 import com.google.inject.Inject;
-import java.util.Arrays;
 import java.util.List;
+import java.util.Set;
+import java.util.stream.IntStream;
 import org.junit.Before;
 import org.junit.Test;
 
 public class SuggestReviewersIT extends AbstractDaemonTest {
-  @Inject private CreateGroup.Factory createGroupFactory;
+  @Inject private AccountOperations accountOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
-  private InternalGroup group1;
-  private InternalGroup group2;
-  private InternalGroup group3;
+  private AccountGroup.UUID group1;
+  private AccountGroup.UUID group2;
+  private AccountGroup.UUID group3;
 
   private TestAccount user1;
   private TestAccount user2;
@@ -55,14 +65,16 @@
 
   @Before
   public void setUp() throws Exception {
-    group1 = newGroup("users1");
-    group2 = newGroup("users2");
-    group3 = newGroup("users3");
-
-    user1 = user("user1", "First1 Last1", group1);
-    user2 = user("user2", "First2 Last2", group2);
-    user3 = user("user3", "First3 Last3", group1, group2);
+    user1 = user("user1", "First1 Last1");
+    user2 = user("user2", "First2 Last2");
+    user3 = user("user3", "First3 Last3");
     user4 = user("jdoe", "John Doe", "JDOE");
+
+    group1 =
+        groupOperations.newGroup().name(name("users1")).members(user1.id(), user3.id()).create();
+    group2 =
+        groupOperations.newGroup().name(name("users2")).members(user2.id(), user3.id()).create();
+    group3 = groupOperations.newGroup().name(name("users3")).members(user1.id()).create();
   }
 
   @Test
@@ -104,7 +116,8 @@
     assertReviewers(
         reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2));
 
-    reviewers = suggestReviewers(changeId, group3.getName(), 10);
+    String group3Name = groupOperations.group(group3).get().name();
+    reviewers = suggestReviewers(changeId, group3Name, 10);
     assertReviewers(reviewers, ImmutableList.of(), ImmutableList.of(group3));
 
     // Suggested accounts are ordered by activity. All users have no activity,
@@ -115,7 +128,9 @@
     assertThat(reviewers.get(0).account).isNotNull();
     assertThat(ImmutableList.of(reviewers.get(0).account._accountId))
         .containsAnyIn(
-            ImmutableList.of(user1, user2, user3).stream().map(u -> u.id.get()).collect(toList()));
+            ImmutableList.of(user1, user2, user3).stream()
+                .map(u -> u.id().get())
+                .collect(toList()));
   }
 
   @Test
@@ -124,23 +139,23 @@
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers;
 
-    reviewers = suggestReviewers(changeId, user2.username, 2);
+    reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName());
 
-    setApiUser(user1);
-    reviewers = suggestReviewers(changeId, user2.fullName, 2);
+    requestScopeOperations.setApiUser(user1.id());
+    reviewers = suggestReviewers(changeId, user2.fullName(), 2);
     assertThat(reviewers).isEmpty();
 
-    setApiUser(user2);
-    reviewers = suggestReviewers(changeId, user2.username, 2);
+    requestScopeOperations.setApiUser(user2.id());
+    reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName());
 
-    setApiUser(user3);
-    reviewers = suggestReviewers(changeId, user2.username, 2);
+    requestScopeOperations.setApiUser(user3.id());
+    reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName());
   }
 
   @Test
@@ -148,10 +163,14 @@
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers;
 
-    setApiUser(user3);
-    block("refs/*", "read", ANONYMOUS_USERS);
-    allow("refs/*", "read", group1.getGroupUUID());
-    reviewers = suggestReviewers(changeId, user2.username, 2);
+    requestScopeOperations.setApiUser(user3.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/*").group(group1))
+        .update();
+    reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).isEmpty();
   }
 
@@ -161,15 +180,19 @@
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers;
 
-    setApiUser(user1);
-    reviewers = suggestReviewers(changeId, user2.username, 2);
+    requestScopeOperations.setApiUser(user1.id());
+    reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).isEmpty();
 
-    setApiUser(user1); // Clear cached group info.
-    allowGlobalCapabilities(group1.getGroupUUID(), GlobalCapability.VIEW_ALL_ACCOUNTS);
-    reviewers = suggestReviewers(changeId, user2.username, 2);
+    // Clear cached group info.
+    requestScopeOperations.setApiUser(user1.id());
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ALL_ACCOUNTS).group(group1))
+        .update();
+    reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName());
   }
 
   @Test
@@ -215,44 +238,38 @@
     reviewers = suggestReviewers(changeId, name("user"));
     assertThat(reviewers).hasSize(6);
 
-    reviewers = suggestReviewers(changeId, user1.username);
+    reviewers = suggestReviewers(changeId, user1.username());
     assertThat(reviewers).hasSize(1);
 
     reviewers = suggestReviewers(changeId, "example.com");
     assertThat(reviewers).hasSize(5);
 
-    reviewers = suggestReviewers(changeId, user1.email);
+    reviewers = suggestReviewers(changeId, user1.email());
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, user1.username + " example");
+    reviewers = suggestReviewers(changeId, user1.username() + " example");
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, user4.email.toLowerCase());
+    reviewers = suggestReviewers(changeId, user4.email().toLowerCase());
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.get(0).account.email).isEqualTo(user4.email);
+    assertThat(reviewers.get(0).account.email).isEqualTo(user4.email());
   }
 
   @Test
   public void suggestReviewersWithoutLimitOptionSpecified() throws Exception {
     String changeId = createChange().getChangeId();
-    String query = user3.username;
+    String query = user3.username();
     List<SuggestedReviewerInfo> suggestedReviewerInfos =
         gApi.changes().id(changeId).suggestReviewers(query).get();
     assertThat(suggestedReviewerInfos).hasSize(1);
   }
 
   @Test
-  @GerritConfig(name = "addreviewer.maxAllowed", value = "2")
+  @GerritConfig(name = "addreviewer.maxAllowed", value = "1")
   @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value = "1")
-  public void suggestReviewersGroupSizeConsiderations() throws Exception {
-    InternalGroup largeGroup = newGroup("large");
-    InternalGroup mediumGroup = newGroup("medium");
-
-    // Both groups have Administrator as a member. Add two users to large
-    // group to push it past maxAllowed, and one to medium group to push it
-    // past maxWithoutConfirmation.
-    user("individual 0", "Test0 Last0", largeGroup, mediumGroup);
-    user("individual 1", "Test1 Last1", largeGroup);
+  public void confirmationIsNeverRequestedForAccounts() throws Exception {
+    user("individual 0", "Test0 Last0");
+    user("individual 1", "Test1 Last1");
 
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers;
@@ -264,60 +281,107 @@
     reviewer = reviewers.get(0);
     assertThat(reviewer.count).isEqualTo(1);
     assertThat(reviewer.confirm).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "addreviewer.maxAllowed", value = "2")
+  @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value = "1")
+  public void suggestReviewersGroupSizeConsiderations() throws Exception {
+    AccountGroup.UUID largeGroup = createGroupWithArbitraryMembers(3);
+    String largeGroupName = groupOperations.group(largeGroup).get().name();
+    AccountGroup.UUID mediumGroup = createGroupWithArbitraryMembers(2);
+    String mediumGroupName = groupOperations.group(mediumGroup).get().name();
+
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers;
+    SuggestedReviewerInfo reviewer;
 
     // Large group should never be suggested.
-    reviewers = suggestReviewers(changeId, largeGroup.getName(), 10);
+    reviewers = suggestReviewers(changeId, largeGroupName, 10);
     assertThat(reviewers).isEmpty();
 
     // Medium group should be suggested with appropriate count and confirm.
-    reviewers = suggestReviewers(changeId, mediumGroup.getName(), 10);
+    reviewers = suggestReviewers(changeId, mediumGroupName, 10);
     assertThat(reviewers).hasSize(1);
     reviewer = reviewers.get(0);
-    assertThat(reviewer.group.name).isEqualTo(mediumGroup.getName());
+    assertThat(reviewer.group.id).isEqualTo(mediumGroup.get());
     assertThat(reviewer.count).isEqualTo(2);
     assertThat(reviewer.confirm).isTrue();
   }
 
   @Test
+  @GerritConfig(name = "addreviewer.maxAllowed", value = "20")
+  @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value = "0")
+  public void confirmationIsNotNecessaryForLargeGroupWhenLimitIsRemoved() throws Exception {
+    String changeId = createChange().getChangeId();
+    int numMembers = 15;
+    AccountGroup.UUID largeGroup = createGroupWithArbitraryMembers(numMembers);
+    String groupName = groupOperations.group(largeGroup).get().name();
+
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, groupName, 10);
+
+    assertThat(reviewers).hasSize(1);
+    SuggestedReviewerInfo reviewer = Iterables.getOnlyElement(reviewers);
+    assertThat(reviewer.group.id).isEqualTo(largeGroup.get());
+    // Confirmation should not be necessary.
+    assertThat(reviewer.confirm).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "addreviewer.maxAllowed", value = "0")
+  public void largeGroupIsSuggestedWhenLimitIsRemoved() throws Exception {
+    String changeId = createChange().getChangeId();
+    int numMembers = 30;
+    AccountGroup.UUID largeGroup = createGroupWithArbitraryMembers(numMembers);
+    String groupName = groupOperations.group(largeGroup).get().name();
+
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, groupName, 10);
+
+    assertThat(reviewers).hasSize(1);
+    SuggestedReviewerInfo reviewer = Iterables.getOnlyElement(reviewers);
+    assertThat(reviewer.group.id).isEqualTo(largeGroup.get());
+  }
+
+  @Test
   public void defaultReviewerSuggestion() throws Exception {
     TestAccount user1 = user("customuser1", "User1");
     TestAccount reviewer1 = user("customuser2", "User2");
     TestAccount reviewer2 = user("customuser3", "User3");
 
-    setApiUser(user1);
+    requestScopeOperations.setApiUser(user1.id());
     String changeId1 = createChangeFromApi();
 
-    setApiUser(reviewer1);
+    requestScopeOperations.setApiUser(reviewer1.id());
     reviewChange(changeId1);
 
-    setApiUser(user1);
+    requestScopeOperations.setApiUser(user1.id());
     String changeId2 = createChangeFromApi();
 
-    setApiUser(reviewer1);
+    requestScopeOperations.setApiUser(reviewer1.id());
     reviewChange(changeId2);
 
-    setApiUser(reviewer2);
+    requestScopeOperations.setApiUser(reviewer2.id());
     reviewChange(changeId2);
 
-    setApiUser(user1);
+    requestScopeOperations.setApiUser(user1.id());
     String changeId3 = createChangeFromApi();
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId3, null, 4);
     assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
-        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
+        .containsExactly(reviewer1.id().get(), reviewer2.id().get())
         .inOrder();
 
     // check that existing reviewers are filtered out
-    gApi.changes().id(changeId3).addReviewer(reviewer1.email);
+    gApi.changes().id(changeId3).addReviewer(reviewer1.email());
     reviewers = suggestReviewers(changeId3, null, 4);
     assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
-        .containsExactly(reviewer2.id.get())
+        .containsExactly(reviewer2.id().get())
         .inOrder();
   }
 
   @Test
   public void defaultReviewerSuggestionOnFirstChange() throws Exception {
     TestAccount user1 = user("customuser1", "User1");
-    setApiUser(user1);
+    requestScopeOperations.setApiUser(user1.id());
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChange().getChangeId(), "", 4);
     assertThat(reviewers).isEmpty();
   }
@@ -336,23 +400,23 @@
     TestAccount userWhoLooksForSuggestions = user("customuser5", fullName);
 
     // Create a change as userWhoOwns and add some reviews
-    setApiUser(userWhoOwns);
+    requestScopeOperations.setApiUser(userWhoOwns.id());
     String changeId1 = createChangeFromApi();
 
-    setApiUser(reviewer1);
+    requestScopeOperations.setApiUser(reviewer1.id());
     reviewChange(changeId1);
 
-    setApiUser(user1);
+    requestScopeOperations.setApiUser(user1.id());
     String changeId2 = createChangeFromApi();
 
-    setApiUser(reviewer1);
+    requestScopeOperations.setApiUser(reviewer1.id());
     reviewChange(changeId2);
 
-    setApiUser(reviewer2);
+    requestScopeOperations.setApiUser(reviewer2.id());
     reviewChange(changeId2);
 
     // Create a comment as a different user
-    setApiUser(userWhoComments);
+    requestScopeOperations.setApiUser(userWhoComments.id());
     ReviewInput ri = new ReviewInput();
     ri.message = "Test";
     gApi.changes().id(changeId1).revision(1).review(ri);
@@ -360,18 +424,21 @@
     // Create a change as a new user to assert that we receive the correct
     // ranking
 
-    setApiUser(userWhoLooksForSuggestions);
+    requestScopeOperations.setApiUser(userWhoLooksForSuggestions.id());
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Pri", 4);
     assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
         .containsExactly(
-            reviewer1.id.get(), reviewer2.id.get(), userWhoOwns.id.get(), userWhoComments.id.get())
+            reviewer1.id().get(),
+            reviewer2.id().get(),
+            userWhoOwns.id().get(),
+            userWhoComments.id().get())
         .inOrder();
   }
 
   @Test
   public void reviewerRankingProjectIsolation() throws Exception {
     // Create new project
-    Project.NameKey newProject = createProject("test");
+    Project.NameKey newProject = projectOperations.newProject().create();
 
     // Create users who review changes in both the default and the new project
     String fullName = "Primum Finalis";
@@ -379,31 +446,31 @@
     TestAccount reviewer1 = user("customuser2", fullName);
     TestAccount reviewer2 = user("customuser3", fullName);
 
-    setApiUser(userWhoOwns);
+    requestScopeOperations.setApiUser(userWhoOwns.id());
     String changeId1 = createChangeFromApi();
 
-    setApiUser(reviewer1);
+    requestScopeOperations.setApiUser(reviewer1.id());
     reviewChange(changeId1);
 
-    setApiUser(userWhoOwns);
+    requestScopeOperations.setApiUser(userWhoOwns.id());
     String changeId2 = createChangeFromApi(newProject);
 
-    setApiUser(reviewer2);
+    requestScopeOperations.setApiUser(reviewer2.id());
     reviewChange(changeId2);
 
-    setApiUser(userWhoOwns);
+    requestScopeOperations.setApiUser(userWhoOwns.id());
     String changeId3 = createChangeFromApi(newProject);
 
-    setApiUser(reviewer2);
+    requestScopeOperations.setApiUser(reviewer2.id());
     reviewChange(changeId3);
 
-    setApiUser(userWhoOwns);
+    requestScopeOperations.setApiUser(userWhoOwns.id());
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Prim", 4);
 
     // Assert that reviewer1 is on top, even though reviewer2 has more reviews
     // in other projects
     assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
-        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
+        .containsExactly(reviewer1.id().get(), reviewer2.id().get())
         .inOrder();
   }
 
@@ -411,17 +478,17 @@
   public void suggestNoInactiveAccounts() throws Exception {
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
-    assertThat(gApi.accounts().id(foo1.username).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    assertThat(gApi.accounts().id(foo2.username).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo2.username()).getActive()).isTrue();
 
     String changeId = createChange().getChangeId();
     assertReviewers(
         suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
-    gApi.accounts().id(foo2.username).setActive(false);
-    assertThat(gApi.accounts().id(foo2.username).getActive()).isFalse();
+    gApi.accounts().id(foo2.username()).setActive(false);
+    assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
     assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
   }
 
@@ -443,7 +510,7 @@
     String secondaryEmail = "foo.secondary@example.com";
     createAccountWithSecondaryEmail("foo", secondaryEmail);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     List<SuggestedReviewerInfo> reviewers =
         suggestReviewers(createChange().getChangeId(), secondaryEmail, 4);
     assertThat(reviewers).isEmpty();
@@ -463,7 +530,7 @@
     assertThat(Iterables.getOnlyElement(reviewers).account.secondaryEmails)
         .containsExactly(secondaryEmail);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     reviewers = suggestReviewers(createChange().getChangeId(), "foo", 4);
     assertReviewers(reviewers, ImmutableList.of(foo), ImmutableList.of());
     assertThat(Iterables.getOnlyElement(reviewers).account.secondaryEmails).isNull();
@@ -475,7 +542,7 @@
     EmailInput input = new EmailInput();
     input.email = secondaryEmail;
     input.noConfirmation = true;
-    gApi.accounts().id(foo.id.get()).addEmail(input);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
     return foo;
   }
 
@@ -489,20 +556,20 @@
     return gApi.changes().id(changeId).suggestReviewers(query).withLimit(n).get();
   }
 
-  private InternalGroup newGroup(String name) throws Exception {
-    GroupInfo group = createGroupFactory.create(name(name)).apply(TopLevelResource.INSTANCE, null);
-    return group(new AccountGroup.UUID(group.id));
+  private AccountGroup.UUID createGroupWithArbitraryMembers(int numMembers) {
+    Set<Account.Id> members =
+        IntStream.rangeClosed(1, numMembers)
+            .mapToObj(i -> accountOperations.newAccount().create())
+            .collect(toImmutableSet());
+    return groupOperations.newGroup().members(members).create();
   }
 
-  private TestAccount user(String name, String fullName, String emailName, InternalGroup... groups)
-      throws Exception {
-    String[] groupNames = Arrays.stream(groups).map(InternalGroup::getName).toArray(String[]::new);
-    return accountCreator.create(
-        name(name), name(emailName) + "@example.com", fullName, groupNames);
+  private TestAccount user(String name, String fullName, String emailName) throws Exception {
+    return accountCreator.create(name(name), name(emailName) + "@example.com", fullName);
   }
 
-  private TestAccount user(String name, String fullName, InternalGroup... groups) throws Exception {
-    return user(name, fullName, name, groups);
+  private TestAccount user(String name, String fullName) throws Exception {
+    return user(name, fullName, name);
   }
 
   private void reviewChange(String changeId) throws RestApiException {
@@ -523,24 +590,23 @@
     return gApi.changes().create(ci).get().changeId;
   }
 
-  private void assertReviewers(
+  private static void assertReviewers(
       List<SuggestedReviewerInfo> actual,
       List<TestAccount> expectedUsers,
-      List<InternalGroup> expectedGroups) {
+      List<AccountGroup.UUID> expectedGroups) {
     List<Integer> actualAccountIds =
-        actual
-            .stream()
+        actual.stream()
             .filter(i -> i.account != null)
             .map(i -> i.account._accountId)
             .collect(toList());
     assertThat(actualAccountIds)
-        .containsExactlyElementsIn(expectedUsers.stream().map(u -> u.id.get()).collect(toList()));
+        .containsExactlyElementsIn(expectedUsers.stream().map(u -> u.id().get()).collect(toList()));
 
     List<String> actualGroupIds =
         actual.stream().filter(i -> i.group != null).map(i -> i.group.id).collect(toList());
     assertThat(actualGroupIds)
         .containsExactlyElementsIn(
-            expectedGroups.stream().map(g -> g.getGroupUUID().get()).collect(toList()))
+            expectedGroups.stream().map(AccountGroup.UUID::get).collect(toList()))
         .inOrder();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java b/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java
new file mode 100644
index 0000000..ccf1c0d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java
@@ -0,0 +1,218 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.junit.Test;
+
+public class WorkInProgressByDefaultIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForProjectDisabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "empty change")).get();
+    assertThat(info.workInProgress).isNull();
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForProject(project);
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForUser();
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void createChangeBypassWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForProject(project);
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    input.workInProgress = false;
+    assertThat(gApi.changes().create(input).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void createChangeBypassWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForUser();
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    input.workInProgress = false;
+    assertThat(gApi.changes().create(input).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForProjectInherited() throws Exception {
+    Project.NameKey parentProject = projectOperations.newProject().create();
+    Project.NameKey childProject = projectOperations.newProject().parent(parentProject).create();
+    setWorkInProgressByDefaultForProject(parentProject);
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(childProject.get(), "master", "empty change")).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
+  public void pushWithWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForProject(project);
+    assertThat(createChange(project).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  @Test
+  public void pushWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForUser();
+    assertThat(createChange(project).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  @Test
+  public void pushBypassWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForProject(project);
+    assertThat(
+            createChange(project, "refs/for/master%ready").getChange().change().isWorkInProgress())
+        .isFalse();
+  }
+
+  @Test
+  public void pushBypassWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    setWorkInProgressByDefaultForUser();
+    assertThat(
+            createChange(project, "refs/for/master%ready").getChange().change().isWorkInProgress())
+        .isFalse();
+  }
+
+  @Test
+  public void pushWithWorkInProgressByDefaultForProjectDisabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    assertThat(createChange(project).getChange().change().isWorkInProgress()).isFalse();
+  }
+
+  @Test
+  public void pushWorkInProgressByDefaultForProjectInherited() throws Exception {
+    Project.NameKey parentProject = projectOperations.newProject().create();
+    Project.NameKey childProject = projectOperations.newProject().parent(parentProject).create();
+    setWorkInProgressByDefaultForProject(parentProject);
+    assertThat(createChange(childProject).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  @Test
+  public void pushNewPatchSetWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    // Create change.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project);
+    PushOneCommit.Result result =
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
+    result.assertOkStatus();
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+
+    setWorkInProgressByDefaultForUser();
+
+    // Create new patch set on existing change, this shouldn't mark the change as WIP.
+    result = pushFactory.create(admin.newIdent(), testRepo, changeId).to("refs/for/master");
+    result.assertOkStatus();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void pushNewPatchSetAndNewChangeAtOnceWithWorkInProgressByDefaultForUserEnabled()
+      throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    // Create change.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project);
+    RevCommit initialHead = getHead(testRepo.getRepository(), "HEAD");
+    RevCommit commit1a =
+        testRepo.commit().parent(initialHead).message("Change 1").insertChangeId().create();
+    String changeId1 = GitUtil.getChangeId(testRepo, commit1a).get();
+    testRepo.reset(commit1a);
+    PushResult result = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(result, "refs/for/master");
+    assertThat(gApi.changes().id(changeId1).get().workInProgress).isNull();
+
+    setWorkInProgressByDefaultForUser();
+
+    // Create a new patch set on the existing change and in the same push create a new successor
+    // change.
+    RevCommit commit1b = testRepo.amend(commit1a).create();
+    testRepo.reset(commit1b);
+    RevCommit commit2 =
+        testRepo.commit().parent(commit1b).message("Change 2").insertChangeId().create();
+    String changeId2 = GitUtil.getChangeId(testRepo, commit2).get();
+    testRepo.reset(commit2);
+    result = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(result, "refs/for/master");
+
+    // Check that the existing change (changeId1) is not marked as WIP, but only the newly created
+    // change (changeId2).
+    assertThat(gApi.changes().id(changeId1).get().workInProgress).isNull();
+    assertThat(gApi.changes().id(changeId2).get().workInProgress).isTrue();
+  }
+
+  private void setWorkInProgressByDefaultForProject(Project.NameKey p) throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.workInProgressByDefault = InheritableBoolean.TRUE;
+    gApi.projects().name(p.get()).config(input);
+  }
+
+  private void setWorkInProgressByDefaultForUser() throws Exception {
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id().get()).getPreferences();
+    prefs.workInProgressByDefault = true;
+    gApi.accounts().id(admin.id().get()).setPreferences(prefs);
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey p) throws Exception {
+    return createChange(p, "refs/for/master");
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey p, String r) throws Exception {
+    TestRepository<InMemoryRepository> testRepo = cloneProject(p);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result result = push.to(r);
+    result.assertOkStatus();
+    return result;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index 65ed7e4..daeb032 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -15,30 +15,38 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH;
 import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH_ALL;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.restapi.config.PostCaches;
+import com.google.inject.Inject;
 import java.util.Arrays;
 import org.junit.Test;
 
 public class CacheOperationsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void flushAll() throws Exception {
-    RestResponse r = adminRestSession.getOK("/config/server/caches/project_list");
+    RestResponse r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
-    r = adminRestSession.postOK("/config/server/caches/", new PostCaches.Input(FLUSH_ALL));
+    r = adminRestSession.post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL));
+    r.assertOK();
     r.consume();
 
-    r = adminRestSession.getOK("/config/server/caches/project_list");
+    r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isNull();
   }
@@ -59,25 +67,30 @@
 
   @Test
   public void flush() throws Exception {
-    RestResponse r = adminRestSession.getOK("/config/server/caches/project_list");
+    RestResponse r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
-    r = adminRestSession.getOK("/config/server/caches/projects");
+    r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
 
     r =
-        adminRestSession.postOK(
+        adminRestSession.post(
             "/config/server/caches/",
             new PostCaches.Input(FLUSH, Arrays.asList("accounts", "project_list")));
+    r.assertOK();
     r.consume();
 
-    r = adminRestSession.getOK("/config/server/caches/project_list");
+    r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isNull();
 
-    r = adminRestSession.getOK("/config/server/caches/projects");
+    r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
   }
@@ -96,7 +109,8 @@
 
   @Test
   public void flush_UnprocessableEntity() throws Exception {
-    RestResponse r = adminRestSession.getOK("/config/server/caches/projects");
+    RestResponse r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
@@ -107,19 +121,24 @@
     r.assertUnprocessableEntity();
     r.consume();
 
-    r = adminRestSession.getOK("/config/server/caches/projects");
+    r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
   }
 
   @Test
   public void flushWebSessions_Forbidden() throws Exception {
-    allowGlobalCapabilities(
-        REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+        .update();
     try {
       RestResponse r =
-          userRestSession.postOK(
+          userRestSession.post(
               "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("projects")));
+      r.assertOK();
       r.consume();
 
       userRestSession
@@ -127,8 +146,11 @@
               "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")))
           .assertForbidden();
     } finally {
-      removeGlobalCapabilities(
-          REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(capabilityKey(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+          .remove(capabilityKey(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+          .update();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
index 7133580..99fdbc8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
@@ -16,9 +16,9 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.gerrit.server.mail.SignedToken;
 import com.google.gerrit.server.restapi.config.ConfirmEmail;
 import com.google.gerrit.testing.ConfigSuite;
-import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
@@ -36,14 +36,14 @@
   @Test
   public void confirm() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
-    in.token = emailTokenVerifier.encode(admin.getId(), "new.mail@example.com");
+    in.token = emailTokenVerifier.encode(admin.id(), "new.mail@example.com");
     adminRestSession.put("/config/server/email.confirm", in).assertNoContent();
   }
 
   @Test
   public void confirmForOtherUser_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
-    in.token = emailTokenVerifier.encode(user.getId(), "new.mail@example.com");
+    in.token = emailTokenVerifier.encode(user.id(), "new.mail@example.com");
     adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
   }
 
@@ -57,7 +57,7 @@
   @Test
   public void confirmAlreadyInUse_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
-    in.token = emailTokenVerifier.encode(admin.getId(), user.email);
+    in.token = emailTokenVerifier.encode(admin.id(), user.email());
     adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index caecefa..a161ec4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -15,15 +15,20 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 public class FlushCacheIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void flushCache() throws Exception {
@@ -65,8 +70,11 @@
 
   @Test
   public void flushWebSessionsCache_Forbidden() throws Exception {
-    allowGlobalCapabilities(
-        REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+        .update();
     try {
       RestResponse r = userRestSession.post("/config/server/caches/accounts/flush");
       r.assertOK();
@@ -74,8 +82,11 @@
 
       userRestSession.post("/config/server/caches/web_sessions/flush").assertForbidden();
     } finally {
-      removeGlobalCapabilities(
-          REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(capabilityKey(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+          .remove(capabilityKey(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+          .update();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
index c19f5d0..2a891aa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
@@ -36,8 +36,7 @@
     r.consume();
 
     Optional<String> id =
-        result
-            .stream()
+        result.stream()
             .filter(t -> "Log File Compressor".equals(t.command))
             .map(t -> t.id)
             .findFirst();
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 7ada34f..4a74018 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -22,10 +22,10 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersNameProvider;
@@ -70,8 +70,6 @@
   // gerrit
   @GerritConfig(name = "gerrit.allProjects", value = "Root")
   @GerritConfig(name = "gerrit.allUsers", value = "Users")
-  @GerritConfig(name = "gerrit.enableGwtUi", value = "true")
-  @GerritConfig(name = "gerrit.reportBugText", value = "REPORT BUG")
   @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
 
   // suggest
@@ -113,7 +111,6 @@
     assertThat(i.gerrit.allProjects).isEqualTo("Root");
     assertThat(i.gerrit.allUsers).isEqualTo("Users");
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
-    assertThat(i.gerrit.reportBugText).isEqualTo("REPORT BUG");
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
@@ -128,10 +125,7 @@
     assertThat(i.user.anonymousCowardName).isEqualTo("Unnamed User");
 
     // notedb
-    notesMigration.setReadChanges(true);
     assertThat(gApi.config().server().getInfo().noteDbEnabled).isTrue();
-    notesMigration.setReadChanges(false);
-    assertThat(gApi.config().server().getInfo().noteDbEnabled).isNull();
   }
 
   @Test
@@ -183,7 +177,6 @@
     assertThat(i.gerrit.allProjects).isEqualTo(AllProjectsNameProvider.DEFAULT);
     assertThat(i.gerrit.allUsers).isEqualTo(AllUsersNameProvider.DEFAULT);
     assertThat(i.gerrit.reportBugUrl).isNull();
-    assertThat(i.gerrit.reportBugText).isNull();
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
@@ -197,13 +190,4 @@
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
   }
-
-  @Test
-  @GerritConfig(name = "auth.contributorAgreements", value = "true")
-  public void anonymousAccess() throws Exception {
-    configureContributorAgreement(true);
-
-    setApiUserAnonymous();
-    gApi.config().server().getInfo();
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index 95bc5a6..e2818d2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -14,21 +14,25 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.createAnnotatedTag;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.GitUtil.updateAnnotatedTag;
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.ANNOTATED;
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.LIGHTWEIGHT;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
@@ -50,6 +54,8 @@
     }
   }
 
+  @Inject private ProjectOperations projectOperations;
+
   private RevCommit initialHead;
   private TagType tagType;
 
@@ -58,7 +64,7 @@
     // clone with user to avoid inherited tag permissions of admin user
     testRepo = cloneProject(project, user);
 
-    initialHead = getRemoteHead();
+    initialHead = projectOperations.project(project).getHead("master");
     tagType = getTagType();
   }
 
@@ -188,7 +194,7 @@
     if (force) {
       testRepo.reset(initialHead);
     }
-    commit(user.getIdent(), "subject");
+    commit(user.newIdent(), "subject");
 
     boolean createTag = tagName == null;
     tagName = MoreObjects.firstNonNull(tagName, "v1_" + System.nanoTime());
@@ -197,9 +203,9 @@
         break;
       case ANNOTATED:
         if (createTag) {
-          createAnnotatedTag(testRepo, tagName, user.getIdent());
+          createAnnotatedTag(testRepo, tagName, user.newIdent());
         } else {
-          updateAnnotatedTag(testRepo, tagName, user.getIdent());
+          updateAnnotatedTag(testRepo, tagName, user.newIdent());
         }
         break;
       default:
@@ -207,7 +213,11 @@
     }
 
     if (!newCommit) {
-      grant(project, "refs/for/refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(REGISTERED_USERS))
+          .update();
       pushHead(testRepo, "refs/for/master%submit");
     }
 
@@ -217,7 +227,7 @@
             ? pushHead(testRepo, tagRef, false, force)
             : GitUtil.pushTag(testRepo, tagName, !createTag);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
-    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+    assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
     return tagName;
   }
 
@@ -225,30 +235,50 @@
     String tagRef = tagRef(tagName);
     PushResult r = deleteRef(testRepo, tagRef);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
-    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+    assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
   }
 
   private void allowTagCreation() throws Exception {
-    grant(project, "refs/tags/*", tagType.createPermission, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(tagType.createPermission).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private void allowPushOnRefsTags() throws Exception {
     removePushFromRefsTags();
-    grant(project, "refs/tags/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private void allowForcePushOnRefsTags() throws Exception {
     removePushFromRefsTags();
-    grant(project, "refs/tags/*", Permission.PUSH, true, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS).force(true))
+        .update();
   }
 
   private void allowTagDeletion() throws Exception {
     removePushFromRefsTags();
-    grant(project, "refs/tags/*", Permission.DELETE, true, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/tags/*").group(REGISTERED_USERS).force(true))
+        .update();
   }
 
   private void removePushFromRefsTags() throws Exception {
-    removePermission(project, "refs/tags/*", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(Permission.PUSH).ref("refs/tags/*"))
+        .update();
   }
 
   private void commit(PersonIdent ident, String subject) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index f7903dd..bb043c2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -15,12 +15,17 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
@@ -30,7 +35,6 @@
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -47,9 +51,9 @@
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.Map;
-import javax.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -63,20 +67,20 @@
 
 public class AccessIT extends AbstractDaemonTest {
 
-  private static final String PROJECT_NAME = "newProject";
-
   private static final String REFS_ALL = Constants.R_REFS + "*";
   private static final String REFS_HEADS = Constants.R_HEADS + "*";
 
   private static final String LABEL_CODE_REVIEW = "Code-Review";
 
-  private Project.NameKey newProjectName;
-
   @Inject private DynamicSet<FileHistoryWebLink> fileHistoryWebLinkDynamicSet;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private Project.NameKey newProjectName;
 
   @Before
   public void setUp() throws Exception {
-    newProjectName = createProject(PROJECT_NAME);
+    newProjectName = projectOperations.newProject().create();
   }
 
   @Test
@@ -89,14 +93,9 @@
   public void webLink() throws Exception {
     RegistrationHandle handle =
         fileHistoryWebLinkDynamicSet.add(
-            new FileHistoryWebLink() {
-              @Override
-              public WebLinkInfo getFileHistoryWebLink(
-                  String projectName, String revision, String fileName) {
-                return new WebLinkInfo(
-                    "name", "imageURL", "http://view/" + projectName + "/" + fileName);
-              }
-            });
+            "gerrit",
+            (projectName, revision, fileName) ->
+                new WebLinkInfo("name", "imageURL", "http://view/" + projectName + "/" + fileName));
     try {
       ProjectAccessInfo info = pApi().access();
       assertThat(info.configWebLinks).hasSize(1);
@@ -111,14 +110,9 @@
   public void webLinkNoRefsMetaConfig() throws Exception {
     RegistrationHandle handle =
         fileHistoryWebLinkDynamicSet.add(
-            new FileHistoryWebLink() {
-              @Override
-              public WebLinkInfo getFileHistoryWebLink(
-                  String projectName, String revision, String fileName) {
-                return new WebLinkInfo(
-                    "name", "imageURL", "http://view/" + projectName + "/" + fileName);
-              }
-            });
+            "gerrit",
+            (projectName, revision, fileName) ->
+                new WebLinkInfo("name", "imageURL", "http://view/" + projectName + "/" + fileName));
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
       u.setForceUpdate(true);
@@ -133,7 +127,7 @@
 
   @Test
   public void addAccessSection() throws Exception {
-    RevCommit initialHead = getRemoteHead(newProjectName, RefNames.REFS_CONFIG);
+    RevCommit initialHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
 
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
@@ -143,7 +137,7 @@
 
     assertThat(pApi().access().local).isEqualTo(accessInput.add);
 
-    RevCommit updatedHead = getRemoteHead(newProjectName, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
         newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
   }
@@ -151,8 +145,7 @@
   @Test
   public void createAccessChangeNop() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
-    exception.expect(BadRequestException.class);
-    pApi().accessChange(accessInput);
+    assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
   }
 
   @Test
@@ -177,9 +170,13 @@
 
   @Test
   public void createAccessChange() throws Exception {
-    allow(newProjectName, RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(newProjectName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
     // User can see the branch
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     pApi().branch("refs/heads/master").get();
 
     ProjectAccessInput accessInput = newProjectAccessInput();
@@ -194,7 +191,7 @@
     accessSection.permissions.put(Permission.READ, read);
     accessInput.add.put(REFS_HEADS, accessSection);
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     ChangeInfo out = pApi().accessChange(accessInput);
 
     assertThat(out.project).isEqualTo(newProjectName.get());
@@ -202,7 +199,7 @@
     assertThat(out.status).isEqualTo(ChangeStatus.NEW);
     assertThat(out.submitted).isNull();
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
 
     ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
     assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
@@ -213,27 +210,22 @@
     gApi.changes().id(out._number).current().submit();
 
     // check that the change took effect.
-    setApiUser(user);
-    try {
-      BranchInfo info = pApi().branch("refs/heads/master").get();
-      fail("wanted failure, got " + newGson().toJson(info));
-    } catch (ResourceNotFoundException e) {
-      // OK.
-    }
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().branch("refs/heads/master").get());
 
     // Restore.
     accessInput.add.clear();
     accessInput.remove.put(REFS_HEADS, accessSection);
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     out = pApi().accessChange(accessInput);
 
     gApi.changes().id(out._number).current().review(reviewIn);
     gApi.changes().id(out._number).current().submit();
 
     // Now it works again.
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     pApi().branch("refs/heads/master").get();
   }
 
@@ -333,9 +325,8 @@
     accessInput.add.put(REFS_ALL, accessSectionInfo);
     pApi().access(accessInput);
 
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    pApi().access();
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
   }
 
   @Test
@@ -353,9 +344,8 @@
     AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
     accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
 
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    pApi().access();
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
   }
 
   @Test
@@ -400,7 +390,7 @@
     assertThat(owners.includes).isNull();
 
     // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     ProjectAccessInfo anonResult = pApi().access();
     assertThat(anonResult.groups.keySet())
         .containsExactly(
@@ -410,22 +400,21 @@
   @Test
   public void updateParentAsUser() throws Exception {
     // Create child
-    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
+    String newParentProjectName = projectOperations.newProject().create().get();
 
     // Set new parent
     ProjectAccessInput accessInput = newProjectAccessInput();
     accessInput.parent = newParentProjectName;
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("administrate server not permitted");
-    pApi().access(accessInput);
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown = assertThrows(AuthException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("administrate server not permitted");
   }
 
   @Test
   public void updateParentAsAdministrator() throws Exception {
     // Create parent
-    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
+    String newParentProjectName = projectOperations.newProject().create().get();
 
     // Set new parent
     ProjectAccessInput accessInput = newProjectAccessInput();
@@ -443,9 +432,9 @@
 
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
   }
 
   @Test
@@ -463,7 +452,7 @@
                 .get(AccessSection.GLOBAL_CAPABILITIES)
                 .permissions
                 .keySet())
-        .containsAllIn(accessSectionInfo.permissions.keySet());
+        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
   }
 
   @Test
@@ -473,8 +462,7 @@
 
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
-    exception.expect(BadRequestException.class);
-    pApi().access(accessInput);
+    assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
   }
 
   @Test
@@ -487,9 +475,9 @@
     accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
 
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    exception.expect(BadRequestException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThrows(
+        BadRequestException.class,
+        () -> gApi.projects().name(allProjects.get()).access(accessInput));
   }
 
   @Test
@@ -499,9 +487,9 @@
 
     accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
   }
 
   @Test
@@ -525,7 +513,7 @@
                 .get(AccessSection.GLOBAL_CAPABILITIES)
                 .permissions
                 .keySet())
-        .containsAllIn(accessSectionInfo.permissions.keySet());
+        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
 
     // Remove
     accessInput.add.clear();
@@ -569,7 +557,7 @@
     config = cfg.toText();
     PushOneCommit push =
         pushFactory.create(
-            db, admin.getIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
+            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
     push.to(RefNames.REFS_CONFIG).assertOkStatus();
 
     // Verify that unknownPermission is present
@@ -580,7 +568,7 @@
             .file(ProjectConfig.PROJECT_CONFIG)
             .asString();
     cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
 
     // Make permission change through API
     ProjectAccessInput accessInput = newProjectAccessInput();
@@ -599,16 +587,20 @@
             .file(ProjectConfig.PROJECT_CONFIG)
             .asString();
     cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
   }
 
   @Test
   public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     accessInput.parent = project.get();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(allUsers.get() + " must inherit from " + allProjects.get());
-    gApi.projects().name(allUsers.get()).access(accessInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allUsers.get()).access(accessInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(allUsers.get() + " must inherit from " + allProjects.get());
   }
 
   @Test
@@ -649,6 +641,34 @@
     assertThat(permissions2.keySet()).containsExactly(Permission.READ);
   }
 
+  @Test
+  public void addAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
+  }
+
+  @Test
+  public void createAccessChangeWithAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
+  }
+
   private ProjectApi pApi() throws Exception {
     return gApi.projects().name(newProjectName.get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index dad3ca9..131c24a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -8,6 +8,7 @@
         ":project",
         ":push_tag_util",
         ":refassert",
+        "//lib/commons:lang",
     ],
 )
 
@@ -33,14 +34,13 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/truth",
     ],
 )
 
 java_library(
     name = "push_tag_util",
-    testonly = 1,
+    testonly = True,
     srcs = [
         "AbstractPushTag.java",
     ],
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/CheckMergeabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
index 7667fc0..22fb829 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -20,12 +20,14 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.MergeableInfo;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
@@ -34,20 +36,19 @@
 
 public class CheckMergeabilityIT extends AbstractDaemonTest {
 
-  private Branch.NameKey branch;
+  @Inject private ProjectOperations projectOperations;
+
+  private BranchNameKey branch;
 
   @Before
   public void setUp() throws Exception {
-    branch = new Branch.NameKey(project, "test");
-    gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get())
-        .create(new BranchInput());
+    branch = BranchNameKey.create(project, "test");
+    gApi.projects().name(branch.project().get()).branch(branch.branch()).create(new BranchInput());
   }
 
   @Test
   public void checkMergeableCommit() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
@@ -82,7 +83,7 @@
 
   @Test
   public void checkUnMergeableCommit() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
@@ -117,7 +118,7 @@
 
   @Test
   public void checkOursMergeStrategy() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
@@ -211,7 +212,7 @@
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
 
-    ObjectId remoteId = getRemoteHead();
+    ObjectId remoteId = projectOperations.project(project).getHead("master");
     assertThat(remoteId).isNotEqualTo(commitId);
     assertContentMerged("master", commitId.getName(), "recursive");
   }
@@ -237,7 +238,7 @@
 
   @Test
   public void checkInvalidStrategy() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
deleted file mode 100644
index c0a413b..0000000
--- a/javatests/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
+++ /dev/null
@@ -1,69 +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.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.eclipse.jgit.lib.Constants.R_TAGS;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.IncludedInInfo;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.reviewdb.client.Branch;
-import org.eclipse.jgit.lib.ObjectId;
-import org.junit.Test;
-
-public class CommitIncludedInIT extends AbstractDaemonTest {
-  @Test
-  public void includedInOpenChange() throws Exception {
-    Result result = createChange();
-    assertThat(getIncludedIn(result.getCommit().getId()).branches).isEmpty();
-    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
-  }
-
-  @Test
-  public void includedInMergedChange() throws Exception {
-    Result result = createChange();
-    gApi.changes()
-        .id(result.getChangeId())
-        .revision(result.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
-
-    assertThat(getIncludedIn(result.getCommit().getId()).branches).containsExactly("master");
-    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
-
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
-    gApi.projects().name(result.getChange().project().get()).tag("test-tag").create(new TagInput());
-
-    assertThat(getIncludedIn(result.getCommit().getId()).tags).containsExactly("test-tag");
-
-    createBranch(new Branch.NameKey(project.get(), "test-branch"));
-
-    assertThat(getIncludedIn(result.getCommit().getId()).branches)
-        .containsExactly("master", "test-branch");
-  }
-
-  private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
-    RestResponse r =
-        userRestSession.get("/projects/" + project.get() + "/commits/" + id.name() + "/in");
-    IncludedInInfo result = newGson().fromJson(r.getReader(), IncludedInInfo.class);
-    r.consume();
-    return result;
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 48dc994..41fa128 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -15,12 +15,18 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_HEADS;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
@@ -30,24 +36,40 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
 
-@NoHttpd
 public class CreateBranchIT extends AbstractDaemonTest {
-  private Branch.NameKey testBranch;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private BranchNameKey testBranch;
 
   @Before
   public void setUp() throws Exception {
-    testBranch = new Branch.NameKey(project, "test");
+    testBranch = BranchNameKey.create(project, "test");
+  }
+
+  @Test
+  public void createBranchRestApi() throws Exception {
+    BranchInput input = new BranchInput();
+    input.ref = "foo";
+    assertThat(gApi.projects().name(project.get()).branches().get().stream().map(i -> i.ref))
+        .doesNotContain(REFS_HEADS + input.ref);
+    RestResponse r =
+        adminRestSession.put("/projects/" + project.get() + "/branches/" + input.ref, input);
+    r.assertCreated();
+    assertThat(gApi.projects().name(project.get()).branches().get().stream().map(i -> i.ref))
+        .contains(REFS_HEADS + input.ref);
   }
 
   @Test
   public void createBranch_Forbidden() throws Exception {
-    setApiUser(user);
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    requestScopeOperations.setApiUser(user.id());
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
@@ -64,94 +86,112 @@
   @Test
   public void createBranchByProjectOwner() throws Exception {
     grantOwner();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertCreateSucceeds(testBranch);
   }
 
   @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
   public void createBranchByProjectOwnerCreateReferenceBlocked_Forbidden() throws Exception {
     grantOwner();
     blockCreateReference();
-    setApiUser(user);
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    requestScopeOperations.setApiUser(user.id());
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
   public void createMetaBranch() throws Exception {
     String metaRef = RefNames.REFS_META + "foo";
-    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
-    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
-    assertCreateSucceeds(new Branch.NameKey(project, metaRef));
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(metaRef).group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(metaRef).group(REGISTERED_USERS))
+        .update();
+    assertCreateSucceeds(BranchNameKey.create(project, metaRef));
   }
 
   @Test
   public void createUserBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .update();
     assertCreateFails(
-        new Branch.NameKey(allUsers, RefNames.refsUsers(new Account.Id(1))),
-        RefNames.refsUsers(admin.getId()),
+        BranchNameKey.create(allUsers, RefNames.refsUsers(Account.id(1))),
+        RefNames.refsUsers(admin.id()),
         ResourceConflictException.class,
         "Not allowed to create user branch.");
   }
 
   @Test
-  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void createGroupBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
     assertCreateFails(
-        new Branch.NameKey(allUsers, RefNames.refsGroups(new AccountGroup.UUID("foo"))),
+        BranchNameKey.create(allUsers, RefNames.refsGroups(AccountGroup.uuid("foo"))),
         RefNames.refsGroups(adminGroupUuid()),
         ResourceConflictException.class,
         "Not allowed to create group branch.");
   }
 
   private void blockCreateReference() throws Exception {
-    block("refs/*", Permission.CREATE, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.CREATE).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void grantOwner() throws Exception {
-    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
-  private BranchApi branch(Branch.NameKey branch) throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+  private BranchApi branch(BranchNameKey branch) throws Exception {
+    return gApi.projects().name(branch.project().get()).branch(branch.branch());
   }
 
-  private void assertCreateSucceeds(Branch.NameKey branch) throws Exception {
+  private void assertCreateSucceeds(BranchNameKey branch) throws Exception {
     BranchInfo created = branch(branch).create(new BranchInput()).get();
-    assertThat(created.ref).isEqualTo(branch.get());
+    assertThat(created.ref).isEqualTo(branch.branch());
   }
 
   private void assertCreateFails(
-      Branch.NameKey branch, Class<? extends RestApiException> errType, String errMsg)
+      BranchNameKey branch, Class<? extends RestApiException> errType, String errMsg)
       throws Exception {
     assertCreateFails(branch, null, errType, errMsg);
   }
 
   private void assertCreateFails(
-      Branch.NameKey branch,
+      BranchNameKey branch,
       String revision,
       Class<? extends RestApiException> errType,
       String errMsg)
       throws Exception {
-    exception.expect(errType);
-    if (errMsg != null) {
-      exception.expectMessage(errMsg);
-    }
     BranchInput in = new BranchInput();
     in.revision = revision;
-    branch(branch).create(in);
+    RestApiException thrown = assertThrows(errType, () -> branch(branch).create(in));
+    if (errMsg != null) {
+      assertThat(thrown).hasMessageThat().contains(errMsg);
+    }
   }
 
-  private void assertCreateFails(Branch.NameKey branch, Class<? extends RestApiException> errType)
+  private void assertCreateFails(BranchNameKey branch, Class<? extends RestApiException> errType)
       throws Exception {
     assertCreateFails(branch, errType, null);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 023c540..043bde7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -19,7 +19,10 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectOwners;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -30,6 +33,8 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -48,6 +53,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
 import java.util.Collections;
 import java.util.Optional;
 import java.util.Set;
@@ -70,6 +76,9 @@
 import org.junit.Test;
 
 public class CreateProjectIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void createProjectHttp() throws Exception {
     String newProjectName = name("newProject");
@@ -82,7 +91,7 @@
     // for more extensive coverage of the LabelTypeInfo.
     assertThat(p.labels).hasSize(1);
 
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -117,7 +126,7 @@
         Future<RestResponse> r1 = executor.submit(createProjectFoo);
         Future<RestResponse> r2 = executor.submit(createProjectFoo);
         assertThat(ImmutableList.of(r1.get().getStatusCode(), r2.get().getStatusCode()))
-            .containsAllOf(HttpStatus.SC_CREATED, HttpStatus.SC_CONFLICT);
+            .containsAtLeast(HttpStatus.SC_CREATED, HttpStatus.SC_CONFLICT);
       }
     } finally {
       executor.shutdown();
@@ -158,7 +167,7 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -171,7 +180,29 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName + ".git").get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isNotNull();
+    assertProjectInfo(projectState.getProject(), p);
+    assertHead(newProjectName, "refs/heads/master");
+  }
+
+  @Test
+  public void createProjectThatEndsWithSlash() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInfo p = gApi.projects().create(newProjectName + "/").get();
+    assertThat(p.name).isEqualTo(newProjectName);
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isNotNull();
+    assertProjectInfo(projectState.getProject(), p);
+    assertHead(newProjectName, "refs/heads/master");
+  }
+
+  @Test
+  public void createProjectThatContainsSlash() throws Exception {
+    String newProjectName = name("newProject/newProject");
+    ProjectInfo p = gApi.projects().create(newProjectName).get();
+    assertThat(p.name).isEqualTo(newProjectName);
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -190,7 +221,7 @@
     in.requireChangeId = InheritableBoolean.TRUE;
     ProjectInfo p = gApi.projects().create(in).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    Project project = projectCache.get(new Project.NameKey(newProjectName)).getProject();
+    Project project = projectCache.get(Project.nameKey(newProjectName)).getProject();
     assertProjectInfo(project, p);
     assertThat(project.getDescription()).isEqualTo(in.description);
     assertThat(project.getConfiguredSubmitType()).isEqualTo(in.submitType);
@@ -216,7 +247,7 @@
     in.name = childName;
     in.parent = parentName;
     gApi.projects().create(in);
-    Project project = projectCache.get(new Project.NameKey(childName)).getProject();
+    Project project = projectCache.get(Project.nameKey(childName)).getProject();
     assertThat(project.getParentName()).isEqualTo(in.parent);
   }
 
@@ -239,12 +270,12 @@
     in.owners.add(
         Integer.toString(
             groupCache
-                .get(new AccountGroup.NameKey("Administrators"))
+                .get(AccountGroup.nameKey("Administrators"))
                 .orElse(null)
                 .getId()
                 .get())); // by ID
     gApi.projects().create(in);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     Set<AccountGroup.UUID> expectedOwnerIds = Sets.newHashSetWithExpectedSize(3);
     expectedOwnerIds.add(SystemGroupBackend.ANONYMOUS_USERS);
     expectedOwnerIds.add(SystemGroupBackend.REGISTERED_USERS);
@@ -297,22 +328,31 @@
 
   @Test
   public void createProjectWithCapability() throws Exception {
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.CREATE_PROJECT)
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     try {
-      setApiUser(user);
+      requestScopeOperations.setApiUser(user.id());
       ProjectInput in = new ProjectInput();
       in.name = name("newProject");
       ProjectInfo p = gApi.projects().create(in).get();
       assertThat(p.name).isEqualTo(in.name);
     } finally {
-      removeGlobalCapabilities(
-          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.CREATE_PROJECT)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
     }
   }
 
   @Test
   public void createProjectWithoutCapability_Forbidden() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     ProjectInput in = new ProjectInput();
     in.name = name("newProject");
     assertCreateFails(in, AuthException.class);
@@ -329,17 +369,26 @@
   public void createProjectWithCreateProjectCapabilityAndParentNotVisible() throws Exception {
     Project parent = projectCache.get(allProjects).getProject();
     parent.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.CREATE_PROJECT)
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     try {
-      setApiUser(user);
+      requestScopeOperations.setApiUser(user.id());
       ProjectInput in = new ProjectInput();
       in.name = name("newProject");
       ProjectInfo p = gApi.projects().create(in).get();
       assertThat(p.name).isEqualTo(in.name);
     } finally {
       parent.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE);
-      removeGlobalCapabilities(
-          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.CREATE_PROJECT)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
     }
   }
 
@@ -421,13 +470,13 @@
   }
 
   private void assertHead(String projectName, String expectedRef) throws Exception {
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName))) {
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
       assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
     }
   }
 
   private void assertEmptyCommit(String projectName, String... refs) throws Exception {
-    Project.NameKey projectKey = new Project.NameKey(projectName);
+    Project.NameKey projectKey = Project.nameKey(projectName);
     try (Repository repo = repoManager.openRepository(projectKey);
         RevWalk rw = new RevWalk(repo);
         TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
@@ -443,13 +492,12 @@
 
   private void assertCreateFails(ProjectInput in, Class<? extends RestApiException> errType)
       throws Exception {
-    exception.expect(errType);
-    gApi.projects().create(in);
+    assertThrows(errType, () -> gApi.projects().create(in));
   }
 
   private Optional<String> readProjectConfig(String projectName) throws Exception {
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName))) {
-      TestRepository<?> tr = new TestRepository<>(repo);
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName));
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
       RevWalk rw = tr.getRevWalk();
       Ref ref = repo.exactRef(RefNames.REFS_CONFIG);
       if (ref == null) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 5e1b0bf..c44c11a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -15,13 +15,17 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -29,25 +33,28 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
 
 public class DeleteBranchIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
-  private Branch.NameKey testBranch;
+  private BranchNameKey testBranch;
 
   @Before
   public void setUp() throws Exception {
-    project = createProject(name("p"));
-    testBranch = new Branch.NameKey(project, "test");
+    project = projectOperations.newProject().create();
+    testBranch = BranchNameKey.create(project, "test");
     branch(testBranch).create(new BranchInput());
   }
 
   @Test
   public void deleteBranch_Forbidden() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteForbidden(testBranch);
   }
 
@@ -59,7 +66,7 @@
   @Test
   public void deleteBranchByProjectOwner() throws Exception {
     grantOwner();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteSucceeds(testBranch);
   }
 
@@ -73,28 +80,28 @@
   public void deleteBranchByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
     grantOwner();
     blockForcePush();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteForbidden(testBranch);
   }
 
   @Test
   public void deleteBranchByUserWithForcePushPermission() throws Exception {
     grantForcePush();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteSucceeds(testBranch);
   }
 
   @Test
   public void deleteBranchByUserWithDeletePermission() throws Exception {
     grantDelete();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteSucceeds(testBranch);
   }
 
   @Test
   public void deleteBranchByRestWithoutRefsHeadsPrefix() throws Exception {
     grantDelete();
-    String ref = testBranch.getShortName();
+    String ref = testBranch.shortName();
     assertThat(ref).doesNotMatch(R_HEADS);
     assertDeleteByRestSucceeds(testBranch, ref);
   }
@@ -102,14 +109,14 @@
   @Test
   public void deleteBranchByRestWithFullName() throws Exception {
     grantDelete();
-    assertDeleteByRestSucceeds(testBranch, testBranch.get());
+    assertDeleteByRestSucceeds(testBranch, testBranch.branch());
   }
 
   @Test
   public void deleteBranchByRestFailsWithUnencodedFullName() throws Exception {
     grantDelete();
     RestResponse r =
-        userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.get());
+        userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.branch());
     r.assertNotFound();
     branch(testBranch).get();
   }
@@ -117,10 +124,14 @@
   @Test
   public void deleteMetaBranch() throws Exception {
     String metaRef = RefNames.REFS_META + "foo";
-    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
-    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(metaRef).group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(metaRef).group(REGISTERED_USERS))
+        .update();
 
-    Branch.NameKey metaBranch = new Branch.NameKey(project, metaRef);
+    BranchNameKey metaBranch = BranchNameKey.create(project, metaRef);
     branch(metaBranch).create(new BranchInput());
 
     grantDelete();
@@ -129,46 +140,75 @@
 
   @Test
   public void deleteUserBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .update();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Not allowed to delete user branch.");
-    branch(new Branch.NameKey(allUsers, RefNames.refsUsers(admin.id))).delete();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> branch(BranchNameKey.create(allUsers, RefNames.refsUsers(admin.id()))).delete());
+    assertThat(thrown).hasMessageThat().contains("Not allowed to delete user branch.");
   }
 
   @Test
-  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void deleteGroupBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Not allowed to delete group branch.");
-    branch(new Branch.NameKey(allUsers, RefNames.refsGroups(adminGroupUuid()))).delete();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                branch(BranchNameKey.create(allUsers, RefNames.refsGroups(adminGroupUuid())))
+                    .delete());
+    assertThat(thrown).hasMessageThat().contains("Not allowed to delete group branch.");
   }
 
   private void blockForcePush() throws Exception {
-    block("refs/heads/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantForcePush() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, true, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantDelete() throws Exception {
-    allow("refs/*", Permission.DELETE, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void grantOwner() throws Exception {
-    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
-  private BranchApi branch(Branch.NameKey branch) throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+  private BranchApi branch(BranchNameKey branch) throws Exception {
+    return gApi.projects().name(branch.project().get()).branch(branch.branch());
   }
 
-  private void assertDeleteByRestSucceeds(Branch.NameKey branch, String ref) throws Exception {
+  private void assertDeleteByRestSucceeds(BranchNameKey branch, String ref) throws Exception {
     RestResponse r =
         userRestSession.delete(
             "/projects/"
@@ -176,24 +216,21 @@
                 + "/branches/"
                 + IdString.fromDecoded(ref).encoded());
     r.assertNoContent();
-    exception.expect(ResourceNotFoundException.class);
-    branch(branch).get();
+    assertThrows(ResourceNotFoundException.class, () -> branch(branch).get());
   }
 
-  private void assertDeleteSucceeds(Branch.NameKey branch) throws Exception {
+  private void assertDeleteSucceeds(BranchNameKey branch) throws Exception {
     assertThat(branch(branch).get().canDelete).isTrue();
     String branchRev = branch(branch).get().revision;
     branch(branch).delete();
     eventRecorder.assertRefUpdatedEvents(
-        project.get(), branch.get(), null, branchRev, branchRev, null);
-    exception.expect(ResourceNotFoundException.class);
-    branch(branch).get();
+        project.get(), branch.branch(), null, branchRev, branchRev, null);
+    assertThrows(ResourceNotFoundException.class, () -> branch(branch).get());
   }
 
-  private void assertDeleteForbidden(Branch.NameKey branch) throws Exception {
+  private void assertDeleteForbidden(BranchNameKey branch) throws Exception {
     assertThat(branch(branch).get().canDelete).isNull();
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    branch(branch).delete();
+    AuthException thrown = assertThrows(AuthException.class, () -> branch(branch).delete());
+    assertThat(thrown).hasMessageThat().contains("not permitted: delete");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index 008997b..523b711 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.eclipse.jgit.lib.Constants.R_REFS;
@@ -24,13 +26,17 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.List;
 import org.eclipse.jgit.lib.Repository;
@@ -43,10 +49,17 @@
   private static final ImmutableList<String> BRANCHES =
       ImmutableList.of("refs/heads/test-1", "refs/heads/test-2", "test-3", "refs/meta/foo");
 
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Before
   public void setUp() throws Exception {
-    allow("refs/*", Permission.CREATE, REGISTERED_USERS);
-    allow("refs/*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     for (String name : BRANCHES) {
       project().branch(name).create(new BranchInput());
     }
@@ -64,17 +77,27 @@
   }
 
   @Test
-  public void deleteBranchesForbidden() throws Exception {
+  public void deleteOneBranchWithoutPermissionForbidden() throws Exception {
+    ImmutableList<String> branchToDelete = ImmutableList.of("refs/heads/test-1");
+
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = branchToDelete;
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown = assertThrows(AuthException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().isEqualTo("not permitted: delete on refs/heads/test-1");
+    requestScopeOperations.setApiUser(admin.id());
+    assertBranches(BRANCHES);
+  }
+
+  @Test
+  public void deleteMultiBranchesWithoutPermissionForbidden() throws Exception {
     DeleteBranchesInput input = new DeleteBranchesInput();
     input.branches = BRANCHES;
-    setApiUser(user);
-    try {
-      project().deleteBranches(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e).hasMessageThat().isEqualTo(errorMessageForBranches(BRANCHES));
-    }
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(user.id());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().isEqualTo(errorMessageForBranches(BRANCHES));
+    requestScopeOperations.setApiUser(admin.id());
     assertBranches(BRANCHES);
   }
 
@@ -84,14 +107,11 @@
     List<String> branches = Lists.newArrayList(BRANCHES);
     branches.add("refs/heads/does-not-exist");
     input.branches = branches;
-    try {
-      project().deleteBranches(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteBranches(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
     assertBranchesDeleted(BRANCHES);
   }
 
@@ -103,40 +123,37 @@
     List<String> branches = Lists.newArrayList("refs/heads/does-not-exist");
     branches.addAll(BRANCHES);
     input.branches = branches;
-    try {
-      project().deleteBranches(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteBranches(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
     assertBranchesDeleted(BRANCHES);
   }
 
   @Test
   public void missingInput() throws Exception {
     DeleteBranchesInput input = null;
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().contains("branches must be specified");
   }
 
   @Test
   public void missingBranchList() throws Exception {
     DeleteBranchesInput input = new DeleteBranchesInput();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().contains("branches must be specified");
   }
 
   @Test
   public void emptyBranchList() throws Exception {
     DeleteBranchesInput input = new DeleteBranchesInput();
     input.branches = Lists.newArrayList();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().contains("branches must be specified");
   }
 
   private String errorMessageForBranches(List<String> branches) {
@@ -154,7 +171,7 @@
   private HashMap<String, RevCommit> initialRevisions(List<String> branches) throws Exception {
     HashMap<String, RevCommit> result = new HashMap<>();
     for (String branch : branches) {
-      result.put(branch, getRemoteHead(project, branch));
+      result.put(branch, projectOperations.project(project).getHead(branch));
     }
     return result;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 0cbbe44..9770031 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -15,24 +15,33 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
 
 public class DeleteTagIT extends AbstractDaemonTest {
   private static final String TAG = "refs/tags/test";
 
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Before
   public void setUp() throws Exception {
     tag().create(new TagInput());
@@ -40,7 +49,7 @@
 
   @Test
   public void deleteTag_Forbidden() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteForbidden();
   }
 
@@ -52,7 +61,7 @@
   @Test
   public void deleteTagByProjectOwner() throws Exception {
     grantOwner();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteSucceeds();
   }
 
@@ -66,21 +75,21 @@
   public void deleteTagByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
     grantOwner();
     blockForcePush();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteForbidden();
   }
 
   @Test
   public void deleteTagByUserWithForcePushPermission() throws Exception {
     grantForcePush();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteSucceeds();
   }
 
   @Test
   public void deleteTagByUserWithDeletePermission() throws Exception {
     grantDelete();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertDeleteSucceeds();
   }
 
@@ -93,19 +102,35 @@
   }
 
   private void blockForcePush() throws Exception {
-    block("refs/tags/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantForcePush() throws Exception {
-    grant(project, "refs/tags/*", Permission.PUSH, true, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantDelete() throws Exception {
-    allow("refs/tags/*", Permission.DELETE, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void grantOwner() throws Exception {
-    allow("refs/tags/*", Permission.OWNER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private TagApi tag() throws Exception {
@@ -118,14 +143,12 @@
     String tagRev = tagInfo.revision;
     tag().delete();
     eventRecorder.assertRefUpdatedEvents(project.get(), TAG, null, tagRev, tagRev, null);
-    exception.expect(ResourceNotFoundException.class);
-    tag().get();
+    assertThrows(ResourceNotFoundException.class, () -> tag().get());
   }
 
   private void assertDeleteForbidden() throws Exception {
     assertThat(tag().get().canDelete).isNull();
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    tag().delete();
+    AuthException thrown = assertThrows(AuthException.class, () -> tag().delete());
+    assertThat(thrown).hasMessageThat().contains("not permitted: delete");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
index 2ada724..46e2345 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
@@ -23,11 +24,14 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.List;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -39,6 +43,9 @@
   private static final ImmutableList<String> TAGS =
       ImmutableList.of("refs/tags/test-1", "refs/tags/test-2", "refs/tags/test-3", "test-4");
 
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Before
   public void setUp() throws Exception {
     for (String name : TAGS) {
@@ -61,14 +68,11 @@
   public void deleteTagsForbidden() throws Exception {
     DeleteTagsInput input = new DeleteTagsInput();
     input.tags = TAGS;
-    setApiUser(user);
-    try {
-      project().deleteTags(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e).hasMessageThat().isEqualTo(errorMessageForTags(TAGS));
-    }
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(user.id());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteTags(input));
+    assertThat(thrown).hasMessageThat().isEqualTo(errorMessageForTags(TAGS));
+    requestScopeOperations.setApiUser(admin.id());
     assertTags(TAGS);
   }
 
@@ -78,14 +82,11 @@
     List<String> tags = Lists.newArrayList(TAGS);
     tags.add("refs/tags/does-not-exist");
     input.tags = tags;
-    try {
-      project().deleteTags(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteTags(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
     assertTagsDeleted();
   }
 
@@ -97,14 +98,11 @@
     List<String> tags = Lists.newArrayList("refs/tags/does-not-exist");
     tags.addAll(TAGS);
     input.tags = tags;
-    try {
-      project().deleteTags(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
-    }
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> project().deleteTags(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
     assertTagsDeleted();
   }
 
@@ -124,7 +122,7 @@
     HashMap<String, RevCommit> result = new HashMap<>();
     for (String tag : tags) {
       String ref = prefixRef(tag);
-      result.put(ref, getRemoteHead(project, ref));
+      result.put(ref, projectOperations.project(project).getHead(ref));
     }
     return result;
   }
@@ -154,6 +152,6 @@
   }
 
   private void assertTagsDeleted() throws Exception {
-    assertTags(ImmutableList.<String>of());
+    assertTags(ImmutableList.of());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
index 63f41ad..e63b28bc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -22,18 +23,18 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class FileBranchIT extends AbstractDaemonTest {
 
-  private Branch.NameKey branch;
+  private BranchNameKey branch;
 
   @Before
   public void setUp() throws Exception {
-    branch = new Branch.NameKey(project, "master");
+    branch = BranchNameKey.create(project, "master");
     PushOneCommit.Result change = createChange();
     approve(change.getChangeId());
     revision(change).submit();
@@ -45,12 +46,12 @@
     assertThat(content.asString()).isEqualTo(PushOneCommit.FILE_CONTENT);
   }
 
-  @Test(expected = ResourceNotFoundException.class)
+  @Test
   public void getNonExistingFile() throws Exception {
-    branch().file("does-not-exist");
+    assertThrows(ResourceNotFoundException.class, () -> branch().file("does-not-exist"));
   }
 
   private BranchApi branch() throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+    return gApi.projects().name(branch.project().get()).branch(branch.branch());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
index 78d0270..48527af 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
@@ -18,12 +18,14 @@
 import com.google.gerrit.acceptance.GcAssert;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
 
 public class GarbageCollectionIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Inject private GcAssert gcAssert;
 
@@ -31,7 +33,7 @@
 
   @Before
   public void setUp() throws Exception {
-    project2 = createProject("p2");
+    project2 = projectOperations.newProject().create();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
index d5e811d..7e45e02 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
@@ -14,17 +14,22 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 @NoHttpd
 public class GetChildProjectIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void getNonExistingChildProject_NotFound() throws Exception {
@@ -33,15 +38,15 @@
 
   @Test
   public void getNonChildProject_NotFound() throws Exception {
-    Project.NameKey p1 = createProject("p1");
-    Project.NameKey p2 = createProject("p2");
+    Project.NameKey p1 = projectOperations.newProject().create();
+    Project.NameKey p2 = projectOperations.newProject().create();
 
     assertChildNotFound(p1, p2.get());
   }
 
   @Test
   public void getChildProject() throws Exception {
-    Project.NameKey child = createProject("p1");
+    Project.NameKey child = projectOperations.newProject().create();
     ProjectInfo childInfo = gApi.projects().name(allProjects.get()).child(child.get()).get();
 
     assertProjectInfo(projectCache.get(child).getProject(), childInfo);
@@ -49,16 +54,16 @@
 
   @Test
   public void getGrandChildProject_NotFound() throws Exception {
-    Project.NameKey child = createProject("p1");
-    Project.NameKey grandChild = createProject("p1.1", child);
+    Project.NameKey child = projectOperations.newProject().create();
+    Project.NameKey grandChild = projectOperations.newProject().parent(child).create();
 
     assertChildNotFound(allProjects, grandChild.get());
   }
 
   @Test
   public void getGrandChildProjectWithRecursiveFlag() throws Exception {
-    Project.NameKey child = createProject("p1");
-    Project.NameKey grandChild = createProject("p1.1", child);
+    Project.NameKey child = projectOperations.newProject().create();
+    Project.NameKey grandChild = projectOperations.newProject().parent(child).create();
 
     ProjectInfo grandChildInfo =
         gApi.projects().name(allProjects.get()).child(grandChild.get()).get(true);
@@ -66,8 +71,10 @@
   }
 
   private void assertChildNotFound(Project.NameKey parent, String child) throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(child);
-    gApi.projects().name(parent.get()).child(child).get();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(parent.get()).child(child).get());
+    assertThat(thrown).hasMessageThat().contains(child);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index 0632221..f9011c7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -15,14 +15,18 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -32,12 +36,18 @@
 import org.junit.Test;
 
 public class GetCommitIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   private TestRepository<Repository> repo;
 
   @Before
   public void setUp() throws Exception {
     repo = GitUtil.newTestRepository(repoManager.openRepository(project));
-    blockRead("refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
   @After
@@ -85,8 +95,7 @@
   @Test
   public void getOpenChange_Found() throws Exception {
     unblockRead();
-    PushOneCommit.Result r =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
+    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
     r.assertOkStatus();
 
     CommitInfo info = getCommit(r.getCommit());
@@ -108,8 +117,7 @@
 
   @Test
   public void getOpenChange_NotFound() throws Exception {
-    PushOneCommit.Result r =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
+    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
     r.assertOkStatus();
     assertNotFound(r.getCommit());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
index 989050c..e9aa589 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -54,8 +55,9 @@
     assertThat(p.name).isEqualTo(name);
   }
 
-  @Test(expected = ResourceNotFoundException.class)
+  @Test
   public void getProjectNotExisting() throws Exception {
-    gApi.projects().name("does-not-exist").get();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.projects().name("does-not-exist").get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index bc029ae..a797b98 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -14,34 +14,49 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
 import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 @NoHttpd
 public class ListBranchesIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void listBranchesOfNonExistingProject_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("non-existing").branches().get();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name("non-existing").branches().get());
   }
 
   @Test
   public void listBranchesOfNonVisibleProject_NotFound() throws Exception {
-    blockRead("refs/*");
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).branches().get();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).branches().get());
   }
 
   @Test
@@ -56,10 +71,11 @@
   public void listBranches() throws Exception {
     String master = pushTo("refs/heads/master").getCommit().name();
     String dev = pushTo("refs/heads/dev").getCommit().name();
+    String refsConfig = projectOperations.project(project).getHead(RefNames.REFS_CONFIG).name();
     assertRefs(
         ImmutableList.of(
             branch("HEAD", "master", false),
-            branch(RefNames.REFS_CONFIG, null, false),
+            branch(RefNames.REFS_CONFIG, refsConfig, false),
             branch("refs/heads/dev", dev, true),
             branch("refs/heads/master", master, false)),
         list().get());
@@ -67,10 +83,14 @@
 
   @Test
   public void listBranchesSomeHidden() throws Exception {
-    blockRead("refs/heads/dev");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/dev").group(REGISTERED_USERS))
+        .update();
     String master = pushTo("refs/heads/master").getCommit().name();
     pushTo("refs/heads/dev");
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     // refs/meta/config is hidden since user is no project owner
     assertRefs(
         ImmutableList.of(
@@ -80,82 +100,81 @@
 
   @Test
   public void listBranchesHeadHidden() throws Exception {
-    blockRead("refs/heads/master");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     pushTo("refs/heads/master");
     String dev = pushTo("refs/heads/dev").getCommit().name();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     // refs/meta/config is hidden since user is no project owner
     assertRefs(ImmutableList.of(branch("refs/heads/dev", dev, false)), list().get());
   }
 
   @Test
   public void listBranchesUsingPagination() throws Exception {
-    pushTo("refs/heads/master");
-    pushTo("refs/heads/someBranch1");
-    pushTo("refs/heads/someBranch2");
-    pushTo("refs/heads/someBranch3");
+    BranchInfo head = branch("HEAD", "master", false);
+    BranchInfo refsConfig =
+        branch(
+            RefNames.REFS_CONFIG,
+            projectOperations.project(project).getHead(RefNames.REFS_CONFIG).name(),
+            false);
+    BranchInfo master =
+        branch("refs/heads/master", pushTo("refs/heads/master").getCommit().getName(), false);
+    BranchInfo branch1 =
+        branch(
+            "refs/heads/someBranch1", pushTo("refs/heads/someBranch1").getCommit().getName(), true);
+    BranchInfo branch2 =
+        branch(
+            "refs/heads/someBranch2", pushTo("refs/heads/someBranch2").getCommit().getName(), true);
+    BranchInfo branch3 =
+        branch(
+            "refs/heads/someBranch3", pushTo("refs/heads/someBranch3").getCommit().getName(), true);
 
     // Using only limit.
-    assertRefNames(
-        ImmutableList.of(
-            "HEAD", RefNames.REFS_CONFIG, "refs/heads/master", "refs/heads/someBranch1"),
-        list().withLimit(4).get());
+    assertRefs(ImmutableList.of(head, refsConfig, master, branch1), list().withLimit(4).get());
 
     // Limit higher than total number of branches.
-    assertRefNames(
-        ImmutableList.of(
-            "HEAD",
-            RefNames.REFS_CONFIG,
-            "refs/heads/master",
-            "refs/heads/someBranch1",
-            "refs/heads/someBranch2",
-            "refs/heads/someBranch3"),
+    assertRefs(
+        ImmutableList.of(head, refsConfig, master, branch1, branch2, branch3),
         list().withLimit(25).get());
 
     // Using start only.
-    assertRefNames(
-        ImmutableList.of(
-            "refs/heads/master",
-            "refs/heads/someBranch1",
-            "refs/heads/someBranch2",
-            "refs/heads/someBranch3"),
-        list().withStart(2).get());
+    assertRefs(ImmutableList.of(master, branch1, branch2, branch3), list().withStart(2).get());
 
     // Skip more branches than the number of available branches.
-    assertRefNames(ImmutableList.<String>of(), list().withStart(7).get());
+    assertRefs(ImmutableList.of(), list().withStart(7).get());
 
     // Ssing start and limit.
-    assertRefNames(
-        ImmutableList.of("refs/heads/master", "refs/heads/someBranch1"),
-        list().withStart(2).withLimit(2).get());
+    assertRefs(ImmutableList.of(master, branch1), list().withStart(2).withLimit(2).get());
   }
 
   @Test
   public void listBranchesUsingFilter() throws Exception {
-    pushTo("refs/heads/master");
-    pushTo("refs/heads/someBranch1");
-    pushTo("refs/heads/someBranch2");
-    pushTo("refs/heads/someBranch3");
+    BranchInfo master =
+        branch("refs/heads/master", pushTo("refs/heads/master").getCommit().getName(), false);
+    BranchInfo branch1 =
+        branch(
+            "refs/heads/someBranch1", pushTo("refs/heads/someBranch1").getCommit().getName(), true);
+    BranchInfo branch2 =
+        branch(
+            "refs/heads/someBranch2", pushTo("refs/heads/someBranch2").getCommit().getName(), true);
+    BranchInfo branch3 =
+        branch(
+            "refs/heads/someBranch3", pushTo("refs/heads/someBranch3").getCommit().getName(), true);
 
     // Using substring.
-    assertRefNames(
-        ImmutableList.of(
-            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
-        list().withSubstring("some").get());
+    assertRefs(ImmutableList.of(branch1, branch2, branch3), list().withSubstring("some").get());
 
-    assertRefNames(
-        ImmutableList.of(
-            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
-        list().withSubstring("Branch").get());
+    assertRefs(ImmutableList.of(branch1, branch2, branch3), list().withSubstring("Branch").get());
 
-    assertRefNames(
-        ImmutableList.of(
-            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
-        list().withSubstring("somebranch").get());
+    assertRefs(
+        ImmutableList.of(branch1, branch2, branch3), list().withSubstring("somebranch").get());
 
     // Using regex.
-    assertRefNames(ImmutableList.of("refs/heads/master"), list().withRegex(".*ast.*r").get());
-    assertRefNames(ImmutableList.of(), list().withRegex(".*AST.*R").get());
+    assertRefs(ImmutableList.of(master), list().withRegex(".*ast.*r").get());
+    assertRefs(ImmutableList.of(), list().withRegex(".*AST.*R").get());
 
     // Conflicting options
     assertBadRequest(list().withSubstring("somebranch").withRegex(".*ast.*r"));
@@ -174,11 +193,6 @@
   }
 
   private void assertBadRequest(ListRefsRequest<BranchInfo> req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index dd92a7a..0fdeba6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -14,22 +14,30 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
+import org.apache.commons.lang.RandomStringUtils;
 import org.junit.Test;
 
 @NoHttpd
 public class ListChildProjectsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void listChildrenOfNonExistingProject_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("non-existing");
-    gApi.projects().name(name("non-existing")).child("children");
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(name("non-existing")).child("children"));
+    assertThat(thrown).hasMessageThat().contains("non-existing");
   }
 
   @Test
@@ -39,23 +47,38 @@
 
   @Test
   public void listChildren() throws Exception {
-    Project.NameKey child1 = createProject("p1");
-    Project.NameKey child1_1 = createProject("p1.1", child1);
-    Project.NameKey child1_2 = createProject("p1.2", child1);
+    Project.NameKey child1 = projectOperations.newProject().create();
+    Project.NameKey child1_1 = projectOperations.newProject().parent(child1).create();
+    Project.NameKey child1_2 = projectOperations.newProject().parent(child1).create();
 
+    assertThatNameList(gApi.projects().name(child1.get()).children()).isInOrder();
     assertThatNameList(gApi.projects().name(child1.get()).children())
-        .containsExactly(child1_1, child1_2)
-        .inOrder();
+        .containsExactly(child1_1, child1_2);
+  }
+
+  @Test
+  public void listChildrenWithLimit() throws Exception {
+    String prefix = RandomStringUtils.randomAlphabetic(8);
+    Project.NameKey child1 = projectOperations.newProject().name(prefix + "p1").create();
+    Project.NameKey child1_1 =
+        projectOperations.newProject().parent(child1).name(prefix + "p1.1").create();
+    projectOperations.newProject().parent(child1).name(prefix + "p1.2").create();
+
+    assertThatNameList(gApi.projects().name(child1.get()).children(1)).containsExactly(child1_1);
   }
 
   @Test
   public void listChildrenRecursively() throws Exception {
-    Project.NameKey child1 = createProject("p1");
-    createProject("p2");
-    Project.NameKey child1_1 = createProject("p1.1", child1);
-    Project.NameKey child1_2 = createProject("p1.2", child1);
-    Project.NameKey child1_1_1 = createProject("p1.1.1", child1_1);
-    Project.NameKey child1_1_1_1 = createProject("p1.1.1.1", child1_1_1);
+    String prefix = RandomStringUtils.randomAlphabetic(8);
+    Project.NameKey child1 = projectOperations.newProject().name(prefix + "p1").create();
+    Project.NameKey child1_1 =
+        projectOperations.newProject().parent(child1).name(prefix + "p1.1").create();
+    Project.NameKey child1_2 =
+        projectOperations.newProject().parent(child1).name(prefix + "p1.2").create();
+    Project.NameKey child1_1_1 =
+        projectOperations.newProject().parent(child1_1).name(prefix + "p1.1.1").create();
+    Project.NameKey child1_1_1_1 =
+        projectOperations.newProject().parent(child1_1_1).name(prefix + "p1.1.1.1").create();
 
     assertThatNameList(gApi.projects().name(child1.get()).children(true))
         .containsExactly(child1_1, child1_1_1, child1_1_1_1, child1_2)
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
new file mode 100644
index 0000000..3ac2d10
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gson.reflect.TypeToken;
+import java.lang.reflect.Type;
+import java.util.Map;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class ListCommitFilesIT extends AbstractDaemonTest {
+  private static String SUBJECT_1 = "subject 1";
+  private static String SUBJECT_2 = "subject 2";
+  private static String FILE_A = "a.txt";
+  private static String FILE_B = "b.txt";
+
+  @Test
+  public void listCommitFiles() throws Exception {
+    commitBuilder().add(FILE_B, "2").message(SUBJECT_1).create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    RevCommit a = commitBuilder().add(FILE_A, "1").rm(FILE_B).message(SUBJECT_2).create();
+    String id = getChangeId(testRepo, a).get();
+    pushHead(testRepo, "refs/for/master", false);
+
+    RestResponse r =
+        userRestSession.get("/projects/" + project.get() + "/commits/" + a.name() + "/files/");
+    r.assertOK();
+    Type type = new TypeToken<Map<String, FileInfo>>() {}.getType();
+    Map<String, FileInfo> files1 = newGson().fromJson(r.getReader(), type);
+    r.consume();
+
+    r = userRestSession.get("/changes/" + id + "/revisions/" + a.name() + "/files");
+    r.assertOK();
+    Map<String, FileInfo> files2 = newGson().fromJson(r.getReader(), type);
+    r.consume();
+
+    assertThat(files1).isEqualTo(files2);
+  }
+
+  @Test
+  public void listMergeCommitFiles() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master");
+
+    RestResponse r =
+        userRestSession.get(
+            "/projects/"
+                + project.get()
+                + "/commits/"
+                + result.getCommit().name()
+                + "/files/?parent=2");
+    r.assertOK();
+    Type type = new TypeToken<Map<String, FileInfo>>() {}.getType();
+    Map<String, FileInfo> files1 = newGson().fromJson(r.getReader(), type);
+    r.consume();
+
+    r =
+        userRestSession.get(
+            "/changes/"
+                + result.getChangeId()
+                + "/revisions/"
+                + result.getCommit().name()
+                + "/files/?parent=2");
+    r.assertOK();
+    Map<String, FileInfo> files2 = newGson().fromJson(r.getReader(), type);
+    r.consume();
+
+    assertThat(files1).isEqualTo(files2);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index cd88a56..caef689 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -16,13 +16,22 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -31,35 +40,47 @@
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.ProjectCacheImpl;
+import com.google.gerrit.server.restapi.project.ListProjects;
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.IntStream;
 import org.junit.Test;
 
 @NoHttpd
 @Sandboxed
 public class ListProjectsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ListProjects listProjects;
 
   @Test
   public void listProjects() throws Exception {
-    Project.NameKey someProject = createProject("some-project");
-    assertThatNameList(filter(gApi.projects().list().get()))
-        .containsExactly(allProjects, allUsers, project, someProject)
-        .inOrder();
+    Project.NameKey someProject = projectOperations.newProject().create();
+    assertThatNameList(gApi.projects().list().get())
+        .containsExactly(allProjects, allUsers, project, someProject);
+    assertThatNameList(gApi.projects().list().get()).isInOrder();
   }
 
   @Test
   public void listProjectsFiltersInvisibleProjects() throws Exception {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     assertThatNameList(gApi.projects().list().get()).contains(project);
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
-    assertThatNameList(filter(gApi.projects().list().get())).doesNotContain(project);
+    assertThatNameList(gApi.projects().list().get()).doesNotContain(project);
   }
 
   @Test
@@ -87,91 +108,172 @@
 
   @Test
   public void listProjectsWithLimit() throws Exception {
-    for (int i = 0; i < 5; i++) {
-      createProject("someProject" + i);
+    ProjectCacheImpl projectCacheImpl = (ProjectCacheImpl) projectCache;
+    String pre = "lpwl-someProject";
+    int n = 6;
+    for (int i = 0; i < n; i++) {
+      projectOperations.newProject().name(pre + i).create();
     }
 
-    String p = name("");
-    // 5, plus p which was automatically created.
-    int n = 6;
+    projectCacheImpl.evictAllByName();
     for (int i = 1; i <= n + 2; i++) {
-      assertThatNameList(gApi.projects().list().withPrefix(p).withLimit(i).get())
+      assertThatNameList(gApi.projects().list().withPrefix(pre).withLimit(i).get())
           .hasSize(Math.min(i, n));
+      assertThat(projectCacheImpl.sizeAllByName())
+          .isAtMost((long) (i + 2)); // 2 = AllProjects + AllUsers
     }
   }
 
   @Test
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "true")
+  public void listProjectsFromIndexShouldBeLimitedTo500() throws Exception {
+    int numTestProjects = 501;
+    assertThat(createProjects("foo", numTestProjects)).hasSize(numTestProjects);
+    assertThat(gApi.projects().list().get()).hasSize(500);
+  }
+
+  @Test
+  public void listProjectsShouldNotBeLimitedByDefault() throws Exception {
+    int numTestProjects = 501;
+    assertThat(createProjects("foo", numTestProjects)).hasSize(numTestProjects);
+    assertThat(gApi.projects().list().get().size()).isAtLeast(numTestProjects);
+  }
+
+  @Test
+  public void listProjectsToOutputStream() throws Exception {
+    int numInitialProjects = gApi.projects().list().get().size();
+    int numTestProjects = 5;
+    List<String> testProjects = createProjects("zzz_testProject", numTestProjects);
+    try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
+
+      listProjects.setStart(numInitialProjects);
+      listProjects.displayToStream(displayOut);
+
+      List<String> lines =
+          Splitter.on("\n")
+              .omitEmptyStrings()
+              .splitToList(new String(displayOut.toByteArray(), UTF_8));
+      assertThat(lines).isEqualTo(testProjects);
+    }
+  }
+
+  @Test
+  public void listProjectsAsJsonMultilineToOutputStream() throws Exception {
+    listProjectsAsJsonToOutputStream(OutputFormat.JSON);
+  }
+
+  @Test
+  public void listProjectsAsJsonCompactToOutputStream() throws Exception {
+    String jsonOutput = listProjectsAsJsonToOutputStream(OutputFormat.JSON_COMPACT).trim();
+    assertThat(jsonOutput).doesNotContain("\n");
+  }
+
+  private String listProjectsAsJsonToOutputStream(OutputFormat jsonFormat) throws Exception {
+    assertThat(jsonFormat.isJson()).isTrue();
+
+    int numInitialProjects = gApi.projects().list().get().size();
+    int numTestProjects = 5;
+    Set<String> testProjects =
+        ImmutableSet.copyOf(createProjects("zzz_testProject", numTestProjects));
+    try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
+
+      listProjects.setStart(numInitialProjects);
+      listProjects.setFormat(jsonFormat);
+      listProjects.displayToStream(displayOut);
+
+      String projectsJsonOutput = new String(displayOut.toByteArray(), UTF_8);
+
+      Gson gson = jsonFormat.newGson();
+      Set<String> projectsJsonNames = gson.fromJson(projectsJsonOutput, JsonObject.class).keySet();
+      assertThat(projectsJsonNames).isEqualTo(testProjects);
+
+      return projectsJsonOutput;
+    }
+  }
+
+  private List<String> createProjects(String prefix, int numProjects) {
+    return IntStream.range(0, numProjects)
+        .mapToObj(i -> projectOperations.newProject().name(prefix + i).create())
+        .map(Project.NameKey::get)
+        .collect(toList());
+  }
+
+  @Test
   public void listProjectsWithPrefix() throws Exception {
-    Project.NameKey someProject = createProject("some-project");
-    Project.NameKey someOtherProject = createProject("some-other-project");
-    createProject("project-awesome");
+    Project.NameKey someProject = projectOperations.newProject().name("listtest-p1").create();
+    Project.NameKey someOtherProject = projectOperations.newProject().name("listtest-p2").create();
+    projectOperations.newProject().name("other-prefix-project").create();
 
-    String p = name("some");
+    String p = "listtest";
     assertBadRequest(gApi.projects().list().withPrefix(p).withRegex(".*"));
     assertBadRequest(gApi.projects().list().withPrefix(p).withSubstring(p));
-    assertThatNameList(filter(gApi.projects().list().withPrefix(p).get()))
-        .containsExactly(someOtherProject, someProject)
-        .inOrder();
-    p = name("SOME");
-    assertThatNameList(filter(gApi.projects().list().withPrefix(p).get())).isEmpty();
+    assertThatNameList(gApi.projects().list().withPrefix(p).get())
+        .containsExactly(someOtherProject, someProject);
+    p = "notlisttest";
+    assertThatNameList(gApi.projects().list().withPrefix(p).get()).isEmpty();
   }
 
   @Test
   public void listProjectsWithRegex() throws Exception {
-    Project.NameKey someProject = createProject("some-project");
-    Project.NameKey someOtherProject = createProject("some-other-project");
-    Project.NameKey projectAwesome = createProject("project-awesome");
+    Project.NameKey someProject = projectOperations.newProject().name("lpwr-some-project").create();
+    Project.NameKey someOtherProject =
+        projectOperations.newProject().name("lpwr-some-other-project").create();
+    Project.NameKey projectAwesome =
+        projectOperations.newProject().name("lpwr-project-awesome").create();
 
     assertBadRequest(gApi.projects().list().withRegex("[.*"));
     assertBadRequest(gApi.projects().list().withRegex(".*").withPrefix("p"));
     assertBadRequest(gApi.projects().list().withRegex(".*").withSubstring("p"));
 
-    assertThatNameList(filter(gApi.projects().list().withRegex(".*some").get()))
+    assertThatNameList(gApi.projects().list().withRegex(".*some").get())
         .containsExactly(projectAwesome);
-    String r = name("some-project$").replace(".", "\\.");
-    assertThatNameList(filter(gApi.projects().list().withRegex(r).get()))
-        .containsExactly(someProject);
-    assertThatNameList(filter(gApi.projects().list().withRegex(".*").get()))
+    String r = ("lpwr-some-project$").replace(".", "\\.");
+    assertThatNameList(gApi.projects().list().withRegex(r).get()).containsExactly(someProject);
+    assertThatNameList(gApi.projects().list().withRegex(".*").get())
         .containsExactly(
-            allProjects, allUsers, project, projectAwesome, someOtherProject, someProject)
-        .inOrder();
+            allProjects, allUsers, project, projectAwesome, someOtherProject, someProject);
   }
 
   @Test
   public void listProjectsWithStart() throws Exception {
+    String pre = "lpws-";
     for (int i = 0; i < 5; i++) {
-      createProject(new Project.NameKey("someProject" + i).get());
+      projectOperations.newProject().name(pre + i).create();
     }
 
-    String p = name("");
-    List<ProjectInfo> all = gApi.projects().list().withPrefix(p).get();
-    // 5, plus p which was automatically created.
-    int n = 6;
+    List<ProjectInfo> all = gApi.projects().list().withPrefix(pre).get();
+    int n = 5;
     assertThat(all).hasSize(n);
-    assertThatNameList(gApi.projects().list().withPrefix(p).withStart(n - 1).get())
-        .containsExactly(new Project.NameKey(Iterables.getLast(all).name));
+    assertThatNameList(gApi.projects().list().withPrefix(pre).withStart(n - 1).get())
+        .containsExactly(Project.nameKey(Iterables.getLast(all).name));
   }
 
   @Test
   public void listProjectsWithSubstring() throws Exception {
-    Project.NameKey someProject = createProject("some-project");
-    Project.NameKey someOtherProject = createProject("some-other-project");
-    Project.NameKey projectAwesome = createProject("project-awesome");
+    Project.NameKey someProject = projectOperations.newProject().name("some-project").create();
+    Project.NameKey someOtherProject =
+        projectOperations.newProject().name("some-other-project").create();
+    Project.NameKey projectAwesome =
+        projectOperations.newProject().name("project-awesome").create();
 
     assertBadRequest(gApi.projects().list().withSubstring("some").withRegex(".*"));
     assertBadRequest(gApi.projects().list().withSubstring("some").withPrefix("some"));
-    assertThatNameList(filter(gApi.projects().list().withSubstring("some").get()))
-        .containsExactly(projectAwesome, someOtherProject, someProject)
-        .inOrder();
-    assertThatNameList(filter(gApi.projects().list().withSubstring("SOME").get()))
-        .containsExactly(projectAwesome, someOtherProject, someProject)
-        .inOrder();
+    assertThatNameList(gApi.projects().list().withSubstring("some").get())
+        .containsExactly(projectAwesome, someOtherProject, someProject);
+    assertThatNameList(gApi.projects().list().withSubstring("SOME").get())
+        .containsExactly(projectAwesome, someOtherProject, someProject);
   }
 
   @Test
   public void listProjectsWithTree() throws Exception {
-    Project.NameKey someParentProject = createProject("some-parent-project");
-    Project.NameKey someChildProject = createProject("some-child-project", someParentProject);
+    Project.NameKey someParentProject =
+        projectOperations.newProject().name("some-parent-project").create();
+    Project.NameKey someChildProject =
+        projectOperations
+            .newProject()
+            .name("some-child-project")
+            .parent(someParentProject)
+            .create();
 
     Map<String, ProjectInfo> result = gApi.projects().list().withTree(true).getAsMap();
     assertThat(result).containsKey(someChildProject.get());
@@ -184,15 +286,14 @@
         gApi.projects().list().withType(FilterType.PERMISSIONS).getAsMap();
     assertThat(result.keySet()).containsExactly(allProjects.get(), allUsers.get());
 
-    assertThatNameList(filter(gApi.projects().list().withType(FilterType.ALL).get()))
-        .containsExactly(allProjects, allUsers, project)
-        .inOrder();
+    assertThatNameList(gApi.projects().list().withType(FilterType.ALL).get())
+        .containsExactly(allProjects, allUsers, project);
   }
 
   @Test
   public void listWithHiddenAndReadonlyProjects() throws Exception {
-    Project.NameKey hidden = createProject("project-to-hide");
-    Project.NameKey readonly = createProject("project-to-read");
+    Project.NameKey hidden = projectOperations.newProject().create();
+    Project.NameKey readonly = projectOperations.newProject().create();
 
     // Set project read-only
     ConfigInput input = new ConfigInput();
@@ -203,8 +304,7 @@
     // The hidden project is included because it was not hidden yet.
     // The read-only project is included.
     assertThatNameList(gApi.projects().list().get())
-        .containsExactly(allProjects, allUsers, project, hidden, readonly)
-        .inOrder();
+        .containsExactly(allProjects, allUsers, project, hidden, readonly);
 
     // Hide the project
     input.state = ProjectState.HIDDEN;
@@ -216,18 +316,15 @@
 
     // Hidden project is not included in the list
     assertThatNameList(gApi.projects().list().get())
-        .containsExactly(allProjects, allUsers, project, readonly)
-        .inOrder();
+        .containsExactly(allProjects, allUsers, project, readonly);
 
     // ALL filter applies to type, and doesn't include hidden state
     assertThatNameList(gApi.projects().list().withType(FilterType.ALL).get())
-        .containsExactly(allProjects, allUsers, project, readonly)
-        .inOrder();
+        .containsExactly(allProjects, allUsers, project, readonly);
 
     // "All" boolean option causes hidden projects to be included
     assertThatNameList(gApi.projects().list().withAll(true).get())
-        .containsExactly(allProjects, allUsers, project, hidden, readonly)
-        .inOrder();
+        .containsExactly(allProjects, allUsers, project, hidden, readonly);
 
     // "State" option causes only the projects in that state to be included
     assertThatNameList(gApi.projects().list().withState(ProjectState.HIDDEN).get())
@@ -235,31 +332,13 @@
     assertThatNameList(gApi.projects().list().withState(ProjectState.READ_ONLY).get())
         .containsExactly(readonly);
     assertThatNameList(gApi.projects().list().withState(ProjectState.ACTIVE).get())
-        .containsExactly(allProjects, allUsers, project)
-        .inOrder();
+        .containsExactly(allProjects, allUsers, project);
 
     // Cannot use "all" and "state" together
     assertBadRequest(gApi.projects().list().withAll(true).withState(ProjectState.ACTIVE));
   }
 
   private void assertBadRequest(ListRequest req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException expected) {
-      // Expected.
-    }
-  }
-
-  private Iterable<ProjectInfo> filter(Iterable<ProjectInfo> infos) {
-    String prefix = name("");
-    return Iterables.filter(
-        infos,
-        p -> {
-          return p.name != null
-              && (p.name.equals(allProjects.get())
-                  || p.name.equals(allUsers.get())
-                  || p.name.startsWith(prefix));
-        });
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
index 1e6afa8..e7663f7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
@@ -26,15 +26,21 @@
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.permissions.PluginPermissionsUtil;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Module;
+import java.util.Set;
 import org.junit.Test;
 
-public class PluginAccessIT extends AbstractDaemonTest {
+public final class PluginAccessIT extends AbstractDaemonTest {
+  private static final String TEST_PLUGIN_NAME = "gerrit";
+  private static final String TEST_PLUGIN_CAPABILITY = "aPluginCapability";
+  private static final String TEST_PLUGIN_PROJECT_PERMISSION = "aPluginProjectPermission";
 
-  private static final String CORE_PLUGIN_PREFIX = "gerrit-";
-  private static final String PLUGIN_CAPABILITY = "printHello";
+  @Inject PluginPermissionsUtil pluginPermissionsUtil;
 
   @Override
   public Module createModule() {
@@ -42,12 +48,21 @@
       @Override
       protected void configure() {
         bind(CapabilityDefinition.class)
-            .annotatedWith(Exports.named(PLUGIN_CAPABILITY))
+            .annotatedWith(Exports.named(TEST_PLUGIN_CAPABILITY))
             .toInstance(
                 new CapabilityDefinition() {
                   @Override
                   public String getDescription() {
-                    return "Print Hello";
+                    return "A Plugin Capability";
+                  }
+                });
+        bind(PluginProjectPermissionDefinition.class)
+            .annotatedWith(Exports.named(TEST_PLUGIN_PROJECT_PERMISSION))
+            .toInstance(
+                new PluginProjectPermissionDefinition() {
+                  @Override
+                  public String getDescription() {
+                    return "A Plugin Project Permission";
                   }
                 });
       }
@@ -55,24 +70,47 @@
   }
 
   @Test
-  public void addPluginCapability() throws Exception {
-    ProjectAccessInput accessInput = new ProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = new AccessSectionInfo();
-    PermissionInfo email = new PermissionInfo(null, null);
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+  public void setAccessAddPluginCapabilitySucceed() throws Exception {
+    String pluginCapability = TEST_PLUGIN_NAME + "-" + TEST_PLUGIN_CAPABILITY;
+    ProjectAccessInput accessInput =
+        createAccessInput(AccessSection.GLOBAL_CAPABILITIES, pluginCapability);
 
-    email.rules = ImmutableMap.of(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions = ImmutableMap.of(CORE_PLUGIN_PREFIX + PLUGIN_CAPABILITY, email);
-    accessInput.add = ImmutableMap.of(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    ProjectAccessInfo updatedAccessSectionInfo =
+    ProjectAccessInfo projectAccessInfo =
         gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedAccessSectionInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
-        .containsAllIn(accessSectionInfo.permissions.keySet());
+
+    Set<String> capabilities =
+        projectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions.keySet();
+    assertThat(capabilities).contains(pluginCapability);
+    // Verifies the plugin defined capability could be listed.
+    assertThat(pluginPermissionsUtil.collectPluginCapabilities()).containsKey(pluginCapability);
+  }
+
+  @Test
+  public void setAccessAddPluginProjectPermissionSucceed() throws Exception {
+    String pluginProjectPermission =
+        "plugin-" + TEST_PLUGIN_NAME + "-" + TEST_PLUGIN_PROJECT_PERMISSION;
+    String accessSection = "refs/heads/plugin-permission";
+    ProjectAccessInput accessInput = createAccessInput(accessSection, pluginProjectPermission);
+
+    ProjectAccessInfo projectAccessInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+
+    Set<String> permissions = projectAccessInfo.local.get(accessSection).permissions.keySet();
+    assertThat(permissions).contains(pluginProjectPermission);
+    // Verifies the plugin defined capability could be listed.
+    assertThat(pluginPermissionsUtil.collectPluginProjectPermissions())
+        .containsKey(pluginProjectPermission);
+  }
+
+  private static ProjectAccessInput createAccessInput(String accessSection, String permissionName) {
+    ProjectAccessInput accessInput = new ProjectAccessInput();
+    PermissionRuleInfo ruleInfo = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    PermissionInfo email = new PermissionInfo(null, null);
+    email.rules = ImmutableMap.of(SystemGroupBackend.REGISTERED_USERS.get(), ruleInfo);
+    AccessSectionInfo accessSectionInfo = new AccessSectionInfo();
+    accessSectionInfo.permissions = ImmutableMap.of(permissionName, email);
+    accessInput.add = ImmutableMap.of(accessSection, accessSectionInfo);
+
+    return accessInput;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
index 3b5a3a4..45f59e9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
@@ -38,7 +38,7 @@
           .that(Url.decode(info.id))
           .isEqualTo(info.name);
     }
-    return assertThat(Iterables.transform(actual, p -> new Project.NameKey(p.name)));
+    return assertThat(Iterables.transform(actual, p -> Project.nameKey(p.name)));
   }
 
   public static void assertProjectInfo(Project project, ProjectInfo info) {
@@ -47,7 +47,7 @@
       assertThat(info.name).isEqualTo(project.getName());
     }
     assertThat(Url.decode(info.id)).isEqualTo(project.getName());
-    Project.NameKey parentName = project.getParent(new Project.NameKey("All-Projects"));
+    Project.NameKey parentName = project.getParent(Project.nameKey("All-Projects"));
     if (parentName != null) {
       assertThat(info.parent).isEqualTo(parentName.get());
     } else {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index c78b47b..bf2a534 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -19,9 +19,11 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
 import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -29,6 +31,8 @@
 import org.junit.Test;
 
 public class ProjectLevelConfigIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   @Before
   public void setUp() throws Exception {
     fetch(testRepo, RefNames.REFS_CONFIG + ":refs/heads/config");
@@ -43,12 +47,7 @@
     cfg.setString("s2", "ss", "k2", "v2");
     PushOneCommit push =
         pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "Create Project Level Config",
-            configName,
-            cfg.toText());
+            admin.newIdent(), testRepo, "Create Project Level Config", configName, cfg.toText());
     push.to(RefNames.REFS_CONFIG);
 
     ProjectState state = projectCache.get(project);
@@ -73,8 +72,7 @@
 
     pushFactory
         .create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             "Create Project Level Config",
             configName,
@@ -82,7 +80,7 @@
         .to(RefNames.REFS_CONFIG)
         .assertOkStatus();
 
-    Project.NameKey childProject = createProject("child", project);
+    Project.NameKey childProject = projectOperations.newProject().parent(project).create();
     TestRepository<?> childTestRepo = cloneProject(childProject);
     fetch(childTestRepo, RefNames.REFS_CONFIG + ":refs/heads/config");
     childTestRepo.reset("refs/heads/config");
@@ -93,8 +91,7 @@
 
     pushFactory
         .create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             childTestRepo,
             "Create Project Level Config",
             configName,
@@ -128,8 +125,7 @@
 
     pushFactory
         .create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             testRepo,
             "Create Project Level Config",
             configName,
@@ -137,7 +133,7 @@
         .to(RefNames.REFS_CONFIG)
         .assertOkStatus();
 
-    Project.NameKey childProject = createProject("child", project);
+    Project.NameKey childProject = projectOperations.newProject().parent(project).create();
     TestRepository<?> childTestRepo = cloneProject(childProject);
     fetch(childTestRepo, RefNames.REFS_CONFIG + ":refs/heads/config");
     childTestRepo.reset("refs/heads/config");
@@ -154,8 +150,7 @@
 
     pushFactory
         .create(
-            db,
-            admin.getIdent(),
+            admin.newIdent(),
             childTestRepo,
             "Create Project Level Config",
             configName,
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
index b3e3d2f..a93fc0f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.api.projects.RefInfo;
@@ -38,10 +39,12 @@
   public static void assertRefInfo(RefInfo expected, RefInfo actual) {
     assertThat(actual.ref).isEqualTo(expected.ref);
     if (expected.revision != null) {
-      assertThat(actual.revision).named("revision of " + actual.ref).isEqualTo(expected.revision);
+      assertWithMessage("revision of " + actual.ref)
+          .that(actual.revision)
+          .isEqualTo(expected.revision);
     }
-    assertThat(toBoolean(actual.canDelete))
-        .named("can delete " + actual.ref)
+    assertWithMessage("can delete " + actual.ref)
+        .that(toBoolean(actual.canDelete))
         .isEqualTo(toBoolean(expected.canDelete));
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 714751d..3d1a148 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -15,7 +15,10 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.collect.FluentIterable;
@@ -23,6 +26,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagApi;
@@ -33,6 +38,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
 import java.sql.Timestamp;
 import java.util.List;
 import org.junit.Test;
@@ -56,31 +62,44 @@
           + "=XFeC\n"
           + "-----END PGP SIGNATURE-----";
 
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void listTagsOfNonExistingProject() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("does-not-exist").tags().get();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.projects().name("does-not-exist").tags().get());
   }
 
   @Test
   public void getTagOfNonExistingProject() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("does-not-exist").tag("tag").get();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name("does-not-exist").tag("tag").get());
   }
 
   @Test
   public void listTagsOfNonVisibleProject() throws Exception {
-    blockRead("refs/*");
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).tags().get();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.projects().name(project.get()).tags().get());
   }
 
   @Test
   public void getTagOfNonVisibleProject() throws Exception {
-    blockRead("refs/*");
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).tag("tag").get();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).tag("tag").get());
   }
 
   @Test
@@ -126,7 +145,7 @@
   public void listTagsOfNonVisibleBranch() throws Exception {
     grantTagPermissions();
 
-    PushOneCommit push1 = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push1 = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
     r1.assertOkStatus();
     TagInput tag1 = new TagInput();
@@ -137,7 +156,7 @@
     assertThat(result.revision).isEqualTo(tag1.revision);
 
     pushTo("refs/heads/hidden");
-    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push2 = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r2 = push2.to("refs/heads/hidden");
     r2.assertOkStatus();
 
@@ -155,7 +174,11 @@
     assertThat(tags.get(1).ref).isEqualTo(R_TAGS + tag2.ref);
     assertThat(tags.get(1).revision).isEqualTo(tag2.revision);
 
-    blockRead("refs/heads/hidden");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/hidden").group(REGISTERED_USERS))
+        .update();
     tags = getTags().get();
     assertThat(tags).hasSize(1);
     assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
@@ -166,7 +189,7 @@
   public void lightweightTag() throws Exception {
     grantTagPermissions();
 
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/heads/master");
     r.assertOkStatus();
 
@@ -187,7 +210,7 @@
     assertThat(result.canDelete).isTrue();
     assertThat(result.created).isEqualTo(timestamp(r));
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     result = tag(input.ref).get();
     assertThat(result.canDelete).isNull();
 
@@ -198,7 +221,7 @@
   public void annotatedTag() throws Exception {
     grantTagPermissions();
 
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/heads/master");
     r.assertOkStatus();
 
@@ -211,8 +234,8 @@
     assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
     assertThat(result.object).isEqualTo(input.revision);
     assertThat(result.message).isEqualTo(input.message);
-    assertThat(result.tagger.name).isEqualTo(admin.fullName);
-    assertThat(result.tagger.email).isEqualTo(admin.email);
+    assertThat(result.tagger.name).isEqualTo(admin.fullName());
+    assertThat(result.tagger.email).isEqualTo(admin.email());
     assertThat(result.created).isEqualTo(result.tagger.date);
 
     eventRecorder.assertRefUpdatedEvents(project.get(), result.ref, null, result.revision);
@@ -226,8 +249,8 @@
     assertThat(result2.ref).isEqualTo(input2.ref);
     assertThat(result2.object).isEqualTo(input2.revision);
     assertThat(result2.message).isEqualTo(input2.message);
-    assertThat(result2.tagger.name).isEqualTo(admin.fullName);
-    assertThat(result2.tagger.email).isEqualTo(admin.email);
+    assertThat(result2.tagger.name).isEqualTo(admin.fullName());
+    assertThat(result2.tagger.email).isEqualTo(admin.email());
     assertThat(result2.created).isEqualTo(result2.tagger.date);
 
     eventRecorder.assertRefUpdatedEvents(project.get(), result2.ref, null, result2.revision);
@@ -243,30 +266,38 @@
     assertThat(result.ref).isEqualTo(R_TAGS + "test");
 
     input.ref = "refs/tags/test";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("tag \"" + R_TAGS + "test\" already exists");
-    tag(input.ref).create(input);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("tag \"" + R_TAGS + "test\" already exists");
   }
 
   @Test
   public void createTagNotAllowed() throws Exception {
-    block(R_TAGS + "*", Permission.CREATE, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.CREATE).ref(R_TAGS + "*").group(REGISTERED_USERS))
+        .update();
     TagInput input = new TagInput();
     input.ref = "test";
-    exception.expect(AuthException.class);
-    exception.expectMessage("create not permitted");
-    tag(input.ref).create(input);
+    AuthException thrown = assertThrows(AuthException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("not permitted: create");
   }
 
   @Test
   public void createAnnotatedTagNotAllowed() throws Exception {
-    block(R_TAGS + "*", Permission.CREATE_TAG, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.CREATE_TAG).ref(R_TAGS + "*").group(REGISTERED_USERS))
+        .update();
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = "annotation";
-    exception.expect(AuthException.class);
-    exception.expectMessage("Cannot create annotated tag \"" + R_TAGS + "test\"");
-    tag(input.ref).create(input);
+    AuthException thrown = assertThrows(AuthException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot create annotated tag \"" + R_TAGS + "test\"");
   }
 
   @Test
@@ -274,9 +305,9 @@
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = SIGNED_ANNOTATION;
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("Cannot create signed tag \"" + R_TAGS + "test\"");
-    tag(input.ref).create(input);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("Cannot create signed tag \"" + R_TAGS + "test\"");
   }
 
   @Test
@@ -284,9 +315,9 @@
     TagInput input = new TagInput();
     input.ref = "test";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("ref must match URL");
-    tag("TEST").create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag("TEST").create(input));
+    assertThat(thrown).hasMessageThat().contains("ref must match URL");
   }
 
   @Test
@@ -296,9 +327,9 @@
     TagInput input = new TagInput();
     input.ref = "refs/heads/test";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid tag name \"" + input.ref + "\"");
-    tag(input.ref).create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid tag name \"" + input.ref + "\"");
   }
 
   @Test
@@ -308,9 +339,9 @@
     TagInput input = new TagInput();
     input.ref = "//";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid tag name \"refs/tags/\"");
-    tag(input.ref).create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid tag name \"refs/tags/\"");
   }
 
   @Test
@@ -321,9 +352,9 @@
     input.ref = "test";
     input.revision = "abcdefg";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Invalid base revision");
-    tag(input.ref).create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("Invalid base revision");
   }
 
   private void assertTagList(FluentIterable<String> expected, List<TagInfo> actual)
@@ -363,18 +394,17 @@
   }
 
   private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
+    assertThrows(BadRequestException.class, () -> req.get());
   }
 
   private void grantTagPermissions() throws Exception {
-    grant(project, R_TAGS + "*", Permission.CREATE);
-    grant(project, R_TAGS + "", Permission.DELETE);
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
-    grant(project, R_TAGS + "*", Permission.CREATE_SIGNED_TAG);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.DELETE).ref(R_TAGS + "").group(adminGroupUuid()))
+        .add(allow(Permission.CREATE_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.CREATE_SIGNED_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .update();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/BUILD b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
new file mode 100644
index 0000000..cc72e8a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
@@ -0,0 +1,10 @@
+java_library(
+    name = "util",
+    testonly = True,
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//lib/commons:lang",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
new file mode 100644
index 0000000..f98fb45
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
@@ -0,0 +1,104 @@
+// 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.util;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.apache.http.HttpStatus.SC_FORBIDDEN;
+import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
+import java.util.List;
+import org.junit.Ignore;
+
+/** Helper to execute REST API calls using the HTTP client. */
+@Ignore
+public class RestApiCallHelper {
+  /** @see #execute(RestSession, List, BeforeRestCall, String...) */
+  public static void execute(RestSession restSession, List<RestCall> restCalls, String... args)
+      throws Exception {
+    execute(restSession, restCalls, () -> {}, args);
+  }
+
+  /** @see #execute(RestSession, List, BeforeRestCall, String...) */
+  public static void execute(
+      RestSession restSession,
+      List<RestCall> restCalls,
+      BeforeRestCall beforeRestCall,
+      String... args)
+      throws Exception {
+    for (RestCall restCall : restCalls) {
+      beforeRestCall.run();
+      execute(restSession, restCall, args);
+    }
+  }
+
+  /**
+   * This method sends a request to a given REST endpoint and verifies that an implementation is
+   * found (no '404 Not Found' response) and that the request doesn't fail (no '500 Internal Server
+   * Error' response). It doesn't verify that the REST endpoint works correctly. This is okay since
+   * the purpose of the test is only to verify that the REST endpoint implementations are correctly
+   * bound.
+   */
+  public static void execute(RestSession restSession, RestCall restCall, String... args)
+      throws Exception {
+    String method = restCall.httpMethod().name();
+    String uri = restCall.uri(args);
+
+    RestResponse response;
+    switch (restCall.httpMethod()) {
+      case GET:
+        response = restSession.get(uri);
+        break;
+      case PUT:
+        response = restSession.put(uri);
+        break;
+      case POST:
+        response = restSession.post(uri);
+        break;
+      case DELETE:
+        response = restSession.delete(uri);
+        break;
+      default:
+        assertWithMessage(String.format("unsupported method: %s", restCall.httpMethod().name()))
+            .fail();
+        throw new IllegalStateException();
+    }
+
+    int status = response.getStatusCode();
+    String body = response.hasContent() ? response.getEntityContent() : "";
+
+    String msg = String.format("%s %s returned %d: %s", method, uri, status, body);
+    if (restCall.expectedResponseCode().isPresent()) {
+      assertWithMessage(msg).that(status).isEqualTo(restCall.expectedResponseCode().get());
+      if (restCall.expectedMessage().isPresent()) {
+        assertWithMessage(msg).that(body).contains(restCall.expectedMessage().get());
+      }
+    } else {
+      assertWithMessage(msg)
+          .that(status)
+          .isNotIn(ImmutableList.of(SC_FORBIDDEN, SC_NOT_FOUND, SC_METHOD_NOT_ALLOWED));
+      assertWithMessage(msg).that(status).isLessThan(SC_INTERNAL_SERVER_ERROR);
+    }
+  }
+
+  @FunctionalInterface
+  public interface BeforeRestCall {
+    void run() throws Exception;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java b/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
new file mode 100644
index 0000000..7b0002c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
@@ -0,0 +1,88 @@
+// 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.util;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+import org.apache.commons.lang.StringUtils;
+import org.junit.Ignore;
+
+/** Data container for test REST requests. */
+@Ignore
+@AutoValue
+public abstract class RestCall {
+  public enum Method {
+    GET,
+    PUT,
+    POST,
+    DELETE
+  }
+
+  public static RestCall get(String uriFormat) {
+    return builder(Method.GET, uriFormat).build();
+  }
+
+  public static RestCall put(String uriFormat) {
+    return builder(Method.PUT, uriFormat).build();
+  }
+
+  public static RestCall post(String uriFormat) {
+    return builder(Method.POST, uriFormat).build();
+  }
+
+  public static RestCall delete(String uriFormat) {
+    return builder(Method.DELETE, uriFormat).build();
+  }
+
+  public static Builder builder(Method httpMethod, String uriFormat) {
+    return new AutoValue_RestCall.Builder().httpMethod(httpMethod).uriFormat(uriFormat);
+  }
+
+  public abstract Method httpMethod();
+
+  public abstract String uriFormat();
+
+  public abstract Optional<Integer> expectedResponseCode();
+
+  public abstract Optional<String> expectedMessage();
+
+  public String uri(String... args) {
+    String uriFormat = uriFormat();
+    int expectedArgNum = StringUtils.countMatches(uriFormat, "%s");
+    checkState(
+        args.length == expectedArgNum,
+        "uriFormat %s needs %s arguments, got only %s: %s",
+        uriFormat,
+        expectedArgNum,
+        args.length,
+        args);
+    return String.format(uriFormat, (Object[]) args);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder httpMethod(Method httpMethod);
+
+    public abstract Builder uriFormat(String uriFormat);
+
+    public abstract Builder expectedResponseCode(int expectedResponseCode);
+
+    public abstract Builder expectedMessage(String expectedMessage);
+
+    public abstract RestCall build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
new file mode 100644
index 0000000..7627e65
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -0,0 +1,345 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.Result;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class AccountResolverIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setEnum("accounts", null, "visibility", AccountVisibility.SAME_GROUP);
+    return cfg;
+  }
+
+  @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject private AccountOperations accountOperations;
+  @Inject private AccountResolver accountResolver;
+  @Inject private Provider<CurrentUser> self;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private Sequences sequences;
+
+  @Test
+  public void bySelf() throws Exception {
+    assertThat(resolve("Self")).isEmpty();
+    accountOperations.newAccount().fullname("self").create();
+
+    Result result = resolveAsResult("self");
+    assertThat(result.asIdSet()).containsExactly(admin.id());
+    assertThat(result.isSelf()).isTrue();
+    assertThat(result.asUniqueUser()).isSameInstanceAs(self.get());
+
+    result = resolveAsResult("me");
+    assertThat(result.asIdSet()).containsExactly(admin.id());
+    assertThat(result.isSelf()).isTrue();
+    assertThat(result.asUniqueUser()).isSameInstanceAs(self.get());
+
+    requestScopeOperations.setApiUserAnonymous();
+    checkBySelfFails();
+
+    requestScopeOperations.setApiUserInternal();
+    checkBySelfFails();
+  }
+
+  private void checkBySelfFails() throws Exception {
+    for (String input : ImmutableList.of("self", "me")) {
+      Result result = resolveAsResult(input);
+      assertThat(result.asIdSet()).isEmpty();
+      assertThat(result.isSelf()).isTrue();
+      UnresolvableAccountException thrown =
+          assertThrows(UnresolvableAccountException.class, () -> result.asUnique());
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(String.format("Resolving account '%s' requires login", input));
+      assertThat(thrown.isSelf()).isTrue();
+    }
+  }
+
+  @Test
+  public void bySelfInactive() throws Exception {
+    gApi.accounts().id(user.id().get()).setActive(false);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(gApi.accounts().id("self").getActive()).isFalse();
+
+    Result result = resolveAsResult("self");
+    assertThat(result.asIdSet()).containsExactly(user.id());
+    assertThat(result.isSelf()).isTrue();
+    assertThat(result.asUniqueUser()).isSameInstanceAs(self.get());
+  }
+
+  @Test
+  public void byExactAccountId() throws Exception {
+    Account.Id existingId = accountOperations.newAccount().create();
+    Account.Id idWithExistingIdAsFullname =
+        accountOperations.newAccount().fullname(existingId.toString()).create();
+
+    Account.Id nonexistentId = Account.id(sequences.nextAccountId());
+    accountOperations.newAccount().fullname(nonexistentId.toString()).create();
+
+    assertThat(resolve(existingId)).containsExactly(existingId);
+    assertThat(resolve(nonexistentId)).isEmpty();
+
+    assertThat(resolveByNameOrEmail(existingId)).containsExactly(idWithExistingIdAsFullname);
+  }
+
+  @Test
+  public void byParenthesizedAccountId() throws Exception {
+    Account.Id existingId = accountOperations.newAccount().fullname("Test User").create();
+    accountOperations.newAccount().fullname(existingId.toString()).create();
+
+    Account.Id nonexistentId = Account.id(sequences.nextAccountId());
+    accountOperations.newAccount().fullname("Any Name (" + nonexistentId + ")").create();
+    accountOperations.newAccount().fullname(nonexistentId.toString()).create();
+
+    String existingInput = "Any Name (" + existingId + ")";
+    assertThat(resolve(existingInput)).containsExactly(existingId);
+    assertThat(resolve("Any Name (" + nonexistentId + ")")).isEmpty();
+
+    assertThat(resolveByNameOrEmail(existingInput)).isEmpty();
+  }
+
+  @Test
+  public void byUsername() throws Exception {
+    String existingUsername = "myusername";
+    Account.Id idWithUsername = accountOperations.newAccount().username(existingUsername).create();
+    Account.Id idWithExistingUsernameAsFullname =
+        accountOperations.newAccount().fullname(existingUsername).create();
+
+    String nonexistentUsername = "anotherusername";
+    Account.Id idWithFullname = accountOperations.newAccount().fullname("anotherusername").create();
+
+    assertThat(resolve(existingUsername)).containsExactly(idWithUsername);
+
+    // Doesn't short-circuit just because the input looks like a valid username.
+    assertThat(ExternalId.isValidUsername(nonexistentUsername)).isTrue();
+    assertThat(resolve(nonexistentUsername)).containsExactly(idWithFullname);
+
+    assertThat(resolveByNameOrEmail(existingUsername))
+        .containsExactly(idWithExistingUsernameAsFullname);
+  }
+
+  @Test
+  public void byNameAndEmail() throws Exception {
+    String email = name("user@example.com");
+    Account.Id idWithEmail = accountOperations.newAccount().preferredEmail(email).create();
+    accountOperations.newAccount().fullname(email).create();
+
+    String input = "First Last <" + email + ">";
+    assertThat(resolve(input)).containsExactly(idWithEmail);
+    assertThat(resolveByNameOrEmail(input)).containsExactly(idWithEmail);
+  }
+
+  @Test
+  public void byNameAndEmailPrefersAccountsWithMatchingFullName() throws Exception {
+    String email = name("user@example.com");
+    Account.Id id1 = accountOperations.newAccount().fullname("Aaa Bbb").create();
+    setPreferredEmailBypassingUniquenessCheck(id1, email);
+    Account.Id id2 = accountOperations.newAccount().fullname("Ccc Ddd").create();
+    setPreferredEmailBypassingUniquenessCheck(id2, email);
+
+    String input = "First Last <" + email + ">";
+    assertThat(resolve(input)).containsExactly(id1, id2);
+    assertThat(resolveByNameOrEmail(input)).containsExactly(id1, id2);
+
+    Account.Id id3 = accountOperations.newAccount().fullname("First Last").create();
+    setPreferredEmailBypassingUniquenessCheck(id3, email);
+    assertThat(resolve(input)).containsExactly(id3);
+    assertThat(resolveByNameOrEmail(input)).containsExactly(id3);
+
+    Account.Id id4 = accountOperations.newAccount().fullname("First Last").create();
+    setPreferredEmailBypassingUniquenessCheck(id4, email);
+    assertThat(resolve(input)).containsExactly(id3, id4);
+    assertThat(resolveByNameOrEmail(input)).containsExactly(id3, id4);
+  }
+
+  @Test
+  public void byEmail() throws Exception {
+    String email = name("user@example.com");
+    Account.Id idWithEmail = accountOperations.newAccount().preferredEmail(email).create();
+    accountOperations.newAccount().fullname(email).create();
+
+    assertThat(resolve(email)).containsExactly(idWithEmail);
+    assertThat(resolveByNameOrEmail(email)).containsExactly(idWithEmail);
+  }
+
+  // Can't test for ByRealm because DefaultRealm with the default (OPENID) auth type doesn't support
+  // email expansion, so anything that would return a non-null value from DefaultRealm#lookup would
+  // just be an email address, handled by other tests. This could be avoided if we inject some sort
+  // of custom test realm instance, but the ugliness is not worth it for this small bit of test
+  // coverage.
+
+  @Test
+  public void byFullName() throws Exception {
+    Account.Id id1 = accountOperations.newAccount().fullname("Somebodys Name").create();
+    accountOperations.newAccount().fullname("A totally different name").create();
+    String input = "Somebodys name";
+    assertThat(resolve(input)).containsExactly(id1);
+    assertThat(resolveByNameOrEmail(input)).containsExactly(id1);
+  }
+
+  @Test
+  public void byDefaultSearch() throws Exception {
+    Account.Id id1 = accountOperations.newAccount().fullname("John Doe").create();
+    Account.Id id2 = accountOperations.newAccount().fullname("Jane Doe").create();
+    assertThat(resolve("doe")).containsExactly(id1, id2);
+    assertThat(resolveByNameOrEmail("doe")).containsExactly(id1, id2);
+  }
+
+  @Test
+  public void onlyExactIdReturnsInactiveAccounts() throws Exception {
+    TestAccount account =
+        accountOperations
+            .account(
+                accountOperations
+                    .newAccount()
+                    .fullname("Inactiveuser Name")
+                    .preferredEmail("inactiveuser@example.com")
+                    .username("inactiveusername")
+                    .create())
+            .get();
+    Account.Id id = account.accountId();
+    String nameEmail = account.fullname().get() + " <" + account.preferredEmail().get() + ">";
+    ImmutableList<String> inputs =
+        ImmutableList.of(
+            account.fullname().get() + " (" + account.accountId() + ")",
+            account.fullname().get(),
+            account.preferredEmail().get(),
+            nameEmail,
+            Splitter.on(' ').splitToList(account.fullname().get()).get(0));
+
+    assertThat(resolve(account.accountId())).containsExactly(id);
+    for (String input : inputs) {
+      assertWithMessage("results for %s (active)", input).that(resolve(input)).containsExactly(id);
+    }
+
+    gApi.accounts().id(id.get()).setActive(false);
+    assertThat(resolve(account.accountId())).containsExactly(id);
+    for (String input : inputs) {
+      Result result = accountResolver.resolve(input);
+      assertWithMessage("results for %s (inactive)", input).that(result.asIdSet()).isEmpty();
+      UnresolvableAccountException thrown =
+          assertThrows(UnresolvableAccountException.class, () -> result.asUnique());
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "Account '"
+                  + input
+                  + "' only matches inactive accounts. To use an inactive account, retry"
+                  + " with one of the following exact account IDs:\n"
+                  + id
+                  + ": "
+                  + nameEmail);
+      assertWithMessage("results by name or email for %s (inactive)", input)
+          .that(resolveByNameOrEmail(input))
+          .isEmpty();
+    }
+  }
+
+  @Test
+  public void filterVisibility() throws Exception {
+    Account.Id id1 =
+        accountOperations
+            .newAccount()
+            .fullname("John Doe")
+            .preferredEmail("johndoe@example.com")
+            .create();
+    Account.Id id2 =
+        accountOperations
+            .newAccount()
+            .fullname("Jane Doe")
+            .preferredEmail("janedoe@example.com")
+            .create();
+
+    // Admin can see all accounts. Use a variety of searches, including with/without
+    // callerMayAssumeCandidatesAreVisible.
+    assertThat(resolve(id1)).containsExactly(id1);
+    assertThat(resolve("John Doe")).containsExactly(id1);
+    assertThat(resolve("johndoe@example.com")).containsExactly(id1);
+    assertThat(resolve(id2)).containsExactly(id2);
+    assertThat(resolve("Jane Doe")).containsExactly(id2);
+    assertThat(resolve("janedoe@example.com")).containsExactly(id2);
+    assertThat(resolve("doe")).containsExactly(id1, id2);
+
+    // id2 can't see id1, and vice versa.
+    requestScopeOperations.setApiUser(id1);
+    assertThat(resolve(id1)).containsExactly(id1);
+    assertThat(resolve("John Doe")).containsExactly(id1);
+    assertThat(resolve("johndoe@example.com")).containsExactly(id1);
+    assertThat(resolve(id2)).isEmpty();
+    assertThat(resolve("Jane Doe")).isEmpty();
+    assertThat(resolve("janedoe@example.com")).isEmpty();
+    assertThat(resolve("doe")).containsExactly(id1);
+
+    requestScopeOperations.setApiUser(id2);
+    assertThat(resolve(id1)).isEmpty();
+    assertThat(resolve("John Doe")).isEmpty();
+    assertThat(resolve("johndoe@example.com")).isEmpty();
+    assertThat(resolve(id2)).containsExactly(id2);
+    assertThat(resolve("Jane Doe")).containsExactly(id2);
+    assertThat(resolve("janedoe@example.com")).containsExactly(id2);
+    assertThat(resolve("doe")).containsExactly(id2);
+  }
+
+  private ImmutableSet<Account.Id> resolve(Object input) throws Exception {
+    return resolveAsResult(input).asIdSet();
+  }
+
+  private Result resolveAsResult(Object input) throws Exception {
+    return accountResolver.resolve(input.toString());
+  }
+
+  @SuppressWarnings("deprecation")
+  private ImmutableSet<Account.Id> resolveByNameOrEmail(Object input) throws Exception {
+    return accountResolver.resolveByNameOrEmail(input.toString()).asIdSet();
+  }
+
+  private void setPreferredEmailBypassingUniquenessCheck(Account.Id id, String email)
+      throws Exception {
+    Optional<AccountState> result =
+        accountsUpdateProvider
+            .get()
+            .update("Force set preferred email", id, (s, u) -> u.setPreferredEmail(email));
+    assertThat(result.map(a -> a.getAccount().preferredEmail())).hasValue(email);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/account/BUILD b/javatests/com/google/gerrit/acceptance/server/account/BUILD
new file mode 100644
index 0000000..48fac99
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_account",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 6c79618..4d1634d 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -4,4 +4,5 @@
     srcs = glob(["*IT.java"]),
     group = "server_change",
     labels = ["server"],
+    deps = ["//java/com/google/gerrit/server/util/time"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 5555185..6842926 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -16,21 +16,20 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 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.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -47,7 +46,6 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -58,45 +56,38 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Optional;
+import java.util.function.Function;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class CommentsIT extends AbstractDaemonTest {
-
-  @Inject private Provider<ChangesCollection> changes;
-
-  @Inject private Provider<PostReview> postReview;
-
-  @Inject private FakeEmailSender email;
-
   @Inject private ChangeNoteUtil noteUtil;
+  @Inject private FakeEmailSender email;
+  @Inject private Provider<ChangesCollection> changes;
+  @Inject private Provider<PostReview> postReview;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private final Integer[] lines = {0, 1};
 
   @Before
   public void setUp() {
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
   }
 
   @Test
@@ -104,8 +95,9 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    exception.expect(ResourceNotFoundException.class);
-    getPublishedComment(changeId, revId, "non-existing");
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> getPublishedComment(changeId, revId, "non-existing"));
   }
 
   @Test
@@ -141,8 +133,7 @@
       addDraft(changeId, revId, c4);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       assertThat(result).hasSize(1);
-      assertThat(Lists.transform(result.get(path), infoToDraft(path)))
-          .containsExactly(c1, c2, c3, c4);
+      assertThat(result.get(path).stream().map(infoToDraft(path))).containsExactly(c1, c2, c3, c4);
     }
   }
 
@@ -152,7 +143,7 @@
       String file = "file";
       String contents = "contents " + line;
       PushOneCommit push =
-          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+          pushFactory.create(admin.newIdent(), testRepo, "first subject", file, contents);
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
@@ -176,7 +167,7 @@
       String file = "file";
       String contents = "contents " + line;
       PushOneCommit push =
-          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+          pushFactory.create(admin.newIdent(), testRepo, "first subject", file, contents);
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
@@ -208,7 +199,7 @@
       String file = "file";
       String contents = "contents " + line;
       PushOneCommit push =
-          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+          pushFactory.create(admin.newIdent(), testRepo, "first subject", file, contents);
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
@@ -243,8 +234,7 @@
       revision(r).review(input);
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
-      assertThat(Lists.transform(result.get(file), infoToInput(file)))
-          .containsExactly(c1, c2, c3, c4);
+      assertThat(result.get(file).stream().map(infoToInput(file))).containsExactly(c1, c2, c3, c4);
     }
 
     // for the commit message comments on the auto-merge are not possible
@@ -262,7 +252,7 @@
       revision(r).review(input);
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
-      assertThat(Lists.transform(result.get(file), infoToInput(file))).containsExactly(c1, c2, c3);
+      assertThat(result.get(file).stream().map(infoToInput(file))).containsExactly(c1, c2, c3);
     }
   }
 
@@ -273,16 +263,47 @@
     CommentInput c = newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
     input.comments = new HashMap<>();
     input.comments.put(Patch.COMMIT_MSG, ImmutableList.of(c));
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot comment on " + Patch.COMMIT_MSG + " on auto-merge");
-    revision(r).review(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> revision(r).review(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("cannot comment on " + Patch.COMMIT_MSG + " on auto-merge");
+  }
+
+  @Test
+  public void postCommentsReplacingDrafts() throws Exception {
+    String file = "file";
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "first subject", file, "contents");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+
+    DraftInput draft = newDraft(file, Side.REVISION, 0, "comment");
+    addDraft(changeId, revId, draft);
+    Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
+    CommentInfo draftInfo = Iterables.getOnlyElement(drafts.get(draft.path));
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.KEEP;
+    reviewInput.message = "foo";
+    CommentInput comment = newComment(file, Side.REVISION, 0, "comment", false);
+    // Replace the existing draft.
+    comment.id = draftInfo.id;
+    reviewInput.comments = new HashMap<>();
+    reviewInput.comments.put(comment.path, ImmutableList.of(comment));
+    revision(r).review(reviewInput);
+
+    // DraftHandling.KEEP is ignored on publishing a comment.
+    drafts = getDraftComments(changeId, revId);
+    assertThat(drafts).isEmpty();
   }
 
   @Test
   public void listComments() throws Exception {
     String file = "file";
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, "contents");
+        pushFactory.create(admin.newIdent(), testRepo, "first subject", file, "contents");
     PushOneCommit.Result r = push.to("refs/for/master");
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
@@ -301,7 +322,7 @@
     Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
     assertThat(result).isNotEmpty();
     List<CommentInfo> actualComments = result.get(file);
-    assertThat(Lists.transform(actualComments, infoToInput(file)))
+    assertThat(actualComments.stream().map(infoToInput(file)))
         .containsExactlyElementsIn(expectedComments);
   }
 
@@ -348,7 +369,7 @@
     Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
     assertThat(result).isNotEmpty();
     List<CommentInfo> actualComments = result.get(file);
-    assertThat(Lists.transform(actualComments, infoToDraft(file)))
+    assertThat(actualComments.stream().map(infoToDraft(file)))
         .containsExactlyElementsIn(expectedDrafts);
   }
 
@@ -391,7 +412,7 @@
       String file = "file";
       String contents = "contents " + line;
       PushOneCommit push =
-          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+          pushFactory.create(admin.newIdent(), testRepo, "first subject", file, contents);
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
@@ -434,7 +455,7 @@
 
     PushOneCommit.Result r2 =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "content")
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "content")
             .to("refs/for/master");
     changeId = r2.getChangeId();
     revId = r2.getCommit().getName();
@@ -449,11 +470,10 @@
 
     PushOneCommit.Result r2 =
         pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
             .to("refs/for/master");
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
@@ -463,13 +483,13 @@
         r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
 
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     Map<String, List<CommentInfo>> actual = gApi.changes().id(r1.getChangeId()).drafts();
     assertThat(actual.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> comments = actual.get(FILE_NAME);
@@ -496,8 +516,7 @@
 
     PushOneCommit.Result r2 =
         pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
             .to("refs/for/master");
 
     addComment(r1, "nit: trailing whitespace");
@@ -510,14 +529,14 @@
     assertThat(comments).hasSize(2);
 
     CommentInfo c1 = comments.get(0);
-    assertThat(c1.author._accountId).isEqualTo(user.getId().get());
+    assertThat(c1.author._accountId).isEqualTo(user.id().get());
     assertThat(c1.patchSet).isEqualTo(1);
     assertThat(c1.message).isEqualTo("nit: trailing whitespace");
     assertThat(c1.side).isNull();
     assertThat(c1.line).isEqualTo(1);
 
     CommentInfo c2 = comments.get(1);
-    assertThat(c2.author._accountId).isEqualTo(user.getId().get());
+    assertThat(c2.author._accountId).isEqualTo(user.id().get());
     assertThat(c2.patchSet).isEqualTo(2);
     assertThat(c2.message).isEqualTo("typo: content");
     assertThat(c2.side).isNull();
@@ -538,32 +557,41 @@
 
   @Test
   public void publishCommentsAllRevisions() throws Exception {
-    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n", changeId)
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "old boring content\n")
+            .to("refs/for/master");
 
     PushOneCommit.Result r2 =
         pushFactory
             .create(
-                db,
-                admin.getIdent(),
+                admin.newIdent(),
                 testRepo,
                 SUBJECT,
                 FILE_NAME,
-                "new\ncntent\n",
+                "new interesting\ncntent\n",
                 r1.getChangeId())
             .to("refs/for/master");
 
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
+        newDraft(FILE_NAME, Side.REVISION, createLineRange(1, 4, 10), "Is it that bad?"));
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 2, "what happened to this?"));
+        newDraft(FILE_NAME, Side.PARENT, createLineRange(1, 0, 7), "what happened to this?"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "join lines"));
+        newDraft(FILE_NAME, Side.REVISION, createLineRange(1, 4, 15), "better now"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
@@ -584,11 +612,11 @@
         other.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     // Drafts by other users aren't returned.
     addDraft(
         r2.getChangeId(), r2.getCommit().getName(), newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
 
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
@@ -604,7 +632,7 @@
     assertThat(ps1List).hasSize(2);
     assertThat(ps1List.get(0).message).isEqualTo("what happened to this?");
     assertThat(ps1List.get(0).side).isEqualTo(Side.PARENT);
-    assertThat(ps1List.get(1).message).isEqualTo("nit: trailing whitespace");
+    assertThat(ps1List.get(1).message).isEqualTo("Is it that bad?");
     assertThat(ps1List.get(1).side).isNull();
 
     assertThat(gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).drafts())
@@ -616,7 +644,7 @@
     assertThat(ps2List).hasSize(4);
     assertThat(ps2List.get(0).message).isEqualTo("comment 1 on base");
     assertThat(ps2List.get(1).message).isEqualTo("comment 2 on base");
-    assertThat(ps2List.get(2).message).isEqualTo("join lines");
+    assertThat(ps2List.get(2).message).isEqualTo("better now");
     assertThat(ps2List.get(3).message).isEqualTo("typo: content");
 
     List<Message> messages = email.getMessages(r2.getChangeId(), "comment");
@@ -632,43 +660,55 @@
                 + "comments\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/1/a.txt \n"
                 + "File a.txt:\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
-                + "/1/a.txt@a2 \n"
-                + "PS1, Line 2: \n"
+                + "/1/a.txt@a1 \n"
+                + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/1/a.txt@1 \n"
-                + "PS1, Line 1: ew\n"
-                + "nit: trailing whitespace\n"
+                + "PS1, Line 1: boring\n"
+                + "Is it that bad?\n"
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt \n"
                 + "File a.txt:\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt@a1 \n"
-                + "PS2, Line 1: \n"
+                + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt@a2 \n"
                 + "PS2, Line 2: \n"
@@ -676,18 +716,22 @@
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt@1 \n"
-                + "PS2, Line 1: ew\n"
-                + "join lines\n"
+                + "PS2, Line 1: interesting\n"
+                + "better now\n"
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt@2 \n"
-                + "PS2, Line 2: nten\n"
+                + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
                 + "\n"
                 + "\n");
@@ -722,7 +766,7 @@
   }
 
   @Test
-  public void queryChangesWithUnresolvedCommentCount() throws Exception {
+  public void queryChangesWithCommentCount() throws Exception {
     // PS1 has three comments in three different threads, PS2 has one comment in one thread.
     PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
     String changeId1 = result.getChangeId();
@@ -751,16 +795,16 @@
     assertThat(comments.get(FILE_NAME)).hasSize(1);
     addComment(result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
 
-    AcceptanceTestRequestScope.Context ctx = disableDb();
-    try {
+    try (AutoCloseable ignored = disableNoteDb()) {
       ChangeInfo changeInfo1 = Iterables.getOnlyElement(query(changeId1));
       ChangeInfo changeInfo2 = Iterables.getOnlyElement(query(changeId2));
       ChangeInfo changeInfo3 = Iterables.getOnlyElement(query(changeId3));
       assertThat(changeInfo1.unresolvedCommentCount).isEqualTo(2);
+      assertThat(changeInfo1.totalCommentCount).isEqualTo(4);
       assertThat(changeInfo2.unresolvedCommentCount).isEqualTo(0);
+      assertThat(changeInfo2.totalCommentCount).isEqualTo(2);
       assertThat(changeInfo3.unresolvedCommentCount).isEqualTo(1);
-    } finally {
-      enableDb(ctx);
+      assertThat(changeInfo3.totalCommentCount).isEqualTo(2);
     }
   }
 
@@ -778,9 +822,10 @@
     String uuid = commentsMap.get(targetComment.path).get(0).id;
     DeleteCommentInput input = new DeleteCommentInput("contains confidential information");
 
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input);
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class,
+        () -> gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input));
   }
 
   @Test
@@ -838,7 +883,7 @@
     CommentInput c9 = newComment("b.txt", "comment 9");
     addComments(changeId, ps2, c9);
 
-    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
+    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(9);
     // PS1 has comments [c1, c2, c3, c4, c5].
     assertThat(getRevisionComments(changeId, ps1)).hasSize(5);
@@ -849,12 +894,9 @@
     // PS4 has comments [c7, c8].
     assertThat(getRevisionComments(changeId, ps4)).hasSize(2);
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     for (int i = 0; i < commentsBeforeDelete.size(); i++) {
-      List<RevCommit> commitsBeforeDelete = new ArrayList<>();
-      if (notesMigration.commitChangeWrites()) {
-        commitsBeforeDelete = getCommits(id);
-      }
+      List<RevCommit> commitsBeforeDelete = getChangeMetaCommitsInReverseOrder(id);
 
       CommentInfo comment = commentsBeforeDelete.get(i);
       String uuid = comment.id;
@@ -867,19 +909,17 @@
           gApi.changes().id(changeId).revision(patchSet).comment(uuid).delete(input);
 
       String expectedMsg =
-          String.format("Comment removed by: %s; Reason: %s", admin.fullName, input.reason);
+          String.format("Comment removed by: %s; Reason: %s", admin.fullName(), input.reason);
       assertThat(updatedComment.message).isEqualTo(expectedMsg);
       oldComment.message = expectedMsg;
       assertThat(updatedComment).isEqualTo(oldComment);
 
       // Check the NoteDb state after the deletion.
-      if (notesMigration.commitChangeWrites()) {
-        assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
-      }
+      assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
 
       comment.message = expectedMsg;
       commentsBeforeDelete.set(i, comment);
-      List<CommentInfo> commentsAfterDelete = getChangeSortedComments(changeId);
+      List<CommentInfo> commentsAfterDelete = getChangeSortedComments(id.get());
       assertThat(commentsAfterDelete).isEqualTo(commentsBeforeDelete);
     }
 
@@ -893,7 +933,7 @@
     addComments(changeId, ps3, c12);
     addComments(changeId, ps4, c13);
 
-    assertThat(getChangeSortedComments(changeId)).hasSize(13);
+    assertThat(getChangeSortedComments(id.get())).hasSize(13);
     assertThat(getRevisionComments(changeId, ps1)).hasSize(6);
     assertThat(getRevisionComments(changeId, ps2)).hasSize(3);
     assertThat(getRevisionComments(changeId, ps3)).hasSize(1);
@@ -914,7 +954,7 @@
     addComments(changeId, ps1, c2);
     addComments(changeId, ps1, c3);
 
-    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
+    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(3);
     Optional<CommentInfo> targetComment =
         commentsBeforeDelete.stream().filter(c -> c.message.equals("comment 2")).findFirst();
@@ -922,12 +962,9 @@
     String uuid = targetComment.get().id;
     CommentInfo oldComment = gApi.changes().id(changeId).revision(ps1).comment(uuid).get();
 
-    List<RevCommit> commitsBeforeDelete = new ArrayList<>();
-    if (notesMigration.commitChangeWrites()) {
-      commitsBeforeDelete = getCommits(id);
-    }
+    List<RevCommit> commitsBeforeDelete = getChangeMetaCommitsInReverseOrder(id);
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     for (int i = 0; i < 3; i++) {
       DeleteCommentInput input = new DeleteCommentInput("delete comment 2, iteration: " + i);
       gApi.changes().id(changeId).revision(ps1).comment(uuid).delete(input);
@@ -936,51 +973,32 @@
     CommentInfo updatedComment = gApi.changes().id(changeId).revision(ps1).comment(uuid).get();
     String expectedMsg =
         String.format(
-            "Comment removed by: %s; Reason: %s", admin.fullName, "delete comment 2, iteration: 2");
+            "Comment removed by: %s; Reason: %s",
+            admin.fullName(), "delete comment 2, iteration: 2");
     assertThat(updatedComment.message).isEqualTo(expectedMsg);
     oldComment.message = expectedMsg;
     assertThat(updatedComment).isEqualTo(oldComment);
 
-    if (notesMigration.commitChangeWrites()) {
-      assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
-    }
-    assertThat(getChangeSortedComments(changeId)).hasSize(3);
+    assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
+    assertThat(getChangeSortedComments(id.get())).hasSize(3);
   }
 
   @Test
   public void jsonCommentHasLegacyFormatFalse() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    assertThat(noteUtil.getChangeNoteJson().getWriteJson()).isTrue();
-
     PushOneCommit.Result result = createChange();
     Change.Id changeId = result.getChange().getId();
     addComment(result.getChangeId(), "comment");
 
     Collection<com.google.gerrit.reviewdb.client.Comment> comments =
-        notesFactory.createChecked(db, project, changeId).getComments().values();
+        notesFactory.createChecked(project, changeId).getComments().values();
     assertThat(comments).hasSize(1);
     com.google.gerrit.reviewdb.client.Comment comment = comments.iterator().next();
     assertThat(comment.message).isEqualTo("comment");
     assertThat(comment.legacyFormat).isFalse();
   }
 
-  private List<CommentInfo> getChangeSortedComments(String changeId) throws Exception {
-    List<CommentInfo> comments = new ArrayList<>();
-    Map<String, List<CommentInfo>> commentsMap = getPublishedComments(changeId);
-    for (Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
-      for (CommentInfo c : e.getValue()) {
-        c.path = e.getKey(); // Set the comment's path field.
-        comments.add(c);
-      }
-    }
-    comments.sort(Comparator.comparing(c -> c.id));
-    return comments;
-  }
-
   private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
-    return getPublishedComments(changeId, revId)
-        .values()
-        .stream()
+    return getPublishedComments(changeId, revId).values().stream()
         .flatMap(List::stream)
         .collect(toList());
   }
@@ -1000,15 +1018,6 @@
     gApi.changes().id(changeId).revision(revision).review(input);
   }
 
-  private List<RevCommit> getCommits(Change.Id changeId) throws IOException {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(repo)) {
-      Ref metaRef = repo.exactRef(RefNames.changeMetaRef(changeId));
-      revWalk.markStart(revWalk.parseCommit(metaRef.getObjectId()));
-      return Lists.newArrayList(revWalk);
-    }
-  }
-
   /**
    * All the commits, which contain the target comment before, should still contain the comment with
    * the updated message. All the other metas of the commits should be exactly the same.
@@ -1019,7 +1028,7 @@
       String targetCommentUuid,
       String expectedMessage)
       throws Exception {
-    List<RevCommit> afterDelete = getCommits(changeId);
+    List<RevCommit> afterDelete = getChangeMetaCommitsInReverseOrder(changeId);
     assertThat(afterDelete).hasSize(beforeDelete.size());
 
     try (Repository repo = repoManager.openRepository(project);
@@ -1121,10 +1130,6 @@
     return gApi.changes().id(changeId).revision(revId).drafts();
   }
 
-  private Map<String, List<CommentInfo>> getPublishedComments(String changeId) throws Exception {
-    return gApi.changes().id(changeId).comments();
-  }
-
   private CommentInfo getDraftComment(String changeId, String revId, String uuid) throws Exception {
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
@@ -1150,30 +1155,51 @@
     return populate(d, path, side, null, line, message, false);
   }
 
+  private DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
+    DraftInput d = new DraftInput();
+    return populate(d, path, side, null, range.startLine, range, message, false);
+  }
+
   private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
     DraftInput d = new DraftInput();
     return populate(d, path, Side.PARENT, Integer.valueOf(parent), line, message, false);
   }
 
   private static <C extends Comment> C populate(
-      C c, String path, Side side, Integer parent, int line, String message, Boolean unresolved) {
+      C c,
+      String path,
+      Side side,
+      Integer parent,
+      int line,
+      Comment.Range range,
+      String message,
+      Boolean unresolved) {
     c.path = path;
     c.side = side;
     c.parent = parent;
     c.line = line != 0 ? line : null;
     c.message = message;
     c.unresolved = unresolved;
-    if (line != 0) {
-      Comment.Range range = new Comment.Range();
-      range.startLine = line;
-      range.startCharacter = 1;
-      range.endLine = line;
-      range.endCharacter = 5;
+    if (range != null) {
       c.range = range;
     }
     return c;
   }
 
+  private static <C extends Comment> C populate(
+      C c, String path, Side side, Integer parent, int line, String message, Boolean unresolved) {
+    return populate(c, path, side, parent, line, null, message, unresolved);
+  }
+
+  private static Comment.Range createLineRange(int line, int startChar, int endChar) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = line;
+    range.startCharacter = startChar;
+    range.endLine = line;
+    range.endCharacter = endChar;
+    return range;
+  }
+
   private static Function<CommentInfo, CommentInput> infoToInput(String path) {
     return infoToInput(path, CommentInput::new);
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index ea44bd7..b1a2ed0 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -14,48 +14,40 @@
 
 package com.google.gerrit.acceptance.server.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIXED;
 import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIX_FAILED;
 import static com.google.gerrit.testing.TestChanges.newPatchSet;
-import static java.util.Collections.singleton;
+import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.FixInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ConsistencyChecker;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.NoteDbMode;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -88,8 +80,6 @@
 
   @Inject private ChangeNoteUtil noteUtil;
 
-  @Inject @AnonymousCowardName private String anonymousCowardName;
-
   @Inject private Sequences sequences;
 
   private RevCommit tip;
@@ -97,11 +87,6 @@
   private ConsistencyChecker checker;
   private TestRepository<InMemoryRepository> serverSideTestRepo;
 
-  private void assumeNoteDbDisabled() {
-    assume().that(notesMigration.readChanges()).isFalse();
-    assume().that(NoteDbMode.get()).isNotEqualTo(NoteDbMode.CHECK);
-  }
-
   @Before
   public void setUp() throws Exception {
     serverSideTestRepo =
@@ -110,7 +95,7 @@
         serverSideTestRepo
             .getRevWalk()
             .parseCommit(serverSideTestRepo.getRepository().exactRef("HEAD").getObjectId());
-    adminId = admin.getId();
+    adminId = admin.id();
     checker = checkerProvider.get();
   }
 
@@ -129,41 +114,9 @@
   public void missingOwner() throws Exception {
     TestAccount owner = accountCreator.create("missing");
     ChangeNotes notes = insertChange(owner);
-    deleteUserBranch(owner.getId());
+    deleteUserBranch(owner.id());
 
-    assertProblems(notes, null, problem("Missing change owner: " + owner.getId()));
-  }
-
-  @Test
-  public void missingRepo() throws Exception {
-    // NoteDb can't have a change without a repo.
-    assumeNoteDbDisabled();
-
-    ChangeNotes notes = insertChange();
-    Project.NameKey name = notes.getProjectName();
-    ((InMemoryRepositoryManager) repoManager).deleteRepository(name);
-    assertThat(checker.check(notes, null).problems())
-        .containsExactly(problem("Destination repository not found: " + name));
-  }
-
-  @Test
-  public void invalidRevision() throws Exception {
-    // NoteDb always parses the revision when inserting a patch set, so we can't
-    // create an invalid patch set.
-    assumeNoteDbDisabled();
-
-    ChangeNotes notes = insertChange();
-    PatchSet ps =
-        newPatchSet(
-            notes.getChange().currentPatchSetId(),
-            "fooooooooooooooooooooooooooooooooooooooo",
-            adminId);
-    db.patchSets().update(singleton(ps));
-
-    assertProblems(
-        notes,
-        null,
-        problem("Invalid revision on patch set 1: fooooooooooooooooooooooooooooooooooooooo"));
+    assertProblems(notes, null, problem("Missing change owner: " + owner.id()));
   }
 
   // No test for ref existing but object missing; InMemoryRepository won't let
@@ -178,7 +131,7 @@
     assertProblems(
         notes,
         null,
-        problem("Ref missing: " + ps.getId().toRefName()),
+        problem("Ref missing: " + ps.id().toRefName()),
         problem("Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
   }
 
@@ -189,7 +142,7 @@
     PatchSet ps = insertMissingPatchSet(notes, rev);
     notes = reload(notes);
 
-    String refName = ps.getId().toRefName();
+    String refName = ps.id().toRefName();
     assertProblems(
         notes,
         new FixInput(),
@@ -200,8 +153,7 @@
   @Test
   public void patchSetRefMissing() throws Exception {
     ChangeNotes notes = insertChange();
-    serverSideTestRepo.update(
-        "refs/other/foo", ObjectId.fromString(psUtil.current(db, notes).getRevision().get()));
+    serverSideTestRepo.update("refs/other/foo", psUtil.current(notes).commitId());
     String refName = notes.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
@@ -211,21 +163,21 @@
   @Test
   public void patchSetRefMissingWithFix() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    serverSideTestRepo.update("refs/other/foo", ObjectId.fromString(rev));
+    ObjectId commitId = psUtil.current(notes).commitId();
+    serverSideTestRepo.update("refs/other/foo", commitId);
     String refName = notes.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
     assertProblems(
         notes, new FixInput(), problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
-    assertThat(serverSideTestRepo.getRepository().exactRef(refName).getObjectId().name())
-        .isEqualTo(rev);
+    assertThat(serverSideTestRepo.getRepository().exactRef(refName).getObjectId())
+        .isEqualTo(commitId);
   }
 
   @Test
   public void patchSetObjectAndRefMissingWithDeletingPatchSet() throws Exception {
     ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
+    PatchSet ps1 = psUtil.current(notes);
 
     String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     PatchSet ps2 = insertMissingPatchSet(notes, rev2);
@@ -236,25 +188,25 @@
     assertProblems(
         notes,
         fix,
-        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Ref missing: " + ps2.id().toRefName()),
         problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"));
 
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
-    assertThat(psUtil.get(db, notes, ps1.getId())).isNotNull();
-    assertThat(psUtil.get(db, notes, ps2.getId())).isNull();
+    assertThat(psUtil.get(notes, ps1.id())).isNotNull();
+    assertThat(psUtil.get(notes, ps2.id())).isNull();
   }
 
   @Test
   public void patchSetMultipleObjectsMissingWithDeletingPatchSets() throws Exception {
     ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
+    PatchSet ps1 = psUtil.current(notes);
 
     String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     PatchSet ps2 = insertMissingPatchSet(notes, rev2);
 
     notes = incrementPatchSet(reload(notes));
-    PatchSet ps3 = psUtil.current(db, notes);
+    PatchSet ps3 = psUtil.current(notes);
 
     String rev4 = "c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee";
     PatchSet ps4 = insertMissingPatchSet(notes, rev4);
@@ -265,41 +217,34 @@
     assertProblems(
         notes,
         fix,
-        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Ref missing: " + ps2.id().toRefName()),
         problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"),
-        problem("Ref missing: " + ps4.getId().toRefName()),
+        problem("Ref missing: " + ps4.id().toRefName()),
         problem("Object missing: patch set 4: " + rev4, FIXED, "Deleted patch set"));
 
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(3);
-    assertThat(psUtil.get(db, notes, ps1.getId())).isNotNull();
-    assertThat(psUtil.get(db, notes, ps2.getId())).isNull();
-    assertThat(psUtil.get(db, notes, ps3.getId())).isNotNull();
-    assertThat(psUtil.get(db, notes, ps4.getId())).isNull();
+    assertThat(psUtil.get(notes, ps1.id())).isNotNull();
+    assertThat(psUtil.get(notes, ps2.id())).isNull();
+    assertThat(psUtil.get(notes, ps3.id())).isNotNull();
+    assertThat(psUtil.get(notes, ps4.id())).isNull();
   }
 
   @Test
   public void onlyPatchSetObjectMissingWithFix() throws Exception {
-    Change c = TestChanges.newChange(project, admin.getId(), sequences.nextChangeId());
-
-    // Set review started, mimicking Schema_153, so tests pass with NoteDbMode.CHECK.
-    c.setReviewStarted(true);
+    Change c = TestChanges.newChange(project, admin.id(), sequences.nextChangeId());
 
     PatchSet.Id psId = c.currentPatchSetId();
     String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     PatchSet ps = newPatchSet(psId, rev, adminId);
 
-    if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
-      db.changes().insert(singleton(c));
-      db.patchSets().insert(singleton(ps));
-    }
     addNoteDbCommit(
         c.getId(),
         "Create change\n"
             + "\n"
             + "Patch-set: 1\n"
             + "Branch: "
-            + c.getDest().get()
+            + c.getDest().branch()
             + "\n"
             + "Change-id: "
             + c.getKey().get()
@@ -311,15 +256,15 @@
             + "Groups: "
             + rev
             + "\n");
-    indexer.index(db, c.getProject(), c.getId());
-    ChangeNotes notes = changeNotesFactory.create(db, c.getProject(), c.getId());
+    indexer.index(c.getProject(), c.getId());
+    ChangeNotes notes = changeNotesFactory.create(c.getProject(), c.getId());
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
     assertProblems(
         notes,
         fix,
-        problem("Ref missing: " + ps.getId().toRefName()),
+        problem("Ref missing: " + ps.id().toRefName()),
         problem(
             "Object missing: patch set 1: " + rev,
             FIX_FAILED,
@@ -327,30 +272,20 @@
 
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
-    assertThat(psUtil.current(db, notes)).isNotNull();
-  }
-
-  @Test
-  public void currentPatchSetMissing() throws Exception {
-    // NoteDb can't create a change without a patch set.
-    assumeNoteDbDisabled();
-
-    ChangeNotes notes = insertChange();
-    db.patchSets().deleteKeys(singleton(notes.getChange().currentPatchSetId()));
-    assertProblems(notes, null, problem("Current patch set 1 not found"));
+    assertThat(psUtil.current(notes)).isNotNull();
   }
 
   @Test
   public void duplicatePatchSetRevisions() throws Exception {
     ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
-    String rev = ps1.getRevision().get();
+    PatchSet ps1 = psUtil.current(notes);
 
-    notes =
-        incrementPatchSet(
-            notes, serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+    notes = incrementPatchSet(notes, serverSideTestRepo.getRevWalk().parseCommit(ps1.commitId()));
 
-    assertProblems(notes, null, problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
+    assertProblems(
+        notes,
+        null,
+        problem("Multiple patch sets pointing to " + ps1.commitId().name() + ": [1, 2]"));
   }
 
   @Test
@@ -376,7 +311,7 @@
           notes.getChangeId(),
           new BatchUpdateOp() {
             @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
+            public boolean updateChange(ChangeContext ctx) {
               ctx.getChange().setStatus(Change.Status.MERGED);
               ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
               return true;
@@ -386,14 +321,13 @@
     }
     notes = reload(notes);
 
-    String rev = psUtil.current(db, notes).getRevision().get();
     ObjectId tip = getDestRef(notes);
     assertProblems(
         notes,
         null,
         problem(
             "Patch set 1 ("
-                + rev
+                + psUtil.current(notes).commitId().name()
                 + ") is not merged into destination ref"
                 + " refs/heads/master ("
                 + tip.name()
@@ -403,56 +337,56 @@
   @Test
   public void newChangeIsMerged() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     assertProblems(
         notes,
         null,
         problem(
             "Patch set 1 ("
-                + rev
+                + commitId.name()
                 + ") is merged into destination ref"
                 + " refs/heads/master ("
-                + rev
+                + commitId.name()
                 + "), but change status is NEW"));
   }
 
   @Test
   public void newChangeIsMergedWithFix() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     assertProblems(
         notes,
         new FixInput(),
         problem(
             "Patch set 1 ("
-                + rev
+                + commitId.name()
                 + ") is merged into destination ref"
                 + " refs/heads/master ("
-                + rev
+                + commitId.name()
                 + "), but change status is NEW",
             FIXED,
             "Marked change as merged"));
 
     notes = reload(notes);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(notes.getChange().isMerged()).isTrue();
     assertNoProblems(notes, null);
   }
 
   @Test
   public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     ChangeInfo info = gApi.changes().id(notes.getChangeId().get()).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
@@ -464,37 +398,37 @@
   @Test
   public void expectedMergedCommitIsLatestPatchSet() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev;
+    fix.expectMergedAs = commitId.name();
     assertProblems(
         notes,
         fix,
         problem(
             "Patch set 1 ("
-                + rev
+                + commitId.name()
                 + ") is merged into destination ref"
                 + " refs/heads/master ("
-                + rev
+                + commitId.name()
                 + "), but change status is NEW",
             FIXED,
             "Marked change as merged"));
 
     notes = reload(notes);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(notes.getChange().isMerged()).isTrue();
     assertNoProblems(notes, null);
   }
 
   @Test
   public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
-    serverSideTestRepo.branch(notes.getChange().getDest().get()).update(commit);
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
+    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit);
 
     FixInput fix = new FixInput();
     RevCommit other = serverSideTestRepo.commit().message(commit.getFullMessage()).create();
@@ -514,9 +448,9 @@
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithNoChangeId() throws Exception {
     ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    String dest = notes.getChange().getDest().branch();
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
 
     RevCommit mergedAs =
         serverSideTestRepo
@@ -545,9 +479,9 @@
             "Inserted as patch set 2"));
 
     notes = reload(notes);
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
+    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(mergedAs);
 
     assertNoProblems(notes, null);
   }
@@ -555,9 +489,9 @@
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithChangeId() throws Exception {
     ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    String dest = notes.getChange().getDest().branch();
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
 
     RevCommit mergedAs =
         serverSideTestRepo
@@ -593,9 +527,9 @@
             "Inserted as patch set 2"));
 
     notes = reload(notes);
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
+    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(mergedAs);
 
     assertNoProblems(notes, null);
   }
@@ -603,116 +537,117 @@
   @Test
   public void expectedMergedCommitIsOldPatchSetOfSameChange() throws Exception {
     ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
-    String rev1 = ps1.getRevision().get();
+    ObjectId commitId1 = psUtil.current(notes).commitId();
     notes = incrementPatchSet(notes);
-    PatchSet ps2 = psUtil.current(db, notes);
+    PatchSet ps2 = psUtil.current(notes);
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev1)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId1));
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev1;
+    fix.expectMergedAs = commitId1.name();
     assertProblems(
         notes,
         fix,
-        problem("No patch set found for merged commit " + rev1, FIXED, "Marked change as merged"),
+        problem(
+            "No patch set found for merged commit " + commitId1.name(),
+            FIXED,
+            "Marked change as merged"),
         problem(
             "Expected merge commit "
-                + rev1
+                + commitId1.name()
                 + " corresponds to patch set 1,"
                 + " not the current patch set 2",
             FIXED,
             "Deleted patch set"),
         problem(
             "Expected merge commit "
-                + rev1
+                + commitId1.name()
                 + " corresponds to patch set 1,"
                 + " not the current patch set 2",
             FIXED,
             "Inserted as patch set 3"));
 
     notes = reload(notes);
-    PatchSet.Id psId3 = new PatchSet.Id(notes.getChangeId(), 3);
+    PatchSet.Id psId3 = PatchSet.id(notes.getChangeId(), 3);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId3);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(ps2.getId(), psId3);
-    assertThat(psUtil.get(db, notes, psId3).getRevision().get()).isEqualTo(rev1);
+    assertThat(notes.getChange().isMerged()).isTrue();
+    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps2.id(), psId3);
+    assertThat(psUtil.get(notes, psId3).commitId()).isEqualTo(commitId1);
   }
 
   @Test
   public void expectedMergedCommitIsDanglingPatchSetOlderThanCurrent() throws Exception {
     ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
+    PatchSet ps1 = psUtil.current(notes);
 
     // Create dangling ref so next ID in the database becomes 3.
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     RevCommit commit2 = patchSetCommit(psId2);
-    String rev2 = commit2.name();
     serverSideTestRepo.branch(psId2.toRefName()).update(commit2);
 
     notes = incrementPatchSet(notes);
-    PatchSet ps3 = psUtil.current(db, notes);
-    assertThat(ps3.getId().get()).isEqualTo(3);
+    PatchSet ps3 = psUtil.current(notes);
+    assertThat(ps3.id().get()).isEqualTo(3);
 
-    serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit2);
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev2;
+    fix.expectMergedAs = commit2.name();
     assertProblems(
         notes,
         fix,
-        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
+        problem(
+            "No patch set found for merged commit " + commit2.name(),
+            FIXED,
+            "Marked change as merged"),
         problem(
             "Expected merge commit "
-                + rev2
+                + commit2.name()
                 + " corresponds to patch set 2,"
                 + " not the current patch set 3",
             FIXED,
             "Deleted patch set"),
         problem(
             "Expected merge commit "
-                + rev2
+                + commit2.name()
                 + " corresponds to patch set 2,"
                 + " not the current patch set 3",
             FIXED,
             "Inserted as patch set 4"));
 
     notes = reload(notes);
-    PatchSet.Id psId4 = new PatchSet.Id(notes.getChangeId(), 4);
+    PatchSet.Id psId4 = PatchSet.id(notes.getChangeId(), 4);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId4);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet())
-        .containsExactly(ps1.getId(), ps3.getId(), psId4);
-    assertThat(psUtil.get(db, notes, psId4).getRevision().get()).isEqualTo(rev2);
+    assertThat(notes.getChange().isMerged()).isTrue();
+    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.id(), ps3.id(), psId4);
+    assertThat(psUtil.get(notes, psId4).commitId()).isEqualTo(commit2);
   }
 
   @Test
   public void expectedMergedCommitIsDanglingPatchSetNewerThanCurrent() throws Exception {
     ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
+    PatchSet ps1 = psUtil.current(notes);
 
     // Create dangling ref with no patch set.
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     RevCommit commit2 = patchSetCommit(psId2);
-    String rev2 = commit2.name();
     serverSideTestRepo.branch(psId2.toRefName()).update(commit2);
 
-    serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit2);
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev2;
+    fix.expectMergedAs = commit2.name();
     assertProblems(
         notes,
         fix,
-        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
+        problem(
+            "No patch set found for merged commit " + commit2.name(),
+            FIXED,
+            "Marked change as merged"),
         problem(
             "Expected merge commit "
-                + rev2
+                + commit2.name()
                 + " corresponds to patch set 2,"
                 + " not the current patch set 1",
             FIXED,
@@ -720,18 +655,18 @@
 
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(ps1.getId(), psId2);
-    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(rev2);
+    assertThat(notes.getChange().isMerged()).isTrue();
+    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.id(), psId2);
+    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(commit2);
   }
 
   @Test
   public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
     ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
+    String dest = notes.getChange().getDest().branch();
     RevCommit parent = serverSideTestRepo.branch(dest).commit().message("parent").create();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
     serverSideTestRepo.branch(dest).update(commit);
 
     String badId = "I0000000000000000000000000000000000000000";
@@ -764,19 +699,19 @@
   @Test
   public void expectedMergedCommitMatchesMultiplePatchSets() throws Exception {
     ChangeNotes notes1 = insertChange();
-    PatchSet.Id psId1 = psUtil.current(db, notes1).getId();
-    String dest = notes1.getChange().getDest().get();
-    String rev = psUtil.current(db, notes1).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    PatchSet.Id psId1 = psUtil.current(notes1).id();
+    String dest = notes1.getChange().getDest().branch();
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes1).commitId());
     serverSideTestRepo.branch(dest).update(commit);
 
     ChangeNotes notes2 = insertChange();
     notes2 = incrementPatchSet(notes2, commit);
-    PatchSet.Id psId2 = psUtil.current(db, notes2).getId();
+    PatchSet.Id psId2 = psUtil.current(notes2).id();
 
     ChangeNotes notes3 = insertChange();
     notes3 = incrementPatchSet(notes3, commit);
-    PatchSet.Id psId3 = psUtil.current(db, notes3).getId();
+    PatchSet.Id psId3 = psUtil.current(notes3).id();
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = commit.name();
@@ -796,7 +731,7 @@
   }
 
   private BatchUpdate newUpdate(Account.Id owner) {
-    return batchUpdateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
+    return batchUpdateFactory.create(project, userFactory.create(owner), TimeUtil.nowTs());
   }
 
   private ChangeNotes insertChange() throws Exception {
@@ -808,20 +743,20 @@
   }
 
   private ChangeNotes insertChange(TestAccount owner, String dest) throws Exception {
-    Change.Id id = new Change.Id(sequences.nextChangeId());
+    Change.Id id = Change.id(sequences.nextChangeId());
     ChangeInserter ins;
-    try (BatchUpdate bu = newUpdate(owner.getId())) {
-      RevCommit commit = patchSetCommit(new PatchSet.Id(id, 1));
+    try (BatchUpdate bu = newUpdate(owner.id())) {
+      RevCommit commit = patchSetCommit(PatchSet.id(id, 1));
+      bu.setNotify(NotifyResolver.Result.none());
       ins =
           changeInserterFactory
               .create(id, commit, dest)
               .setValidate(false)
-              .setNotify(NotifyHandling.NONE)
               .setFireRevisionCreated(false)
               .setSendMail(false);
       bu.insertChange(ins).execute();
     }
-    return changeNotesFactory.create(db, project, ins.getChange().getId());
+    return changeNotesFactory.create(project, ins.getChange().getId());
   }
 
   private PatchSet.Id nextPatchSetId(ChangeNotes notes) throws Exception {
@@ -836,19 +771,19 @@
   private ChangeNotes incrementPatchSet(ChangeNotes notes, RevCommit commit) throws Exception {
     PatchSetInserter ins;
     try (BatchUpdate bu = newUpdate(notes.getChange().getOwner())) {
+      bu.setNotify(NotifyResolver.Result.none());
       ins =
           patchSetInserterFactory
               .create(notes, nextPatchSetId(notes), commit)
               .setValidate(false)
-              .setFireRevisionCreated(false)
-              .setNotify(NotifyHandling.NONE);
+              .setFireRevisionCreated(false);
       bu.addOp(notes.getChangeId(), ins).execute();
     }
     return reload(notes);
   }
 
   private ChangeNotes reload(ChangeNotes notes) throws Exception {
-    return changeNotesFactory.create(db, notes.getChange().getProject(), notes.getChangeId());
+    return changeNotesFactory.create(notes.getChange().getProject(), notes.getChangeId());
   }
 
   private RevCommit patchSetCommit(PatchSet.Id psId) throws Exception {
@@ -865,11 +800,6 @@
     c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
     PatchSet ps = newPatchSet(psId, rev, adminId);
 
-    if (PrimaryStorage.of(c) == PrimaryStorage.REVIEW_DB) {
-      db.patchSets().insert(singleton(ps));
-      db.changes().update(singleton(c));
-    }
-
     addNoteDbCommit(
         c.getId(),
         "Update patch set "
@@ -885,7 +815,7 @@
             + "Subject: "
             + subject
             + "\n");
-    indexer.index(db, c.getProject(), c.getId());
+    indexer.index(c.getProject(), c.getId());
 
     return ps;
   }
@@ -897,14 +827,8 @@
   }
 
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
-    if (!notesMigration.commitChangeWrites()) {
-      return;
-    }
     PersonIdent committer = serverIdent.get();
-    PersonIdent author =
-        noteUtil
-            .getLegacyChangeNoteWrite()
-            .newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
+    PersonIdent author = noteUtil.newIdent(getAccount(admin.id()), committer.getWhen(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
@@ -917,14 +841,14 @@
   private ObjectId getDestRef(ChangeNotes notes) throws Exception {
     return serverSideTestRepo
         .getRepository()
-        .exactRef(notes.getChange().getDest().get())
+        .exactRef(notes.getChange().getDest().branch())
         .getObjectId();
   }
 
   private ChangeNotes mergeChange(ChangeNotes notes) throws Exception {
-    final ObjectId oldId = getDestRef(notes);
-    final ObjectId newId = ObjectId.fromString(psUtil.current(db, notes).getRevision().get());
-    final String dest = notes.getChange().getDest().get();
+    ObjectId oldId = getDestRef(notes);
+    ObjectId newId = psUtil.current(notes).commitId();
+    String dest = notes.getChange().getDest().branch();
 
     try (BatchUpdate bu = newUpdate(adminId)) {
       bu.addOp(
@@ -936,7 +860,7 @@
             }
 
             @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
+            public boolean updateChange(ChangeContext ctx) {
               ctx.getChange().setStatus(Change.Status.MERGED);
               ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
               return true;
@@ -955,8 +879,8 @@
 
   private static ProblemInfo problem(String message, ProblemInfo.Status status, String outcome) {
     ProblemInfo p = problem(message);
-    p.status = checkNotNull(status);
-    p.outcome = checkNotNull(outcome);
+    p.status = requireNonNull(status);
+    p.outcome = requireNonNull(outcome);
     return p;
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 70ca5f5..9882c77 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -15,38 +15,50 @@
 package com.google.gerrit.acceptance.server.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.GetRelated;
-import com.google.gerrit.server.restapi.change.GetRelated.ChangeAndCommit;
-import com.google.gerrit.server.restapi.change.GetRelated.RelatedInfo;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -56,6 +68,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
+@NoHttpd
 public class GetRelatedIT extends AbstractDaemonTest {
   private static final int MAX_TERMS = 10;
 
@@ -66,6 +79,11 @@
     return cfg;
   }
 
+  @Inject private AccountOperations accountOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   private String systemTimeZone;
 
   @Before
@@ -85,7 +103,7 @@
 
   @Test
   public void getRelatedNoResult() throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     assertRelated(push.to("refs/for/master").getPatchSetId());
   }
 
@@ -112,14 +130,14 @@
     testRepo.reset(c1_1);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    String oldETag = changes.parse(ps1_1.getParentKey()).getETag();
+    String oldETag = changes.parse(ps1_1.changeId()).getETag();
 
     testRepo.reset(c2_1);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
 
     // Push of change 2 should not affect groups (or anything else) of change 1.
-    assertThat(changes.parse(ps1_1.getParentKey()).getETag()).isEqualTo(oldETag);
+    assertThat(changes.parse(ps1_1.changeId()).getETag()).isEqualTo(oldETag);
 
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
       assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
@@ -481,7 +499,7 @@
 
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-    PatchSet.Id ps2_edit = new PatchSet.Id(ch2.getId(), 0);
+    PatchSet.Id ps2_edit = PatchSet.id(ch2.getId(), 0);
     PatchSet.Id ps3_1 = getPatchSetId(c3_1);
 
     for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_1)) {
@@ -495,7 +513,7 @@
     assertRelated(
         ps2_edit,
         changeAndCommit(ps3_1, c3_1, 1),
-        changeAndCommit(new PatchSet.Id(ch2.getId(), 0), editRev, 1),
+        changeAndCommit(PatchSet.id(ch2.getId(), 0), editRev, 1),
         changeAndCommit(ps1_1, c1_1, 1));
   }
 
@@ -516,7 +534,7 @@
 
     // Pretend PS1,1 was pushed before the groups field was added.
     clearGroups(psId1_1);
-    indexer.index(changeDataFactory.create(db, project, psId1_1.getParentKey()));
+    indexer.index(changeDataFactory.create(project, psId1_1.changeId()));
 
     // PS1,1 has no groups, so disappeared from related changes.
     assertRelated(psId2_1);
@@ -551,14 +569,13 @@
 
     PatchSet.Id psId1_1 = getPatchSetId(c1_1);
     PatchSet.Id psId2_1 = getPatchSetId(c2_1);
-    PatchSet.Id psId2_2 = new PatchSet.Id(psId2_1.changeId, psId2_1.get() + 1);
+    PatchSet.Id psId2_2 = PatchSet.id(psId2_1.changeId(), psId2_1.get() + 1);
 
     assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
   }
 
   @Test
   public void getRelatedManyGroups() throws Exception {
-    List<RevCommit> commits = new ArrayList<>();
     RevCommit last = null;
     int n = 2 * MAX_TERMS;
     assertThat(n).isGreaterThan(indexConfig.maxTerms());
@@ -567,25 +584,76 @@
       last = cb.add("a.txt", Integer.toString(i)).message("subject: " + i).create();
       testRepo.reset(last);
       assertPushOk(pushHead(testRepo, "refs/for/master", false), "refs/for/master");
-      commits.add(last);
     }
 
     ChangeData cd = getChange(last);
     assertThat(cd.patchSets()).hasSize(n);
-    assertThat(GetRelated.getAllGroups(cd.notes(), db, psUtil)).hasSize(n);
+    assertThat(GetRelated.getAllGroups(cd.notes(), psUtil)).hasSize(n);
 
     assertRelated(cd.change().currentPatchSetId());
   }
 
-  private List<ChangeAndCommit> getRelated(PatchSet.Id ps) throws Exception {
-    return getRelated(ps.getParentKey(), ps.get());
+  @Test
+  public void getRelatedManyChanges() throws Exception {
+    List<ObjectId> commitIds = new ArrayList<>();
+    for (int i = 1; i <= 5; i++) {
+      commitIds.add(commitBuilder().add(i + ".txt", "i").message("subject: " + i).create().copy());
+    }
+    pushHead(testRepo, "refs/for/master", false);
+
+    List<RelatedChangeAndCommitInfo> expected = new ArrayList<>(commitIds.size());
+    for (ObjectId commitId : commitIds) {
+      expected.add(changeAndCommit(getPatchSetId(commitId), commitId, 1));
+    }
+    Collections.reverse(expected);
+
+    PatchSet.Id lastPsId = getPatchSetId(Iterables.getLast(commitIds));
+    assertRelated(lastPsId, expected);
+
+    Account.Id accountId = accountOperations.newAccount().create();
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().addMember(accountId).create();
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.QUERY_LIMIT).group(groupUuid).range(0, 2))
+        .update();
+    requestScopeOperations.setApiUser(accountId);
+
+    assertRelated(lastPsId, expected);
   }
 
-  private List<ChangeAndCommit> getRelated(Change.Id changeId, int ps) throws Exception {
-    String url = String.format("/changes/%d/revisions/%d/related", changeId.get(), ps);
-    RestResponse r = adminRestSession.get(url);
-    r.assertOK();
-    return newGson().fromJson(r.getReader(), RelatedInfo.class).changes;
+  @Test
+  public void stateOfRelatedChangesMatchesDocumentedValues() throws Exception {
+    // Set up three related changes, one new, the other abandoned, and the third merged.
+    RevCommit commit1 =
+        commitBuilder().add("a.txt", "File content 1").message("Subject 1").create();
+    RevCommit commit2 =
+        commitBuilder().add("b.txt", "File content 2").message("Subject 2").create();
+    RevCommit commit3 =
+        commitBuilder().add("c.txt", "File content 3").message("Subject 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+    Change change1 = getChange(commit1).change();
+    Change change2 = getChange(commit2).change();
+    Change change3 = getChange(commit3).change();
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change1.getChangeId()).current().submit();
+    gApi.changes().id(change2.getChangeId()).abandon();
+
+    List<RelatedChangeAndCommitInfo> relatedChanges =
+        gApi.changes().id(change3.getChangeId()).current().related().changes;
+
+    // Ensure that our REST API returns the states exactly as documented (and required by the
+    // frontend).
+    assertThat(relatedChanges)
+        .comparingElementsUsing(getRelatedChangeToStatusCorrespondence())
+        .containsExactly("NEW", "ABANDONED", "MERGED");
+  }
+
+  private static Correspondence<RelatedChangeAndCommitInfo, String>
+      getRelatedChangeToStatusCorrespondence() {
+    return Correspondence.from(
+        (relatedChangeAndCommitInfo, status) ->
+            Objects.equals(relatedChangeAndCommitInfo.status, status),
+        "has status");
   }
 
   private RevCommit parseBody(RevCommit c) throws Exception {
@@ -601,11 +669,11 @@
     return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
   }
 
-  private ChangeAndCommit changeAndCommit(
+  private RelatedChangeAndCommitInfo changeAndCommit(
       PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
-    ChangeAndCommit result = new ChangeAndCommit();
+    RelatedChangeAndCommitInfo result = new RelatedChangeAndCommitInfo();
     result.project = project.get();
-    result._changeNumber = psId.getParentKey().get();
+    result._changeNumber = psId.changeId().get();
     result.commit = new CommitInfo();
     result.commit.commit = commitId.name();
     result._revisionNumber = psId.get();
@@ -615,15 +683,13 @@
   }
 
   private void clearGroups(PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.nowTs())) {
       bu.addOp(
-          psId.getParentKey(),
+          psId.changeId(),
           new BatchUpdateOp() {
             @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, ImmutableList.<String>of());
-              ctx.dontBumpLastUpdatedOn();
+            public boolean updateChange(ChangeContext ctx) {
+              ctx.getUpdate(psId).setGroups(ImmutableList.of());
               return true;
             }
           });
@@ -631,21 +697,29 @@
     }
   }
 
-  private void assertRelated(PatchSet.Id psId, ChangeAndCommit... expected) throws Exception {
-    List<ChangeAndCommit> actual = getRelated(psId);
-    assertThat(actual).named("related to " + psId).hasSize(expected.length);
+  private void assertRelated(PatchSet.Id psId, RelatedChangeAndCommitInfo... expected)
+      throws Exception {
+    assertRelated(psId, Arrays.asList(expected));
+  }
+
+  private void assertRelated(PatchSet.Id psId, List<RelatedChangeAndCommitInfo> expected)
+      throws Exception {
+    List<RelatedChangeAndCommitInfo> actual =
+        gApi.changes().id(psId.changeId().get()).revision(psId.get()).related().changes;
+    assertWithMessage("related to " + psId).that(actual).hasSize(expected.size());
     for (int i = 0; i < actual.size(); i++) {
       String name = "index " + i + " related to " + psId;
-      ChangeAndCommit a = actual.get(i);
-      ChangeAndCommit e = expected[i];
-      assertThat(a.project).named("project of " + name).isEqualTo(e.project);
-      assertThat(a._changeNumber).named("change ID of " + name).isEqualTo(e._changeNumber);
+      RelatedChangeAndCommitInfo a = actual.get(i);
+      RelatedChangeAndCommitInfo e = expected.get(i);
+      assertWithMessage("project of " + name).that(a.project).isEqualTo(e.project);
+      assertWithMessage("change ID of " + name).that(a._changeNumber).isEqualTo(e._changeNumber);
       // Don't bother checking changeId; assume _changeNumber is sufficient.
-      assertThat(a._revisionNumber).named("revision of " + name).isEqualTo(e._revisionNumber);
-      assertThat(a.commit.commit).named("commit of " + name).isEqualTo(e.commit.commit);
-      assertThat(a._currentRevisionNumber)
-          .named("current revision of " + name)
+      assertWithMessage("revision of " + name).that(a._revisionNumber).isEqualTo(e._revisionNumber);
+      assertWithMessage("commit of " + name).that(a.commit.commit).isEqualTo(e.commit.commit);
+      assertWithMessage("current revision of " + name)
+          .that(a._currentRevisionNumber)
           .isEqualTo(e._currentRevisionNumber);
+      assertThat(a.status).isEqualTo(e.status);
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
deleted file mode 100644
index e029e7a..0000000
--- a/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Inject;
-import java.util.Collection;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class LegacyCommentsIT extends AbstractDaemonTest {
-  @Inject private ChangeNoteUtil noteUtil;
-
-  @ConfigSuite.Default
-  public static Config writeJsonFalseConfig() {
-    Config c = new Config();
-    c.setBoolean("noteDb", null, "writeJson", false);
-    return c;
-  }
-
-  @Before
-  public void setUp() {
-    setApiUser(user);
-  }
-
-  @Test
-  public void legacyCommentHasLegacyFormatTrue() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    assertThat(noteUtil.getChangeNoteJson().getWriteJson()).isFalse();
-
-    PushOneCommit.Result result = createChange();
-    Change.Id changeId = result.getChange().getId();
-
-    CommentInput cin = new CommentInput();
-    cin.message = "comment";
-    cin.path = PushOneCommit.FILE_NAME;
-
-    ReviewInput rin = new ReviewInput();
-    rin.comments = ImmutableMap.of(cin.path, ImmutableList.of(cin));
-    gApi.changes().id(changeId.get()).current().review(rin);
-
-    Collection<Comment> comments =
-        notesFactory.createChecked(db, project, changeId).getComments().values();
-    assertThat(comments).hasSize(1);
-    Comment comment = comments.iterator().next();
-    assertThat(comment.message).isEqualTo("comment");
-    assertThat(comment.legacyFormat).isTrue();
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
index cefde21..4e5cb5e 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
@@ -42,6 +42,7 @@
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.lib.ObjectId;
@@ -83,7 +84,7 @@
     assertDeleted(FILE_D, entries.get(2));
 
     // Change 1,2 (+FILE_A, +FILE_B, -FILE_D)
-    c = amendBuilder().add(FILE_B, "2").create();
+    amendBuilder().add(FILE_B, "2").create();
     pushHead(testRepo, "refs/for/master", false);
     entries = getCurrentPatches(id);
 
@@ -189,6 +190,29 @@
   }
 
   @Test
+  public void harmfulMutationsOfEditsAreNotPossibleForPatchListEntry() throws Exception {
+    RevCommit commit =
+        commitBuilder().add("a.txt", "First line\nSecond line\n").message(SUBJECT_1).create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    PatchListKey diffKey = PatchListKey.againstDefaultBase(commit.copy(), Whitespace.IGNORE_NONE);
+    PatchList patchList = patchListCache.get(diffKey, project);
+
+    PatchListEntry patchListEntry = getEntryFor(patchList, "a.txt");
+    Edit outputEdit = Iterables.getOnlyElement(patchListEntry.getEdits());
+    Edit originalEdit =
+        new Edit(
+            outputEdit.getBeginA(),
+            outputEdit.getEndA(),
+            outputEdit.getBeginB(),
+            outputEdit.getEndB());
+
+    outputEdit.shift(5);
+
+    assertThat(patchListEntry.getEdits()).containsExactly(originalEdit);
+  }
+
+  @Test
   public void harmfulMutationsOfEditsAreNotPossibleForIntraLineDiffArgsAndCachedValue() {
     String a = "First line\nSecond line\n";
     String b = "1st line\n2nd line\n";
@@ -230,7 +254,7 @@
     PatchListCacheImpl.LargeObjectTombstone tombstone =
         new PatchListCacheImpl.LargeObjectTombstone();
     abstractPatchListCache.put(key, tombstone);
-    assertThat(abstractPatchListCache.getIfPresent(key)).isSameAs(tombstone);
+    assertThat(abstractPatchListCache.getIfPresent(key)).isSameInstanceAs(tombstone);
   }
 
   private static void assertAdded(String expectedNewName, PatchListEntry e) {
@@ -269,4 +293,13 @@
   private ObjectId getCurrentRevisionId(String changeId) throws Exception {
     return ObjectId.fromString(gApi.changes().id(changeId).get().currentRevision);
   }
+
+  private static PatchListEntry getEntryFor(PatchList patchList, String filePath) {
+    Optional<PatchListEntry> patchListEntry =
+        patchList.getPatches().stream()
+            .filter(entry -> entry.getNewName().equals(filePath))
+            .findAny();
+    return patchListEntry.orElseThrow(
+        () -> new IllegalStateException("No PatchListEntry for " + filePath + " exists"));
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 304a1e4..9d65d39 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -29,6 +31,7 @@
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
 import java.util.EnumSet;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -41,6 +44,9 @@
     return submitWholeTopicEnabledConfig();
   }
 
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void doesNotIncludeCurrentFiles() throws Exception {
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
@@ -100,14 +106,14 @@
     RevCommit b = commitBuilder().add("b", "1").message("change 2").create();
     pushHead(testRepo, "refs/for/master", false);
 
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     assertSubmittedTogether(getChangeId(a));
     assertSubmittedTogether(getChangeId(b), getChangeId(b), getChangeId(a));
   }
 
   @Test
   public void respectWholeTopic() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     // Create two independent commits and push.
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
@@ -129,7 +135,7 @@
 
   @Test
   public void anonymousWholeTopic() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
     pushHead(testRepo, "refs/for/master/" + name("topic"), false);
     String id1 = getChangeId(a);
@@ -139,7 +145,7 @@
     pushHead(testRepo, "refs/for/master/" + name("topic"), false);
     String id2 = getChangeId(b);
 
-    setApiUserAnonymous();
+    requestScopeOperations.setApiUserAnonymous();
     if (isSubmitWholeTopicEnabled()) {
       assertSubmittedTogether(id1, id2, id1);
       assertSubmittedTogether(id2, id2, id1);
@@ -151,7 +157,7 @@
 
   @Test
   public void topicChaining() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
@@ -179,7 +185,7 @@
 
   @Test
   public void respectTopicsOnAncestors() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
@@ -226,7 +232,8 @@
 
   @Test
   public void newBranchTwoChangesTogether() throws Exception {
-    Project.NameKey p1 = createProject("a-new-project", null, false);
+    Project.NameKey p1 = projectOperations.newProject().noEmptyCommit().create();
+
     TestRepository<?> repo1 = cloneProject(p1);
 
     RevCommit c1 =
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 14b3858..46fc689 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -15,16 +15,17 @@
 package com.google.gerrit.acceptance.server.event;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -32,8 +33,6 @@
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import org.junit.After;
 import org.junit.Before;
@@ -43,45 +42,26 @@
 public class CommentAddedEventIT extends AbstractDaemonTest {
 
   @Inject private DynamicSet<CommentAddedListener> source;
+  @Inject private ProjectOperations projectOperations;
 
   private final LabelType label =
-      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
 
   private final LabelType pLabel =
-      category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
+      label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   private RegistrationHandle eventListenerRegistration;
   private CommentAddedListener.Event lastCommentAddedEvent;
 
   @Before
   public void setUp() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(label.getName()),
-          -1,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(pLabel.getName()),
-          0,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
-      u.save();
-    }
-
-    eventListenerRegistration =
-        source.add(
-            new CommentAddedListener() {
-              @Override
-              public void onCommentAdded(Event event) {
-                lastCommentAddedEvent = event;
-              }
-            });
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(pLabel.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .update();
+    eventListenerRegistration = source.add("gerrit", event -> lastCommentAddedEvent = event);
   }
 
   @After
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/BUILD b/javatests/com/google/gerrit/acceptance/server/git/receive/BUILD
new file mode 100644
index 0000000..760e7f4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/BUILD
@@ -0,0 +1,8 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "receive",
+    labels = ["server"],
+    deps = [],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
new file mode 100644
index 0000000..cb5add3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.git.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.testing.TestCommentHelper;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for comment validation when publishing drafts via the {@code --publish-comments} option.
+ */
+public class ReceiveCommitsCommentValidationIT extends AbstractDaemonTest {
+  @Inject private CommentValidator mockCommentValidator;
+  @Inject private TestCommentHelper testCommentHelper;
+
+  private static final String COMMENT_TEXT = "The comment text";
+
+  private Capture<ImmutableList<CommentForValidation>> capture = new Capture<>();
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        CommentValidator mockCommentValidator = EasyMock.createMock(CommentValidator.class);
+        bind(CommentValidator.class)
+            .annotatedWith(Exports.named(mockCommentValidator.getClass()))
+            .toInstance(mockCommentValidator);
+        bind(CommentValidator.class).toInstance(mockCommentValidator);
+      }
+    };
+  }
+
+  @Before
+  public void resetMock() {
+    EasyMock.reset(mockCommentValidator);
+  }
+
+  @After
+  public void verifyMock() {
+    EasyMock.verify(mockCommentValidator);
+  }
+
+  @Test
+  public void validateComments_commentOK() throws Exception {
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of());
+    EasyMock.replay(mockCommentValidator);
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String revId = result.getCommit().getName();
+    DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, comment);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    amendResult.assertOkStatus();
+    amendResult.assertNotMessage("Comment validation failure:");
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+  }
+
+  @Test
+  public void validateComments_commentRejected() throws Exception {
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentType.FILE_COMMENT, COMMENT_TEXT);
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    EasyMock.replay(mockCommentValidator);
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String revId = result.getCommit().getName();
+    DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, comment);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    amendResult.assertOkStatus();
+    amendResult.assertMessage("Comment validation failure:");
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void validateComments_inlineVsFileComments_allOK() throws Exception {
+    EasyMock.expect(mockCommentValidator.validateComments(EasyMock.capture(capture)))
+        .andReturn(ImmutableList.of());
+    EasyMock.replay(mockCommentValidator);
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String revId = result.getCommit().getName();
+    DraftInput draftFile = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, draftFile);
+    DraftInput draftInline =
+        testCommentHelper.newDraft(
+            result.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, draftInline);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+    amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(2);
+    assertThat(capture.getValues()).hasSize(1);
+    assertThat(capture.getValue())
+        .containsExactly(
+            CommentForValidation.create(
+                CommentForValidation.CommentType.INLINE_COMMENT, draftInline.message),
+            CommentForValidation.create(
+                CommentForValidation.CommentType.FILE_COMMENT, draftFile.message));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
index 32f1ce5..768c269 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
@@ -18,23 +18,26 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 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.Side;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
+import com.google.inject.Inject;
 import java.time.Instant;
 import java.util.HashMap;
 import org.junit.Ignore;
 
 @Ignore
 public class AbstractMailIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   protected MailMessage.Builder messageBuilderWithDefaultFields() {
     MailMessage.Builder b = MailMessage.builder();
     b.id("some id");
-    b.from(user.emailAddress);
-    b.addTo(user.emailAddress); // Not evaluated
+    b.from(user.getEmailAddress());
+    b.addTo(user.getEmailAddress()); // Not evaluated
     b.subject("");
     b.dateReceived(Instant.now());
     return b;
@@ -49,12 +52,12 @@
     String file = "gerrit-server/test.txt";
     String contents = "contents \nlorem \nipsum \nlorem";
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+        pushFactory.create(admin.newIdent(), testRepo, "first subject", file, contents);
     PushOneCommit.Result r = push.to("refs/for/master");
     String changeId = r.getChangeId();
 
     // Review it
-    setApiUser(reviewer);
+    requestScopeOperations.setApiUser(reviewer.id());
     ReviewInput input = new ReviewInput();
     input.message = "I have two comments";
     input.comments = new HashMap<>();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/BUILD b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
index 4175272..e21789b 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
@@ -3,6 +3,7 @@
 DEPS = [
     "//lib/greenmail",
     "//lib/mail",
+    "//java/com/google/gerrit/mail",
 ]
 
 acceptance_tests(
@@ -20,7 +21,7 @@
 
 java_library(
     name = "util",
-    testonly = 1,
+    testonly = True,
     srcs = ["AbstractMailIT.java"],
     deps = DEPS + ["//java/com/google/gerrit/acceptance:lib"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 7096581..804462e 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.acceptance.server.mail;
 
-import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.ALL;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.NONE;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER;
@@ -32,6 +34,8 @@
 import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractNotificationTest;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
@@ -42,17 +46,26 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ChangeNotificationsIT extends AbstractNotificationTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   /*
    * Set up for extra standard test accounts and permissions.
    */
@@ -70,11 +83,14 @@
 
   @Before
   public void grantPermissions() throws Exception {
-    grant(project, "refs/*", Permission.FORGE_COMMITTER, false, REGISTERED_USERS);
-    grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
-    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
-    ProjectConfig cfg = projectCache.get(project).getConfig();
-    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.ABANDON).ref("refs/*").group(REGISTERED_USERS))
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
   }
 
   /*
@@ -92,6 +108,7 @@
         .bcc(sc.starrer)
         .bcc(ABANDONED_CHANGES)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -106,6 +123,7 @@
         .bcc(sc.starrer)
         .bcc(ABANDONED_CHANGES)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -121,6 +139,7 @@
         .bcc(sc.starrer)
         .bcc(ABANDONED_CHANGES)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -136,6 +155,7 @@
         .bcc(sc.starrer)
         .bcc(ABANDONED_CHANGES)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -147,13 +167,14 @@
         .cc(sc.reviewer, sc.ccer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void abandonReviewableChangeNotifyOwner() throws Exception {
     StagedChange sc = stageReviewableChange();
     abandon(sc.changeId, sc.owner, OWNER);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -163,7 +184,7 @@
     // Self-CC applies *after* need for sending notification is determined.
     // Since there are no recipients before including the user taking action,
     // there should no notification sent.
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -172,20 +193,21 @@
     TestAccount other = accountCreator.create("other", "other@example.com", "other");
     abandon(sc.changeId, other, CC_ON_OWN_COMMENTS, OWNER);
     assertThat(sender).sent("abandon", sc).to(sc.owner).cc(other).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void abandonReviewableChangeNotifyNone() throws Exception {
     StagedChange sc = stageReviewableChange();
     abandon(sc.changeId, sc.owner, NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void abandonReviewableChangeNotifyNoneCcingSelf() throws Exception {
     StagedChange sc = stageReviewableChange();
     abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS, NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -199,13 +221,14 @@
         .bcc(sc.starrer)
         .bcc(ABANDONED_CHANGES)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void abandonWipChange() throws Exception {
     StagedChange sc = stageWipChange();
     abandon(sc.changeId, sc.owner);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -219,6 +242,7 @@
         .bcc(sc.starrer)
         .bcc(ABANDONED_CHANGES)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   private void abandon(String changeId, TestAccount by) throws Exception {
@@ -239,7 +263,7 @@
       String changeId, TestAccount by, EmailStrategy emailStrategy, @Nullable NotifyHandling notify)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
+    requestScopeOperations.setApiUser(by.id());
     AbandonInput in = new AbandonInput();
     if (notify != null) {
       in.notify = notify;
@@ -251,34 +275,10 @@
    * AddReviewerSender tests.
    */
 
-  private void addReviewerToReviewableChangeInReviewDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
+  private void addReviewerToReviewableChange(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInReviewDbSingly() throws Exception {
-    addReviewerToReviewableChangeInReviewDb(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInReviewDbBatch() throws Exception {
-    addReviewerToReviewableChangeInReviewDb(batch());
-  }
-
-  private void addReviewerToReviewableChangeInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email());
     // TODO(logan): Should CCs be included?
     assertThat(sender)
         .sent("newchange", sc)
@@ -286,23 +286,23 @@
         .cc(sc.reviewer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void addReviewerToReviewableChangeInNoteDbSingly() throws Exception {
-    addReviewerToReviewableChangeInNoteDb(singly());
+  public void addReviewerToReviewableChangeSingly() throws Exception {
+    addReviewerToReviewableChange(singly());
   }
 
   @Test
-  public void addReviewerToReviewableChangeInNoteDbBatch() throws Exception {
-    addReviewerToReviewableChangeInNoteDb(batch());
+  public void addReviewerToReviewableChangeBatch() throws Exception {
+    addReviewerToReviewableChange(batch());
   }
 
-  private void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  private void addReviewerToReviewableChangeByOwnerCcingSelf(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, null);
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), CC_ON_OWN_COMMENTS, null);
     // TODO(logan): Should CCs be included?
     assertThat(sender)
         .sent("newchange", sc)
@@ -310,24 +310,24 @@
         .cc(sc.owner, sc.reviewer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDbSingly() throws Exception {
-    addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(singly());
+  public void addReviewerToReviewableChangeByOwnerCcingSelfSingly() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelf(singly());
   }
 
   @Test
-  public void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDbBatch() throws Exception {
-    addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(batch());
+  public void addReviewerToReviewableChangeByOwnerCcingSelfBatch() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelf(batch());
   }
 
-  private void addReviewerToReviewableChangeByOtherInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  private void addReviewerToReviewableChangeByOther(Adder adder) throws Exception {
     TestAccount other = accountCreator.create("other", "other@example.com", "other");
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, other, reviewer.email);
+    addReviewer(adder, sc.changeId, other, reviewer.email());
     // TODO(logan): Should CCs be included?
     assertThat(sender)
         .sent("newchange", sc)
@@ -335,24 +335,24 @@
         .cc(sc.owner, sc.reviewer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void addReviewerToReviewableChangeByOtherInNoteDbSingly() throws Exception {
-    addReviewerToReviewableChangeByOtherInNoteDb(singly());
+  public void addReviewerToReviewableChangeByOtherSingly() throws Exception {
+    addReviewerToReviewableChangeByOther(singly());
   }
 
   @Test
-  public void addReviewerToReviewableChangeByOtherInNoteDbBatch() throws Exception {
-    addReviewerToReviewableChangeByOtherInNoteDb(batch());
+  public void addReviewerToReviewableChangeByOtherBatch() throws Exception {
+    addReviewerToReviewableChangeByOther(batch());
   }
 
-  private void addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  private void addReviewerToReviewableChangeByOtherCcingSelf(Adder adder) throws Exception {
     TestAccount other = accountCreator.create("other", "other@example.com", "other");
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, other, reviewer.email, CC_ON_OWN_COMMENTS, null);
+    addReviewer(adder, sc.changeId, other, reviewer.email(), CC_ON_OWN_COMMENTS, null);
     // TODO(logan): Should CCs be included?
     assertThat(sender)
         .sent("newchange", sc)
@@ -360,38 +360,20 @@
         .cc(sc.owner, sc.reviewer, other)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void addReviewerToReviewableChangeByOtherCcingSelfInNoteDbSingly() throws Exception {
-    addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(singly());
+  public void addReviewerToReviewableChangeByOtherCcingSelfSingly() throws Exception {
+    addReviewerToReviewableChangeByOtherCcingSelf(singly());
   }
 
   @Test
-  public void addReviewerToReviewableChangeByOtherCcingSelfInNoteDbBatch() throws Exception {
-    addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(batch());
+  public void addReviewerToReviewableChangeByOtherCcingSelfBatch() throws Exception {
+    addReviewerToReviewableChangeByOtherCcingSelf(batch());
   }
 
-  private void addReviewerByEmailToReviewableChangeInReviewDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    String email = "addedbyemail@example.com";
-    StagedChange sc = stageReviewableChange();
-    addReviewer(adder, sc.changeId, sc.owner, email);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void addReviewerByEmailToReviewableChangeInReviewDbSingly() throws Exception {
-    addReviewerByEmailToReviewableChangeInReviewDb(singly());
-  }
-
-  @Test
-  public void addReviewerByEmailToReviewableChangeInReviewDbBatch() throws Exception {
-    addReviewerByEmailToReviewableChangeInReviewDb(batch());
-  }
-
-  private void addReviewerByEmailToReviewableChangeInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  private void addReviewerByEmailToReviewableChange(Adder adder) throws Exception {
     String email = "addedbyemail@example.com";
     StagedChange sc = stageReviewableChange();
     addReviewer(adder, sc.changeId, sc.owner, email);
@@ -402,23 +384,24 @@
         .cc(sc.reviewer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void addReviewerByEmailToReviewableChangeInNoteDbSingly() throws Exception {
-    addReviewerByEmailToReviewableChangeInNoteDb(singly());
+  public void addReviewerByEmailToReviewableChangeSingly() throws Exception {
+    addReviewerByEmailToReviewableChange(singly());
   }
 
   @Test
-  public void addReviewerByEmailToReviewableChangeInNoteDbBatch() throws Exception {
-    addReviewerByEmailToReviewableChangeInNoteDb(batch());
+  public void addReviewerByEmailToReviewableChangeBatch() throws Exception {
+    addReviewerByEmailToReviewableChange(batch());
   }
 
   private void addReviewerToWipChange(Adder adder) throws Exception {
     StagedChange sc = stageWipChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
-    assertThat(sender).notSent();
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email());
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -431,29 +414,26 @@
     addReviewerToWipChange(batch());
   }
 
-  private void addReviewerToReviewableWipChange(Adder adder) throws Exception {
-    StagedChange sc = stageReviewableWipChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
-    assertThat(sender).notSent();
-  }
-
   @Test
   public void addReviewerToReviewableWipChangeSingly() throws Exception {
-    addReviewerToReviewableWipChange(singly());
+    StagedChange sc = stageReviewableWipChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(singly(), sc.changeId, sc.owner, reviewer.email());
+    // TODO(dborowitz): In theory this should match the batch case, but we don't currently pass
+    // enough info into AddReviewersEmail#emailReviewers to distinguish the reviewStarted case.
+    // Complicating the emailReviewers arguments is not the answer; this needs to be rewritten.
+    // Tolerate the difference for now.
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void addReviewerToReviewableWipChangeBatch() throws Exception {
-    addReviewerToReviewableWipChange(batch());
-  }
-
-  private void addReviewerToWipChangeInNoteDbNotifyAll(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageWipChange();
+    StagedChange sc = stageReviewableWipChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, NotifyHandling.ALL);
-    // TODO(logan): Should CCs be included?
+    addReviewer(batch(), sc.changeId, sc.owner, reviewer.email());
+    // For a review-started WIP change, same as in the notify=ALL case. It's not especially
+    // important to notify just because a reviewer is added, but we do want to notify in the other
+    // case that hits this codepath: posting an actual review.
     assertThat(sender)
         .sent("newchange", sc)
         .to(reviewer)
@@ -462,45 +442,10 @@
         .noOneElse();
   }
 
-  @Test
-  public void addReviewerToWipChangeInNoteDbNotifyAllSingly() throws Exception {
-    addReviewerToWipChangeInNoteDbNotifyAll(singly());
-  }
-
-  @Test
-  public void addReviewerToWipChangeInNoteDbNotifyAllBatch() throws Exception {
-    addReviewerToWipChangeInNoteDbNotifyAll(batch());
-  }
-
-  private void addReviewerToWipChangeInReviewDbNotifyAll(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
+  private void addReviewerToWipChangeNotifyAll(Adder adder) throws Exception {
     StagedChange sc = stageWipChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, NotifyHandling.ALL);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToWipChangeInReviewDbNotifyAllSingly() throws Exception {
-    addReviewerToWipChangeInReviewDbNotifyAll(singly());
-  }
-
-  @Test
-  public void addReviewerToWipChangeInReviewDbNotifyAllBatch() throws Exception {
-    addReviewerToWipChangeInReviewDbNotifyAll(batch());
-  }
-
-  private void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(Adder adder)
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, OWNER_REVIEWERS);
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), NotifyHandling.ALL);
     // TODO(logan): Should CCs be included?
     assertThat(sender)
         .sent("newchange", sc)
@@ -508,58 +453,121 @@
         .cc(sc.reviewer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewersSingly() throws Exception {
-    addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(singly());
+  public void addReviewerToWipChangeNotifyAllSingly() throws Exception {
+    addReviewerToWipChangeNotifyAll(singly());
   }
 
   @Test
-  public void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewersBatch() throws Exception {
-    addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(batch());
+  public void addReviewerToWipChangeNotifyAllBatch() throws Exception {
+    addReviewerToWipChangeNotifyAll(batch());
   }
 
-  private void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(Adder adder)
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  private void addReviewerToReviewableChangeNotifyOwnerReviewers(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, OWNER);
-    assertThat(sender).notSent();
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), OWNER_REVIEWERS);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwnerSingly()
-      throws Exception {
-    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(singly());
+  public void addReviewerToReviewableChangeNotifyOwnerReviewersSingly() throws Exception {
+    addReviewerToReviewableChangeNotifyOwnerReviewers(singly());
   }
 
   @Test
-  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwnerBatch()
-      throws Exception {
-    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(batch());
+  public void addReviewerToReviewableChangeNotifyOwnerReviewersBatch() throws Exception {
+    addReviewerToReviewableChangeNotifyOwnerReviewers(batch());
   }
 
-  private void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(Adder adder)
+  private void addReviewerToReviewableChangeByOwnerCcingSelfNotifyOwner(Adder adder)
       throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, NONE);
-    assertThat(sender).notSent();
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), CC_ON_OWN_COMMENTS, OWNER);
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNoneSingly()
-      throws Exception {
-    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(singly());
+  public void addReviewerToReviewableChangeByOwnerCcingSelfNotifyOwnerSingly() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelfNotifyOwner(singly());
   }
 
   @Test
-  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNoneBatch()
+  public void addReviewerToReviewableChangeByOwnerCcingSelfNotifyOwnerBatch() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelfNotifyOwner(batch());
+  }
+
+  private void addReviewerToReviewableChangeByOwnerCcingSelfNotifyNone(Adder adder)
       throws Exception {
-    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(batch());
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), CC_ON_OWN_COMMENTS, NONE);
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOwnerCcingSelfNotifyNoneSingly() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelfNotifyNone(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOwnerCcingSelfNotifyNoneBatch() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelfNotifyNone(batch());
+  }
+
+  private void addNonUserReviewerByEmail(Adder adder) throws Exception {
+    StagedChange sc = stageReviewableChange();
+    addReviewer(adder, sc.changeId, sc.owner, "nonexistent@example.com");
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to("nonexistent@example.com")
+        .cc(sc.reviewer)
+        .cc(sc.ccerByEmail, sc.reviewerByEmail)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void addNonUserReviewerByEmailSingly() throws Exception {
+    addNonUserReviewerByEmail(singly(ReviewerState.REVIEWER));
+  }
+
+  @Test
+  public void addNonUserReviewerByEmailBatch() throws Exception {
+    addNonUserReviewerByEmail(batch(ReviewerState.REVIEWER));
+  }
+
+  private void addNonUserCcByEmail(Adder adder) throws Exception {
+    StagedChange sc = stageReviewableChange();
+    addReviewer(adder, sc.changeId, sc.owner, "nonexistent@example.com");
+    assertThat(sender)
+        .sent("newchange", sc)
+        .cc("nonexistent@example.com")
+        .cc(sc.reviewer)
+        .cc(sc.ccerByEmail, sc.reviewerByEmail)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void addNonUserCcByEmailSingly() throws Exception {
+    addNonUserCcByEmail(singly(ReviewerState.CC));
+  }
+
+  @Test
+  public void addNonUserCcByEmailBatch() throws Exception {
+    addNonUserCcByEmail(batch(ReviewerState.CC));
   }
 
   private interface Adder {
@@ -568,9 +576,14 @@
   }
 
   private Adder singly() {
+    return singly(ReviewerState.REVIEWER);
+  }
+
+  private Adder singly(ReviewerState reviewerState) {
     return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
       AddReviewerInput in = new AddReviewerInput();
       in.reviewer = reviewer;
+      in.state = reviewerState;
       if (notify != null) {
         in.notify = notify;
       }
@@ -579,9 +592,13 @@
   }
 
   private Adder batch() {
+    return batch(ReviewerState.REVIEWER);
+  }
+
+  private Adder batch(ReviewerState reviewerState) {
     return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
       ReviewInput in = ReviewInput.noScore();
-      in.reviewer(reviewer);
+      in.reviewer(reviewer, reviewerState, false);
       if (notify != null) {
         in.notify = notify;
       }
@@ -609,7 +626,7 @@
       @Nullable NotifyHandling notify)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
+    requestScopeOperations.setApiUser(by.id());
     adder.addReviewer(changeId, reviewer, notify);
   }
 
@@ -628,6 +645,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -642,6 +660,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -656,6 +675,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -670,6 +690,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -685,6 +706,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -700,6 +722,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -711,13 +734,14 @@
         .cc(sc.reviewer, sc.ccer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void commentOnReviewableChangeByOwnerNotifyOwner() throws Exception {
     StagedChange sc = stageReviewableChange();
     review(sc.owner, sc.changeId, ENABLED, OWNER);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -725,14 +749,14 @@
     StagedChange sc = stageReviewableChange();
     setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
     review(sc.owner, sc.changeId, ENABLED, OWNER);
-    assertThat(sender).notSent(); // TODO(logan): Why not send to owner?
+    assertThat(sender).didNotSend(); // TODO(logan): Why not send to owner?
   }
 
   @Test
   public void commentOnReviewableChangeByOwnerNotifyNone() throws Exception {
     StagedChange sc = stageReviewableChange();
     review(sc.owner, sc.changeId, ENABLED, NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -740,7 +764,7 @@
     StagedChange sc = stageReviewableChange();
     setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
     review(sc.owner, sc.changeId, ENABLED, NONE);
-    assertThat(sender).notSent(); // TODO(logan): Why not send to owner?
+    assertThat(sender).didNotSend(); // TODO(logan): Why not send to owner?
   }
 
   @Test
@@ -754,20 +778,21 @@
         .cc(sc.reviewer, sc.ccer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void commentOnWipChangeByOwner() throws Exception {
     StagedChange sc = stageWipChange();
     review(sc.owner, sc.changeId, ENABLED);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void commentOnWipChangeByOwnerCcingSelf() throws Exception {
     StagedChange sc = stageWipChange();
     review(sc.owner, sc.changeId, CC_ON_OWN_COMMENTS);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -781,6 +806,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -789,6 +815,7 @@
     TestAccount bot = sc.testAccount("bot");
     review(bot, sc.changeId, ENABLED, null, "autogenerated:tag");
     assertThat(sender).sent("comment", sc).to(sc.owner).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -797,6 +824,7 @@
     TestAccount bot = sc.testAccount("bot");
     review(bot, sc.changeId, ENABLED, null, "autogenerated:tag");
     assertThat(sender).sent("comment", sc).to(sc.owner).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -812,6 +840,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -825,6 +854,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -832,7 +862,7 @@
     StagedChange sc = stageReviewableChange();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
     gApi.changes().id(sc.changeId).revision("current").review(in);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -847,7 +877,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -862,14 +892,13 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void addReviewerOnWipChangeAndStartReviewInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void addReviewerOnWipChangeAndStartReview() throws Exception {
     StagedChange sc = stageWipChange();
-    ReviewInput in = ReviewInput.noScore().reviewer(other.email).setWorkInProgress(false);
+    ReviewInput in = ReviewInput.noScore().reviewer(other.email()).setWorkInProgress(false);
     gApi.changes().id(sc.changeId).revision("current").review(in);
     assertThat(sender)
         .sent("comment", sc)
@@ -885,29 +914,7 @@
         .cc(sc.reviewer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void addReviewerOnWipChangeAndStartReviewInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    ReviewInput in = ReviewInput.noScore().reviewer(other.email).setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
-    assertThat(sender)
-        .sent("comment", sc)
-        .cc(sc.reviewer, sc.ccer, other)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(other)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -961,30 +968,63 @@
         .to(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void createWipChange() throws Exception {
     stagePreChange("refs/for/master%wip");
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void createWipChangeWithWorkInProgressByDefaultForProject() throws Exception {
+    setWorkInProgressByDefault(project, InheritableBoolean.TRUE);
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    Truth.assertThat(gApi.changes().id(spc.changeId).get().workInProgress).isTrue();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void createWipChangeWithWorkInProgressByDefaultForUser() throws Exception {
+    // Make sure owner user is created
+    StagedChange sc = stageReviewableChange();
+    // All was cleaned already
+    assertThat(sender).didNotSend();
+
+    // Toggle workInProgress flag for owner
+    GeneralPreferencesInfo prefs = gApi.accounts().id(sc.owner.id().get()).getPreferences();
+    prefs.workInProgressByDefault = true;
+    gApi.accounts().id(sc.owner.id().get()).setPreferences(prefs);
+
+    // Create another change without notification that should be wip
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    Truth.assertThat(gApi.changes().id(spc.changeId).get().workInProgress).isTrue();
+    assertThat(sender).didNotSend();
+
+    // Clean up workInProgressByDefault by owner
+    prefs = gApi.accounts().id(sc.owner.id().get()).getPreferences();
+    Truth.assertThat(prefs.workInProgressByDefault).isTrue();
+    prefs.workInProgressByDefault = false;
+    gApi.accounts().id(sc.owner.id().get()).setPreferences(prefs);
   }
 
   @Test
   public void createReviewableChangeWithNotifyOwnerReviewers() throws Exception {
     stagePreChange("refs/for/master%notify=OWNER_REVIEWERS");
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void createReviewableChangeWithNotifyOwner() throws Exception {
     stagePreChange("refs/for/master%notify=OWNER");
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void createReviewableChangeWithNotifyNone() throws Exception {
     stagePreChange("refs/for/master%notify=OWNER");
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -995,21 +1035,38 @@
         .to(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void createReviewableChangeWithReviewersAndCcs() throws Exception {
-    // TODO(logan): Support reviewers/CCs-by-email via push option.
     StagedPreChange spc =
         stagePreChange(
             "refs/for/master",
-            users -> ImmutableList.of("r=" + users.reviewer.username, "cc=" + users.ccer.username));
+            users ->
+                ImmutableList.of("r=" + users.reviewer.username(), "cc=" + users.ccer.username()));
+    FakeEmailSenderSubject subject =
+        assertThat(sender).sent("newchange", spc).to(spc.reviewer, spc.watchingProjectOwner);
+    subject.cc(spc.ccer);
+    subject.bcc(NEW_CHANGES, NEW_PATCHSETS).noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void createReviewableChangeWithReviewersAndCcsByEmail() throws Exception {
+    StagedPreChange spc =
+        stagePreChange(
+            "refs/for/master",
+            users -> ImmutableList.of("r=nobody1@example.com,cc=nobody2@example.com"));
+    spc.supportReviewersByEmail = true;
     assertThat(sender)
         .sent("newchange", spc)
-        .to(spc.reviewer, spc.watchingProjectOwner)
-        .cc(spc.ccer)
+        .to("nobody1@example.com")
+        .to(spc.watchingProjectOwner)
+        .cc("nobody2@example.com")
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   /*
@@ -1019,7 +1076,7 @@
   @Test
   public void deleteReviewerFromReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     removeReviewer(sc, extraReviewer);
     assertThat(sender)
         .sent("deleteReviewer", sc)
@@ -1029,6 +1086,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1044,12 +1102,13 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteReviewerFromReviewableChangeByAdmin() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     removeReviewer(sc, extraReviewer);
     assertThat(sender)
         .sent("deleteReviewer", sc)
@@ -1059,13 +1118,14 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteReviewerFromReviewableChangeByAdminCcingSelf() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     setEmailStrategy(admin, EmailStrategy.CC_ON_OWN_COMMENTS);
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     removeReviewer(sc, extraReviewer);
     assertThat(sender)
         .sent("deleteReviewer", sc)
@@ -1075,12 +1135,13 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteCcerFromReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     removeReviewer(sc, extraCcer);
     assertThat(sender)
         .sent("deleteReviewer", sc)
@@ -1090,12 +1151,13 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteReviewerFromReviewableChangeNotifyOwnerReviewers() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     removeReviewer(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
     assertThat(sender)
         .sent("deleteReviewer", sc)
@@ -1103,13 +1165,14 @@
         .cc(extraCcer, sc.reviewer, sc.ccer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteReviewerFromReviewableChangeNotifyOwner() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1118,13 +1181,14 @@
     setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
     removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
     assertThat(sender).sent("deleteReviewer", sc).to(sc.owner, extraReviewer).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteReviewerFromReviewableChangeNotifyNone() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     removeReviewer(sc, extraReviewer, NotifyHandling.NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1132,27 +1196,27 @@
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
     removeReviewer(sc, extraReviewer, NotifyHandling.NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteReviewerFromReviewableWipChange() throws Exception {
     StagedChange sc = stageReviewableWipChangeWithExtraReviewer();
     removeReviewer(sc, extraReviewer);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteReviewerFromWipChange() throws Exception {
     StagedChange sc = stageWipChangeWithExtraReviewer();
     removeReviewer(sc, extraReviewer);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteReviewerFromWipChangeNotifyAll() throws Exception {
     StagedChange sc = stageWipChangeWithExtraReviewer();
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     removeReviewer(sc, extraReviewer, NotifyHandling.ALL);
     assertThat(sender)
         .sent("deleteReviewer", sc)
@@ -1162,15 +1226,17 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteReviewerWithApprovalFromWipChange() throws Exception {
     StagedChange sc = stageWipChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     removeReviewer(sc, extraReviewer);
     assertThat(sender).sent("deleteReviewer", sc).to(extraReviewer).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1178,19 +1244,18 @@
     StagedChange sc = stageWipChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
     removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void deleteReviewerByEmailFromWipChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void deleteReviewerByEmailFromWipChange() throws Exception {
     StagedChange sc = stageWipChangeWithExtraReviewer();
     gApi.changes().id(sc.changeId).reviewer(sc.reviewerByEmail).remove();
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   private void recommend(StagedChange sc, TestAccount by) throws Exception {
-    setApiUser(by);
+    requestScopeOperations.setApiUser(by.id());
     gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.recommend());
   }
 
@@ -1202,15 +1267,18 @@
     StagedChange sc = stager.stage();
     ReviewInput in =
         ReviewInput.noScore()
-            .reviewer(extraReviewer.email)
-            .reviewer(extraCcer.email, ReviewerState.CC, false);
-    setApiUser(extraReviewer);
+            .reviewer(extraReviewer.email())
+            .reviewer(extraCcer.email(), ReviewerState.CC, false);
+    requestScopeOperations.setApiUser(extraReviewer.id());
     gApi.changes().id(sc.changeId).revision("current").review(in);
+    sender.clear();
     return sc;
   }
 
   private StagedChange stageReviewableChangeWithExtraReviewer() throws Exception {
-    return stageChangeWithExtraReviewer(this::stageReviewableChange);
+    StagedChange sc = stageChangeWithExtraReviewer(this::stageReviewableChange);
+    sender.clear();
+    return sc;
   }
 
   private StagedChange stageReviewableWipChangeWithExtraReviewer() throws Exception {
@@ -1218,12 +1286,14 @@
   }
 
   private StagedChange stageWipChangeWithExtraReviewer() throws Exception {
-    return stageChangeWithExtraReviewer(this::stageWipChange);
+    StagedChange sc = stageChangeWithExtraReviewer(this::stageWipChange);
+    assertThat(sender).didNotSend();
+    return sc;
   }
 
   private void removeReviewer(StagedChange sc, TestAccount account) throws Exception {
     sender.clear();
-    gApi.changes().id(sc.changeId).reviewer(account.email).remove();
+    gApi.changes().id(sc.changeId).reviewer(account.email()).remove();
   }
 
   private void removeReviewer(StagedChange sc, TestAccount account, NotifyHandling notify)
@@ -1231,7 +1301,7 @@
     sender.clear();
     DeleteReviewerInput in = new DeleteReviewerInput();
     in.notify = notify;
-    gApi.changes().id(sc.changeId).reviewer(account.email).remove(in);
+    gApi.changes().id(sc.changeId).reviewer(account.email()).remove(in);
   }
 
   /*
@@ -1242,7 +1312,7 @@
   public void deleteVoteFromReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     deleteVote(sc, extraReviewer);
     assertThat(sender)
         .sent("deleteVote", sc)
@@ -1251,6 +1321,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1258,7 +1329,7 @@
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
     setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     deleteVote(sc, extraReviewer);
     assertThat(sender)
         .sent("deleteVote", sc)
@@ -1268,13 +1339,14 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteVoteFromReviewableChangeByAdmin() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     deleteVote(sc, extraReviewer);
     assertThat(sender)
         .sent("deleteVote", sc)
@@ -1284,6 +1356,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1291,7 +1364,7 @@
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
     setEmailStrategy(admin, EmailStrategy.CC_ON_OWN_COMMENTS);
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     deleteVote(sc, extraReviewer);
     assertThat(sender)
         .sent("deleteVote", sc)
@@ -1301,19 +1374,21 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteVoteFromReviewableChangeNotifyOwnerReviewers() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     deleteVote(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
     assertThat(sender)
         .sent("deleteVote", sc)
         .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1321,7 +1396,7 @@
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
     setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     deleteVote(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
     assertThat(sender)
         .sent("deleteVote", sc)
@@ -1329,24 +1404,26 @@
         .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteVoteFromReviewableChangeNotifyOwner() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     deleteVote(sc, extraReviewer, NotifyHandling.OWNER);
     assertThat(sender).sent("deleteVote", sc).to(sc.owner).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteVoteFromReviewableChangeNotifyNone() throws Exception {
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     deleteVote(sc, extraReviewer, NotifyHandling.NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1354,16 +1431,16 @@
     StagedChange sc = stageReviewableChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
     setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     deleteVote(sc, extraReviewer, NotifyHandling.NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteVoteFromReviewableWipChange() throws Exception {
     StagedChange sc = stageReviewableWipChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     deleteVote(sc, extraReviewer);
     assertThat(sender)
         .sent("deleteVote", sc)
@@ -1372,13 +1449,14 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void deleteVoteFromWipChange() throws Exception {
     StagedChange sc = stageWipChangeWithExtraReviewer();
     recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     deleteVote(sc, extraReviewer);
     assertThat(sender)
         .sent("deleteVote", sc)
@@ -1387,11 +1465,12 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   private void deleteVote(StagedChange sc, TestAccount account) throws Exception {
     sender.clear();
-    gApi.changes().id(sc.changeId).reviewer(account.email).deleteVote("Code-Review");
+    gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote("Code-Review");
   }
 
   private void deleteVote(StagedChange sc, TestAccount account, NotifyHandling notify)
@@ -1400,7 +1479,7 @@
     DeleteVoteInput in = new DeleteVoteInput();
     in.label = "Code-Review";
     in.notify = notify;
-    gApi.changes().id(sc.changeId).reviewer(account.email).deleteVote(in);
+    gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote(in);
   }
 
   /*
@@ -1408,16 +1487,49 @@
    */
 
   @Test
-  public void mergeByOwner() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    merge(sc.changeId, sc.owner);
-    assertThat(sender)
-        .sent("merged", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
-        .noOneElse();
+  public void mergeByOwnerAllSubmitStrategies() throws Exception {
+    mergeByOwnerAllSubmitStrategies(false);
+  }
+
+  @Test
+  public void mergeByOwnerAllSubmitStrategiesWithAdvancingBranch() throws Exception {
+    mergeByOwnerAllSubmitStrategies(true);
+  }
+
+  private void mergeByOwnerAllSubmitStrategies(boolean advanceBranchBeforeSubmitting)
+      throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        u.getConfig().getProject().setSubmitType(submitType);
+        u.save();
+      }
+
+      StagedChange sc = stageChangeReadyForMerge();
+
+      String name = submitType + " sender";
+      if (advanceBranchBeforeSubmitting) {
+        if (submitType == SubmitType.FAST_FORWARD_ONLY) {
+          continue;
+        }
+        try (Repository repo = repoManager.openRepository(project);
+            TestRepository<Repository> tr = new TestRepository<>(repo)) {
+          tr.branch("master").commit().create();
+        }
+        name += " after branch has advanced";
+      }
+
+      merge(sc.changeId, sc.owner);
+      assertWithMessage(name)
+          .about(fakeEmailSenders())
+          .that(sender)
+          .sent("merged", sc)
+          .cc(sc.reviewer, sc.ccer)
+          .cc(sc.reviewerByEmail, sc.ccerByEmail)
+          .bcc(sc.starrer)
+          .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+          .noOneElse();
+      assertWithMessage(name).about(fakeEmailSenders()).that(sender).didNotSend();
+    }
   }
 
   @Test
@@ -1432,6 +1544,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1446,6 +1559,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1460,6 +1574,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1472,6 +1587,7 @@
         .cc(sc.reviewer, sc.ccer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1479,6 +1595,7 @@
     StagedChange sc = stageChangeReadyForMerge();
     merge(sc.changeId, other, OWNER);
     assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1487,13 +1604,14 @@
     setEmailStrategy(other, EmailStrategy.CC_ON_OWN_COMMENTS);
     merge(sc.changeId, other, OWNER);
     assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void mergeByOtherNotifyNone() throws Exception {
     StagedChange sc = stageChangeReadyForMerge();
     merge(sc.changeId, other, NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1501,7 +1619,7 @@
     StagedChange sc = stageChangeReadyForMerge();
     setEmailStrategy(other, EmailStrategy.CC_ON_OWN_COMMENTS);
     merge(sc.changeId, other, NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   private void merge(String changeId, TestAccount by) throws Exception {
@@ -1511,7 +1629,7 @@
   private void merge(String changeId, TestAccount by, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
+    requestScopeOperations.setApiUser(by.id());
     gApi.changes().id(changeId).revision("current").submit();
   }
 
@@ -1523,7 +1641,7 @@
       String changeId, TestAccount by, EmailStrategy emailStrategy, NotifyHandling notify)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
+    requestScopeOperations.setApiUser(by.id());
     SubmitInput in = new SubmitInput();
     in.notify = notify;
     gApi.changes().id(changeId).revision("current").submit(in);
@@ -1531,7 +1649,7 @@
 
   private StagedChange stageChangeReadyForMerge() throws Exception {
     StagedChange sc = stageReviewableChange();
-    setApiUser(sc.reviewer);
+    requestScopeOperations.setApiUser(sc.reviewer.id());
     gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
     sender.clear();
     return sc;
@@ -1542,8 +1660,7 @@
    */
 
   @Test
-  public void newPatchSetByOwnerOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void newPatchSetByOwnerOnReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChange();
     pushTo(sc, "refs/for/master", sc.owner);
     assertThat(sender)
@@ -1554,24 +1671,11 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void newPatchSetByOwnerOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void newPatchSetByOtherOnReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChange();
     pushTo(sc, "refs/for/master", other);
     assertThat(sender)
@@ -1583,25 +1687,11 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void newPatchSetByOtherOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master", other);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This email shouldn't come from the owner.
-        .to(sc.reviewer, sc.ccer, other)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCc() throws Exception {
     StagedChange sc = stageReviewableChange();
     pushTo(sc, "refs/for/master", other, EmailStrategy.CC_ON_OWN_COMMENTS);
     assertThat(sender)
@@ -1613,82 +1703,45 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master", other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .to(sc.reviewer, sc.ccer, other)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeNotifyOwnerReviewersInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void newPatchSetByOtherOnReviewableChangeNotifyOwnerReviewers() throws Exception {
     StagedChange sc = stageReviewableChange();
     pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other);
     assertThat(sender)
         .sent("newpatchset", sc)
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer)
+        .to(other)
         .cc(sc.ccer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void newPatchSetByOtherOnReviewableChangeNotifyOwnerReviewersInReviewDb()
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewers()
       throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .to(sc.reviewer, sc.ccer)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInNoteDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     StagedChange sc = stageReviewableChange();
     pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other, EmailStrategy.CC_ON_OWN_COMMENTS);
     assertThat(sender)
         .sent("newpatchset", sc)
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer)
+        .to(other)
         .cc(sc.ccer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInReviewDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void newPatchSetByOtherOnReviewableChangeNotifyOwner() throws Exception {
     StagedChange sc = stageReviewableChange();
     pushTo(sc, "refs/for/master%notify=OWNER", other);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1697,7 +1750,7 @@
     pushTo(sc, "refs/for/master%notify=OWNER", other, EmailStrategy.CC_ON_OWN_COMMENTS);
     // TODO(logan): This email shouldn't come from the owner, and that's why
     // no email is currently sent (owner isn't CCing self).
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -1706,33 +1759,32 @@
     pushTo(sc, "refs/for/master%notify=NONE", other);
     // TODO(logan): This email shouldn't come from the owner, and that's why
     // no email is currently sent (owner isn't CCing self).
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyNone() throws Exception {
     StagedChange sc = stageReviewableChange();
     pushTo(sc, "refs/for/master%notify=NONE", other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void newPatchSetByOwnerOnReviewableChangeToWip() throws Exception {
     StagedChange sc = stageReviewableChange();
     pushTo(sc, "refs/for/master%wip", sc.owner);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void newPatchSetOnWipChange() throws Exception {
     StagedChange sc = stageWipChange();
     pushTo(sc, "refs/for/master%wip", sc.owner);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void newPatchSetOnWipChangeNotifyAllInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void newPatchSetOnWipChangeNotifyAll() throws Exception {
     StagedChange sc = stageWipChange();
     pushTo(sc, "refs/for/master%wip,notify=ALL", sc.owner);
     assertThat(sender)
@@ -1743,24 +1795,11 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void newPatchSetOnWipChangeNotifyAllInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%wip,notify=ALL", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeToReadyInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void newPatchSetOnWipChangeToReady() throws Exception {
     StagedChange sc = stageWipChange();
     pushTo(sc, "refs/for/master%ready", sc.owner);
     assertThat(sender)
@@ -1771,34 +1810,21 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeToReadyInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%ready", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void newPatchSetOnReviewableWipChange() throws Exception {
     StagedChange sc = stageReviewableWipChange();
     pushTo(sc, "refs/for/master%wip", sc.owner);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void newPatchSetOnReviewableChangeAddingReviewerInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void newPatchSetOnReviewableChangeAddingReviewer() throws Exception {
     StagedChange sc = stageReviewableChange();
     TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
+    pushTo(sc, "refs/for/master%r=" + newReviewer.username(), sc.owner);
     assertThat(sender)
         .sent("newpatchset", sc)
         .to(sc.reviewer, newReviewer)
@@ -1807,39 +1833,22 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnReviewableChangeAddingReviewerInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer, newReviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void newPatchSetOnWipChangeAddingReviewer() throws Exception {
     StagedChange sc = stageWipChange();
     TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
-    assertThat(sender).notSent();
+    pushTo(sc, "refs/for/master%r=" + newReviewer.username(), sc.owner);
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void newPatchSetOnWipChangeAddingReviewerNotifyAllInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void newPatchSetOnWipChangeAddingReviewerNotifyAll() throws Exception {
     StagedChange sc = stageWipChange();
     TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%notify=ALL,r=" + newReviewer.username, sc.owner);
+    pushTo(sc, "refs/for/master%notify=ALL,r=" + newReviewer.username(), sc.owner);
     assertThat(sender)
         .sent("newpatchset", sc)
         .to(sc.reviewer, newReviewer)
@@ -1848,28 +1857,11 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void newPatchSetOnWipChangeAddingReviewerNotifyAllInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%notify=ALL,r=" + newReviewer.username, sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer, newReviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeSettingReadyInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void newPatchSetOnWipChangeSettingReady() throws Exception {
     StagedChange sc = stageWipChange();
     pushTo(sc, "refs/for/master%ready", sc.owner);
     assertThat(sender)
@@ -1880,22 +1872,7 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeSettingReadyInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%ready", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   private void pushTo(StagedChange sc, String ref, TestAccount by) throws Exception {
@@ -1905,12 +1882,11 @@
   private void pushTo(StagedChange sc, String ref, TestAccount by, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    pushFactory.create(db, by.getIdent(), sc.repo, sc.changeId).to(ref).assertOkStatus();
+    pushFactory.create(by.newIdent(), sc.repo, sc.changeId).to(ref).assertOkStatus();
   }
 
   @Test
-  public void editCommitMessageEditByOwnerOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void editCommitMessageEditByOwnerOnReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, sc.owner);
     assertThat(sender)
@@ -1921,24 +1897,11 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void editCommitMessageEditByOwnerOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageEditByOtherOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void editCommitMessageEditByOtherOnReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, other);
     assertThat(sender)
@@ -1949,24 +1912,11 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void editCommitMessageEditByOtherOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCc() throws Exception {
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
     assertThat(sender)
@@ -1977,25 +1927,11 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer, other)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewersInNoteDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewers() throws Exception {
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, other, OWNER_REVIEWERS);
     assertThat(sender)
@@ -2004,21 +1940,12 @@
         .cc(sc.ccer)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewersInReviewDb()
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewers()
       throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, OWNER_REVIEWERS);
-    assertThat(sender).sent("newpatchset", sc).to(sc.owner, sc.reviewer, sc.ccer).noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInNoteDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, other, OWNER_REVIEWERS, CC_ON_OWN_COMMENTS);
     assertThat(sender)
@@ -2027,19 +1954,7 @@
         .cc(sc.ccer, other)
         .cc(sc.reviewerByEmail, sc.ccerByEmail)
         .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInReviewDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, OWNER_REVIEWERS, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer)
-        .cc(other)
-        .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2047,6 +1962,7 @@
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, other, OWNER);
     assertThat(sender).sent("newpatchset", sc).to(sc.owner).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2054,27 +1970,28 @@
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, other, OWNER, CC_ON_OWN_COMMENTS);
     assertThat(sender).sent("newpatchset", sc).to(sc.owner).cc(other).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void editCommitMessageByOtherOnReviewableChangeNotifyNone() throws Exception {
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, other, NONE);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyNone() throws Exception {
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, other, NONE, CC_ON_OWN_COMMENTS);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void editCommitMessageOnWipChange() throws Exception {
     StagedChange sc = stageWipChange();
     editCommitMessage(sc, sc.owner);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2082,6 +1999,7 @@
     StagedChange sc = stageWipChange();
     editCommitMessage(sc, other);
     assertThat(sender).sent("newpatchset", sc).to(sc.owner).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2089,11 +2007,11 @@
     StagedChange sc = stageWipChange();
     editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
     assertThat(sender).sent("newpatchset", sc).to(sc.owner).cc(other).noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void editCommitMessageOnWipChangeNotifyAllInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void editCommitMessageOnWipChangeNotifyAll() throws Exception {
     StagedChange sc = stageWipChange();
     editCommitMessage(sc, sc.owner, ALL);
     assertThat(sender)
@@ -2104,19 +2022,7 @@
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageOnWipChangeNotifyAllInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    editCommitMessage(sc, sc.owner, ALL);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   private void editCommitMessage(StagedChange sc, TestAccount by) throws Exception {
@@ -2159,6 +2065,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2172,6 +2079,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2185,6 +2093,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2199,6 +2108,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2213,6 +2123,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2227,6 +2138,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   private void restore(String changeId, TestAccount by) throws Exception {
@@ -2236,7 +2148,7 @@
   private void restore(String changeId, TestAccount by, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
+    requestScopeOperations.setApiUser(by.id());
     gApi.changes().id(changeId).restore();
   }
 
@@ -2245,31 +2157,7 @@
    */
 
   @Test
-  public void revertChangeByOwnerInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageChange();
-    revert(sc, sc.owner);
-
-    // email for the newly created revert change
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse();
-
-    // email for the change that is reverted
-    assertThat(sender)
-        .sent("revert", sc)
-        .cc(sc.reviewer, sc.ccer, admin)
-        .cc(sc.reviewerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void revertChangeByOwnerInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void revertChangeByOwner() throws Exception {
     StagedChange sc = stageChange();
     revert(sc, sc.owner);
 
@@ -2289,36 +2177,11 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void revertChangeByOwnerCcingSelfInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageChange();
-    revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
-
-    // email for the newly created revert change
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
-        .cc(sc.owner)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse();
-
-    // email for the change that is reverted
-    assertThat(sender)
-        .sent("revert", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, admin)
-        .cc(sc.reviewerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void revertChangeByOwnerCcingSelfInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void revertChangeByOwnerCcingSelf() throws Exception {
     StagedChange sc = stageChange();
     revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
 
@@ -2339,35 +2202,11 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void revertChangeByOtherInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageChange();
-    revert(sc, other);
-
-    // email for the newly created revert change
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse();
-
-    // email for the change that is reverted
-    assertThat(sender)
-        .sent("revert", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, admin)
-        .cc(sc.reviewerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void revertChangeByOtherInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void revertChangeByOther() throws Exception {
     StagedChange sc = stageChange();
     revert(sc, other);
 
@@ -2388,36 +2227,11 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void revertChangeByOtherCcingSelfInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageChange();
-    revert(sc, other, CC_ON_OWN_COMMENTS);
-
-    // email for the newly created revert change
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
-        .cc(other)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse();
-
-    // email for the change that is reverted
-    assertThat(sender)
-        .sent("revert", sc)
-        .to(sc.owner)
-        .cc(other, sc.reviewer, sc.ccer, admin)
-        .cc(sc.reviewerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void revertChangeByOtherCcingSelfInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void revertChangeByOtherCcingSelf() throws Exception {
     StagedChange sc = stageChange();
     revert(sc, other, CC_ON_OWN_COMMENTS);
 
@@ -2438,11 +2252,12 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   private StagedChange stageChange() throws Exception {
     StagedChange sc = stageReviewableChange();
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
     gApi.changes().id(sc.changeId).revision("current").submit();
     sender.clear();
@@ -2456,7 +2271,7 @@
   private void revert(StagedChange sc, TestAccount by, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
+    requestScopeOperations.setApiUser(by.id());
     gApi.changes().id(sc.changeId).revert();
   }
 
@@ -2473,6 +2288,7 @@
         .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
         .to(sc.assignee)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2485,6 +2301,7 @@
         .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
         .to(sc.assignee)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2496,6 +2313,7 @@
         .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
         .to(sc.assignee)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2508,25 +2326,18 @@
         .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
         .to(sc.assignee)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void setAssigneeToSelfOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void setAssigneeToSelfOnReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChange();
     assign(sc, sc.owner, sc.owner);
     assertThat(sender)
         .sent("setassignee", sc)
         .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
         .noOneElse();
-  }
-
-  @Test
-  public void setAssigneeToSelfOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.owner);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2541,11 +2352,11 @@
         .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
         .to(sc.assignee)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
-  public void changeAssigneeToSelfOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void changeAssigneeToSelfOnReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChange();
     assign(sc, sc.owner, sc.assignee);
     sender.clear();
@@ -2554,16 +2365,7 @@
         .sent("setassignee", sc)
         .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
         .noOneElse();
-  }
-
-  @Test
-  public void changeAssigneeToSelfOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.assignee);
-    sender.clear();
-    assign(sc, sc.owner, sc.owner);
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2575,6 +2377,7 @@
         .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
         .to(sc.assignee)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2586,6 +2389,7 @@
         .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
         .to(sc.assignee)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   private void assign(StagedChange sc, TestAccount by, TestAccount to) throws Exception {
@@ -2595,9 +2399,9 @@
   private void assign(StagedChange sc, TestAccount by, TestAccount to, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
+    requestScopeOperations.setApiUser(by.id());
     AssigneeInput in = new AssigneeInput();
-    in.assignee = to.email;
+    in.assignee = to.email();
     gApi.changes().id(sc.changeId).setAssignee(in);
   }
 
@@ -2616,6 +2420,7 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
@@ -2631,19 +2436,25 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    assertThat(sender).didNotSend();
   }
 
   @Test
   public void setWorkInProgress() throws Exception {
     StagedChange sc = stageReviewableChange();
     gApi.changes().id(sc.changeId).setWorkInProgress();
-    assertThat(sender).notSent();
+    assertThat(sender).didNotSend();
   }
 
   private void startReview(StagedChange sc) throws Exception {
-    setApiUser(sc.owner);
+    requestScopeOperations.setApiUser(sc.owner.id());
     gApi.changes().id(sc.changeId).setReadyForReview();
-    // PolyGerrit current immediately follows up with a review.
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.noScore());
+  }
+
+  private void setWorkInProgressByDefault(Project.NameKey p, InheritableBoolean v)
+      throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.workInProgressByDefault = v;
+    gApi.projects().name(p.get()).config(input);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index 438954c..13f0416 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.server.mail.MailUtil;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
 import com.google.inject.Inject;
 import java.time.ZoneId;
@@ -100,7 +100,7 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index 3315a33..1386aec 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -20,12 +20,15 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.server.mail.MailUtil;
-import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.mail.MailProcessingUtil;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
 import java.sql.Timestamp;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
@@ -40,6 +43,8 @@
 
 /** Tests the presence of required metadata in email headers, text and html. */
 public class MailMetadataIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   private String systemTimeZone;
 
   @Before
@@ -57,13 +62,13 @@
   @Test
   public void metadataOnNewChange() throws Exception {
     PushOneCommit.Result newChange = createChange();
-    gApi.changes().id(newChange.getChangeId()).addReviewer(user.getId().toString());
+    gApi.changes().id(newChange.getChangeId()).addReviewer(user.id().toString());
 
     List<FakeEmailSender.Message> emails = sender.getMessages();
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
 
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
@@ -84,14 +89,14 @@
   @Test
   public void metadataOnNewComment() throws Exception {
     PushOneCommit.Result newChange = createChange();
-    gApi.changes().id(newChange.getChangeId()).addReviewer(user.getId().toString());
+    gApi.changes().id(newChange.getChangeId()).addReviewer(user.id().toString());
     sender.clear();
 
     // Review change
     ReviewInput input = new ReviewInput();
     input.message = "Test";
     revision(newChange).review(input);
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     Collection<ChangeMessageInfo> result =
         gApi.changes().id(newChange.getChangeId()).get().messages;
     assertThat(result).isNotEmpty();
@@ -100,7 +105,7 @@
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
     expectedHeaders.put(
@@ -147,7 +152,7 @@
             .contains(
                 entry.getKey()
                     + ": "
-                    + MailUtil.rfcDateformatter.format(
+                    + MailProcessingUtil.rfcDateformatter.format(
                         ZonedDateTime.ofInstant(
                             ((Timestamp) entry.getValue()).toInstant(), ZoneId.of("UTC"))));
       } else {
@@ -159,4 +164,12 @@
       }
     }
   }
+
+  private String getChangeUrl(ChangeData changeData) {
+    return canonicalWebUrl.get()
+        + "c/"
+        + changeData.project().get()
+        + "/+/"
+        + changeData.getId().get();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index f34fe33..6e14635 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -16,23 +16,61 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.server.mail.MailUtil;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
+import com.google.inject.Module;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Collection;
 import java.util.List;
+import org.easymock.EasyMock;
+import org.junit.Before;
 import org.junit.Test;
 
 public class MailProcessorIT extends AbstractMailIT {
   @Inject private MailProcessor mailProcessor;
+  @Inject private AccountOperations accountOperations;
+  @Inject private CommentValidator mockCommentValidator;
+  @Inject private TestCommentHelper testCommentHelper;
+
+  private static final String COMMENT_TEXT = "The comment text";
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        CommentValidator mockCommentValidator = EasyMock.createMock(CommentValidator.class);
+        bind(CommentValidator.class)
+            .annotatedWith(Exports.named(mockCommentValidator.getClass()))
+            .toInstance(mockCommentValidator);
+        bind(CommentValidator.class).toInstance(mockCommentValidator);
+      }
+    };
+  }
+
+  @Before
+  public void setUp() {
+    // Let the mock comment validator accept all comments during test setup.
+    EasyMock.reset(mockCommentValidator);
+    EasyMock.expect(mockCommentValidator.validateComments(EasyMock.anyObject()))
+        .andReturn(ImmutableList.of());
+    EasyMock.replay(mockCommentValidator);
+  }
 
   @Test
   public void parseAndPersistChangeMessage() throws Exception {
@@ -40,18 +78,13 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            "Test Message",
-            null,
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -68,18 +101,13 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            null,
-            "Some Inline Comment",
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -104,18 +132,14 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
         newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            null,
-            null,
-            "Some Comment on File 1",
-            null);
+            getChangeUrl(changeInfo) + "/1", null, null, "Some Comment on File 1", null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -141,18 +165,13 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            null,
-            "Some Inline Comment",
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -171,32 +190,24 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
     assertThat(comments).hasSize(2);
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            null,
-            "Some Inline Comment",
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     // Set account state to inactive
-    gApi.accounts().id("user").setActive(false);
+    accountOperations.account(user.id()).forUpdate().inactive().update();
 
     mailProcessor.process(b.build());
     comments = gApi.changes().id(changeId).current().commentsAsList();
 
     // Check that comment size has not changed
     assertThat(comments).hasSize(2);
-
-    // Reset
-    gApi.accounts().id("user").setActive(true);
   }
 
   @Test
@@ -206,20 +217,15 @@
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     assertThat(comments).hasSize(2);
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            "Test Message",
-            null,
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null, null);
     MailMessage.Builder b =
         messageBuilderWithDefaultFields()
-            .from(user.emailAddress)
+            .from(user.getEmailAddress())
             .textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     sender.clear();
@@ -238,15 +244,10 @@
 
     // Build Message
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            "Test Message",
-            null,
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null, null);
     MailMessage.Builder b =
         messageBuilderWithDefaultFields()
-            .from(user.emailAddress)
+            .from(user.getEmailAddress())
             .textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     sender.clear();
@@ -257,4 +258,110 @@
     assertThat(message.body()).contains("was unable to parse your email");
     assertThat(message.headers()).containsKey("Subject");
   }
+
+  @Test
+  public void validateChangeMessage_rejected() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    EasyMock.reset(mockCommentValidator);
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentForValidation.CommentType.CHANGE_MESSAGE, COMMENT_TEXT);
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.CHANGE_MESSAGE, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    EasyMock.replay(mockCommentValidator);
+
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", COMMENT_TEXT, null, null, null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    mailProcessor.process(b.build());
+    assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+    EasyMock.verify(mockCommentValidator);
+  }
+
+  @Test
+  public void validateInlineComment_rejected() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    EasyMock.reset(mockCommentValidator);
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentForValidation.CommentType.CHANGE_MESSAGE, COMMENT_TEXT);
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.INLINE_COMMENT, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    EasyMock.replay(mockCommentValidator);
+
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, COMMENT_TEXT, null, null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    mailProcessor.process(b.build());
+    assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+    EasyMock.verify(mockCommentValidator);
+  }
+
+  @Test
+  public void validateFileComment_rejected() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    EasyMock.reset(mockCommentValidator);
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(CommentForValidation.CommentType.CHANGE_MESSAGE, COMMENT_TEXT);
+    EasyMock.expect(
+            mockCommentValidator.validateComments(
+                ImmutableList.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
+        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+    EasyMock.replay(mockCommentValidator);
+
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, COMMENT_TEXT, null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    mailProcessor.process(b.build());
+    assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+    EasyMock.verify(mockCommentValidator);
+  }
+
+  private String getChangeUrl(ChangeInfo changeInfo) {
+    return canonicalWebUrl.get() + "c/" + changeInfo.project + "/+/" + changeInfo._number;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index 4f51e1f..c395c81 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.mail.EmailHeader;
 import java.net.URI;
 import java.util.Map;
 import org.junit.Test;
@@ -41,7 +41,7 @@
     // Check that the user's email was added as Reply-To
     assertThat(sender.getMessages()).hasSize(1);
     Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
-    assertThat(headerString(headers, "Reply-To")).contains(user.email);
+    assertThat(headerString(headers, "Reply-To")).contains(user.email());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
index c8292ba..628b90c 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
@@ -18,24 +18,27 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
 import com.google.gerrit.testing.FakeEmailSender;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 public class NotificationMailFormatIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void userReceivesPlaintextEmail() throws Exception {
     // Set user preference to receive only plaintext content
     GeneralPreferencesInfo i = new GeneralPreferencesInfo();
     i.emailFormat = EmailFormat.PLAINTEXT;
-    gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+    gApi.accounts().id(admin.id().toString()).setPreferences(i);
 
     // Create change as admin and review as user
     PushOneCommit.Result r = createChange();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
 
     // Check that admin has received only plaintext content
@@ -43,20 +46,20 @@
     FakeEmailSender.Message m = sender.getMessages().get(0);
     assertThat(m.body()).isNotNull();
     assertThat(m.htmlBody()).isNull();
-    assertMailReplyTo(m, admin.email);
-    assertMailReplyTo(m, user.email);
+    assertMailReplyTo(m, admin.email());
+    assertMailReplyTo(m, user.email());
 
     // Reset user preference
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     i.emailFormat = EmailFormat.HTML_PLAINTEXT;
-    gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+    gApi.accounts().id(admin.id().toString()).setPreferences(i);
   }
 
   @Test
   public void userReceivesHtmlAndPlaintextEmail() throws Exception {
     // Create change as admin and review as user
     PushOneCommit.Result r = createChange();
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
 
     // Check that admin has received both HTML and plaintext content
@@ -64,7 +67,7 @@
     FakeEmailSender.Message m = sender.getMessages().get(0);
     assertThat(m.body()).isNotNull();
     assertThat(m.htmlBody()).isNotNull();
-    assertMailReplyTo(m, admin.email);
-    assertMailReplyTo(m, user.email);
+    assertMailReplyTo(m, admin.email());
+    assertMailReplyTo(m, user.email());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/BUILD b/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
index 4965402..ee3bcb0 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
@@ -7,8 +7,8 @@
         "notedb",
         "server",
     ],
-    # TODO(dborowitz): Fix leaks in local disk tests so we can reduce heap size.
-    # http://crbug.com/gerrit/8567
-    vm_args = ["-Xmx1024m"],
-    deps = ["//java/com/google/gerrit/server/schema"],
+    deps = [
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/server/util/time",
+    ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
deleted file mode 100644
index ffff121..0000000
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ /dev/null
@@ -1,1598 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.DAYS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-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.PatchSetApproval;
-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.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.git.RepoRefCache;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.project.testing.Util;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.gerrit.server.restapi.change.Rebuild;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.NoteDbChecker;
-import com.google.gerrit.testing.NoteDbMode;
-import com.google.gerrit.testing.TestChanges;
-import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Stream;
-import org.apache.http.Header;
-import org.apache.http.message.BasicHeader;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ChangeRebuilderIT extends AbstractDaemonTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-    cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
-
-    // Disable async reindex-if-stale check after index update. This avoids
-    // unintentional auto-rebuilding of the change in NoteDb during the read
-    // path of the reindex-if-stale check. For the purposes of this test, we
-    // want precise control over when auto-rebuilding happens.
-    cfg.setBoolean("index", null, "autoReindexIfStale", false);
-
-    // setNotesMigration tries to keep IDs in sync between ReviewDb and NoteDb, which is behavior
-    // unique to this test. This gets prohibitively slow if we use the default sequence gap.
-    cfg.setInt("noteDb", "changes", "initialSequenceGap", 0);
-
-    return cfg;
-  }
-
-  @Inject private NoteDbChecker checker;
-
-  @Inject private Rebuild rebuildHandler;
-
-  @Inject private Provider<ReviewDb> dbProvider;
-
-  @Inject private CommentsUtil commentsUtil;
-
-  @Inject private Provider<PostReview> postReview;
-
-  @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
-
-  @Inject private Sequences seq;
-
-  @Inject private ChangeBundleReader bundleReader;
-
-  @Inject private PatchSetInfoFactory patchSetInfoFactory;
-
-  @Inject private PatchListCache patchListCache;
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    setNotesMigration(false, false);
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @SuppressWarnings("deprecation")
-  private void setNotesMigration(boolean writeChanges, boolean readChanges) throws Exception {
-    notesMigration.setWriteChanges(writeChanges);
-    notesMigration.setReadChanges(readChanges);
-    db = atrScope.reopenDb().getReviewDbProvider().get();
-
-    if (notesMigration.readChangeSequence()) {
-      // Copy next ReviewDb ID to NoteDb.
-      seq.getChangeIdRepoSequence().set(db.nextChangeId());
-    } else {
-      // Copy next NoteDb ID to ReviewDb.
-      while (db.nextChangeId() < seq.getChangeIdRepoSequence().next()) {}
-    }
-  }
-
-  @Test
-  public void changeFields() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void patchSets() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    r = amendChange(r.getChangeId());
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void publishedComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putComment(user, id, 1, "comment", null);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void publishedCommentAndReply() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putComment(user, id, 1, "comment", null);
-    Map<String, List<CommentInfo>> comments = getPublishedComments(id);
-    String parentUuid = comments.get("a.txt").get(0).id;
-    putComment(user, id, 1, "comment", parentUuid);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void patchSetWithNullGroups() throws Exception {
-    Timestamp ts = TimeUtil.nowTs();
-    Change c = TestChanges.newChange(project, user.getId(), seq.nextChangeId());
-    c.setCreatedOn(ts);
-    c.setLastUpdatedOn(ts);
-    c.setReviewStarted(true);
-    PatchSet ps =
-        TestChanges.newPatchSet(
-            c.currentPatchSetId(), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", user.getId());
-    ps.setCreatedOn(ts);
-    db.changes().insert(Collections.singleton(c));
-    db.patchSets().insert(Collections.singleton(ps));
-
-    assertThat(ps.getGroups()).isEmpty();
-    checker.rebuildAndCheckChanges(c.getId());
-  }
-
-  @Test
-  public void draftComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment", null);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void draftAndPublishedComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "draft comment", null);
-    putComment(user, id, 1, "published comment", null);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void publishDraftComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "draft comment", null);
-    publishDrafts(user, id);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void nullAccountId() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-
-    // Events need to be otherwise identical for the account ID to be compared.
-    ChangeMessage msg1 = insertMessage(id, psId, user.getId(), TimeUtil.nowTs(), "message 1");
-    insertMessage(id, psId, null, msg1.getWrittenOn(), "message 2");
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void nullPatchSetId() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId1 = r.getPatchSetId();
-    Change.Id id = psId1.getParentKey();
-
-    // Events need to be otherwise identical for the PatchSet.ID to be compared.
-    ChangeMessage msg1 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 1");
-    insertMessage(id, null, user.getId(), msg1.getWrittenOn(), "message 2");
-
-    PatchSet.Id psId2 = amendChange(r.getChangeId()).getPatchSetId();
-
-    ChangeMessage msg3 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 3");
-    insertMessage(id, null, user.getId(), msg3.getWrittenOn(), "message 4");
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    Map<String, PatchSet.Id> psIds = new HashMap<>();
-    for (ChangeMessage msg : notes.getChangeMessages()) {
-      PatchSet.Id psId = msg.getPatchSetId();
-      assertThat(psId).named("patchset for " + msg).isNotNull();
-      psIds.put(msg.getMessage(), psId);
-    }
-    // Patch set IDs were replaced during conversion process.
-    assertThat(psIds).containsEntry("message 1", psId1);
-    assertThat(psIds).containsEntry("message 2", psId1);
-    assertThat(psIds).containsEntry("message 3", psId2);
-    assertThat(psIds).containsEntry("message 4", psId2);
-  }
-
-  @Test
-  public void noWriteToNewRef() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    checker.assertNoChangeRef(project, id);
-
-    setNotesMigration(true, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-
-    // First write doesn't create the ref, but rebuilding works.
-    checker.assertNoChangeRef(project, id);
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNull();
-    checker.rebuildAndCheckChanges(id);
-
-    // Now that there is a ref, writes are "turned on" for this change, and
-    // NoteDb stays up to date without explicit rebuilding.
-    gApi.changes().id(id.get()).topic(name("new-topic"));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNotNull();
-    checker.checkChanges(id);
-  }
-
-  @Test
-  public void restApiNotFoundWhenNoteDbDisabled() throws Exception {
-    PushOneCommit.Result r = createChange();
-    exception.expect(ResourceNotFoundException.class);
-    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Input());
-  }
-
-  @Test
-  public void rebuildViaRestApi() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    setNotesMigration(true, false);
-
-    checker.assertNoChangeRef(project, id);
-    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Input());
-    checker.checkChanges(id);
-  }
-
-  @Test
-  public void writeToNewRefForNewChange() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    Change.Id id1 = r1.getPatchSetId().getParentKey();
-
-    setNotesMigration(true, false);
-    gApi.changes().id(id1.get()).topic(name("a-topic"));
-    PushOneCommit.Result r2 = createChange();
-    Change.Id id2 = r2.getPatchSetId().getParentKey();
-
-    // Second change was created after NoteDb writes were turned on, so it was
-    // allowed to write to a new ref.
-    checker.checkChanges(id2);
-
-    // First change was created before NoteDb writes were turned on, so its meta
-    // ref doesn't exist until a manual rebuild.
-    checker.assertNoChangeRef(project, id1);
-    checker.rebuildAndCheckChanges(id1);
-  }
-
-  @Test
-  public void noteDbChangeState() throws Exception {
-    setNotesMigration(true, true);
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-
-    ObjectId changeMetaId = getMetaRef(project, changeMetaRef(id));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(changeMetaId.name());
-
-    putDraft(user, id, 1, "comment by user", null);
-    ObjectId userDraftsId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
-        .isEqualTo(changeMetaId.name() + "," + user.getId() + "=" + userDraftsId.name());
-
-    putDraft(admin, id, 2, "comment by admin", null);
-    ObjectId adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId()));
-    assertThat(admin.getId().get()).isLessThan(user.getId().get());
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
-        .isEqualTo(
-            changeMetaId.name()
-                + ","
-                + admin.getId()
-                + "="
-                + adminDraftsId.name()
-                + ","
-                + user.getId()
-                + "="
-                + userDraftsId.name());
-
-    putDraft(admin, id, 2, "revised comment by admin", null);
-    adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId()));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
-        .isEqualTo(
-            changeMetaId.name()
-                + ","
-                + admin.getId()
-                + "="
-                + adminDraftsId.name()
-                + ","
-                + user.getId()
-                + "="
-                + userDraftsId.name());
-  }
-
-  @Test
-  public void rebuildAutomaticallyWhenChangeOutOfDate() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-
-    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
-    setNotesMigration(false, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    setInvalidNoteDbState(id);
-    assertChangeUpToDate(false, id);
-
-    // On next NoteDb read, the change is transparently rebuilt.
-    setNotesMigration(true, true);
-    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
-    assertChangeUpToDate(true, id);
-
-    // Check that the bundles are equal.
-    ChangeBundle actual =
-        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-  }
-
-  @Test
-  public void rebuildAutomaticallyWithinBatchUpdate() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    final Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-
-    // Update ReviewDb and NoteDb, then revert the corresponding NoteDb change
-    // to simulate it failing.
-    NoteDbChangeState oldState = NoteDbChangeState.parse(getUnwrappedDb().changes().get(id));
-    String topic = name("a-topic");
-    gApi.changes().id(id.get()).topic(topic);
-    try (Repository repo = repoManager.openRepository(project)) {
-      new TestRepository<>(repo).update(RefNames.changeMetaRef(id), oldState.getChangeMetaId());
-    }
-    assertChangeUpToDate(false, id);
-
-    // Next NoteDb read comes inside the transaction started by BatchUpdate. In
-    // reality this could be caused by a failed update happening between when
-    // the change is parsed by ChangesCollection and when the BatchUpdate
-    // executes. We simulate it here by using BatchUpdate directly and not going
-    // through an API handler.
-    final String msg = "message from BatchUpdate";
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
-      bu.addOp(
-          id,
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              PatchSet.Id psId = ctx.getChange().currentPatchSetId();
-              ChangeMessage cm =
-                  new ChangeMessage(
-                      new ChangeMessage.Key(id, ChangeUtil.messageUuid()),
-                      ctx.getAccountId(),
-                      ctx.getWhen(),
-                      psId);
-              cm.setMessage(msg);
-              ctx.getDb().changeMessages().insert(Collections.singleton(cm));
-              ctx.getUpdate(psId).setChangeMessage(msg);
-              return true;
-            }
-          });
-      try {
-        bu.execute();
-        fail("expected update to fail");
-      } catch (UpdateException e) {
-        assertThat(e.getMessage()).contains("cannot copy ChangeNotesState");
-      }
-    }
-
-    // TODO(dborowitz): Re-enable these assertions once we fix auto-rebuilding
-    // in the BatchUpdate path.
-    // As an implementation detail, change wasn't actually rebuilt inside the
-    // BatchUpdate transaction, but it was rebuilt during read for the
-    // subsequent reindex. Thus it's impossible to actually observe an
-    // out-of-date state in the caller.
-    // assertChangeUpToDate(true, id);
-
-    // Check that the bundles are equal.
-    // ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    // ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
-    // ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    // assertThat(actual.differencesFrom(expected)).isEmpty();
-    // assertThat(
-    //        Iterables.transform(
-    //            notes.getChangeMessages(),
-    //            ChangeMessage::getMessage))
-    //    .contains(msg);
-    // assertThat(actual.getChange().getTopic()).isEqualTo(topic);
-  }
-
-  @Test
-  public void rebuildIgnoresErrorIfChangeIsUpToDateAfter() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-
-    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
-    setNotesMigration(false, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    setInvalidNoteDbState(id);
-    assertChangeUpToDate(false, id);
-
-    // Force the next rebuild attempt to fail but also rebuild the change in the
-    // background.
-    rebuilderWrapper.stealNextUpdate();
-    setNotesMigration(true, true);
-    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
-    assertChangeUpToDate(true, id);
-
-    // Check that the bundles are equal.
-    ChangeBundle actual =
-        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-  }
-
-  @Test
-  public void rebuildReturnsCorrectResultEvenIfSavingToNoteDbFailed() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-    ObjectId oldMetaId = getMetaRef(project, changeMetaRef(id));
-
-    // Make a ReviewDb change behind NoteDb's back.
-    setNotesMigration(false, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    setInvalidNoteDbState(id);
-    assertChangeUpToDate(false, id);
-    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
-
-    // Force the next rebuild attempt to fail.
-    rebuilderWrapper.failNextUpdate();
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-
-    // Not up to date, but the actual returned state matches anyway.
-    assertChangeUpToDate(false, id);
-    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
-    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-    assertChangeUpToDate(false, id);
-
-    // Another rebuild attempt succeeds
-    notesFactory.create(dbProvider.get(), project, id);
-    assertThat(getMetaRef(project, changeMetaRef(id))).isNotEqualTo(oldMetaId);
-    assertChangeUpToDate(true, id);
-  }
-
-  @Test
-  public void rebuildReturnsDraftResultWhenRebuildingInChangeNotesFails() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment by user", null);
-    assertChangeUpToDate(true, id);
-
-    ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
-
-    // Add a draft behind NoteDb's back.
-    setNotesMigration(false, false);
-    putDraft(user, id, 1, "second comment by user", null);
-    setInvalidNoteDbState(id);
-    assertDraftsUpToDate(false, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
-
-    // Force the next rebuild attempt to fail (in ChangeNotes).
-    rebuilderWrapper.failNextUpdate();
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    notes.getDraftComments(user.getId());
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
-
-    // Not up to date, but the actual returned state matches anyway.
-    assertDraftsUpToDate(false, id, user);
-    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-
-    // Another rebuild attempt succeeds
-    notesFactory.create(dbProvider.get(), project, id);
-    assertChangeUpToDate(true, id);
-    assertDraftsUpToDate(true, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId);
-  }
-
-  @Test
-  public void rebuildReturnsDraftResultWhenRebuildingInDraftCommentNotesFails() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment by user", null);
-    assertChangeUpToDate(true, id);
-
-    ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
-
-    // Add a draft behind NoteDb's back.
-    setNotesMigration(false, false);
-    putDraft(user, id, 1, "second comment by user", null);
-
-    ReviewDb db = getUnwrappedDb();
-    Change c = db.changes().get(id);
-    // Leave change meta ID alone so DraftCommentNotes does the rebuild.
-    ObjectId badSha = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    NoteDbChangeState bogusState =
-        new NoteDbChangeState(
-            id,
-            PrimaryStorage.REVIEW_DB,
-            Optional.of(
-                NoteDbChangeState.RefState.create(
-                    NoteDbChangeState.parse(c).getChangeMetaId(),
-                    ImmutableMap.of(user.getId(), badSha))),
-            Optional.empty());
-    c.setNoteDbState(bogusState.toString());
-    db.changes().update(Collections.singleton(c));
-
-    assertDraftsUpToDate(false, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
-
-    // Force the next rebuild attempt to fail (in DraftCommentNotes).
-    rebuilderWrapper.failNextUpdate();
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    notes.getDraftComments(user.getId());
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
-
-    // Not up to date, but the actual returned state matches anyway.
-    assertChangeUpToDate(true, id);
-    assertDraftsUpToDate(false, id, user);
-    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-
-    // Another rebuild attempt succeeds
-    notesFactory.create(dbProvider.get(), project, id).getDraftComments(user.getId());
-    assertChangeUpToDate(true, id);
-    assertDraftsUpToDate(true, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId);
-  }
-
-  @Test
-  public void rebuildAutomaticallyWhenDraftsOutOfDate() throws Exception {
-    setNotesMigration(true, true);
-    setApiUser(user);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment", null);
-    assertDraftsUpToDate(true, id, user);
-
-    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
-    setNotesMigration(false, false);
-    putDraft(user, id, 1, "comment", null);
-    setInvalidNoteDbState(id);
-    assertDraftsUpToDate(false, id, user);
-
-    // On next NoteDb read, the drafts are transparently rebuilt.
-    setNotesMigration(true, true);
-    assertThat(gApi.changes().id(id.get()).current().drafts()).containsKey(PushOneCommit.FILE_NAME);
-    assertDraftsUpToDate(true, id, user);
-  }
-
-  @Test
-  public void pushCert() throws Exception {
-    // We don't have the code in our test harness to do signed pushes, so just
-    // use a hard-coded cert. This cert was actually generated by C git 2.2.0
-    // (albeit not for sending to Gerrit).
-    String cert =
-        "certificate version 0.1\n"
-            + "pusher Dave Borowitz <dborowitz@google.com> 1433954361 -0700\n"
-            + "pushee git://localhost/repo.git\n"
-            + "nonce 1433954361-bde756572d665bba81d8\n"
-            + "\n"
-            + "0000000000000000000000000000000000000000"
-            + "b981a177396fb47345b7df3e4d3f854c6bea7"
-            + "s/heads/master\n"
-            + "-----BEGIN PGP SIGNATURE-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
-            + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
-            + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
-            + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
-            + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
-            + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
-            + "=XFeC\n"
-            + "-----END PGP SIGNATURE-----\n";
-
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-
-    PatchSet ps = db.patchSets().get(psId);
-    ps.setPushCertificate(cert);
-    db.patchSets().update(Collections.singleton(ps));
-    indexer.index(db, project, id);
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void emptyTopic() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    Change c = db.changes().get(id);
-    assertThat(c.getTopic()).isNull();
-    c.setTopic("");
-    db.changes().update(Collections.singleton(c));
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-
-    // Rebuild and check was successful, but NoteDb doesn't support storing an
-    // empty topic, so it comes out as null.
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getChange().getTopic()).isNull();
-  }
-
-  @Test
-  public void commentBeforeFirstPatchSet() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-
-    Change c = db.changes().get(id);
-    c.setCreatedOn(new Timestamp(c.getCreatedOn().getTime() - 5000));
-    db.changes().update(Collections.singleton(c));
-    indexer.index(db, project, id);
-
-    ReviewInput rin = new ReviewInput();
-    rin.message = "comment";
-
-    Timestamp ts = new Timestamp(c.getCreatedOn().getTime() + 2000);
-    assertThat(ts).isGreaterThan(c.getCreatedOn());
-    assertThat(ts).isLessThan(db.patchSets().get(psId).getCreatedOn());
-    RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
-    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void commentPredatingChangeBySomeoneOtherThanOwner() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-    Change c = db.changes().get(id);
-
-    ReviewInput rin = new ReviewInput();
-    rin.message = "comment";
-
-    Timestamp ts = new Timestamp(c.getCreatedOn().getTime() - 10000);
-    RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
-    setApiUser(user);
-    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void noteDbUsesOriginalSubjectFromPatchSetAndIgnoresChangeField() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String orig = r.getChange().change().getSubject();
-    r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                testRepo,
-                orig + " v2",
-                PushOneCommit.FILE_NAME,
-                "new contents",
-                r.getChangeId())
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-    Change c = db.changes().get(id);
-
-    c.setCurrentPatchSet(psId, c.getSubject(), "Bogus original subject");
-    db.changes().update(Collections.singleton(c));
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    Change nc = notes.getChange();
-    assertThat(nc.getSubject()).isEqualTo(c.getSubject());
-    assertThat(nc.getSubject()).isEqualTo(orig + " v2");
-    assertThat(nc.getOriginalSubject()).isNotEqualTo(c.getOriginalSubject());
-    assertThat(nc.getOriginalSubject()).isEqualTo(orig);
-  }
-
-  @Test
-  public void ignorePatchLineCommentsOnPatchSet0() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change change = r.getChange().change();
-    Change.Id id = change.getId();
-
-    PatchLineComment comment =
-        new PatchLineComment(
-            new PatchLineComment.Key(
-                new Patch.Key(new PatchSet.Id(id, 0), PushOneCommit.FILE_NAME), "uuid"),
-            0,
-            user.getId(),
-            null,
-            TimeUtil.nowTs());
-    comment.setSide((short) 1);
-    comment.setMessage("message");
-    comment.setStatus(PatchLineComment.Status.PUBLISHED);
-    db.patchComments().insert(Collections.singleton(comment));
-    indexer.index(db, change.getProject(), id);
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getComments()).isEmpty();
-  }
-
-  @Test
-  public void leadingSpacesInSubject() throws Exception {
-    String subj = "   " + PushOneCommit.SUBJECT;
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            subj,
-            PushOneCommit.FILE_NAME,
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    Change change = r.getChange().change();
-    assertThat(change.getSubject()).isEqualTo(subj);
-    Change.Id id = r.getPatchSetId().getParentKey();
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getChange().getSubject()).isNotEqualTo(subj);
-    assertThat(notes.getChange().getSubject()).isEqualTo(PushOneCommit.SUBJECT);
-  }
-
-  @Test
-  public void allTimestampsExceptUpdatedAreEqualDueToBadMigration() throws Exception {
-    // https://bugs.chromium.org/p/gerrit/issues/detail?id=7397
-    PushOneCommit.Result r = createChange();
-    Change c = r.getChange().change();
-    Change.Id id = c.getId();
-    Timestamp ts = TimeUtil.nowTs();
-    Timestamp origUpdated = c.getLastUpdatedOn();
-
-    c.setCreatedOn(ts);
-    assertThat(c.getCreatedOn()).isGreaterThan(c.getLastUpdatedOn());
-    db.changes().update(Collections.singleton(c));
-
-    List<ChangeMessage> cm = db.changeMessages().byChange(id).toList();
-    cm.forEach(m -> m.setWrittenOn(ts));
-    db.changeMessages().update(cm);
-
-    List<PatchSet> ps = db.patchSets().byChange(id).toList();
-    ps.forEach(p -> p.setCreatedOn(ts));
-    db.patchSets().update(ps);
-
-    List<PatchSetApproval> psa = db.patchSetApprovals().byChange(id).toList();
-    psa.forEach(p -> p.setGranted(ts));
-    db.patchSetApprovals().update(psa);
-
-    List<PatchLineComment> plc = db.patchComments().byChange(id).toList();
-    plc.forEach(p -> p.setWrittenOn(ts));
-    db.patchComments().update(plc);
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getChange().getCreatedOn()).isEqualTo(origUpdated);
-    assertThat(notes.getChange().getLastUpdatedOn()).isAtLeast(origUpdated);
-    assertThat(notes.getPatchSets().get(new PatchSet.Id(id, 1)).getCreatedOn())
-        .isEqualTo(origUpdated);
-  }
-
-  @Test
-  public void createWithAutoRebuildingDisabled() throws Exception {
-    ReviewDb oldDb = db;
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    ChangeNotes oldNotes = notesFactory.create(db, project, id);
-
-    // Make a ReviewDb change behind NoteDb's back.
-    Change c = oldDb.changes().get(id);
-    assertThat(c.getTopic()).isNull();
-    String topic = name("a-topic");
-    c.setTopic(topic);
-    oldDb.changes().update(Collections.singleton(c));
-
-    c = oldDb.changes().get(c.getId());
-    ChangeNotes newNotes = notesFactory.createWithAutoRebuildingDisabled(c, null);
-    assertThat(newNotes.getChange().getTopic()).isNotEqualTo(topic);
-    assertThat(newNotes.getChange().getTopic()).isEqualTo(oldNotes.getChange().getTopic());
-  }
-
-  @Test
-  public void rebuildDeletesOldDraftRefs() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment", null);
-
-    Account.Id otherAccountId = new Account.Id(user.getId().get() + 1234);
-    String otherDraftRef = refsDraftComments(id, otherAccountId);
-
-    try (Repository repo = repoManager.openRepository(allUsers);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId sha = ins.insert(OBJ_BLOB, "garbage data".getBytes(UTF_8));
-      ins.flush();
-      RefUpdate ru = repo.updateRef(otherDraftRef);
-      ru.setExpectedOldObjectId(ObjectId.zeroId());
-      ru.setNewObjectId(sha);
-      assertThat(ru.update()).isEqualTo(RefUpdate.Result.NEW);
-    }
-
-    checker.rebuildAndCheckChanges(id);
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(otherDraftRef)).isNull();
-    }
-  }
-
-  @Test
-  public void failWhenWritesDisabled() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
-
-    // Turning off writes causes failure.
-    setNotesMigration(false, true);
-    try {
-      gApi.changes().id(id.get()).topic(name("a-topic"));
-      fail("Expected write to fail");
-    } catch (RestApiException e) {
-      assertChangesReadOnly(e);
-    }
-
-    // Update was not written.
-    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
-    assertChangeUpToDate(true, id);
-  }
-
-  @Test
-  public void rebuildWhenWritesDisabledWorksButDoesNotWrite() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-
-    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
-    setNotesMigration(false, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    setInvalidNoteDbState(id);
-    assertChangeUpToDate(false, id);
-
-    // On next NoteDb read, change is rebuilt in-memory but not stored.
-    setNotesMigration(false, true);
-    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
-    assertChangeUpToDate(false, id);
-
-    // Attempting to write directly causes failure.
-    try {
-      gApi.changes().id(id.get()).topic(name("other-topic"));
-      fail("Expected write to fail");
-    } catch (RestApiException e) {
-      assertChangesReadOnly(e);
-    }
-
-    // Update was not written.
-    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
-    assertChangeUpToDate(false, id);
-  }
-
-  @Test
-  public void rebuildChangeWithNoPatchSets() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    db.changes().beginTransaction(id);
-    try {
-      db.patchSets().delete(db.patchSets().byChange(id));
-      db.commit();
-    } finally {
-      db.rollback();
-    }
-
-    try {
-      checker.rebuildAndCheckChanges(id);
-      assert_().fail("expected NoPatchSetsException");
-    } catch (NoPatchSetsException e) {
-      // Expected.
-    }
-
-    Change c = db.changes().get(id);
-    assertThat(c.getNoteDbState()).isNull();
-    checker.assertNoChangeRef(project, id);
-  }
-
-  @Test
-  public void rebuildChangeWithNoEntitiesOtherThanChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    db.changes().beginTransaction(id);
-    try {
-      db.changeMessages().delete(db.changeMessages().byChange(id));
-      db.patchSets().delete(db.patchSets().byChange(id));
-      db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
-      db.patchComments().delete(db.patchComments().byChange(id));
-      db.commit();
-    } finally {
-      db.rollback();
-    }
-
-    try {
-      checker.rebuildAndCheckChanges(id);
-      assert_().fail("expected NoPatchSetsException");
-    } catch (NoPatchSetsException e) {
-      // Expected.
-    }
-
-    Change c = db.changes().get(id);
-    assertThat(c.getNoteDbState()).isNull();
-    checker.assertNoChangeRef(project, id);
-  }
-
-  @Test
-  public void rebuildEntitiesCreatedByImpersonation() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    PatchSet.Id psId = new PatchSet.Id(id, 1);
-    String prefix = "/changes/" + id + "/revisions/current/";
-
-    // For each of the entities that have a real user field, create one entity
-    // without impersonation and one with.
-    CommentInput ci = new CommentInput();
-    ci.path = Patch.COMMIT_MSG;
-    ci.side = Side.REVISION;
-    ci.line = 1;
-    ci.message = "comment without impersonation";
-    ReviewInput ri = new ReviewInput();
-    ri.label("Code-Review", -1);
-    ri.message = "message without impersonation";
-    ri.drafts = DraftHandling.KEEP;
-    ri.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
-    userRestSession.post(prefix + "review", ri).assertOK();
-
-    DraftInput di = new DraftInput();
-    di.path = Patch.COMMIT_MSG;
-    di.side = Side.REVISION;
-    di.line = 1;
-    di.message = "draft without impersonation";
-    userRestSession.put(prefix + "drafts", di).assertCreated();
-
-    allowRunAs();
-    try {
-      Header runAs = new BasicHeader("X-Gerrit-RunAs", user.id.toString());
-      ci.message = "comment with impersonation";
-      ri.message = "message with impersonation";
-      ri.label("Code-Review", 1);
-      adminRestSession.postWithHeader(prefix + "review", ri, runAs).assertOK();
-
-      di.message = "draft with impersonation";
-      adminRestSession.putWithHeader(prefix + "drafts", runAs, di).assertCreated();
-    } finally {
-      removeRunAs();
-    }
-
-    List<ChangeMessage> msgs =
-        Ordering.natural()
-            .onResultOf(ChangeMessage::getWrittenOn)
-            .sortedCopy(db.changeMessages().byChange(id));
-    assertThat(msgs).hasSize(3);
-    assertThat(msgs.get(1).getMessage()).endsWith("message without impersonation");
-    assertThat(msgs.get(1).getAuthor()).isEqualTo(user.id);
-    assertThat(msgs.get(1).getRealAuthor()).isEqualTo(user.id);
-    assertThat(msgs.get(2).getMessage()).endsWith("message with impersonation");
-    assertThat(msgs.get(2).getAuthor()).isEqualTo(user.id);
-    assertThat(msgs.get(2).getRealAuthor()).isEqualTo(admin.id);
-
-    List<PatchSetApproval> psas = db.patchSetApprovals().byChange(id).toList();
-    assertThat(psas).hasSize(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo(1);
-    assertThat(psas.get(0).getAccountId()).isEqualTo(user.id);
-    assertThat(psas.get(0).getRealAccountId()).isEqualTo(admin.id);
-
-    Ordering<PatchLineComment> commentOrder =
-        Ordering.natural().onResultOf(PatchLineComment::getWrittenOn);
-    List<PatchLineComment> drafts =
-        commentOrder.sortedCopy(db.patchComments().draftByPatchSetAuthor(psId, user.id));
-    assertThat(drafts).hasSize(2);
-    assertThat(drafts.get(0).getMessage()).isEqualTo("draft without impersonation");
-    assertThat(drafts.get(0).getAuthor()).isEqualTo(user.id);
-    assertThat(drafts.get(0).getRealAuthor()).isEqualTo(user.id);
-    assertThat(drafts.get(1).getMessage()).isEqualTo("draft with impersonation");
-    assertThat(drafts.get(1).getAuthor()).isEqualTo(user.id);
-    assertThat(drafts.get(1).getRealAuthor()).isEqualTo(admin.id);
-
-    List<PatchLineComment> pub =
-        commentOrder.sortedCopy(db.patchComments().publishedByPatchSet(psId));
-    assertThat(pub).hasSize(2);
-    assertThat(pub.get(0).getMessage()).isEqualTo("comment without impersonation");
-    assertThat(pub.get(0).getAuthor()).isEqualTo(user.id);
-    assertThat(pub.get(0).getRealAuthor()).isEqualTo(user.id);
-    assertThat(pub.get(1).getMessage()).isEqualTo("comment with impersonation");
-    assertThat(pub.get(1).getAuthor()).isEqualTo(user.id);
-    assertThat(pub.get(1).getRealAuthor()).isEqualTo(admin.id);
-  }
-
-  @Test
-  public void laterEventsDependingOnEarlierPatchSetDontIntefereWithOtherPatchSets()
-      throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    ChangeData cd = r1.getChange();
-    Change.Id id = cd.getId();
-    amendChange(cd.change().getKey().get());
-    TestTimeUtil.incrementClock(90, TimeUnit.DAYS);
-
-    ReviewInput rin = ReviewInput.approve();
-    rin.message = "Some very late message on PS1";
-    gApi.changes().id(id.get()).revision(1).review(rin);
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void ignoreChangeMessageBeyondCurrentPatchSet() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId1 = r.getPatchSetId();
-    Change.Id id = psId1.getParentKey();
-    gApi.changes().id(id.get()).current().review(ReviewInput.recommend());
-
-    r = amendChange(r.getChangeId());
-    PatchSet.Id psId2 = r.getPatchSetId();
-
-    assertThat(db.patchSets().byChange(id)).hasSize(2);
-    assertThat(db.changeMessages().byPatchSet(psId2)).hasSize(1);
-    db.patchSets().deleteKeys(Collections.singleton(psId2));
-
-    checker.rebuildAndCheckChanges(psId2.getParentKey());
-    setNotesMigration(true, true);
-
-    ChangeData cd = changeDataFactory.create(db, project, id);
-    assertThat(cd.change().currentPatchSetId()).isEqualTo(psId1);
-    assertThat(cd.patchSets().stream().map(PatchSet::getId).collect(toList()))
-        .containsExactly(psId1);
-    PatchSet ps = cd.currentPatchSet();
-    assertThat(ps).isNotNull();
-    assertThat(ps.getId()).isEqualTo(psId1);
-  }
-
-  @Test
-  public void highestNumberedPatchSetIsNotCurrent() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PatchSet.Id psId1 = r1.getPatchSetId();
-    Change.Id id = psId1.getParentKey();
-    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
-    PatchSet.Id psId2 = r2.getPatchSetId();
-
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
-      bu.addOp(
-          id,
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx)
-                throws PatchSetInfoNotAvailableException {
-              ctx.getChange()
-                  .setCurrentPatchSet(patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), psId1));
-              return true;
-            }
-          });
-      bu.execute();
-    }
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1);
-
-    assertThat(db.changes().get(id).currentPatchSetId()).isEqualTo(psId1);
-
-    checker.rebuildAndCheckChanges(id);
-    setNotesMigration(true, true);
-
-    notes = notesFactory.create(db, project, id);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1);
-  }
-
-  @Test
-  public void resolveCommentsInheritsValueFromParentWhenUnspecified() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment", true);
-    putDraft(user, id, 1, "newComment", null);
-
-    Map<String, List<CommentInfo>> comments = gApi.changes().id(id.get()).current().drafts();
-    for (List<CommentInfo> cList : comments.values()) {
-      for (CommentInfo ci : cList) {
-        assertThat(ci.unresolved).isTrue();
-      }
-    }
-  }
-
-  @Test
-  public void rebuilderRespectsReadOnlyInNoteDbChangeState() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId1 = r.getPatchSetId();
-    Change.Id id = psId1.getParentKey();
-
-    checker.rebuildAndCheckChanges(id);
-    setNotesMigration(true, true);
-
-    ReviewDb db = getUnwrappedDb();
-    Change c = db.changes().get(id);
-    NoteDbChangeState state = NoteDbChangeState.parse(c);
-    Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS));
-    state = state.withReadOnlyUntil(until);
-    c.setNoteDbState(state.toString());
-    db.changes().update(Collections.singleton(c));
-
-    try {
-      rebuilderWrapper.rebuild(db, id);
-      fail("expected rebuild to fail");
-    } catch (OrmRuntimeException e) {
-      assertThat(e.getMessage()).contains("read-only until");
-    }
-
-    TestTimeUtil.setClock(new Timestamp(until.getTime() + MILLISECONDS.convert(1, SECONDS)));
-    rebuilderWrapper.rebuild(db, id);
-  }
-
-  @Test
-  public void commitWithCrLineEndings() throws Exception {
-    PushOneCommit.Result r =
-        createChange("Subject\r\rBody\r", PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT);
-    Change c = r.getChange().change();
-
-    // This assertion demonstrates an arguable bug in JGit's commit subject
-    // parsing, and shows how this kind of data might have gotten into
-    // ReviewDb. If that bug ever gets fixed upstream, this assert may start
-    // failing. If that happens, this test can be rewritten to directly set the
-    // subject field in ReviewDb.
-    assertThat(c.getSubject()).isEqualTo("Subject\r\rBody");
-
-    checker.rebuildAndCheckChanges(c.getId());
-  }
-
-  @Test
-  public void patchSetsOutOfOrder() throws Exception {
-    String id = createChange().getChangeId();
-    amendChange(id);
-    PushOneCommit.Result r = amendChange(id);
-
-    ChangeData cd = r.getChange();
-    PatchSet.Id psId3 = cd.change().currentPatchSetId();
-    assertThat(psId3.get()).isEqualTo(3);
-
-    PatchSet ps1 = db.patchSets().get(new PatchSet.Id(cd.getId(), 1));
-    PatchSet ps3 = db.patchSets().get(psId3);
-    assertThat(ps1.getCreatedOn()).isLessThan(ps3.getCreatedOn());
-
-    // Simulate an old Gerrit bug by setting the created timestamp of the latest
-    // patch set ID to the timestamp of PS1.
-    ps3.setCreatedOn(ps1.getCreatedOn());
-    db.patchSets().update(Collections.singleton(ps3));
-
-    checker.rebuildAndCheckChanges(cd.getId());
-
-    setNotesMigration(true, true);
-    cd = changeDataFactory.create(db, project, cd.getId());
-    assertThat(cd.change().currentPatchSetId()).isEqualTo(psId3);
-
-    List<PatchSet> patchSets = ImmutableList.copyOf(cd.patchSets());
-    assertThat(patchSets).hasSize(3);
-
-    PatchSet newPs1 = patchSets.get(0);
-    assertThat(newPs1.getId()).isEqualTo(ps1.getId());
-    assertThat(newPs1.getCreatedOn()).isEqualTo(ps1.getCreatedOn());
-
-    PatchSet newPs2 = patchSets.get(1);
-    assertThat(newPs2.getCreatedOn()).isGreaterThan(newPs1.getCreatedOn());
-
-    PatchSet newPs3 = patchSets.get(2);
-    assertThat(newPs3.getId()).isEqualTo(ps3.getId());
-    // Migrated with a newer timestamp than the original, to preserve ordering.
-    assertThat(newPs3.getCreatedOn()).isAtLeast(newPs2.getCreatedOn());
-    assertThat(newPs3.getCreatedOn()).isGreaterThan(ps1.getCreatedOn());
-  }
-
-  @Test
-  public void ignoreNoteDbStateWithNoCorrespondingRefWhenWritesAndReadsDisabled() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    ReviewDb db = getUnwrappedDb();
-    Change c = db.changes().get(id);
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-    c = db.changes().get(id);
-
-    String refName = RefNames.changeMetaRef(id);
-    assertThat(getMetaRef(project, refName)).isNull();
-
-    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
-
-    notes = notesFactory.createChecked(dbProvider.get(), project, id);
-    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
-
-    assertThat(getMetaRef(project, refName)).isNull();
-  }
-
-  @Test
-  public void autoRebuildMissingRefWriteOnly() throws Exception {
-    setNotesMigration(true, false);
-    testAutoRebuildMissingRef();
-  }
-
-  @Test
-  public void autoRebuildMissingRefReadWrite() throws Exception {
-    setNotesMigration(true, true);
-    testAutoRebuildMissingRef();
-  }
-
-  private void testAutoRebuildMissingRef() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    assertChangeUpToDate(true, id);
-    notesFactory.createChecked(db, project, id);
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      RefUpdate ru = repo.updateRef(RefNames.changeMetaRef(id));
-      ru.setForceUpdate(true);
-      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
-    }
-    assertChangeUpToDate(false, id);
-
-    notesFactory.createChecked(db, project, id);
-    assertChangeUpToDate(true, id);
-  }
-
-  @Test
-  public void missingPatchSetCommitOkForCommentsNotOnParentSide() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    putDraft(user, id, 1, "draft comment", null, Side.REVISION);
-    putComment(user, id, 1, "published comment", null, Side.REVISION);
-
-    ReviewDb db = getUnwrappedDb();
-    PatchSet ps = db.patchSets().get(new PatchSet.Id(id, 1));
-    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    db.patchSets().update(Collections.singleton(ps));
-
-    try {
-      patchListCache.getOldId(db.changes().get(id), ps, null);
-      assert_().fail("Expected PatchListNotAvailableException");
-    } catch (PatchListNotAvailableException e) {
-      // Expected.
-    }
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void missingPatchSetCommitOmitsCommentsOnParentSide() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    CommentInfo draftInfo = putDraft(user, id, 1, "draft comment", null, Side.PARENT);
-    putComment(user, id, 1, "published comment", null, Side.PARENT);
-    CommentInfo commentInfo =
-        gApi.changes()
-            .id(id.get())
-            .comments()
-            .values()
-            .stream()
-            .flatMap(List::stream)
-            .findFirst()
-            .get();
-
-    ReviewDb db = getUnwrappedDb();
-    PatchSet ps = db.patchSets().get(new PatchSet.Id(id, 1));
-    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    db.patchSets().update(Collections.singleton(ps));
-
-    try {
-      patchListCache.getOldId(db.changes().get(id), ps, null);
-      assert_().fail("Expected PatchListNotAvailableException");
-    } catch (PatchListNotAvailableException e) {
-      // Expected.
-    }
-
-    checker.rebuildAndCheckChange(
-        id,
-        Stream.of(draftInfo.id, commentInfo.id)
-            .sorted()
-            .map(c -> id + ",1," + PushOneCommit.FILE_NAME + "," + c)
-            .collect(
-                joining(", ", "PatchLineComment.Key sets differ: [", "] only in A; [] only in B")));
-  }
-
-  private void assertChangesReadOnly(RestApiException e) throws Exception {
-    Throwable cause = e.getCause();
-    assertThat(cause).isInstanceOf(UpdateException.class);
-    assertThat(cause.getCause()).isInstanceOf(OrmException.class);
-    assertThat(cause.getCause()).hasMessageThat().isEqualTo(NoteDbUpdateManager.CHANGES_READ_ONLY);
-  }
-
-  private void setInvalidNoteDbState(Change.Id id) throws Exception {
-    ReviewDb db = getUnwrappedDb();
-    Change c = db.changes().get(id);
-    // In reality we would have NoteDb writes enabled, which would write a real
-    // state into this field. For tests however, we turn NoteDb writes off, so
-    // just use a dummy state to force ChangeNotes to view the notes as
-    // out-of-date.
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-  }
-
-  private void assertChangeUpToDate(boolean expected, Change.Id id) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      Change c = getUnwrappedDb().changes().get(id);
-      assertThat(c).isNotNull();
-      assertThat(c.getNoteDbState()).isNotNull();
-      NoteDbChangeState state = NoteDbChangeState.parse(c);
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-      assertThat(state.isChangeUpToDate(new RepoRefCache(repo))).isEqualTo(expected);
-    }
-  }
-
-  private void assertDraftsUpToDate(boolean expected, Change.Id changeId, TestAccount account)
-      throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      Change c = getUnwrappedDb().changes().get(changeId);
-      assertThat(c).isNotNull();
-      assertThat(c.getNoteDbState()).isNotNull();
-      NoteDbChangeState state = NoteDbChangeState.parse(c);
-      assertThat(state.areDraftsUpToDate(new RepoRefCache(repo), account.getId()))
-          .isEqualTo(expected);
-    }
-  }
-
-  private ObjectId getMetaRef(Project.NameKey p, String name) throws Exception {
-    try (Repository repo = repoManager.openRepository(p)) {
-      Ref ref = repo.exactRef(name);
-      return ref != null ? ref.getObjectId() : null;
-    }
-  }
-
-  private CommentInfo putDraft(
-      TestAccount account, Change.Id id, int line, String msg, Boolean unresolved)
-      throws Exception {
-    return putDraft(account, id, line, msg, unresolved, Side.REVISION);
-  }
-
-  private CommentInfo putDraft(
-      TestAccount account, Change.Id id, int line, String msg, Boolean unresolved, Side side)
-      throws Exception {
-    DraftInput in = new DraftInput();
-    in.side = side;
-    in.line = line;
-    in.message = msg;
-    in.path = PushOneCommit.FILE_NAME;
-    in.unresolved = unresolved;
-    AcceptanceTestRequestScope.Context old = setApiUser(account);
-    try {
-      return gApi.changes().id(id.get()).current().createDraft(in).get();
-    } finally {
-      atrScope.set(old);
-    }
-  }
-
-  private void putComment(TestAccount account, Change.Id id, int line, String msg, String inReplyTo)
-      throws Exception {
-    putComment(account, id, line, msg, inReplyTo, Side.REVISION);
-  }
-
-  private void putComment(
-      TestAccount account, Change.Id id, int line, String msg, String inReplyTo, Side side)
-      throws Exception {
-    CommentInput in = new CommentInput();
-    in.side = side;
-    in.line = line;
-    in.message = msg;
-    in.inReplyTo = inReplyTo;
-    ReviewInput rin = new ReviewInput();
-    rin.comments = new HashMap<>();
-    rin.comments.put(PushOneCommit.FILE_NAME, ImmutableList.of(in));
-    rin.drafts = ReviewInput.DraftHandling.KEEP;
-    AcceptanceTestRequestScope.Context old = setApiUser(account);
-    try {
-      gApi.changes().id(id.get()).current().review(rin);
-    } finally {
-      atrScope.set(old);
-    }
-  }
-
-  private void publishDrafts(TestAccount account, Change.Id id) throws Exception {
-    ReviewInput rin = new ReviewInput();
-    rin.drafts = ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS;
-    AcceptanceTestRequestScope.Context old = setApiUser(account);
-    try {
-      gApi.changes().id(id.get()).current().review(rin);
-    } finally {
-      atrScope.set(old);
-    }
-  }
-
-  private ChangeMessage insertMessage(
-      Change.Id id, PatchSet.Id psId, Account.Id author, Timestamp ts, String message)
-      throws Exception {
-    ChangeMessage msg =
-        new ChangeMessage(new ChangeMessage.Key(id, ChangeUtil.messageUuid()), author, ts, psId);
-    msg.setMessage(message);
-    db.changeMessages().insert(Collections.singleton(msg));
-
-    Change c = db.changes().get(id);
-    if (ts.compareTo(c.getLastUpdatedOn()) > 0) {
-      c.setLastUpdatedOn(ts);
-      db.changes().update(Collections.singleton(c));
-    }
-
-    return msg;
-  }
-
-  private ReviewDb getUnwrappedDb() {
-    ReviewDb db = dbProvider.get();
-    return ReviewDbUtil.unwrapDb(db);
-  }
-
-  private void allowRunAs() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      Util.allow(
-          u.getConfig(),
-          GlobalCapability.RUN_AS,
-          systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-      u.save();
-    }
-  }
-
-  private void removeRunAs() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      Util.remove(
-          u.getConfig(),
-          GlobalCapability.RUN_AS,
-          systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-      u.save();
-    }
-  }
-
-  private Map<String, List<CommentInfo>> getPublishedComments(Change.Id id) throws Exception {
-    return gApi.changes().id(id.get()).current().comments();
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index c45ea99..5e7070b 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.acceptance.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -56,7 +57,6 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Before;
 import org.junit.Test;
 
 public class NoteDbOnlyIT extends AbstractDaemonTest {
@@ -70,14 +70,8 @@
 
   @Inject private RetryHelper retryHelper;
 
-  @Before
-  public void setUp() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-  }
-
   @Test
   public void updateChangeFailureRollsBackRefUpdate() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getChange().getId();
 
@@ -134,12 +128,9 @@
               throw new ResourceConflictException(msg);
             }
           });
-      try {
-        bu.execute();
-        fail("expected ResourceConflictException");
-      } catch (ResourceConflictException e) {
-        assertThat(e).hasMessageThat().isEqualTo(msg);
-      }
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> bu.execute());
+      assertThat(thrown).hasMessageThat().isEqualTo(msg);
     }
 
     // If updateChange hadn't failed, backup would have been updated to master2.
@@ -149,7 +140,6 @@
 
   @Test
   public void retryOnLockFailureWithAtomicUpdates() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getChange().getId();
     String master = "refs/heads/master";
@@ -197,18 +187,13 @@
 
   @Test
   public void missingChange() throws Exception {
-    Change.Id changeId = new Change.Id(1234567);
-    assertNoSuchChangeException(() -> notesFactory.create(db, project, changeId));
-    assertNoSuchChangeException(() -> notesFactory.createChecked(db, project, changeId));
+    Change.Id changeId = Change.id(1234567);
+    assertNoSuchChangeException(() -> notesFactory.create(project, changeId));
+    assertNoSuchChangeException(() -> notesFactory.createChecked(project, changeId));
   }
 
   private void assertNoSuchChangeException(Callable<?> callable) throws Exception {
-    try {
-      callable.call();
-      fail("expected NoSuchChangeException");
-    } catch (NoSuchChangeException e) {
-      // Expected.
-    }
+    assertThrows(NoSuchChangeException.class, () -> callable.call());
   }
 
   private class ConcurrentWritingListener implements BatchUpdateListener {
@@ -285,7 +270,7 @@
   }
 
   private BatchUpdate newBatchUpdate(BatchUpdate.Factory buf) {
-    return buf.create(db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs());
+    return buf.create(project, identifiedUserFactory.create(user.id()), TimeUtil.nowTs());
   }
 
   private Optional<ObjectId> getRef(String name) throws Exception {
@@ -295,11 +280,7 @@
   }
 
   private List<String> getMessages(Change.Id id) throws Exception {
-    return gApi.changes()
-        .id(id.get())
-        .get(MESSAGES)
-        .messages
-        .stream()
+    return gApi.changes().id(id.get()).get(MESSAGES).messages.stream()
         .map(m -> m.message)
         .collect(toList());
   }
@@ -319,8 +300,8 @@
     if (repo instanceof InMemoryRepository) {
       ((InMemoryRepository) repo).setPerformsAtomicTransactions(true);
     } else {
-      assertThat(repo.getRefDatabase().performsAtomicTransactions())
-          .named("performsAtomicTransactions on %s", repo)
+      assertWithMessage("performsAtomicTransactions on %s", repo)
+          .that(repo.getRefDatabase().performsAtomicTransactions())
           .isTrue();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
deleted file mode 100644
index b7ce7bc..0000000
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
+++ /dev/null
@@ -1,524 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
-import static com.google.gerrit.server.notedb.NoteDbUtil.formatTime;
-import static java.util.concurrent.TimeUnit.DAYS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-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.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.git.RepoRefCache;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
-import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.NoteDbMode;
-import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Inject;
-import com.google.inject.util.Providers;
-import java.sql.Timestamp;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class NoteDbPrimaryIT extends AbstractDaemonTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-    cfg.setString("notedb", null, "concurrentWriterTimeout", "0s");
-    cfg.setString("notedb", null, "primaryStorageMigrationTimeout", "1d");
-    cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
-    return cfg;
-  }
-
-  @Inject private ChangeBundleReader bundleReader;
-  @Inject private CommentsUtil commentsUtil;
-  @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
-  @Inject private ChangeNotes.Factory changeNotesFactory;
-  @Inject private ChangeUpdate.Factory updateFactory;
-  @Inject private InternalUser.Factory internalUserFactory;
-  @Inject private RetryHelper retryHelper;
-
-  private PrimaryStorageMigrator migrator;
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.READ_WRITE);
-    db = ReviewDbUtil.unwrapDb(db);
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    migrator = newMigrator(null);
-  }
-
-  private PrimaryStorageMigrator newMigrator(
-      @Nullable Retryer<NoteDbChangeState> ensureRebuiltRetryer) {
-    return new PrimaryStorageMigrator(
-        cfg,
-        Providers.of(db),
-        repoManager,
-        allUsers,
-        rebuilderWrapper,
-        ensureRebuiltRetryer,
-        changeNotesFactory,
-        queryProvider,
-        updateFactory,
-        internalUserFactory,
-        retryHelper);
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void updateChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id.get()).current().submit();
-
-    ChangeInfo info = gApi.changes().id(id.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    ApprovalInfo approval = Iterables.getOnlyElement(info.labels.get("Code-Review").all);
-    assertThat(approval._accountId).isEqualTo(admin.id.get());
-    assertThat(approval.value).isEqualTo(2);
-    assertThat(info.messages).hasSize(3);
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo("Change has been successfully merged by " + admin.fullName);
-
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(notes.getChange().getNoteDbState())
-        .isEqualTo(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-
-    // Writes weren't reflected in ReviewDb.
-    assertThat(db.changes().get(id).getStatus()).isEqualTo(Change.Status.NEW);
-    assertThat(db.patchSetApprovals().byChange(id)).isEmpty();
-    assertThat(db.changeMessages().byChange(id)).hasSize(1);
-  }
-
-  @Test
-  public void deleteDraftComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    DraftInput din = new DraftInput();
-    din.path = PushOneCommit.FILE_NAME;
-    din.line = 1;
-    din.message = "A comment";
-    gApi.changes().id(id.get()).current().createDraft(din);
-
-    CommentInfo di =
-        Iterables.getOnlyElement(
-            gApi.changes().id(id.get()).current().drafts().get(PushOneCommit.FILE_NAME));
-    assertThat(di.message).isEqualTo(din.message);
-
-    assertThat(db.patchComments().draftByChangeFileAuthor(id, din.path, admin.id)).isEmpty();
-
-    gApi.changes().id(id.get()).current().draft(di.id).delete();
-    assertThat(gApi.changes().id(id.get()).current().drafts()).isEmpty();
-  }
-
-  @Test
-  public void deleteVote() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.get(0).value).isEqualTo(2);
-
-    gApi.changes().id(id.get()).reviewer(admin.id.toString()).deleteVote("Code-Review");
-
-    approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.get(0).value).isEqualTo(0);
-  }
-
-  @Test
-  public void deleteVoteViaReview() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.get(0).value).isEqualTo(2);
-
-    gApi.changes().id(id.get()).current().review(ReviewInput.noScore());
-
-    approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.get(0).value).isEqualTo(0);
-  }
-
-  @Test
-  public void deleteReviewer() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).addReviewer(user.id.toString());
-    assertThat(getReviewers(id)).containsExactly(user.id);
-    gApi.changes().id(id.get()).reviewer(user.id.toString()).remove();
-    assertThat(getReviewers(id)).isEmpty();
-  }
-
-  @Test
-  public void readOnlyReviewDb() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    testReadOnly(id);
-  }
-
-  @Test
-  public void readOnlyNoteDb() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-    testReadOnly(id);
-  }
-
-  private void testReadOnly(Change.Id id) throws Exception {
-    Timestamp before = TimeUtil.nowTs();
-    Timestamp until = new Timestamp(before.getTime() + 1000 * 3600);
-
-    // Set read-only.
-    Change c = db.changes().get(id);
-    assertThat(c).named("change " + id).isNotNull();
-    NoteDbChangeState state = NoteDbChangeState.parse(c);
-    state = state.withReadOnlyUntil(until);
-    c.setNoteDbState(state.toString());
-    db.changes().update(Collections.singleton(c));
-
-    assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
-    assertThat(gApi.changes().id(id.get()).get().topic).isNull();
-    try {
-      gApi.changes().id(id.get()).topic("a-topic");
-      fail("expected read-only exception");
-    } catch (RestApiException e) {
-      Optional<Throwable> oe =
-          Throwables.getCausalChain(e)
-              .stream()
-              .filter(x -> x instanceof OrmRuntimeException)
-              .findFirst();
-      assertThat(oe).named("OrmRuntimeException in causal chain of " + e).isPresent();
-      assertThat(oe.get().getMessage()).contains("read-only");
-    }
-    assertThat(gApi.changes().id(id.get()).get().topic).isNull();
-
-    TestTimeUtil.setClock(new Timestamp(until.getTime() + 1000));
-    assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
-    gApi.changes().id(id.get()).topic("a-topic");
-    assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
-  }
-
-  @Test
-  public void migrateToNoteDb() throws Exception {
-    testMigrateToNoteDb(createChange().getChange().getId());
-  }
-
-  @Test
-  public void migrateToNoteDbWithRebuildingFirst() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    Change c = db.changes().get(id);
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-    testMigrateToNoteDb(id);
-  }
-
-  private void testMigrateToNoteDb(Change.Id id) throws Exception {
-    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).topic("a-topic");
-    assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
-    assertThat(db.changes().get(id).getTopic()).isNull();
-  }
-
-  @Test
-  public void migrateToNoteDbFailsRebuildingOnceAndRetries() throws Exception {
-    Change.Id id = createChange().getChange().getId();
-
-    Change c = db.changes().get(id);
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-    rebuilderWrapper.failNextUpdate();
-
-    migrator =
-        newMigrator(
-            RetryerBuilder.<NoteDbChangeState>newBuilder()
-                .retryIfException()
-                .withStopStrategy(StopStrategies.neverStop())
-                .build());
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-  }
-
-  @Test
-  public void migrateToNoteDbFailsRebuildingAndStops() throws Exception {
-    Change.Id id = createChange().getChange().getId();
-
-    Change c = db.changes().get(id);
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-    rebuilderWrapper.failNextUpdate();
-
-    migrator =
-        newMigrator(
-            RetryerBuilder.<NoteDbChangeState>newBuilder()
-                .retryIfException()
-                .withStopStrategy(StopStrategies.stopAfterAttempt(1))
-                .build());
-    exception.expect(OrmException.class);
-    exception.expectMessage("Retrying failed");
-    migrator.migrateToNoteDbPrimary(id);
-  }
-
-  @Test
-  public void migrateToNoteDbMissingOldState() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    Change c = db.changes().get(id);
-    c.setNoteDbState(null);
-    db.changes().update(Collections.singleton(c));
-
-    exception.expect(PrimaryStorageMigrator.NoNoteDbStateException.class);
-    exception.expectMessage("no note_db_state");
-    migrator.migrateToNoteDbPrimary(id);
-  }
-
-  @Test
-  public void migrateToNoteDbLeaseExpires() throws Exception {
-    TestTimeUtil.resetWithClockStep(2, DAYS);
-    exception.expect(OrmRuntimeException.class);
-    exception.expectMessage("read-only lease");
-    migrator.migrateToNoteDbPrimary(createChange().getChange().getId());
-  }
-
-  @Test
-  public void migrateToNoteDbAlreadyReadOnly() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    Change c = db.changes().get(id);
-    NoteDbChangeState state = NoteDbChangeState.parse(c);
-    Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS));
-    state = state.withReadOnlyUntil(until);
-    c.setNoteDbState(state.toString());
-    db.changes().update(Collections.singleton(c));
-
-    exception.expect(OrmRuntimeException.class);
-    exception.expectMessage("read-only until " + until);
-    migrator.migrateToNoteDbPrimary(id);
-  }
-
-  @Test
-  public void migrateToNoteDbAlreadyMigrated() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-  }
-
-  @Test
-  public void rebuildReviewDb() throws Exception {
-    Change c = createChange().getChange().change();
-    Change.Id id = c.getId();
-
-    CommentInput cin = new CommentInput();
-    cin.line = 1;
-    cin.message = "Published comment";
-    ReviewInput rin = ReviewInput.approve();
-    rin.comments = ImmutableMap.of(PushOneCommit.FILE_NAME, ImmutableList.of(cin));
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-
-    DraftInput din = new DraftInput();
-    din.path = PushOneCommit.FILE_NAME;
-    din.line = 1;
-    din.message = "Draft comment";
-    gApi.changes().id(id.get()).current().createDraft(din);
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id.get()).current().createDraft(din);
-
-    assertThat(db.changeMessages().byChange(id)).isNotEmpty();
-    assertThat(db.patchSets().byChange(id)).isNotEmpty();
-    assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
-    assertThat(db.patchComments().byChange(id)).isNotEmpty();
-
-    ChangeBundle noteDbBundle =
-        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(db, project, id));
-
-    setNoteDbPrimary(id);
-
-    db.changeMessages().delete(db.changeMessages().byChange(id));
-    db.patchSets().delete(db.patchSets().byChange(id));
-    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
-    db.patchComments().delete(db.patchComments().byChange(id));
-    ChangeMessage bogusMessage =
-        ChangeMessagesUtil.newMessage(
-            c.currentPatchSetId(),
-            identifiedUserFactory.create(admin.getId()),
-            TimeUtil.nowTs(),
-            "some message",
-            null);
-    db.changeMessages().insert(Collections.singleton(bogusMessage));
-
-    rebuilderWrapper.rebuildReviewDb(db, project, id);
-
-    assertThat(db.changeMessages().byChange(id)).isNotEmpty();
-    assertThat(db.patchSets().byChange(id)).isNotEmpty();
-    assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
-    assertThat(db.patchComments().byChange(id)).isNotEmpty();
-
-    ChangeBundle reviewDbBundle = bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db), id);
-    assertThat(reviewDbBundle.differencesFrom(noteDbBundle)).isEmpty();
-  }
-
-  @Test
-  public void migrateBackToReviewDbPrimary() throws Exception {
-    Change c = createChange().getChange().change();
-    Change.Id id = c.getId();
-
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).topic("new-topic");
-    assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
-    assertThat(db.changes().get(id).getTopic()).isNotEqualTo("new-topic");
-
-    migrator.migrateToReviewDbPrimary(id, null);
-    ObjectId metaId;
-    try (Repository repo = repoManager.openRepository(c.getProject());
-        RevWalk rw = new RevWalk(repo)) {
-      metaId = repo.exactRef(RefNames.changeMetaRef(id)).getObjectId();
-      RevCommit commit = rw.parseCommit(metaId);
-      rw.parseBody(commit);
-      assertThat(commit.getFullMessage())
-          .contains("Read-only-until: " + formatTime(serverIdent.get(), new Timestamp(0)));
-    }
-    NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
-    assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-    assertThat(state.getChangeMetaId()).isEqualTo(metaId);
-    assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
-    assertThat(db.changes().get(id).getTopic()).isEqualTo("new-topic");
-
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getRevision()).isEqualTo(metaId); // No rebuilding, change was up to date.
-    assertThat(notes.getReadOnlyUntil()).isNotNull();
-
-    gApi.changes().id(id.get()).topic("reviewdb-topic");
-    assertThat(db.changes().get(id).getTopic()).isEqualTo("reviewdb-topic");
-  }
-
-  private void setNoteDbPrimary(Change.Id id) throws Exception {
-    Change c = db.changes().get(id);
-    assertThat(c).named("change " + id).isNotNull();
-    NoteDbChangeState state = NoteDbChangeState.parse(c);
-    assertThat(state.getPrimaryStorage()).named("storage of " + id).isEqualTo(REVIEW_DB);
-
-    try (Repository changeRepo = repoManager.openRepository(c.getProject());
-        Repository allUsersRepo = repoManager.openRepository(allUsers)) {
-      assertThat(state.isUpToDate(new RepoRefCache(changeRepo), new RepoRefCache(allUsersRepo)))
-          .named("change " + id + " up to date")
-          .isTrue();
-    }
-
-    c.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-    db.changes().update(Collections.singleton(c));
-  }
-
-  private void assertNoteDbPrimary(Change.Id id) throws Exception {
-    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.NOTE_DB);
-  }
-
-  private List<Account.Id> getReviewers(Change.Id id) throws Exception {
-    return gApi.changes()
-        .id(id.get())
-        .get()
-        .reviewers
-        .values()
-        .stream()
-        .flatMap(Collection::stream)
-        .map(a -> new Account.Id(a._accountId))
-        .collect(toList());
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
deleted file mode 100644
index a5d78c6..0000000
--- a/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
+++ /dev/null
@@ -1,627 +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.collect.ImmutableSortedSet.toImmutableSortedSet;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.NOTE_DB_PRIMARY_STATE;
-import static com.google.gerrit.server.notedb.NotesMigrationState.NOTE_DB;
-import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_NO_SEQUENCE;
-import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY;
-import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY;
-import static com.google.gerrit.server.notedb.NotesMigrationState.REVIEW_DB;
-import static com.google.gerrit.server.notedb.NotesMigrationState.WRITE;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Comparator.naturalOrder;
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.expectLastCall;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSortedSet;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-import com.google.gerrit.server.notedb.rebuild.MigrationException;
-import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
-import com.google.gerrit.server.notedb.rebuild.NotesMigrationStateListener;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.NoteDbMode;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Set;
-import java.util.stream.Stream;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-@Sandboxed
-@UseLocalDisk
-@NoHttpd
-public class OnlineNoteDbMigrationIT extends AbstractDaemonTest {
-  private static final String INVALID_STATE = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-    cfg.setInt("noteDb", "changes", "sequenceBatchSize", 10);
-    cfg.setInt("noteDb", "changes", "initialSequenceGap", 500);
-    return cfg;
-  }
-
-  // Tests in this class are generally interested in the actual ReviewDb contents, but the shifting
-  // migration state may result in various kinds of wrappers showing up unexpectedly.
-  @Inject @ReviewDbFactory private SchemaFactory<ReviewDb> schemaFactory;
-
-  @Inject private ChangeBundleReader changeBundleReader;
-  @Inject private CommentsUtil commentsUtil;
-  @Inject private DynamicSet<NotesMigrationStateListener> listeners;
-  @Inject private Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
-  @Inject private Sequences sequences;
-  @Inject private SitePaths sitePaths;
-
-  private FileBasedConfig noteDbConfig;
-  private List<RegistrationHandle> addedListeners;
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
-    // Unlike in the running server, for tests, we don't stack notedb.config on gerrit.config.
-    noteDbConfig = new FileBasedConfig(sitePaths.notedb_config.toFile(), FS.detect());
-    assertNotesMigrationState(REVIEW_DB, false, false);
-    addedListeners = new ArrayList<>();
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    if (addedListeners != null) {
-      addedListeners.forEach(RegistrationHandle::remove);
-      addedListeners = null;
-    }
-  }
-
-  @Test
-  public void preconditionsFail() throws Exception {
-    List<Change.Id> cs = ImmutableList.of(new Change.Id(1));
-    List<Project.NameKey> ps = ImmutableList.of(new Project.NameKey("p"));
-    assertMigrationException(
-        "Cannot rebuild without noteDb.changes.write=true", b -> b, NoteDbMigrator::rebuild);
-    assertMigrationException(
-        "Cannot set both changes and projects", b -> b.setChanges(cs).setProjects(ps), m -> {});
-    assertMigrationException(
-        "Cannot set changes or projects during full migration",
-        b -> b.setChanges(cs),
-        NoteDbMigrator::migrate);
-    assertMigrationException(
-        "Cannot set changes or projects during full migration",
-        b -> b.setProjects(ps),
-        NoteDbMigrator::migrate);
-
-    setNotesMigrationState(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
-    assertMigrationException(
-        "Migration has already progressed past the endpoint of the \"trial mode\" state",
-        b -> b.setTrialMode(true),
-        NoteDbMigrator::migrate);
-
-    setNotesMigrationState(READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
-    assertMigrationException(
-        "Cannot force rebuild changes; NoteDb is already the primary storage for some changes",
-        b -> b.setForceRebuild(true),
-        NoteDbMigrator::migrate);
-  }
-
-  @Test
-  @GerritConfig(name = "noteDb.changes.initialSequenceGap", value = "-7")
-  public void initialSequenceGapMustBeNonNegative() throws Exception {
-    setNotesMigrationState(READ_WRITE_NO_SEQUENCE);
-    assertMigrationException("Sequence gap must be non-negative: -7", b -> b, m -> {});
-  }
-
-  @Test
-  public void rebuildOneChangeTrialModeAndForceRebuild() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    migrate(b -> b.setTrialMode(true));
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
-
-    ObjectId oldMetaId;
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
-      assertThat(ref).isNotNull();
-      oldMetaId = ref.getObjectId();
-
-      Change c = db.changes().get(id);
-      assertThat(c).isNotNull();
-      NoteDbChangeState state = NoteDbChangeState.parse(c);
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-      assertThat(state.getRefState()).hasValue(RefState.create(oldMetaId, ImmutableMap.of()));
-
-      // Force change to be out of date, and change topic so it will get rebuilt as something other
-      // than oldMetaId.
-      c.setNoteDbState(INVALID_STATE);
-      c.setTopic(name("a-new-topic"));
-      db.changes().update(ImmutableList.of(c));
-    }
-
-    migrate(b -> b.setTrialMode(true));
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
-
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      // Change is out of date, but was not rebuilt without forceRebuild.
-      assertThat(repo.exactRef(RefNames.changeMetaRef(id)).getObjectId()).isEqualTo(oldMetaId);
-      Change c = db.changes().get(id);
-      assertThat(c.getNoteDbState()).isEqualTo(INVALID_STATE);
-    }
-
-    migrate(b -> b.setTrialMode(true).setForceRebuild(true));
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
-
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
-      assertThat(ref).isNotNull();
-      ObjectId newMetaId = ref.getObjectId();
-      assertThat(newMetaId).isNotEqualTo(oldMetaId);
-
-      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-      assertThat(state.getRefState()).hasValue(RefState.create(newMetaId, ImmutableMap.of()));
-    }
-  }
-
-  @Test
-  public void autoMigrateTrialMode() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    migrate(b -> b.setAutoMigrate(true).setTrialMode(true).setStopAtStateForTesting(WRITE));
-    assertNotesMigrationState(WRITE, true, true);
-
-    migrate(b -> b);
-    // autoMigrate is still enabled so that we can continue the migration by only unsetting trial.
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, true, true);
-
-    ObjectId metaId;
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
-      assertThat(ref).isNotNull();
-      metaId = ref.getObjectId();
-      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-      assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
-    }
-
-    // Unset trial mode and the next migration runs to completion.
-    noteDbConfig.load();
-    NoteDbMigrator.setTrialMode(noteDbConfig, false);
-    noteDbConfig.save();
-
-    migrate(b -> b);
-    assertNotesMigrationState(NOTE_DB, false, false);
-
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
-      assertThat(ref).isNotNull();
-      assertThat(ref.getObjectId()).isEqualTo(metaId);
-      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.NOTE_DB);
-    }
-  }
-
-  @Test
-  public void rebuildSubsetOfChanges() throws Exception {
-    setNotesMigrationState(WRITE);
-
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    Change.Id id1 = r1.getChange().getId();
-    Change.Id id2 = r2.getChange().getId();
-
-    try (ReviewDb db = schemaFactory.open()) {
-      Change c1 = db.changes().get(id1);
-      c1.setNoteDbState(INVALID_STATE);
-      Change c2 = db.changes().get(id2);
-      c2.setNoteDbState(INVALID_STATE);
-      db.changes().update(ImmutableList.of(c1, c2));
-    }
-
-    migrate(b -> b.setChanges(ImmutableList.of(id2)), NoteDbMigrator::rebuild);
-
-    try (ReviewDb db = schemaFactory.open()) {
-      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
-      assertThat(s1.getChangeMetaId().name()).isEqualTo(INVALID_STATE);
-
-      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
-      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(INVALID_STATE);
-    }
-  }
-
-  @Test
-  public void rebuildSubsetOfProjects() throws Exception {
-    setNotesMigrationState(WRITE);
-
-    Project.NameKey p2 = createProject("project2");
-    TestRepository<?> tr2 = cloneProject(p2, admin);
-
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = pushFactory.create(db, admin.getIdent(), tr2).to("refs/for/master");
-    Change.Id id1 = r1.getChange().getId();
-    Change.Id id2 = r2.getChange().getId();
-
-    String invalidState = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    try (ReviewDb db = schemaFactory.open()) {
-      Change c1 = db.changes().get(id1);
-      c1.setNoteDbState(invalidState);
-      Change c2 = db.changes().get(id2);
-      c2.setNoteDbState(invalidState);
-      db.changes().update(ImmutableList.of(c1, c2));
-    }
-
-    migrate(b -> b.setProjects(ImmutableList.of(p2)), NoteDbMigrator::rebuild);
-
-    try (ReviewDb db = schemaFactory.open()) {
-      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
-      assertThat(s1.getChangeMetaId().name()).isEqualTo(invalidState);
-
-      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
-      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(invalidState);
-    }
-  }
-
-  @Test
-  public void enableSequencesNoGap() throws Exception {
-    testEnableSequences(0, 3, "13");
-  }
-
-  @Test
-  public void enableSequencesWithGap() throws Exception {
-    testEnableSequences(-1, 502, "512");
-  }
-
-  private void testEnableSequences(int builderOption, int expectedFirstId, String expectedRefValue)
-      throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    assertThat(id.get()).isEqualTo(1);
-
-    migrate(
-        b ->
-            b.setSequenceGap(builderOption)
-                .setStopAtStateForTesting(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY));
-
-    assertThat(sequences.nextChangeId()).isEqualTo(expectedFirstId);
-    assertThat(sequences.nextChangeId()).isEqualTo(expectedFirstId + 1);
-
-    try (Repository repo = repoManager.openRepository(allProjects);
-        ObjectReader reader = repo.newObjectReader()) {
-      Ref ref = repo.exactRef("refs/sequences/changes");
-      assertThat(ref).isNotNull();
-      ObjectLoader loader = reader.open(ref.getObjectId());
-      assertThat(loader.getType()).isEqualTo(Constants.OBJ_BLOB);
-      // Acquired a block of 10 to serve the first nextChangeId call after migration.
-      assertThat(new String(loader.getCachedBytes(), UTF_8)).isEqualTo(expectedRefValue);
-    }
-
-    try (ReviewDb db = schemaFactory.open()) {
-      // Underlying, unused ReviewDb is still on its own sequence.
-      @SuppressWarnings("deprecation")
-      int nextFromReviewDb = db.nextChangeId();
-      assertThat(nextFromReviewDb).isEqualTo(3);
-    }
-  }
-
-  @Test
-  public void fullMigrationSameThread() throws Exception {
-    testFullMigration(1);
-  }
-
-  @Test
-  public void fullMigrationMultipleThreads() throws Exception {
-    testFullMigration(2);
-  }
-
-  private void testFullMigration(int threads) throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    Change.Id id1 = r1.getChange().getId();
-    Change.Id id2 = r2.getChange().getId();
-
-    Set<String> objectFiles = getObjectFiles(project);
-    assertThat(objectFiles).isNotEmpty();
-
-    migrate(b -> b.setThreads(threads));
-
-    assertNotesMigrationState(NOTE_DB, false, false);
-    assertThat(sequences.nextChangeId()).isEqualTo(503);
-    assertThat(getObjectFiles(project)).containsExactlyElementsIn(objectFiles);
-
-    ObjectId oldMetaId = null;
-    int rowVersion = 0;
-    try (ReviewDb db = schemaFactory.open();
-        Repository repo = repoManager.openRepository(project)) {
-      for (Change.Id id : ImmutableList.of(id1, id2)) {
-        String refName = RefNames.changeMetaRef(id);
-        Ref ref = repo.exactRef(refName);
-        assertThat(ref).named(refName).isNotNull();
-
-        Change c = db.changes().get(id);
-        assertThat(c.getTopic()).named("topic of change %s", id).isNull();
-        NoteDbChangeState s = NoteDbChangeState.parse(c);
-        assertThat(s.getPrimaryStorage())
-            .named("primary storage of change %s", id)
-            .isEqualTo(PrimaryStorage.NOTE_DB);
-        assertThat(s.getRefState()).named("ref state of change %s").isEmpty();
-
-        if (id.equals(id1)) {
-          oldMetaId = ref.getObjectId();
-          rowVersion = c.getRowVersion();
-        }
-      }
-    }
-
-    // Do not open a new context, to simulate races with other threads that opened a context earlier
-    // in the migration process; this needs to work.
-    gApi.changes().id(id1.get()).topic(name("a-topic"));
-
-    // Of course, it should also work with a new context.
-    resetCurrentApiUser();
-    gApi.changes().id(id1.get()).topic(name("another-topic"));
-
-    try (ReviewDb db = schemaFactory.open();
-        Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef(RefNames.changeMetaRef(id1)).getObjectId()).isNotEqualTo(oldMetaId);
-
-      Change c = db.changes().get(id1);
-      assertThat(c.getTopic()).isNull();
-      assertThat(c.getRowVersion()).isEqualTo(rowVersion);
-    }
-  }
-
-  @Test
-  public void fullMigrationOneChangeWithNoPatchSets() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    Change.Id id1 = r1.getChange().getId();
-    Change.Id id2 = r2.getChange().getId();
-
-    db.changes().beginTransaction(id2);
-    try {
-      db.patchSets().delete(db.patchSets().byChange(id2));
-      db.commit();
-    } finally {
-      db.rollback();
-    }
-
-    migrate(b -> b);
-    assertNotesMigrationState(NOTE_DB, false, false);
-
-    try (ReviewDb db = schemaFactory.open();
-        Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef(RefNames.changeMetaRef(id1))).isNotNull();
-      assertThat(db.changes().get(id1).getNoteDbState()).isEqualTo(NOTE_DB_PRIMARY_STATE);
-
-      // A change with no patch sets is so corrupt that it is completely skipped by the migration
-      // process.
-      assertThat(repo.exactRef(RefNames.changeMetaRef(id2))).isNull();
-      assertThat(db.changes().get(id2).getNoteDbState()).isNull();
-    }
-  }
-
-  @Test
-  public void fullMigrationMissingPatchSetRefs() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      RefUpdate u = repo.updateRef(new PatchSet.Id(id, 1).toRefName());
-      u.setForceUpdate(true);
-      assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
-    }
-
-    ChangeBundle reviewDbBundle;
-    try (ReviewDb db = schemaFactory.open()) {
-      reviewDbBundle = changeBundleReader.fromReviewDb(db, id);
-    }
-
-    migrate(b -> b);
-    assertNotesMigrationState(NOTE_DB, false, false);
-
-    try (ReviewDb db = schemaFactory.open();
-        Repository repo = repoManager.openRepository(project)) {
-      // Change migrated successfully even though it was missing patch set refs.
-      assertThat(repo.exactRef(RefNames.changeMetaRef(id))).isNotNull();
-      assertThat(db.changes().get(id).getNoteDbState()).isEqualTo(NOTE_DB_PRIMARY_STATE);
-
-      ChangeBundle noteDbBundle =
-          ChangeBundle.fromNotes(commentsUtil, notesFactory.createChecked(db, project, id));
-      assertThat(noteDbBundle.differencesFrom(reviewDbBundle)).isEmpty();
-    }
-  }
-
-  @Test
-  public void autoMigrationConfig() throws Exception {
-    createChange();
-
-    migrate(b -> b.setStopAtStateForTesting(WRITE));
-    assertNotesMigrationState(WRITE, false, false);
-
-    migrate(b -> b.setAutoMigrate(true).setStopAtStateForTesting(READ_WRITE_NO_SEQUENCE));
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, true, false);
-
-    migrate(b -> b);
-    assertNotesMigrationState(NOTE_DB, false, false);
-  }
-
-  @Test
-  public void notesMigrationStateListener() throws Exception {
-    NotesMigrationStateListener listener = createStrictMock(NotesMigrationStateListener.class);
-    listener.preStateChange(REVIEW_DB, WRITE);
-    expectLastCall();
-    listener.preStateChange(WRITE, READ_WRITE_NO_SEQUENCE);
-    expectLastCall();
-    listener.preStateChange(READ_WRITE_NO_SEQUENCE, READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
-    expectLastCall();
-    listener.preStateChange(
-        READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY, READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
-    listener.preStateChange(READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY, NOTE_DB);
-    expectLastCall();
-    replay(listener);
-    addListener(listener);
-
-    createChange();
-    migrate(b -> b);
-    assertNotesMigrationState(NOTE_DB, false, false);
-    verify(listener);
-  }
-
-  @Test
-  public void notesMigrationStateListenerFails() throws Exception {
-    NotesMigrationStateListener listener = createStrictMock(NotesMigrationStateListener.class);
-    listener.preStateChange(REVIEW_DB, WRITE);
-    expectLastCall();
-    listener.preStateChange(WRITE, READ_WRITE_NO_SEQUENCE);
-    IOException listenerException = new IOException("Listener failed");
-    expectLastCall().andThrow(listenerException);
-    replay(listener);
-    addListener(listener);
-
-    createChange();
-    try {
-      migrate(b -> b);
-      fail("expected IOException");
-    } catch (IOException e) {
-      assertThat(e).isSameAs(listenerException);
-    }
-    assertNotesMigrationState(WRITE, false, false);
-    verify(listener);
-  }
-
-  private void assertNotesMigrationState(
-      NotesMigrationState expected, boolean autoMigrate, boolean trialMode) throws Exception {
-    assertThat(NotesMigrationState.forNotesMigration(notesMigration)).hasValue(expected);
-    noteDbConfig.load();
-    assertThat(NotesMigrationState.forConfig(noteDbConfig)).hasValue(expected);
-    assertThat(NoteDbMigrator.getAutoMigrate(noteDbConfig))
-        .named("noteDb.changes.autoMigrate")
-        .isEqualTo(autoMigrate);
-    assertThat(NoteDbMigrator.getTrialMode(noteDbConfig))
-        .named("noteDb.changes.trial")
-        .isEqualTo(trialMode);
-  }
-
-  private void setNotesMigrationState(NotesMigrationState state) throws Exception {
-    noteDbConfig.load();
-    state.setConfigValues(noteDbConfig);
-    noteDbConfig.save();
-    notesMigration.setFrom(state);
-  }
-
-  @FunctionalInterface
-  interface PrepareBuilder {
-    NoteDbMigrator.Builder prepare(NoteDbMigrator.Builder b) throws Exception;
-  }
-
-  @FunctionalInterface
-  interface RunMigration {
-    void run(NoteDbMigrator m) throws Exception;
-  }
-
-  private void migrate(PrepareBuilder b) throws Exception {
-    migrate(b, NoteDbMigrator::migrate);
-  }
-
-  private void migrate(PrepareBuilder b, RunMigration m) throws Exception {
-    try (NoteDbMigrator migrator = b.prepare(migratorBuilderProvider.get()).build()) {
-      m.run(migrator);
-    }
-  }
-
-  private void assertMigrationException(
-      String expectMessageContains, PrepareBuilder b, RunMigration m) throws Exception {
-    try {
-      migrate(b, m);
-      fail("expected MigrationException");
-    } catch (MigrationException e) {
-      assertThat(e).hasMessageThat().contains(expectMessageContains);
-    }
-  }
-
-  private void addListener(NotesMigrationStateListener listener) {
-    addedListeners.add(listeners.add(listener));
-  }
-
-  private ImmutableSortedSet<String> getObjectFiles(Project.NameKey project) throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        Stream<Path> paths =
-            Files.walk(((FileRepository) repo).getObjectDatabase().getDirectory().toPath())) {
-      return paths
-          .filter(path -> !Files.isDirectory(path))
-          .map(Path::toString)
-          .filter(name -> !name.endsWith(".pack") && !name.endsWith(".idx"))
-          .collect(toImmutableSortedSet(naturalOrder()));
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
deleted file mode 100644
index 834dbfa..0000000
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.reviewdb.client.Change;
-import java.io.File;
-import org.eclipse.jgit.lib.ReflogEntry;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Test;
-
-@UseLocalDisk
-public class ReflogIT extends AbstractDaemonTest {
-  @Before
-  public void setUp() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-  }
-
-  @Test
-  public void guessRestApiInReflog() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
-      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
-      if (!log.exists()) {
-        log.getParentFile().mkdirs();
-        assertThat(log.createNewFile()).isTrue();
-      }
-
-      gApi.changes().id(id.get()).topic("foo");
-      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
-      assertThat(last).named("last RefLogEntry").isNotNull();
-      assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java
index 720eeed..bea3633 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java
@@ -18,8 +18,9 @@
 import static org.junit.Assert.assertNotEquals;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -34,6 +35,7 @@
 public class PermissionBackendConditionIT extends AbstractDaemonTest {
 
   @Inject PermissionBackend pb;
+  @Inject ProjectOperations projectOperations;
 
   @Test
   public void globalPermissions_sameUserAndPermissionEquals() throws Exception {
@@ -110,7 +112,7 @@
 
   @Test
   public void projectPermissions_differentResourceSameUserDoesNotEqual() throws Exception {
-    Project.NameKey project2 = createProject("p2");
+    Project.NameKey project2 = projectOperations.newProject().create();
     BooleanCondition cond1 = pb.user(user()).project(project).testCond(ProjectPermission.READ);
     BooleanCondition cond2 = pb.user(user()).project(project2).testCond(ProjectPermission.READ);
 
@@ -120,7 +122,7 @@
 
   @Test
   public void refPermissions_sameResourceAndUserEquals() throws Exception {
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    BranchNameKey branch = BranchNameKey.create(project, "branch");
     BooleanCondition cond1 = pb.user(user()).ref(branch).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(user()).ref(branch).testCond(RefPermission.READ);
 
@@ -130,7 +132,7 @@
 
   @Test
   public void refPermissions_sameResourceAndDifferentUserDoesNotEqual() throws Exception {
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    BranchNameKey branch = BranchNameKey.create(project, "branch");
     BooleanCondition cond1 = pb.user(user()).ref(branch).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(admin()).ref(branch).testCond(RefPermission.READ);
 
@@ -140,8 +142,8 @@
 
   @Test
   public void refPermissions_differentResourceAndSameUserDoesNotEqual() throws Exception {
-    Branch.NameKey branch1 = new Branch.NameKey(project, "branch");
-    Branch.NameKey branch2 = new Branch.NameKey(project, "branch2");
+    BranchNameKey branch1 = BranchNameKey.create(project, "branch");
+    BranchNameKey branch2 = BranchNameKey.create(project, "branch2");
     BooleanCondition cond1 = pb.user(user()).ref(branch1).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(user()).ref(branch2).testCond(RefPermission.READ);
 
@@ -151,8 +153,8 @@
 
   @Test
   public void refPermissions_differentResourceAndSameUserDoesNotEqual2() throws Exception {
-    Branch.NameKey branch1 = new Branch.NameKey(project, "branch");
-    Branch.NameKey branch2 = new Branch.NameKey(createProject("p2"), "branch");
+    BranchNameKey branch1 = BranchNameKey.create(project, "branch");
+    BranchNameKey branch2 = BranchNameKey.create(projectOperations.newProject().create(), "branch");
     BooleanCondition cond1 = pb.user(user()).ref(branch1).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(user()).ref(branch2).testCond(RefPermission.READ);
 
@@ -161,10 +163,10 @@
   }
 
   private CurrentUser user() {
-    return identifiedUserFactory.create(user.id);
+    return identifiedUserFactory.create(user.id());
   }
 
   private CurrentUser admin() {
-    return identifiedUserFactory.create(admin.id);
+    return identifiedUserFactory.create(admin.id());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/BUILD b/javatests/com/google/gerrit/acceptance/server/project/BUILD
index efa1cdb..42dfbac 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/project/BUILD
@@ -4,4 +4,5 @@
     srcs = glob(["*IT.java"]),
     group = "server_project",
     labels = ["server"],
+    deps = ["//java/com/google/gerrit/mail"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 45b7767..f80f86b 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.common.data.LabelFunction.ANY_WITH_BLOCK;
 import static com.google.gerrit.common.data.LabelFunction.MAX_NO_BLOCK;
 import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
@@ -24,15 +26,17 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -41,10 +45,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import org.junit.After;
@@ -55,45 +56,30 @@
 public class CustomLabelIT extends AbstractDaemonTest {
 
   @Inject private DynamicSet<CommentAddedListener> source;
+  @Inject private ProjectOperations projectOperations;
 
   private final LabelType label =
-      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
 
-  private final LabelType P = category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
+  private final LabelType P = label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   private RegistrationHandle eventListenerRegistration;
   private CommentAddedListener.Event lastCommentAddedEvent;
 
   @Before
   public void setUp() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(label.getName()),
-          -1,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(P.getName()), 0, 1, anonymousUsers, "refs/heads/*");
-      u.save();
-    }
-
-    eventListenerRegistration =
-        source.add(
-            new CommentAddedListener() {
-              @Override
-              public void onCommentAdded(Event event) {
-                lastCommentAddedEvent = event;
-              }
-            });
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(P.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .update();
+    eventListenerRegistration = source.add("gerrit", event -> lastCommentAddedEvent = event);
   }
 
   @After
   public void cleanup() {
     eventListenerRegistration.remove();
-    db.close();
   }
 
   @Test
@@ -186,7 +172,7 @@
     saveLabelConfig();
     PushOneCommit.Result r = createChange();
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     ReviewInput input = new ReviewInput().label(P.getName(), 0);
@@ -273,15 +259,17 @@
     assertPermitted(info, P.getName(), 0, 1);
     assertPermitted(info, label.getName());
 
-    ReviewInput in = new ReviewInput();
-    in.label(P.getName(), P.getMax().getValue());
-    revision(r).review(in);
+    ReviewInput postSubmitReview1 = new ReviewInput();
+    postSubmitReview1.label(P.getName(), P.getMax().getValue());
+    revision(r).review(postSubmitReview1);
 
-    in = new ReviewInput();
-    in.label(label.getName(), label.getMax().getValue());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Voting on labels disallowed after submit: " + label.getName());
-    revision(r).review(in);
+    ReviewInput postSubmitReview2 = new ReviewInput();
+    postSubmitReview2.label(label.getName(), label.getMax().getValue());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision(r).review(postSubmitReview2));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Voting on labels disallowed after submit: " + label.getName());
   }
 
   @Test
@@ -297,11 +285,11 @@
         value(-1, "I would prefer this is not merged as is"),
         value(-2, "This shall not be merged"));
 
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.forLabel(testLabel), -2, +2, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabel).ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -319,11 +307,12 @@
     assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
 
     // Update admin's permitted range for 'Test-Label' to be -1...+1.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.remove(u.getConfig(), Permission.forLabel(testLabel), registered, "refs/heads/*");
-      Util.allow(u.getConfig(), Permission.forLabel(testLabel), -1, +1, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(testLabel).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(allowLabel(testLabel).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
 
     // Verify admin doesn't have +2 permission any more.
     assertPermitted(gApi.changes().id(changeId).get(), testLabel, -1, 0, 1);
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index ca0cae4..1d656ea 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
 
 import com.google.common.collect.ImmutableSet;
@@ -22,16 +23,19 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.git.NotifyConfig;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.inject.Inject;
 import java.util.EnumSet;
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -40,6 +44,9 @@
 
 @NoHttpd
 public class ProjectWatchIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
     Address addr = new Address("Watcher", "watcher@example.com");
@@ -57,20 +64,19 @@
 
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "original subject", "a", "a1")
+            .create(admin.newIdent(), testRepo, "original subject", "a", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
     r =
         pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, "super sekret subject", "a", "a2", r.getChangeId())
+            .create(admin.newIdent(), testRepo, "super sekret subject", "a", "a2", r.getChangeId())
             .to("refs/for/master");
     r.assertOkStatus();
 
     r =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "back to original subject", "a", "a3")
+            .create(admin.newIdent(), testRepo, "back to original subject", "a", "a3")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -99,13 +105,13 @@
     sender.clear();
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "private change", "a", "a1")
+            .create(admin.newIdent(), testRepo, "private change", "a", "a1")
             .to("refs/for/master%private");
     r.assertOkStatus();
 
     assertThat(sender.getMessages()).isEmpty();
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     ReviewInput in = new ReviewInput();
     in.message = "comment";
     gApi.changes().id(r.getChangeId()).current().review(in);
@@ -129,16 +135,14 @@
     }
 
     PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
-            .to("refs/for/master");
+        pushFactory.create(admin.newIdent(), testRepo, "subject", "a", "a1").to("refs/for/master");
     r.assertOkStatus();
 
     sender.clear();
 
     r =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .create(admin.newIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
             .to("refs/for/master%private");
     r.assertOkStatus();
 
@@ -162,13 +166,13 @@
     sender.clear();
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "wip change", "a", "a1")
+            .create(admin.newIdent(), testRepo, "wip change", "a", "a1")
             .to("refs/for/master%wip");
     r.assertOkStatus();
 
     assertThat(sender.getMessages()).isEmpty();
 
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     ReviewInput in = new ReviewInput();
     in.message = "comment";
     gApi.changes().id(r.getChangeId()).current().review(in);
@@ -191,16 +195,14 @@
     }
 
     PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
-            .to("refs/for/master");
+        pushFactory.create(admin.newIdent(), testRepo, "subject", "a", "a1").to("refs/for/master");
     r.assertOkStatus();
 
     sender.clear();
 
     r =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .create(admin.newIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
             .to("refs/for/master%wip");
     r.assertOkStatus();
 
@@ -210,28 +212,28 @@
   @Test
   public void watchProject() throws Exception {
     // watch project
-    String watchedProject = createProject("watchedProject").get();
-    setApiUser(user);
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
     watch(watchedProject);
 
     // push a change to watched project -> should trigger email notification
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+            .create(admin.newIdent(), watchedRepo, "TRIGGER", "a", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
     // push a change to non-watched project -> should not trigger email
     // notification
-    String notWatchedProject = createProject("otherProject").get();
+    String notWatchedProject = projectOperations.newProject().create().get();
     TestRepository<InMemoryRepository> notWatchedRepo =
-        cloneProject(new Project.NameKey(notWatchedProject), admin);
+        cloneProject(Project.nameKey(notWatchedProject), admin);
     r =
         pushFactory
-            .create(db, admin.getIdent(), notWatchedRepo, "DONT_TRIGGER", "a", "a1")
+            .create(admin.newIdent(), notWatchedRepo, "DONT_TRIGGER", "a", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -239,16 +241,16 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
 
   @Test
   public void watchFile() throws Exception {
-    String watchedProject = createProject("watchedProject").get();
-    String otherWatchedProject = createProject("otherWatchedProject").get();
-    setApiUser(user);
+    String watchedProject = projectOperations.newProject().create().get();
+    String otherWatchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
 
     // watch file in project as user
     watch(watchedProject, "file:a.txt");
@@ -258,12 +260,12 @@
 
     // push a change to watched file -> should trigger email notification for
     // user
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a.txt", "a1")
+            .create(admin.newIdent(), watchedRepo, "TRIGGER", "a.txt", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -271,21 +273,21 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
 
     // watch project as user2
     TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
-    setApiUser(user2);
+    requestScopeOperations.setApiUser(user2.id());
     watch(watchedProject);
 
     // push a change to non-watched file -> should not trigger email
     // notification for user, only for user2
     r =
         pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "TRIGGER_USER2", "b.txt", "b1")
+            .create(admin.newIdent(), watchedRepo, "TRIGGER_USER2", "b.txt", "b1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -293,26 +295,26 @@
     messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user2.getEmailAddress());
     assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
 
   @Test
   public void watchKeyword() throws Exception {
-    String watchedProject = createProject("watchedProject").get();
-    setApiUser(user);
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
 
     // watch keyword in project as user
     watch(watchedProject, "multimaster");
 
     // push a change with keyword -> should trigger email notification
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "Document multimaster setup", "a.txt", "a1")
+            .create(admin.newIdent(), watchedRepo, "Document multimaster setup", "a.txt", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -320,7 +322,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
     assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
@@ -328,8 +330,7 @@
     // push a change without keyword -> should not trigger email notification
     r =
         pushFactory
-            .create(
-                db, admin.getIdent(), watchedRepo, "Cleanup cache implementation", "b.txt", "b1")
+            .create(admin.newIdent(), watchedRepo, "Cleanup cache implementation", "b.txt", "b1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -339,35 +340,32 @@
 
   @Test
   public void watchAllProjects() throws Exception {
-    String anyProject = createProject("anyProject").get();
-    setApiUser(user);
+    String anyProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
 
     // watch the All-Projects project to watch all projects
     watch(allProjects.get());
 
     // push a change to any project -> should trigger email notification
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> anyRepo = cloneProject(Project.nameKey(anyProject), admin);
     PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a", "a1")
-            .to("refs/for/master");
+        pushFactory.create(admin.newIdent(), anyRepo, "TRIGGER", "a", "a1").to("refs/for/master");
     r.assertOkStatus();
 
     // assert email notification
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
 
   @Test
   public void watchFileAllProjects() throws Exception {
-    String anyProject = createProject("anyProject").get();
-    setApiUser(user);
+    String anyProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
 
     // watch file in All-Projects project as user to watch the file in all
     // projects
@@ -375,12 +373,11 @@
 
     // push a change to watched file in any project -> should trigger email
     // notification for user
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> anyRepo = cloneProject(Project.nameKey(anyProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a.txt", "a1")
+            .create(admin.newIdent(), anyRepo, "TRIGGER", "a.txt", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -388,21 +385,21 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
 
     // watch project as user2
     TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
-    setApiUser(user2);
+    requestScopeOperations.setApiUser(user2.id());
     watch(anyProject);
 
     // push a change to non-watched file in any project -> should not trigger
     // email notification for user, only for user2
     r =
         pushFactory
-            .create(db, admin.getIdent(), anyRepo, "TRIGGER_USER2", "b.txt", "b1")
+            .create(admin.newIdent(), anyRepo, "TRIGGER_USER2", "b.txt", "b1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -410,27 +407,26 @@
     messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user2.getEmailAddress());
     assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
 
   @Test
   public void watchKeywordAllProjects() throws Exception {
-    String anyProject = createProject("anyProject").get();
-    setApiUser(user);
+    String anyProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
 
     // watch keyword in project as user
     watch(allProjects.get(), "multimaster");
 
     // push a change with keyword to any project -> should trigger email
     // notification
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> anyRepo = cloneProject(Project.nameKey(anyProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), anyRepo, "Document multimaster setup", "a.txt", "a1")
+            .create(admin.newIdent(), anyRepo, "Document multimaster setup", "a.txt", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -438,7 +434,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
     assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
@@ -447,7 +443,7 @@
     // notification
     r =
         pushFactory
-            .create(db, admin.getIdent(), anyRepo, "Cleanup cache implementation", "b.txt", "b1")
+            .create(admin.newIdent(), anyRepo, "Cleanup cache implementation", "b.txt", "b1")
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -458,28 +454,28 @@
   @Test
   public void watchProjectNoNotificationForIgnoredChange() throws Exception {
     // watch project
-    String watchedProject = createProject("watchedProject").get();
-    setApiUser(user);
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
     watch(watchedProject);
 
     // push a change to watched project
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "ignored change", "a", "a1")
+            .create(admin.newIdent(), watchedRepo, "ignored change", "a", "a1")
             .to("refs/for/master");
     r.assertOkStatus();
 
     // ignore the change
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
 
     sender.clear();
 
     // post a comment -> should not trigger email notification since user ignored the change
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     ReviewInput in = new ReviewInput();
     in.message = "comment";
     gApi.changes().id(r.getChangeId()).current().review(in);
@@ -491,17 +487,17 @@
   @Test
   public void watchProjectNoNotificationForPrivateChange() throws Exception {
     // watch project
-    String watchedProject = createProject("watchedProject").get();
-    setApiUser(user);
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
     watch(watchedProject);
 
     // push a private change to watched project -> should not trigger email notification
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "private change", "a", "a1")
+            .create(admin.newIdent(), watchedRepo, "private change", "a", "a1")
             .to("refs/for/master%private");
     r.assertOkStatus();
 
@@ -511,37 +507,39 @@
 
   @Test
   public void watchProjectNotifyOnPrivateChange() throws Exception {
-    String watchedProject = createProject("watchedProject").get();
+    String watchedProject = projectOperations.newProject().create().get();
 
     // create group that can view all private changes
     GroupInfo groupThatCanViewPrivateChanges =
         gApi.groups().create("groupThatCanViewPrivateChanges").get();
-    grant(
-        new Project.NameKey(watchedProject),
-        "refs/*",
-        Permission.VIEW_PRIVATE_CHANGES,
-        false,
-        new AccountGroup.UUID(groupThatCanViewPrivateChanges.id));
+    projectOperations
+        .project(Project.nameKey(watchedProject))
+        .forUpdate()
+        .add(
+            allow(Permission.VIEW_PRIVATE_CHANGES)
+                .ref("refs/*")
+                .group(AccountGroup.uuid(groupThatCanViewPrivateChanges.id)))
+        .update();
 
     // watch project as user that can't view private changes
-    setApiUser(user);
+    requestScopeOperations.setApiUser(user.id());
     watch(watchedProject);
 
     // watch project as user that can view all private change
     TestAccount userThatCanViewPrivateChanges =
         accountCreator.create(
             "user2", "user2@test.com", "User2", groupThatCanViewPrivateChanges.name);
-    setApiUser(userThatCanViewPrivateChanges);
+    requestScopeOperations.setApiUser(userThatCanViewPrivateChanges.id());
     watch(watchedProject);
 
     // push a private change to watched project -> should trigger email notification for
     // userThatCanViewPrivateChanges, but not for user
-    setApiUser(admin);
+    requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+            .create(admin.newIdent(), watchedRepo, "TRIGGER", "a", "a1")
             .to("refs/for/master%private");
     r.assertOkStatus();
 
@@ -549,7 +547,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.emailAddress);
+    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.getEmailAddress());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
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..a230e35
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -0,0 +1,116 @@
+// 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.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+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.inject.Inject;
+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 {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void guessRestApiInReflog() throws Exception {
+    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();
+      assertWithMessage("last RefLogEntry").that(last).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 {
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        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");
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(AccountGroup.uuid(groupApi.get().id)))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  public void adminUserIsAllowedToGetReflog() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/BUILD b/javatests/com/google/gerrit/acceptance/server/quota/BUILD
new file mode 100644
index 0000000..1988b22
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/quota/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_quota",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java b/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
new file mode 100644
index 0000000..7d4b95e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
@@ -0,0 +1,240 @@
+// 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.quota;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.resetToStrict;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.quota.QuotaBackend;
+import com.google.gerrit.server.quota.QuotaEnforcer;
+import com.google.gerrit.server.quota.QuotaException;
+import com.google.gerrit.server.quota.QuotaRequestContext;
+import com.google.gerrit.server.quota.QuotaResponse;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.Collections;
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+public class DefaultQuotaBackendIT extends AbstractDaemonTest {
+
+  private static final QuotaEnforcer quotaEnforcer = EasyMock.createStrictMock(QuotaEnforcer.class);
+
+  private IdentifiedUser identifiedAdmin;
+  @Inject private QuotaBackend quotaBackend;
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        bind(QuotaEnforcer.class)
+            .annotatedWith(Exports.named("TestQuotaEnforcer"))
+            .toProvider(() -> quotaEnforcer);
+      }
+    };
+  }
+
+  @Before
+  public void setUp() {
+    identifiedAdmin = identifiedUserFactory.create(admin.id());
+    resetToStrict(quotaEnforcer);
+  }
+
+  @Test
+  public void requestTokenForUser() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
+    replay(quotaEnforcer);
+    assertThat(quotaBackend.user(identifiedAdmin).requestToken("testGroup"))
+        .isEqualTo(singletonAggregation(QuotaResponse.ok()));
+  }
+
+  @Test
+  public void requestTokenForUserAndAccount() {
+    QuotaRequestContext ctx =
+        QuotaRequestContext.builder().user(identifiedAdmin).account(user.id()).build();
+    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
+    replay(quotaEnforcer);
+    assertThat(quotaBackend.user(identifiedAdmin).account(user.id()).requestToken("testGroup"))
+        .isEqualTo(singletonAggregation(QuotaResponse.ok()));
+  }
+
+  @Test
+  public void requestTokenForUserAndProject() {
+    QuotaRequestContext ctx =
+        QuotaRequestContext.builder().user(identifiedAdmin).project(project).build();
+    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
+    replay(quotaEnforcer);
+    assertThat(quotaBackend.user(identifiedAdmin).project(project).requestToken("testGroup"))
+        .isEqualTo(singletonAggregation(QuotaResponse.ok()));
+  }
+
+  @Test
+  public void requestTokenForUserAndChange() throws Exception {
+    Change.Id changeId = retrieveChangeId();
+    QuotaRequestContext ctx =
+        QuotaRequestContext.builder()
+            .user(identifiedAdmin)
+            .change(changeId)
+            .project(project)
+            .build();
+    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
+    replay(quotaEnforcer);
+    assertThat(
+            quotaBackend.user(identifiedAdmin).change(changeId, project).requestToken("testGroup"))
+        .isEqualTo(singletonAggregation(QuotaResponse.ok()));
+  }
+
+  @Test
+  public void requestTokens() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcer.requestTokens("testGroup", ctx, 123)).andReturn(QuotaResponse.ok());
+    replay(quotaEnforcer);
+    assertThat(quotaBackend.user(identifiedAdmin).requestTokens("testGroup", 123))
+        .isEqualTo(singletonAggregation(QuotaResponse.ok()));
+  }
+
+  @Test
+  public void dryRun() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcer.dryRun("testGroup", ctx, 123)).andReturn(QuotaResponse.ok());
+    replay(quotaEnforcer);
+    assertThat(quotaBackend.user(identifiedAdmin).dryRun("testGroup", 123))
+        .isEqualTo(singletonAggregation(QuotaResponse.ok()));
+  }
+
+  @Test
+  public void availableTokensForUserAndAccount() {
+    QuotaRequestContext ctx =
+        QuotaRequestContext.builder().user(identifiedAdmin).account(user.id()).build();
+    QuotaResponse r = QuotaResponse.ok(10L);
+    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andReturn(r);
+    replay(quotaEnforcer);
+    assertThat(quotaBackend.user(identifiedAdmin).account(user.id()).availableTokens("testGroup"))
+        .isEqualTo(singletonAggregation(r));
+  }
+
+  @Test
+  public void availableTokensForUserAndProject() {
+    QuotaRequestContext ctx =
+        QuotaRequestContext.builder().user(identifiedAdmin).project(project).build();
+    QuotaResponse r = QuotaResponse.ok(10L);
+    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andReturn(r);
+    replay(quotaEnforcer);
+    assertThat(quotaBackend.user(identifiedAdmin).project(project).availableTokens("testGroup"))
+        .isEqualTo(singletonAggregation(r));
+  }
+
+  @Test
+  public void availableTokensForUserAndChange() throws Exception {
+    Change.Id changeId = retrieveChangeId();
+    QuotaRequestContext ctx =
+        QuotaRequestContext.builder()
+            .user(identifiedAdmin)
+            .change(changeId)
+            .project(project)
+            .build();
+    QuotaResponse r = QuotaResponse.ok(10L);
+    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andReturn(r);
+    replay(quotaEnforcer);
+    assertThat(
+            quotaBackend
+                .user(identifiedAdmin)
+                .change(changeId, project)
+                .availableTokens("testGroup"))
+        .isEqualTo(singletonAggregation(r));
+  }
+
+  @Test
+  public void availableTokens() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    QuotaResponse r = QuotaResponse.ok(10L);
+    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andReturn(r);
+    replay(quotaEnforcer);
+    assertThat(quotaBackend.user(identifiedAdmin).availableTokens("testGroup"))
+        .isEqualTo(singletonAggregation(r));
+  }
+
+  @Test
+  public void requestTokenError() throws Exception {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1))
+        .andReturn(QuotaResponse.error("failed"));
+    replay(quotaEnforcer);
+
+    QuotaResponse.Aggregated result = quotaBackend.user(identifiedAdmin).requestToken("testGroup");
+    assertThat(result).isEqualTo(singletonAggregation(QuotaResponse.error("failed")));
+    QuotaException thrown = assertThrows(QuotaException.class, () -> result.throwOnError());
+    assertThat(thrown).hasMessageThat().contains("failed");
+  }
+
+  @Test
+  public void availableTokensError() throws Exception {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcer.availableTokens("testGroup", ctx))
+        .andReturn(QuotaResponse.error("failed"));
+    replay(quotaEnforcer);
+    QuotaResponse.Aggregated result =
+        quotaBackend.user(identifiedAdmin).availableTokens("testGroup");
+    assertThat(result).isEqualTo(singletonAggregation(QuotaResponse.error("failed")));
+    QuotaException thrown = assertThrows(QuotaException.class, () -> result.throwOnError());
+    assertThat(thrown).hasMessageThat().contains("failed");
+  }
+
+  @Test
+  public void requestTokenPluginThrowsAndRethrows() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andThrow(new NullPointerException());
+    replay(quotaEnforcer);
+
+    assertThrows(
+        NullPointerException.class,
+        () -> quotaBackend.user(identifiedAdmin).requestToken("testGroup"));
+  }
+
+  @Test
+  public void availableTokensPluginThrowsAndRethrows() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcer.availableTokens("testGroup", ctx)).andThrow(new NullPointerException());
+    replay(quotaEnforcer);
+
+    assertThrows(
+        NullPointerException.class,
+        () -> quotaBackend.user(identifiedAdmin).availableTokens("testGroup"));
+  }
+
+  private Change.Id retrieveChangeId() throws Exception {
+    // use REST API so that repository size quota doesn't have to be stubbed
+    ChangeInfo changeInfo =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "test")).get();
+    return Change.id(changeInfo._number);
+  }
+
+  private static QuotaResponse.Aggregated singletonAggregation(QuotaResponse response) {
+    return QuotaResponse.Aggregated.create(Collections.singleton(response));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
new file mode 100644
index 0000000..4f47927
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
@@ -0,0 +1,157 @@
+// 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.quota;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.resetToStrict;
+import static org.easymock.EasyMock.verify;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.quota.QuotaBackend;
+import com.google.gerrit.server.quota.QuotaEnforcer;
+import com.google.gerrit.server.quota.QuotaRequestContext;
+import com.google.gerrit.server.quota.QuotaResponse;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.OptionalLong;
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MultipleQuotaPluginsIT extends AbstractDaemonTest {
+  private static final QuotaEnforcer quotaEnforcerA =
+      EasyMock.createStrictMock(QuotaEnforcer.class);
+  private static final QuotaEnforcer quotaEnforcerB =
+      EasyMock.createStrictMock(QuotaEnforcer.class);
+
+  private IdentifiedUser identifiedAdmin;
+  @Inject private QuotaBackend quotaBackend;
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        bind(QuotaEnforcer.class)
+            .annotatedWith(Exports.named("TestQuotaEnforcerA"))
+            .toProvider(() -> quotaEnforcerA);
+
+        bind(QuotaEnforcer.class)
+            .annotatedWith(Exports.named("TestQuotaEnforcerB"))
+            .toProvider(() -> quotaEnforcerB);
+      }
+    };
+  }
+
+  @Before
+  public void setUp() {
+    identifiedAdmin = identifiedUserFactory.create(admin.id());
+    resetToStrict(quotaEnforcerA);
+    resetToStrict(quotaEnforcerB);
+  }
+
+  @Test
+  public void refillsOnError() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcerA.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
+    expect(quotaEnforcerB.requestTokens("testGroup", ctx, 1))
+        .andReturn(QuotaResponse.error("fail"));
+    quotaEnforcerA.refill("testGroup", ctx, 1);
+    expectLastCall();
+
+    replay(quotaEnforcerA);
+    replay(quotaEnforcerB);
+
+    assertThat(quotaBackend.user(identifiedAdmin).requestToken("testGroup"))
+        .isEqualTo(
+            QuotaResponse.Aggregated.create(
+                ImmutableList.of(QuotaResponse.ok(), QuotaResponse.error("fail"))));
+  }
+
+  @Test
+  public void refillsOnException() {
+    NullPointerException exception = new NullPointerException();
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcerA.requestTokens("testGroup", ctx, 1)).andThrow(exception);
+    expect(quotaEnforcerB.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
+    quotaEnforcerB.refill("testGroup", ctx, 1);
+    expectLastCall();
+
+    replay(quotaEnforcerA);
+    replay(quotaEnforcerB);
+
+    NullPointerException thrown =
+        assertThrows(
+            NullPointerException.class,
+            () -> quotaBackend.user(identifiedAdmin).requestToken("testGroup"));
+    assertThat(exception).isEqualTo(thrown);
+
+    verify(quotaEnforcerA);
+  }
+
+  @Test
+  public void doesNotRefillNoOp() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcerA.requestTokens("testGroup", ctx, 1))
+        .andReturn(QuotaResponse.error("fail"));
+    expect(quotaEnforcerB.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.noOp());
+
+    replay(quotaEnforcerA);
+    replay(quotaEnforcerB);
+
+    assertThat(quotaBackend.user(identifiedAdmin).requestToken("testGroup"))
+        .isEqualTo(
+            QuotaResponse.Aggregated.create(
+                ImmutableList.of(QuotaResponse.error("fail"), QuotaResponse.noOp())));
+  }
+
+  @Test
+  public void minimumAvailableTokens() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcerA.availableTokens("testGroup", ctx)).andReturn(QuotaResponse.ok(20L));
+    expect(quotaEnforcerB.availableTokens("testGroup", ctx)).andReturn(QuotaResponse.ok(10L));
+
+    replay(quotaEnforcerA);
+    replay(quotaEnforcerB);
+
+    OptionalLong tokens =
+        quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
+    assertThat(tokens.isPresent()).isTrue();
+    assertThat(tokens.getAsLong()).isEqualTo(10L);
+  }
+
+  @Test
+  public void ignoreNoOpForAvailableTokens() {
+    QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
+    expect(quotaEnforcerA.availableTokens("testGroup", ctx)).andReturn(QuotaResponse.noOp());
+    expect(quotaEnforcerB.availableTokens("testGroup", ctx)).andReturn(QuotaResponse.ok(20L));
+
+    replay(quotaEnforcerA);
+    replay(quotaEnforcerB);
+
+    OptionalLong tokens =
+        quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
+    assertThat(tokens.isPresent()).isTrue();
+    assertThat(tokens.getAsLong()).isEqualTo(20L);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
new file mode 100644
index 0000000..05b3b83
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.quota;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP;
+import static com.google.gerrit.server.quota.QuotaResponse.ok;
+import static org.easymock.EasyMock.anyLong;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.resetToStrict;
+import static org.easymock.EasyMock.verify;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.quota.QuotaBackend;
+import com.google.gerrit.server.quota.QuotaResponse;
+import com.google.inject.Module;
+import java.util.Collections;
+import org.easymock.EasyMock;
+import org.eclipse.jgit.api.errors.TooLargeObjectInPackException;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseLocalDisk
+public class RepositorySizeQuotaIT extends AbstractDaemonTest {
+  private static final QuotaBackend.WithResource quotaBackendWithResource =
+      EasyMock.createStrictMock(QuotaBackend.WithResource.class);
+  private static final QuotaBackend.WithUser quotaBackendWithUser =
+      EasyMock.createStrictMock(QuotaBackend.WithUser.class);
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        bind(QuotaBackend.class)
+            .toInstance(
+                new QuotaBackend() {
+                  @Override
+                  public WithUser currentUser() {
+                    return quotaBackendWithUser;
+                  }
+
+                  @Override
+                  public WithUser user(CurrentUser user) {
+                    return quotaBackendWithUser;
+                  }
+                });
+      }
+    };
+  }
+
+  @Before
+  public void setUp() {
+    resetToStrict(quotaBackendWithResource);
+    resetToStrict(quotaBackendWithUser);
+  }
+
+  @Test
+  public void pushWithAvailableTokens() throws Exception {
+    expect(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
+        .andReturn(singletonAggregation(ok(276L)))
+        .times(2);
+    expect(quotaBackendWithResource.requestTokens(eq(REPOSITORY_SIZE_GROUP), anyLong()))
+        .andReturn(singletonAggregation(ok()));
+    expect(quotaBackendWithUser.project(project)).andReturn(quotaBackendWithResource).anyTimes();
+    replay(quotaBackendWithResource);
+    replay(quotaBackendWithUser);
+    pushCommit();
+    verify(quotaBackendWithUser);
+    verify(quotaBackendWithResource);
+  }
+
+  @Test
+  public void pushWithNotSufficientTokens() throws Exception {
+    long availableTokens = 1L;
+    expect(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
+        .andReturn(singletonAggregation(ok(availableTokens)))
+        .anyTimes();
+    expect(quotaBackendWithUser.project(project)).andReturn(quotaBackendWithResource).anyTimes();
+    replay(quotaBackendWithResource);
+    replay(quotaBackendWithUser);
+    try {
+      pushCommit();
+      assertWithMessage("expected TooLargeObjectInPackException").fail();
+    } catch (TooLargeObjectInPackException e) {
+      String msg = e.getMessage();
+      assertThat(msg).contains("Object too large");
+      assertThat(msg)
+          .contains(String.format("Max object size limit is %d bytes.", availableTokens));
+    }
+    verify(quotaBackendWithUser);
+    verify(quotaBackendWithResource);
+  }
+
+  @Test
+  public void errorGettingAvailableTokens() throws Exception {
+    String msg = "quota error";
+    expect(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
+        .andReturn(singletonAggregation(QuotaResponse.error(msg)))
+        .anyTimes();
+    expect(quotaBackendWithUser.project(project)).andReturn(quotaBackendWithResource).anyTimes();
+    replay(quotaBackendWithResource);
+    replay(quotaBackendWithUser);
+    try {
+      pushCommit();
+      assertWithMessage("expected TransportException").fail();
+    } catch (TransportException e) {
+      // TransportException has not much info about the cause
+    }
+    verify(quotaBackendWithUser);
+    verify(quotaBackendWithResource);
+  }
+
+  private void pushCommit() throws Exception {
+    createCommitAndPush(testRepo, "refs/heads/master", "test 01", "file.test", "some content");
+  }
+
+  private static QuotaResponse.Aggregated singletonAggregation(QuotaResponse response) {
+    return QuotaResponse.Aggregated.create(Collections.singleton(response));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/RestApiQuotaIT.java b/javatests/com/google/gerrit/acceptance/server/quota/RestApiQuotaIT.java
new file mode 100644
index 0000000..802f55f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/quota/RestApiQuotaIT.java
@@ -0,0 +1,144 @@
+// 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.quota;
+
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.reset;
+import static org.easymock.EasyMock.verify;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.quota.QuotaBackend;
+import com.google.gerrit.server.quota.QuotaResponse;
+import com.google.inject.Module;
+import java.util.Collections;
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RestApiQuotaIT extends AbstractDaemonTest {
+  private static final QuotaBackend.WithResource quotaBackendWithResource =
+      EasyMock.createStrictMock(QuotaBackend.WithResource.class);
+  private static final QuotaBackend.WithUser quotaBackendWithUser =
+      EasyMock.createStrictMock(QuotaBackend.WithUser.class);
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        bind(QuotaBackend.class)
+            .toInstance(
+                new QuotaBackend() {
+                  @Override
+                  public WithUser currentUser() {
+                    return quotaBackendWithUser;
+                  }
+
+                  @Override
+                  public WithUser user(CurrentUser user) {
+                    return quotaBackendWithUser;
+                  }
+                });
+      }
+    };
+  }
+
+  @Before
+  public void setUp() {
+    reset(quotaBackendWithResource);
+    reset(quotaBackendWithUser);
+  }
+
+  @Test
+  public void changeDetail() throws Exception {
+    Change.Id changeId = retrieveChangeId();
+    expect(quotaBackendWithResource.requestToken("/restapi/changes/detail:GET"))
+        .andReturn(singletonAggregation(QuotaResponse.ok()));
+    replay(quotaBackendWithResource);
+    expect(quotaBackendWithUser.change(changeId, project)).andReturn(quotaBackendWithResource);
+    replay(quotaBackendWithUser);
+    adminRestSession.get("/changes/" + changeId + "/detail").assertOK();
+    verify(quotaBackendWithUser);
+    verify(quotaBackendWithResource);
+  }
+
+  @Test
+  public void revisionDetail() throws Exception {
+    Change.Id changeId = retrieveChangeId();
+    expect(quotaBackendWithResource.requestToken("/restapi/changes/revisions/actions:GET"))
+        .andReturn(singletonAggregation(QuotaResponse.ok()));
+    replay(quotaBackendWithResource);
+    expect(quotaBackendWithUser.change(changeId, project)).andReturn(quotaBackendWithResource);
+    replay(quotaBackendWithUser);
+    adminRestSession.get("/changes/" + changeId + "/revisions/current/actions").assertOK();
+    verify(quotaBackendWithUser);
+    verify(quotaBackendWithResource);
+  }
+
+  @Test
+  public void createChangePost() throws Exception {
+    expect(quotaBackendWithUser.requestToken("/restapi/changes:POST"))
+        .andReturn(singletonAggregation(QuotaResponse.ok()));
+    replay(quotaBackendWithUser);
+    ChangeInput changeInput = new ChangeInput(project.get(), "master", "test");
+    adminRestSession.post("/changes/", changeInput).assertCreated();
+    verify(quotaBackendWithUser);
+  }
+
+  @Test
+  public void accountDetail() throws Exception {
+    expect(quotaBackendWithResource.requestToken("/restapi/accounts/detail:GET"))
+        .andReturn(singletonAggregation(QuotaResponse.ok()));
+    replay(quotaBackendWithResource);
+    expect(quotaBackendWithUser.account(admin.id())).andReturn(quotaBackendWithResource);
+    replay(quotaBackendWithUser);
+    adminRestSession.get("/accounts/self/detail").assertOK();
+    verify(quotaBackendWithUser);
+    verify(quotaBackendWithResource);
+  }
+
+  @Test
+  public void config() throws Exception {
+    expect(quotaBackendWithUser.requestToken("/restapi/config/version:GET"))
+        .andReturn(singletonAggregation(QuotaResponse.ok()));
+    replay(quotaBackendWithUser);
+    adminRestSession.get("/config/server/version").assertOK();
+  }
+
+  @Test
+  public void outOfQuotaReturnsError() throws Exception {
+    expect(quotaBackendWithUser.requestToken("/restapi/config/version:GET"))
+        .andReturn(singletonAggregation(QuotaResponse.error("no quota")));
+    replay(quotaBackendWithUser);
+    adminRestSession.get("/config/server/version").assertStatus(429);
+  }
+
+  private Change.Id retrieveChangeId() throws Exception {
+    // use REST API so that repository size quota doesn't have to be stubbed
+    ChangeInfo changeInfo =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "test")).get();
+    return Change.id(changeInfo._number);
+  }
+
+  private static QuotaResponse.Aggregated singletonAggregation(QuotaResponse response) {
+    return QuotaResponse.Aggregated.create(Collections.singleton(response));
+  }
+}
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..83782c9
--- /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(user.newIdent(), userTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    // Approve as admin
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+    assertThat(submitRecords).isEmpty();
+  }
+
+  @Test
+  public void doesNothingByDefault() throws Exception {
+    enableRule("Code-Review", false);
+
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+    assertThat(submitRecords).isEmpty();
+  }
+
+  private void enableRule(String labelName, boolean newState) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Map<String, LabelType> localLabelSections = u.getConfig().getLabelSections();
+      if (localLabelSections.isEmpty()) {
+        localLabelSections.putAll(projectCache.getAllProjects().getConfig().getLabelSections());
+      }
+      localLabelSections.get(labelName).setIgnoreSelfApproval(newState);
+      u.save();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
index 8fc32b4..c6f2024 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -32,6 +32,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class PrologRuleEvaluatorIT extends AbstractDaemonTest {
@@ -87,17 +88,17 @@
     SubmitRecord.Label submitRecordLabel1 = new SubmitRecord.Label();
     submitRecordLabel1.label = "Verified";
     submitRecordLabel1.status = SubmitRecord.Label.Status.REJECT;
-    submitRecordLabel1.appliedBy = admin.id;
+    submitRecordLabel1.appliedBy = admin.id();
 
     SubmitRecord.Label submitRecordLabel2 = new SubmitRecord.Label();
     submitRecordLabel2.label = "Code-Review";
     submitRecordLabel2.status = SubmitRecord.Label.Status.OK;
-    submitRecordLabel2.appliedBy = admin.id;
+    submitRecordLabel2.appliedBy = admin.id();
 
     SubmitRecord.Label submitRecordLabel3 = new SubmitRecord.Label();
     submitRecordLabel3.label = "Any-Label-Name";
     submitRecordLabel3.status = SubmitRecord.Label.Status.REJECT;
-    submitRecordLabel3.appliedBy = user.id;
+    submitRecordLabel3.appliedBy = user.id();
 
     List<Term> terms = new ArrayList<>();
 
@@ -140,7 +141,7 @@
   }
 
   private static StructureTerm makeLabel(String name, String status, TestAccount account) {
-    StructureTerm user = new StructureTerm("user", new IntegerTerm(account.id.get()));
+    StructureTerm user = new StructureTerm("user", new IntegerTerm(account.id().get()));
     return new StructureTerm("label", new StructureTerm(name), new StructureTerm(status, user));
   }
 
@@ -149,8 +150,8 @@
   }
 
   private ChangeData makeChangeData() {
-    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
-    cd.setChange(TestChanges.newChange(project, admin.id));
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    cd.setChange(TestChanges.newChange(project, admin.id()));
     return cd;
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index a4d9acb..5634b27 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
@@ -26,7 +27,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.util.Collection;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
@@ -41,6 +41,7 @@
   private static final String RULE_TEMPLATE =
       "submit_rule(submit(W)) :- \n" + "%s,\n" + "W = label('OK', ok(user(1000000))).";
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
   @Before
@@ -71,24 +72,23 @@
 
   @Test
   public void testUserPredicate() throws Exception {
-    // This test results in a RULE_ERROR as Prolog tries to find accounts by email, using the index.
-    // TODO(maximeg) get OK results
-    modifySubmitRules("commit_author(user(1000000), 'John Doe', 'john.doe@example.com')");
-    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+    modifySubmitRules(
+        String.format(
+            "gerrit:commit_author(user(%d), '%s', '%s')",
+            user.id().get(), user.fullName(), user.email()));
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
   }
 
   @Test
   public void testCommitAuthorPredicate() throws Exception {
-    // This test results in a RULE_ERROR as Prolog tries to find accounts by email, using the index.
-    // TODO(maximeg) get OK results
     modifySubmitRules("gerrit:commit_author(Id)");
-    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
   }
 
   private SubmitRecord.Status statusForRule() throws Exception {
-    String oldHead = getRemoteHead().name();
+    String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result1 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
     ChangeData cd = result1.getChange();
 
@@ -108,13 +108,13 @@
   private void modifySubmitRules(String ruleTested) throws Exception {
     String newContent = String.format(RULE_TEMPLATE, ruleTested);
 
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> testRepo = new TestRepository<>((InMemoryRepository) repo);
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
       testRepo
           .branch(RefNames.REFS_CONFIG)
           .commit()
-          .author(admin.getIdent())
-          .committer(admin.getIdent())
+          .author(admin.newIdent())
+          .committer(admin.newIdent())
           .add("rules.pl", newContent)
           .message("Modify rules.pl")
           .create();
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java b/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
index f0b937c..9d6ae44 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 
 import com.google.common.collect.ImmutableList;
@@ -63,7 +62,7 @@
       command.append(" --message ").append(message);
     }
     String response = adminSshSession.exec(command.toString());
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 2fafc1c..ed3cdbc 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -15,32 +15,60 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
-import com.google.common.collect.FluentIterable;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ChangeIndexedCounter;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.List;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 @UseSsh
 public abstract class AbstractIndexTests extends AbstractDaemonTest {
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+
+  private ChangeIndexedCounter changeIndexedCounter;
+  private RegistrationHandle changeIndexedCounterHandle;
+
   /** @param injector injector */
   public abstract void configureIndex(Injector injector) throws Exception;
 
+  @Before
+  public void addChangeIndexedCounter() {
+    changeIndexedCounter = new ChangeIndexedCounter();
+    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
+  }
+
+  @After
+  public void removeChangeIndexedCounter() {
+    if (changeIndexedCounterHandle != null) {
+      changeIndexedCounterHandle.remove();
+    }
+  }
+
   @Test
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
   public void indexChange() throws Exception {
     configureIndex(server.getTestInjector());
 
     PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
     String changeId = change.getChangeId();
     String changeLegacyId = change.getChange().getId().toString();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
 
     disableChangeIndexWrites();
     amendChange(changeId, "second test", "test2.txt", "test2");
@@ -48,24 +76,55 @@
     assertChangeQuery("message:second", change.getChange(), false);
     enableChangeIndexWrites();
 
+    changeIndexedCounter.clear();
     String cmd = Joiner.on(" ").join("gerrit", "index", "changes", changeLegacyId);
     adminSshSession.exec(cmd);
+    adminSshSession.assertSuccess();
+
+    changeIndexedCounter.assertReindexOf(changeInfo, 1);
 
     assertChangeQuery("message:second", change.getChange(), true);
   }
 
-  protected void assertChangeQuery(String q, ChangeData change, Boolean assertTrue)
+  @Test
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
+  public void indexProject() throws Exception {
+    configureIndex(server.getTestInjector());
+
+    PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
+    String changeId = change.getChangeId();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    disableChangeIndexWrites();
+    amendChange(changeId, "second test", "test2.txt", "test2");
+
+    assertChangeQuery("message:second", change.getChange(), false);
+    enableChangeIndexWrites();
+
+    changeIndexedCounter.clear();
+    String cmd = Joiner.on(" ").join("gerrit", "index", "changes-in-project", project.get());
+    adminSshSession.exec(cmd);
+    adminSshSession.assertSuccess();
+
+    boolean indexing = true;
+    while (indexing) {
+      String out = adminSshSession.exec("gerrit show-queue --wide");
+      adminSshSession.assertSuccess();
+      indexing = out.contains("Index all changes of project " + project.get());
+    }
+
+    changeIndexedCounter.assertReindexOf(changeInfo, 1);
+
+    assertChangeQuery("message:second", change.getChange(), true);
+  }
+
+  protected void assertChangeQuery(String q, ChangeData change, boolean assertTrue)
       throws Exception {
-    List<ChangeInfo> result = query(q);
-    Iterable<Integer> ids = ids(result);
+    List<Integer> ids = query(q).stream().map(c -> c._number).collect(toList());
     if (assertTrue) {
       assertThat(ids).contains(change.getId().get());
     } else {
       assertThat(ids).doesNotContain(change.getId().get());
     }
   }
-
-  protected static Iterable<Integer> ids(Iterable<ChangeInfo> changes) {
-    return FluentIterable.from(changes).transform(in -> in._number);
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BUILD b/javatests/com/google/gerrit/acceptance/ssh/BUILD
index eefd9d3..00a0914 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BUILD
+++ b/javatests/com/google/gerrit/acceptance/ssh/BUILD
@@ -2,7 +2,7 @@
 
 java_library(
     name = "util",
-    testonly = 1,
+    testonly = True,
     srcs = ["AbstractIndexTests.java"],
     deps = ["//java/com/google/gerrit/acceptance:lib"],
 )
@@ -17,6 +17,7 @@
     vm_args = ["-Xmx512m"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/server/logging",
         "//lib/commons:compress",
     ],
 )
@@ -25,8 +26,9 @@
     srcs = ["ElasticIndexIT.java"],
     group = "elastic",
     labels = [
-        "elastic",
         "docker",
+        "elastic",
+        "exclusive",
         "ssh",
     ],
     deps = [
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java b/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
index 1c39181..2b00718 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
@@ -36,13 +35,13 @@
     RevCommit c = commitBuilder().add("a.txt", "some content").create();
 
     String response = adminSshSession.exec("gerrit ban-commit " + project.get() + " " + c.name());
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
 
     RemoteRefUpdate u =
         pushHead(testRepo, "refs/heads/master", false).getRemoteUpdate("refs/heads/master");
     assertThat(u).isNotNull();
     assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
-    assertThat(u.getMessage()).startsWith("contains banned commit");
+    assertThat(u.getMessage()).contains("contains banned commit");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index f2dda42..007ad89 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseSsh;
@@ -33,8 +32,8 @@
     String newProjectName = "newProject";
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + newGroupName + " " + newProjectName);
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    adminSshSession.assertSuccess();
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
   }
 
@@ -46,8 +45,42 @@
     String newProjectName = "newProject";
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + wrongGroupName + " " + newProjectName);
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isTrue();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    adminSshSession.assertFailure();
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNull();
   }
+
+  @Test
+  public void withDotGit() throws Exception {
+    String newGroupName = "newGroup";
+    adminRestSession.put("/groups/" + newGroupName);
+    String newProjectName = name("newProject");
+    adminSshSession.exec(
+        "gerrit create-project --branch master --owner "
+            + newGroupName
+            + " "
+            + newProjectName
+            + ".git");
+    adminSshSession.assertSuccess();
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isNotNull();
+    assertThat(projectState.getName()).isEqualTo(newProjectName);
+  }
+
+  @Test
+  public void withTrailingSlash() throws Exception {
+    String newGroupName = "newGroup";
+    adminRestSession.put("/groups/" + newGroupName);
+    String newProjectName = name("newProject");
+    adminSshSession.exec(
+        "gerrit create-project --branch master --owner "
+            + newGroupName
+            + " "
+            + newProjectName
+            + "/");
+    adminSshSession.assertSuccess();
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isNotNull();
+    assertThat(projectState.getName()).isEqualTo(newProjectName);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index 46065c9..f81ca4c 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -14,45 +14,33 @@
 
 package com.google.gerrit.acceptance.ssh;
 
-import com.google.gerrit.elasticsearch.ElasticContainer;
-import com.google.gerrit.elasticsearch.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
+
 import com.google.gerrit.elasticsearch.ElasticVersion;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
-import java.util.UUID;
 import org.eclipse.jgit.lib.Config;
 
 public class ElasticIndexIT extends AbstractIndexTests {
-  private static ElasticContainer<?> container;
-
-  private static Config getConfig(ElasticVersion version) {
-    ElasticNodeInfo elasticNodeInfo;
-    container = ElasticContainer.createAndStart(version);
-    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-    String indicesPrefix = UUID.randomUUID().toString();
-    Config cfg = new Config();
-    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix);
-    return cfg;
-  }
 
   @ConfigSuite.Default
-  public static Config elasticsearchV2() {
-    return getConfig(ElasticVersion.V2_4);
-  }
-
-  @ConfigSuite.Config
   public static Config elasticsearchV5() {
     return getConfig(ElasticVersion.V5_6);
   }
 
   @ConfigSuite.Config
   public static Config elasticsearchV6() {
-    return getConfig(ElasticVersion.V6_2);
+    return getConfig(ElasticVersion.V6_7);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV7() {
+    return getConfig(ElasticVersion.V7_2);
   }
 
   @Override
   public void configureIndex(Injector injector) throws Exception {
-    ElasticTestUtils.createAllIndexes(injector);
+    createAllIndexes(injector);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java b/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
index f2a388e..c23f889d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GcAssert;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GarbageCollectionResult;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GarbageCollection;
@@ -41,14 +41,15 @@
   @Inject private GarbageCollectionQueue gcQueue;
 
   @Inject private GcAssert gcAssert;
+  @Inject private ProjectOperations projectOperations;
 
   private Project.NameKey project2;
   private Project.NameKey project3;
 
   @Before
   public void setUp() throws Exception {
-    project2 = createProject("p2");
-    project3 = createProject("p3");
+    project2 = projectOperations.newProject().create();
+    project3 = projectOperations.newProject().create();
   }
 
   @Test
@@ -56,7 +57,7 @@
   public void testGc() throws Exception {
     String response =
         adminSshSession.exec("gerrit gc \"" + project.get() + "\" \"" + project2.get() + "\"");
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     assertNoError(response);
     gcAssert.assertHasPackFile(project, project2);
     gcAssert.assertHasNoPackFile(allProjects, project3);
@@ -66,7 +67,7 @@
   @UseLocalDisk
   public void testGcAll() throws Exception {
     String response = adminSshSession.exec("gerrit gc --all");
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     assertNoError(response);
     gcAssert.assertHasPackFile(allProjects, project, project2, project3);
   }
@@ -74,7 +75,7 @@
   @Test
   public void gcWithoutCapability_Error() throws Exception {
     userSshSession.exec("gerrit gc --all");
-    assertThat(userSshSession.hasError()).isTrue();
+    userSshSession.assertFailure();
     String error = userSshSession.getError();
     assertThat(error).isNotNull();
     assertError("maintain server not permitted", error);
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
new file mode 100644
index 0000000..e61e2cc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.query.change.OutputStreamQuery;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.Test;
+
+@UseSsh
+public class PluginChangeFieldsIT extends AbstractPluginFieldsTest {
+  // No tests for getting a single change over SSH, since the only API is the query API.
+
+  private static final Gson GSON = OutputStreamQuery.GSON;
+
+  @Test
+  public void queryChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
+  @Test
+  public void queryChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
+  @Test
+  public void queryChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))),
+        (id, opts) -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id, opts))));
+  }
+
+  private String changeQueryCmd(Change.Id id) {
+    return changeQueryCmd(id, ImmutableListMultimap.of());
+  }
+
+  private String changeQueryCmd(Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return "gerrit query --format json "
+        + pluginOptions.entries().stream()
+            .flatMap(e -> Stream.of("--" + e.getKey(), e.getValue()))
+            .collect(joining(" "))
+        + " "
+        + id;
+  }
+
+  @Nullable
+  private static List<MyInfo> pluginInfoFromSingletonList(String sshOutput) throws Exception {
+    List<Map<String, Object>> changeAttrs = new ArrayList<>();
+    for (String line : CharStreams.readLines(new StringReader(sshOutput))) {
+      Map<String, Object> changeAttr =
+          GSON.fromJson(line, new TypeToken<Map<String, Object>>() {}.getType());
+      if (!"stats".equals(changeAttr.get("type"))) {
+        changeAttrs.add(changeAttr);
+      }
+    }
+
+    assertThat(changeAttrs).hasSize(1);
+    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index f741f36..78960bb 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -85,7 +85,7 @@
   public void allReviewersOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
+    in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
     List<ChangeAttribute> changes = executeSuccessfulQuery(changeId);
@@ -149,8 +149,7 @@
   public void shouldFailWithFilesWithoutPatchSetsOrCurrentPatchSetsOption() throws Exception {
     String changeId = createChange().getChangeId();
     adminSshSession.exec("gerrit query --files " + changeId);
-    assertThat(adminSshSession.hasError()).isTrue();
-    assertThat(adminSshSession.getError()).contains("needs --patch-sets or --current-patch-set");
+    adminSshSession.assertFailure("needs --patch-sets or --current-patch-set");
   }
 
   @Test
@@ -296,8 +295,8 @@
     // computation while formatting the output, such as labels, reviewers etc.
     merge(r);
     for (ListChangesOption option : ListChangesOption.values()) {
-      assertThat(gApi.changes().query(r.getChangeId()).withOption(option).get())
-          .named("Option: " + option)
+      assertWithMessage("Option: " + option)
+          .that(gApi.changes().query(r.getChangeId()).withOption(option).get())
           .hasSize(1);
     }
   }
@@ -305,7 +304,7 @@
   private List<ChangeAttribute> executeSuccessfulQuery(String params, SshSession session)
       throws Exception {
     String rawResponse = session.exec("gerrit query --format=JSON " + params);
-    assertWithMessage(session.getError()).that(session.hasError()).isFalse();
+    session.assertSuccess();
     return getChanges(rawResponse);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
index 596fc87..6998a0a 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
@@ -15,14 +15,16 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.SshSession;
 import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -30,10 +32,20 @@
 @NoHttpd
 public class SetReviewersIT extends AbstractDaemonTest {
   PushOneCommit.Result change;
+  SshSession session;
+
+  @ConfigSuite.Config
+  public static Config asAdmin() {
+    Config cfg = new Config();
+    cfg.setBoolean("SetReviewersIT", null, "asAdmin", true);
+    return cfg;
+  }
 
   @Before
   public void setUp() throws Exception {
     change = createChange();
+    session =
+        cfg.getBoolean("SetReviewersIT", null, "asAdmin", false) ? adminSshSession : userSshSession;
   }
 
   @Test
@@ -50,14 +62,14 @@
   }
 
   private void setReviewer(boolean add, String id) throws Exception {
-    adminSshSession.exec(
-        String.format("gerrit set-reviewers -%s %s %s", add ? "a" : "r", user.email, id));
-    assert_().withMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
-    ImmutableSet<Id> reviewers = change.getChange().getReviewers().all();
+    session.exec(
+        String.format("gerrit set-reviewers -%s %s %s", add ? "a" : "r", user.email(), id));
+    session.assertSuccess();
+    ImmutableSet<Account.Id> reviewers = change.getChange().getReviewers().all();
     if (add) {
-      assertThat(reviewers).contains(user.id);
+      assertThat(reviewers).contains(user.id());
     } else {
-      assertThat(reviewers).doesNotContain(user.id);
+      assertThat(reviewers).doesNotContain(user.id());
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 5694bd0..4e9c4a4 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -14,21 +14,19 @@
 
 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;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.UseSsh;
-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 +37,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,14 +55,13 @@
           "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",
           "create-branch",
           "create-group",
           "create-project",
-          "gsql",
           "index",
           "query",
           "receive-pack",
@@ -79,41 +76,40 @@
           "stream-events",
           "test-submit");
 
-  private static final Map<String, List<String>> MASTER_COMMANDS =
-      ImmutableMap.of(
-          Commands.ROOT,
-          ImmutableList.copyOf(
-              new ArrayList<String>() {
-                private static final long serialVersionUID = 1L;
+  private static final ImmutableList<String> EMPTY = ImmutableList.of();
+  private static final ImmutableMap<String, List<String>> MASTER_COMMANDS =
+      ImmutableMap.<String, List<String>>builder()
+          .put("kill", EMPTY)
+          .put("ps", EMPTY)
+          // TODO(dpursehouse): Add "scp" and "suexec"
+          .put(
+              "gerrit",
+              Streams.concat(COMMON_ROOT_COMMANDS.stream(), MASTER_ONLY_ROOT_COMMANDS.stream())
+                  .sorted()
+                  .collect(toImmutableList()))
+          .put(
+              "gerrit index",
+              ImmutableList.of(
+                  "changes", "changes-in-project")) // "activate" and "start" are not included
+          .put("gerrit logging", ImmutableList.of("ls", "set"))
+          .put(
+              "gerrit plugin",
+              ImmutableList.of("add", "enable", "install", "ls", "reload", "remove", "rm"))
+          .put("gerrit test-submit", ImmutableList.of("rule", "type"))
+          .build();
 
-                {
-                  addAll(COMMON_ROOT_COMMANDS);
-                  addAll(MASTER_ONLY_ROOT_COMMANDS);
-                  Collections.sort(this);
-                }
-              }),
-          "index",
-          ImmutableList.of("changes", "project"), // "activate" and "start" are not included
-          "logging",
-          ImmutableList.of("ls", "set"),
-          "plugin",
-          ImmutableList.of("add", "enable", "install", "ls", "reload", "remove", "rm"),
-          "test-submit",
-          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,
+          "kill",
+          EMPTY,
+          "gerrit",
           COMMON_ROOT_COMMANDS,
-          "plugin",
+          "gerrit plugin",
           ImmutableList.of("add", "enable", "install", "ls", "reload", "remove", "rm"));
 
   @Test
   @Sandboxed
   public void sshCommandCanBeExecuted() throws Exception {
-    // Access Database capability is required to run the "gerrit gsql" command
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
     testCommandExecution(MASTER_COMMANDS);
 
     restartAsSlave();
@@ -122,22 +118,30 @@
 
   private void testCommandExecution(Map<String, List<String>> commands) throws Exception {
     for (String root : commands.keySet()) {
-      for (String command : commands.get(root)) {
-        // We can't assert that adminSshSession.hasError() is false, because using the --help
-        // option causes the usage info to be written to stderr. Instead, we assert on the
-        // content of the stderr, which will always start with "gerrit command" when the --help
-        // option is used.
-        String cmd = String.format("gerrit%s%s %s", root.isEmpty() ? "" : " ", root, command);
-        logger.atFine().log(cmd);
-        adminSshSession.exec(String.format("%s --help", cmd));
-        String response = adminSshSession.getError();
-        assertWithMessage(String.format("command %s failed: %s", command, response))
-            .that(response)
-            .startsWith(cmd);
+      List<String> cmds = commands.get(root);
+      if (cmds.isEmpty()) {
+        testCommandExecution(root);
+      } else {
+        for (String cmd : cmds) {
+          testCommandExecution(String.format("%s %s", root, cmd));
+        }
       }
     }
   }
 
+  private void testCommandExecution(String cmd) throws Exception {
+    // We can't assert that adminSshSession.hasError() is false, because using the --help
+    // option causes the usage info to be written to stderr. Instead, we assert on the
+    // content of the stderr, which will always start with "gerrit command" when the --help
+    // option is used.
+    logger.atFine().log(cmd);
+    adminSshSession.exec(String.format("%s --help", cmd));
+    String response = adminSshSession.getError();
+    assertWithMessage(String.format("command %s failed: %s", cmd, response))
+        .that(response)
+        .startsWith(cmd);
+  }
+
   @Test
   public void nonExistingCommandFails() throws Exception {
     adminSshSession.exec("gerrit non-existing-command --help");
@@ -150,12 +154,12 @@
   public void listCommands() throws Exception {
     adminSshSession.exec("gerrit --help");
     List<String> commands = parseCommandsFromGerritHelpText(adminSshSession.getError());
-    assertThat(commands).containsExactlyElementsIn(MASTER_COMMANDS.get(Commands.ROOT)).inOrder();
+    assertThat(commands).containsExactlyElementsIn(MASTER_COMMANDS.get("gerrit")).inOrder();
 
     restartAsSlave();
     adminSshSession.exec("gerrit --help");
     commands = parseCommandsFromGerritHelpText(adminSshSession.getError());
-    assertThat(commands).containsExactlyElementsIn(SLAVE_COMMANDS.get(Commands.ROOT)).inOrder();
+    assertThat(commands).containsExactlyElementsIn(SLAVE_COMMANDS.get("gerrit")).inOrder();
   }
 
   private List<String> parseCommandsFromGerritHelpText(String helpText) {
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..09e97b2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -0,0 +1,149 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+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.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogger;
+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 java.util.ArrayList;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseSsh
+public class SshTraceIT extends AbstractDaemonTest {
+  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+
+  private TraceValidatingProjectCreationValidationListener projectCreationListener;
+  private RegistrationHandle projectCreationListenerRegistrationHandle;
+  private TestPerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
+    projectCreationListenerRegistrationHandle =
+        projectCreationValidationListeners.add("gerrit", projectCreationListener);
+    testPerformanceLogger = new TestPerformanceLogger();
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+  }
+
+  @After
+  public void cleanup() {
+    projectCreationListenerRegistrationHandle.remove();
+    performanceLoggerRegistrationHandle.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 new4");
+    adminSshSession.assertFailure("A trace ID can only be set if --trace was specified.");
+  }
+
+  @Test
+  public void performanceLoggingForSshCall() throws Exception {
+    adminSshSession.exec("gerrit create-project new5");
+    adminSshSession.assertSuccess();
+    assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+  }
+
+  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);
+    }
+  }
+
+  private static class TestPerformanceLogger implements PerformanceLogger {
+    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+
+    @Override
+    public void log(String operation, long durationMs, Metadata metadata) {
+      logEntries.add(PerformanceLogEntry.create(operation, metadata));
+    }
+
+    ImmutableList<PerformanceLogEntry> logEntries() {
+      return ImmutableList.copyOf(logEntries);
+    }
+  }
+
+  @AutoValue
+  abstract static class PerformanceLogEntry {
+    static PerformanceLogEntry create(String operation, Metadata metadata) {
+      return new AutoValue_SshTraceIT_PerformanceLogEntry(operation, metadata);
+    }
+
+    abstract String operation();
+
+    abstract Metadata metadata();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
index 1721545..34406e0 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.base.Splitter;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.testing.NoteDbMode;
+import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
@@ -34,20 +35,12 @@
 import org.eclipse.jgit.transport.PacketLineIn;
 import org.eclipse.jgit.transport.PacketLineOut;
 import org.eclipse.jgit.util.IO;
-import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 @UseSsh
 public class UploadArchiveIT extends AbstractDaemonTest {
 
-  @Before
-  public void setUp() {
-    // There is some Guice request scoping problem preventing this test from
-    // passing in CHECK mode.
-    assume().that(NoteDbMode.get()).isNotEqualTo(NoteDbMode.CHECK);
-  }
-
   @Test
   @GerritConfig(name = "download.archive", value = "off")
   public void archiveFeatureOff() throws Exception {
@@ -65,7 +58,7 @@
   @Test
   public void zipFormat() throws Exception {
     PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommit().abbreviate(8).name();
+    String abbreviated = abbreviateName(r);
     String c = command(r, "zip", abbreviated);
 
     InputStream out =
@@ -75,7 +68,7 @@
     PacketLineIn in = new PacketLineIn(out);
     String tmp = in.readString();
     assertThat(tmp).isEqualTo("ACK");
-    tmp = in.readString();
+    in.readString();
 
     // Skip length (4 bytes) + 1 byte
     // to position the output stream to the raw zip stream
@@ -102,7 +95,7 @@
   @Test
   public void txzFormat() throws Exception {
     PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommit().abbreviate(8).name();
+    String abbreviated = abbreviateName(r);
     String c = command(r, "tar.xz", abbreviated);
 
     try (InputStream out =
@@ -125,7 +118,7 @@
       // that is currently not public.
       char channel = packet.charAt(0);
       if (channel != 1) {
-        fail("got packet on channel " + (int) channel, packet);
+        assertWithMessage("got packet on channel " + (int) channel, packet).fail();
       }
     }
   }
@@ -140,7 +133,7 @@
 
   private void assertArchiveNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommit().abbreviate(8).name();
+    String abbreviated = abbreviateName(r);
     String c = command(r, "zip", abbreviated);
 
     InputStream out =
@@ -150,12 +143,16 @@
     PacketLineIn in = new PacketLineIn(out);
     String tmp = in.readString();
     assertThat(tmp).isEqualTo("ACK");
-    tmp = in.readString();
+    in.readString();
     tmp = in.readString();
     tmp = tmp.substring(1);
     assertThat(tmp).isEqualTo("fatal: upload-archive not permitted for format zip");
   }
 
+  private String abbreviateName(Result r) throws Exception {
+    return ObjectIds.abbreviateName(r.getCommit(), 8, testRepo.getRevWalk().getObjectReader());
+  }
+
   private InputStream argumentsToInputStream(String c) throws Exception {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     PacketLineOut pctOut = new PacketLineOut(out);
diff --git a/javatests/com/google/gerrit/acceptance/tests.bzl b/javatests/com/google/gerrit/acceptance/tests.bzl
index 4b3b802d..08556a0 100644
--- a/javatests/com/google/gerrit/acceptance/tests.bzl
+++ b/javatests/com/google/gerrit/acceptance/tests.bzl
@@ -1,21 +1,21 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 def acceptance_tests(
-    group,
-    deps = [],
-    labels = [],
-    vm_args = ['-Xmx256m'],
-    **kwargs):
-  junit_tests(
-    name = group,
-    deps = deps + [
-      '//java/com/google/gerrit/acceptance:lib',
-    ],
-    tags = labels + [
-      'acceptance',
-      'slow',
-    ],
-    size = "large",
-    jvm_flags = vm_args,
-    **kwargs
-  )
+        group,
+        deps = [],
+        labels = [],
+        vm_args = ["-Xmx256m"],
+        **kwargs):
+    junit_tests(
+        name = group,
+        deps = deps + [
+            "//java/com/google/gerrit/acceptance:lib",
+        ],
+        tags = labels + [
+            "acceptance",
+            "slow",
+        ],
+        size = "large",
+        jvm_flags = vm_args,
+        **kwargs
+    )
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
new file mode 100644
index 0000000..bb84689
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -0,0 +1,643 @@
+// 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.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+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.Test;
+
+public class GroupOperationsImplTest extends AbstractDaemonTest {
+
+  @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 = AccountGroup.uuid("not-existing-group");
+
+    boolean exists = groupOperations.group(notExistingGroupUuid).exists();
+
+    assertThat(exists).isFalse();
+  }
+
+  @Test
+  public void retrievingNotExistingGroupFails() throws Exception {
+    AccountGroup.UUID notExistingGroupUuid = AccountGroup.uuid("not-existing-group");
+    assertThrows(
+        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(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 = 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();
+
+    assertWithMessage("visibility of visible group").that(visibleGroup.visibleToAll()).isTrue();
+    assertWithMessage("visibility of invisible group")
+        .that(invisibleGroup.visibleToAll())
+        .isFalse();
+  }
+
+  @Test
+  public void createdOnOfExistingGroupCanBeRetrieved() throws Exception {
+    GroupInfo group = gApi.groups().create(createArbitraryGroupInput()).detail();
+    AccountGroup.UUID groupUuid = 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 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
+    Account.Id memberId3 = 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 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
+    AccountGroup.UUID subgroupUuid3 = 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 = AccountGroup.uuid("original owner");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
+
+    AccountGroup.UUID updatedOwnerGroupUuid = 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 = Account.id(1000);
+    Account.Id memberId2 = 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 = Account.id(1000);
+    Account.Id memberId2 = 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 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    Account.Id memberId3 = 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 = Account.id(1000);
+    Account.Id memberId2 = 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 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    Account.Id memberId3 = 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 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = 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 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = 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 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    AccountGroup.UUID subgroupUuid3 = 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 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = 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 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    AccountGroup.UUID subgroupUuid3 = 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 AccountGroup.uuid(group.id);
+  }
+
+  private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
+    return Correspondence.from(
+        (actualAccount, expectedId) -> {
+          Account.Id accountId =
+              Optional.ofNullable(actualAccount)
+                  .map(account -> account._accountId)
+                  .map(Account::id)
+                  .orElse(null);
+          return Objects.equals(accountId, expectedId);
+        },
+        "has ID");
+  }
+
+  private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
+    return Correspondence.from(
+        (actualGroup, expectedUuid) -> {
+          AccountGroup.UUID groupUuid =
+              Optional.ofNullable(actualGroup)
+                  .map(group -> group.id)
+                  .map(AccountGroup::uuid)
+                  .orElse(null);
+          return Objects.equals(groupUuid, expectedUuid);
+        },
+        "has UUID");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
new file mode 100644
index 0000000..f5066db
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -0,0 +1,591 @@
+// 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.junit.Test;
+
+public class ProjectOperationsImplTest extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void defaultName() throws Exception {
+    Project.NameKey name = projectOperations.newProject().create();
+    // check that the project was created (throws exception if not found.)
+    gApi.projects().name(name.get());
+    Project.NameKey name2 = projectOperations.newProject().create();
+    assertThat(name2).isNotEqualTo(name);
+  }
+
+  @Test
+  public void specifiedName() throws Exception {
+    String name = "somename";
+    Project.NameKey key = projectOperations.newProject().name(name).create();
+    assertThat(key.get()).isEqualTo(name);
+  }
+
+  @Test
+  public void emptyCommit() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+
+    List<BranchInfo> branches = gApi.projects().name(key.get()).branches().get();
+    assertThat(branches).isNotEmpty();
+    assertThat(branches.stream().map(x -> x.ref).collect(toList()))
+        .isEqualTo(ImmutableList.of("HEAD", "refs/meta/config", "refs/heads/master"));
+  }
+
+  @Test
+  public void getProjectConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
+        .isEmpty();
+
+    ConfigInput input = new ConfigInput();
+    input.description = "my fancy project";
+    gApi.projects().name(key.get()).config(input);
+
+    assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
+        .isEqualTo("my fancy project");
+  }
+
+  @Test
+  public void mutatingResultOfGetProjectConfigDoesNotMutateGlobalCachedValue() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
+    ProjectState cachedProjectState1 = projectCache.checkedGet(key);
+    ProjectConfig cachedProjectConfig1 = cachedProjectState1.getConfig();
+    assertThat(cachedProjectConfig1).isNotSameInstanceAs(projectConfig);
+    assertThat(cachedProjectConfig1.getProject().getDescription()).isEmpty();
+    assertThat(projectConfig.getProject().getDescription()).isEmpty();
+    projectConfig.getProject().setDescription("my fancy project");
+
+    ProjectConfig cachedProjectConfig2 = projectCache.checkedGet(key).getConfig();
+    assertThat(cachedProjectConfig2).isNotSameInstanceAs(projectConfig);
+    assertThat(cachedProjectConfig2.getProject().getDescription()).isEmpty();
+  }
+
+  @Test
+  public void getProjectConfigNoRefsMetaConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    deleteRefsMetaConfig(key);
+
+    ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
+    assertThat(projectConfig.getName()).isEqualTo(key);
+    assertThat(projectConfig.getRevision()).isNull();
+  }
+
+  @Test
+  public void getConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).isNotInstanceOf(StoredConfig.class);
+    assertThat(config).text().isEmpty();
+
+    ConfigInput input = new ConfigInput();
+    input.description = "my fancy project";
+    gApi.projects().name(key.get()).config(input);
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).isNotInstanceOf(StoredConfig.class);
+    assertThat(config).sections().containsExactly("project");
+    assertThat(config).subsections("project").isEmpty();
+    assertThat(config).sectionValues("project").containsExactly("description", "my fancy project");
+  }
+
+  @Test
+  public void getConfigNoRefsMetaConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    deleteRefsMetaConfig(key);
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).isNotInstanceOf(StoredConfig.class);
+    assertThat(config).isEmpty();
+  }
+
+  @Test
+  public void addAllowPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addDenyPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(deny(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "deny group global:Registered-Users");
+  }
+
+  @Test
+  public void addBlockPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(block(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "block group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowForcePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).force(true))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "+force group global:Registered-Users");
+  }
+
+  @Test
+  public void updateExclusivePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), true)
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "exclusiveGroupPermissions", "abandon");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), false)
+        .update();
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addMultipleExclusivePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), true)
+        .setExclusiveGroup(permissionKey(Permission.CREATE).ref("refs/foo"), true)
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsEntry("exclusiveGroupPermissions", "abandon create");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), false)
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsEntry("exclusiveGroupPermissions", "create");
+  }
+
+  @Test
+  public void addMultiplePermissions() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
+        .add(allow(Permission.CREATE).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Project-Owners",
+            "create", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addDuplicatePermissions() throws Exception {
+    TestPermission permission =
+        TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).build();
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations.project(key).forUpdate().add(permission).add(permission).update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Registered-Users");
+
+    projectOperations.project(key).forUpdate().add(permission).update();
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addBlockLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "block -1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowExclusiveLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/foo"), true)
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "label-Code-Review", "-1..+2 group global:Registered-Users",
+            "exclusiveGroupPermissions", "label-Code-Review");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/foo"), false)
+        .update();
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowLabelAsPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(
+            allowLabel("Code-Review")
+                .ref("refs/foo")
+                .group(REGISTERED_USERS)
+                .range(-1, 2)
+                .impersonation(true))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("labelAs-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowCapability() throws Exception {
+    Config config = projectOperations.project(allProjects).getConfig();
+    assertThat(config)
+        .sectionValues("capability")
+        .doesNotContainEntry("administrateServer", "group Registered Users");
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
+
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsEntry("administrateServer", "group Registered Users");
+  }
+
+  @Test
+  public void addAllowCapabilityWithRange() throws Exception {
+    Config config = projectOperations.project(allProjects).getConfig();
+    assertThat(config).sectionValues("capability").doesNotContainKey("queryLimit");
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 5000))
+        .update();
+
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsEntry("queryLimit", "+0..+5000 group Registered Users");
+  }
+
+  @Test
+  public void addAllowCapabilityWithDefaultRange() throws Exception {
+    Config config = projectOperations.project(allProjects).getConfig();
+    assertThat(config).sectionValues("capability").doesNotContainKey("queryLimit");
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS))
+        .update();
+
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsEntry("queryLimit", "+0..+" + DEFAULT_MAX_QUERY_LIMIT + " group Registered Users");
+  }
+
+  @Test
+  public void removePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Project-Owners");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(permissionKey(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "group global:Project-Owners");
+  }
+
+  @Test
+  public void removeLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .add(allowLabel("Code-Review").ref("refs/foo").group(PROJECT_OWNERS).range(-2, 1))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "label-Code-Review", "-1..+2 group global:Registered-Users",
+            "label-Code-Review", "-2..+1 group global:Project-Owners");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(labelPermissionKey("Code-Review").ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "-2..+1 group global:Project-Owners");
+  }
+
+  @Test
+  public void removeCapability() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .add(allowCapability(ADMINISTRATE_SERVER).group(PROJECT_OWNERS))
+        .update();
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsAtLeastEntriesIn(
+            ImmutableListMultimap.of(
+                "administrateServer", "group Registered Users",
+                "administrateServer", "group Project Owners"));
+
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(capabilityKey(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .doesNotContainEntry("administrateServer", "group Registered Users");
+  }
+
+  @Test
+  public void removeOnePermissionForAllGroupsFromOneAccessSection() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .add(allow(Permission.CREATE).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsAtLeastEntriesIn(
+            ImmutableListMultimap.of(
+                "abandon", "group global:Project-Owners",
+                "abandon", "group global:Registered-Users",
+                "create", "group global:Registered-Users"));
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(permissionKey(Permission.ABANDON).ref("refs/foo"))
+        .update();
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).subsectionValues("access", "refs/foo").doesNotContainKey("abandon");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsEntry("create", "group global:Registered-Users");
+  }
+
+  @Test
+  public void updatingCapabilitiesNotAllowedForNonAllProjects() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    assertThrows(
+        RuntimeException.class,
+        () ->
+            projectOperations
+                .project(key)
+                .forUpdate()
+                .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+                .update());
+    assertThrows(
+        RuntimeException.class,
+        () ->
+            projectOperations
+                .project(key)
+                .forUpdate()
+                .remove(capabilityKey(ADMINISTRATE_SERVER))
+                .update());
+  }
+
+  private void deleteRefsMetaConfig(Project.NameKey key) throws Exception {
+    try (Repository repo = repoManager.openRepository(key);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.delete(REFS_CONFIG);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
new file mode 100644
index 0000000..b81b23d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_BATCH_CHANGES_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
+import static com.google.gerrit.common.data.Permission.ABANDON;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermissionKey;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import java.util.function.Function;
+import org.junit.Test;
+
+public class TestProjectUpdateTest {
+  private static final AllProjectsName ALL_PROJECTS_NAME = new AllProjectsName("All-Projects");
+
+  @Test
+  public void testCapabilityDisallowsZeroRangeOnCapabilityThatHasNoRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS).range(0, 0).build());
+  }
+
+  @Test
+  public void testCapabilityAllowsZeroRangeOnCapabilityThatHasRange() throws Exception {
+    TestCapability c = allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 0).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(0);
+  }
+
+  @Test
+  public void testCapabilityDisallowsInvertedRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(1, 0).build());
+  }
+
+  @Test
+  public void testCapabilityDisallowsRangeIfCapabilityDoesNotSupportRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS).range(-1, 1).build());
+  }
+
+  @Test
+  public void testCapabilityRangeIsZeroIfCapabilityDoesNotSupportRange() throws Exception {
+    TestCapability c = allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(0);
+  }
+
+  @Test
+  public void testCapabilityUsesDefaultRangeIfUnspecified() throws Exception {
+    TestCapability c = allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(DEFAULT_MAX_QUERY_LIMIT);
+
+    c = allowCapability(BATCH_CHANGES_LIMIT).group(REGISTERED_USERS).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(DEFAULT_MAX_BATCH_CHANGES_LIMIT);
+  }
+
+  @Test
+  public void testCapabilityUsesExplicitRangeIfSpecified() throws Exception {
+    TestCapability c = allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(5, 20).build();
+    assertThat(c.min()).isEqualTo(5);
+    assertThat(c.max()).isEqualTo(20);
+  }
+
+  @Test
+  public void testLabelPermissionRequiresValidLabelName() throws Exception {
+    Function<String, TestLabelPermission.Builder> labelBuilder =
+        name -> allowLabel(name).ref("refs/*").group(REGISTERED_USERS).range(-1, 1);
+    assertThat(labelBuilder.apply("Code-Review").build().name()).isEqualTo("Code-Review");
+    assertThrows(RuntimeException.class, () -> labelBuilder.apply("not a label").build());
+    assertThrows(RuntimeException.class, () -> labelBuilder.apply("label-Code-Review").build());
+  }
+
+  @Test
+  public void testLabelPermissionDisallowsZeroRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(0, 0).build());
+  }
+
+  @Test
+  public void testLabelPermissionDisallowsInvertedRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(1, 0).build());
+  }
+
+  @Test
+  public void testPermissionKeyRequiresValidRefName() throws Exception {
+    Function<String, TestPermissionKey.Builder> keyBuilder =
+        ref -> permissionKey(ABANDON).ref(ref).group(REGISTERED_USERS);
+    assertThat(keyBuilder.apply("refs/*").build().section()).isEqualTo("refs/*");
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply(null).build());
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply("foo").build());
+  }
+
+  @Test
+  public void testLabelPermissionKeyRequiresValidLabelName() throws Exception {
+    Function<String, TestPermissionKey.Builder> keyBuilder =
+        label -> labelPermissionKey(label).ref("refs/*").group(REGISTERED_USERS);
+    assertThat(keyBuilder.apply("Code-Review").build().name()).isEqualTo("label-Code-Review");
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply(null).build());
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply("not a label").build());
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply("label-Code-Review").build());
+  }
+
+  @Test
+  public void testPermissionKeyDisallowsSettingRefOnGlobalCapability() throws Exception {
+    assertThrows(RuntimeException.class, () -> capabilityKey(ADMINISTRATE_SERVER).ref("refs/*"));
+  }
+
+  @Test
+  public void testProjectUpdateDisallowsGroupOnExclusiveGroupPermissionKey() throws Exception {
+    TestPermissionKey.Builder b = permissionKey(ABANDON).ref("refs/*");
+    Function<TestPermissionKey.Builder, TestProjectUpdate.Builder> updateBuilder =
+        kb -> builder().setExclusiveGroup(kb, true);
+
+    assertThat(updateBuilder.apply(b).build().exclusiveGroupPermissions())
+        .containsExactly(b.build(), true);
+
+    b.group(REGISTERED_USERS);
+    assertThrows(RuntimeException.class, () -> updateBuilder.apply(b).build());
+  }
+
+  @Test
+  public void hasCapabilityUpdates() throws Exception {
+    assertThat(builder().build().hasCapabilityUpdates()).isFalse();
+    assertThat(
+            builder()
+                .add(allow(ABANDON).ref("refs/*").group(REGISTERED_USERS))
+                .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(0, 1))
+                .remove(permissionKey(ABANDON).ref("refs/foo"))
+                .remove(labelPermissionKey("Code-Review").ref("refs/foo"))
+                .setExclusiveGroup(permissionKey(ABANDON).ref("refs/bar"), true)
+                .setExclusiveGroup(labelPermissionKey(ABANDON).ref("refs/bar"), true)
+                .build()
+                .hasCapabilityUpdates())
+        .isFalse();
+    assertThat(
+            builder(ALL_PROJECTS_NAME)
+                .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+                .build()
+                .hasCapabilityUpdates())
+        .isTrue();
+    assertThat(
+            builder(ALL_PROJECTS_NAME)
+                .remove(capabilityKey(ADMINISTRATE_SERVER))
+                .build()
+                .hasCapabilityUpdates())
+        .isTrue();
+  }
+
+  @Test
+  public void updatingCapabilitiesNotAllowedForNonAllProjects() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> builder().add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS)).update());
+    assertThrows(
+        RuntimeException.class,
+        () -> builder().remove(capabilityKey(ADMINISTRATE_SERVER)).update());
+  }
+
+  private static TestProjectUpdate.Builder builder() {
+    return builder(Project.nameKey("test-project"));
+  }
+
+  private static TestProjectUpdate.Builder builder(Project.NameKey nameKey) {
+    return TestProjectUpdate.builder(nameKey, ALL_PROJECTS_NAME, u -> {});
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
new file mode 100644
index 0000000..90f581d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.request;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.CurrentUser.PropertyKey;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.Test;
+
+@UseSsh
+public class RequestScopeOperationsImplTest extends AbstractDaemonTest {
+  private static final AtomicInteger changeCounter = new AtomicInteger();
+
+  @Inject private AccountOperations accountOperations;
+  @Inject private Provider<CurrentUser> userProvider;
+  @Inject private RequestScopeOperationsImpl requestScopeOperations;
+  @Inject private Sequences sequences;
+
+  @Test
+  public void setApiUserToExistingUserById() throws Exception {
+    fastCheckCurrentUser(admin.id());
+    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.setApiUser(user.id());
+    assertThat(oldCtx.getUser().getAccountId()).isEqualTo(admin.id());
+    checkCurrentUser(user.id());
+  }
+
+  @Test
+  public void setApiUserToExistingUserByTestAccount() throws Exception {
+    fastCheckCurrentUser(admin.id());
+    TestAccount testAccount =
+        accountOperations.account(accountOperations.newAccount().username("tester").create()).get();
+    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.setApiUser(testAccount);
+    assertThat(oldCtx.getUser().getAccountId()).isEqualTo(admin.id());
+    checkCurrentUser(testAccount.accountId());
+  }
+
+  @Test
+  public void setApiUserToNonExistingUser() throws Exception {
+    fastCheckCurrentUser(admin.id());
+    assertThrows(
+        RuntimeException.class,
+        () -> requestScopeOperations.setApiUser(Account.id(sequences.nextAccountId())));
+    checkCurrentUser(admin.id());
+  }
+
+  @Test
+  public void resetCurrentApiUserClearsCachedState() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    PropertyKey<String> key = PropertyKey.create();
+    atrScope.get().getUser().put(key, "foo");
+    assertThat(atrScope.get().getUser().get(key)).hasValue("foo");
+
+    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.resetCurrentApiUser();
+    checkCurrentUser(user.id());
+    assertThat(atrScope.get().getUser().get(key)).isEmpty();
+    assertThat(oldCtx.getUser().get(key)).hasValue("foo");
+  }
+
+  @Test
+  public void setApiUserAnonymousSetsAnonymousUser() throws Exception {
+    fastCheckCurrentUser(admin.id());
+    requestScopeOperations.setApiUserAnonymous();
+    assertThat(userProvider.get()).isInstanceOf(AnonymousUser.class);
+  }
+
+  private void fastCheckCurrentUser(Account.Id expected) {
+    // Check current user quickly, since the full check requires creating changes and is quite slow.
+    assertWithMessage("user from provider is an IdentifiedUser")
+        .that(userProvider.get().isIdentifiedUser())
+        .isTrue();
+    assertWithMessage("user from provider")
+        .that(userProvider.get().getAccountId())
+        .isEqualTo(expected);
+  }
+
+  private void checkCurrentUser(Account.Id expected) throws Exception {
+    // Test all supported ways that an acceptance test might query the active user.
+    fastCheckCurrentUser(expected);
+    assertWithMessage("user from GerritApi")
+        .that(gApi.accounts().self().get()._accountId)
+        .isEqualTo(expected.get());
+    AcceptanceTestRequestScope.Context ctx = atrScope.get();
+    assertWithMessage("user from AcceptanceTestRequestScope.Context is an IdentifiedUser")
+        .that(ctx.getUser().isIdentifiedUser())
+        .isTrue();
+    assertWithMessage("user from AcceptanceTestRequestScope.Context")
+        .that(ctx.getUser().getAccountId())
+        .isEqualTo(expected);
+    checkSshUser(expected);
+  }
+
+  private void checkSshUser(Account.Id expected) throws Exception {
+    // No "gerrit whoami" command, so the simplest way to check who the user is over SSH is to query
+    // for owner:self.
+    ChangeInput cin = new ChangeInput();
+    cin.project = project.get();
+    cin.branch = "master";
+    cin.subject = "Test change " + changeCounter.incrementAndGet();
+    String changeId = gApi.changes().create(cin).get().changeId;
+    assertThat(gApi.changes().id(changeId).get().owner._accountId).isEqualTo(expected.get());
+    String queryResults =
+        atrScope.get().getSession().exec("gerrit query owner:self change:" + changeId);
+    assertWithMessage("Change-Ids in query results:\n%s", queryResults)
+        .that(findDistinct(queryResults, "I[0-9a-f]{40}"))
+        .containsExactly(changeId);
+  }
+
+  private static ImmutableSet<String> findDistinct(String input, String pattern) {
+    Matcher m = Pattern.compile(pattern).matcher(input);
+    ImmutableSet.Builder<String> b = ImmutableSet.builder();
+    while (m.find()) {
+      b.add(m.group(0));
+    }
+    return b.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
index ba9a5bc..29a23c3 100644
--- a/javatests/com/google/gerrit/common/BUILD
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -1,32 +1,15 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
-SERVER_TEST_SRCS = [
-    "AutoValueTest.java",
-    "VersionTest.java",
-]
-
-junit_tests(
-    name = "client_tests",
-    srcs = glob(
-        ["**/*.java"],
-        exclude = SERVER_TEST_SRCS,
-    ),
-    deps = [
-        "//java/com/google/gerrit/common:client",
-        "//lib:guava",
-        "//lib:junit",
-        "//lib/truth",
-    ],
-)
-
 junit_tests(
     name = "server_tests",
-    srcs = SERVER_TEST_SRCS,
+    srcs = glob(["*.java"]),
     tags = ["no_windows"],
     deps = [
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/common:version",
         "//java/com/google/gerrit/launcher",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
new file mode 100644
index 0000000..e775cbc
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
@@ -0,0 +1,249 @@
+// 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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccessSectionTest {
+  private static final String REF_PATTERN = "refs/heads/master";
+
+  private AccessSection accessSection;
+
+  @Before
+  public void setup() {
+    this.accessSection = new AccessSection(REF_PATTERN);
+  }
+
+  @Test
+  public void getName() {
+    assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
+  }
+
+  @Test
+  public void getEmptyPermissions() {
+    assertThat(accessSection.getPermissions()).isNotNull();
+    assertThat(accessSection.getPermissions()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetPermissions() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.setPermissions(ImmutableList.of(submitPermission));
+    assertThat(accessSection.getPermissions()).containsExactly(submitPermission);
+    assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
+  }
+
+  @Test
+  public void cannotSetDuplicatePermissions() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection.setPermissions(
+                ImmutableList.of(
+                    new Permission(Permission.ABANDON), new Permission(Permission.ABANDON))));
+  }
+
+  @Test
+  public void cannotSetPermissionsWithConflictingNames() {
+    Permission abandonPermissionLowerCase =
+        new Permission(Permission.ABANDON.toLowerCase(Locale.US));
+    Permission abandonPermissionUpperCase =
+        new Permission(Permission.ABANDON.toUpperCase(Locale.US));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection.setPermissions(
+                ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase)));
+  }
+
+  @Test
+  public void getNonExistingPermission() {
+    assertThat(accessSection.getPermission("non-existing")).isNull();
+    assertThat(accessSection.getPermission("non-existing", false)).isNull();
+  }
+
+  @Test
+  public void getPermission() {
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.setPermissions(ImmutableList.of(submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null));
+  }
+
+  @Test
+  public void getPermissionWithOtherCase() {
+    Permission submitPermissionLowerCase = new Permission(Permission.SUBMIT.toLowerCase(Locale.US));
+    accessSection.setPermissions(ImmutableList.of(submitPermissionLowerCase));
+    assertThat(accessSection.getPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
+        .isEqualTo(submitPermissionLowerCase);
+  }
+
+  @Test
+  public void createMissingPermissionOnGet() {
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    assertThat(accessSection.getPermission(Permission.SUBMIT, true))
+        .isEqualTo(new Permission(Permission.SUBMIT));
+
+    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null, true));
+  }
+
+  @Test
+  public void addPermission() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission, submitPermission)
+        .inOrder();
+    assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    List<Permission> permissions = new ArrayList<>();
+    permissions.add(abandonPermission);
+    permissions.add(rebasePermission);
+    accessSection.setPermissions(permissions);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    permissions.add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+  }
+
+  @Test
+  public void removePermission() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.remove(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+    assertThrows(NullPointerException.class, () -> accessSection.remove(null));
+  }
+
+  @Test
+  public void removePermissionByName() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.removePermission(Permission.SUBMIT);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+
+    assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
+  }
+
+  @Test
+  public void removePermissionByNameOtherCase() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
+    String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
+    Permission submitPermissionLowerCase = new Permission(submitLowerCase);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermissionLowerCase));
+    assertThat(accessSection.getPermission(submitLowerCase)).isNotNull();
+    assertThat(accessSection.getPermission(submitUpperCase)).isNotNull();
+
+    accessSection.removePermission(submitUpperCase);
+    assertThat(accessSection.getPermission(submitLowerCase)).isNull();
+    assertThat(accessSection.getPermission(submitUpperCase)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+  }
+
+  @Test
+  public void mergeAccessSections() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    AccessSection accessSection1 = new AccessSection("refs/heads/foo");
+    accessSection1.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+
+    AccessSection accessSection2 = new AccessSection("refs/heads/bar");
+    accessSection2.setPermissions(ImmutableList.of(rebasePermission, submitPermission));
+
+    accessSection1.mergeFrom(accessSection2);
+    assertThat(accessSection1.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission, submitPermission)
+        .inOrder();
+    assertThrows(NullPointerException.class, () -> accessSection.mergeFrom(null));
+  }
+
+  @Test
+  public void testEquals() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+
+    AccessSection accessSectionSamePermissionsOtherRef = new AccessSection("refs/heads/other");
+    accessSectionSamePermissionsOtherRef.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.equals(accessSectionSamePermissionsOtherRef)).isFalse();
+
+    AccessSection accessSectionOther = new AccessSection(REF_PATTERN);
+    accessSectionOther.setPermissions(ImmutableList.of(abandonPermission));
+    assertThat(accessSection.equals(accessSectionOther)).isFalse();
+
+    accessSectionOther.addPermission(rebasePermission);
+    assertThat(accessSection.equals(accessSectionOther)).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/BUILD b/javatests/com/google/gerrit/common/data/BUILD
new file mode 100644
index 0000000..776a5e0
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/BUILD
@@ -0,0 +1,13 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "data_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
index 4c4c769..dcd3c05 100644
--- a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
@@ -14,20 +14,20 @@
 
 package com.google.gerrit.common.data;
 
-import static org.junit.Assert.assertEquals;
+import static com.google.common.truth.Truth.assertThat;
 
 import org.junit.Test;
 
 public class EncodePathSeparatorTest {
   @Test
   public void defaultBehaviour() {
-    assertEquals("a/b", new GitwebType().replacePathSeparator("a/b"));
+    assertThat(new GitwebType().replacePathSeparator("a/b")).isEqualTo("a/b");
   }
 
   @Test
   public void exclamationMark() {
     GitwebType gitwebType = new GitwebType();
     gitwebType.setPathSeparator('!');
-    assertEquals("a!b", gitwebType.replacePathSeparator("a/b"));
+    assertThat(gitwebType.replacePathSeparator("a/b")).isEqualTo("a!b");
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
new file mode 100644
index 0000000..c4f59a1
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -0,0 +1,163 @@
+// 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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
+import org.junit.Test;
+
+public class GroupReferenceTest {
+  @Test
+  public void forGroupDescription() {
+    String name = "foo";
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
+    GroupReference groupReference =
+        GroupReference.forGroup(
+            new GroupDescription.Basic() {
+
+              @Override
+              public String getUrl() {
+                return null;
+              }
+
+              @Override
+              public String getName() {
+                return name;
+              }
+
+              @Override
+              public UUID getGroupUUID() {
+                return uuid;
+              }
+
+              @Override
+              public String getEmailAddress() {
+                return null;
+              }
+            });
+    assertThat(groupReference.getName()).isEqualTo(name);
+    assertThat(groupReference.getUUID()).isEqualTo(uuid);
+  }
+
+  @Test
+  public void create() {
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid");
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(uuid, name);
+    assertThat(groupReference.getUUID()).isEqualTo(uuid);
+    assertThat(groupReference.getName()).isEqualTo(name);
+  }
+
+  @Test
+  public void createWithoutUuid() {
+    // GroupReferences where the UUID is null are used to represent groups from project.config that
+    // cannot be resolved.
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(name);
+    assertThat(groupReference.getUUID()).isNull();
+    assertThat(groupReference.getName()).isEqualTo(name);
+  }
+
+  @Test
+  public void cannotCreateWithoutName() {
+    assertThrows(
+        NullPointerException.class, () -> new GroupReference(AccountGroup.uuid("uuid"), null));
+  }
+
+  @Test
+  public void isGroupReference() {
+    assertThat(GroupReference.isGroupReference("foo")).isFalse();
+    assertThat(GroupReference.isGroupReference("groupfoo")).isFalse();
+    assertThat(GroupReference.isGroupReference("group foo")).isTrue();
+    assertThat(GroupReference.isGroupReference("group foo-bar")).isTrue();
+    assertThat(GroupReference.isGroupReference("group foo bar")).isTrue();
+  }
+
+  @Test
+  public void extractGroupName() {
+    assertThat(GroupReference.extractGroupName("foo")).isNull();
+    assertThat(GroupReference.extractGroupName("groupfoo")).isNull();
+    assertThat(GroupReference.extractGroupName("group foo")).isEqualTo("foo");
+    assertThat(GroupReference.extractGroupName("group foo-bar")).isEqualTo("foo-bar");
+    assertThat(GroupReference.extractGroupName("group foo bar")).isEqualTo("foo bar");
+  }
+
+  @Test
+  public void getAndSetUuid() {
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(uuid, name);
+    assertThat(groupReference.getUUID()).isEqualTo(uuid);
+
+    AccountGroup.UUID uuid2 = AccountGroup.uuid("uuid-bar");
+    groupReference.setUUID(uuid2);
+    assertThat(groupReference.getUUID()).isEqualTo(uuid2);
+
+    // GroupReferences where the UUID is null are used to represent groups from project.config that
+    // cannot be resolved.
+    groupReference.setUUID(null);
+    assertThat(groupReference.getUUID()).isNull();
+  }
+
+  @Test
+  public void getAndSetName() {
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(uuid, name);
+    assertThat(groupReference.getName()).isEqualTo(name);
+
+    String name2 = "bar";
+    groupReference.setName(name2);
+    assertThat(groupReference.getName()).isEqualTo(name2);
+
+    assertThrows(NullPointerException.class, () -> groupReference.setName(null));
+  }
+
+  @Test
+  public void toConfigValue() {
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-foo"), name);
+    assertThat(groupReference.toConfigValue()).isEqualTo("group " + name);
+  }
+
+  @Test
+  public void testEquals() {
+    AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid-foo");
+    AccountGroup.UUID uuid2 = AccountGroup.uuid("uuid-bar");
+    String name1 = "foo";
+    String name2 = "bar";
+
+    GroupReference groupReference1 = new GroupReference(uuid1, name1);
+    GroupReference groupReference2 = new GroupReference(uuid1, name2);
+    GroupReference groupReference3 = new GroupReference(uuid2, name1);
+
+    assertThat(groupReference1.equals(groupReference2)).isTrue();
+    assertThat(groupReference1.equals(groupReference3)).isFalse();
+    assertThat(groupReference2.equals(groupReference3)).isFalse();
+  }
+
+  @Test
+  public void testHashcode() {
+    AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid1");
+    assertThat(new GroupReference(uuid1, "foo").hashCode())
+        .isEqualTo(new GroupReference(uuid1, "bar").hashCode());
+
+    // Check that the following calls don't fail with an exception.
+    new GroupReference("bar").hashCode();
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java b/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
index 985f514..8f2778a 100644
--- a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
@@ -21,19 +21,18 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSet.Id;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import java.sql.Date;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 import org.junit.Test;
 
 public class LabelFunctionTest {
   private static final String LABEL_NAME = "Verified";
-  private static final LabelId LABEL_ID = new LabelId(LABEL_NAME);
-  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 LabelId LABEL_ID = LabelId.create(LABEL_NAME);
+  private static final Change.Id CHANGE_ID = Change.id(100);
+  private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
   private static final LabelType VERIFIED_LABEL = makeLabel();
   private static final PatchSetApproval APPROVAL_2 = makeApproval(2);
   private static final PatchSetApproval APPROVAL_1 = makeApproval(1);
@@ -82,7 +81,7 @@
     SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
 
     assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.getAccountId());
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
   }
 
   private static LabelType makeLabel() {
@@ -97,13 +96,11 @@
   }
 
   private static PatchSetApproval makeApproval(int value) {
-    Account.Id accountId = new Account.Id(10000 + value);
-    PatchSetApproval.Key key = makeKey(PS_ID, accountId, LABEL_ID);
-    return new PatchSetApproval(key, (short) value, Date.from(Instant.now()));
-  }
-
-  private static PatchSetApproval.Key makeKey(Id psId, Account.Id accountId, LabelId labelId) {
-    return new PatchSetApproval.Key(psId, accountId, labelId);
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
+        .value(value)
+        .granted(Date.from(Instant.now()))
+        .build();
   }
 
   private static void checkBlockWorks(LabelFunction function) {
@@ -112,7 +109,7 @@
     SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
 
     assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.REJECT);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.getAccountId());
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.accountId());
   }
 
   private static void checkNothingHappens(LabelFunction function) {
@@ -143,6 +140,6 @@
     SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
 
     assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.getAccountId());
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/LabelTypeTest.java b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
new file mode 100644
index 0000000..6c3befb
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+
+public class LabelTypeTest {
+  @Test
+  public void sortLabelValues() {
+    LabelValue v0 = new LabelValue((short) 0, "Zero");
+    LabelValue v1 = new LabelValue((short) 1, "One");
+    LabelValue v2 = new LabelValue((short) 2, "Two");
+    LabelType types = new LabelType("Label", ImmutableList.of(v2, v0, v1));
+    assertThat(types.getValues()).containsExactly(v0, v1, v2).inOrder();
+  }
+
+  @Test
+  public void insertMissingLabelValues() {
+    LabelValue v0 = new LabelValue((short) 0, "Zero");
+    LabelValue v2 = new LabelValue((short) 2, "Two");
+    LabelValue v5 = new LabelValue((short) 5, "Five");
+    LabelType types = new LabelType("Label", ImmutableList.of(v2, v5, v0));
+    assertThat(types.getValues())
+        .containsExactly(
+            v0,
+            new LabelValue((short) 1, ""),
+            v2,
+            new LabelValue((short) 3, ""),
+            new LabelValue((short) 4, ""),
+            v5)
+        .inOrder();
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
index 0f067c4..b646d2b 100644
--- a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
+++ b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
+import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableMap;
 import java.util.HashMap;
@@ -26,155 +24,148 @@
 public class ParameterizedStringTest {
   @Test
   public void emptyString() {
-    final ParameterizedString p = new ParameterizedString("");
-    assertEquals("", p.getPattern());
-    assertEquals("", p.getRawPattern());
-    assertTrue(p.getParameterNames().isEmpty());
+    ParameterizedString p = new ParameterizedString("");
+    assertThat(p.getPattern()).isEmpty();
+    assertThat(p.getRawPattern()).isEmpty();
+    assertThat(p.getParameterNames()).isEmpty();
 
-    final Map<String, String> a = new HashMap<>();
-    assertNotNull(p.bind(a));
-    assertEquals(0, p.bind(a).length);
-    assertEquals("", p.replace(a));
+    Map<String, String> a = new HashMap<>();
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).isEmpty();
+    assertThat(p.replace(a)).isEmpty();
   }
 
   @Test
   public void asis1() {
-    final ParameterizedString p = ParameterizedString.asis("${bar}c");
-    assertEquals("${bar}c", p.getPattern());
-    assertEquals("${bar}c", p.getRawPattern());
-    assertTrue(p.getParameterNames().isEmpty());
+    ParameterizedString p = ParameterizedString.asis("${bar}c");
+    assertThat(p.getPattern()).isEqualTo("${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("${bar}c");
+    assertThat(p.getParameterNames()).isEmpty();
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(0, p.bind(a).length);
-    assertEquals("${bar}c", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).isEmpty();
+    assertThat(p.replace(a)).isEqualTo("${bar}c");
   }
 
   @Test
   public void replace1() {
-    final ParameterizedString p = new ParameterizedString("${bar}c");
-    assertEquals("${bar}c", p.getPattern());
-    assertEquals("{0}c", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("${bar}c");
+    assertThat(p.getPattern()).isEqualTo("${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("{0}c");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("frobinator", p.bind(a)[0]);
-    assertEquals("frobinatorc", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("frobinator");
+    assertThat(p.replace(a)).isEqualTo("frobinatorc");
   }
 
   @Test
   public void replace2() {
-    final ParameterizedString p = new ParameterizedString("a${bar}c");
-    assertEquals("a${bar}c", p.getPattern());
-    assertEquals("a{0}c", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("a${bar}c");
+    assertThat(p.getPattern()).isEqualTo("a${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("a{0}c");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("frobinator", p.bind(a)[0]);
-    assertEquals("afrobinatorc", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("frobinator");
+    assertThat(p.replace(a)).isEqualTo("afrobinatorc");
   }
 
   @Test
   public void replace3() {
-    final ParameterizedString p = new ParameterizedString("a${bar}");
-    assertEquals("a${bar}", p.getPattern());
-    assertEquals("a{0}", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("a${bar}");
+    assertThat(p.getPattern()).isEqualTo("a${bar}");
+    assertThat(p.getRawPattern()).isEqualTo("a{0}");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("frobinator", p.bind(a)[0]);
-    assertEquals("afrobinator", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("frobinator");
+    assertThat(p.replace(a)).isEqualTo("afrobinator");
   }
 
   @Test
   public void replace4() {
-    final ParameterizedString p = new ParameterizedString("a${bar}c");
-    assertEquals("a${bar}c", p.getPattern());
-    assertEquals("a{0}c", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("a${bar}c");
+    assertThat(p.getPattern()).isEqualTo("a${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("a{0}c");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("", p.bind(a)[0]);
-    assertEquals("ac", p.replace(a));
+    Map<String, String> a = new HashMap<>();
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEmpty();
+    assertThat(p.replace(a)).isEqualTo("ac");
   }
 
   @Test
   public void replaceToLowerCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "FOO");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceToUpperCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "FOO");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
   }
 
   @Test
   public void replaceLocalName() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "foo");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
@@ -182,226 +173,216 @@
     ParameterizedString p =
         new ParameterizedString(
             "hi, ${userName.toUpperCase},your eamil address is '${email.toLowerCase.localPart}'.right?");
-    assertEquals(2, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("userName"));
-    assertTrue(p.getParameterNames().contains("email"));
+    assertThat(p.getParameterNames()).containsExactly("userName", "email");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("userName", "firstName lastName");
     a.put("email", "FIRSTNAME.LASTNAME@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(2, p.bind(a).length);
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(2);
 
-    assertEquals("FIRSTNAME LASTNAME", p.bind(a)[0]);
-    assertEquals("firstname.lastname", p.bind(a)[1]);
-    assertEquals(
-        "hi, FIRSTNAME LASTNAME,your eamil address is 'firstname.lastname'.right?", p.replace(a));
+    assertThat(p.bind(a)[0]).isEqualTo("FIRSTNAME LASTNAME");
+    assertThat(p.bind(a)[1]).isEqualTo("firstname.lastname");
+    assertThat(p.replace(a))
+        .isEqualTo("hi, FIRSTNAME LASTNAME,your eamil address is 'firstname.lastname'.right?");
   }
 
   @Test
   public void replaceToUpperCaseToLowerCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.toLowerCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase.toLowerCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
   }
 
   @Test
   public void replaceToUpperCaseLocalName() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.localPart}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase.localPart}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
   }
 
   @Test
   public void replaceToUpperCaseAnUndefinedMethod() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.anUndefinedMethod}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase.anUndefinedMethod}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
   }
 
   @Test
   public void replaceLocalNameToUpperCase() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart.toUpperCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart.toUpperCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
   }
 
   @Test
   public void replaceLocalNameToLowerCase() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart.toLowerCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart.toLowerCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceLocalNameAnUndefinedMethod() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart.anUndefinedMethod}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart.anUndefinedMethod}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceToLowerCaseToUpperCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.toUpperCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase.toUpperCase}");
+    assertThat(p.getParameterNames()).hasSize(1);
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
   }
 
   @Test
   public void replaceToLowerCaseLocalName() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.localPart}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase.localPart}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceToLowerCaseAnUndefinedMethod() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.anUndefinedMethod}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase.anUndefinedMethod}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
   }
 
   @Test
   public void replaceSubmitTooltipWithVariables() {
     ParameterizedString p = new ParameterizedString("Submit patch set ${patchSet} into ${branch}");
-    assertEquals(2, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("patchSet"));
+    assertThat(p.getParameterNames()).hasSize(2);
+    assertThat(p.getParameterNames()).containsExactly("patchSet", "branch");
 
     Map<String, String> params =
         ImmutableMap.of(
             "patchSet", "42",
             "branch", "foo");
-    assertNotNull(p.bind(params));
-    assertEquals(2, p.bind(params).length);
-    assertEquals("42", p.bind(params)[0]);
-    assertEquals("foo", p.bind(params)[1]);
-    assertEquals("Submit patch set 42 into foo", p.replace(params));
+    assertThat(p.bind(params)).isNotNull();
+    assertThat(p.bind(params)).hasLength(2);
+    assertThat(p.bind(params)[0]).isEqualTo("42");
+    assertThat(p.bind(params)[1]).isEqualTo("foo");
+    assertThat(p.replace(params)).isEqualTo("Submit patch set 42 into foo");
   }
 
   @Test
@@ -411,7 +392,7 @@
         ImmutableMap.of(
             "patchSet", "42",
             "branch", "foo");
-    assertEquals(0, p.bind(params).length);
-    assertEquals("Submit patch set 40 into master", p.replace(params));
+    assertThat(p.bind(params)).isEmpty();
+    assertThat(p.replace(params)).isEqualTo("Submit patch set 40 into master");
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
new file mode 100644
index 0000000..1b70a8a
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
@@ -0,0 +1,395 @@
+// 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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionRuleTest {
+  private GroupReference groupReference;
+  private PermissionRule permissionRule;
+
+  @Before
+  public void setup() {
+    this.groupReference = new GroupReference(AccountGroup.uuid("uuid"), "group");
+    this.permissionRule = new PermissionRule(groupReference);
+  }
+
+  @Test
+  public void getAndSetAction() {
+    assertThat(permissionRule.getAction()).isEqualTo(Action.ALLOW);
+
+    permissionRule.setAction(Action.DENY);
+    assertThat(permissionRule.getAction()).isEqualTo(Action.DENY);
+  }
+
+  @Test
+  public void cannotSetActionToNull() {
+    assertThrows(NullPointerException.class, () -> permissionRule.setAction(null));
+  }
+
+  @Test
+  public void setDeny() {
+    assertThat(permissionRule.isDeny()).isFalse();
+
+    permissionRule.setDeny();
+    assertThat(permissionRule.isDeny()).isTrue();
+  }
+
+  @Test
+  public void setBlock() {
+    assertThat(permissionRule.isBlock()).isFalse();
+
+    permissionRule.setBlock();
+    assertThat(permissionRule.isBlock()).isTrue();
+  }
+
+  @Test
+  public void setForce() {
+    assertThat(permissionRule.getForce()).isFalse();
+
+    permissionRule.setForce(true);
+    assertThat(permissionRule.getForce()).isTrue();
+
+    permissionRule.setForce(false);
+    assertThat(permissionRule.getForce()).isFalse();
+  }
+
+  @Test
+  public void setMin() {
+    assertThat(permissionRule.getMin()).isEqualTo(0);
+
+    permissionRule.setMin(-2);
+    assertThat(permissionRule.getMin()).isEqualTo(-2);
+
+    permissionRule.setMin(2);
+    assertThat(permissionRule.getMin()).isEqualTo(2);
+  }
+
+  @Test
+  public void setMax() {
+    assertThat(permissionRule.getMax()).isEqualTo(0);
+
+    permissionRule.setMax(2);
+    assertThat(permissionRule.getMax()).isEqualTo(2);
+
+    permissionRule.setMax(-2);
+    assertThat(permissionRule.getMax()).isEqualTo(-2);
+  }
+
+  @Test
+  public void setRange() {
+    assertThat(permissionRule.getMin()).isEqualTo(0);
+    assertThat(permissionRule.getMax()).isEqualTo(0);
+
+    permissionRule.setRange(-2, 2);
+    assertThat(permissionRule.getMin()).isEqualTo(-2);
+    assertThat(permissionRule.getMax()).isEqualTo(2);
+
+    permissionRule.setRange(2, -2);
+    assertThat(permissionRule.getMin()).isEqualTo(-2);
+    assertThat(permissionRule.getMax()).isEqualTo(2);
+
+    permissionRule.setRange(1, 1);
+    assertThat(permissionRule.getMin()).isEqualTo(1);
+    assertThat(permissionRule.getMax()).isEqualTo(1);
+  }
+
+  @Test
+  public void hasRange() {
+    assertThat(permissionRule.hasRange()).isFalse();
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRule.hasRange()).isTrue();
+
+    permissionRule.setMax(1);
+    assertThat(permissionRule.hasRange()).isTrue();
+  }
+
+  @Test
+  public void getGroup() {
+    assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
+  }
+
+  @Test
+  public void setGroup() {
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
+    assertThat(groupReference2).isNotEqualTo(groupReference);
+
+    assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
+
+    permissionRule.setGroup(groupReference2);
+    assertThat(permissionRule.getGroup()).isEqualTo(groupReference2);
+  }
+
+  @Test
+  public void mergeFromAnyBlock() {
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isBlock()).isFalse();
+    assertThat(permissionRule2.isBlock()).isFalse();
+
+    permissionRule2.setBlock();
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isTrue();
+
+    permissionRule2.setDeny();
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isFalse();
+
+    permissionRule2.setAction(Action.BATCH);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isFalse();
+  }
+
+  @Test
+  public void mergeFromAnyDeny() {
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isDeny()).isFalse();
+    assertThat(permissionRule2.isDeny()).isFalse();
+
+    permissionRule2.setDeny();
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isDeny()).isTrue();
+    assertThat(permissionRule2.isDeny()).isTrue();
+
+    permissionRule2.setAction(Action.BATCH);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isDeny()).isTrue();
+    assertThat(permissionRule2.isDeny()).isFalse();
+  }
+
+  @Test
+  public void mergeFromAnyBatch() {
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getAction()).isNotEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+
+    permissionRule2.setAction(Action.BATCH);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isEqualTo(Action.BATCH);
+
+    permissionRule2.setAction(Action.ALLOW);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+  }
+
+  @Test
+  public void mergeFromAnyForce() {
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getForce()).isFalse();
+    assertThat(permissionRule2.getForce()).isFalse();
+
+    permissionRule2.setForce(true);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getForce()).isTrue();
+    assertThat(permissionRule2.getForce()).isTrue();
+
+    permissionRule2.setForce(false);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getForce()).isTrue();
+    assertThat(permissionRule2.getForce()).isFalse();
+  }
+
+  @Test
+  public void mergeFromMergeRange() {
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+    permissionRule1.setRange(-1, 2);
+
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+    permissionRule2.setRange(-2, 1);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getMin()).isEqualTo(-2);
+    assertThat(permissionRule1.getMax()).isEqualTo(2);
+    assertThat(permissionRule2.getMin()).isEqualTo(-2);
+    assertThat(permissionRule2.getMax()).isEqualTo(1);
+  }
+
+  @Test
+  public void mergeFromGroupNotChanged() {
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getGroup()).isEqualTo(groupReference1);
+    assertThat(permissionRule2.getGroup()).isEqualTo(groupReference2);
+  }
+
+  @Test
+  public void asString() {
+    assertThat(permissionRule.asString(true)).isEqualTo("group " + groupReference.getName());
+
+    permissionRule.setDeny();
+    assertThat(permissionRule.asString(true)).isEqualTo("deny group " + groupReference.getName());
+
+    permissionRule.setBlock();
+    assertThat(permissionRule.asString(true)).isEqualTo("block group " + groupReference.getName());
+
+    permissionRule.setAction(Action.BATCH);
+    assertThat(permissionRule.asString(true)).isEqualTo("batch group " + groupReference.getName());
+
+    permissionRule.setAction(Action.INTERACTIVE);
+    assertThat(permissionRule.asString(true))
+        .isEqualTo("interactive group " + groupReference.getName());
+
+    permissionRule.setForce(true);
+    assertThat(permissionRule.asString(true))
+        .isEqualTo("interactive +force group " + groupReference.getName());
+
+    permissionRule.setAction(Action.ALLOW);
+    assertThat(permissionRule.asString(true)).isEqualTo("+force group " + groupReference.getName());
+
+    permissionRule.setMax(1);
+    assertThat(permissionRule.asString(true))
+        .isEqualTo("+force +0..+1 group " + groupReference.getName());
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRule.asString(true))
+        .isEqualTo("+force -1..+1 group " + groupReference.getName());
+
+    assertThat(permissionRule.asString(false))
+        .isEqualTo("+force group " + groupReference.getName());
+  }
+
+  @Test
+  public void fromString() {
+    PermissionRule permissionRule = PermissionRule.fromString("group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("deny group A", true);
+    assertPermissionRule(permissionRule, "A", Action.DENY, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("block group A", true);
+    assertPermissionRule(permissionRule, "A", Action.BLOCK, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("batch group A", true);
+    assertPermissionRule(permissionRule, "A", Action.BATCH, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("interactive group A", true);
+    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("interactive +force group A", true);
+    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, true, 0, 0);
+
+    permissionRule = PermissionRule.fromString("+force group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+
+    permissionRule = PermissionRule.fromString("+force +0..+1 group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 1);
+
+    permissionRule = PermissionRule.fromString("+force -1..+1 group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, -1, 1);
+
+    permissionRule = PermissionRule.fromString("+force group A", false);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+  }
+
+  @Test
+  public void parseInt() {
+    assertThat(PermissionRule.parseInt("0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("+0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("-0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("1")).isEqualTo(1);
+    assertThat(PermissionRule.parseInt("+1")).isEqualTo(1);
+    assertThat(PermissionRule.parseInt("-1")).isEqualTo(-1);
+  }
+
+  @Test
+  public void testEquals() {
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRuleOther = new PermissionRule(groupReference2);
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setGroup(groupReference);
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+
+    permissionRule.setDeny();
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setDeny();
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+
+    permissionRule.setForce(true);
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setForce(true);
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setMin(-1);
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+
+    permissionRule.setMax(1);
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setMax(1);
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+  }
+
+  private void assertPermissionRule(
+      PermissionRule permissionRule,
+      String expectedGroupName,
+      Action expectedAction,
+      boolean expectedForce,
+      int expectedMin,
+      int expectedMax) {
+    assertThat(permissionRule.getGroup().getName()).isEqualTo(expectedGroupName);
+    assertThat(permissionRule.getAction()).isEqualTo(expectedAction);
+    assertThat(permissionRule.getForce()).isEqualTo(expectedForce);
+    assertThat(permissionRule.getMin()).isEqualTo(expectedMin);
+    assertThat(permissionRule.getMax()).isEqualTo(expectedMax);
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
new file mode 100644
index 0000000..0202b10
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/PermissionTest.java
@@ -0,0 +1,325 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionTest {
+  private static final String PERMISSION_NAME = "foo";
+
+  private Permission permission;
+
+  @Before
+  public void setup() {
+    this.permission = new Permission(PERMISSION_NAME);
+  }
+
+  @Test
+  public void isPermission() {
+    assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
+    assertThat(Permission.isPermission("no-permission")).isFalse();
+
+    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void hasRange() {
+    assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
+    assertThat(Permission.hasRange("no-permission")).isFalse();
+
+    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabel() {
+    assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabel("no-permission")).isFalse();
+
+    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
+    assertThat(Permission.isLabel("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabelAs() {
+    assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabelAs("no-permission")).isFalse();
+
+    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void forLabel() {
+    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+  }
+
+  @Test
+  public void forLabelAs() {
+    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+  }
+
+  @Test
+  public void extractLabel() {
+    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
+        .isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel("Code-Review")).isNull();
+    assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
+  }
+
+  @Test
+  public void canBeOnAllProjects() {
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+  }
+
+  @Test
+  public void getName() {
+    assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
+  }
+
+  @Test
+  public void getLabel() {
+    assertThat(new Permission(Permission.LABEL + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(new Permission(Permission.LABEL_AS + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(new Permission("Code-Review").getLabel()).isNull();
+    assertThat(new Permission(Permission.ABANDON).getLabel()).isNull();
+  }
+
+  @Test
+  public void exclusiveGroup() {
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.getExclusiveGroup()).isTrue();
+
+    permission.setExclusiveGroup(false);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void noExclusiveGroupOnOwnerPermission() {
+    Permission permission = new Permission(Permission.OWNER);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void getEmptyRules() {
+    assertThat(permission.getRules()).isNotNull();
+    assertThat(permission.getRules()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetRules() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+
+    PermissionRule permissionRule3 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
+    permission.setRules(ImmutableList.of(permissionRule3));
+    assertThat(permission.getRules()).containsExactly(permissionRule3);
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
+
+    List<PermissionRule> rules = new ArrayList<>();
+    rules.add(permissionRule1);
+    rules.add(permissionRule2);
+    permission.setRules(rules);
+    assertThat(permission.getRule(groupReference3)).isNull();
+
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+    rules.add(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+  }
+
+  @Test
+  public void getNonExistingRule() {
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
+    assertThat(permission.getRule(groupReference)).isNull();
+    assertThat(permission.getRule(groupReference, false)).isNull();
+  }
+
+  @Test
+  public void getRule() {
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
+    PermissionRule permissionRule = new PermissionRule(groupReference);
+    permission.setRules(ImmutableList.of(permissionRule));
+    assertThat(permission.getRule(groupReference)).isEqualTo(permissionRule);
+  }
+
+  @Test
+  public void createMissingRuleOnGet() {
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
+    assertThat(permission.getRule(groupReference)).isNull();
+
+    assertThat(permission.getRule(groupReference, true))
+        .isEqualTo(new PermissionRule(groupReference));
+  }
+
+  @Test
+  public void addRule() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
+    assertThat(permission.getRule(groupReference3)).isNull();
+
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+    permission.add(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isEqualTo(permissionRule3);
+    assertThat(permission.getRules())
+        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+        .inOrder();
+  }
+
+  @Test
+  public void removeRule() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
+    assertThat(permission.getRule(groupReference3)).isNotNull();
+
+    permission.remove(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+  }
+
+  @Test
+  public void removeRuleByGroupReference() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
+    assertThat(permission.getRule(groupReference3)).isNotNull();
+
+    permission.removeRule(groupReference3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+  }
+
+  @Test
+  public void clearRules() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.getRules()).isNotEmpty();
+
+    permission.clearRules();
+    assertThat(permission.getRules()).isEmpty();
+  }
+
+  @Test
+  public void mergePermissions() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    PermissionRule permissionRule3 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
+
+    Permission permission1 = new Permission("foo");
+    permission1.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+
+    Permission permission2 = new Permission("bar");
+    permission2.setRules(ImmutableList.of(permissionRule2, permissionRule3));
+
+    permission1.mergeFrom(permission2);
+    assertThat(permission1.getRules())
+        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+        .inOrder();
+  }
+
+  @Test
+  public void testEquals() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+
+    Permission permissionSameRulesOtherName = new Permission("bar");
+    permissionSameRulesOtherName.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
+
+    Permission permissionSameRulesSameNameOtherExclusiveGroup = new Permission("foo");
+    permissionSameRulesSameNameOtherExclusiveGroup.setRules(
+        ImmutableList.of(permissionRule1, permissionRule2));
+    permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
+    assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
+
+    Permission permissionOther = new Permission(PERMISSION_NAME);
+    permissionOther.setRules(ImmutableList.of(permissionRule1));
+    assertThat(permission.equals(permissionOther)).isFalse();
+
+    permissionOther.add(permissionRule2);
+    assertThat(permission.equals(permissionOther)).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index 1249909..a2bd092 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -2,7 +2,7 @@
 
 java_library(
     name = "elasticsearch_test_utils",
-    testonly = 1,
+    testonly = True,
     srcs = [
         "ElasticContainer.java",
         "ElasticTestUtils.java",
@@ -10,49 +10,91 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
-        "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//lib:gson",
         "//lib:guava",
         "//lib:junit",
         "//lib/guice",
         "//lib/httpcomponents:httpcore",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/testcontainers",
-        "//lib/truth",
+        "//lib/testcontainers:testcontainers-elasticsearch",
     ],
 )
 
-ELASTICSEARCH_TESTS = {i: "ElasticQuery" + i.capitalize() + "sTest.java" for i in [
+ELASTICSEARCH_DEPS = [
+    ":elasticsearch_test_utils",
+    "//java/com/google/gerrit/elasticsearch",
+    "//java/com/google/gerrit/testing:gerrit-test-util",
+    "//lib/guice",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+]
+
+QUERY_TESTS_DEP = "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests"
+
+TYPES = [
     "account",
     "change",
     "group",
     "project",
-]}
+]
+
+SUFFIX = "sTest.java"
+
+ELASTICSEARCH_TESTS_V5 = {i: "ElasticV5Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TESTS_V6 = {i: "ElasticV6Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TESTS_V7 = {i: "ElasticV7Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TAGS = [
+    "docker",
+    "elastic",
+    "exclusive",
+]
 
 [junit_tests(
-    name = "elasticsearch_%ss_test" % name,
+    name = "elasticsearch_query_%ss_test_V5" % name,
     size = "large",
     srcs = [src],
-    tags = [
-        "docker",
-        "elastic",
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name],
+) for name, src in ELASTICSEARCH_TESTS_V5.items()]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_V6" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name],
+) for name, src in ELASTICSEARCH_TESTS_V6.items()]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_V7" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name] + [
+        "//lib/httpcomponents:httpasyncclient",
+        "//lib/httpcomponents:httpclient",
     ],
+) for name, src in ELASTICSEARCH_TESTS_V7.items()]
+
+junit_tests(
+    name = "elasticsearch_tests",
+    size = "small",
+    srcs = glob(
+        ["*Test.java"],
+        exclude = ["Elastic*Query*" + SUFFIX],
+    ),
+    tags = ["elastic"],
     deps = [
-        ":elasticsearch_test_utils",
         "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests" % name,
+        "//lib:guava",
         "//lib/guice",
         "//lib/httpcomponents:httpcore",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
-        "//lib/testcontainers",
+        "//lib/truth",
     ],
-) for name, src in ELASTICSEARCH_TESTS.items()]
+)
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
new file mode 100644
index 0000000..7e044c3
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.DEFAULT_USERNAME;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PASSWORD;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PREFIX;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_SERVER;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_USERNAME;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.SECTION_ELASTICSEARCH;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.ProvisionException;
+import java.util.Arrays;
+import org.apache.http.HttpHost;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class ElasticConfigurationTest {
+  @Test
+  public void singleServerNoOtherConfig() throws Exception {
+    Config cfg = newConfig();
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic:1234");
+    assertThat(esCfg.username).isNull();
+    assertThat(esCfg.password).isNull();
+    assertThat(esCfg.prefix).isEmpty();
+  }
+
+  @Test
+  public void serverWithoutPortSpecified() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic:9200");
+  }
+
+  @Test
+  public void prefix() throws Exception {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PREFIX, "myprefix");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.prefix).isEqualTo("myprefix");
+  }
+
+  @Test
+  public void withAuthentication() throws Exception {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_USERNAME, "myself");
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.username).isEqualTo("myself");
+    assertThat(esCfg.password).isEqualTo("s3kr3t");
+  }
+
+  @Test
+  public void withAuthenticationPasswordOnlyUsesDefaultUsername() throws Exception {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.username).isEqualTo(DEFAULT_USERNAME);
+    assertThat(esCfg.password).isEqualTo("s3kr3t");
+  }
+
+  @Test
+  public void multipleServers() throws Exception {
+    Config cfg = new Config();
+    cfg.setStringList(
+        SECTION_ELASTICSEARCH,
+        null,
+        KEY_SERVER,
+        ImmutableList.of("http://elastic1:1234", "http://elastic2:1234"));
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic1:1234", "http://elastic2:1234");
+  }
+
+  @Test
+  public void noServers() throws Exception {
+    assertProvisionException(new Config());
+  }
+
+  @Test
+  public void singleServerInvalid() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "foo");
+    assertProvisionException(cfg);
+  }
+
+  @Test
+  public void multipleServersIncludingInvalid() throws Exception {
+    Config cfg = new Config();
+    cfg.setStringList(
+        SECTION_ELASTICSEARCH, null, KEY_SERVER, ImmutableList.of("http://elastic1:1234", "foo"));
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic1:1234");
+  }
+
+  private static Config newConfig() {
+    Config config = new Config();
+    config.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic:1234");
+    return config;
+  }
+
+  private void assertHosts(ElasticConfiguration cfg, Object... hostURIs) throws Exception {
+    assertThat(Arrays.asList(cfg.getHosts()).stream().map(HttpHost::toURI).collect(toList()))
+        .containsExactly(hostURIs);
+  }
+
+  private void assertProvisionException(Config cfg) {
+    ProvisionException thrown =
+        assertThrows(ProvisionException.class, () -> new ElasticConfiguration(cfg));
+    assertThat(thrown).hasMessageThat().contains("No valid Elasticsearch servers configured");
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index df15d8f..a0c40c7 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -14,21 +14,19 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.common.collect.ImmutableSet;
-import java.util.Set;
 import org.apache.http.HttpHost;
 import org.junit.AssumptionViolatedException;
-import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.elasticsearch.ElasticsearchContainer;
 
 /* Helper class for running ES integration tests in docker container */
-public class ElasticContainer<SELF extends ElasticContainer<SELF>> extends GenericContainer<SELF> {
+public class ElasticContainer extends ElasticsearchContainer {
   private static final int ELASTICSEARCH_DEFAULT_PORT = 9200;
 
-  public static ElasticContainer<?> createAndStart(ElasticVersion version) {
+  public static ElasticContainer createAndStart(ElasticVersion version) {
     // Assumption violation is not natively supported by Testcontainers.
     // See https://github.com/testcontainers/testcontainers-java/issues/343
     try {
-      ElasticContainer<?> container = new ElasticContainer<>(version);
+      ElasticContainer container = new ElasticContainer(version);
       container.start();
       return container;
     } catch (Throwable t) {
@@ -36,18 +34,28 @@
     }
   }
 
-  public static ElasticContainer<?> createAndStart() {
-    return createAndStart(ElasticVersion.V2_4);
-  }
-
   private static String getImageName(ElasticVersion version) {
     switch (version) {
-      case V2_4:
-        return "elasticsearch:2.4.6-alpine";
       case V5_6:
-        return "elasticsearch:5.6.9-alpine";
+        return "blacktop/elasticsearch:5.6.16";
       case V6_2:
-        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4";
+        return "blacktop/elasticsearch:6.2.4";
+      case V6_3:
+        return "blacktop/elasticsearch:6.3.2";
+      case V6_4:
+        return "blacktop/elasticsearch:6.4.3";
+      case V6_5:
+        return "blacktop/elasticsearch:6.5.4";
+      case V6_6:
+        return "blacktop/elasticsearch:6.6.2";
+      case V6_7:
+        return "blacktop/elasticsearch:6.7.2";
+      case V7_0:
+        return "blacktop/elasticsearch:7.0.1";
+      case V7_1:
+        return "blacktop/elasticsearch:7.1.1";
+      case V7_2:
+        return "blacktop/elasticsearch:7.2.0";
     }
     throw new IllegalStateException("No tests for version: " + version.name());
   }
@@ -56,19 +64,6 @@
     super(getImageName(version));
   }
 
-  @Override
-  protected void configure() {
-    addExposedPort(ELASTICSEARCH_DEFAULT_PORT);
-
-    // https://github.com/docker-library/elasticsearch/issues/58
-    addEnv("-Ees.network.host", "0.0.0.0");
-  }
-
-  @Override
-  public Set<Integer> getLivenessCheckPortNumbers() {
-    return ImmutableSet.of(getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
-  }
-
   public HttpHost getHttpHost() {
     return new HttpHost(getContainerIpAddress(), getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
deleted file mode 100644
index 4f0f8b0..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticQueryAccountsTest extends AbstractQueryAccountsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
-    }
-
-    container = ElasticContainer.createAndStart();
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
deleted file mode 100644
index a02d691..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
+++ /dev/null
@@ -1,73 +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.elasticsearch;
-
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticQueryChangesTest extends AbstractQueryChangesTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
-    }
-
-    container = ElasticContainer.createAndStart();
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
deleted file mode 100644
index f13c491..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
+++ /dev/null
@@ -1,73 +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.elasticsearch;
-
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticQueryGroupsTest extends AbstractQueryGroupsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
-    }
-
-    container = ElasticContainer.createAndStart();
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
deleted file mode 100644
index dd04010..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
+++ /dev/null
@@ -1,73 +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.elasticsearch;
-
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticQueryProjectsTest extends AbstractQueryProjectsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
-    }
-
-    container = ElasticContainer.createAndStart();
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index ca52e2a..6802873 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -15,12 +15,11 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.TypeLiteral;
-import java.io.IOException;
 import java.util.Collection;
+import java.util.UUID;
 import org.eclipse.jgit.lib.Config;
 
 public final class ElasticTestUtils {
@@ -32,16 +31,22 @@
     }
   }
 
-  public static void configure(Config config, int port, String prefix) {
-    config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
-    config.setString("elasticsearch", "test", "protocol", "http");
-    config.setString("elasticsearch", "test", "hostname", "localhost");
-    config.setInt("elasticsearch", "test", "port", port);
+  public static void configure(Config config, int port, String prefix, ElasticVersion version) {
+    config.setString("index", null, "type", "elasticsearch");
+    config.setString("elasticsearch", null, "server", "http://localhost:" + port);
     config.setString("elasticsearch", null, "prefix", prefix);
-    config.setString("index", null, "maxLimit", "10000");
+    config.setInt("index", null, "maxLimit", 10000);
+    String password = version == ElasticVersion.V5_6 ? "changeme" : null;
+    if (password != null) {
+      config.setString("elasticsearch", null, "password", password);
+    }
   }
 
-  public static void createAllIndexes(Injector injector) throws IOException {
+  public static void configure(Config config, int port, String prefix) {
+    configure(config, port, prefix, null);
+  }
+
+  public static void createAllIndexes(Injector injector) {
     Collection<IndexDefinition<?, ?, ?>> indexDefs =
         injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {}));
     for (IndexDefinition<?, ?, ?> indexDef : indexDefs) {
@@ -49,6 +54,16 @@
     }
   }
 
+  public static Config getConfig(ElasticVersion version) {
+    ElasticNodeInfo elasticNodeInfo;
+    ElasticContainer container = ElasticContainer.createAndStart(version);
+    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+    String indicesPrefix = UUID.randomUUID().toString();
+    Config cfg = new Config();
+    configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
+    return cfg;
+  }
+
   private ElasticTestUtils() {
     // hide default constructor
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
index 649fc6f..e5bd19f 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
@@ -32,7 +32,7 @@
   }
 
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -52,10 +52,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -66,8 +62,9 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    String indicesPrefix = testName.getSanitizedMethodName();
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
index 4aa08fa..e1aadb8 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
@@ -24,6 +25,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
+import org.junit.Rule;
 
 public class ElasticV5QueryChangesTest extends AbstractQueryChangesTest {
   @ConfigSuite.Default
@@ -32,7 +34,7 @@
   }
 
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -52,9 +54,7 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
+  @Rule public final GerritTestName testName = new GerritTestName();
 
   @Override
   protected void initAfterLifecycleStart() throws Exception {
@@ -66,8 +66,9 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    String indicesPrefix = testName.getSanitizedMethodName();
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
index 72f8b49..fcec859 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
@@ -32,7 +32,7 @@
   }
 
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -52,10 +52,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -66,8 +62,9 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    String indicesPrefix = testName.getSanitizedMethodName();
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
index 7b49e1d..16f06d5 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
@@ -32,7 +32,7 @@
   }
 
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -52,10 +52,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -66,8 +62,9 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    String indicesPrefix = testName.getSanitizedMethodName();
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index db710f6..9c79270 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -32,7 +32,7 @@
   }
 
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_2);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_7);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
@@ -52,10 +52,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -66,8 +62,8 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index 043de4e..8a20e07 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
@@ -24,6 +25,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
+import org.junit.Rule;
 
 public class ElasticV6QueryChangesTest extends AbstractQueryChangesTest {
   @ConfigSuite.Default
@@ -32,7 +34,7 @@
   }
 
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -41,7 +43,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_2);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_7);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
@@ -52,9 +54,7 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
+  @Rule public final GerritTestName testName = new GerritTestName();
 
   @Override
   protected void initAfterLifecycleStart() throws Exception {
@@ -66,8 +66,8 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index b126c9d..4f152bd 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -32,7 +32,7 @@
   }
 
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_2);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_7);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
@@ -52,10 +52,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -66,8 +62,8 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
index eaaf0c8..96d9296 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
@@ -32,7 +32,7 @@
   }
 
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_2);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_7);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
@@ -52,10 +52,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -66,8 +62,8 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
new file mode 100644
index 0000000..c6faa7b
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV7QueryAccountsTest extends AbstractQueryAccountsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V7_2);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName.getSanitizedMethodName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
new file mode 100644
index 0000000..780c8ab
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.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.elasticsearch;
+
+import static java.util.concurrent.TimeUnit.MINUTES;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritTestName;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.apache.http.impl.nio.client.HttpAsyncClients;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+
+public class ElasticV7QueryChangesTest extends AbstractQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer container;
+  private static CloseableHttpAsyncClient client;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V7_2);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+    client = HttpAsyncClients.createDefault();
+    client.start();
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Rule public final GerritTestName testName = new GerritTestName();
+
+  @After
+  public void closeIndex() throws Exception {
+    client
+        .execute(
+            new HttpPost(
+                String.format(
+                    "http://localhost:%d/%s*/_close",
+                    nodeInfo.port, testName.getSanitizedMethodName())),
+            HttpClientContext.create(),
+            null)
+        .get(5, MINUTES);
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName.getSanitizedMethodName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
new file mode 100644
index 0000000..188ed26
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV7QueryGroupsTest extends AbstractQueryGroupsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V7_2);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName.getSanitizedMethodName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
new file mode 100644
index 0000000..88617ee
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV7QueryProjectsTest extends AbstractQueryProjectsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V7_2);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName.getSanitizedMethodName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index e0da86a..0ad80de 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -15,31 +15,95 @@
 package com.google.gerrit.elasticsearch;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class ElasticVersionTest {
-  @Rule public ExpectedException exception = ExpectedException.none();
-
   @Test
   public void supportedVersion() throws Exception {
-    assertThat(ElasticVersion.forVersion("2.4.0")).isEqualTo(ElasticVersion.V2_4);
-    assertThat(ElasticVersion.forVersion("2.4.6")).isEqualTo(ElasticVersion.V2_4);
-
     assertThat(ElasticVersion.forVersion("5.6.0")).isEqualTo(ElasticVersion.V5_6);
-    assertThat(ElasticVersion.forVersion("5.6.9")).isEqualTo(ElasticVersion.V5_6);
+    assertThat(ElasticVersion.forVersion("5.6.11")).isEqualTo(ElasticVersion.V5_6);
 
     assertThat(ElasticVersion.forVersion("6.2.0")).isEqualTo(ElasticVersion.V6_2);
     assertThat(ElasticVersion.forVersion("6.2.4")).isEqualTo(ElasticVersion.V6_2);
+
+    assertThat(ElasticVersion.forVersion("6.3.0")).isEqualTo(ElasticVersion.V6_3);
+    assertThat(ElasticVersion.forVersion("6.3.2")).isEqualTo(ElasticVersion.V6_3);
+
+    assertThat(ElasticVersion.forVersion("6.4.0")).isEqualTo(ElasticVersion.V6_4);
+    assertThat(ElasticVersion.forVersion("6.4.1")).isEqualTo(ElasticVersion.V6_4);
+
+    assertThat(ElasticVersion.forVersion("6.5.0")).isEqualTo(ElasticVersion.V6_5);
+    assertThat(ElasticVersion.forVersion("6.5.1")).isEqualTo(ElasticVersion.V6_5);
+
+    assertThat(ElasticVersion.forVersion("6.6.0")).isEqualTo(ElasticVersion.V6_6);
+    assertThat(ElasticVersion.forVersion("6.6.1")).isEqualTo(ElasticVersion.V6_6);
+
+    assertThat(ElasticVersion.forVersion("6.7.0")).isEqualTo(ElasticVersion.V6_7);
+    assertThat(ElasticVersion.forVersion("6.7.1")).isEqualTo(ElasticVersion.V6_7);
+
+    assertThat(ElasticVersion.forVersion("7.0.0")).isEqualTo(ElasticVersion.V7_0);
+    assertThat(ElasticVersion.forVersion("7.0.1")).isEqualTo(ElasticVersion.V7_0);
+
+    assertThat(ElasticVersion.forVersion("7.1.0")).isEqualTo(ElasticVersion.V7_1);
+    assertThat(ElasticVersion.forVersion("7.1.1")).isEqualTo(ElasticVersion.V7_1);
+
+    assertThat(ElasticVersion.forVersion("7.2.0")).isEqualTo(ElasticVersion.V7_2);
+    assertThat(ElasticVersion.forVersion("7.2.1")).isEqualTo(ElasticVersion.V7_2);
   }
 
   @Test
   public void unsupportedVersion() throws Exception {
-    exception.expect(ElasticVersion.InvalidVersion.class);
-    exception.expectMessage(
-        "Invalid version: [4.0.0]. Supported versions: " + ElasticVersion.supportedVersions());
-    ElasticVersion.forVersion("4.0.0");
+    ElasticVersion.UnsupportedVersion thrown =
+        assertThrows(
+            ElasticVersion.UnsupportedVersion.class, () -> ElasticVersion.forVersion("4.0.0"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Unsupported version: [4.0.0]. Supported versions: "
+                + ElasticVersion.supportedVersions());
+  }
+
+  @Test
+  public void atLeastMinorVersion() throws Exception {
+    assertThat(ElasticVersion.V5_6.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_2.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_3.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_4.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_5.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_6.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_7.isAtLeastMinorVersion(ElasticVersion.V6_7)).isTrue();
+    assertThat(ElasticVersion.V7_0.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V7_1.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V7_2.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+  }
+
+  @Test
+  public void version6OrLater() throws Exception {
+    assertThat(ElasticVersion.V5_6.isV6OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_2.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_3.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_4.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_5.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_6.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_7.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_0.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_1.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_2.isV6OrLater()).isTrue();
+  }
+
+  @Test
+  public void version7OrLater() throws Exception {
+    assertThat(ElasticVersion.V5_6.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_2.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_3.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_4.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_5.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_6.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_7.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V7_0.isV7OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_1.isV7OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_2.isV7OrLater()).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/extensions/BUILD b/javatests/com/google/gerrit/extensions/BUILD
index 069c915..94e433c 100644
--- a/javatests/com/google/gerrit/extensions/BUILD
+++ b/javatests/com/google/gerrit/extensions/BUILD
@@ -7,6 +7,8 @@
     deps = [
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib/guice",
         "//lib/truth",
     ],
diff --git a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
new file mode 100644
index 0000000..5e8c7b6
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.client;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.BAR;
+import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.BAZ;
+import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.FOO;
+
+import com.google.common.math.IntMath;
+import java.util.EnumSet;
+import org.junit.Test;
+
+public class ListOptionTest {
+  enum MyOption implements ListOption {
+    FOO(0),
+    BAR(1),
+    BAZ(17);
+
+    private final int value;
+
+    MyOption(int value) {
+      this.value = value;
+    }
+
+    @Override
+    public int getValue() {
+      return value;
+    }
+  }
+
+  @Test
+  public void fromBits() {
+    assertThat(IntMath.pow(2, BAZ.getValue())).isEqualTo(131072);
+    assertThat(ListOption.fromBits(MyOption.class, 0)).isEmpty();
+    assertThat(ListOption.fromBits(MyOption.class, 1)).containsExactly(FOO);
+    assertThat(ListOption.fromBits(MyOption.class, 2)).containsExactly(BAR);
+    assertThat(ListOption.fromBits(MyOption.class, 131072)).containsExactly(BAZ);
+    assertThat(ListOption.fromBits(MyOption.class, 3)).containsExactly(FOO, BAR);
+    assertThat(ListOption.fromBits(MyOption.class, 131073)).containsExactly(FOO, BAZ);
+    assertThat(ListOption.fromBits(MyOption.class, 131074)).containsExactly(BAR, BAZ);
+    assertThat(ListOption.fromBits(MyOption.class, 131075)).containsExactly(FOO, BAR, BAZ);
+
+    assertFromBitsFails(4);
+    assertFromBitsFails(8);
+    assertFromBitsFails(16);
+    assertFromBitsFails(250);
+  }
+
+  private void assertFromBitsFails(int v) {
+    try {
+      EnumSet<MyOption> opts = ListOption.fromBits(MyOption.class, v);
+      assertWithMessage("expected RuntimeException for fromBits(%s), got: %s", v, opts).fail();
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/extensions/conditions/BUILD b/javatests/com/google/gerrit/extensions/conditions/BUILD
index e2d5951..7ad2ad3 100644
--- a/javatests/com/google/gerrit/extensions/conditions/BUILD
+++ b/javatests/com/google/gerrit/extensions/conditions/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/extensions:lib",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
index 117e474..0542c35 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<Extension<Integer>> entryIterator = ds.entries().iterator();
+    Extension<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/git/BUILD b/javatests/com/google/gerrit/git/BUILD
new file mode 100644
index 0000000..ca272b2
--- /dev/null
+++ b/javatests/com/google/gerrit/git/BUILD
@@ -0,0 +1,37 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+MEDIUM_TESTS = ["RefUpdateUtilRepoTest.java"]
+
+junit_tests(
+    name = "medium_tests",
+    size = "medium",
+    timeout = "short",
+    srcs = MEDIUM_TESTS,
+    tags = ["no_windows"],
+    deps = [
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
+    ],
+)
+
+junit_tests(
+    name = "small_tests",
+    size = "small",
+    srcs = glob(
+        ["*.java"],
+        exclude = MEDIUM_TESTS,
+    ),
+    deps = [
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/git/ObjectIdsTest.java b/javatests/com/google/gerrit/git/ObjectIdsTest.java
new file mode 100644
index 0000000..b254d6f
--- /dev/null
+++ b/javatests/com/google/gerrit/git/ObjectIdsTest.java
@@ -0,0 +1,156 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.eclipse.jgit.lib.Constants.OBJECT_ID_STRING_LENGTH;
+
+import java.util.function.Function;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevBlob;
+import org.junit.Test;
+
+public class ObjectIdsTest {
+  private static final ObjectId ID =
+      ObjectId.fromString("0000000000100000000000000000000000000000");
+  private static final ObjectId AMBIGUOUS_BLOB_ID =
+      ObjectId.fromString("0000000000b36b6aa7ea4b75318ed078f55505c3");
+  private static final ObjectId AMBIGUOUS_TREE_ID =
+      ObjectId.fromString("0000000000cdcf04beb2fab69e65622616294984");
+
+  @Test
+  public void abbreviateNameDefaultLength() throws Exception {
+    assertRuntimeException(() -> abbreviateName(null));
+    assertThat(abbreviateName(ID)).isEqualTo("0000000");
+    assertThat(abbreviateName(AMBIGUOUS_BLOB_ID)).isEqualTo(abbreviateName(ID));
+    assertThat(abbreviateName(AMBIGUOUS_TREE_ID)).isEqualTo(abbreviateName(ID));
+  }
+
+  @Test
+  public void abbreviateNameCustomLength() throws Exception {
+    assertRuntimeException(() -> abbreviateName(null, 1));
+    assertRuntimeException(() -> abbreviateName(ID, -1));
+    assertRuntimeException(() -> abbreviateName(ID, 0));
+    assertRuntimeException(() -> abbreviateName(ID, 41));
+    assertThat(abbreviateName(ID, 5)).isEqualTo("00000");
+    assertThat(abbreviateName(ID, 40)).isEqualTo(ID.name());
+  }
+
+  @Test
+  public void abbreviateNameDefaultLengthWithReader() throws Exception {
+    assertRuntimeException(() -> abbreviateName(ID, null));
+
+    ObjectReader reader = newReaderWithAmbiguousIds();
+    assertThat(abbreviateName(ID, reader)).isEqualTo("00000000001");
+  }
+
+  @Test
+  public void abbreviateNameCustomLengthWithReader() throws Exception {
+    ObjectReader reader = newReaderWithAmbiguousIds();
+    assertRuntimeException(() -> abbreviateName(ID, -1, reader));
+    assertRuntimeException(() -> abbreviateName(ID, 0, reader));
+    assertRuntimeException(() -> abbreviateName(ID, 41, reader));
+    assertRuntimeException(() -> abbreviateName(ID, 5, null));
+
+    String shortest = "00000000001";
+    assertThat(abbreviateName(ID, 1, reader)).isEqualTo(shortest);
+    assertThat(abbreviateName(ID, 7, reader)).isEqualTo(shortest);
+    assertThat(abbreviateName(ID, shortest.length(), reader)).isEqualTo(shortest);
+    assertThat(abbreviateName(ID, shortest.length() + 1, reader)).isEqualTo("000000000010");
+  }
+
+  @Test
+  public void copyOrNull() throws Exception {
+    testCopy(ObjectIds::copyOrNull);
+    assertThat(ObjectIds.copyOrNull(null)).isNull();
+  }
+
+  @Test
+  public void copyOrZero() throws Exception {
+    testCopy(ObjectIds::copyOrZero);
+    assertThat(ObjectIds.copyOrZero(null)).isEqualTo(ObjectId.zeroId());
+  }
+
+  private void testCopy(Function<AnyObjectId, ObjectId> copyFunc) {
+    MyObjectId myId = new MyObjectId(ID);
+    assertThat(myId).isEqualTo(ID);
+
+    ObjectId copy = copyFunc.apply(myId);
+    assertThat(copy).isEqualTo(myId);
+    assertThat(copy).isNotSameInstanceAs(myId);
+    assertThat(copy.getClass()).isEqualTo(ObjectId.class);
+  }
+
+  @Test
+  public void matchesAbbreviation() throws Exception {
+    assertThat(ObjectIds.matchesAbbreviation(null, "")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, "0")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, "00000")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, "not a SHA-1")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, ID.name())).isFalse();
+
+    assertThat(ObjectIds.matchesAbbreviation(ID, "")).isTrue();
+    for (int i = 1; i <= OBJECT_ID_STRING_LENGTH; i++) {
+      String prefix = ID.name().substring(0, i);
+      assertWithMessage("match %s against %s", ID.name(), prefix)
+          .that(ObjectIds.matchesAbbreviation(ID, prefix))
+          .isTrue();
+    }
+
+    assertThat(ObjectIds.matchesAbbreviation(ID, "1")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(ID, "x")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(ID, "not a SHA-1")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(ID, AMBIGUOUS_BLOB_ID.name())).isFalse();
+  }
+
+  @FunctionalInterface
+  private interface Func {
+    void call() throws Exception;
+  }
+
+  private static void assertRuntimeException(Func func) throws Exception {
+    assertThrows(RuntimeException.class, () -> func.call());
+  }
+
+  private static ObjectReader newReaderWithAmbiguousIds() throws Exception {
+    // Recipe for creating ambiguous IDs courtesy of git core:
+    // https://github.com/git/git/blob/df799f5d99ac51d4fc791d546de3f936088582fc/t/t1512-rev-parse-disambiguation.sh
+    try (TestRepository<Repository> tr =
+        new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("repo")))) {
+      String blobData = "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n\nb1rwzyc3\n";
+      RevBlob blob = tr.blob(blobData);
+      assertThat(blob.name()).isEqualTo(AMBIGUOUS_BLOB_ID.name());
+      assertThat(tr.tree(tr.file("a0blgqsjc", blob)).name()).isEqualTo(AMBIGUOUS_TREE_ID.name());
+      return tr.getRevWalk().getObjectReader();
+    }
+  }
+
+  private static class MyObjectId extends ObjectId {
+    private static final long serialVersionUID = 1L;
+
+    MyObjectId(AnyObjectId src) {
+      super(src);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java
new file mode 100644
index 0000000..60b90f3
--- /dev/null
+++ b/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java
@@ -0,0 +1,120 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.MoreFiles;
+import com.google.common.io.RecursiveDeleteOption;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class RefUpdateUtilRepoTest {
+  public enum RepoSetup {
+    LOCAL_DISK {
+      @Override
+      Repository setUpRepo() throws Exception {
+        Path p = Files.createTempDirectory("gerrit_repo_");
+        try {
+          Repository repo = new FileRepository(p.toFile());
+          repo.create(true);
+          return repo;
+        } catch (Exception e) {
+          delete(p);
+          throw e;
+        }
+      }
+
+      @Override
+      void tearDownRepo(Repository repo) throws Exception {
+        delete(repo.getDirectory().toPath());
+      }
+
+      private void delete(Path p) throws Exception {
+        MoreFiles.deleteRecursively(p, RecursiveDeleteOption.ALLOW_INSECURE);
+      }
+    },
+
+    IN_MEMORY {
+      @Override
+      Repository setUpRepo() {
+        return new InMemoryRepository(new DfsRepositoryDescription("repo"));
+      }
+
+      @Override
+      void tearDownRepo(Repository repo) {}
+    };
+
+    abstract Repository setUpRepo() throws Exception;
+
+    abstract void tearDownRepo(Repository repo) throws Exception;
+  }
+
+  @Parameters(name = "{0}")
+  public static ImmutableList<RepoSetup[]> data() {
+    return ImmutableList.copyOf(new RepoSetup[][] {{RepoSetup.LOCAL_DISK}, {RepoSetup.IN_MEMORY}});
+  }
+
+  @Parameter public RepoSetup repoSetup;
+
+  private Repository repo;
+
+  @Before
+  public void setUp() throws Exception {
+    repo = repoSetup.setUpRepo();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (repo != null) {
+      repoSetup.tearDownRepo(repo);
+      repo = null;
+    }
+  }
+
+  @Test
+  public void deleteRefNoOp() throws Exception {
+    String ref = "refs/heads/foo";
+    assertThat(repo.exactRef(ref)).isNull();
+    RefUpdateUtil.deleteChecked(repo, "refs/heads/foo");
+    assertThat(repo.exactRef(ref)).isNull();
+  }
+
+  @Test
+  public void deleteRef() throws Exception {
+    String ref = "refs/heads/foo";
+    try (TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch(ref).commit().create();
+    }
+
+    assertThat(repo.exactRef(ref)).isNotNull();
+    RefUpdateUtil.deleteChecked(repo, "refs/heads/foo");
+    assertThat(repo.exactRef(ref)).isNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
new file mode 100644
index 0000000..1d021f7
--- /dev/null
+++ b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
@@ -0,0 +1,113 @@
+// 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.git;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.util.function.Consumer;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class RefUpdateUtilTest {
+  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
+  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
+      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
+  private static final Consumer<ReceiveCommand> REJECTED =
+      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
+  private static final Consumer<ReceiveCommand> ABORTED =
+      c -> {
+        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
+        ReceiveCommand.abort(ImmutableList.of(c));
+        checkState(
+            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
+                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
+                && c.getResult() != ReceiveCommand.Result.OK,
+            "unexpected state after abort: %s",
+            c);
+      };
+
+  @Test
+  public void checkBatchRefUpdateResults() throws Exception {
+    checkResults();
+    checkResults(OK);
+    checkResults(OK, OK);
+
+    assertIoException(REJECTED);
+    assertIoException(OK, REJECTED);
+    assertIoException(LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, OK);
+    assertIoException(LOCK_FAILURE, REJECTED, OK);
+    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, OK);
+
+    assertLockFailureException(LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
+    assertLockFailureException(ABORTED);
+    assertLockFailureException(ABORTED, ABORTED);
+  }
+
+  @SafeVarargs
+  private static void checkResults(Consumer<ReceiveCommand>... resultSetters) throws Exception {
+    RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
+  }
+
+  @SafeVarargs
+  private static void assertIoException(Consumer<ReceiveCommand>... resultSetters) {
+    IOException thrown =
+        assertThrows(
+            IOException.class, () -> RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters)));
+    assertThat(thrown).isNotInstanceOf(LockFailureException.class);
+  }
+
+  @SafeVarargs
+  private static void assertLockFailureException(Consumer<ReceiveCommand>... resultSetters)
+      throws Exception {
+    assertThrows(
+        LockFailureException.class,
+        () -> RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters)));
+  }
+
+  @SafeVarargs
+  private static BatchRefUpdate newBatchRefUpdate(Consumer<ReceiveCommand>... resultSetters) {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      for (int i = 0; i < resultSetters.length; i++) {
+        ReceiveCommand cmd =
+            new ReceiveCommand(
+                ObjectId.fromString(String.format("%039x1", i)),
+                ObjectId.fromString(String.format("%039x2", i)),
+                "refs/heads/branch" + i);
+        bru.addCommand(cmd);
+        resultSetters[i].accept(cmd);
+      }
+      return bru;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/git/testing/BUILD b/javatests/com/google/gerrit/git/testing/BUILD
index 56e9ec2..1309185 100644
--- a/javatests/com/google/gerrit/git/testing/BUILD
+++ b/javatests/com/google/gerrit/git/testing/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/git/testing",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/gpg/BUILD b/javatests/com/google/gerrit/gpg/BUILD
index baf65b7..6edfa93 100644
--- a/javatests/com/google/gerrit/gpg/BUILD
+++ b/javatests/com/google/gerrit/gpg/BUILD
@@ -19,7 +19,6 @@
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/bouncycastle:bcpg",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov",
diff --git a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index f8ab417..bc035af 100644
--- a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -33,8 +33,6 @@
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountManager;
@@ -42,16 +40,12 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testing.InMemoryDatabase;
 import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.NoteDbMode;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -78,14 +72,11 @@
 
   @Inject private IdentifiedUser.GenericFactory userFactory;
 
-  @Inject private InMemoryDatabase schemaFactory;
-
   @Inject private SchemaCreator schemaCreator;
 
   @Inject private ThreadLocalRequestContext requestContext;
 
   private LifecycleManager lifecycle;
-  private ReviewDb db;
   private Account.Id userId;
   private IdentifiedUser user;
   private Repository storeRepo;
@@ -102,16 +93,14 @@
         ImmutableList.of(
             Fingerprint.toString(keyB().getPublicKey().getFingerprint()),
             Fingerprint.toString(keyD().getPublicKey().getFingerprint())));
-    Injector injector =
-        Guice.createInjector(new InMemoryModule(cfg, NoteDbMode.newNotesMigrationFromEnv()));
+    Injector injector = Guice.createInjector(new InMemoryModule(cfg));
 
     lifecycle = new LifecycleManager();
     lifecycle.add(injector);
     injector.injectMembers(this);
     lifecycle.start();
 
-    db = schemaFactory.open();
-    schemaCreator.create(db);
+    schemaCreator.create();
     userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     // Note: does not match any key in TestKeys.
     accountsUpdateProvider
@@ -119,18 +108,7 @@
         .update("Set Preferred Email", userId, u -> u.setPreferredEmail("user@example.com"));
     user = reloadUser();
 
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
+    requestContext.setContext(() -> user);
 
     storeRepo = new InMemoryRepository(new DfsRepositoryDescription("repo"));
     store = new PublicKeyStore(storeRepo);
@@ -158,10 +136,6 @@
     if (lifecycle != null) {
       lifecycle.stop();
     }
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
   }
 
   @Test
@@ -232,7 +206,7 @@
     assertProblems(
         checker.check(key.getPublicKey()),
         Status.BAD,
-        "No identities found for user; check http://test/#/settings/web-identities");
+        "No identities found for user; check http://test/settings#Identities");
 
     checker = checkerFactory.create().setStore(store).disableTrust();
     assertProblems(
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index 48d5266..7703fb0 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -57,13 +57,9 @@
 import org.eclipse.jgit.lib.RefUpdate;
 import org.junit.After;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class PublicKeyCheckerTest {
-  @Rule public ExpectedException thrown = ExpectedException.none();
-
   private InMemoryRepository repo;
   private PublicKeyStore store;
 
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
index b5b942d..3727d38 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
@@ -21,11 +21,13 @@
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithExpiration;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpirationWithSubkeyWithExpiration;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
+import com.google.common.collect.Iterators;
 import com.google.gerrit.gpg.testing.TestKey;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -99,6 +101,25 @@
   }
 
   @Test
+  public void getSubkeyReturnsMasterKey() throws Exception {
+    TestKey key1 = validKeyWithoutExpirationWithSubkeyWithExpiration();
+    PGPPublicKeyRing keyRing = key1.getPublicKeyRing();
+    store.add(keyRing);
+
+    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+
+    long masterKeyId = key1.getKeyId();
+    long subKeyId = 0;
+    for (PGPPublicKey key : keyRing) {
+      if (masterKeyId != subKeyId) {
+        subKeyId = key.getKeyID();
+      }
+    }
+
+    assertKeys(subKeyId, key1);
+  }
+
+  @Test
   public void getMultiple() throws Exception {
     TestKey key1 = validKeyWithoutExpiration();
     TestKey key2 = validKeyWithExpiration();
@@ -163,6 +184,8 @@
     TestKey key5 = validKeyWithSecondUserId();
     PGPPublicKeyRing keyRing = key5.getPublicKeyRing();
     PGPPublicKey key = keyRing.getPublicKey();
+    PGPPublicKey subKey =
+        keyRing.getPublicKey(Iterators.get(keyRing.getPublicKeys(), 1).getKeyID());
     store.add(keyRing);
     assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
 
@@ -171,9 +194,11 @@
         "Testuser Five <test5@example.com>",
         "foo:myId");
 
+    keyRing = PGPPublicKeyRing.removePublicKey(keyRing, subKey);
     keyRing = PGPPublicKeyRing.removePublicKey(keyRing, key);
     key = PGPPublicKey.removeCertification(key, "foo:myId");
     keyRing = PGPPublicKeyRing.insertPublicKey(keyRing, key);
+    keyRing = PGPPublicKeyRing.insertPublicKey(keyRing, subKey);
     store.add(keyRing);
     assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
 
@@ -196,6 +221,29 @@
   }
 
   @Test
+  public void removeMasterKeyRemovesSubkey() throws Exception {
+    TestKey key1 = validKeyWithoutExpirationWithSubkeyWithExpiration();
+    PGPPublicKeyRing keyRing = key1.getPublicKeyRing();
+    store.add(keyRing);
+
+    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+
+    long masterKeyId = key1.getKeyId();
+    long subKeyId = 0;
+    for (PGPPublicKey key : keyRing) {
+      if (masterKeyId != subKeyId) {
+        subKeyId = key.getKeyID();
+      }
+    }
+
+    store.remove(key1.getPublicKey().getFingerprint());
+    assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
+
+    assertKeys(masterKeyId);
+    assertKeys(subKeyId);
+  }
+
+  @Test
   public void removeNonexisting() throws Exception {
     TestKey key1 = validKeyWithoutExpiration();
     store.add(key1.getPublicKeyRing());
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/BUILD b/javatests/com/google/gerrit/httpd/BUILD
index ec2df15..6849d66 100644
--- a/javatests/com/google/gerrit/httpd/BUILD
+++ b/javatests/com/google/gerrit/httpd/BUILD
@@ -10,11 +10,11 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/util/http",
         "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:gson",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib:jimfs",
         "//lib:junit",
         "//lib:servlet-api-3_1-without-neverlink",
@@ -25,5 +25,6 @@
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
+        "//lib/truth:truth-java8-extension",
     ],
 )
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
new file mode 100644
index 0000000..d695c48
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.staticTemplateData;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.template.soy.data.SanitizedContent;
+import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
+import java.util.HashMap;
+import org.junit.Test;
+
+public class IndexHtmlUtilTest {
+  @Test
+  public void polymer2() throws Exception {
+    assertThat(
+            staticTemplateData(
+                "http://example.com/",
+                null,
+                null,
+                ImmutableMap.of("p2", new String[0]),
+                IndexHtmlUtilTest::ordain))
+        .containsExactly("canonicalPath", "", "polymer2", "true", "staticResourcePath", ordain(""));
+  }
+
+  @Test
+  public void noPathAndNoCDN() throws Exception {
+    assertThat(
+            staticTemplateData(
+                "http://example.com/", null, null, new HashMap<>(), IndexHtmlUtilTest::ordain))
+        .containsExactly("canonicalPath", "", "staticResourcePath", ordain(""));
+  }
+
+  @Test
+  public void pathAndNoCDN() throws Exception {
+    assertThat(
+            staticTemplateData(
+                "http://example.com/gerrit/",
+                null,
+                null,
+                new HashMap<>(),
+                IndexHtmlUtilTest::ordain))
+        .containsExactly("canonicalPath", "/gerrit", "staticResourcePath", ordain("/gerrit"));
+  }
+
+  @Test
+  public void noPathAndCDN() throws Exception {
+    assertThat(
+            staticTemplateData(
+                "http://example.com/",
+                "http://my-cdn.com/foo/bar/",
+                null,
+                new HashMap<>(),
+                IndexHtmlUtilTest::ordain))
+        .containsExactly(
+            "canonicalPath", "", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
+  }
+
+  @Test
+  public void pathAndCDN() throws Exception {
+    assertThat(
+            staticTemplateData(
+                "http://example.com/gerrit",
+                "http://my-cdn.com/foo/bar/",
+                null,
+                new HashMap<>(),
+                IndexHtmlUtilTest::ordain))
+        .containsExactly(
+            "canonicalPath", "/gerrit", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
+  }
+
+  private static SanitizedContent ordain(String s) {
+    return UnsafeSanitizedContentOrdainer.ordainAsSafe(
+        s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index edafeb3..99835dd 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,65 +15,64 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
 
-import com.google.template.soy.data.SoyMapData;
-import java.net.URISyntaxException;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.Accounts;
+import com.google.gerrit.extensions.api.config.Config;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import org.junit.Test;
 
 public class IndexServletTest {
-  class TestIndexServlet extends IndexServlet {
-    private static final long serialVersionUID = 1L;
-
-    TestIndexServlet(String canonicalURL, String cdnPath, String faviconPath)
-        throws URISyntaxException {
-      super(canonicalURL, cdnPath, faviconPath);
-    }
-
-    String getIndexSource() {
-      return new String(indexSource);
-    }
-  }
 
   @Test
-  public void noPathAndNoCDN() throws URISyntaxException {
-    SoyMapData data = IndexServlet.getTemplateData("http://example.com/", null, null);
-    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
-    assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("");
-  }
+  public void renderTemplate() throws Exception {
+    Accounts accountsApi = createMock(Accounts.class);
+    expect(accountsApi.self()).andThrow(new AuthException("user needs to be authenticated"));
 
-  @Test
-  public void pathAndNoCDN() throws URISyntaxException {
-    SoyMapData data = IndexServlet.getTemplateData("http://example.com/gerrit/", null, null);
-    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
-    assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("/gerrit");
-  }
+    Server serverApi = createMock(Server.class);
+    expect(serverApi.getVersion()).andReturn("123");
+    expect(serverApi.topMenus()).andReturn(ImmutableList.of());
+    ServerInfo serverInfo = new ServerInfo();
+    serverInfo.defaultTheme = "my-default-theme";
+    expect(serverApi.getInfo()).andReturn(serverInfo);
 
-  @Test
-  public void noPathAndCDN() throws URISyntaxException {
-    SoyMapData data =
-        IndexServlet.getTemplateData("http://example.com/", "http://my-cdn.com/foo/bar/", null);
-    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
-    assertThat(data.getSingle("staticResourcePath").stringValue())
-        .isEqualTo("http://my-cdn.com/foo/bar/");
-  }
+    Config configApi = createMock(Config.class);
+    expect(configApi.server()).andReturn(serverApi);
 
-  @Test
-  public void pathAndCDN() throws URISyntaxException {
-    SoyMapData data =
-        IndexServlet.getTemplateData(
-            "http://example.com/gerrit", "http://my-cdn.com/foo/bar/", null);
-    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
-    assertThat(data.getSingle("staticResourcePath").stringValue())
-        .isEqualTo("http://my-cdn.com/foo/bar/");
-  }
+    GerritApi gerritApi = createMock(GerritApi.class);
+    expect(gerritApi.accounts()).andReturn(accountsApi);
+    expect(gerritApi.config()).andReturn(configApi);
 
-  @Test
-  public void renderTemplate() throws URISyntaxException {
     String testCanonicalUrl = "foo-url";
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
-    TestIndexServlet servlet = new TestIndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL);
-    String output = servlet.getIndexSource();
+    IndexServlet servlet =
+        new IndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi);
+
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    replay(gerritApi);
+    replay(configApi);
+    replay(serverApi);
+    replay(accountsApi);
+
+    servlet.doGet(new FakeHttpServletRequest(), response);
+
+    verify(gerritApi);
+    verify(configApi);
+    verify(serverApi);
+    verify(accountsApi);
+
+    String output = response.getActualBodyString();
     assertThat(output).contains("<!DOCTYPE html>");
     assertThat(output).contains("window.CANONICAL_PATH = '" + testCanonicalUrl);
     assertThat(output).contains("<link rel=\"preload\" href=\"" + testCdnPath);
@@ -83,5 +82,12 @@
                 + testCanonicalUrl
                 + "/"
                 + testFaviconURL);
+    assertThat(output)
+        .contains(
+            "window.INITIAL_DATA = JSON.parse("
+                + "'\\x7b\\x22\\/config\\/server\\/version\\x22: \\x22123\\x22, "
+                + "\\x22\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
+                + "\\x22my-default-theme\\x22\\x7d, \\x22\\/config\\/server\\/top-menus\\x22: "
+                + "\\x5b\\x5d\\x7d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
index 6dd15bc..dd594d6 100644
--- a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.raw;
 
 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 static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
@@ -335,8 +336,8 @@
   }
 
   private static void assertCacheHits(Cache<?, ?> cache, int hits, int misses) {
-    assertThat(cache.stats().hitCount()).named("hits").isEqualTo(hits);
-    assertThat(cache.stats().missCount()).named("misses").isEqualTo(misses);
+    assertWithMessage("hits").that(cache.stats().hitCount()).isEqualTo(hits);
+    assertWithMessage("misses").that(cache.stats().missCount()).isEqualTo(misses);
   }
 
   private static void assertCacheable(FakeHttpServletResponse res, boolean revalidate) {
diff --git a/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java b/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java
new file mode 100644
index 0000000..fb1ebd9
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java
@@ -0,0 +1,35 @@
+// 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.httpd.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class HttpLogRedactTest {
+  @Test
+  public void redactAuth() {
+    assertThat(LogRedactUtil.redactQueryString("query=status:open")).isEqualTo("query=status:open");
+
+    assertThat(LogRedactUtil.redactQueryString("query=status:open&access_token=foo"))
+        .isEqualTo("query=status:open&access_token=*");
+
+    assertThat(LogRedactUtil.redactQueryString("access_token=foo")).isEqualTo("access_token=*");
+
+    assertThat(
+            LogRedactUtil.redactQueryString("query=status:open&access_token=foo&access_token=bar"))
+        .isEqualTo("query=status:open&access_token=*&access_token=*");
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java b/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
index 13732b0..a550ac7 100644
--- a/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
+++ b/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.httpd.restapi;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -109,35 +109,26 @@
   public void rejectDuplicateMethod() {
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.setQueryString("$m=PUT&$m=DELETE");
-    try {
-      ParameterParser.getQueryParams(req);
-      fail("expected BadRequestException");
-    } catch (BadRequestException bad) {
-      assertThat(bad).hasMessageThat().isEqualTo("duplicate $m");
-    }
+    BadRequestException bad =
+        assertThrows(BadRequestException.class, () -> ParameterParser.getQueryParams(req));
+    assertThat(bad).hasMessageThat().isEqualTo("duplicate $m");
   }
 
   @Test
   public void rejectDuplicateContentType() {
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.setQueryString("$ct=json&$ct=string");
-    try {
-      ParameterParser.getQueryParams(req);
-      fail("expected BadRequestException");
-    } catch (BadRequestException bad) {
-      assertThat(bad).hasMessageThat().isEqualTo("duplicate $ct");
-    }
+    BadRequestException bad =
+        assertThrows(BadRequestException.class, () -> ParameterParser.getQueryParams(req));
+    assertThat(bad).hasMessageThat().isEqualTo("duplicate $ct");
   }
 
   @Test
   public void rejectInvalidMethod() {
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.setQueryString("$m=CONNECT");
-    try {
-      ParameterParser.getQueryParams(req);
-      fail("expected BadRequestException");
-    } catch (BadRequestException bad) {
-      assertThat(bad).hasMessageThat().isEqualTo("invalid $m");
-    }
+    BadRequestException bad =
+        assertThrows(BadRequestException.class, () -> ParameterParser.getQueryParams(req));
+    assertThat(bad).hasMessageThat().isEqualTo("invalid $m");
   }
 }
diff --git a/javatests/com/google/gerrit/index/BUILD b/javatests/com/google/gerrit/index/BUILD
index 14a7048..a77525e 100644
--- a/javatests/com/google/gerrit/index/BUILD
+++ b/javatests/com/google/gerrit/index/BUILD
@@ -6,9 +6,12 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index:query_parser",
+        "//java/com/google/gerrit/index/query/testing",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:junit",
         "//lib/antlr:java-runtime",
diff --git a/javatests/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
index 3c0bbe0..698e00a 100644
--- a/javatests/com/google/gerrit/index/SchemaUtilTest.java
+++ b/javatests/com/google/gerrit/index/SchemaUtilTest.java
@@ -18,16 +18,13 @@
 import static com.google.gerrit.index.SchemaUtil.getNameParts;
 import static com.google.gerrit.index.SchemaUtil.getPersonParts;
 import static com.google.gerrit.index.SchemaUtil.schema;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import java.util.Map;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class SchemaUtilTest {
-  @Rule public ExpectedException exception = ExpectedException.none();
-
   static class TestSchemas {
     static final Schema<String> V1 = schema();
     static final Schema<String> V2 = schema();
@@ -46,9 +43,9 @@
     assertThat(all.get(1)).isEqualTo(TestSchemas.V1);
     assertThat(all.get(2)).isEqualTo(TestSchemas.V2);
     assertThat(all.get(4)).isEqualTo(TestSchemas.V4);
-
-    exception.expect(IllegalArgumentException.class);
-    SchemaUtil.schemasFromClass(TestSchemas.class, Object.class);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> SchemaUtil.schemasFromClass(TestSchemas.class, Object.class));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/index/query/AndPredicateTest.java b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
index 21098b3..16828dd 100644
--- a/javatests/com/google/gerrit/index/query/AndPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.collect.ImmutableList.of;
 import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.List;
 import org.junit.Test;
@@ -43,28 +43,13 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = and(a, b);
 
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
     assertChildren("clear", n, of(a, b));
 
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
     assertChildren("remove(0)", n, of(a, b));
 
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
     assertChildren("iterator().remove()", n, of(a, b));
   }
 
diff --git a/javatests/com/google/gerrit/index/query/FieldPredicateTest.java b/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
index 8fe90fc..2d2c99e 100644
--- a/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.index.query;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertSame;
@@ -58,11 +60,12 @@
   @Test
   public void testCopy() {
     final OperatorPredicate<String> f = f("author", "alice");
-    assertSame(f, f.copy(Collections.<Predicate<String>>emptyList()));
+    assertSame(f, f.copy(Collections.emptyList()));
     assertSame(f, f.copy(f.getChildren()));
 
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("Expected 0 children");
-    f.copy(Collections.singleton(f("owner", "bob")));
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class, () -> f.copy(Collections.singleton(f("owner", "bob"))));
+    assertThat(thrown).hasMessageThat().contains("Expected 0 children");
   }
 }
diff --git a/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java b/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java
new file mode 100644
index 0000000..7064f64
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.index.query;
+
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gerrit.server.query.change.OrSource;
+import java.util.Collection;
+import java.util.Iterator;
+import org.junit.Test;
+
+/**
+ * Tests that boolean data sources are lazy in that they don't call {@link ResultSet#toList()} or
+ * {@link ResultSet#toList()}. This is necessary because it allows Gerrit to send multiple queries
+ * to the index in parallel, have the results come in asynchronously and wait for them only when we
+ * call aforementioned methods on the {@link ResultSet}.
+ */
+public class LazyDataSourceTest {
+
+  /** Helper to avoid a mock which would be hard to create because of the type inference. */
+  static class LazyPredicate extends Predicate<ChangeData> implements ChangeDataSource {
+    @Override
+    public int getCardinality() {
+      return 1;
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() {
+      return new FailingResultSet<>();
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() {
+      return new FailingResultSet<>();
+    }
+
+    @Override
+    public Predicate<ChangeData> copy(Collection<? extends Predicate<ChangeData>> children) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public int hashCode() {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public boolean hasChange() {
+      throw new UnsupportedOperationException("not implemented");
+    }
+  }
+
+  /** Implementation that throws {@link AssertionError} when accessing results. */
+  static class FailingResultSet<T> implements ResultSet<T> {
+    @Override
+    public Iterator<T> iterator() {
+      throw new AssertionError(
+          "called iterator() on the result set, but shouldn't have because the data source must be lazy");
+    }
+
+    @Override
+    public ImmutableList<T> toList() {
+      throw new AssertionError(
+          "called toList() on the result set, but shouldn't have because the data source must be lazy");
+    }
+
+    @Override
+    public void close() {
+      // No-op
+    }
+  }
+
+  @Test
+  public void andSourceIsLazy() {
+    AndSource<ChangeData> and = new AndSource<>(ImmutableList.of(new LazyPredicate()));
+    ResultSet<ChangeData> resultSet = and.read();
+    assertThrows(AssertionError.class, () -> resultSet.toList());
+  }
+
+  @Test
+  public void orSourceIsLazy() {
+    OrSource or = new OrSource(ImmutableList.of(new LazyPredicate()));
+    ResultSet<ChangeData> resultSet = or.read();
+    assertThrows(AssertionError.class, () -> resultSet.toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/index/query/NotPredicateTest.java b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
index 88d8349..3d1839d 100644
--- a/javatests/com/google/gerrit/index/query/NotPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.gerrit.index.query.Predicate.and;
 import static com.google.gerrit.index.query.Predicate.not;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.Collections;
 import java.util.List;
@@ -50,17 +50,14 @@
     final TestPredicate p = f("author", "bob");
     final Predicate<String> n = not(p);
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().clear();
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
     assertOnlyChild("clear", p, n);
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().remove(0);
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
     assertOnlyChild("remove(0)", p, n);
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().iterator().remove();
-    assertOnlyChild("remove(0)", p, n);
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
+    assertOnlyChild("remove()", p, n);
   }
 
   private static void assertOnlyChild(String o, Predicate<String> c, Predicate<String> p) {
@@ -103,18 +100,11 @@
     assertNotSame(n, n.copy(sb));
     assertEquals(sb, n.copy(sb).getChildren());
 
-    try {
-      n.copy(Collections.<Predicate>emptyList());
-      fail("Expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected exactly one child", e.getMessage());
-    }
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () -> n.copy(Collections.emptyList()));
+    assertEquals("Expected exactly one child", e.getMessage());
 
-    try {
-      n.copy(and(a, b).getChildren());
-      fail("Expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected exactly one child", e.getMessage());
-    }
+    e = assertThrows(IllegalArgumentException.class, () -> n.copy(and(a, b).getChildren()));
+    assertEquals("Expected exactly one child", e.getMessage());
   }
 }
diff --git a/javatests/com/google/gerrit/index/query/OrPredicateTest.java b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
index 255a3f8..1cbcb75 100644
--- a/javatests/com/google/gerrit/index/query/OrPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.collect.ImmutableList.of;
 import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.List;
 import org.junit.Test;
@@ -43,28 +43,13 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = or(a, b);
 
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
     assertChildren("clear", n, of(a, b));
 
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
     assertChildren("remove(0)", n, of(a, b));
 
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
     assertChildren("iterator().remove()", n, of(a, b));
   }
 
diff --git a/javatests/com/google/gerrit/index/query/PredicateTest.java b/javatests/com/google/gerrit/index/query/PredicateTest.java
index 6979d82..3ec7f13 100644
--- a/javatests/com/google/gerrit/index/query/PredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/PredicateTest.java
@@ -15,13 +15,9 @@
 package com.google.gerrit.index.query;
 
 import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.rules.ExpectedException;
 
 @Ignore
 public abstract class PredicateTest {
-  @Rule public ExpectedException exception = ExpectedException.none();
-
   protected static final class TestPredicate extends OperatorPredicate<String> {
     protected TestPredicate(String name, String value) {
       super(name, value);
diff --git a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
new file mode 100644
index 0000000..f653759
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.index.query;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.truth.ThrowableSubject;
+import java.util.Collection;
+import java.util.Objects;
+import org.junit.Test;
+
+public class QueryBuilderTest {
+  private static class TestPredicate extends Predicate<Object> {
+    private final String field;
+    private final String value;
+
+    TestPredicate(String field, String value) {
+      this.field = field;
+      this.value = value;
+    }
+
+    @Override
+    public Predicate<Object> copy(Collection<? extends Predicate<Object>> children) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(field, value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof TestPredicate)) {
+        return false;
+      }
+      TestPredicate p = (TestPredicate) o;
+      return Objects.equals(field, p.field) && Objects.equals(value, p.value);
+    }
+  }
+
+  private static class TestQueryBuilder extends QueryBuilder<Object, TestQueryBuilder> {
+    TestQueryBuilder() {
+      super(new QueryBuilder.Definition<>(TestQueryBuilder.class), null);
+    }
+
+    @Operator
+    public Predicate<Object> a(String value) {
+      return new TestPredicate("a", value);
+    }
+  }
+
+  @Test
+  public void fieldNameAndValue() throws Exception {
+    assertThat(parse("a:foo")).isEqualTo(new TestPredicate("a", "foo"));
+  }
+
+  @Test
+  public void fieldWithParenthesizedValues() throws Exception {
+    assertThatParseException("a:(foo bar)").hasMessageThat().contains("no viable alternative");
+  }
+
+  @Test
+  public void fieldNameAndValueThatLooksLikeFieldNameColonValue() throws Exception {
+    assertThat(parse("a:foo:bar")).isEqualTo(new TestPredicate("a", "foo:bar"));
+  }
+
+  @Test
+  public void fieldNameAndValueThatLooksLikeWordColonValue() throws Exception {
+    assertThat(parse("a:*:bar")).isEqualTo(new TestPredicate("a", "*:bar"));
+  }
+
+  @Test
+  public void fieldNameAndValueWithMultipleColons() throws Exception {
+    assertThat(parse("a:*:*:*")).isEqualTo(new TestPredicate("a", "*:*:*"));
+  }
+
+  @Test
+  public void exactPhraseWithQuotes() throws Exception {
+    assertThat(parse("a:\"foo bar\"")).isEqualTo(new TestPredicate("a", "foo bar"));
+  }
+
+  @Test
+  public void exactPhraseWithQuotesAndColon() throws Exception {
+    assertThat(parse("a:\"foo:bar\"")).isEqualTo(new TestPredicate("a", "foo:bar"));
+  }
+
+  @Test
+  public void exactPhraseWithBraces() throws Exception {
+    assertThat(parse("a:{foo bar}")).isEqualTo(new TestPredicate("a", "foo bar"));
+  }
+
+  @Test
+  public void exactPhraseWithBracesAndColon() throws Exception {
+    assertThat(parse("a:{foo:bar}")).isEqualTo(new TestPredicate("a", "foo:bar"));
+  }
+
+  private static Predicate<Object> parse(String query) throws Exception {
+    return new TestQueryBuilder().parse(query);
+  }
+
+  private static ThrowableSubject assertThatParseException(String query) {
+    try {
+      new TestQueryBuilder().parse(query);
+      throw new AssertionError("expected QueryParseException for " + query);
+    } catch (QueryParseException e) {
+      return assertThat(e);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index 448f292..776a2c4 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -14,34 +14,197 @@
 
 package com.google.gerrit.index.query;
 
-import static org.junit.Assert.assertEquals;
+import static com.google.gerrit.index.query.QueryParser.AND;
+import static com.google.gerrit.index.query.QueryParser.COLON;
+import static com.google.gerrit.index.query.QueryParser.DEFAULT_FIELD;
+import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
+import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
+import static com.google.gerrit.index.query.QueryParser.parse;
+import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import org.antlr.runtime.tree.Tree;
 import org.junit.Test;
 
 public class QueryParserTest {
   @Test
-  public void projectBare() throws QueryParseException {
-    Tree r;
-
-    r = parse("project:tools/gerrit");
-    assertSingleWord("project", "tools/gerrit", r);
-
-    r = parse("project:tools/*");
-    assertSingleWord("project", "tools/*", r);
+  public void fieldNameAndValue() throws Exception {
+    Tree r = parse("project:tools/gerrit");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("project");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("tools/gerrit");
+    assertThat(r).child(0).hasNoChildren();
   }
 
-  private static void assertSingleWord(String name, String value, Tree r) {
-    assertEquals(QueryParser.FIELD_NAME, r.getType());
-    assertEquals(name, r.getText());
-    assertEquals(1, r.getChildCount());
-    final Tree c = r.getChild(0);
-    assertEquals(QueryParser.SINGLE_WORD, c.getType());
-    assertEquals(value, c.getText());
-    assertEquals(0, c.getChildCount());
+  @Test
+  public void fieldNameAndValueThatLooksLikeFieldNameColon() throws Exception {
+    // This should work, but doesn't due to a known issue.
+    assertParseFails("project:foo:");
   }
 
-  private static Tree parse(String str) throws QueryParseException {
-    return QueryParser.parse(str);
+  @Test
+  public void fieldNameAndValueThatLooksLikeFieldNameColonValue() throws Exception {
+    Tree r = parse("project:foo:bar");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("project");
+    assertThat(r).hasChildCount(3);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("foo");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("bar");
+    assertThat(r).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameAndValueThatLooksLikeWordColonValue() throws Exception {
+    Tree r = parse("project:x*y:a*b");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("project");
+    assertThat(r).hasChildCount(3);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("x*y");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("a*b");
+    assertThat(r).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameAndValueThatLooksLikeWordColon() throws Exception {
+    // This should work, but doesn't due to a known issue.
+    assertParseFails("project:x*y:");
+  }
+
+  @Test
+  public void fieldNameAndValueWithMultipleColons() throws Exception {
+    Tree r = parse("project:*:*:*");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("project");
+    assertThat(r).hasChildCount(5);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("*");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("*");
+    assertThat(r).child(2).hasNoChildren();
+    assertThat(r).child(3).hasType(COLON);
+    assertThat(r).child(3).hasText(":");
+    assertThat(r).child(3).hasNoChildren();
+    assertThat(r).child(4).hasType(SINGLE_WORD);
+    assertThat(r).child(4).hasText("*");
+    assertThat(r).child(4).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameAndValueWithColonFollowedByAnotherField() throws Exception {
+    Tree r = parse("project:foo:bar file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("project");
+    assertThat(r).child(0).hasChildCount(3);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("foo");
+    assertThat(r).child(0).child(0).hasNoChildren();
+    assertThat(r).child(0).child(1).hasType(COLON);
+    assertThat(r).child(0).child(1).hasText(":");
+    assertThat(r).child(0).child(1).hasNoChildren();
+    assertThat(r).child(0).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(2).hasText("bar");
+    assertThat(r).child(0).child(2).hasNoChildren();
+
+    assertThat(r).child(1).hasType(FIELD_NAME);
+    assertThat(r).child(1).hasText("file");
+    assertThat(r).child(1).hasChildCount(1);
+    assertThat(r).child(1).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(1).child(0).hasText("baz");
+    assertThat(r).child(1).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameAndValueWithColonFollowedByOpenParen() throws Exception {
+    Tree r = parse("project:foo:bar (file:baz)");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("project");
+    assertThat(r).child(0).hasChildCount(3);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("foo");
+    assertThat(r).child(0).child(0).hasNoChildren();
+    assertThat(r).child(0).child(1).hasType(COLON);
+    assertThat(r).child(0).child(1).hasText(":");
+    assertThat(r).child(0).child(1).hasNoChildren();
+    assertThat(r).child(0).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(2).hasText("bar");
+    assertThat(r).child(0).child(2).hasNoChildren();
+
+    assertThat(r).child(1).hasType(FIELD_NAME);
+    assertThat(r).child(1).hasText("file");
+    assertThat(r).child(1).hasChildCount(1);
+    assertThat(r).child(1).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(1).child(0).hasText("baz");
+    assertThat(r).child(1).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameAndValueWithColonFollowedByCloseParen() throws Exception {
+    Tree r = parse("(project:foo:bar) file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("project");
+    assertThat(r).child(0).hasChildCount(3);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("foo");
+    assertThat(r).child(0).child(0).hasNoChildren();
+    assertThat(r).child(0).child(1).hasType(COLON);
+    assertThat(r).child(0).child(1).hasText(":");
+    assertThat(r).child(0).child(1).hasNoChildren();
+    assertThat(r).child(0).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(2).hasText("bar");
+    assertThat(r).child(0).child(2).hasNoChildren();
+
+    assertThat(r).child(1).hasType(FIELD_NAME);
+    assertThat(r).child(1).hasText("file");
+    assertThat(r).child(1).hasChildCount(1);
+    assertThat(r).child(1).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(1).child(0).hasText("baz");
+    assertThat(r).child(1).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void defaultFieldWithColon() throws Exception {
+    Tree r = parse("CodeReview:+2");
+    assertThat(r).hasType(DEFAULT_FIELD);
+    assertThat(r).hasChildCount(3);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("CodeReview");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("+2");
+    assertThat(r).child(2).hasNoChildren();
+  }
+
+  private static void assertParseFails(String query) {
+    assertThrows(QueryParseException.class, () -> parse(query));
   }
 }
diff --git a/javatests/com/google/gerrit/json/BUILD b/javatests/com/google/gerrit/json/BUILD
new file mode 100644
index 0000000..575f575
--- /dev/null
+++ b/javatests/com/google/gerrit/json/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "json_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/json",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/json/JavaSqlTimestampHelperTest.java b/javatests/com/google/gerrit/json/JavaSqlTimestampHelperTest.java
new file mode 100644
index 0000000..05a9cfb
--- /dev/null
+++ b/javatests/com/google/gerrit/json/JavaSqlTimestampHelperTest.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2014 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.json;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.json.JavaSqlTimestampHelper.parseTimestamp;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.text.SimpleDateFormat;
+import java.util.TimeZone;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class JavaSqlTimestampHelperTest {
+  private SimpleDateFormat format;
+  private TimeZone systemTimeZone;
+
+  @Before
+  public void setUp() throws Exception {
+    synchronized (TimeZone.class) {
+      systemTimeZone = TimeZone.getDefault();
+      TimeZone.setDefault(TimeZone.getTimeZone("GMT-5:00"));
+      format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS Z");
+    }
+  }
+
+  @After
+  public void resetTimeZone() {
+    TimeZone.setDefault(systemTimeZone);
+  }
+
+  @Test
+  public void parseFullTimestamp() {
+    assertThat(reformat("2006-01-02 20:04:05.789000000"))
+        .isEqualTo("2006-01-02 15:04:05.789 -0500");
+    assertThat(reformat("2006-01-02 20:04:05")).isEqualTo("2006-01-02 15:04:05.000 -0500");
+  }
+
+  @Test
+  public void parseDateOnly() {
+    assertThat(reformat("2006-01-02")).isEqualTo("2006-01-01 19:00:00.000 -0500");
+  }
+
+  @Test
+  public void parseTimeZone() {
+    assertThat(reformat("2006-01-02 15:04:05.789 -0100"))
+        .isEqualTo("2006-01-02 11:04:05.789 -0500");
+    assertThat(reformat("2006-01-02 15:04:05.789 -0000"))
+        .isEqualTo("2006-01-02 10:04:05.789 -0500");
+    assertThat(reformat("2006-01-02 15:04:05.789 +0100"))
+        .isEqualTo("2006-01-02 09:04:05.789 -0500");
+  }
+
+  @Test
+  public void parseInvalidTimestamps() {
+    assertInvalid("2006-01-02-15:04:05.789000000");
+    assertInvalid("2006-01-02T15:04:05.789000000");
+    assertInvalid("15:04:05");
+    assertInvalid("15:04:05.999000000");
+  }
+
+  private static void assertInvalid(String input) {
+    assertThrows(IllegalArgumentException.class, () -> parseTimestamp(input));
+  }
+
+  private String reformat(String input) {
+    return format.format(parseTimestamp(input));
+  }
+}
diff --git a/javatests/com/google/gerrit/json/JsonEnumMappingTest.java b/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
new file mode 100644
index 0000000..6e57b01
--- /dev/null
+++ b/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.json;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gson.Gson;
+import org.junit.Test;
+
+public class JsonEnumMappingTest {
+
+  // Use the regular, pre-configured Gson object we use throughout the Gerrit server to ensure that
+  // the EnumTypeAdapterFactory is properly set up.
+  private final Gson gson = OutputFormat.JSON.newGson();
+
+  @Test
+  public void nullCanBeWrittenAndParsedBack() {
+    String resultingJson = gson.toJson(null, TestEnum.class);
+    TestEnum value = gson.fromJson(resultingJson, TestEnum.class);
+    assertThat(value).isNull();
+  }
+
+  @Test
+  public void enumValueCanBeWrittenAndParsedBack() {
+    String resultingJson = gson.toJson(TestEnum.ONE, TestEnum.class);
+    TestEnum value = gson.fromJson(resultingJson, TestEnum.class);
+    assertThat(value).isEqualTo(TestEnum.ONE);
+  }
+
+  @Test
+  public void enumValueCanBeParsed() {
+    TestData data = gson.fromJson("{\"value\":\"ONE\"}", TestData.class);
+    assertThat(data.value).isEqualTo(TestEnum.ONE);
+  }
+
+  @Test
+  public void mixedCaseEnumValueIsTreatedAsUnset() {
+    TestData data = gson.fromJson("{\"value\":\"oNe\"}", TestData.class);
+    assertThat(data.value).isNull();
+  }
+
+  @Test
+  public void lowerCaseEnumValueIsTreatedAsUnset() {
+    TestData data = gson.fromJson("{\"value\":\"one\"}", TestData.class);
+    assertThat(data.value).isNull();
+  }
+
+  @Test
+  public void notExistingEnumValueIsTreatedAsUnset() {
+    TestData data = gson.fromJson("{\"value\":\"FOUR\"}", TestData.class);
+    assertThat(data.value).isNull();
+  }
+
+  @Test
+  public void emptyEnumValueIsTreatedAsUnset() {
+    TestData data = gson.fromJson("{\"value\":\"\"}", TestData.class);
+    assertThat(data.value).isNull();
+  }
+
+  private static class TestData {
+    TestEnum value;
+
+    public TestData(TestEnum value) {
+      this.value = value;
+    }
+  }
+
+  private enum TestEnum {
+    ONE,
+    TWO
+  }
+}
diff --git a/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
new file mode 100644
index 0000000..2699c3b
--- /dev/null
+++ b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.json;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gson.JsonPrimitive;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class SqlTimestampDeserializerTest {
+
+  private final SqlTimestampDeserializer deserializer = new SqlTimestampDeserializer();
+
+  @Test
+  public void emptyStringIsDeserializedToMagicTimestamp() {
+    Timestamp timestamp = deserializer.deserialize(new JsonPrimitive(""), Timestamp.class, null);
+    assertThat(timestamp).isEqualTo(TimeUtil.never());
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
new file mode 100644
index 0000000..c7359f3
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Comment;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Ignore;
+
+@Ignore
+public class AbstractParserTest {
+  protected static final String CHANGE_URL =
+      "https://gerrit-review.googlesource.com/c/project/+/123";
+
+  protected static void assertChangeMessage(String message, MailComment comment) {
+    assertThat(comment.fileName).isNull();
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isNull();
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.CHANGE_MESSAGE);
+  }
+
+  protected static void assertInlineComment(
+      String message, MailComment comment, Comment inReplyTo) {
+    assertThat(comment.fileName).isNull();
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo.key).isEqualTo(inReplyTo.key);
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.INLINE_COMMENT);
+  }
+
+  protected static void assertFileComment(String message, MailComment comment, String file) {
+    assertThat(comment.fileName).isEqualTo(file);
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isNull();
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.FILE_COMMENT);
+  }
+
+  protected static Comment newComment(String uuid, String file, String message, int line) {
+    Comment c =
+        new Comment(
+            new Comment.Key(uuid, file, 1),
+            Account.id(0),
+            new Timestamp(0L),
+            (short) 0,
+            message,
+            "",
+            false);
+    c.lineNbr = line;
+    return c;
+  }
+
+  protected static Comment newRangeComment(String uuid, String file, String message, int line) {
+    Comment c =
+        new Comment(
+            new Comment.Key(uuid, file, 1),
+            Account.id(0),
+            new Timestamp(0L),
+            (short) 0,
+            message,
+            "",
+            false);
+    c.range = new Comment.Range(line, 1, line + 1, 1);
+    c.lineNbr = line + 1;
+    return c;
+  }
+
+  /** Returns a MailMessage.Builder with all required fields populated. */
+  protected static MailMessage.Builder newMailMessageBuilder() {
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("id");
+    b.from(new Address("Foo Bar", "foo@bar.com"));
+    b.dateReceived(Instant.now());
+    b.subject("");
+    return b;
+  }
+
+  /** Returns a List of default comments for testing. */
+  protected static List<Comment> defaultComments() {
+    List<Comment> comments = new ArrayList<>();
+    comments.add(newComment("c1", "gerrit-server/test.txt", "comment", 0));
+    comments.add(newComment("c2", "gerrit-server/test.txt", "comment", 2));
+    comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 3));
+    comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 115));
+    comments.add(newRangeComment("c5", "gerrit-server/readme.txt", "comment", 3));
+    return comments;
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/AddressTest.java b/javatests/com/google/gerrit/mail/AddressTest.java
new file mode 100644
index 0000000..da26123
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/AddressTest.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+
+public class AddressTest {
+  @Test
+  public void parse_NameEmail1() {
+    final Address a = Address.parse("A U Thor <author@example.com>");
+    assertThat(a.getName()).isEqualTo("A U Thor");
+    assertThat(a.getEmail()).isEqualTo("author@example.com");
+  }
+
+  @Test
+  public void parse_NameEmail2() {
+    final Address a = Address.parse("A <a@b>");
+    assertThat(a.getName()).isEqualTo("A");
+    assertThat(a.getEmail()).isEqualTo("a@b");
+  }
+
+  @Test
+  public void parse_NameEmail3() {
+    final Address a = Address.parse("<a@b>");
+    assertThat(a.getName()).isNull();
+    assertThat(a.getEmail()).isEqualTo("a@b");
+  }
+
+  @Test
+  public void parse_NameEmail4() {
+    final Address a = Address.parse("A U Thor<author@example.com>");
+    assertThat(a.getName()).isEqualTo("A U Thor");
+    assertThat(a.getEmail()).isEqualTo("author@example.com");
+  }
+
+  @Test
+  public void parse_NameEmail5() {
+    final Address a = Address.parse("A U Thor  <author@example.com>");
+    assertThat(a.getName()).isEqualTo("A U Thor");
+    assertThat(a.getEmail()).isEqualTo("author@example.com");
+  }
+
+  @Test
+  public void parse_Email1() {
+    final Address a = Address.parse("author@example.com");
+    assertThat(a.getName()).isNull();
+    assertThat(a.getEmail()).isEqualTo("author@example.com");
+  }
+
+  @Test
+  public void parse_Email2() {
+    final Address a = Address.parse("a@b");
+    assertThat(a.getName()).isNull();
+    assertThat(a.getEmail()).isEqualTo("a@b");
+  }
+
+  @Test
+  public void parse_NewTLD() {
+    Address a = Address.parse("A U Thor <author@example.systems>");
+    assertThat(a.getName()).isEqualTo("A U Thor");
+    assertThat(a.getEmail()).isEqualTo("author@example.systems");
+  }
+
+  @Test
+  public void parseInvalid() {
+    assertInvalid("");
+    assertInvalid("a");
+    assertInvalid("a<");
+    assertInvalid("<a");
+    assertInvalid("<a>");
+    assertInvalid("a<a>");
+    assertInvalid("a <a>");
+
+    assertInvalid("a");
+    assertInvalid("a<@");
+    assertInvalid("<a@");
+    assertInvalid("<a@>");
+    assertInvalid("a<a@>");
+    assertInvalid("a <a@>");
+    assertInvalid("a <@a>");
+  }
+
+  private void assertInvalid(String in) {
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> Address.parse(in));
+    assertThat(thrown).hasMessageThat().isEqualTo("Invalid email address: " + in);
+  }
+
+  @Test
+  public void toHeaderString_NameEmail1() {
+    assertThat(format("A", "a@a")).isEqualTo("A <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail2() {
+    assertThat(format("A B", "a@a")).isEqualTo("A B <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail3() {
+    assertThat(format("A B. C", "a@a")).isEqualTo("\"A B. C\" <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail4() {
+    assertThat(format("A B, C", "a@a")).isEqualTo("\"A B, C\" <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail5() {
+    assertThat(format("A \" C", "a@a")).isEqualTo("\"A \\\" C\" <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail6() {
+    assertThat(format("A \u20ac B", "a@a")).isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B?= <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail7() {
+    assertThat(format("A \u20ac B (Code Review)", "a@a"))
+        .isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B_=28Code_Review=29?= <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_Email1() {
+    assertThat(format(null, "a@a")).isEqualTo("a@a");
+  }
+
+  @Test
+  public void toHeaderString_Email2() {
+    assertThat(format(null, "a,b@a")).isEqualTo("<a,b@a>");
+  }
+
+  private static String format(String name, String email) {
+    return new Address(name, email).toHeaderString();
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/BUILD b/javatests/com/google/gerrit/mail/BUILD
new file mode 100644
index 0000000..54671dd
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/BUILD
@@ -0,0 +1,33 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "maillib_tests",
+    size = "small",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/mail",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gson",
+        "//lib:guava-retrying",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+    ],
+)
diff --git a/javatests/com/google/gerrit/mail/GenericHtmlParserTest.java b/javatests/com/google/gerrit/mail/GenericHtmlParserTest.java
new file mode 100644
index 0000000..4718bad
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/GenericHtmlParserTest.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+/** Test parser for a generic Html email client response */
+public class GenericHtmlParserTest extends HtmlParserTest {
+  @Override
+  protected String newHtmlBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
+    String email =
+        ""
+            + "<div dir=\"ltr\">"
+            + (changeMessage != null ? changeMessage : "")
+            + "<div class=\"extra\"><br><div class=\"quote\">"
+            + "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com"
+            + "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" "
+            + "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>"
+            + "<blockquote class=\"quote\" "
+            + "<p>foobar <strong>posted comments</strong> on this change.</p>"
+            + "<p><a href=\""
+            + CHANGE_URL
+            + "/1\" "
+            + "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n"
+            + "\n"
+            + "(3 comments)</div><ul><li>"
+            + "<p>"
+            + // File #1: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "File gerrit-server/<wbr>test.txt:</a></p>"
+            + commentBlock(f1)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "Patch Set #2:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some comment on file 1</p>"
+            + "</li>"
+            + commentBlock(fc1)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@2\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c1)
+            + ""
+            + // Inline comment #2
+            "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@3\">"
+            + "Patch Set #2, Line 47:</a> </p>"
+            + "<blockquote><pre>Some comment posted on Gerrit</pre>"
+            + "</blockquote><p>Some more comments from Gerrit</p>"
+            + "</li>"
+            + commentBlock(c2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@115\">"
+            + "Patch Set #2, Line 115:</a> <code>some code</code></p>"
+            + "<p>some comment</p></li></ul></li>"
+            + ""
+            + "<li><p>"
+            + // File #2: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt\">"
+            + "File gerrit-server/<wbr>readme.txt:</a></p>"
+            + commentBlock(f2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt@3\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c3)
+            + ""
+            + // Inline comment #2
+            "</ul></li></ul>"
+            + ""
+            + // Footer
+            "<p>To view, visit <a href=\""
+            + CHANGE_URL
+            + "/1\">this change</a>. "
+            + "To unsubscribe, visit <a href=\"https://someurl\">settings</a>."
+            + "</p><p>Gerrit-MessageType: comment<br>"
+            + "Footer omitted</p>"
+            + "<div><div></div></div>"
+            + "<p>Gerrit-HasComments: Yes</p></blockquote></div><br></div></div>";
+    return email;
+  }
+
+  private static String commentBlock(String comment) {
+    if (comment == null) {
+      return "";
+    }
+    return "</ul></li></ul></blockquote><div>"
+        + comment
+        + "</div><blockquote class=\"quote\"><ul><li><ul>";
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/GmailHtmlParserTest.java b/javatests/com/google/gerrit/mail/GmailHtmlParserTest.java
new file mode 100644
index 0000000..f597dee
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/GmailHtmlParserTest.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+public class GmailHtmlParserTest extends HtmlParserTest {
+  @Override
+  protected String newHtmlBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
+    String email =
+        ""
+            + "<div class=\"gmail_default\" dir=\"ltr\">"
+            + (changeMessage != null ? changeMessage : "")
+            + "<div class=\"gmail_extra\"><br><div class=\"gmail_quote\">"
+            + "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com"
+            + "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" "
+            + "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>"
+            + "</div></div><blockquote class=\"gmail_quote\" "
+            + "<p>foobar <strong>posted comments</strong> on this change.</p>"
+            + "<p><a href=\""
+            + CHANGE_URL
+            + "/1\" "
+            + "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n"
+            + "\n"
+            + "(3 comments)</div><ul><li>"
+            + "<p>"
+            + // File #1: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "File gerrit-server/<wbr>test.txt:</a></p>"
+            + commentBlock(f1)
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "Patch Set #2:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some comment on file 1</p>"
+            + "</li>"
+            + commentBlock(fc1)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@2\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c1)
+            + ""
+            + // Inline comment #2
+            "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@3\">"
+            + "Patch Set #2, Line 47:</a> </p>"
+            + "<blockquote><pre>Some comment posted on Gerrit</pre>"
+            + "</blockquote><p>Some more comments from Gerrit</p>"
+            + "</li>"
+            + commentBlock(c2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@115\">"
+            + "Patch Set #2, Line 115:</a> <code>some code</code></p>"
+            + "<p>some comment</p></li></ul></li>"
+            + ""
+            + "<li><p>"
+            + // File #2: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt\">"
+            + "File gerrit-server/<wbr>readme.txt:</a></p>"
+            + commentBlock(f2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt@3\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c3)
+            + ""
+            + // Inline comment #2
+            "</ul></li></ul>"
+            + ""
+            + // Footer
+            "<p>To view, visit <a href=\""
+            + CHANGE_URL
+            + "/1\">this change</a>. "
+            + "To unsubscribe, visit <a href=\"https://someurl\">settings</a>."
+            + "</p><p>Gerrit-MessageType: comment<br>"
+            + "Footer omitted</p>"
+            + "<div><div></div></div>"
+            + "<p>Gerrit-HasComments: Yes</p></blockquote></div>";
+    return email;
+  }
+
+  private static String commentBlock(String comment) {
+    if (comment == null) {
+      return "";
+    }
+    return "</ul></li></ul></blockquote><div>"
+        + comment
+        + "</div><blockquote class=\"gmail_quote\"><ul><li><ul>";
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/HtmlParserTest.java b/javatests/com/google/gerrit/mail/HtmlParserTest.java
new file mode 100644
index 0000000..d630bd6
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/HtmlParserTest.java
@@ -0,0 +1,190 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
+import org.junit.Ignore;
+import org.junit.Test;
+
+/**
+ * Abstract parser test for HTML messages. Payload will be added through concrete implementations.
+ */
+@Ignore
+public abstract class HtmlParserTest extends AbstractParserTest {
+  @Test
+  public void simpleChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody("Looks good to me", null, null, null, null, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+  }
+
+  @Test
+  public void changeMessageWithLink() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Did you consider this: "
+                + "<a href=\"http://gerritcodereview.com\">http://gerritcodereview.com</a>",
+            null,
+            null,
+            null,
+            null,
+            null,
+            null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage(
+        "Did you consider this: http://gerritcodereview.com", parsedComments.get(0));
+  }
+
+  @Test
+  public void simpleInlineComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Looks good to me",
+            "I have a comment on this.&nbsp;",
+            null,
+            "Also have a comment here.",
+            null,
+            null,
+            null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
+  }
+
+  @Test
+  public void simpleInlineCommentsWithLink() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Looks good to me",
+            "How about [1]? This would help IMHO.</div><div>[1] "
+                + "<a href=\"http://gerritcodereview.com\">http://gerritcodereview.com</a>",
+            null,
+            "Also have a comment here.",
+            null,
+            null,
+            null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment(
+        "How about [1]? This would help IMHO.\n\n[1] http://gerritcodereview.com",
+        parsedComments.get(1),
+        comments.get(1));
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
+  }
+
+  @Test
+  public void simpleFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Looks good to me",
+            null,
+            null,
+            "Also have a comment here.",
+            "This is a nice file",
+            null,
+            null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
+  }
+
+  @Test
+  public void noComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody(null, null, null, null, null, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).isEmpty();
+  }
+
+  @Test
+  public void noChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            null, null, null, "Also have a comment here.", "This is a nice file", null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertFileComment("This is a nice file", parsedComments.get(0), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(1), comments.get(4));
+  }
+
+  @Test
+  public void commentsSpanningMultipleBlocks() {
+    String htmlMessage =
+        "This is a very long test comment. <div><br></div><div>Now this is a new paragraph yay.</div>";
+    String txtMessage = "This is a very long test comment.\n\nNow this is a new paragraph yay.";
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody(htmlMessage, null, null, htmlMessage, htmlMessage, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage(txtMessage, parsedComments.get(0));
+    assertFileComment(txtMessage, parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment(txtMessage, parsedComments.get(2), comments.get(4));
+  }
+
+  /**
+   * Create an html message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first comment.
+   * @param c2 Comment in reply to second comment.
+   * @param c3 Comment in reply to third comment.
+   * @param f1 Comment on file one.
+   * @param f2 Comment on file two.
+   * @param fc1 Comment in reply to a comment on file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  protected abstract String newHtmlBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1);
+}
diff --git a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
new file mode 100644
index 0000000..2d2c2ea
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Test;
+
+public class MailHeaderParserTest {
+  @Test
+  public void parseMetadataFromHeader() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // email headers of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(Instant.now());
+    b.subject("");
+
+    b.addAdditionalHeader(MailHeader.CHANGE_NUMBER.fieldWithDelimiter() + "123");
+    b.addAdditionalHeader(MailHeader.PATCH_SET.fieldWithDelimiter() + "1");
+    b.addAdditionalHeader(MailHeader.MESSAGE_TYPE.fieldWithDelimiter() + "comment");
+    b.addAdditionalHeader(
+        MailHeader.COMMENT_DATE.fieldWithDelimiter() + "Tue, 25 Oct 2016 02:11:35 -0700");
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MailHeaderParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeNumber).isEqualTo(123);
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+  }
+
+  @Test
+  public void parseMetadataFromText() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // the text body of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(Instant.now());
+    b.subject("");
+
+    StringBuilder stringBuilder = new StringBuilder();
+    stringBuilder.append(MailHeader.CHANGE_NUMBER.withDelimiter()).append("123\r\n");
+    stringBuilder.append("> ").append(MailHeader.PATCH_SET.withDelimiter()).append("1\n");
+    stringBuilder.append(MailHeader.MESSAGE_TYPE.withDelimiter()).append("comment\n");
+    stringBuilder
+        .append(MailHeader.COMMENT_DATE.withDelimiter())
+        .append("Tue, 25 Oct 2016 02:11:35 -0700\r\n");
+    b.textContent(stringBuilder.toString());
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MailHeaderParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeNumber).isEqualTo(123);
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+  }
+
+  @Test
+  public void parseMetadataFromHTML() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // the HTML body of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(Instant.now());
+    b.subject("");
+
+    StringBuilder stringBuilder = new StringBuilder();
+    stringBuilder
+        .append("<div id\"someid\">")
+        .append(MailHeader.CHANGE_NUMBER.withDelimiter())
+        .append("123</div>");
+    stringBuilder.append("<div>").append(MailHeader.PATCH_SET.withDelimiter()).append("1</div>");
+    stringBuilder
+        .append("<div>")
+        .append(MailHeader.MESSAGE_TYPE.withDelimiter())
+        .append("comment</div>");
+    stringBuilder
+        .append("<div>")
+        .append(MailHeader.COMMENT_DATE.withDelimiter())
+        .append("Tue, 25 Oct 2016 02:11:35 -0700")
+        .append("</div>");
+    b.htmlContent(stringBuilder.toString());
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MailHeaderParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeNumber).isEqualTo(123);
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/ParserUtilTest.java b/javatests/com/google/gerrit/mail/ParserUtilTest.java
new file mode 100644
index 0000000..47a5367
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/ParserUtilTest.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class ParserUtilTest {
+  @Test
+  public void trimQuotationLineOnMessageWithoutQuoatationLine() throws Exception {
+    assertThat(ParserUtil.trimQuotation("One line")).isEqualTo("One line");
+    assertThat(ParserUtil.trimQuotation("Two\nlines")).isEqualTo("Two\nlines");
+    assertThat(ParserUtil.trimQuotation("Thr\nee\nlines")).isEqualTo("Thr\nee\nlines");
+  }
+
+  @Test
+  public void trimQuotationLineOnMixedMessages() throws Exception {
+    assertThat(
+            ParserUtil.trimQuotation(
+                "One line\n"
+                    + "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit)\n"
+                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
+        .isEqualTo("One line");
+    assertThat(
+            ParserUtil.trimQuotation(
+                "One line\n"
+                    + "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit) "
+                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
+        .isEqualTo("One line");
+  }
+
+  @Test
+  public void trimQuotationLineOnMessagesContainingQuoationLine() throws Exception {
+    assertThat(
+            ParserUtil.trimQuotation(
+                "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit)\n"
+                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
+        .isEqualTo("");
+    assertThat(
+            ParserUtil.trimQuotation(
+                "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit) "
+                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
+        .isEqualTo("");
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/RawMailParserTest.java b/javatests/com/google/gerrit/mail/RawMailParserTest.java
new file mode 100644
index 0000000..0ab2811
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/RawMailParserTest.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.mail.data.AttachmentMessage;
+import com.google.gerrit.mail.data.Base64HeaderMessage;
+import com.google.gerrit.mail.data.HtmlMimeMessage;
+import com.google.gerrit.mail.data.NonUTF8Message;
+import com.google.gerrit.mail.data.QuotedPrintableHeaderMessage;
+import com.google.gerrit.mail.data.RawMailMessage;
+import com.google.gerrit.mail.data.SimpleTextMessage;
+import org.junit.Test;
+
+public class RawMailParserTest {
+  @Test
+  public void parseEmail() throws Exception {
+    RawMailMessage[] messages =
+        new RawMailMessage[] {
+          new SimpleTextMessage(),
+          new Base64HeaderMessage(),
+          new QuotedPrintableHeaderMessage(),
+          new HtmlMimeMessage(),
+          new AttachmentMessage(),
+          new NonUTF8Message(),
+        };
+    for (RawMailMessage rawMailMessage : messages) {
+      if (rawMailMessage.rawChars() != null) {
+        // Assert Character to Mail Parser
+        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.rawChars());
+        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
+      }
+      if (rawMailMessage.raw() != null) {
+        // Assert String to Mail Parser
+        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.raw());
+        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
+      }
+    }
+  }
+
+  /**
+   * This method makes it easier to debug failing tests by checking each property individual instead
+   * of calling equals as it will immediately reveal the property that diverges between the two
+   * objects.
+   *
+   * @param have MailMessage retrieved from the parser
+   * @param want MailMessage that would be expected
+   */
+  private void assertMail(MailMessage have, MailMessage want) {
+    assertThat(have.id()).isEqualTo(want.id());
+    assertThat(have.to()).isEqualTo(want.to());
+    assertThat(have.from()).isEqualTo(want.from());
+    assertThat(have.cc()).isEqualTo(want.cc());
+    assertThat(have.dateReceived()).isEqualTo(want.dateReceived());
+    assertThat(have.additionalHeaders()).isEqualTo(want.additionalHeaders());
+    assertThat(have.subject()).isEqualTo(want.subject());
+    assertThat(have.textContent()).isEqualTo(want.textContent());
+    assertThat(have.htmlContent()).isEqualTo(want.htmlContent());
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/TextParserTest.java b/javatests/com/google/gerrit/mail/TextParserTest.java
new file mode 100644
index 0000000..a5c2152
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/TextParserTest.java
@@ -0,0 +1,261 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
+import org.junit.Test;
+
+public class TextParserTest extends AbstractParserTest {
+  private static final String quotedFooter =
+      ""
+          + "> To view, visit https://gerrit-review.googlesource.com/c/project/+/123\n"
+          + "> To unsubscribe, visit https://gerrit-review.googlesource.com\n"
+          + "> \n"
+          + "> Gerrit-MessageType: comment\n"
+          + "> Gerrit-Change-Id: Ie1234021bf1e8d1425641af58fd648fc011db153\n"
+          + "> Gerrit-PatchSet: 1\n"
+          + "> Gerrit-Project: gerrit\n"
+          + "> Gerrit-Branch: master\n"
+          + "> Gerrit-Owner: Foo Bar <foo@bar.com>\n"
+          + "> Gerrit-HasComments: Yes";
+
+  @Test
+  public void simpleChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent("Looks good to me\n" + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+  }
+
+  @Test
+  public void simpleInlineComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        newPlaintextBody(
+                "Looks good to me",
+                "I have a comment on this.",
+                null,
+                "Also have a comment here.",
+                null,
+                null,
+                null)
+            + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
+  }
+
+  @Test
+  public void simpleFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        newPlaintextBody(
+                "Looks good to me",
+                null,
+                null,
+                "Also have a comment here.",
+                "This is a nice file",
+                null,
+                null)
+            + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
+  }
+
+  @Test
+  public void noComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(newPlaintextBody(null, null, null, null, null, null, null) + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).isEmpty();
+  }
+
+  @Test
+  public void noChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        newPlaintextBody(
+                null, null, null, "Also have a comment here.", "This is a nice file", null, null)
+            + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertFileComment("This is a nice file", parsedComments.get(0), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(1), comments.get(3));
+  }
+
+  @Test
+  public void allCommentsGmail() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        (newPlaintextBody(
+                    "Looks good to me",
+                    null,
+                    null,
+                    "Also have a comment here.",
+                    "This is a nice file",
+                    null,
+                    null)
+                + quotedFooter)
+            .replace("> ", ">> "));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
+  }
+
+  @Test
+  public void replyToFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        newPlaintextBody(
+                "Looks good to me",
+                null,
+                null,
+                null,
+                null,
+                null,
+                "Comment in reply to file comment")
+            + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("Comment in reply to file comment", parsedComments.get(1), comments.get(0));
+  }
+
+  @Test
+  public void squashComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        "Nice change\n> Some quoted content\nMy other comment on the same entity\n" + quotedFooter);
+
+    List<MailComment> parsedComments = TextParser.parse(b.build(), defaultComments(), CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage(
+        "Nice change\n\nMy other comment on the same entity", parsedComments.get(0));
+  }
+
+  /**
+   * Create a plaintext message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first inline comment.
+   * @param c2 Comment in reply to second inline comment.
+   * @param c3 Comment in reply to third inline comment.
+   * @param f1 Comment on file one.
+   * @param f2 Comment on file two.
+   * @param fc1 Comment in reply to a comment of file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  private static String newPlaintextBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
+    return (changeMessage == null ? "" : changeMessage + "\n")
+        + "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit)\n"
+        + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote: \n"
+        + "> Foo Bar has posted comments on this change. (  \n"
+        + "> "
+        + CHANGE_URL
+        + "/1 )\n"
+        + "> \n"
+        + "> Change subject: Test change\n"
+        + "> ...............................................................\n"
+        + "> \n"
+        + "> \n"
+        + "> Patch Set 1: Code-Review+1\n"
+        + "> \n"
+        + "> (3 comments)\n"
+        + "> \n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/test.txt\n"
+        + "> File  \n"
+        + "> gerrit-server/test.txt:\n"
+        + (f1 == null ? "" : f1 + "\n")
+        + "> \n"
+        + "> Patch Set #4:\n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/test.txt\n"
+        + "> \n"
+        + "> Some comment"
+        + "> \n"
+        + (fc1 == null ? "" : fc1 + "\n")
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/test.txt@2\n"
+        + "> PS1, Line 2: throw new Exception(\"Object has unsupported: \" +\n"
+        + ">               :             entry.getValue() +\n"
+        + ">               :             \" must be java.util.Date\");\n"
+        + "> Should entry.getKey() be included in this message?\n"
+        + "> \n"
+        + (c1 == null ? "" : c1 + "\n")
+        + ">\n"
+        + "> \n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/test.txt@3\n"
+        + "> PS1, Line 3: throw new Exception(\"Object has: \" +\n"
+        + ">               :             entry.getValue().getClass() +\n"
+        + ">              :             \" must be java.util.Date\");\n"
+        + "> same here\n"
+        + "> \n"
+        + (c2 == null ? "" : c2 + "\n")
+        + "> \n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/readme.txt\n"
+        + "> File  \n"
+        + "> gerrit-server/readme.txt:\n"
+        + (f2 == null ? "" : f2 + "\n")
+        + "> \n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/readme.txt@3\n"
+        + "> PS1, Line 3: E\n"
+        + "> Should this be EEE like in other places?\n"
+        + (c3 == null ? "" : c3 + "\n");
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/data/AttachmentMessage.java b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
new file mode 100644
index 0000000..1d94d68
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail.data;
+
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/**
+ * Provides a raw message payload and a parsed {@code MailMessage} to check that mime parts that are
+ * neither text/plain, nor * text/html are dropped.
+ */
+@Ignore
+public class AttachmentMessage extends RawMailMessage {
+  private static String raw =
+      "MIME-Version: 1.0\n"
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w"
+          + "@mail.gmail.com>\n"
+          + "Subject: Test Subject\n"
+          + "From: Patrick Hiesel <hiesel@google.com>\n"
+          + "To: Patrick Hiesel <hiesel@google.com>\n"
+          + "Content-Type: multipart/mixed; boundary=001a114e019a56962d054062708f\n"
+          + "\n"
+          + "--001a114e019a56962d054062708f\n"
+          + "Content-Type: multipart/alternative; boundary=001a114e019a5696250540"
+          + "62708d\n"
+          + "\n"
+          + "--001a114e019a569625054062708d\n"
+          + "Content-Type: text/plain; charset=UTF-8\n"
+          + "\n"
+          + "Contains unwanted attachment"
+          + "\n"
+          + "--001a114e019a569625054062708d\n"
+          + "Content-Type: text/html; charset=UTF-8\n"
+          + "\n"
+          + "<div dir=\"ltr\">Contains unwanted attachment</div>"
+          + "\n"
+          + "--001a114e019a569625054062708d--\n"
+          + "--001a114e019a56962d054062708f\n"
+          + "Content-Type: text/plain; charset=US-ASCII; name=\"test.txt\"\n"
+          + "Content-Disposition: attachment; filename=\"test.txt\"\n"
+          + "Content-Transfer-Encoding: base64\n"
+          + "X-Attachment-Id: f_iv264bt50\n"
+          + "\n"
+          + "VEVTVAo=\n"
+          + "--001a114e019a56962d054062708f--";
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    System.out.println("\uD83D\uDE1B test");
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w@mail.gmail.com>")
+        .from(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .textContent("Contains unwanted attachment")
+        .htmlContent("<div dir=\"ltr\">Contains unwanted attachment</div>")
+        .subject("Test Subject")
+        .addAdditionalHeader("MIME-Version: 1.0")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
new file mode 100644
index 0000000..aa19537
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail.data;
+
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/** Tests parsing a Base64 encoded subject. */
+@Ignore
+public class Base64HeaderMessage extends RawMailMessage {
+  private static String textContent = "Some Text";
+  private static String raw =
+      ""
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
+          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .textContent(textContent)
+        .subject("\uD83D\uDE1B test")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
new file mode 100644
index 0000000..1d68cc8
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail.data;
+
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/** Tests a message containing mime/alternative (text + html) content. */
+@Ignore
+public class HtmlMimeMessage extends RawMailMessage {
+  private static String textContent = "Simple test";
+
+  // htmlContent is encoded in quoted-printable
+  private static String htmlContent =
+      "<div dir=3D\"ltr\">Test <span style"
+          + "=3D\"background-color:rgb(255,255,0)\">Messa=\n"
+          + "ge</span> in <u>HTML=C2=A0</u><a href=3D\"https://en.wikipedia.org/"
+          + "wiki/%C3%=\n9Cmlaut_(band)\" class=3D\"gmail-mw-redirect\" title=3D\""
+          + "=C3=9Cmlaut (band)\" st=\nyle=3D\"text-decoration:none;color:rgb(11,"
+          + "0,128);background-image:none;backg=\nround-position:initial;background"
+          + "-size:initial;background-repeat:initial;ba=\nckground-origin:initial;"
+          + "background-clip:initial;font-family:sans-serif;font=\n"
+          + "-size:14px\">=C3=9C</a></div>";
+
+  private static String unencodedHtmlContent =
+      ""
+          + "<div dir=\"ltr\">Test <span style=\"background-color:rgb(255,255,0)\">"
+          + "Message</span> in <u>HTML </u><a href=\"https://en.wikipedia.org/wiki/"
+          + "%C3%9Cmlaut_(band)\" class=\"gmail-mw-redirect\" title=\"Ümlaut "
+          + "(band)\" style=\"text-decoration:none;color:rgb(11,0,128);"
+          + "background-image:none;background-position:initial;background-size:"
+          + "initial;background-repeat:initial;background-origin:initial;background"
+          + "-clip:initial;font-family:sans-serif;font-size:14px\">Ü</a></div>";
+
+  private static String raw =
+      ""
+          + "MIME-Version: 1.0\n"
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114cd8be55b4ab053face5cd@google.com>\n"
+          + "Subject: Change in gerrit[master]: Implement receiver class structure "
+          + "and bindings\n"
+          + "From: \"ekempin (Gerrit)\" <noreply-gerritcodereview-qUgXfQecoDLHwp0Ml"
+          + "dAzig@google.com>\n"
+          + "To: Patrick Hiesel <hiesel@google.com>\n"
+          + "Cc: ekempin <ekempin@google.com>\n"
+          + "Content-Type: multipart/alternative; boundary=001a114cd8b"
+          + "e55b486053face5ca\n"
+          + "\n"
+          + "--001a114cd8be55b486053face5ca\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent
+          + "\n"
+          + "--001a114cd8be55b486053face5ca\n"
+          + "Content-Type: text/html; charset=UTF-8\n"
+          + "Content-Transfer-Encoding: quoted-printable\n"
+          + "\n"
+          + htmlContent
+          + "\n"
+          + "--001a114cd8be55b486053face5ca--";
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114cd8be55b4ab053face5cd@google.com>")
+        .from(
+            new Address(
+                "ekempin (Gerrit)", "noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com"))
+        .addCc(new Address("ekempin", "ekempin@google.com"))
+        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .textContent(textContent)
+        .htmlContent(unencodedHtmlContent)
+        .subject("Change in gerrit[master]: Implement receiver class structure and bindings")
+        .addAdditionalHeader("MIME-Version: 1.0")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
new file mode 100644
index 0000000..32915e7
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.mail.data;
+
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/** Tests that non-UTF8 encodings are handled correctly. */
+@Ignore
+public class NonUTF8Message extends RawMailMessage {
+  private static String textContent = "Some Text";
+  private static String raw =
+      ""
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
+          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return null;
+  }
+
+  @Override
+  public int[] rawChars() {
+    int[] arr = new int[raw.length()];
+    int i = 0;
+    for (char c : raw.toCharArray()) {
+      arr[i++] = c;
+    }
+    return arr;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .textContent(textContent)
+        .subject("\uD83D\uDE1B test")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
new file mode 100644
index 0000000..47e813a
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail.data;
+
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/** Tests parsing a quoted printable encoded subject */
+@Ignore
+public class QuotedPrintableHeaderMessage extends RawMailMessage {
+  private static String textContent = "Some Text";
+  private static String raw =
+      ""
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: =?UTF-8?Q?=C3=A2me vulgaire?=\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
+          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    System.out.println("\uD83D\uDE1B test");
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .textContent(textContent)
+        .subject("âme vulgaire")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/data/RawMailMessage.java b/javatests/com/google/gerrit/mail/data/RawMailMessage.java
new file mode 100644
index 0000000..53c782c
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/data/RawMailMessage.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail.data;
+
+import com.google.gerrit.mail.MailMessage;
+import org.junit.Ignore;
+
+/** Base class for all email parsing tests. */
+@Ignore
+public abstract class RawMailMessage {
+  // Raw content to feed the parser
+  public abstract String raw();
+
+  public abstract int[] rawChars();
+  // Parsed representation for asserting the expected parser output
+  public abstract MailMessage expectedMailMessage();
+}
diff --git a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
new file mode 100644
index 0000000..c4737e6
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.mail.data;
+
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/** Tests parsing a simple text message with different headers. */
+@Ignore
+public class SimpleTextMessage extends RawMailMessage {
+  private static String textContent =
+      ""
+          + "Jonathan Nieder has posted comments on this change. (  \n"
+          + "https://gerrit-review.googlesource.com/90018 )\n"
+          + "\n"
+          + "Change subject: (Re)enable voting buttons for merged changes\n"
+          + "...........................................................\n"
+          + "\n"
+          + "\n"
+          + "Patch Set 2:\n"
+          + "\n"
+          + "This is producing NPEs server-side and 500s for the client.   \n"
+          + "when I try to load this change:\n"
+          + "\n"
+          + "  Error in GET /changes/90018/detail?O=10004\n"
+          + "  com.google.gerrit.exceptions.StorageException: java.lang.NullPointerException\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:303)\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:285)\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:263)\n"
+          + "\tat com.google.gerrit.change.GetChange.apply(GetChange.java:50)\n"
+          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:51)\n"
+          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:26)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.RestApiServlet.service(RestApiServlet.java:367)\n"
+          + "\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:717)\n"
+          + "[...]\n"
+          + "  Caused by: java.lang.NullPointerException\n"
+          + "\tat  \n"
+          + "com.google.gerrit.ChangeJson.setLabelScores(ChangeJson.java:670)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.ChangeJson.labelsFor(ChangeJson.java:845)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.change.ChangeJson.labelsFor(ChangeJson.java:598)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.change.ChangeJson.toChange(ChangeJson.java:499)\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:294)\n"
+          + "\t... 105 more\n"
+          + "-- \n"
+          + "To view, visit https://gerrit-review.googlesource.com/90018\n"
+          + "To unsubscribe, visit https://gerrit-review.googlesource.com\n"
+          + "\n"
+          + "Gerrit-MessageType: comment\n"
+          + "Gerrit-Change-Id: Iba501e00bee77be3bd0ced72f88fd04ba0accaed\n"
+          + "Gerrit-PatchSet: 2\n"
+          + "Gerrit-Project: gerrit\n"
+          + "Gerrit-Branch: master\n"
+          + "Gerrit-Owner: ekempin <ekempin@google.com>\n"
+          + "Gerrit-Reviewer: Dave Borowitz <dborowitz@google.com>\n"
+          + "Gerrit-Reviewer: Edwin Kempin <ekempin@google.com>\n"
+          + "Gerrit-Reviewer: GerritForge CI <gerritforge@gmail.com>\n"
+          + "Gerrit-Reviewer: Jonathan Nieder <jrn@google.com>\n"
+          + "Gerrit-Reviewer: Patrick Hiesel <hiesel@google.com>\n"
+          + "Gerrit-Reviewer: ekempin <ekempin@google.com>\n"
+          + "Gerrit-HasComments: No";
+
+  private static String raw =
+      ""
+          + "Authentication-Results: mx.google.com; dkim=pass header.i="
+          + "@google.com;\n"
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "In-Reply-To: <gerrit.1477487889000.Iba501e00bee77be3bd0ced"
+          + "72f88fd04ba0accaed@gerrit-review.googlesource.com>\n"
+          + "References: <gerrit.1477487889000.Iba501e00bee77be3bd0ced72f8"
+          + "8fd04ba0accaed@gerrit-review.googlesource.com>\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: Change in gerrit[master]: (Re)enable voting buttons for "
+          + "merged changes\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-CtTy0"
+          + "igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Cc: Dave Borowitz <dborowitz@google.com>, Jonathan Nieder "
+          + "<jrn@google.com>, Patrick Hiesel <hiesel@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .addCc(new Address("Dave Borowitz", "dborowitz@google.com"))
+        .addCc(new Address("Jonathan Nieder", "jrn@google.com"))
+        .addCc(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .textContent(textContent)
+        .subject("Change in gerrit[master]: (Re)enable voting buttons for merged changes")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant())
+        .addAdditionalHeader(
+            "Authentication-Results: mx.google.com; dkim=pass header.i=@google.com;")
+        .addAdditionalHeader(
+            "In-Reply-To: <gerrit.1477487889000.Iba501e00bee"
+                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>")
+        .addAdditionalHeader(
+            "References: <gerrit.1477487889000.Iba501e00bee"
+                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>");
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/BUILD b/javatests/com/google/gerrit/metrics/dropwizard/BUILD
index 98d12b2..63d4452 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/BUILD
+++ b/javatests/com/google/gerrit/metrics/dropwizard/BUILD
@@ -7,6 +7,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/metrics/dropwizard",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/metrics/proc/BUILD b/javatests/com/google/gerrit/metrics/proc/BUILD
index 91e5cf6..7d8af90 100644
--- a/javatests/com/google/gerrit/metrics/proc/BUILD
+++ b/javatests/com/google/gerrit/metrics/proc/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/metrics/dropwizard",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/dropwizard:dropwizard-core",
         "//lib/guice",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
index 91b01f6..33919e7 100644
--- a/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
+++ b/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.metrics.proc;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.codahale.metrics.Counter;
 import com.codahale.metrics.Gauge;
@@ -36,13 +38,9 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class ProcMetricModuleTest {
-  @Rule public ExpectedException exception = ExpectedException.none();
-
   @Inject MetricMaker metrics;
 
   @Inject MetricRegistry registry;
@@ -82,7 +80,9 @@
   public void counter1() {
     Counter1<String> cntr =
         metrics.newCounter(
-            "test/count", new Description("simple test").setCumulative(), Field.ofString("action"));
+            "test/count",
+            new Description("simple test").setCumulative(),
+            Field.ofString("action", Field.ignoreMetadata()).build());
 
     Counter total = get("test/count_total", Counter.class);
     assertThat(total.getCount()).isEqualTo(0);
@@ -107,7 +107,7 @@
             new Description("simple test")
                 .setCumulative()
                 .setFieldOrdering(FieldOrdering.PREFIX_FIELDS_BASENAME),
-            Field.ofString("action"));
+            Field.ofString("action", Field.ignoreMetadata()).build());
 
     Counter total = get("test/count_total", Counter.class);
     assertThat(total.getCount()).isEqualTo(0);
@@ -150,14 +150,16 @@
 
   @Test
   public void invalidName1() {
-    exception.expect(IllegalArgumentException.class);
-    metrics.newCounter("invalid name", new Description("fail"));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> metrics.newCounter("invalid name", new Description("fail")));
   }
 
   @Test
   public void invalidName2() {
-    exception.expect(IllegalArgumentException.class);
-    metrics.newCounter("invalid/ name", new Description("fail"));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> metrics.newCounter("invalid/ name", new Description("fail")));
   }
 
   @SuppressWarnings({"unchecked", "cast"})
@@ -167,8 +169,8 @@
 
   private <M extends Metric> M get(String name, Class<M> type) {
     Metric m = registry.getMetrics().get(name);
-    assertThat(m).named(name).isNotNull();
-    assertThat(m).named(name).isInstanceOf(type);
+    assertWithMessage(name).that(m).isNotNull();
+    assertWithMessage(name).that(m).isInstanceOf(type);
 
     @SuppressWarnings("unchecked")
     M result = (M) m;
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD
index e4afae2..9eaadf8 100644
--- a/javatests/com/google/gerrit/pgm/BUILD
+++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -11,12 +11,14 @@
         "//java/com/google/gerrit/pgm/init",
         "//java/com/google/gerrit/pgm/init/api",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/securestore/testing",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:junit",
-        "//lib/easymock",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/mockito",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java b/javatests/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java
deleted file mode 100644
index 7ed7f81..0000000
--- a/javatests/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.http.jetty;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class HttpLogRedactTest {
-  @Test
-  public void includeQueryString() {
-    assertThat(HttpLog.redactQueryString("/changes/", null)).isEqualTo("/changes/");
-    assertThat(HttpLog.redactQueryString("/changes/", "")).isEqualTo("/changes/");
-    assertThat(HttpLog.redactQueryString("/changes/", "x")).isEqualTo("/changes/?x");
-    assertThat(HttpLog.redactQueryString("/changes/", "x=y")).isEqualTo("/changes/?x=y");
-  }
-
-  @Test
-  public void redactAuth() {
-    assertThat(HttpLog.redactQueryString("/changes/", "query=status:open"))
-        .isEqualTo("/changes/?query=status:open");
-
-    assertThat(HttpLog.redactQueryString("/changes/", "query=status:open&access_token=foo"))
-        .isEqualTo("/changes/?query=status:open&access_token=*");
-
-    assertThat(HttpLog.redactQueryString("/changes/", "access_token=foo"))
-        .isEqualTo("/changes/?access_token=*");
-
-    assertThat(
-            HttpLog.redactQueryString(
-                "/changes/", "query=status:open&access_token=foo&access_token=bar"))
-        .isEqualTo("/changes/?query=status:open&access_token=*&access_token=*");
-  }
-}
diff --git a/javatests/com/google/gerrit/pgm/init/InitTestCase.java b/javatests/com/google/gerrit/pgm/init/InitTestCase.java
deleted file mode 100644
index 35c0937..0000000
--- a/javatests/com/google/gerrit/pgm/init/InitTestCase.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import java.io.IOException;
-import java.nio.file.Path;
-import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
-import org.junit.Ignore;
-
-@Ignore
-public abstract class InitTestCase extends LocalDiskRepositoryTestCase {
-  protected Path newSitePath() throws IOException {
-    return createWorkRepository().getWorkTree().toPath().resolve("test_site");
-  }
-}
diff --git a/javatests/com/google/gerrit/pgm/init/LibrariesTest.java b/javatests/com/google/gerrit/pgm/init/LibrariesTest.java
index af21ad0..5aa4718 100644
--- a/javatests/com/google/gerrit/pgm/init/LibrariesTest.java
+++ b/javatests/com/google/gerrit/pgm/init/LibrariesTest.java
@@ -14,40 +14,33 @@
 
 package com.google.gerrit.pgm.init;
 
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
 import static org.junit.Assert.assertNotNull;
+import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Provider;
 import java.nio.file.Paths;
 import java.util.Collections;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class LibrariesTest {
+  @Mock ConsoleUI ui;
+  @Mock StaleLibraryRemover remover;
+
   @Test
   public void create() throws Exception {
     final SitePaths site = new SitePaths(Paths.get("."));
-    final ConsoleUI ui = createStrictMock(ConsoleUI.class);
-    final StaleLibraryRemover remover = createStrictMock(StaleLibraryRemover.class);
-
-    replay(ui);
 
     Libraries lib =
         new Libraries(
-            new Provider<LibraryDownloader>() {
-              @Override
-              public LibraryDownloader get() {
-                return new LibraryDownloader(ui, site, remover);
-              }
-            },
-            Collections.<String>emptyList(),
-            false);
+            () -> new LibraryDownloader(ui, site, remover), Collections.emptyList(), false);
 
     assertNotNull(lib.mysqlDriver);
-
-    verify(ui);
+    verifyZeroInteractions(ui);
+    verifyZeroInteractions(remover);
   }
 }
diff --git a/javatests/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java b/javatests/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
deleted file mode 100644
index 7721fca..0000000
--- a/javatests/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.common.FileUtil;
-import com.google.gerrit.pgm.init.api.ConsoleUI;
-import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.securestore.SecureStore;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Collections;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.junit.Test;
-
-public class UpgradeFrom2_0_xTest extends InitTestCase {
-
-  @Test
-  public void upgrade() throws IOException, ConfigInvalidException {
-    final Path p = newSitePath();
-    final SitePaths site = new SitePaths(p);
-    assertTrue(site.isNew);
-    FileUtil.mkdirsOrDie(site.etc_dir, "Failed to create");
-
-    for (String n : UpgradeFrom2_0_x.etcFiles) {
-      Files.write(p.resolve(n), ("# " + n + "\n").getBytes(UTF_8));
-    }
-
-    FileBasedConfig old = new FileBasedConfig(p.resolve("gerrit.config").toFile(), FS.DETECTED);
-
-    old.setString("ldap", null, "username", "ldap.user");
-    old.setString("ldap", null, "password", "ldap.s3kr3t");
-
-    old.setString("sendemail", null, "smtpUser", "email.user");
-    old.setString("sendemail", null, "smtpPass", "email.s3kr3t");
-    old.save();
-
-    final InMemorySecureStore secureStore = new InMemorySecureStore();
-    final InitFlags flags =
-        new InitFlags(site, secureStore, Collections.<String>emptyList(), false);
-    final ConsoleUI ui = createStrictMock(ConsoleUI.class);
-    Section.Factory sections =
-        new Section.Factory() {
-          @Override
-          public Section get(String name, String subsection) {
-            return new Section(flags, site, secureStore, ui, name, subsection);
-          }
-        };
-
-    expect(ui.yesno(eq(true), eq("Upgrade '%s'"), eq(p.toAbsolutePath().normalize())))
-        .andReturn(true);
-    replay(ui);
-
-    UpgradeFrom2_0_x u = new UpgradeFrom2_0_x(site, flags, ui, sections);
-    assertTrue(u.isNeedUpgrade());
-    u.run();
-    assertFalse(u.isNeedUpgrade());
-    verify(ui);
-
-    for (String n : UpgradeFrom2_0_x.etcFiles) {
-      if ("gerrit.config".equals(n) || "secure.config".equals(n)) {
-        continue;
-      }
-      try (InputStream in = Files.newInputStream(site.etc_dir.resolve(n))) {
-        assertEquals("# " + n + "\n", new String(ByteStreams.toByteArray(in), UTF_8));
-      }
-    }
-
-    FileBasedConfig cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
-    cfg.load();
-
-    assertEquals("email.user", cfg.getString("sendemail", null, "smtpUser"));
-    assertNull(cfg.getString("sendemail", null, "smtpPass"));
-    assertEquals("email.s3kr3t", secureStore.get("sendemail", null, "smtpPass"));
-
-    assertEquals("ldap.user", cfg.getString("ldap", null, "username"));
-    assertNull(cfg.getString("ldap", null, "password"));
-    assertEquals("ldap.s3kr3t", secureStore.get("ldap", null, "password"));
-
-    u.run();
-  }
-
-  private static class InMemorySecureStore extends SecureStore {
-    private final Config cfg = new Config();
-
-    @Override
-    public String[] getList(String section, String subsection, String name) {
-      return cfg.getStringList(section, subsection, name);
-    }
-
-    @Override
-    public String[] getListForPlugin(
-        String pluginName, String section, String subsection, String name) {
-      throw new UnsupportedOperationException("not used by tests");
-    }
-
-    @Override
-    public void setList(String section, String subsection, String name, List<String> values) {
-      cfg.setStringList(section, subsection, name, values);
-    }
-
-    @Override
-    public void unset(String section, String subsection, String name) {
-      cfg.unset(section, subsection, name);
-    }
-
-    @Override
-    public Iterable<EntryKey> list() {
-      throw new UnsupportedOperationException("not used by tests");
-    }
-
-    @Override
-    public boolean isOutdated() {
-      throw new UnsupportedOperationException("not used by tests");
-    }
-
-    @Override
-    public void reload() {
-      throw new UnsupportedOperationException("not used by tests");
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java b/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java
new file mode 100644
index 0000000..e34b578
--- /dev/null
+++ b/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java
@@ -0,0 +1,116 @@
+// 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.api;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.securestore.testing.InMemorySecureStore;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AllProjectsConfigTest {
+  private static final String ALL_PROJECTS = "All-The-Projects";
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Mock ConsoleUI ui;
+
+  private SitePaths sitePaths;
+  private AllProjectsConfig allProjectsConfig;
+  private File allProjectsRepoFile;
+
+  @Before
+  public void setUp() throws Exception {
+    sitePaths = new SitePaths(temporaryFolder.newFolder().toPath());
+    Files.createDirectories(sitePaths.etc_dir);
+
+    Path gitPath = sitePaths.resolve("git");
+
+    StoredConfig gerritConfig =
+        new FileBasedConfig(
+            sitePaths.resolve("etc").resolve("gerrit.config").toFile(), FS.DETECTED);
+    gerritConfig.load();
+    gerritConfig.setString("gerrit", null, "basePath", gitPath.toAbsolutePath().toString());
+    gerritConfig.setString("gerrit", null, "allProjects", ALL_PROJECTS);
+    gerritConfig.save();
+
+    Files.createDirectories(sitePaths.resolve("git"));
+    allProjectsRepoFile = gitPath.resolve("All-The-Projects.git").toFile();
+    try (Repository repo = new FileRepository(allProjectsRepoFile)) {
+      repo.create(true);
+    }
+
+    InMemorySecureStore secureStore = new InMemorySecureStore();
+    InitFlags flags = new InitFlags(sitePaths, secureStore, ImmutableList.of(), false);
+    Section.Factory sections =
+        (name, subsection) -> new Section(flags, sitePaths, secureStore, ui, name, subsection);
+    allProjectsConfig =
+        new AllProjectsConfig(new AllProjectsNameOnInitProvider(sections), sitePaths, flags);
+  }
+
+  @Test
+  public void noBaseConfig() throws Exception {
+    assertThat(getConfig().getString("foo", null, "bar")).isNull();
+
+    try (Repository repo = new FileRepository(allProjectsRepoFile);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch("refs/meta/config").commit().add("project.config", "[foo]\nbar = baz").create();
+    }
+
+    assertThat(getConfig().getString("foo", null, "bar")).isEqualTo("baz");
+  }
+
+  @Test
+  public void baseConfig() throws Exception {
+    assertThat(getConfig().getString("foo", null, "bar")).isNull();
+
+    Path baseConfigPath = sitePaths.etc_dir.resolve(ALL_PROJECTS).resolve("project.config");
+    Files.createDirectories(baseConfigPath.getParent());
+    Files.write(baseConfigPath, ImmutableList.of("[foo]", "bar = base"));
+
+    assertThat(getConfig().getString("foo", null, "bar")).isEqualTo("base");
+
+    try (Repository repo = new FileRepository(allProjectsRepoFile);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch("refs/meta/config").commit().add("project.config", "[foo]\nbar = baz").create();
+    }
+
+    assertThat(getConfig().getString("foo", null, "bar")).isEqualTo("baz");
+  }
+
+  private Config getConfig() throws IOException, ConfigInvalidException {
+    return allProjectsConfig.load().getConfig();
+  }
+}
diff --git a/javatests/com/google/gerrit/proto/BUILD b/javatests/com/google/gerrit/proto/BUILD
new file mode 100644
index 0000000..6b98b72
--- /dev/null
+++ b/javatests/com/google/gerrit/proto/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "proto_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:protobuf",
+        "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/proto/ProtosTest.java b/javatests/com/google/gerrit/proto/ProtosTest.java
new file mode 100644
index 0000000..550bcc5
--- /dev/null
+++ b/javatests/com/google/gerrit/proto/ProtosTest.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.proto;
+
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
+import com.google.protobuf.ByteString;
+import java.util.Arrays;
+import org.junit.Test;
+
+public class ProtosTest {
+  @Test
+  public void parseUncheckedByteArrayWrongProtoType() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    byte[] bytes = Protos.toByteArray(proto);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes));
+  }
+
+  @Test
+  public void parseUncheckedByteArrayInvalidData() {
+    byte[] bytes = new byte[] {0x00};
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes));
+  }
+
+  @Test
+  public void parseUncheckedByteArray() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    byte[] bytes = Protos.toByteArray(proto);
+    assertThat(Protos.parseUnchecked(ChangeNotesKeyProto.parser(), bytes)).isEqualTo(proto);
+  }
+
+  @Test
+  public void parseUncheckedSegmentOfByteArrayWrongProtoType() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    byte[] bytes = Protos.toByteArray(proto);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes, 0, bytes.length));
+  }
+
+  @Test
+  public void parseUncheckedSegmentOfByteArrayInvalidData() {
+    byte[] bytes = new byte[] {0x00};
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes, 0, bytes.length));
+  }
+
+  @Test
+  public void parseUncheckedSegmentOfByteArray() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    byte[] protoBytes = Protos.toByteArray(proto);
+    int offset = 3;
+    int length = protoBytes.length;
+    byte[] bytes = new byte[length + 20];
+    Arrays.fill(bytes, (byte) 1);
+    System.arraycopy(protoBytes, 0, bytes, offset, length);
+
+    ChangeNotesKeyProto parsedProto =
+        Protos.parseUnchecked(ChangeNotesKeyProto.parser(), bytes, offset, length);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  @Test
+  public void parseUncheckedByteStringWrongProtoType() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    ByteString byteString = Protos.toByteString(proto);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), byteString));
+  }
+
+  @Test
+  public void parseUncheckedByteStringInvalidData() {
+    ByteString byteString = ByteString.copyFrom(new byte[] {0x00});
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Protos.parseUnchecked(ChangeNotesStateProto.parser(), byteString));
+  }
+
+  @Test
+  public void parseUncheckedByteString() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    ByteString byteString = Protos.toByteString(proto);
+    assertThat(Protos.parseUnchecked(ChangeNotesKeyProto.parser(), byteString)).isEqualTo(proto);
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/BUILD b/javatests/com/google/gerrit/reviewdb/BUILD
deleted file mode 100644
index 0fd140e..0000000
--- a/javatests/com/google/gerrit/reviewdb/BUILD
+++ /dev/null
@@ -1,14 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-junit_tests(
-    name = "client_tests",
-    srcs = glob(["client/**/*.java"]),
-    deps = [
-        "//java/com/google/gerrit/reviewdb:client",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib/truth",
-    ],
-)
diff --git a/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java b/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
index 18a55bf..1332171 100644
--- a/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.reviewdb.client.AccountGroup.UUID.fromRef;
 import static com.google.gerrit.reviewdb.client.AccountGroup.UUID.fromRefPart;
+import static com.google.gerrit.reviewdb.client.AccountGroup.uuid;
 
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -70,7 +71,29 @@
     assertThat(fromRefPart("ab/" + TEST_UUID)).isNull();
   }
 
-  private AccountGroup.UUID uuid(String uuid) {
-    return new AccountGroup.UUID(uuid);
+  @Test
+  public void uuidToString() {
+    assertThat(uuid("foo").toString()).isEqualTo("foo");
+    assertThat(uuid("foo bar").toString()).isEqualTo("foo+bar");
+    assertThat(uuid("foo:bar").toString()).isEqualTo("foo%3Abar");
+  }
+
+  @Test
+  public void parseUuid() {
+    assertThat(AccountGroup.UUID.parse("foo")).isEqualTo(uuid("foo"));
+    assertThat(AccountGroup.UUID.parse("foo+bar")).isEqualTo(uuid("foo bar"));
+    assertThat(AccountGroup.UUID.parse("foo%3Abar")).isEqualTo(uuid("foo:bar"));
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(AccountGroup.id(123).toString()).isEqualTo("123");
+  }
+
+  @Test
+  public void nameKeyToString() {
+    assertThat(AccountGroup.nameKey("foo").toString()).isEqualTo("foo");
+    assertThat(AccountGroup.nameKey("foo bar").toString()).isEqualTo("foo+bar");
+    assertThat(AccountGroup.nameKey("foo:bar").toString()).isEqualTo("foo%3Abar");
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/client/AccountTest.java b/javatests/com/google/gerrit/reviewdb/client/AccountTest.java
index 11a562f..e8ab613 100644
--- a/javatests/com/google/gerrit/reviewdb/client/AccountTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/AccountTest.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.reviewdb.client.Account.Id.fromRef;
 import static com.google.gerrit.reviewdb.client.Account.Id.fromRefPart;
 import static com.google.gerrit.reviewdb.client.Account.Id.fromRefSuffix;
+import static com.google.gerrit.reviewdb.client.Account.id;
 
 import org.junit.Test;
 
@@ -90,8 +91,4 @@
     assertThat(fromRefSuffix("12/34")).isEqualTo(id(34));
     assertThat(fromRefSuffix("ab/cd")).isNull();
   }
-
-  private Account.Id id(int n) {
-    return new Account.Id(n);
-  }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/client/BUILD b/javatests/com/google/gerrit/reviewdb/client/BUILD
new file mode 100644
index 0000000..391d80e
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "client_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/reviewdb/client/BranchTest.java b/javatests/com/google/gerrit/reviewdb/client/BranchTest.java
new file mode 100644
index 0000000..ac99a1a
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/BranchTest.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.reviewdb.client;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class BranchTest {
+  @Test
+  public void canonicalizeNameDuringConstruction() {
+    assertThat(BranchNameKey.create(new Project.NameKey("foo"), "bar").branch())
+        .isEqualTo("refs/heads/bar");
+    assertThat(BranchNameKey.create(new Project.NameKey("foo"), "refs/heads/bar").branch())
+        .isEqualTo("refs/heads/bar");
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(BranchNameKey.create(new Project.NameKey("foo"), "bar").toString())
+        .isEqualTo("foo,refs/heads/bar");
+    assertThat(BranchNameKey.create(new Project.NameKey("foo bar"), "bar baz").toString())
+        .isEqualTo("foo+bar,refs/heads/bar+baz");
+    assertThat(BranchNameKey.create(new Project.NameKey("foo^bar"), "bar^baz").toString())
+        .isEqualTo("foo%5Ebar,refs/heads/bar%5Ebaz");
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java b/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
index 6d1d0a6..ccc0bd2 100644
--- a/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
@@ -18,6 +18,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class ChangeTest {
@@ -122,8 +123,8 @@
 
   @Test
   public void toRefPrefix() {
-    assertThat(new Change.Id(1).toRefPrefix()).isEqualTo("refs/changes/01/1/");
-    assertThat(new Change.Id(1234).toRefPrefix()).isEqualTo("refs/changes/34/1234/");
+    assertThat(Change.id(1).toRefPrefix()).isEqualTo("refs/changes/01/1/");
+    assertThat(Change.id(1234).toRefPrefix()).isEqualTo("refs/changes/34/1234/");
   }
 
   @Test
@@ -147,8 +148,20 @@
     assertNotRefPart("1/1");
   }
 
+  @Test
+  public void idToString() {
+    assertThat(Change.id(3).toString()).isEqualTo("3");
+  }
+
+  @Test
+  public void keyToString() {
+    String key = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    assertThat(ObjectId.isId(key.substring(1))).isTrue();
+    assertThat(Change.key(key).toString()).isEqualTo(key);
+  }
+
   private static void assertRef(int changeId, String refName) {
-    assertThat(Change.Id.fromRef(refName)).isEqualTo(new Change.Id(changeId));
+    assertThat(Change.Id.fromRef(refName)).isEqualTo(Change.id(changeId));
   }
 
   private static void assertNotRef(String refName) {
@@ -156,7 +169,7 @@
   }
 
   private static void assertAllUsersRef(int changeId, String refName) {
-    assertThat(Change.Id.fromAllUsersRef(refName)).isEqualTo(new Change.Id(changeId));
+    assertThat(Change.Id.fromAllUsersRef(refName)).isEqualTo(Change.id(changeId));
   }
 
   private static void assertNotAllUsersRef(String refName) {
@@ -164,7 +177,7 @@
   }
 
   private static void assertRefPart(int changeId, String refName) {
-    assertEquals(new Change.Id(changeId), Change.Id.fromRefPart(refName));
+    assertEquals(Change.id(changeId), Change.Id.fromRefPart(refName));
   }
 
   private static void assertNotRefPart(String refName) {
diff --git a/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java b/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
index 5e42ce0..c73f327 100644
--- a/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
@@ -16,23 +16,22 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.HashMap;
 import java.util.Map;
 import org.junit.Test;
 
-public class PatchSetApprovalTest extends GerritBaseTests {
+public class PatchSetApprovalTest {
   @Test
   public void keyEquality() {
     PatchSetApproval.Key k1 =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(1), 2), Account.id(3), LabelId.create("My-Label"));
     PatchSetApproval.Key k2 =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(1), 2), Account.id(3), LabelId.create("My-Label"));
     PatchSetApproval.Key k3 =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("Other-Label"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(1), 2), Account.id(3), LabelId.create("Other-Label"));
 
     assertThat(k2).isEqualTo(k1);
     assertThat(k3).isNotEqualTo(k1);
diff --git a/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java b/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
index 51a405f..c195533 100644
--- a/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.reviewdb.client.PatchSet.joinGroups;
 import static com.google.gerrit.reviewdb.client.PatchSet.splitGroups;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import org.junit.Test;
@@ -64,37 +65,67 @@
 
   @Test
   public void testSplitGroups() {
+    assertRuntimeException(() -> splitGroups(null));
     assertThat(splitGroups("")).containsExactly("");
     assertThat(splitGroups("abcd")).containsExactly("abcd");
     assertThat(splitGroups("ab,cd")).containsExactly("ab", "cd").inOrder();
+    assertThat(splitGroups("ab , cd")).containsExactly("ab ", " cd").inOrder();
     assertThat(splitGroups("ab,")).containsExactly("ab", "").inOrder();
     assertThat(splitGroups(",cd")).containsExactly("", "cd").inOrder();
   }
 
   @Test
   public void testJoinGroups() {
+    assertRuntimeException(() -> joinGroups(null));
+    assertRuntimeException(() -> joinGroups(ImmutableList.of("a,", "b")));
     assertThat(joinGroups(ImmutableList.of(""))).isEqualTo("");
     assertThat(joinGroups(ImmutableList.of("abcd"))).isEqualTo("abcd");
     assertThat(joinGroups(ImmutableList.of("ab", "cd"))).isEqualTo("ab,cd");
+    assertThat(joinGroups(ImmutableList.of("ab ", " cd"))).isEqualTo("ab , cd");
     assertThat(joinGroups(ImmutableList.of("ab", ""))).isEqualTo("ab,");
     assertThat(joinGroups(ImmutableList.of("", "cd"))).isEqualTo(",cd");
   }
 
   @Test
   public void toRefName() {
-    assertThat(new PatchSet.Id(new Change.Id(1), 23).toRefName()).isEqualTo("refs/changes/01/1/23");
-    assertThat(new PatchSet.Id(new Change.Id(1234), 5).toRefName())
-        .isEqualTo("refs/changes/34/1234/5");
+    assertThat(PatchSet.id(Change.id(1), 23).toRefName()).isEqualTo("refs/changes/01/1/23");
+    assertThat(PatchSet.id(Change.id(1234), 5).toRefName()).isEqualTo("refs/changes/34/1234/5");
+  }
+
+  @Test
+  public void parseId() {
+    assertThat(PatchSet.Id.parse("1,2")).isEqualTo(PatchSet.id(Change.id(1), 2));
+    assertThat(PatchSet.Id.parse("01,02")).isEqualTo(PatchSet.id(Change.id(1), 2));
+    assertInvalidId(null);
+    assertInvalidId("");
+    assertInvalidId("1");
+    assertInvalidId("1,foo.txt");
+    assertInvalidId("foo.txt,1");
+
+    String hexComma = "%" + String.format("%02x", (int) ',');
+    assertInvalidId("1" + hexComma + "2");
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(PatchSet.id(Change.id(2), 3).toString()).isEqualTo("2,3");
   }
 
   private static void assertRef(int changeId, int psId, String refName) {
     assertThat(PatchSet.isChangeRef(refName)).isTrue();
-    assertThat(PatchSet.Id.fromRef(refName))
-        .isEqualTo(new PatchSet.Id(new Change.Id(changeId), psId));
+    assertThat(PatchSet.Id.fromRef(refName)).isEqualTo(PatchSet.id(Change.id(changeId), psId));
   }
 
   private static void assertNotRef(String refName) {
     assertThat(PatchSet.isChangeRef(refName)).isFalse();
     assertThat(PatchSet.Id.fromRef(refName)).isNull();
   }
+
+  private static void assertInvalidId(String str) {
+    assertRuntimeException(() -> PatchSet.Id.parse(str));
+  }
+
+  private static void assertRuntimeException(Runnable runnable) {
+    assertThrows(RuntimeException.class, () -> runnable.run());
+  }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/client/PatchTest.java b/javatests/com/google/gerrit/reviewdb/client/PatchTest.java
new file mode 100644
index 0000000..d9a30e5
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/PatchTest.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.reviewdb.client;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+
+public class PatchTest {
+  @Test
+  public void isMagic() {
+    assertThat(Patch.isMagic("/COMMIT_MSG")).isTrue();
+    assertThat(Patch.isMagic("/MERGE_LIST")).isTrue();
+
+    assertThat(Patch.isMagic("/COMMIT_MSG/")).isFalse();
+    assertThat(Patch.isMagic("COMMIT_MSG")).isFalse();
+    assertThat(Patch.isMagic("/commit_msg")).isFalse();
+  }
+
+  @Test
+  public void parseKey() {
+    assertThat(Patch.Key.parse("1,2,foo.txt"))
+        .isEqualTo(Patch.key(PatchSet.id(Change.id(1), 2), "foo.txt"));
+    assertThat(Patch.Key.parse("01,02,foo.txt"))
+        .isEqualTo(Patch.key(PatchSet.id(Change.id(1), 2), "foo.txt"));
+    assertInvalidKey(null);
+    assertInvalidKey("");
+    assertInvalidKey("1,2");
+    assertInvalidKey("1, 2, foo.txt");
+    assertInvalidKey("1,foo.txt");
+    assertInvalidKey("1,foo.txt,2");
+    assertInvalidKey("foo.txt,1,2");
+
+    String hexComma = "%" + String.format("%02x", (int) ',');
+    assertInvalidKey("1" + hexComma + "2" + hexComma + "foo.txt");
+  }
+
+  private static void assertInvalidKey(String str) {
+    assertThrows(RuntimeException.class, () -> Patch.Key.parse(str));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/client/ProjectTest.java b/javatests/com/google/gerrit/reviewdb/client/ProjectTest.java
new file mode 100644
index 0000000..a24cff7
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/ProjectTest.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.reviewdb.client;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class ProjectTest {
+  @Test
+  public void parseId() {
+    assertThat(Project.NameKey.parse("foo")).isEqualTo(new Project.NameKey("foo"));
+    assertThat(Project.NameKey.parse("foo%20bar")).isEqualTo(new Project.NameKey("foo bar"));
+    assertThat(Project.NameKey.parse("foo+bar")).isEqualTo(new Project.NameKey("foo bar"));
+    assertThat(Project.NameKey.parse("foo%2fbar")).isEqualTo(new Project.NameKey("foo/bar"));
+    assertThat(Project.NameKey.parse("foo%2Fbar")).isEqualTo(new Project.NameKey("foo/bar"));
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(Project.nameKey("foo").toString()).isEqualTo("foo");
+    assertThat(Project.nameKey("foo bar").toString()).isEqualTo("foo+bar");
+    assertThat(Project.nameKey("foo/bar").toString()).isEqualTo("foo/bar");
+    assertThat(Project.nameKey("foo^bar").toString()).isEqualTo("foo%5Ebar");
+    assertThat(Project.nameKey("foo%bar").toString()).isEqualTo("foo%25bar");
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java b/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
index fa6a722..7f22275 100644
--- a/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
@@ -20,21 +20,17 @@
 import static com.google.gerrit.reviewdb.client.RefNames.parseShardedRefPart;
 import static com.google.gerrit.reviewdb.client.RefNames.parseShardedUuidFromRefPart;
 import static com.google.gerrit.reviewdb.client.RefNames.skipShardedRefPart;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class RefNamesTest {
   private static final String TEST_GROUP_UUID = "ccab3195282a8ce4f5014efa391e82d10f884c64";
   private static final String TEST_SHARDED_GROUP_UUID =
       TEST_GROUP_UUID.substring(0, 2) + "/" + TEST_GROUP_UUID;
-
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
-  private final Account.Id accountId = new Account.Id(1011123);
-  private final Change.Id changeId = new Change.Id(67473);
-  private final PatchSet.Id psId = new PatchSet.Id(changeId, 42);
+  private final Account.Id accountId = Account.id(1011123);
+  private final Change.Id changeId = Change.id(67473);
+  private final PatchSet.Id psId = PatchSet.id(changeId, 42);
 
   @Test
   public void fullName() throws Exception {
@@ -58,30 +54,28 @@
 
   @Test
   public void refForGroupIsSharded() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEFG");
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("ABCDEFG");
     String groupRef = RefNames.refsGroups(groupUuid);
     assertThat(groupRef).isEqualTo("refs/groups/AB/ABCDEFG");
   }
 
   @Test
   public void refForGroupWithUuidLessThanTwoCharsIsRejected() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("A");
-    expectedException.expect(IllegalArgumentException.class);
-    RefNames.refsGroups(groupUuid);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("A");
+    assertThrows(IllegalArgumentException.class, () -> RefNames.refsGroups(groupUuid));
   }
 
   @Test
   public void refForDeletedGroupIsSharded() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEFG");
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("ABCDEFG");
     String groupRef = RefNames.refsDeletedGroups(groupUuid);
     assertThat(groupRef).isEqualTo("refs/deleted-groups/AB/ABCDEFG");
   }
 
   @Test
   public void refForDeletedGroupWithUuidLessThanTwoCharsIsRejected() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("A");
-    expectedException.expect(IllegalArgumentException.class);
-    RefNames.refsDeletedGroups(groupUuid);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("A");
+    assertThrows(IllegalArgumentException.class, () -> RefNames.refsDeletedGroups(groupUuid));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java
new file mode 100644
index 0000000..18ce0fe
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.protobuf.Parser;
+import org.junit.Test;
+
+public class AccountIdProtoConverterTest {
+  private final AccountIdProtoConverter accountIdProtoConverter = AccountIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    Account.Id accountId = Account.id(24);
+
+    Entities.Account_Id proto = accountIdProtoConverter.toProto(accountId);
+
+    Entities.Account_Id expectedProto = Entities.Account_Id.newBuilder().setId(24).build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    Account.Id accountId = Account.id(34832);
+
+    Account.Id convertedAccountId =
+        accountIdProtoConverter.fromProto(accountIdProtoConverter.toProto(accountId));
+
+    assertThat(convertedAccountId).isEqualTo(accountId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.Account_Id proto = Entities.Account_Id.newBuilder().setId(24).build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.Account_Id> parser = accountIdProtoConverter.getParser();
+    Entities.Account_Id parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(Account.Id.class)
+        .hasAutoValueMethods(ImmutableMap.of("id", int.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/BUILD b/javatests/com/google/gerrit/reviewdb/converter/BUILD
new file mode 100644
index 0000000..e745344
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/BUILD
@@ -0,0 +1,17 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "proto_converter_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/proto/testing",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib:guava",
+        "//lib:protobuf",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:entities_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java
new file mode 100644
index 0000000..2f6bb61
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import org.junit.Test;
+
+public class BranchNameKeyProtoConverterTest {
+  private final BranchNameKeyProtoConverter branchNameKeyProtoConverter =
+      BranchNameKeyProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    BranchNameKey nameKey = BranchNameKey.create(Project.nameKey("project-13"), "branch-72");
+
+    Entities.Branch_NameKey proto = branchNameKeyProtoConverter.toProto(nameKey);
+
+    Entities.Branch_NameKey expectedProto =
+        Entities.Branch_NameKey.newBuilder()
+            .setProject(Entities.Project_NameKey.newBuilder().setName("project-13"))
+            .setBranch("refs/heads/branch-72")
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    BranchNameKey nameKey = BranchNameKey.create(Project.nameKey("project-52"), "branch 14");
+
+    BranchNameKey convertedNameKey =
+        branchNameKeyProtoConverter.fromProto(branchNameKeyProtoConverter.toProto(nameKey));
+
+    assertThat(convertedNameKey).isEqualTo(nameKey);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.Branch_NameKey proto =
+        Entities.Branch_NameKey.newBuilder()
+            .setProject(Entities.Project_NameKey.newBuilder().setName("project 1"))
+            .setBranch("branch 36")
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.Branch_NameKey> parser = branchNameKeyProtoConverter.getParser();
+    Entities.Branch_NameKey parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(BranchNameKey.class)
+        .hasAutoValueMethods(
+            ImmutableMap.<String, Type>builder()
+                .put("project", Project.NameKey.class)
+                .put("branch", String.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java
new file mode 100644
index 0000000..ee5d3ff
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.protobuf.Parser;
+import org.junit.Test;
+
+public class ChangeIdProtoConverterTest {
+  private final ChangeIdProtoConverter changeIdProtoConverter = ChangeIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    Change.Id changeId = Change.id(94);
+
+    Entities.Change_Id proto = changeIdProtoConverter.toProto(changeId);
+
+    Entities.Change_Id expectedProto = Entities.Change_Id.newBuilder().setId(94).build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    Change.Id changeId = Change.id(2903482);
+
+    Change.Id convertedChangeId =
+        changeIdProtoConverter.fromProto(changeIdProtoConverter.toProto(changeId));
+
+    assertThat(convertedChangeId).isEqualTo(changeId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.Change_Id proto = Entities.Change_Id.newBuilder().setId(94).build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.Change_Id> parser = changeIdProtoConverter.getParser();
+    Entities.Change_Id parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(Change.Id.class)
+        .hasAutoValueMethods(ImmutableMap.of("id", int.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java
new file mode 100644
index 0000000..8bcdd49
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.protobuf.Parser;
+import org.junit.Test;
+
+public class ChangeKeyProtoConverterTest {
+  private final ChangeKeyProtoConverter changeKeyProtoConverter = ChangeKeyProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    Change.Key changeKey = Change.key("change-1");
+
+    Entities.Change_Key proto = changeKeyProtoConverter.toProto(changeKey);
+
+    Entities.Change_Key expectedProto = Entities.Change_Key.newBuilder().setId("change-1").build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    Change.Key changeKey = Change.key("change-52");
+
+    Change.Key convertedChangeKey =
+        changeKeyProtoConverter.fromProto(changeKeyProtoConverter.toProto(changeKey));
+
+    assertThat(convertedChangeKey).isEqualTo(changeKey);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.Change_Key proto = Entities.Change_Key.newBuilder().setId("change 36").build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.Change_Key> parser = changeKeyProtoConverter.getParser();
+    Entities.Change_Key parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(Change.Key.class)
+        .hasAutoValueMethods(ImmutableMap.of("key", String.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
new file mode 100644
index 0000000..ed4e887
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import org.junit.Test;
+
+public class ChangeMessageKeyProtoConverterTest {
+  private final ChangeMessageKeyProtoConverter messageKeyProtoConverter =
+      ChangeMessageKeyProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ChangeMessage.Key messageKey = ChangeMessage.key(Change.id(704), "aabbcc");
+
+    Entities.ChangeMessage_Key proto = messageKeyProtoConverter.toProto(messageKey);
+
+    Entities.ChangeMessage_Key expectedProto =
+        Entities.ChangeMessage_Key.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(704))
+            .setUuid("aabbcc")
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage.Key messageKey = ChangeMessage.key(Change.id(704), "aabbcc");
+
+    ChangeMessage.Key convertedMessageKey =
+        messageKeyProtoConverter.fromProto(messageKeyProtoConverter.toProto(messageKey));
+
+    assertThat(convertedMessageKey).isEqualTo(messageKey);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.ChangeMessage_Key proto =
+        Entities.ChangeMessage_Key.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(704))
+            .setUuid("aabbcc")
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.ChangeMessage_Key> parser = messageKeyProtoConverter.getParser();
+    Entities.ChangeMessage_Key parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(ChangeMessage.Key.class)
+        .hasAutoValueMethods(
+            ImmutableMap.<String, Type>builder()
+                .put("changeId", Change.Id.class)
+                .put("uuid", String.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
new file mode 100644
index 0000000..be7a5ee
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
@@ -0,0 +1,209 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class ChangeMessageProtoConverterTest {
+  private final ChangeMessageProtoConverter changeMessageProtoConverter =
+      ChangeMessageProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
+            new Timestamp(9876543),
+            PatchSet.id(Change.id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(Account.id(10003));
+
+    Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Entities.ChangeMessage expectedProto =
+        Entities.ChangeMessage.newBuilder()
+            .setKey(
+                Entities.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .setAuthorId(Entities.Account_Id.newBuilder().setId(63))
+            .setWrittenOn(9876543)
+            .setMessage("This is a change message.")
+            .setPatchset(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(34))
+                    .setId(13))
+            .setTag("An arbitrary tag.")
+            .setRealAuthor(Entities.Account_Id.newBuilder().setId(10003))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mainValuesConvertedToProto() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
+            new Timestamp(9876543),
+            PatchSet.id(Change.id(34), 13));
+
+    Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Entities.ChangeMessage expectedProto =
+        Entities.ChangeMessage.newBuilder()
+            .setKey(
+                Entities.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .setAuthorId(Entities.Account_Id.newBuilder().setId(63))
+            .setWrittenOn(9876543)
+            .setPatchset(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(34))
+                    .setId(13))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  // This test documents a special behavior which is necessary to ensure binary compatibility.
+  @Test
+  public void realAuthorIsNotAutomaticallySetToAuthorWhenConvertedToProto() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            ChangeMessage.key(Change.id(543), "change-message-21"), Account.id(63), null, null);
+
+    Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Entities.ChangeMessage expectedProto =
+        Entities.ChangeMessage.newBuilder()
+            .setKey(
+                Entities.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .setAuthorId(Entities.Account_Id.newBuilder().setId(63))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    // writtenOn may not be null according to the column definition but it's optional for the
+    // protobuf definition. -> assume as optional and hence test null
+    ChangeMessage changeMessage =
+        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
+
+    Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Entities.ChangeMessage expectedProto =
+        Entities.ChangeMessage.newBuilder()
+            .setKey(
+                Entities.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
+            new Timestamp(9876543),
+            PatchSet.id(Change.id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(Account.id(10003));
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
+  public void mainValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
+            new Timestamp(9876543),
+            PatchSet.id(Change.id(34), 13));
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.ChangeMessage proto =
+        Entities.ChangeMessage.newBuilder()
+            .setKey(
+                Entities.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.ChangeMessage> parser = changeMessageProtoConverter.getParser();
+    Entities.ChangeMessage parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(ChangeMessage.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("key", ChangeMessage.Key.class)
+                .put("author", Account.Id.class)
+                .put("writtenOn", Timestamp.class)
+                .put("message", String.class)
+                .put("patchset", PatchSet.Id.class)
+                .put("tag", String.class)
+                .put("realAuthor", Account.Id.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java
new file mode 100644
index 0000000..0393c15
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java
@@ -0,0 +1,363 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class ChangeProtoConverterTest {
+  private final ChangeProtoConverter changeProtoConverter = ChangeProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    Change change =
+        new Change(
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch 74"),
+            new Timestamp(987654L));
+    change.setLastUpdatedOn(new Timestamp(1234567L));
+    change.setStatus(Change.Status.MERGED);
+    change.setCurrentPatchSet(
+        PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
+    change.setTopic("my topic");
+    change.setSubmissionId("submission ID 234");
+    change.setAssignee(Account.id(100001));
+    change.setPrivate(true);
+    change.setWorkInProgress(true);
+    change.setReviewStarted(true);
+    change.setRevertOf(Change.id(180));
+
+    Entities.Change proto = changeProtoConverter.toProto(change);
+
+    Entities.Change expectedProto =
+        Entities.Change.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
+            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
+            .setRowVersion(0)
+            .setCreatedOn(987654L)
+            .setLastUpdatedOn(1234567L)
+            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
+            .setDest(
+                Entities.Branch_NameKey.newBuilder()
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch 74"))
+            .setStatus(Change.STATUS_MERGED)
+            .setCurrentPatchSetId(23)
+            .setSubject("subject XYZ")
+            .setTopic("my topic")
+            .setOriginalSubject("original subject ABC")
+            .setSubmissionId("submission ID 234")
+            .setAssignee(Entities.Account_Id.newBuilder().setId(100001))
+            .setIsPrivate(true)
+            .setWorkInProgress(true)
+            .setReviewStarted(true)
+            .setRevertOf(Entities.Change_Id.newBuilder().setId(180))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    Change change =
+        new Change(
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
+            new Timestamp(987654L));
+
+    Entities.Change proto = changeProtoConverter.toProto(change);
+
+    Entities.Change expectedProto =
+        Entities.Change.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
+            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
+            .setCreatedOn(987654L)
+            // Defaults to createdOn if not set.
+            .setLastUpdatedOn(987654L)
+            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
+            .setDest(
+                Entities.Branch_NameKey.newBuilder()
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch-74"))
+            // Default values which can't be unset.
+            .setCurrentPatchSetId(0)
+            .setRowVersion(0)
+            .setStatus(Change.STATUS_NEW)
+            .setIsPrivate(false)
+            .setWorkInProgress(false)
+            .setReviewStarted(false)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  // This test documents a special behavior which is necessary to ensure binary compatibility.
+  @Test
+  public void currentPatchSetIsAlwaysSetWhenConvertedToProto() {
+    Change change =
+        new Change(
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
+            new Timestamp(987654L));
+    // O as ID actually means that no current patch set is present.
+    change.setCurrentPatchSet(PatchSet.id(Change.id(14), 0), null, null);
+
+    Entities.Change proto = changeProtoConverter.toProto(change);
+
+    Entities.Change expectedProto =
+        Entities.Change.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
+            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
+            .setCreatedOn(987654L)
+            // Defaults to createdOn if not set.
+            .setLastUpdatedOn(987654L)
+            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
+            .setDest(
+                Entities.Branch_NameKey.newBuilder()
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch-74"))
+            .setCurrentPatchSetId(0)
+            // Default values which can't be unset.
+            .setRowVersion(0)
+            .setStatus(Change.STATUS_NEW)
+            .setIsPrivate(false)
+            .setWorkInProgress(false)
+            .setReviewStarted(false)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  // This test documents a special behavior which is necessary to ensure binary compatibility.
+  @Test
+  public void originalSubjectIsNotAutomaticallySetToSubjectWhenConvertedToProto() {
+    Change change =
+        new Change(
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
+            new Timestamp(987654L));
+    change.setCurrentPatchSet(PatchSet.id(Change.id(14), 23), "subject ABC", null);
+
+    Entities.Change proto = changeProtoConverter.toProto(change);
+
+    Entities.Change expectedProto =
+        Entities.Change.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
+            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
+            .setCreatedOn(987654L)
+            // Defaults to createdOn if not set.
+            .setLastUpdatedOn(987654L)
+            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
+            .setDest(
+                Entities.Branch_NameKey.newBuilder()
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch-74"))
+            .setCurrentPatchSetId(23)
+            .setSubject("subject ABC")
+            // Default values which can't be unset.
+            .setRowVersion(0)
+            .setStatus(Change.STATUS_NEW)
+            .setIsPrivate(false)
+            .setWorkInProgress(false)
+            .setReviewStarted(false)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    Change change =
+        new Change(
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
+            new Timestamp(987654L));
+    change.setLastUpdatedOn(new Timestamp(1234567L));
+    change.setStatus(Change.Status.MERGED);
+    change.setCurrentPatchSet(
+        PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
+    change.setTopic("my topic");
+    change.setSubmissionId("submission ID 234");
+    change.setAssignee(Account.id(100001));
+    change.setPrivate(true);
+    change.setWorkInProgress(true);
+    change.setReviewStarted(true);
+    change.setRevertOf(Change.id(180));
+
+    Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
+    assertEqualChange(convertedChange, change);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    Change change =
+        new Change(
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
+            new Timestamp(987654L));
+
+    Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
+    assertEqualChange(convertedChange, change);
+  }
+
+  // We need this special test as some values are only optional in the protobuf definition but can
+  // never be unset in our entity object.
+  @Test
+  public void protoWithOnlyRequiredValuesCanBeConvertedBack() {
+    Entities.Change proto =
+        Entities.Change.newBuilder().setChangeId(Entities.Change_Id.newBuilder().setId(14)).build();
+    Change change = changeProtoConverter.fromProto(proto);
+
+    assertThat(change.getChangeId()).isEqualTo(14);
+    // Values which can't be null according to ReviewDb's column definition but which are optional.
+    assertThat(change.getKey()).isNull();
+    assertThat(change.getOwner()).isNull();
+    assertThat(change.getDest()).isNull();
+    assertThat(change.getCreatedOn()).isEqualTo(new Timestamp(0));
+    assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(0));
+    assertThat(change.getSubject()).isNull();
+    assertThat(change.currentPatchSetId()).isNull();
+    // Default values for unset protobuf fields which can't be unset in the entity object.
+    assertThat(change.getRowVersion()).isEqualTo(0);
+    assertThat(change.isNew()).isTrue();
+    assertThat(change.isPrivate()).isFalse();
+    assertThat(change.isWorkInProgress()).isFalse();
+    assertThat(change.hasReviewStarted()).isFalse();
+  }
+
+  @Test
+  public void unsetLastUpdatedOnIsAutomaticallySetToCreatedOnWhenConvertedBack() {
+    Entities.Change proto =
+        Entities.Change.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
+            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
+            .setCreatedOn(987654L)
+            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
+            .setDest(
+                Entities.Branch_NameKey.newBuilder()
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("branch 74"))
+            .build();
+    Change change = changeProtoConverter.fromProto(proto);
+
+    assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(987654L));
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.Change proto =
+        Entities.Change.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
+            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
+            .setRowVersion(0)
+            .setCreatedOn(987654L)
+            .setLastUpdatedOn(1234567L)
+            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
+            .setDest(
+                Entities.Branch_NameKey.newBuilder()
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("branch 74"))
+            .setStatus(Change.STATUS_MERGED)
+            .setCurrentPatchSetId(23)
+            .setSubject("subject XYZ")
+            .setTopic("my topic")
+            .setOriginalSubject("original subject ABC")
+            .setSubmissionId("submission ID 234")
+            .setAssignee(Entities.Account_Id.newBuilder().setId(100001))
+            .setIsPrivate(true)
+            .setWorkInProgress(true)
+            .setReviewStarted(true)
+            .setRevertOf(Entities.Change_Id.newBuilder().setId(180))
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.Change> parser = changeProtoConverter.getParser();
+    Entities.Change parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(Change.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("changeId", Change.Id.class)
+                .put("changeKey", Change.Key.class)
+                .put("rowVersion", int.class)
+                .put("createdOn", Timestamp.class)
+                .put("lastUpdatedOn", Timestamp.class)
+                .put("owner", Account.Id.class)
+                .put("dest", BranchNameKey.class)
+                .put("status", char.class)
+                .put("currentPatchSetId", int.class)
+                .put("subject", String.class)
+                .put("topic", String.class)
+                .put("originalSubject", String.class)
+                .put("submissionId", String.class)
+                .put("assignee", Account.Id.class)
+                .put("isPrivate", boolean.class)
+                .put("workInProgress", boolean.class)
+                .put("reviewStarted", boolean.class)
+                .put("revertOf", Change.Id.class)
+                .build());
+  }
+
+  // Unfortunately, Change doesn't implement equals(). Remove this method when we switch Change to
+  // an AutoValue.
+  private static void assertEqualChange(Change change, Change expectedChange) {
+    assertThat(change.getChangeId()).isEqualTo(expectedChange.getChangeId());
+    assertThat(change.getKey()).isEqualTo(expectedChange.getKey());
+    assertThat(change.getRowVersion()).isEqualTo(expectedChange.getRowVersion());
+    assertThat(change.getCreatedOn()).isEqualTo(expectedChange.getCreatedOn());
+    assertThat(change.getLastUpdatedOn()).isEqualTo(expectedChange.getLastUpdatedOn());
+    assertThat(change.getOwner()).isEqualTo(expectedChange.getOwner());
+    assertThat(change.getDest()).isEqualTo(expectedChange.getDest());
+    assertThat(change.getStatus()).isEqualTo(expectedChange.getStatus());
+    assertThat(change.currentPatchSetId()).isEqualTo(expectedChange.currentPatchSetId());
+    assertThat(change.getSubject()).isEqualTo(expectedChange.getSubject());
+    assertThat(change.getTopic()).isEqualTo(expectedChange.getTopic());
+    assertThat(change.getOriginalSubject()).isEqualTo(expectedChange.getOriginalSubject());
+    assertThat(change.getSubmissionId()).isEqualTo(expectedChange.getSubmissionId());
+    assertThat(change.getAssignee()).isEqualTo(expectedChange.getAssignee());
+    assertThat(change.isPrivate()).isEqualTo(expectedChange.isPrivate());
+    assertThat(change.isWorkInProgress()).isEqualTo(expectedChange.isWorkInProgress());
+    assertThat(change.hasReviewStarted()).isEqualTo(expectedChange.hasReviewStarted());
+    assertThat(change.getRevertOf()).isEqualTo(expectedChange.getRevertOf());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java
new file mode 100644
index 0000000..a8dd0e2
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.protobuf.Parser;
+import org.junit.Test;
+
+public class LabelIdProtoConverterTest {
+  private final LabelIdProtoConverter labelIdProtoConverter = LabelIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    LabelId labelId = LabelId.create("Label ID 42");
+
+    Entities.LabelId proto = labelIdProtoConverter.toProto(labelId);
+
+    Entities.LabelId expectedProto = Entities.LabelId.newBuilder().setId("Label ID 42").build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    LabelId labelId = LabelId.create("label-5");
+
+    LabelId convertedLabelId =
+        labelIdProtoConverter.fromProto(labelIdProtoConverter.toProto(labelId));
+
+    assertThat(convertedLabelId).isEqualTo(labelId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.LabelId proto = Entities.LabelId.newBuilder().setId("label-23").build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.LabelId> parser = labelIdProtoConverter.getParser();
+    Entities.LabelId parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(LabelId.class)
+        .hasAutoValueMethods(ImmutableMap.of("id", String.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverterTest.java
new file mode 100644
index 0000000..e0dba83
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverterTest.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.protobuf.Parser;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ObjectIdProtoConverterTest {
+  private final ObjectIdProtoConverter objectIdProtoConverter = ObjectIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ObjectId objectId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+    Entities.ObjectId proto = objectIdProtoConverter.toProto(objectId);
+
+    Entities.ObjectId expectedProto =
+        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ObjectId objectId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+    ObjectId convertedObjectId =
+        objectIdProtoConverter.fromProto(objectIdProtoConverter.toProto(objectId));
+
+    assertThat(convertedObjectId).isEqualTo(objectId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.ObjectId proto =
+        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.ObjectId> parser = objectIdProtoConverter.getParser();
+    Entities.ObjectId parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(ObjectId.class)
+        .hasFields(
+            ImmutableMap.of(
+                "w1", int.class,
+                "w2", int.class,
+                "w3", int.class,
+                "w4", int.class,
+                "w5", int.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java
new file mode 100644
index 0000000..5e09e73
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java
@@ -0,0 +1,98 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+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 com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import org.junit.Test;
+
+public class PatchSetApprovalKeyProtoConverterTest {
+  private final PatchSetApprovalKeyProtoConverter protoConverter =
+      PatchSetApprovalKeyProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    PatchSetApproval.Key key =
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8"));
+
+    Entities.PatchSetApproval_Key proto = protoConverter.toProto(key);
+
+    Entities.PatchSetApproval_Key expectedProto =
+        Entities.PatchSetApproval_Key.newBuilder()
+            .setPatchSetId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(42))
+                    .setId(14))
+            .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
+            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    PatchSetApproval.Key key =
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8"));
+
+    PatchSetApproval.Key convertedKey = protoConverter.fromProto(protoConverter.toProto(key));
+
+    assertThat(convertedKey).isEqualTo(key);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.PatchSetApproval_Key proto =
+        Entities.PatchSetApproval_Key.newBuilder()
+            .setPatchSetId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(42))
+                    .setId(14))
+            .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
+            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.PatchSetApproval_Key> parser = protoConverter.getParser();
+    Entities.PatchSetApproval_Key parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(PatchSetApproval.Key.class)
+        .hasAutoValueMethods(
+            ImmutableMap.<String, Type>builder()
+                .put("patchSetId", PatchSet.Id.class)
+                .put("accountId", Account.Id.class)
+                .put("labelId", LabelId.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java
new file mode 100644
index 0000000..acb7d98
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java
@@ -0,0 +1,206 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+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 com.google.inject.TypeLiteral;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.Date;
+import java.util.Optional;
+import org.junit.Test;
+
+public class PatchSetApprovalProtoConverterTest {
+  private final PatchSetApprovalProtoConverter protoConverter =
+      PatchSetApprovalProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    PatchSetApproval patchSetApproval =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .tag("tag-21")
+            .realAccountId(Account.id(612))
+            .postSubmit(true)
+            .build();
+
+    Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
+
+    Entities.PatchSetApproval expectedProto =
+        Entities.PatchSetApproval.newBuilder()
+            .setKey(
+                Entities.PatchSetApproval_Key.newBuilder()
+                    .setPatchSetId(
+                        Entities.PatchSet_Id.newBuilder()
+                            .setChangeId(Entities.Change_Id.newBuilder().setId(42))
+                            .setId(14))
+                    .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
+            .setValue(456)
+            .setGranted(987654L)
+            .setTag("tag-21")
+            .setRealAccountId(Entities.Account_Id.newBuilder().setId(612))
+            .setPostSubmit(true)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    PatchSetApproval patchSetApproval =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .build();
+
+    Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
+
+    Entities.PatchSetApproval expectedProto =
+        Entities.PatchSetApproval.newBuilder()
+            .setKey(
+                Entities.PatchSetApproval_Key.newBuilder()
+                    .setPatchSetId(
+                        Entities.PatchSet_Id.newBuilder()
+                            .setChangeId(Entities.Change_Id.newBuilder().setId(42))
+                            .setId(14))
+                    .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
+            .setValue(456)
+            .setGranted(987654L)
+            // This value can't be unset when our entity class is given.
+            .setPostSubmit(false)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    PatchSetApproval patchSetApproval =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .tag("tag-21")
+            .realAccountId(Account.id(612))
+            .postSubmit(true)
+            .build();
+
+    PatchSetApproval convertedPatchSetApproval =
+        protoConverter.fromProto(protoConverter.toProto(patchSetApproval));
+    assertThat(convertedPatchSetApproval).isEqualTo(patchSetApproval);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    PatchSetApproval patchSetApproval =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .build();
+
+    PatchSetApproval convertedPatchSetApproval =
+        protoConverter.fromProto(protoConverter.toProto(patchSetApproval));
+    assertThat(convertedPatchSetApproval).isEqualTo(patchSetApproval);
+  }
+
+  // We need this special test as some values are only optional in the protobuf definition but can
+  // never be unset in our entity object.
+  @Test
+  public void protoWithOnlyRequiredValuesCanBeConvertedBack() {
+    Entities.PatchSetApproval proto =
+        Entities.PatchSetApproval.newBuilder()
+            .setKey(
+                Entities.PatchSetApproval_Key.newBuilder()
+                    .setPatchSetId(
+                        Entities.PatchSet_Id.newBuilder()
+                            .setChangeId(Entities.Change_Id.newBuilder().setId(42))
+                            .setId(14))
+                    .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
+            .build();
+    PatchSetApproval patchSetApproval = protoConverter.fromProto(proto);
+
+    assertThat(patchSetApproval.patchSetId()).isEqualTo(PatchSet.id(Change.id(42), 14));
+    assertThat(patchSetApproval.accountId()).isEqualTo(Account.id(100013));
+    assertThat(patchSetApproval.labelId()).isEqualTo(LabelId.create("label-8"));
+    // Default values for unset protobuf fields which can't be unset in the entity object.
+    assertThat(patchSetApproval.value()).isEqualTo(0);
+    assertThat(patchSetApproval.granted()).isEqualTo(new Timestamp(0));
+    assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.PatchSetApproval proto =
+        Entities.PatchSetApproval.newBuilder()
+            .setKey(
+                Entities.PatchSetApproval_Key.newBuilder()
+                    .setPatchSetId(
+                        Entities.PatchSet_Id.newBuilder()
+                            .setChangeId(Entities.Change_Id.newBuilder().setId(42))
+                            .setId(14))
+                    .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
+            .setValue(456)
+            .setGranted(987654L)
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.PatchSetApproval> parser = protoConverter.getParser();
+    Entities.PatchSetApproval parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(PatchSetApproval.class)
+        .hasAutoValueMethods(
+            ImmutableMap.<String, Type>builder()
+                .put("key", PatchSetApproval.Key.class)
+                .put("value", short.class)
+                .put("granted", Timestamp.class)
+                .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
+                .put("realAccountId", Account.Id.class)
+                .put("postSubmit", boolean.class)
+                .put("toBuilder", PatchSetApproval.Builder.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java
new file mode 100644
index 0000000..76a290a
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import org.junit.Test;
+
+public class PatchSetIdProtoConverterTest {
+  private final PatchSetIdProtoConverter patchSetIdProtoConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    PatchSet.Id patchSetId = PatchSet.id(Change.id(103), 73);
+
+    Entities.PatchSet_Id proto = patchSetIdProtoConverter.toProto(patchSetId);
+
+    Entities.PatchSet_Id expectedProto =
+        Entities.PatchSet_Id.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+            .setId(73)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    PatchSet.Id patchSetId = PatchSet.id(Change.id(20), 13);
+
+    PatchSet.Id convertedPatchSetId =
+        patchSetIdProtoConverter.fromProto(patchSetIdProtoConverter.toProto(patchSetId));
+
+    assertThat(convertedPatchSetId).isEqualTo(patchSetId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.PatchSet_Id proto =
+        Entities.PatchSet_Id.newBuilder()
+            .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+            .setId(73)
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.PatchSet_Id> parser = patchSetIdProtoConverter.getParser();
+    Entities.PatchSet_Id parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(PatchSet.Id.class)
+        .hasAutoValueMethods(
+            ImmutableMap.<String, Type>builder()
+                .put("changeId", Change.Id.class)
+                .put("id", int.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java
new file mode 100644
index 0000000..ffc6068
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java
@@ -0,0 +1,183 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.truth.Truth;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.inject.TypeLiteral;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class PatchSetProtoConverterTest {
+  private final PatchSetProtoConverter patchSetProtoConverter = PatchSetProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .groups(ImmutableList.of("group1", " group2"))
+            .pushCertificate("my push certificate")
+            .description("This is a patch set description.")
+            .build();
+
+    Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
+
+    Entities.PatchSet expectedProto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .setCommitId(
+                Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .setCreatedOn(930349320L)
+            .setGroups("group1, group2")
+            .setPushCertificate("my push certificate")
+            .setDescription("This is a patch set description.")
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .build();
+
+    Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
+
+    Entities.PatchSet expectedProto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .setCommitId(
+                Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .setCreatedOn(930349320L)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .groups(ImmutableList.of("group1", " group2"))
+            .pushCertificate("my push certificate")
+            .description("This is a patch set description.")
+            .build();
+
+    PatchSet convertedPatchSet =
+        patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
+    Truth.assertThat(convertedPatchSet).isEqualTo(patchSet);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .build();
+
+    PatchSet convertedPatchSet =
+        patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
+    Truth.assertThat(convertedPatchSet).isEqualTo(patchSet);
+  }
+
+  @Test
+  public void previouslyOptionalValuesMayBeMissingFromProto() {
+    Entities.PatchSet proto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .build();
+
+    PatchSet convertedPatchSet = patchSetProtoConverter.fromProto(proto);
+    Truth.assertThat(convertedPatchSet)
+        .isEqualTo(
+            PatchSet.builder()
+                .id(PatchSet.id(Change.id(103), 73))
+                .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
+                .uploader(Account.id(0))
+                .createdOn(new Timestamp(0))
+                .build());
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.PatchSet proto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.PatchSet> parser = patchSetProtoConverter.getParser();
+    Entities.PatchSet parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(PatchSet.class)
+        .hasAutoValueMethods(
+            ImmutableMap.<String, Type>builder()
+                .put("id", PatchSet.Id.class)
+                .put("commitId", ObjectId.class)
+                .put("uploader", Account.Id.class)
+                .put("createdOn", Timestamp.class)
+                .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
+                .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
+                .put("description", new TypeLiteral<Optional<String>>() {}.getType())
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.java
new file mode 100644
index 0000000..05e2893
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.protobuf.Parser;
+import org.junit.Test;
+
+public class ProjectNameKeyProtoConverterTest {
+  private final ProjectNameKeyProtoConverter projectNameKeyProtoConverter =
+      ProjectNameKeyProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    Project.NameKey nameKey = Project.nameKey("project-72");
+
+    Entities.Project_NameKey proto = projectNameKeyProtoConverter.toProto(nameKey);
+
+    Entities.Project_NameKey expectedProto =
+        Entities.Project_NameKey.newBuilder().setName("project-72").build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    Project.NameKey nameKey = Project.nameKey("project-52");
+
+    Project.NameKey convertedNameKey =
+        projectNameKeyProtoConverter.fromProto(projectNameKeyProtoConverter.toProto(nameKey));
+
+    assertThat(convertedNameKey).isEqualTo(nameKey);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.Project_NameKey proto =
+        Entities.Project_NameKey.newBuilder().setName("project 36").build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.Project_NameKey> parser = projectNameKeyProtoConverter.getParser();
+    Entities.Project_NameKey parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(Project.NameKey.class)
+        .hasFields(ImmutableMap.of("name", String.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 3113a8a..f6ed5ef 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -6,7 +6,7 @@
 
 java_library(
     name = "custom-truth-subjects",
-    testonly = 1,
+    testonly = True,
     srcs = CUSTOM_TRUTH_SUBJECTS,
     deps = [
         "//java/com/google/gerrit/extensions:api",
@@ -30,39 +30,53 @@
     visibility = ["//visibility:public"],
     runtime_deps = [
         "//lib/bouncycastle:bcprov",
+        "//prolog:gerrit-prolog-common",
     ],
     deps = [
         ":custom-truth-subjects",
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/jgit",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/account/externalids/testing",
+        "//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/server/schema/testing",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:assertable-executor",
         "//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",
-        "//lib:gwtorm",
         "//lib:protobuf",
         "//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",
+        "//lib/mockito",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
         "//lib/truth:truth-proto-extension",
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 1a76dac..eaddbff 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.inject.Scopes.SINGLETON;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.FakeRealm;
@@ -26,9 +25,10 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
+import com.google.gerrit.server.config.EnableReverseDnsLookup;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeAccountCache;
 import com.google.inject.AbstractModule;
@@ -80,8 +80,8 @@
           @Override
           protected void configure() {
             bind(Boolean.class)
-                .annotatedWith(DisableReverseDnsLookup.class)
-                .toInstance(Boolean.FALSE);
+                .annotatedWith(EnableReverseDnsLookup.class)
+                .toInstance(Boolean.TRUE);
             bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(config);
             bind(String.class)
                 .annotatedWith(AnonymousCowardName.class)
@@ -98,8 +98,11 @@
     Injector injector = Guice.createInjector(mod);
     injector.injectMembers(this);
 
-    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
-    Account.Id ownerId = account.getId();
+    Account account =
+        Account.builder(Account.id(1), TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build();
+    Account.Id ownerId = account.id();
 
     identifiedUser = identifiedUserFactory.create(ownerId);
 
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
new file mode 100644
index 0000000..14e6876
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -0,0 +1,362 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountResolver.Result;
+import com.google.gerrit.server.account.AccountResolver.Searcher;
+import com.google.gerrit.server.account.AccountResolver.StringSearcher;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.util.Arrays;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+import org.junit.Test;
+
+public class AccountResolverTest {
+  private static class TestSearcher extends StringSearcher {
+    private final String pattern;
+    private final boolean shortCircuit;
+    private final ImmutableList<AccountState> accounts;
+    private boolean assumeVisible;
+    private boolean filterInactive;
+
+    private TestSearcher(String pattern, boolean shortCircuit, AccountState... accounts) {
+      this.pattern = pattern;
+      this.shortCircuit = shortCircuit;
+      this.accounts = ImmutableList.copyOf(accounts);
+    }
+
+    @Override
+    protected boolean matches(String input) {
+      return input.matches(pattern);
+    }
+
+    @Override
+    public Stream<AccountState> search(String input) {
+      return accounts.stream();
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return shortCircuit;
+    }
+
+    @Override
+    public boolean callerMayAssumeCandidatesAreVisible() {
+      return assumeVisible;
+    }
+
+    void setCallerMayAssumeCandidatesAreVisible() {
+      this.assumeVisible = true;
+    }
+
+    @Override
+    public boolean callerShouldFilterOutInactiveCandidates() {
+      return filterInactive;
+    }
+
+    void setCallerShouldFilterOutInactiveCandidates() {
+      this.filterInactive = true;
+    }
+
+    @Override
+    public String toString() {
+      return accounts.stream()
+          .map(a -> a.getAccount().id().toString())
+          .collect(joining(",", pattern + "(", ")"));
+    }
+  }
+
+  @Test
+  public void noShortCircuit() throws Exception {
+    ImmutableList<Searcher<?>> searchers =
+        ImmutableList.of(
+            new TestSearcher("foo", false, newAccount(1)),
+            new TestSearcher("bar", false, newAccount(2), newAccount(3)));
+
+    Result result = search("foo", searchers, allVisible());
+    assertThat(result.input()).isEqualTo("foo");
+    assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
+
+    result = search("bar", searchers, allVisible());
+    assertThat(result.input()).isEqualTo("bar");
+    assertThat(result.asIdSet()).containsExactlyElementsIn(ids(2, 3));
+
+    result = search("baz", searchers, allVisible());
+    assertThat(result.input()).isEqualTo("baz");
+    assertThat(result.asIdSet()).isEmpty();
+  }
+
+  @Test
+  public void shortCircuit() throws Exception {
+    ImmutableList<Searcher<?>> searchers =
+        ImmutableList.of(
+            new TestSearcher("f.*", true), new TestSearcher("foo|bar", false, newAccount(1)));
+
+    Result result = search("foo", searchers, allVisible());
+    assertThat(result.input()).isEqualTo("foo");
+    assertThat(result.asIdSet()).isEmpty();
+
+    result = search("bar", searchers, allVisible());
+    assertThat(result.input()).isEqualTo("bar");
+    assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
+  }
+
+  @Test
+  public void filterInvisible() throws Exception {
+    ImmutableList<Searcher<?>> searchers =
+        ImmutableList.of(new TestSearcher("foo", false, newAccount(1), newAccount(2)));
+
+    assertThat(search("foo", searchers, allVisible()).asIdSet())
+        .containsExactlyElementsIn(ids(1, 2));
+    assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(2));
+  }
+
+  @Test
+  public void skipVisibilityCheck() throws Exception {
+    TestSearcher searcher = new TestSearcher("foo", false, newAccount(1), newAccount(2));
+    ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher);
+
+    assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(2));
+
+    searcher.setCallerMayAssumeCandidatesAreVisible();
+    assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(1, 2));
+  }
+
+  @Test
+  public void dontFilterInactive() throws Exception {
+    ImmutableList<Searcher<?>> searchers =
+        ImmutableList.of(
+            new TestSearcher("foo", false, newInactiveAccount(1)),
+            new TestSearcher("f.*", false, newInactiveAccount(2)));
+
+    Result result = search("foo", searchers, allVisible());
+    // Searchers always short-circuit when finding a non-empty result list, and this one didn't
+    // filter out inactive results, so the second searcher never ran.
+    assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
+    assertThat(getOnlyElement(result.asList()).getAccount().isActive()).isFalse();
+    assertThat(filteredInactiveIds(result)).isEmpty();
+  }
+
+  @Test
+  public void filterInactiveEventuallyFindingResults() throws Exception {
+    TestSearcher searcher1 = new TestSearcher("foo", false, newInactiveAccount(1));
+    searcher1.setCallerShouldFilterOutInactiveCandidates();
+    TestSearcher searcher2 = new TestSearcher("f.*", false, newAccount(2));
+    searcher2.setCallerShouldFilterOutInactiveCandidates();
+    ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
+
+    Result result = search("foo", searchers, allVisible());
+    assertThat(search("foo", searchers, allVisible()).asIdSet()).containsExactlyElementsIn(ids(2));
+    // No info about inactive results exposed if there was at least one active result.
+    assertThat(filteredInactiveIds(result)).isEmpty();
+  }
+
+  @Test
+  public void filterInactiveEventuallyFindingNoResults() throws Exception {
+    TestSearcher searcher1 = new TestSearcher("foo", false, newInactiveAccount(1));
+    searcher1.setCallerShouldFilterOutInactiveCandidates();
+    TestSearcher searcher2 = new TestSearcher("f.*", false, newInactiveAccount(2));
+    searcher2.setCallerShouldFilterOutInactiveCandidates();
+    ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
+
+    Result result = search("foo", searchers, allVisible());
+    assertThat(result.asIdSet()).isEmpty();
+    assertThat(filteredInactiveIds(result)).containsExactlyElementsIn(ids(1, 2));
+  }
+
+  @Test
+  public void dontShortCircuitAfterFilteringInactiveCandidatesResultsInEmptyList()
+      throws Exception {
+    AccountState account1 = newAccount(1);
+    AccountState account2 = newInactiveAccount(2);
+    TestSearcher searcher1 = new TestSearcher("foo", false, account2);
+    searcher1.setCallerShouldFilterOutInactiveCandidates();
+
+    TestSearcher searcher2 = new TestSearcher("foo", false, account1, account2);
+    ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
+
+    // searcher1 matched, but filtered out all candidates because account2 is inactive. Actual
+    // result came from searcher2 instead.
+    Result result = search("foo", searchers, allVisible());
+    assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1, 2));
+  }
+
+  @Test
+  public void shortCircuitAfterFilteringInactiveCandidatesResultsInEmptyList() throws Exception {
+    AccountState account1 = newAccount(1);
+    AccountState account2 = newInactiveAccount(2);
+    TestSearcher searcher1 = new TestSearcher("foo", true, account2);
+    searcher1.setCallerShouldFilterOutInactiveCandidates();
+
+    TestSearcher searcher2 = new TestSearcher("foo", false, account1, account2);
+    ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
+
+    // searcher1 matched and then filtered out all candidates because account2 is inactive, but
+    // still short-circuited.
+    Result result = search("foo", searchers, allVisible());
+    assertThat(result.asIdSet()).isEmpty();
+    assertThat(filteredInactiveIds(result)).containsExactlyElementsIn(ids(2));
+  }
+
+  @Test
+  public void asUniqueWithNoResults() throws Exception {
+    String input = "foo";
+    ImmutableList<Searcher<?>> searchers = ImmutableList.of();
+    Supplier<Predicate<AccountState>> visibilitySupplier = allVisible();
+    UnresolvableAccountException thrown =
+        assertThrows(
+            UnresolvableAccountException.class,
+            () -> search(input, searchers, visibilitySupplier).asUnique());
+    assertThat(thrown).hasMessageThat().isEqualTo("Account 'foo' not found");
+  }
+
+  @Test
+  public void asUniqueWithOneResult() throws Exception {
+    AccountState account = newAccount(1);
+    ImmutableList<Searcher<?>> searchers =
+        ImmutableList.of(new TestSearcher("foo", false, account));
+    assertThat(search("foo", searchers, allVisible()).asUnique().getAccount().id())
+        .isEqualTo(account.getAccount().id());
+  }
+
+  @Test
+  public void asUniqueWithMultipleResults() throws Exception {
+    ImmutableList<Searcher<?>> searchers =
+        ImmutableList.of(new TestSearcher("foo", false, newAccount(1), newAccount(2)));
+    UnresolvableAccountException thrown =
+        assertThrows(
+            UnresolvableAccountException.class,
+            () -> search("foo", searchers, allVisible()).asUnique());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Account 'foo' is ambiguous:\n1: Anonymous Name (1)\n2: Anonymous Name (2)");
+  }
+
+  @Test
+  public void exceptionMessageNotFound() throws Exception {
+    AccountResolver resolver = newAccountResolver();
+    assertThat(
+            new UnresolvableAccountException(
+                resolver.new Result("foo", ImmutableList.of(), ImmutableList.of())))
+        .hasMessageThat()
+        .isEqualTo("Account 'foo' not found");
+  }
+
+  @Test
+  public void exceptionMessageSelf() throws Exception {
+    AccountResolver resolver = newAccountResolver();
+    UnresolvableAccountException e =
+        new UnresolvableAccountException(
+            resolver.new Result("self", ImmutableList.of(), ImmutableList.of()));
+    assertThat(e.isSelf()).isTrue();
+    assertThat(e).hasMessageThat().isEqualTo("Resolving account 'self' requires login");
+  }
+
+  @Test
+  public void exceptionMessageMe() throws Exception {
+    AccountResolver resolver = newAccountResolver();
+    UnresolvableAccountException e =
+        new UnresolvableAccountException(
+            resolver.new Result("me", ImmutableList.of(), ImmutableList.of()));
+    assertThat(e.isSelf()).isTrue();
+    assertThat(e).hasMessageThat().isEqualTo("Resolving account 'me' requires login");
+  }
+
+  @Test
+  public void exceptionMessageAmbiguous() throws Exception {
+    AccountResolver resolver = newAccountResolver();
+    assertThat(
+            new UnresolvableAccountException(
+                resolver
+                .new Result(
+                    "foo", ImmutableList.of(newAccount(3), newAccount(1)), ImmutableList.of())))
+        .hasMessageThat()
+        .isEqualTo("Account 'foo' is ambiguous:\n1: Anonymous Name (1)\n3: Anonymous Name (3)");
+  }
+
+  @Test
+  public void exceptionMessageOnlyInactive() throws Exception {
+    AccountResolver resolver = newAccountResolver();
+    assertThat(
+            new UnresolvableAccountException(
+                resolver
+                .new Result(
+                    "foo",
+                    ImmutableList.of(),
+                    ImmutableList.of(newInactiveAccount(3), newInactiveAccount(1)))))
+        .hasMessageThat()
+        .isEqualTo(
+            "Account 'foo' only matches inactive accounts. To use an inactive account, retry"
+                + " with one of the following exact account IDs:\n"
+                + "1: Anonymous Name (1)\n"
+                + "3: Anonymous Name (3)");
+  }
+
+  private Result search(
+      String input,
+      ImmutableList<Searcher<?>> searchers,
+      Supplier<Predicate<AccountState>> visibilitySupplier)
+      throws Exception {
+    return newAccountResolver().searchImpl(input, searchers, visibilitySupplier);
+  }
+
+  private static AccountResolver newAccountResolver() {
+    return new AccountResolver(null, null, null, null, null, null, null, "Anonymous Name");
+  }
+
+  private AccountState newAccount(int id) {
+    return AccountState.forAccount(
+        Account.builder(Account.id(id), TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build());
+  }
+
+  private AccountState newInactiveAccount(int id) {
+    Account.Builder a = Account.builder(Account.id(id), TimeUtil.nowTs());
+    a.setActive(false);
+    return AccountState.forAccount(a.build());
+  }
+
+  private static ImmutableSet<Account.Id> ids(int... ids) {
+    return Arrays.stream(ids).mapToObj(Account::id).collect(toImmutableSet());
+  }
+
+  private static Supplier<Predicate<AccountState>> allVisible() {
+    return () -> a -> true;
+  }
+
+  private static Supplier<Predicate<AccountState>> only(int... ids) {
+    ImmutableSet<Account.Id> idSet =
+        Arrays.stream(ids).mapToObj(Account::id).collect(toImmutableSet());
+    return () -> a -> idSet.contains(a.getAccount().id());
+  }
+
+  private static ImmutableSet<Account.Id> filteredInactiveIds(Result result) {
+    return result.filteredInactive().stream()
+        .map(a -> a.getAccount().id())
+        .collect(toImmutableSet());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
index 80a15a3..e85c575 100644
--- a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
+++ b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -54,7 +54,7 @@
           + "zRuEL5e/QOu9yGq9xkWApCmg6edpWAHG+Bx4AldU78MiZvzoB7gMMdxc9RmZ1gYj/DjxV"
           + "w== john.doe@example.com";
 
-  private final Account.Id accountId = new Account.Id(1);
+  private final Account.Id accountId = Account.id(1);
 
   @Test
   public void test() throws Exception {
@@ -140,7 +140,7 @@
   }
 
   private static String toWindowsLineEndings(String s) {
-    return s.replaceAll("\n", "\r\n");
+    return s.replace("\n", "\r\n");
   }
 
   private static void assertSerialization(
@@ -150,7 +150,7 @@
 
   private static void assertParse(
       StringBuilder authorizedKeys, List<Optional<AccountSshKey>> expectedKeys) {
-    Account.Id accountId = new Account.Id(1);
+    Account.Id accountId = Account.id(1);
     List<Optional<AccountSshKey>> parsedKeys =
         AuthorizedKeys.parse(accountId, authorizedKeys.toString());
     assertThat(parsedKeys).containsExactlyElementsIn(expectedKeys);
@@ -170,7 +170,7 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addKey(List<Optional<AccountSshKey>> keys, String pub) {
-    AccountSshKey key = AccountSshKey.create(new Account.Id(1), keys.size() + 1, pub);
+    AccountSshKey key = AccountSshKey.create(Account.id(1), keys.size() + 1, pub);
     keys.add(Optional.of(key));
     return key.sshPublicKey() + "\n";
   }
@@ -181,7 +181,7 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addInvalidKey(List<Optional<AccountSshKey>> keys, String pub) {
-    AccountSshKey key = AccountSshKey.createInvalid(new Account.Id(1), keys.size() + 1, pub);
+    AccountSshKey key = AccountSshKey.createInvalid(Account.id(1), keys.size() + 1, pub);
     keys.add(Optional.of(key));
     return AuthorizedKeys.INVALID_KEY_COMMENT_PREFIX + key.sshPublicKey() + "\n";
   }
diff --git a/javatests/com/google/gerrit/server/account/DestinationListTest.java b/javatests/com/google/gerrit/server/account/DestinationListTest.java
index 1f6ed60..4bef44a 100644
--- a/javatests/com/google/gerrit/server/account/DestinationListTest.java
+++ b/javatests/com/google/gerrit/server/account/DestinationListTest.java
@@ -15,19 +15,18 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.replay;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
-import junit.framework.TestCase;
 import org.junit.Test;
 
-public class DestinationListTest extends TestCase {
+public class DestinationListTest {
   public static final String R_FOO = "refs/heads/foo";
   public static final String R_BAR = "refs/heads/bar";
 
@@ -55,11 +54,11 @@
   public static final String LABEL = "label";
   public static final String LABEL2 = "another";
 
-  public static final Branch.NameKey B_FOO = dest(P_MY, R_FOO);
-  public static final Branch.NameKey B_BAR = dest(P_SLASH, R_BAR);
-  public static final Branch.NameKey B_COMPLEX = dest(P_COMPLEX, R_FOO);
+  public static final BranchNameKey B_FOO = dest(P_MY, R_FOO);
+  public static final BranchNameKey B_BAR = dest(P_SLASH, R_BAR);
+  public static final BranchNameKey B_COMPLEX = dest(P_COMPLEX, R_FOO);
 
-  public static final Set<Branch.NameKey> D_SIMPLE = new HashSet<>();
+  public static final Set<BranchNameKey> D_SIMPLE = new HashSet<>();
 
   static {
     D_SIMPLE.clear();
@@ -67,15 +66,15 @@
     D_SIMPLE.add(B_BAR);
   }
 
-  private static Branch.NameKey dest(String project, String ref) {
-    return new Branch.NameKey(new Project.NameKey(project), ref);
+  private static BranchNameKey dest(String project, String ref) {
+    return BranchNameKey.create(Project.nameKey(project), ref);
   }
 
   @Test
   public void testParseSimple() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_SIMPLE, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -83,7 +82,7 @@
   public void testParseWHeader() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, HEADER + F_SIMPLE, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -91,7 +90,7 @@
   public void testParseWComments() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, C1 + F_SIMPLE + C2, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -99,7 +98,7 @@
   public void testParseFooComment() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, "#" + L_FOO + L_BAR, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).doesNotContain(B_FOO);
     assertThat(branches).contains(B_BAR);
   }
@@ -108,7 +107,7 @@
   public void testParsePaddedFronts() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_PAD_F, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -116,7 +115,7 @@
   public void testParsePaddedEnds() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_PAD_E, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -124,22 +123,23 @@
   public void testParseComplex() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, L_COMPLEX, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).contains(B_COMPLEX);
   }
 
-  @Test(expected = IOException.class)
+  @Test
   public void testParseBad() throws IOException {
-    ValidationError.Sink sink = createNiceMock(ValidationError.Sink.class);
-    replay(sink);
-    new DestinationList().parseLabel(LABEL, L_BAD, sink);
+    List<ValidationError> errors = new ArrayList<>();
+    new DestinationList().parseLabel(LABEL, L_BAD, errors::add);
+    assertThat(errors)
+        .containsExactly(new ValidationError("destinationslabel", 1, "missing tab delimiter"));
   }
 
   @Test
   public void testParse2Labels() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_SIMPLE, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
 
     dl.parseLabel(LABEL2, L_COMPLEX, null);
diff --git a/javatests/com/google/gerrit/server/account/HashedPasswordTest.java b/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
index 4955c06..82943af 100644
--- a/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
+++ b/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.base.Strings;
 import org.apache.commons.codec.DecoderException;
@@ -40,9 +41,9 @@
     assertThat(roundtrip.checkPassword("not the password")).isFalse();
   }
 
-  @Test(expected = DecoderException.class)
+  @Test
   public void invalidDecode() throws Exception {
-    HashedPassword.decode("invalid");
+    assertThrows(DecoderException.class, () -> HashedPassword.decode("invalid"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/QueryListTest.java b/javatests/com/google/gerrit/server/account/QueryListTest.java
index 2792de8..7d491c9 100644
--- a/javatests/com/google/gerrit/server/account/QueryListTest.java
+++ b/javatests/com/google/gerrit/server/account/QueryListTest.java
@@ -15,15 +15,13 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.replay;
 
 import com.google.gerrit.server.git.ValidationError;
-import java.io.IOException;
-import junit.framework.TestCase;
+import java.util.ArrayList;
+import java.util.List;
 import org.junit.Test;
 
-public class QueryListTest extends TestCase {
+public class QueryListTest {
   public static final String Q_P = "project:foo";
   public static final String Q_B = "branch:bar";
   public static final String Q_COMPLEX = "branch:bar AND peers:'is:open\t'";
@@ -99,11 +97,11 @@
     assertThat(ql.getQuery(N_FOO)).isEqualTo(Q_COMPLEX);
   }
 
-  @Test(expected = IOException.class)
+  @Test
   public void testParseBad() throws Exception {
-    ValidationError.Sink sink = createNiceMock(ValidationError.Sink.class);
-    replay(sink);
-    QueryList.parse(L_BAD, sink);
+    List<ValidationError> errors = new ArrayList<>();
+    assertThat(QueryList.parse(L_BAD, errors::add).asText()).isNull();
+    assertThat(errors).containsExactly(new ValidationError("queries", 1, "missing tab delimiter"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 91cc2b7..8bac910 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -35,15 +35,15 @@
 import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import java.util.Set;
-import org.easymock.IAnswer;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
-public class UniversalGroupBackendTest extends GerritBaseTests {
-  private static final AccountGroup.UUID OTHER_UUID = new AccountGroup.UUID("other");
+public class UniversalGroupBackendTest {
+  private static final AccountGroup.UUID OTHER_UUID = AccountGroup.uuid("other");
 
   private UniversalGroupBackend backend;
   private IdentifiedUser user;
@@ -55,8 +55,10 @@
     user = createNiceMock(IdentifiedUser.class);
     replay(user);
     backends = new DynamicSet<>();
-    backends.add(new SystemGroupBackend(new Config()));
-    backend = new UniversalGroupBackend(backends);
+    backends.add("gerrit", new SystemGroupBackend(new Config()));
+    backend =
+        new UniversalGroupBackend(
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
   }
 
   @Test
@@ -99,8 +101,8 @@
 
   @Test
   public void otherMemberships() {
-    final AccountGroup.UUID handled = new AccountGroup.UUID("handled");
-    final AccountGroup.UUID notHandled = new AccountGroup.UUID("not handled");
+    final AccountGroup.UUID handled = AccountGroup.uuid("handled");
+    final AccountGroup.UUID notHandled = AccountGroup.uuid("not handled");
     final IdentifiedUser member = createNiceMock(IdentifiedUser.class);
     final IdentifiedUser notMember = createNiceMock(IdentifiedUser.class);
 
@@ -109,22 +111,21 @@
     expect(backend.handles(not(eq(handled)))).andStubReturn(false);
     expect(backend.membershipsOf(anyObject(IdentifiedUser.class)))
         .andStubAnswer(
-            new IAnswer<GroupMembership>() {
-              @Override
-              public GroupMembership answer() throws Throwable {
-                Object[] args = getCurrentArguments();
-                GroupMembership membership = createMock(GroupMembership.class);
-                expect(membership.contains(eq(handled))).andStubReturn(args[0] == member);
-                expect(membership.contains(not(eq(notHandled)))).andStubReturn(false);
-                replay(membership);
-                return membership;
-              }
+            () -> {
+              Object[] args = getCurrentArguments();
+              GroupMembership membership = createMock(GroupMembership.class);
+              expect(membership.contains(eq(handled))).andStubReturn(args[0] == member);
+              expect(membership.contains(not(eq(notHandled)))).andStubReturn(false);
+              replay(membership);
+              return membership;
             });
     replay(member, notMember, backend);
 
     backends = new DynamicSet<>();
-    backends.add(backend);
-    backend = new UniversalGroupBackend(backends);
+    backends.add("gerrit", backend);
+    backend =
+        new UniversalGroupBackend(
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
 
     GroupMembership checker = backend.membershipsOf(member);
     assertFalse(checker.contains(REGISTERED_USERS));
diff --git a/javatests/com/google/gerrit/server/account/WatchConfigTest.java b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
index 2ac7be7..ef2d3be 100644
--- a/javatests/com/google/gerrit/server/account/WatchConfigTest.java
+++ b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Account;
@@ -54,12 +55,12 @@
             + "  notify = [NEW_PATCHSETS]\n"
             + "  notify = * [NEW_PATCHSETS, ALL_COMMENTS]\n");
     Map<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
-        ProjectWatches.parse(new Account.Id(1000000), cfg, this);
+        ProjectWatches.parse(Account.id(1000000), cfg, this);
 
     assertThat(validationErrors).isEmpty();
 
-    Project.NameKey myProject = new Project.NameKey("myProject");
-    Project.NameKey otherProject = new Project.NameKey("otherProject");
+    Project.NameKey myProject = Project.nameKey("myProject");
+    Project.NameKey otherProject = Project.nameKey("otherProject");
     Map<ProjectWatchKey, Set<NotifyType>> expectedProjectWatches = new HashMap<>();
     expectedProjectWatches.put(
         ProjectWatchKey.create(myProject, null),
@@ -87,7 +88,7 @@
             + "[project \"otherProject\"]\n"
             + "  notify = [NEW_PATCHSETS]\n");
 
-    ProjectWatches.parse(new Account.Id(1000000), cfg, this);
+    ProjectWatches.parse(Account.id(1000000), cfg, this);
     assertThat(validationErrors).hasSize(1);
     assertThat(validationErrors.get(0).getMessage())
         .isEqualTo(
@@ -170,14 +171,14 @@
   private void assertParseNotifyValueFails(String notifyValue) {
     assertThat(validationErrors).isEmpty();
     parseNotifyValue(notifyValue);
-    assertThat(validationErrors)
-        .named("expected validation error for notifyValue: " + notifyValue)
+    assertWithMessage("expected validation error for notifyValue: " + notifyValue)
+        .that(validationErrors)
         .isNotEmpty();
     validationErrors.clear();
   }
 
   private NotifyValue parseNotifyValue(String notifyValue) {
-    return NotifyValue.parse(new Account.Id(1000000), "project", notifyValue, this);
+    return NotifyValue.parse(Account.id(1000000), "project", notifyValue, this);
   }
 
   @Override
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..8487de4
--- /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.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
+
+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 = Account.id(1001);
+    Account.Id accountId2 = 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(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", 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", 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/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
new file mode 100644
index 0000000..9cd8023
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -0,0 +1,307 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil;
+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.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.util.function.Consumer;
+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.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ExternalIDCacheLoaderTest {
+  private static AllUsersName ALL_USERS = new AllUsersName(AllUsersNameProvider.DEFAULT);
+
+  @Mock Cache<ObjectId, AllExternalIds> externalIdCache;
+
+  private ExternalIdCacheLoader loader;
+  private GitRepositoryManager repoManager = new InMemoryRepositoryManager();
+  private ExternalIdReader externalIdReader;
+  private ExternalIdReader externalIdReaderSpy;
+
+  @Before
+  public void setUp() throws Exception {
+    repoManager.createRepository(ALL_USERS).close();
+    externalIdReader = new ExternalIdReader(repoManager, ALL_USERS, new DisabledMetricMaker());
+    externalIdReaderSpy = Mockito.spy(externalIdReader);
+    loader = createLoader(true);
+  }
+
+  @Test
+  public void worksOnSingleCommit() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    assertThat(loader.load(firstState)).isEqualTo(allFromGit(firstState));
+    verify(externalIdReaderSpy, times(1)).all(firstState);
+  }
+
+  @Test
+  public void reloadsSingleUpdateUsingPartialReload() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head = insertExternalId(2, 2);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void reloadsMultipleUpdatesUsingPartialReload() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    insertExternalId(2, 2);
+    insertExternalId(3, 3);
+    ObjectId head = insertExternalId(4, 4);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void reloadsAllExternalIdsWhenNoOldStateIsCached() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head = insertExternalId(2, 2);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(null);
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verify(externalIdReaderSpy, times(1)).all(head);
+  }
+
+  @Test
+  public void partialReloadingDisabledAlwaysTriggersFullReload() throws Exception {
+    loader = createLoader(false);
+    insertExternalId(1, 1);
+    ObjectId head = insertExternalId(2, 2);
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verify(externalIdReaderSpy, times(1)).all(head);
+  }
+
+  @Test
+  public void fallsBackToFullReloadOnManyUpdatesOnBranch() throws Exception {
+    insertExternalId(1, 1);
+    ObjectId head = null;
+    for (int i = 2; i < 20; i++) {
+      head = insertExternalId(i, i);
+    }
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verify(externalIdReaderSpy, times(1)).all(head);
+  }
+
+  @Test
+  public void doesFullReloadWhenNoCacheStateIsFound() throws Exception {
+    ObjectId head = insertExternalId(1, 1);
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verify(externalIdReaderSpy, times(1)).all(head);
+  }
+
+  @Test
+  public void handlesDeletionInPartialReload() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head = deleteExternalId(1, 1);
+    assertThat(allFromGit(head).byAccount().size()).isEqualTo(0);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void handlesModifyInPartialReload() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head =
+        modifyExternalId(
+            externalId(1, 1),
+            ExternalId.create("fooschema", "bar1", Account.id(1), "foo@bar.com", "password"));
+    assertThat(allFromGit(head).byAccount().size()).isEqualTo(1);
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void ignoresInvalidExternalId() throws Exception {
+    ObjectId firstState = insertExternalId(1, 1);
+    ObjectId head;
+    try (Repository repo = repoManager.openRepository(ALL_USERS);
+        RevWalk rw = new RevWalk(repo)) {
+      ExternalIdTestUtil.insertExternalIdWithKeyThatDoesntMatchNoteId(
+          repo, rw, new PersonIdent("foo", "foo@bar.com"), Account.id(2), "test");
+      head = repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
+    }
+
+    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void handlesTreePrefixesInDifferentialReload() throws Exception {
+    // Create more than 256 notes (NoteMap's current sharding limit) and check that we really have
+    // created a situation where NoteNames are sharded.
+    ObjectId oldState = inserExternalIds(257);
+    assertAllFilesHaveSlashesInPath();
+    ObjectId head = insertExternalId(500, 500);
+
+    when(externalIdCache.getIfPresent(oldState)).thenReturn(allFromGit(oldState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void handlesReshard() throws Exception {
+    // Create 256 notes (NoteMap's current sharding limit) and check that we are not yet sharding
+    ObjectId oldState = inserExternalIds(256);
+    assertNoFilesHaveSlashesInPath();
+    // Create one more external ID and then have the Loader compute the new state
+    ObjectId head = insertExternalId(500, 500);
+    assertAllFilesHaveSlashesInPath(); // NoteMap resharded
+
+    when(externalIdCache.getIfPresent(oldState)).thenReturn(allFromGit(oldState));
+
+    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
+    verifyZeroInteractions(externalIdReaderSpy);
+  }
+
+  private ExternalIdCacheLoader createLoader(boolean allowPartial) {
+    Config cfg = new Config();
+    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", allowPartial);
+    return new ExternalIdCacheLoader(
+        repoManager,
+        ALL_USERS,
+        externalIdReaderSpy,
+        Providers.of(externalIdCache),
+        new DisabledMetricMaker(),
+        cfg);
+  }
+
+  private AllExternalIds allFromGit(ObjectId revision) throws Exception {
+    return AllExternalIds.create(externalIdReader.all(revision));
+  }
+
+  private ObjectId inserExternalIds(int numberOfIdsToInsert) throws Exception {
+    ObjectId oldState = null;
+    // Create more than 256 notes (NoteMap's current sharding limit) and check that we really have
+    // created a situation where NoteNames are sharded.
+    for (int i = 0; i < numberOfIdsToInsert; i++) {
+      oldState = insertExternalId(i, i);
+    }
+    return oldState;
+  }
+
+  private ObjectId insertExternalId(int key, int accountId) throws Exception {
+    return performExternalIdUpdate(
+        u -> {
+          try {
+            u.insert(externalId(key, accountId));
+          } catch (IOException e) {
+            throw new RuntimeException(e);
+          }
+        });
+  }
+
+  private ObjectId modifyExternalId(ExternalId oldId, ExternalId newId) throws Exception {
+    return performExternalIdUpdate(
+        u -> {
+          try {
+            u.replace(oldId, newId);
+          } catch (IOException e) {
+            throw new RuntimeException(e);
+          }
+        });
+  }
+
+  private ObjectId deleteExternalId(int key, int accountId) throws Exception {
+    return performExternalIdUpdate(u -> u.delete(externalId(key, accountId)));
+  }
+
+  private ExternalId externalId(int key, int accountId) {
+    return ExternalId.create("fooschema", "bar" + key, Account.id(accountId));
+  }
+
+  private ObjectId performExternalIdUpdate(Consumer<ExternalIdNotes> update) throws Exception {
+    try (Repository repo = repoManager.openRepository(ALL_USERS)) {
+      PersonIdent updater = new PersonIdent("Foo bar", "foo@bar.com");
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(ALL_USERS, repo);
+      update.accept(extIdNotes);
+      try (MetaDataUpdate metaDataUpdate =
+          new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
+        metaDataUpdate.getCommitBuilder().setAuthor(updater);
+        metaDataUpdate.getCommitBuilder().setCommitter(updater);
+        return extIdNotes.commit(metaDataUpdate).getId();
+      }
+    }
+  }
+
+  private void assertAllFilesHaveSlashesInPath() throws Exception {
+    assertThat(allFilesInExternalIdRef().stream().allMatch(f -> f.contains("/"))).isTrue();
+  }
+
+  private void assertNoFilesHaveSlashesInPath() throws Exception {
+    assertThat(allFilesInExternalIdRef().stream().noneMatch(f -> f.contains("/"))).isTrue();
+  }
+
+  private ImmutableList<String> allFilesInExternalIdRef() throws Exception {
+    try (Repository repo = repoManager.openRepository(ALL_USERS);
+        TreeWalk treeWalk = new TreeWalk(repo);
+        RevWalk rw = new RevWalk(repo)) {
+      treeWalk.reset(
+          rw.parseCommit(repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId()).getTree());
+      treeWalk.setRecursive(true);
+      ImmutableList.Builder<String> allPaths = ImmutableList.builder();
+      while (treeWalk.next()) {
+        allPaths.add(treeWalk.getPathString());
+      }
+      return allPaths.build();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
index 5e93a09..3c7e492 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -1,13 +1,28 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.auth.oauth;
 
 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 static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 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.proto.testing.SerializedClassSubject;
 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;
@@ -56,10 +71,7 @@
     assertThat(s.deserialize(serializedWithEmptyString)).isEqualTo(tokenWithNull);
   }
 
-  /**
-   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
-   * what to do if this test fails.
-   */
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void oAuthTokenFields() throws Exception {
     assertThatSerializedClass(OAuthToken.class)
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index ab88169..c255e61 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -5,16 +5,8 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/cache/testing",
-        "//lib:guava",
-        "//lib:gwtorm",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//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/BooleanCacheSerializerTest.java
deleted file mode 100644
index 3186620..0000000
--- a/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.protobuf.TextFormat;
-import org.junit.Test;
-
-public class BooleanCacheSerializerTest {
-  @Test
-  public void serialize() throws Exception {
-    assertThat(BooleanCacheSerializer.INSTANCE.serialize(true))
-        .isEqualTo(new byte[] {'t', 'r', 'u', 'e'});
-    assertThat(BooleanCacheSerializer.INSTANCE.serialize(false))
-        .isEqualTo(new byte[] {'f', 'a', 'l', 's', 'e'});
-  }
-
-  @Test
-  public void deserialize() throws Exception {
-    assertThat(BooleanCacheSerializer.INSTANCE.deserialize(new byte[] {'t', 'r', 'u', 'e'}))
-        .isEqualTo(true);
-    assertThat(BooleanCacheSerializer.INSTANCE.deserialize(new byte[] {'f', 'a', 'l', 's', 'e'}))
-        .isEqualTo(false);
-  }
-
-  @Test
-  public void deserializeInvalid() throws Exception {
-    assertDeserializeFails(null);
-    assertDeserializeFails("t".getBytes(UTF_8));
-    assertDeserializeFails("tru".getBytes(UTF_8));
-    assertDeserializeFails("trueee".getBytes(UTF_8));
-    assertDeserializeFails("TRUE".getBytes(UTF_8));
-    assertDeserializeFails("f".getBytes(UTF_8));
-    assertDeserializeFails("fal".getBytes(UTF_8));
-    assertDeserializeFails("falseee".getBytes(UTF_8));
-    assertDeserializeFails("FALSE".getBytes(UTF_8));
-  }
-
-  private static void assertDeserializeFails(byte[] in) {
-    try {
-      BooleanCacheSerializer.INSTANCE.deserialize(in);
-      assert_().fail("expected deserialization to fail for \"%s\"", TextFormat.escapeBytes(in));
-    } catch (RuntimeException e) {
-      // Expected.
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
deleted file mode 100644
index 60bbb16..0000000
--- a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import org.junit.Test;
-
-public class EnumCacheSerializerTest {
-  @Test
-  public void serialize() throws Exception {
-    assertRoundTrip(MyEnum.FOO);
-    assertRoundTrip(MyEnum.BAR);
-    assertRoundTrip(MyEnum.BAZ);
-  }
-
-  @Test
-  public void deserializeInvalidValues() throws Exception {
-    assertDeserializeFails(null);
-    assertDeserializeFails("".getBytes(UTF_8));
-    assertDeserializeFails("foo".getBytes(UTF_8));
-    assertDeserializeFails("QUUX".getBytes(UTF_8));
-  }
-
-  private enum MyEnum {
-    FOO,
-    BAR,
-    BAZ;
-  }
-
-  private static void assertRoundTrip(MyEnum e) throws Exception {
-    CacheSerializer<MyEnum> s = new EnumCacheSerializer<>(MyEnum.class);
-    assertThat(s.deserialize(s.serialize(e))).isEqualTo(e);
-  }
-
-  private static void assertDeserializeFails(byte[] in) {
-    CacheSerializer<MyEnum> s = new EnumCacheSerializer<>(MyEnum.class);
-    try {
-      s.deserialize(in);
-      assert_().fail("expected RuntimeException");
-    } catch (RuntimeException e) {
-      // Expected.
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
deleted file mode 100644
index 7a7c27c..0000000
--- a/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.Key;
-import org.junit.Test;
-
-public class IntKeyCacheSerializerTest {
-
-  private static class MyIntKey extends IntKey<Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    private int val;
-
-    MyIntKey(int val) {
-      this.val = val;
-    }
-
-    @Override
-    public int get() {
-      return val;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      this.val = newValue;
-    }
-  }
-
-  private static final IntKeyCacheSerializer<MyIntKey> SERIALIZER =
-      new IntKeyCacheSerializer<>(MyIntKey::new);
-
-  @Test
-  public void serialize() throws Exception {
-    MyIntKey k = new MyIntKey(1234);
-    byte[] serialized = SERIALIZER.serialize(k);
-    assertThat(serialized).isEqualTo(new byte[] {-46, 9});
-    assertThat(SERIALIZER.deserialize(serialized).get()).isEqualTo(1234);
-  }
-
-  @Test
-  public void deserializeNullFails() throws Exception {
-    try {
-      SERIALIZER.deserialize(null);
-      assert_().fail("expected RuntimeException");
-    } catch (RuntimeException e) {
-      // Expected.
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
deleted file mode 100644
index 962b797..0000000
--- a/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.primitives.Bytes;
-import com.google.protobuf.TextFormat;
-import org.junit.Test;
-
-public class IntegerCacheSerializerTest {
-  @Test
-  public void serialize() throws Exception {
-    for (int i :
-        ImmutableList.of(
-            Integer.MIN_VALUE,
-            Integer.MIN_VALUE + 20,
-            -1,
-            0,
-            1,
-            Integer.MAX_VALUE - 20,
-            Integer.MAX_VALUE)) {
-      assertRoundTrip(i);
-    }
-  }
-
-  @Test
-  public void deserializeInvalidValues() throws Exception {
-    assertDeserializeFails(null);
-    assertDeserializeFails(
-        Bytes.concat(IntegerCacheSerializer.INSTANCE.serialize(1), new byte[] {0, 0, 0, 0}));
-  }
-
-  private static void assertRoundTrip(int i) throws Exception {
-    byte[] serialized = IntegerCacheSerializer.INSTANCE.serialize(i);
-    int result = IntegerCacheSerializer.INSTANCE.deserialize(serialized);
-    assertThat(result)
-        .named("round-trip of %s via \"%s\"", i, TextFormat.escapeBytes(serialized))
-        .isEqualTo(i);
-  }
-
-  private static void assertDeserializeFails(byte[] in) {
-    try {
-      IntegerCacheSerializer.INSTANCE.deserialize(in);
-      assert_().fail("expected RuntimeException");
-    } catch (RuntimeException e) {
-      // Expected.
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
deleted file mode 100644
index 41d07b9..0000000
--- a/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.auto.value.AutoValue;
-import java.io.Serializable;
-import org.junit.Test;
-
-public class JavaCacheSerializerTest {
-  @Test
-  public void builtInTypes() throws Exception {
-    assertRoundTrip("foo");
-    assertRoundTrip(Integer.valueOf(1234));
-    assertRoundTrip(Boolean.TRUE);
-  }
-
-  @Test
-  public void customType() throws Exception {
-    assertRoundTrip(new AutoValue_JavaCacheSerializerTest_MyType(123, "four five six"));
-  }
-
-  @AutoValue
-  abstract static class MyType implements Serializable {
-    private static final long serialVersionUID = 1L;
-
-    abstract Integer anInt();
-
-    abstract String aString();
-  }
-
-  private static <T extends Serializable> void assertRoundTrip(T input) throws Exception {
-    JavaCacheSerializer<T> s = new JavaCacheSerializer<>();
-    assertThat(s.deserialize(s.serialize(input))).isEqualTo(input);
-  }
-}
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index cfb5f3f..d19073d 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -15,15 +15,12 @@
 package com.google.gerrit.server.cache;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import java.util.function.Supplier;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class PerThreadCacheTest {
-  @Rule public ExpectedException exception = ExpectedException.none();
-
   @Test
   public void key_respectsClass() {
     assertThat(PerThreadCache.Key.create(String.class))
@@ -78,9 +75,9 @@
   @Test
   public void doubleInstantiationFails() {
     try (PerThreadCache ignored = PerThreadCache.create()) {
-      exception.expect(IllegalStateException.class);
-      exception.expectMessage("called create() twice on the same request");
-      PerThreadCache.create();
+      IllegalStateException thrown =
+          assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
+      assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java b/javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java
deleted file mode 100644
index 8bf9762..0000000
--- a/javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.truth.Truth.assertThat;
-import 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 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.protobuf.ByteString;
-import org.eclipse.jgit.lib.ObjectId;
-import org.junit.Test;
-
-public class ProtoCacheSerializersTest {
-  @Test
-  public void objectIdFromByteString() {
-    ObjectIdConverter idConverter = ObjectIdConverter.create();
-    assertThat(
-            idConverter.fromByteString(
-                bytes(
-                    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(
-                    0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
-                    0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb)))
-        .isEqualTo(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
-  }
-
-  @Test
-  public void objectIdFromByteStringWrongSize() {
-    try {
-      ObjectIdConverter.create().fromByteString(ByteString.copyFromUtf8("foo"));
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
-  }
-
-  @Test
-  public void objectIdToByteString() {
-    ObjectIdConverter idConverter = ObjectIdConverter.create();
-    assertThat(
-            idConverter.toByteString(
-                ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")))
-        .isEqualTo(
-            bytes(
-                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(
-                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
-                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb));
-  }
-
-  @Test
-  public void parseUncheckedWrongProtoType() {
-    ChangeNotesKeyProto proto =
-        ChangeNotesKeyProto.newBuilder()
-            .setProject("project")
-            .setChangeId(1234)
-            .setId(ByteString.copyFromUtf8("foo"))
-            .build();
-    byte[] bytes = ProtoCacheSerializers.toByteArray(proto);
-    try {
-      ProtoCacheSerializers.parseUnchecked(ChangeNotesStateProto.parser(), bytes);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
-  }
-
-  @Test
-  public void parseUncheckedInvalidData() {
-    byte[] bytes = new byte[] {0x00};
-    try {
-      ProtoCacheSerializers.parseUnchecked(ChangeNotesStateProto.parser(), bytes);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
-  }
-
-  @Test
-  public void parseUnchecked() {
-    ChangeNotesKeyProto proto =
-        ChangeNotesKeyProto.newBuilder()
-            .setProject("project")
-            .setChangeId(1234)
-            .setId(ByteString.copyFromUtf8("foo"))
-            .build();
-    byte[] bytes = ProtoCacheSerializers.toByteArray(proto);
-    assertThat(ProtoCacheSerializers.parseUnchecked(ChangeNotesKeyProto.parser(), bytes))
-        .isEqualTo(proto);
-  }
-}
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 63ae94b..2ee8e48 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 4180192..69c2799 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.cache.h2;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -43,8 +43,8 @@
         new SqlStore<>(
             "jdbc:h2:mem:Test_" + id,
             KEY_TYPE,
-            StringSerializer.INSTANCE,
-            StringSerializer.INSTANCE,
+            StringCacheSerializer.INSTANCE,
+            StringCacheSerializer.INSTANCE,
             version,
             1 << 20,
             null);
@@ -67,30 +67,30 @@
                   return "bar";
                 }))
         .isEqualTo("bar");
-    assertThat(called.get()).named("Callable was called").isTrue();
-    assertThat(impl.getIfPresent("foo")).named("in-memory value").isEqualTo("bar");
+    assertWithMessage("Callable was called").that(called.get()).isTrue();
+    assertWithMessage("in-memory value").that(impl.getIfPresent("foo")).isEqualTo("bar");
     mem.invalidate("foo");
-    assertThat(impl.getIfPresent("foo")).named("persistent value").isEqualTo("bar");
+    assertWithMessage("persistent value").that(impl.getIfPresent("foo")).isEqualTo("bar");
 
     called.set(false);
-    assertThat(
+    assertWithMessage("cached value")
+        .that(
             impl.get(
                 "foo",
                 () -> {
                   called.set(true);
                   return "baz";
                 }))
-        .named("cached value")
         .isEqualTo("bar");
-    assertThat(called.get()).named("Callable was called").isFalse();
+    assertWithMessage("Callable was called").that(called.get()).isFalse();
   }
 
   @Test
   public void stringSerializer() {
     String input = "foo";
-    byte[] serialized = StringSerializer.INSTANCE.serialize(input);
+    byte[] serialized = StringCacheSerializer.INSTANCE.serialize(input);
     assertThat(serialized).isEqualTo(new byte[] {'f', 'o', 'o'});
-    assertThat(StringSerializer.INSTANCE.deserialize(serialized)).isEqualTo(input);
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(serialized)).isEqualTo(input);
   }
 
   @Test
@@ -124,23 +124,6 @@
     assertThat(oldImpl.getIfPresent("key")).isNull();
   }
 
-  // TODO(dborowitz): Won't be necessary when we use a real StringSerializer in the server code.
-  private enum StringSerializer implements CacheSerializer<String> {
-    INSTANCE;
-
-    @Override
-    public byte[] serialize(String object) {
-      return object.getBytes(UTF_8);
-    }
-
-    @Override
-    public String deserialize(byte[] in) {
-      // TODO(dborowitz): Consider using CharsetDecoder directly in the real implementation, to get
-      // checked exceptions.
-      return new String(in, UTF_8);
-    }
-  }
-
   private static <K, V> Cache<K, ValueHolder<V>> disableMemCache() {
     return CacheBuilder.newBuilder().maximumSize(0).build();
   }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
new file mode 100644
index 0000000..271c27d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -0,0 +1,21 @@
+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",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//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",
+        "//proto/testing:test_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
new file mode 100644
index 0000000..ebd7d55
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.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.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import org.junit.Test;
+
+public class BooleanCacheSerializerTest {
+  @Test
+  public void serialize() throws Exception {
+    assertThat(BooleanCacheSerializer.INSTANCE.serialize(true))
+        .isEqualTo(new byte[] {'t', 'r', 'u', 'e'});
+    assertThat(BooleanCacheSerializer.INSTANCE.serialize(false))
+        .isEqualTo(new byte[] {'f', 'a', 'l', 's', 'e'});
+  }
+
+  @Test
+  public void deserialize() throws Exception {
+    assertThat(BooleanCacheSerializer.INSTANCE.deserialize(new byte[] {'t', 'r', 'u', 'e'}))
+        .isEqualTo(true);
+    assertThat(BooleanCacheSerializer.INSTANCE.deserialize(new byte[] {'f', 'a', 'l', 's', 'e'}))
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void deserializeInvalid() throws Exception {
+    assertDeserializeFails(null);
+    assertDeserializeFails("t".getBytes(UTF_8));
+    assertDeserializeFails("tru".getBytes(UTF_8));
+    assertDeserializeFails("trueee".getBytes(UTF_8));
+    assertDeserializeFails("TRUE".getBytes(UTF_8));
+    assertDeserializeFails("f".getBytes(UTF_8));
+    assertDeserializeFails("fal".getBytes(UTF_8));
+    assertDeserializeFails("falseee".getBytes(UTF_8));
+    assertDeserializeFails("FALSE".getBytes(UTF_8));
+  }
+
+  private static void assertDeserializeFails(byte[] in) {
+    assertThrows(RuntimeException.class, () -> BooleanCacheSerializer.INSTANCE.deserialize(in));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CacheSerializerTest.java
new file mode 100644
index 0000000..819189f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CacheSerializerTest.java
@@ -0,0 +1,50 @@
+// 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.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import org.junit.Test;
+
+public class CacheSerializerTest {
+  @AutoValue
+  abstract static class MyAutoValue {
+    static MyAutoValue create(int val) {
+      return new AutoValue_CacheSerializerTest_MyAutoValue(val);
+    }
+
+    abstract int val();
+  }
+
+  private static final CacheSerializer<MyAutoValue> SERIALIZER =
+      CacheSerializer.convert(
+          IntegerCacheSerializer.INSTANCE, Converter.from(MyAutoValue::val, MyAutoValue::create));
+
+  @Test
+  public void serialize() throws Exception {
+    MyAutoValue v = MyAutoValue.create(1234);
+    byte[] serialized = SERIALIZER.serialize(v);
+    assertThat(serialized).isEqualTo(new byte[] {-46, 9});
+    assertThat(SERIALIZER.deserialize(serialized).val()).isEqualTo(1234);
+  }
+
+  @Test
+  public void deserializeNullFails() throws Exception {
+    assertThrows(RuntimeException.class, () -> SERIALIZER.deserialize(null));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
new file mode 100644
index 0000000..7bfcc59
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
@@ -0,0 +1,54 @@
+// 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.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import org.junit.Test;
+
+public class EnumCacheSerializerTest {
+  @Test
+  public void serialize() throws Exception {
+    assertRoundTrip(MyEnum.FOO);
+    assertRoundTrip(MyEnum.BAR);
+    assertRoundTrip(MyEnum.BAZ);
+  }
+
+  @Test
+  public void deserializeInvalidValues() throws Exception {
+    assertDeserializeFails(null);
+    assertDeserializeFails("".getBytes(UTF_8));
+    assertDeserializeFails("foo".getBytes(UTF_8));
+    assertDeserializeFails("QUUX".getBytes(UTF_8));
+  }
+
+  private enum MyEnum {
+    FOO,
+    BAR,
+    BAZ;
+  }
+
+  private static void assertRoundTrip(MyEnum e) throws Exception {
+    CacheSerializer<MyEnum> s = new EnumCacheSerializer<>(MyEnum.class);
+    assertThat(s.deserialize(s.serialize(e))).isEqualTo(e);
+  }
+
+  private static void assertDeserializeFails(byte[] in) {
+    CacheSerializer<MyEnum> s = new EnumCacheSerializer<>(MyEnum.class);
+    assertThrows(RuntimeException.class, () -> s.deserialize(in));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
new file mode 100644
index 0000000..40ff0ac
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.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.cache.serialize;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Bytes;
+import com.google.protobuf.TextFormat;
+import org.junit.Test;
+
+public class IntegerCacheSerializerTest {
+  @Test
+  public void serialize() throws Exception {
+    for (int i :
+        ImmutableList.of(
+            Integer.MIN_VALUE,
+            Integer.MIN_VALUE + 20,
+            -1,
+            0,
+            1,
+            Integer.MAX_VALUE - 20,
+            Integer.MAX_VALUE)) {
+      assertRoundTrip(i);
+    }
+  }
+
+  @Test
+  public void deserializeInvalidValues() throws Exception {
+    assertDeserializeFails(null);
+    assertDeserializeFails(
+        Bytes.concat(IntegerCacheSerializer.INSTANCE.serialize(1), new byte[] {0, 0, 0, 0}));
+  }
+
+  private static void assertRoundTrip(int i) throws Exception {
+    byte[] serialized = IntegerCacheSerializer.INSTANCE.serialize(i);
+    int result = IntegerCacheSerializer.INSTANCE.deserialize(serialized);
+    assertWithMessage("round-trip of %s via \"%s\"", i, TextFormat.escapeBytes(serialized))
+        .that(result)
+        .isEqualTo(i);
+  }
+
+  private static void assertDeserializeFails(byte[] in) {
+    assertThrows(RuntimeException.class, () -> IntegerCacheSerializer.INSTANCE.deserialize(in));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
new file mode 100644
index 0000000..6596730
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.auto.value.AutoValue;
+import java.io.Serializable;
+import org.junit.Test;
+
+public class JavaCacheSerializerTest {
+  @Test
+  public void builtInTypes() throws Exception {
+    assertRoundTrip("foo");
+    assertRoundTrip(Integer.valueOf(1234));
+    assertRoundTrip(Boolean.TRUE);
+  }
+
+  @Test
+  public void customType() throws Exception {
+    assertRoundTrip(new AutoValue_JavaCacheSerializerTest_MyType(123, "four five six"));
+  }
+
+  @AutoValue
+  abstract static class MyType implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    abstract Integer anInt();
+
+    abstract String aString();
+  }
+
+  private static <T extends Serializable> void assertRoundTrip(T input) throws Exception {
+    JavaCacheSerializer<T> s = new JavaCacheSerializer<>();
+    assertThat(s.deserialize(s.serialize(input))).isEqualTo(input);
+  }
+}
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..7d6647a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
@@ -0,0 +1,52 @@
+// 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.gerrit.server.cache.testing.CacheSerializerTestUtil.byteArray;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+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) {
+    assertThrows(
+        IllegalArgumentException.class, () -> ObjectIdCacheSerializer.INSTANCE.deserialize(bytes));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
new file mode 100644
index 0000000..f6d6c8a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.protobuf.ByteString;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ObjectIdConverterTest {
+  @Test
+  public void objectIdFromByteString() {
+    ObjectIdConverter idConverter = ObjectIdConverter.create();
+    assertThat(
+            idConverter.fromByteString(
+                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(
+                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"));
+  }
+
+  @Test
+  public void objectIdFromByteStringWrongSize() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> ObjectIdConverter.create().fromByteString(ByteString.copyFromUtf8("foo")));
+  }
+
+  @Test
+  public void objectIdToByteString() {
+    ObjectIdConverter idConverter = ObjectIdConverter.create();
+    assertThat(
+            idConverter.toByteString(
+                ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")))
+        .isEqualTo(
+            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(
+            byteString(
+                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
+                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java
new file mode 100644
index 0000000..04d2f73
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 com.google.gerrit.proto.testing.Test.SerializableProto;
+import org.junit.Test;
+
+public class ProtobufSerializerTest {
+  @Test
+  public void requiredAndOptionalTypes() {
+    assertRoundTrip(SerializableProto.newBuilder().setId(123));
+    assertRoundTrip(SerializableProto.newBuilder().setId(123).setText("foo bar"));
+  }
+
+  @Test
+  public void exactByteSequence() {
+    ProtobufSerializer<SerializableProto> s = new ProtobufSerializer<>(SerializableProto.parser());
+    SerializableProto proto = SerializableProto.newBuilder().setId(123).setText("foo bar").build();
+    byte[] serialized = s.serialize(proto);
+    // Hard-code byte sequence to detect library changes
+    assertThat(serialized).isEqualTo(new byte[] {8, 123, 18, 7, 102, 111, 111, 32, 98, 97, 114});
+  }
+
+  private static void assertRoundTrip(SerializableProto.Builder input) {
+    ProtobufSerializer<SerializableProto> s = new ProtobufSerializer<>(SerializableProto.parser());
+    assertThat(s.deserialize(s.serialize(input.build()))).isEqualTo(input.build());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
new file mode 100644
index 0000000..dc22805
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.StandardCharsets;
+import org.junit.Test;
+
+public class StringCacheSerializerTest {
+  @Test
+  public void serialize() {
+    assertThat(StringCacheSerializer.INSTANCE.serialize("")).isEmpty();
+    assertThat(StringCacheSerializer.INSTANCE.serialize("abc"))
+        .isEqualTo(new byte[] {'a', 'b', 'c'});
+    assertThat(StringCacheSerializer.INSTANCE.serialize("a\u1234c"))
+        .isEqualTo(new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'});
+  }
+
+  @Test
+  public void serializeInvalidChar() {
+    // Can't use UTF-8 for the test, since it can encode all Unicode code points.
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class,
+            () -> StringCacheSerializer.serialize(StandardCharsets.US_ASCII, "\u1234"));
+    assertThat(thrown).hasCauseThat().isInstanceOf(CharacterCodingException.class);
+  }
+
+  @Test
+  public void deserialize() {
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[0])).isEmpty();
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[] {'a', 'b', 'c'}))
+        .isEqualTo("abc");
+    assertThat(
+            StringCacheSerializer.INSTANCE.deserialize(
+                new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'}))
+        .isEqualTo("a\u1234c");
+  }
+
+  @Test
+  public void deserializeInvalidChar() {
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class,
+            () -> StringCacheSerializer.INSTANCE.deserialize(new byte[] {(byte) 0xff}));
+    assertThat(thrown).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..20813f6 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -16,12 +16,13 @@
 
 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.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
 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 +40,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")
@@ -49,10 +50,7 @@
     assertThat(s.deserialize(serialized)).isEqualTo(key);
   }
 
-  /**
-   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
-   * what to do if this test fails.
-   */
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void keyFields() throws Exception {
     assertThatSerializedClass(ChangeKindCacheImpl.Key.class)
diff --git a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
index e91c3b4..13b58e6 100644
--- a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
+++ b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
@@ -14,25 +14,19 @@
 
 package com.google.gerrit.server.change;
 
-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;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
+
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 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.junit.After;
-import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
-public class IncludedInResolverTest extends RepositoryTestCase {
-
+public class IncludedInResolverTest {
   // Branch names
   private static final String BRANCH_MASTER = "master";
   private static final String BRANCH_1_0 = "rel-1.0";
@@ -55,15 +49,11 @@
   private RevCommit commit_v1_3;
   private RevCommit commit_v2_5;
 
-  private List<String> expTags = new ArrayList<>();
-  private List<String> expBranches = new ArrayList<>();
+  private TestRepository<?> tr;
 
-  private RevWalk revWalk;
-
-  @Override
   @Before
   public void setUp() throws Exception {
-    super.setUp();
+    tr = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("repo")));
 
     /*- The following graph will be created.
 
@@ -83,55 +73,37 @@
 
     */
 
-    // TODO(dborowitz): Use try/finally when this doesn't double-close the repo.
-    @SuppressWarnings("resource")
-    Git git = new Git(db);
-    revWalk = new RevWalk(db);
     // Version 1.0
-    commit_initial = git.commit().setMessage("c1").call();
-    git.commit().setMessage("c2").call();
-    RevCommit commit_v1_0 = git.commit().setMessage("version 1.0").call();
-    git.tag().setName(TAG_1_0).setObjectId(commit_v1_0).call();
-    RevCommit c3 = git.commit().setMessage("c3").call();
+    commit_initial = tr.branch(BRANCH_MASTER).commit().message("c1").create();
+    tr.branch(BRANCH_MASTER).commit().message("c2").create();
+    RevCommit commit_v1_0 = tr.branch(BRANCH_MASTER).commit().message("version 1.0").create();
+    tag(TAG_1_0, commit_v1_0);
+    RevCommit c3 = tr.branch(BRANCH_MASTER).commit().message("c3").create();
+
     // Version 1.01
-    createAndCheckoutBranch(commit_v1_0, BRANCH_1_0);
-    RevCommit commit_v1_0_1 = git.commit().setMessage("verREFS_HEADS_RELsion 1.0.1").call();
-    git.tag().setName(TAG_1_0_1).setObjectId(commit_v1_0_1).call();
+    tr.branch(BRANCH_1_0).update(commit_v1_0);
+    RevCommit commit_v1_0_1 = tr.branch(BRANCH_1_0).commit().message("version 1.0.1").create();
+    tag(TAG_1_0_1, commit_v1_0_1);
+
     // Version 1.3
-    createAndCheckoutBranch(c3, BRANCH_1_3);
-    commit_v1_3 = git.commit().setMessage("version 1.3").call();
-    git.tag().setName(TAG_1_3).setObjectId(commit_v1_3).call();
+    tr.branch(BRANCH_1_3).update(c3);
+    commit_v1_3 = tr.branch(BRANCH_1_3).commit().message("version 1.3").create();
+    tag(TAG_1_3, commit_v1_3);
+
     // Version 2.0
-    createAndCheckoutBranch(c3, BRANCH_2_0);
-    RevCommit commit_v2_0 = git.commit().setMessage("version 2.0").call();
-    git.tag().setName(TAG_2_0).setObjectId(commit_v2_0).call();
-    RevCommit commit_v2_0_1 = git.commit().setMessage("version 2.0.1").call();
-    git.tag().setName(TAG_2_0_1).setObjectId(commit_v2_0_1).call();
+    tr.branch(BRANCH_2_0).update(c3);
+    RevCommit commit_v2_0 = tr.branch(BRANCH_2_0).commit().message("version 2.0").create();
+    tag(TAG_2_0, commit_v2_0);
+    RevCommit commit_v2_0_1 = tr.branch(BRANCH_2_0).commit().message("version 2.0.1").create();
+    tag(TAG_2_0_1, commit_v2_0_1);
 
     // Version 2.5
-    createAndCheckoutBranch(commit_v1_3, BRANCH_2_5);
-    git.merge()
-        .include(commit_v2_0_1)
-        .setCommit(false)
-        .setFastForward(FastForwardMode.NO_FF)
-        .call();
-    commit_v2_5 = git.commit().setMessage("version 2.5").call();
-    git.tag().setName(TAG_2_5).setObjectId(commit_v2_5).setAnnotated(false).call();
-    Ref ref_tag_2_5_annotated =
-        git.tag().setName(TAG_2_5_ANNOTATED).setObjectId(commit_v2_5).setAnnotated(true).call();
-    RevTag tag_2_5_annotated = revWalk.parseTag(ref_tag_2_5_annotated.getObjectId());
-    git.tag()
-        .setName(TAG_2_5_ANNOTATED_TWICE)
-        .setObjectId(tag_2_5_annotated)
-        .setAnnotated(true)
-        .call();
-  }
-
-  @Override
-  @After
-  public void tearDown() throws Exception {
-    revWalk.close();
-    super.tearDown();
+    tr.branch(BRANCH_2_5).update(commit_v1_3);
+    tr.branch(BRANCH_2_5).commit().parent(commit_v2_0_1).create(); // Merge v2.0.1
+    commit_v2_5 = tr.branch(BRANCH_2_5).commit().message("version 2.5").create();
+    tr.update(REFS_TAGS + TAG_2_5, commit_v2_5);
+    RevTag tag_2_5_annotated = tag(TAG_2_5_ANNOTATED, commit_v2_5);
+    tag(TAG_2_5_ANNOTATED_TWICE, tag_2_5_annotated);
   }
 
   @Test
@@ -140,12 +112,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 +122,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,30 +142,16 @@
     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);
+    return IncludedInResolver.resolve(tr.getRepository(), tr.getRevWalk(), 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);
-    super.checkoutBranch(fullBranchName);
+  private RevTag tag(String name, RevObject dest) throws Exception {
+    return tr.update(REFS_TAGS + name, tr.tag(name, dest));
   }
 }
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index bce00cd..beeca21 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -14,27 +14,25 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.common.data.Permission.forLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.allow;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.junit.Assert.assertEquals;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.LabelId;
-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.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
@@ -46,15 +44,12 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
@@ -67,16 +62,17 @@
   @Inject private AllProjectsName allProjects;
   @Inject private GitRepositoryManager repoManager;
   @Inject private IdentifiedUser.GenericFactory userFactory;
-  @Inject private InMemoryDatabase schemaFactory;
   @Inject private LabelNormalizer norm;
   @Inject private MetaDataUpdate.User metaDataUpdateFactory;
   @Inject private ProjectCache projectCache;
   @Inject private SchemaCreator schemaCreator;
   @Inject protected ThreadLocalRequestContext requestContext;
   @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ProjectConfig.Factory projectConfigFactory;
+  @Inject private GerritApi gApi;
+  @Inject private ProjectOperations projectOperations;
 
   private LifecycleManager lifecycle;
-  private ReviewDb db;
   private Account.Id userId;
   private IdentifiedUser user;
   private Change change;
@@ -90,23 +86,11 @@
     lifecycle.add(injector);
     lifecycle.start();
 
-    db = schemaFactory.open();
-    schemaCreator.create(db);
+    schemaCreator.create();
     userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     user = userFactory.create(userId);
 
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
+    requestContext.setContext(() -> user);
 
     configureProject();
     setUpChange();
@@ -120,24 +104,20 @@
       }
     }
     LabelType lt =
-        category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+        label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
     pc.getLabelSections().put(lt.getName(), lt);
     save(pc);
   }
 
   private void setUpChange() throws Exception {
-    change =
-        new Change(
-            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
-            new Change.Id(1),
-            userId,
-            new Branch.NameKey(allProjects, "refs/heads/master"),
-            TimeUtil.nowTs());
-    PatchSetInfo ps = new PatchSetInfo(new PatchSet.Id(change.getId(), 1));
-    ps.setSubject("Test change");
-    change.setCurrentPatchSet(ps);
-    db.changes().insert(ImmutableList.of(change));
-    notes = changeNotesFactory.createChecked(db, change);
+    ChangeInput input = new ChangeInput();
+    input.project = allProjects.get();
+    input.branch = "master";
+    input.newBranch = true;
+    input.subject = "Test change";
+    ChangeInfo info = gApi.changes().create(input).get();
+    notes = changeNotesFactory.createChecked(allProjects, Change.id(info._number));
+    change = notes.getChange();
   }
 
   @After
@@ -146,18 +126,15 @@
       lifecycle.stop();
     }
     requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
   }
 
   @Test
   public void noNormalizeByPermission() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    allow(pc, forLabel("Verified"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     PatchSetApproval cr = psa(userId, "Code-Review", 2);
     PatchSetApproval v = psa(userId, "Verified", 1);
@@ -166,10 +143,11 @@
 
   @Test
   public void normalizeByType() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -5, 5, REGISTERED_USERS, "refs/heads/*");
-    allow(pc, forLabel("Verified"), -5, 5, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
+        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
+        .update();
 
     PatchSetApproval cr = psa(userId, "Code-Review", 5);
     PatchSetApproval v = psa(userId, "Verified", 5);
@@ -187,9 +165,10 @@
 
   @Test
   public void explicitZeroVoteOnNonEmptyRangeIsPresent() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     PatchSetApproval cr = psa(userId, "Code-Review", 0);
     PatchSetApproval v = psa(userId, "Verified", 0);
@@ -198,7 +177,7 @@
 
   private ProjectConfig loadAllProjects() throws Exception {
     try (Repository repo = repoManager.openRepository(allProjects)) {
-      ProjectConfig pc = new ProjectConfig(allProjects);
+      ProjectConfig pc = projectConfigFactory.create(allProjects);
       pc.load(repo);
       return pc;
     }
@@ -212,19 +191,18 @@
   }
 
   private PatchSetApproval psa(Account.Id accountId, String label, int value) {
-    return new PatchSetApproval(
-        new PatchSetApproval.Key(change.currentPatchSetId(), accountId, new LabelId(label)),
-        (short) value,
-        TimeUtil.nowTs());
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(change.currentPatchSetId(), accountId, LabelId.create(label)))
+        .value(value)
+        .granted(TimeUtil.nowTs())
+        .build();
   }
 
   private PatchSetApproval copy(PatchSetApproval src, int newValue) {
-    PatchSetApproval result = new PatchSetApproval(src.getKey().getParentKey(), src);
-    result.setValue((short) newValue);
-    return result;
+    return src.toBuilder().value(newValue).build();
   }
 
   private static List<PatchSetApproval> list(PatchSetApproval... psas) {
-    return ImmutableList.<PatchSetApproval>copyOf(psas);
+    return ImmutableList.copyOf(psas);
   }
 }
diff --git a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
index c8e6f2b..19c8998 100644
--- a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
@@ -16,11 +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.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -39,11 +40,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")
@@ -53,10 +54,7 @@
         .isEqualTo(key);
   }
 
-  /**
-   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
-   * what to do if this test fails.
-   */
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void keyFields() throws Exception {
     assertThatSerializedClass(MergeabilityCacheImpl.EntryKey.class)
diff --git a/javatests/com/google/gerrit/server/change/WalkSorterTest.java b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
index 189dfbc..4a42140 100644
--- a/javatests/com/google/gerrit/server/change/WalkSorterTest.java
+++ b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
@@ -23,10 +23,8 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.change.WalkSorter.PatchSetData;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
 import com.google.gerrit.testing.TestChanges;
@@ -39,13 +37,13 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class WalkSorterTest extends GerritBaseTests {
+public class WalkSorterTest {
   private Account.Id userId;
   private InMemoryRepositoryManager repoManager;
 
   @Before
   public void setUp() {
-    userId = new Account.Id(1);
+    userId = Account.id(1);
     repoManager = new InMemoryRepositoryManager();
   }
 
@@ -280,7 +278,7 @@
 
     // If we restrict to PS1 of each change, the sorter uses that commit.
     sorter.includePatchSets(
-        ImmutableSet.of(new PatchSet.Id(cd1.getId(), 1), new PatchSet.Id(cd2.getId(), 1)));
+        ImmutableSet.of(PatchSet.id(cd1.getId(), 1), PatchSet.id(cd2.getId(), 1)));
     assertSorted(
         sorter, changes, ImmutableList.of(patchSetData(cd2, 1, c2_1), patchSetData(cd1, 1, c1_1)));
   }
@@ -297,8 +295,7 @@
 
     List<ChangeData> changes = ImmutableList.of(cd1, cd2);
     WalkSorter sorter =
-        new WalkSorter(repoManager)
-            .includePatchSets(ImmutableSet.of(cd1.currentPatchSet().getId()));
+        new WalkSorter(repoManager).includePatchSets(ImmutableSet.of(cd1.currentPatchSet().id()));
 
     assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd1, c1)));
   }
@@ -335,17 +332,15 @@
   private ChangeData newChange(TestRepository<Repo> tr, ObjectId id) throws Exception {
     Project.NameKey project = tr.getRepository().getDescription().getProject();
     Change c = TestChanges.newChange(project, userId);
-    ChangeData cd = ChangeData.createForTest(project, c.getId(), 1);
+    ChangeData cd = ChangeData.createForTest(project, c.getId(), 1, id);
     cd.setChange(c);
-    cd.currentPatchSet().setRevision(new RevId(id.name()));
     cd.setPatchSets(ImmutableList.of(cd.currentPatchSet()));
     return cd;
   }
 
   private PatchSet addPatchSet(ChangeData cd, ObjectId id) throws Exception {
     TestChanges.incrementPatchSet(cd.change());
-    PatchSet ps = new PatchSet(cd.change().currentPatchSetId());
-    ps.setRevision(new RevId(id.name()));
+    PatchSet ps = TestChanges.newPatchSet(cd.change().currentPatchSetId(), id.name(), userId);
     List<PatchSet> patchSets = new ArrayList<>(cd.patchSets());
     patchSets.add(ps);
     cd.setPatchSets(patchSets);
@@ -353,7 +348,7 @@
   }
 
   private TestRepository<Repo> newRepo(String name) throws Exception {
-    return new TestRepository<>(repoManager.createRepository(new Project.NameKey(name)));
+    return new TestRepository<>(repoManager.createRepository(Project.nameKey(name)));
   }
 
   private static PatchSetData patchSetData(ChangeData cd, RevCommit commit) throws Exception {
@@ -362,7 +357,7 @@
 
   private static PatchSetData patchSetData(ChangeData cd, int psId, RevCommit commit)
       throws Exception {
-    return PatchSetData.create(cd, cd.patchSet(new PatchSet.Id(cd.getId(), psId)), commit);
+    return PatchSetData.create(cd, cd.patchSet(PatchSet.id(cd.getId(), psId)), commit);
   }
 
   private static void assertSorted(
diff --git a/javatests/com/google/gerrit/server/config/AllProjectsNameTest.java b/javatests/com/google/gerrit/server/config/AllProjectsNameTest.java
new file mode 100644
index 0000000..979967d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/AllProjectsNameTest.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Project;
+import org.junit.Test;
+
+public class AllProjectsNameTest {
+  @Test
+  public void equalToProjectNameKey() {
+    String name = "a-project";
+    AllProjectsName allProjectsName = new AllProjectsName(name);
+    Project.NameKey projectName = Project.nameKey(name);
+    assertThat(allProjectsName.get()).isEqualTo(projectName.get());
+    assertThat(allProjectsName).isEqualTo(projectName);
+  }
+
+  @Test
+  public void equalToAllUsersName() {
+    String name = "a-project";
+    AllProjectsName allProjectsName = new AllProjectsName(name);
+    AllUsersName allUsersName = new AllUsersName(name);
+    assertThat(allProjectsName.get()).isEqualTo(allUsersName.get());
+    assertThat(allProjectsName).isEqualTo(allUsersName);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/AllUsersNameTest.java b/javatests/com/google/gerrit/server/config/AllUsersNameTest.java
new file mode 100644
index 0000000..4edc923
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/AllUsersNameTest.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Project;
+import org.junit.Test;
+
+public class AllUsersNameTest {
+  @Test
+  public void equalToProjectNameKey() {
+    String name = "a-project";
+    AllUsersName allUsersName = new AllUsersName(name);
+    Project.NameKey projectName = Project.nameKey(name);
+    assertThat(allUsersName.get()).isEqualTo(projectName.get());
+    assertThat(allUsersName).isEqualTo(projectName);
+  }
+
+  @Test
+  public void equalToAllProjectsName() {
+    String name = "a-project";
+    AllUsersName allUsersName = new AllUsersName(name);
+    AllProjectsName allProjectsName = new AllProjectsName(name);
+    assertThat(allUsersName.get()).isEqualTo(allProjectsName.get());
+    assertThat(allUsersName).isEqualTo(allProjectsName);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
index 231b584..865bda6 100644
--- a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
+++ b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.util.concurrent.TimeUnit.DAYS;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
@@ -90,17 +91,17 @@
     Config cfg = new Config();
     ConfigUtil.storeSection(cfg, SECT, SUB, in, d);
 
-    assertThat(cfg.getString(SECT, SUB, "CONSTANT")).isNull();
-    assertThat(cfg.getString(SECT, SUB, "missing")).isNull();
-    assertThat(cfg.getBoolean(SECT, SUB, "b", false)).isEqualTo(in.b);
-    assertThat(cfg.getBoolean(SECT, SUB, "bb", false)).isEqualTo(in.bb);
-    assertThat(cfg.getInt(SECT, SUB, "i", 0)).isEqualTo(0);
-    assertThat(cfg.getInt(SECT, SUB, "ii", 0)).isEqualTo(in.ii);
-    assertThat(cfg.getLong(SECT, SUB, "l", 0L)).isEqualTo(0L);
-    assertThat(cfg.getLong(SECT, SUB, "ll", 0L)).isEqualTo(in.ll);
-    assertThat(cfg.getString(SECT, SUB, "s")).isEqualTo(in.s);
-    assertThat(cfg.getString(SECT, SUB, "sd")).isNull();
-    assertThat(cfg.getString(SECT, SUB, "nd")).isNull();
+    assertThat(cfg).stringValue(SECT, SUB, "CONSTANT").isNull();
+    assertThat(cfg).stringValue(SECT, SUB, "missing").isNull();
+    assertThat(cfg).booleanValue(SECT, SUB, "b", false).isEqualTo(in.b);
+    assertThat(cfg).booleanValue(SECT, SUB, "bb", false).isEqualTo(in.bb);
+    assertThat(cfg).intValue(SECT, SUB, "i", 0).isEqualTo(0);
+    assertThat(cfg).intValue(SECT, SUB, "ii", 0).isEqualTo(in.ii);
+    assertThat(cfg).longValue(SECT, SUB, "l", 0L).isEqualTo(0L);
+    assertThat(cfg).longValue(SECT, SUB, "ll", 0L).isEqualTo(in.ll);
+    assertThat(cfg).stringValue(SECT, SUB, "s").isEqualTo(in.s);
+    assertThat(cfg).stringValue(SECT, SUB, "sd").isNull();
+    assertThat(cfg).stringValue(SECT, SUB, "nd").isNull();
 
     SectionInfo out = new SectionInfo();
     ConfigUtil.loadSection(cfg, SECT, SUB, out, d, null);
diff --git a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
index fd9c925..ab376ac 100644
--- a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
+++ b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
@@ -16,11 +16,13 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.restapi.config.ListCapabilities;
@@ -43,8 +45,18 @@
           @Override
           protected void configure() {
             DynamicMap.mapOf(binder(), CapabilityDefinition.class);
+            DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
             bind(CapabilityDefinition.class)
-                .annotatedWith(Exports.named("printHello"))
+                .annotatedWith(Exports.named("foo"))
+                .toInstance(
+                    new CapabilityDefinition() {
+                      @Override
+                      public String getDescription() {
+                        return "Print Hello";
+                      }
+                    });
+            bind(CapabilityDefinition.class)
+                .annotatedWith(Exports.named("bar"))
                 .toInstance(
                     new CapabilityDefinition() {
                       @Override
@@ -61,17 +73,18 @@
   @Test
   public void list() throws Exception {
     Map<String, CapabilityInfo> m =
-        injector.getInstance(ListCapabilities.class).apply(new ConfigResource());
+        injector.getInstance(ListCapabilities.class).apply(new ConfigResource()).value();
     for (String id : GlobalCapability.getAllNames()) {
       assertThat(m).containsKey(id);
       assertThat(m.get(id).id).isEqualTo(id);
       assertThat(m.get(id).name).isNotNull();
     }
 
-    String pluginCapability = "gerrit-printHello";
-    assertThat(m).containsKey(pluginCapability);
-    assertThat(m.get(pluginCapability).id).isEqualTo(pluginCapability);
-    assertThat(m.get(pluginCapability).name).isEqualTo("Print Hello");
+    for (String pluginCapability : ImmutableSet.of("gerrit-foo", "gerrit-bar")) {
+      assertThat(m).containsKey(pluginCapability);
+      assertThat(m.get(pluginCapability).id).isEqualTo(pluginCapability);
+      assertThat(m.get(pluginCapability).name).isEqualTo("Print Hello");
+    }
   }
 
   @Singleton
@@ -87,7 +100,7 @@
     }
 
     @Override
-    public WithUser absentUser(Id id) {
+    public WithUser absentUser(Account.Id id) {
       throw new UnsupportedOperationException();
     }
 
diff --git a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
index 2edcf7c..895cc7e 100644
--- a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -19,7 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.reviewdb.client.Project;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
@@ -41,35 +41,35 @@
   @Test
   public void defaultSubmitTypeWhenNotConfigured() {
     // Check expected value explicitly rather than depending on constant.
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.INHERIT);
   }
 
   @Test
   public void defaultSubmitTypeForStarFilter() {
     configureDefaultSubmitType("*", SubmitType.CHERRY_PICK);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
 
     configureDefaultSubmitType("*", SubmitType.FAST_FORWARD_ONLY);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     configureDefaultSubmitType("*", SubmitType.REBASE_IF_NECESSARY);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
 
     configureDefaultSubmitType("*", SubmitType.REBASE_ALWAYS);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_ALWAYS);
   }
 
   @Test
   public void defaultSubmitTypeForSpecificFilter() {
     configureDefaultSubmitType("someProject", SubmitType.CHERRY_PICK);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someOtherProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someOtherProject")))
         .isEqualTo(RepositoryConfig.DEFAULT_SUBMIT_TYPE);
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
   }
 
@@ -79,13 +79,13 @@
     configureDefaultSubmitType("somePath/*", SubmitType.CHERRY_PICK);
     configureDefaultSubmitType("*", SubmitType.MERGE_ALWAYS);
 
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.MERGE_ALWAYS);
 
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("somePath/someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("somePath/someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
 
-    assertThat(repoCfg.getDefaultSubmitType(new NameKey("somePath/somePath/someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("somePath/somePath/someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
   }
 
@@ -99,14 +99,14 @@
 
   @Test
   public void ownerGroupsWhenNotConfigured() {
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject"))).isEmpty();
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject"))).isEmpty();
   }
 
   @Test
   public void ownerGroupsForStarFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("*", ownerGroups);
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups);
   }
 
@@ -114,8 +114,8 @@
   public void ownerGroupsForSpecificFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("someProject", ownerGroups);
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someOtherProject"))).isEmpty();
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someOtherProject"))).isEmpty();
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups);
   }
 
@@ -129,13 +129,13 @@
     configureOwnerGroups("somePath/*", ownerGroups2);
     configureOwnerGroups("somePath/somePath/*", ownerGroups3);
 
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups1);
 
-    assertThat(repoCfg.getOwnerGroups(new NameKey("somePath/someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups2);
 
-    assertThat(repoCfg.getOwnerGroups(new NameKey("somePath/somePath/someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("somePath/somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups3);
   }
 
@@ -149,22 +149,22 @@
 
   @Test
   public void basePathWhenNotConfigured() {
-    assertThat(repoCfg.getBasePath(new NameKey("someProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject"))).isNull();
   }
 
   @Test
   public void basePathForStarFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("*", basePath);
-    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath);
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
   @Test
   public void basePathForSpecificFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("someProject", basePath);
-    assertThat(repoCfg.getBasePath(new NameKey("someOtherProject"))).isNull();
-    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath);
+    assertThat(repoCfg.getBasePath(Project.nameKey("someOtherProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
   @Test
@@ -179,12 +179,12 @@
     configureBasePath("project/*", basePath3);
     configureBasePath("*", basePath4);
 
-    assertThat(repoCfg.getBasePath(new NameKey("project1")).toString()).isEqualTo(basePath1);
-    assertThat(repoCfg.getBasePath(new NameKey("project/project/someProject")).toString())
+    assertThat(repoCfg.getBasePath(Project.nameKey("project1")).toString()).isEqualTo(basePath1);
+    assertThat(repoCfg.getBasePath(Project.nameKey("project/project/someProject")).toString())
         .isEqualTo(basePath2);
-    assertThat(repoCfg.getBasePath(new NameKey("project/someProject")).toString())
+    assertThat(repoCfg.getBasePath(Project.nameKey("project/someProject")).toString())
         .isEqualTo(basePath3);
-    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath4);
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath4);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
index 70893a9..55f0374 100644
--- a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -40,15 +40,18 @@
   @Test
   public void initialDelay() throws Exception {
     assertThat(initialDelay("11:00", "1h")).isEqualTo(ms(1, HOURS));
+    assertThat(initialDelay("11:00", "1 hour")).isEqualTo(ms(1, HOURS));
     assertThat(initialDelay("05:30", "1h")).isEqualTo(ms(30, MINUTES));
     assertThat(initialDelay("09:30", "1h")).isEqualTo(ms(30, MINUTES));
     assertThat(initialDelay("13:30", "1h")).isEqualTo(ms(30, MINUTES));
     assertThat(initialDelay("13:59", "1h")).isEqualTo(ms(59, MINUTES));
 
     assertThat(initialDelay("11:00", "1d")).isEqualTo(ms(1, HOURS));
+    assertThat(initialDelay("11:00", "1 day")).isEqualTo(ms(1, HOURS));
     assertThat(initialDelay("05:30", "1d")).isEqualTo(ms(19, HOURS) + ms(30, MINUTES));
 
     assertThat(initialDelay("11:00", "1w")).isEqualTo(ms(1, HOURS));
+    assertThat(initialDelay("11:00", "1 week")).isEqualTo(ms(1, HOURS));
     assertThat(initialDelay("05:30", "1w")).isEqualTo(ms(7, DAYS) - ms(4, HOURS) - ms(30, MINUTES));
 
     assertThat(initialDelay("Mon 11:00", "1w")).isEqualTo(ms(3, DAYS) + ms(1, HOURS));
@@ -199,6 +202,9 @@
 
     rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "0100");
     assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "1:00");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/config/SitePathsTest.java b/javatests/com/google/gerrit/server/config/SitePathsTest.java
index 853db27..1e5f41d 100644
--- a/javatests/com/google/gerrit/server/config/SitePathsTest.java
+++ b/javatests/com/google/gerrit/server/config/SitePathsTest.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.server.util.HostPlatform;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.server.ioutil.HostPlatform;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.NotDirectoryException;
@@ -26,7 +26,7 @@
 import java.nio.file.Paths;
 import org.junit.Test;
 
-public class SitePathsTest extends GerritBaseTests {
+public class SitePathsTest {
   @Test
   public void create_NotExisting() throws IOException {
     final Path root = random();
@@ -72,8 +72,8 @@
     final Path root = random();
     try {
       Files.createFile(root);
-      exception.expect(NotDirectoryException.class);
-      new SitePaths(root);
+      assertThrows(NotDirectoryException.class, () -> new SitePaths(root));
+
     } finally {
       Files.delete(root);
     }
diff --git a/javatests/com/google/gerrit/server/edit/ChangeEditTest.java b/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
index 6509c4b..c7ed865 100644
--- a/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
+++ b/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
@@ -25,9 +25,9 @@
 public class ChangeEditTest {
   @Test
   public void changeEditRef() throws Exception {
-    Account.Id accountId = new Account.Id(1000042);
-    Change.Id changeId = new Change.Id(56414);
-    PatchSet.Id psId = new PatchSet.Id(changeId, 50);
+    Account.Id accountId = Account.id(1000042);
+    Change.Id changeId = Change.id(56414);
+    PatchSet.Id psId = PatchSet.id(changeId, 50);
     String refName = RefNames.refsEdit(accountId, changeId, psId);
     assertEquals("refs/users/42/1000042/edit-56414/50", refName);
   }
diff --git a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
index 574c795..a618c9e 100644
--- a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
+++ b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
@@ -20,37 +20,43 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.restapi.RawInput;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.nio.charset.StandardCharsets;
 
-public class ChangeFileContentModificationSubject
-    extends Subject<ChangeFileContentModificationSubject, ChangeFileContentModification> {
+public class ChangeFileContentModificationSubject extends Subject {
 
   public static ChangeFileContentModificationSubject assertThat(
       ChangeFileContentModification modification) {
-    return assertAbout(ChangeFileContentModificationSubject::new).that(modification);
+    return assertAbout(modifications()).that(modification);
   }
 
+  public static Factory<ChangeFileContentModificationSubject, ChangeFileContentModification>
+      modifications() {
+    return ChangeFileContentModificationSubject::new;
+  }
+
+  private final ChangeFileContentModification modification;
+
   private ChangeFileContentModificationSubject(
       FailureMetadata failureMetadata, ChangeFileContentModification modification) {
     super(failureMetadata, modification);
+    this.modification = modification;
   }
 
   public StringSubject filePath() {
     isNotNull();
-    return Truth.assertThat(actual().getFilePath()).named("filePath");
+    return check("getFilePath()").that(modification.getFilePath());
   }
 
   public StringSubject newContent() throws IOException {
     isNotNull();
-    RawInput newContent = actual().getNewContent();
-    Truth.assertThat(newContent).named("newContent").isNotNull();
+    RawInput newContent = modification.getNewContent();
+    check("getNewContent()").that(newContent).isNotNull();
     String contentString =
         CharStreams.toString(
             new InputStreamReader(newContent.getInputStream(), StandardCharsets.UTF_8));
-    return Truth.assertThat(contentString).named("newContent");
+    return check("getNewContent()").that(contentString);
   }
 }
diff --git a/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
index 59ee2b7..d5b70bb 100644
--- a/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
+++ b/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
@@ -21,26 +21,33 @@
 import com.google.gerrit.truth.ListSubject;
 import java.util.List;
 
-public class TreeModificationSubject extends Subject<TreeModificationSubject, TreeModification> {
+public class TreeModificationSubject extends Subject {
 
   public static TreeModificationSubject assertThat(TreeModification treeModification) {
-    return assertAbout(TreeModificationSubject::new).that(treeModification);
+    return assertAbout(treeModifications()).that(treeModification);
+  }
+
+  private static Factory<TreeModificationSubject, TreeModification> treeModifications() {
+    return TreeModificationSubject::new;
   }
 
   public static ListSubject<TreeModificationSubject, TreeModification> assertThatList(
       List<TreeModification> treeModifications) {
-    return ListSubject.assertThat(treeModifications, TreeModificationSubject::assertThat)
-        .named("treeModifications");
+    return ListSubject.assertThat(treeModifications, treeModifications());
   }
 
+  private final TreeModification treeModification;
+
   private TreeModificationSubject(
       FailureMetadata failureMetadata, TreeModification treeModification) {
     super(failureMetadata, treeModification);
+    this.treeModification = treeModification;
   }
 
   public ChangeFileContentModificationSubject asChangeFileContentModification() {
     isInstanceOf(ChangeFileContentModification.class);
-    return ChangeFileContentModificationSubject.assertThat(
-        (ChangeFileContentModification) actual());
+    return check("asChangeFileContentModification()")
+        .about(ChangeFileContentModificationSubject.modifications())
+        .that((ChangeFileContentModification) treeModification);
   }
 }
diff --git a/javatests/com/google/gerrit/server/events/BUILD b/javatests/com/google/gerrit/server/events/BUILD
new file mode 100644
index 0000000..f27e4a6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/events/BUILD
@@ -0,0 +1,15 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "events_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index 997fda9..aacee8a 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -18,37 +18,32 @@
 
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
+import java.sql.Timestamp;
 import org.junit.Test;
 
 public class EventDeserializerTest {
+  private final Gson gson = new EventGsonProvider().get();
 
   @Test
   public void refUpdatedEvent() {
-    RefUpdatedEvent refUpdatedEvent = new RefUpdatedEvent();
-
+    RefUpdatedEvent orig = new RefUpdatedEvent();
     RefUpdateAttribute refUpdatedAttribute = new RefUpdateAttribute();
     refUpdatedAttribute.refName = "refs/heads/master";
-    refUpdatedEvent.refUpdate = createSupplier(refUpdatedAttribute);
+    orig.refUpdate = createSupplier(refUpdatedAttribute);
 
     AccountAttribute accountAttribute = new AccountAttribute();
     accountAttribute.email = "some.user@domain.com";
-    refUpdatedEvent.submitter = createSupplier(accountAttribute);
+    orig.submitter = createSupplier(accountAttribute);
 
-    Gson gsonSerializer =
-        new GsonBuilder().registerTypeAdapter(Supplier.class, new SupplierSerializer()).create();
-    String serializedEvent = gsonSerializer.toJson(refUpdatedEvent);
-
-    Gson gsonDeserializer =
-        new GsonBuilder()
-            .registerTypeAdapter(Event.class, new EventDeserializer())
-            .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-            .create();
-
-    RefUpdatedEvent e = (RefUpdatedEvent) gsonDeserializer.fromJson(serializedEvent, Event.class);
+    RefUpdatedEvent e = roundTrip(orig);
 
     assertThat(e).isNotNull();
     assertThat(e.refUpdate).isInstanceOf(Supplier.class);
@@ -57,13 +52,271 @@
     assertThat(e.submitter.get().email).isEqualTo(accountAttribute.email);
   }
 
+  @Test
+  public void patchSetCreatedEvent() {
+    Change change = newChange();
+    PatchSetCreatedEvent orig = new PatchSetCreatedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.uploader = newAccount("uploader");
+
+    PatchSetCreatedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.uploader, orig.uploader);
+  }
+
+  @Test
+  public void assigneeChangedEvent() {
+    Change change = newChange();
+    AssigneeChangedEvent orig = new AssigneeChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.changer = newAccount("changer");
+    orig.oldAssignee = newAccount("oldAssignee");
+
+    AssigneeChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.changer, orig.changer);
+    assertSameAccount(e.oldAssignee, orig.oldAssignee);
+  }
+
+  @Test
+  public void changeDeletedEvent() {
+    Change change = newChange();
+    ChangeDeletedEvent orig = new ChangeDeletedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.deleter = newAccount("deleter");
+
+    ChangeDeletedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.deleter, orig.deleter);
+  }
+
+  @Test
+  public void hashtagsChangedEvent() {
+    Change change = newChange();
+    HashtagsChangedEvent orig = new HashtagsChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.editor = newAccount("editor");
+    orig.added = new String[] {"added"};
+    orig.removed = new String[] {"removed"};
+    orig.hashtags = new String[] {"hashtags"};
+
+    HashtagsChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.editor, orig.editor);
+    assertThat(e.added).isEqualTo(orig.added);
+    assertThat(e.removed).isEqualTo(orig.removed);
+    assertThat(e.hashtags).isEqualTo(orig.hashtags);
+  }
+
+  @Test
+  public void changeAbandonedEvent() {
+    Change change = newChange();
+    ChangeAbandonedEvent orig = new ChangeAbandonedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.abandoner = newAccount("abandoner");
+    orig.reason = "some reason";
+
+    ChangeAbandonedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.abandoner, orig.abandoner);
+    assertThat(e.reason).isEqualTo(orig.reason);
+  }
+
+  @Test
+  public void changeMergedEvent() {
+    Change change = newChange();
+    ChangeMergedEvent orig = new ChangeMergedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ChangeMergedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void changeRestoredEvent() {
+    Change change = newChange();
+    ChangeRestoredEvent orig = new ChangeRestoredEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ChangeRestoredEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void commentAddedEvent() {
+    Change change = newChange();
+    CommentAddedEvent orig = new CommentAddedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    CommentAddedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void privateStateChangedEvent() {
+    Change change = newChange();
+    PrivateStateChangedEvent orig = new PrivateStateChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    PrivateStateChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void reviewerAddedEvent() {
+    Change change = newChange();
+    ReviewerAddedEvent orig = new ReviewerAddedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ReviewerAddedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void reviewerDeletedEvent() {
+    Change change = newChange();
+    ReviewerDeletedEvent orig = new ReviewerDeletedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ReviewerDeletedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void voteDeletedEvent() {
+    Change change = newChange();
+    VoteDeletedEvent orig = new VoteDeletedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    VoteDeletedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void workinProgressStateChangedEvent() {
+    Change change = newChange();
+    WorkInProgressStateChangedEvent orig = new WorkInProgressStateChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    WorkInProgressStateChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void topicChangedEvent() {
+    Change change = newChange();
+    TopicChangedEvent orig = new TopicChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    TopicChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
   private <T> Supplier<T> createSupplier(T value) {
-    return Suppliers.memoize(
-        new Supplier<T>() {
-          @Override
-          public T get() {
-            return value;
-          }
-        });
+    return Suppliers.memoize(() -> value);
+  }
+
+  private Change newChange() {
+    Change change =
+        new Change(
+            Change.key("Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+            Change.id(1000),
+            Account.id(1000),
+            BranchNameKey.create(Project.nameKey("myproject"), "mybranch"),
+            new Timestamp(System.currentTimeMillis()));
+    return change;
+  }
+
+  private Supplier<AccountAttribute> newAccount(String name) {
+    AccountAttribute account = new AccountAttribute();
+    account.name = name;
+    account.email = name + "@somewhere.com";
+    account.username = name;
+    return Suppliers.ofInstance(account);
+  }
+
+  private void assertSameChangeEvent(ChangeEvent current, ChangeEvent expected) {
+    assertThat(current.changeKey.get()).isEqualTo(expected.changeKey.get());
+    assertThat(current.refName).isEqualTo(expected.refName);
+    assertThat(current.project).isEqualTo(expected.project);
+    assertSameChange(current.change, expected.change);
+  }
+
+  private void assertSameChange(
+      Supplier<ChangeAttribute> currentSupplier, Supplier<ChangeAttribute> expectedSupplier) {
+    ChangeAttribute current = currentSupplier.get();
+    ChangeAttribute expected = expectedSupplier.get();
+    assertThat(current.project).isEqualTo(expected.project);
+    assertThat(current.branch).isEqualTo(expected.branch);
+    assertThat(current.topic).isEqualTo(expected.topic);
+    assertThat(current.id).isEqualTo(expected.id);
+    assertThat(current.number).isEqualTo(expected.number);
+    assertThat(current.subject).isEqualTo(expected.subject);
+    assertThat(current.commitMessage).isEqualTo(expected.commitMessage);
+    assertThat(current.url).isEqualTo(expected.url);
+    assertThat(current.status).isEqualTo(expected.status);
+    assertThat(current.createdOn).isEqualTo(expected.createdOn);
+    assertThat(current.wip).isEqualTo(expected.wip);
+    assertThat(current.isPrivate).isEqualTo(expected.isPrivate);
+  }
+
+  private void assertSameAccount(
+      Supplier<AccountAttribute> currentSupplier, Supplier<AccountAttribute> expectedSupplier) {
+    AccountAttribute current = currentSupplier.get();
+    AccountAttribute expected = expectedSupplier.get();
+    assertThat(current.name).isEqualTo(expected.name);
+    assertThat(current.email).isEqualTo(expected.email);
+    assertThat(current.username).isEqualTo(expected.username);
+  }
+
+  public Supplier<ChangeAttribute> asChangeAttribute(Change change) {
+    ChangeAttribute a = new ChangeAttribute();
+    a.project = change.getProject().get();
+    a.branch = change.getDest().shortName();
+    a.topic = change.getTopic();
+    a.id = change.getKey().get();
+    a.number = change.getId().get();
+    a.subject = change.getSubject();
+    a.commitMessage = "This is a test commit message";
+    a.url = "http://somewhere.com";
+    a.status = change.getStatus();
+    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.wip = change.isWorkInProgress() ? true : null;
+    a.isPrivate = change.isPrivate() ? true : null;
+    return Suppliers.ofInstance(a);
+  }
+
+  @SuppressWarnings("unchecked")
+  private <E extends Event> E roundTrip(E event) {
+    String json = gson.toJson(event);
+    return (E) gson.fromJson(json, event.getClass());
   }
 }
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
new file mode 100644
index 0000000..4defda7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -0,0 +1,618 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.truth.MapSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ChangeAttribute;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class EventJsonTest {
+  private static final String BRANCH = "mybranch";
+  private static final String CHANGE_ID = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+  private static final int CHANGE_NUM = 1000;
+  private static final double CHANGE_NUM_DOUBLE = CHANGE_NUM;
+  private static final String COMMIT_MESSAGE = "This is a test commit message";
+  private static final String PROJECT = "myproject";
+  private static final String REF = "refs/heads/" + BRANCH;
+  private static final double TS1 = 1.2543444E9;
+  private static final double TS2 = 1.254344401E9;
+  private static final String URL = "http://somewhere.com";
+
+  private final Gson gson = new EventGsonProvider().get();
+
+  @Before
+  public void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @Test
+  public void refUpdatedEvent() {
+    RefUpdatedEvent event = new RefUpdatedEvent();
+
+    RefUpdateAttribute refUpdatedAttribute = new RefUpdateAttribute();
+    refUpdatedAttribute.refName = REF;
+    event.refUpdate = createSupplier(refUpdatedAttribute);
+    event.submitter = newAccount("submitter");
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "submitter",
+                    ImmutableMap.builder()
+                        .put("name", event.submitter.get().name)
+                        .put("email", event.submitter.get().email)
+                        .put("username", event.submitter.get().username)
+                        .build())
+                .put("refUpdate", ImmutableMap.of("refName", REF))
+                .put("type", "ref-updated")
+                .put("eventCreatedOn", TS1)
+                .build());
+  }
+
+  @Test
+  public void patchSetCreatedEvent() {
+    Change change = newChange();
+    PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
+    event.change = asChangeAttribute(change);
+    event.uploader = newAccount("uploader");
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "uploader",
+                    ImmutableMap.builder()
+                        .put("name", event.uploader.get().name)
+                        .put("email", event.uploader.get().email)
+                        .put("username", event.uploader.get().username)
+                        .build())
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "patchset-created")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void assigneeChangedEvent() {
+    Change change = newChange();
+    AssigneeChangedEvent event = new AssigneeChangedEvent(change);
+    event.change = asChangeAttribute(change);
+    event.changer = newAccount("changer");
+    event.oldAssignee = newAccount("oldAssignee");
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "changer",
+                    ImmutableMap.builder()
+                        .put("name", event.changer.get().name)
+                        .put("email", event.changer.get().email)
+                        .put("username", event.changer.get().username)
+                        .build())
+                .put(
+                    "oldAssignee",
+                    ImmutableMap.builder()
+                        .put("name", event.oldAssignee.get().name)
+                        .put("email", event.oldAssignee.get().email)
+                        .put("username", event.oldAssignee.get().username)
+                        .build())
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "assignee-changed")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void changeDeletedEvent() {
+    Change change = newChange();
+    ChangeDeletedEvent event = new ChangeDeletedEvent(change);
+    event.change = asChangeAttribute(change);
+    event.deleter = newAccount("deleter");
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "deleter",
+                    ImmutableMap.builder()
+                        .put("name", event.deleter.get().name)
+                        .put("email", event.deleter.get().email)
+                        .put("username", event.deleter.get().username)
+                        .build())
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "change-deleted")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void hashtagsChangedEvent() {
+    Change change = newChange();
+    HashtagsChangedEvent event = new HashtagsChangedEvent(change);
+    event.change = asChangeAttribute(change);
+    event.editor = newAccount("editor");
+    event.added = new String[] {"added"};
+    event.removed = new String[] {"removed"};
+    event.hashtags = new String[] {"hashtags"};
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "editor",
+                    ImmutableMap.builder()
+                        .put("name", event.editor.get().name)
+                        .put("email", event.editor.get().email)
+                        .put("username", event.editor.get().username)
+                        .build())
+                .put("added", list("added"))
+                .put("removed", list("removed"))
+                .put("hashtags", list("hashtags"))
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "hashtags-changed")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void changeAbandonedEvent() {
+    Change change = newChange();
+    ChangeAbandonedEvent event = new ChangeAbandonedEvent(change);
+    event.change = asChangeAttribute(change);
+    event.abandoner = newAccount("abandoner");
+    event.reason = "some reason";
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "abandoner",
+                    ImmutableMap.builder()
+                        .put("name", event.abandoner.get().name)
+                        .put("email", event.abandoner.get().email)
+                        .put("username", event.abandoner.get().username)
+                        .build())
+                .put("reason", "some reason")
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "change-abandoned")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void changeMergedEvent() {
+    Change change = newChange();
+    ChangeMergedEvent event = new ChangeMergedEvent(change);
+    event.change = asChangeAttribute(change);
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "change-merged")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void changeRestoredEvent() {
+    Change change = newChange();
+    ChangeRestoredEvent event = new ChangeRestoredEvent(change);
+    event.change = asChangeAttribute(change);
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "change-restored")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void commentAddedEvent() {
+    Change change = newChange();
+    CommentAddedEvent event = new CommentAddedEvent(change);
+    event.change = asChangeAttribute(change);
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "comment-added")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void privateStateChangedEvent() {
+    Change change = newChange();
+    PrivateStateChangedEvent event = new PrivateStateChangedEvent(change);
+    event.change = asChangeAttribute(change);
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "private-state-changed")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void reviewerAddedEvent() {
+    Change change = newChange();
+    ReviewerAddedEvent event = new ReviewerAddedEvent(change);
+    event.change = asChangeAttribute(change);
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "reviewer-added")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void reviewerDeletedEvent() {
+    Change change = newChange();
+    ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
+    event.change = asChangeAttribute(change);
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "reviewer-deleted")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void voteDeletedEvent() {
+    Change change = newChange();
+    VoteDeletedEvent event = new VoteDeletedEvent(change);
+    event.change = asChangeAttribute(change);
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "vote-deleted")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void workInProgressStateChangedEvent() {
+    Change change = newChange();
+    WorkInProgressStateChangedEvent event = new WorkInProgressStateChangedEvent(change);
+    event.change = asChangeAttribute(change);
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "wip-state-changed")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void topicChangedEvent() {
+    Change change = newChange();
+    TopicChangedEvent event = new TopicChangedEvent(change);
+    event.change = asChangeAttribute(change);
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "change",
+                    ImmutableMap.builder()
+                        .put("project", PROJECT)
+                        .put("branch", BRANCH)
+                        .put("id", CHANGE_ID)
+                        .put("number", CHANGE_NUM_DOUBLE)
+                        .put("url", URL)
+                        .put("commitMessage", COMMIT_MESSAGE)
+                        .put("createdOn", TS1)
+                        .put("status", NEW.name())
+                        .build())
+                .put("project", PROJECT)
+                .put("refName", REF)
+                .put("changeKey", map("id", CHANGE_ID))
+                .put("type", "topic-changed")
+                .put("eventCreatedOn", TS2)
+                .build());
+  }
+
+  @Test
+  public void projectCreatedEvent() {
+    ProjectCreatedEvent event = new ProjectCreatedEvent();
+    event.projectName = PROJECT;
+    event.headName = REF;
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put("projectName", PROJECT)
+                .put("headName", REF)
+                .put("type", "project-created")
+                .put("eventCreatedOn", TS1)
+                .build());
+  }
+
+  private Supplier<AccountAttribute> newAccount(String name) {
+    AccountAttribute account = new AccountAttribute();
+    account.name = name;
+    account.email = name + "@somewhere.com";
+    account.username = name;
+    return Suppliers.ofInstance(account);
+  }
+
+  private Change newChange() {
+    return new Change(
+        Change.key(CHANGE_ID),
+        Change.id(CHANGE_NUM),
+        Account.id(9999),
+        BranchNameKey.create(Project.nameKey(PROJECT), BRANCH),
+        TimeUtil.nowTs());
+  }
+
+  private <T> Supplier<T> createSupplier(T value) {
+    return Suppliers.memoize(() -> value);
+  }
+
+  private Supplier<ChangeAttribute> asChangeAttribute(Change change) {
+    ChangeAttribute a = new ChangeAttribute();
+    a.project = change.getProject().get();
+    a.branch = change.getDest().shortName();
+    a.topic = change.getTopic();
+    a.id = change.getKey().get();
+    a.number = change.getId().get();
+    a.subject = change.getSubject();
+    a.commitMessage = COMMIT_MESSAGE;
+    a.url = URL;
+    a.status = change.getStatus();
+    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.wip = change.isWorkInProgress() ? true : null;
+    a.isPrivate = change.isPrivate() ? true : null;
+    return Suppliers.ofInstance(a);
+  }
+
+  private MapSubject assertThatJsonMap(Object src) {
+    // Parse JSON into a raw Java map:
+    //  * Doesn't depend on field iteration order.
+    //  * Avoids excessively long string literals in asserts.
+    String json = gson.toJson(src);
+    Map<Object, Object> map =
+        gson.fromJson(json, new TypeToken<Map<Object, Object>>() {}.getType());
+    return assertThat(map);
+  }
+
+  private static ImmutableMap<Object, Object> map(Object k, Object v) {
+    return ImmutableMap.of(k, v);
+  }
+
+  private static ImmutableList<Object> list(Object... es) {
+    return ImmutableList.copyOf(es);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 834f658..7a1cf51 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -18,6 +18,9 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
@@ -43,7 +46,53 @@
     private boolean allowValueQueries = true;
 
     @Override
-    public CurrentUser user() {
+    public String resourcePath() {
+      return "/projects/test-project";
+    }
+
+    @Override
+    public ForRef ref(String ref) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public void check(CoreOrPluginProjectPermission perm)
+        throws AuthException, PermissionBackendException {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public <T extends CoreOrPluginProjectPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      assertThat(allowValueQueries).isTrue();
+      Set<T> ok = Sets.newHashSetWithExpectedSize(permSet.size());
+      for (T perm : permSet) {
+        // Allow ProjectPermission.READ, if it was requested in the input permSet. This implies
+        // that permSet has type Collection<ProjectPermission>, otherwise no permission would
+        // compare equal to READ.
+        if (perm.equals(ProjectPermission.READ)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    @Override
+    public BooleanCondition testCond(CoreOrPluginProjectPermission perm) {
+      return new PermissionBackendCondition.ForProject(this, perm, fakeUser());
+    }
+
+    @Override
+    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+        throws PermissionBackendException {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    private void disallowValueQueries() {
+      allowValueQueries = false;
+    }
+
+    private static CurrentUser fakeUser() {
       return new CurrentUser() {
         @Override
         public GroupMembership getEffectiveGroups() {
@@ -62,52 +111,10 @@
 
         @Override
         public Account.Id getAccountId() {
-          return new Account.Id(1);
+          return Account.id(1);
         }
       };
     }
-
-    @Override
-    public String resourcePath() {
-      return "/projects/test-project";
-    }
-
-    @Override
-    public ForProject user(CurrentUser user) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public ForProject absentUser(Account.Id id) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public ForRef ref(String ref) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
-        throws PermissionBackendException {
-      assertThat(allowValueQueries).isTrue();
-      return ImmutableSet.of(ProjectPermission.READ);
-    }
-
-    @Override
-    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
-        throws PermissionBackendException {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    private void disallowValueQueries() {
-      allowValueQueries = false;
-    }
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
index c1a65bb..c8df548 100644
--- a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
+++ b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.gerrit.server.edit.tree.TreeModificationSubject.assertThatList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.easymock.EasyMock.createMock;
 import static org.easymock.EasyMock.replay;
 
@@ -33,14 +34,9 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class FixReplacementInterpreterTest {
-
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
   private final FileContentUtil fileContentUtil = createMock(FileContentUtil.class);
   private final Repository repository = createMock(Repository.class);
   private final ProjectState projectState = createMock(ProjectState.class);
@@ -260,9 +256,7 @@
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
     replay(fileContentUtil);
-
-    expectedException.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -273,8 +267,7 @@
 
     replay(fileContentUtil);
 
-    expectedException.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -284,9 +277,7 @@
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
     replay(fileContentUtil);
-
-    expectedException.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -297,8 +288,7 @@
 
     replay(fileContentUtil);
 
-    expectedException.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -308,9 +298,7 @@
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
     replay(fileContentUtil);
-
-    expectedException.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   private void mockFileContent(String filePath, String fileContent) throws Exception {
diff --git a/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java b/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
index f638346..ba80c02 100644
--- a/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
+++ b/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
@@ -15,29 +15,27 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class LineIdentifierTest {
-
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
   @Test
   public void lineNumberMustBePositive() {
     LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
-    expectedException.expect(StringIndexOutOfBoundsException.class);
-    expectedException.expectMessage("positive");
-    lineIdentifier.getStartIndexOfLine(0);
+    StringIndexOutOfBoundsException thrown =
+        assertThrows(
+            StringIndexOutOfBoundsException.class, () -> lineIdentifier.getStartIndexOfLine(0));
+    assertThat(thrown).hasMessageThat().contains("positive");
   }
 
   @Test
   public void lineNumberMustIndicateAnAvailableLine() {
     LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
-    expectedException.expect(StringIndexOutOfBoundsException.class);
-    expectedException.expectMessage("Line 3 isn't available");
-    lineIdentifier.getStartIndexOfLine(3);
+    StringIndexOutOfBoundsException thrown =
+        assertThrows(
+            StringIndexOutOfBoundsException.class, () -> lineIdentifier.getStartIndexOfLine(3));
+    assertThat(thrown).hasMessageThat().contains("Line 3 isn't available");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/fixes/StringModifierTest.java b/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
index d23e928..3447248 100644
--- a/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
+++ b/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
@@ -15,16 +15,12 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class StringModifierTest {
-
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
   private final String originalString = "This is the original, unmodified string.";
   private StringModifier stringModifier;
 
@@ -67,20 +63,20 @@
   @Test
   public void replacedPartsMustNotOverlap() {
     stringModifier.replace(0, 9, "");
-    expectedException.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(8, 32, "The modified");
+    assertThrows(
+        StringIndexOutOfBoundsException.class, () -> stringModifier.replace(8, 32, "The modified"));
   }
 
   @Test
   public void startIndexMustNotBeGreaterThanEndIndex() {
-    expectedException.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(10, 9, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class, () -> stringModifier.replace(10, 9, "something"));
   }
 
   @Test
   public void startIndexMustNotBeNegative() {
-    expectedException.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(-1, 9, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class, () -> stringModifier.replace(-1, 9, "something"));
   }
 
   @Test
@@ -94,13 +90,17 @@
 
   @Test
   public void startIndexMustNotBeGreaterThanLengthOfString() {
-    expectedException.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(originalString.length() + 1, originalString.length() + 1, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class,
+        () ->
+            stringModifier.replace(
+                originalString.length() + 1, originalString.length() + 1, "something"));
   }
 
   @Test
   public void endIndexMustNotBeGreaterThanLengthOfString() {
-    expectedException.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(8, originalString.length() + 1, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class,
+        () -> stringModifier.replace(8, originalString.length() + 1, "something"));
   }
 }
diff --git a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
index e5e8e26..2b59544 100644
--- a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
+++ b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
@@ -284,7 +284,7 @@
   // TODO(dborowitz): Tests for octopus merges.
 
   private static PatchSet.Id psId(int c, int p) {
-    return new PatchSet.Id(new Change.Id(c), p);
+    return PatchSet.id(Change.id(c), p);
   }
 
   private RevWalk newWalk(ObjectId start, ObjectId branchTip) throws Exception {
diff --git a/javatests/com/google/gerrit/server/git/JGitConfigTest.java b/javatests/com/google/gerrit/server/git/JGitConfigTest.java
new file mode 100644
index 0000000..9f6b47e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/JGitConfigTest.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.config.SitePaths;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class JGitConfigTest {
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private SitePaths site;
+  private Path gitPath;
+
+  @Before
+  public void setUp() throws IOException {
+    site = new SitePaths(temporaryFolder.newFolder().toPath());
+    Files.createDirectories(site.etc_dir);
+    gitPath = Files.createDirectories(site.resolve("git"));
+
+    Files.write(
+        site.jgit_config, "[core]\n  trustFolderStat = false\n".getBytes(StandardCharsets.UTF_8));
+    new SystemReaderInstaller(site).start();
+  }
+
+  @Test
+  public void test() throws IOException {
+    try (Repository repo = new FileRepository(gitPath.resolve("foo").toFile())) {
+      assertThat(repo.getConfig().getString("core", null, "trustFolderStat")).isEqualTo("false");
+    }
+  }
+
+  @Test
+  public void openSystemConfigRespectsParent() throws Exception {
+    Config parent = new Config();
+    parent.setString("foo", null, "bar", "value");
+    FileBasedConfig system = SystemReader.getInstance().openSystemConfig(parent, FS.DETECTED);
+    system.load();
+    assertThat(system.getString("core", null, "trustFolderStat")).isEqualTo("false");
+    assertThat(system.getString("foo", null, "bar")).isEqualTo("value");
+  }
+
+  @Test
+  public void openSystemConfigReturnsDifferentInstances() throws Exception {
+    FileBasedConfig system1 = SystemReader.getInstance().openSystemConfig(null, FS.DETECTED);
+    system1.load();
+    assertThat(system1.getString("core", null, "trustFolderStat")).isEqualTo("false");
+
+    FileBasedConfig system2 = SystemReader.getInstance().openSystemConfig(null, FS.DETECTED);
+    system2.load();
+    assertThat(system2.getString("core", null, "trustFolderStat")).isEqualTo("false");
+
+    system1.setString("core", null, "trustFolderStat", "true");
+    assertThat(system1.getString("core", null, "trustFolderStat")).isEqualTo("true");
+    assertThat(system2.getString("core", null, "trustFolderStat")).isEqualTo("false");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
index aaad2a6..4e79e33 100644
--- a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -16,17 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.util.HostPlatform;
-import com.google.gerrit.testing.TempFileUtil;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
+import com.google.gerrit.server.ioutil.HostPlatform;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import org.easymock.EasyMockSupport;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -35,13 +32,12 @@
 import org.eclipse.jgit.lib.RepositoryCache.FileKey;
 import org.eclipse.jgit.util.FS;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 
-public class LocalDiskRepositoryManagerTest extends EasyMockSupport {
-
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
+public class LocalDiskRepositoryManagerTest {
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   private Config cfg;
   private SitePaths site;
@@ -49,21 +45,22 @@
 
   @Before
   public void setUp() throws Exception {
-    site = new SitePaths(TempFileUtil.createTempDirectory().toPath());
+    site = new SitePaths(temporaryFolder.newFolder().toPath());
     site.resolve("git").toFile().mkdir();
     cfg = new Config();
     cfg.setString("gerrit", null, "basePath", "git");
     repoManager = new LocalDiskRepositoryManager(site, cfg);
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testThatNullBasePathThrowsAnException() {
-    new LocalDiskRepositoryManager(site, new Config());
+    assertThrows(
+        IllegalStateException.class, () -> new LocalDiskRepositoryManager(site, new Config()));
   }
 
   @Test
   public void projectCreation() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
+    Project.NameKey projectA = Project.nameKey("projectA");
     try (Repository repo = repoManager.createRepository(projectA)) {
       assertThat(repo).isNotNull();
     }
@@ -73,112 +70,149 @@
     assertThat(repoManager.list()).containsExactly(projectA);
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithEmptyName() throws Exception {
-    repoManager.createRepository(new Project.NameKey(""));
+    assertThrows(
+        RepositoryNotFoundException.class, () -> repoManager.createRepository(Project.nameKey("")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithTrailingSlash() throws Exception {
-    repoManager.createRepository(new Project.NameKey("projectA/"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("projectA/")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithBackSlash() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a\\projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a\\projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationAbsolutePath() throws Exception {
-    repoManager.createRepository(new Project.NameKey("/projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("/projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationStartingWithDotDot() throws Exception {
-    repoManager.createRepository(new Project.NameKey("../projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("../projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationContainsDotDot() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/../projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a/../projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationDotPathSegment() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/./projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a/./projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithTwoSlashes() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a//projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a//projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithPathSegmentEndingByDotGit() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/b.git/projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a/b.git/projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithQuestionMark() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project?A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project?A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithPercentageSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project%A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project%A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithWidlcard() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project*A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project*A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithColon() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project:A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project:A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithLessThatSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project<A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project<A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithGreaterThatSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project>A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project>A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithPipe() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project|A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project|A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithDollarSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project$A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project$A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithCarriageReturn() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project\\rA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project\\rA")));
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testProjectRecreation() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a"));
-    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(Project.nameKey("a"));
+    assertThrows(
+        IllegalStateException.class, () -> repoManager.createRepository(Project.nameKey("a")));
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testProjectRecreationAfterRestart() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(Project.nameKey("a"));
     LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
-    newRepoManager.createRepository(new Project.NameKey("a"));
+    assertThrows(
+        IllegalStateException.class, () -> newRepoManager.createRepository(Project.nameKey("a")));
   }
 
   @Test
   public void openRepositoryCreatedDirectlyOnDisk() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
+    Project.NameKey projectA = Project.nameKey("projectA");
     createRepository(repoManager.getBasePath(projectA), projectA.get());
     try (Repository repo = repoManager.openRepository(projectA)) {
       assertThat(repo).isNotNull();
@@ -186,30 +220,36 @@
     assertThat(repoManager.list()).containsExactly(projectA);
   }
 
-  @Test(expected = RepositoryCaseMismatchException.class)
+  @Test
   public void testNameCaseMismatch() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    repoManager.createRepository(new Project.NameKey("a"));
-    repoManager.createRepository(new Project.NameKey("A"));
+    repoManager.createRepository(Project.nameKey("a"));
+    assertThrows(
+        RepositoryCaseMismatchException.class,
+        () -> repoManager.createRepository(Project.nameKey("A")));
   }
 
-  @Test(expected = RepositoryCaseMismatchException.class)
+  @Test
   public void testNameCaseMismatchWithSymlink() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    Project.NameKey name = new Project.NameKey("a");
+    Project.NameKey name = Project.nameKey("a");
     repoManager.createRepository(name);
     createSymLink(name, "b.git");
-    repoManager.createRepository(new Project.NameKey("B"));
+    assertThrows(
+        RepositoryCaseMismatchException.class,
+        () -> repoManager.createRepository(Project.nameKey("B")));
   }
 
-  @Test(expected = RepositoryCaseMismatchException.class)
+  @Test
   public void testNameCaseMismatchAfterRestart() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    Project.NameKey name = new Project.NameKey("a");
+    Project.NameKey name = Project.nameKey("a");
     repoManager.createRepository(name);
 
     LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
-    newRepoManager.createRepository(new Project.NameKey("A"));
+    assertThrows(
+        RepositoryCaseMismatchException.class,
+        () -> newRepoManager.createRepository(Project.nameKey("A")));
   }
 
   private void createSymLink(Project.NameKey project, String link) throws IOException {
@@ -219,20 +259,22 @@
     Files.createSymbolicLink(symlink, projectDir);
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testOpenRepositoryInvalidName() throws Exception {
-    repoManager.openRepository(new Project.NameKey("project%?|<>A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.openRepository(Project.nameKey("project%?|<>A")));
   }
 
   @Test
   public void list() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
+    Project.NameKey projectA = Project.nameKey("projectA");
     createRepository(repoManager.getBasePath(projectA), projectA.get());
 
-    Project.NameKey projectB = new Project.NameKey("path/projectB");
+    Project.NameKey projectB = Project.nameKey("path/projectB");
     createRepository(repoManager.getBasePath(projectB), projectB.get());
 
-    Project.NameKey projectC = new Project.NameKey("anotherPath/path/projectC");
+    Project.NameKey projectC = Project.nameKey("anotherPath/path/projectC");
     createRepository(repoManager.getBasePath(projectC), projectC.get());
     // create an invalid git repo named only .git
     repoManager.getBasePath(null).resolve(".git").toFile().mkdir();
diff --git a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index e848fa3..491594b 100644
--- a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.easymock.EasyMock.createNiceMock;
 import static org.easymock.EasyMock.expect;
 import static org.easymock.EasyMock.replay;
@@ -24,8 +25,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.testing.GerritBaseTests;
-import com.google.gerrit.testing.TempFileUtil;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -37,11 +36,14 @@
 import org.eclipse.jgit.lib.RepositoryCache;
 import org.eclipse.jgit.lib.RepositoryCache.FileKey;
 import org.eclipse.jgit.util.FS;
-import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 
-public class MultiBaseLocalDiskRepositoryManagerTest extends GerritBaseTests {
+public class MultiBaseLocalDiskRepositoryManagerTest {
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
   private Config cfg;
   private SitePaths site;
   private MultiBaseLocalDiskRepositoryManager repoManager;
@@ -49,7 +51,7 @@
 
   @Before
   public void setUp() throws IOException {
-    site = new SitePaths(TempFileUtil.createTempDirectory().toPath());
+    site = new SitePaths(temporaryFolder.newFolder().toPath());
     site.resolve("git").toFile().mkdir();
     cfg = new Config();
     cfg.setString("gerrit", null, "basePath", "git");
@@ -59,15 +61,10 @@
     repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
   }
 
-  @After
-  public void tearDown() throws IOException {
-    TempFileUtil.cleanup();
-  }
-
   @Test
   public void defaultRepositoryLocation()
       throws RepositoryCaseMismatchException, RepositoryNotFoundException, IOException {
-    Project.NameKey someProjectKey = new Project.NameKey("someProject");
+    Project.NameKey someProjectKey = Project.nameKey("someProject");
     Repository repo = repoManager.createRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
     assertThat(repo.getDirectory().exists()).isTrue();
@@ -91,8 +88,8 @@
 
   @Test
   public void alternateRepositoryLocation() throws IOException {
-    Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
-    Project.NameKey someProjectKey = new Project.NameKey("someProject");
+    Path alternateBasePath = temporaryFolder.newFolder().toPath();
+    Project.NameKey someProjectKey = Project.nameKey("someProject");
     reset(configMock);
     expect(configMock.getBasePath(someProjectKey)).andReturn(alternateBasePath).anyTimes();
     expect(configMock.getAllBasePaths()).andReturn(ImmutableList.of(alternateBasePath)).anyTimes();
@@ -119,12 +116,12 @@
 
   @Test
   public void listReturnRepoFromProperLocation() throws IOException {
-    Project.NameKey basePathProject = new Project.NameKey("basePathProject");
-    Project.NameKey altPathProject = new Project.NameKey("altPathProject");
-    Project.NameKey misplacedProject1 = new Project.NameKey("misplacedProject1");
-    Project.NameKey misplacedProject2 = new Project.NameKey("misplacedProject2");
+    Project.NameKey basePathProject = Project.nameKey("basePathProject");
+    Project.NameKey altPathProject = Project.nameKey("altPathProject");
+    Project.NameKey misplacedProject1 = Project.nameKey("misplacedProject1");
+    Project.NameKey misplacedProject2 = Project.nameKey("misplacedProject2");
 
-    Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
+    Path alternateBasePath = temporaryFolder.newFolder().toPath();
 
     reset(configMock);
     expect(configMock.getBasePath(altPathProject)).andReturn(alternateBasePath).anyTimes();
@@ -153,11 +150,17 @@
     }
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testRelativeAlternateLocation() {
-    configMock = createNiceMock(RepositoryConfig.class);
-    expect(configMock.getAllBasePaths()).andReturn(ImmutableList.of(Paths.get("repos"))).anyTimes();
-    replay(configMock);
-    repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+    assertThrows(
+        IllegalStateException.class,
+        () -> {
+          configMock = createNiceMock(RepositoryConfig.class);
+          expect(configMock.getAllBasePaths())
+              .andReturn(ImmutableList.of(Paths.get("repos")))
+              .anyTimes();
+          replay(configMock);
+          repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+        });
   }
 }
diff --git a/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java b/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java
new file mode 100644
index 0000000..29d89bc
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteArray;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.protobuf.ByteString;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class PureRevertCacheKeyTest {
+  @Test
+  public void serialization() {
+    ObjectId revert = ObjectId.zeroId();
+    ObjectId original = ObjectId.fromString("aabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
+
+    byte[] serializedRevert =
+        new byte[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
+    byte[] serializedOriginal =
+        byteArray(
+            0xaa, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
+            0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb);
+
+    Cache.PureRevertKeyProto key = PureRevertCache.key(Project.nameKey("test"), revert, original);
+    assertThat(key)
+        .isEqualTo(
+            Cache.PureRevertKeyProto.newBuilder()
+                .setProject("test")
+                .setClaimedRevert(ByteString.copyFrom(serializedRevert))
+                .setClaimedOriginal(ByteString.copyFrom(serializedOriginal))
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
new file mode 100644
index 0000000..e3ab8d0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
+import org.junit.Test;
+
+public class TagSetHolderTest {
+  @Test
+  public void serializerWithTagSet() throws Exception {
+    TagSetHolder holder = new TagSetHolder(Project.nameKey("project"));
+    holder.setTagSet(new TagSet(holder.getProjectName()));
+
+    byte[] serialized = TagSetHolder.Serializer.INSTANCE.serialize(holder);
+    assertThat(TagSetHolderProto.parseFrom(serialized))
+        .ignoringRepeatedFieldOrder()
+        .isEqualTo(
+            TagSetHolderProto.newBuilder()
+                .setProjectName("project")
+                .setTags(holder.getTagSet().toProto())
+                .build());
+
+    TagSetHolder deserialized = TagSetHolder.Serializer.INSTANCE.deserialize(serialized);
+    assertThat(deserialized.getProjectName()).isEqualTo(holder.getProjectName());
+    TagSetTest.assertEqual(holder.getTagSet(), deserialized.getTagSet());
+  }
+
+  @Test
+  public void serializerWithoutTagSet() throws Exception {
+    TagSetHolder holder = new TagSetHolder(Project.nameKey("project"));
+
+    byte[] serialized = TagSetHolder.Serializer.INSTANCE.serialize(holder);
+    assertThat(TagSetHolderProto.parseFrom(serialized))
+        .ignoringRepeatedFieldOrder()
+        .isEqualTo(TagSetHolderProto.newBuilder().setProjectName("project").build());
+
+    TagSetHolder deserialized = TagSetHolder.Serializer.INSTANCE.deserialize(serialized);
+    assertThat(deserialized.getProjectName()).isEqualTo(holder.getProjectName());
+    TagSetTest.assertEqual(holder.getTagSet(), deserialized.getTagSet());
+  }
+
+  @Test
+  public void fields() {
+    assertThatSerializedClass(TagSetHolder.class)
+        .hasFields(
+            ImmutableMap.of(
+                "buildLock", Object.class,
+                "projectName", Project.NameKey.class,
+                "tags", TagSet.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/TagSetTest.java b/javatests/com/google/gerrit/server/git/TagSetTest.java
new file mode 100644
index 0000000..7d90d8c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/TagSetTest.java
@@ -0,0 +1,190 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.CachedRefProto;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
+import com.google.gerrit.server.git.TagSet.CachedRef;
+import com.google.gerrit.server.git.TagSet.Tag;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectIdOwnerMap;
+import org.junit.Test;
+
+public class TagSetTest {
+  @Test
+  public void roundTripToProto() {
+    HashMap<String, CachedRef> refs = new HashMap<>();
+    refs.put(
+        "refs/heads/master",
+        new CachedRef(1, ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")));
+    refs.put(
+        "refs/heads/branch",
+        new CachedRef(2, ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")));
+    ObjectIdOwnerMap<Tag> tags = new ObjectIdOwnerMap<>();
+    tags.add(
+        new Tag(
+            ObjectId.fromString("cccccccccccccccccccccccccccccccccccccccc"), newBitSet(1, 3, 5)));
+    tags.add(
+        new Tag(
+            ObjectId.fromString("dddddddddddddddddddddddddddddddddddddddd"), newBitSet(2, 4, 6)));
+    TagSet tagSet = new TagSet(Project.nameKey("project"), refs, tags);
+
+    TagSetProto proto = tagSet.toProto();
+    assertThat(proto)
+        .ignoringRepeatedFieldOrder()
+        .isEqualTo(
+            TagSetProto.newBuilder()
+                .setProjectName("project")
+                .putRef(
+                    "refs/heads/master",
+                    CachedRefProto.newBuilder()
+                        .setId(
+                            byteString(
+                                0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
+                                0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa))
+                        .setFlag(1)
+                        .build())
+                .putRef(
+                    "refs/heads/branch",
+                    CachedRefProto.newBuilder()
+                        .setId(
+                            byteString(
+                                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
+                                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb))
+                        .setFlag(2)
+                        .build())
+                .addTag(
+                    TagProto.newBuilder()
+                        .setId(
+                            byteString(
+                                0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc,
+                                0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc))
+                        .setFlags(byteString(0x2a))
+                        .build())
+                .addTag(
+                    TagProto.newBuilder()
+                        .setId(
+                            byteString(
+                                0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd,
+                                0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd))
+                        .setFlags(byteString(0x54))
+                        .build())
+                .build());
+
+    assertEqual(tagSet, TagSet.fromProto(proto));
+  }
+
+  @Test
+  public void tagSetFields() {
+    assertThatSerializedClass(TagSet.class)
+        .hasFields(
+            ImmutableMap.of(
+                "projectName", Project.NameKey.class,
+                "refs", new TypeLiteral<Map<String, CachedRef>>() {}.getType(),
+                "tags", new TypeLiteral<ObjectIdOwnerMap<Tag>>() {}.getType()));
+  }
+
+  @Test
+  public void cachedRefFields() {
+    assertThatSerializedClass(CachedRef.class)
+        .extendsClass(new TypeLiteral<AtomicReference<ObjectId>>() {}.getType());
+    assertThatSerializedClass(CachedRef.class)
+        .hasFields(
+            ImmutableMap.of(
+                "flag", int.class, "value", AtomicReference.class.getTypeParameters()[0]));
+  }
+
+  @Test
+  public void tagFields() {
+    assertThatSerializedClass(Tag.class).extendsClass(ObjectIdOwnerMap.Entry.class);
+    assertThatSerializedClass(Tag.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("refFlags", BitSet.class)
+                .put("next", ObjectIdOwnerMap.Entry.class)
+                .put("w1", int.class)
+                .put("w2", int.class)
+                .put("w3", int.class)
+                .put("w4", int.class)
+                .put("w5", int.class)
+                .build());
+  }
+
+  // TODO(dborowitz): Find some more common place to put this method, which requires access to
+  // package-private TagSet details.
+  static void assertEqual(@Nullable TagSet a, @Nullable TagSet b) {
+    if (a == null || b == null) {
+      assertWithMessage("only one TagSet is null out of\n%s\n%s", a, b)
+          .that(a == null && b == null)
+          .isTrue();
+      return;
+    }
+    assertThat(a.getProjectName()).isEqualTo(b.getProjectName());
+
+    Map<String, CachedRef> aRefs = a.getRefsForTesting();
+    Map<String, CachedRef> bRefs = b.getRefsForTesting();
+    assertWithMessage("ref name set")
+        .that(ImmutableSortedSet.copyOf(aRefs.keySet()))
+        .isEqualTo(ImmutableSortedSet.copyOf(bRefs.keySet()));
+    for (String name : aRefs.keySet()) {
+      CachedRef aRef = aRefs.get(name);
+      CachedRef bRef = bRefs.get(name);
+      assertWithMessage("value of ref %s", name).that(aRef.get()).isEqualTo(bRef.get());
+      assertWithMessage("flag of ref %s", name).that(aRef.flag).isEqualTo(bRef.flag);
+    }
+
+    ObjectIdOwnerMap<Tag> aTags = a.getTagsForTesting();
+    ObjectIdOwnerMap<Tag> bTags = b.getTagsForTesting();
+    assertWithMessage("tag ID set").that(getTagIds(aTags)).isEqualTo(getTagIds(bTags));
+    for (Tag aTag : aTags) {
+      Tag bTag = bTags.get(aTag);
+      assertWithMessage("flags for tag %s", aTag.name())
+          .that(aTag.refFlags)
+          .isEqualTo(bTag.refFlags);
+    }
+  }
+
+  private static ImmutableSortedSet<String> getTagIds(ObjectIdOwnerMap<Tag> bTags) {
+    return Streams.stream(bTags)
+        .map(Tag::name)
+        .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.naturalOrder()));
+  }
+
+  private BitSet newBitSet(int... bits) {
+    BitSet result = new BitSet();
+    Arrays.stream(bits).forEach(result::set);
+    return result;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 6090a78..e14b526 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -22,11 +22,11 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.VersionedMetaData.BatchMetaDataUpdate;
-import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
 import java.util.Arrays;
@@ -64,7 +64,7 @@
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-    project = new Project.NameKey("repo");
+    project = Project.nameKey("repo");
     repo = new InMemoryRepository(new DfsRepositoryDescription(project.get()));
   }
 
@@ -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..c605e86 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.reviewdb.client.Account;
@@ -28,7 +27,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -44,7 +43,7 @@
 import org.junit.Ignore;
 
 @Ignore
-public class AbstractGroupTest extends GerritBaseTests {
+public class AbstractGroupTest {
   protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
   protected static final String SERVER_ID = "server-id";
   protected static final String SERVER_NAME = "Gerrit Server";
@@ -65,9 +64,9 @@
     allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repoManager = new InMemoryRepositoryManager();
     allUsersRepo = repoManager.createRepository(allUsersName);
-    serverAccountId = new Account.Id(SERVER_ACCOUNT_NUMBER);
+    serverAccountId = Account.id(SERVER_ACCOUNT_NUMBER);
     serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
-    userId = new Account.Id(USER_ACCOUNT_NUMBER);
+    userId = Account.id(USER_ACCOUNT_NUMBER);
     userIdent = newPersonIdent(userId, serverIdent);
   }
 
@@ -124,9 +123,9 @@
   }
 
   private static Optional<Account> getAccount(Account.Id id) {
-    Account account = new Account(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
     account.setFullName("Account " + id);
-    return Optional.of(account);
+    return Optional.of(account.build());
   }
 
   private Optional<GroupDescription.Basic> getGroup(AccountGroup.UUID uuid) {
@@ -140,7 +139,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..060079f 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAudit;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.group.InternalGroup;
@@ -37,7 +37,7 @@
 
   @Before
   public void setUp() throws Exception {
-    auditLogReader = new AuditLogReader(SERVER_ID);
+    auditLogReader = new AuditLogReader(SERVER_ID, allUsersName);
   }
 
   @Test
@@ -66,7 +66,7 @@
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
 
     // User adds account 100002 to the group.
-    Account.Id id = new Account.Id(100002);
+    Account.Id id = Account.id(100002);
     addMembers(uuid, ImmutableSet.of(id));
 
     AccountGroupMemberAudit expAudit2 =
@@ -78,7 +78,7 @@
     // User removes account 100002 from the group.
     removeMembers(uuid, ImmutableSet.of(id));
 
-    expAudit2.removed(userId, getTipTimestamp(uuid));
+    expAudit2 = expAudit2.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
         .containsExactly(expAudit1, expAudit2)
         .inOrder();
@@ -94,8 +94,8 @@
         createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
 
-    Account.Id id1 = new Account.Id(100002);
-    Account.Id id2 = new Account.Id(100003);
+    Account.Id id1 = Account.id(100002);
+    Account.Id id2 = Account.id(100003);
     addMembers(uuid, ImmutableSet.of(id1, id2));
 
     AccountGroupMemberAudit expAudit2 =
@@ -118,13 +118,13 @@
 
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid));
 
-    AccountGroupByIdAud expAudit =
+    AccountGroupByIdAudit expAudit =
         createExpGroupAudit(group.getId(), subgroupUuid, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
 
     removeSubgroups(uuid, ImmutableSet.of(subgroupUuid));
 
-    expAudit.removed(userId, getTipTimestamp(uuid));
+    expAudit = expAudit.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
   }
 
@@ -140,9 +140,9 @@
 
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid2));
 
-    AccountGroupByIdAud expAudit1 =
+    AccountGroupByIdAudit expAudit1 =
         createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
-    AccountGroupByIdAud expAudit2 =
+    AccountGroupByIdAudit expAudit2 =
         createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expAudit1, expAudit2)
@@ -158,9 +158,9 @@
         createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expMemberAudit);
 
-    Account.Id id1 = new Account.Id(100002);
-    Account.Id id2 = new Account.Id(100003);
-    Account.Id id3 = new Account.Id(100004);
+    Account.Id id1 = Account.id(100002);
+    Account.Id id2 = Account.id(100003);
+    Account.Id id3 = Account.id(100004);
     InternalGroup subgroup1 = createGroupAsUser(2, "test-group-2");
     InternalGroup subgroup2 = createGroupAsUser(3, "test-group-3");
     InternalGroup subgroup3 = createGroupAsUser(4, "test-group-4");
@@ -180,23 +180,23 @@
 
     // Add one subgroup.
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
-    AccountGroupByIdAud expGroupAudit1 =
+    AccountGroupByIdAudit expGroupAudit1 =
         createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1);
 
     // Remove one account.
     removeMembers(uuid, ImmutableSet.of(id2));
-    expMemberAudit2.removed(userId, getTipTimestamp(uuid));
+    expMemberAudit2 = expMemberAudit2.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
         .containsExactly(expMemberAudit, expMemberAudit1, expMemberAudit2)
         .inOrder();
 
     // Add two subgroups.
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid2, subgroupUuid3));
-    AccountGroupByIdAud expGroupAudit2 =
+    AccountGroupByIdAudit expGroupAudit2 =
         createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
-    AccountGroupByIdAud expGroupAudit3 =
+    AccountGroupByIdAudit expGroupAudit3 =
         createExpGroupAudit(group.getId(), subgroupUuid3, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
@@ -215,15 +215,15 @@
 
     // Remove two subgroups.
     removeSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid3));
-    expGroupAudit1.removed(userId, getTipTimestamp(uuid));
-    expGroupAudit3.removed(userId, getTipTimestamp(uuid));
+    expGroupAudit1 = expGroupAudit1.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
+    expGroupAudit3 = expGroupAudit3.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
         .inOrder();
 
     // Add back one removed subgroup.
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
-    AccountGroupByIdAud expGroupAudit4 =
+    AccountGroupByIdAudit expGroupAudit4 =
         createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3, expGroupAudit4)
@@ -239,8 +239,8 @@
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(GroupUUID.make(groupName, serverIdent))
-            .setNameKey(new AccountGroup.NameKey(groupName))
-            .setId(new AccountGroup.Id(next))
+            .setNameKey(AccountGroup.nameKey(groupName))
+            .setId(AccountGroup.id(next))
             .build();
     InternalGroupUpdate groupUpdate =
         authorIdent.equals(serverIdent)
@@ -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));
   }
@@ -302,12 +303,21 @@
 
   private static AccountGroupMemberAudit createExpMemberAudit(
       AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Timestamp addedOn) {
-    return new AccountGroupMemberAudit(
-        new AccountGroupMemberAudit.Key(id, groupId, addedOn), addedBy);
+    return AccountGroupMemberAudit.builder()
+        .groupId(groupId)
+        .memberId(id)
+        .addedOn(addedOn)
+        .addedBy(addedBy)
+        .build();
   }
 
-  private static AccountGroupByIdAud createExpGroupAudit(
+  private static AccountGroupByIdAudit createExpGroupAudit(
       AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Timestamp addedOn) {
-    return new AccountGroupByIdAud(new AccountGroupByIdAud.Key(groupId, uuid, addedOn), addedBy);
+    return AccountGroupByIdAudit.builder()
+        .groupId(groupId)
+        .includeUuid(uuid)
+        .addedOn(addedOn)
+        .addedBy(addedBy)
+        .build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
index eee5529..b4652c9 100644
--- a/javatests/com/google/gerrit/server/group/db/BUILD
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -8,16 +8,18 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/common/data/testing:common-data-test-util",
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/group/db/testing",
         "//java/com/google/gerrit/server/group/testing",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/truth",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index d03a38b..d092eed 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -16,13 +16,15 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.MatcherAssert.assertThat;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -32,9 +34,8 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.truth.OptionalSubject;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.time.LocalDate;
@@ -53,29 +54,22 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class GroupConfigTest {
-  static {
-    // Necessary so that toString() methods of ReviewDb entities work correctly.
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
-  @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");
-  private final AccountGroup.NameKey groupName = new AccountGroup.NameKey("users");
-  private final AccountGroup.Id groupId = new AccountGroup.Id(123);
+  private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
+  private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
+  private final AccountGroup.Id groupId = AccountGroup.id(123);
   private final AuditLogFormatter auditLogFormatter =
       AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), "server-id");
   private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
 
   @Before
   public void setUp() throws Exception {
+    projectName = Project.nameKey("Test Repository");
     repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
     testRepository = new TestRepository<>(repository);
   }
@@ -102,7 +96,7 @@
 
   @Test
   public void nameOfGroupUpdateOverridesGroupCreation() throws Exception {
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("Another name");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("Another name");
 
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setNameKey(groupName).build();
@@ -116,26 +110,13 @@
   @Test
   public void nameOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation =
-        getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey("")).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+        getPrefilledGroupCreationBuilder().setNameKey(AccountGroup.nameKey("")).build();
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
-      expectedException.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
-  public void nameOfNewGroupMustNotBeNull() throws Exception {
-    InternalGroupCreation groupCreation =
-        getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
-      expectedException.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
 
@@ -151,13 +132,13 @@
   @Test
   public void idOfNewGroupMustNotBeNegative() throws Exception {
     InternalGroupCreation groupCreation =
-        getPrefilledGroupCreationBuilder().setId(new AccountGroup.Id(-2)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+        getPrefilledGroupCreationBuilder().setId(AccountGroup.id(-2)).build();
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
-      expectedException.expectMessage("ID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
     }
   }
 
@@ -214,7 +195,7 @@
 
   @Test
   public void specifiedOwnerGroupUuidIsRespectedForNewGroup() throws Exception {
-    AccountGroup.UUID ownerGroupUuid = new AccountGroup.UUID("anotherOwnerUuid");
+    AccountGroup.UUID ownerGroupUuid = AccountGroup.uuid("anotherOwnerUuid");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
@@ -226,32 +207,17 @@
   }
 
   @Test
-  public void ownerGroupUuidOfNewGroupMustNotBeNull() throws Exception {
-    InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
-      expectedException.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
   public void ownerGroupUuidOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
-      expectedException.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
 
@@ -310,8 +276,8 @@
 
   @Test
   public void specifiedMembersAreRespectedForNewGroup() throws Exception {
-    Account.Id member1 = new Account.Id(1);
-    Account.Id member2 = new Account.Id(2);
+    Account.Id member1 = Account.id(1);
+    Account.Id member2 = Account.id(2);
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
@@ -326,8 +292,8 @@
 
   @Test
   public void specifiedSubgroupsAreRespectedForNewGroup() throws Exception {
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("subgroup1");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("subgroup2");
+    AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroup1");
+    AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroup2");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
@@ -360,9 +326,11 @@
   public void idInConfigMustBeDefined() throws Exception {
     populateGroupConfig(groupUuid, "[group]\n\tname = users\n\townerGroupUuid = owners\n");
 
-    expectedException.expect(ConfigInvalidException.class);
-    expectedException.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(repository, groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () -> GroupConfig.loadForGroup(projectName, repository, groupUuid));
+    assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
   }
 
   @Test
@@ -370,9 +338,11 @@
     populateGroupConfig(
         groupUuid, "[group]\n\tname = users\n\tid = -5\n\townerGroupUuid = owners\n");
 
-    expectedException.expect(ConfigInvalidException.class);
-    expectedException.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(repository, groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () -> GroupConfig.loadForGroup(projectName, repository, groupUuid));
+    assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
   }
 
   @Test
@@ -396,9 +366,11 @@
   public void ownerGroupUuidInConfigMustBeDefined() throws Exception {
     populateGroupConfig(groupUuid, "[group]\n\tname = users\n\tid = 42\n");
 
-    expectedException.expect(ConfigInvalidException.class);
-    expectedException.expectMessage("Owner UUID of the group " + groupUuid);
-    GroupConfig.loadForGroup(repository, groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () -> GroupConfig.loadForGroup(projectName, repository, groupUuid));
+    assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
   }
 
   @Test
@@ -437,12 +409,12 @@
         .value()
         .members()
         .containsExactly(
-            new Account.Id(1),
-            new Account.Id(2),
-            new Account.Id(3),
-            new Account.Id(4),
-            new Account.Id(5),
-            new Account.Id(6));
+            Account.id(1),
+            Account.id(2),
+            Account.id(3),
+            Account.id(4),
+            Account.id(5),
+            Account.id(6));
   }
 
   @Test
@@ -450,9 +422,9 @@
     populateGroupConfig(groupUuid, "[group]\n\tname=users\n\tid = 42\n\townerGroupUuid = owners\n");
     populateMembersFile(groupUuid, "One");
 
-    expectedException.expect(ConfigInvalidException.class);
-    expectedException.expectMessage("Invalid file members");
-    loadGroup(groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(ConfigInvalidException.class, () -> loadGroup(groupUuid));
+    assertThat(thrown).hasMessageThat().contains("Invalid file members");
   }
 
   @Test
@@ -460,9 +432,9 @@
     populateGroupConfig(groupUuid, "[group]\n\tname=users\n\tid = 42\n\townerGroupUuid = owners\n");
     populateMembersFile(groupUuid, "1\t2");
 
-    expectedException.expect(ConfigInvalidException.class);
-    expectedException.expectMessage("Invalid file members");
-    loadGroup(groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(ConfigInvalidException.class, () -> loadGroup(groupUuid));
+    assertThat(thrown).hasMessageThat().contains("Invalid file members");
   }
 
   @Test
@@ -501,12 +473,12 @@
         .value()
         .subgroups()
         .containsExactly(
-            new AccountGroup.UUID("1"),
-            new AccountGroup.UUID("2"),
-            new AccountGroup.UUID("3"),
-            new AccountGroup.UUID("4"),
-            new AccountGroup.UUID("5"),
-            new AccountGroup.UUID("6"));
+            AccountGroup.uuid("1"),
+            AccountGroup.uuid("2"),
+            AccountGroup.uuid("3"),
+            AccountGroup.uuid("4"),
+            AccountGroup.uuid("5"),
+            AccountGroup.uuid("6"));
   }
 
   @Test
@@ -515,7 +487,7 @@
     populateSubgroupsFile(groupUuid, "1\t2 3");
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
-    assertThatGroup(group).value().subgroups().containsExactly(new AccountGroup.UUID("1\t2 3"));
+    assertThatGroup(group).value().subgroups().containsExactly(AccountGroup.uuid("1\t2 3"));
   }
 
   @Test
@@ -527,13 +499,13 @@
     assertThatGroup(group)
         .value()
         .subgroups()
-        .containsExactly(new AccountGroup.UUID("1\t2"), new AccountGroup.UUID("3"));
+        .containsExactly(AccountGroup.uuid("1\t2"), AccountGroup.uuid("3"));
   }
 
   @Test
   public void nameCanBeUpdated() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.NameKey newName = new AccountGroup.NameKey("New name");
+    AccountGroup.NameKey newName = AccountGroup.nameKey("New name");
 
     InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(newName).build();
     updateGroup(groupUuid, groupUpdate);
@@ -543,43 +515,27 @@
   }
 
   @Test
-  public void nameCannotBeUpdatedToNull() throws Exception {
-    createArbitraryGroup(groupUuid);
-
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(null)).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
-      expectedException.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
   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();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
-      expectedException.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
 
   @Test
   public void nameCanBeUpdatedToEmptyStringIfExplicitlySpecified() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.NameKey emptyName = new AccountGroup.NameKey("");
+    AccountGroup.NameKey emptyName = 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);
@@ -615,7 +571,7 @@
   @Test
   public void ownerGroupUuidCanBeUpdated() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.UUID newOwnerGroupUuid = new AccountGroup.UUID("New owner");
+    AccountGroup.UUID newOwnerGroupUuid = AccountGroup.uuid("New owner");
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(newOwnerGroupUuid).build();
@@ -626,34 +582,18 @@
   }
 
   @Test
-  public void ownerGroupUuidCannotBeUpdatedToNull() throws Exception {
-    createArbitraryGroup(groupUuid);
-
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
-      expectedException.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
   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();
+        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
-      expectedException.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
 
@@ -682,7 +622,7 @@
 
     InternalGroupUpdate laterGroupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     Optional<InternalGroup> group = updateGroup(groupCreation.getGroupUUID(), laterGroupUpdate);
@@ -695,8 +635,8 @@
   @Test
   public void membersCanBeAdded() throws Exception {
     createArbitraryGroup(groupUuid);
-    Account.Id member1 = new Account.Id(1);
-    Account.Id member2 = new Account.Id(2);
+    Account.Id member1 = Account.id(1);
+    Account.Id member2 = Account.id(2);
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -717,8 +657,8 @@
   @Test
   public void membersCanBeDeleted() throws Exception {
     createArbitraryGroup(groupUuid);
-    Account.Id member1 = new Account.Id(1);
-    Account.Id member2 = new Account.Id(2);
+    Account.Id member1 = Account.id(1);
+    Account.Id member2 = Account.id(2);
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -739,8 +679,8 @@
   @Test
   public void subgroupsCanBeAdded() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("subgroups1");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("subgroups2");
+    AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
+    AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -761,8 +701,8 @@
   @Test
   public void subgroupsCanBeDeleted() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("subgroups1");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("subgroups2");
+    AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
+    AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -805,13 +745,12 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setDescription("A test group")
-            .setOwnerGroupUUID(new AccountGroup.UUID("another owner"))
+            .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(92900892))
-            .setMemberModification(members -> ImmutableSet.of(new Account.Id(1), new Account.Id(2)))
-            .setSubgroupModification(
-                subgroups -> ImmutableSet.of(new AccountGroup.UUID("subgroup")))
+            .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
+            .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
     Optional<InternalGroup> createdGroup = createGroup(groupCreation, groupUpdate);
@@ -827,13 +766,12 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setDescription("A test group")
-            .setOwnerGroupUUID(new AccountGroup.UUID("another owner"))
+            .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(92900892))
-            .setMemberModification(members -> ImmutableSet.of(new Account.Id(1), new Account.Id(2)))
-            .setSubgroupModification(
-                subgroups -> ImmutableSet.of(new AccountGroup.UUID("subgroup")))
+            .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
+            .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
     Optional<InternalGroup> updatedGroup = updateGroup(groupUuid, groupUpdate);
@@ -850,19 +788,18 @@
     InternalGroupUpdate initialGroupUpdate =
         InternalGroupUpdate.builder()
             .setDescription("A test group")
-            .setOwnerGroupUUID(new AccountGroup.UUID("another owner"))
+            .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(92900892))
-            .setMemberModification(members -> ImmutableSet.of(new Account.Id(1), new Account.Id(2)))
-            .setSubgroupModification(
-                subgroups -> ImmutableSet.of(new AccountGroup.UUID("subgroup")))
+            .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
+            .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
     createGroup(groupCreation, initialGroupUpdate);
 
     // Only update one of the properties.
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
 
     Optional<InternalGroup> updatedGroup = updateGroup(groupCreation.getGroupUUID(), groupUpdate);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupCreation.getGroupUUID());
@@ -874,10 +811,10 @@
   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");
+    AccountGroup.NameKey name = AccountGroup.nameKey("Robots");
     InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(name).build();
     groupConfig.setGroupUpdate(groupUpdate1, auditLogFormatter);
     commit(groupConfig);
@@ -914,7 +851,7 @@
     RevCommit commitAfterCreation = getLatestCommitForGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
     updateGroup(groupUuid, groupUpdate);
 
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
@@ -997,9 +934,7 @@
     createArbitraryGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
-            .setOwnerGroupUUID(new AccountGroup.UUID("Another owner"))
-            .build();
+        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("Another owner")).build();
     updateGroup(groupUuid, groupUpdate);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
@@ -1015,8 +950,7 @@
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setMemberModification(
-                members -> Sets.union(members, ImmutableSet.of(new Account.Id(10))))
+            .setMemberModification(members -> Sets.union(members, ImmutableSet.of(Account.id(10))))
             .build();
     updateGroup(groupUuid, groupUpdate);
 
@@ -1034,8 +968,7 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setSubgroupModification(
-                subgroups ->
-                    Sets.union(subgroups, ImmutableSet.of(new AccountGroup.UUID("subgroup"))))
+                subgroups -> Sets.union(subgroups, ImmutableSet.of(AccountGroup.uuid("subgroup"))))
             .build();
     updateGroup(groupUuid, groupUpdate);
 
@@ -1052,9 +985,9 @@
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
 
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(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 +1005,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);
 
@@ -1135,10 +1068,10 @@
             .build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(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 =
@@ -1168,10 +1101,10 @@
             .build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(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 =
@@ -1194,7 +1127,7 @@
 
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
     updateGroup(groupUuid, groupUpdate);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1209,7 +1142,7 @@
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(updatedOnAsSecondsSinceEpoch * 1000))
             .build();
     updateGroup(groupUuid, groupUpdate);
@@ -1227,10 +1160,10 @@
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(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 =
@@ -1255,10 +1188,10 @@
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(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 =
@@ -1288,18 +1221,19 @@
   public void groupCanBeLoadedAtASpecificRevision() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    AccountGroup.NameKey firstName = new AccountGroup.NameKey("Bots");
+    AccountGroup.NameKey firstName = AccountGroup.nameKey("Bots");
     InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(firstName).build();
     updateGroup(groupUuid, groupUpdate1);
 
     RevCommit commitAfterUpdate1 = getLatestCommitForGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Robots")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Robots")).build();
     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());
@@ -1321,7 +1255,7 @@
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
     createGroup(groupCreation, groupUpdate);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1330,8 +1264,8 @@
 
   @Test
   public void commitMessageOfNewGroupWithMembersContainsFooters() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
 
     AuditLogFormatter auditLogFormatter =
@@ -1341,10 +1275,10 @@
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(account13.getId(), account7.getId()))
+            .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
 
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
 
@@ -1355,8 +1289,8 @@
 
   @Test
   public void commitMessageOfNewGroupWithSubgroupsContainsFooters() throws Exception {
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     AuditLogFormatter auditLogFormatter =
@@ -1369,7 +1303,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);
 
@@ -1380,8 +1314,8 @@
 
   @Test
   public void commitMessageOfMemberAdditionContainsFooters() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
 
     createArbitraryGroup(groupUuid);
@@ -1391,7 +1325,7 @@
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(account13.getId(), account7.getId()))
+            .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
     updateGroup(groupUuid, groupUpdate, auditLogFormatter);
 
@@ -1402,8 +1336,8 @@
 
   @Test
   public void commitMessageOfMemberRemovalContainsFooters() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
 
     createArbitraryGroup(groupUuid);
@@ -1413,13 +1347,13 @@
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(account13.getId(), account7.getId()))
+            .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
     updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
 
     InternalGroupUpdate groupUpdate2 =
         InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(account7.getId()))
+            .setMemberModification(members -> ImmutableSet.of(account7.id()))
             .build();
     updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
 
@@ -1429,8 +1363,8 @@
 
   @Test
   public void commitMessageOfSubgroupAdditionContainsFooters() throws Exception {
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     createArbitraryGroup(groupUuid);
@@ -1452,8 +1386,8 @@
 
   @Test
   public void commitMessageOfSubgroupRemovalContainsFooters() throws Exception {
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     createArbitraryGroup(groupUuid);
@@ -1484,11 +1418,11 @@
     createArbitraryGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Old name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Old name")).build();
     updateGroup(groupUuid, groupUpdate1);
 
     InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("New name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("New name")).build();
     updateGroup(groupUuid, groupUpdate2);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1498,11 +1432,11 @@
 
   @Test
   public void commitMessageFootersCanBeMixed() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     createArbitraryGroup(groupUuid);
@@ -1512,16 +1446,16 @@
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Old name"))
-            .setMemberModification(members -> ImmutableSet.of(account7.getId()))
+            .setName(AccountGroup.nameKey("Old name"))
+            .setMemberModification(members -> ImmutableSet.of(account7.id()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group2.getGroupUUID()))
             .build();
     updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
 
     InternalGroupUpdate groupUpdate2 =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("New name"))
-            .setMemberModification(members -> ImmutableSet.of(account13.getId()))
+            .setName(AccountGroup.nameKey("New name"))
+            .setMemberModification(members -> ImmutableSet.of(account13.id()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group1.getGroupUUID()))
             .build();
     updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
@@ -1584,14 +1518,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 +1539,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();
   }
 
@@ -1629,7 +1563,7 @@
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
-            GitReferenceUpdated.DISABLED, new Project.NameKey("Test Repository"), repository);
+            GitReferenceUpdated.DISABLED, Project.nameKey("Test Repository"), repository);
     metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
     metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
     return metaDataUpdate;
@@ -1647,9 +1581,9 @@
   }
 
   private static Account createAccount(Account.Id id, String name) {
-    Account account = new Account(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
     account.setFullName(name);
-    return account;
+    return account.build();
   }
 
   private static GroupDescription.Basic createGroup(AccountGroup.UUID uuid, String name) {
@@ -1680,6 +1614,6 @@
 
   private static OptionalSubject<InternalGroupSubject, InternalGroup> assertThatGroup(
       Optional<InternalGroup> loadedGroup) {
-    return assertThat(loadedGroup, InternalGroupSubject::assertThat);
+    return assertThat(loadedGroup, internalGroups());
   }
 }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index 3616e0e..9f0b340 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -15,33 +15,34 @@
 package com.google.gerrit.server.group.db;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.common.data.testing.GroupReferenceSubject.groupReferences;
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.commits;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_GROUPNAMES;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.testing.GroupReferenceSubject;
+import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.testing.CommitInfoSubject;
+import com.google.gerrit.git.RefUpdateUtil;
 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;
-import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GitTestUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.StandardKeyEncoder;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
@@ -65,31 +66,25 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class GroupNameNotesTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
   private static final String SERVER_NAME = "Gerrit Server";
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
   private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
 
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
-  private final AccountGroup.UUID groupUuid = new AccountGroup.UUID("users-XYZ");
-  private final AccountGroup.NameKey groupName = new AccountGroup.NameKey("users");
+  private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
+  private final AccountGroup.NameKey groupName = 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));
   }
 
@@ -109,19 +104,21 @@
 
   @Test
   public void uuidOfNewGroupMustNotBeNull() throws Exception {
-    expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(repo, null, groupName);
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forNewGroup(allUsersName, repo, null, groupName));
   }
 
   @Test
   public void nameOfNewGroupMustNotBeNull() throws Exception {
-    expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(repo, groupUuid, null);
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, null));
   }
 
   @Test
   public void nameOfNewGroupMayBeEmpty() throws Exception {
-    AccountGroup.NameKey emptyName = new AccountGroup.NameKey("");
+    AccountGroup.NameKey emptyName = AccountGroup.nameKey("");
     createGroup(groupUuid, emptyName);
 
     Optional<GroupReference> groupReference = loadGroup(emptyName);
@@ -132,17 +129,19 @@
   public void newGroupMustNotReuseNameOfAnotherGroup() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("AnotherGroup");
-    expectedException.expect(OrmDuplicateKeyException.class);
-    expectedException.expectMessage(groupName.get());
-    GroupNameNotes.forNewGroup(repo, anotherGroupUuid, groupName);
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("AnotherGroup");
+    DuplicateKeyException thrown =
+        assertThrows(
+            DuplicateKeyException.class,
+            () -> GroupNameNotes.forNewGroup(allUsersName, repo, anotherGroupUuid, groupName));
+    assertThat(thrown).hasMessageThat().contains(groupName.get());
   }
 
   @Test
   public void newGroupMayReuseUuidOfAnotherGroup() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     createGroup(groupUuid, anotherName);
 
     Optional<GroupReference> group1 = loadGroup(groupName);
@@ -155,7 +154,7 @@
   public void groupCanBeRenamed() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     Optional<GroupReference> groupReference = loadGroup(anotherName);
@@ -167,7 +166,7 @@
   public void previousNameOfGroupCannotBeUsedAfterRename() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     Optional<GroupReference> group = loadGroup(groupName);
@@ -177,61 +176,75 @@
   @Test
   public void groupCannotBeRenamedToNull() throws Exception {
     createGroup(groupUuid, groupName);
-
-    expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forRename(repo, groupUuid, groupName, null);
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, null));
   }
 
   @Test
   public void oldNameOfGroupMustBeSpecifiedForRename() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forRename(repo, groupUuid, null, anotherName);
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forRename(allUsersName, repo, groupUuid, null, anotherName));
   }
 
   @Test
   public void groupCannotBeRenamedWhenOldNameIsWrong() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherOldName = new AccountGroup.NameKey("contributors");
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    expectedException.expect(ConfigInvalidException.class);
-    expectedException.expectMessage(anotherOldName.get());
-    GroupNameNotes.forRename(repo, groupUuid, anotherOldName, anotherName);
+    AccountGroup.NameKey anotherOldName = AccountGroup.nameKey("contributors");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () ->
+                GroupNameNotes.forRename(
+                    allUsersName, repo, groupUuid, anotherOldName, anotherName));
+    assertThat(thrown).hasMessageThat().contains(anotherOldName.get());
   }
 
   @Test
   public void groupCannotBeRenamedToNameOfAnotherGroup() throws Exception {
     createGroup(groupUuid, groupName);
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey anotherGroupName = new AccountGroup.NameKey("admins");
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey anotherGroupName = AccountGroup.nameKey("admins");
     createGroup(anotherGroupUuid, anotherGroupName);
 
-    expectedException.expect(OrmDuplicateKeyException.class);
-    expectedException.expectMessage(anotherGroupName.get());
-    GroupNameNotes.forRename(repo, groupUuid, groupName, anotherGroupName);
+    DuplicateKeyException thrown =
+        assertThrows(
+            DuplicateKeyException.class,
+            () ->
+                GroupNameNotes.forRename(
+                    allUsersName, repo, groupUuid, groupName, anotherGroupName));
+    assertThat(thrown).hasMessageThat().contains(anotherGroupName.get());
   }
 
   @Test
   public void groupCannotBeRenamedWithoutSpecifiedUuid() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forRename(repo, null, groupName, anotherName);
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forRename(allUsersName, repo, null, groupName, anotherName));
   }
 
   @Test
   public void groupCannotBeRenamedWhenUuidIsWrong() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    expectedException.expect(ConfigInvalidException.class);
-    expectedException.expectMessage(groupUuid.get());
-    GroupNameNotes.forRename(repo, anotherGroupUuid, groupName, anotherName);
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () ->
+                GroupNameNotes.forRename(
+                    allUsersName, repo, anotherGroupUuid, groupName, anotherName));
+    assertThat(thrown).hasMessageThat().contains(groupUuid.get());
   }
 
   @Test
@@ -252,12 +265,12 @@
     createGroup(groupUuid, groupName);
     ImmutableList<CommitInfo> commitsAfterCreation = log();
 
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     createGroup(anotherGroupUuid, anotherName);
 
     ImmutableList<CommitInfo> commitsAfterFurtherGroup = log();
-    assertThatCommits(commitsAfterFurtherGroup).containsAllIn(commitsAfterCreation);
+    assertThatCommits(commitsAfterFurtherGroup).containsAtLeastElementsIn(commitsAfterCreation);
     assertThatCommits(commitsAfterFurtherGroup).lastElement().isNotIn(commitsAfterCreation);
   }
 
@@ -266,11 +279,11 @@
     createGroup(groupUuid, groupName);
     ImmutableList<CommitInfo> commitsAfterCreation = log();
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     ImmutableList<CommitInfo> commitsAfterRename = log();
-    assertThatCommits(commitsAfterRename).containsAllIn(commitsAfterCreation);
+    assertThatCommits(commitsAfterRename).containsAtLeastElementsIn(commitsAfterCreation);
     assertThatCommits(commitsAfterRename).lastElement().isNotIn(commitsAfterCreation);
   }
 
@@ -287,7 +300,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();
@@ -301,9 +315,9 @@
   public void newCommitIsNotCreatedWhenCommittingGroupRenamingTwice() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     GroupNameNotes groupNameNotes =
-        GroupNameNotes.forRename(repo, groupUuid, groupName, anotherName);
+        GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, anotherName);
 
     commit(groupNameNotes);
     ImmutableList<CommitInfo> commitsAfterFirstCommit = log();
@@ -326,7 +340,7 @@
   public void commitMessageMentionsGroupRenaming() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     ImmutableList<CommitInfo> commits = log();
@@ -344,18 +358,18 @@
 
   @Test
   public void nonExistentGroupCannotBeLoaded() throws Exception {
-    createGroup(new AccountGroup.UUID("contributors-MN"), new AccountGroup.NameKey("contributors"));
+    createGroup(AccountGroup.uuid("contributors-MN"), AccountGroup.nameKey("contributors"));
     createGroup(groupUuid, groupName);
 
-    Optional<GroupReference> group = loadGroup(new AccountGroup.NameKey("admins"));
+    Optional<GroupReference> group = loadGroup(AccountGroup.nameKey("admins"));
     assertThatGroup(group).isAbsent();
   }
 
   @Test
   public void specificGroupCanBeLoaded() throws Exception {
-    createGroup(new AccountGroup.UUID("contributors-MN"), new AccountGroup.NameKey("contributors"));
+    createGroup(AccountGroup.uuid("contributors-MN"), AccountGroup.nameKey("contributors"));
     createGroup(groupUuid, groupName);
-    createGroup(new AccountGroup.UUID("admins-ABC"), new AccountGroup.NameKey("admins"));
+    createGroup(AccountGroup.uuid("admins-ABC"), AccountGroup.nameKey("admins"));
 
     Optional<GroupReference> group = loadGroup(groupName);
     assertThatGroup(group).value().groupUuid().isEqualTo(groupUuid);
@@ -370,11 +384,11 @@
 
   @Test
   public void allGroupsCanBeLoaded() throws Exception {
-    AccountGroup.UUID groupUuid1 = new AccountGroup.UUID("contributors-MN");
-    AccountGroup.NameKey groupName1 = new AccountGroup.NameKey("contributors");
+    AccountGroup.UUID groupUuid1 = AccountGroup.uuid("contributors-MN");
+    AccountGroup.NameKey groupName1 = AccountGroup.nameKey("contributors");
     createGroup(groupUuid1, groupName1);
-    AccountGroup.UUID groupUuid2 = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey groupName2 = new AccountGroup.NameKey("admins");
+    AccountGroup.UUID groupUuid2 = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey groupName2 = AccountGroup.nameKey("admins");
     createGroup(groupUuid2, groupName2);
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
@@ -387,7 +401,7 @@
   @Test
   public void loadedGroupsContainGroupsWithDuplicateGroupUuids() throws Exception {
     createGroup(groupUuid, groupName);
-    AccountGroup.NameKey anotherGroupName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherGroupName = AccountGroup.nameKey("admins");
     createGroup(groupUuid, anotherGroupName);
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
@@ -427,35 +441,36 @@
     GroupReference g1 = newGroup("a");
     GroupReference g2 = newGroup("b");
 
-    TestRepository<?> tr = new TestRepository<>(repo);
-    ObjectId k1 = getNoteKey(g1);
-    ObjectId k2 = getNoteKey(g2);
-    ObjectId k3 = GroupNameNotes.getNoteKey(new AccountGroup.NameKey("c"));
-    PersonIdent ident = newPersonIdent();
-    ObjectId origCommitId =
-        tr.branch(REFS_GROUPNAMES)
-            .commit()
-            .message("Prepopulate group name")
-            .author(ident)
-            .committer(ident)
-            .add(k1.name(), "[group]\n\tuuid = a-1\n\tname = a\nanotherKey = foo\n")
-            .add(k2.name(), "[group]\n\tuuid = a-1\n\tname = b\n")
-            .add(k3.name(), "[group]\n\tuuid = c-3\n\tname = c\n")
-            .create()
-            .copy();
+    try (TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      ObjectId k1 = getNoteKey(g1);
+      ObjectId k2 = getNoteKey(g2);
+      ObjectId k3 = GroupNameNotes.getNoteKey(AccountGroup.nameKey("c"));
+      PersonIdent ident = newPersonIdent();
+      ObjectId origCommitId =
+          tr.branch(REFS_GROUPNAMES)
+              .commit()
+              .message("Prepopulate group name")
+              .author(ident)
+              .committer(ident)
+              .add(k1.name(), "[group]\n\tuuid = a-1\n\tname = a\nanotherKey = foo\n")
+              .add(k2.name(), "[group]\n\tuuid = a-1\n\tname = b\n")
+              .add(k3.name(), "[group]\n\tuuid = c-3\n\tname = c\n")
+              .create()
+              .copy();
 
-    ident = newPersonIdent();
-    updateAllGroups(ident, g1, g2);
+      ident = newPersonIdent();
+      updateAllGroups(ident, g1, g2);
 
-    assertThat(GroupNameNotes.loadAllGroups(repo)).containsExactly(g1, g2);
+      assertThat(GroupNameNotes.loadAllGroups(repo)).containsExactly(g1, g2);
 
-    ImmutableList<CommitInfo> log = log();
-    assertThat(log).hasSize(2);
-    assertThat(log.get(0)).commit().isEqualTo(origCommitId.name());
+      ImmutableList<CommitInfo> log = log();
+      assertThat(log).hasSize(2);
+      assertThat(log.get(0)).commit().isEqualTo(origCommitId.name());
 
-    assertThat(log.get(1)).message().isEqualTo("Store 2 group names");
-    assertThat(log.get(1)).author().matches(ident);
-    assertThat(log.get(1)).committer().matches(ident);
+      assertThat(log.get(1)).message().isEqualTo("Store 2 group names");
+      assertThat(log.get(1)).author().matches(ident);
+      assertThat(log.get(1)).committer().matches(ident);
+    }
 
     // Old note content was overwritten.
     assertThat(readNameNote(g1)).isEqualTo("[group]\n\tuuid = a-1\n\tname = a\n");
@@ -483,14 +498,14 @@
   @Test
   public void updateGroupNamesRejectsNonOneToOneGroupReferences() throws Exception {
     assertIllegalArgument(
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name2"));
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
+        new GroupReference(AccountGroup.uuid("uuid1"), "name2"));
     assertIllegalArgument(
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
-        new GroupReference(new AccountGroup.UUID("uuid2"), "name1"));
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
+        new GroupReference(AccountGroup.uuid("uuid2"), "name1"));
     assertIllegalArgument(
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"));
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"));
   }
 
   @Test
@@ -504,14 +519,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);
   }
 
@@ -529,8 +546,7 @@
     PersonIdent serverIdent = newPersonIdent();
 
     MetaDataUpdate metaDataUpdate =
-        new MetaDataUpdate(
-            GitReferenceUpdated.DISABLED, new Project.NameKey("Test Repository"), repo);
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, Project.nameKey("Test Repository"), repo);
     metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
     metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
     return metaDataUpdate;
@@ -538,7 +554,7 @@
 
   private GroupReference newGroup(String name) {
     int id = idCounter.incrementAndGet();
-    return new GroupReference(new AccountGroup.UUID(name + "-" + id), name);
+    return new GroupReference(AccountGroup.uuid(name + "-" + id), name);
   }
 
   private static PersonIdent newPersonIdent() {
@@ -546,7 +562,7 @@
   }
 
   private static ObjectId getNoteKey(GroupReference g) {
-    return GroupNameNotes.getNoteKey(new AccountGroup.NameKey(g.getName()));
+    return GroupNameNotes.getNoteKey(AccountGroup.nameKey(g.getName()));
   }
 
   private void updateAllGroups(PersonIdent ident, GroupReference... groupRefs) throws Exception {
@@ -562,12 +578,13 @@
     try (ObjectInserter inserter = repo.newObjectInserter()) {
       BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
       PersonIdent ident = newPersonIdent();
-      try {
-        GroupNameNotes.updateAllGroups(repo, inserter, bru, Arrays.asList(groupRefs), ident);
-        assert_().fail("Expected IllegalArgumentException");
-      } catch (IllegalArgumentException e) {
-        assertThat(e).hasMessageThat().isEqualTo(GroupNameNotes.UNIQUE_REF_ERROR);
-      }
+      IllegalArgumentException thrown =
+          assertThrows(
+              IllegalArgumentException.class,
+              () ->
+                  GroupNameNotes.updateAllGroups(
+                      repo, inserter, bru, Arrays.asList(groupRefs), ident));
+      assertThat(thrown).hasMessageThat().isEqualTo(GroupNameNotes.UNIQUE_REF_ERROR);
     }
   }
 
@@ -587,11 +604,11 @@
 
   private static OptionalSubject<GroupReferenceSubject, GroupReference> assertThatGroup(
       Optional<GroupReference> group) {
-    return assertThat(group, GroupReferenceSubject::assertThat);
+    return assertThat(group, groupReferences());
   }
 
   private static ListSubject<CommitInfoSubject, CommitInfo> assertThatCommits(
       List<CommitInfo> commits) {
-    return ListSubject.assertThat(commits, CommitInfoSubject::assertThat);
+    return ListSubject.assertThat(commits, commits());
   }
 }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
index a5b04ee..040ad83 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
@@ -30,7 +30,7 @@
   public void groupNamesRefIsMissing() throws Exception {
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(warning("Group with name 'g-1' doesn't exist in the list of all names"));
   }
@@ -40,7 +40,7 @@
     updateGroupNamesRef("g-2", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(warning("Group with name 'g-1' doesn't exist in the list of all names"));
   }
@@ -50,7 +50,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-1\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems).isEmpty();
   }
 
@@ -59,7 +59,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-1\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(
             warning(
@@ -72,7 +72,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-2\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(warning("group note of name 'g-1' claims to represent name of 'g-2'"));
   }
@@ -82,7 +82,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(
             warning(
@@ -97,7 +97,7 @@
     updateGroupNamesRef("g-1", "[invalid");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(
             warning(
@@ -105,7 +105,7 @@
   }
 
   private void updateGroupNamesRef(String groupName, String content) throws Exception {
-    String nameKey = GroupNameNotes.getNoteKey(new AccountGroup.NameKey(groupName)).getName();
+    String nameKey = GroupNameNotes.getNoteKey(AccountGroup.nameKey(groupName)).getName();
     GroupTestUtil.updateGroupFile(
         allUsersRepo, serverIdent, RefNames.REFS_GROUPNAMES, nameKey, content);
   }
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index f0230d5..e4f8078 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -21,37 +21,39 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class AccountFieldTest extends GerritBaseTests {
+public class AccountFieldTest {
   @Test
   public void refStateFieldValues() throws Exception {
     AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(1), TimeUtil.nowTs());
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
     List<String> values =
-        toStrings(AccountField.REF_STATE.get(AccountState.forAccount(allUsersName, account)));
+        toStrings(AccountField.REF_STATE.get(AccountState.forAccount(account.build())));
     assertThat(values).hasSize(1);
     String expectedValue =
-        allUsersName.get() + ":" + RefNames.refsUsers(account.getId()) + ":" + metaId;
+        allUsersName.get() + ":" + RefNames.refsUsers(account.id()) + ":" + metaId;
     assertThat(Iterables.getOnlyElement(values)).isEqualTo(expectedValue);
   }
 
   @Test
   public void externalIdStateFieldValues() throws Exception {
-    Account.Id id = new Account.Id(1);
-    Account account = new Account(id, TimeUtil.nowTs());
+    Account.Id id = Account.id(1);
+    Account account =
+        Account.builder(id, TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build();
     ExternalId extId1 =
         ExternalId.create(
             ExternalId.Key.create(ExternalId.SCHEME_MAILTO, "foo.bar@example.com"),
@@ -69,7 +71,7 @@
     List<String> values =
         toStrings(
             AccountField.EXTERNAL_ID_STATE.get(
-                AccountState.forAccount(null, account, ImmutableSet.of(extId1, extId2))));
+                AccountState.forAccount(account, ImmutableSet.of(extId1, extId2))));
     String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
     String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
     assertThat(values).containsExactly(expectedValue1, expectedValue2);
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 8e8a0ea..4defea5 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -15,20 +15,20 @@
 package com.google.gerrit.server.index.change;
 
 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 static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Table;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.sql.Timestamp;
 import java.util.Collections;
@@ -38,7 +38,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ChangeFieldTest extends GerritBaseTests {
+public class ChangeFieldTest {
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
@@ -53,9 +53,9 @@
   public void reviewerFieldValues() {
     Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
     Timestamp t1 = TimeUtil.nowTs();
-    t.put(ReviewerStateInternal.REVIEWER, new Account.Id(1), t1);
+    t.put(ReviewerStateInternal.REVIEWER, Account.id(1), t1);
     Timestamp t2 = TimeUtil.nowTs();
-    t.put(ReviewerStateInternal.CC, new Account.Id(2), t2);
+    t.put(ReviewerStateInternal.CC, Account.id(2), t2);
     ReviewerSet reviewers = ReviewerSet.fromTable(t);
 
     List<String> values = ChangeField.getReviewerFieldValues(reviewers);
@@ -63,7 +63,7 @@
         .containsExactly(
             "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
 
-    assertThat(ChangeField.parseReviewerFieldValues(new Change.Id(1), values)).isEqualTo(reviewers);
+    assertThat(ChangeField.parseReviewerFieldValues(Change.id(1), values)).isEqualTo(reviewers);
   }
 
   @Test
@@ -75,7 +75,7 @@
                         SubmitRecord.Status.OK,
                         label(SubmitRecord.Label.Status.MAY, "Label-1", null),
                         label(SubmitRecord.Label.Status.OK, "Label-2", 1))),
-                new Account.Id(1)))
+                Account.id(1)))
         .containsExactly("OK", "MAY,label-1", "OK,label-2", "OK,label-2,0", "OK,label-2,1");
   }
 
@@ -142,7 +142,7 @@
     l.status = status;
     l.label = label;
     if (appliedBy != null) {
-      l.appliedBy = new Account.Id(appliedBy);
+      l.appliedBy = Account.id(appliedBy);
     }
     return l;
   }
@@ -150,12 +150,11 @@
   private static void assertStoredRecordRoundTrip(SubmitRecord... records) {
     List<SubmitRecord> recordList = ImmutableList.copyOf(records);
     List<String> stored =
-        ChangeField.storedSubmitRecords(recordList)
-            .stream()
+        ChangeField.storedSubmitRecords(recordList).stream()
             .map(s -> new String(s, UTF_8))
             .collect(toList());
-    assertThat(ChangeField.parseSubmitRecords(stored))
-        .named("JSON %s" + stored)
+    assertWithMessage("JSON %s" + stored)
+        .that(ChangeField.parseSubmitRecords(stored))
         .isEqualTo(recordList);
   }
 }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 53994a6..62b1cbc 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.reviewdb.client.Change.Status.MERGED;
 import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
 import static com.google.gerrit.server.index.change.IndexedChangeQuery.convertOptions;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 
 import com.google.common.collect.ImmutableSet;
@@ -34,14 +35,13 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gerrit.server.query.change.OrSource;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.Set;
 import org.junit.Before;
 import org.junit.Test;
 
-public class ChangeIndexRewriterTest extends GerritBaseTests {
+public class ChangeIndexRewriterTest {
   private static final IndexConfig CONFIG = IndexConfig.createDefault();
 
   private FakeChangeIndex index;
@@ -68,7 +68,7 @@
   public void nonIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
     assertThat(out.getChildren())
         .containsExactly(
             query(Predicate.or(ChangeStatusPredicate.open(), ChangeStatusPredicate.closed())), in)
@@ -85,7 +85,7 @@
   public void nonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("foo:a OR foo:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
     assertThat(out.getChildren())
         .containsExactly(
             query(Predicate.or(ChangeStatusPredicate.open(), ChangeStatusPredicate.closed())), in)
@@ -96,7 +96,7 @@
   public void oneIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a file:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
     assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
   }
 
@@ -110,7 +110,7 @@
   public void threeLevelTreeWithSomeIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isSameAs(AndChangeSource.class);
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
     assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
   }
 
@@ -118,7 +118,7 @@
   public void multipleIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("file:a OR foo:b OR file:c OR foo:d");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isSameAs(OrSource.class);
+    assertThat(out.getClass()).isSameInstanceAs(OrSource.class);
     assertThat(out.getChildren())
         .containsExactly(query(or(in.getChild(0), in.getChild(2))), in.getChild(1), in.getChild(3))
         .inOrder();
@@ -128,7 +128,7 @@
   public void indexAndNonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("status:new bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
     assertThat(out.getChildren())
         .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
         .inOrder();
@@ -196,9 +196,8 @@
 
     indexes.setSearchIndex(new FakeChangeIndex(FakeChangeIndex.V1));
 
-    exception.expect(QueryParseException.class);
-    exception.expectMessage("Unsupported index predicate: file:a");
-    rewrite(in);
+    QueryParseException thrown = assertThrows(QueryParseException.class, () -> rewrite(in));
+    assertThat(thrown).hasMessageThat().contains("Unsupported index predicate: file:a");
   }
 
   @Test
@@ -207,9 +206,9 @@
     Predicate<ChangeData> in = parse(q);
     assertEquals(query(in), rewrite(in));
 
-    exception.expect(QueryParseException.class);
-    exception.expectMessage("too many terms in query");
-    rewrite(parse(q + " OR file:d"));
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> rewrite(parse(q + " OR file:d")));
+    assertThat(thrown).hasMessageThat().contains("too many terms in query");
   }
 
   @Test
@@ -255,7 +254,7 @@
   }
 
   private static QueryOptions options(int start, int limit) {
-    return IndexedChangeQuery.createOptions(CONFIG, start, limit, ImmutableSet.<String>of());
+    return IndexedChangeQuery.createOptions(CONFIG, start, limit, ImmutableSet.of());
   }
 
   private Set<Change.Status> status(String query) throws QueryParseException {
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index d4ecb6d..34c5717 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -15,23 +15,20 @@
 package com.google.gerrit.server.index.change;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import org.junit.Ignore;
 
 @Ignore
 public class FakeChangeIndex implements ChangeIndex {
-  static final Schema<ChangeData> V1 =
-      new Schema<>(1, ImmutableList.<FieldDef<ChangeData, ?>>of(ChangeField.STATUS));
+  static final Schema<ChangeData> V1 = new Schema<>(1, ImmutableList.of(ChangeField.STATUS));
 
   static final Schema<ChangeData> V2 =
       new Schema<>(2, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
@@ -54,12 +51,12 @@
     }
 
     @Override
-    public ResultSet<ChangeData> read() throws OrmException {
+    public ResultSet<ChangeData> read() {
       throw new UnsupportedOperationException();
     }
 
     @Override
-    public ResultSet<FieldBundle> readRaw() throws OrmException {
+    public ResultSet<FieldBundle> readRaw() {
       throw new UnsupportedOperationException("not implemented");
     }
 
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index b525504..0753127 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -24,10 +24,10 @@
 public class FakeQueryBuilder extends ChangeQueryBuilder {
   FakeQueryBuilder(ChangeIndexCollection indexes) {
     super(
-        new FakeQueryBuilder.Definition<>(FakeQueryBuilder.class),
+        new ChangeQueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, null, indexes, null, null, null, null, null, null, null, null));
+            null, null, null, null, indexes, null, null, null, null, null, null, null));
   }
 
   @Operator
diff --git a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index acb33e9..44f33b2 100644
--- a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -15,26 +15,20 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.server.index.change.StalenessChecker.refsAreStale;
-import static com.google.gerrit.testing.TestChanges.newChange;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.ListMultimap;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gwtorm.protobuf.CodecFactory;
-import com.google.gwtorm.protobuf.ProtobufCodec;
 import java.util.stream.Stream;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -42,16 +36,14 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class StalenessCheckerTest extends GerritBaseTests {
+public class StalenessCheckerTest {
   private static final String SHA1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
   private static final String SHA2 = "badc0feebadc0feebadc0feebadc0feebadc0fee";
 
-  private static final Project.NameKey P1 = new Project.NameKey("project1");
-  private static final Project.NameKey P2 = new Project.NameKey("project2");
+  private static final Project.NameKey P1 = Project.nameKey("project1");
+  private static final Project.NameKey P2 = Project.nameKey("project2");
 
-  private static final Change.Id C = new Change.Id(1234);
-
-  private static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
+  private static final Change.Id C = Change.id(1234);
 
   private GitRepositoryManager repoManager;
   private Repository r1;
@@ -90,12 +82,7 @@
   }
 
   private static void assertInvalidState(String state) {
-    try {
-      RefState.parseStates(byteArrays(state));
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(IllegalArgumentException.class, () -> RefState.parseStates(byteArrays(state)));
   }
 
   @Test
@@ -162,12 +149,8 @@
   }
 
   private static void assertInvalidPattern(String state) {
-    try {
-      StalenessChecker.parsePatterns(byteArrays(state));
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(
+        IllegalArgumentException.class, () -> StalenessChecker.parsePatterns(byteArrays(state)));
   }
 
   @Test
@@ -316,29 +299,7 @@
         .isFalse();
   }
 
-  @Test
-  public void reviewDbChangeIsStale() throws Exception {
-    Change indexChange = newChange(P1, new Account.Id(1));
-    indexChange.setNoteDbState(SHA1);
-
-    // Change is missing from ReviewDb but present in index.
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isTrue();
-
-    // Change differs only in primary storage.
-    Change noteDbPrimary = clone(indexChange);
-    noteDbPrimary.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isTrue();
-
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, clone(indexChange))).isFalse();
-
-    // Can't easily change row version to check true case.
-  }
-
   private static Iterable<byte[]> byteArrays(String... strs) {
     return Stream.of(strs).map(s -> s != null ? s.getBytes(UTF_8) : null).collect(toList());
   }
-
-  private static Change clone(Change change) {
-    return CHANGE_CODEC.decode(CHANGE_CODEC.encodeToByteArray(change));
-  }
 }
diff --git a/javatests/com/google/gerrit/server/ioutil/BUILD b/javatests/com/google/gerrit/server/ioutil/BUILD
index 721c6f9..ef02243 100644
--- a/javatests/com/google/gerrit/server/ioutil/BUILD
+++ b/javatests/com/google/gerrit/server/ioutil/BUILD
@@ -10,5 +10,9 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java b/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
new file mode 100644
index 0000000..9bb6951
--- /dev/null
+++ b/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ioutil;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class HexFormatTest {
+
+  @Test
+  public void fromInt() {
+    assertEquals("0000000f", HexFormat.fromInt(0xf));
+    assertEquals("801234ab", HexFormat.fromInt(0x801234ab));
+    assertEquals("deadbeef", HexFormat.fromInt(0xdeadbeef));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
new file mode 100644
index 0000000..048d59d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ioutil;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.junit.Test;
+
+public class RegexListSearcherTest {
+  private static final ImmutableList<String> EMPTY = ImmutableList.of();
+
+  @Test
+  public void emptyList() {
+    assertSearchReturns(EMPTY, "pat", EMPTY);
+  }
+
+  @Test
+  public void anchors() {
+    List<String> list = ImmutableList.of("foo");
+    assertSearchReturns(list, "^f.*", list);
+    assertSearchReturns(list, "^f.*o$", list);
+    assertSearchReturns(list, "f.*o$", list);
+    assertSearchReturns(list, "f.*o$", list);
+    assertSearchReturns(EMPTY, "^.*\\$", list);
+  }
+
+  @Test
+  public void noCommonPrefix() {
+    List<String> list = ImmutableList.of("bar", "foo", "quux");
+    assertSearchReturns(ImmutableList.of("foo"), "f.*", list);
+    assertSearchReturns(ImmutableList.of("foo"), ".*o.*", list);
+    assertSearchReturns(ImmutableList.of("bar", "foo", "quux"), ".*[aou].*", list);
+  }
+
+  @Test
+  public void commonPrefix() {
+    List<String> list = ImmutableList.of("bar", "baz", "foo1", "foo2", "foo3", "quux");
+    assertSearchReturns(ImmutableList.of("bar", "baz"), "b.*", list);
+    assertSearchReturns(ImmutableList.of("foo1", "foo2"), "foo[12]", list);
+    assertSearchReturns(ImmutableList.of("foo1", "foo2", "foo3"), "foo.*", list);
+    assertSearchReturns(ImmutableList.of("quux"), "q.*", list);
+  }
+
+  private void assertSearchReturns(List<?> expected, String re, List<String> inputs) {
+    assertThat(inputs).isInOrder();
+    assertThat(RegexListSearcher.ofStrings(re).search(inputs))
+        .containsExactlyElementsIn(expected)
+        .inOrder();
+  }
+}
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..733d784
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.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.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.truth.Expect;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class LoggingContextAwareExecutorServiceTest {
+  @Rule public final Expect expect = Expect.create();
+
+  @Inject @GerritServerConfig private Config config;
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+
+  private PerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+
+    testPerformanceLogger =
+        new PerformanceLogger() {
+          @Override
+          public void log(String operation, long durationMs, Metadata metadata) {
+            // do nothing
+          }
+        };
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+  }
+
+  @After
+  public void cleanup() {
+    performanceLoggerRegistrationHandle.remove();
+  }
+
+  @Test
+  public void loggingContextPropagationToBackgroundThread() throws Exception {
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar");
+        PerformanceLogContext performanceLogContext =
+            new PerformanceLogContext(config, performanceLoggers)) {
+      // Create a performance log record.
+      TraceContext.newTimer("test").close();
+
+      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+      assertForceLogging(true);
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+
+      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();
+                expect.that(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+                expect.that(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+
+                // Create another performance log record. We expect this to be visible in the outer
+                // thread.
+                TraceContext.newTimer("test2").close();
+                expect.that(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
+              })
+          .get();
+
+      // Verify that logging context values 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().isPerformanceLogging()).isTrue();
+
+      // The performance log record that was added in the inner thread is available in addition to
+      // the performance log record that was created in the outer thread.
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
+    }
+
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  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..f6f3b46
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -0,0 +1,172 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+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) {
+    NullPointerException thrown = assertThrows(NullPointerException.class, () -> r.run());
+    assertThat(thrown).hasMessageThat().isEqualTo(expectedMessage);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
new file mode 100644
index 0000000..ed4325d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -0,0 +1,382 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PerformanceLogContextTest {
+  @Inject @GerritServerConfig private Config config;
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+
+  // In this test setup this gets the DisabledMetricMaker injected. This means it doesn't record any
+  // metric, but performance log records are still created.
+  @Inject private MetricMaker metricMaker;
+
+  private TestPerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+
+    testPerformanceLogger = new TestPerformanceLogger();
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+  }
+
+  @After
+  public void cleanup() {
+    performanceLoggerRegistrationHandle.remove();
+
+    LoggingContext.getInstance().clearPerformanceLogEntries();
+    LoggingContext.getInstance().performanceLogging(false);
+  }
+
+  @Test
+  public void traceTimersInsidePerformanceLogContextCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      TraceContext.newTimer("test1").close();
+      TraceContext.newTimer("test2", Metadata.builder().accountId(1000000).changeId(123).build())
+          .close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create("test1", Metadata.empty()),
+            PerformanceLogEntry.create(
+                "test2", Metadata.builder().accountId(1000000).changeId(123).build()))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void traceTimersOutsidePerformanceLogContextDoNotCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    TraceContext.newTimer("test1").close();
+    TraceContext.newTimer("test2", Metadata.builder().accountId(1000000).changeId(123).build())
+        .close();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+  }
+
+  @Test
+  public void
+      traceTimersInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
+    // Remove test performance logger so that there are no registered performance loggers.
+    performanceLoggerRegistrationHandle.remove();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+
+      TraceContext.newTimer("test1").close();
+      TraceContext.newTimer("test2", Metadata.builder().accountId(1000000).changeId(123).build())
+          .close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    }
+
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void timerMetricsInsidePerformanceLogContextCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      Timer0 timer0 =
+          metricMaker.newTimer("test1/latency", new Description("Latency metric for testing"));
+      timer0.start().close();
+
+      Timer1<Integer> timer1 =
+          metricMaker.newTimer(
+              "test2/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build());
+      timer1.start(1000000).close();
+
+      Timer2<Integer, Integer> timer2 =
+          metricMaker.newTimer(
+              "test3/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build(),
+              Field.ofInteger("change", Metadata.Builder::changeId).build());
+      timer2.start(1000000, 123).close();
+
+      Timer3<Integer, Integer, String> timer3 =
+          metricMaker.newTimer(
+              "test4/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build(),
+              Field.ofInteger("change", Metadata.Builder::changeId).build(),
+              Field.ofString("project", Metadata.Builder::projectName).build());
+      timer3.start(1000000, 123, "foo/bar").close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(4);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create("test1/latency", Metadata.empty()),
+            PerformanceLogEntry.create(
+                "test2/latency", Metadata.builder().accountId(1000000).build()),
+            PerformanceLogEntry.create(
+                "test3/latency", Metadata.builder().accountId(1000000).changeId(123).build()),
+            PerformanceLogEntry.create(
+                "test4/latency",
+                Metadata.builder().accountId(1000000).changeId(123).projectName("foo/bar").build()))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void timerMetricsInsidePerformanceLogContextCreatePerformanceLogNullValuesAllowed() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      Timer1<String> timer1 =
+          metricMaker.newTimer(
+              "test1/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("project", Metadata.Builder::projectName).build());
+      timer1.start(null).close();
+
+      Timer2<String, String> timer2 =
+          metricMaker.newTimer(
+              "test2/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofString("branch", Metadata.Builder::branchName).build());
+      timer2.start(null, null).close();
+
+      Timer3<String, String, String> timer3 =
+          metricMaker.newTimer(
+              "test3/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofString("branch", Metadata.Builder::branchName).build(),
+              Field.ofString("revision", Metadata.Builder::revision).build());
+      timer3.start(null, null, null).close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(3);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create("test1/latency", Metadata.empty()),
+            PerformanceLogEntry.create("test2/latency", Metadata.empty()),
+            PerformanceLogEntry.create("test3/latency", Metadata.empty()))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void timerMetricsOutsidePerformanceLogContextDoNotCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    Timer0 timer0 =
+        metricMaker.newTimer("test1/latency", new Description("Latency metric for testing"));
+    timer0.start().close();
+
+    Timer1<Integer> timer1 =
+        metricMaker.newTimer(
+            "test2/latency",
+            new Description("Latency metric for testing"),
+            Field.ofInteger("account", Metadata.Builder::accountId).build());
+    timer1.start(1000000).close();
+
+    Timer2<Integer, Integer> timer2 =
+        metricMaker.newTimer(
+            "test3/latency",
+            new Description("Latency metric for testing"),
+            Field.ofInteger("account", Metadata.Builder::accountId).build(),
+            Field.ofInteger("change", Metadata.Builder::changeId).build());
+    timer2.start(1000000, 123).close();
+
+    Timer3<Integer, Integer, String> timer3 =
+        metricMaker.newTimer(
+            "test4/latency",
+            new Description("Latency metric for testing"),
+            Field.ofInteger("account", Metadata.Builder::accountId).build(),
+            Field.ofInteger("change", Metadata.Builder::changeId).build(),
+            Field.ofString("project", Metadata.Builder::projectName).build());
+    timer3.start(1000000, 123, "value3").close();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+  }
+
+  @Test
+  public void
+      timerMetricssInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
+    // Remove test performance logger so that there are no registered performance loggers.
+    performanceLoggerRegistrationHandle.remove();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+
+      Timer0 timer0 =
+          metricMaker.newTimer("test1/latency", new Description("Latency metric for testing"));
+      timer0.start().close();
+
+      Timer1<Integer> timer1 =
+          metricMaker.newTimer(
+              "test2/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("accoutn", Metadata.Builder::accountId).build());
+      timer1.start(1000000).close();
+
+      Timer2<Integer, Integer> timer2 =
+          metricMaker.newTimer(
+              "test3/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build(),
+              Field.ofInteger("change", Metadata.Builder::changeId).build());
+      timer2.start(1000000, 123).close();
+
+      Timer3<Integer, Integer, String> timer3 =
+          metricMaker.newTimer(
+              "test4/latency",
+              new Description("Latency metric for testing"),
+              Field.ofInteger("account", Metadata.Builder::accountId).build(),
+              Field.ofInteger("change", Metadata.Builder::changeId).build(),
+              Field.ofString("project", Metadata.Builder::projectName).build());
+      timer3.start(1000000, 123, "foo/bar").close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    }
+
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void nestingPerformanceLogContextsIsPossible() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext1 =
+        new PerformanceLogContext(config, performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      TraceContext.newTimer("test1").close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+
+      try (PerformanceLogContext traceContext2 =
+          new PerformanceLogContext(config, performanceLoggers)) {
+        assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+        assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+        TraceContext.newTimer("test2").close();
+        TraceContext.newTimer("test3").close();
+
+        assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
+      }
+
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+    }
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  private static class TestPerformanceLogger implements PerformanceLogger {
+    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+
+    @Override
+    public void log(String operation, long durationMs, Metadata metadata) {
+      logEntries.add(PerformanceLogEntry.create(operation, metadata));
+    }
+
+    ImmutableList<PerformanceLogEntry> logEntries() {
+      return ImmutableList.copyOf(logEntries);
+    }
+  }
+
+  @AutoValue
+  abstract static class PerformanceLogEntry {
+    static PerformanceLogEntry create(String operation, Metadata metadata) {
+      return new AutoValue_PerformanceLogContextTest_PerformanceLogEntry(operation, metadata);
+    }
+
+    abstract String operation();
+
+    abstract Metadata metadata();
+  }
+}
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..13f2035
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -0,0 +1,281 @@
+// 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.gerrit.testing.GerritJUnit.assertThrows;
+
+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);
+    }
+  }
+
+  @Test
+  public void operationForTraceTimerCannotBeNull() throws Exception {
+    assertThrows(NullPointerException.class, () -> TraceContext.newTimer(null));
+    assertThrows(NullPointerException.class, () -> TraceContext.newTimer(null, Metadata.empty()));
+    assertThrows(
+        NullPointerException.class,
+        () ->
+            TraceContext.newTimer(
+                null, Metadata.builder().accountId(1000000).changeId(123).build()));
+  }
+
+  @Test
+  public void metadataForTraceTimerCannotBeNull() throws Exception {
+    assertThrows(NullPointerException.class, () -> TraceContext.newTimer("test", null));
+  }
+
+  private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
+    SortedMap<String, SortedSet<Object>> actualTagMap =
+        LoggingContext.getInstance().getTags().asMap();
+    assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
+    for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
+      assertThat(actualTagMap.get(expectedEntry.getKey()))
+          .containsExactlyElementsIn(expectedEntry.getValue());
+    }
+  }
+
+  private void assertForceLogging(boolean expected) {
+    assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+        .isEqualTo(expected);
+  }
+
+  private static class TestTraceIdConsumer implements TraceIdConsumer {
+    String tagName;
+    String traceId;
+
+    @Override
+    public void accept(String tagName, String traceId) {
+      this.tagName = tagName;
+      this.traceId = traceId;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/AddressTest.java b/javatests/com/google/gerrit/server/mail/AddressTest.java
deleted file mode 100644
index 7dbd563..0000000
--- a/javatests/com/google/gerrit/server/mail/AddressTest.java
+++ /dev/null
@@ -1,157 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
-
-import com.google.gerrit.testing.GerritBaseTests;
-import org.junit.Test;
-
-public class AddressTest extends GerritBaseTests {
-  @Test
-  public void parse_NameEmail1() {
-    final Address a = Address.parse("A U Thor <author@example.com>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.com");
-  }
-
-  @Test
-  public void parse_NameEmail2() {
-    final Address a = Address.parse("A <a@b>");
-    assertThat(a.name).isEqualTo("A");
-    assertThat(a.email).isEqualTo("a@b");
-  }
-
-  @Test
-  public void parse_NameEmail3() {
-    final Address a = Address.parse("<a@b>");
-    assertThat(a.name).isNull();
-    assertThat(a.email).isEqualTo("a@b");
-  }
-
-  @Test
-  public void parse_NameEmail4() {
-    final Address a = Address.parse("A U Thor<author@example.com>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.com");
-  }
-
-  @Test
-  public void parse_NameEmail5() {
-    final Address a = Address.parse("A U Thor  <author@example.com>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.com");
-  }
-
-  @Test
-  public void parse_Email1() {
-    final Address a = Address.parse("author@example.com");
-    assertThat(a.name).isNull();
-    assertThat(a.email).isEqualTo("author@example.com");
-  }
-
-  @Test
-  public void parse_Email2() {
-    final Address a = Address.parse("a@b");
-    assertThat(a.name).isNull();
-    assertThat(a.email).isEqualTo("a@b");
-  }
-
-  @Test
-  public void parse_NewTLD() {
-    Address a = Address.parse("A U Thor <author@example.systems>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.systems");
-  }
-
-  @Test
-  public void parseInvalid() {
-    assertInvalid("");
-    assertInvalid("a");
-    assertInvalid("a<");
-    assertInvalid("<a");
-    assertInvalid("<a>");
-    assertInvalid("a<a>");
-    assertInvalid("a <a>");
-
-    assertInvalid("a");
-    assertInvalid("a<@");
-    assertInvalid("<a@");
-    assertInvalid("<a@>");
-    assertInvalid("a<a@>");
-    assertInvalid("a <a@>");
-    assertInvalid("a <@a>");
-  }
-
-  private void assertInvalid(String in) {
-    try {
-      Address.parse(in);
-      fail("Expected IllegalArgumentException for " + in);
-    } catch (IllegalArgumentException e) {
-      assertThat(e.getMessage()).isEqualTo("Invalid email address: " + in);
-    }
-  }
-
-  @Test
-  public void toHeaderString_NameEmail1() {
-    assertThat(format("A", "a@a")).isEqualTo("A <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail2() {
-    assertThat(format("A B", "a@a")).isEqualTo("A B <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail3() {
-    assertThat(format("A B. C", "a@a")).isEqualTo("\"A B. C\" <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail4() {
-    assertThat(format("A B, C", "a@a")).isEqualTo("\"A B, C\" <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail5() {
-    assertThat(format("A \" C", "a@a")).isEqualTo("\"A \\\" C\" <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail6() {
-    assertThat(format("A \u20ac B", "a@a")).isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B?= <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail7() {
-    assertThat(format("A \u20ac B (Code Review)", "a@a"))
-        .isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B_=28Code_Review=29?= <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_Email1() {
-    assertThat(format(null, "a@a")).isEqualTo("a@a");
-  }
-
-  @Test
-  public void toHeaderString_Email2() {
-    assertThat(format(null, "a,b@a")).isEqualTo("<a,b@a>");
-  }
-
-  private static String format(String name, String email) {
-    return new Address(name, email).toHeaderString();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
index a7234f4..9dcb08c 100644
--- a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
+++ b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.server.mail.receive.MailMessage;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
 import java.time.Instant;
 import org.junit.Test;
 
-public class AutoReplyMailFilterTest extends GerritBaseTests {
+public class AutoReplyMailFilterTest {
 
   private AutoReplyMailFilter autoReplyMailFilter = new AutoReplyMailFilter();
 
diff --git a/javatests/com/google/gerrit/server/mail/receive/AbstractParserTest.java b/javatests/com/google/gerrit/server/mail/receive/AbstractParserTest.java
deleted file mode 100644
index 0e894a6..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/AbstractParserTest.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.server.mail.Address;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.List;
-import org.junit.Ignore;
-
-@Ignore
-public class AbstractParserTest {
-  protected static final String CHANGE_URL = "https://gerrit-review.googlesource.com/#/changes/123";
-
-  protected static void assertChangeMessage(String message, MailComment comment) {
-    assertThat(comment.fileName).isNull();
-    assertThat(comment.message).isEqualTo(message);
-    assertThat(comment.inReplyTo).isNull();
-    assertThat(comment.type).isEqualTo(MailComment.CommentType.CHANGE_MESSAGE);
-  }
-
-  protected static void assertInlineComment(
-      String message, MailComment comment, Comment inReplyTo) {
-    assertThat(comment.fileName).isNull();
-    assertThat(comment.message).isEqualTo(message);
-    assertThat(comment.inReplyTo).isEqualTo(inReplyTo);
-    assertThat(comment.type).isEqualTo(MailComment.CommentType.INLINE_COMMENT);
-  }
-
-  protected static void assertFileComment(String message, MailComment comment, String file) {
-    assertThat(comment.fileName).isEqualTo(file);
-    assertThat(comment.message).isEqualTo(message);
-    assertThat(comment.inReplyTo).isNull();
-    assertThat(comment.type).isEqualTo(MailComment.CommentType.FILE_COMMENT);
-  }
-
-  protected static Comment newComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
-            new Comment.Key(uuid, file, 1),
-            new Account.Id(0),
-            new Timestamp(0L),
-            (short) 0,
-            message,
-            "",
-            false);
-    c.lineNbr = line;
-    return c;
-  }
-
-  protected static Comment newRangeComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
-            new Comment.Key(uuid, file, 1),
-            new Account.Id(0),
-            new Timestamp(0L),
-            (short) 0,
-            message,
-            "",
-            false);
-    c.range = new Comment.Range(line, 1, line + 1, 1);
-    c.lineNbr = line + 1;
-    return c;
-  }
-
-  /** Returns a MailMessage.Builder with all required fields populated. */
-  protected static MailMessage.Builder newMailMessageBuilder() {
-    MailMessage.Builder b = MailMessage.builder();
-    b.id("id");
-    b.from(new Address("Foo Bar", "foo@bar.com"));
-    b.dateReceived(Instant.now());
-    b.subject("");
-    return b;
-  }
-
-  /** Returns a List of default comments for testing. */
-  protected static List<Comment> defaultComments() {
-    List<Comment> comments = new ArrayList<>();
-    comments.add(newComment("c1", "gerrit-server/test.txt", "comment", 0));
-    comments.add(newComment("c2", "gerrit-server/test.txt", "comment", 2));
-    comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 3));
-    comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 115));
-    comments.add(newRangeComment("c5", "gerrit-server/readme.txt", "comment", 3));
-    return comments;
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java b/javatests/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
deleted file mode 100644
index f78953d..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-/** Test parser for a generic Html email client response */
-public class GenericHtmlParserTest extends HtmlParserTest {
-  @Override
-  protected String newHtmlBody(
-      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
-    String email =
-        ""
-            + "<div dir=\"ltr\">"
-            + (changeMessage != null ? changeMessage : "")
-            + "<div class=\"extra\"><br><div class=\"quote\">"
-            + "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com"
-            + "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" "
-            + "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>"
-            + "<blockquote class=\"quote\" "
-            + "<p>foobar <strong>posted comments</strong> on this change.</p>"
-            + "<p><a href=\""
-            + CHANGE_URL
-            + "/1\" "
-            + "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n"
-            + "\n"
-            + "(3 comments)</div><ul><li>"
-            + "<p>"
-            + // File #1: test.txt
-            "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt\">"
-            + "File gerrit-server/<wbr>test.txt:</a></p>"
-            + commentBlock(f1)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt\">"
-            + "Patch Set #2:</a> </p>"
-            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
-            + "</blockquote><p>Some comment on file 1</p>"
-            + "</li>"
-            + commentBlock(fc1)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt@2\">"
-            + "Patch Set #2, Line 31:</a> </p>"
-            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
-            + "</blockquote><p>Some text from original comment</p>"
-            + "</li>"
-            + commentBlock(c1)
-            + ""
-            + // Inline comment #2
-            "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt@3\">"
-            + "Patch Set #2, Line 47:</a> </p>"
-            + "<blockquote><pre>Some comment posted on Gerrit</pre>"
-            + "</blockquote><p>Some more comments from Gerrit</p>"
-            + "</li>"
-            + commentBlock(c2)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt@115\">"
-            + "Patch Set #2, Line 115:</a> <code>some code</code></p>"
-            + "<p>some comment</p></li></ul></li>"
-            + ""
-            + "<li><p>"
-            + // File #2: test.txt
-            "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/readme.txt\">"
-            + "File gerrit-server/<wbr>readme.txt:</a></p>"
-            + commentBlock(f2)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/readme.txt@3\">"
-            + "Patch Set #2, Line 31:</a> </p>"
-            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
-            + "</blockquote><p>Some text from original comment</p>"
-            + "</li>"
-            + commentBlock(c3)
-            + ""
-            + // Inline comment #2
-            "</ul></li></ul>"
-            + ""
-            + // Footer
-            "<p>To view, visit <a href=\""
-            + CHANGE_URL
-            + "/1\">this change</a>. "
-            + "To unsubscribe, visit <a href=\"https://someurl\">settings</a>."
-            + "</p><p>Gerrit-MessageType: comment<br>"
-            + "Footer omitted</p>"
-            + "<div><div></div></div>"
-            + "<p>Gerrit-HasComments: Yes</p></blockquote></div><br></div></div>";
-    return email;
-  }
-
-  private static String commentBlock(String comment) {
-    if (comment == null) {
-      return "";
-    }
-    return "</ul></li></ul></blockquote><div>"
-        + comment
-        + "</div><blockquote class=\"quote\"><ul><li><ul>";
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java b/javatests/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
deleted file mode 100644
index df71629..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
+++ /dev/null
@@ -1,119 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-public class GmailHtmlParserTest extends HtmlParserTest {
-  @Override
-  protected String newHtmlBody(
-      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
-    String email =
-        ""
-            + "<div class=\"gmail_default\" dir=\"ltr\">"
-            + (changeMessage != null ? changeMessage : "")
-            + "<div class=\"gmail_extra\"><br><div class=\"gmail_quote\">"
-            + "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com"
-            + "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" "
-            + "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>"
-            + "</div></div><blockquote class=\"gmail_quote\" "
-            + "<p>foobar <strong>posted comments</strong> on this change.</p>"
-            + "<p><a href=\""
-            + CHANGE_URL
-            + "/1\" "
-            + "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n"
-            + "\n"
-            + "(3 comments)</div><ul><li>"
-            + "<p>"
-            + // File #1: test.txt
-            "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt\">"
-            + "File gerrit-server/<wbr>test.txt:</a></p>"
-            + commentBlock(f1)
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt\">"
-            + "Patch Set #2:</a> </p>"
-            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
-            + "</blockquote><p>Some comment on file 1</p>"
-            + "</li>"
-            + commentBlock(fc1)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt@2\">"
-            + "Patch Set #2, Line 31:</a> </p>"
-            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
-            + "</blockquote><p>Some text from original comment</p>"
-            + "</li>"
-            + commentBlock(c1)
-            + ""
-            + // Inline comment #2
-            "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt@3\">"
-            + "Patch Set #2, Line 47:</a> </p>"
-            + "<blockquote><pre>Some comment posted on Gerrit</pre>"
-            + "</blockquote><p>Some more comments from Gerrit</p>"
-            + "</li>"
-            + commentBlock(c2)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt@115\">"
-            + "Patch Set #2, Line 115:</a> <code>some code</code></p>"
-            + "<p>some comment</p></li></ul></li>"
-            + ""
-            + "<li><p>"
-            + // File #2: test.txt
-            "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/readme.txt\">"
-            + "File gerrit-server/<wbr>readme.txt:</a></p>"
-            + commentBlock(f2)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/readme.txt@3\">"
-            + "Patch Set #2, Line 31:</a> </p>"
-            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
-            + "</blockquote><p>Some text from original comment</p>"
-            + "</li>"
-            + commentBlock(c3)
-            + ""
-            + // Inline comment #2
-            "</ul></li></ul>"
-            + ""
-            + // Footer
-            "<p>To view, visit <a href=\""
-            + CHANGE_URL
-            + "/1\">this change</a>. "
-            + "To unsubscribe, visit <a href=\"https://someurl\">settings</a>."
-            + "</p><p>Gerrit-MessageType: comment<br>"
-            + "Footer omitted</p>"
-            + "<div><div></div></div>"
-            + "<p>Gerrit-HasComments: Yes</p></blockquote></div>";
-    return email;
-  }
-
-  private static String commentBlock(String comment) {
-    if (comment == null) {
-      return "";
-    }
-    return "</ul></li></ul></blockquote><div>"
-        + comment
-        + "</div><blockquote class=\"gmail_quote\"><ul><li><ul>";
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java b/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java
deleted file mode 100644
index d88e09f..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java
+++ /dev/null
@@ -1,190 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.List;
-import org.junit.Ignore;
-import org.junit.Test;
-
-/**
- * Abstract parser test for HTML messages. Payload will be added through concrete implementations.
- */
-@Ignore
-public abstract class HtmlParserTest extends AbstractParserTest {
-  @Test
-  public void simpleChangeMessage() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(newHtmlBody("Looks good to me", null, null, null, null, null, null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
-
-    assertThat(parsedComments).hasSize(1);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-  }
-
-  @Test
-  public void changeMessageWithLink() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(
-        newHtmlBody(
-            "Did you consider this: "
-                + "<a href=\"http://gerritcodereview.com\">http://gerritcodereview.com</a>",
-            null,
-            null,
-            null,
-            null,
-            null,
-            null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
-
-    assertThat(parsedComments).hasSize(1);
-    assertChangeMessage(
-        "Did you consider this: http://gerritcodereview.com", parsedComments.get(0));
-  }
-
-  @Test
-  public void simpleInlineComments() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(
-        newHtmlBody(
-            "Looks good to me",
-            "I have a comment on this.&nbsp;",
-            null,
-            "Also have a comment here.",
-            null,
-            null,
-            null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-    assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
-    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
-  }
-
-  @Test
-  public void simpleInlineCommentsWithLink() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(
-        newHtmlBody(
-            "Looks good to me",
-            "How about [1]? This would help IMHO.</div><div>[1] "
-                + "<a href=\"http://gerritcodereview.com\">http://gerritcodereview.com</a>",
-            null,
-            "Also have a comment here.",
-            null,
-            null,
-            null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-    assertInlineComment(
-        "How about [1]? This would help IMHO.\n\n[1] http://gerritcodereview.com",
-        parsedComments.get(1),
-        comments.get(1));
-    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
-  }
-
-  @Test
-  public void simpleFileComment() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(
-        newHtmlBody(
-            "Looks good to me",
-            null,
-            null,
-            "Also have a comment here.",
-            "This is a nice file",
-            null,
-            null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
-    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
-  }
-
-  @Test
-  public void noComments() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(newHtmlBody(null, null, null, null, null, null, null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).isEmpty();
-  }
-
-  @Test
-  public void noChangeMessage() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(
-        newHtmlBody(
-            null, null, null, "Also have a comment here.", "This is a nice file", null, null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(2);
-    assertFileComment("This is a nice file", parsedComments.get(0), comments.get(1).key.filename);
-    assertInlineComment("Also have a comment here.", parsedComments.get(1), comments.get(4));
-  }
-
-  @Test
-  public void commentsSpanningMultipleBlocks() {
-    String htmlMessage =
-        "This is a very long test comment. <div><br></div><div>Now this is a new paragraph yay.</div>";
-    String txtMessage = "This is a very long test comment.\n\nNow this is a new paragraph yay.";
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(newHtmlBody(htmlMessage, null, null, htmlMessage, htmlMessage, null, null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage(txtMessage, parsedComments.get(0));
-    assertFileComment(txtMessage, parsedComments.get(1), comments.get(1).key.filename);
-    assertInlineComment(txtMessage, parsedComments.get(2), comments.get(4));
-  }
-
-  /**
-   * Create an html message body with the specified comments.
-   *
-   * @param changeMessage
-   * @param c1 Comment in reply to first comment.
-   * @param c2 Comment in reply to second comment.
-   * @param c3 Comment in reply to third comment.
-   * @param f1 Comment on file one.
-   * @param f2 Comment on file two.
-   * @param fc1 Comment in reply to a comment on file 1.
-   * @return A string with all inline comments and the original quoted email.
-   */
-  protected abstract String newHtmlBody(
-      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1);
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/MailHeaderParserTest.java b/javatests/com/google/gerrit/server/mail/receive/MailHeaderParserTest.java
deleted file mode 100644
index 071dc4b..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/MailHeaderParserTest.java
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
-import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.junit.Test;
-
-public class MailHeaderParserTest {
-  @Test
-  public void parseMetadataFromHeader() {
-    // This tests if the metadata parser is able to parse metadata from the
-    // email headers of the message.
-    MailMessage.Builder b = MailMessage.builder();
-    b.id("");
-    b.dateReceived(Instant.now());
-    b.subject("");
-
-    b.addAdditionalHeader(MailHeader.CHANGE_NUMBER.fieldWithDelimiter() + "123");
-    b.addAdditionalHeader(MailHeader.PATCH_SET.fieldWithDelimiter() + "1");
-    b.addAdditionalHeader(MailHeader.MESSAGE_TYPE.fieldWithDelimiter() + "comment");
-    b.addAdditionalHeader(
-        MailHeader.COMMENT_DATE.fieldWithDelimiter() + "Tue, 25 Oct 2016 02:11:35 -0700");
-
-    Address author = new Address("Diffy", "test@gerritcodereview.com");
-    b.from(author);
-
-    MailMetadata meta = MailHeaderParser.parse(b.build());
-    assertThat(meta.author).isEqualTo(author.getEmail());
-    assertThat(meta.changeNumber).isEqualTo(123);
-    assertThat(meta.patchSet).isEqualTo(1);
-    assertThat(meta.messageType).isEqualTo("comment");
-    assertThat(meta.timestamp.toInstant())
-        .isEqualTo(
-            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
-                .atOffset(ZoneOffset.UTC)
-                .toInstant());
-  }
-
-  @Test
-  public void parseMetadataFromText() {
-    // This tests if the metadata parser is able to parse metadata from the
-    // the text body of the message.
-    MailMessage.Builder b = MailMessage.builder();
-    b.id("");
-    b.dateReceived(Instant.now());
-    b.subject("");
-
-    StringBuilder stringBuilder = new StringBuilder();
-    stringBuilder.append(MailHeader.CHANGE_NUMBER.withDelimiter()).append("123\r\n");
-    stringBuilder.append("> ").append(MailHeader.PATCH_SET.withDelimiter()).append("1\n");
-    stringBuilder.append(MailHeader.MESSAGE_TYPE.withDelimiter()).append("comment\n");
-    stringBuilder
-        .append(MailHeader.COMMENT_DATE.withDelimiter())
-        .append("Tue, 25 Oct 2016 02:11:35 -0700\r\n");
-    b.textContent(stringBuilder.toString());
-
-    Address author = new Address("Diffy", "test@gerritcodereview.com");
-    b.from(author);
-
-    MailMetadata meta = MailHeaderParser.parse(b.build());
-    assertThat(meta.author).isEqualTo(author.getEmail());
-    assertThat(meta.changeNumber).isEqualTo(123);
-    assertThat(meta.patchSet).isEqualTo(1);
-    assertThat(meta.messageType).isEqualTo("comment");
-    assertThat(meta.timestamp.toInstant())
-        .isEqualTo(
-            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
-                .atOffset(ZoneOffset.UTC)
-                .toInstant());
-  }
-
-  @Test
-  public void parseMetadataFromHTML() {
-    // This tests if the metadata parser is able to parse metadata from the
-    // the HTML body of the message.
-    MailMessage.Builder b = MailMessage.builder();
-    b.id("");
-    b.dateReceived(Instant.now());
-    b.subject("");
-
-    StringBuilder stringBuilder = new StringBuilder();
-    stringBuilder
-        .append("<div id\"someid\">")
-        .append(MailHeader.CHANGE_NUMBER.withDelimiter())
-        .append("123</div>");
-    stringBuilder.append("<div>").append(MailHeader.PATCH_SET.withDelimiter()).append("1</div>");
-    stringBuilder
-        .append("<div>")
-        .append(MailHeader.MESSAGE_TYPE.withDelimiter())
-        .append("comment</div>");
-    stringBuilder
-        .append("<div>")
-        .append(MailHeader.COMMENT_DATE.withDelimiter())
-        .append("Tue, 25 Oct 2016 02:11:35 -0700")
-        .append("</div>");
-    b.htmlContent(stringBuilder.toString());
-
-    Address author = new Address("Diffy", "test@gerritcodereview.com");
-    b.from(author);
-
-    MailMetadata meta = MailHeaderParser.parse(b.build());
-    assertThat(meta.author).isEqualTo(author.getEmail());
-    assertThat(meta.changeNumber).isEqualTo(123);
-    assertThat(meta.patchSet).isEqualTo(1);
-    assertThat(meta.messageType).isEqualTo("comment");
-    assertThat(meta.timestamp.toInstant())
-        .isEqualTo(
-            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
-                .atOffset(ZoneOffset.UTC)
-                .toInstant());
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/ParserUtilTest.java b/javatests/com/google/gerrit/server/mail/receive/ParserUtilTest.java
deleted file mode 100644
index dfa492c..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/ParserUtilTest.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class ParserUtilTest {
-  @Test
-  public void trimQuotationLineOnMessageWithoutQuoatationLine() throws Exception {
-    assertThat(ParserUtil.trimQuotation("One line")).isEqualTo("One line");
-    assertThat(ParserUtil.trimQuotation("Two\nlines")).isEqualTo("Two\nlines");
-    assertThat(ParserUtil.trimQuotation("Thr\nee\nlines")).isEqualTo("Thr\nee\nlines");
-  }
-
-  @Test
-  public void trimQuotationLineOnMixedMessages() throws Exception {
-    assertThat(
-            ParserUtil.trimQuotation(
-                "One line\n"
-                    + "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit)\n"
-                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
-        .isEqualTo("One line");
-    assertThat(
-            ParserUtil.trimQuotation(
-                "One line\n"
-                    + "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit) "
-                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
-        .isEqualTo("One line");
-  }
-
-  @Test
-  public void trimQuotationLineOnMessagesContainingQuoationLine() throws Exception {
-    assertThat(
-            ParserUtil.trimQuotation(
-                "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit)\n"
-                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
-        .isEqualTo("");
-    assertThat(
-            ParserUtil.trimQuotation(
-                "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit) "
-                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
-        .isEqualTo("");
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/RawMailParserTest.java b/javatests/com/google/gerrit/server/mail/receive/RawMailParserTest.java
deleted file mode 100644
index fb52947..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/RawMailParserTest.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.server.mail.receive.data.AttachmentMessage;
-import com.google.gerrit.server.mail.receive.data.Base64HeaderMessage;
-import com.google.gerrit.server.mail.receive.data.HtmlMimeMessage;
-import com.google.gerrit.server.mail.receive.data.NonUTF8Message;
-import com.google.gerrit.server.mail.receive.data.QuotedPrintableHeaderMessage;
-import com.google.gerrit.server.mail.receive.data.RawMailMessage;
-import com.google.gerrit.server.mail.receive.data.SimpleTextMessage;
-import com.google.gerrit.testing.GerritBaseTests;
-import org.junit.Test;
-
-public class RawMailParserTest extends GerritBaseTests {
-  @Test
-  public void parseEmail() throws Exception {
-    RawMailMessage[] messages =
-        new RawMailMessage[] {
-          new SimpleTextMessage(),
-          new Base64HeaderMessage(),
-          new QuotedPrintableHeaderMessage(),
-          new HtmlMimeMessage(),
-          new AttachmentMessage(),
-          new NonUTF8Message(),
-        };
-    for (RawMailMessage rawMailMessage : messages) {
-      if (rawMailMessage.rawChars() != null) {
-        // Assert Character to Mail Parser
-        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.rawChars());
-        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
-      }
-      if (rawMailMessage.raw() != null) {
-        // Assert String to Mail Parser
-        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.raw());
-        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
-      }
-    }
-  }
-
-  /**
-   * This method makes it easier to debug failing tests by checking each property individual instead
-   * of calling equals as it will immediately reveal the property that diverges between the two
-   * objects.
-   *
-   * @param have MailMessage retrieved from the parser
-   * @param want MailMessage that would be expected
-   */
-  private void assertMail(MailMessage have, MailMessage want) {
-    assertThat(have.id()).isEqualTo(want.id());
-    assertThat(have.to()).isEqualTo(want.to());
-    assertThat(have.from()).isEqualTo(want.from());
-    assertThat(have.cc()).isEqualTo(want.cc());
-    assertThat(have.dateReceived()).isEqualTo(want.dateReceived());
-    assertThat(have.additionalHeaders()).isEqualTo(want.additionalHeaders());
-    assertThat(have.subject()).isEqualTo(want.subject());
-    assertThat(have.textContent()).isEqualTo(want.textContent());
-    assertThat(have.htmlContent()).isEqualTo(want.htmlContent());
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/TextParserTest.java b/javatests/com/google/gerrit/server/mail/receive/TextParserTest.java
deleted file mode 100644
index 89e1f22..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/TextParserTest.java
+++ /dev/null
@@ -1,261 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.List;
-import org.junit.Test;
-
-public class TextParserTest extends AbstractParserTest {
-  private static final String quotedFooter =
-      ""
-          + "> To view, visit https://gerrit-review.googlesource.com/123\n"
-          + "> To unsubscribe, visit https://gerrit-review.googlesource.com\n"
-          + "> \n"
-          + "> Gerrit-MessageType: comment\n"
-          + "> Gerrit-Change-Id: Ie1234021bf1e8d1425641af58fd648fc011db153\n"
-          + "> Gerrit-PatchSet: 1\n"
-          + "> Gerrit-Project: gerrit\n"
-          + "> Gerrit-Branch: master\n"
-          + "> Gerrit-Owner: Foo Bar <foo@bar.com>\n"
-          + "> Gerrit-HasComments: Yes";
-
-  @Test
-  public void simpleChangeMessage() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.textContent("Looks good to me\n" + quotedFooter);
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(1);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-  }
-
-  @Test
-  public void simpleInlineComments() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.textContent(
-        newPlaintextBody(
-                "Looks good to me",
-                "I have a comment on this.",
-                null,
-                "Also have a comment here.",
-                null,
-                null,
-                null)
-            + quotedFooter);
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-    assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
-    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
-  }
-
-  @Test
-  public void simpleFileComment() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.textContent(
-        newPlaintextBody(
-                "Looks good to me",
-                null,
-                null,
-                "Also have a comment here.",
-                "This is a nice file",
-                null,
-                null)
-            + quotedFooter);
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
-    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
-  }
-
-  @Test
-  public void noComments() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.textContent(newPlaintextBody(null, null, null, null, null, null, null) + quotedFooter);
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).isEmpty();
-  }
-
-  @Test
-  public void noChangeMessage() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.textContent(
-        newPlaintextBody(
-                null, null, null, "Also have a comment here.", "This is a nice file", null, null)
-            + quotedFooter);
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(2);
-    assertFileComment("This is a nice file", parsedComments.get(0), comments.get(1).key.filename);
-    assertInlineComment("Also have a comment here.", parsedComments.get(1), comments.get(3));
-  }
-
-  @Test
-  public void allCommentsGmail() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.textContent(
-        (newPlaintextBody(
-                    "Looks good to me",
-                    null,
-                    null,
-                    "Also have a comment here.",
-                    "This is a nice file",
-                    null,
-                    null)
-                + quotedFooter)
-            .replace("> ", ">> "));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
-    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
-  }
-
-  @Test
-  public void replyToFileComment() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.textContent(
-        newPlaintextBody(
-                "Looks good to me",
-                null,
-                null,
-                null,
-                null,
-                null,
-                "Comment in reply to file comment")
-            + quotedFooter);
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(2);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-    assertInlineComment("Comment in reply to file comment", parsedComments.get(1), comments.get(0));
-  }
-
-  @Test
-  public void squashComments() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.textContent(
-        "Nice change\n> Some quoted content\nMy other comment on the same entity\n" + quotedFooter);
-
-    List<MailComment> parsedComments = TextParser.parse(b.build(), defaultComments(), CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(1);
-    assertChangeMessage(
-        "Nice change\n\nMy other comment on the same entity", parsedComments.get(0));
-  }
-
-  /**
-   * Create a plaintext message body with the specified comments.
-   *
-   * @param changeMessage
-   * @param c1 Comment in reply to first inline comment.
-   * @param c2 Comment in reply to second inline comment.
-   * @param c3 Comment in reply to third inline comment.
-   * @param f1 Comment on file one.
-   * @param f2 Comment on file two.
-   * @param fc1 Comment in reply to a comment of file 1.
-   * @return A string with all inline comments and the original quoted email.
-   */
-  private static String newPlaintextBody(
-      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
-    return (changeMessage == null ? "" : changeMessage + "\n")
-        + "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit)\n"
-        + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote: \n"
-        + "> Foo Bar has posted comments on this change. (  \n"
-        + "> "
-        + CHANGE_URL
-        + "/1 )\n"
-        + "> \n"
-        + "> Change subject: Test change\n"
-        + "> ...............................................................\n"
-        + "> \n"
-        + "> \n"
-        + "> Patch Set 1: Code-Review+1\n"
-        + "> \n"
-        + "> (3 comments)\n"
-        + "> \n"
-        + "> "
-        + CHANGE_URL
-        + "/1/gerrit-server/test.txt\n"
-        + "> File  \n"
-        + "> gerrit-server/test.txt:\n"
-        + (f1 == null ? "" : f1 + "\n")
-        + "> \n"
-        + "> Patch Set #4:\n"
-        + "> "
-        + CHANGE_URL
-        + "/1/gerrit-server/test.txt\n"
-        + "> \n"
-        + "> Some comment"
-        + "> \n"
-        + (fc1 == null ? "" : fc1 + "\n")
-        + "> "
-        + CHANGE_URL
-        + "/1/gerrit-server/test.txt@2\n"
-        + "> PS1, Line 2: throw new Exception(\"Object has unsupported: \" +\n"
-        + ">               :             entry.getValue() +\n"
-        + ">               :             \" must be java.util.Date\");\n"
-        + "> Should entry.getKey() be included in this message?\n"
-        + "> \n"
-        + (c1 == null ? "" : c1 + "\n")
-        + ">\n"
-        + "> \n"
-        + "> "
-        + CHANGE_URL
-        + "/1/gerrit-server/test.txt@3\n"
-        + "> PS1, Line 3: throw new Exception(\"Object has: \" +\n"
-        + ">               :             entry.getValue().getClass() +\n"
-        + ">              :             \" must be java.util.Date\");\n"
-        + "> same here\n"
-        + "> \n"
-        + (c2 == null ? "" : c2 + "\n")
-        + "> \n"
-        + "> "
-        + CHANGE_URL
-        + "/1/gerrit-server/readme.txt\n"
-        + "> File  \n"
-        + "> gerrit-server/readme.txt:\n"
-        + (f2 == null ? "" : f2 + "\n")
-        + "> \n"
-        + "> "
-        + CHANGE_URL
-        + "/1/gerrit-server/readme.txt@3\n"
-        + "> PS1, Line 3: E\n"
-        + "> Should this be EEE like in other places?\n"
-        + (c3 == null ? "" : c3 + "\n");
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
deleted file mode 100644
index eb4d180..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.junit.Ignore;
-
-/**
- * Provides a raw message payload and a parsed {@code MailMessage} to check that mime parts that are
- * neither text/plain, nor * text/html are dropped.
- */
-@Ignore
-public class AttachmentMessage extends RawMailMessage {
-  private static String raw =
-      "MIME-Version: 1.0\n"
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w"
-          + "@mail.gmail.com>\n"
-          + "Subject: Test Subject\n"
-          + "From: Patrick Hiesel <hiesel@google.com>\n"
-          + "To: Patrick Hiesel <hiesel@google.com>\n"
-          + "Content-Type: multipart/mixed; boundary=001a114e019a56962d054062708f\n"
-          + "\n"
-          + "--001a114e019a56962d054062708f\n"
-          + "Content-Type: multipart/alternative; boundary=001a114e019a5696250540"
-          + "62708d\n"
-          + "\n"
-          + "--001a114e019a569625054062708d\n"
-          + "Content-Type: text/plain; charset=UTF-8\n"
-          + "\n"
-          + "Contains unwanted attachment"
-          + "\n"
-          + "--001a114e019a569625054062708d\n"
-          + "Content-Type: text/html; charset=UTF-8\n"
-          + "\n"
-          + "<div dir=\"ltr\">Contains unwanted attachment</div>"
-          + "\n"
-          + "--001a114e019a569625054062708d--\n"
-          + "--001a114e019a56962d054062708f\n"
-          + "Content-Type: text/plain; charset=US-ASCII; name=\"test.txt\"\n"
-          + "Content-Disposition: attachment; filename=\"test.txt\"\n"
-          + "Content-Transfer-Encoding: base64\n"
-          + "X-Attachment-Id: f_iv264bt50\n"
-          + "\n"
-          + "VEVTVAo=\n"
-          + "--001a114e019a56962d054062708f--";
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    System.out.println("\uD83D\uDE1B test");
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w@mail.gmail.com>")
-        .from(new Address("Patrick Hiesel", "hiesel@google.com"))
-        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
-        .textContent("Contains unwanted attachment")
-        .htmlContent("<div dir=\"ltr\">Contains unwanted attachment</div>")
-        .subject("Test Subject")
-        .addAdditionalHeader("MIME-Version: 1.0")
-        .dateReceived(
-            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
-                .atOffset(ZoneOffset.UTC)
-                .toInstant());
-    return expect.build();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
deleted file mode 100644
index 91dc6f1..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.junit.Ignore;
-
-/** Tests parsing a Base64 encoded subject. */
-@Ignore
-public class Base64HeaderMessage extends RawMailMessage {
-  private static String textContent = "Some Text";
-  private static String raw =
-      ""
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
-          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
-          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
-          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
-          + "To: ekempin <ekempin@google.com>\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent;
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
-        .from(
-            new Address(
-                "Jonathan Nieder (Gerrit)",
-                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
-        .textContent(textContent)
-        .subject("\uD83D\uDE1B test")
-        .dateReceived(
-            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
-                .atOffset(ZoneOffset.UTC)
-                .toInstant());
-    return expect.build();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
deleted file mode 100644
index 756581f..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.junit.Ignore;
-
-/** Tests a message containing mime/alternative (text + html) content. */
-@Ignore
-public class HtmlMimeMessage extends RawMailMessage {
-  private static String textContent = "Simple test";
-
-  // htmlContent is encoded in quoted-printable
-  private static String htmlContent =
-      "<div dir=3D\"ltr\">Test <span style"
-          + "=3D\"background-color:rgb(255,255,0)\">Messa=\n"
-          + "ge</span> in <u>HTML=C2=A0</u><a href=3D\"https://en.wikipedia.org/"
-          + "wiki/%C3%=\n9Cmlaut_(band)\" class=3D\"gmail-mw-redirect\" title=3D\""
-          + "=C3=9Cmlaut (band)\" st=\nyle=3D\"text-decoration:none;color:rgb(11,"
-          + "0,128);background-image:none;backg=\nround-position:initial;background"
-          + "-size:initial;background-repeat:initial;ba=\nckground-origin:initial;"
-          + "background-clip:initial;font-family:sans-serif;font=\n"
-          + "-size:14px\">=C3=9C</a></div>";
-
-  private static String unencodedHtmlContent =
-      ""
-          + "<div dir=\"ltr\">Test <span style=\"background-color:rgb(255,255,0)\">"
-          + "Message</span> in <u>HTML </u><a href=\"https://en.wikipedia.org/wiki/"
-          + "%C3%9Cmlaut_(band)\" class=\"gmail-mw-redirect\" title=\"Ümlaut "
-          + "(band)\" style=\"text-decoration:none;color:rgb(11,0,128);"
-          + "background-image:none;background-position:initial;background-size:"
-          + "initial;background-repeat:initial;background-origin:initial;background"
-          + "-clip:initial;font-family:sans-serif;font-size:14px\">Ü</a></div>";
-
-  private static String raw =
-      ""
-          + "MIME-Version: 1.0\n"
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <001a114cd8be55b4ab053face5cd@google.com>\n"
-          + "Subject: Change in gerrit[master]: Implement receiver class structure "
-          + "and bindings\n"
-          + "From: \"ekempin (Gerrit)\" <noreply-gerritcodereview-qUgXfQecoDLHwp0Ml"
-          + "dAzig@google.com>\n"
-          + "To: Patrick Hiesel <hiesel@google.com>\n"
-          + "Cc: ekempin <ekempin@google.com>\n"
-          + "Content-Type: multipart/alternative; boundary=001a114cd8b"
-          + "e55b486053face5ca\n"
-          + "\n"
-          + "--001a114cd8be55b486053face5ca\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent
-          + "\n"
-          + "--001a114cd8be55b486053face5ca\n"
-          + "Content-Type: text/html; charset=UTF-8\n"
-          + "Content-Transfer-Encoding: quoted-printable\n"
-          + "\n"
-          + htmlContent
-          + "\n"
-          + "--001a114cd8be55b486053face5ca--";
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114cd8be55b4ab053face5cd@google.com>")
-        .from(
-            new Address(
-                "ekempin (Gerrit)", "noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com"))
-        .addCc(new Address("ekempin", "ekempin@google.com"))
-        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
-        .textContent(textContent)
-        .htmlContent(unencodedHtmlContent)
-        .subject("Change in gerrit[master]: Implement receiver class structure and bindings")
-        .addAdditionalHeader("MIME-Version: 1.0")
-        .dateReceived(
-            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
-                .atOffset(ZoneOffset.UTC)
-                .toInstant());
-    return expect.build();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java b/javatests/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
deleted file mode 100644
index 3fafd4b..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.server.mail.receive.data;
-
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.junit.Ignore;
-
-/** Tests that non-UTF8 encodings are handled correctly. */
-@Ignore
-public class NonUTF8Message extends RawMailMessage {
-  private static String textContent = "Some Text";
-  private static String raw =
-      ""
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
-          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
-          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
-          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
-          + "To: ekempin <ekempin@google.com>\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent;
-
-  @Override
-  public String raw() {
-    return null;
-  }
-
-  @Override
-  public int[] rawChars() {
-    int[] arr = new int[raw.length()];
-    int i = 0;
-    for (char c : raw.toCharArray()) {
-      arr[i++] = c;
-    }
-    return arr;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
-        .from(
-            new Address(
-                "Jonathan Nieder (Gerrit)",
-                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
-        .textContent(textContent)
-        .subject("\uD83D\uDE1B test")
-        .dateReceived(
-            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
-                .atOffset(ZoneOffset.UTC)
-                .toInstant());
-    return expect.build();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
deleted file mode 100644
index 2dc48b5..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.junit.Ignore;
-
-/** Tests parsing a quoted printable encoded subject */
-@Ignore
-public class QuotedPrintableHeaderMessage extends RawMailMessage {
-  private static String textContent = "Some Text";
-  private static String raw =
-      ""
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
-          + "Subject: =?UTF-8?Q?=C3=A2me vulgaire?=\n"
-          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
-          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
-          + "To: ekempin <ekempin@google.com>\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent;
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    System.out.println("\uD83D\uDE1B test");
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
-        .from(
-            new Address(
-                "Jonathan Nieder (Gerrit)",
-                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
-        .textContent(textContent)
-        .subject("âme vulgaire")
-        .dateReceived(
-            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
-                .atOffset(ZoneOffset.UTC)
-                .toInstant());
-    return expect.build();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/RawMailMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/RawMailMessage.java
deleted file mode 100644
index 2af82ad..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/data/RawMailMessage.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-import com.google.gerrit.server.mail.receive.MailMessage;
-import org.junit.Ignore;
-
-/** Base class for all email parsing tests. */
-@Ignore
-public abstract class RawMailMessage {
-  // Raw content to feed the parser
-  public abstract String raw();
-
-  public abstract int[] rawChars();
-  // Parsed representation for asserting the expected parser output
-  public abstract MailMessage expectedMailMessage();
-}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
deleted file mode 100644
index aa5b78a..0000000
--- a/javatests/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
+++ /dev/null
@@ -1,142 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.junit.Ignore;
-
-/** Tests parsing a simple text message with different headers. */
-@Ignore
-public class SimpleTextMessage extends RawMailMessage {
-  private static String textContent =
-      ""
-          + "Jonathan Nieder has posted comments on this change. (  \n"
-          + "https://gerrit-review.googlesource.com/90018 )\n"
-          + "\n"
-          + "Change subject: (Re)enable voting buttons for merged changes\n"
-          + "...........................................................\n"
-          + "\n"
-          + "\n"
-          + "Patch Set 2:\n"
-          + "\n"
-          + "This is producing NPEs server-side and 500s for the client.   \n"
-          + "when I try to load this change:\n"
-          + "\n"
-          + "  Error in GET /changes/90018/detail?O=10004\n"
-          + "  com.google.gwtorm.OrmException: java.lang.NullPointerException\n"
-          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:303)\n"
-          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:285)\n"
-          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:263)\n"
-          + "\tat com.google.gerrit.change.GetChange.apply(GetChange.java:50)\n"
-          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:51)\n"
-          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:26)\n"
-          + "\tat  \n"
-          + "com.google.gerrit.RestApiServlet.service(RestApiServlet.java:367)\n"
-          + "\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:717)\n"
-          + "[...]\n"
-          + "  Caused by: java.lang.NullPointerException\n"
-          + "\tat  \n"
-          + "com.google.gerrit.ChangeJson.setLabelScores(ChangeJson.java:670)\n"
-          + "\tat  \n"
-          + "com.google.gerrit.ChangeJson.labelsFor(ChangeJson.java:845)\n"
-          + "\tat  \n"
-          + "com.google.gerrit.change.ChangeJson.labelsFor(ChangeJson.java:598)\n"
-          + "\tat  \n"
-          + "com.google.gerrit.change.ChangeJson.toChange(ChangeJson.java:499)\n"
-          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:294)\n"
-          + "\t... 105 more\n"
-          + "-- \n"
-          + "To view, visit https://gerrit-review.googlesource.com/90018\n"
-          + "To unsubscribe, visit https://gerrit-review.googlesource.com\n"
-          + "\n"
-          + "Gerrit-MessageType: comment\n"
-          + "Gerrit-Change-Id: Iba501e00bee77be3bd0ced72f88fd04ba0accaed\n"
-          + "Gerrit-PatchSet: 2\n"
-          + "Gerrit-Project: gerrit\n"
-          + "Gerrit-Branch: master\n"
-          + "Gerrit-Owner: ekempin <ekempin@google.com>\n"
-          + "Gerrit-Reviewer: Dave Borowitz <dborowitz@google.com>\n"
-          + "Gerrit-Reviewer: Edwin Kempin <ekempin@google.com>\n"
-          + "Gerrit-Reviewer: GerritForge CI <gerritforge@gmail.com>\n"
-          + "Gerrit-Reviewer: Jonathan Nieder <jrn@google.com>\n"
-          + "Gerrit-Reviewer: Patrick Hiesel <hiesel@google.com>\n"
-          + "Gerrit-Reviewer: ekempin <ekempin@google.com>\n"
-          + "Gerrit-HasComments: No";
-
-  private static String raw =
-      ""
-          + "Authentication-Results: mx.google.com; dkim=pass header.i="
-          + "@google.com;\n"
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "In-Reply-To: <gerrit.1477487889000.Iba501e00bee77be3bd0ced"
-          + "72f88fd04ba0accaed@gerrit-review.googlesource.com>\n"
-          + "References: <gerrit.1477487889000.Iba501e00bee77be3bd0ced72f8"
-          + "8fd04ba0accaed@gerrit-review.googlesource.com>\n"
-          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
-          + "Subject: Change in gerrit[master]: (Re)enable voting buttons for "
-          + "merged changes\n"
-          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-CtTy0"
-          + "igsBrnvL7dKoWEIEg@google.com>\n"
-          + "To: ekempin <ekempin@google.com>\n"
-          + "Cc: Dave Borowitz <dborowitz@google.com>, Jonathan Nieder "
-          + "<jrn@google.com>, Patrick Hiesel <hiesel@google.com>\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent;
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
-        .from(
-            new Address(
-                "Jonathan Nieder (Gerrit)",
-                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
-        .addCc(new Address("Dave Borowitz", "dborowitz@google.com"))
-        .addCc(new Address("Jonathan Nieder", "jrn@google.com"))
-        .addCc(new Address("Patrick Hiesel", "hiesel@google.com"))
-        .textContent(textContent)
-        .subject("Change in gerrit[master]: (Re)enable voting buttons for merged changes")
-        .dateReceived(
-            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
-                .atOffset(ZoneOffset.UTC)
-                .toInstant())
-        .addAdditionalHeader(
-            "Authentication-Results: mx.google.com; dkim=pass header.i=@google.com;")
-        .addAdditionalHeader(
-            "In-Reply-To: <gerrit.1477487889000.Iba501e00bee"
-                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>")
-        .addAdditionalHeader(
-            "References: <gerrit.1477487889000.Iba501e00bee"
-                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>");
-    return expect.build();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
index 6b6632c..78cefdf 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
@@ -16,13 +16,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gwtorm.server.OrmException;
 import java.util.Collections;
 import org.junit.Test;
 
 public class CommentSenderTest {
   private static class TestSender extends CommentSender {
-    TestSender() throws OrmException {
+    TestSender() {
       super(null, null, null, null, null);
     }
   }
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 7028bb2..ff114fa7 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -21,13 +21,11 @@
 import static org.easymock.EasyMock.replay;
 import static org.easymock.EasyMock.verify;
 
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
@@ -369,20 +367,20 @@
 
   private Account.Id user(String name, String email) {
     final AccountState s = makeUser(name, email);
-    expect(accountCache.get(eq(s.getAccount().getId()))).andReturn(Optional.of(s));
-    return s.getAccount().getId();
+    expect(accountCache.get(eq(s.getAccount().id()))).andReturn(Optional.of(s));
+    return s.getAccount().id();
   }
 
   private Account.Id userNoLookup(String name, String email) {
     final AccountState s = makeUser(name, email);
-    return s.getAccount().getId();
+    return s.getAccount().id();
   }
 
   private AccountState makeUser(String name, String email) {
-    final Account.Id userId = new Account.Id(42);
-    final Account account = new Account(userId, TimeUtil.nowTs());
+    final Account.Id userId = Account.id(42);
+    final Account.Builder account = Account.builder(userId, TimeUtil.nowTs());
     account.setFullName(name);
     account.setPreferredEmail(email);
-    return AccountState.forAccount(new AllUsersName(AllUsersNameProvider.DEFAULT), account);
+    return AccountState.forAccount(account.build());
   }
 }
diff --git a/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java b/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java
index 885f7cd..b87c4a1 100644
--- a/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java
@@ -15,27 +15,31 @@
 package com.google.gerrit.server.mail.send;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.mail.send.NotificationEmail.getInstanceAndProjectName;
+import static com.google.gerrit.server.mail.send.NotificationEmail.getShortProjectName;
 
 import org.junit.Test;
 
 public class NotificationEmailTest {
-
   @Test
-  public void getInstanceAndProjectName_returnsTheRightValue() {
-    String instanceAndProjectName = NotificationEmail.getInstanceAndProjectName("test", "/my/api");
-    assertThat(instanceAndProjectName).isEqualTo("test/api");
+  public void instanceAndProjectName() throws Exception {
+    assertThat(getInstanceAndProjectName("test", "/my/api")).isEqualTo("test/api");
+    assertThat(getInstanceAndProjectName("test", "/api")).isEqualTo("test/api");
+    assertThat(getInstanceAndProjectName("test", "api")).isEqualTo("test/api");
   }
 
   @Test
-  public void getInstanceAndProjectName_handlesNull() {
-    String instanceAndProjectName = NotificationEmail.getInstanceAndProjectName(null, "/my/api");
-    assertThat(instanceAndProjectName).isEqualTo("...api");
+  public void instanceAndProjectNameNull() throws Exception {
+    assertThat(getInstanceAndProjectName(null, "/my/api")).isEqualTo("...api");
+    assertThat(getInstanceAndProjectName(null, "/api")).isEqualTo("api");
+    assertThat(getInstanceAndProjectName(null, "api")).isEqualTo("api");
   }
 
   @Test
-  public void getShortProjectName() {
-    assertThat(NotificationEmail.getShortProjectName("/api")).isEqualTo("api");
-    assertThat(NotificationEmail.getShortProjectName("/my/api")).isEqualTo("...api");
-    assertThat(NotificationEmail.getShortProjectName("/my/sub/project")).isEqualTo("...project");
+  public void shortProjectName() throws Exception {
+    assertThat(getShortProjectName("api")).isEqualTo("api");
+    assertThat(getShortProjectName("/api")).isEqualTo("api");
+    assertThat(getShortProjectName("/my/api")).isEqualTo("...api");
+    assertThat(getShortProjectName("/my/sub/project")).isEqualTo("...project");
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index c677be5..58697ad 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -18,7 +18,6 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
@@ -29,8 +28,8 @@
 import com.google.gerrit.reviewdb.client.CommentRange;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
@@ -43,7 +42,8 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
+import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.config.EnableReverseDnsLookup;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -51,24 +51,24 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.AssertableExecutorService;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeAccountCache;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestChanges;
 import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
 import java.sql.Timestamp;
 import java.util.TimeZone;
+import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
@@ -78,25 +78,11 @@
 
 @Ignore
 @RunWith(ConfigSuite.class)
-public abstract class AbstractChangeNotesTest extends GerritBaseTests {
-  @ConfigSuite.Default
-  public static Config changeNotesLegacy() {
-    Config cfg = new Config();
-    cfg.setBoolean("notedb", null, "writeJson", false);
-    return cfg;
-  }
-
-  @ConfigSuite.Config
-  public static Config changeNotesJson() {
-    Config cfg = new Config();
-    cfg.setBoolean("notedb", null, "writeJson", true);
-    return cfg;
-  }
+public abstract class AbstractChangeNotesTest {
+  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
 
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
-
   protected Account.Id otherUserId;
   protected FakeAccountCache accountCache;
   protected IdentifiedUser changeOwner;
@@ -108,6 +94,7 @@
   protected Project.NameKey project;
   protected RevWalk rw;
   protected TestRepository<InMemoryRepository> tr;
+  protected AssertableExecutorService assertableFanOutExecutor;
 
   @Inject protected IdentifiedUser.GenericFactory userFactory;
 
@@ -127,20 +114,21 @@
     setTimeForTesting();
 
     serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
-    project = new Project.NameKey("test-project");
+    project = Project.nameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
     tr = new TestRepository<>(repo);
     rw = tr.getRevWalk();
     accountCache = new FakeAccountCache();
-    Account co = new Account(new Account.Id(1), TimeUtil.nowTs());
+    Account.Builder co = Account.builder(Account.id(1), TimeUtil.nowTs());
     co.setFullName("Change Owner");
     co.setPreferredEmail("change@owner.com");
-    accountCache.put(co);
-    Account ou = new Account(new Account.Id(2), TimeUtil.nowTs());
+    accountCache.put(co.build());
+    Account.Builder ou = Account.builder(Account.id(2), TimeUtil.nowTs());
     ou.setFullName("Other Account");
     ou.setPreferredEmail("other@account.com");
-    accountCache.put(ou);
+    accountCache.put(ou.build());
+    assertableFanOutExecutor = new AssertableExecutorService();
 
     injector =
         Guice.createInjector(
@@ -148,11 +136,13 @@
               @Override
               public void configure() {
                 install(new GitModule());
-                install(NoteDbModule.forTest(testConfig));
+
+                install(new DefaultUrlFormatter.Module());
+                install(NoteDbModule.forTest());
                 bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
                 bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
                 bind(GitRepositoryManager.class).toInstance(repoManager);
-                bind(ProjectCache.class).toProvider(Providers.<ProjectCache>of(null));
+                bind(ProjectCache.class).toProvider(Providers.of(null));
                 bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
                 bind(String.class)
                     .annotatedWith(AnonymousCowardName.class)
@@ -161,8 +151,8 @@
                     .annotatedWith(CanonicalWebUrl.class)
                     .toInstance("http://localhost:8080/");
                 bind(Boolean.class)
-                    .annotatedWith(DisableReverseDnsLookup.class)
-                    .toInstance(Boolean.FALSE);
+                    .annotatedWith(EnableReverseDnsLookup.class)
+                    .toInstance(Boolean.TRUE);
                 bind(Realm.class).to(FakeRealm.class);
                 bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
                 bind(AccountCache.class).toInstance(accountCache);
@@ -171,31 +161,16 @@
                     .toInstance(serverIdent);
                 bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
                 bind(MetricMaker.class).to(DisabledMetricMaker.class);
-                bind(ReviewDb.class).toProvider(Providers.<ReviewDb>of(null));
-
-                MutableNotesMigration migration = MutableNotesMigration.newDisabled();
-                migration.setFrom(NotesMigrationState.FINAL);
-                bind(MutableNotesMigration.class).toInstance(migration);
-                bind(NotesMigration.class).to(MutableNotesMigration.class);
-
-                // Tests don't support ReviewDb at all, but bindings are required via NoteDbModule.
-                bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {})
-                    .toInstance(
-                        () -> {
-                          throw new UnsupportedOperationException();
-                        });
-                bind(ChangeBundleReader.class)
-                    .toInstance(
-                        (db, id) -> {
-                          throw new UnsupportedOperationException();
-                        });
+                bind(ExecutorService.class)
+                    .annotatedWith(FanOutExecutor.class)
+                    .toInstance(assertableFanOutExecutor);
               }
             });
 
     injector.injectMembers(this);
     repoManager.createRepository(allUsers);
-    changeOwner = userFactory.create(co.getId());
-    otherUser = userFactory.create(ou.getId());
+    changeOwner = userFactory.create(co.id());
+    otherUser = userFactory.create(ou.id());
     otherUserId = otherUser.getAccountId();
     internalUser = new InternalUser();
   }
@@ -213,9 +188,9 @@
 
   protected Change newChange(boolean workInProgress) throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    ChangeUpdate u = newUpdate(c, changeOwner);
+    ChangeUpdate u = newUpdateForNewChange(c, changeOwner);
     u.setChangeId(c.getKey().get());
-    u.setBranch(c.getDest().get());
+    u.setBranch(c.getDest().branch());
     u.setWorkInProgress(workInProgress);
     u.commit();
     return c;
@@ -229,15 +204,24 @@
     return newChange(false);
   }
 
+  protected ChangeUpdate newUpdateForNewChange(Change c, CurrentUser user) throws Exception {
+    return newUpdate(c, user, false);
+  }
+
   protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
-    ChangeUpdate update = TestChanges.newUpdate(injector, c, user);
+    return newUpdate(c, user, true);
+  }
+
+  protected ChangeUpdate newUpdate(Change c, CurrentUser user, boolean shouldExist)
+      throws Exception {
+    ChangeUpdate update = TestChanges.newUpdate(injector, c, user, shouldExist);
     update.setPatchSetId(c.currentPatchSetId());
     update.setAllowWriteToNewRef(true);
     return update;
   }
 
-  protected ChangeNotes newNotes(Change c) throws OrmException {
-    return new ChangeNotes(args, c).load();
+  protected ChangeNotes newNotes(Change c) {
+    return new ChangeNotes(args, c, true, null).load();
   }
 
   protected static SubmitRecord submitRecord(
@@ -271,7 +255,7 @@
       Timestamp t,
       String message,
       short side,
-      String commitSHA1,
+      ObjectId commitId,
       boolean unresolved) {
     Comment c =
         new Comment(
@@ -284,7 +268,7 @@
             unresolved);
     c.lineNbr = line;
     c.parentUuid = parentUUID;
-    c.revId = commitSHA1;
+    c.setCommitId(commitId);
     c.setRange(range);
     return c;
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java b/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java
deleted file mode 100644
index 722dd08..0000000
--- a/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java
+++ /dev/null
@@ -1,1976 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.common.TimeUtil.truncateToSecond;
-import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
-import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Table;
-import com.google.gerrit.common.TimeUtil;
-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.LabelId;
-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.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
-import com.google.gerrit.testing.GerritBaseTests;
-import com.google.gerrit.testing.TestChanges;
-import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.protobuf.CodecFactory;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import java.sql.Timestamp;
-import java.time.LocalDate;
-import java.time.Month;
-import java.time.ZoneId;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.TimeZone;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ChangeBundleTest extends GerritBaseTests {
-  private static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
-  private static final ProtobufCodec<ChangeMessage> CHANGE_MESSAGE_CODEC =
-      CodecFactory.encoder(ChangeMessage.class);
-  private static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
-      CodecFactory.encoder(PatchSet.class);
-  private static final ProtobufCodec<PatchSetApproval> PATCH_SET_APPROVAL_CODEC =
-      CodecFactory.encoder(PatchSetApproval.class);
-  private static final ProtobufCodec<PatchLineComment> PATCH_LINE_COMMENT_CODEC =
-      CodecFactory.encoder(PatchLineComment.class);
-  private static final String TIMEZONE_ID = "US/Eastern";
-
-  private String systemTimeZoneProperty;
-  private TimeZone systemTimeZone;
-
-  private Project.NameKey project;
-  private Account.Id accountId;
-
-  @Before
-  public void setUp() {
-    systemTimeZoneProperty = System.setProperty("user.timezone", TIMEZONE_ID);
-    systemTimeZone = TimeZone.getDefault();
-    TimeZone.setDefault(TimeZone.getTimeZone(TIMEZONE_ID));
-    long maxMs = ChangeRebuilderImpl.MAX_WINDOW_MS;
-    assertThat(maxMs).isGreaterThan(1000L);
-    TestTimeUtil.resetWithClockStep(maxMs * 2, MILLISECONDS);
-    project = new Project.NameKey("project");
-    accountId = new Account.Id(100);
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZoneProperty);
-    TimeZone.setDefault(systemTimeZone);
-  }
-
-  private void superWindowResolution() {
-    TestTimeUtil.setClockStep(ChangeRebuilderImpl.MAX_WINDOW_MS * 2, MILLISECONDS);
-    TimeUtil.nowTs();
-  }
-
-  private void subWindowResolution() {
-    TestTimeUtil.setClockStep(1, SECONDS);
-    TimeUtil.nowTs();
-  }
-
-  @Test
-  public void diffChangesDifferentIds() throws Exception {
-    Change c1 = TestChanges.newChange(project, accountId);
-    int id1 = c1.getId().get();
-    Change c2 = TestChanges.newChange(project, accountId);
-    int id2 = c2.getId().get();
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertDiffs(
-        b1,
-        b2,
-        "changeId differs for Changes: {" + id1 + "} != {" + id2 + "}",
-        "createdOn differs for Changes: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}",
-        "effective last updated time differs for Changes:"
-            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}");
-  }
-
-  @Test
-  public void diffChangesSameId() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    c2.setTopic("topic");
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {null} != {topic}");
-  }
-
-  @Test
-  public void diffChangesMixedSourcesAllowsSlop() throws Exception {
-    subWindowResolution();
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCreatedOn(TimeUtil.nowTs());
-    c2.setLastUpdatedOn(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:02.0}",
-        "effective last updated time differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:03.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // But not too much slop.
-    superWindowResolution();
-    Change c3 = clone(c1);
-    c3.setLastUpdatedOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c3, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    String msg =
-        "effective last updated time differs for Change.Id "
-            + c1.getId()
-            + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:10.0}";
-    assertDiffs(b1, b3, msg);
-    assertDiffs(b3, b1, msg);
-  }
-
-  @Test
-  public void diffChangesIgnoresOriginalSubjectInReviewDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject", "Original A");
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c2.currentPatchSetId(), c1.getSubject(), "Original B");
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "originalSubject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Original A} != {Original B}");
-
-    // Both NoteDb, exact match required.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "originalSubject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Original A} != {Original B}");
-
-    // One ReviewDb, one NoteDb, original subject is ignored.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesSanitizesSubjectsBeforeComparison() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject\r\rbody", "Original");
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject  body", "Original");
-
-    // Both ReviewDb, exact match required
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Subject\r\rbody} != {Subject  body}");
-
-    // Both NoteDb, exact match required (although it should be impossible to
-    // create a NoteDb change with '\r' in the subject).
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Subject\r\rbody} != {Subject  body}");
-
-    // One ReviewDb, one NoteDb, '\r' is normalized to ' '.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesConsidersEmptyReviewDbTopicEquivalentToNullInNoteDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setTopic("");
-    Change c2 = clone(c1);
-    c2.setTopic(null);
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {} != {null}");
-
-    // Topic ignored if ReviewDb is empty and NoteDb is null.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-
-    // Exact match still required if NoteDb has empty value (not realistic).
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {} != {null}");
-
-    // Null is not equal to a non-empty string.
-    Change c3 = clone(c1);
-    c3.setTopic("topic");
-    b1 =
-        new ChangeBundle(
-            c3, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {topic} != {null}");
-
-    // Null is equal to a string that is all whitespace.
-    Change c4 = clone(c1);
-    c4.setTopic("  ");
-    b1 =
-        new ChangeBundle(
-            c4, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesIgnoresLeadingAndTrailingWhitespaceInReviewDbTopics() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setTopic(" abc ");
-    Change c2 = clone(c1);
-    c2.setTopic("abc");
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {abc}");
-
-    // Leading whitespace in ReviewDb topic is ignored.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // Must match except for the leading/trailing whitespace.
-    Change c3 = clone(c1);
-    c3.setTopic("cba");
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c3, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {cba}");
-  }
-
-  @Test
-  public void diffChangesTakesMaxEntityTimestampFromReviewDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    PatchSet ps = new PatchSet(c1.currentPatchSetId());
-    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps.setUploader(accountId);
-    ps.setCreatedOn(TimeUtil.nowTs());
-    PatchSetApproval a =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-
-    Change c2 = clone(c1);
-    c2.setLastUpdatedOn(a.getGranted());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "effective last updated time differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:12.0}");
-
-    // NoteDb allows latest timestamp from all entities in bundle.
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-  }
-
-  @Test
-  public void diffChangesIgnoresChangeTimestampIfAnyOtherEntitiesExist() {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    PatchSet ps = new PatchSet(c1.currentPatchSetId());
-    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps.setUploader(accountId);
-    ps.setCreatedOn(TimeUtil.nowTs());
-    PatchSetApproval a =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-    c1.setLastUpdatedOn(a.getGranted());
-
-    Change c2 = clone(c1);
-    c2.setLastUpdatedOn(TimeUtil.nowTs());
-
-    // ReviewDb has later lastUpdatedOn timestamp than NoteDb, allowed since
-    // NoteDb matches the latest timestamp of a non-Change entity.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c1, messages(), patchSets(ps), approvals(a), comments(), reviewers(), NOTE_DB);
-    assertThat(b1.getChange().getLastUpdatedOn()).isGreaterThan(b2.getChange().getLastUpdatedOn());
-    assertNoDiffs(b1, b2);
-
-    // Timestamps must actually match if Change is the only entity.
-    b1 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "effective last updated time differs for Change.Id "
-            + c1.getId()
-            + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:12.0} != {2009-09-30 17:00:18.0}");
-  }
-
-  @Test
-  public void diffChangesAllowsReviewDbSubjectToBePrefixOfNoteDbSubject() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(
-        c1.currentPatchSetId(), c1.getSubject().substring(0, 10), c1.getOriginalSubject());
-    assertThat(c2.getSubject()).isNotEqualTo(c1.getSubject());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id " + c1.getId() + ": {Change subject} != {Change sub}");
-
-    // ReviewDb has shorter subject, allowed.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // NoteDb has shorter subject, not allowed.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), latest(c1), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(c2, messages(), latest(c2), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id " + c1.getId() + ": {Change subject} != {Change sub}");
-  }
-
-  @Test
-  public void diffChangesTrimsLeadingSpacesFromReviewDbComparingToNoteDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c1.currentPatchSetId(), "   " + c1.getSubject(), c1.getOriginalSubject());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Change subject} != {   Change subject}");
-
-    // ReviewDb is missing leading spaces, allowed.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesDoesntTrimLeadingNonSpaceWhitespaceFromSubject() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c1.currentPatchSetId(), "\t" + c1.getSubject(), c1.getOriginalSubject());
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Change subject} != {\tChange subject}");
-
-    // One NoteDb.
-    b1 =
-        new ChangeBundle(c1, messages(), latest(c1), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), latest(c2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Change subject} != {\tChange subject}");
-    assertDiffs(
-        b2,
-        b1,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {\tChange subject} != {Change subject}");
-  }
-
-  @Test
-  public void diffChangesHandlesBuggyJGitSubjectExtraction() throws Exception {
-    Change c1 = TestChanges.newChange(project, accountId);
-    String buggySubject = "Subject\r \r Rest of message.";
-    c1.setCurrentPatchSet(c1.currentPatchSetId(), buggySubject, buggySubject);
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject", "Subject");
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "originalSubject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Subject\r \r Rest of message.} != {Subject}",
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Subject\r \r Rest of message.} != {Subject}");
-
-    // NoteDb has correct subject without "\r ".
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesIgnoresInvalidCurrentPatchSetIdInReviewDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(
-        new PatchSet.Id(c2.getId(), 0), "Unrelated subject", c2.getOriginalSubject());
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "currentPatchSetId differs for Change.Id " + c1.getId() + ": {1} != {0}",
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Change subject} != {Unrelated subject}");
-
-    // One NoteDb.
-    //
-    // This is based on a real corrupt change where all patch sets were deleted
-    // but the Change entity stuck around, resulting in a currentPatchSetId of 0
-    // after converting to NoteDb.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesAllowsCreatedToMatchLastUpdated() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setCreatedOn(TimeUtil.nowTs());
-    assertThat(c1.getCreatedOn()).isGreaterThan(c1.getLastUpdatedOn());
-    Change c2 = clone(c1);
-    c2.setCreatedOn(c2.getLastUpdatedOn());
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for Change.Id "
-            + c1.getId()
-            + ": {2009-09-30 17:00:06.0} != {2009-09-30 17:00:00.0}");
-
-    // One NoteDb.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangeMessageKeySets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    ChangeMessage cm2 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid2"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessage.Key sets differ:"
-            + " ["
-            + id
-            + ",uuid1] only in A; ["
-            + id
-            + ",uuid2] only in B");
-  }
-
-  @Test
-  public void diffChangeMessages() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 = clone(cm1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    cm2.setMessage("message 2");
-    assertDiffs(
-        b1,
-        b2,
-        "message differs for ChangeMessage.Key "
-            + c.getId()
-            + ",uuid:"
-            + " {message 1} != {message 2}");
-  }
-
-  @Test
-  public void diffChangeMessagesIgnoresUuids() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 = clone(cm1);
-    cm2.getKey().set("uuid2");
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    // Both are ReviewDb, exact UUID match is required.
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessage.Key sets differ:"
-            + " ["
-            + id
-            + ",uuid1] only in A; ["
-            + id
-            + ",uuid2] only in B");
-
-    // One NoteDb, UUIDs are ignored.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-  }
-
-  @Test
-  public void diffChangeMessagesWithDifferentCounts() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid2"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 2");
-
-    // Both ReviewDb: Uses same keySet diff as other types.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1, b2, "ChangeMessage.Key sets differ: [" + id + ",uuid2] only in A; [] only in B");
-
-    // One NoteDb: UUIDs in keys can't be used for comparison, just diff counts.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(b1, b2, "ChangeMessages differ for Change.Id " + id + "\nOnly in A:\n  " + cm2);
-    assertDiffs(b2, b1, "ChangeMessages differ for Change.Id " + id + "\nOnly in B:\n  " + cm2);
-  }
-
-  @Test
-  public void diffChangeMessagesMixedSourcesWithDifferences() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 = clone(cm1);
-    cm2.setMessage("message 2");
-    ChangeMessage cm3 = clone(cm1);
-    cm3.getKey().set("uuid2"); // Differs only in UUID.
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1, cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2, cm3), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    // Implementation happens to pair up cm1 in b1 with cm3 in b2 because it
-    // depends on iteration order and doesn't care about UUIDs. The important
-    // thing is that there's some diff.
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm3
-            + "\n"
-            + "Only in B:\n  "
-            + cm2);
-    assertDiffs(
-        b2,
-        b1,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm2
-            + "\n"
-            + "Only in B:\n  "
-            + cm3);
-  }
-
-  @Test
-  public void diffChangeMessagesMixedSourcesAllowsSlop() throws Exception {
-    subWindowResolution();
-    Change c = TestChanges.newChange(project, accountId);
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    ChangeMessage cm2 = clone(cm1);
-    cm2.setWrittenOn(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "writtenOn differs for ChangeMessage.Key "
-            + c.getId()
-            + ",uuid1:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // But not too much slop.
-    superWindowResolution();
-    ChangeMessage cm3 = clone(cm1);
-    cm3.setWrittenOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c, messages(cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    int id = c.getId().get();
-    assertDiffs(
-        b1,
-        b3,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm1
-            + "\n"
-            + "Only in B:\n  "
-            + cm3);
-    assertDiffs(
-        b3,
-        b1,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm3
-            + "\n"
-            + "Only in B:\n  "
-            + cm1);
-  }
-
-  @Test
-  public void diffChangeMessagesAllowsNullPatchSetIdFromReviewDb() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 = clone(cm1);
-    cm2.setPatchSetId(null);
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    // Both are ReviewDb, exact patch set ID match is required.
-    assertDiffs(
-        b1,
-        b2,
-        "patchset differs for ChangeMessage.Key "
-            + c.getId()
-            + ",uuid:"
-            + " {"
-            + id
-            + ",1} != {null}");
-
-    // Null patch set ID on ReviewDb is ignored.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // Null patch set ID on NoteDb is not ignored (but is not realistic).
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm1
-            + "\n"
-            + "Only in B:\n  "
-            + cm2);
-    assertDiffs(
-        b2,
-        b1,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm2
-            + "\n"
-            + "Only in B:\n  "
-            + cm1);
-  }
-
-  @Test
-  public void diffPatchSetIdSets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    TestChanges.incrementPatchSet(c);
-
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(TimeUtil.nowTs());
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
-    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
-    ps2.setUploader(accountId);
-    ps2.setCreatedOn(TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1, ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertDiffs(b1, b2, "PatchSet.Id sets differ: [] only in A; [" + c.getId() + ",1] only in B");
-  }
-
-  @Test
-  public void diffPatchSets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(TimeUtil.nowTs());
-    PatchSet ps2 = clone(ps1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
-    assertDiffs(
-        b1,
-        b2,
-        "revision differs for PatchSet.Id "
-            + c.getId()
-            + ",1:"
-            + " {RevId{deadbeefdeadbeefdeadbeefdeadbeefdeadbeef}}"
-            + " != {RevId{badc0feebadc0feebadc0feebadc0feebadc0fee}}");
-  }
-
-  @Test
-  public void diffPatchSetsMixedSourcesAllowsSlop() throws Exception {
-    subWindowResolution();
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(truncateToSecond(TimeUtil.nowTs()));
-    PatchSet ps2 = clone(ps1);
-    ps2.setCreatedOn(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",1:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // But not too much slop.
-    superWindowResolution();
-    PatchSet ps3 = clone(ps1);
-    ps3.setCreatedOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps3), approvals(), comments(), reviewers(), REVIEW_DB);
-    String msg =
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",1 in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
-    assertDiffs(b1, b3, msg);
-    assertDiffs(b3, b1, msg);
-  }
-
-  @Test
-  public void diffPatchSetsIgnoresTrailingNewlinesInPushCertificate() throws Exception {
-    subWindowResolution();
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(truncateToSecond(TimeUtil.nowTs()));
-    ps1.setPushCertificate("some cert");
-    PatchSet ps2 = clone(ps1);
-    ps2.setPushCertificate(ps2.getPushCertificate() + "\n\n");
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffPatchSetsGreaterThanCurrent() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(TimeUtil.nowTs());
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
-    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
-    ps2.setUploader(accountId);
-    ps2.setCreatedOn(TimeUtil.nowTs());
-    assertThat(ps2.getId().get()).isGreaterThan(c.currentPatchSetId().get());
-
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    ChangeMessage cm2 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid2"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(ps1.getId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-    PatchSetApproval a2 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(ps2.getId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c,
-            messages(cm1, cm2),
-            patchSets(ps1, ps2),
-            approvals(a1, a2),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessage.Key sets differ: [] only in A; [" + cm2.getKey() + "] only in B",
-        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
-        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
-
-    // One NoteDb.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(cm1, cm2),
-            patchSets(ps1, ps2),
-            approvals(a1, a2),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n  " + cm2,
-        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
-        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
-
-    // Both NoteDb.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(cm1, cm2),
-            patchSets(ps1, ps2),
-            approvals(a1, a2),
-            comments(),
-            reviewers(),
-            NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n  " + cm2,
-        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
-        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
-  }
-
-  @Test
-  public void diffPatchSetsIgnoresLeadingAndTrailingWhitespaceInReviewDbDescriptions()
-      throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(TimeUtil.nowTs());
-    ps1.setDescription(" abc ");
-    PatchSet ps2 = clone(ps1);
-    ps2.setDescription("abc");
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {abc}");
-
-    // Whitespace in ReviewDb description is ignored.
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // Must match except for the leading/trailing whitespace.
-    PatchSet ps3 = clone(ps1);
-    ps3.setDescription("cba");
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps3), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {cba}");
-  }
-
-  @Test
-  public void diffPatchSetsIgnoresCreatedOnWhenReviewDbIsNonMonotonic() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-
-    Timestamp beforePs1 = TimeUtil.nowTs();
-
-    PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    goodPs1.setUploader(accountId);
-    goodPs1.setCreatedOn(TimeUtil.nowTs());
-    PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
-    goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    goodPs2.setUploader(accountId);
-    goodPs2.setCreatedOn(TimeUtil.nowTs());
-    assertThat(goodPs2.getCreatedOn()).isGreaterThan(goodPs1.getCreatedOn());
-
-    PatchSet badPs2 = clone(goodPs2);
-    badPs2.setCreatedOn(beforePs1);
-    assertThat(badPs2.getCreatedOn()).isLessThan(goodPs1.getCreatedOn());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, badPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + badPs2.getId()
-            + ":"
-            + " {2009-09-30 17:00:18.0} != {2009-09-30 17:00:06.0}");
-
-    // Non-monotonic in ReviewDb but monotonic in NoteDb, timestamps are
-    // ignored, including for ps1.
-    PatchSet badPs1 = clone(goodPs1);
-    badPs1.setCreatedOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(badPs1, badPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // Non-monotonic in NoteDb but monotonic in ReviewDb, timestamps are not
-    // ignored.
-    b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(badPs1, badPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + badPs1.getId()
-            + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:24.0} != {2009-09-30 17:00:12.0}",
-        "createdOn differs for PatchSet.Id "
-            + badPs2.getId()
-            + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:06.0} != {2009-09-30 17:00:18.0}");
-  }
-
-  @Test
-  public void diffPatchSetsAllowsFirstPatchSetCreatedOnToMatchChangeCreatedOn() {
-    Change c = TestChanges.newChange(project, accountId);
-    c.setLastUpdatedOn(TimeUtil.nowTs());
-
-    PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    goodPs1.setUploader(accountId);
-    goodPs1.setCreatedOn(TimeUtil.nowTs());
-    assertThat(goodPs1.getCreatedOn()).isGreaterThan(c.getCreatedOn());
-
-    PatchSet ps1AtCreatedOn = clone(goodPs1);
-    ps1AtCreatedOn.setCreatedOn(c.getCreatedOn());
-
-    PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
-    goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    goodPs2.setUploader(accountId);
-    goodPs2.setCreatedOn(TimeUtil.nowTs());
-
-    PatchSet ps2AtCreatedOn = clone(goodPs2);
-    ps2AtCreatedOn.setCreatedOn(c.getCreatedOn());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(ps1AtCreatedOn, ps2AtCreatedOn),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",1: {2009-09-30 17:00:12.0} != {2009-09-30 17:00:00.0}",
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",2: {2009-09-30 17:00:18.0} != {2009-09-30 17:00:00.0}");
-
-    // One ReviewDb, PS1 is allowed to match change createdOn, but PS2 isn't.
-    b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(ps1AtCreatedOn, ps2AtCreatedOn),
-            approvals(),
-            comments(),
-            reviewers(),
-            NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}");
-    assertDiffs(
-        b2,
-        b1,
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}");
-  }
-
-  @Test
-  public void diffPatchSetApprovalKeySets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-    PatchSetApproval a2 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Verified")),
-            (short) 1,
-            TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-
-    assertDiffs(
-        b1,
-        b2,
-        "PatchSetApproval.Key sets differ:"
-            + " ["
-            + id
-            + "%2C1,100,Code-Review] only in A;"
-            + " ["
-            + id
-            + "%2C1,100,Verified] only in B");
-  }
-
-  @Test
-  public void diffPatchSetApprovals() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-    PatchSetApproval a2 = clone(a1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    a2.setValue((short) -1);
-    assertDiffs(
-        b1,
-        b2,
-        "value differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review: {1} != {-1}");
-  }
-
-  @Test
-  public void diffPatchSetApprovalsMixedSourcesAllowsSlop() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    subWindowResolution();
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            truncateToSecond(TimeUtil.nowTs()));
-    PatchSetApproval a2 = clone(a1);
-    a2.setGranted(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "granted differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:08.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // But not too much slop.
-    superWindowResolution();
-    PatchSetApproval a3 = clone(a1);
-    a3.setGranted(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a3), comments(), reviewers(), REVIEW_DB);
-    String msg =
-        "granted differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:15.0}";
-    assertDiffs(b1, b3, msg);
-    assertDiffs(b3, b1, msg);
-  }
-
-  @Test
-  public void diffPatchSetApprovalsAllowsTruncatedTimestampInNoteDb() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            c.getCreatedOn());
-    PatchSetApproval a2 = clone(a1);
-    a2.setGranted(
-        new Timestamp(
-            LocalDate.of(1900, Month.JANUARY, 1)
-                .atStartOfDay()
-                .atZone(ZoneId.of(TIMEZONE_ID))
-                .toInstant()
-                .toEpochMilli()));
-
-    // Both are ReviewDb, exact match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "granted differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {2009-09-30 17:00:00.0} != {1900-01-01 00:00:00.0}");
-
-    // Truncating NoteDb timestamp is allowed.
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffPatchSetApprovalsIgnoresPostSubmitBitOnZeroVote() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    c.setStatus(Change.Status.MERGED);
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 0,
-            TimeUtil.nowTs());
-    a1.setPostSubmit(false);
-    PatchSetApproval a2 = clone(a1);
-    a2.setPostSubmit(true);
-
-    // Both are ReviewDb, exact match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "postSubmit differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {false} != {true}");
-
-    // One NoteDb, postSubmit is ignored.
-    b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // postSubmit is not ignored if vote isn't 0.
-    a1.setValue((short) 1);
-    a2.setValue((short) 1);
-    assertDiffs(
-        b1,
-        b2,
-        "postSubmit differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {false} != {true}");
-    assertDiffs(
-        b2,
-        b1,
-        "postSubmit differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {true} != {false}");
-  }
-
-  @Test
-  public void diffReviewers() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    Timestamp now = TimeUtil.nowTs();
-    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), now);
-    ReviewerSet r2 = reviewers(REVIEWER, new Account.Id(2), now);
-
-    ChangeBundle b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1, REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2, REVIEW_DB);
-    assertNoDiffs(b1, b1);
-    assertNoDiffs(b2, b2);
-    assertDiffs(b1, b2, "reviewer sets differ: [1] only in A; [2] only in B");
-  }
-
-  @Test
-  public void diffReviewersIgnoresStateAndTimestamp() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
-    ReviewerSet r2 = reviewers(CC, new Account.Id(1), TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1, REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2, REVIEW_DB);
-    assertNoDiffs(b1, b1);
-    assertNoDiffs(b2, b2);
-  }
-
-  @Test
-  public void diffPatchLineCommentKeySets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    PatchLineComment c1 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-    PatchLineComment c2 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename2"), "uuid2"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
-
-    assertDiffs(
-        b1,
-        b2,
-        "PatchLineComment.Key sets differ:"
-            + " ["
-            + id
-            + ",1,filename1,uuid1] only in A;"
-            + " ["
-            + id
-            + ",1,filename2,uuid2] only in B");
-  }
-
-  @Test
-  public void diffPatchLineComments() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchLineComment c1 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-    PatchLineComment c2 = clone(c1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    c2.setStatus(PatchLineComment.Status.PUBLISHED);
-    assertDiffs(
-        b1,
-        b2,
-        "status differs for PatchLineComment.Key " + c.getId() + ",1,filename,uuid: {d} != {P}");
-  }
-
-  @Test
-  public void diffPatchLineCommentsMixedSourcesAllowsSlop() throws Exception {
-    subWindowResolution();
-    Change c = TestChanges.newChange(project, accountId);
-    PatchLineComment c1 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
-            5,
-            accountId,
-            null,
-            truncateToSecond(TimeUtil.nowTs()));
-    PatchLineComment c2 = clone(c1);
-    c2.setWrittenOn(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "writtenOn differs for PatchLineComment.Key "
-            + c.getId()
-            + ",1,filename,uuid:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // But not too much slop.
-    superWindowResolution();
-    PatchLineComment c3 = clone(c1);
-    c3.setWrittenOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c3), reviewers(), REVIEW_DB);
-    String msg =
-        "writtenOn differs for PatchLineComment.Key "
-            + c.getId()
-            + ",1,filename,uuid in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
-    assertDiffs(b1, b3, msg);
-    assertDiffs(b3, b1, msg);
-  }
-
-  @Test
-  public void diffPatchLineCommentsIgnoresCommentsOnInvalidPatchSet() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchLineComment c1 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-    PatchLineComment c2 =
-        new PatchLineComment(
-            new PatchLineComment.Key(
-                new Patch.Key(new PatchSet.Id(c.getId(), 0), "filename2"), "uuid2"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1, c2), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-  }
-
-  private static void assertNoDiffs(ChangeBundle a, ChangeBundle b) {
-    assertThat(a.differencesFrom(b)).isEmpty();
-    assertThat(b.differencesFrom(a)).isEmpty();
-  }
-
-  private static void assertDiffs(ChangeBundle a, ChangeBundle b, String first, String... rest) {
-    List<String> actual = a.differencesFrom(b);
-    if (actual.size() == 1 && rest.length == 0) {
-      // This error message is much easier to read.
-      assertThat(actual.get(0)).isEqualTo(first);
-    } else {
-      List<String> expected = new ArrayList<>(1 + rest.length);
-      expected.add(first);
-      Collections.addAll(expected, rest);
-      assertThat(actual).containsExactlyElementsIn(expected).inOrder();
-    }
-    assertThat(a).isNotEqualTo(b);
-  }
-
-  private static List<ChangeMessage> messages(ChangeMessage... ents) {
-    return Arrays.asList(ents);
-  }
-
-  private static List<PatchSet> patchSets(PatchSet... ents) {
-    return Arrays.asList(ents);
-  }
-
-  private static List<PatchSet> latest(Change c) {
-    PatchSet ps = new PatchSet(c.currentPatchSetId());
-    ps.setCreatedOn(c.getLastUpdatedOn());
-    return ImmutableList.of(ps);
-  }
-
-  private static List<PatchSetApproval> approvals(PatchSetApproval... ents) {
-    return Arrays.asList(ents);
-  }
-
-  private static ReviewerSet reviewers(Object... ents) {
-    checkArgument(ents.length % 3 == 0);
-    Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
-    for (int i = 0; i < ents.length; i += 3) {
-      t.put((ReviewerStateInternal) ents[i], (Account.Id) ents[i + 1], (Timestamp) ents[i + 2]);
-    }
-    return ReviewerSet.fromTable(t);
-  }
-
-  private static List<PatchLineComment> comments(PatchLineComment... ents) {
-    return Arrays.asList(ents);
-  }
-
-  private static Change clone(Change ent) {
-    return clone(CHANGE_CODEC, ent);
-  }
-
-  private static ChangeMessage clone(ChangeMessage ent) {
-    return clone(CHANGE_MESSAGE_CODEC, ent);
-  }
-
-  private static PatchSet clone(PatchSet ent) {
-    return clone(PATCH_SET_CODEC, ent);
-  }
-
-  private static PatchSetApproval clone(PatchSetApproval ent) {
-    return clone(PATCH_SET_APPROVAL_CODEC, ent);
-  }
-
-  private static PatchLineComment clone(PatchLineComment ent) {
-    return clone(PATCH_LINE_COMMENT_CODEC, ent);
-  }
-
-  private static <T> T clone(ProtobufCodec<T> codec, T obj) {
-    return codec.decode(codec.encodeToByteArray(obj));
-  }
-}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
index 5a7d812..1141080 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
@@ -16,8 +16,8 @@
 
 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.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Change;
@@ -31,8 +31,8 @@
   public void keySerializer() throws Exception {
     ChangeNotesCache.Key key =
         ChangeNotesCache.Key.create(
-            new Project.NameKey("project"),
-            new Change.Id(1234),
+            Project.nameKey("project"),
+            Change.id(1234),
             ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
     byte[] serialized = ChangeNotesCache.Key.Serializer.INSTANCE.serialize(key);
     assertThat(ChangeNotesKeyProto.parseFrom(serialized))
@@ -41,7 +41,7 @@
                 .setProject("project")
                 .setChangeId(1234)
                 .setId(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .build());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index de964d8..52000f5 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.util.time.TimeUtil;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -498,11 +498,7 @@
   private RevCommit writeCommit(String body) throws Exception {
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
-        body,
-        noteUtil
-            .getLegacyChangeNoteWrite()
-            .newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
-        false);
+        body, noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent), false);
   }
 
   private RevCommit writeCommit(String body, PersonIdent author) throws Exception {
@@ -513,9 +509,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil
-            .getLegacyChangeNoteWrite()
-            .newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
         initWorkInProgress);
   }
 
@@ -551,12 +545,7 @@
   }
 
   private void assertParseFails(RevCommit commit) throws Exception {
-    try {
-      newParser(commit).parseAll();
-      fail("Expected parse to fail:\n" + commit.getFullMessage());
-    } catch (ConfigInvalidException e) {
-      // Expected
-    }
+    assertThrows(ConfigInvalidException.class, () -> newParser(commit).parseAll());
   }
 
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 3d65eae..3e54863 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -16,11 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-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.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.server.notedb.ChangeNotesState.Serializer.toByteString;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -30,6 +27,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -37,37 +35,33 @@
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.converter.ChangeMessageProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
 import com.google.inject.TypeLiteral;
 import com.google.protobuf.ByteString;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ChangeNotesStateTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
-  private static final Change.Id ID = new Change.Id(123);
+  private static final Change.Id ID = Change.id(123);
   private static final ObjectId SHA =
       ObjectId.fromString("1234567812345678123456781234567812345678");
   private static final ByteString SHA_BYTES = ObjectIdConverter.create().toByteString(SHA);
@@ -80,10 +74,10 @@
   public void setUp() throws Exception {
     cols =
         ChangeColumns.builder()
-            .changeKey(new Change.Key(CHANGE_KEY))
+            .changeKey(Change.key(CHANGE_KEY))
             .createdOn(new Timestamp(123456L))
             .lastUpdatedOn(new Timestamp(234567L))
-            .owner(new Account.Id(1000))
+            .owner(Account.id(1000))
             .branch("refs/heads/master")
             .subject("Test change")
             .isPrivate(false)
@@ -103,7 +97,7 @@
         newBuilder()
             .columns(
                 cols.toBuilder()
-                    .changeKey(new Change.Key("Ieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"))
+                    .changeKey(Change.key("Ieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"))
                     .build())
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -139,7 +133,7 @@
   @Test
   public void serializeOwner() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().owner(new Account.Id(7777)).build()).build(),
+        newBuilder().columns(cols.toBuilder().owner(Account.id(7777)).build()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -173,7 +167,7 @@
   public void serializeCurrentPatchSetId() throws Exception {
     assertRoundTrip(
         newBuilder()
-            .columns(cols.toBuilder().currentPatchSetId(new PatchSet.Id(ID, 2)).build())
+            .columns(cols.toBuilder().currentPatchSetId(PatchSet.id(ID, 2)).build())
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -248,7 +242,7 @@
   @Test
   public void serializeAssignee() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().assignee(new Account.Id(2000)).build()).build(),
+        newBuilder().columns(cols.toBuilder().assignee(Account.id(2000)).build()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -303,7 +297,7 @@
   @Test
   public void serializeRevertOf() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().revertOf(new Change.Id(999)).build()).build(),
+        newBuilder().columns(cols.toBuilder().revertOf(Change.id(999)).build()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -314,9 +308,7 @@
   @Test
   public void serializePastAssignees() throws Exception {
     assertRoundTrip(
-        newBuilder()
-            .pastAssignees(ImmutableSet.of(new Account.Id(2002), new Account.Id(2001)))
-            .build(),
+        newBuilder().pastAssignees(ImmutableSet.of(Account.id(2002), Account.id(2001))).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -341,25 +333,29 @@
 
   @Test
   public void serializePatchSets() throws Exception {
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(ID, 1));
-    ps1.setUploader(new Account.Id(2000));
-    ps1.setRevision(new RevId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
-    ps1.setCreatedOn(cols.createdOn());
-    ByteString ps1Bytes = toByteString(ps1, PATCH_SET_CODEC);
+    PatchSet ps1 =
+        PatchSet.builder()
+            .id(PatchSet.id(ID, 1))
+            .commitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
+            .uploader(Account.id(2000))
+            .createdOn(cols.createdOn())
+            .build();
+    ByteString ps1Bytes = toByteString(ps1, PatchSetProtoConverter.INSTANCE);
     assertThat(ps1Bytes.size()).isEqualTo(66);
 
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(ID, 2));
-    ps2.setUploader(new Account.Id(3000));
-    ps2.setRevision(new RevId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
-    ps2.setCreatedOn(cols.lastUpdatedOn());
-    ByteString ps2Bytes = toByteString(ps2, PATCH_SET_CODEC);
+    PatchSet ps2 =
+        PatchSet.builder()
+            .id(PatchSet.id(ID, 2))
+            .commitId(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))
+            .uploader(Account.id(3000))
+            .createdOn(cols.lastUpdatedOn())
+            .build();
+    ByteString ps2Bytes = toByteString(ps2, PatchSetProtoConverter.INSTANCE);
     assertThat(ps2Bytes.size()).isEqualTo(66);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
     assertRoundTrip(
-        newBuilder()
-            .patchSets(ImmutableMap.of(ps2.getId(), ps2, ps1.getId(), ps1).entrySet())
-            .build(),
+        newBuilder().patchSets(ImmutableMap.of(ps2.id(), ps2, ps1.id(), ps1).entrySet()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -372,28 +368,31 @@
   @Test
   public void serializeApprovals() throws Exception {
     PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(ID, 1), new Account.Id(2001), new LabelId("Code-Review")),
-            (short) 1,
-            new Timestamp(1212L));
-    ByteString a1Bytes = toByteString(a1, APPROVAL_CODEC);
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create("Code-Review")))
+            .value(1)
+            .granted(new Timestamp(1212L))
+            .build();
+    ByteString a1Bytes = toByteString(a1, PatchSetApprovalProtoConverter.INSTANCE);
     assertThat(a1Bytes.size()).isEqualTo(43);
 
     PatchSetApproval a2 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(ID, 1), new Account.Id(2002), new LabelId("Verified")),
-            (short) -1,
-            new Timestamp(3434L));
-    ByteString a2Bytes = toByteString(a2, APPROVAL_CODEC);
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create("Verified")))
+            .value(-1)
+            .granted(new Timestamp(3434L))
+            .build();
+    ByteString a2Bytes = toByteString(a2, PatchSetApprovalProtoConverter.INSTANCE);
     assertThat(a2Bytes.size()).isEqualTo(49);
     assertThat(a2Bytes).isNotEqualTo(a1Bytes);
 
     assertRoundTrip(
         newBuilder()
-            .approvals(
-                ImmutableListMultimap.of(a2.getPatchSetId(), a2, a1.getPatchSetId(), a1).entries())
+            .approvals(ImmutableListMultimap.of(a2.patchSetId(), a2, a1.patchSetId(), a1).entries())
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -411,11 +410,8 @@
             .reviewers(
                 ReviewerSet.fromTable(
                     ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, new Account.Id(2001), new Timestamp(1212L))
-                        .put(
-                            ReviewerStateInternal.REVIEWER,
-                            new Account.Id(2002),
-                            new Timestamp(3434L))
+                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
+                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -508,11 +504,8 @@
             .pendingReviewers(
                 ReviewerSet.fromTable(
                     ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, new Account.Id(2001), new Timestamp(1212L))
-                        .put(
-                            ReviewerStateInternal.REVIEWER,
-                            new Account.Id(2002),
-                            new Timestamp(3434L))
+                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
+                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -569,9 +562,7 @@
   @Test
   public void serializeAllPastReviewers() throws Exception {
     assertRoundTrip(
-        newBuilder()
-            .allPastReviewers(ImmutableList.of(new Account.Id(2002), new Account.Id(2001)))
-            .build(),
+        newBuilder().allPastReviewers(ImmutableList.of(Account.id(2002), Account.id(2001))).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -589,13 +580,13 @@
                 ImmutableList.of(
                     ReviewerStatusUpdate.create(
                         new Timestamp(1212L),
-                        new Account.Id(1000),
-                        new Account.Id(2002),
+                        Account.id(1000),
+                        Account.id(2002),
                         ReviewerStateInternal.CC),
                     ReviewerStatusUpdate.create(
                         new Timestamp(3434L),
-                        new Account.Id(1000),
-                        new Account.Id(2001),
+                        Account.id(1000),
+                        Account.id(2001),
                         ReviewerStateInternal.REVIEWER)))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -640,20 +631,20 @@
   public void serializeChangeMessages() throws Exception {
     ChangeMessage m1 =
         new ChangeMessage(
-            new ChangeMessage.Key(ID, "uuid1"),
-            new Account.Id(1000),
+            ChangeMessage.key(ID, "uuid1"),
+            Account.id(1000),
             new Timestamp(1212L),
-            new PatchSet.Id(ID, 1));
-    ByteString m1Bytes = toByteString(m1, MESSAGE_CODEC);
+            PatchSet.id(ID, 1));
+    ByteString m1Bytes = toByteString(m1, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
         new ChangeMessage(
-            new ChangeMessage.Key(ID, "uuid2"),
-            new Account.Id(2000),
+            ChangeMessage.key(ID, "uuid2"),
+            Account.id(2000),
             new Timestamp(3434L),
-            new PatchSet.Id(ID, 2));
-    ByteString m2Bytes = toByteString(m2, MESSAGE_CODEC);
+            PatchSet.id(ID, 2));
+    ByteString m2Bytes = toByteString(m2, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m2Bytes.size()).isEqualTo(35);
     assertThat(m2Bytes).isNotEqualTo(m1Bytes);
 
@@ -673,31 +664,30 @@
     Comment c1 =
         new Comment(
             new Comment.Key("uuid1", "file1", 1),
-            new Account.Id(1001),
+            Account.id(1001),
             new Timestamp(1212L),
             (short) 1,
             "message 1",
             "serverId",
             false);
-    c1.setRevId(new RevId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
+    c1.setCommitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     String c1Json = Serializer.GSON.toJson(c1);
 
     Comment c2 =
         new Comment(
             new Comment.Key("uuid2", "file2", 2),
-            new Account.Id(1002),
+            Account.id(1002),
             new Timestamp(3434L),
             (short) 2,
             "message 2",
             "serverId",
             true);
-    c2.setRevId(new RevId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
+    c2.setCommitId(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
     String c2Json = Serializer.GSON.toJson(c2);
 
     assertRoundTrip(
         newBuilder()
-            .publishedComments(
-                ImmutableListMultimap.of(new RevId(c2.revId), c2, new RevId(c1.revId), c1))
+            .publishedComments(ImmutableListMultimap.of(c2.getCommitId(), c2, c1.getCommitId(), c1))
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -709,15 +699,14 @@
   }
 
   @Test
-  public void serializeReadOnlyUntil() throws Exception {
+  public void serializeUpdateCount() throws Exception {
     assertRoundTrip(
-        newBuilder().readOnlyUntil(new Timestamp(1212L)).build(),
+        newBuilder().updateCount(234).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .setReadOnlyUntil(1212L)
-            .setHasReadOnlyUntil(true)
+            .setUpdateCount(234)
             .build());
   }
 
@@ -750,8 +739,8 @@
                 .put("changeMessages", new TypeLiteral<ImmutableList<ChangeMessage>>() {}.getType())
                 .put(
                     "publishedComments",
-                    new TypeLiteral<ImmutableListMultimap<RevId, Comment>>() {}.getType())
-                .put("readOnlyUntil", Timestamp.class)
+                    new TypeLiteral<ImmutableListMultimap<ObjectId, Comment>>() {}.getType())
+                .put("updateCount", int.class)
                 .build());
   }
 
@@ -783,36 +772,37 @@
   @Test
   public void patchSetFields() throws Exception {
     assertThatSerializedClass(PatchSet.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("id", PatchSet.Id.class)
-                .put("revision", RevId.class)
+                .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
                 .put("createdOn", Timestamp.class)
-                .put("groups", String.class)
-                .put("pushCertificate", String.class)
-                .put("description", String.class)
+                .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
+                .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
+                .put("description", new TypeLiteral<Optional<String>>() {}.getType())
                 .build());
   }
 
   @Test
   public void patchSetApprovalFields() throws Exception {
     assertThatSerializedClass(PatchSetApproval.Key.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("patchSetId", PatchSet.Id.class)
                 .put("accountId", Account.Id.class)
-                .put("categoryId", LabelId.class)
+                .put("labelId", LabelId.class)
                 .build());
     assertThatSerializedClass(PatchSetApproval.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
                 .put("value", short.class)
                 .put("granted", Timestamp.class)
-                .put("tag", String.class)
+                .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
+                .put("toBuilder", PatchSetApproval.Builder.class)
                 .build());
   }
 
@@ -880,7 +870,7 @@
   @Test
   public void changeMessageFields() throws Exception {
     assertThatSerializedClass(ChangeMessage.Key.class)
-        .hasFields(ImmutableMap.of("changeId", Change.Id.class, "uuid", String.class));
+        .hasAutoValueMethods(ImmutableMap.of("changeId", Change.Id.class, "uuid", String.class));
     assertThatSerializedClass(ChangeMessage.class)
         .hasFields(
             ImmutableMap.<String, Type>builder()
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 74ba0c2..de1e4e7 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -14,16 +14,19 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
@@ -33,10 +36,11 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -44,31 +48,23 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
-import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
-import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
-import java.util.ArrayList;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -79,7 +75,6 @@
 
   @Inject private ChangeNoteJson changeNoteJson;
   @Inject private LegacyChangeNoteRead legacyChangeNoteRead;
-  @Inject private ChangeNoteUtil noteUtil;
 
   @Inject private @GerritServerId String serverId;
 
@@ -107,7 +102,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+    assertThat(notes.getCurrentPatchSet().description()).hasValue(description);
 
     description = "new, now more descriptive!";
     update = newUpdate(c, changeOwner);
@@ -115,7 +110,7 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+    assertThat(notes.getCurrentPatchSet().description()).hasValue(description);
   }
 
   @Test
@@ -137,14 +132,14 @@
             TimeUtil.nowTs(),
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.setTag(tag);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(tag);
   }
@@ -168,7 +163,7 @@
 
     ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
     assertThat(approvals).hasSize(1);
-    assertThat(approvals.entries().asList().get(0).getValue().getTag()).isEqualTo(tag2);
+    assertThat(approvals.entries().asList().get(0).getValue().tag()).hasValue(tag2);
   }
 
   @Test
@@ -199,7 +194,7 @@
             TimeUtil.nowTs(),
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.setChangeMessage("coverage verification");
     update.setTag(coverageTag);
@@ -215,10 +210,10 @@
     ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
     assertThat(approvals).hasSize(1);
     PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
-    assertThat(approval.getTag()).isEqualTo(integrationTag);
-    assertThat(approval.getValue()).isEqualTo(-1);
+    assertThat(approval.tag()).hasValue(integrationTag);
+    assertThat(approval.value()).isEqualTo(-1);
 
-    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(coverageTag);
 
@@ -242,17 +237,17 @@
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
-    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(0).accountId().get()).isEqualTo(1);
+    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).value()).isEqualTo((short) -1);
+    assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
-    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(1).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(1).getLabel()).isEqualTo("Verified");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
-    assertThat(psas.get(1).getGranted()).isEqualTo(psas.get(0).getGranted());
+    assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(1).accountId().get()).isEqualTo(1);
+    assertThat(psas.get(1).label()).isEqualTo("Verified");
+    assertThat(psas.get(1).value()).isEqualTo((short) 1);
+    assertThat(psas.get(1).granted()).isEqualTo(psas.get(0).granted());
   }
 
   @Test
@@ -274,18 +269,18 @@
     assertThat(psas).hasSize(2);
 
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
-    assertThat(psa1.getPatchSetId()).isEqualTo(ps1);
-    assertThat(psa1.getAccountId().get()).isEqualTo(1);
-    assertThat(psa1.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa1.getValue()).isEqualTo((short) -1);
-    assertThat(psa1.getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psa1.patchSetId()).isEqualTo(ps1);
+    assertThat(psa1.accountId().get()).isEqualTo(1);
+    assertThat(psa1.label()).isEqualTo("Code-Review");
+    assertThat(psa1.value()).isEqualTo((short) -1);
+    assertThat(psa1.granted()).isEqualTo(truncate(after(c, 2000)));
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
-    assertThat(psa2.getPatchSetId()).isEqualTo(ps2);
-    assertThat(psa2.getAccountId().get()).isEqualTo(1);
-    assertThat(psa2.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa2.getValue()).isEqualTo((short) +1);
-    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 4000)));
+    assertThat(psa2.patchSetId()).isEqualTo(ps2);
+    assertThat(psa2.accountId().get()).isEqualTo(1);
+    assertThat(psa2.label()).isEqualTo("Code-Review");
+    assertThat(psa2.value()).isEqualTo((short) +1);
+    assertThat(psa2.granted()).isEqualTo(truncate(after(c, 4000)));
   }
 
   @Test
@@ -298,8 +293,8 @@
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo((short) -1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.value()).isEqualTo((short) -1);
 
     update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
@@ -307,8 +302,8 @@
 
     notes = newNotes(c);
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.value()).isEqualTo((short) 1);
   }
 
   @Test
@@ -327,17 +322,17 @@
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
-    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(0).accountId().get()).isEqualTo(1);
+    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).value()).isEqualTo((short) -1);
+    assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
-    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(1).getAccountId().get()).isEqualTo(2);
-    assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
-    assertThat(psas.get(1).getGranted()).isEqualTo(truncate(after(c, 3000)));
+    assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(1).accountId().get()).isEqualTo(2);
+    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).value()).isEqualTo((short) 1);
+    assertThat(psas.get(1).granted()).isEqualTo(truncate(after(c, 3000)));
   }
 
   @Test
@@ -350,9 +345,9 @@
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getAccountId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
+    assertThat(psa.accountId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Not-For-Long");
+    assertThat(psa.value()).isEqualTo((short) 1);
 
     update = newUpdate(c, changeOwner);
     update.removeApproval("Not-For-Long");
@@ -362,8 +357,12 @@
     assertThat(notes.getApprovals())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                psa.getPatchSetId(),
-                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
+                psa.patchSetId(),
+                PatchSetApproval.builder()
+                    .key(psa.key())
+                    .value(0)
+                    .granted(update.getWhen())
+                    .build()));
   }
 
   @Test
@@ -376,9 +375,9 @@
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
-    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
+    assertThat(psa.accountId()).isEqualTo(otherUserId);
+    assertThat(psa.label()).isEqualTo("Not-For-Long");
+    assertThat(psa.value()).isEqualTo((short) 1);
 
     update = newUpdate(c, changeOwner);
     update.removeApprovalFor(otherUserId, "Not-For-Long");
@@ -388,8 +387,12 @@
     assertThat(notes.getApprovals())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                psa.getPatchSetId(),
-                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
+                psa.patchSetId(),
+                PatchSetApproval.builder()
+                    .key(psa.key())
+                    .value(0)
+                    .granted(update.getWhen())
+                    .build()));
 
     // Add back approval on same label.
     update = newUpdate(c, otherUser);
@@ -398,9 +401,9 @@
 
     notes = newNotes(c);
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
-    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
-    assertThat(psa.getValue()).isEqualTo((short) 2);
+    assertThat(psa.accountId()).isEqualTo(otherUserId);
+    assertThat(psa.label()).isEqualTo("Not-For-Long");
+    assertThat(psa.value()).isEqualTo((short) 2);
   }
 
   @Test
@@ -412,25 +415,25 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals =
-        ReviewDbUtil.intKeyOrdering()
-            .onResultOf(PatchSetApproval::getAccountId)
-            .sortedCopy(notes.getApprovals().get(c.currentPatchSetId()));
+    ImmutableList<PatchSetApproval> approvals =
+        notes.getApprovals().get(c.currentPatchSetId()).stream()
+            .sorted(comparing(a -> a.accountId().get()))
+            .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
 
-    assertThat(approvals.get(0).getAccountId()).isEqualTo(changeOwner.getAccountId());
-    assertThat(approvals.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
+    assertThat(approvals.get(0).accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(approvals.get(0).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(0).value()).isEqualTo((short) 1);
 
-    assertThat(approvals.get(1).getAccountId()).isEqualTo(otherUser.getAccountId());
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo((short) -1);
+    assertThat(approvals.get(1).accountId()).isEqualTo(otherUser.getAccountId());
+    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).value()).isEqualTo((short) -1);
   }
 
   @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);
@@ -454,18 +457,18 @@
     ChangeNotes notes = newNotes(c);
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(2);
-    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
-    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
-    assertThat(approvals.get(0).isPostSubmit()).isFalse();
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo((short) 2);
-    assertThat(approvals.get(1).isPostSubmit()).isTrue();
+    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).value()).isEqualTo((short) 1);
+    assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).value()).isEqualTo((short) 2);
+    assertThat(approvals.get(1).postSubmit()).isTrue();
   }
 
   @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);
@@ -494,26 +497,26 @@
 
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(3);
-    assertThat(approvals.get(0).getAccountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
-    assertThat(approvals.get(0).getValue()).isEqualTo(1);
-    assertThat(approvals.get(0).isPostSubmit()).isFalse();
-    assertThat(approvals.get(1).getAccountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo(2);
-    assertThat(approvals.get(1).isPostSubmit()).isFalse(); // During submit.
-    assertThat(approvals.get(2).getAccountId()).isEqualTo(otherId);
-    assertThat(approvals.get(2).getLabel()).isEqualTo("Other-Label");
-    assertThat(approvals.get(2).getValue()).isEqualTo(2);
-    assertThat(approvals.get(2).isPostSubmit()).isTrue();
+    assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).value()).isEqualTo(1);
+    assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertThat(approvals.get(1).accountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).value()).isEqualTo(2);
+    assertThat(approvals.get(1).postSubmit()).isFalse(); // During submit.
+    assertThat(approvals.get(2).accountId()).isEqualTo(otherId);
+    assertThat(approvals.get(2).label()).isEqualTo("Other-Label");
+    assertThat(approvals.get(2).value()).isEqualTo(2);
+    assertThat(approvals.get(2).postSubmit()).isTrue();
   }
 
   @Test
   public void multipleReviewers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), REVIEWER);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -522,8 +525,8 @@
         .isEqualTo(
             ReviewerSet.fromTable(
                 ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                    .put(REVIEWER, new Account.Id(1), ts)
-                    .put(REVIEWER, new Account.Id(2), ts)
+                    .put(REVIEWER, Account.id(1), ts)
+                    .put(REVIEWER, Account.id(2), ts)
                     .build()));
   }
 
@@ -531,8 +534,8 @@
   public void reviewerTypes() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.putReviewer(otherUser.getAccount().getId(), CC);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -541,8 +544,8 @@
         .isEqualTo(
             ReviewerSet.fromTable(
                 ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                    .put(REVIEWER, new Account.Id(1), ts)
-                    .put(CC, new Account.Id(2), ts)
+                    .put(REVIEWER, Account.id(1), ts)
+                    .put(CC, Account.id(2), ts)
                     .build()));
   }
 
@@ -550,29 +553,29 @@
   public void oneReviewerMultipleTypes() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), REVIEWER);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     Timestamp ts = new Timestamp(update.getWhen().getTime());
     assertThat(notes.getReviewers())
-        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, new Account.Id(2), ts)));
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, Account.id(2), ts)));
 
     update = newUpdate(c, otherUser);
-    update.putReviewer(otherUser.getAccount().getId(), CC);
+    update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
 
     notes = newNotes(c);
     ts = new Timestamp(update.getWhen().getTime());
     assertThat(notes.getReviewers())
-        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, new Account.Id(2), ts)));
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, Account.id(2), ts)));
   }
 
   @Test
   public void removeReviewer() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), REVIEWER);
     update.commit();
 
     update = newUpdate(c, changeOwner);
@@ -586,23 +589,23 @@
     ChangeNotes notes = newNotes(c);
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
-    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
+    assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
+    assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
 
     update = newUpdate(c, changeOwner);
-    update.removeReviewer(otherUser.getAccount().getId());
+    update.removeReviewer(otherUser.getAccount().id());
     update.commit();
 
     notes = newNotes(c);
     psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(1);
-    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
   }
 
   @Test
   public void submitRecords() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
@@ -644,7 +647,7 @@
   @Test
   public void latestSubmitRecordsOnly() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
     update.merge(
@@ -695,7 +698,7 @@
     try (RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(update.getResult());
       rw.parseBody(commit);
-      String strIdent = otherUser.getName() + " <" + otherUserId + "@" + serverId + ">";
+      String strIdent = "Gerrit User " + otherUserId + " <" + otherUserId + "@" + serverId + ">";
       assertThat(commit.getFullMessage()).contains("Assignee: " + strIdent);
     }
   }
@@ -725,8 +728,6 @@
     update.setAssignee(otherUserId);
     update.commit();
 
-    ChangeNotes notes = newNotes(c);
-
     update = newUpdate(c, changeOwner);
     update.setAssignee(changeOwner.getAccountId());
     update.commit();
@@ -739,7 +740,7 @@
     update.removeAssignee();
     update.commit();
 
-    notes = newNotes(c);
+    ChangeNotes notes = newNotes(c);
     assertThat(notes.getPastAssignees()).hasSize(2);
   }
 
@@ -827,14 +828,17 @@
 
     // Trying to set another Change-Id fails
     String otherChangeId = "I577fb248e474018276351785930358ec0450e9f7";
-    update = newUpdate(c, changeOwner);
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage(
-        "The Change-Id was already set to "
-            + c.getKey()
-            + ", so we cannot set this Change-Id: "
-            + otherChangeId);
-    update.setChangeId(otherChangeId);
+    ChangeUpdate failingUpdate = newUpdate(c, changeOwner);
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class, () -> failingUpdate.setChangeId(otherChangeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The Change-Id was already set to "
+                + c.getKey()
+                + ", so we cannot set this Change-Id: "
+                + otherChangeId);
   }
 
   @Test
@@ -842,7 +846,7 @@
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
-    Branch.NameKey expectedBranch = new Branch.NameKey(project, "refs/heads/master");
+    BranchNameKey expectedBranch = BranchNameKey.create(project, "refs/heads/master");
     assertThat(notes.getChange().getDest()).isEqualTo(expectedBranch);
 
     // An update doesn't affect the branch
@@ -857,7 +861,7 @@
     update.setBranch(otherBranch);
     update.commit();
     assertThat(newNotes(c).getChange().getDest())
-        .isEqualTo(new Branch.NameKey(project, otherBranch));
+        .isEqualTo(BranchNameKey.create(project, otherBranch));
   }
 
   @Test
@@ -945,7 +949,7 @@
     // Finish off by merging the change.
     update = newUpdate(c, changeOwner);
     update.merge(
-        RequestId.forChange(c),
+        submissionId(c),
         ImmutableList.of(
             submitRecord(
                 "NOT_READY",
@@ -962,9 +966,9 @@
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
     String trimmedSubj = c.getSubject();
     c.setCurrentPatchSet(c.currentPatchSetId(), "  " + trimmedSubj, c.getOriginalSubject());
-    ChangeUpdate update = newUpdate(c, changeOwner);
+    ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -974,9 +978,9 @@
 
     c = TestChanges.newChange(project, changeOwner.getAccountId());
     c.setCurrentPatchSet(c.currentPatchSetId(), tabSubj, c.getOriginalSubject());
-    update = newUpdate(c, changeOwner);
+    update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     notes = newNotes(c);
@@ -985,7 +989,7 @@
 
   @Test
   public void commitChangeNotesUnique() throws Exception {
-    // PatchSetId -> RevId must be a one to one mapping
+    // PatchSetId -> ObjectId must be a one to one mapping
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
@@ -998,19 +1002,15 @@
     update.setCommit(rw, commit);
     update.commit();
 
-    try {
-      notes = newNotes(c);
-      fail("Expected IOException");
-    } catch (OrmException e) {
-      assertCause(
-          e,
-          ConfigInvalidException.class,
-          "Multiple revisions parsed for patch set 1:"
-              + " RevId{"
-              + commit.name()
-              + "} and "
-              + ps.getRevision().get());
-    }
+    StorageException e = assertThrows(StorageException.class, () -> newNotes(c));
+    assertCause(
+        e,
+        ConfigInvalidException.class,
+        "Multiple revisions parsed for patch set 1:"
+            + " "
+            + commit.name()
+            + " and "
+            + ps.commitId().name());
   }
 
   @Test
@@ -1020,32 +1020,32 @@
     // ps1 created by newChange()
     ChangeNotes notes = newNotes(c);
     PatchSet ps1 = notes.getCurrentPatchSet();
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.getId());
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.id());
     assertThat(notes.getChange().getSubject()).isEqualTo("Change subject");
     assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
-    assertThat(ps1.getId()).isEqualTo(new PatchSet.Id(c.getId(), 1));
-    assertThat(ps1.getUploader()).isEqualTo(changeOwner.getAccountId());
+    assertThat(ps1.id()).isEqualTo(PatchSet.id(c.getId(), 1));
+    assertThat(ps1.uploader()).isEqualTo(changeOwner.getAccountId());
 
     // ps2 by other user
     RevCommit commit = incrementPatchSet(c, otherUser);
     notes = newNotes(c);
     PatchSet ps2 = notes.getCurrentPatchSet();
-    assertThat(ps2.getId()).isEqualTo(new PatchSet.Id(c.getId(), 2));
+    assertThat(ps2.id()).isEqualTo(PatchSet.id(c.getId(), 2));
     assertThat(notes.getChange().getSubject()).isEqualTo("PS2");
     assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
-    assertThat(ps2.getRevision().get()).isNotEqualTo(ps1.getRevision());
-    assertThat(ps2.getRevision().get()).isEqualTo(commit.name());
-    assertThat(ps2.getUploader()).isEqualTo(otherUser.getAccountId());
-    assertThat(ps2.getCreatedOn()).isEqualTo(notes.getChange().getLastUpdatedOn());
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.id());
+    assertThat(ps2.commitId()).isNotEqualTo(ps1.commitId());
+    assertThat(ps2.commitId()).isEqualTo(commit);
+    assertThat(ps2.uploader()).isEqualTo(otherUser.getAccountId());
+    assertThat(ps2.createdOn()).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     // comment on ps1, current patch set is still ps2
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(ps1.getId());
+    update.setPatchSetId(ps1.id());
     update.setChangeMessage("Comment on old patch set.");
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.id());
   }
 
   @Test
@@ -1073,7 +1073,7 @@
             TimeUtil.nowTs(),
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.commit();
 
@@ -1106,14 +1106,14 @@
     PatchSet.Id psId1 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).isEmpty();
+    assertThat(notes.getPatchSets().get(psId1).groups()).isEmpty();
 
     // ps1
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setGroups(ImmutableList.of("a", "b"));
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
+    assertThat(notes.getPatchSets().get(psId1).groups()).containsExactly("a", "b").inOrder();
 
     incrementCurrentPatchSetFieldOnly(c);
     PatchSet.Id psId2 = c.currentPatchSetId();
@@ -1122,8 +1122,8 @@
     update.setGroups(ImmutableList.of("d"));
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId2).getGroups()).containsExactly("d");
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
+    assertThat(notes.getPatchSets().get(psId2).groups()).containsExactly("d");
+    assertThat(notes.getPatchSets().get(psId1).groups()).containsExactly("a", "b").inOrder();
   }
 
   @Test
@@ -1149,13 +1149,11 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    String note = readNote(notes, commit);
-    if (!testJson()) {
-      assertThat(note).isEqualTo(pushCert);
-    }
+    readNote(notes, commit);
+
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
-    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
-    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
+    assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
     assertThat(notes.getComments()).isEmpty();
 
     // comment on ps2
@@ -1175,37 +1173,16 @@
             ts,
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.commit();
 
     notes = newNotes(c);
 
     patchSets = notes.getPatchSets();
-    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
-    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
+    assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
     assertThat(notes.getComments()).isNotEmpty();
-
-    if (!testJson()) {
-      assertThat(readNote(notes, commit))
-          .isEqualTo(
-              pushCert
-                  + "Revision: "
-                  + commit.name()
-                  + "\n"
-                  + "Patch-set: 2\n"
-                  + "File: a.txt\n"
-                  + "\n"
-                  + "1:2-3:4\n"
-                  + NoteDbUtil.formatTime(serverIdent, ts)
-                  + "\n"
-                  + "Author: Change Owner <1@gerrit>\n"
-                  + "Unresolved: false\n"
-                  + "UUID: uuid1\n"
-                  + "Bytes: 7\n"
-                  + "Comment\n"
-                  + "\n");
-    }
   }
 
   @Test
@@ -1234,13 +1211,13 @@
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
-    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(psas.get(0).getLabel()).isEqualTo("Verified");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
+    assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
+    assertThat(psas.get(0).label()).isEqualTo("Verified");
+    assertThat(psas.get(0).value()).isEqualTo((short) 1);
 
-    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
-    assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 2);
+    assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
+    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).value()).isEqualTo((short) 2);
   }
 
   @Test
@@ -1266,7 +1243,7 @@
               time1,
               message1,
               (short) 0,
-              "abcd1234abcd1234abcd1234abcd1234abcd1234",
+              ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
               false);
       update1.setPatchSetId(psId);
       update1.putComment(Status.PUBLISHED, comment1);
@@ -1348,25 +1325,25 @@
 
     PatchSetApproval approval1 =
         newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
-    assertThat(approval1.getLabel()).isEqualTo("Verified");
+    assertThat(approval1.label()).isEqualTo("Verified");
 
     PatchSetApproval approval2 =
         newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
-    assertThat(approval2.getLabel()).isEqualTo("Code-Review");
+    assertThat(approval2.label()).isEqualTo("Code-Review");
   }
 
   @Test
   public void changeMessageOnePatchSet() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.setChangeMessage("Just a little code change.\n");
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     ChangeMessage cm = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm.getMessage()).isEqualTo("Just a little code change.\n");
-    assertThat(cm.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm.getPatchSetId()).isEqualTo(c.currentPatchSetId());
   }
 
@@ -1374,7 +1351,7 @@
   public void noChangeMessage() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1391,7 +1368,7 @@
     ChangeNotes notes = newNotes(c);
     ChangeMessage cm1 = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n\n");
-    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
   @Test
@@ -1410,14 +1387,14 @@
                 + "Testing paragraph 2\n"
                 + "\n"
                 + "Testing paragraph 3");
-    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
   @Test
   public void changeMessagesMultiplePatchSets() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.setChangeMessage("This is the change message for the first PS.");
     update.commit();
     PatchSet.Id ps1 = c.currentPatchSetId();
@@ -1435,12 +1412,12 @@
     ChangeMessage cm1 = notes.getChangeMessages().get(0);
     assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
     assertThat(cm1.getMessage()).isEqualTo("This is the change message for the first PS.");
-    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
 
     ChangeMessage cm2 = notes.getChangeMessages().get(1);
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
     assertThat(cm2.getMessage()).isEqualTo("This is the change message for the second PS.");
-    assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
   }
 
@@ -1448,14 +1425,14 @@
   public void changeMessageMultipleInOnePatchSet() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.setChangeMessage("First change message.\n");
     update.commit();
 
     PatchSet.Id ps1 = c.currentPatchSetId();
 
     update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.setChangeMessage("Second change message.\n");
     update.commit();
 
@@ -1464,10 +1441,10 @@
     List<ChangeMessage> cm = notes.getChangeMessages();
     assertThat(cm).hasSize(2);
     assertThat(cm.get(0).getMessage()).isEqualTo("First change message.\n");
-    assertThat(cm.get(0).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.get(0).getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm.get(0).getPatchSetId()).isEqualTo(ps1);
     assertThat(cm.get(1).getMessage()).isEqualTo("Second change message.\n");
-    assertThat(cm.get(1).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.get(1).getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm.get(1).getPatchSetId()).isEqualTo(ps1);
   }
 
@@ -1476,7 +1453,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     Comment comment =
         newComment(
@@ -1490,14 +1467,14 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1505,7 +1482,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 0, 2, 0);
 
     Comment comment =
@@ -1520,14 +1497,14 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1535,7 +1512,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(0, 0, 0, 0);
 
     Comment comment =
@@ -1550,14 +1527,14 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1565,7 +1542,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 2, 3, 4);
 
     Comment comment =
@@ -1580,319 +1557,18 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
-  public void patchLineCommentNotesFormatSide1() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String uuid3 = "uuid3";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    String message3 = "comment 3";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    Timestamp time3 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            time2,
-            message2,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range3 = new CommentRange(3, 0, 4, 1);
-    Comment comment3 =
-        newComment(
-            psId,
-            "file2",
-            uuid3,
-            range3,
-            range3.getEndLine(),
-            otherUser,
-            null,
-            time3,
-            message3,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment3);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n"
-                    + "File: file2\n"
-                    + "\n"
-                    + "3:0-4:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time3)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid3\n"
-                    + "Bytes: 9\n"
-                    + "comment 3\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesFormatSide0() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            time2,
-            message2,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesResolvedChangesValue() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            uuid1,
-            time2,
-            message2,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            true);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Parent: uuid1\n"
-                    + "Unresolved: true\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId() throws Exception {
+  public void patchLineCommentNotesFormatMultiplePatchSetsSameCommitId() throws Exception {
     Change c = newChange();
     PatchSet.Id psId1 = c.currentPatchSetId();
     incrementPatchSet(c);
@@ -1906,7 +1582,7 @@
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
     Timestamp time = TimeUtil.nowTs();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     Comment comment1 =
         newComment(
@@ -1920,7 +1596,7 @@
             time,
             message1,
             (short) 0,
-            revId.get(),
+            commitId,
             false);
     Comment comment2 =
         newComment(
@@ -1934,7 +1610,7 @@
             time,
             message2,
             (short) 0,
-            revId.get(),
+            commitId,
             false);
     Comment comment3 =
         newComment(
@@ -1948,7 +1624,7 @@
             time,
             message3,
             (short) 0,
-            revId.get(),
+            commitId,
             false);
 
     ChangeUpdate update = newUpdate(c, otherUser);
@@ -1959,60 +1635,12 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-      String timeStr = NoteDbUtil.formatTime(serverIdent, time);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n"
-                    + "Base-for-patch-set: 2\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid3\n"
-                    + "Bytes: 9\n"
-                    + "comment 3\n"
-                    + "\n");
-      }
-    }
     assertThat(notes.getComments())
         .isEqualTo(
             ImmutableListMultimap.of(
-                revId, comment1,
-                revId, comment2,
-                revId, comment3));
+                commitId, comment1,
+                commitId, comment2,
+                commitId, comment3));
   }
 
   @Test
@@ -2025,7 +1653,7 @@
     CommentRange range = new CommentRange(1, 1, 2, 1);
     Timestamp time = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     Comment comment =
         newComment(
@@ -2039,7 +1667,7 @@
             time,
             message,
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     comment.setRealAuthor(changeOwner.getAccountId());
     update.setPatchSetId(psId);
@@ -2048,42 +1676,16 @@
 
     ChangeNotes notes = newNotes(c);
 
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Real-author: Change Owner <1@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid\n"
-                    + "Bytes: 7\n"
-                    + "comment\n"
-                    + "\n");
-      }
-    }
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
   public void patchLineCommentNotesFormatWeirdUser() throws Exception {
-    Account account = new Account(new Account.Id(3), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(3), TimeUtil.nowTs());
     account.setFullName("Weird\n\u0002<User>\n");
     account.setPreferredEmail(" we\r\nird@ex>ample<.com");
-    accountCache.put(account);
-    IdentifiedUser user = userFactory.create(account.getId());
+    accountCache.put(account.build());
+    IdentifiedUser user = userFactory.create(Account.id(3));
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, user);
@@ -2104,7 +1706,7 @@
             time,
             "comment",
             (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
@@ -2112,34 +1714,8 @@
 
     ChangeNotes notes = newNotes(c);
 
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-      String timeStr = NoteDbUtil.formatTime(serverIdent, time);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Weird\u0002User <3@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid\n"
-                    + "Bytes: 7\n"
-                    + "comment\n"
-                    + "\n");
-      }
-    }
     assertThat(notes.getComments())
-        .isEqualTo(ImmutableListMultimap.of(new RevId(comment.revId), comment));
+        .isEqualTo(ImmutableListMultimap.of(comment.getCommitId(), comment));
   }
 
   @Test
@@ -2148,8 +1724,8 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     String messageForBase = "comment for base";
     String messageForPS = "comment for ps";
     CommentRange range = new CommentRange(1, 1, 2, 1);
@@ -2168,7 +1744,7 @@
             now,
             messageForBase,
             (short) 0,
-            rev1,
+            commitId1,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, commentForBase);
@@ -2187,7 +1763,7 @@
             now,
             messageForPS,
             (short) 1,
-            rev2,
+            commitId2,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, commentForPS);
@@ -2196,8 +1772,8 @@
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), commentForBase,
-                new RevId(rev2), commentForPS));
+                commitId1, commentForBase,
+                commitId2, commentForPS));
   }
 
   @Test
@@ -2205,7 +1781,7 @@
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id psId = c.currentPatchSetId();
     String filename = "filename";
@@ -2226,7 +1802,7 @@
             timeForComment1,
             "comment 1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment1);
@@ -2245,7 +1821,7 @@
             timeForComment2,
             "comment 2",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment2);
@@ -2254,8 +1830,8 @@
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
+                commitId, comment1,
+                commitId, comment2))
         .inOrder();
   }
 
@@ -2263,7 +1839,7 @@
   public void patchLineCommentMultipleOnePatchsetMultipleFiles() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id psId = c.currentPatchSetId();
     String filename1 = "filename1";
@@ -2284,7 +1860,7 @@
             now,
             "comment 1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment1);
@@ -2303,7 +1879,7 @@
             now,
             "comment 2",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment2);
@@ -2312,8 +1888,8 @@
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
+                commitId, comment1,
+                commitId, comment2))
         .inOrder();
   }
 
@@ -2321,8 +1897,8 @@
   public void patchLineCommentMultiplePatchsets() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2342,7 +1918,7 @@
             now,
             "comment on ps1",
             side,
-            rev1,
+            commitId1,
             false);
     update.setPatchSetId(ps1);
     update.putComment(Status.PUBLISHED, comment1);
@@ -2365,7 +1941,7 @@
             now,
             "comment on ps2",
             side,
-            rev2,
+            commitId2,
             false);
     update.setPatchSetId(ps2);
     update.putComment(Status.PUBLISHED, comment2);
@@ -2374,15 +1950,15 @@
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), comment1,
-                new RevId(rev2), comment2));
+                commitId1, comment1,
+                commitId2, comment2));
   }
 
   @Test
   public void patchLineCommentSingleDraftToPublished() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2402,7 +1978,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(ps1);
     update.putComment(Status.DRAFT, comment1);
@@ -2410,7 +1986,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
     assertThat(notes.getComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
@@ -2421,7 +1997,7 @@
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
     assertThat(notes.getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
   @Test
@@ -2429,7 +2005,7 @@
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range1 = new CommentRange(1, 1, 2, 2);
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
@@ -2452,7 +2028,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     Comment comment2 =
         newComment(
@@ -2466,7 +2042,7 @@
             now,
             "other on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.putComment(Status.DRAFT, comment1);
     update.putComment(Status.DRAFT, comment2);
@@ -2476,8 +2052,8 @@
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
+                commitId, comment1,
+                commitId, comment2))
         .inOrder();
     assertThat(notes.getComments()).isEmpty();
 
@@ -2489,9 +2065,9 @@
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment2));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment2));
     assertThat(notes.getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
   @Test
@@ -2499,8 +2075,8 @@
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range1 = new CommentRange(1, 1, 2, 2);
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
@@ -2522,7 +2098,7 @@
             now,
             "comment on base",
             (short) 0,
-            rev1,
+            commitId1,
             false);
     Comment psComment =
         newComment(
@@ -2536,7 +2112,7 @@
             now,
             "comment on ps",
             (short) 1,
-            rev2,
+            commitId2,
             false);
 
     update.putComment(Status.DRAFT, baseComment);
@@ -2547,8 +2123,8 @@
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), baseComment,
-                new RevId(rev2), psComment));
+                commitId1, baseComment,
+                commitId2, psComment));
     assertThat(notes.getComments()).isEmpty();
 
     // Publish both comments.
@@ -2564,16 +2140,15 @@
     assertThat(notes.getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), baseComment,
-                new RevId(rev2), psComment));
+                commitId1, baseComment,
+                commitId2, psComment));
   }
 
   @Test
   public void patchLineCommentsDeleteAllDrafts() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    ObjectId objId = ObjectId.fromString(rev);
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id psId = c.currentPatchSetId();
     String filename = "filename";
@@ -2593,7 +2168,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.DRAFT, comment);
@@ -2601,10 +2176,9 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
-    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(objId)).isTrue();
+    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(commitId)).isTrue();
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
     update.setPatchSetId(psId);
     update.deleteComment(comment);
     update.commit();
@@ -2618,10 +2192,8 @@
   public void patchLineCommentsDeleteAllDraftsForOneRevision() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    ObjectId objId1 = ObjectId.fromString(rev1);
-    ObjectId objId2 = ObjectId.fromString(rev2);
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2641,7 +2213,7 @@
             now,
             "comment on ps1",
             side,
-            rev1,
+            commitId1,
             false);
     update.setPatchSetId(ps1);
     update.putComment(Status.DRAFT, comment1);
@@ -2664,7 +2236,7 @@
             now,
             "comment on ps2",
             side,
-            rev2,
+            commitId2,
             false);
     update.setPatchSetId(ps2);
     update.putComment(Status.DRAFT, comment2);
@@ -2674,7 +2246,6 @@
     assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
     update.setPatchSetId(ps2);
     update.deleteComment(comment2);
     update.commit();
@@ -2682,15 +2253,15 @@
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
     NoteMap noteMap = notes.getDraftCommentNotes().getNoteMap();
-    assertThat(noteMap.contains(objId1)).isTrue();
-    assertThat(noteMap.contains(objId2)).isFalse();
+    assertThat(noteMap.contains(commitId1)).isTrue();
+    assertThat(noteMap.contains(commitId2)).isFalse();
   }
 
   @Test
   public void addingPublishedCommentDoesNotCreateNoOpCommitOnEmptyDraftRef() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2710,7 +2281,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
@@ -2723,7 +2294,7 @@
   @Test
   public void addingPublishedCommentDoesNotCreateNoOpCommitOnNonEmptyDraftRef() throws Exception {
     Change c = newChange();
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2743,7 +2314,7 @@
             now,
             "draft comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.putComment(Status.DRAFT, draft);
     update.commit();
@@ -2765,7 +2336,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.putComment(Status.PUBLISHED, pub);
     update.commit();
@@ -2778,7 +2349,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
@@ -2795,14 +2366,14 @@
             now,
             messageForBase,
             (short) 0,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -2810,7 +2381,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
@@ -2827,22 +2398,22 @@
             now,
             messageForBase,
             (short) 0,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
   public void putCommentsForMultipleRevisions() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2866,7 +2437,7 @@
             now,
             "comment on ps1",
             side,
-            rev1,
+            commitId1,
             false);
     Comment comment2 =
         newComment(
@@ -2880,7 +2451,7 @@
             now,
             "comment on ps2",
             side,
-            rev2,
+            commitId2,
             false);
     update.putComment(Status.DRAFT, comment1);
     update.putComment(Status.DRAFT, comment2);
@@ -2904,7 +2475,7 @@
   @Test
   public void publishSubsetOfCommentsOnRevision() throws Exception {
     Change c = newChange();
-    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     short side = (short) 1;
@@ -2924,7 +2495,7 @@
             now,
             "comment1",
             side,
-            rev1.get(),
+            commitId1,
             false);
     Comment comment2 =
         newComment(
@@ -2938,14 +2509,15 @@
             now,
             "comment2",
             side,
-            rev1.get(),
+            commitId1,
             false);
     update.putComment(Status.DRAFT, comment1);
     update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1, comment2);
+    assertThat(notes.getDraftComments(otherUserId).get(commitId1))
+        .containsExactly(comment1, comment2);
     assertThat(notes.getComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
@@ -2954,8 +2526,8 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
+    assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
+    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
   }
 
   @Test
@@ -2969,15 +2541,15 @@
     assertThat(msg.getMessage()).isEqualTo("A message.");
     assertThat(msg.getAuthor()).isNull();
 
-    update = newUpdate(c, internalUser);
-    exception.expect(IllegalStateException.class);
-    update.putApproval("Code-Review", (short) 1);
+    ChangeUpdate failingUpdate = newUpdate(c, internalUser);
+    assertThrows(
+        IllegalStateException.class, () -> failingUpdate.putApproval("Code-Review", (short) 1));
   }
 
   @Test
   public void filterOutAndFixUpZombieDraftComments() throws Exception {
     Change c = newChange();
-    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     short side = (short) 1;
@@ -2996,7 +2568,7 @@
             now,
             "comment on ps1",
             side,
-            rev1.get(),
+            commitId1,
             false);
     Comment comment2 =
         newComment(
@@ -3010,7 +2582,7 @@
             now,
             "another comment",
             side,
-            rev1.get(),
+            commitId1,
             false);
     update.putComment(Status.DRAFT, comment1);
     update.putComment(Status.DRAFT, comment2);
@@ -3037,13 +2609,13 @@
     }
 
     // Looking at drafts directly shows the zombie comment.
-    DraftCommentNotes draftNotes = draftNotesFactory.create(c, otherUserId);
-    assertThat(draftNotes.load().getComments().get(rev1)).containsExactly(comment1, comment2);
+    DraftCommentNotes draftNotes = draftNotesFactory.create(c.getId(), otherUserId);
+    assertThat(draftNotes.load().getComments().get(commitId1)).containsExactly(comment1, comment2);
 
     // Zombie comment is filtered out of drafts via ChangeNotes.
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
+    assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
+    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
@@ -3058,7 +2630,7 @@
   public void updateCommentsInSequentialUpdates() throws Exception {
     Change c = newChange();
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     ChangeUpdate update1 = newUpdate(c, otherUser);
     Comment comment1 =
@@ -3073,7 +2645,7 @@
             new Timestamp(update1.getWhen().getTime()),
             "comment 1",
             (short) 1,
-            rev,
+            commitId,
             false);
     update1.putComment(Status.PUBLISHED, comment1);
 
@@ -3090,7 +2662,7 @@
             new Timestamp(update2.getWhen().getTime()),
             "comment 2",
             (short) 1,
-            rev,
+            commitId,
             false);
     update2.putComment(Status.PUBLISHED, comment2);
 
@@ -3101,7 +2673,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<Comment> comments = notes.getComments().get(new RevId(rev));
+    List<Comment> comments = notes.getComments().get(commitId);
     assertThat(comments).hasSize(2);
     assertThat(comments.get(0).message).isEqualTo("comment 1");
     assertThat(comments.get(1).message).isEqualTo("comment 2");
@@ -3131,7 +2703,7 @@
     int numComments = notes.getComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), c.currentPatchSetId().get() + 1));
+    update.setPatchSetId(PatchSet.id(c.getId(), c.currentPatchSetId().get() + 1));
     update.setChangeMessage("Should be ignored");
     update.putApproval("Code-Review", (short) 2);
     CommentRange range = new CommentRange(1, 1, 2, 1);
@@ -3147,7 +2719,7 @@
             new Timestamp(update.getWhen().getTime()),
             "comment",
             (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
@@ -3168,7 +2740,7 @@
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setPatchSetId(PatchSet.id(c.getId(), 1));
     update.setCurrentPatchSet();
     update.commit();
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
@@ -3185,83 +2757,13 @@
 
     // Delete PS1, PS2 becomes current.
     update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setPatchSetId(PatchSet.id(c.getId(), 1));
     update.setPatchSetState(PatchSetState.DELETED);
     update.commit();
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
   }
 
   @Test
-  public void readOnlyUntilExpires() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    Timestamp until = new Timestamp(TimeUtil.nowMs() + 10000);
-    update.setReadOnlyUntil(until);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setTopic("failing-topic");
-    try {
-      update.commit();
-      assert_().fail("expected OrmException");
-    } catch (OrmException e) {
-      assertThat(e.getMessage()).contains("read-only until");
-    }
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isNotEqualTo("failing-topic");
-    assertThat(notes.getReadOnlyUntil()).isEqualTo(until);
-
-    TestTimeUtil.incrementClock(30, TimeUnit.SECONDS);
-    update = newUpdate(c, changeOwner);
-    update.setTopic("succeeding-topic");
-    update.commit();
-
-    // Write succeeded; lease still exists, even though it's expired.
-    notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isEqualTo("succeeding-topic");
-    assertThat(notes.getReadOnlyUntil()).isEqualTo(until);
-
-    // New lease takes precedence.
-    update = newUpdate(c, changeOwner);
-    until = new Timestamp(TimeUtil.nowMs() + 10000);
-    update.setReadOnlyUntil(until);
-    update.commit();
-    assertThat(newNotes(c).getReadOnlyUntil()).isEqualTo(until);
-  }
-
-  @Test
-  public void readOnlyUntilCleared() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    Timestamp until = new Timestamp(TimeUtil.nowMs() + TimeUnit.DAYS.toMillis(30));
-    update.setReadOnlyUntil(until);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setTopic("failing-topic");
-    try {
-      update.commit();
-      assert_().fail("expected OrmException");
-    } catch (OrmException e) {
-      assertThat(e.getMessage()).contains("read-only until");
-    }
-
-    // Sentinel timestamp of 0 can be written to clear lease.
-    update = newUpdate(c, changeOwner);
-    update.setReadOnlyUntil(new Timestamp(0));
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setTopic("succeeding-topic");
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isEqualTo("succeeding-topic");
-    assertThat(notes.getReadOnlyUntil()).isEqualTo(new Timestamp(0));
-  }
-
-  @Test
   public void privateDefault() throws Exception {
     Change c = newChange();
     ChangeNotes notes = newNotes(c);
@@ -3434,8 +2936,8 @@
   public void pendingReviewers() throws Exception {
     Address adr1 = new Address("Foo Bar1", "foo.bar1@gerritcodereview.com");
     Address adr2 = new Address("Foo Bar2", "foo.bar2@gerritcodereview.com");
-    Account.Id ownerId = changeOwner.getAccount().getId();
-    Account.Id otherUserId = otherUser.getAccount().getId();
+    Account.Id ownerId = changeOwner.getAccount().id();
+    Account.Id otherUserId = otherUser.getAccount().id();
 
     ChangeNotes notes = newNotes(newChange());
     assertThat(notes.getPendingReviewers().asTable()).isEmpty();
@@ -3499,7 +3001,7 @@
   public void setRevertOfPersistsValue() throws Exception {
     Change changeToRevert = newChange();
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    ChangeUpdate update = newUpdate(c, changeOwner);
+    ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
     update.setRevertOf(changeToRevert.getId().get());
     update.commit();
@@ -3510,9 +3012,9 @@
   public void setRevertOfToCurrentChangeFails() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("A change cannot revert itself");
-    update.setRevertOf(c.getId().get());
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> update.setRevertOf(c.getId().get()));
+    assertThat(thrown).hasMessageThat().contains("A change cannot revert itself");
   }
 
   @Test
@@ -3520,13 +3022,26 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setRevertOf(newChange().getId().get());
-    exception.expect(OrmException.class);
-    exception.expectMessage("Given ChangeUpdate is only allowed on initial commit");
-    update.commit();
+    StorageException thrown = assertThrows(StorageException.class, () -> update.commit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Given ChangeUpdate is only allowed on initial commit");
   }
 
-  private boolean testJson() {
-    return noteUtil.getChangeNoteJson().getWriteJson();
+  @Test
+  public void updateCount() throws Exception {
+    Change c = newChange();
+    assertThat(newNotes(c).getUpdateCount()).isEqualTo(1);
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) -1);
+    update.commit();
+    assertThat(newNotes(c).getUpdateCount()).isEqualTo(2);
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+    assertThat(newNotes(c).getUpdateCount()).isEqualTo(3);
   }
 
   private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
@@ -3550,11 +3065,11 @@
         break;
       }
     }
-    assertThat(cause)
-        .named(
+    assertWithMessage(
             expectedClass.getSimpleName()
                 + " in causal chain of:\n"
                 + Throwables.getStackTraceAsString(e))
+        .that(cause)
         .isNotNull();
     assertThat(cause.getMessage()).isEqualTo(expectedMsg);
   }
@@ -3575,4 +3090,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/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index e7d8956..02f187d 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -24,6 +24,7 @@
 import java.time.ZonedDateTime;
 import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -152,14 +153,14 @@
     Comment c =
         new Comment(
             new Comment.Key("uuid", "filename", 1),
-            new Account.Id(100),
+            Account.id(100),
             NON_DST_TS,
             (short) 0,
             "message",
             "serverId",
             false);
     c.lineNbr = 1;
-    c.revId = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    c.setCommitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
 
     String json = gson.toJson(c);
     assertThat(json).contains("\"writtenOn\": \"" + NON_DST_STR_TRUNC + "\",");
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index f826fec..eb8d4c2 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -19,12 +19,12 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestChanges;
 import java.util.Date;
@@ -41,11 +41,11 @@
   @Test
   public void approvalsCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
-    ChangeUpdate update = newUpdate(c, changeOwner);
+    ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.putApproval("Verified", (short) 1);
     update.putApproval("Code-Review", (short) -1);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.putReviewer(otherUser.getAccount().getId(), CC);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
 
@@ -62,14 +62,14 @@
             + "Commit: "
             + update.getCommit().name()
             + "\n"
-            + "Reviewer: Change Owner <1@gerrit>\n"
-            + "CC: Other Account <2@gerrit>\n"
+            + "Reviewer: Gerrit User 1 <1@gerrit>\n"
+            + "CC: Gerrit User 2 <2@gerrit>\n"
             + "Label: Code-Review=-1\n"
             + "Label: Verified=+1\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Change Owner");
+    assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
     assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
     assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
@@ -84,7 +84,7 @@
   @Test
   public void changeMessageCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
-    ChangeUpdate update = newUpdate(c, changeOwner);
+    ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.setChangeMessage("Just a little code change.\nHow about a new line");
     update.commit();
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
@@ -110,7 +110,7 @@
   @Test
   public void changeWithRevision() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
-    ChangeUpdate update = newUpdate(c, changeOwner);
+    ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.setChangeMessage("Foo");
     RevCommit commit = tr.commit().message("Subject").create();
     update.setCommit(rw, commit);
@@ -151,7 +151,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     update.merge(
         submissionId,
         ImmutableList.of(
@@ -177,15 +177,15 @@
             + submissionId.toStringForStorage()
             + "\n"
             + "Submitted-with: NOT_READY\n"
-            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: OK: Verified: Gerrit User 1 <1@gerrit>\n"
             + "Submitted-with: NEED: Code-Review\n"
             + "Submitted-with: NOT_READY\n"
-            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: OK: Verified: Gerrit User 1 <1@gerrit>\n"
             + "Submitted-with: NEED: Alternative-Code-Review\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Change Owner");
+    assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
     assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
     assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
@@ -199,10 +199,13 @@
 
   @Test
   public void anonymousUser() throws Exception {
-    Account anon = new Account(new Account.Id(3), TimeUtil.nowTs());
+    Account anon =
+        Account.builder(Account.id(3), TimeUtil.nowTs())
+            .setMetaId("1234567812345678123456781234567812345678")
+            .build();
     accountCache.put(anon);
     Change c = newChange();
-    ChangeUpdate update = newUpdate(c, userFactory.create(anon.getId()));
+    ChangeUpdate update = newUpdate(c, userFactory.create(anon.id()));
     update.setChangeMessage("Comment on the change.");
     update.commit();
 
@@ -210,7 +213,7 @@
     assertBodyEquals("Update patch set 1\n\nComment on the change.\n\nPatch-set: 1\n", commit);
 
     PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("GerritAccount #3");
+    assertThat(author.getName()).isEqualTo("Gerrit User 3");
     assertThat(author.getEmailAddress()).isEqualTo("3@gerrit");
   }
 
@@ -220,7 +223,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();
@@ -241,11 +244,11 @@
   public void noChangeMessage() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nReviewer: Change Owner <1@gerrit>\n",
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n",
         update.getResult());
   }
 
@@ -309,9 +312,9 @@
   public void leadingWhitespace() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
     c.setCurrentPatchSet(c.currentPatchSetId(), "  " + c.getSubject(), c.getOriginalSubject());
-    ChangeUpdate update = newUpdate(c, changeOwner);
+    ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     assertBodyEquals(
@@ -330,9 +333,9 @@
 
     c = TestChanges.newChange(project, changeOwner.getAccountId());
     c.setCurrentPatchSet(c.currentPatchSetId(), "\t\t" + c.getSubject(), c.getOriginalSubject());
-    update = newUpdate(c, changeOwner);
+    update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     assertBodyEquals(
@@ -360,7 +363,7 @@
 
     RevCommit commit = parseCommit(update.getResult());
     PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Other Account");
+    assertThat(author.getName()).isEqualTo("Gerrit User 2");
     assertThat(author.getEmailAddress()).isEqualTo("2@gerrit");
 
     assertBodyEquals(
@@ -369,7 +372,7 @@
             + "Message on behalf of other user\n"
             + "\n"
             + "Patch-set: 1\n"
-            + "Real-user: Change Owner <1@gerrit>\n",
+            + "Real-user: Gerrit User 1 <1@gerrit>\n",
         commit);
   }
 
@@ -424,4 +427,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/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
new file mode 100644
index 0000000..ac13037
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class DraftCommentNotesTest extends AbstractChangeNotesTest {
+
+  @Test
+  public void createAndPublishCommentInOneAction_runsDraftOperationAsynchronously()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.commit();
+
+    assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
+    assertableFanOutExecutor.assertInteractions(1);
+  }
+
+  @Test
+  public void createAndPublishComment_runsPublishDraftOperationAsynchronously() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(Status.DRAFT, comment(c.currentPatchSetId()));
+    update.commit();
+    assertThat(newNotes(c).getDraftComments(otherUserId)).hasSize(1);
+    assertableFanOutExecutor.assertInteractions(0);
+
+    update = newUpdate(c, otherUser);
+    update.putComment(Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.commit();
+
+    assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
+    assertableFanOutExecutor.assertInteractions(1);
+  }
+
+  @Test
+  public void createAndDeleteDraftComment_runsDraftOperationSynchronously() throws Exception {
+    Change c = newChange();
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(Status.DRAFT, comment(c.currentPatchSetId()));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
+    assertableFanOutExecutor.assertInteractions(0);
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.deleteComment(comment(c.currentPatchSetId()));
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
+    assertableFanOutExecutor.assertInteractions(0);
+  }
+
+  private Comment comment(PatchSet.Id psId) {
+    return newComment(
+        psId,
+        "filename",
+        "uuid",
+        null,
+        0,
+        otherUser,
+        null,
+        TimeUtil.nowTs(),
+        "comment",
+        (short) 0,
+        ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
+        false);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
new file mode 100644
index 0000000..201f94f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
@@ -0,0 +1,194 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import java.io.IOException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class IntBlobTest {
+  // Note: Can't easily test GitRefUpdated behavior, since binding GitRefUpdated requires a thick
+  // stack of dependencies, and it's not just a simple interface or abstract class.
+
+  private Project.NameKey projectName;
+  private InMemoryRepository repo;
+  private TestRepository<InMemoryRepository> tr;
+  private RevWalk rw;
+
+  @Before
+  public void setUp() throws Exception {
+    projectName = Project.nameKey("repo");
+    repo = new InMemoryRepository(new DfsRepositoryDescription(projectName.get()));
+    tr = new TestRepository<>(repo);
+    rw = tr.getRevWalk();
+  }
+
+  @Test
+  public void parseNoRef() throws Exception {
+    assertThat(IntBlob.parse(repo, "refs/nothing")).isEmpty();
+  }
+
+  @Test
+  public void parseNonBlob() throws Exception {
+    String refName = "refs/foo/master";
+    tr.branch(refName).commit().create();
+    assertThrows(IncorrectObjectTypeException.class, () -> IntBlob.parse(repo, refName));
+  }
+
+  @Test
+  public void parseValid() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob("123"));
+    assertThat(IntBlob.parse(repo, refName)).value().isEqualTo(IntBlob.create(id, 123));
+  }
+
+  @Test
+  public void parseWithWhitespace() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob(" 123 "));
+    assertThat(IntBlob.parse(repo, refName)).value().isEqualTo(IntBlob.create(id, 123));
+  }
+
+  @Test
+  public void parseInvalid() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob("1 2 3"));
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> IntBlob.parse(repo, refName));
+    assertThat(thrown).hasMessageThat().isEqualTo("invalid value in refs/foo blob at " + id.name());
+  }
+
+  @Test
+  public void tryStoreNoOldId() throws Exception {
+    String refName = "refs/foo";
+    RefUpdate ru =
+        IntBlob.tryStore(repo, rw, projectName, refName, null, 123, GitReferenceUpdated.DISABLED);
+    assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.NEW);
+    assertThat(ru.getName()).isEqualTo(refName);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(ru.getNewObjectId(), 123));
+  }
+
+  @Test
+  public void tryStoreOldIdZero() throws Exception {
+    String refName = "refs/foo";
+    RefUpdate ru =
+        IntBlob.tryStore(
+            repo, rw, projectName, refName, ObjectId.zeroId(), 123, GitReferenceUpdated.DISABLED);
+    assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.NEW);
+    assertThat(ru.getName()).isEqualTo(refName);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(ru.getNewObjectId(), 123));
+  }
+
+  @Test
+  public void tryStoreCorrectOldId() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob("123"));
+    RefUpdate ru =
+        IntBlob.tryStore(repo, rw, projectName, refName, id, 456, GitReferenceUpdated.DISABLED);
+    assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.FORCED);
+    assertThat(ru.getName()).isEqualTo(refName);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(ru.getNewObjectId(), 456));
+  }
+
+  @Test
+  public void tryStoreWrongOldId() throws Exception {
+    String refName = "refs/foo";
+    RefUpdate ru =
+        IntBlob.tryStore(
+            repo,
+            rw,
+            projectName,
+            refName,
+            ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+            123,
+            GitReferenceUpdated.DISABLED);
+    assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.LOCK_FAILURE);
+    assertThat(ru.getName()).isEqualTo(refName);
+    assertThat(IntBlob.parse(repo, refName)).isEmpty();
+  }
+
+  @Test
+  public void storeNoOldId() throws Exception {
+    String refName = "refs/foo";
+    IntBlob.store(repo, rw, projectName, refName, null, 123, GitReferenceUpdated.DISABLED);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(getRef(refName), 123));
+  }
+
+  @Test
+  public void storeOldIdZero() throws Exception {
+    String refName = "refs/foo";
+    IntBlob.store(
+        repo, rw, projectName, refName, ObjectId.zeroId(), 123, GitReferenceUpdated.DISABLED);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(getRef(refName), 123));
+  }
+
+  @Test
+  public void storeCorrectOldId() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob("123"));
+    IntBlob.store(repo, rw, projectName, refName, id, 456, GitReferenceUpdated.DISABLED);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(getRef(refName), 456));
+  }
+
+  @Test
+  public void storeWrongOldId() throws Exception {
+    String refName = "refs/foo";
+    LockFailureException thrown =
+        assertThrows(
+            LockFailureException.class,
+            () ->
+                IntBlob.store(
+                    repo,
+                    rw,
+                    projectName,
+                    refName,
+                    ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+                    123,
+                    GitReferenceUpdated.DISABLED));
+    assertThat(thrown.getFailedRefs()).containsExactly("refs/foo");
+    assertThat(IntBlob.parse(repo, refName)).isEmpty();
+  }
+
+  private ObjectId getRef(String refName) throws IOException {
+    return repo.exactRef(refName).getObjectId();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java b/javatests/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
deleted file mode 100644
index fe27cac..0000000
--- a/javatests/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
+++ /dev/null
@@ -1,241 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.common.TimeUtil.nowTs;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.applyDelta;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.parse;
-import static org.eclipse.jgit.lib.ObjectId.zeroId;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.notedb.NoteDbChangeState.Delta;
-import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
-import com.google.gerrit.testing.GerritBaseTests;
-import com.google.gerrit.testing.TestChanges;
-import com.google.gerrit.testing.TestTimeUtil;
-import java.sql.Timestamp;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.ObjectId;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Unit tests for {@link NoteDbChangeState}. */
-public class NoteDbChangeStateTest extends GerritBaseTests {
-  ObjectId SHA1 = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-  ObjectId SHA2 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
-  ObjectId SHA3 = ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee");
-
-  @Before
-  public void setUp() {
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void parseReviewDbWithoutDrafts() {
-    NoteDbChangeState state = parse(new Change.Id(1), SHA1.name());
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getDraftIds()).isEmpty();
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-    assertThat(state.toString()).isEqualTo(SHA1.name());
-
-    state = parse(new Change.Id(1), "R," + SHA1.name());
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getDraftIds()).isEmpty();
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-    assertThat(state.toString()).isEqualTo(SHA1.name());
-  }
-
-  @Test
-  public void parseReviewDbWithDrafts() {
-    String str = SHA1.name() + ",2003=" + SHA2.name() + ",1001=" + SHA3.name();
-    String expected = SHA1.name() + ",1001=" + SHA3.name() + ",2003=" + SHA2.name();
-    NoteDbChangeState state = parse(new Change.Id(1), str);
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getDraftIds())
-        .containsExactly(
-            new Account.Id(1001), SHA3,
-            new Account.Id(2003), SHA2);
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-    assertThat(state.toString()).isEqualTo(expected);
-
-    state = parse(new Change.Id(1), "R," + str);
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getDraftIds())
-        .containsExactly(
-            new Account.Id(1001), SHA3,
-            new Account.Id(2003), SHA2);
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-    assertThat(state.toString()).isEqualTo(expected);
-  }
-
-  @Test
-  public void parseReadOnlyUntil() {
-    Timestamp ts = new Timestamp(12345);
-    String str = "R=12345," + SHA1.name();
-    NoteDbChangeState state = parse(new Change.Id(1), str);
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getReadOnlyUntil().get()).isEqualTo(ts);
-    assertThat(state.toString()).isEqualTo(str);
-
-    str = "N=12345";
-    state = parse(new Change.Id(1), str);
-    assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getRefState()).isEmpty();
-    assertThat(state.getReadOnlyUntil().get()).isEqualTo(ts);
-    assertThat(state.toString()).isEqualTo(str);
-  }
-
-  @Test
-  public void applyDeltaToNullWithNoNewMetaId() throws Exception {
-    Change c = newChange();
-    assertThat(c.getNoteDbState()).isNull();
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), noDrafts()));
-    assertThat(c.getNoteDbState()).isNull();
-
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(1001), zeroId())));
-    assertThat(c.getNoteDbState()).isNull();
-  }
-
-  @Test
-  public void applyDeltaToMetaId() throws Exception {
-    Change c = newChange();
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), noDrafts()));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name());
-
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA2), noDrafts()));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA2.name());
-
-    // No-op delta.
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), noDrafts()));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA2.name());
-
-    // Set to zero clears the field.
-    applyDelta(c, Delta.create(c.getId(), metaId(zeroId()), noDrafts()));
-    assertThat(c.getNoteDbState()).isNull();
-  }
-
-  @Test
-  public void applyDeltaToDrafts() throws Exception {
-    Change c = newChange();
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), drafts(new Account.Id(1001), SHA2)));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name() + ",1001=" + SHA2.name());
-
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(2003), SHA3)));
-    assertThat(c.getNoteDbState())
-        .isEqualTo(SHA1.name() + ",1001=" + SHA2.name() + ",2003=" + SHA3.name());
-
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(2003), zeroId())));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name() + ",1001=" + SHA2.name());
-
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA3), noDrafts()));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA3.name() + ",1001=" + SHA2.name());
-  }
-
-  @Test
-  public void applyDeltaToReadOnly() throws Exception {
-    Timestamp ts = nowTs();
-    Change c = newChange();
-    NoteDbChangeState state =
-        new NoteDbChangeState(
-            c.getId(),
-            REVIEW_DB,
-            Optional.of(RefState.create(SHA1, ImmutableMap.of())),
-            Optional.of(new Timestamp(ts.getTime() + 10000)));
-    c.setNoteDbState(state.toString());
-    Delta delta = Delta.create(c.getId(), metaId(SHA2), noDrafts());
-    applyDelta(c, delta);
-    assertThat(NoteDbChangeState.parse(c))
-        .isEqualTo(
-            new NoteDbChangeState(
-                state.getChangeId(),
-                state.getPrimaryStorage(),
-                Optional.of(RefState.create(SHA2, ImmutableMap.of())),
-                state.getReadOnlyUntil()));
-  }
-
-  @Test
-  public void parseNoteDbPrimary() {
-    NoteDbChangeState state = parse(new Change.Id(1), "N");
-    assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
-    assertThat(state.getRefState()).isEmpty();
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-  }
-
-  @Test(expected = IllegalArgumentException.class)
-  public void parseInvalidPrimaryStorage() {
-    parse(new Change.Id(1), "X");
-  }
-
-  @Test
-  public void applyDeltaToNoteDbPrimaryIsNoOp() throws Exception {
-    Change c = newChange();
-    c.setNoteDbState("N");
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), drafts(new Account.Id(1001), SHA2)));
-    assertThat(c.getNoteDbState()).isEqualTo("N");
-  }
-
-  private static Change newChange() {
-    return TestChanges.newChange(new Project.NameKey("project"), new Account.Id(12345));
-  }
-
-  // Static factory methods to avoid type arguments when using as method args.
-
-  private static Optional<ObjectId> noMetaId() {
-    return Optional.empty();
-  }
-
-  private static Optional<ObjectId> metaId(ObjectId id) {
-    return Optional.of(id);
-  }
-
-  private static ImmutableMap<Account.Id, ObjectId> noDrafts() {
-    return ImmutableMap.of();
-  }
-
-  private static ImmutableMap<Account.Id, ObjectId> drafts(Object... args) {
-    checkArgument(args.length % 2 == 0);
-    ImmutableMap.Builder<Account.Id, ObjectId> b = ImmutableMap.builder();
-    for (int i = 0; i < args.length / 2; i++) {
-      b.put((Account.Id) args[2 * i], (ObjectId) args[2 * i + 1]);
-    }
-    return b.build();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
index a21f5ba..6baa3e4 100644
--- a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -15,21 +15,27 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
 
+import com.github.rholder.retry.BlockStrategy;
 import com.github.rholder.retry.Retryer;
 import com.github.rholder.retry.RetryerBuilder;
 import com.github.rholder.retry.StopStrategies;
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Expect;
 import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
-import java.util.concurrent.ExecutionException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -42,14 +48,13 @@
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class RepoSequenceTest {
-  // Don't sleep in tests.
-  private static final Retryer<RefUpdate.Result> RETRYER =
-      RepoSequence.retryerBuilder().withBlockStrategy(t -> {}).build();
+  @Rule public final Expect expect = Expect.create();
 
-  @Rule public ExpectedException exception = ExpectedException.none();
+  // Don't sleep in tests.
+  private static final Retryer<ImmutableList<Integer>> RETRYER =
+      RepoSequence.retryerBuilder().withBlockStrategy(t -> {}).build();
 
   private InMemoryRepositoryManager repoManager;
   private Project.NameKey project;
@@ -57,7 +62,7 @@
   @Before
   public void setUp() throws Exception {
     repoManager = new InMemoryRepositoryManager();
-    project = new Project.NameKey("project");
+    project = Project.nameKey("project");
     repoManager.createRepository(project);
   }
 
@@ -69,13 +74,13 @@
       RepoSequence s = newSequence(name, 1, batchSize);
       for (int i = 1; i <= max; i++) {
         try {
-          assertThat(s.next()).named("i=" + i + " for " + name).isEqualTo(i);
-        } catch (OrmException e) {
+          assertWithMessage("i=" + i + " for " + name).that(s.next()).isEqualTo(i);
+        } catch (StorageException e) {
           throw new AssertionError("failed batchSize=" + batchSize + ", i=" + i, e);
         }
       }
-      assertThat(s.acquireCount)
-          .named("acquireCount for " + name)
+      assertWithMessage("acquireCount for " + name)
+          .that(s.acquireCount)
           .isEqualTo(divCeil(max, batchSize));
     }
   }
@@ -163,7 +168,7 @@
     RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
     assertThat(doneBgUpdate.get()).isFalse();
     assertThat(s.next()).isEqualTo(1234);
-    // Single acquire call that results in 2 ref reads.
+    // Two acquire calls, but only one successful.
     assertThat(s.acquireCount).isEqualTo(1);
     assertThat(doneBgUpdate.get()).isTrue();
   }
@@ -171,23 +176,21 @@
   @Test
   public void failOnInvalidValue() throws Exception {
     ObjectId id = writeBlob("id", "not a number");
-    exception.expect(OrmException.class);
-    exception.expectMessage("invalid value in refs/sequences/id blob at " + id.name());
-    newSequence("id", 1, 3).next();
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> newSequence("id", 1, 3).next());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("invalid value in refs/sequences/id blob at " + id.name());
   }
 
   @Test
   public void failOnWrongType() throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<Repository> tr = new TestRepository<>(repo);
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
       tr.branch(RefNames.REFS_SEQUENCES + "id").commit().create();
-      try {
-        newSequence("id", 1, 3).next();
-        fail();
-      } catch (OrmException e) {
-        assertThat(e.getCause()).isInstanceOf(ExecutionException.class);
-        assertThat(e.getCause().getCause()).isInstanceOf(IncorrectObjectTypeException.class);
-      }
+      StorageException e =
+          assertThrows(StorageException.class, () -> newSequence("id", 1, 3).next());
+      assertThat(e.getCause()).isInstanceOf(IncorrectObjectTypeException.class);
     }
   }
 
@@ -200,12 +203,84 @@
             1,
             10,
             () -> writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000))),
-            RetryerBuilder.<RefUpdate.Result>newBuilder()
+            RetryerBuilder.<ImmutableList<Integer>>newBuilder()
                 .withStopStrategy(StopStrategies.stopAfterAttempt(3))
                 .build());
-    exception.expect(OrmException.class);
-    exception.expectMessage("failed to update refs/sequences/id: LOCK_FAILURE");
-    s.next();
+    StorageException thrown = assertThrows(StorageException.class, () -> s.next());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to update refs/sequences/id: LOCK_FAILURE");
+  }
+
+  @Test
+  public void idCanBeRetrievedFromOtherThreadWhileWaitingToRetry() throws Exception {
+    // Seed existing ref value.
+    writeBlob("id", "1");
+
+    // Let the first update of the sequence fail with LOCK_FAILURE, so that the update is retried.
+    CountDownLatch lockFailure = new CountDownLatch(1);
+    CountDownLatch parallelSuccessfulSequenceGeneration = new CountDownLatch(1);
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    Runnable bgUpdate =
+        () -> {
+          if (!doneBgUpdate.getAndSet(true)) {
+            writeBlob("id", "1234");
+          }
+        };
+
+    BlockStrategy blockStrategy =
+        t -> {
+          // Keep blocking until we verified that another thread can retrieve a sequence number
+          // while we are blocking here.
+          lockFailure.countDown();
+          parallelSuccessfulSequenceGeneration.await();
+        };
+
+    // Use batch size = 1 to make each call go to NoteDb.
+    RepoSequence s =
+        newSequence(
+            "id",
+            1,
+            1,
+            bgUpdate,
+            RepoSequence.retryerBuilder().withBlockStrategy(blockStrategy).build());
+
+    assertThat(doneBgUpdate.get()).isFalse();
+
+    // Start a thread to get a sequence number. This thread needs to update the sequence in NoteDb,
+    // but due to the background update (see bgUpdate) the first attempt to update NoteDb fails
+    // with LOCK_FAILURE. RepoSequence uses a retryer to retry the NoteDb update on LOCK_FAILURE,
+    // but our block strategy ensures that this retry only happens after isBlocking was set to
+    // false.
+    Future<?> future =
+        Executors.newFixedThreadPool(1)
+            .submit(
+                () -> {
+                  // The background update sets the next available sequence number to 1234. Then the
+                  // test thread retrieves one sequence number, so that the next available sequence
+                  // number for this thread is 1235.
+                  expect.that(s.next()).isEqualTo(1235);
+                });
+
+    // Wait until the LOCK_FAILURE has happened and the block strategy was entered.
+    lockFailure.await();
+
+    // Verify that the background update was done now.
+    assertThat(doneBgUpdate.get()).isTrue();
+
+    // Verify that we can retrieve a sequence number while the other thread is blocked. If the
+    // s.next() call hangs it means that the RepoSequence.counterLock was not released before the
+    // background thread started to block for retry. In this case the test would time out.
+    assertThat(s.next()).isEqualTo(1234);
+
+    // Stop blocking the retry of the background thread (and verify that it was still blocked).
+    parallelSuccessfulSequenceGeneration.countDown();
+
+    // Wait until the background thread is done.
+    future.get();
+
+    // Two successful acquire calls (because batch size == 1).
+    assertThat(s.acquireCount).isEqualTo(2);
   }
 
   @Test
@@ -254,95 +329,6 @@
     assertThat(s2.acquireCount).isEqualTo(1);
   }
 
-  @Test
-  public void increaseTo() throws Exception {
-    // Seed existing ref value.
-    writeBlob("id", "1");
-
-    RepoSequence s = newSequence("id", 1, 10);
-
-    s.increaseTo(2);
-    assertThat(s.next()).isEqualTo(2);
-  }
-
-  @Test
-  public void increaseToLowerValueIsIgnored() throws Exception {
-    // Seed existing ref value.
-    writeBlob("id", "2");
-
-    RepoSequence s = newSequence("id", 1, 10);
-
-    s.increaseTo(1);
-    assertThat(s.next()).isEqualTo(2);
-  }
-
-  @Test
-  public void increaseToRetryOnLockFailureV1() throws Exception {
-    // Seed existing ref value.
-    writeBlob("id", "1");
-
-    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
-    Runnable bgUpdate =
-        () -> {
-          if (!doneBgUpdate.getAndSet(true)) {
-            writeBlob("id", "2");
-          }
-        };
-
-    RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
-    assertThat(doneBgUpdate.get()).isFalse();
-
-    // Increase the value to 3. The background thread increases the value to 2, which makes the
-    // increase to value 3 fail once with LockFailure. The increase to 3 is then retried and is
-    // expected to succeed.
-    s.increaseTo(3);
-    assertThat(s.next()).isEqualTo(3);
-
-    assertThat(doneBgUpdate.get()).isTrue();
-  }
-
-  @Test
-  public void increaseToRetryOnLockFailureV2() throws Exception {
-    // Seed existing ref value.
-    writeBlob("id", "1");
-
-    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
-    Runnable bgUpdate =
-        () -> {
-          if (!doneBgUpdate.getAndSet(true)) {
-            writeBlob("id", "3");
-          }
-        };
-
-    RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
-    assertThat(doneBgUpdate.get()).isFalse();
-
-    // Increase the value to 2. The background thread increases the value to 3, which makes the
-    // increase to value 2 fail with LockFailure. The increase to 2 is then not retried because the
-    // current value is already higher and it should be preserved.
-    s.increaseTo(2);
-    assertThat(s.next()).isEqualTo(3);
-
-    assertThat(doneBgUpdate.get()).isTrue();
-  }
-
-  @Test
-  public void increaseToFailAfterRetryerGivesUp() throws Exception {
-    AtomicInteger bgCounter = new AtomicInteger(1234);
-    RepoSequence s =
-        newSequence(
-            "id",
-            1,
-            10,
-            () -> writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000))),
-            RetryerBuilder.<RefUpdate.Result>newBuilder()
-                .withStopStrategy(StopStrategies.stopAfterAttempt(3))
-                .build());
-    exception.expect(OrmException.class);
-    exception.expectMessage("failed to update refs/sequences/id: LOCK_FAILURE");
-    s.increaseTo(2);
-  }
-
   private RepoSequence newSequence(String name, int start, int batchSize) {
     return newSequence(name, start, batchSize, Runnables.doNothing(), RETRYER);
   }
@@ -352,7 +338,7 @@
       final int start,
       int batchSize,
       Runnable afterReadRef,
-      Retryer<RefUpdate.Result> retryer) {
+      Retryer<ImmutableList<Integer>> retryer) {
     return new RepoSequence(
         repoManager,
         GitReferenceUpdated.DISABLED,
diff --git a/javatests/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java b/javatests/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
deleted file mode 100644
index 5a1ec2b..0000000
--- a/javatests/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
+++ /dev/null
@@ -1,231 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.Collections2;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.testing.TestTimeUtil;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Stream;
-import org.junit.Before;
-import org.junit.Test;
-
-public class EventSorterTest {
-  private class TestEvent extends Event {
-    protected TestEvent(Timestamp when) {
-      super(
-          new PatchSet.Id(new Change.Id(1), 1),
-          new Account.Id(1000),
-          new Account.Id(1000),
-          when,
-          changeCreatedOn,
-          null);
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return false;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) {
-      throw new UnsupportedOperationException();
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public String toString() {
-      return "E{" + when.getSeconds() + '}';
-    }
-  }
-
-  private Timestamp changeCreatedOn;
-
-  @Before
-  public void setUp() {
-    TestTimeUtil.resetWithClockStep(10, TimeUnit.SECONDS);
-    changeCreatedOn = TimeUtil.nowTs();
-  }
-
-  @Test
-  public void naturalSort() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-
-    for (List<Event> events : Collections2.permutations(events(e1, e2, e3))) {
-      assertSorted(events, events(e1, e2, e3));
-    }
-  }
-
-  @Test
-  public void topoSortOneDep() {
-    List<Event> es;
-
-    // Input list is 0,1,2
-
-    // 0 depends on 1 => 1,0,2
-    es = threeEventsOneDep(0, 1);
-    assertSorted(es, events(es, 1, 0, 2));
-
-    // 1 depends on 0 => 0,1,2
-    es = threeEventsOneDep(1, 0);
-    assertSorted(es, events(es, 0, 1, 2));
-
-    // 0 depends on 2 => 1,2,0
-    es = threeEventsOneDep(0, 2);
-    assertSorted(es, events(es, 1, 2, 0));
-
-    // 2 depends on 0 => 0,1,2
-    es = threeEventsOneDep(2, 0);
-    assertSorted(es, events(es, 0, 1, 2));
-
-    // 1 depends on 2 => 0,2,1
-    es = threeEventsOneDep(1, 2);
-    assertSorted(es, events(es, 0, 2, 1));
-
-    // 2 depends on 1 => 0,1,2
-    es = threeEventsOneDep(2, 1);
-    assertSorted(es, events(es, 0, 1, 2));
-  }
-
-  private List<Event> threeEventsOneDep(int depFromIdx, int depOnIdx) {
-    List<Event> events =
-        Lists.newArrayList(
-            new TestEvent(TimeUtil.nowTs()),
-            new TestEvent(TimeUtil.nowTs()),
-            new TestEvent(TimeUtil.nowTs()));
-    events.get(depFromIdx).addDep(events.get(depOnIdx));
-    return events;
-  }
-
-  @Test
-  public void lastEventDependsOnFirstEvent() {
-    List<Event> events = new ArrayList<>();
-    for (int i = 0; i < 20; i++) {
-      events.add(new TestEvent(TimeUtil.nowTs()));
-    }
-    events.get(events.size() - 1).addDep(events.get(0));
-    assertSorted(events, events);
-  }
-
-  @Test
-  public void firstEventDependsOnLastEvent() {
-    List<Event> events = new ArrayList<>();
-    for (int i = 0; i < 20; i++) {
-      events.add(new TestEvent(TimeUtil.nowTs()));
-    }
-    events.get(0).addDep(events.get(events.size() - 1));
-
-    List<Event> expected = new ArrayList<>();
-    expected.addAll(events.subList(1, events.size()));
-    expected.add(events.get(0));
-    assertSorted(events, expected);
-  }
-
-  @Test
-  public void topoSortChainOfDeps() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-    Event e4 = new TestEvent(TimeUtil.nowTs());
-    e1.addDep(e2);
-    e2.addDep(e3);
-    e3.addDep(e4);
-
-    assertSorted(events(e1, e2, e3, e4), events(e4, e3, e2, e1));
-  }
-
-  @Test
-  public void topoSortMultipleDeps() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-    Event e4 = new TestEvent(TimeUtil.nowTs());
-    e1.addDep(e2);
-    e1.addDep(e4);
-    e2.addDep(e3);
-
-    // Processing 3 pops 2, processing 4 pops 1.
-    assertSorted(events(e2, e3, e1, e4), events(e3, e2, e4, e1));
-  }
-
-  @Test
-  public void topoSortMultipleDepsPreservesNaturalOrder() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-    Event e4 = new TestEvent(TimeUtil.nowTs());
-    e1.addDep(e4);
-    e2.addDep(e4);
-    e3.addDep(e4);
-
-    // Processing 4 pops 1, 2, 3 in natural order.
-    assertSorted(events(e4, e3, e2, e1), events(e4, e1, e2, e3));
-  }
-
-  @Test
-  public void topoSortCycle() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-
-    // Implementation is not really defined, but infinite looping would be bad.
-    // According to current implementation details, 2 pops 1, 1 pops 2 which was
-    // already seen.
-    assertSorted(events(e2, e1), events(e1, e2));
-  }
-
-  @Test
-  public void topoSortDepNotInInputList() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-    e1.addDep(e3);
-
-    List<Event> events = events(e2, e1);
-    try {
-      new EventSorter(events).sort();
-      fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
-  }
-
-  private static List<Event> events(Event... es) {
-    return Lists.newArrayList(es);
-  }
-
-  private static List<Event> events(List<Event> in, Integer... indexes) {
-    return Stream.of(indexes).map(in::get).collect(toList());
-  }
-
-  private static void assertSorted(List<Event> unsorted, List<Event> expected) {
-    List<Event> actual = new ArrayList<>(unsorted);
-    new EventSorter(actual).sort();
-    assertThat(actual).named("sorted" + unsorted).isEqualTo(expected);
-  }
-}
diff --git a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
index ef80d7e..52a81ad 100644
--- a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
+++ b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
@@ -15,14 +15,15 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.jgit.diff.ReplaceEdit;
 import java.util.List;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.EditList;
-import org.eclipse.jgit.diff.ReplaceEdit;
 import org.junit.Test;
 
 public class IntraLineLoaderTest {
@@ -87,22 +88,30 @@
   // TODO: expected failure
   // the current code does not work on the first line
   // and the insert marker is in the wrong location
-  @Test(expected = AssertionError.class)
+  @Test
   public void preferInsertAtLineBreak2() throws Exception {
-    String a = "  abc\n    def\n";
-    String b = "    abc\n      def\n";
-    assertThat(intraline(a, b))
-        .isEqualTo(ref().insert("  ").common("  abc\n").insert("  ").common("  def\n").edits);
+    assertThrows(
+        AssertionError.class,
+        () -> {
+          String a = "  abc\n    def\n";
+          String b = "    abc\n      def\n";
+          assertThat(intraline(a, b))
+              .isEqualTo(ref().insert("  ").common("  abc\n").insert("  ").common("  def\n").edits);
+        });
   }
 
   // TODO: expected failure
   // the current code does not work on the first line
-  @Test(expected = AssertionError.class)
+  @Test
   public void preferDeleteAtLineBreak() throws Exception {
-    String a = "    abc\n      def\n";
-    String b = "  abc\n    def\n";
-    assertThat(intraline(a, b))
-        .isEqualTo(ref().remove("  ").common("  abc\n").remove("  ").common("  def\n").edits);
+    assertThrows(
+        AssertionError.class,
+        () -> {
+          String a = "    abc\n      def\n";
+          String b = "  abc\n    def\n";
+          assertThat(intraline(a, b))
+              .isEqualTo(ref().remove("  ").common("  abc\n").remove("  ").common("  def\n").edits);
+        });
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java b/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java
new file mode 100644
index 0000000..7aa73a7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.truth.Truth.assertWithMessage;
+import static com.google.gerrit.server.permissions.PluginPermissionsUtil.isValidPluginPermission;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+
+/** Small tests for {@link PluginPermissionsUtil}. */
+public final class PluginPermissionsUtilTest {
+
+  @Test
+  public void isPluginPermissionReturnsTrueForValidName() {
+    // "-" is allowed for a plugin name. Here "foo-a" should be the name of the plugin.
+    ImmutableList<String> validPluginPermissions =
+        ImmutableList.of("plugin-foo-a", "plugin-foo-a-b");
+
+    for (String permission : validPluginPermissions) {
+      assertWithMessage("valid plugin permission: %s", permission)
+          .that(isValidPluginPermission(permission))
+          .isTrue();
+    }
+  }
+
+  @Test
+  public void isPluginPermissionReturnsFalseForInvalidName() {
+    ImmutableList<String> invalidPluginPermissions =
+        ImmutableList.of(
+            "create",
+            "label-Code-Review",
+            "plugin-foo",
+            "plugin-foo",
+            "plugin-foo-a-",
+            "plugin-foo-a1");
+
+    for (String permission : invalidPluginPermissions) {
+      assertWithMessage("invalid plugin permission: %s", permission)
+          .that(isValidPluginPermission(permission))
+          .isFalse();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 7890de8..2869743 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -15,347 +15,261 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
 import static com.google.gerrit.common.data.Permission.LABEL;
 import static com.google.gerrit.common.data.Permission.OWNER;
 import static com.google.gerrit.common.data.Permission.PUSH;
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.common.data.Permission.SUBMIT;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.ADMIN;
-import static com.google.gerrit.server.project.testing.Util.DEVS;
-import static com.google.gerrit.server.project.testing.Util.allow;
-import static com.google.gerrit.server.project.testing.Util.allowExclusive;
-import static com.google.gerrit.server.project.testing.Util.block;
-import static com.google.gerrit.server.project.testing.Util.deny;
-import static com.google.gerrit.server.project.testing.Util.doNotInherit;
-import static com.google.gerrit.testing.InMemoryRepositoryManager.newRepository;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.exceptions.InvalidNameException;
 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.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-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.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RefPattern;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testing.InMemoryDatabase;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 public class RefControlTest {
-  private void assertAdminsAreOwnersAndDevsAreNot() {
-    ProjectControl uBlah = user(local, DEVS);
-    ProjectControl uAdmin = user(local, DEVS, ADMIN);
+  private static final AccountGroup.UUID ADMIN = AccountGroup.uuid("test.admin");
+  private static final AccountGroup.UUID DEVS = AccountGroup.uuid("test.devs");
 
-    assertThat(uBlah.isOwner()).named("not owner").isFalse();
-    assertThat(uAdmin.isOwner()).named("is owner").isTrue();
+  private void assertAdminsAreOwnersAndDevsAreNot() throws Exception {
+    ProjectControl uBlah = user(localKey, DEVS);
+    ProjectControl uAdmin = user(localKey, DEVS, ADMIN);
+
+    assertWithMessage("not owner").that(uBlah.isOwner()).isFalse();
+    assertWithMessage("is owner").that(uAdmin.isOwner()).isTrue();
   }
 
   private void assertOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner()).named("OWN " + ref).isTrue();
+    assertWithMessage("OWN " + ref).that(u.controlForRef(ref).isOwner()).isTrue();
   }
 
   private void assertNotOwner(ProjectControl u) {
-    assertThat(u.isOwner()).named("not owner").isFalse();
+    assertWithMessage("not owner").that(u.isOwner()).isFalse();
   }
 
   private void assertNotOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner()).named("NOT OWN " + ref).isFalse();
+    assertWithMessage("NOT OWN " + ref).that(u.controlForRef(ref).isOwner()).isFalse();
   }
 
   private void assertCanAccess(ProjectControl u) {
     boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
-    assertThat(access).named("can access").isTrue();
+    assertWithMessage("can access").that(access).isTrue();
   }
 
   private void assertAccessDenied(ProjectControl u) {
     boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
-    assertThat(access).named("cannot access").isFalse();
+    assertWithMessage("cannot access").that(access).isFalse();
   }
 
   private void assertCanRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible()).named("can read " + ref).isTrue();
+    assertWithMessage("can read " + ref).that(u.controlForRef(ref).isVisible()).isTrue();
   }
 
   private void assertCannotRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible()).named("cannot read " + ref).isFalse();
+    assertWithMessage("cannot read " + ref).that(u.controlForRef(ref).isVisible()).isFalse();
   }
 
   private void assertCanSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isTrue();
+    assertWithMessage("can submit " + ref).that(u.controlForRef(ref).canSubmit(false)).isTrue();
   }
 
   private void assertCannotSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isFalse();
+    assertWithMessage("can submit " + ref).that(u.controlForRef(ref).canSubmit(false)).isFalse();
   }
 
   private void assertCanUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("can upload").isTrue();
+    assertWithMessage("can upload").that(u.canPushToAtLeastOneRef()).isTrue();
   }
 
   private void assertCreateChange(String ref, ProjectControl u) {
     boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
-    assertThat(create).named("can create change " + ref).isTrue();
+    assertWithMessage("can create change " + ref).that(create).isTrue();
   }
 
   private void assertCannotUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("cannot upload").isFalse();
+    assertWithMessage("cannot upload").that(u.canPushToAtLeastOneRef()).isFalse();
   }
 
   private void assertCannotCreateChange(String ref, ProjectControl u) {
     boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
-    assertThat(create).named("cannot create change " + ref).isFalse();
+    assertWithMessage("cannot create change " + ref).that(create).isFalse();
   }
 
   private void assertCanUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
-    assertThat(update).named("can update " + ref).isTrue();
+    assertWithMessage("can update " + ref).that(update).isTrue();
   }
 
   private void assertCannotUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
-    assertThat(update).named("cannot update " + ref).isFalse();
+    assertWithMessage("cannot update " + ref).that(update).isFalse();
   }
 
   private void assertCanForceUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
-    assertThat(update).named("can force push " + ref).isTrue();
+    assertWithMessage("can force push " + ref).that(update).isTrue();
   }
 
   private void assertCannotForceUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
-    assertThat(update).named("cannot force push " + ref).isFalse();
+    assertWithMessage("cannot force push " + ref).that(update).isFalse();
   }
 
   private void assertCanVote(int score, PermissionRange range) {
-    assertThat(range.contains(score)).named("can vote " + score).isTrue();
+    assertWithMessage("can vote " + score).that(range.contains(score)).isTrue();
   }
 
   private void assertCannotVote(int score, PermissionRange range) {
-    assertThat(range.contains(score)).named("cannot vote " + score).isFalse();
+    assertWithMessage("cannot vote " + score).that(range.contains(score)).isFalse();
   }
 
-  private final AllProjectsName allProjectsName =
-      new AllProjectsName(AllProjectsNameProvider.DEFAULT);
-  private final AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-  private final AccountGroup.UUID fixers = new AccountGroup.UUID("test.fixers");
-  private final Map<Project.NameKey, ProjectState> all = new HashMap<>();
-  private Project.NameKey localKey = new Project.NameKey("local");
-  private ProjectConfig local;
-  private Project.NameKey parentKey = new Project.NameKey("parent");
-  private ProjectConfig parent;
-  private InMemoryRepositoryManager repoManager;
-  private ProjectCache projectCache;
-  private PermissionCollection.Factory sectionSorter;
-  private ChangeControl.Factory changeControlFactory;
-  private ReviewDb db;
+  private final AccountGroup.UUID fixers = AccountGroup.uuid("test.fixers");
+  private final Project.NameKey localKey = Project.nameKey("local");
+  private final Project.NameKey parentKey = Project.nameKey("parent");
 
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private CapabilityCollection.Factory capabilityCollectionFactory;
+  @Inject private AllProjectsName allProjectsName;
+  @Inject private InMemoryRepositoryManager repoManager;
+  @Inject private MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject private ProjectCache projectCache;
+  @Inject private ProjectControl.Factory projectControlFactory;
+  @Inject private ProjectOperations projectOperations;
   @Inject private SchemaCreator schemaCreator;
   @Inject private SingleVersionListener singleVersionListener;
-  @Inject private InMemoryDatabase schemaFactory;
   @Inject private ThreadLocalRequestContext requestContext;
-  @Inject private DefaultRefFilter.Factory refFilterFactory;
-  @Inject private IdentifiedUser.GenericFactory identifiedUserFactory;
 
   @Before
   public void setUp() throws Exception {
-    repoManager = new InMemoryRepositoryManager();
-    projectCache =
-        new ProjectCache() {
-          @Override
-          public ProjectState getAllProjects() {
-            return get(allProjectsName);
-          }
-
-          @Override
-          public ProjectState getAllUsers() {
-            return null;
-          }
-
-          @Override
-          public ProjectState get(Project.NameKey projectName) {
-            return all.get(projectName);
-          }
-
-          @Override
-          public void evict(Project p) {}
-
-          @Override
-          public void remove(Project p) {}
-
-          @Override
-          public void remove(Project.NameKey name) {}
-
-          @Override
-          public ImmutableSortedSet<Project.NameKey> all() {
-            return ImmutableSortedSet.of();
-          }
-
-          @Override
-          public ImmutableSortedSet<Project.NameKey> byName(String prefix) {
-            return ImmutableSortedSet.of();
-          }
-
-          @Override
-          public void onCreateProject(Project.NameKey newProjectName) {}
-
-          @Override
-          public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-            return Collections.emptySet();
-          }
-
-          @Override
-          public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
-            return all.get(projectName);
-          }
-
-          @Override
-          public void evict(Project.NameKey p) {}
-
-          @Override
-          public ProjectState checkedGet(Project.NameKey projectName, boolean strict)
-              throws Exception {
-            return all.get(projectName);
-          }
-        };
-
     Injector injector = Guice.createInjector(new InMemoryModule());
     injector.injectMembers(this);
 
-    try {
-      Repository repo = repoManager.createRepository(allProjectsName);
-      ProjectConfig allProjects = new ProjectConfig(new Project.NameKey(allProjectsName.get()));
-      allProjects.load(repo);
-      LabelType cr = Util.codeReview();
-      allProjects.getLabelSections().put(cr.getName(), cr);
-      add(allProjects);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
+    // Tests previously used ProjectConfig.Factory to create ProjectConfigs without going through
+    // the ProjectCache, which was wrong. Manually call getInstance so we don't store it in a
+    // field that is accessible to test methods.
+    ProjectConfig.Factory projectConfigFactory = injector.getInstance(ProjectConfig.Factory.class);
 
-    db = schemaFactory.open();
     singleVersionListener.start();
     try {
-      schemaCreator.create(db);
+      schemaCreator.create();
     } finally {
       singleVersionListener.stop();
     }
 
-    Cache<SectionSortCache.EntryKey, SectionSortCache.EntryVal> c =
-        CacheBuilder.newBuilder().build();
-    sectionSorter = new PermissionCollection.Factory(new SectionSortCache(c));
+    // Clear out All-Projects and use the lowest-level API possible for project creation, so the
+    // only ACL entries are exactly what is initialized by this test, and we aren't subject to
+    // changing defaults in SchemaCreator or ProjectCreator.
+    try (Repository allProjectsRepo = repoManager.createRepository(allProjectsName);
+        TestRepository<Repository> tr = new TestRepository<>(allProjectsRepo)) {
+      tr.delete(REFS_CONFIG);
+      try (MetaDataUpdate md = metaDataUpdateFactory.create(allProjectsName)) {
+        ProjectConfig allProjectsConfig = projectConfigFactory.create(allProjectsName);
+        allProjectsConfig.load(md);
+        LabelType cr = TestLabels.codeReview();
+        allProjectsConfig.getLabelSections().put(cr.getName(), cr);
+        allProjectsConfig.commit(md);
+      }
+    }
 
-    parent = new ProjectConfig(parentKey);
-    parent.load(newRepository(parentKey));
-    add(parent);
+    repoManager.createRepository(parentKey).close();
+    repoManager.createRepository(localKey).close();
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(localKey)) {
+      ProjectConfig newLocal = projectConfigFactory.create(localKey);
+      newLocal.load(md);
+      newLocal.getProject().setParentName(parentKey);
+      newLocal.commit(md);
+    }
 
-    local = new ProjectConfig(localKey);
-    local.load(newRepository(localKey));
-    add(local);
-    local.getProject().setParentName(parentKey);
-
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return null;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
-
-    changeControlFactory = injector.getInstance(ChangeControl.Factory.class);
+    requestContext.setContext(() -> null);
   }
 
   @After
-  public void tearDown() {
+  public void tearDown() throws Exception {
     requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
   }
 
   @Test
-  public void ownerProject() {
-    allow(local, OWNER, ADMIN, "refs/*");
-
+  public void ownerProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .update();
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
-  public void denyOwnerProject() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    deny(local, OWNER, DEVS, "refs/*");
-
+  public void denyOwnerProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(deny(OWNER).ref("refs/*").group(DEVS))
+        .update();
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
-  public void blockOwnerProject() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    block(local, OWNER, DEVS, "refs/*");
-
+  public void blockOwnerProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(block(OWNER).ref("refs/*").group(DEVS))
+        .update();
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
-  public void branchDelegation1() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    allow(local, OWNER, DEVS, "refs/heads/x/*");
+  public void branchDelegation1() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(allow(OWNER).ref("refs/heads/x/*").group(DEVS))
+        .update();
 
-    ProjectControl uDev = user(local, DEVS);
+    ProjectControl uDev = user(localKey, DEVS);
     assertNotOwner(uDev);
 
     assertOwner("refs/heads/x/*", uDev);
@@ -367,13 +281,17 @@
   }
 
   @Test
-  public void branchDelegation2() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    allow(local, OWNER, DEVS, "refs/heads/x/*");
-    allow(local, OWNER, fixers, "refs/heads/x/y/*");
-    doNotInherit(local, OWNER, "refs/heads/x/y/*");
+  public void branchDelegation2() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(allow(OWNER).ref("refs/heads/x/*").group(DEVS))
+        .add(allow(OWNER).ref("refs/heads/x/y/*").group(fixers))
+        .setExclusiveGroup(permissionKey(OWNER).ref("refs/heads/x/y/*"), true)
+        .update();
 
-    ProjectControl uDev = user(local, DEVS);
+    ProjectControl uDev = user(localKey, DEVS);
     assertNotOwner(uDev);
 
     assertOwner("refs/heads/x/*", uDev);
@@ -382,7 +300,7 @@
     assertNotOwner("refs/*", uDev);
     assertNotOwner("refs/heads/master", uDev);
 
-    ProjectControl uFix = user(local, fixers);
+    ProjectControl uFix = user(localKey, fixers);
     assertNotOwner(uFix);
 
     assertOwner("refs/heads/x/y/*", uFix);
@@ -394,54 +312,86 @@
   }
 
   @Test
-  public void inheritRead_SingleBranchDeniesUpload() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
-    doNotInherit(local, READ, "refs/heads/foobar");
-    doNotInherit(local, PUSH, "refs/for/refs/heads/foobar");
+  public void inheritRead_SingleBranchDeniesUpload() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(PUSH).ref("refs/for/refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/foobar").group(REGISTERED_USERS))
+        .setExclusiveGroup(permissionKey(READ).ref("refs/heads/foobar"), true)
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/for/refs/heads/foobar"), true)
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanUpload(u);
     assertCreateChange("refs/heads/master", u);
     assertCannotCreateChange("refs/heads/foobar", u);
   }
 
   @Test
-  public void blockPushDrafts() {
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-    allow(local, PUSH, REGISTERED_USERS, "refs/drafts/*");
+  public void blockPushDrafts() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/for/refs/*").group(REGISTERED_USERS))
+        .add(block(PUSH).ref("refs/drafts/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/drafts/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCreateChange("refs/heads/master", u);
     assertThat(u.controlForRef("refs/drafts/master").canPerform(PUSH)).isFalse();
   }
 
   @Test
-  public void blockPushDraftsUnblockAdmin() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-    allow(parent, PUSH, ADMIN, "refs/drafts/*");
-    allow(local, PUSH, REGISTERED_USERS, "refs/drafts/*");
+  public void blockPushDraftsUnblockAdmin() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/drafts/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/drafts/*").group(ADMIN))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/drafts/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
-    ProjectControl a = user(local, "a", ADMIN);
+    ProjectControl u = user(localKey);
+    ProjectControl a = user(localKey, "a", ADMIN);
 
-    assertThat(a.controlForRef("refs/drafts/master").canPerform(PUSH))
-        .named("push is allowed")
+    assertWithMessage("push is allowed")
+        .that(a.controlForRef("refs/drafts/master").canPerform(PUSH))
         .isTrue();
-    assertThat(u.controlForRef("refs/drafts/master").canPerform(PUSH))
-        .named("push is not allowed")
+    assertWithMessage("push is not allowed")
+        .that(u.controlForRef("refs/drafts/master").canPerform(PUSH))
         .isFalse();
   }
 
   @Test
-  public void inheritRead_SingleBranchDoesNotOverrideInherited() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
+  public void inheritRead_SingleBranchDoesNotOverrideInherited() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(PUSH).ref("refs/for/refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/foobar").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanUpload(u);
     assertCreateChange("refs/heads/master", u);
     assertCreateChange("refs/heads/foobar", u);
@@ -449,31 +399,50 @@
 
   @Test
   public void inheritDuplicateSections() throws Exception {
-    allow(parent, READ, ADMIN, "refs/*");
-    allow(local, READ, DEVS, "refs/heads/*");
-    assertCanAccess(user(local, "a", ADMIN));
-
-    local = new ProjectConfig(localKey);
-    local.load(newRepository(localKey));
-    local.getProject().setParentName(parentKey);
-    allow(local, READ, DEVS, "refs/*");
-    assertCanAccess(user(local, "d", DEVS));
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(ADMIN))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(DEVS))
+        .update();
+    assertCanAccess(user(localKey, "a", ADMIN));
+    assertCanAccess(user(localKey, "d", DEVS));
   }
 
   @Test
-  public void inheritRead_OverrideWithDeny() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/*");
+  public void inheritRead_OverrideWithDeny() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
-    assertAccessDenied(user(local));
+    assertAccessDenied(user(localKey));
   }
 
   @Test
-  public void inheritRead_AppendWithDenyOfRef() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/heads/*");
+  public void inheritRead_AppendWithDenyOfRef() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanAccess(u);
     assertCanRead("refs/master", u);
     assertCanRead("refs/tags/foobar", u);
@@ -481,12 +450,20 @@
   }
 
   @Test
-  public void inheritRead_OverridesAndDeniesOfRef() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/*");
+  public void inheritRead_OverridesAndDeniesOfRef() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanAccess(u);
     assertCannotRead("refs/foobar", u);
     assertCannotRead("refs/tags/foobar", u);
@@ -494,100 +471,178 @@
   }
 
   @Test
-  public void inheritSubmit_OverridesAndDeniesOfRef() {
-    allow(parent, SUBMIT, REGISTERED_USERS, "refs/*");
-    deny(local, SUBMIT, REGISTERED_USERS, "refs/*");
-    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
+  public void inheritSubmit_OverridesAndDeniesOfRef() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCannotSubmit("refs/foobar", u);
     assertCannotSubmit("refs/tags/foobar", u);
     assertCanSubmit("refs/heads/foobar", u);
   }
 
   @Test
-  public void cannotUploadToAnyRef() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(local, READ, DEVS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/for/refs/heads/*");
+  public void cannotUploadToAnyRef() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/*").group(DEVS))
+        .add(allow(PUSH).ref("refs/for/refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCannotUpload(u);
     assertCannotCreateChange("refs/heads/master", u);
   }
 
   @Test
-  public void usernamePatternCanUploadToAnyRef() {
-    allow(local, PUSH, REGISTERED_USERS, "refs/heads/users/${username}/*");
-    ProjectControl u = user(local, "a-registered-user");
+  public void usernamePatternCanUploadToAnyRef() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/users/${username}/*").group(REGISTERED_USERS))
+        .update();
+    ProjectControl u = user(localKey, "a-registered-user");
     assertCanUpload(u);
   }
 
   @Test
-  public void usernamePatternNonRegex() {
-    allow(local, READ, DEVS, "refs/sb/${username}/heads/*");
+  public void usernamePatternNonRegex() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/sb/${username}/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, "u", DEVS);
-    ProjectControl d = user(local, "d", DEVS);
+    ProjectControl u = user(localKey, "u", DEVS);
+    ProjectControl d = user(localKey, "d", DEVS);
     assertCannotRead("refs/sb/d/heads/foobar", u);
     assertCanRead("refs/sb/d/heads/foobar", d);
   }
 
   @Test
-  public void usernamePatternWithRegex() {
-    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
+  public void usernamePatternWithRegex() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/sb/${username}/heads/.*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, "d.v", DEVS);
-    ProjectControl d = user(local, "dev", DEVS);
+    ProjectControl u = user(localKey, "d.v", DEVS);
+    ProjectControl d = user(localKey, "dev", DEVS);
     assertCannotRead("refs/sb/dev/heads/foobar", u);
     assertCanRead("refs/sb/dev/heads/foobar", d);
   }
 
   @Test
-  public void usernameEmailPatternWithRegex() {
-    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
+  public void usernameEmailPatternWithRegex() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/sb/${username}/heads/.*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, "d.v@ger-rit.org", DEVS);
-    ProjectControl d = user(local, "dev@ger-rit.org", DEVS);
+    ProjectControl u = user(localKey, "d.v@ger-rit.org", DEVS);
+    ProjectControl d = user(localKey, "dev@ger-rit.org", DEVS);
     assertCannotRead("refs/sb/dev@ger-rit.org/heads/foobar", u);
     assertCanRead("refs/sb/dev@ger-rit.org/heads/foobar", d);
   }
 
   @Test
-  public void sortWithRegex() {
-    allow(local, READ, DEVS, "^refs/heads/.*");
-    allow(parent, READ, ANONYMOUS_USERS, "^refs/heads/.*-QA-.*");
+  public void sortWithRegex() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/heads/.*").group(DEVS))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/heads/.*-QA-.*").group(ANONYMOUS_USERS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
-    ProjectControl d = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
+    ProjectControl d = user(localKey, DEVS);
     assertCanRead("refs/heads/foo-QA-bar", u);
     assertCanRead("refs/heads/foo-QA-bar", d);
   }
 
   @Test
-  public void blockRule_ParentBlocksChild() {
-    allow(local, PUSH, DEVS, "refs/tags/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-    ProjectControl u = user(local, DEVS);
+  public void blockRule_ParentBlocksChild() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/tags/*").group(DEVS))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/tags/V10", u);
   }
 
   @Test
-  public void blockRule_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
-    allow(local, PUSH, DEVS, "refs/tags/*");
-    block(local, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
+  public void blockRule_ParentBlocksChildEvenIfAlreadyBlockedInChild() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/tags/*").group(DEVS))
+        .add(block(PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/tags/V10", u);
   }
 
   @Test
-  public void blockLabelRange_ParentBlocksChild() {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void blockPartialRangeLocally() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(+1, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
+
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    assertCannotVote(2, range);
+  }
+
+  @Test
+  public void blockLabelRange_ParentBlocksChild() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
+
+    ProjectControl u = user(localKey, DEVS);
 
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
@@ -597,12 +652,20 @@
   }
 
   @Test
-  public void blockLabelRange_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void blockLabelRange_ParentBlocksChildEvenIfAlreadyBlockedInChild() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
 
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
@@ -612,251 +675,396 @@
   }
 
   @Test
-  public void inheritSubmit_AllowInChildDoesntAffectUnblockInParent() {
-    block(parent, SUBMIT, ANONYMOUS_USERS, "refs/heads/*");
-    allow(parent, SUBMIT, REGISTERED_USERS, "refs/heads/*");
-    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
+  public void inheritSubmit_AllowInChildDoesntAffectUnblockInParent() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(SUBMIT).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
-    assertThat(u.controlForRef("refs/heads/master").canPerform(SUBMIT))
-        .named("submit is allowed")
+    ProjectControl u = user(localKey);
+    assertWithMessage("submit is allowed")
+        .that(u.controlForRef("refs/heads/master").canPerform(SUBMIT))
         .isTrue();
   }
 
   @Test
-  public void unblockNoForce() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/*");
+  public void unblockNoForce() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCanUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockForce() {
-    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    r.setForce(true);
-    allow(local, PUSH, DEVS, "refs/heads/*").setForce(true);
+  public void unblockForce() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS).force(true))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCanForceUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockRead_NotPossible() {
-    block(parent, READ, ANONYMOUS_USERS, "refs/*");
-    allow(parent, READ, ADMIN, "refs/*");
-    allow(local, READ, ANONYMOUS_USERS, "refs/*");
-    allow(local, READ, ADMIN, "refs/*");
-    ProjectControl u = user(local);
+  public void unblockRead_NotPossible() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/*").group(ADMIN))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/*").group(ADMIN))
+        .update();
+
+    ProjectControl u = user(localKey);
     assertCannotRead("refs/heads/master", u);
   }
 
   @Test
-  public void unblockForceWithAllowNoForce_NotPossible() {
-    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    r.setForce(true);
-    allow(local, PUSH, DEVS, "refs/heads/*");
+  public void unblockForceWithAllowNoForce_NotPossible() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotForceUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockMoreSpecificRef_Fails() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
+  public void unblockMoreSpecificRef_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockMoreSpecificRefInLocal_Fails() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
+  public void unblockMoreSpecificRefInLocal_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockMoreSpecificRefWithExclusiveFlag() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master", true);
+  public void unblockMoreSpecificRefWithExclusiveFlag() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCanUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockVoteMoreSpecificRefWithExclusiveFlag() {
-    String perm = LABEL + "Code-Review";
+  public void unblockVoteMoreSpecificRefWithExclusiveFlag() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/master"), true)
+        .update();
 
-    block(local, perm, -1, 1, ANONYMOUS_USERS, "refs/heads/*");
-    allowExclusive(local, perm, -2, 2, DEVS, "refs/heads/master");
-
-    ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(perm);
+    ProjectControl u = user(localKey, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
   }
 
   @Test
-  public void unblockFromParentDoesNotAffectChild() {
-    allow(parent, PUSH, DEVS, "refs/heads/master", true);
-    block(local, PUSH, DEVS, "refs/heads/master");
+  public void unblockFromParentDoesNotAffectChild() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/master").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockFromParentDoesNotAffectChildDifferentGroups() {
-    allow(parent, PUSH, DEVS, "refs/heads/master", true);
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
+  public void unblockFromParentDoesNotAffectChildDifferentGroups() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master", true);
+  public void unblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void blockMoreSpecificRefWithinProject() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/secret");
-    allow(local, PUSH, DEVS, "refs/heads/*", true);
+  public void blockMoreSpecificRefWithinProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/secret").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/*"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/secret", u);
     assertCanUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
-    allow(local, SUBMIT, DEVS, "refs/heads/master", true);
+  public void unblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .add(allow(SUBMIT).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(SUBMIT).ref("refs/heads/master"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockLargerScope_Fails() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
-    allow(local, PUSH, DEVS, "refs/heads/*");
+  public void unblockLargerScope_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockInLocal_Fails() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, fixers, "refs/heads/*");
+  public void unblockInLocal_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/*").group(fixers))
+        .update();
 
-    ProjectControl f = user(local, fixers);
+    ProjectControl f = user(localKey, fixers);
     assertCannotUpdate("refs/heads/master", f);
   }
 
   @Test
-  public void unblockInParentBlockInLocal() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(parent, PUSH, DEVS, "refs/heads/*");
-    block(local, PUSH, DEVS, "refs/heads/*");
+  public void unblockInParentBlockInLocal() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl d = user(local, DEVS);
+    ProjectControl d = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", d);
   }
 
   @Test
-  public void unblockForceEditTopicName() {
-    block(local, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
+  public void unblockForceEditTopicName() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(EDIT_TOPIC_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(EDIT_TOPIC_NAME).ref("refs/heads/*").group(DEVS).force(true))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
-    assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
-        .named("u can edit topic name")
+    ProjectControl u = user(localKey, DEVS);
+    assertWithMessage("u can edit topic name")
+        .that(u.controlForRef("refs/heads/master").canForceEditTopicName())
         .isTrue();
   }
 
   @Test
-  public void unblockInLocalForceEditTopicName_Fails() {
-    block(parent, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
+  public void unblockInLocalForceEditTopicName_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(EDIT_TOPIC_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(EDIT_TOPIC_NAME).ref("refs/heads/*").group(DEVS).force(true))
+        .update();
 
-    ProjectControl u = user(local, REGISTERED_USERS);
-    assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
-        .named("u can't edit topic name")
+    ProjectControl u = user(localKey, REGISTERED_USERS);
+    assertWithMessage("u can't edit topic name")
+        .that(u.controlForRef("refs/heads/master").canForceEditTopicName())
         .isFalse();
   }
 
   @Test
-  public void unblockRange() {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void unblockRange() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
 
   @Test
-  public void unblockRangeOnMoreSpecificRef_Fails() {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/master");
+  public void unblockRangeOnMoreSpecificRef_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void unblockRangeOnLargerScope_Fails() {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/master");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void unblockRangeOnLargerScope_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(
+            blockLabel("Code-Review").ref("refs/heads/master").group(ANONYMOUS_USERS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void nonconfiguredCannotVote() {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void nonconfiguredCannotVote() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, REGISTERED_USERS);
+    ProjectControl u = user(localKey, REGISTERED_USERS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-1, range);
     assertCannotVote(1, range);
   }
 
   @Test
-  public void unblockInLocalRange_Fails() {
-    block(parent, LABEL + "Code-Review", -1, 1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void unblockInLocalRange_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void unblockRangeForChangeOwner() {
-    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+  public void unblockRangeForChangeOwner() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range =
         u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review", true);
     assertCanVote(-2, range);
@@ -864,65 +1072,97 @@
   }
 
   @Test
-  public void unblockRangeForNotChangeOwner() {
-    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+  public void unblockRangeForNotChangeOwner() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void blockChangeOwnerVote() {
-    block(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+  public void blockChangeOwnerVote() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void unionOfPermissibleVotes() {
-    allow(local, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
+  public void unionOfPermissibleVotes() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
 
   @Test
-  public void unionOfPermissibleVotesPermissionOrder() {
-    allow(local, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
+  public void unionOfPermissibleVotesPermissionOrder() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
 
   @Test
-  public void unionOfBlockedVotes() {
-    allow(parent, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
-    block(local, LABEL + "Code-Review", -2, +1, REGISTERED_USERS, "refs/heads/*");
+  public void unionOfBlockedVotes() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +1))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
     assertCannotVote(1, range);
   }
 
   @Test
-  public void blockOwner() {
-    block(parent, OWNER, ANONYMOUS_USERS, "refs/*");
-    allow(local, OWNER, DEVS, "refs/*");
+  public void blockOwner() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(OWNER).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(DEVS))
+        .update();
 
-    assertThat(user(local, DEVS).isOwner()).isFalse();
+    assertThat(user(localKey, DEVS).isOwner()).isFalse();
   }
 
   @Test
@@ -933,14 +1173,16 @@
     RefPattern.validate("refs/heads/review/${username}/*");
   }
 
-  @Test(expected = InvalidNameException.class)
+  @Test
   public void testValidateBadRefPatternDoubleCaret() throws Exception {
-    RefPattern.validate("^^refs/*");
+    assertThrows(InvalidNameException.class, () -> RefPattern.validate("^^refs/*"));
   }
 
-  @Test(expected = InvalidNameException.class)
+  @Test
   public void testValidateBadRefPatternDanglingCharacter() throws Exception {
-    RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*");
+    assertThrows(
+        InvalidNameException.class,
+        () -> RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*"));
   }
 
   @Test
@@ -948,57 +1190,22 @@
     RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}");
   }
 
-  private InMemoryRepository add(ProjectConfig pc) {
-    SitePaths sitePaths = null;
-    List<CommentLinkInfo> commentLinks = null;
-
-    InMemoryRepository repo;
-    try {
-      repo = repoManager.createRepository(pc.getName());
-      if (pc.getProject() == null) {
-        pc.load(repo);
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
-    all.put(
-        pc.getName(),
-        new ProjectState(
-            sitePaths,
-            projectCache,
-            allProjectsName,
-            allUsersName,
-            repoManager,
-            commentLinks,
-            capabilityCollectionFactory,
-            pc));
-    return repo;
+  private ProjectState getProjectState(Project.NameKey nameKey) throws Exception {
+    return projectCache.checkedGet(nameKey, true);
   }
 
-  private ProjectControl user(ProjectConfig local, AccountGroup.UUID... memberOf) {
-    return user(local, null, memberOf);
+  private ProjectControl user(Project.NameKey localKey, AccountGroup.UUID... memberOf)
+      throws Exception {
+    return user(localKey, null, memberOf);
   }
 
   private ProjectControl user(
-      ProjectConfig local, @Nullable String name, AccountGroup.UUID... memberOf) {
-    return new ProjectControl(
-        Collections.<AccountGroup.UUID>emptySet(),
-        Collections.<AccountGroup.UUID>emptySet(),
-        sectionSorter,
-        changeControlFactory,
-        permissionBackend,
-        refFilterFactory,
-        identifiedUserFactory,
-        new MockUser(name, memberOf),
-        newProjectState(local));
+      Project.NameKey localKey, @Nullable String name, AccountGroup.UUID... memberOf)
+      throws Exception {
+    return projectControlFactory.create(new MockUser(name, memberOf), getProjectState(localKey));
   }
 
-  private ProjectState newProjectState(ProjectConfig local) {
-    add(local);
-    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/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index b39208a..12c0838 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -14,12 +14,18 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
@@ -32,7 +38,6 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
@@ -43,6 +48,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -57,9 +63,10 @@
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected AllProjectsName allProjects;
   @Inject private CommitsCollection commits;
+  @Inject private ProjectOperations projectOperations;
 
   private TestRepository<InMemoryRepository> repo;
-  private ProjectConfig project;
+  private Project.NameKey project;
 
   @Before
   public void setUp() throws Exception {
@@ -67,17 +74,22 @@
 
     Account.Id user = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     testEnvironment.setApiUser(user);
+    project = projectOperations.newProject().create();
+    repo = new TestRepository<>(repoManager.openRepository(project));
+  }
 
-    Project.NameKey name = new Project.NameKey("project");
-    InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
-    project = new ProjectConfig(name);
-    project.load(inMemoryRepo);
-    repo = new TestRepository<>(inMemoryRepo);
+  @After
+  public void tearDown() {
+    repo.getRepository().close();
   }
 
   @Test
   public void canReadCommitWhenAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     ObjectId id = repo.branch("master").commit().create();
     ProjectState state = readProjectState();
     RevWalk rw = repo.getRevWalk();
@@ -88,8 +100,12 @@
 
   @Test
   public void canReadCommitIfTwoRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/heads/branch2").group(REGISTERED_USERS))
+        .update();
 
     ObjectId id1 = repo.branch("branch1").commit().create();
     ObjectId id2 = repo.branch("branch2").commit().create();
@@ -104,8 +120,12 @@
 
   @Test
   public void canReadCommitIfRefVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .add(deny(READ).ref("refs/heads/branch2").group(REGISTERED_USERS))
+        .update();
 
     ObjectId id1 = repo.branch("branch1").commit().create();
     ObjectId id2 = repo.branch("branch2").commit().create();
@@ -120,8 +140,12 @@
 
   @Test
   public void canReadCommitIfReachableFromVisibleRef() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .add(deny(READ).ref("refs/heads/branch2").group(REGISTERED_USERS))
+        .update();
 
     RevCommit parent1 = repo.commit().create();
     repo.branch("branch1").commit().parent(parent1).create();
@@ -138,7 +162,11 @@
 
   @Test
   public void cannotReadAfterRollbackWithRestrictedRead() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .update();
 
     RevCommit parent1 = repo.commit().create();
     ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
@@ -157,7 +185,11 @@
 
   @Test
   public void canReadAfterRollbackWithAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     RevCommit parent1 = repo.commit().create();
     ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
@@ -175,41 +207,19 @@
   }
 
   private ProjectState readProjectState() throws Exception {
-    return projectCache.get(project.getName());
-  }
-
-  protected void allow(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.allow(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void deny(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.deny(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(cfg.getName())) {
-      cfg.commit(md);
-    }
-    projectCache.evict(cfg.getProject());
+    return projectCache.get(project);
   }
 
   private void setUpPermissions() throws Exception {
-    ImmutableList<AccountGroup.UUID> admins = getAdmins();
-
     // Remove read permissions for all users besides admin, because by default
     // Anonymous user group has ALLOW READ permission in refs/*.
     // This method is idempotent, so is safe to call on every test setup.
-    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
-    }
-    for (AccountGroup.UUID admin : admins) {
-      allow(pc, Permission.READ, admin, "refs/*");
-    }
+    TestProjectUpdate.Builder u = projectOperations.allProjectsForUpdate();
+    projectCache.checkedGet(allProjects).getConfig().getAccessSectionNames().stream()
+        .filter(sec -> sec.startsWith(R_REFS))
+        .forEach(sec -> u.remove(permissionKey(Permission.READ).ref(sec)));
+    getAdmins().forEach(admin -> u.add(allow(Permission.READ).ref("refs/*").group(admin)));
+    u.update();
   }
 
   private ImmutableList<AccountGroup.UUID> getAdmins() {
@@ -220,9 +230,7 @@
             .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
             .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
 
-    return adminPermission
-        .getRules()
-        .stream()
+    return adminPermission.getRules().stream()
         .map(PermissionRule::getGroup)
         .map(GroupReference::getUUID)
         .collect(ImmutableList.toImmutableList());
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 2249a16..5ccefa0 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -37,7 +37,7 @@
 import org.junit.Test;
 
 public class GroupListTest {
-  private static final Project.NameKey PROJECT = new Project.NameKey("project");
+  private static final Project.NameKey PROJECT = Project.nameKey("project");
   private static final String TEXT =
       "# UUID                                  \tGroup Name\n"
           + "#\n"
@@ -55,7 +55,7 @@
 
   @Test
   public void byUUID() throws Exception {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
+    AccountGroup.UUID uuid = AccountGroup.uuid("d96b998f8a66ff433af50befb975d0e2bb6e0999");
 
     GroupReference groupReference = groupList.byUUID(uuid);
 
@@ -65,7 +65,7 @@
 
   @Test
   public void put() {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("abc");
+    AccountGroup.UUID uuid = AccountGroup.uuid("abc");
     GroupReference groupReference = new GroupReference(uuid, "Hutzliputz");
 
     groupList.put(uuid, groupReference);
@@ -80,7 +80,7 @@
     Collection<GroupReference> result = groupList.references();
 
     assertEquals(2, result.size());
-    AccountGroup.UUID uuid = new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
+    AccountGroup.UUID uuid = AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
     GroupReference expected = new GroupReference(uuid, "Administrators");
 
     assertTrue(result.contains(expected));
@@ -91,7 +91,7 @@
     Set<AccountGroup.UUID> result = groupList.uuids();
 
     assertEquals(2, result.size());
-    AccountGroup.UUID expected = new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
+    AccountGroup.UUID expected = AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
     assertTrue(result.contains(expected));
   }
 
@@ -107,11 +107,11 @@
 
   @Test
   public void retainAll() throws Exception {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
+    AccountGroup.UUID uuid = AccountGroup.uuid("d96b998f8a66ff433af50befb975d0e2bb6e0999");
     groupList.retainUUIDs(Collections.singleton(uuid));
 
     assertNotNull(groupList.byUUID(uuid));
-    assertNull(groupList.byUUID(new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310")));
+    assertNull(groupList.byUUID(AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310")));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 0e4ba10..07cfe2f 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -16,7 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.reviewdb.client.BooleanProjectConfig.REQUIRE_CHANGE_ID;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ContributorAgreement;
@@ -24,16 +26,20 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 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.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.testing.Util;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.server.project.testing.TestLabels;
 import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Map;
@@ -51,9 +57,11 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 
-public class ProjectConfigTest extends GerritBaseTests {
+public class ProjectConfigTest {
   private static final String LABEL_SCORES_CONFIG =
       "  copyMinScore = "
           + !LabelType.DEF_COPY_MIN_SCORE
@@ -74,15 +82,24 @@
           + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE
           + "\n";
 
-  private final GroupReference developers =
-      new GroupReference(new AccountGroup.UUID("X"), "Developers");
-  private final GroupReference staff = new GroupReference(new AccountGroup.UUID("Y"), "Staff");
+  private static final AllProjectsName ALL_PROJECTS = new AllProjectsName("All-The-Projects");
 
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private final GroupReference developers =
+      new GroupReference(AccountGroup.uuid("X"), "Developers");
+  private final GroupReference staff = new GroupReference(AccountGroup.uuid("Y"), "Staff");
+
+  private SitePaths sitePaths;
+  private ProjectConfig.Factory factory;
   private Repository db;
   private TestRepository<?> tr;
 
   @Before
   public void setUp() throws Exception {
+    sitePaths = new SitePaths(temporaryFolder.newFolder().toPath());
+    Files.createDirectories(sitePaths.etc_dir);
+    factory = new ProjectConfig.Factory(sitePaths, ALL_PROJECTS);
     db = new InMemoryRepository(new DfsRepositoryDescription("repo"));
     tr = new TestRepository<>(db);
   }
@@ -104,6 +121,13 @@
                     + "  sameGroupVisibility = block group Staff\n"
                     + "[contributor-agreement \"Individual\"]\n"
                     + "  description = A simple description\n"
+                    + "  matchProjects = ^/ourproject\n"
+                    + "  matchProjects = ^/ourotherproject\n"
+                    + "  matchProjects = ^/someotherroot/ourproject\n"
+                    + "  excludeProjects = ^/theirproject\n"
+                    + "  excludeProjects = ^/theirotherproject\n"
+                    + "  excludeProjects = ^/someotherroot/theirproject\n"
+                    + "  excludeProjects = ^/someotherroot/theirotherproject\n"
                     + "  accepted = group Developers\n"
                     + "  accepted = group Staff\n"
                     + "  autoVerify = group Developers\n"
@@ -115,6 +139,14 @@
     ContributorAgreement ca = cfg.getContributorAgreement("Individual");
     assertThat(ca.getName()).isEqualTo("Individual");
     assertThat(ca.getDescription()).isEqualTo("A simple description");
+    assertThat(ca.getMatchProjectsRegexes())
+        .containsExactly("^/ourproject", "^/ourotherproject", "^/someotherroot/ourproject");
+    assertThat(ca.getExcludeProjectsRegexes())
+        .containsExactly(
+            "^/theirproject",
+            "^/theirotherproject",
+            "^/someotherroot/theirproject",
+            "^/someotherroot/theirotherproject");
     assertThat(ca.getAgreementUrl()).isEqualTo("http://www.example.com/agree");
     assertThat(ca.getAccepted()).hasSize(2);
     assertThat(ca.getAccepted().get(0).getGroup()).isEqualTo(developers);
@@ -256,6 +288,7 @@
                     + "  sameGroupVisibility = block group Staff\n"
                     + "[contributor-agreement \"Individual\"]\n"
                     + "  description = A simple description\n"
+                    + "  matchProjects = ^/ourproject\n"
                     + "  accepted = group Developers\n"
                     + "  autoVerify = group Developers\n"
                     + "  agreementUrl = http://www.example.com/agree\n"
@@ -273,6 +306,8 @@
     ContributorAgreement ca = cfg.getContributorAgreement("Individual");
     ca.setAccepted(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
     ca.setAutoVerify(null);
+    ca.setMatchProjectsRegexes(null);
+    ca.setExcludeProjectsRegexes(Collections.singletonList("^/theirproject"));
     ca.setDescription("A new description");
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
@@ -289,6 +324,7 @@
                 + "  description = A new description\n"
                 + "  accepted = group Staff\n"
                 + "  agreementUrl = http://www.example.com/agree\n"
+                + "\texcludeProjects = ^/theirproject\n"
                 + "[label \"CustomLabel\"]\n"
                 + LABEL_SCORES_CONFIG
                 + "\tfunction = MaxWithBlock\n" // label gets this function when it is created
@@ -304,11 +340,11 @@
     cfg.getLabelSections()
         .put(
             "My-Label",
-            Util.category(
+            TestLabels.label(
                 "My-Label",
-                Util.value(-1, "Negative"),
-                Util.value(0, "No score"),
-                Util.value(1, "Positive")));
+                TestLabels.value(-1, "Negative"),
+                TestLabels.value(0, "No score"),
+                TestLabels.value(1, "Positive")));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -385,7 +421,7 @@
 
   @Test
   public void readUnexistingPluginConfig() throws Exception {
-    ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(Project.nameKey("test"));
     cfg.load(db);
     PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
     assertThat(pluginCfg.getNames()).isEmpty();
@@ -418,6 +454,27 @@
   }
 
   @Test
+  public void pluginSectionIsUnsetIfAllPluginConfigsAreEmpty() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[commentlink \"bugzilla\"]\n"
+                    + "  match = \"(bugs#?)(d+)\"\n"
+                    + "[plugin \"somePlugin\"]\n"
+                    + "  key = value\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    pluginCfg.unset("key");
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo("[commentlink \"bugzilla\"]\n  match = \"(bugs#?)(d+)\"\n");
+  }
+
+  @Test
   public void readPluginConfigGroupReference() throws Exception {
     RevCommit rev =
         tr.commit()
@@ -572,8 +629,46 @@
                     + "commentlink.bugzilla must have either link or html"));
   }
 
+  @Test
+  public void readAllProjectsBaseConfigFromSitePaths() throws Exception {
+    ProjectConfig cfg = factory.create(ALL_PROJECTS);
+    cfg.load(db);
+    assertThat(cfg.getProject().getBooleanConfig(REQUIRE_CHANGE_ID))
+        .isEqualTo(InheritableBoolean.INHERIT);
+
+    writeDefaultAllProjectsConfig("[receive]", "requireChangeId = false");
+
+    cfg.load(db);
+    assertThat(cfg.getProject().getBooleanConfig(REQUIRE_CHANGE_ID))
+        .isEqualTo(InheritableBoolean.FALSE);
+  }
+
+  @Test
+  public void readOtherProjectIgnoresAllProjectsBaseConfig() throws Exception {
+    ProjectConfig cfg = factory.create(Project.nameKey("test"));
+    cfg.load(db);
+    assertThat(cfg.getProject().getBooleanConfig(REQUIRE_CHANGE_ID))
+        .isEqualTo(InheritableBoolean.INHERIT);
+
+    writeDefaultAllProjectsConfig("[receive]", "requireChangeId = false");
+
+    cfg.load(db);
+    // If we went through ProjectState, then this would return FALSE, since project.config for
+    // All-Projects would inherit from all_projects.config, and this project would inherit from
+    // All-Projects. But in ProjectConfig itself, there is no inheritance from All-Projects, so this
+    // continues to return the default.
+    assertThat(cfg.getProject().getBooleanConfig(REQUIRE_CHANGE_ID))
+        .isEqualTo(InheritableBoolean.INHERIT);
+  }
+
+  private Path writeDefaultAllProjectsConfig(String... lines) throws IOException {
+    Path dir = sitePaths.etc_dir.resolve(ALL_PROJECTS.get());
+    Files.createDirectories(dir);
+    return Files.write(dir.resolve("project.config"), ImmutableList.copyOf(lines));
+  }
+
   private ProjectConfig read(RevCommit rev) throws IOException, ConfigInvalidException {
-    ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(Project.nameKey("test"));
     cfg.load(db, rev);
     return cfg;
   }
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index bc3c9a9..33f47b2 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.query.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
 
@@ -49,7 +51,6 @@
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -80,11 +81,10 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.GerritServerTests;
-import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -96,10 +96,13 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 
 @Ignore
 public abstract class AbstractQueryAccountsTest extends GerritServerTests {
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Inject protected Accounts accounts;
 
   @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdate;
@@ -118,8 +121,6 @@
 
   @Inject private Provider<AnonymousUser> anonymousUser;
 
-  @Inject protected InMemoryDatabase schemaFactory;
-
   @Inject protected SchemaCreator schemaCreator;
 
   @Inject protected ThreadLocalRequestContext requestContext;
@@ -140,7 +141,6 @@
 
   protected LifecycleManager lifecycle;
   protected Injector injector;
-  protected ReviewDb db;
   protected AccountInfo currentUserInfo;
   protected CurrentUser admin;
 
@@ -160,12 +160,10 @@
   @After
   public void cleanUp() {
     lifecycle.stop();
-    db.close();
   }
 
   protected void setUpDatabase() throws Exception {
-    db = schemaFactory.open();
-    schemaCreator.create(db);
+    schemaCreator.create();
 
     Account.Id adminId = createAccount("admin", "Administrator", "admin@example.com", true);
     admin = userFactory.create(adminId);
@@ -177,32 +175,11 @@
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
     final CurrentUser requestUser = userFactory.create(requestUserId);
-    return new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return requestUser;
-      }
-
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    };
+    return () -> requestUser;
   }
 
   protected void setAnonymous() {
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return anonymousUser.get();
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
+    requestContext.setContext(anonymousUser::get);
   }
 
   @After
@@ -211,10 +188,6 @@
       lifecycle.stop();
     }
     requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
   }
 
   @Test
@@ -286,7 +259,7 @@
     addEmails(user1, secondaryEmail);
 
     AccountInfo user2 = newAccount("user");
-    requestContext.setContext(newRequestContext(new Account.Id(user2._accountId)));
+    requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
 
     if (getSchemaVersion() < 5) {
       assertMissingField(AccountField.PREFERRED_EMAIL);
@@ -373,7 +346,7 @@
     AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
 
     AccountInfo user3 = newAccount("user");
-    requestContext.setContext(newRequestContext(new Account.Id(user3._accountId)));
+    requestContext.setContext(newRequestContext(Account.id(user3._accountId)));
 
     assertQuery("notexisting");
     assertQuery("Not Existing");
@@ -487,6 +460,67 @@
   }
 
   @Test
+  public void sortedByFullname() throws Exception {
+    String appendix = name("name");
+
+    // Use an account creation order that ensures that sorting by fullname differs from sorting by
+    // account ID.
+    AccountInfo userFoo = newAccountWithFullName("user1", "foo-" + appendix);
+    AccountInfo userBar = newAccountWithFullName("user2", "bar-" + appendix);
+    AccountInfo userBaz = newAccountWithFullName("user3", "baz-" + appendix);
+    assertThat(userFoo._accountId).isLessThan(userBar._accountId);
+    assertThat(userBar._accountId).isLessThan(userBaz._accountId);
+
+    String query = "name:" + userFoo.name + " OR name:" + userBar.name + " OR name:" + userBaz.name;
+    // Must request details to populate fullname in the results. If fullname is not set sorting by
+    // fullname is not possible.
+    assertQuery(newQuery(query).withOption(ListAccountsOption.DETAILS), userBar, userBaz, userFoo);
+  }
+
+  @Test
+  public void sortedByPreferredEmail() throws Exception {
+    String appendix = name("name");
+
+    // Use an account creation order that ensures that sorting by preferred email differs from
+    // sorting by account ID. Use the same fullname for all accounts so that sorting must be done by
+    // preferred email.
+    AccountInfo userFoo3 =
+        newAccount("user3", "foo-" + appendix, "foo3-" + appendix + "@test.com", true);
+    AccountInfo userFoo1 =
+        newAccount("user1", "foo-" + appendix, "foo1-" + appendix + "@test.com", true);
+    AccountInfo userFoo2 =
+        newAccount("user2", "foo-" + appendix, "foo2-" + appendix + "@test.com", true);
+    assertThat(userFoo3._accountId).isLessThan(userFoo1._accountId);
+    assertThat(userFoo1._accountId).isLessThan(userFoo2._accountId);
+
+    String query =
+        "name:" + userFoo1.name + " OR name:" + userFoo2.name + " OR name:" + userFoo3.name;
+    // Must request details to populate fullname and preferred email in the results. If fullname and
+    // preferred email are not set sorting by fullname and preferred email is not possible. Since
+    // all 3 accounts have the same fullname we expect sorting by preferred email.
+    assertQuery(
+        newQuery(query).withOption(ListAccountsOption.DETAILS), userFoo1, userFoo2, userFoo3);
+  }
+
+  @Test
+  public void sortedById() throws Exception {
+    String appendix = name("name");
+
+    // Each new account gets a higher account ID. Create the accounts in an order that sorting by
+    // fullname differs from sorting by accout ID.
+    AccountInfo userFoo = newAccountWithFullName("user1", "foo-" + appendix);
+    AccountInfo userBar = newAccountWithFullName("user2", "bar-" + appendix);
+    AccountInfo userBaz = newAccountWithFullName("user3", "baz-" + appendix);
+    assertThat(userFoo._accountId).isLessThan(userBar._accountId);
+    assertThat(userBar._accountId).isLessThan(userBaz._accountId);
+
+    String query = "name:" + userFoo.name + " OR name:" + userBar.name + " OR name:" + userBaz.name;
+    // Normally sorting is done by fullname and preferred email, but if no details are requested
+    // fullname and preferred email are not set and then sorting is done by account ID.
+    assertQuery(newQuery(query), userFoo, userBar, userBaz);
+  }
+
+  @Test
   public void withDetails() throws Exception {
     AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
 
@@ -541,17 +575,19 @@
 
   @Test
   public void withSecondaryEmailsWithoutModifyAccountCapability() throws Exception {
-    AccountInfo user = newAccount("myuser", "My User", "abc@example.com", true);
+    AccountInfo user = newAccount("myuser", "My User", "other@example.com", true);
+
+    AccountInfo otherUser = newAccount("otheruser", "Other User", "abc@example.com", true);
     String[] secondaryEmails = new String[] {"dfg@example.com", "hij@example.com"};
-    addEmails(user, secondaryEmails);
+    addEmails(otherUser, secondaryEmails);
 
-    requestContext.setContext(newRequestContext(new Account.Id(user._accountId)));
+    requestContext.setContext(newRequestContext(Account.id(user._accountId)));
 
-    List<AccountInfo> result = newQuery(user.username).withSuggest(true).get();
+    List<AccountInfo> result = newQuery(otherUser.username).withSuggest(true).get();
     assertThat(result.get(0).secondaryEmails).isNull();
-
-    exception.expect(AuthException.class);
-    newQuery(user.username).withOption(ListAccountsOption.ALL_EMAILS).get();
+    assertThrows(
+        AuthException.class,
+        () -> newQuery(otherUser.username).withOption(ListAccountsOption.ALL_EMAILS).get());
   }
 
   @Test
@@ -570,14 +606,14 @@
     AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
 
     // update account without reindex so that account index is stale
-    Account.Id accountId = new Account.Id(user1._accountId);
+    Account.Id accountId = Account.id(user1._accountId);
     String newName = "Test User";
     try (Repository repo = repoManager.openRepository(allUsers)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, repo);
       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);
@@ -599,7 +635,7 @@
         indexes
             .getSearchIndex()
             .getRaw(
-                new Account.Id(userInfo._accountId),
+                Account.id(userInfo._accountId),
                 QueryOptions.create(
                     IndexConfig.createDefault(),
                     0,
@@ -665,7 +701,7 @@
     in.name = name;
     in.createEmptyCommit = true;
     gApi.projects().create(in);
-    return new Project.NameKey(name);
+    return Project.nameKey(name);
   }
 
   protected void blockRead(Project.NameKey project, GroupInfo group) throws RestApiException {
@@ -720,7 +756,7 @@
       return null;
     }
 
-    String suffix = getSanitizedMethodName();
+    String suffix = testName.getSanitizedMethodName();
     if (name.contains("@")) {
       return name + "." + suffix;
     }
@@ -747,7 +783,7 @@
   }
 
   private void addEmails(AccountInfo account, String... emails) throws Exception {
-    Account.Id id = new Account.Id(account._accountId);
+    Account.Id id = Account.id(account._accountId);
     for (String email : emails) {
       accountManager.link(id, AuthRequest.forEmail(email));
     }
@@ -772,15 +808,15 @@
       throws Exception {
     List<AccountInfo> result = query.get();
     Iterable<Integer> ids = ids(result);
-    assertThat(ids)
-        .named(format(query, result, accounts))
+    assertWithMessage(format(query, result, accounts))
+        .that(ids)
         .containsExactlyElementsIn(ids(accounts))
         .inOrder();
     return result;
   }
 
   protected void assertAccounts(List<AccountState> accounts, AccountInfo... expectedAccounts) {
-    assertThat(accounts.stream().map(a -> a.getAccount().getId().get()).collect(toList()))
+    assertThat(accounts.stream().map(a -> a.getAccount().id().get()).collect(toList()))
         .containsExactlyElementsIn(
             Arrays.asList(expectedAccounts).stream().map(a -> a._accountId).collect(toList()));
   }
@@ -830,8 +866,8 @@
   }
 
   protected void assertMissingField(FieldDef<AccountState, ?> field) {
-    assertThat(getSchema().hasField(field))
-        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
+        .that(getSchema().hasField(field))
         .isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index e6c631b..e41d390 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -4,7 +4,7 @@
 
 java_library(
     name = "abstract_query_tests",
-    testonly = 1,
+    testonly = True,
     srcs = ABSTRACT_QUERY_TEST,
     visibility = ["//visibility:public"],
     deps = [
diff --git a/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
index 660c1d8..e36b79e 100644
--- a/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
@@ -44,6 +44,6 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index cf85aeb..4d7ba94 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -14,15 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.allow;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
-import static com.google.gerrit.server.project.testing.Util.verified;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
@@ -39,10 +41,9 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
@@ -50,10 +51,8 @@
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -66,26 +65,25 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 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.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
@@ -96,17 +94,15 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.index.change.StalenessChecker;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.schema.SchemaCreator;
@@ -115,28 +111,23 @@
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testing.DisabledReviewDb;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritServerTests;
-import com.google.gerrit.testing.InMemoryDatabase;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
 import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -169,6 +160,7 @@
   @Inject protected ChangeIndexer indexer;
   @Inject protected IndexConfig indexConfig;
   @Inject protected InMemoryRepositoryManager repoManager;
+  @Inject protected Provider<AnonymousUser> anonymousUserProvider;
   @Inject protected Provider<InternalChangeQuery> queryProvider;
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected OneOffRequestContext oneOffRequestContext;
@@ -177,19 +169,17 @@
   @Inject protected ChangeNotes.Factory changeNotesFactory;
   @Inject protected Provider<ChangeQueryProcessor> queryProcessorProvider;
   @Inject protected SchemaCreator schemaCreator;
-  @Inject protected SchemaFactory<ReviewDb> schemaFactory;
   @Inject protected Sequences seq;
   @Inject protected ThreadLocalRequestContext requestContext;
   @Inject protected ProjectCache projectCache;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
 
-  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
-  @Inject private InMemoryDatabase inMemoryDatabase;
+  @Inject private ProjectConfig.Factory projectConfigFactory;
+  @Inject private ProjectOperations projectOperations;
 
   protected Injector injector;
   protected LifecycleManager lifecycle;
-  protected ReviewDb db;
   protected Account.Id userId;
   protected CurrentUser user;
 
@@ -198,6 +188,9 @@
   // These queries must be kept in sync with PolyGerrit:
   // polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
 
+  protected static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft";
+  protected static final String DASHBOARD_ASSIGNED_QUERY =
+      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open -is:ignored";
   protected static final String DASHBOARD_WORK_IN_PROGRESS_QUERY = "is:open owner:${user} is:wip";
   protected static final String DASHBOARD_OUTGOING_QUERY =
       "is:open owner:${user} -is:wip -is:ignored";
@@ -205,7 +198,8 @@
       "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user})";
   protected static final String DASHBOARD_RECENTLY_CLOSED_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})";
 
   protected abstract Injector createInjector();
 
@@ -223,16 +217,12 @@
   @After
   public void cleanUp() {
     lifecycle.stop();
-    db.close();
   }
 
   protected void initAfterLifecycleStart() throws Exception {}
 
   protected void setUpDatabase() throws Exception {
-    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
-      schemaCreator.create(underlyingDb);
-    }
-    db = schemaFactory.open();
+    schemaCreator.create();
 
     userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     String email = "user@example.com";
@@ -247,17 +237,7 @@
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
     final CurrentUser requestUser = userFactory.create(requestUserId);
-    return new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return requestUser;
-      }
-
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    };
+    return () -> requestUser;
   }
 
   protected void resetUser() {
@@ -271,10 +251,6 @@
       lifecycle.stop();
     }
     requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(inMemoryDatabase);
   }
 
   @Before
@@ -556,8 +532,7 @@
 
     // Convert AccountInfos to strings, either account ID or email.
     List<String> reviewerIds =
-        reviewers
-            .stream()
+        reviewers.stream()
             .map(
                 ai -> {
                   if (ai._accountId != null) {
@@ -572,9 +547,8 @@
   @Test
   public void restorePendingReviewers() throws Exception {
     assume().that(getSchemaVersion()).isAtLeast(44);
-    assume().that(notesMigration.readChanges()).isTrue();
 
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -732,10 +706,10 @@
     assertQuery(searchOperator + "\"John Smith\"");
 
     // By invalid query.
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid value");
     // SchemaUtil.getNameParts will return an empty set for query only containing these characters.
-    assertQuery(searchOperator + "@.- /_");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery(searchOperator + "@.- /_"));
+    assertThat(thrown).hasMessageThat().contains("invalid value");
   }
 
   private Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
@@ -799,6 +773,80 @@
   }
 
   @Test
+  public void byRepository() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("repository:foo");
+    assertQuery("repository:repo");
+    assertQuery("repository:repo1", change1);
+    assertQuery("repository:repo2", change2);
+  }
+
+  @Test
+  public void byParentRepository() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("parentrepository:repo1", change2, change1);
+    assertQuery("parentrepository:repo2", change2);
+  }
+
+  @Test
+  public void byRepositoryPrefix() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("repositories:foo");
+    assertQuery("repositories:repo1", change1);
+    assertQuery("repositories:repo2", change2);
+    assertQuery("repositories:repo", change2, change1);
+  }
+
+  @Test
+  public void byRepo() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("repo:foo");
+    assertQuery("repo:repo");
+    assertQuery("repo:repo1", change1);
+    assertQuery("repo:repo2", change2);
+  }
+
+  @Test
+  public void byParentRepo() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("parentrepo:repo1", change2, change1);
+    assertQuery("parentrepo:repo2", change2);
+  }
+
+  @Test
+  public void byRepoPrefix() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("repos:foo");
+    assertQuery("repos:repo1", change1);
+    assertQuery("repos:repo2", change2);
+    assertQuery("repos:repo", change2, change1);
+  }
+
+  @Test
   public void byBranchAndRef() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeForBranch(repo, "master"));
@@ -831,7 +879,13 @@
     ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
     Change change4 = insert(repo, ins4);
 
-    Change change5 = insert(repo, newChange(repo));
+    ChangeInserter ins5 = newChangeWithTopic(repo, "https://gerrit.local");
+    Change change5 = insert(repo, ins5);
+
+    ChangeInserter ins6 = newChangeWithTopic(repo, "git_gerrit_training");
+    Change change6 = insert(repo, ins6);
+
+    Change change_no_topic = insert(repo, newChange(repo));
 
     assertQuery("intopic:foo");
     assertQuery("intopic:feature1", change1);
@@ -839,8 +893,9 @@
     assertQuery("topic:feature2", change2);
     assertQuery("intopic:feature2", change4, change3, change2);
     assertQuery("intopic:fixup", change4);
-    assertQuery("topic:\"\"", change5);
-    assertQuery("intopic:\"\"", change5);
+    assertQuery("intopic:gerrit", change6, change5);
+    assertQuery("topic:\"\"", change_no_topic);
+    assertQuery("intopic:\"\"", change_no_topic);
   }
 
   @Test
@@ -899,6 +954,14 @@
   }
 
   @Test
+  public void byMessageSubstring() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("https://gerrit.local").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    assertQuery("message:gerrit", change1);
+  }
+
+  @Test
   public void byLabel() throws Exception {
     accountManager.authenticate(AuthRequest.forUser("anotheruser"));
     TestRepository<Repo> repo = createProject("repo");
@@ -987,20 +1050,24 @@
   public void byLabelMulti() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Project.NameKey project =
-        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
 
     LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    cfg.getLabelSections().put(verified.getName(), verified);
-
-    String heads = RefNames.REFS_HEADS + "*";
-    allow(cfg, Permission.forLabel(verified().getName()), -1, 1, REGISTERED_USERS, heads);
-
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig cfg = projectConfigFactory.create(project);
+      cfg.load(md);
+      cfg.getLabelSections().put(verified.getName(), verified);
       cfg.commit(md);
     }
-    projectCache.evict(cfg.getProject());
+    projectCache.evict(project);
+
+    String heads = RefNames.REFS_HEADS + "*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     ReviewInput reviewVerified = new ReviewInput().label("Verified", 1);
     ChangeInserter ins = newChange(repo, null, null, null, null, false);
@@ -1137,9 +1204,9 @@
       }
       String q = "status:new limit:" + i;
       List<ChangeInfo> results = newQuery(q).get();
-      assertThat(results).named(q).hasSize(expectedSize);
-      assertThat(results.get(results.size() - 1)._moreChanges)
-          .named(q)
+      assertWithMessage(q).that(results).hasSize(expectedSize);
+      assertWithMessage(q)
+          .that(results.get(results.size() - 1)._moreChanges)
           .isEqualTo(expectedMoreChanges);
       assertThat(results.get(0)._number).isEqualTo(last.getId().get());
     }
@@ -1227,7 +1294,7 @@
     assertQuery("status:new", change2, change1);
 
     gApi.changes().id(change1.getId().get()).topic("new-topic");
-    change1 = notesFactory.create(db, change1.getProject(), change1.getId()).getChange();
+    change1 = notesFactory.create(change1.getProject(), change1.getId()).getChange();
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
     assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
@@ -1267,14 +1334,7 @@
   @Test
   public void byFileExact() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit =
-        repo.parseBody(
-            repo.commit()
-                .message("one")
-                .add("dir/file1", "contents1")
-                .add("dir/file2", "contents2")
-                .create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:file");
     assertQuery("file:dir", change);
@@ -1287,14 +1347,7 @@
   @Test
   public void byFileRegex() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit =
-        repo.parseBody(
-            repo.commit()
-                .message("one")
-                .add("dir/file1", "contents1")
-                .add("dir/file2", "contents2")
-                .create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:.*file.*");
     assertQuery("file:^file.*"); // Whole path only.
@@ -1304,14 +1357,7 @@
   @Test
   public void byPathExact() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit =
-        repo.parseBody(
-            repo.commit()
-                .message("one")
-                .add("dir/file1", "contents1")
-                .add("dir/file2", "contents2")
-                .create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:file");
     assertQuery("path:dir");
@@ -1324,20 +1370,248 @@
   @Test
   public void byPathRegex() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit =
-        repo.parseBody(
-            repo.commit()
-                .message("one")
-                .add("dir/file1", "contents1")
-                .add("dir/file2", "contents2")
-                .create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:.*file.*");
     assertQuery("path:^dir.file.*", change);
   }
 
   @Test
+  public void byExtension() throws Exception {
+    if (getSchemaVersion() < 52) {
+      assertMissingField(ChangeField.EXTENSION);
+      String unsupportedOperatorMsg =
+          "'extension' operator is not supported by change index version";
+      assertFailingQuery("extension:txt", unsupportedOperatorMsg);
+      assertFailingQuery("ext:txt", unsupportedOperatorMsg);
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc"));
+    Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC"));
+    Change change3 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change4 = insert(repo, newChangeWithFiles(repo, "Quux.java", "foo"));
+    Change change5 = insert(repo, newChangeWithFiles(repo, "foo"));
+
+    assertQuery("extension:java", change4);
+    assertQuery("ext:java", change4);
+    assertQuery("ext:.java", change4);
+    assertQuery("ext:jAvA", change4);
+    assertQuery("ext:.jAvA", change4);
+    assertQuery("ext:cc", change3, change2, change1);
+
+    if (getSchemaVersion() >= 56) {
+      // matching changes with files that have no extension is possible
+      assertQuery("ext:\"\"", change5, change4);
+      assertFailingQuery("ext:");
+    }
+  }
+
+  @Test
+  public void byOnlyExtensions() throws Exception {
+    if (getSchemaVersion() < 53) {
+      assertMissingField(ChangeField.ONLY_EXTENSIONS);
+      String unsupportedOperatorMessage =
+          "'onlyextensions' operator is not supported by change index version";
+      assertFailingQuery("onlyextensions:txt,jpg", unsupportedOperatorMessage);
+      assertFailingQuery("onlyexts:txt,jpg", unsupportedOperatorMessage);
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
+    Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
+    Change change3 = insert(repo, newChangeWithFiles(repo, "foo.CC", "bar.cc"));
+    Change change4 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change5 = insert(repo, newChangeWithFiles(repo, "Quux.java"));
+    Change change6 = insert(repo, newChangeWithFiles(repo, "foo.txt", "foo"));
+    Change change7 = insert(repo, newChangeWithFiles(repo, "foo"));
+
+    // case doesn't matter
+    assertQuery("onlyextensions:cc,h", change4, change2, change1);
+    assertQuery("onlyextensions:CC,H", change4, change2, change1);
+    assertQuery("onlyextensions:cc,H", change4, change2, change1);
+    assertQuery("onlyextensions:cC,h", change4, change2, change1);
+    assertQuery("onlyextensions:cc", change3);
+    assertQuery("onlyextensions:CC", change3);
+    assertQuery("onlyexts:java", change5);
+    assertQuery("onlyexts:jAvA", change5);
+    assertQuery("onlyexts:.jAvA", change5);
+
+    // order doesn't matter
+    assertQuery("onlyextensions:h,cc", change4, change2, change1);
+    assertQuery("onlyextensions:H,CC", change4, change2, change1);
+
+    // specifying extension with '.' is okay
+    assertQuery("onlyextensions:.cc,.h", change4, change2, change1);
+    assertQuery("onlyextensions:cc,.h", change4, change2, change1);
+    assertQuery("onlyextensions:.cc,h", change4, change2, change1);
+    assertQuery("onlyexts:.java", change5);
+
+    // matching changes without extension is possible
+    assertQuery("onlyexts:txt");
+    assertQuery("onlyexts:txt,", change6);
+    assertQuery("onlyexts:,txt", change6);
+    assertQuery("onlyextensions:\"\"", change7);
+    assertQuery("onlyexts:\"\"", change7);
+    assertQuery("onlyextensions:,", change7);
+    assertQuery("onlyexts:,", change7);
+    assertFailingQuery("onlyextensions:");
+    assertFailingQuery("onlyexts:");
+
+    // inverse queries
+    assertQuery("-onlyextensions:cc,h", change7, change6, change5, change3);
+  }
+
+  @Test
+  public void byFooter() throws Exception {
+    if (getSchemaVersion() < 54) {
+      assertMissingField(ChangeField.FOOTER);
+      assertFailingQuery(
+          "footer:Change-Id=I3d2b978ed455f835d1dad2daa920be0b0ec2ae36",
+          "'footer' operator is not supported by change index version");
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nfoo: baz").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar\nfoo:baz").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    RevCommit commit4 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar=baz").create());
+    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+
+    // create a changes with lines that look like footers, but which are not
+    RevCommit commit5 =
+        repo.parseBody(
+            repo.commit().message("Test\n\nfoo: bar\n\nfoo=bar").insertChangeId().create());
+    Change change5 = insert(repo, newChangeForCommit(repo, commit5));
+    RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
+    insert(repo, newChangeForCommit(repo, commit6));
+
+    // matching by 'key=value' works
+    assertQuery("footer:foo=bar", change3, change1);
+    assertQuery("footer:foo=baz", change3, change2);
+    assertQuery("footer:Change-Id=" + change5.getKey(), change5);
+    assertQuery("footer:foo=bar=baz", change4);
+
+    // case doesn't matter
+    assertQuery("footer:foo=BAR", change3, change1);
+    assertQuery("footer:FOO=bar", change3, change1);
+    assertQuery("footer:fOo=BaZ", change3, change2);
+
+    // verbatim matching of footers works
+    assertQuery("footer:\"foo: bar\"", change3, change1);
+    assertQuery("footer:\"foo: baz\"", change3, change2);
+    assertQuery("footer:\"Change-Id: " + change5.getKey() + "\"", change5);
+    assertQuery("footer:\"foo: bar=baz\"", change4);
+
+    // expect no match because 'a=b: c' of commit6 is not a valid footer (footer key cannot contain
+    // '=')
+    assertQuery("footer:a=b=c");
+    assertQuery("footer:\"a=b: c\"");
+
+    // expect empty result for invalid footers
+    assertQuery("footer:foo");
+    assertQuery("footer:foo=");
+    assertQuery("footer:=foo");
+    assertQuery("footer:=");
+  }
+
+  @Test
+  public void byDirectory() throws Exception {
+    if (getSchemaVersion() < 55) {
+      assertMissingField(ChangeField.DIRECTORY);
+      String unsupportedOperatorMessage =
+          "'directory' operator is not supported by change index version";
+      assertFailingQuery("directory:src/java", unsupportedOperatorMessage);
+      assertFailingQuery("dir:src/java", unsupportedOperatorMessage);
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
+    Change change2 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    Change change3 =
+        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+    Change change4 = insert(repo, newChangeWithFiles(repo, "a.txt"));
+    Change change5 = insert(repo, newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
+
+    // matching by directory prefix works
+    assertQuery("directory:src", change2, change1);
+    assertQuery("directory:src/java", change2);
+    assertQuery("directory:src/js", change2);
+    assertQuery("directory:documentation/", change3);
+    assertQuery("directory:documentation/training", change3);
+    assertQuery("directory:documentation/training/slides", change3);
+
+    // 'dir' alias works
+    assertQuery("dir:src", change2, change1);
+    assertQuery("dir:src/java", change2);
+
+    // case doesn't matter
+    assertQuery("directory:Documentation/TrAiNiNg/SLIDES", change3);
+
+    // leading and trailing '/' doesn't matter
+    assertQuery("directory:/documentation/training/slides", change3);
+    assertQuery("directory:documentation/training/slides/", change3);
+    assertQuery("directory:/documentation/training/slides/", change3);
+
+    // files do not match as directory
+    assertQuery("directory:src/foo.h");
+    assertQuery("directory:documentation/training/slides/README.txt");
+
+    // root directory matches all changes
+    assertQuery("directory:/", change5, change4, change3, change2, change1);
+    assertQuery("directory:\"\"", change5, change4, change3, change2, change1);
+    assertFailingQuery("directory:");
+
+    // matching single directory segments works
+    assertQuery("directory:java", change2);
+    assertQuery("directory:slides", change3);
+
+    // files do not match as directory segment
+    assertQuery("directory:foo.h");
+
+    // matching any combination of intermediate directory segments works
+    assertQuery("directory:training/slides", change3);
+    assertQuery("directory:b/c", change5);
+    assertQuery("directory:b/c/d", change5);
+    assertQuery("directory:b/c/d/e", change5);
+    assertQuery("directory:c/d", change5);
+    assertQuery("directory:c/d/e", change5);
+    assertQuery("directory:d/e", change5);
+
+    // files do not match as directory segments
+    assertQuery("directory:d/e/foo.txt");
+    assertQuery("directory:e/foo.txt");
+
+    // matching any combination of intermediate directory segments works with leading and trailing
+    // '/'
+    assertQuery("directory:/b/c", change5);
+    assertQuery("directory:/b/c/", change5);
+    assertQuery("directory:b/c/", change5);
+  }
+
+  @Test
+  public void byDirectoryRegex() throws Exception {
+    assume().that(getSchemaVersion()).isAtLeast(55);
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    Change change2 =
+        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+
+    // match by regexp
+    assertQuery("directory:^.*va.*", change1);
+    assertQuery("directory:^documentation/.*/slides", change2);
+    assertQuery("directory:^train.*", change2);
+  }
+
+  @Test
   public void byComment() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins = newChange(repo);
@@ -1348,9 +1622,7 @@
     ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
     commentInput.line = 1;
     commentInput.message = "inline";
-    input.comments =
-        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
-            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(commentInput));
+    input.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(commentInput));
     gApi.changes().id(change.getId().get()).current().review(input);
 
     Map<String, List<CommentInfo>> comments =
@@ -1503,15 +1775,14 @@
     in.add = ImmutableSet.of("foo");
     gApi.changes().id(change1.getId().get()).setHashtags(in);
 
-    in.add = ImmutableSet.of("foo", "bar", "a tag");
+    in.add = ImmutableSet.of("foo", "bar", "a tag", "ACamelCaseTag");
     gApi.changes().id(change2.getId().get()).setHashtags(in);
 
     return ImmutableList.of(change1, change2);
   }
 
   @Test
-  public void byHashtagWithNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
+  public void byHashtag() throws Exception {
     List<Change> changes = setUpHashtagChanges();
     assertQuery("hashtag:foo", changes.get(1), changes.get(0));
     assertQuery("hashtag:bar", changes.get(1));
@@ -1520,35 +1791,8 @@
     assertQuery("hashtag:\" a tag \"", changes.get(1));
     assertQuery("hashtag:\"#a tag\"", changes.get(1));
     assertQuery("hashtag:\"# #a tag\"", changes.get(1));
-  }
-
-  @Test
-  public void byHashtagWithoutNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-
-    notesMigration.setWriteChanges(true);
-    notesMigration.setReadChanges(true);
-    db.close();
-    db = schemaFactory.open();
-    List<Change> changes;
-    try {
-      changes = setUpHashtagChanges();
-      notesMigration.setWriteChanges(false);
-      notesMigration.setReadChanges(false);
-    } finally {
-      db.close();
-    }
-    db = schemaFactory.open();
-    for (Change c : changes) {
-      indexer.index(db, c); // Reindex without hashtag field.
-    }
-    assertQuery("hashtag:foo");
-    assertQuery("hashtag:bar");
-    assertQuery("hashtag:\" bar \"");
-    assertQuery("hashtag:\"a tag\"");
-    assertQuery("hashtag:\" a tag \"");
-    assertQuery("hashtag:#foo");
-    assertQuery("hashtag:\"# #foo\"");
+    assertQuery("hashtag:acamelcasetag", changes.get(1));
+    assertQuery("hashtag:ACamelCaseTAg", changes.get(1));
   }
 
   @Test
@@ -1567,7 +1811,7 @@
     Change change4 = insert(repo, ins4);
     ReviewInput ri4 = new ReviewInput();
     ri4.message = "toplevel";
-    ri4.labels = ImmutableMap.<String, Short>of("Code-Review", (short) 1);
+    ri4.labels = ImmutableMap.of("Code-Review", (short) 1);
     gApi.changes().id(change4.getId().get()).current().review(ri4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
@@ -1588,6 +1832,8 @@
     Change[] expected = new Change[] {change6, change5, change4, change3, change2, change1};
     assertQuery("user@example.com", expected);
     assertQuery("repo", expected);
+
+    assertQuery("Code-Review:+1", change4);
   }
 
   @Test
@@ -1615,6 +1861,10 @@
         accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     assertQuery(q + " visibleto:" + user2.get(), change1);
 
+    String g1 = createGroup("group1", "Administrators");
+    gApi.groups().id(g1).addMembers("anotheruser");
+    assertQuery(q + " visibleto:" + g1, change1);
+
     requestContext.setContext(
         newRequestContext(
             accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId()));
@@ -1622,6 +1872,24 @@
   }
 
   @Test
+  public void visibleToSelf() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    gApi.changes().id(change2.getChangeId()).setPrivate(true, "private");
+
+    String q = "project:repo";
+    assertQuery(q + " visibleto:self", change2, change1);
+    assertQuery(q + " visibleto:me", change2, change1);
+
+    // Anonymous user cannot see first user's private change.
+    requestContext.setContext(anonymousUserProvider::get);
+    assertQuery(q + " visibleto:self", change1);
+    assertQuery(q + " visibleto:me", change1);
+  }
+
+  @Test
   public void byCommentBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
@@ -1635,9 +1903,7 @@
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
     comment.line = 1;
     comment.message = "inline";
-    input.comments =
-        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
-            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
+    input.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
     gApi.changes().id(change1.getId().get()).current().review(input);
 
     input = new ReviewInput();
@@ -1678,9 +1944,7 @@
 
   @Test
   public void byDraftByExcludesZombieDrafts() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     Change change = insert(repo, newChange(repo));
     Change.Id id = change.getId();
@@ -1694,36 +1958,25 @@
     assertQuery("draftby:" + userId, change);
     assertQuery("commentby:" + userId);
 
-    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
+    try (TestRepository<Repo> allUsers =
+        new TestRepository<>(repoManager.openRepository(allUsersName))) {
+      Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
+      assertThat(draftsRef).isNotNull();
 
-    Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
-    assertThat(draftsRef).isNotNull();
+      ReviewInput rin = ReviewInput.dislike();
+      rin.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+      gApi.changes().id(id.get()).current().review(rin);
 
-    ReviewInput rin = ReviewInput.dislike();
-    rin.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
-    gApi.changes().id(id.get()).current().review(rin);
+      assertQuery("draftby:" + userId);
+      assertQuery("commentby:" + userId, change);
+      assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNull();
 
-    assertQuery("draftby:" + userId);
-    assertQuery("commentby:" + userId, change);
-    assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNull();
-
-    // Re-add drafts ref and ensure it gets filtered out during indexing.
-    allUsers.update(draftsRef.getName(), draftsRef.getObjectId());
-    assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNotNull();
-
-    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB
-        && !notesMigration.disableChangeReviewDb()) {
-      // Record draft ref in noteDbState as well.
-      ReviewDb db = ReviewDbUtil.unwrapDb(this.db);
-      change = db.changes().get(id);
-      NoteDbChangeState.applyDelta(
-          change,
-          NoteDbChangeState.Delta.create(
-              id, Optional.empty(), ImmutableMap.of(userId, draftsRef.getObjectId())));
-      db.changes().update(Collections.singleton(change));
+      // Re-add drafts ref and ensure it gets filtered out during indexing.
+      allUsers.update(draftsRef.getName(), draftsRef.getObjectId());
+      assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNotNull();
     }
 
-    indexer.index(db, project, id);
+    indexer.index(project, id);
     assertQuery("draftby:" + userId);
   }
 
@@ -1816,9 +2069,7 @@
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
     comment.line = 1;
     comment.message = "inline";
-    input.comments =
-        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
-            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
+    input.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
     gApi.changes().id(change2.getId().get()).current().review(input);
 
     assertQuery("from:" + userId.get(), change2, change1);
@@ -1903,7 +2154,7 @@
     actual = assertQuery(newQuery("-is:reviewed").withOption(REVIEWED), change1);
     assertThat(actual.get(0).reviewed).isNull();
 
-    actual = assertQuery("reviewedby:" + userId.get());
+    assertQuery("reviewedby:" + userId.get());
 
     actual =
         assertQuery(newQuery("reviewedby:" + user2.get()).withOption(REVIEWED), change3, change2);
@@ -1937,17 +2188,10 @@
     assertQuery("reviewer:self", change3);
 
     requestContext.setContext(newRequestContext(user1));
-    if (notesMigration.readChanges()) {
-      assertQuery("reviewer:" + user1, change1);
-      assertQuery("cc:" + user1, change2);
-      assertQuery("is:cc", change2);
-      assertQuery("cc:self", change2);
-    } else {
-      assertQuery("reviewer:" + user1, change2, change1);
-      assertQuery("cc:" + user1);
-      assertQuery("is:cc");
-      assertQuery("cc:self");
-    }
+    assertQuery("reviewer:" + user1, change1);
+    assertQuery("cc:" + user1, change2);
+    assertQuery("is:cc", change2);
+    assertQuery("cc:self", change2);
   }
 
   @Test
@@ -2002,45 +2246,25 @@
     gApi.groups().id(group).addMembers(user2.toString(), user3.toString());
 
     List<String> members =
-        gApi.groups()
-            .id(group)
-            .members()
-            .stream()
+        gApi.groups().id(group).members().stream()
             .map(a -> a._accountId.toString())
             .collect(toList());
     assertThat(members).contains(user2.toString());
 
-    if (notesMigration.readChanges()) {
-      // CC and REVIEWER are separate in NoteDB
-      assertQuery("reviewerin:\"Registered Users\"", change2, change1);
-      assertQuery("reviewerin:" + group, change2);
-    } else {
-      // CC and REVIEWER are the same in ReviewDb
-      assertQuery("reviewerin:\"Registered Users\"", change3, change2, change1);
-      assertQuery("reviewerin:" + group, change3, change2);
-    }
+    assertQuery("reviewerin:\"Registered Users\"", change2, change1);
+    assertQuery("reviewerin:" + group, change2);
 
     gApi.changes().id(change2.getId().get()).current().review(ReviewInput.approve());
     gApi.changes().id(change2.getId().get()).current().submit();
 
-    if (notesMigration.readChanges()) {
-      // CC and REVIEWER are separate in NoteDB
-      assertQuery("reviewerin:" + group, change2);
-      assertQuery("project:repo reviewerin:" + group, change2);
-      assertQuery("status:merged reviewerin:" + group, change2);
-    } else {
-      // CC and REVIEWER are the same in ReviewDb
-      assertQuery("reviewerin:" + group, change2, change3);
-      assertQuery("project:repo reviewerin:" + group, change2, change3);
-      assertQuery("status:merged reviewerin:" + group, change2);
-    }
+    assertQuery("reviewerin:" + group, change2);
+    assertQuery("project:repo reviewerin:" + group, change2);
+    assertQuery("status:merged reviewerin:" + group, change2);
   }
 
   @Test
   public void reviewerAndCcByEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -2086,9 +2310,7 @@
 
   @Test
   public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -2223,7 +2445,7 @@
   public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ObjectId missing =
-        repo.branch(new PatchSet.Id(new Change.Id(987654), 1).toRefName())
+        repo.branch(PatchSet.id(Change.id(987654), 1).toRefName())
             .commit()
             .message("No change for this commit")
             .insertChangeId()
@@ -2238,7 +2460,7 @@
     List<String> shas = new ArrayList<>(n + extra.size());
     extra.forEach(i -> shas.add(i.name()));
     List<Integer> expectedIds = new ArrayList<>(n);
-    Branch.NameKey dest = null;
+    BranchNameKey dest = null;
     for (int i = 0; i < n; i++) {
       ChangeInserter ins = newChange(repo);
       insert(repo, ins);
@@ -2251,82 +2473,23 @@
 
     for (int i = 1; i <= 11; i++) {
       Iterable<ChangeData> cds =
-          queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), db, dest, shas, i);
+          queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), dest, shas, i);
       Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
       String name = "limit " + i;
-      assertThat(ids).named(name).hasSize(n);
-      assertThat(ids).named(name).containsExactlyElementsIn(expectedIds);
+      assertWithMessage(name).that(ids).hasSize(n);
+      assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
     }
   }
 
   @Test
-  public void prepopulatedFields() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
-
-    db = new DisabledReviewDb();
-    requestContext.setContext(newRequestContext(userId));
-    // Use QueryProcessor directly instead of API so we get ChangeDatas back.
-    List<ChangeData> cds =
-        queryProcessorProvider
-            .get()
-            .query(queryBuilder.parse(change.getId().toString()))
-            .entities();
-    assertThat(cds).hasSize(1);
-
-    ChangeData cd = cds.get(0);
-    cd.change();
-    cd.patchSets();
-    cd.currentApprovals();
-    cd.changedLines();
-    cd.reviewedBy();
-    cd.reviewers();
-    cd.unresolvedCommentCount();
-
-    // TODO(dborowitz): Swap out GitRepositoryManager somehow? Will probably be
-    // necessary for NoteDb anyway.
-    cd.isMergeable();
-
-    exception.expect(DisabledReviewDb.Disabled.class);
-    cd.messages();
-  }
-
-  @Test
-  public void prepopulateOnlyRequestedFields() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
-
-    db = new DisabledReviewDb();
-    requestContext.setContext(newRequestContext(userId));
-    // Use QueryProcessor directly instead of API so we get ChangeDatas back.
-    List<ChangeData> cds =
-        queryProcessorProvider
-            .get()
-            .setRequestedFields(
-                ImmutableSet.of(ChangeField.PATCH_SET.getName(), ChangeField.CHANGE.getName()))
-            .query(queryBuilder.parse(change.getId().toString()))
-            .entities();
-    assertThat(cds).hasSize(1);
-
-    ChangeData cd = cds.get(0);
-    cd.change();
-    cd.patchSets();
-
-    exception.expect(DisabledReviewDb.Disabled.class);
-    cd.currentApprovals();
-  }
-
-  @Test
   public void reindexIfStale() throws Exception {
     Account.Id user = createAccount("user");
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     Change change = insert(repo, newChange(repo));
     String changeId = change.getKey().get();
-    ChangeNotes notes = notesFactory.create(db, change.getProject(), change.getId());
-    PatchSet ps = psUtil.get(db, notes, change.currentPatchSetId());
+    ChangeNotes notes = notesFactory.create(change.getProject(), change.getId());
+    PatchSet ps = psUtil.get(notes, change.currentPatchSetId());
 
     requestContext.setContext(newRequestContext(user));
     gApi.changes().id(changeId).edit().create();
@@ -2334,8 +2497,7 @@
     assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
 
     // Delete edit ref behind index's back.
-    RefUpdate ru =
-        repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.getId()));
+    RefUpdate ru = repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.id()));
     ru.setForceUpdate(true);
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
 
@@ -2346,93 +2508,6 @@
   }
 
   @Test
-  public void refStateFields() throws Exception {
-    // This test method manages primary storage manually.
-    assume().that(notesMigration.changePrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-    Account.Id user = createAccount("user");
-    Project.NameKey project = new Project.NameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    String path = "file";
-    RevCommit commit = repo.parseBody(repo.commit().message("one").add(path, "contents").create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
-    Change.Id id = change.getId();
-    int c = id.get();
-    String changeId = change.getKey().get();
-    requestContext.setContext(newRequestContext(user));
-
-    // Ensure one of each type of supported ref is present for the change. If
-    // any more refs are added, update this test to reflect them.
-
-    // Edit
-    gApi.changes().id(changeId).edit().create();
-
-    // Star
-    gApi.accounts().self().starChange(change.getId().toString());
-
-    if (notesMigration.readChanges()) {
-      // Robot comment.
-      ReviewInput rin = new ReviewInput();
-      RobotCommentInput rcin = new RobotCommentInput();
-      rcin.robotId = "happyRobot";
-      rcin.robotRunId = "1";
-      rcin.line = 1;
-      rcin.message = "nit: trailing whitespace";
-      rcin.path = path;
-      rin.robotComments = ImmutableMap.of(path, ImmutableList.of(rcin));
-      gApi.changes().id(c).current().review(rin);
-    }
-
-    // Draft.
-    DraftInput din = new DraftInput();
-    din.path = path;
-    din.line = 1;
-    din.message = "draft";
-    gApi.changes().id(c).current().createDraft(din);
-
-    if (notesMigration.readChanges()) {
-      // Force NoteDb primary.
-      change = ReviewDbUtil.unwrapDb(db).changes().get(id);
-      change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-      ReviewDbUtil.unwrapDb(db).changes().update(Collections.singleton(change));
-      indexer.index(db, change);
-    }
-
-    QueryOptions opts =
-        IndexedChangeQuery.createOptions(indexConfig, 0, 1, StalenessChecker.FIELDS);
-    ChangeData cd = indexes.getSearchIndex().get(id, opts).get();
-
-    String cs = RefNames.shard(c);
-    int u = user.get();
-    String us = RefNames.shard(u);
-
-    List<String> expectedStates =
-        Lists.newArrayList(
-            "repo:refs/users/" + us + "/edit-" + c + "/1",
-            "All-Users:refs/starred-changes/" + cs + "/" + u);
-    if (notesMigration.readChanges()) {
-      expectedStates.add("repo:refs/changes/" + cs + "/meta");
-      expectedStates.add("repo:refs/changes/" + cs + "/robot-comments");
-      expectedStates.add("All-Users:refs/draft-comments/" + cs + "/" + u);
-    }
-    assertThat(
-            cd.getRefStates()
-                .stream()
-                .map(String::new)
-                // Omit SHA-1, we're just concerned with the project/ref names.
-                .map(s -> s.substring(0, s.lastIndexOf(':')))
-                .collect(toList()))
-        .containsExactlyElementsIn(expectedStates);
-
-    List<String> expectedPatterns = Lists.newArrayList("repo:refs/users/*/edit-" + c + "/*");
-    expectedPatterns.add("All-Users:refs/starred-changes/" + cs + "/*");
-    if (notesMigration.readChanges()) {
-      expectedPatterns.add("All-Users:refs/draft-comments/" + cs + "/*");
-    }
-    assertThat(cd.getRefStatePatterns().stream().map(String::new).collect(toList()))
-        .containsExactlyElementsIn(expectedPatterns);
-  }
-
-  @Test
   public void watched() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
@@ -2525,15 +2600,17 @@
     gApi.changes().id(changeToRevert.id).current().submit();
 
     ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
-    assertQueryByIds(
-        "revertof:" + changeToRevert._number, new Change.Id(changeThatReverts._number));
+    assertQueryByIds("revertof:" + changeToRevert._number, Change.id(changeThatReverts._number));
   }
 
   /** Change builder for helping in tests for dashboard sections. */
   protected class DashboardChangeState {
     private final Account.Id ownerId;
     private final List<Account.Id> reviewedBy;
+    private final List<Account.Id> cced;
     private final List<Account.Id> ignoredBy;
+    private final List<Account.Id> draftCommentBy;
+    private final List<Account.Id> deleteDraftCommentBy;
     private boolean wip;
     private boolean abandoned;
     @Nullable private Account.Id mergedBy;
@@ -2544,7 +2621,10 @@
     DashboardChangeState(Account.Id ownerId) {
       this.ownerId = ownerId;
       reviewedBy = new ArrayList<>();
+      cced = new ArrayList<>();
       ignoredBy = new ArrayList<>();
+      draftCommentBy = new ArrayList<>();
+      deleteDraftCommentBy = new ArrayList<>();
     }
 
     DashboardChangeState assignTo(Account.Id assigneeId) {
@@ -2577,6 +2657,21 @@
       return this;
     }
 
+    DashboardChangeState addCc(Account.Id ccId) {
+      cced.add(ccId);
+      return this;
+    }
+
+    DashboardChangeState draftCommentBy(Account.Id commenterId) {
+      draftCommentBy.add(commenterId);
+      return this;
+    }
+
+    DashboardChangeState draftAndDeleteCommentBy(Account.Id commenterId) {
+      deleteDraftCommentBy.add(commenterId);
+      return this;
+    }
+
     DashboardChangeState create(TestRepository<Repo> repo) throws Exception {
       requestContext.setContext(newRequestContext(ownerId));
       Change change = insert(repo, newChange(repo), ownerId);
@@ -2596,11 +2691,28 @@
       for (Account.Id reviewerId : reviewedBy) {
         cApi.addReviewer("" + reviewerId);
       }
+      for (Account.Id reviewerId : cced) {
+        AddReviewerInput in = new AddReviewerInput();
+        in.reviewer = reviewerId.toString();
+        in.state = ReviewerState.CC;
+        cApi.addReviewer(in);
+      }
       for (Account.Id ignorerId : ignoredBy) {
         requestContext.setContext(newRequestContext(ignorerId));
         StarsInput in = new StarsInput(new HashSet<>(Arrays.asList("ignore")));
         gApi.accounts().self().setStars("" + id, in);
       }
+      DraftInput in = new DraftInput();
+      in.path = Patch.COMMIT_MSG;
+      in.message = "message";
+      for (Account.Id commenterId : draftCommentBy) {
+        requestContext.setContext(newRequestContext(commenterId));
+        gApi.changes().id(change.getChangeId()).current().createDraft(in);
+      }
+      for (Account.Id commenterId : deleteDraftCommentBy) {
+        requestContext.setContext(newRequestContext(commenterId));
+        gApi.changes().id(change.getChangeId()).current().createDraft(in).delete();
+      }
       if (mergedBy != null) {
         requestContext.setContext(newRequestContext(mergedBy));
         cApi = gApi.changes().id(change.getChangeId());
@@ -2622,6 +2734,51 @@
   }
 
   @Test
+  public void dashboardHasUnpublishedDrafts() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id otherAccountId = createAccount("other");
+    DashboardChangeState hasUnpublishedDraft =
+        new DashboardChangeState(otherAccountId).draftCommentBy(user.getAccountId()).create(repo);
+
+    // Create changes that should not be returned by query.
+    new DashboardChangeState(user.getAccountId()).create(repo);
+    new DashboardChangeState(user.getAccountId()).draftCommentBy(otherAccountId).create(repo);
+    new DashboardChangeState(user.getAccountId())
+        .draftAndDeleteCommentBy(user.getAccountId())
+        .create(repo);
+
+    assertDashboardQuery("self", DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY, hasUnpublishedDraft);
+  }
+
+  @Test
+  public void dashboardAssignedReviews() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id otherAccountId = createAccount("other");
+    DashboardChangeState otherOpenWip =
+        new DashboardChangeState(otherAccountId).wip().assignTo(user.getAccountId()).create(repo);
+    DashboardChangeState selfOpenWip =
+        new DashboardChangeState(user.getAccountId())
+            .wip()
+            .assignTo(user.getAccountId())
+            .create(repo);
+
+    // Create changes that should not be returned by query.
+    new DashboardChangeState(user.getAccountId()).assignTo(user.getAccountId()).abandon();
+    new DashboardChangeState(user.getAccountId())
+        .assignTo(user.getAccountId())
+        .ignoreBy(user.getAccountId());
+    new DashboardChangeState(user.getAccountId())
+        .assignTo(user.getAccountId())
+        .mergeBy(user.getAccountId());
+
+    assertDashboardQuery("self", DASHBOARD_ASSIGNED_QUERY, selfOpenWip, otherOpenWip);
+
+    // Viewing another user's dashboard.
+    requestContext.setContext(newRequestContext(otherAccountId));
+    assertDashboardQuery(user.getUserName().get(), DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
+  }
+
+  @Test
   public void dashboardWorkInProgressReviews() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     DashboardChangeState ownedOpenWip =
@@ -2721,6 +2878,11 @@
             .ignoreBy(user.getAccountId())
             .mergeBy(user.getAccountId())
             .create(repo);
+    DashboardChangeState mergedCced =
+        new DashboardChangeState(otherAccountId)
+            .addCc(user.getAccountId())
+            .mergeBy(user.getAccountId())
+            .create(repo);
     DashboardChangeState mergedAssigned =
         new DashboardChangeState(otherAccountId)
             .assignTo(user.getAccountId())
@@ -2807,6 +2969,7 @@
         abandonedOwnedIgnoredByOther,
         abandonedOwned,
         mergedAssigned,
+        mergedCced,
         mergedReviewing,
         mergedOwnedIgnoredByOther,
         mergedOwned);
@@ -2825,6 +2988,7 @@
         abandonedOwned,
         mergedAssignedIgnoredByUser,
         mergedAssigned,
+        mergedCced,
         mergedReviewingIgnoredByUser,
         mergedReviewing,
         mergedOwned);
@@ -2865,16 +3029,18 @@
     String destination4 = "refs/heads/master\trepo3";
     String destination5 = "refs/heads/other\trepo1";
 
-    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
-    String refsUsers = RefNames.refsUsers(userId);
-    allUsers.branch(refsUsers).commit().add("destinations/destination1", destination1).create();
-    allUsers.branch(refsUsers).commit().add("destinations/destination2", destination2).create();
-    allUsers.branch(refsUsers).commit().add("destinations/destination3", destination3).create();
-    allUsers.branch(refsUsers).commit().add("destinations/destination4", destination4).create();
-    allUsers.branch(refsUsers).commit().add("destinations/destination5", destination5).create();
+    try (TestRepository<Repo> allUsers =
+        new TestRepository<>(repoManager.openRepository(allUsersName))) {
+      String refsUsers = RefNames.refsUsers(userId);
+      allUsers.branch(refsUsers).commit().add("destinations/destination1", destination1).create();
+      allUsers.branch(refsUsers).commit().add("destinations/destination2", destination2).create();
+      allUsers.branch(refsUsers).commit().add("destinations/destination3", destination3).create();
+      allUsers.branch(refsUsers).commit().add("destinations/destination4", destination4).create();
+      allUsers.branch(refsUsers).commit().add("destinations/destination5", destination5).create();
 
-    Ref userRef = allUsers.getRepository().exactRef(refsUsers);
-    assertThat(userRef).isNotNull();
+      Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+      assertThat(userRef).isNotNull();
+    }
 
     assertQuery("destination:destination1", change1);
     assertQuery("destination:destination2", change2);
@@ -2895,12 +3061,14 @@
             + "query3\tproject:repo branch:stable\n"
             + "query4\tproject:repo branch:other";
 
-    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
-    String refsUsers = RefNames.refsUsers(userId);
-    allUsers.branch(refsUsers).commit().add("queries", queries).create();
+    try (TestRepository<Repo> allUsers =
+        new TestRepository<>(repoManager.openRepository(allUsersName))) {
+      String refsUsers = RefNames.refsUsers(userId);
+      allUsers.branch(refsUsers).commit().add("queries", queries).create();
 
-    Ref userRef = allUsers.getRepository().exactRef(refsUsers);
-    assertThat(userRef).isNotNull();
+      Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+      assertThat(userRef).isNotNull();
+    }
 
     assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
 
@@ -2933,6 +3101,71 @@
     assertQuery(query);
   }
 
+  @Test
+  public void byUrlEncodedProject() throws Exception {
+    TestRepository<Repo> repo = createProject("repo+foo");
+    Change change = insert(repo, newChange(repo));
+    assertQuery("project:repo+foo", change);
+  }
+
+  @Test
+  public void selfFailsForAnonymousUser() throws Exception {
+    for (String query : ImmutableList.of("assignee:self", "starredby:self", "is:starred")) {
+      assertQuery(query);
+      RequestContext oldContext = requestContext.setContext(anonymousUserProvider::get);
+
+      try {
+        requestContext.setContext(anonymousUserProvider::get);
+        assertThatAuthException(query)
+            .hasMessageThat()
+            .isEqualTo("Must be signed-in to use this operator");
+      } finally {
+        requestContext.setContext(oldContext);
+      }
+    }
+  }
+
+  @Test
+  public void selfSucceedsForInactiveAccount() throws Exception {
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
+    AssigneeInput ain = new AssigneeInput();
+    ain.assignee = user2.toString();
+    gApi.changes().id(change.getId().get()).setAssignee(ain);
+
+    RequestContext adminContext = requestContext.setContext(newRequestContext(user2));
+    assertQuery("assignee:self", change);
+
+    requestContext.setContext(adminContext);
+    gApi.accounts().id(user2.get()).setActive(false);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("assignee:self", change);
+  }
+
+  @Test
+  public void none() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
+
+    assertQuery(ChangeIndexPredicate.none());
+
+    for (Predicate<ChangeData> matchingOneChange :
+        ImmutableList.of(
+            // One index query, one post-filtering query.
+            queryBuilder.parse(change.getId().toString()),
+            queryBuilder.parse("ownerin:Administrators"))) {
+      assertQuery(matchingOneChange, change);
+      assertQuery(Predicate.or(ChangeIndexPredicate.none(), matchingOneChange), change);
+      assertQuery(Predicate.and(ChangeIndexPredicate.none(), matchingOneChange));
+      assertQuery(
+          Predicate.and(Predicate.not(ChangeIndexPredicate.none()), matchingOneChange), change);
+    }
+  }
+
   protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
     return newChange(repo, null, null, null, null, false);
   }
@@ -2942,6 +3175,15 @@
     return newChange(repo, commit, null, null, null, false);
   }
 
+  protected ChangeInserter newChangeWithFiles(TestRepository<Repo> repo, String... paths)
+      throws Exception {
+    TestRepository<?>.CommitBuilder b = repo.commit().message("Change with files");
+    for (String path : paths) {
+      b.add(path, "contents of " + path);
+    }
+    return newChangeForCommit(repo, repo.parseBody(b.create()));
+  }
+
   protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
       throws Exception {
     return newChange(repo, null, branch, null, null, false);
@@ -2978,7 +3220,7 @@
       branch = "refs/heads/" + branch;
     }
 
-    Change.Id id = new Change.Id(seq.nextChangeId());
+    Change.Id id = Change.id(seq.nextChangeId());
     ChangeInserter ins =
         changeFactory
             .create(id, commit, branch)
@@ -3005,10 +3247,10 @@
       Timestamp createdOn)
       throws Exception {
     Project.NameKey project =
-        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
+        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
     Account.Id ownerId = owner != null ? owner : userId;
     IdentifiedUser user = userFactory.create(ownerId);
-    try (BatchUpdate bu = updateFactory.create(db, project, user, createdOn)) {
+    try (BatchUpdate bu = updateFactory.create(project, user, createdOn)) {
       bu.insertChange(ins);
       bu.execute();
       return ins.getChange();
@@ -3024,15 +3266,15 @@
 
     PatchSetInserter inserter =
         patchSetFactory
-            .create(changeNotesFactory.createChecked(db, c), new PatchSet.Id(c.getId(), n), commit)
-            .setNotify(NotifyHandling.NONE)
+            .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
             .setFireRevisionCreated(false)
             .setValidate(false);
-    try (BatchUpdate bu = updateFactory.create(db, c.getProject(), user, TimeUtil.nowTs());
+    try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.nowTs());
         ObjectInserter oi = repo.getRepository().newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk rw = new RevWalk(reader)) {
       bu.setRepository(repo.getRepository(), rw, oi);
+      bu.setNotify(NotifyResolver.Result.none());
       bu.addOp(c.getId(), inserter);
       bu.execute();
     }
@@ -3053,9 +3295,18 @@
     }
   }
 
+  protected ThrowableSubject assertThatAuthException(Object query) throws Exception {
+    try {
+      newQuery(query).get();
+      throw new AssertionError("expected AuthException for query: " + query);
+    } catch (AuthException e) {
+      return assertThat(e);
+    }
+  }
+
   protected TestRepository<Repo> createProject(String name) throws Exception {
     gApi.projects().create(name).get();
-    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
+    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
   protected TestRepository<Repo> createProject(String name, String parent) throws Exception {
@@ -3063,7 +3314,7 @@
     input.name = name;
     input.parent = parent;
     gApi.projects().create(input).get();
-    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
+    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
   protected QueryRequest newQuery(Object query) {
@@ -3087,22 +3338,33 @@
       throws Exception {
     List<ChangeInfo> result = query.get();
     Iterable<Change.Id> ids = ids(result);
-    assertThat(ids)
-        .named(format(query, ids, changes))
+    assertWithMessage(format(query.getQuery(), ids, changes))
+        .that(ids)
         .containsExactlyElementsIn(Arrays.asList(changes))
         .inOrder();
     return result;
   }
 
-  private String format(
-      QueryRequest query, Iterable<Change.Id> actualIds, Change.Id... expectedChanges)
+  protected void assertQuery(Predicate<ChangeData> predicate, Change... changes) throws Exception {
+    ImmutableList<Change.Id> actualIds =
+        queryProvider.get().query(predicate).stream()
+            .map(ChangeData::getId)
+            .collect(toImmutableList());
+    Change.Id[] expectedIds = Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new);
+    assertWithMessage(format(predicate.toString(), actualIds, expectedIds))
+        .that(actualIds)
+        .containsExactlyElementsIn(expectedIds)
+        .inOrder();
+  }
+
+  private String format(String query, Iterable<Change.Id> actualIds, Change.Id... expectedChanges)
       throws RestApiException {
-    StringBuilder b = new StringBuilder();
-    b.append("query '").append(query.getQuery()).append("' with expected changes ");
-    b.append(format(Arrays.asList(expectedChanges)));
-    b.append(" and result ");
-    b.append(format(actualIds));
-    return b.toString();
+    return "query '"
+        + query
+        + "' with expected changes "
+        + format(Arrays.asList(expectedChanges))
+        + " and result "
+        + format(actualIds);
   }
 
   private String format(Iterable<Change.Id> changeIds) throws RestApiException {
@@ -3121,7 +3383,7 @@
           .append(c.changeId)
           .append("), ")
           .append("dest=")
-          .append(new Branch.NameKey(new Project.NameKey(c.project), c.branch))
+          .append(BranchNameKey.create(Project.nameKey(c.project), c.branch))
           .append(", ")
           .append("status=")
           .append(c.status)
@@ -3142,7 +3404,7 @@
   }
 
   protected static Iterable<Change.Id> ids(Iterable<ChangeInfo> changes) {
-    return Streams.stream(changes).map(c -> new Change.Id(c._number)).collect(toList());
+    return Streams.stream(changes).map(c -> Change.id(c._number)).collect(toList());
   }
 
   protected static long lastUpdatedMs(Change c) {
@@ -3155,9 +3417,7 @@
     comment.line = 1;
     comment.message = message;
     comment.unresolved = unresolved;
-    input.comments =
-        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
-            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
+    input.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
     gApi.changes().id(changeId).current().review(input);
   }
 
@@ -3181,17 +3441,24 @@
   }
 
   protected void assertMissingField(FieldDef<ChangeData, ?> field) {
-    assertThat(getSchema().hasField(field))
-        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
+        .that(getSchema().hasField(field))
         .isFalse();
   }
 
-  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+  protected void assertFailingQuery(String query) throws Exception {
+    assertFailingQuery(query, null);
+  }
+
+  protected void assertFailingQuery(String query, @Nullable String expectedMessage)
+      throws Exception {
     try {
       assertQuery(query);
       fail("expected BadRequestException for query '" + query + "'");
     } catch (BadRequestException e) {
-      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+      if (expectedMessage != null) {
+        assertThat(e.getMessage()).isEqualTo(expectedMessage);
+      }
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 09e3243..a128593 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -4,11 +4,12 @@
 
 java_library(
     name = "abstract_query_tests",
-    testonly = 1,
+    testonly = True,
     srcs = ABSTRACT_QUERY_TEST,
     visibility = ["//visibility:public"],
     runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
@@ -18,9 +19,9 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
@@ -42,7 +43,6 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
@@ -59,13 +59,15 @@
     ),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/testing",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
         "//lib/truth:truth-proto-extension",
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index c8637cd..0f7292d 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -21,21 +21,32 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class ChangeDataTest {
   @Test
   public void setPatchSetsClearsCurrentPatchSet() throws Exception {
-    Project.NameKey project = new Project.NameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
-    cd.setChange(TestChanges.newChange(project, new Account.Id(1000)));
+    Project.NameKey project = Project.nameKey("project");
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    cd.setChange(TestChanges.newChange(project, Account.id(1000)));
     PatchSet curr1 = cd.currentPatchSet();
-    int currId = curr1.getId().get();
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 1));
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 2));
+    int currId = curr1.id().get();
+    PatchSet ps1 = newPatchSet(cd.getId(), currId + 1);
+    PatchSet ps2 = newPatchSet(cd.getId(), currId + 2);
     cd.setPatchSets(ImmutableList.of(ps1, ps2));
     PatchSet curr2 = cd.currentPatchSet();
-    assertThat(curr2).isNotSameAs(curr1);
+    assertThat(curr2).isNotSameInstanceAs(curr1);
+  }
+
+  private static PatchSet newPatchSet(Change.Id changeId, int num) {
+    return PatchSet.builder()
+        .id(PatchSet.id(changeId, num))
+        .commitId(ObjectId.zeroId())
+        .uploader(Account.id(1234))
+        .createdOn(TimeUtil.nowTs())
+        .build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
index b87bbf7..00c1a80 100644
--- a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
@@ -18,11 +18,12 @@
 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.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.ConflictKeyProto;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -68,11 +69,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")
@@ -81,10 +82,7 @@
     assertThat(ConflictKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
   }
 
-  /**
-   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
-   * what to do if this test fails.
-   */
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methods() throws Exception {
     assertThatSerializedClass(ConflictKey.class)
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 1dfe7df..621f474 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
@@ -49,7 +52,7 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig));
   }
 
   @Test
@@ -76,8 +79,10 @@
     Change change1 = insert(repo, newChange(repo), userId);
     String nameEmail = user.asIdentifiedUser().getNameEmail();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Cannot create full-text query with value: \\");
-    assertQuery("owner: \"" + nameEmail + "\"\\", change1);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> assertQuery("owner: \"" + nameEmail + "\"\\", change1));
+    assertThat(thrown).hasMessageThat().contains("Cannot create full-text query with value: \\");
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java b/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
index a13a8f7..ac528f2e 100644
--- a/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
+++ b/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
@@ -19,13 +19,13 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
 import java.util.Arrays;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class RegexPathPredicateTest {
   @Test
-  public void prefixOnlyOptimization() throws OrmException {
+  public void prefixOnlyOptimization() {
     RegexPathPredicate p = predicate("^a/b/.*");
     assertTrue(p.match(change("a/b/source.c")));
     assertFalse(p.match(change("source.c")));
@@ -35,7 +35,7 @@
   }
 
   @Test
-  public void prefixReducesSearchSpace() throws OrmException {
+  public void prefixReducesSearchSpace() {
     RegexPathPredicate p = predicate("^a/b/.*\\.[ch]");
     assertTrue(p.match(change("a/b/source.c")));
     assertFalse(p.match(change("a/b/source.res")));
@@ -45,7 +45,7 @@
   }
 
   @Test
-  public void fileExtension_Constant() throws OrmException {
+  public void fileExtension_Constant() {
     RegexPathPredicate p = predicate("^.*\\.res");
     assertTrue(p.match(change("test.res")));
     assertTrue(p.match(change("foo/bar/test.res")));
@@ -53,7 +53,7 @@
   }
 
   @Test
-  public void fileExtension_CharacterGroup() throws OrmException {
+  public void fileExtension_CharacterGroup() {
     RegexPathPredicate p = predicate("^.*\\.[ch]");
     assertTrue(p.match(change("test.c")));
     assertTrue(p.match(change("test.h")));
@@ -61,7 +61,7 @@
   }
 
   @Test
-  public void endOfString() throws OrmException {
+  public void endOfString() {
     assertTrue(predicate("^a$").match(change("a")));
     assertFalse(predicate("^a$").match(change("a$")));
 
@@ -70,7 +70,7 @@
   }
 
   @Test
-  public void exactMatch() throws OrmException {
+  public void exactMatch() {
     RegexPathPredicate p = predicate("^foo.c");
     assertTrue(p.match(change("foo.c")));
     assertFalse(p.match(change("foo.cc")));
@@ -81,9 +81,10 @@
     return new RegexPathPredicate(pattern);
   }
 
-  private static ChangeData change(String... files) throws OrmException {
+  private static ChangeData change(String... files) {
     Arrays.sort(files);
-    ChangeData cd = ChangeData.createForTest(new Project.NameKey("project"), new Change.Id(1), 1);
+    ChangeData cd =
+        ChangeData.createForTest(Project.nameKey("project"), Change.id(1), 1, ObjectId.zeroId());
     cd.setCurrentFilePaths(Arrays.asList(files));
     return cd;
   }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 750813a..3b13041 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.query.group;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
@@ -34,7 +36,6 @@
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -58,11 +59,10 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.GerritServerTests;
-import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 import java.util.Arrays;
 import java.util.Iterator;
 import java.util.List;
@@ -71,10 +71,13 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 
 @Ignore
 public abstract class AbstractQueryGroupsTest extends GerritServerTests {
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Inject protected Accounts accounts;
 
   @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdate;
@@ -89,8 +92,6 @@
 
   @Inject private Provider<AnonymousUser> anonymousUser;
 
-  @Inject protected InMemoryDatabase schemaFactory;
-
   @Inject protected SchemaCreator schemaCreator;
 
   @Inject protected ThreadLocalRequestContext requestContext;
@@ -109,7 +110,6 @@
 
   protected LifecycleManager lifecycle;
   protected Injector injector;
-  protected ReviewDb db;
   protected AccountInfo currentUserInfo;
   protected CurrentUser user;
 
@@ -129,12 +129,10 @@
   @After
   public void cleanUp() {
     lifecycle.stop();
-    db.close();
   }
 
   protected void setUpDatabase() throws Exception {
-    db = schemaFactory.open();
-    schemaCreator.create(db);
+    schemaCreator.create();
 
     Account.Id userId =
         createAccountOutsideRequestContext("user", "User", "user@example.com", true);
@@ -147,32 +145,11 @@
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
     final CurrentUser requestUser = userFactory.create(requestUserId);
-    return new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return requestUser;
-      }
-
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    };
+    return () -> requestUser;
   }
 
   protected void setAnonymous() {
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return anonymousUser.get();
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
+    requestContext.setContext(anonymousUser::get);
   }
 
   @After
@@ -183,10 +160,6 @@
     if (requestContext != null) {
       requestContext.setContext(null);
     }
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
   }
 
   @Test
@@ -217,7 +190,7 @@
 
   @Test
   public void byInname() throws Exception {
-    String namePart = getSanitizedMethodName();
+    String namePart = testName.getSanitizedMethodName();
     namePart = CharMatcher.is('_').removeFrom(namePart);
 
     GroupInfo group1 = createGroup("group-" + namePart);
@@ -237,9 +210,9 @@
 
     assertQuery("description:non-existing");
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("description operator requires a value");
-    assertQuery("description:\"\"");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("description:\"\""));
+    assertThat(thrown).hasMessageThat().contains("description operator requires a value");
   }
 
   @Test
@@ -348,6 +321,17 @@
   }
 
   @Test
+  public void sortedByUuid() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    GroupInfo group2 = createGroup(name("group2"));
+    GroupInfo group3 = createGroup(name("group3"));
+
+    String query = "uuid:" + group1.id + " OR uuid:" + group2.id + " OR uuid:" + group3.id;
+    // assertQuery sorts the expected groups by UUID
+    assertQuery(newQuery(query), group1, group2, group3);
+  }
+
+  @Test
   public void asAnonymous() throws Exception {
     GroupInfo group = createGroup(name("group"));
 
@@ -362,7 +346,7 @@
 
     // update group in the database so that group index is stale
     String newDescription = "barY";
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group1.id);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid(group1.id);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setDescription(newDescription).build();
     groupsUpdateProvider.get().updateGroupInNoteDb(groupUuid, groupUpdate);
@@ -378,7 +362,7 @@
   @Test
   public void rawDocument() throws Exception {
     GroupInfo group1 = createGroup(name("group1"));
-    AccountGroup.UUID uuid = new AccountGroup.UUID(group1.id);
+    AccountGroup.UUID uuid = AccountGroup.uuid(group1.id);
 
     Optional<FieldBundle> rawFields =
         indexes
@@ -398,7 +382,7 @@
   @Test
   public void byDeletedGroup() throws Exception {
     GroupInfo group = createGroup(name("group"));
-    AccountGroup.UUID uuid = new AccountGroup.UUID(group.id);
+    AccountGroup.UUID uuid = AccountGroup.uuid(group.id);
     String query = "uuid:" + uuid;
     assertQuery(query, group);
 
@@ -481,7 +465,10 @@
       throws Exception {
     List<GroupInfo> result = query.get();
     Iterable<String> uuids = uuids(result);
-    assertThat(uuids).named(format(query, result, groups)).containsExactlyElementsIn(uuids(groups));
+    assertWithMessage(format(query, result, groups))
+        .that(uuids)
+        .containsExactlyElementsIn(uuids(groups))
+        .inOrder();
     return result;
   }
 
@@ -546,7 +533,7 @@
   }
 
   protected static Iterable<String> uuids(List<GroupInfo> groups) {
-    return groups.stream().map(g -> g.id).collect(toList());
+    return groups.stream().map(g -> g.id).sorted().collect(toList());
   }
 
   protected String name(String name) {
@@ -554,7 +541,7 @@
       return null;
     }
 
-    return name + "_" + getSanitizedMethodName();
+    return name + "_" + testName.getSanitizedMethodName();
   }
 
   protected int getSchemaVersion() {
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 0dd16cd..3f147c9 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -4,7 +4,7 @@
 
 java_library(
     name = "abstract_query_tests",
-    testonly = 1,
+    testonly = True,
     srcs = ABSTRACT_QUERY_TEST,
     visibility = ["//visibility:public"],
     deps = [
diff --git a/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
index 83835c1..2a453a0 100644
--- a/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
@@ -43,6 +43,6 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index e34746c..8f13099 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -15,19 +15,32 @@
 package com.google.gerrit.server.query.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.Projects.QueryRequest;
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -38,29 +51,33 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.GerritServerTests;
-import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 import java.util.Arrays;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 
 @Ignore
 public abstract class AbstractQueryProjectsTest extends GerritServerTests {
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Inject protected Accounts accounts;
 
   @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdate;
@@ -75,21 +92,20 @@
 
   @Inject private Provider<AnonymousUser> anonymousUser;
 
-  @Inject protected InMemoryDatabase schemaFactory;
-
   @Inject protected SchemaCreator schemaCreator;
 
   @Inject protected ThreadLocalRequestContext requestContext;
 
   @Inject protected OneOffRequestContext oneOffRequestContext;
 
-  @Inject protected InternalAccountQuery internalAccountQuery;
+  @Inject protected ProjectIndexCollection indexes;
 
   @Inject protected AllProjectsName allProjects;
 
+  @Inject protected AllUsersName allUsers;
+
   protected LifecycleManager lifecycle;
   protected Injector injector;
-  protected ReviewDb db;
   protected AccountInfo currentUserInfo;
   protected CurrentUser user;
 
@@ -109,12 +125,10 @@
   @After
   public void cleanUp() {
     lifecycle.stop();
-    db.close();
   }
 
   protected void setUpDatabase() throws Exception {
-    db = schemaFactory.open();
-    schemaCreator.create(db);
+    schemaCreator.create();
 
     Account.Id userId = createAccount("user", "User", "user@example.com", true);
     user = userFactory.create(userId);
@@ -126,32 +140,11 @@
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
     final CurrentUser requestUser = userFactory.create(requestUserId);
-    return new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return requestUser;
-      }
-
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    };
+    return () -> requestUser;
   }
 
   protected void setAnonymous() {
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return anonymousUser.get();
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
+    requestContext.setContext(anonymousUser::get);
   }
 
   @After
@@ -160,10 +153,6 @@
       lifecycle.stop();
     }
     requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
   }
 
   @Test
@@ -182,13 +171,32 @@
   }
 
   @Test
+  public void byParent() throws Exception {
+    assertQuery("parent:project");
+    ProjectInfo parent = createProject(name("parent"));
+    assertQuery("parent:" + parent.name);
+    ProjectInfo child = createProject(name("child"), parent.name);
+    assertQuery("parent:" + parent.name, child);
+  }
+
+  @Test
+  public void byParentOfAllProjects() throws Exception {
+    Set<String> excludedProjects = ImmutableSet.of(allProjects.get(), allUsers.get());
+    ProjectInfo[] projects =
+        gApi.projects().list().get().stream()
+            .filter(p -> !excludedProjects.contains(p.name))
+            .toArray(s -> new ProjectInfo[s]);
+    assertQuery("parent:" + allProjects.get(), projects);
+  }
+
+  @Test
   public void byInname() throws Exception {
-    String namePart = getSanitizedMethodName();
+    String namePart = testName.getSanitizedMethodName();
     namePart = CharMatcher.is('_').removeFrom(namePart);
 
-    ProjectInfo project1 = createProject(name("project-" + namePart));
-    ProjectInfo project2 = createProject(name("project-" + namePart + "-2"));
-    ProjectInfo project3 = createProject(name("project-" + namePart + "3"));
+    ProjectInfo project1 = createProject(name("project1-" + namePart));
+    ProjectInfo project2 = createProject(name("project2-" + namePart + "-foo"));
+    ProjectInfo project3 = createProject(name("project3-" + namePart + "foo"));
 
     assertQuery("inname:" + namePart, project1, project2, project3);
     assertQuery("inname:" + namePart.toUpperCase(Locale.US), project1, project2, project3);
@@ -205,9 +213,35 @@
 
     assertQuery("description:non-existing");
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("description operator requires a value");
-    assertQuery("description:\"\"");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("description:\"\""));
+    assertThat(thrown).hasMessageThat().contains("description operator requires a value");
+  }
+
+  @Test
+  public void byState() throws Exception {
+    assume().that(getSchemaVersion() >= 2).isTrue();
+
+    ProjectInfo project1 = createProjectWithState(name("project1"), ProjectState.ACTIVE);
+    ProjectInfo project2 = createProjectWithState(name("project2"), ProjectState.READ_ONLY);
+    assertQuery("state:active", project1);
+    assertQuery("state:read-only", project2);
+  }
+
+  @Test
+  public void byState_emptyQuery() throws Exception {
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("state:\"\""));
+    assertThat(thrown).hasMessageThat().contains("state operator requires a value");
+  }
+
+  @Test
+  public void byState_badQuery() throws Exception {
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("state:bla"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("state operator must be either 'active' or 'read-only'");
   }
 
   @Test
@@ -234,7 +268,7 @@
         "name:" + project1.name + " OR name:" + project2.name + " OR name:" + project3.name;
     List<ProjectInfo> result = assertQuery(query, project1, project2, project3);
 
-    result = assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
+    assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
   }
 
   @Test
@@ -251,8 +285,19 @@
   }
 
   @Test
+  public void sortedByName() throws Exception {
+    ProjectInfo projectFoo = createProject("foo-" + name("project1"));
+    ProjectInfo projectBar = createProject("bar-" + name("project2"));
+    ProjectInfo projectBaz = createProject("baz-" + name("project3"));
+
+    String query =
+        "name:" + projectFoo.name + " OR name:" + projectBar.name + " OR name:" + projectBaz.name;
+    assertQuery(newQuery(query), projectBar, projectBaz, projectFoo);
+  }
+
+  @Test
   public void asAnonymous() throws Exception {
-    ProjectInfo project = createProject(name("project"));
+    ProjectInfo project = createProjectRestrictedToRegisteredUsers(name("project"));
 
     setAnonymous();
     assertQuery("name:" + project.name);
@@ -283,6 +328,13 @@
     return gApi.projects().create(in).get();
   }
 
+  protected ProjectInfo createProject(String name, String parent) throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name;
+    in.parent = parent;
+    return gApi.projects().create(in).get();
+  }
+
   protected ProjectInfo createProjectWithDescription(String name, String description)
       throws Exception {
     ProjectInput in = new ProjectInput();
@@ -291,6 +343,29 @@
     return gApi.projects().create(in).get();
   }
 
+  protected ProjectInfo createProjectWithState(String name, ProjectState state) throws Exception {
+    ProjectInfo info = createProject(name);
+    ConfigInput config = new ConfigInput();
+    config.state = state;
+    gApi.projects().name(info.name).config(config);
+    return info;
+  }
+
+  protected ProjectInfo createProjectRestrictedToRegisteredUsers(String name) throws Exception {
+    createProject(name);
+
+    ProjectAccessInput accessInput = new ProjectAccessInput();
+    AccessSectionInfo accessSection = new AccessSectionInfo();
+    PermissionInfo read = new PermissionInfo(null, null);
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false);
+    read.rules = ImmutableMap.of(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions = ImmutableMap.of("read", read);
+    accessInput.add = ImmutableMap.of("refs/*", accessSection);
+    gApi.projects().name(name).access(accessInput);
+
+    return gApi.projects().name(name).get();
+  }
+
   protected ProjectInfo getProject(Project.NameKey nameKey) throws Exception {
     return gApi.projects().name(nameKey.get()).get();
   }
@@ -308,9 +383,10 @@
       throws Exception {
     List<ProjectInfo> result = query.get();
     Iterable<String> names = names(result);
-    assertThat(names)
-        .named(format(query, result, projects))
-        .containsExactlyElementsIn(names(projects));
+    assertWithMessage(format(query, result, projects))
+        .that(names)
+        .containsExactlyElementsIn(names(projects))
+        .inOrder();
     return result;
   }
 
@@ -354,6 +430,14 @@
     return b.toString();
   }
 
+  protected int getSchemaVersion() {
+    return getSchema().getVersion();
+  }
+
+  protected Schema<ProjectData> getSchema() {
+    return indexes.getSearchIndex().getSchema();
+  }
+
   protected static Iterable<String> names(ProjectInfo... projects) {
     return names(Arrays.asList(projects));
   }
@@ -367,6 +451,6 @@
       return null;
     }
 
-    return name + "_" + getSanitizedMethodName();
+    return name + "_" + testName.getSanitizedMethodName();
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index eaa3df3..4ce1c00 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -4,11 +4,13 @@
 
 java_library(
     name = "abstract_query_tests",
-    testonly = 1,
+    testonly = True,
     srcs = ABSTRACT_QUERY_TEST,
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
diff --git a/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
index 42964fa..77a56ed 100644
--- a/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
@@ -45,6 +45,6 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 42452df..62d9a79 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -7,9 +7,12 @@
     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/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/prolog:runtime",
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 314941e..be0b8e7 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.rules;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.easymock.EasyMock.expect;
 
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
@@ -49,14 +51,15 @@
             bind(PrologEnvironment.Args.class)
                 .toInstance(
                     new PrologEnvironment.Args(
-                        null, null, null, null, null, null, null, cfg, null));
+                        null, null, null, null, null, null, null, null, cfg, null, null));
           }
         });
   }
 
   @Override
   protected void setUpEnvironment(PrologEnvironment env) throws Exception {
-    LabelTypes labelTypes = new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
+    LabelTypes labelTypes =
+        new LabelTypes(Arrays.asList(TestLabels.codeReview(), TestLabels.verified()));
     ChangeData cd = EasyMock.createMock(ChangeData.class);
     expect(cd.getLabelTypes()).andStubReturn(labelTypes);
     EasyMock.replay(cd);
@@ -82,11 +85,14 @@
       throw new CompileException("Cannot consult " + nameTerm);
     }
 
-    exception.expect(ReductionLimitException.class);
-    exception.expectMessage("exceeded reduction limit of 1300");
-    env.once(
-        Prolog.BUILTIN,
-        "call",
-        new StructureTerm(":", SymbolTerm.create("user"), SymbolTerm.create("loopy")));
+    ReductionLimitException thrown =
+        assertThrows(
+            ReductionLimitException.class,
+            () ->
+                env.once(
+                    Prolog.BUILTIN,
+                    "call",
+                    new StructureTerm(":", SymbolTerm.create("user"), SymbolTerm.create("loopy"))));
+    assertThat(thrown).hasMessageThat().contains("exceeded reduction limit of 1300");
   }
 }
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..d2493cb
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -0,0 +1,94 @@
+// 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 = Change.id(100);
+  private static final PatchSet.Id PS_ID = 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) {
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(PS_ID, accountId, labelId))
+        .value(value)
+        .granted(Date.from(Instant.now()))
+        .build();
+  }
+
+  private static Account.Id makeAccount(int account) {
+    return Account.id(account);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
index f709f55..8622b32 100644
--- a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
+++ b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
@@ -30,18 +30,18 @@
   @Test
   public void labelWithSpacesIsTransformed() {
     assertThat(PrologRuleEvaluator.checkLabelName("Label with spaces"))
-        .isEqualTo("Invalid-Prolog-Rules-Label-Name--Labelwithspaces");
+        .isEqualTo("Invalid-Prolog-Rules-Label-Name-Labelwithspaces");
   }
 
   @Test
   public void labelStartingWithADashIsTransformed() {
     assertThat(PrologRuleEvaluator.checkLabelName("-dashed-label"))
-        .isEqualTo("Invalid-Prolog-Rules-Label-Name---dashed-label");
+        .isEqualTo("Invalid-Prolog-Rules-Label-Name-dashed-label");
   }
 
   @Test
   public void labelWithInvalidCharactersIsTransformed() {
     assertThat(PrologRuleEvaluator.checkLabelName("*urgent*"))
-        .isEqualTo("Invalid-Prolog-Rules-Label-Name--urgent");
+        .isEqualTo("Invalid-Prolog-Rules-Label-Name-urgent");
   }
 }
diff --git a/javatests/com/google/gerrit/server/rules/PrologTestCase.java b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
index e8eea2d..c2b6dbb 100644
--- a/javatests/com/google/gerrit/server/rules/PrologTestCase.java
+++ b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
@@ -18,8 +18,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Guice;
 import com.google.inject.Module;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
@@ -45,7 +44,7 @@
 
 /** Base class for any tests written in Prolog. */
 @Ignore
-public abstract class PrologTestCase extends GerritBaseTests {
+public abstract class PrologTestCase {
   private static final SymbolTerm test_1 = SymbolTerm.intern("test", 1);
 
   private String pkg;
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
new file mode 100644
index 0000000..4c384e0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.schema.AllProjectsInput.getDefaultCodeReviewLabel;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertSectionEquivalent;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertTwoConfigsEquivalent;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultAcls;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getDefaultAllProjectsWithAllDefaultSections;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.readAllProjectsConfig;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AllProjectsCreatorTest {
+  private static final LabelType TEST_LABEL =
+      new LabelType(
+          "Test-Label",
+          ImmutableList.of(
+              new LabelValue((short) 2, "Two"),
+              new LabelValue((short) 0, "Zero"),
+              new LabelValue((short) 1, "One")));
+
+  private static final String TEST_LABEL_STRING =
+      String.join(
+          "\n",
+          ImmutableList.of(
+              "[label \"Test-Label\"]",
+              "\tfunction = MaxWithBlock",
+              "\tdefaultValue = 0",
+              "\tvalue = 0 Zero",
+              "\tvalue = +1 One",
+              "\tvalue = +2 Two"));
+
+  @Inject private AllProjectsName allProjectsName;
+
+  @Inject @GerritPersonIdent private PersonIdent serverUser;
+
+  @Inject private AllProjectsCreator allProjectsCreator;
+
+  @Inject private GitRepositoryManager repoManager;
+
+  @Before
+  public void setUp() throws Exception {
+    InMemoryModule inMemoryModule = new InMemoryModule();
+    inMemoryModule.inject(this);
+
+    // Creates an empty All-Projects.
+    try (Repository repo = repoManager.createRepository(allProjectsName)) {
+      // Intentionally empty.
+    }
+  }
+
+  @Test
+  public void createDefaultAllProjectsConfig() throws Exception {
+    // Loads the expected configs.
+    Config expectedConfig = new Config();
+    expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
+
+    GroupReference adminsGroup = createGroupReference("Administrators");
+    GroupReference batchUsersGroup = createGroupReference("Non-Interactive Users");
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builder()
+            .administratorsGroup(adminsGroup)
+            .batchUsersGroup(batchUsersGroup)
+            .build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertTwoConfigsEquivalent(config, expectedConfig);
+  }
+
+  private GroupReference createGroupReference(String name) {
+    AccountGroup.UUID groupUuid = GroupUUID.make(name, serverUser);
+    return new GroupReference(groupUuid, name);
+  }
+
+  @Test
+  public void createAllProjectsWithNewCodeReviewLabel() throws Exception {
+    Config expectedLabelConfig = new Config();
+    expectedLabelConfig.fromText(TEST_LABEL_STRING);
+
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builder().codeReviewLabel(TEST_LABEL).build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertSectionEquivalent(config, expectedLabelConfig, "label");
+  }
+
+  @Test
+  public void createAllProjectsWithProjectDescription() throws Exception {
+    String testDescription = "test description";
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builder().projectDescription(testDescription).build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertThat(config).stringValue("project", null, "description").isEqualTo(testDescription);
+  }
+
+  @Test
+  public void createAllProjectsWithBooleanConfigs() throws Exception {
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builderWithNoDefault()
+            .codeReviewLabel(getDefaultCodeReviewLabel())
+            .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
+            .addBooleanProjectConfig(
+                BooleanProjectConfig.REJECT_EMPTY_COMMIT, InheritableBoolean.TRUE)
+            .initDefaultAcls(true)
+            .build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertThat(config).booleanValue("submit", null, "rejectEmptyCommit", false).isTrue();
+  }
+
+  @Test
+  public void createAllProjectsWithoutInitializingDefaultACLs() throws Exception {
+    AllProjectsInput allProjectsInput = AllProjectsInput.builder().initDefaultAcls(false).build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config expectedConfig = new Config();
+    expectedConfig.fromText(getAllProjectsWithoutDefaultAcls());
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertTwoConfigsEquivalent(config, expectedConfig);
+  }
+
+  @Test
+  public void createAllProjectsOnlyInitializingProjectDescription() throws Exception {
+    String description = "a project.config with just a project description";
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builderWithNoDefault()
+            .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
+            .projectDescription(description)
+            .initDefaultAcls(false)
+            .build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config expectedConfig = new Config();
+    expectedConfig.setString("project", null, "description", description);
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertTwoConfigsEquivalent(config, expectedConfig);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/GroupBundleTest.java b/javatests/com/google/gerrit/server/schema/GroupBundleTest.java
deleted file mode 100644
index 43fd59a..0000000
--- a/javatests/com/google/gerrit/server/schema/GroupBundleTest.java
+++ /dev/null
@@ -1,146 +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.server.schema;
-
-import static com.google.common.truth.Truth.assertThat;
-
-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.AccountGroupById;
-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.server.schema.GroupBundle.Source;
-import com.google.gerrit.testing.GerritBaseTests;
-import com.google.gerrit.testing.TestTimeUtil;
-import java.sql.Timestamp;
-import java.util.TimeZone;
-import java.util.concurrent.TimeUnit;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class GroupBundleTest extends GerritBaseTests {
-  // This class just contains sanity checks that GroupBundle#compare correctly compares all parts of
-  // the bundle. Most other test coverage should come via the slightly more realistic
-  // GroupRebuilderTest.
-
-  private static final String TIMEZONE_ID = "US/Eastern";
-
-  private String systemTimeZoneProperty;
-  private TimeZone systemTimeZone;
-  private Timestamp ts;
-
-  @Before
-  public void setUp() {
-    systemTimeZoneProperty = System.setProperty("user.timezone", TIMEZONE_ID);
-    systemTimeZone = TimeZone.getDefault();
-    TimeZone.setDefault(TimeZone.getTimeZone(TIMEZONE_ID));
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-    ts = TimeUtil.nowTs();
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZoneProperty);
-    TimeZone.setDefault(systemTimeZone);
-  }
-
-  @Test
-  public void compareNonEqual() throws Exception {
-    GroupBundle reviewDbBundle = newBundle().source(Source.REVIEW_DB).build();
-    AccountGroup g2 = new AccountGroup(reviewDbBundle.group());
-    g2.setDescription("Hello!");
-    GroupBundle noteDbBundle = GroupBundle.builder().source(Source.NOTE_DB).group(g2).build();
-    assertThat(GroupBundle.compareWithAudits(reviewDbBundle, noteDbBundle))
-        .containsExactly(
-            "AccountGroups differ\n"
-                + ("ReviewDb: AccountGroup{name=group, groupId=1, description=null,"
-                    + " visibleToAll=false, groupUUID=group-1, ownerGroupUUID=group-1,"
-                    + " createdOn=2009-09-30 17:00:00.0}\n")
-                + ("NoteDb  : AccountGroup{name=group, groupId=1, description=Hello!,"
-                    + " visibleToAll=false, groupUUID=group-1, ownerGroupUUID=group-1,"
-                    + " createdOn=2009-09-30 17:00:00.0}"),
-            "AccountGroupMembers differ\n"
-                + "ReviewDb: [AccountGroupMember{key=1000,1}]\n"
-                + "NoteDb  : []",
-            "AccountGroupMemberAudits differ\n"
-                + ("ReviewDb: [AccountGroupMemberAudit{key=Key{groupId=1, accountId=1000,"
-                    + " addedOn=2009-09-30 17:00:00.0}, addedBy=2000, removedBy=null,"
-                    + " removedOn=null}]\n")
-                + "NoteDb  : []",
-            "AccountGroupByIds differ\n"
-                + "ReviewDb: [AccountGroupById{key=1,subgroup}]\n"
-                + "NoteDb  : []",
-            "AccountGroupByIdAudits differ\n"
-                + ("ReviewDb: [AccountGroupByIdAud{key=Key{groupId=1, includeUUID=subgroup,"
-                    + " addedOn=2009-09-30 17:00:00.0}, addedBy=3000, removedBy=null,"
-                    + " removedOn=null}]\n")
-                + "NoteDb  : []");
-  }
-
-  @Test
-  public void compareIgnoreAudits() throws Exception {
-    GroupBundle reviewDbBundle = newBundle().source(Source.REVIEW_DB).build();
-    AccountGroup group = new AccountGroup(reviewDbBundle.group());
-
-    AccountGroupMember member =
-        new AccountGroupMember(new AccountGroupMember.Key(new Account.Id(1), group.getId()));
-    AccountGroupMemberAudit memberAudit =
-        new AccountGroupMemberAudit(member, new Account.Id(2), ts);
-    AccountGroupById byId =
-        new AccountGroupById(
-            new AccountGroupById.Key(group.getId(), new AccountGroup.UUID("subgroup-2")));
-    AccountGroupByIdAud byIdAudit = new AccountGroupByIdAud(byId, new Account.Id(3), ts);
-
-    GroupBundle noteDbBundle =
-        newBundle().source(Source.NOTE_DB).memberAudit(memberAudit).byIdAudit(byIdAudit).build();
-
-    assertThat(GroupBundle.compareWithAudits(reviewDbBundle, noteDbBundle)).isNotEmpty();
-    assertThat(GroupBundle.compareWithoutAudits(reviewDbBundle, noteDbBundle)).isEmpty();
-  }
-
-  @Test
-  public void compareEqual() throws Exception {
-    GroupBundle reviewDbBundle = newBundle().source(Source.REVIEW_DB).build();
-    GroupBundle noteDbBundle = newBundle().source(Source.NOTE_DB).build();
-    assertThat(GroupBundle.compareWithAudits(reviewDbBundle, noteDbBundle)).isEmpty();
-  }
-
-  private GroupBundle.Builder newBundle() {
-    AccountGroup group =
-        new AccountGroup(
-            new AccountGroup.NameKey("group"),
-            new AccountGroup.Id(1),
-            new AccountGroup.UUID("group-1"),
-            ts);
-    AccountGroupMember member =
-        new AccountGroupMember(new AccountGroupMember.Key(new Account.Id(1000), group.getId()));
-    AccountGroupMemberAudit memberAudit =
-        new AccountGroupMemberAudit(member, new Account.Id(2000), ts);
-    AccountGroupById byId =
-        new AccountGroupById(
-            new AccountGroupById.Key(group.getId(), new AccountGroup.UUID("subgroup")));
-    AccountGroupByIdAud byIdAudit = new AccountGroupByIdAud(byId, new Account.Id(3000), ts);
-    return GroupBundle.builder()
-        .group(group)
-        .members(member)
-        .memberAudit(memberAudit)
-        .byId(byId)
-        .byIdAudit(byIdAudit);
-  }
-}
diff --git a/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java b/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java
deleted file mode 100644
index a6178ac..0000000
--- a/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java
+++ /dev/null
@@ -1,747 +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.server.schema;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_GROUPNAMES;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-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.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.group.db.AuditLogFormatter;
-import com.google.gerrit.server.group.db.AuditLogReader;
-import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.update.RefUpdateUtil;
-import com.google.gerrit.testing.GerritBaseTests;
-import com.google.gerrit.testing.GitTestUtil;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import java.sql.Timestamp;
-import java.util.Optional;
-import java.util.TimeZone;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.stream.IntStream;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class GroupRebuilderTest extends GerritBaseTests {
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
-  private static final String SERVER_ID = "server-id";
-  private static final String SERVER_NAME = "Gerrit Server";
-  private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
-
-  private AtomicInteger idCounter;
-  private Repository repo;
-  private GroupRebuilder rebuilder;
-  private GroupBundle.Factory bundleFactory;
-
-  @Before
-  public void setUp() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-    idCounter = new AtomicInteger();
-    AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-    repo = new InMemoryRepositoryManager().createRepository(allUsersName);
-    rebuilder =
-        new GroupRebuilder(
-            GroupRebuilderTest.newPersonIdent(),
-            allUsersName,
-            // 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));
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void minimalGroupFields() throws Exception {
-    AccountGroup g = newGroup("a");
-    GroupBundle b = builder().group(g).build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(1);
-    assertCommit(log.get(0), "Create group", SERVER_NAME, SERVER_EMAIL);
-    assertThat(logGroupNames()).isEmpty();
-  }
-
-  @Test
-  public void allGroupFields() throws Exception {
-    AccountGroup g = newGroup("a");
-    g.setDescription("Description");
-    g.setOwnerGroupUUID(new AccountGroup.UUID("owner"));
-    g.setVisibleToAll(true);
-    GroupBundle b = builder().group(g).build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(1);
-    assertServerCommit(log.get(0), "Create group");
-  }
-
-  @Test
-  public void emptyGroupName() throws Exception {
-    AccountGroup g = newGroup("");
-    GroupBundle b = builder().group(g).build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    GroupBundle noteDbBundle = reload(g);
-    assertMigratedCleanly(noteDbBundle, b);
-    assertThat(noteDbBundle.group().getName()).isEmpty();
-  }
-
-  @Test
-  public void nullGroupDescription() throws Exception {
-    AccountGroup g = newGroup("a");
-    g.setDescription(null);
-    assertThat(g.getDescription()).isNull();
-    GroupBundle b = builder().group(g).build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    GroupBundle noteDbBundle = reload(g);
-    assertMigratedCleanly(noteDbBundle, b);
-    assertThat(noteDbBundle.group().getDescription()).isNull();
-  }
-
-  @Test
-  public void emptyGroupDescription() throws Exception {
-    AccountGroup g = newGroup("a");
-    g.setDescription("");
-    assertThat(g.getDescription()).isEmpty();
-    GroupBundle b = builder().group(g).build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    GroupBundle noteDbBundle = reload(g);
-    assertMigratedCleanly(noteDbBundle, b);
-    assertThat(noteDbBundle.group().getDescription()).isNull();
-  }
-
-  @Test
-  public void membersAndSubgroups() throws Exception {
-    AccountGroup g = newGroup("a");
-    GroupBundle b =
-        builder()
-            .group(g)
-            .members(member(g, 1), member(g, 2))
-            .byId(byId(g, "x"), byId(g, "y"))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(2);
-    assertServerCommit(log.get(0), "Create group");
-    assertServerCommit(
-        log.get(1),
-        "Update group\n"
-            + "\n"
-            + "Add-group: Group x <x>\n"
-            + "Add-group: Group y <y>\n"
-            + "Add: Account 1 <1@server-id>\n"
-            + "Add: Account 2 <2@server-id>");
-  }
-
-  @Test
-  public void memberAudit() throws Exception {
-    AccountGroup g = newGroup("a");
-    Timestamp t1 = TimeUtil.nowTs();
-    Timestamp t2 = TimeUtil.nowTs();
-    Timestamp t3 = TimeUtil.nowTs();
-    GroupBundle b =
-        builder()
-            .group(g)
-            .members(member(g, 1))
-            .memberAudit(addMember(g, 1, 8, t2), addAndRemoveMember(g, 2, 8, t1, 9, t3))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(4);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(
-        log.get(1), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 8", "8@server-id");
-    assertCommit(
-        log.get(2), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
-    assertCommit(
-        log.get(3), "Update group\n\nRemove: Account 2 <2@server-id>", "Account 9", "9@server-id");
-  }
-
-  @Test
-  public void memberAuditLegacyRemoved() throws Exception {
-    AccountGroup g = newGroup("a");
-    GroupBundle b =
-        builder()
-            .group(g)
-            .members(member(g, 2))
-            .memberAudit(
-                addAndLegacyRemoveMember(g, 1, 8, TimeUtil.nowTs()),
-                addMember(g, 2, 8, TimeUtil.nowTs()))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(4);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(
-        log.get(1), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
-    assertCommit(
-        log.get(2), "Update group\n\nRemove: Account 1 <1@server-id>", "Account 8", "8@server-id");
-    assertCommit(
-        log.get(3), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 8", "8@server-id");
-  }
-
-  @Test
-  public void unauditedMembershipsAddedAtEnd() throws Exception {
-    AccountGroup g = newGroup("a");
-    GroupBundle b =
-        builder()
-            .group(g)
-            .members(member(g, 1), member(g, 2), member(g, 3))
-            .memberAudit(addMember(g, 1, 8, TimeUtil.nowTs()))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(3);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(
-        log.get(1), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
-    assertServerCommit(
-        log.get(2), "Update group\n\nAdd: Account 2 <2@server-id>\nAdd: Account 3 <3@server-id>");
-  }
-
-  @Test
-  public void byIdAudit() throws Exception {
-    AccountGroup g = newGroup("a");
-    Timestamp t1 = TimeUtil.nowTs();
-    Timestamp t2 = TimeUtil.nowTs();
-    Timestamp t3 = TimeUtil.nowTs();
-    GroupBundle b =
-        builder()
-            .group(g)
-            .byId(byId(g, "x"))
-            .byIdAudit(addById(g, "x", 8, t2), addAndRemoveById(g, "y", 8, t1, 9, t3))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(4);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(log.get(1), "Update group\n\nAdd-group: Group y <y>", "Account 8", "8@server-id");
-    assertCommit(log.get(2), "Update group\n\nAdd-group: Group x <x>", "Account 8", "8@server-id");
-    assertCommit(
-        log.get(3), "Update group\n\nRemove-group: Group y <y>", "Account 9", "9@server-id");
-  }
-
-  @Test
-  public void unauditedByIdAddedAtEnd() throws Exception {
-    AccountGroup g = newGroup("a");
-    GroupBundle b =
-        builder()
-            .group(g)
-            .byId(byId(g, "x"), byId(g, "y"), byId(g, "z"))
-            .byIdAudit(addById(g, "x", 8, TimeUtil.nowTs()))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(3);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(log.get(1), "Update group\n\nAdd-group: Group x <x>", "Account 8", "8@server-id");
-    assertServerCommit(
-        log.get(2), "Update group\n\nAdd-group: Group y <y>\nAdd-group: Group z <z>");
-  }
-
-  @Test
-  public void auditsAtSameTimestampBrokenDownByType() throws Exception {
-    AccountGroup g = newGroup("a");
-    Timestamp ts = TimeUtil.nowTs();
-    int user = 8;
-    GroupBundle b =
-        builder()
-            .group(g)
-            .members(member(g, 1), member(g, 2))
-            .memberAudit(
-                addMember(g, 1, user, ts),
-                addMember(g, 2, user, ts),
-                addAndRemoveMember(g, 3, user, ts, user, ts))
-            .byId(byId(g, "x"), byId(g, "y"))
-            .byIdAudit(
-                addById(g, "x", user, ts),
-                addById(g, "y", user, ts),
-                addAndRemoveById(g, "z", user, ts, user, ts))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(5);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(
-        log.get(1),
-        "Update group\n"
-            + "\n"
-            + "Add: Account 1 <1@server-id>\n"
-            + "Add: Account 2 <2@server-id>\n"
-            + "Add: Account 3 <3@server-id>",
-        "Account 8",
-        "8@server-id");
-    assertCommit(
-        log.get(2), "Update group\n\nRemove: Account 3 <3@server-id>", "Account 8", "8@server-id");
-    assertCommit(
-        log.get(3),
-        "Update group\n"
-            + "\n"
-            + "Add-group: Group x <x>\n"
-            + "Add-group: Group y <y>\n"
-            + "Add-group: Group z <z>",
-        "Account 8",
-        "8@server-id");
-    assertCommit(
-        log.get(4), "Update group\n\nRemove-group: Group z <z>", "Account 8", "8@server-id");
-  }
-
-  @Test
-  public void auditsAtSameTimestampBrokenDownByUserAndType() throws Exception {
-    AccountGroup g = newGroup("a");
-    Timestamp ts = TimeUtil.nowTs();
-    int user1 = 8;
-    int user2 = 9;
-
-    GroupBundle b =
-        builder()
-            .group(g)
-            .members(member(g, 1), member(g, 2), member(g, 3))
-            .memberAudit(
-                addMember(g, 1, user1, ts), addMember(g, 2, user2, ts), addMember(g, 3, user1, ts))
-            .byId(byId(g, "x"), byId(g, "y"), byId(g, "z"))
-            .byIdAudit(
-                addById(g, "x", user1, ts), addById(g, "y", user2, ts), addById(g, "z", user1, ts))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(5);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(
-        log.get(1),
-        "Update group\n" + "\n" + "Add: Account 1 <1@server-id>\n" + "Add: Account 3 <3@server-id>",
-        "Account 8",
-        "8@server-id");
-    assertCommit(
-        log.get(2),
-        "Update group\n\nAdd-group: Group x <x>\nAdd-group: Group z <z>",
-        "Account 8",
-        "8@server-id");
-    assertCommit(
-        log.get(3), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 9", "9@server-id");
-    assertCommit(log.get(4), "Update group\n\nAdd-group: Group y <y>", "Account 9", "9@server-id");
-  }
-
-  @Test
-  public void fixupCommitPostDatesAllAuditEventsEvenIfAuditEventsAreInTheFuture() throws Exception {
-    AccountGroup g = newGroup("a");
-    IntStream.range(0, 20).forEach(i -> TimeUtil.nowTs());
-    Timestamp future = TimeUtil.nowTs();
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-
-    GroupBundle b =
-        builder()
-            .group(g)
-            .byId(byId(g, "x"), byId(g, "y"), byId(g, "z"))
-            .byIdAudit(addById(g, "x", 8, future))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(3);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(log.get(1), "Update group\n\nAdd-group: Group x <x>", "Account 8", "8@server-id");
-    assertServerCommit(
-        log.get(2), "Update group\n\nAdd-group: Group y <y>\nAdd-group: Group z <z>");
-
-    assertThat(log.stream().map(c -> c.committer.date).collect(toImmutableList()))
-        .named("%s", log)
-        .isOrdered();
-    assertThat(TimeUtil.nowTs()).isLessThan(future);
-  }
-
-  @Test
-  public void redundantMemberAuditsAreIgnored() throws Exception {
-    AccountGroup g = newGroup("a");
-    Timestamp t1 = TimeUtil.nowTs();
-    Timestamp t2 = TimeUtil.nowTs();
-    Timestamp t3 = TimeUtil.nowTs();
-    Timestamp t4 = TimeUtil.nowTs();
-    Timestamp t5 = TimeUtil.nowTs();
-    GroupBundle b =
-        builder()
-            .group(g)
-            .members(member(g, 2))
-            .memberAudit(
-                addMember(g, 1, 8, t1),
-                addMember(g, 1, 8, t1),
-                addMember(g, 1, 8, t3),
-                addMember(g, 1, 9, t4),
-                addAndRemoveMember(g, 1, 8, t2, 9, t5),
-                addAndLegacyRemoveMember(g, 2, 9, t3),
-                addMember(g, 2, 8, t1),
-                addMember(g, 2, 9, t4),
-                addMember(g, 1, 8, t5))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(5);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(
-        log.get(1),
-        "Update group\n\nAdd: Account 1 <1@server-id>\nAdd: Account 2 <2@server-id>",
-        "Account 8",
-        "8@server-id");
-    assertCommit(
-        log.get(2), "Update group\n\nRemove: Account 2 <2@server-id>", "Account 9", "9@server-id");
-    assertCommit(
-        log.get(3), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 9", "9@server-id");
-    assertCommit(
-        log.get(4), "Update group\n\nRemove: Account 1 <1@server-id>", "Account 9", "9@server-id");
-  }
-
-  @Test
-  public void additionsAndRemovalsWithinSameSecondCanBeMigrated() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.MILLISECONDS);
-    AccountGroup g = newGroup("a");
-    Timestamp t1 = TimeUtil.nowTs();
-    Timestamp t2 = TimeUtil.nowTs();
-    Timestamp t3 = TimeUtil.nowTs();
-    Timestamp t4 = TimeUtil.nowTs();
-    Timestamp t5 = TimeUtil.nowTs();
-    GroupBundle b =
-        builder()
-            .group(g)
-            .members(member(g, 1))
-            .memberAudit(
-                addAndLegacyRemoveMember(g, 1, 8, t1),
-                addMember(g, 1, 10, t2),
-                addAndRemoveMember(g, 1, 8, t3, 9, t4),
-                addMember(g, 1, 8, t5))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(6);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(
-        log.get(1), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
-    assertCommit(
-        log.get(2), "Update group\n\nRemove: Account 1 <1@server-id>", "Account 8", "8@server-id");
-    assertCommit(
-        log.get(3), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 10", "10@server-id");
-    assertCommit(
-        log.get(4), "Update group\n\nRemove: Account 1 <1@server-id>", "Account 9", "9@server-id");
-    assertCommit(
-        log.get(5), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
-  }
-
-  @Test
-  public void redundantByIdAuditsAreIgnored() throws Exception {
-    AccountGroup g = newGroup("a");
-    Timestamp t1 = TimeUtil.nowTs();
-    Timestamp t2 = TimeUtil.nowTs();
-    Timestamp t3 = TimeUtil.nowTs();
-    Timestamp t4 = TimeUtil.nowTs();
-    Timestamp t5 = TimeUtil.nowTs();
-    GroupBundle b =
-        builder()
-            .group(g)
-            .byId()
-            .byIdAudit(
-                addById(g, "x", 8, t1),
-                addById(g, "x", 8, t3),
-                addById(g, "x", 9, t4),
-                addAndRemoveById(g, "x", 8, t2, 9, t5))
-            .build();
-
-    rebuilder.rebuild(repo, b, null);
-
-    assertMigratedCleanly(reload(g), b);
-    ImmutableList<CommitInfo> log = log(g);
-    assertThat(log).hasSize(3);
-    assertServerCommit(log.get(0), "Create group");
-    assertCommit(log.get(1), "Update group\n\nAdd-group: Group x <x>", "Account 8", "8@server-id");
-    assertCommit(
-        log.get(2), "Update group\n\nRemove-group: Group x <x>", "Account 9", "9@server-id");
-  }
-
-  @Test
-  public void combineWithBatchGroupNameNotes() throws Exception {
-    AccountGroup g1 = newGroup("a");
-    AccountGroup g2 = newGroup("b");
-
-    GroupBundle b1 = builder().group(g1).build();
-    GroupBundle b2 = builder().group(g2).build();
-
-    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-
-    rebuilder.rebuild(repo, b1, bru);
-    rebuilder.rebuild(repo, b2, bru);
-    try (ObjectInserter inserter = repo.newObjectInserter()) {
-      ImmutableList<GroupReference> refs =
-          ImmutableList.of(GroupReference.forGroup(g1), GroupReference.forGroup(g2));
-      GroupNameNotes.updateAllGroups(repo, inserter, bru, refs, newPersonIdent());
-      inserter.flush();
-    }
-
-    assertThat(log(g1)).isEmpty();
-    assertThat(log(g2)).isEmpty();
-    assertThat(logGroupNames()).isEmpty();
-
-    RefUpdateUtil.executeChecked(bru, repo);
-
-    assertThat(log(g1)).hasSize(1);
-    assertThat(log(g2)).hasSize(1);
-    assertThat(logGroupNames()).hasSize(1);
-    assertMigratedCleanly(reload(g1), b1);
-    assertMigratedCleanly(reload(g2), b2);
-
-    GroupReference group1 = GroupReference.forGroup(g1);
-    GroupReference group2 = GroupReference.forGroup(g2);
-    assertThat(GroupNameNotes.loadAllGroups(repo)).containsExactly(group1, group2);
-  }
-
-  @Test
-  public void groupNamesWithLeadingAndTrailingWhitespace() throws Exception {
-    for (String leading : ImmutableList.of("", " ", "  ")) {
-      for (String trailing : ImmutableList.of("", " ", "  ")) {
-        AccountGroup g = newGroup(leading + "a" + trailing);
-        GroupBundle b = builder().group(g).build();
-        rebuilder.rebuild(repo, b, null);
-        assertMigratedCleanly(reload(g), b);
-      }
-    }
-  }
-
-  @Test
-  public void disallowExisting() throws Exception {
-    AccountGroup g = newGroup("a");
-    GroupBundle b = builder().group(g).build();
-
-    rebuilder.rebuild(repo, b, null);
-    assertMigratedCleanly(reload(g), b);
-    String refName = RefNames.refsGroups(g.getGroupUUID());
-    ObjectId oldId = repo.exactRef(refName).getObjectId();
-
-    try {
-      rebuilder.rebuild(repo, b, null);
-      assert_().fail("expected OrmDuplicateKeyException");
-    } catch (OrmDuplicateKeyException e) {
-      // Expected.
-    }
-
-    assertThat(repo.exactRef(refName).getObjectId()).isEqualTo(oldId);
-  }
-
-  private GroupBundle reload(AccountGroup g) throws Exception {
-    return bundleFactory.fromNoteDb(repo, g.getGroupUUID());
-  }
-
-  private void assertMigratedCleanly(GroupBundle noteDbBundle, GroupBundle expectedReviewDbBundle) {
-    assertThat(GroupBundle.compareWithAudits(expectedReviewDbBundle, noteDbBundle)).isEmpty();
-  }
-
-  private AccountGroup newGroup(String name) {
-    int id = idCounter.incrementAndGet();
-    return new AccountGroup(
-        new AccountGroup.NameKey(name),
-        new AccountGroup.Id(id),
-        new AccountGroup.UUID(name.trim() + "-" + id),
-        TimeUtil.nowTs());
-  }
-
-  private AccountGroupMember member(AccountGroup g, int accountId) {
-    return new AccountGroupMember(new AccountGroupMember.Key(new Account.Id(accountId), g.getId()));
-  }
-
-  private AccountGroupMemberAudit addMember(
-      AccountGroup g, int accountId, int adder, Timestamp addedOn) {
-    return new AccountGroupMemberAudit(member(g, accountId), new Account.Id(adder), addedOn);
-  }
-
-  private AccountGroupMemberAudit addAndLegacyRemoveMember(
-      AccountGroup g, int accountId, int adder, Timestamp addedOn) {
-    AccountGroupMemberAudit a = addMember(g, accountId, adder, addedOn);
-    a.removedLegacy();
-    return a;
-  }
-
-  private AccountGroupMemberAudit addAndRemoveMember(
-      AccountGroup g,
-      int accountId,
-      int adder,
-      Timestamp addedOn,
-      int removedBy,
-      Timestamp removedOn) {
-    AccountGroupMemberAudit a = addMember(g, accountId, adder, addedOn);
-    a.removed(new Account.Id(removedBy), removedOn);
-    return a;
-  }
-
-  private AccountGroupByIdAud addById(
-      AccountGroup g, String subgroupUuid, int adder, Timestamp addedOn) {
-    return new AccountGroupByIdAud(byId(g, subgroupUuid), new Account.Id(adder), addedOn);
-  }
-
-  private AccountGroupByIdAud addAndRemoveById(
-      AccountGroup g,
-      String subgroupUuid,
-      int adder,
-      Timestamp addedOn,
-      int removedBy,
-      Timestamp removedOn) {
-    AccountGroupByIdAud a = addById(g, subgroupUuid, adder, addedOn);
-    a.removed(new Account.Id(removedBy), removedOn);
-    return a;
-  }
-
-  private AccountGroupById byId(AccountGroup g, String subgroupUuid) {
-    return new AccountGroupById(
-        new AccountGroupById.Key(g.getId(), new AccountGroup.UUID(subgroupUuid)));
-  }
-
-  private ImmutableList<CommitInfo> log(AccountGroup g) throws Exception {
-    return GitTestUtil.log(repo, RefNames.refsGroups(g.getGroupUUID()));
-  }
-
-  private ImmutableList<CommitInfo> logGroupNames() throws Exception {
-    return GitTestUtil.log(repo, REFS_GROUPNAMES);
-  }
-
-  private static GroupBundle.Builder builder() {
-    return GroupBundle.builder().source(GroupBundle.Source.REVIEW_DB);
-  }
-
-  private static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
-  }
-
-  private static void assertServerCommit(CommitInfo commitInfo, String expectedMessage) {
-    assertCommit(commitInfo, expectedMessage, SERVER_NAME, SERVER_EMAIL);
-  }
-
-  private static void assertCommit(
-      CommitInfo commitInfo, String expectedMessage, String expectedName, String expectedEmail) {
-    assertThat(commitInfo).message().isEqualTo(expectedMessage);
-    assertThat(commitInfo).author().name().isEqualTo(expectedName);
-    assertThat(commitInfo).author().email().isEqualTo(expectedEmail);
-
-    // Committer should always be the server, regardless of author.
-    assertThat(commitInfo).committer().name().isEqualTo(SERVER_NAME);
-    assertThat(commitInfo).committer().email().isEqualTo(SERVER_EMAIL);
-    assertThat(commitInfo).committer().date().isEqualTo(commitInfo.author.date);
-    assertThat(commitInfo).committer().tz().isEqualTo(commitInfo.author.tz);
-  }
-
-  private static AuditLogFormatter getAuditLogFormatter() {
-    return AuditLogFormatter.create(
-        GroupRebuilderTest::getAccount, GroupRebuilderTest::getGroup, SERVER_ID);
-  }
-
-  private static Optional<Account> getAccount(Account.Id id) {
-    Account account = new Account(id, TimeUtil.nowTs());
-    account.setFullName("Account " + id);
-    return Optional.of(account);
-  }
-
-  private static Optional<GroupDescription.Basic> getGroup(AccountGroup.UUID uuid) {
-    GroupDescription.Basic group =
-        new GroupDescription.Basic() {
-          @Override
-          public AccountGroup.UUID getGroupUUID() {
-            return uuid;
-          }
-
-          @Override
-          public String getName() {
-            return "Group " + uuid;
-          }
-
-          @Nullable
-          @Override
-          public String getEmailAddress() {
-            return null;
-          }
-
-          @Nullable
-          @Override
-          public String getUrl() {
-            return null;
-          }
-        };
-    return Optional.of(group);
-  }
-}
diff --git a/javatests/com/google/gerrit/server/schema/HANATest.java b/javatests/com/google/gerrit/server/schema/HANATest.java
deleted file mode 100644
index ac58134..0000000
--- a/javatests/com/google/gerrit/server/schema/HANATest.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-
-package com.google.gerrit.server.schema;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-
-public class HANATest {
-
-  private HANA hana;
-  private Config config;
-
-  @Before
-  public void setup() {
-    config = new Config();
-    config.setString("database", null, "hostname", "my.host");
-    hana = new HANA(config);
-  }
-
-  @Test
-  public void getUrl() throws Exception {
-    config.setString("database", null, "port", "4242");
-    assertThat(hana.getUrl()).isEqualTo("jdbc:sap://my.host:4242");
-  }
-
-  @Test
-  public void getIndexScript() throws Exception {
-    assertThat(hana.getIndexScript()).isSameAs(ScriptRunner.NOOP);
-  }
-}
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
new file mode 100644
index 0000000..45a05ac
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -0,0 +1,282 @@
+// 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.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.server.schema.NoteDbSchemaUpdater.requiredUpgrades;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.IntBlob;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.TestUpdateUI;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class NoteDbSchemaUpdaterTest {
+  @Test
+  public void requiredUpgradesFromNoVersion() throws Exception {
+    assertThat(requiredUpgrades(0, versions(10))).containsExactly(10).inOrder();
+    assertThat(requiredUpgrades(0, versions(10, 11, 12))).containsExactly(10, 11, 12).inOrder();
+  }
+
+  @Test
+  public void requiredUpgradesFromExistingVersion() throws Exception {
+    ImmutableSortedSet<Integer> versions = versions(10, 11, 12, 13);
+    assertThat(requiredUpgrades(10, versions)).containsExactly(11, 12, 13).inOrder();
+    assertThat(requiredUpgrades(11, versions)).containsExactly(12, 13).inOrder();
+    assertThat(requiredUpgrades(12, versions)).containsExactly(13).inOrder();
+    assertThat(requiredUpgrades(13, versions)).isEmpty();
+  }
+
+  @Test
+  public void downgradeNotSupported() throws Exception {
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> requiredUpgrades(14, versions(10, 11, 12, 13)));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot downgrade NoteDb schema from version 14 to 13");
+  }
+
+  @Test
+  public void skipToFirstVersionNotSupported() throws Exception {
+    ImmutableSortedSet<Integer> versions = versions(10, 11, 12);
+    assertThat(requiredUpgrades(9, versions)).containsExactly(10, 11, 12).inOrder();
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> requiredUpgrades(8, versions));
+    assertThat(thrown).hasMessageThat().contains("Cannot skip NoteDb schema from version 8 to 10");
+  }
+
+  private static class TestUpdate {
+    protected final Config cfg;
+    protected final AllProjectsName allProjectsName;
+    protected final AllUsersName allUsersName;
+    protected final NoteDbSchemaUpdater updater;
+    protected final GitRepositoryManager repoManager;
+    protected final NoteDbSchemaVersion.Arguments args;
+    private final List<String> messages;
+
+    TestUpdate(Optional<Integer> initialVersion) {
+      cfg = new Config();
+      allProjectsName = new AllProjectsName("The-Projects");
+      allUsersName = new AllUsersName("The-Users");
+      repoManager = new InMemoryRepositoryManager();
+
+      args = new NoteDbSchemaVersion.Arguments(repoManager, allProjectsName, allUsersName);
+      NoteDbSchemaVersionManager versionManager =
+          new NoteDbSchemaVersionManager(allProjectsName, repoManager);
+      updater =
+          new NoteDbSchemaUpdater(
+              cfg,
+              allUsersName,
+              repoManager,
+              new TestSchemaCreator(initialVersion),
+              versionManager,
+              args,
+              ImmutableSortedMap.of(10, TestSchema_10.class, 11, TestSchema_11.class));
+      messages = new ArrayList<>();
+    }
+
+    private class TestSchemaCreator implements SchemaCreator {
+      private final Optional<Integer> initialVersion;
+
+      TestSchemaCreator(Optional<Integer> initialVersion) {
+        this.initialVersion = initialVersion;
+      }
+
+      @Override
+      public void create() throws IOException {
+        try (Repository repo = repoManager.createRepository(allProjectsName);
+            TestRepository<Repository> tr = new TestRepository<>(repo)) {
+          if (initialVersion.isPresent()) {
+            tr.update(RefNames.REFS_VERSION, tr.blob(initialVersion.get().toString()));
+          }
+        } catch (Exception e) {
+          throw new StorageException(e);
+        }
+        repoManager.createRepository(allUsersName).close();
+        setUp();
+      }
+
+      @Override
+      public void ensureCreated() throws IOException {
+        try {
+          repoManager.openRepository(allProjectsName).close();
+        } catch (RepositoryNotFoundException e) {
+          create();
+        }
+      }
+    }
+
+    protected void setNotesMigrationConfig() {
+      cfg.setString("noteDb", "changes", "write", "true");
+      cfg.setString("noteDb", "changes", "read", "true");
+      cfg.setString("noteDb", "changes", "primaryStorage", "NOTE_DB");
+      cfg.setString("noteDb", "changes", "disableReviewDb", "true");
+    }
+
+    protected void seedGroupSequenceRef() {
+      new RepoSequence(
+              repoManager,
+              GitReferenceUpdated.DISABLED,
+              allUsersName,
+              Sequences.NAME_GROUPS,
+              () -> 1,
+              1)
+          .next();
+    }
+
+    /** Test-specific setup. */
+    protected void setUp() {}
+
+    ImmutableList<String> update() throws Exception {
+      updater.update(
+          new TestUpdateUI() {
+            @Override
+            public void message(String m) {
+              messages.add(m);
+            }
+          });
+      return getMessages();
+    }
+
+    ImmutableList<String> getMessages() {
+      return ImmutableList.copyOf(messages);
+    }
+
+    Optional<Integer> readVersion() throws Exception {
+      try (Repository repo = repoManager.openRepository(allProjectsName)) {
+        return IntBlob.parse(repo, RefNames.REFS_VERSION).map(IntBlob::value);
+      }
+    }
+
+    static class TestSchema_10 implements NoteDbSchemaVersion {
+      @Override
+      public void upgrade(Arguments args, UpdateUI ui) {
+        ui.message("body of 10");
+      }
+    }
+
+    static class TestSchema_11 implements NoteDbSchemaVersion {
+      @Override
+      public void upgrade(Arguments args, UpdateUI ui) {
+        ui.message("BODY OF 11");
+      }
+    }
+  }
+
+  @Test
+  public void bootstrapUpdateWith216Prerequisites() throws Exception {
+    TestUpdate u =
+        new TestUpdate(Optional.empty()) {
+          @Override
+          public void setUp() {
+            setNotesMigrationConfig();
+            seedGroupSequenceRef();
+          }
+        };
+    assertThat(u.update())
+        .containsExactly(
+            "Migrating data to schema 10 ...",
+            "body of 10",
+            "Migrating data to schema 11 ...",
+            "BODY OF 11")
+        .inOrder();
+    assertThat(u.readVersion()).hasValue(11);
+  }
+
+  @Test
+  public void bootstrapUpdateFailsWithoutNotesMigrationConfig() throws Exception {
+    TestUpdate u =
+        new TestUpdate(Optional.empty()) {
+          @Override
+          public void setUp() {
+            seedGroupSequenceRef();
+          }
+        };
+    StorageException thrown = assertThrows(StorageException.class, () -> u.update());
+    assertThat(thrown).hasMessageThat().contains("NoteDb change migration was not completed");
+    assertThat(u.getMessages()).isEmpty();
+    assertThat(u.readVersion()).isEmpty();
+  }
+
+  @Test
+  public void bootstrapUpdateFailsWithoutGroupSequenceRef() throws Exception {
+    TestUpdate u =
+        new TestUpdate(Optional.empty()) {
+          @Override
+          public void setUp() {
+            setNotesMigrationConfig();
+          }
+        };
+    StorageException thrown = assertThrows(StorageException.class, () -> u.update());
+    assertThat(thrown).hasMessageThat().contains("upgrade to 2.16.x first");
+    assertThat(u.getMessages()).isEmpty();
+    assertThat(u.readVersion()).isEmpty();
+  }
+
+  @Test
+  public void updateTwoVersions() throws Exception {
+    TestUpdate u = new TestUpdate(Optional.of(9));
+    assertThat(u.update())
+        .containsExactly(
+            "Migrating data to schema 10 ...",
+            "body of 10",
+            "Migrating data to schema 11 ...",
+            "BODY OF 11")
+        .inOrder();
+    assertThat(u.readVersion()).hasValue(11);
+  }
+
+  @Test
+  public void updateOneVersion() throws Exception {
+    TestUpdate u = new TestUpdate(Optional.of(10));
+    assertThat(u.update())
+        .containsExactly("Migrating data to schema 11 ...", "BODY OF 11")
+        .inOrder();
+    assertThat(u.readVersion()).hasValue(11);
+  }
+
+  @Test
+  public void updateNoOp() throws Exception {
+    // This test covers the state when running the updater after initializing a new 3.x site, which
+    // seeds the schema version ref with the latest version.
+    TestUpdate u = new TestUpdate(Optional.of(11));
+    assertThat(u.update()).isEmpty();
+    assertThat(u.readVersion()).hasValue(11);
+  }
+
+  private static ImmutableSortedSet<Integer> versions(Integer... versions) {
+    return ImmutableSortedSet.copyOf(versions);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
new file mode 100644
index 0000000..8ccf631
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
@@ -0,0 +1,84 @@
+// 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.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_VERSION;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+public class NoteDbSchemaVersionManagerTest {
+  private NoteDbSchemaVersionManager manager;
+  private TestRepository<?> tr;
+
+  @Before
+  public void setUp() throws Exception {
+    AllProjectsName allProjectsName = new AllProjectsName("The-Projects");
+    GitRepositoryManager repoManager = new InMemoryRepositoryManager();
+    tr = new TestRepository<>(repoManager.createRepository(allProjectsName));
+    manager = new NoteDbSchemaVersionManager(allProjectsName, repoManager);
+  }
+
+  @Test
+  public void readMissing() throws Exception {
+    assertThat(manager.read()).isEqualTo(0);
+  }
+
+  @Test
+  public void read() throws Exception {
+    tr.update(REFS_VERSION, tr.blob("123"));
+    assertThat(manager.read()).isEqualTo(123);
+  }
+
+  @Test
+  public void readInvalid() throws Exception {
+    ObjectId blobId = tr.blob(" 1 2 3 ");
+    tr.update(REFS_VERSION, blobId);
+    StorageException thrown = assertThrows(StorageException.class, () -> manager.read());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("invalid value in refs/meta/version blob at " + blobId.name());
+  }
+
+  @Test
+  public void incrementFromMissing() throws Exception {
+    manager.increment(123);
+    assertThat(manager.read()).isEqualTo(124);
+  }
+
+  @Test
+  public void increment() throws Exception {
+    tr.update(REFS_VERSION, tr.blob("123"));
+    manager.increment(123);
+    assertThat(manager.read()).isEqualTo(124);
+  }
+
+  @Test
+  public void incrementWrongOldVersion() throws Exception {
+    tr.update(REFS_VERSION, tr.blob("123"));
+    StorageException thrown = assertThrows(StorageException.class, () -> manager.increment(456));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Expected old version 456 for refs/meta/version, found 123");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
new file mode 100644
index 0000000..31697fd
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
@@ -0,0 +1,73 @@
+// 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.schema;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.server.schema.NoteDbSchemaVersions.guessVersion;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Streams;
+import com.google.common.reflect.ClassPath;
+import com.google.common.reflect.ClassPath.ClassInfo;
+import java.util.stream.IntStream;
+import org.junit.Test;
+
+public class NoteDbSchemaVersionsTest {
+  @Test
+  public void testGuessVersion() {
+    assertThat(guessVersion(getClass())).isEmpty();
+    assertThat(guessVersion(Schema_180.class)).hasValue(180);
+  }
+
+  @Test
+  public void contiguousVersions() {
+    ImmutableSortedSet<Integer> keys = NoteDbSchemaVersions.ALL.keySet();
+    ImmutableList<Integer> expected =
+        IntStream.rangeClosed(keys.first(), keys.last()).boxed().collect(toImmutableList());
+    assertThat(keys).containsExactlyElementsIn(expected).inOrder();
+  }
+
+  @Test
+  public void exceedsReviewDbVersion() {
+    assertThat(NoteDbSchemaVersions.ALL.firstKey()).isGreaterThan(170);
+  }
+
+  @Test
+  public void containsAllNoteDbSchemas() throws Exception {
+    int minNoteDbVersion = 180;
+    ImmutableList<Integer> allSchemaVersions =
+        ClassPath.from(getClass().getClassLoader())
+            .getTopLevelClasses(getClass().getPackage().getName()).stream()
+            .map(ClassInfo::load)
+            .map(NoteDbSchemaVersions::guessVersion)
+            .flatMap(Streams::stream)
+            .filter(v -> v >= minNoteDbVersion)
+            .sorted()
+            .collect(toImmutableList());
+    assertThat(NoteDbSchemaVersions.ALL.keySet())
+        .containsExactlyElementsIn(allSchemaVersions)
+        .inOrder();
+  }
+
+  @Test
+  public void schemaConstructors() throws Exception {
+    for (int version : NoteDbSchemaVersions.ALL.keySet()) {
+      NoteDbSchemaVersions.get(NoteDbSchemaVersions.ALL, version);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java b/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
new file mode 100644
index 0000000..d26771f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.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.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class ProjectConfigSchemaUpdateTest {
+  private static final String ALL_PROJECTS = "All-The-Projects";
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private SitePaths sitePaths;
+  private ProjectConfigSchemaUpdate.Factory factory;
+  private File allProjectsRepoFile;
+
+  @Before
+  public void setUp() throws Exception {
+    sitePaths = new SitePaths(temporaryFolder.newFolder().toPath());
+    Files.createDirectories(sitePaths.etc_dir);
+
+    Path gitPath = sitePaths.resolve("git");
+
+    StoredConfig gerritConfig = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
+    gerritConfig.load();
+    gerritConfig.setString("gerrit", null, "basePath", gitPath.toAbsolutePath().toString());
+    gerritConfig.setString("gerrit", null, "allProjects", ALL_PROJECTS);
+    gerritConfig.save();
+
+    Files.createDirectories(sitePaths.resolve("git"));
+    allProjectsRepoFile = gitPath.resolve("All-The-Projects.git").toFile();
+    try (Repository repo = new FileRepository(allProjectsRepoFile)) {
+      repo.create(true);
+    }
+
+    factory = new ProjectConfigSchemaUpdate.Factory(sitePaths, new AllProjectsName(ALL_PROJECTS));
+  }
+
+  @Test
+  public void noBaseConfig() throws Exception {
+    assertThat(getConfig().getString("foo", null, "bar")).isNull();
+
+    try (Repository repo = new FileRepository(allProjectsRepoFile);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch("refs/meta/config").commit().add("project.config", "[foo]\nbar = baz").create();
+    }
+
+    assertThat(getConfig().getString("foo", null, "bar")).isEqualTo("baz");
+  }
+
+  @Test
+  public void baseConfig() throws Exception {
+    assertThat(getConfig().getString("foo", null, "bar")).isNull();
+
+    Path baseConfigPath = sitePaths.etc_dir.resolve(ALL_PROJECTS).resolve("project.config");
+    Files.createDirectories(baseConfigPath.getParent());
+    Files.write(baseConfigPath, ImmutableList.of("[foo]", "bar = base"));
+
+    assertThat(getConfig().getString("foo", null, "bar")).isEqualTo("base");
+
+    try (Repository repo = new FileRepository(allProjectsRepoFile);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch("refs/meta/config").commit().add("project.config", "[foo]\nbar = baz").create();
+    }
+
+    assertThat(getConfig().getString("foo", null, "bar")).isEqualTo("baz");
+  }
+
+  private Config getConfig() throws Exception {
+    try (Repository repo = new FileRepository(allProjectsRepoFile)) {
+      return factory
+          .read(
+              new MetaDataUpdate(
+                  GitReferenceUpdated.DISABLED, Project.nameKey(ALL_PROJECTS), repo, null))
+          .getConfig();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
new file mode 100644
index 0000000..c92a8e0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Inject;
+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.Before;
+import org.junit.Test;
+
+public class SchemaCreatorImplTest {
+  @Inject private AllProjectsName allProjects;
+
+  @Inject private GitRepositoryManager repoManager;
+
+  @Inject private SchemaCreator schemaCreator;
+
+  @Inject private ProjectConfig.Factory projectConfigFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    new InMemoryModule().inject(this);
+    schemaCreator.create();
+  }
+
+  private LabelTypes getLabelTypes() throws Exception {
+    ProjectConfig c = projectConfigFactory.create(allProjects);
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      c.load(repo);
+      return new LabelTypes(ImmutableList.copyOf(c.getLabelSections().values()));
+    }
+  }
+
+  @Test
+  public void createSchema_LabelTypes() throws Exception {
+    List<String> labels = new ArrayList<>();
+    for (LabelType label : getLabelTypes().getLabelTypes()) {
+      labels.add(label.getName());
+    }
+    assertThat(labels).containsExactly("Code-Review");
+  }
+
+  @Test
+  public void createSchema_Label_CodeReview() throws Exception {
+    LabelType codeReview = getLabelTypes().byLabel("Code-Review");
+    assertThat(codeReview).isNotNull();
+    assertThat(codeReview.getName()).isEqualTo("Code-Review");
+    assertThat(codeReview.getDefaultValue()).isEqualTo(0);
+    assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
+    assertThat(codeReview.isCopyMinScore()).isTrue();
+    assertValueRange(codeReview, -2, -1, 0, 1, 2);
+  }
+
+  private void assertValueRange(LabelType label, Integer... range) {
+    List<Integer> rangeList = Arrays.asList(range);
+    assertThat(rangeList).isNotEmpty();
+    assertThat(rangeList).isInStrictOrder();
+
+    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(v.getText()).isNotNull();
+      assertThat(v.getText()).isNotEmpty();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
deleted file mode 100644
index 7f8b6f3..0000000
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
+++ /dev/null
@@ -1,122 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.common.truth.Truth.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;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.testing.InMemoryDatabase;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.File;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class SchemaCreatorTest {
-  @Inject private AllProjectsName allProjects;
-
-  @Inject private GitRepositoryManager repoManager;
-
-  @Inject private InMemoryDatabase db;
-
-  @Before
-  public void setUp() throws Exception {
-    new InMemoryModule().inject(this);
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    InMemoryDatabase.drop(db);
-  }
-
-  @Test
-  public void getCauses_CreateSchema() throws OrmException, SQLException, IOException {
-    // Initially the schema should be empty.
-    String[] types = {"TABLE", "VIEW"};
-    try (JdbcSchema d = (JdbcSchema) db.open();
-        ResultSet rs = d.getConnection().getMetaData().getTables(null, null, null, types)) {
-      assertThat(rs.next()).isFalse();
-    }
-
-    // Create the schema using the current schema version.
-    //
-    db.create();
-    db.assertSchemaVersion();
-
-    // By default sitePath is set to the current working directory.
-    //
-    File sitePath = new File(".").getAbsoluteFile();
-    if (sitePath.getName().equals(".")) {
-      sitePath = sitePath.getParentFile();
-    }
-    assertThat(db.getSystemConfig().sitePath).isEqualTo(sitePath.getCanonicalPath());
-  }
-
-  private LabelTypes getLabelTypes() throws Exception {
-    db.create();
-    ProjectConfig c = new ProjectConfig(allProjects);
-    try (Repository repo = repoManager.openRepository(allProjects)) {
-      c.load(repo);
-      return new LabelTypes(ImmutableList.copyOf(c.getLabelSections().values()));
-    }
-  }
-
-  @Test
-  public void createSchema_LabelTypes() throws Exception {
-    List<String> labels = new ArrayList<>();
-    for (LabelType label : getLabelTypes().getLabelTypes()) {
-      labels.add(label.getName());
-    }
-    assertThat(labels).containsExactly("Code-Review");
-  }
-
-  @Test
-  public void createSchema_Label_CodeReview() throws Exception {
-    LabelType codeReview = getLabelTypes().byLabel("Code-Review");
-    assertThat(codeReview).isNotNull();
-    assertThat(codeReview.getName()).isEqualTo("Code-Review");
-    assertThat(codeReview.getDefaultValue()).isEqualTo(0);
-    assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
-    assertThat(codeReview.isCopyMinScore()).isTrue();
-    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]);
-    for (LabelValue v : label.getValues()) {
-      assertThat(Strings.isNullOrEmpty(v.getText())).isFalse();
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
deleted file mode 100644
index ed94c97..0000000
--- a/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ /dev/null
@@ -1,147 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.metrics.DisabledMetricMaker;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.testing.InMemoryDatabase;
-import com.google.gerrit.testing.InMemoryH2Type;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.TestUpdateUI;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Guice;
-import com.google.inject.Key;
-import com.google.inject.ProvisionException;
-import com.google.inject.TypeLiteral;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.UUID;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class SchemaUpdaterTest {
-  private LifecycleManager lifecycle;
-  private InMemoryDatabase db;
-
-  @Before
-  public void setUp() throws Exception {
-    lifecycle = new LifecycleManager();
-    db = InMemoryDatabase.newDatabase(lifecycle);
-    lifecycle.start();
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    InMemoryDatabase.drop(db);
-  }
-
-  @Test
-  public void update() throws OrmException, FileNotFoundException, IOException {
-    db.create();
-
-    final Path site = Paths.get(UUID.randomUUID().toString());
-    final SitePaths paths = new SitePaths(site);
-    SchemaUpdater u =
-        Guice.createInjector(
-                new FactoryModule() {
-                  @Override
-                  protected void configure() {
-                    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
-                        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
-                    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
-                    bind(Key.get(schemaFactory, ReviewDbFactory.class)).toInstance(db);
-                    bind(SitePaths.class).toInstance(paths);
-
-                    Config cfg = new Config();
-                    cfg.setString("user", null, "name", "Gerrit Code Review");
-                    cfg.setString("user", null, "email", "gerrit@localhost");
-
-                    bind(Config.class) //
-                        .annotatedWith(GerritServerConfig.class) //
-                        .toInstance(cfg);
-
-                    bind(PersonIdent.class) //
-                        .annotatedWith(GerritPersonIdent.class) //
-                        .toProvider(GerritPersonIdentProvider.class);
-
-                    bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
-
-                    bind(AllProjectsName.class).toInstance(new AllProjectsName("All-Projects"));
-                    bind(AllUsersName.class).toInstance(new AllUsersName("All-Users"));
-
-                    bind(GitRepositoryManager.class).toInstance(new InMemoryRepositoryManager());
-
-                    bind(String.class) //
-                        .annotatedWith(AnonymousCowardName.class) //
-                        .toProvider(AnonymousCowardNameProvider.class);
-
-                    bind(DataSourceType.class).to(InMemoryH2Type.class);
-
-                    bind(SystemGroupBackend.class);
-                    install(new NotesMigration.Module());
-                    bind(MetricMaker.class).to(DisabledMetricMaker.class);
-                  }
-                })
-            .getInstance(SchemaUpdater.class);
-
-    for (SchemaVersion s = u.getLatestSchemaVersion(); s.getVersionNbr() > 1; s = s.getPrior()) {
-      try {
-        assertThat(s.getPrior().getVersionNbr())
-            .named(
-                "schema %s has prior version %s. Not true that",
-                s.getVersionNbr(), s.getPrior().getVersionNbr())
-            .isEqualTo(s.getVersionNbr() - 1);
-      } catch (ProvisionException e) {
-        // Ignored
-        // The oldest supported schema version doesn't have a prior schema
-        // version.
-        break;
-      }
-    }
-
-    u.update(new TestUpdateUI());
-
-    db.assertSchemaVersion();
-    final SystemConfig sc = db.getSystemConfig();
-    assertThat(sc.sitePath).isEqualTo(paths.site_path.toAbsolutePath().toString());
-  }
-}
diff --git a/javatests/com/google/gerrit/server/schema/Schema_150_to_151_Test.java b/javatests/com/google/gerrit/server/schema/Schema_150_to_151_Test.java
deleted file mode 100644
index 42af2ca..0000000
--- a/javatests/com/google/gerrit/server/schema/Schema_150_to_151_Test.java
+++ /dev/null
@@ -1,253 +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.server.schema;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-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.AccountGroup.Id;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.GroupUUID;
-import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gerrit.testing.TestUpdateUI;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.Connection;
-import java.sql.PreparedStatement;
-import java.sql.ResultSet;
-import java.sql.Statement;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-
-public class Schema_150_to_151_Test {
-
-  @Rule public InMemoryTestEnvironment testEnv = new InMemoryTestEnvironment();
-
-  @Inject private Schema_151 schema151;
-  @Inject private ReviewDb db;
-  @Inject private IdentifiedUser currentUser;
-  @Inject private @GerritPersonIdent Provider<PersonIdent> serverIdent;
-  @Inject private Sequences seq;
-
-  private Connection connection;
-  private PreparedStatement createdOnRetrieval;
-  private PreparedStatement createdOnUpdate;
-  private PreparedStatement auditEntryDeletion;
-  private JdbcSchema jdbcSchema;
-
-  @Before
-  public void unwrapDb() {
-    jdbcSchema = ReviewDbWrapper.unwrapJbdcSchema(db);
-  }
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(db instanceof JdbcSchema).isTrue();
-
-    connection = ((JdbcSchema) db).getConnection();
-
-    try (Statement stmt = connection.createStatement()) {
-      stmt.execute(
-          "CREATE TABLE account_groups ("
-              + " group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " name varchar(255) DEFAULT '' NOT NULL,"
-              + " created_on TIMESTAMP,"
-              + " description CLOB,"
-              + " owner_group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " visible_to_all CHAR(1) DEFAULT 'N' NOT NULL"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_members ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " account_id INTEGER DEFAULT 0 NOT NULL"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_members_audit ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " account_id INTEGER DEFAULT 0 NOT NULL,"
-              + " added_by INTEGER DEFAULT 0 NOT NULL,"
-              + " added_on TIMESTAMP,"
-              + " removed_by INTEGER,"
-              + " removed_on TIMESTAMP"
-              + ")");
-    }
-
-    createdOnRetrieval =
-        connection.prepareStatement("SELECT created_on FROM account_groups WHERE group_id = ?");
-    createdOnUpdate =
-        connection.prepareStatement("UPDATE account_groups SET created_on = ? WHERE group_id = ?");
-    auditEntryDeletion =
-        connection.prepareStatement("DELETE FROM account_group_members_audit WHERE group_id = ?");
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    if (auditEntryDeletion != null) {
-      auditEntryDeletion.close();
-    }
-    if (createdOnUpdate != null) {
-      createdOnUpdate.close();
-    }
-    if (createdOnRetrieval != null) {
-      createdOnRetrieval.close();
-    }
-    if (connection != null) {
-      connection.close();
-    }
-  }
-
-  @Test
-  public void createdOnIsPopulatedForGroupsCreatedAfterAudit() throws Exception {
-    Timestamp testStartTime = TimeUtil.nowTs();
-    AccountGroup.Id groupId = createGroupInReviewDb("Group for schema migration");
-    setCreatedOnToVeryOldTimestamp(groupId);
-
-    schema151.migrateData(db, new TestUpdateUI());
-
-    Timestamp createdOn = getCreatedOn(groupId);
-    assertThat(createdOn).isAtLeast(testStartTime);
-  }
-
-  @Test
-  public void createdOnIsPopulatedForGroupsCreatedBeforeAudit() throws Exception {
-    AccountGroup.Id groupId = createGroupInReviewDb("Ancient group for schema migration");
-    setCreatedOnToVeryOldTimestamp(groupId);
-    removeAuditEntriesFor(groupId);
-
-    schema151.migrateData(db, new TestUpdateUI());
-
-    Timestamp createdOn = getCreatedOn(groupId);
-    assertThat(createdOn).isEqualTo(AccountGroup.auditCreationInstantTs());
-  }
-
-  private AccountGroup.Id createGroupInReviewDb(String name) throws Exception {
-    AccountGroup group =
-        new AccountGroup(
-            new AccountGroup.NameKey(name),
-            new AccountGroup.Id(seq.nextGroupId()),
-            GroupUUID.make(name, serverIdent.get()),
-            TimeUtil.nowTs());
-    storeInReviewDb(group);
-    addMembersInReviewDb(group.getId(), currentUser.getAccountId());
-    return group.getId();
-  }
-
-  private Timestamp getCreatedOn(Id groupId) throws Exception {
-    createdOnRetrieval.setInt(1, groupId.get());
-    try (ResultSet results = createdOnRetrieval.executeQuery()) {
-      if (results.first()) {
-        return results.getTimestamp(1);
-      }
-    }
-    return null;
-  }
-
-  private void setCreatedOnToVeryOldTimestamp(Id groupId) throws Exception {
-    createdOnUpdate.setInt(1, groupId.get());
-    Instant instant = LocalDateTime.of(1800, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC);
-    createdOnUpdate.setTimestamp(1, Timestamp.from(instant));
-    createdOnUpdate.setInt(2, groupId.get());
-    createdOnUpdate.executeUpdate();
-  }
-
-  private void removeAuditEntriesFor(AccountGroup.Id groupId) throws Exception {
-    auditEntryDeletion.setInt(1, groupId.get());
-    auditEntryDeletion.executeUpdate();
-  }
-
-  private void storeInReviewDb(AccountGroup... groups) throws Exception {
-    try (PreparedStatement stmt =
-        jdbcSchema
-            .getConnection()
-            .prepareStatement(
-                "INSERT INTO account_groups"
-                    + " (group_uuid,"
-                    + " group_id,"
-                    + " name,"
-                    + " description,"
-                    + " created_on,"
-                    + " owner_group_uuid,"
-                    + " visible_to_all) VALUES (?, ?, ?, ?, ?, ?, ?)")) {
-      for (AccountGroup group : groups) {
-        stmt.setString(1, group.getGroupUUID().get());
-        stmt.setInt(2, group.getId().get());
-        stmt.setString(3, group.getName());
-        stmt.setString(4, group.getDescription());
-        stmt.setTimestamp(5, group.getCreatedOn());
-        stmt.setString(6, group.getOwnerGroupUUID().get());
-        stmt.setString(7, group.isVisibleToAll() ? "Y" : "N");
-        stmt.addBatch();
-      }
-      stmt.executeBatch();
-    }
-  }
-
-  private void addMembersInReviewDb(AccountGroup.Id groupId, Account.Id... memberIds)
-      throws Exception {
-    try (PreparedStatement addMemberStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_members"
-                        + " (group_id,"
-                        + " account_id) VALUES ("
-                        + groupId.get()
-                        + ", ?)");
-        PreparedStatement addMemberAuditStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_members_audit"
-                        + " (group_id,"
-                        + " account_id,"
-                        + " added_by,"
-                        + " added_on) VALUES ("
-                        + groupId.get()
-                        + ", ?, "
-                        + currentUser.getAccountId().get()
-                        + ", ?)")) {
-      Timestamp addedOn = TimeUtil.nowTs();
-      for (Account.Id memberId : memberIds) {
-        addMemberStmt.setInt(1, memberId.get());
-        addMemberStmt.addBatch();
-
-        addMemberAuditStmt.setInt(1, memberId.get());
-        addMemberAuditStmt.setTimestamp(2, addedOn);
-        addMemberAuditStmt.addBatch();
-      }
-      addMemberStmt.executeBatch();
-      addMemberAuditStmt.executeBatch();
-    }
-  }
-}
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
deleted file mode 100644
index a01d611..0000000
--- a/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
+++ /dev/null
@@ -1,223 +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.server.schema;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.collect.ImmutableMap.toImmutableMap;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_DEFAULT;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
-import static com.google.gerrit.server.git.UserConfigSections.MY;
-import static com.google.gerrit.server.schema.Schema_160.DEFAULT_DRAFT_ITEMS;
-import static com.google.gerrit.server.schema.VersionedAccountPreferences.PREFERENCES;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.MenuItem;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gerrit.testing.TestUpdateUI;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.Supplier;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.BlobBasedConfig;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-
-public class Schema_159_to_160_Test {
-  @Rule public InMemoryTestEnvironment testEnv = new InMemoryTestEnvironment();
-
-  @Inject private AccountCache accountCache;
-  @Inject private AccountIndexer accountIndexer;
-  @Inject private AllUsersName allUsersName;
-  @Inject private GerritApi gApi;
-  @Inject private GitRepositoryManager repoManager;
-  @Inject private Provider<IdentifiedUser> userProvider;
-  @Inject private ReviewDb db;
-  @Inject private Schema_160 schema160;
-
-  private Account.Id accountId;
-
-  @Before
-  public void setUp() throws Exception {
-    accountId = userProvider.get().getAccountId();
-  }
-
-  @Test
-  public void skipUnmodified() throws Exception {
-    ObjectId oldMetaId = metaRef(accountId);
-    ImmutableSet<String> fromNoteDb = myMenusFromNoteDb(accountId);
-    ImmutableSet<String> fromApi = myMenusFromApi(accountId);
-    for (String item : DEFAULT_DRAFT_ITEMS) {
-      assertThat(fromNoteDb).doesNotContain(item);
-      assertThat(fromApi).doesNotContain(item);
-    }
-
-    schema160.migrateData(db, new TestUpdateUI());
-
-    assertThat(metaRef(accountId)).isEqualTo(oldMetaId);
-  }
-
-  @Test
-  public void deleteItems() throws Exception {
-    ObjectId oldMetaId = metaRef(accountId);
-    ImmutableSet<String> defaultNames = myMenusFromApi(accountId);
-
-    GeneralPreferencesInfo prefs = gApi.accounts().id(accountId.get()).getPreferences();
-    prefs.my.add(0, new MenuItem("Something else", DEFAULT_DRAFT_ITEMS.get(0) + "+is:mergeable"));
-    for (int i = 0; i < DEFAULT_DRAFT_ITEMS.size(); i++) {
-      prefs.my.add(new MenuItem("Draft entry " + i, DEFAULT_DRAFT_ITEMS.get(i)));
-    }
-    gApi.accounts().id(accountId.get()).setPreferences(prefs);
-
-    List<String> oldNames =
-        ImmutableList.<String>builder()
-            .add("Something else")
-            .addAll(defaultNames)
-            .add("Draft entry 0")
-            .add("Draft entry 1")
-            .add("Draft entry 2")
-            .add("Draft entry 3")
-            .build();
-    assertThat(myMenusFromApi(accountId)).containsExactlyElementsIn(oldNames).inOrder();
-
-    schema160.migrateData(db, new TestUpdateUI());
-    accountCache.evict(accountId);
-    accountIndexer.index(accountId);
-    testEnv.setApiUser(accountId);
-
-    assertThat(metaRef(accountId)).isNotEqualTo(oldMetaId);
-
-    List<String> newNames =
-        ImmutableList.<String>builder().add("Something else").addAll(defaultNames).build();
-    assertThat(myMenusFromNoteDb(accountId)).containsExactlyElementsIn(newNames).inOrder();
-    assertThat(myMenusFromApi(accountId)).containsExactlyElementsIn(newNames).inOrder();
-  }
-
-  @Test
-  public void skipNonExistentRefsUsersDefault() throws Exception {
-    assertThat(readRef(REFS_USERS_DEFAULT)).isEmpty();
-    schema160.migrateData(db, new TestUpdateUI());
-    assertThat(readRef(REFS_USERS_DEFAULT)).isEmpty();
-  }
-
-  @Test
-  public void deleteDefaultItem() throws Exception {
-    assertThat(readRef(REFS_USERS_DEFAULT)).isEmpty();
-    ImmutableSet<String> defaultNames = defaultMenusFromApi();
-
-    // Setting *any* preference causes preferences.config to contain the full set of "my" sections.
-    // This mimics real-world behavior prior to the 2.15 upgrade; see Issue 8439 for details.
-    GeneralPreferencesInfo prefs = gApi.config().server().getDefaultPreferences();
-    prefs.signedOffBy = !firstNonNull(prefs.signedOffBy, false);
-    gApi.config().server().setDefaultPreferences(prefs);
-
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      Config cfg = new BlobBasedConfig(null, repo, readRef(REFS_USERS_DEFAULT).get(), PREFERENCES);
-      assertThat(cfg.getSubsections("my")).containsExactlyElementsIn(defaultNames).inOrder();
-
-      // Add more defaults directly in git, the SetPreferences endpoint doesn't respect the "my"
-      // field in the input in 2.15 and earlier.
-      cfg.setString("my", "Drafts", "url", "#/q/owner:self+is:draft");
-      cfg.setString("my", "Something else", "url", "#/q/owner:self+is:draft+is:mergeable");
-      cfg.setString("my", "Totally not drafts", "url", "#/q/owner:self+is:draft");
-      new TestRepository<>(repo)
-          .branch(REFS_USERS_DEFAULT)
-          .commit()
-          .add(PREFERENCES, cfg.toText())
-          .create();
-    }
-
-    List<String> oldNames =
-        ImmutableList.<String>builder()
-            .addAll(defaultNames)
-            .add("Drafts")
-            .add("Something else")
-            .add("Totally not drafts")
-            .build();
-    assertThat(defaultMenusFromApi()).containsExactlyElementsIn(oldNames).inOrder();
-
-    schema160.migrateData(db, new TestUpdateUI());
-
-    assertThat(readRef(REFS_USERS_DEFAULT)).isPresent();
-
-    List<String> newNames =
-        ImmutableList.<String>builder().addAll(defaultNames).add("Something else").build();
-    assertThat(myMenusFromNoteDb(VersionedAccountPreferences::forDefault).keySet())
-        .containsExactlyElementsIn(newNames)
-        .inOrder();
-    assertThat(defaultMenusFromApi()).containsExactlyElementsIn(newNames).inOrder();
-  }
-
-  private ImmutableSet<String> myMenusFromNoteDb(Account.Id id) throws Exception {
-    return myMenusFromNoteDb(() -> VersionedAccountPreferences.forUser(id)).keySet();
-  }
-
-  // Raw config values, bypassing the defaults set by PreferencesConfig.
-  private ImmutableMap<String, String> myMenusFromNoteDb(
-      Supplier<VersionedAccountPreferences> prefsSupplier) throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      VersionedAccountPreferences prefs = prefsSupplier.get();
-      prefs.load(repo);
-      Config cfg = prefs.getConfig();
-      return cfg.getSubsections(MY)
-          .stream()
-          .collect(toImmutableMap(i -> i, i -> cfg.getString(MY, i, KEY_URL)));
-    }
-  }
-
-  private ImmutableSet<String> myMenusFromApi(Account.Id id) throws Exception {
-    return myMenus(gApi.accounts().id(id.get()).getPreferences()).keySet();
-  }
-
-  private ImmutableSet<String> defaultMenusFromApi() throws Exception {
-    return myMenus(gApi.config().server().getDefaultPreferences()).keySet();
-  }
-
-  private static ImmutableMap<String, String> myMenus(GeneralPreferencesInfo prefs) {
-
-    return prefs.my.stream().collect(toImmutableMap(i -> i.name, i -> i.url));
-  }
-
-  private ObjectId metaRef(Account.Id id) throws Exception {
-    return readRef(RefNames.refsUsers(id))
-        .orElseThrow(() -> new AssertionError("missing ref for account " + id));
-  }
-
-  private Optional<ObjectId> readRef(String ref) throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return Optional.ofNullable(repo.exactRef(ref)).map(Ref::getObjectId);
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java b/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java
deleted file mode 100644
index 67d071d..0000000
--- a/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java
+++ /dev/null
@@ -1,96 +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.server.schema;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gerrit.testing.TestUpdateUI;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Rule;
-import org.junit.Test;
-
-public class Schema_161_to_162_Test {
-  @Rule public InMemoryTestEnvironment testEnv = new InMemoryTestEnvironment();
-
-  @Inject private AllProjectsName allProjectsName;
-  @Inject private AllUsersName allUsersName;
-  @Inject private GerritApi gApi;
-  @Inject private GitRepositoryManager repoManager;
-  @Inject private Schema_162 schema162;
-  @Inject private ReviewDb db;
-  @Inject @GerritPersonIdent private PersonIdent serverUser;
-
-  @Test
-  public void skipCorrectInheritance() throws Exception {
-    assertThatAllUsersInheritsFrom(allProjectsName.get());
-    ObjectId oldHead;
-    try (Repository git = repoManager.openRepository(allUsersName)) {
-      oldHead = git.findRef(RefNames.REFS_CONFIG).getObjectId();
-    }
-
-    schema162.migrateData(db, new TestUpdateUI());
-
-    // Check that the parent remained unchanged and that no commit was made
-    assertThatAllUsersInheritsFrom(allProjectsName.get());
-    try (Repository git = repoManager.openRepository(allUsersName)) {
-      assertThat(oldHead).isEqualTo(git.findRef(RefNames.REFS_CONFIG).getObjectId());
-    }
-  }
-
-  @Test
-  public void fixIncorrectInheritance() throws Exception {
-    String testProject = gApi.projects().create("test").get().name;
-    assertThatAllUsersInheritsFrom(allProjectsName.get());
-
-    try (Repository git = repoManager.openRepository(allUsersName);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      ProjectConfig cfg = ProjectConfig.read(md);
-      cfg.getProject().setParentName(testProject);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.setMessage("Test");
-      cfg.commit(md);
-    } catch (ConfigInvalidException | IOException ex) {
-      throw new OrmException(ex);
-    }
-    assertThatAllUsersInheritsFrom(testProject);
-
-    schema162.migrateData(db, new TestUpdateUI());
-
-    assertThatAllUsersInheritsFrom(allProjectsName.get());
-  }
-
-  private void assertThatAllUsersInheritsFrom(String parent) throws Exception {
-    assertThat(gApi.projects().name(allUsersName.get()).access().inheritsFrom.name)
-        .isEqualTo(parent);
-  }
-}
diff --git a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInNoteDbTest.java b/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInNoteDbTest.java
deleted file mode 100644
index 57689b3..0000000
--- a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInNoteDbTest.java
+++ /dev/null
@@ -1,227 +0,0 @@
-// 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.schema;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
-import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
-import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
-
-import com.google.common.collect.ImmutableList;
-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.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerIdProvider;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gerrit.testing.TestUpdateUI;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.PreparedStatement;
-import java.sql.Statement;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-
-public class Schema_166_to_167_WithGroupsInNoteDbTest {
-  private static Config createConfig() {
-    Config config = new Config();
-    config.setString(GerritServerIdProvider.SECTION, null, GerritServerIdProvider.KEY, "1234567");
-
-    // Disable groups in ReviewDb. This means the primary storage for groups is NoteDb.
-    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, true);
-
-    return config;
-  }
-
-  @Rule
-  public InMemoryTestEnvironment testEnv =
-      new InMemoryTestEnvironment(Schema_166_to_167_WithGroupsInNoteDbTest::createConfig);
-
-  @Inject private Schema_167 schema167;
-  @Inject private ReviewDb db;
-  @Inject private GitRepositoryManager gitRepoManager;
-  @Inject private AllUsersName allUsersName;
-  @Inject private @ServerInitiated GroupsUpdate groupsUpdate;
-  @Inject private Sequences seq;
-
-  private JdbcSchema jdbcSchema;
-
-  @Before
-  public void initDb() throws Exception {
-    jdbcSchema = ReviewDbWrapper.unwrapJbdcSchema(db);
-
-    try (Statement stmt = jdbcSchema.getConnection().createStatement()) {
-      stmt.execute(
-          "CREATE TABLE account_groups ("
-              + " group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " name varchar(255) DEFAULT '' NOT NULL,"
-              + " created_on TIMESTAMP,"
-              + " description CLOB,"
-              + " owner_group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " visible_to_all CHAR(1) DEFAULT 'N' NOT NULL"
-              + ")");
-    }
-  }
-
-  @Test
-  public void migrationIsSkipped() throws Exception {
-    // Create a group in NoteDb (doesn't create the group in ReviewDb since
-    // disableReviewDb == true)
-    InternalGroup internalGroup =
-        groupsUpdate.createGroup(
-            InternalGroupCreation.builder()
-                .setNameKey(new AccountGroup.NameKey("users"))
-                .setGroupUUID(new AccountGroup.UUID("users"))
-                .setId(new AccountGroup.Id(seq.nextGroupId()))
-                .build(),
-            InternalGroupUpdate.builder().setDescription("description").build());
-
-    // Insert the group into ReviewDb
-    AccountGroup group1 =
-        newGroup()
-            .setName(internalGroup.getName())
-            .setGroupUuid(internalGroup.getGroupUUID())
-            .setId(internalGroup.getId())
-            .setCreatedOn(internalGroup.getCreatedOn())
-            .setDescription(internalGroup.getDescription())
-            .setGroupUuid(internalGroup.getGroupUUID())
-            .setVisibleToAll(internalGroup.isVisibleToAll())
-            .build();
-    storeInReviewDb(group1);
-
-    // Update the group description in ReviewDb so that the group state differs between ReviewDb and
-    // NoteDb
-    group1.setDescription("outdated");
-    updateInReviewDb(group1);
-
-    // Create a group that only exists in ReviewDb
-    AccountGroup group2 = newGroup().setName("reviewDbOnlyGroup").build();
-    storeInReviewDb(group2);
-
-    // Remember the SHA1 of the group ref in NoteDb
-    ObjectId groupSha1 = getGroupSha1(group1.getGroupUUID());
-
-    executeSchemaMigration(schema167);
-
-    // Verify the groups in NoteDb: "users" should still exist, "reviewDbOnlyGroup" should not have
-    // been created
-    ImmutableList<GroupReference> groupReferences = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groupReferences.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).contains("users");
-    assertThat(groupNames).doesNotContain("reviewDbOnlyGroup");
-
-    // Verify that the group refs in NoteDb were not touched.
-    assertThat(getGroupSha1(group1.getGroupUUID())).isEqualTo(groupSha1);
-    assertThat(getGroupSha1(group2.getGroupUUID())).isNull();
-  }
-
-  private static TestGroup.Builder newGroup() {
-    return TestGroup.builder();
-  }
-
-  private void storeInReviewDb(AccountGroup... groups) throws Exception {
-    try (PreparedStatement stmt =
-        jdbcSchema
-            .getConnection()
-            .prepareStatement(
-                "INSERT INTO account_groups"
-                    + " (group_uuid,"
-                    + " group_id,"
-                    + " name,"
-                    + " description,"
-                    + " created_on,"
-                    + " owner_group_uuid,"
-                    + " visible_to_all) VALUES (?, ?, ?, ?, ?, ?, ?)")) {
-      for (AccountGroup group : groups) {
-        stmt.setString(1, group.getGroupUUID().get());
-        stmt.setInt(2, group.getId().get());
-        stmt.setString(3, group.getName());
-        stmt.setString(4, group.getDescription());
-        stmt.setTimestamp(5, group.getCreatedOn());
-        stmt.setString(6, group.getOwnerGroupUUID().get());
-        stmt.setString(7, group.isVisibleToAll() ? "Y" : "N");
-        stmt.addBatch();
-      }
-      stmt.executeBatch();
-    }
-  }
-
-  private void updateInReviewDb(AccountGroup... groups) throws Exception {
-    try (PreparedStatement stmt =
-        jdbcSchema
-            .getConnection()
-            .prepareStatement(
-                "UPDATE account_groups SET"
-                    + " group_uuid = ?,"
-                    + " name = ?,"
-                    + " description = ?,"
-                    + " created_on = ?,"
-                    + " owner_group_uuid = ?,"
-                    + " visible_to_all = ?"
-                    + " WHERE group_id = ?")) {
-      for (AccountGroup group : groups) {
-        stmt.setString(1, group.getGroupUUID().get());
-        stmt.setString(2, group.getName());
-        stmt.setString(3, group.getDescription());
-        stmt.setTimestamp(4, group.getCreatedOn());
-        stmt.setString(5, group.getOwnerGroupUUID().get());
-        stmt.setString(6, group.isVisibleToAll() ? "Y" : "N");
-        stmt.setInt(7, group.getId().get());
-        stmt.addBatch();
-      }
-      stmt.executeBatch();
-    }
-  }
-
-  private void executeSchemaMigration(SchemaVersion schema) throws Exception {
-    schema.migrateData(db, new TestUpdateUI());
-  }
-
-  private ImmutableList<GroupReference> getAllGroupsFromNoteDb()
-      throws IOException, ConfigInvalidException {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return GroupNameNotes.loadAllGroups(allUsersRepo);
-    }
-  }
-
-  @Nullable
-  private ObjectId getGroupSha1(AccountGroup.UUID groupUuid) throws IOException {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(groupUuid));
-      return ref != null ? ref.getObjectId() : null;
-    }
-  }
-}
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
deleted file mode 100644
index 6020325..0000000
--- a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java
+++ /dev/null
@@ -1,1125 +0,0 @@
-// 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.schema;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
-import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
-import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
-import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
-import static com.google.gerrit.truth.OptionalSubject.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.accounts.AccountInput;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.GroupMemberAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.Type;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.UserMemberAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.GroupOptionsInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupUUID;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.config.GerritServerIdProvider;
-import com.google.gerrit.server.git.CommitUtil;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.group.db.GroupConfig;
-import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.group.db.GroupsConsistencyChecker;
-import com.google.gerrit.server.group.testing.InternalGroupSubject;
-import com.google.gerrit.server.group.testing.TestGroupBackend;
-import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gerrit.testing.TestTimeUtil.TempClockStep;
-import com.google.gerrit.testing.TestUpdateUI;
-import com.google.gerrit.truth.OptionalSubject;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.PreparedStatement;
-import java.sql.ResultSet;
-import java.sql.Statement;
-import java.sql.Timestamp;
-import java.time.LocalDate;
-import java.time.Month;
-import java.time.ZoneOffset;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-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;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-
-public class Schema_166_to_167_WithGroupsInReviewDbTest {
-  private static Config createConfig() {
-    Config config = new Config();
-    config.setString(GerritServerIdProvider.SECTION, null, GerritServerIdProvider.KEY, "1234567");
-
-    // Enable groups in ReviewDb. This means the primary storage for groups is ReviewDb.
-    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, false);
-
-    return config;
-  }
-
-  @Rule
-  public InMemoryTestEnvironment testEnv =
-      new InMemoryTestEnvironment(Schema_166_to_167_WithGroupsInReviewDbTest::createConfig);
-
-  @Inject private GerritApi gApi;
-  @Inject private Schema_167 schema167;
-  @Inject private ReviewDb db;
-  @Inject private GitRepositoryManager gitRepoManager;
-  @Inject private AllUsersName allUsersName;
-  @Inject private GroupsConsistencyChecker consistencyChecker;
-  @Inject private IdentifiedUser currentUser;
-  @Inject private @GerritServerId String serverId;
-  @Inject private @GerritPersonIdent PersonIdent serverIdent;
-  @Inject private GroupBundle.Factory groupBundleFactory;
-  @Inject private GroupBackend groupBackend;
-  @Inject private DynamicSet<GroupBackend> backends;
-  @Inject private Sequences seq;
-
-  private JdbcSchema jdbcSchema;
-
-  @Before
-  public void initDb() throws Exception {
-    jdbcSchema = ReviewDbWrapper.unwrapJbdcSchema(db);
-
-    try (Statement stmt = jdbcSchema.getConnection().createStatement()) {
-      stmt.execute(
-          "CREATE TABLE account_groups ("
-              + " group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " name varchar(255) DEFAULT '' NOT NULL,"
-              + " created_on TIMESTAMP,"
-              + " description CLOB,"
-              + " owner_group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " visible_to_all CHAR(1) DEFAULT 'N' NOT NULL"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_members ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " account_id INTEGER DEFAULT 0 NOT NULL"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_members_audit ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " account_id INTEGER DEFAULT 0 NOT NULL,"
-              + " added_by INTEGER DEFAULT 0 NOT NULL,"
-              + " added_on TIMESTAMP,"
-              + " removed_by INTEGER,"
-              + " removed_on TIMESTAMP"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_by_id ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " include_uuid VARCHAR(255) DEFAULT '' NOT NULL"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_by_id_aud ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " include_uuid VARCHAR(255) DEFAULT '' NOT NULL,"
-              + " added_by INTEGER DEFAULT 0 NOT NULL,"
-              + " added_on TIMESTAMP,"
-              + " removed_by INTEGER,"
-              + " removed_on TIMESTAMP"
-              + ")");
-    }
-  }
-
-  @Before
-  public void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void reviewDbOnlyGroupsAreMigratedToNoteDb() throws Exception {
-    // Create groups only in ReviewDb
-    AccountGroup group1 = newGroup().setName("verifiers").build();
-    AccountGroup group2 = newGroup().setName("contributors").build();
-    storeInReviewDb(group1, group2);
-
-    executeSchemaMigration(schema167, group1, group2);
-
-    ImmutableList<GroupReference> groups = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groups.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).containsAllOf("verifiers", "contributors");
-  }
-
-  @Test
-  public void alreadyExistingGroupsAreMigratedToNoteDb() throws Exception {
-    // Create group in NoteDb and ReviewDb
-    GroupInput groupInput = new GroupInput();
-    groupInput.name = "verifiers";
-    groupInput.description = "old";
-    GroupInfo group1 = gApi.groups().create(groupInput).get();
-    storeInReviewDb(group1);
-
-    // Update group only in ReviewDb
-    AccountGroup group1InReviewDb = getFromReviewDb(new AccountGroup.Id(group1.groupId));
-    group1InReviewDb.setDescription("new");
-    updateInReviewDb(group1InReviewDb);
-
-    // Create a second group in NoteDb and ReviewDb
-    GroupInfo group2 = gApi.groups().create("contributors").get();
-    storeInReviewDb(group2);
-
-    executeSchemaMigration(schema167, group1, group2);
-
-    // Verify that both groups are present in NoteDb
-    ImmutableList<GroupReference> groups = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groups.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).containsAllOf("verifiers", "contributors");
-
-    // Verify that group1 has the description from ReviewDb
-    Optional<InternalGroup> group1InNoteDb = getGroupFromNoteDb(new AccountGroup.UUID(group1.id));
-    assertThatGroup(group1InNoteDb).value().description().isEqualTo("new");
-  }
-
-  @Test
-  public void adminGroupIsMigratedToNoteDb() throws Exception {
-    // Administrators group is automatically created for all Gerrit servers (NoteDb only).
-    GroupInfo adminGroup = gApi.groups().id("Administrators").get();
-    storeInReviewDb(adminGroup);
-
-    executeSchemaMigration(schema167, adminGroup);
-
-    ImmutableList<GroupReference> groups = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groups.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).contains("Administrators");
-  }
-
-  @Test
-  public void nonInteractiveUsersGroupIsMigratedToNoteDb() throws Exception {
-    // 'Non-Interactive Users' group is automatically created for all Gerrit servers (NoteDb only).
-    GroupInfo nonInteractiveUsersGroup = gApi.groups().id("Non-Interactive Users").get();
-    storeInReviewDb(nonInteractiveUsersGroup);
-
-    executeSchemaMigration(schema167, nonInteractiveUsersGroup);
-
-    ImmutableList<GroupReference> groups = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groups.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).contains("Non-Interactive Users");
-  }
-
-  @Test
-  public void groupsAreConsistentAfterMigrationToNoteDb() throws Exception {
-    // Administrators group are automatically created for all Gerrit servers (NoteDb only).
-    GroupInfo adminGroup = gApi.groups().id("Administrators").get();
-    GroupInfo nonInteractiveUsersGroup = gApi.groups().id("Non-Interactive Users").get();
-    storeInReviewDb(adminGroup, nonInteractiveUsersGroup);
-
-    AccountGroup group1 = newGroup().setName("verifiers").build();
-    AccountGroup group2 = newGroup().setName("contributors").build();
-    storeInReviewDb(group1, group2);
-
-    executeSchemaMigration(schema167, group1, group2);
-
-    List<ConsistencyCheckInfo.ConsistencyProblemInfo> consistencyProblems =
-        consistencyChecker.check();
-    assertThat(consistencyProblems).isEmpty();
-  }
-
-  @Test
-  public void nameIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().setName("verifiers").build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().name().isEqualTo("verifiers");
-  }
-
-  @Test
-  public void emptyNameIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().setName("").build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().name().isEqualTo("");
-  }
-
-  @Test
-  public void uuidIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEF");
-    AccountGroup group = newGroup().setGroupUuid(groupUuid).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(groupUuid);
-    assertThatGroup(groupInNoteDb).value().groupUuid().isEqualTo(groupUuid);
-  }
-
-  @Test
-  public void idIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup.Id id = new AccountGroup.Id(12345);
-    AccountGroup group = newGroup().setId(id).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().id().isEqualTo(id);
-  }
-
-  @Test
-  public void createdOnIsKeptDuringMigrationToNoteDb() throws Exception {
-    Timestamp createdOn =
-        Timestamp.from(
-            LocalDate.of(2018, Month.FEBRUARY, 20)
-                .atTime(18, 2, 56)
-                .atZone(ZoneOffset.UTC)
-                .toInstant());
-    AccountGroup group = newGroup().setCreatedOn(createdOn).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().createdOn().isEqualTo(createdOn);
-  }
-
-  @Test
-  public void ownerUuidIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup.UUID ownerGroupUuid = new AccountGroup.UUID("UVWXYZ");
-    AccountGroup group = newGroup().setOwnerGroupUuid(ownerGroupUuid).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().ownerGroupUuid().isEqualTo(ownerGroupUuid);
-  }
-
-  @Test
-  public void descriptionIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().setDescription("A test group").build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().description().isEqualTo("A test group");
-  }
-
-  @Test
-  public void absentDescriptionIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().description().isNull();
-  }
-
-  @Test
-  public void visibleToAllIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().setVisibleToAll(true).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().visibleToAll().isTrue();
-  }
-
-  @Test
-  public void membersAreKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().build();
-    storeInReviewDb(group);
-    Account.Id member1 = new Account.Id(23456);
-    Account.Id member2 = new Account.Id(93483);
-    addMembersInReviewDb(group.getId(), member1, member2);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().members().containsExactly(member1, member2);
-  }
-
-  @Test
-  public void subgroupsAreKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().build();
-    storeInReviewDb(group);
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("FGHIKL");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("MNOPQR");
-    addSubgroupsInReviewDb(group.getId(), subgroup1, subgroup2);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().subgroups().containsExactly(subgroup1, subgroup2);
-  }
-
-  @Test
-  public void logFormatWithAccountsAndGerritGroups() throws Exception {
-    AccountInfo user1 = createAccount("user1");
-    AccountInfo user2 = createAccount("user2");
-
-    AccountGroup group1 = createInReviewDb("group1");
-    AccountGroup group2 = createInReviewDb("group2");
-    AccountGroup group3 = createInReviewDb("group3");
-
-    // Add some accounts
-    try (TempClockStep step = TestTimeUtil.freezeClock()) {
-      addMembersInReviewDb(
-          group1.getId(), new Account.Id(user1._accountId), new Account.Id(user2._accountId));
-    }
-    TimeUtil.nowTs();
-
-    // Add some Gerrit groups
-    try (TempClockStep step = TestTimeUtil.freezeClock()) {
-      addSubgroupsInReviewDb(group1.getId(), group2.getGroupUUID(), group3.getGroupUUID());
-    }
-
-    executeSchemaMigration(schema167, group1, group2, group3);
-
-    GroupBundle noteDbBundle = readGroupBundleFromNoteDb(group1.getGroupUUID());
-
-    ImmutableList<CommitInfo> log = log(group1);
-    assertThat(log).hasSize(4);
-
-    // Verify commit that created the group
-    assertThat(log.get(0)).message().isEqualTo("Create group");
-    assertThat(log.get(0)).author().name().isEqualTo(serverIdent.getName());
-    assertThat(log.get(0)).author().email().isEqualTo(serverIdent.getEmailAddress());
-    assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
-    assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.getTimeZoneOffset());
-    assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
-
-    // Verify commit that the group creator as member
-    assertThat(log.get(1))
-        .message()
-        .isEqualTo(
-            "Update group\n\nAdd: "
-                + currentUser.getName()
-                + " <"
-                + currentUser.getAccountId()
-                + "@"
-                + serverId
-                + ">");
-    assertThat(log.get(1)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(1)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
-
-    // Verify commit that added members
-    assertThat(log.get(2))
-        .message()
-        .isEqualTo(
-            "Update group\n"
-                + "\n"
-                + ("Add: user1 <" + user1._accountId + "@" + serverId + ">\n")
-                + ("Add: user2 <" + user2._accountId + "@" + serverId + ">"));
-    assertThat(log.get(2)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(2)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
-
-    // Verify commit that added Gerrit groups
-    assertThat(log.get(3))
-        .message()
-        .isEqualTo(
-            "Update group\n"
-                + "\n"
-                + ("Add-group: " + group2.getName() + " <" + group2.getGroupUUID().get() + ">\n")
-                + ("Add-group: " + group3.getName() + " <" + group3.getGroupUUID().get() + ">"));
-    assertThat(log.get(3)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(3)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(3)).committer().hasSameDateAs(log.get(3).author);
-
-    // Verify that audit log is correctly read by Gerrit
-    List<? extends GroupAuditEventInfo> auditEvents =
-        gApi.groups().id(group1.getGroupUUID().get()).auditLog();
-    assertThat(auditEvents).hasSize(5);
-    AccountInfo currentUserInfo = gApi.accounts().id(currentUser.getAccountId().get()).get();
-    assertMemberAuditEvent(
-        auditEvents.get(4), Type.ADD_USER, currentUser.getAccountId(), currentUserInfo);
-    assertMemberAuditEvents(
-        auditEvents.get(3),
-        auditEvents.get(2),
-        Type.ADD_USER,
-        currentUser.getAccountId(),
-        user1,
-        user2);
-    assertSubgroupAuditEvents(
-        auditEvents.get(1),
-        auditEvents.get(0),
-        Type.ADD_GROUP,
-        currentUser.getAccountId(),
-        toGroupInfo(group2),
-        toGroupInfo(group3));
-  }
-
-  @Test
-  public void logFormatWithSystemGroups() throws Exception {
-    AccountGroup group = createInReviewDb("group");
-
-    try (TempClockStep step = TestTimeUtil.freezeClock()) {
-      addSubgroupsInReviewDb(
-          group.getId(), SystemGroupBackend.ANONYMOUS_USERS, SystemGroupBackend.REGISTERED_USERS);
-    }
-
-    executeSchemaMigration(schema167, group);
-
-    GroupBundle noteDbBundle = readGroupBundleFromNoteDb(group.getGroupUUID());
-
-    ImmutableList<CommitInfo> log = log(group);
-    assertThat(log).hasSize(3);
-
-    // Verify commit that created the group
-    assertThat(log.get(0)).message().isEqualTo("Create group");
-    assertThat(log.get(0)).author().name().isEqualTo(serverIdent.getName());
-    assertThat(log.get(0)).author().email().isEqualTo(serverIdent.getEmailAddress());
-    assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
-    assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.getTimeZoneOffset());
-    assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
-
-    // Verify commit that the group creator as member
-    assertThat(log.get(1))
-        .message()
-        .isEqualTo(
-            "Update group\n\nAdd: "
-                + currentUser.getName()
-                + " <"
-                + currentUser.getAccountId()
-                + "@"
-                + serverId
-                + ">");
-    assertThat(log.get(1)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(1)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
-
-    // Verify commit that added system groups
-    assertThat(log.get(2))
-        .message()
-        .isEqualTo(
-            "Update group\n"
-                + "\n"
-                + "Add-group: Anonymous Users <global:Anonymous-Users>\n"
-                + "Add-group: Registered Users <global:Registered-Users>");
-    assertThat(log.get(2)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(2)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
-
-    // Verify that audit log is correctly read by Gerrit
-    List<? extends GroupAuditEventInfo> auditEvents =
-        gApi.groups().id(group.getGroupUUID().get()).auditLog();
-    assertThat(auditEvents).hasSize(3);
-    AccountInfo currentUserInfo = gApi.accounts().id(currentUser.getAccountId().get()).get();
-    assertMemberAuditEvent(
-        auditEvents.get(2), Type.ADD_USER, currentUser.getAccountId(), currentUserInfo);
-    assertSubgroupAuditEvents(
-        auditEvents.get(1),
-        auditEvents.get(0),
-        Type.ADD_GROUP,
-        currentUser.getAccountId(),
-        groupInfoForExternalGroup(SystemGroupBackend.ANONYMOUS_USERS),
-        groupInfoForExternalGroup(SystemGroupBackend.REGISTERED_USERS));
-  }
-
-  @Test
-  public void logFormatWithExternalGroup() throws Exception {
-    AccountGroup group = createInReviewDb("group");
-
-    TestGroupBackend testGroupBackend = new TestGroupBackend();
-    backends.add(testGroupBackend);
-    AccountGroup.UUID subgroupUuid = testGroupBackend.create("test").getGroupUUID();
-    assertThat(groupBackend.handles(subgroupUuid)).isTrue();
-    addSubgroupsInReviewDb(group.getId(), subgroupUuid);
-
-    executeSchemaMigration(schema167, group);
-
-    GroupBundle noteDbBundle = readGroupBundleFromNoteDb(group.getGroupUUID());
-
-    ImmutableList<CommitInfo> log = log(group);
-    assertThat(log).hasSize(3);
-
-    // Verify commit that created the group
-    assertThat(log.get(0)).message().isEqualTo("Create group");
-    assertThat(log.get(0)).author().name().isEqualTo(serverIdent.getName());
-    assertThat(log.get(0)).author().email().isEqualTo(serverIdent.getEmailAddress());
-    assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
-    assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.getTimeZoneOffset());
-    assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
-
-    // Verify commit that the group creator as member
-    assertThat(log.get(1))
-        .message()
-        .isEqualTo(
-            "Update group\n\nAdd: "
-                + currentUser.getName()
-                + " <"
-                + currentUser.getAccountId()
-                + "@"
-                + serverId
-                + ">");
-    assertThat(log.get(1)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(1)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
-
-    // Verify commit that added system groups
-    // Note: The schema migration can only resolve names of Gerrit groups, not of external groups
-    // and system groups, hence the UUID shows up in commit messages where we would otherwise
-    // expect the group name.
-    assertThat(log.get(2))
-        .message()
-        .isEqualTo(
-            "Update group\n"
-                + "\n"
-                + "Add-group: "
-                + subgroupUuid.get()
-                + " <"
-                + subgroupUuid.get()
-                + ">");
-    assertThat(log.get(2)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(2)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
-
-    // Verify that audit log is correctly read by Gerrit
-    List<? extends GroupAuditEventInfo> auditEvents =
-        gApi.groups().id(group.getGroupUUID().get()).auditLog();
-    assertThat(auditEvents).hasSize(2);
-    AccountInfo currentUserInfo = gApi.accounts().id(currentUser.getAccountId().get()).get();
-    assertMemberAuditEvent(
-        auditEvents.get(1), Type.ADD_USER, currentUser.getAccountId(), currentUserInfo);
-    assertSubgroupAuditEvent(
-        auditEvents.get(0),
-        Type.ADD_GROUP,
-        currentUser.getAccountId(),
-        groupInfoForExternalGroup(subgroupUuid));
-  }
-
-  @Test
-  public void logFormatWithNonExistingExternalGroup() throws Exception {
-    AccountGroup group = createInReviewDb("group");
-
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("notExisting:foo");
-
-    assertThat(groupBackend.handles(subgroupUuid)).isFalse();
-    addSubgroupsInReviewDb(group.getId(), subgroupUuid);
-
-    executeSchemaMigration(schema167, group);
-
-    GroupBundle noteDbBundle = readGroupBundleFromNoteDb(group.getGroupUUID());
-
-    ImmutableList<CommitInfo> log = log(group);
-    assertThat(log).hasSize(3);
-
-    // Verify commit that created the group
-    assertThat(log.get(0)).message().isEqualTo("Create group");
-    assertThat(log.get(0)).author().name().isEqualTo(serverIdent.getName());
-    assertThat(log.get(0)).author().email().isEqualTo(serverIdent.getEmailAddress());
-    assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
-    assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.getTimeZoneOffset());
-    assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
-
-    // Verify commit that the group creator as member
-    assertThat(log.get(1))
-        .message()
-        .isEqualTo(
-            "Update group\n\nAdd: "
-                + currentUser.getName()
-                + " <"
-                + currentUser.getAccountId()
-                + "@"
-                + serverId
-                + ">");
-    assertThat(log.get(1)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(1)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
-
-    // Verify commit that added system groups
-    // Note: The schema migration can only resolve names of Gerrit groups, not of external groups
-    // and system groups, hence the UUID shows up in commit messages where we would otherwise
-    // expect the group name.
-    assertThat(log.get(2))
-        .message()
-        .isEqualTo("Update group\n" + "\n" + "Add-group: notExisting:foo <notExisting:foo>");
-    assertThat(log.get(2)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(2)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
-
-    // Verify that audit log is correctly read by Gerrit
-    List<? extends GroupAuditEventInfo> auditEvents =
-        gApi.groups().id(group.getGroupUUID().get()).auditLog();
-    assertThat(auditEvents).hasSize(2);
-    AccountInfo currentUserInfo = gApi.accounts().id(currentUser.getAccountId().get()).get();
-    assertMemberAuditEvent(
-        auditEvents.get(1), Type.ADD_USER, currentUser.getAccountId(), currentUserInfo);
-    assertSubgroupAuditEvent(
-        auditEvents.get(0),
-        Type.ADD_GROUP,
-        currentUser.getAccountId(),
-        groupInfoForExternalGroup(subgroupUuid));
-  }
-
-  private static TestGroup.Builder newGroup() {
-    return TestGroup.builder();
-  }
-
-  private AccountGroup createInReviewDb(String groupName) throws Exception {
-    AccountGroup group =
-        new AccountGroup(
-            new AccountGroup.NameKey(groupName),
-            new AccountGroup.Id(seq.nextGroupId()),
-            GroupUUID.make(groupName, serverIdent),
-            TimeUtil.nowTs());
-    storeInReviewDb(group);
-    addMembersInReviewDb(group.getId(), currentUser.getAccountId());
-    return group;
-  }
-
-  private void storeInReviewDb(GroupInfo... groups) throws Exception {
-    storeInReviewDb(
-        Arrays.stream(groups)
-            .map(Schema_166_to_167_WithGroupsInReviewDbTest::toAccountGroup)
-            .toArray(AccountGroup[]::new));
-  }
-
-  private void storeInReviewDb(AccountGroup... groups) throws Exception {
-    try (PreparedStatement stmt =
-        jdbcSchema
-            .getConnection()
-            .prepareStatement(
-                "INSERT INTO account_groups"
-                    + " (group_uuid,"
-                    + " group_id,"
-                    + " name,"
-                    + " description,"
-                    + " created_on,"
-                    + " owner_group_uuid,"
-                    + " visible_to_all) VALUES (?, ?, ?, ?, ?, ?, ?)")) {
-      for (AccountGroup group : groups) {
-        stmt.setString(1, group.getGroupUUID().get());
-        stmt.setInt(2, group.getId().get());
-        stmt.setString(3, group.getName());
-        stmt.setString(4, group.getDescription());
-        stmt.setTimestamp(5, group.getCreatedOn());
-        stmt.setString(6, group.getOwnerGroupUUID().get());
-        stmt.setString(7, group.isVisibleToAll() ? "Y" : "N");
-        stmt.addBatch();
-      }
-      stmt.executeBatch();
-    }
-  }
-
-  private void updateInReviewDb(AccountGroup... groups) throws Exception {
-    try (PreparedStatement stmt =
-        jdbcSchema
-            .getConnection()
-            .prepareStatement(
-                "UPDATE account_groups SET"
-                    + " group_uuid = ?,"
-                    + " name = ?,"
-                    + " description = ?,"
-                    + " created_on = ?,"
-                    + " owner_group_uuid = ?,"
-                    + " visible_to_all = ?"
-                    + " WHERE group_id = ?")) {
-      for (AccountGroup group : groups) {
-        stmt.setString(1, group.getGroupUUID().get());
-        stmt.setString(2, group.getName());
-        stmt.setString(3, group.getDescription());
-        stmt.setTimestamp(4, group.getCreatedOn());
-        stmt.setString(5, group.getOwnerGroupUUID().get());
-        stmt.setString(6, group.isVisibleToAll() ? "Y" : "N");
-        stmt.setInt(7, group.getId().get());
-        stmt.addBatch();
-      }
-      stmt.executeBatch();
-    }
-  }
-
-  private AccountGroup getFromReviewDb(AccountGroup.Id groupId) throws Exception {
-    try (Statement stmt = jdbcSchema.getConnection().createStatement();
-        ResultSet rs =
-            stmt.executeQuery(
-                "SELECT group_uuid,"
-                    + " name,"
-                    + " description,"
-                    + " created_on,"
-                    + " owner_group_uuid,"
-                    + " visible_to_all"
-                    + " FROM account_groups"
-                    + " WHERE group_id = "
-                    + groupId.get())) {
-      if (!rs.next()) {
-        throw new OrmException(String.format("Group %s not found", groupId.get()));
-      }
-
-      AccountGroup.UUID groupUuid = new AccountGroup.UUID(rs.getString(1));
-      AccountGroup.NameKey groupName = new AccountGroup.NameKey(rs.getString(2));
-      String description = rs.getString(3);
-      Timestamp createdOn = rs.getTimestamp(4);
-      AccountGroup.UUID ownerGroupUuid = new AccountGroup.UUID(rs.getString(5));
-      boolean visibleToAll = "Y".equals(rs.getString(6));
-
-      AccountGroup group = new AccountGroup(groupName, groupId, groupUuid, createdOn);
-      group.setDescription(description);
-      group.setOwnerGroupUUID(ownerGroupUuid);
-      group.setVisibleToAll(visibleToAll);
-
-      if (rs.next()) {
-        throw new OrmException(String.format("Group ID %s is ambiguous", groupId.get()));
-      }
-
-      return group;
-    }
-  }
-
-  private void addMembersInReviewDb(AccountGroup.Id groupId, Account.Id... memberIds)
-      throws Exception {
-    try (PreparedStatement addMemberStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_members"
-                        + " (group_id,"
-                        + " account_id) VALUES ("
-                        + groupId.get()
-                        + ", ?)");
-        PreparedStatement addMemberAuditStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_members_audit"
-                        + " (group_id,"
-                        + " account_id,"
-                        + " added_by,"
-                        + " added_on) VALUES ("
-                        + groupId.get()
-                        + ", ?, "
-                        + currentUser.getAccountId().get()
-                        + ", ?)")) {
-      Timestamp addedOn = TimeUtil.nowTs();
-      for (Account.Id memberId : memberIds) {
-        addMemberStmt.setInt(1, memberId.get());
-        addMemberStmt.addBatch();
-
-        addMemberAuditStmt.setInt(1, memberId.get());
-        addMemberAuditStmt.setTimestamp(2, addedOn);
-        addMemberAuditStmt.addBatch();
-      }
-      addMemberStmt.executeBatch();
-      addMemberAuditStmt.executeBatch();
-    }
-  }
-
-  private void addSubgroupsInReviewDb(AccountGroup.Id groupId, AccountGroup.UUID... subgroupUuids)
-      throws Exception {
-    try (PreparedStatement addSubGroupStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_by_id"
-                        + " (group_id,"
-                        + " include_uuid) VALUES ("
-                        + groupId.get()
-                        + ", ?)");
-        PreparedStatement addSubGroupAuditStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_by_id_aud"
-                        + " (group_id,"
-                        + " include_uuid,"
-                        + " added_by,"
-                        + " added_on) VALUES ("
-                        + groupId.get()
-                        + ", ?, "
-                        + currentUser.getAccountId().get()
-                        + ", ?)")) {
-      Timestamp addedOn = TimeUtil.nowTs();
-      for (AccountGroup.UUID subgroupUuid : subgroupUuids) {
-        addSubGroupStmt.setString(1, subgroupUuid.get());
-        addSubGroupStmt.addBatch();
-
-        addSubGroupAuditStmt.setString(1, subgroupUuid.get());
-        addSubGroupAuditStmt.setTimestamp(2, addedOn);
-        addSubGroupAuditStmt.addBatch();
-      }
-      addSubGroupStmt.executeBatch();
-      addSubGroupAuditStmt.executeBatch();
-    }
-  }
-
-  private AccountInfo createAccount(String name) throws RestApiException {
-    AccountInput accountInput = new AccountInput();
-    accountInput.username = name;
-    accountInput.name = name;
-    return gApi.accounts().create(accountInput).get();
-  }
-
-  private GroupBundle readGroupBundleFromNoteDb(AccountGroup.UUID groupUuid) throws Exception {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return groupBundleFactory.fromNoteDb(allUsersRepo, groupUuid);
-    }
-  }
-
-  private void executeSchemaMigration(SchemaVersion schema, AccountGroup... groupsToVerify)
-      throws Exception {
-    executeSchemaMigration(
-        schema,
-        Arrays.stream(groupsToVerify)
-            .map(AccountGroup::getGroupUUID)
-            .toArray(AccountGroup.UUID[]::new));
-  }
-
-  private void executeSchemaMigration(SchemaVersion schema, GroupInfo... groupsToVerify)
-      throws Exception {
-    executeSchemaMigration(
-        schema,
-        Arrays.stream(groupsToVerify)
-            .map(i -> new AccountGroup.UUID(i.id))
-            .toArray(AccountGroup.UUID[]::new));
-  }
-
-  private void executeSchemaMigration(SchemaVersion schema, AccountGroup.UUID... groupsToVerify)
-      throws Exception {
-    List<GroupBundle> reviewDbBundles = new ArrayList<>();
-    for (AccountGroup.UUID groupUuid : groupsToVerify) {
-      reviewDbBundles.add(GroupBundle.Factory.fromReviewDb(db, groupUuid));
-    }
-
-    schema.migrateData(db, new TestUpdateUI());
-
-    for (GroupBundle reviewDbBundle : reviewDbBundles) {
-      assertMigratedCleanly(readGroupBundleFromNoteDb(reviewDbBundle.uuid()), reviewDbBundle);
-    }
-  }
-
-  private void assertMigratedCleanly(GroupBundle noteDbBundle, GroupBundle expectedReviewDbBundle) {
-    assertThat(GroupBundle.compareWithAudits(expectedReviewDbBundle, noteDbBundle)).isEmpty();
-  }
-
-  private ImmutableList<CommitInfo> log(AccountGroup group) throws Exception {
-    ImmutableList.Builder<CommitInfo> result = ImmutableList.builder();
-    List<Date> commitDates = new ArrayList<>();
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(allUsersRepo)) {
-      Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(group.getGroupUUID()));
-      if (ref != null) {
-        rw.sort(RevSort.REVERSE);
-        rw.setRetainBody(true);
-        rw.markStart(rw.parseCommit(ref.getObjectId()));
-        for (RevCommit c : rw) {
-          result.add(CommitUtil.toCommitInfo(c));
-          commitDates.add(c.getCommitterIdent().getWhen());
-        }
-      }
-    }
-    assertThat(commitDates).named("commit timestamps for %s", result).isOrdered();
-    return result.build();
-  }
-
-  private ImmutableList<GroupReference> getAllGroupsFromNoteDb()
-      throws IOException, ConfigInvalidException {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return GroupNameNotes.loadAllGroups(allUsersRepo);
-    }
-  }
-
-  private Optional<InternalGroup> getGroupFromNoteDb(AccountGroup.UUID groupUuid) throws Exception {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return GroupConfig.loadForGroup(allUsersRepo, groupUuid).getLoadedGroup();
-    }
-  }
-
-  private static OptionalSubject<InternalGroupSubject, InternalGroup> assertThatGroup(
-      Optional<InternalGroup> group) {
-    return assertThat(group, InternalGroupSubject::assertThat).named("group");
-  }
-
-  private void assertMemberAuditEvent(
-      GroupAuditEventInfo info,
-      Type expectedType,
-      Account.Id expectedUser,
-      AccountInfo expectedMember) {
-    assertThat(info.user._accountId).isEqualTo(expectedUser.get());
-    assertThat(info.type).isEqualTo(expectedType);
-    assertThat(info).isInstanceOf(UserMemberAuditEventInfo.class);
-    assertAccount(((UserMemberAuditEventInfo) info).member, expectedMember);
-  }
-
-  private void assertMemberAuditEvents(
-      GroupAuditEventInfo info1,
-      GroupAuditEventInfo info2,
-      Type expectedType,
-      Account.Id expectedUser,
-      AccountInfo expectedMember1,
-      AccountInfo expectedMember2) {
-    assertThat(info1).isInstanceOf(UserMemberAuditEventInfo.class);
-    assertThat(info2).isInstanceOf(UserMemberAuditEventInfo.class);
-
-    UserMemberAuditEventInfo event1 = (UserMemberAuditEventInfo) info1;
-    UserMemberAuditEventInfo event2 = (UserMemberAuditEventInfo) info2;
-
-    assertThat(event1.member._accountId)
-        .isAnyOf(expectedMember1._accountId, expectedMember2._accountId);
-    assertThat(event2.member._accountId)
-        .isAnyOf(expectedMember1._accountId, expectedMember2._accountId);
-    assertThat(event1.member._accountId).isNotEqualTo(event2.member._accountId);
-
-    if (event1.member._accountId == expectedMember1._accountId) {
-      assertMemberAuditEvent(info1, expectedType, expectedUser, expectedMember1);
-      assertMemberAuditEvent(info2, expectedType, expectedUser, expectedMember2);
-    } else {
-      assertMemberAuditEvent(info1, expectedType, expectedUser, expectedMember2);
-      assertMemberAuditEvent(info2, expectedType, expectedUser, expectedMember1);
-    }
-  }
-
-  private void assertSubgroupAuditEvent(
-      GroupAuditEventInfo info,
-      Type expectedType,
-      Account.Id expectedUser,
-      GroupInfo expectedSubGroup) {
-    assertThat(info.user._accountId).isEqualTo(expectedUser.get());
-    assertThat(info.type).isEqualTo(expectedType);
-    assertThat(info).isInstanceOf(GroupMemberAuditEventInfo.class);
-    assertGroup(((GroupMemberAuditEventInfo) info).member, expectedSubGroup);
-  }
-
-  private void assertSubgroupAuditEvents(
-      GroupAuditEventInfo info1,
-      GroupAuditEventInfo info2,
-      Type expectedType,
-      Account.Id expectedUser,
-      GroupInfo expectedSubGroup1,
-      GroupInfo expectedSubGroup2) {
-    assertThat(info1).isInstanceOf(GroupMemberAuditEventInfo.class);
-    assertThat(info2).isInstanceOf(GroupMemberAuditEventInfo.class);
-
-    GroupMemberAuditEventInfo event1 = (GroupMemberAuditEventInfo) info1;
-    GroupMemberAuditEventInfo event2 = (GroupMemberAuditEventInfo) info2;
-
-    assertThat(event1.member.id).isAnyOf(expectedSubGroup1.id, expectedSubGroup2.id);
-    assertThat(event2.member.id).isAnyOf(expectedSubGroup1.id, expectedSubGroup2.id);
-    assertThat(event1.member.id).isNotEqualTo(event2.member.id);
-
-    if (event1.member.id.equals(expectedSubGroup1.id)) {
-      assertSubgroupAuditEvent(info1, expectedType, expectedUser, expectedSubGroup1);
-      assertSubgroupAuditEvent(info2, expectedType, expectedUser, expectedSubGroup2);
-    } else {
-      assertSubgroupAuditEvent(info1, expectedType, expectedUser, expectedSubGroup2);
-      assertSubgroupAuditEvent(info2, expectedType, expectedUser, expectedSubGroup1);
-    }
-  }
-
-  private void assertAccount(AccountInfo actual, AccountInfo expected) {
-    assertThat(actual._accountId).isEqualTo(expected._accountId);
-    assertThat(actual.name).isEqualTo(expected.name);
-    assertThat(actual.email).isEqualTo(expected.email);
-    assertThat(actual.username).isEqualTo(expected.username);
-  }
-
-  private void assertGroup(GroupInfo actual, GroupInfo expected) {
-    assertThat(actual.id).isEqualTo(expected.id);
-    assertThat(actual.name).isEqualTo(expected.name);
-    assertThat(actual.groupId).isEqualTo(expected.groupId);
-  }
-
-  private GroupInfo groupInfoForExternalGroup(AccountGroup.UUID groupUuid) {
-    GroupInfo groupInfo = new GroupInfo();
-    groupInfo.id = IdString.fromDecoded(groupUuid.get()).encoded();
-
-    if (groupBackend.handles(groupUuid)) {
-      groupInfo.name = groupBackend.get(groupUuid).getName();
-    }
-
-    return groupInfo;
-  }
-
-  private static AccountGroup toAccountGroup(GroupInfo info) {
-    AccountGroup group =
-        new AccountGroup(
-            new AccountGroup.NameKey(info.name),
-            new AccountGroup.Id(info.groupId),
-            new AccountGroup.UUID(info.id),
-            info.createdOn);
-    group.setDescription(info.description);
-    if (info.ownerId != null) {
-      group.setOwnerGroupUUID(new AccountGroup.UUID(info.ownerId));
-    }
-    group.setVisibleToAll(
-        info.options != null && info.options.visibleToAll != null && info.options.visibleToAll);
-    return group;
-  }
-
-  private static GroupInfo toGroupInfo(AccountGroup group) {
-    GroupInfo groupInfo = new GroupInfo();
-    groupInfo.id = group.getGroupUUID().get();
-    groupInfo.groupId = group.getId().get();
-    groupInfo.name = group.getName();
-    groupInfo.createdOn = group.getCreatedOn();
-    groupInfo.description = group.getDescription();
-    groupInfo.owner = group.getOwnerGroupUUID().get();
-    groupInfo.options = new GroupOptionsInfo();
-    groupInfo.options.visibleToAll = group.isVisibleToAll() ? true : null;
-    return groupInfo;
-  }
-}
diff --git a/javatests/com/google/gerrit/server/schema/TestGroup.java b/javatests/com/google/gerrit/server/schema/TestGroup.java
index 49cf028..c8b53d3 100644
--- a/javatests/com/google/gerrit/server/schema/TestGroup.java
+++ b/javatests/com/google/gerrit/server/schema/TestGroup.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.schema;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.sql.Timestamp;
 import java.util.Optional;
 import org.junit.Ignore;
@@ -47,7 +47,7 @@
     public abstract Builder setNameKey(AccountGroup.NameKey nameKey);
 
     public Builder setName(String name) {
-      return setNameKey(new AccountGroup.NameKey(name));
+      return setNameKey(AccountGroup.nameKey(name));
     }
 
     public abstract Builder setGroupUuid(AccountGroup.UUID uuid);
@@ -66,10 +66,9 @@
 
     public AccountGroup build() {
       TestGroup testGroup = autoBuild();
-      AccountGroup.NameKey name = testGroup.getNameKey().orElse(new AccountGroup.NameKey("users"));
-      AccountGroup.Id id = testGroup.getId().orElse(new AccountGroup.Id(Math.abs(name.hashCode())));
-      AccountGroup.UUID uuid =
-          testGroup.getGroupUuid().orElse(new AccountGroup.UUID(name + "-UUID"));
+      AccountGroup.NameKey name = testGroup.getNameKey().orElse(AccountGroup.nameKey("users"));
+      AccountGroup.Id id = testGroup.getId().orElse(AccountGroup.id(Math.abs(name.hashCode())));
+      AccountGroup.UUID uuid = testGroup.getGroupUuid().orElse(AccountGroup.uuid(name + "-UUID"));
       Timestamp createdOn = testGroup.getCreatedOn().orElseGet(TimeUtil::nowTs);
       AccountGroup accountGroup = new AccountGroup(name, id, uuid, createdOn);
       testGroup.getOwnerGroupUuid().ifPresent(accountGroup::setOwnerGroupUUID);
diff --git a/javatests/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java b/javatests/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
deleted file mode 100644
index bd54ddc..0000000
--- a/javatests/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
+++ /dev/null
@@ -1,666 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.tools.hooks;
-
-import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.TruthJUnit.assume;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
-import com.google.gerrit.server.util.HostPlatform;
-import java.io.File;
-import java.io.IOException;
-import java.util.Date;
-import java.util.TimeZone;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-public class CommitMsgHookTest extends HookTestCase {
-  private final String SOB1 = "Signed-off-by: J Author <ja@example.com>\n";
-  private final String SOB2 = "Signed-off-by: J Committer <jc@example.com>\n";
-
-  @BeforeClass
-  public static void skipIfWin32Platform() {
-    assume().that(HostPlatform.isWin32()).isFalse();
-  }
-
-  @Override
-  @Before
-  public void setUp() throws Exception {
-    super.setUp();
-    final Date when = author.getWhen();
-    final TimeZone tz = author.getTimeZone();
-
-    author = new PersonIdent("J. Author", "ja@example.com");
-    author = new PersonIdent(author, when, tz);
-
-    committer = new PersonIdent("J. Committer", "jc@example.com");
-    committer = new PersonIdent(committer, when, tz);
-  }
-
-  @Test
-  public void emptyMessages() throws Exception {
-    // Empty input must yield empty output so commit will abort.
-    // Note we must consider different commit templates formats.
-    //
-    hookDoesNotModify("");
-    hookDoesNotModify(" ");
-    hookDoesNotModify("\n");
-    hookDoesNotModify("\n\n");
-    hookDoesNotModify("  \n  ");
-
-    hookDoesNotModify("#");
-    hookDoesNotModify("#\n");
-    hookDoesNotModify("# on branch master\n# Untracked files:\n");
-    hookDoesNotModify("\n# on branch master\n# Untracked files:\n");
-    hookDoesNotModify("\n\n# on branch master\n# Untracked files:\n");
-
-    hookDoesNotModify(
-        "\n# on branch master\ndiff --git a/src b/src\n"
-            + "new file mode 100644\nindex 0000000..c78b7f0\n");
-  }
-
-  @Test
-  public void changeIdAlreadySet() throws Exception {
-    // If a Change-Id is already present in the footer, the hook must
-    // not modify the message but instead must leave the identity alone.
-    //
-    hookDoesNotModify(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: Iaeac9b4149291060228ef0154db2985a31111335\n");
-    hookDoesNotModify(
-        "fix: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I388bdaf52ed05b55e62a22d0a20d2c1ae0d33e7e\n");
-    hookDoesNotModify(
-        "fix-a-widget: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: Id3bc5359d768a6400450283e12bdfb6cd135ea4b\n");
-    hookDoesNotModify(
-        "FIX: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I1b55098b5a2cce0b3f3da783dda50d5f79f873fa\n");
-    hookDoesNotModify(
-        "Fix-A-Widget: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I4f4e2e1e8568ddc1509baecb8c1270a1fb4b6da7\n");
-  }
-
-  @Test
-  public void timeAltersId() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
-        call("a\n"));
-
-    tick();
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I3251906b99dda598a58a6346d8126237ee1ea800\n", //
-        call("a\n"));
-
-    tick();
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I69adf9208d828f41a3d7e41afbca63aff37c0c5c\n", //
-        call("a\n"));
-  }
-
-  @Test
-  public void firstParentAltersId() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
-        call("a\n"));
-
-    setHEAD();
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I51e86482bde7f92028541aaf724d3a3f996e7ea2\n", //
-        call("a\n"));
-  }
-
-  @Test
-  public void dirCacheAltersId() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
-        call("a\n"));
-
-    final DirCacheBuilder builder = repository.lockDirCache().builder();
-    builder.add(file("A"));
-    assertTrue(builder.commit());
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: If56597ea9759f23b070677ea6f064c60c38da631\n", //
-        call("a\n"));
-  }
-
-  @Test
-  public void singleLineMessages() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
-        call("a\n"));
-
-    assertEquals(
-        "fix: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I0f13d0e6c739ca3ae399a05a93792e80feb97f37\n", //
-        call("fix: this thing\n"));
-    assertEquals(
-        "fix-a-widget: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I1a1a0c751e4273d532e4046a501a612b9b8a775e\n", //
-        call("fix-a-widget: this thing\n"));
-
-    assertEquals(
-        "FIX: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: If816d944c57d3893b60cf10c65931fead1290d97\n", //
-        call("FIX: this thing\n"));
-    assertEquals(
-        "Fix-A-Widget: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I3e18d00cbda2ba1f73aeb63ed8c7d57d7fd16c76\n", //
-        call("Fix-A-Widget: this thing\n"));
-  }
-
-  @Test
-  public void multiLineMessagesWithoutFooter() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: Id0b4f42d3d6fc1569595c9b97cb665e738486f5d\n", //
-        call("a\n\nb\n"));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7d237b20058a0f46cc3f5fabc4a0476877289d75\n", //
-        call("a\n\nb\nc\nd\ne\n"));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "f\ng\nh\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n", //
-        call("a\n\nb\nc\nd\ne\n\nf\ng\nh\n"));
-  }
-
-  @Test
-  public void singleLineMessagesWithSignedOffBy() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n"
-            + //
-            SOB1, //
-        call("a\n\n" + SOB1));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n"
-            + //
-            SOB1
-            + //
-            SOB2, //
-        call("a\n\n" + SOB1 + SOB2));
-  }
-
-  @Test
-  public void multiLineMessagesWithSignedOffBy() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "f\ng\nh\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n"
-            + //
-            SOB1, //
-        call("a\n\nb\nc\nd\ne\n\nf\ng\nh\n\n" + SOB1));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "f\ng\nh\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n"
-            + //
-            SOB1
-            + //
-            SOB2, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "b\nc\nd\ne\n"
-                + //
-                "\n"
-                + //
-                "f\ng\nh\n"
-                + //
-                "\n"
-                + //
-                SOB1
-                + //
-                SOB2));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b: not a footer\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "f\ng\nh\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I8869aabd44b3017cd55d2d7e0d546a03e3931ee2\n"
-            + //
-            SOB1
-            + //
-            SOB2, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "b: not a footer\nc\nd\ne\n"
-                + //
-                "\n"
-                + //
-                "f\ng\nh\n"
-                + //
-                "\n"
-                + //
-                SOB1
-                + //
-                SOB2));
-  }
-
-  @Test
-  public void noteInMiddle() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "NOTE: This\n"
-            + //
-            "does not fix it.\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I988a127969a6ee5e58db546aab74fc46e66847f8\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "NOTE: This\n"
-                + //
-                "does not fix it.\n"));
-  }
-
-  @Test
-  public void kernelStyleFooter() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I1bd787f9e7590a2ac82b02c404c955ffb21877c4\n"
-            + //
-            SOB1
-            + //
-            "[ja: Fixed\n"
-            + //
-            "     the indentation]\n"
-            + //
-            SOB2, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                SOB1
-                + //
-                "[ja: Fixed\n"
-                + //
-                "     the indentation]\n"
-                + //
-                SOB2));
-  }
-
-  @Test
-  public void changeIdAfterBugOrIssue() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Bug: 42\n"
-            + //
-            "Change-Id: I8c0321227c4324e670b9ae8cf40eccc87af21b1b\n"
-            + //
-            SOB1, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "Bug: 42\n"
-                + //
-                SOB1));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Issue: 42\n"
-            + //
-            "Change-Id: Ie66e07d89ae5b114c0975b49cf326e90331dd822\n"
-            + //
-            SOB1, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "Issue: 42\n"
-                + //
-                SOB1));
-  }
-
-  @Test
-  public void commitDashV() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n"
-            + //
-            SOB1
-            + //
-            SOB2, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                SOB1
-                + //
-                SOB2
-                + //
-                "\n"
-                + //
-                "# on branch master\n"
-                + //
-                "diff --git a/src b/src\n"
-                + //
-                "new file mode 100644\n"
-                + //
-                "index 0000000..c78b7f0\n"));
-  }
-
-  @Test
-  public void withEndingURL() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "http://example.com/ fixes this\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I3b7e4e16b503ce00f07ba6ad01d97a356dad7701\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "http://example.com/ fixes this\n"));
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "https://example.com/ fixes this\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I62b9039e2fc0dce274af55e8f99312a8a80a805d\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "https://example.com/ fixes this\n"));
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "ftp://example.com/ fixes this\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I71b05dc1f6b9a5540a53a693e64d58b65a8910e8\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "ftp://example.com/ fixes this\n"));
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "git://example.com/ fixes this\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: Id34e942baa68d790633737d815ddf11bac9183e5\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "git://example.com/ fixes this\n"));
-  }
-
-  @Test
-  public void withFalseTags() throws Exception {
-    assertEquals(
-        "foo\n"
-            + //
-            "\n"
-            + //
-            "FakeLine:\n"
-            + //
-            "  foo\n"
-            + //
-            "  bar\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I67632a37fd2e08a35f766f52fc9a47f4ea868c55\n"
-            + //
-            "RealTag: abc\n", //
-        call(
-            "foo\n"
-                + //
-                "\n"
-                + //
-                "FakeLine:\n"
-                + //
-                "  foo\n"
-                + //
-                "  bar\n"
-                + //
-                "\n"
-                + //
-                "RealTag: abc\n"));
-  }
-
-  private void hookDoesNotModify(String in) throws Exception {
-    assertEquals(in, call(in));
-  }
-
-  private String call(String body) throws Exception {
-    final File tmp = write(body);
-    try {
-      final File hook = getHook("commit-msg");
-      assertEquals(0, runHook(repository, hook, tmp.getAbsolutePath()));
-      return read(tmp);
-    } finally {
-      tmp.delete();
-    }
-  }
-
-  private DirCacheEntry file(String name) throws IOException {
-    try (ObjectInserter oi = repository.newObjectInserter()) {
-      final DirCacheEntry e = new DirCacheEntry(name);
-      e.setFileMode(FileMode.REGULAR_FILE);
-      e.setObjectId(oi.insert(Constants.OBJ_BLOB, Constants.encode(name)));
-      oi.flush();
-      return e;
-    }
-  }
-
-  private void setHEAD() throws Exception {
-    try (ObjectInserter oi = repository.newObjectInserter()) {
-      final CommitBuilder commit = new CommitBuilder();
-      commit.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
-      commit.setAuthor(author);
-      commit.setCommitter(committer);
-      commit.setMessage("test\n");
-      ObjectId commitId = oi.insert(commit);
-
-      final RefUpdate ref = repository.updateRef(Constants.HEAD);
-      ref.setNewObjectId(commitId);
-      Result result = ref.forceUpdate();
-      assertWithMessage(Constants.HEAD + " did not change: " + ref.getResult())
-          .that(result)
-          .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/tools/hooks/HookTestCase.java b/javatests/com/google/gerrit/server/tools/hooks/HookTestCase.java
deleted file mode 100644
index ac1ce53..0000000
--- a/javatests/com/google/gerrit/server/tools/hooks/HookTestCase.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// Portions related to finding the hook script to execute are:
-// Copyright (C) 2008, Imran M Yousuf <imyousuf@smartitengineering.com>
-//
-// All rights reserved.
-//
-// Redistribution and use in source and binary forms, with or
-// without modification, are permitted provided that the following
-// conditions are met:
-//
-// - Redistributions of source code must retain the above copyright
-// notice, this list of conditions and the following disclaimer.
-//
-// - Redistributions in binary form must reproduce the above
-// copyright notice, this list of conditions and the following
-// disclaimer in the documentation and/or other materials provided
-// with the distribution.
-//
-// - Neither the name of the Git Development Community nor the
-// names of its contributors may be used to endorse or promote
-// products derived from this software without specific prior
-// written permission.
-//
-// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
-// CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
-// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
-// OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
-// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
-// NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
-// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
-// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-package com.google.gerrit.server.tools.hooks;
-
-import static com.google.common.truth.Truth.assertWithMessage;
-
-import com.google.common.io.ByteStreams;
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.URL;
-import java.nio.file.Files;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Ignore;
-
-@Ignore
-public abstract class HookTestCase extends LocalDiskRepositoryTestCase {
-  protected Repository repository;
-  private final Map<String, File> hooks = new TreeMap<>();
-  private final List<File> cleanup = new ArrayList<>();
-
-  @Override
-  @Before
-  public void setUp() throws Exception {
-    super.setUp();
-    repository = createWorkRepository();
-  }
-
-  @Override
-  @After
-  public void tearDown() throws Exception {
-    super.tearDown();
-    for (File p : cleanup) {
-      if (!p.delete()) {
-        p.deleteOnExit();
-      }
-    }
-    cleanup.clear();
-  }
-
-  protected File getHook(String name) throws IOException {
-    File hook = hooks.get(name);
-    if (hook != null) {
-      return hook;
-    }
-
-    String scproot = "com/google/gerrit/server/tools/root";
-    String path = scproot + "/hooks/" + name;
-    String errorMessage = "Cannot locate " + path + " in CLASSPATH";
-    URL url = cl().getResource(path);
-    assertWithMessage(errorMessage).that(url).isNotNull();
-
-    String protocol = url.getProtocol();
-    assertWithMessage("Cannot invoke " + url).that(protocol).isAnyOf("file", "jar");
-
-    if ("file".equals(protocol)) {
-      hook = new File(url.getPath());
-      assertWithMessage(errorMessage).that(hook.isFile()).isTrue();
-      long time = hook.lastModified();
-      hook.setExecutable(true);
-      hook.setLastModified(time);
-      hooks.put(name, hook);
-    } else if ("jar".equals(protocol)) {
-      try (InputStream in = url.openStream()) {
-        hook = File.createTempFile("hook_", ".sh");
-        cleanup.add(hook);
-        try (OutputStream out = Files.newOutputStream(hook.toPath())) {
-          ByteStreams.copy(in, out);
-        }
-      }
-      hook.setExecutable(true);
-      hooks.put(name, hook);
-    }
-    return hook;
-  }
-
-  private ClassLoader cl() {
-    return HookTestCase.class.getClassLoader();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 46820c7..6831fa3 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -1,39 +1,22 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
-MEDIUM_TESTS = ["RefUpdateUtilRepoTest.java"]
-
-junit_tests(
-    name = "medium_tests",
-    size = "medium",
-    timeout = "short",
-    srcs = MEDIUM_TESTS,
-    tags = ["no_windows"],
-    deps = [
-        "//java/com/google/gerrit/server",
-        "//lib:guava",
-        "//lib:junit",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
-        "//lib/truth",
-    ],
-)
-
 junit_tests(
     name = "small_tests",
     size = "small",
-    srcs = glob(
-        ["*.java"],
-        exclude = MEDIUM_TESTS,
-    ),
+    srcs = glob(["*.java"]),
+    runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
-        "//lib:gwtorm",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 67672d3..1d84d67 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -14,17 +14,35 @@
 
 package com.google.gerrit.server.update;
 
-import static org.junit.Assert.assertEquals;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.common.TimeUtil;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.notedb.TooManyUpdatesException;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
@@ -32,19 +50,31 @@
 import org.junit.Test;
 
 public class BatchUpdateTest {
-  @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
+  private static final int MAX_UPDATES = 4;
 
-  @Inject private GitRepositoryManager repoManager;
+  @Rule
+  public InMemoryTestEnvironment testEnvironment =
+      new InMemoryTestEnvironment(
+          () -> {
+            Config cfg = new Config();
+            cfg.setInt("change", null, "maxUpdates", MAX_UPDATES);
+            return cfg;
+          });
+
   @Inject private BatchUpdate.Factory batchUpdateFactory;
-  @Inject private ReviewDb db;
+  @Inject private ChangeInserter.Factory changeInserterFactory;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private PatchSetInserter.Factory patchSetInserterFactory;
   @Inject private Provider<CurrentUser> user;
+  @Inject private Sequences sequences;
 
   private Project.NameKey project;
   private TestRepository<Repository> repo;
 
   @Before
   public void setUp() throws Exception {
-    project = new Project.NameKey("test");
+    project = Project.nameKey("test");
 
     Repository inMemoryRepo = repoManager.createRepository(project);
     repo = new TestRepository<>(inMemoryRepo);
@@ -52,10 +82,10 @@
 
   @Test
   public void addRefUpdateFromFastForwardCommit() throws Exception {
-    final RevCommit masterCommit = repo.branch("master").commit().create();
-    final RevCommit branchCommit = repo.branch("branch").commit().parent(masterCommit).create();
+    RevCommit masterCommit = repo.branch("master").commit().create();
+    RevCommit branchCommit = repo.branch("branch").commit().parent(masterCommit).create();
 
-    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
       bu.addRepoOnlyOp(
           new RepoOnlyOp() {
             @Override
@@ -66,7 +96,223 @@
       bu.execute();
     }
 
-    assertEquals(
-        repo.getRepository().exactRef("refs/heads/master").getObjectId(), branchCommit.getId());
+    assertThat(repo.getRepository().exactRef("refs/heads/master").getObjectId())
+        .isEqualTo(branchCommit.getId());
+  }
+
+  @Test
+  public void cannotExceedMaxUpdates() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new AddMessageOp("Excessive update"));
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> bu.execute());
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(TooManyUpdatesException.message(id, MAX_UPDATES));
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void cannotExceedMaxUpdatesCountingMultipleChangeUpdatesInSingleBatch() throws Exception {
+    Change.Id id = createChangeWithTwoPatchSets(MAX_UPDATES - 1);
+
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new AddMessageOp("Update on PS1", PatchSet.id(id, 1)));
+      bu.addOp(id, new AddMessageOp("Update on PS2", PatchSet.id(id, 2)));
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> bu.execute());
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(TooManyUpdatesException.message(id, MAX_UPDATES));
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES - 1);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithCompleteNoOp() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              return false;
+            }
+          });
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithNoOpAfterPopulatingUpdate() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage("No-op");
+              return false;
+            }
+          });
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithSubmit() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new SubmitOp());
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1);
+    assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithSubmitAfterOtherOp() throws Exception {
+    Change.Id id = createChangeWithTwoPatchSets(MAX_UPDATES - 1);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new AddMessageOp("Message on PS1", PatchSet.id(id, 1)));
+      bu.addOp(id, new SubmitOp());
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1);
+    assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
+  }
+  // Not possible to write a variant of this test that submits first and adds a message second in
+  // the same batch, since submit always comes last.
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithAbandon() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+              update.setChangeMessage("Abandon");
+              update.setStatus(Change.Status.ABANDONED);
+              return true;
+            }
+          });
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1);
+    assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
+  }
+
+  private Change.Id createChangeWithUpdates(int totalUpdates) throws Exception {
+    checkArgument(totalUpdates > 0);
+    checkArgument(totalUpdates <= MAX_UPDATES);
+    Change.Id id = Change.id(sequences.nextChangeId());
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.insertChange(
+          changeInserterFactory.create(
+              id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(1);
+    for (int i = 2; i <= totalUpdates; i++) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+        bu.addOp(id, new AddMessageOp("Update " + i));
+        bu.execute();
+      }
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(totalUpdates);
+    return id;
+  }
+
+  private Change.Id createChangeWithTwoPatchSets(int totalUpdates) throws Exception {
+    Change.Id id = createChangeWithUpdates(totalUpdates - 1);
+    ChangeNotes notes = changeNotesFactory.create(project, id);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      ObjectId commitId = repo.amend(notes.getCurrentPatchSet().commitId()).message("PS2").create();
+      bu.addOp(
+          id,
+          patchSetInserterFactory
+              .create(notes, PatchSet.id(id, 2), commitId)
+              .setMessage("Add PS2"));
+      bu.execute();
+    }
+
+    assertThat(getUpdateCount(id)).isEqualTo(totalUpdates);
+    return id;
+  }
+
+  private static class AddMessageOp implements BatchUpdateOp {
+    private final String message;
+    @Nullable private final PatchSet.Id psId;
+
+    AddMessageOp(String message) {
+      this(message, null);
+    }
+
+    AddMessageOp(String message, PatchSet.Id psId) {
+      this.message = message;
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      PatchSet.Id psIdToUpdate = psId;
+      if (psIdToUpdate == null) {
+        psIdToUpdate = ctx.getChange().currentPatchSetId();
+      } else {
+        checkState(
+            ctx.getNotes().getPatchSets().containsKey(psIdToUpdate),
+            "%s not in %s",
+            psIdToUpdate,
+            ctx.getNotes().getPatchSets().keySet());
+      }
+      ctx.getUpdate(psIdToUpdate).setChangeMessage(message);
+      return true;
+    }
+  }
+
+  private int getUpdateCount(Change.Id changeId) throws Exception {
+    return changeNotesFactory.create(project, changeId).getUpdateCount();
+  }
+
+  private ObjectId getMetaId(Change.Id changeId) throws Exception {
+    return repo.getRepository().exactRef(RefNames.changeMetaRef(changeId)).getObjectId();
+  }
+
+  private static class SubmitOp implements BatchUpdateOp {
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      SubmitRecord sr = new SubmitRecord();
+      sr.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label cr = new SubmitRecord.Label();
+      cr.status = SubmitRecord.Label.Status.OK;
+      cr.appliedBy = ctx.getAccountId();
+      cr.label = "Code-Review";
+      sr.labels = ImmutableList.of(cr);
+      ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+      update.merge(new RequestId(), ImmutableList.of(sr));
+      update.setChangeMessage("Submitted");
+      return true;
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/server/update/RefUpdateUtilRepoTest.java b/javatests/com/google/gerrit/server/update/RefUpdateUtilRepoTest.java
deleted file mode 100644
index fe9d522..0000000
--- a/javatests/com/google/gerrit/server/update/RefUpdateUtilRepoTest.java
+++ /dev/null
@@ -1,117 +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.server.update;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.io.MoreFiles;
-import com.google.common.io.RecursiveDeleteOption;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.Parameterized;
-import org.junit.runners.Parameterized.Parameter;
-import org.junit.runners.Parameterized.Parameters;
-
-@RunWith(Parameterized.class)
-public class RefUpdateUtilRepoTest {
-  public enum RepoSetup {
-    LOCAL_DISK {
-      @Override
-      Repository setUpRepo() throws Exception {
-        Path p = Files.createTempDirectory("gerrit_repo_");
-        try {
-          Repository repo = new FileRepository(p.toFile());
-          repo.create(true);
-          return repo;
-        } catch (Exception e) {
-          delete(p);
-          throw e;
-        }
-      }
-
-      @Override
-      void tearDownRepo(Repository repo) throws Exception {
-        delete(repo.getDirectory().toPath());
-      }
-
-      private void delete(Path p) throws Exception {
-        MoreFiles.deleteRecursively(p, RecursiveDeleteOption.ALLOW_INSECURE);
-      }
-    },
-
-    IN_MEMORY {
-      @Override
-      Repository setUpRepo() {
-        return new InMemoryRepository(new DfsRepositoryDescription("repo"));
-      }
-
-      @Override
-      void tearDownRepo(Repository repo) {}
-    };
-
-    abstract Repository setUpRepo() throws Exception;
-
-    abstract void tearDownRepo(Repository repo) throws Exception;
-  }
-
-  @Parameters(name = "{0}")
-  public static ImmutableList<RepoSetup[]> data() {
-    return ImmutableList.copyOf(new RepoSetup[][] {{RepoSetup.LOCAL_DISK}, {RepoSetup.IN_MEMORY}});
-  }
-
-  @Parameter public RepoSetup repoSetup;
-
-  private Repository repo;
-
-  @Before
-  public void setUp() throws Exception {
-    repo = repoSetup.setUpRepo();
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    if (repo != null) {
-      repoSetup.tearDownRepo(repo);
-      repo = null;
-    }
-  }
-
-  @Test
-  public void deleteRefNoOp() throws Exception {
-    String ref = "refs/heads/foo";
-    assertThat(repo.exactRef(ref)).isNull();
-    RefUpdateUtil.deleteChecked(repo, "refs/heads/foo");
-    assertThat(repo.exactRef(ref)).isNull();
-  }
-
-  @Test
-  public void deleteRef() throws Exception {
-    String ref = "refs/heads/foo";
-    new TestRepository<>(repo).branch(ref).commit().create();
-    assertThat(repo.exactRef(ref)).isNotNull();
-    RefUpdateUtil.deleteChecked(repo, "refs/heads/foo");
-    assertThat(repo.exactRef(ref)).isNull();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/update/RefUpdateUtilTest.java b/javatests/com/google/gerrit/server/update/RefUpdateUtilTest.java
deleted file mode 100644
index fc8696a..0000000
--- a/javatests/com/google/gerrit/server/update/RefUpdateUtilTest.java
+++ /dev/null
@@ -1,119 +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.server.update;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.server.git.LockFailureException;
-import java.io.IOException;
-import java.util.function.Consumer;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
-
-@RunWith(JUnit4.class)
-public class RefUpdateUtilTest {
-  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
-  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
-      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
-  private static final Consumer<ReceiveCommand> REJECTED =
-      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
-  private static final Consumer<ReceiveCommand> ABORTED =
-      c -> {
-        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
-        ReceiveCommand.abort(ImmutableList.of(c));
-        checkState(
-            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
-                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
-                && c.getResult() != ReceiveCommand.Result.OK,
-            "unexpected state after abort: %s",
-            c);
-      };
-
-  @Test
-  public void checkBatchRefUpdateResults() throws Exception {
-    checkResults();
-    checkResults(OK);
-    checkResults(OK, OK);
-
-    assertIoException(REJECTED);
-    assertIoException(OK, REJECTED);
-    assertIoException(LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, OK);
-    assertIoException(LOCK_FAILURE, REJECTED, OK);
-    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, OK);
-
-    assertLockFailureException(LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
-    assertLockFailureException(ABORTED);
-    assertLockFailureException(ABORTED, ABORTED);
-  }
-
-  @SafeVarargs
-  private static void checkResults(Consumer<ReceiveCommand>... resultSetters) throws Exception {
-    RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
-  }
-
-  @SafeVarargs
-  private static void assertIoException(Consumer<ReceiveCommand>... resultSetters) {
-    try {
-      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
-      assert_().fail("expected IOException");
-    } catch (IOException e) {
-      assertThat(e).isNotInstanceOf(LockFailureException.class);
-    }
-  }
-
-  @SafeVarargs
-  private static void assertLockFailureException(Consumer<ReceiveCommand>... resultSetters)
-      throws Exception {
-    try {
-      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
-      assert_().fail("expected LockFailureException");
-    } catch (LockFailureException e) {
-      // Expected.
-    }
-  }
-
-  @SafeVarargs
-  private static BatchRefUpdate newBatchRefUpdate(Consumer<ReceiveCommand>... resultSetters) {
-    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
-      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-      for (int i = 0; i < resultSetters.length; i++) {
-        ReceiveCommand cmd =
-            new ReceiveCommand(
-                ObjectId.fromString(String.format("%039x1", i)),
-                ObjectId.fromString(String.format("%039x2", i)),
-                "refs/heads/branch" + i);
-        bru.addCommand(cmd);
-        resultSetters[i].accept(cmd);
-      }
-      return bru;
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/update/RepoViewTest.java b/javatests/com/google/gerrit/server/update/RepoViewTest.java
index 9f7deee..ea80633 100644
--- a/javatests/com/google/gerrit/server/update/RepoViewTest.java
+++ b/javatests/com/google/gerrit/server/update/RepoViewTest.java
@@ -41,7 +41,7 @@
   @Before
   public void setUp() throws Exception {
     InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
-    Project.NameKey project = new Project.NameKey("project");
+    Project.NameKey project = Project.nameKey("project");
     repo = repoManager.createRepository(project);
     tr = new TestRepository<>(repo);
     tr.branch(MASTER).commit().create();
diff --git a/javatests/com/google/gerrit/server/util/IdGeneratorTest.java b/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
index 39afcac..808eca8 100644
--- a/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
+++ b/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
@@ -34,11 +34,4 @@
     assertEquals(0xdeadbeef, IdGenerator.unmix(IdGenerator.mix(0xdeadbeef)));
     assertEquals(0x0b966b11, IdGenerator.unmix(IdGenerator.mix(0x0b966b11)));
   }
-
-  @Test
-  public void format() {
-    assertEquals("0000000f", IdGenerator.format(0xf));
-    assertEquals("801234ab", IdGenerator.format(0x801234ab));
-    assertEquals("deadbeef", IdGenerator.format(0xdeadbeef));
-  }
 }
diff --git a/javatests/com/google/gerrit/server/util/LabelVoteTest.java b/javatests/com/google/gerrit/server/util/LabelVoteTest.java
index 9069928..bda99a8 100644
--- a/javatests/com/google/gerrit/server/util/LabelVoteTest.java
+++ b/javatests/com/google/gerrit/server/util/LabelVoteTest.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.util;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.server.util.LabelVote.parse;
 import static com.google.gerrit.server.util.LabelVote.parseWithEquals;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import org.junit.Test;
 
@@ -82,11 +82,6 @@
   }
 
   private void assertParseWithEqualsFails(String value) {
-    try {
-      parseWithEquals(value);
-      assert_().fail("expected IllegalArgumentException when parsing \"%s\"", value);
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
+    assertThrows(IllegalArgumentException.class, () -> parseWithEquals(value));
   }
 }
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/RegexListSearcherTest.java b/javatests/com/google/gerrit/server/util/RegexListSearcherTest.java
deleted file mode 100644
index 01964a8..0000000
--- a/javatests/com/google/gerrit/server/util/RegexListSearcherTest.java
+++ /dev/null
@@ -1,65 +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 static com.google.common.truth.Truth8.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import java.util.List;
-import org.junit.Test;
-
-public class RegexListSearcherTest {
-  private static final ImmutableList<String> EMPTY = ImmutableList.of();
-
-  @Test
-  public void emptyList() {
-    assertSearchReturns(EMPTY, "pat", EMPTY);
-  }
-
-  @Test
-  public void anchors() {
-    List<String> list = ImmutableList.of("foo");
-    assertSearchReturns(list, "^f.*", list);
-    assertSearchReturns(list, "^f.*o$", list);
-    assertSearchReturns(list, "f.*o$", list);
-    assertSearchReturns(list, "f.*o$", list);
-    assertSearchReturns(EMPTY, "^.*\\$", list);
-  }
-
-  @Test
-  public void noCommonPrefix() {
-    List<String> list = ImmutableList.of("bar", "foo", "quux");
-    assertSearchReturns(ImmutableList.of("foo"), "f.*", list);
-    assertSearchReturns(ImmutableList.of("foo"), ".*o.*", list);
-    assertSearchReturns(ImmutableList.of("bar", "foo", "quux"), ".*[aou].*", list);
-  }
-
-  @Test
-  public void commonPrefix() {
-    List<String> list = ImmutableList.of("bar", "baz", "foo1", "foo2", "foo3", "quux");
-    assertSearchReturns(ImmutableList.of("bar", "baz"), "b.*", list);
-    assertSearchReturns(ImmutableList.of("foo1", "foo2"), "foo[12]", list);
-    assertSearchReturns(ImmutableList.of("foo1", "foo2", "foo3"), "foo.*", list);
-    assertSearchReturns(ImmutableList.of("quux"), "q.*", list);
-  }
-
-  private void assertSearchReturns(List<?> expected, String re, List<String> inputs) {
-    assertThat(inputs).isOrdered();
-    assertThat(RegexListSearcher.ofStrings(re).search(inputs))
-        .containsExactlyElementsIn(expected)
-        .inOrder();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/util/SocketUtilTest.java b/javatests/com/google/gerrit/server/util/SocketUtilTest.java
index 018b8db..25114f9 100644
--- a/javatests/com/google/gerrit/server/util/SocketUtilTest.java
+++ b/javatests/com/google/gerrit/server/util/SocketUtilTest.java
@@ -14,17 +14,18 @@
 
 package com.google.gerrit.server.util;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.util.SocketUtil.hostname;
 import static com.google.gerrit.server.util.SocketUtil.isIPv6;
 import static com.google.gerrit.server.util.SocketUtil.parse;
 import static com.google.gerrit.server.util.SocketUtil.resolve;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.net.InetAddress.getByName;
 import static java.net.InetSocketAddress.createUnresolved;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
@@ -32,7 +33,7 @@
 import java.net.UnknownHostException;
 import org.junit.Test;
 
-public class SocketUtilTest extends GerritBaseTests {
+public class SocketUtilTest {
   @Test
   public void testIsIPv6() throws UnknownHostException {
     final InetAddress ipv6 = getByName("1:2:3:4:5:6:7:8");
@@ -105,16 +106,16 @@
 
   @Test
   public void testParseInvalidIPv6() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("invalid IPv6: [:3");
-    parse("[:3", 80);
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> parse("[:3", 80));
+    assertThat(thrown).hasMessageThat().contains("invalid IPv6: [:3");
   }
 
   @Test
   public void testParseInvalidPort() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("invalid port: localhost:A");
-    parse("localhost:A", 80);
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> parse("localhost:A", 80));
+    assertThat(thrown).hasMessageThat().contains("invalid port: localhost:A");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/util/git/BUILD b/javatests/com/google/gerrit/server/util/git/BUILD
new file mode 100644
index 0000000..0cb7b8a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/util/git/BUILD
@@ -0,0 +1,30 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "git_tests",
+    size = "small",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server/util/git",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/truth",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:protobuf",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java b/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
new file mode 100644
index 0000000..50f28ab
--- /dev/null
+++ b/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
@@ -0,0 +1,423 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class SubmoduleSectionParserTest {
+  private static final String THIS_SERVER = "http://localhost/";
+
+  @Test
+  public void followMasterBranch() throws Exception {
+    Project.NameKey p = Project.nameKey("proj");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]\n"
+            + "path = localpath-to-a\n"
+            + "url = ssh://localhost/"
+            + p.get()
+            + "\n"
+            + "branch = master\n");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(
+                targetBranch, BranchNameKey.create(p, "master"), "localpath-to-a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void followMatchingBranch() throws Exception {
+    Project.NameKey p = Project.nameKey("a");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]\n"
+            + "path = a\n"
+            + "url = ssh://localhost/"
+            + p.get()
+            + "\n"
+            + "branch = .\n");
+
+    BranchNameKey targetBranch1 = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res1 =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch1).parseAllSections();
+
+    Set<SubmoduleSubscription> expected1 =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch1, BranchNameKey.create(p, "master"), "a"));
+
+    assertThat(res1).containsExactlyElementsIn(expected1);
+
+    BranchNameKey targetBranch2 = BranchNameKey.create(Project.nameKey("project"), "somebranch");
+
+    Set<SubmoduleSubscription> res2 =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch2).parseAllSections();
+
+    Set<SubmoduleSubscription> expected2 =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch2, BranchNameKey.create(p, "somebranch"), "a"));
+
+    assertThat(res2).containsExactlyElementsIn(expected2);
+  }
+
+  @Test
+  public void followAnotherBranch() throws Exception {
+    Project.NameKey p = Project.nameKey("a");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]\n"
+            + "path = a\n"
+            + "url = ssh://localhost/"
+            + p.get()
+            + "\n"
+            + "branch = anotherbranch\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p, "anotherbranch"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void withAnotherURI() throws Exception {
+    Project.NameKey p = Project.nameKey("a");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]\n"
+            + "path = a\n"
+            + "url = http://localhost:80/"
+            + p.get()
+            + "\n"
+            + "branch = master\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void withSlashesInProjectName() throws Exception {
+    Project.NameKey p = Project.nameKey("project/with/slashes/a");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"project/with/slashes/a\"]\n"
+            + "path = a\n"
+            + "url = http://localhost:80/"
+            + p.get()
+            + "\n"
+            + "branch = master\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void withSlashesInPath() throws Exception {
+    Project.NameKey p = Project.nameKey("a");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]\n"
+            + "path = a/b/c/d/e\n"
+            + "url = http://localhost:80/"
+            + p.get()
+            + "\n"
+            + "branch = master\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(
+                targetBranch, BranchNameKey.create(p, "master"), "a/b/c/d/e"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void withMoreSections() throws Exception {
+    Project.NameKey p1 = Project.nameKey("a");
+    Project.NameKey p2 = Project.nameKey("b");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]\n"
+            + "     path = a\n"
+            + "     url = ssh://localhost/"
+            + p1.get()
+            + "\n"
+            + "     branch = .\n"
+            + "[submodule \"b\"]\n"
+            + "		path = b\n"
+            + "		url = http://localhost:80/"
+            + p2.get()
+            + "\n"
+            + "		branch = master\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"),
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p2, "master"), "b"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void withSubProjectFound() throws Exception {
+    Project.NameKey p1 = Project.nameKey("a/b");
+    Project.NameKey p2 = Project.nameKey("b");
+    Config cfg = new Config();
+    cfg.fromText(
+        "\n"
+            + "[submodule \"a/b\"]\n"
+            + "path = a/b\n"
+            + "url = ssh://localhost/"
+            + p1.get()
+            + "\n"
+            + "branch = .\n"
+            + "[submodule \"b\"]\n"
+            + "path = b\n"
+            + "url = http://localhost/"
+            + p2.get()
+            + "\n"
+            + "branch = .\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p2, "master"), "b"),
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a/b"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void withAnInvalidSection() throws Exception {
+    Project.NameKey p1 = Project.nameKey("a");
+    Project.NameKey p2 = Project.nameKey("b");
+    Project.NameKey p3 = Project.nameKey("d");
+    Project.NameKey p4 = Project.nameKey("e");
+    Config cfg = new Config();
+    cfg.fromText(
+        "\n"
+            + "[submodule \"a\"]\n"
+            + "    path = a\n"
+            + "    url = ssh://localhost/"
+            + p1.get()
+            + "\n"
+            + "    branch = .\n"
+            + "[submodule \"b\"]\n"
+            // path missing
+            + "    url = http://localhost:80/"
+            + p2.get()
+            + "\n"
+            + "    branch = master\n"
+            + "[submodule \"c\"]\n"
+            + "    path = c\n"
+            // url missing
+            + "    branch = .\n"
+            + "[submodule \"d\"]\n"
+            + "    path = d-parent/the-d-folder\n"
+            + "    url = ssh://localhost/"
+            + p3.get()
+            + "\n"
+            // branch missing
+            + "[submodule \"e\"]\n"
+            + "    path = e\n"
+            + "    url = ssh://localhost/"
+            + p4.get()
+            + "\n"
+            + "    branch = refs/heads/master\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"),
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p4, "master"), "e"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void withSectionOfNonexistingProject() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText(
+        "\n"
+            + "[submodule \"a\"]\n"
+            + "path = a\n"
+            + "url = ssh://non-localhost/a\n"
+            // Project "a" doesn't exist
+            + "branch = .\\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    assertThat(res).isEmpty();
+  }
+
+  @Test
+  public void withSectionToOtherServer() throws Exception {
+    Project.NameKey p1 = Project.nameKey("a");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]"
+            + "path = a"
+            + "url = ssh://non-localhost/"
+            + p1.get()
+            + "\n"
+            + "branch = .");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    assertThat(res).isEmpty();
+  }
+
+  @Test
+  public void withRelativeURI() throws Exception {
+    Project.NameKey p1 = Project.nameKey("a");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]\n"
+            + "path = a\n"
+            + "url = ../"
+            + p1.get()
+            + "\n"
+            + "branch = master\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void withDeepRelativeURI() throws Exception {
+    Project.NameKey p1 = Project.nameKey("a");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]\n"
+            + "path = a\n"
+            + "url = ../../"
+            + p1.get()
+            + "\n"
+            + "branch = master\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("nested/project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void withOverlyDeepRelativeURI() throws Exception {
+    Project.NameKey p1 = Project.nameKey("nested/a");
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[submodule \"a\"]\n"
+            + "path = a\n"
+            + "url = ../../"
+            + p1.get()
+            + "\n"
+            + "branch = master\n");
+
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("nested/project"), "master");
+
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+}
diff --git a/javatests/com/google/gerrit/sshd/BUILD b/javatests/com/google/gerrit/sshd/BUILD
index ad7d8a9..7a5e18e 100644
--- a/javatests/com/google/gerrit/sshd/BUILD
+++ b/javatests/com/google/gerrit/sshd/BUILD
@@ -7,6 +7,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/sshd",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/mina:sshd",
         "//lib/truth",
     ],
diff --git a/javatests/com/google/gerrit/testing/GerritJUnitTest.java b/javatests/com/google/gerrit/testing/GerritJUnitTest.java
new file mode 100644
index 0000000..56dda08
--- /dev/null
+++ b/javatests/com/google/gerrit/testing/GerritJUnitTest.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+
+public class GerritJUnitTest {
+  private static class MyException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    MyException(String msg) {
+      super(msg);
+    }
+  }
+
+  private static class MySubException extends MyException {
+    private static final long serialVersionUID = 1L;
+
+    MySubException(String msg) {
+      super(msg);
+    }
+  }
+
+  @Test
+  public void assertThrowsCatchesSpecifiedExceptionType() {
+    MyException e =
+        assertThrows(
+            MyException.class,
+            () -> {
+              throw new MyException("foo");
+            });
+    assertThat(e).hasMessageThat().isEqualTo("foo");
+  }
+
+  @Test
+  public void assertThrowsCatchesSubclassOfSpecifiedExceptionType() {
+    MyException e =
+        assertThrows(
+            MyException.class,
+            () -> {
+              throw new MySubException("foo");
+            });
+    assertThat(e).isInstanceOf(MySubException.class);
+    assertThat(e).hasMessageThat().isEqualTo("foo");
+  }
+
+  @Test
+  public void assertThrowsConvertsUnexpectedExceptionTypeToAssertionError() {
+    try {
+      assertThrows(
+          IllegalStateException.class,
+          () -> {
+            throw new MyException("foo");
+          });
+      assertWithMessage("expected AssertionError").fail();
+    } catch (AssertionError e) {
+      assertThat(e).hasMessageThat().contains(IllegalStateException.class.getSimpleName());
+      assertThat(e).hasMessageThat().contains(MyException.class.getSimpleName());
+      assertThat(e).hasCauseThat().isInstanceOf(MyException.class);
+      assertThat(e).hasCauseThat().hasMessageThat().isEqualTo("foo");
+    }
+  }
+
+  @Test
+  public void assertThrowsThrowsAssertionErrorWhenNothingThrown() {
+    try {
+      assertThrows(MyException.class, () -> {});
+      assertWithMessage("expected AssertionError").fail();
+    } catch (AssertionError e) {
+      assertThat(e).hasMessageThat().contains(MyException.class.getSimpleName());
+      assertThat(e).hasCauseThat().isNull();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/testing/IndexVersionsTest.java b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
index 36247f8..0362ddc 100644
--- a/javatests/com/google/gerrit/testing/IndexVersionsTest.java
+++ b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.IndexVersions.ALL;
 import static com.google.gerrit.testing.IndexVersions.CURRENT;
 import static com.google.gerrit.testing.IndexVersions.PREVIOUS;
@@ -25,7 +26,7 @@
 import java.util.List;
 import org.junit.Test;
 
-public class IndexVersionsTest extends GerritBaseTests {
+public class IndexVersionsTest {
   private static final ChangeSchemaDefinitions SCHEMA_DEF = ChangeSchemaDefinitions.INSTANCE;
 
   @Test
@@ -133,8 +134,8 @@
   }
 
   private void assertIllegalArgument(String value, String expectedMessage) {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage(expectedMessage);
-    get(value);
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> get(value));
+    assertThat(thrown).hasMessageThat().contains(expectedMessage);
   }
 }
diff --git a/javatests/com/google/gerrit/util/http/BUILD b/javatests/com/google/gerrit/util/http/BUILD
index 48b4339..63af18d 100644
--- a/javatests/com/google/gerrit/util/http/BUILD
+++ b/javatests/com/google/gerrit/util/http/BUILD
@@ -4,11 +4,11 @@
     name = "http_tests",
     srcs = glob(["**/*.java"]),
     deps = [
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/util/http",
         "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:junit",
         "//lib:servlet-api-3_1-without-neverlink",
-        "//lib/easymock",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/util/http/RequestUtilTest.java b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
index 48b7b9c..bef9d4b1 100644
--- a/javatests/com/google/gerrit/util/http/RequestUtilTest.java
+++ b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
@@ -15,42 +15,53 @@
 package com.google.gerrit.util.http;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.util.http.RequestUtil.getEncodedPathInfo;
+import static com.google.gerrit.util.http.RequestUtil.getRestPathWithoutIds;
 
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import org.junit.Test;
 
 public class RequestUtilTest {
   @Test
-  public void emptyContextPath() {
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar")))
-        .isEqualTo("/foo/bar");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo%2Fbar")))
-        .isEqualTo("/foo%2Fbar");
+  public void getEncodedPathInfo_emptyContextPath() {
+    assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar"))).isEqualTo("/foo/bar");
+    assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
   }
 
   @Test
-  public void emptyServletPath() {
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo/bar")))
-        .isEqualTo("/foo/bar");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo%2Fbar")))
-        .isEqualTo("/foo%2Fbar");
+  public void getEncodedPathInfo_emptyServletPath() {
+    assertThat(getEncodedPathInfo(fakeRequest("", "/c", "/foo/bar"))).isEqualTo("/foo/bar");
+    assertThat(getEncodedPathInfo(fakeRequest("", "/c", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
   }
 
   @Test
-  public void trailingSlashes() {
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar/")))
-        .isEqualTo("/foo/bar/");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar///")))
-        .isEqualTo("/foo/bar/");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar/")))
-        .isEqualTo("/foo%2Fbar/");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar///")))
+  public void getEncodedPathInfo_trailingSlashes() {
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar/"))).isEqualTo("/foo/bar/");
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar///"))).isEqualTo("/foo/bar/");
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar/"))).isEqualTo("/foo%2Fbar/");
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar///")))
         .isEqualTo("/foo%2Fbar/");
   }
 
   @Test
   public void emptyPathInfo() {
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", ""))).isNull();
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", ""))).isNull();
+  }
+
+  @Test
+  public void getRestPathWithoutIds_emptyContextPath() {
+    assertThat(getRestPathWithoutIds(fakeRequest("", "/a/accounts", "/123/test")))
+        .isEqualTo("/accounts/test");
+    assertThat(getRestPathWithoutIds(fakeRequest("", "/accounts", "/123/test")))
+        .isEqualTo("/accounts/test");
+  }
+
+  @Test
+  public void getRestPathWithoutIds_nonEmptyContextPath() {
+    assertThat(getRestPathWithoutIds(fakeRequest("/c", "/a/accounts", "/123/test")))
+        .isEqualTo("/accounts/test");
+    assertThat(getRestPathWithoutIds(fakeRequest("/c", "/accounts", "/123/test")))
+        .isEqualTo("/accounts/test");
   }
 
   private FakeHttpServletRequest fakeRequest(
diff --git a/javatests/com/google/gerrit/util/http/testutil/BUILD b/javatests/com/google/gerrit/util/http/testutil/BUILD
index b925188..adae68e 100644
--- a/javatests/com/google/gerrit/util/http/testutil/BUILD
+++ b/javatests/com/google/gerrit/util/http/testutil/BUILD
@@ -1,6 +1,6 @@
 java_library(
     name = "testutil",
-    testonly = 1,
+    testonly = True,
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index 56734ff..a4175e3 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.util.http.testutil;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
@@ -72,11 +72,11 @@
   }
 
   public FakeHttpServletRequest(String hostName, int port, String contextPath, String servletPath) {
-    this.hostName = checkNotNull(hostName, "hostName");
+    this.hostName = requireNonNull(hostName, "hostName");
     checkArgument(port > 0);
     this.port = port;
-    this.contextPath = checkNotNull(contextPath, "contextPath");
-    this.servletPath = checkNotNull(servletPath, "servletPath");
+    this.contextPath = requireNonNull(contextPath, "contextPath");
+    this.servletPath = requireNonNull(servletPath, "servletPath");
     attributes = Maps.newConcurrentMap();
     parameters = LinkedListMultimap.create();
     headers = LinkedListMultimap.create();
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
index 2b1a07e..9a98ecd 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.util.http.testutil;
 
 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 java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
@@ -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;
   }
@@ -261,7 +262,7 @@
 
   @Override
   public String getHeader(String name) {
-    return Iterables.getFirst(headers.get(checkNotNull(name.toLowerCase())), null);
+    return Iterables.getFirst(headers.get(requireNonNull(name.toLowerCase())), null);
   }
 
   @Override
@@ -271,7 +272,7 @@
 
   @Override
   public Collection<String> getHeaders(String name) {
-    return headers.get(checkNotNull(name.toLowerCase()));
+    return headers.get(requireNonNull(name.toLowerCase()));
   }
 
   public byte[] getActualBody() {
diff --git a/javatests/com/google/gwtexpui/safehtml/BUILD b/javatests/com/google/gwtexpui/safehtml/BUILD
deleted file mode 100644
index 694f422..0000000
--- a/javatests/com/google/gwtexpui/safehtml/BUILD
+++ /dev/null
@@ -1,13 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-junit_tests(
-    name = "safehtml_tests",
-    srcs = glob(["client/**/*.java"]),
-    deps = [
-        "//java/com/google/gwtexpui/safehtml",
-        "//lib:guava",
-        "//lib/gwt:dev",
-        "//lib/gwt:user",
-        "//lib/truth",
-    ],
-)
diff --git a/javatests/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java b/javatests/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java
deleted file mode 100644
index a77c5b4..0000000
--- a/javatests/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.gwtexpui.safehtml.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-
-public class LinkFindReplaceTest {
-  @Rule public ExpectedException exception = ExpectedException.none();
-
-  @Test
-  public void noEscaping() {
-    String find = "find";
-    String link = "link";
-    LinkFindReplace a = new LinkFindReplace(find, link);
-    assertThat(a.pattern().getSource()).isEqualTo(find);
-    assertThat(a.replace(find)).isEqualTo("<a href=\"link\">find</a>");
-    assertThat(a.toString()).isEqualTo("find = " + find + ", link = " + link);
-  }
-
-  @Test
-  public void backreference() {
-    LinkFindReplace l = new LinkFindReplace("(bug|issue)\\s*([0-9]+)", "/bug?id=$2");
-    assertThat(l.replace("issue 123")).isEqualTo("<a href=\"/bug?id=123\">issue 123</a>");
-  }
-
-  @Test
-  public void hasValidScheme() {
-    assertThat(LinkFindReplace.hasValidScheme("/absolute/path")).isTrue();
-    assertThat(LinkFindReplace.hasValidScheme("relative/path")).isTrue();
-    assertThat(LinkFindReplace.hasValidScheme("http://url/")).isTrue();
-    assertThat(LinkFindReplace.hasValidScheme("HTTP://url/")).isTrue();
-    assertThat(LinkFindReplace.hasValidScheme("https://url/")).isTrue();
-    assertThat(LinkFindReplace.hasValidScheme("mailto://url/")).isTrue();
-    assertThat(LinkFindReplace.hasValidScheme("ftp://url/")).isFalse();
-    assertThat(LinkFindReplace.hasValidScheme("data:evil")).isFalse();
-    assertThat(LinkFindReplace.hasValidScheme("javascript:alert(1)")).isFalse();
-  }
-
-  @Test
-  public void invalidSchemeInReplace() {
-    exception.expect(IllegalArgumentException.class);
-    new LinkFindReplace("find", "javascript:alert(1)").replace("find");
-  }
-
-  @Test
-  public void invalidSchemeWithBackreference() {
-    exception.expect(IllegalArgumentException.class);
-    new LinkFindReplace(".*(script:[^;]*)", "java$1").replace("Look at this script: alert(1);");
-  }
-
-  @Test
-  public void replaceEscaping() {
-    assertThat(new LinkFindReplace("find", "a\"&'<>b").replace("find"))
-        .isEqualTo("<a href=\"a&quot;&amp;&#39;&lt;&gt;b\">find</a>");
-  }
-
-  @Test
-  public void htmlInFind() {
-    String rawFind = "<b>&quot;bold&quot;</b>";
-    LinkFindReplace a = new LinkFindReplace(rawFind, "/bold");
-    assertThat(a.pattern().getSource()).isEqualTo(rawFind);
-    assertThat(a.replace(rawFind)).isEqualTo("<a href=\"/bold\">" + rawFind + "</a>");
-  }
-}
diff --git a/javatests/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java b/javatests/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java
deleted file mode 100644
index 3b5e769..0000000
--- a/javatests/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class RawFindReplaceTest {
-  @Test
-  public void findReplace() {
-    final String find = "find";
-    final String replace = "replace";
-    final RawFindReplace a = new RawFindReplace(find, replace);
-    assertThat(a.pattern().getSource()).isEqualTo(find);
-    assertThat(a.replace(find)).isEqualTo(replace);
-    assertThat(a.toString()).isEqualTo("find = " + find + ", replace = " + replace);
-  }
-}
diff --git a/javatests/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
deleted file mode 100644
index 9a2dbe3..0000000
--- a/javatests/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
+++ /dev/null
@@ -1,290 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-
-public class SafeHtmlBuilderTest {
-  @Rule public ExpectedException exception = ExpectedException.none();
-
-  @Test
-  public void empty() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b.isEmpty()).isTrue();
-    assertThat(b.hasContent()).isFalse();
-    assertThat(b.asString()).isEmpty();
-
-    b.append("a");
-    assertThat(b.hasContent()).isTrue();
-    assertThat(b.asString()).isEqualTo("a");
-  }
-
-  @Test
-  public void toSafeHtml() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    b.append(1);
-
-    final SafeHtml h = b.toSafeHtml();
-    assertThat(h).isNotNull();
-    assertThat(h).isNotSameAs(b);
-    assertThat(h).isNotInstanceOf(SafeHtmlBuilder.class);
-    assertThat(h.asString()).isEqualTo("1");
-  }
-
-  @Test
-  public void append_boolean() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append(true));
-    assertThat(b).isSameAs(b.append(false));
-    assertThat(b.asString()).isEqualTo("truefalse");
-  }
-
-  @Test
-  public void append_char() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append('a'));
-    assertThat(b).isSameAs(b.append('b'));
-    assertThat(b.asString()).isEqualTo("ab");
-  }
-
-  @Test
-  public void append_int() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append(4));
-    assertThat(b).isSameAs(b.append(2));
-    assertThat(b).isSameAs(b.append(-100));
-    assertThat(b.asString()).isEqualTo("42-100");
-  }
-
-  @Test
-  public void append_long() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append(4L));
-    assertThat(b).isSameAs(b.append(2L));
-    assertThat(b.asString()).isEqualTo("42");
-  }
-
-  @Test
-  public void append_float() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append(0.0f));
-    assertThat(b.asString()).isEqualTo("0.0");
-  }
-
-  @Test
-  public void append_double() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append(0.0));
-    assertThat(b.asString()).isEqualTo("0.0");
-  }
-
-  @Test
-  public void append_String() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append((String) null));
-    assertThat(b.asString()).isEmpty();
-    assertThat(b).isSameAs(b.append("foo"));
-    assertThat(b).isSameAs(b.append("bar"));
-    assertThat(b.asString()).isEqualTo("foobar");
-  }
-
-  @Test
-  public void append_StringBuilder() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append((StringBuilder) null));
-    assertThat(b.asString()).isEmpty();
-    assertThat(b).isSameAs(b.append(new StringBuilder("foo")));
-    assertThat(b).isSameAs(b.append(new StringBuilder("bar")));
-    assertThat(b.asString()).isEqualTo("foobar");
-  }
-
-  @Test
-  public void append_StringBuffer() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append((StringBuffer) null));
-    assertThat(b.asString()).isEmpty();
-    assertThat(b).isSameAs(b.append(new StringBuffer("foo")));
-    assertThat(b).isSameAs(b.append(new StringBuffer("bar")));
-    assertThat(b.asString()).isEqualTo("foobar");
-  }
-
-  @Test
-  public void append_Object() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append((Object) null));
-    assertThat(b.asString()).isEmpty();
-    assertThat(b)
-        .isSameAs(
-            b.append(
-                new Object() {
-                  @Override
-                  public String toString() {
-                    return "foobar";
-                  }
-                }));
-    assertThat(b.asString()).isEqualTo("foobar");
-  }
-
-  @Test
-  public void append_CharSequence() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append((CharSequence) null));
-    assertThat(b.asString()).isEmpty();
-    assertThat(b).isSameAs(b.append((CharSequence) "foo"));
-    assertThat(b).isSameAs(b.append((CharSequence) "bar"));
-    assertThat(b.asString()).isEqualTo("foobar");
-  }
-
-  @Test
-  public void append_SafeHtml() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.append((SafeHtml) null));
-    assertThat(b.asString()).isEmpty();
-    assertThat(b).isSameAs(b.append(new SafeHtmlString("foo")));
-    assertThat(b).isSameAs(b.append(new SafeHtmlBuilder().append("bar")));
-    assertThat(b.asString()).isEqualTo("foobar");
-  }
-
-  @Test
-  public void htmlSpecialCharacters() {
-    assertThat(escape("&")).isEqualTo("&amp;");
-    assertThat(escape("<")).isEqualTo("&lt;");
-    assertThat(escape(">")).isEqualTo("&gt;");
-    assertThat(escape("\"")).isEqualTo("&quot;");
-    assertThat(escape("'")).isEqualTo("&#39;");
-
-    assertThat(escape('&')).isEqualTo("&amp;");
-    assertThat(escape('<')).isEqualTo("&lt;");
-    assertThat(escape('>')).isEqualTo("&gt;");
-    assertThat(escape('"')).isEqualTo("&quot;");
-    assertThat(escape('\'')).isEqualTo("&#39;");
-
-    assertThat(escape("<b>")).isEqualTo("&lt;b&gt;");
-    assertThat(escape("&lt;b&gt;")).isEqualTo("&amp;lt;b&amp;gt;");
-  }
-
-  @Test
-  public void entityNbsp() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.nbsp());
-    assertThat(b.asString()).isEqualTo("&nbsp;");
-  }
-
-  @Test
-  public void tagBr() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.br());
-    assertThat(b.asString()).isEqualTo("<br />");
-  }
-
-  @Test
-  public void tagTableTrTd() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.openElement("table"));
-    assertThat(b).isSameAs(b.openTr());
-    assertThat(b).isSameAs(b.openTd());
-    assertThat(b).isSameAs(b.append("d<a>ta"));
-    assertThat(b).isSameAs(b.closeTd());
-    assertThat(b).isSameAs(b.closeTr());
-    assertThat(b).isSameAs(b.closeElement("table"));
-    assertThat(b.asString()).isEqualTo("<table><tr><td>d&lt;a&gt;ta</td></tr></table>");
-  }
-
-  @Test
-  public void tagDiv() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.openDiv());
-    assertThat(b).isSameAs(b.append("d<a>ta"));
-    assertThat(b).isSameAs(b.closeDiv());
-    assertThat(b.asString()).isEqualTo("<div>d&lt;a&gt;ta</div>");
-  }
-
-  @Test
-  public void tagAnchor() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.openAnchor());
-
-    assertThat(b.getAttribute("href")).isEmpty();
-    assertThat(b).isSameAs(b.setAttribute("href", "http://here"));
-    assertThat(b.getAttribute("href")).isEqualTo("http://here");
-    assertThat(b).isSameAs(b.setAttribute("href", "d<a>ta"));
-    assertThat(b.getAttribute("href")).isEqualTo("d<a>ta");
-
-    assertThat(b.getAttribute("target")).isEmpty();
-    assertThat(b).isSameAs(b.setAttribute("target", null));
-    assertThat(b.getAttribute("target")).isEmpty();
-
-    assertThat(b).isSameAs(b.append("go"));
-    assertThat(b).isSameAs(b.closeAnchor());
-    assertThat(b.asString()).isEqualTo("<a href=\"d&lt;a&gt;ta\">go</a>");
-  }
-
-  @Test
-  public void tagHeightWidth() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.openElement("img"));
-    assertThat(b).isSameAs(b.setHeight(100));
-    assertThat(b).isSameAs(b.setWidth(42));
-    assertThat(b).isSameAs(b.closeSelf());
-    assertThat(b.asString()).isEqualTo("<img height=\"100\" width=\"42\" />");
-  }
-
-  @Test
-  public void styleName() {
-    final SafeHtmlBuilder b = new SafeHtmlBuilder();
-    assertThat(b).isSameAs(b.openSpan());
-    assertThat(b).isSameAs(b.setStyleName("foo"));
-    assertThat(b).isSameAs(b.addStyleName("bar"));
-    assertThat(b).isSameAs(b.append("d<a>ta"));
-    assertThat(b).isSameAs(b.closeSpan());
-    assertThat(b.asString()).isEqualTo("<span class=\"foo bar\">d&lt;a&gt;ta</span>");
-  }
-
-  @Test
-  public void rejectJavaScript_AnchorHref() {
-    final String href = "javascript:window.close();";
-    exception.expect(RuntimeException.class);
-    exception.expectMessage("javascript unsafe in href: " + href);
-    new SafeHtmlBuilder().openAnchor().setAttribute("href", href);
-  }
-
-  @Test
-  public void rejectJavaScript_ImgSrc() {
-    final String href = "javascript:window.close();";
-    exception.expect(RuntimeException.class);
-    exception.expectMessage("javascript unsafe in href: " + href);
-    new SafeHtmlBuilder().openElement("img").setAttribute("src", href);
-  }
-
-  @Test
-  public void rejectJavaScript_FormAction() {
-    final String href = "javascript:window.close();";
-    exception.expect(RuntimeException.class);
-    exception.expectMessage("javascript unsafe in href: " + href);
-    new SafeHtmlBuilder().openElement("form").setAttribute("action", href);
-  }
-
-  private static String escape(char c) {
-    return new SafeHtmlBuilder().append(c).asString();
-  }
-
-  private static String escape(String c) {
-    return new SafeHtmlBuilder().append(c).asString();
-  }
-}
diff --git a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
deleted file mode 100644
index b42878b..0000000
--- a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
+++ /dev/null
@@ -1,124 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class SafeHtml_LinkifyTest {
-  @Test
-  public void linkify_SimpleHttp1() {
-    final SafeHtml o = html("A http://go.here/ B");
-    final SafeHtml n = o.linkify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/</a> B");
-  }
-
-  @Test
-  public void linkify_SimpleHttps2() {
-    final SafeHtml o = html("A https://go.here/ B");
-    final SafeHtml n = o.linkify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A <a href=\"https://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">https://go.here/</a> B");
-  }
-
-  @Test
-  public void linkify_Parens1() {
-    final SafeHtml o = html("A (http://go.here/) B");
-    final SafeHtml n = o.linkify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A (<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/</a>) B");
-  }
-
-  @Test
-  public void linkify_Parens() {
-    final SafeHtml o = html("A http://go.here/#m() B");
-    final SafeHtml n = o.linkify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A <a href=\"http://go.here/#m()\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/#m()</a> B");
-  }
-
-  @Test
-  public void linkify_AngleBrackets1() {
-    final SafeHtml o = html("A <http://go.here/> B");
-    final SafeHtml n = o.linkify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A &lt;<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/</a>&gt; B");
-  }
-
-  @Test
-  public void linkify_TrailingPlainLetter() {
-    final SafeHtml o = html("A http://go.here/foo B");
-    final SafeHtml n = o.linkify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A <a href=\"http://go.here/foo\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/foo</a> B");
-  }
-
-  @Test
-  public void linkify_TrailingDot() {
-    final SafeHtml o = html("A http://go.here/. B");
-    final SafeHtml n = o.linkify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/</a>. B");
-  }
-
-  @Test
-  public void linkify_TrailingComma() {
-    final SafeHtml o = html("A http://go.here/, B");
-    final SafeHtml n = o.linkify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/</a>, B");
-  }
-
-  @Test
-  public void linkify_TrailingDotDot() {
-    final SafeHtml o = html("A http://go.here/.. B");
-    final SafeHtml n = o.linkify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A <a href=\"http://go.here/.\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/.</a>. B");
-  }
-
-  private static SafeHtml html(String text) {
-    return new SafeHtmlBuilder().append(text).toSafeHtml();
-  }
-}
diff --git a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java
deleted file mode 100644
index ac0f6fd6..0000000
--- a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtexpui.safehtml.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import org.junit.Test;
-
-public class SafeHtml_ReplaceTest {
-  @Test
-  public void replaceEmpty() {
-    SafeHtml o = html("A\nissue42\nB");
-    assertThat(o.replaceAll(null)).isSameAs(o);
-    assertThat(o.replaceAll(Collections.<FindReplace>emptyList())).isSameAs(o);
-  }
-
-  @Test
-  public void replaceOneLink() {
-    SafeHtml o = html("A\nissue 42\nB");
-    SafeHtml n =
-        o.replaceAll(repls(new RawFindReplace("(issue\\s(\\d+))", "<a href=\"?$2\">$1</a>")));
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo("A\n<a href=\"?42\">issue 42</a>\nB");
-  }
-
-  @Test
-  public void replaceNoLeadingOrTrailingText() {
-    SafeHtml o = html("issue 42");
-    SafeHtml n =
-        o.replaceAll(repls(new RawFindReplace("(issue\\s(\\d+))", "<a href=\"?$2\">$1</a>")));
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo("<a href=\"?42\">issue 42</a>");
-  }
-
-  @Test
-  public void replaceTwoLinks() {
-    SafeHtml o = html("A\nissue 42\nissue 9918\nB");
-    SafeHtml n =
-        o.replaceAll(repls(new RawFindReplace("(issue\\s(\\d+))", "<a href=\"?$2\">$1</a>")));
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo("A\n<a href=\"?42\">issue 42</a>\n<a href=\"?9918\">issue 9918</a>\nB");
-  }
-
-  @Test
-  public void replaceInOrder() {
-    SafeHtml o = html("A\nissue 42\nReally GWTEXPUI-9918 is better\nB");
-    SafeHtml n =
-        o.replaceAll(
-            repls(
-                new RawFindReplace("(GWTEXPUI-(\\d+))", "<a href=\"gwtexpui-bug?$2\">$1</a>"),
-                new RawFindReplace("(issue\\s+(\\d+))", "<a href=\"generic-bug?$2\">$1</a>")));
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "A\n"
-                + "<a href=\"generic-bug?42\">issue 42</a>\n"
-                + "Really <a href=\"gwtexpui-bug?9918\">GWTEXPUI-9918</a> is better\n"
-                + "B");
-  }
-
-  @Test
-  public void replaceOverlappingAfterFirstChar() {
-    SafeHtml o = html("abcd");
-    RawFindReplace ab = new RawFindReplace("ab", "AB");
-    RawFindReplace bc = new RawFindReplace("bc", "23");
-    RawFindReplace cd = new RawFindReplace("cd", "YZ");
-
-    assertThat(o.replaceAll(repls(ab, bc)).asString()).isEqualTo("ABcd");
-    assertThat(o.replaceAll(repls(bc, ab)).asString()).isEqualTo("ABcd");
-    assertThat(o.replaceAll(repls(ab, bc, cd)).asString()).isEqualTo("ABYZ");
-  }
-
-  @Test
-  public void replaceOverlappingAtFirstCharLongestMatch() {
-    SafeHtml o = html("abcd");
-    RawFindReplace ab = new RawFindReplace("ab", "AB");
-    RawFindReplace abc = new RawFindReplace("[^d][^d][^d]", "234");
-
-    assertThat(o.replaceAll(repls(ab, abc)).asString()).isEqualTo("ABcd");
-    assertThat(o.replaceAll(repls(abc, ab)).asString()).isEqualTo("234d");
-  }
-
-  @Test
-  public void replaceOverlappingAtFirstCharFirstMatch() {
-    SafeHtml o = html("abcd");
-    RawFindReplace ab1 = new RawFindReplace("ab", "AB");
-    RawFindReplace ab2 = new RawFindReplace("[^cd][^cd]", "12");
-
-    assertThat(o.replaceAll(repls(ab1, ab2)).asString()).isEqualTo("ABcd");
-    assertThat(o.replaceAll(repls(ab2, ab1)).asString()).isEqualTo("12cd");
-  }
-
-  @Test
-  public void failedSanitization() {
-    SafeHtml o = html("abcd");
-    LinkFindReplace evil = new LinkFindReplace("(b)", "javascript:alert('$1')");
-    LinkFindReplace ok = new LinkFindReplace("(b)", "/$1");
-    assertThat(o.replaceAll(repls(evil)).asString()).isEqualTo("abcd");
-    String linked = "a<a href=\"/b\">b</a>cd";
-    assertThat(o.replaceAll(repls(ok)).asString()).isEqualTo(linked);
-    assertThat(o.replaceAll(repls(evil, ok)).asString()).isEqualTo(linked);
-  }
-
-  private static SafeHtml html(String text) {
-    return new SafeHtmlBuilder().append(text).toSafeHtml();
-  }
-
-  private static List<FindReplace> repls(FindReplace... repls) {
-    return Arrays.asList(repls);
-  }
-}
diff --git a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java
deleted file mode 100644
index d69b36c..0000000
--- a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "<p>AS IS" BASIS,
-// WITHOUT 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.gwtexpui.safehtml.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class SafeHtml_WikifyListTest {
-  private static final String BEGIN_LIST = "<ul class=\"wikiList\">";
-  private static final String END_LIST = "</ul>";
-
-  private static String item(String raw) {
-    return "<li>" + raw + "</li>";
-  }
-
-  @Test
-  public void bulletList1() {
-    final SafeHtml o = html("A\n\n* line 1\n* 2nd line");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo("<p>A</p>" + BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST);
-  }
-
-  @Test
-  public void bulletList2() {
-    final SafeHtml o = html("A\n\n* line 1\n* 2nd line\n\nB");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>A</p>" + BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST + "<p>B</p>");
-  }
-
-  @Test
-  public void bulletList3() {
-    final SafeHtml o = html("* line 1\n* 2nd line\n\nB");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST + "<p>B</p>");
-  }
-
-  @Test
-  public void bulletList4() {
-    final SafeHtml o =
-        html(
-            "To see this bug, you have to:\n" //
-                + "* Be on IMAP or EAS (not on POP)\n" //
-                + "* Be very unlucky\n");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>To see this bug, you have to:</p>"
-                + BEGIN_LIST
-                + item("Be on IMAP or EAS (not on POP)")
-                + item("Be very unlucky")
-                + END_LIST);
-  }
-
-  @Test
-  public void bulletList5() {
-    final SafeHtml o =
-        html(
-            "To see this bug,\n" //
-                + "you have to:\n" //
-                + "* Be on IMAP or EAS (not on POP)\n" //
-                + "* Be very unlucky\n");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>To see this bug, you have to:</p>"
-                + BEGIN_LIST
-                + item("Be on IMAP or EAS (not on POP)")
-                + item("Be very unlucky")
-                + END_LIST);
-  }
-
-  @Test
-  public void dashList1() {
-    final SafeHtml o = html("A\n\n- line 1\n- 2nd line");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo("<p>A</p>" + BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST);
-  }
-
-  @Test
-  public void dashList2() {
-    final SafeHtml o = html("A\n\n- line 1\n- 2nd line\n\nB");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>A</p>" + BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST + "<p>B</p>");
-  }
-
-  @Test
-  public void dashList3() {
-    final SafeHtml o = html("- line 1\n- 2nd line\n\nB");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST + "<p>B</p>");
-  }
-
-  private static SafeHtml html(String text) {
-    return new SafeHtmlBuilder().append(text).toSafeHtml();
-  }
-}
diff --git a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java
deleted file mode 100644
index 1346cda..0000000
--- a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "<p>AS IS" BASIS,
-// WITHOUT 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.gwtexpui.safehtml.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class SafeHtml_WikifyPreformatTest {
-  private static final String B = "<span class=\"wikiPreFormat\">";
-  private static final String E = "</span><br />";
-
-  private static String pre(String raw) {
-    return B + raw + E;
-  }
-
-  @Test
-  public void preformat1() {
-    final SafeHtml o = html("A\n\n  This is pre\n  formatted");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo("<p>A</p><p>" + pre("  This is pre") + pre("  formatted") + "</p>");
-  }
-
-  @Test
-  public void preformat2() {
-    final SafeHtml o = html("A\n\n  This is pre\n  formatted\n\nbut this is not");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>A</p>"
-                + "<p>"
-                + pre("  This is pre")
-                + pre("  formatted")
-                + "</p>"
-                + "<p>but this is not</p>");
-  }
-
-  @Test
-  public void preformat3() {
-    final SafeHtml o = html("A\n\n  Q\n    <R>\n  S\n\nB");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>A</p>"
-                + "<p>"
-                + pre("  Q")
-                + pre("    &lt;R&gt;")
-                + pre("  S")
-                + "</p>"
-                + "<p>B</p>");
-  }
-
-  @Test
-  public void preformat4() {
-    final SafeHtml o = html("  Q\n    <R>\n  S\n\nB");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo("<p>" + pre("  Q") + pre("    &lt;R&gt;") + pre("  S") + "</p><p>B</p>");
-  }
-
-  private static SafeHtml html(String text) {
-    return new SafeHtmlBuilder().append(text).toSafeHtml();
-  }
-}
diff --git a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java
deleted file mode 100644
index 2008447..0000000
--- a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "<p>AS IS" BASIS,
-// WITHOUT 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.gwtexpui.safehtml.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class SafeHtml_WikifyQuoteTest {
-  private static final String B = "<blockquote class=\"wikiQuote\">";
-  private static final String E = "</blockquote>";
-
-  private static String quote(String raw) {
-    return B + raw + E;
-  }
-
-  @Test
-  public void quote1() {
-    final SafeHtml o = html("> I'm happy\n > with quotes!\n\nSee above.");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(quote("I&#39;m happy\nwith quotes!") + "<p>See above.</p>");
-  }
-
-  @Test
-  public void quote2() {
-    final SafeHtml o = html("See this said:\n\n > a quoted\n > string block\n\nOK?");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo("<p>See this said:</p>" + quote("a quoted\nstring block") + "<p>OK?</p>");
-  }
-
-  @Test
-  public void nestedQuotes1() {
-    final SafeHtml o = html(" > > prior\n > \n > next\n");
-    final SafeHtml n = o.wikify();
-    assertThat(n.asString()).isEqualTo(quote(quote("prior") + "next\n"));
-  }
-
-  private static SafeHtml html(String text) {
-    return new SafeHtmlBuilder().append(text).toSafeHtml();
-  }
-}
diff --git a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
deleted file mode 100644
index 166af97..0000000
--- a/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "<p>AS IS" BASIS,
-// WITHOUT 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.gwtexpui.safehtml.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class SafeHtml_WikifyTest {
-  @Test
-  public void wikify_OneLine1() {
-    final SafeHtml o = html("A  B");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo("<p>A  B</p>");
-  }
-
-  @Test
-  public void wikify_OneLine2() {
-    final SafeHtml o = html("A  B\n");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo("<p>A  B\n</p>");
-  }
-
-  @Test
-  public void wikify_OneParagraph1() {
-    final SafeHtml o = html("A\nB");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo("<p>A\nB</p>");
-  }
-
-  @Test
-  public void wikify_OneParagraph2() {
-    final SafeHtml o = html("A\nB\n");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo("<p>A\nB\n</p>");
-  }
-
-  @Test
-  public void wikify_TwoParagraphs() {
-    final SafeHtml o = html("A\nB\n\nC\nD");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo("<p>A\nB</p><p>C\nD</p>");
-  }
-
-  @Test
-  public void linkify_SimpleHttp1() {
-    final SafeHtml o = html("A http://go.here/ B");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/</a> B</p>");
-  }
-
-  @Test
-  public void linkify_SimpleHttps2() {
-    final SafeHtml o = html("A https://go.here/ B");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>A <a href=\"https://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">https://go.here/</a> B</p>");
-  }
-
-  @Test
-  public void linkify_Parens1() {
-    final SafeHtml o = html("A (http://go.here/) B");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>A (<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/</a>) B</p>");
-  }
-
-  @Test
-  public void linkify_Parens() {
-    final SafeHtml o = html("A http://go.here/#m() B");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>A <a href=\"http://go.here/#m()\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/#m()</a> B</p>");
-  }
-
-  @Test
-  public void linkify_AngleBrackets1() {
-    final SafeHtml o = html("A <http://go.here/> B");
-    final SafeHtml n = o.wikify();
-    assertThat(o).isNotSameAs(n);
-    assertThat(n.asString())
-        .isEqualTo(
-            "<p>A &lt;<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-                + ">http://go.here/</a>&gt; B</p>");
-  }
-
-  private static SafeHtml html(String text) {
-    return new SafeHtmlBuilder().append(text).toSafeHtml();
-  }
-}
diff --git a/javatests/org/eclipse/jgit/BUILD b/javatests/org/eclipse/jgit/BUILD
deleted file mode 100644
index 213c8c5..0000000
--- a/javatests/org/eclipse/jgit/BUILD
+++ /dev/null
@@ -1,11 +0,0 @@
-java_test(
-    name = "jgit_patch_tests",
-    srcs = glob(["**/*.java"]),
-    test_class = "org.eclipse.jgit.diff.EditDeserializerTest",
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/org/eclipse/jgit:server",
-        "//lib:junit",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
diff --git a/javatests/org/eclipse/jgit/diff/EditDeserializerTest.java b/javatests/org/eclipse/jgit/diff/EditDeserializerTest.java
deleted file mode 100644
index c431715..0000000
--- a/javatests/org/eclipse/jgit/diff/EditDeserializerTest.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 org.eclipse.jgit.diff;
-
-import static org.junit.Assert.assertNotNull;
-
-import org.junit.Test;
-
-public class EditDeserializerTest {
-  @Test
-  public void diffDeserializer() {
-    assertNotNull("edit deserializer", new EditDeserializer());
-  }
-}
diff --git a/lib/BUILD b/lib/BUILD
index e2dbbf1..f98f6fe 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -27,20 +27,6 @@
 )
 
 java_library(
-    name = "gwtjsonrpc",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@gwtjsonrpc//jar"],
-)
-
-java_library(
-    name = "gwtjsonrpc_src",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@gwtjsonrpc//jar:src"],
-)
-
-java_library(
     name = "gson",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
@@ -48,31 +34,17 @@
 )
 
 java_library(
-    name = "gwtorm-client",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@gwtorm-client//jar"],
-)
-
-java_library(
-    name = "gwtorm-client_src",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@gwtorm-client//jar:src"],
-)
-
-java_library(
     name = "protobuf",
     data = ["//lib:LICENSE-protobuf"],
     visibility = ["//visibility:public"],
-    exports = ["@protobuf//jar"],
+    exports = ["@com_google_protobuf//:protobuf_java"],
 )
 
 java_library(
-    name = "gwtorm",
+    name = "guava-failureaccess",
+    data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = [":gwtorm-client"],
-    runtime_deps = [":protobuf"],
+    exports = ["@guava-failureaccess//jar"],
 )
 
 java_library(
@@ -86,8 +58,11 @@
     name = "guava",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@guava//jar"],
-    runtime_deps = [":j2objc"],
+    exports = [
+        ":guava-failureaccess",
+        ":j2objc",
+        "@guava//jar",
+    ],
 )
 
 java_library(
@@ -108,7 +83,7 @@
     name = "args4j",
     data = ["//lib:LICENSE-args4j"],
     visibility = ["//visibility:public"],
-    exports = ["@args4j//jar"],
+    exports = ["@args4j-intern//jar"],
 )
 
 java_library(
@@ -119,32 +94,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(
@@ -224,13 +424,6 @@
 )
 
 java_library(
-    name = "derby",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@derby//jar"],
-)
-
-java_library(
     name = "soy",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
@@ -267,9 +460,8 @@
     exports = ["@icu4j//jar"],
 )
 
-java_library(
-    name = "postgresql",
-    data = ["//lib:LICENSE-postgresql"],
-    visibility = ["//visibility:public"],
-    exports = ["@postgresql//jar"],
+sh_test(
+    name = "nongoogle_test",
+    srcs = ["nongoogle_test.sh"],
+    data = ["//tools:nongoogle.bzl"],
 )
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-codemirror-original b/lib/LICENSE-codemirror-original
deleted file mode 100644
index 7661321..0000000
--- a/lib/LICENSE-codemirror-original
+++ /dev/null
@@ -1,19 +0,0 @@
-Copyright (C) 2016 by Marijn Haverbeke <marijnh@gmail.com> and others
-
-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-commonmark b/lib/LICENSE-commonmark
new file mode 100644
index 0000000..3fecb98
--- /dev/null
+++ b/lib/LICENSE-commonmark
@@ -0,0 +1,23 @@
+Copyright (c) 2015-2016, Atlassian Pty Ltd
+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/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/LICENSE-postgresql b/lib/LICENSE-postgresql
deleted file mode 100644
index fd416d2..0000000
--- a/lib/LICENSE-postgresql
+++ /dev/null
@@ -1,26 +0,0 @@
-Copyright (c) 1997-2011, PostgreSQL Global Development Group
-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. Neither the name of the PostgreSQL Global Development Group nor the names
-   of its contributors may be used to endorse or promote products derived
-   from this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
-LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/LICENSE-resemblejs b/lib/LICENSE-resemblejs
new file mode 100644
index 0000000..b265c8a
--- /dev/null
+++ b/lib/LICENSE-resemblejs
@@ -0,0 +1,18 @@
+The MIT License (MIT) Copyright © 2013 Huddle
+
+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/codemirror/BUILD b/lib/codemirror/BUILD
deleted file mode 100644
index d0c9278..0000000
--- a/lib/codemirror/BUILD
+++ /dev/null
@@ -1,11 +0,0 @@
-load("//lib/codemirror:cm.bzl", "pkg_cm")
-
-# This library is only used to insert a license statement into
-# js_licenses.txt.
-java_library(
-    name = "diff-match-patch",
-    data = ["//lib:LICENSE-Apache2.0"],
-    runtime_deps = ["@diff-match-patch//jar"],
-)
-
-pkg_cm()
diff --git a/lib/codemirror/cm.bzl b/lib/codemirror/cm.bzl
deleted file mode 100644
index 5088a05..0000000
--- a/lib/codemirror/cm.bzl
+++ /dev/null
@@ -1,359 +0,0 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
-
-CM_CSS = [
-    "lib/codemirror.css",
-    "addon/dialog/dialog.css",
-    "addon/merge/merge.css",
-    "addon/scroll/simplescrollbars.css",
-    "addon/search/matchesonscrollbar.css",
-    "addon/lint/lint.css",
-]
-
-CM_JS = [
-    "lib/codemirror.js",
-    "mode/meta.js",
-    "keymap/emacs.js",
-    "keymap/sublime.js",
-    "keymap/vim.js",
-]
-
-CM_ADDONS = [
-    "dialog/dialog.js",
-    "edit/closebrackets.js",
-    "edit/matchbrackets.js",
-    "edit/trailingspace.js",
-    "scroll/annotatescrollbar.js",
-    "scroll/simplescrollbars.js",
-    "search/jump-to-line.js",
-    "search/matchesonscrollbar.js",
-    "search/searchcursor.js",
-    "search/search.js",
-    "selection/mark-selection.js",
-    "mode/multiplex.js",
-    "mode/overlay.js",
-    "mode/simple.js",
-    "lint/lint.js",
-]
-
-# Available themes must be enumerated here,
-# in gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java,
-# in gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
-CM_THEMES = [
-    "3024-day",
-    "3024-night",
-    "abcdef",
-    "ambiance",
-    "base16-dark",
-    "base16-light",
-    "bespin",
-    "blackboard",
-    "cobalt",
-    "colorforth",
-    "dracula",
-    "duotone-dark",
-    "duotone-light",
-    "eclipse",
-    "elegant",
-    "erlang-dark",
-    "gruvbox-dark",
-    "hopscotch",
-    "icecoder",
-    "idea",
-    "isotope",
-    "lesser-dark",
-    "liquibyte",
-    "lucario",
-    "material",
-    "mbo",
-    "mdn-like",
-    "midnight",
-    "monokai",
-    "neat",
-    "neo",
-    "night",
-    "paraiso-dark",
-    "paraiso-light",
-    "pastel-on-dark",
-    "railscasts",
-    "rubyblue",
-    "seti",
-    "solarized",
-    "ssms",
-    "the-matrix",
-    "tomorrow-night-bright",
-    "tomorrow-night-eighties",
-    "ttcn",
-    "twilight",
-    "vibrant-ink",
-    "xq-dark",
-    "xq-light",
-    "yeti",
-    "zenburn",
-]
-
-# Available modes must be enumerated here,
-# in gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java,
-# gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java,
-# and in CodeMirror's own mode/meta.js script.
-CM_MODES = [
-    "apl",
-    "asciiarmor",
-    "asn.1",
-    "asterisk",
-    "brainfuck",
-    "clike",
-    "clojure",
-    "cmake",
-    "cobol",
-    "coffeescript",
-    "commonlisp",
-    "crystal",
-    "css",
-    "cypher",
-    "d",
-    "dart",
-    "diff",
-    "django",
-    "dockerfile",
-    "dtd",
-    "dylan",
-    "ebnf",
-    "ecl",
-    "eiffel",
-    "elm",
-    "erlang",
-    "factor",
-    "fcl",
-    "forth",
-    "fortran",
-    "gas",
-    "gfm",
-    "gherkin",
-    "go",
-    "groovy",
-    "haml",
-    "handlebars",
-    "haskell-literate",
-    "haskell",
-    "haxe",
-    "htmlembedded",
-    "htmlmixed",
-    "http",
-    "idl",
-    "javascript",
-    "jinja2",
-    "jsx",
-    "julia",
-    "livescript",
-    "lua",
-    "markdown",
-    "mathematica",
-    "mbox",
-    "mirc",
-    "mllike",
-    "modelica",
-    "mscgen",
-    "mumps",
-    "nginx",
-    "nsis",
-    "ntriples",
-    "octave",
-    "oz",
-    "pascal",
-    "pegjs",
-    "perl",
-    "php",
-    "pig",
-    "powershell",
-    "properties",
-    "protobuf",
-    "pug",
-    "puppet",
-    "python",
-    "q",
-    "r",
-    "rpm",
-    "rst",
-    "ruby",
-    "rust",
-    "sas",
-    "sass",
-    "scheme",
-    "shell",
-    "sieve",
-    "slim",
-    "smalltalk",
-    "smarty",
-    "solr",
-    "soy",
-    "sparql",
-    "spreadsheet",
-    "sql",
-    "stex",
-    "stylus",
-    "swift",
-    "tcl",
-    "textile",
-    "tiddlywiki",
-    "tiki",
-    "toml",
-    "tornado",
-    "troff",
-    "ttcn-cfg",
-    "ttcn",
-    "turtle",
-    "twig",
-    "vb",
-    "vbscript",
-    "velocity",
-    "verilog",
-    "vhdl",
-    "vue",
-    "webidl",
-    "xml",
-    "xquery",
-    "yacas",
-    "yaml-frontmatter",
-    "yaml",
-    "z80",
-]
-
-CM_VERSION = "5.37.0"
-
-TOP = "META-INF/resources/webjars/codemirror/%s" % CM_VERSION
-
-TOP_MINIFIED = "META-INF/resources/webjars/codemirror-minified/%s" % CM_VERSION
-
-LICENSE = "//lib:LICENSE-codemirror-original"
-
-LICENSE_MINIFIED = "//lib:LICENSE-codemirror-minified"
-
-DIFF_MATCH_PATCH_VERSION = "20121119-1"
-
-DIFF_MATCH_PATCH_TOP = ("META-INF/resources/webjars/google-diff-match-patch/%s" %
-                        DIFF_MATCH_PATCH_VERSION)
-
-def pkg_cm():
-  for archive, suffix, top, license in [
-      ('@codemirror-original-gwt//jar', '', TOP, LICENSE),
-      ('@codemirror-minified-gwt//jar', '_r', TOP_MINIFIED, LICENSE_MINIFIED)
-  ]:
-    # Main JavaScript and addons
-    genrule2(
-      name = 'cm' + suffix,
-      cmd = ' && '.join([
-          "echo '/** @license' >$@",
-          'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-          "echo '*/' >>$@",
-        ] +
-        ['unzip -p $(location %s) %s/%s >>$@' % (archive, top, n) for n in CM_JS] +
-        ['unzip -p $(location %s) %s/addon/%s >>$@' % (archive, top, n)
-         for n in CM_ADDONS]
-      ),
-      tools = [archive],
-      outs = ['cm%s.js' % suffix],
-    )
-
-    # Main CSS
-    genrule2(
-      name = 'css' + suffix,
-      cmd = ' && '.join([
-          "echo '/** @license' >$@",
-          'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-          "echo '*/' >>$@",
-        ] +
-        ['unzip -p $(location %s) %s/%s >>$@' % (archive, top, n)
-         for n in CM_CSS]
-      ),
-      tools = [archive],
-      outs = ['cm%s.css' % suffix],
-    )
-
-    # Modes
-    for n in CM_MODES:
-      genrule2(
-        name = 'mode_%s%s' % (n, suffix),
-        cmd = ' && '.join([
-            "echo '/** @license' >$@",
-            'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-            "echo '*/' >>$@",
-            'unzip -p $(location %s) %s/mode/%s/%s.js >>$@' % (archive, top, n, n),
-          ]
-        ),
-        tools = [archive],
-        outs = ['mode_%s%s.js' % (n, suffix)],
-      )
-
-    # Themes
-    for n in CM_THEMES:
-      genrule2(
-        name = 'theme_%s%s' % (n, suffix),
-        cmd = ' && '.join([
-            "echo '/** @license' >$@",
-            'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-            "echo '*/' >>$@",
-            'unzip -p $(location %s) %s/theme/%s.css >>$@' % (archive, top, n)
-          ]
-        ),
-        tools = [archive],
-        outs = ['theme_%s%s.css' % (n, suffix)],
-      )
-
-    # Merge Addon bundled with diff-match-patch
-    genrule2(
-      name = 'addon_merge_with_diff_match_patch%s' % suffix,
-      cmd = ' && '.join([
-          "echo '/** @license' >$@",
-          'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-          "echo '*/\n' >>$@",
-          "echo '// The google-diff-match-patch library is from https://repo1.maven.org/maven2/org/webjars/google-diff-match-patch/%s/google-diff-match-patch-%s.jar\n' >> $@" % (DIFF_MATCH_PATCH_VERSION, DIFF_MATCH_PATCH_VERSION),
-          "echo '/** @license' >>$@",
-          "echo 'LICENSE-Apache2.0' >>$@",
-          "echo '*/' >>$@",
-          'unzip -p $(location @diff-match-patch//jar) %s/diff_match_patch.js >>$@' % DIFF_MATCH_PATCH_TOP,
-          "echo ';' >> $@",
-          'unzip -p $(location %s) %s/addon/merge/merge.js >>$@' % (archive, top)
-        ]
-      ),
-      tools = [
-        '@diff-match-patch//jar',
-        # dependency just for license tracking.
-        ':diff-match-patch',
-        archive,
-        "//lib:LICENSE-Apache2.0",
-      ],
-      outs = ['addon_merge_with_diff_match_patch%s.js' % suffix],
-    )
-
-    # Jar packaging
-    genrule2(
-      name = 'jar' + suffix,
-      cmd = ' && '.join([
-        'cd $$TMP',
-        'mkdir -p net/codemirror/{addon,lib,mode,theme}',
-        'cp $$ROOT/$(location :css%s) net/codemirror/lib/cm.css' % suffix,
-        'cp $$ROOT/$(location :cm%s) net/codemirror/lib/cm.js' % suffix]
-        + ['cp $$ROOT/$(location :mode_%s%s) net/codemirror/mode/%s.js' % (n, suffix, n)
-           for n in CM_MODES]
-        + ['cp $$ROOT/$(location :theme_%s%s) net/codemirror/theme/%s.css' % (n, suffix, n)
-           for n in CM_THEMES]
-        + ['cp $$ROOT/$(location :addon_merge_with_diff_match_patch%s) net/codemirror/addon/merge_bundled.js' % suffix]
-        + ['zip -qr $$ROOT/$@ net/codemirror/{addon,lib,mode,theme}']),
-      tools = [
-        ':addon_merge_with_diff_match_patch%s' % suffix,
-        ':cm%s' % suffix,
-        ':css%s' % suffix,
-      ] + [
-        ':mode_%s%s' % (n, suffix) for n in CM_MODES
-      ] + [
-        ':theme_%s%s' % (n, suffix) for n in CM_THEMES
-      ],
-      outs = ['codemirror%s.jar' % suffix],
-    )
-
-    native.java_import(
-      name = 'codemirror' + suffix,
-      jars = [':jar%s' % suffix],
-      visibility = ['//visibility:public'],
-      data = [license],
-    )
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
index bb36389..e8de396 100644
--- a/lib/commons/BUILD
+++ b/lib/commons/BUILD
@@ -3,21 +3,18 @@
 java_library(
     name = "codec",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@commons-codec//jar"],
 )
 
 java_library(
     name = "compress",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@commons-compress//jar"],
 )
 
 java_library(
     name = "lang",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@commons-lang//jar"],
 )
 
@@ -30,14 +27,12 @@
 java_library(
     name = "net",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@commons-net//jar"],
 )
 
 java_library(
     name = "dbcp",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@commons-dbcp//jar"],
     runtime_deps = [":pool"],
 )
@@ -45,20 +40,24 @@
 java_library(
     name = "pool",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@commons-pool//jar"],
 )
 
 java_library(
+    name = "text",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@commons-text//jar"],
+)
+
+java_library(
     name = "validator",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@commons-validator//jar"],
 )
 
 java_library(
     name = "io",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@commons-io//jar"],
 )
diff --git a/lib/elasticsearch-rest-client/BUILD b/lib/elasticsearch-rest-client/BUILD
index c6357d0..8df3c70 100644
--- a/lib/elasticsearch-rest-client/BUILD
+++ b/lib/elasticsearch-rest-client/BUILD
@@ -3,6 +3,5 @@
 java_library(
     name = "elasticsearch-rest-client",
     data = ["//lib:LICENSE-elasticsearch"],
-    visibility = ["//visibility:public"],
     exports = ["@elasticsearch-rest-client//jar"],
 )
diff --git a/lib/errorprone/BUILD b/lib/errorprone/BUILD
new file mode 100644
index 0000000..b5c130b
--- /dev/null
+++ b/lib/errorprone/BUILD
@@ -0,0 +1,6 @@
+java_library(
+    name = "annotations",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@error-prone-annotations//jar"],
+)
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
index 025b93e..c4719d5 100644
--- a/lib/fonts/BUILD
+++ b/lib/fonts/BUILD
@@ -1,7 +1,6 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
-
 # Roboto Mono. Version 2.136
 # https://github.com/google/roboto/releases/tag/v2.136
+
 filegroup(
     name = "robotofonts",
     srcs = [
diff --git a/lib/fonts/RobotoMono-Regular.woff b/lib/fonts/RobotoMono-Regular.woff
old mode 100755
new mode 100644
Binary files differ
diff --git a/lib/fonts/RobotoMono-Regular.woff2 b/lib/fonts/RobotoMono-Regular.woff2
old mode 100755
new mode 100644
Binary files differ
diff --git a/lib/fonts/SourceCodePro-Regular.woff b/lib/fonts/SourceCodePro-Regular.woff
deleted file mode 100644
index 395436e..0000000
--- a/lib/fonts/SourceCodePro-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/SourceCodePro-Regular.woff2 b/lib/fonts/SourceCodePro-Regular.woff2
deleted file mode 100644
index 65cd591..0000000
--- a/lib/fonts/SourceCodePro-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/lib/gitiles/BUILD b/lib/gitiles/BUILD
new file mode 100644
index 0000000..b1bbca1
--- /dev/null
+++ b/lib/gitiles/BUILD
@@ -0,0 +1,56 @@
+java_library(
+    name = "gitiles",
+    visibility = ["//visibility:public"],
+    exports = [
+        ":cm-autolink",
+        ":commonmark",
+        ":gfm-strikethrough",
+        ":gfm-tables",
+        ":gitiles-servlet",
+        ":prettify",
+        "//lib/commons:lang3",
+        "//lib/commons:text",
+    ],
+)
+
+java_library(
+    name = "cm-autolink",
+    data = ["//lib:LICENSE-commonmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@cm-autolink//jar"],
+)
+
+java_library(
+    name = "commonmark",
+    data = ["//lib:LICENSE-commonmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@commonmark//jar"],
+)
+
+java_library(
+    name = "gfm-strikethrough",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@gfm-strikethrough//jar"],
+)
+
+java_library(
+    name = "gfm-tables",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@gfm-tables//jar"],
+)
+
+java_library(
+    name = "gitiles-servlet",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@gitiles-servlet//jar"],
+)
+
+java_library(
+    name = "prettify",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@prettify//jar"],
+)
diff --git a/lib/greenmail/BUILD b/lib/greenmail/BUILD
index 55eb9f3..9cbd0eb 100644
--- a/lib/greenmail/BUILD
+++ b/lib/greenmail/BUILD
@@ -1,8 +1,22 @@
 package(default_visibility = ["//visibility:public"])
 
+POST_JDK8_DEPS = [":javax-activation"]
+
+java_library(
+    name = "javax-activation",
+    testonly = True,
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    exports = ["@javax-activation//jar"],
+)
+
 java_library(
     name = "greenmail",
+    testonly = True,
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@greenmail//jar"],
+    runtime_deps = select({
+        "//:java9": POST_JDK8_DEPS,
+        "//:java_next": POST_JDK8_DEPS,
+        "//conditions:default": [],
+    }),
 )
diff --git a/lib/guava.bzl b/lib/guava.bzl
index 069149b..9f0ff32 100644
--- a/lib/guava.bzl
+++ b/lib/guava.bzl
@@ -1,5 +1,5 @@
-GUAVA_VERSION = "25.1-jre"
+GUAVA_VERSION = "28.0-jre"
 
-GUAVA_BIN_SHA1 = "6c57e4b22b44e89e548b5c9f70f0c45fe10fb0b4"
+GUAVA_BIN_SHA1 = "54fed371b4b8a8cce1e94a9abd9620982d3aa54b"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/gwt/BUILD b/lib/gwt/BUILD
deleted file mode 100644
index fa2fef3..0000000
--- a/lib/gwt/BUILD
+++ /dev/null
@@ -1,45 +0,0 @@
-[java_library(
-    name = n,
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@%s//jar" % n],
-) for n in [
-    "ant",
-    "colt",
-    "dev",
-    "javax-validation",
-    "jsinterop-annotations",
-    "tapestry",
-    "user",
-    "w3c-css-sac",
-]]
-
-java_library(
-    name = "user-neverlink",
-    data = ["//lib:LICENSE-Apache2.0"],
-    neverlink = 1,
-    visibility = ["//visibility:public"],
-    exports = ["@user//jar"],
-)
-
-java_library(
-    name = "dev-neverlink",
-    data = ["//lib:LICENSE-Apache2.0"],
-    neverlink = 1,
-    visibility = ["//visibility:public"],
-    exports = ["@dev//jar"],
-)
-
-java_library(
-    name = "javax-validation_src",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@javax-validation//jar:src"],
-)
-
-java_library(
-    name = "jsinterop-annotations_src",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@jsinterop-annotations//jar:src"],
-)
diff --git a/lib/highlightjs/building.md b/lib/highlightjs/building.md
index bd1cd54..cd5884a 100644
--- a/lib/highlightjs/building.md
+++ b/lib/highlightjs/building.md
@@ -20,51 +20,25 @@
 languages included. Build it with the following:
 
     $>  # start in some temp directory
-    $>  git clone https://github.com/isagalaev/highlight.js.git
+    $>  git clone https://github.com/highlightjs/highlight.js
     $>  cd highlight.js
-    $>  node tools/build.js -n \
-          bash \
-          cpp \
-          cs \
-          clojure \
-          css \
-          d \
-          dart \
-          erb \
-          go \
-          haskell \
-          java \
-          javascript \
-          json \
-          kotlin \
-          lisp \
-          lua \
-          objectivec \
-          ocaml \
-          perl \
-          php \
-          protobuf \
-          puppet \
-          python \
-          ruby \
-          rust \
-          scala \
-          shell \
-          sql \
-          swift \
-          typescript \
-          xml \
-          yaml
+    $>  npm install
+    $>  node tools/build.js -n
 
 The resulting JS file will appear in the "build" directory of the Highlight.js
 repo under the name "highlight.pack.js".
 
 ## Minification
 
-Minify the file using closure-compiler using the command below. (Modify
-`/path/to` with the path to your compiler jar.)
+Minify the file using closure-compiler using the command below.
 
-    $>  java -jar /path/to/closure-compiler.jar \
+    $> wget https://dl.google.com/closure-compiler/compiler-latest.zip
+
+    $> unzip compiler-latest.zip
+
+    $> mv closure-compiler-*.jar closure-compiler.jar
+
+    $>  java -jar ./closure-compiler.jar \
             --js build/highlight.pack.js \
             --js_output_file build/highlight.min.js
 
diff --git a/lib/highlightjs/highlight.min.js b/lib/highlightjs/highlight.min.js
index 9775c0d..ac2be8a 100644
--- a/lib/highlightjs/highlight.min.js
+++ b/lib/highlightjs/highlight.min.js
@@ -1,62 +1,480 @@
 /*
- highlight.js v9.12.0 | BSD3 License | git.io/hljslicense */
-(function(b){var l="object"===typeof window&&window||"object"===typeof self&&self;"undefined"!==typeof exports?b(exports):l&&(l.hljs=b({}),"function"===typeof define&&define.amd&&define([],function(){return l.hljs}))})(function(b){function l(a){return a.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}function C(a,d){var e=a&&a.exec(d);return e&&0===e.index}function r(a){var d,e={},b=Array.prototype.slice.call(arguments,1);for(d in a)e[d]=a[d];b.forEach(function(a){for(d in a)e[d]=
-a[d]});return e}function G(a){var d=[];(function h(a,b){for(var k=a.firstChild;k;k=k.nextSibling)3===k.nodeType?b+=k.nodeValue.length:1===k.nodeType&&(d.push({event:"start",offset:b,node:k}),b=h(k,b),k.nodeName.toLowerCase().match(/br|hr|img|input/)||d.push({event:"stop",offset:b,node:k}));return b})(a,0);return d}function L(a,d,e){function b(){return a.length&&d.length?a[0].offset!==d[0].offset?a[0].offset<d[0].offset?a:d:"start"===d[0].event?a:d:a.length?a:d}function c(a){n+="<"+a.nodeName.toLowerCase()+
-H.map.call(a.attributes,function(a){return" "+a.nodeName+'="'+l(a.value).replace('"',"&quot;")+'"'}).join("")+">"}function f(a){n+="</"+a.nodeName.toLowerCase()+">"}function k(a){("start"===a.event?c:f)(a.node)}for(var v=0,n="",p=[];a.length||d.length;){var g=b(),n=n+l(e.substring(v,g[0].offset)),v=g[0].offset;if(g===a){p.reverse().forEach(f);do k(g.splice(0,1)[0]),g=b();while(g===a&&g.length&&g[0].offset===v);p.reverse().forEach(c)}else"start"===g[0].event?p.push(g[0].node):p.pop(),k(g.splice(0,
-1)[0])}return n+l(e.substr(v))}function M(a){a.variants&&!a.cached_variants&&(a.cached_variants=a.variants.map(function(d){return r(a,{variants:null},d)}));return a.cached_variants||a.endsWithParent&&[r(a)]||[a]}function N(a){function d(a){return a&&a.source||a}function e(c,e){return new RegExp(d(c),"m"+(a.case_insensitive?"i":"")+(e?"g":""))}function b(c,f){if(!c.compiled){c.compiled=!0;c.keywords=c.keywords||c.beginKeywords;if(c.keywords){var k={},l=function(d,c){a.case_insensitive&&(c=c.toLowerCase());
-c.split(" ").forEach(function(a){a=a.split("|");k[a[0]]=[d,a[1]?Number(a[1]):1]})};"string"===typeof c.keywords?l("keyword",c.keywords):w(c.keywords).forEach(function(a){l(a,c.keywords[a])});c.keywords=k}c.lexemesRe=e(c.lexemes||/\w+/,!0);f&&(c.beginKeywords&&(c.begin="\\b("+c.beginKeywords.split(" ").join("|")+")\\b"),c.begin||(c.begin=/\B|\b/),c.beginRe=e(c.begin),c.end||c.endsWithParent||(c.end=/\B|\b/),c.end&&(c.endRe=e(c.end)),c.terminator_end=d(c.end)||"",c.endsWithParent&&f.terminator_end&&
-(c.terminator_end+=(c.end?"|":"")+f.terminator_end));c.illegal&&(c.illegalRe=e(c.illegal));null==c.relevance&&(c.relevance=1);c.contains||(c.contains=[]);c.contains=Array.prototype.concat.apply([],c.contains.map(function(a){return M("self"===a?c:a)}));c.contains.forEach(function(a){b(a,c)});c.starts&&b(c.starts,f);var n=c.contains.map(function(a){return a.beginKeywords?"\\.?("+a.begin+")\\.?":a.begin}).concat([c.terminator_end,c.illegal]).map(d).filter(Boolean);c.terminators=n.length?e(n.join("|"),
-!0):{exec:function(){return null}}}}b(a)}function A(a,d,e,b){function c(a,d){if(C(a.endRe,d)){for(;a.endsParent&&a.parent;)a=a.parent;return a}if(a.endsWithParent)return c(a.parent,d)}function f(a,d,b,c){return'<span class="'+(c?"":q.classPrefix)+(a+'">')+d+(b?"":"</span>")}function k(){var a=t,d;if(null!=g.subLanguage)if((d="string"===typeof g.subLanguage)&&!x[g.subLanguage])d=l(m);else{var b=d?A(g.subLanguage,m,!0,u[g.subLanguage]):E(m,g.subLanguage.length?g.subLanguage:void 0);0<g.relevance&&(r+=
-b.relevance);d&&(u[g.subLanguage]=b.top);d=f(b.language,b.value,!1,!0)}else{var c;if(g.keywords){b="";c=0;g.lexemesRe.lastIndex=0;for(d=g.lexemesRe.exec(m);d;){b+=l(m.substring(c,d.index));c=g;var e=d,e=p.case_insensitive?e[0].toLowerCase():e[0];(c=c.keywords.hasOwnProperty(e)&&c.keywords[e])?(r+=c[1],b+=f(c[0],l(d[0]))):b+=l(d[0]);c=g.lexemesRe.lastIndex;d=g.lexemesRe.exec(m)}d=b+l(m.substr(c))}else d=l(m)}t=a+d;m=""}function v(a){t+=a.className?f(a.className,"",!0):"";g=Object.create(a,{parent:{value:g}})}
-function n(a,d){m+=a;if(null==d)return k(),0;var b;a:{b=g;var f,h;f=0;for(h=b.contains.length;f<h;f++)if(C(b.contains[f].beginRe,d)){b=b.contains[f];break a}b=void 0}if(b)return b.skip?m+=d:(b.excludeBegin&&(m+=d),k(),b.returnBegin||b.excludeBegin||(m=d)),v(b,d),b.returnBegin?0:d.length;if(b=c(g,d)){f=g;f.skip?m+=d:(f.returnEnd||f.excludeEnd||(m+=d),k(),f.excludeEnd&&(m=d));do g.className&&(t+="</span>"),g.skip||(r+=g.relevance),g=g.parent;while(g!==b.parent);b.starts&&v(b.starts,"");return f.returnEnd?
-0:d.length}if(!e&&C(g.illegalRe,d))throw Error('Illegal lexeme "'+d+'" for mode "'+(g.className||"<unnamed>")+'"');m+=d;return d.length||1}var p=y(a);if(!p)throw Error('Unknown language: "'+a+'"');N(p);var g=b||p,u={},t="";for(b=g;b!==p;b=b.parent)b.className&&(t=f(b.className,"",!0)+t);var m="",r=0;try{for(var z,w,B=0;;){g.terminators.lastIndex=B;z=g.terminators.exec(d);if(!z)break;w=n(d.substring(B,z.index),z[0]);B=z.index+w}n(d.substr(B));for(b=g;b.parent;b=b.parent)b.className&&(t+="</span>");
-return{relevance:r,value:t,language:a,top:g}}catch(D){if(D.message&&-1!==D.message.indexOf("Illegal"))return{relevance:0,value:l(d)};throw D;}}function E(a,d){d=d||q.languages||w(x);var b={relevance:0,value:l(a)},h=b;d.filter(y).forEach(function(d){var f=A(d,a,!1);f.language=d;f.relevance>h.relevance&&(h=f);f.relevance>b.relevance&&(h=b,b=f)});h.language&&(b.second_best=h);return b}function I(a){return q.tabReplace||q.useBR?a.replace(O,function(a,b){return q.useBR&&"\n"===a?"<br>":q.tabReplace?b.replace(/\t/g,
-q.tabReplace):""}):a}function J(a){var d,b,h,c,f;a:if(b=a.className+" ",b+=a.parentNode?a.parentNode.className:"",f=P.exec(b))f=y(f[1])?f[1]:"no-highlight";else{b=b.split(/\s+/);f=0;for(c=b.length;f<c;f++)if(d=b[f],K.test(d)||y(d)){f=d;break a}f=void 0}K.test(f)||(q.useBR?(d=document.createElementNS("http://www.w3.org/1999/xhtml","div"),d.innerHTML=a.innerHTML.replace(/\n/g,"").replace(/<br[ \/]*>/g,"\n")):d=a,c=d.textContent,b=f?A(f,c,!0):E(c),d=G(d),d.length&&(h=document.createElementNS("http://www.w3.org/1999/xhtml",
-"div"),h.innerHTML=b.value,b.value=L(d,G(h),c)),b.value=I(b.value),a.innerHTML=b.value,c=a.className,f=f?F[f]:b.language,d=[c.trim()],c.match(/\bhljs\b/)||d.push("hljs"),-1===c.indexOf(f)&&d.push(f),f=d.join(" ").trim(),a.className=f,a.result={language:b.language,re:b.relevance},b.second_best&&(a.second_best={language:b.second_best.language,re:b.second_best.relevance}))}function u(){if(!u.called){u.called=!0;var a=document.querySelectorAll("pre code");H.forEach.call(a,J)}}function y(a){a=(a||"").toLowerCase();
-return x[a]||x[F[a]]}var H=[],w=Object.keys,x={},F={},K=/^(no-?highlight|plain|text)$/i,P=/\blang(?:uage)?-([\w-]+)\b/i,O=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,q={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0};b.highlight=A;b.highlightAuto=E;b.fixMarkup=I;b.highlightBlock=J;b.configure=function(a){q=r(q,a)};b.initHighlighting=u;b.initHighlightingOnLoad=function(){addEventListener("DOMContentLoaded",u,!1);addEventListener("load",u,!1)};b.registerLanguage=function(a,d){var e=x[a]=d(b);e.aliases&&
-e.aliases.forEach(function(b){F[b]=a})};b.listLanguages=function(){return w(x)};b.getLanguage=y;b.inherit=r;b.IDENT_RE="[a-zA-Z]\\w*";b.UNDERSCORE_IDENT_RE="[a-zA-Z_]\\w*";b.NUMBER_RE="\\b\\d+(\\.\\d+)?";b.C_NUMBER_RE="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";b.BINARY_NUMBER_RE="\\b(0b[01]+)";b.RE_STARTERS_RE="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";b.BACKSLASH_ESCAPE=
-{begin:"\\\\[\\s\\S]",relevance:0};b.APOS_STRING_MODE={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.QUOTE_STRING_MODE={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.PHRASAL_WORDS_MODE={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/};b.COMMENT=function(a,d,e){a=b.inherit({className:"comment",begin:a,end:d,contains:[]},e||{});
-a.contains.push(b.PHRASAL_WORDS_MODE);a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|XXX):",relevance:0});return a};b.C_LINE_COMMENT_MODE=b.COMMENT("//","$");b.C_BLOCK_COMMENT_MODE=b.COMMENT("/\\*","\\*/");b.HASH_COMMENT_MODE=b.COMMENT("#","$");b.NUMBER_MODE={className:"number",begin:b.NUMBER_RE,relevance:0};b.C_NUMBER_MODE={className:"number",begin:b.C_NUMBER_RE,relevance:0};b.BINARY_NUMBER_MODE={className:"number",begin:b.BINARY_NUMBER_RE,relevance:0};b.CSS_NUMBER_MODE={className:"number",
-begin:b.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0};b.REGEXP_MODE={className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0,contains:[b.BACKSLASH_ESCAPE]}]};b.TITLE_MODE={className:"title",begin:b.IDENT_RE,relevance:0};b.UNDERSCORE_TITLE_MODE={className:"title",begin:b.UNDERSCORE_IDENT_RE,relevance:0};b.METHOD_GUARD={begin:"\\.\\s*"+b.UNDERSCORE_IDENT_RE,relevance:0};
-b.registerLanguage("bash",function(a){var b={className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},e={className:"string",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,b,{className:"variable",begin:/\$\(/,end:/\)/,contains:[a.BACKSLASH_ESCAPE]}]};return{aliases:["sh","zsh"],lexemes:/\b-?[a-z\._]+\b/,keywords:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",
-_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[{className:"meta",begin:/^#![^\n]+sh\s*$/,relevance:10},{className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0},a.HASH_COMMENT_MODE,e,{className:"string",begin:/'/,end:/'/},b]}});b.registerLanguage("cpp",function(a){var b={className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},e={className:"string",variants:[{begin:'(u8?|U)?L?"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},
-{begin:'(u8?|U)?R"',end:'"',contains:[a.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},h={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"},contains:[{begin:/\\\n/,
-relevance:0},a.inherit(e,{className:"meta-string"}),{className:"meta-string",begin:/<[^\n>]*>/,end:/$/,illegal:"\\n"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},f=a.IDENT_RE+"\\s*\\(",k={keyword:"int float while private char catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignof constexpr decltype noexcept static_assert thread_local restrict _Bool complex _Complex _Imaginary atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and or not",
+ highlight.js v9.15.8 | BSD3 License | git.io/hljslicense */
+var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(b){var h=0;return function(){return h<b.length?{done:!1,value:b[h++]}:{done:!0}}};$jscomp.arrayIterator=function(b){return{next:$jscomp.arrayIteratorImpl(b)}};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
+$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(b,h,q){b!=Array.prototype&&b!=Object.prototype&&(b[h]=q.value)};$jscomp.getGlobal=function(b){return"undefined"!=typeof window&&window===b?b:"undefined"!=typeof global&&null!=global?global:b};$jscomp.global=$jscomp.getGlobal(this);$jscomp.SYMBOL_PREFIX="jscomp_symbol_";$jscomp.initSymbol=function(){$jscomp.initSymbol=function(){};$jscomp.global.Symbol||($jscomp.global.Symbol=$jscomp.Symbol)};
+$jscomp.SymbolClass=function(b,h){this.$jscomp$symbol$id_=b;$jscomp.defineProperty(this,"description",{configurable:!0,writable:!0,value:h})};$jscomp.SymbolClass.prototype.toString=function(){return this.$jscomp$symbol$id_};$jscomp.Symbol=function(){function b(q){if(this instanceof b)throw new TypeError("Symbol is not a constructor");return new $jscomp.SymbolClass($jscomp.SYMBOL_PREFIX+(q||"")+"_"+h++,q)}var h=0;return b}();
+$jscomp.initSymbolIterator=function(){$jscomp.initSymbol();var b=$jscomp.global.Symbol.iterator;b||(b=$jscomp.global.Symbol.iterator=$jscomp.global.Symbol("Symbol.iterator"));"function"!=typeof Array.prototype[b]&&$jscomp.defineProperty(Array.prototype,b,{configurable:!0,writable:!0,value:function(){return $jscomp.iteratorPrototype($jscomp.arrayIteratorImpl(this))}});$jscomp.initSymbolIterator=function(){}};
+$jscomp.initSymbolAsyncIterator=function(){$jscomp.initSymbol();var b=$jscomp.global.Symbol.asyncIterator;b||(b=$jscomp.global.Symbol.asyncIterator=$jscomp.global.Symbol("Symbol.asyncIterator"));$jscomp.initSymbolAsyncIterator=function(){}};$jscomp.iteratorPrototype=function(b){$jscomp.initSymbolIterator();b={next:b};b[$jscomp.global.Symbol.iterator]=function(){return this};return b};
+$jscomp.iteratorFromArray=function(b,h){$jscomp.initSymbolIterator();b instanceof String&&(b+="");var q=0,r={next:function(){if(q<b.length){var u=q++;return{value:h(u,b[u]),done:!1}}r.next=function(){return{done:!0,value:void 0}};return r.next()}};r[Symbol.iterator]=function(){return r};return r};
+$jscomp.polyfill=function(b,h,q,r){if(h){q=$jscomp.global;b=b.split(".");for(r=0;r<b.length-1;r++){var u=b[r];u in q||(q[u]={});q=q[u]}b=b[b.length-1];r=q[b];h=h(r);h!=r&&null!=h&&$jscomp.defineProperty(q,b,{configurable:!0,writable:!0,value:h})}};$jscomp.polyfill("Array.prototype.keys",function(b){return b?b:function(){return $jscomp.iteratorFromArray(this,function(b){return b})}},"es6","es3");
+(function(b){var h="object"===typeof window&&window||"object"===typeof self&&self;"undefined"!==typeof exports?b(exports):h&&(h.hljs=b({}),"function"===typeof define&&define.amd&&define([],function(){return h.hljs}))})(function(b){function h(a){return a.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}function q(a,c){return(a=a&&a.exec(c))&&0===a.index}function r(a){var c,b={},e=Array.prototype.slice.call(arguments,1);for(c in a)b[c]=a[c];e.forEach(function(a){for(c in a)b[c]=a[c]});
+return b}function u(a){var c=[];(function g(a,b){for(a=a.firstChild;a;a=a.nextSibling)3===a.nodeType?b+=a.nodeValue.length:1===a.nodeType&&(c.push({event:"start",offset:b,node:a}),b=g(a,b),a.nodeName.toLowerCase().match(/br|hr|img|input/)||c.push({event:"stop",offset:b,node:a}));return b})(a,0);return c}function N(a,c,b){function d(){return a.length&&c.length?a[0].offset!==c[0].offset?a[0].offset<c[0].offset?a:c:"start"===c[0].event?a:c:a.length?a:c}function f(a){m+="<"+a.nodeName.toLowerCase()+H.map.call(a.attributes,
+function(a){return" "+a.nodeName+'="'+h(a.value).replace('"',"&quot;")+'"'}).join("")+">"}function g(a){m+="</"+a.nodeName.toLowerCase()+">"}function k(a){("start"===a.event?f:g)(a.node)}for(var t=0,m="",n=[];a.length||c.length;){var l=d();m+=h(b.substring(t,l[0].offset));t=l[0].offset;if(l===a){n.reverse().forEach(g);do k(l.splice(0,1)[0]),l=d();while(l===a&&l.length&&l[0].offset===t);n.reverse().forEach(f)}else"start"===l[0].event?n.push(l[0].node):n.pop(),k(l.splice(0,1)[0])}return m+h(b.substr(t))}
+function O(a){a.variants&&!a.cached_variants&&(a.cached_variants=a.variants.map(function(c){return r(a,{variants:null},c)}));return a.cached_variants||a.endsWithParent&&[r(a)]||[a]}function I(a){if(y&&!a.langApiRestored){a.langApiRestored=!0;for(var c in y)a[c]&&(a[y[c]]=a[c]);(a.contains||[]).concat(a.variants||[]).forEach(I)}}function P(a){function c(a){return a&&a.source||a}function b(b,d){return new RegExp(c(b),"m"+(a.case_insensitive?"i":"")+(d?"g":""))}function e(a,b){for(var d=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,
+e=0,f="",g=0;g<a.length;g++){var k=e,h=c(a[g]);for(0<g&&(f+=b);0<h.length;){var p=d.exec(h);if(null==p){f+=h;break}f+=h.substring(0,p.index);h=h.substring(p.index+p[0].length);"\\"==p[0][0]&&p[1]?f+="\\"+String(Number(p[1])+k):(f+=p[0],"("==p[0]&&e++)}}return f}function f(d,k){if(!d.compiled){d.compiled=!0;d.keywords=d.keywords||d.beginKeywords;if(d.keywords){var g={},m=function(c,d){a.case_insensitive&&(d=d.toLowerCase());d.split(" ").forEach(function(a){a=a.split("|");g[a[0]]=[c,a[1]?Number(a[1]):
+1]})};"string"===typeof d.keywords?m("keyword",d.keywords):D(d.keywords).forEach(function(a){m(a,d.keywords[a])});d.keywords=g}d.lexemesRe=b(d.lexemes||/\w+/,!0);k&&(d.beginKeywords&&(d.begin="\\b("+d.beginKeywords.split(" ").join("|")+")\\b"),d.begin||(d.begin=/\B|\b/),d.beginRe=b(d.begin),d.endSameAsBegin&&(d.end=d.begin),d.end||d.endsWithParent||(d.end=/\B|\b/),d.end&&(d.endRe=b(d.end)),d.terminator_end=c(d.end)||"",d.endsWithParent&&k.terminator_end&&(d.terminator_end+=(d.end?"|":"")+k.terminator_end));
+d.illegal&&(d.illegalRe=b(d.illegal));null==d.relevance&&(d.relevance=1);d.contains||(d.contains=[]);d.contains=Array.prototype.concat.apply([],d.contains.map(function(a){return O("self"===a?d:a)}));d.contains.forEach(function(a){f(a,d)});d.starts&&f(d.starts,k);k=d.contains.map(function(a){return a.beginKeywords?"\\.?(?:"+a.begin+")\\.?":a.begin}).concat([d.terminator_end,d.illegal]).map(c).filter(Boolean);d.terminators=k.length?b(e(k,"|"),!0):{exec:function(){return null}}}}f(a)}function B(a,c,
+d,b){function e(a,c){if(q(a.endRe,c)){for(;a.endsParent&&a.parent;)a=a.parent;return a}if(a.endsWithParent)return e(a.parent,c)}function g(a,c,d,b){return a?'<span class="'+(b?"":w.classPrefix)+(a+'">')+c+(d?"":"</span>"):c}function k(){var a=x,c;if(null!=l.subLanguage)if((c="string"===typeof l.subLanguage)&&!z[l.subLanguage])c=h(p);else{var d=c?B(l.subLanguage,p,!0,r[l.subLanguage]):F(p,l.subLanguage.length?l.subLanguage:void 0);0<l.relevance&&(u+=d.relevance);c&&(r[l.subLanguage]=d.top);c=g(d.language,
+d.value,!1,!0)}else if(l.keywords){d="";var b=0;l.lexemesRe.lastIndex=0;for(c=l.lexemesRe.exec(p);c;){d+=h(p.substring(b,c.index));b=l;var e=c;e=n.case_insensitive?e[0].toLowerCase():e[0];(b=b.keywords.hasOwnProperty(e)&&b.keywords[e])?(u+=b[1],d+=g(b[0],h(c[0]))):d+=h(c[0]);b=l.lexemesRe.lastIndex;c=l.lexemesRe.exec(p)}c=d+h(p.substr(b))}else c=h(p);x=a+c;p=""}function t(a){x+=a.className?g(a.className,"",!0):"";l=Object.create(a,{parent:{value:l}})}function m(a,c){p+=a;if(null==c)return k(),0;a:{a=
+l;var b;var f=0;for(b=a.contains.length;f<b;f++)if(q(a.contains[f].beginRe,c)){a.contains[f].endSameAsBegin&&(a.contains[f].endRe=new RegExp(a.contains[f].beginRe.exec(c)[0].replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&"),"m"));a=a.contains[f];break a}a=void 0}if(a)return a.skip?p+=c:(a.excludeBegin&&(p+=c),k(),a.returnBegin||a.excludeBegin||(p=c)),t(a,c),a.returnBegin?0:c.length;if(a=e(l,c)){f=l;f.skip?p+=c:(f.returnEnd||f.excludeEnd||(p+=c),k(),f.excludeEnd&&(p=c));do l.className&&(x+="</span>"),l.skip||
+l.subLanguage||(u+=l.relevance),l=l.parent;while(l!==a.parent);a.starts&&(a.endSameAsBegin&&(a.starts.endRe=a.endRe),t(a.starts,""));return f.returnEnd?0:c.length}if(!d&&q(l.illegalRe,c))throw Error('Illegal lexeme "'+c+'" for mode "'+(l.className||"<unnamed>")+'"');p+=c;return c.length||1}var n=A(a);if(!n)throw Error('Unknown language: "'+a+'"');P(n);var l=b||n,r={},x="";for(b=l;b!==n;b=b.parent)b.className&&(x=g(b.className,"",!0)+x);var p="",u=0;try{for(var v,y,C=0;;){l.terminators.lastIndex=C;
+v=l.terminators.exec(c);if(!v)break;y=m(c.substring(C,v.index),v[0]);C=v.index+y}m(c.substr(C));for(b=l;b.parent;b=b.parent)b.className&&(x+="</span>");return{relevance:u,value:x,language:a,top:l}}catch(E){if(E.message&&-1!==E.message.indexOf("Illegal"))return{relevance:0,value:h(c)};throw E;}}function F(a,c){c=c||w.languages||D(z);var d={relevance:0,value:h(a)},b=d;c.filter(A).filter(J).forEach(function(c){var e=B(c,a,!1);e.language=c;e.relevance>b.relevance&&(b=e);e.relevance>d.relevance&&(b=d,
+d=e)});b.language&&(d.second_best=b);return d}function K(a){return w.tabReplace||w.useBR?a.replace(Q,function(a,d){return w.useBR&&"\n"===a?"<br>":w.tabReplace?d.replace(/\t/g,w.tabReplace):""}):a}function L(a){var c,d;a:{var b=a.className+" ";b+=a.parentNode?a.parentNode.className:"";if(d=R.exec(b))d=A(d[1])?d[1]:"no-highlight";else{b=b.split(/\s+/);d=0;for(c=b.length;d<c;d++){var f=b[d];if(M.test(f)||A(f)){d=f;break a}}d=void 0}}if(!M.test(d)){w.useBR?(f=document.createElementNS("http://www.w3.org/1999/xhtml",
+"div"),f.innerHTML=a.innerHTML.replace(/\n/g,"").replace(/<br[ \/]*>/g,"\n")):f=a;c=f.textContent;b=d?B(d,c,!0):F(c);f=u(f);if(f.length){var g=document.createElementNS("http://www.w3.org/1999/xhtml","div");g.innerHTML=b.value;b.value=N(f,u(g),c)}b.value=K(b.value);a.innerHTML=b.value;c=a.className;d=d?G[d]:b.language;f=[c.trim()];c.match(/\bhljs\b/)||f.push("hljs");-1===c.indexOf(d)&&f.push(d);d=f.join(" ").trim();a.className=d;a.result={language:b.language,re:b.relevance};b.second_best&&(a.second_best=
+{language:b.second_best.language,re:b.second_best.relevance})}}function v(){if(!v.called){v.called=!0;var a=document.querySelectorAll("pre code");H.forEach.call(a,L)}}function A(a){a=(a||"").toLowerCase();return z[a]||z[G[a]]}function J(a){return(a=A(a))&&!a.disableAutodetect}var H=[],D=Object.keys,z={},G={},M=/^(no-?highlight|plain|text)$/i,R=/\blang(?:uage)?-([\w-]+)\b/i,Q=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,y,w={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0};b.highlight=B;b.highlightAuto=
+F;b.fixMarkup=K;b.highlightBlock=L;b.configure=function(a){w=r(w,a)};b.initHighlighting=v;b.initHighlightingOnLoad=function(){addEventListener("DOMContentLoaded",v,!1);addEventListener("load",v,!1)};b.registerLanguage=function(a,c){c=z[a]=c(b);I(c);c.aliases&&c.aliases.forEach(function(c){G[c]=a})};b.listLanguages=function(){return D(z)};b.getLanguage=A;b.autoDetection=J;b.inherit=r;b.IDENT_RE="[a-zA-Z]\\w*";b.UNDERSCORE_IDENT_RE="[a-zA-Z_]\\w*";b.NUMBER_RE="\\b\\d+(\\.\\d+)?";b.C_NUMBER_RE="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";
+b.BINARY_NUMBER_RE="\\b(0b[01]+)";b.RE_STARTERS_RE="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";b.BACKSLASH_ESCAPE={begin:"\\\\[\\s\\S]",relevance:0};b.APOS_STRING_MODE={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.QUOTE_STRING_MODE={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.PHRASAL_WORDS_MODE={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/};
+b.COMMENT=function(a,c,d){a=b.inherit({className:"comment",begin:a,end:c,contains:[]},d||{});a.contains.push(b.PHRASAL_WORDS_MODE);a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|XXX):",relevance:0});return a};b.C_LINE_COMMENT_MODE=b.COMMENT("//","$");b.C_BLOCK_COMMENT_MODE=b.COMMENT("/\\*","\\*/");b.HASH_COMMENT_MODE=b.COMMENT("#","$");b.NUMBER_MODE={className:"number",begin:b.NUMBER_RE,relevance:0};b.C_NUMBER_MODE={className:"number",begin:b.C_NUMBER_RE,relevance:0};b.BINARY_NUMBER_MODE=
+{className:"number",begin:b.BINARY_NUMBER_RE,relevance:0};b.CSS_NUMBER_MODE={className:"number",begin:b.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0};b.REGEXP_MODE={className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0,contains:[b.BACKSLASH_ESCAPE]}]};b.TITLE_MODE={className:"title",begin:b.IDENT_RE,relevance:0};b.UNDERSCORE_TITLE_MODE={className:"title",begin:b.UNDERSCORE_IDENT_RE,
+relevance:0};b.METHOD_GUARD={begin:"\\.\\s*"+b.UNDERSCORE_IDENT_RE,relevance:0};b.registerLanguage("1c",function(a){var c=a.inherit(a.NUMBER_MODE),b={className:"string",begin:'"|\\|',end:'"|$',contains:[{begin:'""'}]},e={begin:"'",end:"'",excludeBegin:!0,excludeEnd:!0,contains:[{className:"number",begin:"\\d{4}([\\.\\\\/:-]?\\d{2}){0,5}"}]},f=a.inherit(a.C_LINE_COMMENT_MODE),g={className:"meta",lexemes:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+",
+begin:"#|&",end:"$",keywords:{"meta-keyword":"\u0434\u0430\u043b\u0435\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c\u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0434\u043b\u044f \u0435\u0441\u043b\u0438 \u0438 \u0438\u0437 \u0438\u043b\u0438 \u0438\u043d\u0430\u0447\u0435 \u0438\u043d\u0430\u0447\u0435\u0435\u0441\u043b\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043f\u044b\u0442\u043a\u0438 \u043a\u043e\u043d\u0435\u0446\u0446\u0438\u043a\u043b\u0430 \u043d\u0435 \u043d\u043e\u0432\u044b\u0439 \u043f\u0435\u0440\u0435\u0439\u0442\u0438 \u043f\u0435\u0440\u0435\u043c \u043f\u043e \u043f\u043e\u043a\u0430 \u043f\u043e\u043f\u044b\u0442\u043a\u0430 \u043f\u0440\u0435\u0440\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c \u0442\u043e\u0433\u0434\u0430 \u0446\u0438\u043a\u043b \u044d\u043a\u0441\u043f\u043e\u0440\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0432\u0435\u0431\u043a\u043b\u0438\u0435\u043d\u0442 \u0432\u043c\u0435\u0441\u0442\u043e \u0432\u043d\u0435\u0448\u043d\u0435\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442 \u043a\u043e\u043d\u0435\u0446\u043e\u0431\u043b\u0430\u0441\u0442\u0438 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043b\u0438\u0435\u043d\u0442 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0432\u0435\u0440 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043e\u0431\u043b\u0430\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u0434 \u043f\u043e\u0441\u043b\u0435 \u0441\u0435\u0440\u0432\u0435\u0440 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0431\u044b\u0447\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043d\u043a\u0438\u0439\u043a\u043b\u0438\u0435\u043d\u0442 "},
+contains:[f]};a={className:"function",lexemes:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+",variants:[{begin:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430|\u0444\u0443\u043d\u043a\u0446\u0438\u044f",end:"\\)",keywords:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f"},{begin:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b|\u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438",
+keywords:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b \u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438"}],contains:[{begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"params",lexemes:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+",begin:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+",end:",",excludeEnd:!0,endsWithParent:!0,
+keywords:{keyword:"\u0437\u043d\u0430\u0447",literal:"null \u0438\u0441\u0442\u0438\u043d\u0430 \u043b\u043e\u0436\u044c \u043d\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043e"},contains:[c,b,e]},f]},a.inherit(a.TITLE_MODE,{begin:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+"})]};return{case_insensitive:!0,lexemes:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+",keywords:{keyword:"\u0434\u0430\u043b\u0435\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c\u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0434\u043b\u044f \u0435\u0441\u043b\u0438 \u0438 \u0438\u0437 \u0438\u043b\u0438 \u0438\u043d\u0430\u0447\u0435 \u0438\u043d\u0430\u0447\u0435\u0435\u0441\u043b\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043f\u044b\u0442\u043a\u0438 \u043a\u043e\u043d\u0435\u0446\u0446\u0438\u043a\u043b\u0430 \u043d\u0435 \u043d\u043e\u0432\u044b\u0439 \u043f\u0435\u0440\u0435\u0439\u0442\u0438 \u043f\u0435\u0440\u0435\u043c \u043f\u043e \u043f\u043e\u043a\u0430 \u043f\u043e\u043f\u044b\u0442\u043a\u0430 \u043f\u0440\u0435\u0440\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c \u0442\u043e\u0433\u0434\u0430 \u0446\u0438\u043a\u043b \u044d\u043a\u0441\u043f\u043e\u0440\u0442 ",
+built_in:"\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u043e\u043a \u0441\u0438\u043c\u0432\u043e\u043b\u0442\u0430\u0431\u0443\u043b\u044f\u0446\u0438\u0438 ansitooem oemtoansi \u0432\u0432\u0435\u0441\u0442\u0438\u0432\u0438\u0434\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0438\u043e\u0434 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0434\u0430\u0442\u0430\u0433\u043e\u0434 \u0434\u0430\u0442\u0430\u043c\u0435\u0441\u044f\u0446 \u0434\u0430\u0442\u0430\u0447\u0438\u0441\u043b\u043e \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0438\u0431 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u0434\u0441\u0438\u043c\u0432 \u043a\u043e\u043d\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u043d\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043a\u043e\u043d\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u043d\u0435\u0434\u0435\u043b\u0438 \u043b\u043e\u0433 \u043b\u043e\u043310 \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043d\u0430\u0431\u043e\u0440\u0430\u043f\u0440\u0430\u0432 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0432\u0438\u0434 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0441\u0447\u0435\u0442 \u043d\u0430\u0439\u0442\u0438\u0441\u0441\u044b\u043b\u043a\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043d\u0430\u0447\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u0433\u043e\u0434\u0430 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u043d\u0435\u0434\u0435\u043b\u0438\u0433\u043e\u0434\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u044f\u0437\u044b\u043a \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043e\u043a\u043d\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 \u043f\u0435\u0440\u0438\u043e\u0434\u0441\u0442\u0440 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u0442\u0443\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0430 \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u043e\u043f\u0438\u0441\u044c \u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u043c \u0440\u0430\u0437\u043e\u0431\u0440\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043f\u043e \u0441\u0438\u043c\u0432 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442 \u0441\u0442\u0430\u0442\u0443\u0441\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430 \u0441\u0442\u0440\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u0447\u0435\u0442\u043f\u043e\u043a\u043e\u0434\u0443 \u0442\u0435\u043a\u0443\u0449\u0435\u0435\u0432\u0440\u0435\u043c\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0442\u0440 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043d\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043f\u043e \u0444\u0438\u043a\u0441\u0448\u0430\u0431\u043b\u043e\u043d \u0448\u0430\u0431\u043b\u043e\u043d acos asin atan base64\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 base64\u0441\u0442\u0440\u043e\u043a\u0430 cos exp log log10 pow sin sqrt tan xml\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 xml\u0441\u0442\u0440\u043e\u043a\u0430 xml\u0442\u0438\u043f xml\u0442\u0438\u043f\u0437\u043d\u0447 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0435\u043e\u043a\u043d\u043e \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u043b\u0435\u0432\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u0434\u0430\u0442\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u0441\u0442\u0440\u043e\u043a\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0447\u0438\u0441\u043b\u043e \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c\u0447\u0442\u0435\u043d\u0438\u044fxml \u0432\u043e\u043f\u0440\u043e\u0441 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0440\u0435\u0433 \u0432\u044b\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u043f\u0440\u0430\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0432\u044b\u0447\u0438\u0441\u043b\u0438\u0442\u044c \u0433\u043e\u0434 \u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b\u0432\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0434\u0430\u0442\u0430 \u0434\u0435\u043d\u044c \u0434\u0435\u043d\u044c\u0433\u043e\u0434\u0430 \u0434\u0435\u043d\u044c\u043d\u0435\u0434\u0435\u043b\u0438 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c\u043c\u0435\u0441\u044f\u0446 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0437\u0430\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cjson \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cxml \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u0437\u0430\u043f\u0438\u0441\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0432\u043e\u0439\u0441\u0442\u0432 \u0437\u0430\u043f\u0440\u043e\u0441\u0438\u0442\u044c\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u0441\u0438\u0441\u0442\u0435\u043c\u0443 \u0437\u0430\u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0444\u0430\u0439\u043b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0438\u0437xml\u0442\u0438\u043f\u0430 \u0438\u043c\u043f\u043e\u0440\u0442\u043c\u043e\u0434\u0435\u043b\u0438xdto \u0438\u043c\u044f\u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0430 \u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0438\u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0433\u043e\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043a\u043e\u0434\u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u043a\u043e\u043c\u0430\u043d\u0434\u0430\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043a\u043e\u043d\u0435\u0446\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u0434\u043d\u044f \u043a\u043e\u043d\u0435\u0446\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0438\u043d\u0443\u0442\u044b \u043a\u043e\u043d\u0435\u0446\u043d\u0435\u0434\u0435\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u0447\u0430\u0441\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0444\u0430\u0439\u043b \u043a\u0440\u0430\u0442\u043a\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043b\u0435\u0432 \u043c\u0430\u043a\u0441 \u043c\u0435\u0441\u0442\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u043c\u0435\u0441\u044f\u0446 \u043c\u0438\u043d \u043c\u0438\u043d\u0443\u0442\u0430 \u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043d\u0430\u0439\u0442\u0438 \u043d\u0430\u0439\u0442\u0438\u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u043d\u0430\u0439\u0442\u0438\u043e\u043a\u043d\u043e\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u043c\u0435\u0447\u0435\u043d\u043d\u044b\u0435\u043d\u0430\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u0441\u0441\u044b\u043b\u043a\u0430\u043c \u043d\u0430\u0439\u0442\u0438\u0444\u0430\u0439\u043b\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u0434\u043d\u044f \u043d\u0430\u0447\u0430\u043b\u043e\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0438\u043d\u0443\u0442\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0447\u0430\u0441\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0443\u0441\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0438\u0441\u043a\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0433\u043e\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0435\u0434\u0435\u043b\u044f\u0433\u043e\u0434\u0430 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0441\u0442\u044c\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043d\u043e\u043c\u0435\u0440\u0441\u0435\u0430\u043d\u0441\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u043e\u043c\u0435\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u0440\u0435\u0433 \u043d\u0441\u0442\u0440 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044e\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u0435\u0440\u044b\u0432\u0430\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043e\u043a\u0440 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c\u043e\u0431\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438 \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0438\u043d\u0434\u0435\u043a\u0441\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0435\u0440\u0435\u0439\u0442\u0438\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0434\u0430\u0442\u044b \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0441\u0442\u0440\u043e\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0447\u0438\u0441\u043b\u0430 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u043e\u043f\u0440\u043e\u0441 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043d\u0430\u043a\u0430\u0440\u0442\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044ccom\u043e\u0431\u044a\u0435\u043a\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044cxml\u0442\u0438\u043f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0430\u0434\u0440\u0435\u0441\u043f\u043e\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043a\u043e\u0434\u044b\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0447\u0430\u0441\u043e\u0432\u044b\u0435\u043f\u043e\u044f\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u044d\u043a\u0440\u0430\u043d\u043e\u0432\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0430\u0434\u0440\u0435\u0441\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0438\u0439\u043c\u0430\u043a\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0443\u044e\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043a\u043d\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u0443\u044e\u043e\u0442\u043c\u0435\u0442\u043a\u0443\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0445\u0441\u0441\u044b\u043b\u043e\u043a \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0435\u0430\u043d\u0441\u044b\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0443\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0441\u0435\u0430\u043d\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u043e\u0441 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0432\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u0440\u0430\u0432 \u043f\u0440\u0430\u0432\u043e\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u0434\u0430\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0430\u0432\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0447\u0430\u0441\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u044f\u0441\u0430 \u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043a\u0440\u0430\u0442\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c\u0432\u044b\u0437\u043e\u0432 \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cjson \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cxml \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u043f\u0443\u0441\u0442\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0440\u0430\u0431\u043e\u0447\u0438\u0439\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0440\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u0440\u0430\u0437\u043e\u0440\u0432\u0430\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0440\u043e\u043b\u044c\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0441\u0435\u043a\u0443\u043d\u0434\u0430 \u0441\u0438\u0433\u043d\u0430\u043b \u0441\u0438\u043c\u0432\u043e\u043b \u0441\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u043b\u0435\u0442\u043d\u0435\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0431\u0443\u0444\u0435\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043a\u0430\u0442\u0430\u043b\u043e\u0433 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u0444\u0430\u0431\u0440\u0438\u043a\u0443xdto \u0441\u043e\u043a\u0440\u043b \u0441\u043e\u043a\u0440\u043b\u043f \u0441\u043e\u043a\u0440\u043f \u0441\u043e\u043e\u0431\u0449\u0438\u0442\u044c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0440\u0435\u0434 \u0441\u0442\u0440\u0434\u043b\u0438\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043a\u0430\u043d\u0447\u0438\u0432\u0430\u0435\u0442\u0441\u044f\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043c\u0435\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u043d\u0430\u0439\u0442\u0438 \u0441\u0442\u0440\u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442\u0441\u044f\u0441 \u0441\u0442\u0440\u043e\u043a\u0430 \u0441\u0442\u0440\u043e\u043a\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0441\u0442\u0440\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0441\u0442\u0440\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u0440\u0430\u0432\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0432\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0442\u0440\u0448\u0430\u0431\u043b\u043e\u043d \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430\u0441\u0435\u0430\u043d\u0441\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u0432\u043c\u0438\u043b\u043b\u0438\u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0442\u0438\u043f \u0442\u0438\u043f\u0437\u043d\u0447 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044f\u0430\u043a\u0442\u0438\u0432\u043d\u0430 \u0442\u0440\u0435\u0433 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0444\u043e\u0440\u043c\u0430\u0442 \u0446\u0435\u043b \u0447\u0430\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0447\u0438\u0441\u043b\u043e \u0447\u0438\u0441\u043b\u043e\u043f\u0440\u043e\u043f\u0438\u0441\u044c\u044e \u044d\u0442\u043e\u0430\u0434\u0440\u0435\u0441\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 ws\u0441\u0441\u044b\u043b\u043a\u0438 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043c\u0430\u043a\u0435\u0442\u043e\u0432\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u0441\u0442\u0438\u043b\u0435\u0439 \u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u044b \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0442\u0447\u0435\u0442\u044b \u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u043a\u0443\u043f\u043a\u0438 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0441\u0442\u0438\u043b\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u0436\u0443\u0440\u043d\u0430\u043b\u044b\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u0437\u0430\u0434\u0430\u0447\u0438 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0439\u0434\u0430\u0442\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u043d\u0441\u0442\u0430\u043d\u0442\u044b \u043a\u0440\u0438\u0442\u0435\u0440\u0438\u0438\u043e\u0442\u0431\u043e\u0440\u0430 \u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0435 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u043e\u0442\u0447\u0435\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u044c\u0437\u0430\u0434\u0430\u0447\u043e\u0441 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044f \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u043f\u043b\u0430\u043d\u044b\u043e\u0431\u043c\u0435\u043d\u0430 \u043f\u043b\u0430\u043d\u044b\u0441\u0447\u0435\u0442\u043e\u0432 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0440\u0430\u0431\u043e\u0447\u0430\u044f\u0434\u0430\u0442\u0430 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0433\u0435\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043c\u0443\u043b\u044c\u0442\u0438\u043c\u0435\u0434\u0438\u0430 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043f\u043e\u0447\u0442\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0438\u0438 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0435\u043f\u043e\u0442\u043e\u043a\u0438 \u0444\u043e\u043d\u043e\u0432\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0432\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043e\u0431\u0449\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438\u0445\u0441\u043f\u0438\u0441\u043a\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0441\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a ",
+class:"web\u0446\u0432\u0435\u0442\u0430 windows\u0446\u0432\u0435\u0442\u0430 windows\u0448\u0440\u0438\u0444\u0442\u044b \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0440\u0430\u043c\u043a\u0438\u0441\u0442\u0438\u043b\u044f \u0441\u0438\u043c\u0432\u043e\u043b\u044b \u0446\u0432\u0435\u0442\u0430\u0441\u0442\u0438\u043b\u044f \u0448\u0440\u0438\u0444\u0442\u044b\u0441\u0442\u0438\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0435\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044f\u0432\u0444\u043e\u0440\u043c\u0435 \u0430\u0432\u0442\u043e\u0440\u0430\u0437\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0438\u0439 \u0430\u043d\u0438\u043c\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0438\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u043e\u0432 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0432\u044b\u0441\u043e\u0442\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u0430\u044f\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0435\u043a\u043e\u0440\u0430\u0446\u0438\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0438\u0434\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044f \u0432\u0438\u0434\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u0432\u0438\u0434\u043f\u043e\u043b\u044f\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0444\u043b\u0430\u0436\u043a\u0430 \u0432\u043b\u0438\u044f\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043d\u0430\u043f\u0443\u0437\u044b\u0440\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043a\u043e\u043b\u043e\u043d\u043e\u043a \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0433\u0440\u0443\u043f\u043f\u044b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u043c\u0435\u0436\u0434\u0443\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043c\u0438\u0444\u043e\u0440\u043c\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u044b\u0432\u043e\u0434\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043e\u0441\u044b\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u043e\u0447\u043a\u0438\u0431\u0438\u0440\u0436\u0435\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043e\u0441\u0438\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043e\u043c\u0430\u043d\u0434 \u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c\u0441\u0435\u0440\u0438\u0439 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0434\u0435\u0440\u0435\u0432\u0430 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u043f\u0438\u0441\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043d\u043e\u043f\u043e\u043a \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438\u0432\u044b\u0431\u043e\u0440\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u0441\u0443\u0436\u0434\u0435\u043d\u0438\u0439\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u043e\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u044f\u043f\u0440\u0438\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0442\u043a\u0438\u043f\u043e\u043b\u043e\u0441\u044b\u0440\u0435\u0433\u0443\u043b\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0444\u0438\u0433\u0443\u0440\u044b\u043a\u043d\u043e\u043f\u043a\u0438 \u043f\u0430\u043b\u0438\u0442\u0440\u0430\u0446\u0432\u0435\u0442\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0438\u0441\u043a\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043e\u043f\u043e\u0440\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u0448\u043a\u0430\u043b\u044b\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u043e\u0438\u0441\u043a\u043e\u043c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0439\u0433\u0438\u0441\u0442\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0441\u0435\u0440\u0438\u0439\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0437\u043c\u0435\u0440\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0441\u0442\u044f\u0433\u0438\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0432\u0432\u043e\u0434\u0430\u0441\u0442\u0440\u043e\u043a\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0431\u043e\u0440\u0430\u043d\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u043e\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u0442\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0441\u0432\u044f\u0437\u0430\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u043f\u0435\u0447\u0430\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u0430\u043d\u0434\u044b \u0440\u0435\u0436\u0438\u043c\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u043a\u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u043e\u043a\u043d\u0430\u0444\u043e\u0440\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0435\u0440\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438\u0441\u0435\u0442\u043a\u0438\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u043e\u0441\u0442\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0431\u0435\u043b\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435 \u0440\u0435\u0436\u0438\u043c\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043a\u043e\u043b\u043e\u043d\u043a\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0441\u043f\u0438\u0441\u043a\u0430\u0437\u0430\u0434\u0430\u0447 \u0441\u043a\u0432\u043e\u0437\u043d\u043e\u0435\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u043f\u043e\u0441\u043e\u0431\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0438\u0432\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0433\u0440\u0443\u043f\u043f\u0430\u043a\u043e\u043c\u0430\u043d\u0434 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0435\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u0435 \u0441\u0442\u0430\u0442\u0443\u0441\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0442\u0438\u043b\u044c\u0441\u0442\u0440\u0435\u043b\u043a\u0438 \u0442\u0438\u043f\u0430\u043f\u043f\u0440\u043e\u043a\u0441\u0438\u043c\u0430\u0446\u0438\u0438\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0442\u0438\u043f\u0438\u043c\u043f\u043e\u0440\u0442\u0430\u0441\u0435\u0440\u0438\u0439\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0438\u0438\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u0447\u043d\u043e\u0433\u043e\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0448\u043a\u0430\u043b\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0438\u0441\u043a\u0430\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u0440\u043e\u0435\u043a\u0446\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u043c\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u043e\u0441\u0435\u0440\u0438\u044f\u043c\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u0442\u0438\u043f\u0441\u0442\u043e\u0440\u043e\u043d\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0448\u043a\u0430\u043b\u044b\u0440\u0430\u0434\u0430\u0440\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0430\u043a\u0442\u043e\u0440\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0438\u0433\u0443\u0440\u0430\u043a\u043d\u043e\u043f\u043a\u0438 \u0444\u0438\u0433\u0443\u0440\u044b\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435 \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u043d\u044f\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0444\u043e\u0440\u043c\u0430\u0442\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0448\u0438\u0440\u0438\u043d\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0442\u043e\u0447\u043a\u0438\u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u0436\u0438\u043c\u0430\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0440\u0435\u0437\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u0432\u0440\u0435\u043c\u044f \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0430\u0432\u0442\u043e\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0439 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u043d\u043e\u043c\u0435\u0440\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043b\u043e\u043d\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u0441\u0442\u0440\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u043e\u0442\u043d\u043e\u0441\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u0447\u0442\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0434\u0432\u0443\u0441\u0442\u043e\u0440\u043e\u043d\u043d\u0435\u0439\u043f\u0435\u0447\u0430\u0442\u0438 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043a\u0443\u0440\u0441\u043e\u0440\u043e\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u044f\u0447\u0435\u0439\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043b\u0438\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0443\u0437\u043e\u0440\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u043e\u0447\u043d\u043e\u0441\u0442\u044c\u043f\u0435\u0447\u0430\u0442\u0438 \u0447\u0435\u0440\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0431\u0445\u043e\u0434\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0437\u0430\u043f\u0438\u0441\u0438\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0442\u043e\u0433\u043e\u0432 \u0434\u043e\u0441\u0442\u0443\u043f\u043a\u0444\u0430\u0439\u043b\u0443 \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u0430\u0439\u043b\u0430 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0434\u0430\u043d\u043d\u044b\u0445\u0430\u043d\u0430\u043b\u0438\u0437\u0430 \u043c\u0435\u0442\u043e\u0434\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0447\u0438\u0441\u043b\u043e\u0432\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0434\u0435\u0440\u0435\u0432\u043e\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0430\u044f\u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0430 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u043c\u043e\u0434\u0435\u043b\u0438\u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 \u0442\u0438\u043f\u043c\u0435\u0440\u044b\u0440\u0430\u0441\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0442\u0441\u0435\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u043f\u043e\u043b\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u0438\u0437\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u0448\u0430\u0431\u043b\u043e\u043d\u043e\u0432\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u0440\u043e\u0449\u0435\u043d\u0438\u044f\u0434\u0435\u0440\u0435\u0432\u0430\u0440\u0435\u0448\u0435\u043d\u0438\u0439 ws\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442xpathxs \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u0430\u0442\u044bjson \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u0432\u0438\u0434\u0444\u0430\u0441\u0435\u0442\u0430xdto \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044fdom \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u0445\u0435\u043c\u044bxs \u0437\u0430\u043f\u0440\u0435\u0449\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043dxs \u043c\u0435\u0442\u043e\u0434\u043d\u0430\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u044fxs \u043c\u043e\u0434\u0435\u043b\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430xml \u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u043e\u0442\u0431\u043e\u0440\u0430\u0443\u0437\u043b\u043e\u0432dom \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0441\u0442\u0440\u043e\u043ajson \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0435dom \u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u0442\u0438\u043f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xml \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fjson \u0442\u0438\u043f\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043exml \u0442\u0438\u043f\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044bxs \u0442\u0438\u043f\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438xml \u0442\u0438\u043f\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430domxpath \u0442\u0438\u043f\u0443\u0437\u043b\u0430dom \u0442\u0438\u043f\u0443\u0437\u043b\u0430xml \u0444\u043e\u0440\u043c\u0430xml \u0444\u043e\u0440\u043c\u0430\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044fxs \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u0430\u0442\u044bjson \u044d\u043a\u0440\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432json \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u043e\u0436\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u0435\u0439\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u043e\u0433\u043e\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0432\u044b\u0432\u043e\u0434\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u043f\u043e\u043b\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u043d\u0430\u0431\u043e\u0440\u043e\u0432\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u0432\u0442\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0443\u0441\u043b\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0432\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043d\u0435ascii\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0442\u0435\u043a\u0441\u0442\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u044b \u0441\u0442\u0430\u0442\u0443\u0441\u0440\u0430\u0437\u0431\u043e\u0440\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0442\u0438\u043f\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0438\u043c\u0435\u043d\u0444\u0430\u0439\u043b\u043e\u0432\u0432zip\u0444\u0430\u0439\u043b\u0435 \u043c\u0435\u0442\u043e\u0434\u0441\u0436\u0430\u0442\u0438\u044fzip \u043c\u0435\u0442\u043e\u0434\u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044fzip \u0440\u0435\u0436\u0438\u043c\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439\u0444\u0430\u0439\u043b\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u043f\u043e\u0434\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439zip \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0441\u0436\u0430\u0442\u0438\u044fzip \u0437\u0432\u0443\u043a\u043e\u0432\u043e\u0435\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u043a\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u043f\u043e\u0442\u043e\u043a\u0435 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0431\u0430\u0439\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0432\u0438\u0441\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0444\u043e\u043d\u043e\u0432\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u043f\u043e\u0434\u043f\u0438\u0441\u0447\u0438\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044fftp \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0440\u044f\u0434\u043a\u0430\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043c\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044c\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 http\u043c\u0435\u0442\u043e\u0434 \u0430\u0432\u0442\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0430\u0432\u0442\u043e\u043f\u0440\u0435\u0444\u0438\u043a\u0441\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u043e\u0433\u043e\u044f\u0437\u044b\u043a\u0430 \u0432\u0438\u0434\u0438\u0435\u0440\u0430\u0440\u0445\u0438\u0438 \u0432\u0438\u0434\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0438\u043d\u0434\u0435\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u0430\u0437\u044b\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u044b\u0441\u0442\u0440\u043e\u0433\u043e\u0432\u044b\u0431\u043e\u0440\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0437\u0434\u0435\u043b\u044f\u0435\u043c\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u043e\u0435\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0437\u0430\u0434\u0430\u0447\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043b\u0430\u043d\u0430\u043e\u0431\u043c\u0435\u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0433\u0440\u0430\u043d\u0438\u0446\u044b\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u043c\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u043d\u043e\u0441\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u0430 \u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u044b\u0445\u0432\u044b\u0437\u043e\u0432\u043e\u0432\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b\u0438\u0432\u043d\u0435\u0448\u043d\u0438\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0433\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0444\u043e\u0440\u043c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0439\u0434\u0430\u0442\u044b\u043d\u0430\u0447\u0430\u043b\u0430 \u0432\u0438\u0434\u0433\u0440\u0430\u043d\u0438\u0446\u044b \u0432\u0438\u0434\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0432\u0438\u0434\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0432\u0438\u0434\u0440\u0430\u043c\u043a\u0438 \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0446\u0432\u0435\u0442\u0430 \u0432\u0438\u0434\u0447\u0438\u0441\u043b\u043e\u0432\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u0430\u044f\u0434\u043b\u0438\u043d\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u0437\u043d\u0430\u043a \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435byteordermark \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043a\u043b\u0430\u0432\u0438\u0448\u0430 \u043a\u043e\u0434\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430\u0434\u0438\u0430\u043b\u043e\u0433\u0430 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430xbase \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0438\u0441\u043a\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u043e\u043f\u0440\u043e\u0441 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u043a\u0440\u0443\u0433\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u043e\u0440\u043c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430windows \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u0442\u0438\u043f\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u043a\u043b\u0430\u0432\u0438\u0448\u0438enter \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438\u043e\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0438\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0437\u043e\u043b\u044f\u0446\u0438\u0438\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0439 \u0445\u0435\u0448\u0444\u0443\u043d\u043a\u0446\u0438\u044f \u0447\u0430\u0441\u0442\u0438\u0434\u0430\u0442\u044b",
+type:"com\u043e\u0431\u044a\u0435\u043a\u0442 ftp\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 http\u0437\u0430\u043f\u0440\u043e\u0441 http\u0441\u0435\u0440\u0432\u0438\u0441\u043e\u0442\u0432\u0435\u0442 http\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 ws\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f ws\u043f\u0440\u043e\u043a\u0441\u0438 xbase \u0430\u043d\u0430\u043b\u0438\u0437\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u043d\u043d\u043e\u0442\u0430\u0446\u0438\u044fxs \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u0444\u0435\u0440\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435xs \u0432\u044b\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0433\u0435\u043d\u0435\u0440\u0430\u0442\u043e\u0440\u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0445\u0447\u0438\u0441\u0435\u043b \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0438\u0435\u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0440\u0443\u043f\u043f\u0430\u043c\u043e\u0434\u0435\u043b\u0438xs \u0434\u0430\u043d\u043d\u044b\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430\u0433\u0430\u043d\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0446\u0432\u0435\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u044f\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442dom \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442html \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044fxs \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0438\u0441\u044cdom \u0437\u0430\u043f\u0438\u0441\u044cfastinfoset \u0437\u0430\u043f\u0438\u0441\u044chtml \u0437\u0430\u043f\u0438\u0441\u044cjson \u0437\u0430\u043f\u0438\u0441\u044cxml \u0437\u0430\u043f\u0438\u0441\u044czip\u0444\u0430\u0439\u043b\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0442\u0435\u043a\u0441\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0443\u0437\u043b\u043e\u0432dom \u0437\u0430\u043f\u0440\u043e\u0441 \u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435openssl \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u0435\u0439\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0437\u0432\u043b\u0435\u0447\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0438\u043c\u043f\u043e\u0440\u0442xs \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u0430 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439\u043f\u0440\u043e\u0444\u0438\u043b\u044c \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u0440\u043e\u043a\u0441\u0438 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0434\u043b\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044fxs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0442\u0435\u0440\u0430\u0442\u043e\u0440\u0443\u0437\u043b\u043e\u0432dom \u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0430 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0430\u0442\u044b \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0447\u0438\u0441\u043b\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043c\u0430\u043a\u0435\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u0444\u043e\u0440\u043c\u0430\u0442\u043d\u043e\u0439\u0441\u0442\u0440\u043e\u043a\u0438 \u043b\u0438\u043d\u0438\u044f \u043c\u0430\u043a\u0435\u0442\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u0441\u043a\u0430xs \u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043d\u0430\u0431\u043e\u0440\u0441\u0445\u0435\u043cxml \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438json \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u0445\u043e\u0434\u0434\u0435\u0440\u0435\u0432\u0430dom \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u043d\u043e\u0442\u0430\u0446\u0438\u0438xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430xs \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0434\u043e\u0441\u0442\u0443\u043f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u043e\u0442\u043a\u0430\u0437\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0432\u0430\u0435\u043c\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0442\u0438\u043f\u043e\u0432 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u043e\u0432xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430dom \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044fxpathxs \u043e\u0442\u0431\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u043a\u0435\u0442\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u043c\u044b\u0445\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438json \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438xml \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0447\u0442\u0435\u043d\u0438\u044fxml \u043f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435xs \u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a \u043f\u043e\u043b\u0435\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044cdom \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0441\u0445\u0435\u043cxml \u043f\u043e\u0442\u043e\u043a \u043f\u043e\u0442\u043e\u043a\u0432\u043f\u0430\u043c\u044f\u0442\u0438 \u043f\u043e\u0447\u0442\u0430 \u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435xsl \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043a\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u043c\u0443xml \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u043a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u044e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0437\u044b\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043ddom \u0440\u0430\u043c\u043a\u0430 \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0435\u0438\u043c\u044fxml \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0447\u0442\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0432\u043e\u0434\u043d\u0430\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u0444\u0430\u0439\u043b \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432\u0444\u0430\u0439\u043b \u0441\u0436\u0430\u0442\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0438\u0441\u0442\u0435\u043c\u043d\u0430\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u0441\u043e\u0447\u0435\u0442\u0430\u043d\u0438\u0435\u043a\u043b\u0430\u0432\u0438\u0448 \u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u043d\u0430\u0447\u0430\u043b\u0430 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439\u043f\u0435\u0440\u0438\u043e\u0434 \u0441\u0445\u0435\u043c\u0430xml \u0441\u0445\u0435\u043c\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u0441\u0442\u0438\u0440\u0443\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445xml \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u0439\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0439\u043f\u043e\u0442\u043e\u043a \u0444\u0430\u0441\u0435\u0442\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432\u0434\u0440\u043e\u0431\u043d\u043e\u0439\u0447\u0430\u0441\u0442\u0438xs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0440\u0430\u0437\u0446\u0430xs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0449\u0435\u0433\u043e\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432xs \u0444\u0430\u0441\u0435\u0442\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u0444\u0438\u043b\u044c\u0442\u0440\u0443\u0437\u043b\u043e\u0432dom \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0444\u0440\u0430\u0433\u043c\u0435\u043d\u0442xs \u0445\u0435\u0448\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0446\u0432\u0435\u0442 \u0447\u0442\u0435\u043d\u0438\u0435fastinfoset \u0447\u0442\u0435\u043d\u0438\u0435html \u0447\u0442\u0435\u043d\u0438\u0435json \u0447\u0442\u0435\u043d\u0438\u0435xml \u0447\u0442\u0435\u043d\u0438\u0435zip\u0444\u0430\u0439\u043b\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0447\u0442\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0443\u0437\u043b\u043e\u0432dom \u0448\u0440\u0438\u0444\u0442 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 comsafearray \u0434\u0435\u0440\u0435\u0432\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043c\u0430\u0441\u0441\u0438\u0432 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0441\u043f\u0438\u0441\u043e\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0442\u0430\u0431\u043b\u0438\u0446\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0435\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u043c\u0430\u0441\u0441\u0438\u0432 ",
+literal:"null \u0438\u0441\u0442\u0438\u043d\u0430 \u043b\u043e\u0436\u044c \u043d\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043e"},contains:[g,a,f,{className:"symbol",begin:"~",end:";|:",excludeEnd:!0},c,b,e]}});b.registerLanguage("abnf",function(a){return{illegal:"[!@#$^&',?+~`|:]",keywords:"ALPHA BIT CHAR CR CRLF CTL DIGIT DQUOTE HEXDIG HTAB LF LWSP OCTET SP VCHAR WSP",contains:[{begin:"^[a-zA-Z][a-zA-Z0-9-]*\\s*=",returnBegin:!0,end:/=/,relevance:0,contains:[{className:"attribute",
+begin:"^[a-zA-Z][a-zA-Z0-9-]*"}]},a.COMMENT(";","$"),{className:"symbol",begin:/%b[0-1]+(-[0-1]+|(\.[0-1]+)+){0,1}/},{className:"symbol",begin:/%d[0-9]+(-[0-9]+|(\.[0-9]+)+){0,1}/},{className:"symbol",begin:/%x[0-9A-F]+(-[0-9A-F]+|(\.[0-9A-F]+)+){0,1}/},{className:"symbol",begin:/%[si]/},a.QUOTE_STRING_MODE,a.NUMBER_MODE]}});b.registerLanguage("accesslog",function(a){return{contains:[{className:"number",begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{className:"number",begin:"\\b\\d+\\b",
+relevance:0},{className:"string",begin:'"(GET|POST|HEAD|PUT|DELETE|CONNECT|OPTIONS|PATCH|TRACE)',end:'"',keywords:"GET POST HEAD PUT DELETE CONNECT OPTIONS PATCH TRACE",illegal:"\\n",relevance:10},{className:"string",begin:/\[/,end:/\]/,illegal:"\\n"},{className:"string",begin:'"',end:'"',illegal:"\\n"}]}});b.registerLanguage("actionscript",function(a){return{aliases:["as"],keywords:{keyword:"as break case catch class const continue default delete do dynamic each else extends final finally for function get if implements import in include instanceof interface internal is namespace native new override package private protected public return set static super switch this throw try typeof use var void while with",
+literal:"true false null undefined"},contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.C_NUMBER_MODE,{className:"class",beginKeywords:"package",end:"{",contains:[a.TITLE_MODE]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,contains:[{beginKeywords:"extends implements"},a.TITLE_MODE]},{className:"meta",beginKeywords:"import include",end:";",keywords:{"meta-keyword":"import include"}},{className:"function",beginKeywords:"function",
+end:"[{;]",excludeEnd:!0,illegal:"\\S",contains:[a.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"rest_arg",begin:"[.]{3}",end:"[a-zA-Z_$][a-zA-Z0-9_$]*",relevance:10}]},{begin:":\\s*([*]|[a-zA-Z_$][a-zA-Z0-9_$]*)"}]},a.METHOD_GUARD],illegal:/#/}});b.registerLanguage("ada",function(a){a=a.COMMENT("--","$");var c={begin:"\\s+:\\s+",end:"\\s*(:=|;|\\)|=>|$)",illegal:"[]{}%#'\"",contains:[{beginKeywords:"loop for declare others",
+endsParent:!0},{className:"keyword",beginKeywords:"not null constant access function procedure in out aliased exception"},{className:"type",begin:"[A-Za-z](_?[A-Za-z0-9.])*",endsParent:!0,relevance:0}]};return{case_insensitive:!0,keywords:{keyword:"abort else new return abs elsif not reverse abstract end accept entry select access exception of separate aliased exit or some all others subtype and for out synchronized array function overriding at tagged generic package task begin goto pragma terminate body private then if procedure type case in protected constant interface is raise use declare range delay limited record when delta loop rem while digits renames with do mod requeue xor",
+literal:"True False"},contains:[a,{className:"string",begin:/"/,end:/"/,contains:[{begin:/""/,relevance:0}]},{className:"string",begin:/'.'/},{className:"number",begin:"\\b(\\d(_|\\d)*#\\w+(\\.\\w+)?#([eE][-+]?\\d(_|\\d)*)?|\\d(_|\\d)*(\\.\\d(_|\\d)*)?([eE][-+]?\\d(_|\\d)*)?)",relevance:0},{className:"symbol",begin:"'[A-Za-z](_?[A-Za-z0-9.])*"},{className:"title",begin:"(\\bwith\\s+)?(\\bprivate\\s+)?\\bpackage\\s+(\\bbody\\s+)?",end:"(is|$)",keywords:"package body",excludeBegin:!0,excludeEnd:!0,
+illegal:"[]{}%#'\""},{begin:"(\\b(with|overriding)\\s+)?\\b(function|procedure)\\s+",end:"(\\bis|\\bwith|\\brenames|\\)\\s*;)",keywords:"overriding function procedure with is renames return",returnBegin:!0,contains:[a,{className:"title",begin:"(\\bwith\\s+)?\\b(function|procedure)\\s+",end:"(\\(|\\s+|$)",excludeBegin:!0,excludeEnd:!0,illegal:"[]{}%#'\""},c,{className:"type",begin:"\\breturn\\s+",end:"(\\s+|;|$)",keywords:"return",excludeBegin:!0,excludeEnd:!0,endsParent:!0,illegal:"[]{}%#'\""}]},
+{className:"type",begin:"\\b(sub)?type\\s+",end:"\\s+",keywords:"type",excludeBegin:!0,illegal:"[]{}%#'\""},c]}});b.registerLanguage("angelscript",function(a){var c={className:"built_in",begin:"\\b(void|bool|int|int8|int16|int32|int64|uint|uint8|uint16|uint32|uint64|string|ref|array|double|float|auto|dictionary)"},b={className:"symbol",begin:"[a-zA-Z0-9_]+@"},e={className:"keyword",begin:"<",end:">",contains:[c,b]};c.contains=[e];b.contains=[e];return{aliases:["asc"],keywords:"for in|0 break continue while do|0 return if else case switch namespace is cast or and xor not get|0 in inout|10 out override set|0 private public const default|0 final shared external mixin|10 enum typedef funcdef this super import from interface abstract|0 try catch protected explicit",
+illegal:"(^using\\s+[A-Za-z0-9_\\.]+;$|\\bfunctions*[^\\(])",contains:[{className:"string",begin:"'",end:"'",illegal:"\\n",contains:[a.BACKSLASH_ESCAPE],relevance:0},{className:"string",begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE],relevance:0},{className:"string",begin:'"""',end:'"""'},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{beginKeywords:"interface namespace",end:"{",illegal:"[;.\\-]",contains:[{className:"symbol",begin:"[a-zA-Z0-9_]+"}]},{beginKeywords:"class",end:"{",illegal:"[;.\\-]",
+contains:[{className:"symbol",begin:"[a-zA-Z0-9_]+",contains:[{begin:"[:,]\\s*",contains:[{className:"symbol",begin:"[a-zA-Z0-9_]+"}]}]}]},c,b,{className:"literal",begin:"\\b(null|true|false)"},{className:"number",begin:"(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?f?|\\.\\d+f?)([eE][-+]?\\d+f?)?)"}]}});b.registerLanguage("apache",function(a){var c={className:"number",begin:"[\\$%]\\d+"};return{aliases:["apacheconf"],case_insensitive:!0,contains:[a.HASH_COMMENT_MODE,{className:"section",begin:"</?",
+end:">"},{className:"attribute",begin:/\w+/,relevance:0,keywords:{nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername"},starts:{end:/$/,relevance:0,keywords:{literal:"on off all"},contains:[{className:"meta",begin:"\\s\\[",end:"\\]$"},{className:"variable",begin:"[\\$%]\\{",end:"\\}",contains:["self",c]},c,a.QUOTE_STRING_MODE]}}],illegal:/\S/}});b.registerLanguage("applescript",function(a){var c=
+a.inherit(a.QUOTE_STRING_MODE,{illegal:""}),b={className:"params",begin:"\\(",end:"\\)",contains:["self",a.C_NUMBER_MODE,c]},e=a.COMMENT("--","$"),f=a.COMMENT("\\(\\*","\\*\\)",{contains:["self",e]});return{aliases:["osascript"],keywords:{keyword:"about above after against and around as at back before beginning behind below beneath beside between but by considering contain contains continue copy div does eighth else end equal equals error every exit fifth first for fourth from front get given global if ignoring in into is it its last local me middle mod my ninth not of on onto or over prop property put ref reference repeat returning script second set seventh since sixth some tell tenth that the|0 then third through thru timeout times to transaction try until where while whose with without",
+literal:"AppleScript false linefeed return pi quote result space tab true",built_in:"alias application boolean class constant date file integer list number real record string text activate beep count delay launch log offset read round run say summarize write character characters contents day frontmost id item length month name paragraph paragraphs rest reverse running time version weekday word words year"},contains:[c,a.C_NUMBER_MODE,{className:"built_in",begin:"\\b(clipboard info|the clipboard|info for|list (disks|folder)|mount volume|path to|(close|open for) access|(get|set) eof|current date|do shell script|get volume settings|random number|set volume|system attribute|system info|time to GMT|(load|run|store) script|scripting components|ASCII (character|number)|localized string|choose (application|color|file|file name|folder|from list|remote application|URL)|display (alert|dialog))\\b|^\\s*return\\b"},
+{className:"literal",begin:"\\b(text item delimiters|current application|missing value)\\b"},{className:"keyword",begin:"\\b(apart from|aside from|instead of|out of|greater than|isn't|(doesn't|does not) (equal|come before|come after|contain)|(greater|less) than( or equal)?|(starts?|ends|begins?) with|contained by|comes (before|after)|a (ref|reference)|POSIX file|POSIX path|(date|time) string|quoted form)\\b"},{beginKeywords:"on",illegal:"[${=;\\n]",contains:[a.UNDERSCORE_TITLE_MODE,b]}].concat([e,
+f,a.HASH_COMMENT_MODE]),illegal:"//|->|=>|\\[\\["}});b.registerLanguage("arcade",function(a){var c={keyword:"if for while var new function do return void else break",literal:"true false null undefined NaN Infinity PI BackSlash DoubleQuote ForwardSlash NewLine SingleQuote Tab",built_in:"Abs Acos Area AreaGeodetic Asin Atan Atan2 Average Boolean Buffer BufferGeodetic Ceil Centroid Clip Console Constrain Contains Cos Count Crosses Cut Date DateAdd DateDiff Day Decode DefaultValue Dictionary Difference Disjoint Distance Distinct DomainCode DomainName Equals Exp Extent Feature FeatureSet FeatureSetById FeatureSetByTitle FeatureSetByUrl Filter First Floor Geometry Guid HasKey Hour IIf IndexOf Intersection Intersects IsEmpty Length LengthGeodetic Log Max Mean Millisecond Min Minute Month MultiPartToSinglePart Multipoint NextSequenceValue Now Number OrderBy Overlaps Point Polygon Polyline Pow Random Relate Reverse Round Second SetGeometry Sin Sort Sqrt Stdev Sum SymmetricDifference Tan Text Timestamp Today ToLocal Top Touches ToUTC TypeOf Union Variance Weekday When Within Year "},
+b={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},e={className:"subst",begin:"\\$\\{",end:"\\}",keywords:c,contains:[]},f={className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,e]};e.contains=[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,f,b,a.REGEXP_MODE];e=e.contains.concat([a.C_BLOCK_COMMENT_MODE,a.C_LINE_COMMENT_MODE]);return{aliases:["arcade"],keywords:c,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,f,a.C_LINE_COMMENT_MODE,
+a.C_BLOCK_COMMENT_MODE,{className:"symbol",begin:"\\$[feature|layer|map|value|view]+"},b,{begin:/[{,]\s*/,relevance:0,contains:[{begin:"[A-Za-z_][0-9A-Za-z_]*\\s*:",returnBegin:!0,relevance:0,contains:[{className:"attr",begin:"[A-Za-z_][0-9A-Za-z_]*",relevance:0}]}]},{begin:"("+a.RE_STARTERS_RE+"|\\b(return)\\b)\\s*",keywords:"return",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|[A-Za-z_][0-9A-Za-z_]*)\\s*=>",returnBegin:!0,end:"\\s*=>",
+contains:[{className:"params",variants:[{begin:"[A-Za-z_][0-9A-Za-z_]*"},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:c,contains:e}]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z_][0-9A-Za-z_]*"}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:e}],illegal:/\[|%/},{begin:/\$[(.]/}],illegal:/#(?!!)/}});b.registerLanguage("cpp",function(a){var c={className:"keyword",
+begin:"\\b[a-z\\d_]*_t\\b"},b={className:"string",variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\((?:.|\n)*?\)\1"/},{begin:"'\\\\?.",end:"'",illegal:"."}]},e={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},f={className:"meta",begin:/#\s*[a-z]+\b/,
+end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"},contains:[{begin:/\\\n/,relevance:0},a.inherit(b,{className:"meta-string"}),{className:"meta-string",begin:/<[^\n>]*>/,end:/$/,illegal:"\\n"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},g=a.IDENT_RE+"\\s*\\(",k={keyword:"int float while private char catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignof constexpr decltype noexcept static_assert thread_local restrict _Bool complex _Complex _Imaginary atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and or not",
 built_in:"std string cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap array shared_ptr abort abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr",
-literal:"true false nullptr NULL"},l=[b,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,h,e];return{aliases:"c cc h c++ h++ hpp".split(" "),keywords:k,illegal:"</",contains:l.concat([c,{begin:"\\b(deque|list|queue|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",end:">",keywords:k,contains:["self",b]},{begin:a.IDENT_RE+"::",keywords:k},{variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",
-end:/;/}],keywords:k,contains:l.concat([{begin:/\(/,end:/\)/,keywords:k,contains:l.concat(["self"]),relevance:0}]),relevance:0},{className:"function",begin:"("+a.IDENT_RE+"[\\*&\\s]+)+"+f,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:k,illegal:/[^\w\s\*&]/,contains:[{begin:f,returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:k,relevance:0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,e,h,b]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,
-c]},{className:"class",beginKeywords:"class struct",end:/[{;:]/,contains:[{begin:/</,end:/>/,contains:["self"]},a.TITLE_MODE]}]),exports:{preprocessor:c,strings:e,keywords:k}}});b.registerLanguage("css",function(a){return{case_insensitive:!0,illegal:/[=\/|'\$]/,contains:[a.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},
-{begin:"@(font-face|page)",lexemes:"[a-z-]+",keywords:"font-face page"},{begin:"@",end:"[{;]",illegal:/:/,contains:[{className:"keyword",begin:/\w+/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[a.C_BLOCK_COMMENT_MODE,{begin:/[A-Z\_\.\-]+\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",
-begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}]},a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]}]}]}});b.registerLanguage("go",function(a){var b={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",
-literal:"true false iota nil",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{aliases:["golang"],keywords:b,illegal:"</",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[a.QUOTE_STRING_MODE,{begin:"'",end:"[^\\\\]'"},{begin:"`",end:"`"}]},{className:"number",variants:[{begin:a.C_NUMBER_RE+"[dflsi]",relevance:1},a.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func",end:/\s*\{/,excludeEnd:!0,
-contains:[a.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:b,illegal:/["']/}]}]}});b.registerLanguage("haskell",function(a){var b={variants:[a.COMMENT("--","$"),a.COMMENT("{-","-}",{contains:["self"]})]},e={className:"meta",begin:"{-#",end:"#-}"},h={className:"meta",begin:"^#",end:"$"},c={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},f={begin:"\\(",end:"\\)",illegal:'"',contains:[e,h,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},a.inherit(a.TITLE_MODE,{begin:"[_a-z][\\w']*"}),
-b]};return{aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[f,b],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[f,b],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",
-end:"where",keywords:"class family instance where",contains:[c,f,b]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[e,c,f,{begin:"{",end:"}",contains:f.contains},b]},{beginKeywords:"default",end:"$",contains:[c,f,b]},{beginKeywords:"infix infixl infixr",end:"$",contains:[a.C_NUMBER_MODE,b]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[c,a.QUOTE_STRING_MODE,
-b]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},e,h,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,c,a.inherit(a.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),b,{begin:"->|<-"}]}});b.registerLanguage("java",function(a){return{aliases:["jsp"],keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",
+literal:"true false nullptr NULL"},t=[c,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,e,b];return{aliases:"c cc h c++ h++ hpp hh hxx cxx".split(" "),keywords:k,illegal:"</",contains:t.concat([f,{begin:"\\b(deque|list|queue|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",end:">",keywords:k,contains:["self",c]},{begin:a.IDENT_RE+"::",keywords:k},{variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",
+end:/;/}],keywords:k,contains:t.concat([{begin:/\(/,end:/\)/,keywords:k,contains:t.concat(["self"]),relevance:0}]),relevance:0},{className:"function",begin:"("+a.IDENT_RE+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:k,illegal:/[^\w\s\*&]/,contains:[{begin:g,returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:k,relevance:0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b,e,c,{begin:/\(/,end:/\)/,keywords:k,relevance:0,contains:["self",
+a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b,e,c]}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,f]},{className:"class",beginKeywords:"class struct",end:/[{;:]/,contains:[{begin:/</,end:/>/,contains:["self"]},a.TITLE_MODE]}]),exports:{preprocessor:f,strings:b,keywords:k}}});b.registerLanguage("arduino",function(a){var c=a.getLanguage("cpp").exports;return{keywords:{keyword:"boolean byte word string String array "+c.keywords.keyword,built_in:"setup loop while catch for if do goto try switch case else default break continue return KeyboardController MouseController SoftwareSerial EthernetServer EthernetClient LiquidCrystal RobotControl GSMVoiceCall EthernetUDP EsploraTFT HttpClient RobotMotor WiFiClient GSMScanner FileSystem Scheduler GSMServer YunClient YunServer IPAddress GSMClient GSMModem Keyboard Ethernet Console GSMBand Esplora Stepper Process WiFiUDP GSM_SMS Mailbox USBHost Firmata PImage Client Server GSMPIN FileIO Bridge Serial EEPROM Stream Mouse Audio Servo File Task GPRS WiFi Wire TFT GSM SPI SD runShellCommandAsynchronously analogWriteResolution retrieveCallingNumber printFirmwareVersion analogReadResolution sendDigitalPortPair noListenOnLocalhost readJoystickButton setFirmwareVersion readJoystickSwitch scrollDisplayRight getVoiceCallStatus scrollDisplayLeft writeMicroseconds delayMicroseconds beginTransmission getSignalStrength runAsynchronously getAsynchronously listenOnLocalhost getCurrentCarrier readAccelerometer messageAvailable sendDigitalPorts lineFollowConfig countryNameWrite runShellCommand readStringUntil rewindDirectory readTemperature setClockDivider readLightSensor endTransmission analogReference detachInterrupt countryNameRead attachInterrupt encryptionType readBytesUntil robotNameWrite readMicrophone robotNameRead cityNameWrite userNameWrite readJoystickY readJoystickX mouseReleased openNextFile scanNetworks noInterrupts digitalWrite beginSpeaker mousePressed isActionDone mouseDragged displayLogos noAutoscroll addParameter remoteNumber getModifiers keyboardRead userNameRead waitContinue processInput parseCommand printVersion readNetworks writeMessage blinkVersion cityNameRead readMessage setDataMode parsePacket isListening setBitOrder beginPacket isDirectory motorsWrite drawCompass digitalRead clearScreen serialEvent rightToLeft setTextSize leftToRight requestFrom keyReleased compassRead analogWrite interrupts WiFiServer disconnect playMelody parseFloat autoscroll getPINUsed setPINUsed setTimeout sendAnalog readSlider analogRead beginWrite createChar motorsStop keyPressed tempoWrite readButton subnetMask debugPrint macAddress writeGreen randomSeed attachGPRS readString sendString remotePort releaseAll mouseMoved background getXChange getYChange answerCall getResult voiceCall endPacket constrain getSocket writeJSON getButton available connected findUntil readBytes exitValue readGreen writeBlue startLoop IPAddress isPressed sendSysex pauseMode gatewayIP setCursor getOemKey tuneWrite noDisplay loadImage switchPIN onRequest onReceive changePIN playFile noBuffer parseInt overflow checkPIN knobRead beginTFT bitClear updateIR bitWrite position writeRGB highByte writeRed setSpeed readBlue noStroke remoteIP transfer shutdown hangCall beginSMS endWrite attached maintain noCursor checkReg checkPUK shiftOut isValid shiftIn pulseIn connect println localIP pinMode getIMEI display noBlink process getBand running beginSD drawBMP lowByte setBand release bitRead prepare pointTo readRed setMode noFill remove listen stroke detach attach noTone exists buffer height bitSet circle config cursor random IRread setDNS endSMS getKey micros millis begin print write ready flush width isPIN blink clear press mkdir rmdir close point yield image BSSID click delay read text move peek beep rect line open seek fill size turn stop home find step tone sqrt RSSI SSID end bit tan cos sin pow map abs max min get run put",
+literal:"DIGITAL_MESSAGE FIRMATA_STRING ANALOG_MESSAGE REPORT_DIGITAL REPORT_ANALOG INPUT_PULLUP SET_PIN_MODE INTERNAL2V56 SYSTEM_RESET LED_BUILTIN INTERNAL1V1 SYSEX_START INTERNAL EXTERNAL DEFAULT OUTPUT INPUT HIGH LOW"},contains:[c.preprocessor,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE]}});b.registerLanguage("armasm",function(a){return{case_insensitive:!0,aliases:["arm"],lexemes:"\\.?"+a.IDENT_RE,keywords:{meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .arm .thumb .code16 .code32 .force_thumb .thumb_func .ltorg ALIAS ALIGN ARM AREA ASSERT ATTR CN CODE CODE16 CODE32 COMMON CP DATA DCB DCD DCDU DCDO DCFD DCFDU DCI DCQ DCQU DCW DCWU DN ELIF ELSE END ENDFUNC ENDIF ENDP ENTRY EQU EXPORT EXPORTAS EXTERN FIELD FILL FUNCTION GBLA GBLL GBLS GET GLOBAL IF IMPORT INCBIN INCLUDE INFO KEEP LCLA LCLL LCLS LTORG MACRO MAP MEND MEXIT NOFP OPT PRESERVE8 PROC QN READONLY RELOC REQUIRE REQUIRE8 RLIST FN ROUT SETA SETL SETS SN SPACE SUBT THUMB THUMBX TTL WHILE WEND ",
+built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 pc lr sp ip sl sb fp a1 a2 a3 a4 v1 v2 v3 v4 v5 v6 v7 v8 f0 f1 f2 f3 f4 f5 f6 f7 p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11 p12 p13 p14 p15 c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 q0 q1 q2 q3 q4 q5 q6 q7 q8 q9 q10 q11 q12 q13 q14 q15 cpsr_c cpsr_x cpsr_s cpsr_f cpsr_cx cpsr_cxs cpsr_xs cpsr_xsf cpsr_sf cpsr_cxsf spsr_c spsr_x spsr_s spsr_f spsr_cx spsr_cxs spsr_xs spsr_xsf spsr_sf spsr_cxsf s0 s1 s2 s3 s4 s5 s6 s7 s8 s9 s10 s11 s12 s13 s14 s15 s16 s17 s18 s19 s20 s21 s22 s23 s24 s25 s26 s27 s28 s29 s30 s31 d0 d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 d11 d12 d13 d14 d15 d16 d17 d18 d19 d20 d21 d22 d23 d24 d25 d26 d27 d28 d29 d30 d31 {PC} {VAR} {TRUE} {FALSE} {OPT} {CONFIG} {ENDIAN} {CODESIZE} {CPU} {FPU} {ARCHITECTURE} {PCSTOREOFFSET} {ARMASM_VERSION} {INTER} {ROPI} {RWPI} {SWST} {NOSWST} . @"},
+contains:[{className:"keyword",begin:"\\b(adc|(qd?|sh?|u[qh]?)?add(8|16)?|usada?8|(q|sh?|u[qh]?)?(as|sa)x|and|adrl?|sbc|rs[bc]|asr|b[lx]?|blx|bxj|cbn?z|tb[bh]|bic|bfc|bfi|[su]bfx|bkpt|cdp2?|clz|clrex|cmp|cmn|cpsi[ed]|cps|setend|dbg|dmb|dsb|eor|isb|it[te]{0,3}|lsl|lsr|ror|rrx|ldm(([id][ab])|f[ds])?|ldr((s|ex)?[bhd])?|movt?|mvn|mra|mar|mul|[us]mull|smul[bwt][bt]|smu[as]d|smmul|smmla|mla|umlaal|smlal?([wbt][bt]|d)|mls|smlsl?[ds]|smc|svc|sev|mia([bt]{2}|ph)?|mrr?c2?|mcrr2?|mrs|msr|orr|orn|pkh(tb|bt)|rbit|rev(16|sh)?|sel|[su]sat(16)?|nop|pop|push|rfe([id][ab])?|stm([id][ab])?|str(ex)?[bhd]?|(qd?)?sub|(sh?|q|u[qh]?)?sub(8|16)|[su]xt(a?h|a?b(16)?)|srs([id][ab])?|swpb?|swi|smi|tst|teq|wfe|wfi|yield)(eq|ne|cs|cc|mi|pl|vs|vc|hi|ls|ge|lt|gt|le|al|hs|lo)?[sptrx]?",
+end:"\\s"},a.COMMENT("[;@]","$",{relevance:0}),a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",relevance:0},{className:"title",begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{className:"number",variants:[{begin:"[#$=]?0x[0-9a-f]+"},{begin:"[#$=]?0b[01]+"},{begin:"[#$=]\\d+"},{begin:"\\b\\d+"}],relevance:0},{className:"symbol",variants:[{begin:"^[a-z_\\.\\$][a-z0-9_\\.\\$]+"},{begin:"^\\s*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{begin:"[=#]\\w+"}],relevance:0}]}});
+b.registerLanguage("xml",function(a){var c={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]};return{aliases:"html xhtml rss atom xjb xsd xsl plist".split(" "),case_insensitive:!0,contains:[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},a.COMMENT("\x3c!--",
+"--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{begin:/<\?(php)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},a.inherit(a.APOS_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0})]},{className:"tag",begin:"<style(?=\\s|>|$)",end:">",
+keywords:{name:"style"},contains:[c],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[c],starts:{end:"\x3c/script>",returnEnd:!0,subLanguage:["actionscript","javascript","handlebars","xml"]}},{className:"tag",begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},c]}]}});b.registerLanguage("asciidoc",function(a){return{aliases:["adoc"],contains:[a.COMMENT("^/{4,}\\n","\\n/{4,}$",
+{relevance:10}),a.COMMENT("^//","$",{relevance:0}),{className:"title",begin:"^\\.\\w.*$"},{begin:"^[=\\*]{4,}\\n",end:"\\n^[=\\*]{4,}$",relevance:10},{className:"section",relevance:10,variants:[{begin:"^(={1,5}) .+?( \\1)?$"},{begin:"^[^\\[\\]\\n]+?\\n[=\\-~\\^\\+]{2,}$"}]},{className:"meta",begin:"^:.+?:",end:"\\s",excludeEnd:!0,relevance:10},{className:"meta",begin:"^\\[.+?\\]$",relevance:0},{className:"quote",begin:"^_{4,}\\n",end:"\\n_{4,}$",relevance:10},{className:"code",begin:"^[\\-\\.]{4,}\\n",
+end:"\\n[\\-\\.]{4,}$",relevance:10},{begin:"^\\+{4,}\\n",end:"\\n\\+{4,}$",contains:[{begin:"<",end:">",subLanguage:"xml",relevance:0}],relevance:10},{className:"bullet",begin:"^(\\*+|\\-+|\\.+|[^\\n]+?::)\\s+"},{className:"symbol",begin:"^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\\s+",relevance:10},{className:"strong",begin:"\\B\\*(?![\\*\\s])",end:"(\\n{2}|\\*)",contains:[{begin:"\\\\*\\w",relevance:0}]},{className:"emphasis",begin:"\\B'(?!['\\s])",end:"(\\n{2}|')",contains:[{begin:"\\\\'\\w",relevance:0}],
+relevance:0},{className:"emphasis",begin:"_(?![_\\s])",end:"(\\n{2}|_)",relevance:0},{className:"string",variants:[{begin:"``.+?''"},{begin:"`.+?'"}]},{className:"code",begin:"(`.+?`|\\+.+?\\+)",relevance:0},{className:"code",begin:"^[ \\t]",end:"$",relevance:0},{begin:"^'{3,}[ \\t]*$",relevance:10},{begin:"(link:)?(http|https|ftp|file|irc|image:?):\\S+\\[.*?\\]",returnBegin:!0,contains:[{begin:"(link|image:?):",relevance:0},{className:"link",begin:"\\w",end:"[^\\[]+",relevance:0},{className:"string",
+begin:"\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0,relevance:0}],relevance:10}]}});b.registerLanguage("aspectj",function(a){return{keywords:"false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance",
+illegal:/<\/|#/,contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"class",beginKeywords:"aspect",end:/[{;=]/,excludeEnd:!0,illegal:/[:;"\[\]]/,contains:[{beginKeywords:"extends implements pertypewithin perthis pertarget percflowbelow percflow issingleton"},a.UNDERSCORE_TITLE_MODE,{begin:/\([^\)]*/,end:/[)]+/,keywords:"false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance get set args call",
+excludeEnd:!1}]},{className:"class",beginKeywords:"class interface",end:/[{;=]/,excludeEnd:!0,relevance:0,keywords:"class interface",illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"pointcut after before around throwing returning",end:/[)]/,excludeEnd:!1,illegal:/["\[\]]/,contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,contains:[a.UNDERSCORE_TITLE_MODE]}]},{begin:/[:]/,returnBegin:!0,end:/[{;]/,relevance:0,excludeEnd:!1,keywords:"false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance",
+illegal:/["\[\]]/,contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",keywords:"false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance get set args call",
+relevance:0},a.QUOTE_STRING_MODE]},{beginKeywords:"new throw",relevance:0},{className:"function",begin:/\w+ +\w+(\.)?\w+\s*\([^\)]*\)\s*((throws)[\w\s,]+)?[\{;]/,returnBegin:!0,end:/[{;=]/,keywords:"false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance",
+excludeEnd:!0,contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,relevance:0,keywords:"false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance",
+contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("autohotkey",function(a){var c={begin:"`[\\s\\S]"};return{case_insensitive:!0,aliases:["ahk"],keywords:{keyword:"Break Continue Critical Exit ExitApp Gosub Goto New OnExit Pause return SetBatchLines SetTimer Suspend Thread Throw Until ahk_id ahk_class ahk_pid ahk_exe ahk_group",literal:"A|0 true false NOT AND OR",
+built_in:"ComSpec Clipboard ClipboardAll ErrorLevel"},contains:[{className:"built_in",begin:"A_[a-zA-Z0-9]+"},c,a.inherit(a.QUOTE_STRING_MODE,{contains:[c]}),a.COMMENT(";","$",{relevance:0}),a.C_BLOCK_COMMENT_MODE,{className:"number",begin:a.NUMBER_RE,relevance:0},{className:"subst",begin:"%(?=[a-zA-Z0-9#_$@])",end:"%",illegal:"[^a-zA-Z0-9#_$@]"},{className:"built_in",begin:"^\\s*\\w+\\s*,"},{className:"meta",begin:"^\\s*#w+",end:"$",relevance:0},{className:"symbol",contains:[c],variants:[{begin:'^[^\\n";]+::(?!=)'},
+{begin:'^[^\\n";]+:(?!=)',relevance:0}]},{begin:",\\s*,"}]}});b.registerLanguage("autoit",function(a){var c={variants:[a.COMMENT(";","$",{relevance:0}),a.COMMENT("#cs","#ce"),a.COMMENT("#comments-start","#comments-end")]},b={begin:"\\$[A-z0-9_]+"},e={className:"string",variants:[{begin:/"/,end:/"/,contains:[{begin:/""/,relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]},f={variants:[a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE]};return{case_insensitive:!0,illegal:/\/\*/,keywords:{keyword:"ByRef Case Const ContinueCase ContinueLoop Default Dim Do Else ElseIf EndFunc EndIf EndSelect EndSwitch EndWith Enum Exit ExitLoop For Func Global If In Local Next ReDim Return Select Static Step Switch Then To Until Volatile WEnd While With",
+built_in:"Abs ACos AdlibRegister AdlibUnRegister Asc AscW ASin Assign ATan AutoItSetOption AutoItWinGetTitle AutoItWinSetTitle Beep Binary BinaryLen BinaryMid BinaryToString BitAND BitNOT BitOR BitRotate BitShift BitXOR BlockInput Break Call CDTray Ceiling Chr ChrW ClipGet ClipPut ConsoleRead ConsoleWrite ConsoleWriteError ControlClick ControlCommand ControlDisable ControlEnable ControlFocus ControlGetFocus ControlGetHandle ControlGetPos ControlGetText ControlHide ControlListView ControlMove ControlSend ControlSetText ControlShow ControlTreeView Cos Dec DirCopy DirCreate DirGetSize DirMove DirRemove DllCall DllCallAddress DllCallbackFree DllCallbackGetPtr DllCallbackRegister DllClose DllOpen DllStructCreate DllStructGetData DllStructGetPtr DllStructGetSize DllStructSetData DriveGetDrive DriveGetFileSystem DriveGetLabel DriveGetSerial DriveGetType DriveMapAdd DriveMapDel DriveMapGet DriveSetLabel DriveSpaceFree DriveSpaceTotal DriveStatus EnvGet EnvSet EnvUpdate Eval Execute Exp FileChangeDir FileClose FileCopy FileCreateNTFSLink FileCreateShortcut FileDelete FileExists FileFindFirstFile FileFindNextFile FileFlush FileGetAttrib FileGetEncoding FileGetLongName FileGetPos FileGetShortcut FileGetShortName FileGetSize FileGetTime FileGetVersion FileInstall FileMove FileOpen FileOpenDialog FileRead FileReadLine FileReadToArray FileRecycle FileRecycleEmpty FileSaveDialog FileSelectFolder FileSetAttrib FileSetEnd FileSetPos FileSetTime FileWrite FileWriteLine Floor FtpSetProxy FuncName GUICreate GUICtrlCreateAvi GUICtrlCreateButton GUICtrlCreateCheckbox GUICtrlCreateCombo GUICtrlCreateContextMenu GUICtrlCreateDate GUICtrlCreateDummy GUICtrlCreateEdit GUICtrlCreateGraphic GUICtrlCreateGroup GUICtrlCreateIcon GUICtrlCreateInput GUICtrlCreateLabel GUICtrlCreateList GUICtrlCreateListView GUICtrlCreateListViewItem GUICtrlCreateMenu GUICtrlCreateMenuItem GUICtrlCreateMonthCal GUICtrlCreateObj GUICtrlCreatePic GUICtrlCreateProgress GUICtrlCreateRadio GUICtrlCreateSlider GUICtrlCreateTab GUICtrlCreateTabItem GUICtrlCreateTreeView GUICtrlCreateTreeViewItem GUICtrlCreateUpdown GUICtrlDelete GUICtrlGetHandle GUICtrlGetState GUICtrlRead GUICtrlRecvMsg GUICtrlRegisterListViewSort GUICtrlSendMsg GUICtrlSendToDummy GUICtrlSetBkColor GUICtrlSetColor GUICtrlSetCursor GUICtrlSetData GUICtrlSetDefBkColor GUICtrlSetDefColor GUICtrlSetFont GUICtrlSetGraphic GUICtrlSetImage GUICtrlSetLimit GUICtrlSetOnEvent GUICtrlSetPos GUICtrlSetResizing GUICtrlSetState GUICtrlSetStyle GUICtrlSetTip GUIDelete GUIGetCursorInfo GUIGetMsg GUIGetStyle GUIRegisterMsg GUISetAccelerators GUISetBkColor GUISetCoord GUISetCursor GUISetFont GUISetHelp GUISetIcon GUISetOnEvent GUISetState GUISetStyle GUIStartGroup GUISwitch Hex HotKeySet HttpSetProxy HttpSetUserAgent HWnd InetClose InetGet InetGetInfo InetGetSize InetRead IniDelete IniRead IniReadSection IniReadSectionNames IniRenameSection IniWrite IniWriteSection InputBox Int IsAdmin IsArray IsBinary IsBool IsDeclared IsDllStruct IsFloat IsFunc IsHWnd IsInt IsKeyword IsNumber IsObj IsPtr IsString Log MemGetStats Mod MouseClick MouseClickDrag MouseDown MouseGetCursor MouseGetPos MouseMove MouseUp MouseWheel MsgBox Number ObjCreate ObjCreateInterface ObjEvent ObjGet ObjName OnAutoItExitRegister OnAutoItExitUnRegister Ping PixelChecksum PixelGetColor PixelSearch ProcessClose ProcessExists ProcessGetStats ProcessList ProcessSetPriority ProcessWait ProcessWaitClose ProgressOff ProgressOn ProgressSet Ptr Random RegDelete RegEnumKey RegEnumVal RegRead RegWrite Round Run RunAs RunAsWait RunWait Send SendKeepActive SetError SetExtended ShellExecute ShellExecuteWait Shutdown Sin Sleep SoundPlay SoundSetWaveVolume SplashImageOn SplashOff SplashTextOn Sqrt SRandom StatusbarGetText StderrRead StdinWrite StdioClose StdoutRead String StringAddCR StringCompare StringFormat StringFromASCIIArray StringInStr StringIsAlNum StringIsAlpha StringIsASCII StringIsDigit StringIsFloat StringIsInt StringIsLower StringIsSpace StringIsUpper StringIsXDigit StringLeft StringLen StringLower StringMid StringRegExp StringRegExpReplace StringReplace StringReverse StringRight StringSplit StringStripCR StringStripWS StringToASCIIArray StringToBinary StringTrimLeft StringTrimRight StringUpper Tan TCPAccept TCPCloseSocket TCPConnect TCPListen TCPNameToIP TCPRecv TCPSend TCPShutdown, UDPShutdown TCPStartup, UDPStartup TimerDiff TimerInit ToolTip TrayCreateItem TrayCreateMenu TrayGetMsg TrayItemDelete TrayItemGetHandle TrayItemGetState TrayItemGetText TrayItemSetOnEvent TrayItemSetState TrayItemSetText TraySetClick TraySetIcon TraySetOnEvent TraySetPauseIcon TraySetState TraySetToolTip TrayTip UBound UDPBind UDPCloseSocket UDPOpen UDPRecv UDPSend VarGetType WinActivate WinActive WinClose WinExists WinFlash WinGetCaretPos WinGetClassList WinGetClientSize WinGetHandle WinGetPos WinGetProcess WinGetState WinGetText WinGetTitle WinKill WinList WinMenuSelectItem WinMinimizeAll WinMinimizeAllUndo WinMove WinSetOnTop WinSetState WinSetTitle WinSetTrans WinWait",
+literal:"True False And Null Not Or"},contains:[c,b,e,f,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"comments include include-once NoTrayIcon OnAutoItStartRegister pragma compile RequireAdmin"},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",keywords:{"meta-keyword":"include"},end:"$",contains:[e,{className:"meta-string",variants:[{begin:"<",end:">"},{begin:/"/,end:/"/,contains:[{begin:/""/,relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]}]},e,
+c]},{className:"symbol",begin:"@[A-z0-9_]+"},{className:"function",beginKeywords:"Func",end:"$",illegal:"\\$|\\[|%",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",contains:[b,e,f]}]}]}});b.registerLanguage("avrasm",function(a){return{case_insensitive:!0,lexemes:"\\.?"+a.IDENT_RE,keywords:{keyword:"adc add adiw and andi asr bclr bld brbc brbs brcc brcs break breq brge brhc brhs brid brie brlo brlt brmi brne brpl brsh brtc brts brvc brvs bset bst call cbi cbr clc clh cli cln clr cls clt clv clz com cp cpc cpi cpse dec eicall eijmp elpm eor fmul fmuls fmulsu icall ijmp in inc jmp ld ldd ldi lds lpm lsl lsr mov movw mul muls mulsu neg nop or ori out pop push rcall ret reti rjmp rol ror sbc sbr sbrc sbrs sec seh sbi sbci sbic sbis sbiw sei sen ser ses set sev sez sleep spm st std sts sub subi swap tst wdr",
+built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 r16 r17 r18 r19 r20 r21 r22 r23 r24 r25 r26 r27 r28 r29 r30 r31 x|0 xh xl y|0 yh yl z|0 zh zl ucsr1c udr1 ucsr1a ucsr1b ubrr1l ubrr1h ucsr0c ubrr0h tccr3c tccr3a tccr3b tcnt3h tcnt3l ocr3ah ocr3al ocr3bh ocr3bl ocr3ch ocr3cl icr3h icr3l etimsk etifr tccr1c ocr1ch ocr1cl twcr twdr twar twsr twbr osccal xmcra xmcrb eicra spmcsr spmcr portg ddrg ping portf ddrf sreg sph spl xdiv rampz eicrb eimsk gimsk gicr eifr gifr timsk tifr mcucr mcucsr tccr0 tcnt0 ocr0 assr tccr1a tccr1b tcnt1h tcnt1l ocr1ah ocr1al ocr1bh ocr1bl icr1h icr1l tccr2 tcnt2 ocr2 ocdr wdtcr sfior eearh eearl eedr eecr porta ddra pina portb ddrb pinb portc ddrc pinc portd ddrd pind spdr spsr spcr udr0 ucsr0a ucsr0b ubrr0l acsr admux adcsr adch adcl porte ddre pine pinf",
+meta:".byte .cseg .db .def .device .dseg .dw .endmacro .equ .eseg .exit .include .list .listmac .macro .nolist .org .set"},contains:[a.C_BLOCK_COMMENT_MODE,a.COMMENT(";","$",{relevance:0}),a.C_NUMBER_MODE,a.BINARY_NUMBER_MODE,{className:"number",begin:"\\b(\\$[a-zA-Z0-9]+|0o[0-7]+)"},a.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",illegal:"[^\\\\][^']"},{className:"symbol",begin:"^[A-Za-z0-9_.$]+:"},{className:"meta",begin:"#",end:"$"},{className:"subst",begin:"@[0-9]+"}]}});b.registerLanguage("awk",
+function(a){return{keywords:{keyword:"BEGIN END if else while do for in break continue delete next nextfile function func exit|10"},contains:[{className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},{className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,relevance:10},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,
+end:/"/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]},a.REGEXP_MODE,a.HASH_COMMENT_MODE,a.NUMBER_MODE]}});b.registerLanguage("axapta",function(a){return{keywords:"false int abstract private char boolean static null if for true while long throw finally protected final return void enum else break new catch byte super case short default double public try this switch continue reverse firstfast firstonly forupdate nofetch sum avg minof maxof count order group by asc desc index hint like dispaly edit client server ttsbegin ttscommit str real date container anytype common div mod",
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,{className:"meta",begin:"#",end:"$"},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:":",contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]}]}});b.registerLanguage("bash",function(a){var c={className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},b={className:"string",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,
+c,{className:"variable",begin:/\$\(/,end:/\)/,contains:[a.BACKSLASH_ESCAPE]}]};return{aliases:["sh","zsh"],lexemes:/\b-?[a-z\._]+\b/,keywords:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",
+_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[{className:"meta",begin:/^#![^\n]+sh\s*$/,relevance:10},{className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0},a.HASH_COMMENT_MODE,b,{className:"string",begin:/'/,end:/'/},c]}});b.registerLanguage("basic",function(a){return{case_insensitive:!0,illegal:"^.",lexemes:"[a-zA-Z][a-zA-Z0-9_$%!#]*",keywords:{keyword:"ABS ASC AND ATN AUTO|0 BEEP BLOAD|10 BSAVE|10 CALL CALLS CDBL CHAIN CHDIR CHR$|10 CINT CIRCLE CLEAR CLOSE CLS COLOR COM COMMON CONT COS CSNG CSRLIN CVD CVI CVS DATA DATE$ DEFDBL DEFINT DEFSNG DEFSTR DEF|0 SEG USR DELETE DIM DRAW EDIT END ENVIRON ENVIRON$ EOF EQV ERASE ERDEV ERDEV$ ERL ERR ERROR EXP FIELD FILES FIX FOR|0 FRE GET GOSUB|10 GOTO HEX$ IF|0 THEN ELSE|0 INKEY$ INP INPUT INPUT# INPUT$ INSTR IMP INT IOCTL IOCTL$ KEY ON OFF LIST KILL LEFT$ LEN LET LINE LLIST LOAD LOC LOCATE LOF LOG LPRINT USING LSET MERGE MID$ MKDIR MKD$ MKI$ MKS$ MOD NAME NEW NEXT NOISE NOT OCT$ ON OR PEN PLAY STRIG OPEN OPTION BASE OUT PAINT PALETTE PCOPY PEEK PMAP POINT POKE POS PRINT PRINT] PSET PRESET PUT RANDOMIZE READ REM RENUM RESET|0 RESTORE RESUME RETURN|0 RIGHT$ RMDIR RND RSET RUN SAVE SCREEN SGN SHELL SIN SOUND SPACE$ SPC SQR STEP STICK STOP STR$ STRING$ SWAP SYSTEM TAB TAN TIME$ TIMER TROFF TRON TO USR VAL VARPTR VARPTR$ VIEW WAIT WHILE WEND WIDTH WINDOW WRITE XOR"},
+contains:[a.QUOTE_STRING_MODE,a.COMMENT("REM","$",{relevance:10}),a.COMMENT("'","$",{relevance:0}),{className:"symbol",begin:"^[0-9]+ ",relevance:10},{className:"number",begin:"\\b([0-9]+[0-9edED.]*[#!]?)",relevance:0},{className:"number",begin:"(&[hH][0-9a-fA-F]{1,4})"},{className:"number",begin:"(&[oO][0-7]{1,6})"}]}});b.registerLanguage("bnf",function(a){return{contains:[{className:"attribute",begin:/</,end:/>/},{begin:/::=/,starts:{end:/$/,contains:[{begin:/</,end:/>/},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,
+a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}}]}});b.registerLanguage("brainfuck",function(a){var c={className:"literal",begin:"[\\+\\-]",relevance:0};return{aliases:["bf"],contains:[a.COMMENT("[^\\[\\]\\.,\\+\\-<> \r\n]","[\\[\\]\\.,\\+\\-<> \r\n]",{returnEnd:!0,relevance:0}),{className:"title",begin:"[\\[\\]]",relevance:0},{className:"string",begin:"[\\.,]",relevance:0},{begin:/\+\+|\-\-/,returnBegin:!0,contains:[c]},c]}});b.registerLanguage("cal",function(a){var c=[a.C_LINE_COMMENT_MODE,a.COMMENT(/\{/,
+/\}/,{relevance:0}),a.COMMENT(/\(\*/,/\*\)/,{relevance:10})],b={className:"string",begin:/'/,end:/'/,contains:[{begin:/''/}]},e={className:"string",begin:/(#\d+)+/};c={className:"function",beginKeywords:"procedure",end:/[:;]/,keywords:"procedure|10",contains:[a.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:"div mod in and or not xor asserterror begin case do downto else end exit for if of repeat then to until while with var",contains:[b,e]}].concat(c)};return{case_insensitive:!0,keywords:{keyword:"div mod in and or not xor asserterror begin case do downto else end exit for if of repeat then to until while with var",
+literal:"false true"},illegal:/\/\*/,contains:[b,e,{className:"number",begin:"\\b\\d+(\\.\\d+)?(DT|D|T)",relevance:0},{className:"string",begin:'"',end:'"'},a.NUMBER_MODE,{className:"class",begin:"OBJECT (Table|Form|Report|Dataport|Codeunit|XMLport|MenuSuite|Page|Query) (\\d+) ([^\\r\\n]+)",returnBegin:!0,contains:[a.TITLE_MODE,c]},c]}});b.registerLanguage("capnproto",function(a){return{aliases:["capnp"],keywords:{keyword:"struct enum interface union group import using const annotation extends in of on as with from fixed",
+built_in:"Void Bool Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 Float32 Float64 Text Data AnyPointer AnyStruct Capability List",literal:"true false"},contains:[a.QUOTE_STRING_MODE,a.NUMBER_MODE,a.HASH_COMMENT_MODE,{className:"meta",begin:/@0x[\w\d]{16};/,illegal:/\n/},{className:"symbol",begin:/@\d+\b/},{className:"class",beginKeywords:"struct enum",end:/\{/,illegal:/\n/,contains:[a.inherit(a.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},{className:"class",beginKeywords:"interface",
+end:/\{/,illegal:/\n/,contains:[a.inherit(a.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]}]}});b.registerLanguage("ceylon",function(a){var c={className:"subst",excludeBegin:!0,excludeEnd:!0,begin:/``/,end:/``/,keywords:"assembly module package import alias class interface object given value assign void function new of extends satisfies abstracts in out return break continue throw assert dynamic if else switch case for while try catch finally then let this outer super is exists nonempty",
+relevance:10},b=[{className:"string",begin:'"""',end:'"""',relevance:10},{className:"string",begin:'"',end:'"',contains:[c]},{className:"string",begin:"'",end:"'"},{className:"number",begin:"#[0-9a-fA-F_]+|\\$[01_]+|[0-9_]+(?:\\.[0-9_](?:[eE][+-]?\\d+)?)?[kMGTPmunpf]?",relevance:0}];c.contains=b;return{keywords:{keyword:"assembly module package import alias class interface object given value assign void function new of extends satisfies abstracts in out return break continue throw assert dynamic if else switch case for while try catch finally then let this outer super is exists nonempty shared abstract formal default actual variable late native deprecatedfinal sealed annotation suppressWarnings small",
+meta:"doc by license see throws tagged"},illegal:"\\$[^01]|#[^0-9a-fA-F]",contains:[a.C_LINE_COMMENT_MODE,a.COMMENT("/\\*","\\*/",{contains:["self"]}),{className:"meta",begin:'@[a-z]\\w*(?:\\:"[^"]*")?'}].concat(b)}});b.registerLanguage("clean",function(a){return{aliases:["clean","icl","dcl"],keywords:{keyword:"if let in with where case of class instance otherwise implementation definition system module from import qualified as special code inline foreign export ccall stdcall generic derive infix infixl infixr",
+built_in:"Int Real Char Bool",literal:"True False"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,{begin:"->|<-[|:]?|#!?|>>=|\\{\\||\\|\\}|:==|=:|<>"}]}});b.registerLanguage("clojure",function(a){var c={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",relevance:0},b=a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),e=a.COMMENT(";","$",{relevance:0}),f={className:"literal",begin:/\b(true|false|nil)\b/},g={begin:"[\\[\\{]",end:"[\\]\\}]"},k=
+{className:"comment",begin:"\\^[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},t=a.COMMENT("\\^\\{","\\}"),m={className:"symbol",begin:"[:]{1,2}[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},h={begin:"\\(",end:"\\)"},l={endsWithParent:!0,relevance:0},r={keywords:{"builtin-name":"def defonce cond apply if-not if-let if not not= = < > <= >= == + / * - rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit defmacro defn defn- macroexpand macroexpand-1 for dosync and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy defstruct first rest cons defprotocol cast coll deftype defrecord last butlast sigs reify second ffirst fnext nfirst nnext defmulti defmethod meta with-meta ns in-ns create-ns import refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time re-find re-groups rand-int rand mod locking assert-valid-fdecl alias resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! to-array future future-call into-array aset gen-class reduce map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"},
+lexemes:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",className:"name",begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",starts:l},q=[h,b,k,t,e,m,g,c,f,{begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",relevance:0}];h.contains=[a.COMMENT("comment",""),r,l];l.contains=q;g.contains=q;t.contains=[g];return{aliases:["clj"],illegal:/\S/,contains:[h,b,k,t,e,m,g,c,f]}});b.registerLanguage("clojure-repl",function(a){return{contains:[{className:"meta",begin:/^([\w.-]+|\s*#_)?=>/,
+starts:{end:/$/,subLanguage:"clojure"}}]}});b.registerLanguage("cmake",function(a){return{aliases:["cmake.in"],case_insensitive:!0,keywords:{keyword:"break cmake_host_system_information cmake_minimum_required cmake_parse_arguments cmake_policy configure_file continue elseif else endforeach endfunction endif endmacro endwhile execute_process file find_file find_library find_package find_path find_program foreach function get_cmake_property get_directory_property get_filename_component get_property if include include_guard list macro mark_as_advanced math message option return separate_arguments set_directory_properties set_property set site_name string unset variable_watch while add_compile_definitions add_compile_options add_custom_command add_custom_target add_definitions add_dependencies add_executable add_library add_link_options add_subdirectory add_test aux_source_directory build_command create_test_sourcelist define_property enable_language enable_testing export fltk_wrap_ui get_source_file_property get_target_property get_test_property include_directories include_external_msproject include_regular_expression install link_directories link_libraries load_cache project qt_wrap_cpp qt_wrap_ui remove_definitions set_source_files_properties set_target_properties set_tests_properties source_group target_compile_definitions target_compile_features target_compile_options target_include_directories target_link_directories target_link_libraries target_link_options target_sources try_compile try_run ctest_build ctest_configure ctest_coverage ctest_empty_binary_directory ctest_memcheck ctest_read_custom_files ctest_run_script ctest_sleep ctest_start ctest_submit ctest_test ctest_update ctest_upload build_name exec_program export_library_dependencies install_files install_programs install_targets load_command make_directory output_required_files remove subdir_depends subdirs use_mangled_mesa utility_source variable_requires write_file qt5_use_modules qt5_use_package qt5_wrap_cpp on off true false and or not command policy target test exists is_newer_than is_directory is_symlink is_absolute matches less greater equal less_equal greater_equal strless strgreater strequal strless_equal strgreater_equal version_less version_greater version_equal version_less_equal version_greater_equal in_list defined"},
+contains:[{className:"variable",begin:"\\${",end:"}"},a.HASH_COMMENT_MODE,a.QUOTE_STRING_MODE,a.NUMBER_MODE]}});b.registerLanguage("coffeescript",function(a){var c={keyword:"in if for while finally new do return else break catch instanceof throw try this switch continue typeof delete debugger super yield import export from as default await then unless until loop of by when and or is isnt not",literal:"true false null undefined yes no on off",built_in:"npm require console print module global window document"},
+b={className:"subst",begin:/#\{/,end:/}/,keywords:c},e=[a.BINARY_NUMBER_MODE,a.inherit(a.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",relevance:0}}),{className:"string",variants:[{begin:/'''/,end:/'''/,contains:[a.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[a.BACKSLASH_ESCAPE]},{begin:/"""/,end:/"""/,contains:[a.BACKSLASH_ESCAPE,b]},{begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,b]}]},{className:"regexp",variants:[{begin:"///",end:"///",contains:[b,a.HASH_COMMENT_MODE]},{begin:"//[gim]*",relevance:0},
+{begin:/\/(?![ *])(\\\/|.)*?\/[gim]*(?=\W|$)/}]},{begin:"@[A-Za-z$_][0-9A-Za-z$_]*"},{subLanguage:"javascript",excludeBegin:!0,excludeEnd:!0,variants:[{begin:"```",end:"```"},{begin:"`",end:"`"}]}];b.contains=e;b=a.inherit(a.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"});var f={className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,end:/\)/,keywords:c,contains:["self"].concat(e)}]};return{aliases:["coffee","cson","iced"],keywords:c,illegal:/\/\*/,contains:e.concat([a.COMMENT("###",
+"###"),a.HASH_COMMENT_MODE,{className:"function",begin:"^\\s*[A-Za-z$_][0-9A-Za-z$_]*\\s*=\\s*(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[b,f]},{begin:/[:\(,=]\s*/,relevance:0,contains:[{className:"function",begin:"(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[f]}]},{className:"class",beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[b]},b]},{begin:"[A-Za-z$_][0-9A-Za-z$_]*:",end:":",
+returnBegin:!0,returnEnd:!0,relevance:0}])}});b.registerLanguage("coq",function(a){return{keywords:{keyword:"_ as at cofix else end exists exists2 fix for forall fun if IF in let match mod Prop return Set then Type using where with Abort About Add Admit Admitted All Arguments Assumptions Axiom Back BackTo Backtrack Bind Blacklist Canonical Cd Check Class Classes Close Coercion Coercions CoFixpoint CoInductive Collection Combined Compute Conjecture Conjectures Constant constr Constraint Constructors Context Corollary CreateHintDb Cut Declare Defined Definition Delimit Dependencies DependentDerive Drop eauto End Equality Eval Example Existential Existentials Existing Export exporting Extern Extract Extraction Fact Field Fields File Fixpoint Focus for From Function Functional Generalizable Global Goal Grab Grammar Graph Guarded Heap Hint HintDb Hints Hypotheses Hypothesis ident Identity If Immediate Implicit Import Include Inductive Infix Info Initial Inline Inspect Instance Instances Intro Intros Inversion Inversion_clear Language Left Lemma Let Libraries Library Load LoadPath Local Locate Ltac ML Mode Module Modules Monomorphic Morphism Next NoInline Notation Obligation Obligations Opaque Open Optimize Options Parameter Parameters Parametric Path Paths pattern Polymorphic Preterm Print Printing Program Projections Proof Proposition Pwd Qed Quit Rec Record Recursive Redirect Relation Remark Remove Require Reserved Reset Resolve Restart Rewrite Right Ring Rings Save Scheme Scope Scopes Script Search SearchAbout SearchHead SearchPattern SearchRewrite Section Separate Set Setoid Show Solve Sorted Step Strategies Strategy Structure SubClass Table Tables Tactic Term Test Theorem Time Timeout Transparent Type Typeclasses Types Undelimit Undo Unfocus Unfocused Unfold Universe Universes Unset Unshelve using Variable Variables Variant Verbose Visibility where with",
+built_in:"abstract absurd admit after apply as assert assumption at auto autorewrite autounfold before bottom btauto by case case_eq cbn cbv change classical_left classical_right clear clearbody cofix compare compute congruence constr_eq constructor contradict contradiction cut cutrewrite cycle decide decompose dependent destruct destruction dintuition discriminate discrR do double dtauto eapply eassumption eauto ecase econstructor edestruct ediscriminate eelim eexact eexists einduction einjection eleft elim elimtype enough equality erewrite eright esimplify_eq esplit evar exact exactly_once exfalso exists f_equal fail field field_simplify field_simplify_eq first firstorder fix fold fourier functional generalize generalizing gfail give_up has_evar hnf idtac in induction injection instantiate intro intro_pattern intros intuition inversion inversion_clear is_evar is_var lapply lazy left lia lra move native_compute nia nsatz omega once pattern pose progress proof psatz quote record red refine reflexivity remember rename repeat replace revert revgoals rewrite rewrite_strat right ring ring_simplify rtauto set setoid_reflexivity setoid_replace setoid_rewrite setoid_symmetry setoid_transitivity shelve shelve_unifiable simpl simple simplify_eq solve specialize split split_Rabs split_Rmult stepl stepr subst sum swap symmetry tactic tauto time timeout top transitivity trivial try tryif unfold unify until using vm_compute with"},
+contains:[a.QUOTE_STRING_MODE,a.COMMENT("\\(\\*","\\*\\)"),a.C_NUMBER_MODE,{className:"type",excludeBegin:!0,begin:"\\|\\s*",end:"\\w+"},{begin:/[-=]>/}]}});b.registerLanguage("cos",function(a){return{case_insensitive:!0,aliases:["cos","cls"],keywords:"property parameter class classmethod clientmethod extends as break catch close continue do d|0 else elseif for goto halt hang h|0 if job j|0 kill k|0 lock l|0 merge new open quit q|0 read r|0 return set s|0 tcommit throw trollback try tstart use view while write w|0 xecute x|0 zkill znspace zn ztrap zwrite zw zzdump zzwrite print zbreak zinsert zload zprint zremove zsave zzprint mv mvcall mvcrt mvdim mvprint zquit zsync ascii",
+contains:[{className:"number",begin:"\\b(\\d+(\\.\\d*)?|\\.\\d+)",relevance:0},{className:"string",variants:[{begin:'"',end:'"',contains:[{begin:'""',relevance:0}]}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"comment",begin:/;/,end:"$",relevance:0},{className:"built_in",begin:/(?:\$\$?|\.\.)\^?[a-zA-Z]+/},{className:"built_in",begin:/\$\$\$[a-zA-Z]+/},{className:"built_in",begin:/%[a-z]+(?:\.[a-z]+)*/},{className:"symbol",begin:/\^%?[a-zA-Z][\w]*/},{className:"keyword",begin:/##class|##super|#define|#dim/},
+{begin:/&sql\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,subLanguage:"sql"},{begin:/&(js|jscript|javascript)</,end:/>/,excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"},{begin:/&html<\s*</,end:/>\s*>/,subLanguage:"xml"}]}});b.registerLanguage("crmsh",function(a){return{aliases:["crm","pcmk"],case_insensitive:!0,keywords:{keyword:"params meta operations op rule attributes utilization read write deny defined not_defined in_range date spec in ref reference attribute type xpath version and or lt gt tag lte gte eq ne \\ number string",
+literal:"Master Started Slave Stopped start promote demote stop monitor true false"},contains:[a.HASH_COMMENT_MODE,{beginKeywords:"node",starts:{end:"\\s*([\\w_-]+:)?",starts:{className:"title",end:"\\s*[\\$\\w_][\\w_-]*"}}},{beginKeywords:"primitive rsc_template",starts:{className:"title",end:"\\s*[\\$\\w_][\\w_-]*",starts:{end:"\\s*@?[\\w_][\\w_\\.:-]*"}}},{begin:"\\b(group|clone|ms|master|location|colocation|order|fencing_topology|rsc_ticket|acl_target|acl_group|user|role|tag|xml)\\s+",keywords:"group clone ms master location colocation order fencing_topology rsc_ticket acl_target acl_group user role tag xml",
+starts:{className:"title",end:"[\\$\\w_][\\w_-]*"}},{beginKeywords:"property rsc_defaults op_defaults",starts:{className:"title",end:"\\s*([\\w_-]+:)?"}},a.QUOTE_STRING_MODE,{className:"meta",begin:"(ocf|systemd|service|lsb):[\\w_:-]+",relevance:0},{className:"number",begin:"\\b\\d+(\\.\\d+)?(ms|s|h|m)?",relevance:0},{className:"literal",begin:"[-]?(infinity|inf)",relevance:0},{className:"attr",begin:/([A-Za-z\$_#][\w_-]+)=/,relevance:0},{className:"tag",begin:"</?",end:"/?>",relevance:0}]}});b.registerLanguage("crystal",
+function(a){function c(a,c){a=[{begin:a,end:c}];return a[0].contains=a}var b={keyword:"abstract alias annotation as as? asm begin break case class def do else elsif end ensure enum extend for fun if include instance_sizeof is_a? lib macro module next nil? of out pointerof private protected rescue responds_to? return require select self sizeof struct super then type typeof union uninitialized unless until verbatim when while with yield __DIR__ __END_LINE__ __FILE__ __LINE__",literal:"false nil true"},
+e={className:"subst",begin:"#{",end:"}",keywords:b},f={className:"template-variable",variants:[{begin:"\\{\\{",end:"\\}\\}"},{begin:"\\{%",end:"%\\}"}],keywords:b},g={className:"string",contains:[a.BACKSLASH_ESCAPE,e],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[Qwi]?\\(",end:"\\)",contains:c("\\(","\\)")},{begin:"%[Qwi]?\\[",end:"\\]",contains:c("\\[","\\]")},{begin:"%[Qwi]?{",end:"}",contains:c("{","}")},{begin:"%[Qwi]?<",end:">",contains:c("<",">")},{begin:"%[Qwi]?\\|",
+end:"\\|"},{begin:/<<-\w+$/,end:/^\s*\w+$/}],relevance:0},k={className:"string",variants:[{begin:"%q\\(",end:"\\)",contains:c("\\(","\\)")},{begin:"%q\\[",end:"\\]",contains:c("\\[","\\]")},{begin:"%q{",end:"}",contains:c("{","}")},{begin:"%q<",end:">",contains:c("<",">")},{begin:"%q\\|",end:"\\|"},{begin:/<<-'\w+'$/,end:/^\s*\w+$/}],relevance:0},t={begin:"(?!%})("+a.RE_STARTERS_RE+"|\\n|\\b(case|if|select|unless|until|when|while)\\b)\\s*",keywords:"case if select unless until when while",contains:[{className:"regexp",
+contains:[a.BACKSLASH_ESCAPE,e],variants:[{begin:"//[a-z]*",relevance:0},{begin:"/(?!\\/)",end:"/[a-z]*"}]}],relevance:0},m={className:"regexp",contains:[a.BACKSLASH_ESCAPE,e],variants:[{begin:"%r\\(",end:"\\)",contains:c("\\(","\\)")},{begin:"%r\\[",end:"\\]",contains:c("\\[","\\]")},{begin:"%r{",end:"}",contains:c("{","}")},{begin:"%r<",end:">",contains:c("<",">")},{begin:"%r\\|",end:"\\|"}],relevance:0},h={className:"meta",begin:"@\\[",end:"\\]",contains:[a.inherit(a.QUOTE_STRING_MODE,{className:"meta-string"})]};
+a=[f,g,k,m,t,h,a.HASH_COMMENT_MODE,{className:"class",beginKeywords:"class module struct",end:"$|;",illegal:/=/,contains:[a.HASH_COMMENT_MODE,a.inherit(a.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<"}]},{className:"class",beginKeywords:"lib enum union",end:"$|;",illegal:/=/,contains:[a.HASH_COMMENT_MODE,a.inherit(a.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"})],relevance:10},{beginKeywords:"annotation",end:"$|;",illegal:/=/,contains:[a.HASH_COMMENT_MODE,a.inherit(a.TITLE_MODE,
+{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"})],relevance:10},{className:"function",beginKeywords:"def",end:/\B\b/,contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~|]|//|//=|&[-+*]=?|&\\*\\*|\\[\\][=?]?",endsParent:!0})]},{className:"function",beginKeywords:"fun macro",end:/\B\b/,contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~|]|//|//=|&[-+*]=?|&\\*\\*|\\[\\][=?]?",endsParent:!0})],
+relevance:5},{className:"symbol",begin:a.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":",contains:[g,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~|]|//|//=|&[-+*]=?|&\\*\\*|\\[\\][=?]?"}],relevance:0},{className:"number",variants:[{begin:"\\b0b([01_]+)(_*[ui](8|16|32|64|128))?"},{begin:"\\b0o([0-7_]+)(_*[ui](8|16|32|64|128))?"},{begin:"\\b0x([A-Fa-f0-9_]+)(_*[ui](8|16|32|64|128))?"},{begin:"\\b([1-9][0-9_]*[0-9]|[0-9])(\\.[0-9][0-9_]*)?([eE]_*[-+]?[0-9_]*)?(_*f(32|64))?(?!_)"},
+{begin:"\\b([1-9][0-9_]*|0)(_*[ui](8|16|32|64|128))?"}],relevance:0}];e.contains=a;f.contains=a.slice(1);return{aliases:["cr"],lexemes:"[a-zA-Z_]\\w*[!?=]?",keywords:b,contains:a}});b.registerLanguage("cs",function(a){var c={keyword:"abstract as base bool break byte case catch char checked const continue decimal default delegate do double enum event explicit extern finally fixed float for foreach goto if implicit in int interface internal is lock long nameof object operator out override params private protected public readonly ref sbyte sealed short sizeof stackalloc static string struct switch this try typeof uint ulong unchecked unsafe ushort using virtual void volatile while add alias ascending async await by descending dynamic equals from get global group into join let on orderby partial remove select set value var where yield",
+literal:"null false true"},b={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},e={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},f=a.inherit(e,{illegal:/\n/}),g={className:"subst",begin:"{",end:"}",keywords:c},k=a.inherit(g,{illegal:/\n/}),h={className:"string",begin:/\$"/,end:'"',illegal:/\n/,contains:[{begin:"{{"},
+{begin:"}}"},a.BACKSLASH_ESCAPE,k]},m={className:"string",begin:/\$@"/,end:'"',contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},g]},n=a.inherit(m,{illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},k]});g.contains=[m,h,e,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,b,a.C_BLOCK_COMMENT_MODE];k.contains=[n,h,f,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,b,a.inherit(a.C_BLOCK_COMMENT_MODE,{illegal:/\n/})];e={variants:[m,h,e,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]};f=a.IDENT_RE+"(<"+a.IDENT_RE+"(\\s*,\\s*"+
+a.IDENT_RE+")*>)?(\\[\\])?";return{aliases:["csharp","c#"],keywords:c,illegal:/::/,contains:[a.COMMENT("///","$",{returnBegin:!0,contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{begin:"\x3c!--|--\x3e"},{begin:"</?",end:">"}]}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"}},e,b,{beginKeywords:"class interface",end:/[{;=]/,illegal:/[^\s:,]/,
+contains:[a.TITLE_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",end:/[{;=]/,illegal:/[^\s:]/,contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z](\\.?\\w)*"}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{className:"meta",begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{className:"meta-string",begin:/"/,end:/"/}]},{beginKeywords:"new return throw await else",relevance:0},{className:"function",begin:"("+f+"\\s+)+"+a.IDENT_RE+"\\s*\\(",returnBegin:!0,
+end:/\s*[{;=]/,excludeEnd:!0,keywords:c,contains:[{begin:a.IDENT_RE+"\\s*\\(",returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:c,relevance:0,contains:[e,b,a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}]}});b.registerLanguage("csp",function(a){return{case_insensitive:!1,lexemes:"[a-zA-Z][a-zA-Z0-9_-]*",keywords:{keyword:"base-uri child-src connect-src default-src font-src form-action frame-ancestors frame-src img-src media-src object-src plugin-types report-uri sandbox script-src style-src"},
+contains:[{className:"string",begin:"'",end:"'"},{className:"attribute",begin:"^Content",end:":",excludeEnd:!0}]}});b.registerLanguage("css",function(a){return{case_insensitive:!0,illegal:/[=\/|'\$]/,contains:[a.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9_\-\+\(\)"'.]+/},{begin:"@(font-face|page)",lexemes:"[a-z-]+",
+keywords:"font-face page"},{begin:"@",end:"[{;]",illegal:/:/,contains:[{className:"keyword",begin:/\w+/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[a.C_BLOCK_COMMENT_MODE,{begin:/[A-Z_\.\-]+\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,
+excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}]},a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]}]}]}});b.registerLanguage("d",function(a){var c=a.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{lexemes:a.UNDERSCORE_IDENT_RE,keywords:{keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",
+built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",literal:"false null true"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,c,{className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},{className:"string",begin:'"',contains:[{begin:"\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",relevance:0}],
+end:'"[cwd]?'},{className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},{className:"number",begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))(i|[fF]i|Li))",
+relevance:0},{className:"number",begin:"\\b((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))(L|u|U|Lu|LU|uL|UL)?",relevance:0},{className:"string",begin:"'(\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};|.)",end:"'",illegal:"."},{className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",begin:"#(line)",end:"$",relevance:5},{className:"keyword",begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}});b.registerLanguage("markdown",
+function(a){return{aliases:["md","mkdown","mkd"],contains:[{className:"section",variants:[{begin:"^#{1,6}",end:"$"},{begin:"^.+?\\n[=-]{2,}$"}]},{begin:"<",end:">",subLanguage:"xml",relevance:0},{className:"bullet",begin:"^\\s*([*+-]|(\\d+\\.))\\s+"},{className:"strong",begin:"[*_]{2}.+?[*_]{2}"},{className:"emphasis",variants:[{begin:"\\*.+?\\*"},{begin:"_.+?_",relevance:0}]},{className:"quote",begin:"^>\\s+",end:"$"},{className:"code",variants:[{begin:"^```w*s*$",end:"^```s*$"},{begin:"`.+?`"},
+{begin:"^( {4}|\t)",end:"$",relevance:0}]},{begin:"^[-\\*]{3,}",end:"$"},{begin:"\\[.+?\\][\\(\\[].*?[\\)\\]]",returnBegin:!0,contains:[{className:"string",begin:"\\[",end:"\\]",excludeBegin:!0,returnEnd:!0,relevance:0},{className:"link",begin:"\\]\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"symbol",begin:"\\]\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0}],relevance:10},{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},
+{className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}});b.registerLanguage("dart",function(a){var c={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"}]},b={className:"subst",variants:[{begin:"\\${",end:"}"}],keywords:"true false null this is new super"};c={className:"string",variants:[{begin:"r'''",end:"'''"},{begin:'r"""',end:'"""'},{begin:"r'",end:"'",illegal:"\\n"},{begin:'r"',end:'"',illegal:"\\n"},{begin:"'''",end:"'''",contains:[a.BACKSLASH_ESCAPE,c,b]},{begin:'"""',end:'"""',contains:[a.BACKSLASH_ESCAPE,
+c,b]},{begin:"'",end:"'",illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,c,b]},{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,c,b]}]};b.contains=[a.C_NUMBER_MODE,c];return{keywords:{keyword:"assert async await break case catch class const continue default do else enum extends false final finally for if in is new null rethrow return super switch sync this throw true try var void while with yield abstract as dynamic export external factory get implements import library operator part set static typedef",
+built_in:"print Comparable DateTime Duration Function Iterable Iterator List Map Match Null Object Pattern RegExp Set Stopwatch String StringBuffer StringSink Symbol Type Uri bool double int num document window querySelector querySelectorAll Element ElementList"},contains:[c,a.COMMENT("/\\*\\*","\\*/",{subLanguage:"markdown"}),a.COMMENT("///","$",{subLanguage:"markdown"}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,contains:[{beginKeywords:"extends implements"},
+a.UNDERSCORE_TITLE_MODE]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{begin:"=>"}]}});b.registerLanguage("delphi",function(a){var c=[a.C_LINE_COMMENT_MODE,a.COMMENT(/\{/,/\}/,{relevance:0}),a.COMMENT(/\(\*/,/\*\)/,{relevance:10})],b={className:"meta",variants:[{begin:/\{\$/,end:/\}/},{begin:/\(\*\$/,end:/\*\)/}]},e={className:"string",begin:/'/,end:/'/,contains:[{begin:/''/}]},f={className:"string",begin:/(#\d+)+/},g={begin:a.IDENT_RE+"\\s*=\\s*class\\s*\\(",returnBegin:!0,contains:[a.TITLE_MODE]},
+k={className:"function",beginKeywords:"function constructor destructor procedure",end:/[:;]/,keywords:"function constructor|10 destructor|10 procedure|10",contains:[a.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:"exports register file shl array record property for mod while set ally label uses raise not stored class safecall var interface or private static exit index inherited to else stdcall override shr asm far resourcestring finalization packed virtual out and protected library do xorwrite goto near function end div overload object unit begin string on inline repeat until destructor write message program with read initialization except default nil if case cdecl in downto threadvar of try pascal const external constructor type public then implementation finally published procedure absolute reintroduce operator as is abstract alias assembler bitpacked break continue cppdecl cvar enumerator experimental platform deprecated unimplemented dynamic export far16 forward generic helper implements interrupt iochecks local name nodefault noreturn nostackframe oldfpccall otherwise saveregisters softfloat specialize strict unaligned varargs ",
+contains:[e,f,b].concat(c)},b].concat(c)};return{aliases:"dpr dfm pas pascal freepascal lazarus lpr lfm".split(" "),case_insensitive:!0,keywords:"exports register file shl array record property for mod while set ally label uses raise not stored class safecall var interface or private static exit index inherited to else stdcall override shr asm far resourcestring finalization packed virtual out and protected library do xorwrite goto near function end div overload object unit begin string on inline repeat until destructor write message program with read initialization except default nil if case cdecl in downto threadvar of try pascal const external constructor type public then implementation finally published procedure absolute reintroduce operator as is abstract alias assembler bitpacked break continue cppdecl cvar enumerator experimental platform deprecated unimplemented dynamic export far16 forward generic helper implements interrupt iochecks local name nodefault noreturn nostackframe oldfpccall otherwise saveregisters softfloat specialize strict unaligned varargs ",
+illegal:/"|\$[G-Zg-z]|\/\*|<\/|\|/,contains:[e,f,a.NUMBER_MODE,g,k,b].concat(c)}});b.registerLanguage("diff",function(a){return{aliases:["patch"],contains:[{className:"meta",relevance:10,variants:[{begin:/^@@ +\-\d+,\d+ +\+\d+,\d+ +@@$/},{begin:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{begin:/^\-\-\- +\d+,\d+ +\-\-\-\-$/}]},{className:"comment",variants:[{begin:/Index: /,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^\-{3}/,end:/$/},{begin:/^\*{3} /,end:/$/},{begin:/^\+{3}/,end:/$/},{begin:/\*{5}/,end:/\*{5}$/}]},
+{className:"addition",begin:"^\\+",end:"$"},{className:"deletion",begin:"^\\-",end:"$"},{className:"addition",begin:"^\\!",end:"$"}]}});b.registerLanguage("django",function(a){var c={begin:/\|[A-Za-z]+:?/,keywords:{name:"truncatewords removetags linebreaksbr yesno get_digit timesince random striptags filesizeformat escape linebreaks length_is ljust rjust cut urlize fix_ampersands title floatformat capfirst pprint divisibleby add make_list unordered_list urlencode timeuntil urlizetrunc wordcount stringformat linenumbers slice date dictsort dictsortreversed default_if_none pluralize lower join center default truncatewords_html upper length phone2numeric wordwrap time addslashes slugify first escapejs force_escape iriencode last safe safeseq truncatechars localize unlocalize localtime utc timezone"},
+contains:[a.QUOTE_STRING_MODE,a.APOS_STRING_MODE]};return{aliases:["jinja"],case_insensitive:!0,subLanguage:"xml",contains:[a.COMMENT(/\{%\s*comment\s*%}/,/\{%\s*endcomment\s*%}/),a.COMMENT(/\{#/,/#}/),{className:"template-tag",begin:/\{%/,end:/%}/,contains:[{className:"name",begin:/\w+/,keywords:{name:"comment endcomment load templatetag ifchanged endifchanged if endif firstof for endfor ifnotequal endifnotequal widthratio extends include spaceless endspaceless regroup ifequal endifequal ssi now with cycle url filter endfilter debug block endblock else autoescape endautoescape csrf_token empty elif endwith static trans blocktrans endblocktrans get_static_prefix get_media_prefix plural get_current_language language get_available_languages get_current_language_bidi get_language_info get_language_info_list localize endlocalize localtime endlocaltime timezone endtimezone get_current_timezone verbatim"},
+starts:{endsWithParent:!0,keywords:"in by as",contains:[c],relevance:0}}]},{className:"template-variable",begin:/\{\{/,end:/}}/,contains:[c]}]}});b.registerLanguage("dns",function(a){return{aliases:["bind","zone"],keywords:{keyword:"IN A AAAA AFSDB APL CAA CDNSKEY CDS CERT CNAME DHCID DLV DNAME DNSKEY DS HIP IPSECKEY KEY KX LOC MX NAPTR NS NSEC NSEC3 NSEC3PARAM PTR RRSIG RP SIG SOA SRV SSHFP TA TKEY TLSA TSIG TXT"},contains:[a.COMMENT(";","$",{relevance:0}),{className:"meta",begin:/^\$(TTL|GENERATE|INCLUDE|ORIGIN)\b/},
+{className:"number",begin:"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))\\b"},
+{className:"number",begin:"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\b"},a.inherit(a.NUMBER_MODE,{begin:/\b\d+[dhwm]?/})]}});b.registerLanguage("dockerfile",function(a){return{aliases:["docker"],case_insensitive:!0,keywords:"from maintainer expose env arg user onbuild stopsignal",contains:[a.HASH_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.NUMBER_MODE,{beginKeywords:"run cmd entrypoint volume add copy workdir label healthcheck shell",starts:{end:/[^\\]$/,
+subLanguage:"bash"}}],illegal:"</"}});b.registerLanguage("dos",function(a){var c=a.COMMENT(/^\s*@?rem\b/,/$/,{relevance:10});return{aliases:["bat","cmd"],case_insensitive:!0,illegal:/\/\*/,keywords:{keyword:"if else goto for in do call exit not exist errorlevel defined equ neq lss leq gtr geq",built_in:"prn nul lpt3 lpt2 lpt1 con com4 com3 com2 com1 aux shift cd dir echo setlocal endlocal set pause copy append assoc at attrib break cacls cd chcp chdir chkdsk chkntfs cls cmd color comp compact convert date dir diskcomp diskcopy doskey erase fs find findstr format ftype graftabl help keyb label md mkdir mode more move path pause print popd pushd promt rd recover rem rename replace restore rmdir shiftsort start subst time title tree type ver verify vol ping net ipconfig taskkill xcopy ren del"},
+contains:[{className:"variable",begin:/%%[^ ]|%[^ ]+?%|![^ ]+?!/},{className:"function",begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)",end:"goto:eof",contains:[a.inherit(a.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),c]},{className:"number",begin:"\\b\\d+",relevance:0},c]}});b.registerLanguage("dsconfig",function(a){return{keywords:"dsconfig",contains:[{className:"keyword",begin:"^dsconfig",end:"\\s",excludeEnd:!0,relevance:10},{className:"built_in",begin:"(list|create|get|set|delete)-(\\w+)",
+end:"\\s",excludeEnd:!0,illegal:"!@#$%^&*()",relevance:10},{className:"built_in",begin:"--(\\w+)",end:"\\s",excludeEnd:!0},{className:"string",begin:/"/,end:/"/},{className:"string",begin:/'/,end:/'/},{className:"string",begin:"[\\w-?]+:\\w+",end:"\\W",relevance:0},{className:"string",begin:"\\w+-?\\w+",end:"\\W",relevance:0},a.HASH_COMMENT_MODE]}});b.registerLanguage("dts",function(a){var c={className:"string",variants:[a.inherit(a.QUOTE_STRING_MODE,{begin:'((u8?|U)|L)?"'}),{begin:'(u8?|U)?R"',end:'"',
+contains:[a.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},b={className:"number",variants:[{begin:"\\b(\\d+(\\.\\d*)?|\\.\\d+)(u|U|l|L|ul|UL|f|F)"},{begin:a.C_NUMBER_RE}],relevance:0},e={className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elif endif define undef ifdef ifndef"},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",end:"$",keywords:{"meta-keyword":"include"},contains:[a.inherit(c,{className:"meta-string"}),{className:"meta-string",begin:"<",end:">",
+illegal:"\\n"}]},c,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},f={className:"variable",begin:"\\&[a-z\\d_]*\\b"},g={className:"meta-keyword",begin:"/[a-z][a-z\\d-]*/"},k={className:"symbol",begin:"^\\s*[a-zA-Z_][a-zA-Z\\d_]*:"},h={className:"params",begin:"<",end:">",contains:[b,f]},m={className:"class",begin:/[a-zA-Z_][a-zA-Z\d_@]*\s{/,end:/[{;=]/,returnBegin:!0,excludeEnd:!0};return{keywords:"",contains:[{className:"class",begin:"/\\s*{",end:"};",relevance:10,contains:[f,g,k,m,h,a.C_LINE_COMMENT_MODE,
+a.C_BLOCK_COMMENT_MODE,b,c]},f,g,k,m,h,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b,c,e,{begin:a.IDENT_RE+"::",keywords:""}]}});b.registerLanguage("dust",function(a){return{aliases:["dst"],case_insensitive:!0,subLanguage:"xml",contains:[{className:"template-tag",begin:/\{[#\/]/,end:/\}/,illegal:/;/,contains:[{className:"name",begin:/[a-zA-Z\.-]+/,starts:{endsWithParent:!0,relevance:0,contains:[a.QUOTE_STRING_MODE]}}]},{className:"template-variable",begin:/\{/,end:/\}/,illegal:/;/,keywords:"if eq ne lt lte gt gte select default math sep"}]}});
+b.registerLanguage("ebnf",function(a){var c=a.COMMENT(/\(\*/,/\*\)/);return{illegal:/\S/,contains:[c,{className:"attribute",begin:/^[ ]*[a-zA-Z][a-zA-Z-]*([\s-]+[a-zA-Z][a-zA-Z]*)*/},{begin:/=/,end:/;/,contains:[c,{className:"meta",begin:/\?.*\?/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}]}});b.registerLanguage("elixir",function(a){var c={className:"subst",begin:"#\\{",end:"}",lexemes:"[a-zA-Z_][a-zA-Z0-9_.]*(\\!|\\?)?",keywords:"and false then defined module in return redo retry end for true self when next until do begin unless nil break not case cond alias while ensure or include use alias fn quote require import with|0"},
+b={className:"string",contains:[a.BACKSLASH_ESCAPE,c],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/}]},e={className:"function",beginKeywords:"def defp defmacro",end:/\B\b/,contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_][a-zA-Z0-9_.]*(\\!|\\?)?",endsParent:!0})]},f=a.inherit(e,{className:"class",beginKeywords:"defimpl defmodule defprotocol defrecord",end:/\bdo\b|$|;/});a=[b,a.HASH_COMMENT_MODE,f,e,{begin:"::"},{className:"symbol",begin:":(?![\\s:])",contains:[b,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}],
+relevance:0},{className:"symbol",begin:"[a-zA-Z_][a-zA-Z0-9_.]*(\\!|\\?)?:(?!:)",relevance:0},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{className:"variable",begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{begin:"->"},{begin:"("+a.RE_STARTERS_RE+")\\s*",contains:[a.HASH_COMMENT_MODE,{className:"regexp",illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,c],variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}],relevance:0}];
+c.contains=a;return{lexemes:"[a-zA-Z_][a-zA-Z0-9_.]*(\\!|\\?)?",keywords:"and false then defined module in return redo retry end for true self when next until do begin unless nil break not case cond alias while ensure or include use alias fn quote require import with|0",contains:a}});b.registerLanguage("elm",function(a){var c={variants:[a.COMMENT("--","$"),a.COMMENT("{-","-}",{contains:["self"]})]},b={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},e={begin:"\\(",end:"\\)",illegal:'"',contains:[{className:"type",
+begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},c]};return{keywords:"let in if then else case of where module import exposing type alias as infix infixl infixr port effect command subscription",contains:[{beginKeywords:"port effect module",end:"exposing",keywords:"port effect module where command subscription exposing",contains:[e,c],illegal:"\\W\\.|;"},{begin:"import",end:"$",keywords:"import as exposing",contains:[e,c],illegal:"\\W\\.|;"},{begin:"type",end:"$",keywords:"type alias",contains:[b,
+e,{begin:"{",end:"}",contains:e.contains},c]},{beginKeywords:"infix infixl infixr",end:"$",contains:[a.C_NUMBER_MODE,c]},{begin:"port",end:"$",keywords:"port",contains:[c]},{className:"string",begin:"'\\\\?.",end:"'",illegal:"."},a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,b,a.inherit(a.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),c,{begin:"->|<-"}],illegal:/;/}});b.registerLanguage("ruby",function(a){var c={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",
+literal:"true false nil"},b={className:"doctag",begin:"@[A-Za-z]+"},e={begin:"#<",end:">"};b=[a.COMMENT("#","$",{contains:[b]}),a.COMMENT("^\\=begin","^\\=end",{contains:[b],relevance:10}),a.COMMENT("^__END__","\\n$")];var f={className:"subst",begin:"#\\{",end:"}",keywords:c},g={className:"string",contains:[a.BACKSLASH_ESCAPE,f],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[qQwWx]?\\(",end:"\\)"},{begin:"%[qQwWx]?\\[",end:"\\]"},{begin:"%[qQwWx]?{",end:"}"},{begin:"%[qQwWx]?<",
+end:">"},{begin:"%[qQwWx]?/",end:"/"},{begin:"%[qQwWx]?%",end:"%"},{begin:"%[qQwWx]?-",end:"-"},{begin:"%[qQwWx]?\\|",end:"\\|"},{begin:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/},{begin:/<<(-?)\w+$/,end:/^\s*\w+$/}]},k={className:"params",begin:"\\(",end:"\\)",endsParent:!0,keywords:c};a=[g,e,{className:"class",beginKeywords:"class module",end:"$|;",illegal:/=/,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<\\s*",contains:[{begin:"("+a.IDENT_RE+
+"::)?"+a.IDENT_RE}]}].concat(b)},{className:"function",beginKeywords:"def",end:"$|;",contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}),k].concat(b)},{begin:a.IDENT_RE+"::"},{className:"symbol",begin:a.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[g,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}],relevance:0},{className:"number",
+begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{className:"params",begin:/\|/,end:/\|/,keywords:c},{begin:"("+a.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[e,{className:"regexp",contains:[a.BACKSLASH_ESCAPE,f],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r{",end:"}[a-z]*"},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(b),relevance:0}].concat(b);
+f.contains=a;k.contains=a;return{aliases:["rb","gemspec","podspec","thor","irb"],keywords:c,illegal:/\/\*/,contains:b.concat([{begin:/^\s*=>/,starts:{end:"$",contains:a}},{className:"meta",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>)",starts:{end:"$",contains:a}}]).concat(a)}});b.registerLanguage("erb",function(a){return{subLanguage:"xml",contains:[a.COMMENT("<%#","%>"),{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0}]}});b.registerLanguage("erlang-repl",
+function(a){return{keywords:{built_in:"spawn spawn_link self",keyword:"after and andalso|10 band begin bnot bor bsl bsr bxor case catch cond div end fun if let not of or orelse|10 query receive rem try when xor"},contains:[{className:"meta",begin:"^[0-9]+> ",relevance:10},a.COMMENT("%","$"),{className:"number",begin:"\\b(\\d+#[a-fA-F0-9]+|\\d+(\\.\\d+)?([eE][-+]?\\d+)?)",relevance:0},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{begin:"\\?(::)?([A-Z]\\w*(::)?)+"},{begin:"->"},{begin:"ok"},{begin:"!"},{begin:"(\\b[a-z'][a-zA-Z0-9_']*:[a-z'][a-zA-Z0-9_']*)|(\\b[a-z'][a-zA-Z0-9_']*)",
+relevance:0},{begin:"[A-Z][a-zA-Z0-9_']*",relevance:0}]}});b.registerLanguage("erlang",function(a){var c={keyword:"after and andalso|10 band begin bnot bor bsl bzr bxor case catch cond div end fun if let not of orelse|10 query receive rem try when xor",literal:"false true"},b=a.COMMENT("%","$"),e={className:"number",begin:"\\b(\\d+#[a-fA-F0-9]+|\\d+(\\.\\d+)?([eE][-+]?\\d+)?)",relevance:0},f={begin:"fun\\s+[a-z'][a-zA-Z0-9_']*/\\d+"},g={begin:"([a-z'][a-zA-Z0-9_']*:[a-z'][a-zA-Z0-9_']*|[a-z'][a-zA-Z0-9_']*)\\(",
+end:"\\)",returnBegin:!0,relevance:0,contains:[{begin:"([a-z'][a-zA-Z0-9_']*:[a-z'][a-zA-Z0-9_']*|[a-z'][a-zA-Z0-9_']*)",relevance:0},{begin:"\\(",end:"\\)",endsWithParent:!0,returnEnd:!0,relevance:0}]},k={begin:"{",end:"}",relevance:0},h={begin:"\\b_([A-Z][A-Za-z0-9_]*)?",relevance:0},m={begin:"[A-Z][a-zA-Z0-9_]*",relevance:0},n={begin:"#"+a.UNDERSCORE_IDENT_RE,relevance:0,returnBegin:!0,contains:[{begin:"#"+a.UNDERSCORE_IDENT_RE,relevance:0},{begin:"{",end:"}",relevance:0}]},l={beginKeywords:"fun receive if try case",
+end:"end",keywords:c};l.contains=[b,f,a.inherit(a.APOS_STRING_MODE,{className:""}),l,g,a.QUOTE_STRING_MODE,e,k,h,m,n];f=[b,f,l,g,a.QUOTE_STRING_MODE,e,k,h,m,n];g.contains[1].contains=f;k.contains=f;n.contains[1].contains=f;g={className:"params",begin:"\\(",end:"\\)",contains:f};return{aliases:["erl"],keywords:c,illegal:"(</|\\*=|\\+=|-=|/\\*|\\*/|\\(\\*|\\*\\))",contains:[{className:"function",begin:"^[a-z'][a-zA-Z0-9_']*\\s*\\(",end:"->",returnBegin:!0,illegal:"\\(|#|//|/\\*|\\\\|:|;",contains:[g,
+a.inherit(a.TITLE_MODE,{begin:"[a-z'][a-zA-Z0-9_']*"})],starts:{end:";|\\.",keywords:c,contains:f}},b,{begin:"^-",end:"\\.",relevance:0,excludeEnd:!0,returnBegin:!0,lexemes:"-"+a.IDENT_RE,keywords:"-module -record -undef -export -ifdef -ifndef -author -copyright -doc -vsn -import -include -include_lib -compile -define -else -endif -file -behaviour -behavior -spec",contains:[g]},e,a.QUOTE_STRING_MODE,n,h,m,k,{begin:/\.$/}]}});b.registerLanguage("excel",function(a){return{aliases:["xlsx","xls"],case_insensitive:!0,
+lexemes:/[a-zA-Z][\w\.]*/,keywords:{built_in:"ABS ACCRINT ACCRINTM ACOS ACOSH ACOT ACOTH AGGREGATE ADDRESS AMORDEGRC AMORLINC AND ARABIC AREAS ASC ASIN ASINH ATAN ATAN2 ATANH AVEDEV AVERAGE AVERAGEA AVERAGEIF AVERAGEIFS BAHTTEXT BASE BESSELI BESSELJ BESSELK BESSELY BETADIST BETA.DIST BETAINV BETA.INV BIN2DEC BIN2HEX BIN2OCT BINOMDIST BINOM.DIST BINOM.DIST.RANGE BINOM.INV BITAND BITLSHIFT BITOR BITRSHIFT BITXOR CALL CEILING CEILING.MATH CEILING.PRECISE CELL CHAR CHIDIST CHIINV CHITEST CHISQ.DIST CHISQ.DIST.RT CHISQ.INV CHISQ.INV.RT CHISQ.TEST CHOOSE CLEAN CODE COLUMN COLUMNS COMBIN COMBINA COMPLEX CONCAT CONCATENATE CONFIDENCE CONFIDENCE.NORM CONFIDENCE.T CONVERT CORREL COS COSH COT COTH COUNT COUNTA COUNTBLANK COUNTIF COUNTIFS COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD COVAR COVARIANCE.P COVARIANCE.S CRITBINOM CSC CSCH CUBEKPIMEMBER CUBEMEMBER CUBEMEMBERPROPERTY CUBERANKEDMEMBER CUBESET CUBESETCOUNT CUBEVALUE CUMIPMT CUMPRINC DATE DATEDIF DATEVALUE DAVERAGE DAY DAYS DAYS360 DB DBCS DCOUNT DCOUNTA DDB DEC2BIN DEC2HEX DEC2OCT DECIMAL DEGREES DELTA DEVSQ DGET DISC DMAX DMIN DOLLAR DOLLARDE DOLLARFR DPRODUCT DSTDEV DSTDEVP DSUM DURATION DVAR DVARP EDATE EFFECT ENCODEURL EOMONTH ERF ERF.PRECISE ERFC ERFC.PRECISE ERROR.TYPE EUROCONVERT EVEN EXACT EXP EXPON.DIST EXPONDIST FACT FACTDOUBLE FALSE|0 F.DIST FDIST F.DIST.RT FILTERXML FIND FINDB F.INV F.INV.RT FINV FISHER FISHERINV FIXED FLOOR FLOOR.MATH FLOOR.PRECISE FORECAST FORECAST.ETS FORECAST.ETS.CONFINT FORECAST.ETS.SEASONALITY FORECAST.ETS.STAT FORECAST.LINEAR FORMULATEXT FREQUENCY F.TEST FTEST FV FVSCHEDULE GAMMA GAMMA.DIST GAMMADIST GAMMA.INV GAMMAINV GAMMALN GAMMALN.PRECISE GAUSS GCD GEOMEAN GESTEP GETPIVOTDATA GROWTH HARMEAN HEX2BIN HEX2DEC HEX2OCT HLOOKUP HOUR HYPERLINK HYPGEOM.DIST HYPGEOMDIST IF|0 IFERROR IFNA IFS IMABS IMAGINARY IMARGUMENT IMCONJUGATE IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMPRODUCT IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB IMSUM IMTAN INDEX INDIRECT INFO INT INTERCEPT INTRATE IPMT IRR ISBLANK ISERR ISERROR ISEVEN ISFORMULA ISLOGICAL ISNA ISNONTEXT ISNUMBER ISODD ISREF ISTEXT ISO.CEILING ISOWEEKNUM ISPMT JIS KURT LARGE LCM LEFT LEFTB LEN LENB LINEST LN LOG LOG10 LOGEST LOGINV LOGNORM.DIST LOGNORMDIST LOGNORM.INV LOOKUP LOWER MATCH MAX MAXA MAXIFS MDETERM MDURATION MEDIAN MID MIDBs MIN MINIFS MINA MINUTE MINVERSE MIRR MMULT MOD MODE MODE.MULT MODE.SNGL MONTH MROUND MULTINOMIAL MUNIT N NA NEGBINOM.DIST NEGBINOMDIST NETWORKDAYS NETWORKDAYS.INTL NOMINAL NORM.DIST NORMDIST NORMINV NORM.INV NORM.S.DIST NORMSDIST NORM.S.INV NORMSINV NOT NOW NPER NPV NUMBERVALUE OCT2BIN OCT2DEC OCT2HEX ODD ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD OFFSET OR PDURATION PEARSON PERCENTILE.EXC PERCENTILE.INC PERCENTILE PERCENTRANK.EXC PERCENTRANK.INC PERCENTRANK PERMUT PERMUTATIONA PHI PHONETIC PI PMT POISSON.DIST POISSON POWER PPMT PRICE PRICEDISC PRICEMAT PROB PRODUCT PROPER PV QUARTILE QUARTILE.EXC QUARTILE.INC QUOTIENT RADIANS RAND RANDBETWEEN RANK.AVG RANK.EQ RANK RATE RECEIVED REGISTER.ID REPLACE REPLACEB REPT RIGHT RIGHTB ROMAN ROUND ROUNDDOWN ROUNDUP ROW ROWS RRI RSQ RTD SEARCH SEARCHB SEC SECH SECOND SERIESSUM SHEET SHEETS SIGN SIN SINH SKEW SKEW.P SLN SLOPE SMALL SQL.REQUEST SQRT SQRTPI STANDARDIZE STDEV STDEV.P STDEV.S STDEVA STDEVP STDEVPA STEYX SUBSTITUTE SUBTOTAL SUM SUMIF SUMIFS SUMPRODUCT SUMSQ SUMX2MY2 SUMX2PY2 SUMXMY2 SWITCH SYD T TAN TANH TBILLEQ TBILLPRICE TBILLYIELD T.DIST T.DIST.2T T.DIST.RT TDIST TEXT TEXTJOIN TIME TIMEVALUE T.INV T.INV.2T TINV TODAY TRANSPOSE TREND TRIM TRIMMEAN TRUE|0 TRUNC T.TEST TTEST TYPE UNICHAR UNICODE UPPER VALUE VAR VAR.P VAR.S VARA VARP VARPA VDB VLOOKUP WEBSERVICE WEEKDAY WEEKNUM WEIBULL WEIBULL.DIST WORKDAY WORKDAY.INTL XIRR XNPV XOR YEAR YEARFRAC YIELD YIELDDISC YIELDMAT Z.TEST ZTEST"},
+contains:[{begin:/^=/,end:/[^=]/,returnEnd:!0,illegal:/=/,relevance:10},{className:"symbol",begin:/\b[A-Z]{1,2}\d+\b/,end:/[^\d]/,excludeEnd:!0,relevance:0},{className:"symbol",begin:/[A-Z]{0,2}\d*:[A-Z]{0,2}\d*/,relevance:0},a.BACKSLASH_ESCAPE,a.QUOTE_STRING_MODE,{className:"number",begin:a.NUMBER_RE+"(%)?",relevance:0},a.COMMENT(/\bN\(/,/\)/,{excludeBegin:!0,excludeEnd:!0,illegal:/\n/})]}});b.registerLanguage("fix",function(a){return{contains:[{begin:/[^\u2401\u0001]+/,end:/[\u2401\u0001]/,excludeEnd:!0,
+returnBegin:!0,returnEnd:!1,contains:[{begin:/([^\u2401\u0001=]+)/,end:/=([^\u2401\u0001=]+)/,returnEnd:!0,returnBegin:!1,className:"attr"},{begin:/=/,end:/([\u2401\u0001])/,excludeEnd:!0,excludeBegin:!0,className:"string"}]}],case_insensitive:!0}});b.registerLanguage("flix",function(a){return{keywords:{literal:"true false",keyword:"case class def else enum if impl import in lat rel index let match namespace switch type yield with"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",
+begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},{className:"string",variants:[{begin:'"',end:'"'}]},{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[{className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/}]},a.C_NUMBER_MODE]}});b.registerLanguage("fortran",function(a){return{case_insensitive:!0,aliases:["f90","f95"],keywords:{literal:".False. .True.",keyword:"kind do while private call intrinsic where elsewhere type endtype endmodule endselect endinterface end enddo endif if forall endforall only contains default return stop then public subroutine|10 function program .and. .or. .not. .le. .eq. .ge. .gt. .lt. goto save else use module select case access blank direct exist file fmt form formatted iostat name named nextrec number opened rec recl sequential status unformatted unit continue format pause cycle exit c_null_char c_alert c_backspace c_form_feed flush wait decimal round iomsg synchronous nopass non_overridable pass protected volatile abstract extends import non_intrinsic value deferred generic final enumerator class associate bind enum c_int c_short c_long c_long_long c_signed_char c_size_t c_int8_t c_int16_t c_int32_t c_int64_t c_int_least8_t c_int_least16_t c_int_least32_t c_int_least64_t c_int_fast8_t c_int_fast16_t c_int_fast32_t c_int_fast64_t c_intmax_t C_intptr_t c_float c_double c_long_double c_float_complex c_double_complex c_long_double_complex c_bool c_char c_null_ptr c_null_funptr c_new_line c_carriage_return c_horizontal_tab c_vertical_tab iso_c_binding c_loc c_funloc c_associated  c_f_pointer c_ptr c_funptr iso_fortran_env character_storage_size error_unit file_storage_size input_unit iostat_end iostat_eor numeric_storage_size output_unit c_f_procpointer ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode newunit contiguous recursive pad position action delim readwrite eor advance nml interface procedure namelist include sequence elemental pure integer real character complex logical dimension allocatable|10 parameter external implicit|10 none double precision assign intent optional pointer target in out common equivalence data",
+built_in:"alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl algama cdabs cdcos cdexp cdlog cdsin cdsqrt cqabs cqcos cqexp cqlog cqsin cqsqrt dcmplx dconjg derf derfc dfloat dgamma dimag dlgama iqint qabs qacos qasin qatan qatan2 qcmplx qconjg qcos qcosh qdim qerf qerfc qexp qgamma qimag qlgama qlog qlog10 qmax1 qmin1 qmod qnint qsign qsin qsinh qsqrt qtan qtanh abs acos aimag aint anint asin atan atan2 char cmplx conjg cos cosh exp ichar index int log log10 max min nint sign sin sinh sqrt tan tanh print write dim lge lgt lle llt mod nullify allocate deallocate adjustl adjustr all allocated any associated bit_size btest ceiling count cshift date_and_time digits dot_product eoshift epsilon exponent floor fraction huge iand ibclr ibits ibset ieor ior ishft ishftc lbound len_trim matmul maxexponent maxloc maxval merge minexponent minloc minval modulo mvbits nearest pack present product radix random_number random_seed range repeat reshape rrspacing scale scan selected_int_kind selected_real_kind set_exponent shape size spacing spread sum system_clock tiny transpose trim ubound unpack verify achar iachar transfer dble entry dprod cpu_time command_argument_count get_command get_command_argument get_environment_variable is_iostat_end ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode is_iostat_eor move_alloc new_line selected_char_kind same_type_as extends_type_ofacosh asinh atanh bessel_j0 bessel_j1 bessel_jn bessel_y0 bessel_y1 bessel_yn erf erfc erfc_scaled gamma log_gamma hypot norm2 atomic_define atomic_ref execute_command_line leadz trailz storage_size merge_bits bge bgt ble blt dshiftl dshiftr findloc iall iany iparity image_index lcobound ucobound maskl maskr num_images parity popcnt poppar shifta shiftl shiftr this_image"},
+illegal:/\/\*/,contains:[a.inherit(a.APOS_STRING_MODE,{className:"string",relevance:0}),a.inherit(a.QUOTE_STRING_MODE,{className:"string",relevance:0}),{className:"function",beginKeywords:"subroutine function program",illegal:"[${=\\n]",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]},a.COMMENT("!","$",{relevance:0}),{className:"number",begin:"(?=\\b|\\+|\\-|\\.)(?=\\.\\d|\\d)(?:\\d+)?(?:\\.?\\d*)(?:[de][+-]?\\d+)?\\b\\.?",relevance:0}]}});b.registerLanguage("fsharp",
+function(a){var c={begin:"<",end:">",contains:[a.inherit(a.TITLE_MODE,{begin:/'[a-zA-Z0-9_]+/})]};return{aliases:["fs"],keywords:"abstract and as assert base begin class default delegate do done downcast downto elif else end exception extern false finally for fun function global if in inherit inline interface internal lazy let match member module mutable namespace new null of open or override private public rec return sig static struct then to true try type upcast use val void when while with yield",
+illegal:/\/\*/,contains:[{className:"keyword",begin:/\b(yield|return|let|do)!/},{className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},{className:"string",begin:'"""',end:'"""'},a.COMMENT("\\(\\*","\\*\\)"),{className:"class",beginKeywords:"type",end:"\\(|=|$",excludeEnd:!0,contains:[a.UNDERSCORE_TITLE_MODE,c]},{className:"meta",begin:"\\[<",end:">\\]",relevance:10},{className:"symbol",begin:"\\B('[A-Za-z])\\b",contains:[a.BACKSLASH_ESCAPE]},a.C_LINE_COMMENT_MODE,a.inherit(a.QUOTE_STRING_MODE,
+{illegal:null}),a.C_NUMBER_MODE]}});b.registerLanguage("gams",function(a){var c={keyword:"abort acronym acronyms alias all and assign binary card diag display else eq file files for free ge gt if integer le loop lt maximizing minimizing model models ne negative no not option options or ord positive prod put putpage puttl repeat sameas semicont semiint smax smin solve sos1 sos2 sum system table then until using while xor yes",literal:"eps inf na","built-in":"abs arccos arcsin arctan arctan2 Beta betaReg binomial ceil centropy cos cosh cvPower div div0 eDist entropy errorf execSeed exp fact floor frac gamma gammaReg log logBeta logGamma log10 log2 mapVal max min mod ncpCM ncpF ncpVUpow ncpVUsin normal pi poly power randBinomial randLinear randTriangle round rPower sigmoid sign signPower sin sinh slexp sllog10 slrec sqexp sqlog10 sqr sqrec sqrt tan tanh trunc uniform uniformInt vcPower bool_and bool_eqv bool_imp bool_not bool_or bool_xor ifThen rel_eq rel_ge rel_gt rel_le rel_lt rel_ne gday gdow ghour gleap gmillisec gminute gmonth gsecond gyear jdate jnow jstart jtime errorLevel execError gamsRelease gamsVersion handleCollect handleDelete handleStatus handleSubmit heapFree heapLimit heapSize jobHandle jobKill jobStatus jobTerminate licenseLevel licenseStatus maxExecError sleep timeClose timeComp timeElapsed timeExec timeStart"},
+b={className:"symbol",variants:[{begin:/=[lgenxc]=/},{begin:/\$/}]},e={className:"comment",variants:[{begin:"'",end:"'"},{begin:'"',end:'"'}],illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},f={begin:"/",end:"/",keywords:c,contains:[e,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_NUMBER_MODE]};e={begin:/[a-z][a-z0-9_]*(\([a-z0-9_, ]*\))?[ \t]+/,excludeBegin:!0,end:"$",endsWithParent:!0,contains:[e,f,{className:"comment",begin:/([ ]*[a-z0-9&#*=?@>\\<:\-,()$\[\]_.{}!+%^]+)+/,
+relevance:0}]};return{aliases:["gms"],case_insensitive:!0,keywords:c,contains:[a.COMMENT(/^\$ontext/,/^\$offtext/),{className:"meta",begin:"^\\$[a-z0-9]+",end:"$",returnBegin:!0,contains:[{className:"meta-keyword",begin:"^\\$[a-z0-9]+"}]},a.COMMENT("^\\*","$"),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{beginKeywords:"set sets parameter parameters variable variables scalar scalars equation equations",end:";",contains:[a.COMMENT("^\\*","$"),a.C_LINE_COMMENT_MODE,
+a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,f,e]},{beginKeywords:"table",end:";",returnBegin:!0,contains:[{beginKeywords:"table",end:"$",contains:[e]},a.COMMENT("^\\*","$"),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_NUMBER_MODE]},{className:"function",begin:/^[a-z][a-z0-9_,\-+' ()$]+\.{2}/,returnBegin:!0,contains:[{className:"title",begin:/^[a-z0-9_]+/},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0},b]},a.C_NUMBER_MODE,
+b]}});b.registerLanguage("gauss",function(a){var c={keyword:"bool break call callexe checkinterrupt clear clearg closeall cls comlog compile continue create debug declare delete disable dlibrary dllcall do dos ed edit else elseif enable end endfor endif endp endo errorlog errorlogat expr external fn for format goto gosub graph if keyword let lib library line load loadarray loadexe loadf loadk loadm loadp loads loadx local locate loopnextindex lprint lpwidth lshow matrix msym ndpclex new open output outwidth plot plotsym pop prcsn print printdos proc push retp return rndcon rndmod rndmult rndseed run save saveall screen scroll setarray show sparse stop string struct system trace trap threadfor threadendfor threadbegin threadjoin threadstat threadend until use while winprint ne ge le gt lt and xor or not eq eqv",
+built_in:"abs acf aconcat aeye amax amean AmericanBinomCall AmericanBinomCall_Greeks AmericanBinomCall_ImpVol AmericanBinomPut AmericanBinomPut_Greeks AmericanBinomPut_ImpVol AmericanBSCall AmericanBSCall_Greeks AmericanBSCall_ImpVol AmericanBSPut AmericanBSPut_Greeks AmericanBSPut_ImpVol amin amult annotationGetDefaults annotationSetBkd annotationSetFont annotationSetLineColor annotationSetLineStyle annotationSetLineThickness annualTradingDays arccos arcsin areshape arrayalloc arrayindex arrayinit arraytomat asciiload asclabel astd astds asum atan atan2 atranspose axmargin balance band bandchol bandcholsol bandltsol bandrv bandsolpd bar base10 begwind besselj bessely beta box boxcox cdfBeta cdfBetaInv cdfBinomial cdfBinomialInv cdfBvn cdfBvn2 cdfBvn2e cdfCauchy cdfCauchyInv cdfChic cdfChii cdfChinc cdfChincInv cdfExp cdfExpInv cdfFc cdfFnc cdfFncInv cdfGam cdfGenPareto cdfHyperGeo cdfLaplace cdfLaplaceInv cdfLogistic cdfLogisticInv cdfmControlCreate cdfMvn cdfMvn2e cdfMvnce cdfMvne cdfMvt2e cdfMvtce cdfMvte cdfN cdfN2 cdfNc cdfNegBinomial cdfNegBinomialInv cdfNi cdfPoisson cdfPoissonInv cdfRayleigh cdfRayleighInv cdfTc cdfTci cdfTnc cdfTvn cdfWeibull cdfWeibullInv cdir ceil ChangeDir chdir chiBarSquare chol choldn cholsol cholup chrs close code cols colsf combinate combinated complex con cond conj cons ConScore contour conv convertsatostr convertstrtosa corrm corrms corrvc corrx corrxs cos cosh counts countwts crossprd crout croutp csrcol csrlin csvReadM csvReadSA cumprodc cumsumc curve cvtos datacreate datacreatecomplex datalist dataload dataloop dataopen datasave date datestr datestring datestrymd dayinyr dayofweek dbAddDatabase dbClose dbCommit dbCreateQuery dbExecQuery dbGetConnectOptions dbGetDatabaseName dbGetDriverName dbGetDrivers dbGetHostName dbGetLastErrorNum dbGetLastErrorText dbGetNumericalPrecPolicy dbGetPassword dbGetPort dbGetTableHeaders dbGetTables dbGetUserName dbHasFeature dbIsDriverAvailable dbIsOpen dbIsOpenError dbOpen dbQueryBindValue dbQueryClear dbQueryCols dbQueryExecPrepared dbQueryFetchAllM dbQueryFetchAllSA dbQueryFetchOneM dbQueryFetchOneSA dbQueryFinish dbQueryGetBoundValue dbQueryGetBoundValues dbQueryGetField dbQueryGetLastErrorNum dbQueryGetLastErrorText dbQueryGetLastInsertID dbQueryGetLastQuery dbQueryGetPosition dbQueryIsActive dbQueryIsForwardOnly dbQueryIsNull dbQueryIsSelect dbQueryIsValid dbQueryPrepare dbQueryRows dbQuerySeek dbQuerySeekFirst dbQuerySeekLast dbQuerySeekNext dbQuerySeekPrevious dbQuerySetForwardOnly dbRemoveDatabase dbRollback dbSetConnectOptions dbSetDatabaseName dbSetHostName dbSetNumericalPrecPolicy dbSetPort dbSetUserName dbTransaction DeleteFile delif delrows denseToSp denseToSpRE denToZero design det detl dfft dffti diag diagrv digamma doswin DOSWinCloseall DOSWinOpen dotfeq dotfeqmt dotfge dotfgemt dotfgt dotfgtmt dotfle dotflemt dotflt dotfltmt dotfne dotfnemt draw drop dsCreate dstat dstatmt dstatmtControlCreate dtdate dtday dttime dttodtv dttostr dttoutc dtvnormal dtvtodt dtvtoutc dummy dummybr dummydn eig eigh eighv eigv elapsedTradingDays endwind envget eof eqSolve eqSolvemt eqSolvemtControlCreate eqSolvemtOutCreate eqSolveset erf erfc erfccplx erfcplx error etdays ethsec etstr EuropeanBinomCall EuropeanBinomCall_Greeks EuropeanBinomCall_ImpVol EuropeanBinomPut EuropeanBinomPut_Greeks EuropeanBinomPut_ImpVol EuropeanBSCall EuropeanBSCall_Greeks EuropeanBSCall_ImpVol EuropeanBSPut EuropeanBSPut_Greeks EuropeanBSPut_ImpVol exctsmpl exec execbg exp extern eye fcheckerr fclearerr feq feqmt fflush fft ffti fftm fftmi fftn fge fgemt fgets fgetsa fgetsat fgetst fgt fgtmt fileinfo filesa fle flemt floor flt fltmt fmod fne fnemt fonts fopen formatcv formatnv fputs fputst fseek fstrerror ftell ftocv ftos ftostrC gamma gammacplx gammaii gausset gdaAppend gdaCreate gdaDStat gdaDStatMat gdaGetIndex gdaGetName gdaGetNames gdaGetOrders gdaGetType gdaGetTypes gdaGetVarInfo gdaIsCplx gdaLoad gdaPack gdaRead gdaReadByIndex gdaReadSome gdaReadSparse gdaReadStruct gdaReportVarInfo gdaSave gdaUpdate gdaUpdateAndPack gdaVars gdaWrite gdaWrite32 gdaWriteSome getarray getdims getf getGAUSShome getmatrix getmatrix4D getname getnamef getNextTradingDay getNextWeekDay getnr getorders getpath getPreviousTradingDay getPreviousWeekDay getRow getscalar3D getscalar4D getTrRow getwind glm gradcplx gradMT gradMTm gradMTT gradMTTm gradp graphprt graphset hasimag header headermt hess hessMT hessMTg hessMTgw hessMTm hessMTmw hessMTT hessMTTg hessMTTgw hessMTTm hessMTw hessp hist histf histp hsec imag indcv indexcat indices indices2 indicesf indicesfn indnv indsav integrate1d integrateControlCreate intgrat2 intgrat3 inthp1 inthp2 inthp3 inthp4 inthpControlCreate intquad1 intquad2 intquad3 intrleav intrleavsa intrsect intsimp inv invpd invswp iscplx iscplxf isden isinfnanmiss ismiss key keyav keyw lag lag1 lagn lapEighb lapEighi lapEighvb lapEighvi lapgEig lapgEigh lapgEighv lapgEigv lapgSchur lapgSvdcst lapgSvds lapgSvdst lapSvdcusv lapSvds lapSvdusv ldlp ldlsol linSolve listwise ln lncdfbvn lncdfbvn2 lncdfmvn lncdfn lncdfn2 lncdfnc lnfact lngammacplx lnpdfmvn lnpdfmvt lnpdfn lnpdft loadd loadstruct loadwind loess loessmt loessmtControlCreate log loglog logx logy lower lowmat lowmat1 ltrisol lu lusol machEpsilon make makevars makewind margin matalloc matinit mattoarray maxbytes maxc maxindc maxv maxvec mbesselei mbesselei0 mbesselei1 mbesseli mbesseli0 mbesseli1 meanc median mergeby mergevar minc minindc minv miss missex missrv moment momentd movingave movingaveExpwgt movingaveWgt nextindex nextn nextnevn nextwind ntos null null1 numCombinations ols olsmt olsmtControlCreate olsqr olsqr2 olsqrmt ones optn optnevn orth outtyp pacf packedToSp packr parse pause pdfCauchy pdfChi pdfExp pdfGenPareto pdfHyperGeo pdfLaplace pdfLogistic pdfn pdfPoisson pdfRayleigh pdfWeibull pi pinv pinvmt plotAddArrow plotAddBar plotAddBox plotAddHist plotAddHistF plotAddHistP plotAddPolar plotAddScatter plotAddShape plotAddTextbox plotAddTS plotAddXY plotArea plotBar plotBox plotClearLayout plotContour plotCustomLayout plotGetDefaults plotHist plotHistF plotHistP plotLayout plotLogLog plotLogX plotLogY plotOpenWindow plotPolar plotSave plotScatter plotSetAxesPen plotSetBar plotSetBarFill plotSetBarStacked plotSetBkdColor plotSetFill plotSetGrid plotSetLegend plotSetLineColor plotSetLineStyle plotSetLineSymbol plotSetLineThickness plotSetNewWindow plotSetTitle plotSetWhichYAxis plotSetXAxisShow plotSetXLabel plotSetXRange plotSetXTicInterval plotSetXTicLabel plotSetYAxisShow plotSetYLabel plotSetYRange plotSetZAxisShow plotSetZLabel plotSurface plotTS plotXY polar polychar polyeval polygamma polyint polymake polymat polymroot polymult polyroot pqgwin previousindex princomp printfm printfmt prodc psi putarray putf putvals pvCreate pvGetIndex pvGetParNames pvGetParVector pvLength pvList pvPack pvPacki pvPackm pvPackmi pvPacks pvPacksi pvPacksm pvPacksmi pvPutParVector pvTest pvUnpack QNewton QNewtonmt QNewtonmtControlCreate QNewtonmtOutCreate QNewtonSet QProg QProgmt QProgmtInCreate qqr qqre qqrep qr qre qrep qrsol qrtsol qtyr qtyre qtyrep quantile quantiled qyr qyre qyrep qz rank rankindx readr real reclassify reclassifyCuts recode recserar recsercp recserrc rerun rescale reshape rets rev rfft rffti rfftip rfftn rfftnp rfftp rndBernoulli rndBeta rndBinomial rndCauchy rndChiSquare rndCon rndCreateState rndExp rndGamma rndGeo rndGumbel rndHyperGeo rndi rndKMbeta rndKMgam rndKMi rndKMn rndKMnb rndKMp rndKMu rndKMvm rndLaplace rndLCbeta rndLCgam rndLCi rndLCn rndLCnb rndLCp rndLCu rndLCvm rndLogNorm rndMTu rndMVn rndMVt rndn rndnb rndNegBinomial rndp rndPoisson rndRayleigh rndStateSkip rndu rndvm rndWeibull rndWishart rotater round rows rowsf rref sampleData satostrC saved saveStruct savewind scale scale3d scalerr scalinfnanmiss scalmiss schtoc schur searchsourcepath seekr select selif seqa seqm setdif setdifsa setvars setvwrmode setwind shell shiftr sin singleindex sinh sleep solpd sortc sortcc sortd sorthc sorthcc sortind sortindc sortmc sortr sortrc spBiconjGradSol spChol spConjGradSol spCreate spDenseSubmat spDiagRvMat spEigv spEye spLDL spline spLU spNumNZE spOnes spreadSheetReadM spreadSheetReadSA spreadSheetWrite spScale spSubmat spToDense spTrTDense spTScalar spZeros sqpSolve sqpSolveMT sqpSolveMTControlCreate sqpSolveMTlagrangeCreate sqpSolveMToutCreate sqpSolveSet sqrt statements stdc stdsc stocv stof strcombine strindx strlen strput strrindx strsect strsplit strsplitPad strtodt strtof strtofcplx strtriml strtrimr strtrunc strtruncl strtruncpad strtruncr submat subscat substute subvec sumc sumr surface svd svd1 svd2 svdcusv svds svdusv sysstate tab tan tanh tempname time timedt timestr timeutc title tkf2eps tkf2ps tocart todaydt toeplitz token topolar trapchk trigamma trimr trunc type typecv typef union unionsa uniqindx uniqindxsa unique uniquesa upmat upmat1 upper utctodt utctodtv utrisol vals varCovMS varCovXS varget vargetl varmall varmares varput varputl vartypef vcm vcms vcx vcxs vec vech vecr vector vget view viewxyz vlist vnamecv volume vput vread vtypecv wait waitc walkindex where window writer xlabel xlsGetSheetCount xlsGetSheetSize xlsGetSheetTypes xlsMakeRange xlsReadM xlsReadSA xlsWrite xlsWriteM xlsWriteSA xpnd xtics xy xyz ylabel ytics zeros zeta zlabel ztics cdfEmpirical dot h5create h5open h5read h5readAttribute h5write h5writeAttribute ldl plotAddErrorBar plotAddSurface plotCDFEmpirical plotSetColormap plotSetContourLabels plotSetLegendFont plotSetTextInterpreter plotSetXTicCount plotSetYTicCount plotSetZLevels powerm strjoin sylvester strtrim",
+literal:"DB_AFTER_LAST_ROW DB_ALL_TABLES DB_BATCH_OPERATIONS DB_BEFORE_FIRST_ROW DB_BLOB DB_EVENT_NOTIFICATIONS DB_FINISH_QUERY DB_HIGH_PRECISION DB_LAST_INSERT_ID DB_LOW_PRECISION_DOUBLE DB_LOW_PRECISION_INT32 DB_LOW_PRECISION_INT64 DB_LOW_PRECISION_NUMBERS DB_MULTIPLE_RESULT_SETS DB_NAMED_PLACEHOLDERS DB_POSITIONAL_PLACEHOLDERS DB_PREPARED_QUERIES DB_QUERY_SIZE DB_SIMPLE_LOCKING DB_SYSTEM_TABLES DB_TABLES DB_TRANSACTIONS DB_UNICODE DB_VIEWS __STDIN __STDOUT __STDERR __FILE_DIR"},b=a.COMMENT("@",
+"@"),e={className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"define definecs|10 undef ifdef ifndef iflight ifdllcall ifmac ifos2win ifunix else endif lineson linesoff srcfile srcline"},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",end:"$",keywords:{"meta-keyword":"include"},contains:[{className:"meta-string",begin:'"',end:'"',illegal:"\\n"}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b]},f={begin:/\bstruct\s+/,end:/\s/,keywords:"struct",contains:[{className:"type",begin:a.UNDERSCORE_IDENT_RE,
+relevance:0}]},g=[{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,endsWithParent:!0,relevance:0,contains:[{className:"literal",begin:/\.\.\./},a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE,b,f]}],k={className:"title",begin:a.UNDERSCORE_IDENT_RE,relevance:0},h=function(c,d,e){c=a.inherit({className:"function",beginKeywords:c,end:d,excludeEnd:!0,contains:[].concat(g)},e||{});c.contains.push(k);c.contains.push(a.C_NUMBER_MODE);c.contains.push(a.C_BLOCK_COMMENT_MODE);c.contains.push(b);
+return c},m={className:"built_in",begin:"\\b("+c.built_in.split(" ").join("|")+")\\b"},n={className:"string",begin:'"',end:'"',contains:[a.BACKSLASH_ESCAPE],relevance:0},l={begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,keywords:c,relevance:0,contains:[{beginKeywords:c.keyword},m,{className:"built_in",begin:a.UNDERSCORE_IDENT_RE,relevance:0}]};m={begin:/\(/,end:/\)/,relevance:0,keywords:{built_in:c.built_in,literal:c.literal},contains:[a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE,b,m,l,n,"self"]};
+l.contains.push(m);return{aliases:["gss"],case_insensitive:!0,keywords:c,illegal:/(\{[%#]|[%#]\}| <- )/,contains:[a.C_NUMBER_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b,n,e,{className:"keyword",begin:/\bexternal (matrix|string|array|sparse matrix|struct|proc|keyword|fn)/},h("proc keyword",";"),h("fn","="),{beginKeywords:"for threadfor",end:/;/,relevance:0,contains:[a.C_BLOCK_COMMENT_MODE,b,m]},{variants:[{begin:a.UNDERSCORE_IDENT_RE+"\\."+a.UNDERSCORE_IDENT_RE},{begin:a.UNDERSCORE_IDENT_RE+
+"\\s*="}],relevance:0},l,f]}});b.registerLanguage("gcode",function(a){a=[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.COMMENT(/\(/,/\)/),a.inherit(a.C_NUMBER_MODE,{begin:"([-+]?([0-9]*\\.?[0-9]+\\.?))|"+a.C_NUMBER_RE}),a.inherit(a.APOS_STRING_MODE,{illegal:null}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),{className:"name",begin:"([G])([0-9]+\\.?[0-9]?)"},{className:"name",begin:"([M])([0-9]+\\.?[0-9]?)"},{className:"attr",begin:"(VC|VS|#)",end:"(\\d+)"},{className:"attr",begin:"(VZOFX|VZOFY|VZOFZ)"},
+{className:"built_in",begin:"(ATAN|ABS|ACOS|ASIN|SIN|COS|EXP|FIX|FUP|ROUND|LN|TAN)(\\[)",end:"([-+]?([0-9]*\\.?[0-9]+\\.?))(\\])"},{className:"symbol",variants:[{begin:"N",end:"\\d+",illegal:"\\W"}]}];return{aliases:["nc"],case_insensitive:!0,lexemes:"[A-Z_][A-Z0-9_.]*",keywords:"IF DO WHILE ENDWHILE CALL ENDIF SUB ENDSUB GOTO REPEAT ENDREPEAT EQ LT GT NE GE LE OR XOR",contains:[{className:"meta",begin:"\\%"},{className:"meta",begin:"([O])([0-9]+)"}].concat(a)}});b.registerLanguage("gherkin",function(a){return{aliases:["feature"],
+keywords:"Feature Background Ability Business Need Scenario Scenarios Scenario Outline Scenario Template Examples Given And Then But When",contains:[{className:"symbol",begin:"\\*",relevance:0},{className:"meta",begin:"@[^@\\s]+"},{begin:"\\|",end:"\\|\\w*$",contains:[{className:"string",begin:"[^|]+"}]},{className:"variable",begin:"<",end:">"},a.HASH_COMMENT_MODE,{className:"string",begin:'"""',end:'"""'},a.QUOTE_STRING_MODE]}});b.registerLanguage("glsl",function(a){return{keywords:{keyword:"break continue discard do else for if return while switch case default attribute binding buffer ccw centroid centroid varying coherent column_major const cw depth_any depth_greater depth_less depth_unchanged early_fragment_tests equal_spacing flat fractional_even_spacing fractional_odd_spacing highp in index inout invariant invocations isolines layout line_strip lines lines_adjacency local_size_x local_size_y local_size_z location lowp max_vertices mediump noperspective offset origin_upper_left out packed patch pixel_center_integer point_mode points precise precision quads r11f_g11f_b10f r16 r16_snorm r16f r16i r16ui r32f r32i r32ui r8 r8_snorm r8i r8ui readonly restrict rg16 rg16_snorm rg16f rg16i rg16ui rg32f rg32i rg32ui rg8 rg8_snorm rg8i rg8ui rgb10_a2 rgb10_a2ui rgba16 rgba16_snorm rgba16f rgba16i rgba16ui rgba32f rgba32i rgba32ui rgba8 rgba8_snorm rgba8i rgba8ui row_major sample shared smooth std140 std430 stream triangle_strip triangles triangles_adjacency uniform varying vertices volatile writeonly",
+type:"atomic_uint bool bvec2 bvec3 bvec4 dmat2 dmat2x2 dmat2x3 dmat2x4 dmat3 dmat3x2 dmat3x3 dmat3x4 dmat4 dmat4x2 dmat4x3 dmat4x4 double dvec2 dvec3 dvec4 float iimage1D iimage1DArray iimage2D iimage2DArray iimage2DMS iimage2DMSArray iimage2DRect iimage3D iimageBufferiimageCube iimageCubeArray image1D image1DArray image2D image2DArray image2DMS image2DMSArray image2DRect image3D imageBuffer imageCube imageCubeArray int isampler1D isampler1DArray isampler2D isampler2DArray isampler2DMS isampler2DMSArray isampler2DRect isampler3D isamplerBuffer isamplerCube isamplerCubeArray ivec2 ivec3 ivec4 mat2 mat2x2 mat2x3 mat2x4 mat3 mat3x2 mat3x3 mat3x4 mat4 mat4x2 mat4x3 mat4x4 sampler1D sampler1DArray sampler1DArrayShadow sampler1DShadow sampler2D sampler2DArray sampler2DArrayShadow sampler2DMS sampler2DMSArray sampler2DRect sampler2DRectShadow sampler2DShadow sampler3D samplerBuffer samplerCube samplerCubeArray samplerCubeArrayShadow samplerCubeShadow image1D uimage1DArray uimage2D uimage2DArray uimage2DMS uimage2DMSArray uimage2DRect uimage3D uimageBuffer uimageCube uimageCubeArray uint usampler1D usampler1DArray usampler2D usampler2DArray usampler2DMS usampler2DMSArray usampler2DRect usampler3D samplerBuffer usamplerCube usamplerCubeArray uvec2 uvec3 uvec4 vec2 vec3 vec4 void",
+built_in:"gl_MaxAtomicCounterBindings gl_MaxAtomicCounterBufferSize gl_MaxClipDistances gl_MaxClipPlanes gl_MaxCombinedAtomicCounterBuffers gl_MaxCombinedAtomicCounters gl_MaxCombinedImageUniforms gl_MaxCombinedImageUnitsAndFragmentOutputs gl_MaxCombinedTextureImageUnits gl_MaxComputeAtomicCounterBuffers gl_MaxComputeAtomicCounters gl_MaxComputeImageUniforms gl_MaxComputeTextureImageUnits gl_MaxComputeUniformComponents gl_MaxComputeWorkGroupCount gl_MaxComputeWorkGroupSize gl_MaxDrawBuffers gl_MaxFragmentAtomicCounterBuffers gl_MaxFragmentAtomicCounters gl_MaxFragmentImageUniforms gl_MaxFragmentInputComponents gl_MaxFragmentInputVectors gl_MaxFragmentUniformComponents gl_MaxFragmentUniformVectors gl_MaxGeometryAtomicCounterBuffers gl_MaxGeometryAtomicCounters gl_MaxGeometryImageUniforms gl_MaxGeometryInputComponents gl_MaxGeometryOutputComponents gl_MaxGeometryOutputVertices gl_MaxGeometryTextureImageUnits gl_MaxGeometryTotalOutputComponents gl_MaxGeometryUniformComponents gl_MaxGeometryVaryingComponents gl_MaxImageSamples gl_MaxImageUnits gl_MaxLights gl_MaxPatchVertices gl_MaxProgramTexelOffset gl_MaxTessControlAtomicCounterBuffers gl_MaxTessControlAtomicCounters gl_MaxTessControlImageUniforms gl_MaxTessControlInputComponents gl_MaxTessControlOutputComponents gl_MaxTessControlTextureImageUnits gl_MaxTessControlTotalOutputComponents gl_MaxTessControlUniformComponents gl_MaxTessEvaluationAtomicCounterBuffers gl_MaxTessEvaluationAtomicCounters gl_MaxTessEvaluationImageUniforms gl_MaxTessEvaluationInputComponents gl_MaxTessEvaluationOutputComponents gl_MaxTessEvaluationTextureImageUnits gl_MaxTessEvaluationUniformComponents gl_MaxTessGenLevel gl_MaxTessPatchComponents gl_MaxTextureCoords gl_MaxTextureImageUnits gl_MaxTextureUnits gl_MaxVaryingComponents gl_MaxVaryingFloats gl_MaxVaryingVectors gl_MaxVertexAtomicCounterBuffers gl_MaxVertexAtomicCounters gl_MaxVertexAttribs gl_MaxVertexImageUniforms gl_MaxVertexOutputComponents gl_MaxVertexOutputVectors gl_MaxVertexTextureImageUnits gl_MaxVertexUniformComponents gl_MaxVertexUniformVectors gl_MaxViewports gl_MinProgramTexelOffset gl_BackColor gl_BackLightModelProduct gl_BackLightProduct gl_BackMaterial gl_BackSecondaryColor gl_ClipDistance gl_ClipPlane gl_ClipVertex gl_Color gl_DepthRange gl_EyePlaneQ gl_EyePlaneR gl_EyePlaneS gl_EyePlaneT gl_Fog gl_FogCoord gl_FogFragCoord gl_FragColor gl_FragCoord gl_FragData gl_FragDepth gl_FrontColor gl_FrontFacing gl_FrontLightModelProduct gl_FrontLightProduct gl_FrontMaterial gl_FrontSecondaryColor gl_GlobalInvocationID gl_InstanceID gl_InvocationID gl_Layer gl_LightModel gl_LightSource gl_LocalInvocationID gl_LocalInvocationIndex gl_ModelViewMatrix gl_ModelViewMatrixInverse gl_ModelViewMatrixInverseTranspose gl_ModelViewMatrixTranspose gl_ModelViewProjectionMatrix gl_ModelViewProjectionMatrixInverse gl_ModelViewProjectionMatrixInverseTranspose gl_ModelViewProjectionMatrixTranspose gl_MultiTexCoord0 gl_MultiTexCoord1 gl_MultiTexCoord2 gl_MultiTexCoord3 gl_MultiTexCoord4 gl_MultiTexCoord5 gl_MultiTexCoord6 gl_MultiTexCoord7 gl_Normal gl_NormalMatrix gl_NormalScale gl_NumSamples gl_NumWorkGroups gl_ObjectPlaneQ gl_ObjectPlaneR gl_ObjectPlaneS gl_ObjectPlaneT gl_PatchVerticesIn gl_Point gl_PointCoord gl_PointSize gl_Position gl_PrimitiveID gl_PrimitiveIDIn gl_ProjectionMatrix gl_ProjectionMatrixInverse gl_ProjectionMatrixInverseTranspose gl_ProjectionMatrixTranspose gl_SampleID gl_SampleMask gl_SampleMaskIn gl_SamplePosition gl_SecondaryColor gl_TessCoord gl_TessLevelInner gl_TessLevelOuter gl_TexCoord gl_TextureEnvColor gl_TextureMatrix gl_TextureMatrixInverse gl_TextureMatrixInverseTranspose gl_TextureMatrixTranspose gl_Vertex gl_VertexID gl_ViewportIndex gl_WorkGroupID gl_WorkGroupSize gl_in gl_out EmitStreamVertex EmitVertex EndPrimitive EndStreamPrimitive abs acos acosh all any asin asinh atan atanh atomicAdd atomicAnd atomicCompSwap atomicCounter atomicCounterDecrement atomicCounterIncrement atomicExchange atomicMax atomicMin atomicOr atomicXor barrier bitCount bitfieldExtract bitfieldInsert bitfieldReverse ceil clamp cos cosh cross dFdx dFdy degrees determinant distance dot equal exp exp2 faceforward findLSB findMSB floatBitsToInt floatBitsToUint floor fma fract frexp ftransform fwidth greaterThan greaterThanEqual groupMemoryBarrier imageAtomicAdd imageAtomicAnd imageAtomicCompSwap imageAtomicExchange imageAtomicMax imageAtomicMin imageAtomicOr imageAtomicXor imageLoad imageSize imageStore imulExtended intBitsToFloat interpolateAtCentroid interpolateAtOffset interpolateAtSample inverse inversesqrt isinf isnan ldexp length lessThan lessThanEqual log log2 matrixCompMult max memoryBarrier memoryBarrierAtomicCounter memoryBarrierBuffer memoryBarrierImage memoryBarrierShared min mix mod modf noise1 noise2 noise3 noise4 normalize not notEqual outerProduct packDouble2x32 packHalf2x16 packSnorm2x16 packSnorm4x8 packUnorm2x16 packUnorm4x8 pow radians reflect refract round roundEven shadow1D shadow1DLod shadow1DProj shadow1DProjLod shadow2D shadow2DLod shadow2DProj shadow2DProjLod sign sin sinh smoothstep sqrt step tan tanh texelFetch texelFetchOffset texture texture1D texture1DLod texture1DProj texture1DProjLod texture2D texture2DLod texture2DProj texture2DProjLod texture3D texture3DLod texture3DProj texture3DProjLod textureCube textureCubeLod textureGather textureGatherOffset textureGatherOffsets textureGrad textureGradOffset textureLod textureLodOffset textureOffset textureProj textureProjGrad textureProjGradOffset textureProjLod textureProjLodOffset textureProjOffset textureQueryLevels textureQueryLod textureSize transpose trunc uaddCarry uintBitsToFloat umulExtended unpackDouble2x32 unpackHalf2x16 unpackSnorm2x16 unpackSnorm4x8 unpackUnorm2x16 unpackUnorm4x8 usubBorrow",
+literal:"true false"},illegal:'"',contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.C_NUMBER_MODE,{className:"meta",begin:"#",end:"$"}]}});b.registerLanguage("gml",function(a){return{aliases:["gml","GML"],case_insensitive:!1,keywords:{keywords:"begin end if then else while do for break continue with until repeat exit and or xor not return mod div switch case default var globalvar enum #macro #region #endregion",built_in:"is_real is_string is_array is_undefined is_int32 is_int64 is_ptr is_vec3 is_vec4 is_matrix is_bool typeof variable_global_exists variable_global_get variable_global_set variable_instance_exists variable_instance_get variable_instance_set variable_instance_get_names array_length_1d array_length_2d array_height_2d array_equals array_create array_copy random random_range irandom irandom_range random_set_seed random_get_seed randomize randomise choose abs round floor ceil sign frac sqrt sqr exp ln log2 log10 sin cos tan arcsin arccos arctan arctan2 dsin dcos dtan darcsin darccos darctan darctan2 degtorad radtodeg power logn min max mean median clamp lerp dot_product dot_product_3d dot_product_normalised dot_product_3d_normalised dot_product_normalized dot_product_3d_normalized math_set_epsilon math_get_epsilon angle_difference point_distance_3d point_distance point_direction lengthdir_x lengthdir_y real string int64 ptr string_format chr ansi_char ord string_length string_byte_length string_pos string_copy string_char_at string_ord_at string_byte_at string_set_byte_at string_delete string_insert string_lower string_upper string_repeat string_letters string_digits string_lettersdigits string_replace string_replace_all string_count string_hash_to_newline clipboard_has_text clipboard_set_text clipboard_get_text date_current_datetime date_create_datetime date_valid_datetime date_inc_year date_inc_month date_inc_week date_inc_day date_inc_hour date_inc_minute date_inc_second date_get_year date_get_month date_get_week date_get_day date_get_hour date_get_minute date_get_second date_get_weekday date_get_day_of_year date_get_hour_of_year date_get_minute_of_year date_get_second_of_year date_year_span date_month_span date_week_span date_day_span date_hour_span date_minute_span date_second_span date_compare_datetime date_compare_date date_compare_time date_date_of date_time_of date_datetime_string date_date_string date_time_string date_days_in_month date_days_in_year date_leap_year date_is_today date_set_timezone date_get_timezone game_set_speed game_get_speed motion_set motion_add place_free place_empty place_meeting place_snapped move_random move_snap move_towards_point move_contact_solid move_contact_all move_outside_solid move_outside_all move_bounce_solid move_bounce_all move_wrap distance_to_point distance_to_object position_empty position_meeting path_start path_end mp_linear_step mp_potential_step mp_linear_step_object mp_potential_step_object mp_potential_settings mp_linear_path mp_potential_path mp_linear_path_object mp_potential_path_object mp_grid_create mp_grid_destroy mp_grid_clear_all mp_grid_clear_cell mp_grid_clear_rectangle mp_grid_add_cell mp_grid_get_cell mp_grid_add_rectangle mp_grid_add_instances mp_grid_path mp_grid_draw mp_grid_to_ds_grid collision_point collision_rectangle collision_circle collision_ellipse collision_line collision_point_list collision_rectangle_list collision_circle_list collision_ellipse_list collision_line_list instance_position_list instance_place_list point_in_rectangle point_in_triangle point_in_circle rectangle_in_rectangle rectangle_in_triangle rectangle_in_circle instance_find instance_exists instance_number instance_position instance_nearest instance_furthest instance_place instance_create_depth instance_create_layer instance_copy instance_change instance_destroy position_destroy position_change instance_id_get instance_deactivate_all instance_deactivate_object instance_deactivate_region instance_activate_all instance_activate_object instance_activate_region room_goto room_goto_previous room_goto_next room_previous room_next room_restart game_end game_restart game_load game_save game_save_buffer game_load_buffer event_perform event_user event_perform_object event_inherited show_debug_message show_debug_overlay debug_event debug_get_callstack alarm_get alarm_set font_texture_page_size keyboard_set_map keyboard_get_map keyboard_unset_map keyboard_check keyboard_check_pressed keyboard_check_released keyboard_check_direct keyboard_get_numlock keyboard_set_numlock keyboard_key_press keyboard_key_release keyboard_clear io_clear mouse_check_button mouse_check_button_pressed mouse_check_button_released mouse_wheel_up mouse_wheel_down mouse_clear draw_self draw_sprite draw_sprite_pos draw_sprite_ext draw_sprite_stretched draw_sprite_stretched_ext draw_sprite_tiled draw_sprite_tiled_ext draw_sprite_part draw_sprite_part_ext draw_sprite_general draw_clear draw_clear_alpha draw_point draw_line draw_line_width draw_rectangle draw_roundrect draw_roundrect_ext draw_triangle draw_circle draw_ellipse draw_set_circle_precision draw_arrow draw_button draw_path draw_healthbar draw_getpixel draw_getpixel_ext draw_set_colour draw_set_color draw_set_alpha draw_get_colour draw_get_color draw_get_alpha merge_colour make_colour_rgb make_colour_hsv colour_get_red colour_get_green colour_get_blue colour_get_hue colour_get_saturation colour_get_value merge_color make_color_rgb make_color_hsv color_get_red color_get_green color_get_blue color_get_hue color_get_saturation color_get_value merge_color screen_save screen_save_part draw_set_font draw_set_halign draw_set_valign draw_text draw_text_ext string_width string_height string_width_ext string_height_ext draw_text_transformed draw_text_ext_transformed draw_text_colour draw_text_ext_colour draw_text_transformed_colour draw_text_ext_transformed_colour draw_text_color draw_text_ext_color draw_text_transformed_color draw_text_ext_transformed_color draw_point_colour draw_line_colour draw_line_width_colour draw_rectangle_colour draw_roundrect_colour draw_roundrect_colour_ext draw_triangle_colour draw_circle_colour draw_ellipse_colour draw_point_color draw_line_color draw_line_width_color draw_rectangle_color draw_roundrect_color draw_roundrect_color_ext draw_triangle_color draw_circle_color draw_ellipse_color draw_primitive_begin draw_vertex draw_vertex_colour draw_vertex_color draw_primitive_end sprite_get_uvs font_get_uvs sprite_get_texture font_get_texture texture_get_width texture_get_height texture_get_uvs draw_primitive_begin_texture draw_vertex_texture draw_vertex_texture_colour draw_vertex_texture_color texture_global_scale surface_create surface_create_ext surface_resize surface_free surface_exists surface_get_width surface_get_height surface_get_texture surface_set_target surface_set_target_ext surface_reset_target surface_depth_disable surface_get_depth_disable draw_surface draw_surface_stretched draw_surface_tiled draw_surface_part draw_surface_ext draw_surface_stretched_ext draw_surface_tiled_ext draw_surface_part_ext draw_surface_general surface_getpixel surface_getpixel_ext surface_save surface_save_part surface_copy surface_copy_part application_surface_draw_enable application_get_position application_surface_enable application_surface_is_enabled display_get_width display_get_height display_get_orientation display_get_gui_width display_get_gui_height display_reset display_mouse_get_x display_mouse_get_y display_mouse_set display_set_ui_visibility window_set_fullscreen window_get_fullscreen window_set_caption window_set_min_width window_set_max_width window_set_min_height window_set_max_height window_get_visible_rects window_get_caption window_set_cursor window_get_cursor window_set_colour window_get_colour window_set_color window_get_color window_set_position window_set_size window_set_rectangle window_center window_get_x window_get_y window_get_width window_get_height window_mouse_get_x window_mouse_get_y window_mouse_set window_view_mouse_get_x window_view_mouse_get_y window_views_mouse_get_x window_views_mouse_get_y audio_listener_position audio_listener_velocity audio_listener_orientation audio_emitter_position audio_emitter_create audio_emitter_free audio_emitter_exists audio_emitter_pitch audio_emitter_velocity audio_emitter_falloff audio_emitter_gain audio_play_sound audio_play_sound_on audio_play_sound_at audio_stop_sound audio_resume_music audio_music_is_playing audio_resume_sound audio_pause_sound audio_pause_music audio_channel_num audio_sound_length audio_get_type audio_falloff_set_model audio_play_music audio_stop_music audio_master_gain audio_music_gain audio_sound_gain audio_sound_pitch audio_stop_all audio_resume_all audio_pause_all audio_is_playing audio_is_paused audio_exists audio_sound_set_track_position audio_sound_get_track_position audio_emitter_get_gain audio_emitter_get_pitch audio_emitter_get_x audio_emitter_get_y audio_emitter_get_z audio_emitter_get_vx audio_emitter_get_vy audio_emitter_get_vz audio_listener_set_position audio_listener_set_velocity audio_listener_set_orientation audio_listener_get_data audio_set_master_gain audio_get_master_gain audio_sound_get_gain audio_sound_get_pitch audio_get_name audio_sound_set_track_position audio_sound_get_track_position audio_create_stream audio_destroy_stream audio_create_sync_group audio_destroy_sync_group audio_play_in_sync_group audio_start_sync_group audio_stop_sync_group audio_pause_sync_group audio_resume_sync_group audio_sync_group_get_track_pos audio_sync_group_debug audio_sync_group_is_playing audio_debug audio_group_load audio_group_unload audio_group_is_loaded audio_group_load_progress audio_group_name audio_group_stop_all audio_group_set_gain audio_create_buffer_sound audio_free_buffer_sound audio_create_play_queue audio_free_play_queue audio_queue_sound audio_get_recorder_count audio_get_recorder_info audio_start_recording audio_stop_recording audio_sound_get_listener_mask audio_emitter_get_listener_mask audio_get_listener_mask audio_sound_set_listener_mask audio_emitter_set_listener_mask audio_set_listener_mask audio_get_listener_count audio_get_listener_info audio_system show_message show_message_async clickable_add clickable_add_ext clickable_change clickable_change_ext clickable_delete clickable_exists clickable_set_style show_question show_question_async get_integer get_string get_integer_async get_string_async get_login_async get_open_filename get_save_filename get_open_filename_ext get_save_filename_ext show_error highscore_clear highscore_add highscore_value highscore_name draw_highscore sprite_exists sprite_get_name sprite_get_number sprite_get_width sprite_get_height sprite_get_xoffset sprite_get_yoffset sprite_get_bbox_left sprite_get_bbox_right sprite_get_bbox_top sprite_get_bbox_bottom sprite_save sprite_save_strip sprite_set_cache_size sprite_set_cache_size_ext sprite_get_tpe sprite_prefetch sprite_prefetch_multi sprite_flush sprite_flush_multi sprite_set_speed sprite_get_speed_type sprite_get_speed font_exists font_get_name font_get_fontname font_get_bold font_get_italic font_get_first font_get_last font_get_size font_set_cache_size path_exists path_get_name path_get_length path_get_time path_get_kind path_get_closed path_get_precision path_get_number path_get_point_x path_get_point_y path_get_point_speed path_get_x path_get_y path_get_speed script_exists script_get_name timeline_add timeline_delete timeline_clear timeline_exists timeline_get_name timeline_moment_clear timeline_moment_add_script timeline_size timeline_max_moment object_exists object_get_name object_get_sprite object_get_solid object_get_visible object_get_persistent object_get_mask object_get_parent object_get_physics object_is_ancestor room_exists room_get_name sprite_set_offset sprite_duplicate sprite_assign sprite_merge sprite_add sprite_replace sprite_create_from_surface sprite_add_from_surface sprite_delete sprite_set_alpha_from_sprite sprite_collision_mask font_add_enable_aa font_add_get_enable_aa font_add font_add_sprite font_add_sprite_ext font_replace font_replace_sprite font_replace_sprite_ext font_delete path_set_kind path_set_closed path_set_precision path_add path_assign path_duplicate path_append path_delete path_add_point path_insert_point path_change_point path_delete_point path_clear_points path_reverse path_mirror path_flip path_rotate path_rescale path_shift script_execute object_set_sprite object_set_solid object_set_visible object_set_persistent object_set_mask room_set_width room_set_height room_set_persistent room_set_background_colour room_set_background_color room_set_view room_set_viewport room_get_viewport room_set_view_enabled room_add room_duplicate room_assign room_instance_add room_instance_clear room_get_camera room_set_camera asset_get_index asset_get_type file_text_open_from_string file_text_open_read file_text_open_write file_text_open_append file_text_close file_text_write_string file_text_write_real file_text_writeln file_text_read_string file_text_read_real file_text_readln file_text_eof file_text_eoln file_exists file_delete file_rename file_copy directory_exists directory_create directory_destroy file_find_first file_find_next file_find_close file_attributes filename_name filename_path filename_dir filename_drive filename_ext filename_change_ext file_bin_open file_bin_rewrite file_bin_close file_bin_position file_bin_size file_bin_seek file_bin_write_byte file_bin_read_byte parameter_count parameter_string environment_get_variable ini_open_from_string ini_open ini_close ini_read_string ini_read_real ini_write_string ini_write_real ini_key_exists ini_section_exists ini_key_delete ini_section_delete ds_set_precision ds_exists ds_stack_create ds_stack_destroy ds_stack_clear ds_stack_copy ds_stack_size ds_stack_empty ds_stack_push ds_stack_pop ds_stack_top ds_stack_write ds_stack_read ds_queue_create ds_queue_destroy ds_queue_clear ds_queue_copy ds_queue_size ds_queue_empty ds_queue_enqueue ds_queue_dequeue ds_queue_head ds_queue_tail ds_queue_write ds_queue_read ds_list_create ds_list_destroy ds_list_clear ds_list_copy ds_list_size ds_list_empty ds_list_add ds_list_insert ds_list_replace ds_list_delete ds_list_find_index ds_list_find_value ds_list_mark_as_list ds_list_mark_as_map ds_list_sort ds_list_shuffle ds_list_write ds_list_read ds_list_set ds_map_create ds_map_destroy ds_map_clear ds_map_copy ds_map_size ds_map_empty ds_map_add ds_map_add_list ds_map_add_map ds_map_replace ds_map_replace_map ds_map_replace_list ds_map_delete ds_map_exists ds_map_find_value ds_map_find_previous ds_map_find_next ds_map_find_first ds_map_find_last ds_map_write ds_map_read ds_map_secure_save ds_map_secure_load ds_map_secure_load_buffer ds_map_secure_save_buffer ds_map_set ds_priority_create ds_priority_destroy ds_priority_clear ds_priority_copy ds_priority_size ds_priority_empty ds_priority_add ds_priority_change_priority ds_priority_find_priority ds_priority_delete_value ds_priority_delete_min ds_priority_find_min ds_priority_delete_max ds_priority_find_max ds_priority_write ds_priority_read ds_grid_create ds_grid_destroy ds_grid_copy ds_grid_resize ds_grid_width ds_grid_height ds_grid_clear ds_grid_set ds_grid_add ds_grid_multiply ds_grid_set_region ds_grid_add_region ds_grid_multiply_region ds_grid_set_disk ds_grid_add_disk ds_grid_multiply_disk ds_grid_set_grid_region ds_grid_add_grid_region ds_grid_multiply_grid_region ds_grid_get ds_grid_get_sum ds_grid_get_max ds_grid_get_min ds_grid_get_mean ds_grid_get_disk_sum ds_grid_get_disk_min ds_grid_get_disk_max ds_grid_get_disk_mean ds_grid_value_exists ds_grid_value_x ds_grid_value_y ds_grid_value_disk_exists ds_grid_value_disk_x ds_grid_value_disk_y ds_grid_shuffle ds_grid_write ds_grid_read ds_grid_sort ds_grid_set ds_grid_get effect_create_below effect_create_above effect_clear part_type_create part_type_destroy part_type_exists part_type_clear part_type_shape part_type_sprite part_type_size part_type_scale part_type_orientation part_type_life part_type_step part_type_death part_type_speed part_type_direction part_type_gravity part_type_colour1 part_type_colour2 part_type_colour3 part_type_colour_mix part_type_colour_rgb part_type_colour_hsv part_type_color1 part_type_color2 part_type_color3 part_type_color_mix part_type_color_rgb part_type_color_hsv part_type_alpha1 part_type_alpha2 part_type_alpha3 part_type_blend part_system_create part_system_create_layer part_system_destroy part_system_exists part_system_clear part_system_draw_order part_system_depth part_system_position part_system_automatic_update part_system_automatic_draw part_system_update part_system_drawit part_system_get_layer part_system_layer part_particles_create part_particles_create_colour part_particles_create_color part_particles_clear part_particles_count part_emitter_create part_emitter_destroy part_emitter_destroy_all part_emitter_exists part_emitter_clear part_emitter_region part_emitter_burst part_emitter_stream external_call external_define external_free window_handle window_device matrix_get matrix_set matrix_build_identity matrix_build matrix_build_lookat matrix_build_projection_ortho matrix_build_projection_perspective matrix_build_projection_perspective_fov matrix_multiply matrix_transform_vertex matrix_stack_push matrix_stack_pop matrix_stack_multiply matrix_stack_set matrix_stack_clear matrix_stack_top matrix_stack_is_empty browser_input_capture os_get_config os_get_info os_get_language os_get_region os_lock_orientation display_get_dpi_x display_get_dpi_y display_set_gui_size display_set_gui_maximise display_set_gui_maximize device_mouse_dbclick_enable display_set_timing_method display_get_timing_method display_set_sleep_margin display_get_sleep_margin virtual_key_add virtual_key_hide virtual_key_delete virtual_key_show draw_enable_drawevent draw_enable_swf_aa draw_set_swf_aa_level draw_get_swf_aa_level draw_texture_flush draw_flush gpu_set_blendenable gpu_set_ztestenable gpu_set_zfunc gpu_set_zwriteenable gpu_set_lightingenable gpu_set_fog gpu_set_cullmode gpu_set_blendmode gpu_set_blendmode_ext gpu_set_blendmode_ext_sepalpha gpu_set_colorwriteenable gpu_set_colourwriteenable gpu_set_alphatestenable gpu_set_alphatestref gpu_set_alphatestfunc gpu_set_texfilter gpu_set_texfilter_ext gpu_set_texrepeat gpu_set_texrepeat_ext gpu_set_tex_filter gpu_set_tex_filter_ext gpu_set_tex_repeat gpu_set_tex_repeat_ext gpu_set_tex_mip_filter gpu_set_tex_mip_filter_ext gpu_set_tex_mip_bias gpu_set_tex_mip_bias_ext gpu_set_tex_min_mip gpu_set_tex_min_mip_ext gpu_set_tex_max_mip gpu_set_tex_max_mip_ext gpu_set_tex_max_aniso gpu_set_tex_max_aniso_ext gpu_set_tex_mip_enable gpu_set_tex_mip_enable_ext gpu_get_blendenable gpu_get_ztestenable gpu_get_zfunc gpu_get_zwriteenable gpu_get_lightingenable gpu_get_fog gpu_get_cullmode gpu_get_blendmode gpu_get_blendmode_ext gpu_get_blendmode_ext_sepalpha gpu_get_blendmode_src gpu_get_blendmode_dest gpu_get_blendmode_srcalpha gpu_get_blendmode_destalpha gpu_get_colorwriteenable gpu_get_colourwriteenable gpu_get_alphatestenable gpu_get_alphatestref gpu_get_alphatestfunc gpu_get_texfilter gpu_get_texfilter_ext gpu_get_texrepeat gpu_get_texrepeat_ext gpu_get_tex_filter gpu_get_tex_filter_ext gpu_get_tex_repeat gpu_get_tex_repeat_ext gpu_get_tex_mip_filter gpu_get_tex_mip_filter_ext gpu_get_tex_mip_bias gpu_get_tex_mip_bias_ext gpu_get_tex_min_mip gpu_get_tex_min_mip_ext gpu_get_tex_max_mip gpu_get_tex_max_mip_ext gpu_get_tex_max_aniso gpu_get_tex_max_aniso_ext gpu_get_tex_mip_enable gpu_get_tex_mip_enable_ext gpu_push_state gpu_pop_state gpu_get_state gpu_set_state draw_light_define_ambient draw_light_define_direction draw_light_define_point draw_light_enable draw_set_lighting draw_light_get_ambient draw_light_get draw_get_lighting shop_leave_rating url_get_domain url_open url_open_ext url_open_full get_timer achievement_login achievement_logout achievement_post achievement_increment achievement_post_score achievement_available achievement_show_achievements achievement_show_leaderboards achievement_load_friends achievement_load_leaderboard achievement_send_challenge achievement_load_progress achievement_reset achievement_login_status achievement_get_pic achievement_show_challenge_notifications achievement_get_challenges achievement_event achievement_show achievement_get_info cloud_file_save cloud_string_save cloud_synchronise ads_enable ads_disable ads_setup ads_engagement_launch ads_engagement_available ads_engagement_active ads_event ads_event_preload ads_set_reward_callback ads_get_display_height ads_get_display_width ads_move ads_interstitial_available ads_interstitial_display device_get_tilt_x device_get_tilt_y device_get_tilt_z device_is_keypad_open device_mouse_check_button device_mouse_check_button_pressed device_mouse_check_button_released device_mouse_x device_mouse_y device_mouse_raw_x device_mouse_raw_y device_mouse_x_to_gui device_mouse_y_to_gui iap_activate iap_status iap_enumerate_products iap_restore_all iap_acquire iap_consume iap_product_details iap_purchase_details facebook_init facebook_login facebook_status facebook_graph_request facebook_dialog facebook_logout facebook_launch_offerwall facebook_post_message facebook_send_invite facebook_user_id facebook_accesstoken facebook_check_permission facebook_request_read_permissions facebook_request_publish_permissions gamepad_is_supported gamepad_get_device_count gamepad_is_connected gamepad_get_description gamepad_get_button_threshold gamepad_set_button_threshold gamepad_get_axis_deadzone gamepad_set_axis_deadzone gamepad_button_count gamepad_button_check gamepad_button_check_pressed gamepad_button_check_released gamepad_button_value gamepad_axis_count gamepad_axis_value gamepad_set_vibration gamepad_set_colour gamepad_set_color os_is_paused window_has_focus code_is_compiled http_get http_get_file http_post_string http_request json_encode json_decode zip_unzip load_csv base64_encode base64_decode md5_string_unicode md5_string_utf8 md5_file os_is_network_connected sha1_string_unicode sha1_string_utf8 sha1_file os_powersave_enable analytics_event analytics_event_ext win8_livetile_tile_notification win8_livetile_tile_clear win8_livetile_badge_notification win8_livetile_badge_clear win8_livetile_queue_enable win8_secondarytile_pin win8_secondarytile_badge_notification win8_secondarytile_delete win8_livetile_notification_begin win8_livetile_notification_secondary_begin win8_livetile_notification_expiry win8_livetile_notification_tag win8_livetile_notification_text_add win8_livetile_notification_image_add win8_livetile_notification_end win8_appbar_enable win8_appbar_add_element win8_appbar_remove_element win8_settingscharm_add_entry win8_settingscharm_add_html_entry win8_settingscharm_add_xaml_entry win8_settingscharm_set_xaml_property win8_settingscharm_get_xaml_property win8_settingscharm_remove_entry win8_share_image win8_share_screenshot win8_share_file win8_share_url win8_share_text win8_search_enable win8_search_disable win8_search_add_suggestions win8_device_touchscreen_available win8_license_initialize_sandbox win8_license_trial_version winphone_license_trial_version winphone_tile_title winphone_tile_count winphone_tile_back_title winphone_tile_back_content winphone_tile_back_content_wide winphone_tile_front_image winphone_tile_front_image_small winphone_tile_front_image_wide winphone_tile_back_image winphone_tile_back_image_wide winphone_tile_background_colour winphone_tile_background_color winphone_tile_icon_image winphone_tile_small_icon_image winphone_tile_wide_content winphone_tile_cycle_images winphone_tile_small_background_image physics_world_create physics_world_gravity physics_world_update_speed physics_world_update_iterations physics_world_draw_debug physics_pause_enable physics_fixture_create physics_fixture_set_kinematic physics_fixture_set_density physics_fixture_set_awake physics_fixture_set_restitution physics_fixture_set_friction physics_fixture_set_collision_group physics_fixture_set_sensor physics_fixture_set_linear_damping physics_fixture_set_angular_damping physics_fixture_set_circle_shape physics_fixture_set_box_shape physics_fixture_set_edge_shape physics_fixture_set_polygon_shape physics_fixture_set_chain_shape physics_fixture_add_point physics_fixture_bind physics_fixture_bind_ext physics_fixture_delete physics_apply_force physics_apply_impulse physics_apply_angular_impulse physics_apply_local_force physics_apply_local_impulse physics_apply_torque physics_mass_properties physics_draw_debug physics_test_overlap physics_remove_fixture physics_set_friction physics_set_density physics_set_restitution physics_get_friction physics_get_density physics_get_restitution physics_joint_distance_create physics_joint_rope_create physics_joint_revolute_create physics_joint_prismatic_create physics_joint_pulley_create physics_joint_wheel_create physics_joint_weld_create physics_joint_friction_create physics_joint_gear_create physics_joint_enable_motor physics_joint_get_value physics_joint_set_value physics_joint_delete physics_particle_create physics_particle_delete physics_particle_delete_region_circle physics_particle_delete_region_box physics_particle_delete_region_poly physics_particle_set_flags physics_particle_set_category_flags physics_particle_draw physics_particle_draw_ext physics_particle_count physics_particle_get_data physics_particle_get_data_particle physics_particle_group_begin physics_particle_group_circle physics_particle_group_box physics_particle_group_polygon physics_particle_group_add_point physics_particle_group_end physics_particle_group_join physics_particle_group_delete physics_particle_group_count physics_particle_group_get_data physics_particle_group_get_mass physics_particle_group_get_inertia physics_particle_group_get_centre_x physics_particle_group_get_centre_y physics_particle_group_get_vel_x physics_particle_group_get_vel_y physics_particle_group_get_ang_vel physics_particle_group_get_x physics_particle_group_get_y physics_particle_group_get_angle physics_particle_set_group_flags physics_particle_get_group_flags physics_particle_get_max_count physics_particle_get_radius physics_particle_get_density physics_particle_get_damping physics_particle_get_gravity_scale physics_particle_set_max_count physics_particle_set_radius physics_particle_set_density physics_particle_set_damping physics_particle_set_gravity_scale network_create_socket network_create_socket_ext network_create_server network_create_server_raw network_connect network_connect_raw network_send_packet network_send_raw network_send_broadcast network_send_udp network_send_udp_raw network_set_timeout network_set_config network_resolve network_destroy buffer_create buffer_write buffer_read buffer_seek buffer_get_surface buffer_set_surface buffer_delete buffer_exists buffer_get_type buffer_get_alignment buffer_poke buffer_peek buffer_save buffer_save_ext buffer_load buffer_load_ext buffer_load_partial buffer_copy buffer_fill buffer_get_size buffer_tell buffer_resize buffer_md5 buffer_sha1 buffer_base64_encode buffer_base64_decode buffer_base64_decode_ext buffer_sizeof buffer_get_address buffer_create_from_vertex_buffer buffer_create_from_vertex_buffer_ext buffer_copy_from_vertex_buffer buffer_async_group_begin buffer_async_group_option buffer_async_group_end buffer_load_async buffer_save_async gml_release_mode gml_pragma steam_activate_overlay steam_is_overlay_enabled steam_is_overlay_activated steam_get_persona_name steam_initialised steam_is_cloud_enabled_for_app steam_is_cloud_enabled_for_account steam_file_persisted steam_get_quota_total steam_get_quota_free steam_file_write steam_file_write_file steam_file_read steam_file_delete steam_file_exists steam_file_size steam_file_share steam_is_screenshot_requested steam_send_screenshot steam_is_user_logged_on steam_get_user_steam_id steam_user_owns_dlc steam_user_installed_dlc steam_set_achievement steam_get_achievement steam_clear_achievement steam_set_stat_int steam_set_stat_float steam_set_stat_avg_rate steam_get_stat_int steam_get_stat_float steam_get_stat_avg_rate steam_reset_all_stats steam_reset_all_stats_achievements steam_stats_ready steam_create_leaderboard steam_upload_score steam_upload_score_ext steam_download_scores_around_user steam_download_scores steam_download_friends_scores steam_upload_score_buffer steam_upload_score_buffer_ext steam_current_game_language steam_available_languages steam_activate_overlay_browser steam_activate_overlay_user steam_activate_overlay_store steam_get_user_persona_name steam_get_app_id steam_get_user_account_id steam_ugc_download steam_ugc_create_item steam_ugc_start_item_update steam_ugc_set_item_title steam_ugc_set_item_description steam_ugc_set_item_visibility steam_ugc_set_item_tags steam_ugc_set_item_content steam_ugc_set_item_preview steam_ugc_submit_item_update steam_ugc_get_item_update_progress steam_ugc_subscribe_item steam_ugc_unsubscribe_item steam_ugc_num_subscribed_items steam_ugc_get_subscribed_items steam_ugc_get_item_install_info steam_ugc_get_item_update_info steam_ugc_request_item_details steam_ugc_create_query_user steam_ugc_create_query_user_ex steam_ugc_create_query_all steam_ugc_create_query_all_ex steam_ugc_query_set_cloud_filename_filter steam_ugc_query_set_match_any_tag steam_ugc_query_set_search_text steam_ugc_query_set_ranked_by_trend_days steam_ugc_query_add_required_tag steam_ugc_query_add_excluded_tag steam_ugc_query_set_return_long_description steam_ugc_query_set_return_total_only steam_ugc_query_set_allow_cached_response steam_ugc_send_query shader_set shader_get_name shader_reset shader_current shader_is_compiled shader_get_sampler_index shader_get_uniform shader_set_uniform_i shader_set_uniform_i_array shader_set_uniform_f shader_set_uniform_f_array shader_set_uniform_matrix shader_set_uniform_matrix_array shader_enable_corner_id texture_set_stage texture_get_texel_width texture_get_texel_height shaders_are_supported vertex_format_begin vertex_format_end vertex_format_delete vertex_format_add_position vertex_format_add_position_3d vertex_format_add_colour vertex_format_add_color vertex_format_add_normal vertex_format_add_texcoord vertex_format_add_textcoord vertex_format_add_custom vertex_create_buffer vertex_create_buffer_ext vertex_delete_buffer vertex_begin vertex_end vertex_position vertex_position_3d vertex_colour vertex_color vertex_argb vertex_texcoord vertex_normal vertex_float1 vertex_float2 vertex_float3 vertex_float4 vertex_ubyte4 vertex_submit vertex_freeze vertex_get_number vertex_get_buffer_size vertex_create_buffer_from_buffer vertex_create_buffer_from_buffer_ext push_local_notification push_get_first_local_notification push_get_next_local_notification push_cancel_local_notification skeleton_animation_set skeleton_animation_get skeleton_animation_mix skeleton_animation_set_ext skeleton_animation_get_ext skeleton_animation_get_duration skeleton_animation_get_frames skeleton_animation_clear skeleton_skin_set skeleton_skin_get skeleton_attachment_set skeleton_attachment_get skeleton_attachment_create skeleton_collision_draw_set skeleton_bone_data_get skeleton_bone_data_set skeleton_bone_state_get skeleton_bone_state_set skeleton_get_minmax skeleton_get_num_bounds skeleton_get_bounds skeleton_animation_get_frame skeleton_animation_set_frame draw_skeleton draw_skeleton_time draw_skeleton_instance draw_skeleton_collision skeleton_animation_list skeleton_skin_list skeleton_slot_data layer_get_id layer_get_id_at_depth layer_get_depth layer_create layer_destroy layer_destroy_instances layer_add_instance layer_has_instance layer_set_visible layer_get_visible layer_exists layer_x layer_y layer_get_x layer_get_y layer_hspeed layer_vspeed layer_get_hspeed layer_get_vspeed layer_script_begin layer_script_end layer_shader layer_get_script_begin layer_get_script_end layer_get_shader layer_set_target_room layer_get_target_room layer_reset_target_room layer_get_all layer_get_all_elements layer_get_name layer_depth layer_get_element_layer layer_get_element_type layer_element_move layer_force_draw_depth layer_is_draw_depth_forced layer_get_forced_depth layer_background_get_id layer_background_exists layer_background_create layer_background_destroy layer_background_visible layer_background_change layer_background_sprite layer_background_htiled layer_background_vtiled layer_background_stretch layer_background_yscale layer_background_xscale layer_background_blend layer_background_alpha layer_background_index layer_background_speed layer_background_get_visible layer_background_get_sprite layer_background_get_htiled layer_background_get_vtiled layer_background_get_stretch layer_background_get_yscale layer_background_get_xscale layer_background_get_blend layer_background_get_alpha layer_background_get_index layer_background_get_speed layer_sprite_get_id layer_sprite_exists layer_sprite_create layer_sprite_destroy layer_sprite_change layer_sprite_index layer_sprite_speed layer_sprite_xscale layer_sprite_yscale layer_sprite_angle layer_sprite_blend layer_sprite_alpha layer_sprite_x layer_sprite_y layer_sprite_get_sprite layer_sprite_get_index layer_sprite_get_speed layer_sprite_get_xscale layer_sprite_get_yscale layer_sprite_get_angle layer_sprite_get_blend layer_sprite_get_alpha layer_sprite_get_x layer_sprite_get_y layer_tilemap_get_id layer_tilemap_exists layer_tilemap_create layer_tilemap_destroy tilemap_tileset tilemap_x tilemap_y tilemap_set tilemap_set_at_pixel tilemap_get_tileset tilemap_get_tile_width tilemap_get_tile_height tilemap_get_width tilemap_get_height tilemap_get_x tilemap_get_y tilemap_get tilemap_get_at_pixel tilemap_get_cell_x_at_pixel tilemap_get_cell_y_at_pixel tilemap_clear draw_tilemap draw_tile tilemap_set_global_mask tilemap_get_global_mask tilemap_set_mask tilemap_get_mask tilemap_get_frame tile_set_empty tile_set_index tile_set_flip tile_set_mirror tile_set_rotate tile_get_empty tile_get_index tile_get_flip tile_get_mirror tile_get_rotate layer_tile_exists layer_tile_create layer_tile_destroy layer_tile_change layer_tile_xscale layer_tile_yscale layer_tile_blend layer_tile_alpha layer_tile_x layer_tile_y layer_tile_region layer_tile_visible layer_tile_get_sprite layer_tile_get_xscale layer_tile_get_yscale layer_tile_get_blend layer_tile_get_alpha layer_tile_get_x layer_tile_get_y layer_tile_get_region layer_tile_get_visible layer_instance_get_instance instance_activate_layer instance_deactivate_layer camera_create camera_create_view camera_destroy camera_apply camera_get_active camera_get_default camera_set_default camera_set_view_mat camera_set_proj_mat camera_set_update_script camera_set_begin_script camera_set_end_script camera_set_view_pos camera_set_view_size camera_set_view_speed camera_set_view_border camera_set_view_angle camera_set_view_target camera_get_view_mat camera_get_proj_mat camera_get_update_script camera_get_begin_script camera_get_end_script camera_get_view_x camera_get_view_y camera_get_view_width camera_get_view_height camera_get_view_speed_x camera_get_view_speed_y camera_get_view_border_x camera_get_view_border_y camera_get_view_angle camera_get_view_target view_get_camera view_get_visible view_get_xport view_get_yport view_get_wport view_get_hport view_get_surface_id view_set_camera view_set_visible view_set_xport view_set_yport view_set_wport view_set_hport view_set_surface_id gesture_drag_time gesture_drag_distance gesture_flick_speed gesture_double_tap_time gesture_double_tap_distance gesture_pinch_distance gesture_pinch_angle_towards gesture_pinch_angle_away gesture_rotate_time gesture_rotate_angle gesture_tap_count gesture_get_drag_time gesture_get_drag_distance gesture_get_flick_speed gesture_get_double_tap_time gesture_get_double_tap_distance gesture_get_pinch_distance gesture_get_pinch_angle_towards gesture_get_pinch_angle_away gesture_get_rotate_time gesture_get_rotate_angle gesture_get_tap_count keyboard_virtual_show keyboard_virtual_hide keyboard_virtual_status keyboard_virtual_height",
+literal:"self other all noone global local undefined pointer_invalid pointer_null path_action_stop path_action_restart path_action_continue path_action_reverse true false pi GM_build_date GM_version GM_runtime_version  timezone_local timezone_utc gamespeed_fps gamespeed_microseconds  ev_create ev_destroy ev_step ev_alarm ev_keyboard ev_mouse ev_collision ev_other ev_draw ev_draw_begin ev_draw_end ev_draw_pre ev_draw_post ev_keypress ev_keyrelease ev_trigger ev_left_button ev_right_button ev_middle_button ev_no_button ev_left_press ev_right_press ev_middle_press ev_left_release ev_right_release ev_middle_release ev_mouse_enter ev_mouse_leave ev_mouse_wheel_up ev_mouse_wheel_down ev_global_left_button ev_global_right_button ev_global_middle_button ev_global_left_press ev_global_right_press ev_global_middle_press ev_global_left_release ev_global_right_release ev_global_middle_release ev_joystick1_left ev_joystick1_right ev_joystick1_up ev_joystick1_down ev_joystick1_button1 ev_joystick1_button2 ev_joystick1_button3 ev_joystick1_button4 ev_joystick1_button5 ev_joystick1_button6 ev_joystick1_button7 ev_joystick1_button8 ev_joystick2_left ev_joystick2_right ev_joystick2_up ev_joystick2_down ev_joystick2_button1 ev_joystick2_button2 ev_joystick2_button3 ev_joystick2_button4 ev_joystick2_button5 ev_joystick2_button6 ev_joystick2_button7 ev_joystick2_button8 ev_outside ev_boundary ev_game_start ev_game_end ev_room_start ev_room_end ev_no_more_lives ev_animation_end ev_end_of_path ev_no_more_health ev_close_button ev_user0 ev_user1 ev_user2 ev_user3 ev_user4 ev_user5 ev_user6 ev_user7 ev_user8 ev_user9 ev_user10 ev_user11 ev_user12 ev_user13 ev_user14 ev_user15 ev_step_normal ev_step_begin ev_step_end ev_gui ev_gui_begin ev_gui_end ev_cleanup ev_gesture ev_gesture_tap ev_gesture_double_tap ev_gesture_drag_start ev_gesture_dragging ev_gesture_drag_end ev_gesture_flick ev_gesture_pinch_start ev_gesture_pinch_in ev_gesture_pinch_out ev_gesture_pinch_end ev_gesture_rotate_start ev_gesture_rotating ev_gesture_rotate_end ev_global_gesture_tap ev_global_gesture_double_tap ev_global_gesture_drag_start ev_global_gesture_dragging ev_global_gesture_drag_end ev_global_gesture_flick ev_global_gesture_pinch_start ev_global_gesture_pinch_in ev_global_gesture_pinch_out ev_global_gesture_pinch_end ev_global_gesture_rotate_start ev_global_gesture_rotating ev_global_gesture_rotate_end vk_nokey vk_anykey vk_enter vk_return vk_shift vk_control vk_alt vk_escape vk_space vk_backspace vk_tab vk_pause vk_printscreen vk_left vk_right vk_up vk_down vk_home vk_end vk_delete vk_insert vk_pageup vk_pagedown vk_f1 vk_f2 vk_f3 vk_f4 vk_f5 vk_f6 vk_f7 vk_f8 vk_f9 vk_f10 vk_f11 vk_f12 vk_numpad0 vk_numpad1 vk_numpad2 vk_numpad3 vk_numpad4 vk_numpad5 vk_numpad6 vk_numpad7 vk_numpad8 vk_numpad9 vk_divide vk_multiply vk_subtract vk_add vk_decimal vk_lshift vk_lcontrol vk_lalt vk_rshift vk_rcontrol vk_ralt  mb_any mb_none mb_left mb_right mb_middle c_aqua c_black c_blue c_dkgray c_fuchsia c_gray c_green c_lime c_ltgray c_maroon c_navy c_olive c_purple c_red c_silver c_teal c_white c_yellow c_orange fa_left fa_center fa_right fa_top fa_middle fa_bottom pr_pointlist pr_linelist pr_linestrip pr_trianglelist pr_trianglestrip pr_trianglefan bm_complex bm_normal bm_add bm_max bm_subtract bm_zero bm_one bm_src_colour bm_inv_src_colour bm_src_color bm_inv_src_color bm_src_alpha bm_inv_src_alpha bm_dest_alpha bm_inv_dest_alpha bm_dest_colour bm_inv_dest_colour bm_dest_color bm_inv_dest_color bm_src_alpha_sat tf_point tf_linear tf_anisotropic mip_off mip_on mip_markedonly audio_falloff_none audio_falloff_inverse_distance audio_falloff_inverse_distance_clamped audio_falloff_linear_distance audio_falloff_linear_distance_clamped audio_falloff_exponent_distance audio_falloff_exponent_distance_clamped audio_old_system audio_new_system audio_mono audio_stereo audio_3d cr_default cr_none cr_arrow cr_cross cr_beam cr_size_nesw cr_size_ns cr_size_nwse cr_size_we cr_uparrow cr_hourglass cr_drag cr_appstart cr_handpoint cr_size_all spritespeed_framespersecond spritespeed_framespergameframe asset_object asset_unknown asset_sprite asset_sound asset_room asset_path asset_script asset_font asset_timeline asset_tiles asset_shader fa_readonly fa_hidden fa_sysfile fa_volumeid fa_directory fa_archive  ds_type_map ds_type_list ds_type_stack ds_type_queue ds_type_grid ds_type_priority ef_explosion ef_ring ef_ellipse ef_firework ef_smoke ef_smokeup ef_star ef_spark ef_flare ef_cloud ef_rain ef_snow pt_shape_pixel pt_shape_disk pt_shape_square pt_shape_line pt_shape_star pt_shape_circle pt_shape_ring pt_shape_sphere pt_shape_flare pt_shape_spark pt_shape_explosion pt_shape_cloud pt_shape_smoke pt_shape_snow ps_distr_linear ps_distr_gaussian ps_distr_invgaussian ps_shape_rectangle ps_shape_ellipse ps_shape_diamond ps_shape_line ty_real ty_string dll_cdecl dll_stdcall matrix_view matrix_projection matrix_world os_win32 os_windows os_macosx os_ios os_android os_symbian os_linux os_unknown os_winphone os_tizen os_win8native os_wiiu os_3ds  os_psvita os_bb10 os_ps4 os_xboxone os_ps3 os_xbox360 os_uwp os_tvos os_switch browser_not_a_browser browser_unknown browser_ie browser_firefox browser_chrome browser_safari browser_safari_mobile browser_opera browser_tizen browser_edge browser_windows_store browser_ie_mobile  device_ios_unknown device_ios_iphone device_ios_iphone_retina device_ios_ipad device_ios_ipad_retina device_ios_iphone5 device_ios_iphone6 device_ios_iphone6plus device_emulator device_tablet display_landscape display_landscape_flipped display_portrait display_portrait_flipped tm_sleep tm_countvsyncs of_challenge_win of_challen ge_lose of_challenge_tie leaderboard_type_number leaderboard_type_time_mins_secs cmpfunc_never cmpfunc_less cmpfunc_equal cmpfunc_lessequal cmpfunc_greater cmpfunc_notequal cmpfunc_greaterequal cmpfunc_always cull_noculling cull_clockwise cull_counterclockwise lighttype_dir lighttype_point iap_ev_storeload iap_ev_product iap_ev_purchase iap_ev_consume iap_ev_restore iap_storeload_ok iap_storeload_failed iap_status_uninitialised iap_status_unavailable iap_status_loading iap_status_available iap_status_processing iap_status_restoring iap_failed iap_unavailable iap_available iap_purchased iap_canceled iap_refunded fb_login_default fb_login_fallback_to_webview fb_login_no_fallback_to_webview fb_login_forcing_webview fb_login_use_system_account fb_login_forcing_safari  phy_joint_anchor_1_x phy_joint_anchor_1_y phy_joint_anchor_2_x phy_joint_anchor_2_y phy_joint_reaction_force_x phy_joint_reaction_force_y phy_joint_reaction_torque phy_joint_motor_speed phy_joint_angle phy_joint_motor_torque phy_joint_max_motor_torque phy_joint_translation phy_joint_speed phy_joint_motor_force phy_joint_max_motor_force phy_joint_length_1 phy_joint_length_2 phy_joint_damping_ratio phy_joint_frequency phy_joint_lower_angle_limit phy_joint_upper_angle_limit phy_joint_angle_limits phy_joint_max_length phy_joint_max_torque phy_joint_max_force phy_debug_render_aabb phy_debug_render_collision_pairs phy_debug_render_coms phy_debug_render_core_shapes phy_debug_render_joints phy_debug_render_obb phy_debug_render_shapes  phy_particle_flag_water phy_particle_flag_zombie phy_particle_flag_wall phy_particle_flag_spring phy_particle_flag_elastic phy_particle_flag_viscous phy_particle_flag_powder phy_particle_flag_tensile phy_particle_flag_colourmixing phy_particle_flag_colormixing phy_particle_group_flag_solid phy_particle_group_flag_rigid phy_particle_data_flag_typeflags phy_particle_data_flag_position phy_particle_data_flag_velocity phy_particle_data_flag_colour phy_particle_data_flag_color phy_particle_data_flag_category  achievement_our_info achievement_friends_info achievement_leaderboard_info achievement_achievement_info achievement_filter_all_players achievement_filter_friends_only achievement_filter_favorites_only achievement_type_achievement_challenge achievement_type_score_challenge achievement_pic_loaded  achievement_show_ui achievement_show_profile achievement_show_leaderboard achievement_show_achievement achievement_show_bank achievement_show_friend_picker achievement_show_purchase_prompt network_socket_tcp network_socket_udp network_socket_bluetooth network_type_connect network_type_disconnect network_type_data network_type_non_blocking_connect network_config_connect_timeout network_config_use_non_blocking_socket network_config_enable_reliable_udp network_config_disable_reliable_udp buffer_fixed buffer_grow buffer_wrap buffer_fast buffer_vbuffer buffer_network buffer_u8 buffer_s8 buffer_u16 buffer_s16 buffer_u32 buffer_s32 buffer_u64 buffer_f16 buffer_f32 buffer_f64 buffer_bool buffer_text buffer_string buffer_surface_copy buffer_seek_start buffer_seek_relative buffer_seek_end buffer_generalerror buffer_outofspace buffer_outofbounds buffer_invalidtype  text_type button_type input_type ANSI_CHARSET DEFAULT_CHARSET EASTEUROPE_CHARSET RUSSIAN_CHARSET SYMBOL_CHARSET SHIFTJIS_CHARSET HANGEUL_CHARSET GB2312_CHARSET CHINESEBIG5_CHARSET JOHAB_CHARSET HEBREW_CHARSET ARABIC_CHARSET GREEK_CHARSET TURKISH_CHARSET VIETNAMESE_CHARSET THAI_CHARSET MAC_CHARSET BALTIC_CHARSET OEM_CHARSET  gp_face1 gp_face2 gp_face3 gp_face4 gp_shoulderl gp_shoulderr gp_shoulderlb gp_shoulderrb gp_select gp_start gp_stickl gp_stickr gp_padu gp_padd gp_padl gp_padr gp_axislh gp_axislv gp_axisrh gp_axisrv ov_friends ov_community ov_players ov_settings ov_gamegroup ov_achievements lb_sort_none lb_sort_ascending lb_sort_descending lb_disp_none lb_disp_numeric lb_disp_time_sec lb_disp_time_ms ugc_result_success ugc_filetype_community ugc_filetype_microtrans ugc_visibility_public ugc_visibility_friends_only ugc_visibility_private ugc_query_RankedByVote ugc_query_RankedByPublicationDate ugc_query_AcceptedForGameRankedByAcceptanceDate ugc_query_RankedByTrend ugc_query_FavoritedByFriendsRankedByPublicationDate ugc_query_CreatedByFriendsRankedByPublicationDate ugc_query_RankedByNumTimesReported ugc_query_CreatedByFollowedUsersRankedByPublicationDate ugc_query_NotYetRated ugc_query_RankedByTotalVotesAsc ugc_query_RankedByVotesUp ugc_query_RankedByTextSearch ugc_sortorder_CreationOrderDesc ugc_sortorder_CreationOrderAsc ugc_sortorder_TitleAsc ugc_sortorder_LastUpdatedDesc ugc_sortorder_SubscriptionDateDesc ugc_sortorder_VoteScoreDesc ugc_sortorder_ForModeration ugc_list_Published ugc_list_VotedOn ugc_list_VotedUp ugc_list_VotedDown ugc_list_WillVoteLater ugc_list_Favorited ugc_list_Subscribed ugc_list_UsedOrPlayed ugc_list_Followed ugc_match_Items ugc_match_Items_Mtx ugc_match_Items_ReadyToUse ugc_match_Collections ugc_match_Artwork ugc_match_Videos ugc_match_Screenshots ugc_match_AllGuides ugc_match_WebGuides ugc_match_IntegratedGuides ugc_match_UsableInGame ugc_match_ControllerBindings  vertex_usage_position vertex_usage_colour vertex_usage_color vertex_usage_normal vertex_usage_texcoord vertex_usage_textcoord vertex_usage_blendweight vertex_usage_blendindices vertex_usage_psize vertex_usage_tangent vertex_usage_binormal vertex_usage_fog vertex_usage_depth vertex_usage_sample vertex_type_float1 vertex_type_float2 vertex_type_float3 vertex_type_float4 vertex_type_colour vertex_type_color vertex_type_ubyte4 layerelementtype_undefined layerelementtype_background layerelementtype_instance layerelementtype_oldtilemap layerelementtype_sprite layerelementtype_tilemap layerelementtype_particlesystem layerelementtype_tile tile_rotate tile_flip tile_mirror tile_index_mask kbv_type_default kbv_type_ascii kbv_type_url kbv_type_email kbv_type_numbers kbv_type_phone kbv_type_phone_name kbv_returnkey_default kbv_returnkey_go kbv_returnkey_google kbv_returnkey_join kbv_returnkey_next kbv_returnkey_route kbv_returnkey_search kbv_returnkey_send kbv_returnkey_yahoo kbv_returnkey_done kbv_returnkey_continue kbv_returnkey_emergency kbv_autocapitalize_none kbv_autocapitalize_words kbv_autocapitalize_sentences kbv_autocapitalize_characters",
+symbol:"argument_relative argument argument0 argument1 argument2 argument3 argument4 argument5 argument6 argument7 argument8 argument9 argument10 argument11 argument12 argument13 argument14 argument15 argument_count x y xprevious yprevious xstart ystart hspeed vspeed direction speed friction gravity gravity_direction path_index path_position path_positionprevious path_speed path_scale path_orientation path_endaction object_index id solid persistent mask_index instance_count instance_id room_speed fps fps_real current_time current_year current_month current_day current_weekday current_hour current_minute current_second alarm timeline_index timeline_position timeline_speed timeline_running timeline_loop room room_first room_last room_width room_height room_caption room_persistent score lives health show_score show_lives show_health caption_score caption_lives caption_health event_type event_number event_object event_action application_surface gamemaker_pro gamemaker_registered gamemaker_version error_occurred error_last debug_mode keyboard_key keyboard_lastkey keyboard_lastchar keyboard_string mouse_x mouse_y mouse_button mouse_lastbutton cursor_sprite visible sprite_index sprite_width sprite_height sprite_xoffset sprite_yoffset image_number image_index image_speed depth image_xscale image_yscale image_angle image_alpha image_blend bbox_left bbox_right bbox_top bbox_bottom layer background_colour  background_showcolour background_color background_showcolor view_enabled view_current view_visible view_xview view_yview view_wview view_hview view_xport view_yport view_wport view_hport view_angle view_hborder view_vborder view_hspeed view_vspeed view_object view_surface_id view_camera game_id game_display_name game_project_name game_save_id working_directory temp_directory program_directory browser_width browser_height os_type os_device os_browser os_version display_aa async_load delta_time webgl_enabled event_data iap_data phy_rotation phy_position_x phy_position_y phy_angular_velocity phy_linear_velocity_x phy_linear_velocity_y phy_speed_x phy_speed_y phy_speed phy_angular_damping phy_linear_damping phy_bullet phy_fixed_rotation phy_active phy_mass phy_inertia phy_com_x phy_com_y phy_dynamic phy_kinematic phy_sleeping phy_collision_points phy_collision_x phy_collision_y phy_col_normal_x phy_col_normal_y phy_position_xprevious phy_position_yprevious"},
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE]}});b.registerLanguage("go",function(a){var c={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",literal:"true false iota nil",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};
+return{aliases:["golang"],keywords:c,illegal:"</",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[a.QUOTE_STRING_MODE,{begin:"'",end:"[^\\\\]'"},{begin:"`",end:"`"}]},{className:"number",variants:[{begin:a.C_NUMBER_RE+"[dflsi]",relevance:1},a.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func",end:/\s*\{/,excludeEnd:!0,contains:[a.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:c,illegal:/["']/}]}]}});b.registerLanguage("golo",function(a){return{keywords:{keyword:"println readln print import module function local return let var while for foreach times in case when match with break continue augment augmentation each find filter reduce if then else otherwise try catch finally raise throw orIfNull DynamicObject|10 DynamicVariable struct Observable map set vector list array",
+literal:"true false null"},contains:[a.HASH_COMMENT_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("gradle",function(a){return{case_insensitive:!0,keywords:{keyword:"task project allprojects subprojects artifacts buildscript configurations dependencies repositories sourceSets description delete from into include exclude source classpath destinationDir includes options sourceCompatibility targetCompatibility group flatDir doLast doFirst flatten todir fromdir ant def abstract break case catch continue default do else extends final finally for if implements instanceof native new private protected public return static switch synchronized throw throws transient try volatile while strictfp package import false null super this true antlrtask checkstyle codenarc copy boolean byte char class double float int interface long short void compile runTime file fileTree abs any append asList asWritable call collect compareTo count div dump each eachByte eachFile eachLine every find findAll flatten getAt getErr getIn getOut getText grep immutable inject inspect intersect invokeMethods isCase join leftShift minus multiply newInputStream newOutputStream newPrintWriter newReader newWriter next plus pop power previous print println push putAt read readBytes readLines reverse reverseEach round size sort splitEachLine step subMap times toInteger toList tokenize upto waitForOrKill withPrintWriter withReader withStream withWriter withWriterAppend write writeLine"},
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.NUMBER_MODE,a.REGEXP_MODE]}});b.registerLanguage("groovy",function(a){return{keywords:{literal:"true false null",keyword:"byte short char int long boolean float double void def as in assert trait super this abstract static volatile transient public private protected synchronized final class interface enum if else for while switch case break default continue throw throws try catch finally implements extends new import package return instanceof"},
+contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",begin:'"""',end:'"""'},{className:"string",begin:"'''",end:"'''"},{className:"string",begin:"\\$/",end:"/\\$",relevance:10},a.APOS_STRING_MODE,{className:"regexp",begin:/~?\/[^\/\n]+\//,contains:[a.BACKSLASH_ESCAPE]},a.QUOTE_STRING_MODE,{className:"meta",begin:"^#!/usr/bin/env",end:"$",illegal:"\n"},a.BINARY_NUMBER_MODE,
+{className:"class",beginKeywords:"class interface trait enum",end:"{",illegal:":",contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{className:"string",begin:/[^\?]{0}[A-Za-z0-9_$]+ *:/},{begin:/\?/,end:/:/},{className:"symbol",begin:"^\\s*[A-Za-z0-9_$]+:",relevance:0}],illegal:/#|<\//}});b.registerLanguage("haml",function(a){return{case_insensitive:!0,contains:[{className:"meta",begin:"^!!!( (5|1\\.1|Strict|Frameset|Basic|Mobile|RDFa|XML\\b.*))?$",
+relevance:10},a.COMMENT("^\\s*(!=#|=#|-#|/).*$",!1,{relevance:0}),{begin:"^\\s*(-|=|!=)(?!#)",starts:{end:"\\n",subLanguage:"ruby"}},{className:"tag",begin:"^\\s*%",contains:[{className:"selector-tag",begin:"\\w+"},{className:"selector-id",begin:"#[\\w-]+"},{className:"selector-class",begin:"\\.[\\w-]+"},{begin:"{\\s*",end:"\\s*}",contains:[{begin:":\\w+\\s*=>",end:",\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",begin:":\\w+"},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{begin:"\\w+",
+relevance:0}]}]},{begin:"\\(\\s*",end:"\\s*\\)",excludeEnd:!0,contains:[{begin:"\\w+\\s*=",end:"\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",begin:"\\w+",relevance:0},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{begin:"\\w+",relevance:0}]}]}]},{begin:"^\\s*[=~]\\s*"},{begin:"#{",starts:{end:"}",subLanguage:"ruby"}}]}});b.registerLanguage("handlebars",function(a){var c={"builtin-name":"each in with if else unless bindattr action collection debugger log outlet template unbound view yield"};
+return{aliases:["hbs","html.hbs","html.handlebars"],case_insensitive:!0,subLanguage:"xml",contains:[a.COMMENT("{{!(--)?","(--)?}}"),{className:"template-tag",begin:/\{\{[#\/]/,end:/\}\}/,contains:[{className:"name",begin:/[a-zA-Z\.-]+/,keywords:c,starts:{endsWithParent:!0,relevance:0,contains:[a.QUOTE_STRING_MODE]}}]},{className:"template-variable",begin:/\{\{/,end:/\}\}/,keywords:c}]}});b.registerLanguage("haskell",function(a){var c={variants:[a.COMMENT("--","$"),a.COMMENT("{-","-}",{contains:["self"]})]},
+b={className:"meta",begin:"{-#",end:"#-}"},e={className:"meta",begin:"^#",end:"$"},f={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},g={begin:"\\(",end:"\\)",illegal:'"',contains:[b,e,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},a.inherit(a.TITLE_MODE,{begin:"[_a-z][\\w']*"}),c]};return{aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",
+contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[g,c],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[g,c],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",end:"where",keywords:"class family instance where",contains:[f,g,c]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[b,f,g,{begin:"{",end:"}",contains:g.contains},c]},{beginKeywords:"default",
+end:"$",contains:[f,g,c]},{beginKeywords:"infix infixl infixr",end:"$",contains:[a.C_NUMBER_MODE,c]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[f,a.QUOTE_STRING_MODE,c]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},b,e,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,f,a.inherit(a.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),c,{begin:"->|<-"}]}});b.registerLanguage("haxe",function(a){return{aliases:["hx"],keywords:{keyword:"break case cast catch continue default do dynamic else enum extern for function here if import in inline never new override package private get set public return static super switch this throw trace try typedef untyped using var while Int Float String Bool Dynamic Void Array ",
+built_in:"trace this",literal:"true false null _"},contains:[{className:"string",begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"},{className:"subst",begin:"\\$",end:"\\W}"}]},a.QUOTE_STRING_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.C_NUMBER_MODE,{className:"meta",begin:"@:",end:"$"},{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elseif end error"}},{className:"type",begin:":[ \t]*",end:"[^A-Za-z0-9_ \t\\->]",excludeBegin:!0,
+excludeEnd:!0,relevance:0},{className:"type",begin:":[ \t]*",end:"\\W",excludeBegin:!0,excludeEnd:!0},{className:"type",begin:"new *",end:"\\W",excludeBegin:!0,excludeEnd:!0},{className:"class",beginKeywords:"enum",end:"\\{",contains:[a.TITLE_MODE]},{className:"class",beginKeywords:"abstract",end:"[\\{$]",contains:[{className:"type",begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"type",begin:"from +",end:"\\W",excludeBegin:!0,excludeEnd:!0},{className:"type",begin:"to +",end:"\\W",
+excludeBegin:!0,excludeEnd:!0},a.TITLE_MODE],keywords:{keyword:"abstract from to"}},{className:"class",begin:"\\b(class|interface) +",end:"[\\{$]",excludeEnd:!0,keywords:"class interface",contains:[{className:"keyword",begin:"\\b(extends|implements) +",keywords:"extends implements",contains:[{className:"type",begin:a.IDENT_RE,relevance:0}]},a.TITLE_MODE]},{className:"function",beginKeywords:"function",end:"\\(",excludeEnd:!0,illegal:"\\S",contains:[a.TITLE_MODE]}],illegal:/<\//}});b.registerLanguage("hsp",
+function(a){return{case_insensitive:!0,lexemes:/[\w\._]+/,keywords:"goto gosub return break repeat loop continue wait await dim sdim foreach dimtype dup dupptr end stop newmod delmod mref run exgoto on mcall assert logmes newlab resume yield onexit onerror onkey onclick oncmd exist delete mkdir chdir dirlist bload bsave bcopy memfile if else poke wpoke lpoke getstr chdpm memexpand memcpy memset notesel noteadd notedel noteload notesave randomize noteunsel noteget split strrep setease button chgdisp exec dialog mmload mmplay mmstop mci pset pget syscolor mes print title pos circle cls font sysfont objsize picload color palcolor palette redraw width gsel gcopy gzoom gmode bmpsave hsvcolor getkey listbox chkbox combox input mesbox buffer screen bgscr mouse objsel groll line clrobj boxf objprm objmode stick grect grotate gsquare gradf objimage objskip objenable celload celdiv celput newcom querycom delcom cnvstow comres axobj winobj sendmsg comevent comevarg sarrayconv callfunc cnvwtos comevdisp libptr system hspstat hspver stat cnt err strsize looplev sublev iparam wparam lparam refstr refdval int rnd strlen length length2 length3 length4 vartype gettime peek wpeek lpeek varptr varuse noteinfo instr abs limit getease str strmid strf getpath strtrim sin cos tan atan sqrt double absf expf logf limitf powf geteasef mousex mousey mousew hwnd hinstance hdc ginfo objinfo dirinfo sysinfo thismod __hspver__ __hsp30__ __date__ __time__ __line__ __file__ _debug __hspdef__ and or xor not screen_normal screen_palette screen_hide screen_fixedsize screen_tool screen_frame gmode_gdi gmode_mem gmode_rgb0 gmode_alpha gmode_rgb0alpha gmode_add gmode_sub gmode_pixela ginfo_mx ginfo_my ginfo_act ginfo_sel ginfo_wx1 ginfo_wy1 ginfo_wx2 ginfo_wy2 ginfo_vx ginfo_vy ginfo_sizex ginfo_sizey ginfo_winx ginfo_winy ginfo_mesx ginfo_mesy ginfo_r ginfo_g ginfo_b ginfo_paluse ginfo_dispx ginfo_dispy ginfo_cx ginfo_cy ginfo_intid ginfo_newid ginfo_sx ginfo_sy objinfo_mode objinfo_bmscr objinfo_hwnd notemax notesize dir_cur dir_exe dir_win dir_sys dir_cmdline dir_desktop dir_mydoc dir_tv font_normal font_bold font_italic font_underline font_strikeout font_antialias objmode_normal objmode_guifont objmode_usefont gsquare_grad msgothic msmincho do until while wend for next _break _continue switch case default swbreak swend ddim ldim alloc m_pi rad2deg deg2rad ease_linear ease_quad_in ease_quad_out ease_quad_inout ease_cubic_in ease_cubic_out ease_cubic_inout ease_quartic_in ease_quartic_out ease_quartic_inout ease_bounce_in ease_bounce_out ease_bounce_inout ease_shake_in ease_shake_out ease_shake_inout ease_loop",
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{className:"string",begin:'{"',end:'"}',contains:[a.BACKSLASH_ESCAPE]},a.COMMENT(";","$",{relevance:0}),{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"addion cfunc cmd cmpopt comfunc const defcfunc deffunc define else endif enum epack func global if ifdef ifndef include modcfunc modfunc modinit modterm module pack packopt regcmd runtime undef usecom uselib"},contains:[a.inherit(a.QUOTE_STRING_MODE,
+{className:"meta-string"}),a.NUMBER_MODE,a.C_NUMBER_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{className:"symbol",begin:"^\\*(\\w+|@)"},a.NUMBER_MODE,a.C_NUMBER_MODE]}});b.registerLanguage("htmlbars",function(a){var b={endsWithParent:!0,relevance:0,keywords:{keyword:"as",built_in:"action collection component concat debugger each each-in else get hash if input link-to loc log mut outlet partial query-params render textarea unbound unless with yield view"},contains:[a.QUOTE_STRING_MODE,{illegal:/\}\}/,
+begin:/[a-zA-Z0-9_]+=/,returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/[a-zA-Z0-9_]+/}]},a.NUMBER_MODE]};return{case_insensitive:!0,subLanguage:"xml",contains:[a.COMMENT("{{!(--)?","(--)?}}"),{className:"template-tag",begin:/\{\{[#\/]/,end:/\}\}/,contains:[{className:"name",begin:/[a-zA-Z\.\-]+/,keywords:{"builtin-name":"action collection component concat debugger each each-in else get hash if input link-to loc log mut outlet partial query-params render textarea unbound unless with yield view"},
+starts:b}]},{className:"template-variable",begin:/\{\{[a-zA-Z][a-zA-Z\-]+/,end:/\}\}/,keywords:{keyword:"as",built_in:"action collection component concat debugger each each-in else get hash if input link-to loc log mut outlet partial query-params render textarea unbound unless with yield view"},contains:[a.QUOTE_STRING_MODE]}]}});b.registerLanguage("http",function(a){return{aliases:["https"],illegal:"\\S",contains:[{begin:"^HTTP/[0-9\\.]+",end:"$",contains:[{className:"number",begin:"\\b\\d{3}\\b"}]},
+{begin:"^[A-Z]+ (.*?) HTTP/[0-9\\.]+$",returnBegin:!0,end:"$",contains:[{className:"string",begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{begin:"HTTP/[0-9\\.]+"},{className:"keyword",begin:"[A-Z]+"}]},{className:"attribute",begin:"^\\w",end:": ",excludeEnd:!0,illegal:"\\n|\\s|=",starts:{end:"$",relevance:0}},{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}]}});b.registerLanguage("hy",function(a){var b={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",relevance:0},d=a.inherit(a.QUOTE_STRING_MODE,
+{illegal:null}),e=a.COMMENT(";","$",{relevance:0}),f={className:"literal",begin:/\b([Tt]rue|[Ff]alse|nil|None)\b/},g={begin:"[\\[\\{]",end:"[\\]\\}]"},k={className:"comment",begin:"\\^[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},h=a.COMMENT("\\^\\{","\\}"),m={className:"symbol",begin:"[:]{1,2}[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},n={begin:"\\(",end:"\\)"},l={endsWithParent:!0,relevance:0},r={keywords:{"builtin-name":"!= % %= & &= * ** **= *= *map + += , --build-class-- --import-- -= . / // //= /= < << <<= <= = > >= >> >>= @ @= ^ ^= abs accumulate all and any ap-compose ap-dotimes ap-each ap-each-while ap-filter ap-first ap-if ap-last ap-map ap-map-when ap-pipe ap-reduce ap-reject apply as-> ascii assert assoc bin break butlast callable calling-module-name car case cdr chain chr coll? combinations compile compress cond cons cons? continue count curry cut cycle dec def default-method defclass defmacro defmacro-alias defmacro/g! defmain defmethod defmulti defn defn-alias defnc defnr defreader defseq del delattr delete-route dict-comp dir disassemble dispatch-reader-macro distinct divmod do doto drop drop-last drop-while empty? end-sequence eval eval-and-compile eval-when-compile even? every? except exec filter first flatten float? fn fnc fnr for for* format fraction genexpr gensym get getattr global globals group-by hasattr hash hex id identity if if* if-not if-python2 import in inc input instance? integer integer-char? integer? interleave interpose is is-coll is-cons is-empty is-even is-every is-float is-instance is-integer is-integer-char is-iterable is-iterator is-keyword is-neg is-none is-not is-numeric is-odd is-pos is-string is-symbol is-zero isinstance islice issubclass iter iterable? iterate iterator? keyword keyword? lambda last len let lif lif-not list* list-comp locals loop macro-error macroexpand macroexpand-1 macroexpand-all map max merge-with method-decorator min multi-decorator multicombinations name neg? next none? nonlocal not not-in not? nth numeric? oct odd? open or ord partition permutations pos? post-route postwalk pow prewalk print product profile/calls profile/cpu put-route quasiquote quote raise range read read-str recursive-replace reduce remove repeat repeatedly repr require rest round route route-with-methods rwm second seq set-comp setattr setv some sorted string string? sum switch symbol? take take-nth take-while tee try unless unquote unquote-splicing vars walk when while with with* with-decorator with-gensyms xi xor yield yield-from zero? zip zip-longest | |= ~"},
+lexemes:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",className:"name",begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",starts:l},q=[n,d,k,h,e,m,g,b,f,{begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",relevance:0}];n.contains=[a.COMMENT("comment",""),r,l];l.contains=q;g.contains=q;return{aliases:["hylang"],illegal:/\S/,contains:[{className:"meta",begin:"^#!",end:"$"},n,d,k,h,e,m,g,b,f]}});b.registerLanguage("inform7",function(a){return{aliases:["i7"],case_insensitive:!0,
+keywords:{keyword:"thing room person man woman animal container supporter backdrop door scenery open closed locked inside gender is are say understand kind of rule"},contains:[{className:"string",begin:'"',end:'"',relevance:0,contains:[{className:"subst",begin:"\\[",end:"\\]"}]},{className:"section",begin:/^(Volume|Book|Part|Chapter|Section|Table)\b/,end:"$"},{begin:/^(Check|Carry out|Report|Instead of|To|Rule|When|Before|After)\b/,end:":",contains:[{begin:"\\(This",end:"\\)"}]},{className:"comment",
+begin:"\\[",end:"\\]",contains:["self"]}]}});b.registerLanguage("ini",function(a){var b={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}]};return{aliases:["toml"],case_insensitive:!0,illegal:/\S/,contains:[a.COMMENT(";","$"),a.HASH_COMMENT_MODE,{className:"section",begin:/^\s*\[+/,end:/\]+/},{begin:/^[a-z0-9\[\]_\.-]+\s*=\s*/,end:"$",returnBegin:!0,contains:[{className:"attr",
+begin:/[a-z0-9\[\]_\.-]+/},{begin:/=/,endsWithParent:!0,relevance:0,contains:[a.COMMENT(";","$"),a.HASH_COMMENT_MODE,{className:"literal",begin:/\bon|off|true|false|yes|no\b/},{className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{begin:/\$\{(.*?)}/}]},b,{className:"number",begin:/([\+\-]+)?[\d]+_[\d_]+/},a.NUMBER_MODE]}]}]}});b.registerLanguage("irpf90",function(a){return{case_insensitive:!0,keywords:{literal:".False. .True.",keyword:"kind do while private call intrinsic where elsewhere type endtype endmodule endselect endinterface end enddo endif if forall endforall only contains default return stop then public subroutine|10 function program .and. .or. .not. .le. .eq. .ge. .gt. .lt. goto save else use module select case access blank direct exist file fmt form formatted iostat name named nextrec number opened rec recl sequential status unformatted unit continue format pause cycle exit c_null_char c_alert c_backspace c_form_feed flush wait decimal round iomsg synchronous nopass non_overridable pass protected volatile abstract extends import non_intrinsic value deferred generic final enumerator class associate bind enum c_int c_short c_long c_long_long c_signed_char c_size_t c_int8_t c_int16_t c_int32_t c_int64_t c_int_least8_t c_int_least16_t c_int_least32_t c_int_least64_t c_int_fast8_t c_int_fast16_t c_int_fast32_t c_int_fast64_t c_intmax_t C_intptr_t c_float c_double c_long_double c_float_complex c_double_complex c_long_double_complex c_bool c_char c_null_ptr c_null_funptr c_new_line c_carriage_return c_horizontal_tab c_vertical_tab iso_c_binding c_loc c_funloc c_associated  c_f_pointer c_ptr c_funptr iso_fortran_env character_storage_size error_unit file_storage_size input_unit iostat_end iostat_eor numeric_storage_size output_unit c_f_procpointer ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode newunit contiguous recursive pad position action delim readwrite eor advance nml interface procedure namelist include sequence elemental pure integer real character complex logical dimension allocatable|10 parameter external implicit|10 none double precision assign intent optional pointer target in out common equivalence data begin_provider &begin_provider end_provider begin_shell end_shell begin_template end_template subst assert touch soft_touch provide no_dep free irp_if irp_else irp_endif irp_write irp_read",
+built_in:"alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl algama cdabs cdcos cdexp cdlog cdsin cdsqrt cqabs cqcos cqexp cqlog cqsin cqsqrt dcmplx dconjg derf derfc dfloat dgamma dimag dlgama iqint qabs qacos qasin qatan qatan2 qcmplx qconjg qcos qcosh qdim qerf qerfc qexp qgamma qimag qlgama qlog qlog10 qmax1 qmin1 qmod qnint qsign qsin qsinh qsqrt qtan qtanh abs acos aimag aint anint asin atan atan2 char cmplx conjg cos cosh exp ichar index int log log10 max min nint sign sin sinh sqrt tan tanh print write dim lge lgt lle llt mod nullify allocate deallocate adjustl adjustr all allocated any associated bit_size btest ceiling count cshift date_and_time digits dot_product eoshift epsilon exponent floor fraction huge iand ibclr ibits ibset ieor ior ishft ishftc lbound len_trim matmul maxexponent maxloc maxval merge minexponent minloc minval modulo mvbits nearest pack present product radix random_number random_seed range repeat reshape rrspacing scale scan selected_int_kind selected_real_kind set_exponent shape size spacing spread sum system_clock tiny transpose trim ubound unpack verify achar iachar transfer dble entry dprod cpu_time command_argument_count get_command get_command_argument get_environment_variable is_iostat_end ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode is_iostat_eor move_alloc new_line selected_char_kind same_type_as extends_type_ofacosh asinh atanh bessel_j0 bessel_j1 bessel_jn bessel_y0 bessel_y1 bessel_yn erf erfc erfc_scaled gamma log_gamma hypot norm2 atomic_define atomic_ref execute_command_line leadz trailz storage_size merge_bits bge bgt ble blt dshiftl dshiftr findloc iall iany iparity image_index lcobound ucobound maskl maskr num_images parity popcnt poppar shifta shiftl shiftr this_image IRP_ALIGN irp_here"},
+illegal:/\/\*/,contains:[a.inherit(a.APOS_STRING_MODE,{className:"string",relevance:0}),a.inherit(a.QUOTE_STRING_MODE,{className:"string",relevance:0}),{className:"function",beginKeywords:"subroutine function program",illegal:"[${=\\n]",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]},a.COMMENT("!","$",{relevance:0}),a.COMMENT("begin_doc","end_doc",{relevance:10}),{className:"number",begin:"(?=\\b|\\+|\\-|\\.)(?=\\.\\d|\\d)(?:\\d+)?(?:\\.?\\d*)(?:[de][+-]?\\d+)?\\b\\.?",
+relevance:0}]}});b.registerLanguage("isbl",function(a){var b={className:"number",begin:a.NUMBER_RE,relevance:0},d={className:"string",variants:[{begin:'"',end:'"'},{begin:"'",end:"'"}]},e={className:"doctag",begin:"\\b(?:TODO|DONE|BEGIN|END|STUB|CHG|FIXME|NOTE|BUG|XXX)\\b",relevance:0};e={variants:[{className:"comment",begin:"//",end:"$",relevance:0,contains:[a.PHRASAL_WORDS_MODE,e]},{className:"comment",begin:"/\\*",end:"\\*/",relevance:0,contains:[a.PHRASAL_WORDS_MODE,e]}]};var f={keyword:"and \u0438 else \u0438\u043d\u0430\u0447\u0435 endexcept endfinally endforeach \u043a\u043e\u043d\u0435\u0446\u0432\u0441\u0435 endif \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 endwhile \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043a\u0430 except exitfor finally foreach \u0432\u0441\u0435 if \u0435\u0441\u043b\u0438 in \u0432 not \u043d\u0435 or \u0438\u043b\u0438 try while \u043f\u043e\u043a\u0430 ",
+built_in:"SYSRES_CONST_ACCES_RIGHT_TYPE_EDIT SYSRES_CONST_ACCES_RIGHT_TYPE_FULL SYSRES_CONST_ACCES_RIGHT_TYPE_VIEW SYSRES_CONST_ACCESS_MODE_REQUISITE_CODE SYSRES_CONST_ACCESS_NO_ACCESS_VIEW SYSRES_CONST_ACCESS_NO_ACCESS_VIEW_CODE SYSRES_CONST_ACCESS_RIGHTS_ADD_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_ADD_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_CHANGE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_CHANGE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_DELETE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_DELETE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_EXECUTE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_EXECUTE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_NO_ACCESS_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_NO_ACCESS_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_RATIFY_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_RATIFY_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW SYSRES_CONST_ACCESS_RIGHTS_VIEW_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_TYPE_CHANGE SYSRES_CONST_ACCESS_TYPE_CHANGE_CODE SYSRES_CONST_ACCESS_TYPE_EXISTS SYSRES_CONST_ACCESS_TYPE_EXISTS_CODE SYSRES_CONST_ACCESS_TYPE_FULL SYSRES_CONST_ACCESS_TYPE_FULL_CODE SYSRES_CONST_ACCESS_TYPE_VIEW SYSRES_CONST_ACCESS_TYPE_VIEW_CODE SYSRES_CONST_ACTION_TYPE_ABORT SYSRES_CONST_ACTION_TYPE_ACCEPT SYSRES_CONST_ACTION_TYPE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ADD_ATTACHMENT SYSRES_CONST_ACTION_TYPE_CHANGE_CARD SYSRES_CONST_ACTION_TYPE_CHANGE_KIND SYSRES_CONST_ACTION_TYPE_CHANGE_STORAGE SYSRES_CONST_ACTION_TYPE_CONTINUE SYSRES_CONST_ACTION_TYPE_COPY SYSRES_CONST_ACTION_TYPE_CREATE SYSRES_CONST_ACTION_TYPE_CREATE_VERSION SYSRES_CONST_ACTION_TYPE_DELETE SYSRES_CONST_ACTION_TYPE_DELETE_ATTACHMENT SYSRES_CONST_ACTION_TYPE_DELETE_VERSION SYSRES_CONST_ACTION_TYPE_DISABLE_DELEGATE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ENABLE_DELEGATE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_CERTIFICATE SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_CERTIFICATE_AND_PASSWORD SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_PASSWORD SYSRES_CONST_ACTION_TYPE_EXPORT_WITH_LOCK SYSRES_CONST_ACTION_TYPE_EXPORT_WITHOUT_LOCK SYSRES_CONST_ACTION_TYPE_IMPORT_WITH_UNLOCK SYSRES_CONST_ACTION_TYPE_IMPORT_WITHOUT_UNLOCK SYSRES_CONST_ACTION_TYPE_LIFE_CYCLE_STAGE SYSRES_CONST_ACTION_TYPE_LOCK SYSRES_CONST_ACTION_TYPE_LOCK_FOR_SERVER SYSRES_CONST_ACTION_TYPE_LOCK_MODIFY SYSRES_CONST_ACTION_TYPE_MARK_AS_READED SYSRES_CONST_ACTION_TYPE_MARK_AS_UNREADED SYSRES_CONST_ACTION_TYPE_MODIFY SYSRES_CONST_ACTION_TYPE_MODIFY_CARD SYSRES_CONST_ACTION_TYPE_MOVE_TO_ARCHIVE SYSRES_CONST_ACTION_TYPE_OFF_ENCRYPTION SYSRES_CONST_ACTION_TYPE_PASSWORD_CHANGE SYSRES_CONST_ACTION_TYPE_PERFORM SYSRES_CONST_ACTION_TYPE_RECOVER_FROM_LOCAL_COPY SYSRES_CONST_ACTION_TYPE_RESTART SYSRES_CONST_ACTION_TYPE_RESTORE_FROM_ARCHIVE SYSRES_CONST_ACTION_TYPE_REVISION SYSRES_CONST_ACTION_TYPE_SEND_BY_MAIL SYSRES_CONST_ACTION_TYPE_SIGN SYSRES_CONST_ACTION_TYPE_START SYSRES_CONST_ACTION_TYPE_UNLOCK SYSRES_CONST_ACTION_TYPE_UNLOCK_FROM_SERVER SYSRES_CONST_ACTION_TYPE_VERSION_STATE SYSRES_CONST_ACTION_TYPE_VERSION_VISIBILITY SYSRES_CONST_ACTION_TYPE_VIEW SYSRES_CONST_ACTION_TYPE_VIEW_SHADOW_COPY SYSRES_CONST_ACTION_TYPE_WORKFLOW_DESCRIPTION_MODIFY SYSRES_CONST_ACTION_TYPE_WRITE_HISTORY SYSRES_CONST_ACTIVE_VERSION_STATE_PICK_VALUE SYSRES_CONST_ADD_REFERENCE_MODE_NAME SYSRES_CONST_ADDITION_REQUISITE_CODE SYSRES_CONST_ADDITIONAL_PARAMS_REQUISITE_CODE SYSRES_CONST_ADITIONAL_JOB_END_DATE_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_READ_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_START_DATE_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_STATE_REQUISITE_NAME SYSRES_CONST_ADMINISTRATION_HISTORY_ADDING_USER_TO_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_ADDING_USER_TO_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_COMP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_COMP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_USER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_USER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_CREATION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_CREATION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_DELETION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_DELETION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_COMP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_COMP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_FROM_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_FROM_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_RESTRICTION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_RESTRICTION_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_PRIVILEGE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_PRIVILEGE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_RIGHTS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_RIGHTS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_IS_MAIN_SERVER_CHANGED_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_IS_MAIN_SERVER_CHANGED_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_IS_PUBLIC_CHANGED_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_IS_PUBLIC_CHANGED_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_RESTRICTION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_RESTRICTION_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_PRIVILEGE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_PRIVILEGE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_RIGHTS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_RIGHTS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_CREATION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_CREATION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_DELETION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_DELETION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_CATEGORY_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_CATEGORY_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_COMP_TITLE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_COMP_TITLE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_FULL_NAME_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_FULL_NAME_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_PARENT_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_PARENT_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_AUTH_TYPE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_AUTH_TYPE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_LOGIN_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_LOGIN_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_STATUS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_STATUS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_USER_PASSWORD_CHANGE SYSRES_CONST_ADMINISTRATION_HISTORY_USER_PASSWORD_CHANGE_ACTION SYSRES_CONST_ALL_ACCEPT_CONDITION_RUS SYSRES_CONST_ALL_USERS_GROUP SYSRES_CONST_ALL_USERS_GROUP_NAME SYSRES_CONST_ALL_USERS_SERVER_GROUP_NAME SYSRES_CONST_ALLOWED_ACCESS_TYPE_CODE SYSRES_CONST_ALLOWED_ACCESS_TYPE_NAME SYSRES_CONST_APP_VIEWER_TYPE_REQUISITE_CODE SYSRES_CONST_APPROVING_SIGNATURE_NAME SYSRES_CONST_APPROVING_SIGNATURE_REQUISITE_CODE SYSRES_CONST_ASSISTANT_SUBSTITUE_TYPE SYSRES_CONST_ASSISTANT_SUBSTITUE_TYPE_CODE SYSRES_CONST_ATTACH_TYPE_COMPONENT_TOKEN SYSRES_CONST_ATTACH_TYPE_DOC SYSRES_CONST_ATTACH_TYPE_EDOC SYSRES_CONST_ATTACH_TYPE_FOLDER SYSRES_CONST_ATTACH_TYPE_JOB SYSRES_CONST_ATTACH_TYPE_REFERENCE SYSRES_CONST_ATTACH_TYPE_TASK SYSRES_CONST_AUTH_ENCODED_PASSWORD SYSRES_CONST_AUTH_ENCODED_PASSWORD_CODE SYSRES_CONST_AUTH_NOVELL SYSRES_CONST_AUTH_PASSWORD SYSRES_CONST_AUTH_PASSWORD_CODE SYSRES_CONST_AUTH_WINDOWS SYSRES_CONST_AUTHENTICATING_SIGNATURE_NAME SYSRES_CONST_AUTHENTICATING_SIGNATURE_REQUISITE_CODE SYSRES_CONST_AUTO_ENUM_METHOD_FLAG SYSRES_CONST_AUTO_NUMERATION_CODE SYSRES_CONST_AUTO_STRONG_ENUM_METHOD_FLAG SYSRES_CONST_AUTOTEXT_NAME_REQUISITE_CODE SYSRES_CONST_AUTOTEXT_TEXT_REQUISITE_CODE SYSRES_CONST_AUTOTEXT_USAGE_ALL SYSRES_CONST_AUTOTEXT_USAGE_ALL_CODE SYSRES_CONST_AUTOTEXT_USAGE_SIGN SYSRES_CONST_AUTOTEXT_USAGE_SIGN_CODE SYSRES_CONST_AUTOTEXT_USAGE_WORK SYSRES_CONST_AUTOTEXT_USAGE_WORK_CODE SYSRES_CONST_AUTOTEXT_USE_ANYWHERE_CODE SYSRES_CONST_AUTOTEXT_USE_ON_SIGNING_CODE SYSRES_CONST_AUTOTEXT_USE_ON_WORK_CODE SYSRES_CONST_BEGIN_DATE_REQUISITE_CODE SYSRES_CONST_BLACK_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_BLUE_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_BTN_PART SYSRES_CONST_CALCULATED_ROLE_TYPE_CODE SYSRES_CONST_CALL_TYPE_VARIABLE_BUTTON_VALUE SYSRES_CONST_CALL_TYPE_VARIABLE_PROGRAM_VALUE SYSRES_CONST_CANCEL_MESSAGE_FUNCTION_RESULT SYSRES_CONST_CARD_PART SYSRES_CONST_CARD_REFERENCE_MODE_NAME SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_ENCRYPT_VALUE SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_SIGN_AND_ENCRYPT_VALUE SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_SIGN_VALUE SYSRES_CONST_CHECK_PARAM_VALUE_DATE_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_FLOAT_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_INTEGER_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_PICK_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_REEFRENCE_PARAM_TYPE SYSRES_CONST_CLOSED_RECORD_FLAG_VALUE_FEMININE SYSRES_CONST_CLOSED_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_CODE_COMPONENT_TYPE_ADMIN SYSRES_CONST_CODE_COMPONENT_TYPE_DEVELOPER SYSRES_CONST_CODE_COMPONENT_TYPE_DOCS SYSRES_CONST_CODE_COMPONENT_TYPE_EDOC_CARDS SYSRES_CONST_CODE_COMPONENT_TYPE_EXTERNAL_EXECUTABLE SYSRES_CONST_CODE_COMPONENT_TYPE_OTHER SYSRES_CONST_CODE_COMPONENT_TYPE_REFERENCE SYSRES_CONST_CODE_COMPONENT_TYPE_REPORT SYSRES_CONST_CODE_COMPONENT_TYPE_SCRIPT SYSRES_CONST_CODE_COMPONENT_TYPE_URL SYSRES_CONST_CODE_REQUISITE_ACCESS SYSRES_CONST_CODE_REQUISITE_CODE SYSRES_CONST_CODE_REQUISITE_COMPONENT SYSRES_CONST_CODE_REQUISITE_DESCRIPTION SYSRES_CONST_CODE_REQUISITE_EXCLUDE_COMPONENT SYSRES_CONST_CODE_REQUISITE_RECORD SYSRES_CONST_COMMENT_REQ_CODE SYSRES_CONST_COMMON_SETTINGS_REQUISITE_CODE SYSRES_CONST_COMP_CODE_GRD SYSRES_CONST_COMPONENT_GROUP_TYPE_REQUISITE_CODE SYSRES_CONST_COMPONENT_TYPE_ADMIN_COMPONENTS SYSRES_CONST_COMPONENT_TYPE_DEVELOPER_COMPONENTS SYSRES_CONST_COMPONENT_TYPE_DOCS SYSRES_CONST_COMPONENT_TYPE_EDOC_CARDS SYSRES_CONST_COMPONENT_TYPE_EDOCS SYSRES_CONST_COMPONENT_TYPE_EXTERNAL_EXECUTABLE SYSRES_CONST_COMPONENT_TYPE_OTHER SYSRES_CONST_COMPONENT_TYPE_REFERENCE_TYPES SYSRES_CONST_COMPONENT_TYPE_REFERENCES SYSRES_CONST_COMPONENT_TYPE_REPORTS SYSRES_CONST_COMPONENT_TYPE_SCRIPTS SYSRES_CONST_COMPONENT_TYPE_URL SYSRES_CONST_COMPONENTS_REMOTE_SERVERS_VIEW_CODE SYSRES_CONST_CONDITION_BLOCK_DESCRIPTION SYSRES_CONST_CONST_FIRM_STATUS_COMMON SYSRES_CONST_CONST_FIRM_STATUS_INDIVIDUAL SYSRES_CONST_CONST_NEGATIVE_VALUE SYSRES_CONST_CONST_POSITIVE_VALUE SYSRES_CONST_CONST_SERVER_STATUS_DONT_REPLICATE SYSRES_CONST_CONST_SERVER_STATUS_REPLICATE SYSRES_CONST_CONTENTS_REQUISITE_CODE SYSRES_CONST_DATA_TYPE_BOOLEAN SYSRES_CONST_DATA_TYPE_DATE SYSRES_CONST_DATA_TYPE_FLOAT SYSRES_CONST_DATA_TYPE_INTEGER SYSRES_CONST_DATA_TYPE_PICK SYSRES_CONST_DATA_TYPE_REFERENCE SYSRES_CONST_DATA_TYPE_STRING SYSRES_CONST_DATA_TYPE_TEXT SYSRES_CONST_DATA_TYPE_VARIANT SYSRES_CONST_DATE_CLOSE_REQ_CODE SYSRES_CONST_DATE_FORMAT_DATE_ONLY_CHAR SYSRES_CONST_DATE_OPEN_REQ_CODE SYSRES_CONST_DATE_REQUISITE SYSRES_CONST_DATE_REQUISITE_CODE SYSRES_CONST_DATE_REQUISITE_NAME SYSRES_CONST_DATE_REQUISITE_TYPE SYSRES_CONST_DATE_TYPE_CHAR SYSRES_CONST_DATETIME_FORMAT_VALUE SYSRES_CONST_DEA_ACCESS_RIGHTS_ACTION_CODE SYSRES_CONST_DESCRIPTION_LOCALIZE_ID_REQUISITE_CODE SYSRES_CONST_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_DET1_PART SYSRES_CONST_DET2_PART SYSRES_CONST_DET3_PART SYSRES_CONST_DET4_PART SYSRES_CONST_DET5_PART SYSRES_CONST_DET6_PART SYSRES_CONST_DETAIL_DATASET_KEY_REQUISITE_CODE SYSRES_CONST_DETAIL_PICK_REQUISITE_CODE SYSRES_CONST_DETAIL_REQ_CODE SYSRES_CONST_DO_NOT_USE_ACCESS_TYPE_CODE SYSRES_CONST_DO_NOT_USE_ACCESS_TYPE_NAME SYSRES_CONST_DO_NOT_USE_ON_VIEW_ACCESS_TYPE_CODE SYSRES_CONST_DO_NOT_USE_ON_VIEW_ACCESS_TYPE_NAME SYSRES_CONST_DOCUMENT_STORAGES_CODE SYSRES_CONST_DOCUMENT_TEMPLATES_TYPE_NAME SYSRES_CONST_DOUBLE_REQUISITE_CODE SYSRES_CONST_EDITOR_CLOSE_FILE_OBSERV_TYPE_CODE SYSRES_CONST_EDITOR_CLOSE_PROCESS_OBSERV_TYPE_CODE SYSRES_CONST_EDITOR_TYPE_REQUISITE_CODE SYSRES_CONST_EDITORS_APPLICATION_NAME_REQUISITE_CODE SYSRES_CONST_EDITORS_CREATE_SEVERAL_PROCESSES_REQUISITE_CODE SYSRES_CONST_EDITORS_EXTENSION_REQUISITE_CODE SYSRES_CONST_EDITORS_OBSERVER_BY_PROCESS_TYPE SYSRES_CONST_EDITORS_REFERENCE_CODE SYSRES_CONST_EDITORS_REPLACE_SPEC_CHARS_REQUISITE_CODE SYSRES_CONST_EDITORS_USE_PLUGINS_REQUISITE_CODE SYSRES_CONST_EDITORS_VIEW_DOCUMENT_OPENED_TO_EDIT_CODE SYSRES_CONST_EDOC_CARD_TYPE_REQUISITE_CODE SYSRES_CONST_EDOC_CARD_TYPES_LINK_REQUISITE_CODE SYSRES_CONST_EDOC_CERTIFICATE_AND_PASSWORD_ENCODE_CODE SYSRES_CONST_EDOC_CERTIFICATE_ENCODE_CODE SYSRES_CONST_EDOC_DATE_REQUISITE_CODE SYSRES_CONST_EDOC_KIND_REFERENCE_CODE SYSRES_CONST_EDOC_KINDS_BY_TEMPLATE_ACTION_CODE SYSRES_CONST_EDOC_MANAGE_ACCESS_CODE SYSRES_CONST_EDOC_NONE_ENCODE_CODE SYSRES_CONST_EDOC_NUMBER_REQUISITE_CODE SYSRES_CONST_EDOC_PASSWORD_ENCODE_CODE SYSRES_CONST_EDOC_READONLY_ACCESS_CODE SYSRES_CONST_EDOC_SHELL_LIFE_TYPE_VIEW_VALUE SYSRES_CONST_EDOC_SIZE_RESTRICTION_PRIORITY_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_CHECK_ACCESS_RIGHTS_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_COMPUTER_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_DATABASE_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_EDIT_IN_STORAGE_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_LOCAL_PATH_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_SHARED_SOURCE_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_TEMPLATE_REQUISITE_CODE SYSRES_CONST_EDOC_TYPES_REFERENCE_CODE SYSRES_CONST_EDOC_VERSION_ACTIVE_STAGE_CODE SYSRES_CONST_EDOC_VERSION_DESIGN_STAGE_CODE SYSRES_CONST_EDOC_VERSION_OBSOLETE_STAGE_CODE SYSRES_CONST_EDOC_WRITE_ACCES_CODE SYSRES_CONST_EDOCUMENT_CARD_REQUISITES_REFERENCE_CODE_SELECTED_REQUISITE SYSRES_CONST_ENCODE_CERTIFICATE_TYPE_CODE SYSRES_CONST_END_DATE_REQUISITE_CODE SYSRES_CONST_ENUMERATION_TYPE_REQUISITE_CODE SYSRES_CONST_EXECUTE_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_EXECUTIVE_FILE_STORAGE_TYPE SYSRES_CONST_EXIST_CONST SYSRES_CONST_EXIST_VALUE SYSRES_CONST_EXPORT_LOCK_TYPE_ASK SYSRES_CONST_EXPORT_LOCK_TYPE_WITH_LOCK SYSRES_CONST_EXPORT_LOCK_TYPE_WITHOUT_LOCK SYSRES_CONST_EXPORT_VERSION_TYPE_ASK SYSRES_CONST_EXPORT_VERSION_TYPE_LAST SYSRES_CONST_EXPORT_VERSION_TYPE_LAST_ACTIVE SYSRES_CONST_EXTENSION_REQUISITE_CODE SYSRES_CONST_FILTER_NAME_REQUISITE_CODE SYSRES_CONST_FILTER_REQUISITE_CODE SYSRES_CONST_FILTER_TYPE_COMMON_CODE SYSRES_CONST_FILTER_TYPE_COMMON_NAME SYSRES_CONST_FILTER_TYPE_USER_CODE SYSRES_CONST_FILTER_TYPE_USER_NAME SYSRES_CONST_FILTER_VALUE_REQUISITE_NAME SYSRES_CONST_FLOAT_NUMBER_FORMAT_CHAR SYSRES_CONST_FLOAT_REQUISITE_TYPE SYSRES_CONST_FOLDER_AUTHOR_VALUE SYSRES_CONST_FOLDER_KIND_ANY_OBJECTS SYSRES_CONST_FOLDER_KIND_COMPONENTS SYSRES_CONST_FOLDER_KIND_EDOCS SYSRES_CONST_FOLDER_KIND_JOBS SYSRES_CONST_FOLDER_KIND_TASKS SYSRES_CONST_FOLDER_TYPE_COMMON SYSRES_CONST_FOLDER_TYPE_COMPONENT SYSRES_CONST_FOLDER_TYPE_FAVORITES SYSRES_CONST_FOLDER_TYPE_INBOX SYSRES_CONST_FOLDER_TYPE_OUTBOX SYSRES_CONST_FOLDER_TYPE_QUICK_LAUNCH SYSRES_CONST_FOLDER_TYPE_SEARCH SYSRES_CONST_FOLDER_TYPE_SHORTCUTS SYSRES_CONST_FOLDER_TYPE_USER SYSRES_CONST_FROM_DICTIONARY_ENUM_METHOD_FLAG SYSRES_CONST_FULL_SUBSTITUTE_TYPE SYSRES_CONST_FULL_SUBSTITUTE_TYPE_CODE SYSRES_CONST_FUNCTION_CANCEL_RESULT SYSRES_CONST_FUNCTION_CATEGORY_SYSTEM SYSRES_CONST_FUNCTION_CATEGORY_USER SYSRES_CONST_FUNCTION_FAILURE_RESULT SYSRES_CONST_FUNCTION_SAVE_RESULT SYSRES_CONST_GENERATED_REQUISITE SYSRES_CONST_GREEN_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_GROUP_ACCOUNT_TYPE_VALUE_CODE SYSRES_CONST_GROUP_CATEGORY_NORMAL_CODE SYSRES_CONST_GROUP_CATEGORY_NORMAL_NAME SYSRES_CONST_GROUP_CATEGORY_SERVICE_CODE SYSRES_CONST_GROUP_CATEGORY_SERVICE_NAME SYSRES_CONST_GROUP_COMMON_CATEGORY_FIELD_VALUE SYSRES_CONST_GROUP_FULL_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_RIGHTS_T_REQUISITE_CODE SYSRES_CONST_GROUP_SERVER_CODES_REQUISITE_CODE SYSRES_CONST_GROUP_SERVER_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_SERVICE_CATEGORY_FIELD_VALUE SYSRES_CONST_GROUP_USER_REQUISITE_CODE SYSRES_CONST_GROUPS_REFERENCE_CODE SYSRES_CONST_GROUPS_REQUISITE_CODE SYSRES_CONST_HIDDEN_MODE_NAME SYSRES_CONST_HIGH_LVL_REQUISITE_CODE SYSRES_CONST_HISTORY_ACTION_CREATE_CODE SYSRES_CONST_HISTORY_ACTION_DELETE_CODE SYSRES_CONST_HISTORY_ACTION_EDIT_CODE SYSRES_CONST_HOUR_CHAR SYSRES_CONST_ID_REQUISITE_CODE SYSRES_CONST_IDSPS_REQUISITE_CODE SYSRES_CONST_IMAGE_MODE_COLOR SYSRES_CONST_IMAGE_MODE_GREYSCALE SYSRES_CONST_IMAGE_MODE_MONOCHROME SYSRES_CONST_IMPORTANCE_HIGH SYSRES_CONST_IMPORTANCE_LOW SYSRES_CONST_IMPORTANCE_NORMAL SYSRES_CONST_IN_DESIGN_VERSION_STATE_PICK_VALUE SYSRES_CONST_INCOMING_WORK_RULE_TYPE_CODE SYSRES_CONST_INT_REQUISITE SYSRES_CONST_INT_REQUISITE_TYPE SYSRES_CONST_INTEGER_NUMBER_FORMAT_CHAR SYSRES_CONST_INTEGER_TYPE_CHAR SYSRES_CONST_IS_GENERATED_REQUISITE_NEGATIVE_VALUE SYSRES_CONST_IS_PUBLIC_ROLE_REQUISITE_CODE SYSRES_CONST_IS_REMOTE_USER_NEGATIVE_VALUE SYSRES_CONST_IS_REMOTE_USER_POSITIVE_VALUE SYSRES_CONST_IS_STORED_REQUISITE_NEGATIVE_VALUE SYSRES_CONST_IS_STORED_REQUISITE_STORED_VALUE SYSRES_CONST_ITALIC_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_JOB_BLOCK_DESCRIPTION SYSRES_CONST_JOB_KIND_CONTROL_JOB SYSRES_CONST_JOB_KIND_JOB SYSRES_CONST_JOB_KIND_NOTICE SYSRES_CONST_JOB_STATE_ABORTED SYSRES_CONST_JOB_STATE_COMPLETE SYSRES_CONST_JOB_STATE_WORKING SYSRES_CONST_KIND_REQUISITE_CODE SYSRES_CONST_KIND_REQUISITE_NAME SYSRES_CONST_KINDS_CREATE_SHADOW_COPIES_REQUISITE_CODE SYSRES_CONST_KINDS_DEFAULT_EDOC_LIFE_STAGE_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALL_TEPLATES_ALLOWED_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALLOW_LIFE_CYCLE_STAGE_CHANGING_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALLOW_MULTIPLE_ACTIVE_VERSIONS_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_SHARE_ACCES_RIGHTS_BY_DEFAULT_CODE SYSRES_CONST_KINDS_EDOC_TEMPLATE_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_TYPE_REQUISITE_CODE SYSRES_CONST_KINDS_SIGNERS_REQUISITES_CODE SYSRES_CONST_KOD_INPUT_TYPE SYSRES_CONST_LAST_UPDATE_DATE_REQUISITE_CODE SYSRES_CONST_LIFE_CYCLE_START_STAGE_REQUISITE_CODE SYSRES_CONST_LILAC_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_LINK_OBJECT_KIND_COMPONENT SYSRES_CONST_LINK_OBJECT_KIND_DOCUMENT SYSRES_CONST_LINK_OBJECT_KIND_EDOC SYSRES_CONST_LINK_OBJECT_KIND_FOLDER SYSRES_CONST_LINK_OBJECT_KIND_JOB SYSRES_CONST_LINK_OBJECT_KIND_REFERENCE SYSRES_CONST_LINK_OBJECT_KIND_TASK SYSRES_CONST_LINK_REF_TYPE_REQUISITE_CODE SYSRES_CONST_LIST_REFERENCE_MODE_NAME SYSRES_CONST_LOCALIZATION_DICTIONARY_MAIN_VIEW_CODE SYSRES_CONST_MAIN_VIEW_CODE SYSRES_CONST_MANUAL_ENUM_METHOD_FLAG SYSRES_CONST_MASTER_COMP_TYPE_REQUISITE_CODE SYSRES_CONST_MASTER_TABLE_REC_ID_REQUISITE_CODE SYSRES_CONST_MAXIMIZED_MODE_NAME SYSRES_CONST_ME_VALUE SYSRES_CONST_MESSAGE_ATTENTION_CAPTION SYSRES_CONST_MESSAGE_CONFIRMATION_CAPTION SYSRES_CONST_MESSAGE_ERROR_CAPTION SYSRES_CONST_MESSAGE_INFORMATION_CAPTION SYSRES_CONST_MINIMIZED_MODE_NAME SYSRES_CONST_MINUTE_CHAR SYSRES_CONST_MODULE_REQUISITE_CODE SYSRES_CONST_MONITORING_BLOCK_DESCRIPTION SYSRES_CONST_MONTH_FORMAT_VALUE SYSRES_CONST_NAME_LOCALIZE_ID_REQUISITE_CODE SYSRES_CONST_NAME_REQUISITE_CODE SYSRES_CONST_NAME_SINGULAR_REQUISITE_CODE SYSRES_CONST_NAMEAN_INPUT_TYPE SYSRES_CONST_NEGATIVE_PICK_VALUE SYSRES_CONST_NEGATIVE_VALUE SYSRES_CONST_NO SYSRES_CONST_NO_PICK_VALUE SYSRES_CONST_NO_SIGNATURE_REQUISITE_CODE SYSRES_CONST_NO_VALUE SYSRES_CONST_NONE_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_NONOPERATING_RECORD_FLAG_VALUE SYSRES_CONST_NONOPERATING_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_NORMAL_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_NORMAL_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_NORMAL_MODE_NAME SYSRES_CONST_NOT_ALLOWED_ACCESS_TYPE_CODE SYSRES_CONST_NOT_ALLOWED_ACCESS_TYPE_NAME SYSRES_CONST_NOTE_REQUISITE_CODE SYSRES_CONST_NOTICE_BLOCK_DESCRIPTION SYSRES_CONST_NUM_REQUISITE SYSRES_CONST_NUM_STR_REQUISITE_CODE SYSRES_CONST_NUMERATION_AUTO_NOT_STRONG SYSRES_CONST_NUMERATION_AUTO_STRONG SYSRES_CONST_NUMERATION_FROM_DICTONARY SYSRES_CONST_NUMERATION_MANUAL SYSRES_CONST_NUMERIC_TYPE_CHAR SYSRES_CONST_NUMREQ_REQUISITE_CODE SYSRES_CONST_OBSOLETE_VERSION_STATE_PICK_VALUE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_CODE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_FEMININE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_OPTIONAL_FORM_COMP_REQCODE_PREFIX SYSRES_CONST_ORANGE_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_ORIGINALREF_REQUISITE_CODE SYSRES_CONST_OURFIRM_REF_CODE SYSRES_CONST_OURFIRM_REQUISITE_CODE SYSRES_CONST_OURFIRM_VAR SYSRES_CONST_OUTGOING_WORK_RULE_TYPE_CODE SYSRES_CONST_PICK_NEGATIVE_RESULT SYSRES_CONST_PICK_POSITIVE_RESULT SYSRES_CONST_PICK_REQUISITE SYSRES_CONST_PICK_REQUISITE_TYPE SYSRES_CONST_PICK_TYPE_CHAR SYSRES_CONST_PLAN_STATUS_REQUISITE_CODE SYSRES_CONST_PLATFORM_VERSION_COMMENT SYSRES_CONST_PLUGINS_SETTINGS_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_POSITIVE_PICK_VALUE SYSRES_CONST_POWER_TO_CREATE_ACTION_CODE SYSRES_CONST_POWER_TO_SIGN_ACTION_CODE SYSRES_CONST_PRIORITY_REQUISITE_CODE SYSRES_CONST_QUALIFIED_TASK_TYPE SYSRES_CONST_QUALIFIED_TASK_TYPE_CODE SYSRES_CONST_RECSTAT_REQUISITE_CODE SYSRES_CONST_RED_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_REF_ID_T_REF_TYPE_REQUISITE_CODE SYSRES_CONST_REF_REQUISITE SYSRES_CONST_REF_REQUISITE_TYPE SYSRES_CONST_REF_REQUISITES_REFERENCE_CODE_SELECTED_REQUISITE SYSRES_CONST_REFERENCE_RECORD_HISTORY_CREATE_ACTION_CODE SYSRES_CONST_REFERENCE_RECORD_HISTORY_DELETE_ACTION_CODE SYSRES_CONST_REFERENCE_RECORD_HISTORY_MODIFY_ACTION_CODE SYSRES_CONST_REFERENCE_TYPE_CHAR SYSRES_CONST_REFERENCE_TYPE_REQUISITE_NAME SYSRES_CONST_REFERENCES_ADD_PARAMS_REQUISITE_CODE SYSRES_CONST_REFERENCES_DISPLAY_REQUISITE_REQUISITE_CODE SYSRES_CONST_REMOTE_SERVER_STATUS_WORKING SYSRES_CONST_REMOTE_SERVER_TYPE_MAIN SYSRES_CONST_REMOTE_SERVER_TYPE_SECONDARY SYSRES_CONST_REMOTE_USER_FLAG_VALUE_CODE SYSRES_CONST_REPORT_APP_EDITOR_INTERNAL SYSRES_CONST_REPORT_BASE_REPORT_ID_REQUISITE_CODE SYSRES_CONST_REPORT_BASE_REPORT_REQUISITE_CODE SYSRES_CONST_REPORT_SCRIPT_REQUISITE_CODE SYSRES_CONST_REPORT_TEMPLATE_REQUISITE_CODE SYSRES_CONST_REPORT_VIEWER_CODE_REQUISITE_CODE SYSRES_CONST_REQ_ALLOW_COMPONENT_DEFAULT_VALUE SYSRES_CONST_REQ_ALLOW_RECORD_DEFAULT_VALUE SYSRES_CONST_REQ_ALLOW_SERVER_COMPONENT_DEFAULT_VALUE SYSRES_CONST_REQ_MODE_AVAILABLE_CODE SYSRES_CONST_REQ_MODE_EDIT_CODE SYSRES_CONST_REQ_MODE_HIDDEN_CODE SYSRES_CONST_REQ_MODE_NOT_AVAILABLE_CODE SYSRES_CONST_REQ_MODE_VIEW_CODE SYSRES_CONST_REQ_NUMBER_REQUISITE_CODE SYSRES_CONST_REQ_SECTION_VALUE SYSRES_CONST_REQ_TYPE_VALUE SYSRES_CONST_REQUISITE_FORMAT_BY_UNIT SYSRES_CONST_REQUISITE_FORMAT_DATE_FULL SYSRES_CONST_REQUISITE_FORMAT_DATE_TIME SYSRES_CONST_REQUISITE_FORMAT_LEFT SYSRES_CONST_REQUISITE_FORMAT_RIGHT SYSRES_CONST_REQUISITE_FORMAT_WITHOUT_UNIT SYSRES_CONST_REQUISITE_NUMBER_REQUISITE_CODE SYSRES_CONST_REQUISITE_SECTION_ACTIONS SYSRES_CONST_REQUISITE_SECTION_BUTTON SYSRES_CONST_REQUISITE_SECTION_BUTTONS SYSRES_CONST_REQUISITE_SECTION_CARD SYSRES_CONST_REQUISITE_SECTION_TABLE SYSRES_CONST_REQUISITE_SECTION_TABLE10 SYSRES_CONST_REQUISITE_SECTION_TABLE11 SYSRES_CONST_REQUISITE_SECTION_TABLE12 SYSRES_CONST_REQUISITE_SECTION_TABLE13 SYSRES_CONST_REQUISITE_SECTION_TABLE14 SYSRES_CONST_REQUISITE_SECTION_TABLE15 SYSRES_CONST_REQUISITE_SECTION_TABLE16 SYSRES_CONST_REQUISITE_SECTION_TABLE17 SYSRES_CONST_REQUISITE_SECTION_TABLE18 SYSRES_CONST_REQUISITE_SECTION_TABLE19 SYSRES_CONST_REQUISITE_SECTION_TABLE2 SYSRES_CONST_REQUISITE_SECTION_TABLE20 SYSRES_CONST_REQUISITE_SECTION_TABLE21 SYSRES_CONST_REQUISITE_SECTION_TABLE22 SYSRES_CONST_REQUISITE_SECTION_TABLE23 SYSRES_CONST_REQUISITE_SECTION_TABLE24 SYSRES_CONST_REQUISITE_SECTION_TABLE3 SYSRES_CONST_REQUISITE_SECTION_TABLE4 SYSRES_CONST_REQUISITE_SECTION_TABLE5 SYSRES_CONST_REQUISITE_SECTION_TABLE6 SYSRES_CONST_REQUISITE_SECTION_TABLE7 SYSRES_CONST_REQUISITE_SECTION_TABLE8 SYSRES_CONST_REQUISITE_SECTION_TABLE9 SYSRES_CONST_REQUISITES_PSEUDOREFERENCE_REQUISITE_NUMBER_REQUISITE_CODE SYSRES_CONST_RIGHT_ALIGNMENT_CODE SYSRES_CONST_ROLES_REFERENCE_CODE SYSRES_CONST_ROUTE_STEP_AFTER_RUS SYSRES_CONST_ROUTE_STEP_AND_CONDITION_RUS SYSRES_CONST_ROUTE_STEP_OR_CONDITION_RUS SYSRES_CONST_ROUTE_TYPE_COMPLEX SYSRES_CONST_ROUTE_TYPE_PARALLEL SYSRES_CONST_ROUTE_TYPE_SERIAL SYSRES_CONST_SBDATASETDESC_NEGATIVE_VALUE SYSRES_CONST_SBDATASETDESC_POSITIVE_VALUE SYSRES_CONST_SBVIEWSDESC_POSITIVE_VALUE SYSRES_CONST_SCRIPT_BLOCK_DESCRIPTION SYSRES_CONST_SEARCH_BY_TEXT_REQUISITE_CODE SYSRES_CONST_SEARCHES_COMPONENT_CONTENT SYSRES_CONST_SEARCHES_CRITERIA_ACTION_NAME SYSRES_CONST_SEARCHES_EDOC_CONTENT SYSRES_CONST_SEARCHES_FOLDER_CONTENT SYSRES_CONST_SEARCHES_JOB_CONTENT SYSRES_CONST_SEARCHES_REFERENCE_CODE SYSRES_CONST_SEARCHES_TASK_CONTENT SYSRES_CONST_SECOND_CHAR SYSRES_CONST_SECTION_REQUISITE_ACTIONS_VALUE SYSRES_CONST_SECTION_REQUISITE_CARD_VALUE SYSRES_CONST_SECTION_REQUISITE_CODE SYSRES_CONST_SECTION_REQUISITE_DETAIL_1_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_2_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_3_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_4_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_5_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_6_VALUE SYSRES_CONST_SELECT_REFERENCE_MODE_NAME SYSRES_CONST_SELECT_TYPE_SELECTABLE SYSRES_CONST_SELECT_TYPE_SELECTABLE_ONLY_CHILD SYSRES_CONST_SELECT_TYPE_SELECTABLE_WITH_CHILD SYSRES_CONST_SELECT_TYPE_UNSLECTABLE SYSRES_CONST_SERVER_TYPE_MAIN SYSRES_CONST_SERVICE_USER_CATEGORY_FIELD_VALUE SYSRES_CONST_SETTINGS_USER_REQUISITE_CODE SYSRES_CONST_SIGNATURE_AND_ENCODE_CERTIFICATE_TYPE_CODE SYSRES_CONST_SIGNATURE_CERTIFICATE_TYPE_CODE SYSRES_CONST_SINGULAR_TITLE_REQUISITE_CODE SYSRES_CONST_SQL_SERVER_AUTHENTIFICATION_FLAG_VALUE_CODE SYSRES_CONST_SQL_SERVER_ENCODE_AUTHENTIFICATION_FLAG_VALUE_CODE SYSRES_CONST_STANDART_ROUTE_REFERENCE_CODE SYSRES_CONST_STANDART_ROUTE_REFERENCE_COMMENT_REQUISITE_CODE SYSRES_CONST_STANDART_ROUTES_GROUPS_REFERENCE_CODE SYSRES_CONST_STATE_REQ_NAME SYSRES_CONST_STATE_REQUISITE_ACTIVE_VALUE SYSRES_CONST_STATE_REQUISITE_CLOSED_VALUE SYSRES_CONST_STATE_REQUISITE_CODE SYSRES_CONST_STATIC_ROLE_TYPE_CODE SYSRES_CONST_STATUS_PLAN_DEFAULT_VALUE SYSRES_CONST_STATUS_VALUE_AUTOCLEANING SYSRES_CONST_STATUS_VALUE_BLUE_SQUARE SYSRES_CONST_STATUS_VALUE_COMPLETE SYSRES_CONST_STATUS_VALUE_GREEN_SQUARE SYSRES_CONST_STATUS_VALUE_ORANGE_SQUARE SYSRES_CONST_STATUS_VALUE_PURPLE_SQUARE SYSRES_CONST_STATUS_VALUE_RED_SQUARE SYSRES_CONST_STATUS_VALUE_SUSPEND SYSRES_CONST_STATUS_VALUE_YELLOW_SQUARE SYSRES_CONST_STDROUTE_SHOW_TO_USERS_REQUISITE_CODE SYSRES_CONST_STORAGE_TYPE_FILE SYSRES_CONST_STORAGE_TYPE_SQL_SERVER SYSRES_CONST_STR_REQUISITE SYSRES_CONST_STRIKEOUT_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_STRING_FORMAT_LEFT_ALIGN_CHAR SYSRES_CONST_STRING_FORMAT_RIGHT_ALIGN_CHAR SYSRES_CONST_STRING_REQUISITE_CODE SYSRES_CONST_STRING_REQUISITE_TYPE SYSRES_CONST_STRING_TYPE_CHAR SYSRES_CONST_SUBSTITUTES_PSEUDOREFERENCE_CODE SYSRES_CONST_SUBTASK_BLOCK_DESCRIPTION SYSRES_CONST_SYSTEM_SETTING_CURRENT_USER_PARAM_VALUE SYSRES_CONST_SYSTEM_SETTING_EMPTY_VALUE_PARAM_VALUE SYSRES_CONST_SYSTEM_VERSION_COMMENT SYSRES_CONST_TASK_ACCESS_TYPE_ALL SYSRES_CONST_TASK_ACCESS_TYPE_ALL_MEMBERS SYSRES_CONST_TASK_ACCESS_TYPE_MANUAL SYSRES_CONST_TASK_ENCODE_TYPE_CERTIFICATION SYSRES_CONST_TASK_ENCODE_TYPE_CERTIFICATION_AND_PASSWORD SYSRES_CONST_TASK_ENCODE_TYPE_NONE SYSRES_CONST_TASK_ENCODE_TYPE_PASSWORD SYSRES_CONST_TASK_ROUTE_ALL_CONDITION SYSRES_CONST_TASK_ROUTE_AND_CONDITION SYSRES_CONST_TASK_ROUTE_OR_CONDITION SYSRES_CONST_TASK_STATE_ABORTED SYSRES_CONST_TASK_STATE_COMPLETE SYSRES_CONST_TASK_STATE_CONTINUED SYSRES_CONST_TASK_STATE_CONTROL SYSRES_CONST_TASK_STATE_INIT SYSRES_CONST_TASK_STATE_WORKING SYSRES_CONST_TASK_TITLE SYSRES_CONST_TASK_TYPES_GROUPS_REFERENCE_CODE SYSRES_CONST_TASK_TYPES_REFERENCE_CODE SYSRES_CONST_TEMPLATES_REFERENCE_CODE SYSRES_CONST_TEST_DATE_REQUISITE_NAME SYSRES_CONST_TEST_DEV_DATABASE_NAME SYSRES_CONST_TEST_DEV_SYSTEM_CODE SYSRES_CONST_TEST_EDMS_DATABASE_NAME SYSRES_CONST_TEST_EDMS_MAIN_CODE SYSRES_CONST_TEST_EDMS_MAIN_DB_NAME SYSRES_CONST_TEST_EDMS_SECOND_CODE SYSRES_CONST_TEST_EDMS_SECOND_DB_NAME SYSRES_CONST_TEST_EDMS_SYSTEM_CODE SYSRES_CONST_TEST_NUMERIC_REQUISITE_NAME SYSRES_CONST_TEXT_REQUISITE SYSRES_CONST_TEXT_REQUISITE_CODE SYSRES_CONST_TEXT_REQUISITE_TYPE SYSRES_CONST_TEXT_TYPE_CHAR SYSRES_CONST_TYPE_CODE_REQUISITE_CODE SYSRES_CONST_TYPE_REQUISITE_CODE SYSRES_CONST_UNDEFINED_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_UNITS_SECTION_ID_REQUISITE_CODE SYSRES_CONST_UNITS_SECTION_REQUISITE_CODE SYSRES_CONST_UNOPERATING_RECORD_FLAG_VALUE_CODE SYSRES_CONST_UNSTORED_DATA_REQUISITE_CODE SYSRES_CONST_UNSTORED_DATA_REQUISITE_NAME SYSRES_CONST_USE_ACCESS_TYPE_CODE SYSRES_CONST_USE_ACCESS_TYPE_NAME SYSRES_CONST_USER_ACCOUNT_TYPE_VALUE_CODE SYSRES_CONST_USER_ADDITIONAL_INFORMATION_REQUISITE_CODE SYSRES_CONST_USER_AND_GROUP_ID_FROM_PSEUDOREFERENCE_REQUISITE_CODE SYSRES_CONST_USER_CATEGORY_NORMAL SYSRES_CONST_USER_CERTIFICATE_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_STATE_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_SUBJECT_NAME_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_THUMBPRINT_REQUISITE_CODE SYSRES_CONST_USER_COMMON_CATEGORY SYSRES_CONST_USER_COMMON_CATEGORY_CODE SYSRES_CONST_USER_FULL_NAME_REQUISITE_CODE SYSRES_CONST_USER_GROUP_TYPE_REQUISITE_CODE SYSRES_CONST_USER_LOGIN_REQUISITE_CODE SYSRES_CONST_USER_REMOTE_CONTROLLER_REQUISITE_CODE SYSRES_CONST_USER_REMOTE_SYSTEM_REQUISITE_CODE SYSRES_CONST_USER_RIGHTS_T_REQUISITE_CODE SYSRES_CONST_USER_SERVER_NAME_REQUISITE_CODE SYSRES_CONST_USER_SERVICE_CATEGORY SYSRES_CONST_USER_SERVICE_CATEGORY_CODE SYSRES_CONST_USER_STATUS_ADMINISTRATOR_CODE SYSRES_CONST_USER_STATUS_ADMINISTRATOR_NAME SYSRES_CONST_USER_STATUS_DEVELOPER_CODE SYSRES_CONST_USER_STATUS_DEVELOPER_NAME SYSRES_CONST_USER_STATUS_DISABLED_CODE SYSRES_CONST_USER_STATUS_DISABLED_NAME SYSRES_CONST_USER_STATUS_SYSTEM_DEVELOPER_CODE SYSRES_CONST_USER_STATUS_USER_CODE SYSRES_CONST_USER_STATUS_USER_NAME SYSRES_CONST_USER_STATUS_USER_NAME_DEPRECATED SYSRES_CONST_USER_TYPE_FIELD_VALUE_USER SYSRES_CONST_USER_TYPE_REQUISITE_CODE SYSRES_CONST_USERS_CONTROLLER_REQUISITE_CODE SYSRES_CONST_USERS_IS_MAIN_SERVER_REQUISITE_CODE SYSRES_CONST_USERS_REFERENCE_CODE SYSRES_CONST_USERS_REGISTRATION_CERTIFICATES_ACTION_NAME SYSRES_CONST_USERS_REQUISITE_CODE SYSRES_CONST_USERS_SYSTEM_REQUISITE_CODE SYSRES_CONST_USERS_USER_ACCESS_RIGHTS_TYPR_REQUISITE_CODE SYSRES_CONST_USERS_USER_AUTHENTICATION_REQUISITE_CODE SYSRES_CONST_USERS_USER_COMPONENT_REQUISITE_CODE SYSRES_CONST_USERS_USER_GROUP_REQUISITE_CODE SYSRES_CONST_USERS_VIEW_CERTIFICATES_ACTION_NAME SYSRES_CONST_VIEW_DEFAULT_CODE SYSRES_CONST_VIEW_DEFAULT_NAME SYSRES_CONST_VIEWER_REQUISITE_CODE SYSRES_CONST_WAITING_BLOCK_DESCRIPTION SYSRES_CONST_WIZARD_FORM_LABEL_TEST_STRING  SYSRES_CONST_WIZARD_QUERY_PARAM_HEIGHT_ETALON_STRING SYSRES_CONST_WIZARD_REFERENCE_COMMENT_REQUISITE_CODE SYSRES_CONST_WORK_RULES_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_WORK_TIME_CALENDAR_REFERENCE_CODE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE_CODE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE_CODE_RUS SYSRES_CONST_WORK_WORKFLOW_SOFT_ROUTE_TYPE_VALUE_CODE_RUS SYSRES_CONST_WORKFLOW_ROUTE_TYPR_HARD SYSRES_CONST_WORKFLOW_ROUTE_TYPR_SOFT SYSRES_CONST_XML_ENCODING SYSRES_CONST_XREC_STAT_REQUISITE_CODE SYSRES_CONST_XRECID_FIELD_NAME SYSRES_CONST_YES SYSRES_CONST_YES_NO_2_REQUISITE_CODE SYSRES_CONST_YES_NO_REQUISITE_CODE SYSRES_CONST_YES_NO_T_REF_TYPE_REQUISITE_CODE SYSRES_CONST_YES_PICK_VALUE SYSRES_CONST_YES_VALUE CR FALSE nil NO_VALUE NULL TAB TRUE YES_VALUE ADMINISTRATORS_GROUP_NAME CUSTOMIZERS_GROUP_NAME DEVELOPERS_GROUP_NAME SERVICE_USERS_GROUP_NAME DECISION_BLOCK_FIRST_OPERAND_PROPERTY DECISION_BLOCK_NAME_PROPERTY DECISION_BLOCK_OPERATION_PROPERTY DECISION_BLOCK_RESULT_TYPE_PROPERTY DECISION_BLOCK_SECOND_OPERAND_PROPERTY ANY_FILE_EXTENTION COMPRESSED_DOCUMENT_EXTENSION EXTENDED_DOCUMENT_EXTENSION SHORT_COMPRESSED_DOCUMENT_EXTENSION SHORT_EXTENDED_DOCUMENT_EXTENSION JOB_BLOCK_ABORT_DEADLINE_PROPERTY JOB_BLOCK_AFTER_FINISH_EVENT JOB_BLOCK_AFTER_QUERY_PARAMETERS_EVENT JOB_BLOCK_ATTACHMENT_PROPERTY JOB_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY JOB_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY JOB_BLOCK_BEFORE_QUERY_PARAMETERS_EVENT JOB_BLOCK_BEFORE_START_EVENT JOB_BLOCK_CREATED_JOBS_PROPERTY JOB_BLOCK_DEADLINE_PROPERTY JOB_BLOCK_EXECUTION_RESULTS_PROPERTY JOB_BLOCK_IS_PARALLEL_PROPERTY JOB_BLOCK_IS_RELATIVE_ABORT_DEADLINE_PROPERTY JOB_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY JOB_BLOCK_JOB_TEXT_PROPERTY JOB_BLOCK_NAME_PROPERTY JOB_BLOCK_NEED_SIGN_ON_PERFORM_PROPERTY JOB_BLOCK_PERFORMER_PROPERTY JOB_BLOCK_RELATIVE_ABORT_DEADLINE_TYPE_PROPERTY JOB_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY JOB_BLOCK_SUBJECT_PROPERTY ENGLISH_LANGUAGE_CODE RUSSIAN_LANGUAGE_CODE smHidden smMaximized smMinimized smNormal wmNo wmYes COMPONENT_TOKEN_LINK_KIND DOCUMENT_LINK_KIND EDOCUMENT_LINK_KIND FOLDER_LINK_KIND JOB_LINK_KIND REFERENCE_LINK_KIND TASK_LINK_KIND COMPONENT_TOKEN_LOCK_TYPE EDOCUMENT_VERSION_LOCK_TYPE MONITOR_BLOCK_AFTER_FINISH_EVENT MONITOR_BLOCK_BEFORE_START_EVENT MONITOR_BLOCK_DEADLINE_PROPERTY MONITOR_BLOCK_INTERVAL_PROPERTY MONITOR_BLOCK_INTERVAL_TYPE_PROPERTY MONITOR_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY MONITOR_BLOCK_NAME_PROPERTY MONITOR_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY MONITOR_BLOCK_SEARCH_SCRIPT_PROPERTY NOTICE_BLOCK_AFTER_FINISH_EVENT NOTICE_BLOCK_ATTACHMENT_PROPERTY NOTICE_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY NOTICE_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY NOTICE_BLOCK_BEFORE_START_EVENT NOTICE_BLOCK_CREATED_NOTICES_PROPERTY NOTICE_BLOCK_DEADLINE_PROPERTY NOTICE_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY NOTICE_BLOCK_NAME_PROPERTY NOTICE_BLOCK_NOTICE_TEXT_PROPERTY NOTICE_BLOCK_PERFORMER_PROPERTY NOTICE_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY NOTICE_BLOCK_SUBJECT_PROPERTY dseAfterCancel dseAfterClose dseAfterDelete dseAfterDeleteOutOfTransaction dseAfterInsert dseAfterOpen dseAfterScroll dseAfterUpdate dseAfterUpdateOutOfTransaction dseBeforeCancel dseBeforeClose dseBeforeDelete dseBeforeDetailUpdate dseBeforeInsert dseBeforeOpen dseBeforeUpdate dseOnAnyRequisiteChange dseOnCloseRecord dseOnDeleteError dseOnOpenRecord dseOnPrepareUpdate dseOnUpdateError dseOnUpdateRatifiedRecord dseOnValidDelete dseOnValidUpdate reOnChange reOnChangeValues SELECTION_BEGIN_ROUTE_EVENT SELECTION_END_ROUTE_EVENT CURRENT_PERIOD_IS_REQUIRED PREVIOUS_CARD_TYPE_NAME SHOW_RECORD_PROPERTIES_FORM ACCESS_RIGHTS_SETTING_DIALOG_CODE ADMINISTRATOR_USER_CODE ANALYTIC_REPORT_TYPE asrtHideLocal asrtHideRemote CALCULATED_ROLE_TYPE_CODE COMPONENTS_REFERENCE_DEVELOPER_VIEW_CODE DCTS_TEST_PROTOCOLS_FOLDER_PATH E_EDOC_VERSION_ALREADY_APPROVINGLY_SIGNED E_EDOC_VERSION_ALREADY_APPROVINGLY_SIGNED_BY_USER E_EDOC_VERSION_ALREDY_SIGNED E_EDOC_VERSION_ALREDY_SIGNED_BY_USER EDOC_TYPES_CODE_REQUISITE_FIELD_NAME EDOCUMENTS_ALIAS_NAME FILES_FOLDER_PATH FILTER_OPERANDS_DELIMITER FILTER_OPERATIONS_DELIMITER FORMCARD_NAME FORMLIST_NAME GET_EXTENDED_DOCUMENT_EXTENSION_CREATION_MODE GET_EXTENDED_DOCUMENT_EXTENSION_IMPORT_MODE INTEGRATED_REPORT_TYPE IS_BUILDER_APPLICATION_ROLE IS_BUILDER_APPLICATION_ROLE2 IS_BUILDER_USERS ISBSYSDEV LOG_FOLDER_PATH mbCancel mbNo mbNoToAll mbOK mbYes mbYesToAll MEMORY_DATASET_DESRIPTIONS_FILENAME mrNo mrNoToAll mrYes mrYesToAll MULTIPLE_SELECT_DIALOG_CODE NONOPERATING_RECORD_FLAG_FEMININE NONOPERATING_RECORD_FLAG_MASCULINE OPERATING_RECORD_FLAG_FEMININE OPERATING_RECORD_FLAG_MASCULINE PROFILING_SETTINGS_COMMON_SETTINGS_CODE_VALUE PROGRAM_INITIATED_LOOKUP_ACTION ratDelete ratEdit ratInsert REPORT_TYPE REQUIRED_PICK_VALUES_VARIABLE rmCard rmList SBRTE_PROGID_DEV SBRTE_PROGID_RELEASE STATIC_ROLE_TYPE_CODE SUPPRESS_EMPTY_TEMPLATE_CREATION SYSTEM_USER_CODE UPDATE_DIALOG_DATASET USED_IN_OBJECT_HINT_PARAM USER_INITIATED_LOOKUP_ACTION USER_NAME_FORMAT USER_SELECTION_RESTRICTIONS WORKFLOW_TEST_PROTOCOLS_FOLDER_PATH ELS_SUBTYPE_CONTROL_NAME ELS_FOLDER_KIND_CONTROL_NAME REPEAT_PROCESS_CURRENT_OBJECT_EXCEPTION_NAME PRIVILEGE_COMPONENT_FULL_ACCESS PRIVILEGE_DEVELOPMENT_EXPORT PRIVILEGE_DEVELOPMENT_IMPORT PRIVILEGE_DOCUMENT_DELETE PRIVILEGE_ESD PRIVILEGE_FOLDER_DELETE PRIVILEGE_MANAGE_ACCESS_RIGHTS PRIVILEGE_MANAGE_REPLICATION PRIVILEGE_MANAGE_SESSION_SERVER PRIVILEGE_OBJECT_FULL_ACCESS PRIVILEGE_OBJECT_VIEW PRIVILEGE_RESERVE_LICENSE PRIVILEGE_SYSTEM_CUSTOMIZE PRIVILEGE_SYSTEM_DEVELOP PRIVILEGE_SYSTEM_INSTALL PRIVILEGE_TASK_DELETE PRIVILEGE_USER_PLUGIN_SETTINGS_CUSTOMIZE PRIVILEGES_PSEUDOREFERENCE_CODE ACCESS_TYPES_PSEUDOREFERENCE_CODE ALL_AVAILABLE_COMPONENTS_PSEUDOREFERENCE_CODE ALL_AVAILABLE_PRIVILEGES_PSEUDOREFERENCE_CODE ALL_REPLICATE_COMPONENTS_PSEUDOREFERENCE_CODE AVAILABLE_DEVELOPERS_COMPONENTS_PSEUDOREFERENCE_CODE COMPONENTS_PSEUDOREFERENCE_CODE FILTRATER_SETTINGS_CONFLICTS_PSEUDOREFERENCE_CODE GROUPS_PSEUDOREFERENCE_CODE RECEIVE_PROTOCOL_PSEUDOREFERENCE_CODE REFERENCE_REQUISITE_PSEUDOREFERENCE_CODE REFERENCE_REQUISITES_PSEUDOREFERENCE_CODE REFTYPES_PSEUDOREFERENCE_CODE REPLICATION_SEANCES_DIARY_PSEUDOREFERENCE_CODE SEND_PROTOCOL_PSEUDOREFERENCE_CODE SUBSTITUTES_PSEUDOREFERENCE_CODE SYSTEM_SETTINGS_PSEUDOREFERENCE_CODE UNITS_PSEUDOREFERENCE_CODE USERS_PSEUDOREFERENCE_CODE VIEWERS_PSEUDOREFERENCE_CODE CERTIFICATE_TYPE_ENCRYPT CERTIFICATE_TYPE_SIGN CERTIFICATE_TYPE_SIGN_AND_ENCRYPT STORAGE_TYPE_FILE STORAGE_TYPE_NAS_CIFS STORAGE_TYPE_SAPERION STORAGE_TYPE_SQL_SERVER COMPTYPE2_REQUISITE_DOCUMENTS_VALUE COMPTYPE2_REQUISITE_TASKS_VALUE COMPTYPE2_REQUISITE_FOLDERS_VALUE COMPTYPE2_REQUISITE_REFERENCES_VALUE SYSREQ_CODE SYSREQ_COMPTYPE2 SYSREQ_CONST_AVAILABLE_FOR_WEB SYSREQ_CONST_COMMON_CODE SYSREQ_CONST_COMMON_VALUE SYSREQ_CONST_FIRM_CODE SYSREQ_CONST_FIRM_STATUS SYSREQ_CONST_FIRM_VALUE SYSREQ_CONST_SERVER_STATUS SYSREQ_CONTENTS SYSREQ_DATE_OPEN SYSREQ_DATE_CLOSE SYSREQ_DESCRIPTION SYSREQ_DESCRIPTION_LOCALIZE_ID SYSREQ_DOUBLE SYSREQ_EDOC_ACCESS_TYPE SYSREQ_EDOC_AUTHOR SYSREQ_EDOC_CREATED SYSREQ_EDOC_DELEGATE_RIGHTS_REQUISITE_CODE SYSREQ_EDOC_EDITOR SYSREQ_EDOC_ENCODE_TYPE SYSREQ_EDOC_ENCRYPTION_PLUGIN_NAME SYSREQ_EDOC_ENCRYPTION_PLUGIN_VERSION SYSREQ_EDOC_EXPORT_DATE SYSREQ_EDOC_EXPORTER SYSREQ_EDOC_KIND SYSREQ_EDOC_LIFE_STAGE_NAME SYSREQ_EDOC_LOCKED_FOR_SERVER_CODE SYSREQ_EDOC_MODIFIED SYSREQ_EDOC_NAME SYSREQ_EDOC_NOTE SYSREQ_EDOC_QUALIFIED_ID SYSREQ_EDOC_SESSION_KEY SYSREQ_EDOC_SESSION_KEY_ENCRYPTION_PLUGIN_NAME SYSREQ_EDOC_SESSION_KEY_ENCRYPTION_PLUGIN_VERSION SYSREQ_EDOC_SIGNATURE_TYPE SYSREQ_EDOC_SIGNED SYSREQ_EDOC_STORAGE SYSREQ_EDOC_STORAGES_ARCHIVE_STORAGE SYSREQ_EDOC_STORAGES_CHECK_RIGHTS SYSREQ_EDOC_STORAGES_COMPUTER_NAME SYSREQ_EDOC_STORAGES_EDIT_IN_STORAGE SYSREQ_EDOC_STORAGES_EXECUTIVE_STORAGE SYSREQ_EDOC_STORAGES_FUNCTION SYSREQ_EDOC_STORAGES_INITIALIZED SYSREQ_EDOC_STORAGES_LOCAL_PATH SYSREQ_EDOC_STORAGES_SAPERION_DATABASE_NAME SYSREQ_EDOC_STORAGES_SEARCH_BY_TEXT SYSREQ_EDOC_STORAGES_SERVER_NAME SYSREQ_EDOC_STORAGES_SHARED_SOURCE_NAME SYSREQ_EDOC_STORAGES_TYPE SYSREQ_EDOC_TEXT_MODIFIED SYSREQ_EDOC_TYPE_ACT_CODE SYSREQ_EDOC_TYPE_ACT_DESCRIPTION SYSREQ_EDOC_TYPE_ACT_DESCRIPTION_LOCALIZE_ID SYSREQ_EDOC_TYPE_ACT_ON_EXECUTE SYSREQ_EDOC_TYPE_ACT_ON_EXECUTE_EXISTS SYSREQ_EDOC_TYPE_ACT_SECTION SYSREQ_EDOC_TYPE_ADD_PARAMS SYSREQ_EDOC_TYPE_COMMENT SYSREQ_EDOC_TYPE_EVENT_TEXT SYSREQ_EDOC_TYPE_NAME_IN_SINGULAR SYSREQ_EDOC_TYPE_NAME_IN_SINGULAR_LOCALIZE_ID SYSREQ_EDOC_TYPE_NAME_LOCALIZE_ID SYSREQ_EDOC_TYPE_NUMERATION_METHOD SYSREQ_EDOC_TYPE_PSEUDO_REQUISITE_CODE SYSREQ_EDOC_TYPE_REQ_CODE SYSREQ_EDOC_TYPE_REQ_DESCRIPTION SYSREQ_EDOC_TYPE_REQ_DESCRIPTION_LOCALIZE_ID SYSREQ_EDOC_TYPE_REQ_IS_LEADING SYSREQ_EDOC_TYPE_REQ_IS_REQUIRED SYSREQ_EDOC_TYPE_REQ_NUMBER SYSREQ_EDOC_TYPE_REQ_ON_CHANGE SYSREQ_EDOC_TYPE_REQ_ON_CHANGE_EXISTS SYSREQ_EDOC_TYPE_REQ_ON_SELECT SYSREQ_EDOC_TYPE_REQ_ON_SELECT_KIND SYSREQ_EDOC_TYPE_REQ_SECTION SYSREQ_EDOC_TYPE_VIEW_CARD SYSREQ_EDOC_TYPE_VIEW_CODE SYSREQ_EDOC_TYPE_VIEW_COMMENT SYSREQ_EDOC_TYPE_VIEW_IS_MAIN SYSREQ_EDOC_TYPE_VIEW_NAME SYSREQ_EDOC_TYPE_VIEW_NAME_LOCALIZE_ID SYSREQ_EDOC_VERSION_AUTHOR SYSREQ_EDOC_VERSION_CRC SYSREQ_EDOC_VERSION_DATA SYSREQ_EDOC_VERSION_EDITOR SYSREQ_EDOC_VERSION_EXPORT_DATE SYSREQ_EDOC_VERSION_EXPORTER SYSREQ_EDOC_VERSION_HIDDEN SYSREQ_EDOC_VERSION_LIFE_STAGE SYSREQ_EDOC_VERSION_MODIFIED SYSREQ_EDOC_VERSION_NOTE SYSREQ_EDOC_VERSION_SIGNATURE_TYPE SYSREQ_EDOC_VERSION_SIGNED SYSREQ_EDOC_VERSION_SIZE SYSREQ_EDOC_VERSION_SOURCE SYSREQ_EDOC_VERSION_TEXT_MODIFIED SYSREQ_EDOCKIND_DEFAULT_VERSION_STATE_CODE SYSREQ_FOLDER_KIND SYSREQ_FUNC_CATEGORY SYSREQ_FUNC_COMMENT SYSREQ_FUNC_GROUP SYSREQ_FUNC_GROUP_COMMENT SYSREQ_FUNC_GROUP_NUMBER SYSREQ_FUNC_HELP SYSREQ_FUNC_PARAM_DEF_VALUE SYSREQ_FUNC_PARAM_IDENT SYSREQ_FUNC_PARAM_NUMBER SYSREQ_FUNC_PARAM_TYPE SYSREQ_FUNC_TEXT SYSREQ_GROUP_CATEGORY SYSREQ_ID SYSREQ_LAST_UPDATE SYSREQ_LEADER_REFERENCE SYSREQ_LINE_NUMBER SYSREQ_MAIN_RECORD_ID SYSREQ_NAME SYSREQ_NAME_LOCALIZE_ID SYSREQ_NOTE SYSREQ_ORIGINAL_RECORD SYSREQ_OUR_FIRM SYSREQ_PROFILING_SETTINGS_BATCH_LOGING SYSREQ_PROFILING_SETTINGS_BATCH_SIZE SYSREQ_PROFILING_SETTINGS_PROFILING_ENABLED SYSREQ_PROFILING_SETTINGS_SQL_PROFILING_ENABLED SYSREQ_PROFILING_SETTINGS_START_LOGGED SYSREQ_RECORD_STATUS SYSREQ_REF_REQ_FIELD_NAME SYSREQ_REF_REQ_FORMAT SYSREQ_REF_REQ_GENERATED SYSREQ_REF_REQ_LENGTH SYSREQ_REF_REQ_PRECISION SYSREQ_REF_REQ_REFERENCE SYSREQ_REF_REQ_SECTION SYSREQ_REF_REQ_STORED SYSREQ_REF_REQ_TOKENS SYSREQ_REF_REQ_TYPE SYSREQ_REF_REQ_VIEW SYSREQ_REF_TYPE_ACT_CODE SYSREQ_REF_TYPE_ACT_DESCRIPTION SYSREQ_REF_TYPE_ACT_DESCRIPTION_LOCALIZE_ID SYSREQ_REF_TYPE_ACT_ON_EXECUTE SYSREQ_REF_TYPE_ACT_ON_EXECUTE_EXISTS SYSREQ_REF_TYPE_ACT_SECTION SYSREQ_REF_TYPE_ADD_PARAMS SYSREQ_REF_TYPE_COMMENT SYSREQ_REF_TYPE_COMMON_SETTINGS SYSREQ_REF_TYPE_DISPLAY_REQUISITE_NAME SYSREQ_REF_TYPE_EVENT_TEXT SYSREQ_REF_TYPE_MAIN_LEADING_REF SYSREQ_REF_TYPE_NAME_IN_SINGULAR SYSREQ_REF_TYPE_NAME_IN_SINGULAR_LOCALIZE_ID SYSREQ_REF_TYPE_NAME_LOCALIZE_ID SYSREQ_REF_TYPE_NUMERATION_METHOD SYSREQ_REF_TYPE_REQ_CODE SYSREQ_REF_TYPE_REQ_DESCRIPTION SYSREQ_REF_TYPE_REQ_DESCRIPTION_LOCALIZE_ID SYSREQ_REF_TYPE_REQ_IS_CONTROL SYSREQ_REF_TYPE_REQ_IS_FILTER SYSREQ_REF_TYPE_REQ_IS_LEADING SYSREQ_REF_TYPE_REQ_IS_REQUIRED SYSREQ_REF_TYPE_REQ_NUMBER SYSREQ_REF_TYPE_REQ_ON_CHANGE SYSREQ_REF_TYPE_REQ_ON_CHANGE_EXISTS SYSREQ_REF_TYPE_REQ_ON_SELECT SYSREQ_REF_TYPE_REQ_ON_SELECT_KIND SYSREQ_REF_TYPE_REQ_SECTION SYSREQ_REF_TYPE_VIEW_CARD SYSREQ_REF_TYPE_VIEW_CODE SYSREQ_REF_TYPE_VIEW_COMMENT SYSREQ_REF_TYPE_VIEW_IS_MAIN SYSREQ_REF_TYPE_VIEW_NAME SYSREQ_REF_TYPE_VIEW_NAME_LOCALIZE_ID SYSREQ_REFERENCE_TYPE_ID SYSREQ_STATE SYSREQ_STAT\u0415 SYSREQ_SYSTEM_SETTINGS_VALUE SYSREQ_TYPE SYSREQ_UNIT SYSREQ_UNIT_ID SYSREQ_USER_GROUPS_GROUP_FULL_NAME SYSREQ_USER_GROUPS_GROUP_NAME SYSREQ_USER_GROUPS_GROUP_SERVER_NAME SYSREQ_USERS_ACCESS_RIGHTS SYSREQ_USERS_AUTHENTICATION SYSREQ_USERS_CATEGORY SYSREQ_USERS_COMPONENT SYSREQ_USERS_COMPONENT_USER_IS_PUBLIC SYSREQ_USERS_DOMAIN SYSREQ_USERS_FULL_USER_NAME SYSREQ_USERS_GROUP SYSREQ_USERS_IS_MAIN_SERVER SYSREQ_USERS_LOGIN SYSREQ_USERS_REFERENCE_USER_IS_PUBLIC SYSREQ_USERS_STATUS SYSREQ_USERS_USER_CERTIFICATE SYSREQ_USERS_USER_CERTIFICATE_INFO SYSREQ_USERS_USER_CERTIFICATE_PLUGIN_NAME SYSREQ_USERS_USER_CERTIFICATE_PLUGIN_VERSION SYSREQ_USERS_USER_CERTIFICATE_STATE SYSREQ_USERS_USER_CERTIFICATE_SUBJECT_NAME SYSREQ_USERS_USER_CERTIFICATE_THUMBPRINT SYSREQ_USERS_USER_DEFAULT_CERTIFICATE SYSREQ_USERS_USER_DESCRIPTION SYSREQ_USERS_USER_GLOBAL_NAME SYSREQ_USERS_USER_LOGIN SYSREQ_USERS_USER_MAIN_SERVER SYSREQ_USERS_USER_TYPE SYSREQ_WORK_RULES_FOLDER_ID RESULT_VAR_NAME RESULT_VAR_NAME_ENG AUTO_NUMERATION_RULE_ID CANT_CHANGE_ID_REQUISITE_RULE_ID CANT_CHANGE_OURFIRM_REQUISITE_RULE_ID CHECK_CHANGING_REFERENCE_RECORD_USE_RULE_ID CHECK_CODE_REQUISITE_RULE_ID CHECK_DELETING_REFERENCE_RECORD_USE_RULE_ID CHECK_FILTRATER_CHANGES_RULE_ID CHECK_RECORD_INTERVAL_RULE_ID CHECK_REFERENCE_INTERVAL_RULE_ID CHECK_REQUIRED_DATA_FULLNESS_RULE_ID CHECK_REQUIRED_REQUISITES_FULLNESS_RULE_ID MAKE_RECORD_UNRATIFIED_RULE_ID RESTORE_AUTO_NUMERATION_RULE_ID SET_FIRM_CONTEXT_FROM_RECORD_RULE_ID SET_FIRST_RECORD_IN_LIST_FORM_RULE_ID SET_IDSPS_VALUE_RULE_ID SET_NEXT_CODE_VALUE_RULE_ID SET_OURFIRM_BOUNDS_RULE_ID SET_OURFIRM_REQUISITE_RULE_ID SCRIPT_BLOCK_AFTER_FINISH_EVENT SCRIPT_BLOCK_BEFORE_START_EVENT SCRIPT_BLOCK_EXECUTION_RESULTS_PROPERTY SCRIPT_BLOCK_NAME_PROPERTY SCRIPT_BLOCK_SCRIPT_PROPERTY SUBTASK_BLOCK_ABORT_DEADLINE_PROPERTY SUBTASK_BLOCK_AFTER_FINISH_EVENT SUBTASK_BLOCK_ASSIGN_PARAMS_EVENT SUBTASK_BLOCK_ATTACHMENTS_PROPERTY SUBTASK_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY SUBTASK_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY SUBTASK_BLOCK_BEFORE_START_EVENT SUBTASK_BLOCK_CREATED_TASK_PROPERTY SUBTASK_BLOCK_CREATION_EVENT SUBTASK_BLOCK_DEADLINE_PROPERTY SUBTASK_BLOCK_IMPORTANCE_PROPERTY SUBTASK_BLOCK_INITIATOR_PROPERTY SUBTASK_BLOCK_IS_RELATIVE_ABORT_DEADLINE_PROPERTY SUBTASK_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY SUBTASK_BLOCK_JOBS_TYPE_PROPERTY SUBTASK_BLOCK_NAME_PROPERTY SUBTASK_BLOCK_PARALLEL_ROUTE_PROPERTY SUBTASK_BLOCK_PERFORMERS_PROPERTY SUBTASK_BLOCK_RELATIVE_ABORT_DEADLINE_TYPE_PROPERTY SUBTASK_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY SUBTASK_BLOCK_REQUIRE_SIGN_PROPERTY SUBTASK_BLOCK_STANDARD_ROUTE_PROPERTY SUBTASK_BLOCK_START_EVENT SUBTASK_BLOCK_STEP_CONTROL_PROPERTY SUBTASK_BLOCK_SUBJECT_PROPERTY SUBTASK_BLOCK_TASK_CONTROL_PROPERTY SUBTASK_BLOCK_TEXT_PROPERTY SUBTASK_BLOCK_UNLOCK_ATTACHMENTS_ON_STOP_PROPERTY SUBTASK_BLOCK_USE_STANDARD_ROUTE_PROPERTY SUBTASK_BLOCK_WAIT_FOR_TASK_COMPLETE_PROPERTY SYSCOMP_CONTROL_JOBS SYSCOMP_FOLDERS SYSCOMP_JOBS SYSCOMP_NOTICES SYSCOMP_TASKS SYSDLG_CREATE_EDOCUMENT SYSDLG_CREATE_EDOCUMENT_VERSION SYSDLG_CURRENT_PERIOD SYSDLG_EDIT_FUNCTION_HELP SYSDLG_EDOCUMENT_KINDS_FOR_TEMPLATE SYSDLG_EXPORT_MULTIPLE_EDOCUMENTS SYSDLG_EXPORT_SINGLE_EDOCUMENT SYSDLG_IMPORT_EDOCUMENT SYSDLG_MULTIPLE_SELECT SYSDLG_SETUP_ACCESS_RIGHTS SYSDLG_SETUP_DEFAULT_RIGHTS SYSDLG_SETUP_FILTER_CONDITION SYSDLG_SETUP_SIGN_RIGHTS SYSDLG_SETUP_TASK_OBSERVERS SYSDLG_SETUP_TASK_ROUTE SYSDLG_SETUP_USERS_LIST SYSDLG_SIGN_EDOCUMENT SYSDLG_SIGN_MULTIPLE_EDOCUMENTS SYSREF_ACCESS_RIGHTS_TYPES SYSREF_ADMINISTRATION_HISTORY SYSREF_ALL_AVAILABLE_COMPONENTS SYSREF_ALL_AVAILABLE_PRIVILEGES SYSREF_ALL_REPLICATING_COMPONENTS SYSREF_AVAILABLE_DEVELOPERS_COMPONENTS SYSREF_CALENDAR_EVENTS SYSREF_COMPONENT_TOKEN_HISTORY SYSREF_COMPONENT_TOKENS SYSREF_COMPONENTS SYSREF_CONSTANTS SYSREF_DATA_RECEIVE_PROTOCOL SYSREF_DATA_SEND_PROTOCOL SYSREF_DIALOGS SYSREF_DIALOGS_REQUISITES SYSREF_EDITORS SYSREF_EDOC_CARDS SYSREF_EDOC_TYPES SYSREF_EDOCUMENT_CARD_REQUISITES SYSREF_EDOCUMENT_CARD_TYPES SYSREF_EDOCUMENT_CARD_TYPES_REFERENCE SYSREF_EDOCUMENT_CARDS SYSREF_EDOCUMENT_HISTORY SYSREF_EDOCUMENT_KINDS SYSREF_EDOCUMENT_REQUISITES SYSREF_EDOCUMENT_SIGNATURES SYSREF_EDOCUMENT_TEMPLATES SYSREF_EDOCUMENT_TEXT_STORAGES SYSREF_EDOCUMENT_VIEWS SYSREF_FILTERER_SETUP_CONFLICTS SYSREF_FILTRATER_SETTING_CONFLICTS SYSREF_FOLDER_HISTORY SYSREF_FOLDERS SYSREF_FUNCTION_GROUPS SYSREF_FUNCTION_PARAMS SYSREF_FUNCTIONS SYSREF_JOB_HISTORY SYSREF_LINKS SYSREF_LOCALIZATION_DICTIONARY SYSREF_LOCALIZATION_LANGUAGES SYSREF_MODULES SYSREF_PRIVILEGES SYSREF_RECORD_HISTORY SYSREF_REFERENCE_REQUISITES SYSREF_REFERENCE_TYPE_VIEWS SYSREF_REFERENCE_TYPES SYSREF_REFERENCES SYSREF_REFERENCES_REQUISITES SYSREF_REMOTE_SERVERS SYSREF_REPLICATION_SESSIONS_LOG SYSREF_REPLICATION_SESSIONS_PROTOCOL SYSREF_REPORTS SYSREF_ROLES SYSREF_ROUTE_BLOCK_GROUPS SYSREF_ROUTE_BLOCKS SYSREF_SCRIPTS SYSREF_SEARCHES SYSREF_SERVER_EVENTS SYSREF_SERVER_EVENTS_HISTORY SYSREF_STANDARD_ROUTE_GROUPS SYSREF_STANDARD_ROUTES SYSREF_STATUSES SYSREF_SYSTEM_SETTINGS SYSREF_TASK_HISTORY SYSREF_TASK_KIND_GROUPS SYSREF_TASK_KINDS SYSREF_TASK_RIGHTS SYSREF_TASK_SIGNATURES SYSREF_TASKS SYSREF_UNITS SYSREF_USER_GROUPS SYSREF_USER_GROUPS_REFERENCE SYSREF_USER_SUBSTITUTION SYSREF_USERS SYSREF_USERS_REFERENCE SYSREF_VIEWERS SYSREF_WORKING_TIME_CALENDARS ACCESS_RIGHTS_TABLE_NAME EDMS_ACCESS_TABLE_NAME EDOC_TYPES_TABLE_NAME TEST_DEV_DB_NAME TEST_DEV_SYSTEM_CODE TEST_EDMS_DB_NAME TEST_EDMS_MAIN_CODE TEST_EDMS_MAIN_DB_NAME TEST_EDMS_SECOND_CODE TEST_EDMS_SECOND_DB_NAME TEST_EDMS_SYSTEM_CODE TEST_ISB5_MAIN_CODE TEST_ISB5_SECOND_CODE TEST_SQL_SERVER_2005_NAME TEST_SQL_SERVER_NAME ATTENTION_CAPTION cbsCommandLinks cbsDefault CONFIRMATION_CAPTION ERROR_CAPTION INFORMATION_CAPTION mrCancel mrOk EDOC_VERSION_ACTIVE_STAGE_CODE EDOC_VERSION_DESIGN_STAGE_CODE EDOC_VERSION_OBSOLETE_STAGE_CODE cpDataEnciphermentEnabled cpDigitalSignatureEnabled cpID cpIssuer cpPluginVersion cpSerial cpSubjectName cpSubjSimpleName cpValidFromDate cpValidToDate ISBL_SYNTAX NO_SYNTAX XML_SYNTAX WAIT_BLOCK_AFTER_FINISH_EVENT WAIT_BLOCK_BEFORE_START_EVENT WAIT_BLOCK_DEADLINE_PROPERTY WAIT_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY WAIT_BLOCK_NAME_PROPERTY WAIT_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY SYSRES_COMMON SYSRES_CONST SYSRES_MBFUNC SYSRES_SBDATA SYSRES_SBGUI SYSRES_SBINTF SYSRES_SBREFDSC SYSRES_SQLERRORS SYSRES_SYSCOMP atUser atGroup atRole aemEnabledAlways aemDisabledAlways aemEnabledOnBrowse aemEnabledOnEdit aemDisabledOnBrowseEmpty apBegin apEnd alLeft alRight asmNever asmNoButCustomize asmAsLastTime asmYesButCustomize asmAlways cirCommon cirRevoked ctSignature ctEncode ctSignatureEncode clbUnchecked clbChecked clbGrayed ceISB ceAlways ceNever ctDocument ctReference ctScript ctUnknown ctReport ctDialog ctFunction ctFolder ctEDocument ctTask ctJob ctNotice ctControlJob cfInternal cfDisplay ciUnspecified ciWrite ciRead ckFolder ckEDocument ckTask ckJob ckComponentToken ckAny ckReference ckScript ckReport ckDialog ctISBLEditor ctBevel ctButton ctCheckListBox ctComboBox ctComboEdit ctGrid ctDBCheckBox ctDBComboBox ctDBEdit ctDBEllipsis ctDBMemo ctDBNavigator ctDBRadioGroup ctDBStatusLabel ctEdit ctGroupBox ctInplaceHint ctMemo ctPanel ctListBox ctRadioButton ctRichEdit ctTabSheet ctWebBrowser ctImage ctHyperLink ctLabel ctDBMultiEllipsis ctRibbon ctRichView ctInnerPanel ctPanelGroup ctBitButton cctDate cctInteger cctNumeric cctPick cctReference cctString cctText cltInternal cltPrimary cltGUI dseBeforeOpen dseAfterOpen dseBeforeClose dseAfterClose dseOnValidDelete dseBeforeDelete dseAfterDelete dseAfterDeleteOutOfTransaction dseOnDeleteError dseBeforeInsert dseAfterInsert dseOnValidUpdate dseBeforeUpdate dseOnUpdateRatifiedRecord dseAfterUpdate dseAfterUpdateOutOfTransaction dseOnUpdateError dseAfterScroll dseOnOpenRecord dseOnCloseRecord dseBeforeCancel dseAfterCancel dseOnUpdateDeadlockError dseBeforeDetailUpdate dseOnPrepareUpdate dseOnAnyRequisiteChange dssEdit dssInsert dssBrowse dssInActive dftDate dftShortDate dftDateTime dftTimeStamp dotDays dotHours dotMinutes dotSeconds dtkndLocal dtkndUTC arNone arView arEdit arFull ddaView ddaEdit emLock emEdit emSign emExportWithLock emImportWithUnlock emChangeVersionNote emOpenForModify emChangeLifeStage emDelete emCreateVersion emImport emUnlockExportedWithLock emStart emAbort emReInit emMarkAsReaded emMarkAsUnreaded emPerform emAccept emResume emChangeRights emEditRoute emEditObserver emRecoveryFromLocalCopy emChangeWorkAccessType emChangeEncodeTypeToCertificate emChangeEncodeTypeToPassword emChangeEncodeTypeToNone emChangeEncodeTypeToCertificatePassword emChangeStandardRoute emGetText emOpenForView emMoveToStorage emCreateObject emChangeVersionHidden emDeleteVersion emChangeLifeCycleStage emApprovingSign emExport emContinue emLockFromEdit emUnLockForEdit emLockForServer emUnlockFromServer emDelegateAccessRights emReEncode ecotFile ecotProcess eaGet eaCopy eaCreate eaCreateStandardRoute edltAll edltNothing edltQuery essmText essmCard esvtLast esvtLastActive esvtSpecified edsfExecutive edsfArchive edstSQLServer edstFile edvstNone edvstEDocumentVersionCopy edvstFile edvstTemplate edvstScannedFile vsDefault vsDesign vsActive vsObsolete etNone etCertificate etPassword etCertificatePassword ecException ecWarning ecInformation estAll estApprovingOnly evtLast evtLastActive evtQuery fdtString fdtNumeric fdtInteger fdtDate fdtText fdtUnknown fdtWideString fdtLargeInteger ftInbox ftOutbox ftFavorites ftCommonFolder ftUserFolder ftComponents ftQuickLaunch ftShortcuts ftSearch grhAuto grhX1 grhX2 grhX3 hltText hltRTF hltHTML iffBMP iffJPEG iffMultiPageTIFF iffSinglePageTIFF iffTIFF iffPNG im8bGrayscale im24bRGB im1bMonochrome itBMP itJPEG itWMF itPNG ikhInformation ikhWarning ikhError ikhNoIcon icUnknown icScript icFunction icIntegratedReport icAnalyticReport icDataSetEventHandler icActionHandler icFormEventHandler icLookUpEventHandler icRequisiteChangeEventHandler icBeforeSearchEventHandler icRoleCalculation icSelectRouteEventHandler icBlockPropertyCalculation icBlockQueryParamsEventHandler icChangeSearchResultEventHandler icBlockEventHandler icSubTaskInitEventHandler icEDocDataSetEventHandler icEDocLookUpEventHandler icEDocActionHandler icEDocFormEventHandler icEDocRequisiteChangeEventHandler icStructuredConversionRule icStructuredConversionEventBefore icStructuredConversionEventAfter icWizardEventHandler icWizardFinishEventHandler icWizardStepEventHandler icWizardStepFinishEventHandler icWizardActionEnableEventHandler icWizardActionExecuteEventHandler icCreateJobsHandler icCreateNoticesHandler icBeforeLookUpEventHandler icAfterLookUpEventHandler icTaskAbortEventHandler icWorkflowBlockActionHandler icDialogDataSetEventHandler icDialogActionHandler icDialogLookUpEventHandler icDialogRequisiteChangeEventHandler icDialogFormEventHandler icDialogValidCloseEventHandler icBlockFormEventHandler icTaskFormEventHandler icReferenceMethod icEDocMethod icDialogMethod icProcessMessageHandler isShow isHide isByUserSettings jkJob jkNotice jkControlJob jtInner jtLeft jtRight jtFull jtCross lbpAbove lbpBelow lbpLeft lbpRight eltPerConnection eltPerUser sfcUndefined sfcBlack sfcGreen sfcRed sfcBlue sfcOrange sfcLilac sfsItalic sfsStrikeout sfsNormal ldctStandardRoute ldctWizard ldctScript ldctFunction ldctRouteBlock ldctIntegratedReport ldctAnalyticReport ldctReferenceType ldctEDocumentType ldctDialog ldctServerEvents mrcrtNone mrcrtUser mrcrtMaximal mrcrtCustom vtEqual vtGreaterOrEqual vtLessOrEqual vtRange rdYesterday rdToday rdTomorrow rdThisWeek rdThisMonth rdThisYear rdNextMonth rdNextWeek rdLastWeek rdLastMonth rdWindow rdFile rdPrinter rdtString rdtNumeric rdtInteger rdtDate rdtReference rdtAccount rdtText rdtPick rdtUnknown rdtLargeInteger rdtDocument reOnChange reOnChangeValues ttGlobal ttLocal ttUser ttSystem ssmBrowse ssmSelect ssmMultiSelect ssmBrowseModal smSelect smLike smCard stNone stAuthenticating stApproving sctString sctStream sstAnsiSort sstNaturalSort svtEqual svtContain soatString soatNumeric soatInteger soatDatetime soatReferenceRecord soatText soatPick soatBoolean soatEDocument soatAccount soatIntegerCollection soatNumericCollection soatStringCollection soatPickCollection soatDatetimeCollection soatBooleanCollection soatReferenceRecordCollection soatEDocumentCollection soatAccountCollection soatContents soatUnknown tarAbortByUser tarAbortByWorkflowException tvtAllWords tvtExactPhrase tvtAnyWord usNone usCompleted usRedSquare usBlueSquare usYellowSquare usGreenSquare usOrangeSquare usPurpleSquare usFollowUp utUnknown utUser utDeveloper utAdministrator utSystemDeveloper utDisconnected btAnd btDetailAnd btOr btNotOr btOnly vmView vmSelect vmNavigation vsmSingle vsmMultiple vsmMultipleCheck vsmNoSelection wfatPrevious wfatNext wfatCancel wfatFinish wfepUndefined wfepText3 wfepText6 wfepText9 wfepSpinEdit wfepDropDown wfepRadioGroup wfepFlag wfepText12 wfepText15 wfepText18 wfepText21 wfepText24 wfepText27 wfepText30 wfepRadioGroupColumn1 wfepRadioGroupColumn2 wfepRadioGroupColumn3 wfetQueryParameter wfetText wfetDelimiter wfetLabel wptString wptInteger wptNumeric wptBoolean wptDateTime wptPick wptText wptUser wptUserList wptEDocumentInfo wptEDocumentInfoList wptReferenceRecordInfo wptReferenceRecordInfoList wptFolderInfo wptTaskInfo wptContents wptFileName wptDate wsrComplete wsrGoNext wsrGoPrevious wsrCustom wsrCancel wsrGoFinal wstForm wstEDocument wstTaskCard wstReferenceRecordCard wstFinal waAll waPerformers waManual wsbStart wsbFinish wsbNotice wsbStep wsbDecision wsbWait wsbMonitor wsbScript wsbConnector wsbSubTask wsbLifeCycleStage wsbPause wdtInteger wdtFloat wdtString wdtPick wdtDateTime wdtBoolean wdtTask wdtJob wdtFolder wdtEDocument wdtReferenceRecord wdtUser wdtGroup wdtRole wdtIntegerCollection wdtFloatCollection wdtStringCollection wdtPickCollection wdtDateTimeCollection wdtBooleanCollection wdtTaskCollection wdtJobCollection wdtFolderCollection wdtEDocumentCollection wdtReferenceRecordCollection wdtUserCollection wdtGroupCollection wdtRoleCollection wdtContents wdtUserList wdtSearchDescription wdtDeadLine wdtPickSet wdtAccountCollection wiLow wiNormal wiHigh wrtSoft wrtHard wsInit wsRunning wsDone wsControlled wsAborted wsContinued wtmFull wtmFromCurrent wtmOnlyCurrent ",
+class:"AltState Application CallType ComponentTokens CreatedJobs CreatedNotices ControlState DialogResult Dialogs EDocuments EDocumentVersionSource Folders GlobalIDs Job Jobs InputValue LookUpReference LookUpRequisiteNames LookUpSearch Object ParentComponent Processes References Requisite ReportName Reports Result Scripts Searches SelectedAttachments SelectedItems SelectMode Sender ServerEvents ServiceFactory ShiftState SubTask SystemDialogs Tasks Wizard Wizards Work \u0412\u044b\u0437\u043e\u0432\u0421\u043f\u043e\u0441\u043e\u0431 \u0418\u043c\u044f\u041e\u0442\u0447\u0435\u0442\u0430 \u0420\u0435\u043a\u0432\u0417\u043d\u0430\u0447 ",
+literal:"null true false nil "};a={begin:"\\.\\s*"+a.UNDERSCORE_IDENT_RE,keywords:f,relevance:0};var g={className:"type",begin:":[ \\t]*("+"IApplication IAccessRights IAccountRepository IAccountSelectionRestrictions IAction IActionList IAdministrationHistoryDescription IAnchors IApplication IArchiveInfo IAttachment IAttachmentList ICheckListBox ICheckPointedList IColumn IComponent IComponentDescription IComponentToken IComponentTokenFactory IComponentTokenInfo ICompRecordInfo IConnection IContents IControl IControlJob IControlJobInfo IControlList ICrypto ICrypto2 ICustomJob ICustomJobInfo ICustomListBox ICustomObjectWizardStep ICustomWork ICustomWorkInfo IDataSet IDataSetAccessInfo IDataSigner IDateCriterion IDateRequisite IDateRequisiteDescription IDateValue IDeaAccessRights IDeaObjectInfo IDevelopmentComponentLock IDialog IDialogFactory IDialogPickRequisiteItems IDialogsFactory IDICSFactory IDocRequisite IDocumentInfo IDualListDialog IECertificate IECertificateInfo IECertificates IEditControl IEditorForm IEdmsExplorer IEdmsObject IEdmsObjectDescription IEdmsObjectFactory IEdmsObjectInfo IEDocument IEDocumentAccessRights IEDocumentDescription IEDocumentEditor IEDocumentFactory IEDocumentInfo IEDocumentStorage IEDocumentVersion IEDocumentVersionListDialog IEDocumentVersionSource IEDocumentWizardStep IEDocVerSignature IEDocVersionState IEnabledMode IEncodeProvider IEncrypter IEvent IEventList IException IExternalEvents IExternalHandler IFactory IField IFileDialog IFolder IFolderDescription IFolderDialog IFolderFactory IFolderInfo IForEach IForm IFormTitle IFormWizardStep IGlobalIDFactory IGlobalIDInfo IGrid IHasher IHistoryDescription IHyperLinkControl IImageButton IImageControl IInnerPanel IInplaceHint IIntegerCriterion IIntegerList IIntegerRequisite IIntegerValue IISBLEditorForm IJob IJobDescription IJobFactory IJobForm IJobInfo ILabelControl ILargeIntegerCriterion ILargeIntegerRequisite ILargeIntegerValue ILicenseInfo ILifeCycleStage IList IListBox ILocalIDInfo ILocalization ILock IMemoryDataSet IMessagingFactory IMetadataRepository INotice INoticeInfo INumericCriterion INumericRequisite INumericValue IObject IObjectDescription IObjectImporter IObjectInfo IObserver IPanelGroup IPickCriterion IPickProperty IPickRequisite IPickRequisiteDescription IPickRequisiteItem IPickRequisiteItems IPickValue IPrivilege IPrivilegeList IProcess IProcessFactory IProcessMessage IProgress IProperty IPropertyChangeEvent IQuery IReference IReferenceCriterion IReferenceEnabledMode IReferenceFactory IReferenceHistoryDescription IReferenceInfo IReferenceRecordCardWizardStep IReferenceRequisiteDescription IReferencesFactory IReferenceValue IRefRequisite IReport IReportFactory IRequisite IRequisiteDescription IRequisiteDescriptionList IRequisiteFactory IRichEdit IRouteStep IRule IRuleList ISchemeBlock IScript IScriptFactory ISearchCriteria ISearchCriterion ISearchDescription ISearchFactory ISearchFolderInfo ISearchForObjectDescription ISearchResultRestrictions ISecuredContext ISelectDialog IServerEvent IServerEventFactory IServiceDialog IServiceFactory ISignature ISignProvider ISignProvider2 ISignProvider3 ISimpleCriterion IStringCriterion IStringList IStringRequisite IStringRequisiteDescription IStringValue ISystemDialogsFactory ISystemInfo ITabSheet ITask ITaskAbortReasonInfo ITaskCardWizardStep ITaskDescription ITaskFactory ITaskInfo ITaskRoute ITextCriterion ITextRequisite ITextValue ITreeListSelectDialog IUser IUserList IValue IView IWebBrowserControl IWizard IWizardAction IWizardFactory IWizardFormElement IWizardParam IWizardPickParam IWizardReferenceParam IWizardStep IWorkAccessRights IWorkDescription IWorkflowAskableParam IWorkflowAskableParams IWorkflowBlock IWorkflowBlockResult IWorkflowEnabledMode IWorkflowParam IWorkflowPickParam IWorkflowReferenceParam IWorkState IWorkTreeCustomNode IWorkTreeJobNode IWorkTreeTaskNode IXMLEditorForm SBCrypto".replace(/\s/g,
+"|")+")",end:"[ \\t]*=",excludeEnd:!0},k={className:"variable",lexemes:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_!][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*",keywords:f,begin:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_!][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*",relevance:0,containts:[g,a]};return{aliases:["isbl"],case_insensitive:!0,lexemes:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_!][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*",keywords:f,illegal:"\\$|\\?|%|,|;$|~|#|@|</",
+contains:[{className:"function",begin:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*\\(",end:"\\)$",returnBegin:!0,lexemes:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_!][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*",keywords:f,illegal:"[\\[\\]\\|\\$\\?%,~#@]",contains:[{className:"title",lexemes:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_!][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*",keywords:{built_in:"AddSubString AdjustLineBreaks AmountInWords Analysis ArrayDimCount ArrayHighBound ArrayLowBound ArrayOf ArrayReDim Assert Assigned BeginOfMonth BeginOfPeriod BuildProfilingOperationAnalysis CallProcedure CanReadFile CArrayElement CDataSetRequisite ChangeDate ChangeReferenceDataset Char CharPos CheckParam CheckParamValue CompareStrings ConstantExists ControlState ConvertDateStr Copy CopyFile CreateArray CreateCachedReference CreateConnection CreateDialog CreateDualListDialog CreateEditor CreateException CreateFile CreateFolderDialog CreateInputDialog CreateLinkFile CreateList CreateLock CreateMemoryDataSet CreateObject CreateOpenDialog CreateProgress CreateQuery CreateReference CreateReport CreateSaveDialog CreateScript CreateSQLPivotFunction CreateStringList CreateTreeListSelectDialog CSelectSQL CSQL CSubString CurrentUserID CurrentUserName CurrentVersion DataSetLocateEx DateDiff DateTimeDiff DateToStr DayOfWeek DeleteFile DirectoryExists DisableCheckAccessRights DisableCheckFullShowingRestriction DisableMassTaskSendingRestrictions DropTable DupeString EditText EnableCheckAccessRights EnableCheckFullShowingRestriction EnableMassTaskSendingRestrictions EndOfMonth EndOfPeriod ExceptionExists ExceptionsOff ExceptionsOn Execute ExecuteProcess Exit ExpandEnvironmentVariables ExtractFileDrive ExtractFileExt ExtractFileName ExtractFilePath ExtractParams FileExists FileSize FindFile FindSubString FirmContext ForceDirectories Format FormatDate FormatNumeric FormatSQLDate FormatString FreeException GetComponent GetComponentLaunchParam GetConstant GetLastException GetReferenceRecord GetRefTypeByRefID GetTableID GetTempFolder IfThen In IndexOf InputDialog InputDialogEx InteractiveMode IsFileLocked IsGraphicFile IsNumeric Length LoadString LoadStringFmt LocalTimeToUTC LowerCase Max MessageBox MessageBoxEx MimeDecodeBinary MimeDecodeString MimeEncodeBinary MimeEncodeString Min MoneyInWords MoveFile NewID Now OpenFile Ord Precision Raise ReadCertificateFromFile ReadFile ReferenceCodeByID ReferenceNumber ReferenceRequisiteMode ReferenceRequisiteValue RegionDateSettings RegionNumberSettings RegionTimeSettings RegRead RegWrite RenameFile Replace Round SelectServerCode SelectSQL ServerDateTime SetConstant SetManagedFolderFieldsState ShowConstantsInputDialog ShowMessage Sleep Split SQL SQL2XLSTAB SQLProfilingSendReport StrToDate SubString SubStringCount SystemSetting Time TimeDiff Today Transliterate Trim UpperCase UserStatus UTCToLocalTime ValidateXML VarIsClear VarIsEmpty VarIsNull WorkTimeDiff WriteFile WriteFileEx WriteObjectHistory \u0410\u043d\u0430\u043b\u0438\u0437 \u0411\u0430\u0437\u0430\u0414\u0430\u043d\u043d\u044b\u0445 \u0411\u043b\u043e\u043a\u0415\u0441\u0442\u044c \u0411\u043b\u043e\u043a\u0415\u0441\u0442\u044c\u0420\u0430\u0441\u0448 \u0411\u043b\u043e\u043a\u0418\u043d\u0444\u043e \u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c \u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c\u0420\u0430\u0441\u0448 \u0411\u043b\u043e\u043a\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0412\u0432\u043e\u0434 \u0412\u0432\u043e\u0434\u041c\u0435\u043d\u044e \u0412\u0435\u0434\u0421 \u0412\u0435\u0434\u0421\u043f\u0440 \u0412\u0435\u0440\u0445\u043d\u044f\u044f\u0413\u0440\u0430\u043d\u0438\u0446\u0430\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u0412\u043d\u0435\u0448\u041f\u0440\u043e\u0433\u0440 \u0412\u043e\u0441\u0441\u0442 \u0412\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f\u041f\u0430\u043f\u043a\u0430 \u0412\u0440\u0435\u043c\u044f \u0412\u044b\u0431\u043e\u0440SQL \u0412\u044b\u0431\u0440\u0430\u0442\u044c\u0417\u0430\u043f\u0438\u0441\u044c \u0412\u044b\u0434\u0435\u043b\u0438\u0442\u044c\u0421\u0442\u0440 \u0412\u044b\u0437\u0432\u0430\u0442\u044c \u0412\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0412\u044b\u043f\u041f\u0440\u043e\u0433\u0440 \u0413\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0438\u0439\u0424\u0430\u0439\u043b \u0413\u0440\u0443\u043f\u043f\u0430\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0414\u0430\u0442\u0430\u0412\u0440\u0435\u043c\u044f\u0421\u0435\u0440\u0432 \u0414\u0435\u043d\u044c\u041d\u0435\u0434\u0435\u043b\u0438 \u0414\u0438\u0430\u043b\u043e\u0433\u0414\u0430\u041d\u0435\u0442 \u0414\u043b\u0438\u043d\u0430\u0421\u0442\u0440 \u0414\u043e\u0431\u041f\u043e\u0434\u0441\u0442\u0440 \u0415\u041f\u0443\u0441\u0442\u043e \u0415\u0441\u043b\u0438\u0422\u043e \u0415\u0427\u0438\u0441\u043b\u043e \u0417\u0430\u043c\u041f\u043e\u0434\u0441\u0442\u0440 \u0417\u0430\u043f\u0438\u0441\u044c\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0417\u043d\u0430\u0447\u041f\u043e\u043b\u044f\u0421\u043f\u0440 \u0418\u0414\u0422\u0438\u043f\u0421\u043f\u0440 \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u0414\u0438\u0441\u043a \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u0418\u043c\u044f\u0424\u0430\u0439\u043b\u0430 \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u041f\u0443\u0442\u044c \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0418\u0437\u043c\u0414\u0430\u0442 \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u044c\u0420\u0430\u0437\u043c\u0435\u0440\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u0418\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u0418\u043c\u044f\u041e\u0440\u0433 \u0418\u043c\u044f\u041f\u043e\u043b\u044f\u0421\u043f\u0440 \u0418\u043d\u0434\u0435\u043a\u0441 \u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0428\u0430\u0433 \u0418\u043d\u0442\u0435\u0440\u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0439\u0420\u0435\u0436\u0438\u043c \u0418\u0442\u043e\u0433\u0422\u0431\u043b\u0421\u043f\u0440 \u041a\u043e\u0434\u0412\u0438\u0434\u0412\u0435\u0434\u0421\u043f\u0440 \u041a\u043e\u0434\u0412\u0438\u0434\u0421\u043f\u0440\u041f\u043e\u0418\u0414 \u041a\u043e\u0434\u041f\u043eAnalit \u041a\u043e\u0434\u0421\u0438\u043c\u0432\u043e\u043b\u0430 \u041a\u043e\u0434\u0421\u043f\u0440 \u041a\u043e\u043b\u041f\u043e\u0434\u0441\u0442\u0440 \u041a\u043e\u043b\u041f\u0440\u043e\u043f \u041a\u043e\u043d\u041c\u0435\u0441 \u041a\u043e\u043d\u0441\u0442 \u041a\u043e\u043d\u0441\u0442\u0415\u0441\u0442\u044c \u041a\u043e\u043d\u0441\u0442\u0417\u043d\u0430\u0447 \u041a\u043e\u043d\u0422\u0440\u0430\u043d \u041a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0424\u0430\u0439\u043b \u041a\u043e\u043f\u0438\u044f\u0421\u0442\u0440 \u041a\u041f\u0435\u0440\u0438\u043e\u0434 \u041a\u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u041c\u0430\u043a\u0441 \u041c\u0430\u043a\u0441\u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u041c\u0430\u0441\u0441\u0438\u0432 \u041c\u0435\u043d\u044e \u041c\u0435\u043d\u044e\u0420\u0430\u0441\u0448 \u041c\u0438\u043d \u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445\u041d\u0430\u0439\u0442\u0438\u0420\u0430\u0441\u0448 \u041d\u0430\u0438\u043c\u0412\u0438\u0434\u0421\u043f\u0440 \u041d\u0430\u0438\u043c\u041f\u043eAnalit \u041d\u0430\u0438\u043c\u0421\u043f\u0440 \u041d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c\u041f\u0435\u0440\u0435\u0432\u043e\u0434\u044b\u0421\u0442\u0440\u043e\u043a \u041d\u0430\u0447\u041c\u0435\u0441 \u041d\u0430\u0447\u0422\u0440\u0430\u043d \u041d\u0438\u0436\u043d\u044f\u044f\u0413\u0440\u0430\u043d\u0438\u0446\u0430\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u041d\u043e\u043c\u0435\u0440\u0421\u043f\u0440 \u041d\u041f\u0435\u0440\u0438\u043e\u0434 \u041e\u043a\u043d\u043e \u041e\u043a\u0440 \u041e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u0435 \u041e\u0442\u043b\u0418\u043d\u0444\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u041e\u0442\u043b\u0418\u043d\u0444\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u041e\u0442\u0447\u0435\u0442 \u041e\u0442\u0447\u0435\u0442\u0410\u043d\u0430\u043b \u041e\u0442\u0447\u0435\u0442\u0418\u043d\u0442 \u041f\u0430\u043f\u043a\u0430\u0421\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442 \u041f\u0430\u0443\u0437\u0430 \u041f\u0412\u044b\u0431\u043e\u0440SQL \u041f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u044c\u0424\u0430\u0439\u043b \u041f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0435 \u041f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0424\u0430\u0439\u043b \u041f\u043e\u0434\u0441\u0442\u0440 \u041f\u043e\u0438\u0441\u043a\u041f\u043e\u0434\u0441\u0442\u0440 \u041f\u043e\u0438\u0441\u043a\u0421\u0442\u0440 \u041f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0418\u0414\u0422\u0430\u0431\u043b\u0438\u0446\u044b \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0418\u0414 \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0418\u043c\u044f \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0421\u0442\u0430\u0442\u0443\u0441 \u041f\u0440\u0435\u0440\u0432\u0430\u0442\u044c \u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440 \u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0417\u043d\u0430\u0447 \u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\u0423\u0441\u043b\u043e\u0432\u0438\u0435 \u0420\u0430\u0437\u0431\u0421\u0442\u0440 \u0420\u0430\u0437\u043d\u0412\u0440\u0435\u043c\u044f \u0420\u0430\u0437\u043d\u0414\u0430\u0442 \u0420\u0430\u0437\u043d\u0414\u0430\u0442\u0430\u0412\u0440\u0435\u043c\u044f \u0420\u0430\u0437\u043d\u0420\u0430\u0431\u0412\u0440\u0435\u043c\u044f \u0420\u0435\u0433\u0423\u0441\u0442\u0412\u0440\u0435\u043c \u0420\u0435\u0433\u0423\u0441\u0442\u0414\u0430\u0442 \u0420\u0435\u0433\u0423\u0441\u0442\u0427\u0441\u043b \u0420\u0435\u0434\u0422\u0435\u043a\u0441\u0442 \u0420\u0435\u0435\u0441\u0442\u0440\u0417\u0430\u043f\u0438\u0441\u044c \u0420\u0435\u0435\u0441\u0442\u0440\u0421\u043f\u0438\u0441\u043e\u043a\u0418\u043c\u0435\u043d\u041f\u0430\u0440\u0430\u043c \u0420\u0435\u0435\u0441\u0442\u0440\u0427\u0442\u0435\u043d\u0438\u0435 \u0420\u0435\u043a\u0432\u0421\u043f\u0440 \u0420\u0435\u043a\u0432\u0421\u043f\u0440\u041f\u0440 \u0421\u0435\u0433\u043e\u0434\u043d\u044f \u0421\u0435\u0439\u0447\u0430\u0441 \u0421\u0435\u0440\u0432\u0435\u0440 \u0421\u0435\u0440\u0432\u0435\u0440\u041f\u0440\u043e\u0446\u0435\u0441\u0441\u0418\u0414 \u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0424\u0430\u0439\u043b\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0421\u0436\u041f\u0440\u043e\u0431 \u0421\u0438\u043c\u0432\u043e\u043b \u0421\u0438\u0441\u0442\u0435\u043c\u0430\u0414\u0438\u0440\u0435\u043a\u0442\u0443\u043c\u041a\u043e\u0434 \u0421\u0438\u0441\u0442\u0435\u043c\u0430\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0421\u0438\u0441\u0442\u0435\u043c\u0430\u041a\u043e\u0434 \u0421\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u0412\u044b\u0431\u043e\u0440\u0430\u0418\u0437\u0414\u0432\u0443\u0445\u0421\u043f\u0438\u0441\u043a\u043e\u0432 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u0412\u044b\u0431\u043e\u0440\u0430\u041f\u0430\u043f\u043a\u0438 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u041e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0424\u0430\u0439\u043b\u0430 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u0424\u0430\u0439\u043b\u0430 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0417\u0430\u043f\u0440\u043e\u0441 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0418\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041a\u044d\u0448\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041c\u0430\u0441\u0441\u0438\u0432 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041e\u0431\u044a\u0435\u043a\u0442 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041e\u0442\u0447\u0435\u0442 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041f\u0430\u043f\u043a\u0443 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0420\u0435\u0434\u0430\u043a\u0442\u043e\u0440 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043f\u0438\u0441\u043e\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043f\u0438\u0441\u043e\u043a\u0421\u0442\u0440\u043e\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439 \u0421\u043e\u0437\u0434\u0421\u043f\u0440 \u0421\u043e\u0441\u0442\u0421\u043f\u0440 \u0421\u043e\u0445\u0440 \u0421\u043e\u0445\u0440\u0421\u043f\u0440 \u0421\u043f\u0438\u0441\u043e\u043a\u0421\u0438\u0441\u0442\u0435\u043c \u0421\u043f\u0440 \u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0415\u0441\u0442\u044c \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c\u0420\u0430\u0441\u0448 \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0421\u043f\u0440\u0418\u0437\u043c\u041d\u0430\u0431\u0414\u0430\u043d \u0421\u043f\u0440\u041a\u043e\u0434 \u0421\u043f\u0440\u041d\u043e\u043c\u0435\u0440 \u0421\u043f\u0440\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u0421\u043f\u0440\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0421\u043f\u0440\u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0421\u043f\u0440\u041f\u0430\u0440\u0430\u043c \u0421\u043f\u0440\u041f\u043e\u043b\u0435\u0417\u043d\u0430\u0447 \u0421\u043f\u0440\u041f\u043e\u043b\u0435\u0418\u043c\u044f \u0421\u043f\u0440\u0420\u0435\u043a\u0432 \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u0412\u0432\u0435\u0434\u0417\u043d \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u041d\u043e\u0432\u044b\u0435 \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u041f\u0440 \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u041f\u0440\u0435\u0434\u0417\u043d \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u0420\u0435\u0436\u0438\u043c \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u0422\u0438\u043f\u0422\u0435\u043a\u0441\u0442 \u0421\u043f\u0440\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0421\u043f\u0440\u0421\u043e\u0441\u0442 \u0421\u043f\u0440\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u0421\u043f\u0440\u0422\u0431\u043b\u0418\u0442\u043e\u0433 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041a\u043e\u043b \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041c\u0430\u043a\u0441 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041c\u0438\u043d \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041f\u0440\u0435\u0434 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u0421\u043b\u0435\u0434 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u0421\u043e\u0437\u0434 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u0423\u0434 \u0421\u043f\u0440\u0422\u0435\u043a\u041f\u0440\u0435\u0434\u0441\u0442 \u0421\u043f\u0440\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0421\u0440\u0430\u0432\u043d\u0438\u0442\u044c\u0421\u0442\u0440 \u0421\u0442\u0440\u0412\u0435\u0440\u0445\u0420\u0435\u0433\u0438\u0441\u0442\u0440 \u0421\u0442\u0440\u041d\u0438\u0436\u043d\u0420\u0435\u0433\u0438\u0441\u0442\u0440 \u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u0421\u0443\u043c\u041f\u0440\u043e\u043f \u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439 \u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439\u041f\u0430\u0440\u0430\u043c \u0422\u0435\u043a\u0412\u0435\u0440\u0441\u0438\u044f \u0422\u0435\u043a\u041e\u0440\u0433 \u0422\u043e\u0447\u043d \u0422\u0440\u0430\u043d \u0422\u0440\u0430\u043d\u0441\u043b\u0438\u0442\u0435\u0440\u0430\u0446\u0438\u044f \u0423\u0434\u0430\u043b\u0438\u0442\u044c\u0422\u0430\u0431\u043b\u0438\u0446\u0443 \u0423\u0434\u0430\u043b\u0438\u0442\u044c\u0424\u0430\u0439\u043b \u0423\u0434\u0421\u043f\u0440 \u0423\u0434\u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u0423\u0441\u0442 \u0423\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438\u041a\u043e\u043d\u0441\u0442\u0430\u043d\u0442 \u0424\u0430\u0439\u043b\u0410\u0442\u0440\u0438\u0431\u0443\u0442\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0410\u0442\u0440\u0438\u0431\u0443\u0442\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0424\u0430\u0439\u043b\u0412\u0440\u0435\u043c\u044f \u0424\u0430\u0439\u043b\u0412\u0440\u0435\u043c\u044f\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0424\u0430\u0439\u043b\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0417\u0430\u043d\u044f\u0442 \u0424\u0430\u0439\u043b\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0418\u0441\u043a\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041c\u043e\u0436\u043d\u043e\u0427\u0438\u0442\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0435\u0440\u0435\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0424\u0430\u0439\u043b\u0420\u0430\u0437\u043c\u0435\u0440 \u0424\u0430\u0439\u043b\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0421\u0441\u044b\u043b\u043a\u0430\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0421\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442 \u0424\u0430\u0439\u043b\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0424\u043c\u0442SQL\u0414\u0430\u0442 \u0424\u043c\u0442\u0414\u0430\u0442 \u0424\u043c\u0442\u0421\u0442\u0440 \u0424\u043c\u0442\u0427\u0441\u043b \u0424\u043e\u0440\u043c\u0430\u0442 \u0426\u041c\u0430\u0441\u0441\u0438\u0432\u042d\u043b\u0435\u043c\u0435\u043d\u0442 \u0426\u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442 \u0426\u041f\u043e\u0434\u0441\u0442\u0440 "},
+begin:"[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*\\(",end:"\\(",returnBegin:!0,excludeEnd:!0},a,k,d,b,e]},g,a,k,d,b,e]}});b.registerLanguage("java",function(a){return{aliases:["jsp"],keywords:"false synchronized int abstract float private char boolean var static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",
 illegal:/<\/|#/,contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"class",beginKeywords:"class interface",end:/[{;=]/,excludeEnd:!0,keywords:"class interface",illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"new throw return else",relevance:0},{className:"function",begin:"([\u00c0-\u02b8a-zA-Z_$][\u00c0-\u02b8a-zA-Z_$0-9]*(<[\u00c0-\u02b8a-zA-Z_$][\u00c0-\u02b8a-zA-Z_$0-9]*(\\s*,\\s*[\u00c0-\u02b8a-zA-Z_$][\u00c0-\u02b8a-zA-Z_$0-9]*)*>)?\\s+)+"+
-a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
-contains:[a.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,
+a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:"false synchronized int abstract float private char boolean var static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
+contains:[a.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,keywords:"false synchronized int abstract float private char boolean var static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,
 a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{className:"number",begin:"\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",relevance:0},{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("javascript",function(a){var b={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",
 literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},
-e={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},h={className:"subst",begin:"\\$\\{",end:"\\}",keywords:b,contains:[]},c={className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,h]};h.contains=[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,c,e,a.REGEXP_MODE];h=h.contains.concat([a.C_BLOCK_COMMENT_MODE,a.C_LINE_COMMENT_MODE]);return{aliases:["js","jsx"],keywords:b,contains:[{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},
-{className:"meta",begin:/^#!/,end:/$/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,c,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,e,{begin:/[{,]\s*/,relevance:0,contains:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*\\s*:",returnBegin:!0,relevance:0,contains:[{className:"attr",begin:"[A-Za-z$_][0-9A-Za-z$_]*",relevance:0}]}]},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|[A-Za-z$_][0-9A-Za-z$_]*)\\s*=>",
-returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*"},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:h}]}]},{begin:/</,end:/(\/\w+|\w+\/)>/,subLanguage:"xml",contains:[{begin:/<\w+\s*\/>/,skip:!0},{begin:/<\w+/,end:/(\/\w+|\w+\/)>/,skip:!0,contains:[{begin:/<\w+\s*\/>/,skip:!0},"self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"}),
-{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:h}],illegal:/\[|%/},{begin:/\$[(.]/},a.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0}],illegal:/#(?!!)/}});b.registerLanguage("json",function(a){var b={literal:"true false null"},e=[a.QUOTE_STRING_MODE,a.C_NUMBER_MODE],h={end:",",endsWithParent:!0,excludeEnd:!0,
-contains:e,keywords:b},c={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE],illegal:"\\n"},a.inherit(h,{begin:/:/})],illegal:"\\S"};a={begin:"\\[",end:"\\]",contains:[a.inherit(h)],illegal:"\\S"};e.splice(e.length,0,c,a);return{contains:e,keywords:b,illegal:"\\S"}});b.registerLanguage("perl",function(a){var b={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when"},
-e={begin:"->{",end:"}"},h={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},c=[a.BACKSLASH_ESCAPE,b,h];a=[h,a.HASH_COMMENT_MODE,a.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),e,{className:"string",contains:c,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",
+d={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},e={className:"subst",begin:"\\$\\{",end:"\\}",keywords:b,contains:[]},f={className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,e]};e.contains=[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,f,d,a.REGEXP_MODE];e=e.contains.concat([a.C_BLOCK_COMMENT_MODE,a.C_LINE_COMMENT_MODE]);return{aliases:["js","jsx"],keywords:b,contains:[{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},
+{className:"meta",begin:/^#!/,end:/$/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,f,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,d,{begin:/[{,]\s*/,relevance:0,contains:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*\\s*:",returnBegin:!0,relevance:0,contains:[{className:"attr",begin:"[A-Za-z$_][0-9A-Za-z$_]*",relevance:0}]}]},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|[A-Za-z$_][0-9A-Za-z$_]*)\\s*=>",
+returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*"},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:e}]}]},{className:"",begin:/\s/,end:/\s*/,skip:!0},{begin:/</,end:/(\/[A-Za-z0-9\\._:-]+|[A-Za-z0-9\\._:-]+\/)>/,subLanguage:"xml",contains:[{begin:/<[A-Za-z0-9\\._:-]+\s*\/>/,skip:!0},{begin:/<[A-Za-z0-9\\._:-]+/,end:/(\/[A-Za-z0-9\\._:-]+|[A-Za-z0-9\\._:-]+\/)>/,skip:!0,contains:[{begin:/<[A-Za-z0-9\\._:-]+\s*\/>/,
+skip:!0},"self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:e}],illegal:/\[|%/},{begin:/\$[(.]/},a.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor get set",end:/\{/,excludeEnd:!0}],
+illegal:/#(?!!)/}});b.registerLanguage("jboss-cli",function(a){return{aliases:["wildfly-cli"],lexemes:"[a-z-]+",keywords:{keyword:"alias batch cd clear command connect connection-factory connection-info data-source deploy deployment-info deployment-overlay echo echo-dmr help history if jdbc-driver-info jms-queue|20 jms-topic|20 ls patch pwd quit read-attribute read-operation reload rollout-plan run-batch set shutdown try unalias undeploy unset version xa-data-source",literal:"true false"},contains:[a.HASH_COMMENT_MODE,
+a.QUOTE_STRING_MODE,{className:"params",begin:/--[\w\-=\/]+/},{className:"function",begin:/:[\w\-.]+/,relevance:0},{className:"string",begin:/\B(([\/.])[\w\-.\/=]+)+/},{className:"params",begin:/\(/,end:/\)/,contains:[{begin:/[\w-]+ *=/,returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/[\w-]+/}]}],relevance:0}]}});b.registerLanguage("json",function(a){var b={literal:"true false null"},d=[a.QUOTE_STRING_MODE,a.C_NUMBER_MODE],e={end:",",endsWithParent:!0,excludeEnd:!0,contains:d,keywords:b},
+f={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE],illegal:"\\n"},a.inherit(e,{begin:/:/})],illegal:"\\S"};a={begin:"\\[",end:"\\]",contains:[a.inherit(e)],illegal:"\\S"};d.splice(d.length,0,f,a);return{contains:d,keywords:b,illegal:"\\S"}});b.registerLanguage("julia",function(a){var b={keyword:"in isa where baremodule begin break catch ccall const continue do else elseif end export false finally for function global if import importall let local macro module quote return true try using while type immutable abstract bitstype typealias ",
+literal:"true false ARGS C_NULL DevNull ENDIAN_BOM ENV I Inf Inf16 Inf32 Inf64 InsertionSort JULIA_HOME LOAD_PATH MergeSort NaN NaN16 NaN32 NaN64 PROGRAM_FILE QuickSort RoundDown RoundFromZero RoundNearest RoundNearestTiesAway RoundNearestTiesUp RoundToZero RoundUp STDERR STDIN STDOUT VERSION catalan e|0 eu|0 eulergamma golden im nothing pi \u03b3 \u03c0 \u03c6 ",built_in:"ANY AbstractArray AbstractChannel AbstractFloat AbstractMatrix AbstractRNG AbstractSerializer AbstractSet AbstractSparseArray AbstractSparseMatrix AbstractSparseVector AbstractString AbstractUnitRange AbstractVecOrMat AbstractVector Any ArgumentError Array AssertionError Associative Base64DecodePipe Base64EncodePipe Bidiagonal BigFloat BigInt BitArray BitMatrix BitVector Bool BoundsError BufferStream CachingPool CapturedException CartesianIndex CartesianRange Cchar Cdouble Cfloat Channel Char Cint Cintmax_t Clong Clonglong ClusterManager Cmd CodeInfo Colon Complex Complex128 Complex32 Complex64 CompositeException Condition ConjArray ConjMatrix ConjVector Cptrdiff_t Cshort Csize_t Cssize_t Cstring Cuchar Cuint Cuintmax_t Culong Culonglong Cushort Cwchar_t Cwstring DataType Date DateFormat DateTime DenseArray DenseMatrix DenseVecOrMat DenseVector Diagonal Dict DimensionMismatch Dims DirectIndexString Display DivideError DomainError EOFError EachLine Enum Enumerate ErrorException Exception ExponentialBackOff Expr Factorization FileMonitor Float16 Float32 Float64 Function Future GlobalRef GotoNode HTML Hermitian IO IOBuffer IOContext IOStream IPAddr IPv4 IPv6 IndexCartesian IndexLinear IndexStyle InexactError InitError Int Int128 Int16 Int32 Int64 Int8 IntSet Integer InterruptException InvalidStateException Irrational KeyError LabelNode LinSpace LineNumberNode LoadError LowerTriangular MIME Matrix MersenneTwister Method MethodError MethodTable Module NTuple NewvarNode NullException Nullable Number ObjectIdDict OrdinalRange OutOfMemoryError OverflowError Pair ParseError PartialQuickSort PermutedDimsArray Pipe PollingFileWatcher ProcessExitedException Ptr QuoteNode RandomDevice Range RangeIndex Rational RawFD ReadOnlyMemoryError Real ReentrantLock Ref Regex RegexMatch RemoteChannel RemoteException RevString RoundingMode RowVector SSAValue SegmentationFault SerializationState Set SharedArray SharedMatrix SharedVector Signed SimpleVector Slot SlotNumber SparseMatrixCSC SparseVector StackFrame StackOverflowError StackTrace StepRange StepRangeLen StridedArray StridedMatrix StridedVecOrMat StridedVector String SubArray SubString SymTridiagonal Symbol Symmetric SystemError TCPSocket Task Text TextDisplay Timer Tridiagonal Tuple Type TypeError TypeMapEntry TypeMapLevel TypeName TypeVar TypedSlot UDPSocket UInt UInt128 UInt16 UInt32 UInt64 UInt8 UndefRefError UndefVarError UnicodeError UniformScaling Union UnionAll UnitRange Unsigned UpperTriangular Val Vararg VecElement VecOrMat Vector VersionNumber Void WeakKeyDict WeakRef WorkerConfig WorkerPool "},
+d={lexemes:"[A-Za-z_\\u00A1-\\uFFFF][A-Za-z_0-9\\u00A1-\\uFFFF]*",keywords:b,illegal:/<\//};b={className:"subst",begin:/\$\(/,end:/\)/,keywords:b};var e={className:"variable",begin:"\\$[A-Za-z_\\u00A1-\\uFFFF][A-Za-z_0-9\\u00A1-\\uFFFF]*"};d.contains=[{className:"number",begin:/(\b0x[\d_]*(\.[\d_]*)?|0x\.\d[\d_]*)p[-+]?\d+|\b0[box][a-fA-F0-9][a-fA-F0-9_]*|(\b\d[\d_]*(\.[\d_]*)?|\.\d[\d_]*)([eEfF][-+]?\d+)?/,relevance:0},{className:"string",begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},{className:"string",contains:[a.BACKSLASH_ESCAPE,
+b,e],variants:[{begin:/\w*"""/,end:/"""\w*/,relevance:10},{begin:/\w*"/,end:/"\w*/}]},{className:"string",contains:[a.BACKSLASH_ESCAPE,b,e],begin:"`",end:"`"},{className:"meta",begin:"@[A-Za-z_\\u00A1-\\uFFFF][A-Za-z_0-9\\u00A1-\\uFFFF]*"},{className:"comment",variants:[{begin:"#=",end:"=#",relevance:10},{begin:"#",end:"$"}]},a.HASH_COMMENT_MODE,{className:"keyword",begin:"\\b(((abstract|primitive)\\s+)type|(mutable\\s+)?struct)\\b"},{begin:/<:/}];b.contains=d.contains;return d});b.registerLanguage("julia-repl",
+function(a){return{contains:[{className:"meta",begin:/^julia>/,relevance:10,starts:{end:/^(?![ ]{6})/,subLanguage:"julia"},aliases:["jldoctest"]}]}});b.registerLanguage("kotlin",function(a){var b={keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual trait volatile transient native default",
+built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing",literal:"true false null"},d={className:"symbol",begin:a.UNDERSCORE_IDENT_RE+"@"},e={className:"subst",begin:"\\${",end:"}",contains:[a.APOS_STRING_MODE,a.C_NUMBER_MODE]},f={className:"variable",begin:"\\$"+a.UNDERSCORE_IDENT_RE};e={className:"string",variants:[{begin:'"""',end:'"""',contains:[f,e]},{begin:"'",end:"'",illegal:/\n/,contains:[a.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/,contains:[a.BACKSLASH_ESCAPE,f,
+e]}]};f={className:"meta",begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+a.UNDERSCORE_IDENT_RE+")?"};var g={className:"meta",begin:"@"+a.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/,end:/\)/,contains:[a.inherit(e,{className:"meta-string"})]}]};return{aliases:["kt"],keywords:b,contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"keyword",begin:/\b(break|continue|return|this)\b/,
+starts:{contains:[{className:"symbol",begin:/@\w+/}]}},d,f,g,{className:"function",beginKeywords:"fun",end:"[(]|$",returnBegin:!0,excludeEnd:!0,keywords:b,illegal:/fun\s+(<.*>)?[^\s\(]+(\s+[^\s\(]+)\s*=/,relevance:5,contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"type",begin:/</,end:/>/,keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:b,relevance:0,contains:[{begin:/:/,end:/[=,\/]/,
+endsWithParent:!0,contains:[{className:"type",begin:a.UNDERSCORE_IDENT_RE},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE],relevance:0},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,f,g,e,a.C_NUMBER_MODE]},a.C_BLOCK_COMMENT_MODE]},{className:"class",beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0,illegal:"extends implements",contains:[{beginKeywords:"public protected internal private constructor"},a.UNDERSCORE_TITLE_MODE,{className:"type",begin:/</,end:/>/,excludeBegin:!0,excludeEnd:!0,
+relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/,excludeBegin:!0,returnEnd:!0},f,g]},e,{className:"meta",begin:"^#!/usr/bin/env",end:"$",illegal:"\n"},{className:"number",begin:"\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",relevance:0}]}});b.registerLanguage("lasso",function(a){var b={literal:"true false none minimal full all void and or not bw nbw ew new cn ncn lt lte gt gte eq neq rx nrx ft",
+built_in:"array date decimal duration integer map pair string tag xml null boolean bytes keyword list locale queue set stack staticarray local var variable global data self inherited currentcapture givenblock",keyword:"cache database_names database_schemanames database_tablenames define_tag define_type email_batch encode_set html_comment handle handle_error header if inline iterate ljax_target link link_currentaction link_currentgroup link_currentrecord link_detail link_firstgroup link_firstrecord link_lastgroup link_lastrecord link_nextgroup link_nextrecord link_prevgroup link_prevrecord log loop namespace_using output_none portal private protect records referer referrer repeating resultset rows search_args search_arguments select sort_args sort_arguments thread_atomic value_list while abort case else fail_if fail_ifnot fail if_empty if_false if_null if_true loop_abort loop_continue loop_count params params_up return return_value run_children soap_definetag soap_lastrequest soap_lastresponse tag_name ascending average by define descending do equals frozen group handle_failure import in into join let match max min on order parent protected provide public require returnhome skip split_thread sum take thread to trait type where with yield yieldhome"},
+d=a.COMMENT("\x3c!--","--\x3e",{relevance:0}),e={className:"meta",begin:"\\[noprocess\\]",starts:{end:"\\[/noprocess\\]",returnEnd:!0,contains:[d]}},f={className:"meta",begin:"\\[/noprocess|<\\?(lasso(script)?|=)"};a=[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.inherit(a.C_NUMBER_MODE,{begin:a.C_NUMBER_RE+"|(-?infinity|NaN)\\b"}),a.inherit(a.APOS_STRING_MODE,{illegal:null}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),{className:"string",begin:"`",end:"`"},{variants:[{begin:"[#$][a-zA-Z_][\\w.]*"},
+{begin:"#",end:"\\d+",illegal:"\\W"}]},{className:"type",begin:"::\\s*",end:"[a-zA-Z_][\\w.]*",illegal:"\\W"},{className:"params",variants:[{begin:"-(?!infinity)[a-zA-Z_][\\w.]*",relevance:0},{begin:"(\\.\\.\\.)"}]},{begin:/(->|\.)\s*/,relevance:0,contains:[{className:"symbol",begin:"'[a-zA-Z_][\\w.]*'"}]},{className:"class",beginKeywords:"define",returnEnd:!0,end:"\\(|=>",contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_][\\w.]*(=(?!>))?|[-+*/%](?!>)"})]}];return{aliases:["ls","lassoscript"],case_insensitive:!0,
+lexemes:"[a-zA-Z_][\\w.]*|&[lg]t;",keywords:b,contains:[{className:"meta",begin:"\\]|\\?>",relevance:0,starts:{end:"\\[|<\\?(lasso(script)?|=)",returnEnd:!0,relevance:0,contains:[d]}},e,f,{className:"meta",begin:"\\[no_square_brackets",starts:{end:"\\[/no_square_brackets\\]",lexemes:"[a-zA-Z_][\\w.]*|&[lg]t;",keywords:b,contains:[{className:"meta",begin:"\\]|\\?>",relevance:0,starts:{end:"\\[noprocess\\]|<\\?(lasso(script)?|=)",returnEnd:!0,contains:[d]}},e,f].concat(a)}},{className:"meta",begin:"\\[",
+relevance:0},{className:"meta",begin:"^#!",end:"lasso9$",relevance:10}].concat(a)}});b.registerLanguage("ldif",function(a){return{contains:[{className:"attribute",begin:"^dn",end:": ",excludeEnd:!0,starts:{end:"$",relevance:0},relevance:10},{className:"attribute",begin:"^\\w",end:": ",excludeEnd:!0,starts:{end:"$",relevance:0}},{className:"literal",begin:"^-",end:"$"},a.HASH_COMMENT_MODE]}});b.registerLanguage("leaf",function(a){return{contains:[{className:"function",begin:"#+[A-Za-z_0-9]*\\(",end:" {",
+returnBegin:!0,excludeEnd:!0,contains:[{className:"keyword",begin:"#+"},{className:"title",begin:"[A-Za-z_][A-Za-z_0-9]*"},{className:"params",begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"string",begin:'"',end:'"'},{className:"variable",begin:"[A-Za-z_][A-Za-z_0-9]*"}]}]}]}});b.registerLanguage("less",function(a){var b=[],d=[],e=function(a){return{className:"string",begin:"~?"+a+".*?"+a}},f=function(a,b,c){return{className:a,begin:b,relevance:c}},g={begin:"\\(",end:"\\)",contains:d,relevance:0};
+d.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,e("'"),e('"'),a.CSS_NUMBER_MODE,{begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]",excludeEnd:!0}},f("number","#[0-9A-Fa-f]+\\b"),g,f("variable","@@?[\\w-]+",10),f("variable","@{[\\w-]+}"),f("built_in","~?`[^`]*?`"),{className:"attribute",begin:"[\\w-]+\\s*:",end:":",returnBegin:!0,excludeEnd:!0},{className:"meta",begin:"!important"});g=d.concat({begin:"{",end:"}",contains:b});var k={beginKeywords:"when",endsWithParent:!0,contains:[{beginKeywords:"and not"}].concat(d)};
+e={begin:"([\\w-]+|@{[\\w-]+})\\s*:",returnBegin:!0,end:"[;}]",relevance:0,contains:[{className:"attribute",begin:"([\\w-]+|@{[\\w-]+})",end:":",excludeEnd:!0,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:d}}]};d={className:"keyword",begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b",starts:{end:"[;{}]",returnEnd:!0,contains:d,relevance:0}};var h={className:"variable",variants:[{begin:"@[\\w-]+\\s*:",relevance:15},{begin:"@[\\w-]+"}],
+starts:{end:"[;}]",returnEnd:!0,contains:g}};f={variants:[{begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:"([\\w-]+|@{[\\w-]+})",end:"{"}],returnBegin:!0,returnEnd:!0,illegal:"[<='$\"]",relevance:0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,k,f("keyword","all\\b"),f("variable","@{[\\w-]+}"),f("selector-tag","([\\w-]+|@{[\\w-]+})%?",0),f("selector-id","#([\\w-]+|@{[\\w-]+})"),f("selector-class","\\.([\\w-]+|@{[\\w-]+})",0),f("selector-tag","&",0),{className:"selector-attr",begin:"\\[",end:"\\]"},
+{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9_\-\+\(\)"'.]+/},{begin:"\\(",end:"\\)",contains:g},{begin:"!important"}]};b.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,d,h,e,f);return{case_insensitive:!0,illegal:"[=>'/<($\"]",contains:b}});b.registerLanguage("lisp",function(a){var b={className:"literal",begin:"\\b(t{1}|nil)\\b"},d={className:"number",variants:[{begin:"(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",relevance:0},{begin:"#(b|B)[0-1]+(/[0-1]+)?"},{begin:"#(o|O)[0-7]+(/[0-7]+)?"},
+{begin:"#(x|X)[0-9a-fA-F]+(/[0-9a-fA-F]+)?"},{begin:"#(c|C)\\((\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)? +(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",end:"\\)"}]},e=a.inherit(a.QUOTE_STRING_MODE,{illegal:null});a=a.COMMENT(";","$",{relevance:0});var f={begin:"\\*",end:"\\*"},g={className:"symbol",begin:"[:&][a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},k={begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*",
+relevance:0},h={contains:[d,e,f,g,{begin:"\\(",end:"\\)",contains:["self",b,e,d,k]},k],variants:[{begin:"['`]\\(",end:"\\)"},{begin:"\\(quote ",end:"\\)",keywords:{name:"quote"}},{begin:"'\\|[^]*?\\|"}]},m={variants:[{begin:"'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"#'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*(::[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*)*"}]},n={begin:"\\(\\s*",end:"\\)"},
+l={endsWithParent:!0,relevance:0};n.contains=[{className:"name",variants:[{begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"\\|[^]*?\\|"}]},l];l.contains=[h,m,n,b,d,e,a,f,g,{begin:"\\|[^]*?\\|"},k];return{illegal:/\S/,contains:[d,{className:"meta",begin:"^#!",end:"$"},b,e,a,h,m,n,k]}});b.registerLanguage("livecodeserver",function(a){var b={className:"variable",variants:[{begin:"\\b([gtps][A-Z]{1}[a-zA-Z0-9]*)(\\[.+\\])?(?:\\s*?)"},{begin:"\\$_[A-Z]+"}],
+relevance:0},d=[a.C_BLOCK_COMMENT_MODE,a.HASH_COMMENT_MODE,a.COMMENT("--","$"),a.COMMENT("[^:]//","$")],e=a.inherit(a.TITLE_MODE,{variants:[{begin:"\\b_*rig[A-Z]+[A-Za-z0-9_\\-]*"},{begin:"\\b_[a-z0-9\\-]+"}]}),f=a.inherit(a.TITLE_MODE,{begin:"\\b([A-Za-z0-9_\\-]+)\\b"});return{case_insensitive:!1,keywords:{keyword:"$_COOKIE $_FILES $_GET $_GET_BINARY $_GET_RAW $_POST $_POST_BINARY $_POST_RAW $_SESSION $_SERVER codepoint codepoints segment segments codeunit codeunits sentence sentences trueWord trueWords paragraph after byte bytes english the until http forever descending using line real8 with seventh for stdout finally element word words fourth before black ninth sixth characters chars stderr uInt1 uInt1s uInt2 uInt2s stdin string lines relative rel any fifth items from middle mid at else of catch then third it file milliseconds seconds second secs sec int1 int1s int4 int4s internet int2 int2s normal text item last long detailed effective uInt4 uInt4s repeat end repeat URL in try into switch to words https token binfile each tenth as ticks tick system real4 by dateItems without char character ascending eighth whole dateTime numeric short first ftp integer abbreviated abbr abbrev private case while if div mod wrap and or bitAnd bitNot bitOr bitXor among not in a an within contains ends with begins the keys of keys",
+literal:"SIX TEN FORMFEED NINE ZERO NONE SPACE FOUR FALSE COLON CRLF PI COMMA ENDOFFILE EOF EIGHT FIVE QUOTE EMPTY ONE TRUE RETURN CR LINEFEED RIGHT BACKSLASH NULL SEVEN TAB THREE TWO six ten formfeed nine zero none space four false colon crlf pi comma endoffile eof eight five quote empty one true return cr linefeed right backslash null seven tab three two RIVERSION RISTATE FILE_READ_MODE FILE_WRITE_MODE FILE_WRITE_MODE DIR_WRITE_MODE FILE_READ_UMASK FILE_WRITE_UMASK DIR_READ_UMASK DIR_WRITE_UMASK",
+built_in:"put abs acos aliasReference annuity arrayDecode arrayEncode asin atan atan2 average avg avgDev base64Decode base64Encode baseConvert binaryDecode binaryEncode byteOffset byteToNum cachedURL cachedURLs charToNum cipherNames codepointOffset codepointProperty codepointToNum codeunitOffset commandNames compound compress constantNames cos date dateFormat decompress difference directories diskSpace DNSServers exp exp1 exp2 exp10 extents files flushEvents folders format functionNames geometricMean global globals hasMemory harmonicMean hostAddress hostAddressToName hostName hostNameToAddress isNumber ISOToMac itemOffset keys len length libURLErrorData libUrlFormData libURLftpCommand libURLLastHTTPHeaders libURLLastRHHeaders libUrlMultipartFormAddPart libUrlMultipartFormData libURLVersion lineOffset ln ln1 localNames log log2 log10 longFilePath lower macToISO matchChunk matchText matrixMultiply max md5Digest median merge messageAuthenticationCode messageDigest millisec millisecs millisecond milliseconds min monthNames nativeCharToNum normalizeText num number numToByte numToChar numToCodepoint numToNativeChar offset open openfiles openProcesses openProcessIDs openSockets paragraphOffset paramCount param params peerAddress pendingMessages platform popStdDev populationStandardDeviation populationVariance popVariance processID random randomBytes replaceText result revCreateXMLTree revCreateXMLTreeFromFile revCurrentRecord revCurrentRecordIsFirst revCurrentRecordIsLast revDatabaseColumnCount revDatabaseColumnIsNull revDatabaseColumnLengths revDatabaseColumnNames revDatabaseColumnNamed revDatabaseColumnNumbered revDatabaseColumnTypes revDatabaseConnectResult revDatabaseCursors revDatabaseID revDatabaseTableNames revDatabaseType revDataFromQuery revdb_closeCursor revdb_columnbynumber revdb_columncount revdb_columnisnull revdb_columnlengths revdb_columnnames revdb_columntypes revdb_commit revdb_connect revdb_connections revdb_connectionerr revdb_currentrecord revdb_cursorconnection revdb_cursorerr revdb_cursors revdb_dbtype revdb_disconnect revdb_execute revdb_iseof revdb_isbof revdb_movefirst revdb_movelast revdb_movenext revdb_moveprev revdb_query revdb_querylist revdb_recordcount revdb_rollback revdb_tablenames revGetDatabaseDriverPath revNumberOfRecords revOpenDatabase revOpenDatabases revQueryDatabase revQueryDatabaseBlob revQueryResult revQueryIsAtStart revQueryIsAtEnd revUnixFromMacPath revXMLAttribute revXMLAttributes revXMLAttributeValues revXMLChildContents revXMLChildNames revXMLCreateTreeFromFileWithNamespaces revXMLCreateTreeWithNamespaces revXMLDataFromXPathQuery revXMLEvaluateXPath revXMLFirstChild revXMLMatchingNode revXMLNextSibling revXMLNodeContents revXMLNumberOfChildren revXMLParent revXMLPreviousSibling revXMLRootNode revXMLRPC_CreateRequest revXMLRPC_Documents revXMLRPC_Error revXMLRPC_GetHost revXMLRPC_GetMethod revXMLRPC_GetParam revXMLText revXMLRPC_Execute revXMLRPC_GetParamCount revXMLRPC_GetParamNode revXMLRPC_GetParamType revXMLRPC_GetPath revXMLRPC_GetPort revXMLRPC_GetProtocol revXMLRPC_GetRequest revXMLRPC_GetResponse revXMLRPC_GetSocket revXMLTree revXMLTrees revXMLValidateDTD revZipDescribeItem revZipEnumerateItems revZipOpenArchives round sampVariance sec secs seconds sentenceOffset sha1Digest shell shortFilePath sin specialFolderPath sqrt standardDeviation statRound stdDev sum sysError systemVersion tan tempName textDecode textEncode tick ticks time to tokenOffset toLower toUpper transpose truewordOffset trunc uniDecode uniEncode upper URLDecode URLEncode URLStatus uuid value variableNames variance version waitDepth weekdayNames wordOffset xsltApplyStylesheet xsltApplyStylesheetFromFile xsltLoadStylesheet xsltLoadStylesheetFromFile add breakpoint cancel clear local variable file word line folder directory URL close socket process combine constant convert create new alias folder directory decrypt delete variable word line folder directory URL dispatch divide do encrypt filter get include intersect kill libURLDownloadToFile libURLFollowHttpRedirects libURLftpUpload libURLftpUploadFile libURLresetAll libUrlSetAuthCallback libURLSetDriver libURLSetCustomHTTPHeaders libUrlSetExpect100 libURLSetFTPListCommand libURLSetFTPMode libURLSetFTPStopTime libURLSetStatusCallback load extension loadedExtensions multiply socket prepare process post seek rel relative read from process rename replace require resetAll resolve revAddXMLNode revAppendXML revCloseCursor revCloseDatabase revCommitDatabase revCopyFile revCopyFolder revCopyXMLNode revDeleteFolder revDeleteXMLNode revDeleteAllXMLTrees revDeleteXMLTree revExecuteSQL revGoURL revInsertXMLNode revMoveFolder revMoveToFirstRecord revMoveToLastRecord revMoveToNextRecord revMoveToPreviousRecord revMoveToRecord revMoveXMLNode revPutIntoXMLNode revRollBackDatabase revSetDatabaseDriverPath revSetXMLAttribute revXMLRPC_AddParam revXMLRPC_DeleteAllDocuments revXMLAddDTD revXMLRPC_Free revXMLRPC_FreeAll revXMLRPC_DeleteDocument revXMLRPC_DeleteParam revXMLRPC_SetHost revXMLRPC_SetMethod revXMLRPC_SetPort revXMLRPC_SetProtocol revXMLRPC_SetSocket revZipAddItemWithData revZipAddItemWithFile revZipAddUncompressedItemWithData revZipAddUncompressedItemWithFile revZipCancel revZipCloseArchive revZipDeleteItem revZipExtractItemToFile revZipExtractItemToVariable revZipSetProgressCallback revZipRenameItem revZipReplaceItemWithData revZipReplaceItemWithFile revZipOpenArchive send set sort split start stop subtract symmetric union unload vectorDotProduct wait write"},
+contains:[b,{className:"keyword",begin:"\\bend\\sif\\b"},{className:"function",beginKeywords:"function",end:"$",contains:[b,f,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE,e]},{className:"function",begin:"\\bend\\s+",end:"$",keywords:"end",contains:[f,e],relevance:0},{beginKeywords:"command on",end:"$",contains:[b,f,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE,e]},{className:"meta",variants:[{begin:"<\\?(rev|lc|livecode)",relevance:10},
+{begin:"<\\?"},{begin:"\\?>"}]},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE,e].concat(d),illegal:";$|^\\[|^=|&|{"}});b.registerLanguage("livescript",function(a){var b={keyword:"in if for while finally new do return else break catch instanceof throw try this switch continue typeof delete debugger case default function var with then unless until loop of by when and or is isnt not it that otherwise from to til fallthrough super case default function var void const let enum export import native __hasProp __extends __slice __bind __indexOf",
+literal:"true false null undefined yes no on off it that void",built_in:"npm require console print module global window document"},d=a.inherit(a.TITLE_MODE,{begin:"[A-Za-z$_](?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*"}),e={className:"subst",begin:/#\{/,end:/}/,keywords:b},f={className:"subst",begin:/#[A-Za-z$_]/,end:/(?:\-[0-9A-Za-z$_]|[0-9A-Za-z$_])*/,keywords:b};f=[a.BINARY_NUMBER_MODE,{className:"number",begin:"(\\b0[xX][a-fA-F0-9_]+)|(\\b\\d(\\d|_\\d)*(\\.(\\d(\\d|_\\d)*)?)?(_*[eE]([-+]\\d(_\\d|\\d)*)?)?[_a-z]*)",
+relevance:0,starts:{end:"(\\s*/)?",relevance:0}},{className:"string",variants:[{begin:/'''/,end:/'''/,contains:[a.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[a.BACKSLASH_ESCAPE]},{begin:/"""/,end:/"""/,contains:[a.BACKSLASH_ESCAPE,e,f]},{begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,e,f]},{begin:/\\/,end:/(\s|$)/,excludeEnd:!0}]},{className:"regexp",variants:[{begin:"//",end:"//[gim]*",contains:[e,a.HASH_COMMENT_MODE]},{begin:/\/(?![ *])(\\\/|.)*?\/[gim]*(?=\W|$)/}]},{begin:"@[A-Za-z$_](?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*"},
+{begin:"``",end:"``",excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"}];e.contains=f;e={className:"params",begin:"\\(",returnBegin:!0,contains:[{begin:/\(/,end:/\)/,keywords:b,contains:["self"].concat(f)}]};return{aliases:["ls"],keywords:b,illegal:/\/\*/,contains:f.concat([a.COMMENT("\\/\\*","\\*\\/"),a.HASH_COMMENT_MODE,{className:"function",contains:[d,e],returnBegin:!0,variants:[{begin:"([A-Za-z$_](?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*\\s*(?:=|:=)\\s*)?(\\(.*\\))?\\s*\\B\\->\\*?",end:"\\->\\*?"},
+{begin:"([A-Za-z$_](?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*\\s*(?:=|:=)\\s*)?!?(\\(.*\\))?\\s*\\B[-~]{1,2}>\\*?",end:"[-~]{1,2}>\\*?"},{begin:"([A-Za-z$_](?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*\\s*(?:=|:=)\\s*)?(\\(.*\\))?\\s*\\B!?[-~]{1,2}>\\*?",end:"!?[-~]{1,2}>\\*?"}]},{className:"class",beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[d]},d]},{begin:"[A-Za-z$_](?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*:",end:":",returnBegin:!0,returnEnd:!0,
+relevance:0}])}});b.registerLanguage("llvm",function(a){return{keywords:"begin end true false declare define global constant private linker_private internal available_externally linkonce linkonce_odr weak weak_odr appending dllimport dllexport common default hidden protected extern_weak external thread_local zeroinitializer undef null to tail target triple datalayout volatile nuw nsw nnan ninf nsz arcp fast exact inbounds align addrspace section alias module asm sideeffect gc dbg linker_private_weak attributes blockaddress initialexec localdynamic localexec prefix unnamed_addr ccc fastcc coldcc x86_stdcallcc x86_fastcallcc arm_apcscc arm_aapcscc arm_aapcs_vfpcc ptx_device ptx_kernel intel_ocl_bicc msp430_intrcc spir_func spir_kernel x86_64_sysvcc x86_64_win64cc x86_thiscallcc cc c signext zeroext inreg sret nounwind noreturn noalias nocapture byval nest readnone readonly inlinehint noinline alwaysinline optsize ssp sspreq noredzone noimplicitfloat naked builtin cold nobuiltin noduplicate nonlazybind optnone returns_twice sanitize_address sanitize_memory sanitize_thread sspstrong uwtable returned type opaque eq ne slt sgt sle sge ult ugt ule uge oeq one olt ogt ole oge ord uno ueq une x acq_rel acquire alignstack atomic catch cleanup filter inteldialect max min monotonic nand personality release seq_cst singlethread umax umin unordered xchg add fadd sub fsub mul fmul udiv sdiv fdiv urem srem frem shl lshr ashr and or xor icmp fcmp phi call trunc zext sext fptrunc fpext uitofp sitofp fptoui fptosi inttoptr ptrtoint bitcast addrspacecast select va_arg ret br switch invoke unwind unreachable indirectbr landingpad resume malloc alloca free load store getelementptr extractelement insertelement shufflevector getresult extractvalue insertvalue atomicrmw cmpxchg fence argmemonly double",
+contains:[{className:"keyword",begin:"i\\d+"},a.COMMENT(";","\\n",{relevance:0}),a.QUOTE_STRING_MODE,{className:"string",variants:[{begin:'"',end:'[^\\\\]"'}],relevance:0},{className:"title",variants:[{begin:"@([-a-zA-Z$._][\\w\\-$.]*)"},{begin:"@\\d+"},{begin:"!([-a-zA-Z$._][\\w\\-$.]*)"},{begin:"!\\d+([-a-zA-Z$._][\\w\\-$.]*)"}]},{className:"symbol",variants:[{begin:"%([-a-zA-Z$._][\\w\\-$.]*)"},{begin:"%\\d+"},{begin:"#\\d+"}]},{className:"number",variants:[{begin:"0[xX][a-fA-F0-9]+"},{begin:"-?\\d+(?:[.]\\d+)?(?:[eE][-+]?\\d+(?:[.]\\d+)?)?"}],
+relevance:0}]}});b.registerLanguage("lsl",function(a){var b={className:"number",begin:a.C_NUMBER_RE};return{illegal:":",contains:[{className:"string",begin:'"',end:'"',contains:[{className:"subst",begin:/\\[tn"\\]/}]},{className:"comment",variants:[a.COMMENT("//","$"),a.COMMENT("/\\*","\\*/")]},b,{className:"section",variants:[{begin:"\\b(?:state|default)\\b"},{begin:"\\b(?:state_(?:entry|exit)|touch(?:_(?:start|end))?|(?:land_)?collision(?:_(?:start|end))?|timer|listen|(?:no_)?sensor|control|(?:not_)?at_(?:rot_)?target|money|email|experience_permissions(?:_denied)?|run_time_permissions|changed|attach|dataserver|moving_(?:start|end)|link_message|(?:on|object)_rez|remote_data|http_re(?:sponse|quest)|path_update|transaction_result)\\b"}]},
+{className:"built_in",begin:"\\b(?:ll(?:AgentInExperience|(?:Create|DataSize|Delete|KeyCount|Keys|Read|Update)KeyValue|GetExperience(?:Details|ErrorMessage)|ReturnObjectsBy(?:ID|Owner)|Json(?:2List|[GS]etValue|ValueType)|Sin|Cos|Tan|Atan2|Sqrt|Pow|Abs|Fabs|Frand|Floor|Ceil|Round|Vec(?:Mag|Norm|Dist)|Rot(?:Between|2(?:Euler|Fwd|Left|Up))|(?:Euler|Axes)2Rot|Whisper|(?:Region|Owner)?Say|Shout|Listen(?:Control|Remove)?|Sensor(?:Repeat|Remove)?|Detected(?:Name|Key|Owner|Type|Pos|Vel|Grab|Rot|Group|LinkNumber)|Die|Ground|Wind|(?:[GS]et)(?:AnimationOverride|MemoryLimit|PrimMediaParams|ParcelMusicURL|Object(?:Desc|Name)|PhysicsMaterial|Status|Scale|Color|Alpha|Texture|Pos|Rot|Force|Torque)|ResetAnimationOverride|(?:Scale|Offset|Rotate)Texture|(?:Rot)?Target(?:Remove)?|(?:Stop)?MoveToTarget|Apply(?:Rotational)?Impulse|Set(?:KeyframedMotion|ContentType|RegionPos|(?:Angular)?Velocity|Buoyancy|HoverHeight|ForceAndTorque|TimerEvent|ScriptState|Damage|TextureAnim|Sound(?:Queueing|Radius)|Vehicle(?:Type|(?:Float|Vector|Rotation)Param)|(?:Touch|Sit)?Text|Camera(?:Eye|At)Offset|PrimitiveParams|ClickAction|Link(?:Alpha|Color|PrimitiveParams(?:Fast)?|Texture(?:Anim)?|Camera|Media)|RemoteScriptAccessPin|PayPrice|LocalRot)|ScaleByFactor|Get(?:(?:Max|Min)ScaleFactor|ClosestNavPoint|StaticPath|SimStats|Env|PrimitiveParams|Link(?:PrimitiveParams|Number(?:OfSides)?|Key|Name|Media)|HTTPHeader|FreeURLs|Object(?:Details|PermMask|PrimCount)|Parcel(?:MaxPrims|Details|Prim(?:Count|Owners))|Attached(?:List)?|(?:SPMax|Free|Used)Memory|Region(?:Name|TimeDilation|FPS|Corner|AgentCount)|Root(?:Position|Rotation)|UnixTime|(?:Parcel|Region)Flags|(?:Wall|GMT)clock|SimulatorHostname|BoundingBox|GeometricCenter|Creator|NumberOf(?:Prims|NotecardLines|Sides)|Animation(?:List)?|(?:Camera|Local)(?:Pos|Rot)|Vel|Accel|Omega|Time(?:stamp|OfDay)|(?:Object|CenterOf)?Mass|MassMKS|Energy|Owner|(?:Owner)?Key|SunDirection|Texture(?:Offset|Scale|Rot)|Inventory(?:Number|Name|Key|Type|Creator|PermMask)|Permissions(?:Key)?|StartParameter|List(?:Length|EntryType)|Date|Agent(?:Size|Info|Language|List)|LandOwnerAt|NotecardLine|Script(?:Name|State))|(?:Get|Reset|GetAndReset)Time|PlaySound(?:Slave)?|LoopSound(?:Master|Slave)?|(?:Trigger|Stop|Preload)Sound|(?:(?:Get|Delete)Sub|Insert)String|To(?:Upper|Lower)|Give(?:InventoryList|Money)|RezObject|(?:Stop)?LookAt|Sleep|CollisionFilter|(?:Take|Release)Controls|DetachFromAvatar|AttachToAvatar(?:Temp)?|InstantMessage|(?:GetNext)?Email|StopHover|MinEventDelay|RotLookAt|String(?:Length|Trim)|(?:Start|Stop)Animation|TargetOmega|Request(?:Experience)?Permissions|(?:Create|Break)Link|BreakAllLinks|(?:Give|Remove)Inventory|Water|PassTouches|Request(?:Agent|Inventory)Data|TeleportAgent(?:Home|GlobalCoords)?|ModifyLand|CollisionSound|ResetScript|MessageLinked|PushObject|PassCollisions|AxisAngle2Rot|Rot2(?:Axis|Angle)|A(?:cos|sin)|AngleBetween|AllowInventoryDrop|SubStringIndex|List2(?:CSV|Integer|Json|Float|String|Key|Vector|Rot|List(?:Strided)?)|DeleteSubList|List(?:Statistics|Sort|Randomize|(?:Insert|Find|Replace)List)|EdgeOfWorld|AdjustSoundVolume|Key2Name|TriggerSoundLimited|EjectFromLand|(?:CSV|ParseString)2List|OverMyLand|SameGroup|UnSit|Ground(?:Slope|Normal|Contour)|GroundRepel|(?:Set|Remove)VehicleFlags|(?:AvatarOn)?(?:Link)?SitTarget|Script(?:Danger|Profiler)|Dialog|VolumeDetect|ResetOtherScript|RemoteLoadScriptPin|(?:Open|Close)RemoteDataChannel|SendRemoteData|RemoteDataReply|(?:Integer|String)ToBase64|XorBase64|Log(?:10)?|Base64To(?:String|Integer)|ParseStringKeepNulls|RezAtRoot|RequestSimulatorData|ForceMouselook|(?:Load|Release|(?:E|Une)scape)URL|ParcelMedia(?:CommandList|Query)|ModPow|MapDestination|(?:RemoveFrom|AddTo|Reset)Land(?:Pass|Ban)List|(?:Set|Clear)CameraParams|HTTP(?:Request|Response)|TextBox|DetectedTouch(?:UV|Face|Pos|(?:N|Bin)ormal|ST)|(?:MD5|SHA1|DumpList2)String|Request(?:Secure)?URL|Clear(?:Prim|Link)Media|(?:Link)?ParticleSystem|(?:Get|Request)(?:Username|DisplayName)|RegionSayTo|CastRay|GenerateKey|TransferLindenDollars|ManageEstateAccess|(?:Create|Delete)Character|ExecCharacterCmd|Evade|FleeFrom|NavigateTo|PatrolPoints|Pursue|UpdateCharacter|WanderWithin))\\b"},
+{className:"literal",variants:[{begin:"\\b(?:PI|TWO_PI|PI_BY_TWO|DEG_TO_RAD|RAD_TO_DEG|SQRT2)\\b"},{begin:"\\b(?:XP_ERROR_(?:EXPERIENCES_DISABLED|EXPERIENCE_(?:DISABLED|SUSPENDED)|INVALID_(?:EXPERIENCE|PARAMETERS)|KEY_NOT_FOUND|MATURITY_EXCEEDED|NONE|NOT_(?:FOUND|PERMITTED(?:_LAND)?)|NO_EXPERIENCE|QUOTA_EXCEEDED|RETRY_UPDATE|STORAGE_EXCEPTION|STORE_DISABLED|THROTTLED|UNKNOWN_ERROR)|JSON_APPEND|STATUS_(?:PHYSICS|ROTATE_[XYZ]|PHANTOM|SANDBOX|BLOCK_GRAB(?:_OBJECT)?|(?:DIE|RETURN)_AT_EDGE|CAST_SHADOWS|OK|MALFORMED_PARAMS|TYPE_MISMATCH|BOUNDS_ERROR|NOT_(?:FOUND|SUPPORTED)|INTERNAL_ERROR|WHITELIST_FAILED)|AGENT(?:_(?:BY_(?:LEGACY_|USER)NAME|FLYING|ATTACHMENTS|SCRIPTED|MOUSELOOK|SITTING|ON_OBJECT|AWAY|WALKING|IN_AIR|TYPING|CROUCHING|BUSY|ALWAYS_RUN|AUTOPILOT|LIST_(?:PARCEL(?:_OWNER)?|REGION)))?|CAMERA_(?:PITCH|DISTANCE|BEHINDNESS_(?:ANGLE|LAG)|(?:FOCUS|POSITION)(?:_(?:THRESHOLD|LOCKED|LAG))?|FOCUS_OFFSET|ACTIVE)|ANIM_ON|LOOP|REVERSE|PING_PONG|SMOOTH|ROTATE|SCALE|ALL_SIDES|LINK_(?:ROOT|SET|ALL_(?:OTHERS|CHILDREN)|THIS)|ACTIVE|PASS(?:IVE|_(?:ALWAYS|IF_NOT_HANDLED|NEVER))|SCRIPTED|CONTROL_(?:FWD|BACK|(?:ROT_)?(?:LEFT|RIGHT)|UP|DOWN|(?:ML_)?LBUTTON)|PERMISSION_(?:RETURN_OBJECTS|DEBIT|OVERRIDE_ANIMATIONS|SILENT_ESTATE_MANAGEMENT|TAKE_CONTROLS|TRIGGER_ANIMATION|ATTACH|CHANGE_LINKS|(?:CONTROL|TRACK)_CAMERA|TELEPORT)|INVENTORY_(?:TEXTURE|SOUND|OBJECT|SCRIPT|LANDMARK|CLOTHING|NOTECARD|BODYPART|ANIMATION|GESTURE|ALL|NONE)|CHANGED_(?:INVENTORY|COLOR|SHAPE|SCALE|TEXTURE|LINK|ALLOWED_DROP|OWNER|REGION(?:_START)?|TELEPORT|MEDIA)|OBJECT_(?:CLICK_ACTION|HOVER_HEIGHT|LAST_OWNER_ID|(?:PHYSICS|SERVER|STREAMING)_COST|UNKNOWN_DETAIL|CHARACTER_TIME|PHANTOM|PHYSICS|TEMP_ON_REZ|NAME|DESC|POS|PRIM_(?:COUNT|EQUIVALENCE)|RETURN_(?:PARCEL(?:_OWNER)?|REGION)|REZZER_KEY|ROO?T|VELOCITY|OMEGA|OWNER|GROUP|CREATOR|ATTACHED_POINT|RENDER_WEIGHT|(?:BODY_SHAPE|PATHFINDING)_TYPE|(?:RUNNING|TOTAL)_SCRIPT_COUNT|TOTAL_INVENTORY_COUNT|SCRIPT_(?:MEMORY|TIME))|TYPE_(?:INTEGER|FLOAT|STRING|KEY|VECTOR|ROTATION|INVALID)|(?:DEBUG|PUBLIC)_CHANNEL|ATTACH_(?:AVATAR_CENTER|CHEST|HEAD|BACK|PELVIS|MOUTH|CHIN|NECK|NOSE|BELLY|[LR](?:SHOULDER|HAND|FOOT|EAR|EYE|[UL](?:ARM|LEG)|HIP)|(?:LEFT|RIGHT)_PEC|HUD_(?:CENTER_[12]|TOP_(?:RIGHT|CENTER|LEFT)|BOTTOM(?:_(?:RIGHT|LEFT))?)|[LR]HAND_RING1|TAIL_(?:BASE|TIP)|[LR]WING|FACE_(?:JAW|[LR]EAR|[LR]EYE|TOUNGE)|GROIN|HIND_[LR]FOOT)|LAND_(?:LEVEL|RAISE|LOWER|SMOOTH|NOISE|REVERT)|DATA_(?:ONLINE|NAME|BORN|SIM_(?:POS|STATUS|RATING)|PAYINFO)|PAYMENT_INFO_(?:ON_FILE|USED)|REMOTE_DATA_(?:CHANNEL|REQUEST|REPLY)|PSYS_(?:PART_(?:BF_(?:ZERO|ONE(?:_MINUS_(?:DEST_COLOR|SOURCE_(ALPHA|COLOR)))?|DEST_COLOR|SOURCE_(ALPHA|COLOR))|BLEND_FUNC_(DEST|SOURCE)|FLAGS|(?:START|END)_(?:COLOR|ALPHA|SCALE|GLOW)|MAX_AGE|(?:RIBBON|WIND|INTERP_(?:COLOR|SCALE)|BOUNCE|FOLLOW_(?:SRC|VELOCITY)|TARGET_(?:POS|LINEAR)|EMISSIVE)_MASK)|SRC_(?:MAX_AGE|PATTERN|ANGLE_(?:BEGIN|END)|BURST_(?:RATE|PART_COUNT|RADIUS|SPEED_(?:MIN|MAX))|ACCEL|TEXTURE|TARGET_KEY|OMEGA|PATTERN_(?:DROP|EXPLODE|ANGLE(?:_CONE(?:_EMPTY)?)?)))|VEHICLE_(?:REFERENCE_FRAME|TYPE_(?:NONE|SLED|CAR|BOAT|AIRPLANE|BALLOON)|(?:LINEAR|ANGULAR)_(?:FRICTION_TIMESCALE|MOTOR_DIRECTION)|LINEAR_MOTOR_OFFSET|HOVER_(?:HEIGHT|EFFICIENCY|TIMESCALE)|BUOYANCY|(?:LINEAR|ANGULAR)_(?:DEFLECTION_(?:EFFICIENCY|TIMESCALE)|MOTOR_(?:DECAY_)?TIMESCALE)|VERTICAL_ATTRACTION_(?:EFFICIENCY|TIMESCALE)|BANKING_(?:EFFICIENCY|MIX|TIMESCALE)|FLAG_(?:NO_DEFLECTION_UP|LIMIT_(?:ROLL_ONLY|MOTOR_UP)|HOVER_(?:(?:WATER|TERRAIN|UP)_ONLY|GLOBAL_HEIGHT)|MOUSELOOK_(?:STEER|BANK)|CAMERA_DECOUPLED))|PRIM_(?:ALPHA_MODE(?:_(?:BLEND|EMISSIVE|MASK|NONE))?|NORMAL|SPECULAR|TYPE(?:_(?:BOX|CYLINDER|PRISM|SPHERE|TORUS|TUBE|RING|SCULPT))?|HOLE_(?:DEFAULT|CIRCLE|SQUARE|TRIANGLE)|MATERIAL(?:_(?:STONE|METAL|GLASS|WOOD|FLESH|PLASTIC|RUBBER))?|SHINY_(?:NONE|LOW|MEDIUM|HIGH)|BUMP_(?:NONE|BRIGHT|DARK|WOOD|BARK|BRICKS|CHECKER|CONCRETE|TILE|STONE|DISKS|GRAVEL|BLOBS|SIDING|LARGETILE|STUCCO|SUCTION|WEAVE)|TEXGEN_(?:DEFAULT|PLANAR)|SCULPT_(?:TYPE_(?:SPHERE|TORUS|PLANE|CYLINDER|MASK)|FLAG_(?:MIRROR|INVERT))|PHYSICS(?:_(?:SHAPE_(?:CONVEX|NONE|PRIM|TYPE)))?|(?:POS|ROT)_LOCAL|SLICE|TEXT|FLEXIBLE|POINT_LIGHT|TEMP_ON_REZ|PHANTOM|POSITION|SIZE|ROTATION|TEXTURE|NAME|OMEGA|DESC|LINK_TARGET|COLOR|BUMP_SHINY|FULLBRIGHT|TEXGEN|GLOW|MEDIA_(?:ALT_IMAGE_ENABLE|CONTROLS|(?:CURRENT|HOME)_URL|AUTO_(?:LOOP|PLAY|SCALE|ZOOM)|FIRST_CLICK_INTERACT|(?:WIDTH|HEIGHT)_PIXELS|WHITELIST(?:_ENABLE)?|PERMS_(?:INTERACT|CONTROL)|PARAM_MAX|CONTROLS_(?:STANDARD|MINI)|PERM_(?:NONE|OWNER|GROUP|ANYONE)|MAX_(?:URL_LENGTH|WHITELIST_(?:SIZE|COUNT)|(?:WIDTH|HEIGHT)_PIXELS)))|MASK_(?:BASE|OWNER|GROUP|EVERYONE|NEXT)|PERM_(?:TRANSFER|MODIFY|COPY|MOVE|ALL)|PARCEL_(?:MEDIA_COMMAND_(?:STOP|PAUSE|PLAY|LOOP|TEXTURE|URL|TIME|AGENT|UNLOAD|AUTO_ALIGN|TYPE|SIZE|DESC|LOOP_SET)|FLAG_(?:ALLOW_(?:FLY|(?:GROUP_)?SCRIPTS|LANDMARK|TERRAFORM|DAMAGE|CREATE_(?:GROUP_)?OBJECTS)|USE_(?:ACCESS_(?:GROUP|LIST)|BAN_LIST|LAND_PASS_LIST)|LOCAL_SOUND_ONLY|RESTRICT_PUSHOBJECT|ALLOW_(?:GROUP|ALL)_OBJECT_ENTRY)|COUNT_(?:TOTAL|OWNER|GROUP|OTHER|SELECTED|TEMP)|DETAILS_(?:NAME|DESC|OWNER|GROUP|AREA|ID|SEE_AVATARS))|LIST_STAT_(?:MAX|MIN|MEAN|MEDIAN|STD_DEV|SUM(?:_SQUARES)?|NUM_COUNT|GEOMETRIC_MEAN|RANGE)|PAY_(?:HIDE|DEFAULT)|REGION_FLAG_(?:ALLOW_DAMAGE|FIXED_SUN|BLOCK_TERRAFORM|SANDBOX|DISABLE_(?:COLLISIONS|PHYSICS)|BLOCK_FLY|ALLOW_DIRECT_TELEPORT|RESTRICT_PUSHOBJECT)|HTTP_(?:METHOD|MIMETYPE|BODY_(?:MAXLENGTH|TRUNCATED)|CUSTOM_HEADER|PRAGMA_NO_CACHE|VERBOSE_THROTTLE|VERIFY_CERT)|STRING_(?:TRIM(?:_(?:HEAD|TAIL))?)|CLICK_ACTION_(?:NONE|TOUCH|SIT|BUY|PAY|OPEN(?:_MEDIA)?|PLAY|ZOOM)|TOUCH_INVALID_FACE|PROFILE_(?:NONE|SCRIPT_MEMORY)|RC_(?:DATA_FLAGS|DETECT_PHANTOM|GET_(?:LINK_NUM|NORMAL|ROOT_KEY)|MAX_HITS|REJECT_(?:TYPES|AGENTS|(?:NON)?PHYSICAL|LAND))|RCERR_(?:CAST_TIME_EXCEEDED|SIM_PERF_LOW|UNKNOWN)|ESTATE_ACCESS_(?:ALLOWED_(?:AGENT|GROUP)_(?:ADD|REMOVE)|BANNED_AGENT_(?:ADD|REMOVE))|DENSITY|FRICTION|RESTITUTION|GRAVITY_MULTIPLIER|KFM_(?:COMMAND|CMD_(?:PLAY|STOP|PAUSE)|MODE|FORWARD|LOOP|PING_PONG|REVERSE|DATA|ROTATION|TRANSLATION)|ERR_(?:GENERIC|PARCEL_PERMISSIONS|MALFORMED_PARAMS|RUNTIME_PERMISSIONS|THROTTLED)|CHARACTER_(?:CMD_(?:(?:SMOOTH_)?STOP|JUMP)|DESIRED_(?:TURN_)?SPEED|RADIUS|STAY_WITHIN_PARCEL|LENGTH|ORIENTATION|ACCOUNT_FOR_SKIPPED_FRAMES|AVOIDANCE_MODE|TYPE(?:_(?:[ABCD]|NONE))?|MAX_(?:DECEL|TURN_RADIUS|(?:ACCEL|SPEED)))|PURSUIT_(?:OFFSET|FUZZ_FACTOR|GOAL_TOLERANCE|INTERCEPT)|REQUIRE_LINE_OF_SIGHT|FORCE_DIRECT_PATH|VERTICAL|HORIZONTAL|AVOID_(?:CHARACTERS|DYNAMIC_OBSTACLES|NONE)|PU_(?:EVADE_(?:HIDDEN|SPOTTED)|FAILURE_(?:DYNAMIC_PATHFINDING_DISABLED|INVALID_(?:GOAL|START)|NO_(?:NAVMESH|VALID_DESTINATION)|OTHER|TARGET_GONE|(?:PARCEL_)?UNREACHABLE)|(?:GOAL|SLOWDOWN_DISTANCE)_REACHED)|TRAVERSAL_TYPE(?:_(?:FAST|NONE|SLOW))?|CONTENT_TYPE_(?:ATOM|FORM|HTML|JSON|LLSD|RSS|TEXT|XHTML|XML)|GCNP_(?:RADIUS|STATIC)|(?:PATROL|WANDER)_PAUSE_AT_WAYPOINTS|OPT_(?:AVATAR|CHARACTER|EXCLUSION_VOLUME|LEGACY_LINKSET|MATERIAL_VOLUME|OTHER|STATIC_OBSTACLE|WALKABLE)|SIM_STAT_PCT_CHARS_STEPPED)\\b"},
+{begin:"\\b(?:FALSE|TRUE)\\b"},{begin:"\\b(?:ZERO_ROTATION)\\b"},{begin:"\\b(?:EOF|JSON_(?:ARRAY|DELETE|FALSE|INVALID|NULL|NUMBER|OBJECT|STRING|TRUE)|NULL_KEY|TEXTURE_(?:BLANK|DEFAULT|MEDIA|PLYWOOD|TRANSPARENT)|URL_REQUEST_(?:GRANTED|DENIED))\\b"},{begin:"\\b(?:ZERO_VECTOR|TOUCH_INVALID_(?:TEXCOORD|VECTOR))\\b"}]},{className:"type",begin:"\\b(?:integer|float|string|key|vector|quaternion|rotation|list)\\b"}]}});b.registerLanguage("lua",function(a){var b={begin:"\\[=*\\[",end:"\\]=*\\]",contains:["self"]},
+d=[a.COMMENT("--(?!\\[=*\\[)","$"),a.COMMENT("--\\[=*\\[","\\]=*\\]",{contains:[b],relevance:10})];return{lexemes:a.UNDERSCORE_IDENT_RE,keywords:{literal:"true false nil",keyword:"and break do else elseif end for goto if in local not or repeat return then until while",built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstringmodule next pairs pcall print rawequal rawget rawset require select setfenvsetmetatable tonumber tostring type unpack xpcall arg selfcoroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"},
+contains:d.concat([{className:"function",beginKeywords:"function",end:"\\)",contains:[a.inherit(a.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",begin:"\\(",endsWithParent:!0,contains:d}].concat(d)},a.C_NUMBER_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"\\[=*\\[",end:"\\]=*\\]",contains:[b],relevance:5}])}});b.registerLanguage("makefile",function(a){var b={className:"variable",variants:[{begin:"\\$\\("+a.UNDERSCORE_IDENT_RE+"\\)",
+contains:[a.BACKSLASH_ESCAPE]},{begin:/\$[@%<?\^\+\*]/}]};return{aliases:["mk","mak"],keywords:"define endef undefine ifdef ifndef ifeq ifneq else endif include -include sinclude override export unexport private vpath",lexemes:/[\w-]+/,contains:[a.HASH_COMMENT_MODE,b,{className:"string",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,b]},{className:"variable",begin:/\$\([\w-]+\s/,end:/\)/,keywords:{built_in:"subst patsubst strip findstring filter filter-out sort word wordlist firstword lastword dir notdir suffix basename addsuffix addprefix join wildcard realpath abspath error warning shell origin flavor foreach if or and call eval file value"},
+contains:[b]},{begin:"^"+a.UNDERSCORE_IDENT_RE+"\\s*[:+?]?=",illegal:"\\n",returnBegin:!0,contains:[{begin:"^"+a.UNDERSCORE_IDENT_RE,end:"[:+?]?=",excludeEnd:!0}]},{className:"meta",begin:/^\.PHONY:/,end:/$/,keywords:{"meta-keyword":".PHONY"},lexemes:/[\.\w]+/},{className:"section",begin:/^[^\s]+:/,end:/$/,contains:[b]}]}});b.registerLanguage("mathematica",function(a){return{aliases:["mma"],lexemes:"(\\$|\\b)"+a.IDENT_RE+"\\b",keywords:"AbelianGroup Abort AbortKernels AbortProtect Above Abs Absolute AbsoluteCorrelation AbsoluteCorrelationFunction AbsoluteCurrentValue AbsoluteDashing AbsoluteFileName AbsoluteOptions AbsolutePointSize AbsoluteThickness AbsoluteTime AbsoluteTiming AccountingForm Accumulate Accuracy AccuracyGoal ActionDelay ActionMenu ActionMenuBox ActionMenuBoxOptions Active ActiveItem ActiveStyle AcyclicGraphQ AddOnHelpPath AddTo AdjacencyGraph AdjacencyList AdjacencyMatrix AdjustmentBox AdjustmentBoxOptions AdjustTimeSeriesForecast AffineTransform After AiryAi AiryAiPrime AiryAiZero AiryBi AiryBiPrime AiryBiZero AlgebraicIntegerQ AlgebraicNumber AlgebraicNumberDenominator AlgebraicNumberNorm AlgebraicNumberPolynomial AlgebraicNumberTrace AlgebraicRules AlgebraicRulesData Algebraics AlgebraicUnitQ Alignment AlignmentMarker AlignmentPoint All AllowedDimensions AllowGroupClose AllowInlineCells AllowKernelInitialization AllowReverseGroupClose AllowScriptLevelChange AlphaChannel AlternatingGroup AlternativeHypothesis Alternatives AmbientLight Analytic AnchoredSearch And AndersonDarlingTest AngerJ AngleBracket AngularGauge Animate AnimationCycleOffset AnimationCycleRepetitions AnimationDirection AnimationDisplayTime AnimationRate AnimationRepetitions AnimationRunning Animator AnimatorBox AnimatorBoxOptions AnimatorElements Annotation Annuity AnnuityDue Antialiasing Antisymmetric Apart ApartSquareFree Appearance AppearanceElements AppellF1 Append AppendTo Apply ArcCos ArcCosh ArcCot ArcCoth ArcCsc ArcCsch ArcSec ArcSech ArcSin ArcSinDistribution ArcSinh ArcTan ArcTanh Arg ArgMax ArgMin ArgumentCountQ ARIMAProcess ArithmeticGeometricMean ARMAProcess ARProcess Array ArrayComponents ArrayDepth ArrayFlatten ArrayPad ArrayPlot ArrayQ ArrayReshape ArrayRules Arrays Arrow Arrow3DBox ArrowBox Arrowheads AspectRatio AspectRatioFixed Assert Assuming Assumptions AstronomicalData Asynchronous AsynchronousTaskObject AsynchronousTasks AtomQ Attributes AugmentedSymmetricPolynomial AutoAction AutoDelete AutoEvaluateEvents AutoGeneratedPackage AutoIndent AutoIndentSpacings AutoItalicWords AutoloadPath AutoMatch Automatic AutomaticImageSize AutoMultiplicationSymbol AutoNumberFormatting AutoOpenNotebooks AutoOpenPalettes AutorunSequencing AutoScaling AutoScroll AutoSpacing AutoStyleOptions AutoStyleWords Axes AxesEdge AxesLabel AxesOrigin AxesStyle Axis BabyMonsterGroupB Back Background BackgroundTasksSettings Backslash Backsubstitution Backward Band BandpassFilter BandstopFilter BarabasiAlbertGraphDistribution BarChart BarChart3D BarLegend BarlowProschanImportance BarnesG BarOrigin BarSpacing BartlettHannWindow BartlettWindow BaseForm Baseline BaselinePosition BaseStyle BatesDistribution BattleLemarieWavelet Because BeckmannDistribution Beep Before Begin BeginDialogPacket BeginFrontEndInteractionPacket BeginPackage BellB BellY Below BenfordDistribution BeniniDistribution BenktanderGibratDistribution BenktanderWeibullDistribution BernoulliB BernoulliDistribution BernoulliGraphDistribution BernoulliProcess BernsteinBasis BesselFilterModel BesselI BesselJ BesselJZero BesselK BesselY BesselYZero Beta BetaBinomialDistribution BetaDistribution BetaNegativeBinomialDistribution BetaPrimeDistribution BetaRegularized BetweennessCentrality BezierCurve BezierCurve3DBox BezierCurve3DBoxOptions BezierCurveBox BezierCurveBoxOptions BezierFunction BilateralFilter Binarize BinaryFormat BinaryImageQ BinaryRead BinaryReadList BinaryWrite BinCounts BinLists Binomial BinomialDistribution BinomialProcess BinormalDistribution BiorthogonalSplineWavelet BipartiteGraphQ BirnbaumImportance BirnbaumSaundersDistribution BitAnd BitClear BitGet BitLength BitNot BitOr BitSet BitShiftLeft BitShiftRight BitXor Black BlackmanHarrisWindow BlackmanNuttallWindow BlackmanWindow Blank BlankForm BlankNullSequence BlankSequence Blend Block BlockRandom BlomqvistBeta BlomqvistBetaTest Blue Blur BodePlot BohmanWindow Bold Bookmarks Boole BooleanConsecutiveFunction BooleanConvert BooleanCountingFunction BooleanFunction BooleanGraph BooleanMaxterms BooleanMinimize BooleanMinterms Booleans BooleanTable BooleanVariables BorderDimensions BorelTannerDistribution Bottom BottomHatTransform BoundaryStyle Bounds Box BoxBaselineShift BoxData BoxDimensions Boxed Boxes BoxForm BoxFormFormatTypes BoxFrame BoxID BoxMargins BoxMatrix BoxRatios BoxRotation BoxRotationPoint BoxStyle BoxWhiskerChart Bra BracketingBar BraKet BrayCurtisDistance BreadthFirstScan Break Brown BrownForsytheTest BrownianBridgeProcess BrowserCategory BSplineBasis BSplineCurve BSplineCurve3DBox BSplineCurveBox BSplineCurveBoxOptions BSplineFunction BSplineSurface BSplineSurface3DBox BubbleChart BubbleChart3D BubbleScale BubbleSizes BulletGauge BusinessDayQ ButterflyGraph ButterworthFilterModel Button ButtonBar ButtonBox ButtonBoxOptions ButtonCell ButtonContents ButtonData ButtonEvaluator ButtonExpandable ButtonFrame ButtonFunction ButtonMargins ButtonMinHeight ButtonNote ButtonNotebook ButtonSource ButtonStyle ButtonStyleMenuListing Byte ByteCount ByteOrdering C CachedValue CacheGraphics CalendarData CalendarType CallPacket CanberraDistance Cancel CancelButton CandlestickChart Cap CapForm CapitalDifferentialD CardinalBSplineBasis CarmichaelLambda Cases Cashflow Casoratian Catalan CatalanNumber Catch CauchyDistribution CauchyWindow CayleyGraph CDF CDFDeploy CDFInformation CDFWavelet Ceiling Cell CellAutoOverwrite CellBaseline CellBoundingBox CellBracketOptions CellChangeTimes CellContents CellContext CellDingbat CellDynamicExpression CellEditDuplicate CellElementsBoundingBox CellElementSpacings CellEpilog CellEvaluationDuplicate CellEvaluationFunction CellEventActions CellFrame CellFrameColor CellFrameLabelMargins CellFrameLabels CellFrameMargins CellGroup CellGroupData CellGrouping CellGroupingRules CellHorizontalScrolling CellID CellLabel CellLabelAutoDelete CellLabelMargins CellLabelPositioning CellMargins CellObject CellOpen CellPrint CellProlog Cells CellSize CellStyle CellTags CellularAutomaton CensoredDistribution Censoring Center CenterDot CentralMoment CentralMomentGeneratingFunction CForm ChampernowneNumber ChanVeseBinarize Character CharacterEncoding CharacterEncodingsPath CharacteristicFunction CharacteristicPolynomial CharacterRange Characters ChartBaseStyle ChartElementData ChartElementDataFunction ChartElementFunction ChartElements ChartLabels ChartLayout ChartLegends ChartStyle Chebyshev1FilterModel Chebyshev2FilterModel ChebyshevDistance ChebyshevT ChebyshevU Check CheckAbort CheckAll Checkbox CheckboxBar CheckboxBox CheckboxBoxOptions ChemicalData ChessboardDistance ChiDistribution ChineseRemainder ChiSquareDistribution ChoiceButtons ChoiceDialog CholeskyDecomposition Chop Circle CircleBox CircleDot CircleMinus CirclePlus CircleTimes CirculantGraph CityData Clear ClearAll ClearAttributes ClearSystemCache ClebschGordan ClickPane Clip ClipboardNotebook ClipFill ClippingStyle ClipPlanes ClipRange Clock ClockGauge ClockwiseContourIntegral Close Closed CloseKernels ClosenessCentrality Closing ClosingAutoSave ClosingEvent ClusteringComponents CMYKColor Coarse Coefficient CoefficientArrays CoefficientDomain CoefficientList CoefficientRules CoifletWavelet Collect Colon ColonForm ColorCombine ColorConvert ColorData ColorDataFunction ColorFunction ColorFunctionScaling Colorize ColorNegate ColorOutput ColorProfileData ColorQuantize ColorReplace ColorRules ColorSelectorSettings ColorSeparate ColorSetter ColorSetterBox ColorSetterBoxOptions ColorSlider ColorSpace Column ColumnAlignments ColumnBackgrounds ColumnForm ColumnLines ColumnsEqual ColumnSpacings ColumnWidths CommonDefaultFormatTypes Commonest CommonestFilter CommonUnits CommunityBoundaryStyle CommunityGraphPlot CommunityLabels CommunityRegionStyle CompatibleUnitQ CompilationOptions CompilationTarget Compile Compiled CompiledFunction Complement CompleteGraph CompleteGraphQ CompleteKaryTree CompletionsListPacket Complex Complexes ComplexExpand ComplexInfinity ComplexityFunction ComponentMeasurements ComponentwiseContextMenu Compose ComposeList ComposeSeries Composition CompoundExpression CompoundPoissonDistribution CompoundPoissonProcess CompoundRenewalProcess Compress CompressedData Condition ConditionalExpression Conditioned Cone ConeBox ConfidenceLevel ConfidenceRange ConfidenceTransform ConfigurationPath Congruent Conjugate ConjugateTranspose Conjunction Connect ConnectedComponents ConnectedGraphQ ConnesWindow ConoverTest ConsoleMessage ConsoleMessagePacket ConsolePrint Constant ConstantArray Constants ConstrainedMax ConstrainedMin ContentPadding ContentsBoundingBox ContentSelectable ContentSize Context ContextMenu Contexts ContextToFilename ContextToFileName Continuation Continue ContinuedFraction ContinuedFractionK ContinuousAction ContinuousMarkovProcess ContinuousTimeModelQ ContinuousWaveletData ContinuousWaveletTransform ContourDetect ContourGraphics ContourIntegral ContourLabels ContourLines ContourPlot ContourPlot3D Contours ContourShading ContourSmoothing ContourStyle ContraharmonicMean Control ControlActive ControlAlignment ControllabilityGramian ControllabilityMatrix ControllableDecomposition ControllableModelQ ControllerDuration ControllerInformation ControllerInformationData ControllerLinking ControllerManipulate ControllerMethod ControllerPath ControllerState ControlPlacement ControlsRendering ControlType Convergents ConversionOptions ConversionRules ConvertToBitmapPacket ConvertToPostScript ConvertToPostScriptPacket Convolve ConwayGroupCo1 ConwayGroupCo2 ConwayGroupCo3 CoordinateChartData CoordinatesToolOptions CoordinateTransform CoordinateTransformData CoprimeQ Coproduct CopulaDistribution Copyable CopyDirectory CopyFile CopyTag CopyToClipboard CornerFilter CornerNeighbors Correlation CorrelationDistance CorrelationFunction CorrelationTest Cos Cosh CoshIntegral CosineDistance CosineWindow CosIntegral Cot Coth Count CounterAssignments CounterBox CounterBoxOptions CounterClockwiseContourIntegral CounterEvaluator CounterFunction CounterIncrements CounterStyle CounterStyleMenuListing CountRoots CountryData Covariance CovarianceEstimatorFunction CovarianceFunction CoxianDistribution CoxIngersollRossProcess CoxModel CoxModelFit CramerVonMisesTest CreateArchive CreateDialog CreateDirectory CreateDocument CreateIntermediateDirectories CreatePalette CreatePalettePacket CreateScheduledTask CreateTemporary CreateWindow CriticalityFailureImportance CriticalitySuccessImportance CriticalSection Cross CrossingDetect CrossMatrix Csc Csch CubeRoot Cubics Cuboid CuboidBox Cumulant CumulantGeneratingFunction Cup CupCap Curl CurlyDoubleQuote CurlyQuote CurrentImage CurrentlySpeakingPacket CurrentValue CurvatureFlowFilter CurveClosed Cyan CycleGraph CycleIndexPolynomial Cycles CyclicGroup Cyclotomic Cylinder CylinderBox CylindricalDecomposition D DagumDistribution DamerauLevenshteinDistance DampingFactor Darker Dashed Dashing DataCompression DataDistribution DataRange DataReversed Date DateDelimiters DateDifference DateFunction DateList DateListLogPlot DateListPlot DatePattern DatePlus DateRange DateString DateTicksFormat DaubechiesWavelet DavisDistribution DawsonF DayCount DayCountConvention DayMatchQ DayName DayPlus DayRange DayRound DeBruijnGraph Debug DebugTag Decimal DeclareKnownSymbols DeclarePackage Decompose Decrement DedekindEta Default DefaultAxesStyle DefaultBaseStyle DefaultBoxStyle DefaultButton DefaultColor DefaultControlPlacement DefaultDuplicateCellStyle DefaultDuration DefaultElement DefaultFaceGridsStyle DefaultFieldHintStyle DefaultFont DefaultFontProperties DefaultFormatType DefaultFormatTypeForStyle DefaultFrameStyle DefaultFrameTicksStyle DefaultGridLinesStyle DefaultInlineFormatType DefaultInputFormatType DefaultLabelStyle DefaultMenuStyle DefaultNaturalLanguage DefaultNewCellStyle DefaultNewInlineCellStyle DefaultNotebook DefaultOptions DefaultOutputFormatType DefaultStyle DefaultStyleDefinitions DefaultTextFormatType DefaultTextInlineFormatType DefaultTicksStyle DefaultTooltipStyle DefaultValues Defer DefineExternal DefineInputStreamMethod DefineOutputStreamMethod Definition Degree DegreeCentrality DegreeGraphDistribution DegreeLexicographic DegreeReverseLexicographic Deinitialization Del Deletable Delete DeleteBorderComponents DeleteCases DeleteContents DeleteDirectory DeleteDuplicates DeleteFile DeleteSmallComponents DeleteWithContents DeletionWarning Delimiter DelimiterFlashTime DelimiterMatching Delimiters Denominator DensityGraphics DensityHistogram DensityPlot DependentVariables Deploy Deployed Depth DepthFirstScan Derivative DerivativeFilter DescriptorStateSpace DesignMatrix Det DGaussianWavelet DiacriticalPositioning Diagonal DiagonalMatrix Dialog DialogIndent DialogInput DialogLevel DialogNotebook DialogProlog DialogReturn DialogSymbols Diamond DiamondMatrix DiceDissimilarity DictionaryLookup DifferenceDelta DifferenceOrder DifferenceRoot DifferenceRootReduce Differences DifferentialD DifferentialRoot DifferentialRootReduce DifferentiatorFilter DigitBlock DigitBlockMinimum DigitCharacter DigitCount DigitQ DihedralGroup Dilation Dimensions DiracComb DiracDelta DirectedEdge DirectedEdges DirectedGraph DirectedGraphQ DirectedInfinity Direction Directive Directory DirectoryName DirectoryQ DirectoryStack DirichletCharacter DirichletConvolve DirichletDistribution DirichletL DirichletTransform DirichletWindow DisableConsolePrintPacket DiscreteChirpZTransform DiscreteConvolve DiscreteDelta DiscreteHadamardTransform DiscreteIndicator DiscreteLQEstimatorGains DiscreteLQRegulatorGains DiscreteLyapunovSolve DiscreteMarkovProcess DiscretePlot DiscretePlot3D DiscreteRatio DiscreteRiccatiSolve DiscreteShift DiscreteTimeModelQ DiscreteUniformDistribution DiscreteVariables DiscreteWaveletData DiscreteWaveletPacketTransform DiscreteWaveletTransform Discriminant Disjunction Disk DiskBox DiskMatrix Dispatch DispersionEstimatorFunction Display DisplayAllSteps DisplayEndPacket DisplayFlushImagePacket DisplayForm DisplayFunction DisplayPacket DisplayRules DisplaySetSizePacket DisplayString DisplayTemporary DisplayWith DisplayWithRef DisplayWithVariable DistanceFunction DistanceTransform Distribute Distributed DistributedContexts DistributeDefinitions DistributionChart DistributionDomain DistributionFitTest DistributionParameterAssumptions DistributionParameterQ Dithering Div Divergence Divide DivideBy Dividers Divisible Divisors DivisorSigma DivisorSum DMSList DMSString Do DockedCells DocumentNotebook DominantColors DOSTextFormat Dot DotDashed DotEqual Dotted DoubleBracketingBar DoubleContourIntegral DoubleDownArrow DoubleLeftArrow DoubleLeftRightArrow DoubleLeftTee DoubleLongLeftArrow DoubleLongLeftRightArrow DoubleLongRightArrow DoubleRightArrow DoubleRightTee DoubleUpArrow DoubleUpDownArrow DoubleVerticalBar DoublyInfinite Down DownArrow DownArrowBar DownArrowUpArrow DownLeftRightVector DownLeftTeeVector DownLeftVector DownLeftVectorBar DownRightTeeVector DownRightVector DownRightVectorBar Downsample DownTee DownTeeArrow DownValues DragAndDrop DrawEdges DrawFrontFaces DrawHighlighted Drop DSolve Dt DualLinearProgramming DualSystemsModel DumpGet DumpSave DuplicateFreeQ Dynamic DynamicBox DynamicBoxOptions DynamicEvaluationTimeout DynamicLocation DynamicModule DynamicModuleBox DynamicModuleBoxOptions DynamicModuleParent DynamicModuleValues DynamicName DynamicNamespace DynamicReference DynamicSetting DynamicUpdating DynamicWrapper DynamicWrapperBox DynamicWrapperBoxOptions E EccentricityCentrality EdgeAdd EdgeBetweennessCentrality EdgeCapacity EdgeCapForm EdgeColor EdgeConnectivity EdgeCost EdgeCount EdgeCoverQ EdgeDashing EdgeDelete EdgeDetect EdgeForm EdgeIndex EdgeJoinForm EdgeLabeling EdgeLabels EdgeLabelStyle EdgeList EdgeOpacity EdgeQ EdgeRenderingFunction EdgeRules EdgeShapeFunction EdgeStyle EdgeThickness EdgeWeight Editable EditButtonSettings EditCellTagsSettings EditDistance EffectiveInterest Eigensystem Eigenvalues EigenvectorCentrality Eigenvectors Element ElementData Eliminate EliminationOrder EllipticE EllipticExp EllipticExpPrime EllipticF EllipticFilterModel EllipticK EllipticLog EllipticNomeQ EllipticPi EllipticReducedHalfPeriods EllipticTheta EllipticThetaPrime EmitSound EmphasizeSyntaxErrors EmpiricalDistribution Empty EmptyGraphQ EnableConsolePrintPacket Enabled Encode End EndAdd EndDialogPacket EndFrontEndInteractionPacket EndOfFile EndOfLine EndOfString EndPackage EngineeringForm Enter EnterExpressionPacket EnterTextPacket Entropy EntropyFilter Environment Epilog Equal EqualColumns EqualRows EqualTilde EquatedTo Equilibrium EquirippleFilterKernel Equivalent Erf Erfc Erfi ErlangB ErlangC ErlangDistribution Erosion ErrorBox ErrorBoxOptions ErrorNorm ErrorPacket ErrorsDialogSettings EstimatedDistribution EstimatedProcess EstimatorGains EstimatorRegulator EuclideanDistance EulerE EulerGamma EulerianGraphQ EulerPhi Evaluatable Evaluate Evaluated EvaluatePacket EvaluationCell EvaluationCompletionAction EvaluationElements EvaluationMode EvaluationMonitor EvaluationNotebook EvaluationObject EvaluationOrder Evaluator EvaluatorNames EvenQ EventData EventEvaluator EventHandler EventHandlerTag EventLabels ExactBlackmanWindow ExactNumberQ ExactRootIsolation ExampleData Except ExcludedForms ExcludePods Exclusions ExclusionsStyle Exists Exit ExitDialog Exp Expand ExpandAll ExpandDenominator ExpandFileName ExpandNumerator Expectation ExpectationE ExpectedValue ExpGammaDistribution ExpIntegralE ExpIntegralEi Exponent ExponentFunction ExponentialDistribution ExponentialFamily ExponentialGeneratingFunction ExponentialMovingAverage ExponentialPowerDistribution ExponentPosition ExponentStep Export ExportAutoReplacements ExportPacket ExportString Expression ExpressionCell ExpressionPacket ExpToTrig ExtendedGCD Extension ExtentElementFunction ExtentMarkers ExtentSize ExternalCall ExternalDataCharacterEncoding Extract ExtractArchive ExtremeValueDistribution FaceForm FaceGrids FaceGridsStyle Factor FactorComplete Factorial Factorial2 FactorialMoment FactorialMomentGeneratingFunction FactorialPower FactorInteger FactorList FactorSquareFree FactorSquareFreeList FactorTerms FactorTermsList Fail FailureDistribution False FARIMAProcess FEDisableConsolePrintPacket FeedbackSector FeedbackSectorStyle FeedbackType FEEnableConsolePrintPacket Fibonacci FieldHint FieldHintStyle FieldMasked FieldSize File FileBaseName FileByteCount FileDate FileExistsQ FileExtension FileFormat FileHash FileInformation FileName FileNameDepth FileNameDialogSettings FileNameDrop FileNameJoin FileNames FileNameSetter FileNameSplit FileNameTake FilePrint FileType FilledCurve FilledCurveBox Filling FillingStyle FillingTransform FilterRules FinancialBond FinancialData FinancialDerivative FinancialIndicator Find FindArgMax FindArgMin FindClique FindClusters FindCurvePath FindDistributionParameters FindDivisions FindEdgeCover FindEdgeCut FindEulerianCycle FindFaces FindFile FindFit FindGeneratingFunction FindGeoLocation FindGeometricTransform FindGraphCommunities FindGraphIsomorphism FindGraphPartition FindHamiltonianCycle FindIndependentEdgeSet FindIndependentVertexSet FindInstance FindIntegerNullVector FindKClan FindKClique FindKClub FindKPlex FindLibrary FindLinearRecurrence FindList FindMaximum FindMaximumFlow FindMaxValue FindMinimum FindMinimumCostFlow FindMinimumCut FindMinValue FindPermutation FindPostmanTour FindProcessParameters FindRoot FindSequenceFunction FindSettings FindShortestPath FindShortestTour FindThreshold FindVertexCover FindVertexCut Fine FinishDynamic FiniteAbelianGroupCount FiniteGroupCount FiniteGroupData First FirstPassageTimeDistribution FischerGroupFi22 FischerGroupFi23 FischerGroupFi24Prime FisherHypergeometricDistribution FisherRatioTest FisherZDistribution Fit FitAll FittedModel FixedPoint FixedPointList FlashSelection Flat Flatten FlattenAt FlatTopWindow FlipView Floor FlushPrintOutputPacket Fold FoldList Font FontColor FontFamily FontForm FontName FontOpacity FontPostScriptName FontProperties FontReencoding FontSize FontSlant FontSubstitutions FontTracking FontVariations FontWeight For ForAll Format FormatRules FormatType FormatTypeAutoConvert FormatValues FormBox FormBoxOptions FortranForm Forward ForwardBackward Fourier FourierCoefficient FourierCosCoefficient FourierCosSeries FourierCosTransform FourierDCT FourierDCTFilter FourierDCTMatrix FourierDST FourierDSTMatrix FourierMatrix FourierParameters FourierSequenceTransform FourierSeries FourierSinCoefficient FourierSinSeries FourierSinTransform FourierTransform FourierTrigSeries FractionalBrownianMotionProcess FractionalPart FractionBox FractionBoxOptions FractionLine Frame FrameBox FrameBoxOptions Framed FrameInset FrameLabel Frameless FrameMargins FrameStyle FrameTicks FrameTicksStyle FRatioDistribution FrechetDistribution FreeQ FrequencySamplingFilterKernel FresnelC FresnelS Friday FrobeniusNumber FrobeniusSolve FromCharacterCode FromCoefficientRules FromContinuedFraction FromDate FromDigits FromDMS Front FrontEndDynamicExpression FrontEndEventActions FrontEndExecute FrontEndObject FrontEndResource FrontEndResourceString FrontEndStackSize FrontEndToken FrontEndTokenExecute FrontEndValueCache FrontEndVersion FrontFaceColor FrontFaceOpacity Full FullAxes FullDefinition FullForm FullGraphics FullOptions FullSimplify Function FunctionExpand FunctionInterpolation FunctionSpace FussellVeselyImportance GaborFilter GaborMatrix GaborWavelet GainMargins GainPhaseMargins Gamma GammaDistribution GammaRegularized GapPenalty Gather GatherBy GaugeFaceElementFunction GaugeFaceStyle GaugeFrameElementFunction GaugeFrameSize GaugeFrameStyle GaugeLabels GaugeMarkers GaugeStyle GaussianFilter GaussianIntegers GaussianMatrix GaussianWindow GCD GegenbauerC General GeneralizedLinearModelFit GenerateConditions GeneratedCell GeneratedParameters GeneratingFunction Generic GenericCylindricalDecomposition GenomeData GenomeLookup GeodesicClosing GeodesicDilation GeodesicErosion GeodesicOpening GeoDestination GeodesyData GeoDirection GeoDistance GeoGridPosition GeometricBrownianMotionProcess GeometricDistribution GeometricMean GeometricMeanFilter GeometricTransformation GeometricTransformation3DBox GeometricTransformation3DBoxOptions GeometricTransformationBox GeometricTransformationBoxOptions GeoPosition GeoPositionENU GeoPositionXYZ GeoProjectionData GestureHandler GestureHandlerTag Get GetBoundingBoxSizePacket GetContext GetEnvironment GetFileName GetFrontEndOptionsDataPacket GetLinebreakInformationPacket GetMenusPacket GetPageBreakInformationPacket Glaisher GlobalClusteringCoefficient GlobalPreferences GlobalSession Glow GoldenRatio GompertzMakehamDistribution GoodmanKruskalGamma GoodmanKruskalGammaTest Goto Grad Gradient GradientFilter GradientOrientationFilter Graph GraphAssortativity GraphCenter GraphComplement GraphData GraphDensity GraphDiameter GraphDifference GraphDisjointUnion GraphDistance GraphDistanceMatrix GraphElementData GraphEmbedding GraphHighlight GraphHighlightStyle GraphHub Graphics Graphics3D Graphics3DBox Graphics3DBoxOptions GraphicsArray GraphicsBaseline GraphicsBox GraphicsBoxOptions GraphicsColor GraphicsColumn GraphicsComplex GraphicsComplex3DBox GraphicsComplex3DBoxOptions GraphicsComplexBox GraphicsComplexBoxOptions GraphicsContents GraphicsData GraphicsGrid GraphicsGridBox GraphicsGroup GraphicsGroup3DBox GraphicsGroup3DBoxOptions GraphicsGroupBox GraphicsGroupBoxOptions GraphicsGrouping GraphicsHighlightColor GraphicsRow GraphicsSpacing GraphicsStyle GraphIntersection GraphLayout GraphLinkEfficiency GraphPeriphery GraphPlot GraphPlot3D GraphPower GraphPropertyDistribution GraphQ GraphRadius GraphReciprocity GraphRoot GraphStyle GraphUnion Gray GrayLevel GreatCircleDistance Greater GreaterEqual GreaterEqualLess GreaterFullEqual GreaterGreater GreaterLess GreaterSlantEqual GreaterTilde Green Grid GridBaseline GridBox GridBoxAlignment GridBoxBackground GridBoxDividers GridBoxFrame GridBoxItemSize GridBoxItemStyle GridBoxOptions GridBoxSpacings GridCreationSettings GridDefaultElement GridElementStyleOptions GridFrame GridFrameMargins GridGraph GridLines GridLinesStyle GroebnerBasis GroupActionBase GroupCentralizer GroupElementFromWord GroupElementPosition GroupElementQ GroupElements GroupElementToWord GroupGenerators GroupMultiplicationTable GroupOrbits GroupOrder GroupPageBreakWithin GroupSetwiseStabilizer GroupStabilizer GroupStabilizerChain Gudermannian GumbelDistribution HaarWavelet HadamardMatrix HalfNormalDistribution HamiltonianGraphQ HammingDistance HammingWindow HankelH1 HankelH2 HankelMatrix HannPoissonWindow HannWindow HaradaNortonGroupHN HararyGraph HarmonicMean HarmonicMeanFilter HarmonicNumber Hash HashTable Haversine HazardFunction Head HeadCompose Heads HeavisideLambda HeavisidePi HeavisideTheta HeldGroupHe HeldPart HelpBrowserLookup HelpBrowserNotebook HelpBrowserSettings HermiteDecomposition HermiteH HermitianMatrixQ HessenbergDecomposition Hessian HexadecimalCharacter Hexahedron HexahedronBox HexahedronBoxOptions HiddenSurface HighlightGraph HighlightImage HighpassFilter HigmanSimsGroupHS HilbertFilter HilbertMatrix Histogram Histogram3D HistogramDistribution HistogramList HistogramTransform HistogramTransformInterpolation HitMissTransform HITSCentrality HodgeDual HoeffdingD HoeffdingDTest Hold HoldAll HoldAllComplete HoldComplete HoldFirst HoldForm HoldPattern HoldRest HolidayCalendar HomeDirectory HomePage Horizontal HorizontalForm HorizontalGauge HorizontalScrollPosition HornerForm HotellingTSquareDistribution HoytDistribution HTMLSave Hue HumpDownHump HumpEqual HurwitzLerchPhi HurwitzZeta HyperbolicDistribution HypercubeGraph HyperexponentialDistribution Hyperfactorial Hypergeometric0F1 Hypergeometric0F1Regularized Hypergeometric1F1 Hypergeometric1F1Regularized Hypergeometric2F1 Hypergeometric2F1Regularized HypergeometricDistribution HypergeometricPFQ HypergeometricPFQRegularized HypergeometricU Hyperlink HyperlinkCreationSettings Hyphenation HyphenationOptions HypoexponentialDistribution HypothesisTestData I Identity IdentityMatrix If IgnoreCase Im Image Image3D Image3DSlices ImageAccumulate ImageAdd ImageAdjust ImageAlign ImageApply ImageAspectRatio ImageAssemble ImageCache ImageCacheValid ImageCapture ImageChannels ImageClip ImageColorSpace ImageCompose ImageConvolve ImageCooccurrence ImageCorners ImageCorrelate ImageCorrespondingPoints ImageCrop ImageData ImageDataPacket ImageDeconvolve ImageDemosaic ImageDifference ImageDimensions ImageDistance ImageEffect ImageFeatureTrack ImageFileApply ImageFileFilter ImageFileScan ImageFilter ImageForestingComponents ImageForwardTransformation ImageHistogram ImageKeypoints ImageLevels ImageLines ImageMargins ImageMarkers ImageMeasurements ImageMultiply ImageOffset ImagePad ImagePadding ImagePartition ImagePeriodogram ImagePerspectiveTransformation ImageQ ImageRangeCache ImageReflect ImageRegion ImageResize ImageResolution ImageRotate ImageRotated ImageScaled ImageScan ImageSize ImageSizeAction ImageSizeCache ImageSizeMultipliers ImageSizeRaw ImageSubtract ImageTake ImageTransformation ImageTrim ImageType ImageValue ImageValuePositions Implies Import ImportAutoReplacements ImportString ImprovementImportance In IncidenceGraph IncidenceList IncidenceMatrix IncludeConstantBasis IncludeFileExtension IncludePods IncludeSingularTerm Increment Indent IndentingNewlineSpacings IndentMaxFraction IndependenceTest IndependentEdgeSetQ IndependentUnit IndependentVertexSetQ Indeterminate IndexCreationOptions Indexed IndexGraph IndexTag Inequality InexactNumberQ InexactNumbers Infinity Infix Information Inherited InheritScope Initialization InitializationCell InitializationCellEvaluation InitializationCellWarning InlineCounterAssignments InlineCounterIncrements InlineRules Inner Inpaint Input InputAliases InputAssumptions InputAutoReplacements InputField InputFieldBox InputFieldBoxOptions InputForm InputGrouping InputNamePacket InputNotebook InputPacket InputSettings InputStream InputString InputStringPacket InputToBoxFormPacket Insert InsertionPointObject InsertResults Inset Inset3DBox Inset3DBoxOptions InsetBox InsetBoxOptions Install InstallService InString Integer IntegerDigits IntegerExponent IntegerLength IntegerPart IntegerPartitions IntegerQ Integers IntegerString Integral Integrate Interactive InteractiveTradingChart Interlaced Interleaving InternallyBalancedDecomposition InterpolatingFunction InterpolatingPolynomial Interpolation InterpolationOrder InterpolationPoints InterpolationPrecision Interpretation InterpretationBox InterpretationBoxOptions InterpretationFunction InterpretTemplate InterquartileRange Interrupt InterruptSettings Intersection Interval IntervalIntersection IntervalMemberQ IntervalUnion Inverse InverseBetaRegularized InverseCDF InverseChiSquareDistribution InverseContinuousWaveletTransform InverseDistanceTransform InverseEllipticNomeQ InverseErf InverseErfc InverseFourier InverseFourierCosTransform InverseFourierSequenceTransform InverseFourierSinTransform InverseFourierTransform InverseFunction InverseFunctions InverseGammaDistribution InverseGammaRegularized InverseGaussianDistribution InverseGudermannian InverseHaversine InverseJacobiCD InverseJacobiCN InverseJacobiCS InverseJacobiDC InverseJacobiDN InverseJacobiDS InverseJacobiNC InverseJacobiND InverseJacobiNS InverseJacobiSC InverseJacobiSD InverseJacobiSN InverseLaplaceTransform InversePermutation InverseRadon InverseSeries InverseSurvivalFunction InverseWaveletTransform InverseWeierstrassP InverseZTransform Invisible InvisibleApplication InvisibleTimes IrreduciblePolynomialQ IsolatingInterval IsomorphicGraphQ IsotopeData Italic Item ItemBox ItemBoxOptions ItemSize ItemStyle ItoProcess JaccardDissimilarity JacobiAmplitude Jacobian JacobiCD JacobiCN JacobiCS JacobiDC JacobiDN JacobiDS JacobiNC JacobiND JacobiNS JacobiP JacobiSC JacobiSD JacobiSN JacobiSymbol JacobiZeta JankoGroupJ1 JankoGroupJ2 JankoGroupJ3 JankoGroupJ4 JarqueBeraALMTest JohnsonDistribution Join Joined JoinedCurve JoinedCurveBox JoinForm JordanDecomposition JordanModelDecomposition K KagiChart KaiserBesselWindow KaiserWindow KalmanEstimator KalmanFilter KarhunenLoeveDecomposition KaryTree KatzCentrality KCoreComponents KDistribution KelvinBei KelvinBer KelvinKei KelvinKer KendallTau KendallTauTest KernelExecute KernelMixtureDistribution KernelObject Kernels Ket Khinchin KirchhoffGraph KirchhoffMatrix KleinInvariantJ KnightTourGraph KnotData KnownUnitQ KolmogorovSmirnovTest KroneckerDelta KroneckerModelDecomposition KroneckerProduct KroneckerSymbol KuiperTest KumaraswamyDistribution Kurtosis KuwaharaFilter Label Labeled LabeledSlider LabelingFunction LabelStyle LaguerreL LambdaComponents LambertW LanczosWindow LandauDistribution Language LanguageCategory LaplaceDistribution LaplaceTransform Laplacian LaplacianFilter LaplacianGaussianFilter Large Larger Last Latitude LatitudeLongitude LatticeData LatticeReduce Launch LaunchKernels LayeredGraphPlot LayerSizeFunction LayoutInformation LCM LeafCount LeapYearQ LeastSquares LeastSquaresFilterKernel Left LeftArrow LeftArrowBar LeftArrowRightArrow LeftDownTeeVector LeftDownVector LeftDownVectorBar LeftRightArrow LeftRightVector LeftTee LeftTeeArrow LeftTeeVector LeftTriangle LeftTriangleBar LeftTriangleEqual LeftUpDownVector LeftUpTeeVector LeftUpVector LeftUpVectorBar LeftVector LeftVectorBar LegendAppearance Legended LegendFunction LegendLabel LegendLayout LegendMargins LegendMarkers LegendMarkerSize LegendreP LegendreQ LegendreType Length LengthWhile LerchPhi Less LessEqual LessEqualGreater LessFullEqual LessGreater LessLess LessSlantEqual LessTilde LetterCharacter LetterQ Level LeveneTest LeviCivitaTensor LevyDistribution Lexicographic LibraryFunction LibraryFunctionError LibraryFunctionInformation LibraryFunctionLoad LibraryFunctionUnload LibraryLoad LibraryUnload LicenseID LiftingFilterData LiftingWaveletTransform LightBlue LightBrown LightCyan Lighter LightGray LightGreen Lighting LightingAngle LightMagenta LightOrange LightPink LightPurple LightRed LightSources LightYellow Likelihood Limit LimitsPositioning LimitsPositioningTokens LindleyDistribution Line Line3DBox LinearFilter LinearFractionalTransform LinearModelFit LinearOffsetFunction LinearProgramming LinearRecurrence LinearSolve LinearSolveFunction LineBox LineBreak LinebreakAdjustments LineBreakChart LineBreakWithin LineColor LineForm LineGraph LineIndent LineIndentMaxFraction LineIntegralConvolutionPlot LineIntegralConvolutionScale LineLegend LineOpacity LineSpacing LineWrapParts LinkActivate LinkClose LinkConnect LinkConnectedQ LinkCreate LinkError LinkFlush LinkFunction LinkHost LinkInterrupt LinkLaunch LinkMode LinkObject LinkOpen LinkOptions LinkPatterns LinkProtocol LinkRead LinkReadHeld LinkReadyQ Links LinkWrite LinkWriteHeld LiouvilleLambda List Listable ListAnimate ListContourPlot ListContourPlot3D ListConvolve ListCorrelate ListCurvePathPlot ListDeconvolve ListDensityPlot Listen ListFourierSequenceTransform ListInterpolation ListLineIntegralConvolutionPlot ListLinePlot ListLogLinearPlot ListLogLogPlot ListLogPlot ListPicker ListPickerBox ListPickerBoxBackground ListPickerBoxOptions ListPlay ListPlot ListPlot3D ListPointPlot3D ListPolarPlot ListQ ListStreamDensityPlot ListStreamPlot ListSurfacePlot3D ListVectorDensityPlot ListVectorPlot ListVectorPlot3D ListZTransform Literal LiteralSearch LocalClusteringCoefficient LocalizeVariables LocationEquivalenceTest LocationTest Locator LocatorAutoCreate LocatorBox LocatorBoxOptions LocatorCentering LocatorPane LocatorPaneBox LocatorPaneBoxOptions LocatorRegion Locked Log Log10 Log2 LogBarnesG LogGamma LogGammaDistribution LogicalExpand LogIntegral LogisticDistribution LogitModelFit LogLikelihood LogLinearPlot LogLogisticDistribution LogLogPlot LogMultinormalDistribution LogNormalDistribution LogPlot LogRankTest LogSeriesDistribution LongEqual Longest LongestAscendingSequence LongestCommonSequence LongestCommonSequencePositions LongestCommonSubsequence LongestCommonSubsequencePositions LongestMatch LongForm Longitude LongLeftArrow LongLeftRightArrow LongRightArrow Loopback LoopFreeGraphQ LowerCaseQ LowerLeftArrow LowerRightArrow LowerTriangularize LowpassFilter LQEstimatorGains LQGRegulator LQOutputRegulatorGains LQRegulatorGains LUBackSubstitution LucasL LuccioSamiComponents LUDecomposition LyapunovSolve LyonsGroupLy MachineID MachineName MachineNumberQ MachinePrecision MacintoshSystemPageSetup Magenta Magnification Magnify MainSolve MaintainDynamicCaches Majority MakeBoxes MakeExpression MakeRules MangoldtLambda ManhattanDistance Manipulate Manipulator MannWhitneyTest MantissaExponent Manual Map MapAll MapAt MapIndexed MAProcess MapThread MarcumQ MardiaCombinedTest MardiaKurtosisTest MardiaSkewnessTest MarginalDistribution MarkovProcessProperties Masking MatchingDissimilarity MatchLocalNameQ MatchLocalNames MatchQ Material MathematicaNotation MathieuC MathieuCharacteristicA MathieuCharacteristicB MathieuCharacteristicExponent MathieuCPrime MathieuGroupM11 MathieuGroupM12 MathieuGroupM22 MathieuGroupM23 MathieuGroupM24 MathieuS MathieuSPrime MathMLForm MathMLText Matrices MatrixExp MatrixForm MatrixFunction MatrixLog MatrixPlot MatrixPower MatrixQ MatrixRank Max MaxBend MaxDetect MaxExtraBandwidths MaxExtraConditions MaxFeatures MaxFilter Maximize MaxIterations MaxMemoryUsed MaxMixtureKernels MaxPlotPoints MaxPoints MaxRecursion MaxStableDistribution MaxStepFraction MaxSteps MaxStepSize MaxValue MaxwellDistribution McLaughlinGroupMcL Mean MeanClusteringCoefficient MeanDegreeConnectivity MeanDeviation MeanFilter MeanGraphDistance MeanNeighborDegree MeanShift MeanShiftFilter Median MedianDeviation MedianFilter Medium MeijerG MeixnerDistribution MemberQ MemoryConstrained MemoryInUse Menu MenuAppearance MenuCommandKey MenuEvaluator MenuItem MenuPacket MenuSortingValue MenuStyle MenuView MergeDifferences Mesh MeshFunctions MeshRange MeshShading MeshStyle Message MessageDialog MessageList MessageName MessageOptions MessagePacket Messages MessagesNotebook MetaCharacters MetaInformation Method MethodOptions MexicanHatWavelet MeyerWavelet Min MinDetect MinFilter MinimalPolynomial MinimalStateSpaceModel Minimize Minors MinRecursion MinSize MinStableDistribution Minus MinusPlus MinValue Missing MissingDataMethod MittagLefflerE MixedRadix MixedRadixQuantity MixtureDistribution Mod Modal Mode Modular ModularLambda Module Modulus MoebiusMu Moment Momentary MomentConvert MomentEvaluate MomentGeneratingFunction Monday Monitor MonomialList MonomialOrder MonsterGroupM MorletWavelet MorphologicalBinarize MorphologicalBranchPoints MorphologicalComponents MorphologicalEulerNumber MorphologicalGraph MorphologicalPerimeter MorphologicalTransform Most MouseAnnotation MouseAppearance MouseAppearanceTag MouseButtons Mouseover MousePointerNote MousePosition MovingAverage MovingMedian MoyalDistribution MultiedgeStyle MultilaunchWarning MultiLetterItalics MultiLetterStyle MultilineFunction Multinomial MultinomialDistribution MultinormalDistribution MultiplicativeOrder Multiplicity Multiselection MultivariateHypergeometricDistribution MultivariatePoissonDistribution MultivariateTDistribution N NakagamiDistribution NameQ Names NamespaceBox Nand NArgMax NArgMin NBernoulliB NCache NDSolve NDSolveValue Nearest NearestFunction NeedCurrentFrontEndPackagePacket NeedCurrentFrontEndSymbolsPacket NeedlemanWunschSimilarity Needs Negative NegativeBinomialDistribution NegativeMultinomialDistribution NeighborhoodGraph Nest NestedGreaterGreater NestedLessLess NestedScriptRules NestList NestWhile NestWhileList NevilleThetaC NevilleThetaD NevilleThetaN NevilleThetaS NewPrimitiveStyle NExpectation Next NextPrime NHoldAll NHoldFirst NHoldRest NicholsGridLines NicholsPlot NIntegrate NMaximize NMaxValue NMinimize NMinValue NominalVariables NonAssociative NoncentralBetaDistribution NoncentralChiSquareDistribution NoncentralFRatioDistribution NoncentralStudentTDistribution NonCommutativeMultiply NonConstants None NonlinearModelFit NonlocalMeansFilter NonNegative NonPositive Nor NorlundB Norm Normal NormalDistribution NormalGrouping Normalize NormalizedSquaredEuclideanDistance NormalsFunction NormFunction Not NotCongruent NotCupCap NotDoubleVerticalBar Notebook NotebookApply NotebookAutoSave NotebookClose NotebookConvertSettings NotebookCreate NotebookCreateReturnObject NotebookDefault NotebookDelete NotebookDirectory NotebookDynamicExpression NotebookEvaluate NotebookEventActions NotebookFileName NotebookFind NotebookFindReturnObject NotebookGet NotebookGetLayoutInformationPacket NotebookGetMisspellingsPacket NotebookInformation NotebookInterfaceObject NotebookLocate NotebookObject NotebookOpen NotebookOpenReturnObject NotebookPath NotebookPrint NotebookPut NotebookPutReturnObject NotebookRead NotebookResetGeneratedCells Notebooks NotebookSave NotebookSaveAs NotebookSelection NotebookSetupLayoutInformationPacket NotebooksMenu NotebookWrite NotElement NotEqualTilde NotExists NotGreater NotGreaterEqual NotGreaterFullEqual NotGreaterGreater NotGreaterLess NotGreaterSlantEqual NotGreaterTilde NotHumpDownHump NotHumpEqual NotLeftTriangle NotLeftTriangleBar NotLeftTriangleEqual NotLess NotLessEqual NotLessFullEqual NotLessGreater NotLessLess NotLessSlantEqual NotLessTilde NotNestedGreaterGreater NotNestedLessLess NotPrecedes NotPrecedesEqual NotPrecedesSlantEqual NotPrecedesTilde NotReverseElement NotRightTriangle NotRightTriangleBar NotRightTriangleEqual NotSquareSubset NotSquareSubsetEqual NotSquareSuperset NotSquareSupersetEqual NotSubset NotSubsetEqual NotSucceeds NotSucceedsEqual NotSucceedsSlantEqual NotSucceedsTilde NotSuperset NotSupersetEqual NotTilde NotTildeEqual NotTildeFullEqual NotTildeTilde NotVerticalBar NProbability NProduct NProductFactors NRoots NSolve NSum NSumTerms Null NullRecords NullSpace NullWords Number NumberFieldClassNumber NumberFieldDiscriminant NumberFieldFundamentalUnits NumberFieldIntegralBasis NumberFieldNormRepresentatives NumberFieldRegulator NumberFieldRootsOfUnity NumberFieldSignature NumberForm NumberFormat NumberMarks NumberMultiplier NumberPadding NumberPoint NumberQ NumberSeparator NumberSigns NumberString Numerator NumericFunction NumericQ NuttallWindow NValues NyquistGridLines NyquistPlot O ObservabilityGramian ObservabilityMatrix ObservableDecomposition ObservableModelQ OddQ Off Offset OLEData On ONanGroupON OneIdentity Opacity Open OpenAppend Opener OpenerBox OpenerBoxOptions OpenerView OpenFunctionInspectorPacket Opening OpenRead OpenSpecialOptions OpenTemporary OpenWrite Operate OperatingSystem OptimumFlowData Optional OptionInspectorSettings OptionQ Options OptionsPacket OptionsPattern OptionValue OptionValueBox OptionValueBoxOptions Or Orange Order OrderDistribution OrderedQ Ordering Orderless OrnsteinUhlenbeckProcess Orthogonalize Out Outer OutputAutoOverwrite OutputControllabilityMatrix OutputControllableModelQ OutputForm OutputFormData OutputGrouping OutputMathEditExpression OutputNamePacket OutputResponse OutputSizeLimit OutputStream Over OverBar OverDot Overflow OverHat Overlaps Overlay OverlayBox OverlayBoxOptions Overscript OverscriptBox OverscriptBoxOptions OverTilde OverVector OwenT OwnValues PackingMethod PaddedForm Padding PadeApproximant PadLeft PadRight PageBreakAbove PageBreakBelow PageBreakWithin PageFooterLines PageFooters PageHeaderLines PageHeaders PageHeight PageRankCentrality PageWidth PairedBarChart PairedHistogram PairedSmoothHistogram PairedTTest PairedZTest PaletteNotebook PalettePath Pane PaneBox PaneBoxOptions Panel PanelBox PanelBoxOptions Paneled PaneSelector PaneSelectorBox PaneSelectorBoxOptions PaperWidth ParabolicCylinderD ParagraphIndent ParagraphSpacing ParallelArray ParallelCombine ParallelDo ParallelEvaluate Parallelization Parallelize ParallelMap ParallelNeeds ParallelProduct ParallelSubmit ParallelSum ParallelTable ParallelTry Parameter ParameterEstimator ParameterMixtureDistribution ParameterVariables ParametricFunction ParametricNDSolve ParametricNDSolveValue ParametricPlot ParametricPlot3D ParentConnect ParentDirectory ParentForm Parenthesize ParentList ParetoDistribution Part PartialCorrelationFunction PartialD ParticleData Partition PartitionsP PartitionsQ ParzenWindow PascalDistribution PassEventsDown PassEventsUp Paste PasteBoxFormInlineCells PasteButton Path PathGraph PathGraphQ Pattern PatternSequence PatternTest PauliMatrix PaulWavelet Pause PausedTime PDF PearsonChiSquareTest PearsonCorrelationTest PearsonDistribution PerformanceGoal PeriodicInterpolation Periodogram PeriodogramArray PermutationCycles PermutationCyclesQ PermutationGroup PermutationLength PermutationList PermutationListQ PermutationMax PermutationMin PermutationOrder PermutationPower PermutationProduct PermutationReplace Permutations PermutationSupport Permute PeronaMalikFilter Perpendicular PERTDistribution PetersenGraph PhaseMargins Pi Pick PIDData PIDDerivativeFilter PIDFeedforward PIDTune Piecewise PiecewiseExpand PieChart PieChart3D PillaiTrace PillaiTraceTest Pink Pivoting PixelConstrained PixelValue PixelValuePositions Placed Placeholder PlaceholderReplace Plain PlanarGraphQ Play PlayRange Plot Plot3D Plot3Matrix PlotDivision PlotJoined PlotLabel PlotLayout PlotLegends PlotMarkers PlotPoints PlotRange PlotRangeClipping PlotRangePadding PlotRegion PlotStyle Plus PlusMinus Pochhammer PodStates PodWidth Point Point3DBox PointBox PointFigureChart PointForm PointLegend PointSize PoissonConsulDistribution PoissonDistribution PoissonProcess PoissonWindow PolarAxes PolarAxesOrigin PolarGridLines PolarPlot PolarTicks PoleZeroMarkers PolyaAeppliDistribution PolyGamma Polygon Polygon3DBox Polygon3DBoxOptions PolygonBox PolygonBoxOptions PolygonHoleScale PolygonIntersections PolygonScale PolyhedronData PolyLog PolynomialExtendedGCD PolynomialForm PolynomialGCD PolynomialLCM PolynomialMod PolynomialQ PolynomialQuotient PolynomialQuotientRemainder PolynomialReduce PolynomialRemainder Polynomials PopupMenu PopupMenuBox PopupMenuBoxOptions PopupView PopupWindow Position Positive PositiveDefiniteMatrixQ PossibleZeroQ Postfix PostScript Power PowerDistribution PowerExpand PowerMod PowerModList PowerSpectralDensity PowersRepresentations PowerSymmetricPolynomial Precedence PrecedenceForm Precedes PrecedesEqual PrecedesSlantEqual PrecedesTilde Precision PrecisionGoal PreDecrement PredictionRoot PreemptProtect PreferencesPath Prefix PreIncrement Prepend PrependTo PreserveImageOptions Previous PriceGraphDistribution PrimaryPlaceholder Prime PrimeNu PrimeOmega PrimePi PrimePowerQ PrimeQ Primes PrimeZetaP PrimitiveRoot PrincipalComponents PrincipalValue Print PrintAction PrintForm PrintingCopies PrintingOptions PrintingPageRange PrintingStartingPageNumber PrintingStyleEnvironment PrintPrecision PrintTemporary Prism PrismBox PrismBoxOptions PrivateCellOptions PrivateEvaluationOptions PrivateFontOptions PrivateFrontEndOptions PrivateNotebookOptions PrivatePaths Probability ProbabilityDistribution ProbabilityPlot ProbabilityPr ProbabilityScalePlot ProbitModelFit ProcessEstimator ProcessParameterAssumptions ProcessParameterQ ProcessStateDomain ProcessTimeDomain Product ProductDistribution ProductLog ProgressIndicator ProgressIndicatorBox ProgressIndicatorBoxOptions Projection Prolog PromptForm Properties Property PropertyList PropertyValue Proportion Proportional Protect Protected ProteinData Pruning PseudoInverse Purple Put PutAppend Pyramid PyramidBox PyramidBoxOptions QBinomial QFactorial QGamma QHypergeometricPFQ QPochhammer QPolyGamma QRDecomposition QuadraticIrrationalQ Quantile QuantilePlot Quantity QuantityForm QuantityMagnitude QuantityQ QuantityUnit Quartics QuartileDeviation Quartiles QuartileSkewness QueueingNetworkProcess QueueingProcess QueueProperties Quiet Quit Quotient QuotientRemainder RadialityCentrality RadicalBox RadicalBoxOptions RadioButton RadioButtonBar RadioButtonBox RadioButtonBoxOptions Radon RamanujanTau RamanujanTauL RamanujanTauTheta RamanujanTauZ Random RandomChoice RandomComplex RandomFunction RandomGraph RandomImage RandomInteger RandomPermutation RandomPrime RandomReal RandomSample RandomSeed RandomVariate RandomWalkProcess Range RangeFilter RangeSpecification RankedMax RankedMin Raster Raster3D Raster3DBox Raster3DBoxOptions RasterArray RasterBox RasterBoxOptions Rasterize RasterSize Rational RationalFunctions Rationalize Rationals Ratios Raw RawArray RawBoxes RawData RawMedium RayleighDistribution Re Read ReadList ReadProtected Real RealBlockDiagonalForm RealDigits RealExponent Reals Reap Record RecordLists RecordSeparators Rectangle RectangleBox RectangleBoxOptions RectangleChart RectangleChart3D RecurrenceFilter RecurrenceTable RecurringDigitsForm Red Reduce RefBox ReferenceLineStyle ReferenceMarkers ReferenceMarkerStyle Refine ReflectionMatrix ReflectionTransform Refresh RefreshRate RegionBinarize RegionFunction RegionPlot RegionPlot3D RegularExpression Regularization Reinstall Release ReleaseHold ReliabilityDistribution ReliefImage ReliefPlot Remove RemoveAlphaChannel RemoveAsynchronousTask Removed RemoveInputStreamMethod RemoveOutputStreamMethod RemoveProperty RemoveScheduledTask RenameDirectory RenameFile RenderAll RenderingOptions RenewalProcess RenkoChart Repeated RepeatedNull RepeatedString Replace ReplaceAll ReplaceHeldPart ReplaceImageValue ReplaceList ReplacePart ReplacePixelValue ReplaceRepeated Resampling Rescale RescalingTransform ResetDirectory ResetMenusPacket ResetScheduledTask Residue Resolve Rest Resultant ResumePacket Return ReturnExpressionPacket ReturnInputFormPacket ReturnPacket ReturnTextPacket Reverse ReverseBiorthogonalSplineWavelet ReverseElement ReverseEquilibrium ReverseGraph ReverseUpEquilibrium RevolutionAxis RevolutionPlot3D RGBColor RiccatiSolve RiceDistribution RidgeFilter RiemannR RiemannSiegelTheta RiemannSiegelZ Riffle Right RightArrow RightArrowBar RightArrowLeftArrow RightCosetRepresentative RightDownTeeVector RightDownVector RightDownVectorBar RightTee RightTeeArrow RightTeeVector RightTriangle RightTriangleBar RightTriangleEqual RightUpDownVector RightUpTeeVector RightUpVector RightUpVectorBar RightVector RightVectorBar RiskAchievementImportance RiskReductionImportance RogersTanimotoDissimilarity Root RootApproximant RootIntervals RootLocusPlot RootMeanSquare RootOfUnityQ RootReduce Roots RootSum Rotate RotateLabel RotateLeft RotateRight RotationAction RotationBox RotationBoxOptions RotationMatrix RotationTransform Round RoundImplies RoundingRadius Row RowAlignments RowBackgrounds RowBox RowHeights RowLines RowMinHeight RowReduce RowsEqual RowSpacings RSolve RudvalisGroupRu Rule RuleCondition RuleDelayed RuleForm RulerUnits Run RunScheduledTask RunThrough RuntimeAttributes RuntimeOptions RussellRaoDissimilarity SameQ SameTest SampleDepth SampledSoundFunction SampledSoundList SampleRate SamplingPeriod SARIMAProcess SARMAProcess SatisfiabilityCount SatisfiabilityInstances SatisfiableQ Saturday Save Saveable SaveAutoDelete SaveDefinitions SawtoothWave Scale Scaled ScaleDivisions ScaledMousePosition ScaleOrigin ScalePadding ScaleRanges ScaleRangeStyle ScalingFunctions ScalingMatrix ScalingTransform Scan ScheduledTaskActiveQ ScheduledTaskData ScheduledTaskObject ScheduledTasks SchurDecomposition ScientificForm ScreenRectangle ScreenStyleEnvironment ScriptBaselineShifts ScriptLevel ScriptMinSize ScriptRules ScriptSizeMultipliers Scrollbars ScrollingOptions ScrollPosition Sec Sech SechDistribution SectionGrouping SectorChart SectorChart3D SectorOrigin SectorSpacing SeedRandom Select Selectable SelectComponents SelectedCells SelectedNotebook Selection SelectionAnimate SelectionCell SelectionCellCreateCell SelectionCellDefaultStyle SelectionCellParentStyle SelectionCreateCell SelectionDebuggerTag SelectionDuplicateCell SelectionEvaluate SelectionEvaluateCreateCell SelectionMove SelectionPlaceholder SelectionSetStyle SelectWithContents SelfLoops SelfLoopStyle SemialgebraicComponentInstances SendMail Sequence SequenceAlignment SequenceForm SequenceHold SequenceLimit Series SeriesCoefficient SeriesData SessionTime Set SetAccuracy SetAlphaChannel SetAttributes Setbacks SetBoxFormNamesPacket SetDelayed SetDirectory SetEnvironment SetEvaluationNotebook SetFileDate SetFileLoadingContext SetNotebookStatusLine SetOptions SetOptionsPacket SetPrecision SetProperty SetSelectedNotebook SetSharedFunction SetSharedVariable SetSpeechParametersPacket SetStreamPosition SetSystemOptions Setter SetterBar SetterBox SetterBoxOptions Setting SetValue Shading Shallow ShannonWavelet ShapiroWilkTest Share Sharpen ShearingMatrix ShearingTransform ShenCastanMatrix Short ShortDownArrow Shortest ShortestMatch ShortestPathFunction ShortLeftArrow ShortRightArrow ShortUpArrow Show ShowAutoStyles ShowCellBracket ShowCellLabel ShowCellTags ShowClosedCellArea ShowContents ShowControls ShowCursorTracker ShowGroupOpenCloseIcon ShowGroupOpener ShowInvisibleCharacters ShowPageBreaks ShowPredictiveInterface ShowSelection ShowShortBoxForm ShowSpecialCharacters ShowStringCharacters ShowSyntaxStyles ShrinkingDelay ShrinkWrapBoundingBox SiegelTheta SiegelTukeyTest Sign Signature SignedRankTest SignificanceLevel SignPadding SignTest SimilarityRules SimpleGraph SimpleGraphQ Simplify Sin Sinc SinghMaddalaDistribution SingleEvaluation SingleLetterItalics SingleLetterStyle SingularValueDecomposition SingularValueList SingularValuePlot SingularValues Sinh SinhIntegral SinIntegral SixJSymbol Skeleton SkeletonTransform SkellamDistribution Skewness SkewNormalDistribution Skip SliceDistribution Slider Slider2D Slider2DBox Slider2DBoxOptions SliderBox SliderBoxOptions SlideView Slot SlotSequence Small SmallCircle Smaller SmithDelayCompensator SmithWatermanSimilarity SmoothDensityHistogram SmoothHistogram SmoothHistogram3D SmoothKernelDistribution SocialMediaData Socket SokalSneathDissimilarity Solve SolveAlways SolveDelayed Sort SortBy Sound SoundAndGraphics SoundNote SoundVolume Sow Space SpaceForm Spacer Spacings Span SpanAdjustments SpanCharacterRounding SpanFromAbove SpanFromBoth SpanFromLeft SpanLineThickness SpanMaxSize SpanMinSize SpanningCharacters SpanSymmetric SparseArray SpatialGraphDistribution Speak SpeakTextPacket SpearmanRankTest SpearmanRho Spectrogram SpectrogramArray Specularity SpellingCorrection SpellingDictionaries SpellingDictionariesPath SpellingOptions SpellingSuggestionsPacket Sphere SphereBox SphericalBesselJ SphericalBesselY SphericalHankelH1 SphericalHankelH2 SphericalHarmonicY SphericalPlot3D SphericalRegion SpheroidalEigenvalue SpheroidalJoiningFactor SpheroidalPS SpheroidalPSPrime SpheroidalQS SpheroidalQSPrime SpheroidalRadialFactor SpheroidalS1 SpheroidalS1Prime SpheroidalS2 SpheroidalS2Prime Splice SplicedDistribution SplineClosed SplineDegree SplineKnots SplineWeights Split SplitBy SpokenString Sqrt SqrtBox SqrtBoxOptions Square SquaredEuclideanDistance SquareFreeQ SquareIntersection SquaresR SquareSubset SquareSubsetEqual SquareSuperset SquareSupersetEqual SquareUnion SquareWave StabilityMargins StabilityMarginsStyle StableDistribution Stack StackBegin StackComplete StackInhibit StandardDeviation StandardDeviationFilter StandardForm Standardize StandbyDistribution Star StarGraph StartAsynchronousTask StartingStepSize StartOfLine StartOfString StartScheduledTask StartupSound StateDimensions StateFeedbackGains StateOutputEstimator StateResponse StateSpaceModel StateSpaceRealization StateSpaceTransform StationaryDistribution StationaryWaveletPacketTransform StationaryWaveletTransform StatusArea StatusCentrality StepMonitor StieltjesGamma StirlingS1 StirlingS2 StopAsynchronousTask StopScheduledTask StrataVariables StratonovichProcess StreamColorFunction StreamColorFunctionScaling StreamDensityPlot StreamPlot StreamPoints StreamPosition Streams StreamScale StreamStyle String StringBreak StringByteCount StringCases StringCount StringDrop StringExpression StringForm StringFormat StringFreeQ StringInsert StringJoin StringLength StringMatchQ StringPosition StringQ StringReplace StringReplaceList StringReplacePart StringReverse StringRotateLeft StringRotateRight StringSkeleton StringSplit StringTake StringToStream StringTrim StripBoxes StripOnInput StripWrapperBoxes StrokeForm StructuralImportance StructuredArray StructuredSelection StruveH StruveL Stub StudentTDistribution Style StyleBox StyleBoxAutoDelete StyleBoxOptions StyleData StyleDefinitions StyleForm StyleKeyMapping StyleMenuListing StyleNameDialogSettings StyleNames StylePrint StyleSheetPath Subfactorial Subgraph SubMinus SubPlus SubresultantPolynomialRemainders SubresultantPolynomials Subresultants Subscript SubscriptBox SubscriptBoxOptions Subscripted Subset SubsetEqual Subsets SubStar Subsuperscript SubsuperscriptBox SubsuperscriptBoxOptions Subtract SubtractFrom SubValues Succeeds SucceedsEqual SucceedsSlantEqual SucceedsTilde SuchThat Sum SumConvergence Sunday SuperDagger SuperMinus SuperPlus Superscript SuperscriptBox SuperscriptBoxOptions Superset SupersetEqual SuperStar Surd SurdForm SurfaceColor SurfaceGraphics SurvivalDistribution SurvivalFunction SurvivalModel SurvivalModelFit SuspendPacket SuzukiDistribution SuzukiGroupSuz SwatchLegend Switch Symbol SymbolName SymletWavelet Symmetric SymmetricGroup SymmetricMatrixQ SymmetricPolynomial SymmetricReduction Symmetrize SymmetrizedArray SymmetrizedArrayRules SymmetrizedDependentComponents SymmetrizedIndependentComponents SymmetrizedReplacePart SynchronousInitialization SynchronousUpdating Syntax SyntaxForm SyntaxInformation SyntaxLength SyntaxPacket SyntaxQ SystemDialogInput SystemException SystemHelpPath SystemInformation SystemInformationData SystemOpen SystemOptions SystemsModelDelay SystemsModelDelayApproximate SystemsModelDelete SystemsModelDimensions SystemsModelExtract SystemsModelFeedbackConnect SystemsModelLabels SystemsModelOrder SystemsModelParallelConnect SystemsModelSeriesConnect SystemsModelStateFeedbackConnect SystemStub Tab TabFilling Table TableAlignments TableDepth TableDirections TableForm TableHeadings TableSpacing TableView TableViewBox TabSpacings TabView TabViewBox TabViewBoxOptions TagBox TagBoxNote TagBoxOptions TaggingRules TagSet TagSetDelayed TagStyle TagUnset Take TakeWhile Tally Tan Tanh TargetFunctions TargetUnits TautologyQ TelegraphProcess TemplateBox TemplateBoxOptions TemplateSlotSequence TemporalData Temporary TemporaryVariable TensorContract TensorDimensions TensorExpand TensorProduct TensorQ TensorRank TensorReduce TensorSymmetry TensorTranspose TensorWedge Tetrahedron TetrahedronBox TetrahedronBoxOptions TeXForm TeXSave Text Text3DBox Text3DBoxOptions TextAlignment TextBand TextBoundingBox TextBox TextCell TextClipboardType TextData TextForm TextJustification TextLine TextPacket TextParagraph TextRecognize TextRendering TextStyle Texture TextureCoordinateFunction TextureCoordinateScaling Therefore ThermometerGauge Thick Thickness Thin Thinning ThisLink ThompsonGroupTh Thread ThreeJSymbol Threshold Through Throw Thumbnail Thursday Ticks TicksStyle Tilde TildeEqual TildeFullEqual TildeTilde TimeConstrained TimeConstraint Times TimesBy TimeSeriesForecast TimeSeriesInvertibility TimeUsed TimeValue TimeZone Timing Tiny TitleGrouping TitsGroupT ToBoxes ToCharacterCode ToColor ToContinuousTimeModel ToDate ToDiscreteTimeModel ToeplitzMatrix ToExpression ToFileName Together Toggle ToggleFalse Toggler TogglerBar TogglerBox TogglerBoxOptions ToHeldExpression ToInvertibleTimeSeries TokenWords Tolerance ToLowerCase ToNumberField TooBig Tooltip TooltipBox TooltipBoxOptions TooltipDelay TooltipStyle Top TopHatTransform TopologicalSort ToRadicals ToRules ToString Total TotalHeight TotalVariationFilter TotalWidth TouchscreenAutoZoom TouchscreenControlPlacement ToUpperCase Tr Trace TraceAbove TraceAction TraceBackward TraceDepth TraceDialog TraceForward TraceInternal TraceLevel TraceOff TraceOn TraceOriginal TracePrint TraceScan TrackedSymbols TradingChart TraditionalForm TraditionalFunctionNotation TraditionalNotation TraditionalOrder TransferFunctionCancel TransferFunctionExpand TransferFunctionFactor TransferFunctionModel TransferFunctionPoles TransferFunctionTransform TransferFunctionZeros TransformationFunction TransformationFunctions TransformationMatrix TransformedDistribution TransformedField Translate TranslationTransform TransparentColor Transpose TreeForm TreeGraph TreeGraphQ TreePlot TrendStyle TriangleWave TriangularDistribution Trig TrigExpand TrigFactor TrigFactorList Trigger TrigReduce TrigToExp TrimmedMean True TrueQ TruncatedDistribution TsallisQExponentialDistribution TsallisQGaussianDistribution TTest Tube TubeBezierCurveBox TubeBezierCurveBoxOptions TubeBox TubeBSplineCurveBox TubeBSplineCurveBoxOptions Tuesday TukeyLambdaDistribution TukeyWindow Tuples TuranGraph TuringMachine Transparent UnateQ Uncompress Undefined UnderBar Underflow Underlined Underoverscript UnderoverscriptBox UnderoverscriptBoxOptions Underscript UnderscriptBox UnderscriptBoxOptions UndirectedEdge UndirectedGraph UndirectedGraphQ UndocumentedTestFEParserPacket UndocumentedTestGetSelectionPacket Unequal Unevaluated UniformDistribution UniformGraphDistribution UniformSumDistribution Uninstall Union UnionPlus Unique UnitBox UnitConvert UnitDimensions Unitize UnitRootTest UnitSimplify UnitStep UnitTriangle UnitVector Unprotect UnsameQ UnsavedVariables Unset UnsetShared UntrackedVariables Up UpArrow UpArrowBar UpArrowDownArrow Update UpdateDynamicObjects UpdateDynamicObjectsSynchronous UpdateInterval UpDownArrow UpEquilibrium UpperCaseQ UpperLeftArrow UpperRightArrow UpperTriangularize Upsample UpSet UpSetDelayed UpTee UpTeeArrow UpValues URL URLFetch URLFetchAsynchronous URLSave URLSaveAsynchronous UseGraphicsRange Using UsingFrontEnd V2Get ValidationLength Value ValueBox ValueBoxOptions ValueForm ValueQ ValuesData Variables Variance VarianceEquivalenceTest VarianceEstimatorFunction VarianceGammaDistribution VarianceTest VectorAngle VectorColorFunction VectorColorFunctionScaling VectorDensityPlot VectorGlyphData VectorPlot VectorPlot3D VectorPoints VectorQ Vectors VectorScale VectorStyle Vee Verbatim Verbose VerboseConvertToPostScriptPacket VerifyConvergence VerifySolutions VerifyTestAssumptions Version VersionNumber VertexAdd VertexCapacity VertexColors VertexComponent VertexConnectivity VertexCoordinateRules VertexCoordinates VertexCorrelationSimilarity VertexCosineSimilarity VertexCount VertexCoverQ VertexDataCoordinates VertexDegree VertexDelete VertexDiceSimilarity VertexEccentricity VertexInComponent VertexInDegree VertexIndex VertexJaccardSimilarity VertexLabeling VertexLabels VertexLabelStyle VertexList VertexNormals VertexOutComponent VertexOutDegree VertexQ VertexRenderingFunction VertexReplace VertexShape VertexShapeFunction VertexSize VertexStyle VertexTextureCoordinates VertexWeight Vertical VerticalBar VerticalForm VerticalGauge VerticalSeparator VerticalSlider VerticalTilde ViewAngle ViewCenter ViewMatrix ViewPoint ViewPointSelectorSettings ViewPort ViewRange ViewVector ViewVertical VirtualGroupData Visible VisibleCell VoigtDistribution VonMisesDistribution WaitAll WaitAsynchronousTask WaitNext WaitUntil WakebyDistribution WalleniusHypergeometricDistribution WaringYuleDistribution WatershedComponents WatsonUSquareTest WattsStrogatzGraphDistribution WaveletBestBasis WaveletFilterCoefficients WaveletImagePlot WaveletListPlot WaveletMapIndexed WaveletMatrixPlot WaveletPhi WaveletPsi WaveletScale WaveletScalogram WaveletThreshold WeaklyConnectedComponents WeaklyConnectedGraphQ WeakStationarity WeatherData WeberE Wedge Wednesday WeibullDistribution WeierstrassHalfPeriods WeierstrassInvariants WeierstrassP WeierstrassPPrime WeierstrassSigma WeierstrassZeta WeightedAdjacencyGraph WeightedAdjacencyMatrix WeightedData WeightedGraphQ Weights WelchWindow WheelGraph WhenEvent Which While White Whitespace WhitespaceCharacter WhittakerM WhittakerW WienerFilter WienerProcess WignerD WignerSemicircleDistribution WilksW WilksWTest WindowClickSelect WindowElements WindowFloating WindowFrame WindowFrameElements WindowMargins WindowMovable WindowOpacity WindowSelected WindowSize WindowStatusArea WindowTitle WindowToolbars WindowWidth With WolframAlpha WolframAlphaDate WolframAlphaQuantity WolframAlphaResult Word WordBoundary WordCharacter WordData WordSearch WordSeparators WorkingPrecision Write WriteString Wronskian XMLElement XMLObject Xnor Xor Yellow YuleDissimilarity ZernikeR ZeroSymmetric ZeroTest ZeroWidthTimes Zeta ZetaZero ZipfDistribution ZTest ZTransform $Aborted $ActivationGroupID $ActivationKey $ActivationUserRegistered $AddOnsDirectory $AssertFunction $Assumptions $AsynchronousTask $BaseDirectory $BatchInput $BatchOutput $BoxForms $ByteOrdering $Canceled $CharacterEncoding $CharacterEncodings $CommandLine $CompilationTarget $ConditionHold $ConfiguredKernels $Context $ContextPath $ControlActiveSetting $CreationDate $CurrentLink $DateStringFormat $DefaultFont $DefaultFrontEnd $DefaultImagingDevice $DefaultPath $Display $DisplayFunction $DistributedContexts $DynamicEvaluation $Echo $Epilog $ExportFormats $Failed $FinancialDataSource $FormatType $FrontEnd $FrontEndSession $GeoLocation $HistoryLength $HomeDirectory $HTTPCookies $IgnoreEOF $ImagingDevices $ImportFormats $InitialDirectory $Input $InputFileName $InputStreamMethods $Inspector $InstallationDate $InstallationDirectory $InterfaceEnvironment $IterationLimit $KernelCount $KernelID $Language $LaunchDirectory $LibraryPath $LicenseExpirationDate $LicenseID $LicenseProcesses $LicenseServer $LicenseSubprocesses $LicenseType $Line $Linked $LinkSupported $LoadedFiles $MachineAddresses $MachineDomain $MachineDomains $MachineEpsilon $MachineID $MachineName $MachinePrecision $MachineType $MaxExtraPrecision $MaxLicenseProcesses $MaxLicenseSubprocesses $MaxMachineNumber $MaxNumber $MaxPiecewiseCases $MaxPrecision $MaxRootDegree $MessageGroups $MessageList $MessagePrePrint $Messages $MinMachineNumber $MinNumber $MinorReleaseNumber $MinPrecision $ModuleNumber $NetworkLicense $NewMessage $NewSymbol $Notebooks $NumberMarks $Off $OperatingSystem $Output $OutputForms $OutputSizeLimit $OutputStreamMethods $Packages $ParentLink $ParentProcessID $PasswordFile $PatchLevelID $Path $PathnameSeparator $PerformanceGoal $PipeSupported $Post $Pre $PreferencesDirectory $PrePrint $PreRead $PrintForms $PrintLiteral $ProcessID $ProcessorCount $ProcessorType $ProductInformation $ProgramName $RandomState $RecursionLimit $ReleaseNumber $RootDirectory $ScheduledTask $ScriptCommandLine $SessionID $SetParentLink $SharedFunctions $SharedVariables $SoundDisplay $SoundDisplayFunction $SuppressInputFormHeads $SynchronousEvaluation $SyntaxHandler $System $SystemCharacterEncoding $SystemID $SystemWordLength $TemporaryDirectory $TemporaryPrefix $TextStyle $TimedOut $TimeUnit $TimeZone $TopDirectory $TraceOff $TraceOn $TracePattern $TracePostAction $TracePreAction $Urgent $UserAddOnsDirectory $UserBaseDirectory $UserDocumentsDirectory $UserName $Version $VersionNumber",
+contains:[{className:"comment",begin:/\(\*/,end:/\*\)/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,{begin:/\{/,end:/\}/,illegal:/:/}]}});b.registerLanguage("matlab",function(a){var b={relevance:0,contains:[{begin:"('|\\.')+"}]};return{keywords:{keyword:"break case catch classdef continue else elseif end enumerated events for function global if methods otherwise parfor persistent properties return spmd switch try while",built_in:"sin sind sinh asin asind asinh cos cosd cosh acos acosd acosh tan tand tanh atan atand atan2 atanh sec secd sech asec asecd asech csc cscd csch acsc acscd acsch cot cotd coth acot acotd acoth hypot exp expm1 log log1p log10 log2 pow2 realpow reallog realsqrt sqrt nthroot nextpow2 abs angle complex conj imag real unwrap isreal cplxpair fix floor ceil round mod rem sign airy besselj bessely besselh besseli besselk beta betainc betaln ellipj ellipke erf erfc erfcx erfinv expint gamma gammainc gammaln psi legendre cross dot factor isprime primes gcd lcm rat rats perms nchoosek factorial cart2sph cart2pol pol2cart sph2cart hsv2rgb rgb2hsv zeros ones eye repmat rand randn linspace logspace freqspace meshgrid accumarray size length ndims numel disp isempty isequal isequalwithequalnans cat reshape diag blkdiag tril triu fliplr flipud flipdim rot90 find sub2ind ind2sub bsxfun ndgrid permute ipermute shiftdim circshift squeeze isscalar isvector ans eps realmax realmin pi i inf nan isnan isinf isfinite j why compan gallery hadamard hankel hilb invhilb magic pascal rosser toeplitz vander wilkinson max min nanmax nanmin mean nanmean type table readtable writetable sortrows sort figure plot plot3 scatter scatter3 cellfun legend intersect ismember procrustes hold num2cell "},
+illegal:'(//|"|#|/\\*|\\s+/\\w+)',contains:[{className:"function",beginKeywords:"function",end:"$",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",variants:[{begin:"\\(",end:"\\)"},{begin:"\\[",end:"\\]"}]}]},{className:"built_in",begin:/true|false/,relevance:0,starts:b},{begin:"[a-zA-Z][a-zA-Z_0-9]*('|\\.')+",relevance:0},{className:"number",begin:a.C_NUMBER_RE,relevance:0,starts:b},{className:"string",begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE,{begin:"''"}]},{begin:/\]|}|\)/,relevance:0,
+starts:b},{className:"string",begin:'"',end:'"',contains:[a.BACKSLASH_ESCAPE,{begin:'""'}],starts:b},a.COMMENT("^\\s*\\%\\{\\s*$","^\\s*\\%\\}\\s*$"),a.COMMENT("\\%","$")]}});b.registerLanguage("maxima",function(a){return{lexemes:"[A-Za-z_%][0-9A-Za-z_%]*",keywords:{keyword:"if then else elseif for thru do while unless step in and or not",literal:"true false unknown inf minf ind und %e %i %pi %phi %gamma",built_in:" abasep abs absint absolute_real_time acos acosh acot acoth acsc acsch activate addcol add_edge add_edges addmatrices addrow add_vertex add_vertices adjacency_matrix adjoin adjoint af agd airy airy_ai airy_bi airy_dai airy_dbi algsys alg_type alias allroots alphacharp alphanumericp amortization %and annuity_fv annuity_pv antid antidiff AntiDifference append appendfile apply apply1 apply2 applyb1 apropos args arit_amortization arithmetic arithsum array arrayapply arrayinfo arraymake arraysetapply ascii asec asech asin asinh askinteger asksign assoc assoc_legendre_p assoc_legendre_q assume assume_external_byte_order asympa at atan atan2 atanh atensimp atom atvalue augcoefmatrix augmented_lagrangian_method av average_degree backtrace bars barsplot barsplot_description base64 base64_decode bashindices batch batchload bc2 bdvac belln benefit_cost bern bernpoly bernstein_approx bernstein_expand bernstein_poly bessel bessel_i bessel_j bessel_k bessel_simplify bessel_y beta beta_incomplete beta_incomplete_generalized beta_incomplete_regularized bezout bfallroots bffac bf_find_root bf_fmin_cobyla bfhzeta bfloat bfloatp bfpsi bfpsi0 bfzeta biconnected_components bimetric binomial bipartition block blockmatrixp bode_gain bode_phase bothcoef box boxplot boxplot_description break bug_report build_info|10 buildq build_sample burn cabs canform canten cardinality carg cartan cartesian_product catch cauchy_matrix cbffac cdf_bernoulli cdf_beta cdf_binomial cdf_cauchy cdf_chi2 cdf_continuous_uniform cdf_discrete_uniform cdf_exp cdf_f cdf_gamma cdf_general_finite_discrete cdf_geometric cdf_gumbel cdf_hypergeometric cdf_laplace cdf_logistic cdf_lognormal cdf_negative_binomial cdf_noncentral_chi2 cdf_noncentral_student_t cdf_normal cdf_pareto cdf_poisson cdf_rank_sum cdf_rayleigh cdf_signed_rank cdf_student_t cdf_weibull cdisplay ceiling central_moment cequal cequalignore cf cfdisrep cfexpand cgeodesic cgreaterp cgreaterpignore changename changevar chaosgame charat charfun charfun2 charlist charp charpoly chdir chebyshev_t chebyshev_u checkdiv check_overlaps chinese cholesky christof chromatic_index chromatic_number cint circulant_graph clear_edge_weight clear_rules clear_vertex_label clebsch_gordan clebsch_graph clessp clesspignore close closefile cmetric coeff coefmatrix cograd col collapse collectterms columnop columnspace columnswap columnvector combination combine comp2pui compare compfile compile compile_file complement_graph complete_bipartite_graph complete_graph complex_number_p components compose_functions concan concat conjugate conmetderiv connected_components connect_vertices cons constant constantp constituent constvalue cont2part content continuous_freq contortion contour_plot contract contract_edge contragrad contrib_ode convert coord copy copy_file copy_graph copylist copymatrix cor cos cosh cot coth cov cov1 covdiff covect covers crc24sum create_graph create_list csc csch csetup cspline ctaylor ct_coordsys ctransform ctranspose cube_graph cuboctahedron_graph cunlisp cv cycle_digraph cycle_graph cylindrical days360 dblint deactivate declare declare_constvalue declare_dimensions declare_fundamental_dimensions declare_fundamental_units declare_qty declare_translated declare_unit_conversion declare_units declare_weights decsym defcon define define_alt_display define_variable defint defmatch defrule defstruct deftaylor degree_sequence del delete deleten delta demo demoivre denom depends derivdegree derivlist describe desolve determinant dfloat dgauss_a dgauss_b dgeev dgemm dgeqrf dgesv dgesvd diag diagmatrix diag_matrix diagmatrixp diameter diff digitcharp dimacs_export dimacs_import dimension dimensionless dimensions dimensions_as_list direct directory discrete_freq disjoin disjointp disolate disp dispcon dispform dispfun dispJordan display disprule dispterms distrib divide divisors divsum dkummer_m dkummer_u dlange dodecahedron_graph dotproduct dotsimp dpart draw draw2d draw3d drawdf draw_file draw_graph dscalar echelon edge_coloring edge_connectivity edges eigens_by_jacobi eigenvalues eigenvectors eighth einstein eivals eivects elapsed_real_time elapsed_run_time ele2comp ele2polynome ele2pui elem elementp elevation_grid elim elim_allbut eliminate eliminate_using ellipse elliptic_e elliptic_ec elliptic_eu elliptic_f elliptic_kc elliptic_pi ematrix empty_graph emptyp endcons entermatrix entertensor entier equal equalp equiv_classes erf erfc erf_generalized erfi errcatch error errormsg errors euler ev eval_string evenp every evolution evolution2d evundiff example exp expand expandwrt expandwrt_factored expint expintegral_chi expintegral_ci expintegral_e expintegral_e1 expintegral_ei expintegral_e_simplify expintegral_li expintegral_shi expintegral_si explicit explose exponentialize express expt exsec extdiff extract_linear_equations extremal_subset ezgcd %f f90 facsum factcomb factor factorfacsum factorial factorout factorsum facts fast_central_elements fast_linsolve fasttimes featurep fernfale fft fib fibtophi fifth filename_merge file_search file_type fillarray findde find_root find_root_abs find_root_error find_root_rel first fix flatten flength float floatnump floor flower_snark flush flush1deriv flushd flushnd flush_output fmin_cobyla forget fortran fourcos fourexpand fourier fourier_elim fourint fourintcos fourintsin foursimp foursin fourth fposition frame_bracket freeof freshline fresnel_c fresnel_s from_adjacency_matrix frucht_graph full_listify fullmap fullmapl fullratsimp fullratsubst fullsetify funcsolve fundamental_dimensions fundamental_units fundef funmake funp fv g0 g1 gamma gamma_greek gamma_incomplete gamma_incomplete_generalized gamma_incomplete_regularized gauss gauss_a gauss_b gaussprob gcd gcdex gcdivide gcfac gcfactor gd generalized_lambert_w genfact gen_laguerre genmatrix gensym geo_amortization geo_annuity_fv geo_annuity_pv geomap geometric geometric_mean geosum get getcurrentdirectory get_edge_weight getenv get_lu_factors get_output_stream_string get_pixel get_plot_option get_tex_environment get_tex_environment_default get_vertex_label gfactor gfactorsum ggf girth global_variances gn gnuplot_close gnuplot_replot gnuplot_reset gnuplot_restart gnuplot_start go Gosper GosperSum gr2d gr3d gradef gramschmidt graph6_decode graph6_encode graph6_export graph6_import graph_center graph_charpoly graph_eigenvalues graph_flow graph_order graph_periphery graph_product graph_size graph_union great_rhombicosidodecahedron_graph great_rhombicuboctahedron_graph grid_graph grind grobner_basis grotzch_graph hamilton_cycle hamilton_path hankel hankel_1 hankel_2 harmonic harmonic_mean hav heawood_graph hermite hessian hgfred hilbertmap hilbert_matrix hipow histogram histogram_description hodge horner hypergeometric i0 i1 %ibes ic1 ic2 ic_convert ichr1 ichr2 icosahedron_graph icosidodecahedron_graph icurvature ident identfor identity idiff idim idummy ieqn %if ifactors iframes ifs igcdex igeodesic_coords ilt image imagpart imetric implicit implicit_derivative implicit_plot indexed_tensor indices induced_subgraph inferencep inference_result infix info_display init_atensor init_ctensor in_neighbors innerproduct inpart inprod inrt integerp integer_partitions integrate intersect intersection intervalp intopois intosum invariant1 invariant2 inverse_fft inverse_jacobi_cd inverse_jacobi_cn inverse_jacobi_cs inverse_jacobi_dc inverse_jacobi_dn inverse_jacobi_ds inverse_jacobi_nc inverse_jacobi_nd inverse_jacobi_ns inverse_jacobi_sc inverse_jacobi_sd inverse_jacobi_sn invert invert_by_adjoint invert_by_lu inv_mod irr is is_biconnected is_bipartite is_connected is_digraph is_edge_in_graph is_graph is_graph_or_digraph ishow is_isomorphic isolate isomorphism is_planar isqrt isreal_p is_sconnected is_tree is_vertex_in_graph items_inference %j j0 j1 jacobi jacobian jacobi_cd jacobi_cn jacobi_cs jacobi_dc jacobi_dn jacobi_ds jacobi_nc jacobi_nd jacobi_ns jacobi_p jacobi_sc jacobi_sd jacobi_sn JF jn join jordan julia julia_set julia_sin %k kdels kdelta kill killcontext kostka kron_delta kronecker_product kummer_m kummer_u kurtosis kurtosis_bernoulli kurtosis_beta kurtosis_binomial kurtosis_chi2 kurtosis_continuous_uniform kurtosis_discrete_uniform kurtosis_exp kurtosis_f kurtosis_gamma kurtosis_general_finite_discrete kurtosis_geometric kurtosis_gumbel kurtosis_hypergeometric kurtosis_laplace kurtosis_logistic kurtosis_lognormal kurtosis_negative_binomial kurtosis_noncentral_chi2 kurtosis_noncentral_student_t kurtosis_normal kurtosis_pareto kurtosis_poisson kurtosis_rayleigh kurtosis_student_t kurtosis_weibull label labels lagrange laguerre lambda lambert_w laplace laplacian_matrix last lbfgs lc2kdt lcharp lc_l lcm lc_u ldefint ldisp ldisplay legendre_p legendre_q leinstein length let letrules letsimp levi_civita lfreeof lgtreillis lhs li liediff limit Lindstedt linear linearinterpol linear_program linear_regression line_graph linsolve listarray list_correlations listify list_matrix_entries list_nc_monomials listoftens listofvars listp lmax lmin load loadfile local locate_matrix_entry log logcontract log_gamma lopow lorentz_gauge lowercasep lpart lratsubst lreduce lriemann lsquares_estimates lsquares_estimates_approximate lsquares_estimates_exact lsquares_mse lsquares_residual_mse lsquares_residuals lsum ltreillis lu_backsub lucas lu_factor %m macroexpand macroexpand1 make_array makebox makefact makegamma make_graph make_level_picture makelist makeOrders make_poly_continent make_poly_country make_polygon make_random_state make_rgb_picture makeset make_string_input_stream make_string_output_stream make_transform mandelbrot mandelbrot_set map mapatom maplist matchdeclare matchfix mat_cond mat_fullunblocker mat_function mathml_display mat_norm matrix matrixmap matrixp matrix_size mattrace mat_trace mat_unblocker max max_clique max_degree max_flow maximize_lp max_independent_set max_matching maybe md5sum mean mean_bernoulli mean_beta mean_binomial mean_chi2 mean_continuous_uniform mean_deviation mean_discrete_uniform mean_exp mean_f mean_gamma mean_general_finite_discrete mean_geometric mean_gumbel mean_hypergeometric mean_laplace mean_logistic mean_lognormal mean_negative_binomial mean_noncentral_chi2 mean_noncentral_student_t mean_normal mean_pareto mean_poisson mean_rayleigh mean_student_t mean_weibull median median_deviation member mesh metricexpandall mgf1_sha1 min min_degree min_edge_cut minfactorial minimalPoly minimize_lp minimum_spanning_tree minor minpack_lsquares minpack_solve min_vertex_cover min_vertex_cut mkdir mnewton mod mode_declare mode_identity ModeMatrix moebius mon2schur mono monomial_dimensions multibernstein_poly multi_display_for_texinfo multi_elem multinomial multinomial_coeff multi_orbit multiplot_mode multi_pui multsym multthru mycielski_graph nary natural_unit nc_degree ncexpt ncharpoly negative_picture neighbors new newcontext newdet new_graph newline newton new_variable next_prime nicedummies niceindices ninth nofix nonarray noncentral_moment nonmetricity nonnegintegerp nonscalarp nonzeroandfreeof notequal nounify nptetrad npv nroots nterms ntermst nthroot nullity nullspace num numbered_boundaries numberp number_to_octets num_distinct_partitions numerval numfactor num_partitions nusum nzeta nzetai nzetar octets_to_number octets_to_oid odd_girth oddp ode2 ode_check odelin oid_to_octets op opena opena_binary openr openr_binary openw openw_binary operatorp opsubst optimize %or orbit orbits ordergreat ordergreatp orderless orderlessp orthogonal_complement orthopoly_recur orthopoly_weight outermap out_neighbors outofpois pade parabolic_cylinder_d parametric parametric_surface parg parGosper parse_string parse_timedate part part2cont partfrac partition partition_set partpol path_digraph path_graph pathname_directory pathname_name pathname_type pdf_bernoulli pdf_beta pdf_binomial pdf_cauchy pdf_chi2 pdf_continuous_uniform pdf_discrete_uniform pdf_exp pdf_f pdf_gamma pdf_general_finite_discrete pdf_geometric pdf_gumbel pdf_hypergeometric pdf_laplace pdf_logistic pdf_lognormal pdf_negative_binomial pdf_noncentral_chi2 pdf_noncentral_student_t pdf_normal pdf_pareto pdf_poisson pdf_rank_sum pdf_rayleigh pdf_signed_rank pdf_student_t pdf_weibull pearson_skewness permanent permut permutation permutations petersen_graph petrov pickapart picture_equalp picturep piechart piechart_description planar_embedding playback plog plot2d plot3d plotdf ploteq plsquares pochhammer points poisdiff poisexpt poisint poismap poisplus poissimp poissubst poistimes poistrim polar polarform polartorect polar_to_xy poly_add poly_buchberger poly_buchberger_criterion poly_colon_ideal poly_content polydecomp poly_depends_p poly_elimination_ideal poly_exact_divide poly_expand poly_expt poly_gcd polygon poly_grobner poly_grobner_equal poly_grobner_member poly_grobner_subsetp poly_ideal_intersection poly_ideal_polysaturation poly_ideal_polysaturation1 poly_ideal_saturation poly_ideal_saturation1 poly_lcm poly_minimization polymod poly_multiply polynome2ele polynomialp poly_normal_form poly_normalize poly_normalize_list poly_polysaturation_extension poly_primitive_part poly_pseudo_divide poly_reduced_grobner poly_reduction poly_saturation_extension poly_s_polynomial poly_subtract polytocompanion pop postfix potential power_mod powerseries powerset prefix prev_prime primep primes principal_components print printf printfile print_graph printpois printprops prodrac product properties propvars psi psubst ptriangularize pui pui2comp pui2ele pui2polynome pui_direct puireduc push put pv qput qrange qty quad_control quad_qag quad_qagi quad_qagp quad_qags quad_qawc quad_qawf quad_qawo quad_qaws quadrilateral quantile quantile_bernoulli quantile_beta quantile_binomial quantile_cauchy quantile_chi2 quantile_continuous_uniform quantile_discrete_uniform quantile_exp quantile_f quantile_gamma quantile_general_finite_discrete quantile_geometric quantile_gumbel quantile_hypergeometric quantile_laplace quantile_logistic quantile_lognormal quantile_negative_binomial quantile_noncentral_chi2 quantile_noncentral_student_t quantile_normal quantile_pareto quantile_poisson quantile_rayleigh quantile_student_t quantile_weibull quartile_skewness quit qunit quotient racah_v racah_w radcan radius random random_bernoulli random_beta random_binomial random_bipartite_graph random_cauchy random_chi2 random_continuous_uniform random_digraph random_discrete_uniform random_exp random_f random_gamma random_general_finite_discrete random_geometric random_graph random_graph1 random_gumbel random_hypergeometric random_laplace random_logistic random_lognormal random_negative_binomial random_network random_noncentral_chi2 random_noncentral_student_t random_normal random_pareto random_permutation random_poisson random_rayleigh random_regular_graph random_student_t random_tournament random_tree random_weibull range rank rat ratcoef ratdenom ratdiff ratdisrep ratexpand ratinterpol rational rationalize ratnumer ratnump ratp ratsimp ratsubst ratvars ratweight read read_array read_binary_array read_binary_list read_binary_matrix readbyte readchar read_hashed_array readline read_list read_matrix read_nested_list readonly read_xpm real_imagpart_to_conjugate realpart realroots rearray rectangle rectform rectform_log_if_constant recttopolar rediff reduce_consts reduce_order region region_boundaries region_boundaries_plus rem remainder remarray rembox remcomps remcon remcoord remfun remfunction remlet remove remove_constvalue remove_dimensions remove_edge remove_fundamental_dimensions remove_fundamental_units remove_plot_option remove_vertex rempart remrule remsym remvalue rename rename_file reset reset_displays residue resolvante resolvante_alternee1 resolvante_bipartite resolvante_diedrale resolvante_klein resolvante_klein3 resolvante_produit_sym resolvante_unitaire resolvante_vierer rest resultant return reveal reverse revert revert2 rgb2level rhs ricci riemann rinvariant risch rk rmdir rncombine romberg room rootscontract round row rowop rowswap rreduce run_testsuite %s save saving scalarp scaled_bessel_i scaled_bessel_i0 scaled_bessel_i1 scalefactors scanmap scatterplot scatterplot_description scene schur2comp sconcat scopy scsimp scurvature sdowncase sec sech second sequal sequalignore set_alt_display setdifference set_draw_defaults set_edge_weight setelmx setequalp setify setp set_partitions set_plot_option set_prompt set_random_state set_tex_environment set_tex_environment_default setunits setup_autoload set_up_dot_simplifications set_vertex_label seventh sexplode sf sha1sum sha256sum shortest_path shortest_weighted_path show showcomps showratvars sierpinskiale sierpinskimap sign signum similaritytransform simp_inequality simplify_sum simplode simpmetderiv simtran sin sinh sinsert sinvertcase sixth skewness skewness_bernoulli skewness_beta skewness_binomial skewness_chi2 skewness_continuous_uniform skewness_discrete_uniform skewness_exp skewness_f skewness_gamma skewness_general_finite_discrete skewness_geometric skewness_gumbel skewness_hypergeometric skewness_laplace skewness_logistic skewness_lognormal skewness_negative_binomial skewness_noncentral_chi2 skewness_noncentral_student_t skewness_normal skewness_pareto skewness_poisson skewness_rayleigh skewness_student_t skewness_weibull slength smake small_rhombicosidodecahedron_graph small_rhombicuboctahedron_graph smax smin smismatch snowmap snub_cube_graph snub_dodecahedron_graph solve solve_rec solve_rec_rat some somrac sort sparse6_decode sparse6_encode sparse6_export sparse6_import specint spherical spherical_bessel_j spherical_bessel_y spherical_hankel1 spherical_hankel2 spherical_harmonic spherical_to_xyz splice split sposition sprint sqfr sqrt sqrtdenest sremove sremovefirst sreverse ssearch ssort sstatus ssubst ssubstfirst staircase standardize standardize_inverse_trig starplot starplot_description status std std1 std_bernoulli std_beta std_binomial std_chi2 std_continuous_uniform std_discrete_uniform std_exp std_f std_gamma std_general_finite_discrete std_geometric std_gumbel std_hypergeometric std_laplace std_logistic std_lognormal std_negative_binomial std_noncentral_chi2 std_noncentral_student_t std_normal std_pareto std_poisson std_rayleigh std_student_t std_weibull stemplot stirling stirling1 stirling2 strim striml strimr string stringout stringp strong_components struve_h struve_l sublis sublist sublist_indices submatrix subsample subset subsetp subst substinpart subst_parallel substpart substring subvar subvarp sum sumcontract summand_to_rec supcase supcontext symbolp symmdifference symmetricp system take_channel take_inference tan tanh taylor taylorinfo taylorp taylor_simplifier taytorat tcl_output tcontract tellrat tellsimp tellsimpafter tentex tenth test_mean test_means_difference test_normality test_proportion test_proportions_difference test_rank_sum test_sign test_signed_rank test_variance test_variance_ratio tex tex1 tex_display texput %th third throw time timedate timer timer_info tldefint tlimit todd_coxeter toeplitz tokens to_lisp topological_sort to_poly to_poly_solve totaldisrep totalfourier totient tpartpol trace tracematrix trace_options transform_sample translate translate_file transpose treefale tree_reduce treillis treinat triangle triangularize trigexpand trigrat trigreduce trigsimp trunc truncate truncated_cube_graph truncated_dodecahedron_graph truncated_icosahedron_graph truncated_tetrahedron_graph tr_warnings_get tube tutte_graph ueivects uforget ultraspherical underlying_graph undiff union unique uniteigenvectors unitp units unit_step unitvector unorder unsum untellrat untimer untrace uppercasep uricci uriemann uvect vandermonde_matrix var var1 var_bernoulli var_beta var_binomial var_chi2 var_continuous_uniform var_discrete_uniform var_exp var_f var_gamma var_general_finite_discrete var_geometric var_gumbel var_hypergeometric var_laplace var_logistic var_lognormal var_negative_binomial var_noncentral_chi2 var_noncentral_student_t var_normal var_pareto var_poisson var_rayleigh var_student_t var_weibull vector vectorpotential vectorsimp verbify vers vertex_coloring vertex_connectivity vertex_degree vertex_distance vertex_eccentricity vertex_in_degree vertex_out_degree vertices vertices_to_cycle vertices_to_path %w weyl wheel_graph wiener_index wigner_3j wigner_6j wigner_9j with_stdout write_binary_data writebyte write_data writefile wronskian xreduce xthru %y Zeilberger zeroequiv zerofor zeromatrix zeromatrixp zeta zgeev zheev zlange zn_add_table zn_carmichael_lambda zn_characteristic_factors zn_determinant zn_factor_generators zn_invert_by_lu zn_log zn_mult_table absboxchar activecontexts adapt_depth additive adim aform algebraic algepsilon algexact aliases allbut all_dotsimp_denoms allocation allsym alphabetic animation antisymmetric arrays askexp assume_pos assume_pos_pred assumescalar asymbol atomgrad atrig1 axes axis_3d axis_bottom axis_left axis_right axis_top azimuth background background_color backsubst berlefact bernstein_explicit besselexpand beta_args_sum_to_integer beta_expand bftorat bftrunc bindtest border boundaries_array box boxchar breakup %c capping cauchysum cbrange cbtics center cflength cframe_flag cnonmet_flag color color_bar color_bar_tics colorbox columns commutative complex cone context contexts contour contour_levels cosnpiflag ctaypov ctaypt ctayswitch ctayvar ct_coords ctorsion_flag ctrgsimp cube current_let_rule_package cylinder data_file_name debugmode decreasing default_let_rule_package delay dependencies derivabbrev derivsubst detout diagmetric diff dim dimensions dispflag display2d|10 display_format_internal distribute_over doallmxops domain domxexpt domxmxops domxnctimes dontfactor doscmxops doscmxplus dot0nscsimp dot0simp dot1simp dotassoc dotconstrules dotdistrib dotexptsimp dotident dotscrules draw_graph_program draw_realpart edge_color edge_coloring edge_partition edge_type edge_width %edispflag elevation %emode endphi endtheta engineering_format_floats enhanced3d %enumer epsilon_lp erfflag erf_representation errormsg error_size error_syms error_type %e_to_numlog eval even evenfun evflag evfun ev_point expandwrt_denom expintexpand expintrep expon expop exptdispflag exptisolate exptsubst facexpand facsum_combine factlim factorflag factorial_expand factors_only fb feature features file_name file_output_append file_search_demo file_search_lisp file_search_maxima|10 file_search_tests file_search_usage file_type_lisp file_type_maxima|10 fill_color fill_density filled_func fixed_vertices flipflag float2bf font font_size fortindent fortspaces fpprec fpprintprec functions gamma_expand gammalim gdet genindex gensumnum GGFCFMAX GGFINFINITY globalsolve gnuplot_command gnuplot_curve_styles gnuplot_curve_titles gnuplot_default_term_command gnuplot_dumb_term_command gnuplot_file_args gnuplot_file_name gnuplot_out_file gnuplot_pdf_term_command gnuplot_pm3d gnuplot_png_term_command gnuplot_postamble gnuplot_preamble gnuplot_ps_term_command gnuplot_svg_term_command gnuplot_term gnuplot_view_args Gosper_in_Zeilberger gradefs grid grid2d grind halfangles head_angle head_both head_length head_type height hypergeometric_representation %iargs ibase icc1 icc2 icounter idummyx ieqnprint ifb ifc1 ifc2 ifg ifgi ifr iframe_bracket_form ifri igeowedge_flag ikt1 ikt2 imaginary inchar increasing infeval infinity inflag infolists inm inmc1 inmc2 intanalysis integer integervalued integrate_use_rootsof integration_constant integration_constant_counter interpolate_color intfaclim ip_grid ip_grid_in irrational isolate_wrt_times iterations itr julia_parameter %k1 %k2 keepfloat key key_pos kinvariant kt label label_alignment label_orientation labels lassociative lbfgs_ncorrections lbfgs_nfeval_max leftjust legend letrat let_rule_packages lfg lg lhospitallim limsubst linear linear_solver linechar linel|10 linenum line_type linewidth line_width linsolve_params linsolvewarn lispdisp listarith listconstvars listdummyvars lmxchar load_pathname loadprint logabs logarc logcb logconcoeffp logexpand lognegint logsimp logx logx_secondary logy logy_secondary logz lriem m1pbranch macroexpansion macros mainvar manual_demo maperror mapprint matrix_element_add matrix_element_mult matrix_element_transpose maxapplydepth maxapplyheight maxima_tempdir|10 maxima_userdir|10 maxnegex MAX_ORD maxposex maxpsifracdenom maxpsifracnum maxpsinegint maxpsiposint maxtayorder mesh_lines_color method mod_big_prime mode_check_errorp mode_checkp mode_check_warnp mod_test mod_threshold modular_linear_solver modulus multiplicative multiplicities myoptions nary negdistrib negsumdispflag newline newtonepsilon newtonmaxiter nextlayerfactor niceindicespref nm nmc noeval nolabels nonegative_lp noninteger nonscalar noun noundisp nouns np npi nticks ntrig numer numer_pbranch obase odd oddfun opacity opproperties opsubst optimprefix optionset orientation origin orthopoly_returns_intervals outative outchar packagefile palette partswitch pdf_file pfeformat phiresolution %piargs piece pivot_count_sx pivot_max_sx plot_format plot_options plot_realpart png_file pochhammer_max_index points pointsize point_size points_joined point_type poislim poisson poly_coefficient_ring poly_elimination_order polyfactor poly_grobner_algorithm poly_grobner_debug poly_monomial_order poly_primary_elimination_order poly_return_term_list poly_secondary_elimination_order poly_top_reduction_only posfun position powerdisp pred prederror primep_number_of_tests product_use_gamma program programmode promote_float_to_bigfloat prompt proportional_axes props psexpand ps_file radexpand radius radsubstflag rassociative ratalgdenom ratchristof ratdenomdivide rateinstein ratepsilon ratfac rational ratmx ratprint ratriemann ratsimpexpons ratvarswitch ratweights ratweyl ratwtlvl real realonly redraw refcheck resolution restart resultant ric riem rmxchar %rnum_list rombergabs rombergit rombergmin rombergtol rootsconmode rootsepsilon run_viewer same_xy same_xyz savedef savefactors scalar scalarmatrixp scale scale_lp setcheck setcheckbreak setval show_edge_color show_edges show_edge_type show_edge_width show_id show_label showtime show_vertex_color show_vertex_size show_vertex_type show_vertices show_weight simp simplified_output simplify_products simpproduct simpsum sinnpiflag solvedecomposes solveexplicit solvefactors solvenullwarn solveradcan solvetrigwarn space sparse sphere spring_embedding_depth sqrtdispflag stardisp startphi starttheta stats_numer stringdisp structures style sublis_apply_lambda subnumsimp sumexpand sumsplitfact surface surface_hide svg_file symmetric tab taylordepth taylor_logexpand taylor_order_coefficients taylor_truncate_polynomials tensorkill terminal testsuite_files thetaresolution timer_devalue title tlimswitch tr track transcompile transform transform_xy translate_fast_arrays transparent transrun tr_array_as_ref tr_bound_function_applyp tr_file_tty_messagesp tr_float_can_branch_complex tr_function_call_default trigexpandplus trigexpandtimes triginverses trigsign trivial_solutions tr_numer tr_optimize_max_loop tr_semicompile tr_state_vars tr_warn_bad_function_calls tr_warn_fexpr tr_warn_meval tr_warn_mode tr_warn_undeclared tr_warn_undefined_variable tstep ttyoff tube_extremes ufg ug %unitexpand unit_vectors uric uriem use_fast_arrays user_preamble usersetunits values vect_cross verbose vertex_color vertex_coloring vertex_partition vertex_size vertex_type view warnings weyl width windowname windowtitle wired_surface wireframe xaxis xaxis_color xaxis_secondary xaxis_type xaxis_width xlabel xlabel_secondary xlength xrange xrange_secondary xtics xtics_axis xtics_rotate xtics_rotate_secondary xtics_secondary xtics_secondary_axis xu_grid x_voxel xy_file xyplane xy_scale yaxis yaxis_color yaxis_secondary yaxis_type yaxis_width ylabel ylabel_secondary ylength yrange yrange_secondary ytics ytics_axis ytics_rotate ytics_rotate_secondary ytics_secondary ytics_secondary_axis yv_grid y_voxel yx_ratio zaxis zaxis_color zaxis_type zaxis_width zeroa zerob zerobern zeta%pi zlabel zlabel_rotate zlength zmin zn_primroot_limit zn_primroot_pretest",
+symbol:"_ __ %|0 %%|0"},contains:[{className:"comment",begin:"/\\*",end:"\\*/",contains:["self"]},a.QUOTE_STRING_MODE,{className:"number",relevance:0,variants:[{begin:"\\b(\\d+|\\d+\\.|\\.\\d+|\\d+\\.\\d+)[Ee][-+]?\\d+\\b"},{begin:"\\b(\\d+|\\d+\\.|\\.\\d+|\\d+\\.\\d+)[Bb][-+]?\\d+\\b",relevance:10},{begin:"\\b(\\.\\d+|\\d+\\.\\d+)\\b"},{begin:"\\b(\\d+|0[0-9A-Za-z]+)\\.?\\b"}]}],illegal:/@/}});b.registerLanguage("mel",function(a){return{keywords:"int float string vector matrix if else switch case default while do for in break continue global proc return about abs addAttr addAttributeEditorNodeHelp addDynamic addNewShelfTab addPP addPanelCategory addPrefixToName advanceToNextDrivenKey affectedNet affects aimConstraint air alias aliasAttr align alignCtx alignCurve alignSurface allViewFit ambientLight angle angleBetween animCone animCurveEditor animDisplay animView annotate appendStringArray applicationName applyAttrPreset applyTake arcLenDimContext arcLengthDimension arclen arrayMapper art3dPaintCtx artAttrCtx artAttrPaintVertexCtx artAttrSkinPaintCtx artAttrTool artBuildPaintMenu artFluidAttrCtx artPuttyCtx artSelectCtx artSetPaintCtx artUserPaintCtx assignCommand assignInputDevice assignViewportFactories attachCurve attachDeviceAttr attachSurface attrColorSliderGrp attrCompatibility attrControlGrp attrEnumOptionMenu attrEnumOptionMenuGrp attrFieldGrp attrFieldSliderGrp attrNavigationControlGrp attrPresetEditWin attributeExists attributeInfo attributeMenu attributeQuery autoKeyframe autoPlace bakeClip bakeFluidShading bakePartialHistory bakeResults bakeSimulation basename basenameEx batchRender bessel bevel bevelPlus binMembership bindSkin blend2 blendShape blendShapeEditor blendShapePanel blendTwoAttr blindDataType boneLattice boundary boxDollyCtx boxZoomCtx bufferCurve buildBookmarkMenu buildKeyframeMenu button buttonManip CBG cacheFile cacheFileCombine cacheFileMerge cacheFileTrack camera cameraView canCreateManip canvas capitalizeString catch catchQuiet ceil changeSubdivComponentDisplayLevel changeSubdivRegion channelBox character characterMap characterOutlineEditor characterize chdir checkBox checkBoxGrp checkDefaultRenderGlobals choice circle circularFillet clamp clear clearCache clip clipEditor clipEditorCurrentTimeCtx clipSchedule clipSchedulerOutliner clipTrimBefore closeCurve closeSurface cluster cmdFileOutput cmdScrollFieldExecuter cmdScrollFieldReporter cmdShell coarsenSubdivSelectionList collision color colorAtPoint colorEditor colorIndex colorIndexSliderGrp colorSliderButtonGrp colorSliderGrp columnLayout commandEcho commandLine commandPort compactHairSystem componentEditor compositingInterop computePolysetVolume condition cone confirmDialog connectAttr connectControl connectDynamic connectJoint connectionInfo constrain constrainValue constructionHistory container containsMultibyte contextInfo control convertFromOldLayers convertIffToPsd convertLightmap convertSolidTx convertTessellation convertUnit copyArray copyFlexor copyKey copySkinWeights cos cpButton cpCache cpClothSet cpCollision cpConstraint cpConvClothToMesh cpForces cpGetSolverAttr cpPanel cpProperty cpRigidCollisionFilter cpSeam cpSetEdit cpSetSolverAttr cpSolver cpSolverTypes cpTool cpUpdateClothUVs createDisplayLayer createDrawCtx createEditor createLayeredPsdFile createMotionField createNewShelf createNode createRenderLayer createSubdivRegion cross crossProduct ctxAbort ctxCompletion ctxEditMode ctxTraverse currentCtx currentTime currentTimeCtx currentUnit curve curveAddPtCtx curveCVCtx curveEPCtx curveEditorCtx curveIntersect curveMoveEPCtx curveOnSurface curveSketchCtx cutKey cycleCheck cylinder dagPose date defaultLightListCheckBox defaultNavigation defineDataServer defineVirtualDevice deformer deg_to_rad delete deleteAttr deleteShadingGroupsAndMaterials deleteShelfTab deleteUI deleteUnusedBrushes delrandstr detachCurve detachDeviceAttr detachSurface deviceEditor devicePanel dgInfo dgdirty dgeval dgtimer dimWhen directKeyCtx directionalLight dirmap dirname disable disconnectAttr disconnectJoint diskCache displacementToPoly displayAffected displayColor displayCull displayLevelOfDetail displayPref displayRGBColor displaySmoothness displayStats displayString displaySurface distanceDimContext distanceDimension doBlur dolly dollyCtx dopeSheetEditor dot dotProduct doubleProfileBirailSurface drag dragAttrContext draggerContext dropoffLocator duplicate duplicateCurve duplicateSurface dynCache dynControl dynExport dynExpression dynGlobals dynPaintEditor dynParticleCtx dynPref dynRelEdPanel dynRelEditor dynamicLoad editAttrLimits editDisplayLayerGlobals editDisplayLayerMembers editRenderLayerAdjustment editRenderLayerGlobals editRenderLayerMembers editor editorTemplate effector emit emitter enableDevice encodeString endString endsWith env equivalent equivalentTol erf error eval evalDeferred evalEcho event exactWorldBoundingBox exclusiveLightCheckBox exec executeForEachObject exists exp expression expressionEditorListen extendCurve extendSurface extrude fcheck fclose feof fflush fgetline fgetword file fileBrowserDialog fileDialog fileExtension fileInfo filetest filletCurve filter filterCurve filterExpand filterStudioImport findAllIntersections findAnimCurves findKeyframe findMenuItem findRelatedSkinCluster finder firstParentOf fitBspline flexor floatEq floatField floatFieldGrp floatScrollBar floatSlider floatSlider2 floatSliderButtonGrp floatSliderGrp floor flow fluidCacheInfo fluidEmitter fluidVoxelInfo flushUndo fmod fontDialog fopen formLayout format fprint frameLayout fread freeFormFillet frewind fromNativePath fwrite gamma gauss geometryConstraint getApplicationVersionAsFloat getAttr getClassification getDefaultBrush getFileList getFluidAttr getInputDeviceRange getMayaPanelTypes getModifiers getPanel getParticleAttr getPluginResource getenv getpid glRender glRenderEditor globalStitch gmatch goal gotoBindPose grabColor gradientControl gradientControlNoAttr graphDollyCtx graphSelectContext graphTrackCtx gravity grid gridLayout group groupObjectsByName HfAddAttractorToAS HfAssignAS HfBuildEqualMap HfBuildFurFiles HfBuildFurImages HfCancelAFR HfConnectASToHF HfCreateAttractor HfDeleteAS HfEditAS HfPerformCreateAS HfRemoveAttractorFromAS HfSelectAttached HfSelectAttractors HfUnAssignAS hardenPointCurve hardware hardwareRenderPanel headsUpDisplay headsUpMessage help helpLine hermite hide hilite hitTest hotBox hotkey hotkeyCheck hsv_to_rgb hudButton hudSlider hudSliderButton hwReflectionMap hwRender hwRenderLoad hyperGraph hyperPanel hyperShade hypot iconTextButton iconTextCheckBox iconTextRadioButton iconTextRadioCollection iconTextScrollList iconTextStaticLabel ikHandle ikHandleCtx ikHandleDisplayScale ikSolver ikSplineHandleCtx ikSystem ikSystemInfo ikfkDisplayMethod illustratorCurves image imfPlugins inheritTransform insertJoint insertJointCtx insertKeyCtx insertKnotCurve insertKnotSurface instance instanceable instancer intField intFieldGrp intScrollBar intSlider intSliderGrp interToUI internalVar intersect iprEngine isAnimCurve isConnected isDirty isParentOf isSameObject isTrue isValidObjectName isValidString isValidUiName isolateSelect itemFilter itemFilterAttr itemFilterRender itemFilterType joint jointCluster jointCtx jointDisplayScale jointLattice keyTangent keyframe keyframeOutliner keyframeRegionCurrentTimeCtx keyframeRegionDirectKeyCtx keyframeRegionDollyCtx keyframeRegionInsertKeyCtx keyframeRegionMoveKeyCtx keyframeRegionScaleKeyCtx keyframeRegionSelectKeyCtx keyframeRegionSetKeyCtx keyframeRegionTrackCtx keyframeStats lassoContext lattice latticeDeformKeyCtx launch launchImageEditor layerButton layeredShaderPort layeredTexturePort layout layoutDialog lightList lightListEditor lightListPanel lightlink lineIntersection linearPrecision linstep listAnimatable listAttr listCameras listConnections listDeviceAttachments listHistory listInputDeviceAxes listInputDeviceButtons listInputDevices listMenuAnnotation listNodeTypes listPanelCategories listRelatives listSets listTransforms listUnselected listerEditor loadFluid loadNewShelf loadPlugin loadPluginLanguageResources loadPrefObjects localizedPanelLabel lockNode loft log longNameOf lookThru ls lsThroughFilter lsType lsUI Mayatomr mag makeIdentity makeLive makePaintable makeRoll makeSingleSurface makeTubeOn makebot manipMoveContext manipMoveLimitsCtx manipOptions manipRotateContext manipRotateLimitsCtx manipScaleContext manipScaleLimitsCtx marker match max memory menu menuBarLayout menuEditor menuItem menuItemToShelf menuSet menuSetPref messageLine min minimizeApp mirrorJoint modelCurrentTimeCtx modelEditor modelPanel mouse movIn movOut move moveIKtoFK moveKeyCtx moveVertexAlongDirection multiProfileBirailSurface mute nParticle nameCommand nameField namespace namespaceInfo newPanelItems newton nodeCast nodeIconButton nodeOutliner nodePreset nodeType noise nonLinear normalConstraint normalize nurbsBoolean nurbsCopyUVSet nurbsCube nurbsEditUV nurbsPlane nurbsSelect nurbsSquare nurbsToPoly nurbsToPolygonsPref nurbsToSubdiv nurbsToSubdivPref nurbsUVSet nurbsViewDirectionVector objExists objectCenter objectLayer objectType objectTypeUI obsoleteProc oceanNurbsPreviewPlane offsetCurve offsetCurveOnSurface offsetSurface openGLExtension openMayaPref optionMenu optionMenuGrp optionVar orbit orbitCtx orientConstraint outlinerEditor outlinerPanel overrideModifier paintEffectsDisplay pairBlend palettePort paneLayout panel panelConfiguration panelHistory paramDimContext paramDimension paramLocator parent parentConstraint particle particleExists particleInstancer particleRenderInfo partition pasteKey pathAnimation pause pclose percent performanceOptions pfxstrokes pickWalk picture pixelMove planarSrf plane play playbackOptions playblast plugAttr plugNode pluginInfo pluginResourceUtil pointConstraint pointCurveConstraint pointLight pointMatrixMult pointOnCurve pointOnSurface pointPosition poleVectorConstraint polyAppend polyAppendFacetCtx polyAppendVertex polyAutoProjection polyAverageNormal polyAverageVertex polyBevel polyBlendColor polyBlindData polyBoolOp polyBridgeEdge polyCacheMonitor polyCheck polyChipOff polyClipboard polyCloseBorder polyCollapseEdge polyCollapseFacet polyColorBlindData polyColorDel polyColorPerVertex polyColorSet polyCompare polyCone polyCopyUV polyCrease polyCreaseCtx polyCreateFacet polyCreateFacetCtx polyCube polyCut polyCutCtx polyCylinder polyCylindricalProjection polyDelEdge polyDelFacet polyDelVertex polyDuplicateAndConnect polyDuplicateEdge polyEditUV polyEditUVShell polyEvaluate polyExtrudeEdge polyExtrudeFacet polyExtrudeVertex polyFlipEdge polyFlipUV polyForceUV polyGeoSampler polyHelix polyInfo polyInstallAction polyLayoutUV polyListComponentConversion polyMapCut polyMapDel polyMapSew polyMapSewMove polyMergeEdge polyMergeEdgeCtx polyMergeFacet polyMergeFacetCtx polyMergeUV polyMergeVertex polyMirrorFace polyMoveEdge polyMoveFacet polyMoveFacetUV polyMoveUV polyMoveVertex polyNormal polyNormalPerVertex polyNormalizeUV polyOptUvs polyOptions polyOutput polyPipe polyPlanarProjection polyPlane polyPlatonicSolid polyPoke polyPrimitive polyPrism polyProjection polyPyramid polyQuad polyQueryBlindData polyReduce polySelect polySelectConstraint polySelectConstraintMonitor polySelectCtx polySelectEditCtx polySeparate polySetToFaceNormal polySewEdge polyShortestPathCtx polySmooth polySoftEdge polySphere polySphericalProjection polySplit polySplitCtx polySplitEdge polySplitRing polySplitVertex polyStraightenUVBorder polySubdivideEdge polySubdivideFacet polyToSubdiv polyTorus polyTransfer polyTriangulate polyUVSet polyUnite polyWedgeFace popen popupMenu pose pow preloadRefEd print progressBar progressWindow projFileViewer projectCurve projectTangent projectionContext projectionManip promptDialog propModCtx propMove psdChannelOutliner psdEditTextureFile psdExport psdTextureFile putenv pwd python querySubdiv quit rad_to_deg radial radioButton radioButtonGrp radioCollection radioMenuItemCollection rampColorPort rand randomizeFollicles randstate rangeControl readTake rebuildCurve rebuildSurface recordAttr recordDevice redo reference referenceEdit referenceQuery refineSubdivSelectionList refresh refreshAE registerPluginResource rehash reloadImage removeJoint removeMultiInstance removePanelCategory rename renameAttr renameSelectionList renameUI render renderGlobalsNode renderInfo renderLayerButton renderLayerParent renderLayerPostProcess renderLayerUnparent renderManip renderPartition renderQualityNode renderSettings renderThumbnailUpdate renderWindowEditor renderWindowSelectContext renderer reorder reorderDeformers requires reroot resampleFluid resetAE resetPfxToPolyCamera resetTool resolutionNode retarget reverseCurve reverseSurface revolve rgb_to_hsv rigidBody rigidSolver roll rollCtx rootOf rot rotate rotationInterpolation roundConstantRadius rowColumnLayout rowLayout runTimeCommand runup sampleImage saveAllShelves saveAttrPreset saveFluid saveImage saveInitialState saveMenu savePrefObjects savePrefs saveShelf saveToolSettings scale scaleBrushBrightness scaleComponents scaleConstraint scaleKey scaleKeyCtx sceneEditor sceneUIReplacement scmh scriptCtx scriptEditorInfo scriptJob scriptNode scriptTable scriptToShelf scriptedPanel scriptedPanelType scrollField scrollLayout sculpt searchPathArray seed selLoadSettings select selectContext selectCurveCV selectKey selectKeyCtx selectKeyframeRegionCtx selectMode selectPref selectPriority selectType selectedNodes selectionConnection separator setAttr setAttrEnumResource setAttrMapping setAttrNiceNameResource setConstraintRestPosition setDefaultShadingGroup setDrivenKeyframe setDynamic setEditCtx setEditor setFluidAttr setFocus setInfinity setInputDeviceMapping setKeyCtx setKeyPath setKeyframe setKeyframeBlendshapeTargetWts setMenuMode setNodeNiceNameResource setNodeTypeFlag setParent setParticleAttr setPfxToPolyCamera setPluginResource setProject setStampDensity setStartupMessage setState setToolTo setUITemplate setXformManip sets shadingConnection shadingGeometryRelCtx shadingLightRelCtx shadingNetworkCompare shadingNode shapeCompare shelfButton shelfLayout shelfTabLayout shellField shortNameOf showHelp showHidden showManipCtx showSelectionInTitle showShadingGroupAttrEditor showWindow sign simplify sin singleProfileBirailSurface size sizeBytes skinCluster skinPercent smoothCurve smoothTangentSurface smoothstep snap2to2 snapKey snapMode snapTogetherCtx snapshot soft softMod softModCtx sort sound soundControl source spaceLocator sphere sphrand spotLight spotLightPreviewPort spreadSheetEditor spring sqrt squareSurface srtContext stackTrace startString startsWith stitchAndExplodeShell stitchSurface stitchSurfacePoints strcmp stringArrayCatenate stringArrayContains stringArrayCount stringArrayInsertAtIndex stringArrayIntersector stringArrayRemove stringArrayRemoveAtIndex stringArrayRemoveDuplicates stringArrayRemoveExact stringArrayToString stringToStringArray strip stripPrefixFromName stroke subdAutoProjection subdCleanTopology subdCollapse subdDuplicateAndConnect subdEditUV subdListComponentConversion subdMapCut subdMapSewMove subdMatchTopology subdMirror subdToBlind subdToPoly subdTransferUVsToCache subdiv subdivCrease subdivDisplaySmoothness substitute substituteAllString substituteGeometry substring surface surfaceSampler surfaceShaderList swatchDisplayPort switchTable symbolButton symbolCheckBox sysFile system tabLayout tan tangentConstraint texLatticeDeformContext texManipContext texMoveContext texMoveUVShellContext texRotateContext texScaleContext texSelectContext texSelectShortestPathCtx texSmudgeUVContext texWinToolCtx text textCurves textField textFieldButtonGrp textFieldGrp textManip textScrollList textToShelf textureDisplacePlane textureHairColor texturePlacementContext textureWindow threadCount threePointArcCtx timeControl timePort timerX toNativePath toggle toggleAxis toggleWindowVisibility tokenize tokenizeList tolerance tolower toolButton toolCollection toolDropped toolHasOptions toolPropertyWindow torus toupper trace track trackCtx transferAttributes transformCompare transformLimits translator trim trunc truncateFluidCache truncateHairCache tumble tumbleCtx turbulence twoPointArcCtx uiRes uiTemplate unassignInputDevice undo undoInfo ungroup uniform unit unloadPlugin untangleUV untitledFileName untrim upAxis updateAE userCtx uvLink uvSnapshot validateShelfName vectorize view2dToolCtx viewCamera viewClipPlane viewFit viewHeadOn viewLookAt viewManip viewPlace viewSet visor volumeAxis vortex waitCursor warning webBrowser webBrowserPrefs whatIs window windowPref wire wireContext workspace wrinkle wrinkleContext writeTake xbmLangPathList xform",
+illegal:"</",contains:[a.C_NUMBER_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},{begin:"[\\$\\%\\@](\\^\\w\\b|#\\w+|[^\\s\\w{]|{\\w+}|\\w+)"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}});b.registerLanguage("mercury",function(a){var b=a.COMMENT("%","$"),d=a.inherit(a.APOS_STRING_MODE,{relevance:0}),e=a.inherit(a.QUOTE_STRING_MODE,{relevance:0});e.contains.push({className:"subst",begin:"\\\\[abfnrtv]\\|\\\\x[0-9a-fA-F]*\\\\\\|%[-+# *.0-9]*[dioxXucsfeEgGp]",
+relevance:0});return{aliases:["m","moo"],keywords:{keyword:"module use_module import_module include_module end_module initialise mutable initialize finalize finalise interface implementation pred mode func type inst solver any_pred any_func is semidet det nondet multi erroneous failure cc_nondet cc_multi typeclass instance where pragma promise external trace atomic or_else require_complete_switch require_det require_semidet require_multi require_nondet require_cc_multi require_cc_nondet require_erroneous require_failure",
+meta:"inline no_inline type_spec source_file fact_table obsolete memo loop_check minimal_model terminates does_not_terminate check_termination promise_equivalent_clauses foreign_proc foreign_decl foreign_code foreign_type foreign_import_module foreign_export_enum foreign_export foreign_enum may_call_mercury will_not_call_mercury thread_safe not_thread_safe maybe_thread_safe promise_pure promise_semipure tabled_for_io local untrailed trailed attach_to_io_state can_pass_as_mercury_type stable will_not_throw_exception may_modify_trail will_not_modify_trail may_duplicate may_not_duplicate affects_liveness does_not_affect_liveness doesnt_affect_liveness no_sharing unknown_sharing sharing",
+built_in:"some all not if then else true fail false try catch catch_any semidet_true semidet_false semidet_fail impure_true impure semipure"},contains:[{className:"built_in",variants:[{begin:"<=>"},{begin:"<=",relevance:0},{begin:"=>",relevance:0},{begin:"/\\\\"},{begin:"\\\\/"}]},{className:"built_in",variants:[{begin:":-\\|--\x3e"},{begin:"=",relevance:0}]},b,a.C_BLOCK_COMMENT_MODE,{className:"number",begin:"0'.\\|0[box][0-9a-fA-F]*"},a.NUMBER_MODE,d,e,{begin:/:-/}]}});b.registerLanguage("mipsasm",
+function(a){return{case_insensitive:!0,aliases:["mips"],lexemes:"\\.?"+a.IDENT_RE,keywords:{meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .ltorg ",built_in:"$0 $1 $2 $3 $4 $5 $6 $7 $8 $9 $10 $11 $12 $13 $14 $15 $16 $17 $18 $19 $20 $21 $22 $23 $24 $25 $26 $27 $28 $29 $30 $31 zero at v0 v1 a0 a1 a2 a3 a4 a5 a6 a7 t0 t1 t2 t3 t4 t5 t6 t7 t8 t9 s0 s1 s2 s3 s4 s5 s6 s7 s8 k0 k1 gp sp fp ra $f0 $f1 $f2 $f2 $f4 $f5 $f6 $f7 $f8 $f9 $f10 $f11 $f12 $f13 $f14 $f15 $f16 $f17 $f18 $f19 $f20 $f21 $f22 $f23 $f24 $f25 $f26 $f27 $f28 $f29 $f30 $f31 Context Random EntryLo0 EntryLo1 Context PageMask Wired EntryHi HWREna BadVAddr Count Compare SR IntCtl SRSCtl SRSMap Cause EPC PRId EBase Config Config1 Config2 Config3 LLAddr Debug DEPC DESAVE CacheErr ECC ErrorEPC TagLo DataLo TagHi DataHi WatchLo WatchHi PerfCtl PerfCnt "},
+contains:[{className:"keyword",begin:"\\b(addi?u?|andi?|b(al)?|beql?|bgez(al)?l?|bgtzl?|blezl?|bltz(al)?l?|bnel?|cl[oz]|divu?|ext|ins|j(al)?|jalr(.hb)?|jr(.hb)?|lbu?|lhu?|ll|lui|lw[lr]?|maddu?|mfhi|mflo|movn|movz|move|msubu?|mthi|mtlo|mul|multu?|nop|nor|ori?|rotrv?|sb|sc|se[bh]|sh|sllv?|slti?u?|srav?|srlv?|subu?|sw[lr]?|xori?|wsbh|abs.[sd]|add.[sd]|alnv.ps|bc1[ft]l?|c.(s?f|un|u?eq|[ou]lt|[ou]le|ngle?|seq|l[et]|ng[et]).[sd]|(ceil|floor|round|trunc).[lw].[sd]|cfc1|cvt.d.[lsw]|cvt.l.[dsw]|cvt.ps.s|cvt.s.[dlw]|cvt.s.p[lu]|cvt.w.[dls]|div.[ds]|ldx?c1|luxc1|lwx?c1|madd.[sd]|mfc1|mov[fntz]?.[ds]|msub.[sd]|mth?c1|mul.[ds]|neg.[ds]|nmadd.[ds]|nmsub.[ds]|p[lu][lu].ps|recip.fmt|r?sqrt.[ds]|sdx?c1|sub.[ds]|suxc1|swx?c1|break|cache|d?eret|[de]i|ehb|mfc0|mtc0|pause|prefx?|rdhwr|rdpgpr|sdbbp|ssnop|synci?|syscall|teqi?|tgei?u?|tlb(p|r|w[ir])|tlti?u?|tnei?|wait|wrpgpr)",
+end:"\\s"},a.COMMENT("[;#]","$"),a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",relevance:0},{className:"title",begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{className:"number",variants:[{begin:"0x[0-9a-f]+"},{begin:"\\b-?\\d+"}],relevance:0},{className:"symbol",variants:[{begin:"^\\s*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{begin:"^\\s*[0-9]+:"},{begin:"[0-9]+[bf]"}],relevance:0}],illegal:"/"}});b.registerLanguage("mizar",function(a){return{keywords:"environ vocabularies notations constructors definitions registrations theorems schemes requirements begin end definition registration cluster existence pred func defpred deffunc theorem proof let take assume then thus hence ex for st holds consider reconsider such that and in provided of as from be being by means equals implies iff redefine define now not or attr is mode suppose per cases set thesis contradiction scheme reserve struct correctness compatibility coherence symmetry assymetry reflexivity irreflexivity connectedness uniqueness commutativity idempotence involutiveness projectivity",
+contains:[a.COMMENT("::","$")]}});b.registerLanguage("perl",function(a){var b={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when"},
+d={begin:"->{",end:"}"},e={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},f=[a.BACKSLASH_ESCAPE,b,e];a=[e,a.HASH_COMMENT_MODE,a.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),d,{className:"string",contains:f,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",
 end:"\\>",relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},{begin:"{\\w+}",contains:[],relevance:0},{begin:"-?\\w+\\s*\\=\\>",contains:[],relevance:0}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\/\\/|"+a.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",keywords:"split return print reverse grep",
 relevance:0,contains:[a.HASH_COMMENT_MODE,{className:"regexp",begin:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",relevance:10},{className:"regexp",begin:"(m|qr)?/",end:"/[a-z]*",contains:[a.BACKSLASH_ESCAPE],relevance:0}]},{className:"function",beginKeywords:"sub",end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[a.TITLE_MODE]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]}];b.contains=
-a;e.contains=a;return{aliases:["pl","pm"],lexemes:/[\w\.]+/,keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when",
-contains:a}});b.registerLanguage("prolog",function(a){var b={begin:/\(/,end:/\)/,relevance:0},e={begin:/\[/,end:/\]/};a=[{begin:/[a-z][A-Za-z0-9_]*/,relevance:0},{className:"symbol",variants:[{begin:/[A-Z][a-zA-Z0-9_]*/},{begin:/_[A-Za-z0-9_]*/}],relevance:0},b,{begin:/:-/},e,{className:"comment",begin:/%/,end:/$/,contains:[a.PHRASAL_WORDS_MODE]},a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{className:"string",begin:/`/,end:/`/,contains:[a.BACKSLASH_ESCAPE]},{className:"string",begin:/0\'(\\\'|.)/},
-{className:"string",begin:/0\'\\s/},a.C_NUMBER_MODE];b.contains=a;e.contains=a;return{contains:a.concat([{begin:/\.$/}])}});b.registerLanguage("python",function(a){var b={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},e={className:"meta",begin:/^(>>>|\.\.\.) /},h={className:"subst",begin:/\{/,end:/\}/,
-keywords:b,illegal:/#/},c={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[e],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[e],relevance:10},{begin:/(fr|rf|f)'''/,end:/'''/,contains:[e,h]},{begin:/(fr|rf|f)"""/,end:/"""/,contains:[e,h]},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,end:/"/},{begin:/(fr|rf|f)'/,end:/'/,contains:[h]},{begin:/(fr|rf|f)"/,end:/"/,
-contains:[h]},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]},f={className:"number",relevance:0,variants:[{begin:a.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:a.C_NUMBER_RE+"[lLjJ]?"}]},k={className:"params",begin:/\(/,end:/\)/,contains:["self",e,f,c]};h.contains=[c,f,e];return{aliases:["py","gyp"],keywords:b,illegal:/(<\/|->|\?)|=>/,contains:[e,f,c,a.HASH_COMMENT_MODE,{variants:[{className:"function",beginKeywords:"def"},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,
-contains:[a.UNDERSCORE_TITLE_MODE,k,{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}});b.registerLanguage("scala",function(a){var b={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},e={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},h={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0};return{keywords:{literal:"true false null",
-keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,
-b]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[b],relevance:10}]},{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},e,{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[h]},{className:"class",beginKeywords:"class object trait type",end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[e]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,
-relevance:0,contains:[e]},h]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("sql",function(a){var b=a.COMMENT("--","$");return{case_insensitive:!0,illegal:/[<>{}*#]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment",
-end:/;/,endsWithParent:!0,lexemes:/[\w\.]+/,keywords:{keyword:"abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias allocate allow alter always analyze ancillary and any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second section securefile security seed segment select self sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",
-literal:"true false null",built_in:"array bigint binary bit blob boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text varchar varying void"},contains:[{className:"string",begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE,{begin:"''"}]},{className:"string",begin:'"',end:'"',contains:[a.BACKSLASH_ESCAPE,{begin:'""'}]},{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE,b]},
-a.C_BLOCK_COMMENT_MODE,b]}});b.registerLanguage("xml",function(a){var b={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]};return{aliases:"html xhtml rss atom xjb xsd xsl plist".split(" "),case_insensitive:!0,contains:[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",
-end:"\\]"}]},a.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{begin:/<\?(php)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0}]},{className:"tag",begin:"<style(?=\\s|>|$)",end:">",keywords:{name:"style"},contains:[b],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[b],starts:{end:"\x3c/script>",returnEnd:!0,subLanguage:["actionscript",
-"javascript","handlebars","xml"]}},{className:"meta",variants:[{begin:/<\?xml/,end:/\?>/,relevance:10},{begin:/<\?\w+/,end:/\?>/}]},{className:"tag",begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},b]}]}});return b});
+a;d.contains=a;return{aliases:["pl","pm"],lexemes:/[\w\.]+/,keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when",
+contains:a}});b.registerLanguage("mojolicious",function(a){return{subLanguage:"xml",contains:[{className:"meta",begin:"^__(END|DATA)__$"},{begin:"^\\s*%{1,2}={0,2}",end:"$",subLanguage:"perl"},{begin:"<%{1,2}={0,2}",end:"={0,1}%>",subLanguage:"perl",excludeBegin:!0,excludeEnd:!0}]}});b.registerLanguage("monkey",function(a){var b={className:"number",relevance:0,variants:[{begin:"[$][a-fA-F0-9]+"},a.NUMBER_MODE]};return{case_insensitive:!0,keywords:{keyword:"public private property continue exit extern new try catch eachin not abstract final select case default const local global field end if then else elseif endif while wend repeat until forever for to step next return module inline throw import",
+built_in:"DebugLog DebugStop Error Print ACos ACosr ASin ASinr ATan ATan2 ATan2r ATanr Abs Abs Ceil Clamp Clamp Cos Cosr Exp Floor Log Max Max Min Min Pow Sgn Sgn Sin Sinr Sqrt Tan Tanr Seed PI HALFPI TWOPI",literal:"true false null and or shl shr mod"},illegal:/\/\*/,contains:[a.COMMENT("#rem","#end"),a.COMMENT("'","$",{relevance:0}),{className:"function",beginKeywords:"function method",end:"[(=:]|$",illegal:/\n/,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"class",beginKeywords:"class interface",
+end:"$",contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{className:"built_in",begin:"\\b(self|super)\\b"},{className:"meta",begin:"\\s*#",end:"$",keywords:{"meta-keyword":"if else elseif endif end then"}},{className:"meta",begin:"^\\s*strict\\b"},{beginKeywords:"alias",end:"=",contains:[a.UNDERSCORE_TITLE_MODE]},a.QUOTE_STRING_MODE,b]}});b.registerLanguage("moonscript",function(a){var b={keyword:"if then not for in while do return else elseif break continue switch and or unless when class extends super local import export from using",
+literal:"true false nil",built_in:"_G _VERSION assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall coroutine debug io math os package string table"},d={className:"subst",begin:/#\{/,end:/}/,keywords:b},e=[a.inherit(a.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",relevance:0}}),{className:"string",variants:[{begin:/'/,end:/'/,contains:[a.BACKSLASH_ESCAPE]},
+{begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,d]}]},{className:"built_in",begin:"@__"+a.IDENT_RE},{begin:"@"+a.IDENT_RE},{begin:a.IDENT_RE+"\\\\"+a.IDENT_RE}];d.contains=e;d=a.inherit(a.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"});var f={className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,end:/\)/,keywords:b,contains:["self"].concat(e)}]};return{aliases:["moon"],keywords:b,illegal:/\/\*/,contains:e.concat([a.COMMENT("--","$"),{className:"function",begin:"^\\s*[A-Za-z$_][0-9A-Za-z$_]*\\s*=\\s*(\\(.*\\))?\\s*\\B[-=]>",
+end:"[-=]>",returnBegin:!0,contains:[d,f]},{begin:/[\(,:=]\s*/,relevance:0,contains:[{className:"function",begin:"(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[f]}]},{className:"class",beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[d]},d]},{className:"name",begin:"[A-Za-z$_][0-9A-Za-z$_]*:",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}});b.registerLanguage("n1ql",function(a){return{case_insensitive:!0,
+contains:[{beginKeywords:"build create index delete drop explain infer|10 insert merge prepare select update upsert|10",end:/;/,endsWithParent:!0,keywords:{keyword:"all alter analyze and any array as asc begin between binary boolean break bucket build by call case cast cluster collate collection commit connect continue correlate cover create database dataset datastore declare decrement delete derived desc describe distinct do drop each element else end every except exclude execute exists explain fetch first flatten for force from function grant group gsi having if ignore ilike in include increment index infer inline inner insert intersect into is join key keys keyspace known last left let letting like limit lsm map mapping matched materialized merge minus namespace nest not number object offset on option or order outer over parse partition password path pool prepare primary private privilege procedure public raw realm reduce rename return returning revoke right role rollback satisfies schema select self semi set show some start statistics string system then to transaction trigger truncate under union unique unknown unnest unset update upsert use user using validate value valued values via view when where while with within work xor",
+literal:"true false null missing|5",built_in:"array_agg array_append array_concat array_contains array_count array_distinct array_ifnull array_length array_max array_min array_position array_prepend array_put array_range array_remove array_repeat array_replace array_reverse array_sort array_sum avg count max min sum greatest least ifmissing ifmissingornull ifnull missingif nullif ifinf ifnan ifnanorinf naninf neginfif posinfif clock_millis clock_str date_add_millis date_add_str date_diff_millis date_diff_str date_part_millis date_part_str date_trunc_millis date_trunc_str duration_to_str millis str_to_millis millis_to_str millis_to_utc millis_to_zone_name now_millis now_str str_to_duration str_to_utc str_to_zone_name decode_json encode_json encoded_size poly_length base64 base64_encode base64_decode meta uuid abs acos asin atan atan2 ceil cos degrees e exp ln log floor pi power radians random round sign sin sqrt tan trunc object_length object_names object_pairs object_inner_pairs object_values object_inner_values object_add object_put object_remove object_unwrap regexp_contains regexp_like regexp_position regexp_replace contains initcap length lower ltrim position repeat replace rtrim split substr title trim upper isarray isatom isboolean isnumber isobject isstring type toarray toatom toboolean tonumber toobject tostring"},
+contains:[{className:"string",begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE],relevance:0},{className:"string",begin:'"',end:'"',contains:[a.BACKSLASH_ESCAPE],relevance:0},{className:"symbol",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE],relevance:2},a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE]},a.C_BLOCK_COMMENT_MODE]}});b.registerLanguage("nginx",function(a){var b={className:"variable",variants:[{begin:/\$\d+/},{begin:/\$\{/,end:/}/},{begin:"[\\$\\@]"+a.UNDERSCORE_IDENT_RE}]};return{aliases:["nginxconf"],
+contains:[a.HASH_COMMENT_MODE,{begin:a.UNDERSCORE_IDENT_RE+"\\s+{",returnBegin:!0,end:"{",contains:[{className:"section",begin:a.UNDERSCORE_IDENT_RE}],relevance:0},{begin:a.UNDERSCORE_IDENT_RE+"\\s",end:";|{",returnBegin:!0,contains:[{className:"attribute",begin:a.UNDERSCORE_IDENT_RE,starts:{endsWithParent:!0,lexemes:"[a-z/_]+",keywords:{literal:"on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll"},relevance:0,
+illegal:"=>",contains:[a.HASH_COMMENT_MODE,{className:"string",contains:[a.BACKSLASH_ESCAPE,b],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/}]},{begin:"([a-z]+):/",end:"\\s",endsWithParent:!0,excludeEnd:!0,contains:[b]},{className:"regexp",contains:[a.BACKSLASH_ESCAPE,b],variants:[{begin:"\\s\\^",end:"\\s|{|;",returnEnd:!0},{begin:"~\\*?\\s+",end:"\\s|{|;",returnEnd:!0},{begin:"\\*(\\.[a-z\\-]+)+"},{begin:"([a-z\\-]+\\.)+\\*"}]},{className:"number",begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},
+{className:"number",begin:"\\b\\d+[kKmMgGdshdwy]*\\b",relevance:0},b]}}],relevance:0}],illegal:"[^\\s\\}]"}});b.registerLanguage("nimrod",function(a){return{aliases:["nim"],keywords:{keyword:"addr and as asm bind block break case cast const continue converter discard distinct div do elif else end enum except export finally for from generic if import in include interface is isnot iterator let macro method mixin mod nil not notin object of or out proc ptr raise ref return shl shr static template try tuple type using var when while with without xor yield",
+literal:"shared guarded stdin stdout stderr result true false",built_in:"int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float float32 float64 bool char string cstring pointer expr stmt void auto any range array openarray varargs seq set clong culong cchar cschar cshort cint csize clonglong cfloat cdouble clongdouble cuchar cushort cuint culonglong cstringarray semistatic"},contains:[{className:"meta",begin:/{\./,end:/\.}/,relevance:10},{className:"string",begin:/[a-zA-Z]\w*"/,end:/"/,
+contains:[{begin:/""/}]},{className:"string",begin:/([a-zA-Z]\w*)?"""/,end:/"""/},a.QUOTE_STRING_MODE,{className:"type",begin:/\b[A-Z]\w+\b/,relevance:0},{className:"number",relevance:0,variants:[{begin:/\b(0[xX][0-9a-fA-F][_0-9a-fA-F]*)('?[iIuU](8|16|32|64))?/},{begin:/\b(0o[0-7][_0-7]*)('?[iIuUfF](8|16|32|64))?/},{begin:/\b(0(b|B)[01][_01]*)('?[iIuUfF](8|16|32|64))?/},{begin:/\b(\d[_\d]*)('?[iIuUfF](8|16|32|64))?/}]},a.HASH_COMMENT_MODE]}});b.registerLanguage("nix",function(a){var b={keyword:"rec with let in inherit assert if else then",
+literal:"true false or and null",built_in:"import abort baseNameOf dirOf isNull builtins map removeAttrs throw toString derivation"},d={className:"subst",begin:/\$\{/,end:/}/,keywords:b};a=[a.NUMBER_MODE,a.HASH_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",contains:[d],variants:[{begin:"''",end:"''"},{begin:'"',end:'"'}]},{begin:/[a-zA-Z0-9-_]+(\s*=)/,returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/\S+/}]}];d.contains=a;return{aliases:["nixos"],keywords:b,contains:a}});b.registerLanguage("nsis",
+function(a){var b={className:"variable",begin:/\$+{[\w\.:-]+}/},d={className:"variable",begin:/\$+\w+/,illegal:/\(\){}/},e={className:"variable",begin:/\$+\([\w\^\.:-]+\)/},f={className:"string",variants:[{begin:'"',end:'"'},{begin:"'",end:"'"},{begin:"`",end:"`"}],illegal:/\n/,contains:[{className:"meta",begin:/\$(\\[nrt]|\$)/},{className:"variable",begin:/\$(ADMINTOOLS|APPDATA|CDBURN_AREA|CMDLINE|COMMONFILES32|COMMONFILES64|COMMONFILES|COOKIES|DESKTOP|DOCUMENTS|EXEDIR|EXEFILE|EXEPATH|FAVORITES|FONTS|HISTORY|HWNDPARENT|INSTDIR|INTERNET_CACHE|LANGUAGE|LOCALAPPDATA|MUSIC|NETHOOD|OUTDIR|PICTURES|PLUGINSDIR|PRINTHOOD|PROFILE|PROGRAMFILES32|PROGRAMFILES64|PROGRAMFILES|QUICKLAUNCH|RECENT|RESOURCES_LOCALIZED|RESOURCES|SENDTO|SMPROGRAMS|SMSTARTUP|STARTMENU|SYSDIR|TEMP|TEMPLATES|VIDEOS|WINDIR)/},
+b,d,e]};return{case_insensitive:!1,keywords:{keyword:"Abort AddBrandingImage AddSize AllowRootDirInstall AllowSkipFiles AutoCloseWindow BGFont BGGradient BrandingText BringToFront Call CallInstDLL Caption ChangeUI CheckBitmap ClearErrors CompletedText ComponentText CopyFiles CRCCheck CreateDirectory CreateFont CreateShortCut Delete DeleteINISec DeleteINIStr DeleteRegKey DeleteRegValue DetailPrint DetailsButtonText DirText DirVar DirVerify EnableWindow EnumRegKey EnumRegValue Exch Exec ExecShell ExecShellWait ExecWait ExpandEnvStrings File FileBufSize FileClose FileErrorText FileOpen FileRead FileReadByte FileReadUTF16LE FileReadWord FileSeek FileWrite FileWriteByte FileWriteUTF16LE FileWriteWord FindClose FindFirst FindNext FindWindow FlushINI FunctionEnd GetCurInstType GetCurrentAddress GetDlgItem GetDLLVersion GetDLLVersionLocal GetErrorLevel GetFileTime GetFileTimeLocal GetFullPathName GetFunctionAddress GetInstDirError GetLabelAddress GetTempFileName Goto HideWindow Icon IfAbort IfErrors IfFileExists IfRebootFlag IfSilent InitPluginsDir InstallButtonText InstallColors InstallDir InstallDirRegKey InstProgressFlags InstType InstTypeGetText InstTypeSetText Int64Cmp Int64CmpU Int64Fmt IntCmp IntCmpU IntFmt IntOp IntPtrCmp IntPtrCmpU IntPtrOp IsWindow LangString LicenseBkColor LicenseData LicenseForceSelection LicenseLangString LicenseText LoadLanguageFile LockWindow LogSet LogText ManifestDPIAware ManifestSupportedOS MessageBox MiscButtonText Name Nop OutFile Page PageCallbacks PageExEnd Pop Push Quit ReadEnvStr ReadINIStr ReadRegDWORD ReadRegStr Reboot RegDLL Rename RequestExecutionLevel ReserveFile Return RMDir SearchPath SectionEnd SectionGetFlags SectionGetInstTypes SectionGetSize SectionGetText SectionGroupEnd SectionIn SectionSetFlags SectionSetInstTypes SectionSetSize SectionSetText SendMessage SetAutoClose SetBrandingImage SetCompress SetCompressor SetCompressorDictSize SetCtlColors SetCurInstType SetDatablockOptimize SetDateSave SetDetailsPrint SetDetailsView SetErrorLevel SetErrors SetFileAttributes SetFont SetOutPath SetOverwrite SetRebootFlag SetRegView SetShellVarContext SetSilent ShowInstDetails ShowUninstDetails ShowWindow SilentInstall SilentUnInstall Sleep SpaceTexts StrCmp StrCmpS StrCpy StrLen SubCaption Unicode UninstallButtonText UninstallCaption UninstallIcon UninstallSubCaption UninstallText UninstPage UnRegDLL Var VIAddVersionKey VIFileVersion VIProductVersion WindowIcon WriteINIStr WriteRegBin WriteRegDWORD WriteRegExpandStr WriteRegMultiStr WriteRegNone WriteRegStr WriteUninstaller XPStyle",
+literal:"admin all auto both bottom bzip2 colored components current custom directory false force hide highest ifdiff ifnewer instfiles lastused leave left license listonly lzma nevershow none normal notset off on open print right show silent silentlog smooth textonly top true try un.components un.custom un.directory un.instfiles un.license uninstConfirm user Win10 Win7 Win8 WinVista zlib"},contains:[a.HASH_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.COMMENT(";","$",{relevance:0}),{className:"function",
+beginKeywords:"Function PageEx Section SectionGroup",end:"$"},f,{className:"keyword",begin:/!(addincludedir|addplugindir|appendfile|cd|define|delfile|echo|else|endif|error|execute|finalize|getdllversion|gettlbversion|if|ifdef|ifmacrodef|ifmacrondef|ifndef|include|insertmacro|macro|macroend|makensis|packhdr|searchparse|searchreplace|system|tempfile|undef|verbose|warning)/},b,d,e,{className:"params",begin:"(ARCHIVE|FILE_ATTRIBUTE_ARCHIVE|FILE_ATTRIBUTE_NORMAL|FILE_ATTRIBUTE_OFFLINE|FILE_ATTRIBUTE_READONLY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_TEMPORARY|HKCR|HKCU|HKDD|HKEY_CLASSES_ROOT|HKEY_CURRENT_CONFIG|HKEY_CURRENT_USER|HKEY_DYN_DATA|HKEY_LOCAL_MACHINE|HKEY_PERFORMANCE_DATA|HKEY_USERS|HKLM|HKPD|HKU|IDABORT|IDCANCEL|IDIGNORE|IDNO|IDOK|IDRETRY|IDYES|MB_ABORTRETRYIGNORE|MB_DEFBUTTON1|MB_DEFBUTTON2|MB_DEFBUTTON3|MB_DEFBUTTON4|MB_ICONEXCLAMATION|MB_ICONINFORMATION|MB_ICONQUESTION|MB_ICONSTOP|MB_OK|MB_OKCANCEL|MB_RETRYCANCEL|MB_RIGHT|MB_RTLREADING|MB_SETFOREGROUND|MB_TOPMOST|MB_USERICON|MB_YESNO|NORMAL|OFFLINE|READONLY|SHCTX|SHELL_CONTEXT|SYSTEM|TEMPORARY)"},
+{className:"class",begin:/\w+::\w+/},a.NUMBER_MODE]}});b.registerLanguage("objectivec",function(a){var b=/[a-zA-Z@][a-zA-Z0-9_]*/;return{aliases:["mm","objc","obj-c"],keywords:{keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",
+literal:"false true FALSE TRUE nil YES NO NULL",built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"},lexemes:b,illegal:"</",contains:[{className:"built_in",begin:"\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\w+"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.C_NUMBER_MODE,a.QUOTE_STRING_MODE,{className:"string",variants:[{begin:'@"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:"'",end:"[^\\\\]'",illegal:"[^\\\\][^']"}]},
+{className:"meta",begin:"#",end:"$",contains:[{className:"meta-string",variants:[{begin:'"',end:'"'},{begin:"<",end:">"}]}]},{className:"class",begin:"(@interface|@class|@protocol|@implementation)\\b",end:"({|$)",excludeEnd:!0,keywords:"@interface @class @protocol @implementation",lexemes:b,contains:[a.UNDERSCORE_TITLE_MODE]},{begin:"\\."+a.UNDERSCORE_IDENT_RE,relevance:0}]}});b.registerLanguage("ocaml",function(a){return{aliases:["ml"],keywords:{keyword:"and as assert asr begin class constraint do done downto else end exception external for fun function functor if in include inherit! inherit initializer land lazy let lor lsl lsr lxor match method!|10 method mod module mutable new object of open! open or private rec sig struct then to try type val! val virtual when while with parser value",
+built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 string unit in_channel out_channel ref",literal:"true false"},illegal:/\/\/|>>/,lexemes:"[a-z_]\\w*!?",contains:[{className:"literal",begin:"\\[(\\|\\|)?\\]|\\(\\)",relevance:0},a.COMMENT("\\(\\*","\\*\\)",{contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{className:"type",begin:"`[A-Z][\\w']*"},{className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*",relevance:0},
+a.inherit(a.APOS_STRING_MODE,{className:"string",relevance:0}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),{className:"number",begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",relevance:0},{begin:/[-=]>/}]}});b.registerLanguage("openscad",function(a){var b={className:"keyword",begin:"\\$(f[asn]|t|vp[rtd]|children)"},d={className:"number",begin:"\\b\\d+(\\.\\d+)?(e-?\\d+)?",relevance:0},e=a.inherit(a.QUOTE_STRING_MODE,
+{illegal:null});return{aliases:["scad"],keywords:{keyword:"function module include use for intersection_for if else \\%",literal:"false true PI undef",built_in:"circle square polygon text sphere cube cylinder polyhedron translate rotate scale resize mirror multmatrix color offset hull minkowski union difference intersection abs sign sin cos tan acos asin atan atan2 floor round ceil ln log pow sqrt exp rands min max concat lookup str chr search version version_num norm cross parent_module echo import import_dxf dxf_linear_extrude linear_extrude rotate_extrude surface projection render children dxf_cross dxf_dim let assign"},
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,d,{className:"meta",keywords:{"meta-keyword":"include use"},begin:"include|use <",end:">"},e,b,{begin:"[*!#%]",relevance:0},{className:"function",beginKeywords:"module function",end:"\\=|\\{",contains:[{className:"params",begin:"\\(",end:"\\)",contains:["self",d,e,b,{className:"literal",begin:"false|true|PI|undef"}]},a.UNDERSCORE_TITLE_MODE]}]}});b.registerLanguage("oxygene",function(a){var b=a.COMMENT("{","}",{relevance:0}),d=a.COMMENT("\\(\\*",
+"\\*\\)",{relevance:10}),e={className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},f={className:"string",begin:"(#\\d+)+"},g={className:"function",beginKeywords:"function constructor destructor procedure method",end:"[:;]",keywords:"function constructor|10 destructor|10 procedure|10 method|10",contains:[a.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",keywords:"abstract add and array as asc aspect assembly async begin break block by case class concat const copy constructor continue create default delegate desc distinct div do downto dynamic each else empty end ensure enum equals event except exit extension external false final finalize finalizer finally flags for forward from function future global group has if implementation implements implies in index inherited inline interface into invariants is iterator join locked locking loop matching method mod module namespace nested new nil not notify nullable of old on operator or order out override parallel params partial pinned private procedure property protected public queryable raise read readonly record reintroduce remove repeat require result reverse sealed select self sequence set shl shr skip static step soft take then to true try tuple type union unit unsafe until uses using var virtual raises volatile where while with write xor yield await mapped deprecated stdcall cdecl pascal register safecall overload library platform reference packed strict published autoreleasepool selector strong weak unretained",
+contains:[e,f]},b,d]};return{case_insensitive:!0,lexemes:/\.?\w+/,keywords:"abstract add and array as asc aspect assembly async begin break block by case class concat const copy constructor continue create default delegate desc distinct div do downto dynamic each else empty end ensure enum equals event except exit extension external false final finalize finalizer finally flags for forward from function future global group has if implementation implements implies in index inherited inline interface into invariants is iterator join locked locking loop matching method mod module namespace nested new nil not notify nullable of old on operator or order out override parallel params partial pinned private procedure property protected public queryable raise read readonly record reintroduce remove repeat require result reverse sealed select self sequence set shl shr skip static step soft take then to true try tuple type union unit unsafe until uses using var virtual raises volatile where while with write xor yield await mapped deprecated stdcall cdecl pascal register safecall overload library platform reference packed strict published autoreleasepool selector strong weak unretained",
+illegal:'("|\\$[G-Zg-z]|\\/\\*|</|=>|->)',contains:[b,d,a.C_LINE_COMMENT_MODE,e,f,a.NUMBER_MODE,g,{className:"class",begin:"=\\bclass\\b",end:"end;",keywords:"abstract add and array as asc aspect assembly async begin break block by case class concat const copy constructor continue create default delegate desc distinct div do downto dynamic each else empty end ensure enum equals event except exit extension external false final finalize finalizer finally flags for forward from function future global group has if implementation implements implies in index inherited inline interface into invariants is iterator join locked locking loop matching method mod module namespace nested new nil not notify nullable of old on operator or order out override parallel params partial pinned private procedure property protected public queryable raise read readonly record reintroduce remove repeat require result reverse sealed select self sequence set shl shr skip static step soft take then to true try tuple type union unit unsafe until uses using var virtual raises volatile where while with write xor yield await mapped deprecated stdcall cdecl pascal register safecall overload library platform reference packed strict published autoreleasepool selector strong weak unretained",
+contains:[e,f,b,d,a.C_LINE_COMMENT_MODE,g]}]}});b.registerLanguage("parser3",function(a){var b=a.COMMENT("{","}",{contains:["self"]});return{subLanguage:"xml",relevance:0,contains:[a.COMMENT("^#","$"),a.COMMENT("\\^rem{","}",{relevance:10,contains:[b]}),{className:"meta",begin:"^@(?:BASE|USE|CLASS|OPTIONS)$",relevance:10},{className:"title",begin:"@[\\w\\-]+\\[[\\w^;\\-]*\\](?:\\[[\\w^;\\-]*\\])?(?:.*)$"},{className:"variable",begin:"\\$\\{?[\\w\\-\\.\\:]+\\}?"},{className:"keyword",begin:"\\^[\\w\\-\\.\\:]+"},
+{className:"number",begin:"\\^#[0-9a-fA-F]+"},a.C_NUMBER_MODE]}});b.registerLanguage("pf",function(a){return{aliases:["pf.conf"],lexemes:/[a-z0-9_<>-]+/,keywords:{built_in:"block match pass load anchor|5 antispoof|10 set table",keyword:"in out log quick on rdomain inet inet6 proto from port os to routeallow-opts divert-packet divert-reply divert-to flags group icmp-typeicmp6-type label once probability recieved-on rtable prio queuetos tag tagged user keep fragment for os dropaf-to|10 binat-to|10 nat-to|10 rdr-to|10 bitmask least-stats random round-robinsource-hash static-portdup-to reply-to route-toparent bandwidth default min max qlimitblock-policy debug fingerprints hostid limit loginterface optimizationreassemble ruleset-optimization basic none profile skip state-defaultsstate-policy timeoutconst counters persistno modulate synproxy state|5 floating if-bound no-sync pflow|10 sloppysource-track global rule max-src-nodes max-src-states max-src-connmax-src-conn-rate overload flushscrub|5 max-mss min-ttl no-df|10 random-id",
+literal:"all any no-route self urpf-failed egress|5 unknown"},contains:[a.HASH_COMMENT_MODE,a.NUMBER_MODE,a.QUOTE_STRING_MODE,{className:"variable",begin:/\$[\w\d#@][\w\d_]*/},{className:"variable",begin:/<(?!\/)/,end:/>/}]}});b.registerLanguage("pgsql",function(a){var b=a.COMMENT("--","$");"HSTORE|10 LO LTREE|10 ";var d="BIGINT INT8 BIGSERIAL SERIAL8 BIT VARYING VARBIT BOOLEAN BOOL BOX BYTEA CHARACTER CHAR VARCHAR CIDR CIRCLE DATE DOUBLE PRECISION FLOAT8 FLOAT INET INTEGER INT INT4 INTERVAL JSON JSONB LINE LSEG|10 MACADDR MACADDR8 MONEY NUMERIC DEC DECIMAL PATH POINT POLYGON REAL FLOAT4 SMALLINT INT2 SMALLSERIAL|10 SERIAL2|10 SERIAL|10 SERIAL4|10 TEXT TIME ZONE TIMETZ|10 TIMESTAMP TIMESTAMPTZ|10 TSQUERY|10 TSVECTOR|10 TXID_SNAPSHOT|10 UUID XML NATIONAL NCHAR INT4RANGE|10 INT8RANGE|10 NUMRANGE|10 TSRANGE|10 TSTZRANGE|10 DATERANGE|10 ANYELEMENT ANYARRAY ANYNONARRAY ANYENUM ANYRANGE CSTRING INTERNAL RECORD PG_DDL_COMMAND VOID UNKNOWN OPAQUE REFCURSOR NAME OID REGPROC|10 REGPROCEDURE|10 REGOPER|10 REGOPERATOR|10 REGCLASS|10 REGTYPE|10 REGROLE|10 REGNAMESPACE|10 REGCONFIG|10 REGDICTIONARY|10".split(" ").map(function(a){return a.split("|")[0]}).join("|"),
+e="ARRAY_AGG AVG BIT_AND BIT_OR BOOL_AND BOOL_OR COUNT EVERY JSON_AGG JSONB_AGG JSON_OBJECT_AGG JSONB_OBJECT_AGG MAX MIN MODE STRING_AGG SUM XMLAGG CORR COVAR_POP COVAR_SAMP REGR_AVGX REGR_AVGY REGR_COUNT REGR_INTERCEPT REGR_R2 REGR_SLOPE REGR_SXX REGR_SXY REGR_SYY STDDEV STDDEV_POP STDDEV_SAMP VARIANCE VAR_POP VAR_SAMP PERCENTILE_CONT PERCENTILE_DISC ROW_NUMBER RANK DENSE_RANK PERCENT_RANK CUME_DIST NTILE LAG LEAD FIRST_VALUE LAST_VALUE NTH_VALUE NUM_NONNULLS NUM_NULLS ABS CBRT CEIL CEILING DEGREES DIV EXP FLOOR LN LOG MOD PI POWER RADIANS ROUND SCALE SIGN SQRT TRUNC WIDTH_BUCKET RANDOM SETSEED ACOS ACOSD ASIN ASIND ATAN ATAND ATAN2 ATAN2D COS COSD COT COTD SIN SIND TAN TAND BIT_LENGTH CHAR_LENGTH CHARACTER_LENGTH LOWER OCTET_LENGTH OVERLAY POSITION SUBSTRING TREAT TRIM UPPER ASCII BTRIM CHR CONCAT CONCAT_WS CONVERT CONVERT_FROM CONVERT_TO DECODE ENCODE INITCAPLEFT LENGTH LPAD LTRIM MD5 PARSE_IDENT PG_CLIENT_ENCODING QUOTE_IDENT|10 QUOTE_LITERAL|10 QUOTE_NULLABLE|10 REGEXP_MATCH REGEXP_MATCHES REGEXP_REPLACE REGEXP_SPLIT_TO_ARRAY REGEXP_SPLIT_TO_TABLE REPEAT REPLACE REVERSE RIGHT RPAD RTRIM SPLIT_PART STRPOS SUBSTR TO_ASCII TO_HEX TRANSLATE OCTET_LENGTH GET_BIT GET_BYTE SET_BIT SET_BYTE TO_CHAR TO_DATE TO_NUMBER TO_TIMESTAMP AGE CLOCK_TIMESTAMP|10 DATE_PART DATE_TRUNC ISFINITE JUSTIFY_DAYS JUSTIFY_HOURS JUSTIFY_INTERVAL MAKE_DATE MAKE_INTERVAL|10 MAKE_TIME MAKE_TIMESTAMP|10 MAKE_TIMESTAMPTZ|10 NOW STATEMENT_TIMESTAMP|10 TIMEOFDAY TRANSACTION_TIMESTAMP|10 ENUM_FIRST ENUM_LAST ENUM_RANGE AREA CENTER DIAMETER HEIGHT ISCLOSED ISOPEN NPOINTS PCLOSE POPEN RADIUS WIDTH BOX BOUND_BOX CIRCLE LINE LSEG PATH POLYGON ABBREV BROADCAST HOST HOSTMASK MASKLEN NETMASK NETWORK SET_MASKLEN TEXT INET_SAME_FAMILYINET_MERGE MACADDR8_SET7BIT ARRAY_TO_TSVECTOR GET_CURRENT_TS_CONFIG NUMNODE PLAINTO_TSQUERY PHRASETO_TSQUERY WEBSEARCH_TO_TSQUERY QUERYTREE SETWEIGHT STRIP TO_TSQUERY TO_TSVECTOR JSON_TO_TSVECTOR JSONB_TO_TSVECTOR TS_DELETE TS_FILTER TS_HEADLINE TS_RANK TS_RANK_CD TS_REWRITE TSQUERY_PHRASE TSVECTOR_TO_ARRAY TSVECTOR_UPDATE_TRIGGER TSVECTOR_UPDATE_TRIGGER_COLUMN XMLCOMMENT XMLCONCAT XMLELEMENT XMLFOREST XMLPI XMLROOT XMLEXISTS XML_IS_WELL_FORMED XML_IS_WELL_FORMED_DOCUMENT XML_IS_WELL_FORMED_CONTENT XPATH XPATH_EXISTS XMLTABLE XMLNAMESPACES TABLE_TO_XML TABLE_TO_XMLSCHEMA TABLE_TO_XML_AND_XMLSCHEMA QUERY_TO_XML QUERY_TO_XMLSCHEMA QUERY_TO_XML_AND_XMLSCHEMA CURSOR_TO_XML CURSOR_TO_XMLSCHEMA SCHEMA_TO_XML SCHEMA_TO_XMLSCHEMA SCHEMA_TO_XML_AND_XMLSCHEMA DATABASE_TO_XML DATABASE_TO_XMLSCHEMA DATABASE_TO_XML_AND_XMLSCHEMA XMLATTRIBUTES TO_JSON TO_JSONB ARRAY_TO_JSON ROW_TO_JSON JSON_BUILD_ARRAY JSONB_BUILD_ARRAY JSON_BUILD_OBJECT JSONB_BUILD_OBJECT JSON_OBJECT JSONB_OBJECT JSON_ARRAY_LENGTH JSONB_ARRAY_LENGTH JSON_EACH JSONB_EACH JSON_EACH_TEXT JSONB_EACH_TEXT JSON_EXTRACT_PATH JSONB_EXTRACT_PATH JSON_OBJECT_KEYS JSONB_OBJECT_KEYS JSON_POPULATE_RECORD JSONB_POPULATE_RECORD JSON_POPULATE_RECORDSET JSONB_POPULATE_RECORDSET JSON_ARRAY_ELEMENTS JSONB_ARRAY_ELEMENTS JSON_ARRAY_ELEMENTS_TEXT JSONB_ARRAY_ELEMENTS_TEXT JSON_TYPEOF JSONB_TYPEOF JSON_TO_RECORD JSONB_TO_RECORD JSON_TO_RECORDSET JSONB_TO_RECORDSET JSON_STRIP_NULLS JSONB_STRIP_NULLS JSONB_SET JSONB_INSERT JSONB_PRETTY CURRVAL LASTVAL NEXTVAL SETVAL COALESCE NULLIF GREATEST LEAST ARRAY_APPEND ARRAY_CAT ARRAY_NDIMS ARRAY_DIMS ARRAY_FILL ARRAY_LENGTH ARRAY_LOWER ARRAY_POSITION ARRAY_POSITIONS ARRAY_PREPEND ARRAY_REMOVE ARRAY_REPLACE ARRAY_TO_STRING ARRAY_UPPER CARDINALITY STRING_TO_ARRAY UNNEST ISEMPTY LOWER_INC UPPER_INC LOWER_INF UPPER_INF RANGE_MERGE GENERATE_SERIES GENERATE_SUBSCRIPTS CURRENT_DATABASE CURRENT_QUERY CURRENT_SCHEMA|10 CURRENT_SCHEMAS|10 INET_CLIENT_ADDR INET_CLIENT_PORT INET_SERVER_ADDR INET_SERVER_PORT ROW_SECURITY_ACTIVE FORMAT_TYPE TO_REGCLASS TO_REGPROC TO_REGPROCEDURE TO_REGOPER TO_REGOPERATOR TO_REGTYPE TO_REGNAMESPACE TO_REGROLE COL_DESCRIPTION OBJ_DESCRIPTION SHOBJ_DESCRIPTION TXID_CURRENT TXID_CURRENT_IF_ASSIGNED TXID_CURRENT_SNAPSHOT TXID_SNAPSHOT_XIP TXID_SNAPSHOT_XMAX TXID_SNAPSHOT_XMIN TXID_VISIBLE_IN_SNAPSHOT TXID_STATUS CURRENT_SETTING SET_CONFIG BRIN_SUMMARIZE_NEW_VALUES BRIN_SUMMARIZE_RANGE BRIN_DESUMMARIZE_RANGE GIN_CLEAN_PENDING_LIST SUPPRESS_REDUNDANT_UPDATES_TRIGGER LO_FROM_BYTEA LO_PUT LO_GET LO_CREAT LO_CREATE LO_UNLINK LO_IMPORT LO_EXPORT LOREAD LOWRITE GROUPING CAST".split(" ").map(function(a){return a.split("|")[0]}).join("|");
+return{aliases:["postgres","postgresql"],case_insensitive:!0,keywords:{keyword:"ABORT ALTER ANALYZE BEGIN CALL CHECKPOINT|10 CLOSE CLUSTER COMMENT COMMIT COPY CREATE DEALLOCATE DECLARE DELETE DISCARD DO DROP END EXECUTE EXPLAIN FETCH GRANT IMPORT INSERT LISTEN LOAD LOCK MOVE NOTIFY PREPARE REASSIGN|10 REFRESH REINDEX RELEASE RESET REVOKE ROLLBACK SAVEPOINT SECURITY SELECT SET SHOW START TRUNCATE UNLISTEN|10 UPDATE VACUUM|10 VALUES AGGREGATE COLLATION CONVERSION|10 DATABASE DEFAULT PRIVILEGES DOMAIN TRIGGER EXTENSION FOREIGN WRAPPER|10 TABLE FUNCTION GROUP LANGUAGE LARGE OBJECT MATERIALIZED VIEW OPERATOR CLASS FAMILY POLICY PUBLICATION|10 ROLE RULE SCHEMA SEQUENCE SERVER STATISTICS SUBSCRIPTION SYSTEM TABLESPACE CONFIGURATION DICTIONARY PARSER TEMPLATE TYPE USER MAPPING PREPARED ACCESS METHOD CAST AS TRANSFORM TRANSACTION OWNED TO INTO SESSION AUTHORIZATION INDEX PROCEDURE ASSERTION ALL ANALYSE AND ANY ARRAY ASC ASYMMETRIC|10 BOTH CASE CHECK COLLATE COLUMN CONCURRENTLY|10 CONSTRAINT CROSS DEFERRABLE RANGE DESC DISTINCT ELSE EXCEPT FOR FREEZE|10 FROM FULL HAVING ILIKE IN INITIALLY INNER INTERSECT IS ISNULL JOIN LATERAL LEADING LIKE LIMIT NATURAL NOT NOTNULL NULL OFFSET ON ONLY OR ORDER OUTER OVERLAPS PLACING PRIMARY REFERENCES RETURNING SIMILAR SOME SYMMETRIC TABLESAMPLE THEN TRAILING UNION UNIQUE USING VARIADIC|10 VERBOSE WHEN WHERE WINDOW WITH BY RETURNS INOUT OUT SETOF|10 IF STRICT CURRENT CONTINUE OWNER LOCATION OVER PARTITION WITHIN BETWEEN ESCAPE EXTERNAL INVOKER DEFINER WORK RENAME VERSION CONNECTION CONNECT TABLES TEMP TEMPORARY FUNCTIONS SEQUENCES TYPES SCHEMAS OPTION CASCADE RESTRICT ADD ADMIN EXISTS VALID VALIDATE ENABLE DISABLE REPLICA|10 ALWAYS PASSING COLUMNS PATH REF VALUE OVERRIDING IMMUTABLE STABLE VOLATILE BEFORE AFTER EACH ROW PROCEDURAL ROUTINE NO HANDLER VALIDATOR OPTIONS STORAGE OIDS|10 WITHOUT INHERIT DEPENDS CALLED INPUT LEAKPROOF|10 COST ROWS NOWAIT SEARCH UNTIL ENCRYPTED|10 PASSWORD CONFLICT|10 INSTEAD INHERITS CHARACTERISTICS WRITE CURSOR ALSO STATEMENT SHARE EXCLUSIVE INLINE ISOLATION REPEATABLE READ COMMITTED SERIALIZABLE UNCOMMITTED LOCAL GLOBAL SQL PROCEDURES RECURSIVE SNAPSHOT ROLLUP CUBE TRUSTED|10 INCLUDE FOLLOWING PRECEDING UNBOUNDED RANGE GROUPS UNENCRYPTED|10 SYSID FORMAT DELIMITER HEADER QUOTE ENCODING FILTER OFF FORCE_QUOTE FORCE_NOT_NULL FORCE_NULL COSTS BUFFERS TIMING SUMMARY DISABLE_PAGE_SKIPPING RESTART CYCLE GENERATED IDENTITY DEFERRED IMMEDIATE LEVEL LOGGED UNLOGGED OF NOTHING NONE EXCLUDE ATTRIBUTE USAGE ROUTINES TRUE FALSE NAN INFINITY ALIAS BEGIN CONSTANT DECLARE END EXCEPTION RETURN PERFORM|10 RAISE GET DIAGNOSTICS STACKED|10 FOREACH LOOP ELSIF EXIT WHILE REVERSE SLICE DEBUG LOG INFO NOTICE WARNING ASSERT OPEN SUPERUSER NOSUPERUSER CREATEDB NOCREATEDB CREATEROLE NOCREATEROLE INHERIT NOINHERIT LOGIN NOLOGIN REPLICATION NOREPLICATION BYPASSRLS NOBYPASSRLS ",
+built_in:"CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER CURRENT_CATALOG|10 CURRENT_DATE LOCALTIME LOCALTIMESTAMP CURRENT_ROLE|10 CURRENT_SCHEMA|10 SESSION_USER PUBLIC FOUND NEW OLD TG_NAME|10 TG_WHEN|10 TG_LEVEL|10 TG_OP|10 TG_RELID|10 TG_RELNAME|10 TG_TABLE_NAME|10 TG_TABLE_SCHEMA|10 TG_NARGS|10 TG_ARGV|10 TG_EVENT|10 TG_TAG|10 ROW_COUNT RESULT_OID|10 PG_CONTEXT|10 RETURNED_SQLSTATE COLUMN_NAME CONSTRAINT_NAME PG_DATATYPE_NAME|10 MESSAGE_TEXT TABLE_NAME SCHEMA_NAME PG_EXCEPTION_DETAIL|10 PG_EXCEPTION_HINT|10 PG_EXCEPTION_CONTEXT|10 SQLSTATE SQLERRM|10 SUCCESSFUL_COMPLETION WARNING DYNAMIC_RESULT_SETS_RETURNED IMPLICIT_ZERO_BIT_PADDING NULL_VALUE_ELIMINATED_IN_SET_FUNCTION PRIVILEGE_NOT_GRANTED PRIVILEGE_NOT_REVOKED STRING_DATA_RIGHT_TRUNCATION DEPRECATED_FEATURE NO_DATA NO_ADDITIONAL_DYNAMIC_RESULT_SETS_RETURNED SQL_STATEMENT_NOT_YET_COMPLETE CONNECTION_EXCEPTION CONNECTION_DOES_NOT_EXIST CONNECTION_FAILURE SQLCLIENT_UNABLE_TO_ESTABLISH_SQLCONNECTION SQLSERVER_REJECTED_ESTABLISHMENT_OF_SQLCONNECTION TRANSACTION_RESOLUTION_UNKNOWN PROTOCOL_VIOLATION TRIGGERED_ACTION_EXCEPTION FEATURE_NOT_SUPPORTED INVALID_TRANSACTION_INITIATION LOCATOR_EXCEPTION INVALID_LOCATOR_SPECIFICATION INVALID_GRANTOR INVALID_GRANT_OPERATION INVALID_ROLE_SPECIFICATION DIAGNOSTICS_EXCEPTION STACKED_DIAGNOSTICS_ACCESSED_WITHOUT_ACTIVE_HANDLER CASE_NOT_FOUND CARDINALITY_VIOLATION DATA_EXCEPTION ARRAY_SUBSCRIPT_ERROR CHARACTER_NOT_IN_REPERTOIRE DATETIME_FIELD_OVERFLOW DIVISION_BY_ZERO ERROR_IN_ASSIGNMENT ESCAPE_CHARACTER_CONFLICT INDICATOR_OVERFLOW INTERVAL_FIELD_OVERFLOW INVALID_ARGUMENT_FOR_LOGARITHM INVALID_ARGUMENT_FOR_NTILE_FUNCTION INVALID_ARGUMENT_FOR_NTH_VALUE_FUNCTION INVALID_ARGUMENT_FOR_POWER_FUNCTION INVALID_ARGUMENT_FOR_WIDTH_BUCKET_FUNCTION INVALID_CHARACTER_VALUE_FOR_CAST INVALID_DATETIME_FORMAT INVALID_ESCAPE_CHARACTER INVALID_ESCAPE_OCTET INVALID_ESCAPE_SEQUENCE NONSTANDARD_USE_OF_ESCAPE_CHARACTER INVALID_INDICATOR_PARAMETER_VALUE INVALID_PARAMETER_VALUE INVALID_REGULAR_EXPRESSION INVALID_ROW_COUNT_IN_LIMIT_CLAUSE INVALID_ROW_COUNT_IN_RESULT_OFFSET_CLAUSE INVALID_TABLESAMPLE_ARGUMENT INVALID_TABLESAMPLE_REPEAT INVALID_TIME_ZONE_DISPLACEMENT_VALUE INVALID_USE_OF_ESCAPE_CHARACTER MOST_SPECIFIC_TYPE_MISMATCH NULL_VALUE_NOT_ALLOWED NULL_VALUE_NO_INDICATOR_PARAMETER NUMERIC_VALUE_OUT_OF_RANGE SEQUENCE_GENERATOR_LIMIT_EXCEEDED STRING_DATA_LENGTH_MISMATCH STRING_DATA_RIGHT_TRUNCATION SUBSTRING_ERROR TRIM_ERROR UNTERMINATED_C_STRING ZERO_LENGTH_CHARACTER_STRING FLOATING_POINT_EXCEPTION INVALID_TEXT_REPRESENTATION INVALID_BINARY_REPRESENTATION BAD_COPY_FILE_FORMAT UNTRANSLATABLE_CHARACTER NOT_AN_XML_DOCUMENT INVALID_XML_DOCUMENT INVALID_XML_CONTENT INVALID_XML_COMMENT INVALID_XML_PROCESSING_INSTRUCTION INTEGRITY_CONSTRAINT_VIOLATION RESTRICT_VIOLATION NOT_NULL_VIOLATION FOREIGN_KEY_VIOLATION UNIQUE_VIOLATION CHECK_VIOLATION EXCLUSION_VIOLATION INVALID_CURSOR_STATE INVALID_TRANSACTION_STATE ACTIVE_SQL_TRANSACTION BRANCH_TRANSACTION_ALREADY_ACTIVE HELD_CURSOR_REQUIRES_SAME_ISOLATION_LEVEL INAPPROPRIATE_ACCESS_MODE_FOR_BRANCH_TRANSACTION INAPPROPRIATE_ISOLATION_LEVEL_FOR_BRANCH_TRANSACTION NO_ACTIVE_SQL_TRANSACTION_FOR_BRANCH_TRANSACTION READ_ONLY_SQL_TRANSACTION SCHEMA_AND_DATA_STATEMENT_MIXING_NOT_SUPPORTED NO_ACTIVE_SQL_TRANSACTION IN_FAILED_SQL_TRANSACTION IDLE_IN_TRANSACTION_SESSION_TIMEOUT INVALID_SQL_STATEMENT_NAME TRIGGERED_DATA_CHANGE_VIOLATION INVALID_AUTHORIZATION_SPECIFICATION INVALID_PASSWORD DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST DEPENDENT_OBJECTS_STILL_EXIST INVALID_TRANSACTION_TERMINATION SQL_ROUTINE_EXCEPTION FUNCTION_EXECUTED_NO_RETURN_STATEMENT MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED INVALID_CURSOR_NAME EXTERNAL_ROUTINE_EXCEPTION CONTAINING_SQL_NOT_PERMITTED MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED EXTERNAL_ROUTINE_INVOCATION_EXCEPTION INVALID_SQLSTATE_RETURNED NULL_VALUE_NOT_ALLOWED TRIGGER_PROTOCOL_VIOLATED SRF_PROTOCOL_VIOLATED EVENT_TRIGGER_PROTOCOL_VIOLATED SAVEPOINT_EXCEPTION INVALID_SAVEPOINT_SPECIFICATION INVALID_CATALOG_NAME INVALID_SCHEMA_NAME TRANSACTION_ROLLBACK TRANSACTION_INTEGRITY_CONSTRAINT_VIOLATION SERIALIZATION_FAILURE STATEMENT_COMPLETION_UNKNOWN DEADLOCK_DETECTED SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION SYNTAX_ERROR INSUFFICIENT_PRIVILEGE CANNOT_COERCE GROUPING_ERROR WINDOWING_ERROR INVALID_RECURSION INVALID_FOREIGN_KEY INVALID_NAME NAME_TOO_LONG RESERVED_NAME DATATYPE_MISMATCH INDETERMINATE_DATATYPE COLLATION_MISMATCH INDETERMINATE_COLLATION WRONG_OBJECT_TYPE GENERATED_ALWAYS UNDEFINED_COLUMN UNDEFINED_FUNCTION UNDEFINED_TABLE UNDEFINED_PARAMETER UNDEFINED_OBJECT DUPLICATE_COLUMN DUPLICATE_CURSOR DUPLICATE_DATABASE DUPLICATE_FUNCTION DUPLICATE_PREPARED_STATEMENT DUPLICATE_SCHEMA DUPLICATE_TABLE DUPLICATE_ALIAS DUPLICATE_OBJECT AMBIGUOUS_COLUMN AMBIGUOUS_FUNCTION AMBIGUOUS_PARAMETER AMBIGUOUS_ALIAS INVALID_COLUMN_REFERENCE INVALID_COLUMN_DEFINITION INVALID_CURSOR_DEFINITION INVALID_DATABASE_DEFINITION INVALID_FUNCTION_DEFINITION INVALID_PREPARED_STATEMENT_DEFINITION INVALID_SCHEMA_DEFINITION INVALID_TABLE_DEFINITION INVALID_OBJECT_DEFINITION WITH_CHECK_OPTION_VIOLATION INSUFFICIENT_RESOURCES DISK_FULL OUT_OF_MEMORY TOO_MANY_CONNECTIONS CONFIGURATION_LIMIT_EXCEEDED PROGRAM_LIMIT_EXCEEDED STATEMENT_TOO_COMPLEX TOO_MANY_COLUMNS TOO_MANY_ARGUMENTS OBJECT_NOT_IN_PREREQUISITE_STATE OBJECT_IN_USE CANT_CHANGE_RUNTIME_PARAM LOCK_NOT_AVAILABLE OPERATOR_INTERVENTION QUERY_CANCELED ADMIN_SHUTDOWN CRASH_SHUTDOWN CANNOT_CONNECT_NOW DATABASE_DROPPED SYSTEM_ERROR IO_ERROR UNDEFINED_FILE DUPLICATE_FILE SNAPSHOT_TOO_OLD CONFIG_FILE_ERROR LOCK_FILE_EXISTS FDW_ERROR FDW_COLUMN_NAME_NOT_FOUND FDW_DYNAMIC_PARAMETER_VALUE_NEEDED FDW_FUNCTION_SEQUENCE_ERROR FDW_INCONSISTENT_DESCRIPTOR_INFORMATION FDW_INVALID_ATTRIBUTE_VALUE FDW_INVALID_COLUMN_NAME FDW_INVALID_COLUMN_NUMBER FDW_INVALID_DATA_TYPE FDW_INVALID_DATA_TYPE_DESCRIPTORS FDW_INVALID_DESCRIPTOR_FIELD_IDENTIFIER FDW_INVALID_HANDLE FDW_INVALID_OPTION_INDEX FDW_INVALID_OPTION_NAME FDW_INVALID_STRING_LENGTH_OR_BUFFER_LENGTH FDW_INVALID_STRING_FORMAT FDW_INVALID_USE_OF_NULL_POINTER FDW_TOO_MANY_HANDLES FDW_OUT_OF_MEMORY FDW_NO_SCHEMAS FDW_OPTION_NAME_NOT_FOUND FDW_REPLY_HANDLE FDW_SCHEMA_NOT_FOUND FDW_TABLE_NOT_FOUND FDW_UNABLE_TO_CREATE_EXECUTION FDW_UNABLE_TO_CREATE_REPLY FDW_UNABLE_TO_ESTABLISH_CONNECTION PLPGSQL_ERROR RAISE_EXCEPTION NO_DATA_FOUND TOO_MANY_ROWS ASSERT_FAILURE INTERNAL_ERROR DATA_CORRUPTED INDEX_CORRUPTED "},
+illegal:/:==|\W\s*\(\*|(^|\s)\$[a-z]|{{|[a-z]:\s*$|\.\.\.|TO:|DO:/,contains:[{className:"keyword",variants:[{begin:/\bTEXT\s*SEARCH\b/},{begin:/\b(PRIMARY|FOREIGN|FOR(\s+NO)?)\s+KEY\b/},{begin:/\bPARALLEL\s+(UNSAFE|RESTRICTED|SAFE)\b/},{begin:/\bSTORAGE\s+(PLAIN|EXTERNAL|EXTENDED|MAIN)\b/},{begin:/\bMATCH\s+(FULL|PARTIAL|SIMPLE)\b/},{begin:/\bNULLS\s+(FIRST|LAST)\b/},{begin:/\bEVENT\s+TRIGGER\b/},{begin:/\b(MAPPING|OR)\s+REPLACE\b/},{begin:/\b(FROM|TO)\s+(PROGRAM|STDIN|STDOUT)\b/},{begin:/\b(SHARE|EXCLUSIVE)\s+MODE\b/},
+{begin:/\b(LEFT|RIGHT)\s+(OUTER\s+)?JOIN\b/},{begin:/\b(FETCH|MOVE)\s+(NEXT|PRIOR|FIRST|LAST|ABSOLUTE|RELATIVE|FORWARD|BACKWARD)\b/},{begin:/\bPRESERVE\s+ROWS\b/},{begin:/\bDISCARD\s+PLANS\b/},{begin:/\bREFERENCING\s+(OLD|NEW)\b/},{begin:/\bSKIP\s+LOCKED\b/},{begin:/\bGROUPING\s+SETS\b/},{begin:/\b(BINARY|INSENSITIVE|SCROLL|NO\s+SCROLL)\s+(CURSOR|FOR)\b/},{begin:/\b(WITH|WITHOUT)\s+HOLD\b/},{begin:/\bWITH\s+(CASCADED|LOCAL)\s+CHECK\s+OPTION\b/},{begin:/\bEXCLUDE\s+(TIES|NO\s+OTHERS)\b/},{begin:/\bFORMAT\s+(TEXT|XML|JSON|YAML)\b/},
+{begin:/\bSET\s+((SESSION|LOCAL)\s+)?NAMES\b/},{begin:/\bIS\s+(NOT\s+)?UNKNOWN\b/},{begin:/\bSECURITY\s+LABEL\b/},{begin:/\bSTANDALONE\s+(YES|NO|NO\s+VALUE)\b/},{begin:/\bWITH\s+(NO\s+)?DATA\b/},{begin:/\b(FOREIGN|SET)\s+DATA\b/},{begin:/\bSET\s+(CATALOG|CONSTRAINTS)\b/},{begin:/\b(WITH|FOR)\s+ORDINALITY\b/},{begin:/\bIS\s+(NOT\s+)?DOCUMENT\b/},{begin:/\bXML\s+OPTION\s+(DOCUMENT|CONTENT)\b/},{begin:/\b(STRIP|PRESERVE)\s+WHITESPACE\b/},{begin:/\bNO\s+(ACTION|MAXVALUE|MINVALUE)\b/},{begin:/\bPARTITION\s+BY\s+(RANGE|LIST|HASH)\b/},
+{begin:/\bAT\s+TIME\s+ZONE\b/},{begin:/\bGRANTED\s+BY\b/},{begin:/\bRETURN\s+(QUERY|NEXT)\b/},{begin:/\b(ATTACH|DETACH)\s+PARTITION\b/},{begin:/\bFORCE\s+ROW\s+LEVEL\s+SECURITY\b/},{begin:/\b(INCLUDING|EXCLUDING)\s+(COMMENTS|CONSTRAINTS|DEFAULTS|IDENTITY|INDEXES|STATISTICS|STORAGE|ALL)\b/},{begin:/\bAS\s+(ASSIGNMENT|IMPLICIT|PERMISSIVE|RESTRICTIVE|ENUM|RANGE)\b/}]},{begin:/\b(FORMAT|FAMILY|VERSION)\s*\(/},{begin:/\bINCLUDE\s*\(/,keywords:"INCLUDE"},{begin:/\bRANGE(?!\s*(BETWEEN|UNBOUNDED|CURRENT|[-0-9]+))/},
+{begin:/\b(VERSION|OWNER|TEMPLATE|TABLESPACE|CONNECTION\s+LIMIT|PROCEDURE|RESTRICT|JOIN|PARSER|COPY|START|END|COLLATION|INPUT|ANALYZE|STORAGE|LIKE|DEFAULT|DELIMITER|ENCODING|COLUMN|CONSTRAINT|TABLE|SCHEMA)\s*=/},{begin:/\b(PG_\w+?|HAS_[A-Z_]+_PRIVILEGE)\b/,relevance:10},{begin:/\bEXTRACT\s*\(/,end:/\bFROM\b/,returnEnd:!0,keywords:{type:"CENTURY DAY DECADE DOW DOY EPOCH HOUR ISODOW ISOYEAR MICROSECONDS MILLENNIUM MILLISECONDS MINUTE MONTH QUARTER SECOND TIMEZONE TIMEZONE_HOUR TIMEZONE_MINUTE WEEK YEAR"}},
+{begin:/\b(XMLELEMENT|XMLPI)\s*\(\s*NAME/,keywords:{keyword:"NAME"}},{begin:/\b(XMLPARSE|XMLSERIALIZE)\s*\(\s*(DOCUMENT|CONTENT)/,keywords:{keyword:"DOCUMENT CONTENT"}},{beginKeywords:"CACHE INCREMENT MAXVALUE MINVALUE",end:a.C_NUMBER_RE,returnEnd:!0,keywords:"BY CACHE INCREMENT MAXVALUE MINVALUE"},{className:"type",begin:/\b(WITH|WITHOUT)\s+TIME\s+ZONE\b/},{className:"type",begin:/\bINTERVAL\s+(YEAR|MONTH|DAY|HOUR|MINUTE|SECOND)(\s+TO\s+(MONTH|HOUR|MINUTE|SECOND))?\b/},{begin:/\bRETURNS\s+(LANGUAGE_HANDLER|TRIGGER|EVENT_TRIGGER|FDW_HANDLER|INDEX_AM_HANDLER|TSM_HANDLER)\b/,
+keywords:{keyword:"RETURNS",type:"LANGUAGE_HANDLER TRIGGER EVENT_TRIGGER FDW_HANDLER INDEX_AM_HANDLER TSM_HANDLER"}},{begin:"\\b("+e+")\\s*\\("},{begin:"\\.("+d+")\\b"},{begin:"\\b("+d+")\\s+PATH\\b",keywords:{keyword:"PATH",type:"BIGINT INT8 BIGSERIAL SERIAL8 BIT VARYING VARBIT BOOLEAN BOOL BOX BYTEA CHARACTER CHAR VARCHAR CIDR CIRCLE DATE DOUBLE PRECISION FLOAT8 FLOAT INET INTEGER INT INT4 INTERVAL JSON JSONB LINE LSEG|10 MACADDR MACADDR8 MONEY NUMERIC DEC DECIMAL PATH POINT POLYGON REAL FLOAT4 SMALLINT INT2 SMALLSERIAL|10 SERIAL2|10 SERIAL|10 SERIAL4|10 TEXT TIME ZONE TIMETZ|10 TIMESTAMP TIMESTAMPTZ|10 TSQUERY|10 TSVECTOR|10 TXID_SNAPSHOT|10 UUID XML NATIONAL NCHAR INT4RANGE|10 INT8RANGE|10 NUMRANGE|10 TSRANGE|10 TSTZRANGE|10 DATERANGE|10 ANYELEMENT ANYARRAY ANYNONARRAY ANYENUM ANYRANGE CSTRING INTERNAL RECORD PG_DDL_COMMAND VOID UNKNOWN OPAQUE REFCURSOR NAME OID REGPROC|10 REGPROCEDURE|10 REGOPER|10 REGOPERATOR|10 REGCLASS|10 REGTYPE|10 REGROLE|10 REGNAMESPACE|10 REGCONFIG|10 REGDICTIONARY|10 ".replace("PATH ",
+"")}},{className:"type",begin:"\\b("+d+")\\b"},{className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{className:"string",begin:"(e|E|u&|U&)'",end:"'",contains:[{begin:"\\\\."}],relevance:10},{begin:"\\$([a-zA-Z_]?|[a-zA-Z_][a-zA-Z_0-9]*)\\$",endSameAsBegin:!0,contains:[{subLanguage:"pgsql perl python tcl r lua java php ruby bash scheme xml json".split(" "),endsWithParent:!0}]},{begin:'"',end:'"',contains:[{begin:'""'}]},a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE,b,{className:"meta",variants:[{begin:"%(ROW)?TYPE",
+relevance:10},{begin:"\\$\\d+"},{begin:"^#\\w",end:"$"}]},{className:"symbol",begin:"<<\\s*[a-zA-Z_][a-zA-Z_0-9$]*\\s*>>",relevance:10}]}});b.registerLanguage("php",function(a){var b={begin:"\\$+[a-zA-Z_\u007f-\u00ff][a-zA-Z0-9_\u007f-\u00ff]*"},d={className:"meta",begin:/<\?(php)?|\?>/},e={className:"string",contains:[a.BACKSLASH_ESCAPE,d],variants:[{begin:'b"',end:'"'},{begin:"b'",end:"'"},a.inherit(a.APOS_STRING_MODE,{illegal:null}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null})]},f={variants:[a.BINARY_NUMBER_MODE,
+a.C_NUMBER_MODE]};return{aliases:"php php3 php4 php5 php6 php7".split(" "),case_insensitive:!0,keywords:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally",
+contains:[a.HASH_COMMENT_MODE,a.COMMENT("//","$",{contains:[d]}),a.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),a.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,keywords:"__halt_compiler",lexemes:a.UNDERSCORE_IDENT_RE}),{className:"string",begin:/<<<['"]?\w+['"]?$/,end:/^\w+;?$/,contains:[a.BACKSLASH_ESCAPE,{className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,end:/\}/}]}]},d,{className:"keyword",begin:/\$this\b/},b,{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},
+{className:"function",beginKeywords:"function",end:/[;{]/,excludeEnd:!0,illegal:"\\$|\\[|%",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",contains:["self",b,a.C_BLOCK_COMMENT_MODE,e,f]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",end:";",contains:[a.UNDERSCORE_TITLE_MODE]},
+{begin:"=>"},e,f]}});b.registerLanguage("plaintext",function(a){return{disableAutodetect:!0}});b.registerLanguage("pony",function(a){return{keywords:{keyword:"actor addressof and as be break class compile_error compile_intrinsic consume continue delegate digestof do else elseif embed end error for fun if ifdef in interface is isnt lambda let match new not object or primitive recover repeat return struct then trait try type until use var where while with xor",meta:"iso val tag trn box ref",literal:"this false true"},
+contains:[{className:"type",begin:"\\b_?[A-Z][\\w]*",relevance:0},{className:"string",begin:'"""',end:'"""',relevance:10},{className:"string",begin:'"',end:'"',contains:[a.BACKSLASH_ESCAPE]},{className:"string",begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE],relevance:0},{begin:a.IDENT_RE+"'",relevance:0},a.C_NUMBER_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}});b.registerLanguage("powershell",function(a){var b={begin:"`[\\s\\S]",relevance:0},d={className:"variable",variants:[{begin:/\$[\w\d][\w\d_:]*/}]},
+e={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],contains:[b,d,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},f=a.inherit(a.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,end:/#>/}],contains:[{className:"doctag",variants:[{begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/},{begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/}]}]});return{aliases:["ps"],lexemes:/-?[A-z\.\-]+/,
+case_insensitive:!0,keywords:{keyword:"if else foreach return function do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catchValidateNoCircleInNodeResources ValidateNodeExclusiveResources ValidateNodeManager ValidateNodeResources ValidateNodeResourceSource ValidateNoNameNodeResources ThrowError IsHiddenResourceIsPatternMatched ",built_in:"Add-Computer Add-Content Add-History Add-JobTrigger Add-Member Add-PSSnapin Add-Type Checkpoint-Computer Clear-Content Clear-EventLog Clear-History Clear-Host Clear-Item Clear-ItemProperty Clear-Variable Compare-Object Complete-Transaction Connect-PSSession Connect-WSMan Convert-Path ConvertFrom-Csv ConvertFrom-Json ConvertFrom-SecureString ConvertFrom-StringData ConvertTo-Csv ConvertTo-Html ConvertTo-Json ConvertTo-SecureString ConvertTo-Xml Copy-Item Copy-ItemProperty Debug-Process Disable-ComputerRestore Disable-JobTrigger Disable-PSBreakpoint Disable-PSRemoting Disable-PSSessionConfiguration Disable-WSManCredSSP Disconnect-PSSession Disconnect-WSMan Disable-ScheduledJob Enable-ComputerRestore Enable-JobTrigger Enable-PSBreakpoint Enable-PSRemoting Enable-PSSessionConfiguration Enable-ScheduledJob Enable-WSManCredSSP Enter-PSSession Exit-PSSession Export-Alias Export-Clixml Export-Console Export-Counter Export-Csv Export-FormatData Export-ModuleMember Export-PSSession ForEach-Object Format-Custom Format-List Format-Table Format-Wide Get-Acl Get-Alias Get-AuthenticodeSignature Get-ChildItem Get-Command Get-ComputerRestorePoint Get-Content Get-ControlPanelItem Get-Counter Get-Credential Get-Culture Get-Date Get-Event Get-EventLog Get-EventSubscriber Get-ExecutionPolicy Get-FormatData Get-Host Get-HotFix Get-Help Get-History Get-IseSnippet Get-Item Get-ItemProperty Get-Job Get-JobTrigger Get-Location Get-Member Get-Module Get-PfxCertificate Get-Process Get-PSBreakpoint Get-PSCallStack Get-PSDrive Get-PSProvider Get-PSSession Get-PSSessionConfiguration Get-PSSnapin Get-Random Get-ScheduledJob Get-ScheduledJobOption Get-Service Get-TraceSource Get-Transaction Get-TypeData Get-UICulture Get-Unique Get-Variable Get-Verb Get-WinEvent Get-WmiObject Get-WSManCredSSP Get-WSManInstance Group-Object Import-Alias Import-Clixml Import-Counter Import-Csv Import-IseSnippet Import-LocalizedData Import-PSSession Import-Module Invoke-AsWorkflow Invoke-Command Invoke-Expression Invoke-History Invoke-Item Invoke-RestMethod Invoke-WebRequest Invoke-WmiMethod Invoke-WSManAction Join-Path Limit-EventLog Measure-Command Measure-Object Move-Item Move-ItemProperty New-Alias New-Event New-EventLog New-IseSnippet New-Item New-ItemProperty New-JobTrigger New-Object New-Module New-ModuleManifest New-PSDrive New-PSSession New-PSSessionConfigurationFile New-PSSessionOption New-PSTransportOption New-PSWorkflowExecutionOption New-PSWorkflowSession New-ScheduledJobOption New-Service New-TimeSpan New-Variable New-WebServiceProxy New-WinEvent New-WSManInstance New-WSManSessionOption Out-Default Out-File Out-GridView Out-Host Out-Null Out-Printer Out-String Pop-Location Push-Location Read-Host Receive-Job Register-EngineEvent Register-ObjectEvent Register-PSSessionConfiguration Register-ScheduledJob Register-WmiEvent Remove-Computer Remove-Event Remove-EventLog Remove-Item Remove-ItemProperty Remove-Job Remove-JobTrigger Remove-Module Remove-PSBreakpoint Remove-PSDrive Remove-PSSession Remove-PSSnapin Remove-TypeData Remove-Variable Remove-WmiObject Remove-WSManInstance Rename-Computer Rename-Item Rename-ItemProperty Reset-ComputerMachinePassword Resolve-Path Restart-Computer Restart-Service Restore-Computer Resume-Job Resume-Service Save-Help Select-Object Select-String Select-Xml Send-MailMessage Set-Acl Set-Alias Set-AuthenticodeSignature Set-Content Set-Date Set-ExecutionPolicy Set-Item Set-ItemProperty Set-JobTrigger Set-Location Set-PSBreakpoint Set-PSDebug Set-PSSessionConfiguration Set-ScheduledJob Set-ScheduledJobOption Set-Service Set-StrictMode Set-TraceSource Set-Variable Set-WmiInstance Set-WSManInstance Set-WSManQuickConfig Show-Command Show-ControlPanelItem Show-EventLog Sort-Object Split-Path Start-Job Start-Process Start-Service Start-Sleep Start-Transaction Start-Transcript Stop-Computer Stop-Job Stop-Process Stop-Service Stop-Transcript Suspend-Job Suspend-Service Tee-Object Test-ComputerSecureChannel Test-Connection Test-ModuleManifest Test-Path Test-PSSessionConfigurationFile Trace-Command Unblock-File Undo-Transaction Unregister-Event Unregister-PSSessionConfiguration Unregister-ScheduledJob Update-FormatData Update-Help Update-List Update-TypeData Use-Transaction Wait-Event Wait-Job Wait-Process Where-Object Write-Debug Write-Error Write-EventLog Write-Host Write-Output Write-Progress Write-Verbose Write-Warning Add-MDTPersistentDrive Disable-MDTMonitorService Enable-MDTMonitorService Get-MDTDeploymentShareStatistics Get-MDTMonitorData Get-MDTOperatingSystemCatalog Get-MDTPersistentDrive Import-MDTApplication Import-MDTDriver Import-MDTOperatingSystem Import-MDTPackage Import-MDTTaskSequence New-MDTDatabase Remove-MDTMonitorData Remove-MDTPersistentDrive Restore-MDTPersistentDrive Set-MDTMonitorData Test-MDTDeploymentShare Test-MDTMonitorData Update-MDTDatabaseSchema Update-MDTDeploymentShare Update-MDTLinkedDS Update-MDTMedia Add-VamtProductKey Export-VamtData Find-VamtManagedMachine Get-VamtConfirmationId Get-VamtProduct Get-VamtProductKey Import-VamtData Initialize-VamtData Install-VamtConfirmationId Install-VamtProductActivation Install-VamtProductKey Update-VamtProduct Add-CIDatastore Add-KeyManagementServer Add-NodeKeys Add-NsxDynamicCriteria Add-NsxDynamicMemberSet Add-NsxEdgeInterfaceAddress Add-NsxFirewallExclusionListMember Add-NsxFirewallRuleMember Add-NsxIpSetMember Add-NsxLicense Add-NsxLoadBalancerPoolMember Add-NsxLoadBalancerVip Add-NsxSecondaryManager Add-NsxSecurityGroupMember Add-NsxSecurityPolicyRule Add-NsxSecurityPolicyRuleGroup Add-NsxSecurityPolicyRuleService Add-NsxServiceGroupMember Add-NsxTransportZoneMember Add-PassthroughDevice Add-VDSwitchPhysicalNetworkAdapter Add-VDSwitchVMHost Add-VMHost Add-VMHostNtpServer Add-VirtualSwitchPhysicalNetworkAdapter Add-XmlElement Add-vRACustomForm Add-vRAPrincipalToTenantRole Add-vRAReservationNetwork Add-vRAReservationStorage Clear-NsxEdgeInterface Clear-NsxManagerTimeSettings Compress-Archive Connect-CIServer Connect-CisServer Connect-HCXServer Connect-NIServer Connect-NsxLogicalSwitch Connect-NsxServer Connect-NsxtServer Connect-SrmServer Connect-VIServer Connect-Vmc Connect-vRAServer Connect-vRNIServer ConvertFrom-Markdown ConvertTo-MOFInstance Copy-DatastoreItem Copy-HardDisk Copy-NsxEdge Copy-VDisk Copy-VMGuestFile Debug-Runspace Disable-NsxEdgeSsh Disable-RunspaceDebug Disable-vRNIDataSource Disconnect-CIServer Disconnect-CisServer Disconnect-HCXServer Disconnect-NsxLogicalSwitch Disconnect-NsxServer Disconnect-NsxtServer Disconnect-SrmServer Disconnect-VIServer Disconnect-Vmc Disconnect-vRAServer Disconnect-vRNIServer Dismount-Tools Enable-NsxEdgeSsh Enable-RunspaceDebug Enable-vRNIDataSource Expand-Archive Export-NsxObject Export-SpbmStoragePolicy Export-VApp Export-VDPortGroup Export-VDSwitch Export-VMHostProfile Export-vRAIcon Export-vRAPackage Find-Command Find-DscResource Find-Module Find-NsxWhereVMUsed Find-Package Find-PackageProvider Find-RoleCapability Find-Script Format-Hex Format-VMHostDiskPartition Format-XML Generate-VersionInfo Get-AdvancedSetting Get-AlarmAction Get-AlarmActionTrigger Get-AlarmDefinition Get-Annotation Get-CDDrive Get-CIAccessControlRule Get-CIDatastore Get-CINetworkAdapter Get-CIRole Get-CIUser Get-CIVApp Get-CIVAppNetwork Get-CIVAppStartRule Get-CIVAppTemplate Get-CIVM Get-CIVMTemplate Get-CIView Get-Catalog Get-CisCommand Get-CisService Get-CloudCommand Get-Cluster Get-CompatibleVersionAddtionaPropertiesStr Get-ComplexResourceQualifier Get-ConfigurationErrorCount Get-ContentLibraryItem Get-CustomAttribute Get-DSCResourceModules Get-Datacenter Get-Datastore Get-DatastoreCluster Get-DrsClusterGroup Get-DrsRecommendation Get-DrsRule Get-DrsVMHostRule Get-DscResource Get-EdgeGateway Get-EncryptedPassword Get-ErrorReport Get-EsxCli Get-EsxTop Get-ExternalNetwork Get-FileHash Get-FloppyDrive Get-Folder Get-HAPrimaryVMHost Get-HCXAppliance Get-HCXApplianceCompute Get-HCXApplianceDVS Get-HCXApplianceDatastore Get-HCXApplianceNetwork Get-HCXContainer Get-HCXDatastore Get-HCXGateway Get-HCXInterconnectStatus Get-HCXJob Get-HCXMigration Get-HCXNetwork Get-HCXNetworkExtension Get-HCXReplication Get-HCXReplicationSnapshot Get-HCXService Get-HCXSite Get-HCXSitePairing Get-HCXVM Get-HardDisk Get-IScsiHbaTarget Get-InnerMostErrorRecord Get-InstallPath Get-InstalledModule Get-InstalledScript Get-Inventory Get-ItemPropertyValue Get-KeyManagementServer Get-KmipClientCertificate Get-KmsCluster Get-Log Get-LogType Get-MarkdownOption Get-Media Get-MofInstanceName Get-MofInstanceText Get-NetworkAdapter Get-NetworkPool Get-NfsUser Get-NicTeamingPolicy Get-NsxApplicableMember Get-NsxApplicableSecurityAction Get-NsxBackingDVSwitch Get-NsxBackingPortGroup Get-NsxCliDfwAddrSet Get-NsxCliDfwFilter Get-NsxCliDfwRule Get-NsxClusterStatus Get-NsxController Get-NsxDynamicCriteria Get-NsxDynamicMemberSet Get-NsxEdge Get-NsxEdgeBgp Get-NsxEdgeBgpNeighbour Get-NsxEdgeCertificate Get-NsxEdgeCsr Get-NsxEdgeFirewall Get-NsxEdgeFirewallRule Get-NsxEdgeInterface Get-NsxEdgeInterfaceAddress Get-NsxEdgeNat Get-NsxEdgeNatRule Get-NsxEdgeOspf Get-NsxEdgeOspfArea Get-NsxEdgeOspfInterface Get-NsxEdgePrefix Get-NsxEdgeRedistributionRule Get-NsxEdgeRouting Get-NsxEdgeStaticRoute Get-NsxEdgeSubInterface Get-NsxFirewallExclusionListMember Get-NsxFirewallGlobalConfiguration Get-NsxFirewallPublishStatus Get-NsxFirewallRule Get-NsxFirewallRuleMember Get-NsxFirewallSavedConfiguration Get-NsxFirewallSection Get-NsxFirewallThreshold Get-NsxIpPool Get-NsxIpSet Get-NsxLicense Get-NsxLoadBalancer Get-NsxLoadBalancerApplicationProfile Get-NsxLoadBalancerApplicationRule Get-NsxLoadBalancerMonitor Get-NsxLoadBalancerPool Get-NsxLoadBalancerPoolMember Get-NsxLoadBalancerStats Get-NsxLoadBalancerVip Get-NsxLogicalRouter Get-NsxLogicalRouterBgp Get-NsxLogicalRouterBgpNeighbour Get-NsxLogicalRouterBridge Get-NsxLogicalRouterBridging Get-NsxLogicalRouterInterface Get-NsxLogicalRouterOspf Get-NsxLogicalRouterOspfArea Get-NsxLogicalRouterOspfInterface Get-NsxLogicalRouterPrefix Get-NsxLogicalRouterRedistributionRule Get-NsxLogicalRouterRouting Get-NsxLogicalRouterStaticRoute Get-NsxLogicalSwitch Get-NsxMacSet Get-NsxManagerBackup Get-NsxManagerCertificate Get-NsxManagerComponentSummary Get-NsxManagerNetwork Get-NsxManagerRole Get-NsxManagerSsoConfig Get-NsxManagerSyncStatus Get-NsxManagerSyslogServer Get-NsxManagerSystemSummary Get-NsxManagerTimeSettings Get-NsxManagerVcenterConfig Get-NsxSecondaryManager Get-NsxSecurityGroup Get-NsxSecurityGroupEffectiveIpAddress Get-NsxSecurityGroupEffectiveMacAddress Get-NsxSecurityGroupEffectiveMember Get-NsxSecurityGroupEffectiveVirtualMachine Get-NsxSecurityGroupEffectiveVnic Get-NsxSecurityGroupMemberTypes Get-NsxSecurityPolicy Get-NsxSecurityPolicyHighestUsedPrecedence Get-NsxSecurityPolicyRule Get-NsxSecurityTag Get-NsxSecurityTagAssignment Get-NsxSegmentIdRange Get-NsxService Get-NsxServiceDefinition Get-NsxServiceGroup Get-NsxServiceGroupMember Get-NsxServiceProfile Get-NsxSpoofguardNic Get-NsxSpoofguardPolicy Get-NsxSslVpn Get-NsxSslVpnAuthServer Get-NsxSslVpnClientInstallationPackage Get-NsxSslVpnIpPool Get-NsxSslVpnPrivateNetwork Get-NsxSslVpnUser Get-NsxTransportZone Get-NsxUserRole Get-NsxVdsContext Get-NsxtPolicyService Get-NsxtService Get-OSCustomizationNicMapping Get-OSCustomizationSpec Get-Org Get-OrgNetwork Get-OrgVdc Get-OrgVdcNetwork Get-OvfConfiguration Get-PSCurrentConfigurationNode Get-PSDefaultConfigurationDocument Get-PSMetaConfigDocumentInstVersionInfo Get-PSMetaConfigurationProcessed Get-PSReadLineKeyHandler Get-PSReadLineOption Get-PSRepository Get-PSTopConfigurationName Get-PSVersion Get-Package Get-PackageProvider Get-PackageSource Get-PassthroughDevice Get-PositionInfo Get-PowerCLICommunity Get-PowerCLIConfiguration Get-PowerCLIHelp Get-PowerCLIVersion Get-PowerNsxVersion Get-ProviderVdc Get-PublicKeyFromFile Get-PublicKeyFromStore Get-ResourcePool Get-Runspace Get-RunspaceDebug Get-ScsiController Get-ScsiLun Get-ScsiLunPath Get-SecurityInfo Get-SecurityPolicy Get-Snapshot Get-SpbmCapability Get-SpbmCompatibleStorage Get-SpbmEntityConfiguration Get-SpbmFaultDomain Get-SpbmPointInTimeReplica Get-SpbmReplicationGroup Get-SpbmReplicationPair Get-SpbmStoragePolicy Get-Stat Get-StatInterval Get-StatType Get-Tag Get-TagAssignment Get-TagCategory Get-Task Get-Template Get-TimeZone Get-Uptime Get-UsbDevice Get-VAIOFilter Get-VApp Get-VDBlockedPolicy Get-VDPort Get-VDPortgroup Get-VDPortgroupOverridePolicy Get-VDSecurityPolicy Get-VDSwitch Get-VDSwitchPrivateVlan Get-VDTrafficShapingPolicy Get-VDUplinkLacpPolicy Get-VDUplinkTeamingPolicy Get-VDisk Get-VIAccount Get-VICommand Get-VICredentialStoreItem Get-VIEvent Get-VIObjectByVIView Get-VIPermission Get-VIPrivilege Get-VIProperty Get-VIRole Get-VM Get-VMGuest Get-VMHost Get-VMHostAccount Get-VMHostAdvancedConfiguration Get-VMHostAuthentication Get-VMHostAvailableTimeZone Get-VMHostDiagnosticPartition Get-VMHostDisk Get-VMHostDiskPartition Get-VMHostFirewallDefaultPolicy Get-VMHostFirewallException Get-VMHostFirmware Get-VMHostHardware Get-VMHostHba Get-VMHostModule Get-VMHostNetwork Get-VMHostNetworkAdapter Get-VMHostNtpServer Get-VMHostPatch Get-VMHostPciDevice Get-VMHostProfile Get-VMHostProfileImageCacheConfiguration Get-VMHostProfileRequiredInput Get-VMHostProfileStorageDeviceConfiguration Get-VMHostProfileUserConfiguration Get-VMHostProfileVmPortGroupConfiguration Get-VMHostRoute Get-VMHostService Get-VMHostSnmp Get-VMHostStartPolicy Get-VMHostStorage Get-VMHostSysLogServer Get-VMQuestion Get-VMResourceConfiguration Get-VMStartPolicy Get-VTpm Get-VTpmCSR Get-VTpmCertificate Get-VasaProvider Get-VasaStorageArray Get-View Get-VirtualPortGroup Get-VirtualSwitch Get-VmcSddcNetworkService Get-VmcService Get-VsanClusterConfiguration Get-VsanComponent Get-VsanDisk Get-VsanDiskGroup Get-VsanEvacuationPlan Get-VsanFaultDomain Get-VsanIscsiInitiatorGroup Get-VsanIscsiInitiatorGroupTargetAssociation Get-VsanIscsiLun Get-VsanIscsiTarget Get-VsanObject Get-VsanResyncingComponent Get-VsanRuntimeInfo Get-VsanSpaceUsage Get-VsanStat Get-VsanView Get-vRAApplianceServiceStatus Get-vRAAuthorizationRole Get-vRABlueprint Get-vRABusinessGroup Get-vRACatalogItem Get-vRACatalogItemRequestTemplate Get-vRACatalogPrincipal Get-vRAComponentRegistryService Get-vRAComponentRegistryServiceEndpoint Get-vRAComponentRegistryServiceStatus Get-vRAContent Get-vRAContentData Get-vRAContentType Get-vRACustomForm Get-vRAEntitledCatalogItem Get-vRAEntitledService Get-vRAEntitlement Get-vRAExternalNetworkProfile Get-vRAGroupPrincipal Get-vRAIcon Get-vRANATNetworkProfile Get-vRANetworkProfileIPAddressList Get-vRANetworkProfileIPRangeSummary Get-vRAPackage Get-vRAPackageContent Get-vRAPropertyDefinition Get-vRAPropertyGroup Get-vRARequest Get-vRARequestDetail Get-vRAReservation Get-vRAReservationComputeResource Get-vRAReservationComputeResourceMemory Get-vRAReservationComputeResourceNetwork Get-vRAReservationComputeResourceResourcePool Get-vRAReservationComputeResourceStorage Get-vRAReservationPolicy Get-vRAReservationTemplate Get-vRAReservationType Get-vRAResource Get-vRAResourceAction Get-vRAResourceActionRequestTemplate Get-vRAResourceMetric Get-vRAResourceOperation Get-vRAResourceType Get-vRARoutedNetworkProfile Get-vRAService Get-vRAServiceBlueprint Get-vRASourceMachine Get-vRAStorageReservationPolicy Get-vRATenant Get-vRATenantDirectory Get-vRATenantDirectoryStatus Get-vRATenantRole Get-vRAUserPrincipal Get-vRAUserPrincipalGroupMembership Get-vRAVersion Get-vRNIAPIVersion Get-vRNIApplication Get-vRNIApplicationTier Get-vRNIDataSource Get-vRNIDataSourceSNMPConfig Get-vRNIDatastore Get-vRNIDistributedSwitch Get-vRNIDistributedSwitchPortGroup Get-vRNIEntity Get-vRNIEntityName Get-vRNIFirewallRule Get-vRNIFlow Get-vRNIHost Get-vRNIHostVMKNic Get-vRNIIPSet Get-vRNIL2Network Get-vRNINSXManager Get-vRNINodes Get-vRNIProblem Get-vRNIRecommendedRules Get-vRNIRecommendedRulesNsxBundle Get-vRNISecurityGroup Get-vRNISecurityTag Get-vRNIService Get-vRNIServiceGroup Get-vRNIVM Get-vRNIVMvNIC Get-vRNIvCenter Get-vRNIvCenterCluster Get-vRNIvCenterDatacenter Get-vRNIvCenterFolder Grant-NsxSpoofguardNicApproval Import-CIVApp Import-CIVAppTemplate Import-NsxObject Import-PackageProvider Import-PowerShellDataFile Import-SpbmStoragePolicy Import-VApp Import-VMHostProfile Import-vRAContentData Import-vRAIcon Import-vRAPackage Initialize-ConfigurationRuntimeState Install-Module Install-NsxCluster Install-Package Install-PackageProvider Install-Script Install-VMHostPatch Invoke-DrsRecommendation Invoke-NsxCli Invoke-NsxClusterResolveAll Invoke-NsxManagerSync Invoke-NsxRestMethod Invoke-NsxWebRequest Invoke-VMHostProfile Invoke-VMScript Invoke-XpathQuery Invoke-vRADataCollection Invoke-vRARestMethod Invoke-vRATenantDirectorySync Invoke-vRNIRestMethod Join-String Mount-Tools Move-Cluster Move-Datacenter Move-Datastore Move-Folder Move-HardDisk Move-Inventory Move-NsxSecurityPolicyRule Move-ResourcePool Move-Template Move-VApp Move-VDisk Move-VM Move-VMHost New-AdvancedSetting New-AlarmAction New-AlarmActionTrigger New-CDDrive New-CIAccessControlRule New-CIVApp New-CIVAppNetwork New-CIVAppTemplate New-CIVM New-Cluster New-CustomAttribute New-Datacenter New-Datastore New-DatastoreCluster New-DatastoreDrive New-DrsClusterGroup New-DrsRule New-DrsVMHostRule New-DscChecksum New-FloppyDrive New-Folder New-Guid New-HCXAppliance New-HCXMigration New-HCXNetworkExtension New-HCXNetworkMapping New-HCXReplication New-HCXSitePairing New-HCXStaticRoute New-HardDisk New-IScsiHbaTarget New-KmipClientCertificate New-NetworkAdapter New-NfsUser New-NsxAddressSpec New-NsxClusterVxlanConfig New-NsxController New-NsxDynamicCriteriaSpec New-NsxEdge New-NsxEdgeBgpNeighbour New-NsxEdgeCsr New-NsxEdgeFirewallRule New-NsxEdgeInterfaceSpec New-NsxEdgeNatRule New-NsxEdgeOspfArea New-NsxEdgeOspfInterface New-NsxEdgePrefix New-NsxEdgeRedistributionRule New-NsxEdgeSelfSignedCertificate New-NsxEdgeStaticRoute New-NsxEdgeSubInterface New-NsxEdgeSubInterfaceSpec New-NsxFirewallRule New-NsxFirewallSavedConfiguration New-NsxFirewallSection New-NsxIpPool New-NsxIpSet New-NsxLoadBalancerApplicationProfile New-NsxLoadBalancerApplicationRule New-NsxLoadBalancerMemberSpec New-NsxLoadBalancerMonitor New-NsxLoadBalancerPool New-NsxLogicalRouter New-NsxLogicalRouterBgpNeighbour New-NsxLogicalRouterBridge New-NsxLogicalRouterInterface New-NsxLogicalRouterInterfaceSpec New-NsxLogicalRouterOspfArea New-NsxLogicalRouterOspfInterface New-NsxLogicalRouterPrefix New-NsxLogicalRouterRedistributionRule New-NsxLogicalRouterStaticRoute New-NsxLogicalSwitch New-NsxMacSet New-NsxManager New-NsxSecurityGroup New-NsxSecurityPolicy New-NsxSecurityPolicyAssignment New-NsxSecurityPolicyFirewallRuleSpec New-NsxSecurityPolicyGuestIntrospectionSpec New-NsxSecurityPolicyNetworkIntrospectionSpec New-NsxSecurityTag New-NsxSecurityTagAssignment New-NsxSegmentIdRange New-NsxService New-NsxServiceGroup New-NsxSpoofguardPolicy New-NsxSslVpnAuthServer New-NsxSslVpnClientInstallationPackage New-NsxSslVpnIpPool New-NsxSslVpnPrivateNetwork New-NsxSslVpnUser New-NsxTransportZone New-NsxVdsContext New-OSCustomizationNicMapping New-OSCustomizationSpec New-Org New-OrgNetwork New-OrgVdc New-OrgVdcNetwork New-ResourcePool New-ScriptFileInfo New-ScsiController New-Snapshot New-SpbmRule New-SpbmRuleSet New-SpbmStoragePolicy New-StatInterval New-Tag New-TagAssignment New-TagCategory New-Template New-TemporaryFile New-VAIOFilter New-VApp New-VDPortgroup New-VDSwitch New-VDSwitchPrivateVlan New-VDisk New-VICredentialStoreItem New-VIInventoryDrive New-VIPermission New-VIProperty New-VIRole New-VISamlSecurityContext New-VM New-VMHostAccount New-VMHostNetworkAdapter New-VMHostProfile New-VMHostProfileVmPortGroupConfiguration New-VMHostRoute New-VTpm New-VasaProvider New-VcsOAuthSecurityContext New-VirtualPortGroup New-VirtualSwitch New-VsanDisk New-VsanDiskGroup New-VsanFaultDomain New-VsanIscsiInitiatorGroup New-VsanIscsiInitiatorGroupTargetAssociation New-VsanIscsiLun New-VsanIscsiTarget New-vRABusinessGroup New-vRAEntitlement New-vRAExternalNetworkProfile New-vRAGroupPrincipal New-vRANATNetworkProfile New-vRANetworkProfileIPRangeDefinition New-vRAPackage New-vRAPropertyDefinition New-vRAPropertyGroup New-vRAReservation New-vRAReservationNetworkDefinition New-vRAReservationPolicy New-vRAReservationStorageDefinition New-vRARoutedNetworkProfile New-vRAService New-vRAStorageReservationPolicy New-vRATenant New-vRATenantDirectory New-vRAUserPrincipal New-vRNIApplication New-vRNIApplicationTier New-vRNIDataSource Open-VMConsoleWindow Publish-Module Publish-NsxSpoofguardPolicy Publish-Script Register-PSRepository Register-PackageSource Remove-AdvancedSetting Remove-AlarmAction Remove-AlarmActionTrigger Remove-Alias Remove-CDDrive Remove-CIAccessControlRule Remove-CIVApp Remove-CIVAppNetwork Remove-CIVAppTemplate Remove-Cluster Remove-CustomAttribute Remove-Datacenter Remove-Datastore Remove-DatastoreCluster Remove-DrsClusterGroup Remove-DrsRule Remove-DrsVMHostRule Remove-FloppyDrive Remove-Folder Remove-HCXAppliance Remove-HCXNetworkExtension Remove-HCXReplication Remove-HCXSitePairing Remove-HardDisk Remove-IScsiHbaTarget Remove-Inventory Remove-KeyManagementServer Remove-NetworkAdapter Remove-NfsUser Remove-NsxCluster Remove-NsxClusterVxlanConfig Remove-NsxController Remove-NsxDynamicCriteria Remove-NsxDynamicMemberSet Remove-NsxEdge Remove-NsxEdgeBgpNeighbour Remove-NsxEdgeCertificate Remove-NsxEdgeCsr Remove-NsxEdgeFirewallRule Remove-NsxEdgeInterfaceAddress Remove-NsxEdgeNatRule Remove-NsxEdgeOspfArea Remove-NsxEdgeOspfInterface Remove-NsxEdgePrefix Remove-NsxEdgeRedistributionRule Remove-NsxEdgeStaticRoute Remove-NsxEdgeSubInterface Remove-NsxFirewallExclusionListMember Remove-NsxFirewallRule Remove-NsxFirewallRuleMember Remove-NsxFirewallSavedConfiguration Remove-NsxFirewallSection Remove-NsxIpPool Remove-NsxIpSet Remove-NsxIpSetMember Remove-NsxLoadBalancerApplicationProfile Remove-NsxLoadBalancerMonitor Remove-NsxLoadBalancerPool Remove-NsxLoadBalancerPoolMember Remove-NsxLoadBalancerVip Remove-NsxLogicalRouter Remove-NsxLogicalRouterBgpNeighbour Remove-NsxLogicalRouterBridge Remove-NsxLogicalRouterInterface Remove-NsxLogicalRouterOspfArea Remove-NsxLogicalRouterOspfInterface Remove-NsxLogicalRouterPrefix Remove-NsxLogicalRouterRedistributionRule Remove-NsxLogicalRouterStaticRoute Remove-NsxLogicalSwitch Remove-NsxMacSet Remove-NsxSecondaryManager Remove-NsxSecurityGroup Remove-NsxSecurityGroupMember Remove-NsxSecurityPolicy Remove-NsxSecurityPolicyAssignment Remove-NsxSecurityPolicyRule Remove-NsxSecurityPolicyRuleGroup Remove-NsxSecurityPolicyRuleService Remove-NsxSecurityTag Remove-NsxSecurityTagAssignment Remove-NsxSegmentIdRange Remove-NsxService Remove-NsxServiceGroup Remove-NsxSpoofguardPolicy Remove-NsxSslVpnClientInstallationPackage Remove-NsxSslVpnIpPool Remove-NsxSslVpnPrivateNetwork Remove-NsxSslVpnUser Remove-NsxTransportZone Remove-NsxTransportZoneMember Remove-NsxVdsContext Remove-OSCustomizationNicMapping Remove-OSCustomizationSpec Remove-Org Remove-OrgNetwork Remove-OrgVdc Remove-OrgVdcNetwork Remove-PSReadLineKeyHandler Remove-PassthroughDevice Remove-ResourcePool Remove-Snapshot Remove-SpbmStoragePolicy Remove-StatInterval Remove-Tag Remove-TagAssignment Remove-TagCategory Remove-Template Remove-UsbDevice Remove-VAIOFilter Remove-VApp Remove-VDPortGroup Remove-VDSwitch Remove-VDSwitchPhysicalNetworkAdapter Remove-VDSwitchPrivateVlan Remove-VDSwitchVMHost Remove-VDisk Remove-VICredentialStoreItem Remove-VIPermission Remove-VIProperty Remove-VIRole Remove-VM Remove-VMHost Remove-VMHostAccount Remove-VMHostNetworkAdapter Remove-VMHostNtpServer Remove-VMHostProfile Remove-VMHostProfileVmPortGroupConfiguration Remove-VMHostRoute Remove-VTpm Remove-VasaProvider Remove-VirtualPortGroup Remove-VirtualSwitch Remove-VirtualSwitchPhysicalNetworkAdapter Remove-VsanDisk Remove-VsanDiskGroup Remove-VsanFaultDomain Remove-VsanIscsiInitiatorGroup Remove-VsanIscsiInitiatorGroupTargetAssociation Remove-VsanIscsiLun Remove-VsanIscsiTarget Remove-vRABusinessGroup Remove-vRACustomForm Remove-vRAExternalNetworkProfile Remove-vRAGroupPrincipal Remove-vRAIcon Remove-vRANATNetworkProfile Remove-vRAPackage Remove-vRAPrincipalFromTenantRole Remove-vRAPropertyDefinition Remove-vRAPropertyGroup Remove-vRAReservation Remove-vRAReservationNetwork Remove-vRAReservationPolicy Remove-vRAReservationStorage Remove-vRARoutedNetworkProfile Remove-vRAService Remove-vRAStorageReservationPolicy Remove-vRATenant Remove-vRATenantDirectory Remove-vRAUserPrincipal Remove-vRNIApplication Remove-vRNIApplicationTier Remove-vRNIDataSource Repair-NsxEdge Repair-VsanObject Request-vRACatalogItem Request-vRAResourceAction Restart-CIVApp Restart-CIVAppGuest Restart-CIVM Restart-CIVMGuest Restart-VM Restart-VMGuest Restart-VMHost Restart-VMHostService Resume-HCXReplication Revoke-NsxSpoofguardNicApproval Save-Module Save-Package Save-Script Search-Cloud Set-AdvancedSetting Set-AlarmDefinition Set-Annotation Set-CDDrive Set-CIAccessControlRule Set-CINetworkAdapter Set-CIVApp Set-CIVAppNetwork Set-CIVAppStartRule Set-CIVAppTemplate Set-Cluster Set-CustomAttribute Set-Datacenter Set-Datastore Set-DatastoreCluster Set-DrsClusterGroup Set-DrsRule Set-DrsVMHostRule Set-FloppyDrive Set-Folder Set-HCXAppliance Set-HCXMigration Set-HCXReplication Set-HardDisk Set-IScsiHbaTarget Set-KeyManagementServer Set-KmsCluster Set-MarkdownOption Set-NetworkAdapter Set-NfsUser Set-NicTeamingPolicy Set-NodeExclusiveResources Set-NodeManager Set-NodeResourceSource Set-NodeResources Set-NsxEdge Set-NsxEdgeBgp Set-NsxEdgeFirewall Set-NsxEdgeInterface Set-NsxEdgeNat Set-NsxEdgeOspf Set-NsxEdgeRouting Set-NsxFirewallGlobalConfiguration Set-NsxFirewallRule Set-NsxFirewallSavedConfiguration Set-NsxFirewallThreshold Set-NsxLoadBalancer Set-NsxLoadBalancerPoolMember Set-NsxLogicalRouter Set-NsxLogicalRouterBgp Set-NsxLogicalRouterBridging Set-NsxLogicalRouterInterface Set-NsxLogicalRouterOspf Set-NsxLogicalRouterRouting Set-NsxManager Set-NsxManagerRole Set-NsxManagerTimeSettings Set-NsxSecurityPolicy Set-NsxSecurityPolicyFirewallRule Set-NsxSslVpn Set-OSCustomizationNicMapping Set-OSCustomizationSpec Set-Org Set-OrgNetwork Set-OrgVdc Set-OrgVdcNetwork Set-PSCurrentConfigurationNode Set-PSDefaultConfigurationDocument Set-PSMetaConfigDocInsProcessedBeforeMeta Set-PSMetaConfigVersionInfoV2 Set-PSReadLineKeyHandler Set-PSReadLineOption Set-PSRepository Set-PSTopConfigurationName Set-PackageSource Set-PowerCLIConfiguration Set-ResourcePool Set-ScsiController Set-ScsiLun Set-ScsiLunPath Set-SecurityPolicy Set-Snapshot Set-SpbmEntityConfiguration Set-SpbmStoragePolicy Set-StatInterval Set-Tag Set-TagCategory Set-Template Set-VAIOFilter Set-VApp Set-VDBlockedPolicy Set-VDPort Set-VDPortgroup Set-VDPortgroupOverridePolicy Set-VDSecurityPolicy Set-VDSwitch Set-VDTrafficShapingPolicy Set-VDUplinkLacpPolicy Set-VDUplinkTeamingPolicy Set-VDVlanConfiguration Set-VDisk Set-VIPermission Set-VIRole Set-VM Set-VMHost Set-VMHostAccount Set-VMHostAdvancedConfiguration Set-VMHostAuthentication Set-VMHostDiagnosticPartition Set-VMHostFirewallDefaultPolicy Set-VMHostFirewallException Set-VMHostFirmware Set-VMHostHba Set-VMHostModule Set-VMHostNetwork Set-VMHostNetworkAdapter Set-VMHostProfile Set-VMHostProfileImageCacheConfiguration Set-VMHostProfileStorageDeviceConfiguration Set-VMHostProfileUserConfiguration Set-VMHostProfileVmPortGroupConfiguration Set-VMHostRoute Set-VMHostService Set-VMHostSnmp Set-VMHostStartPolicy Set-VMHostStorage Set-VMHostSysLogServer Set-VMQuestion Set-VMResourceConfiguration Set-VMStartPolicy Set-VTpm Set-VirtualPortGroup Set-VirtualSwitch Set-VsanClusterConfiguration Set-VsanFaultDomain Set-VsanIscsiInitiatorGroup Set-VsanIscsiLun Set-VsanIscsiTarget Set-vRABusinessGroup Set-vRACatalogItem Set-vRACustomForm Set-vRAEntitlement Set-vRAExternalNetworkProfile Set-vRANATNetworkProfile Set-vRAReservation Set-vRAReservationNetwork Set-vRAReservationPolicy Set-vRAReservationStorage Set-vRARoutedNetworkProfile Set-vRAService Set-vRAStorageReservationPolicy Set-vRATenant Set-vRATenantDirectory Set-vRAUserPrincipal Set-vRNIDataSourceSNMPConfig Show-Markdown Start-CIVApp Start-CIVM Start-HCXMigration Start-HCXReplication Start-SpbmReplicationFailover Start-SpbmReplicationPrepareFailover Start-SpbmReplicationPromote Start-SpbmReplicationReverse Start-SpbmReplicationTestFailover Start-ThreadJob Start-VApp Start-VM Start-VMHost Start-VMHostService Start-VsanClusterDiskUpdate Start-VsanClusterRebalance Start-VsanEncryptionConfiguration Stop-CIVApp Stop-CIVAppGuest Stop-CIVM Stop-CIVMGuest Stop-SpbmReplicationTestFailover Stop-Task Stop-VApp Stop-VM Stop-VMGuest Stop-VMHost Stop-VMHostService Stop-VsanClusterRebalance Suspend-CIVApp Suspend-CIVM Suspend-HCXReplication Suspend-VM Suspend-VMGuest Suspend-VMHost Sync-SpbmReplicationGroup Test-ConflictingResources Test-HCXMigration Test-HCXReplication Test-Json Test-ModuleReloadRequired Test-MofInstanceText Test-NodeManager Test-NodeResourceSource Test-NodeResources Test-ScriptFileInfo Test-VMHostProfileCompliance Test-VMHostSnmp Test-VsanClusterHealth Test-VsanNetworkPerformance Test-VsanStoragePerformance Test-VsanVMCreation Test-vRAPackage Uninstall-Module Uninstall-Package Uninstall-Script Unlock-VM Unregister-PSRepository Unregister-PackageSource Update-ConfigurationDocumentRef Update-ConfigurationErrorCount Update-DependsOn Update-LocalConfigManager Update-Module Update-ModuleManifest Update-ModuleVersion Update-PowerNsx Update-Script Update-ScriptFileInfo Update-Tools Update-VsanHclDatabase ValidateUpdate-ConfigurationData Wait-Debugger Wait-NsxControllerJob Wait-NsxGenericJob Wait-NsxJob Wait-Task Wait-Tools Write-Information Write-Log Write-MetaConfigFile Write-NodeMOFFile",
+nomarkup:"-ne -eq -lt -gt -ge -le -not -like -notlike -match -notmatch -contains -notcontains -in -notin -replace"},contains:[b,a.NUMBER_MODE,e,{className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]},{className:"literal",begin:/\$(null|true|false)\b/},d,f]}});b.registerLanguage("processing",function(a){return{keywords:{keyword:"BufferedReader PVector PFont PImage PGraphics HashMap boolean byte char color double float int long String Array FloatDict FloatList IntDict IntList JSONArray JSONObject Object StringDict StringList Table TableRow XML false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private",
+literal:"P2D P3D HALF_PI PI QUARTER_PI TAU TWO_PI",title:"setup draw",built_in:"displayHeight displayWidth mouseY mouseX mousePressed pmouseX pmouseY key keyCode pixels focused frameCount frameRate height width size createGraphics beginDraw createShape loadShape PShape arc ellipse line point quad rect triangle bezier bezierDetail bezierPoint bezierTangent curve curveDetail curvePoint curveTangent curveTightness shape shapeMode beginContour beginShape bezierVertex curveVertex endContour endShape quadraticVertex vertex ellipseMode noSmooth rectMode smooth strokeCap strokeJoin strokeWeight mouseClicked mouseDragged mouseMoved mousePressed mouseReleased mouseWheel keyPressed keyPressedkeyReleased keyTyped print println save saveFrame day hour millis minute month second year background clear colorMode fill noFill noStroke stroke alpha blue brightness color green hue lerpColor red saturation modelX modelY modelZ screenX screenY screenZ ambient emissive shininess specular add createImage beginCamera camera endCamera frustum ortho perspective printCamera printProjection cursor frameRate noCursor exit loop noLoop popStyle pushStyle redraw binary boolean byte char float hex int str unbinary unhex join match matchAll nf nfc nfp nfs split splitTokens trim append arrayCopy concat expand reverse shorten sort splice subset box sphere sphereDetail createInput createReader loadBytes loadJSONArray loadJSONObject loadStrings loadTable loadXML open parseXML saveTable selectFolder selectInput beginRaw beginRecord createOutput createWriter endRaw endRecord PrintWritersaveBytes saveJSONArray saveJSONObject saveStream saveStrings saveXML selectOutput popMatrix printMatrix pushMatrix resetMatrix rotate rotateX rotateY rotateZ scale shearX shearY translate ambientLight directionalLight lightFalloff lights lightSpecular noLights normal pointLight spotLight image imageMode loadImage noTint requestImage tint texture textureMode textureWrap blend copy filter get loadPixels set updatePixels blendMode loadShader PShaderresetShader shader createFont loadFont text textFont textAlign textLeading textMode textSize textWidth textAscent textDescent abs ceil constrain dist exp floor lerp log mag map max min norm pow round sq sqrt acos asin atan atan2 cos degrees radians sin tan noise noiseDetail noiseSeed random randomGaussian randomSeed"},
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE]}});b.registerLanguage("profile",function(a){return{contains:[a.C_NUMBER_MODE,{begin:"[a-zA-Z_][\\da-zA-Z_]+\\.[\\da-zA-Z_]{1,3}",end:":",excludeEnd:!0},{begin:"(ncalls|tottime|cumtime)",end:"$",keywords:"ncalls tottime|10 cumtime|10 filename",relevance:10},{begin:"function calls",end:"$",contains:[a.C_NUMBER_MODE],relevance:10},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"\\(",
+end:"\\)$",excludeBegin:!0,excludeEnd:!0,relevance:0}]}});b.registerLanguage("prolog",function(a){var b={begin:/\(/,end:/\)/,relevance:0},d={begin:/\[/,end:/\]/};a=[{begin:/[a-z][A-Za-z0-9_]*/,relevance:0},{className:"symbol",variants:[{begin:/[A-Z][a-zA-Z0-9_]*/},{begin:/_[A-Za-z0-9_]*/}],relevance:0},b,{begin:/:-/},d,{className:"comment",begin:/%/,end:/$/,contains:[a.PHRASAL_WORDS_MODE]},a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{className:"string",begin:/`/,end:/`/,contains:[a.BACKSLASH_ESCAPE]},
+{className:"string",begin:/0'(\\'|.)/},{className:"string",begin:/0'\\s/},a.C_NUMBER_MODE];b.contains=a;d.contains=a;return{contains:a.concat([{begin:/\.$/}])}});b.registerLanguage("properties",function(a){var b={end:"([ \\t\\f]*[:=][ \\t\\f]*|[ \\t\\f]+)",relevance:0,starts:{className:"string",end:/$/,relevance:0,contains:[{begin:"\\\\\\n"}]}};return{case_insensitive:!0,illegal:/\S/,contains:[a.COMMENT("^\\s*[!#]","$"),{begin:"([^\\\\\\W:= \\t\\f\\n]|\\\\.)+([ \\t\\f]*[:=][ \\t\\f]*|[ \\t\\f]+)",
+returnBegin:!0,contains:[{className:"attr",begin:"([^\\\\\\W:= \\t\\f\\n]|\\\\.)+",endsParent:!0,relevance:0}],starts:b},{begin:"([^\\\\:= \\t\\f\\n]|\\\\.)+([ \\t\\f]*[:=][ \\t\\f]*|[ \\t\\f]+)",returnBegin:!0,relevance:0,contains:[{className:"meta",begin:"([^\\\\:= \\t\\f\\n]|\\\\.)+",endsParent:!0,relevance:0}],starts:b},{className:"attr",relevance:0,begin:"([^\\\\:= \\t\\f\\n]|\\\\.)+[ \\t\\f]*$"}]}});b.registerLanguage("protobuf",function(a){return{keywords:{keyword:"package import option optional required repeated group oneof",
+built_in:"double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string bytes",literal:"true false"},contains:[a.QUOTE_STRING_MODE,a.NUMBER_MODE,a.C_LINE_COMMENT_MODE,{className:"class",beginKeywords:"message enum service",end:/\{/,illegal:/\n/,contains:[a.inherit(a.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},{className:"function",beginKeywords:"rpc",end:/;/,excludeEnd:!0,keywords:"rpc returns"},{begin:/^\s*[A-Z_]+/,end:/\s*=/,excludeEnd:!0}]}});b.registerLanguage("puppet",
+function(a){var b=a.COMMENT("#","$"),d=a.inherit(a.TITLE_MODE,{begin:"([A-Za-z_]|::)(\\w|::)*"}),e={className:"variable",begin:"\\$([A-Za-z_]|::)(\\w|::)*"},f={className:"string",contains:[a.BACKSLASH_ESCAPE,e],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/}]};return{aliases:["pp"],contains:[b,e,f,{beginKeywords:"class",end:"\\{|;",illegal:/=/,contains:[d,b]},{beginKeywords:"define",end:/\{/,contains:[{className:"section",begin:a.IDENT_RE,endsParent:!0}]},{begin:a.IDENT_RE+"\\s+\\{",returnBegin:!0,
+end:/\S/,contains:[{className:"keyword",begin:a.IDENT_RE},{begin:/\{/,end:/\}/,keywords:{keyword:"and case default else elsif false if in import enherits node or true undef unless main settings $string ",literal:"alias audit before loglevel noop require subscribe tag owner ensure group mode name|0 changes context force incl lens load_path onlyif provider returns root show_diff type_check en_address ip_address realname command environment hour monute month monthday special target weekday creates cwd ogoutput refresh refreshonly tries try_sleep umask backup checksum content ctime force ignore links mtime purge recurse recurselimit replace selinux_ignore_defaults selrange selrole seltype seluser source souirce_permissions sourceselect validate_cmd validate_replacement allowdupe attribute_membership auth_membership forcelocal gid ia_load_module members system host_aliases ip allowed_trunk_vlans description device_url duplex encapsulation etherchannel native_vlan speed principals allow_root auth_class auth_type authenticate_user k_of_n mechanisms rule session_owner shared options device fstype enable hasrestart directory present absent link atboot blockdevice device dump pass remounts poller_tag use message withpath adminfile allow_virtual allowcdrom category configfiles flavor install_options instance package_settings platform responsefile status uninstall_options vendor unless_system_user unless_uid binary control flags hasstatus manifest pattern restart running start stop allowdupe auths expiry gid groups home iterations key_membership keys managehome membership password password_max_age password_min_age profile_membership profiles project purge_ssh_keys role_membership roles salt shell uid baseurl cost descr enabled enablegroups exclude failovermethod gpgcheck gpgkey http_caching include includepkgs keepalive metadata_expire metalink mirrorlist priority protect proxy proxy_password proxy_username repo_gpgcheck s3_enabled skip_if_unavailable sslcacert sslclientcert sslclientkey sslverify mounted",
+built_in:"architecture augeasversion blockdevices boardmanufacturer boardproductname boardserialnumber cfkey dhcp_servers domain ec2_ ec2_userdata facterversion filesystems ldom fqdn gid hardwareisa hardwaremodel hostname id|0 interfaces ipaddress ipaddress_ ipaddress6 ipaddress6_ iphostnumber is_virtual kernel kernelmajversion kernelrelease kernelversion kernelrelease kernelversion lsbdistcodename lsbdistdescription lsbdistid lsbdistrelease lsbmajdistrelease lsbminordistrelease lsbrelease macaddress macaddress_ macosx_buildversion macosx_productname macosx_productversion macosx_productverson_major macosx_productversion_minor manufacturer memoryfree memorysize netmask metmask_ network_ operatingsystem operatingsystemmajrelease operatingsystemrelease osfamily partitions path physicalprocessorcount processor processorcount productname ps puppetversion rubysitedir rubyversion selinux selinux_config_mode selinux_config_policy selinux_current_mode selinux_current_mode selinux_enforced selinux_policyversion serialnumber sp_ sshdsakey sshecdsakey sshrsakey swapencrypted swapfree swapsize timezone type uniqueid uptime uptime_days uptime_hours uptime_seconds uuid virtual vlans xendomains zfs_version zonenae zones zpool_version"},
+relevance:0,contains:[f,b,{begin:"[a-zA-Z_]+\\s*=>",returnBegin:!0,end:"=>",contains:[{className:"attr",begin:a.IDENT_RE}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},e]}],relevance:0}]}});b.registerLanguage("purebasic",function(a){return{aliases:["pb","pbi"],keywords:"And As Break CallDebugger Case CompilerCase CompilerDefault CompilerElse CompilerEndIf CompilerEndSelect CompilerError CompilerIf CompilerSelect Continue Data DataSection EndDataSection Debug DebugLevel Default Define Dim DisableASM DisableDebugger DisableExplicit Else ElseIf EnableASM EnableDebugger EnableExplicit End EndEnumeration EndIf EndImport EndInterface EndMacro EndProcedure EndSelect EndStructure EndStructureUnion EndWith Enumeration Extends FakeReturn For Next ForEach ForEver Global Gosub Goto If Import ImportC IncludeBinary IncludeFile IncludePath Interface Macro NewList Not Or ProcedureReturn Protected Prototype PrototypeC Read ReDim Repeat Until Restore Return Select Shared Static Step Structure StructureUnion Swap To Wend While With XIncludeFile XOr Procedure ProcedureC ProcedureCDLL ProcedureDLL Declare DeclareC DeclareCDLL DeclareDLL",
+contains:[a.COMMENT(";","$",{relevance:0}),{className:"function",begin:"\\b(Procedure|Declare)(C|CDLL|DLL)?\\b",end:"\\(",excludeEnd:!0,returnBegin:!0,contains:[{className:"keyword",begin:"(Procedure|Declare)(C|CDLL|DLL)?",excludeEnd:!0},{className:"type",begin:"\\.\\w*"},a.UNDERSCORE_TITLE_MODE]},{className:"string",begin:'(~)?"',end:'"',illegal:"\\n"},{className:"symbol",begin:"#[a-zA-Z_]\\w*\\$?"}]}});b.registerLanguage("python",function(a){var b={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10",
+built_in:"Ellipsis NotImplemented",literal:"False None True"},d={className:"meta",begin:/^(>>>|\.\.\.) /},e={className:"subst",begin:/\{/,end:/\}/,keywords:b,illegal:/#/},f={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[a.BACKSLASH_ESCAPE,d],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[a.BACKSLASH_ESCAPE,d],relevance:10},{begin:/(fr|rf|f)'''/,end:/'''/,contains:[a.BACKSLASH_ESCAPE,d,e]},{begin:/(fr|rf|f)"""/,end:/"""/,contains:[a.BACKSLASH_ESCAPE,
+d,e]},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,end:/"/},{begin:/(fr|rf|f)'/,end:/'/,contains:[a.BACKSLASH_ESCAPE,e]},{begin:/(fr|rf|f)"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,e]},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]},g={className:"number",relevance:0,variants:[{begin:a.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:a.C_NUMBER_RE+"[lLjJ]?"}]},k={className:"params",begin:/\(/,end:/\)/,contains:["self",
+d,g,f]};e.contains=[f,g,d];return{aliases:["py","gyp","ipython"],keywords:b,illegal:/(<\/|->|\?)|=>/,contains:[d,g,f,a.HASH_COMMENT_MODE,{variants:[{className:"function",beginKeywords:"def"},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,contains:[a.UNDERSCORE_TITLE_MODE,k,{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}});b.registerLanguage("q",function(a){return{aliases:["k","kdb"],keywords:{keyword:"do while select delete by update from",
+literal:"0b 1b",built_in:"neg not null string reciprocal floor ceiling signum mod xbar xlog and or each scan over prior mmu lsq inv md5 ltime gtime count first var dev med cov cor all any rand sums prds mins maxs fills deltas ratios avgs differ prev next rank reverse iasc idesc asc desc msum mcount mavg mdev xrank mmin mmax xprev rotate distinct group where flip type key til get value attr cut set upsert raze union inter except cross sv vs sublist enlist read0 read1 hopen hclose hdel hsym hcount peach system ltrim rtrim trim lower upper ssr view tables views cols xcols keys xkey xcol xasc xdesc fkeys meta lj aj aj0 ij pj asof uj ww wj wj1 fby xgroup ungroup ej save load rsave rload show csv parse eval min max avg wavg wsum sin cos tan sum",
+type:"`float `double int `timestamp `timespan `datetime `time `boolean `symbol `char `byte `short `long `real `month `date `minute `second `guid"},lexemes:/(`?)[A-Za-z0-9_]+\b/,contains:[a.C_LINE_COMMENT_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE]}});b.registerLanguage("qml",function(a){var b={begin:"[a-zA-Z_][a-zA-Z0-9\\._]*\\s*{",end:"{",returnBegin:!0,relevance:0,contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_][a-zA-Z0-9\\._]*"})]};return{aliases:["qt"],case_insensitive:!1,keywords:{keyword:"in of on if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await import",
+literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Behavior bool color coordinate date double enumeration font geocircle georectangle geoshape int list matrix4x4 parent point quaternion real rect size string url variant vector2d vector3d vector4dPromise"},
+contains:[{className:"meta",begin:/^\s*['"]use (strict|asm)['"]/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[a.C_LINE_COMMENT_MODE,
+a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{begin:/</,end:/>\s*[);\]]/,relevance:0,subLanguage:"xml"}],relevance:0},{className:"keyword",begin:"\\bsignal\\b",starts:{className:"string",end:"(\\(|:|=|;|,|//|/\\*|$)",returnEnd:!0}},{className:"keyword",begin:"\\bproperty\\b",starts:{className:"string",end:"(:|=|;|,|//|/\\*|$)",returnEnd:!0}},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{className:"params",begin:/\(/,
+end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}],illegal:/\[|%/},{begin:"\\."+a.IDENT_RE,relevance:0},{className:"attribute",begin:"\\bid\\s*:",starts:{className:"string",end:"[a-zA-Z_][a-zA-Z0-9\\._]*",returnEnd:!1}},{begin:"[a-zA-Z_][a-zA-Z0-9\\._]*\\s*:",returnBegin:!0,contains:[{className:"attribute",begin:"[a-zA-Z_][a-zA-Z0-9\\._]*",end:"\\s*:",excludeEnd:!0,relevance:0}],relevance:0},b],illegal:/#/}});b.registerLanguage("r",function(a){return{contains:[a.HASH_COMMENT_MODE,
+{begin:"([a-zA-Z]|\\.[a-zA-Z.])[a-zA-Z0-9._]*",lexemes:"([a-zA-Z]|\\.[a-zA-Z.])[a-zA-Z0-9._]*",keywords:{keyword:"function if in break next repeat else for return switch while try tryCatch stop warning require library attach detach source setMethod setGeneric setGroupGeneric setClass ...",literal:"NULL NA TRUE FALSE T F Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10"},relevance:0},{className:"number",begin:"0[xX][0-9a-fA-F]+[Li]?\\b",relevance:0},{className:"number",begin:"\\d+(?:[eE][+\\-]?\\d*)?L\\b",
+relevance:0},{className:"number",begin:"\\d+\\.(?!\\d)(?:i\\b)?",relevance:0},{className:"number",begin:"\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0},{className:"number",begin:"\\.\\d+(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0},{begin:"`",end:"`",relevance:0},{className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:'"',end:'"'},{begin:"'",end:"'"}]}]}});b.registerLanguage("reasonml",function(a){var b="("+function(a){return a.map(function(a){return a.split("").map(function(a){return"\\"+
+a}).join("")}).join("|")}("|| && ++ ** +. * / *. /. ... |>".split(" "))+"|==|===)",d="\\s+"+b+"\\s+",e={keyword:"and as asr assert begin class constraint do done downto else end exception externalfor fun function functor if in include inherit initializerland lazy let lor lsl lsr lxor match method mod module mutable new nonrecobject of open or private rec sig struct then to try type val virtual when while with",built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 ref string unit ",
+literal:"true false"},f={className:"number",relevance:0,variants:[{begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)"},{begin:"\\(\\-\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)\\)"}]},g={className:"operator",relevance:0,begin:b};b=[{className:"identifier",relevance:0,begin:"~?[a-z$_][0-9a-zA-Z$_]*"},g,f];var k=[a.QUOTE_STRING_MODE,g,{className:"module",
+begin:"\\b`?[A-Z$_][0-9a-zA-Z$_]*",returnBegin:!0,end:".",contains:[{className:"identifier",begin:"`?[A-Z$_][0-9a-zA-Z$_]*",relevance:0}]}],h=[{className:"module",begin:"\\b`?[A-Z$_][0-9a-zA-Z$_]*",returnBegin:!0,end:".",relevance:0,contains:[{className:"identifier",begin:"`?[A-Z$_][0-9a-zA-Z$_]*",relevance:0}]}],m={className:"function",relevance:0,keywords:e,variants:[{begin:"\\s(\\(\\.?.*?\\)|~?[a-z$_][0-9a-zA-Z$_]*)\\s*=>",end:"\\s*=>",returnBegin:!0,relevance:0,contains:[{className:"params",variants:[{begin:"~?[a-z$_][0-9a-zA-Z$_]*"},
+{begin:"~?[a-z$_][0-9a-zA-Z$_]*(s*:s*[a-z$_][0-9a-z$_]*((s*('?[a-z$_][0-9a-z$_]*s*(,'?[a-z$_][0-9a-z$_]*)*)?s*))?)?(s*:s*[a-z$_][0-9a-z$_]*((s*('?[a-z$_][0-9a-z$_]*s*(,'?[a-z$_][0-9a-z$_]*)*)?s*))?)?"},{begin:/\(\s*\)/}]}]},{begin:"\\s\\(\\.?[^;\\|]*\\)\\s*=>",end:"\\s=>",returnBegin:!0,relevance:0,contains:[{className:"params",relevance:0,variants:[{begin:"~?[a-z$_][0-9a-zA-Z$_]*",end:"(,|\\n|\\))",relevance:0,contains:[g,{className:"typing",begin:":",end:"(,|\\n)",returnBegin:!0,relevance:0,contains:h}]}]}]},
+{begin:"\\(\\.\\s~?[a-z$_][0-9a-zA-Z$_]*\\)\\s*=>"}]};k.push(m);var n={className:"constructor",begin:"`?[A-Z$_][0-9a-zA-Z$_]*\\(",end:"\\)",illegal:"\\n",keywords:e,contains:[a.QUOTE_STRING_MODE,g,{className:"params",begin:"\\b~?[a-z$_][0-9a-zA-Z$_]*"}]};g={className:"pattern-match",begin:"\\|",returnBegin:!0,keywords:e,end:"=>",relevance:0,contains:[n,g,{relevance:0,className:"constructor",begin:"`?[A-Z$_][0-9a-zA-Z$_]*"}]};var l={className:"module-access",keywords:e,returnBegin:!0,variants:[{begin:"\\b(`?[A-Z$_][0-9a-zA-Z$_]*\\.)+~?[a-z$_][0-9a-zA-Z$_]*"},
+{begin:"\\b(`?[A-Z$_][0-9a-zA-Z$_]*\\.)+\\(",end:"\\)",returnBegin:!0,contains:[m,{begin:"\\(",end:"\\)",skip:!0}].concat(k)},{begin:"\\b(`?[A-Z$_][0-9a-zA-Z$_]*\\.)+{",end:"}"}],contains:k};h.push(l);return{aliases:["re"],keywords:e,illegal:"(:\\-|:=|\\${|\\+=)",contains:[a.COMMENT("/\\*","\\*/",{illegal:"^(\\#,\\/\\/)"}),{className:"character",begin:"'(\\\\[^']+|[^'])'",illegal:"\\n",relevance:0},a.QUOTE_STRING_MODE,{className:"literal",begin:"\\(\\)",relevance:0},{className:"literal",begin:"\\[\\|",
+end:"\\|\\]",relevance:0,contains:b},{className:"literal",begin:"\\[",end:"\\]",relevance:0,contains:b},n,{className:"operator",begin:d,illegal:"\\-\\->",relevance:0},f,a.C_LINE_COMMENT_MODE,g,m,{className:"module-def",begin:"\\bmodule\\s+~?[a-z$_][0-9a-zA-Z$_]*\\s+`?[A-Z$_][0-9a-zA-Z$_]*\\s+=\\s+{",end:"}",returnBegin:!0,keywords:e,relevance:0,contains:[{className:"module",relevance:0,begin:"`?[A-Z$_][0-9a-zA-Z$_]*"},{begin:"{",end:"}",skip:!0}].concat(k)},l]}});b.registerLanguage("rib",function(a){return{keywords:"ArchiveRecord AreaLightSource Atmosphere Attribute AttributeBegin AttributeEnd Basis Begin Blobby Bound Clipping ClippingPlane Color ColorSamples ConcatTransform Cone CoordinateSystem CoordSysTransform CropWindow Curves Cylinder DepthOfField Detail DetailRange Disk Displacement Display End ErrorHandler Exposure Exterior Format FrameAspectRatio FrameBegin FrameEnd GeneralPolygon GeometricApproximation Geometry Hider Hyperboloid Identity Illuminate Imager Interior LightSource MakeCubeFaceEnvironment MakeLatLongEnvironment MakeShadow MakeTexture Matte MotionBegin MotionEnd NuPatch ObjectBegin ObjectEnd ObjectInstance Opacity Option Orientation Paraboloid Patch PatchMesh Perspective PixelFilter PixelSamples PixelVariance Points PointsGeneralPolygons PointsPolygons Polygon Procedural Projection Quantize ReadArchive RelativeDetail ReverseOrientation Rotate Scale ScreenWindow ShadingInterpolation ShadingRate Shutter Sides Skew SolidBegin SolidEnd Sphere SubdivisionMesh Surface TextureCoordinates Torus Transform TransformBegin TransformEnd TransformPoints Translate TrimCurve WorldBegin WorldEnd",
+illegal:"</",contains:[a.HASH_COMMENT_MODE,a.C_NUMBER_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}});b.registerLanguage("roboconf",function(a){var b={className:"attribute",begin:/[a-zA-Z-_]+/,end:/\s*:/,excludeEnd:!0,starts:{end:";",relevance:0,contains:[{className:"variable",begin:/\.[a-zA-Z-_]+/},{className:"keyword",begin:/\(optional\)/}]}};return{aliases:["graph","instances"],case_insensitive:!0,keywords:"import",contains:[{begin:"^facet [a-zA-Z-_][^\\n{]+\\{",end:"}",keywords:"facet",contains:[b,
+a.HASH_COMMENT_MODE]},{begin:"^\\s*instance of [a-zA-Z-_][^\\n{]+\\{",end:"}",keywords:"name count channels instance-data instance-state instance of",illegal:/\S/,contains:["self",b,a.HASH_COMMENT_MODE]},{begin:"^[a-zA-Z-_][^\\n{]+\\{",end:"}",contains:[b,a.HASH_COMMENT_MODE]},a.HASH_COMMENT_MODE]}});b.registerLanguage("routeros",function(a){var b={className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},d={className:"string",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,
+b,{className:"variable",begin:/\$\(/,end:/\)/,contains:[a.BACKSLASH_ESCAPE]}]},e={className:"string",begin:/'/,end:/'/};return{aliases:["routeros","mikrotik"],case_insensitive:!0,lexemes:/:?[\w-]+/,keywords:{literal:"true false yes no nothing nil null",keyword:"foreach do while for if from to step else on-error and or not in :foreach :do :while :for :if :from :to :step :else :on-error :and :or :not :in :global :local :beep :delay :put :len :typeof :pick :log :time :set :find :environment :terminal :error :execute :parse :resolve :toarray :tobool :toid :toip :toip6 :tonum :tostr :totime"},
+contains:[{variants:[{begin:/^@/,end:/$/},{begin:/\/\*/,end:/\*\//},{begin:/%%/,end:/$/},{begin:/^'/,end:/$/},{begin:/^\s*\/[\w-]+=/,end:/$/},{begin:/\/\//,end:/$/},{begin:/^\[</,end:/>\]$/},{begin:/<\//,end:/>/},{begin:/^facet /,end:/\}/},{begin:"^1\\.\\.(\\d+)$",end:/$/}],illegal:/./},a.COMMENT("^#","$"),d,e,b,{begin:/[\w-]+=([^\s\{\}\[\]\(\)]+)/,relevance:0,returnBegin:!0,contains:[{className:"attribute",begin:/[^=]+/},{begin:/=/,endsWithParent:!0,relevance:0,contains:[d,e,b,{className:"literal",
+begin:"\\b(true|false|yes|no|nothing|nil|null)\\b"},{begin:/("[^"]*"|[^\s\{\}\[\]]+)/}]}]},{className:"number",begin:/\*[0-9a-fA-F]+/},{begin:"\\b(add|remove|enable|disable|set|get|print|export|edit|find|run|debug|error|info|warning)([\\s[(]|])",returnBegin:!0,contains:[{className:"builtin-name",begin:/\w+/}]},{className:"built_in",variants:[{begin:"(\\.\\./|/|\\s)((traffic-flow|traffic-generator|firewall|scheduler|aaa|accounting|address-list|address|align|area|bandwidth-server|bfd|bgp|bridge|client|clock|community|config|connection|console|customer|default|dhcp-client|dhcp-server|discovery|dns|e-mail|ethernet|filter|firewall|firmware|gps|graphing|group|hardware|health|hotspot|identity|igmp-proxy|incoming|instance|interface|ip|ipsec|ipv6|irq|l2tp-server|lcd|ldp|logging|mac-server|mac-winbox|mangle|manual|mirror|mme|mpls|nat|nd|neighbor|network|note|ntp|ospf|ospf-v3|ovpn-server|page|peer|pim|ping|policy|pool|port|ppp|pppoe-client|pptp-server|prefix|profile|proposal|proxy|queue|radius|resource|rip|ripng|route|routing|screen|script|security-profiles|server|service|service-port|settings|shares|smb|sms|sniffer|snmp|snooper|socks|sstp-server|system|tool|tracking|type|upgrade|upnp|user-manager|users|user|vlan|secret|vrrp|watchdog|web-access|wireless|pptp|pppoe|lan|wan|layer7-protocol|lease|simple|raw);?\\s)+",
+relevance:10},{begin:/\.\./}]}]}});b.registerLanguage("rsl",function(a){return{keywords:{keyword:"float color point normal vector matrix while for if do return else break extern continue",built_in:"abs acos ambient area asin atan atmosphere attribute calculatenormal ceil cellnoise clamp comp concat cos degrees depth Deriv diffuse distance Du Dv environment exp faceforward filterstep floor format fresnel incident length lightsource log match max min mod noise normalize ntransform opposite option phong pnoise pow printf ptlined radians random reflect refract renderinfo round setcomp setxcomp setycomp setzcomp shadow sign sin smoothstep specular specularbrdf spline sqrt step tan texture textureinfo trace transform vtransform xcomp ycomp zcomp"},
+illegal:"</",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_NUMBER_MODE,{className:"meta",begin:"#",end:"$"},{className:"class",beginKeywords:"surface displacement light volume imager",end:"\\("},{beginKeywords:"illuminate illuminance gather",end:"\\("}]}});b.registerLanguage("ruleslanguage",function(a){return{keywords:{keyword:"BILL_PERIOD BILL_START BILL_STOP RS_EFFECTIVE_START RS_EFFECTIVE_STOP RS_JURIS_CODE RS_OPCO_CODE INTDADDATTRIBUTE|5 INTDADDVMSG|5 INTDBLOCKOP|5 INTDBLOCKOPNA|5 INTDCLOSE|5 INTDCOUNT|5 INTDCOUNTSTATUSCODE|5 INTDCREATEMASK|5 INTDCREATEDAYMASK|5 INTDCREATEFACTORMASK|5 INTDCREATEHANDLE|5 INTDCREATEOVERRIDEDAYMASK|5 INTDCREATEOVERRIDEMASK|5 INTDCREATESTATUSCODEMASK|5 INTDCREATETOUPERIOD|5 INTDDELETE|5 INTDDIPTEST|5 INTDEXPORT|5 INTDGETERRORCODE|5 INTDGETERRORMESSAGE|5 INTDISEQUAL|5 INTDJOIN|5 INTDLOAD|5 INTDLOADACTUALCUT|5 INTDLOADDATES|5 INTDLOADHIST|5 INTDLOADLIST|5 INTDLOADLISTDATES|5 INTDLOADLISTENERGY|5 INTDLOADLISTHIST|5 INTDLOADRELATEDCHANNEL|5 INTDLOADSP|5 INTDLOADSTAGING|5 INTDLOADUOM|5 INTDLOADUOMDATES|5 INTDLOADUOMHIST|5 INTDLOADVERSION|5 INTDOPEN|5 INTDREADFIRST|5 INTDREADNEXT|5 INTDRECCOUNT|5 INTDRELEASE|5 INTDREPLACE|5 INTDROLLAVG|5 INTDROLLPEAK|5 INTDSCALAROP|5 INTDSCALE|5 INTDSETATTRIBUTE|5 INTDSETDSTPARTICIPANT|5 INTDSETSTRING|5 INTDSETVALUE|5 INTDSETVALUESTATUS|5 INTDSHIFTSTARTTIME|5 INTDSMOOTH|5 INTDSORT|5 INTDSPIKETEST|5 INTDSUBSET|5 INTDTOU|5 INTDTOURELEASE|5 INTDTOUVALUE|5 INTDUPDATESTATS|5 INTDVALUE|5 STDEV INTDDELETEEX|5 INTDLOADEXACTUAL|5 INTDLOADEXCUT|5 INTDLOADEXDATES|5 INTDLOADEX|5 INTDLOADEXRELATEDCHANNEL|5 INTDSAVEEX|5 MVLOAD|5 MVLOADACCT|5 MVLOADACCTDATES|5 MVLOADACCTHIST|5 MVLOADDATES|5 MVLOADHIST|5 MVLOADLIST|5 MVLOADLISTDATES|5 MVLOADLISTHIST|5 IF FOR NEXT DONE SELECT END CALL ABORT CLEAR CHANNEL FACTOR LIST NUMBER OVERRIDE SET WEEK DISTRIBUTIONNODE ELSE WHEN THEN OTHERWISE IENUM CSV INCLUDE LEAVE RIDER SAVE DELETE NOVALUE SECTION WARN SAVE_UPDATE DETERMINANT LABEL REPORT REVENUE EACH IN FROM TOTAL CHARGE BLOCK AND OR CSV_FILE RATE_CODE AUXILIARY_DEMAND UIDACCOUNT RS BILL_PERIOD_SELECT HOURS_PER_MONTH INTD_ERROR_STOP SEASON_SCHEDULE_NAME ACCOUNTFACTOR ARRAYUPPERBOUND CALLSTOREDPROC GETADOCONNECTION GETCONNECT GETDATASOURCE GETQUALIFIER GETUSERID HASVALUE LISTCOUNT LISTOP LISTUPDATE LISTVALUE PRORATEFACTOR RSPRORATE SETBINPATH SETDBMONITOR WQ_OPEN BILLINGHOURS DATE DATEFROMFLOAT DATETIMEFROMSTRING DATETIMETOSTRING DATETOFLOAT DAY DAYDIFF DAYNAME DBDATETIME HOUR MINUTE MONTH MONTHDIFF MONTHHOURS MONTHNAME ROUNDDATE SAMEWEEKDAYLASTYEAR SECOND WEEKDAY WEEKDIFF YEAR YEARDAY YEARSTR COMPSUM HISTCOUNT HISTMAX HISTMIN HISTMINNZ HISTVALUE MAXNRANGE MAXRANGE MINRANGE COMPIKVA COMPKVA COMPKVARFROMKQKW COMPLF IDATTR FLAG LF2KW LF2KWH MAXKW POWERFACTOR READING2USAGE AVGSEASON MAXSEASON MONTHLYMERGE SEASONVALUE SUMSEASON ACCTREADDATES ACCTTABLELOAD CONFIGADD CONFIGGET CREATEOBJECT CREATEREPORT EMAILCLIENT EXPBLKMDMUSAGE EXPMDMUSAGE EXPORT_USAGE FACTORINEFFECT GETUSERSPECIFIEDSTOP INEFFECT ISHOLIDAY RUNRATE SAVE_PROFILE SETREPORTTITLE USEREXIT WATFORRUNRATE TO TABLE ACOS ASIN ATAN ATAN2 BITAND CEIL COS COSECANT COSH COTANGENT DIVQUOT DIVREM EXP FABS FLOOR FMOD FREPM FREXPN LOG LOG10 MAX MAXN MIN MINNZ MODF POW ROUND ROUND2VALUE ROUNDINT SECANT SIN SINH SQROOT TAN TANH FLOAT2STRING FLOAT2STRINGNC INSTR LEFT LEN LTRIM MID RIGHT RTRIM STRING STRINGNC TOLOWER TOUPPER TRIM NUMDAYS READ_DATE STAGING",
+built_in:"IDENTIFIER OPTIONS XML_ELEMENT XML_OP XML_ELEMENT_OF DOMDOCCREATE DOMDOCLOADFILE DOMDOCLOADXML DOMDOCSAVEFILE DOMDOCGETROOT DOMDOCADDPI DOMNODEGETNAME DOMNODEGETTYPE DOMNODEGETVALUE DOMNODEGETCHILDCT DOMNODEGETFIRSTCHILD DOMNODEGETSIBLING DOMNODECREATECHILDELEMENT DOMNODESETATTRIBUTE DOMNODEGETCHILDELEMENTCT DOMNODEGETFIRSTCHILDELEMENT DOMNODEGETSIBLINGELEMENT DOMNODEGETATTRIBUTECT DOMNODEGETATTRIBUTEI DOMNODEGETATTRIBUTEBYNAME DOMNODEGETBYNAME"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,
+a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,{className:"literal",variants:[{begin:"#\\s+[a-zA-Z\\ \\.]*",relevance:0},{begin:"#[a-zA-Z\\ \\.]+"}]}]}});b.registerLanguage("rust",function(a){return{aliases:["rs"],keywords:{keyword:"alignof as be box break const continue crate do else enum extern false fn for if impl in let loop match mod mut offsetof once priv proc pub pure ref return self Self sizeof static struct super trait true type typeof unsafe unsized use virtual while where yield move default",
+literal:"true false Some None Ok Err",built_in:"drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!"},
+lexemes:a.IDENT_RE+"!?",illegal:"</",contains:[a.C_LINE_COMMENT_MODE,a.COMMENT("/\\*","\\*/",{contains:["self"]}),a.inherit(a.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{className:"string",variants:[{begin:/r(#*)"(.|\n)*?"\1(?!#)/},{begin:/b?'\\?(x\w{2}|u\w{4}|U\w{8}|.)'/}]},{className:"symbol",begin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:"number",variants:[{begin:"\\b0b([01_]+)([ui](8|16|32|64|128|size)|f(32|64))?"},{begin:"\\b0o([0-7_]+)([ui](8|16|32|64|128|size)|f(32|64))?"},{begin:"\\b0x([A-Fa-f0-9_]+)([ui](8|16|32|64|128|size)|f(32|64))?"},
+{begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)([ui](8|16|32|64|128|size)|f(32|64))?"}],relevance:0},{className:"function",beginKeywords:"fn",end:"(\\(|<)",excludeEnd:!0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"#\\!?\\[",end:"\\]",contains:[{className:"meta-string",begin:/"/,end:/"/}]},{className:"class",beginKeywords:"type",end:";",contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"\\S"},{className:"class",beginKeywords:"trait enum struct union",end:"{",
+contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"[\\w\\d]"},{begin:a.IDENT_RE+"::",keywords:{built_in:"drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!"}},
+{begin:"->"}]}});b.registerLanguage("sas",function(a){return{aliases:["sas","SAS"],case_insensitive:!0,keywords:{literal:"null missing _all_ _automatic_ _character_ _infile_ _n_ _name_ _null_ _numeric_ _user_ _webout_",meta:"do if then else end until while abort array attrib by call cards cards4 catname continue datalines datalines4 delete delim delimiter display dm drop endsas error file filename footnote format goto in infile informat input keep label leave length libname link list lostcard merge missing modify options output out page put redirect remove rename replace retain return select set skip startsas stop title update waitsas where window x systask add and alter as cascade check create delete describe distinct drop foreign from group having index insert into in key like message modify msgtype not null on or order primary references reset restrict select set table unique update validate view where"},
+contains:[{className:"keyword",begin:/^\s*(proc [\w\d_]+|data|run|quit)[\s;]/},{className:"variable",begin:/&[a-zA-Z_&][a-zA-Z0-9_]*\.?/},{className:"emphasis",begin:/^\s*datalines|cards.*;/,end:/^\s*;\s*$/},{className:"built_in",begin:"%(bquote|nrbquote|cmpres|qcmpres|compstor|datatyp|display|do|else|end|eval|global|goto|if|index|input|keydef|label|left|length|let|local|lowcase|macro|mend|nrbquote|nrquote|nrstr|put|qcmpres|qleft|qlowcase|qscan|qsubstr|qsysfunc|qtrim|quote|qupcase|scan|str|substr|superq|syscall|sysevalf|sysexec|sysfunc|sysget|syslput|sysprod|sysrc|sysrput|then|to|trim|unquote|until|upcase|verify|while|window)"},
+{className:"name",begin:/%[a-zA-Z_][a-zA-Z_0-9]*/},{className:"meta",begin:"[^%](abs|addr|airy|arcos|arsin|atan|attrc|attrn|band|betainv|blshift|bnot|bor|brshift|bxor|byte|cdf|ceil|cexist|cinv|close|cnonct|collate|compbl|compound|compress|cos|cosh|css|curobs|cv|daccdb|daccdbsl|daccsl|daccsyd|dacctab|dairy|date|datejul|datepart|datetime|day|dclose|depdb|depdbsl|depdbsl|depsl|depsl|depsyd|depsyd|deptab|deptab|dequote|dhms|dif|digamma|dim|dinfo|dnum|dopen|doptname|doptnum|dread|dropnote|dsname|erf|erfc|exist|exp|fappend|fclose|fcol|fdelete|fetch|fetchobs|fexist|fget|fileexist|filename|fileref|finfo|finv|fipname|fipnamel|fipstate|floor|fnonct|fnote|fopen|foptname|foptnum|fpoint|fpos|fput|fread|frewind|frlen|fsep|fuzz|fwrite|gaminv|gamma|getoption|getvarc|getvarn|hbound|hms|hosthelp|hour|ibessel|index|indexc|indexw|input|inputc|inputn|int|intck|intnx|intrr|irr|jbessel|juldate|kurtosis|lag|lbound|left|length|lgamma|libname|libref|log|log10|log2|logpdf|logpmf|logsdf|lowcase|max|mdy|mean|min|minute|mod|month|mopen|mort|n|netpv|nmiss|normal|note|npv|open|ordinal|pathname|pdf|peek|peekc|pmf|point|poisson|poke|probbeta|probbnml|probchi|probf|probgam|probhypr|probit|probnegb|probnorm|probt|put|putc|putn|qtr|quote|ranbin|rancau|ranexp|rangam|range|rank|rannor|ranpoi|rantbl|rantri|ranuni|repeat|resolve|reverse|rewind|right|round|saving|scan|sdf|second|sign|sin|sinh|skewness|soundex|spedis|sqrt|std|stderr|stfips|stname|stnamel|substr|sum|symget|sysget|sysmsg|sysprod|sysrc|system|tan|tanh|time|timepart|tinv|tnonct|today|translate|tranwrd|trigamma|trim|trimn|trunc|uniform|upcase|uss|var|varfmt|varinfmt|varlabel|varlen|varname|varnum|varray|varrayx|vartype|verify|vformat|vformatd|vformatdx|vformatn|vformatnx|vformatw|vformatwx|vformatx|vinarray|vinarrayx|vinformat|vinformatd|vinformatdx|vinformatn|vinformatnx|vinformatw|vinformatwx|vinformatx|vlabel|vlabelx|vlength|vlengthx|vname|vnamex|vtype|vtypex|weekday|year|yyq|zipfips|zipname|zipnamel|zipstate)[(]"},
+{className:"string",variants:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]},a.COMMENT("\\*",";"),a.C_BLOCK_COMMENT_MODE]}});b.registerLanguage("scala",function(a){var b={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},d={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},e={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0};return{keywords:{literal:"true false null",keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,b]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[b],relevance:10}]},{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},d,{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[e]},{className:"class",beginKeywords:"class object trait type",
+end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[d]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[d]},e]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("scheme",function(a){var b={className:"literal",begin:"(#t|#f|#\\\\[^\\(\\)\\[\\]\\{\\}\",'`;#|\\\\\\s]+|#\\\\.)"},d={className:"number",variants:[{begin:"(\\-|\\+)?\\d+([./]\\d+)?",
+relevance:0},{begin:"(\\-|\\+)?\\d+([./]\\d+)?[+\\-](\\-|\\+)?\\d+([./]\\d+)?i",relevance:0},{begin:"#b[0-1]+(/[0-1]+)?"},{begin:"#o[0-7]+(/[0-7]+)?"},{begin:"#x[0-9a-f]+(/[0-9a-f]+)?"}]},e=a.QUOTE_STRING_MODE;a=[a.COMMENT(";","$",{relevance:0}),a.COMMENT("#\\|","\\|#")];var f={begin:"[^\\(\\)\\[\\]\\{\\}\",'`;#|\\\\\\s]+",relevance:0},g={className:"symbol",begin:"'[^\\(\\)\\[\\]\\{\\}\",'`;#|\\\\\\s]+"},k={endsWithParent:!0,relevance:0},h={variants:[{begin:/'/},{begin:"`"}],contains:[{begin:"\\(",
+end:"\\)",contains:["self",b,e,d,f,g]}]},m={className:"name",begin:"[^\\(\\)\\[\\]\\{\\}\",'`;#|\\\\\\s]+",lexemes:"[^\\(\\)\\[\\]\\{\\}\",'`;#|\\\\\\s]+",keywords:{"builtin-name":"case-lambda call/cc class define-class exit-handler field import inherit init-field interface let*-values let-values let/ec mixin opt-lambda override protect provide public rename require require-for-syntax syntax syntax-case syntax-error unit/sig unless when with-syntax and begin call-with-current-continuation call-with-input-file call-with-output-file case cond define define-syntax delay do dynamic-wind else for-each if lambda let let* let-syntax letrec letrec-syntax map or syntax-rules ' * + , ,@ - ... / ; < <= = => > >= ` abs acos angle append apply asin assoc assq assv atan boolean? caar cadr call-with-input-file call-with-output-file call-with-values car cdddar cddddr cdr ceiling char->integer char-alphabetic? char-ci<=? char-ci<? char-ci=? char-ci>=? char-ci>? char-downcase char-lower-case? char-numeric? char-ready? char-upcase char-upper-case? char-whitespace? char<=? char<? char=? char>=? char>? char? close-input-port close-output-port complex? cons cos current-input-port current-output-port denominator display eof-object? eq? equal? eqv? eval even? exact->inexact exact? exp expt floor force gcd imag-part inexact->exact inexact? input-port? integer->char integer? interaction-environment lcm length list list->string list->vector list-ref list-tail list? load log magnitude make-polar make-rectangular make-string make-vector max member memq memv min modulo negative? newline not null-environment null? number->string number? numerator odd? open-input-file open-output-file output-port? pair? peek-char port? positive? procedure? quasiquote quote quotient rational? rationalize read read-char real-part real? remainder reverse round scheme-report-environment set! set-car! set-cdr! sin sqrt string string->list string->number string->symbol string-append string-ci<=? string-ci<? string-ci=? string-ci>=? string-ci>? string-copy string-fill! string-length string-ref string-set! string<=? string<? string=? string>=? string>? string? substring symbol->string symbol? tan transcript-off transcript-on truncate values vector vector->list vector-fill! vector-length vector-ref vector-set! with-input-from-file with-output-to-file write write-char zero?"}};
+m={variants:[{begin:"\\(",end:"\\)"},{begin:"\\[",end:"\\]"}],contains:[{begin:/lambda/,endsWithParent:!0,returnBegin:!0,contains:[m,{begin:/\(/,end:/\)/,endsParent:!0,contains:[f]}]},m,k]};k.contains=[b,d,e,f,g,h,m].concat(a);return{illegal:/\S/,contains:[{className:"meta",begin:"^#!",end:"$"},d,e,g,h,m].concat(a)}});b.registerLanguage("scilab",function(a){var b=[a.C_NUMBER_MODE,{className:"string",begin:"'|\"",end:"'|\"",contains:[a.BACKSLASH_ESCAPE,{begin:"''"}]}];return{aliases:["sci"],lexemes:/%?\w+/,
+keywords:{keyword:"abort break case clear catch continue do elseif else endfunction end for function global if pause return resume select try then while",literal:"%f %F %t %T %pi %eps %inf %nan %e %i %z %s",built_in:"abs and acos asin atan ceil cd chdir clearglobal cosh cos cumprod deff disp error exec execstr exists exp eye gettext floor fprintf fread fsolve imag isdef isempty isinfisnan isvector lasterror length load linspace list listfiles log10 log2 log max min msprintf mclose mopen ones or pathconvert poly printf prod pwd rand real round sinh sin size gsort sprintf sqrt strcat strcmps tring sum system tanh tan type typename warning zeros matrix"},
+illegal:'("|#|/\\*|\\s+/\\w+)',contains:[{className:"function",beginKeywords:"function",end:"$",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]},{begin:"[a-zA-Z_][a-zA-Z_0-9]*('+[\\.']*|[\\.']+)",end:"",relevance:0},{begin:"\\[",end:"\\]'*[\\.']*",relevance:0,contains:b},a.COMMENT("//","$")].concat(b)}});b.registerLanguage("scss",function(a){var b={className:"variable",begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b"},d={className:"number",begin:"#[0-9A-Fa-f]+"};return{case_insensitive:!0,
+illegal:"[=/|']",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:"\\#[A-Za-z0-9_-]+",relevance:0},{className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0},{className:"selector-attr",begin:"\\[",end:"\\]",illegal:"$"},{className:"selector-tag",begin:"\\b(a|abbr|acronym|address|area|article|aside|audio|b|base|big|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|command|datalist|dd|del|details|dfn|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|frame|frameset|(h[1-6])|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|keygen|label|legend|li|link|map|mark|meta|meter|nav|noframes|noscript|object|ol|optgroup|option|output|p|param|pre|progress|q|rp|rt|ruby|samp|script|section|select|small|span|strike|strong|style|sub|sup|table|tbody|td|textarea|tfoot|th|thead|time|title|tr|tt|ul|var|video)\\b",
+relevance:0},{begin:":(visited|valid|root|right|required|read-write|read-only|out-range|optional|only-of-type|only-child|nth-of-type|nth-last-of-type|nth-last-child|nth-child|not|link|left|last-of-type|last-child|lang|invalid|indeterminate|in-range|hover|focus|first-of-type|first-line|first-letter|first-child|first|enabled|empty|disabled|default|checked|before|after|active)"},{begin:"::(after|before|choices|first-letter|first-line|repeat-index|repeat-item|selection|value)"},b,{className:"attribute",
+begin:"\\b(z-index|word-wrap|word-spacing|word-break|width|widows|white-space|visibility|vertical-align|unicode-bidi|transition-timing-function|transition-property|transition-duration|transition-delay|transition|transform-style|transform-origin|transform|top|text-underline-position|text-transform|text-shadow|text-rendering|text-overflow|text-indent|text-decoration-style|text-decoration-line|text-decoration-color|text-decoration|text-align-last|text-align|tab-size|table-layout|right|resize|quotes|position|pointer-events|perspective-origin|perspective|page-break-inside|page-break-before|page-break-after|padding-top|padding-right|padding-left|padding-bottom|padding|overflow-y|overflow-x|overflow-wrap|overflow|outline-width|outline-style|outline-offset|outline-color|outline|orphans|order|opacity|object-position|object-fit|normal|none|nav-up|nav-right|nav-left|nav-index|nav-down|min-width|min-height|max-width|max-height|mask|marks|margin-top|margin-right|margin-left|margin-bottom|margin|list-style-type|list-style-position|list-style-image|list-style|line-height|letter-spacing|left|justify-content|initial|inherit|ime-mode|image-orientation|image-resolution|image-rendering|icon|hyphens|height|font-weight|font-variant-ligatures|font-variant|font-style|font-stretch|font-size-adjust|font-size|font-language-override|font-kerning|font-feature-settings|font-family|font|float|flex-wrap|flex-shrink|flex-grow|flex-flow|flex-direction|flex-basis|flex|filter|empty-cells|display|direction|cursor|counter-reset|counter-increment|content|column-width|column-span|column-rule-width|column-rule-style|column-rule-color|column-rule|column-gap|column-fill|column-count|columns|color|clip-path|clip|clear|caption-side|break-inside|break-before|break-after|box-sizing|box-shadow|box-decoration-break|bottom|border-width|border-top-width|border-top-style|border-top-right-radius|border-top-left-radius|border-top-color|border-top|border-style|border-spacing|border-right-width|border-right-style|border-right-color|border-right|border-radius|border-left-width|border-left-style|border-left-color|border-left|border-image-width|border-image-source|border-image-slice|border-image-repeat|border-image-outset|border-image|border-color|border-collapse|border-bottom-width|border-bottom-style|border-bottom-right-radius|border-bottom-left-radius|border-bottom-color|border-bottom|border|background-size|background-repeat|background-position|background-origin|background-image|background-color|background-clip|background-attachment|background-blend-mode|background|backface-visibility|auto|animation-timing-function|animation-play-state|animation-name|animation-iteration-count|animation-fill-mode|animation-duration|animation-direction|animation-delay|animation|align-self|align-items|align-content)\\b",
+illegal:"[^\\s]"},{begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"},
+{begin:":",end:";",contains:[b,d,a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{className:"meta",begin:"!important"}]},{begin:"@",end:"[{;]",keywords:"mixin include extend for if else each while charset import debug media page content font-face namespace warn",contains:[b,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,d,a.CSS_NUMBER_MODE,{begin:"\\s[A-Za-z0-9_.-]+",relevance:0}]}]}});b.registerLanguage("shell",function(a){return{aliases:["console"],contains:[{className:"meta",begin:"^\\s{0,3}[\\w\\d\\[\\]()@-]*[>%$#]",
+starts:{end:"$",subLanguage:"bash"}}]}});b.registerLanguage("smali",function(a){var b="add and cmp cmpg cmpl const div double float goto if int long move mul neg new nop not or rem return shl shr sput sub throw ushr xor".split(" ");return{aliases:["smali"],contains:[{className:"string",begin:'"',end:'"',relevance:0},a.COMMENT("#","$",{relevance:0}),{className:"keyword",variants:[{begin:"\\s*\\.end\\s[a-zA-Z0-9]*"},{begin:"^[ ]*\\.[a-zA-Z]*",relevance:0},{begin:"\\s:[a-zA-Z_0-9]*",relevance:0},{begin:"\\s(transient|constructor|abstract|final|synthetic|public|private|protected|static|bridge|system)"}]},
+{className:"built_in",variants:[{begin:"\\s("+b.join("|")+")\\s"},{begin:"\\s("+b.join("|")+")((\\-|/)[a-zA-Z0-9]+)+\\s",relevance:10},{begin:"\\s(aget|aput|array|check|execute|fill|filled|goto/16|goto/32|iget|instance|invoke|iput|monitor|packed|sget|sparse)((\\-|/)[a-zA-Z0-9]+)*\\s",relevance:10}]},{className:"class",begin:"L[^(;:\n]*;",relevance:0},{begin:"[vp][0-9]+"}]}});b.registerLanguage("smalltalk",function(a){var b={className:"string",begin:"\\$.{1}"},d={className:"symbol",begin:"#"+a.UNDERSCORE_IDENT_RE};
+return{aliases:["st"],keywords:"self super nil true false thisContext",contains:[a.COMMENT('"','"'),a.APOS_STRING_MODE,{className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},{begin:"[a-z][a-zA-Z0-9_]*:",relevance:0},a.C_NUMBER_MODE,d,b,{begin:"\\|[ ]*[a-z][a-zA-Z0-9_]*([ ]+[a-z][a-zA-Z0-9_]*)*[ ]*\\|",returnBegin:!0,end:/\|/,illegal:/\S/,contains:[{begin:"(\\|[ ]*)?[a-z][a-zA-Z0-9_]*"}]},{begin:"\\#\\(",end:"\\)",contains:[a.APOS_STRING_MODE,b,a.C_NUMBER_MODE,d]}]}});b.registerLanguage("sml",
+function(a){return{aliases:["ml"],keywords:{keyword:"abstype and andalso as case datatype do else end eqtype exception fn fun functor handle if in include infix infixr let local nonfix of op open orelse raise rec sharing sig signature struct structure then type val with withtype where while",built_in:"array bool char exn int list option order real ref string substring vector unit word",literal:"true false NONE SOME LESS EQUAL GREATER nil"},illegal:/\/\/|>>/,lexemes:"[a-z_]\\w*!?",contains:[{className:"literal",
+begin:/\[(\|\|)?\]|\(\)/,relevance:0},a.COMMENT("\\(\\*","\\*\\)",{contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{className:"type",begin:"`[A-Z][\\w']*"},{className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*"},a.inherit(a.APOS_STRING_MODE,{className:"string",relevance:0}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),{className:"number",begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",
+relevance:0},{begin:/[-=]>/}]}});b.registerLanguage("sqf",function(a){var b=a.getLanguage("cpp").exports;return{aliases:["sqf"],case_insensitive:!0,keywords:{keyword:"case catch default do else exit exitWith for forEach from if private switch then throw to try waitUntil while with",built_in:"abs accTime acos action actionIDs actionKeys actionKeysImages actionKeysNames actionKeysNamesArray actionName actionParams activateAddons activatedAddons activateKey add3DENConnection add3DENEventHandler add3DENLayer addAction addBackpack addBackpackCargo addBackpackCargoGlobal addBackpackGlobal addCamShake addCuratorAddons addCuratorCameraArea addCuratorEditableObjects addCuratorEditingArea addCuratorPoints addEditorObject addEventHandler addForce addGoggles addGroupIcon addHandgunItem addHeadgear addItem addItemCargo addItemCargoGlobal addItemPool addItemToBackpack addItemToUniform addItemToVest addLiveStats addMagazine addMagazineAmmoCargo addMagazineCargo addMagazineCargoGlobal addMagazineGlobal addMagazinePool addMagazines addMagazineTurret addMenu addMenuItem addMissionEventHandler addMPEventHandler addMusicEventHandler addOwnedMine addPlayerScores addPrimaryWeaponItem addPublicVariableEventHandler addRating addResources addScore addScoreSide addSecondaryWeaponItem addSwitchableUnit addTeamMember addToRemainsCollector addTorque addUniform addVehicle addVest addWaypoint addWeapon addWeaponCargo addWeaponCargoGlobal addWeaponGlobal addWeaponItem addWeaponPool addWeaponTurret admin agent agents AGLToASL aimedAtTarget aimPos airDensityRTD airplaneThrottle airportSide AISFinishHeal alive all3DENEntities allAirports allControls allCurators allCutLayers allDead allDeadMen allDisplays allGroups allMapMarkers allMines allMissionObjects allow3DMode allowCrewInImmobile allowCuratorLogicIgnoreAreas allowDamage allowDammage allowFileOperations allowFleeing allowGetIn allowSprint allPlayers allSimpleObjects allSites allTurrets allUnits allUnitsUAV allVariables ammo ammoOnPylon and animate animateBay animateDoor animatePylon animateSource animationNames animationPhase animationSourcePhase animationState append apply armoryPoints arrayIntersect asin ASLToAGL ASLToATL assert assignAsCargo assignAsCargoIndex assignAsCommander assignAsDriver assignAsGunner assignAsTurret assignCurator assignedCargo assignedCommander assignedDriver assignedGunner assignedItems assignedTarget assignedTeam assignedVehicle assignedVehicleRole assignItem assignTeam assignToAirport atan atan2 atg ATLToASL attachedObject attachedObjects attachedTo attachObject attachTo attackEnabled backpack backpackCargo backpackContainer backpackItems backpackMagazines backpackSpaceFor behaviour benchmark binocular boundingBox boundingBoxReal boundingCenter breakOut breakTo briefingName buildingExit buildingPos buttonAction buttonSetAction cadetMode call callExtension camCommand camCommit camCommitPrepared camCommitted camConstuctionSetParams camCreate camDestroy cameraEffect cameraEffectEnableHUD cameraInterest cameraOn cameraView campaignConfigFile camPreload camPreloaded camPrepareBank camPrepareDir camPrepareDive camPrepareFocus camPrepareFov camPrepareFovRange camPreparePos camPrepareRelPos camPrepareTarget camSetBank camSetDir camSetDive camSetFocus camSetFov camSetFovRange camSetPos camSetRelPos camSetTarget camTarget camUseNVG canAdd canAddItemToBackpack canAddItemToUniform canAddItemToVest cancelSimpleTaskDestination canFire canMove canSlingLoad canStand canSuspend canTriggerDynamicSimulation canUnloadInCombat canVehicleCargo captive captiveNum cbChecked cbSetChecked ceil channelEnabled cheatsEnabled checkAIFeature checkVisibility className clearAllItemsFromBackpack clearBackpackCargo clearBackpackCargoGlobal clearGroupIcons clearItemCargo clearItemCargoGlobal clearItemPool clearMagazineCargo clearMagazineCargoGlobal clearMagazinePool clearOverlay clearRadio clearWeaponCargo clearWeaponCargoGlobal clearWeaponPool clientOwner closeDialog closeDisplay closeOverlay collapseObjectTree collect3DENHistory collectiveRTD combatMode commandArtilleryFire commandChat commander commandFire commandFollow commandFSM commandGetOut commandingMenu commandMove commandRadio commandStop commandSuppressiveFire commandTarget commandWatch comment commitOverlay compile compileFinal completedFSM composeText configClasses configFile configHierarchy configName configProperties configSourceAddonList configSourceMod configSourceModList confirmSensorTarget connectTerminalToUAV controlsGroupCtrl copyFromClipboard copyToClipboard copyWaypoints cos count countEnemy countFriendly countSide countType countUnknown create3DENComposition create3DENEntity createAgent createCenter createDialog createDiaryLink createDiaryRecord createDiarySubject createDisplay createGearDialog createGroup createGuardedPoint createLocation createMarker createMarkerLocal createMenu createMine createMissionDisplay createMPCampaignDisplay createSimpleObject createSimpleTask createSite createSoundSource createTask createTeam createTrigger createUnit createVehicle createVehicleCrew createVehicleLocal crew ctAddHeader ctAddRow ctClear ctCurSel ctData ctFindHeaderRows ctFindRowHeader ctHeaderControls ctHeaderCount ctRemoveHeaders ctRemoveRows ctrlActivate ctrlAddEventHandler ctrlAngle ctrlAutoScrollDelay ctrlAutoScrollRewind ctrlAutoScrollSpeed ctrlChecked ctrlClassName ctrlCommit ctrlCommitted ctrlCreate ctrlDelete ctrlEnable ctrlEnabled ctrlFade ctrlHTMLLoaded ctrlIDC ctrlIDD ctrlMapAnimAdd ctrlMapAnimClear ctrlMapAnimCommit ctrlMapAnimDone ctrlMapCursor ctrlMapMouseOver ctrlMapScale ctrlMapScreenToWorld ctrlMapWorldToScreen ctrlModel ctrlModelDirAndUp ctrlModelScale ctrlParent ctrlParentControlsGroup ctrlPosition ctrlRemoveAllEventHandlers ctrlRemoveEventHandler ctrlScale ctrlSetActiveColor ctrlSetAngle ctrlSetAutoScrollDelay ctrlSetAutoScrollRewind ctrlSetAutoScrollSpeed ctrlSetBackgroundColor ctrlSetChecked ctrlSetEventHandler ctrlSetFade ctrlSetFocus ctrlSetFont ctrlSetFontH1 ctrlSetFontH1B ctrlSetFontH2 ctrlSetFontH2B ctrlSetFontH3 ctrlSetFontH3B ctrlSetFontH4 ctrlSetFontH4B ctrlSetFontH5 ctrlSetFontH5B ctrlSetFontH6 ctrlSetFontH6B ctrlSetFontHeight ctrlSetFontHeightH1 ctrlSetFontHeightH2 ctrlSetFontHeightH3 ctrlSetFontHeightH4 ctrlSetFontHeightH5 ctrlSetFontHeightH6 ctrlSetFontHeightSecondary ctrlSetFontP ctrlSetFontPB ctrlSetFontSecondary ctrlSetForegroundColor ctrlSetModel ctrlSetModelDirAndUp ctrlSetModelScale ctrlSetPixelPrecision ctrlSetPosition ctrlSetScale ctrlSetStructuredText ctrlSetText ctrlSetTextColor ctrlSetTooltip ctrlSetTooltipColorBox ctrlSetTooltipColorShade ctrlSetTooltipColorText ctrlShow ctrlShown ctrlText ctrlTextHeight ctrlTextWidth ctrlType ctrlVisible ctRowControls ctRowCount ctSetCurSel ctSetData ctSetHeaderTemplate ctSetRowTemplate ctSetValue ctValue curatorAddons curatorCamera curatorCameraArea curatorCameraAreaCeiling curatorCoef curatorEditableObjects curatorEditingArea curatorEditingAreaType curatorMouseOver curatorPoints curatorRegisteredObjects curatorSelected curatorWaypointCost current3DENOperation currentChannel currentCommand currentMagazine currentMagazineDetail currentMagazineDetailTurret currentMagazineTurret currentMuzzle currentNamespace currentTask currentTasks currentThrowable currentVisionMode currentWaypoint currentWeapon currentWeaponMode currentWeaponTurret currentZeroing cursorObject cursorTarget customChat customRadio cutFadeOut cutObj cutRsc cutText damage date dateToNumber daytime deActivateKey debriefingText debugFSM debugLog deg delete3DENEntities deleteAt deleteCenter deleteCollection deleteEditorObject deleteGroup deleteGroupWhenEmpty deleteIdentity deleteLocation deleteMarker deleteMarkerLocal deleteRange deleteResources deleteSite deleteStatus deleteTeam deleteVehicle deleteVehicleCrew deleteWaypoint detach detectedMines diag_activeMissionFSMs diag_activeScripts diag_activeSQFScripts diag_activeSQSScripts diag_captureFrame diag_captureFrameToFile diag_captureSlowFrame diag_codePerformance diag_drawMode diag_enable diag_enabled diag_fps diag_fpsMin diag_frameNo diag_lightNewLoad diag_list diag_log diag_logSlowFrame diag_mergeConfigFile diag_recordTurretLimits diag_setLightNew diag_tickTime diag_toggle dialog diarySubjectExists didJIP didJIPOwner difficulty difficultyEnabled difficultyEnabledRTD difficultyOption direction directSay disableAI disableCollisionWith disableConversation disableDebriefingStats disableMapIndicators disableNVGEquipment disableRemoteSensors disableSerialization disableTIEquipment disableUAVConnectability disableUserInput displayAddEventHandler displayCtrl displayParent displayRemoveAllEventHandlers displayRemoveEventHandler displaySetEventHandler dissolveTeam distance distance2D distanceSqr distributionRegion do3DENAction doArtilleryFire doFire doFollow doFSM doGetOut doMove doorPhase doStop doSuppressiveFire doTarget doWatch drawArrow drawEllipse drawIcon drawIcon3D drawLine drawLine3D drawLink drawLocation drawPolygon drawRectangle drawTriangle driver drop dynamicSimulationDistance dynamicSimulationDistanceCoef dynamicSimulationEnabled dynamicSimulationSystemEnabled echo edit3DENMissionAttributes editObject editorSetEventHandler effectiveCommander emptyPositions enableAI enableAIFeature enableAimPrecision enableAttack enableAudioFeature enableAutoStartUpRTD enableAutoTrimRTD enableCamShake enableCaustics enableChannel enableCollisionWith enableCopilot enableDebriefingStats enableDiagLegend enableDynamicSimulation enableDynamicSimulationSystem enableEndDialog enableEngineArtillery enableEnvironment enableFatigue enableGunLights enableInfoPanelComponent enableIRLasers enableMimics enablePersonTurret enableRadio enableReload enableRopeAttach enableSatNormalOnDetail enableSaving enableSentences enableSimulation enableSimulationGlobal enableStamina enableTeamSwitch enableTraffic enableUAVConnectability enableUAVWaypoints enableVehicleCargo enableVehicleSensor enableWeaponDisassembly endLoadingScreen endMission engineOn enginesIsOnRTD enginesRpmRTD enginesTorqueRTD entities environmentEnabled estimatedEndServerTime estimatedTimeLeft evalObjectArgument everyBackpack everyContainer exec execEditorScript execFSM execVM exp expectedDestination exportJIPMessages eyeDirection eyePos face faction fadeMusic fadeRadio fadeSound fadeSpeech failMission fillWeaponsFromPool find findCover findDisplay findEditorObject findEmptyPosition findEmptyPositionReady findIf findNearestEnemy finishMissionInit finite fire fireAtTarget firstBackpack flag flagAnimationPhase flagOwner flagSide flagTexture fleeing floor flyInHeight flyInHeightASL fog fogForecast fogParams forceAddUniform forcedMap forceEnd forceFlagTexture forceFollowRoad forceMap forceRespawn forceSpeed forceWalk forceWeaponFire forceWeatherChange forEachMember forEachMemberAgent forEachMemberTeam forgetTarget format formation formationDirection formationLeader formationMembers formationPosition formationTask formatText formLeader freeLook fromEditor fuel fullCrew gearIDCAmmoCount gearSlotAmmoCount gearSlotData get3DENActionState get3DENAttribute get3DENCamera get3DENConnections get3DENEntity get3DENEntityID get3DENGrid get3DENIconsVisible get3DENLayerEntities get3DENLinesVisible get3DENMissionAttribute get3DENMouseOver get3DENSelected getAimingCoef getAllEnvSoundControllers getAllHitPointsDamage getAllOwnedMines getAllSoundControllers getAmmoCargo getAnimAimPrecision getAnimSpeedCoef getArray getArtilleryAmmo getArtilleryComputerSettings getArtilleryETA getAssignedCuratorLogic getAssignedCuratorUnit getBackpackCargo getBleedingRemaining getBurningValue getCameraViewDirection getCargoIndex getCenterOfMass getClientState getClientStateNumber getCompatiblePylonMagazines getConnectedUAV getContainerMaxLoad getCursorObjectParams getCustomAimCoef getDammage getDescription getDir getDirVisual getDLCAssetsUsage getDLCAssetsUsageByName getDLCs getEditorCamera getEditorMode getEditorObjectScope getElevationOffset getEnvSoundController getFatigue getForcedFlagTexture getFriend getFSMVariable getFuelCargo getGroupIcon getGroupIconParams getGroupIcons getHideFrom getHit getHitIndex getHitPointDamage getItemCargo getMagazineCargo getMarkerColor getMarkerPos getMarkerSize getMarkerType getMass getMissionConfig getMissionConfigValue getMissionDLCs getMissionLayerEntities getModelInfo getMousePosition getMusicPlayedTime getNumber getObjectArgument getObjectChildren getObjectDLC getObjectMaterials getObjectProxy getObjectTextures getObjectType getObjectViewDistance getOxygenRemaining getPersonUsedDLCs getPilotCameraDirection getPilotCameraPosition getPilotCameraRotation getPilotCameraTarget getPlateNumber getPlayerChannel getPlayerScores getPlayerUID getPos getPosASL getPosASLVisual getPosASLW getPosATL getPosATLVisual getPosVisual getPosWorld getPylonMagazines getRelDir getRelPos getRemoteSensorsDisabled getRepairCargo getResolution getShadowDistance getShotParents getSlingLoad getSoundController getSoundControllerResult getSpeed getStamina getStatValue getSuppression getTerrainGrid getTerrainHeightASL getText getTotalDLCUsageTime getUnitLoadout getUnitTrait getUserMFDText getUserMFDvalue getVariable getVehicleCargo getWeaponCargo getWeaponSway getWingsOrientationRTD getWingsPositionRTD getWPPos glanceAt globalChat globalRadio goggles goto group groupChat groupFromNetId groupIconSelectable groupIconsVisible groupId groupOwner groupRadio groupSelectedUnits groupSelectUnit gunner gusts halt handgunItems handgunMagazine handgunWeapon handsHit hasInterface hasPilotCamera hasWeapon hcAllGroups hcGroupParams hcLeader hcRemoveAllGroups hcRemoveGroup hcSelected hcSelectGroup hcSetGroup hcShowBar hcShownBar headgear hideBody hideObject hideObjectGlobal hideSelection hint hintC hintCadet hintSilent hmd hostMission htmlLoad HUDMovementLevels humidity image importAllGroups importance in inArea inAreaArray incapacitatedState inflame inflamed infoPanel infoPanelComponentEnabled infoPanelComponents infoPanels inGameUISetEventHandler inheritsFrom initAmbientLife inPolygon inputAction inRangeOfArtillery insertEditorObject intersect is3DEN is3DENMultiplayer isAbleToBreathe isAgent isArray isAutoHoverOn isAutonomous isAutotest isBleeding isBurning isClass isCollisionLightOn isCopilotEnabled isDamageAllowed isDedicated isDLCAvailable isEngineOn isEqualTo isEqualType isEqualTypeAll isEqualTypeAny isEqualTypeArray isEqualTypeParams isFilePatchingEnabled isFlashlightOn isFlatEmpty isForcedWalk isFormationLeader isGroupDeletedWhenEmpty isHidden isInRemainsCollector isInstructorFigureEnabled isIRLaserOn isKeyActive isKindOf isLaserOn isLightOn isLocalized isManualFire isMarkedForCollection isMultiplayer isMultiplayerSolo isNil isNull isNumber isObjectHidden isObjectRTD isOnRoad isPipEnabled isPlayer isRealTime isRemoteExecuted isRemoteExecutedJIP isServer isShowing3DIcons isSimpleObject isSprintAllowed isStaminaEnabled isSteamMission isStreamFriendlyUIEnabled isText isTouchingGround isTurnedOut isTutHintsEnabled isUAVConnectable isUAVConnected isUIContext isUniformAllowed isVehicleCargo isVehicleRadarOn isVehicleSensorEnabled isWalking isWeaponDeployed isWeaponRested itemCargo items itemsWithMagazines join joinAs joinAsSilent joinSilent joinString kbAddDatabase kbAddDatabaseTargets kbAddTopic kbHasTopic kbReact kbRemoveTopic kbTell kbWasSaid keyImage keyName knowsAbout land landAt landResult language laserTarget lbAdd lbClear lbColor lbColorRight lbCurSel lbData lbDelete lbIsSelected lbPicture lbPictureRight lbSelection lbSetColor lbSetColorRight lbSetCurSel lbSetData lbSetPicture lbSetPictureColor lbSetPictureColorDisabled lbSetPictureColorSelected lbSetPictureRight lbSetPictureRightColor lbSetPictureRightColorDisabled lbSetPictureRightColorSelected lbSetSelectColor lbSetSelectColorRight lbSetSelected lbSetText lbSetTextRight lbSetTooltip lbSetValue lbSize lbSort lbSortByValue lbText lbTextRight lbValue leader leaderboardDeInit leaderboardGetRows leaderboardInit leaderboardRequestRowsFriends leaderboardsRequestUploadScore leaderboardsRequestUploadScoreKeepBest leaderboardState leaveVehicle libraryCredits libraryDisclaimers lifeState lightAttachObject lightDetachObject lightIsOn lightnings limitSpeed linearConversion lineIntersects lineIntersectsObjs lineIntersectsSurfaces lineIntersectsWith linkItem list listObjects listRemoteTargets listVehicleSensors ln lnbAddArray lnbAddColumn lnbAddRow lnbClear lnbColor lnbCurSelRow lnbData lnbDeleteColumn lnbDeleteRow lnbGetColumnsPosition lnbPicture lnbSetColor lnbSetColumnsPos lnbSetCurSelRow lnbSetData lnbSetPicture lnbSetText lnbSetValue lnbSize lnbSort lnbSortByValue lnbText lnbValue load loadAbs loadBackpack loadFile loadGame loadIdentity loadMagazine loadOverlay loadStatus loadUniform loadVest local localize locationPosition lock lockCameraTo lockCargo lockDriver locked lockedCargo lockedDriver lockedTurret lockIdentity lockTurret lockWP log logEntities logNetwork logNetworkTerminate lookAt lookAtPos magazineCargo magazines magazinesAllTurrets magazinesAmmo magazinesAmmoCargo magazinesAmmoFull magazinesDetail magazinesDetailBackpack magazinesDetailUniform magazinesDetailVest magazinesTurret magazineTurretAmmo mapAnimAdd mapAnimClear mapAnimCommit mapAnimDone mapCenterOnCamera mapGridPosition markAsFinishedOnSteam markerAlpha markerBrush markerColor markerDir markerPos markerShape markerSize markerText markerType max members menuAction menuAdd menuChecked menuClear menuCollapse menuData menuDelete menuEnable menuEnabled menuExpand menuHover menuPicture menuSetAction menuSetCheck menuSetData menuSetPicture menuSetValue menuShortcut menuShortcutText menuSize menuSort menuText menuURL menuValue min mineActive mineDetectedBy missionConfigFile missionDifficulty missionName missionNamespace missionStart missionVersion mod modelToWorld modelToWorldVisual modelToWorldVisualWorld modelToWorldWorld modParams moonIntensity moonPhase morale move move3DENCamera moveInAny moveInCargo moveInCommander moveInDriver moveInGunner moveInTurret moveObjectToEnd moveOut moveTime moveTo moveToCompleted moveToFailed musicVolume name nameSound nearEntities nearestBuilding nearestLocation nearestLocations nearestLocationWithDubbing nearestObject nearestObjects nearestTerrainObjects nearObjects nearObjectsReady nearRoads nearSupplies nearTargets needReload netId netObjNull newOverlay nextMenuItemIndex nextWeatherChange nMenuItems not numberOfEnginesRTD numberToDate objectCurators objectFromNetId objectParent objStatus onBriefingGroup onBriefingNotes onBriefingPlan onBriefingTeamSwitch onCommandModeChanged onDoubleClick onEachFrame onGroupIconClick onGroupIconOverEnter onGroupIconOverLeave onHCGroupSelectionChanged onMapSingleClick onPlayerConnected onPlayerDisconnected onPreloadFinished onPreloadStarted onShowNewObject onTeamSwitch openCuratorInterface openDLCPage openMap openSteamApp openYoutubeVideo or orderGetIn overcast overcastForecast owner param params parseNumber parseSimpleArray parseText parsingNamespace particlesQuality pickWeaponPool pitch pixelGrid pixelGridBase pixelGridNoUIScale pixelH pixelW playableSlotsNumber playableUnits playAction playActionNow player playerRespawnTime playerSide playersNumber playGesture playMission playMove playMoveNow playMusic playScriptedMission playSound playSound3D position positionCameraToWorld posScreenToWorld posWorldToScreen ppEffectAdjust ppEffectCommit ppEffectCommitted ppEffectCreate ppEffectDestroy ppEffectEnable ppEffectEnabled ppEffectForceInNVG precision preloadCamera preloadObject preloadSound preloadTitleObj preloadTitleRsc preprocessFile preprocessFileLineNumbers primaryWeapon primaryWeaponItems primaryWeaponMagazine priority processDiaryLink productVersion profileName profileNamespace profileNameSteam progressLoadingScreen progressPosition progressSetPosition publicVariable publicVariableClient publicVariableServer pushBack pushBackUnique putWeaponPool queryItemsPool queryMagazinePool queryWeaponPool rad radioChannelAdd radioChannelCreate radioChannelRemove radioChannelSetCallSign radioChannelSetLabel radioVolume rain rainbow random rank rankId rating rectangular registeredTasks registerTask reload reloadEnabled remoteControl remoteExec remoteExecCall remoteExecutedOwner remove3DENConnection remove3DENEventHandler remove3DENLayer removeAction removeAll3DENEventHandlers removeAllActions removeAllAssignedItems removeAllContainers removeAllCuratorAddons removeAllCuratorCameraAreas removeAllCuratorEditingAreas removeAllEventHandlers removeAllHandgunItems removeAllItems removeAllItemsWithMagazines removeAllMissionEventHandlers removeAllMPEventHandlers removeAllMusicEventHandlers removeAllOwnedMines removeAllPrimaryWeaponItems removeAllWeapons removeBackpack removeBackpackGlobal removeCuratorAddons removeCuratorCameraArea removeCuratorEditableObjects removeCuratorEditingArea removeDrawIcon removeDrawLinks removeEventHandler removeFromRemainsCollector removeGoggles removeGroupIcon removeHandgunItem removeHeadgear removeItem removeItemFromBackpack removeItemFromUniform removeItemFromVest removeItems removeMagazine removeMagazineGlobal removeMagazines removeMagazinesTurret removeMagazineTurret removeMenuItem removeMissionEventHandler removeMPEventHandler removeMusicEventHandler removeOwnedMine removePrimaryWeaponItem removeSecondaryWeaponItem removeSimpleTask removeSwitchableUnit removeTeamMember removeUniform removeVest removeWeapon removeWeaponAttachmentCargo removeWeaponCargo removeWeaponGlobal removeWeaponTurret reportRemoteTarget requiredVersion resetCamShake resetSubgroupDirection resize resources respawnVehicle restartEditorCamera reveal revealMine reverse reversedMouseY roadAt roadsConnectedTo roleDescription ropeAttachedObjects ropeAttachedTo ropeAttachEnabled ropeAttachTo ropeCreate ropeCut ropeDestroy ropeDetach ropeEndPosition ropeLength ropes ropeUnwind ropeUnwound rotorsForcesRTD rotorsRpmRTD round runInitScript safeZoneH safeZoneW safeZoneWAbs safeZoneX safeZoneXAbs safeZoneY save3DENInventory saveGame saveIdentity saveJoysticks saveOverlay saveProfileNamespace saveStatus saveVar savingEnabled say say2D say3D scopeName score scoreSide screenshot screenToWorld scriptDone scriptName scudState secondaryWeapon secondaryWeaponItems secondaryWeaponMagazine select selectBestPlaces selectDiarySubject selectedEditorObjects selectEditorObject selectionNames selectionPosition selectLeader selectMax selectMin selectNoPlayer selectPlayer selectRandom selectRandomWeighted selectWeapon selectWeaponTurret sendAUMessage sendSimpleCommand sendTask sendTaskResult sendUDPMessage serverCommand serverCommandAvailable serverCommandExecutable serverName serverTime set set3DENAttribute set3DENAttributes set3DENGrid set3DENIconsVisible set3DENLayer set3DENLinesVisible set3DENLogicType set3DENMissionAttribute set3DENMissionAttributes set3DENModelsVisible set3DENObjectType set3DENSelected setAccTime setActualCollectiveRTD setAirplaneThrottle setAirportSide setAmmo setAmmoCargo setAmmoOnPylon setAnimSpeedCoef setAperture setApertureNew setArmoryPoints setAttributes setAutonomous setBehaviour setBleedingRemaining setBrakesRTD setCameraInterest setCamShakeDefParams setCamShakeParams setCamUseTI setCaptive setCenterOfMass setCollisionLight setCombatMode setCompassOscillation setConvoySeparation setCuratorCameraAreaCeiling setCuratorCoef setCuratorEditingAreaType setCuratorWaypointCost setCurrentChannel setCurrentTask setCurrentWaypoint setCustomAimCoef setCustomWeightRTD setDamage setDammage setDate setDebriefingText setDefaultCamera setDestination setDetailMapBlendPars setDir setDirection setDrawIcon setDriveOnPath setDropInterval setDynamicSimulationDistance setDynamicSimulationDistanceCoef setEditorMode setEditorObjectScope setEffectCondition setEngineRPMRTD setFace setFaceAnimation setFatigue setFeatureType setFlagAnimationPhase setFlagOwner setFlagSide setFlagTexture setFog setFormation setFormationTask setFormDir setFriend setFromEditor setFSMVariable setFuel setFuelCargo setGroupIcon setGroupIconParams setGroupIconsSelectable setGroupIconsVisible setGroupId setGroupIdGlobal setGroupOwner setGusts setHideBehind setHit setHitIndex setHitPointDamage setHorizonParallaxCoef setHUDMovementLevels setIdentity setImportance setInfoPanel setLeader setLightAmbient setLightAttenuation setLightBrightness setLightColor setLightDayLight setLightFlareMaxDistance setLightFlareSize setLightIntensity setLightnings setLightUseFlare setLocalWindParams setMagazineTurretAmmo setMarkerAlpha setMarkerAlphaLocal setMarkerBrush setMarkerBrushLocal setMarkerColor setMarkerColorLocal setMarkerDir setMarkerDirLocal setMarkerPos setMarkerPosLocal setMarkerShape setMarkerShapeLocal setMarkerSize setMarkerSizeLocal setMarkerText setMarkerTextLocal setMarkerType setMarkerTypeLocal setMass setMimic setMousePosition setMusicEffect setMusicEventHandler setName setNameSound setObjectArguments setObjectMaterial setObjectMaterialGlobal setObjectProxy setObjectTexture setObjectTextureGlobal setObjectViewDistance setOvercast setOwner setOxygenRemaining setParticleCircle setParticleClass setParticleFire setParticleParams setParticleRandom setPilotCameraDirection setPilotCameraRotation setPilotCameraTarget setPilotLight setPiPEffect setPitch setPlateNumber setPlayable setPlayerRespawnTime setPos setPosASL setPosASL2 setPosASLW setPosATL setPosition setPosWorld setPylonLoadOut setPylonsPriority setRadioMsg setRain setRainbow setRandomLip setRank setRectangular setRepairCargo setRotorBrakeRTD setShadowDistance setShotParents setSide setSimpleTaskAlwaysVisible setSimpleTaskCustomData setSimpleTaskDescription setSimpleTaskDestination setSimpleTaskTarget setSimpleTaskType setSimulWeatherLayers setSize setSkill setSlingLoad setSoundEffect setSpeaker setSpeech setSpeedMode setStamina setStaminaScheme setStatValue setSuppression setSystemOfUnits setTargetAge setTaskMarkerOffset setTaskResult setTaskState setTerrainGrid setText setTimeMultiplier setTitleEffect setTrafficDensity setTrafficDistance setTrafficGap setTrafficSpeed setTriggerActivation setTriggerArea setTriggerStatements setTriggerText setTriggerTimeout setTriggerType setType setUnconscious setUnitAbility setUnitLoadout setUnitPos setUnitPosWeak setUnitRank setUnitRecoilCoefficient setUnitTrait setUnloadInCombat setUserActionText setUserMFDText setUserMFDvalue setVariable setVectorDir setVectorDirAndUp setVectorUp setVehicleAmmo setVehicleAmmoDef setVehicleArmor setVehicleCargo setVehicleId setVehicleLock setVehiclePosition setVehicleRadar setVehicleReceiveRemoteTargets setVehicleReportOwnPosition setVehicleReportRemoteTargets setVehicleTIPars setVehicleVarName setVelocity setVelocityModelSpace setVelocityTransformation setViewDistance setVisibleIfTreeCollapsed setWantedRPMRTD setWaves setWaypointBehaviour setWaypointCombatMode setWaypointCompletionRadius setWaypointDescription setWaypointForceBehaviour setWaypointFormation setWaypointHousePosition setWaypointLoiterRadius setWaypointLoiterType setWaypointName setWaypointPosition setWaypointScript setWaypointSpeed setWaypointStatements setWaypointTimeout setWaypointType setWaypointVisible setWeaponReloadingTime setWind setWindDir setWindForce setWindStr setWingForceScaleRTD setWPPos show3DIcons showChat showCinemaBorder showCommandingMenu showCompass showCuratorCompass showGPS showHUD showLegend showMap shownArtilleryComputer shownChat shownCompass shownCuratorCompass showNewEditorObject shownGPS shownHUD shownMap shownPad shownRadio shownScoretable shownUAVFeed shownWarrant shownWatch showPad showRadio showScoretable showSubtitles showUAVFeed showWarrant showWatch showWaypoint showWaypoints side sideChat sideEnemy sideFriendly sideRadio simpleTasks simulationEnabled simulCloudDensity simulCloudOcclusion simulInClouds simulWeatherSync sin size sizeOf skill skillFinal skipTime sleep sliderPosition sliderRange sliderSetPosition sliderSetRange sliderSetSpeed sliderSpeed slingLoadAssistantShown soldierMagazines someAmmo sort soundVolume spawn speaker speed speedMode splitString sqrt squadParams stance startLoadingScreen step stop stopEngineRTD stopped str sunOrMoon supportInfo suppressFor surfaceIsWater surfaceNormal surfaceType swimInDepth switchableUnits switchAction switchCamera switchGesture switchLight switchMove synchronizedObjects synchronizedTriggers synchronizedWaypoints synchronizeObjectsAdd synchronizeObjectsRemove synchronizeTrigger synchronizeWaypoint systemChat systemOfUnits tan targetKnowledge targets targetsAggregate targetsQuery taskAlwaysVisible taskChildren taskCompleted taskCustomData taskDescription taskDestination taskHint taskMarkerOffset taskParent taskResult taskState taskType teamMember teamName teams teamSwitch teamSwitchEnabled teamType terminate terrainIntersect terrainIntersectASL terrainIntersectAtASL text textLog textLogFormat tg time timeMultiplier titleCut titleFadeOut titleObj titleRsc titleText toArray toFixed toLower toString toUpper triggerActivated triggerActivation triggerArea triggerAttachedVehicle triggerAttachObject triggerAttachVehicle triggerDynamicSimulation triggerStatements triggerText triggerTimeout triggerTimeoutCurrent triggerType turretLocal turretOwner turretUnit tvAdd tvClear tvCollapse tvCollapseAll tvCount tvCurSel tvData tvDelete tvExpand tvExpandAll tvPicture tvSetColor tvSetCurSel tvSetData tvSetPicture tvSetPictureColor tvSetPictureColorDisabled tvSetPictureColorSelected tvSetPictureRight tvSetPictureRightColor tvSetPictureRightColorDisabled tvSetPictureRightColorSelected tvSetText tvSetTooltip tvSetValue tvSort tvSortByValue tvText tvTooltip tvValue type typeName typeOf UAVControl uiNamespace uiSleep unassignCurator unassignItem unassignTeam unassignVehicle underwater uniform uniformContainer uniformItems uniformMagazines unitAddons unitAimPosition unitAimPositionVisual unitBackpack unitIsUAV unitPos unitReady unitRecoilCoefficient units unitsBelowHeight unlinkItem unlockAchievement unregisterTask updateDrawIcon updateMenuItem updateObjectTree useAISteeringComponent useAudioTimeForMoves userInputDisabled vectorAdd vectorCos vectorCrossProduct vectorDiff vectorDir vectorDirVisual vectorDistance vectorDistanceSqr vectorDotProduct vectorFromTo vectorMagnitude vectorMagnitudeSqr vectorModelToWorld vectorModelToWorldVisual vectorMultiply vectorNormalized vectorUp vectorUpVisual vectorWorldToModel vectorWorldToModelVisual vehicle vehicleCargoEnabled vehicleChat vehicleRadio vehicleReceiveRemoteTargets vehicleReportOwnPosition vehicleReportRemoteTargets vehicles vehicleVarName velocity velocityModelSpace verifySignature vest vestContainer vestItems vestMagazines viewDistance visibleCompass visibleGPS visibleMap visiblePosition visiblePositionASL visibleScoretable visibleWatch waves waypointAttachedObject waypointAttachedVehicle waypointAttachObject waypointAttachVehicle waypointBehaviour waypointCombatMode waypointCompletionRadius waypointDescription waypointForceBehaviour waypointFormation waypointHousePosition waypointLoiterRadius waypointLoiterType waypointName waypointPosition waypoints waypointScript waypointsEnabledUAV waypointShow waypointSpeed waypointStatements waypointTimeout waypointTimeoutCurrent waypointType waypointVisible weaponAccessories weaponAccessoriesCargo weaponCargo weaponDirection weaponInertia weaponLowered weapons weaponsItems weaponsItemsCargo weaponState weaponsTurret weightRTD WFSideText wind ",
+literal:"blufor civilian configNull controlNull displayNull east endl false grpNull independent lineBreak locationNull nil objNull opfor pi resistance scriptNull sideAmbientLife sideEmpty sideLogic sideUnknown taskNull teamMemberNull true west"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.NUMBER_MODE,{className:"variable",begin:/\b_+[a-zA-Z_]\w*/},{className:"title",begin:/[a-zA-Z][a-zA-Z0-9]+_fnc_\w*/},{className:"string",variants:[{begin:'"',end:'"',contains:[{begin:'""',relevance:0}]},
+{begin:"'",end:"'",contains:[{begin:"''",relevance:0}]}]},b.preprocessor],illegal:/#|^\$ /}});b.registerLanguage("sql",function(a){var b=a.COMMENT("--","$");return{case_insensitive:!0,illegal:/[<>{}*]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment values with",
+end:/;/,endsWithParent:!0,lexemes:/[\w\.]+/,keywords:{keyword:"as abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias all allocate allow alter always analyze ancillary and anti any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound bucket buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain explode export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force foreign form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour hours http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lateral lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minutes minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notnull notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second seconds section securefile security seed segment select self semi sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tablesample tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unnest unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace window with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",
+literal:"true false null unknown",built_in:"array bigint binary bit blob bool boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text time timestamp tinyint varchar varying void"},contains:[{className:"string",begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE,{begin:"''"}]},{className:"string",begin:'"',end:'"',contains:[a.BACKSLASH_ESCAPE,{begin:'""'}]},{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},a.C_NUMBER_MODE,
+a.C_BLOCK_COMMENT_MODE,b,a.HASH_COMMENT_MODE]},a.C_BLOCK_COMMENT_MODE,b,a.HASH_COMMENT_MODE]}});b.registerLanguage("stan",function(a){return{contains:[a.HASH_COMMENT_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{begin:a.UNDERSCORE_IDENT_RE,lexemes:a.UNDERSCORE_IDENT_RE,keywords:{name:"for in while repeat until if then else",symbol:"bernoulli bernoulli_logit binomial binomial_logit beta_binomial hypergeometric categorical categorical_logit ordered_logistic neg_binomial neg_binomial_2 neg_binomial_2_log poisson poisson_log multinomial normal exp_mod_normal skew_normal student_t cauchy double_exponential logistic gumbel lognormal chi_square inv_chi_square scaled_inv_chi_square exponential inv_gamma weibull frechet rayleigh wiener pareto pareto_type_2 von_mises uniform multi_normal multi_normal_prec multi_normal_cholesky multi_gp multi_gp_cholesky multi_student_t gaussian_dlm_obs dirichlet lkj_corr lkj_corr_cholesky wishart inv_wishart",
+"selector-tag":"int real vector simplex unit_vector ordered positive_ordered row_vector matrix cholesky_factor_corr cholesky_factor_cov corr_matrix cov_matrix",title:"functions model data parameters quantities transformed generated",literal:"true false"},relevance:0},{className:"number",begin:"0[xX][0-9a-fA-F]+[Li]?\\b",relevance:0},{className:"number",begin:"0[xX][0-9a-fA-F]+[Li]?\\b",relevance:0},{className:"number",begin:"\\d+(?:[eE][+\\-]?\\d*)?L\\b",relevance:0},{className:"number",begin:"\\d+\\.(?!\\d)(?:i\\b)?",
+relevance:0},{className:"number",begin:"\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0},{className:"number",begin:"\\.\\d+(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0}]}});b.registerLanguage("stata",function(a){return{aliases:["do","ado"],case_insensitive:!0,keywords:"if else in foreach for forv forva forval forvalu forvalue forvalues by bys bysort xi quietly qui capture about ac ac_7 acprplot acprplot_7 adjust ado adopath adoupdate alpha ameans an ano anov anova anova_estat anova_terms anovadef aorder ap app appe appen append arch arch_dr arch_estat arch_p archlm areg areg_p args arima arima_dr arima_estat arima_p as asmprobit asmprobit_estat asmprobit_lf asmprobit_mfx__dlg asmprobit_p ass asse asser assert avplot avplot_7 avplots avplots_7 bcskew0 bgodfrey binreg bip0_lf biplot bipp_lf bipr_lf bipr_p biprobit bitest bitesti bitowt blogit bmemsize boot bootsamp bootstrap bootstrap_8 boxco_l boxco_p boxcox boxcox_6 boxcox_p bprobit br break brier bro brow brows browse brr brrstat bs bs_7 bsampl_w bsample bsample_7 bsqreg bstat bstat_7 bstat_8 bstrap bstrap_7 ca ca_estat ca_p cabiplot camat canon canon_8 canon_8_p canon_estat canon_p cap caprojection capt captu captur capture cat cc cchart cchart_7 cci cd censobs_table centile cf char chdir checkdlgfiles checkestimationsample checkhlpfiles checksum chelp ci cii cl class classutil clear cli clis clist clo clog clog_lf clog_p clogi clogi_sw clogit clogit_lf clogit_p clogitp clogl_sw cloglog clonevar clslistarray cluster cluster_measures cluster_stop cluster_tree cluster_tree_8 clustermat cmdlog cnr cnre cnreg cnreg_p cnreg_sw cnsreg codebook collaps4 collapse colormult_nb colormult_nw compare compress conf confi confir confirm conren cons const constr constra constrai constrain constraint continue contract copy copyright copysource cor corc corr corr2data corr_anti corr_kmo corr_smc corre correl correla correlat correlate corrgram cou coun count cox cox_p cox_sw coxbase coxhaz coxvar cprplot cprplot_7 crc cret cretu cretur creturn cross cs cscript cscript_log csi ct ct_is ctset ctst_5 ctst_st cttost cumsp cumsp_7 cumul cusum cusum_7 cutil d|0 datasig datasign datasigna datasignat datasignatu datasignatur datasignature datetof db dbeta de dec deco decod decode deff des desc descr descri describ describe destring dfbeta dfgls dfuller di di_g dir dirstats dis discard disp disp_res disp_s displ displa display distinct do doe doed doedi doedit dotplot dotplot_7 dprobit drawnorm drop ds ds_util dstdize duplicates durbina dwstat dydx e|0 ed edi edit egen eivreg emdef en enc enco encod encode eq erase ereg ereg_lf ereg_p ereg_sw ereghet ereghet_glf ereghet_glf_sh ereghet_gp ereghet_ilf ereghet_ilf_sh ereghet_ip eret eretu eretur ereturn err erro error est est_cfexist est_cfname est_clickable est_expand est_hold est_table est_unhold est_unholdok estat estat_default estat_summ estat_vce_only esti estimates etodow etof etomdy ex exi exit expand expandcl fac fact facto factor factor_estat factor_p factor_pca_rotated factor_rotate factormat fcast fcast_compute fcast_graph fdades fdadesc fdadescr fdadescri fdadescrib fdadescribe fdasav fdasave fdause fh_st file open file read file close file filefilter fillin find_hlp_file findfile findit findit_7 fit fl fli flis flist for5_0 form forma format fpredict frac_154 frac_adj frac_chk frac_cox frac_ddp frac_dis frac_dv frac_in frac_mun frac_pp frac_pq frac_pv frac_wgt frac_xo fracgen fracplot fracplot_7 fracpoly fracpred fron_ex fron_hn fron_p fron_tn fron_tn2 frontier ftodate ftoe ftomdy ftowdate g|0 gamhet_glf gamhet_gp gamhet_ilf gamhet_ip gamma gamma_d2 gamma_p gamma_sw gammahet gdi_hexagon gdi_spokes ge gen gene gener genera generat generate genrank genstd genvmean gettoken gl gladder gladder_7 glim_l01 glim_l02 glim_l03 glim_l04 glim_l05 glim_l06 glim_l07 glim_l08 glim_l09 glim_l10 glim_l11 glim_l12 glim_lf glim_mu glim_nw1 glim_nw2 glim_nw3 glim_p glim_v1 glim_v2 glim_v3 glim_v4 glim_v5 glim_v6 glim_v7 glm glm_6 glm_p glm_sw glmpred glo glob globa global glogit glogit_8 glogit_p gmeans gnbre_lf gnbreg gnbreg_5 gnbreg_p gomp_lf gompe_sw gomper_p gompertz gompertzhet gomphet_glf gomphet_glf_sh gomphet_gp gomphet_ilf gomphet_ilf_sh gomphet_ip gphdot gphpen gphprint gprefs gprobi_p gprobit gprobit_8 gr gr7 gr_copy gr_current gr_db gr_describe gr_dir gr_draw gr_draw_replay gr_drop gr_edit gr_editviewopts gr_example gr_example2 gr_export gr_print gr_qscheme gr_query gr_read gr_rename gr_replay gr_save gr_set gr_setscheme gr_table gr_undo gr_use graph graph7 grebar greigen greigen_7 greigen_8 grmeanby grmeanby_7 gs_fileinfo gs_filetype gs_graphinfo gs_stat gsort gwood h|0 hadimvo hareg hausman haver he heck_d2 heckma_p heckman heckp_lf heckpr_p heckprob hel help hereg hetpr_lf hetpr_p hetprob hettest hexdump hilite hist hist_7 histogram hlogit hlu hmeans hotel hotelling hprobit hreg hsearch icd9 icd9_ff icd9p iis impute imtest inbase include inf infi infil infile infix inp inpu input ins insheet insp inspe inspec inspect integ inten intreg intreg_7 intreg_p intrg2_ll intrg_ll intrg_ll2 ipolate iqreg ir irf irf_create irfm iri is_svy is_svysum isid istdize ivprob_1_lf ivprob_lf ivprobit ivprobit_p ivreg ivreg_footnote ivtob_1_lf ivtob_lf ivtobit ivtobit_p jackknife jacknife jknife jknife_6 jknife_8 jkstat joinby kalarma1 kap kap_3 kapmeier kappa kapwgt kdensity kdensity_7 keep ksm ksmirnov ktau kwallis l|0 la lab labe label labelbook ladder levels levelsof leverage lfit lfit_p li lincom line linktest lis list lloghet_glf lloghet_glf_sh lloghet_gp lloghet_ilf lloghet_ilf_sh lloghet_ip llogi_sw llogis_p llogist llogistic llogistichet lnorm_lf lnorm_sw lnorma_p lnormal lnormalhet lnormhet_glf lnormhet_glf_sh lnormhet_gp lnormhet_ilf lnormhet_ilf_sh lnormhet_ip lnskew0 loadingplot loc loca local log logi logis_lf logistic logistic_p logit logit_estat logit_p loglogs logrank loneway lookfor lookup lowess lowess_7 lpredict lrecomp lroc lroc_7 lrtest ls lsens lsens_7 lsens_x lstat ltable ltable_7 ltriang lv lvr2plot lvr2plot_7 m|0 ma mac macr macro makecns man manova manova_estat manova_p manovatest mantel mark markin markout marksample mat mat_capp mat_order mat_put_rr mat_rapp mata mata_clear mata_describe mata_drop mata_matdescribe mata_matsave mata_matuse mata_memory mata_mlib mata_mosave mata_rename mata_which matalabel matcproc matlist matname matr matri matrix matrix_input__dlg matstrik mcc mcci md0_ md1_ md1debug_ md2_ md2debug_ mds mds_estat mds_p mdsconfig mdslong mdsmat mdsshepard mdytoe mdytof me_derd mean means median memory memsize meqparse mer merg merge mfp mfx mhelp mhodds minbound mixed_ll mixed_ll_reparm mkassert mkdir mkmat mkspline ml ml_5 ml_adjs ml_bhhhs ml_c_d ml_check ml_clear ml_cnt ml_debug ml_defd ml_e0 ml_e0_bfgs ml_e0_cycle ml_e0_dfp ml_e0i ml_e1 ml_e1_bfgs ml_e1_bhhh ml_e1_cycle ml_e1_dfp ml_e2 ml_e2_cycle ml_ebfg0 ml_ebfr0 ml_ebfr1 ml_ebh0q ml_ebhh0 ml_ebhr0 ml_ebr0i ml_ecr0i ml_edfp0 ml_edfr0 ml_edfr1 ml_edr0i ml_eds ml_eer0i ml_egr0i ml_elf ml_elf_bfgs ml_elf_bhhh ml_elf_cycle ml_elf_dfp ml_elfi ml_elfs ml_enr0i ml_enrr0 ml_erdu0 ml_erdu0_bfgs ml_erdu0_bhhh ml_erdu0_bhhhq ml_erdu0_cycle ml_erdu0_dfp ml_erdu0_nrbfgs ml_exde ml_footnote ml_geqnr ml_grad0 ml_graph ml_hbhhh ml_hd0 ml_hold ml_init ml_inv ml_log ml_max ml_mlout ml_mlout_8 ml_model ml_nb0 ml_opt ml_p ml_plot ml_query ml_rdgrd ml_repor ml_s_e ml_score ml_searc ml_technique ml_unhold mleval mlf_ mlmatbysum mlmatsum mlog mlogi mlogit mlogit_footnote mlogit_p mlopts mlsum mlvecsum mnl0_ mor more mov move mprobit mprobit_lf mprobit_p mrdu0_ mrdu1_ mvdecode mvencode mvreg mvreg_estat n|0 nbreg nbreg_al nbreg_lf nbreg_p nbreg_sw nestreg net newey newey_7 newey_p news nl nl_7 nl_9 nl_9_p nl_p nl_p_7 nlcom nlcom_p nlexp2 nlexp2_7 nlexp2a nlexp2a_7 nlexp3 nlexp3_7 nlgom3 nlgom3_7 nlgom4 nlgom4_7 nlinit nllog3 nllog3_7 nllog4 nllog4_7 nlog_rd nlogit nlogit_p nlogitgen nlogittree nlpred no nobreak noi nois noisi noisil noisily note notes notes_dlg nptrend numlabel numlist odbc old_ver olo olog ologi ologi_sw ologit ologit_p ologitp on one onew onewa oneway op_colnm op_comp op_diff op_inv op_str opr opro oprob oprob_sw oprobi oprobi_p oprobit oprobitp opts_exclusive order orthog orthpoly ou out outf outfi outfil outfile outs outsh outshe outshee outsheet ovtest pac pac_7 palette parse parse_dissim pause pca pca_8 pca_display pca_estat pca_p pca_rotate pcamat pchart pchart_7 pchi pchi_7 pcorr pctile pentium pergram pergram_7 permute permute_8 personal peto_st pkcollapse pkcross pkequiv pkexamine pkexamine_7 pkshape pksumm pksumm_7 pl plo plot plugin pnorm pnorm_7 poisgof poiss_lf poiss_sw poisso_p poisson poisson_estat post postclose postfile postutil pperron pr prais prais_e prais_e2 prais_p predict predictnl preserve print pro prob probi probit probit_estat probit_p proc_time procoverlay procrustes procrustes_estat procrustes_p profiler prog progr progra program prop proportion prtest prtesti pwcorr pwd q\\s qby qbys qchi qchi_7 qladder qladder_7 qnorm qnorm_7 qqplot qqplot_7 qreg qreg_c qreg_p qreg_sw qu quadchk quantile quantile_7 que quer query range ranksum ratio rchart rchart_7 rcof recast reclink recode reg reg3 reg3_p regdw regr regre regre_p2 regres regres_p regress regress_estat regriv_p remap ren rena renam rename renpfix repeat replace report reshape restore ret retu retur return rm rmdir robvar roccomp roccomp_7 roccomp_8 rocf_lf rocfit rocfit_8 rocgold rocplot rocplot_7 roctab roctab_7 rolling rologit rologit_p rot rota rotat rotate rotatemat rreg rreg_p ru run runtest rvfplot rvfplot_7 rvpplot rvpplot_7 sa safesum sample sampsi sav save savedresults saveold sc sca scal scala scalar scatter scm_mine sco scob_lf scob_p scobi_sw scobit scor score scoreplot scoreplot_help scree screeplot screeplot_help sdtest sdtesti se search separate seperate serrbar serrbar_7 serset set set_defaults sfrancia sh she shel shell shewhart shewhart_7 signestimationsample signrank signtest simul simul_7 simulate simulate_8 sktest sleep slogit slogit_d2 slogit_p smooth snapspan so sor sort spearman spikeplot spikeplot_7 spikeplt spline_x split sqreg sqreg_p sret sretu sretur sreturn ssc st st_ct st_hc st_hcd st_hcd_sh st_is st_issys st_note st_promo st_set st_show st_smpl st_subid stack statsby statsby_8 stbase stci stci_7 stcox stcox_estat stcox_fr stcox_fr_ll stcox_p stcox_sw stcoxkm stcoxkm_7 stcstat stcurv stcurve stcurve_7 stdes stem stepwise stereg stfill stgen stir stjoin stmc stmh stphplot stphplot_7 stphtest stphtest_7 stptime strate strate_7 streg streg_sw streset sts sts_7 stset stsplit stsum sttocc sttoct stvary stweib su suest suest_8 sum summ summa summar summari summariz summarize sunflower sureg survcurv survsum svar svar_p svmat svy svy_disp svy_dreg svy_est svy_est_7 svy_estat svy_get svy_gnbreg_p svy_head svy_header svy_heckman_p svy_heckprob_p svy_intreg_p svy_ivreg_p svy_logistic_p svy_logit_p svy_mlogit_p svy_nbreg_p svy_ologit_p svy_oprobit_p svy_poisson_p svy_probit_p svy_regress_p svy_sub svy_sub_7 svy_x svy_x_7 svy_x_p svydes svydes_8 svygen svygnbreg svyheckman svyheckprob svyintreg svyintreg_7 svyintrg svyivreg svylc svylog_p svylogit svymarkout svymarkout_8 svymean svymlog svymlogit svynbreg svyolog svyologit svyoprob svyoprobit svyopts svypois svypois_7 svypoisson svyprobit svyprobt svyprop svyprop_7 svyratio svyreg svyreg_p svyregress svyset svyset_7 svyset_8 svytab svytab_7 svytest svytotal sw sw_8 swcnreg swcox swereg swilk swlogis swlogit swologit swoprbt swpois swprobit swqreg swtobit swweib symmetry symmi symplot symplot_7 syntax sysdescribe sysdir sysuse szroeter ta tab tab1 tab2 tab_or tabd tabdi tabdis tabdisp tabi table tabodds tabodds_7 tabstat tabu tabul tabula tabulat tabulate te tempfile tempname tempvar tes test testnl testparm teststd tetrachoric time_it timer tis tob tobi tobit tobit_p tobit_sw token tokeni tokeniz tokenize tostring total translate translator transmap treat_ll treatr_p treatreg trim trnb_cons trnb_mean trpoiss_d2 trunc_ll truncr_p truncreg tsappend tset tsfill tsline tsline_ex tsreport tsrevar tsrline tsset tssmooth tsunab ttest ttesti tut_chk tut_wait tutorial tw tware_st two twoway twoway__fpfit_serset twoway__function_gen twoway__histogram_gen twoway__ipoint_serset twoway__ipoints_serset twoway__kdensity_gen twoway__lfit_serset twoway__normgen_gen twoway__pci_serset twoway__qfit_serset twoway__scatteri_serset twoway__sunflower_gen twoway_ksm_serset ty typ type typeof u|0 unab unabbrev unabcmd update us use uselabel var var_mkcompanion var_p varbasic varfcast vargranger varirf varirf_add varirf_cgraph varirf_create varirf_ctable varirf_describe varirf_dir varirf_drop varirf_erase varirf_graph varirf_ograph varirf_rename varirf_set varirf_table varlist varlmar varnorm varsoc varstable varstable_w varstable_w2 varwle vce vec vec_fevd vec_mkphi vec_p vec_p_w vecirf_create veclmar veclmar_w vecnorm vecnorm_w vecrank vecstable verinst vers versi versio version view viewsource vif vwls wdatetof webdescribe webseek webuse weib1_lf weib2_lf weib_lf weib_lf0 weibhet_glf weibhet_glf_sh weibhet_glfa weibhet_glfa_sh weibhet_gp weibhet_ilf weibhet_ilf_sh weibhet_ilfa weibhet_ilfa_sh weibhet_ip weibu_sw weibul_p weibull weibull_c weibull_s weibullhet wh whelp whi which whil while wilc_st wilcoxon win wind windo window winexec wntestb wntestb_7 wntestq xchart xchart_7 xcorr xcorr_7 xi xi_6 xmlsav xmlsave xmluse xpose xsh xshe xshel xshell xt_iis xt_tis xtab_p xtabond xtbin_p xtclog xtcloglog xtcloglog_8 xtcloglog_d2 xtcloglog_pa_p xtcloglog_re_p xtcnt_p xtcorr xtdata xtdes xtfront_p xtfrontier xtgee xtgee_elink xtgee_estat xtgee_makeivar xtgee_p xtgee_plink xtgls xtgls_p xthaus xthausman xtht_p xthtaylor xtile xtint_p xtintreg xtintreg_8 xtintreg_d2 xtintreg_p xtivp_1 xtivp_2 xtivreg xtline xtline_ex xtlogit xtlogit_8 xtlogit_d2 xtlogit_fe_p xtlogit_pa_p xtlogit_re_p xtmixed xtmixed_estat xtmixed_p xtnb_fe xtnb_lf xtnbreg xtnbreg_pa_p xtnbreg_refe_p xtpcse xtpcse_p xtpois xtpoisson xtpoisson_d2 xtpoisson_pa_p xtpoisson_refe_p xtpred xtprobit xtprobit_8 xtprobit_d2 xtprobit_re_p xtps_fe xtps_lf xtps_ren xtps_ren_8 xtrar_p xtrc xtrc_p xtrchh xtrefe_p xtreg xtreg_be xtreg_fe xtreg_ml xtreg_pa_p xtreg_re xtregar xtrere_p xtset xtsf_ll xtsf_llti xtsum xttab xttest0 xttobit xttobit_8 xttobit_p xttrans yx yxview__barlike_draw yxview_area_draw yxview_bar_draw yxview_dot_draw yxview_dropline_draw yxview_function_draw yxview_iarrow_draw yxview_ilabels_draw yxview_normal_draw yxview_pcarrow_draw yxview_pcbarrow_draw yxview_pccapsym_draw yxview_pcscatter_draw yxview_pcspike_draw yxview_rarea_draw yxview_rbar_draw yxview_rbarm_draw yxview_rcap_draw yxview_rcapsym_draw yxview_rconnected_draw yxview_rline_draw yxview_rscatter_draw yxview_rspike_draw yxview_spike_draw yxview_sunflower_draw zap_s zinb zinb_llf zinb_plf zip zip_llf zip_p zip_plf zt_ct_5 zt_hc_5 zt_hcd_5 zt_is_5 zt_iss_5 zt_sho_5 zt_smp_5 ztbase_5 ztcox_5 ztdes_5 ztereg_5 ztfill_5 ztgen_5 ztir_5 ztjoin_5 ztnb ztnb_p ztp ztp_p zts_5 ztset_5 ztspli_5 ztsum_5 zttoct_5 ztvary_5 ztweib_5",
+contains:[{className:"symbol",begin:/`[a-zA-Z0-9_]+'/},{className:"variable",begin:/\$\{?[a-zA-Z0-9_]+\}?/},{className:"string",variants:[{begin:'`"[^\r\n]*?"\''},{begin:'"[^\r\n"]*"'}]},{className:"built_in",variants:[{begin:"\\b(abs|acos|asin|atan|atan2|atanh|ceil|cloglog|comb|cos|digamma|exp|floor|invcloglog|invlogit|ln|lnfact|lnfactorial|lngamma|log|log10|max|min|mod|reldif|round|sign|sin|sqrt|sum|tan|tanh|trigamma|trunc|betaden|Binomial|binorm|binormal|chi2|chi2tail|dgammapda|dgammapdada|dgammapdadx|dgammapdx|dgammapdxdx|F|Fden|Ftail|gammaden|gammap|ibeta|invbinomial|invchi2|invchi2tail|invF|invFtail|invgammap|invibeta|invnchi2|invnFtail|invnibeta|invnorm|invnormal|invttail|nbetaden|nchi2|nFden|nFtail|nibeta|norm|normal|normalden|normd|npnchi2|tden|ttail|uniform|abbrev|char|index|indexnot|length|lower|ltrim|match|plural|proper|real|regexm|regexr|regexs|reverse|rtrim|string|strlen|strlower|strltrim|strmatch|strofreal|strpos|strproper|strreverse|strrtrim|strtrim|strupper|subinstr|subinword|substr|trim|upper|word|wordcount|_caller|autocode|byteorder|chop|clip|cond|e|epsdouble|epsfloat|group|inlist|inrange|irecode|matrix|maxbyte|maxdouble|maxfloat|maxint|maxlong|mi|minbyte|mindouble|minfloat|minint|minlong|missing|r|recode|replay|return|s|scalar|d|date|day|dow|doy|halfyear|mdy|month|quarter|week|year|d|daily|dofd|dofh|dofm|dofq|dofw|dofy|h|halfyearly|hofd|m|mofd|monthly|q|qofd|quarterly|tin|twithin|w|weekly|wofd|y|yearly|yh|ym|yofd|yq|yw|cholesky|colnumb|colsof|corr|det|diag|diag0cnt|el|get|hadamard|I|inv|invsym|issym|issymmetric|J|matmissing|matuniform|mreldif|nullmat|rownumb|rowsof|sweep|syminv|trace|vec|vecdiag)(?=\\(|$)"}]},
+a.COMMENT("^[ \t]*\\*.*$",!1),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}});b.registerLanguage("step21",function(a){return{aliases:["p21","step","stp"],case_insensitive:!0,lexemes:"[A-Z_][A-Z0-9_.]*",keywords:{keyword:"HEADER ENDSEC DATA"},contains:[{className:"meta",begin:"ISO-10303-21;",relevance:10},{className:"meta",begin:"END-ISO-10303-21;",relevance:10},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.COMMENT("/\\*\\*!","\\*/"),a.C_NUMBER_MODE,a.inherit(a.APOS_STRING_MODE,{illegal:null}),a.inherit(a.QUOTE_STRING_MODE,
+{illegal:null}),{className:"string",begin:"'",end:"'"},{className:"symbol",variants:[{begin:"#",end:"\\d+",illegal:"\\W"}]}]}});b.registerLanguage("stylus",function(a){var b={className:"variable",begin:"\\$"+a.IDENT_RE},d={className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"};return{aliases:["styl"],case_insensitive:!1,keywords:"if else for in",illegal:"(\\?|(\\bReturn\\b)|(\\bEnd\\b)|(\\bend\\b)|(\\bdef\\b)|;|#\\s|\\*\\s|===\\s|\\||%)",contains:[a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_LINE_COMMENT_MODE,
+a.C_BLOCK_COMMENT_MODE,d,{begin:"\\.[a-zA-Z][a-zA-Z0-9_-]*[\\.\\s\\n\\[\\:,]",returnBegin:!0,contains:[{className:"selector-class",begin:"\\.[a-zA-Z][a-zA-Z0-9_-]*"}]},{begin:"\\#[a-zA-Z][a-zA-Z0-9_-]*[\\.\\s\\n\\[\\:,]",returnBegin:!0,contains:[{className:"selector-id",begin:"\\#[a-zA-Z][a-zA-Z0-9_-]*"}]},{begin:"\\b(a|abbr|address|article|aside|audio|b|blockquote|body|button|canvas|caption|cite|code|dd|del|details|dfn|div|dl|dt|em|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|html|i|iframe|img|input|ins|kbd|label|legend|li|mark|menu|nav|object|ol|p|q|quote|samp|section|span|strong|summary|sup|table|tbody|td|textarea|tfoot|th|thead|time|tr|ul|var|video)[\\.\\s\\n\\[\\:,]",
+returnBegin:!0,contains:[{className:"selector-tag",begin:"\\b[a-zA-Z][a-zA-Z0-9_-]*"}]},{begin:"&?:?:\\b(after|before|first-letter|first-line|active|first-child|focus|hover|lang|link|visited)[\\.\\s\\n\\[\\:,]"},{begin:"@(charset|css|debug|extend|font-face|for|import|include|media|mixin|page|warn|while)\\b"},b,a.CSS_NUMBER_MODE,a.NUMBER_MODE,{className:"function",begin:"^[a-zA-Z][a-zA-Z0-9_-]*\\(.*\\)",illegal:"[\\n]",returnBegin:!0,contains:[{className:"title",begin:"\\b[a-zA-Z][a-zA-Z0-9_-]*"},
+{className:"params",begin:/\(/,end:/\)/,contains:[d,b,a.APOS_STRING_MODE,a.CSS_NUMBER_MODE,a.NUMBER_MODE,a.QUOTE_STRING_MODE]}]},{className:"attribute",begin:"\\b("+"align-content align-items align-self animation animation-delay animation-direction animation-duration animation-fill-mode animation-iteration-count animation-name animation-play-state animation-timing-function auto backface-visibility background background-attachment background-clip background-color background-image background-origin background-position background-repeat background-size border border-bottom border-bottom-color border-bottom-left-radius border-bottom-right-radius border-bottom-style border-bottom-width border-collapse border-color border-image border-image-outset border-image-repeat border-image-slice border-image-source border-image-width border-left border-left-color border-left-style border-left-width border-radius border-right border-right-color border-right-style border-right-width border-spacing border-style border-top border-top-color border-top-left-radius border-top-right-radius border-top-style border-top-width border-width bottom box-decoration-break box-shadow box-sizing break-after break-before break-inside caption-side clear clip clip-path color column-count column-fill column-gap column-rule column-rule-color column-rule-style column-rule-width column-span column-width columns content counter-increment counter-reset cursor direction display empty-cells filter flex flex-basis flex-direction flex-flow flex-grow flex-shrink flex-wrap float font font-family font-feature-settings font-kerning font-language-override font-size font-size-adjust font-stretch font-style font-variant font-variant-ligatures font-weight height hyphens icon image-orientation image-rendering image-resolution ime-mode inherit initial justify-content left letter-spacing line-height list-style list-style-image list-style-position list-style-type margin margin-bottom margin-left margin-right margin-top marks mask max-height max-width min-height min-width nav-down nav-index nav-left nav-right nav-up none normal object-fit object-position opacity order orphans outline outline-color outline-offset outline-style outline-width overflow overflow-wrap overflow-x overflow-y padding padding-bottom padding-left padding-right padding-top page-break-after page-break-before page-break-inside perspective perspective-origin pointer-events position quotes resize right tab-size table-layout text-align text-align-last text-decoration text-decoration-color text-decoration-line text-decoration-style text-indent text-overflow text-rendering text-shadow text-transform text-underline-position top transform transform-origin transform-style transition transition-delay transition-duration transition-property transition-timing-function unicode-bidi vertical-align visibility white-space widows width word-break word-spacing word-wrap z-index".split(" ").reverse().join("|")+
+")\\b",starts:{end:/;|$/,contains:[d,b,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.CSS_NUMBER_MODE,a.NUMBER_MODE,a.C_BLOCK_COMMENT_MODE],illegal:/\./,relevance:0}}]}});b.registerLanguage("subunit",function(a){return{case_insensitive:!0,contains:[{className:"string",begin:"\\[\n(multipart)?",end:"\\]\n"},{className:"string",begin:"\\d{4}-\\d{2}-\\d{2}(\\s+)\\d{2}:\\d{2}:\\d{2}.\\d+Z"},{className:"string",begin:"(\\+|-)\\d+"},{className:"keyword",relevance:10,variants:[{begin:"^(test|testing|success|successful|failure|error|skip|xfail|uxsuccess)(:?)\\s+(test)?"},
+{begin:"^progress(:?)(\\s+)?(pop|push)?"},{begin:"^tags:"},{begin:"^time:"}]}]}});b.registerLanguage("swift",function(a){var b={keyword:"#available #colorLiteral #column #else #elseif #endif #file #fileLiteral #function #if #imageLiteral #line #selector #sourceLocation _ __COLUMN__ __FILE__ __FUNCTION__ __LINE__ Any as as! as? associatedtype associativity break case catch class continue convenience default defer deinit didSet do dynamic dynamicType else enum extension fallthrough false fileprivate final for func get guard if import in indirect infix init inout internal is lazy left let mutating nil none nonmutating open operator optional override postfix precedence prefix private protocol Protocol public repeat required rethrows return right self Self set static struct subscript super switch throw throws true try try! try? Type typealias unowned var weak where while willSet",
+literal:"true false nil",built_in:"abs advance alignof alignofValue anyGenerator assert assertionFailure bridgeFromObjectiveC bridgeFromObjectiveCUnconditional bridgeToObjectiveC bridgeToObjectiveCUnconditional c contains count countElements countLeadingZeros debugPrint debugPrintln distance dropFirst dropLast dump encodeBitsAsWords enumerate equal fatalError filter find getBridgedObjectiveCType getVaList indices insertionSort isBridgedToObjectiveC isBridgedVerbatimToObjectiveC isUniquelyReferenced isUniquelyReferencedNonObjC join lazy lexicographicalCompare map max maxElement min minElement numericCast overlaps partition posix precondition preconditionFailure print println quickSort readLine reduce reflect reinterpretCast reverse roundUpToAlignment sizeof sizeofValue sort split startsWith stride strideof strideofValue swap toString transcode underestimateCount unsafeAddressOf unsafeBitCast unsafeDowncast unsafeUnwrap unsafeReflect withExtendedLifetime withObjectAtPlusZero withUnsafePointer withUnsafePointerToObject withUnsafeMutablePointer withUnsafeMutablePointers withUnsafePointer withUnsafePointers withVaList zip"},
+d=a.COMMENT("/\\*","\\*/",{contains:["self"]}),e={className:"subst",begin:/\\\(/,end:"\\)",keywords:b,contains:[]},f={className:"string",contains:[a.BACKSLASH_ESCAPE,e],variants:[{begin:/"""/,end:/"""/},{begin:/"/,end:/"/}]},g={className:"number",begin:"\\b([\\d_]+(\\.[\\deE_]+)?|0x[a-fA-F0-9_]+(\\.[a-fA-F0-9p_]+)?|0b[01_]+|0o[0-7_]+)\\b",relevance:0};e.contains=[g];return{keywords:b,contains:[f,a.C_LINE_COMMENT_MODE,d,{className:"type",begin:"\\b[A-Z][\\w\u00c0-\u02b8']*[!?]"},{className:"type",
+begin:"\\b[A-Z][\\w\u00c0-\u02b8']*",relevance:0},g,{className:"function",beginKeywords:"func",end:"{",excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{begin:/</,end:/>/},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:b,contains:["self",g,f,a.C_BLOCK_COMMENT_MODE,{begin:":"}],illegal:/["']/}],illegal:/\[|%/},{className:"class",beginKeywords:"struct protocol class extension enum",keywords:b,end:"\\{",excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,
+{begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/})]},{className:"meta",begin:"(@discardableResult|@warn_unused_result|@exported|@lazy|@noescape|@NSCopying|@NSManaged|@objc|@objcMembers|@convention|@required|@noreturn|@IBAction|@IBDesignable|@IBInspectable|@IBOutlet|@infix|@prefix|@postfix|@autoclosure|@testable|@available|@nonobjc|@NSApplicationMain|@UIApplicationMain)"},{beginKeywords:"import",end:/$/,contains:[a.C_LINE_COMMENT_MODE,d]}]}});b.registerLanguage("taggerscript",function(a){return{contains:[{className:"comment",
+begin:/\$noop\(/,end:/\)/,contains:[{begin:/\(/,end:/\)/,contains:["self",{begin:/\\./}]}],relevance:10},{className:"keyword",begin:/\$(?!noop)[a-zA-Z][_a-zA-Z0-9]*/,end:/\(/,excludeEnd:!0},{className:"variable",begin:/%[_a-zA-Z0-9:]*/,end:"%"},{className:"symbol",begin:/\\./}]}});b.registerLanguage("yaml",function(a){var b={className:"attr",variants:[{begin:"^[ \\-]*[a-zA-Z_][\\w\\-]*:"},{begin:'^[ \\-]*"[a-zA-Z_][\\w\\-]*":'},{begin:"^[ \\-]*'[a-zA-Z_][\\w\\-]*':"}]},d={className:"string",relevance:0,
+variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[a.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]};return{case_insensitive:!0,aliases:["yml","YAML","yaml"],contains:[b,{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>] *$",returnEnd:!0,contains:d.contains,end:b.variants[0].begin},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",
+begin:"!"+a.UNDERSCORE_IDENT_RE},{className:"type",begin:"!!"+a.UNDERSCORE_IDENT_RE},{className:"meta",begin:"&"+a.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+a.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"^ *-",relevance:0},a.HASH_COMMENT_MODE,{beginKeywords:"true false yes no null",keywords:{literal:"true false yes no null"}},a.C_NUMBER_MODE,d]}});b.registerLanguage("tap",function(a){return{case_insensitive:!0,contains:[a.HASH_COMMENT_MODE,{className:"meta",variants:[{begin:"^TAP version (\\d+)$"},
+{begin:"^1\\.\\.(\\d+)$"}]},{begin:"(s+)?---$",end:"\\.\\.\\.$",subLanguage:"yaml",relevance:0},{className:"number",begin:" (\\d+) "},{className:"symbol",variants:[{begin:"^ok"},{begin:"^not ok"}]}]}});b.registerLanguage("tcl",function(a){return{aliases:["tk"],keywords:"after append apply array auto_execok auto_import auto_load auto_mkindex auto_mkindex_old auto_qualify auto_reset bgerror binary break catch cd chan clock close concat continue dde dict encoding eof error eval exec exit expr fblocked fconfigure fcopy file fileevent filename flush for foreach format gets glob global history http if incr info interp join lappend|10 lassign|10 lindex|10 linsert|10 list llength|10 load lrange|10 lrepeat|10 lreplace|10 lreverse|10 lsearch|10 lset|10 lsort|10 mathfunc mathop memory msgcat namespace open package parray pid pkg::create pkg_mkIndex platform platform::shell proc puts pwd read refchan regexp registry regsub|10 rename return safe scan seek set socket source split string subst switch tcl_endOfWord tcl_findLibrary tcl_startOfNextWord tcl_startOfPreviousWord tcl_wordBreakAfter tcl_wordBreakBefore tcltest tclvars tell time tm trace unknown unload unset update uplevel upvar variable vwait while",
+contains:[a.COMMENT(";[ \\t]*#","$"),a.COMMENT("^[ \\t]*#","$"),{beginKeywords:"proc",end:"[\\{]",excludeEnd:!0,contains:[{className:"title",begin:"[ \\t\\n\\r]+(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*",end:"[ \\t\\n\\r]",endsWithParent:!0,excludeEnd:!0}]},{excludeEnd:!0,variants:[{begin:"\\$(\\{)?(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*\\(([a-zA-Z0-9_])*\\)",end:"[^a-zA-Z0-9_\\}\\$]"},{begin:"\\$(\\{)?(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*",end:"(\\))?[^a-zA-Z0-9_\\}\\$]"}]},{className:"string",contains:[a.BACKSLASH_ESCAPE],
+variants:[a.inherit(a.QUOTE_STRING_MODE,{illegal:null})]},{className:"number",variants:[a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE]}]}});b.registerLanguage("tex",function(a){var b={className:"tag",begin:/\\/,relevance:0,contains:[{className:"name",variants:[{begin:/[a-zA-Z\u0430-\u044f\u0410-\u042f]+[*]?/},{begin:/[^a-zA-Z\u0430-\u044f\u0410-\u042f0-9]/}],starts:{endsWithParent:!0,relevance:0,contains:[{className:"string",variants:[{begin:/\[/,end:/\]/},{begin:/\{/,end:/\}/}]},{begin:/\s*=\s*/,endsWithParent:!0,
+relevance:0,contains:[{className:"number",begin:/-?\d*\.?\d+(pt|pc|mm|cm|in|dd|cc|ex|em)?/}]}]}}]};return{contains:[b,{className:"formula",contains:[b],relevance:0,variants:[{begin:/\$\$/,end:/\$\$/},{begin:/\$/,end:/\$/}]},a.COMMENT("%","$",{relevance:0})]}});b.registerLanguage("thrift",function(a){return{keywords:{keyword:"namespace const typedef struct enum service exception void oneway set list map required optional",built_in:"bool byte i16 i32 i64 double string binary",literal:"true false"},
+contains:[a.QUOTE_STRING_MODE,a.NUMBER_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"class",beginKeywords:"struct enum service exception",end:/\{/,illegal:/\n/,contains:[a.inherit(a.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},{begin:"\\b(set|list|map)\\s*<",end:">",keywords:"bool byte i16 i32 i64 double string binary",contains:["self"]}]}});b.registerLanguage("tp",function(a){var b={className:"number",begin:"[1-9][0-9]*",relevance:0},d={className:"symbol",begin:":[^\\]]+"};
+return{keywords:{keyword:"ABORT ACC ADJUST AND AP_LD BREAK CALL CNT COL CONDITION CONFIG DA DB DIV DETECT ELSE END ENDFOR ERR_NUM ERROR_PROG FINE FOR GP GUARD INC IF JMP LINEAR_MAX_SPEED LOCK MOD MONITOR OFFSET Offset OR OVERRIDE PAUSE PREG PTH RT_LD RUN SELECT SKIP Skip TA TB TO TOOL_OFFSET Tool_Offset UF UT UFRAME_NUM UTOOL_NUM UNLOCK WAIT X Y Z W P R STRLEN SUBSTR FINDSTR VOFFSET PROG ATTR MN POS",literal:"ON OFF max_speed LPOS JPOS ENABLE DISABLE START STOP RESET"},contains:[{className:"built_in",
+begin:"(AR|P|PAYLOAD|PR|R|SR|RSR|LBL|VR|UALM|MESSAGE|UTOOL|UFRAME|TIMER|TIMER_OVERFLOW|JOINT_MAX_SPEED|RESUME_PROG|DIAG_REC)\\[",end:"\\]",contains:["self",b,d]},{className:"built_in",begin:"(AI|AO|DI|DO|F|RI|RO|UI|UO|GI|GO|SI|SO)\\[",end:"\\]",contains:["self",b,a.QUOTE_STRING_MODE,d]},{className:"keyword",begin:"/(PROG|ATTR|MN|POS|END)\\b"},{className:"keyword",begin:"(CALL|RUN|POINT_LOGIC|LBL)\\b"},{className:"keyword",begin:"\\b(ACC|CNT|Skip|Offset|PSPD|RT_LD|AP_LD|Tool_Offset)"},{className:"number",
+begin:"\\d+(sec|msec|mm/sec|cm/min|inch/min|deg/sec|mm|in|cm)?\\b",relevance:0},a.COMMENT("//","[;$]"),a.COMMENT("!","[;$]"),a.COMMENT("--eg:","$"),a.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"'"},a.C_NUMBER_MODE,{className:"variable",begin:"\\$[A-Za-z0-9_]+"}]}});b.registerLanguage("twig",function(a){var b={beginKeywords:"attribute block constant cycle date dump include max min parent random range source template_from_string",keywords:{name:"attribute block constant cycle date dump include max min parent random range source template_from_string"},
+relevance:0,contains:[{className:"params",begin:"\\(",end:"\\)"}]},d={begin:/\|[A-Za-z_]+:?/,keywords:"abs batch capitalize convert_encoding date date_modify default escape first format join json_encode keys last length lower merge nl2br number_format raw replace reverse round slice sort split striptags title trim upper url_encode",contains:[b]},e="autoescape block do embed extends filter flush for if import include macro sandbox set spaceless use verbatim";e=e+" "+e.split(" ").map(function(a){return"end"+
+a}).join(" ");return{aliases:["craftcms"],case_insensitive:!0,subLanguage:"xml",contains:[a.COMMENT(/\{#/,/#}/),{className:"template-tag",begin:/\{%/,end:/%}/,contains:[{className:"name",begin:/\w+/,keywords:e,starts:{endsWithParent:!0,contains:[d,b],relevance:0}}]},{className:"template-variable",begin:/\{\{/,end:/}}/,contains:["self",d,b]}]}});b.registerLanguage("typescript",function(a){var b={keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const class public private protected get set super static implements enum export import declare type namespace abstract as from extends async await",
+literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document any number boolean string void Promise"},
+d={className:"meta",begin:"@[A-Za-z$_][0-9A-Za-z$_]*"},e={begin:"\\(",end:/\)/,keywords:b,contains:["self",a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.NUMBER_MODE]},f={className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,d,e]};return{aliases:["ts"],keywords:b,contains:[{className:"meta",begin:/^\s*['"]use strict['"]/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,
+{className:"subst",begin:"\\$\\{",end:"\\}"}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|"+a.IDENT_RE+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:a.IDENT_RE},
+{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:["self",a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}]}]}],relevance:0},{className:"function",begin:"function",end:/[\{;]/,excludeEnd:!0,keywords:b,contains:["self",a.inherit(a.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"}),f],illegal:/%/,relevance:0},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0,contains:["self",f]},{begin:/module\./,keywords:{built_in:"module"},relevance:0},{beginKeywords:"module",end:/\{/,
+excludeEnd:!0},{beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:"interface extends"},{begin:/\$[(.]/},{begin:"\\."+a.IDENT_RE,relevance:0},d,e]}});b.registerLanguage("vala",function(a){return{keywords:{keyword:"char uchar unichar int uint long ulong short ushort int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double bool struct enum string void weak unowned owned async signal static abstract interface override virtual delegate if while do for foreach else switch case break default return try catch public private protected internal using new this get set const stdout stdin stderr var",
+built_in:"DBus GLib CCode Gee Object Gtk Posix",literal:"false true null"},contains:[{className:"class",beginKeywords:"class interface namespace",end:"{",excludeEnd:!0,illegal:"[^,:\\n\\s\\.]",contains:[a.UNDERSCORE_TITLE_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",begin:'"""',end:'"""',relevance:5},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,{className:"meta",begin:"^#",end:"$",relevance:2}]}});b.registerLanguage("vbnet",function(a){return{aliases:["vb"],case_insensitive:!0,
+keywords:{keyword:"addhandler addressof alias and andalso aggregate ansi as assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into is isfalse isnot istrue join key let lib like loop me mid mod module mustinherit mustoverride mybase myclass namespace narrowing new next not notinheritable notoverridable of off on operator option optional or order orelse overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim rem removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly xor",
+built_in:"boolean byte cbool cbyte cchar cdate cdec cdbl char cint clng cobj csbyte cshort csng cstr ctype date decimal directcast double gettype getxmlnamespace iif integer long object sbyte short single string trycast typeof uinteger ulong ushort",literal:"true false nothing"},illegal:"//|{|}|endif|gosub|variant|wend|^\\$ ",contains:[a.inherit(a.QUOTE_STRING_MODE,{contains:[{begin:'""'}]}),a.COMMENT("'","$",{returnBegin:!0,contains:[{className:"doctag",begin:"'''|\x3c!--|--\x3e",contains:[a.PHRASAL_WORDS_MODE]},
+{className:"doctag",begin:"</?",end:">",contains:[a.PHRASAL_WORDS_MODE]}]}),a.C_NUMBER_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elseif end region externalsource"}}]}});b.registerLanguage("vbscript",function(a){return{aliases:["vbs"],case_insensitive:!0,keywords:{keyword:"call class const dim do loop erase execute executeglobal exit for each next function if then else on error option explicit new private property let get public randomize redim rem select case set stop sub while wend with end to elseif is or xor and not class_initialize class_terminate default preserve in me byval byref step resume goto",
+built_in:"lcase month vartype instrrev ubound setlocale getobject rgb getref string weekdayname rnd dateadd monthname now day minute isarray cbool round formatcurrency conversions csng timevalue second year space abs clng timeserial fixs len asc isempty maths dateserial atn timer isobject filter weekday datevalue ccur isdate instr datediff formatdatetime replace isnull right sgn array snumeric log cdbl hex chr lbound msgbox ucase getlocale cos cdate cbyte rtrim join hour oct typename trim strcomp int createobject loadpicture tan formatnumber mid scriptenginebuildversion scriptengine split scriptengineminorversion cint sin datepart ltrim sqr scriptenginemajorversion time derived eval date formatpercent exp inputbox left ascw chrw regexp server response request cstr err",
+literal:"true false null nothing empty"},illegal:"//",contains:[a.inherit(a.QUOTE_STRING_MODE,{contains:[{begin:'""'}]}),a.COMMENT(/'/,/$/,{relevance:0}),a.C_NUMBER_MODE]}});b.registerLanguage("vbscript-html",function(a){return{subLanguage:"xml",contains:[{begin:"<%",end:"%>",subLanguage:"vbscript"}]}});b.registerLanguage("verilog",function(a){return{aliases:["v","sv","svh"],case_insensitive:!1,keywords:{keyword:"accept_on alias always always_comb always_ff always_latch and assert assign assume automatic before begin bind bins binsof bit break buf|0 bufif0 bufif1 byte case casex casez cell chandle checker class clocking cmos config const constraint context continue cover covergroup coverpoint cross deassign default defparam design disable dist do edge else end endcase endchecker endclass endclocking endconfig endfunction endgenerate endgroup endinterface endmodule endpackage endprimitive endprogram endproperty endspecify endsequence endtable endtask enum event eventually expect export extends extern final first_match for force foreach forever fork forkjoin function generate|5 genvar global highz0 highz1 if iff ifnone ignore_bins illegal_bins implements implies import incdir include initial inout input inside instance int integer interconnect interface intersect join join_any join_none large let liblist library local localparam logic longint macromodule matches medium modport module nand negedge nettype new nexttime nmos nor noshowcancelled not notif0 notif1 or output package packed parameter pmos posedge primitive priority program property protected pull0 pull1 pulldown pullup pulsestyle_ondetect pulsestyle_onevent pure rand randc randcase randsequence rcmos real realtime ref reg reject_on release repeat restrict return rnmos rpmos rtran rtranif0 rtranif1 s_always s_eventually s_nexttime s_until s_until_with scalared sequence shortint shortreal showcancelled signed small soft solve specify specparam static string strong strong0 strong1 struct super supply0 supply1 sync_accept_on sync_reject_on table tagged task this throughout time timeprecision timeunit tran tranif0 tranif1 tri tri0 tri1 triand trior trireg type typedef union unique unique0 unsigned until until_with untyped use uwire var vectored virtual void wait wait_order wand weak weak0 weak1 while wildcard wire with within wor xnor xor",
+literal:"null",built_in:"$finish $stop $exit $fatal $error $warning $info $realtime $time $printtimescale $bitstoreal $bitstoshortreal $itor $signed $cast $bits $stime $timeformat $realtobits $shortrealtobits $rtoi $unsigned $asserton $assertkill $assertpasson $assertfailon $assertnonvacuouson $assertoff $assertcontrol $assertpassoff $assertfailoff $assertvacuousoff $isunbounded $sampled $fell $changed $past_gclk $fell_gclk $changed_gclk $rising_gclk $steady_gclk $coverage_control $coverage_get $coverage_save $set_coverage_db_name $rose $stable $past $rose_gclk $stable_gclk $future_gclk $falling_gclk $changing_gclk $display $coverage_get_max $coverage_merge $get_coverage $load_coverage_db $typename $unpacked_dimensions $left $low $increment $clog2 $ln $log10 $exp $sqrt $pow $floor $ceil $sin $cos $tan $countbits $onehot $isunknown $fatal $warning $dimensions $right $high $size $asin $acos $atan $atan2 $hypot $sinh $cosh $tanh $asinh $acosh $atanh $countones $onehot0 $error $info $random $dist_chi_square $dist_erlang $dist_exponential $dist_normal $dist_poisson $dist_t $dist_uniform $q_initialize $q_remove $q_exam $async$and$array $async$nand$array $async$or$array $async$nor$array $sync$and$array $sync$nand$array $sync$or$array $sync$nor$array $q_add $q_full $psprintf $async$and$plane $async$nand$plane $async$or$plane $async$nor$plane $sync$and$plane $sync$nand$plane $sync$or$plane $sync$nor$plane $system $display $displayb $displayh $displayo $strobe $strobeb $strobeh $strobeo $write $readmemb $readmemh $writememh $value$plusargs $dumpvars $dumpon $dumplimit $dumpports $dumpportson $dumpportslimit $writeb $writeh $writeo $monitor $monitorb $monitorh $monitoro $writememb $dumpfile $dumpoff $dumpall $dumpflush $dumpportsoff $dumpportsall $dumpportsflush $fclose $fdisplay $fdisplayb $fdisplayh $fdisplayo $fstrobe $fstrobeb $fstrobeh $fstrobeo $swrite $swriteb $swriteh $swriteo $fscanf $fread $fseek $fflush $feof $fopen $fwrite $fwriteb $fwriteh $fwriteo $fmonitor $fmonitorb $fmonitorh $fmonitoro $sformat $sformatf $fgetc $ungetc $fgets $sscanf $rewind $ftell $ferror"},
+lexemes:/[\w\$]+/,contains:[a.C_BLOCK_COMMENT_MODE,a.C_LINE_COMMENT_MODE,a.QUOTE_STRING_MODE,{className:"number",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:"\\b((\\d+'(b|h|o|d|B|H|O|D))[0-9xzXZa-fA-F_]+)"},{begin:"\\B(('(b|h|o|d|B|H|O|D))[0-9xzXZa-fA-F_]+)"},{begin:"\\b([0-9_])+",relevance:0}]},{className:"variable",variants:[{begin:"#\\((?!parameter).+\\)"},{begin:"\\.\\w+",relevance:0}]},{className:"meta",begin:"`",end:"$",keywords:{"meta-keyword":"define __FILE__ __LINE__ begin_keywords celldefine default_nettype define else elsif end_keywords endcelldefine endif ifdef ifndef include line nounconnected_drive pragma resetall timescale unconnected_drive undef undefineall"},
+relevance:0}]}});b.registerLanguage("vhdl",function(a){return{case_insensitive:!0,keywords:{keyword:"abs access after alias all and architecture array assert assume assume_guarantee attribute begin block body buffer bus case component configuration constant context cover disconnect downto default else elsif end entity exit fairness file for force function generate generic group guarded if impure in inertial inout is label library linkage literal loop map mod nand new next nor not null of on open or others out package parameter port postponed procedure process property protected pure range record register reject release rem report restrict restrict_guarantee return rol ror select sequence severity shared signal sla sll sra srl strong subtype then to transport type unaffected units until use variable view vmode vprop vunit wait when while with xnor xor",
+built_in:"boolean bit character integer time delay_length natural positive string bit_vector file_open_kind file_open_status std_logic std_logic_vector unsigned signed boolean_vector integer_vector std_ulogic std_ulogic_vector unresolved_unsigned u_unsigned unresolved_signed u_signed real_vector time_vector",literal:"false true note warning error failure line text side width"},illegal:"{",contains:[a.C_BLOCK_COMMENT_MODE,a.COMMENT("--","$"),a.QUOTE_STRING_MODE,{className:"number",begin:"\\b(\\d(_|\\d)*#\\w+(\\.\\w+)?#([eE][-+]?\\d(_|\\d)*)?|\\d(_|\\d)*(\\.\\d(_|\\d)*)?([eE][-+]?\\d(_|\\d)*)?)",
+relevance:0},{className:"string",begin:"'(U|X|0|1|Z|W|L|H|-)'",contains:[a.BACKSLASH_ESCAPE]},{className:"symbol",begin:"'[A-Za-z](_?[A-Za-z0-9])*",contains:[a.BACKSLASH_ESCAPE]}]}});b.registerLanguage("vim",function(a){return{lexemes:/[!#@\w]+/,keywords:{keyword:"N|0 P|0 X|0 a|0 ab abc abo al am an|0 ar arga argd arge argdo argg argl argu as au aug aun b|0 bN ba bad bd be bel bf bl bm bn bo bp br brea breaka breakd breakl bro bufdo buffers bun bw c|0 cN cNf ca cabc caddb cad caddf cal cat cb cc ccl cd ce cex cf cfir cgetb cgete cg changes chd che checkt cl cla clo cm cmapc cme cn cnew cnf cno cnorea cnoreme co col colo com comc comp con conf cope cp cpf cq cr cs cst cu cuna cunme cw delm deb debugg delc delf dif diffg diffo diffp diffpu diffs diffthis dig di dl dell dj dli do doautoa dp dr ds dsp e|0 ea ec echoe echoh echom echon el elsei em en endfo endf endt endw ene ex exe exi exu f|0 files filet fin fina fini fir fix fo foldc foldd folddoc foldo for fu go gr grepa gu gv ha helpf helpg helpt hi hid his ia iabc if ij il im imapc ime ino inorea inoreme int is isp iu iuna iunme j|0 ju k|0 keepa kee keepj lN lNf l|0 lad laddb laddf la lan lat lb lc lch lcl lcs le lefta let lex lf lfir lgetb lgete lg lgr lgrepa lh ll lla lli lmak lm lmapc lne lnew lnf ln loadk lo loc lockv lol lope lp lpf lr ls lt lu lua luad luaf lv lvimgrepa lw m|0 ma mak map mapc marks mat me menut mes mk mks mksp mkv mkvie mod mz mzf nbc nb nbs new nm nmapc nme nn nnoreme noa no noh norea noreme norm nu nun nunme ol o|0 om omapc ome on ono onoreme opt ou ounme ow p|0 profd prof pro promptr pc ped pe perld po popu pp pre prev ps pt ptN ptf ptj ptl ptn ptp ptr pts pu pw py3 python3 py3d py3f py pyd pyf quita qa rec red redi redr redraws reg res ret retu rew ri rightb rub rubyd rubyf rund ru rv sN san sa sal sav sb sbN sba sbf sbl sbm sbn sbp sbr scrip scripte scs se setf setg setl sf sfir sh sim sig sil sl sla sm smap smapc sme sn sni sno snor snoreme sor so spelld spe spelli spellr spellu spellw sp spr sre st sta startg startr star stopi stj sts sun sunm sunme sus sv sw sy synti sync tN tabN tabc tabdo tabe tabf tabfir tabl tabm tabnew tabn tabo tabp tabr tabs tab ta tags tc tcld tclf te tf th tj tl tm tn to tp tr try ts tu u|0 undoj undol una unh unl unlo unm unme uns up ve verb vert vim vimgrepa vi viu vie vm vmapc vme vne vn vnoreme vs vu vunme windo w|0 wN wa wh wi winc winp wn wp wq wqa ws wu wv x|0 xa xmapc xm xme xn xnoreme xu xunme y|0 z|0 ~ Next Print append abbreviate abclear aboveleft all amenu anoremenu args argadd argdelete argedit argglobal arglocal argument ascii autocmd augroup aunmenu buffer bNext ball badd bdelete behave belowright bfirst blast bmodified bnext botright bprevious brewind break breakadd breakdel breaklist browse bunload bwipeout change cNext cNfile cabbrev cabclear caddbuffer caddexpr caddfile call catch cbuffer cclose center cexpr cfile cfirst cgetbuffer cgetexpr cgetfile chdir checkpath checktime clist clast close cmap cmapclear cmenu cnext cnewer cnfile cnoremap cnoreabbrev cnoremenu copy colder colorscheme command comclear compiler continue confirm copen cprevious cpfile cquit crewind cscope cstag cunmap cunabbrev cunmenu cwindow delete delmarks debug debuggreedy delcommand delfunction diffupdate diffget diffoff diffpatch diffput diffsplit digraphs display deletel djump dlist doautocmd doautoall deletep drop dsearch dsplit edit earlier echo echoerr echohl echomsg else elseif emenu endif endfor endfunction endtry endwhile enew execute exit exusage file filetype find finally finish first fixdel fold foldclose folddoopen folddoclosed foldopen function global goto grep grepadd gui gvim hardcopy help helpfind helpgrep helptags highlight hide history insert iabbrev iabclear ijump ilist imap imapclear imenu inoremap inoreabbrev inoremenu intro isearch isplit iunmap iunabbrev iunmenu join jumps keepalt keepmarks keepjumps lNext lNfile list laddexpr laddbuffer laddfile last language later lbuffer lcd lchdir lclose lcscope left leftabove lexpr lfile lfirst lgetbuffer lgetexpr lgetfile lgrep lgrepadd lhelpgrep llast llist lmake lmap lmapclear lnext lnewer lnfile lnoremap loadkeymap loadview lockmarks lockvar lolder lopen lprevious lpfile lrewind ltag lunmap luado luafile lvimgrep lvimgrepadd lwindow move mark make mapclear match menu menutranslate messages mkexrc mksession mkspell mkvimrc mkview mode mzscheme mzfile nbclose nbkey nbsart next nmap nmapclear nmenu nnoremap nnoremenu noautocmd noremap nohlsearch noreabbrev noremenu normal number nunmap nunmenu oldfiles open omap omapclear omenu only onoremap onoremenu options ounmap ounmenu ownsyntax print profdel profile promptfind promptrepl pclose pedit perl perldo pop popup ppop preserve previous psearch ptag ptNext ptfirst ptjump ptlast ptnext ptprevious ptrewind ptselect put pwd py3do py3file python pydo pyfile quit quitall qall read recover redo redir redraw redrawstatus registers resize retab return rewind right rightbelow ruby rubydo rubyfile rundo runtime rviminfo substitute sNext sandbox sargument sall saveas sbuffer sbNext sball sbfirst sblast sbmodified sbnext sbprevious sbrewind scriptnames scriptencoding scscope set setfiletype setglobal setlocal sfind sfirst shell simalt sign silent sleep slast smagic smapclear smenu snext sniff snomagic snoremap snoremenu sort source spelldump spellgood spellinfo spellrepall spellundo spellwrong split sprevious srewind stop stag startgreplace startreplace startinsert stopinsert stjump stselect sunhide sunmap sunmenu suspend sview swapname syntax syntime syncbind tNext tabNext tabclose tabedit tabfind tabfirst tablast tabmove tabnext tabonly tabprevious tabrewind tag tcl tcldo tclfile tearoff tfirst throw tjump tlast tmenu tnext topleft tprevious trewind tselect tunmenu undo undojoin undolist unabbreviate unhide unlet unlockvar unmap unmenu unsilent update vglobal version verbose vertical vimgrep vimgrepadd visual viusage view vmap vmapclear vmenu vnew vnoremap vnoremenu vsplit vunmap vunmenu write wNext wall while winsize wincmd winpos wnext wprevious wqall wsverb wundo wviminfo xit xall xmapclear xmap xmenu xnoremap xnoremenu xunmap xunmenu yank",
+built_in:"synIDtrans atan2 range matcharg did_filetype asin feedkeys xor argv complete_check add getwinposx getqflist getwinposy screencol clearmatches empty extend getcmdpos mzeval garbagecollect setreg ceil sqrt diff_hlID inputsecret get getfperm getpid filewritable shiftwidth max sinh isdirectory synID system inputrestore winline atan visualmode inputlist tabpagewinnr round getregtype mapcheck hasmapto histdel argidx findfile sha256 exists toupper getcmdline taglist string getmatches bufnr strftime winwidth bufexists strtrans tabpagebuflist setcmdpos remote_read printf setloclist getpos getline bufwinnr float2nr len getcmdtype diff_filler luaeval resolve libcallnr foldclosedend reverse filter has_key bufname str2float strlen setline getcharmod setbufvar index searchpos shellescape undofile foldclosed setqflist buflisted strchars str2nr virtcol floor remove undotree remote_expr winheight gettabwinvar reltime cursor tabpagenr finddir localtime acos getloclist search tanh matchend rename gettabvar strdisplaywidth type abs py3eval setwinvar tolower wildmenumode log10 spellsuggest bufloaded synconcealed nextnonblank server2client complete settabwinvar executable input wincol setmatches getftype hlID inputsave searchpair or screenrow line settabvar histadd deepcopy strpart remote_peek and eval getftime submatch screenchar winsaveview matchadd mkdir screenattr getfontname libcall reltimestr getfsize winnr invert pow getbufline byte2line soundfold repeat fnameescape tagfiles sin strwidth spellbadword trunc maparg log lispindent hostname setpos globpath remote_foreground getchar synIDattr fnamemodify cscope_connection stridx winbufnr indent min complete_add nr2char searchpairpos inputdialog values matchlist items hlexists strridx browsedir expand fmod pathshorten line2byte argc count getwinvar glob foldtextresult getreg foreground cosh matchdelete has char2nr simplify histget searchdecl iconv winrestcmd pumvisible writefile foldlevel haslocaldir keys cos matchstr foldtext histnr tan tempname getcwd byteidx getbufvar islocked escape eventhandler remote_send serverlist winrestview synstack pyeval prevnonblank readfile cindent filereadable changenr exp"},
+illegal:/;/,contains:[a.NUMBER_MODE,{className:"string",begin:"'",end:"'",illegal:"\\n"},{className:"string",begin:/"(\\"|\n\\|[^"\n])*"/},a.COMMENT('"',"$"),{className:"variable",begin:/[bwtglsav]:[\w\d_]*/},{className:"function",beginKeywords:"function function!",end:"$",relevance:0,contains:[a.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]},{className:"symbol",begin:/<[\w-]+>/}]}});b.registerLanguage("x86asm",function(a){return{case_insensitive:!0,lexemes:"[.%]?"+a.IDENT_RE,keywords:{keyword:"lock rep repe repz repne repnz xaquire xrelease bnd nobnd aaa aad aam aas adc add and arpl bb0_reset bb1_reset bound bsf bsr bswap bt btc btr bts call cbw cdq cdqe clc cld cli clts cmc cmp cmpsb cmpsd cmpsq cmpsw cmpxchg cmpxchg486 cmpxchg8b cmpxchg16b cpuid cpu_read cpu_write cqo cwd cwde daa das dec div dmint emms enter equ f2xm1 fabs fadd faddp fbld fbstp fchs fclex fcmovb fcmovbe fcmove fcmovnb fcmovnbe fcmovne fcmovnu fcmovu fcom fcomi fcomip fcomp fcompp fcos fdecstp fdisi fdiv fdivp fdivr fdivrp femms feni ffree ffreep fiadd ficom ficomp fidiv fidivr fild fimul fincstp finit fist fistp fisttp fisub fisubr fld fld1 fldcw fldenv fldl2e fldl2t fldlg2 fldln2 fldpi fldz fmul fmulp fnclex fndisi fneni fninit fnop fnsave fnstcw fnstenv fnstsw fpatan fprem fprem1 fptan frndint frstor fsave fscale fsetpm fsin fsincos fsqrt fst fstcw fstenv fstp fstsw fsub fsubp fsubr fsubrp ftst fucom fucomi fucomip fucomp fucompp fxam fxch fxtract fyl2x fyl2xp1 hlt ibts icebp idiv imul in inc incbin insb insd insw int int01 int1 int03 int3 into invd invpcid invlpg invlpga iret iretd iretq iretw jcxz jecxz jrcxz jmp jmpe lahf lar lds lea leave les lfence lfs lgdt lgs lidt lldt lmsw loadall loadall286 lodsb lodsd lodsq lodsw loop loope loopne loopnz loopz lsl lss ltr mfence monitor mov movd movq movsb movsd movsq movsw movsx movsxd movzx mul mwait neg nop not or out outsb outsd outsw packssdw packsswb packuswb paddb paddd paddsb paddsiw paddsw paddusb paddusw paddw pand pandn pause paveb pavgusb pcmpeqb pcmpeqd pcmpeqw pcmpgtb pcmpgtd pcmpgtw pdistib pf2id pfacc pfadd pfcmpeq pfcmpge pfcmpgt pfmax pfmin pfmul pfrcp pfrcpit1 pfrcpit2 pfrsqit1 pfrsqrt pfsub pfsubr pi2fd pmachriw pmaddwd pmagw pmulhriw pmulhrwa pmulhrwc pmulhw pmullw pmvgezb pmvlzb pmvnzb pmvzb pop popa popad popaw popf popfd popfq popfw por prefetch prefetchw pslld psllq psllw psrad psraw psrld psrlq psrlw psubb psubd psubsb psubsiw psubsw psubusb psubusw psubw punpckhbw punpckhdq punpckhwd punpcklbw punpckldq punpcklwd push pusha pushad pushaw pushf pushfd pushfq pushfw pxor rcl rcr rdshr rdmsr rdpmc rdtsc rdtscp ret retf retn rol ror rdm rsdc rsldt rsm rsts sahf sal salc sar sbb scasb scasd scasq scasw sfence sgdt shl shld shr shrd sidt sldt skinit smi smint smintold smsw stc std sti stosb stosd stosq stosw str sub svdc svldt svts swapgs syscall sysenter sysexit sysret test ud0 ud1 ud2b ud2 ud2a umov verr verw fwait wbinvd wrshr wrmsr xadd xbts xchg xlatb xlat xor cmove cmovz cmovne cmovnz cmova cmovnbe cmovae cmovnb cmovb cmovnae cmovbe cmovna cmovg cmovnle cmovge cmovnl cmovl cmovnge cmovle cmovng cmovc cmovnc cmovo cmovno cmovs cmovns cmovp cmovpe cmovnp cmovpo je jz jne jnz ja jnbe jae jnb jb jnae jbe jna jg jnle jge jnl jl jnge jle jng jc jnc jo jno js jns jpo jnp jpe jp sete setz setne setnz seta setnbe setae setnb setnc setb setnae setcset setbe setna setg setnle setge setnl setl setnge setle setng sets setns seto setno setpe setp setpo setnp addps addss andnps andps cmpeqps cmpeqss cmpleps cmpless cmpltps cmpltss cmpneqps cmpneqss cmpnleps cmpnless cmpnltps cmpnltss cmpordps cmpordss cmpunordps cmpunordss cmpps cmpss comiss cvtpi2ps cvtps2pi cvtsi2ss cvtss2si cvttps2pi cvttss2si divps divss ldmxcsr maxps maxss minps minss movaps movhps movlhps movlps movhlps movmskps movntps movss movups mulps mulss orps rcpps rcpss rsqrtps rsqrtss shufps sqrtps sqrtss stmxcsr subps subss ucomiss unpckhps unpcklps xorps fxrstor fxrstor64 fxsave fxsave64 xgetbv xsetbv xsave xsave64 xsaveopt xsaveopt64 xrstor xrstor64 prefetchnta prefetcht0 prefetcht1 prefetcht2 maskmovq movntq pavgb pavgw pextrw pinsrw pmaxsw pmaxub pminsw pminub pmovmskb pmulhuw psadbw pshufw pf2iw pfnacc pfpnacc pi2fw pswapd maskmovdqu clflush movntdq movnti movntpd movdqa movdqu movdq2q movq2dq paddq pmuludq pshufd pshufhw pshuflw pslldq psrldq psubq punpckhqdq punpcklqdq addpd addsd andnpd andpd cmpeqpd cmpeqsd cmplepd cmplesd cmpltpd cmpltsd cmpneqpd cmpneqsd cmpnlepd cmpnlesd cmpnltpd cmpnltsd cmpordpd cmpordsd cmpunordpd cmpunordsd cmppd comisd cvtdq2pd cvtdq2ps cvtpd2dq cvtpd2pi cvtpd2ps cvtpi2pd cvtps2dq cvtps2pd cvtsd2si cvtsd2ss cvtsi2sd cvtss2sd cvttpd2pi cvttpd2dq cvttps2dq cvttsd2si divpd divsd maxpd maxsd minpd minsd movapd movhpd movlpd movmskpd movupd mulpd mulsd orpd shufpd sqrtpd sqrtsd subpd subsd ucomisd unpckhpd unpcklpd xorpd addsubpd addsubps haddpd haddps hsubpd hsubps lddqu movddup movshdup movsldup clgi stgi vmcall vmclear vmfunc vmlaunch vmload vmmcall vmptrld vmptrst vmread vmresume vmrun vmsave vmwrite vmxoff vmxon invept invvpid pabsb pabsw pabsd palignr phaddw phaddd phaddsw phsubw phsubd phsubsw pmaddubsw pmulhrsw pshufb psignb psignw psignd extrq insertq movntsd movntss lzcnt blendpd blendps blendvpd blendvps dppd dpps extractps insertps movntdqa mpsadbw packusdw pblendvb pblendw pcmpeqq pextrb pextrd pextrq phminposuw pinsrb pinsrd pinsrq pmaxsb pmaxsd pmaxud pmaxuw pminsb pminsd pminud pminuw pmovsxbw pmovsxbd pmovsxbq pmovsxwd pmovsxwq pmovsxdq pmovzxbw pmovzxbd pmovzxbq pmovzxwd pmovzxwq pmovzxdq pmuldq pmulld ptest roundpd roundps roundsd roundss crc32 pcmpestri pcmpestrm pcmpistri pcmpistrm pcmpgtq popcnt getsec pfrcpv pfrsqrtv movbe aesenc aesenclast aesdec aesdeclast aesimc aeskeygenassist vaesenc vaesenclast vaesdec vaesdeclast vaesimc vaeskeygenassist vaddpd vaddps vaddsd vaddss vaddsubpd vaddsubps vandpd vandps vandnpd vandnps vblendpd vblendps vblendvpd vblendvps vbroadcastss vbroadcastsd vbroadcastf128 vcmpeq_ospd vcmpeqpd vcmplt_ospd vcmpltpd vcmple_ospd vcmplepd vcmpunord_qpd vcmpunordpd vcmpneq_uqpd vcmpneqpd vcmpnlt_uspd vcmpnltpd vcmpnle_uspd vcmpnlepd vcmpord_qpd vcmpordpd vcmpeq_uqpd vcmpnge_uspd vcmpngepd vcmpngt_uspd vcmpngtpd vcmpfalse_oqpd vcmpfalsepd vcmpneq_oqpd vcmpge_ospd vcmpgepd vcmpgt_ospd vcmpgtpd vcmptrue_uqpd vcmptruepd vcmplt_oqpd vcmple_oqpd vcmpunord_spd vcmpneq_uspd vcmpnlt_uqpd vcmpnle_uqpd vcmpord_spd vcmpeq_uspd vcmpnge_uqpd vcmpngt_uqpd vcmpfalse_ospd vcmpneq_ospd vcmpge_oqpd vcmpgt_oqpd vcmptrue_uspd vcmppd vcmpeq_osps vcmpeqps vcmplt_osps vcmpltps vcmple_osps vcmpleps vcmpunord_qps vcmpunordps vcmpneq_uqps vcmpneqps vcmpnlt_usps vcmpnltps vcmpnle_usps vcmpnleps vcmpord_qps vcmpordps vcmpeq_uqps vcmpnge_usps vcmpngeps vcmpngt_usps vcmpngtps vcmpfalse_oqps vcmpfalseps vcmpneq_oqps vcmpge_osps vcmpgeps vcmpgt_osps vcmpgtps vcmptrue_uqps vcmptrueps vcmplt_oqps vcmple_oqps vcmpunord_sps vcmpneq_usps vcmpnlt_uqps vcmpnle_uqps vcmpord_sps vcmpeq_usps vcmpnge_uqps vcmpngt_uqps vcmpfalse_osps vcmpneq_osps vcmpge_oqps vcmpgt_oqps vcmptrue_usps vcmpps vcmpeq_ossd vcmpeqsd vcmplt_ossd vcmpltsd vcmple_ossd vcmplesd vcmpunord_qsd vcmpunordsd vcmpneq_uqsd vcmpneqsd vcmpnlt_ussd vcmpnltsd vcmpnle_ussd vcmpnlesd vcmpord_qsd vcmpordsd vcmpeq_uqsd vcmpnge_ussd vcmpngesd vcmpngt_ussd vcmpngtsd vcmpfalse_oqsd vcmpfalsesd vcmpneq_oqsd vcmpge_ossd vcmpgesd vcmpgt_ossd vcmpgtsd vcmptrue_uqsd vcmptruesd vcmplt_oqsd vcmple_oqsd vcmpunord_ssd vcmpneq_ussd vcmpnlt_uqsd vcmpnle_uqsd vcmpord_ssd vcmpeq_ussd vcmpnge_uqsd vcmpngt_uqsd vcmpfalse_ossd vcmpneq_ossd vcmpge_oqsd vcmpgt_oqsd vcmptrue_ussd vcmpsd vcmpeq_osss vcmpeqss vcmplt_osss vcmpltss vcmple_osss vcmpless vcmpunord_qss vcmpunordss vcmpneq_uqss vcmpneqss vcmpnlt_usss vcmpnltss vcmpnle_usss vcmpnless vcmpord_qss vcmpordss vcmpeq_uqss vcmpnge_usss vcmpngess vcmpngt_usss vcmpngtss vcmpfalse_oqss vcmpfalsess vcmpneq_oqss vcmpge_osss vcmpgess vcmpgt_osss vcmpgtss vcmptrue_uqss vcmptruess vcmplt_oqss vcmple_oqss vcmpunord_sss vcmpneq_usss vcmpnlt_uqss vcmpnle_uqss vcmpord_sss vcmpeq_usss vcmpnge_uqss vcmpngt_uqss vcmpfalse_osss vcmpneq_osss vcmpge_oqss vcmpgt_oqss vcmptrue_usss vcmpss vcomisd vcomiss vcvtdq2pd vcvtdq2ps vcvtpd2dq vcvtpd2ps vcvtps2dq vcvtps2pd vcvtsd2si vcvtsd2ss vcvtsi2sd vcvtsi2ss vcvtss2sd vcvtss2si vcvttpd2dq vcvttps2dq vcvttsd2si vcvttss2si vdivpd vdivps vdivsd vdivss vdppd vdpps vextractf128 vextractps vhaddpd vhaddps vhsubpd vhsubps vinsertf128 vinsertps vlddqu vldqqu vldmxcsr vmaskmovdqu vmaskmovps vmaskmovpd vmaxpd vmaxps vmaxsd vmaxss vminpd vminps vminsd vminss vmovapd vmovaps vmovd vmovq vmovddup vmovdqa vmovqqa vmovdqu vmovqqu vmovhlps vmovhpd vmovhps vmovlhps vmovlpd vmovlps vmovmskpd vmovmskps vmovntdq vmovntqq vmovntdqa vmovntpd vmovntps vmovsd vmovshdup vmovsldup vmovss vmovupd vmovups vmpsadbw vmulpd vmulps vmulsd vmulss vorpd vorps vpabsb vpabsw vpabsd vpacksswb vpackssdw vpackuswb vpackusdw vpaddb vpaddw vpaddd vpaddq vpaddsb vpaddsw vpaddusb vpaddusw vpalignr vpand vpandn vpavgb vpavgw vpblendvb vpblendw vpcmpestri vpcmpestrm vpcmpistri vpcmpistrm vpcmpeqb vpcmpeqw vpcmpeqd vpcmpeqq vpcmpgtb vpcmpgtw vpcmpgtd vpcmpgtq vpermilpd vpermilps vperm2f128 vpextrb vpextrw vpextrd vpextrq vphaddw vphaddd vphaddsw vphminposuw vphsubw vphsubd vphsubsw vpinsrb vpinsrw vpinsrd vpinsrq vpmaddwd vpmaddubsw vpmaxsb vpmaxsw vpmaxsd vpmaxub vpmaxuw vpmaxud vpminsb vpminsw vpminsd vpminub vpminuw vpminud vpmovmskb vpmovsxbw vpmovsxbd vpmovsxbq vpmovsxwd vpmovsxwq vpmovsxdq vpmovzxbw vpmovzxbd vpmovzxbq vpmovzxwd vpmovzxwq vpmovzxdq vpmulhuw vpmulhrsw vpmulhw vpmullw vpmulld vpmuludq vpmuldq vpor vpsadbw vpshufb vpshufd vpshufhw vpshuflw vpsignb vpsignw vpsignd vpslldq vpsrldq vpsllw vpslld vpsllq vpsraw vpsrad vpsrlw vpsrld vpsrlq vptest vpsubb vpsubw vpsubd vpsubq vpsubsb vpsubsw vpsubusb vpsubusw vpunpckhbw vpunpckhwd vpunpckhdq vpunpckhqdq vpunpcklbw vpunpcklwd vpunpckldq vpunpcklqdq vpxor vrcpps vrcpss vrsqrtps vrsqrtss vroundpd vroundps vroundsd vroundss vshufpd vshufps vsqrtpd vsqrtps vsqrtsd vsqrtss vstmxcsr vsubpd vsubps vsubsd vsubss vtestps vtestpd vucomisd vucomiss vunpckhpd vunpckhps vunpcklpd vunpcklps vxorpd vxorps vzeroall vzeroupper pclmullqlqdq pclmulhqlqdq pclmullqhqdq pclmulhqhqdq pclmulqdq vpclmullqlqdq vpclmulhqlqdq vpclmullqhqdq vpclmulhqhqdq vpclmulqdq vfmadd132ps vfmadd132pd vfmadd312ps vfmadd312pd vfmadd213ps vfmadd213pd vfmadd123ps vfmadd123pd vfmadd231ps vfmadd231pd vfmadd321ps vfmadd321pd vfmaddsub132ps vfmaddsub132pd vfmaddsub312ps vfmaddsub312pd vfmaddsub213ps vfmaddsub213pd vfmaddsub123ps vfmaddsub123pd vfmaddsub231ps vfmaddsub231pd vfmaddsub321ps vfmaddsub321pd vfmsub132ps vfmsub132pd vfmsub312ps vfmsub312pd vfmsub213ps vfmsub213pd vfmsub123ps vfmsub123pd vfmsub231ps vfmsub231pd vfmsub321ps vfmsub321pd vfmsubadd132ps vfmsubadd132pd vfmsubadd312ps vfmsubadd312pd vfmsubadd213ps vfmsubadd213pd vfmsubadd123ps vfmsubadd123pd vfmsubadd231ps vfmsubadd231pd vfmsubadd321ps vfmsubadd321pd vfnmadd132ps vfnmadd132pd vfnmadd312ps vfnmadd312pd vfnmadd213ps vfnmadd213pd vfnmadd123ps vfnmadd123pd vfnmadd231ps vfnmadd231pd vfnmadd321ps vfnmadd321pd vfnmsub132ps vfnmsub132pd vfnmsub312ps vfnmsub312pd vfnmsub213ps vfnmsub213pd vfnmsub123ps vfnmsub123pd vfnmsub231ps vfnmsub231pd vfnmsub321ps vfnmsub321pd vfmadd132ss vfmadd132sd vfmadd312ss vfmadd312sd vfmadd213ss vfmadd213sd vfmadd123ss vfmadd123sd vfmadd231ss vfmadd231sd vfmadd321ss vfmadd321sd vfmsub132ss vfmsub132sd vfmsub312ss vfmsub312sd vfmsub213ss vfmsub213sd vfmsub123ss vfmsub123sd vfmsub231ss vfmsub231sd vfmsub321ss vfmsub321sd vfnmadd132ss vfnmadd132sd vfnmadd312ss vfnmadd312sd vfnmadd213ss vfnmadd213sd vfnmadd123ss vfnmadd123sd vfnmadd231ss vfnmadd231sd vfnmadd321ss vfnmadd321sd vfnmsub132ss vfnmsub132sd vfnmsub312ss vfnmsub312sd vfnmsub213ss vfnmsub213sd vfnmsub123ss vfnmsub123sd vfnmsub231ss vfnmsub231sd vfnmsub321ss vfnmsub321sd rdfsbase rdgsbase rdrand wrfsbase wrgsbase vcvtph2ps vcvtps2ph adcx adox rdseed clac stac xstore xcryptecb xcryptcbc xcryptctr xcryptcfb xcryptofb montmul xsha1 xsha256 llwpcb slwpcb lwpval lwpins vfmaddpd vfmaddps vfmaddsd vfmaddss vfmaddsubpd vfmaddsubps vfmsubaddpd vfmsubaddps vfmsubpd vfmsubps vfmsubsd vfmsubss vfnmaddpd vfnmaddps vfnmaddsd vfnmaddss vfnmsubpd vfnmsubps vfnmsubsd vfnmsubss vfrczpd vfrczps vfrczsd vfrczss vpcmov vpcomb vpcomd vpcomq vpcomub vpcomud vpcomuq vpcomuw vpcomw vphaddbd vphaddbq vphaddbw vphadddq vphaddubd vphaddubq vphaddubw vphaddudq vphadduwd vphadduwq vphaddwd vphaddwq vphsubbw vphsubdq vphsubwd vpmacsdd vpmacsdqh vpmacsdql vpmacssdd vpmacssdqh vpmacssdql vpmacsswd vpmacssww vpmacswd vpmacsww vpmadcsswd vpmadcswd vpperm vprotb vprotd vprotq vprotw vpshab vpshad vpshaq vpshaw vpshlb vpshld vpshlq vpshlw vbroadcasti128 vpblendd vpbroadcastb vpbroadcastw vpbroadcastd vpbroadcastq vpermd vpermpd vpermps vpermq vperm2i128 vextracti128 vinserti128 vpmaskmovd vpmaskmovq vpsllvd vpsllvq vpsravd vpsrlvd vpsrlvq vgatherdpd vgatherqpd vgatherdps vgatherqps vpgatherdd vpgatherqd vpgatherdq vpgatherqq xabort xbegin xend xtest andn bextr blci blcic blsi blsic blcfill blsfill blcmsk blsmsk blsr blcs bzhi mulx pdep pext rorx sarx shlx shrx tzcnt tzmsk t1mskc valignd valignq vblendmpd vblendmps vbroadcastf32x4 vbroadcastf64x4 vbroadcasti32x4 vbroadcasti64x4 vcompresspd vcompressps vcvtpd2udq vcvtps2udq vcvtsd2usi vcvtss2usi vcvttpd2udq vcvttps2udq vcvttsd2usi vcvttss2usi vcvtudq2pd vcvtudq2ps vcvtusi2sd vcvtusi2ss vexpandpd vexpandps vextractf32x4 vextractf64x4 vextracti32x4 vextracti64x4 vfixupimmpd vfixupimmps vfixupimmsd vfixupimmss vgetexppd vgetexpps vgetexpsd vgetexpss vgetmantpd vgetmantps vgetmantsd vgetmantss vinsertf32x4 vinsertf64x4 vinserti32x4 vinserti64x4 vmovdqa32 vmovdqa64 vmovdqu32 vmovdqu64 vpabsq vpandd vpandnd vpandnq vpandq vpblendmd vpblendmq vpcmpltd vpcmpled vpcmpneqd vpcmpnltd vpcmpnled vpcmpd vpcmpltq vpcmpleq vpcmpneqq vpcmpnltq vpcmpnleq vpcmpq vpcmpequd vpcmpltud vpcmpleud vpcmpnequd vpcmpnltud vpcmpnleud vpcmpud vpcmpequq vpcmpltuq vpcmpleuq vpcmpnequq vpcmpnltuq vpcmpnleuq vpcmpuq vpcompressd vpcompressq vpermi2d vpermi2pd vpermi2ps vpermi2q vpermt2d vpermt2pd vpermt2ps vpermt2q vpexpandd vpexpandq vpmaxsq vpmaxuq vpminsq vpminuq vpmovdb vpmovdw vpmovqb vpmovqd vpmovqw vpmovsdb vpmovsdw vpmovsqb vpmovsqd vpmovsqw vpmovusdb vpmovusdw vpmovusqb vpmovusqd vpmovusqw vpord vporq vprold vprolq vprolvd vprolvq vprord vprorq vprorvd vprorvq vpscatterdd vpscatterdq vpscatterqd vpscatterqq vpsraq vpsravq vpternlogd vpternlogq vptestmd vptestmq vptestnmd vptestnmq vpxord vpxorq vrcp14pd vrcp14ps vrcp14sd vrcp14ss vrndscalepd vrndscaleps vrndscalesd vrndscaless vrsqrt14pd vrsqrt14ps vrsqrt14sd vrsqrt14ss vscalefpd vscalefps vscalefsd vscalefss vscatterdpd vscatterdps vscatterqpd vscatterqps vshuff32x4 vshuff64x2 vshufi32x4 vshufi64x2 kandnw kandw kmovw knotw kortestw korw kshiftlw kshiftrw kunpckbw kxnorw kxorw vpbroadcastmb2q vpbroadcastmw2d vpconflictd vpconflictq vplzcntd vplzcntq vexp2pd vexp2ps vrcp28pd vrcp28ps vrcp28sd vrcp28ss vrsqrt28pd vrsqrt28ps vrsqrt28sd vrsqrt28ss vgatherpf0dpd vgatherpf0dps vgatherpf0qpd vgatherpf0qps vgatherpf1dpd vgatherpf1dps vgatherpf1qpd vgatherpf1qps vscatterpf0dpd vscatterpf0dps vscatterpf0qpd vscatterpf0qps vscatterpf1dpd vscatterpf1dps vscatterpf1qpd vscatterpf1qps prefetchwt1 bndmk bndcl bndcu bndcn bndmov bndldx bndstx sha1rnds4 sha1nexte sha1msg1 sha1msg2 sha256rnds2 sha256msg1 sha256msg2 hint_nop0 hint_nop1 hint_nop2 hint_nop3 hint_nop4 hint_nop5 hint_nop6 hint_nop7 hint_nop8 hint_nop9 hint_nop10 hint_nop11 hint_nop12 hint_nop13 hint_nop14 hint_nop15 hint_nop16 hint_nop17 hint_nop18 hint_nop19 hint_nop20 hint_nop21 hint_nop22 hint_nop23 hint_nop24 hint_nop25 hint_nop26 hint_nop27 hint_nop28 hint_nop29 hint_nop30 hint_nop31 hint_nop32 hint_nop33 hint_nop34 hint_nop35 hint_nop36 hint_nop37 hint_nop38 hint_nop39 hint_nop40 hint_nop41 hint_nop42 hint_nop43 hint_nop44 hint_nop45 hint_nop46 hint_nop47 hint_nop48 hint_nop49 hint_nop50 hint_nop51 hint_nop52 hint_nop53 hint_nop54 hint_nop55 hint_nop56 hint_nop57 hint_nop58 hint_nop59 hint_nop60 hint_nop61 hint_nop62 hint_nop63",
+built_in:"ip eip rip al ah bl bh cl ch dl dh sil dil bpl spl r8b r9b r10b r11b r12b r13b r14b r15b ax bx cx dx si di bp sp r8w r9w r10w r11w r12w r13w r14w r15w eax ebx ecx edx esi edi ebp esp eip r8d r9d r10d r11d r12d r13d r14d r15d rax rbx rcx rdx rsi rdi rbp rsp r8 r9 r10 r11 r12 r13 r14 r15 cs ds es fs gs ss st st0 st1 st2 st3 st4 st5 st6 st7 mm0 mm1 mm2 mm3 mm4 mm5 mm6 mm7 xmm0  xmm1  xmm2  xmm3  xmm4  xmm5  xmm6  xmm7  xmm8  xmm9 xmm10  xmm11 xmm12 xmm13 xmm14 xmm15 xmm16 xmm17 xmm18 xmm19 xmm20 xmm21 xmm22 xmm23 xmm24 xmm25 xmm26 xmm27 xmm28 xmm29 xmm30 xmm31 ymm0  ymm1  ymm2  ymm3  ymm4  ymm5  ymm6  ymm7  ymm8  ymm9 ymm10  ymm11 ymm12 ymm13 ymm14 ymm15 ymm16 ymm17 ymm18 ymm19 ymm20 ymm21 ymm22 ymm23 ymm24 ymm25 ymm26 ymm27 ymm28 ymm29 ymm30 ymm31 zmm0  zmm1  zmm2  zmm3  zmm4  zmm5  zmm6  zmm7  zmm8  zmm9 zmm10  zmm11 zmm12 zmm13 zmm14 zmm15 zmm16 zmm17 zmm18 zmm19 zmm20 zmm21 zmm22 zmm23 zmm24 zmm25 zmm26 zmm27 zmm28 zmm29 zmm30 zmm31 k0 k1 k2 k3 k4 k5 k6 k7 bnd0 bnd1 bnd2 bnd3 cr0 cr1 cr2 cr3 cr4 cr8 dr0 dr1 dr2 dr3 dr8 tr3 tr4 tr5 tr6 tr7 r0 r1 r2 r3 r4 r5 r6 r7 r0b r1b r2b r3b r4b r5b r6b r7b r0w r1w r2w r3w r4w r5w r6w r7w r0d r1d r2d r3d r4d r5d r6d r7d r0h r1h r2h r3h r0l r1l r2l r3l r4l r5l r6l r7l r8l r9l r10l r11l r12l r13l r14l r15l db dw dd dq dt ddq do dy dz resb resw resd resq rest resdq reso resy resz incbin equ times byte word dword qword nosplit rel abs seg wrt strict near far a32 ptr",
+meta:"%define %xdefine %+ %undef %defstr %deftok %assign %strcat %strlen %substr %rotate %elif %else %endif %if %ifmacro %ifctx %ifidn %ifidni %ifid %ifnum %ifstr %iftoken %ifempty %ifenv %error %warning %fatal %rep %endrep %include %push %pop %repl %pathsearch %depend %use %arg %stacksize %local %line %comment %endcomment .nolist __FILE__ __LINE__ __SECT__  __BITS__ __OUTPUT_FORMAT__ __DATE__ __TIME__ __DATE_NUM__ __TIME_NUM__ __UTC_DATE__ __UTC_TIME__ __UTC_DATE_NUM__ __UTC_TIME_NUM__  __PASS__ struc endstruc istruc at iend align alignb sectalign daz nodaz up down zero default option assume public bits use16 use32 use64 default section segment absolute extern global common cpu float __utf16__ __utf16le__ __utf16be__ __utf32__ __utf32le__ __utf32be__ __float8__ __float16__ __float32__ __float64__ __float80m__ __float80e__ __float128l__ __float128h__ __Infinity__ __QNaN__ __SNaN__ Inf NaN QNaN SNaN float8 float16 float32 float64 float80m float80e float128l float128h __FLOAT_DAZ__ __FLOAT_ROUND__ __FLOAT__"},
+contains:[a.COMMENT(";","$",{relevance:0}),{className:"number",variants:[{begin:"\\b(?:([0-9][0-9_]*)?\\.[0-9_]*(?:[eE][+-]?[0-9_]+)?|(0[Xx])?[0-9][0-9_]*\\.?[0-9_]*(?:[pP](?:[+-]?[0-9_]+)?)?)\\b",relevance:0},{begin:"\\$[0-9][0-9A-Fa-f]*",relevance:0},{begin:"\\b(?:[0-9A-Fa-f][0-9A-Fa-f_]*[Hh]|[0-9][0-9_]*[DdTt]?|[0-7][0-7_]*[QqOo]|[0-1][0-1_]*[BbYy])\\b"},{begin:"\\b(?:0[Xx][0-9A-Fa-f_]+|0[DdTt][0-9_]+|0[QqOo][0-7_]+|0[BbYy][0-1_]+)\\b"}]},a.QUOTE_STRING_MODE,{className:"string",variants:[{begin:"'",
+end:"[^\\\\]'"},{begin:"`",end:"[^\\\\]`"}],relevance:0},{className:"symbol",variants:[{begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)"},{begin:"^\\s*%%[A-Za-z0-9_$#@~.?]*:"}],relevance:0},{className:"subst",begin:"%[0-9]+",relevance:0},{className:"subst",begin:"%!S+",relevance:0},{className:"meta",begin:/^\s*\.[\w_-]+/}]}});b.registerLanguage("xl",function(a){var b={keyword:"if then else do while until for loop import with is as where when by data constant integer real text name boolean symbol infix prefix postfix block tree",
+literal:"true false nil",built_in:"in mod rem and or xor not abs sign floor ceil sqrt sin cos tan asin acos atan exp expm1 log log2 log10 log1p pi at text_length text_range text_find text_replace contains page slide basic_slide title_slide title subtitle fade_in fade_out fade_at clear_color color line_color line_width texture_wrap texture_transform texture scale_?x scale_?y scale_?z? translate_?x translate_?y translate_?z? rotate_?x rotate_?y rotate_?z? rectangle circle ellipse sphere path line_to move_to quad_to curve_to theme background contents locally time mouse_?x mouse_?y mouse_buttons ObjectLoader Animate MovieCredits Slides Filters Shading Materials LensFlare Mapping VLCAudioVideo StereoDecoder PointCloud NetworkAccess RemoteControl RegExp ChromaKey Snowfall NodeJS Speech Charts"},
+d={className:"string",begin:'"',end:'"',illegal:"\\n"},e={beginKeywords:"import",end:"$",keywords:b,contains:[d]},f={className:"function",begin:/[a-z][^\n]*->/,returnBegin:!0,end:/->/,contains:[a.inherit(a.TITLE_MODE,{starts:{endsWithParent:!0,keywords:b}})]};return{aliases:["tao"],lexemes:/[a-zA-Z][a-zA-Z0-9_?]*/,keywords:b,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,d,{className:"string",begin:"'",end:"'",illegal:"\\n"},{className:"string",begin:"<<",end:">>"},f,e,{className:"number",
+begin:"[0-9]+#[0-9A-Z_]+(\\.[0-9-A-Z_]+)?#?([Ee][+-]?[0-9]+)?"},a.NUMBER_MODE]}});b.registerLanguage("xquery",function(a){return{aliases:["xpath","xq"],case_insensitive:!1,lexemes:/[a-zA-Z\$][a-zA-Z0-9_:\-]*/,illegal:/(proc)|(abstract)|(extends)|(until)|(#)/,keywords:{keyword:"module schema namespace boundary-space preserve no-preserve strip default collation base-uri ordering context decimal-format decimal-separator copy-namespaces empty-sequence except exponent-separator external grouping-separator inherit no-inherit lax minus-sign per-mille percent schema-attribute schema-element strict unordered zero-digit declare import option function validate variable for at in let where order group by return if then else tumbling sliding window start when only end previous next stable ascending descending allowing empty greatest least some every satisfies switch case typeswitch try catch and or to union intersect instance of treat as castable cast map array delete insert into replace value rename copy modify update",
+type:"item document-node node attribute document element comment namespace namespace-node processing-instruction text construction xs:anyAtomicType xs:untypedAtomic xs:duration xs:time xs:decimal xs:float xs:double xs:gYearMonth xs:gYear xs:gMonthDay xs:gMonth xs:gDay xs:boolean xs:base64Binary xs:hexBinary xs:anyURI xs:QName xs:NOTATION xs:dateTime xs:dateTimeStamp xs:date xs:string xs:normalizedString xs:token xs:language xs:NMTOKEN xs:Name xs:NCName xs:ID xs:IDREF xs:ENTITY xs:integer xs:nonPositiveInteger xs:negativeInteger xs:long xs:int xs:short xs:byte xs:nonNegativeInteger xs:unisignedLong xs:unsignedInt xs:unsignedShort xs:unsignedByte xs:positiveInteger xs:yearMonthDuration xs:dayTimeDuration",
+literal:"eq ne lt le gt ge is self:: child:: descendant:: descendant-or-self:: attribute:: following:: following-sibling:: parent:: ancestor:: ancestor-or-self:: preceding:: preceding-sibling:: NaN"},contains:[{className:"variable",begin:/[\$][\w-:]+/},{className:"built_in",variants:[{begin:/\barray:/,end:/(?:append|filter|flatten|fold\-(?:left|right)|for-each(?:\-pair)?|get|head|insert\-before|join|put|remove|reverse|size|sort|subarray|tail)\b/},{begin:/\bmap:/,end:/(?:contains|entry|find|for\-each|get|keys|merge|put|remove|size)\b/},
+{begin:/\bmath:/,end:/(?:a(?:cos|sin|tan[2]?)|cos|exp(?:10)?|log(?:10)?|pi|pow|sin|sqrt|tan)\b/},{begin:/\bop:/,end:/\(/,excludeEnd:!0},{begin:/\bfn:/,end:/\(/,excludeEnd:!0},{begin:/[^<\/\$:'"-]\b(?:abs|accumulator\-(?:after|before)|adjust\-(?:date(?:Time)?|time)\-to\-timezone|analyze\-string|apply|available\-(?:environment\-variables|system\-properties)|avg|base\-uri|boolean|ceiling|codepoints?\-(?:equal|to\-string)|collation\-key|collection|compare|concat|contains(?:\-token)?|copy\-of|count|current(?:\-)?(?:date(?:Time)?|time|group(?:ing\-key)?|output\-uri|merge\-(?:group|key))?data|dateTime|days?\-from\-(?:date(?:Time)?|duration)|deep\-equal|default\-(?:collation|language)|distinct\-values|document(?:\-uri)?|doc(?:\-available)?|element\-(?:available|with\-id)|empty|encode\-for\-uri|ends\-with|environment\-variable|error|escape\-html\-uri|exactly\-one|exists|false|filter|floor|fold\-(?:left|right)|for\-each(?:\-pair)?|format\-(?:date(?:Time)?|time|integer|number)|function\-(?:arity|available|lookup|name)|generate\-id|has\-children|head|hours\-from\-(?:dateTime|duration|time)|id(?:ref)?|implicit\-timezone|in\-scope\-prefixes|index\-of|innermost|insert\-before|iri\-to\-uri|json\-(?:doc|to\-xml)|key|lang|last|load\-xquery\-module|local\-name(?:\-from\-QName)?|(?:lower|upper)\-case|matches|max|minutes\-from\-(?:dateTime|duration|time)|min|months?\-from\-(?:date(?:Time)?|duration)|name(?:space\-uri\-?(?:for\-prefix|from\-QName)?)?|nilled|node\-name|normalize\-(?:space|unicode)|not|number|one\-or\-more|outermost|parse\-(?:ietf\-date|json)|path|position|(?:prefix\-from\-)?QName|random\-number\-generator|regex\-group|remove|replace|resolve\-(?:QName|uri)|reverse|root|round(?:\-half\-to\-even)?|seconds\-from\-(?:dateTime|duration|time)|snapshot|sort|starts\-with|static\-base\-uri|stream\-available|string\-?(?:join|length|to\-codepoints)?|subsequence|substring\-?(?:after|before)?|sum|system\-property|tail|timezone\-from\-(?:date(?:Time)?|time)|tokenize|trace|trans(?:form|late)|true|type\-available|unordered|unparsed\-(?:entity|text)?\-?(?:public\-id|uri|available|lines)?|uri\-collection|xml\-to\-json|years?\-from\-(?:date(?:Time)?|duration)|zero\-or\-one)\b/},
+{begin:/\blocal:/,end:/\(/,excludeEnd:!0},{begin:/\bzip:/,end:/(?:zip\-file|(?:xml|html|text|binary)\-entry| (?:update\-)?entries)\b/},{begin:/\b(?:util|db|functx|app|xdmp|xmldb):/,end:/\(/,excludeEnd:!0}]},{className:"string",variants:[{begin:/"/,end:/"/,contains:[{begin:/""/,relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{className:"comment",begin:"\\(:",end:":\\)",
+relevance:10,contains:[{className:"doctag",begin:"@\\w+"}]},{className:"meta",begin:/%[\w-:]+/},{className:"title",begin:/\bxquery version "[13]\.[01]"\s?(?:encoding ".+")?/,end:/;/},{beginKeywords:"element attribute comment document processing-instruction",end:"{",excludeEnd:!0},{begin:/<([\w\._:\-]+)((\s*.*)=('|").*('|"))?>/,end:/(\/[\w\._:\-]+>)/,subLanguage:"xml",contains:[{begin:"{",end:"}",subLanguage:"xquery"},"self"]}]}});b.registerLanguage("zephir",function(a){var b={className:"string",contains:[a.BACKSLASH_ESCAPE],
+variants:[{begin:'b"',end:'"'},{begin:"b'",end:"'"},a.inherit(a.APOS_STRING_MODE,{illegal:null}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null})]},d={variants:[a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE]};return{aliases:["zep"],case_insensitive:!0,keywords:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var let while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally int uint long ulong char uchar double float bool boolean stringlikely unlikely",
+contains:[a.C_LINE_COMMENT_MODE,a.HASH_COMMENT_MODE,a.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),a.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,keywords:"__halt_compiler",lexemes:a.UNDERSCORE_IDENT_RE}),{className:"string",begin:"<<<['\"]?\\w+['\"]?$",end:"^\\w+;",contains:[a.BACKSLASH_ESCAPE]},{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function",beginKeywords:"function",end:/[;{]/,excludeEnd:!0,illegal:"\\$|\\[|%",contains:[a.UNDERSCORE_TITLE_MODE,
+{className:"params",begin:"\\(",end:"\\)",contains:["self",a.C_BLOCK_COMMENT_MODE,b,d]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",end:";",contains:[a.UNDERSCORE_TITLE_MODE]},{begin:"=>"},b,d]}});return b});
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD
index 8e9fbc5..03d9b68 100644
--- a/lib/httpcomponents/BUILD
+++ b/lib/httpcomponents/BUILD
@@ -3,7 +3,6 @@
 java_library(
     name = "fluent-hc",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@fluent-hc//jar"],
     runtime_deps = [":httpclient"],
 )
@@ -11,7 +10,6 @@
 java_library(
     name = "httpclient",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@httpclient//jar"],
     runtime_deps = [
         ":httpcore",
@@ -23,25 +21,22 @@
 java_library(
     name = "httpcore",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@httpcore//jar"],
 )
 
 java_library(
-    name = "httpmime",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@httpmime//jar"],
-)
-
-java_library(
     name = "httpasyncclient",
     data = ["//lib:LICENSE-Apache2.0"],
+    visibility = [
+        "//java/com/google/gerrit/elasticsearch:__pkg__",
+        "//javatests/com/google/gerrit/elasticsearch:__pkg__",
+    ],
     exports = ["@httpasyncclient//jar"],
 )
 
 java_library(
     name = "httpcore-nio",
     data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//java/com/google/gerrit/elasticsearch:__pkg__"],
     exports = ["@httpcore-nio//jar"],
 )
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
index 5c15193..0034748 100644
--- a/lib/jackson/BUILD
+++ b/lib/jackson/BUILD
@@ -1,9 +1,9 @@
-package(default_visibility = ["//visibility:public"])
-
-VERSION = "2.6.6"
-
 java_library(
     name = "jackson-core",
     data = ["//lib:LICENSE-Apache2.0"],
+    visibility = [
+        "//java/com/google/gerrit/elasticsearch:__pkg__",
+        "//plugins:__pkg__",
+    ],
     exports = ["@jackson-core//jar"],
 )
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
index c5f1da8..b78ac58 100644
--- a/lib/jetty/BUILD
+++ b/lib/jetty/BUILD
@@ -15,13 +15,6 @@
 )
 
 java_library(
-    name = "servlets",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@jetty-servlets//jar"],
-)
-
-java_library(
     name = "server",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index 20dbdcb..0f52913 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,66 +1,75 @@
-load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
+load("//tools/bzl:maven_jar.bzl", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "4.11.0.201803080745-r.93-gcbb2e65db"
+_JGIT_VERS = "5.3.1.201904271842-r"
 
-_DOC_VERS = "4.11.0.201803080745-r"  # Set to _JGIT_VERS unless using a snapshot
+_DOC_VERS = _JGIT_VERS  # Set to _JGIT_VERS unless using a snapshot
 
-JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
+JGIT_DOC_URL = "https://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
-_JGIT_REPO = GERRIT  # Leave here even if set to MAVEN_CENTRAL.
+_JGIT_REPO = MAVEN_CENTRAL  # Leave here even if set to MAVEN_CENTRAL.
 
 # set this to use a local version.
 # "/home/<user>/projects/jgit"
 LOCAL_JGIT_REPO = ""
 
 def jgit_repos():
-  if LOCAL_JGIT_REPO:
-    native.local_repository(
-        name = "jgit",
-        path = LOCAL_JGIT_REPO,
+    if LOCAL_JGIT_REPO:
+        native.local_repository(
+            name = "jgit",
+            path = LOCAL_JGIT_REPO,
+        )
+        jgit_maven_repos_dev()
+    else:
+        jgit_maven_repos()
+
+def jgit_maven_repos_dev():
+    # Transitive dependencies from JGit's WORKSPACE.
+    maven_jar(
+        name = "hamcrest-library",
+        artifact = "org.hamcrest:hamcrest-library:1.3",
+        sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
     )
-  else:
-    jgit_maven_repos()
+    maven_jar(
+        name = "jzlib",
+        artifact = "com.jcraft:jzlib:1.1.1",
+        sha1 = "a1551373315ffc2f96130a0e5704f74e151777ba",
+    )
 
 def jgit_maven_repos():
     maven_jar(
         name = "jgit-lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "265a39c017ecfeed7e992b6aaa336e515bf6e157",
-        src_sha1 = "e9d801e17afe71cdd5ade84ab41ff0110c3f28fd",
-        unsign = True,
+        sha1 = "dba85014483315fa426259bc1b8ccda9373a624b",
     )
     maven_jar(
         name = "jgit-servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "0d68f62286b5db759fdbeb122c789db1f833a06a",
-        unsign = True,
+        sha1 = "3287341fca859340a00b51cb5dd3b78b8e532b39",
     )
     maven_jar(
         name = "jgit-archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "4cc3ed2c42ee63593fd1b16215fcf13eeefb833e",
+        sha1 = "3585027e83fb44a5de2c10ae9ddbf976593bf080",
     )
     maven_jar(
         name = "jgit-junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "6f1bcc9ac22b31b5a6e1e68c08283850108b900c",
-        unsign = True,
+        sha1 = "3d9ba7e610d6ab5d08dcb1e4ba448b592a34de77",
     )
 
 def jgit_dep(name):
-  mapping = {
-      "@jgit-junit//jar": "@jgit//org.eclipse.jgit.junit:junit",
-      "@jgit-lib//jar:src": "@jgit//org.eclipse.jgit:libjgit-src.jar",
-      "@jgit-lib//jar": "@jgit//org.eclipse.jgit:jgit",
-      "@jgit-servlet//jar":"@jgit//org.eclipse.jgit.http.server:jgit-servlet",
-      "@jgit-archive//jar": "@jgit//org.eclipse.jgit.archive:jgit-archive",
-  }
+    mapping = {
+        "@jgit-archive//jar": "@jgit//org.eclipse.jgit.archive:jgit-archive",
+        "@jgit-junit//jar": "@jgit//org.eclipse.jgit.junit:junit",
+        "@jgit-lib//jar": "@jgit//org.eclipse.jgit:jgit",
+        "@jgit-servlet//jar": "@jgit//org.eclipse.jgit.http.server:jgit-servlet",
+    }
 
-  if LOCAL_JGIT_REPO:
-    return mapping[name]
-  else:
-    return name
+    if LOCAL_JGIT_REPO:
+        return mapping[name]
+    else:
+        return name
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUILD b/lib/jgit/org.eclipse.jgit.junit/BUILD
index 85e9167..29d80d3 100644
--- a/lib/jgit/org.eclipse.jgit.junit/BUILD
+++ b/lib/jgit/org.eclipse.jgit.junit/BUILD
@@ -2,7 +2,7 @@
 
 java_library(
     name = "junit",
-    testonly = 1,
+    testonly = True,
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
     exports = [jgit_dep("@jgit-junit//jar")],
diff --git a/lib/jgit/org.eclipse.jgit/BUILD b/lib/jgit/org.eclipse.jgit/BUILD
index d61ac93..dc11171 100644
--- a/lib/jgit/org.eclipse.jgit/BUILD
+++ b/lib/jgit/org.eclipse.jgit/BUILD
@@ -11,12 +11,6 @@
     ],
 )
 
-alias(
-    name = "jgit-source",
-    actual = jgit_dep("@jgit-lib//jar:src"),
-    visibility = ["//visibility:public"],
-)
-
 java_library(
     name = "javaewah",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/js/BUILD b/lib/js/BUILD
index 706c472..7478ef3 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -1,7 +1,8 @@
-package(default_visibility = ["//visibility:public"])
-
+load("//lib/js:bower_components.bzl", "define_bower_components")
 load("//tools/bzl:js.bzl", "bower_component", "js_component")
 
+package(default_visibility = ["//visibility:public"])
+
 # For importing new versions of existing bower packages,
 #
 # 1) edit the versions of 'seed' components in WORKSPACE as desired
@@ -20,8 +21,6 @@
 # 4) remove bower_component(name="my_new_dependency", .. ) here
 #
 
-load("//lib/js:bower_components.bzl", "define_bower_components")
-
 define_bower_components()
 
 js_component(
@@ -46,3 +45,8 @@
     name = "codemirror-minified",
     license = "//lib:LICENSE-codemirror-minified",
 )
+
+bower_component(
+    name = "resemblejs",
+    license = "//lib:LICENSE-resemblejs",
+)
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
index 6b4e003..48529a0 100644
--- a/lib/js/bower_archives.bzl
+++ b/lib/js/bower_archives.bzl
@@ -7,138 +7,165 @@
 load("//tools/bzl:js.bzl", "bower_archive")
 
 def load_bower_archives():
-  bower_archive(
-    name = "accessibility-developer-tools",
-    package = "accessibility-developer-tools",
-    version = "2.12.0",
-    sha1 = "88ae82dcdeb6c658f76eff509d0ee425cae14d49")
-  bower_archive(
-    name = "async",
-    package = "async",
-    version = "1.5.2",
-    sha1 = "1ec975d3b3834646a7e3d4b7e68118b90ed72508")
-  bower_archive(
-    name = "chai",
-    package = "chai",
-    version = "3.5.0",
-    sha1 = "849ad3ee7c77506548b7b5db603a4e150b9431aa")
-  bower_archive(
-    name = "font-roboto",
-    package = "PolymerElements/font-roboto",
-    version = "1.1.0",
-    sha1 = "ab4218d87b9ce569d6282b01f7642e551879c3d5")
-  bower_archive(
-    name = "iron-a11y-announcer",
-    package = "PolymerElements/iron-a11y-announcer",
-    version = "1.0.6",
-    sha1 = "14aed1e1b300ea344e80362e875919ea3d104dcc")
-  bower_archive(
-    name = "iron-a11y-keys-behavior",
-    package = "PolymerElements/iron-a11y-keys-behavior",
-    version = "1.1.9",
-    sha1 = "f58358ee652c67e6e721364ba50fb77a2ece1465")
-  bower_archive(
-    name = "iron-behaviors",
-    package = "PolymerElements/iron-behaviors",
-    version = "1.0.18",
-    sha1 = "e231a1a02b090f5183db917639fdb96cdd0dca18")
-  bower_archive(
-    name = "iron-checked-element-behavior",
-    package = "PolymerElements/iron-checked-element-behavior",
-    version = "1.0.6",
-    sha1 = "93ad3554cec119d8c5732d1c722ad113e1866370")
-  bower_archive(
-    name = "iron-fit-behavior",
-    package = "PolymerElements/iron-fit-behavior",
-    version = "1.2.7",
-    sha1 = "01c485fbf898307029bbb72ac7e132db1570a842")
-  bower_archive(
-    name = "iron-flex-layout",
-    package = "PolymerElements/iron-flex-layout",
-    version = "1.3.9",
-    sha1 = "d987b924cf29fcfe4b393833e81fdc9f1e268796")
-  bower_archive(
-    name = "iron-form-element-behavior",
-    package = "PolymerElements/iron-form-element-behavior",
-    version = "1.0.7",
-    sha1 = "7b5a79e02cc32f0918725dd26925d0df1e03ed12")
-  bower_archive(
-    name = "iron-menu-behavior",
-    package = "PolymerElements/iron-menu-behavior",
-    version = "2.1.1",
-    sha1 = "1504997f6eb9aec490b855dadee473cac064f38c")
-  bower_archive(
-    name = "iron-meta",
-    package = "PolymerElements/iron-meta",
-    version = "1.1.3",
-    sha1 = "f77eba3f6f6817f10bda33918bde8f963d450041")
-  bower_archive(
-    name = "iron-resizable-behavior",
-    package = "polymerelements/iron-resizable-behavior",
-    version = "1.0.6",
-    sha1 = "719c2a8a1a784f8aefcdeef41fcc2e5a03518d9e")
-  bower_archive(
-    name = "iron-validatable-behavior",
-    package = "PolymerElements/iron-validatable-behavior",
-    version = "1.1.2",
-    sha1 = "7111f34ff32e1510131dfbdb1eaa51bfa291e8be")
-  bower_archive(
-    name = "lodash",
-    package = "lodash",
-    version = "3.10.1",
-    sha1 = "2f207a8293c4c554bf6cf071241f7a00dc513d3a")
-  bower_archive(
-    name = "mocha",
-    package = "mocha",
-    version = "3.5.3",
-    sha1 = "c14f149821e4e96241b20f85134aa757b73038f1")
-  bower_archive(
-    name = "neon-animation",
-    package = "polymerelements/neon-animation",
-    version = "1.2.5",
-    sha1 = "588d289f779d02b21ce5b676e257bbd6155649e8")
-  bower_archive(
-    name = "paper-behaviors",
-    package = "PolymerElements/paper-behaviors",
-    version = "1.0.13",
-    sha1 = "a81eab28a952e124c208430e17508d9a1aae4ee7")
-  bower_archive(
-    name = "paper-icon-button",
-    package = "PolymerElements/paper-icon-button",
-    version = "2.2.0",
-    sha1 = "9525e76ef433428bb9d6ec4fa65c4ef83156a803")
-  bower_archive(
-    name = "paper-ripple",
-    package = "PolymerElements/paper-ripple",
-    version = "1.0.10",
-    sha1 = "21199db50d02b842da54bd6f4f1d1b10b474e893")
-  bower_archive(
-    name = "paper-styles",
-    package = "PolymerElements/paper-styles",
-    version = "1.3.1",
-    sha1 = "4ee9c692366949a754e0e39f8031aa60ce66f24d")
-  bower_archive(
-    name = "sinon-chai",
-    package = "sinon-chai",
-    version = "2.14.0",
-    sha1 = "78f0dc184efe47012a2b1b9a16a4289acf8300dc")
-  bower_archive(
-    name = "sinonjs",
-    package = "sinonjs",
-    version = "1.17.1",
-    sha1 = "a26a6aab7358807de52ba738770f6ac709afd240")
-  bower_archive(
-    name = "stacky",
-    package = "stacky",
-    version = "1.3.2",
-    sha1 = "d6c07a0112ab2e9677fe085933744466a89232fb")
-  bower_archive(
-    name = "web-animations-js",
-    package = "web-animations/web-animations-js",
-    version = "2.3.1",
-    sha1 = "2ba5548d36188fe54555eaad0a576de4b027661e")
-  bower_archive(
-    name = "webcomponentsjs",
-    package = "webcomponents/webcomponentsjs",
-    version = "0.7.24",
-    sha1 = "559227f8ee9db9bfbd81989f24510cc0c1bfc65c")
+    bower_archive(
+        name = "accessibility-developer-tools",
+        package = "accessibility-developer-tools",
+        version = "2.12.0",
+        sha1 = "88ae82dcdeb6c658f76eff509d0ee425cae14d49",
+    )
+    bower_archive(
+        name = "async",
+        package = "async",
+        version = "1.5.2",
+        sha1 = "1ec975d3b3834646a7e3d4b7e68118b90ed72508",
+    )
+    bower_archive(
+        name = "chai",
+        package = "chai",
+        version = "3.5.0",
+        sha1 = "849ad3ee7c77506548b7b5db603a4e150b9431aa",
+    )
+    bower_archive(
+        name = "font-roboto",
+        package = "PolymerElements/font-roboto",
+        version = "1.1.0",
+        sha1 = "ab4218d87b9ce569d6282b01f7642e551879c3d5",
+    )
+    bower_archive(
+        name = "iron-a11y-announcer",
+        package = "PolymerElements/iron-a11y-announcer",
+        version = "1.0.6",
+        sha1 = "14aed1e1b300ea344e80362e875919ea3d104dcc",
+    )
+    bower_archive(
+        name = "iron-a11y-keys-behavior",
+        package = "PolymerElements/iron-a11y-keys-behavior",
+        version = "1.1.9",
+        sha1 = "f58358ee652c67e6e721364ba50fb77a2ece1465",
+    )
+    bower_archive(
+        name = "iron-behaviors",
+        package = "PolymerElements/iron-behaviors",
+        version = "1.0.18",
+        sha1 = "e231a1a02b090f5183db917639fdb96cdd0dca18",
+    )
+    bower_archive(
+        name = "iron-checked-element-behavior",
+        package = "PolymerElements/iron-checked-element-behavior",
+        version = "1.0.6",
+        sha1 = "93ad3554cec119d8c5732d1c722ad113e1866370",
+    )
+    bower_archive(
+        name = "iron-fit-behavior",
+        package = "PolymerElements/iron-fit-behavior",
+        version = "1.2.7",
+        sha1 = "01c485fbf898307029bbb72ac7e132db1570a842",
+    )
+    bower_archive(
+        name = "iron-flex-layout",
+        package = "PolymerElements/iron-flex-layout",
+        version = "1.3.9",
+        sha1 = "d987b924cf29fcfe4b393833e81fdc9f1e268796",
+    )
+    bower_archive(
+        name = "iron-form-element-behavior",
+        package = "PolymerElements/iron-form-element-behavior",
+        version = "1.0.7",
+        sha1 = "7b5a79e02cc32f0918725dd26925d0df1e03ed12",
+    )
+    bower_archive(
+        name = "iron-menu-behavior",
+        package = "PolymerElements/iron-menu-behavior",
+        version = "2.1.1",
+        sha1 = "1504997f6eb9aec490b855dadee473cac064f38c",
+    )
+    bower_archive(
+        name = "iron-meta",
+        package = "PolymerElements/iron-meta",
+        version = "1.1.3",
+        sha1 = "f77eba3f6f6817f10bda33918bde8f963d450041",
+    )
+    bower_archive(
+        name = "iron-resizable-behavior",
+        package = "polymerelements/iron-resizable-behavior",
+        version = "1.0.6",
+        sha1 = "719c2a8a1a784f8aefcdeef41fcc2e5a03518d9e",
+    )
+    bower_archive(
+        name = "iron-validatable-behavior",
+        package = "PolymerElements/iron-validatable-behavior",
+        version = "1.1.2",
+        sha1 = "7111f34ff32e1510131dfbdb1eaa51bfa291e8be",
+    )
+    bower_archive(
+        name = "lodash",
+        package = "lodash",
+        version = "3.10.1",
+        sha1 = "2f207a8293c4c554bf6cf071241f7a00dc513d3a",
+    )
+    bower_archive(
+        name = "mocha",
+        package = "mocha",
+        version = "3.5.3",
+        sha1 = "c14f149821e4e96241b20f85134aa757b73038f1",
+    )
+    bower_archive(
+        name = "neon-animation",
+        package = "polymerelements/neon-animation",
+        version = "1.2.5",
+        sha1 = "588d289f779d02b21ce5b676e257bbd6155649e8",
+    )
+    bower_archive(
+        name = "paper-behaviors",
+        package = "PolymerElements/paper-behaviors",
+        version = "1.0.13",
+        sha1 = "a81eab28a952e124c208430e17508d9a1aae4ee7",
+    )
+    bower_archive(
+        name = "paper-icon-button",
+        package = "PolymerElements/paper-icon-button",
+        version = "2.2.1",
+        sha1 = "68f76af3a9379f256a3900a4b68d871898f1fe57",
+    )
+    bower_archive(
+        name = "paper-ripple",
+        package = "PolymerElements/paper-ripple",
+        version = "1.0.10",
+        sha1 = "21199db50d02b842da54bd6f4f1d1b10b474e893",
+    )
+    bower_archive(
+        name = "paper-styles",
+        package = "PolymerElements/paper-styles",
+        version = "1.3.1",
+        sha1 = "4ee9c692366949a754e0e39f8031aa60ce66f24d",
+    )
+    bower_archive(
+        name = "sinon-chai",
+        package = "sinon-chai",
+        version = "2.14.0",
+        sha1 = "78f0dc184efe47012a2b1b9a16a4289acf8300dc",
+    )
+    bower_archive(
+        name = "sinonjs",
+        package = "sinonjs",
+        version = "1.17.1",
+        sha1 = "a26a6aab7358807de52ba738770f6ac709afd240",
+    )
+    bower_archive(
+        name = "stacky",
+        package = "stacky",
+        version = "1.3.2",
+        sha1 = "d6c07a0112ab2e9677fe085933744466a89232fb",
+    )
+    bower_archive(
+        name = "web-animations-js",
+        package = "web-animations/web-animations-js",
+        version = "2.3.1",
+        sha1 = "2ba5548d36188fe54555eaad0a576de4b027661e",
+    )
+    bower_archive(
+        name = "webcomponentsjs",
+        package = "webcomponents/webcomponentsjs",
+        version = "0.7.24",
+        sha1 = "559227f8ee9db9bfbd81989f24510cc0c1bfc65c",
+    )
diff --git a/lib/js/bower_components.bzl b/lib/js/bower_components.bzl
index dc16ccf..a540828 100644
--- a/lib/js/bower_components.bzl
+++ b/lib/js/bower_components.bzl
@@ -7,377 +7,377 @@
 load("//tools/bzl:js.bzl", "bower_component")
 
 def define_bower_components():
-  bower_component(
-    name = "accessibility-developer-tools",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "async",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "chai",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "es6-promise",
-    license = "//lib:LICENSE-es6-promise",
-    seed = True,
-  )
-  bower_component(
-    name = "fetch",
-    license = "//lib:LICENSE-fetch",
-    seed = True,
-  )
-  bower_component(
-    name = "font-roboto",
-    license = "//lib:LICENSE-polymer",
-  )
-  bower_component(
-    name = "iron-a11y-announcer",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-a11y-keys-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-autogrow-textarea",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-flex-layout",
-      ":iron-validatable-behavior",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-behaviors",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "iron-checked-element-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-form-element-behavior",
-      ":iron-validatable-behavior",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "iron-dropdown",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-overlay-behavior",
-      ":iron-resizable-behavior",
-      ":neon-animation",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-fit-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-flex-layout",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-form-element-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-icon",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-flex-layout",
-      ":iron-meta",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-iconset-svg",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-meta",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-input",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-announcer",
-      ":iron-validatable-behavior",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-menu-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":iron-flex-layout",
-      ":iron-selector",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "iron-meta",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-overlay-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":iron-fit-behavior",
-      ":iron-resizable-behavior",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-resizable-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-selector",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-test-helpers",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    deps = [ ":polymer" ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-validatable-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-meta",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "lodash",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "mocha",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "moment",
-    license = "//lib:LICENSE-moment",
-    seed = True,
-  )
-  bower_component(
-    name = "neon-animation",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-meta",
-      ":iron-resizable-behavior",
-      ":iron-selector",
-      ":polymer",
-      ":web-animations-js",
-    ],
-  )
-  bower_component(
-    name = "page",
-    license = "//lib:LICENSE-page.js",
-    seed = True,
-  )
-  bower_component(
-    name = "paper-behaviors",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-checked-element-behavior",
-      ":paper-ripple",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "paper-button",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-flex-layout",
-      ":paper-behaviors",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-icon-button",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-icon",
-      ":paper-behaviors",
-      ":paper-styles",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "paper-input",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":iron-autogrow-textarea",
-      ":iron-behaviors",
-      ":iron-form-element-behavior",
-      ":iron-input",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-item",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-flex-layout",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-listbox",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-menu-behavior",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-ripple",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "paper-styles",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":font-roboto",
-      ":iron-flex-layout",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "paper-tabs",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-flex-layout",
-      ":iron-icon",
-      ":iron-iconset-svg",
-      ":iron-menu-behavior",
-      ":iron-resizable-behavior",
-      ":paper-behaviors",
-      ":paper-icon-button",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-toggle-button",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-checked-element-behavior",
-      ":paper-behaviors",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "polymer-resin",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":polymer",
-      ":webcomponentsjs",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "polymer",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":webcomponentsjs" ],
-    seed = True,
-  )
-  bower_component(
-    name = "promise-polyfill",
-    license = "//lib:LICENSE-promise-polyfill",
-    deps = [ ":polymer" ],
-    seed = True,
-  )
-  bower_component(
-    name = "sinon-chai",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "sinonjs",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "stacky",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "test-fixture",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    seed = True,
-  )
-  bower_component(
-    name = "web-animations-js",
-    license = "//lib:LICENSE-Apache2.0",
-  )
-  bower_component(
-    name = "web-component-tester",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    deps = [
-      ":accessibility-developer-tools",
-      ":async",
-      ":chai",
-      ":lodash",
-      ":mocha",
-      ":sinon-chai",
-      ":sinonjs",
-      ":stacky",
-      ":test-fixture",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "webcomponentsjs",
-    license = "//lib:LICENSE-polymer",
-  )
+    bower_component(
+        name = "accessibility-developer-tools",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "async",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "chai",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "es6-promise",
+        license = "//lib:LICENSE-es6-promise",
+        seed = True,
+    )
+    bower_component(
+        name = "fetch",
+        license = "//lib:LICENSE-fetch",
+        seed = True,
+    )
+    bower_component(
+        name = "font-roboto",
+        license = "//lib:LICENSE-polymer",
+    )
+    bower_component(
+        name = "iron-a11y-announcer",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-a11y-keys-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-autogrow-textarea",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-flex-layout",
+            ":iron-validatable-behavior",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-behaviors",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "iron-checked-element-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-form-element-behavior",
+            ":iron-validatable-behavior",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "iron-dropdown",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-overlay-behavior",
+            ":iron-resizable-behavior",
+            ":neon-animation",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-fit-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-flex-layout",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-form-element-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-icon",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-flex-layout",
+            ":iron-meta",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-iconset-svg",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-meta",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-input",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-announcer",
+            ":iron-validatable-behavior",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-menu-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-flex-layout",
+            ":iron-selector",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "iron-meta",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-overlay-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-fit-behavior",
+            ":iron-resizable-behavior",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-resizable-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-selector",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-test-helpers",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+        deps = [":polymer"],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-validatable-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-meta",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "lodash",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "mocha",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "moment",
+        license = "//lib:LICENSE-moment",
+        seed = True,
+    )
+    bower_component(
+        name = "neon-animation",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-meta",
+            ":iron-resizable-behavior",
+            ":iron-selector",
+            ":polymer",
+            ":web-animations-js",
+        ],
+    )
+    bower_component(
+        name = "page",
+        license = "//lib:LICENSE-page.js",
+        seed = True,
+    )
+    bower_component(
+        name = "paper-behaviors",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-checked-element-behavior",
+            ":paper-ripple",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-button",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-flex-layout",
+            ":paper-behaviors",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-icon-button",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-icon",
+            ":paper-behaviors",
+            ":paper-styles",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-input",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-autogrow-textarea",
+            ":iron-behaviors",
+            ":iron-form-element-behavior",
+            ":iron-input",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-item",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-flex-layout",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-listbox",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-menu-behavior",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-ripple",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-styles",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":font-roboto",
+            ":iron-flex-layout",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-tabs",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-flex-layout",
+            ":iron-icon",
+            ":iron-iconset-svg",
+            ":iron-menu-behavior",
+            ":iron-resizable-behavior",
+            ":paper-behaviors",
+            ":paper-icon-button",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-toggle-button",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-checked-element-behavior",
+            ":paper-behaviors",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "polymer-resin",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":polymer",
+            ":webcomponentsjs",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "polymer",
+        license = "//lib:LICENSE-polymer",
+        deps = [":webcomponentsjs"],
+        seed = True,
+    )
+    bower_component(
+        name = "promise-polyfill",
+        license = "//lib:LICENSE-promise-polyfill",
+        deps = [":polymer"],
+        seed = True,
+    )
+    bower_component(
+        name = "sinon-chai",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "sinonjs",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "stacky",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "test-fixture",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+        seed = True,
+    )
+    bower_component(
+        name = "web-animations-js",
+        license = "//lib:LICENSE-Apache2.0",
+    )
+    bower_component(
+        name = "web-component-tester",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+        deps = [
+            ":accessibility-developer-tools",
+            ":async",
+            ":chai",
+            ":lodash",
+            ":mocha",
+            ":sinon-chai",
+            ":sinonjs",
+            ":stacky",
+            ":test-fixture",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "webcomponentsjs",
+        license = "//lib:LICENSE-polymer",
+    )
diff --git a/lib/js/npm.bzl b/lib/js/npm.bzl
index 0fd575d..5a6a8c0 100644
--- a/lib/js/npm.bzl
+++ b/lib/js/npm.bzl
@@ -1,11 +1,11 @@
 NPM_VERSIONS = {
-    "bower": "1.8.2",
+    "bower": "1.8.8",
     "crisper": "2.0.2",
-    "vulcanize": "1.14.8",
+    "polymer-bundler": "4.0.9",
 }
 
 NPM_SHA1S = {
-    "bower": "adf53529c8d4af02ef24fb8d5341c1419d33e2f7",
+    "bower": "82544be34a33aeae7efb8bdf9905247b2cffa985",
     "crisper": "7183c58cea33632fb036c91cefd1b43e390d22a2",
-    "vulcanize": "679107f251c19ab7539529b1e3fdd40829e6fc63",
+    "polymer-bundler": "c80c9815690d76656d1fa6a231481850b4fa3874",
 }
diff --git a/lib/lucene/BUILD b/lib/lucene/BUILD
index 421caed..adb5030 100644
--- a/lib/lucene/BUILD
+++ b/lib/lucene/BUILD
@@ -1,7 +1,7 @@
-package(default_visibility = ["//visibility:public"])
-
 load("//tools/bzl:maven.bzl", "merge_maven_jars")
 
+package(default_visibility = ["//visibility:public"])
+
 # core and backward-codecs both provide
 # META-INF/services/org.apache.lucene.codecs.Codec, so they must be merged.
 merge_maven_jars(
@@ -11,13 +11,11 @@
         "@lucene-core//jar",
     ],
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
 )
 
 java_library(
     name = "lucene-analyzers-common",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@lucene-analyzers-common//jar"],
     runtime_deps = [":lucene-core-and-backward-codecs"],
 )
@@ -25,14 +23,12 @@
 java_library(
     name = "lucene-core",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@lucene-core//jar"],
 )
 
 java_library(
     name = "lucene-misc",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@lucene-misc//jar"],
     runtime_deps = [":lucene-core-and-backward-codecs"],
 )
@@ -40,7 +36,6 @@
 java_library(
     name = "lucene-queryparser",
     data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
     exports = ["@lucene-queryparser//jar"],
     runtime_deps = [":lucene-core-and-backward-codecs"],
 )
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
index 8595bb5..6ee7e41 100644
--- a/lib/mina/BUILD
+++ b/lib/mina/BUILD
@@ -4,6 +4,7 @@
     visibility = ["//visibility:public"],
     exports = [
         ":eddsa",
+        "@sshd-mina//jar",
         "@sshd//jar",
     ],
     runtime_deps = [":core"],
diff --git a/lib/mockito/BUILD b/lib/mockito/BUILD
new file mode 100644
index 0000000..fa4839b
--- /dev/null
+++ b/lib/mockito/BUILD
@@ -0,0 +1,34 @@
+package(
+    default_testonly = True,
+    default_visibility = ["//visibility:private"],
+)
+
+java_library(
+    name = "mockito",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@mockito//jar"],
+    runtime_deps = [
+        ":byte-buddy",
+        ":byte-buddy-agent",
+        ":objenesis",
+    ],
+)
+
+java_library(
+    name = "byte-buddy",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@byte-buddy//jar"],
+)
+
+java_library(
+    name = "byte-buddy-agent",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@byte-buddy-agent//jar"],
+)
+
+java_library(
+    name = "objenesis",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@objenesis//jar"],
+)
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
new file mode 100755
index 0000000..23b40ad
--- /dev/null
+++ b/lib/nongoogle_test.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+# This test ensures that new dependencies in nongoogle.bzl go through LC review.
+
+set -eux
+
+bzl=$(pwd)/tools/nongoogle.bzl
+
+TMP=$(mktemp -d || mktemp -d -t /tmp/tmp.XXXXXX)
+
+grep 'name = "[^"]*"' ${bzl} | sed 's|^[^"]*"||g;s|".*$||g' | sort > $TMP/names
+
+cat << EOF > $TMP/want
+tukaani-xz
+EOF
+
+diff -u $TMP/names $TMP/want
diff --git a/lib/polymer_externs/BUILD b/lib/polymer_externs/BUILD
index ae8f9c0..f07aa2f 100644
--- a/lib/polymer_externs/BUILD
+++ b/lib/polymer_externs/BUILD
@@ -12,22 +12,13 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-package(
-    default_visibility = ["//visibility:public"],
-)
-
 load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library")
 
-genrule(
-    name = "polymer_closure_renamed",
-    srcs = ["@polymer_closure//file"],
-    outs = ["polymer_closure_renamed.js"],
-    cmd = "cp $< $@",
-)
+package(default_visibility = ["//visibility:public"])
 
 closure_js_library(
     name = "polymer_closure",
-    srcs = [":polymer_closure_renamed"],
+    srcs = ["@polymer_closure//file"],
     data = ["//lib:LICENSE-Apache2.0"],
     no_closure_library = True,
 )
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
index d905ad8..4d4dd3a 100644
--- a/lib/prolog/prolog.bzl
+++ b/lib/prolog/prolog.bzl
@@ -13,22 +13,22 @@
 # limitations under the License.
 
 def prolog_cafe_library(
-    name,
-    srcs,
-    deps = [],
-    **kwargs):
-  native.genrule(
-    name = name + '__pl2j',
-    cmd = '$(location //lib/prolog:compiler-bin) ' +
-      '$$(dirname $@) $@ ' +
-      '$(SRCS)',
-    srcs = srcs,
-    tools = ['//lib/prolog:compiler-bin'],
-    outs = [ name + '.srcjar' ],
-  )
-  native.java_library(
-    name = name,
-    srcs = [':' + name + '__pl2j'],
-    deps = ['//lib/prolog:runtime-neverlink'] + deps,
-    **kwargs
-  )
+        name,
+        srcs,
+        deps = [],
+        **kwargs):
+    native.genrule(
+        name = name + "__pl2j",
+        cmd = "$(location //lib/prolog:compiler-bin) " +
+              "$$(dirname $@) $@ " +
+              "$(SRCS)",
+        srcs = srcs,
+        tools = ["//lib/prolog:compiler-bin"],
+        outs = [name + ".srcjar"],
+    )
+    native.java_library(
+        name = name,
+        srcs = [":" + name + "__pl2j"],
+        deps = ["//lib/prolog:runtime-neverlink"] + deps,
+        **kwargs
+    )
diff --git a/lib/testcontainers/BUILD b/lib/testcontainers/BUILD
index f99365d..25ca327 100644
--- a/lib/testcontainers/BUILD
+++ b/lib/testcontainers/BUILD
@@ -35,3 +35,12 @@
         "//lib/log:ext",
     ],
 )
+
+java_library(
+    name = "testcontainers-elasticsearch",
+    testonly = True,
+    data = ["//lib:LICENSE-testcontainers"],
+    visibility = ["//visibility:public"],
+    exports = ["@testcontainers-elasticsearch//jar"],
+    runtime_deps = [":testcontainers"],
+)
diff --git a/lib/truth/BUILD b/lib/truth/BUILD
index 82cd98a..db5bc48 100644
--- a/lib/truth/BUILD
+++ b/lib/truth/BUILD
@@ -4,6 +4,7 @@
     visibility = ["//visibility:public"],
     exports = ["@truth//jar"],
     runtime_deps = [
+        ":diffutils",
         "//lib:guava",
         "//lib:junit",
     ],
@@ -33,6 +34,13 @@
 )
 
 java_library(
+    name = "diffutils",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:private"],
+    exports = ["@diffutils//jar"],
+)
+
+java_library(
     name = "truth-proto-extension",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6b9a38d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,28 @@
+{
+  "name": "gerrit",
+  "version": "3.1.0-SNAPSHOT",
+  "description": "Gerrit Code Review",
+  "dependencies": {},
+  "devDependencies": {
+    "eslint": "^5.16.0",
+    "eslint-config-google": "^0.13.0",
+    "eslint-plugin-html": "^5.0.5",
+    "fried-twinkie": "^0.2.2",
+    "polylint": "^2.10.4",
+    "typescript": "^2.x.x",
+    "web-component-tester": "^6.5.0"
+  },
+  "scripts": {
+    "start": "polygerrit-ui/run-server.sh",
+    "test": "WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh",
+    "eslint": "./node_modules/eslint/bin/eslint.js --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js polygerrit-ui/app || exit 0",
+    "test-template": "./polygerrit-ui/app/run_template_test.sh",
+    "polylint": "bazel test polygerrit-ui/app:polylint_test"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://gerrit.googlesource.com/gerrit"
+  },
+  "author": "",
+  "license": "Apache-2.0"
+}
diff --git a/plugins/BUILD b/plugins/BUILD
index 4cc982a..7d81213 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -1,4 +1,5 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:javadoc.bzl", "java_doc")
 load(
     "//tools/bzl:plugins.bzl",
     "CORE_PLUGINS",
@@ -19,6 +20,7 @@
 
 PLUGIN_API = [
     "//java/com/google/gerrit/server",
+    "//java/com/google/gerrit/server/ioutil",
     "//java/com/google/gerrit/server/restapi",
     "//java/com/google/gerrit/pgm/init/api",
     "//java/com/google/gerrit/httpd",
@@ -26,17 +28,33 @@
 ]
 
 EXPORTS = [
+    "//antlr3:query_parser",
     "//java/com/google/gerrit/common:annotations",
     "//java/com/google/gerrit/common:server",
+    "//java/com/google/gerrit/exceptions",
     "//java/com/google/gerrit/extensions:api",
+    "//java/com/google/gerrit/git",
     "//java/com/google/gerrit/index",
+    "//java/com/google/gerrit/index/project",
     "//java/com/google/gerrit/index:query_exception",
-    "//java/com/google/gerrit/index:query_parser",
+    "//java/com/google/gerrit/json",
     "//java/com/google/gerrit/lifecycle",
+    "//java/com/google/gerrit/mail",
     "//java/com/google/gerrit/metrics",
     "//java/com/google/gerrit/metrics/dropwizard",
     "//java/com/google/gerrit/reviewdb:server",
+    "//java/com/google/gerrit/server/api",
+    "//java/com/google/gerrit/server/audit",
+    "//java/com/google/gerrit/server/cache/mem",
+    "//java/com/google/gerrit/server/cache/serialize",
+    "//java/com/google/gerrit/server/logging",
+    "//java/com/google/gerrit/server/schema",
+    "//java/com/google/gerrit/server/util/time",
+    "//java/com/google/gerrit/util/cli",
     "//java/com/google/gerrit/util/http",
+    "//lib/antlr:java-runtime",
+    "//lib/auto:auto-value-annotations",
+    "//lib/commons:compress",
     "//lib/commons:dbcp",
     "//lib/commons:lang",
     "//lib/dropwizard:dropwizard-core",
@@ -50,6 +68,7 @@
     "//lib/jackson:jackson-core",
     "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
     "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib:jsr305",
     "//lib/log:api",
     "//lib/log:log4j",
     "//lib/mina:sshd",
@@ -62,7 +81,6 @@
     "//lib:guava",
     "//lib:guava-retrying",
     "//lib:gson",
-    "//lib:gwtorm",
     "//lib:icu4j",
     "//lib:jsch",
     "//lib:mime-util",
@@ -97,13 +115,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",
@@ -113,14 +131,12 @@
     ],
 )
 
-load("//tools/bzl:javadoc.bzl", "java_doc")
-
 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 53dccff..56ebd4f 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 53dccff17c029459999ff70ac886b80626af634b
+Subproject commit 56ebd4f7a2bf27f89aa11245ff77f7eefcf4a7d6
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 315a115..556e427 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 315a11558913fa8f9c6d3b1723e45583b25afa1c
+Subproject commit 556e427fd737744ce8a6a37b89fd427ae59bc8ea
diff --git a/plugins/delete-project b/plugins/delete-project
new file mode 160000
index 0000000..b618043
--- /dev/null
+++ b/plugins/delete-project
@@ -0,0 +1 @@
+Subproject commit b618043544ebc62a0730aa1bfc1a1e26011b471a
diff --git a/plugins/download-commands b/plugins/download-commands
index cf58d79..8914550 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit cf58d79bc034e8904aa459d8974df5796a734e1d
+Subproject commit 891455076417dd097fdfd63f4afc0d28a3e85aff
diff --git a/plugins/external_plugin_deps.bzl b/plugins/external_plugin_deps.bzl
index 391f920..1f7c020 100644
--- a/plugins/external_plugin_deps.bzl
+++ b/plugins/external_plugin_deps.bzl
@@ -1,2 +1,2 @@
 def external_plugin_deps():
-    pass
\ No newline at end of file
+    pass
diff --git a/plugins/gitiles b/plugins/gitiles
new file mode 160000
index 0000000..3764262
--- /dev/null
+++ b/plugins/gitiles
@@ -0,0 +1 @@
+Subproject commit 37642627f0a4e6a3b2990ec754120b5055de6d41
diff --git a/plugins/hooks b/plugins/hooks
index e05011f..cfc7675 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit e05011f15604158ce2b700a65e85503fb1febafc
+Subproject commit cfc7675ef9c4d0f2bd1da47957835306bb1fd36a
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
new file mode 160000
index 0000000..833889d
--- /dev/null
+++ b/plugins/plugin-manager
@@ -0,0 +1 @@
+Subproject commit 833889d327a159b5ccea7064f4fcff3f94d4b26e
diff --git a/plugins/replication b/plugins/replication
index 3a47f8c..a5a5e0c 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 3a47f8c11ebdbcbc65fc6a58c35d18f1f3c3a74b
+Subproject commit a5a5e0cd13f1ff2614d77e9bf1bacbbc1d61b696
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index c73171e..3667220 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit c73171ea9abbeb765d585a92753ce01151355a5c
+Subproject commit 3667220b860d444406ca5fa5cc27d87858642596
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index e4024e9..db53979 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit e4024e9d8d8139fc4c658c3af1a5e11e19b2d476
+Subproject commit db5397931ed05e9aedd385d3ec1511b4a64d3ddc
diff --git a/plugins/webhooks b/plugins/webhooks
new file mode 160000
index 0000000..f860a0c
--- /dev/null
+++ b/plugins/webhooks
@@ -0,0 +1 @@
+Subproject commit f860a0cf6931164a6e5c2b333eaa0004ea14acec
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index fddfc57..0e9b4bb 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,9 +1,8 @@
-package(
-    default_visibility = ["//visibility:public"],
-)
-
-load("//tools/bzl:js.bzl", "bower_component_bundle")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary")
 load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:js.bzl", "bower_component_bundle")
+
+package(default_visibility = ["//visibility:public"])
 
 bower_component_bundle(
     name = "polygerrit_components.bower_components",
@@ -50,5 +49,17 @@
         "zip -qr $$ROOT/$@ fonts",
     ]),
     output_to_bindir = 1,
-    visibility = ["//visibility:public"],
+)
+
+go_binary(
+    name = "devserver",
+    srcs = ["server.go"],
+    data = [
+        ":fonts.zip",
+        "//polygerrit-ui/app:test_components.zip",
+    ],
+    deps = [
+        "@org_golang_x_tools//godoc/vfs/httpfs:go_default_library",
+        "@org_golang_x_tools//godoc/vfs/zipfs:go_default_library",
+    ],
 )
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 32e1953..a73f243 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -1,4 +1,8 @@
-# PolyGerrit
+# Gerrit Polymer Frontend
+
+Follow the
+[setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
+where applicable.
 
 ## Installing [Bazel](https://bazel.build/)
 
@@ -8,7 +12,7 @@
 
 ## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
-The minimum nodejs version supported is 6.x+
+The minimum nodejs version supported is 8.x+
 
 ```sh
 # Debian experimental
@@ -20,103 +24,75 @@
 brew install npm
 ```
 
-All other platforms: [download from
-nodejs.org](https://nodejs.org/en/download/).
+All other platforms:
+[download from nodejs.org](https://nodejs.org/en/download/).
 
 Various steps below require installing additional npm packages. The full list of
 dependencies can be installed with:
 
 ```sh
-sudo npm install -g \
-  eslint \
-  eslint-config-google \
-  eslint-plugin-html \
-  typescript \
-  fried-twinkie \
-  polylint \
-  web-component-tester
+npm install
 ```
 
 It may complain about a missing `typescript@2.3.4` peer dependency, which is
 harmless.
 
-If you're interested in the details, keep reading.
+## Running locally against production data
 
-## Local UI, Production Data
+#### Go server
 
-This is a quick and easy way to test your local changes against real data.
-Unfortunately, you can't sign in, so testing certain features will require
-you to use the "test data" technique described below.
-
-### Installing [go](https://golang.org/)
-
-This is required for running the `run-server.sh` script below.
+To test the local Polymer frontend against gerrit-review.googlesource.com
+simply execute:
 
 ```sh
-# Debian/Ubuntu
-sudo apt-get install golang
-
-# OS X with Homebrew
-brew install go
+./polygerrit-ui/run-server.sh
 ```
 
-All other platforms: [download from golang.org](https://golang.org/)
+Then visit <http://localhost:8081>.
 
-Then add go to your path:
-
-```
-PATH=$PATH:/usr/local/go/bin
-```
-
-Install the go Soy template library:
-
-```
-go get "github.com/robfig/soy"
-```
-
-### Running the server
-
-To test the local UI against gerrit-review.googlesource.com:
+This method is based on a
+[simple hand-written Go webserver](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/server.go).
+Mostly it just switches between serving files locally and proxying the real
+server based on the file name. It also does some basic response rewriting, e.g.
+it patches the `config/server/info` response with plugin information provided on
+the command line:
 
 ```sh
-./run-server.sh
+./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js,plugins/my_plugin/static/my_plugin.html
 ```
 
-Then visit http://localhost:8081
+The biggest draw back of this method is that you cannot log in, so cannot test
+scenarios that require it.
 
-## Local UI, Test Data
+#### MITM Proxy
 
-One-time setup:
+[MITM Proxy](https://mitmproxy.org/) is an open source product for proxying
+https servers. The
+[contrib/mitm-ui/](https://gerrit.googlesource.com/gerrit/+/master/contrib/mitm-ui/)
+directory contains scripts (and documentation) for using this technology
+(instead of the Go server). These scripts are somewhat experimental and
+unmaintained though.
+
+## Running locally against a Gerrit test site
+
+Set up a local test site once:
 
 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/dev-readme.html#init).
+2. [Set up a local test site](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
+3. Optionally [populate](https://gerrit.googlesource.com/gerrit/+/master/contrib/populate-fixture-data.py) your test site with some test data.
 
-When your project is set up and works using the classic UI, run a test server
-that serves PolyGerrit:
+For running a locally built Gerrit war against your test instance use
+[this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon),
+and add the `--polygerrit-dev` option, if you want to serve the Polymer frontend
+directly from the sources in `polygerrit_ui/app/` instead of from the war:
 
 ```sh
-bazel build polygerrit &&
-  $(bazel info output_base)/external/local_jdk/bin/java \
-  -jar bazel-bin/polygerrit.war daemon --polygerrit-dev \
-  -d ../gerrit_testsite --console-log --show-stack-trace
-```
-
-Serving plugins
-
-> Local dev plugins must be put inside of gerrit/plugins
-
-Loading a single plugin file:
-
-```sh
-./run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js
-```
-
-Loading multiple plugin files:
-
-```sh
-./run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js,plugins/my_plugin/static/my_plugin.html
+$(bazel info output_base)/external/local_jdk/bin/java \
+    -DsourceRoot=$(bazel info workspace) \
+    -jar bazel-bin/gerrit.war daemon \
+    -d $GERRIT_SITE \
+    --console-log \
+    --polygerrit-dev
 ```
 
 ## Running Tests
@@ -126,10 +102,17 @@
 Note: it may be necessary to add the options `--unsafe-perm=true --allow-root`
 to the `npm install` command to avoid file permission errors.
 
-Run all web tests:
+For daily development you typically only want to run and debug individual tests.
+Run the local [Go proxy server](#go-server) and navigate for example to
+<http://localhost:8081/elements/change/gr-account-entry/gr-account-entry_test.html>.
+Check "Disable cache" in the "Network" tab of Chrome's dev tools, so code
+changes are picked up on "reload".
+
+Our CI integration ensures that all tests are run when you upload a change to
+Gerrit, but you can also run all tests locally in headless mode:
 
 ```sh
-./polygerrit-ui/app/run_test.sh
+npm test
 ```
 
 To allow the tests to run in Safari:
@@ -137,31 +120,12 @@
 * In the Advanced preferences tab, check "Show Develop menu in menu bar".
 * In the Develop menu, enable the "Allow Remote Automation" option.
 
-If you need to pass additional arguments to `wct`:
-
-```sh
-WCT_ARGS='-p --some-flag="foo bar"' ./polygerrit-ui/app/run_test.sh
-```
-
-For interactively working on a single test file, do the following:
-
-```sh
-./polygerrit-ui/run-server.sh
-```
-
-Then visit http://localhost:8081/elements/foo/bar_test.html
-
 To run Chrome tests in headless mode:
 
 ```sh
-WCT_HEADLESS_MODE=1 ./polygerrit-ui/app/run_test.sh
+WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh
 ```
 
-Toolchain requirements for headless mode:
-
-* Chrome: 59+
-* web-component-tester: v6.5.0+
-
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
@@ -185,11 +149,22 @@
 Some useful commands:
 
 * To run ESLint on the whole app, less some dependency code:
-`eslint --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js polygerrit-ui/app`
+
+```sh
+npm run eslint
+```
+
 * To run ESLint on just the subdirectory you modified:
-`eslint --ext .html,.js polygerrit-ui/app/$YOUR_DIR_HERE`
+
+```sh
+node_modules/eslint/bin/eslint.js --ext .html,.js polygerrit-ui/app/$YOUR_DIR_HERE
+```
+
 * To run the linter on all of your local changes:
-`git diff --name-only master | xargs eslint --ext .html,.js`
+
+```sh
+git diff --name-only master | xargs node_modules/eslint/bin/eslint.js --ext .html,.js
+```
 
 We also use the `polylint` tool to lint use of Polymer. To install polylint,
 execute the following command.
@@ -199,12 +174,22 @@
 ```sh
 bazel test //polygerrit-ui/app:polylint_test
 ```
+
+or
+
+```sh
+npm run polylint
+```
+
 ## Template Type Safety
-Polymer elements are not type checked against the element definition, making it trivial to break the display when refactoring or moving code. We now run additional tests to help ensure that template types are checked.
+Polymer elements are not type checked against the element definition, making it
+trivial to break the display when refactoring or moving code. We now run
+additional tests to help ensure that template types are checked.
 
 A few notes to ensure that these tests pass
 - Any functions with optional parameters will need closure annotations.
-- Any Polymer parameters that are nullable or can be multiple types (other than the one explicitly delared) will need type annotations.
+- Any Polymer parameters that are nullable or can be multiple types (other than
+  the one explicitly delared) will need type annotations.
 
 These tests require the `typescript` and `fried-twinkie` npm packages.
 
@@ -214,6 +199,12 @@
 ./polygerrit-ui/app/run_template_test.sh
 ```
 
+or
+
+```sh
+npm run test-template
+```
+
 To run on a specific top level directory (ex: change-list)
 ```sh
 TEMPLATE_NO_DEFAULT=true ./polygerrit-ui/app/run_template_test.sh //polygerrit-ui/app:template_test_change-list
diff --git a/polygerrit-ui/app/.eslintrc.json b/polygerrit-ui/app/.eslintrc.json
index 7cb1a11..97151f2 100644
--- a/polygerrit-ui/app/.eslintrc.json
+++ b/polygerrit-ui/app/.eslintrc.json
@@ -1,5 +1,8 @@
 {
   "extends": ["eslint:recommended", "google"],
+  "parserOptions": {
+    "ecmaVersion": 8
+  },
   "env": {
     "browser": true,
     "es6": true
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index c735746..6d30a14 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,10 +1,8 @@
-package(
-    default_visibility = ["//visibility:public"],
-)
-
-load(":rules.bzl", "polygerrit_bundle")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:js.bzl", "bower_component_bundle")
+load(":rules.bzl", "polygerrit_bundle")
+
+package(default_visibility = ["//visibility:public"])
 
 polygerrit_bundle(
     name = "polygerrit_ui",
@@ -25,7 +23,7 @@
 
 bower_component_bundle(
     name = "test_components",
-    testonly = 1,
+    testonly = True,
     deps = [
         "//lib/js:iron-test-helpers",
         "//lib/js:test-fixture",
@@ -53,6 +51,7 @@
         [
             "bower_components/**/*.html",
             "bower_components/**/*.js",
+            "bower_components/**/*.js.map",
         ],
     ),
 )
@@ -192,7 +191,7 @@
         "embed/test.html",
         "test/common-test-setup.html",
         ":embed_test_files",
-        ":polygerrit_embed_ui.zip",
+        ":pg_code.zip",
         ":test_components.zip",
     ],
     # Should not run sandboxed.
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
index fec459b..970bfc7 100644
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>async-foreach-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="async-foreach-behavior.html">
 
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
index 48bd10e..1748647 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
@@ -21,27 +21,12 @@
 
   window.Gerrit = window.Gerrit || {};
 
-  const PROJECT_DASHBOARD_PATTERN = /\/p\/(.+)\/\+\/dashboard\/(.*)/;
-  const REPO_URL_PATTERN = /^\/admin\/repos/;
-  const PROJECT_URL = '/admin/projects';
   /** @polymerBehavior Gerrit.BaseUrlBehavior */
   Gerrit.BaseUrlBehavior = {
     /** @return {string} */
     getBaseUrl() {
       return window.CANONICAL_PATH || '';
     },
-
-    computeGwtUrl(path) {
-      const base = this.getBaseUrl();
-      let clientPath = path.substring(base.length);
-      const match = clientPath.match(PROJECT_DASHBOARD_PATTERN);
-      if (match) {
-        clientPath = `/projects/${match[1]},dashboards/${match[2]}`;
-      }
-      // Replace any '/admin/project' links to '/admin/repo'
-      clientPath = clientPath.replace(REPO_URL_PATTERN, PROJECT_URL);
-      return base + '/?polygerrit=0#' + clientPath;
-    },
   };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
index 429abe1..8a76d9d 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>base-url-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <script>
   /** @type {string} */
@@ -52,6 +54,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
+        _legacyUndefinedCheck: true,
         behaviors: [
           Gerrit.BaseUrlBehavior,
         ],
@@ -66,24 +69,5 @@
     test('getBaseUrl', () => {
       assert.deepEqual(element.getBaseUrl(), '/r');
     });
-
-    test('computeGwtUrl', () => {
-      assert.deepEqual(
-          element.computeGwtUrl('/r/c/1/'),
-          '/r/?polygerrit=0#/c/1/'
-      );
-    });
-
-    test('computeGwtUrl for project dashboard', () => {
-      assert.deepEqual(
-          element.computeGwtUrl('/r/p/gerrit/proj/+/dashboard/main:default'),
-          '/r/?polygerrit=0#/projects/gerrit/proj,dashboards/main:default');
-    });
-
-    test('computeGwtUrl for project access', () => {
-      assert.deepEqual(
-          element.computeGwtUrl('/r/admin/repos/demo-project,access'),
-          '/r/?polygerrit=0#/admin/projects/demo-project,access');
-    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
index e92eb49..4f16e79 100644
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <title>docs-url-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <link rel="import" href="docs-url-behavior.html">
 
@@ -38,6 +40,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'docs-url-behavior-element',
+        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.DocsUrlBehavior],
       });
     });
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
index 640d902..15affee 100644
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>dom-util-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="dom-util-behavior.html">
 
@@ -46,6 +48,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
+        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.DomUtilBehavior],
       });
     });
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
index fb0c685..499970e 100644
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
@@ -120,6 +120,10 @@
             id: 'submitAs',
             name: 'Submit (On Behalf Of)',
           },
+          toggleWipState: {
+            id: 'toggleWipState',
+            name: 'Toggle Work In Progress State',
+          },
           viewDrafts: {
             id: 'viewDrafts',
             name: 'View Drafts',
@@ -138,6 +142,7 @@
      *    object.
      */
     toSortedArray(obj) {
+      if (!obj) { return []; }
       return Object.keys(obj).map(key => {
         return {
           id: key,
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
index 929834f..817d26e 100644
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-access-behavior.html">
 
@@ -38,6 +40,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
+        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.AccessBehavior],
       });
     });
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
index a1902cd..60817aa 100644
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <link rel="import" href="gr-admin-nav-behavior.html">
 
@@ -41,6 +43,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
+        _legacyUndefinedCheck: true,
         behaviors: [
           Gerrit.AdminNavBehavior,
         ],
diff --git a/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html b/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html
index 3ac94fe..64f0b3a 100644
--- a/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-anonymous-name-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-anonymous-name-behavior.html">
 
@@ -44,6 +46,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element-anon',
+        _legacyUndefinedCheck: true,
         behaviors: [
           Gerrit.AnonymousNameBehavior,
         ],
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
index d18e442..c462c6f 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
@@ -30,7 +30,7 @@
           'Status',
           'Owner',
           'Assignee',
-          'Project',
+          'Repo',
           'Branch',
           'Updated',
           'Size',
@@ -58,6 +58,22 @@
     isColumnHidden(columnToCheck, columnsToDisplay) {
       return !columnsToDisplay.includes(columnToCheck);
     },
+
+    /**
+     * The Project column was renamed to Repo, but some users may have
+     * preferences that use its old name. If that column is found, rename it
+     * before use.
+     * @param {!Array<string>} columns
+     * @return {!Array<string>} If the column was renamed, returns a new array
+     *     with the corrected name. Otherwise, it returns the original param.
+     */
+    getVisibleColumns(columns) {
+      const projectIndex = columns.indexOf('Project');
+      if (projectIndex === -1) { return columns; }
+      const newColumns = columns.slice(0);
+      newColumns[projectIndex] = 'Repo';
+      return newColumns;
+    },
   };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
index dc98b59..9b7339d 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-table-behavior.html">
 
@@ -48,6 +50,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
+        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.ChangeTableBehavior],
       });
     });
@@ -63,7 +66,7 @@
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
         'Size',
@@ -74,7 +77,7 @@
         'Subject',
         'Status',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Size',
       ];
@@ -83,13 +86,13 @@
     });
 
     test('isColumnHidden', () => {
-      const columnToCheck = 'Project';
+      const columnToCheck = 'Repo';
       let columnsToDisplay = [
         'Subject',
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
         'Size',
@@ -107,5 +110,17 @@
       ];
       assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
     });
+
+    test('getVisibleColumns maps Project to Repo', () => {
+      const columns = [
+        'Subject',
+        'Status',
+        'Owner',
+      ];
+      assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
+      assert.deepEqual(
+          element.getVisibleColumns(columns.concat(['Project'])),
+          columns.slice(0).concat(['Repo']));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
index a82253a..f251db8 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../gr-url-encoding-behavior.html">
+<link rel="import" href="../gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <script>
 (function(window) {
   'use strict';
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
index 54b979f..9973ae8 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-list-view-behavior.html">
 
@@ -40,6 +42,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
+        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.ListViewBehavior],
       });
     });
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index d4e83d9..7e68669 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -232,7 +232,8 @@
       return restAPI.getChangeDetail(change._number)
           .then(detail => {
             if (!detail) {
-              return Promise.reject('Unable to check for latest patchset.');
+              const error = new Error('Unable to check for latest patchset.');
+              return Promise.reject(error);
             }
             const actualLatest = Gerrit.PatchSetBehavior.computeLatestPatchNum(
                 Gerrit.PatchSetBehavior.computeAllPatchSets(detail));
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index b858c51..3db4084 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <title>gr-patch-set-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <link rel="import" href="gr-patch-set-behavior.html">
 
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
index 75c2433..0046290 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <title>gr-path-list-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <link rel="import" href="gr-path-list-behavior.html">
 
diff --git a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
new file mode 100644
index 0000000..2dc070d
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
@@ -0,0 +1,38 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior this */
+  Gerrit.RepoPluginConfig = {
+    // Should be kept in sync with
+    // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
+    ENTRY_TYPES: {
+      ARRAY: 'ARRAY',
+      BOOLEAN: 'BOOLEAN',
+      INT: 'INT',
+      LIST: 'LIST',
+      LONG: 'LONG',
+      STRING: 'STRING',
+    },
+    PLUGIN_CONFIG_CHANGED: 'plugin-config-changed',
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
index 07d3484..0e2e99f 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../elements/shared/gr-tooltip/gr-tooltip.html">
 <script src="../../scripts/rootElement.js"></script>
 
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
index 8bca339..2c4b376 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
@@ -17,9 +17,11 @@
 -->
 
 <title>tooltip-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-tooltip-behavior.html">
 
@@ -51,6 +53,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'tooltip-behavior-element',
+        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.TooltipBehavior],
       });
     });
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
deleted file mode 100644
index 365266c..0000000
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
+++ /dev/null
@@ -1,42 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.URLEncodingBehavior */
-  Gerrit.URLEncodingBehavior = {
-    /**
-     * Pretty-encodes a URL. Double-encodes the string, and then replaces
-     *   benevolent characters for legibility.
-     */
-    encodeURL(url, replaceSlashes) {
-      // @see Issue 4255 regarding double-encoding.
-      let output = encodeURIComponent(encodeURIComponent(url));
-      // @see Issue 4577 regarding more readable URLs.
-      output = output.replace(/%253A/g, ':');
-      output = output.replace(/%2520/g, '+');
-      if (replaceSlashes) {
-        output = output.replace(/%252F/g, '/');
-      }
-      return output;
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html
new file mode 100644
index 0000000..69703f6
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html
@@ -0,0 +1,57 @@
+<!--
+@license
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.URLEncodingBehavior */
+  Gerrit.URLEncodingBehavior = {
+    /**
+     * Pretty-encodes a URL. Double-encodes the string, and then replaces
+     *   benevolent characters for legibility.
+     * @param {string} url
+     * @param {boolean=} replaceSlashes
+     * @return {string}
+     */
+    encodeURL(url, replaceSlashes) {
+      // @see Issue 4255 regarding double-encoding.
+      let output = encodeURIComponent(encodeURIComponent(url));
+      // @see Issue 4577 regarding more readable URLs.
+      output = output.replace(/%253A/g, ':');
+      output = output.replace(/%2520/g, '+');
+      if (replaceSlashes) {
+        output = output.replace(/%252F/g, '/');
+      }
+      return output;
+    },
+
+    /**
+     * Single decode for URL components. Will decode plus signs ('+') to spaces.
+     * Note: because this function decodes once, it is not the inverse of
+     * encodeURL.
+     * @param {string} url
+     * @return {string}
+     */
+    singleDecodeURL(url) {
+      const withoutPlus = url.replace(/\+/g, '%20');
+      return decodeURIComponent(withoutPlus);
+    },
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
new file mode 100644
index 0000000..8601397
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<title>gr-url-encoding-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="gr-url-encoding-behavior.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-url-encoding-behavior tests', () => {
+    let element;
+    let sandbox;
+
+    suiteSetup(() => {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        _legacyUndefinedCheck: true,
+        behaviors: [Gerrit.URLEncodingBehavior],
+      });
+    });
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('encodeURL', () => {
+      test('double encodes', () => {
+        assert.equal(element.encodeURL('abc?123'), 'abc%253F123');
+        assert.equal(element.encodeURL('def/ghi'), 'def%252Fghi');
+        assert.equal(element.encodeURL('jkl'), 'jkl');
+        assert.equal(element.encodeURL(''), '');
+      });
+
+      test('does not convert colons', () => {
+        assert.equal(element.encodeURL('mno:pqr'), 'mno:pqr');
+      });
+
+      test('converts spaces to +', () => {
+        assert.equal(element.encodeURL('words with spaces'), 'words+with+spaces');
+      });
+
+      test('does not convert slashes when configured', () => {
+        assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
+      });
+
+      test('does not convert slashes when configured', () => {
+        assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
+      });
+    });
+
+    suite('singleDecodeUrl', () => {
+      test('single decodes', () => {
+        assert.equal(element.singleDecodeURL('abc%3Fdef'), 'abc?def');
+      });
+
+      test('converts + to space', () => {
+        assert.equal(element.singleDecodeURL('ghi+jkl'), 'ghi jkl');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index 89f3038..51adf2e 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -14,17 +14,283 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+
+<!--
+
+How to Add a Keyboard Shortcut
+==============================
+
+A keyboard shortcut is composed of the following parts:
+
+  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
+  2. Documentation for the keyboard shortcut help dialog
+  3. A binding between key combos and the semantic identifier
+  4. A binding between the semantic identifier and a listener
+
+Parts (1) and (2) for all shortcuts are defined in this file. The semantic
+identifier is declared in the Shortcut enum near the head of this script:
+
+  const Shortcut = {
+    // ...
+    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
+    // ...
+  };
+
+Immediately following the Shortcut enum definition, there is a _describe
+function defined which is then invoked many times to populate the help dialog.
+Add a new invocation here to document the shortcut:
+
+  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
+      'Hide/show left diff');
+
+When an attached view binds one or more key combos to this shortcut, the help
+dialog will display this text in the given section (in this case, "Diffs"). See
+the ShortcutSection enum immediately below for the list of supported sections.
+
+Part (3), the actual key bindings, are declared by gr-app. In the future, this
+system may be expanded to allow key binding customizations by plugins or user
+preferences. Key bindings are defined in the following forms:
+
+  // Ordinary shortcut with a single binding.
+  this.bindShortcut(
+      this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+
+  // Ordinary shortcut with multiple bindings.
+  this.bindShortcut(
+      this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+
+  // A "go-key" keyboard shortcut, which is combined with a previously and
+  // continuously pressed "go" key (the go-key is hard-coded as 'g').
+  this.bindShortcut(
+      this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
+
+  // A "doc-only" keyboard shortcut. This declares the key-binding for help
+  // dialog purposes, but doesn't actually implement the binding. It is up
+  // to some element to implement this binding using iron-a11y-keys-behavior's
+  // keyBindings property.
+  this.bindShortcut(
+      this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
+
+Part (4), the listener definitions, are declared by the view or element that
+implements the shortcut behavior. This is done by implementing a method named
+keyboardShortcuts() in an element that mixes in this behavior, returning an
+object that maps semantic identifiers (as property names) to listener method
+names, like this:
+
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+    };
+  },
+
+You can implement key bindings in an element that is hosted by a view IF that
+element is always attached exactly once under that view (e.g. the search bar in
+gr-app). When that is not the case, you will have to define a doc-only binding
+in gr-app, declare the shortcut in the view that hosts the element, and use
+iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
+element. An example of this is in comment threads. A diff view supports actions
+on comment threads, but there may be zero or many comment threads attached at
+any given point. So the shortcut is declared as doc-only by the diff view and
+by gr-app, and actually implemented by gr-comment-thread.
+
+NOTE: doc-only shortcuts will not be customizable in the same way that other
+shortcuts are.
+-->
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
 
 <script>
 (function(window) {
   'use strict';
 
+  const DOC_ONLY = 'DOC_ONLY';
+  const GO_KEY = 'GO_KEY';
+
+  // The maximum age of a keydown event to be used in a jump navigation. This
+  // is only for cases when the keyup event is lost.
+  const GO_KEY_TIMEOUT_MS = 1000;
+
+  const ShortcutSection = {
+    ACTIONS: 'Actions',
+    DIFFS: 'Diffs',
+    EVERYWHERE: 'Everywhere',
+    FILE_LIST: 'File list',
+    NAVIGATION: 'Navigation',
+    REPLY_DIALOG: 'Reply dialog',
+  };
+
+  const Shortcut = {
+    OPEN_SHORTCUT_HELP_DIALOG: 'OPEN_SHORTCUT_HELP_DIALOG',
+    GO_TO_OPENED_CHANGES: 'GO_TO_OPENED_CHANGES',
+    GO_TO_MERGED_CHANGES: 'GO_TO_MERGED_CHANGES',
+    GO_TO_ABANDONED_CHANGES: 'GO_TO_ABANDONED_CHANGES',
+
+    CURSOR_NEXT_CHANGE: 'CURSOR_NEXT_CHANGE',
+    CURSOR_PREV_CHANGE: 'CURSOR_PREV_CHANGE',
+    OPEN_CHANGE: 'OPEN_CHANGE',
+    NEXT_PAGE: 'NEXT_PAGE',
+    PREV_PAGE: 'PREV_PAGE',
+    TOGGLE_CHANGE_REVIEWED: 'TOGGLE_CHANGE_REVIEWED',
+    TOGGLE_CHANGE_STAR: 'TOGGLE_CHANGE_STAR',
+    REFRESH_CHANGE_LIST: 'REFRESH_CHANGE_LIST',
+
+    OPEN_REPLY_DIALOG: 'OPEN_REPLY_DIALOG',
+    OPEN_DOWNLOAD_DIALOG: 'OPEN_DOWNLOAD_DIALOG',
+    EXPAND_ALL_MESSAGES: 'EXPAND_ALL_MESSAGES',
+    COLLAPSE_ALL_MESSAGES: 'COLLAPSE_ALL_MESSAGES',
+    UP_TO_DASHBOARD: 'UP_TO_DASHBOARD',
+    UP_TO_CHANGE: 'UP_TO_CHANGE',
+    TOGGLE_DIFF_MODE: 'TOGGLE_DIFF_MODE',
+    REFRESH_CHANGE: 'REFRESH_CHANGE',
+    EDIT_TOPIC: 'EDIT_TOPIC',
+
+    NEXT_LINE: 'NEXT_LINE',
+    PREV_LINE: 'PREV_LINE',
+    NEXT_CHUNK: 'NEXT_CHUNK',
+    PREV_CHUNK: 'PREV_CHUNK',
+    EXPAND_ALL_DIFF_CONTEXT: 'EXPAND_ALL_DIFF_CONTEXT',
+    NEXT_COMMENT_THREAD: 'NEXT_COMMENT_THREAD',
+    PREV_COMMENT_THREAD: 'PREV_COMMENT_THREAD',
+    EXPAND_ALL_COMMENT_THREADS: 'EXPAND_ALL_COMMENT_THREADS',
+    COLLAPSE_ALL_COMMENT_THREADS: 'COLLAPSE_ALL_COMMENT_THREADS',
+    LEFT_PANE: 'LEFT_PANE',
+    RIGHT_PANE: 'RIGHT_PANE',
+    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
+    NEW_COMMENT: 'NEW_COMMENT',
+    SAVE_COMMENT: 'SAVE_COMMENT',
+    OPEN_DIFF_PREFS: 'OPEN_DIFF_PREFS',
+    TOGGLE_DIFF_REVIEWED: 'TOGGLE_DIFF_REVIEWED',
+
+    NEXT_FILE: 'NEXT_FILE',
+    PREV_FILE: 'PREV_FILE',
+    NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
+    PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
+    NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
+    CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
+    CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
+    OPEN_FILE: 'OPEN_FILE',
+    TOGGLE_FILE_REVIEWED: 'TOGGLE_FILE_REVIEWED',
+    TOGGLE_ALL_INLINE_DIFFS: 'TOGGLE_ALL_INLINE_DIFFS',
+    TOGGLE_INLINE_DIFF: 'TOGGLE_INLINE_DIFF',
+
+    OPEN_FIRST_FILE: 'OPEN_FIRST_FILE',
+    OPEN_LAST_FILE: 'OPEN_LAST_FILE',
+
+    SEARCH: 'SEARCH',
+    SEND_REPLY: 'SEND_REPLY',
+  };
+
+  const _help = new Map();
+
+  function _describe(shortcut, section, text) {
+    if (!_help.has(section)) {
+      _help.set(section, []);
+    }
+    _help.get(section).push({shortcut, text});
+  }
+
+  _describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
+  _describe(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, ShortcutSection.EVERYWHERE,
+      'Show this dialog');
+  _describe(Shortcut.GO_TO_OPENED_CHANGES, ShortcutSection.EVERYWHERE,
+      'Go to Opened Changes');
+  _describe(Shortcut.GO_TO_MERGED_CHANGES, ShortcutSection.EVERYWHERE,
+      'Go to Merged Changes');
+  _describe(Shortcut.GO_TO_ABANDONED_CHANGES, ShortcutSection.EVERYWHERE,
+      'Go to Abandoned Changes');
+
+  _describe(Shortcut.CURSOR_NEXT_CHANGE, ShortcutSection.ACTIONS,
+      'Select next change');
+  _describe(Shortcut.CURSOR_PREV_CHANGE, ShortcutSection.ACTIONS,
+      'Select previous change');
+  _describe(Shortcut.OPEN_CHANGE, ShortcutSection.ACTIONS,
+      'Show selected change');
+  _describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
+  _describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
+  _describe(Shortcut.OPEN_REPLY_DIALOG, ShortcutSection.ACTIONS,
+      'Open reply dialog to publish comments and add reviewers');
+  _describe(Shortcut.OPEN_DOWNLOAD_DIALOG, ShortcutSection.ACTIONS,
+      'Open download overlay');
+  _describe(Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS,
+      'Expand all messages');
+  _describe(Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS,
+      'Collapse all messages');
+  _describe(Shortcut.REFRESH_CHANGE, ShortcutSection.ACTIONS,
+      'Reload the change at the latest patch');
+  _describe(Shortcut.TOGGLE_CHANGE_REVIEWED, ShortcutSection.ACTIONS,
+      'Mark/unmark change as reviewed');
+  _describe(Shortcut.TOGGLE_FILE_REVIEWED, ShortcutSection.ACTIONS,
+      'Toggle review flag on selected file');
+  _describe(Shortcut.REFRESH_CHANGE_LIST, ShortcutSection.ACTIONS,
+      'Refresh list of changes');
+  _describe(Shortcut.TOGGLE_CHANGE_STAR, ShortcutSection.ACTIONS,
+      'Star/unstar change');
+  _describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS,
+      'Add a change topic');
+
+  _describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
+  _describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
+  _describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS,
+      'Go to next diff chunk');
+  _describe(Shortcut.PREV_CHUNK, ShortcutSection.DIFFS,
+      'Go to previous diff chunk');
+  _describe(Shortcut.EXPAND_ALL_DIFF_CONTEXT, ShortcutSection.DIFFS,
+      'Expand all diff context');
+  _describe(Shortcut.NEXT_COMMENT_THREAD, ShortcutSection.DIFFS,
+      'Go to next comment thread');
+  _describe(Shortcut.PREV_COMMENT_THREAD, ShortcutSection.DIFFS,
+      'Go to previous comment thread');
+  _describe(Shortcut.EXPAND_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
+      'Expand all comment threads');
+  _describe(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
+      'Collapse all comment threads');
+  _describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
+  _describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
+  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
+      'Hide/show left diff');
+  _describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
+  _describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
+  _describe(Shortcut.OPEN_DIFF_PREFS, ShortcutSection.DIFFS,
+      'Show diff preferences');
+  _describe(Shortcut.TOGGLE_DIFF_REVIEWED, ShortcutSection.DIFFS,
+      'Mark/unmark file as reviewed');
+  _describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
+      'Toggle unified/side-by-side diff');
+  _describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
+      'Mark file as reviewed and go to next unreviewed file');
+
+  _describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Select next file');
+  _describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
+      'Select previous file');
+  _describe(Shortcut.NEXT_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
+      'Select next file that has comments');
+  _describe(Shortcut.PREV_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
+      'Select previous file that has comments');
+  _describe(Shortcut.OPEN_FIRST_FILE, ShortcutSection.NAVIGATION,
+      'Show first file');
+  _describe(Shortcut.OPEN_LAST_FILE, ShortcutSection.NAVIGATION,
+      'Show last file');
+  _describe(Shortcut.UP_TO_DASHBOARD, ShortcutSection.NAVIGATION,
+      'Up to dashboard');
+  _describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
+
+  _describe(Shortcut.CURSOR_NEXT_FILE, ShortcutSection.FILE_LIST,
+      'Select next file');
+  _describe(Shortcut.CURSOR_PREV_FILE, ShortcutSection.FILE_LIST,
+      'Select previous file');
+  _describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST,
+      'Go to selected file');
+  _describe(Shortcut.TOGGLE_ALL_INLINE_DIFFS, ShortcutSection.FILE_LIST,
+      'Show/hide all inline diffs');
+  _describe(Shortcut.TOGGLE_INLINE_DIFF, ShortcutSection.FILE_LIST,
+      'Show/hide selected inline diff');
+
+  _describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
+
   // Must be declared outside behavior implementation to be accessed inside
   // behavior functions.
 
-  /** @return {!Object} */
+  /** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
   const getKeyboardEvent = function(e) {
     e = Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
     // When e is a keyboardEvent, e.event is not null.
@@ -32,42 +298,307 @@
     return e;
   };
 
+  class ShortcutManager {
+    constructor() {
+      this.activeHosts = new Map();
+      this.bindings = new Map();
+      this.listeners = new Set();
+    }
+
+    bindShortcut(shortcut, ...bindings) {
+      this.bindings.set(shortcut, bindings);
+    }
+
+    getBindingsForShortcut(shortcut) {
+      return this.bindings.get(shortcut);
+    }
+
+    attachHost(host) {
+      if (!host.keyboardShortcuts) { return; }
+      const shortcuts = host.keyboardShortcuts();
+      this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
+      this.notifyListeners();
+      return shortcuts;
+    }
+
+    detachHost(host) {
+      if (this.activeHosts.delete(host)) {
+        this.notifyListeners();
+        return true;
+      }
+      return false;
+    }
+
+    addListener(listener) {
+      this.listeners.add(listener);
+      listener(this.directoryView());
+    }
+
+    removeListener(listener) {
+      return this.listeners.delete(listener);
+    }
+
+    activeShortcutsBySection() {
+      const activeShortcuts = new Set();
+      this.activeHosts.forEach(shortcuts => {
+        shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
+      });
+
+      const activeShortcutsBySection = new Map();
+      _help.forEach((shortcutList, section) => {
+        shortcutList.forEach(shortcutHelp => {
+          if (activeShortcuts.has(shortcutHelp.shortcut)) {
+            if (!activeShortcutsBySection.has(section)) {
+              activeShortcutsBySection.set(section, []);
+            }
+            activeShortcutsBySection.get(section).push(shortcutHelp);
+          }
+        });
+      });
+      return activeShortcutsBySection;
+    }
+
+    directoryView() {
+      const view = new Map();
+      this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+        const sectionView = [];
+        shortcutHelps.forEach(shortcutHelp => {
+          const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
+          if (!bindingDesc) { return; }
+          this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
+            sectionView.push({
+              binding: bindingDesc,
+              text: shortcutHelp.text,
+            });
+          });
+        });
+        view.set(section, sectionView);
+      });
+      return view;
+    }
+
+    distributeBindingDesc(bindingDesc) {
+      if (bindingDesc.length === 1 ||
+          this.comboSetDisplayWidth(bindingDesc) < 21) {
+        return [bindingDesc];
+      }
+      // Find the largest prefix of bindings that is under the
+      // size threshold.
+      const head = [bindingDesc[0]];
+      for (let i = 1; i < bindingDesc.length; i++) {
+        head.push(bindingDesc[i]);
+        if (this.comboSetDisplayWidth(head) >= 21) {
+          head.pop();
+          return [head].concat(
+              this.distributeBindingDesc(bindingDesc.slice(i)));
+        }
+      }
+    }
+
+    comboSetDisplayWidth(bindingDesc) {
+      const bindingSizer = binding => binding.reduce(
+          (acc, key) => acc + key.length, 0);
+      // Width is the sum of strings + (n-1) * 2 to account for the word
+      // "or" joining them.
+      return bindingDesc.reduce(
+          (acc, binding) => acc + bindingSizer(binding), 0) +
+          2 * (bindingDesc.length - 1);
+    }
+
+    describeBindings(shortcut) {
+      const bindings = this.bindings.get(shortcut);
+      if (!bindings) { return null; }
+      if (bindings[0] === GO_KEY) {
+        return [['g'].concat(bindings.slice(1))];
+      }
+      return bindings
+          .filter(binding => binding !== DOC_ONLY)
+          .map(binding => this.describeBinding(binding));
+    }
+
+    describeBinding(binding) {
+      return binding.split(':')[0].split('+').map(part => {
+        switch (part) {
+          case 'shift':
+            return 'Shift';
+          case 'meta':
+            return 'Meta';
+          case 'ctrl':
+            return 'Ctrl';
+          case 'enter':
+            return 'Enter';
+          case 'up':
+            return '↑';
+          case 'down':
+            return '↓';
+          case 'left':
+            return '←';
+          case 'right':
+            return '→';
+          default:
+            return part;
+        }
+      });
+    }
+
+    notifyListeners() {
+      const view = this.directoryView();
+      this.listeners.forEach(listener => listener(view));
+    }
+  }
+
+  const shortcutManager = new ShortcutManager();
+
   window.Gerrit = window.Gerrit || {};
 
   /** @polymerBehavior KeyboardShortcutBehavior */
-  Gerrit.KeyboardShortcutBehavior = [{
-    modifierPressed(e) {
-      e = getKeyboardEvent(e);
-      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
-    },
-
-    isModifierPressed(e, modifier) {
-      return getKeyboardEvent(e)[modifier];
-    },
-
-    shouldSuppressKeyboardShortcut(e) {
-      e = getKeyboardEvent(e);
-      const tagName = Polymer.dom(e).rootTarget.tagName;
-      if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
-        return true;
-      }
-      for (let i = 0; e.path && i < e.path.length; i++) {
-        if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
-      }
-      return false;
-    },
-
-    // Alias for getKeyboardEvent.
-    /** @return {!Object} */
-    getKeyboardEvent(e) {
-      return getKeyboardEvent(e);
-    },
-
-    getRootTarget(e) {
-      return Polymer.dom(getKeyboardEvent(e)).rootTarget;
-    },
-  },
+  Gerrit.KeyboardShortcutBehavior = [
     Polymer.IronA11yKeysBehavior,
+    {
+      // Exports for convenience. Note: Closure compiler crashes when
+      // object-shorthand syntax is used here.
+      // eslint-disable-next-line object-shorthand
+      DOC_ONLY: DOC_ONLY,
+      // eslint-disable-next-line object-shorthand
+      GO_KEY: GO_KEY,
+      // eslint-disable-next-line object-shorthand
+      Shortcut: Shortcut,
+
+      properties: {
+        _shortcut_go_key_last_pressed: {
+          type: Number,
+          value: null,
+        },
+
+        _shortcut_go_table: {
+          type: Array,
+          value() { return new Map(); },
+        },
+      },
+
+      modifierPressed(e) {
+        e = getKeyboardEvent(e);
+        return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+      },
+
+      isModifierPressed(e, modifier) {
+        return getKeyboardEvent(e)[modifier];
+      },
+
+      shouldSuppressKeyboardShortcut(e) {
+        e = getKeyboardEvent(e);
+        const tagName = Polymer.dom(e).rootTarget.tagName;
+        if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
+            (e.keyCode === 13 && tagName === 'A')) {
+          // Suppress shortcuts if the key is 'enter' and target is an anchor.
+          return true;
+        }
+        for (let i = 0; e.path && i < e.path.length; i++) {
+          if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
+        }
+        return false;
+      },
+
+      // Alias for getKeyboardEvent.
+      /** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
+      getKeyboardEvent(e) {
+        return getKeyboardEvent(e);
+      },
+
+      getRootTarget(e) {
+        return Polymer.dom(getKeyboardEvent(e)).rootTarget;
+      },
+
+      bindShortcut(shortcut, ...bindings) {
+        shortcutManager.bindShortcut(shortcut, ...bindings);
+      },
+
+      _addOwnKeyBindings(shortcut, handler) {
+        const bindings = shortcutManager.getBindingsForShortcut(shortcut);
+        if (!bindings) {
+          return;
+        }
+        if (bindings[0] === DOC_ONLY) {
+          return;
+        }
+        if (bindings[0] === GO_KEY) {
+          this._shortcut_go_table.set(bindings[1], handler);
+        } else {
+          this.addOwnKeyBinding(bindings.join(' '), handler);
+        }
+      },
+
+      attached() {
+        const shortcuts = shortcutManager.attachHost(this);
+        if (!shortcuts) { return; }
+
+        for (const key of Object.keys(shortcuts)) {
+          this._addOwnKeyBindings(key, shortcuts[key]);
+        }
+
+        // If any of the shortcuts utilized GO_KEY, then they are handled
+        // directly by this behavior.
+        if (this._shortcut_go_table.size > 0) {
+          this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
+          this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
+          this._shortcut_go_table.forEach((handler, key) => {
+            this.addOwnKeyBinding(key, '_handleGoAction');
+          });
+        }
+      },
+
+      detached() {
+        if (shortcutManager.detachHost(this)) {
+          this.removeOwnKeyBindings();
+        }
+      },
+
+      keyboardShortcuts() {
+        return {};
+      },
+
+      addKeyboardShortcutDirectoryListener(listener) {
+        shortcutManager.addListener(listener);
+      },
+
+      removeKeyboardShortcutDirectoryListener(listener) {
+        shortcutManager.removeListener(listener);
+      },
+
+      _handleGoKeyDown(e) {
+        if (this.modifierPressed(e)) { return; }
+        this._shortcut_go_key_last_pressed = Date.now();
+      },
+
+      _handleGoKeyUp(e) {
+        this._shortcut_go_key_last_pressed = null;
+      },
+
+      _handleGoAction(e) {
+        if (!this._shortcut_go_key_last_pressed ||
+            (Date.now() - this._shortcut_go_key_last_pressed >
+                GO_KEY_TIMEOUT_MS) ||
+            !this._shortcut_go_table.has(e.detail.key) ||
+            this.shouldSuppressKeyboardShortcut(e)) {
+          return;
+        }
+        e.preventDefault();
+        const handler = this._shortcut_go_table.get(e.detail.key);
+        this[handler](e);
+      },
+    },
   ];
+
+  Gerrit.KeyboardShortcutBinder = {
+    DOC_ONLY,
+    GO_KEY,
+    Shortcut,
+    ShortcutManager,
+    ShortcutSection,
+
+    bindShortcut(shortcut, ...bindings) {
+      shortcutManager.bindShortcut(shortcut, ...bindings);
+    },
+  };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index 8e10302..9d5481d 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="keyboard-shortcut-behavior.html">
 
@@ -40,6 +42,8 @@
 
 <script>
   suite('keyboard-shortcut-behavior tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+
     let element;
     let overlay;
     let sandbox;
@@ -48,9 +52,11 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
+        _legacyUndefinedCheck: true,
         behaviors: [Gerrit.KeyboardShortcutBehavior],
         keyBindings: {
           k: '_handleKey',
+          enter: '_handleKey',
         },
         _handleKey() {},
       });
@@ -66,6 +72,228 @@
       sandbox.restore();
     });
 
+    suite('ShortcutManager', () => {
+      test('bindings management', () => {
+        const mgr = new kb.ShortcutManager();
+        const {NEXT_FILE} = kb.Shortcut;
+
+        assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+        mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+        assert.deepEqual(
+            mgr.getBindingsForShortcut(NEXT_FILE),
+            [']', '}', 'right']);
+      });
+
+      suite('binding descriptions', () => {
+        function mapToObject(m) {
+          const o = {};
+          m.forEach((v, k) => o[k] = v);
+          return o;
+        }
+
+        test('single combo description', () => {
+          const mgr = new kb.ShortcutManager();
+          assert.deepEqual(mgr.describeBinding('a'), ['a']);
+          assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
+          assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
+          assert.deepEqual(
+              mgr.describeBinding('ctrl+shift+up:keyup'),
+              ['Ctrl', 'Shift', '↑']);
+        });
+
+        test('combo set description', () => {
+          const {GO_KEY, DOC_ONLY, ShortcutManager} = kb;
+          const {GO_TO_OPENED_CHANGES, NEXT_FILE, PREV_FILE} = kb.Shortcut;
+
+          const mgr = new ShortcutManager();
+          assert.isNull(mgr.describeBindings(NEXT_FILE));
+
+          mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
+          assert.deepEqual(
+              mgr.describeBindings(GO_TO_OPENED_CHANGES),
+              [['g', 'o']]);
+
+          mgr.bindShortcut(NEXT_FILE, DOC_ONLY, ']', 'ctrl+shift+right:keyup');
+          assert.deepEqual(
+              mgr.describeBindings(NEXT_FILE),
+              [[']'], ['Ctrl', 'Shift', '→']]);
+
+          mgr.bindShortcut(PREV_FILE, '[');
+          assert.deepEqual(mgr.describeBindings(PREV_FILE), [['[']]);
+        });
+
+        test('combo set description width', () => {
+          const mgr = new kb.ShortcutManager();
+          assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
+          assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
+          assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
+          assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
+          assert.strictEqual(
+              mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
+              12);
+        });
+
+        test('distribute shortcut help', () => {
+          const mgr = new kb.ShortcutManager();
+          assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
+          assert.deepEqual(
+              mgr.distributeBindingDesc([['g', 'o']]),
+              [[['g', 'o']]]);
+          assert.deepEqual(
+              mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
+              [[['ctrl', 'shift', 'meta', 'enter']]]);
+          assert.deepEqual(
+              mgr.distributeBindingDesc([
+                ['ctrl', 'shift', 'meta', 'enter'],
+                ['o'],
+              ]),
+              [
+                [['ctrl', 'shift', 'meta', 'enter']],
+                [['o']],
+              ]);
+          assert.deepEqual(
+              mgr.distributeBindingDesc([
+                ['ctrl', 'enter'],
+                ['meta', 'enter'],
+                ['ctrl', 's'],
+                ['meta', 's'],
+              ]),
+              [
+                [['ctrl', 'enter'], ['meta', 'enter']],
+                [['ctrl', 's'], ['meta', 's']],
+              ]);
+        });
+
+        test('active shortcuts by section', () => {
+          const {NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH} =
+              kb.Shortcut;
+          const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
+
+          const mgr = new kb.ShortcutManager();
+          mgr.bindShortcut(NEXT_FILE, ']');
+          mgr.bindShortcut(NEXT_LINE, 'j');
+          mgr.bindShortcut(GO_TO_OPENED_CHANGES, 'g+o');
+          mgr.bindShortcut(SEARCH, '/');
+
+          assert.deepEqual(
+              mapToObject(mgr.activeShortcutsBySection()),
+              {});
+
+          mgr.attachHost({
+            keyboardShortcuts() {
+              return {
+                [NEXT_FILE]: null,
+              };
+            },
+          });
+          assert.deepEqual(
+              mapToObject(mgr.activeShortcutsBySection()),
+              {
+                [NAVIGATION]: [
+                  {shortcut: NEXT_FILE, text: 'Select next file'},
+                ],
+              });
+
+          mgr.attachHost({
+            keyboardShortcuts() {
+              return {
+                [NEXT_LINE]: null,
+              };
+            },
+          });
+          assert.deepEqual(
+              mapToObject(mgr.activeShortcutsBySection()),
+              {
+                [DIFFS]: [
+                  {shortcut: NEXT_LINE, text: 'Go to next line'},
+                ],
+                [NAVIGATION]: [
+                  {shortcut: NEXT_FILE, text: 'Select next file'},
+                ],
+              });
+
+          mgr.attachHost({
+            keyboardShortcuts() {
+              return {
+                [SEARCH]: null,
+                [GO_TO_OPENED_CHANGES]: null,
+              };
+            },
+          });
+          assert.deepEqual(
+              mapToObject(mgr.activeShortcutsBySection()),
+              {
+                [DIFFS]: [
+                  {shortcut: NEXT_LINE, text: 'Go to next line'},
+                ],
+                [EVERYWHERE]: [
+                  {shortcut: SEARCH, text: 'Search'},
+                  {
+                    shortcut: GO_TO_OPENED_CHANGES,
+                    text: 'Go to Opened Changes',
+                  },
+                ],
+                [NAVIGATION]: [
+                  {shortcut: NEXT_FILE, text: 'Select next file'},
+                ],
+              });
+        });
+
+        test('directory view', () => {
+          const {
+              NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH,
+              SAVE_COMMENT,
+          } = kb.Shortcut;
+          const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
+          const {GO_KEY, ShortcutManager} = kb;
+
+          const mgr = new ShortcutManager();
+          mgr.bindShortcut(NEXT_FILE, ']');
+          mgr.bindShortcut(NEXT_LINE, 'j');
+          mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
+          mgr.bindShortcut(SEARCH, '/');
+          mgr.bindShortcut(
+              SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
+
+          assert.deepEqual(mapToObject(mgr.directoryView()), {});
+
+          mgr.attachHost({
+            keyboardShortcuts() {
+              return {
+                [GO_TO_OPENED_CHANGES]: null,
+                [NEXT_FILE]: null,
+                [NEXT_LINE]: null,
+                [SAVE_COMMENT]: null,
+                [SEARCH]: null,
+              };
+            },
+          });
+          assert.deepEqual(
+              mapToObject(mgr.directoryView()),
+              {
+                [DIFFS]: [
+                  {binding: [['j']], text: 'Go to next line'},
+                  {
+                    binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
+                    text: 'Save comment',
+                  },
+                  {
+                    binding: [['Ctrl', 's'], ['Meta', 's']],
+                    text: 'Save comment',
+                  },
+                ],
+                [EVERYWHERE]: [
+                  {binding: [['/']], text: 'Search'},
+                  {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+                ],
+                [NAVIGATION]: [
+                  {binding: [[']']], text: 'Select next file'},
+                ],
+              });
+        });
+      });
+    });
+
     test('doesn’t block kb shortcuts for non-whitelisted els', done => {
       const divEl = document.createElement('div');
       element.appendChild(divEl);
@@ -107,6 +335,17 @@
       MockInteractions.keyDownOn(divEl, 75, null, 'k');
     });
 
+    test('blocks enter shortcut on an anchor', done => {
+      const anchorEl = document.createElement('a');
+      const element = overlay.querySelector('test-element');
+      element.appendChild(anchorEl);
+      element._handleKey = e => {
+        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
+    });
+
     test('modifierPressed returns accurate values', () => {
       const spy = sandbox.spy(element, 'modifierPressed');
       element._handleKey = e => {
@@ -148,5 +387,56 @@
       MockInteractions.keyDownOn(element, 75, 'alt', 'k');
       assert.isFalse(spy.lastCall.returnValue);
     });
+
+    suite('GO_KEY timing', () => {
+      let handlerStub;
+
+      setup(() => {
+        element._shortcut_go_table.set('a', '_handleA');
+        handlerStub = element._handleA = sinon.stub();
+        sandbox.stub(Date, 'now').returns(10000);
+      });
+
+      test('success', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._shortcut_go_key_last_pressed = 9000;
+        element._handleGoAction(e);
+        assert.isTrue(handlerStub.calledOnce);
+        assert.strictEqual(handlerStub.lastCall.args[0], e);
+      });
+
+      test('go key not pressed', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._shortcut_go_key_last_pressed = null;
+        element._handleGoAction(e);
+        assert.isFalse(handlerStub.called);
+      });
+
+      test('go key pressed too long ago', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._shortcut_go_key_last_pressed = 3000;
+        element._handleGoAction(e);
+        assert.isFalse(handlerStub.called);
+      });
+
+      test('should suppress', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
+        element._shortcut_go_key_last_pressed = 9000;
+        element._handleGoAction(e);
+        assert.isFalse(handlerStub.called);
+      });
+
+      test('unrecognized key', () => {
+        const e = {detail: {key: 'f'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._shortcut_go_key_last_pressed = 9000;
+        element._handleGoAction(e);
+        assert.isFalse(handlerStub.called);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
index 4be674f..4252e6e 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../base-url-behavior/base-url-behavior.html">
 <script>
 (function(window) {
@@ -97,6 +97,12 @@
 
       // Skip mergeability data.
       SKIP_MERGEABLE: 22,
+
+      /**
+      * Skip diffstat computation that compute the insertions field (number of lines inserted) and
+      * deletions field (number of lines deleted)
+      */
+      SKIP_DIFFSTAT: 23,
     },
 
     listChangesOptionsToHex(...args) {
@@ -110,8 +116,9 @@
     /**
      *  @return {string}
      */
-    changeBaseURL(changeNum, patchNum) {
-      let v = this.getBaseUrl() + '/changes/' + changeNum;
+    changeBaseURL(project, changeNum, patchNum) {
+      let v = this.getBaseUrl() + '/changes/' +
+         encodeURIComponent(project) + '~' + changeNum;
       if (patchNum) {
         v += '/revisions/' + patchNum;
       }
@@ -122,8 +129,8 @@
       return this.getBaseUrl() + '/c/' + changeNum;
     },
 
-    changeIsOpen(status) {
-      return status === this.ChangeStatus.NEW;
+    changeIsOpen(change) {
+      return change && change.status === this.ChangeStatus.NEW;
     },
 
     /**
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index d3ce73c..013ec2e 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
-<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <script>
   /** @type {string} */
@@ -54,6 +56,7 @@
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
+        _legacyUndefinedCheck: true,
         behaviors: [
           Gerrit.BaseUrlBehavior,
           Gerrit.RESTClientBehavior,
@@ -68,8 +71,8 @@
 
     test('changeBaseURL', () => {
       assert.deepEqual(
-          element.changeBaseURL('1', '1'),
-          '/r/changes/1/revisions/1'
+          element.changeBaseURL('test/project', '1', '2'),
+          '/r/changes/test%2Fproject~1/revisions/2'
       );
     });
 
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
new file mode 100644
index 0000000..43022d9
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
@@ -0,0 +1,75 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.SafeTypes */
+  Gerrit.SafeTypes = {};
+
+  const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
+
+  /**
+   * Wraps a string to be used as a URL. An error is thrown if the string cannot
+   * be considered safe.
+   * @constructor
+   * @param {string} url the unwrapped, potentially unsafe URL.
+   */
+  Gerrit.SafeTypes.SafeUrl = function(url) {
+    if (!SAFE_URL_PATTERN.test(url)) {
+      throw new Error(`URL not marked as safe: ${url}`);
+    }
+    this._url = url;
+  };
+
+  /**
+   * Get the string representation of the safe URL.
+   * @returns {string}
+   */
+  Gerrit.SafeTypes.SafeUrl.prototype.asString = function() {
+    return this._url;
+  };
+
+  Gerrit.SafeTypes.safeTypesBridge = function(value, type) {
+    // If the value is being bound to a URL, ensure the value is wrapped in the
+    // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
+    // to surface the error.
+    if (type === 'URL') {
+      let safeValue = null;
+      if (value instanceof Gerrit.SafeTypes.SafeUrl) {
+        safeValue = value;
+      } else if (typeof value === 'string') {
+        safeValue = new Gerrit.SafeTypes.SafeUrl(value);
+      }
+      if (safeValue) {
+        return safeValue.asString();
+      }
+    }
+
+    // If the value is being bound to a string or a constant, then the string
+    // can be used as is.
+    if (type === 'STRING' || type === 'CONSTANT') {
+      return value;
+    }
+
+    // Otherwise fail.
+    throw new Error(`Refused to bind value as ${type}: ${value}`);
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
new file mode 100644
index 0000000..5d949a5
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<title>safe-types-behavior</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="safe-types-behavior.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <safe-types-element></safe-types-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-tooltip-behavior tests', () => {
+    let element;
+    let sandbox;
+
+    suiteSetup(() => {
+      Polymer({
+        is: 'safe-types-element',
+        _legacyUndefinedCheck: true,
+        behaviors: [Gerrit.SafeTypes],
+      });
+    });
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('SafeUrl accepts valid urls', () => {
+      function accepts(url) {
+        const safeUrl = new element.SafeUrl(url);
+        assert.isOk(safeUrl);
+        assert.equal(url, safeUrl.asString());
+      }
+      accepts('http://www.google.com/');
+      accepts('https://www.google.com/');
+      accepts('HtTpS://www.google.com/');
+      accepts('//www.google.com/');
+      accepts('/c/1234/file/path.html@45');
+      accepts('#hash-url');
+      accepts('mailto:name@example.com');
+    });
+
+    test('SafeUrl rejects invalid urls', () => {
+      function rejects(url) {
+        assert.throws(() => { new element.SafeUrl(url); });
+      }
+      rejects('javascript://alert("evil");');
+      rejects('ftp:example.com');
+      rejects('data:text/html,scary business');
+    });
+
+    suite('safeTypesBridge', () => {
+      function acceptsString(value, type) {
+        assert.equal(Gerrit.SafeTypes.safeTypesBridge(value, type),
+            value);
+      }
+
+      function rejects(value, type) {
+        assert.throws(() => { Gerrit.SafeTypes.safeTypesBridge(value, type); });
+      }
+
+      test('accepts valid URL strings', () => {
+        acceptsString('/foo/bar', 'URL');
+        acceptsString('#baz', 'URL');
+      });
+
+      test('rejects invalid URL strings', () => {
+        rejects('javascript://void();', 'URL');
+      });
+
+      test('accepts SafeUrl values', () => {
+        const url = '/abc/123';
+        const safeUrl = new element.SafeUrl(url);
+        assert.equal(Gerrit.SafeTypes.safeTypesBridge(safeUrl, 'URL'), url);
+      });
+
+      test('rejects non-string or non-SafeUrl types', () => {
+        rejects(3.1415926, 'URL');
+      });
+
+      test('accepts any binding to STRING or CONSTANT', () => {
+        acceptsString('foo/bar/baz', 'STRING');
+        acceptsString('lorem ipsum dolor', 'CONSTANT');
+      });
+
+      test('rejects all other types', () => {
+        rejects('foo', 'JAVASCRIPT');
+        rejects('foo', 'HTML');
+        rejects('foo', 'RESOURCE_URL');
+        rejects('foo', 'STYLE');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
index 61df877..cddbbf3 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
@@ -15,10 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -45,7 +45,7 @@
       .header,
       #deletedContainer {
         align-items: center;
-        background: #f6f6f6;
+        background: var(--table-header-background-color);
         border-bottom: 1px dotted var(--border-color);
         display: flex;
         justify-content: space-between;
@@ -65,11 +65,11 @@
       #addPermission,
       #deleteBtn,
       .editingRef .name,
-      #editRefInput {
+      .editRefInput {
         display: none;
       }
       .editing #editBtn,
-      .editingRef #editRefInput {
+      .editingRef .editRefInput {
         display: flex;
       }
       .deleted #deletedContainer {
@@ -100,12 +100,18 @@
               <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
             </gr-button>
           </div>
-          <input
-              id="editRefInput"
+          <iron-input
+              class="editRefInput"
               bind-value="{{section.id}}"
-              is="iron-input"
               type="text"
               on-input="_handleValueChange">
+            <input
+                class="editRefInput"
+                bind-value="{{section.id}}"
+                is="iron-input"
+                type="text"
+                on-input="_handleValueChange">
+          </iron-input>
           <gr-button
               link
               id="deleteBtn"
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
index 6fb7b0e..d771448 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
@@ -38,6 +38,7 @@
 
   Polymer({
     is: 'gr-access-section',
+    _legacyUndefinedCheck: true,
 
     properties: {
       capabilities: Object,
@@ -94,7 +95,8 @@
         // For a new section, this is not fired because new permissions and
         // rules have to be added in order to save, modifying the ref is not
         // enough.
-        this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'access-modified', {bubbles: true, composed: true}));
       }
       this.section.value.updatedId = this.section.id;
     },
@@ -198,12 +200,13 @@
 
     _handleRemoveReference() {
       if (this.section.value.added) {
-        this.dispatchEvent(new CustomEvent('added-section-removed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'added-section-removed', {bubbles: true, composed: true}));
       }
       this._deleted = true;
       this.section.value.deleted = true;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleUndoRemove() {
@@ -211,9 +214,15 @@
       delete this.section.value.deleted;
     },
 
+    editRefInput() {
+      return Polymer.dom(this.root).querySelector(Polymer.Element ?
+          'iron-input.editRefInput' :
+          'input[is=iron-input].editRefInput');
+    },
+
     editReference() {
       this._editingRef = true;
-      this.$.editRefInput.focus();
+      this.editRefInput().focus();
     },
 
     _isEditEnabled(canUpload, ownerOf, sectionId) {
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
index 21a426f..5110dc1 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-access-section</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-access-section.html">
 
@@ -455,7 +457,7 @@
               1);
         });
 
-        test('edit section reference', () => {
+        test('edit section reference', done => {
           element.canUpload = true;
           element.ownerOf = [];
           element.section = {id: 'refs/for/bar', value: {permissions: {}}};
@@ -464,14 +466,16 @@
           assert.isTrue(element.$.section.classList.contains('editing'));
           assert.isFalse(element._editingRef);
           MockInteractions.tap(element.$.editBtn);
-          element.$.editRefInput.bindValue='new/ref';
-          flushAsynchronousOperations();
-          assert.equal(element.section.id, 'new/ref');
-          assert.isTrue(element._editingRef);
-          assert.isTrue(element.$.section.classList.contains('editingRef'));
-          element.editing = false;
-          assert.isFalse(element._editingRef);
-          assert.equal(element.section.id, 'refs/for/bar');
+          element.editRefInput().bindValue='new/ref';
+          setTimeout(() => {
+            assert.equal(element.section.id, 'new/ref');
+            assert.isTrue(element._editingRef);
+            assert.isTrue(element.$.section.classList.contains('editingRef'));
+            element.editing = false;
+            assert.isFalse(element._editingRef);
+            assert.equal(element.section.id, 'refs/for/bar');
+            done();
+          });
         });
 
         test('_handleValueChange', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
index 1ad80f5..b2a3105 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
@@ -15,14 +15,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -64,7 +63,7 @@
       </table>
     </gr-list-view>
     <gr-overlay id="createOverlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="createDialog"
           class="confirmDialog"
           disabled="[[!_hasNewGroupName]]"
@@ -81,7 +80,7 @@
               params="[[params]]"
               id="createNewModal"></gr-create-group-dialog>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
index 5a463be..aca691a 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-admin-group-list',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -128,8 +129,16 @@
           });
     },
 
+    _refreshGroupsList() {
+      this.$.restAPI.invalidateGroupsCache();
+      return this._getGroups(this._filter, this._groupsPerPage,
+          this._offset);
+    },
+
     _handleCreateGroup() {
-      this.$.createNewModal.handleCreateGroup();
+      this.$.createNewModal.handleCreateGroup().then(() => {
+        this._refreshGroupsList();
+      });
     },
 
     _handleCloseCreate() {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
index 065a757..58c7be4 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-group-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../test/common-test-setup.html"/>
 
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
index 3c9cdfb..f70025d 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/gr-page-nav-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index 3d430c2..5b728eb 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -22,6 +22,7 @@
 
   Polymer({
     is: 'gr-admin-view',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /** @type {?} */
@@ -197,10 +198,6 @@
       this.reload();
     },
 
-    _computeSelectedTitle(params) {
-      return this.getSelectedTitle(params.view);
-    },
-
     // TODO (beckysiegel): Update these functions after router abstraction is
     // updated. They are currently copied from gr-dropdown (and should be
     // updated there as well once complete).
@@ -227,6 +224,7 @@
      * @param {string=} opt_detailType
      */
     _computeSelectedClass(itemView, params, opt_detailType) {
+      if (!params) return '';
       // Group params are structured differently from admin params. Compute
       // selected differently for groups.
       // TODO(wyatta): Simplify this when all routes work like group params.
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
index 56079e3..178d056 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-admin-view.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
index 0a49016..b7932dd 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="/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-confirm-delete-item-dialog">
@@ -27,7 +27,7 @@
         width: 30em;
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Delete [[_computeItemName(itemType)]]"
         confirm-on-enter
         on-confirm="_handleConfirmTap"
@@ -41,7 +41,7 @@
           [[item]]
         </div>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
   </template>
   <script src="gr-confirm-delete-item-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
index b5dcf63..9c0e405 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
@@ -25,6 +25,7 @@
 
   Polymer({
     is: 'gr-confirm-delete-item-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
index d429d7b..cf9e0fa 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-delete-item-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-delete-item-dialog.html">
 
@@ -50,7 +52,7 @@
       const confirmHandler = sandbox.stub();
       element.addEventListener('confirm', confirmHandler);
       sandbox.stub(element, '_handleConfirmTap');
-      element.$$('gr-confirm-dialog').fire('confirm');
+      element.$$('gr-dialog').fire('confirm');
       assert.isTrue(confirmHandler.called);
       assert.isTrue(element._handleConfirmTap.called);
     });
@@ -59,7 +61,7 @@
       const cancelHandler = sandbox.stub();
       element.addEventListener('cancel', cancelHandler);
       sandbox.stub(element, '_handleCancelTap');
-      element.$$('gr-confirm-dialog').fire('cancel');
+      element.$$('gr-dialog').fire('cancel');
       assert.isTrue(cancelHandler.called);
       assert.isTrue(element._handleCancelTap.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index 85f01e7..518905c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
@@ -32,9 +32,6 @@
   <template>
     <style include="shared-styles"></style>
     <style include="gr-form-styles">
-      :host {
-        display: inline-block;
-      }
       input:not([type="checkbox"]),
       gr-autocomplete,
       iron-autogrow-textarea {
@@ -43,83 +40,89 @@
       .value {
         width: 32em;
       }
-      section {
-        align-items: center;
-        display: flex;
-      }
-      #description {
-        align-items: initial;
-      }
       gr-autocomplete {
         --gr-autocomplete: {
           padding: 0 .15em;
         }
       }
-      .hideBranch {
+      .hide {
         display: none;
       }
+      @media only screen and (max-width: 40em) {
+        .value {
+          width: 29em;
+        }
+      }
     </style>
     <div class="gr-form-styles">
-      <div id="form">
-        <section class$="[[_computeBranchClass(baseChange)]]">
-          <span class="title">Select branch for new change</span>
-          <span class="value">
-            <gr-autocomplete
-                id="branchInput"
-                text="{{branch}}"
-                query="[[_query]]"
-                placeholder="Destination branch">
-            </gr-autocomplete>
-          </span>
-        </section>
-        <section class$="[[_computeBranchClass(baseChange)]]">
-          <span class="title">Provide base commit sha1 for change</span>
-          <span class="value">
+      <section class$="[[_computeBranchClass(baseChange)]]">
+        <span class="title">Select branch for new change</span>
+        <span class="value">
+          <gr-autocomplete
+              id="branchInput"
+              text="{{branch}}"
+              query="[[_query]]"
+              placeholder="Destination branch">
+          </gr-autocomplete>
+        </span>
+      </section>
+      <section class$="[[_computeBranchClass(baseChange)]]">
+        <span class="title">Provide base commit sha1 for change</span>
+        <span class="value">
+          <iron-input
+              maxlength="40"
+              placeholder="(optional)"
+              bind-value="{{baseCommit}}">
             <input
                 is="iron-input"
                 id="baseCommitInput"
                 maxlength="40"
                 placeholder="(optional)"
                 bind-value="{{baseCommit}}">
-          </span>
-        </section>
-        <section>
-          <span class="title">Enter topic for new change</span>
-          <span class="value">
+          </iron-input>
+        </span>
+      </section>
+      <section>
+        <span class="title">Enter topic for new change</span>
+        <span class="value">
+          <iron-input
+              maxlength="1024"
+              placeholder="(optional)"
+              bind-value="{{topic}}">
             <input
                 is="iron-input"
                 id="tagNameInput"
                 maxlength="1024"
                 placeholder="(optional)"
                 bind-value="{{topic}}">
-          </span>
-        </section>
-        <section id="description">
-          <span class="title">Description</span>
-          <span class="value">
-            <iron-autogrow-textarea
-                id="messageInput"
-                class="message"
-                autocomplete="on"
-                rows="4"
-                max-rows="15"
-                bind-value="{{subject}}"
-                placeholder="Insert the description of the change.">
-            </iron-autogrow-textarea>
-          </span>
-        </section>
-        <section>
-          <label
-              class="title"
-              for="privateChangeCheckBox">Private change</label>
-          <span class="value">
-            <input
-                type="checkbox"
-                id="privateChangeCheckBox"
-                checked$="[[_formatBooleanString(privateByDefault)]]">
-          </span>
-        </section>
-      </div>
+          </iron-input>
+        </span>
+      </section>
+      <section id="description">
+        <span class="title">Description</span>
+        <span class="value">
+          <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              autocomplete="on"
+              rows="4"
+              max-rows="15"
+              bind-value="{{subject}}"
+              placeholder="Insert the description of the change.">
+          </iron-autogrow-textarea>
+        </span>
+      </section>
+      <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
+        <label
+            class="title"
+            for="privateChangeCheckBox">Private change</label>
+        <span class="value">
+          <input
+              type="checkbox"
+              id="privateChangeCheckBox"
+              checked$="[[_formatBooleanString(privateByDefault)]]">
+        </span>
+      </section>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index 826a6dc..7eec959 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -22,6 +22,7 @@
 
   Polymer({
     is: 'gr-create-change-dialog',
+    _legacyUndefinedCheck: true,
 
     properties: {
       repoName: String,
@@ -44,6 +45,7 @@
         notify: true,
         value: false,
       },
+      _privateChangesEnabled: Boolean,
     },
 
     behaviors: [
@@ -52,10 +54,23 @@
     ],
 
     attached() {
-      if (!this.repoName) { return; }
-      this.$.restAPI.getProjectConfig(this.repoName).then(config => {
-        this.privateByDefault = config.private_by_default;
-      });
+      if (!this.repoName) { return Promise.resolve(); }
+
+      const promises = [];
+
+      promises.push(this.$.restAPI.getProjectConfig(this.repoName)
+          .then(config => {
+            this.privateByDefault = config.private_by_default;
+          }));
+
+      promises.push(this.$.restAPI.getConfig().then(config => {
+        if (!config) { return; }
+
+        this._privateConfig = config && config.change &&
+            config.change.disable_private_changes;
+      }));
+
+      return Promise.all(promises);
     },
 
     observers: [
@@ -63,7 +78,7 @@
     ],
 
     _computeBranchClass(baseChange) {
-      return baseChange ? 'hideBranch' : '';
+      return baseChange ? 'hide' : '';
     },
 
     _allowCreate(branch, subject) {
@@ -120,5 +135,9 @@
         return false;
       }
     },
+
+    _computePrivateSectionClass(config) {
+      return config ? 'hide' : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
index 08c569c..3a3683f 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-change-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-create-change-dialog.html">
 
@@ -158,5 +160,15 @@
         done();
       });
     });
+
+    test('_computeBranchClass', () => {
+      assert.equal(element._computeBranchClass(true), 'hide');
+      assert.equal(element._computeBranchClass(false), '');
+    });
+
+    test('_computePrivateSectionClass', () => {
+      assert.equal(element._computePrivateSectionClass(true), 'hide');
+      assert.equal(element._computePrivateSectionClass(false), '');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
index 0f12e04..8a4287b 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -39,10 +39,12 @@
       <div id="form">
         <section>
           <span class="title">Group name</span>
-          <input
-              is="iron-input"
-              id="groupNameInput"
+          <iron-input
               bind-value="{{_name}}">
+            <input
+                is="iron-input"
+                bind-value="{{_name}}">
+          </iron-input>
         </section>
       </div>
     </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
index 01aeb43..ec667ee 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-create-group-dialog',
+    _legacyUndefinedCheck: true,
 
     properties: {
       params: Object,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
index 95ffdb1..ebca289 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-group-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-create-group-dialog.html">
 
@@ -51,13 +53,16 @@
       sandbox.restore();
     });
 
-    test('name is updated correctly', () => {
+    test('name is updated correctly', done => {
       assert.isFalse(element.hasNewGroupName);
 
-      element.$.groupNameInput.bindValue = GROUP_NAME;
+      ironInput(element.root).bindValue = GROUP_NAME;
 
-      assert.isTrue(element.hasNewGroupName);
-      assert.deepEqual(element._name, GROUP_NAME);
+      setTimeout(() => {
+        assert.isTrue(element.hasNewGroupName);
+        assert.deepEqual(element._name, GROUP_NAME);
+        done();
+      });
     });
 
     test('test for redirecting to group on successful creation', done => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
index 74c4891..ea5b84b 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -42,29 +42,39 @@
     </style>
     <div class="gr-form-styles">
       <div id="form">
-        <section>
+        <section id="itemNameSection">
           <span class="title">[[detailType]] name</span>
-          <input
-              is="iron-input"
-              id="itemNameInput"
+          <iron-input
               placeholder="[[detailType]] Name"
               bind-value="{{_itemName}}">
+            <input
+                is="iron-input"
+                placeholder="[[detailType]] Name"
+                bind-value="{{_itemName}}">
+          </iron-input>
         </section>
-        <section>
+        <section id="itemRevisionSection">
           <span class="title">Initial Revision</span>
-          <input
-              is="iron-input"
-              id="itemRevisionInput"
+          <iron-input
               placeholder="Revision (Branch or SHA-1)"
               bind-value="{{_itemRevision}}">
+            <input
+                is="iron-input"
+                placeholder="Revision (Branch or SHA-1)"
+                bind-value="{{_itemRevision}}">
+          </iron-input>
         </section>
-        <section class$="[[_computeHideItemClass(itemDetail)]]">
+        <section id="itemAnnotationSection"
+                 class$="[[_computeHideItemClass(itemDetail)]]">
           <span class="title">Annotation</span>
-          <input
-              is="iron-input"
-              id="itemAnnotationInput"
+          <iron-input
               placeholder="Annotation (Optional)"
               bind-value="{{_itemAnnotation}}">
+            <input
+                is="iron-input"
+                placeholder="Annotation (Optional)"
+                bind-value="{{_itemAnnotation}}">
+          </iron-input>
         </section>
       </div>
     </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
index 4e9da90..65bb46d 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
@@ -24,6 +24,7 @@
 
   Polymer({
     is: 'gr-create-pointer-dialog',
+    _legacyUndefinedCheck: true,
 
     properties: {
       detailType: String,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
index 39e200a..08e8213 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-pointer-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-create-pointer-dialog.html">
 
@@ -49,7 +51,7 @@
       sandbox.restore();
     });
 
-    test('branch created', () => {
+    test('branch created', done => {
       sandbox.stub(element.$.restAPI, 'createRepoBranch', () => {
         return Promise.resolve({});
       });
@@ -59,17 +61,18 @@
       element._itemName = 'test-branch';
       element.itemDetail = 'branches';
 
-      element.$.itemNameInput.bindValue = 'test-branch2';
-      element.$.itemRevisionInput.bindValue = 'HEAD';
+      ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
+      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
 
-      assert.isTrue(element.hasNewItemName);
-
-      assert.equal(element._itemName, 'test-branch2');
-
-      assert.equal(element._itemRevision, 'HEAD');
+      setTimeout(() => {
+        assert.isTrue(element.hasNewItemName);
+        assert.equal(element._itemName, 'test-branch2');
+        assert.equal(element._itemRevision, 'HEAD');
+        done();
+      });
     });
 
-    test('tag created', () => {
+    test('tag created', done => {
       sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
         return Promise.resolve({});
       });
@@ -79,17 +82,18 @@
       element._itemName = 'test-tag';
       element.itemDetail = 'tags';
 
-      element.$.itemNameInput.bindValue = 'test-tag2';
-      element.$.itemRevisionInput.bindValue = 'HEAD';
+      ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
 
-      assert.isTrue(element.hasNewItemName);
-
-      assert.equal(element._itemName, 'test-tag2');
-
-      assert.equal(element._itemRevision, 'HEAD');
+      setTimeout(() => {
+        assert.isTrue(element.hasNewItemName);
+        assert.equal(element._itemName, 'test-tag2');
+        assert.equal(element._itemRevision, 'HEAD');
+        done();
+      });
     });
 
-    test('tag created with annotations', () => {
+    test('tag created with annotations', done => {
       sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
         return Promise.resolve({});
       });
@@ -100,17 +104,17 @@
       element._itemAnnotation = 'test-message';
       element.itemDetail = 'tags';
 
-      element.$.itemNameInput.bindValue = 'test-tag2';
-      element.$.itemAnnotationInput.bindValue = 'test-message2';
-      element.$.itemRevisionInput.bindValue = 'HEAD';
+      ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+      ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
+      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
 
-      assert.isTrue(element.hasNewItemName);
-
-      assert.equal(element._itemName, 'test-tag2');
-
-      assert.equal(element._itemAnnotation, 'test-message2');
-
-      assert.equal(element._itemRevision, 'HEAD');
+      setTimeout(() => {
+        assert.isTrue(element.hasNewItemName);
+        assert.equal(element._itemName, 'test-tag2');
+        assert.equal(element._itemAnnotation, 'test-message2');
+        assert.equal(element._itemRevision, 'HEAD');
+        done();
+      });
     });
 
     test('_computeHideItemClass returns hideItem if type is branches', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
index 70dfe52..5c48de1 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
@@ -54,10 +54,13 @@
       <div id="form">
         <section>
           <span class="title">Repository name</span>
-          <input is="iron-input"
-              id="repoNameInput"
-              autocomplete="on"
-              bind-value="{{_repoConfig.name}}">
+          <iron-input autocomplete="on"
+                      bind-value="{{_repoConfig.name}}">
+            <input is="iron-input"
+                   id="repoNameInput"
+                   autocomplete="on"
+                   bind-value="{{_repoConfig.name}}">
+          </iron-input>
         </section>
         <section>
           <span class="title">Rights inherit from</span>
@@ -71,10 +74,21 @@
           </span>
         </section>
         <section>
+          <span class="title">Owner</span>
+          <span class="value">
+            <gr-autocomplete
+                id="ownerInput"
+                text="{{_repoOwner}}"
+                value="{{_repoOwnerId}}"
+                query="[[_queryGroups]]">
+            </gr-autocomplete>
+          </span>
+        </section>
+        <section>
           <span class="title">Create initial empty commit</span>
           <span class="value">
             <gr-select
-                id="initalCommit"
+                id="initialCommit"
                 bind-value="{{_repoConfig.create_empty_commit}}">
               <select>
                 <option value="false">False</option>
@@ -88,7 +102,6 @@
           <span class="value">
             <gr-select
                 id="parentRepo"
-                is="gr-select"
                 bind-value="{{_repoConfig.permissions_only}}">
               <select>
                 <option value="false">False</option>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
index 9dde290..ef7edd4 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-create-repo-dialog',
+    _legacyUndefinedCheck: true,
 
     properties: {
       params: Object,
@@ -43,6 +44,11 @@
         type: Boolean,
         value: false,
       },
+      _repoOwner: String,
+      _repoOwnerId: {
+        type: String,
+        observer: '_repoOwnerIdUpdate',
+      },
 
       _query: {
         type: Function,
@@ -50,6 +56,12 @@
           return this._getRepoSuggestions.bind(this);
         },
       },
+      _queryGroups: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
+        },
+      },
     },
 
     observers: [
@@ -70,6 +82,14 @@
       this.hasNewRepoName = !!name;
     },
 
+    _repoOwnerIdUpdate(id) {
+      if (id) {
+        this.set('_repoConfig.owners', [id]);
+      } else {
+        this.set('_repoConfig.owners', undefined);
+      }
+    },
+
     handleCreateRepo() {
       return this.$.restAPI.createRepo(this._repoConfig)
           .then(repoRegistered => {
@@ -94,5 +114,20 @@
             return repos;
           });
     },
+
+    _getGroupSuggestions(input) {
+      return this.$.restAPI.getSuggestedGroups(input)
+          .then(response => {
+            const groups = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              groups.push({
+                name: key,
+                value: decodeURIComponent(response[key].id),
+              });
+            }
+            return groups;
+          });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
index e70c11a..7e32c5c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-repo-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-create-repo-dialog.html">
 
@@ -50,7 +52,7 @@
     });
 
     test('default values are populated', () => {
-      assert.isTrue(element.$.initalCommit.bindValue);
+      assert.isTrue(element.$.initialCommit.bindValue);
       assert.isFalse(element.$.parentRepo.bindValue);
     });
 
@@ -60,6 +62,7 @@
         create_empty_commit: true,
         parent: 'All-Project',
         permissions_only: false,
+        owners: ['testId'],
       };
 
       const saveStub = sandbox.stub(element.$.restAPI,
@@ -76,9 +79,13 @@
         permissions_only: false,
       };
 
+      element._repoOwner = 'test';
+      element._repoOwnerId = 'testId';
+
       element.$.repoNameInput.bindValue = configInputObj.name;
       element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-      element.$.initalCommit.bindValue =
+      element.$.ownerInput.text = configInputObj.owners[0];
+      element.$.initialCommit.bindValue =
           configInputObj.create_empty_commit;
       element.$.parentRepo.bindValue =
           configInputObj.permissions_only;
@@ -92,5 +99,10 @@
         done();
       });
     });
+
+    test('testing observer of _repoOwner', () => {
+      element._repoOwnerId = 'test-5';
+      assert.deepEqual(element._repoConfig.owners, ['test-5']);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
index eb6a708..08131ab 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
@@ -16,7 +16,7 @@
 -->
 
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
@@ -26,7 +26,13 @@
 <dom-module id="gr-group-audit-log">
   <template>
     <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
+    <style include="gr-table-styles">
+      /* GenericList style centers the last column, but we don't want that here. */
+      .genericList tr th:last-of-type,
+      .genericList tr td:last-of-type {
+        text-align: left;
+      }
+    </style>
     <table id="list" class="genericList">
       <tr class="headerRow">
         <th class="date topHeader">Date</th>
@@ -37,33 +43,34 @@
       <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
         <td>Loading...</td>
       </tr>
-      <template is="dom-repeat" items="[[_auditLog]]"
-          class$="[[computeLoadingClass(_loading)]]">
-        <tr class="table">
-          <td class="date">
-            <gr-date-formatter
-                has-tooltip
-                date-str="[[item.date]]">
-            </gr-date-formatter>
-          </td>
-          <td class="type">[[itemType(item.type)]]</td>
-          <td class="member">
-            <template is="dom-if" if="[[_isGroupEvent(item.type)]]">
-              <a href$="[[_computeGroupUrl(item.member)]]">
-                [[_getNameForGroup(item.member)]]
-              </a>
-            </template>
-            <template is="dom-if" if="[[!_isGroupEvent(item.type)]]">
-              <gr-account-link account="[[item.member]]"></gr-account-link>
-              [[_getIdForUser(item.member)]]
-            </template>
-          </td>
-          <td class="by-user">
-            <gr-account-link account="[[item.user]]"></gr-account-link>
-            [[_getIdForUser(item.user)]]
-          </td>
-        </tr>
-      </template>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_auditLog]]">
+          <tr class="table">
+            <td class="date">
+              <gr-date-formatter
+                  has-tooltip
+                  date-str="[[item.date]]">
+              </gr-date-formatter>
+            </td>
+            <td class="type">[[itemType(item.type)]]</td>
+            <td class="member">
+              <template is="dom-if" if="[[_isGroupEvent(item.type)]]">
+                <a href$="[[_computeGroupUrl(item.member)]]">
+                  [[_getNameForGroup(item.member)]]
+                </a>
+              </template>
+              <template is="dom-if" if="[[!_isGroupEvent(item.type)]]">
+                <gr-account-link account="[[item.member]]"></gr-account-link>
+                [[_getIdForUser(item.member)]]
+              </template>
+            </td>
+            <td class="by-user">
+              <gr-account-link account="[[item.user]]"></gr-account-link>
+              [[_getIdForUser(item.user)]]
+            </td>
+          </tr>
+        </template>
+      </tbody>
     </table>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
index bc6a5d0..966f3c9 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
@@ -21,6 +21,7 @@
 
   Polymer({
     is: 'gr-group-audit-log',
+    _legacyUndefinedCheck: true,
 
     properties: {
       groupId: String,
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
index 59a665b..313d465 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-audit-log</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-group-audit-log.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
index fd5bfd1..52cbfb3 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
@@ -16,10 +16,9 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
@@ -57,7 +56,7 @@
       }
       th {
         border-bottom: 1px solid var(--border-color);
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         text-align: left;
       }
       .canModify #groupMemberSearchInput,
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
index c698617..8a262b8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -25,6 +25,7 @@
 
   Polymer({
     is: 'gr-group-members',
+    _legacyUndefinedCheck: true,
 
     properties: {
       groupId: Number,
@@ -199,11 +200,12 @@
 
     _handleSavingIncludedGroups() {
       return this.$.restAPI.saveIncludedGroup(this._groupName,
-          this._includedGroupSearchId, err => {
+          this._includedGroupSearchId.replace(/\+/g, ' '), err => {
             if (err.status === 404) {
               this.dispatchEvent(new CustomEvent('show-alert', {
                 detail: {message: SAVING_ERROR_TEXT},
                 bubbles: true,
+                composed: true,
               }));
               return err;
             }
@@ -223,7 +225,7 @@
     },
 
     _handleDeleteIncludedGroup(e) {
-      const id = decodeURIComponent(e.model.get('item.id'));
+      const id = decodeURIComponent(e.model.get('item.id')).replace(/\+/g, ' ');
       const name = e.model.get('item.name');
       const item = name || id;
       if (!item) { return ''; }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
index 81123e7..15a59c8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-members</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-group-members.html">
 
@@ -233,9 +235,10 @@
       const memberName = 'bad-name';
       const alertStub = sandbox.stub();
       element.addEventListener('show-alert', alertStub);
-
+      const error = new Error('error');
+      error.status = 404;
       sandbox.stub(element.$.restAPI, 'saveGroupMembers',
-          () => Promise.reject({status: 404}));
+          () => Promise.reject(error));
 
       element.$.groupMemberSearchInput.text = memberName;
       element.$.groupMemberSearchInput.value = 1234;
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..a3bbddc 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
@@ -15,15 +15,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-<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">
 
@@ -78,7 +76,9 @@
             <fieldset>
               <span class="value">
                 <gr-autocomplete
+                    id="groupOwnerInput"
                     text="{{_groupConfig.owner}}"
+                    value="{{_groupConfigOwner}}"
                     query="[[_query]]"
                     disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
                 </gr-autocomplete>
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
index 5c02691..095a0f9 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
@@ -32,6 +32,7 @@
 
   Polymer({
     is: 'gr-group',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the group name changes.
@@ -64,6 +65,7 @@
       },
       /** @type {?} */
       _groupConfig: Object,
+      _groupConfigOwner: String,
       _groupName: Object,
       _groupOwner: {
         type: Boolean,
@@ -89,7 +91,7 @@
 
     observers: [
       '_handleConfigName(_groupConfig.name)',
-      '_handleConfigOwner(_groupConfig.owner)',
+      '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
       '_handleConfigDescription(_groupConfig.description)',
       '_handleConfigOptions(_groupConfig.options.visible_to_all)',
     ],
@@ -161,8 +163,12 @@
     },
 
     _handleSaveOwner() {
+      let owner = this._groupConfig.owner;
+      if (this._groupConfigOwner) {
+        owner = decodeURIComponent(this._groupConfigOwner);
+      }
       return this.$.restAPI.saveGroupOwner(this.groupId,
-          this._groupConfig.owner).then(config => {
+          owner).then(config => {
             this._owner = false;
           });
     },
@@ -175,13 +181,10 @@
     },
 
     _handleSaveOptions() {
-      let options;
-      // The value is in string so we have to convert it to a boolean.
-      if (this._groupConfig.options.visible_to_all) {
-        options = {visible_to_all: true};
-      } else if (!this._groupConfig.options.visible_to_all) {
-        options = {visible_to_all: false};
-      }
+      const visible = this._groupConfig.options.visible_to_all;
+
+      const options = {visible_to_all: visible};
+
       return this.$.restAPI.saveGroupOptions(this.groupId,
           options).then(config => {
             this._options = false;
@@ -220,7 +223,7 @@
               if (!response.hasOwnProperty(key)) { continue; }
               groups.push({
                 name: key,
-                value: response[key],
+                value: decodeURIComponent(response[key].id),
               });
             }
             return groups;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
index a0fc71a..1672e85 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-group.html">
 
@@ -108,6 +110,7 @@
       element._groupConfig = {
         name: groupName,
       };
+      element._groupConfigOwner = 'testId';
       element._groupName = groupName;
       element._groupOwner = true;
 
@@ -127,6 +130,8 @@
 
         element.$.groupNameInput.text = groupName2;
 
+        element.$.groupOwnerInput.text = 'testId2';
+
         assert.isFalse(button.hasAttribute('disabled'));
         assert.isTrue(element.$.groupName.classList.contains('edited'));
 
@@ -136,6 +141,13 @@
           assert.equal(element._groupName, groupName2);
           done();
         });
+
+        element._handleSaveOwner().then(() => {
+          assert.isTrue(button.hasAttribute('disabled'));
+          assert.isFalse(element.$.Title.classList.contains('edited'));
+          assert.equal(element._groupConfigOwner, 'testId2');
+          done();
+        });
       });
     });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
index 22f461b..78c30ff 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
@@ -15,9 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -109,6 +109,7 @@
               items="{{_rules}}"
               as="rule">
             <gr-rule-editor
+                has-range="[[_computeHasRange(name)]]"
                 label="[[_label]]"
                 editing="[[editing]]"
                 group-id="[[rule.id]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index 31d371d..64e156b 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -19,6 +19,11 @@
 
   const MAX_AUTOCOMPLETE_RESULTS = 20;
 
+  const RANGE_NAMES = [
+    'QUERY LIMIT',
+    'BATCH CHANGES LIMIT',
+  ];
+
   /**
    * Fired when the permission has been modified or removed.
    *
@@ -131,17 +136,19 @@
     _handleValueChange() {
       this.permission.value.modified = true;
       // Allows overall access page to know a change has been made.
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleRemovePermission() {
       if (this.permission.value.added) {
-        this.dispatchEvent(new CustomEvent('added-permission-removed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'added-permission-removed', {bubbles: true, composed: true}));
       }
       this._deleted = true;
       this.permission.value.deleted = true;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleRulesChanged(changeRecord) {
@@ -170,7 +177,8 @@
     },
 
     _computeLabel(permission, labels) {
-      if (!permission.value.label) { return; }
+      if (!labels || !permission ||
+          !permission.value || !permission.value.label) { return; }
 
       const labelName = permission.value.label;
 
@@ -244,7 +252,7 @@
     _handleAddRuleItem(e) {
       // The group id is encoded, but have to decode in order for the access
       // API to work as expected.
-      const groupId = decodeURIComponent(e.detail.value.id);
+      const groupId = decodeURIComponent(e.detail.value.id).replace(/\+/g, ' ');
       this.set(['permission', 'value', 'rules', groupId], {});
 
       // Purposely don't recompute sorted array so that the newly added rule
@@ -267,7 +275,14 @@
       const value = this._rules[this._rules.length - 1].value;
       value.added = true;
       this.set(['permission', 'value', 'rules', groupId], value);
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
+    },
+
+    _computeHasRange(name) {
+      if (!name) { return false; }
+
+      return RANGE_NAMES.includes(name.toUpperCase());
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
index e29c4a2..8e57534 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-permission</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-permission.html">
 
@@ -255,6 +257,14 @@
         assert.isFalse(element._deleted);
         assert.isNotOk(element.permission.value.deleted);
       });
+
+      test('_computeHasRange', () => {
+        assert.isTrue(element._computeHasRange('Query Limit'));
+
+        assert.isTrue(element._computeHasRange('Batch Changes Limit'));
+
+        assert.isFalse(element._computeHasRange('test'));
+      });
     });
 
     suite('interactions', () => {
@@ -302,11 +312,11 @@
         element.name = 'Priority';
         element.section = 'refs/*';
         element.groups = {};
-        element.$.groupAutocomplete.text = 'new group name';
+        element.$.groupAutocomplete.text = 'ldap/tests tests';
         const e = {
           detail: {
             value: {
-              id: 'newUserGroupId',
+              id: 'ldap:CN=test+test',
             },
           },
         };
@@ -315,11 +325,11 @@
         assert.equal(Object.keys(element._groupsWithRules).length, 2);
         element._handleAddRuleItem(e);
         flushAsynchronousOperations();
-        assert.deepEqual(element.groups, {newUserGroupId: {
-          name: 'new group name'}});
+        assert.deepEqual(element.groups, {'ldap:CN=test test': {
+          name: 'ldap/tests tests'}});
         assert.equal(element._rules.length, 3);
         assert.equal(Object.keys(element._groupsWithRules).length, 3);
-        assert.deepEqual(element.permission.value.rules['newUserGroupId'],
+        assert.deepEqual(element.permission.value.rules['ldap:CN=test test'],
             {action: 'ALLOW', min: -2, max: 2, added: true});
         // New rule should be removed if cancel from editing.
         element.editing = false;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
new file mode 100644
index 0000000..9ef408b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
@@ -0,0 +1,104 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+<dom-module id="gr-plugin-config-array-editor">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      .wrapper {
+        width: 30em;
+      }
+      .existingItems {
+        background: var(--table-header-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: 2px;
+      }
+      gr-button {
+        float: right;
+        margin-left: .5em;
+        width: 4.5em;
+      }
+      .row {
+        align-items: center;
+        display: flex;
+        justify-content: space-between;
+        padding: .5em 0;
+        width: 100%;
+      }
+      .existingItems .row {
+        padding: .5em;
+      }
+      .existingItems .row:not(:first-of-type) {
+        border-top: 1px solid var(--border-color);
+      }
+      input {
+        flex-grow: 1;
+      }
+      .hide {
+        display: none;
+      }
+      .placeholder {
+        color: var(--deemphasized-text-color);
+        padding-top: .75em;
+      }
+    </style>
+    <div class="wrapper gr-form-styles">
+      <template is="dom-if" if="[[pluginOption.info.values.length]]">
+        <div class="existingItems">
+          <template is="dom-repeat" items="[[pluginOption.info.values]]">
+            <div class="row">
+              <span>[[item]]</span>
+              <gr-button
+                  link
+                  disabled$="[[disabled]]"
+                  data-item="[[item]]"
+                  on-tap="_handleDelete">Delete</gr-button>
+            </div>
+          </template>
+        </div>
+      </template>
+      <template is="dom-if" if="[[!pluginOption.info.values.length]]">
+        <div class="row placeholder">None configured.</div>
+      </template>
+      <div class$="row [[_computeShowInputRow(disabled)]]">
+        <iron-input
+            on-keydown="_handleInputKeydown"
+            bind-value="{{_newValue}}">
+          <input
+              is="iron-input"
+              id="input"
+              on-keydown="_handleInputKeydown"
+              bind-value="{{_newValue}}">
+        </iron-input>
+        <gr-button
+            id="addButton"
+            disabled$="[[!_newValue.length]]"
+            link
+            on-tap="_handleAddTap">Add</gr-button>
+      </div>
+    </div>
+  </template>
+  <script src="gr-plugin-config-array-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
new file mode 100644
index 0000000..ab4d286
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
@@ -0,0 +1,90 @@
+/**
+ * @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-plugin-config-array-editor',
+
+    /**
+     * Fired when the plugin config option changes.
+     *
+     * @event plugin-config-option-changed
+     */
+
+    properties: {
+      /** @type {?} */
+      pluginOption: Object,
+      /** @type {Boolean} */
+      disabled: {
+        type: Boolean,
+        computed: '_computeDisabled(pluginOption.*)',
+      },
+      /** @type {?} */
+      _newValue: {
+        type: String,
+        value: '',
+      },
+    },
+
+    _computeDisabled(record) {
+      return !(record && record.base && record.base.info &&
+          record.base.info.editable);
+    },
+
+    _handleAddTap(e) {
+      e.preventDefault();
+      this._handleAdd();
+    },
+
+    _handleInputKeydown(e) {
+      // Enter.
+      if (e.keyCode === 13) {
+        e.preventDefault();
+        this._handleAdd();
+      }
+    },
+
+    _handleAdd() {
+      if (!this._newValue.length) { return; }
+      this._dispatchChanged(
+          this.pluginOption.info.values.concat([this._newValue]));
+      this._newValue = '';
+    },
+
+    _handleDelete(e) {
+      const value = Polymer.dom(e).localTarget.dataItem;
+      this._dispatchChanged(
+          this.pluginOption.info.values.filter(str => str !== value));
+    },
+
+    _dispatchChanged(values) {
+      const {_key, info} = this.pluginOption;
+      const detail = {
+        _key,
+        info: Object.assign(info, {values}, {}),
+        notifyPath: `${_key}.values`,
+      };
+      this.dispatchEvent(
+          new CustomEvent('plugin-config-option-changed', {detail}));
+    },
+
+    _computeShowInputRow(disabled) {
+      return disabled ? 'hide' : '';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
new file mode 100644
index 0000000..39e4ddc
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
@@ -0,0 +1,144 @@
+<!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-plugin-config-array-editor</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-plugin-config-array-editor.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-config-array-editor></gr-plugin-config-array-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-plugin-config-array-editor tests', () => {
+    let element;
+    let sandbox;
+    let dispatchStub;
+
+    const getAll = str => Polymer.dom(element.root).querySelectorAll(str);
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.pluginOption = {
+        _key: 'test-key',
+        info: {
+          values: [],
+        },
+      };
+    });
+
+    teardown(() => sandbox.restore());
+
+    test('_computeShowInputRow', () => {
+      assert.equal(element._computeShowInputRow(true), 'hide');
+      assert.equal(element._computeShowInputRow(false), '');
+    });
+
+    test('_computeDisabled', () => {
+      assert.isTrue(element._computeDisabled({}));
+      assert.isTrue(element._computeDisabled({base: {}}));
+      assert.isTrue(element._computeDisabled({base: {info: {}}}));
+      assert.isTrue(
+          element._computeDisabled({base: {info: {editable: false}}}));
+      assert.isFalse(
+          element._computeDisabled({base: {info: {editable: true}}}));
+    });
+
+    suite('adding', () => {
+      setup(() => {
+        dispatchStub = sandbox.stub(element, '_dispatchChanged');
+      });
+
+      test('with enter', () => {
+        element._newValue = '';
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        flushAsynchronousOperations();
+
+        assert.isFalse(dispatchStub.called);
+        element._newValue = 'test';
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        flushAsynchronousOperations();
+
+        assert.isTrue(dispatchStub.called);
+        assert.equal(dispatchStub.lastCall.args[0], 'test');
+        assert.equal(element._newValue, '');
+      });
+
+      test('with add btn', () => {
+        element._newValue = '';
+        MockInteractions.tap(element.$.addButton);
+        flushAsynchronousOperations();
+
+        assert.isFalse(dispatchStub.called);
+        element._newValue = 'test';
+        MockInteractions.tap(element.$.addButton);
+        flushAsynchronousOperations();
+
+        assert.isTrue(dispatchStub.called);
+        assert.equal(dispatchStub.lastCall.args[0], 'test');
+        assert.equal(element._newValue, '');
+      });
+    });
+
+    test('deleting', () => {
+      dispatchStub = sandbox.stub(element, '_dispatchChanged');
+      element.pluginOption = {info: {values: ['test', 'test2']}};
+      flushAsynchronousOperations();
+
+      const rows = getAll('.existingItems .row');
+      assert.equal(rows.length, 2);
+      const button = rows[0].querySelector('gr-button');
+
+      MockInteractions.tap(button);
+      flushAsynchronousOperations();
+
+      assert.isFalse(dispatchStub.called);
+      element.pluginOption.info.editable = true;
+      element.notifyPath('pluginOption.info.editable');
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(button);
+      flushAsynchronousOperations();
+
+      assert.isTrue(dispatchStub.called);
+      assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+    });
+
+    test('_dispatchChanged', () => {
+      const eventStub = sandbox.stub(element, 'dispatchEvent');
+      element._dispatchChanged(['new-test-value']);
+
+      assert.isTrue(eventStub.called);
+      const {detail} = eventStub.lastCall.args[0];
+      assert.equal(detail._key, 'test-key');
+      assert.deepEqual(detail.info, {values: ['new-test-value']});
+      assert.equal(detail.notifyPath, 'test-key.values');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
index 9e4396a..26f37e1 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
index 6cbc94a..d533f98 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-plugin-list',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /**
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
index 9781cf7..96fff60 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-plugin-list.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
index f718806..40d32d4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 24d0c62..7d47d08 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
@@ -21,7 +21,7 @@
 
   const NOTHING_TO_SAVE = 'No changes to save.';
 
-  const MAX_AUTOCOMPLETE_RESULTS = 20;
+  const MAX_AUTOCOMPLETE_RESULTS = 50;
 
   /**
    * Fired when save is a no-op
@@ -70,6 +70,7 @@
 
   Polymer({
     is: 'gr-repo-access',
+    _legacyUndefinedCheck: true,
 
     properties: {
       repo: {
@@ -133,6 +134,8 @@
      * @return {!Promise}
      */
     _repoChanged(repo) {
+      this._loading = true;
+
       if (!repo) { return Promise.resolve(); }
 
       return this._reload(repo);
@@ -196,11 +199,10 @@
     },
 
     _handleUpdateInheritFrom(e) {
-      const projectId = decodeURIComponent(e.detail.value);
       if (!this._inheritsFrom) {
         this._inheritsFrom = {};
       }
-      this._inheritsFrom.id = projectId;
+      this._inheritsFrom.id = e.detail.value;
       this._inheritsFrom.name = this._inheritFromFilter;
       this._handleAccessModified();
     },
@@ -214,7 +216,7 @@
             for (const key in response) {
               if (!response.hasOwnProperty(key)) { continue; }
               projects.push({
-                name: key,
+                name: response[key].name,
                 value: response[key].id,
               });
             }
@@ -235,7 +237,7 @@
     },
 
     _computeWebLinkClass(weblinks) {
-      return weblinks.length ? 'show' : '';
+      return weblinks && weblinks.length ? 'show' : '';
     },
 
     _computeShowInherit(inheritsFrom) {
@@ -361,18 +363,24 @@
         remove: {},
       };
 
+      const originalInheritsFromId = this._originalInheritsFrom ?
+          this.singleDecodeURL(this._originalInheritsFrom.id) :
+          null;
+      const inheritsFromId = this._inheritsFrom ?
+          this.singleDecodeURL(this._inheritsFrom.id) :
+          null;
+
       const inheritFromChanged =
           // Inherit from changed
-          (this._originalInheritsFrom &&
-          this._originalInheritsFrom.id !== this._inheritsFrom.id) ||
-          // Inherit froma dded (did not have one initially);
-          (!this._originalInheritsFrom && this._inheritsFrom
-              && this._inheritsFrom.id);
+          (originalInheritsFromId
+              && originalInheritsFromId !== inheritsFromId) ||
+          // Inherit from added (did not have one initially);
+          (!originalInheritsFromId && inheritsFromId);
 
       this._recursivelyUpdateAddRemoveObj(this._local, addRemoveObj);
 
       if (inheritFromChanged) {
-        addRemoveObj.parent = this._inheritsFrom.id;
+        addRemoveObj.parent = inheritsFromId;
       }
       return addRemoveObj;
     },
@@ -398,8 +406,11 @@
       if (!Object.keys(addRemoveObj.add).length &&
           !Object.keys(addRemoveObj.remove).length &&
           !addRemoveObj.parent) {
-        this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message: NOTHING_TO_SAVE}, bubbles: true}));
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {message: NOTHING_TO_SAVE},
+          bubbles: true,
+          composed: true,
+        }));
         return;
       }
       const obj = {
@@ -435,12 +446,12 @@
     },
 
     _computeSaveBtnClass(ownerOf) {
-      return ownerOf.length < 0 ? 'invisible' : '';
+      return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
     },
 
     _computeMainClass(ownerOf, canUpload, editing) {
       const classList = [];
-      if (ownerOf.length > 0 || canUpload) {
+      if (ownerOf && ownerOf.length > 0 || canUpload) {
         classList.push('admin');
       }
       if (editing) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
index e5b0a25..abd11d6 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-access</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-repo-access.html">
 
@@ -238,6 +240,14 @@
           'none');
     });
 
+    test('_handleUpdateInheritFrom', () => {
+      element._inheritFromFilter = 'foo bar baz';
+      element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
+      assert.isOk(element._inheritsFrom);
+      assert.equal(element._inheritsFrom.id, 'abc+123');
+      assert.equal(element._inheritsFrom.name, 'foo bar baz');
+    });
+
     test('_computeLoadingClass', () => {
       assert.equal(element._computeLoadingClass(true), 'loading');
       assert.equal(element._computeLoadingClass(false), '');
@@ -422,6 +432,14 @@
         });
       });
 
+      test('_handleSaveForReview new parent with spaces', () => {
+        element._inheritsFrom = {id: 'spaces+in+project+name'};
+        element._originalInheritsFrom = {id: 'old-project'};
+        assert.deepEqual(element._computeAddAndRemove(), {
+          parent: 'spaces in project name', add: {}, remove: {},
+        });
+      });
+
       test('_handleSaveForReview rules', () => {
         // Delete a rule.
         element._local['refs/*'].permissions.owner.rules[123].deleted = true;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
index f42652d..49ff186 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
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.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..04d9781 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
@@ -19,9 +19,12 @@
 
   Polymer({
     is: 'gr-repo-command',
+    _legacyUndefinedCheck: true,
 
     properties: {
       title: String,
+      disabled: Boolean,
+      tooltip: String,
     },
 
     /**
@@ -31,7 +34,8 @@
      */
 
     _onCommandTap() {
-      this.dispatchEvent(new CustomEvent('command-tap', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('command-tap', {bubbles: true, composed: true}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
index 9f9ac92..49d8765 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-command</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-repo-command.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
index 510f654..63c2a97 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
@@ -15,15 +15,14 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-create-change-dialog/gr-create-change-dialog.html">
@@ -50,7 +49,8 @@
               on-command-tap="_handleEditRepoConfig">
           </gr-repo-command>
           <gr-repo-command
-              title="Run GC"
+              title="[[_repoConfig.actions.gc.label]]"
+              tooltip="[[_repoConfig.actions.gc.title]]"
               hidden$="[[!_repoConfig.actions.gc.enabled]]"
               on-command-tap="_handleRunningGC">
           </gr-repo-command>
@@ -64,7 +64,7 @@
       </div>
     </main>
     <gr-overlay id="createChangeOverlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="createChangeDialog"
           confirm-label="Create"
           disabled="[[!_canCreate]]"
@@ -79,7 +79,7 @@
               can-create="{{_canCreate}}"
               repo-name="[[repo]]"></gr-create-change-dialog>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
index a25055e..169672a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -28,6 +28,7 @@
 
   Polymer({
     is: 'gr-repo-commands',
+    _legacyUndefinedCheck: true,
 
     properties: {
       params: Object,
@@ -74,8 +75,9 @@
     _handleRunningGC() {
       return this.$.restAPI.runRepoGC(this.repo).then(response => {
         if (response.status === 200) {
-          this.dispatchEvent(new CustomEvent('show-alert',
-              {detail: {message: GC_MESSAGE}, bubbles: true}));
+          this.dispatchEvent(new CustomEvent(
+              'show-alert',
+              {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
         }
       });
     },
@@ -99,8 +101,9 @@
             const message = change ?
                 CREATE_CHANGE_SUCCEEDED_MESSAGE :
                 CREATE_CHANGE_FAILED_MESSAGE;
-            this.dispatchEvent(new CustomEvent('show-alert',
-                {detail: {message}, bubbles: true}));
+            this.dispatchEvent(new CustomEvent(
+                'show-alert',
+                {detail: {message}, bubbles: true, composed: true}));
             if (!change) { return; }
 
             Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getEditUrlForDiff(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
index 76c65e8..2976923 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-commands</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-repo-commands.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
index 36a7d76..6cc0958 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -53,7 +53,7 @@
           </tr>
           <template is="dom-repeat" items="[[item.dashboards]]">
             <tr class="table">
-              <td class="name"><a href$="[[_getUrl(item.project, item.sections)]]">[[item.path]]</a></td>
+              <td class="name"><a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a></td>
               <td class="title">[[item.title]]</td>
               <td class="desc">[[item.description]]</td>
               <td class="inherited">[[_computeInheritedFrom(item.project, item.defining_project)]]</td>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
index c0fc0cb..72ee83d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-repo-dashboards',
+    _legacyUndefinedCheck: true,
 
     properties: {
       repo: {
@@ -43,24 +44,24 @@
       this.$.restAPI.getRepoDashboards(this.repo, errFn).then(res => {
         if (!res) { return Promise.resolve(); }
 
-        // Flatten 2 dimenional array, and sort by id.
+        // Group by ref and sort by id.
         const dashboards = res.concat.apply([], res).sort((a, b) =>
-            a.id > b.id);
-        const customList = dashboards.filter(a => a.ref === 'custom');
-        const defaultList = dashboards.filter(a => a.ref === 'default');
+            a.id < b.id ? -1 : 1);
+        const dashboardsByRef = {};
+        dashboards.forEach(d => {
+          if (!dashboardsByRef[d.ref]) {
+            dashboardsByRef[d.ref] = [];
+          }
+          dashboardsByRef[d.ref].push(d);
+        });
+
         const dashboardBuilder = [];
-        if (customList.length) {
+        Object.keys(dashboardsByRef).sort().forEach(ref => {
           dashboardBuilder.push({
-            section: 'Custom',
-            dashboards: customList,
+            section: ref,
+            dashboards: dashboardsByRef[ref],
           });
-        }
-        if (defaultList.length) {
-          dashboardBuilder.push({
-            section: 'Default',
-            dashboards: defaultList,
-          });
-        }
+        });
 
         this._dashboards = dashboardBuilder;
         this._loading = false;
@@ -68,10 +69,10 @@
       });
     },
 
-    _getUrl(project, sections) {
-      if (!project || !sections) { return ''; }
+    _getUrl(project, id) {
+      if (!project || !id) { return ''; }
 
-      return Gerrit.Nav.getUrlForCustomDashboard(project, sections);
+      return Gerrit.Nav.getUrlForRepoDashboard(project, id);
     },
 
     _computeLoadingClass(loading) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
index 4d86a0c..4f76983 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-dashboards</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-repo-dashboards.html">
 
@@ -46,54 +48,61 @@
       sandbox.restore();
     });
 
-    suite('with default only', () => {
+    suite('dashboard table', () => {
       setup(() => {
         sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
             Promise.resolve([
-              [
-                {
-                  id: 'default:contributor',
-                  project: 'gerrit',
-                  defining_project: 'gerrit',
-                  ref: 'default',
-                  path: 'contributor',
-                  description: 'Own contributions.',
-                  foreach: 'owner:self',
-                  url: '/dashboard/?params',
-                  title: 'Contributor Dashboard',
-                  sections: [
-                    {
-                      name: 'Mine To Rebase',
-                      query: 'is:open -is:mergeable',
-                    },
-                    {
-                      name: 'My Recently Merged',
-                      query: 'is:merged limit:10',
-                    },
-                  ],
-                },
-              ],
-              [
-                {
-                  id: 'default:open',
-                  project: 'gerrit',
-                  defining_project: 'Public-Projects',
-                  ref: 'default',
-                  path: 'open',
-                  description: 'Recent open changes.',
-                  url: '/dashboard/?params',
-                  title: 'Open Changes',
-                  sections: [
-                    {
-                      name: 'Open Changes',
-                      query: 'status:open project:${project} -age:7w',
-                    },
-                  ],
-                },
-              ],
+              {
+                id: 'default:contributor',
+                project: 'gerrit',
+                defining_project: 'gerrit',
+                ref: 'default',
+                path: 'contributor',
+                description: 'Own contributions.',
+                foreach: 'owner:self',
+                url: '/dashboard/?params',
+                title: 'Contributor Dashboard',
+                sections: [
+                  {
+                    name: 'Mine To Rebase',
+                    query: 'is:open -is:mergeable',
+                  },
+                  {
+                    name: 'My Recently Merged',
+                    query: 'is:merged limit:10',
+                  },
+                ],
+              },
+              {
+                id: 'custom:custom2',
+                project: 'gerrit',
+                defining_project: 'Public-Projects',
+                ref: 'custom',
+                path: 'open',
+                description: 'Recent open changes.',
+                url: '/dashboard/?params',
+                title: 'Open Changes',
+                sections: [
+                  {
+                    name: 'Open Changes',
+                    query: 'status:open project:${project} -age:7w',
+                  },
+                ],
+              },
+              {
+                id: 'default:abc',
+                project: 'gerrit',
+                ref: 'default',
+              },
+              {
+                id: 'custom:custom1',
+                project: 'gerrit',
+                ref: 'custom',
+              },
             ]));
       });
-      test('loading', done => {
+
+      test('loading, sections, and ordering', done => {
         assert.isTrue(element._loading);
         assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
             'none');
@@ -101,143 +110,20 @@
             'none');
         element.repo = 'test';
         flush(() => {
-          assert.equal(element._dashboards.length, 1);
-          assert.equal(element._dashboards[0].section, 'Default');
-          assert.equal(element._dashboards[0].dashboards.length, 2);
           assert.equal(getComputedStyle(element.$.loadingContainer).display,
               'none');
           assert.notEqual(getComputedStyle(element.$.dashboards).display,
               'none');
-          done();
-        });
-      });
 
-      test('dispatched command-tap on button tap', done => {
-        element.repo = 'test';
-        flush(() => {
-          assert.equal(element._dashboards.length, 1);
-          assert.equal(element._dashboards[0].section, 'Default');
-          assert.equal(element._dashboards[0].dashboards.length, 2);
-          done();
-        });
-      });
-    });
-
-    suite('with custom only', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
-            Promise.resolve([
-              [
-                {
-                  id: 'custom:custom1',
-                  project: 'gerrit',
-                  defining_project: 'gerrit',
-                  ref: 'custom',
-                  path: 'contributor',
-                  description: 'Own contributions.',
-                  foreach: 'owner:self',
-                  url: '/dashboard/?params',
-                  title: 'Contributor Dashboard',
-                  sections: [
-                    {
-                      name: 'Mine To Rebase',
-                      query: 'is:open -is:mergeable',
-                    },
-                    {
-                      name: 'My Recently Merged',
-                      query: 'is:merged limit:10',
-                    },
-                  ],
-                },
-              ],
-              [
-                {
-                  id: 'custom:custom2',
-                  project: 'gerrit',
-                  defining_project: 'Public-Projects',
-                  ref: 'custom',
-                  path: 'open',
-                  description: 'Recent open changes.',
-                  url: '/dashboard/?params',
-                  title: 'Open Changes',
-                  sections: [
-                    {
-                      name: 'Open Changes',
-                      query: 'status:open project:${project} -age:7w',
-                    },
-                  ],
-                },
-              ],
-            ]));
-      });
-
-      test('dispatched command-tap on button tap', done => {
-        element.repo = 'test';
-        flush(() => {
-          assert.equal(element._dashboards.length, 1);
-          assert.equal(element._dashboards[0].section, 'Custom');
-          assert.equal(element._dashboards[0].dashboards.length, 2);
-          done();
-        });
-      });
-    });
-
-    suite('with custom and default', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
-            Promise.resolve([
-              [
-                {
-                  id: 'default:contributor',
-                  project: 'gerrit',
-                  defining_project: 'gerrit',
-                  ref: 'default',
-                  path: 'contributor',
-                  description: 'Own contributions.',
-                  foreach: 'owner:self',
-                  url: '/dashboard/?params',
-                  title: 'Contributor Dashboard',
-                  sections: [
-                    {
-                      name: 'Mine To Rebase',
-                      query: 'is:open -is:mergeable',
-                    },
-                    {
-                      name: 'My Recently Merged',
-                      query: 'is:merged limit:10',
-                    },
-                  ],
-                },
-              ],
-              [
-                {
-                  id: 'custom:custom2',
-                  project: 'gerrit',
-                  defining_project: 'Public-Projects',
-                  ref: 'custom',
-                  path: 'open',
-                  description: 'Recent open changes.',
-                  url: '/dashboard/?params',
-                  title: 'Open Changes',
-                  sections: [
-                    {
-                      name: 'Open Changes',
-                      query: 'status:open project:${project} -age:7w',
-                    },
-                  ],
-                },
-              ],
-            ]));
-      });
-
-      test('dispatched command-tap on button tap', done => {
-        element.repo = 'test';
-        flush(() => {
           assert.equal(element._dashboards.length, 2);
-          assert.equal(element._dashboards[0].section, 'Custom');
-          assert.equal(element._dashboards[1].section, 'Default');
-          assert.equal(element._dashboards[0].dashboards.length, 1);
-          assert.equal(element._dashboards[1].dashboards.length, 1);
+          assert.equal(element._dashboards[0].section, 'custom');
+          assert.equal(element._dashboards[1].section, 'default');
+
+          const dashboards = element._dashboards[0].dashboards;
+          assert.equal(dashboards.length, 2);
+          assert.equal(dashboards[0].id, 'custom:custom1');
+          assert.equal(dashboards[1].id, 'custom:custom2');
+
           done();
         });
       });
@@ -245,7 +131,7 @@
 
     suite('test url', () => {
       test('_getUrl', () => {
-        sandbox.stub(Gerrit.Nav, 'getUrlForCustomDashboard',
+        sandbox.stub(Gerrit.Nav, 'getUrlForRepoDashboard',
             () => '/r/dashboard/test');
 
         assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
index e9bc674..e284201 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
@@ -16,15 +16,16 @@
 -->
 
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -123,10 +124,13 @@
                       class="editBtn">
                     edit
                   </gr-button>
-                  <input
-                      is=iron-input
+                  <iron-input
                       bind-value="{{_revisedRef}}"
                       class="editItem">
+                    <input
+                        is="iron-input"
+                        bind-value="{{_revisedRef}}">
+                  </iron-input>
                   <gr-button
                       link
                       on-tap="_handleCancelRevision"
@@ -189,7 +193,7 @@
       </gr-overlay>
     </gr-list-view>
     <gr-overlay id="createOverlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="createDialog"
           disabled="[[!_hasNewItemName]]"
           confirm-label="Create"
@@ -206,7 +210,7 @@
               item-detail="[[detailType]]"
               repo-name="[[_repo]]"></gr-create-pointer-dialog>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
index 8512a5d..8b91977 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
@@ -26,6 +26,7 @@
 
   Polymer({
     is: 'gr-repo-detail-list',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -62,7 +63,7 @@
       /**
        * Because  we request one more than the projectsPerPage, _shownProjects
        * maybe one less than _projects.
-       * */
+       */
       _shownItems: {
         type: Array,
         computed: 'computeShownItems(_items)',
@@ -90,7 +91,7 @@
     _determineIfOwner(repo) {
       return this.$.restAPI.getRepoAccess(repo)
           .then(access =>
-                this._isOwner = access && access[repo].is_owner);
+                this._isOwner = access && !!access[repo].is_owner);
     },
 
     _paramsChanged(params) {
@@ -194,6 +195,11 @@
         if (res.status < 400) {
           this._isEditing = false;
           e.model.set('item.revision', ref);
+          // This is needed to refresh _items property with fresh data,
+          // specifically can_delete from the json response.
+          this._getItems(
+              this._filter, this._repo, this._itemsPerPage,
+              this._offset, this.detailType);
         }
       });
     },
@@ -240,10 +246,11 @@
       this.$.overlay.open();
     },
 
-    _computeHideDeleteClass(owner, deleteRef) {
-      if (owner && !deleteRef || owner && deleteRef || deleteRef || owner) {
+    _computeHideDeleteClass(owner, canDelete) {
+      if (canDelete || owner) {
         return 'show';
       }
+
       return '';
     },
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
index 24edadc..d8d4f7c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-detail-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-repo-detail-list.html">
 
@@ -549,5 +551,11 @@
         element._paramsChanged(params);
       });
     });
+
+    test('test _computeHideDeleteClass', () => {
+      assert.deepEqual(element._computeHideDeleteClass(true, false), 'show');
+      assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
+      assert.deepEqual(element._computeHideDeleteClass(false, false), '');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
index 6c1f8fb..52db4c2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
@@ -14,13 +14,12 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -43,6 +42,7 @@
         <tr class="headerRow">
           <th class="name topHeader">Repository Name</th>
           <th class="description topHeader">Repository Description</th>
+          <th class="changesLink topHeader">Changes</th>
           <th class="repositoryBrowser topHeader">Repository Browser</th>
           <th class="readOnly topHeader">Read only</th>
         </tr>
@@ -56,6 +56,7 @@
                 <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
               </td>
               <td class="description">[[item.description]]</td>
+              <td class="changesLink"><a href$="[[_computeChangesLink(item.name)]]">(view all)</a></td>
               <td class="repositoryBrowser">
                 <template is="dom-repeat"
                     items="[[_computeWeblink(item)]]" as="link">
@@ -74,7 +75,7 @@
       </table>
     </gr-list-view>
     <gr-overlay id="createOverlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="createDialog"
           class="confirmDialog"
           disabled="[[!_hasNewRepoName]]"
@@ -90,7 +91,7 @@
               params="[[params]]"
               id="createNewModal"></gr-create-repo-dialog>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
index 5560972..6b46cef 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
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-repo-list',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -102,6 +103,10 @@
       return this.getUrl(this._path + '/', name);
     },
 
+    _computeChangesLink(name) {
+      return Gerrit.Nav.getUrlForProjectChanges(name);
+    },
+
     _getCreateRepoCapability() {
       return this.$.restAPI.getAccount().then(account => {
         if (!account) { return; }
@@ -120,18 +125,21 @@
           .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;
           });
     },
 
+    _refreshReposList() {
+      this.$.restAPI.invalidateReposCache();
+      return this._getRepos(this._filter, this._reposPerPage,
+          this._offset);
+    },
+
     _handleCreateRepo() {
-      this.$.createNewModal.handleCreateRepo();
+      this.$.createNewModal.handleCreateRepo().then(() => {
+        this._refreshReposList();
+      });
     },
 
     _handleCloseCreate() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
index 4bc023f..c77592c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-repo-list.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
new file mode 100644
index 0000000..e17409e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
@@ -0,0 +1,115 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
+
+<link rel="import" href="../../../behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/gr-subpage-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
+<link rel="import" href="../gr-plugin-config-array-editor/gr-plugin-config-array-editor.html">
+
+<dom-module id="gr-repo-plugin-config">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles"></style>
+    <style include="gr-subpage-styles">
+      .inherited {
+        color: var(--deemphasized-text-color);
+        margin-left: .5em;
+      }
+      section.section:not(.ARRAY) .title {
+        align-items: center;
+        display: flex;
+      }
+      section.section.ARRAY .title {
+        padding-top: .75em;
+      }
+    </style>
+    <div class="gr-form-styles">
+      <fieldset>
+        <h4>[[pluginData.name]]</h4>
+        <template is="dom-repeat" items="[[_pluginConfigOptions]]" as="option">
+          <section class$="section [[option.info.type]]">
+            <span class="title">
+              <gr-tooltip-content
+                  has-tooltip="[[option.info.description]]"
+                  show-icon="[[option.info.description]]"
+                  title="[[option.info.description]]">
+                <span>[[option.info.display_name]]</span>
+              </gr-tooltip-content>
+            </span>
+            <span class="value">
+              <template is="dom-if" if="[[_isArray(option.info.type)]]">
+                <gr-plugin-config-array-editor
+                    on-plugin-config-option-changed="_handleArrayChange"
+                    plugin-option="[[option]]"></gr-plugin-config-array-editor>
+              </template>
+              <template is="dom-if" if="[[_isBoolean(option.info.type)]]">
+                <paper-toggle-button
+                    checked="[[_computeChecked(option.info.value)]]"
+                    on-change="_handleBooleanChange"
+                    data-option-key$="[[option._key]]"
+                    disabled$="[[_computeDisabled(option.info.editable)]]"></paper-toggle-button>
+              </template>
+              <template is="dom-if" if="[[_isList(option.info.type)]]">
+                <gr-select
+                    bind-value$="[[option.info.value]]"
+                    on-change="_handleListChange">
+                  <select
+                      data-option-key$="[[option._key]]"
+                      disabled$="[[_computeDisabled(option.info.editable)]]">
+                    <template is="dom-repeat"
+                        items="[[option.info.permitted_values]]"
+                        as="value">
+                      <option value$="[[value]]">[[value]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </template>
+              <template is="dom-if" if="[[_isString(option.info.type)]]">
+                <iron-input
+                    bind-value="[[option.info.value]]"
+                    on-input="_handleStringChange"
+                    data-option-key$="[[option._key]]"
+                    disabled$="[[_computeDisabled(option.info.editable)]]">
+                  <input
+                      is="iron-input"
+                      value="[[option.info.value]]"
+                      on-input="_handleStringChange"
+                      data-option-key$="[[option._key]]"
+                      disabled$="[[_computeDisabled(option.info.editable)]]">
+                </iron-input>
+              </template>
+              <template is="dom-if" if="[[option.info.inherited_value]]">
+                <span class="inherited">
+                  (Inherited: [[option.info.inherited_value]])
+                </span>
+              </template>
+            </span>
+          </section>
+        </template>
+      </fieldset>
+    </div>
+  </template>
+  <script src="gr-repo-plugin-config.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
new file mode 100644
index 0000000..883a4e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-repo-plugin-config',
+
+    /**
+     * Fired when the plugin config changes.
+     *
+     * @event plugin-config-changed
+     */
+
+    properties: {
+      /** @type {?} */
+      pluginData: Object,
+      /** @type {Array} */
+      _pluginConfigOptions: {
+        type: Array,
+        computed: '_computePluginConfigOptions(pluginData.*)',
+      },
+    },
+
+    behaviors: [
+      Gerrit.RepoPluginConfig,
+    ],
+
+    _computePluginConfigOptions(dataRecord) {
+      if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
+        return [];
+      }
+      const {config} = dataRecord.base;
+      return Object.keys(config).map(_key => ({_key, info: config[_key]}));
+    },
+
+    _isArray(type) {
+      return type === this.ENTRY_TYPES.ARRAY;
+    },
+
+    _isBoolean(type) {
+      return type === this.ENTRY_TYPES.BOOLEAN;
+    },
+
+    _isList(type) {
+      return type === this.ENTRY_TYPES.LIST;
+    },
+
+    _isString(type) {
+      // Treat numbers like strings for simplicity.
+      return type === this.ENTRY_TYPES.STRING ||
+          type === this.ENTRY_TYPES.INT ||
+          type === this.ENTRY_TYPES.LONG;
+    },
+
+    _computeDisabled(editable) {
+      return editable === 'false';
+    },
+
+    _computeChecked(value) {
+      return JSON.parse(value);
+    },
+
+    _handleStringChange(e) {
+      const el = Polymer.dom(e).localTarget;
+      const _key = el.getAttribute('data-option-key');
+      const configChangeInfo =
+          this._buildConfigChangeInfo(el.value, _key);
+      this._handleChange(configChangeInfo);
+    },
+
+    _handleListChange(e) {
+      const el = Polymer.dom(e).localTarget;
+      const _key = el.getAttribute('data-option-key');
+      const configChangeInfo =
+          this._buildConfigChangeInfo(el.value, _key);
+      this._handleChange(configChangeInfo);
+    },
+
+    _handleBooleanChange(e) {
+      const el = Polymer.dom(e).localTarget;
+      const _key = el.getAttribute('data-option-key');
+      const configChangeInfo =
+          this._buildConfigChangeInfo(JSON.stringify(el.checked), _key);
+      this._handleChange(configChangeInfo);
+    },
+
+    _buildConfigChangeInfo(value, _key) {
+      const info = this.pluginData.config[_key];
+      info.value = value;
+      return {
+        _key,
+        info,
+        notifyPath: `${_key}.value`,
+      };
+    },
+
+    _handleArrayChange({detail}) {
+      this._handleChange(detail);
+    },
+
+    _handleChange({_key, info, notifyPath}) {
+      const {name, config} = this.pluginData;
+
+      /** @type {Object} */
+      const detail = {
+        name,
+        config: Object.assign(config, {[_key]: info}, {}),
+        notifyPath: `${name}.${notifyPath}`,
+      };
+
+      this.dispatchEvent(new CustomEvent(
+          this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
new file mode 100644
index 0000000..07da7c7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
@@ -0,0 +1,178 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-repo-plugin-config</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-repo-plugin-config.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-plugin-config></gr-repo-plugin-config>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo-plugin-config tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => sandbox.restore());
+
+    test('_computePluginConfigOptions', () => {
+      assert.deepEqual(element._computePluginConfigOptions(), []);
+      assert.deepEqual(element._computePluginConfigOptions({}), []);
+      assert.deepEqual(element._computePluginConfigOptions({base: {}}), []);
+      assert.deepEqual(element._computePluginConfigOptions(
+          {base: {config: {}}}), []);
+      assert.deepEqual(element._computePluginConfigOptions(
+          {base: {config: {testKey: 'testInfo'}}}),
+          [{_key: 'testKey', info: 'testInfo'}]);
+    });
+
+    test('_computeDisabled', () => {
+      assert.isFalse(element._computeDisabled('true'));
+      assert.isTrue(element._computeDisabled('false'));
+    });
+
+    test('_handleChange', () => {
+      const eventStub = sandbox.stub(element, 'dispatchEvent');
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test'}},
+      };
+      element._handleChange({
+        _key: 'plugin',
+        info: {value: 'newTest'},
+        notifyPath: 'plugin.value',
+      });
+
+      assert.isTrue(eventStub.called);
+
+      const {detail} = eventStub.lastCall.args[0];
+      assert.equal(detail.name, 'testName');
+      assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
+      assert.equal(detail.notifyPath, 'testName.plugin.value');
+    });
+
+    suite('option types', () => {
+      let changeStub;
+      let buildStub;
+
+      setup(() => {
+        changeStub = sandbox.stub(element, '_handleChange');
+        buildStub = sandbox.stub(element, '_buildConfigChangeInfo');
+      });
+
+      test('ARRAY type option', () => {
+        element.pluginData = {
+          name: 'testName',
+          config: {plugin: {value: 'test', type: 'ARRAY'}},
+        };
+        flushAsynchronousOperations();
+
+        const editor = element.$$('gr-plugin-config-array-editor');
+        assert.ok(editor);
+        element._handleArrayChange({detail: 'test'});
+        assert.isTrue(changeStub.called);
+        assert.equal(changeStub.lastCall.args[0], 'test');
+      });
+
+      test('BOOLEAN type option', () => {
+        element.pluginData = {
+          name: 'testName',
+          config: {plugin: {value: 'true', type: 'BOOLEAN'}},
+        };
+        flushAsynchronousOperations();
+
+        const toggle = element.$$('paper-toggle-button');
+        assert.ok(toggle);
+        toggle.click();
+        flushAsynchronousOperations();
+
+        assert.isTrue(buildStub.called);
+        assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
+
+        assert.isTrue(changeStub.called);
+      });
+
+      test('INT/LONG/STRING type option', () => {
+        element.pluginData = {
+          name: 'testName',
+          config: {plugin: {value: 'test', type: 'STRING'}},
+        };
+        flushAsynchronousOperations();
+
+        const input = element.$$('input');
+        assert.ok(input);
+        input.value = 'newTest';
+        input.dispatchEvent(new Event('input'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(buildStub.called);
+        assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+        assert.isTrue(changeStub.called);
+      });
+
+      test('LIST type option', () => {
+        const permitted_values = ['test', 'newTest'];
+        element.pluginData = {
+          name: 'testName',
+          config: {plugin: {value: 'test', type: 'LIST', permitted_values}},
+        };
+        flushAsynchronousOperations();
+
+        const select = element.$$('select');
+        assert.ok(select);
+        select.value = 'newTest';
+        select.dispatchEvent(new Event(
+            'change', {bubbles: true, composed: true}));
+        flushAsynchronousOperations();
+
+        assert.isTrue(buildStub.called);
+        assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+        assert.isTrue(changeStub.called);
+      });
+    });
+
+    test('_buildConfigChangeInfo', () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test'}},
+      };
+      const detail = element._buildConfigChangeInfo('newTest', 'plugin');
+      assert.equal(detail._key, 'plugin');
+      assert.deepEqual(detail.info, {value: 'newTest'});
+      assert.equal(detail.notifyPath, 'plugin.value');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
index 704974d..52f143c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
@@ -15,9 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
@@ -26,7 +26,7 @@
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-
+<link rel="import" href="../gr-repo-plugin-config/gr-repo-plugin-config.html">
 
 <dom-module id="gr-repo">
   <template>
@@ -37,7 +37,7 @@
         content: ' *';
       }
       .loading,
-      .hideDownload {
+      .hide {
         display: none;
       }
       #loading.loading {
@@ -46,19 +46,28 @@
       #loading:not(.loading) {
         display: none;
       }
-      .repositorySettings {
+      #options .repositorySettings {
         display: none;
       }
-      .repositorySettings.showConfig {
+      #options .repositorySettings.showConfig {
         display: block;
       }
     </style>
     <style include="gr-form-styles"></style>
     <main class="gr-form-styles read-only">
-      <h1 id="Title">[[repo]]</h1>
+      <style include="shared-styles"></style>
+      <div class="info">
+        <h1 id="Title" class$="name">
+          [[repo]]
+          <hr/>
+        </h1>
+        <div>
+          <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
+        </div>
+      </div>
       <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
       <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <div id="downloadContent" class$="[[_computeDownloadClass(_schemes)]]">
+        <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
           <h2 id="download">Download</h2>
           <fieldset>
             <gr-download-commands
@@ -210,7 +219,7 @@
                   </gr-select>
                 </span>
               </section>
-              <section id="noteDbSettings" class$="repositorySettings [[_computeRepositoriesClass(_noteDbEnabled)]]">
+              <section>
                 <span class="title">
                   Enable adding unregistered users as reviewers and CCs on changes</span>
                 <span class="value">
@@ -243,14 +252,39 @@
                 </span>
               </section>
               <section>
+                <span class="title">
+                  Set new changes to "work in progress" by default</span>
+                <span class="value">
+                  <gr-select
+                      id="setAllNewChangesWorkInProgressByDefaultSelect"
+                      bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
                 <span class="title">Maximum Git object size limit</span>
                 <span class="value">
-                  <input
-                      id="maxGitObjSizeInput"
+                  <iron-input
+                      id="maxGitObjSizeIronInput"
                       bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                      is="iron-input"
                       type="text"
                       disabled$="[[_readOnly]]">
+                    <input
+                        id="maxGitObjSizeInput"
+                        bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
+                        is="iron-input"
+                        type="text"
+                        disabled$="[[_readOnly]]">
+                  </iron-input>
+                  <template is="dom-if" if="[[_repoConfig.max_object_size_limit.value]]">
+                    effective: [[_repoConfig.max_object_size_limit.value]] bytes
+                  </template>
                 </span>
               </section>
               <section>
@@ -318,7 +352,15 @@
                 </span>
               </section>
             </fieldset>
-            <!-- TODO @beckysiegel add plugin config widgets -->
+            <div
+                class$="pluginConfig [[_computeHideClass(_pluginData)]]"
+                on-plugin-config-changed="_handlePluginConfigChanged">
+              <h3>Plugins</h3>
+              <template is="dom-repeat" items="[[_pluginData]]" as="data">
+                <gr-repo-plugin-config
+                    plugin-data="[[data]]"></gr-repo-plugin-config>
+              </template>
+            </div>
             <gr-button
                 on-tap="_handleSaveRepoConfig"
                 disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]">Save changes</gr-button>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
index 94b9e3f..fded936 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -53,6 +53,7 @@
 
   Polymer({
     is: 'gr-repo',
+    _legacyUndefinedCheck: true,
 
     properties: {
       params: Object,
@@ -73,6 +74,11 @@
       },
       /** @type {?} */
       _repoConfig: Object,
+      /** @type {?} */
+      _pluginData: {
+        type: Array,
+        computed: '_computePluginData(_repoConfig.plugin_config.*)',
+      },
       _readOnly: {
         type: Boolean,
         value: true,
@@ -101,10 +107,6 @@
       },
       _selectedScheme: String,
       _schemesObj: Object,
-      _noteDbEnabled: {
-        type: Boolean,
-        value: false,
-      },
     },
 
     observers: [
@@ -117,6 +119,15 @@
       this.fire('title-change', {title: this.repo});
     },
 
+    _computePluginData(configRecord) {
+      if (!configRecord ||
+          !configRecord.base) { return []; }
+
+      const pluginConfig = configRecord.base;
+      return Object.keys(pluginConfig)
+          .map(name => ({name, config: pluginConfig[name]}));
+    },
+
     _loadRepo() {
       if (!this.repo) { return Promise.resolve(); }
 
@@ -162,7 +173,6 @@
         if (!config) { return Promise.resolve(); }
 
         this._schemesObj = config.download.schemes;
-        this._noteDbEnabled = !!config.note_db_enabled;
       }));
 
       return Promise.all(promises);
@@ -172,8 +182,8 @@
       return loading ? 'loading' : '';
     },
 
-    _computeDownloadClass(schemes) {
-      return !schemes || !schemes.length ? 'hideDownload' : '';
+    _computeHideClass(arr) {
+      return !arr || !arr.length ? 'hide' : '';
     },
 
     _loggedInChanged(_loggedIn) {
@@ -245,20 +255,22 @@
       return this.$.restAPI.getLoggedIn();
     },
 
-    _formatRepoConfigForSave(p) {
+    _formatRepoConfigForSave(repoConfig) {
       const configInputObj = {};
-      for (const key in p) {
-        if (p.hasOwnProperty(key)) {
+      for (const key in repoConfig) {
+        if (repoConfig.hasOwnProperty(key)) {
           if (key === 'default_submit_type') {
             // default_submit_type is not in the input type, and the
             // configured value was already copied to submit_type by
             // _loadProject. Omit this property when saving.
             continue;
           }
-          if (typeof p[key] === 'object') {
-            configInputObj[key] = p[key].configured_value;
+          if (key === 'plugin_config') {
+            configInputObj.plugin_config_values = repoConfig[key];
+          } else if (typeof repoConfig[key] === 'object') {
+            configInputObj[key] = repoConfig[key].configured_value;
           } else {
-            configInputObj[key] = p[key];
+            configInputObj[key] = repoConfig[key];
           }
         }
       }
@@ -297,6 +309,9 @@
     },
 
     _computeCommands(repo, schemesObj, _selectedScheme) {
+      if (!schemesObj || !repo || !_selectedScheme) {
+        return [];
+      }
       const commands = [];
       let commandObj;
       if (schemesObj.hasOwnProperty(_selectedScheme)) {
@@ -307,9 +322,9 @@
         commands.push({
           title,
           command: commandObj[title]
-              .replace('${project}', repo)
-              .replace('${project-base-name}',
-              repo.substring(repo.lastIndexOf('/') + 1)),
+              .replace(/\$\{project\}/gi, encodeURI(repo))
+              .replace(/\$\{project-base-name\}/gi,
+              encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
         });
       }
       return commands;
@@ -318,5 +333,14 @@
     _computeRepositoriesClass(config) {
       return config ? 'showConfig': '';
     },
+
+    _computeChangesUrl(name) {
+      return Gerrit.Nav.getUrlForProjectChanges(name);
+    },
+
+    _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
+      this._repoConfig.plugin_config[name] = config;
+      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
index d6d4366..53db6d5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-repo.html">
 
@@ -125,6 +127,29 @@
       sandbox.restore();
     });
 
+    test('_computePluginData', () => {
+      assert.deepEqual(element._computePluginData(), []);
+      assert.deepEqual(element._computePluginData({}), []);
+      assert.deepEqual(element._computePluginData({base: {}}), []);
+      assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
+          [{name: 'plugin', config: 'data'}]);
+    });
+
+    test('_handlePluginConfigChanged', () => {
+      const notifyStub = sandbox.stub(element, 'notifyPath');
+      element._repoConfig = {plugin_config: {}};
+      element._handlePluginConfigChanged({detail: {
+        name: 'test',
+        config: 'data',
+        notifyPath: 'path',
+      }});
+      flushAsynchronousOperations();
+
+      assert.equal(element._repoConfig.plugin_config.test, 'data');
+      assert.equal(notifyStub.lastCall.args[0],
+          '_repoConfig.plugin_config.path');
+    });
+
     test('loading displays before repo config is loaded', () => {
       assert.isTrue(element.$.loading.classList.contains('loading'));
       assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
@@ -136,14 +161,12 @@
     test('download commands visibility', () => {
       element._loading = false;
       flushAsynchronousOperations();
-      assert.isTrue(element.$.downloadContent.classList
-          .contains('hideDownload'));
+      assert.isTrue(element.$.downloadContent.classList.contains('hide'));
       assert.isTrue(getComputedStyle(element.$.downloadContent)
           .display == 'none');
       element._schemesObj = SCHEMES;
       flushAsynchronousOperations();
-      assert.isFalse(element.$.downloadContent.classList
-          .contains('hideDownload'));
+      assert.isFalse(element.$.downloadContent.classList.contains('hide'));
       assert.isFalse(getComputedStyle(element.$.downloadContent)
           .display == 'none');
     });
@@ -293,17 +316,6 @@
       });
 
       test('fields update and save correctly', () => {
-        // test notedb
-        element._noteDbEnabled = false;
-
-        assert.equal(
-            element._computeRepositoriesClass(element._noteDbEnabled), '');
-
-        element._noteDbEnabled = true;
-
-        assert.equal(element._computeRepositoriesClass(
-            element._noteDbEnabled), 'showConfig');
-
         const configInputObj = {
           description: 'new description',
           use_contributor_agreements: 'TRUE',
@@ -352,8 +364,9 @@
               configInputObj.private_by_default;
           element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
               configInputObj.match_author_to_committer_date;
-          element.$.maxGitObjSizeInput.bindValue =
-              configInputObj.max_object_size_limit;
+          const inputElement = Polymer.Element ?
+              element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
+          inputElement.bindValue = configInputObj.max_object_size_limit;
           element.$.contributorAgreementSelect.bindValue =
               configInputObj.use_contributor_agreements;
           element.$.useSignedOffBySelect.bindValue =
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
index febd446..7ba541f 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -71,7 +71,11 @@
         color: var(--deemphasized-text-color);
       }
     </style>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      iron-autogrow-textarea {
+        width: 14em;
+      }
+    </style>
     <div id="mainContainer"
         class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
       <div id="options">
@@ -106,18 +110,34 @@
             </select>
           </gr-select>
         </template>
+        <template is="dom-if" if="[[hasRange]]">
+          <iron-autogrow-textarea
+              id="minInput"
+              class="min"
+              autocomplete="on"
+              placeholder="Min value"
+              bind-value="{{rule.value.min}}"
+              disabled$="[[!editing]]"></iron-autogrow-textarea>
+          <iron-autogrow-textarea
+              id="maxInput"
+              class="max"
+              autocomplete="on"
+              placeholder="Max value"
+              bind-value="{{rule.value.max}}"
+              disabled$="[[!editing]]"></iron-autogrow-textarea>
+        </template>
         <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
           [[groupName]]
         </a>
         <gr-select
             id="force"
-            class$="[[_computeForceClass(permission)]]"
+            class$="[[_computeForceClass(permission, rule.value.action)]]"
             bind-value="{{rule.value.force}}"
             on-change="_handleValueChange">
           <select disabled$="[[!editing]]">
             <template
                 is="dom-repeat"
-                items="[[_computeForceOptions(permission)]]">
+                items="[[_computeForceOptions(permission, rule.value.action)]]">
               <option value="[[item.value]]">[[item.name]]</option>
             </template>
           </select>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index 4af4952..4b01b18 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -33,22 +33,24 @@
     'INTERACTIVE',
   ];
 
-  const DROPDOWN_OPTIONS = [
-    'ALLOW',
-    'DENY',
-    'BLOCK',
-  ];
+  const Action = {
+    ALLOW: 'ALLOW',
+    DENY: 'DENY',
+    BLOCK: 'BLOCK',
+  };
 
-  const FORCE_PUSH_OPTIONS = [
-    {
-      name: 'Block all pushes, block force push only',
-      value: false,
-    },
-    {
-      name: 'Allow fast-forward only push, allow all pushes',
-      value: true,
-    },
-  ];
+  const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
+
+  const ForcePushOptions = {
+    ALLOW: [
+      {name: 'Allow pushing (but not force pushing)', value: false},
+      {name: 'Allow pushing with or without force', value: true},
+    ],
+    BLOCK: [
+      {name: 'Block pushing with or without force', value: false},
+      {name: 'Block force pushing', value: true},
+    ],
+  };
 
   const FORCE_EDIT_OPTIONS = [
     {
@@ -65,6 +67,7 @@
     is: 'gr-rule-editor',
 
     properties: {
+      hasRange: Boolean,
       /** @type {?} */
       label: Object,
       editing: {
@@ -117,13 +120,17 @@
       this._setOriginalRuleValues(rule.value);
     },
 
-    _computeForce(permission) {
-      return this.permissionValues.push.id === permission ||
-          this.permissionValues.editTopicName.id === permission;
+    _computeForce(permission, action) {
+      if (this.permissionValues.push.id === permission &&
+          action !== Action.DENY) {
+        return true;
+      }
+
+      return this.permissionValues.editTopicName.id === permission;
     },
 
-    _computeForceClass(permission) {
-      return this._computeForce(permission) ? 'force' : '';
+    _computeForceClass(permission, action) {
+      return this._computeForce(permission, action) ? 'force' : '';
     },
 
     _computeGroupPath(group) {
@@ -156,9 +163,15 @@
       return classList.join(' ');
     },
 
-    _computeForceOptions(permission) {
+    _computeForceOptions(permission, action) {
       if (permission === this.permissionValues.push.id) {
-        return FORCE_PUSH_OPTIONS;
+        if (action === Action.ALLOW) {
+          return ForcePushOptions.ALLOW;
+        } else if (action === Action.BLOCK) {
+          return ForcePushOptions.BLOCK;
+        } else {
+          return [];
+        }
       } else if (permission === this.permissionValues.editTopicName.id) {
         return FORCE_EDIT_OPTIONS;
       }
@@ -166,6 +179,7 @@
     },
 
     _getDefaultRuleValues(permission, label) {
+      const ruleAction = Action.ALLOW;
       const value = {};
       if (permission === 'priority') {
         value.action = PRIORITY_OPTIONS[0];
@@ -173,16 +187,17 @@
       } else if (label) {
         value.min = label.values[0].value;
         value.max = label.values[label.values.length - 1].value;
-      } else if (this._computeForce(permission)) {
-        value.force = this._computeForceOptions(permission)[0].value;
+      } else if (this._computeForce(permission, ruleAction)) {
+        value.force =
+            this._computeForceOptions(permission, ruleAction)[0].value;
       }
       value.action = DROPDOWN_OPTIONS[0];
       return value;
     },
 
     _setDefaultRuleValues() {
-      this.set('rule.value',
-          this._getDefaultRuleValues(this.permission, this.label));
+      this.set('rule.value', this._getDefaultRuleValues(this.permission,
+          this.label));
     },
 
     _computeOptions(permission) {
@@ -194,12 +209,13 @@
 
     _handleRemoveRule() {
       if (this.rule.value.added) {
-        this.dispatchEvent(new CustomEvent('added-rule-removed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'added-rule-removed', {bubbles: true, composed: true}));
       }
       this._deleted = true;
       this.rule.value.deleted = true;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleUndoRemove() {
@@ -221,7 +237,8 @@
       if (!this._originalRuleValues) { return; }
       this.rule.value.modified = true;
       // Allows overall access page to know a change has been made.
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _setOriginalRuleValues(value) {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
index 5b6f947..17e8c6c 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rule-editor</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-rule-editor.html">
 
@@ -50,16 +52,16 @@
     suite('unit tests', () => {
       test('_computeForce, _computeForceClass, and _computeForceOptions',
           () => {
-            const FORCE_PUSH_OPTIONS = [
-              {
-                name: 'Block all pushes, block force push only',
-                value: false,
-              },
-              {
-                name: 'Allow fast-forward only push, allow all pushes',
-                value: true,
-              },
-            ];
+            const ForcePushOptions = {
+              ALLOW: [
+                {name: 'Allow pushing (but not force pushing)', value: false},
+                {name: 'Allow pushing with or without force', value: true},
+              ],
+              BLOCK: [
+                {name: 'Block pushing with or without force', value: false},
+                {name: 'Block force pushing', value: true},
+              ],
+            };
 
             const FORCE_EDIT_OPTIONS = [
               {
@@ -72,10 +74,26 @@
               },
             ];
             let permission = 'push';
-            assert.isTrue(element._computeForce(permission));
-            assert.equal(element._computeForceClass(permission), 'force');
-            assert.deepEqual(element._computeForceOptions(permission),
-                FORCE_PUSH_OPTIONS);
+            let action = 'ALLOW';
+            assert.isTrue(element._computeForce(permission, action));
+            assert.equal(element._computeForceClass(permission, action),
+                'force');
+            assert.deepEqual(element._computeForceOptions(permission, action),
+                ForcePushOptions.ALLOW);
+
+            action = 'BLOCK';
+            assert.isTrue(element._computeForce(permission, action));
+            assert.equal(element._computeForceClass(permission, action),
+                'force');
+            assert.deepEqual(element._computeForceOptions(permission, action),
+                ForcePushOptions.BLOCK);
+
+            action = 'DENY';
+            assert.isFalse(element._computeForce(permission, action));
+            assert.equal(element._computeForceClass(permission, action), '');
+            assert.equal(
+                element._computeForceOptions(permission, action).length, 0);
+
             permission = 'editTopicName';
             assert.isTrue(element._computeForce(permission));
             assert.equal(element._computeForceClass(permission), 'force');
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index 6ea7cf3..eaba285 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -17,9 +17,9 @@
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
@@ -43,7 +43,7 @@
         background-color: var(--hover-background-color);
       }
       :host([needs-review]) {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       :host([highlight]) {
         background-color: var(--assignee-highlight-color);
@@ -98,10 +98,10 @@
         font-family: var(--monospace-font-family);
       }
       .u-green {
-        color: #388E3C;
+        color: var(--vote-text-color-recommended);
       }
       .u-red {
-        color: #D32F2F;
+        color: var(--vote-text-color-disliked);
       }
       .label.u-green:not(.u-monospace),
       .label.u-red:not(.u-monospace) {
@@ -114,6 +114,9 @@
       .placeholder {
         color: var(--deemphasized-text-color);
       }
+      .cell.label {
+        font-weight: normal;
+      }
       @media only screen and (max-width: 50em) {
         :host {
           display: flex;
@@ -162,25 +165,29 @@
         hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]">
       <template is="dom-if" if="[[change.assignee]]">
         <gr-account-link
+            id="assigneeAccountLink"
             account="[[change.assignee]]"
-            additional-text="[[_computeAccountStatusString(change.owner)]]"></gr-account-link>
+            additional-text="[[_computeAccountStatusString(change.assignee)]]"></gr-account-link>
       </template>
       <template is="dom-if" if="[[!change.assignee]]">
         <span class="placeholder">--</span>
       </template>
     </td>
-    <td class="cell project"
-        hidden$="[[isColumnHidden('Project', visibleChangeTableColumns)]]">
-      <a class="fullProject" href$="[[_computeProjectURL(change.project)]]">
-        [[change.project]]
+    <td class="cell repo"
+        hidden$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]">
+      <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
+        [[_computeRepoDisplay(change)]]
       </a>
-      <a class="truncatedProject" href$="[[_computeProjectURL(change.project)]]">
-        [[_computeTruncatedProject(change.project)]]
+      <a
+          class="truncatedRepo"
+          href$="[[_computeRepoUrl(change)]]"
+          title$="[[_computeRepoDisplay(change)]]">
+        [[_computeRepoDisplay(change, 'true')]]
       </a>
     </td>
     <td class="cell branch"
         hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]">
-      <a href$="[[_computeProjectBranchURL(change)]]">
+      <a href$="[[_computeRepoBranchURL(change)]]">
         [[change.branch]]
       </a>
       <template is="dom-if" if="[[change.topic]]">
@@ -215,6 +222,15 @@
         [[_computeLabelValue(change, labelName)]]
       </td>
     </template>
+    <template is="dom-repeat" items="[[_dynamicCellEndpoints]]"
+      as="pluginEndpointName">
+      <td class="cell endpoint">
+        <gr-endpoint-decorator name$="[[pluginEndpointName]]">
+          <gr-endpoint-param name="change" value="[[change]]">
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </td>
+    </template>
   </template>
   <script src="gr-change-list-item.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 259580b..1f0da2b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -26,6 +26,7 @@
 
   Polymer({
     is: 'gr-change-list-item',
+    _legacyUndefinedCheck: true,
 
     properties: {
       visibleChangeTableColumns: Array,
@@ -52,6 +53,9 @@
         type: String,
         computed: '_computeChangeSize(change)',
       },
+      _dynamicCellEndpoints: {
+        type: Array,
+      },
     },
 
     behaviors: [
@@ -62,6 +66,13 @@
       Gerrit.URLEncodingBehavior,
     ],
 
+    attached() {
+      Gerrit.awaitPluginsLoaded().then(() => {
+        this._dynamicCellEndpoints = Gerrit._endpoints.getDynamicEndpoints(
+            'change-list-item-cell');
+      });
+    },
+
     _computeChangeURL(change) {
       return Gerrit.Nav.getUrlForChange(change);
     },
@@ -122,22 +133,36 @@
       return '';
     },
 
-    _computeProjectURL(project) {
-      return Gerrit.Nav.getUrlForProjectChanges(project, true);
+    _computeRepoUrl(change) {
+      return Gerrit.Nav.getUrlForProjectChanges(change.project, true,
+          change.internalHost);
     },
 
-    _computeProjectBranchURL(change) {
-      return Gerrit.Nav.getUrlForBranch(change.branch, change.project);
+    _computeRepoBranchURL(change) {
+      return Gerrit.Nav.getUrlForBranch(change.branch, change.project, null,
+          change.internalHost);
     },
 
     _computeTopicURL(change) {
       if (!change.topic) { return ''; }
-      return Gerrit.Nav.getUrlForTopic(change.topic);
+      return Gerrit.Nav.getUrlForTopic(change.topic, change.internalHost);
     },
 
-    _computeTruncatedProject(project) {
-      if (!project) { return ''; }
-      return this.truncatePath(project, 2);
+    /**
+     * Computes the display string for the project column. If there is a host
+     * specified in the change detail, the string will be prefixed with it.
+     *
+     * @param {!Object} change
+     * @param {string=} truncate whether or not the project name should be
+     *     truncated. If this value is truthy, the name will be truncated.
+     * @return {string}
+     */
+    _computeRepoDisplay(change, truncate) {
+      if (!change || !change.project) { return ''; }
+      let str = '';
+      if (change.internalHost) { str += change.internalHost + '/'; }
+      str += truncate ? this.truncatePath(change.project, 2) : change.project;
+      return str;
     },
 
     _computeAccountStatusString(account) {
@@ -174,5 +199,15 @@
         return 'XL';
       }
     },
+
+    toggleReviewed() {
+      const newVal = !this.change.reviewed;
+      this.set('change.reviewed', newVal);
+      this.dispatchEvent(new CustomEvent('toggle-reviewed', {
+        bubbles: true,
+        composed: true,
+        detail: {change: this.change, reviewed: newVal},
+      }));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index 81e1034..df4a442 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list-item</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
@@ -37,8 +39,10 @@
 <script>
   suite('gr-change-list-item tests', () => {
     let element;
+    let sandbox;
 
     setup(() => {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
         getLoggedIn() { return Promise.resolve(false); },
@@ -46,6 +50,8 @@
       element = fixture('basic');
     });
 
+    teardown(() => { sandbox.restore(); });
+
     test('computed fields', () => {
       assert.equal(element._computeLabelClass({labels: {}}),
           'cell label u-gray-background');
@@ -121,7 +127,7 @@
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
         'Size',
@@ -131,31 +137,13 @@
 
       for (const column of element.columnNames) {
         const elementClass = '.' + column.toLowerCase();
+        assert.isOk(element.$$(elementClass),
+            `Expect ${elementClass} element to be found`);
         assert.isFalse(element.$$(elementClass).hidden);
       }
     });
 
-    test('no hidden columns', () => {
-      element.visibleChangeTableColumns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Project',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-
-      flushAsynchronousOperations();
-
-      for (const column of element.columnNames) {
-        const elementClass = '.' + column.toLowerCase();
-        assert.isFalse(element.$$(elementClass).hidden);
-      }
-    });
-
-    test('project column hidden', () => {
+    test('repo column hidden', () => {
       element.visibleChangeTableColumns = [
         'Subject',
         'Status',
@@ -170,7 +158,7 @@
 
       for (const column of element.columnNames) {
         const elementClass = '.' + column.toLowerCase();
-        if (column === 'Project') {
+        if (column === 'Repo') {
           assert.isTrue(element.$$(elementClass).hidden);
         } else {
           assert.isFalse(element.$$(elementClass).hidden);
@@ -196,10 +184,13 @@
       element.change = {
         assignee: {
           name: 'test',
+          status: 'test',
         },
       };
       flushAsynchronousOperations();
       assert.isOk(element.$$('.assignee gr-account-link'));
+      assert.equal(Polymer.dom(element.root)
+          .querySelector('#assigneeAccountLink').additionalText, '(test)');
     });
 
     test('_computeAccountStatusString', () => {
@@ -249,5 +240,39 @@
         deletions: 999,
       }), 'XL');
     });
+
+    test('change params passed to gr-navigation', () => {
+      sandbox.stub(Gerrit.Nav);
+      const change = {
+        internalHost: 'test-host',
+        project: 'test-repo',
+        topic: 'test-topic',
+        branch: 'test-branch',
+      };
+      element.change = change;
+      flushAsynchronousOperations();
+
+      assert.deepEqual(Gerrit.Nav.getUrlForChange.lastCall.args, [change]);
+      assert.deepEqual(Gerrit.Nav.getUrlForProjectChanges.lastCall.args,
+          [change.project, true, change.internalHost]);
+      assert.deepEqual(Gerrit.Nav.getUrlForBranch.lastCall.args,
+          [change.branch, change.project, null, change.internalHost]);
+      assert.deepEqual(Gerrit.Nav.getUrlForTopic.lastCall.args,
+          [change.topic, change.internalHost]);
+    });
+
+    test('_computeRepoDisplay', () => {
+      const change = {
+        project: 'a/test/repo',
+        internalHost: 'host',
+      };
+      assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
+      assert.equal(element._computeRepoDisplay(change, true),
+          'host/…/test/repo');
+      delete change.internalHost;
+      assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
+      assert.equal(element._computeRepoDisplay(change, true),
+          '…/test/repo');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
index 3a7ff10..1ca5668 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
@@ -16,8 +16,8 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -86,7 +86,9 @@
           changes="{{_changes}}"
           preferences="[[preferences]]"
           selected-index="{{viewState.selectedChangeIndex}}"
-          show-star="[[_loggedIn]]"></gr-change-list>
+          show-star="[[_loggedIn]]"
+          on-toggle-star="_handleToggleStar"
+          on-toggle-reviewed="_handleToggleReviewed"></gr-change-list>
       <nav class$="[[_computeNavClass(_loading)]]">
           Page [[_computePage(_offset, _changesPerPage)]]
           <a id="prevArrow"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index 2a05a2f..d546fe3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -31,6 +31,7 @@
 
   Polymer({
     is: 'gr-change-list-view',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -263,5 +264,15 @@
     _computeLoggedIn(account) {
       return !!(account && Object.keys(account).length > 0);
     },
+
+    _handleToggleStar(e) {
+      this.$.restAPI.saveChangeStarred(e.detail.change._number,
+          e.detail.starred);
+    },
+
+    _handleToggleReviewed(e) {
+      this.$.restAPI.saveChangeReviewed(e.detail.change._number,
+          e.detail.reviewed);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
index 3911364..2367aac 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-list-view.html">
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index 3509f35..6167630 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -17,10 +17,10 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
@@ -52,28 +52,40 @@
             [[_computeLabelShortcut(labelName)]]
           </th>
         </template>
+        <template is="dom-repeat" items="[[_dynamicHeaderEndpoints]]"
+          as="pluginHeader">
+          <th class="endpoint">
+            <gr-endpoint-decorator name$="[[pluginHeader]]">
+            </gr-endpoint-decorator>
+          </th>
+        </template>
       </tr>
       <template is="dom-repeat" items="[[sections]]" as="changeSection"
           index-as="sectionIndex">
-        <template is="dom-if" if="[[changeSection.sectionName]]">
+        <template is="dom-if" if="[[changeSection.name]]">
           <tr class="groupHeader">
             <td class="leftPadding"></td>
             <td class="star" hidden$="[[!showStar]]" hidden></td>
             <td class="cell"
                 colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
               <a href$="[[_sectionHref(changeSection.query)]]">
-                [[changeSection.sectionName]]
+                [[changeSection.name]]
               </a>
             </td>
           </tr>
         </template>
-        <template is="dom-if" if="[[!changeSection.results.length]]">
+        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
           <tr class="noChanges">
             <td class="leftPadding"></td>
             <td class="star" hidden$="[[!showStar]]" hidden></td>
             <td class="cell"
                 colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
-              No changes
+              <template is="dom-if" if="[[_isOutgoing(changeSection)]]">
+                <slot name="empty-outgoing"></slot>
+              </template>
+              <template is="dom-if" if="[[!_isOutgoing(changeSection)]]">
+                No changes
+              </template>
             </td>
           </tr>
         </template>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index dc41f59..57d6f8a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -18,11 +18,13 @@
   'use strict';
 
   const NUMBER_FIXED_COLUMNS = 3;
-
   const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
+  const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+  const MAX_SHORTCUT_CHARS = 5;
 
   Polymer({
     is: 'gr-change-list',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when next page key shortcut was pressed.
@@ -62,7 +64,7 @@
        * properties should not be used together.
        *
        * @type {!Array<{
-       *   sectionName: string,
+       *   name: string,
        *   query: string,
        *   results: !Array<!Object>
        * }>}
@@ -75,6 +77,9 @@
         type: Array,
         computed: '_computeLabelNames(sections)',
       },
+      _dynamicHeaderEndpoints: {
+        type: Array,
+      },
       selectedIndex: {
         type: Number,
         notify: true,
@@ -105,16 +110,6 @@
       Gerrit.URLEncodingBehavior,
     ],
 
-    keyBindings: {
-      'j': '_handleJKey',
-      'k': '_handleKKey',
-      'n ]': '_handleNKey',
-      'o': '_handleOKey',
-      'p [': '_handlePKey',
-      'shift+r': '_handleRKey',
-      's': '_handleSKey',
-    },
-
     listeners: {
       keydown: '_scopedKeydownHandler',
     },
@@ -124,6 +119,26 @@
       '_computePreferences(account, preferences)',
     ],
 
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+        [this.Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+        [this.Shortcut.NEXT_PAGE]: '_nextPage',
+        [this.Shortcut.PREV_PAGE]: '_prevPage',
+        [this.Shortcut.OPEN_CHANGE]: '_openChange',
+        [this.Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+        [this.Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+        [this.Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+      };
+    },
+
+    attached() {
+      Gerrit.awaitPluginsLoaded().then(() => {
+        this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
+            'change-list-header');
+      });
+    },
+
     /**
      * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
      * events must be scoped to a component level (e.g. `enter`) in order to not
@@ -134,7 +149,7 @@
     _scopedKeydownHandler(e) {
       if (e.keyCode === 13) {
         // Enter.
-        this._handleOKey(e);
+        this._openChange(e);
       }
     },
 
@@ -149,7 +164,7 @@
         this.showNumber = !!(preferences &&
             preferences.legacycid_in_change_table);
         this.visibleChangeTableColumns = preferences.change_table.length > 0 ?
-            preferences.change_table : this.columnNames;
+            this.getVisibleColumns(preferences.change_table) : this.columnNames;
       } else {
         // Not logged in.
         this.showNumber = false;
@@ -180,9 +195,15 @@
     },
 
     _computeLabelShortcut(labelName) {
-      return labelName.split('-').reduce((a, i) => {
-        return a + i[0].toUpperCase();
-      }, '');
+      if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+        labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+      }
+      return labelName.split('-')
+          .reduce((a, i) => {
+            if (!i) { return a; }
+            return a + i[0].toUpperCase();
+          }, '')
+          .slice(0, MAX_SHORTCUT_CHARS);
     },
 
     _changesChanged(changes) {
@@ -216,7 +237,8 @@
 
     _computeItemNeedsReview(account, change, showReviewedState) {
       return showReviewedState && !change.reviewed &&
-          this.changeIsOpen(change.status) &&
+          !change.work_in_progress &&
+          this.changeIsOpen(change) &&
           (!account || account._account_id != change.owner._account_id);
     },
 
@@ -230,7 +252,7 @@
       return account._account_id === change.assignee._account_id;
     },
 
-    _handleJKey(e) {
+    _nextChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -238,7 +260,7 @@
       this.$.cursor.next();
     },
 
-    _handleKKey(e) {
+    _prevChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -246,7 +268,7 @@
       this.$.cursor.previous();
     },
 
-    _handleOKey(e) {
+    _openChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -254,7 +276,7 @@
       Gerrit.Nav.navigateToChange(this._changeForIndex(this.selectedIndex));
     },
 
-    _handleNKey(e) {
+    _nextPage(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
         return;
@@ -264,7 +286,7 @@
       this.fire('next-page');
     },
 
-    _handlePKey(e) {
+    _prevPage(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
         return;
@@ -274,7 +296,25 @@
       this.fire('previous-page');
     },
 
-    _handleRKey(e) {
+    _toggleChangeReviewed(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._toggleReviewedForIndex(this.selectedIndex);
+    },
+
+    _toggleReviewedForIndex(index) {
+      const changeEls = this._getListItems();
+      if (index >= changeEls.length || !changeEls[index]) {
+        return;
+      }
+
+      const changeEl = changeEls[index];
+      changeEl.toggleReviewed();
+    },
+
+    _refreshChangeList(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -285,7 +325,7 @@
       window.location.reload();
     },
 
-    _handleSKey(e) {
+    _toggleChangeStar(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -300,10 +340,7 @@
       }
 
       const changeEl = changeEls[index];
-      const change = changeEl.change;
-      const newVal = !change.starred;
-      changeEl.set('change.starred', newVal);
-      this.$.restAPI.saveChangeStarred(change._number, newVal);
+      changeEl.$$('gr-change-star').toggleStar();
     },
 
     _changeForIndex(index) {
@@ -315,7 +352,9 @@
     },
 
     _getListItems() {
-      return Polymer.dom(this.root).querySelectorAll('gr-change-list-item');
+      // Polymer2: querySelectorAll returns NodeList instead of Array.
+      return Array.from(
+          Polymer.dom(this.root).querySelectorAll('gr-change-list-item'));
     },
 
     _sectionsChanged() {
@@ -324,5 +363,13 @@
       this.$.cursor.stops = this._getListItems();
       this.$.cursor.moveToStart();
     },
+
+    _isOutgoing(section) {
+      return !!section.isOutgoing;
+    },
+
+    _isEmpty(section) {
+      return !section.results.length;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index 3dd8c9d..dce2a55 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="gr-change-list.html">
 
@@ -42,6 +44,17 @@
 
 <script>
   suite('gr-change-list basic tests', () => {
+    // Define keybindings before attaching other fixtures.
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+    kb.bindShortcut(kb.Shortcut.CURSOR_PREV_CHANGE, 'k');
+    kb.bindShortcut(kb.Shortcut.OPEN_CHANGE, 'o');
+    kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
+    kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'n');
+    kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'p');
+
     let element;
     let sandbox;
 
@@ -127,7 +140,13 @@
       assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
       assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
       assert.equal(element._computeLabelShortcut(
+          'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
+      assert.equal(element._computeLabelShortcut(
           'Some-Special-Label-7'), 'SSL7');
+      assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
+          'TMD');
+      assert.equal(element._computeLabelShortcut(
+          'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
     });
 
     test('colspans', () => {
@@ -214,11 +233,17 @@
           status: 'ABANDONED',
           owner: {_account_id: 0},
         },
+        {
+          _number: 4,
+          status: 'NEW',
+          work_in_progress: true,
+          owner: {_account_id: 0},
+        },
       ];
       flushAsynchronousOperations();
       let elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
-      assert.equal(elementItems.length, 4);
+      assert.equal(elementItems.length, 5);
       for (let i = 0; i < elementItems.length; i++) {
         assert.isFalse(elementItems[i].hasAttribute('needs-review'));
       }
@@ -226,20 +251,22 @@
       element.showReviewedState = true;
       elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
-      assert.equal(elementItems.length, 4);
+      assert.equal(elementItems.length, 5);
       assert.isFalse(elementItems[0].hasAttribute('needs-review'));
       assert.isTrue(elementItems[1].hasAttribute('needs-review'));
       assert.isFalse(elementItems[2].hasAttribute('needs-review'));
       assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
 
       element.account = {_account_id: 42};
       elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
-      assert.equal(elementItems.length, 4);
+      assert.equal(elementItems.length, 5);
       assert.isFalse(elementItems[0].hasAttribute('needs-review'));
       assert.isTrue(elementItems[1].hasAttribute('needs-review'));
       assert.isFalse(elementItems[2].hasAttribute('needs-review'));
       assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
     });
 
     test('no changes', () => {
@@ -264,6 +291,32 @@
       assert.equal(noChangesMsg.length, 2);
     });
 
+    suite('empty outgoing', () => {
+      test('not shown on empty non-outgoing sections', () => {
+        const section = {results: []};
+        assert.isTrue(element._isEmpty(section));
+        assert.isFalse(element._isOutgoing(section));
+      });
+
+      test('shown on empty outgoing sections', () => {
+        const section = {results: [], isOutgoing: true};
+        assert.isTrue(element._isEmpty(section));
+        assert.isTrue(element._isOutgoing(section));
+      });
+
+      test('not shown on non-empty outgoing sections', () => {
+        const section = {isOutgoing: true, results: [
+          {_number: 0, labels: {Verified: {approved: {}}}}]};
+        assert.isFalse(element._isEmpty(section));
+        assert.isTrue(element._isOutgoing(section));
+      });
+    });
+
+    test('_isOutgoing', () => {
+      assert.isTrue(element._isOutgoing({results: [], isOutgoing: true}));
+      assert.isFalse(element._isOutgoing({results: []}));
+    });
+
     suite('empty column preference', () => {
       let element;
 
@@ -304,7 +357,7 @@
             'Status',
             'Owner',
             'Assignee',
-            'Project',
+            'Repo',
             'Branch',
             'Updated',
             'Size',
@@ -343,10 +396,10 @@
         flushAsynchronousOperations();
       });
 
-      test('all columns except project visible', () => {
+      test('all columns except repo visible', () => {
         for (const column of element.changeTableColumns) {
           const elementClass = '.' + column.toLowerCase();
-          if (column === 'Project') {
+          if (column === 'Repo') {
             assert.isTrue(element.$$(elementClass).hidden);
           } else {
             assert.isFalse(element.$$(elementClass).hidden);
@@ -445,6 +498,14 @@
       MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
       assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
           'Should navigate to /c/4/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+      const change = element._changeForIndex(element.selectedIndex);
+      assert.equal(change.reviewed, true,
+          'Should mark change as reviewed');
+      MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+      assert.equal(change.reviewed, false,
+          'Should mark change as unreviewed');
     });
 
     test('highlight attribute is updated correctly', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
new file mode 100644
index 0000000..11493f4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
@@ -0,0 +1,89 @@
+<!--
+@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-button/gr-button.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
+
+<dom-module id="gr-create-change-help">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+      }
+      #graphic,
+      #help {
+        display: inline-block;
+        margin: .5em;
+      }
+      #graphic #circle {
+        align-items: center;
+        background-color: var(--chip-background-color);
+        border-radius: 50%;
+        display: flex;
+        height: 10em;
+        justify-content: center;
+        width: 10em;
+      }
+      #graphic iron-icon {
+        color: #9e9e9e;
+        height: 5em;
+        width: 5em;
+      }
+      #graphic p {
+        color: var(--deemphasized-text-color);
+        text-align: center;
+      }
+      #help {
+        padding-top: 1.35em;
+        vertical-align: top;
+      }
+      #help h1 {
+        font-size: var(--font-size-large);
+      }
+      #help p {
+        margin-bottom: .6em;
+        max-width: 35em;
+      }
+      @media only screen and (max-width: 50em) {
+        #graphic {
+          display: none;
+        }
+      }
+    </style>
+    <div id="graphic">
+      <div id="circle">
+        <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
+      </div>
+      <p>
+        No outgoing changes yet
+      </p>
+    </div>
+    <div id="help">
+      <h1>Push your first change for code review</h1>
+      <p>
+        Pushing a change for review is easy, but a little different from
+        other git code review tools. Click on the `Create Change' button
+        and follow the step by step instructions.
+      </p>
+      <gr-button on-tap="_handleCreateTap">Create Change</gr-button>
+    </div>
+  </template>
+  <script src="gr-create-change-help.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
new file mode 100644
index 0000000..b9df583b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
@@ -0,0 +1,36 @@
+/**
+ * @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-create-change-help',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the "Create change" button is tapped.
+     *
+     * @event create-tap
+     */
+
+    _handleCreateTap(e) {
+      e.preventDefault();
+      this.dispatchEvent(
+          new CustomEvent('create-tap', {bubbles: true, composed: true}));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
new file mode 100644
index 0000000..c43d62a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_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-create-change-help</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-create-change-help.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-create-change-help></gr-create-change-help>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-create-change-help tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('Create change tap', done => {
+      element.addEventListener('create-tap', () => done());
+      MockInteractions.tap(element.$$('gr-button'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html
new file mode 100644
index 0000000..4cef6f7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html
@@ -0,0 +1,87 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
+
+<dom-module id="gr-create-commands-dialog">
+  <template>
+    <style include="shared-styles">
+      ol {
+        list-style: decimal;
+        margin-left: 1em;
+      }
+      p {
+        margin-bottom: .75em;
+      }
+      #commandsDialog {
+        max-width: 40em;
+      }
+    </style>
+    <gr-overlay id="commandsOverlay" with-backdrop>
+      <gr-dialog
+          id="commandsDialog"
+          confirm-label="Done"
+          cancel-label=""
+          confirm-on-enter
+          on-confirm="_handleClose">
+        <div class="header" slot="header">
+          Create change commands
+        </div>
+        <div class="main" slot="main">
+          <ol>
+            <li>
+              <p>
+                Make the changes to the files on your machine
+              </p>
+            </li>
+            <li>
+              <p>
+                If you are making a new commit use
+              </p>
+              <gr-shell-command command="[[_createNewCommitCommand]]"></gr-shell-command>
+              <p>
+                Or to amend an existing commit use
+              </p>
+              <gr-shell-command command="[[_amendExistingCommitCommand]]"></gr-shell-command>
+              <p>
+                Please make sure you add a commit message as it becomes the
+                description for your change.
+              </p>
+            </li>
+            <li>
+              <p>
+                Push the change for code review
+              </p>
+              <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
+            </li>
+            <li>
+              <p>
+                Close this dialog and you should be able to see your recently
+                created change in the 'Outgoing changes' section on the
+                'Your changes' page.
+              </p>
+            </li>
+          </ol>
+        </div>
+      </gr-dialog>
+    </gr-overlay>
+  </template>
+  <script src="gr-create-commands-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
new file mode 100644
index 0000000..e4958c5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
@@ -0,0 +1,59 @@
+/**
+ * @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 Commands = {
+    CREATE: 'git commit',
+    AMEND: 'git commit --amend',
+    PUSH_PREFIX: 'git push origin HEAD:refs/for/',
+  };
+
+  Polymer({
+    is: 'gr-create-commands-dialog',
+    _legacyUndefinedCheck: true,
+    properties: {
+      branch: String,
+      _createNewCommitCommand: {
+        type: String,
+        readonly: true,
+        value: Commands.CREATE,
+      },
+      _amendExistingCommitCommand: {
+        type: String,
+        readonly: true,
+        value: Commands.AMEND,
+      },
+      _pushCommand: {
+        type: String,
+        computed: '_computePushCommand(branch)',
+      },
+    },
+
+    open() {
+      this.$.commandsOverlay.open();
+    },
+
+    _handleClose() {
+      this.$.commandsOverlay.close();
+    },
+
+    _computePushCommand(branch) {
+      return Commands.PUSH_PREFIX + branch;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
new file mode 100644
index 0000000..89ad573
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
@@ -0,0 +1,55 @@
+<!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-create-commands-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-create-commands-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-create-commands-dialog></gr-create-commands-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-create-commands-dialog tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('_computePushCommand', () => {
+      element.branch = 'master';
+      assert.equal(element._pushCommand,
+          'git push origin HEAD:refs/for/master');
+
+      element.branch = 'stable-2.15';
+      assert.equal(element._pushCommand,
+          'git push origin HEAD:refs/for/stable-2.15');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html
new file mode 100644
index 0000000..def5228
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html
@@ -0,0 +1,48 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<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-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-repo-branch-picker/gr-repo-branch-picker.html">
+
+<dom-module id="gr-create-destination-dialog">
+  <template>
+    <style include="shared-styles">
+    </style>
+    <gr-overlay id="createOverlay" with-backdrop>
+      <gr-dialog
+          confirm-label="View commands"
+          on-confirm="_pickerConfirm"
+          on-cancel="_handleClose"
+          disabled="[[!_repoAndBranchSelected]]">
+        <div class="header" slot="header">
+          Create change
+        </div>
+        <div class="main" slot="main">
+          <gr-repo-branch-picker
+              repo="{{_repo}}"
+              branch="{{_branch}}"></gr-repo-branch-picker>
+          <p>
+            If you haven't done so, you will need to clone the repository.
+          </p>
+        </div>
+      </gr-dialog>
+    </gr-overlay>
+  </template>
+  <script src="gr-create-destination-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
new file mode 100644
index 0000000..ed87e9e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
@@ -0,0 +1,59 @@
+/**
+ * @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';
+
+  /**
+   * Fired when a destination has been picked. Event details contain the repo
+   * name and the branch name.
+   *
+   * @event confirm
+   */
+
+  Polymer({
+    is: 'gr-create-destination-dialog',
+    _legacyUndefinedCheck: true,
+    properties: {
+      _repo: String,
+      _branch: String,
+      _repoAndBranchSelected: {
+        type: Boolean,
+        value: false,
+        computed: '_computeRepoAndBranchSelected(_repo, _branch)',
+      },
+    },
+    open() {
+      this._repo = '';
+      this._branch = '';
+      this.$.createOverlay.open();
+    },
+
+    _handleClose() {
+      this.$.createOverlay.close();
+    },
+
+    _pickerConfirm() {
+      this.$.createOverlay.close();
+      const detail = {repo: this._repo, branch: this._branch};
+      this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
+    },
+
+    _computeRepoAndBranchSelected(repo, branch) {
+      return !!(repo && branch);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
index 1935962..4360d5d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -15,12 +15,18 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-create-commands-dialog/gr-create-commands-dialog.html">
+<link rel="import" href="../gr-create-change-help/gr-create-change-help.html">
+<link rel="import" href="../gr-create-destination-dialog/gr-create-destination-dialog.html">
 <link rel="import" href="../gr-user-header/gr-user-header.html">
 
 <dom-module id="gr-dashboard-view">
@@ -37,11 +43,27 @@
       gr-change-list {
         width: 100%;
       }
+      gr-user-header {
+        border-bottom: 1px solid var(--border-color);
+      }
+      .banner {
+        align-items: center;
+        background-color: var(--comment-background-color);
+        border-bottom: 1px solid var(--border-color);
+        display: flex;
+        justify-content: space-between;
+        padding: .25em var(--default-horizontal-margin);
+      }
+      .banner gr-button {
+        --gr-button: {
+          color: var(--primary-text-color);
+        }
+      }
       .hide {
         display: none;
       }
-      gr-user-header {
-        border-bottom: 1px solid var(--border-color);
+      #emptyOutgoing {
+        display: block;
       }
       @media only screen and (max-width: 50em) {
         .loading {
@@ -49,19 +71,61 @@
         }
       }
     </style>
+    <div class$="banner [[_computeBannerClass(_showDraftsBanner)]]">
+      <div>
+        You have draft comments on closed changes.
+        <a href$="[[_computeDraftsLink(_showDraftsBanner)]]" target="_blank">(view all)</a>
+      </div>
+      <div>
+        <gr-button
+            class="delete"
+            link
+            on-tap="_handleOpenDeleteDialog">Delete All</gr-button>
+      </div>
+    </div>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
       <gr-user-header
           user-id="[[params.user]]"
-          class$="[[_computeUserHeaderClass(params.user)]]"></gr-user-header>
+          class$="[[_computeUserHeaderClass(params)]]"></gr-user-header>
       <gr-change-list
           show-star
           show-reviewed-state
           account="[[account]]"
           preferences="[[preferences]]"
           selected-index="{{viewState.selectedChangeIndex}}"
-          sections="[[_results]]"></gr-change-list>
+          sections="[[_results]]"
+          on-toggle-star="_handleToggleStar"
+          on-toggle-reviewed="_handleToggleReviewed">
+        <div id="emptyOutgoing" slot="empty-outgoing">
+          <template is="dom-if" if="[[_showNewUserHelp]]">
+            <gr-create-change-help on-create-tap="createChangeTap"></gr-create-change-help>
+          </template>
+          <template is="dom-if" if="[[!_showNewUserHelp]]">
+            No changes
+          </template>
+        </div>
+      </gr-change-list>
     </div>
+    <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+      <gr-dialog
+          id="confirmDeleteDialog"
+          confirm-label="Delete"
+          on-confirm="_handleConfirmDelete"
+          on-cancel="_closeConfirmDeleteOverlay">
+        <div class="header" slot="header">
+          Delete comments
+        </div>
+        <div class="main" slot="main">
+          Are you sure you want to delete all your draft comments in closed changes? This action
+          cannot be undone.
+        </div>
+      </gr-dialog>
+    </gr-overlay>
+    <gr-create-destination-dialog
+        id="destinationDialog"
+        on-confirm="_handleDestinationConfirm"></gr-create-destination-dialog>
+    <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
   </template>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index f86c98c..608aaf7 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
@@ -18,53 +18,10 @@
   'use strict';
 
   const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
-  const USER_PLACEHOLDER_PATTERN = /\$\{user\}/g;
-
-  // NOTE: These queries are tested in Java. Any changes made to definitions
-  // here require corresponding changes to:
-  // gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
-  const DEFAULT_SECTIONS = [
-    {
-      // 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,
-    },
-    {
-      // Non-WIP open changes owned by viewed user. Filter out changes ignored
-      // by the viewing user.
-      name: 'Outgoing reviews',
-      query: 'is:open owner:${user} -is:wip -is:ignored',
-    },
-    {
-      // Non-WIP open changes not owned by the viewed user, that the viewed user
-      // is associated with (as either a reviewer or the assignee). Changes
-      // ignored by the viewing user are filtered out.
-      name: 'Incoming reviews',
-      query: 'is:open -owner:${user} -is:wip -is:ignored ' +
-          '(reviewer:${user} OR assignee:${user})',
-    },
-    {
-      // Open changes the viewed user is CCed on. Changes ignored by the viewing
-      // user are filtered out.
-      name: 'CCed on',
-      query: 'is:open -is:ignored cc:${user}',
-    },
-    {
-      name: 'Recently closed',
-      // Closed changes where viewed user is owner, reviewer, or assignee.
-      // Changes ignored by the viewing user are filtered out, and so are WIP
-      // 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})',
-      suffixForDashboard: '-age:4w limit:10',
-    },
-  ];
 
   Polymer({
     is: 'gr-dashboard-view',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -81,17 +38,20 @@
       /** @type {{ selectedChangeIndex: number }} */
       viewState: Object,
 
-      /** @type {{ user: string }} */
+      /** @type {{ project: string, user: string }} */
       params: {
         type: Object,
       },
 
-      _results: Array,
-      _sectionMetadata: {
-        type: Array,
-        value() { return DEFAULT_SECTIONS; },
+      createChangeTap: {
+        type: Function,
+        value() {
+          return this._createChangeTap.bind(this);
+        },
       },
 
+      _results: Array,
+
       /**
        * For showing a "loading..." string during ajax requests.
        */
@@ -99,6 +59,16 @@
         type: Boolean,
         value: true,
       },
+
+      _showDraftsBanner: {
+        type: Boolean,
+        value: false,
+      },
+
+      _showNewUserHelp: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     observers: [
@@ -157,22 +127,6 @@
           });
     },
 
-    _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;
-        });
-      return Promise.resolve({title, sections});
-    },
-
     _computeTitle(user) {
       if (!user || user === 'self') {
         return 'My Reviews';
@@ -197,49 +151,141 @@
       // 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 {project, dashboard, title, user, sections} = this.params;
+      const dashboardPromise = project ?
+          this._getProjectDashboard(project, dashboard) :
+          Promise.resolve(Gerrit.Nav.getUserDashboard(
+              user,
+              sections,
+              title || this._computeTitle(user)));
 
-      const dashboardPromise = params.project ?
-          this._getProjectDashboard(params.project, params.dashboard) :
-          this._getUserDashboard(
-              params.user || 'self',
-              params.sections || DEFAULT_SECTIONS,
-              params.title || this._computeTitle(params.user));
+      const checkForNewUser = !project && user === 'self';
+      return dashboardPromise
+          .then(res => this._fetchDashboardChanges(res, checkForNewUser))
+          .then(() => {
+            this._maybeShowDraftsBanner();
+            this.$.reporting.dashboardDisplayed();
+          }).catch(err => {
+            console.warn(err);
+          }).then(() => { this._loading = false; });
+    },
 
-      return dashboardPromise.then(dashboard => {
-        if (!dashboard) {
-          this._loading = false;
-          return;
-        }
-        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,
+    /**
+     * Fetches the changes for each dashboard section and sets this._results
+     * with the response.
+     *
+     * @param {!Object} res
+     * @param {boolean} checkForNewUser
+     * @return {Promise}
+     */
+    _fetchDashboardChanges(res, checkForNewUser) {
+      if (!res) { return Promise.resolve(); }
+
+      const queries = res.sections
+          .map(section => section.suffixForDashboard ?
+              section.query + ' ' + section.suffixForDashboard :
+              section.query);
+
+      if (checkForNewUser) {
+        queries.push('owner:self');
+      }
+
+      return this.$.restAPI.getChanges(null, queries, null, this.options)
+          .then(changes => {
+            if (checkForNewUser) {
+              // Last set of results is not meant for dashboard display.
+              const lastResultSet = changes.pop();
+              this._showNewUserHelp = lastResultSet.length == 0;
+            }
+            this._results = changes.map((results, i) => ({
+              name: res.sections[i].name,
+              query: res.sections[i].query,
               results,
-            };
+              isOutgoing: res.sections[i].isOutgoing,
+            })).filter((section, i) => i < res.sections.length && (
+                !res.sections[i].hideIfEmpty ||
+                section.results.length));
           });
-        });
-      }).then(() => {
-        this.$.reporting.dashboardDisplayed();
-      }).catch(err => {
-        this._loading = false;
-        console.warn(err);
+    },
+
+    _computeUserHeaderClass(params) {
+      if (!params || !!params.project || !params.user
+          || params.user === 'self') {
+        return 'hide';
+      }
+      return '';
+    },
+
+    _handleToggleStar(e) {
+      this.$.restAPI.saveChangeStarred(e.detail.change._number,
+          e.detail.starred);
+    },
+
+    _handleToggleReviewed(e) {
+      this.$.restAPI.saveChangeReviewed(e.detail.change._number,
+          e.detail.reviewed);
+    },
+
+    /**
+     * Banner is shown if a user is on their own dashboard and they have draft
+     * comments on closed changes.
+     */
+    _maybeShowDraftsBanner() {
+      this._showDraftsBanner = false;
+      if (!(this.params.user === 'self')) { return; }
+
+      const draftSection = this._results
+          .find(section => section.query === 'has:draft');
+      if (!draftSection || !draftSection.results.length) { return; }
+
+      const closedChanges = draftSection.results
+          .filter(change => !this.changeIsOpen(change));
+      if (!closedChanges.length) { return; }
+
+      this._showDraftsBanner = true;
+    },
+
+    _computeBannerClass(show) {
+      return show ? '' : 'hide';
+    },
+
+    _handleOpenDeleteDialog() {
+      this.$.confirmDeleteOverlay.open();
+    },
+
+    _handleConfirmDelete() {
+      this.$.confirmDeleteDialog.disabled = true;
+      return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+        this._closeConfirmDeleteOverlay();
+        this._reload();
       });
     },
 
-    _computeUserHeaderClass(userParam) {
-      return userParam === 'self' ? 'hide' : '';
+    _closeConfirmDeleteOverlay() {
+      this.$.confirmDeleteOverlay.close();
+    },
+
+    _computeDraftsLink() {
+      return Gerrit.Nav.getUrlForSearchQuery('has:draft -is:open');
+    },
+
+    _createChangeTap(e) {
+      this.$.destinationDialog.open();
+    },
+
+    _handleDestinationConfirm(e) {
+      this.$.commandsDialog.branch = e.detail.branch;
+      this.$.commandsDialog.open();
     },
   });
 })();
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..f9eb256 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dashboard-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-dashboard-view.html">
 
@@ -47,7 +49,7 @@
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges',
-          () => Promise.resolve([]));
+          (_, qs) => Promise.resolve(qs.map(() => [])));
 
       let resolver;
       paramsChangedPromise = new Promise(resolve => {
@@ -63,6 +65,85 @@
       sandbox.restore();
     });
 
+    suite('drafts banner functionality', () => {
+      suite('_maybeShowDraftsBanner', () => {
+        test('not dashboard/self', () => {
+          element.params = {user: 'notself'};
+          element._maybeShowDraftsBanner();
+          assert.isFalse(element._showDraftsBanner);
+        });
+
+        test('no drafts at all', () => {
+          element.params = {user: 'self'};
+          element._results = [];
+          element._maybeShowDraftsBanner();
+          assert.isFalse(element._showDraftsBanner);
+        });
+
+        test('no drafts on open changes', () => {
+          element.params = {user: 'self'};
+          element._results = [{query: 'has:draft', results: [{status: '_'}]}];
+          sandbox.stub(element, 'changeIsOpen').returns(true);
+          element._maybeShowDraftsBanner();
+          assert.isFalse(element._showDraftsBanner);
+        });
+
+        test('no drafts on open changes', () => {
+          element.params = {user: 'self'};
+          element._results = [{query: 'has:draft', results: [{status: '_'}]}];
+          sandbox.stub(element, 'changeIsOpen').returns(false);
+          element._maybeShowDraftsBanner();
+          assert.isTrue(element._showDraftsBanner);
+        });
+      });
+
+      test('_showDraftsBanner', () => {
+        element._showDraftsBanner = false;
+        flushAsynchronousOperations();
+        assert.isTrue(isHidden(element.$$('.banner')));
+
+        element._showDraftsBanner = true;
+        flushAsynchronousOperations();
+        assert.isFalse(isHidden(element.$$('.banner')));
+      });
+
+      test('delete tap opens dialog', () => {
+        sandbox.stub(element, '_handleOpenDeleteDialog');
+        element._showDraftsBanner = true;
+        flushAsynchronousOperations();
+
+        MockInteractions.tap(element.$$('.banner .delete'));
+        assert.isTrue(element._handleOpenDeleteDialog.called);
+      });
+
+      test('delete comments flow', async () => {
+        sandbox.spy(element, '_handleConfirmDelete');
+        sandbox.stub(element, '_reload');
+
+        // Set up control over timing of when RPC resolves.
+        let deleteDraftCommentsPromiseResolver;
+        const deleteDraftCommentsPromise = new Promise(resolve => {
+          deleteDraftCommentsPromiseResolver = resolve;
+        });
+        sandbox.stub(element.$.restAPI, 'deleteDraftComments')
+            .returns(deleteDraftCommentsPromise);
+
+        // Open confirmation dialog and tap confirm button.
+        await element.$.confirmDeleteOverlay.open();
+        MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.restAPI.deleteDraftComments
+            .calledWithExactly('-is:open'));
+        assert.isTrue(element.$.confirmDeleteDialog.disabled);
+        assert.equal(element._reload.callCount, 0);
+
+        // Verify state after RPC resolves.
+        deleteDraftCommentsPromiseResolver([]);
+        await deleteDraftCommentsPromise;
+        assert.equal(element._reload.callCount, 1);
+      });
+    });
+
     test('_computeTitle', () => {
       assert.equal(element._computeTitle('self'), 'My Reviews');
       assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
@@ -98,12 +179,12 @@
             {query: '1'},
             {query: '2', selfOnly: true},
           ],
-          user: 'user',
+          user: 'self',
         };
         return paramsChangedPromise.then(() => {
           assert.isTrue(
               getChangesStub.calledWith(
-                  null, ['1'], null, element.options));
+                  null, ['1', '2', 'owner:self'], null, element.options));
         });
       });
 
@@ -114,12 +195,12 @@
             {query: '1'},
             {query: '2', selfOnly: true},
           ],
-          user: 'self',
+          user: 'user',
         };
         return paramsChangedPromise.then(() => {
           assert.isTrue(
               getChangesStub.calledWith(
-                  null, ['1', '2'], null, element.options));
+                  null, ['1'], null, element.options));
         });
       });
     });
@@ -189,67 +270,65 @@
       });
     });
 
-    suite('_getUserDashboard', () => {
+    test('hideIfEmpty sections', () => {
       const sections = [
-        {name: 'section 1', query: 'query 1'},
-        {name: 'section 2', query: 'query 2 for ${user}'},
-        {name: 'section 3', query: 'self only query', selfOnly: true},
-        {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
+        {name: 'test1', query: 'test1', hideIfEmpty: true},
+        {name: 'test2', query: 'test2', hideIfEmpty: true},
       ];
+      getChangesStub.restore();
+      sandbox.stub(element.$.restAPI, 'getChanges')
+          .returns(Promise.resolve([[], ['nonempty']]));
 
-      test('dashboard for self', () => {
-        return element._getUserDashboard('self', sections, 'title')
-            .then(dashboard => {
-              assert.deepEqual(
-                  dashboard,
-                  {
-                    title: 'title',
-                    sections: [
-                      {name: 'section 1', query: 'query 1'},
-                      {name: 'section 2', query: 'query 2 for self'},
-                      {name: 'section 3', query: 'self only query'},
-                      {
-                        name: 'section 4',
-                        query: 'query 4',
-                        suffixForDashboard: 'suffix',
-                      },
-                    ],
-                  });
-            });
-      });
-
-      test('dashboard for other user', () => {
-        return element._getUserDashboard('user', sections, 'title')
-            .then(dashboard => {
-              assert.deepEqual(
-                  dashboard,
-                  {
-                    title: 'title',
-                    sections: [
-                      {name: 'section 1', query: 'query 1'},
-                      {name: 'section 2', query: 'query 2 for user'},
-                      {
-                        name: 'section 4',
-                        query: 'query 4',
-                        suffixForDashboard: 'suffix',
-                      },
-                    ],
-                  });
-            });
+      return element._fetchDashboardChanges({sections}, false).then(() => {
+        assert.equal(element._results.length, 1);
+        assert.equal(element._results[0].name, 'test2');
       });
     });
 
+    test('preserve isOutgoing sections', () => {
+      const sections = [
+        {name: 'test1', query: 'test1', isOutgoing: true},
+        {name: 'test2', query: 'test2'},
+      ];
+      getChangesStub.restore();
+      sandbox.stub(element.$.restAPI, 'getChanges')
+          .returns(Promise.resolve([[], []]));
+
+      return element._fetchDashboardChanges({sections}, false).then(() => {
+        assert.equal(element._results.length, 2);
+        assert.isTrue(element._results[0].isOutgoing);
+        assert.isNotOk(element._results[1].isOutgoing);
+      });
+    });
+
+    test('_showNewUserHelp', () => {
+      element._loading = false;
+      element._showNewUserHelp = false;
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+      assert.isNotOk(element.$$('gr-create-change-help'));
+      element._showNewUserHelp = true;
+      flushAsynchronousOperations();
+
+      assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+      assert.isOk(element.$$('gr-create-change-help'));
+    });
+
     test('_computeUserHeaderClass', () => {
-      assert.equal(element._computeUserHeaderClass(undefined), '');
-      assert.equal(element._computeUserHeaderClass(''), '');
-      assert.equal(element._computeUserHeaderClass('self'), 'hide');
-      assert.equal(element._computeUserHeaderClass('user'), '');
+      assert.equal(element._computeUserHeaderClass(undefined), 'hide');
+      assert.equal(element._computeUserHeaderClass({}), 'hide');
+      assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
+      assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+      assert.equal(
+          element._computeUserHeaderClass({project: 'p', user: 'user'}),
+          'hide');
     });
 
     test('404 page', done => {
       const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getDashboard', (project, dashboard, errFn) => {
+      sandbox.stub(element.$.restAPI, 'getDashboard',
+          async (project, dashboard, errFn) => {
             errFn(response);
           });
       element.addEventListener('page-error', e => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html
new file mode 100644
index 0000000..d445185
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html
@@ -0,0 +1,41 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
+<link rel="import" href="../gr-create-change-help/gr-create-change-help.html">
+
+<dom-module id="gr-embed-dashboard">
+  <template>
+    <gr-change-list
+        show-star
+        account="[[account]]"
+        preferences="[[preferences]]"
+        sections="[[sections]]">
+      <div id="emptyOutgoing" slot="empty-outgoing">
+        <template is="dom-if" if="[[showNewUserHelp]]">
+          <gr-create-change-help></gr-create-change-help>
+        </template>
+        <template is="dom-if" if="[[!showNewUserHelp]]">
+          No changes
+        </template>
+      </div>
+    </gr-change-list>
+  </template>
+  <script src="gr-embed-dashboard.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
new file mode 100644
index 0000000..14d0cb0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-embed-dashboard',
+    _legacyUndefinedCheck: true,
+    properties: {
+      account: Object,
+      sections: Array,
+      preferences: Object,
+      showNewUserHelp: Boolean,
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
index 2328725..0b4459c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/dashboard-header-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
index cd6eb77..67fbd97 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-repo-header',
+    _legacyUndefinedCheck: true,
     properties: {
       /** @type {?String} */
       repo: {
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
index a561e09..49af1b4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-header</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-repo-header.html">
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
index 89e2b7d..fed1c12 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
@@ -15,13 +15,15 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/dashboard-header-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-user-header">
   <template>
@@ -62,6 +64,12 @@
             date-str="[[_computeDetail(_accountDetails, 'registered_on')]]">
         </gr-date-formatter>
       </div>
+      <gr-endpoint-decorator name="user-header">
+        <gr-endpoint-param name="accountDetails" value="[[_accountDetails]]">
+        </gr-endpoint-param>
+        <gr-endpoint-param name="loggedIn" value="[[loggedIn]]">
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
     </div>
     <div class="info">
       <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
index cf5fefd..dc945d8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-user-header',
+    _legacyUndefinedCheck: true,
     properties: {
       /** @type {?String} */
       userId: {
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
index c33be3b..e837a5b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-user-header</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-user-header.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
index c94a716..95ef881 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
@@ -16,7 +16,7 @@
 -->
 
 <link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -37,7 +37,6 @@
         threshold="[[suggestFrom]]"
         query="[[query]]"
         allow-non-suggested-values="[[allowAnyInput]]"
-        no-debounce
         on-commit="_handleInputCommit"
         clear-on-commit
         warn-uncommitted
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
index 5cb3b77..147d1f2 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-account-entry',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when an account is entered.
@@ -69,6 +70,8 @@
         type: String,
         observer: '_inputTextChanged',
       },
+
+      _loggedIn: Boolean,
     },
 
     behaviors: [
@@ -79,6 +82,9 @@
       this.$.restAPI.getConfig().then(cfg => {
         this._config = cfg;
       });
+      this.$.restAPI.getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+      });
     },
 
     get focusStart() {
@@ -112,8 +118,8 @@
 
     _inputTextChanged(text) {
       if (text.length && this.allowAnyInput) {
-        this.dispatchEvent(new CustomEvent('account-text-changed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'account-text-changed', {bubbles: true, composed: true}));
       }
     },
 
@@ -121,13 +127,15 @@
       let name;
       let value;
       const generateStatusStr = function(account) {
-        return account.status ? ' (' + account.status + ')' : '';
+        return account.status ? '(' + account.status + ')' : '';
       };
       if (reviewer.account) {
         // Reviewer is an account suggestion from getChangeSuggestedReviewers.
         const reviewerName = this._accountOrAnon(reviewer.account);
-        name = reviewerName + ' <' + reviewer.account.email + '>' +
-            generateStatusStr(reviewer.account);
+        const reviewerEmail = this._reviewerEmail(reviewer.account.email);
+        const reviewerStatus = generateStatusStr(reviewer.account);
+        name = [reviewerName, reviewerEmail, reviewerStatus]
+            .filter(p => p.length > 0).join(' ');
         value = reviewer;
       } else if (reviewer.group) {
         // Reviewer is a group suggestion from getChangeSuggestedReviewers.
@@ -136,15 +144,19 @@
       } else if (reviewer._account_id) {
         // Reviewer is an account suggestion from getSuggestedAccounts.
         const reviewerName = this._accountOrAnon(reviewer);
-        name = reviewerName + ' <' + reviewer.email + '>' +
-            generateStatusStr(reviewer);
+        const reviewerEmail = this._reviewerEmail(reviewer.email);
+        const reviewerStatus = generateStatusStr(reviewer);
+        name = [reviewerName, reviewerEmail, reviewerStatus]
+            .filter(p => p.length > 0).join(' ');
         value = {account: reviewer, count: 1};
       }
       return {name, value};
     },
 
     _getReviewerSuggestions(input) {
-      if (!this.change || !this.change._number) { return Promise.resolve([]); }
+      if (!this.change || !this.change._number || !this._loggedIn) {
+        return Promise.resolve([]);
+      }
 
       const api = this.$.restAPI;
       const xhr = this.allowAnyUser ?
@@ -161,5 +173,13 @@
             .map(this._makeSuggestion.bind(this));
       });
     },
+
+    _reviewerEmail(email) {
+      if (typeof email !== 'undefined') {
+        return '<' + email + '>';
+      }
+
+      return '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
index 7d5ddd8..57bdd1d 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-entry</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
@@ -56,6 +58,15 @@
         status: opt_status,
       };
     };
+    let _nextAccountId3 = 0;
+    const makeAccount3 = function(opt_status) {
+      const accountId3 = ++_nextAccountId3;
+      return {
+        _account_id: accountId3,
+        name: 'name ' + accountId3,
+        status: opt_status,
+      };
+    };
 
     let owner;
     let existingReviewer1;
@@ -65,7 +76,7 @@
     let suggestion3;
     let element;
 
-    setup(() => {
+    setup(done => {
       owner = makeAccount();
       existingReviewer1 = makeAccount();
       existingReviewer2 = makeAccount();
@@ -78,6 +89,10 @@
         },
       };
 
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+      });
+
       element = fixture('basic');
       element.change = {
         _number: 42,
@@ -88,6 +103,7 @@
         },
       };
       sandbox = sinon.sandbox.create();
+      return flush(done);
     });
 
     teardown(() => {
@@ -110,6 +126,7 @@
       test('_makeSuggestion formats account or group accordingly', () => {
         let account = makeAccount();
         const account2 = makeAccount2();
+        const account3 = makeAccount3();
         let suggestion = element._makeSuggestion({account});
         assert.deepEqual(suggestion, {
           name: account.name + ' <' + account.email + '>',
@@ -149,6 +166,22 @@
           name: account.name + ' <' + account.email + '> (OOO)',
           value: {account, count: 1},
         });
+
+        sandbox.stub(element, '_reviewerEmail',
+            () => { return ''; });
+
+        suggestion = element._makeSuggestion(account3);
+        assert.deepEqual(suggestion, {
+          name: account3.name,
+          value: {account: account3, count: 1},
+        });
+      });
+
+      test('_reviewerEmail', () => {
+        assert.equal(
+            element._reviewerEmail('email@gerritreview.com'),
+            '<email@gerritreview.com>');
+        assert.equal(element._reviewerEmail(undefined), '');
       });
 
       test('_getReviewerSuggestions excludes owner+reviewers', done => {
@@ -168,6 +201,19 @@
           }).then(done);
         });
       });
+
+      test('_getReviewerSuggestions short circuits when logged out', () => {
+        // API call is already stubbed.
+        const xhrSpy = element.$.restAPI.getChangeSuggestedReviewers;
+        element._loggedIn = false;
+        return element._getReviewerSuggestions('').then(() => {
+          assert.isFalse(xhrSpy.called);
+          element._loggedIn = true;
+          return element._getReviewerSuggestions('').then(() => {
+            assert.isTrue(xhrSpy.called);
+          });
+        });
+      });
     });
 
     test('allowAnyUser', done => {
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
index 1bfc5eb..9cb936a 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../gr-account-entry/gr-account-entry.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
index 950c1e8..c10f3d5 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
@@ -21,6 +21,7 @@
 
   Polymer({
     is: 'gr-account-list',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when user inputs an invalid email address.
@@ -88,7 +89,9 @@
     },
 
     get accountChips() {
-      return Polymer.dom(this.root).querySelectorAll('gr-account-chip');
+      // Polymer2: querySelectorAll returns NodeList instead of Array.
+      return Array.from(
+          Polymer.dom(this.root).querySelectorAll('gr-account-chip'));
     },
 
     get focusStart() {
@@ -120,8 +123,11 @@
           // Repopulate the input with what the user tried to enter and have
           // a toast tell them why they can't enter it.
           this.$.entry.setText(reviewer);
-          this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message: VALID_EMAIL_ALERT}, bubbles: true}));
+          this.dispatchEvent(new CustomEvent('show-alert', {
+            detail: {message: VALID_EMAIL_ALERT},
+            bubbles: true,
+            composed: true,
+          }));
           return false;
         } else {
           const account = {email: reviewer, _pendingAdd: true};
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
index 544238b..d32c269 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-account-list.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index f8c1ff4..1e50e4b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -15,15 +15,15 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../admin/gr-create-change-dialog/gr-create-change-dialog.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
@@ -31,9 +31,11 @@
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html">
 <link rel="import" href="../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html">
+<link rel="import" href="../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html">
 <link rel="import" href="../gr-confirm-move-dialog/gr-confirm-move-dialog.html">
 <link rel="import" href="../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html">
 <link rel="import" href="../gr-confirm-revert-dialog/gr-confirm-revert-dialog.html">
+<link rel="import" href="../gr-confirm-submit-dialog/gr-confirm-submit-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-actions">
@@ -52,7 +54,7 @@
       gr-button,
       gr-dropdown {
         /* px because don't have the same font size */
-        margin-left: 12px;
+        margin-left: 8px;
       }
       #actionLoadingMessage {
         align-items: center;
@@ -68,6 +70,14 @@
         margin-right: .2rem;
         width: 1.2rem;
       }
+      gr-button {
+        min-height: 2.25em;
+      }
+      gr-dropdown {
+        --gr-button: {
+          min-height: 2.25em;
+        }
+      }
       #moreActions iron-icon {
         margin: 0;
       }
@@ -166,7 +176,7 @@
           on-cancel="_handleConfirmDialogCancel"
           branch="[[change.branch]]"
           has-parent="[[hasParent]]"
-          rebase-on-current="[[revisionActions.rebase.rebaseOnCurrent]]"
+          rebase-on-current="[[_revisionRebaseAction.rebaseOnCurrent]]"
           hidden></gr-confirm-rebase-dialog>
       <gr-confirm-cherrypick-dialog id="confirmCherrypick"
           class="confirmDialog"
@@ -177,6 +187,15 @@
           on-cancel="_handleConfirmDialogCancel"
           project="[[change.project]]"
           hidden></gr-confirm-cherrypick-dialog>
+      <gr-confirm-cherrypick-conflict-dialog id="confirmCherrypickConflict"
+          class="confirmDialog"
+          change-status="[[changeStatus]]"
+          commit-message="[[commitMessage]]"
+          commit-num="[[commitNum]]"
+          on-confirm="_handleCherrypickConflictConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          project="[[change.project]]"
+          hidden></gr-confirm-cherrypick-conflict-dialog>
       <gr-confirm-move-dialog id="confirmMove"
           class="confirmDialog"
           on-confirm="_handleMoveConfirm"
@@ -193,7 +212,14 @@
           on-confirm="_handleAbandonDialogConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-abandon-dialog>
-      <gr-confirm-dialog id="createFollowUpDialog"
+      <gr-confirm-submit-dialog
+          id="confirmSubmitDialog"
+          class="confirmDialog"
+          change="[[change]]"
+          action="[[_revisionSubmitAction]]"
+          on-cancel="_handleConfirmDialogCancel"
+          on-confirm="_handleSubmitConfirm" hidden></gr-confirm-submit-dialog>
+      <gr-dialog id="createFollowUpDialog"
           class="confirmDialog"
           confirm-label="Create"
           on-confirm="_handleCreateFollowUpChange"
@@ -209,8 +235,8 @@
               repo-name="[[change.project]]"
               private-by-default="[[privateByDefault]]"></gr-create-change-dialog>
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="confirmDeleteDialog"
           class="confirmDialog"
           confirm-label="Delete"
@@ -223,8 +249,8 @@
         <div class="main" slot="main">
           Do you really want to delete the change?
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="confirmDeleteEditDialog"
           class="confirmDialog"
           confirm-label="Delete"
@@ -237,26 +263,7 @@
         <div class="main" slot="main">
           Do you really want to delete the edit?
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
-          id="confirmSubmitDialog"
-          class="confirmDialog"
-          confirm-label="Submit"
-          confirm-on-enter
-          on-cancel="_handleConfirmDialogCancel"
-          on-confirm="_handleSubmitConfirm">
-        <div class="header" slot="header">
-          Submit Change
-        </div>
-        <div class="main" slot="main">
-          <p>
-            Are you sure you want to to <strong>submit</strong> this change?
-          </p>
-          <p class="changeSubject">
-            <strong>[[change.subject]]</strong>
-          </p>
-        </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 910b7fc..a448377 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
@@ -193,6 +193,7 @@
 
   Polymer({
     is: 'gr-change-actions',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the change should be reloaded.
@@ -212,6 +213,12 @@
      * @event show-alert
      */
 
+    /**
+     * Fires when a change action fails.
+     *
+     * @event show-error
+     */
+
     properties: {
       /**
        * @type {{
@@ -235,6 +242,10 @@
           ];
         },
       },
+      disableEdit: {
+        type: Boolean,
+        value: false,
+      },
       _hasKnownChainState: {
         type: Boolean,
         value: false,
@@ -260,6 +271,20 @@
         type: Object,
         value() { return {}; },
       },
+      // If property binds directly to [[revisionActions.submit]] it is not
+      // updated when revisionActions doesn't contain submit action.
+      /** @type {?} */
+      _revisionSubmitAction: {
+        type: Object,
+        computed: '_getSubmitAction(revisionActions)',
+      },
+      // If property binds directly to [[revisionActions.rebase]] it is not
+      // updated when revisionActions doesn't contain rebase action.
+      /** @type {?} */
+      _revisionRebaseAction: {
+        type: Object,
+        computed: '_getRebaseAction(revisionActions)',
+      },
       privateByDefault: String,
 
       _loading: {
@@ -391,7 +416,7 @@
       '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
       '_changeChanged(change)',
       '_editStatusChanged(editMode, editPatchsetLoaded, ' +
-          'editBasedOnCurrentPatchSet, actions.*, change.*)',
+          'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
     ],
 
     listeners: {
@@ -404,6 +429,28 @@
       this._handleLoadingComplete();
     },
 
+    _getSubmitAction(revisionActions) {
+      return this._getRevisionAction(revisionActions, 'submit', null);
+    },
+
+    _getRebaseAction(revisionActions) {
+      return this._getRevisionAction(revisionActions, 'rebase',
+        {rebaseOnCurrent: null}
+      );
+    },
+
+    _getRevisionAction(revisionActions, actionName, emptyActionValue) {
+      if (!revisionActions) {
+        return undefined;
+      }
+      if (revisionActions[actionName] === undefined) {
+        // Return null to fire an event when reveisionActions was loaded
+        // but doesn't contain actionName. undefined doesn't fire an event
+        return emptyActionValue;
+      }
+      return revisionActions[actionName];
+    },
+
     reload() {
       if (!this.changeNum || !this.latestPatchNum) {
         return Promise.resolve();
@@ -564,16 +611,26 @@
     _deleteAndNotify(actionName) {
       if (this.actions[actionName]) {
         delete this.actions[actionName];
-        this.notifyPath('actions.' + actionName);
+        // We assign a fake value of 'false' to support Polymer 2
+        // see https://github.com/Polymer/polymer/issues/2631
+        this.notifyPath('actions.' + actionName, false);
       }
     },
 
     _editStatusChanged(editMode, editPatchsetLoaded,
-        editBasedOnCurrentPatchSet) {
+        editBasedOnCurrentPatchSet, disableEdit) {
+      if (disableEdit) {
+        this._deleteAndNotify('publishEdit');
+        this._deleteAndNotify('rebaseEdit');
+        this._deleteAndNotify('deleteEdit');
+        this._deleteAndNotify('stopEdit');
+        this._deleteAndNotify('edit');
+        return;
+      }
       if (editPatchsetLoaded) {
         // Only show actions that mutate an edit if an actual edit patch set
         // is loaded.
-        if (this.changeIsOpen(this.change.status)) {
+        if (this.changeIsOpen(this.change)) {
           if (editBasedOnCurrentPatchSet) {
             if (!this.actions.publishEdit) {
               this.set('actions.publishEdit', PUBLISH_EDIT);
@@ -595,7 +652,7 @@
         this._deleteAndNotify('deleteEdit');
       }
 
-      if (this.changeIsOpen(this.change.status)) {
+      if (this.changeIsOpen(this.change)) {
         // Only show edit button if there is no edit patchset loaded and the
         // file list is not in edit mode.
         if (editPatchsetLoaded || editMode) {
@@ -738,7 +795,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 +825,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);
     },
 
@@ -822,7 +879,12 @@
 
     _handleActionTap(e) {
       e.preventDefault();
-      const el = Polymer.dom(e).localTarget;
+      let el = Polymer.dom(e).localTarget;
+      while (el.is !== 'gr-button') {
+        if (!el.parentElement) { return; }
+        el = el.parentElement;
+      }
+
       const key = el.getAttribute('data-action-key');
       if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
           key.indexOf('~') !== -1) {
@@ -968,6 +1030,14 @@
     },
 
     _handleCherrypickConfirm() {
+      this._handleCherryPickRestApi(false);
+    },
+
+    _handleCherrypickConflictConfirm() {
+      this._handleCherryPickRestApi(true);
+    },
+
+    _handleCherryPickRestApi(conflicts) {
       const el = this.$.confirmCherrypick;
       if (!el.branch) {
         // TODO(davido): Fix error handling
@@ -986,7 +1056,9 @@
           true,
           {
             destination: el.branch,
+            base: el.baseCommit ? el.baseCommit : null,
             message: el.message,
+            allow_conflicts: conflicts,
           }
       );
     },
@@ -1089,8 +1161,9 @@
     _fireAction(endpoint, action, revAction, opt_payload) {
       const cleanupFn =
           this._setLoadingOnButtonWithKey(action.__type, action.__key);
-      this._send(action.method, opt_payload, endpoint, revAction, cleanupFn)
-          .then(this._handleResponse.bind(this, action));
+
+      this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
+          action).then(this._handleResponse.bind(this, action));
     },
 
     _showActionDialog(dialog) {
@@ -1130,14 +1203,14 @@
             break;
           case ChangeActions.DELETE:
             if (action.__type === ActionType.CHANGE) {
-              page.show('/');
+              Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getUrlForRoot());
             }
             break;
           case ChangeActions.WIP:
           case ChangeActions.DELETE_EDIT:
           case ChangeActions.PUBLISH_EDIT:
           case ChangeActions.REBASE_EDIT:
-            page.show(this.changePath(this.changeNum));
+            Gerrit.Nav.navigateToChange(this.change);
             break;
           default:
             this.dispatchEvent(new CustomEvent('reload-change',
@@ -1147,9 +1220,16 @@
       });
     },
 
-    _handleResponseError(response) {
+    _handleResponseError(action, response, body) {
+      if (action && action.__key === RevisionActions.CHERRYPICK) {
+        if (response && response.status === 409 &&
+            body && !body.allow_conflicts) {
+          return this._showActionDialog(
+              this.$.confirmCherrypickConflict);
+        }
+      }
       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);
@@ -1163,13 +1243,12 @@
      * @param {string} actionEndpoint
      * @param {boolean} revisionAction
      * @param {?Function} cleanupFn
-     * @param {?Function=} opt_errorFn
+     * @param {!Object|undefined} action
      */
-    _send(method, payload, actionEndpoint, revisionAction, cleanupFn,
-        opt_errorFn) {
+    _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
       const handleError = response => {
         cleanupFn.call(this);
-        this._handleResponseError(response);
+        this._handleResponseError(action, response, payload);
       };
 
       return this.fetchChangeUpdates(this.change, this.$.restAPI)
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 67d28f4..b88e06b 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-actions</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
@@ -64,7 +66,9 @@
           });
         },
         send(method, url, payload) {
-          if (method !== 'POST') { return Promise.reject('bad method'); }
+          if (method !== 'POST') {
+            return Promise.reject(new Error('bad method'));
+          }
 
           if (url === '/changes/test~42/revisions/2/submit') {
             return Promise.resolve({
@@ -78,7 +82,7 @@
             });
           }
 
-          return Promise.reject('bad url');
+          return Promise.reject(new Error('bad url'));
         },
         getProjectConfig() { return Promise.resolve({}); },
       });
@@ -252,8 +256,8 @@
       assert.deepEqual(result, actions);
     });
 
-    test('submit change', done => {
-      element.$.confirmSubmitDialog.$.confirm.focus = done;
+    test('submit change', () => {
+      const showSpy = sandbox.spy(element, '_showActionDialog');
       sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
       sandbox.stub(element, 'fetchChangeUpdates',
@@ -270,6 +274,30 @@
       const submitButton = element.$$('gr-button[data-action-key="submit"]');
       assert.ok(submitButton);
       MockInteractions.tap(submitButton);
+
+      flushAsynchronousOperations();
+      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+    });
+
+    test('submit change, tap on icon', done => {
+      sandbox.stub(element.$.confirmSubmitDialog, 'resetFocus', done);
+      sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sandbox.stub(element, 'fetchChangeUpdates',
+          () => { return Promise.resolve({isLatest: true}); });
+      sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.latestPatchNum = '2';
+
+      const submitIcon =
+          element.$$('gr-button[data-action-key="submit"] iron-icon');
+      assert.ok(submitIcon);
+      MockInteractions.tap(submitIcon);
     });
 
     test('_handleSubmitConfirm', () => {
@@ -415,6 +443,20 @@
     });
 
     suite('change edits', () => {
+      test('disableEdit', () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        element.set('disableEdit', true);
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.$$('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.$$('gr-button[data-action-key="stopEdit"]'));
+      });
+
       test('shows confirm dialog for delete edit', () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
@@ -557,7 +599,40 @@
         assert.deepEqual(fireActionStub.lastCall.args, [
           '/cherrypick', action, true, {
             destination: 'master',
+            base: null,
             message: 'foo message',
+            allow_conflicts: false,
+          },
+        ]);
+      });
+
+      test('cherry pick even with conflicts', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: 'POST',
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element.$.confirmCherrypick.branch = 'master';
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = 'OPEN';
+        element.$.confirmCherrypick.commitNum = '123';
+
+        element._handleCherrypickConflictConfirm();
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick', action, true, {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: true,
           },
         ]);
       });
@@ -1346,6 +1421,7 @@
     suite('_send', () => {
       let cleanup;
       let payload;
+      let onShowError;
       let onShowAlert;
 
       setup(() => {
@@ -1354,6 +1430,8 @@
         element.latestPatchNum = 12;
         payload = {foo: 'bar'};
 
+        onShowError = sinon.stub();
+        element.addEventListener('show-error', onShowError);
         onShowAlert = sinon.stub();
         element.addEventListener('show-alert', onShowAlert);
       });
@@ -1371,7 +1449,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));
@@ -1381,7 +1459,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));
@@ -1399,6 +1477,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);
               });
@@ -1417,7 +1496,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);
@@ -1434,4 +1513,50 @@
       assert.equal(reportStub.lastCall.args[0], 'type-key');
     });
   });
+
+  suite('getChangeRevisionActions returns only some actions', () => {
+    let element;
+    let sandbox;
+    let changeRevisionActions;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getChangeRevisionActions() {
+          return Promise.resolve(changeRevisionActions);
+        },
+        send(method, url, payload) {
+          return Promise.reject(new Error('error'));
+        },
+        getProjectConfig() { return Promise.resolve({}); },
+      });
+
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+
+      element = fixture('basic');
+      // getChangeRevisionActions is not called without
+      // set the following properies
+      element.change = {};
+      element.changeNum = '42';
+      element.latestPatchNum = '2';
+
+
+      sandbox.stub(element.$.confirmCherrypick.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      sandbox.stub(element.$.confirmMove.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      return element.reload();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
+      changeRevisionActions = {};
+      element.reload();
+      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
+      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
+    });
+  });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
index ff9e547..c5dba2f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-metadata</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="../../plugins/gr-plugin-host/gr-plugin-host.html">
 <link rel="import" href="gr-change-metadata.html">
@@ -83,9 +85,6 @@
         getLoggedIn() { return Promise.resolve(false); },
         deleteVote() { return Promise.resolve({ok: true}); },
       });
-      stub('gr-change-metadata', {
-        _computeShowReviewersByState() { return true; },
-      });
     });
 
     teardown(() => {
@@ -136,34 +135,42 @@
 
     suite('label updates', () => {
       let plugin;
-      let labelChangeStub;
 
-      setup(done => {
+      setup(() => {
         Gerrit.install(p => plugin = p, '0.1',
             new URL('test/plugin.html?' + Math.random(),
                     window.location.href).toString());
         sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
         Gerrit._setPluginsPending([]);
         element = createElement();
-        sandbox.stub(element, '_computeCanDeleteVote').returns(true);
-
-        labelChangeStub = sandbox.stub();
-        plugin.changeMetadata().onLabelsChanged(labelChangeStub);
-        flush(done);
       });
 
       test('labels changed callback', done => {
-        assert.equal(labelChangeStub.callCount, 1);
-        assert.isTrue(labelChangeStub.calledWithExactly(labels));
-        assert.equal(labelChangeStub.args[0][0]['CI'].all.length, 2);
-        MockInteractions.tap(Polymer.dom(element.root).querySelector(
-            'gr-account-chip').$.remove);
-        // Wait for fake rest API response.
-        flush(() => {
-          assert.equal(labelChangeStub.callCount, 2);
-          assert.equal(labelChangeStub.args[1][0]['CI'].all.length, 1);
-          done();
+        let callCount = 0;
+        const labelChangeSpy = sandbox.spy(arg => {
+          callCount++;
+          if (callCount === 1) {
+            assert.deepEqual(arg, labels);
+            assert.equal(arg.CI.all.length, 2);
+            element.set(['change', 'labels'], {
+              CI: {
+                all: [
+                  {value: 1, name: 'user 2', _account_id: 1},
+                ],
+                values: {
+                  ' 0': 'Don\'t submit as-is',
+                  '+1': 'No score',
+                  '+2': 'Looks good to me',
+                },
+              },
+            });
+          } else if (callCount === 2) {
+            assert.equal(arg.CI.all.length, 1);
+            done();
+          }
         });
+
+        plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 8118d77..e7179d7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../../styles/gr-voting-styles.html">
@@ -27,7 +27,7 @@
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
-<link rel="import" href="../../shared/gr-label/gr-label.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
 <link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
@@ -38,7 +38,6 @@
 
 <dom-module id="gr-change-metadata">
   <template>
-    <style include="gr-voting-styles"></style>
     <style include="shared-styles">
       :host {
         display: table;
@@ -59,11 +58,18 @@
       }
       .title {
         color: var(--deemphasized-text-color);
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         max-width: 20em;
+        padding-left: var(--metadata-horizontal-padding);
         padding-right: .5em;
         word-break: break-word;
       }
+      .value {
+        padding-right: var(--metadata-horizontal-padding);
+      }
+      gr-change-requirements {
+        --requirements-horizontal-padding: var(--metadata-horizontal-padding);
+      }
       gr-account-link {
         max-width: 20ch;
         overflow: hidden;
@@ -74,35 +80,6 @@
       gr-editable-label {
         max-width: 9em;
       }
-      .labelValueContainer:not(:first-of-type) {
-        margin-top: .25em;
-      }
-      .labelValueContainer span {
-        align-items: baseline;
-        display: inline-flex;
-      }
-      .labelValueContainer {
-        border-radius: 3px;
-        padding: .1em .3em;
-      }
-      gr-label {
-        margin-right: .3em;
-        padding: .05em .85em;
-        text-align: center;
-        @apply --vote-chip-styles;
-      }
-      .max {
-        background-color: var(--vote-color-approved);
-      }
-      .min {
-        background-color: var(--vote-color-rejected);
-      }
-      .positive {
-        background-color: var(--vote-color-recommended);
-      }
-      .negative {
-        background-color: var(--vote-color-disliked);
-      }
       .webLink {
         display: block;
       }
@@ -138,10 +115,28 @@
       #parentNotCurrentMessage {
         display: none;
       }
+      .icon {
+        margin: -.25em 0;
+      }
+      .icon.help,
+      .icon.notTrusted {
+        color: #FFA62F;
+      }
+      .icon.invalid {
+        color: var(--vote-text-color-disliked);
+      }
+      .icon.trusted {
+        color: var(--vote-text-color-recommended);
+      }
       .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
         --arrow-color: #ffa62f;
         display: inline-block;
       }
+      .separatedSection {
+        border-top: 1px solid var(--border-color);
+        margin-top: .5em;
+        padding: .5em 0;
+      }
     </style>
     <gr-external-style id="externalStyle" name="change-metadata">
       <section>
@@ -156,13 +151,40 @@
         <span class="title">Owner</span>
         <span class="value">
           <gr-account-link account="[[change.owner]]"></gr-account-link>
+          <template is="dom-if" if="[[_pushCertificateValidation]]">
+            <gr-tooltip-content
+                has-tooltip
+                title$="[[_pushCertificateValidation.message]]">
+              <iron-icon
+                  class$="icon [[_pushCertificateValidation.class]]"
+                  icon="[[_pushCertificateValidation.icon]]">
+              </iron-icon>
+            </gr-tooltip-content>
+          </template>
         </span>
       </section>
-      <section class$="[[_computeShowUploaderHide(change)]]">
+      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
         <span class="title">Uploader</span>
         <span class="value">
           <gr-account-link
-              account="[[_computeShowUploader(change)]]"></gr-account-link>
+              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
+              ></gr-account-link>
+        </span>
+      </section>
+      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
+        <span class="title">Author</span>
+        <span class="value">
+          <gr-account-link
+              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
+              ></gr-account-link>
+        </span>
+      </section>
+      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
+        <span class="title">Committer</span>
+        <span class="value">
+          <gr-account-link
+              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
+              ></gr-account-link>
         </span>
       </section>
       <section class="assignee">
@@ -174,44 +196,32 @@
               placeholder="Set assignee..."
               accounts="{{_assignee}}"
               change="[[change]]"
-              readonly="[[_computeAssigneeReadOnly(mutable, change)]]"
+              readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
               allow-any-user></gr-account-list>
         </span>
       </section>
-      <template is="dom-if" if="[[_showReviewersByState]]">
-        <section>
-          <span class="title">Reviewers</span>
-          <span class="value">
-            <gr-reviewer-list
-                change="{{change}}"
-                mutable="[[mutable]]"
-                reviewers-only
-                max-reviewers-displayed="3"></gr-reviewer-list>
-          </span>
-        </section>
-        <section>
-          <span class="title">CC</span>
-          <span class="value">
-            <gr-reviewer-list
-                change="{{change}}"
-                mutable="[[mutable]]"
-                ccs-only
-                max-reviewers-displayed="3"></gr-reviewer-list>
-          </span>
-        </section>
-      </template>
-      <template is="dom-if" if="[[!_showReviewersByState]]">
-        <section>
-          <span class="title">Reviewers</span>
-          <span class="value">
-            <gr-reviewer-list
-                change="{{change}}"
-                mutable="[[mutable]]"></gr-reviewer-list>
-          </span>
-        </section>
-      </template>
       <section>
-        <span class="title">Project</span>
+        <span class="title">Reviewers</span>
+        <span class="value">
+          <gr-reviewer-list
+              change="{{change}}"
+              mutable="[[_mutable]]"
+              reviewers-only
+              max-reviewers-displayed="3"></gr-reviewer-list>
+        </span>
+      </section>
+      <section>
+        <span class="title">CC</span>
+        <span class="value">
+          <gr-reviewer-list
+              change="{{change}}"
+              mutable="[[_mutable]]"
+              ccs-only
+              max-reviewers-displayed="3"></gr-reviewer-list>
+        </span>
+      </section>
+      <section>
+        <span class="title">Repo</span>
         <span class="value">
           <a href$="[[_computeProjectURL(change.project)]]">
             <gr-limited-text limit="40" text="[[change.project]]"></gr-limited-text>
@@ -263,6 +273,7 @@
               is="dom-if"
               if="[[_showAddTopic(change.*, _settingTopic)]]">
             <gr-editable-label
+                class="topicEditableLabel"
                 label-text="Add a topic"
                 value="[[change.topic]]"
                 max-length="1024"
@@ -276,19 +287,19 @@
         <span class="title">Strategy</span>
         <span class="value">[[_computeStrategy(change)]]</span>
       </section>
-      <template is="dom-if" if="[[serverConfig.note_db_enabled]]">
-        <section class="hashtag">
-          <span class="title">Hashtags</span>
-          <span class="value">
-            <template is="dom-repeat" items="[[change.hashtags]]">
-              <gr-linked-chip
-                  class="hashtagChip"
-                  text="[[item]]"
-                  href="[[_computeHashtagURL(item)]]"
-                  removable="[[!_hashtagReadOnly]]"
-                  on-remove="_handleHashtagRemoved">
-              </gr-linked-chip>
-            </template>
+      <section class="hashtag">
+        <span class="title">Hashtags</span>
+        <span class="value">
+          <template is="dom-repeat" items="[[change.hashtags]]">
+            <gr-linked-chip
+                class="hashtagChip"
+                text="[[item]]"
+                href="[[_computeHashtagURL(item)]]"
+                removable="[[!_hashtagReadOnly]]"
+                on-remove="_handleHashtagRemoved">
+            </gr-linked-chip>
+          </template>
+          <template is="dom-if" if="[[!_hashtagReadOnly]]">
             <gr-editable-label
                 uppercase
                 label-text="Add a hashtag"
@@ -296,51 +307,20 @@
                 placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
                 read-only="[[_hashtagReadOnly]]"
                 on-changed="_handleHashtagChanged"></gr-editable-label>
-          </span>
-        </section>
-      </template>
-      <template is="dom-repeat"
-          items="[[_computeLabelNames(labels)]]" as="labelName">
-        <section>
-          <span class="title">[[labelName]]</span>
-          <span class="value">
-            <template is="dom-repeat"
-                items="[[_computeLabelValues(labelName, labels.*)]]"
-                as="label">
-              <div class="labelValueContainer">
-                <span>
-                  <gr-label
-                      has-tooltip
-                      title="[[_computeValueTooltip(change, label.value, labelName)]]"
-                      class$="[[label.className]] voteChip">
-                    [[label.value]]
-                  </gr-label>
-                  <gr-account-chip
-                      account="[[label.account]]"
-                      data-account-id$="[[label.account._account_id]]"
-                      label-name="[[labelName]]"
-                      removable="[[_computeCanDeleteVote(label.account, mutable)]]"
-                      transparent-background
-                      on-remove="_onDeleteVote"></gr-account-chip>
-                </span>
-              </div>
-            </template>
-          </span>
-        </section>
-      </template>
-      <template is="dom-if" if="[[_showRequirements]]">
-        <section class="requirementsStatus">
-          <span class="title">Submit Status</span>
-          <span class="value">
-            <gr-change-requirements change="[[change]]"></gr-change-requirements>
-          </span>
-        </section>
-      </template>
-      <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo)]]">
+          </template>
+        </span>
+      </section>
+      <div class="separatedSection">
+        <gr-change-requirements
+            change="{{change}}"
+            account="[[account]]"
+            mutable="[[_mutable]]"></gr-change-requirements>
+      </div>
+      <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo, serverConfig)]]">
         <span class="title">Links</span>
         <span class="value">
           <template is="dom-repeat"
-              items="[[_computeWebLinks(commitInfo)]]" as="link">
+              items="[[_computeWebLinks(commitInfo, serverConfig)]]" as="link">
             <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
               [[link.name]]
             </a>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index aaada4f..154fc36 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -17,6 +17,17 @@
 (function() {
   'use strict';
 
+  const Defs = {};
+
+  /**
+   * @typedef {{
+   *    message: string,
+   *    icon: string,
+   *    class: string,
+   *  }}
+   */
+  Defs.PushCertificateValidation;
+
   const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
   const SubmitTypeLabel = {
@@ -30,8 +41,27 @@
 
   const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
 
+  /**
+   * @enum {string}
+   */
+  const CertificateStatus = {
+    /**
+     * This certificate status is bad.
+     */
+    BAD: 'BAD',
+    /**
+     * This certificate status is OK.
+     */
+    OK: 'OK',
+    /**
+     * This certificate status is TRUSTED.
+     */
+    TRUSTED: 'TRUSTED',
+  };
+
   Polymer({
     is: 'gr-change-metadata',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the change topic is changed.
@@ -46,13 +76,15 @@
         type: Object,
         notify: true,
       },
+      account: Object,
       /** @type {?} */
       revision: Object,
       commitInfo: Object,
-      mutable: Boolean,
-      /**
-       * @type {{ note_db_enabled: string }}
-       */
+      _mutable: {
+        type: Boolean,
+        computed: '_computeIsMutable(account)',
+      },
+      /** @type {?} */
       serverConfig: Object,
       parentIsCurrent: Boolean,
       _notCurrentMessage: {
@@ -62,15 +94,18 @@
       },
       _topicReadOnly: {
         type: Boolean,
-        computed: '_computeTopicReadOnly(mutable, change)',
+        computed: '_computeTopicReadOnly(_mutable, change)',
       },
       _hashtagReadOnly: {
         type: Boolean,
-        computed: '_computeHashtagReadOnly(mutable, change)',
+        computed: '_computeHashtagReadOnly(_mutable, change)',
       },
-      _showReviewersByState: {
-        type: Boolean,
-        computed: '_computeShowReviewersByState(serverConfig)',
+      /**
+       * @type {Defs.PushCertificateValidation}
+       */
+      _pushCertificateValidation: {
+        type: Object,
+        computed: '_computePushCertificateValidation(serverConfig, change)',
       },
       _showRequirements: {
         type: Boolean,
@@ -93,6 +128,18 @@
         type: Array,
         computed: '_computeParents(change)',
       },
+
+      /** @type {?} */
+      _CHANGE_ROLE: {
+        type: Object,
+        readOnly: true,
+        value: {
+          OWNER: 'owner',
+          UPLOADER: 'uploader',
+          AUTHOR: 'author',
+          COMMITTER: 'committer',
+        },
+      },
     },
 
     behaviors: [
@@ -130,7 +177,7 @@
     },
 
     _computeHideStrategy(change) {
-      return !this.changeIsOpen(change.status);
+      return !this.changeIsOpen(change);
     },
 
     /**
@@ -139,12 +186,15 @@
      * an existential check can be used to hide or show the webLinks
      * section.
      */
-    _computeWebLinks(commitInfo) {
+    _computeWebLinks(commitInfo, serverConfig) {
       if (!commitInfo) { return null; }
       const weblinks = Gerrit.Nav.getChangeWeblinks(
           this.change ? this.change.repo : '',
           commitInfo.commit,
-          {weblinks: commitInfo.web_links});
+          {
+            weblinks: commitInfo.web_links,
+            config: serverConfig,
+          });
       return weblinks.length ? weblinks : null;
     },
 
@@ -156,60 +206,6 @@
       return Object.keys(labels).sort();
     },
 
-    _computeLabelValues(labelName, _labels) {
-      const result = [];
-      const labels = _labels.base;
-      const labelInfo = labels[labelName];
-      if (!labelInfo) { return result; }
-      if (!labelInfo.values) {
-        if (labelInfo.rejected || labelInfo.approved) {
-          const ok = labelInfo.approved || !labelInfo.rejected;
-          return [{
-            value: ok ? '👍️' : '👎️',
-            className: ok ? 'positive' : 'negative',
-            account: ok ? labelInfo.approved : labelInfo.rejected,
-          }];
-        }
-        return result;
-      }
-      const approvals = labelInfo.all || [];
-      const values = Object.keys(labelInfo.values);
-      for (const label of approvals) {
-        if (label.value && label.value != labels[labelName].default_value) {
-          let labelClassName;
-          let labelValPrefix = '';
-          if (label.value > 0) {
-            labelValPrefix = '+';
-            if (parseInt(label.value, 10) ===
-                parseInt(values[values.length - 1], 10)) {
-              labelClassName = 'max';
-            } else {
-              labelClassName = 'positive';
-            }
-          } else if (label.value < 0) {
-            if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
-              labelClassName = 'min';
-            } else {
-              labelClassName = 'negative';
-            }
-          }
-          result.push({
-            value: labelValPrefix + label.value,
-            className: labelClassName,
-            account: label,
-          });
-        }
-      }
-      return result;
-    },
-
-    _computeValueTooltip(change, score, labelName) {
-      if (!change.labels[labelName] ||
-          !change.labels[labelName].values ||
-          !change.labels[labelName].values[score]) { return ''; }
-      return change.labels[labelName].values[score];
-    },
-
     _handleTopicChanged(e, topic) {
       const lastTopic = this.change.topic;
       if (!topic.length) { topic = null; }
@@ -219,19 +215,21 @@
             this._settingTopic = false;
             this.set(['change', 'topic'], newTopic);
             if (newTopic !== lastTopic) {
-              this.dispatchEvent(
-                  new CustomEvent('topic-changed', {bubbles: true}));
+              this.dispatchEvent(new CustomEvent(
+                  'topic-changed', {bubbles: true, composed: true}));
             }
           });
     },
 
     _showAddTopic(changeRecord, settingTopic) {
-      const hasTopic = !!changeRecord && !!changeRecord.base.topic;
+      const hasTopic = !!changeRecord &&
+          !!changeRecord.base && !!changeRecord.base.topic;
       return !hasTopic && !settingTopic;
     },
 
     _showTopicChip(changeRecord, settingTopic) {
-      const hasTopic = !!changeRecord && !!changeRecord.base.topic;
+      const hasTopic = !!changeRecord &&
+          !!changeRecord.base && !!changeRecord.base.topic;
       return hasTopic && !settingTopic;
     },
 
@@ -244,8 +242,8 @@
           this.change._number, {add: [newHashtag]}).then(newHashtag => {
             this.set(['change', 'hashtags'], newHashtag);
             if (newHashtag !== lastHashtag) {
-              this.dispatchEvent(
-                  new CustomEvent('hashtag-changed', {bubbles: true}));
+              this.dispatchEvent(new CustomEvent(
+                  'hashtag-changed', {bubbles: true, composed: true}));
             }
           });
     },
@@ -281,10 +279,6 @@
       return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
     },
 
-    _computeShowReviewersByState(serverConfig) {
-      return !!serverConfig.note_db_enabled;
-    },
-
     _computeShowRequirements(change) {
       if (change.status !== this.ChangeStatus.NEW) {
         // TODO(maximeg) change this to display the stored
@@ -299,72 +293,56 @@
     },
 
     /**
-     * A user is able to delete a vote iff the mutable property is true and the
-     * reviewer that left the vote exists in the list of removable_reviewers
-     * received from the backend.
-     *
-     * @param {!Object} reviewer An object describing the reviewer that left the
-     *     vote.
-     * @param {boolean} mutable this.mutable describes whether the
-     *     change-metadata section is modifiable by the current user.
+     * @return {?Defs.PushCertificateValidation} object representing data for
+     *     the push validation.
      */
-    _computeCanDeleteVote(reviewer, mutable) {
-      if (!mutable || !this.change || !this.change.removable_reviewers) {
-        return false;
+    _computePushCertificateValidation(serverConfig, change) {
+      if (!serverConfig || !serverConfig.receive ||
+          !serverConfig.receive.enable_signed_push) {
+        return null;
       }
-      for (let i = 0; i < this.change.removable_reviewers.length; i++) {
-        if (this.change.removable_reviewers[i]._account_id ===
-            reviewer._account_id) {
-          return true;
-        }
+      const rev = change.revisions[change.current_revision];
+      if (!rev.push_certificate || !rev.push_certificate.key) {
+        return {
+          class: 'help',
+          icon: 'gr-icons:help',
+          message: 'This patch set was created without a push certificate',
+        };
       }
-      return false;
+
+      const key = rev.push_certificate.key;
+      switch (key.status) {
+        case CertificateStatus.BAD:
+          return {
+            class: 'invalid',
+            icon: 'gr-icons:close',
+            message: this._problems('Push certificate is invalid', key),
+          };
+        case CertificateStatus.OK:
+          return {
+            class: 'notTrusted',
+            icon: 'gr-icons:info',
+            message: this._problems(
+                'Push certificate is valid, but key is not trusted', key),
+          };
+        case CertificateStatus.TRUSTED:
+          return {
+            class: 'trusted',
+            icon: 'gr-icons:check',
+            message: this._problems(
+                'Push certificate is valid and key is trusted', key),
+          };
+        default:
+          throw new Error(`unknown certificate status: ${key.status}`);
+      }
     },
 
-    /**
-     * Closure annotation for Polymer.prototype.splice is off.
-     * For now, supressing annotations.
-     *
-     * TODO(beckysiegel) submit Polymer PR
-     *
-     * @suppress {checkTypes} */
-    _onDeleteVote(e) {
-      e.preventDefault();
-      const target = Polymer.dom(e).rootTarget;
-      target.disabled = true;
-      const labelName = target.labelName;
-      const accountID = parseInt(target.getAttribute('data-account-id'), 10);
-      this._xhrPromise =
-          this.$.restAPI.deleteVote(this.change._number, accountID, labelName)
-          .then(response => {
-            target.disabled = false;
-            if (!response.ok) { return response; }
-            const label = this.change.labels[labelName];
-            const labels = label.all || [];
-            let wasChanged = false;
-            for (let i = 0; i < labels.length; i++) {
-              if (labels[i]._account_id === accountID) {
-                for (const key in label) {
-                  if (label.hasOwnProperty(key) &&
-                      label[key]._account_id === accountID) {
-                    // Remove special label field, keeping change label values
-                    // in sync with the backend.
-                    this.change.labels[labelName][key] = null;
-                    wasChanged = true;
-                  }
-                }
-                this.change.labels[labelName].all.splice(i, 1);
-                wasChanged = true;
-                break;
-              }
-            }
-            if (wasChanged) {
-              this.notifyPath('change.labels');
-            }
-          }).catch(err => {
-            target.disabled = false;
-            return;
-          });
+    _problems(msg, key) {
+      if (!key || !key.problems || key.problems.length === 0) {
+        return msg;
+      }
+
+      return [msg + ':'].concat(key.problems).join('\n');
     },
 
     _computeProjectURL(project) {
@@ -392,7 +370,7 @@
         target.disabled = false;
         this.set(['change', 'topic'], '');
         this.dispatchEvent(
-            new CustomEvent('topic-changed', {bubbles: true}));
+            new CustomEvent('topic-changed', {bubbles: true, composed: true}));
       }).catch(err => {
         target.disabled = false;
         return;
@@ -418,24 +396,45 @@
       return !!change.work_in_progress;
     },
 
-    _computeShowUploaderHide(change) {
-      return this._computeShowUploader(change) ? '' : 'hideDisplay';
+    _computeShowRoleClass(change, role) {
+      return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
     },
 
-    _computeShowUploader(change) {
-      if (!change.current_revision ||
+    /**
+     * Get the user with the specified role on the change. Returns null if the
+     * user with that role is the same as the owner.
+     * @param {!Object} change
+     * @param {string} role One of the values from _CHANGE_ROLE
+     * @return {Object|null} either an accound or null.
+     */
+    _getNonOwnerRole(change, role) {
+      if (!change || !change.current_revision ||
           !change.revisions[change.current_revision]) {
         return null;
       }
 
       const rev = change.revisions[change.current_revision];
+      if (!rev) { return null; }
 
-      if (!rev || !rev.uploader ||
-        change.owner._account_id === rev.uploader._account_id) {
-        return null;
+      if (role === this._CHANGE_ROLE.UPLOADER &&
+          rev.uploader &&
+          change.owner._account_id !== rev.uploader._account_id) {
+        return rev.uploader;
       }
 
-      return rev.uploader;
+      if (role === this._CHANGE_ROLE.AUTHOR &&
+          rev.commit && rev.commit.author &&
+          change.owner.email !== rev.commit.author.email) {
+        return rev.commit.author;
+      }
+
+      if (role === this._CHANGE_ROLE.COMMITTER &&
+          rev.commit && rev.commit.committer &&
+          change.owner.email !== rev.commit.committer.email) {
+        return rev.commit.committer;
+      }
+
+      return null;
     },
 
     _computeParents(change) {
@@ -458,5 +457,16 @@
         parentIsCurrent ? 'current' : 'notCurrent',
       ].join(' ');
     },
+
+    _computeIsMutable(account) {
+      return !!Object.keys(account).length;
+    },
+
+    editTopic() {
+      if (this._topicReadOnly || this.change.topic) { return; }
+      // Cannot use `this.$.ID` syntax because the element exists inside of a
+      // dom-if.
+      this.$$('.topicEditableLabel').open();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 1330451..fe09869 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-metadata</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="../../core/gr-router/gr-router.html">
 <link rel="import" href="gr-change-metadata.html">
@@ -117,22 +119,11 @@
       assert.isTrue(element.$$('.strategy').hasAttribute('hidden'));
     });
 
-    test('show CC section when NoteDb enabled', () => {
-      function hasCc() {
-        return element._showReviewersByState;
-      }
-
-      element.serverConfig = {};
-      assert.isFalse(hasCc());
-
-      element.serverConfig = {note_db_enabled: true};
-      assert.isTrue(hasCc());
-    });
-
     test('weblinks use Gerrit.Nav interface', () => {
       const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
           .returns([{name: 'stubb', url: '#s'}]);
       element.commitInfo = {};
+      element.serverConfig = {};
       flushAsynchronousOperations();
       const webLinks = element.$.webLinks;
       assert.isTrue(weblinksStub.called);
@@ -142,6 +133,7 @@
 
     test('weblinks hidden when no weblinks', () => {
       element.commitInfo = {};
+      element.serverConfig = {};
       flushAsynchronousOperations();
       const webLinks = element.$.webLinks;
       assert.isTrue(webLinks.hasAttribute('hidden'));
@@ -149,12 +141,26 @@
 
     test('weblinks hidden when only gitiles weblink', () => {
       element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
+      element.serverConfig = {};
       flushAsynchronousOperations();
       const webLinks = element.$.webLinks;
       assert.isTrue(webLinks.hasAttribute('hidden'));
       assert.equal(element._computeWebLinks(element.commitInfo), null);
     });
 
+    test('weblinks hidden when sole weblink is set as primary', () => {
+      const browser = 'browser';
+      element.commitInfo = {web_links: [{name: browser, url: '#'}]};
+      element.serverConfig = {
+        gerrit: {
+          primary_weblink_name: browser,
+        },
+      };
+      flushAsynchronousOperations();
+      const webLinks = element.$.webLinks;
+      assert.isTrue(webLinks.hasAttribute('hidden'));
+    });
+
     test('weblinks are visible when other weblinks', () => {
       const router = document.createElement('gr-router');
       sandbox.stub(Gerrit.Nav, '_generateWeblinks',
@@ -185,7 +191,138 @@
       assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
     });
 
-    test('_computeShowUploader test for uploader', () => {
+    suite('_getNonOwnerRole', () => {
+      let change;
+
+      setup(() => {
+        change = {
+          owner: {
+            email: 'abc@def',
+            _account_id: 1019328,
+          },
+          revisions: {
+            rev1: {
+              _number: 1,
+              uploader: {
+                email: 'ghi@def',
+                _account_id: 1011123,
+              },
+              commit: {
+                author: {email: 'jkl@def'},
+                committer: {email: 'ghi@def'},
+              },
+            },
+          },
+          current_revision: 'rev1',
+        };
+      });
+
+      suite('role=uploader', () => {
+        test('_getNonOwnerRole for uploader', () => {
+          assert.deepEqual(
+              element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
+              {email: 'ghi@def', _account_id: 1011123});
+        });
+
+        test('_getNonOwnerRole that it does not return uploader', () => {
+          // Set the uploader email to be the same as the owner.
+          change.revisions.rev1.uploader._account_id = 1019328;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.UPLOADER));
+        });
+
+        test('_getNonOwnerRole null for uploader with no current rev', () => {
+          delete change.current_revision;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.UPLOADER));
+        });
+
+        test('_computeShowRoleClass show uploader', () => {
+          assert.equal(element._computeShowRoleClass(
+              change, element._CHANGE_ROLE.UPLOADER), '');
+        });
+
+        test('_computeShowRoleClass hide uploader', () => {
+          // Set the uploader email to be the same as the owner.
+          change.revisions.rev1.uploader._account_id = 1019328;
+          assert.equal(element._computeShowRoleClass(change,
+              element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
+        });
+      });
+
+      suite('role=committer', () => {
+        test('_getNonOwnerRole for committer', () => {
+          assert.deepEqual(
+              element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
+              {email: 'ghi@def'});
+        });
+
+        test('_getNonOwnerRole that it does not return committer', () => {
+          // Set the committer email to be the same as the owner.
+          change.revisions.rev1.commit.committer.email = 'abc@def';
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+
+        test('_getNonOwnerRole null for committer with no current rev', () => {
+          delete change.current_revision;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+
+        test('_getNonOwnerRole null for committer with no commit', () => {
+          delete change.revisions.rev1.commit;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+
+        test('_getNonOwnerRole null for committer with no committer', () => {
+          delete change.revisions.rev1.commit.committer;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+      });
+
+      suite('role=author', () => {
+        test('_getNonOwnerRole for author', () => {
+          assert.deepEqual(
+              element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
+              {email: 'jkl@def'});
+        });
+
+        test('_getNonOwnerRole that it does not return author', () => {
+          // Set the author email to be the same as the owner.
+          change.revisions.rev1.commit.author.email = 'abc@def';
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+
+        test('_getNonOwnerRole null for author with no current rev', () => {
+          delete change.current_revision;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+
+        test('_getNonOwnerRole null for author with no commit', () => {
+          delete change.revisions.rev1.commit;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+
+        test('_getNonOwnerRole null for author with no author', () => {
+          delete change.revisions.rev1.commit.author;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+      });
+    });
+
+    test('Push Certificate Validation test BAD', () => {
+      const serverConfig = {
+        receive: {
+          enable_signed_push: true,
+        },
+      };
       const change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         owner: {
@@ -194,8 +331,13 @@
         revisions: {
           rev1: {
             _number: 1,
-            uploader: {
-              _account_id: 1011123,
+            push_certificate: {
+              key: {
+                status: 'BAD',
+                problems: [
+                  'No public keys found for key ID E5E20E52',
+                ],
+              },
             },
           },
         },
@@ -204,54 +346,21 @@
         labels: {},
         mergeable: true,
       };
-      assert.deepEqual(element._computeShowUploader(change),
-          {_account_id: 1011123});
+      const result =
+          element._computePushCertificateValidation(serverConfig, change);
+      assert.equal(result.message,
+          'Push certificate is invalid:\n' +
+          'No public keys found for key ID E5E20E52');
+      assert.equal(result.icon, 'gr-icons:close');
+      assert.equal(result.class, 'invalid');
     });
 
-    test('_computeShowUploader test that it does not return uploader', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1011123,
+    test('Push Certificate Validation test TRUSTED', () => {
+      const serverConfig = {
+        receive: {
+          enable_signed_push: true,
         },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
       };
-      assert.isNotOk(element._computeShowUploader(change));
-    });
-
-    test('no current_revision makes _computeShowUploader return null', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1011123,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
-          },
-        },
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      assert.isNotOk(element._computeShowUploader(change));
-    });
-
-    test('_computeShowUploaderHide test for string which equals true', () => {
       const change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         owner: {
@@ -260,8 +369,10 @@
         revisions: {
           rev1: {
             _number: 1,
-            uploader: {
-              _account_id: 1011123,
+            push_certificate: {
+              key: {
+                status: 'TRUSTED',
+              },
             },
           },
         },
@@ -270,21 +381,28 @@
         labels: {},
         mergeable: true,
       };
-      assert.equal(element._computeShowUploaderHide(change), '');
+      const result =
+          element._computePushCertificateValidation(serverConfig, change);
+      assert.equal(result.message,
+          'Push certificate is valid and key is trusted');
+      assert.equal(result.icon, 'gr-icons:check');
+      assert.equal(result.class, 'trusted');
     });
 
-    test('_computeShowUploaderHide test for hideDisplay', () => {
+    test('Push Certificate Validation is missing test', () => {
+      const serverConfig = {
+        receive: {
+          enable_signed_push: true,
+        },
+      };
       const change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         owner: {
-          _account_id: 1011123,
+          _account_id: 1019328,
         },
         revisions: {
           rev1: {
             _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
           },
         },
         current_revision: 'rev1',
@@ -292,34 +410,12 @@
         labels: {},
         mergeable: true,
       };
-      assert.equal(
-          element._computeShowUploaderHide(change), 'hideDisplay');
-    });
-
-    test('_computeValueTooltip', () => {
-      // Existing label.
-      const change = {labels: {'Foo-bar': {values: {0: 'Baz'}}}};
-      let score = '0';
-      let labelName = 'Foo-bar';
-      let actual = element._computeValueTooltip(change, score, labelName);
-      assert.equal(actual, 'Baz');
-
-      // Non-extsistent label.
-      labelName = 'xyz';
-      actual = element._computeValueTooltip(change, score, labelName);
-      assert.equal(actual, '');
-
-      // Non-extsistent score.
-      score = '2';
-      actual = element._computeValueTooltip(change, score, labelName);
-      assert.equal(actual, '');
-
-      // No values on label.
-      labelName = 'abcd';
-      score = '0';
-      change.labels.abcd = {};
-      actual = element._computeValueTooltip(change, score, labelName);
-      assert.equal(actual, '');
+      const result =
+          element._computePushCertificateValidation(serverConfig, change);
+      assert.equal(result.message,
+          'This patch set was created without a push certificate');
+      assert.equal(result.icon, 'gr-icons:help');
+      assert.equal(result.class, 'help');
     });
 
     test('_computeParents', () => {
@@ -403,7 +499,7 @@
       });
 
       test('topic read only hides delete button', () => {
-        element.mutable = false;
+        element.account = {};
         element.change = change;
         flushAsynchronousOperations();
         const button = element.$$('gr-linked-chip').$$('gr-button');
@@ -411,7 +507,7 @@
       });
 
       test('topic not read only does not hide delete button', () => {
-        element.mutable = true;
+        element.account = {test: true};
         change.actions.topic.enabled = true;
         element.change = change;
         flushAsynchronousOperations();
@@ -444,9 +540,6 @@
       });
 
       test('_computeHashtagReadOnly', () => {
-        element.serverConfig = {
-          note_db_enabled: true,
-        };
         flushAsynchronousOperations();
         let mutable = false;
         assert.isTrue(element._computeHashtagReadOnly(mutable, change));
@@ -459,11 +552,8 @@
       });
 
       test('hashtag read only hides delete button', () => {
-        element.serverConfig = {
-          note_db_enabled: true,
-        };
         flushAsynchronousOperations();
-        element.mutable = false;
+        element.account = {};
         element.change = change;
         flushAsynchronousOperations();
         const button = element.$$('gr-linked-chip').$$('gr-button');
@@ -471,11 +561,8 @@
       });
 
       test('hashtag not read only does not hide delete button', () => {
-        element.serverConfig = {
-          note_db_enabled: true,
-        };
         flushAsynchronousOperations();
-        element.mutable = true;
+        element.account = {test: true};
         change.actions.hashtags.enabled = true;
         element.change = change;
         flushAsynchronousOperations();
@@ -486,7 +573,6 @@
 
     suite('remove reviewer votes', () => {
       setup(() => {
-        sandbox.stub(element, '_computeValueTooltip').returns('');
         sandbox.stub(element, '_computeTopicReadOnly').returns(true);
         element.change = {
           _number: 42,
@@ -507,100 +593,6 @@
         flushAsynchronousOperations();
       });
 
-      test('_computeCanDeleteVote hides delete button', () => {
-        const button = element.$$('gr-account-chip').$$('gr-button');
-        assert.isTrue(button.hasAttribute('hidden'));
-        element.mutable = true;
-        assert.isTrue(button.hasAttribute('hidden'));
-      });
-
-      test('_computeCanDeleteVote shows delete button', () => {
-        element.change.removable_reviewers = [
-          {
-            _account_id: 1,
-            name: 'bojack',
-          },
-        ];
-        element.mutable = true;
-        const button = element.$$('gr-account-chip').$$('gr-button');
-        assert.isFalse(button.hasAttribute('hidden'));
-      });
-
-      test('deletes votes', () => {
-        const deleteResponse = Promise.resolve({ok: true});
-        const deleteStub = sandbox.stub(
-            element.$.restAPI, 'deleteVote').returns(deleteResponse);
-
-        element.change.removable_reviewers = [{
-          _account_id: 1,
-          name: 'bojack',
-        }];
-        element.change.labels.test.recommended = {_account_id: 1};
-        element.mutable = true;
-        const chip = element.$$('gr-account-chip');
-        const button = chip.$$('gr-button');
-        MockInteractions.tap(button);
-        assert.isTrue(chip.disabled);
-        return deleteResponse.then(() => {
-          assert.isFalse(chip.disabled);
-          assert.notOk(element.change.labels.test.recommended);
-          assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
-        });
-      });
-
-      test('changing topic', () => {
-        const newTopic = 'the new topic';
-        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
-            Promise.resolve(newTopic));
-        element._handleTopicChanged({}, newTopic);
-        const topicChangedSpy = sandbox.spy();
-        element.addEventListener('topic-changed', topicChangedSpy);
-        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-            42, newTopic));
-        return element.$.restAPI.setChangeTopic.lastCall.returnValue
-            .then(() => {
-              assert.equal(element.change.topic, newTopic);
-              assert.isTrue(topicChangedSpy.called);
-            });
-      });
-
-      test('topic removal', () => {
-        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
-            Promise.resolve());
-        const chip = element.$$('gr-linked-chip');
-        const remove = chip.$.remove;
-        const topicChangedSpy = sandbox.spy();
-        element.addEventListener('topic-changed', topicChangedSpy);
-        MockInteractions.tap(remove);
-        assert.isTrue(chip.disabled);
-        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-            42, null));
-        return element.$.restAPI.setChangeTopic.lastCall.returnValue
-            .then(() => {
-              assert.isFalse(chip.disabled);
-              assert.equal(element.change.topic, '');
-              assert.isTrue(topicChangedSpy.called);
-            });
-      });
-
-      test('changing hashtag', () => {
-        element.serverConfig = {
-          note_db_enabled: true,
-        };
-        flushAsynchronousOperations();
-        element._newHashtag = 'new hashtag';
-        const newHashtag = ['new hashtag'];
-        sandbox.stub(element.$.restAPI, 'setChangeHashtag').returns(
-            Promise.resolve(newHashtag));
-        element._handleHashtagChanged({}, 'new hashtag');
-        assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
-            42, {add: ['new hashtag']}));
-        return element.$.restAPI.setChangeHashtag.lastCall.returnValue
-            .then(() => {
-              assert.equal(element.change.hashtags, newHashtag);
-            });
-      });
-
       suite('assignee field', () => {
         const dummyAccount = {
           _account_id: 1,
@@ -653,6 +645,70 @@
           assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
         });
       });
+
+      test('changing topic', () => {
+        const newTopic = 'the new topic';
+        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
+            Promise.resolve(newTopic));
+        element._handleTopicChanged({}, newTopic);
+        const topicChangedSpy = sandbox.spy();
+        element.addEventListener('topic-changed', topicChangedSpy);
+        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+            42, newTopic));
+        return element.$.restAPI.setChangeTopic.lastCall.returnValue
+            .then(() => {
+              assert.equal(element.change.topic, newTopic);
+              assert.isTrue(topicChangedSpy.called);
+            });
+      });
+
+      test('topic removal', () => {
+        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
+            Promise.resolve());
+        const chip = element.$$('gr-linked-chip');
+        const remove = chip.$.remove;
+        const topicChangedSpy = sandbox.spy();
+        element.addEventListener('topic-changed', topicChangedSpy);
+        MockInteractions.tap(remove);
+        assert.isTrue(chip.disabled);
+        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+            42, null));
+        return element.$.restAPI.setChangeTopic.lastCall.returnValue
+            .then(() => {
+              assert.isFalse(chip.disabled);
+              assert.equal(element.change.topic, '');
+              assert.isTrue(topicChangedSpy.called);
+            });
+      });
+
+      test('changing hashtag', () => {
+        flushAsynchronousOperations();
+        element._newHashtag = 'new hashtag';
+        const newHashtag = ['new hashtag'];
+        sandbox.stub(element.$.restAPI, 'setChangeHashtag').returns(
+            Promise.resolve(newHashtag));
+        element._handleHashtagChanged({}, 'new hashtag');
+        assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
+            42, {add: ['new hashtag']}));
+        return element.$.restAPI.setChangeHashtag.lastCall.returnValue
+            .then(() => {
+              assert.equal(element.change.hashtags, newHashtag);
+            });
+      });
+    });
+
+    test('editTopic', () => {
+      element.account = {test: true};
+      element.change = {actions: {topic: {enabled: true}}};
+      flushAsynchronousOperations();
+
+      const label = element.$$('.topicEditableLabel');
+      assert.ok(label);
+      sandbox.stub(label, 'open');
+      element.editTopic();
+      flushAsynchronousOperations();
+
+      assert.isTrue(label.open.called);
     });
 
     suite('plugin endpoints', () => {
@@ -678,105 +734,5 @@
         });
       });
     });
-
-    suite('label colors', () => {
-      test('valueless label rejected', () => {
-        element.change = {
-          labels: {
-            'Do-Not-Submit': {
-              rejected: {name: 'someone'},
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('negative'));
-      });
-
-      test('valueless label approved', () => {
-        element.change = {
-          labels: {
-            'To-The-Infinity': {
-              approved: {name: 'someone'},
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('positive'));
-      });
-
-      test('-2 to +2', () => {
-        element.change = {
-          labels: {
-            'Code-Review': {
-              all: [
-                {value: 2, name: 'user 2'},
-                {value: 1, name: 'user 1'},
-                {value: -1, name: 'user 3'},
-                {value: -2, name: 'user 4'},
-              ],
-              values: {
-                '-2': 'Awful',
-                '-1': 'Don\'t submit as-is',
-                ' 0': 'No score',
-                '+1': 'Looks good to me',
-                '+2': 'Ready to submit',
-              },
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('positive'));
-        assert.isTrue(labels[2].classList.contains('negative'));
-        assert.isTrue(labels[3].classList.contains('min'));
-      });
-
-      test('-1 to +1', () => {
-        element.change = {
-          labels: {
-            CI: {
-              all: [
-                {value: 1, name: 'user 1'},
-                {value: -1, name: 'user 2'},
-              ],
-              values: {
-                '-1': 'Don\'t submit as-is',
-                ' 0': 'No score',
-                '+1': 'Looks good to me',
-              },
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('min'));
-      });
-
-      test('0 to +2', () => {
-        element.change = {
-          labels: {
-            CI: {
-              all: [
-                {value: 1, name: 'user 2'},
-                {value: 2, name: 'user '},
-              ],
-              values: {
-                ' 0': 'Don\'t submit as-is',
-                '+1': 'No score',
-                '+2': 'Looks good to me',
-              },
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('positive'));
-        assert.isTrue(labels[1].classList.contains('max'));
-      });
-    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
index d0ed4a1..b3aa98f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
@@ -7,20 +7,22 @@
 </dom-module>
 
 <dom-module id="my-plugin-style">
-  <style>
-    html {
-      --change-metadata-assignee: {
-        display: none;
+  <template>
+    <style>
+      html {
+        --change-metadata-assignee: {
+          display: none;
+        }
+        --change-metadata-label-status: {
+          display: none;
+        }
+        --change-metadata-strategy: {
+          display: none;
+        }
+        --change-metadata-topic: {
+          display: none;
+        }
       }
-      --change-metadata-label-status: {
-        display: none;
-      }
-      --change-metadata-strategy: {
-        display: none;
-      }
-      --change-metadata-topic: {
-        display: none;
-      }
-    }
-  </style>
+    </style>
+  </template>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
index 439677a..80fc1ae 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
@@ -15,63 +15,156 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../../shared/gr-label/gr-label.html">
+<link rel="import" href="../../shared/gr-label-info/gr-label-info.html">
+<link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
 
 <dom-module id="gr-change-requirements">
   <template strip-whitespace>
     <style include="shared-styles">
+      :host {
+        display: table;
+        width: 100%;
+      }
       .status {
+        color: #FFA62F;
         display: inline-block;
-        font-weight: initial;
+        font-family: var(--monospace-font-family);
         text-align: center;
       }
-      .unsatisfied .icon {
-        color: #FFA62F;
+      .approved.status {
+        color: var(--vote-text-color-recommended);
       }
-      .satisfied .icon {
-        color: #388E3C;
+      .rejected.status {
+        color: var(--vote-text-color-disliked);
       }
-      .requirement {
-        padding: .1em 0;
+      iron-icon {
+        color: inherit;
       }
-      .requirementContainer:not(:first-of-type) {
-        margin-top: .25em;
+      .name {
+        font-weight: var(--font-weight-bold);
       }
-      .labelName, .changeIsWip {
-        font-weight: bold;
+      section {
+        display: table-row;
+      }
+      .show-hide {
+        float: right;
+      }
+      .title {
+        min-width: 10em;
+        padding: .75em .5em 0 var(--requirements-horizontal-padding);
+        vertical-align: top;
+      }
+      .value {
+        padding: .6em .5em 0 0;
+        vertical-align: middle;
+      }
+      .title,
+      .value {
+        display: table-cell;
+      }
+      .hidden {
+        display: none;
+      }
+      .showHide {
+        cursor: pointer;
+      }
+      .showHide .title {
+        border-top: 1px solid var(--border-color);
+        padding-bottom: .5em;
+        padding-top: .5em;
+      }
+      .showHide .value {
+        border-top: 1px solid var(--border-color);
+        padding-top: 0;
+        vertical-align: middle;
+      }
+      .showHide iron-icon {
+        color: var(--deemphasized-text-color);
+        float: right;
+      }
+      .spacer {
+        height: .5em;
       }
     </style>
-    <template is="dom-if" if="[[_showWip]]">
-      <div class="requirement unsatisfied changeIsWip">
-        <span class="status"><iron-icon class="icon" icon="gr-icons:hourglass"></iron-icon></span>
-        Work in Progress
-      </div>
-    </template>
-    <template is="dom-if" if="[[_showLabels]]">
-      <template
-          is="dom-repeat"
-          items="[[labels]]">
-        <div class$="requirement [[item.style]]">
-          <span class="status">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+    <template
+        is="dom-repeat"
+        items="[[_requirements]]">
+      <section>
+        <div class="title requirement">
+          <span class$="status [[item.style]]">
+            <iron-icon class="icon" icon="[[_computeRequirementIcon(item.satisfied)]]"></iron-icon>
           </span>
-          Label <span class="labelName">[[item.label]]</span>
+          <gr-limited-text class="name" limit="40" text="[[item.fallback_text]]"></gr-limited-text>
         </div>
-      </template>
+      </section>
     </template>
     <template
         is="dom-repeat"
-        items="[[requirements]]">
-      <div class$="requirement [[_computeRequirementClass(item.satisfied)]]">
-        <span class="status">
-          <iron-icon class="icon" icon="[[_computeRequirementIcon(item.satisfied)]]"></iron-icon>
-        </span>
-        [[item.fallback_text]]
-      </div>
+        items="[[_requiredLabels]]">
+      <section>
+        <div class="title">
+          <span class$="status [[item.style]]">
+            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+          </span>
+          <gr-limited-text class="name" limit="40" text="[[item.label]]"></gr-limited-text>
+        </div>
+        <div class="value">
+          <gr-label-info
+              change="{{change}}"
+              account="[[account]]"
+              mutable="[[mutable]]"
+              label="[[item.label]]"
+              label-info="[[item.labelInfo]]"></gr-label-info>
+        </div>
+      </section>
     </template>
+    <section class="spacer"></section>
+    <section class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"></section>
+    <section
+        show-bottom-border$="[[_showOptionalLabels]]"
+        on-tap="_handleShowHide"
+        class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
+      <div class="title">Other labels</div>
+      <div class="value">
+        <iron-icon
+            id="showHide"
+            icon="[[_computeShowHideIcon(_showOptionalLabels)]]">
+        </iron-icon>
+      </label>
+      </div>
+    </section>
+    <template
+        is="dom-repeat"
+        items="[[_optionalLabels]]">
+      <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
+        <div class="title">
+          <span class$="status [[item.style]]">
+            <template is="dom-if" if="[[item.icon]]">
+              <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+            </template>
+            <template is="dom-if" if="[[!item.icon]]">
+              <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
+            </template>
+          </span>
+          <gr-limited-text class="name" limit="40" text="[[item.label]]"></gr-limited-text>
+        </div>
+        <div class="value">
+          <gr-label-info
+              change="{{change}}"
+              account="[[account]]"
+              mutable="[[mutable]]"
+              label="[[item.label]]"
+              label-info="[[item.labelInfo]]"></gr-label-info>
+        </div>
+      </section>
+    </template>
+    <section class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"></section>
   </template>
   <script src="gr-change-requirements.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index a26c7ec..09efa8d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -17,29 +17,34 @@
 (function() {
   'use strict';
 
-  const VALID_SELECTOR_REGEX = /[^A-Za-z0-9\-]/g;
-
   Polymer({
     is: 'gr-change-requirements',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /** @type {?} */
       change: Object,
-      requirements: {
+      account: Object,
+      mutable: Boolean,
+      _requirements: {
         type: Array,
         computed: '_computeRequirements(change)',
       },
-      labels: {
+      _requiredLabels: {
         type: Array,
-        computed: '_computeLabels(change)',
+        value: () => [],
+      },
+      _optionalLabels: {
+        type: Array,
+        value: () => [],
       },
       _showWip: {
         type: Boolean,
         computed: '_computeShowWip(change)',
       },
-      _showLabels: {
+      _showOptionalLabels: {
         type: Boolean,
-        computed: '_computeShowLabelStatus(change)',
+        value: true,
       },
     },
 
@@ -47,9 +52,9 @@
       Gerrit.RESTClientBehavior,
     ],
 
-    _computeShowLabelStatus(change) {
-      return change.status === this.ChangeStatus.NEW;
-    },
+    observers: [
+      '_computeLabels(change.labels.*)',
+    ],
 
     _computeShowWip(change) {
       return change.work_in_progress;
@@ -61,48 +66,86 @@
       if (change.requirements) {
         for (const requirement of change.requirements) {
           requirement.satisfied = requirement.status === 'OK';
+          requirement.style =
+              this._computeRequirementClass(requirement.satisfied);
           _requirements.push(requirement);
         }
       }
+      if (change.work_in_progress) {
+        _requirements.push({
+          fallback_text: 'Work-in-progress',
+          tooltip: 'Change must not be in \'Work in Progress\' state.',
+        });
+      }
 
       return _requirements;
     },
 
-    _computeLabels(change) {
-      const labels = change.labels;
-      const _labels = [];
-
-      for (const label in labels) {
-        if (!labels.hasOwnProperty(label)) { continue; }
-        const obj = labels[label];
-        if (obj.optional) { continue; }
-
-        const icon = this._computeRequirementIcon(obj.approved);
-        const style = this._computeRequirementClass(obj.approved);
-        _labels.push({label, icon, style});
-      }
-
-      return _labels;
-    },
-
     _computeRequirementClass(requirementStatus) {
-      if (requirementStatus) {
-        return 'satisfied';
-      } else {
-        return 'unsatisfied';
-      }
+      return requirementStatus ? 'approved' : '';
     },
 
     _computeRequirementIcon(requirementStatus) {
-      if (requirementStatus) {
-        return 'gr-icons:check';
-      } else {
-        return 'gr-icons:hourglass';
+      return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
+    },
+
+    _computeLabels(labelsRecord) {
+      const labels = labelsRecord.base;
+      this._optionalLabels = [];
+      this._requiredLabels = [];
+
+      for (const label in labels) {
+        if (!labels.hasOwnProperty(label)) { continue; }
+
+        const labelInfo = labels[label];
+        const icon = this._computeLabelIcon(labelInfo);
+        const style = this._computeLabelClass(labelInfo);
+        const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
+
+        this.push(path, {label, icon, style, labelInfo});
       }
     },
 
-    _removeInvalidChars(text) {
-      return text.replace(VALID_SELECTOR_REGEX, '');
+    /**
+     * @param {Object} labelInfo
+     * @return {string} The icon name, or undefined if no icon should
+     *     be used.
+     */
+    _computeLabelIcon(labelInfo) {
+      if (labelInfo.approved) { return 'gr-icons:check'; }
+      if (labelInfo.rejected) { return 'gr-icons:close'; }
+      return 'gr-icons:hourglass';
+    },
+
+    /**
+     * @param {Object} labelInfo
+     */
+    _computeLabelClass(labelInfo) {
+      if (labelInfo.approved) { return 'approved'; }
+      if (labelInfo.rejected) { return 'rejected'; }
+      return '';
+    },
+
+    _computeShowOptional(optionalFieldsRecord) {
+      return optionalFieldsRecord.base.length ? '' : 'hidden';
+    },
+
+    _computeLabelValue(value) {
+      return (value > 0 ? '+' : '') + value;
+    },
+
+    _computeShowHideIcon(showOptionalLabels) {
+      return showOptionalLabels ?
+          'gr-icons:expand-less' :
+          'gr-icons:expand-more';
+    },
+
+    _computeSectionClass(show) {
+      return show ? '' : 'hidden';
+    },
+
+    _handleShowHide(e) {
+      this._showOptionalLabels = !this._showOptionalLabels;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
index efac0c2..2ceac39 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-requirements</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-requirements.html">
 
@@ -40,19 +42,69 @@
       element = fixture('basic');
     });
 
-    test('computed fields', () => {
-      assert.isTrue(element._computeShowLabelStatus({status: 'NEW'}));
-      assert.isFalse(element._computeShowLabelStatus({status: 'MERGED'}));
-      assert.isFalse(element._computeShowLabelStatus({status: 'ABANDONED'}));
-
+    test('requirements computed fields', () => {
       assert.isTrue(element._computeShowWip({work_in_progress: true}));
       assert.isFalse(element._computeShowWip({work_in_progress: false}));
 
-      assert.equal(element._computeRequirementClass(true), 'satisfied');
-      assert.equal(element._computeRequirementClass(false), 'unsatisfied');
+      assert.equal(element._computeRequirementClass(true), 'approved');
+      assert.equal(element._computeRequirementClass(false), '');
 
       assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-      assert.equal(element._computeRequirementIcon(false), 'gr-icons:hourglass');
+      assert.equal(element._computeRequirementIcon(false),
+          'gr-icons:hourglass');
+    });
+
+    test('label computed fields', () => {
+      assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
+      assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
+      assert.equal(element._computeLabelIcon({}), 'gr-icons:hourglass');
+
+      assert.equal(element._computeLabelClass({approved: []}), 'approved');
+      assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
+      assert.equal(element._computeLabelClass({}), '');
+      assert.equal(element._computeLabelClass({value: 0}), '');
+
+      assert.equal(element._computeLabelValue(1), '+1');
+      assert.equal(element._computeLabelValue(-1), '-1');
+      assert.equal(element._computeLabelValue(0), '0');
+    });
+
+    test('_computeLabels', () => {
+      assert.equal(element._optionalLabels.length, 0);
+      assert.equal(element._requiredLabels.length, 0);
+      element._computeLabels({base: {
+        test: {
+          all: [{_account_id: 1, name: 'bojack', value: 1}],
+          default_value: 0,
+          values: [],
+          value: 1,
+        },
+        opt_test: {
+          all: [{_account_id: 1, name: 'bojack', value: 1}],
+          default_value: 0,
+          values: [],
+          optional: true,
+        },
+      }});
+      assert.equal(element._optionalLabels.length, 1);
+      assert.equal(element._requiredLabels.length, 1);
+
+      assert.equal(element._optionalLabels[0].label, 'opt_test');
+      assert.equal(element._optionalLabels[0].icon, 'gr-icons:hourglass');
+      assert.equal(element._optionalLabels[0].style, '');
+      assert.ok(element._optionalLabels[0].labelInfo);
+    });
+
+    test('optional show/hide', () => {
+      element._optionalLabels = [{label: 'test'}];
+      flushAsynchronousOperations();
+
+      assert.ok(element.$$('section.optional'));
+      MockInteractions.tap(element.$$('.showHide'));
+      flushAsynchronousOperations();
+
+      assert.isFalse(element._showOptionalLabels);
+      assert.isTrue(isHidden(element.$$('section.optional')));
     });
 
     test('properly converts satisfied labels', () => {
@@ -60,17 +112,16 @@
         status: 'NEW',
         labels: {
           Verified: {
-            approved: true,
+            approved: [],
           },
         },
         requirements: [],
       };
       flushAsynchronousOperations();
 
-      const labelName = element.$$('.satisfied .labelName');
-      assert.ok(labelName);
-      assert.isFalse(labelName.hasAttribute('hidden'));
-      assert.equal(labelName.innerHTML, 'Verified');
+      assert.ok(element.$$('.approved'));
+      assert.ok(element.$$('.name'));
+      assert.equal(element.$$('.name').text, 'Verified');
     });
 
     test('properly converts unsatisfied labels', () => {
@@ -84,10 +135,10 @@
       };
       flushAsynchronousOperations();
 
-      const labelName = element.$$('.unsatisfied .labelName');
-      assert.ok(labelName);
-      assert.isFalse(labelName.hasAttribute('hidden'));
-      assert.equal(labelName.innerHTML, 'Verified');
+      const name = element.$$('.name');
+      assert.ok(name);
+      assert.isFalse(name.hasAttribute('hidden'));
+      assert.equal(name.text, 'Verified');
     });
 
     test('properly displays Work In Progress', () => {
@@ -99,13 +150,10 @@
       };
       flushAsynchronousOperations();
 
-      const changeIsWip = element.$$('.changeIsWip.unsatisfied');
+      const changeIsWip = element.$$('.title');
       assert.ok(changeIsWip);
-      assert.isFalse(changeIsWip.hasAttribute('hidden'));
-      assert.notEqual(changeIsWip.innerHTML.indexOf('Work in Progress'), -1);
     });
 
-
     test('properly displays a satisfied requirement', () => {
       element.change = {
         status: 'NEW',
@@ -117,13 +165,60 @@
       };
       flushAsynchronousOperations();
 
-      const satisfiedRequirement = element.$$('.satisfied');
-      assert.ok(satisfiedRequirement);
-      assert.isFalse(satisfiedRequirement.hasAttribute('hidden'));
+      const requirement = element.$$('.requirement');
+      assert.ok(requirement);
+      assert.isFalse(requirement.hasAttribute('hidden'));
+      assert.ok(requirement.querySelector('.approved'));
+      assert.equal(requirement.querySelector('.name').text,
+          'Resolve all comments');
+    });
 
-      // Extract the content of the text node (second element, after the span)
-      const textNode = satisfiedRequirement.childNodes[1].nodeValue.trim();
-      assert.equal(textNode, 'Resolve all comments');
+    test('satisfied class is applied with OK', () => {
+      element.change = {
+        status: 'NEW',
+        labels: {},
+        requirements: [{
+          fallback_text: 'Resolve all comments',
+          status: 'OK',
+        }],
+      };
+      flushAsynchronousOperations();
+
+      const requirement = element.$$('.requirement');
+      assert.ok(requirement);
+      assert.ok(requirement.querySelector('.approved'));
+    });
+
+    test('satisfied class is not applied with NOT_READY', () => {
+      element.change = {
+        status: 'NEW',
+        labels: {},
+        requirements: [{
+          fallback_text: 'Resolve all comments',
+          status: 'NOT_READY',
+        }],
+      };
+      flushAsynchronousOperations();
+
+      const requirement = element.$$('.requirement');
+      assert.ok(requirement);
+      assert.strictEqual(requirement.querySelector('.approved'), null);
+    });
+
+    test('satisfied class is not applied with RULE_ERROR', () => {
+      element.change = {
+        status: 'NEW',
+        labels: {},
+        requirements: [{
+          fallback_text: 'Resolve all comments',
+          status: 'RULE_ERROR',
+        }],
+      };
+      flushAsynchronousOperations();
+
+      const requirement = element.$$('.requirement');
+      assert.ok(requirement);
+      assert.strictEqual(requirement.querySelector('.approved'), null);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index df86161..4860490 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -15,16 +15,15 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/paper-tabs/paper-tabs.html">
+<link rel="import" href="/bower_components/paper-tabs/paper-tabs.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
-<link rel="import" href="../../diff/gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../../edit/gr-edit-constants.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
@@ -40,6 +39,7 @@
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
+<link rel="import" href="../../shared/revision-info/revision-info.html">
 <link rel="import" href="../gr-change-actions/gr-change-actions.html">
 <link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
 <link rel="import" href="../gr-commit-info/gr-commit-info.html">
@@ -51,6 +51,7 @@
 <link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
 <link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
 <link rel="import" href="../gr-thread-list/gr-thread-list.html">
+<link rel="import" href="../gr-upload-help-dialog/gr-upload-help-dialog.html">
 
 <dom-module id="gr-change-view">
   <template>
@@ -90,7 +91,7 @@
         font-size: 1.2rem;
       }
       .headerTitle .headerSubject {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       #replyBtn {
         margin-bottom: 1em;
@@ -109,20 +110,14 @@
          https://github.com/Polymer/polymer/issues/2531 */
       .container section.changeInfo {
         display: flex;
-        padding: 0 var(--default-horizontal-margin);
       }
       .changeId {
         color: var(--deemphasized-text-color);
         font-family: var(--font-family);
         margin-top: 1em;
       }
-      .changeInfo-column {
-        padding: 0 1em;
-      }
       .changeMetadata {
         border-right: 1px solid var(--border-color);
-        font-size: var(--font-size-small);
-        padding: 1em 0;
       }
       /* Prevent plugin text from overflowing. */
       #change_plugins {
@@ -190,7 +185,6 @@
         overflow: hidden;
       }
       #relatedChanges {
-        font-size: var(--font-size-small);
       }
       #relatedChanges.collapsed {
         margin-bottom: 1.1em;
@@ -202,9 +196,11 @@
         flex-direction: column;
         flex-shrink: 0;
         margin: 1em 0;
+        padding: 0 1em;
       }
       .collapseToggleContainer {
         display: flex;
+        margin-bottom: 8px;
       }
       #relatedChangesToggle {
         display: none;
@@ -239,6 +235,7 @@
         --paper-tabs-selection-bar-color: var(--link-color);
       }
       paper-tab {
+        box-sizing: border-box;
         max-width: 15rem;
         --paper-tab-ink: var(--link-color);
       }
@@ -249,13 +246,17 @@
       #includedInOverlay {
         width: 65em;
       }
+      #uploadHelpOverlay {
+        width: 50em;
+      }
       @media screen and (min-width: 80em) {
         .commitMessage {
           max-width: var(--commit-message-max-width, 100ch);
         }
       }
       #metadata {
-        padding-right: 1em;
+        --metadata-horizontal-padding: 1em;
+        padding-top: 1em;
         width: 100%;
       }
       /* NOTE: If you update this breakpoint, also update the
@@ -275,10 +276,11 @@
         #commitMessageEditor {
           min-width: 0;
         }
-        gr-reply-dialog {
-          height: 100vh;
-          min-width: initial;
-          width: 100vw;
+        .commitMessage {
+          margin-right: 0;
+        }
+        .mainChangeInfo {
+          padding-right: 0;
         }
       }
       /* NOTE: If you update this breakpoint, also update the
@@ -319,19 +321,18 @@
           flex-wrap: nowrap;
         }
         .commitContainer {
-          border-top: 1px solid var(--border-color);
           margin: 0;
-          padding: 1em 0;
+          padding: 1em;
         }
         .relatedChanges,
         .changeMetadata {
           font-size: var(--font-size-normal);
         }
         .changeMetadata {
+          border-bottom: 1px solid var(--border-color);
           border-right: none;
           margin-top: .25em;
           max-width: none;
-          padding: 1em 0;
         }
         #metadata,
         .mainChangeInfo {
@@ -351,18 +352,29 @@
         #mainContent.overlayOpen .hideOnMobileOverlay {
           display: none;
         }
+        gr-reply-dialog {
+          height: 100vh;
+          min-width: initial;
+          width: 100vw;
+        }
+        #replyOverlay {
+          z-index: var(--reply-overlay-z-index);
+        }
       }
     </style>
     <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
     <div
         id="mainContent"
         class="container"
+        on-show-checks-table="_handleShowChecksTable"
         hidden$="{{_loading}}">
       <div class$="[[_computeHeaderClass(_editMode)]]">
         <div class="headerTitle">
           <gr-change-star
               id="changeStar"
-              change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
+              change="{{_change}}"
+              on-toggle-star="_handleToggleStar"
+              hidden$="[[!_loggedIn]]"></gr-change-star>
           <div class="changeStatuses">
             <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
               <gr-change-status
@@ -388,8 +400,10 @@
           <span class="headerSubject">[[_change.subject]]</span>
         </div><!-- end headerTitle -->
         <div class="commitActions" hidden$="[[!_loggedIn]]">
-          <gr-change-actions id="actions"
+          <gr-change-actions
+              id="actions"
               change="[[_change]]"
+              disable-edit="[[disableEdit]]"
               has-parent="[[hasParent]]"
               actions="[[_change.actions]]"
               revision-actions="[[_currentRevisionActions]]"
@@ -415,10 +429,10 @@
           <gr-change-metadata
               id="metadata"
               change="{{_change}}"
+              account="[[_account]]"
               revision="[[_selectedRevision]]"
               commit-info="[[_commitInfo]]"
               server-config="[[_serverConfig]]"
-              mutable="[[_loggedIn]]"
               parent-is-current="[[_parentIsCurrent]]"
               on-show-reply-dialog="_handleShowReplyDialog">
           </gr-change-metadata>
@@ -430,13 +444,15 @@
         <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
           <div id="commitAndRelated" class="hideOnMobileOverlay">
             <div class="commitContainer">
-              <gr-button
-                  id="replyBtn"
-                  class="reply"
-                  hidden$="[[!_loggedIn]]"
-                  primary
-                  disabled="[[_replyDisabled]]"
-                  on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+              <div>
+                <gr-button
+                    id="replyBtn"
+                    class="reply"
+                    hidden$="[[!_loggedIn]]"
+                    primary
+                    disabled="[[_replyDisabled]]"
+                    on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+              </div>
               <div
                   id="commitMessage"
                   class$="commitMessage [[_computeCommitClass(_commitCollapsed, _latestCommitMessage)]]">
@@ -476,6 +492,12 @@
                   [[_computeCollapseText(_commitCollapsed)]]
                 </gr-button>
               </div>
+              <gr-endpoint-decorator name="commit-container">
+                <gr-endpoint-param name="change" value="[[_change]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
             </div>
             <div class="relatedChanges">
               <gr-related-changes-list id="relatedChanges"
@@ -502,13 +524,33 @@
           </div>
         </div>
       </section>
+
       <section class="patchInfo">
-        <gr-file-list-header
+        <template is="dom-if" if="[[_showPrimaryTabs]]">
+          <paper-tabs id="primaryTabs" on-selected-changed="_handleFileTabChange">
+            <paper-tab>Files</paper-tab>
+            <template is="dom-repeat" items="[[_dynamicTabHeaderEndpoints]]"
+              as="tabHeader">
+              <paper-tab>
+                  <gr-endpoint-decorator name$="[[tabHeader]]">
+                      <gr-endpoint-param name="change" value="[[_change]]">
+                      </gr-endpoint-param>
+                      <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
+                      </gr-endpoint-param>
+                  </gr-endpoint-decorator>
+              </paper-tab>
+            </template>
+          </paper-tabs>
+        </template>
+
+        <div hidden$="[[!_showFileTabContent]]">
+          <gr-file-list-header
             id="fileListHeader"
             account="[[_account]]"
             all-patch-sets="[[_allPatchSets]]"
             change="[[_change]]"
             change-num="[[_changeNum]]"
+            revision-info="[[_revisionInfo]]"
             change-comments="[[_changeComments]]"
             commit-info="[[_commitInfo]]"
             change-url="[[_computeChangeUrl(_change)]]"
@@ -521,8 +563,11 @@
             patch-num="{{_patchRange.patchNum}}"
             base-patch-num="{{_patchRange.basePatchNum}}"
             files-expanded="[[_filesExpanded]]"
+            diff-prefs-disabled="[[_diffPrefsDisabled]]"
+            show-title="[[!_showPrimaryTabs]]"
             on-open-diff-prefs="_handleOpenDiffPrefs"
             on-open-download-dialog="_handleOpenDownloadDialog"
+            on-open-upload-help-dialog="_handleOpenUploadHelpDialog"
             on-open-included-in-dialog="_handleOpenIncludedInDialog"
             on-expand-diffs="_expandAllDiffs"
             on-collapse-diffs="_collapseAllDiffs">
@@ -547,7 +592,18 @@
             on-files-shown-changed="_setShownFiles"
             on-file-action-tap="_handleFileActionTap"
             on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
+        </div>
+
+        <template is="dom-if" if="[[!_showFileTabContent]]">
+            <gr-endpoint-decorator name$="[[_selectedFilesTabPluginEndpoint]]">
+                <gr-endpoint-param name="change" value="[[_change]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
+                </gr-endpoint-param>
+            </gr-endpoint-decorator>
+        </template>
       </section>
+
       <gr-endpoint-decorator name="change-view-integration">
         <gr-endpoint-param name="change" value="[[_change]]">
         </gr-endpoint-param>
@@ -556,7 +612,7 @@
       </gr-endpoint-decorator>
       <paper-tabs
           id="commentTabs"
-          on-selected-changed="_handleTabChange">
+          on-selected-changed="_handleCommentTabChange">
         <paper-tab class="changeLog">Change Log</paper-tab>
         <paper-tab
             class="commentThreads">
@@ -576,6 +632,7 @@
             change-comments="[[_changeComments]]"
             project-name="[[_change.project]]"
             show-reply-buttons="[[_loggedIn]]"
+            on-message-anchor-tap="_handleMessageAnchorTap"
             on-reply="_handleMessageReply"></gr-messages-list>
       </template>
       <template is="dom-if" if="[[!_showMessagesView]]">
@@ -595,6 +652,12 @@
           config="[[_serverConfig.download]]"
           on-close="_handleDownloadDialogClose"></gr-download-dialog>
     </gr-overlay>
+    <gr-overlay id="uploadHelpOverlay" with-backdrop>
+      <gr-upload-help-dialog
+          revision="[[_currentRevision]]"
+          target-branch="[[_change.branch]]"
+          on-close="_handleCloseUploadHelpDialog"></gr-upload-help-dialog>
+    </gr-overlay>
     <gr-overlay id="includedInOverlay" with-backdrop>
       <gr-included-in-dialog
           id="includedInDialog"
@@ -611,7 +674,6 @@
           patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
           permitted-labels="[[_change.permitted_labels]]"
           diff-drafts="[[_diffDrafts]]"
-          server-config="[[_serverConfig]]"
           project-config="[[_projectConfig]]"
           can-be-started="[[_canStartReview]]"
           on-send="_handleReplySent"
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 533545e..ae361d7 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
@@ -44,6 +44,8 @@
 
   const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
 
+  const MSG_PREFIX = '#message-';
+
   const ReloadToastMessage = {
     NEWER_REVISION: 'A newer patch set has been uploaded',
     RESTORED: 'This change has been restored',
@@ -62,6 +64,7 @@
 
   Polymer({
     is: 'gr-change-view',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -102,6 +105,18 @@
         type: Object,
         value() { return document.body; },
       },
+      disableEdit: {
+        type: Boolean,
+        value: false,
+      },
+      disableDiffPrefs: {
+        type: Boolean,
+        value: false,
+      },
+      _diffPrefsDisabled: {
+        type: Boolean,
+        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+      },
       _commentThreads: Array,
       /** @type {?} */
       _serverConfig: {
@@ -118,11 +133,12 @@
         type: Object,
         value: {},
       },
+      _prefs: Object,
       /** @type {?} */
       _changeComments: Object,
       _canStartReview: {
         type: Boolean,
-        computed: '_computeCanStartReview(_loggedIn, _change, _account)',
+        computed: '_computeCanStartReview(_change)',
       },
       _comments: Object,
       /** @type {?} */
@@ -130,8 +146,17 @@
         type: Object,
         observer: '_changeChanged',
       },
+      _revisionInfo: {
+        type: Object,
+        computed: '_getRevisionInfo(_change)',
+      },
       /** @type {?} */
       _commitInfo: Object,
+      _currentRevision: {
+        type: Object,
+        computed: '_computeCurrentRevision(_change.current_revision, ' +
+            '_change.revisions)',
+      },
       _files: Object,
       _changeNum: String,
       _diffDrafts: {
@@ -145,7 +170,7 @@
       _hideEditCommitMessage: {
         type: Boolean,
         computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-            '_editingCommitMessage, _change)',
+            '_editingCommitMessage, _change, _editMode)',
       },
       _diffAgainst: String,
       /** @type {?string} */
@@ -234,6 +259,25 @@
         type: Boolean,
         value: true,
       },
+      _showFileTabContent: {
+        type: Boolean,
+        value: true,
+      },
+      /** @type {Array<string>} */
+      _dynamicTabHeaderEndpoints: {
+        type: Array,
+      },
+      _showPrimaryTabs: {
+        type: Boolean,
+        computed: '_computeShowPrimaryTabs(_dynamicTabHeaderEndpoints)',
+      },
+      /** @type {Array<string>} */
+      _dynamicTabContentEndpoints: {
+        type: Array,
+      },
+      _selectedFilesTabPluginEndpoint: {
+        type: String,
+      },
     },
 
     behaviors: [
@@ -253,22 +297,28 @@
       'fullscreen-overlay-closed': '_handleShowBackgroundContent',
       'diff-comments-modified': '_handleReloadCommentThreads',
     },
+
     observers: [
       '_labelsChanged(_change.labels.*)',
       '_paramsAndChangeChanged(params, _change)',
       '_patchNumChanged(_patchRange.patchNum)',
     ],
 
-    keyBindings: {
-      'shift+r': '_handleCapitalRKey',
-      'a': '_handleAKey',
-      'd': '_handleDKey',
-      'm': '_handleMKey',
-      's': '_handleSKey',
-      'u': '_handleUKey',
-      'x': '_handleXKey',
-      'z': '_handleZKey',
-      ',': '_handleCommaKey',
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+        [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+        [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+        [this.Shortcut.OPEN_DOWNLOAD_DIALOG]:
+            '_handleOpenDownloadDialogShortcut',
+        [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+        [this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
+        [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+        [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+        [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+        [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+        [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+      };
     },
 
     attached() {
@@ -286,6 +336,17 @@
         this._setDiffViewMode();
       });
 
+      Gerrit.awaitPluginsLoaded().then(() => {
+        this._dynamicTabHeaderEndpoints =
+            Gerrit._endpoints.getDynamicEndpoints('change-view-tab-header');
+        this._dynamicTabContentEndpoints =
+            Gerrit._endpoints.getDynamicEndpoints('change-view-tab-content');
+        if (this._dynamicTabContentEndpoints.length
+            !== this._dynamicTabHeaderEndpoints.length) {
+          console.warn('Different number of tab headers and tab content.');
+        }
+      });
+
       this.addEventListener('comment-save', this._handleCommentSave.bind(this));
       this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
       this.addEventListener('comment-discard',
@@ -321,7 +382,7 @@
     _setDiffViewMode(opt_reset) {
       if (!opt_reset && this.viewState.diffViewMode) { return; }
 
-      return this.$.restAPI.getPreferences().then( prefs => {
+      return this._getPreferences().then( prefs => {
         if (!this.viewState.diffMode) {
           this.set('viewState.diffMode', prefs.default_diff_view);
         }
@@ -332,7 +393,7 @@
       });
     },
 
-    _handleMKey(e) {
+    _handleToggleDiffMode(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -344,10 +405,29 @@
       }
     },
 
-    _handleTabChange() {
+    _handleCommentTabChange() {
       this._showMessagesView = this.$.commentTabs.selected === 0;
     },
 
+    _handleFileTabChange() {
+      const selectedIndex = this.$$('#primaryTabs').selected;
+      this._showFileTabContent = selectedIndex === 0;
+      // Initial tab is the static files list.
+      this._selectedFilesTabPluginEndpoint =
+          this._dynamicTabContentEndpoints[selectedIndex - 1];
+    },
+
+    _handleShowChecksTable(e) {
+      const idx = this._dynamicTabContentEndpoints.indexOf(e.detail.tab);
+      if (idx === -1) {
+        console.warn(e.detail.tab + ' tab not found');
+        return;
+      }
+      this.$$('#primaryTabs').selected = idx + 1;
+      this.$$('#primaryTabs').scrollIntoView();
+      this._handleFileTabChange();
+    },
+
     _handleEditCommitMessage(e) {
       this._editingCommitMessage = true;
       this.$.commitMessageEditor.focusTextarea();
@@ -394,8 +474,9 @@
       return this.changeStatuses(change, options);
     },
 
-    _computeHideEditCommitMessage(loggedIn, editing, change) {
-      if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED) {
+    _computeHideEditCommitMessage(loggedIn, editing, change, editMode) {
+      if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED ||
+          editMode) {
         return true;
       }
 
@@ -437,9 +518,9 @@
     },
 
     _handleCommentSave(e) {
-      if (!e.target.comment.__draft) { return; }
+      const draft = e.detail.comment;
+      if (!draft.__draft) { return; }
 
-      const draft = e.target.comment;
       draft.patch_set = draft.patch_set || this._patchRange.patchNum;
 
       // The use of path-based notification helpers (set, push) can’t be used
@@ -469,9 +550,9 @@
     },
 
     _handleCommentDiscard(e) {
-      if (!e.target.comment.__draft) { return; }
+      const draft = e.detail.comment;
+      if (!draft.__draft) { return; }
 
-      const draft = e.target.comment;
       if (!this._diffDrafts[draft.path]) {
         return;
       }
@@ -535,6 +616,14 @@
       this.$.downloadOverlay.close();
     },
 
+    _handleOpenUploadHelpDialog(e) {
+      this.$.uploadHelpOverlay.open();
+    },
+
+    _handleCloseUploadHelpDialog(e) {
+      this.$.uploadHelpOverlay.close();
+    },
+
     _handleMessageReply(e) {
       const msg = e.detail.message.message;
       const quoteStr = msg.split('\n').map(
@@ -666,6 +755,8 @@
       // Selected has to be set after the paper-tabs are visible because
       // the selected underline depends on calculations made by the browser.
       this.$.commentTabs.selected = 0;
+      const primaryTabs = this.$$('#primaryTabs');
+      if (primaryTabs) primaryTabs.selected = 0;
 
       this.async(() => {
         if (this.viewState.scrollTop) {
@@ -698,10 +789,17 @@
       this.viewState.numFilesShown = numFilesShown;
     },
 
+    _handleMessageAnchorTap(e) {
+      const hash = MSG_PREFIX + e.detail.id;
+      const url = Gerrit.Nav.getUrlForChange(this._change,
+          this._patchRange.patchNum, this._patchRange.basePatchNum,
+          this._editMode, hash);
+      history.replaceState(null, '', url);
+    },
+
     _maybeScrollToMessage(hash) {
-      const msgPrefix = '#message-';
-      if (hash.startsWith(msgPrefix)) {
-        this.messagesList.scrollToMessage(hash.substr(msgPrefix.length));
+      if (hash.startsWith(MSG_PREFIX)) {
+        this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
       }
     },
 
@@ -767,20 +865,53 @@
 
     _changeChanged(change) {
       if (!change || !this._patchRange || !this._allPatchSets) { return; }
-      this.set('_patchRange.basePatchNum',
-          this._patchRange.basePatchNum || 'PARENT');
-      this.set('_patchRange.patchNum',
-          this._patchRange.patchNum ||
-              this.computeLatestPatchNum(this._allPatchSets));
 
-      // Reset the related changes toggle in the event it was previously
-      // displayed on an earlier change.
-      this._showRelatedToggle = false;
+      const parent = this._getBasePatchNum(change, this._patchRange);
+
+      this.set('_patchRange.basePatchNum', parent);
+      this.set('_patchRange.patchNum', this._patchRange.patchNum ||
+              this.computeLatestPatchNum(this._allPatchSets));
 
       const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
       this.fire('title-change', {title});
     },
 
+    /**
+     * Gets base patch number, if it is a parent try and decide from
+     * preference weather to default to `auto merge`, `Parent 1` or `PARENT`.
+     * @param {Object} change
+     * @param {Object} patchRange
+     * @return {number|string}
+     */
+    _getBasePatchNum(change, patchRange) {
+      if (patchRange.basePatchNum &&
+          patchRange.basePatchNum !== 'PARENT') {
+        return patchRange.basePatchNum;
+      }
+
+      const revisionInfo = this._getRevisionInfo(change);
+      if (!revisionInfo) return 'PARENT';
+
+      const parentCounts = revisionInfo.getParentCountMap();
+      // check that there is at least 2 parents otherwise fall back to 1,
+      // which means there is only one parent.
+      const parentCount = parentCounts.hasOwnProperty(1) ?
+          parentCounts[1] : 1;
+
+      const preferFirst = this._prefs &&
+          this._prefs.default_base_for_merges === 'FIRST_PARENT';
+
+      if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
+        return -1;
+      }
+
+      return 'PARENT';
+    },
+
+    _computeShowPrimaryTabs(dynamicTabContentEndpoints) {
+      return dynamicTabContentEndpoints.length > 0;
+    },
+
     _computeChangeUrl(change) {
       return Gerrit.Nav.getUrlForChange(change);
     },
@@ -881,7 +1012,7 @@
       return label;
     },
 
-    _handleAKey(e) {
+    _handleOpenReplyDialog(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) {
         return;
@@ -897,7 +1028,7 @@
       });
     },
 
-    _handleDKey(e) {
+    _handleOpenDownloadDialogShortcut(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -905,13 +1036,21 @@
       this.$.downloadOverlay.open();
     },
 
-    _handleCapitalRKey(e) {
+    _handleEditTopic(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.metadata.editTopic();
+    },
+
+    _handleRefreshChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       e.preventDefault();
       Gerrit.Nav.navigateToChange(this._change);
     },
 
-    _handleSKey(e) {
+    _handleToggleChangeStar(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -919,7 +1058,7 @@
       this.$.changeStar.toggleStar();
     },
 
-    _handleUKey(e) {
+    _handleUpToDashboard(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -927,7 +1066,7 @@
       this._determinePageBack();
     },
 
-    _handleXKey(e) {
+    _handleExpandAllMessages(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -935,7 +1074,7 @@
       this.messagesList.handleExpandCollapse(true);
     },
 
-    _handleZKey(e) {
+    _handleCollapseAllMessages(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -943,18 +1082,21 @@
       this.messagesList.handleExpandCollapse(false);
     },
 
-    _handleCommaKey(e) {
+    _handleOpenDiffPrefsShortcut(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
+      if (this._diffPrefsDisabled) { return; }
+
       e.preventDefault();
       this.$.fileList.openDiffPrefs();
     },
 
     _determinePageBack() {
-      // Default backPage to '/' if user came to change view page
+      // Default backPage to root if user came to change view page
       // via an email link, etc.
-      Gerrit.Nav.navigateToRelativeUrl(this.backPage || '/');
+      Gerrit.Nav.navigateToRelativeUrl(this.backPage ||
+          Gerrit.Nav.getUrlForRoot());
     },
 
     _handleLabelRemoved(splices, path) {
@@ -997,9 +1139,10 @@
 
     _handleReloadChange(e) {
       return this._reload().then(() => {
-        // If the change was rebased, we need to reload the page with the
-        // latest patch.
-        if (e.detail.action === 'rebase') {
+        // If the change was rebased or submitted, we need to reload the page
+        // with the latest patch.
+        const action = e.detail.action;
+        if (action === 'rebase' || action === 'submit') {
           Gerrit.Nav.navigateToChange(this._change);
         }
       });
@@ -1024,6 +1167,10 @@
           });
     },
 
+    _getPreferences() {
+      return this.$.restAPI.getPreferences();
+    },
+
     _updateRebaseAction(revisionActions) {
       if (revisionActions && revisionActions.rebase) {
         revisionActions.rebase.rebaseOnCurrent =
@@ -1077,9 +1224,12 @@
       const detailCompletes = this.$.restAPI.getChangeDetail(
           this._changeNum, this._handleGetChangeDetailError.bind(this));
       const editCompletes = this._getEdit();
+      const prefCompletes = this._getPreferences();
 
-      return Promise.all([detailCompletes, editCompletes])
-          .then(([change, edit]) => {
+      return Promise.all([detailCompletes, editCompletes, prefCompletes])
+          .then(([change, edit, prefs]) => {
+            this._prefs = prefs;
+
             if (!change) {
               return '';
             }
@@ -1228,7 +1378,8 @@
       // Resolves when the loading flag is set to false, meaning that some
       // change content may start appearing.
       const loadingFlagSet = detailCompletes
-          .then(() => { this._loading = false; });
+          .then(() => { this._loading = false; })
+          .then(() => { this.$.reporting.changeDisplayed(); });
 
       // Resolves when the project config has loaded.
       const projectConfigLoaded = detailCompletes
@@ -1300,8 +1451,7 @@
         this.$.reporting.changeFullyLoaded();
       });
 
-      return coreDataPromise
-          .then(() => { this.$.reporting.changeDisplayed(); });
+      return coreDataPromise;
     },
 
     /**
@@ -1331,9 +1481,9 @@
       });
     },
 
-    _computeCanStartReview(loggedIn, change, account) {
-      return !!(loggedIn && change.work_in_progress &&
-          change.owner._account_id === account._account_id);
+    _computeCanStartReview(change) {
+      return !!(change.actions && change.actions.ready &&
+          change.actions.ready.enabled);
     },
 
     _computeReplyDisabled() { return false; },
@@ -1622,5 +1772,22 @@
     _resetReplyOverlayFocusStops() {
       this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
     },
+
+    _handleToggleStar(e) {
+      this.$.restAPI.saveChangeStarred(e.detail.change._number,
+          e.detail.starred);
+    },
+
+    _getRevisionInfo(change) {
+      return new Gerrit.RevisionInfo(change);
+    },
+
+    _computeCurrentRevision(currentRevision, revisions) {
+      return revisions && revisions[currentRevision];
+    },
+
+    _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
+      return disableDiffPrefs || !loggedIn;
+    },
   });
 })();
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 9671711..e8de93b 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
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="../../edit/gr-edit-constants.html">
 <link rel="import" href="gr-change-view.html">
@@ -43,6 +45,19 @@
 
 <script>
   suite('gr-change-view tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.SEND_REPLY, 'ctrl+enter');
+    kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE, 'shift+r');
+    kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
+    kb.bindShortcut(kb.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
+    kb.bindShortcut(kb.Shortcut.UP_TO_DASHBOARD, 'u');
+    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+    kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+    kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
+    kb.bindShortcut(kb.Shortcut.EDIT_TOPIC, 't');
+
     let element;
     let sandbox;
     let navigateToChangeStub;
@@ -82,19 +97,40 @@
       return element.getComputedStyleValue(cssParam);
     };
 
+    test('_handleMessageAnchorTap', () => {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForChange');
+      const replaceStateStub = sandbox.stub(history, 'replaceState');
+      element._handleMessageAnchorTap({detail: {id: 'a12345'}});
+
+      assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+      assert.isTrue(replaceStateStub.called);
+    });
+
     suite('keyboard shortcuts', () => {
+      test('t to add topic', () => {
+        const editStub = sandbox.stub(element.$.metadata, 'editTopic');
+        MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
+        assert(editStub.called);
+      });
+
       test('S should toggle the CL star', () => {
         const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
         MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
         assert(starStub.called);
       });
 
-      test('U should navigate to / if no backPage set', () => {
+      test('U should navigate to root if no backPage set', () => {
         const relativeNavStub = sandbox.stub(Gerrit.Nav,
             'navigateToRelativeUrl');
         MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
         assert.isTrue(relativeNavStub.called);
-        assert.isTrue(relativeNavStub.lastCall.calledWithExactly('/'));
+        assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+            Gerrit.Nav.getUrlForRoot()));
       });
 
       test('U should navigate to backPage if set', () => {
@@ -263,7 +299,18 @@
       });
 
       test(', should open diff preferences', () => {
-        const stub = sandbox.stub(element.$.fileList.$.diffPreferences, 'open');
+        const stub = sandbox.stub(
+            element.$.fileList.$.diffPreferencesDialog, 'open');
+        element._loggedIn = false;
+        element.disableDiffPrefs = true;
+        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+        assert.isFalse(stub.called);
+
+        element._loggedIn = true;
+        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+        assert.isFalse(stub.called);
+
+        element.disableDiffPrefs = false;
         MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
         assert.isTrue(stub.called);
       });
@@ -276,11 +323,11 @@
         flushAsynchronousOperations();
 
         element.viewState.diffMode = 'SIDE_BY_SIDE';
-        element._handleMKey(e);
+        element._handleToggleDiffMode(e);
         assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
 
         element.viewState.diffMode = 'UNIFIED_DIFF';
-        element._handleMKey(e);
+        element._handleToggleDiffMode(e);
         assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
       });
     });
@@ -555,6 +602,7 @@
       };
       element._change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        owner: {email: 'abc@def'},
         revisions: {
           rev2: {_number: 2, commit: {parents: []}},
           rev1: {_number: 1, commit: {parents: []}},
@@ -621,12 +669,12 @@
         path: '/foo/bar.txt',
         text: 'hello',
       };
-      element._handleCommentSave({target: {comment: draft}});
+      element._handleCommentSave({detail: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
       draft.patch_set = null;
       draft.text = 'hello, there';
-      element._handleCommentSave({target: {comment: draft}});
+      element._handleCommentSave({detail: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
       const draft2 = {
@@ -635,14 +683,14 @@
         path: '/foo/bar.txt',
         text: 'hola',
       };
-      element._handleCommentSave({target: {comment: draft2}});
+      element._handleCommentSave({detail: {comment: draft2}});
       draft2.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
       draft.patch_set = null;
-      element._handleCommentDiscard({target: {comment: draft}});
+      element._handleCommentDiscard({detail: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-      element._handleCommentDiscard({target: {comment: draft2}});
+      element._handleCommentDiscard({detail: {comment: draft2}});
       assert.deepEqual(element._diffDrafts, {});
     });
 
@@ -840,6 +888,10 @@
       assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
       assert.isTrue(element._computeHideEditCommitMessage(true, false,
           _change));
+      assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
+          true));
+      assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
+          false));
     });
 
     test('_handleCommitMessageSave trims trailing whitespace', () => {
@@ -994,6 +1046,53 @@
       });
     });
 
+    test('_getBasePatchNum', () => {
+      const _change = {
+        _number: 42,
+        revisions: {
+          '98da160735fb81604b4c40e93c368f380539dd0e': {
+            _number: 1,
+            commit: {
+              parents: [],
+            },
+          },
+        },
+      };
+      const _patchRange = {
+        basePatchNum: 'PARENT',
+      };
+      assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+
+      element._prefs = {
+        default_base_for_merges: 'FIRST_PARENT',
+      };
+
+      const _change2 = {
+        _number: 42,
+        revisions: {
+          '98da160735fb81604b4c40e93c368f380539dd0e': {
+            _number: 1,
+            commit: {
+              parents: [
+                {
+                  commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
+                  subject: 'test',
+                },
+                {
+                  commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
+                  subject: 'test3',
+                },
+              ],
+            },
+          },
+        },
+      };
+      assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+
+      _patchRange.patchNum = 1;
+      assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+    });
+
     test('_openReplyDialog called with `ANY` when coming from tap event',
         () => {
           const openStub = sandbox.stub(element, '_openReplyDialog');
@@ -1208,24 +1307,6 @@
         updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
       });
 
-      test('_showRelatedToggle is reset when a new change is loaded', () => {
-        element._patchRange = {};
-        assert.isFalse(element._showRelatedToggle);
-        element._showRelatedToggle = true;
-        element._change = {
-          change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-          _number: 42,
-          revisions: {
-            rev1: {_number: 1, commit: {parents: []}},
-          },
-          current_revision: 'rev1',
-          status: 'NEW',
-          labels: {},
-          actions: {},
-        };
-        assert.isFalse(element._showRelatedToggle);
-      });
-
       test('relatedChangesToggle shown height greater than changeInfo height',
           () => {
             assert.isFalse(element.$.relatedChangesToggle.classList
@@ -1406,18 +1487,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));
       });
     });
 
@@ -1514,32 +1601,44 @@
       sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
 
       // Delete
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.DELETE.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.DELETE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(controls.openDeleteDialog.called);
       assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
 
       // Restore
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.RESTORE.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RESTORE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(controls.openRestoreDialog.called);
       assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
 
       // Rename
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.RENAME.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RENAME.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(controls.openRenameDialog.called);
       assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
 
       // Open
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.OPEN.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.OPEN.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(Gerrit.Nav.getEditUrlForDiff.called);
@@ -1549,8 +1648,8 @@
     });
 
     test('_selectedRevision updates when patchNum is changed', () => {
-      const revision1 = {_number: 1, commit: {}};
-      const revision2 = {_number: 2, commit: {}};
+      const revision1 = {_number: 1, commit: {parents: []}};
+      const revision2 = {_number: 2, commit: {parents: []}};
       sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
           Promise.resolve({
             revisions: {
@@ -1563,6 +1662,7 @@
             change_id: 'loremipsumdolorsitamet',
           }));
       sandbox.stub(element, '_getEdit').returns(Promise.resolve());
+      sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
       element._patchRange = {patchNum: '2'};
       return element._getChangeDetail().then(() => {
         assert.strictEqual(element._selectedRevision, revision2);
@@ -1731,5 +1831,18 @@
       assert.isTrue(setStub.calledOnce);
       assert.isTrue(setStub.calledWith(101, 'test-project'));
     });
+
+    test('_handleToggleStar called when star is tapped', () => {
+      element._change = {
+        owner: {_account_id: 1},
+        starred: false,
+      };
+      element._loggedIn = true;
+      const stub = sandbox.stub(element, '_handleToggleStar');
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.$.changeStar.$$('button'));
+      assert.isTrue(stub.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index a3d1ffa..f1d677e 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -17,8 +17,8 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -37,7 +37,7 @@
       }
       .file {
         border-top: 1px solid var(--border-color);
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         margin: 10px 0 3px;
         padding: 10px 0 5px;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index cbc7e42..660dfd9 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -18,6 +18,7 @@
   'use strict';
   Polymer({
     is: 'gr-comment-list',
+    _legacyUndefinedCheck: true,
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
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..c18ae8d 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-comment-list.html">
 
@@ -40,6 +42,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..902bf41 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
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<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">
 
@@ -39,7 +39,6 @@
           has-tooltip
           button-title="Copy full SHA to clipboard"
           hide-input
-          hide-label
           text="[[commitInfo.commit]]">
       </gr-copy-clipboard>
     </div>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
index 837de59..ddab319 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-commit-info',
+    _legacyUndefinedCheck: true,
 
     properties: {
       change: Object,
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
index c0a09f3..d25a871 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-commit-info</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="../../core/gr-router/gr-router.html">
 <link rel="import" href="gr-commit-info.html">
@@ -78,7 +80,7 @@
       assert.isOk(element._computeShowWebLink(element.change,
           element.commitInfo, element.serverConfig));
       assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), '../../link-url');
+          element.serverConfig), 'link-url');
     });
 
     test('does not relativize web links that begin with scheme', () => {
@@ -99,57 +101,6 @@
           element.serverConfig), 'https://link-url');
     });
 
-    test('use gitweb when available', () => {
-      const router = document.createElement('gr-router');
-      element.serverConfig = {gitweb: {
-        url: 'url-base/',
-        type: {revision: 'xx ${project} xx ${commit} xx'},
-      }};
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.commitInfo = {commit: 'commit-sha'};
-      element.change = {
-        project: 'project-name',
-        labels: [],
-        current_revision: element.commitInfo.commit,
-      };
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'url-base/xx project-name xx commit-sha xx');
-    });
-
-    test('prefer gitweb when both are available', () => {
-      const router = document.createElement('gr-router');
-      element.serverConfig = {gitweb: {
-        url: 'url-base/',
-        type: {revision: 'xx ${project} xx ${commit} xx'},
-      }};
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.commitInfo = {
-        commit: 'commit-sha',
-        web_links: [{url: 'link-url'}],
-      };
-      element.change = {
-        project: 'project-name',
-        labels: [],
-        current_revision: element.commitInfo.commit,
-      };
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-
-      const link = element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig);
-
-      assert.equal(link, 'url-base/xx project-name xx commit-sha xx');
-      assert.notEqual(link, '../../link-url');
-    });
 
     test('ignore web links that are neither gitweb nor gitiles', () => {
       const router = document.createElement('gr-router');
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index e420312..145d126 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -15,9 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<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-confirm-abandon-dialog">
@@ -52,7 +52,7 @@
         }
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Abandon"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
@@ -66,7 +66,7 @@
             placeholder="<Insert reasoning here>"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
   </template>
   <script src="gr-confirm-abandon-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index 03509ce..a371e13 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-confirm-abandon-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
index a5c047c..3a3cad6 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-abandon-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-abandon-dialog.html">
 
@@ -50,7 +52,7 @@
       const confirmHandler = sandbox.stub();
       element.addEventListener('confirm', confirmHandler);
       sandbox.stub(element, '_confirm');
-      element.$$('gr-confirm-dialog').fire('confirm');
+      element.$$('gr-dialog').fire('confirm');
       assert.isTrue(confirmHandler.called);
       assert.isTrue(element._confirm.called);
     });
@@ -59,7 +61,7 @@
       const cancelHandler = sandbox.stub();
       element.addEventListener('cancel', cancelHandler);
       sandbox.stub(element, '_handleCancelTap');
-      element.$$('gr-confirm-dialog').fire('cancel');
+      element.$$('gr-dialog').fire('cancel');
       assert.isTrue(cancelHandler.called);
       assert.isTrue(element._handleCancelTap.called);
     });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html
new file mode 100644
index 0000000..55b14b1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html
@@ -0,0 +1,51 @@
+<!--
+@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-dialog/gr-dialog.html">
+
+<dom-module id="gr-confirm-cherrypick-conflict-dialog">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+    </style>
+    <gr-dialog
+        confirm-label="Continue"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header" slot="header">Cherry Pick Conflict!</div>
+      <div class="main" slot="main">
+        <span>Cherry Pick failed! (merge conflicts)</span>
+
+        <span>Please select "Continue" to continue with conflicts or select "cancel" to close the dialog.</span>
+      </div>
+    </gr-dialog>
+  </template>
+  <script src="gr-confirm-cherrypick-conflict-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
new file mode 100644
index 0000000..2e8af0e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-cherrypick-conflict-dialog',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    _handleConfirmTap(e) {
+      e.preventDefault();
+      this.fire('confirm', null, {bubbles: false});
+    },
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
new file mode 100644
index 0000000..dbf332c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-cherrypick-conflict-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-confirm-cherrypick-conflict-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-cherrypick-conflict-dialog></gr-confirm-cherrypick-conflict-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('_handleConfirmTap', () => {
+      const confirmHandler = sandbox.stub();
+      element.addEventListener('confirm', confirmHandler);
+      sandbox.stub(element, '_handleConfirmTap');
+      element.$$('gr-dialog').fire('confirm');
+      assert.isTrue(confirmHandler.called);
+      assert.isTrue(element._handleConfirmTap.called);
+    });
+
+    test('_handleCancelTap', () => {
+      const cancelHandler = sandbox.stub();
+      element.addEventListener('cancel', cancelHandler);
+      sandbox.stub(element, '_handleCancelTap');
+      element.$$('gr-dialog').fire('cancel');
+      assert.isTrue(cancelHandler.called);
+      assert.isTrue(element._handleCancelTap.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
index 1590fd9..767b62d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -15,11 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-confirm-cherrypick-dialog">
@@ -59,7 +60,7 @@
         };
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Cherry Pick"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
@@ -74,6 +75,20 @@
             query="[[_query]]"
             placeholder="Destination branch">
         </gr-autocomplete>
+        <label for="baseInput">
+          Provide base commit sha1 for cherry-pick
+        </label>
+        <iron-input
+            maxlength="40"
+            placeholder="(optional)"
+            bind-value="{{baseCommit}}">
+          <input
+              is="iron-input"
+              id="baseCommitInput"
+              maxlength="40"
+              placeholder="(optional)"
+              bind-value="{{baseCommit}}">
+        </iron-input>
         <label for="messageInput">
           Cherry Pick Commit Message
         </label>
@@ -85,7 +100,7 @@
             max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-confirm-cherrypick-dialog.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index ea63dd5..fa281ec 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -21,6 +21,7 @@
 
   Polymer({
     is: 'gr-confirm-cherrypick-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
@@ -36,6 +37,7 @@
 
     properties: {
       branch: String,
+      baseCommit: String,
       changeStatus: String,
       commitMessage: String,
       commitNum: String,
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
index 5c51fe0..22a2aba 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-cherrypick-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-cherrypick-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
index 350af900..c07bb37 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-confirm-move-dialog">
@@ -58,7 +58,7 @@
         color: var(--error-text-color);
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Move Change"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
@@ -87,7 +87,7 @@
             max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-confirm-move-dialog.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
index f8d7151..093dca6 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
@@ -21,6 +21,7 @@
 
   Polymer({
     is: 'gr-confirm-move-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
index e619425..8d6e029 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-move-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-move-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
index ab4271d..116b26f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -15,9 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -51,7 +51,7 @@
         margin: .5em 0;
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         id="confirmDialog"
         confirm-label="Rebase"
         on-confirm="_handleConfirmTap"
@@ -113,7 +113,7 @@
           </gr-autocomplete>
         </div>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-confirm-rebase-dialog.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
index 29f8b95..1c3bcfb 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-confirm-rebase-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
index c6e9ec4..cd5b130 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-rebase-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-rebase-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index 07d9b83..c68912c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -15,9 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<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-confirm-revert-dialog">
@@ -47,7 +47,7 @@
         }
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Revert"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
@@ -63,7 +63,7 @@
             max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
   </template>
   <script src="gr-confirm-revert-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
index 1cae866..93e21c7 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -22,6 +22,7 @@
 
   Polymer({
     is: 'gr-confirm-revert-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the confirm button is pressed.
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
index c5a1bde..6e41555 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-revert-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-revert-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
new file mode 100644
index 0000000..42ecacf
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
@@ -0,0 +1,64 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-confirm-submit-dialog">
+  <template>
+    <style include="shared-styles">
+      #dialog {
+        min-width: 40em;
+      }
+      p {
+        margin-bottom: 1em;
+      }
+      @media screen and (max-width: 50em) {
+        #dialog {
+          min-width: inherit;
+          width: 100%;
+        }
+      }
+    </style>
+    <gr-dialog
+        id="dialog"
+        confirm-label="Continue"
+        confirm-on-enter
+        on-cancel="_handleCancelTap"
+        on-confirm="_handleConfirmTap">
+      <div class="header" slot="header">
+        [[action.label]]
+      </div>
+      <div class="main" slot="main">
+        <gr-endpoint-decorator name="confirm-submit-change">
+          <p>Ready to submit &ldquo;<strong>[[change.subject]]</strong>&rdquo;?</p>
+          <template is="dom-if" if="[[change.is_private]]">
+            <p><strong>Heads Up!</strong> Submitting this private change will also make it public.</p>
+          </template>
+          <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+          <gr-endpoint-param name="action" value="[[action]]"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    </gr-dialog>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-confirm-submit-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
new file mode 100644
index 0000000..93d38df
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-submit-dialog',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      /**
+       * @type {{
+       *    is_private: boolean,
+       *    subject: string,
+       *  }}
+       */
+      change: Object,
+
+      /**
+       * @type {{
+       *    label: string,
+       *  }}
+       */
+      action: Object,
+    },
+
+    resetFocus(e) {
+      this.$.dialog.resetFocus();
+    },
+
+    _handleConfirmTap(e) {
+      e.preventDefault();
+      this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
+    },
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
new file mode 100644
index 0000000..40fa29a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-submit-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/bower_components/page/page.js"></script>
+
+<link rel="import" href="gr-confirm-submit-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-submit-dialog></gr-confirm-submit-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-file-list-header tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('display', () => {
+      element.action = {label: 'my-label'};
+      element.change = {subject: 'my-subject'};
+      flushAsynchronousOperations();
+      const header = element.$$('.header');
+      assert.equal(header.textContent.trim(), 'my-label');
+
+      const message = element.$$('.main p');
+      assert.notEqual(message.textContent.length, 0);
+      assert.notEqual(message.textContent.indexOf('my-subject'), -1);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index 9085971..ab7525f 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -15,12 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
 
 <dom-module id="gr-download-dialog">
   <template>
@@ -28,21 +28,32 @@
       :host {
         background-color: var(--dialog-background-color);
         display: block;
-        padding: 1em;
       }
-      header {
+      section {
         display: flex;
+        padding: .5em 1.5em;
       }
-      footer {
+      section:not(:first-of-type) {
+        border-top: 1px solid var(--border-color);
+      }
+      .flexContainer {
         display: flex;
         justify-content: space-between;
         padding-top: .75em;
       }
+      .footer {
+        justify-content: flex-end;
+      }
       .closeButtonContainer {
+        align-items: flex-end;
         display: flex;
         flex: 0;
         justify-content: flex-end;
       }
+      .patchFiles,
+      .archivesContainer {
+        padding-bottom: .5em;
+      }
       .patchFiles {
         margin-right: 2em;
       }
@@ -56,27 +67,27 @@
         margin-right: 0;
       }
       .title {
-        text-align: center;
         flex: 1;
+        font-weight: var(--font-weight-bold);
+      }
+      .hidden {
+        display: none;
       }
     </style>
-    <header>
+    <section>
       <span class="title">
         Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
       </span>
-      <span class="closeButtonContainer">
-        <gr-button id="closeButton"
-            link
-            on-tap="_handleCloseTap">Close</gr-button>
-      </span>
-    </header>
-     <gr-download-commands
+    </section>
+    <section class$="[[_computeShowDownloadCommands(_schemes)]]">
+      <gr-download-commands
           id="downloadCommands"
           commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
           schemes="[[_schemes]]"
           selected-scheme="{{_selectedScheme}}"></gr-download-commands>
-    <footer>
-      <div class="patchFiles">
+    </section>
+    <section class="flexContainer">
+      <div class="patchFiles" hidden="[[_computeHidePatchFile(change, patchNum)]]" hidden>
         <label>Patch file</label>
         <div>
           <a
@@ -104,7 +115,14 @@
           </template>
         </div>
       </div>
-    </footer>
+    </section>
+    <section class="footer">
+      <span class="closeButtonContainer">
+        <gr-button id="closeButton"
+            link
+            on-tap="_handleCloseTap">Close</gr-button>
+      </span>
+    </section>
   </template>
   <script src="gr-download-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index fa3e5c9..9bfe3a9 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-download-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user presses the close button.
@@ -115,8 +116,8 @@
      * @return {string} Not sure why there was a mismatch
      */
     _computeDownloadLink(change, patchNum, opt_zip) {
-      return this.changeBaseURL(change._number, patchNum) + '/patch?' +
-          (opt_zip ? 'zip' : 'download');
+      return this.changeBaseURL(change.project, change._number, patchNum) +
+          '/patch?' + (opt_zip ? 'zip' : 'download');
     },
 
 
@@ -138,8 +139,19 @@
       return shortRev + '.diff.' + (opt_zip ? 'zip' : 'base64');
     },
 
+    _computeHidePatchFile(change, patchNum) {
+      for (const rev of Object.values(change.revisions || {})) {
+        if (this.patchNumEquals(rev._number, patchNum)) {
+          const parentLength = rev.commit && rev.commit.parents ?
+                rev.commit.parents.length : 0;
+          return parentLength == 0;
+        }
+      }
+      return false;
+    },
+
     _computeArchiveDownloadLink(change, patchNum, format) {
-      return this.changeBaseURL(change._number, patchNum) +
+      return this.changeBaseURL(change.project, change._number, patchNum) +
           '/archive?format=' + format;
     },
 
@@ -172,5 +184,9 @@
         this._selectedScheme = schemes.sort()[0];
       }
     },
+
+    _computeShowDownloadCommands(schemes) {
+      return schemes.length ? '' : 'hidden';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 84fe8a9..282d8de 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-download-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-download-dialog.html">
 
@@ -45,6 +47,9 @@
       revisions: {
         '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
           _number: 1,
+          commit: {
+            parents: [],
+          },
           fetch: {
             repo: {
               commands: {
@@ -105,6 +110,9 @@
       revisions: {
         '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
           _number: 1,
+          commit: {
+            parents: [],
+          },
           fetch: {},
         },
       },
@@ -172,8 +180,8 @@
 
       test('computed fields', () => {
         assert.equal(element._computeArchiveDownloadLink(
-            {_number: 123}, 2, 'tgz'),
-            '/changes/123/revisions/2/archive?format=tgz');
+            {project: 'test/project', _number: 123}, 2, 'tgz'),
+            '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
       });
 
       test('close event', done => {
@@ -183,5 +191,30 @@
         MockInteractions.tap(element.$$('.closeButtonContainer gr-button'));
       });
     });
+
+    test('_computeShowDownloadCommands', () => {
+      assert.equal(element._computeShowDownloadCommands([]), 'hidden');
+      assert.equal(element._computeShowDownloadCommands(['test']), '');
+    });
+
+    test('_computeHidePatchFile', () => {
+      const patchNum = '1';
+
+      const change1 = {
+        revisions: {
+          r1: {_number: 1, commit: {parents: []}},
+        },
+      };
+      assert.isTrue(element._computeHidePatchFile(change1, patchNum));
+
+      const change2 = {
+        revisions: {
+          r1: {_number: 1, commit: {parents: [
+            {commit: 'p1'},
+          ]}},
+        },
+      };
+      assert.isFalse(element._computeHidePatchFile(change2, patchNum));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index 3dc556d..bdb4295 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<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-navigation/gr-navigation.html">
@@ -28,7 +28,6 @@
 <link rel="import" href="../../shared/gr-select/gr-select.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/revision-info/revision-info.html">
 <link rel="import" href="../gr-file-list-constants.html">
 
 <dom-module id="gr-file-list-header">
@@ -78,13 +77,13 @@
         align-items: center;
         display: flex;
       }
-      .downloadContainer {
-        margin-right: 16px;
-      }
+      .downloadContainer,
+      .uploadContainer,
       .includedInContainer {
         margin-right: 16px;
       }
-      .includedInContainer.hide {
+      .includedInContainer.hide,
+      .uploadContainer.hide {
         display: none;
       }
       .rightControls {
@@ -136,7 +135,7 @@
         display: flex;
       }
       .label {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         margin-right: 24px;
       }
       gr-commit-info,
@@ -154,7 +153,10 @@
     </style>
     <div class$="patchInfo-header [[_computeEditModeClass(editMode)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]">
       <div class="patchInfo-left">
-        <h3 class="label">Files</h3>
+        <template is="dom-if"
+            if="[[showTitle]]">
+          <h3 class="label">Files</h3>
+        </template>
         <div class="patchInfoContent">
           <gr-patch-range-select
               id="rangeSelect"
@@ -164,7 +166,7 @@
               base-patch-num="[[basePatchNum]]"
               available-patches="[[allPatchSets]]"
               revisions="[[change.revisions]]"
-              revision-info="[[_revisionInfo]]"
+              revision-info="[[revisionInfo]]"
               on-patch-range-change="_handlePatchChange">
           </gr-patch-range-select>
           <span class="separator"></span>
@@ -211,6 +213,11 @@
               change="[[change]]"></gr-edit-controls>
           <span class="separator"></span>
         </span>
+        <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
+          <gr-button link
+              class="upload"
+              on-tap="_handleUploadTap">Update Change</gr-button>
+        </span>
         <span class="downloadContainer desktop">
           <gr-button link
               class="download"
@@ -244,10 +251,10 @@
           <gr-diff-mode-selector
               id="modeSelect"
               mode="{{diffViewMode}}"
-              save-on-change="[[loggedIn]]"></gr-diff-mode-selector>
+              save-on-change="[[!diffPrefsDisabled]]"></gr-diff-mode-selector>
           <span id="diffPrefsContainer"
               class="hideOnEdit"
-              hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
+              hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
               hidden>
             <gr-button
                 link
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
index 7c5cbc9..250900a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -19,9 +19,35 @@
 
   // Maximum length for patch set descriptions.
   const PATCH_DESC_MAX_LENGTH = 500;
+  const MERGED_STATUS = 'MERGED';
 
   Polymer({
     is: 'gr-file-list-header',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * @event expand-diffs
+     */
+
+    /**
+     * @event collapse-diffs
+     */
+
+    /**
+     * @event open-diff-prefs
+     */
+
+    /**
+     * @event open-included-in-dialog
+     */
+
+    /**
+     * @event open-download-dialog
+     */
+
+    /**
+     * @event open-upload-help-dialog
+     */
 
     properties: {
       account: Object,
@@ -37,6 +63,7 @@
       serverConfig: Object,
       shownFileCount: Number,
       diffPrefs: Object,
+      diffPrefsDisabled: Boolean,
       diffViewMode: {
         type: String,
         notify: true,
@@ -55,14 +82,15 @@
         type: String,
         value: '',
       },
+      showTitle: {
+        type: Boolean,
+        value: true,
+      },
       _descriptionReadOnly: {
         type: Boolean,
         computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
       },
-      _revisionInfo: {
-        type: Object,
-        computed: '_getRevisionInfo(change)',
-      },
+      revisionInfo: Object,
     },
 
     behaviors: [
@@ -161,11 +189,10 @@
           });
     },
 
-    _computePrefsButtonHidden(prefs, loggedIn) {
-      return !loggedIn || !prefs;
+    _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
+      return diffPrefsDisabled || !prefs;
     },
 
-
     _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
       return shownFileCount <= maxFilesForBulkActions;
     },
@@ -189,7 +216,8 @@
 
     _handleDownloadTap(e) {
       e.preventDefault();
-      this.fire('open-download-dialog');
+      this.dispatchEvent(
+          new CustomEvent('open-download-dialog', {bubbles: false}));
     },
 
     _computeEditModeClass(editMode) {
@@ -204,12 +232,24 @@
       return 'patchInfoOldPatchSet';
     },
 
-    _getRevisionInfo(change) {
-      return new Gerrit.RevisionInfo(change);
+    _hideIncludedIn(change) {
+      return change && change.status === MERGED_STATUS ? '' : 'hide';
     },
 
-    _hideIncludedIn(change) {
-      return change && change.status === 'MERGED' ? '' : 'hide';
+    _handleUploadTap(e) {
+      e.preventDefault();
+      this.dispatchEvent(
+          new CustomEvent('open-upload-help-dialog', {bubbles: false}));
+    },
+
+    _computeUploadHelpContainerClass(change, account) {
+      const changeIsMerged = change && change.status === MERGED_STATUS;
+      const ownerId = change && change.owner && change.owner._account_id ?
+          change.owner._account_id : null;
+      const userId = account && account._account_id;
+      const userIsOwner = ownerId && userId && ownerId === userId;
+      const hideContainer = !userIsOwner || changeIsMerged;
+      return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
index ba186b2..ac626ab 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-file-list-header</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="gr-file-list-header.html">
 
@@ -62,21 +64,21 @@
       });
     });
 
-    test('Diff preferences hidden when no prefs or logged out', () => {
-      element.loggedIn = false;
+    test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
+      element.diffPrefsDisabled = true;
       flushAsynchronousOperations();
       assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element.loggedIn = true;
+      element.diffPrefsDisabled = false;
       flushAsynchronousOperations();
       assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element.loggedIn = false;
+      element.diffPrefsDisabled = true;
       element.diffPrefs = {font_size: '12'};
       flushAsynchronousOperations();
       assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element.loggedIn = true;
+      element.diffPrefsDisabled = false;
       flushAsynchronousOperations();
       assert.isFalse(element.$.diffPrefsContainer.hidden);
     });
@@ -265,7 +267,7 @@
 
     suite('editMode behavior', () => {
       setup(() => {
-        element.loggedIn = true;
+        element.diffPrefsDisabled = false;
         element.diffPrefs = {};
       });
 
@@ -298,6 +300,17 @@
         flushAsynchronousOperations();
         assert.isFalse(isVisible(element.$.editControls.parentElement));
       });
+
+      test('_computeUploadHelpContainerClass', () => {
+        // Only show the upload helper button when an unmerged change is viewed
+        // by its owner.
+        const accountA = {_account_id: 1};
+        const accountB = {_account_id: 2};
+        assert.notInclude(element._computeUploadHelpContainerClass(
+            {owner: accountA}, accountA), 'hide');
+        assert.include(element._computeUploadHelpContainerClass(
+            {owner: accountA}, accountB), 'hide');
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index 41e5227..f087b21 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/async-foreach-behavior/async-foreach-behavior.html">
 <link rel="import" href="../../../behaviors/dom-util-behavior/dom-util-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
@@ -23,8 +23,9 @@
 <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-cursor/gr-diff-cursor.html">
+<link rel="import" href="../../diff/gr-diff-host/gr-diff-host.html">
+<link rel="import" href="../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.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">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
@@ -150,15 +151,15 @@
         min-width: 3.5em;
       }
       .added {
-        color: #388E3C;
+        color: var(--vote-text-color-recommended);
       }
       .removed {
-        color: #D32F2F;
+        color: var(--vote-text-color-disliked);
         text-align: left;
       }
       .drafts {
         color: #C62828;
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       .show-hide {
         margin-left: .35em;
@@ -178,10 +179,8 @@
         display: none;
       }
       label.show-hide {
-        color: var(--link-color);
         cursor: pointer;
         display: block;
-        font-size: var(--font-size-small);
         min-width: 2em;
       }
       gr-diff {
@@ -289,6 +288,7 @@
               data-path$="[[file.__path]]" tabindex="-1">
               <div class$="[[_computeClass('status', file.__path)]]"
                   tabindex="0"
+                  title$="[[_computeFileStatusLabel(file.status)]]"
                   aria-label$="[[_computeFileStatusLabel(file.status)]]">
               [[_computeFileStatus(file.status)]]
             </div>
@@ -365,7 +365,7 @@
               <span class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]">Reviewed</span>
               <label>
                 <input class="reviewed" type="checkbox" checked="[[file.isReviewed]]">
-                <span class="markReviewed" title="Mark as reviewed (shortcut: r)">[[_computeReviewedText(file.isReviewed)]]</span>
+                <span class="markReviewed" title$="[[_reviewedTitle(file.isReviewed)]]">[[_computeReviewedText(file.isReviewed)]]</span>
               </label>
             </div>
             <div class="editFileControls showOnEdit">
@@ -390,8 +390,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.*)]]"
@@ -400,10 +401,9 @@
                 path="[[file.__path]]"
                 prefs="[[diffPrefs]]"
                 project-name="[[change.project]]"
-                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>
@@ -465,10 +465,11 @@
         </gr-button><!--
   --></gr-tooltip-content>
     </div>
-    <gr-diff-preferences
-        id="diffPreferences"
-        prefs="{{diffPrefs}}"
-        local-prefs="{{_localPrefs}}"></gr-diff-preferences>
+    <gr-diff-preferences-dialog
+        id="diffPreferencesDialog"
+        diff-prefs="{{diffPrefs}}"
+        on-reload-diff-preference="_handleReloadingDiffPreference">
+    </gr-diff-preferences-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
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 e27a8ea..db41da0 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -35,6 +35,7 @@
     A: 'Added',
     C: 'Copied',
     D: 'Deleted',
+    M: 'Modified',
     R: 'Renamed',
     W: 'Rewritten',
     U: 'Unchanged',
@@ -62,6 +63,7 @@
 
   Polymer({
     is: 'gr-file-list',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a draft refresh should get triggered
@@ -124,7 +126,6 @@
       },
       /** @type {?} */
       _userPrefs: Object,
-      _localPrefs: Object,
       _showInlineDiffs: Boolean,
       numFilesShown: {
         type: Number,
@@ -196,23 +197,33 @@
     ],
 
     keyBindings: {
-      'shift+left': '_handleShiftLeftKey',
-      'shift+right': '_handleShiftRightKey',
-      'i:keyup': '_handleIKey',
-      'shift+i:keyup': '_handleCapitalIKey',
-      'down j': '_handleDownKey',
-      'up k': '_handleUpKey',
-      'c': '_handleCKey',
-      '[': '_handleLeftBracketKey',
-      ']': '_handleRightBracketKey',
-      'o': '_handleOKey',
-      'n': '_handleNKey',
-      'p': '_handlePKey',
-      'r': '_handleRKey',
-      'shift+a': '_handleCapitalAKey',
-      'esc': '_handleEscKey',
+      esc: '_handleEscKey',
     },
 
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
+        [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
+        [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+        [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+        [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+        [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+        [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
+        [this.Shortcut.PREV_LINE]: '_handleCursorPrev',
+        [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
+        [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+        [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+        [this.Shortcut.OPEN_FILE]: '_handleOpenFile',
+        [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+        [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+        [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+        [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+
+        // Final two are actually handled by gr-comment-thread.
+        [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+        [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+      };
+    },
     listeners: {
       keydown: '_scopedKeydownHandler',
     },
@@ -231,7 +242,7 @@
     _scopedKeydownHandler(e) {
       if (e.keyCode === 13) {
         // Enter.
-        this._handleOKey(e);
+        this._handleOpenFile(e);
       }
     },
 
@@ -258,7 +269,6 @@
         });
       }));
 
-      this._localPrefs = this.$.storage.getPreferences();
       promises.push(this._getDiffPreferences().then(prefs => {
         this.diffPrefs = prefs;
       }));
@@ -282,11 +292,13 @@
     },
 
     get diffs() {
-      return Polymer.dom(this.root).querySelectorAll('gr-diff');
+      // Polymer2: querySelectorAll returns NodeList instead of Array.
+      return Array.from(
+          Polymer.dom(this.root).querySelectorAll('gr-diff-host'));
     },
 
     openDiffPrefs() {
-      this.$.diffPreferences.open();
+      this.$.diffPreferencesDialog.open();
     },
 
     _calculatePatchChange(files) {
@@ -528,11 +540,14 @@
       // link, defer to the native behavior.
       if (!path || this.descendedFromClass(e.target, 'pathLink')) { return; }
 
+      // Disregard the event if the click target is in the edit controls.
+      if (this.descendedFromClass(e.target, 'editFileControls')) { return; }
+
       e.preventDefault();
       this._togglePathExpanded(path);
     },
 
-    _handleShiftLeftKey(e) {
+    _handleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
         return;
       }
@@ -541,7 +556,7 @@
       this.$.diffCursor.moveLeft();
     },
 
-    _handleShiftRightKey(e) {
+    _handleRightPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
         return;
       }
@@ -550,7 +565,7 @@
       this.$.diffCursor.moveRight();
     },
 
-    _handleIKey(e) {
+    _handleToggleInlineDiff(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e) ||
           this.$.fileCursor.index === -1) { return; }
@@ -559,14 +574,14 @@
       this._togglePathExpandedByIndex(this.$.fileCursor.index);
     },
 
-    _handleCapitalIKey(e) {
+    _handleToggleAllInlineDiffs(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this._toggleInlineDiffs();
     },
 
-    _handleDownKey(e) {
+    _handleCursorNext(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
         return;
       }
@@ -584,7 +599,7 @@
       }
     },
 
-    _handleUpKey(e) {
+    _handleCursorPrev(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
         return;
       }
@@ -602,7 +617,7 @@
       }
     },
 
-    _handleCKey(e) {
+    _handleNewComment(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -615,7 +630,7 @@
       }
     },
 
-    _handleLeftBracketKey(e) {
+    _handleOpenLastFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.getKeyboardEvent(e).metaKey) { return; }
@@ -624,7 +639,7 @@
       this._openSelectedFile(this._files.length - 1);
     },
 
-    _handleRightBracketKey(e) {
+    _handleOpenFirstFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.getKeyboardEvent(e).metaKey) { return; }
@@ -633,7 +648,7 @@
       this._openSelectedFile(0);
     },
 
-    _handleOKey(e) {
+    _handleOpenFile(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
       e.preventDefault();
@@ -646,7 +661,7 @@
       this._openSelectedFile();
     },
 
-    _handleNKey(e) {
+    _handleNextChunk(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
           this._noDiffsExpanded()) {
@@ -661,7 +676,7 @@
       }
     },
 
-    _handlePKey(e) {
+    _handlePrevChunk(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
           this._noDiffsExpanded()) {
@@ -676,7 +691,7 @@
       }
     },
 
-    _handleRKey(e) {
+    _handleToggleFileReviewed(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
         return;
       }
@@ -686,7 +701,7 @@
       this._reviewFile(this._files[this.$.fileCursor.index].__path);
     },
 
-    _handleCapitalAKey(e) {
+    _handleToggleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -802,7 +817,7 @@
 
     _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
       // Await all promises resolving from reload. @See Issue 9057
-      if (loading) { return; }
+      if (loading || !changeComments) { return; }
 
       const commentedPaths = changeComments.getPaths(patchRange);
       const files = Object.assign({}, filesByPath);
@@ -839,15 +854,16 @@
     },
 
     _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() {
       Polymer.dom.flush();
-      const files = Polymer.dom(this.root).querySelectorAll('.file-row');
+      // Polymer2: querySelectorAll returns NodeList instead of Array.
+      const files = Array.from(
+          Polymer.dom(this.root).querySelectorAll('.file-row'));
       this.$.fileCursor.stops = files;
       this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
     },
@@ -892,6 +908,12 @@
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
+    /**
+     * Get a descriptive label for use in the status indicator's tooltip and
+     * ARIA label.
+     * @param {string} status
+     * @return {string}
+     */
     _computeFileStatusLabel(status) {
       const statusCode = this._computeFileStatus(status);
       return FileStatus.hasOwnProperty(statusCode) ?
@@ -966,7 +988,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}
@@ -1228,5 +1250,19 @@
       }
       return '';
     },
+
+    _reviewedTitle(reviewed) {
+      if (reviewed) {
+        return 'Mark as not reviewed (shortcut: r)';
+      }
+
+      return 'Mark as reviewed (shortcut: r)';
+    },
+
+    _handleReloadingDiffPreference() {
+      this._getDiffPreferences().then(prefs => {
+        this.diffPrefs = prefs;
+      });
+    },
   });
 })();
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 5833a9a..9529d38 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
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-file-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 <link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
 <script src="../../../scripts/util.js"></script>
 
@@ -49,6 +51,24 @@
 
 <script>
   suite('gr-file-list tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
+    kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+    kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+    kb.bindShortcut(kb.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+    kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
+    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
+    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
+    kb.bindShortcut(kb.Shortcut.OPEN_LAST_FILE, '[');
+    kb.bindShortcut(kb.Shortcut.OPEN_FIRST_FILE, ']');
+    kb.bindShortcut(kb.Shortcut.OPEN_FILE, 'o');
+    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
+    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+
     let element;
     let commentApiWrapper;
     let sandbox;
@@ -69,7 +89,7 @@
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
       });
-      stub('gr-diff', {
+      stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
       });
 
@@ -80,7 +100,7 @@
       loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
 
       // Stub methods on the changeComments object after changeComments has
-      // been initalized.
+      // been initialized.
       commentApiWrapper.loadComments().then(() => {
         sandbox.stub(element.changeComments, 'getPaths').returns({});
         sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
@@ -534,6 +554,14 @@
               'unresolved.file', 'comment'), '3 comments (1 unresolved)');
     });
 
+    test('_reviewedTitle', () => {
+      assert.equal(
+          element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');
+
+      assert.equal(
+          element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
+    });
+
     suite('keyboard shortcuts', () => {
       setup(() => {
         element._filesByPath = {
@@ -668,7 +696,7 @@
         assert.equal(getNumReviewed(), 0);
       });
 
-      suite('_handleOKey', () => {
+      suite('_handleOpenFile', () => {
         let interact;
 
         setup(() => {
@@ -686,7 +714,7 @@
 
             const e = new CustomEvent('fake-keyboard-event', opt_payload);
             sinon.stub(e, 'preventDefault');
-            element._handleOKey(e);
+            element._handleOpenFile(e);
             assert.isTrue(e.preventDefault.called);
             const result = {};
             if (openCursorStub.called) {
@@ -797,6 +825,11 @@
       assert.isTrue(tapSpy.lastCall.args[0].defaultPrevented);
     });
 
+    test('_computeFileStatusLabel', () => {
+      assert.equal(element._computeFileStatusLabel('A'), 'Added');
+      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
+    });
+
     test('_handleFileListTap', () => {
       element._filesByPath = {
         '/COMMIT_MSG': {},
@@ -825,7 +858,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);
@@ -838,6 +871,28 @@
       assert.isTrue(reviewStub.calledOnce);
     });
 
+    test('_handleFileListTap editMode', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'f1.txt': {},
+        'f2.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.editMode = true;
+      flushAsynchronousOperations();
+      const tapSpy = sandbox.spy(element, '_handleFileListTap');
+      const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
+
+      // Tap the edit controls. Should be ignored by _handleFileListTap.
+      MockInteractions.tap(element.$$('.editFileControls'));
+      assert.isTrue(tapSpy.calledOnce);
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
     test('patch set from revisions', () => {
       const expected = [
         {num: 4, desc: 'test'},
@@ -997,6 +1052,8 @@
           done();
         },
         cancel() {},
+        getCursorStops() { return []; },
+        addEventListener(eventName, callback) { callback(new Event(eventName)); },
       }];
       sinon.stub(element, 'diffs', {
         get() { return diffs; },
@@ -1311,7 +1368,7 @@
         id: '503008e2_0ab203ee',
         line: 10,
         updated: '2018-02-14 22:07:43.000000000',
-        message: 'response',
+        message: 'a comment',
         unresolved: true,
       },
       {
@@ -1320,14 +1377,13 @@
         line: 20,
         in_reply_to: 'ecf0b9fa_fe1a5f62',
         updated: '2018-02-13 22:07:43.000000000',
-        message: 'a comments',
+        message: 'response',
         unresolved: true,
       },
     ];
 
     const setupDiff = function(diff) {
       const mock = document.createElement('mock-diff-response');
-      diff._diff = mock.diffResponse;
       diff.comments = {
         left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
         right: [],
@@ -1355,12 +1411,13 @@
         theme: 'DEFAULT',
         ignore_whitespace: 'IGNORE_NONE',
       };
-      diff._renderDiffTable();
+      diff._diff = mock.diffResponse;
+      diff.$.diff.flushDebouncer('renderDiffTable');
     };
 
     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]);
@@ -1383,7 +1440,7 @@
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
       });
-      stub('gr-diff', {
+      stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
       });
 
@@ -1396,7 +1453,7 @@
       sandbox.stub(element, '_reviewFile');
 
       // Stub methods on the changeComments object after changeComments has
-      // been initalized.
+      // been initialized.
       commentApiWrapper.loadComments().then(() => {
         sandbox.stub(element.changeComments, 'getPaths').returns({});
         sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
@@ -1517,7 +1574,7 @@
 
       setup(() => {
         sandbox.stub(element, '_renderInOrder').returns(Promise.resolve());
-        nKeySpy = sandbox.spy(element, '_handleNKey');
+        nKeySpy = sandbox.spy(element, '_handleNextChunk');
         nextCommentStub = sandbox.stub(element.$.diffCursor,
             'moveToNextCommentThread');
         nextChunkStub = sandbox.stub(element.$.diffCursor,
@@ -1604,11 +1661,11 @@
       const mockEvent = {preventDefault() {}};
 
       element._displayLine = false;
-      element._handleDownKey(mockEvent);
+      element._handleCursorNext(mockEvent);
       assert.isTrue(element._displayLine);
 
       element._displayLine = false;
-      element._handleUpKey(mockEvent);
+      element._handleCursorPrev(mockEvent);
       assert.isTrue(element._displayLine);
 
       element._displayLine = true;
@@ -1658,11 +1715,43 @@
     });
 
     test('reloadCommentsForThreadWithRootId', () => {
+      // Expand the commit message diff
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      const diffs = renderAndGetNewDiffs(0);
+      flushAsynchronousOperations();
+
+      // Two comment threads should be generated by renderAndGetNewDiffs
+      const threadEls = diffs[0].getThreadEls();
+      assert.equal(threadEls.length, 2);
+      const threadElsByRootId = new Map(
+          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
+
+      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
+      assert.equal(thread1.comments.length, 1);
+      assert.equal(thread1.comments[0].message, 'a comment');
+      assert.equal(thread1.comments[0].line, 10);
+
+      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
+      assert.equal(thread2.comments.length, 2);
+      assert.isTrue(thread2.comments[0].unresolved);
+      assert.equal(thread2.comments[0].message, 'another comment');
+      assert.equal(thread2.comments[0].line, 20);
+
       const commentStub =
           sandbox.stub(element.changeComments, 'getCommentsForThread');
       const commentStubRes1 = [
         {
           patch_set: 2,
+          id: '503008e2_0ab203ee',
+          line: 20,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'edited text',
+          unresolved: false,
+        },
+      ];
+      const commentStubRes2 = [
+        {
+          patch_set: 2,
           id: 'ecf0b9fa_fe1a5f62',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1673,6 +1762,7 @@
           patch_set: 2,
           id: '503008e2_0ab203ee',
           line: 10,
+          in_reply_to: 'ecf0b9fa_fe1a5f62',
           updated: '2018-02-14 22:07:43.000000000',
           message: 'response',
           unresolved: true,
@@ -1681,57 +1771,35 @@
           patch_set: 2,
           id: '503008e2_0ab203ef',
           line: 20,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
+          in_reply_to: '503008e2_0ab203ee',
           updated: '2018-02-15 22:07:43.000000000',
           message: 'a third comment in the thread',
           unresolved: true,
         },
       ];
-      const commentStubRes2 = [
-        {
-          patch_set: 2,
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'edited text',
-          unresolved: false,
-        },
-      ];
-      commentStub.withArgs('cc788d2c_cb1d728c').returns(
+      commentStub.withArgs('503008e2_0ab203ee').returns(
           commentStubRes1);
       commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
           commentStubRes2);
-      // Expand the commit message diff
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      const diffs = renderAndGetNewDiffs(0);
-      flushAsynchronousOperations();
-
-      // Two comment threads sould be generated
-      const commentThreadEls = diffs[0].getThreadEls();
-      assert(commentThreadEls[0].comments.length, 2);
-      assert(commentThreadEls[1].comments.length, 1);
-      assert.isTrue(commentThreadEls[1].comments[0].unresolved);
-      assert.equal(commentThreadEls[1].comments[0].message, 'another comment');
-
-      // Reload comments from the first comment thread, which should have a new
-      // reply.
-      element.reloadCommentsForThreadWithRootId('cc788d2c_cb1d728c',
-          '/COMMIT_MSG');
-      assert(commentThreadEls[0].comments.length, 3);
-
 
       // Reload comments from the first comment thread, which should have a
       // an updated message and a toggled resolve state.
+      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
+          '/COMMIT_MSG');
+      assert.equal(thread1.comments.length, 1);
+      assert.isFalse(thread1.comments[0].unresolved);
+      assert.equal(thread1.comments[0].message, 'edited text');
+
+      // Reload comments from the second comment thread, which should have a new
+      // reply.
       element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
           '/COMMIT_MSG');
-      assert(commentThreadEls[1].comments.length, 1);
-      assert.isFalse(commentThreadEls[1].comments[0].unresolved);
-      assert.equal(commentThreadEls[1].comments[0].message, 'edited text');
+      assert.equal(thread2.comments.length, 3);
 
       const commentStubCount = commentStub.callCount;
       const getThreadsSpy = sandbox.spy(diffs[0], 'getThreadEls');
 
-      // Should not be getting threadss when the file is not expanded.
+      // Should not be getting threads when the file is not expanded.
       element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
           'other/file');
       assert.isFalse(getThreadsSpy.called);
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
index b824f1c..d5d8128 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
@@ -77,11 +78,15 @@
             link
             on-tap="_handleCloseTap">Close</gr-button>
       </span>
-      <input
-          id="filterInput"
-          is="iron-input"
+      <iron-input
           placeholder="Filter"
           on-bind-value-changed="_onFilterChanged">
+        <input
+            id="filterInput"
+            is="iron-input"
+            placeholder="Filter"
+            on-bind-value-changed="_onFilterChanged">
+      </iron-input>
     </header>
     <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
     <template
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
index d2ff035..7755a60 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-included-in-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user presses the close button.
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
index 539011a..68c77e6 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-included-in-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-included-in-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
index 0ac5019..571ddf9 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-selector/iron-selector.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../../styles/gr-voting-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -61,7 +61,7 @@
           background-color: var(--button-background-color, var(--table-header-background-color));
           color: var(--primary-text-color);
           padding: .2em .85em;
-          @apply(--vote-chip-styles);
+          @apply --vote-chip-styles;
         }
       }
       gr-button.iron-selected.max {
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 396fed8..fe28a1d 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-label-score-row',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when any label is changed.
@@ -49,12 +50,12 @@
     },
 
     get selectedItem() {
-      if (!this._ironSelector) { return; }
+      if (!this._ironSelector) { return undefined; }
       return this._ironSelector.selectedItem;
     },
 
     get selectedValue() {
-      if (!this._ironSelector) { return; }
+      if (!this._ironSelector) { return undefined; }
       return this._ironSelector.selected;
     },
 
@@ -69,7 +70,8 @@
     },
 
     _computeBlankItems(permittedLabels, label, side) {
-      if (!permittedLabels || !permittedLabels[label] || !this.labelValues ||
+      if (!permittedLabels || !permittedLabels[label] ||
+          !permittedLabels[label].length || !this.labelValues ||
           !Object.keys(this.labelValues).length) {
         return [];
       }
@@ -131,11 +133,13 @@
       const name = e.target.selectedItem.name;
       const value = e.target.selectedItem.getAttribute('value');
       this.dispatchEvent(new CustomEvent(
-        'labels-changed', {detail: {name, value}, bubbles: true}));
+          'labels-changed',
+          {detail: {name, value}, bubbles: true, composed: true}));
     },
 
     _computeAnyPermittedLabelValues(permittedLabels, label) {
-      return permittedLabels.hasOwnProperty(label);
+      return permittedLabels.hasOwnProperty(label) &&
+        permittedLabels[label].length;
     },
 
     _computeHiddenClass(permittedLabels, label) {
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
index e5431f6..4920e20 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-score-row</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-label-score-row.html">
@@ -258,6 +260,11 @@
       flushAsynchronousOperations();
       assert.isOk(element.$$('iron-selector'));
       assert.isTrue(element.$$('iron-selector').hidden);
+
+      element.permittedLabels = {Verified: []};
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('iron-selector'));
+      assert.isTrue(element.$$('iron-selector').hidden);
     });
 
     test('asymetrical labels', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
index 7dd4c76..c607a9f 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-label-score-row/gr-label-score-row.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -28,6 +28,9 @@
         text-align: center;
         width: 100%;
       }
+      gr-label-score-row.no-access {
+        display: var(--label-no-access-display, initial);
+      }
       @media only screen and (max-width: 25em) {
         :host {
           text-align: center;
@@ -36,6 +39,7 @@
     </style>
     <template is="dom-repeat" items="[[_labels]]" as="label">
       <gr-label-score-row
+          class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
           label="[[label]]"
           name="[[label.name]]"
           labels="[[change.labels]]"
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
index 8734da2..f18680e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-label-scores',
+    _legacyUndefinedCheck: true,
     properties: {
       _labels: {
         type: Array,
@@ -114,5 +115,19 @@
     _changeIsMerged(changeStatus) {
       return changeStatus === 'MERGED';
     },
+
+    /**
+     * @param label {string|undefined}
+     * @param permittedLabels {Object|undefined}
+     * @return {string}
+     */
+    _computeLabelAccessClass(label, permittedLabels) {
+      if (label == null || permittedLabels == null) {
+        return '';
+      }
+
+      return permittedLabels.hasOwnProperty(label) &&
+        permittedLabels[label].length ? 'access' : 'no-access';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
index 24529ec..f986a58 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-scores</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-label-scores.html">
 
@@ -135,6 +137,25 @@
       });
     });
 
+    test('_computeLabelAccessClass undefined case', () => {
+      assert.strictEqual(
+          element._computeLabelAccessClass(undefined, undefined), '');
+      assert.strictEqual(
+          element._computeLabelAccessClass('', undefined), '');
+      assert.strictEqual(
+          element._computeLabelAccessClass(undefined, {}), '');
+    });
+
+    test('_computeLabelAccessClass has access', () => {
+      assert.strictEqual(
+          element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
+    });
+
+    test('_computeLabelAccessClass no access', () => {
+      assert.strictEqual(
+          element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
+    });
+
     test('changes in label score are reflected in _labels', () => {
       element.change = {
         _number: '123',
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 00157ab..df3cc37 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
 <link rel="import" href="../../shared/gr-account-label/gr-account-label.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
@@ -80,7 +80,7 @@
         width: 2.5em;
       }
       .name {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       .message {
         --gr-formatted-text-prose-max-width: 80ch;
@@ -120,6 +120,7 @@
         position: static;
       }
       .collapsed .author {
+        overflow: hidden;
         color: var(--primary-text-color);
         margin-right: .4em;
       }
@@ -132,9 +133,12 @@
         right: var(--default-horizontal-margin);
         top: 10px;
       }
-      .date {
+      span.date {
         color: var(--deemphasized-text-color);
       }
+      span.date:hover {
+        text-decoration: underline;
+      }
       .dateContainer iron-icon {
         cursor: pointer;
       }
@@ -163,7 +167,7 @@
       }
       gr-account-label {
         --gr-account-label-text-style: {
-          font-family: var(--font-family-bold);
+          font-weight: var(--font-weight-bold);
         };
       }
     </style>
@@ -227,12 +231,12 @@
             </span>
           </template>
           <template is="dom-if" if="[[message.id]]">
-            <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap">
+            <span class="date" on-tap="_handleAnchorTap">
               <gr-date-formatter
                   has-tooltip
                   show-date-and-time
                   date-str="[[message.date]]"></gr-date-formatter>
-            </a>
+            </span>
           </template>
           <iron-icon
               id="expandToggle"
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 0590c73..a2ec28c 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -22,12 +22,7 @@
 
   Polymer({
     is: 'gr-message',
-
-    /**
-     * Fired when this message's permalink is tapped.
-     *
-     * @event scroll-to
-     */
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when this message's reply link is tapped.
@@ -35,6 +30,12 @@
      * @event reply
      */
 
+    /**
+     * Fired when the message's timestamp is tapped.
+     *
+     * @event message-anchor-tap
+     */
+
     listeners: {
       tap: '_handleTap',
     },
@@ -223,22 +224,13 @@
       return classes.join(' ');
     },
 
-    _computeMessageHash(message) {
-      return '#message-' + message.id;
-    },
-
-    _handleLinkTap(e) {
+    _handleAnchorTap(e) {
       e.preventDefault();
-
-      this.fire('scroll-to', {message: this.message}, {bubbles: false});
-
-      const hash = this._computeMessageHash(this.message);
-      // Don't add the hash to the window history if it's already there.
-      // Otherwise you mess up expected back button behavior.
-      if (window.location.hash == hash) { return; }
-      // Change the URL but don’t trigger a nav event. Otherwise it will
-      // reload the page.
-      page.show(window.location.pathname + hash, null, false);
+      this.dispatchEvent(new CustomEvent('message-anchor-tap', {
+        bubbles: true,
+        composed: true,
+        detail: {id: this.message.id},
+      }));
     },
 
     _handleReplyTap(e) {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 870f366..ef5a756 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-message</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-message.html">
 
@@ -164,6 +166,24 @@
       });
     });
 
+    test('clicking on date link fires event', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        id: '47c43261_55aa2c41',
+      };
+      flushAsynchronousOperations();
+      const stub = sinon.stub();
+      element.addEventListener('message-anchor-tap', stub);
+      const dateEl = element.$$('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+    });
+
     test('votes', () => {
       element.message = {
         author: {},
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 0a7dacc..7545dd8 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.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="../gr-message/gr-message.html">
@@ -109,7 +109,7 @@
           hide-automated="[[_hideAutomated]]"
           project-name="[[projectName]]"
           show-reply-button="[[showReplyButtons]]"
-          on-scroll-to="_handleScrollTo"
+          on-message-anchor-tap="_handleAnchorTap"
           label-extremes="[[_labelExtremes]]"
           data-message-id$="[[message.id]]"></gr-message>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 89a3523..81c3322 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -27,6 +27,7 @@
 
   Polymer({
     is: 'gr-messages-list',
+    _legacyUndefinedCheck: true,
 
     properties: {
       changeNum: Number,
@@ -184,8 +185,8 @@
       this.handleExpandCollapse(!this._expanded);
     },
 
-    _handleScrollTo(e) {
-      this.scrollToMessage(e.detail.message.id);
+    _handleAnchorTap(e) {
+      this.scrollToMessage(e.detail.id);
     },
 
     _hasAutomatedMessages(messages) {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index 80d1b9d..d6b887f 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-messages-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="../../diff/gr-comment-api/gr-comment-api.html">
 
@@ -149,7 +151,7 @@
       element.messages = messages;
 
       // Stub methods on the changeComments object after changeComments has
-      // been initalized.
+      // been initialized.
       return commentApiWrapper.loadComments();
     });
 
@@ -466,7 +468,7 @@
       element.messages = messages;
 
       // Stub methods on the changeComments object after changeComments has
-      // been initalized.
+      // been initialized.
       return commentApiWrapper.loadComments();
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 721e38d..4a70506 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
@@ -61,6 +61,9 @@
         flex-shrink: 0;
         width: 1.2em;
       }
+      .note {
+        color: var(--error-text-color);
+      }
       .relatedChanges a {
         display: inline-block;
       }
@@ -70,7 +73,7 @@
       }
       .status {
         color: var(--deemphasized-text-color);
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         margin-left: .25em;
       }
       .notCurrent {
@@ -83,7 +86,7 @@
         color: #1b5e20;
       }
       .submittableCheck {
-        color: #388E3C;
+        color: var(--vote-text-color-recommended);
         display: none;
       }
       .submittableCheck.submittable {
@@ -118,9 +121,11 @@
           </div>
         </template>
       </section>
-      <section hidden$="[[!_submittedTogether.length]]" hidden>
+      <section
+          id="submittedTogether"
+          class$="[[_computeSubmittedTogetherClass(_submittedTogether)]]">
         <h4>Submitted together</h4>
-        <template is="dom-repeat" items="[[_submittedTogether]]" as="related">
+        <template is="dom-repeat" items="[[_submittedTogether.changes]]" as="related">
           <div class$="[[_computeChangeContainerClass(change, related)]]">
             <a href$="[[_computeChangeURL(related._number, related.project)]]"
                 class$="[[_computeLinkClass(related)]]"
@@ -133,6 +138,11 @@
                 class$="submittableCheck [[_computeLinkClass(related)]]">✓</span>
           </div>
         </template>
+        <template is="dom-if" if="[[_submittedTogether.non_visible_changes]]">
+          <div class="note">
+            [[_computeNonVisibleChangesNote(_submittedTogether.non_visible_changes)]]
+          </div>
+        </template>
       </section>
       <section hidden$="[[!_sameTopic.length]]" hidden>
         <h4>Same topic</h4>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index c2ce364..07bf139 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
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-related-changes-list',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a new section is loaded so that the change view can determine
@@ -57,9 +58,10 @@
         type: Object,
         value() { return {changes: []}; },
       },
+      /** @type {?} */
       _submittedTogether: {
-        type: Array,
-        value() { return []; },
+        type: Object,
+        value() { return {changes: []}; },
       },
       _conflicts: {
         type: Array,
@@ -81,7 +83,7 @@
     ],
 
     observers: [
-      '_resultsChanged(_relatedResponse.changes, _submittedTogether, ' +
+      '_resultsChanged(_relatedResponse, _submittedTogether, ' +
           '_conflicts, _cherryPicks, _sameTopic)',
     ],
 
@@ -90,7 +92,7 @@
       this.hidden = true;
 
       this._relatedResponse = {changes: []};
-      this._submittedTogether = [];
+      this._submittedTogether = {changes: []};
       this._conflicts = [];
       this._cherryPicks = [];
       this._sameTopic = [];
@@ -119,7 +121,7 @@
       ];
 
       // Get conflicts if change is open and is mergeable.
-      if (this.changeIsOpen(this.change.status) && this.mergeable) {
+      if (this.changeIsOpen(this.change) && this.mergeable) {
         promises.push(this._getConflicts().then(response => {
           // Because the server doesn't always return a response and the
           // template expects an array, always return an array.
@@ -189,7 +191,8 @@
     },
 
     _getChangesWithSameTopic() {
-      return this.$.restAPI.getChangesWithSameTopic(this.change.topic);
+      return this.$.restAPI.getChangesWithSameTopic(this.change.topic,
+          this.change._number);
     },
 
     /**
@@ -204,12 +207,46 @@
 
     _computeChangeContainerClass(currentChange, relatedChange) {
       const classes = ['changeContainer'];
-      if (relatedChange.change_id === currentChange.change_id) {
+      if (this._changesEqual(relatedChange, currentChange)) {
         classes.push('thisChange');
       }
       return classes.join(' ');
     },
 
+    /**
+     * Do the given objects describe the same change? Compares the changes by
+     * their numbers.
+     * @see /Documentation/rest-api-changes.html#change-info
+     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+     * @param {!Object} a Either ChangeInfo or RelatedChangeAndCommitInfo
+     * @param {!Object} b Either ChangeInfo or RelatedChangeAndCommitInfo
+     * @return {boolean}
+     */
+    _changesEqual(a, b) {
+      const aNum = this._getChangeNumber(a);
+      const bNum = this._getChangeNumber(b);
+      return aNum === bNum;
+    },
+
+    /**
+     * Get the change number from either a ChangeInfo (such as those included in
+     * SubmittedTogetherInfo responses) or get the change number from a
+     * RelatedChangeAndCommitInfo (such as those included in a
+     * RelatedChangesInfo response).
+     * @see /Documentation/rest-api-changes.html#change-info
+     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+     *
+     * @param {!Object} change Either a ChangeInfo or a
+     *     RelatedChangeAndCommitInfo object.
+     * @return {number}
+     */
+    _getChangeNumber(change) {
+      if (change.hasOwnProperty('_change_number')) {
+        return change._change_number;
+      }
+      return change._number;
+    },
+
     _computeLinkClass(change) {
       const statuses = [];
       if (change.status == this.ChangeStatus.ABANDONED) {
@@ -255,14 +292,14 @@
     _resultsChanged(related, submittedTogether, conflicts,
         cherryPicks, sameTopic) {
       const results = [
-        related,
-        submittedTogether,
+        related && related.changes,
+        submittedTogether && submittedTogether.changes,
         conflicts,
         cherryPicks,
         sameTopic,
       ];
       for (let i = 0; i < results.length; i++) {
-        if (results[i].length > 0) {
+        if (results[i] && results[i].length > 0) {
           this.hidden = false;
           this.fire('update', null, {bubbles: false});
           return;
@@ -278,6 +315,7 @@
     _computeConnectedRevisions(change, patchNum, relatedChanges) {
       const connected = [];
       let changeRevision;
+      if (!change) { return []; }
       for (const rev in change.revisions) {
         if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
           changeRevision = rev;
@@ -305,5 +343,19 @@
       }
       return connected;
     },
+
+    _computeSubmittedTogetherClass(submittedTogether) {
+      if (!submittedTogether || (
+          submittedTogether.changes.length === 0 &&
+          !submittedTogether.non_visible_changes)) {
+        return 'hidden';
+      }
+      return '';
+    },
+
+    _computeNonVisibleChangesNote(n) {
+      const noun = n === 1 ? 'change' : 'changes';
+      return `(+ ${n} non-visible ${noun})`;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index 3f3a255..06b7a5d 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-related-changes-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-related-changes-list.html">
 
@@ -223,13 +225,35 @@
     });
 
     test('_computeChangeContainerClass', () => {
-      const change1 = {change_id: 123};
-      const change2 = {change_id: 456};
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _change_number: 1};
+      const change3 = {change_id: 123, _number: 2};
 
       assert.notEqual(element._computeChangeContainerClass(
           change1, change1).indexOf('thisChange'), -1);
       assert.equal(element._computeChangeContainerClass(
           change1, change2).indexOf('thisChange'), -1);
+      assert.equal(element._computeChangeContainerClass(
+          change1, change3).indexOf('thisChange'), -1);
+    });
+
+    test('_changesEqual', () => {
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _number: 1};
+      const change3 = {change_id: 123, _number: 2};
+      const change4 = {change_id: 123, _change_number: 1};
+
+      assert.isTrue(element._changesEqual(change1, change1));
+      assert.isFalse(element._changesEqual(change1, change2));
+      assert.isFalse(element._changesEqual(change1, change3));
+      assert.isTrue(element._changesEqual(change2, change4));
+    });
+
+    test('_getChangeNumber', () => {
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _change_number: 1};
+      assert.equal(element._getChangeNumber(change1), 0);
+      assert.equal(element._getChangeNumber(change2), 1);
     });
 
     test('event for section loaded fires for each section ', () => {
@@ -360,7 +384,7 @@
           true);
     });
 
-    test('clear and empties', () => {
+    suite('hidden attribute and update event', () => {
       const changes = [{
         project: 'foo/bar',
         change_id: 'Ideadbeef',
@@ -375,33 +399,68 @@
         _current_revision_number: 1,
         status: 'NEW',
       }];
-      element._relatedResponse = {changes};
-      element._submittedTogether = changes;
-      element._conflicts = changes;
-      element._cherryPicks = changes;
-      element._sameTopic = changes;
 
-      element.hidden = false;
-      element.clear();
-      assert.isTrue(element.hidden);
-      assert.equal(element._relatedResponse.changes.length, 0);
-      assert.equal(element._submittedTogether.length, 0);
-      assert.equal(element._conflicts.length, 0);
-      assert.equal(element._cherryPicks.length, 0);
-      assert.equal(element._sameTopic.length, 0);
-    });
+      test('clear and empties', () => {
+        element._relatedResponse = {changes};
+        element._submittedTogether = {changes};
+        element._conflicts = changes;
+        element._cherryPicks = changes;
+        element._sameTopic = changes;
 
-    test('update fires', () => {
-      const updateHandler = sandbox.stub();
-      element.addEventListener('update', updateHandler);
+        element.hidden = false;
+        element.clear();
+        assert.isTrue(element.hidden);
+        assert.equal(element._relatedResponse.changes.length, 0);
+        assert.equal(element._submittedTogether.changes.length, 0);
+        assert.equal(element._conflicts.length, 0);
+        assert.equal(element._cherryPicks.length, 0);
+        assert.equal(element._sameTopic.length, 0);
+      });
 
-      element._resultsChanged([], [], [], [], []);
-      assert.isTrue(element.hidden);
-      assert.isFalse(updateHandler.called);
+      test('update fires', () => {
+        const updateHandler = sandbox.stub();
+        element.addEventListener('update', updateHandler);
 
-      element._resultsChanged([], [], [], [], ['test']);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
+        element._resultsChanged({}, {}, [], [], []);
+        assert.isTrue(element.hidden);
+        assert.isFalse(updateHandler.called);
+
+        element._resultsChanged({}, {}, [], [], ['test']);
+        assert.isFalse(element.hidden);
+        assert.isTrue(updateHandler.called);
+      });
+
+      suite('hiding and unhiding', () => {
+        test('related response', () => {
+          assert.isTrue(element.hidden);
+          element._resultsChanged({changes}, {}, [], [], []);
+          assert.isFalse(element.hidden);
+        });
+
+        test('submitted together', () => {
+          assert.isTrue(element.hidden);
+          element._resultsChanged({}, {changes}, [], [], []);
+          assert.isFalse(element.hidden);
+        });
+
+        test('conflicts', () => {
+          assert.isTrue(element.hidden);
+          element._resultsChanged({}, {}, changes, [], []);
+          assert.isFalse(element.hidden);
+        });
+
+        test('cherrypicks', () => {
+          assert.isTrue(element.hidden);
+          element._resultsChanged({}, {}, [], changes, []);
+          assert.isFalse(element.hidden);
+        });
+
+        test('same topic', () => {
+          assert.isTrue(element.hidden);
+          element._resultsChanged({}, {}, [], [], changes);
+          assert.isFalse(element.hidden);
+        });
+      });
     });
 
     test('_computeChangeURL uses Gerrit.Nav', () => {
@@ -409,5 +468,83 @@
       element._computeChangeURL(123, 'abc/def', 12);
       assert.isTrue(getUrlStub.called);
     });
+
+    suite('submitted together changes', () => {
+      const change = {
+        project: 'foo/bar',
+        change_id: 'Ideadbeef',
+        commit: {
+          commit: 'deadbeef',
+          parents: [{commit: 'abc123'}],
+          author: {},
+          subject: 'do that thing',
+        },
+        _change_number: 12345,
+        _revision_number: 1,
+        _current_revision_number: 1,
+        status: 'NEW',
+      };
+
+      test('_computeSubmittedTogetherClass', () => {
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass(undefined),
+            'hidden');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({changes: []}),
+            'hidden');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({changes: [{}]}),
+            '');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({
+              changes: [],
+              non_visible_changes: 0,
+            }),
+            'hidden');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({
+              changes: [],
+              non_visible_changes: 1,
+            }),
+            '');
+        assert.strictEqual(
+            element._computeSubmittedTogetherClass({
+              changes: [{}],
+              non_visible_changes: 1,
+            }),
+            '');
+      });
+
+      test('no submitted together changes', () => {
+        flushAsynchronousOperations();
+        assert.include(element.$.submittedTogether.className, 'hidden');
+      });
+
+      test('no non-visible submitted together changes', () => {
+        element._submittedTogether = {changes: [change]};
+        flushAsynchronousOperations();
+        assert.notInclude(element.$.submittedTogether.className, 'hidden');
+        assert.isNull(element.$$('.note'));
+      });
+
+      test('no visible submitted together changes', () => {
+        // Technically this should never happen, but worth asserting the logic.
+        element._submittedTogether = {changes: [], non_visible_changes: 1};
+        flushAsynchronousOperations();
+        assert.notInclude(element.$.submittedTogether.className, 'hidden');
+        assert.isNotNull(element.$$('.note'));
+        assert.strictEqual(
+            element.$$('.note').innerText, '(+ 1 non-visible change)');
+      });
+
+      test('visible and non-visible submitted together changes', () => {
+        element._submittedTogether = {changes: [change], non_visible_changes: 2};
+        flushAsynchronousOperations();
+        assert.notInclude(element.$.submittedTogether.className, 'hidden');
+        assert.isNotNull(element.$$('.note'));
+        assert.strictEqual(
+            element.$$('.note').innerText, '(+ 2 non-visible changes)');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
index eb06b0f..5e5a89e 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reply-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="../../plugins/gr-plugin-host/gr-plugin-host.html">
@@ -86,7 +88,6 @@
           '+1',
         ],
       };
-      element.serverConfig = {note_db_enabled: true};
       sandbox.stub(element, 'fetchChangeUpdates')
           .returns(Promise.resolve({isLatest: true}));
     };
@@ -150,7 +151,8 @@
           flush(() => {
             const textarea = element.$.textarea.getNativeTextarea();
             textarea.value = 'LGTM';
-            textarea.dispatchEvent(new CustomEvent('input', {bubbles: true}));
+            textarea.dispatchEvent(new CustomEvent(
+                'input', {bubbles: true, composed: true}));
             const labelScoreRows = Polymer.dom(element.$.labelScores.root)
                 .querySelector('gr-label-score-row[name="Code-Review"]');
             const selectedBtn = Polymer.dom(labelScoreRows.root)
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index c9f0a16..6612180 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -15,12 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
@@ -104,7 +104,7 @@
         margin-top: 1em;
       }
       .groupName {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       .groupSize {
         font-style: italic;
@@ -171,21 +171,19 @@
               on-account-text-changed="_handleAccountTextEntry">
           </gr-account-list>
         </div>
-        <template is="dom-if" if="[[serverConfig.note_db_enabled]]">
-          <div class="peopleList">
-            <div class="peopleListLabel">CC</div>
-            <gr-account-list
-                id="ccs"
-                accounts="{{_ccs}}"
-                change="[[change]]"
-                filter="[[filterCCSuggestion]]"
-                pending-confirmation="{{_ccPendingConfirmation}}"
-                allow-any-input
-                placeholder="Add CC..."
-                on-account-text-changed="_handleAccountTextEntry">
-            </gr-account-list>
-          </div>
-        </template>
+        <div class="peopleList">
+          <div class="peopleListLabel">CC</div>
+          <gr-account-list
+              id="ccs"
+              accounts="{{_ccs}}"
+              change="[[change]]"
+              filter="[[filterCCSuggestion]]"
+              pending-confirmation="{{_ccPendingConfirmation}}"
+              allow-any-input
+              placeholder="Add CC..."
+              on-account-text-changed="_handleAccountTextEntry">
+          </gr-account-list>
+        </div>
         <gr-overlay
             id="reviewerConfirmationOverlay"
             on-iron-overlay-canceled="_cancelPendingReviewer">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 65d681d..cd93a2c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -43,7 +43,7 @@
   };
 
   const ButtonTooltips = {
-    SAVE: 'Save reply but do not send',
+    SAVE: 'Save reply but do not send notification',
     START_REVIEW: 'Mark as ready for review and send reply',
     SEND: 'Send reply',
   };
@@ -58,6 +58,7 @@
 
   Polymer({
     is: 'gr-reply-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a reply is successfully sent.
@@ -141,10 +142,6 @@
       },
       permittedLabels: Object,
       /**
-       * @type {{ note_db_enabled: boolean }}
-       */
-      serverConfig: Object,
-      /**
        * @type {{ commentlinks: Array }}
        */
       projectConfig: Object,
@@ -194,10 +191,6 @@
         type: String,
         computed: '_computeSendButtonLabel(canBeStarted)',
       },
-      _ccsEnabled: {
-        type: Boolean,
-        computed: '_computeCCsEnabled(serverConfig)',
-      },
       _savingComments: Boolean,
       _reviewersMutated: {
         type: Boolean,
@@ -244,7 +237,7 @@
     },
 
     observers: [
-      '_changeUpdated(change.reviewers.*, change.owner, serverConfig)',
+      '_changeUpdated(change.reviewers.*, change.owner)',
       '_ccsChanged(_ccs.splices)',
       '_reviewersChanged(_reviewers.splices)',
     ],
@@ -578,31 +571,31 @@
       //
       this.disabled = false;
 
-      if (response.status !== 400) {
-        // This is all restAPI does when there is no custom error handling.
-        this.fire('server-error', {response});
-        return response;
-      }
-
-      // Process the response body, format a better error message, and fire
-      // an event for gr-event-manager to display.
-      const jsonPromise = this.$.restAPI.getResponseObject(response);
+      // Using response.clone() here, because getResponseObject() and
+      // potentially the generic error handler will want to call text() on the
+      // response object, which can only be done once per object.
+      const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
       return jsonPromise.then(result => {
-        const errors = [];
-        for (const state of ['reviewers', 'ccs']) {
-          if (!result.hasOwnProperty(state)) { continue; }
-          for (const reviewer of Object.values(result[state])) {
-            if (reviewer.error) {
-              errors.push(reviewer.error);
+        // Only perform custom error handling for 400s and a parseable
+        // ReviewResult response.
+        if (response.status === 400 && result) {
+          const errors = [];
+          for (const state of ['reviewers', 'ccs']) {
+            if (!result.hasOwnProperty(state)) { continue; }
+            for (const reviewer of Object.values(result[state])) {
+              if (reviewer.error) {
+                errors.push(reviewer.error);
+              }
             }
           }
+          response = {
+            ok: false,
+            status: response.status,
+            text() { return Promise.resolve(errors.join(', ')); },
+          };
         }
-        response = {
-          ok: false,
-          status: response.status,
-          text() { return Promise.resolve(errors.join(', ')); },
-        };
         this.fire('server-error', {response});
+        return null; // Means that the error has been handled.
       });
     },
 
@@ -628,14 +621,14 @@
         'Say something nice...';
     },
 
-    _changeUpdated(changeRecord, owner, serverConfig) {
-      this._rebuildReviewerArrays(changeRecord.base, owner, serverConfig);
+    _changeUpdated(changeRecord, owner) {
+      this._rebuildReviewerArrays(changeRecord.base, owner);
     },
 
-    _rebuildReviewerArrays(change, owner, serverConfig) {
+    _rebuildReviewerArrays(change, owner) {
       this._owner = owner;
 
-      let reviewers = [];
+      const reviewers = [];
       const ccs = [];
 
       for (const key in change) {
@@ -660,12 +653,7 @@
         }
       }
 
-      if (this._ccsEnabled) {
-        this._ccs = ccs;
-      } else {
-        this._ccs = [];
-        reviewers = reviewers.concat(ccs);
-      }
+      this._ccs = ccs;
       this._reviewers = reviewers;
     },
 
@@ -718,13 +706,12 @@
       this.fire('cancel', null, {bubbles: false});
       this.$.textarea.closeDropdown();
       this._purgeReviewersPendingRemove(true);
-      this._rebuildReviewerArrays(this.change.reviewers, this._owner,
-          this.serverConfig);
+      this._rebuildReviewerArrays(this.change.reviewers, this._owner);
     },
 
     _saveTapHandler(e) {
       e.preventDefault();
-      if (this._ccsEnabled && !this.$$('#ccs').submitEntryText()) {
+      if (!this.$$('#ccs').submitEntryText()) {
         // Do not proceed with the save if there is an invalid email entry in
         // the text field of the CC entry.
         return;
@@ -740,7 +727,7 @@
     },
 
     _submit() {
-      if (this._ccsEnabled && !this.$$('#ccs').submitEntryText()) {
+      if (!this.$$('#ccs').submitEntryText()) {
         // Do not proceed with the send if there is an invalid email entry in
         // the text field of the CC entry.
         return;
@@ -748,6 +735,7 @@
       if (this._sendDisabled) {
         this.dispatchEvent(new CustomEvent('show-alert', {
           bubbles: true,
+          composed: true,
           detail: {message: EMPTY_REPLY_MESSAGE},
         }));
         return;
@@ -755,6 +743,13 @@
       return this.send(this._includeComments, this.canBeStarted)
           .then(keepReviewers => {
             this._purgeReviewersPendingRemove(false, keepReviewers);
+          })
+          .catch(err => {
+            this.dispatchEvent(new CustomEvent('show-error', {
+              bubbles: true,
+              composed: true,
+              detail: {message: `Error submitting review ${err}`},
+            }));
           });
     },
 
@@ -854,10 +849,6 @@
       return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
     },
 
-    _computeCCsEnabled(serverConfig) {
-      return serverConfig && serverConfig.note_db_enabled;
-    },
-
     _computeSavingLabelClass(savingComments) {
       return savingComments ? 'saving' : '';
     },
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index f9108c7..e137eef 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reply-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-reply-dialog.html">
 
@@ -33,6 +35,25 @@
 </test-fixture>
 
 <script>
+  function cloneableResponse(status, text) {
+    return {
+      ok: false,
+      status,
+      text() {
+        return Promise.resolve(text);
+      },
+      clone() {
+        return {
+          ok: false,
+          status,
+          text() {
+            return Promise.resolve(text);
+          },
+        };
+      },
+    };
+  }
+
   suite('gr-reply-dialog tests', () => {
     let element;
     let changeNum;
@@ -97,7 +118,6 @@
           '+1',
         ],
       };
-      element.serverConfig = {};
 
       getDraftCommentStub = sandbox.stub(element.$.storage, 'getDraftComment');
       setDraftCommentStub = sandbox.stub(element.$.storage, 'setDraftComment');
@@ -296,7 +316,6 @@
       const noButton =
           element.$$('.reviewerConfirmationButtons gr-button:last-child');
 
-      element.serverConfig = {note_db_enabled: true};
       element._ccPendingConfirmation = null;
       element._reviewerPendingConfirmation = null;
       flushAsynchronousOperations();
@@ -409,13 +428,12 @@
     });
 
     test('_reviewersMutated when account-text-change is fired from ccs', () => {
-      element.serverConfig = {note_db_enabled: true};
       flushAsynchronousOperations();
       assert.isFalse(element._reviewersMutated);
       assert.isTrue(element.$$('#ccs').allowAnyInput);
       assert.isFalse(element.$$('#reviewers').allowAnyInput);
       element.$$('#ccs').dispatchEvent(new CustomEvent('account-text-changed',
-          {bubbles: true}));
+          {bubbles: true, composed: true}));
       assert.isTrue(element._reviewersMutated);
     });
 
@@ -474,11 +492,7 @@
       sandbox.stub(window, 'fetch', () => {
         const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
           '"ccs":{"id2":{"error":"second error"}}}';
-        return Promise.resolve({
-          ok: false,
-          status: 400,
-          text() { return Promise.resolve(text); },
-        });
+        return Promise.resolve(cloneableResponse(400, text));
       });
 
       element.addEventListener('server-error', event => {
@@ -496,17 +510,25 @@
       flush(() => { element.send(); });
     });
 
-    test('ccs are displayed if NoteDb is enabled', () => {
-      function hasCc() {
-        flushAsynchronousOperations();
-        return !!element.$$('#ccs');
-      }
+    test('non-json 400 is treated as a normal server-error', done => {
+      sandbox.stub(window, 'fetch', () => {
+        const text = 'Comment validation error!';
+        return Promise.resolve(cloneableResponse(400, text));
+      });
 
-      element.serverConfig = {};
-      assert.isFalse(hasCc());
+      element.addEventListener('server-error', event => {
+        if (event.target !== element) {
+          return;
+        }
+        event.detail.response.text().then(body => {
+          assert.equal(body, 'Comment validation error!');
+          done();
+        });
+      });
 
-      element.serverConfig = {note_db_enabled: true};
-      assert.isTrue(hasCc());
+      // Async tick is needed because iron-selector content is distributed and
+      // distributed content requires an observer to be set up.
+      flush(() => { element.send(); });
     });
 
     test('filterReviewerSuggestion', () => {
@@ -540,7 +562,6 @@
 
     test('_focusOn', () => {
       sandbox.spy(element, '_chooseFocusTarget');
-      element.serverConfig = {note_db_enabled: true};
       flushAsynchronousOperations();
       const textareaStub = sandbox.stub(element.$.textarea, 'async');
       const reviewerEntryStub = sandbox.stub(element.$.reviewers.focusStart,
@@ -680,7 +701,6 @@
     });
 
     test('moving from cc to reviewer', () => {
-      element.serverConfig = {note_db_enabled: true};
       element._reviewersPendingRemove = {
         CC: [],
         REVIEWER: [],
@@ -714,7 +734,6 @@
     });
 
     test('migrate reviewers between states', done => {
-      element.serverConfig = {note_db_enabled: true};
       element._reviewersPendingRemove = {
         CC: [],
         REVIEWER: [],
@@ -820,60 +839,50 @@
       const error1 = 'error 1';
       const error2 = 'error 2';
       const error3 = 'error 3';
-      const response = {
-        status: 400,
-        text() {
-          return Promise.resolve(')]}\'' + JSON.stringify({
-            reviewers: {
-              username1: {
-                input: 'user 1',
-                error: error1,
-              },
-              username2: {
-                input: 'user 2',
-                error: error2,
-              },
-            },
-            ccs: {
-              username3: {
-                input: 'user 3',
-                error: error3,
-              },
-            },
-          }));
+      const text = ')]}\'' + JSON.stringify({
+        reviewers: {
+          username1: {
+            input: 'user 1',
+            error: error1,
+          },
+          username2: {
+            input: 'user 2',
+            error: error2,
+          },
         },
-      };
+        ccs: {
+          username3: {
+            input: 'user 3',
+            error: error3,
+          },
+        },
+      });
       element.addEventListener('server-error', e => {
         e.detail.response.text().then(text => {
           assert.equal(text, [error1, error2, error3].join(', '));
           done();
         });
       });
-      element._handle400Error(response);
+      element._handle400Error(cloneableResponse(400, text));
     });
 
     test('_handle400Error CCs only', done => {
       const error1 = 'error 1';
-      const response = {
-        status: 400,
-        text() {
-          return Promise.resolve(')]}\'' + JSON.stringify({
-            ccs: {
-              username1: {
-                input: 'user 1',
-                error: error1,
-              },
-            },
-          }));
+      const text = ')]}\'' + JSON.stringify({
+        ccs: {
+          username1: {
+            input: 'user 1',
+            error: error1,
+          },
         },
-      };
+      });
       element.addEventListener('server-error', e => {
         e.detail.response.text().then(text => {
           assert.equal(text, error1);
           done();
         });
       });
-      element._handle400Error(response);
+      element._handle400Error(cloneableResponse(400, text));
     });
 
     test('fires height change when the drafts load', done => {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 73e8bea..46f973e 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -15,8 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.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">
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index ab1f55e..1cae50a 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-reviewer-list',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the "Add reviewer..." button is tapped.
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 1a406c9..80359e0 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-reviewer-list.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
index 2201a9a..50782be 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
@@ -15,10 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../diff/gr-diff-comment-thread/gr-diff-comment-thread.html">
+<link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
 
 <dom-module id="gr-thread-list">
   <template>
@@ -28,7 +28,7 @@
         min-height: 20rem;
         padding: 1rem;
       }
-      gr-diff-comment-thread {
+      gr-comment-thread {
         display: block;
         margin-bottom: .5rem;
         max-width: 80ch;
@@ -54,9 +54,9 @@
         display: flex;
         margin-right: 1rem;
       }
-      .draftsOnly:not(.unresolvedOnly) gr-diff-comment-thread[has-draft],
-      .unresolvedOnly:not(.draftsOnly) gr-diff-comment-thread[unresolved],
-      .draftsOnly.unresolvedOnly gr-diff-comment-thread[has-draft][unresolved] {
+      .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
+      .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
+      .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
         display: block
       }
     </style>
@@ -82,7 +82,7 @@
           as="thread"
           initial-count="5"
           target-framerate="60">
-        <gr-diff-comment-thread
+        <gr-comment-thread
             show-file-path
             change-num="[[changeNum]]"
             comments="[[thread.comments]]"
@@ -94,7 +94,7 @@
             path="[[thread.path]]"
             root-id="{{thread.rootId}}"
             on-thread-changed="_handleCommentsChanged"
-            on-thread-discard="_handleThreadDiscard"></gr-diff-comment-thread>
+            on-thread-discard="_handleThreadDiscard"></gr-comment-thread>
       </template>
     </div>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index 69d77f6..8f15a06 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -25,6 +25,7 @@
 
   Polymer({
     is: 'gr-thread-list',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /** @type {?} */
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
index 804446a..bd4a6ac 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-thread-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-thread-list.html">
 
@@ -171,7 +173,7 @@
       ];
       flushAsynchronousOperations();
       threadElements = Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread');
+          .querySelectorAll('gr-comment-thread');
     });
 
     teardown(() => {
@@ -188,7 +190,7 @@
 
     test('there are five threads by default', () => {
       assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread').length, 5);
+          .querySelectorAll('gr-comment-thread').length, 5);
     });
 
     test('_computeSortedThreads', () => {
@@ -231,14 +233,14 @@
       MockInteractions.tap(element.$.unresolvedToggle);
       flushAsynchronousOperations();
       assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread').length, 3);
+          .querySelectorAll('gr-comment-thread').length, 3);
     });
 
     test('toggle drafts only shows threads with draft comments', () => {
       MockInteractions.tap(element.$.draftToggle);
       flushAsynchronousOperations();
       assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread').length, 2);
+          .querySelectorAll('gr-comment-thread').length, 2);
     });
 
     test('toggle drafts and unresolved only shows threads with drafts and ' +
@@ -247,7 +249,7 @@
       MockInteractions.tap(element.$.unresolvedToggle);
       flushAsynchronousOperations();
       assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread').length, 2);
+          .querySelectorAll('gr-comment-thread').length, 2);
     });
 
     test('modification events are consumed and displatched', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
new file mode 100644
index 0000000..ff4ab39
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
@@ -0,0 +1,81 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-upload-help-dialog">
+  <template>
+    <style include="shared-styles">
+      :host {
+        background-color: var(--dialog-background-color);
+        display: block;
+      }
+      .main {
+        width: 100%;
+      }
+      ol {
+        margin-left: 1em;
+        list-style: decimal;
+      }
+      p {
+        margin-bottom: .75em;
+      }
+    </style>
+    <gr-dialog
+        confirm-label="Done"
+        cancel-label=""
+        on-confirm="_handleCloseTap">
+      <div class="header" slot="header">How to update this change:</div>
+      <div class="main" slot="main">
+        <ol>
+          <li>
+            <p>
+              Checkout this change locally and make your desired modifications
+              to the files.
+            </p>
+            <template is="dom-if" if="[[_fetchCommand]]">
+              <gr-shell-command command="[[_fetchCommand]]"></gr-shell-command>
+            </template>
+          </li>
+          <li>
+            <p>
+              Update the local commit with your modifications using the following
+              command.
+            </p>
+            <gr-shell-command command="[[_commitCommand]]"></gr-shell-command>
+            <p>
+              Leave the "Change-Id:" line of the commit message as is.
+            </p>
+          </li>
+          <li>
+            <p>Push the updated commit to Gerrit.</p>
+            <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
+          </li>
+          <li>
+            <p>Refresh this page to view the the update.</p>
+          </li>
+        </ol>
+      </div>
+    </gr-dialog>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-upload-help-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
new file mode 100644
index 0000000..df96be2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
+  const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
+
+  // Command names correspond to download plugin definitions.
+  const PREFERRED_FETCH_COMMAND_ORDER = [
+    'checkout',
+    'cherry pick',
+    'pull',
+  ];
+
+  Polymer({
+    is: 'gr-upload-help-dialog',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the user presses the close button.
+     *
+     * @event close
+     */
+
+    properties: {
+      revision: Object,
+      targetBranch: String,
+      _commitCommand: {
+        type: String,
+        value: COMMIT_COMMAND,
+        readOnly: true,
+      },
+      _fetchCommand: {
+        type: String,
+        computed: '_computeFetchCommand(revision, ' +
+            '_preferredDownloadCommand, _preferredDownloadScheme)',
+      },
+      _preferredDownloadCommand: String,
+      _preferredDownloadScheme: String,
+      _pushCommand: {
+        type: String,
+        computed: '_computePushCommand(targetBranch)',
+      },
+    },
+
+    attached() {
+      this.$.restAPI.getLoggedIn().then(loggedIn => {
+        if (loggedIn) {
+          return this.$.restAPI.getPreferences();
+        }
+      }).then(prefs => {
+        if (prefs) {
+          this._preferredDownloadCommand = prefs.download_command;
+          this._preferredDownloadScheme = prefs.download_scheme;
+        }
+      });
+    },
+
+    _handleCloseTap(e) {
+      e.preventDefault();
+      this.fire('close', null, {bubbles: false});
+    },
+
+    _computeFetchCommand(revision, preferredDownloadCommand,
+        preferredDownloadScheme) {
+      if (!revision) { return; }
+      if (!revision || !revision.fetch) { return; }
+
+      let scheme = preferredDownloadScheme;
+      if (!scheme) {
+        const keys = Object.keys(revision.fetch).sort();
+        if (keys.length === 0) {
+          return;
+        }
+        scheme = keys[0];
+      }
+
+      if (!revision.fetch[scheme] || !revision.fetch[scheme].commands) {
+        return;
+      }
+
+      const cmds = {};
+      Object.entries(revision.fetch[scheme].commands).forEach(([key, cmd]) => {
+        cmds[key.toLowerCase()] = cmd;
+      });
+
+      if (preferredDownloadCommand &&
+          cmds[preferredDownloadCommand.toLowerCase()]) {
+        return cmds[preferredDownloadCommand.toLowerCase()];
+      }
+
+      // If no supported command preference is given, look for known commands
+      // from the downloads plugin in order of preference.
+      for (let i = 0; i < PREFERRED_FETCH_COMMAND_ORDER.length; i++) {
+        if (cmds[PREFERRED_FETCH_COMMAND_ORDER[i]]) {
+          return cmds[PREFERRED_FETCH_COMMAND_ORDER[i]];
+        }
+      }
+
+      return undefined;
+    },
+
+    _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..66d0a01
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
@@ -0,0 +1,116 @@
+<!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="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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');
+    });
+
+    suite('fetch command', () => {
+      const testRev = {
+        fetch: {
+          http: {
+            commands: {
+              Checkout: 'http checkout',
+              Pull: 'http pull',
+            },
+          },
+          ssh: {
+            commands: {
+              Pull: 'ssh pull',
+            },
+          },
+        },
+      };
+
+      test('null cases', () => {
+        assert.isUndefined(element._computeFetchCommand());
+        assert.isUndefined(element._computeFetchCommand({}));
+        assert.isUndefined(element._computeFetchCommand({fetch: null}));
+        assert.isUndefined(element._computeFetchCommand({fetch: {}}));
+      });
+
+      test('insufficiently defined scheme', () => {
+        assert.isUndefined(
+            element._computeFetchCommand(testRev, undefined, 'badscheme'));
+
+        const rev = Object.assign({}, testRev);
+        rev.fetch = Object.assign({}, testRev.fetch, {nocmds: {commands: {}}});
+        assert.isUndefined(
+            element._computeFetchCommand(rev, undefined, 'nocmds'));
+
+        rev.fetch.nocmds.commands.unsupported = 'unsupported';
+        assert.isUndefined(
+            element._computeFetchCommand(rev, undefined, 'nocmds'));
+      });
+
+      test('default scheme and command', () => {
+        const cmd = element._computeFetchCommand(testRev);
+        assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
+      });
+
+      test('default command', () => {
+        assert.strictEqual(
+            element._computeFetchCommand(testRev, undefined, 'http'),
+            'http checkout');
+        assert.strictEqual(
+            element._computeFetchCommand(testRev, undefined, 'ssh'),
+            'ssh pull');
+      });
+
+      test('user preferred scheme and command', () => {
+        assert.strictEqual(
+            element._computeFetchCommand(testRev, 'PULL', 'http'),
+            'http pull');
+        assert.strictEqual(
+            element._computeFetchCommand(testRev, 'badcmd', 'http'),
+            'http checkout');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
index d1ae719..7949002 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
@@ -16,7 +16,7 @@
 -->
 
 <link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -26,7 +26,7 @@
   <template>
     <style include="shared-styles">
       gr-dropdown {
-        padding: .5em;
+        padding: 0 .5em;
         --gr-button: {
           color: var(--header-text-color);
         }
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 72ec7fa..af4510d 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -21,6 +21,7 @@
 
   Polymer({
     is: 'gr-account-dropdown',
+    _legacyUndefinedCheck: true,
 
     properties: {
       account: Object,
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index fe63a3e..37d8882 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-dropdown</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-account-dropdown.html">
 
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..09b928e
--- /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..5679408
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
@@ -0,0 +1,38 @@
+/**
+ * @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',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * 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..648f8be
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
@@ -0,0 +1,50 @@
+<!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="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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..db40496 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
@@ -16,13 +16,24 @@
 -->
 
 <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="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-error-dialog/gr-error-dialog.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.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>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="gr-error-manager.js"></script>
 </dom-module>
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..38dfecb 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
@@ -27,6 +27,7 @@
 
   Polymer({
     is: 'gr-error-manager',
+    _legacyUndefinedCheck: true,
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
@@ -62,6 +63,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 +75,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) {
@@ -89,22 +92,36 @@
     },
 
     _handleServerError(e) {
-      Promise.all([
-        e.detail.response.text(), this._getLoggedIn(),
-      ]).then(values => {
-        const text = values[0];
-        const loggedIn = values[1];
-        if (e.detail.response.status === 403 &&
-            loggedIn &&
-            text === AUTHENTICATION_REQUIRED) {
-          // The app was logged at one point and is now getting auth errors.
-          // This indicates the auth token is no longer valid.
-          this._handleAuthError();
-        } else if (!this._shouldSuppressError(text)) {
-          this._showAlert('Server error: ' + text);
-        }
-        console.error(text);
-      });
+      const {request, response} = e.detail;
+      Promise.all([response.text(), this._getLoggedIn()])
+          .then(([errorText, loggedIn]) => {
+            const url = request && (request.anonymizedUrl || request.url);
+            const {status, statusText} = response;
+            if (response.status === 403 &&
+                loggedIn &&
+                errorText === AUTHENTICATION_REQUIRED) {
+              // The app was logged at one point and is now getting auth errors.
+              // This indicates the auth token is no longer valid.
+              this._handleAuthError();
+            } else if (!this._shouldSuppressError(errorText)) {
+              this._showErrorDialog(this._constructServerErrorMsg({
+                status,
+                statusText,
+                errorText,
+                url,
+              }));
+            }
+            console.error(errorText);
+          });
+    },
+
+    _constructServerErrorMsg({errorText, status, statusText, url}) {
+      let err = `Error ${status}`;
+      if (statusText) { err += ` (${statusText})`; }
+      if (errorText || url) { err += ': '; }
+      if (errorText) { err += errorText; }
+      if (url) { err += `\nEndpoint: ${url}`; }
+      return err;
     },
 
     _handleShowAlert(e) {
@@ -257,5 +274,19 @@
     _handleWindowFocus() {
       this.flushDebouncer('checkLoggedIn');
     },
+
+    _handleShowErrorDialog(e) {
+      this._showErrorDialog(e.detail.message);
+    },
+
+    _handleDismissErrorDialog() {
+      this.$.errorOverlay.close();
+    },
+
+    _showErrorDialog(message) {
+      this.$.reporting.reportErrorDialog(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..9140c17 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-error-manager</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-manager.html">
 
@@ -86,8 +88,8 @@
           'Log in is required to perform that action.', 'Log in.'));
     });
 
-    test('show normal server error', done => {
-      const showAlertStub = sandbox.stub(element, '_showAlert');
+    test('show normal Error', done => {
+      const showErrorStub = sandbox.stub(element, '_showErrorDialog');
       const textSpy = sandbox.spy(() => { return Promise.resolve('ZOMG'); });
       element.fire('server-error', {response: {status: 500, text: textSpy}});
 
@@ -96,13 +98,34 @@
         element.$.restAPI.getLoggedIn.lastCall.returnValue,
         textSpy.lastCall.returnValue,
       ]).then(() => {
-        assert.isTrue(showAlertStub.calledOnce);
-        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
-            'Server error: ZOMG'));
+        assert.isTrue(showErrorStub.calledOnce);
+        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
+            'Error 500: ZOMG'));
         done();
       });
     });
 
+    test('_constructServerErrorMsg', () => {
+      const errorText = 'change conflicts';
+      const status = 409;
+      const statusText = 'Conflict';
+      const url = '/my/test/url';
+
+      assert.equal(element._constructServerErrorMsg({status}),
+          'Error 409');
+      assert.equal(element._constructServerErrorMsg({status, url}),
+          'Error 409: \nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({status, statusText, url}),
+          'Error 409 (Conflict): \nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+      }), 'Error 409 (Conflict): change conflicts' +
+          '\nEndpoint: /my/test/url');
+    });
+
     test('suppress TOO_MANY_FILES error', done => {
       const showAlertStub = sandbox.stub(element, '_showAlert');
       const textSpy = sandbox.spy(() => {
@@ -279,5 +302,24 @@
       element._showAlert();
       assert.isTrue(hideStub.calledOnce);
     });
+
+    test('show-error', () => {
+      const openStub = sandbox.stub(element.$.errorOverlay, 'open');
+      const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
+      const reportStub = sandbox.stub(element.$.reporting, 'reportErrorDialog');
+
+      const message = 'test message';
+      element.fire('show-error', {message});
+      flushAsynchronousOperations();
+
+      assert.isTrue(openStub.called);
+      assert.isTrue(reportStub.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-key-binding-display/gr-key-binding-display.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html
new file mode 100644
index 0000000..276db72
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html
@@ -0,0 +1,48 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-key-binding-display">
+  <template>
+    <style include="shared-styles">
+      .key {
+        background-color: var(--chip-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: 3px;
+        display: inline-block;
+        font-weight: var(--font-weight-bold);
+        padding: .1em .5em;
+        text-align: center;
+      }
+    </style>
+    <template is="dom-repeat" items="[[binding]]">
+      <template is="dom-if" if="[[index]]">
+        or
+      </template>
+      <template
+          is="dom-repeat"
+          items="[[_computeModifiers(item)]]"
+          as="modifier">
+        <span class="key modifier">[[modifier]]</span>
+      </template>
+      <span class="key">[[_computeKey(item)]]</span>
+    </template>
+  </template>
+  <script src="gr-key-binding-display.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
new file mode 100644
index 0000000..e8c6479
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.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-key-binding-display',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      /** @type {Array<string>} */
+      binding: Array,
+    },
+
+    _computeModifiers(binding) {
+      return binding.slice(0, binding.length - 1);
+    },
+
+    _computeKey(binding) {
+      return binding[binding.length - 1];
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
new file mode 100644
index 0000000..39c8af8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
@@ -0,0 +1,68 @@
+<!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-key-binding-display</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-key-binding-display.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-key-binding-display></gr-key-binding-display>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-key-binding-display tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    suite('_computeKey', () => {
+      test('unmodified key', () => {
+        assert.strictEqual(element._computeKey(['x']), 'x');
+      });
+
+      test('key with modifiers', () => {
+        assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x');
+        assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x');
+      });
+    });
+
+    suite('_computeModifiers', () => {
+      test('single unmodified key', () => {
+        assert.deepEqual(element._computeModifiers(['x']), []);
+      });
+
+      test('key with modifiers', () => {
+        assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
+        assert.deepEqual(
+            element._computeModifiers(['Shift', 'Meta', 'x']),
+            ['Shift', 'Meta']);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index e4cb243..e153074 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -15,8 +15,10 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../gr-key-binding-display/gr-key-binding-display.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-keyboard-shortcuts-dialog">
@@ -51,18 +53,9 @@
         text-align: right;
       }
       .header {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         padding-top: 1em;
       }
-      .key {
-        background-color: var(--chip-background-color);
-        border: 1px solid var(--border-color);
-        border-radius: 3px;
-        display: inline-block;
-        font-family: var(--font-family-bold);
-        padding: .1em .5em;
-        text-align: center;
-      }
       .modifier {
         font-weight: normal;
       }
@@ -74,438 +67,42 @@
     <main>
       <table>
         <tbody>
-          <tr>
-            <td></td><td class="header">Everywhere</td>
-          </tr>
-          <tr>
-            <td><span class="key">/</span></td>
-            <td>Search</td>
-          </tr>
-          <tr>
-            <td><span class="key">?</span></td>
-            <td>Show this dialog</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">g</span>
-              <span class="key">o</span>
-            </td>
-            <td>Go to Opened Changes</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">g</span>
-              <span class="key">m</span>
-            </td>
-            <td>Go to Merged Changes</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">g</span>
-              <span class="key">a</span>
-            </td>
-            <td>Go to Abandoned Changes</td>
-          </tr>
-        </tbody>
-        <!-- Change View -->
-        <tbody hidden$="[[!_computeInView(view, 'change')]]" hidden>
-          <tr>
-            <td></td><td class="header">Navigation</td>
-          </tr>
-          <tr>
-            <td><span class="key">]</span></td>
-            <td>Show first file</td>
-          </tr>
-          <tr>
-            <td><span class="key">[</span></td>
-            <td>Show last file</td>
-          </tr>
-          <tr>
-            <td><span class="key">u</span></td>
-            <td>Up to dashboard</td>
-          </tr>
-        </tbody>
-        <!-- Diff View -->
-        <tbody hidden$="[[!_computeInView(view, 'diff')]]" hidden>
-          <tr>
-            <td></td><td class="header">Navigation</td>
-          </tr>
-          <tr>
-            <td><span class="key">]</span></td>
-            <td>Show next file</td>
-          </tr>
-          <tr>
-            <td><span class="key">[</span></td>
-            <td>Show previous file</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">j</span>
-            </td>
-            <td>Show next file that has comments</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">k</span>
-            </td>
-            <td>Show previous file that has comments</td>
-          </tr>
-          <tr>
-            <td><span class="key">u</span></td>
-            <td>Up to change</td>
-          </tr>
+          <template is="dom-repeat" items="[[_left]]">
+            <tr>
+              <td></td><td class="header">[[item.section]]</td>
+            </tr>
+            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+              <tr>
+                <td>
+                  <gr-key-binding-display binding="[[shortcut.binding]]">
+                  </gr-key-binding-display>
+                </td>
+                <td>[[shortcut.text]]</td>
+              </tr>
+            </template>
+          </template>
         </tbody>
       </table>
-
-      <table>
-        <!-- Change List -->
-        <tbody hidden$="[[!_computeInView(view, 'search')]]" hidden>
-          <tr>
-            <td></td><td class="header">Change list</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span></td>
-            <td>Select next change</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span></td>
-            <td>Show previous change</td>
-          </tr>
-          <tr>
-            <td><span class="key">n</span> or <span class="key">]</span></td>
-            <td>Go to next page</td>
-          </tr>
-          <tr>
-            <td><span class="key">p</span> or <span class="key">[</span></td>
-            <td>Go to previous page</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">Enter</span> or
-              <span class="key">o</span>
-            </td>
-            <td>Show selected change</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">r</span>
-            </td>
-            <td>Refresh list of changes</td>
-          </tr>
-          <tr>
-            <td><span class="key">s</span></td>
-            <td>Star (or unstar) change</td>
-          </tr>
-        </tbody>
-        <!-- Dashboard -->
-        <tbody hidden$="[[!_computeInView(view, 'dashboard')]]" hidden>
-          <tr>
-            <td></td><td class="header">Dashboard</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span></td>
-            <td>Select next change</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span></td>
-            <td>Show previous change</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">Enter</span> or
-              <span class="key">o</span>
-            </td>
-            <td>Show selected change</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">r</span>
-            </td>
-            <td>Refresh list of changes</td>
-          </tr>
-          <tr>
-            <td><span class="key">s</span></td>
-            <td>Star (or unstar) change</td>
-          </tr>
-        </tbody>
-        <!-- Change View -->
-        <tbody hidden$="[[!_computeInView(view, 'change')]]" hidden>
-          <tr>
-            <td></td><td class="header">Actions</td>
-          </tr>
-          <tr>
-            <td><span class="key">a</span></td>
-            <td>Open reply dialog to publish comments and add reviewers</td>
-          </tr>
-          <tr>
-            <td><span class="key">d</span></td>
-            <td>Open download overlay</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">r</span>
-            </td>
-            <td>Reload the change at the latest patch</td>
-          </tr>
-          <tr>
-            <td><span class="key">s</span></td>
-            <td>Star (or unstar) change</td>
-          </tr>
-          <tr>
-            <td><span class="key">x</span></td>
-            <td>Expand all messages</td>
-          </tr>
-          <tr>
-            <td><span class="key">z</span></td>
-            <td>Collapse all messages</td>
-          </tr>
-          <tr>
-            <td></td><td class="header">Reply dialog</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Ctrl</span>
-              <span class="key">Enter</span><br/>
-            </td>
-            <td>Send reply</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Meta</span>
-              <span class="key">Enter</span>
-            </td>
-            <td>Send reply</td>
-          </tr>
-          <tr>
-            <td></td><td class="header">File list</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span> or <span class="key">↓</span></td>
-            <td>Select next file</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span> or <span class="key">↑</span></td>
-            <td>Select previous file</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">Enter</span> or
-              <span class="key">o</span>
-            </td>
-            <td>Go to selected file</td>
-          </tr>
-          <tr>
-            <td><span class="key">r</span></td>
-            <td>Toggle review flag on selected file</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">i</span>
-            </td>
-            <td>Show/hide all inline diffs</td>
-          </tr>
-          <tr>
-            <td><span class="key">i</span></td>
-            <td>Show/hide selected inline diff</td>
-          </tr>
-          <tr>
-            <td></td><td class="header">Diffs</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span> or <span class="key">↓</span></td>
-            <td>Go to next line</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span> or <span class="key">↑</span></td>
-            <td>Go to previous line</td>
-          </tr>
-          <tr>
-            <td><span class="key">n</span></td>
-            <td>Go to next diff chunk</td>
-          </tr>
-          <tr>
-            <td><span class="key">p</span></td>
-            <td>Go to previous diff chunk</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">n</span>
-            </td>
-            <td>Go to next comment thread</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">p</span>
-            </td>
-            <td>Go to previous comment thread</td>
-          </tr>
-          <tr>
-            <td><span class="key">e</span></td>
-            <td>Expand all comment threads</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">e</span>
-            </td>
-            <td>Collapse all comment threads</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">←</span>
-            </td>
-            <td>Select left pane</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">→</span>
-            </td>
-            <td>Select right pane</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">a</span>
-            </td>
-            <td>Hide/show left diff</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">m</span>
-            </td>
-            <td>Toggle unified/side-by-side diff</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">c</span>
-            </td>
-            <td>Draft new comment</td>
-          </tr>
-        </tbody>
-        <!-- Diff View -->
-        <tbody hidden$="[[!_computeInView(view, 'diff')]]" hidden>
-          <tr>
-            <td></td><td class="header">Actions</td>
-          </tr>
-          <tr>
-            <td><span class="key">j</span> or <span class="key">↓</span></td>
-            <td>Show next line</td>
-          </tr>
-          <tr>
-            <td><span class="key">k</span> or <span class="key">↑</span></td>
-            <td>Show previous line</td>
-          </tr>
-          <tr>
-            <td><span class="key">n</span></td>
-            <td>Show next diff chunk</td>
-          </tr>
-          <tr>
-            <td><span class="key">p</span></td>
-            <td>Show previous diff chunk</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">n</span>
-            </td>
-            <td>Show next comment thread</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">p</span>
-            </td>
-            <td>Show previous comment thread</td>
-          </tr>
-          <tr>
-            <td><span class="key">e</span></td>
-            <td>Expand all comment threads</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">e</span>
-            </td>
-            <td>Collapse all comment threads</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">←</span>
-            </td>
-            <td>Select left pane</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">→</span>
-            </td>
-            <td>Select right pane</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">a</span>
-            </td>
-            <td>Hide/show left diff</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">m</span>
-            </td>
-            <td>Toggle unified/side-by-side diff</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key">c</span>
-            </td>
-            <td>Draft new comment</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Ctrl</span>
-              <span class="key">s</span><br/>
-            </td>
-            <td>Save comment</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Ctrl</span>
-              <span class="key">Enter</span><br/>
-            </td>
-            <td>Save comment</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Meta</span>
-              <span class="key">Enter</span>
-            </td>
-            <td>Save comment</td>
-          </tr>
-          <tr>
-            <td><span class="key">a</span></td>
-            <td>Open reply dialog to publish comments and add reviewers</td>
-          </tr>
-          <tr>
-            <td><span class="key">,</span></td>
-            <td>Show diff preferences</td>
-          </tr>
-          <tr>
-            <td><span class="key">r</span></td>
-            <td>Mark/unmark file as reviewed</td>
-          </tr>
-        </tbody>
-      </table>
+      <template is="dom-if" if="[[_right]]">
+        <table>
+          <tbody>
+            <template is="dom-repeat" items="[[_right]]">
+              <tr>
+                <td></td><td class="header">[[item.section]]</td>
+              </tr>
+              <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+                <tr>
+                  <td>
+                    <gr-key-binding-display binding="[[shortcut.binding]]">
+                    </gr-key-binding-display>
+                  </td>
+                  <td>[[shortcut.text]]</td>
+                </tr>
+              </template>
+            </template>
+          </tbody>
+        </table>
+      </template>
     </main>
     <footer></footer>
   </template>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index e5dd019..f206db1 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -17,8 +17,11 @@
 (function() {
   'use strict';
 
+  const {ShortcutSection} = window.Gerrit.KeyboardShortcutBinder;
+
   Polymer({
     is: 'gr-keyboard-shortcuts-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user presses the close button.
@@ -27,20 +30,97 @@
      */
 
     properties: {
-      view: String,
+      _left: Array,
+      _right: Array,
+
+      _propertyBySection: {
+        type: Object,
+        value() {
+          return {
+            [ShortcutSection.EVERYWHERE]: '_everywhere',
+            [ShortcutSection.NAVIGATION]: '_navigation',
+            [ShortcutSection.DASHBOARD]: '_dashboard',
+            [ShortcutSection.CHANGE_LIST]: '_changeList',
+            [ShortcutSection.ACTIONS]: '_actions',
+            [ShortcutSection.REPLY_DIALOG]: '_replyDialog',
+            [ShortcutSection.FILE_LIST]: '_fileList',
+            [ShortcutSection.DIFFS]: '_diffs',
+          };
+        },
+      },
     },
 
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
     hostAttributes: {
       role: 'dialog',
     },
 
-    _computeInView(currentView, view) {
-      return view === currentView;
+    attached() {
+      this.addKeyboardShortcutDirectoryListener(
+          this._onDirectoryUpdated.bind(this));
+    },
+
+    detached() {
+      this.removeKeyboardShortcutDirectoryListener(
+          this._onDirectoryUpdated.bind(this));
     },
 
     _handleCloseTap(e) {
       e.preventDefault();
       this.fire('close', null, {bubbles: false});
     },
+
+    _onDirectoryUpdated(directory) {
+      const left = [];
+      const right = [];
+
+      if (directory.has(ShortcutSection.EVERYWHERE)) {
+        left.push({
+          section: ShortcutSection.EVERYWHERE,
+          shortcuts: directory.get(ShortcutSection.EVERYWHERE),
+        });
+      }
+
+      if (directory.has(ShortcutSection.NAVIGATION)) {
+        left.push({
+          section: ShortcutSection.NAVIGATION,
+          shortcuts: directory.get(ShortcutSection.NAVIGATION),
+        });
+      }
+
+      if (directory.has(ShortcutSection.ACTIONS)) {
+        right.push({
+          section: ShortcutSection.ACTIONS,
+          shortcuts: directory.get(ShortcutSection.ACTIONS),
+        });
+      }
+
+      if (directory.has(ShortcutSection.REPLY_DIALOG)) {
+        right.push({
+          section: ShortcutSection.REPLY_DIALOG,
+          shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
+        });
+      }
+
+      if (directory.has(ShortcutSection.FILE_LIST)) {
+        right.push({
+          section: ShortcutSection.FILE_LIST,
+          shortcuts: directory.get(ShortcutSection.FILE_LIST),
+        });
+      }
+
+      if (directory.has(ShortcutSection.DIFFS)) {
+        right.push({
+          section: ShortcutSection.DIFFS,
+          shortcuts: directory.get(ShortcutSection.DIFFS),
+        });
+      }
+
+      this.set('_left', left);
+      this.set('_right', right);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
new file mode 100644
index 0000000..1a3d6c7
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
@@ -0,0 +1,181 @@
+<!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-key-binding-display</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-keyboard-shortcuts-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-keyboard-shortcuts-dialog></gr-keyboard-shortcuts-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-keyboard-shortcuts-dialog tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    function update(directory) {
+      element._onDirectoryUpdated(directory);
+      flushAsynchronousOperations();
+    }
+
+    suite('_left and _right contents', () => {
+      test('empty dialog', () => {
+        assert.strictEqual(element._left.length, 0);
+        assert.strictEqual(element._right.length, 0);
+      });
+
+      test('everywhere goes on left', () => {
+        update(new Map([
+          [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._left,
+            [
+              {
+                section: kb.ShortcutSection.EVERYWHERE,
+                shortcuts: ['everywhere shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._right.length, 0);
+      });
+
+      test('navigation goes on left', () => {
+        update(new Map([
+          [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._left,
+            [
+              {
+                section: kb.ShortcutSection.NAVIGATION,
+                shortcuts: ['navigation shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._right.length, 0);
+      });
+
+      test('actions go on right', () => {
+        update(new Map([
+          [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.ACTIONS,
+                shortcuts: ['actions shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._left.length, 0);
+      });
+
+      test('reply dialog goes on right', () => {
+        update(new Map([
+          [kb.ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.REPLY_DIALOG,
+                shortcuts: ['reply dialog shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._left.length, 0);
+      });
+
+      test('file list goes on right', () => {
+        update(new Map([
+          [kb.ShortcutSection.FILE_LIST, ['file list shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.FILE_LIST,
+                shortcuts: ['file list shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._left.length, 0);
+      });
+
+      test('diffs go on right', () => {
+        update(new Map([
+          [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.DIFFS,
+                shortcuts: ['diffs shortcuts'],
+              },
+            ]);
+        assert.strictEqual(element._left.length, 0);
+      });
+
+      test('multiple sections on each side', () => {
+        update(new Map([
+          [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
+          [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
+          [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+          [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+        ]));
+        assert.deepEqual(
+            element._left,
+            [
+              {
+                section: kb.ShortcutSection.EVERYWHERE,
+                shortcuts: ['everywhere shortcuts'],
+              },
+              {
+                section: kb.ShortcutSection.NAVIGATION,
+                shortcuts: ['navigation shortcuts'],
+              },
+            ]);
+        assert.deepEqual(
+            element._right,
+            [
+              {
+                section: kb.ShortcutSection.ACTIONS,
+                shortcuts: ['actions shortcuts'],
+              },
+              {
+                section: kb.ShortcutSection.DIFFS,
+                shortcuts: ['diffs shortcuts'],
+              },
+            ]);
+      });
+    });
+  });
+</script>
+
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index 04e3794..37cb317 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
@@ -53,6 +53,7 @@
         content: "";
         display: inline-block;
         height: var(--header-icon-size);
+        margin-right: calc(var(--header-icon-size) / 4);
         vertical-align: text-bottom;
         width: var(--header-icon-size);
       }
@@ -72,7 +73,7 @@
       .linksTitle {
         color: var(--header-text-color);
         display: inline-block;
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         position: relative;
         text-transform: uppercase;
       }
@@ -111,12 +112,14 @@
         margin: 5px 4px;
         text-decoration: none;
       }
+      .invisible,
       .settingsButton,
       gr-account-dropdown {
         display: none;
       }
       :host([loading]) .accountContainer,
-      :host([logged-in]) .loginButton {
+      :host([logged-in]) .loginButton,
+      :host([logged-in]) .registerButton {
         display: none;
       }
       :host([logged-in]) .settingsButton,
@@ -134,9 +137,9 @@
         text-overflow: ellipsis;
         white-space: nowrap;
       }
-      .loginButton {
+      .loginButton, .registerButton {
         color: var(--header-text-color);
-        padding: 1em;
+        padding: .5em 1em;
       }
       .dropdown-trigger {
         text-decoration: none;
@@ -145,10 +148,13 @@
         background-color: var(--view-background-color);
         box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
       }
+      #mobileSearch {
+        display: none;
+      }
       @media screen and (max-width: 50em) {
         .bigTitle {
           font-size: var(--font-size-large);
-          font-family: var(--font-family-bold);
+          font-weight: var(--font-weight-bold);
         }
         gr-smart-search,
         .browse,
@@ -156,6 +162,9 @@
         .links > li.hideOnMobile {
           display: none;
         }
+        #mobileSearch {
+          display: inline-flex;
+        }
         .accountContainer {
           margin-left: .5em !important;
         }
@@ -172,20 +181,23 @@
       </a>
       <ul class="links">
         <template is="dom-repeat" items="[[_links]]" as="linkGroup">
-          <li class$="[[linkGroup.class]]">
-          <gr-dropdown
-              link
-              down-arrow
-              items = [[linkGroup.links]]
-              horizontal-align="left">
-            <span class="linksTitle" id="[[linkGroup.title]]">
-              [[linkGroup.title]]
-            </span>
-          </gr-dropdown>
+          <li class$="[[_computeLinkGroupClass(linkGroup)]]">
+            <gr-dropdown
+                link
+                down-arrow
+                items = [[linkGroup.links]]
+                horizontal-align="left">
+              <span class="linksTitle" id="[[linkGroup.title]]">
+                [[linkGroup.title]]
+              </span>
+            </gr-dropdown>
           </li>
         </template>
       </ul>
       <div class="rightItems">
+        <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-small-banner"></gr-endpoint-decorator>
         <gr-smart-search
             id="search"
             search-query="{{searchQuery}}"></gr-smart-search>
@@ -193,6 +205,14 @@
             class="hideOnMobile"
             name="header-browse-source"></gr-endpoint-decorator>
         <div class="accountContainer" id="accountContainer">
+          <iron-icon id="mobileSearch" icon="gr-icons:search" on-tap='_onMobileSearchTap'></iron-icon>
+          <div class$="[[_computeIsInvisible(_registerURL)]]">
+            <a
+                class="registerButton"
+                href$="[[_registerURL]]">
+              [[_registerText]]
+            </a>
+          </div>
           <a class="loginButton" href$="[[_loginURL]]">Sign in</a>
           <a
               class="settingsButton"
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index dad7b7c..496f8d3 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -62,8 +62,16 @@
     },
   ];
 
+  // Set of authentication methods that can provide custom registration page.
+  const AUTH_TYPES_WITH_REGISTER_URL = new Set([
+    'LDAP',
+    'LDAP_BIND',
+    'CUSTOM_EXTENSION',
+  ]);
+
   Polymer({
     is: 'gr-main-header',
+    _legacyUndefinedCheck: true,
 
     hostAttributes: {
       role: 'banner',
@@ -102,7 +110,7 @@
       _links: {
         type: Array,
         computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
-            '_docBaseUrl)',
+            '_topMenus, _docBaseUrl)',
       },
       _loginURL: {
         type: String,
@@ -112,6 +120,18 @@
         type: Array,
         value() { return []; },
       },
+      _topMenus: {
+        type: Array,
+        value() { return []; },
+      },
+      _registerText: {
+        type: String,
+        value: 'Sign up',
+      },
+      _registerURL: {
+        type: String,
+        value: null,
+      },
     },
 
     behaviors: [
@@ -159,12 +179,17 @@
       return '//' + window.location.host + this.getBaseUrl() + path;
     },
 
-    _computeLinks(defaultLinks, userLinks, adminLinks, docBaseUrl) {
-      const links = defaultLinks.slice();
+    _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
+      const links = defaultLinks.map(menu => {
+        return {
+          title: menu.title,
+          links: menu.links.slice(),
+        };
+      });
       if (userLinks && userLinks.length > 0) {
         links.push({
           title: 'Your',
-          links: userLinks,
+          links: userLinks.slice(),
         });
       }
       const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
@@ -177,8 +202,24 @@
       }
       links.push({
         title: 'Browse',
-        links: adminLinks,
+        links: adminLinks.slice(),
       });
+      const topMenuLinks = [];
+      links.forEach(link => { topMenuLinks[link.title] = link.links; });
+      for (const m of topMenus) {
+        const items = m.items.map(this._fixCustomMenuItem).filter(link => {
+          // Ignore GWT project links
+          return !link.url.includes('${projectName}');
+        });
+        if (m.name in topMenuLinks) {
+          items.forEach(link => { topMenuLinks[m.name].push(link); });
+        } else {
+          links.push({
+            title: m.name,
+            links: topMenuLinks[m.name] = items,
+          });
+        }
+      }
       return links;
     },
 
@@ -203,6 +244,7 @@
       this.loading = true;
       const promises = [
         this.$.restAPI.getAccount(),
+        this.$.restAPI.getTopMenus(),
         Gerrit.awaitPluginsLoaded(),
       ];
 
@@ -211,6 +253,7 @@
         this._account = account;
         this.loggedIn = !!account;
         this.loading = false;
+        this._topMenus = result[1];
 
         return this.getAdminLinks(account,
             this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
@@ -223,7 +266,10 @@
 
     _loadConfig() {
       this.$.restAPI.getConfig()
-          .then(config => this.getDocsBaseUrl(config, this.$.restAPI))
+          .then(config => {
+            this._retrieveRegisterURL(config);
+            return this.getDocsBaseUrl(config, this.$.restAPI);
+          })
           .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
     },
 
@@ -231,12 +277,24 @@
       if (!account) { return; }
 
       this.$.restAPI.getPreferences().then(prefs => {
-        this._userLinks =
-            prefs.my.map(this._fixMyMenuItem).filter(this._isSupportedLink);
+        this._userLinks = prefs.my.map(this._fixCustomMenuItem);
       });
     },
 
-    _fixMyMenuItem(linkObj) {
+    _retrieveRegisterURL(config) {
+      if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
+        this._registerURL = config.auth.register_url;
+        if (config.auth.register_text) {
+          this._registerText = config.auth.register_text;
+        }
+      }
+    },
+
+    _computeIsInvisible(registerURL) {
+      return registerURL ? '' : 'invisible';
+    },
+
+    _fixCustomMenuItem(linkObj) {
       // Normalize all urls to PolyGerrit style.
       if (linkObj.url.startsWith('#')) {
         linkObj.url = linkObj.url.slice(1);
@@ -251,21 +309,24 @@
       // so we'll just disable it altogether for now.
       delete linkObj.target;
 
-      // Becasue the "my menu" links may be arbitrary URLs, we don't know
-      // whether they correspond to any client routes. Mark all such links as
-      // external.
-      linkObj.external = true;
-
       return linkObj;
     },
 
-    _isSupportedLink(linkObj) {
-      // Groups are not yet supported.
-      return !linkObj.url.startsWith('/groups');
-    },
-
     _generateSettingsLink() {
       return this.getBaseUrl() + '/settings/';
     },
+
+    _onMobileSearchTap(e) {
+      e.preventDefault();
+      this.fire('mobile-search', null, {bubbles: false});
+    },
+
+    _computeLinkGroupClass(linkGroup) {
+      if (linkGroup && linkGroup.class) {
+        return linkGroup.class;
+      }
+
+      return '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index 30e8e1f..7bf6728 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-main-header</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-main-header.html">
 
@@ -63,6 +65,8 @@
           'none');
       assert.notEqual(getComputedStyle(element.$$('.loginButton')).display,
           'none');
+      assert.notEqual(getComputedStyle(element.$$('.registerButton')).display,
+          'none');
       assert.equal(getComputedStyle(element.$$('gr-account-dropdown')).display,
           'none');
       assert.equal(getComputedStyle(element.$$('.settingsButton')).display,
@@ -70,6 +74,8 @@
       element.loggedIn = true;
       assert.equal(getComputedStyle(element.$$('.loginButton')).display,
           'none');
+      assert.equal(getComputedStyle(element.$$('.registerButton')).display,
+          'none');
       assert.notEqual(getComputedStyle(element.$$('gr-account-dropdown'))
           .display,
           'none');
@@ -81,24 +87,12 @@
       assert.deepEqual([
         {url: 'https://awesometown.com/#hashyhash'},
         {url: 'url', target: '_blank'},
-      ].map(element._fixMyMenuItem), [
-        {url: 'https://awesometown.com/#hashyhash', external: true},
-        {url: 'url', external: true},
+      ].map(element._fixCustomMenuItem), [
+        {url: 'https://awesometown.com/#hashyhash'},
+        {url: 'url'},
       ]);
     });
 
-    test('filter unsupported urls', () => {
-      assert.deepEqual([
-        {url: '/c/331788/'},
-        {url: '/groups/self'},
-        {url: 'https://awesometown.com/#hashyhash'},
-      ].filter(element._isSupportedLink), [
-        {url: '/c/331788/'},
-        {url: 'https://awesometown.com/#hashyhash'},
-      ]);
-    });
-
-
     test('user links', () => {
       const defaultLinks = [{
         title: 'Faves',
@@ -117,13 +111,13 @@
       }];
 
       // When no admin links are passed, it should use the default.
-      assert.deepEqual(element._computeLinks(defaultLinks, [], adminLinks),
+      assert.deepEqual(element._computeLinks(defaultLinks, [], adminLinks, []),
           defaultLinks.concat({
             title: 'Browse',
             links: adminLinks,
           }));
       assert.deepEqual(
-          element._computeLinks(defaultLinks, userLinks, adminLinks),
+          element._computeLinks(defaultLinks, userLinks, adminLinks, []),
           defaultLinks.concat([
             {
               title: 'Your',
@@ -160,5 +154,200 @@
         url: 'base/index.html',
       }]);
     });
+
+    test('top menus', () => {
+      const adminLinks = [{
+        name: 'Repos',
+        url: '/repos',
+      }];
+      const topMenus = [{
+        name: 'Plugins',
+        items: [{
+          name: 'Manage',
+          target: '_blank',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }],
+      }];
+      assert.deepEqual(element._computeLinks([], [], adminLinks, topMenus), [{
+        title: 'Browse',
+        links: adminLinks,
+      },
+      {
+        title: 'Plugins',
+        links: [{
+          name: 'Manage',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }],
+      }]);
+    });
+
+    test('ignore top project menus', () => {
+      const adminLinks = [{
+        name: 'Repos',
+        url: '/repos',
+      }];
+      const topMenus = [{
+        name: 'Projects',
+        items: [{
+          name: 'Project Settings',
+          target: '_blank',
+          url: '/plugins/myplugin/${projectName}',
+        }, {
+          name: 'Project List',
+          target: '_blank',
+          url: '/plugins/myplugin/index.html',
+        }],
+      }];
+      assert.deepEqual(element._computeLinks([], [], adminLinks, topMenus), [{
+        title: 'Browse',
+        links: adminLinks,
+      },
+      {
+        title: 'Projects',
+        links: [{
+          name: 'Project List',
+          url: '/plugins/myplugin/index.html',
+        }],
+      }]);
+    });
+
+    test('merge top menus', () => {
+      const adminLinks = [{
+        name: 'Repos',
+        url: '/repos',
+      }];
+      const topMenus = [{
+        name: 'Plugins',
+        items: [{
+          name: 'Manage',
+          target: '_blank',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }],
+      }, {
+        name: 'Plugins',
+        items: [{
+          name: 'Create',
+          target: '_blank',
+          url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+        }],
+      }];
+      assert.deepEqual(element._computeLinks([], [], adminLinks, topMenus), [{
+        title: 'Browse',
+        links: adminLinks,
+      }, {
+        title: 'Plugins',
+        links: [{
+          name: 'Manage',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }, {
+          name: 'Create',
+          url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+        }],
+      }]);
+    });
+
+    test('merge top menus in default links', () => {
+      const defaultLinks = [{
+        title: 'Faves',
+        links: [{
+          name: 'Pinterest',
+          url: 'https://pinterest.com',
+        }],
+      }];
+      const topMenus = [{
+        name: 'Faves',
+        items: [{
+          name: 'Manage',
+          target: '_blank',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }],
+      }];
+      assert.deepEqual(element._computeLinks(defaultLinks, [], [], topMenus), [{
+        title: 'Faves',
+        links: defaultLinks[0].links.concat([{
+          name: 'Manage',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }]),
+      }, {
+        title: 'Browse',
+        links: [],
+      }]);
+    });
+
+    test('merge top menus in user links', () => {
+      const userLinks = [{
+        name: 'Facebook',
+        url: 'https://facebook.com',
+      }];
+      const topMenus = [{
+        name: 'Your',
+        items: [{
+          name: 'Manage',
+          target: '_blank',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }],
+      }];
+      assert.deepEqual(element._computeLinks([], userLinks, [], topMenus), [{
+        title: 'Your',
+        links: userLinks.concat([{
+          name: 'Manage',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }]),
+      }, {
+        title: 'Browse',
+        links: [],
+      }]);
+    });
+
+    test('merge top menus in admin links', () => {
+      const adminLinks = [{
+        name: 'Repos',
+        url: '/repos',
+      }];
+      const topMenus = [{
+        name: 'Browse',
+        items: [{
+          name: 'Manage',
+          target: '_blank',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }],
+      }];
+      assert.deepEqual(element._computeLinks([], [], adminLinks, topMenus), [{
+        title: 'Browse',
+        links: adminLinks.concat([{
+          name: 'Manage',
+          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+        }]),
+      }]);
+    });
+
+    test('register URL', () => {
+      const config = {
+        auth: {
+          auth_type: 'LDAP',
+          register_url: 'https//gerrit.example.com/register',
+        },
+      };
+      element._retrieveRegisterURL(config);
+      assert.equal(element._registerURL, config.auth.register_url);
+      assert.equal(element._registerText, 'Sign up');
+
+      config.auth.register_text = 'Create account';
+      element._retrieveRegisterURL(config);
+      assert.equal(element._registerURL, config.auth.register_url);
+      assert.equal(element._registerText, config.auth.register_text);
+    });
+
+    test('register URL ignored for wrong auth type', () => {
+      const config = {
+        auth: {
+          auth_type: 'OPENID',
+          register_url: 'https//gerrit.example.com/register',
+        },
+      };
+      element._retrieveRegisterURL(config);
+      assert.equal(element._registerURL, null);
+      assert.equal(element._registerText, 'Sign up');
+    });
   });
 </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 e217b4b..b1433a3 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -33,6 +33,8 @@
     //        also be provided.
     //    - `edit`, optional, Boolean: whether or not to load the file list with
     //        edit controls.
+    //    - `messageHash`, optional, String: the hash of the change message to
+    //        scroll to.
     //
     // - Gerrit.Nav.View.SEARCH:
     //    - `query`, optional, String: the literal search query. If provided,
@@ -70,7 +72,15 @@
     //    - `repoName`, required, String: the name of the repo
     //    - `detail`, optional, String: the name of the repo detail view.
     //      Takes any value from Gerrit.Nav.RepoDetailView.
-
+    //
+    //  - Gerrit.Nav.View.DASHBOARD
+    //    - `repo`, optional, String.
+    //    - `sections`, optional, Array of objects with `title` and `query`
+    //      strings.
+    //    - `user`, optional, String.
+    //
+    //  - Gerrit.Nav.View.ROOT:
+    //    - no possible parameters.
 
     window.Gerrit = window.Gerrit || {};
 
@@ -84,6 +94,70 @@
     const EDIT_PATCHNUM = 'edit';
     const PARENT_PATCHNUM = 'PARENT';
 
+    const USER_PLACEHOLDER_PATTERN = /\$\{user\}/g;
+
+    // NOTE: These queries are tested in Java. Any changes made to definitions
+    // here require corresponding changes to:
+    // javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+    const DEFAULT_SECTIONS = [
+      {
+        // Changes with unpublished draft comments. This section is omitted when
+        // viewing other users, so we don't need to filter anything out.
+        name: 'Has draft comments',
+        query: 'has:draft',
+        selfOnly: true,
+        hideIfEmpty: true,
+        suffixForDashboard: 'limit:10',
+      },
+      {
+        // Changes that are assigned to the viewed user.
+        name: 'Assigned reviews',
+        query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
+            'is:open -is:ignored',
+        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
+        // by the viewing user.
+        name: 'Outgoing reviews',
+        query: 'is:open owner:${user} -is:wip -is:ignored',
+        isOutgoing: true,
+      },
+      {
+        // Non-WIP open changes not owned by the viewed user, that the viewed user
+        // is associated with (as either a reviewer or the assignee). Changes
+        // ignored by the viewing user are filtered out.
+        name: 'Incoming reviews',
+        query: 'is:open -owner:${user} -is:wip -is:ignored ' +
+            '(reviewer:${user} OR assignee:${user})',
+      },
+      {
+        // Open changes the viewed user is CCed on. Changes ignored by the viewing
+        // user are filtered out.
+        name: 'CCed on',
+        query: 'is:open -is:ignored cc:${user}',
+      },
+      {
+        name: 'Recently closed',
+        // Closed changes where viewed user is owner, reviewer, or assignee.
+        // Changes ignored by the viewing user are filtered out, and so are WIP
+        // 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} ' +
+            'OR cc:${user})',
+        suffixForDashboard: '-age:4w limit:10',
+      },
+    ];
+
     window.Gerrit.Nav = {
 
       View: {
@@ -92,10 +166,12 @@
         CHANGE: 'change',
         DASHBOARD: 'dashboard',
         DIFF: 'diff',
+        DOCUMENTATION_SEARCH: 'documentation-search',
         EDIT: 'edit',
         GROUP: 'group',
         PLUGIN_SCREEN: 'plugin-screen',
         REPO: 'repo',
+        ROOT: 'root',
         SEARCH: 'search',
         SETTINGS: 'settings',
       },
@@ -128,6 +204,9 @@
       /** @type {Function} */
       _generateWeblinks: uninitialized,
 
+      /** @type {Function} */
+      mapCommentlinks: uninitialized,
+
       /**
        * @param {number=} patchNum
        * @param {number|string=} basePatchNum
@@ -140,20 +219,38 @@
 
       /**
        * Setup router implementation.
-       * @param {Function} navigate
-       * @param {Function} generateUrl
-       * @param {Function} generateWeblinks
+       * @param {function(!string)} navigate the router-abstracted equivalent of
+       *     `window.location.href = ...`. Takes a string.
+       * @param {function(!Object): string} generateUrl generates a URL given
+       *     navigation parameters, detailed in the file header.
+       * @param {function(!Object): string} generateWeblinks weblinks generator
+       *     function takes single payload parameter with type property that
+       *  determines which
+       *     part of the UI is the consumer of the weblinks. type property can
+       *     be one of file, change, or patchset.
+       *     - For file type, payload will also contain string properties: repo,
+       *         commit, file.
+       *     - For patchset type, payload will also contain string properties:
+       *         repo, commit.
+       *     - For change type, payload will also contain string properties:
+       *         repo, commit. If server provides weblinks, those will be passed
+       *         as options.weblinks property on the main payload object.
+       * @param {function(!Object): Object} mapCommentlinks provides an escape
+       *     hatch to modify the commentlinks object, e.g. if it contains any
+       *     relative URLs.
        */
-      setup(navigate, generateUrl, generateWeblinks) {
+      setup(navigate, generateUrl, generateWeblinks, mapCommentlinks) {
         this._navigate = navigate;
         this._generateUrl = generateUrl;
         this._generateWeblinks = generateWeblinks;
+        this.mapCommentlinks = mapCommentlinks;
       },
 
       destroy() {
         this._navigate = uninitialized;
         this._generateUrl = uninitialized;
         this._generateWeblinks = uninitialized;
+        this.mapCommentlinks = uninitialized;
       },
 
       /**
@@ -177,13 +274,15 @@
        * @param {!string} project The name of the project.
        * @param {boolean=} opt_openOnly When true, only search open changes in
        *     the project.
+       * @param {string=} opt_host The host in which to search.
        * @return {string}
        */
-      getUrlForProjectChanges(project, opt_openOnly) {
+      getUrlForProjectChanges(project, opt_openOnly, opt_host) {
         return this._getUrlFor({
           view: Gerrit.Nav.View.SEARCH,
           project,
           statuses: opt_openOnly ? ['open'] : [],
+          host: opt_host,
         });
       },
 
@@ -191,26 +290,30 @@
        * @param {string} branch The name of the branch.
        * @param {string} project The name of the project.
        * @param {string=} opt_status The status to search.
+       * @param {string=} opt_host The host in which to search.
        * @return {string}
        */
-      getUrlForBranch(branch, project, opt_status) {
+      getUrlForBranch(branch, project, opt_status, opt_host) {
         return this._getUrlFor({
           view: Gerrit.Nav.View.SEARCH,
           branch,
           project,
           statuses: opt_status ? [opt_status] : undefined,
+          host: opt_host,
         });
       },
 
       /**
        * @param {string} topic The name of the topic.
+       * @param {string=} opt_host The host in which to search.
        * @return {string}
        */
-      getUrlForTopic(topic) {
+      getUrlForTopic(topic, opt_host) {
         return this._getUrlFor({
           view: Gerrit.Nav.View.SEARCH,
           topic,
           statuses: ['open', 'merged'],
+          host: opt_host,
         });
       },
 
@@ -252,9 +355,11 @@
        * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
        *     used for none.
        * @param {boolean=} opt_isEdit
+       * @param {string=} opt_messageHash
        * @return {string}
        */
-      getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit) {
+      getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
+          opt_messageHash) {
         if (opt_basePatchNum === PARENT_PATCHNUM) {
           opt_basePatchNum = undefined;
         }
@@ -267,6 +372,8 @@
           patchNum: opt_patchNum,
           basePatchNum: opt_basePatchNum,
           edit: opt_isEdit,
+          host: change.internalHost || undefined,
+          messageHash: opt_messageHash,
         });
       },
 
@@ -405,15 +512,25 @@
       },
 
       /**
-       * @param {string} repo The name of the repo.
-       * @param {!Array} sections The sections to display in the dashboard
        * @return {string}
        */
-      getUrlForCustomDashboard(repo, sections) {
+      getUrlForRoot() {
         return this._getUrlFor({
-          repo,
+          view: Gerrit.Nav.View.ROOT,
+        });
+      },
+
+      /**
+       * @param {string} repo The name of the repo.
+       * @param {string} dashboard The ID of the dashboard, in the form of
+       *     '<ref>:<path>'.
+       * @return {string}
+       */
+      getUrlForRepoDashboard(repo, dashboard) {
+        return this._getUrlFor({
           view: Gerrit.Nav.View.DASHBOARD,
-          sections,
+          repo,
+          dashboard,
         });
       },
 
@@ -599,6 +716,17 @@
         }
         return [].concat(this._generateWeblinks(params));
       },
+
+      getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
+          title = '') {
+        sections = sections
+          .filter(section => (user === 'self' || !section.selfOnly))
+          .map(section => Object.assign({}, section, {
+            name: section.name,
+            query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+          }));
+        return {title, sections};
+      },
     };
   })(window);
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
index 61d1100..73ce86a 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-navigation</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 
 <script>
@@ -29,5 +31,56 @@
       assert.throw(() => Gerrit.Nav.getUrlForChange('123', undefined, 12));
       assert.throw(() => Gerrit.Nav.getUrlForDiff('123', 'x.c', undefined, 12));
     });
+
+    suite('_getUserDashboard', () => {
+      const sections = [
+        {name: 'section 1', query: 'query 1'},
+        {name: 'section 2', query: 'query 2 for ${user}'},
+        {name: 'section 3', query: 'self only query', selfOnly: true},
+        {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
+      ];
+
+      test('dashboard for self', () => {
+        const dashboard =
+            Gerrit.Nav.getUserDashboard('self', sections, 'title');
+        assert.deepEqual(
+            dashboard,
+            {
+              title: 'title',
+              sections: [
+                {name: 'section 1', query: 'query 1'},
+                {name: 'section 2', query: 'query 2 for self'},
+                {
+                  name: 'section 3',
+                  query: 'self only query',
+                  selfOnly: true,
+                }, {
+                  name: 'section 4',
+                  query: 'query 4',
+                  suffixForDashboard: 'suffix',
+                },
+              ],
+            });
+      });
+
+      test('dashboard for other user', () => {
+        const dashboard =
+            Gerrit.Nav.getUserDashboard('user', sections, 'title');
+        assert.deepEqual(
+            dashboard,
+            {
+              title: 'title',
+              sections: [
+                {name: 'section 1', query: 'query 1'},
+                {name: 'section 2', query: 'query 2 for user'},
+                {
+                  name: 'section 4',
+                  query: 'query 4',
+                  suffixForDashboard: 'suffix',
+                },
+              ],
+            });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html
index 6faeec1..825a5fc 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-jank-detector</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 
 <script src="gr-jank-detector.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
index 935de6b..6588df4 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-reporting">
   <script src="gr-jank-detector.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 43d43d8..ca494d2 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -20,7 +20,8 @@
   // Latency reporting constants.
   const TIMING = {
     TYPE: 'timing-report',
-    CATEGORY: 'UI Latency',
+    CATEGORY_UI_LATENCY: 'UI Latency',
+    CATEGORY_RPC: 'RPC Timing',
     // Reported events - alphabetize below.
     APP_STARTED: 'App Started',
     PAGE_LOADED: 'Page Loaded',
@@ -68,6 +69,11 @@
     CATEGORY: 'exception',
   };
 
+  const ERROR_DIALOG = {
+    TYPE: 'error',
+    CATEGORY: 'Error Dialog',
+  };
+
   const TIMER = {
     CHANGE_DISPLAYED: 'ChangeDisplayed',
     CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
@@ -81,20 +87,26 @@
     STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
     STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
     WEB_COMPONENTS_READY: 'WebComponentsReady',
+    METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
   };
 
   const STARTUP_TIMERS = {};
   STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0;
+  STARTUP_TIMERS[TIMER.METRICS_PLUGIN_LOADED] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_CHANGE_LOAD_FULL] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0;
   STARTUP_TIMERS[TIMER.STARTUP_FILE_LIST_DISPLAYED] = 0;
+  STARTUP_TIMERS[TIMING.APP_STARTED] = 0;
   // WebComponentsReady timer is triggered from gr-router.
   STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
 
   const INTERACTION_TYPE = 'interaction';
 
+  const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
+  const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
+
   const pending = [];
 
   const onError = function(oldOnError, msg, url, line, column, error) {
@@ -131,8 +143,11 @@
 
   GrJankDetector.start();
 
-  const GrReporting = Polymer({
+  // The Polymer pass of JSCompiler requires this to be reassignable
+  // eslint-disable-next-line prefer-const
+  let GrReporting = Polymer({
     is: 'gr-reporting',
+    _legacyUndefinedCheck: true,
 
     properties: {
       category: String,
@@ -141,6 +156,11 @@
         type: Object,
         value: STARTUP_TIMERS, // Shared across all instances.
       },
+
+      _timers: {
+        type: Object,
+        value: {timeBetweenDraftActions: null}, // Shared across all instances.
+      },
     },
 
     get performanceTiming() {
@@ -156,13 +176,27 @@
         !this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED);
     },
 
+    _isMetricsPluginLoaded() {
+      return this._arePluginsLoaded() || this._baselines &&
+        !this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED);
+    },
+
     reporter(...args) {
-      const report = (this._arePluginsLoaded() && !pending.length) ?
+      const report = (this._isMetricsPluginLoaded() && !pending.length) ?
         this.defaultReporter : this.cachingReporter;
       report.apply(this, args);
     },
 
-    defaultReporter(type, category, eventName, eventValue) {
+    /**
+     * The default reporter reports events immediately.
+     * @param {string} type
+     * @param {string} category
+     * @param {string} eventName
+     * @param {string|number} eventValue
+     * @param {boolean|undefined} opt_noLog If true, the event will not be
+     *     logged to the JS console.
+     */
+    defaultReporter(type, category, eventName, eventValue, opt_noLog) {
       const detail = {
         type,
         category,
@@ -170,27 +204,41 @@
         value: eventValue,
       };
       document.dispatchEvent(new CustomEvent(type, {detail}));
-      if (type === ERROR.TYPE) {
-        console.error(eventValue.error || eventName);
+      if (opt_noLog) { return; }
+      if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
+        console.error(eventValue && eventValue.error || eventName);
       } else {
-        console.log(eventName + (eventValue !== undefined ?
-            (': ' + eventValue) : ''));
+        if (eventValue !== undefined) {
+          console.log(`Reporting: ${eventName}: ${eventValue}`);
+        } else {
+          console.log(`Reporting: ${eventName}`);
+        }
       }
     },
 
-    cachingReporter(type, category, eventName, eventValue) {
-      if (type === ERROR.TYPE) {
-        console.error(eventValue.error || eventName);
+    /**
+     * The caching reporter will queue reports until plugins have loaded, and
+     * log events immediately if they're reported after plugins have loaded.
+     * @param {string} type
+     * @param {string} category
+     * @param {string} eventName
+     * @param {string|number} eventValue
+     * @param {boolean|undefined} opt_noLog If true, the event will not be
+     *     logged to the JS console.
+     */
+    cachingReporter(type, category, eventName, eventValue, opt_noLog) {
+      if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
+        console.error(eventValue && eventValue.error || eventName);
       }
-      if (this._arePluginsLoaded()) {
+      if (this._isMetricsPluginLoaded()) {
         if (pending.length) {
           for (const args of pending.splice(0)) {
             this.reporter(...args);
           }
         }
-        this.reporter(type, category, eventName, eventValue);
+        this.reporter(type, category, eventName, eventValue, opt_noLog);
       } else {
-        pending.push([type, category, eventName, eventValue]);
+        pending.push([type, category, eventName, eventValue, opt_noLog]);
       }
     },
 
@@ -198,10 +246,7 @@
      * User-perceived app start time, should be reported when the app is ready.
      */
     appStarted(hidden) {
-      const startTime =
-          new Date().getTime() - this.performanceTiming.navigationStart;
-      this.reporter(
-          TIMING.TYPE, TIMING.CATEGORY, TIMING.APP_STARTED, startTime);
+      this.timeEnd(TIMING.APP_STARTED);
       if (hidden) {
         this.reporter(PAGE_VISIBILITY.TYPE, PAGE_VISIBILITY.CATEGORY,
             PAGE_VISIBILITY.STARTED_HIDDEN);
@@ -218,8 +263,8 @@
       } else {
         const loadTime = this.performanceTiming.loadEventEnd -
             this.performanceTiming.navigationStart;
-        this.reporter(
-            TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
+        this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
+            TIMING.PAGE_LOADED, loadTime);
       }
     },
 
@@ -288,6 +333,12 @@
       this.reporter(EXTENSION.TYPE, EXTENSION.DETECTED, name);
     },
 
+    pluginLoaded(name) {
+      if (name.startsWith('metrics-')) {
+        this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
+      }
+    },
+
     pluginsLoaded(pluginsList) {
       this.timeEnd(TIMER.PLUGINS_LOADED);
       this.reporter(
@@ -299,6 +350,7 @@
      */
     time(name) {
       this._baselines[name] = this.now();
+      window.performance.mark(`${name}-start`);
     },
 
     /**
@@ -307,8 +359,13 @@
     timeEnd(name) {
       if (!this._baselines.hasOwnProperty(name)) { return; }
       const baseTime = this._baselines[name];
-      this._reportTiming(name, this.now() - baseTime);
       delete this._baselines[name];
+      this._reportTiming(name, this.now() - baseTime);
+
+      // Finalize the interval. Either from a registered start mark or
+      // the navigation start time (if baseTime is 0).
+      const startMark = baseTime === 0 ? undefined : `${name}-start`;
+      window.performance.measure(name, startMark);
     },
 
     /**
@@ -336,7 +393,8 @@
      * @param {number} time The time to report as an integer of milliseconds.
      */
     _reportTiming(name, time) {
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY, name, Math.round(time));
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name,
+          Math.round(time));
     },
 
     /**
@@ -347,22 +405,84 @@
      * @returns {!Object} The timer object.
      */
     getTimer(name) {
-      const start = this.now();
       let called = false;
-      return {
+      let start;
+      let max = null;
+
+      const timer = {
+
+        // Clear the timer and reset the start time.
+        reset: () => {
+          called = false;
+          start = this.now();
+          return timer;
+        },
+
+        // Stop the timer and report the intervening time.
         end: () => {
           if (called) {
             throw new Error(`Timer for "${name}" already ended.`);
           }
           called = true;
-          this._reportTiming(name, this.now() - start);
+          const time = this.now() - start;
+
+          // If a maximum is specified and the time exceeds it, do not report.
+          if (max && time > max) { return timer; }
+
+          this._reportTiming(name, time);
+          return timer;
+        },
+
+        // Set a maximum reportable time. If a maximum is set and the timer is
+        // ended after the specified amount of time, the value is not reported.
+        withMaximum(maximum) {
+          max = maximum;
+          return timer;
         },
       };
+
+      // The timer is initialized to its creation time.
+      return timer.reset();
+    },
+
+    /**
+     * Log timing information for an RPC.
+     * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
+     * @param {number} elapsed The time elapsed of the RPC.
+     */
+    reportRpcTiming(anonymizedUrl, elapsed) {
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl,
+          elapsed, true);
     },
 
     reportInteraction(eventName, opt_msg) {
       this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg);
     },
+
+    /**
+     * A draft interaction was started. Update the time-betweeen-draft-actions
+     * timer.
+     */
+    recordDraftInteraction() {
+      // If there is no timer defined, then this is the first interaction.
+      // Set up the timer so that it's ready to record the intervening time when
+      // called again.
+      const timer = this._timers.timeBetweenDraftActions;
+      if (!timer) {
+        // Create a timer with a maximum length.
+        this._timers.timeBetweenDraftActions = this.getTimer(DRAFT_ACTION_TIMER)
+            .withMaximum(DRAFT_ACTION_TIMER_MAX);
+        return;
+      }
+
+      // Mark the time and reinitialize the timer.
+      timer.end().reset();
+    },
+
+    reportErrorDialog(message) {
+      this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY,
+          'ErrorDialog: ' + message);
+    },
   });
 
   window.GrReporting = GrReporting;
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index ca3cfa4..f505311 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reporting</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-reporting.html">
 
@@ -61,11 +63,11 @@
     });
 
     test('appStarted', () => {
+      sandbox.stub(element, 'now').returns(42);
       element.appStarted(true);
       assert.isTrue(
           element.reporter.calledWithExactly(
-              'timing-report', 'UI Latency', 'App Started',
-              NOW_TIME - fakePerformance.navigationStart
+              'timing-report', 'UI Latency', 'App Started', 42
       ));
       assert.isTrue(
           element.reporter.calledWithExactly(
@@ -197,6 +199,43 @@
       }, 'Timer for "foo-bar" already ended.');
     });
 
+    test('timer object maximum', () => {
+      const nowStub = sandbox.stub(element, 'now').returns(100);
+      const timer = element.getTimer('foo-bar').withMaximum(100);
+      nowStub.returns(150);
+      timer.end();
+      assert.isTrue(element.reporter.calledOnce);
+
+      timer.reset();
+      nowStub.returns(260);
+      timer.end();
+      assert.isTrue(element.reporter.calledOnce);
+    });
+
+    test('recordDraftInteraction', () => {
+      const key = 'TimeBetweenDraftActions';
+      const nowStub = sandbox.stub(element, 'now').returns(100);
+      const timingStub = sandbox.stub(element, '_reportTiming');
+      element.recordDraftInteraction();
+      assert.isFalse(timingStub.called);
+
+      nowStub.returns(200);
+      element.recordDraftInteraction();
+      assert.isTrue(timingStub.calledOnce);
+      assert.equal(timingStub.lastCall.args[0], key);
+      assert.equal(timingStub.lastCall.args[1], 100);
+
+      nowStub.returns(350);
+      element.recordDraftInteraction();
+      assert.isTrue(timingStub.calledTwice);
+      assert.equal(timingStub.lastCall.args[0], key);
+      assert.equal(timingStub.lastCall.args[1], 150);
+
+      nowStub.returns(370 + 2 * 60 * 1000);
+      element.recordDraftInteraction();
+      assert.isFalse(timingStub.calledThrice);
+    });
+
     test('timeEndWithAverage', () => {
       const nowStub = sandbox.stub(element, 'now').returns(0);
       nowStub.returns(1000);
@@ -248,6 +287,11 @@
         assert.isTrue(element.defaultReporter.called);
       });
 
+      test('reports if metrics plugin xyz is loaded', () => {
+        element.pluginLoaded('metrics-xyz');
+        assert.isTrue(element.defaultReporter.called);
+      });
+
       test('reports cached events preserving order', () => {
         element.time('foo');
         element.time('bar');
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.html b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
index 7186b52..6035069 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
@@ -14,11 +14,11 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-reporting/gr-reporting.html">
@@ -28,6 +28,6 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
   </template>
-  <script src="../../../bower_components/page/page.js"></script>
+  <script src="/bower_components/page/page.js"></script>
   <script src="gr-router.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 6adc286..4d70bdc 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -38,6 +38,9 @@
     // Matches /admin/groups/[uuid-]<group>
     GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
 
+    // Redirects /groups/self to /settings/#Groups for GWT compatibility
+    GROUP_SELF: /^\/groups\/self/,
+
     // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
     // Redirects to /admin/groups/[uuid-]<group>
     GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
@@ -148,6 +151,10 @@
     IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
 
     PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
+
+    DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+    DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
+    DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
   };
 
   /**
@@ -176,6 +183,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');
@@ -188,7 +197,9 @@
     const reporting = document.createElement('gr-reporting');
 
     window.addEventListener('load', () => {
-      reporting.pageLoaded();
+      setTimeout(() => {
+        reporting.pageLoaded();
+      }, 0);
     });
 
     window.addEventListener('WebComponentsReady', () => {
@@ -198,6 +209,7 @@
 
   Polymer({
     is: 'gr-router',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _app: {
@@ -205,6 +217,12 @@
         value: app,
       },
       _isRedirecting: Boolean,
+      // This variable is to differentiate between internal navigation (false)
+      // and for first navigation in app after loaded from server (true).
+      _isInitialLoad: {
+        type: Boolean,
+        value: true,
+      },
     },
 
     behaviors: [
@@ -219,7 +237,17 @@
     },
 
     _setParams(params) {
-      this._app.params = params;
+      this._appElement().params = params;
+    },
+
+    _appElement() {
+      // In Polymer2 you have to reach through the shadow root of the app
+      // element. This obviously breaks encapsulation.
+      // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
+      // explicitly in app, or by delegating to it.
+      return document.getElementById('app-element') ||
+          document.getElementById('app').shadowRoot.getElementById(
+              'app-element');
     },
 
     _redirect(url) {
@@ -248,6 +276,8 @@
         url = this._generateGroupUrl(params);
       } else if (params.view === Views.REPO) {
         url = this._generateRepoUrl(params);
+      } else if (params.view === Views.ROOT) {
+        url = '/';
       } else if (params.view === Views.SETTINGS) {
         url = this._generateSettingsUrl(params);
       } else {
@@ -272,64 +302,53 @@
     },
 
     _getPatchSetWeblink(params) {
-      const {repo, commit, options} = params;
+      const {commit, options} = params;
       const {weblinks, config} = options || {};
       const name = commit && commit.slice(0, 7);
-      const gitwebConfigUrl = this._configBasedCommitUrl(repo, commit, config);
-      if (gitwebConfigUrl) {
-        return {
-          name,
-          url: gitwebConfigUrl,
-        };
-      }
-      const url = this._getSupportedWeblinkUrl(weblinks);
-      if (!url) {
+      const weblink = this._getBrowseCommitWeblink(weblinks, config);
+      if (!weblink || !weblink.url) {
         return {name};
       } else {
-        return {name, url};
+        return {name, url: weblink.url};
       }
     },
 
-    _configBasedCommitUrl(repo, commit, config) {
-      if (config && config.gitweb && config.gitweb.url &&
-          config.gitweb.type && config.gitweb.type.revision) {
-        return config.gitweb.url + config.gitweb.type.revision
-            .replace('${project}', repo)
-            .replace('${commit}', commit);
+    _firstCodeBrowserWeblink(weblinks) {
+      // This is an ordered whitelist of web link types that provide direct
+      // links to the commit in the url property.
+      const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
+      for (let i = 0; i < codeBrowserLinks.length; i++) {
+        const weblink =
+          weblinks.find(weblink => weblink.name === codeBrowserLinks[i]);
+        if (weblink) { return weblink; }
       }
+      return null;
     },
 
-    _isDirectCommit(link) {
-      // This is a whitelist of web link types that provide direct links to
-      // the commit in the url property.
-      return link.name === 'gitiles' || link.name === 'gitweb';
-    },
 
-    _getSupportedWeblinkUrl(weblinks) {
+    _getBrowseCommitWeblink(weblinks, config) {
       if (!weblinks) { return null; }
-      const weblink = weblinks.find(this._isDirectCommit);
-      if (!weblink) { return null; }
-      const url = weblink.url;
-      if (url.startsWith('https:') || url.startsWith('http:')) {
-        return url;
-      } else {
-        return `../../${url}`;
+      let weblink;
+      // Use primary weblink if configured and exists.
+      if (config && config.gerrit && config.gerrit.primary_weblink_name) {
+        weblink = weblinks.find(
+            weblink => weblink.name === config.gerrit.primary_weblink_name
+        );
       }
+      if (!weblink) {
+        weblink = this._firstCodeBrowserWeblink(weblinks);
+      }
+      if (!weblink) { return null; }
+      return weblink;
     },
 
-    _getChangeWeblinks({repo, commit, options: {weblinks}}) {
+    _getChangeWeblinks({repo, commit, options: {weblinks, config}}) {
       if (!weblinks || !weblinks.length) return [];
-      return weblinks.filter(weblink => !this._isDirectCommit(weblink)).map(
-          ({name, url}) => {
-            if (url.startsWith('https:') || url.startsWith('http:')) {
-              return {name, url};
-            } else {
-              return {
-                name,
-                url: `../../${url}`,
-              };
-            }
-          });
+      const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
+      return weblinks.filter(weblink =>
+        !commitWeblink ||
+        !commitWeblink.name ||
+        weblink.name !== commitWeblink.name);
     },
 
     _getFileWebLinks({repo, commit, file, options: {weblinks}}) {
@@ -396,8 +415,12 @@
       } else if (params.edit) {
         suffix += ',edit';
       }
+      if (params.messageHash) {
+        suffix += params.messageHash;
+      }
       if (params.project) {
-        return `/c/${params.project}/+/${params.changeNum}${suffix}`;
+        const encodedProject = this.encodeURL(params.project, true);
+        return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
       } else {
         return `/c/${params.changeNum}${suffix}`;
       }
@@ -408,20 +431,20 @@
      * @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}`;
+        const encodedRepo = this.encodeURL(repoName, true);
+        return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
       } else {
         // User dashboard.
         return `/dashboard/${params.user || 'self'}`;
@@ -429,6 +452,23 @@
     },
 
     /**
+     * @param {!Array<!{name: string, query: string}>} sections
+     * @param {string=} opt_repoName
+     * @return {!Array<string>}
+     */
+    _sectionsToEncodedParams(sections, opt_repoName) {
+      return sections.map(section => {
+        // If there is a repo name provided, make sure to substitute it into the
+        // ${repo} (or legacy ${project}) query tokens.
+        const query = opt_repoName ?
+            section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
+            section.query;
+        return encodeURIComponent(section.name) + '=' +
+            encodeURIComponent(query);
+      });
+    },
+
+    /**
      * @param {!Object} params
      * @return {string}
      */
@@ -447,7 +487,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}`;
       }
@@ -617,7 +658,7 @@
           return Promise.resolve();
         } else {
           this._redirectToLogin(data.canonicalPath);
-          return Promise.reject();
+          return Promise.reject(new Error());
         }
       });
     },
@@ -663,7 +704,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) => {
@@ -671,6 +713,7 @@
           this.$.reporting.beforeLocationChanged();
         }
         this._isRedirecting = false;
+        this._isInitialLoad = false;
         next();
       });
 
@@ -724,6 +767,9 @@
       this._mapRoute(RoutePattern.GROUP_LIST_FILTER,
           '_handleGroupListFilterRoute', true);
 
+      this._mapRoute(RoutePattern.GROUP_SELF, '_handleGroupSelfRedirectRoute',
+          true);
+
       this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
 
       this._mapRoute(RoutePattern.PROJECT_OLD,
@@ -827,6 +873,17 @@
 
       this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
 
+      this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH_FILTER,
+          '_handleDocumentationSearchRoute');
+
+      // redirects /Documentation/q/* to /Documentation/q/filter:*
+      this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH,
+          '_handleDocumentationSearchRedirectRoute');
+
+      // Makes sure /Documentation/* links work (doin't return 404)
+      this._mapRoute(RoutePattern.DOCUMENTATION,
+          '_handleDocumentationRedirectRoute');
+
       // Note: this route should appear last so it only catches URLs unmatched
       // by other patterns.
       this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
@@ -1006,6 +1063,10 @@
       this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
     },
 
+    _handleGroupSelfRedirectRoute(data) {
+      this._redirect('/settings/#Groups');
+    },
+
     _handleGroupRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.GROUP,
@@ -1057,11 +1118,16 @@
     },
 
     _handleProjectsOldRoute(data) {
+      let params = '';
       if (data.params[1]) {
-        this._redirect('/admin/repos/' + encodeURIComponent(data.params[1]));
-      } else {
-        this._redirect('/admin/repos');
+        params = encodeURIComponent(data.params[1]);
+        if (data.params[1].includes(',')) {
+          params =
+              encodeURIComponent(data.params[1]).replace('%2C', ',');
+        }
       }
+
+      this._redirect(`/admin/repos/${params}`);
     },
 
     _handleRepoCommandsRoute(data) {
@@ -1176,7 +1242,7 @@
     _handleCreateProjectRoute(data) {
       // Redirects the legacy route to the new route, which displays the project
       // list with a hash 'create'.
-      this._redirect('/admin/projects#create');
+      this._redirect('/admin/repos#create');
     },
 
     _handleCreateGroupRoute(data) {
@@ -1404,18 +1470,45 @@
       this._setParams({view, plugin, screen});
     },
 
+    _handleDocumentationSearchRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.DOCUMENTATION_SEARCH,
+        filter: data.params.filter || null,
+      });
+    },
+
+    _handleDocumentationSearchRedirectRoute(data) {
+      this._redirect('/Documentation/q/filter:' +
+          encodeURIComponent(data.params[0]));
+    },
+
+    _handleDocumentationRedirectRoute(data) {
+      if (data.params[1]) {
+        location.reload();
+      } else {
+        // Redirect /Documentation to /Documentation/index.html
+        this._redirect('/Documentation/index.html');
+      }
+    },
+
     /**
      * Catchall route for when no other route is matched.
      */
     _handleDefaultRoute() {
-      this._show404();
+      if (this._isInitialLoad) {
+        // Server recognized this route as polygerrit, so we show 404.
+        this._show404();
+      } else {
+        // Route can be recognized by server, so we pass it to server.
+        this._handlePassThroughRoute();
+      }
     },
 
     _show404() {
       // Note: the app's 404 display is tightly-coupled with catching 404
       // network responses, so we simulate a 404 response status to display it.
       // TODO: Decouple the gr-app error view from network responses.
-      this._app.dispatchEvent(new CustomEvent('page-error',
+      this._appElement().dispatchEvent(new CustomEvent('page-error',
           {detail: {response: {status: 404}}}));
     },
   });
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..27016e8 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-router</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-router.html">
 
@@ -44,6 +46,49 @@
 
     teardown(() => { sandbox.restore(); });
 
+    test('_firstCodeBrowserWeblink', () => {
+      assert.deepEqual(element._firstCodeBrowserWeblink([
+        {name: 'gitweb'},
+        {name: 'gitiles'},
+        {name: 'browse'},
+        {name: 'test'}]), {name: 'gitiles'});
+
+      assert.deepEqual(element._firstCodeBrowserWeblink([
+        {name: 'gitweb'},
+        {name: 'test'}]), {name: 'gitweb'});
+    });
+
+    test('_getBrowseCommitWeblink', () => {
+      const browserLink = {name: 'browser', url: 'browser/url'};
+      const link = {name: 'test', url: 'test/url'};
+      const weblinks = [browserLink, link];
+      const config = {gerrit: {primary_weblink_name: browserLink.name}};
+      sandbox.stub(element, '_firstCodeBrowserWeblink').returns(link);
+
+      assert.deepEqual(element._getBrowseCommitWeblink(weblinks, config),
+          browserLink);
+
+      assert.deepEqual(element._getBrowseCommitWeblink(weblinks, {}), link);
+    });
+
+    test('_getChangeWeblinks', () => {
+      const link = {name: 'test', url: 'test/url'};
+      const browserLink = {name: 'browser', url: 'browser/url'};
+      const mapLinksToConfig = weblinks => ({options: {weblinks}});
+      sandbox.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
+
+      assert.deepEqual(
+          element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
+          {name: 'test', url: 'test/url'});
+
+      assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
+          {name: 'test', url: 'test/url'});
+
+      link.url = 'https://' + link.url;
+      assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
+          {name: 'test', url: 'https://test/url'});
+    });
+
     test('_getHashFromCanonicalPath', () => {
       let url = '/foo/bar';
       let hash = element._getHashFromCanonicalPath(url);
@@ -136,6 +181,7 @@
         '_handleGroupListOffsetRoute',
         '_handleGroupMembersRoute',
         '_handleGroupRoute',
+        '_handleGroupSelfRedirectRoute',
         '_handleNewAgreementsRoute',
         '_handlePluginListFilterOffsetRoute',
         '_handlePluginListFilterRoute',
@@ -157,6 +203,9 @@
         '_handleDefaultRoute',
         '_handleChangeLegacyRoute',
         '_handleDiffLegacyRoute',
+        '_handleDocumentationRedirectRoute',
+        '_handleDocumentationSearchRoute',
+        '_handleDocumentationSearchRedirectRoute',
         '_handleLegacyLinenum',
         '_handleImproperlyEncodedPlusRoute',
         '_handlePassThroughRoute',
@@ -281,6 +330,19 @@
         paramsWithQuery.basePatchNum = 5;
         assert.equal(element._generateUrl(paramsWithQuery),
             '/c/test/+/1234/5..10?revert&foo=bar');
+
+        params.messageHash = '#123';
+        assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
+      });
+
+      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', () => {
@@ -317,6 +379,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 +449,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 +476,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',
@@ -534,6 +634,7 @@
     suite('route handlers', () => {
       let redirectStub;
       let setParamsStub;
+      let handlePassThroughRoute;
 
       // Simple route handlers are direct mappings from parsed route data to a
       // new set of app.params. This test helper asserts that passing `data`
@@ -546,6 +647,7 @@
       setup(() => {
         redirectStub = sandbox.stub(element, '_redirect');
         setParamsStub = sandbox.stub(element, '_setParams');
+        handlePassThroughRoute = sandbox.stub(element, '_handlePassThroughRoute');
       });
 
       test('_handleAgreementsRoute', () => {
@@ -577,15 +679,38 @@
         });
       });
 
-      test('_handleDefaultRoute', () => {
-        element._app = {dispatchEvent: sinon.stub()};
+      test('_handleDefaultRoute on first load', () => {
+        const appElementStub = {dispatchEvent: sinon.stub()};
+        element._appElement = () => appElementStub;
         element._handleDefaultRoute();
-        assert.isTrue(element._app.dispatchEvent.calledOnce);
+        assert.isTrue(appElementStub.dispatchEvent.calledOnce);
         assert.equal(
-            element._app.dispatchEvent.lastCall.args[0].detail.response.status,
+            appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
             404);
       });
 
+      test('_handleDefaultRoute after internal navigation', () => {
+        let onExit = null;
+        const onRegisteringExit = (match, _onExit) => {
+          onExit = _onExit;
+        };
+        sandbox.stub(window.page, 'exit', onRegisteringExit);
+        sandbox.stub(Gerrit.Nav, 'setup');
+        sandbox.stub(window.page, 'start');
+        sandbox.stub(window.page, 'base');
+        sandbox.stub(window, 'page');
+        element._startRouter();
+
+        const appElementStub = {dispatchEvent: sinon.stub()};
+        element._appElement = () => appElementStub;
+        element._handleDefaultRoute();
+
+        onExit('', () => {}); // we left page;
+
+        element._handleDefaultRoute();
+        assert.isTrue(handlePassThroughRoute.calledOnce);
+      });
+
       test('_handleImproperlyEncodedPlusRoute', () => {
         // Regression test for Issue 7100.
         element._handleImproperlyEncodedPlusRoute(
@@ -625,6 +750,22 @@
         assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
       });
 
+      test('_handleQueryRoute', () => {
+        const data = {params: ['project:foo/bar/baz']};
+        assertDataToParams(data, '_handleQueryRoute', {
+          view: Gerrit.Nav.View.SEARCH,
+          query: 'project:foo/bar/baz',
+          offset: undefined,
+        });
+
+        data.params.push(',123', '123');
+        assertDataToParams(data, '_handleQueryRoute', {
+          view: Gerrit.Nav.View.SEARCH,
+          query: 'project:foo/bar/baz',
+          offset: '123',
+        });
+      });
+
       suite('_handleRegisterRoute', () => {
         test('happy path', () => {
           const ctx = {params: ['/foo/bar']};
@@ -967,6 +1108,28 @@
       });
 
       suite('repo routes', () => {
+        test('_handleProjectsOldRoute', () => {
+          const data = {params: {}};
+          element._handleProjectsOldRoute(data);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
+        });
+
+        test('_handleProjectsOldRoute test', () => {
+          const data = {params: {1: 'test'}};
+          element._handleProjectsOldRoute(data);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
+        });
+
+        test('_handleProjectsOldRoute test,branches', () => {
+          const data = {params: {1: 'test,branches'}};
+          element._handleProjectsOldRoute(data);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(
+              redirectStub.lastCall.args[0], '/admin/repos/test,branches');
+        });
+
         test('_handleRepoRoute', () => {
           const data = {params: {0: 4321}};
           assertDataToParams(data, '_handleRepoRoute', {
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
index 39b2f7e..d766f74 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -15,9 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../../styles/shared-styles.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/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 2d0a759..e37fb2e 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -36,7 +36,12 @@
     'conflicts:',
     'deleted:',
     'delta:',
+    'dir:',
+    'directory:',
+    'ext:',
+    'extension:',
     'file:',
+    'footer:',
     'from:',
     'has:',
     'has:draft',
@@ -60,10 +65,13 @@
     'is:reviewed',
     'is:reviewer',
     'is:starred',
+    'is:submittable',
     'is:watched',
     'is:wip',
     'label:',
     'message:',
+    'onlyexts:',
+    'onlyextensions:',
     'owner:',
     'ownerin:',
     'parentproject:',
@@ -98,6 +106,7 @@
 
   Polymer({
     is: 'gr-search-bar',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a search is committed
@@ -110,10 +119,6 @@
       Gerrit.URLEncodingBehavior,
     ],
 
-    keyBindings: {
-      '/': '_handleForwardSlashKey',
-    },
-
     properties: {
       value: {
         type: String,
@@ -156,6 +161,12 @@
       },
     },
 
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.SEARCH]: '_handleSearch',
+      };
+    },
+
     _valueChanged(value) {
       this._inputVal = value;
     },
@@ -194,7 +205,7 @@
      *     to _getSearchSuggestions.
      * @param {string} input - The full search term, in lowercase.
      * @return {!Promise} This returns a promise that resolves to an array of
-     *     strings.
+     *     suggestion objects.
      */
     _fetchSuggestions(input) {
       // Split the input on colon to get a two part predicate/expression.
@@ -226,7 +237,8 @@
 
         default:
           return Promise.resolve(SEARCH_OPERATORS_WITH_NEGATIONS
-              .filter(operator => operator.includes(input)));
+              .filter(operator => operator.includes(input))
+              .map(operator => ({text: operator})));
       }
     },
 
@@ -234,7 +246,7 @@
      * Get the sorted, pruned list of suggestions for the current search query.
      * @param {string} input - The complete search query.
      * @return {!Promise} This returns a promise that resolves to an array of
-     *     strings.
+     *     suggestions.
      */
     _getSearchSuggestions(input) {
       // Allow spaces within quoted terms.
@@ -242,15 +254,15 @@
       const trimmedInput = tokens[tokens.length - 1].toLowerCase();
 
       return this._fetchSuggestions(trimmedInput)
-          .then(operators => {
-            if (!operators || !operators.length) { return []; }
-            return operators
+          .then(suggestions => {
+            if (!suggestions || !suggestions.length) { return []; }
+            return suggestions
                 // Prioritize results that start with the input.
                 .sort((a, b) => {
-                  const aContains = a.toLowerCase().indexOf(trimmedInput);
-                  const bContains = b.toLowerCase().indexOf(trimmedInput);
+                  const aContains = a.text.toLowerCase().indexOf(trimmedInput);
+                  const bContains = b.text.toLowerCase().indexOf(trimmedInput);
                   if (aContains === bContains) {
-                    return a.localeCompare(b);
+                    return a.text.localeCompare(b.text);
                   }
                   if (aContains === -1) {
                     return 1;
@@ -263,16 +275,17 @@
                 // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
                 .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
                 // Map to an object to play nice with gr-autocomplete.
-                .map(operator => {
+                .map(({text, label}) => {
                   return {
-                    name: operator,
-                    value: operator,
+                    name: text,
+                    value: text,
+                    label,
                   };
                 });
           });
     },
 
-    _handleForwardSlashKey(e) {
+    _handleSearch(e) {
       const keyboardEvent = this.getKeyboardEvent(e);
       if (this.shouldSuppressKeyboardShortcut(e) ||
           (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; }
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index 60d6237..1365c00 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-search-bar</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="gr-search-bar.html">
 <script src="../../../scripts/util.js"></script>
@@ -37,6 +39,9 @@
 
 <script>
   suite('gr-search-bar tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.SEARCH, '/');
+
     let element;
     let sandbox;
 
@@ -99,7 +104,7 @@
     suite('_getSearchSuggestions', () => {
       test('Autocompletes accounts', () => {
         sandbox.stub(element, 'accountSuggestions', () =>
-          Promise.resolve(['owner:fred@goog.co'])
+          Promise.resolve([{text: 'owner:fred@goog.co'}])
         );
         return element._getSearchSuggestions('owner:fr').then(s => {
           assert.equal(s[0].value, 'owner:fred@goog.co');
@@ -109,8 +114,8 @@
       test('Autocompletes groups', done => {
         sandbox.stub(element, 'groupSuggestions', () =>
           Promise.resolve([
-            'ownerin:Polygerrit',
-            'ownerin:gerrit',
+            {text: 'ownerin:Polygerrit'},
+            {text: 'ownerin:gerrit'},
           ])
         );
         element._getSearchSuggestions('ownerin:pol').then(s => {
@@ -122,9 +127,9 @@
       test('Autocompletes projects', done => {
         sandbox.stub(element, 'projectSuggestions', () =>
           Promise.resolve([
-            'project:Polygerrit',
-            'project:gerrit',
-            'project:gerrittest',
+            {text: 'project:Polygerrit'},
+            {text: 'project:gerrit'},
+            {text: 'project:gerrittest'},
           ])
         );
         element._getSearchSuggestions('project:pol').then(s => {
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html
index 4c98068..06e354c 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
index 5dcd2bb..fed02d6 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
@@ -23,6 +23,7 @@
 
   Polymer({
     is: 'gr-smart-search',
+    _legacyUndefinedCheck: true,
 
     properties: {
       searchQuery: String,
@@ -84,7 +85,7 @@
           .then(projects => {
             if (!projects) { return []; }
             const keys = Object.keys(projects);
-            return keys.map(key => predicate + ':' + key);
+            return keys.map(key => ({text: predicate + ':' + key}));
           });
     },
 
@@ -105,7 +106,7 @@
           .then(groups => {
             if (!groups) { return []; }
             const keys = Object.keys(groups);
-            return keys.map(key => predicate + ':' + key);
+            return keys.map(key => ({text: predicate + ':' + key}));
           });
     },
 
@@ -125,20 +126,28 @@
           MAX_AUTOCOMPLETE_RESULTS)
           .then(accounts => {
             if (!accounts) { return []; }
-            return accounts.map(acct => acct.email ?
-              `${predicate}:${acct.email}` :
-              `${predicate}:"${this._accountOrAnon(acct)}"`);
+            return this._mapAccountsHelper(accounts, predicate);
           }).then(accounts => {
             // When the expression supplied is a beginning substring of 'self',
             // add it as an autocomplete option.
             if (SELF_EXPRESSION.startsWith(expression)) {
-              return accounts.concat([predicate + ':' + SELF_EXPRESSION]);
+              return accounts.concat(
+                  [{text: predicate + ':' + SELF_EXPRESSION}]);
             } else if (ME_EXPRESSION.startsWith(expression)) {
-              return accounts.concat([predicate + ':' + ME_EXPRESSION]);
+              return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
             } else {
               return accounts;
             }
           });
     },
+
+    _mapAccountsHelper(accounts, predicate) {
+      return accounts.map(account => ({
+        label: account.name || '',
+        text: account.email ?
+            `${predicate}:${account.email}` :
+            `${predicate}:"${this._accountOrAnon(account)}"`,
+      }));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
index 66dc0f0..a70eb7c 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-smart-search</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-smart-search.html">
 
@@ -57,11 +59,11 @@
         ])
       );
       return element._fetchAccounts('owner', 'fr').then(s => {
-        assert.equal(s[0], 'owner:fred@goog.co');
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
       });
     });
 
-    test('Inserts self as option when valid', done => {
+    test('Inserts self as option when valid', () => {
       sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
         Promise.resolve([
           {
@@ -71,17 +73,14 @@
         ])
       );
       element._fetchAccounts('owner', 's').then(s => {
-        assert.equal(s[0], 'owner:fred@goog.co');
-        assert.equal(s[1], 'owner:self');
-      }).then(() => {
-        element._fetchAccounts('owner', 'selfs').then(s => {
-          assert.notEqual(s[0], 'owner:self');
-          done();
-        });
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+        assert.deepEqual(s[1], {text: 'owner:self'});
+      }).then(() => element._fetchAccounts('owner', 'selfs')).then(s => {
+        assert.notEqual(s[0], {text: 'owner:self'});
       });
     });
 
-    test('Inserts me as option when valid', done => {
+    test('Inserts me as option when valid', () => {
       sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
         Promise.resolve([
           {
@@ -90,18 +89,15 @@
           },
         ])
       );
-      element._fetchAccounts('owner', 'm').then(s => {
-        assert.equal(s[0], 'owner:fred@goog.co');
-        assert.equal(s[1], 'owner:me');
-      }).then(() => {
-        element._fetchAccounts('owner', 'meme').then(s => {
-          assert.notEqual(s[0], 'owner:me');
-          done();
-        });
+      return element._fetchAccounts('owner', 'm').then(s => {
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+        assert.deepEqual(s[1], {text: 'owner:me'});
+      }).then(() => element._fetchAccounts('owner', 'meme')).then(s => {
+        assert.notEqual(s[0], {text: 'owner:me'});
       });
     });
 
-    test('Autocompletes groups', done => {
+    test('Autocompletes groups', () => {
       sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
         Promise.resolve({
           Polygerrit: 0,
@@ -109,25 +105,20 @@
           gerrittest: 0,
         })
       );
-      element._fetchGroups('ownerin', 'pol').then(s => {
-        assert.equal(s[0], 'ownerin:Polygerrit');
-        done();
+      return element._fetchGroups('ownerin', 'pol').then(s => {
+        assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
       });
     });
 
-    test('Autocompletes projects', done => {
+    test('Autocompletes projects', () => {
       sandbox.stub(element.$.restAPI, 'getSuggestedProjects', () =>
-        Promise.resolve({
-          Polygerrit: 0,
-        })
-      );
-      element._fetchProjects('project', 'pol').then(s => {
-        assert.equal(s[0], 'project:Polygerrit');
-        done();
+        Promise.resolve({Polygerrit: 0}));
+      return element._fetchProjects('project', 'pol').then(s => {
+        assert.deepEqual(s[0], {text: 'project:Polygerrit'});
       });
     });
 
-    test('Autocomplete doesnt override exact matches to input', done => {
+    test('Autocomplete doesnt override exact matches to input', () => {
       sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
         Promise.resolve({
           Polygerrit: 0,
@@ -135,39 +126,26 @@
           gerrittest: 0,
         })
       );
-      element._fetchGroups('ownerin', 'gerrit').then(s => {
-        assert.equal(s[0], 'ownerin:Polygerrit');
-        assert.equal(s[1], 'ownerin:gerrit');
-        assert.equal(s[2], 'ownerin:gerrittest');
-        done();
+      return element._fetchGroups('ownerin', 'gerrit').then(s => {
+        assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+        assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
+        assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
       });
     });
 
-    test('Autocompletes accounts with no email', done => {
+    test('Autocompletes accounts with no email', () => {
       sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([
-          {
-            name: 'fred',
-          },
-        ])
-      );
-      element._fetchAccounts('owner', 'fr').then(s => {
-        assert.equal(s[0], 'owner:"fred"');
-        done();
+        Promise.resolve([{name: 'fred'}]));
+      return element._fetchAccounts('owner', 'fr').then(s => {
+        assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
       });
     });
 
-    test('Autocompletes accounts with email', done => {
+    test('Autocompletes accounts with email', () => {
       sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([
-          {
-            email: 'fred@goog.co',
-          },
-        ])
-      );
-      element._fetchAccounts('owner', 'fr').then(s => {
-        assert.equal(s[0], 'owner:fred@goog.co');
-        done();
+        Promise.resolve([{email: 'fred@goog.co'}]));
+      return element._fetchAccounts('owner', 'fr').then(s => {
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
index b7994e6..7bf71f5 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'comment-api-mock',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _changeComments: Object,
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
index c31bd1166..317e9e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<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="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
index 4b64f7b..b5ff9a3 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -482,6 +482,7 @@
 
   Polymer({
     is: 'gr-comment-api',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _changeComments: Object,
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
index 1e53a14..c44e8c4 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-api</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-comment-api.html">
diff --git a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
deleted file mode 100644
index 22fd2aa..0000000
--- a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
+++ /dev/null
@@ -1,72 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-confirm-delete-comment-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: .5;
-        pointer-events: none;
-      }
-      .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-      label {
-        cursor: pointer;
-        display: block;
-        width: 100%;
-      }
-      iron-autogrow-textarea {
-        font-family: var(--monospace-font-family);
-        padding: 0;
-        width: 73ch; /* Add a char to account for the border. */
-
-        --iron-autogrow-textarea {
-          border: 1px solid var(--border-color);
-          box-sizing: border-box;
-          font-family: var(--monospace-font-family);
-        }
-      }
-    </style>
-    <gr-confirm-dialog
-        confirm-label="Delete"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
-      <div class="header" slot="header">Delete Comment</div>
-      <div class="main" slot="main">
-        <label for="messageInput">Enter comment delete reason</label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            placeholder="<Insert reasoning here>"
-            bind-value="{{message}}"></iron-autogrow-textarea>
-      </div>
-    </gr-confirm-dialog>
-  </template>
-  <script src="gr-confirm-delete-comment-dialog.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
deleted file mode 100644
index b86b72a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * @license
- * 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.
- */
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-confirm-delete-comment-dialog',
-
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
-
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    properties: {
-      message: String,
-    },
-
-    resetFocus() {
-      this.$.messageInput.textarea.focus();
-    },
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      this.fire('confirm', {reason: this.message}, {bubbles: false});
-    },
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      this.fire('cancel', null, {bubbles: false});
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html
new file mode 100644
index 0000000..d743d92
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html
@@ -0,0 +1,24 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<dom-module id="gr-coverage-layer">
+  <template>
+  </template>
+  <script src="../gr-diff-highlight/gr-annotation.js"></script>
+  <script src="gr-coverage-layer.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
new file mode 100644
index 0000000..e8d6900
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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';
+
+  /** @enum {string} */
+  Gerrit.CoverageType = {
+    /**
+     * start_character and end_character of the range will be ignored for this
+     * type.
+     */
+    COVERED: 'COVERED',
+    /**
+     * start_character and end_character of the range will be ignored for this
+     * type.
+     */
+    NOT_COVERED: 'NOT_COVERED',
+    PARTIALLY_COVERED: 'PARTIALLY_COVERED',
+    /**
+     * You don't have to use this. If there is no coverage information for a
+     * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+     * end_character of the range will be ignored for this type.
+     */
+    NOT_INSTRUMENTED: 'NOT_INSTRUMENTED',
+  };
+
+  const TOOLTIP_MAP = new Map([
+    [Gerrit.CoverageType.COVERED, 'Covered by tests.'],
+    [Gerrit.CoverageType.NOT_COVERED, 'Not covered by tests.'],
+    [Gerrit.CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
+    [Gerrit.CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
+  ]);
+
+  /**
+   * @typedef {{
+   *   side: string,
+   *   type: Gerrit.CoverageType,
+   *   code_range: Gerrit.Range,
+   * }}
+   */
+  Gerrit.CoverageRange;
+
+  Polymer({
+    is: 'gr-coverage-layer',
+
+    properties: {
+      /**
+       * Must be sorted by code_range.start_line.
+       * Must only contain ranges that match the side.
+       *
+       * @type {!Array<!Gerrit.CoverageRange>}
+       */
+      coverageRanges: Array,
+      side: String,
+
+      /**
+       * We keep track of the line number from the previous annotate() call,
+       * and also of the index of the coverage range that had matched.
+       * annotate() calls are coming in with increasing line numbers and
+       * coverage ranges are sorted by line number. So this is a very simple
+       * and efficient way for finding the coverage range that matches a given
+       * line number.
+       */
+      _lineNumber: {
+        type: Number,
+        value: 0,
+      },
+      _index: {
+        type: Number,
+        value: 0,
+      },
+    },
+
+    /**
+     * Layer method to add annotations to a line.
+     *
+     * @param {!HTMLElement} el Not used for this layer.
+     * @param {!HTMLElement} lineNumberEl The <td> element with the line number.
+     * @param {!Object} line Not used for this layer.
+     */
+    annotate(el, lineNumberEl, line) {
+      if (!lineNumberEl || !lineNumberEl.classList.contains(this.side)) {
+        return;
+      }
+      const elementLineNumber = parseInt(
+          lineNumberEl.getAttribute('data-value'), 10);
+      if (!elementLineNumber || elementLineNumber < 1) return;
+
+      // If the line number is smaller than before, then we have to reset our
+      // algorithm and start searching the coverage ranges from the beginning.
+      // That happens for example when you expand diff sections.
+      if (elementLineNumber < this._lineNumber) {
+        this._index = 0;
+      }
+      this._lineNumber = elementLineNumber;
+
+      // We simply loop through all the coverage ranges until we find one that
+      // matches the line number.
+      while (this._index < this.coverageRanges.length) {
+        const coverageRange = this.coverageRanges[this._index];
+
+        // If the line number has moved past the current coverage range, then
+        // try the next coverage range.
+        if (this._lineNumber > coverageRange.code_range.end_line) {
+          this._index++;
+          continue;
+        }
+
+        // If the line number has not reached the next coverage range (and the
+        // range before also did not match), then this line has not been
+        // instrumented. Nothing to do for this line.
+        if (this._lineNumber < coverageRange.code_range.start_line) {
+          return;
+        }
+
+        // The line number is within the current coverage range. Style it!
+        lineNumberEl.classList.add(coverageRange.type);
+        lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type);
+        return;
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
new file mode 100644
index 0000000..45a67e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
@@ -0,0 +1,140 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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-coverage-layer</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="../gr-diff/gr-diff-line.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-coverage-layer.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-coverage-layer></gr-coverage-layer>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-coverage-layer', () => {
+    let element;
+
+    setup(() => {
+      const initialCoverageRanges = [
+        {
+          type: 'COVERED',
+          side: 'right',
+          code_range: {
+            start_line: 1,
+            end_line: 2,
+          },
+        },
+        {
+          type: 'NOT_COVERED',
+          side: 'right',
+          code_range: {
+            start_line: 3,
+            end_line: 4,
+          },
+        },
+        {
+          type: 'PARTIALLY_COVERED',
+          side: 'right',
+          code_range: {
+            start_line: 5,
+            end_line: 6,
+          },
+        },
+        {
+          type: 'NOT_INSTRUMENTED',
+          side: 'right',
+          code_range: {
+            start_line: 8,
+            end_line: 9,
+          },
+        },
+      ];
+
+      element = fixture('basic');
+      element.coverageRanges = initialCoverageRanges;
+      element.side = 'right';
+    });
+
+    suite('annotate', () => {
+      function createLine(lineNumber) {
+        lineEl = document.createElement('div');
+        lineEl.setAttribute('data-side', 'right');
+        lineEl.setAttribute('data-value', lineNumber);
+        lineEl.className = 'right';
+        return lineEl;
+      }
+
+      function checkLine(lineNumber, className, opt_negated) {
+        const line = createLine(lineNumber);
+        element.annotate(undefined, line, undefined);
+        let contains = line.classList.contains(className);
+        if (opt_negated) contains = !contains;
+        assert.isTrue(contains);
+      }
+
+      test('line 1-2 are covered', () => {
+        checkLine(1, 'COVERED');
+        checkLine(2, 'COVERED');
+      });
+
+      test('line 3-4 are not covered', () => {
+        checkLine(3, 'NOT_COVERED');
+        checkLine(4, 'NOT_COVERED');
+      });
+
+      test('line 5-6 are partially covered', () => {
+        checkLine(5, 'PARTIALLY_COVERED');
+        checkLine(6, 'PARTIALLY_COVERED');
+      });
+
+      test('line 7 is implicitly not instrumented', () => {
+        checkLine(7, 'COVERED', true);
+        checkLine(7, 'NOT_COVERED', true);
+        checkLine(7, 'PARTIALLY_COVERED', true);
+        checkLine(7, 'NOT_INSTRUMENTED', true);
+      });
+
+      test('line 8-9 are not instrumented', () => {
+        checkLine(8, 'NOT_INSTRUMENTED');
+        checkLine(9, 'NOT_INSTRUMENTED');
+      });
+
+      test('coverage correct, if annotate is called out of order', () => {
+        checkLine(8, 'NOT_INSTRUMENTED');
+        checkLine(1, 'COVERED');
+        checkLine(5, 'PARTIALLY_COVERED');
+        checkLine(3, 'NOT_COVERED');
+        checkLine(6, 'PARTIALLY_COVERED');
+        checkLine(4, 'NOT_COVERED');
+        checkLine(9, 'NOT_INSTRUMENTED');
+        checkLine(2, 'COVERED');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
index 02ad67b..6f5a8d3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
@@ -14,15 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffBuilderSideBySide) {
+(function(window, GrDiffBuilder) {
   'use strict';
 
   // Prevent redefinition.
   if (window.GrDiffBuilderBinary) { return; }
 
-  function GrDiffBuilderBinary(diff, comments, prefs, projectName, outputEl) {
-    GrDiffBuilder.call(this, diff, comments, prefs, projectName, outputEl);
-    console.log('binary village');
+  function GrDiffBuilderBinary(diff, prefs, outputEl) {
+    GrDiffBuilder.call(this, diff, prefs, outputEl);
   }
 
   GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
@@ -44,4 +43,4 @@
   };
 
   window.GrDiffBuilderBinary = GrDiffBuilderBinary;
-})(window, GrDiffBuilderSideBySide);
+})(window, GrDiffBuilder);
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 27f8d39..11bea8c 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
@@ -20,12 +20,12 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderImage) { return; }
 
-  const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|jpeg|jpg|png|tiff|webp)$/;
+  // MIME types for images we allow showing. Do not include SVG, it can contain
+  // arbitrary JavaScript.
+  const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
 
-  function GrDiffBuilderImage(
-      diff, comments, prefs, projectName, outputEl, baseImage, revisionImage) {
-    GrDiffBuilderSideBySide.call(
-        this, diff, comments, prefs, projectName, outputEl, []);
+  function GrDiffBuilderImage(diff, prefs, outputEl, baseImage, revisionImage) {
+    GrDiffBuilderSideBySide.call(this, diff, prefs, outputEl, []);
     this._baseImage = baseImage;
     this._revisionImage = revisionImage;
   }
@@ -75,10 +75,10 @@
   GrDiffBuilderImage.prototype._emitImagePair = function(section) {
     const tr = this._createElement('tr');
 
-    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createElement('td', 'left lineNum blank'));
     tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
 
-    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createElement('td', 'right lineNum blank'));
     tr.appendChild(this._createImageCell(
         this._revisionImage, 'right', section));
 
@@ -126,7 +126,7 @@
       addNamesInLabel = true;
     }
 
-    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createElement('td', 'left lineNum blank'));
     let td = this._createElement('td', 'left');
     let label = this._createElement('label');
     let nameSpan;
@@ -145,7 +145,7 @@
     td.appendChild(label);
     tr.appendChild(td);
 
-    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createElement('td', 'right lineNum blank'));
     td = this._createElement('td', 'right');
     label = this._createElement('label');
     labelSpan = this._createElement('span', 'label');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index 3d6dedd..bb590ba 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -20,10 +20,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderSideBySide) { return; }
 
-  function GrDiffBuilderSideBySide(
-      diff, comments, prefs, projectName, outputEl, layers) {
-    GrDiffBuilder.call(
-        this, diff, comments, prefs, projectName, outputEl, layers);
+  function GrDiffBuilderSideBySide(diff, prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
   }
   GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
@@ -37,6 +35,9 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
+    if (group.ignoredWhitespaceOnly) {
+      sectionEl.classList.add('ignoredWhitespaceOnly');
+    }
     const pairs = group.getSideBySidePairs();
     for (let i = 0; i < pairs.length; i++) {
       sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
@@ -91,18 +92,14 @@
 
   GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
       lineNumber, side) {
-    const lineEl = this._createLineEl(line, lineNumber, line.type, side);
-    lineEl.classList.add(side);
-    row.appendChild(lineEl);
+    const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
+    lineNumberEl.classList.add(side);
+    row.appendChild(lineNumberEl);
     const action = this._createContextControl(section, line);
     if (action) {
       row.appendChild(action);
     } else {
-      const textEl = this._createTextEl(line, side);
-      const threadGroupEl = this._commentThreadGroupForLine(line, side);
-      if (threadGroupEl) {
-        textEl.appendChild(threadGroupEl);
-      }
+      const textEl = this._createTextEl(lineNumberEl, line, side);
       row.appendChild(textEl);
     }
   };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 65a31a1..611f886 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -20,10 +20,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderUnified) { return; }
 
-  function GrDiffBuilderUnified(
-      diff, comments, prefs, projectName, outputEl, layers) {
-    GrDiffBuilder.call(
-        this, diff, comments, prefs, projectName, outputEl, layers);
+  function GrDiffBuilderUnified(diff, prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
   }
   GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
@@ -37,6 +35,9 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
+    if (group.ignoredWhitespaceOnly) {
+      sectionEl.classList.add('ignoredWhitespaceOnly');
+    }
 
     for (let i = 0; i < group.lines.length; ++i) {
       sectionEl.appendChild(this._createRow(sectionEl, group.lines[i]));
@@ -70,28 +71,24 @@
 
   GrDiffBuilderUnified.prototype._createRow = function(section, line) {
     const row = this._createElement('tr', line.type);
-    row.appendChild(this._createBlameCell(line));
-
-    let lineEl = this._createLineEl(line, line.beforeNumber,
-        GrDiffLine.Type.REMOVE);
-    lineEl.classList.add('left');
-    row.appendChild(lineEl);
-    lineEl = this._createLineEl(line, line.afterNumber,
-        GrDiffLine.Type.ADD);
-    lineEl.classList.add('right');
-    row.appendChild(lineEl);
     row.classList.add('diff-row', 'unified');
     row.tabIndex = -1;
+    row.appendChild(this._createBlameCell(line));
+
+    let lineNumberEl = this._createLineEl(line, line.beforeNumber,
+        GrDiffLine.Type.REMOVE);
+    lineNumberEl.classList.add('left');
+    row.appendChild(lineNumberEl);
+    lineNumberEl = this._createLineEl(line, line.afterNumber,
+        GrDiffLine.Type.ADD);
+    lineNumberEl.classList.add('right');
+    row.appendChild(lineNumberEl);
 
     const action = this._createContextControl(section, line);
     if (action) {
       row.appendChild(action);
     } else {
-      const textEl = this._createTextEl(line);
-      const threadGroupEl = this._commentThreadGroupForLine(line);
-      if (threadGroupEl) {
-        textEl.appendChild(threadGroupEl);
-      }
+      const textEl = this._createTextEl(lineNumberEl, line);
       row.appendChild(textEl);
     }
     return row;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index e8f4b21..ddbb1df 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -14,10 +14,9 @@
 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="../gr-diff-comment-thread/gr-diff-comment-thread.html">
-<link rel="import" href="../gr-diff-comment-thread-group/gr-diff-comment-thread-group.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="../gr-coverage-layer/gr-coverage-layer.html">
 <link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
 <link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
 <link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
@@ -29,16 +28,24 @@
     </div>
     <gr-ranged-comment-layer
         id="rangeLayer"
-        comments="[[comments]]"></gr-ranged-comment-layer>
+        comment-ranges="[[commentRanges]]"></gr-ranged-comment-layer>
     <gr-syntax-layer
         id="syntaxLayer"
         diff="[[diff]]"></gr-syntax-layer>
+    <gr-coverage-layer
+        id="coverageLayerLeft"
+        coverage-ranges="[[_leftCoverageRanges]]"
+        side="left"></gr-coverage-layer>
+    <gr-coverage-layer
+        id="coverageLayerRight"
+        coverage-ranges="[[_rightCoverageRanges]]"
+        side="right"></gr-coverage-layer>
     <gr-diff-processor
         id="processor"
         groups="{{_groups}}"></gr-diff-processor>
-    <gr-reporting id="reporting"></gr-reporting>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
   </template>
+  <script src="../../../scripts/util.js"></script>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff/gr-diff-group.js"></script>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
@@ -51,35 +58,23 @@
     (function() {
       'use strict';
 
-      const Defs = {};
-
-      /**
-       * @typedef {{
-       *  number: number,
-       *  leftSide: {boolean}
-       * }}
-       */
-      Defs.LineOfInterest;
-
       const DiffViewMode = {
         SIDE_BY_SIDE: 'SIDE_BY_SIDE',
         UNIFIED: 'UNIFIED_DIFF',
       };
 
-      const TimingLabel = {
-        TOTAL: 'Diff Total Render',
-        CONTENT: 'Diff Content Render',
-        SYNTAX: 'Diff Syntax Render',
-      };
-
       // If any line of the diff is more than the character limit, then disable
       // syntax highlighting for the entire file.
       const SYNTAX_MAX_LINE_LENGTH = 500;
 
+      // Disable syntax highlighting if the overall diff is too large.
+      const SYNTAX_MAX_DIFF_LENGTH = 20000;
+
       const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
       Polymer({
         is: 'gr-diff-builder',
+        _legacyUndefinedCheck: true,
 
         /**
          * Fired when the diff begins rendering.
@@ -88,16 +83,16 @@
          */
 
         /**
-         * Fired when the diff is rendered.
+         * Fired when the diff finishes rendering text content and starts
+         * syntax highlighting.
          *
-         * @event render
+         * @event render-content
          */
 
         /**
-         * Fired when the diff finishes rendering text content, but not
-         * necessarily syntax highlights.
+         * Fired when the diff finishes syntax highlighting.
          *
-         * @event render-content
+         * @event render-syntax
          */
 
         properties: {
@@ -106,20 +101,42 @@
           changeNum: String,
           patchNum: String,
           viewMode: String,
-          comments: Object,
           isImageDiff: Boolean,
           baseImage: Object,
           revisionImage: Object,
-          projectName: String,
           parentIndex: Number,
-          /**
-           * @type {Defs.LineOfInterest|null}
-           */
-          lineOfInterest: Object,
+          path: String,
+          projectName: String,
+
           _builder: Object,
           _groups: Array,
           _layers: Array,
           _showTabs: Boolean,
+          /** @type {!Array<!Gerrit.HoveredRange>} */
+          commentRanges: {
+            type: Array,
+            value: () => [],
+          },
+          /** @type {!Array<!Gerrit.CoverageRange>} */
+          coverageRanges: {
+            type: Array,
+            value: () => [],
+          },
+          _leftCoverageRanges: {
+            type: Array,
+            computed: '_computeLeftCoverageRanges(coverageRanges)',
+          },
+          _rightCoverageRanges: {
+            type: Array,
+            computed: '_computeRightCoverageRanges(coverageRanges)',
+          },
+          /**
+           * The promise last returned from `render()` while the asynchronous
+           * rendering is running - `null` otherwise. Provides a `cancel()`
+           * method that rejects it with `{isCancelled: true}`.
+           * @type {?Object}
+           */
+          _cancelableRenderPromise: Object,
         },
 
         get diffElement() {
@@ -130,14 +147,76 @@
           '_groupsChanged(_groups.splices)',
         ],
 
-        attached() {
-          // Setup annotation layers.
+        _computeLeftCoverageRanges(coverageRanges) {
+          return coverageRanges.filter(range => range && range.side === 'left');
+        },
+
+        _computeRightCoverageRanges(coverageRanges) {
+          return coverageRanges.filter(range => range && range.side === 'right');
+        },
+
+        render(keyLocations, prefs) {
+          // Setting up annotation layers must happen after plugins are
+          // installed, and |render| satisfies the requirement, however,
+          // |attached| doesn't because in the diff view page, the element is
+          // attached before plugins are installed.
+          this._setupAnnotationLayers();
+
+          this.$.syntaxLayer.enabled = prefs.syntax_highlighting;
+          this._showTabs = !!prefs.show_tabs;
+          this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
+
+          // Stop the processor and syntax layer (if they're running).
+          this.cancel();
+
+          this._builder = this._getDiffBuilder(this.diff, prefs);
+
+          this.$.processor.context = prefs.context;
+          this.$.processor.keyLocations = keyLocations;
+
+          this._clearDiffContent();
+          this._builder.addColumns(this.diffElement, prefs.font_size);
+
+          const isBinary = !!(this.isImageDiff || this.diff.binary);
+
+          this.dispatchEvent(new CustomEvent(
+              'render-start', {bubbles: true, composed: true}));
+          this._cancelableRenderPromise = util.makeCancelable(
+              this.$.processor.process(this.diff.content, isBinary)
+                  .then(() => {
+                    if (this.isImageDiff) {
+                      this._builder.renderDiff();
+                    }
+                    this.dispatchEvent(new CustomEvent('render-content',
+                        {bubbles: true, composed: true}));
+
+                    if (this._diffTooLargeForSyntax()) {
+                      this.$.syntaxLayer.enabled = false;
+                    }
+
+                    return this.$.syntaxLayer.process();
+                  })
+                  .then(() => {
+                    this.dispatchEvent(new CustomEvent(
+                        'render-syntax', {bubbles: true, composed: true}));
+                  }));
+          return this._cancelableRenderPromise
+              .finally(() => { this._cancelableRenderPromise = null; })
+              // Mocca testing does not like uncaught rejections, so we catch
+              // the cancels which are expected and should not throw errors in
+              // tests.
+              .catch(e => { if (!e.isCanceled) return Promise.reject(e); });
+        },
+
+        _setupAnnotationLayers() {
           const layers = [
             this._createTrailingWhitespaceLayer(),
             this.$.syntaxLayer,
             this._createIntralineLayer(),
             this._createTabIndicatorLayer(),
             this.$.rangeLayer,
+            this.$.coverageLayerLeft,
+            this.$.coverageLayerRight,
           ];
 
           // Get layers from plugins (if any).
@@ -147,56 +226,6 @@
           }
 
           this._layers = layers;
-
-          this.async(() => {
-            this._preRenderThread();
-          });
-        },
-
-        render(comments, prefs) {
-          this.$.syntaxLayer.enabled = prefs.syntax_highlighting;
-          this._showTabs = !!prefs.show_tabs;
-          this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
-
-          // Stop the processor and syntax layer (if they're running).
-          this.cancel();
-
-          this._builder = this._getDiffBuilder(this.diff, comments, prefs);
-
-          this.$.processor.context = prefs.context;
-          this.$.processor.keyLocations = this._getKeyLocations(comments,
-              this.lineOfInterest);
-
-          this._clearDiffContent();
-          this._builder.addColumns(this.diffElement, prefs.font_size);
-
-          const reporting = this.$.reporting;
-          const isBinary = !!(this.isImageDiff || this.diff.binary);
-
-          reporting.time(TimingLabel.TOTAL);
-          reporting.time(TimingLabel.CONTENT);
-          this.dispatchEvent(new CustomEvent('render-start', {bubbles: true}));
-          return this.$.processor.process(this.diff.content, isBinary)
-              .then(() => {
-                if (this.isImageDiff) {
-                  this._builder.renderDiff();
-                }
-                this.dispatchEvent(new CustomEvent('render-content',
-                    {bubbles: true}));
-
-                if (this._anyLineTooLong()) {
-                  this.$.syntaxLayer.enabled = false;
-                }
-
-                reporting.timeEnd(TimingLabel.CONTENT);
-                reporting.time(TimingLabel.SYNTAX);
-                return this.$.syntaxLayer.process().then(() => {
-                  reporting.timeEnd(TimingLabel.SYNTAX);
-                  reporting.timeEnd(TimingLabel.TOTAL);
-                  this.dispatchEvent(
-                      new CustomEvent('render', {bubbles: true}));
-                });
-              });
         },
 
         getLineElByChild(node) {
@@ -250,12 +279,6 @@
           GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
         },
 
-        createCommentThreadGroup(changeNum, patchNum, path,
-            isOnParent, commentSide) {
-          return this._builder.createCommentThreadGroup(changeNum, patchNum,
-              path, isOnParent, commentSide);
-        },
-
         emitGroup(group, sectionEl) {
           this._builder.emitGroup(group, sectionEl);
         },
@@ -266,7 +289,7 @@
           const contextIndex = groups.findIndex(group =>
             group.element === sectionEl
           );
-          groups.splice(...[contextIndex, 1].concat(newGroups));
+          groups.splice(contextIndex, 1, ...newGroups);
 
           for (const newGroup of newGroups) {
             this._builder.emitGroup(newGroup, sectionEl);
@@ -279,6 +302,10 @@
         cancel() {
           this.$.processor.cancel();
           this.$.syntaxLayer.cancel();
+          if (this._cancelableRenderPromise) {
+            this._cancelableRenderPromise.cancel();
+            this._cancelableRenderPromise = null;
+          }
         },
 
         _handlePreferenceError(pref) {
@@ -287,11 +314,11 @@
           this.dispatchEvent(new CustomEvent('show-alert', {
             detail: {
               message,
-            }, bubbles: true}));
+            }, bubbles: true, composed: true}));
           throw Error(`Invalid preference value: ${pref}`);
         },
 
-        _getDiffBuilder(diff, comments, prefs) {
+        _getDiffBuilder(diff, prefs) {
           if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
             this._handlePreferenceError('tab size');
             return;
@@ -304,26 +331,21 @@
 
           let builder = null;
           if (this.isImageDiff) {
-            builder = new GrDiffBuilderImage(diff, comments, prefs,
-                this.projectName, this.diffElement, this.baseImage,
-                this.revisionImage);
+            builder = new GrDiffBuilderImage(diff, prefs, this.diffElement,
+              this.baseImage, this.revisionImage);
           } else if (diff.binary) {
             // If the diff is binary, but not an image.
-            return new GrDiffBuilderBinary(diff, comments, prefs,
-                this.projectName, this.diffElement);
+            return new GrDiffBuilderBinary(diff, prefs, this.diffElement);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-            builder = new GrDiffBuilderSideBySide(diff, comments, prefs,
-                this.projectName, this.diffElement, this._layers);
+            builder = new GrDiffBuilderSideBySide(diff, prefs, this.diffElement,
+                this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
-            builder = new GrDiffBuilderUnified(diff, comments, prefs,
-                this.projectName, this.diffElement, this._layers);
+            builder = new GrDiffBuilderUnified(diff, prefs, this.diffElement,
+                this._layers);
           }
           if (!builder) {
             throw Error('Unsupported diff view mode: ' + this.viewMode);
           }
-          if (this.parentIndex) {
-            builder.setParentIndex(this.parentIndex);
-          }
           return builder;
         },
 
@@ -331,33 +353,6 @@
           this.diffElement.innerHTML = null;
         },
 
-        /**
-         * @param {!Object} comments
-         * @param {Defs.LineOfInterest|null} lineOfInterest
-         */
-        _getKeyLocations(comments, lineOfInterest) {
-          const result = {
-            left: {},
-            right: {},
-          };
-          for (const side in comments) {
-            if (side !== GrDiffBuilder.Side.LEFT &&
-                side !== GrDiffBuilder.Side.RIGHT) {
-              continue;
-            }
-            for (const c of comments[side]) {
-              result[side][c.line || GrDiffLine.FILE] = true;
-            }
-          }
-
-          if (lineOfInterest) {
-            const side = lineOfInterest.leftSide ? 'left' : 'right';
-            result[side][lineOfInterest.number] = true;
-          }
-
-          return result;
-        },
-
         _groupsChanged(changeRecord) {
           if (!changeRecord) { return; }
           for (const splice of changeRecord.indexSplices) {
@@ -375,7 +370,7 @@
             // Take a DIV.contentText element and a line object with intraline
             // differences to highlight and apply them to the element as
             // annotations.
-            annotate(el, line) {
+            annotate(contentEl, lineNumberEl, line) {
               const HL_CLASS = 'style-scope gr-diff intraline';
               for (const highlight of line.highlights) {
                 // The start and end indices could be the same if a highlight is
@@ -389,7 +384,7 @@
                     highlight.endIndex;
 
                 GrAnnotation.annotateElement(
-                    el,
+                    contentEl,
                     highlight.startIndex,
                     endIndex - highlight.startIndex,
                     HL_CLASS);
@@ -401,7 +396,7 @@
         _createTabIndicatorLayer() {
           const show = () => this._showTabs;
           return {
-            annotate(el, line) {
+            annotate(contentEl, lineNumberEl, line) {
               // If visible tabs are disabled, do nothing.
               if (!show()) { return; }
 
@@ -412,7 +407,7 @@
                 // Skip forward by the length of the content
                 pos += split[i].length;
 
-                GrAnnotation.annotateElement(el, pos, 1,
+                GrAnnotation.annotateElement(contentEl, pos, 1,
                     'style-scope gr-diff tab-indicator');
 
                 // Skip forward by one tab character.
@@ -428,7 +423,7 @@
           }.bind(this);
 
           return {
-            annotate(el, line) {
+            annotate(contentEl, lineNumberEl, line) {
               if (!show()) { return; }
 
               const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
@@ -438,7 +433,7 @@
                 const index = GrAnnotation.getStringLength(
                     line.text.substr(0, match.index));
                 const length = GrAnnotation.getStringLength(match[0]);
-                GrAnnotation.annotateElement(el, index, length,
+                GrAnnotation.annotateElement(contentEl, index, length,
                     'style-scope gr-diff trailing-whitespace');
               }
             },
@@ -446,25 +441,6 @@
         },
 
         /**
-         * In pages with large diffs, creating the first comment thread can be
-         * slow because nested Polymer elements (particularly
-         * iron-autogrow-textarea) add style elements to the document head,
-         * which, in turn, triggers a reflow on the page. Create a hidden
-         * thread, attach it to the page, and remove it so the stylesheet will
-         * already exist and the user's comment will be quick to load.
-         * @see https://gerrit-review.googlesource.com/c/82213/
-         */
-        _preRenderThread() {
-          const thread = document.createElement('gr-diff-comment-thread');
-          thread.setAttribute('hidden', true);
-          thread.addDraft();
-          const parent = Polymer.dom(this.root);
-          parent.appendChild(thread);
-          Polymer.dom.flush();
-          parent.removeChild(thread);
-        },
-
-        /**
          * @return {boolean} whether any of the lines in _groups are longer
          * than SYNTAX_MAX_LINE_LENGTH.
          */
@@ -476,10 +452,32 @@
           }, false);
         },
 
+        _diffTooLargeForSyntax() {
+          return this._anyLineTooLong() ||
+              this.getDiffLength() > SYNTAX_MAX_DIFF_LENGTH;
+        },
+
         setBlame(blame) {
           if (!this._builder || !blame) { return; }
           this._builder.setBlame(blame);
         },
+
+        /**
+         * Get the approximate length of the diff as the sum of the maximum
+         * length of the chunks.
+         * @return {number}
+         */
+        getDiffLength() {
+          return this.diff.content.reduce((sum, sec) => {
+            if (sec.hasOwnProperty('ab')) {
+              return sum + sec.ab.length;
+            } else {
+              return sum + Math.max(
+                  sec.hasOwnProperty('a') ? sec.a.length : 0,
+                  sec.hasOwnProperty('b') ? sec.b.length : 0);
+            }
+          }, 0);
+        },
       });
     })();
   </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 997e1ba..5ddf97a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -42,15 +42,12 @@
    */
   const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  function GrDiffBuilder(diff, comments, prefs, projectName, outputEl, layers) {
+  function GrDiffBuilder(diff, prefs, outputEl, layers) {
     this._diff = diff;
-    this._comments = comments;
     this._prefs = prefs;
-    this._projectName = projectName;
     this._outputEl = outputEl;
     this.groups = [];
     this._blameInfo = null;
-    this._parentIndex = undefined;
 
     this.layers = layers || [];
 
@@ -62,7 +59,6 @@
       throw Error('Invalid line length from preferences.');
     }
 
-
     for (const layer of this.layers) {
       if (layer.addListener) {
         layer.addListener(this._handleLayerUpdate.bind(this));
@@ -222,7 +218,9 @@
         // if lines are collapsed and not visible on the page yet.
         continue;
       }
-      el.parentElement.replaceChild(this._createTextEl(line, side).firstChild,
+      const lineNumberEl = this._getLineNumberEl(el, side);
+      el.parentElement.replaceChild(
+          this._createTextEl(lineNumberEl, line, side).firstChild,
           el);
     }
   };
@@ -233,57 +231,38 @@
         group => { return group.element; });
   };
 
-  // TODO(wyatta): Move this completely into the processor.
-  GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
-      hiddenRange) {
-    const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
-    const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
-    const linesAfterCtx = lines.slice(hiddenRange[1]);
-
-    if (linesBeforeCtx.length > 0) {
-      groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
-    }
-
-    const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
-    ctxLine.contextGroup =
-        new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
-    groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
-        [ctxLine]));
-
-    if (linesAfterCtx.length > 0) {
-      groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
-    }
-  };
-
   GrDiffBuilder.prototype._createContextControl = function(section, line) {
-    if (!line.contextGroup || !line.contextGroup.lines.length) {
-      return null;
-    }
+    if (!line.contextGroups) return null;
+
+    const numLines =
+        line.contextGroups[line.contextGroups.length - 1].lineRange.left.end -
+        line.contextGroups[0].lineRange.left.start + 1;
+
+    if (numLines === 0) return null;
 
     const td = this._createElement('td');
-    const showPartialLinks =
-        line.contextGroup.lines.length > PARTIAL_CONTEXT_AMOUNT;
+    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
 
     if (showPartialLinks) {
       td.appendChild(this._createContextButton(
-          GrDiffBuilder.ContextButtonType.ABOVE, section, line));
+          GrDiffBuilder.ContextButtonType.ABOVE, section, line, numLines));
       td.appendChild(document.createTextNode(' - '));
     }
 
     td.appendChild(this._createContextButton(
-        GrDiffBuilder.ContextButtonType.ALL, section, line));
+        GrDiffBuilder.ContextButtonType.ALL, section, line, numLines));
 
     if (showPartialLinks) {
       td.appendChild(document.createTextNode(' - '));
       td.appendChild(this._createContextButton(
-          GrDiffBuilder.ContextButtonType.BELOW, section, line));
+          GrDiffBuilder.ContextButtonType.BELOW, section, line, numLines));
     }
 
     return td;
   };
 
-  GrDiffBuilder.prototype._createContextButton = function(type, section, line) {
-    const contextLines = line.contextGroup.lines;
+  GrDiffBuilder.prototype._createContextButton = function(type, section, line,
+      numLines) {
     const context = PARTIAL_CONTEXT_AMOUNT;
 
     const button = this._createElement('gr-button', 'showContext');
@@ -291,20 +270,20 @@
     button.setAttribute('no-uppercase', true);
 
     let text;
-    const groups = []; // The groups that replace this one if tapped.
+    let groups = []; // The groups that replace this one if tapped.
 
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
-      text = 'Show ' + contextLines.length + ' common line';
-      if (contextLines.length > 1) { text += 's'; }
-      groups.push(line.contextGroup);
+      text = 'Show ' + numLines + ' common line';
+      if (numLines > 1) { text += 's'; }
+      groups.push(...line.contextGroups);
     } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
       text = '+' + context + '↑';
-      this._insertContextGroups(groups, contextLines,
-          [context, contextLines.length]);
+      groups = GrDiffGroup.hideInContextControl(line.contextGroups,
+          context, numLines);
     } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
       text = '+' + context + '↓';
-      this._insertContextGroups(groups, contextLines,
-          [0, contextLines.length - context]);
+      groups = GrDiffGroup.hideInContextControl(line.contextGroups,
+          0, numLines - context);
     }
 
     Polymer.dom(button).textContent = text;
@@ -320,96 +299,6 @@
     return button;
   };
 
-  GrDiffBuilder.prototype._getCommentsForLine = function(comments, line,
-      opt_side) {
-    function byLineNum(lineNum) {
-      return function(c) {
-        return (c.line === lineNum) ||
-               (c.line === undefined && lineNum === GrDiffLine.FILE);
-      };
-    }
-    const leftComments =
-        comments[GrDiffBuilder.Side.LEFT].filter(byLineNum(line.beforeNumber));
-    const rightComments =
-        comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber));
-
-    leftComments.forEach(c => { c.__commentSide = 'left'; });
-    rightComments.forEach(c => { c.__commentSide = 'right'; });
-
-    let result;
-
-    switch (opt_side) {
-      case GrDiffBuilder.Side.LEFT:
-        result = leftComments;
-        break;
-      case GrDiffBuilder.Side.RIGHT:
-        result = rightComments;
-        break;
-      default:
-        result = leftComments.concat(rightComments);
-        break;
-    }
-
-    return result;
-  };
-
-  /**
-   * @param {number} changeNum
-   * @param {number|string} patchNum
-   * @param {string} path
-   * @param {boolean} isOnParent
-   * @param {string} commentSide
-   * @return {!Object}
-   */
-  GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum,
-      patchNum, path, isOnParent, commentSide) {
-    const threadGroupEl =
-        document.createElement('gr-diff-comment-thread-group');
-    threadGroupEl.changeNum = changeNum;
-    threadGroupEl.commentSide = commentSide;
-    threadGroupEl.patchForNewThreads = patchNum;
-    threadGroupEl.path = path;
-    threadGroupEl.isOnParent = isOnParent;
-    threadGroupEl.projectName = this._projectName;
-    threadGroupEl.parentIndex = this._parentIndex;
-    return threadGroupEl;
-  };
-
-  /**
-   * @param {number} line
-   * @param {string=} opt_side
-   * @return {!Object}
-   */
-  GrDiffBuilder.prototype._commentThreadGroupForLine = function(
-      line, opt_side) {
-    const comments =
-        this._getCommentsForLine(this._comments, line, opt_side);
-    if (!comments || comments.length === 0) {
-      return null;
-    }
-
-    let patchNum = this._comments.meta.patchRange.patchNum;
-    let isOnParent = comments[0].side === 'PARENT' || false;
-    if (line.type === GrDiffLine.Type.REMOVE ||
-        opt_side === GrDiffBuilder.Side.LEFT) {
-      if (this._comments.meta.patchRange.basePatchNum === 'PARENT' ||
-          Gerrit.PatchSetBehavior.isMergeParent(
-              this._comments.meta.patchRange.basePatchNum)) {
-        isOnParent = true;
-      } else {
-        patchNum = this._comments.meta.patchRange.basePatchNum;
-      }
-    }
-    const threadGroupEl = this.createCommentThreadGroup(
-        this._comments.meta.changeNum, patchNum, this._comments.meta.path,
-        isOnParent, opt_side);
-    threadGroupEl.comments = comments;
-    if (opt_side) {
-      threadGroupEl.setAttribute('data-side', opt_side);
-    }
-    return threadGroupEl;
-  };
-
   GrDiffBuilder.prototype._createLineEl = function(
       line, number, type, opt_class) {
     const td = this._createElement('td');
@@ -437,11 +326,15 @@
     return td;
   };
 
-  GrDiffBuilder.prototype._createTextEl = function(line, opt_side) {
+  GrDiffBuilder.prototype._createTextEl = function(
+      lineNumberEl, line, opt_side) {
     const td = this._createElement('td');
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
     }
+    if (line.highlights.length === 0) {
+      td.classList.add('no-highlights');
+    }
     td.classList.add(line.type);
 
     const lineLimit =
@@ -454,7 +347,7 @@
     }
 
     for (const layer of this.layers) {
-      layer.annotate(contentText, line);
+      layer.annotate(contentText, lineNumberEl, line);
     }
 
     td.appendChild(contentText);
@@ -544,7 +437,7 @@
     return result;
   };
 
-  GrDiffBuilder.prototype._createElement = function(tagName, className) {
+  GrDiffBuilder.prototype._createElement = function(tagName, classStr) {
     const el = document.createElement(tagName);
     // When Shady DOM is being used, these classes are added to account for
     // Polymer's polyfill behavior. In order to guarantee sufficient
@@ -553,8 +446,10 @@
     // automatically) are not being used for performance reasons, this is
     // done manually.
     el.classList.add('style-scope', 'gr-diff');
-    if (className) {
-      el.classList.add(className);
+    if (classStr) {
+      for (const className of classStr.split(' ')) {
+        el.classList.add(className);
+      }
     }
     return el;
   };
@@ -614,10 +509,6 @@
     }
   };
 
-  GrDiffBuilder.prototype.setParentIndex = function(index) {
-    this._parentIndex = index;
-  };
-
   /**
    * Find the blame cell for a given line number.
    * @param {number} lineNum
@@ -690,5 +581,18 @@
     return blameTd;
   };
 
+  /**
+   * Finds the line number element given the content element by walking up the
+   * DOM tree to the diff row and then querying for a .lineNum element on the
+   * requested side.
+   *
+   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
+   */
+  GrDiffBuilder.prototype._getLineNumberEl = function(content, side) {
+    let row = content;
+    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
+    return row ? row.querySelector('.lineNum.' + side) : null;
+  };
+
   window.GrDiffBuilder = GrDiffBuilder;
 })(window, GrDiffGroup, GrDiffLine);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index 129bff1..b917845 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-builder</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 <script src="../gr-diff/gr-diff-line.js"></script>
@@ -57,6 +59,7 @@
 
 <script>
   suite('gr-diff-builder tests', () => {
+    let prefs;
     let element;
     let builder;
     let sandbox;
@@ -69,39 +72,55 @@
         getLoggedIn() { return Promise.resolve(false); },
         getProjectConfig() { return Promise.resolve({}); },
       });
-      const prefs = {
+      prefs = {
         line_length: 10,
         show_tabs: true,
         tab_size: 4,
       };
-      const projectName = 'my-project';
-      builder = new GrDiffBuilder(
-          {content: []}, {left: [], right: []}, prefs, projectName);
+      builder = new GrDiffBuilder({content: []}, prefs);
     });
 
     teardown(() => { sandbox.restore(); });
 
-    test('context control buttons', () => {
-      const section = {};
-      const line = {contextGroup: {lines: []}};
+    test('_createElement classStr applies all classes', () => {
+      const node = builder._createElement('div', 'test classes');
+      assert.isTrue(node.classList.contains('gr-diff'));
+      assert.isTrue(node.classList.contains('test'));
+      assert.isTrue(node.classList.contains('classes'));
+    });
 
+    test('context control buttons', () => {
       // Create 10 lines.
+      const lines = [];
       for (let i = 0; i < 10; i++) {
-        line.contextGroup.lines.push('lorem upsum');
+        const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+        line.beforeNumber = i + 1;
+        line.afterNumber = i + 1;
+        line.text = 'lorem upsum';
+        lines.push(line);
       }
 
+      const contextLine = {
+        contextGroups: [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)],
+      };
+
+      const section = {};
       // Does not include +10 buttons when there are fewer than 11 lines.
-      let td = builder._createContextControl(section, line);
+      let td = builder._createContextControl(section, contextLine);
       let buttons = td.querySelectorAll('gr-button.showContext');
 
       assert.equal(buttons.length, 1);
       assert.equal(Polymer.dom(buttons[0]).textContent, 'Show 10 common lines');
 
       // Add another line.
-      line.contextGroup.lines.push('lorem upsum');
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.text = 'lorem upsum';
+      line.beforeNumber = 11;
+      line.afterNumber = 11;
+      contextLine.contextGroups[0].addLine(line);
 
       // Includes +10 buttons when there are at least 11 lines.
-      td = builder._createContextControl(section, line);
+      td = builder._createContextControl(section, contextLine);
       buttons = td.querySelectorAll('gr-button.showContext');
 
       assert.equal(buttons.length, 3);
@@ -157,7 +176,7 @@
       const text = 'a'.repeat(51);
 
       const line = {text, highlights: []};
-      const result = builder._createTextEl(line).firstChild.innerHTML;
+      const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
       assert.equal(result, text);
     });
 
@@ -167,14 +186,14 @@
 
       const line = {text, highlights: []};
       const expected = 'a'.repeat(50) + LINE_FEED_HTML + 'a';
-      const result = builder._createTextEl(line).firstChild.innerHTML;
+      const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
       assert.equal(result, expected);
     });
 
     test('_createTextEl linewrap with tabs', () => {
       const text = '\t'.repeat(7) + '!';
       const line = {text, highlights: []};
-      const el = builder._createTextEl(line);
+      const el = builder._createTextEl(undefined, line);
       assert.equal(el.innerText, text);
       // With line length 10 and tab size 2, there should be a line break
       // after every two tabs.
@@ -247,118 +266,10 @@
       }
     });
 
-    test('comments', () => {
-      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      let comments = {left: [], right: []};
-      assert.deepEqual(builder._getCommentsForLine(comments, line), []);
-      assert.deepEqual(builder._getCommentsForLine(comments, line,
-          GrDiffBuilder.Side.LEFT), []);
-      assert.deepEqual(builder._getCommentsForLine(comments, line,
-          GrDiffBuilder.Side.RIGHT), []);
-
-      comments = {
-        left: [
-          {id: 'l3', line: 3},
-          {id: 'l5', line: 5},
-        ],
-        right: [
-          {id: 'r3', line: 3},
-          {id: 'r5', line: 5},
-        ],
-      };
-      assert.deepEqual(builder._getCommentsForLine(comments, line),
-          [{id: 'l3', line: 3, __commentSide: 'left'},
-          {id: 'r5', line: 5, __commentSide: 'right'}]);
-      assert.deepEqual(builder._getCommentsForLine(comments, line,
-          GrDiffBuilder.Side.LEFT), [{id: 'l3', line: 3,
-            __commentSide: 'left'}]);
-      assert.deepEqual(builder._getCommentsForLine(comments, line,
-          GrDiffBuilder.Side.RIGHT), [{id: 'r5', line: 5,
-            __commentSide: 'right'}]);
-    });
-
-    test('comment thread group creation', () => {
-      const l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000',
-        __commentSide: 'left'};
-      const l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-        __commentSide: 'left'};
-      const r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-        __commentSide: 'right'};
-
-      builder._comments = {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: '3',
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [l3, l5],
-        right: [r5],
-      };
-
-      function checkThreadGroupProps(threadGroupEl, patchNum, isOnParent,
-          comments) {
-        assert.equal(threadGroupEl.changeNum, '42');
-        assert.equal(threadGroupEl.patchForNewThreads, patchNum);
-        assert.equal(threadGroupEl.path, '/path/to/foo');
-        assert.equal(threadGroupEl.isOnParent, isOnParent);
-        assert.deepEqual(threadGroupEl.projectName, 'my-project');
-        assert.deepEqual(threadGroupEl.comments, comments);
-      }
-
-      let line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = 5;
-      line.afterNumber = 5;
-      let threadGroupEl = builder._commentThreadGroupForLine(line);
-      checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
-
-      threadGroupEl =
-          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
-      checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
-
-      threadGroupEl =
-          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
-      checkThreadGroupProps(threadGroupEl, '3', true, [l5]);
-
-      builder._comments.meta.patchRange.basePatchNum = '1';
-
-      threadGroupEl = builder._commentThreadGroupForLine(line);
-      checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
-
-      threadEl =
-          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
-      checkThreadGroupProps(threadEl, '1', false, [l5]);
-
-      threadGroupEl =
-          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
-      checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
-
-      builder._comments.meta.patchRange.basePatchNum = 'PARENT';
-
-      line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      line.beforeNumber = 5;
-      line.afterNumber = 5;
-      threadGroupEl = builder._commentThreadGroupForLine(line);
-      checkThreadGroupProps(threadGroupEl, '3', true, [l5, r5]);
-
-      line = new GrDiffLine(GrDiffLine.Type.ADD);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-      threadGroupEl = builder._commentThreadGroupForLine(line);
-      checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
-    });
-
-
     test('_handlePreferenceError called with invalid preference', () => {
       sandbox.stub(element, '_handlePreferenceError');
       const prefs = {tab_size: 0};
-      element._getDiffBuilder(element.diff, element.comments, prefs);
+      element._getDiffBuilder(element.diff, prefs);
       assert.isTrue(element._handlePreferenceError.lastCall
           .calledWithExactly('tab size'));
     });
@@ -372,31 +283,6 @@
         `Fix in diff preferences`);
     });
 
-    test('_getKeyLocations', () => {
-      assert.deepEqual(element._getKeyLocations({left: [], right: []}, null),
-          {left: {}, right: {}});
-      const comments = {
-        left: [{line: 123}, {}],
-        right: [{line: 456}],
-      };
-      assert.deepEqual(element._getKeyLocations(comments, null), {
-        left: {FILE: true, 123: true},
-        right: {456: true},
-      });
-
-      const lineOfInterest = {number: 789, leftSide: true};
-      assert.deepEqual(element._getKeyLocations(comments, lineOfInterest), {
-        left: {FILE: true, 123: true, 789: true},
-        right: {456: true},
-      });
-
-      delete lineOfInterest.leftSide;
-      assert.deepEqual(element._getKeyLocations(comments, lineOfInterest), {
-        left: {FILE: true, 123: true},
-        right: {456: true, 789: true},
-      });
-    });
-
     suite('_isTotal', () => {
       test('is total for add', () => {
         const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
@@ -433,6 +319,7 @@
       let str;
       let annotateElementSpy;
       let layer;
+      const lineNumberEl = document.createElement('td');
 
       function slice(str, start, end) {
         return Array.from(str).slice(start, end).join('');
@@ -452,7 +339,7 @@
           highlights: [],
         };
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         // The content is unchanged.
         assert.isFalse(annotateElementSpy.called);
@@ -475,7 +362,7 @@
         const str3 = slice(str, 18, 22);
         const str4 = slice(str, 22);
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.isTrue(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 5);
@@ -507,7 +394,7 @@
         const str0 = slice(str, 0, 28);
         const str1 = slice(str, 28);
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.isTrue(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 2);
@@ -527,7 +414,7 @@
           ],
         };
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.isFalse(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 1);
@@ -548,7 +435,7 @@
         const str1 = slice(str, 6, 12);
         const str2 = slice(str, 12);
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.isTrue(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 3);
@@ -578,7 +465,7 @@
         const str0 = slice(str, 0, 6);
         const str1 = slice(str, 6);
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.isTrue(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 2);
@@ -594,6 +481,7 @@
     suite('tab indicators', () => {
       let element;
       let layer;
+      const lineNumberEl = document.createElement('td');
 
       setup(() => {
         element = fixture('basic');
@@ -607,7 +495,7 @@
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.isFalse(annotateElementStub.called);
       });
@@ -620,7 +508,7 @@
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.isFalse(annotateElementStub.called);
       });
@@ -633,7 +521,7 @@
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.equal(annotateElementStub.callCount, 1);
         const args = annotateElementStub.getCalls()[0].args;
@@ -653,7 +541,7 @@
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.isFalse(annotateElementStub.called);
       });
@@ -666,7 +554,7 @@
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.equal(annotateElementStub.callCount, 2);
 
@@ -691,7 +579,7 @@
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
 
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
 
         assert.equal(annotateElementStub.callCount, 1);
         const args = annotateElementStub.getCalls()[0].args;
@@ -709,13 +597,14 @@
       setup(() => {
         element = fixture('basic');
         element._showTrailingWhitespace = true;
+        element._setupAnnotationLayers();
         initialLayersCount = element._layers.length;
       });
 
       test('no plugin layers', () => {
         const getDiffLayersStub = sinon.stub(element.$.jsAPI, 'getDiffLayers')
                                        .returns([]);
-        element.attached();
+        element._setupAnnotationLayers();
         assert.isTrue(getDiffLayersStub.called);
         assert.equal(element._layers.length, initialLayersCount);
       });
@@ -723,15 +612,16 @@
       test('with plugin layers', () => {
         const getDiffLayersStub = sinon.stub(element.$.jsAPI, 'getDiffLayers')
                                        .returns([{}, {}]);
-        element.attached();
+        element._setupAnnotationLayers();
         assert.isTrue(getDiffLayersStub.called);
-        assert.equal(element._layers.length, initialLayersCount+2);
+        assert.equal(element._layers.length, initialLayersCount + 2);
       });
     });
 
     suite('trailing whitespace', () => {
       let element;
       let layer;
+      const lineNumberEl = document.createElement('td');
 
       setup(() => {
         element = fixture('basic');
@@ -744,7 +634,7 @@
         const el = document.createElement('div');
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
         assert.isFalse(annotateElementStub.called);
       });
 
@@ -755,7 +645,7 @@
         el.textContent = str;
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
         assert.isFalse(annotateElementStub.called);
       });
 
@@ -766,7 +656,7 @@
         el.textContent = str;
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 11);
         assert.equal(annotateElementStub.lastCall.args[2], 3);
@@ -779,7 +669,7 @@
         el.textContent = str;
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 11);
         assert.equal(annotateElementStub.lastCall.args[2], 3);
@@ -792,7 +682,7 @@
         el.textContent = str;
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 11);
         assert.equal(annotateElementStub.lastCall.args[2], 3);
@@ -805,7 +695,7 @@
         el.textContent = str;
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 1);
         assert.equal(annotateElementStub.lastCall.args[2], 1);
@@ -819,14 +709,14 @@
         el.textContent = str;
         const annotateElementStub =
             sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, line);
+        layer.annotate(el, lineNumberEl, line);
         assert.isFalse(annotateElementStub.called);
       });
     });
 
     suite('rendering text, images and binary files', () => {
       let processStub;
-      let comments;
+      let keyLocations;
       let prefs;
       let content;
 
@@ -836,7 +726,7 @@
         processStub = sandbox.stub(element.$.processor, 'process')
             .returns(Promise.resolve());
         sandbox.stub(element, '_anyLineTooLong').returns(true);
-        comments = {left: [], right: []};
+        keyLocations = {left: {}, right: {}};
         prefs = {
           line_length: 10,
           show_tabs: true,
@@ -857,7 +747,7 @@
 
       test('text', () => {
         element.diff = {content};
-        return element.render(comments, prefs).then(() => {
+        return element.render(keyLocations, prefs).then(() => {
           assert.isTrue(processStub.calledOnce);
           assert.isFalse(processStub.lastCall.args[1]);
         });
@@ -866,7 +756,7 @@
       test('image', () => {
         element.diff = {content, binary: true};
         element.isImageDiff = true;
-        return element.render(comments, prefs).then(() => {
+        return element.render(keyLocations, prefs).then(() => {
           assert.isTrue(processStub.calledOnce);
           assert.isTrue(processStub.lastCall.args[1]);
         });
@@ -874,7 +764,7 @@
 
       test('binary', () => {
         element.diff = {content, binary: true};
-        return element.render(comments, prefs).then(() => {
+        return element.render(keyLocations, prefs).then(() => {
           assert.isTrue(processStub.calledOnce);
           assert.isTrue(processStub.lastCall.args[1]);
         });
@@ -884,6 +774,7 @@
     suite('rendering', () => {
       let content;
       let outputEl;
+      let keyLocations;
 
       setup(done => {
         const prefs = {
@@ -905,15 +796,11 @@
             ],
           },
         ];
-        stub('gr-reporting', {
-          time: sandbox.stub(),
-          timeEnd: sandbox.stub(),
-        });
         element = fixture('basic');
         outputEl = element.queryEffectiveChildren('#diffTable');
+        keyLocations = {left: {}, right: {}};
         sandbox.stub(element, '_getDiffBuilder', () => {
-          const builder = new GrDiffBuilder(
-              {content}, {left: [], right: []}, prefs, 'my-project', outputEl);
+          const builder = new GrDiffBuilder({content}, prefs, outputEl);
           sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
             const section = document.createElement('stub');
@@ -925,19 +812,7 @@
           return builder;
         });
         element.diff = {content};
-        element.render({left: [], right: []}, prefs).then(done);
-      });
-
-      test('reporting', done => {
-        const timeStub = element.$.reporting.time;
-        const timeEndStub = element.$.reporting.timeEnd;
-        assert.isTrue(timeStub.calledWithExactly('Diff Total Render'));
-        assert.isTrue(timeStub.calledWithExactly('Diff Content Render'));
-        assert.isTrue(timeStub.calledWithExactly('Diff Syntax Render'));
-        assert.isTrue(timeEndStub.calledWithExactly('Diff Total Render'));
-        assert.isTrue(timeEndStub.calledWithExactly('Diff Content Render'));
-        assert.isTrue(timeEndStub.calledWithExactly('Diff Syntax Render'));
-        done();
+        element.render(keyLocations, prefs).then(done);
       });
 
       test('renderSection', () => {
@@ -950,7 +825,7 @@
       });
 
       test('addColumns is called', done => {
-        element.render({left: [], right: []}, {}).then(done);
+        element.render(keyLocations, {}).then(done);
         assert.isTrue(element._builder.addColumns.called);
       });
 
@@ -972,14 +847,14 @@
         assert.strictEqual(sections[1], section[1]);
       });
 
-      test('render-start and render are fired', done => {
+      test('render-start and render-content are fired', done => {
         const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
-        element.render({left: [], right: []}, {}).then(() => {
+        element.render(keyLocations, {}).then(() => {
           const firedEventTypes = dispatchEventStub.getCalls()
               .map(c => { return c.args[0].type; });
           assert.include(firedEventTypes, 'render-start');
           assert.include(firedEventTypes, 'render-content');
-          assert.include(firedEventTypes, 'render');
+          assert.include(firedEventTypes, 'render-syntax');
           done();
         });
       });
@@ -991,10 +866,6 @@
       test('rendering large diff disables syntax', done => {
         // Before it renders, set the first diff line to 500 '*' characters.
         element.diff.content[0].a = [new Array(501).join('*')];
-        element.addEventListener('render', () => {
-          assert.isFalse(element.$.syntaxLayer.enabled);
-          done();
-        });
         const prefs = {
           line_length: 10,
           show_tabs: true,
@@ -1002,7 +873,10 @@
           context: -1,
           syntax_highlighting: true,
         };
-        element.render({left: [], right: []}, prefs);
+        element.render(keyLocations, prefs).then(() => {
+          assert.isFalse(element.$.syntaxLayer.enabled);
+          done();
+        });
       });
 
       test('cancel', () => {
@@ -1019,6 +893,7 @@
       let builder;
       let diff;
       let prefs;
+      let keyLocations;
 
       setup(done => {
         element = fixture('mock-diff');
@@ -1030,13 +905,18 @@
           show_tabs: true,
           tab_size: 4,
         };
+        keyLocations = {left: {}, right: {}};
 
-        element.render({left: [], right: []}, prefs).then(() => {
+        element.render(keyLocations, prefs).then(() => {
           builder = element._builder;
           done();
         });
       });
 
+      test('getDiffLength', () => {
+        assert.equal(element.getDiffLength(diff), 52);
+      });
+
       test('getContentByLine', () => {
         let actual;
 
@@ -1083,7 +963,7 @@
 
         assert.equal(spy.callCount, count);
         spy.getCalls().forEach((call, i) => {
-          assert.equal(call.args[0].beforeNumber, start + i);
+          assert.equal(call.args[1].beforeNumber, start + i);
         });
       });
 
@@ -1094,9 +974,11 @@
             (s, e, d, lines, elements) => {
               // Add a line and a corresponding element.
               lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-              const parEl = document.createElement('div');
+              const tr = document.createElement('tr');
+              const td = document.createElement('td');
               const el = document.createElement('div');
-              parEl.appendChild(el);
+              tr.appendChild(td);
+              td.appendChild(el);
               elements.push(el);
 
               // Add 2 lines without corresponding elements.
@@ -1110,6 +992,52 @@
         assert.equal(spy.callCount, 1);
       });
 
+      test('_getLineNumberEl side-by-side left', () => {
+        const contentEl = builder.getContentByLine(5, 'left',
+            element.$.diffTable);
+        const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl.classList.contains('left'));
+      });
+
+      test('_getLineNumberEl side-by-side right', () => {
+        const contentEl = builder.getContentByLine(5, 'right',
+            element.$.diffTable);
+        const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl.classList.contains('right'));
+      });
+
+      test('_getLineNumberEl unified left', done => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations, prefs).then(() => {
+          builder = element._builder;
+
+          const contentEl = builder.getContentByLine(5, 'left',
+              element.$.diffTable);
+          const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+          assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+          assert.isTrue(lineNumberEl.classList.contains('left'));
+          done();
+        });
+      });
+
+      test('_getLineNumberEl unified right', done => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations, prefs).then(() => {
+          builder = element._builder;
+
+          const contentEl = builder.getContentByLine(5, 'right',
+              element.$.diffTable);
+          const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+          assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+          assert.isTrue(lineNumberEl.classList.contains('right'));
+          done();
+        });
+      });
+
       test('_getNextContentOnSide side-by-side left', () => {
         const startElem = builder.getContentByLine(5, 'left',
             element.$.diffTable);
@@ -1137,7 +1065,7 @@
       test('_getNextContentOnSide unified left', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(() => {
+        element.render(keyLocations, prefs).then(() => {
           builder = element._builder;
 
           const startElem = builder.getContentByLine(5, 'left',
@@ -1157,7 +1085,7 @@
       test('_getNextContentOnSide unified right', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(() => {
+        element.render(keyLocations, prefs).then(() => {
           builder = element._builder;
 
           const startElem = builder.getContentByLine(5, 'right',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
deleted file mode 100644
index fb801e5..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
+++ /dev/null
@@ -1,50 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-diff-comment-thread-group">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        max-width: var(--content-width, 80ch);
-        white-space: normal;
-      }
-      gr-diff-comment-thread + gr-diff-comment-thread {
-        margin-top: .2em;
-      }
-    </style>
-    <template is="dom-repeat" items="[[_threads]]" as="thread">
-      <gr-diff-comment-thread
-          comments="[[thread.comments]]"
-          comment-side="[[thread.commentSide]]"
-          is-on-parent="[[isOnParent]]"
-          parent-index="[[parentIndex]]"
-          change-num="[[changeNum]]"
-          patch-num="[[thread.patchNum]]"
-          root-id="{{thread.rootId}}"
-          path="[[path]]"
-          project-name="[[projectName]]"
-          range="[[thread.range]]"
-          on-thread-discard="_handleThreadDiscard"></gr-diff-comment-thread>
-    </template>
-  </template>
-  <script src="gr-diff-comment-thread-group.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
deleted file mode 100644
index b053330..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
+++ /dev/null
@@ -1,191 +0,0 @@
-/**
- * @license
- * 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.
- */
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-diff-comment-thread-group',
-
-    properties: {
-      changeNum: String,
-      comments: {
-        type: Array,
-        value() { return []; },
-      },
-      projectName: String,
-      patchForNewThreads: String,
-      range: Object,
-      isOnParent: {
-        type: Boolean,
-        value: false,
-      },
-      parentIndex: {
-        type: Number,
-        value: null,
-      },
-      _threads: {
-        type: Array,
-        value() { return []; },
-      },
-    },
-
-    observers: [
-      '_commentsChanged(comments.*)',
-    ],
-
-    get threadEls() {
-      return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
-    },
-
-    /**
-     * Adds a new thread. Range is optional because a comment can be
-     * added to a line without a range selected.
-     *
-     * @param {!Object} opt_range
-     */
-    addNewThread(commentSide, opt_range) {
-      this.push('_threads', {
-        comments: [],
-        commentSide,
-        patchNum: this.patchForNewThreads,
-        range: opt_range,
-      });
-    },
-
-    removeThread(rootId) {
-      for (let i = 0; i < this._threads.length; i++) {
-        if (this._threads[i].rootId === rootId) {
-          this.splice('_threads', i, 1);
-          return;
-        }
-      }
-    },
-
-    /**
-     * Fetch the thread group at the given range, or the range-less thread
-     * on the line if no range is provided, lineNum, and side.
-     *
-     * @param {string} side
-     * @param {!Object=} opt_range
-     * @return {!Object|undefined}
-     */
-    getThread(side, opt_range) {
-      const threads = [].filter.call(this.threadEls,
-          thread => this._rangesEqual(thread.range, opt_range))
-          .filter(thread => thread.commentSide === side);
-      if (threads.length === 1) {
-        return threads[0];
-      }
-    },
-
-    _handleThreadDiscard(e) {
-      this.removeThread(e.detail.rootId);
-    },
-
-    /**
-     * Compare two ranges. Either argument may be falsy, but will only return
-     * true if both are falsy or if neither are falsy and have the same position
-     * values.
-     *
-     * @param {Object=} a range 1
-     * @param {Object=} b range 2
-     * @return {boolean}
-     */
-    _rangesEqual(a, b) {
-      if (!a && !b) { return true; }
-      if (!a || !b) { return false; }
-      return a.startLine === b.startLine &&
-          a.startChar === b.startChar &&
-          a.endLine === b.endLine &&
-          a.endChar === b.endChar;
-    },
-
-    _commentsChanged() {
-      this._threads = this._getThreads(this.comments);
-    },
-
-    _sortByDate(threadGroups) {
-      if (!threadGroups.length) { return; }
-      return threadGroups.sort((a, b) => {
-        // If a comment is a draft, it doesn't have a start_datetime yet.
-        // Assume it is newer than the comment it is being compared to.
-        if (!a.start_datetime) {
-          return 1;
-        }
-        if (!b.start_datetime) {
-          return -1;
-        }
-        return util.parseDate(a.start_datetime) -
-            util.parseDate(b.start_datetime);
-      });
-    },
-
-    _calculateLocationRange(range, comment) {
-      return 'range-' + range.start_line + '-' +
-          range.start_character + '-' +
-          range.end_line + '-' +
-          range.end_character + '-' +
-          comment.__commentSide;
-    },
-
-    /**
-     * Determines what the patchNum of a thread should be. Use patchNum from
-     * comment if it exists, otherwise the property of the thread group.
-     * This is needed for switching between side-by-side and unified views when
-     * there are unsaved drafts.
-     */
-    _getPatchNum(comment) {
-      return comment.patch_set || this.patchForNewThreads;
-    },
-
-    _getThreads(comments) {
-      const sortedComments = comments.slice(0).sort((a, b) => {
-        if (b.__draft && !a.__draft ) { return 0; }
-        if (a.__draft && !b.__draft ) { return 1; }
-        return util.parseDate(a.updated) - util.parseDate(b.updated);
-      });
-
-      const threads = [];
-      for (const comment of sortedComments) {
-        // If the comment is in reply to another comment, find that comment's
-        // thread and append to it.
-        if (comment.in_reply_to) {
-          const thread = threads.find(thread =>
-              thread.comments.some(c => c.id === comment.in_reply_to));
-          if (thread) {
-            thread.comments.push(comment);
-            continue;
-          }
-        }
-
-        // Otherwise, this comment starts its own thread.
-        const newThread = {
-          start_datetime: comment.updated,
-          comments: [comment],
-          commentSide: comment.__commentSide,
-          patchNum: this._getPatchNum(comment),
-          rootId: comment.id || comment.__draftID,
-        };
-        if (comment.range) {
-          newThread.range = Object.assign({}, comment.range);
-        }
-        threads.push(newThread);
-      }
-      return threads;
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
deleted file mode 100644
index b0ee743..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
+++ /dev/null
@@ -1,392 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-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.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-diff-comment-thread-group</title>
-
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-
-<link rel="import" href="gr-diff-comment-thread-group.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-comment-thread-group></gr-diff-comment-thread-group>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-diff-comment-thread-group tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_getThreads', () => {
-      element.patchForNewThreads = 3;
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-          in_reply_to: 'sallys_confession',
-        },
-        {
-          id: 'new_draft',
-          message: 'i do not like either of you',
-          __commentSide: 'left',
-          __draft: true,
-          updated: '2015-12-20 15:01:20.396000000',
-        },
-      ];
-
-      let expectedThreadGroups = [
-        {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          commentSide: 'left',
-          comments: [{
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-23 15:00:20.396000000',
-            __commentSide: 'left',
-          }, {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            __commentSide: 'left',
-            in_reply_to: 'sallys_confession',
-          }],
-          rootId: 'sallys_confession',
-          patchNum: 3,
-        },
-        {
-          start_datetime: '2015-12-20 15:01:20.396000000',
-          commentSide: 'left',
-          comments: [
-            {
-              id: 'new_draft',
-              message: 'i do not like either of you',
-              __commentSide: 'left',
-              __draft: true,
-              updated: '2015-12-20 15:01:20.396000000',
-            },
-          ],
-          rootId: 'new_draft',
-          patchNum: 3,
-        },
-      ];
-
-      assert.deepEqual(element._getThreads(comments), expectedThreadGroups);
-
-      // Patch num should get inherited from comment rather
-      comments.push({
-        id: 'betsys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-24 15:00:10.396000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 2,
-        },
-        __commentSide: 'left',
-      });
-
-      expectedThreadGroups = [
-        {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          commentSide: 'left',
-          comments: [{
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-23 15:00:20.396000000',
-            __commentSide: 'left',
-          }, {
-            id: 'jacks_reply',
-            in_reply_to: 'sallys_confession',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            __commentSide: 'left',
-          }],
-          patchNum: 3,
-          rootId: 'sallys_confession',
-        },
-        {
-          start_datetime: '2015-12-24 15:00:10.396000000',
-          commentSide: 'left',
-          comments: [{
-            id: 'betsys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:10.396000000',
-            range: {
-              start_line: 1,
-              start_character: 1,
-              end_line: 1,
-              end_character: 2,
-            },
-            __commentSide: 'left',
-          }],
-          patchNum: 3,
-          rootId: 'betsys_confession',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 1,
-            end_character: 2,
-          },
-        },
-        {
-          start_datetime: '2015-12-20 15:01:20.396000000',
-          commentSide: 'left',
-          comments: [
-            {
-              id: 'new_draft',
-              message: 'i do not like either of you',
-              __commentSide: 'left',
-              __draft: true,
-              updated: '2015-12-20 15:01:20.396000000',
-            },
-          ],
-          rootId: 'new_draft',
-          patchNum: 3,
-        },
-      ];
-
-      assert.deepEqual(element._getThreads(comments), expectedThreadGroups);
-    });
-
-    test('getThread', () => {
-      const range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 1,
-        end_character: 2,
-      };
-      element.comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-          in_reply_to: 'sallys_confession',
-        }, {
-          id: 'new_draft',
-          message: 'i do not like either of you',
-          __commentSide: 'left',
-          __draft: true,
-          in_reply_to: 'sallys_confession',
-          updated: '2015-12-20 15:01:20.396000000',
-        }, {
-          id: 'right_side_comment',
-          message: 'right side comment',
-          __commentSide: 'right',
-          __draft: true,
-          updated: '2015-12-20 15:01:20.396000000',
-        }, {
-          id: 'betsys_confession',
-          message: 'i like you more, jack',
-          updated: '2015-12-24 15:00:10.396000000',
-          range,
-          __commentSide: 'left',
-        },
-      ];
-
-      flushAsynchronousOperations();
-      assert.deepEqual(element.getThread('right').rootId, 'right_side_comment');
-      assert.deepEqual(element.getThread('right').comments.length, 1);
-      assert.deepEqual(element.getThread('left').rootId, 'sallys_confession');
-      assert.deepEqual(element.getThread('left').comments.length, 3);
-      assert.deepEqual(element.getThread('left', range).rootId,
-          'betsys_confession');
-      assert.deepEqual(element.getThread('left', range).comments.length, 1);
-    });
-
-    test('multiple comments at same location but not threaded', () => {
-      element.patchForNewThreads = 3;
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-        },
-      ];
-      assert.equal(element._getThreads(comments).length, 2);
-    });
-
-    test('_sortByDate', () => {
-      let threadGroups = [
-        {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          comments: [],
-          locationRange: 'line',
-        },
-        {
-          start_datetime: '2015-12-22 15:00:10.396000000',
-          comments: [],
-          locationRange: 'range-1-1-1-2',
-        },
-      ];
-
-      let expectedResult = [
-        {
-          start_datetime: '2015-12-22 15:00:10.396000000',
-          comments: [],
-          locationRange: 'range-1-1-1-2',
-        }, {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          comments: [],
-          locationRange: 'line',
-        },
-      ];
-
-      assert.deepEqual(element._sortByDate(threadGroups), expectedResult);
-
-      // When a comment doesn't have a date, the one without the date should be
-      // last.
-      threadGroups = [
-        {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          comments: [],
-          locationRange: 'line',
-        },
-        {
-          comments: [],
-          locationRange: 'range-1-1-1-2',
-        },
-      ];
-
-      expectedResult = [
-        {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          comments: [],
-          locationRange: 'line',
-        },
-        {
-          comments: [],
-          locationRange: 'range-1-1-1-2',
-        },
-      ];
-
-      assert.deepEqual(element._sortByDate(threadGroups), expectedResult);
-    });
-
-    test('_calculateLocationRange', () => {
-      const comment = {__commentSide: 'left'};
-      const range = {
-        start_line: 1,
-        start_character: 2,
-        end_line: 3,
-        end_character: 4,
-      };
-      assert.equal(
-          element._calculateLocationRange(range, comment),
-          'range-1-2-3-4-left');
-    });
-
-    test('thread groups are updated when comments change', () => {
-      const commentsChangedStub = sandbox.stub(element, '_commentsChanged');
-      element.comments = [];
-      element.comments.push({
-        id: 'sallys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000',
-      });
-      assert(commentsChangedStub.called);
-    });
-
-    test('addNewThread', () => {
-      const locationRange = 'range-1-2-3-4';
-      element._threads = [{locationRange: 'line'}];
-      element.addNewThread(locationRange);
-      assert(element._threads.length, 2);
-    });
-
-    test('_getPatchNum', () => {
-      element.patchForNewThreads = 3;
-      const comment = {
-        id: 'sallys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000',
-      };
-      assert.equal(element._getPatchNum(comment), 3);
-      comment.patch_set = 4;
-      assert.equal(element._getPatchNum(comment), 4);
-    });
-
-    test('removeThread', () => {
-      const locationRange = 'range-1-2-3-4';
-      element._threads = [
-        {locationRange: 'range-1-2-3-4', comments: []},
-        {locationRange: 'line', comments: []},
-      ];
-      flushAsynchronousOperations();
-      element.removeThread(locationRange);
-      flushAsynchronousOperations();
-      assert(element._threads.length, 1);
-    });
-
-    test('_rangesEqual', () => {
-      const range1 =
-          {startLine: 123, startChar: 345, endLine: 234, endChar: 456};
-      const range2 =
-          {startLine: 1, startChar: 2, endLine: 3, endChar: 4};
-
-      assert.isTrue(element._rangesEqual(null, null));
-      assert.isTrue(element._rangesEqual(null, undefined));
-      assert.isTrue(element._rangesEqual(undefined, null));
-      assert.isTrue(element._rangesEqual(undefined, undefined));
-
-      assert.isFalse(element._rangesEqual(range1, null));
-      assert.isFalse(element._rangesEqual(null, range1));
-      assert.isFalse(element._rangesEqual(range1, range2));
-
-      assert.isTrue(element._rangesEqual(range1, Object.assign({}, range1)));
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
deleted file mode 100644
index 0da1f00..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ /dev/null
@@ -1,125 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../gr-diff-comment/gr-diff-comment.html">
-
-<dom-module id="gr-diff-comment-thread">
-  <template>
-    <style include="shared-styles">
-      gr-button {
-        margin-left: .5em;
-      }
-      #actions {
-        margin-left: auto;
-        padding: .5em .7em;
-      }
-      #container {
-        background-color: var(--comment-background-color);
-        border: 1px solid var(--border-color);
-        color: var(--comment-text-color);
-        display: block;
-        margin-bottom: 1px;
-        white-space: normal;
-      }
-      #container.unresolved {
-        background-color: var(--unresolved-comment-background-color);
-      }
-      #commentInfoContainer {
-        border-top: 1px dotted var(--border-color);
-        display: flex;
-      }
-      #unresolvedLabel {
-        font-family: var(--font-family);
-        margin: auto 0;
-        padding: .5em .7em;
-      }
-      .pathInfo {
-        display: flex;
-        align-items: baseline;
-      }
-      .descriptionText {
-        margin-left: .5rem;
-        font-size: var(--font-size-small);
-        font-style: italic;
-      }
-    </style>
-    <template is="dom-if" if="[[showFilePath]]">
-      <div class="pathInfo">
-        <a href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]">[[_computeDisplayPath(path)]]</a>
-        <span class="descriptionText">Patchset [[patchNum]]</span>
-      </div>
-    </template>
-    <div id="container" class$="[[_computeHostClass(unresolved)]]">
-      <template id="commentList" is="dom-repeat" items="[[_orderedComments]]"
-          as="comment">
-        <gr-diff-comment
-            comment="{{comment}}"
-            robot-button-disabled="[[_hideActions(_showActions, _lastComment)]]"
-            change-num="[[changeNum]]"
-            patch-num="[[patchNum]]"
-            draft="[[_isDraft(comment)]]"
-            show-actions="[[_showActions]]"
-            comment-side="[[comment.__commentSide]]"
-            side="[[comment.side]]"
-            root-id="[[rootId]]"
-            project-config="[[_projectConfig]]"
-            on-create-fix-comment="_handleCommentFix"
-            on-comment-discard="_handleCommentDiscard"
-            on-comment-save="_handleCommentSavedOrDiscarded"></gr-diff-comment>
-      </template>
-      <div id="commentInfoContainer"
-          hidden$="[[_hideActions(_showActions, _lastComment)]]">
-        <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
-        <div id="actions">
-          <gr-button
-              id="replyBtn"
-              link
-              secondary
-              class="action reply"
-              on-tap="_handleCommentReply">Reply</gr-button>
-          <gr-button
-              id="quoteBtn"
-              link
-              secondary
-              class="action quote"
-              on-tap="_handleCommentQuote">Quote</gr-button>
-          <gr-button
-              id="ackBtn"
-              link
-              secondary
-              class="action ack"
-              on-tap="_handleCommentAck">Ack</gr-button>
-          <gr-button
-              id="doneBtn"
-              link
-              secondary
-              class="action done"
-              on-tap="_handleCommentDone">Done</gr-button>
-        </div>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-storage id="storage"></gr-storage>
-  </template>
-  <script src="gr-diff-comment-thread.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
deleted file mode 100644
index 136d23f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ /dev/null
@@ -1,464 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-(function() {
-  'use strict';
-
-  const UNRESOLVED_EXPAND_COUNT = 5;
-  const NEWLINE_PATTERN = /\n/g;
-
-  Polymer({
-    is: 'gr-diff-comment-thread',
-
-    /**
-     * Fired when the thread should be discarded.
-     *
-     * @event thread-discard
-     */
-
-    /**
-     * Fired when a comment in the thread is permanently modified.
-     *
-     * @event thread-changed
-     */
-
-    properties: {
-      changeNum: String,
-      comments: {
-        type: Array,
-        value() { return []; },
-      },
-      range: Object,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      commentSide: String,
-      patchNum: String,
-      path: String,
-      projectName: {
-        type: String,
-        observer: '_projectNameChanged',
-      },
-      hasDraft: {
-        type: Boolean,
-        notify: true,
-        reflectToAttribute: true,
-      },
-      isOnParent: {
-        type: Boolean,
-        value: false,
-      },
-      parentIndex: {
-        type: Number,
-        value: null,
-      },
-      rootId: {
-        type: String,
-        notify: true,
-        computed: '_computeRootId(comments.*)',
-      },
-      /**
-       * If this is true, the comment thread also needs to have the change and
-       * line properties property set
-       */
-      showFilePath: {
-        type: Boolean,
-        value: false,
-      },
-      /** Necessary only if showFilePath is true */
-      lineNum: Number,
-      unresolved: {
-        type: Boolean,
-        notify: true,
-        reflectToAttribute: true,
-      },
-      _showActions: Boolean,
-      _lastComment: Object,
-      _orderedComments: Array,
-      _projectConfig: Object,
-    },
-
-    behaviors: [
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PathListBehavior,
-    ],
-
-    listeners: {
-      'comment-update': '_handleCommentUpdate',
-    },
-
-    observers: [
-      '_commentsChanged(comments.*)',
-    ],
-
-    keyBindings: {
-      'e shift+e': '_handleEKey',
-    },
-
-    attached() {
-      this._getLoggedIn().then(loggedIn => {
-        this._showActions = loggedIn;
-      });
-      this._setInitialExpandedState();
-    },
-
-    addOrEditDraft(opt_lineNum, opt_range) {
-      const lastComment = this.comments[this.comments.length - 1] || {};
-      if (lastComment.__draft) {
-        const commentEl = this._commentElWithDraftID(
-            lastComment.id || lastComment.__draftID);
-        commentEl.editing = true;
-
-        // If the comment was collapsed, re-open it to make it clear which
-        // actions are available.
-        commentEl.collapsed = false;
-      } else {
-        const range = opt_range ? opt_range :
-            lastComment ? lastComment.range : undefined;
-        const unresolved = lastComment ? lastComment.unresolved : undefined;
-        this.addDraft(opt_lineNum, range, unresolved);
-      }
-    },
-
-    addDraft(opt_lineNum, opt_range, opt_unresolved) {
-      const draft = this._newDraft(opt_lineNum, opt_range);
-      draft.__editing = true;
-      draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
-      this.push('comments', draft);
-    },
-
-    fireRemoveSelf() {
-      this.dispatchEvent(new CustomEvent('thread-discard',
-          {detail: {rootId: this.rootId}, bubbles: false}));
-    },
-
-    _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
-      return Gerrit.Nav.getUrlForDiffById(changeNum,
-          projectName, path, patchNum,
-          null, this.lineNum);
-    },
-
-    _computeDisplayPath(path) {
-      const lineString = this.lineNum ? `#${this.lineNum}` : '';
-      return this.computeDisplayPath(path) + lineString;
-    },
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
-
-    _commentsChanged() {
-      this._orderedComments = this._sortedComments(this.comments);
-      this.updateThreadProperties();
-    },
-
-    updateThreadProperties() {
-      if (this._orderedComments.length) {
-        this._lastComment = this._getLastComment();
-        this.unresolved = this._lastComment.unresolved;
-        this.hasDraft = this._lastComment.__draft;
-      }
-    },
-
-    _hideActions(_showActions, _lastComment) {
-      return !_showActions || !_lastComment || !!_lastComment.__draft;
-    },
-
-    _getLastComment() {
-      return this._orderedComments[this._orderedComments.length - 1] || {};
-    },
-
-    _handleEKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      // Don’t preventDefault in this case because it will render the event
-      // useless for other handlers (other gr-diff-comment-thread elements).
-      if (e.detail.keyboardEvent.shiftKey) {
-        this._expandCollapseComments(true);
-      } else {
-        if (this.modifierPressed(e)) { return; }
-        this._expandCollapseComments(false);
-      }
-    },
-
-    _expandCollapseComments(actionIsCollapse) {
-      const comments =
-          Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
-      for (const comment of comments) {
-        comment.collapsed = actionIsCollapse;
-      }
-    },
-
-    /**
-     * Sets the initial state of the comment thread.
-     * Expands the thread if one of the following is true:
-     * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
-     * thread is unresolved,
-     * - it's a robot comment.
-     */
-    _setInitialExpandedState() {
-      if (this._orderedComments) {
-        for (let i = 0; i < this._orderedComments.length; i++) {
-          const comment = this._orderedComments[i];
-          const isRobotComment = !!comment.robot_id;
-          // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
-          const resolvedThread = !this.unresolved ||
-                this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
-          comment.collapsed = !isRobotComment && resolvedThread;
-        }
-      }
-    },
-
-    _sortedComments(comments) {
-      return comments.slice().sort((c1, c2) => {
-        const c1Date = c1.__date || util.parseDate(c1.updated);
-        const c2Date = c2.__date || util.parseDate(c2.updated);
-        const dateCompare = c1Date - c2Date;
-        // Ensure drafts are at the end. There should only be one but in edge
-        // cases could be more. In the unlikely event two drafts are being
-        // compared, use the typical date compare.
-        if (c2.__draft && !c1.__draft ) { return 0; }
-        if (c1.__draft && !c2.__draft ) { return 1; }
-        if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
-        // If same date, fall back to sorting by id.
-        return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
-      });
-    },
-
-    _createReplyComment(parent, content, opt_isEditing,
-        opt_unresolved) {
-      const reply = this._newReply(
-          this._orderedComments[this._orderedComments.length - 1].id,
-          parent.line,
-          content,
-          opt_unresolved,
-          parent.range);
-
-      // If there is currently a comment in an editing state, add an attribute
-      // so that the gr-diff-comment knows not to populate the draft text.
-      for (let i = 0; i < this.comments.length; i++) {
-        if (this.comments[i].__editing) {
-          reply.__otherEditing = true;
-          break;
-        }
-      }
-
-      if (opt_isEditing) {
-        reply.__editing = true;
-      }
-
-      this.push('comments', reply);
-
-      if (!opt_isEditing) {
-        // Allow the reply to render in the dom-repeat.
-        this.async(() => {
-          const commentEl = this._commentElWithDraftID(reply.__draftID);
-          commentEl.save();
-        }, 1);
-      }
-    },
-
-    _isDraft(comment) {
-      return !!comment.__draft;
-    },
-
-    /**
-     * @param {boolean=} opt_quote
-     */
-    _processCommentReply(opt_quote) {
-      const comment = this._lastComment;
-      let quoteStr;
-      if (opt_quote) {
-        const msg = comment.message;
-        quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
-      }
-      this._createReplyComment(comment, quoteStr, true, comment.unresolved);
-    },
-
-    _handleCommentReply(e) {
-      this._processCommentReply();
-    },
-
-    _handleCommentQuote(e) {
-      this._processCommentReply(true);
-    },
-
-    _handleCommentAck(e) {
-      const comment = this._lastComment;
-      this._createReplyComment(comment, 'Ack', false, false);
-    },
-
-    _handleCommentDone(e) {
-      const comment = this._lastComment;
-      this._createReplyComment(comment, 'Done', false, false);
-    },
-
-    _handleCommentFix(e) {
-      const comment = e.detail.comment;
-      const msg = comment.message;
-      const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
-      const response = quoteStr + 'Please Fix';
-      this._createReplyComment(comment, response, false, true);
-    },
-
-    _commentElWithDraftID(id) {
-      const els = Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
-      for (const el of els) {
-        if (el.comment.id === id || el.comment.__draftID === id) {
-          return el;
-        }
-      }
-      return null;
-    },
-
-    _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
-        opt_range) {
-      const d = this._newDraft(opt_lineNum);
-      d.in_reply_to = inReplyTo;
-      d.range = opt_range;
-      if (opt_message != null) {
-        d.message = opt_message;
-      }
-      if (opt_unresolved !== undefined) {
-        d.unresolved = opt_unresolved;
-      }
-      return d;
-    },
-
-    /**
-     * @param {number=} opt_lineNum
-     * @param {!Object=} opt_range
-     */
-    _newDraft(opt_lineNum, opt_range) {
-      const d = {
-        __draft: true,
-        __draftID: Math.random().toString(36),
-        __date: new Date(),
-        path: this.path,
-        patchNum: this.patchNum,
-        side: this._getSide(this.isOnParent),
-        __commentSide: this.commentSide,
-      };
-      if (opt_lineNum) {
-        d.line = opt_lineNum;
-      }
-      if (opt_range) {
-        d.range = {
-          start_line: opt_range.startLine,
-          start_character: opt_range.startChar,
-          end_line: opt_range.endLine,
-          end_character: opt_range.endChar,
-        };
-      }
-      if (this.parentIndex) {
-        d.parent = this.parentIndex;
-      }
-      return d;
-    },
-
-    _getSide(isOnParent) {
-      if (isOnParent) { return 'PARENT'; }
-      return 'REVISION';
-    },
-
-    _computeRootId(comments) {
-      // Keep the root ID even if the comment was removed, so that notification
-      // to sync will know which thread to remove.
-      if (!comments.base.length) { return this.rootId; }
-      const rootComment = comments.base[0];
-      return rootComment.id || rootComment.__draftID;
-    },
-
-    _handleCommentDiscard(e) {
-      const diffCommentEl = Polymer.dom(e).rootTarget;
-      const comment = diffCommentEl.comment;
-      const idx = this._indexOf(comment, this.comments);
-      if (idx == -1) {
-        throw Error('Cannot find comment ' +
-            JSON.stringify(diffCommentEl.comment));
-      }
-      this.splice('comments', idx, 1);
-      if (this.comments.length === 0) {
-        this.fireRemoveSelf();
-      }
-      this._handleCommentSavedOrDiscarded(e);
-
-      // Check to see if there are any other open comments getting edited and
-      // set the local storage value to its message value.
-      for (const changeComment of this.comments) {
-        if (changeComment.__editing) {
-          const commentLocation = {
-            changeNum: this.changeNum,
-            patchNum: this.patchNum,
-            path: changeComment.path,
-            line: changeComment.line,
-          };
-          return this.$.storage.setDraftComment(commentLocation,
-              changeComment.message);
-        }
-      }
-    },
-
-    _handleCommentSavedOrDiscarded(e) {
-      this.dispatchEvent(new CustomEvent('thread-changed',
-          {detail: {rootId: this.rootId, path: this.path},
-            bubbles: false}));
-    },
-
-    _handleCommentUpdate(e) {
-      const comment = e.detail.comment;
-      const index = this._indexOf(comment, this.comments);
-      if (index === -1) {
-        // This should never happen: comment belongs to another thread.
-        console.warn('Comment update for another comment thread.');
-        return;
-      }
-      this.set(['comments', index], comment);
-      // Because of the way we pass these comment objects around by-ref, in
-      // combination with the fact that Polymer does dirty checking in
-      // observers, the this.set() call above will not cause a thread update in
-      // some situations.
-      this.updateThreadProperties();
-    },
-
-    _indexOf(comment, arr) {
-      for (let i = 0; i < arr.length; i++) {
-        const c = arr[i];
-        if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
-            (c.id != null && c.id == comment.id)) {
-          return i;
-        }
-      }
-      return -1;
-    },
-
-    _computeHostClass(unresolved) {
-      return unresolved ? 'unresolved' : '';
-    },
-
-    /**
-     * Load the project config when a project name has been provided.
-     * @param {string} name The project name.
-     */
-    _projectNameChanged(name) {
-      if (!name) { return; }
-      this.$.restAPI.getProjectConfig(name).then(config => {
-        this._projectConfig = config;
-      });
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
deleted file mode 100644
index 6fb27ec..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ /dev/null
@@ -1,706 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-diff-comment-thread</title>
-
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-
-<link rel="import" href="gr-diff-comment-thread.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-comment-thread></gr-diff-comment-thread>
-  </template>
-</test-fixture>
-
-<test-fixture id="withComment">
-  <template>
-    <gr-diff-comment-thread></gr-diff-comment-thread>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-diff-comment-thread tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('comments are sorted correctly', () => {
-      const comments = [
-        {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        },
-      ];
-      const results = element._sortedComments(comments);
-      assert.deepEqual(results, [
-        {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        },
-      ]);
-    });
-
-    test('addOrEditDraft w/ edit draft', () => {
-      element.comments = [{
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        in_reply_to: 'sallys_confession',
-        updated: '2015-12-25 15:00:20.396000000',
-        __draft: true,
-      }];
-      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          () => { return {}; });
-      const addDraftStub = sandbox.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isTrue(commentElStub.called);
-      assert.isFalse(addDraftStub.called);
-    });
-
-    test('addOrEditDraft w/o edit draft', () => {
-      element.comments = [];
-      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          () => { return {}; });
-      const addDraftStub = sandbox.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isFalse(commentElStub.called);
-      assert.isTrue(addDraftStub.called);
-    });
-
-    test('_hideActions', () => {
-      let showActions = true;
-      const lastComment = {};
-      assert.equal(element._hideActions(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-    });
-
-    test('setting project name loads the project config', done => {
-      const projectName = 'foo/bar/baz';
-      const getProjectStub = sandbox.stub(element.$.restAPI, 'getProjectConfig')
-          .returns(Promise.resolve({}));
-      element.projectName = projectName;
-      flush(() => {
-        assert.isTrue(getProjectStub.calledWithExactly(projectName));
-        done();
-      });
-    });
-
-    test('optionally show file path', () => {
-      // Path info doesn't exist when showFilePath is false. Because it's in a
-      // dom-if it is not yet in the dom.
-      assert.isNotOk(element.$$('.pathInfo'));
-
-      sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
-      element.changeNum = 123;
-      element.projectName = 'test project';
-      element.path = 'path/to/file';
-      element.patchNum = 3;
-      element.lineNum = 5;
-      element.showFilePath = true;
-      flushAsynchronousOperations();
-      assert.isOk(element.$$('.pathInfo'));
-      assert.notEqual(getComputedStyle(element.$$('.pathInfo')).display,
-          'none');
-      assert.isTrue(Gerrit.Nav.getUrlForDiffById.lastCall.calledWithExactly(
-          element.changeNum, element.projectName, element.path,
-          element.patchNum, null, element.lineNum));
-    });
-
-    test('_computeDisplayPath', () => {
-      const path = 'path/to/file';
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file#5');
-    });
-  });
-
-  suite('comment action tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        saveDiffDraft() {
-          return Promise.resolve({
-            ok: true,
-            text() {
-              return Promise.resolve(')]}\'\n' +
-                  JSON.stringify({
-                    id: '7afa4931_de3d65bd',
-                    path: '/path/to/file.txt',
-                    line: 5,
-                    in_reply_to: 'baf0414d_60047215',
-                    updated: '2015-12-21 02:01:10.850000000',
-                    message: 'Done',
-                  }));
-            },
-          });
-        },
-        deleteDiffDraft() { return Promise.resolve({ok: true}); },
-      });
-      element = fixture('withComment');
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
-        path: '/path/to/file.txt',
-      }];
-      flushAsynchronousOperations();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('reply', done => {
-      const commentEl = element.$$('gr-diff-comment');
-      assert.ok(commentEl);
-
-      const replyBtn = element.$.replyBtn;
-      MockInteractions.tap(replyBtn);
-      flushAsynchronousOperations();
-
-      const drafts = element._orderedComments.filter(c => {
-        return c.__draft == true;
-      });
-      assert.equal(drafts.length, 1);
-      assert.notOk(drafts[0].message, 'message should be empty');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      done();
-    });
-
-    test('quote reply', done => {
-      const commentEl = element.$$('gr-diff-comment');
-      assert.ok(commentEl);
-
-      const quoteBtn = element.$.quoteBtn;
-      MockInteractions.tap(quoteBtn);
-      flushAsynchronousOperations();
-
-      const drafts = element._orderedComments.filter(c => {
-        return c.__draft == true;
-      });
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      done();
-    });
-
-    test('quote reply multiline', done => {
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?\nIt might be!',
-        updated: '2015-12-08 19:48:33.843000000',
-      }];
-      flushAsynchronousOperations();
-
-      const commentEl = element.$$('gr-diff-comment');
-      assert.ok(commentEl);
-
-      const quoteBtn = element.$.quoteBtn;
-      MockInteractions.tap(quoteBtn);
-      flushAsynchronousOperations();
-
-      const drafts = element._orderedComments.filter(c => {
-        return c.__draft == true;
-      });
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message,
-          '> is this a crossover episode!?\n> It might be!\n\n');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      done();
-    });
-
-    test('ack', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-
-      const commentEl = element.$$('gr-diff-comment');
-      assert.ok(commentEl);
-
-      const ackBtn = element.$.ackBtn;
-      MockInteractions.tap(ackBtn);
-      flush(() => {
-        const drafts = element.comments.filter(c => {
-          return c.__draft == true;
-        });
-        assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].message, 'Ack');
-        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-        assert.equal(drafts[0].unresolved, false);
-        done();
-      });
-    });
-
-    test('done', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      const commentEl = element.$$('gr-diff-comment');
-      assert.ok(commentEl);
-
-      const doneBtn = element.$.doneBtn;
-      MockInteractions.tap(doneBtn);
-      flush(() => {
-        const drafts = element.comments.filter(c => {
-          return c.__draft == true;
-        });
-        assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].message, 'Done');
-        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-        assert.isFalse(drafts[0].unresolved);
-        done();
-      });
-    });
-
-    test('save', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      element.path = '/path/to/file.txt';
-      const commentEl = element.$$('gr-diff-comment');
-      assert.ok(commentEl);
-
-      const saveOrDiscardStub = sandbox.stub();
-      element.addEventListener('thread-changed', saveOrDiscardStub);
-      element.$$('gr-diff-comment')._fireSave();
-
-      flush(() => {
-        assert.isTrue(saveOrDiscardStub.called);
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-            'baf0414d_60047215');
-        assert.equal(element.rootId, 'baf0414d_60047215');
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-            '/path/to/file.txt');
-        done();
-      });
-    });
-
-    test('please fix', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      const commentEl = element.$$('gr-diff-comment');
-      assert.ok(commentEl);
-      commentEl.addEventListener('create-fix-comment', () => {
-        const drafts = element._orderedComments.filter(c => {
-          return c.__draft == true;
-        });
-        assert.equal(drafts.length, 1);
-        assert.equal(
-            drafts[0].message, '> is this a crossover episode!?\n\nPlease Fix');
-        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-        assert.isTrue(drafts[0].unresolved);
-        done();
-      });
-      commentEl.fire('create-fix-comment', {comment: commentEl.comment},
-          {bubbles: false});
-    });
-
-    test('discard', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      element.path = '/path/to/file.txt';
-      element.push('comments', element._newReply(
-          element.comments[0].id,
-          element.comments[0].line,
-          element.comments[0].path,
-          'it’s pronouced jiff, not giff'));
-      flushAsynchronousOperations();
-
-      const saveOrDiscardStub = sandbox.stub();
-      element.addEventListener('thread-changed', saveOrDiscardStub);
-      const draftEl =
-          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
-      assert.ok(draftEl);
-      draftEl.addEventListener('comment-discard', () => {
-        const drafts = element.comments.filter(c => {
-          return c.__draft == true;
-        });
-        assert.equal(drafts.length, 0);
-        assert.isTrue(saveOrDiscardStub.called);
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-            element.rootId);
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-            element.path);
-        done();
-      });
-      draftEl.fire('comment-discard', {comment: draftEl.comment},
-          {bubbles: false});
-    });
-
-    test('discard with a single comment still fires event with previous rootId',
-        done => {
-          element.changeNum = '42';
-          element.patchNum = '1';
-          element.path = '/path/to/file.txt';
-          element.comments = [];
-          element.addOrEditDraft('1');
-          flushAsynchronousOperations();
-          const rootId = element.rootId;
-          assert.isOk(rootId);
-
-          const saveOrDiscardStub = sandbox.stub();
-          element.addEventListener('thread-changed', saveOrDiscardStub);
-          const draftEl =
-          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[0];
-          assert.ok(draftEl);
-          draftEl.addEventListener('comment-discard', () => {
-            assert.equal(element.comments.length, 0);
-            assert.isTrue(saveOrDiscardStub.called);
-            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-                rootId);
-            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-                element.path);
-            done();
-          });
-          draftEl.fire('comment-discard', {comment: draftEl.comment},
-          {bubbles: false});
-        });
-
-    test('first editing comment does not add __otherEditing attribute', () => {
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
-        __draft: true,
-      }];
-
-      const replyBtn = element.$.replyBtn;
-      MockInteractions.tap(replyBtn);
-      flushAsynchronousOperations();
-
-      const editing = element._orderedComments.filter(c => {
-        return c.__editing == true;
-      });
-      assert.equal(editing.length, 1);
-      assert.equal(!!editing[0].__otherEditing, false);
-    });
-
-    test('When not editing other comments, local storage not set' +
-        ' after discard', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:31.843000000',
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        __draftID: '1',
-        in_reply_to: 'baf0414d_60047215',
-        line: 5,
-        message: 'yes',
-        updated: '2015-12-08 19:48:32.843000000',
-        __draft: true,
-        __editing: true,
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        __draftID: '2',
-        in_reply_to: 'baf0414d_60047215',
-        line: 5,
-        message: 'no',
-        updated: '2015-12-08 19:48:33.843000000',
-        __draft: true,
-      }];
-      const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
-      flushAsynchronousOperations();
-
-      const draftEl =
-      Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
-      assert.ok(draftEl);
-      draftEl.addEventListener('comment-discard', () => {
-        assert.isFalse(storageStub.called);
-        storageStub.restore();
-        done();
-      });
-      draftEl.fire('comment-discard', {comment: draftEl.comment},
-          {bubbles: false});
-    });
-
-    test('comment-update', () => {
-      const commentEl = element.$$('gr-diff-comment');
-      const updatedComment = {
-        id: element.comments[0].id,
-        foo: 'bar',
-      };
-      commentEl.fire('comment-update', {comment: updatedComment});
-      assert.strictEqual(element.comments[0], updatedComment);
-    });
-
-    suite('jack and sally comment data test consolidation', () => {
-      setup(() => {
-        element.comments = [
-          {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            in_reply_to: 'sallys_confession',
-            updated: '2015-12-25 15:00:20.396000000',
-            unresolved: false,
-          }, {
-            id: 'sallys_confession',
-            in_reply_to: 'nonexistent_comment',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:20.396000000',
-          }, {
-            id: 'sally_to_dr_finklestein',
-            in_reply_to: 'nonexistent_comment',
-            message: 'i’m running away',
-            updated: '2015-10-31 09:00:20.396000000',
-          }, {
-            id: 'sallys_defiance',
-            message: 'i will poison you so i can get away',
-            updated: '2015-10-31 15:00:20.396000000',
-          }];
-      });
-
-      test('orphan replies', () => {
-        assert.equal(4, element._orderedComments.length);
-      });
-
-      test('keyboard shortcuts', () => {
-        const expandCollapseStub =
-            sinon.stub(element, '_expandCollapseComments');
-        MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
-        assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-        MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
-        assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-      });
-
-      test('comment in_reply_to is either null or most recent comment', () => {
-        element._createReplyComment(element.comments[3], 'dummy', true);
-        flushAsynchronousOperations();
-        assert.equal(element._orderedComments.length, 5);
-        assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
-      });
-
-      test('resolvable comments', () => {
-        assert.isFalse(element.unresolved);
-        element._createReplyComment(element.comments[3], 'dummy', true, true);
-        flushAsynchronousOperations();
-        assert.isTrue(element.unresolved);
-      });
-
-      test('_setInitialExpandedState', () => {
-        element.unresolved = true;
-        element._setInitialExpandedState();
-        for (let i = 0; i < element.comments.length; i++) {
-          assert.isFalse(element.comments[i].collapsed);
-        }
-        element.unresolved = false;
-        element._setInitialExpandedState();
-        for (let i = 0; i < element.comments.length; i++) {
-          assert.isTrue(element.comments[i].collapsed);
-        }
-        for (let i = 0; i < element.comments.length; i++) {
-          element.comments[i].robot_id = 123;
-        }
-        element._setInitialExpandedState();
-        for (let i = 0; i < element.comments.length; i++) {
-          assert.isFalse(element.comments[i].collapsed);
-        }
-      });
-    });
-
-    test('_computeHostClass', () => {
-      assert.equal(element._computeHostClass(true), 'unresolved');
-      assert.equal(element._computeHostClass(false), '');
-    });
-
-    test('addDraft sets unresolved state correctly', () => {
-      let unresolved = true;
-      element.comments = [];
-      element.addDraft(null, null, unresolved);
-      assert.equal(element.comments[0].unresolved, true);
-
-      unresolved = false; // comment should get added as actually resolved.
-      element.comments = [];
-      element.addDraft(null, null, unresolved);
-      assert.equal(element.comments[0].unresolved, false);
-
-      element.comments = [];
-      element.addDraft();
-      assert.equal(element.comments[0].unresolved, true);
-    });
-
-    test('_newDraft', () => {
-      element.commentSide = 'left';
-      element.patchNum = 3;
-      const draft = element._newDraft();
-      assert.equal(draft.__commentSide, 'left');
-      assert.equal(draft.patchNum, 3);
-    });
-
-    test('new comment gets created', () => {
-      element.comments = [];
-      element.addOrEditDraft(1);
-      assert.equal(element.comments.length, 1);
-      // Mock a submitted comment.
-      element.comments[0].id = element.comments[0].__draftID;
-      element.comments[0].__draft = false;
-      element.addOrEditDraft(1);
-      assert.equal(element.comments.length, 2);
-    });
-
-    test('unresolved label', () => {
-      element.unresolved = false;
-      assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
-      element.unresolved = true;
-      assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
-    });
-
-    test('draft comments are at the end of orderedComments', () => {
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 2,
-        line: 5,
-        message: 'Earlier draft',
-        updated: '2015-12-08 19:48:33.843000000',
-        __draft: true,
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 1,
-        line: 5,
-        message: 'This comment was left last but is not a draft',
-        updated: '2015-12-10 19:48:33.843000000',
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 3,
-        line: 5,
-        message: 'Later draft',
-        updated: '2015-12-09 19:48:33.843000000',
-        __draft: true,
-      }];
-      assert.equal(element._orderedComments[0].id, '1');
-      assert.equal(element._orderedComments[1].id, '2');
-      assert.equal(element._orderedComments[2].id, '3');
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
deleted file mode 100644
index 7df4dde..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ /dev/null
@@ -1,393 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html">
-<script src="../../../scripts/rootElement.js"></script>
-
-<dom-module id="gr-diff-comment">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        font-family: var(--font-family);
-        padding: .7em .7em;
-        --iron-autogrow-textarea: {
-          box-sizing: border-box;
-          padding: 2px;
-        };
-      }
-      :host([disabled]) {
-        pointer-events: none;
-      }
-      :host([disabled]) .body,
-      :host([disabled]) .date {
-        opacity: .5;
-      }
-      .header {
-        align-items: baseline;
-        cursor: pointer;
-        display: flex;
-        font-family: 'Open Sans', sans-serif;
-        margin: -.7em -.7em 0 -.7em;
-        padding: .7em;
-      }
-      .container.collapsed .header {
-        margin-bottom: -.7em;
-      }
-      .headerMiddle {
-        color: var(--deemphasized-text-color);
-        flex: 1;
-        overflow: hidden;
-      }
-      .authorName,
-      .draftLabel,
-      .draftTooltip {
-        font-family: var(--font-family-bold);
-      }
-      .draftLabel,
-      .draftTooltip {
-        color: var(--deemphasized-text-color);
-        display: none;
-      }
-      .date {
-        justify-content: flex-end;
-        margin-left: 5px;
-        min-width: 4.5em;
-        text-align: right;
-        white-space: nowrap;
-      }
-      a.date:link,
-      a.date:visited {
-        color: var(--deemphasized-text-color);
-      }
-      .actions {
-        display: flex;
-        justify-content: flex-end;
-        padding-top: 0;
-      }
-      .action {
-        margin-left: 1em;
-      }
-      .robotActions {
-        display: flex;
-        justify-content: flex-start;
-        padding-top: 0;
-      }
-      .robotActions .action {
-        /* Keep button text lined up with output text */
-        margin-left: -.3rem;
-        margin-right: 1em;
-      }
-      .rightActions {
-        display: flex;
-        justify-content: flex-end;
-      }
-      .editMessage {
-        display: none;
-        margin: .5em 0;
-        width: 100%;
-      }
-      .container:not(.draft) .actions .hideOnPublished {
-        display: none;
-      }
-      .draft .reply,
-      .draft .quote,
-      .draft .ack,
-      .draft .done {
-        display: none;
-      }
-      .draft .draftLabel,
-      .draft .draftTooltip {
-        display: inline;
-      }
-      .draft:not(.editing) .save,
-      .draft:not(.editing) .cancel {
-        display: none;
-      }
-      .editing .message,
-      .editing .reply,
-      .editing .quote,
-      .editing .ack,
-      .editing .done,
-      .editing .edit,
-      .editing .discard,
-      .editing .unresolved {
-        display: none;
-      }
-      .editing .editMessage {
-        display: block;
-      }
-      .show-hide {
-        margin-left: .4em;
-      }
-      .robotId {
-        color: var(--deemphasized-text-color);
-        margin-bottom: .8em;
-        margin-top: -.4em;
-      }
-      .robotIcon {
-        margin-right: .2em;
-        /* because of the antenna of the robot, it looks off center even when it
-         is centered. artificially adjust margin to account for this. */
-        margin-top: -.3em;
-      }
-      .runIdInformation {
-        margin: .7em 0;
-      }
-      .robotRun {
-        margin-left: .5em;
-      }
-      .robotRunLink {
-        margin-left: .5em;
-      }
-      input.show-hide {
-        display: none;
-      }
-      label.show-hide {
-        color: var(--comment-text-color);
-        cursor: pointer;
-        display: block;
-        font-size: .8rem;
-        height: 1.1em;
-        margin-top: .1em;
-      }
-      #container .collapsedContent {
-        display: none;
-      }
-      #container.collapsed {
-        padding-bottom: 3px;
-      }
-      #container.collapsed .collapsedContent {
-        display: block;
-        overflow: hidden;
-        padding-left: 5px;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-      #container.collapsed .actions,
-      #container.collapsed gr-formatted-text,
-      #container.collapsed gr-textarea {
-        display: none;
-      }
-      .resolve,
-      .unresolved {
-        align-items: center;
-        display: flex;
-        flex: 1;
-        margin: 0;
-      }
-      .resolve label {
-        color: var(--comment-text-color);
-        font-size: var(--font-size-small);
-      }
-      gr-confirm-dialog .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-      #deleteBtn {
-        display: none;
-        --gr-button: {
-          color: var(--deemphasized-text-color);
-          padding: 0;
-        }
-      }
-      #deleteBtn.showDeleteButtons {
-        display: block;
-      }
-      #savingMessage {
-        display: none;
-      }
-      :host([disabled]) #savingMessage {
-        display: inline;
-      }
-    </style>
-    <div id="container"
-        class="container"
-        on-mouseenter="_handleMouseEnter"
-        on-mouseleave="_handleMouseLeave">
-      <div class="header" id="header" on-tap="_handleToggleCollapsed">
-        <div class="headerLeft">
-          <span class="authorName">[[comment.author.name]]</span>
-          <span class="draftLabel">DRAFT</span>
-          <gr-tooltip-content class="draftTooltip"
-              has-tooltip
-              title="This draft is only visible to you. To publish drafts, click the red 'Reply' button at the top of the change or press the 'A' key."
-              max-width="20em"
-              show-icon></gr-tooltip-content>
-          <span id="savingMessage">[[_savingMessage]]</span>
-        </div>
-        <div class="headerMiddle">
-          <span class="collapsedContent">[[comment.message]]</span>
-        </div>
-        <gr-button
-            id="deleteBtn"
-            link
-            secondary
-            class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
-            on-tap="_handleCommentDelete">
-          (Delete)
-        </gr-button>
-        <a class="date" href$="[[_computeLinkToComment(comment)]]" on-tap="_handleLinkTap">
-          <gr-date-formatter
-              has-tooltip
-              date-str="[[comment.updated]]"></gr-date-formatter>
-        </a>
-        <div class="show-hide">
-          <label class="show-hide">
-            <input type="checkbox" class="show-hide"
-               checked$="[[collapsed]]"
-               on-change="_handleToggleCollapsed">
-            [[_computeShowHideText(collapsed)]]
-          </label>
-        </div>
-      </div>
-      <div class="body">
-        <template is="dom-if" if="[[comment.robot_id]]">
-          <div class="robotId" hidden$="[[collapsed]]">
-            <iron-icon class="robotIcon" icon="gr-icons:robot"></iron-icon>
-            [[comment.robot_id]]
-          </div>
-        </template>
-        <template is="dom-if" if="[[editing]]">
-          <gr-textarea
-              id="editTextarea"
-              class="editMessage"
-              autocomplete="on"
-              monospace
-              disabled="{{disabled}}"
-              rows="4"
-              text="{{_messageText}}"></gr-textarea>
-        </template>
-        <!--The message class is needed to ensure selectability from
-        gr-diff-selection.-->
-        <gr-formatted-text class="message"
-            content="[[comment.message]]"
-            no-trailing-margin="[[!comment.__draft]]"
-            collapsed="[[collapsed]]"
-            config="[[projectConfig.commentlinks]]"></gr-formatted-text>
-        <div hidden$="[[!comment.robot_run_id]]" class="message">
-          <div class="runIdInformation" hidden$="[[collapsed]]">
-            Run ID:
-            <template is="dom-if" if="[[comment.url]]">
-              <a class="robotRunLink" href$="[[comment.url]]">
-                <span class="robotRun link">[[comment.robot_run_id]]</span>
-              </a>
-            </template>
-            <template is="dom-if" if="[[!comment.url]]">
-              <span class="robotRun text">[[comment.robot_run_id]]</span>
-            </template>
-          </div>
-        </div>
-        <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
-          <div class="action resolve hideOnPublished">
-            <label>
-              <input type="checkbox"
-                  id="resolvedCheckbox"
-                  checked="[[resolved]]"
-                  on-change="_handleToggleResolved">
-              Resolved
-            </label>
-          </div>
-          <div class="rightActions">
-            <gr-button
-                link
-                secondary
-                class="action cancel hideOnPublished"
-                on-tap="_handleCancel">Cancel</gr-button>
-            <gr-button
-                link
-                secondary
-                class="action discard hideOnPublished"
-                on-tap="_handleDiscard">Discard</gr-button>
-            <gr-button
-                link
-                secondary
-                class="action edit hideOnPublished"
-                on-tap="_handleEdit">Edit</gr-button>
-            <gr-button
-                link
-                secondary
-                disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
-                class="action save hideOnPublished"
-                on-tap="_handleSave">Save</gr-button>
-          </div>
-        </div>
-        <div class="robotActions" hidden$="[[!_showRobotActions]]">
-          <template is="dom-if" if="[[isRobotComment]]">
-            <gr-button
-                link
-                secondary
-                class="action fix"
-                on-tap="_handleFix"
-                disabled="[[robotButtonDisabled]]">
-              Please Fix
-            </gr-button>
-            <gr-endpoint-decorator name="robot-comment-controls">
-              <gr-endpoint-param name="comment" value="[[comment]]">
-              </gr-endpoint-param>
-            </gr-endpoint-decorator>
-          </template>
-        </div>
-      </div>
-    </div>
-    <template is="dom-if" if="[[_enableOverlay]]">
-      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
-        <gr-confirm-delete-comment-dialog id="confirmDeleteComment"
-            on-confirm="_handleConfirmDeleteComment"
-            on-cancel="_handleCancelDeleteComment">
-        </gr-confirm-delete-comment-dialog>
-      </gr-overlay>
-      <gr-overlay id="confirmDiscardOverlay" with-backdrop>
-        <gr-confirm-dialog
-            id="confirmDiscardDialog"
-            confirm-label="Discard"
-            confirm-on-enter
-            on-confirm="_handleConfirmDiscard"
-            on-cancel="_closeConfirmDiscardOverlay">
-          <div class="header" slot="header">
-            Discard comment
-          </div>
-          <div class="main" slot="main">
-            Are you sure you want to discard this draft comment?
-          </div>
-        </gr-confirm-dialog>
-      </gr-overlay>
-    </template>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-storage id="storage"></gr-storage>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-diff-comment.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
deleted file mode 100644
index ea6e91e..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ /dev/null
@@ -1,676 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-(function() {
-  'use strict';
-
-  const STORAGE_DEBOUNCE_INTERVAL = 400;
-  const TOAST_DEBOUNCE_INTERVAL = 200;
-
-  const SAVING_MESSAGE = 'Saving';
-  const DRAFT_SINGULAR = 'draft...';
-  const DRAFT_PLURAL = 'drafts...';
-  const SAVED_MESSAGE = 'All changes saved';
-  const SAVING_PROGRESS_MESSAGE = 'Saving draft...';
-  const DiSCARDING_PROGRESS_MESSAGE = 'Discarding draft...';
-
-  const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-  const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-  const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
-  Polymer({
-    is: 'gr-diff-comment',
-
-    /**
-     * Fired when the create fix comment action is triggered.
-     *
-     * @event create-fix-comment
-     */
-
-    /**
-     * Fired when this comment is discarded.
-     *
-     * @event comment-discard
-     */
-
-    /**
-     * Fired when this comment is saved.
-     *
-     * @event comment-save
-     */
-
-    /**
-     * Fired when this comment is updated.
-     *
-     * @event comment-update
-     */
-
-    /**
-     * @event comment-mouse-over
-     */
-
-    /**
-     * @event comment-mouse-out
-     */
-
-    properties: {
-      changeNum: String,
-      /** @type {?} */
-      comment: {
-        type: Object,
-        notify: true,
-        observer: '_commentChanged',
-      },
-      isRobotComment: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      draft: {
-        type: Boolean,
-        value: false,
-        observer: '_draftChanged',
-      },
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_editingChanged',
-      },
-      hasChildren: Boolean,
-      patchNum: String,
-      showActions: Boolean,
-      _showHumanActions: Boolean,
-      _showRobotActions: Boolean,
-      collapsed: {
-        type: Boolean,
-        value: true,
-        observer: '_toggleCollapseClass',
-      },
-      /** @type {?} */
-      projectConfig: Object,
-      robotButtonDisabled: Boolean,
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-
-      _xhrPromise: Object, // Used for testing.
-      _messageText: {
-        type: String,
-        value: '',
-        observer: '_messageTextChanged',
-      },
-      commentSide: String,
-
-      resolved: Boolean,
-
-      _numPendingDraftRequests: {
-        type: Object,
-        value: {number: 0}, // Intentional to share the object across instances.
-      },
-
-      _savingMessage: String,
-
-      _enableOverlay: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Property for storing references to overlay elements. When the overlays
-       * are moved to Gerrit.getRootElement() to be shown they are no-longer
-       * children, so they can't be queried along the tree, so they are stored
-       * here.
-       */
-      _overlays: {
-        type: Object,
-        value: () => ({}),
-      },
-    },
-
-    observers: [
-      '_commentMessageChanged(comment.message)',
-      '_loadLocalDraft(changeNum, patchNum, comment)',
-      '_isRobotComment(comment)',
-      '_calculateActionstoShow(showActions, isRobotComment)',
-    ],
-
-    behaviors: [
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    keyBindings: {
-      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
-      'esc': '_handleEsc',
-    },
-
-    attached() {
-      if (this.editing) {
-        this.collapsed = false;
-      } else if (this.comment) {
-        this.collapsed = this.comment.collapsed;
-      }
-      this._getIsAdmin().then(isAdmin => {
-        this._isAdmin = isAdmin;
-      });
-    },
-
-    detached() {
-      this.cancelDebouncer('fire-update');
-      if (this.textarea) {
-        this.textarea.closeDropdown();
-      }
-    },
-
-    get textarea() {
-      return this.$$('#editTextarea');
-    },
-
-    get confirmDeleteOverlay() {
-      if (!this._overlays.confirmDelete) {
-        this._enableOverlay = true;
-        Polymer.dom.flush();
-        this._overlays.confirmDelete = this.$$('#confirmDeleteOverlay');
-      }
-      return this._overlays.confirmDelete;
-    },
-
-    get confirmDiscardOverlay() {
-      if (!this._overlays.confirmDiscard) {
-        this._enableOverlay = true;
-        Polymer.dom.flush();
-        this._overlays.confirmDiscard = this.$$('#confirmDiscardOverlay');
-      }
-      return this._overlays.confirmDiscard;
-    },
-
-    _computeShowHideText(collapsed) {
-      return collapsed ? '◀' : '▼';
-    },
-
-    _calculateActionstoShow(showActions, isRobotComment) {
-      this._showHumanActions = showActions && !isRobotComment;
-      this._showRobotActions = showActions && isRobotComment;
-    },
-
-    _isRobotComment(comment) {
-      this.isRobotComment = !!comment.robot_id;
-    },
-
-    isOnParent() {
-      return this.side === 'PARENT';
-    },
-
-    _getIsAdmin() {
-      return this.$.restAPI.getIsAdmin();
-    },
-
-    /**
-     * @param {*=} opt_comment
-     */
-    save(opt_comment) {
-      let comment = opt_comment;
-      if (!comment) {
-        comment = this.comment;
-        this.comment.message = this._messageText;
-      }
-
-      this.disabled = true;
-
-      if (!this._messageText) {
-        return this._discardDraft();
-      }
-
-      this._xhrPromise = this._saveDraft(comment).then(response => {
-        this.disabled = false;
-        if (!response.ok) { return response; }
-
-        this._eraseDraftComment();
-        return this.$.restAPI.getResponseObject(response).then(obj => {
-          const resComment = obj;
-          resComment.__draft = true;
-          // Maintain the ephemeral draft ID for identification by other
-          // elements.
-          if (this.comment.__draftID) {
-            resComment.__draftID = this.comment.__draftID;
-          }
-          resComment.__commentSide = this.commentSide;
-          this.comment = resComment;
-          this.editing = false;
-          this._fireSave();
-          return obj;
-        });
-      }).catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-      return this._xhrPromise;
-    },
-
-    _eraseDraftComment() {
-      // Prevents a race condition in which removing the draft comment occurs
-      // prior to it being saved.
-      this.cancelDebouncer('store');
-
-      this.$.storage.eraseDraftComment({
-        changeNum: this.changeNum,
-        patchNum: this._getPatchNum(),
-        path: this.comment.path,
-        line: this.comment.line,
-        range: this.comment.range,
-      });
-    },
-
-    _commentChanged(comment) {
-      this.editing = !!comment.__editing;
-      this.resolved = !comment.unresolved;
-      if (this.editing) { // It's a new draft/reply, notify.
-        this._fireUpdate();
-      }
-    },
-
-    /**
-     * @param {!Object=} opt_mixin
-     *
-     * @return {!Object}
-     */
-    _getEventPayload(opt_mixin) {
-      return Object.assign({}, opt_mixin, {
-        comment: this.comment,
-        patchNum: this.patchNum,
-      });
-    },
-
-    _fireSave() {
-      this.fire('comment-save', this._getEventPayload());
-    },
-
-    _fireUpdate() {
-      this.debounce('fire-update', () => {
-        this.fire('comment-update', this._getEventPayload());
-      });
-    },
-
-    _draftChanged(draft) {
-      this.$.container.classList.toggle('draft', draft);
-    },
-
-    _editingChanged(editing, previousValue) {
-      this.$.container.classList.toggle('editing', editing);
-      if (this.comment && this.comment.id) {
-        this.$$('.cancel').hidden = !editing;
-      }
-      if (this.comment) {
-        this.comment.__editing = this.editing;
-      }
-      if (editing != !!previousValue) {
-        // To prevent event firing on comment creation.
-        this._fireUpdate();
-      }
-      if (editing) {
-        this.async(() => {
-          Polymer.dom.flush();
-          this.textarea.putCursorAtEnd();
-        }, 1);
-      }
-    },
-
-    _computeLinkToComment(comment) {
-      return '#' + comment.line;
-    },
-
-    _computeDeleteButtonClass(isAdmin, draft) {
-      return isAdmin && !draft ? 'showDeleteButtons' : '';
-    },
-
-    _computeSaveDisabled(draft, comment, resolved) {
-      // If resolved state has changed and a msg exists, save should be enabled.
-      if (comment.unresolved === resolved && draft) { return false; }
-      return !draft || draft.trim() === '';
-    },
-
-    _handleSaveKey(e) {
-      if (!this._computeSaveDisabled(this._messageText, this.comment,
-          this.resolved)) {
-        e.preventDefault();
-        this._handleSave(e);
-      }
-    },
-
-    _handleEsc(e) {
-      if (!this._messageText.length) {
-        e.preventDefault();
-        this._handleCancel(e);
-      }
-    },
-
-    _handleToggleCollapsed() {
-      this.collapsed = !this.collapsed;
-    },
-
-    _toggleCollapseClass(collapsed) {
-      if (collapsed) {
-        this.$.container.classList.add('collapsed');
-      } else {
-        this.$.container.classList.remove('collapsed');
-      }
-    },
-
-    _commentMessageChanged(message) {
-      this._messageText = message || '';
-    },
-
-    _messageTextChanged(newValue, oldValue) {
-      if (!this.comment || (this.comment && this.comment.id)) { return; }
-
-      this.debounce('store', () => {
-        const message = this._messageText;
-        const commentLocation = {
-          changeNum: this.changeNum,
-          patchNum: this._getPatchNum(),
-          path: this.comment.path,
-          line: this.comment.line,
-          range: this.comment.range,
-        };
-
-        if ((!this._messageText || !this._messageText.length) && oldValue) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.$.storage.eraseDraftComment(commentLocation);
-        } else {
-          this.$.storage.setDraftComment(commentLocation, message);
-        }
-      }, STORAGE_DEBOUNCE_INTERVAL);
-    },
-
-    _handleLinkTap(e) {
-      e.preventDefault();
-      const hash = this._computeLinkToComment(this.comment);
-      // Don't add the hash to the window history if it's already there.
-      // Otherwise you mess up expected back button behavior.
-      if (window.location.hash == hash) { return; }
-      // Change the URL but don’t trigger a nav event. Otherwise it will
-      // reload the page.
-      page.show(window.location.pathname + hash, null, false);
-    },
-
-    _handleReply(e) {
-      e.preventDefault();
-      this.fire('create-reply-comment', this._getEventPayload(),
-          {bubbles: false});
-    },
-
-    _handleQuote(e) {
-      e.preventDefault();
-      this.fire('create-reply-comment', this._getEventPayload({quote: true}),
-          {bubbles: false});
-    },
-
-    _handleFix(e) {
-      e.preventDefault();
-      this.fire('create-fix-comment', this._getEventPayload({quote: true}),
-          {bubbles: false});
-    },
-
-    _handleAck(e) {
-      e.preventDefault();
-      this.fire('create-ack-comment', this._getEventPayload(),
-          {bubbles: false});
-    },
-
-    _handleDone(e) {
-      e.preventDefault();
-      this.fire('create-done-comment', this._getEventPayload(),
-          {bubbles: false});
-    },
-
-    _handleEdit(e) {
-      e.preventDefault();
-      this._messageText = this.comment.message;
-      this.editing = true;
-    },
-
-    _handleSave(e) {
-      e.preventDefault();
-
-      // Ignore saves started while already saving.
-      if (this.disabled) { return; }
-      const timingLabel = this.comment.id ?
-          REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
-      const timer = this.$.reporting.getTimer(timingLabel);
-      this.set('comment.__editing', false);
-      return this.save().then(() => { timer.end(); });
-    },
-
-    _handleCancel(e) {
-      e.preventDefault();
-
-      if (!this.comment.message ||
-          this.comment.message.trim().length === 0 ||
-          !this.comment.id) {
-        this._fireDiscard();
-        return;
-      }
-      this._messageText = this.comment.message;
-      this.editing = false;
-    },
-
-    _fireDiscard() {
-      this.cancelDebouncer('fire-update');
-      this.fire('comment-discard', this._getEventPayload());
-    },
-
-    _handleDiscard(e) {
-      e.preventDefault();
-
-      if (!this._messageText) {
-        this._discardDraft();
-        return;
-      }
-      this._openOverlay(this.confirmDiscardOverlay);
-    },
-
-    _handleConfirmDiscard(e) {
-      e.preventDefault();
-      const timer = this.$.reporting.getTimer(REPORT_DISCARD_DRAFT);
-      this._closeConfirmDiscardOverlay();
-      return this._discardDraft().then(() => { timer.end(); });
-    },
-
-    _discardDraft() {
-      if (!this.comment.__draft) {
-        throw Error('Cannot discard a non-draft comment.');
-      }
-      this._savingMessage = DiSCARDING_PROGRESS_MESSAGE;
-      this.editing = false;
-      this.disabled = true;
-      this._eraseDraftComment();
-
-      if (!this.comment.id) {
-        this.disabled = false;
-        this._fireDiscard();
-        return;
-      }
-
-      this._xhrPromise = this._deleteDraft(this.comment).then(response => {
-        this.disabled = false;
-        if (!response.ok) { return response; }
-
-        this._fireDiscard();
-      }).catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-      return this._xhrPromise;
-    },
-
-    _closeConfirmDiscardOverlay() {
-      this._closeOverlay(this.confirmDiscardOverlay);
-    },
-
-    _getSavingMessage(numPending) {
-      if (numPending === 0) { return SAVED_MESSAGE; }
-      return [
-        SAVING_MESSAGE,
-        numPending,
-        numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
-      ].join(' ');
-    },
-
-    _showStartRequest() {
-      const numPending = ++this._numPendingDraftRequests.number;
-      this._updateRequestToast(numPending);
-    },
-
-    _showEndRequest() {
-      const numPending = --this._numPendingDraftRequests.number;
-      this._updateRequestToast(numPending);
-    },
-
-    _handleFailedDraftRequest() {
-      this._numPendingDraftRequests.number--;
-
-      // Cancel the debouncer so that error toasts from the error-manager will
-      // not be overridden.
-      this.cancelDebouncer('draft-toast');
-    },
-
-    _updateRequestToast(numPending) {
-      const message = this._getSavingMessage(numPending);
-      this.debounce('draft-toast', () => {
-        // Note: the event is fired on the body rather than this element because
-        // this element may not be attached by the time this executes, in which
-        // case the event would not bubble.
-        document.body.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message}, bubbles: true}));
-      }, TOAST_DEBOUNCE_INTERVAL);
-    },
-
-    _saveDraft(draft) {
-      this._savingMessage = SAVING_PROGRESS_MESSAGE;
-      this._showStartRequest();
-      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
-          .then(result => {
-            if (result.ok) {
-              this._showEndRequest();
-            } else {
-              this._handleFailedDraftRequest();
-            }
-            return result;
-          });
-    },
-
-    _deleteDraft(draft) {
-      this._showStartRequest();
-      return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
-          draft).then(result => {
-            if (result.ok) {
-              this._showEndRequest();
-            } else {
-              this._handleFailedDraftRequest();
-            }
-            return result;
-          });
-    },
-
-    _getPatchNum() {
-      return this.isOnParent() ? 'PARENT' : this.patchNum;
-    },
-
-    _loadLocalDraft(changeNum, patchNum, comment) {
-      // Only apply local drafts to comments that haven't been saved
-      // remotely, and haven't been given a default message already.
-      //
-      // Don't get local draft if there is another comment that is currently
-      // in an editing state.
-      if (!comment || comment.id || comment.message || comment.__otherEditing) {
-        delete comment.__otherEditing;
-        return;
-      }
-
-      const draft = this.$.storage.getDraftComment({
-        changeNum,
-        patchNum: this._getPatchNum(),
-        path: comment.path,
-        line: comment.line,
-        range: comment.range,
-      });
-
-      if (draft) {
-        this.set('comment.message', draft.message);
-      }
-    },
-
-    _handleMouseEnter(e) {
-      this.fire('comment-mouse-over', this._getEventPayload());
-    },
-
-    _handleMouseLeave(e) {
-      this.fire('comment-mouse-out', this._getEventPayload());
-    },
-
-    _handleToggleResolved() {
-      this.resolved = !this.resolved;
-      // Modify payload instead of this.comment, as this.comment is passed from
-      // the parent by ref.
-      const payload = this._getEventPayload();
-      payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
-      this.fire('comment-update', payload);
-      if (!this.editing) {
-        // Save the resolved state immediately.
-        this.save(payload.comment);
-      }
-    },
-
-    _handleCommentDelete() {
-      this._openOverlay(this.confirmDeleteOverlay);
-    },
-
-    _handleCancelDeleteComment() {
-      this._closeOverlay(this.confirmDeleteOverlay);
-    },
-
-    _openOverlay(overlay) {
-      Polymer.dom(Gerrit.getRootElement()).appendChild(overlay);
-      this.async(() => {
-        overlay.open();
-      }, 1);
-    },
-
-    _closeOverlay(overlay) {
-      Polymer.dom(Gerrit.getRootElement()).removeChild(overlay);
-      overlay.close();
-    },
-
-    _handleConfirmDeleteComment() {
-      const dialog =
-          this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
-      this.$.restAPI.deleteComment(
-          this.changeNum, this.patchNum, this.comment.id, dialog.message)
-          .then(newComment => {
-            this._handleCancelDeleteComment();
-            this.comment = newComment;
-          });
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
deleted file mode 100644
index c2daef4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ /dev/null
@@ -1,847 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-diff-comment</title>
-
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../scripts/util.js"></script>
-
-<link rel="import" href="gr-diff-comment.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-comment></gr-diff-comment>
-  </template>
-</test-fixture>
-
-<test-fixture id="draft">
-  <template>
-    <gr-diff-comment draft="true"></gr-diff-comment>
-  </template>
-</test-fixture>
-
-<script>
-
-  function isVisible(el) {
-    assert.ok(el);
-    return getComputedStyle(el).getPropertyValue('display') !== 'none';
-  }
-
-  suite('gr-diff-comment tests', () => {
-    let element;
-    let sandbox;
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-      });
-      element = fixture('basic');
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
-      };
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('collapsible comments', () => {
-      // When a comment (not draft) is loaded, it should be collapsed
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.$$('.actions')),
-          'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-
-      // The header middle content is only visible when comments are collapsed.
-      // It shows the message in a condensed way, and limits to a single line.
-      assert.isTrue(isVisible(element.$$('.collapsedContent')),
-          'header middle content is visible');
-
-      // When the header row is clicked, the comment should expand
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is not visible');
-    });
-
-    test('clicking on date link does not trigger nav', () => {
-      const showStub = sinon.stub(page, 'show');
-      const dateEl = element.$$('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-      const dest = window.location.pathname + '#5';
-      assert(showStub.lastCall.calledWithExactly(dest, null, false),
-          'Should navigate to ' + dest + ' without triggering nav');
-      showStub.restore();
-    });
-
-    test('message is not retrieved from storage when other edits', done => {
-      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-        __otherEditing: true,
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isFalse(storageStub.called);
-        done();
-      });
-    });
-
-    test('message is retrieved from storage when no other edits', done => {
-      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isTrue(storageStub.called);
-        done();
-      });
-    });
-
-    test('_getPatchNum', () => {
-      element.side = 'PARENT';
-      element.patchNum = 1;
-      assert.equal(element._getPatchNum(), 'PARENT');
-      element.side = 'REVISION';
-      assert.equal(element._getPatchNum(), 1);
-    });
-
-    test('comment expand and collapse', () => {
-      element.collapsed = true;
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.$$('.actions')),
-          'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.$$('.collapsedContent')),
-          'header middle content is visible');
-
-      element.collapsed = false;
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is is not visible');
-    });
-
-    suite('while editing', () => {
-      setup(() => {
-        element.editing = true;
-        element._messageText = 'test';
-        sandbox.stub(element, '_handleCancel');
-        sandbox.stub(element, '_handleSave');
-        flushAsynchronousOperations();
-      });
-
-      suite('when text is empty', () => {
-        setup(() => {
-          element._messageText = '';
-          element.comment = {};
-        });
-
-        test('esc closes comment when text is empty', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 27); // esc
-          assert.isTrue(element._handleCancel.called);
-        });
-
-        test('ctrl+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'ctrl'); // ctrl + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('meta+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'meta'); // meta + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('ctrl+s does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 83, 'ctrl'); // ctrl + s
-          assert.isFalse(element._handleSave.called);
-        });
-      });
-
-      test('esc does not close comment that has content', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 27); // esc
-        assert.isFalse(element._handleCancel.called);
-      });
-
-      test('ctrl+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'ctrl'); // ctrl + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('meta+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'meta'); // meta + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('ctrl+s saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 83, 'ctrl'); // ctrl + s
-        assert.isTrue(element._handleSave.called);
-      });
-    });
-    test('delete comment button for non-admins is hidden', () => {
-      element._isAdmin = false;
-      assert.isFalse(element.$$('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment button for admins with draft is hidden', () => {
-      element._isAdmin = false;
-      element.draft = true;
-      assert.isFalse(element.$$('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment', done => {
-      sandbox.stub(
-          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
-      sandbox.spy(element.confirmDeleteOverlay, 'open');
-      element.changeNum = 42;
-      element.patchNum = 0xDEADBEEF;
-      element._isAdmin = true;
-      assert.isTrue(element.$$('.action.delete')
-          .classList.contains('showDeleteButtons'));
-      MockInteractions.tap(element.$$('.action.delete'));
-      flush(() => {
-        element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
-          const dialog =
-              this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
-          dialog.message = 'removal reason';
-          element._handleConfirmDeleteComment();
-          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
-              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
-          done();
-        });
-      });
-    });
-
-    suite('draft update reporting', () => {
-      let endStub;
-      let getTimerStub;
-      let mockEvent;
-
-      setup(() => {
-        mockEvent = {preventDefault() {}};
-        sandbox.stub(element, 'save')
-            .returns(Promise.resolve({}));
-        sandbox.stub(element, '_discardDraft')
-            .returns(Promise.resolve({}));
-        endStub = sinon.stub();
-        getTimerStub = sandbox.stub(element.$.reporting, 'getTimer')
-            .returns({end: endStub});
-      });
-
-      test('create', () => {
-        element.comment = {};
-        return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-        });
-      });
-
-      test('update', () => {
-        element.comment = {id: 'abc_123'};
-        return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-        });
-      });
-
-      test('discard', () => {
-        element.comment = {id: 'abc_123'};
-        sandbox.stub(element, '_closeConfirmDiscardOverlay');
-        return element._handleConfirmDiscard(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-        });
-      });
-    });
-  });
-
-  suite('gr-diff-comment draft tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-        saveDiffDraft() {
-          return Promise.resolve({
-            ok: true,
-            text() {
-              return Promise.resolve(
-                  ')]}\'\n{' +
-                  '"id": "baf0414d_40572e03",' +
-                  '"path": "/path/to/file",' +
-                  '"line": 5,' +
-                  '"updated": "2015-12-08 21:52:36.177000000",' +
-                  '"message": "saved!"' +
-                '}'
-              );
-            },
-          });
-        },
-        removeChangeReviewer() {
-          return Promise.resolve({ok: true});
-        },
-      });
-      stub('gr-storage', {
-        getDraftComment() { return null; },
-      });
-      element = fixture('draft');
-      element.changeNum = 42;
-      element.patchNum = 1;
-      element.editing = false;
-      element.comment = {
-        __commentSide: 'right',
-        __draft: true,
-        __draftID: 'temp_draft_id',
-        path: '/path/to/file',
-        line: 5,
-      };
-      element.commentSide = 'right';
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('button visibility states', () => {
-      element.showActions = false;
-      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
-
-      element.showActions = true;
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
-
-      element.draft = true;
-      assert.isTrue(isVisible(element.$$('.edit')), 'edit is visible');
-      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
-      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
-      assert.isTrue(isVisible(element.$$('.resolve')), 'resolve is visible');
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
-
-      element.editing = true;
-      flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.$$('.discard')), 'discard not visible');
-      assert.isTrue(isVisible(element.$$('.save')), 'save is visible');
-      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
-      assert.isTrue(isVisible(element.$$('.resolve')), 'resolve is visible');
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
-
-      element.draft = false;
-      element.editing = false;
-      flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.$$('.discard')),
-          'discard is not visible');
-      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
-
-      element.comment.id = 'foo';
-      element.draft = true;
-      element.editing = true;
-      flushAsynchronousOperations();
-      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
-
-      element.isRobotComment = true;
-      element.draft = true;
-      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
-
-      // It is not expected to see Robot comment drafts, but if they appear,
-      // they will behave the same as non-drafts.
-      element.draft = false;
-      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
-
-      // A robot comment with run ID should display plain text.
-      element.set(['comment', 'robot_run_id'], 'text');
-      element.editing = false;
-      element.collapsed = false;
-      flushAsynchronousOperations();
-      assert.isNotOk(element.$$('.robotRun.link'));
-      assert.notEqual(getComputedStyle(element.$$('.robotRun.text')).display,
-          'none');
-
-      // A robot comment with run ID and url should display a link.
-      element.set(['comment', 'url'], '/path/to/run');
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(element.$$('.robotRun.link')).display,
-          'none');
-      assert.equal(getComputedStyle(element.$$('.robotRun.text')).display,
-          'none');
-    });
-
-    test('collapsible drafts', () => {
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.$$('.actions')),
-          'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.$$('.collapsedContent')),
-          'header middle content is visible');
-
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is is not visible');
-
-      // When the edit button is pressed, should still see the actions
-      // and also textarea
-      MockInteractions.tap(element.$$('.edit'));
-      flushAsynchronousOperations();
-      assert.isFalse(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is not visible');
-
-      // When toggle again, everything should be hidden except for textarea
-      // and header middle content should be visible
-      MockInteractions.tap(element.$.header);
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.$$('.actions')),
-          'actions are not visible');
-      assert.isFalse(isVisible(element.$$('gr-textarea')),
-          'textarea is not visible');
-      assert.isTrue(isVisible(element.$$('.collapsedContent')),
-          'header middle content is visible');
-
-      // When toggle again, textarea should remain open in the state it was
-      // before
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is not visible');
-    });
-
-    test('draft creation/cancellation', done => {
-      assert.isFalse(element.editing);
-      MockInteractions.tap(element.$$('.edit'));
-      assert.isTrue(element.editing);
-
-      element._messageText = '';
-      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
-
-      // Save should be disabled on an empty message.
-      let disabled = element.$$('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-      element._messageText = '     ';
-      disabled = element.$$('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-
-      const updateStub = sinon.stub();
-      element.addEventListener('comment-update', updateStub);
-
-      let numDiscardEvents = 0;
-      element.addEventListener('comment-discard', e => {
-        numDiscardEvents++;
-        assert.isFalse(eraseMessageDraftSpy.called);
-        if (numDiscardEvents === 2) {
-          assert.isFalse(updateStub.called);
-          done();
-        }
-      });
-      MockInteractions.tap(element.$$('.cancel'));
-      element.flushDebouncer('fire-update');
-      element._messageText = '';
-      flushAsynchronousOperations();
-      MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
-    });
-
-    test('draft discard removes message from storage', done => {
-      element._messageText = '';
-      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
-      sandbox.stub(element, '_closeConfirmDiscardOverlay');
-
-      element.addEventListener('comment-discard', e => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        done();
-      });
-      element._handleConfirmDiscard({preventDefault: sinon.stub()});
-    });
-
-    test('storage is cleared only after save success', () => {
-      element._messageText = 'test';
-      const eraseStub = sandbox.stub(element, '_eraseDraftComment');
-      sandbox.stub(element.$.restAPI, 'getResponseObject')
-          .returns(Promise.resolve({}));
-
-      sandbox.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
-
-      const savePromise = element.save();
-      assert.isFalse(eraseStub.called);
-      return savePromise.then(() => {
-        assert.isFalse(eraseStub.called);
-
-        element._saveDraft.restore();
-        sandbox.stub(element, '_saveDraft')
-            .returns(Promise.resolve({ok: true}));
-        return element.save().then(() => {
-          assert.isTrue(eraseStub.called);
-        });
-      });
-    });
-
-    test('_computeSaveDisabled', () => {
-      const comment = {unresolved: true};
-      const msgComment = {message: 'test', unresolved: true};
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-      assert.equal(element._computeSaveDisabled('test', comment, false), false);
-      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-      assert.equal(
-          element._computeSaveDisabled('test', msgComment, false), false);
-      assert.equal(
-          element._computeSaveDisabled('test2', msgComment, false), false);
-      assert.equal(element._computeSaveDisabled('test', comment, true), false);
-      assert.equal(element._computeSaveDisabled('', comment, true), true);
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-    });
-
-    suite('confirm discard', () => {
-      let discardStub;
-      let overlayStub;
-      let mockEvent;
-
-      setup(() => {
-        discardStub = sandbox.stub(element, '_discardDraft');
-        overlayStub = sandbox.stub(element, '_openOverlay');
-        mockEvent = {preventDefault: sinon.stub()};
-      });
-
-      test('confirms discard of comments with message text', () => {
-        element._messageText = 'test';
-        element._handleDiscard(mockEvent);
-        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
-        assert.isFalse(discardStub.called);
-      });
-
-      test('no confirmation for comments without message text', () => {
-        element._messageText = '';
-        element._handleDiscard(mockEvent);
-        assert.isFalse(overlayStub.called);
-        assert.isTrue(discardStub.calledOnce);
-      });
-    });
-
-    test('ctrl+s saves comment', done => {
-      const stub = sinon.stub(element, 'save', () => {
-        assert.isTrue(stub.called);
-        stub.restore();
-        done();
-        return Promise.resolve();
-      });
-      element._messageText = 'is that the horse from horsing around??';
-      element.editing = true;
-      flushAsynchronousOperations();
-      MockInteractions.pressAndReleaseKeyOn(
-          element.textarea.$.textarea.textarea,
-          83, 'ctrl'); // 'ctrl + s'
-    });
-
-    test('draft saving/editing', done => {
-      const fireStub = sinon.stub(element, 'fire');
-      const cancelDebounce = sandbox.stub(element, 'cancelDebouncer');
-
-      element.draft = true;
-      MockInteractions.tap(element.$$('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
-      assert(fireStub.calledWith('comment-update'),
-          'comment-update should be sent');
-      assert.isTrue(fireStub.calledOnce);
-
-      element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
-      assert.isTrue(fireStub.calledOnce,
-          'No events should fire for text editing');
-
-      MockInteractions.tap(element.$$('.save'));
-
-      assert.isTrue(element.disabled,
-          'Element should be disabled when creating draft.');
-
-      element._xhrPromise.then(draft => {
-        assert(fireStub.calledWith('comment-save'),
-            'comment-save should be sent');
-        assert(cancelDebounce.calledWith('store'));
-
-        assert.deepEqual(fireStub.lastCall.args[1], {
-          comment: {
-            __commentSide: 'right',
-            __draft: true,
-            __draftID: 'temp_draft_id',
-            __editing: false,
-            id: 'baf0414d_40572e03',
-            line: 5,
-            message: 'saved!',
-            path: '/path/to/file',
-            updated: '2015-12-08 21:52:36.177000000',
-          },
-          patchNum: 1,
-        });
-        assert.isFalse(element.disabled,
-            'Element should be enabled when done creating draft.');
-        assert.equal(draft.message, 'saved!');
-        assert.isFalse(element.editing);
-      }).then(() => {
-        MockInteractions.tap(element.$$('.edit'));
-        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
-            'a world where humans are killed on sight.';
-        MockInteractions.tap(element.$$('.save'));
-        assert.isTrue(element.disabled,
-            'Element should be disabled when updating draft.');
-
-        element._xhrPromise.then(draft => {
-          assert.isFalse(element.disabled,
-              'Element should be enabled when done updating draft.');
-          assert.equal(draft.message, 'saved!');
-          assert.isFalse(element.editing);
-          fireStub.restore();
-          done();
-        });
-      });
-    });
-
-    test('draft prevent save when disabled', () => {
-      const saveStub = sandbox.stub(element, 'save').returns(Promise.resolve());
-      element.showActions = true;
-      element.draft = true;
-      MockInteractions.tap(element.$.header);
-      MockInteractions.tap(element.$$('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
-
-      element.disabled = true;
-      MockInteractions.tap(element.$$('.save'));
-      assert.isFalse(saveStub.called);
-
-      element.disabled = false;
-      MockInteractions.tap(element.$$('.save'));
-      assert.isTrue(saveStub.calledOnce);
-    });
-
-    test('clicking on date link does not trigger nav', () => {
-      const showStub = sinon.stub(page, 'show');
-      const dateEl = element.$$('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-      const dest = window.location.pathname + '#5';
-      assert(showStub.lastCall.calledWithExactly(dest, null, false),
-          'Should navigate to ' + dest + ' without triggering nav');
-      showStub.restore();
-    });
-
-    test('proper event fires on resolve, comment is not saved', done => {
-      const save = sandbox.stub(element, 'save');
-      element.addEventListener('comment-update', e => {
-        assert.isTrue(e.detail.comment.unresolved);
-        assert.isFalse(save.called);
-        done();
-      });
-      MockInteractions.tap(element.$$('.resolve input'));
-    });
-
-    test('resolved comment state indicated by checkbox', () => {
-      sandbox.stub(element, 'save');
-      element.comment = {unresolved: false};
-      assert.isTrue(element.$$('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.$$('.resolve input').checked);
-    });
-
-    test('resolved checkbox saves with tap when !editing', () => {
-      element.editing = false;
-      const save = sandbox.stub(element, 'save');
-
-      element.comment = {unresolved: false};
-      assert.isTrue(element.$$('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.$$('.resolve input').checked);
-      assert.isFalse(save.called);
-      MockInteractions.tap(element.$.resolvedCheckbox);
-      assert.isTrue(element.$$('.resolve input').checked);
-      assert.isTrue(save.called);
-    });
-
-    suite('draft saving messages', () => {
-      test('_getSavingMessage', () => {
-        assert.equal(element._getSavingMessage(0), 'All changes saved');
-        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-      });
-
-      test('_show{Start,End}Request', () => {
-        const updateStub = sandbox.stub(element, '_updateRequestToast');
-        element._numPendingDraftRequests.number = 1;
-
-        element._showStartRequest();
-        assert.isTrue(updateStub.calledOnce);
-        assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDraftRequests.number, 2);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledTwice);
-        assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDraftRequests.number, 1);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledThrice);
-        assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDraftRequests.number, 0);
-      });
-    });
-
-    suite('saving progress indicators', () => {
-      setup(() => {
-        sandbox.stub(element, '_deleteDraft').returns(Promise.resolve());
-        element._savingMessage = '';
-      });
-
-      test('saving', () => {
-        element._saveDraft({});
-        assert.equal(element._savingMessage, 'Saving draft...');
-      });
-
-      test('discarding', () => {
-        element._discardDraft();
-        assert.equal(element._savingMessage, 'Discarding draft...');
-      });
-    });
-
-    test('cancelling an unsaved draft discards, persists in storage', () => {
-      const discardSpy = sandbox.spy(element, '_fireDiscard');
-      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
-      const eraseStub = sandbox.stub(element.$.storage, 'eraseDraftComment');
-      element._messageText = 'test text';
-      flushAsynchronousOperations();
-      element.flushDebouncer('store');
-
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'test text');
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-      assert.isFalse(eraseStub.called);
-    });
-
-    test('cancelling edit on a saved draft does not store', () => {
-      element.comment.id = 'foo';
-      const discardSpy = sandbox.spy(element, '_fireDiscard');
-      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
-      element._messageText = 'test text';
-      flushAsynchronousOperations();
-      element.flushDebouncer('store');
-
-      assert.isFalse(storeStub.called);
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-    });
-
-    test('deleting text from saved draft and saving deletes the draft', () => {
-      element.comment = {id: 'foo', message: 'test'};
-      element._messageText = '';
-      const discardStub = sandbox.stub(element, '_discardDraft');
-
-      element.save();
-      assert.isTrue(discardStub.called);
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
index c24574e..99d0498 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 
 <dom-module id="gr-diff-cursor">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index aee7a62..6dbc399 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -37,6 +37,7 @@
 
   Polymer({
     is: 'gr-diff-cursor',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /**
@@ -135,11 +136,11 @@
       }
     },
 
-    moveToNextChunk() {
+    moveToNextChunk(opt_clipToTop) {
       this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
           target => {
             return target.parentNode.scrollHeight;
-          });
+          }, opt_clipToTop);
       this._fixSide();
     },
 
@@ -199,7 +200,7 @@
 
     moveToFirstChunk() {
       this.$.cursorManager.moveToStart();
-      this.moveToNextChunk();
+      this.moveToNextChunk(true);
     },
 
     reInitCursor() {
@@ -293,7 +294,7 @@
     },
 
     _rowHasThread(row) {
-      return row.querySelector('gr-diff-comment-thread');
+      return row.querySelector('.comment-thread');
     },
 
     /**
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..d36a72d4 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-cursor</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
@@ -35,6 +37,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 +51,22 @@
     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.comments = {left: [], right: []};
-      diffElement.$.restAPI.getDiffPreferences().then(prefs => {
-        diffElement.prefs = prefs;
-      });
-
-      sandbox.stub(diffElement, '_getDiff', () => {
-        return Promise.resolve(mockDiffResponse.diffResponse);
-      });
-
+      diffElement.loggedIn = false;
+      diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
+      diffElement.comments = {
+        left: [],
+        right: [],
+        meta: {patchRange: undefined},
+      };
       const setupDone = () => {
         cursorElement._updateStops();
         cursorElement.moveToFirstChunk();
@@ -77,7 +75,10 @@
       };
       diffElement.addEventListener('render', setupDone);
 
-      diffElement.reload();
+      restAPI.getDiffPreferences().then(prefs => {
+        diffElement.prefs = prefs;
+        diffElement.diff = mockDiffResponse.diffResponse;
+      });
     });
 
     teardown(() => sandbox.restore());
@@ -219,7 +220,7 @@
         done();
       }
       diffElement.addEventListener('render', renderHandler);
-      diffElement.reload();
+      diffElement._diffChanged(mockDiffResponse.diffResponse);
     });
 
     test('initialLineNumber enabled', done => {
@@ -239,7 +240,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-annotation_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
index 652aa4c..86d5e45 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="gr-annotation.js"></script>
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
index c912a16..612aab6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../gr-selection-action-box/gr-selection-action-box.html">
 <link rel="import" href="../../../styles/shared-styles.html">
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..ad9e99b 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
@@ -19,9 +19,14 @@
 
   Polymer({
     is: 'gr-diff-highlight',
+    _legacyUndefinedCheck: true,
 
     properties: {
-      comments: Object,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: {
+        type: Array,
+        notify: true,
+      },
       loggedIn: Boolean,
       /**
        * querySelector can return null, so needs to be nullable.
@@ -29,19 +34,14 @@
        * @type {?HTMLElement}
        * */
       _cachedDiffBuilder: Object,
-      isAttached: Boolean,
     },
 
     listeners: {
-      'comment-mouse-out': '_handleCommentMouseOut',
-      'comment-mouse-over': '_handleCommentMouseOver',
-      'create-comment': '_createComment',
+      'comment-thread-mouseleave': '_handleCommentThreadMouseleave',
+      'comment-thread-mouseenter': '_handleCommentThreadMouseenter',
+      'create-range-comment': '_createRangeComment',
     },
 
-    observers: [
-      '_enableSelectionObserver(loggedIn, isAttached)',
-    ],
-
     get diffBuilder() {
       if (!this._cachedDiffBuilder) {
         this._cachedDiffBuilder =
@@ -50,56 +50,91 @@
       return this._cachedDiffBuilder;
     },
 
-    _enableSelectionObserver(loggedIn, isAttached) {
-      if (loggedIn && isAttached) {
-        this.listen(document, 'selectionchange', '_handleSelectionChange');
-      } else {
-        this.unlisten(document, 'selectionchange', '_handleSelectionChange');
-      }
-    },
 
     isRangeSelected() {
       return !!this.$$('gr-selection-action-box');
     },
 
-    _handleSelectionChange() {
-      // Can't use up or down events to handle selection started and/or ended in
-      // in comment threads or outside of diff.
-      // Debounce removeActionBox to give it a chance to react to click/tap.
-      this._removeActionBoxDebounced();
-      this.debounce('selectionChange', this._handleSelection, 200);
+    /**
+     * Determines side/line/range for a DOM selection and shows a tooltip.
+     *
+     * With native shadow DOM, gr-diff-highlight cannot access a selection that
+     * references the DOM elements making up the diff because they are in the
+     * shadow DOM the gr-diff element. For this reason, we listen to the
+     * selectionchange event and retrieve the selection in gr-diff, and then
+     * call this method to process the Selection.
+     *
+     * @param {Selection} selection A DOM Selection living in the shadow DOM of
+     *     the diff element.
+     * @param {boolean} isMouseUp If true, this is called due to a mouseup
+     *     event, in which case we might want to immediately create a comment,
+     *     because isMouseUp === true combined with an existing selection must
+     *     mean that this is the end of a double-click.
+     */
+    handleSelectionChange(selection, isMouseUp) {
+      // Debounce is not just nice for waiting until the selection has settled,
+      // it is also vital for being able to click on the action box before it is
+      // removed.
+      // If you wait longer than 50 ms, then you don't properly catch a very
+      // quick 'c' press after the selection change. If you wait less than 10
+      // ms, then you will have about 50 _handleSelection calls when doing a
+      // simple drag for select.
+      this.debounce(
+          'selectionChange', () => this._handleSelection(selection, isMouseUp),
+          10);
     },
 
-    _handleCommentMouseOver(e) {
-      const comment = e.detail.comment;
-      if (!comment.range) { return; }
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const index = this._indexOfComment(side, comment);
+    _getThreadEl(e) {
+      const path = Polymer.dom(e).path || [];
+      for (const pathEl of path) {
+        if (pathEl.classList.contains('comment-thread')) return pathEl;
+      }
+      return null;
+    },
+
+    _handleCommentThreadMouseenter(e) {
+      const threadEl = this._getThreadEl(e);
+      const index = this._indexForThreadEl(threadEl);
+
       if (index !== undefined) {
-        this.set(['comments', side, index, '__hovering'], true);
+        this.set(['commentRanges', index, 'hovering'], true);
       }
     },
 
-    _handleCommentMouseOut(e) {
-      const comment = e.detail.comment;
-      if (!comment.range) { return; }
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const index = this._indexOfComment(side, comment);
+    _handleCommentThreadMouseleave(e) {
+      const threadEl = this._getThreadEl(e);
+      const index = this._indexForThreadEl(threadEl);
+
       if (index !== undefined) {
-        this.set(['comments', side, index, '__hovering'], false);
+        this.set(['commentRanges', index, 'hovering'], false);
       }
     },
 
-    _indexOfComment(side, comment) {
-      const idProp = comment.id ? 'id' : '__draftID';
-      for (let i = 0; i < this.comments[side].length; i++) {
-        if (comment[idProp] &&
-            this.comments[side][i][idProp] === comment[idProp]) {
-          return i;
+    _indexForThreadEl(threadEl) {
+      const side = threadEl.getAttribute('comment-side');
+      const range = JSON.parse(threadEl.getAttribute('range'));
+
+      if (!range) return undefined;
+
+      return this._indexOfCommentRange(side, range);
+    },
+
+    _indexOfCommentRange(side, range) {
+      function rangesEqual(a, b) {
+        if (!a && !b) {
+          return true;
         }
+        if (!a || !b) {
+          return false;
+        }
+        return a.start_line === b.start_line &&
+            a.start_character === b.start_character &&
+            a.end_line === b.end_line &&
+            a.end_character === b.end_character;
       }
+
+      return this.commentRanges.findIndex(commentRange =>
+          commentRange.side === side && rangesEqual(commentRange.range, range));
     },
 
     /**
@@ -107,6 +142,7 @@
      * Merges multiple ranges, accounts for triple click, accounts for
      * syntax highligh, convert native DOM Range objects to Gerrit concepts
      * (line, side, etc).
+     * @param {Selection} selection
      * @return {({
      *   start: {
      *     node: Node,
@@ -122,8 +158,7 @@
      *   }
      * })|null|!Object}
      */
-    _getNormalizedRange() {
-      const selection = window.getSelection();
+    _getNormalizedRange(selection) {
       const rangeCount = selection.rangeCount;
       if (rangeCount === 0) {
         return null;
@@ -156,11 +191,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 +206,19 @@
       }
       const start = range.start;
       const end = range.end;
-      const endsAtOtherSideLineNum =
+      // Happens when triple click in side-by-side mode with other side empty.
+      const endsAtOtherEmptySide = !end &&
           domRange.endOffset === 0 &&
           domRange.endContainer.nodeName === 'TD' &&
           (domRange.endContainer.classList.contains('left') ||
-              domRange.endContainer.classList.contains('right'));
-      const endsOnOtherSideStart = endsAtOtherSideLineNum ||
-          end &&
+           domRange.endContainer.classList.contains('right'));
+      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 || endsAtOtherEmptySide)) {
         // Move the selection to the end of the previous line.
         range.end = {
           node: start.node,
@@ -237,7 +270,7 @@
         node = contentText;
         column = 0;
       } else {
-        const thread = contentTd.querySelector('gr-diff-comment-thread');
+        const thread = contentTd.querySelector('.comment-thread');
         if (thread && thread.contains(node)) {
           column = this._getLength(contentText);
           node = contentText;
@@ -270,37 +303,72 @@
       actionBox.placeBelow(range);
     },
 
-    _handleSelection() {
-      const normalizedRange = this._getNormalizedRange();
-      if (!normalizedRange) {
-        return;
+    _isRangeValid(range) {
+      if (!range || !range.start || !range.end) {
+        return false;
       }
-      const domRange = window.getSelection().getRangeAt(0);
-      /** @type {?} */
-      const start = normalizedRange.start;
-      if (!start) {
-        return;
-      }
-      const end = normalizedRange.end;
-      if (!end) {
-        return;
-      }
+      const start = range.start;
+      const end = range.end;
       if (start.side !== end.side ||
           end.line < start.line ||
           (start.line === end.line && start.column === end.column)) {
+        return false;
+      }
+      return true;
+    },
+
+    _handleSelection(selection, isMouseUp) {
+      const normalizedRange = this._getNormalizedRange(selection);
+      if (!this._isRangeValid(normalizedRange)) {
+        this._removeActionBox();
         return;
       }
+      const domRange = selection.getRangeAt(0);
+      const start = normalizedRange.start;
+      const end = normalizedRange.end;
 
       // TODO (viktard): Drop empty first and last lines from selection.
 
-      const actionBox = document.createElement('gr-selection-action-box');
-      const root = Polymer.dom(this.root);
-      root.insertBefore(actionBox, root.firstElementChild);
+      // If the selection is from the end of one line to the start of the next
+      // line, then this must have been a double-click, or you have started
+      // dragging. Showing the action box is bad in the former case and not very
+      // useful in the latter, so never do that.
+      // If this was a mouse-up event, we create a comment immediately if
+      // the selection is from the end of a line to the start of the next line.
+      // In a perfect world we would only do this for double-click, but it is
+      // extremely rare that a user would drag from the end of one line to the
+      // start of the next and release the mouse, so we don't bother.
+      // TODO(brohlfs): This does not work, if the double-click is before a new
+      // diff chunk (start will be equal to end), and neither before an "expand
+      // the diff context" block (end line will match the first line of the new
+      // section and thus be greater than start line + 1).
+      if (start.line === end.line - 1 && end.column === 0) {
+        // Rather than trying to find the line contents (for comparing
+        // start.column with the content length), we just check if the selection
+        // is empty to see that it's at the end of a line.
+        const content = domRange.cloneContents().querySelector('.contentText');
+        if (isMouseUp && this._getLength(content) === 0) {
+          this.fire('create-range-comment', {side: start.side, range: {
+            start_line: start.line,
+            start_character: 0,
+            end_line: start.line,
+            end_character: start.column,
+          }});
+        }
+        return;
+      }
+
+      let actionBox = this.$$('gr-selection-action-box');
+      if (!actionBox) {
+        actionBox = document.createElement('gr-selection-action-box');
+        const root = Polymer.dom(this.root);
+        root.insertBefore(actionBox, root.firstElementChild);
+      }
       actionBox.range = {
-        startLine: start.line,
-        startChar: start.column,
-        endLine: end.line,
-        endChar: end.column,
+        start_line: start.line,
+        start_character: start.column,
+        end_line: end.line,
+        end_character: end.column,
       };
       actionBox.side = start.side;
       if (start.line === end.line) {
@@ -319,14 +387,10 @@
       }
     },
 
-    _createComment(e) {
+    _createRangeComment(e) {
       this._removeActionBox();
     },
 
-    _removeActionBoxDebounced() {
-      this.debounce('removeActionBox', this._removeActionBox, 10);
-    },
-
     _removeActionBox() {
       const actionBox = this.$$('gr-selection-action-box');
       if (actionBox) {
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..b83dd53 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-highlight</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-highlight.html">
 
@@ -72,9 +74,9 @@
           <tr class="diff-row side-by-side" left-type="remove" right-type="add">
             <td class="left lineNum" data-value="140"></td>
             <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div><gr-diff-comment-thread>
+            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
                 [Yet another random diff thread content here]
-              </gr-diff-comment-thread></td>
+            </div></td>
             <td class="right lineNum" data-value="120"></td>
             <!-- Next tag is formatted to eliminate zero-length text nodes. -->
             <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">	</span> audiam,  sit, quod</div></td>
@@ -123,7 +125,7 @@
         <tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
             <td class="left lineNum" data-value="165"></td>
-            <td class="content both"><div class="contentText">in physicis, quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+            <td class="content both"><div class="contentText"></div></td>
             <td class="right lineNum" data-value="147"></td>
             <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
           </tr>
@@ -158,33 +160,6 @@
       sandbox.restore();
     });
 
-    suite('selectionchange event handling', () => {
-      const emulateSelection = function() {
-        document.dispatchEvent(new CustomEvent('selectionchange'));
-        element.flushDebouncer('selectionChange');
-        element.flushDebouncer('removeActionBox');
-      };
-
-      setup(() => {
-        sandbox.stub(element, '_handleSelection');
-        sandbox.stub(element, '_removeActionBox');
-      });
-
-      test('enabled if logged in', () => {
-        element.loggedIn = true;
-        emulateSelection();
-        assert.isTrue(element._handleSelection.called);
-        assert.isTrue(element._removeActionBox.called);
-      });
-
-      test('ignored if logged out', () => {
-        element.loggedIn = false;
-        emulateSelection();
-        assert.isFalse(element._handleSelection.called);
-        assert.isFalse(element._removeActionBox.called);
-      });
-    });
-
     suite('comment events', () => {
       let builder;
 
@@ -197,27 +172,65 @@
         element._cachedDiffBuilder = builder;
       });
 
-      test('comment-mouse-over from line comments is ignored', () => {
+      test('comment-thread-mouseenter from line comments is ignored', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'right');
+        threadEl.setAttribute('line-num', 3);
+        element.appendChild(threadEl);
+        element.commentRanges = [{side: 'right'}];
+
         sandbox.stub(element, 'set');
-        element.fire('comment-mouse-over', {comment: {}});
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseenter', {bubbles: true, composed: true}));
         assert.isFalse(element.set.called);
       });
 
-      test('comment-mouse-over from ranged comment causes set', () => {
+      test('comment-thread-mouseenter from ranged comment causes set', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'right');
+        threadEl.setAttribute('line-num', 3);
+        threadEl.setAttribute('range', JSON.stringify({
+          start_line: 3,
+          start_character: 4,
+          end_line: 5,
+          end_character: 6,
+        }));
+        element.appendChild(threadEl);
+        element.commentRanges = [{side: 'right', range: {
+          start_line: 3,
+          start_character: 4,
+          end_line: 5,
+          end_character: 6,
+        }}];
+
         sandbox.stub(element, 'set');
-        sandbox.stub(element, '_indexOfComment').returns(0);
-        element.fire('comment-mouse-over', {comment: {range: {}}});
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseenter', {bubbles: true, composed: true}));
         assert.isTrue(element.set.called);
+        const args = element.set.lastCall.args;
+        assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
+        assert.deepEqual(args[1], true);
       });
 
-      test('comment-mouse-out from line comments is ignored', () => {
-        element.fire('comment-mouse-over', {comment: {}});
-        assert.isFalse(builder.getContentsByLineRange.called);
+      test('comment-thread-mouseleave from line comments is ignored', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'right');
+        threadEl.setAttribute('line-num', 3);
+        element.appendChild(threadEl);
+        element.commentRanges = [{side: 'right'}];
+
+        sandbox.stub(element, 'set');
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseleave', {bubbles: true, composed: true}));
+        assert.isFalse(element.set.called);
       });
 
-      test('on create-comment action box is removed', () => {
+      test('on create-range-comment action box is removed', () => {
         sandbox.stub(element, '_removeActionBox');
-        element.fire('create-comment', {
+        element.fire('create-range-comment', {
           comment: {
             range: {},
           },
@@ -255,7 +268,7 @@
         range.setStart(startNode, startOffset);
         range.setEnd(endNode, endOffset);
         selection.addRange(range);
-        element._handleSelection();
+        element._handleSelection(selection);
       };
 
       const getActionRange = () =>
@@ -318,10 +331,10 @@
         const actionBox = element.$$('gr-selection-action-box');
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 138,
-          startChar: 5,
-          endLine: 138,
-          endChar: 12,
+          start_line: 138,
+          start_character: 5,
+          end_line: 138,
+          end_character: 12,
         });
         assert.equal(getActionSide(), 'left');
         assert.notOk(actionBox.positionBelow);
@@ -337,10 +350,10 @@
         const actionBox = element.$$('gr-selection-action-box');
 
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 36,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 36,
         });
         assert.equal(getActionSide(), 'right');
         assert.notOk(actionBox.positionBelow);
@@ -362,18 +375,18 @@
         getRangeAtStub
             .onFirstCall().returns(startRange)
             .onSecondCall().returns(endRange);
-        sandbox.stub(window, 'getSelection').returns({
+        const selection = {
           rangeCount: 2,
           getRangeAt: getRangeAtStub,
           removeAllRanges: sandbox.stub(),
-        });
-        element._handleSelection();
+        };
+        element._handleSelection(selection);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 36,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 36,
         });
       });
 
@@ -383,10 +396,10 @@
         emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 2,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 2,
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -404,10 +417,10 @@
         emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 8,
-          endLine: 140,
-          endChar: 23,
+          start_line: 140,
+          start_character: 8,
+          end_line: 140,
+          end_character: 23,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -418,10 +431,10 @@
         emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 18,
-          endLine: 140,
-          endChar: 27,
+          start_line: 140,
+          start_character: 18,
+          end_line: 140,
+          end_character: 27,
         });
       });
 
@@ -431,10 +444,10 @@
         emulateSelection(content.firstChild, 2, hl.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 2,
-          endLine: 140,
-          endChar: 61,
+          start_line: 140,
+          start_character: 2,
+          end_line: 140,
+          end_character: 61,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -465,15 +478,15 @@
       test('starts in comment thread element', () => {
         const startContent = stubContent(140, 'left');
         const comment = startContent.parentElement.querySelector(
-            'gr-diff-comment-thread');
+            '.comment-thread');
         const endContent = stubContent(141, 'left');
         emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 83,
-          endLine: 141,
-          endChar: 4,
+          start_line: 140,
+          start_character: 83,
+          end_line: 141,
+          end_character: 4,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -481,14 +494,14 @@
       test('ends in comment thread element', () => {
         const content = stubContent(140, 'left');
         const comment = content.parentElement.querySelector(
-            'gr-diff-comment-thread');
+            '.comment-thread');
         emulateSelection(content.firstChild, 4, comment.firstChild, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 4,
-          endLine: 140,
-          endChar: 83,
+          start_line: 140,
+          start_character: 4,
+          end_line: 140,
+          end_character: 83,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -517,10 +530,10 @@
         emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 130,
-          startChar: 3,
-          endLine: 146,
-          endChar: 14,
+          start_line: 130,
+          start_character: 3,
+          end_line: 146,
+          end_character: 14,
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -531,10 +544,10 @@
             content.firstChild, 1, content.querySelector('span'), 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 1,
-          endLine: 140,
-          endChar: 51,
+          start_line: 140,
+          start_character: 1,
+          end_line: 140,
+          end_character: 51,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -546,10 +559,10 @@
             content.querySelectorAll('span')[1].nextSibling, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 51,
-          endLine: 140,
-          endChar: 71,
+          start_line: 140,
+          start_character: 51,
+          end_line: 140,
+          end_character: 71,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -576,54 +589,33 @@
         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(), {
+          start_line: 119,
+          start_character: 0,
+          end_line: 119,
+          end_character: element._getLength(startContent),
         });
+        assert.equal(getActionSide(), 'right');
+      });
 
-        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('_fixTripleClickSelection empty line', () => {
+        const startContent = stubContent(146, 'right');
+        const endContent = stubContent(165, 'left');
+        emulateSelection(startContent.firstChild, 0,
+            endContent.parentElement.previousElementSibling, 0);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          start_line: 146,
+          start_character: 0,
+          end_line: 146,
+          end_character: 84,
         });
-
-        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..fc71be6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
@@ -0,0 +1,57 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-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-name="[[projectName]]"
+        display-line="[[displayLine]]"
+        is-image-diff="[[isImageDiff]]"
+        commit-range="[[commitRange]]"
+        hidden$="[[hidden]]"
+        no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
+        line-wrapping="[[lineWrapping]]"
+        view-mode="[[viewMode]]"
+        line-of-interest="[[lineOfInterest]]"
+        logged-in="[[_loggedIn]]"
+        loading="[[_loading]]"
+        error-message="[[_errorMessage]]"
+        base-image="[[_baseImage]]"
+        revision-image=[[_revisionImage]]
+        coverage-ranges="[[_coverageRanges]]"
+        blame="[[_blame]]"
+        diff="[[_diff]]"></gr-diff>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+    <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..2d42b00
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -0,0 +1,852 @@
+/**
+ * @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',
+  };
+
+  /** @enum {string} */
+  const TimingLabel = {
+    TOTAL: 'Diff Total Render',
+    CONTENT: 'Diff Content Render',
+    SYNTAX: 'Diff Syntax Render',
+  };
+
+  const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';
+
+  /**
+   * @param {Object} diff
+   * @return {boolean}
+   */
+  function isImageDiff(diff) {
+    if (!diff) { return false; }
+
+    const isA = diff.meta_a &&
+        diff.meta_a.content_type.startsWith('image/');
+    const isB = diff.meta_b &&
+        diff.meta_b.content_type.startsWith('image/');
+
+    return !!(diff.binary && (isA || isB));
+  }
+
+  /** @enum {string} */
+  Gerrit.DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  /**
+   * Wrapper around gr-diff.
+   *
+   * Webcomponent fetching diffs and related data from restAPI and passing them
+   * to the presentational gr-diff for rendering.
+   */
+  Polymer({
+    is: 'gr-diff-host',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * 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,
+      },
+      projectName: String,
+      displayLine: {
+        type: Boolean,
+        value: false,
+      },
+      isImageDiff: {
+        type: Boolean,
+        computed: '_computeIsImageDiff(_diff)',
+        notify: true,
+      },
+      commitRange: Object,
+      filesWeblinks: {
+        type: Object,
+        value() {
+          return {};
+        },
+        notify: true,
+      },
+      hidden: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
+      noRenderOnPrefsChange: {
+        type: Boolean,
+        value: false,
+      },
+      comments: {
+        type: Object,
+        observer: '_commentsChanged',
+      },
+      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,
+      },
+
+      /**
+       * TODO(brohlfs): Replace Object type by Gerrit.CoverageRange.
+       *
+       * @type {!Array<!Object>}
+       */
+      _coverageRanges: {
+        type: Array,
+        value: () => [],
+      },
+
+      _loadedWhitespaceLevel: String,
+
+      _parentIndex: {
+        type: Number,
+        computed: '_computeParentIndex(patchRange.*)',
+      },
+    },
+
+    behaviors: [
+      Gerrit.PatchSetBehavior,
+    ],
+
+    listeners: {
+      // These are named inconsistently for a reason:
+      // The create-comment event is fired to indicate that we should
+      // create a comment.
+      // The comment-* events are just notifying that the comments did already
+      // change in some way, and that we should update any models we may want
+      // to keep in sync.
+      'create-comment': '_handleCreateComment',
+      'comment-discard': '_handleCommentDiscard',
+      'comment-update': '_handleCommentUpdate',
+      'comment-save': '_handleCommentSave',
+
+      'render-start': '_handleRenderStart',
+      'render-content': '_handleRenderContent',
+      'render-syntax': '_handleRenderSyntax',
+
+      'normalize-range': '_handleNormalizeRange',
+    },
+
+    observers: [
+      '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
+          ' noRenderOnPrefsChange)',
+    ],
+
+    ready() {
+      if (this._canReload()) {
+        this.reload();
+      }
+    },
+
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+      });
+    },
+
+    /** @return {!Promise} */
+    reload() {
+      this._loading = true;
+      this._errorMessage = null;
+      const whitespaceLevel = this._getIgnoreWhitespace();
+
+      this._coverageRanges = [];
+      const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
+      this.$.jsAPI.getCoverageRanges(changeNum, path, basePatchNum, patchNum).
+          then(coverageRanges => {
+            if (changeNum !== this.changeNum ||
+                path !== this.path ||
+                basePatchNum !== this.patchRange.basePatchNum ||
+                patchNum !== this.patchRange.patchNum) {
+              return;
+            }
+            this._coverageRanges = coverageRanges;
+          }).catch(err => {
+            console.warn('Loading coverage ranges failed: ', err);
+          });
+
+      const diffRequest = this._getDiff()
+          .then(diff => {
+            this._loadedWhitespaceLevel = whitespaceLevel;
+            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;
+    },
+
+    /**
+     * The thread elements in this diff, in no particular order.
+     * @return {!Array<!HTMLElement>}
+     */
+    getThreadEls() {
+      // Polymer2: querySelectorAll returns NodeList instead of Array.
+      return Array.from(
+          Polymer.dom(this.$.diff).querySelectorAll('.comment-thread'));
+    },
+
+    /** @param {HTMLElement} el */
+    addDraftAtLine(el) {
+      this.$.diff.addDraftAtLine(el);
+    },
+
+    clearDiffContent() {
+      this.$.diff.clearDiffContent();
+    },
+
+    expandAllContext() {
+      this.$.diff.expandAllContext();
+    },
+
+    /** @return {!Promise} */
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    /** @return {boolean}} */
+    _canReload() {
+      return !!this.changeNum && !!this.patchRange && !!this.path &&
+          !this.noAutoRender;
+    },
+
+    /** @return {!Promise<!Object>} */
+    _getDiff() {
+      // Wrap the diff request in a new promise so that the error handler
+      // rejects the promise, allowing the error to be handled in the .catch.
+      return new Promise((resolve, reject) => {
+        this.$.restAPI.getDiff(
+            this.changeNum,
+            this.patchRange.basePatchNum,
+            this.patchRange.patchNum,
+            this.path,
+            this._getIgnoreWhitespace(),
+            reject)
+            .then(resolve);
+      });
+    },
+
+    _handleGetDiffError(response) {
+      // Loading the diff may respond with 409 if the file is too large. In this
+      // case, use a toast error..
+      if (response.status === 409) {
+        this.fire('server-error', {response});
+        return;
+      }
+
+      if (this.showLoadFailure) {
+        this._errorMessage = [
+          'Encountered error when loading the diff:',
+          response.status,
+          response.statusText,
+        ].join(' ');
+        return;
+      }
+
+      this.fire('page-error', {response});
+    },
+
+    /**
+     * Report info about the diff response.
+     */
+    _reportDiff(diff) {
+      if (!diff || !diff.content) {
+        return;
+      }
+
+      // Count the delta lines stemming from normal deltas, and from
+      // due_to_rebase deltas.
+      let nonRebaseDelta = 0;
+      let rebaseDelta = 0;
+      diff.content.forEach(chunk => {
+        if (chunk.ab) { return; }
+        const deltaSize = Math.max(
+            chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
+        if (chunk.due_to_rebase) {
+          rebaseDelta += deltaSize;
+        } else {
+          nonRebaseDelta += deltaSize;
+        }
+      });
+
+      // Find the percent of the delta from due_to_rebase chunks rounded to two
+      // digits. Diffs with no delta are considered 0%.
+      const totalDelta = rebaseDelta + nonRebaseDelta;
+      const percentRebaseDelta = !totalDelta ? 0 :
+          Math.round(100 * rebaseDelta / totalDelta);
+
+      // Report the due_to_rebase percentage in the "diff" category when
+      // applicable.
+      if (this.patchRange.basePatchNum === 'PARENT') {
+        this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
+      } else if (percentRebaseDelta === 0) {
+        this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
+      } else {
+        this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
+            percentRebaseDelta);
+      }
+    },
+
+    /**
+     * @param {Object} diff
+     * @return {!Promise}
+     */
+    _loadDiffAssets(diff) {
+      if (isImageDiff(diff)) {
+        return this._getImages(diff).then(images => {
+          this._baseImage = images.baseImage;
+          this._revisionImage = images.revisionImage;
+        });
+      } else {
+        this._baseImage = null;
+        this._revisionImage = null;
+        return Promise.resolve();
+      }
+    },
+
+    /**
+     * @param {Object} diff
+     * @return {boolean}
+     */
+    _computeIsImageDiff(diff) {
+      return isImageDiff(diff);
+    },
+
+    _commentsChanged(newComments) {
+      const allComments = [];
+      for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
+        // This is needed by the threading.
+        for (const comment of newComments[side]) {
+          comment.__commentSide = side;
+        }
+        allComments.push(...newComments[side]);
+      }
+      // Currently, the only way this is ever changed here is when the initial
+      // comments are loaded, so it's okay performance wise to clear the threads
+      // and recreate them. If this changes in future, we might want to reuse
+      // some DOM nodes here.
+      this._clearThreads();
+      const threads = this._createThreads(allComments);
+      for (const thread of threads) {
+        const threadEl = this._createThreadElement(thread);
+        this._attachThreadElement(threadEl);
+      }
+    },
+
+    /**
+     * @param {!Array<!Object>} comments
+     * @return {!Array<!Object>} Threads for the given comments.
+     */
+    _createThreads(comments) {
+      const sortedComments = comments.slice(0).sort((a, b) => {
+        if (b.__draft && !a.__draft ) { return 0; }
+        if (a.__draft && !b.__draft ) { return 1; }
+        return util.parseDate(a.updated) - util.parseDate(b.updated);
+      });
+
+      const threads = [];
+      for (const comment of sortedComments) {
+        // If the comment is in reply to another comment, find that comment's
+        // thread and append to it.
+        if (comment.in_reply_to) {
+          const thread = threads.find(thread =>
+              thread.comments.some(c => c.id === comment.in_reply_to));
+          if (thread) {
+            thread.comments.push(comment);
+            continue;
+          }
+        }
+
+        // Otherwise, this comment starts its own thread.
+        const newThread = {
+          start_datetime: comment.updated,
+          comments: [comment],
+          commentSide: comment.__commentSide,
+          patchNum: comment.patch_set,
+          rootId: comment.id || comment.__draftID,
+          lineNum: comment.line,
+          isOnParent: comment.side === 'PARENT',
+        };
+        if (comment.range) {
+          newThread.range = Object.assign({}, comment.range);
+        }
+        threads.push(newThread);
+      }
+      return threads;
+    },
+
+    /**
+     * @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);
+    },
+
+    /** @param {CustomEvent} e */
+    _handleCreateComment(e) {
+      const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+      const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
+          isOnParent);
+      threadEl.addOrEditDraft(lineNum, range);
+
+      this.$.reporting.recordDraftInteraction();
+    },
+
+    /**
+     * Gets or creates a comment thread at a given location.
+     * May provide a range, to get/create a range comment.
+     *
+     * @param {string} patchNum
+     * @param {?number} lineNum
+     * @param {string} commentSide
+     * @param {Gerrit.Range|undefined} range
+     * @param {boolean} isOnParent
+     * @return {!Object}
+     */
+    _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
+      let threadEl = this._getThreadEl(lineNum, commentSide, range);
+      if (!threadEl) {
+        threadEl = this._createThreadElement({
+          comments: [],
+          commentSide,
+          patchNum,
+          lineNum,
+          range,
+          isOnParent,
+        });
+        this._attachThreadElement(threadEl);
+      }
+      return threadEl;
+    },
+
+    _attachThreadElement(threadEl) {
+      Polymer.dom(this.$.diff).appendChild(threadEl);
+    },
+
+    _clearThreads() {
+      for (const threadEl of this.getThreadEls()) {
+        const parent = Polymer.dom(threadEl).parentNode;
+        Polymer.dom(parent).removeChild(threadEl);
+      }
+    },
+
+    _createThreadElement(thread) {
+      const threadEl = document.createElement('gr-comment-thread');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
+      threadEl.comments = thread.comments;
+      threadEl.commentSide = thread.commentSide;
+      threadEl.isOnParent = !!thread.isOnParent;
+      threadEl.parentIndex = this._parentIndex;
+      threadEl.changeNum = this.changeNum;
+      threadEl.patchNum = thread.patchNum;
+      threadEl.lineNum = thread.lineNum;
+      const rootIdChangedListener = changeEvent => {
+        thread.rootId = changeEvent.detail.value;
+      };
+      threadEl.addEventListener('root-id-changed', rootIdChangedListener);
+      threadEl.path = this.path;
+      threadEl.projectName = this.projectName;
+      threadEl.range = thread.range;
+      const threadDiscardListener = e => {
+        const threadEl = /** @type {!Node} */ (e.currentTarget);
+
+        const parent = Polymer.dom(threadEl).parentNode;
+        Polymer.dom(parent).removeChild(threadEl);
+
+        threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
+        threadEl.removeEventListener('thread-discard', threadDiscardListener);
+      };
+      threadEl.addEventListener('thread-discard', threadDiscardListener);
+      return threadEl;
+    },
+
+    /**
+     * Gets a comment thread element at a given location.
+     * May provide a range, to get a range comment.
+     *
+     * @param {?number} lineNum
+     * @param {string} commentSide
+     * @param {!Gerrit.Range=} range
+     * @return {?Node}
+     */
+    _getThreadEl(lineNum, commentSide, range = undefined) {
+      let line;
+      if (commentSide === GrDiffBuilder.Side.LEFT) {
+        line = {beforeNumber: lineNum};
+      } else if (commentSide === GrDiffBuilder.Side.RIGHT) {
+        line = {afterNumber: lineNum};
+      } else {
+        throw new Error(`Unknown side: ${commentSide}`);
+      }
+      function matchesRange(threadEl) {
+        const threadRange = /** @type {!Gerrit.Range} */(
+            JSON.parse(threadEl.getAttribute('range')));
+        return Gerrit.rangesEqual(threadRange, range);
+      }
+
+      const filteredThreadEls = this._filterThreadElsForLocation(
+          this.getThreadEls(), line, commentSide).filter(matchesRange);
+      return filteredThreadEls.length ? filteredThreadEls[0] : null;
+    },
+
+    /**
+     * @param {!Array<!HTMLElement>} threadEls
+     * @param {!{beforeNumber: (number|string|undefined|null),
+     *           afterNumber: (number|string|undefined|null)}}
+     *     lineInfo
+     * @param {!Gerrit.DiffSide=} side The side (LEFT, RIGHT) for
+     *     which to return the threads.
+     * @return {!Array<!HTMLElement>} The thread elements matching the given
+     *     location.
+     */
+    _filterThreadElsForLocation(threadEls, lineInfo, side) {
+      function matchesLeftLine(threadEl) {
+        return threadEl.getAttribute('comment-side') ==
+            Gerrit.DiffSide.LEFT &&
+            threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
+      }
+      function matchesRightLine(threadEl) {
+        return threadEl.getAttribute('comment-side') ==
+            Gerrit.DiffSide.RIGHT &&
+            threadEl.getAttribute('line-num') == lineInfo.afterNumber;
+      }
+      function matchesFileComment(threadEl) {
+        return threadEl.getAttribute('comment-side') == side &&
+              // line/range comments have 1-based line set, if line is falsy it's
+              // a file comment
+              !threadEl.getAttribute('line-num');
+      }
+
+      // Select the appropriate matchers for the desired side and line
+      // If side is BOTH, we want both the left and right matcher.
+      const matchers = [];
+      if (side !== Gerrit.DiffSide.RIGHT) {
+        matchers.push(matchesLeftLine);
+      }
+      if (side !== Gerrit.DiffSide.LEFT) {
+        matchers.push(matchesRightLine);
+      }
+      if (lineInfo.afterNumber === 'FILE' ||
+          lineInfo.beforeNumber === 'FILE') {
+        matchers.push(matchesFileComment);
+      }
+      return threadEls.filter(threadEl =>
+          matchers.some(matcher => matcher(threadEl)));
+    },
+
+    _getIgnoreWhitespace() {
+      if (!this.prefs || !this.prefs.ignore_whitespace) {
+        return WHITESPACE_IGNORE_NONE;
+      }
+      return this.prefs.ignore_whitespace;
+    },
+
+    _whitespaceChanged(
+        preferredWhitespaceLevel, loadedWhitespaceLevel,
+        noRenderOnPrefsChange) {
+      if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
+          !noRenderOnPrefsChange) {
+        this.reload();
+      }
+    },
+
+    /**
+     * @param {Object} patchRangeRecord
+     * @return {number|null}
+     */
+    _computeParentIndex(patchRangeRecord) {
+      return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
+          this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
+    },
+
+    _handleCommentSave(e) {
+      const comment = e.detail.comment;
+      const side = e.detail.comment.__commentSide;
+      const idx = this._findDraftIndex(comment, side);
+      this.set(['comments', side, idx], comment);
+      this._handleCommentSaveOrDiscard();
+    },
+
+    _handleCommentDiscard(e) {
+      const comment = e.detail.comment;
+      this._removeComment(comment);
+      this._handleCommentSaveOrDiscard();
+    },
+
+    /**
+     * Closure annotation for Polymer.prototype.push is off. Submitted PR:
+     * https://github.com/Polymer/polymer/pull/4776
+     * but for not supressing annotations.
+     *
+     * @suppress {checkTypes}
+     */
+    _handleCommentUpdate(e) {
+      const comment = e.detail.comment;
+      const side = e.detail.comment.__commentSide;
+      let idx = this._findCommentIndex(comment, side);
+      if (idx === -1) {
+        idx = this._findDraftIndex(comment, side);
+      }
+      if (idx !== -1) { // Update draft or comment.
+        this.set(['comments', side, idx], comment);
+      } else { // Create new draft.
+        this.push(['comments', side], comment);
+      }
+    },
+
+    _handleCommentSaveOrDiscard() {
+      this.dispatchEvent(new CustomEvent(
+          'diff-comments-modified', {bubbles: true, composed: true}));
+    },
+
+    _removeComment(comment) {
+      const side = comment.__commentSide;
+      this._removeCommentFromSide(comment, side);
+    },
+
+    _removeCommentFromSide(comment, side) {
+      let idx = this._findCommentIndex(comment, side);
+      if (idx === -1) {
+        idx = this._findDraftIndex(comment, side);
+      }
+      if (idx !== -1) {
+        this.splice('comments.' + side, idx, 1);
+      }
+    },
+
+    /** @return {number} */
+    _findCommentIndex(comment, side) {
+      if (!comment.id || !this.comments[side]) {
+        return -1;
+      }
+      return this.comments[side].findIndex(item => item.id === comment.id);
+    },
+
+    /** @return {number} */
+    _findDraftIndex(comment, side) {
+      if (!comment.__draftID || !this.comments[side]) {
+        return -1;
+      }
+      return this.comments[side].findIndex(
+          item => item.__draftID === comment.__draftID);
+    },
+
+    _handleRenderStart() {
+      this.$.reporting.time(TimingLabel.TOTAL);
+      this.$.reporting.time(TimingLabel.CONTENT);
+    },
+
+    _handleRenderContent() {
+      this.$.reporting.timeEnd(TimingLabel.CONTENT);
+      this.$.reporting.time(TimingLabel.SYNTAX);
+    },
+
+    _handleRenderSyntax() {
+      this.$.reporting.timeEnd(TimingLabel.SYNTAX);
+      this.$.reporting.timeEnd(TimingLabel.TOTAL);
+    },
+
+    _handleNormalizeRange(event) {
+      this.$.reporting.reportInteraction('normalize-range',
+          `Modified invalid comment range on l. ${event.detail.lineNum}` +
+          ` of the ${event.detail.side} side`);
+    },
+  });
+})();
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..65d81bf
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -0,0 +1,1261 @@
+<!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="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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;
+    let getLoggedIn;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      getLoggedIn = false;
+      stub('gr-rest-api-interface', {
+        async getLoggedIn() { return getLoggedIn; },
+      });
+      stub('gr-reporting', {
+        time: sandbox.stub(),
+        timeEnd: sandbox.stub(),
+      });
+
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('handle comment-update', () => {
+      setup(() => {
+        sandbox.stub(element, '_commentsChanged');
+        element.comments = {
+          meta: {
+            changeNum: '42',
+            patchRange: {
+              basePatchNum: 'PARENT',
+              patchNum: 3,
+            },
+            path: '/path/to/foo',
+            projectConfig: {foo: 'bar'},
+          },
+          left: [
+            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          ],
+          right: [
+            {id: 'c1', __commentSide: 'right'},
+            {id: 'c2', __commentSide: 'right'},
+            {id: 'd1', __draft: true, __commentSide: 'right'},
+            {id: 'd2', __draft: true, __commentSide: 'right'},
+          ],
+        };
+      });
+
+      test('creating a draft', () => {
+        const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
+          __commentSide: 'left'};
+        element.fire('comment-update', {comment});
+        assert.include(element.comments.left, comment);
+      });
+
+      test('discarding a draft', () => {
+        const draftID = 'tempID';
+        const id = 'savedID';
+        const comment = {
+          __draft: true,
+          __draftID: draftID,
+          side: 'PARENT',
+          __commentSide: 'left',
+        };
+        const diffCommentsModifiedStub = sandbox.stub();
+        element.addEventListener('diff-comments-modified',
+            diffCommentsModifiedStub);
+        element.comments.left.push(comment);
+        comment.id = id;
+        element.fire('comment-discard', {comment});
+        const drafts = element.comments.left.filter(item => {
+          return item.__draftID === draftID;
+        });
+        assert.equal(drafts.length, 0);
+        assert.isTrue(diffCommentsModifiedStub.called);
+      });
+
+      test('saving a draft', () => {
+        const draftID = 'tempID';
+        const id = 'savedID';
+        const comment = {
+          __draft: true,
+          __draftID: draftID,
+          side: 'PARENT',
+          __commentSide: 'left',
+        };
+        const diffCommentsModifiedStub = sandbox.stub();
+        element.addEventListener('diff-comments-modified',
+            diffCommentsModifiedStub);
+        element.comments.left.push(comment);
+        comment.id = id;
+        element.fire('comment-save', {comment});
+        const drafts = element.comments.left.filter(item => {
+          return item.__draftID === draftID;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(drafts[0].id, id);
+        assert.isTrue(diffCommentsModifiedStub.called);
+      });
+    });
+
+    test('remove comment', () => {
+      sandbox.stub(element, '_commentsChanged');
+      element.comments = {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        ],
+        right: [
+          {id: 'c1', __commentSide: 'right'},
+          {id: 'c2', __commentSide: 'right'},
+          {id: 'd1', __draft: true, __commentSide: 'right'},
+          {id: 'd2', __draft: true, __commentSide: 'right'},
+        ],
+      };
+
+      element._removeComment({});
+      // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
+      // to believe that one object deepEquals another even when they do :-/.
+      assert.equal(JSON.stringify(element.comments), JSON.stringify({
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        ],
+        right: [
+          {id: 'c1', __commentSide: 'right'},
+          {id: 'c2', __commentSide: 'right'},
+          {id: 'd1', __draft: true, __commentSide: 'right'},
+          {id: 'd2', __draft: true, __commentSide: 'right'},
+        ],
+      }));
+
+      element._removeComment({id: 'bc2', side: 'PARENT',
+        __commentSide: 'left'});
+      assert.deepEqual(element.comments, {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        ],
+        right: [
+          {id: 'c1', __commentSide: 'right'},
+          {id: 'c2', __commentSide: 'right'},
+          {id: 'd1', __draft: true, __commentSide: 'right'},
+          {id: 'd2', __draft: true, __commentSide: 'right'},
+        ],
+      });
+
+      element._removeComment({id: 'd2', __commentSide: 'right'});
+      assert.deepEqual(element.comments, {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        ],
+        right: [
+          {id: 'c1', __commentSide: 'right'},
+          {id: 'c2', __commentSide: 'right'},
+          {id: 'd1', __draft: true, __commentSide: 'right'},
+        ],
+      });
+    });
+
+    test('thread-discard handling', () => {
+      const threads = [
+        {comments: [{id: 4711}]},
+        {comments: [{id: 42}]},
+      ];
+      element._parentIndex = 1;
+      element.changeNum = '2';
+      element.path = 'some/path';
+      element.projectName = 'Some project';
+      const threadEls = threads.map(
+          thread => element._createThreadElement(thread));
+      assert.equal(threadEls.length, 2);
+      assert.equal(threadEls[0].rootId, 4711);
+      assert.equal(threadEls[1].rootId, 42);
+      for (const threadEl of threadEls) {
+        Polymer.dom(element).appendChild(threadEl);
+      }
+
+      threadEls[0].dispatchEvent(
+          new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
+      const attachedThreads = element.queryAllEffectiveChildren(
+          'gr-comment-thread');
+      assert.equal(attachedThreads.length, 1);
+      assert.equal(attachedThreads[0].rootId, 42);
+    });
+
+    suite('render reporting', () => {
+      test('starts total and content timer on render-start', done => {
+        element.dispatchEvent(
+            new CustomEvent('render-start', {bubbles: true, composed: true}));
+        assert.isTrue(element.$.reporting.time.calledWithExactly(
+            'Diff Total Render'));
+        assert.isTrue(element.$.reporting.time.calledWithExactly(
+            'Diff Content Render'));
+        done();
+      });
+
+      test('ends content and starts syntax timer on render-content', done => {
+        element.dispatchEvent(
+            new CustomEvent('render-content', {bubbles: true, composed: true}));
+        assert.isTrue(element.$.reporting.time.calledWithExactly(
+            'Diff Syntax Render'));
+        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+            'Diff Content Render'));
+        done();
+      });
+
+      test('ends total and syntax timer on render-syntax', done => {
+        element.dispatchEvent(
+            new CustomEvent('render-syntax', {bubbles: true, composed: true}));
+        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+            'Diff Total Render'));
+        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+            'Diff Syntax Render'));
+        done();
+      });
+    });
+
+    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(() => {
+        getLoggedIn = false;
+        element = fixture('basic');
+      });
+
+      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: [],
+            meta: {patchRange: element.patchRange},
+          };
+        });
+
+        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('getThreadEls() returns .comment-threads', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      Polymer.dom(element.$.diff).appendChild(threadEl);
+      assert.deepEqual(element.getThreadEls(), [threadEl]);
+    });
+
+    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 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 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]);
+      });
+    });
+
+    test('_createThreads', () => {
+      const comments = [
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000',
+          line: 1,
+          __commentSide: 'left',
+        }, {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          updated: '2015-12-24 15:01:20.396000000',
+          __commentSide: 'left',
+          line: 1,
+          in_reply_to: 'sallys_confession',
+        },
+        {
+          id: 'new_draft',
+          message: 'i do not like either of you',
+          __commentSide: 'left',
+          __draft: true,
+          updated: '2015-12-20 15:01:20.396000000',
+        },
+      ];
+
+      const actualThreads = element._createThreads(comments);
+
+      assert.equal(actualThreads.length, 2);
+
+      assert.equal(
+          actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
+      assert.equal(actualThreads[0].commentSide, 'left');
+      assert.equal(actualThreads[0].comments.length, 2);
+      assert.deepEqual(actualThreads[0].comments[0], comments[0]);
+      assert.deepEqual(actualThreads[0].comments[1], comments[1]);
+      assert.equal(actualThreads[0].patchNum, undefined);
+      assert.equal(actualThreads[0].rootId, 'sallys_confession');
+      assert.equal(actualThreads[0].lineNum, 1);
+
+      assert.equal(
+          actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
+      assert.equal(actualThreads[1].commentSide, 'left');
+      assert.equal(actualThreads[1].comments.length, 1);
+      assert.deepEqual(actualThreads[1].comments[0], comments[2]);
+      assert.equal(actualThreads[1].patchNum, undefined);
+      assert.equal(actualThreads[1].rootId, 'new_draft');
+      assert.equal(actualThreads[1].lineNum, undefined);
+    });
+
+    test('_createThreads inherits patchNum and range', () => {
+      const comments = [{
+        id: 'betsys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:10.396000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
+        },
+        patch_set: 5,
+        __commentSide: 'left',
+        line: 1,
+      }];
+
+      expectedThreads = [
+        {
+          start_datetime: '2015-12-24 15:00:10.396000000',
+          commentSide: 'left',
+          comments: [{
+            id: 'betsys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-24 15:00:10.396000000',
+            range: {
+              start_line: 1,
+              start_character: 1,
+              end_line: 1,
+              end_character: 2,
+            },
+            patch_set: 5,
+            __commentSide: 'left',
+            line: 1,
+          }],
+          patchNum: 5,
+          rootId: 'betsys_confession',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          lineNum: 1,
+          isOnParent: false,
+        },
+      ];
+
+      assert.deepEqual(
+          element._createThreads(comments),
+          expectedThreads);
+    });
+
+    test('_createThreads does not thread unrelated comments at same location',
+        () => {
+          const comments = [
+            {
+              id: 'sallys_confession',
+              message: 'i like you, jack',
+              updated: '2015-12-23 15:00:20.396000000',
+              __commentSide: 'left',
+            }, {
+              id: 'jacks_reply',
+              message: 'i like you, too',
+              updated: '2015-12-24 15:01:20.396000000',
+              __commentSide: 'left',
+            },
+          ];
+          assert.equal(element._createThreads(comments).length, 2);
+        });
+
+    test('_createThreads derives isOnParent using  side from first comment',
+        () => {
+          const comments = [
+            {
+              id: 'sallys_confession',
+              message: 'i like you, jack',
+              updated: '2015-12-23 15:00:20.396000000',
+              // line: 1,
+              // __commentSide: 'left',
+            }, {
+              id: 'jacks_reply',
+              message: 'i like you, too',
+              updated: '2015-12-24 15:01:20.396000000',
+              // __commentSide: 'left',
+              // line: 1,
+              in_reply_to: 'sallys_confession',
+            },
+          ];
+
+          assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+          comments[0].side = 'REVISION';
+          assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+          comments[0].side = 'PARENT';
+          assert.equal(element._createThreads(comments)[0].isOnParent, true);
+        });
+
+    test('_getOrCreateThread', () => {
+      const commentSide = 'left';
+
+      assert.isOk(element._getOrCreateThread('2', 3,
+          commentSide, undefined, false));
+
+      let threads = Polymer.dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].commentSide, commentSide);
+      assert.equal(threads[0].range, undefined);
+      assert.equal(threads[0].isOnParent, false);
+      assert.equal(threads[0].patchNum, 2);
+
+
+      // Try to fetch a thread with a different range.
+      range = {
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 3,
+      };
+
+      assert.isOk(element._getOrCreateThread(
+          '3', 1, commentSide, range, true));
+
+      threads = Polymer.dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+
+      assert.equal(threads.length, 2);
+      assert.equal(threads[1].commentSide, commentSide);
+      assert.equal(threads[1].range, range);
+      assert.equal(threads[1].isOnParent, true);
+      assert.equal(threads[1].patchNum, 3);
+    });
+
+    test('_filterThreadElsForLocation with no threads', () => {
+      const line = {beforeNumber: 3, afterNumber: 5};
+
+      const threads = [];
+      assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
+      assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+          Gerrit.DiffSide.LEFT), []);
+      assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+          Gerrit.DiffSide.RIGHT), []);
+    });
+
+    test('_filterThreadElsForLocation for line comments', () => {
+      const line = {beforeNumber: 3, afterNumber: 5};
+
+      const l3 = document.createElement('div');
+      l3.setAttribute('line-num', 3);
+      l3.setAttribute('comment-side', 'left');
+
+      const l5 = document.createElement('div');
+      l5.setAttribute('line-num', 5);
+      l5.setAttribute('comment-side', 'left');
+
+      const r3 = document.createElement('div');
+      r3.setAttribute('line-num', 3);
+      r3.setAttribute('comment-side', 'right');
+
+      const r5 = document.createElement('div');
+      r5.setAttribute('line-num', 5);
+      r5.setAttribute('comment-side', 'right');
+
+      const threadEls = [l3, l5, r3, r5];
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+          [l3, r5]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.LEFT), [l3]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.RIGHT), [r5]);
+    });
+
+    test('_filterThreadElsForLocation for file comments', () => {
+      const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+      const l = document.createElement('div');
+      l.setAttribute('comment-side', 'left');
+
+      const r = document.createElement('div');
+      r.setAttribute('comment-side', 'right');
+
+      const threadEls = [l, r];
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+          [l, r]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.BOTH), [l, r]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.LEFT), [l]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.RIGHT), [r]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
index 8251e53..ad7ff11 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
index 88dd91a..e2d6a28 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-diff-mode-selector',
+    _legacyUndefinedCheck: true,
 
     properties: {
       mode: {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
index c011106..adeaa15 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-mode-selector</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-diff-mode-selector.html">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html
new file mode 100644
index 0000000..10d2828
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html
@@ -0,0 +1,80 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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-button/gr-button.html">
+<link rel="import" href="../../shared/gr-diff-preferences/gr-diff-preferences.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+
+<dom-module id="gr-diff-preferences-dialog">
+  <template>
+    <style include="shared-styles">
+      .diffHeader,
+      .diffActions {
+        padding: 1em 1.5em;
+      }
+      .diffHeader,
+      .diffActions {
+        background-color: var(--dialog-background-color);
+      }
+      .diffHeader {
+        border-bottom: 1px solid var(--border-color);
+        font-weight: var(--font-weight-bold);
+      }
+      .diffActions {
+        border-top: 1px solid var(--border-color);
+        display: flex;
+        justify-content: flex-end;
+      }
+      .diffPrefsOverlay gr-button {
+        margin-left: 1em;
+      }
+      div.edited:after {
+        color: var(--deemphasized-text-color);
+        content: ' *';
+      }
+      #diffPreferences {
+        display: flex;
+        padding: .35em 1.5em;
+      }
+    </style>
+    <gr-overlay id="diffPrefsOverlay" with-backdrop>
+      <div class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]">Diff Preferences</div>
+      <gr-diff-preferences
+          id="diffPreferences"
+          diff-prefs="{{diffPrefs}}"
+          has-unsaved-changes="{{_diffPrefsChanged}}"></gr-diff-preferences>
+      <div class="diffActions">
+        <gr-button
+            id="cancelButton"
+            link
+            on-tap="_handleCancelDiff">
+            Cancel
+        </gr-button>
+        <gr-button
+            id="saveButton"
+            link primary
+            on-tap="_handleSaveDiffPreferences"
+            disabled$="[[!_diffPrefsChanged]]">
+            Save
+        </gr-button>
+      </div>
+    </gr-overlay>
+  </template>
+  <script src="gr-diff-preferences-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
new file mode 100644
index 0000000..bcc3c2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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-diff-preferences-dialog',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      /** @type {?} */
+      diffPrefs: Object,
+
+      _diffPrefsChanged: Boolean,
+    },
+
+    getFocusStops() {
+      return {
+        start: this.$.contextSelect,
+        end: this.$.saveButton,
+      };
+    },
+
+    resetFocus() {
+      this.$.contextSelect.focus();
+    },
+
+    _computeHeaderClass(changed) {
+      return changed ? 'edited' : '';
+    },
+
+    _handleCancelDiff(e) {
+      e.stopPropagation();
+      this.$.diffPrefsOverlay.close();
+    },
+
+    open() {
+      this.$.diffPrefsOverlay.open().then(() => {
+        const focusStops = this.getFocusStops();
+        this.$.diffPrefsOverlay.setFocusStops(focusStops);
+        this.resetFocus();
+      });
+    },
+
+    _handleSaveDiffPreferences() {
+      this.$.diffPreferences.save().then(() => {
+        this.fire('reload-diff-preference', null, {bubbles: false});
+
+        this.$.diffPrefsOverlay.close();
+      });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
deleted file mode 100644
index 375e598..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
+++ /dev/null
@@ -1,164 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-diff-preferences">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: .5;
-        pointer-events: none;
-      }
-      input,
-      select {
-        font: inherit;
-      }
-      input[type="number"] {
-        width: 4em;
-      }
-      .header,
-      .actions {
-        padding: 1em 1.5em;
-      }
-      .header,
-      .mainContainer,
-      .actions {
-        background-color: var(--dialog-background-color);
-      }
-      .header {
-        border-bottom: 1px solid var(--border-color);
-        font-family: var(--font-family-bold);
-      }
-      .mainContainer {
-        padding: 1em 0;
-      }
-      .pref {
-        align-items: center;
-        display: flex;
-        padding: .35em 1.5em;
-        width: 20em;
-      }
-      .pref:hover {
-        background-color: var(--hover-background-color);
-      }
-      .pref label {
-        cursor: pointer;
-        flex: 1;
-      }
-      .actions {
-        border-top: 1px solid var(--border-color);
-        display: flex;
-        justify-content: flex-end;
-      }
-      gr-button {
-        margin-left: 1em;
-      }
-    </style>
-    <gr-overlay id="prefsOverlay" with-backdrop>
-      <div class="header">
-        Diff View Preferences
-      </div>
-      <div class="mainContainer">
-        <div class="pref">
-          <label for="contextSelect">Context</label>
-          <select id="contextSelect" on-change="_handleContextSelectChange">
-            <option value="3">3 lines</option>
-            <option value="10">10 lines</option>
-            <option value="25">25 lines</option>
-            <option value="50">50 lines</option>
-            <option value="75">75 lines</option>
-            <option value="100">100 lines</option>
-            <option value="-1">Whole file</option>
-          </select>
-        </div>
-        <div class="pref">
-          <label for="lineWrappingInput">Fit to screen</label>
-          <input
-              is="iron-input"
-              type="checkbox"
-              id="lineWrappingInput"
-              on-tap="_handlelineWrappingTap">
-        </div>
-        <div class="pref" id="columnsPref">
-          <label for="columnsInput">Diff width</label>
-          <input is="iron-input" type="number" id="columnsInput"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{_newPrefs.line_length}}">
-        </div>
-        <div class="pref">
-          <label for="tabSizeInput">Tab width</label>
-          <input is="iron-input" type="number" id="tabSizeInput"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{_newPrefs.tab_size}}">
-        </div>
-        <div class="pref" hidden$="[[!_newPrefs.font_size]]">
-          <label for="fontSizeInput">Font size</label>
-          <input is="iron-input" type="number" id="fontSizeInput"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{_newPrefs.font_size}}">
-        </div>
-        <div class="pref">
-          <label for="showTabsInput">Show tabs</label>
-          <input is="iron-input" type="checkbox" id="showTabsInput"
-              on-tap="_handleShowTabsTap">
-        </div>
-        <div class="pref">
-          <label for="showTrailingWhitespaceInput">
-            Show trailing whitespace</label>
-          <input is="iron-input" type="checkbox"
-              id="showTrailingWhitespaceInput"
-              on-tap="_handleShowTrailingWhitespaceTap">
-        </div>
-        <div class="pref">
-          <label for="syntaxHighlightInput">Syntax highlighting</label>
-          <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
-              on-tap="_handleSyntaxHighlightTap">
-        </div>
-        <div class="pref">
-          <label for="automaticReviewInput">Automatically mark viewed files reviewed</label>
-          <input
-              is="iron-input"
-              id="automaticReviewInput"
-              type="checkbox"
-              on-tap="_handleAutomaticReviewTap">
-        </div>
-      </div>
-      <div class="actions">
-        <gr-button id="cancelButton" link on-tap="_handleCancel">
-            Cancel</gr-button>
-        <gr-button id="saveButton" link primary on-tap="_handleSave">
-            Save</gr-button>
-      </div>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-storage id="storage"></gr-storage>
-  </template>
-  <script src="gr-diff-preferences.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
deleted file mode 100644
index 47a3c2d..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
+++ /dev/null
@@ -1,138 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-diff-preferences',
-
-    properties: {
-      prefs: {
-        type: Object,
-        notify: true,
-      },
-      localPrefs: {
-        type: Object,
-        notify: true,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-
-      /** @type {?} */
-      _newPrefs: Object,
-      _newLocalPrefs: Object,
-    },
-
-    observers: [
-      '_prefsChanged(prefs.*)',
-      '_localPrefsChanged(localPrefs.*)',
-    ],
-
-    getFocusStops() {
-      return {
-        start: this.$.contextSelect,
-        end: this.$.saveButton,
-      };
-    },
-
-    resetFocus() {
-      this.$.contextSelect.focus();
-    },
-
-    _prefsChanged(changeRecord) {
-      const prefs = changeRecord.base;
-      // NOTE: Object.assign is NOT automatically a deep copy. If prefs adds
-      // an object as a value, it must be marked enumerable.
-      this._newPrefs = Object.assign({}, prefs);
-      this.$.contextSelect.value = prefs.context;
-      this.$.showTabsInput.checked = prefs.show_tabs;
-      this.$.showTrailingWhitespaceInput.checked = prefs.show_whitespace_errors;
-      this.$.lineWrappingInput.checked = prefs.line_wrapping;
-      this.$.syntaxHighlightInput.checked = prefs.syntax_highlighting;
-      this.$.automaticReviewInput.checked = !prefs.manual_review;
-    },
-
-    _localPrefsChanged(changeRecord) {
-      const localPrefs = changeRecord.base || {};
-      this._newLocalPrefs = Object.assign({}, localPrefs);
-    },
-
-    _handleContextSelectChange(e) {
-      const selectEl = Polymer.dom(e).rootTarget;
-      this.set('_newPrefs.context', parseInt(selectEl.value, 10));
-    },
-
-    _handleShowTabsTap(e) {
-      this.set('_newPrefs.show_tabs', Polymer.dom(e).rootTarget.checked);
-    },
-
-    _handleShowTrailingWhitespaceTap(e) {
-      this.set('_newPrefs.show_whitespace_errors',
-          Polymer.dom(e).rootTarget.checked);
-    },
-
-    _handleSyntaxHighlightTap(e) {
-      this.set('_newPrefs.syntax_highlighting',
-          Polymer.dom(e).rootTarget.checked);
-    },
-
-    _handlelineWrappingTap(e) {
-      this.set('_newPrefs.line_wrapping', Polymer.dom(e).rootTarget.checked);
-    },
-
-    _handleAutomaticReviewTap(e) {
-      this.set('_newPrefs.manual_review', !Polymer.dom(e).rootTarget.checked);
-    },
-
-    _handleSave(e) {
-      e.stopPropagation();
-      this.prefs = this._newPrefs;
-      this.localPrefs = this._newLocalPrefs;
-      const el = Polymer.dom(e).rootTarget;
-      el.disabled = true;
-      this.$.storage.savePreferences(this._localPrefs);
-      this._saveDiffPreferences().then(response => {
-        el.disabled = false;
-        if (!response.ok) { return response; }
-
-        this.$.prefsOverlay.close();
-      }).catch(err => {
-        el.disabled = false;
-      });
-    },
-
-    _handleCancel(e) {
-      e.stopPropagation();
-      this.$.prefsOverlay.close();
-    },
-
-    open() {
-      this.$.prefsOverlay.open().then(() => {
-        const focusStops = this.getFocusStops();
-        this.$.prefsOverlay.setFocusStops(focusStops);
-        this.resetFocus();
-      });
-    },
-
-    _saveDiffPreferences() {
-      return this.$.restAPI.saveDiffPreferences(this.prefs);
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
deleted file mode 100644
index d9e14c0..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
+++ /dev/null
@@ -1,110 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-diff-preferences</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-preferences.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-preferences></gr-diff-preferences>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-diff-preferences tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('model changes', () => {
-      element.prefs = {
-        context: 10,
-        font_size: 12,
-        line_length: 100,
-        show_tabs: true,
-        tab_size: 8,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-      };
-      assert.deepEqual(element.prefs, element._newPrefs);
-
-      element.$.contextSelect.value = '50';
-      element.fire('change', {}, {node: element.$.contextSelect});
-      element.$.columnsInput.bindValue = 80;
-      element.$.fontSizeInput.bindValue = 10;
-      element.$.tabSizeInput.bindValue = 4;
-      MockInteractions.tap(element.$.showTabsInput);
-      MockInteractions.tap(element.$.showTrailingWhitespaceInput);
-      MockInteractions.tap(element.$.syntaxHighlightInput);
-      MockInteractions.tap(element.$.lineWrappingInput);
-
-      assert.equal(element._newPrefs.context, 50);
-      assert.equal(element._newPrefs.font_size, 10);
-      assert.equal(element._newPrefs.line_length, 80);
-      assert.equal(element._newPrefs.tab_size, 4);
-      assert.isFalse(element._newPrefs.show_tabs);
-      assert.isFalse(element._newPrefs.show_whitespace_errors);
-      assert.isTrue(element._newPrefs.line_wrapping);
-      assert.isFalse(element._newPrefs.syntax_highlighting);
-    });
-
-    test('clicking save button calls _handleSave function', () => {
-      const savePrefs = sinon.stub(element, '_handleSave');
-      MockInteractions.tap(element.$.saveButton);
-      flushAsynchronousOperations();
-      assert(savePrefs.calledOnce);
-      savePrefs.restore();
-    });
-
-    test('save button', () => {
-      element.prefs = {
-        font_size: '11',
-      };
-      element._newPrefs = {
-        font_size: '12',
-      };
-      const saveStub = sandbox.stub(element.$.restAPI, 'saveDiffPreferences',
-          () => { return Promise.resolve(); });
-
-      MockInteractions.tap(element.$$('gr-button[primary]'));
-      assert.deepEqual(element.prefs, element._newPrefs);
-      assert.deepEqual(saveStub.lastCall.args[0], element._newPrefs);
-    });
-
-    test('cancel button', () => {
-      const closeStub = sandbox.stub(element.$.prefsOverlay, 'close');
-      MockInteractions.tap(element.$$('gr-button:not([primary])'));
-      assert.isTrue(closeStub.called);
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
index baa8bba..922ac87 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
@@ -15,10 +15,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-diff-processor">
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff/gr-diff-group.js"></script>
+
+  <script src="../../../scripts/util.js"></script>
   <script src="gr-diff-processor.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index dead8d7..ccc3bb2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -19,17 +19,49 @@
 
   const WHOLE_FILE = -1;
 
+  const Defs = {};
+
+  /**
+   * The DiffIntralineInfo entity contains information about intraline edits in a
+   * file.
+   *
+   * The information consists of a list of <skip length, mark length> pairs, where
+   * the skip length is the number of characters between the end of the previous
+   * edit and the start of this edit, and the mark length is the number of edited
+   * characters following the skip. The start of the edits is from the beginning
+   * of the related diff content lines.
+   *
+   * Note that the implied newline character at the end of each line is included
+   * in the length calculation, and thus it is possible for the edits to span
+   * newlines.
+   * @typedef {!Array<number>}
+   */
+  Defs.IntralineInfo;
+
+  /**
+   * A portion of the diff that is treated the same.
+   *
+   * Called `DiffContent` in the API, see
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
+   *
+   * @typedef {{
+   *  ab: ?Array<!string>,
+   *  a: ?Array<!string>,
+   *  b: ?Array<!string>,
+   *  skip: ?number,
+   *  edit_a: ?Array<!Defs.IntralineInfo>,
+   *  edit_b: ?Array<!Defs.IntralineInfo>,
+   *  due_to_rebase: ?boolean,
+   *  common: ?boolean
+   * }}
+   */
+  Defs.Chunk;
+
   const DiffSide = {
     LEFT: 'left',
     RIGHT: 'right',
   };
 
-  const DiffGroupType = {
-    ADDED: 'b',
-    BOTH: 'ab',
-    REMOVED: 'a',
-  };
-
   const DiffHighlights = {
     ADDED: 'edit_b',
     REMOVED: 'edit_a',
@@ -45,8 +77,34 @@
    */
   const MAX_GROUP_SIZE = 120;
 
+  /**
+   * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
+   *
+   * Glossary:
+   * - "chunk": A single `DiffContent` as returned by the API.
+   * - "group": A single `GrDiffGroup` as used for rendering.
+   * - "common" chunk/group: A chunk/group that should be considered unchanged
+   *   for diffing purposes. This can mean its either actually unchanged, or it
+   *   has only whitespace changes.
+   * - "key location": A line number and side of the diff that should not be
+   *   collapsed e.g. because a comment is attached to it, or because it was
+   *   provided in the URL and thus should be visible
+   * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
+   *   or cannot be collapsed because it contains a key location
+   *
+   * Here a a number of tasks this processor performs:
+   *  - splitting large chunks to allow more granular async rendering
+   *  - adding a group for the "File" pseudo line that file-level comments can
+   *    be attached to
+   *  - replacing common parts of the diff that are outside the user's
+   *    context setting and do not have comments with a group representing the
+   *    "expand context" widget. This may require splitting a chunk/group so
+   *    that the part that is within the context or has comments is shown, while
+   *    the rest is not.
+   */
   Polymer({
     is: 'gr-diff-processor',
+    _legacyUndefinedCheck: true,
 
     properties: {
 
@@ -80,8 +138,18 @@
         value: 64,
       },
 
-      /** @type {number|undefined} */
+      /** @type {?number} */
       _nextStepHandle: Number,
+      /**
+       * The promise last returned from `process()` while the asynchronous
+       * processing is running - `null` otherwise. Provides a `cancel()`
+       * method that rejects it with `{isCancelled: true}`.
+       * @type {?Object}
+       */
+      _processPromise: {
+        type: Object,
+        value: null,
+      },
       _isScrolling: Boolean,
     },
 
@@ -102,12 +170,18 @@
     },
 
     /**
-     * Asynchronously process the diff object into groups. As it processes, it
+     * Asynchronously process the diff chunks into groups. As it processes, it
      * will splice groups into the `groups` property of the component.
-     * @return {Promise} A promise that resolves when the diff is completely
-     *     processed.
+     * @param {!Array<!Defs.Chunk>} chunks
+     * @param {boolean} isBinary
+     * @return {!Promise<!Array<!Object>>} A promise that resolves with an
+     *     array of GrDiffGroups when the diff is completely processed.
      */
-    process(content, isBinary) {
+    process(chunks, isBinary) {
+      // Cancel any still running process() calls, because they append to the
+      // same groups field.
+      this.cancel();
+
       this.groups = [];
       this.push('groups', this._makeFileComments());
 
@@ -115,233 +189,259 @@
       // so finish processing.
       if (isBinary) { return Promise.resolve(); }
 
-      return new Promise(resolve => {
-        const state = {
-          lineNums: {left: 0, right: 0},
-          sectionIndex: 0,
-        };
 
-        content = this._splitCommonGroupsWithComments(content);
+      this._processPromise = util.makeCancelable(
+          new Promise(resolve => {
+            const state = {
+              lineNums: {left: 0, right: 0},
+              chunkIndex: 0,
+            };
 
-        let currentBatch = 0;
-        const nextStep = () => {
-          if (this._isScrolling) {
-            this.async(nextStep, 100);
-            return;
-          }
-          // If we are done, resolve the promise.
-          if (state.sectionIndex >= content.length) {
-            resolve(this.groups);
-            this._nextStepHandle = undefined;
-            return;
-          }
+            chunks = this._splitLargeChunks(chunks);
+            chunks = this._splitCommonChunksWithKeyLocations(chunks);
 
-          // Process the next section and incorporate the result.
-          const result = this._processNext(state, content);
-          for (const group of result.groups) {
-            this.push('groups', group);
-            currentBatch += group.lines.length;
-          }
-          state.lineNums.left += result.lineDelta.left;
-          state.lineNums.right += result.lineDelta.right;
+            let currentBatch = 0;
+            const nextStep = () => {
+              if (this._isScrolling) {
+                this._nextStepHandle = this.async(nextStep, 100);
+                return;
+              }
+              // If we are done, resolve the promise.
+              if (state.chunkIndex >= chunks.length) {
+                resolve(this.groups);
+                this._nextStepHandle = null;
+                return;
+              }
 
-          // Increment the index and recurse.
-          state.sectionIndex++;
-          if (currentBatch >= this._asyncThreshold) {
-            currentBatch = 0;
-            this._nextStepHandle = this.async(nextStep, 1);
-          } else {
+              // Process the next chunk and incorporate the result.
+              const stateUpdate = this._processNext(state, chunks);
+              for (const group of stateUpdate.groups) {
+                this.push('groups', group);
+                currentBatch += group.lines.length;
+              }
+              state.lineNums.left += stateUpdate.lineDelta.left;
+              state.lineNums.right += stateUpdate.lineDelta.right;
+
+              // Increment the index and recurse.
+              state.chunkIndex = stateUpdate.newChunkIndex;
+              if (currentBatch >= this._asyncThreshold) {
+                currentBatch = 0;
+                this._nextStepHandle = this.async(nextStep, 1);
+              } else {
+                nextStep.call(this);
+              }
+            };
+
             nextStep.call(this);
-          }
-        };
-
-        nextStep.call(this);
-      });
+          }));
+      return this._processPromise
+          .finally(() => { this._processPromise = null; });
     },
 
     /**
      * Cancel any jobs that are running.
      */
     cancel() {
-      if (this._nextStepHandle !== undefined) {
+      if (this._nextStepHandle != null) {
         this.cancelAsync(this._nextStepHandle);
-        this._nextStepHandle = undefined;
+        this._nextStepHandle = null;
+      }
+      if (this._processPromise) {
+        this._processPromise.cancel();
       }
     },
 
     /**
-     * Process the next section of the diff.
+     * Process the next uncollapsible chunk, or the next collapsible chunks.
+     *
+     * @param {!Object} state
+     * @param {!Array<!Object>} chunks
+     * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
      */
-    _processNext(state, content) {
-      const section = content[state.sectionIndex];
-
-      const rows = {
-        both: section[DiffGroupType.BOTH] || null,
-        added: section[DiffGroupType.ADDED] || null,
-        removed: section[DiffGroupType.REMOVED] || null,
-      };
-
-      const highlights = {
-        added: section[DiffHighlights.ADDED] || null,
-        removed: section[DiffHighlights.REMOVED] || null,
-      };
-
-      if (rows.both) { // If it's a shared section.
-        let sectionEnd = null;
-        if (state.sectionIndex === 0) {
-          sectionEnd = 'first';
-        } else if (state.sectionIndex === content.length - 1) {
-          sectionEnd = 'last';
-        }
-
-        const sharedGroups = this._sharedGroupsFromRows(
-            rows.both,
-            content.length > 1 ? this.context : WHOLE_FILE,
-            state.lineNums.left,
-            state.lineNums.right,
-            sectionEnd);
-
+    _processNext(state, chunks) {
+      const firstUncollapsibleChunkIndex =
+          this._firstUncollapsibleChunkIndex(chunks, state.chunkIndex);
+      if (firstUncollapsibleChunkIndex === state.chunkIndex) {
+        const chunk = chunks[state.chunkIndex];
         return {
           lineDelta: {
-            left: rows.both.length,
-            right: rows.both.length,
+            left: this._linesLeft(chunk).length,
+            right: this._linesRight(chunk).length,
           },
-          groups: sharedGroups,
-        };
-      } else { // Otherwise it's a delta section.
-        const deltaGroup = this._deltaGroupFromRows(
-            rows.added,
-            rows.removed,
-            state.lineNums.left,
-            state.lineNums.right,
-            highlights);
-        deltaGroup.dueToRebase = section.due_to_rebase;
-
-        return {
-          lineDelta: {
-            left: rows.removed ? rows.removed.length : 0,
-            right: rows.added ? rows.added.length : 0,
-          },
-          groups: [deltaGroup],
+          groups: [this._chunkToGroup(
+              chunk, state.lineNums.left + 1, state.lineNums.right + 1)],
+          newChunkIndex: state.chunkIndex + 1,
         };
       }
+
+      return this._processCollapsibleChunks(
+          state, chunks, firstUncollapsibleChunkIndex);
+    },
+
+    _linesLeft(chunk) {
+      return chunk.ab || chunk.a || [];
+    },
+
+    _linesRight(chunk) {
+      return chunk.ab || chunk.b || [];
+    },
+
+    _firstUncollapsibleChunkIndex(chunks, offset) {
+      let chunkIndex = offset;
+      while (chunkIndex < chunks.length &&
+          this._isCollapsibleChunk(chunks[chunkIndex])) {
+        chunkIndex++;
+      }
+      return chunkIndex;
+    },
+
+    _isCollapsibleChunk(chunk) {
+      return (chunk.ab || chunk.common) && !chunk.keyLocation;
     },
 
     /**
-     * Take rows of a shared diff section and produce an array of corresponding
-     * (potentially collapsed) groups.
-     * @param {!Array<string>} rows
-     * @param {number} context
-     * @param {number} startLineNumLeft
-     * @param {number} startLineNumRight
-     * @param {?string=} opt_sectionEnd String representing whether this is the
-     *     first section or the last section or neither. Use the values 'first',
-     *     'last' and null respectively.
-     * @return {!Array<!Object>} Array of GrDiffGroup
+     * Process a stretch of collapsible chunks.
+     *
+     * Outputs up to three groups:
+     *  1) Visible context before the hidden common code, unless it's the
+     *     very beginning of the file.
+     *  2) Context hidden behind a context bar, unless empty.
+     *  3) Visible context after the hidden common code, unless it's the very
+     *     end of the file.
+     *
+     * @param {!Object} state
+     * @param {!Array<Object>} chunks
+     * @param {number} firstUncollapsibleChunkIndex
+     * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
      */
-    _sharedGroupsFromRows(rows, context, startLineNumLeft,
-        startLineNumRight, opt_sectionEnd) {
-      const result = [];
-      const lines = [];
-      let line;
+    _processCollapsibleChunks(
+        state, chunks, firstUncollapsibleChunkIndex) {
+      const collapsibleChunks = chunks.slice(
+          state.chunkIndex, firstUncollapsibleChunkIndex);
+      const lineCount = collapsibleChunks.reduce(
+          (sum, chunk) => sum + this._commonChunkLength(chunk), 0);
 
-      // Map each row to a GrDiffLine.
-      for (let i = 0; i < rows.length; i++) {
-        line = new GrDiffLine(GrDiffLine.Type.BOTH);
-        line.text = rows[i];
-        line.beforeNumber = ++startLineNumLeft;
-        line.afterNumber = ++startLineNumRight;
-        lines.push(line);
+      let groups = this._chunksToGroups(
+          collapsibleChunks,
+          state.lineNums.left + 1,
+          state.lineNums.right + 1);
+
+      if (this.context !== WHOLE_FILE) {
+        const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
+        const hiddenEnd = lineCount - (
+            firstUncollapsibleChunkIndex === chunks.length ?
+            0 : this.context);
+        groups = GrDiffGroup.hideInContextControl(
+            groups, hiddenStart, hiddenEnd);
       }
 
-      // Find the hidden range based on the user's context preference. If this
-      // is the first or the last section of the diff, make sure the collapsed
-      // part of the section extends to the edge of the file.
-      const hiddenRange = [context, rows.length - context];
-      if (opt_sectionEnd === 'first') {
-        hiddenRange[0] = 0;
-      } else if (opt_sectionEnd === 'last') {
-        hiddenRange[1] = rows.length;
-      }
+      return {
+        lineDelta: {
+          left: lineCount,
+          right: lineCount,
+        },
+        groups,
+        newChunkIndex: firstUncollapsibleChunkIndex,
+      };
+    },
 
-      // If there is a range to hide.
-      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 1) {
-        const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
-        const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
-        const linesAfterCtx = lines.slice(hiddenRange[1]);
-
-        if (linesBeforeCtx.length > 0) {
-          result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
-        }
-
-        const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
-        ctxLine.contextGroup =
-            new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
-        result.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
-            [ctxLine]));
-
-        if (linesAfterCtx.length > 0) {
-          result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
-        }
-      } else {
-        result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, lines));
-      }
-
-      return result;
+    _commonChunkLength(chunk) {
+      console.assert(chunk.ab || chunk.common);
+      console.assert(
+          !chunk.a || (chunk.b && chunk.a.length === chunk.b.length),
+          `common chunk needs same number of a and b lines: `, chunk);
+      return this._linesLeft(chunk).length;
     },
 
     /**
-     * Take the rows of a delta diff section and produce the corresponding
-     * group.
-     * @param {!Array<string>} rowsAdded
-     * @param {!Array<string>} rowsRemoved
-     * @param {number} startLineNumLeft
-     * @param {number} startLineNumRight
-     * @return {!Object} (Gr-Diff-Group)
+     * @param {!Array<!Object>} chunks
+     * @param {number} offsetLeft
+     * @param {number} offsetRight
+     * @return {!Array<!Object>} (GrDiffGroup)
      */
-    _deltaGroupFromRows(rowsAdded, rowsRemoved, startLineNumLeft,
-        startLineNumRight, highlights) {
+    _chunksToGroups(chunks, offsetLeft, offsetRight) {
+      return chunks.map(chunk => {
+        const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
+        const chunkLength = this._commonChunkLength(chunk);
+        offsetLeft += chunkLength;
+        offsetRight += chunkLength;
+        return group;
+      });
+    },
+
+    /**
+     * @param {!Object} chunk
+     * @param {number} offsetLeft
+     * @param {number} offsetRight
+     * @return {!Object} (GrDiffGroup)
+     */
+    _chunkToGroup(chunk, offsetLeft, offsetRight) {
+      const type = chunk.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA;
+      const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
+      const group = new GrDiffGroup(type, lines);
+      group.keyLocation = chunk.keyLocation;
+      group.dueToRebase = chunk.due_to_rebase;
+      group.ignoredWhitespaceOnly = chunk.common;
+      return group;
+    },
+
+    _linesFromChunk(chunk, offsetLeft, offsetRight) {
+      if (chunk.ab) {
+        return chunk.ab.map((row, i) => this._lineFromRow(
+            GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i));
+      }
       let lines = [];
-      if (rowsRemoved) {
-        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.REMOVE,
-            rowsRemoved, startLineNumLeft, highlights.removed));
+      if (chunk.a) {
+        // Avoiding a.push(...b) because that causes callstack overflows for
+        // large b, which can occur when large files are added removed.
+        lines = lines.concat(this._linesFromRows(
+            GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
+            chunk[DiffHighlights.REMOVED]));
       }
-      if (rowsAdded) {
-        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.ADD,
-            rowsAdded, startLineNumRight, highlights.added));
-      }
-      return new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
-    },
-
-    /**
-     * @return {!Array<!Object>} Array of GrDiffLines
-     */
-    _deltaLinesFromRows(lineType, rows, startLineNum,
-        opt_highlights) {
-      // Normalize highlights if they have been passed.
-      if (opt_highlights) {
-        opt_highlights = this._normalizeIntralineHighlights(rows,
-            opt_highlights);
-      }
-
-      const lines = [];
-      let line;
-      for (let i = 0; i < rows.length; i++) {
-        line = new GrDiffLine(lineType);
-        line.text = rows[i];
-        if (lineType === GrDiffLine.Type.ADD) {
-          line.afterNumber = ++startLineNum;
-        } else {
-          line.beforeNumber = ++startLineNum;
-        }
-        if (opt_highlights) {
-          line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
-        }
-        lines.push(line);
+      if (chunk.b) {
+        // Avoiding a.push(...b) because that causes callstack overflows for
+        // large b, which can occur when large files are added removed.
+        lines = lines.concat(this._linesFromRows(
+            GrDiffLine.Type.ADD, chunk.b, offsetRight,
+            chunk[DiffHighlights.ADDED]));
       }
       return lines;
     },
 
+    /**
+     * @param {string} lineType (GrDiffLine.Type)
+     * @param {!Array<string>} rows
+     * @param {number} offset
+     * @param {?Array<!Defs.IntralineInfo>=} opt_intralineInfos
+     * @return {!Array<!Object>} (GrDiffLine)
+     */
+    _linesFromRows(lineType, rows, offset, opt_intralineInfos) {
+      const grDiffHighlights = opt_intralineInfos ?
+        this._convertIntralineInfos(rows, opt_intralineInfos) : undefined;
+      return rows.map((row, i) => this._lineFromRow(
+          lineType, offset, offset, row, i, grDiffHighlights));
+    },
+
+    /**
+     * @param {string} type (GrDiffLine.Type)
+     * @param {number} offsetLeft
+     * @param {number} offsetRight
+     * @param {string} row
+     * @param {number} i
+     * @param {!Array<!Object>=} opt_highlights
+     * @return {!Object} (GrDiffLine)
+     */
+    _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) {
+      const line = new GrDiffLine(type);
+      line.text = row;
+      if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i;
+      if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i;
+      if (opt_highlights) {
+        line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
+      }
+      return line;
+    },
+
     _makeFileComments() {
       const line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = GrDiffLine.FILE;
@@ -349,84 +449,94 @@
       return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
     },
 
+
     /**
-     * In order to show comments out of the bounds of the selected context,
-     * treat them as separate chunks within the model so that the content (and
-     * context surrounding it) renders correctly.
-     * @param {?} content The diff content object. (has to be iterable)
-     * @return {!Object} A new diff content object with regions split up.
+     * Split chunks into smaller chunks of the same kind.
+     *
+     * This is done to prevent doing too much work on the main thread in one
+     * uninterrupted rendering step, which would make the browser unresponsive.
+     *
+     * Note that in the case of unmodified chunks, we only split chunks if the
+     * context is set to file (because otherwise they are split up further down
+     * the processing into the visible and hidden context), and only split it
+     * into 2 chunks, one max sized one and the rest (for reasons that are
+     * unclear to me).
+     *
+     * @param {!Array<!Defs.Chunk>} chunks Chunks as returned from the server
+     * @return {!Array<!Defs.Chunk>} Finer grained chunks.
      */
-    _splitCommonGroupsWithComments(content) {
-      const result = [];
-      let leftLineNum = 0;
-      let rightLineNum = 0;
+    _splitLargeChunks(chunks) {
+      const newChunks = [];
 
-      // If the context is set to "whole file", then break down the shared
-      // chunks so they can be rendered incrementally. Note: this is not enabled
-      // for any other context preference because manipulating the chunks in
-      // this way violates assumptions by the context grouper logic.
-      if (this.context === -1) {
-        const newContent = [];
-        for (const group of content) {
-          if (group.ab && group.ab.length > MAX_GROUP_SIZE * 2) {
-            // Split large shared groups in two, where the first is the maximum
-            // group size.
-            newContent.push({ab: group.ab.slice(0, MAX_GROUP_SIZE)});
-            newContent.push({ab: group.ab.slice(MAX_GROUP_SIZE)});
-          } else {
-            newContent.push(group);
+      for (const chunk of chunks) {
+        if (!chunk.ab) {
+          for (const subChunk of this._breakdownChunk(chunk)) {
+            newChunks.push(subChunk);
           }
-        }
-        content = newContent;
-      }
-
-      // For each section in the diff.
-      for (let i = 0; i < content.length; i++) {
-        // If it isn't a common group, append it as-is and update line numbers.
-        if (!content[i].ab) {
-          if (content[i].a) {
-            leftLineNum += content[i].a.length;
-          }
-          if (content[i].b) {
-            rightLineNum += content[i].b.length;
-          }
-
-          for (const group of this._breakdownGroup(content[i])) {
-            result.push(group);
-          }
-
           continue;
         }
 
-        const chunk = content[i].ab;
-        let currentChunk = {ab: []};
+        // If the context is set to "whole file", then break down the shared
+        // chunks so they can be rendered incrementally. Note: this is not
+        // enabled for any other context preference because manipulating the
+        // chunks in this way violates assumptions by the context grouper logic.
+        if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
+          // Split large shared chunks in two, where the first is the maximum
+          // group size.
+          newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
+          newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
+        } else {
+          newChunks.push(chunk);
+        }
+      }
+      return newChunks;
+    },
 
-        // For each line in the common group.
-        for (const subChunk of chunk) {
-          leftLineNum++;
-          rightLineNum++;
+    /**
+     * In order to show key locations, such as comments, out of the bounds of
+     * the selected context, treat them as separate chunks within the model so
+     * that the content (and context surrounding it) renders correctly.
+     * @param {!Array<!Object>} chunks DiffContents as returned from server.
+     * @return {!Array<!Object>} Finer grained DiffContents.
+     */
+    _splitCommonChunksWithKeyLocations(chunks) {
+      const result = [];
+      let leftLineNum = 1;
+      let rightLineNum = 1;
 
-          // If this line should not be collapsed.
-          if (this.keyLocations[DiffSide.LEFT][leftLineNum] ||
-              this.keyLocations[DiffSide.RIGHT][rightLineNum]) {
-            // If any lines have been accumulated into the chunk leading up to
-            // this non-collapse line, then add them as a chunk and start a new
-            // one.
-            if (currentChunk.ab && currentChunk.ab.length > 0) {
-              result.push(currentChunk);
-              currentChunk = {ab: []};
-            }
-
-            // Add the non-collapse line as its own chunk.
-            result.push({ab: [subChunk]});
-          } else {
-            // Append the current line to the current chunk.
-            currentChunk.ab.push(subChunk);
+      for (const chunk of chunks) {
+        // If it isn't a common chunk, append it as-is and update line numbers.
+        if (!chunk.ab && !chunk.common) {
+          if (chunk.a) {
+            leftLineNum += chunk.a.length;
           }
+          if (chunk.b) {
+            rightLineNum += chunk.b.length;
+          }
+          result.push(chunk);
+          continue;
         }
 
-        if (currentChunk.ab && currentChunk.ab.length > 0) {
-          result.push(currentChunk);
+        if (chunk.common && chunk.a.length != chunk.b.length) {
+          throw new Error(
+            'DiffContent with common=true must always have equal length');
+        }
+        const numLines = this._commonChunkLength(chunk);
+        const chunkEnds = this._findChunkEndsAtKeyLocations(
+            numLines, leftLineNum, rightLineNum);
+        leftLineNum += numLines;
+        rightLineNum += numLines;
+
+        if (chunk.ab) {
+          result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
+              .map(({lines, keyLocation}) =>
+                  Object.assign({}, chunk, {ab: lines, keyLocation})));
+        } else if (chunk.common) {
+          const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
+          const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
+          result.push(...aChunks.map(({lines, keyLocation}, i) =>
+            Object.assign(
+                {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation})));
         }
       }
 
@@ -434,52 +544,84 @@
     },
 
     /**
-     * The `highlights` array consists of a list of <skip length, mark length>
-     * pairs, where the skip length is the number of characters between the
-     * end of the previous edit and the start of this edit, and the mark
-     * length is the number of edited characters following the skip. The start
-     * of the edits is from the beginning of the related diff content lines.
-     *
-     * Note that the implied newline character at the end of each line is
-     * included in the length calculation, and thus it is possible for the
-     * edits to span newlines.
-     *
-     * A line highlight object consists of three fields:
-     * - contentIndex: The index of the diffChunk `content` field (the line
-     *   being referred to).
-     * - startIndex: Where the highlight should begin.
-     * - endIndex: (optional) Where the highlight should end. If omitted, the
-     *   highlight is meant to be a continuation onto the next line.
+     * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the
+     *   new chunk ends, including whether it's a key location.
      */
-    _normalizeIntralineHighlights(content, highlights) {
-      let contentIndex = 0;
+    _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) {
+      const result = [];
+      let lastChunkEnd = 0;
+      for (let i=0; i<numLines; i++) {
+        // If this line should not be collapsed.
+        if (this.keyLocations[DiffSide.LEFT][leftOffset + i] ||
+            this.keyLocations[DiffSide.RIGHT][rightOffset + i]) {
+          // If any lines have been accumulated into the chunk leading up to
+          // this non-collapse line, then add them as a chunk and start a new
+          // one.
+          if (i > lastChunkEnd) {
+            result.push({offset: i, keyLocation: false});
+            lastChunkEnd = i;
+          }
+
+          // Add the non-collapse line as its own chunk.
+          result.push({offset: i + 1, keyLocation: true});
+        }
+      }
+
+      if (numLines > lastChunkEnd) {
+        result.push({offset: numLines, keyLocation: false});
+      }
+
+      return result;
+    },
+
+    _splitAtChunkEnds(lines, chunkEnds) {
+      const result = [];
+      let lastChunkEndOffset = 0;
+      for (const {offset, keyLocation} of chunkEnds) {
+        result.push(
+            {lines: lines.slice(lastChunkEndOffset, offset), keyLocation});
+        lastChunkEndOffset = offset;
+      }
+      return result;
+    },
+
+    /**
+     * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
+     * for rendering.
+     *
+     * @param {!Array<string>} rows
+     * @param {!Array<!Defs.IntralineInfo>} intralineInfos
+     * @return {!Array<!Object>} (GrDiffLine.Highlight)
+     */
+    _convertIntralineInfos(rows, intralineInfos) {
+      let rowIndex = 0;
       let idx = 0;
       const normalized = [];
-      for (const hl of highlights) {
-        let line = content[contentIndex] + '\n';
+      for (const [skipLength, markLength] of intralineInfos) {
+        let line = rows[rowIndex] + '\n';
         let j = 0;
-        while (j < hl[0]) {
+        while (j < skipLength) {
           if (idx === line.length) {
             idx = 0;
-            line = content[++contentIndex] + '\n';
+            line = rows[++rowIndex] + '\n';
             continue;
           }
           idx++;
           j++;
         }
         let lineHighlight = {
-          contentIndex,
+          contentIndex: rowIndex,
           startIndex: idx,
         };
 
         j = 0;
-        while (line && j < hl[1]) {
+        while (line && j < markLength) {
           if (idx === line.length) {
             idx = 0;
-            line = content[++contentIndex] + '\n';
+            line = rows[++rowIndex] + '\n';
             normalized.push(lineHighlight);
             lineHighlight = {
-              contentIndex,
+              contentIndex: rowIndex,
               startIndex: idx,
             };
             continue;
@@ -495,31 +637,31 @@
 
     /**
      * If a group is an addition or a removal, break it down into smaller groups
-     * of that type using the MAX_GROUP_SIZE. If the group is a shared section
+     * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
      * or a delta it is returned as the single element of the result array.
-     * @param {!Object} group A raw chunk from a diff response.
+     * @param {!Defs.Chunk} chunk A raw chunk from a diff response.
      * @return {!Array<!Array<!Object>>}
      */
-    _breakdownGroup(group) {
+    _breakdownChunk(chunk) {
       let key = null;
-      if (group.a && !group.b) {
+      if (chunk.a && !chunk.b) {
         key = 'a';
-      } else if (group.b && !group.a) {
+      } else if (chunk.b && !chunk.a) {
         key = 'b';
-      } else if (group.ab) {
+      } else if (chunk.ab) {
         key = 'ab';
       }
 
-      if (!key) { return [group]; }
+      if (!key) { return [chunk]; }
 
-      return this._breakdown(group[key], MAX_GROUP_SIZE)
-          .map(subgroupLines => {
-            const subGroup = {};
-            subGroup[key] = subgroupLines;
-            if (group.due_to_rebase) {
-              subGroup.due_to_rebase = true;
+      return this._breakdown(chunk[key], MAX_GROUP_SIZE)
+          .map(subChunkLines => {
+            const subChunk = {};
+            subChunk[key] = subChunkLines;
+            if (chunk.due_to_rebase) {
+              subChunk.due_to_rebase = true;
             }
-            return subGroup;
+            return subChunk;
           });
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
index 7ccd9f8..c04b066 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-processor test</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-processor.html">
 
@@ -60,7 +62,7 @@
         element.context = 4;
       });
 
-      test('process loaded content', done => {
+      test('process loaded content', () => {
         const content = [
           {
             ab: [
@@ -86,7 +88,7 @@
           },
         ];
 
-        element.process(content).then(() => {
+        return element.process(content).then(() => {
           const groups = element.groups;
 
           assert.equal(groups.length, 4);
@@ -139,31 +141,15 @@
             'everyone pretend to shower.',
             'Fry: Same as every day. Got it.',
           ]);
-
-          done();
         });
       });
 
-      test('insert context groups', done => {
+      test('first group is for file', () => {
         const content = [
-          {ab: []},
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: []},
-          {b: ['elgoog elgoog elgoog']},
-          {ab: []},
+          {b: ['foo']},
         ];
-        for (let i = 0; i < 100; i++) {
-          content[0].ab.push('all work and no play make jack a dull boy');
-          content[4].ab.push('all work and no play make jill a dull girl');
-        }
-        for (let i = 0; i < 5; i++) {
-          content[2].ab.push('no tv and no beer make homer go crazy');
-        }
 
-        const context = 10;
-        element.context = context;
-
-        element.process(content).then(() => {
+        return element.process(content).then(() => {
           const groups = element.groups;
 
           assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
@@ -171,107 +157,278 @@
           assert.equal(groups[0].lines[0].text, '');
           assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
           assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
-
-          assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
-          assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
-          for (const l of groups[1].lines[0].contextGroup.lines) {
-            assert.equal(l.text, content[0].ab[0]);
-          }
-
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, context);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, content[0].ab[0]);
-          }
-
-          assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[3].lines.length, 1);
-          assert.equal(groups[3].removes.length, 1);
-          assert.equal(groups[3].removes[0].text,
-              'all work and no play make andybons a dull boy');
-
-          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[4].lines.length, 5);
-          for (const l of groups[4].lines) {
-            assert.equal(l.text, content[2].ab[0]);
-          }
-
-          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[5].lines.length, 1);
-          assert.equal(groups[5].adds.length, 1);
-          assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
-
-          assert.equal(groups[6].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[6].lines.length, context);
-          for (const l of groups[6].lines) {
-            assert.equal(l.text, content[4].ab[0]);
-          }
-
-          assert.equal(groups[7].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
-          assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
-          for (const l of groups[7].lines[0].contextGroup.lines) {
-            assert.equal(l.text, content[4].ab[0]);
-          }
-
-          done();
         });
       });
 
-      test('insert context groups', done => {
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: []},
-          {b: ['elgoog elgoog elgoog']},
-        ];
-        for (let i = 0; i < 50; i++) {
-          content[1].ab.push('no tv and no beer make homer go crazy');
-        }
+      suite('context groups', () => {
+        test('at the beginning, larger than context', () => {
+          element.context = 10;
+          const content = [
+            {ab: new Array(100)
+                .fill('all work and no play make jack a dull boy')},
+            {a: ['all work and no play make andybons a dull boy']},
+          ];
 
-        const context = 10;
-        element.context = context;
+          return element.process(content).then(() => {
+            const groups = element.groups;
 
-        element.process(content).then(() => {
-          const groups = element.groups;
+            // group[0] is the file group
 
-          assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[0].lines.length, 1);
-          assert.equal(groups[0].lines[0].text, '');
-          assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
-          assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+            assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+            assert.instanceOf(groups[1].lines[0].contextGroups[0], GrDiffGroup);
+            assert.equal(groups[1].lines[0].contextGroups[0].lines.length, 90);
+            for (const l of groups[1].lines[0].contextGroups[0].lines) {
+              assert.equal(l.text, 'all work and no play make jack a dull boy');
+            }
 
-          assert.equal(groups[1].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[1].lines.length, 1);
-          assert.equal(groups[1].removes.length, 1);
-          assert.equal(groups[1].removes[0].text,
-              'all work and no play make andybons a dull boy');
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 10);
+            for (const l of groups[2].lines) {
+              assert.equal(l.text, 'all work and no play make jack a dull boy');
+            }
+          });
+        });
 
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, context);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, content[1].ab[0]);
-          }
+        test('at the beginning, smaller than context', () => {
+          element.context = 10;
+          const content = [
+            {ab: new Array(5)
+                .fill('all work and no play make jack a dull boy')},
+            {a: ['all work and no play make andybons a dull boy']},
+          ];
 
-          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
-          assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
-          for (const l of groups[3].lines[0].contextGroup.lines) {
-            assert.equal(l.text, content[1].ab[0]);
-          }
+          return element.process(content).then(() => {
+            const groups = element.groups;
 
-          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[4].lines.length, context);
-          for (const l of groups[4].lines) {
-            assert.equal(l.text, content[1].ab[0]);
-          }
+            // group[0] is the file group
 
-          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[5].lines.length, 1);
-          assert.equal(groups[5].adds.length, 1);
-          assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
+            assert.equal(groups[1].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[1].lines.length, 5);
+            for (const l of groups[1].lines) {
+              assert.equal(l.text, 'all work and no play make jack a dull boy');
+            }
+          });
+        });
 
-          done();
+        test('at the end, larger than context', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(100)
+                .fill('all work and no play make jill a dull girl')},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 10);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+            assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
+            assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 90);
+            for (const l of groups[3].lines[0].contextGroups[0].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
+        });
+
+        test('at the end, smaller than context', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(5)
+                .fill('all work and no play make jill a dull girl')},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 5);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
+        });
+
+        test('for interleaved ab and common: true chunks', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(3)
+                .fill('all work and no play make jill a dull girl')},
+            {
+              a: new Array(3).fill(
+                  'all work and no play make jill a dull girl'),
+              b: new Array(3).fill(
+                  '  all work and no play make jill a dull girl'),
+              common: true,
+            },
+            {ab: new Array(3)
+                .fill('all work and no play make jill a dull girl')},
+            {
+              a: new Array(3).fill(
+                  'all work and no play make jill a dull girl'),
+              b: new Array(3).fill(
+                  '  all work and no play make jill a dull girl'),
+              common: true,
+            },
+            {ab: new Array(3)
+                .fill('all work and no play make jill a dull girl')},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            // The first three interleaved chunks are completely shown because
+            // they are part of the context (3 * 3 <= 10)
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 3);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
+            assert.equal(groups[3].lines.length, 6);
+            assert.equal(groups[3].adds.length, 3);
+            assert.equal(groups[3].removes.length, 3);
+            for (const l of groups[3].removes) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+            for (const l of groups[3].adds) {
+              assert.equal(
+                  l.text, '  all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[4].lines.length, 3);
+            for (const l of groups[4].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            // The next chunk is partially shown, so it results in two groups
+
+            assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+            assert.equal(groups[5].lines.length, 2);
+            assert.equal(groups[5].adds.length, 1);
+            assert.equal(groups[5].removes.length, 1);
+            for (const l of groups[5].removes) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+            for (const l of groups[5].adds) {
+              assert.equal(
+                  l.text, '  all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[6].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+            assert.equal(groups[6].lines[0].contextGroups.length, 2);
+
+            assert.equal(groups[6].lines[0].contextGroups[0].lines.length, 4);
+            assert.equal(groups[6].lines[0].contextGroups[0].removes.length, 2);
+            assert.equal(groups[6].lines[0].contextGroups[0].adds.length, 2);
+            for (const l of groups[6].lines[0].contextGroups[0].removes) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+            for (const l of groups[6].lines[0].contextGroups[0].adds) {
+              assert.equal(
+                  l.text, '  all work and no play make jill a dull girl');
+            }
+
+            // The final chunk is completely hidden
+            assert.equal(
+                groups[6].lines[0].contextGroups[1].type,
+                GrDiffGroup.Type.BOTH);
+            assert.equal(groups[6].lines[0].contextGroups[1].lines.length, 3);
+            for (const l of groups[6].lines[0].contextGroups[1].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
+        });
+
+        test('in the middle, larger than context', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(100)
+                .fill('all work and no play make jill a dull girl')},
+            {a: ['all work and no play make andybons a dull boy']},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 10);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+            assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
+            assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 80);
+            for (const l of groups[3].lines[0].contextGroups[0].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+
+            assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[4].lines.length, 10);
+            for (const l of groups[4].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
+        });
+
+        test('in the middle, smaller than context', () => {
+          element.context = 10;
+          const content = [
+            {a: ['all work and no play make andybons a dull boy']},
+            {ab: new Array(5)
+                .fill('all work and no play make jill a dull girl')},
+            {a: ['all work and no play make andybons a dull boy']},
+          ];
+
+          return element.process(content).then(() => {
+            const groups = element.groups;
+
+            // group[0] is the file group
+            // group[1] is the "a" group
+
+            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+            assert.equal(groups[2].lines.length, 5);
+            for (const l of groups[2].lines) {
+              assert.equal(
+                  l.text, 'all work and no play make jill a dull girl');
+            }
+          });
         });
       });
 
@@ -280,7 +437,6 @@
           left: {1: true},
           right: {10: true},
         };
-        const lineNums = {left: 0, right: 0};
 
         const content = [
           {
@@ -304,10 +460,11 @@
           },
         ];
         const result =
-            element._splitCommonGroupsWithComments(content, lineNums);
+            element._splitCommonChunksWithKeyLocations(content);
         assert.deepEqual(result, [
           {
             ab: ['Copyright (C) 2015 The Android Open Source Project'],
+            keyLocation: true,
           },
           {
             ab: [
@@ -321,10 +478,12 @@
               '',
               'Unless required by applicable law or agreed to in writing, ',
             ],
+            keyLocation: false,
           },
           {
             ab: [
               'software distributed under the License is distributed on an '],
+            keyLocation: true,
           },
           {
             ab: [
@@ -333,33 +492,33 @@
               'language governing permissions and limitations under the ' +
                   'License.',
             ],
+            keyLocation: false,
           },
         ]);
       });
 
-      test('breaks-down shared chunks w/ whole-file', () => {
+      test('breaks down shared chunks w/ whole-file', () => {
         const size = 120 * 2 + 5;
-        const lineNums = {left: 0, right: 0};
         const content = [{
           ab: _.times(size, () => { return `${Math.random()}`; }),
         }];
         element.context = -1;
-        const result =
-            element._splitCommonGroupsWithComments(content, lineNums);
+        const result = element._splitLargeChunks(content);
         assert.equal(result.length, 2);
         assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
         assert.deepEqual(result[1].ab, content[0].ab.slice(120));
       });
 
-      test('does not break-down shared chunks w/ context', () => {
-        const lineNums = {left: 0, right: 0};
+      test('does not break-down common chunks w/ context', () => {
         const content = [{
           ab: _.times(75, () => { return `${Math.random()}`; }),
         }];
         element.context = 4;
         const result =
-            element._splitCommonGroupsWithComments(content, lineNums);
-        assert.deepEqual(result, content);
+            element._splitCommonChunksWithKeyLocations(content);
+        assert.equal(result.length, 1);
+        assert.deepEqual(result[0].ab, content[0].ab);
+        assert.isFalse(result[0].keyLocation);
       });
 
       test('intraline normalization', () => {
@@ -375,7 +534,7 @@
           [31, 34], [42, 26],
         ];
 
-        let results = element._normalizeIntralineHighlights(content,
+        let results = element._convertIntralineInfos(content,
             highlights);
         assert.deepEqual(results, [
           {
@@ -416,7 +575,7 @@
           [12, 67],
           [14, 29],
         ];
-        results = element._normalizeIntralineHighlights(content, highlights);
+        results = element._convertIntralineInfos(content, highlights);
         assert.deepEqual(results, [
           {
             contentIndex: 0,
@@ -457,10 +616,13 @@
         sandbox.stub(element, 'async');
         element._isScrolling = true;
         element.process(content);
+        // Just the files group - no more processing during scrolling.
         assert.equal(element.groups.length, 1);
+
         element._isScrolling = false;
         element.process(content);
-        assert.equal(element.groups.length, 33);
+        // More groups have been processed. How many does not matter here.
+        assert.isAtLeast(element.groups.length, 2);
       });
 
       test('image diffs', () => {
@@ -479,6 +641,198 @@
         assert.equal(element.groups[0].lines.length, 1);
       });
 
+      suite('_processNext', () => {
+        let rows;
+
+        setup(() => {
+          rows = loremIpsum.split(' ');
+        });
+
+        test('WHOLE_FILE', () => {
+          element.context = WHOLE_FILE;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 1,
+          };
+          const chunks = [
+            {a: ['foo']},
+            {ab: rows},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+
+          // Results in one, uncollapsed group with all rows.
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
+          assert.equal(result.groups[0].lines.length, rows.length);
+
+          // Line numbers are set correctly.
+          assert.equal(
+              result.groups[0].lines[0].beforeNumber,
+              state.lineNums.left + 1);
+          assert.equal(
+              result.groups[0].lines[0].afterNumber,
+              state.lineNums.right + 1);
+
+          assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
+              state.lineNums.left + rows.length);
+          assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
+              state.lineNums.right + rows.length);
+        });
+
+        test('with context', () => {
+          element.context = 10;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 1,
+          };
+          const chunks = [
+            {a: ['foo']},
+            {ab: rows},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+          const expectedCollapseSize = rows.length - 2 * element.context;
+
+          assert.equal(result.groups.length, 3, 'Results in three groups');
+
+          // The first and last are uncollapsed context, whereas the middle has
+          // a single context-control line.
+          assert.equal(result.groups[0].lines.length, element.context);
+          assert.equal(result.groups[1].lines.length, 1);
+          assert.equal(result.groups[2].lines.length, element.context);
+
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+              expectedCollapseSize);
+        });
+
+        test('first', () => {
+          element.context = 10;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 0,
+          };
+          const chunks = [
+            {ab: rows},
+            {a: ['foo']},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+          const expectedCollapseSize = rows.length - element.context;
+
+          assert.equal(result.groups.length, 2, 'Results in two groups');
+
+          // Only the first group is collapsed.
+          assert.equal(result.groups[0].lines.length, 1);
+          assert.equal(result.groups[1].lines.length, element.context);
+
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
+              expectedCollapseSize);
+        });
+
+        test('few-rows', () => {
+          // Only ten rows.
+          rows = rows.slice(0, 10);
+          element.context = 10;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 0,
+          };
+          const chunks = [
+            {ab: rows},
+            {a: ['foo']},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+
+          // Results in one uncollapsed group with all rows.
+          assert.equal(result.groups.length, 1, 'Results in one group');
+          assert.equal(result.groups[0].lines.length, rows.length);
+        });
+
+        test('no single line collapse', () => {
+          rows = rows.slice(0, 7);
+          element.context = 3;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 1,
+          };
+          const chunks = [
+            {a: ['foo']},
+            {ab: rows},
+            {a: ['bar']},
+          ];
+          const result = element._processNext(state, chunks);
+
+          // Results in one uncollapsed group with all rows.
+          assert.equal(result.groups.length, 1, 'Results in one group');
+          assert.equal(result.groups[0].lines.length, rows.length);
+        });
+
+        suite('with key location', () => {
+          let state;
+          let chunks;
+
+          setup(() => {
+            state = {
+              lineNums: {left: 10, right: 100},
+            };
+            element.context = 10;
+            chunks = [
+              {ab: rows},
+              {ab: ['foo'], keyLocation: true},
+              {ab: rows},
+            ];
+          });
+
+          test('context before', () => {
+            state.chunkIndex = 0;
+            const result = element._processNext(state, chunks);
+
+            // The first chunk is split into two groups:
+            // 1) A context-control, hiding everything but the context before
+            //    the key location.
+            // 2) The context before the key location.
+            // The key location is not processed in this call to _processNext
+            assert.equal(result.groups.length, 2);
+            assert.equal(result.groups[0].lines.length, 1);
+            // The collapsed group has the hidden lines as its context group.
+            assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
+                rows.length - element.context);
+            assert.equal(result.groups[1].lines.length, element.context);
+          });
+
+          test('key location itself', () => {
+            state.chunkIndex = 1;
+            const result = element._processNext(state, chunks);
+
+            // The second chunk results in a single group, that is just the
+            // line with the key location
+            assert.equal(result.groups.length, 1);
+            assert.equal(result.groups[0].lines.length, 1);
+            assert.equal(result.lineDelta.left, 1);
+            assert.equal(result.lineDelta.right, 1);
+          });
+
+          test('context after', () => {
+            state.chunkIndex = 2;
+            const result = element._processNext(state, chunks);
+
+            // The last chunk is split into two groups:
+            // 1) The context after the key location.
+            // 1) A context-control, hiding everything but the context after the
+            //    key location.
+            assert.equal(result.groups.length, 2);
+            assert.equal(result.groups[0].lines.length, element.context);
+            assert.equal(result.groups[1].lines.length, 1);
+            // The collapsed group has the hidden lines as its context group.
+            assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+                rows.length - element.context);
+          });
+        });
+      });
 
       suite('gr-diff-processor helpers', () => {
         let rows;
@@ -487,90 +841,10 @@
           rows = loremIpsum.split(' ');
         });
 
-        test('_sharedGroupsFromRows WHOLE_FILE', () => {
-          const context = WHOLE_FILE;
-          const lineNumbers = {left: 10, right: 100};
-          const result = element._sharedGroupsFromRows(
-              rows, context, lineNumbers.left, lineNumbers.right, null);
-
-          // Results in one, uncollapsed group with all rows.
-          assert.equal(result.length, 1);
-          assert.equal(result[0].type, GrDiffGroup.Type.BOTH);
-          assert.equal(result[0].lines.length, rows.length);
-
-          // Line numbers are set correctly.
-          assert.equal(result[0].lines[0].beforeNumber, lineNumbers.left + 1);
-          assert.equal(result[0].lines[0].afterNumber, lineNumbers.right + 1);
-
-          assert.equal(result[0].lines[rows.length - 1].beforeNumber,
-              lineNumbers.left + rows.length);
-          assert.equal(result[0].lines[rows.length - 1].afterNumber,
-              lineNumbers.right + rows.length);
-        });
-
-        test('_sharedGroupsFromRows context', () => {
-          const context = 10;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100, null);
-          const expectedCollapseSize = rows.length - 2 * context;
-
-          assert.equal(result.length, 3, 'Results in three groups');
-
-          // The first and last are uncollapsed context, whereas the middle has
-          // a single context-control line.
-          assert.equal(result[0].lines.length, context);
-          assert.equal(result[1].lines.length, 1);
-          assert.equal(result[2].lines.length, context);
-
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result[1].lines[0].contextGroup.lines.length,
-              expectedCollapseSize);
-        });
-
-        test('_sharedGroupsFromRows first', () => {
-          const context = 10;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100, 'first');
-          const expectedCollapseSize = rows.length - context;
-
-          assert.equal(result.length, 2, 'Results in two groups');
-
-          // Only the first group is collapsed.
-          assert.equal(result[0].lines.length, 1);
-          assert.equal(result[1].lines.length, context);
-
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result[0].lines[0].contextGroup.lines.length,
-              expectedCollapseSize);
-        });
-
-        test('_sharedGroupsFromRows few-rows', () => {
-          // Only ten rows.
-          rows = rows.slice(0, 10);
-          const context = 10;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100, 'first');
-
-          // Results in one uncollapsed group with all rows.
-          assert.equal(result.length, 1, 'Results in one group');
-          assert.equal(result[0].lines.length, rows.length);
-        });
-
-        test('_sharedGroupsFromRows no single line collapse', () => {
-          rows = rows.slice(0, 7);
-          const context = 3;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100);
-
-          // Results in one uncollapsed group with all rows.
-          assert.equal(result.length, 1, 'Results in one group');
-          assert.equal(result[0].lines.length, rows.length);
-        });
-
-        test('_deltaLinesFromRows', () => {
+        test('_linesFromRows', () => {
           const startLineNum = 10;
-          let result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
-              startLineNum);
+          let result = element._linesFromRows(GrDiffLine.Type.ADD, rows,
+              startLineNum + 1);
 
           assert.equal(result.length, rows.length);
           assert.equal(result[0].type, GrDiffLine.Type.ADD);
@@ -580,8 +854,8 @@
               startLineNum + rows.length);
           assert.notOk(result[result.length - 1].beforeNumber);
 
-          result = element._deltaLinesFromRows(GrDiffLine.Type.REMOVE, rows,
-              startLineNum);
+          result = element._linesFromRows(GrDiffLine.Type.REMOVE, rows,
+              startLineNum + 1);
 
           assert.equal(result.length, rows.length);
           assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
@@ -594,19 +868,19 @@
       });
 
       suite('_breakdown*', () => {
-        test('_breakdownGroup breaks down additions', () => {
+        test('_breakdownChunk breaks down additions', () => {
           sandbox.spy(element, '_breakdown');
           const chunk = {b: ['blah', 'blah', 'blah']};
-          const result = element._breakdownGroup(chunk);
+          const result = element._breakdownChunk(chunk);
           assert.deepEqual(result, [chunk]);
           assert.isTrue(element._breakdown.called);
         });
 
-        test('_breakdownGroup keeps due_to_rebase for broken down additions',
+        test('_breakdownChunk keeps due_to_rebase for broken down additions',
             () => {
               sandbox.spy(element, '_breakdown');
               const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-              const result = element._breakdownGroup(chunk);
+              const result = element._breakdownChunk(chunk);
               for (const subResult of result) {
                 assert.isTrue(subResult.due_to_rebase);
               }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
index f9822f2..4679b7d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/dom-util-behavior/dom-util-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 35e2fe1..072a0d7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -32,6 +32,7 @@
 
   Polymer({
     is: 'gr-diff-selection',
+    _legacyUndefinedCheck: true,
 
     properties: {
       diff: Object,
@@ -83,7 +84,7 @@
         targetClasses.push(SelectionClass.BLAME);
       } else {
         const commentSelected =
-            this._elementDescendedFromClass(e.target, 'gr-diff-comment');
+            this._elementDescendedFromClass(e.target, 'gr-comment');
         const side = this.diffBuilder.getSideByLineEl(lineEl);
 
         targetClasses.push(side === 'left' ?
@@ -179,10 +180,19 @@
       const startLineEl =
           this.diffBuilder.getLineElByChild(range.startContainer);
       const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+      // Happens when triple click in side-by-side mode with other side empty.
+      const endsAtOtherEmptySide = !endLineEl &&
+          range.endOffset === 0 &&
+          range.endContainer.nodeName === 'TD' &&
+          (range.endContainer.classList.contains('left') ||
+           range.endContainer.classList.contains('right'));
       const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
-      const endLineNum = endLineEl === null ?
-          undefined :
-          parseInt(endLineEl.getAttribute('data-value'), 10);
+      let endLineNum;
+      if (endsAtOtherEmptySide) {
+        endLineNum = startLineNum + 1;
+      } else if (endLineEl) {
+        endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
+      }
 
       return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
           range.endOffset, side);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index f34429b8..0f5c6dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-selection</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-selection.html">
 
@@ -36,7 +38,7 @@
           <td class="content">
             <div class="contentText" data-side="left">ba ba</div>
             <div data-side="left">
-              <div class="gr-diff-comment-thread">
+              <div class="comment-thread">
                 <div class="gr-formatted-text message">
                   <span id="output" class="gr-linked-text">This is a comment</span>
                 </div>
@@ -58,7 +60,7 @@
           <td class="content">
             <div class="contentText" data-side="right">more more more</div>
             <div data-side="right">
-              <div class="gr-diff-comment-thread">
+              <div class="comment-thread">
                 <div class="gr-formatted-text message">
                   <span id="output" class="gr-linked-text">This is a comment on the right</span>
                 </div>
@@ -72,7 +74,7 @@
           <td class="content">
             <div class="contentText" data-side="left">ga ga</div>
             <div data-side="left">
-              <div class="gr-diff-comment-thread">
+              <div class="comment-thread">
                 <div class="gr-formatted-text message">
                   <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
                 </div>
@@ -87,7 +89,7 @@
           <td class="content">
             <div class="contentText" data-side="left">ga ga</div>
             <div data-side="left">
-              <div class="gr-diff-comment-thread">
+              <div class="comment-thread">
                 <textarea data-side="right">test for textarea copying</textarea>
               </div>
             </div>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index edee1ae..c992a51 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
@@ -15,12 +15,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
@@ -34,9 +35,9 @@
 <link rel="import" href="../../shared/revision-info/revision-info.html">
 <link rel="import" href="../gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
+<link rel="import" href="../gr-diff-host/gr-diff-host.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-preferences-dialog/gr-diff-preferences-dialog.html">
 <link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
 
 <dom-module id="gr-diff-view">
@@ -171,7 +172,6 @@
         }
         .fullFileName {
           display: block;
-          font-size: var(--font-size-small);
           font-style: italic;
           min-width: 50%;
           padding: 0 .1em;
@@ -185,7 +185,7 @@
         .mobileNavLink {
           color: var(--primary-text-color);
           font-size: 1.5rem;
-          font-family: var(--font-family-bold);
+          font-weight: var(--font-weight-bold);
           text-decoration: none;
         }
         .mobileNavLink:not([href]) {
@@ -270,7 +270,7 @@
             <a
               class="downloadLink"
               download
-              href$="[[_computeDownloadLink(_changeNum, _patchRange, _path)]]">
+              href$="[[_computeDownloadLink(_change.project, _changeNum, _patchRange, _path)]]">
               Download
             </a>
           </span>
@@ -287,11 +287,11 @@
             <span>Diff view:</span>
             <gr-diff-mode-selector
                 id="modeSelect"
-                save-on-change="[[_loggedIn]]"
+                save-on-change="[[!_diffPrefsDisabled]]"
                 mode="{{changeViewState.diffMode}}"></gr-diff-mode-selector>
           </div>
           <span id="diffPrefsContainer"
-              hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]" hidden>
+              hidden$="[[_computePrefsButtonHidden(_prefs, _diffPrefsDisabled)]]" hidden>
             <span class="preferences desktop">
               <gr-button
                   link
@@ -304,7 +304,9 @@
           <gr-endpoint-decorator name="annotation-toggler">
             <span hidden id="annotation-span">
               <label for="annotation-checkbox" id="annotation-label"></label>
-              <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+              <iron-input type="checkbox" disabled>
+                <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+              </iron-input>
             </span>
           </gr-endpoint-decorator>
         </div>
@@ -321,8 +323,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)]]"
@@ -333,16 +335,17 @@
         patch-range="[[_patchRange]]"
         path="[[_path]]"
         prefs="[[_prefs]]"
-        project-config="[[_projectConfig]]"
         project-name="[[_change.project]]"
         view-mode="[[_diffMode]]"
         is-blame-loaded="{{_isBlameLoaded}}"
+        on-comment-anchor-tap="_onLineSelected"
         on-line-selected="_onLineSelected">
-    </gr-diff>
-    <gr-diff-preferences
-        id="diffPreferences"
-        prefs="{{_prefs}}"
-        local-prefs="{{_localPrefs}}"></gr-diff-preferences>
+    </gr-diff-host>
+    <gr-diff-preferences-dialog
+        id="diffPreferencesDialog"
+        diff-prefs="{{_prefs}}"
+        on-reload-diff-preference="_handleReloadingDiffPreference">
+    </gr-diff-preferences-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="cursor"></gr-diff-cursor>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 9c27bae..d37c97d 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
@@ -35,6 +35,7 @@
 
   Polymer({
     is: 'gr-diff-view',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -69,6 +70,14 @@
         value() { return {}; },
         observer: '_changeViewStateChanged',
       },
+      disableDiffPrefs: {
+        type: Boolean,
+        value: false,
+      },
+      _diffPrefsDisabled: {
+        type: Boolean,
+        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+      },
       /** @type {?} */
       _patchRange: Object,
       /** @type {?} */
@@ -161,6 +170,10 @@
         type: Object,
         computed: '_getRevisionInfo(_change)',
       },
+      _reviewedFiles: {
+        type: Object,
+        value: () => new Set(),
+      },
     },
 
     behaviors: [
@@ -177,21 +190,42 @@
     ],
 
     keyBindings: {
-      'esc': '_handleEscKey',
-      'shift+left': '_handleShiftLeftKey',
-      'shift+right': '_handleShiftRightKey',
-      'up k': '_handleUpKey',
-      'down j': '_handleDownKey',
-      'c': '_handleCKey',
-      '[': '_handleLeftBracketKey',
-      ']': '_handleRightBracketKey',
-      'n shift+n': '_handleNKey',
-      'p shift+p': '_handlePKey',
-      'a shift+a': '_handleAKey',
-      'u': '_handleUKey',
-      ',': '_handleCommaKey',
-      'm': '_handleMKey',
-      'r': '_handleRKey',
+      esc: '_handleEscKey',
+    },
+
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
+        [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
+        [this.Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+        [this.Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+        [this.Shortcut.NEXT_FILE_WITH_COMMENTS]:
+            '_handleNextLineOrFileWithComments',
+        [this.Shortcut.PREV_FILE_WITH_COMMENTS]:
+            '_handlePrevLineOrFileWithComments',
+        [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
+        [this.Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+        [this.Shortcut.NEXT_FILE]: '_handleNextFile',
+        [this.Shortcut.PREV_FILE]: '_handlePrevFile',
+        [this.Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+        [this.Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+        [this.Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+        [this.Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+        [this.Shortcut.OPEN_REPLY_DIALOG]:
+            '_handleOpenReplyDialogOrToggleLeftPane',
+        [this.Shortcut.TOGGLE_LEFT_PANE]:
+            '_handleOpenReplyDialogOrToggleLeftPane',
+        [this.Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+        [this.Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+        [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+        [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+        [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+        [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+
+        // Final two are actually handled by gr-comment-thread.
+        [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+        [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+      };
     },
 
     attached() {
@@ -199,7 +233,7 @@
         this._loggedIn = loggedIn;
       });
 
-      this.$.cursor.push('diffs', this.$.diff);
+      this.$.cursor.push('diffs', this.$.diffHost);
     },
 
     _getLoggedIn() {
@@ -233,7 +267,9 @@
     },
 
     _getDiffPreferences() {
-      return this.$.restAPI.getDiffPreferences();
+      return this.$.restAPI.getDiffPreferences().then(prefs => {
+        this._prefs = prefs;
+      });
     },
 
     _getPreferences() {
@@ -262,7 +298,7 @@
           this._patchRange.patchNum, this._path, reviewed);
     },
 
-    _handleRKey(e) {
+    _handleToggleFileReviewed(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -275,24 +311,24 @@
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this.$.diff.displayLine = false;
+      this.$.diffHost.displayLine = false;
     },
 
-    _handleShiftLeftKey(e) {
+    _handleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveLeft();
     },
 
-    _handleShiftRightKey(e) {
+    _handleRightPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveRight();
     },
 
-    _handleUpKey(e) {
+    _handlePrevLineOrFileWithComments(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (e.detail.keyboardEvent.shiftKey &&
           e.detail.keyboardEvent.keyCode === 75) { // 'K'
@@ -302,11 +338,11 @@
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this.$.diff.displayLine = true;
+      this.$.diffHost.displayLine = true;
       this.$.cursor.moveUp();
     },
 
-    _handleDownKey(e) {
+    _handleNextLineOrFileWithComments(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (e.detail.keyboardEvent.shiftKey &&
           e.detail.keyboardEvent.keyCode === 74) { // 'J'
@@ -316,7 +352,7 @@
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this.$.diff.displayLine = true;
+      this.$.diffHost.displayLine = true;
       this.$.cursor.moveDown();
     },
 
@@ -347,19 +383,19 @@
           this._patchRange.patchNum, this._patchRange.basePatchNum);
     },
 
-    _handleCKey(e) {
+    _handleNewComment(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);
       }
     },
 
-    _handleLeftBracketKey(e) {
+    _handlePrevFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.getKeyboardEvent(e).metaKey) { return; }
@@ -368,7 +404,7 @@
       this._navToFile(this._path, this._fileList, -1);
     },
 
-    _handleRightBracketKey(e) {
+    _handleNextFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.getKeyboardEvent(e).metaKey) { return; }
@@ -377,7 +413,7 @@
       this._navToFile(this._path, this._fileList, 1);
     },
 
-    _handleNKey(e) {
+    _handleNextChunkOrCommentThread(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -389,7 +425,7 @@
       }
     },
 
-    _handlePKey(e) {
+    _handlePrevChunkOrCommentThread(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -401,12 +437,12 @@
       }
     },
 
-    _handleAKey(e) {
+    _handleOpenReplyDialogOrToggleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
         e.preventDefault();
-        this.$.diff.toggleLeftDiff();
+        this.$.diffHost.toggleLeftDiff();
         return;
       }
 
@@ -419,7 +455,7 @@
       this._navToChangeView();
     },
 
-    _handleUKey(e) {
+    _handleUpToChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -430,12 +466,13 @@
     _handleCommaKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
+      if (this._diffPrefsDisabled) { return; }
 
       e.preventDefault();
-      this.$.diffPreferences.open();
+      this.$.diffPreferencesDialog.open();
     },
 
-    _handleMKey(e) {
+    _handleToggleDiffMode(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -535,10 +572,18 @@
       return {path: fileList[idx]};
     },
 
+    _getReviewedFiles(changeNum, patchNum) {
+      return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
+          .then(files => {
+            this._reviewedFiles = new Set(files);
+            return this._reviewedFiles;
+          });
+    },
+
     _getReviewedStatus(editMode, changeNum, patchNum, path) {
       if (editMode) { return Promise.resolve(false); }
-      return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
-          .then(files => files.includes(path));
+      return this._getReviewedFiles(changeNum, patchNum)
+          .then(files => files.has(path));
     },
 
     _paramsChanged(value) {
@@ -548,7 +593,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;
@@ -575,10 +620,7 @@
 
       const promises = [];
 
-      this._localPrefs = this.$.storage.getPreferences();
-      promises.push(this._getDiffPreferences().then(prefs => {
-        this._prefs = prefs;
-      }));
+      promises.push(this._getDiffPreferences());
 
       promises.push(this._getPreferences().then(prefs => {
         this._userPrefs = prefs;
@@ -620,8 +662,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();
       });
@@ -771,8 +813,8 @@
           (unresolvedString ? `${unresolvedString}` : '');
     },
 
-    _computePrefsButtonHidden(prefs, loggedIn) {
-      return !loggedIn || !prefs;
+    _computePrefsButtonHidden(prefs, prefsDisabled) {
+      return prefsDisabled || !prefs;
     },
 
     _handleFileChange(e) {
@@ -804,22 +846,7 @@
 
     _handlePrefsTap(e) {
       e.preventDefault();
-      this.$.diffPreferences.open();
-    },
-
-    _handlePrefsSave(e) {
-      e.stopPropagation();
-      const el = Polymer.dom(e).rootTarget;
-      el.disabled = true;
-      this.$.storage.savePreferences(this._localPrefs);
-      this._saveDiffPreferences().then(response => {
-        el.disabled = false;
-        if (!response.ok) { return response; }
-
-        this.$.prefsOverlay.close();
-      }).catch(err => {
-        el.disabled = false;
-      });
+      this.$.diffPreferencesDialog.open();
     },
 
     /**
@@ -863,8 +890,8 @@
       history.replaceState(null, '', url);
     },
 
-    _computeDownloadLink(changeNum, patchRange, path) {
-      let url = this.changeBaseURL(changeNum, patchRange.patchNum);
+    _computeDownloadLink(project, changeNum, patchRange, path) {
+      let url = this.changeBaseURL(project, changeNum, patchRange.patchNum);
       url += '/patch?zip&path=' + encodeURIComponent(path);
       return url;
     },
@@ -948,13 +975,13 @@
      */
     _toggleBlame() {
       if (this._isBlameLoaded) {
-        this.$.diff.clearBlame();
+        this.$.diffHost.clearBlame();
         return;
       }
 
       this._isBlameLoading = true;
       this.fire('show-alert', {message: MSG_LOADING_BLAME});
-      this.$.diff.loadBlame()
+      this.$.diffHost.loadBlame()
           .then(() => {
             this._isBlameLoading = false;
             this.fire('show-alert', {message: MSG_LOADED_BLAME});
@@ -987,5 +1014,29 @@
       }
       return '';
     },
+
+    _handleExpandAllDiffContext(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      this.$.diffHost.expandAllContext();
+    },
+
+    _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
+      return disableDiffPrefs || !loggedIn;
+    },
+
+    _handleNextUnreviewedFile(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      this._setReviewed(true);
+      // Ensure that the currently viewed file always appears in unreviewedFiles
+      // so we resolve the right "next" file.
+      const unreviewedFiles = this._fileList
+          .filter(file =>
+          (file === this._path || !this._reviewedFiles.has(file)));
+      this._navToFile(this._path, unreviewedFiles, 1);
+    },
+
+    _handleReloadingDiffPreference() {
+      this._getDiffPreferences();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 620286b..1fde41c 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
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-diff-view.html">
@@ -43,6 +45,32 @@
 
 <script>
   suite('gr-diff-view tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
+    kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
+    kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
+    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
+    kb.bindShortcut(kb.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+    kb.bindShortcut(kb.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
+    kb.bindShortcut(kb.Shortcut.SAVE_COMMENT, 'ctrl+s');
+    kb.bindShortcut(kb.Shortcut.NEXT_FILE, ']');
+    kb.bindShortcut(kb.Shortcut.PREV_FILE, '[');
+    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
+    kb.bindShortcut(kb.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
+    kb.bindShortcut(kb.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+    kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+    kb.bindShortcut(kb.Shortcut.UP_TO_CHANGE, 'u');
+    kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
+    kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
+    kb.bindShortcut(kb.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+
     let element;
     let sandbox;
 
@@ -73,7 +101,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 +117,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);
     });
@@ -109,6 +138,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       element.changeViewState.selectedFileIndex = 1;
+      element._loggedIn = true;
 
       const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
       const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
@@ -145,12 +175,16 @@
       assert.isTrue(element._loading);
 
       const showPrefsStub =
-          sandbox.stub(element.$.diffPreferences.$.prefsOverlay, 'open',
+          sandbox.stub(element.$.diffPreferencesDialog, 'open',
               () => Promise.resolve());
 
       MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
+      element.disableDiffPrefs = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert(showPrefsStub.calledOnce);
+
       let scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
@@ -168,7 +202,7 @@
       MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
       assert(scrollStub.calledOnce);
 
-      const computeContainerClassStub = sandbox.stub(element.$.diff,
+      const computeContainerClassStub = sandbox.stub(element.$.diffHost.$.diff,
           '_computeContainerClass');
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
@@ -188,6 +222,13 @@
       assert.equal(element._setReviewed.lastCall.args[0], true);
     });
 
+    test('shift+x shortcut expands all diff context', () => {
+      const expandStub = sandbox.stub(element.$.diffHost, 'expandAllContext');
+      MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
+      flushAsynchronousOperations();
+      assert.isTrue(expandStub.called);
+    });
+
     test('keyboard shortcuts with patch range', () => {
       element._changeNum = '42';
       element._patchRange = {
@@ -310,28 +351,44 @@
           PARENT), 'Should navigate to /c/42/1');
     });
 
-    test('Diff preferences hidden when no prefs or logged out', () => {
-      element._loggedIn = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
+    suite('diff prefs hidden', () => {
+      test('when no prefs or logged out', () => {
+        element.disableDiffPrefs = false;
+        element._loggedIn = false;
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element._loggedIn = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
+        element._loggedIn = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element._loggedIn = false;
-      element._prefs = {font_size: '12'};
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
+        element._loggedIn = false;
+        element._prefs = {font_size: '12'};
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-      element._loggedIn = true;
-      flushAsynchronousOperations();
-      assert.isFalse(element.$.diffPrefsContainer.hidden);
+        element._loggedIn = true;
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+      });
+
+      test('when disableDiffPrefs is set', () => {
+        element._loggedIn = true;
+        element._prefs = {font_size: '12'};
+        element.disableDiffPrefs = false;
+        flushAsynchronousOperations();
+
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+        element.disableDiffPrefs = true;
+        flushAsynchronousOperations();
+
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+      });
     });
 
     test('prefsButton opens gr-diff-preferences', () => {
       const handlePrefsTapSpy = sandbox.spy(element, '_handlePrefsTap');
-      const overlayOpenStub = sandbox.stub(element.$.diffPreferences,
+      const overlayOpenStub = sandbox.stub(element.$.diffPreferencesDialog,
           'open');
       const prefsButton =
           Polymer.dom(element.root).querySelector('.prefsButton');
@@ -523,6 +580,7 @@
     });
 
     test('download link', () => {
+      element._change = {project: 'test'},
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: PARENT,
@@ -533,7 +591,7 @@
       flushAsynchronousOperations();
       const link = element.$$('.downloadLink');
       assert.equal(link.getAttribute('href'),
-          '/changes/42/revisions/10/patch?zip&path=glados.txt');
+          '/changes/test~42/revisions/10/patch?zip&path=glados.txt');
       assert.isTrue(link.hasAttribute('download'));
     });
 
@@ -543,7 +601,7 @@
       const getReviewedStub = sandbox.stub(element, '_getReviewedStatus',
           () => Promise.resolve());
 
-      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element.$.diffHost, 'reload');
       element._loggedIn = true;
       element.params = {
         view: Gerrit.Nav.View.DIFF,
@@ -568,7 +626,7 @@
     test('file review status', () => {
       const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
           () => Promise.resolve());
-      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element.$.diffHost, 'reload');
 
       element._loggedIn = true;
       element.params = {
@@ -614,7 +672,7 @@
     });
 
     test('hash is determined from params', done => {
-      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element.$.diffHost, 'reload');
       sandbox.stub(element, '_initCursor');
 
       element._loggedIn = true;
@@ -635,7 +693,7 @@
 
     test('diff mode selector correctly toggles the diff', () => {
       const select = element.$.modeSelect;
-      const diffDisplay = element.$.diff;
+      const diffDisplay = element.$.diffHost;
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
 
       // The mode selected in the view state reflects the selected option.
@@ -680,7 +738,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,
@@ -830,16 +888,16 @@
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
-    test('_handleMKey', () => {
+    test('_handleToggleDiffMode', () => {
       sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const e = {preventDefault: () => {}};
       // Initial state.
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
 
-      element._handleMKey(e);
+      element._handleToggleDiffMode(e);
       assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
 
-      element._handleMKey(e);
+      element._handleToggleDiffMode(e);
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
@@ -1072,5 +1130,22 @@
       assert.isTrue(setStub.calledOnce);
       assert.isTrue(setStub.calledWith(101, 'test-project'));
     });
+
+    test('shift+m navigates to next unreviewed file', () => {
+      element._fileList = ['file1', 'file2', 'file3'];
+      element._reviewedFiles = new Set(['file1', 'file2']);
+      element._path = 'file1';
+      const reviewedStub = sandbox.stub(element, '_setReviewed');
+      const navStub = sandbox.stub(element, '_navToFile');
+      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      flushAsynchronousOperations();
+
+      assert.isTrue(reviewedStub.lastCall.args[0]);
+      assert.deepEqual(navStub.lastCall.args, [
+        'file1',
+        ['file1', 'file3'],
+        1,
+      ]);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
index 88fcd0e..02ca7e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
@@ -20,13 +20,43 @@
   // Prevent redefinition.
   if (window.GrDiffGroup) { return; }
 
+  /**
+   * A chunk of the diff that should be rendered together.
+   *
+   * @param {!GrDiffGroup.Type} type
+   * @param {!Array<!GrDiffLine>=} opt_lines
+   */
   function GrDiffGroup(type, opt_lines) {
+    /** @type {!GrDiffGroup.Type} */
     this.type = type;
-    this.lines = [];
-    this.adds = [];
-    this.removes = [];
-    this.dueToRebase = undefined;
 
+    /** @type {boolean} */
+    this.dueToRebase = false;
+
+    /**
+     * True means all changes in this line are whitespace changes that should
+     * not be highlighted as changed as per the user settings.
+     * @type{boolean}
+     */
+    this.ignoredWhitespaceOnly = false;
+
+    /**
+     * True means it should not be collapsed (because it was in the URL, or
+     * there is a comment on that line)
+     */
+    this.keyLocation = false;
+
+    /** @type {?HTMLElement} */
+    this.element = null;
+
+    /** @type {!Array<!GrDiffLine>} */
+    this.lines = [];
+    /** @type {!Array<!GrDiffLine>} */
+    this.adds = [];
+    /** @type {!Array<!GrDiffLine>} */
+    this.removes = [];
+
+    /** Both start and end line are inclusive. */
     this.lineRange = {
       left: {start: null, end: null},
       right: {start: null, end: null},
@@ -37,14 +67,151 @@
     }
   }
 
-  GrDiffGroup.prototype.element = null;
-
+  /** @enum {string} */
   GrDiffGroup.Type = {
+    /** Unchanged context. */
     BOTH: 'both',
+
+    /** A widget used to show more context. */
     CONTEXT_CONTROL: 'contextControl',
+
+    /** Added, removed or modified chunk. */
     DELTA: 'delta',
   };
 
+
+  /**
+   * Hides lines in the given range behind a context control group.
+   *
+   * Groups that would be partially visible are split into their visible and
+   * hidden parts, respectively.
+   * The groups need to be "common groups", meaning they have to have either
+   * originated from an `ab` chunk, or from an `a`+`b` chunk with
+   * `common: true`.
+   *
+   * If the hidden range is 1 line or less, nothing is hidden and no context
+   * control group is created.
+   *
+   * @param {!Array<!GrDiffGroup>} groups Common groups, ordered by their line
+   *     ranges.
+   * @param {number} hiddenStart The first element to be hidden, as a
+   *     non-negative line number offset relative to the first group's start
+   *     line, left and right respectively.
+   * @param {number} hiddenEnd The first visible element after the hidden range,
+   *     as a non-negative line number offset relative to the first group's
+   *     start line, left and right respectively.
+   * @return {!Array<!GrDiffGroup>}
+   */
+  GrDiffGroup.hideInContextControl = function(groups, hiddenStart, hiddenEnd) {
+    if (groups.length === 0) return [];
+    // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
+    hiddenStart = Math.max(hiddenStart, 0);
+    hiddenEnd = Math.max(hiddenEnd, hiddenStart);
+
+    let before = [];
+    let hidden = groups;
+    let after = [];
+
+    const numHidden = hiddenEnd - hiddenStart;
+
+    // Only collapse if there is more than 1 line to be hidden.
+    if (numHidden > 1) {
+      if (hiddenStart) {
+        [before, hidden] = GrDiffGroup._splitCommonGroups(hidden, hiddenStart);
+      }
+      if (hiddenEnd) {
+        [hidden, after] = GrDiffGroup._splitCommonGroups(
+            hidden, hiddenEnd - hiddenStart);
+      }
+    } else {
+      [hidden, after] = [[], hidden];
+    }
+
+    const result = [...before];
+    if (hidden.length) {
+      const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+      ctxLine.contextGroups = hidden;
+      const ctxGroup = new GrDiffGroup(
+          GrDiffGroup.Type.CONTEXT_CONTROL, [ctxLine]);
+      result.push(ctxGroup);
+    }
+    result.push(...after);
+    return result;
+  };
+
+  /**
+   * Splits a list of common groups into two lists of groups.
+   *
+   * Groups where all lines are before or all lines are after the split will be
+   * retained as is and put into the first or second list respectively. Groups
+   * with some lines before and some lines after the split will be split into
+   * two groups, which will be put into the first and second list.
+   *
+   * @param {!Array<!GrDiffGroup>} groups
+   * @param {number} split A line number offset relative to the first group's
+   *     start line at which the groups should be split.
+   * @return {!Array<!Array<!GrDiffGroup>>} The outer array has 2 elements, the
+   *   list of groups before and the list of groups after the split.
+   */
+  GrDiffGroup._splitCommonGroups = function(groups, split) {
+    if (groups.length === 0) return [[], []];
+    const leftSplit = groups[0].lineRange.left.start + split;
+    const rightSplit = groups[0].lineRange.right.start + split;
+
+    const beforeGroups = [];
+    const afterGroups = [];
+    for (const group of groups) {
+      if (group.lineRange.left.end < leftSplit ||
+          group.lineRange.right.end < rightSplit) {
+        beforeGroups.push(group);
+        continue;
+      }
+      if (leftSplit <= group.lineRange.left.start ||
+          rightSplit <= group.lineRange.right.start) {
+        afterGroups.push(group);
+        continue;
+      }
+
+      const before = [];
+      const after = [];
+      for (const line of group.lines) {
+        if ((line.beforeNumber && line.beforeNumber < leftSplit) ||
+            (line.afterNumber && line.afterNumber < rightSplit)) {
+          before.push(line);
+        } else {
+          after.push(line);
+        }
+      }
+
+      if (before.length) {
+        beforeGroups.push(before.length === group.lines.length ?
+            group : group.cloneWithLines(before));
+      }
+      if (after.length) {
+        afterGroups.push(after.length === group.lines.length ?
+            group : group.cloneWithLines(after));
+      }
+    }
+    return [beforeGroups, afterGroups];
+  };
+
+  /**
+   * Creates a new group with the same properties but different lines.
+   *
+   * The element property is not copied, because the original element is still a
+   * rendering of the old lines, so that would not make sense.
+   *
+   * @param {!Array<!GrDiffLine>} lines
+   * @return {!GrDiffGroup}
+   */
+  GrDiffGroup.prototype.cloneWithLines = function(lines) {
+    const group = new GrDiffGroup(this.type, lines);
+    group.dueToRebase = this.dueToRebase;
+    group.ignoredWhitespaceOnly = this.ignoredWhitespaceOnly;
+    return group;
+  };
+
+  /** @param {!GrDiffLine} line */
   GrDiffGroup.prototype.addLine = function(line) {
     this.lines.push(line);
 
@@ -63,6 +230,7 @@
     this._updateRange(line);
   };
 
+  /** @return {!Array<{left: GrDiffLine, right: GrDiffLine}>} */
   GrDiffGroup.prototype.getSideBySidePairs = function() {
     if (this.type === GrDiffGroup.Type.BOTH ||
         this.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
index 9dc5311..16e8036 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
@@ -18,8 +18,10 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-group</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="gr-diff-line.js"></script>
 <script src="gr-diff-group.js"></script>
@@ -28,12 +30,9 @@
   suite('gr-diff-group tests', () => {
     test('delta line pairs', () => {
       let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-      const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
-      const l2 = new GrDiffLine(GrDiffLine.Type.ADD);
-      const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      l1.afterNumber = 128;
-      l2.afterNumber = 129;
-      l3.beforeNumber = 64;
+      const l1 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 128);
+      const l2 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 129);
+      const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE, 64, 0);
       group.addLine(l1);
       group.addLine(l2);
       group.addLine(l3);
@@ -64,17 +63,9 @@
     });
 
     test('group/header line pairs', () => {
-      const l1 = new GrDiffLine(GrDiffLine.Type.BOTH);
-      l1.beforeNumber = 64;
-      l1.afterNumber = 128;
-
-      const l2 = new GrDiffLine(GrDiffLine.Type.BOTH);
-      l2.beforeNumber = 65;
-      l2.afterNumber = 129;
-
-      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
-      l3.beforeNumber = 66;
-      l3.afterNumber = 130;
+      const l1 = new GrDiffLine(GrDiffLine.Type.BOTH, 64, 128);
+      const l2 = new GrDiffLine(GrDiffLine.Type.BOTH, 65, 129);
+      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH, 66, 130);
 
       let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
 
@@ -122,6 +113,97 @@
       assert.throws(group.addLine.bind(group, l2));
       assert.doesNotThrow(group.addLine.bind(group, l3));
     });
+
+    suite('hideInContextControl', () => {
+      let groups;
+      setup(() => {
+        groups = [
+          new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+            new GrDiffLine(GrDiffLine.Type.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLine.Type.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLine.Type.BOTH, 7, 9),
+          ]),
+          new GrDiffGroup(GrDiffGroup.Type.DELTA, [
+            new GrDiffLine(GrDiffLine.Type.REMOVE, 8),
+            new GrDiffLine(GrDiffLine.Type.ADD, 0, 10),
+            new GrDiffLine(GrDiffLine.Type.REMOVE, 9),
+            new GrDiffLine(GrDiffLine.Type.ADD, 0, 11),
+            new GrDiffLine(GrDiffLine.Type.REMOVE, 10),
+            new GrDiffLine(GrDiffLine.Type.ADD, 0, 12),
+          ]),
+          new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+            new GrDiffLine(GrDiffLine.Type.BOTH, 11, 13),
+            new GrDiffLine(GrDiffLine.Type.BOTH, 12, 14),
+            new GrDiffLine(GrDiffLine.Type.BOTH, 13, 15),
+          ]),
+        ];
+      });
+
+      test('hides hidden groups in context control', () => {
+        const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 3, 6);
+        assert.equal(collapsedGroups.length, 3);
+
+        assert.equal(collapsedGroups[0], groups[0]);
+
+        assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+        assert.equal(collapsedGroups[1].lines.length, 1);
+        assert.equal(
+            collapsedGroups[1].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
+        assert.equal(
+            collapsedGroups[1].lines[0].contextGroups.length, 1);
+        assert.equal(
+            collapsedGroups[1].lines[0].contextGroups[0], groups[1]);
+
+        assert.equal(collapsedGroups[2], groups[2]);
+      });
+
+      test('splits partially hidden groups', () => {
+        const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 4, 7);
+        assert.equal(collapsedGroups.length, 4);
+        assert.equal(collapsedGroups[0], groups[0]);
+
+        assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.DELTA);
+        assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
+        assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+
+        assert.equal(collapsedGroups[2].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+        assert.equal(collapsedGroups[2].lines.length, 1);
+        assert.equal(
+            collapsedGroups[2].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
+        assert.equal(
+            collapsedGroups[2].lines[0].contextGroups.length, 2);
+
+        assert.equal(
+            collapsedGroups[2].lines[0].contextGroups[0].type,
+            GrDiffGroup.Type.DELTA);
+        assert.deepEqual(
+            collapsedGroups[2].lines[0].contextGroups[0].adds,
+            groups[1].adds.slice(1));
+        assert.deepEqual(
+            collapsedGroups[2].lines[0].contextGroups[0].removes,
+            groups[1].removes.slice(1));
+
+        assert.equal(
+            collapsedGroups[2].lines[0].contextGroups[1].type,
+            GrDiffGroup.Type.BOTH);
+        assert.deepEqual(
+            collapsedGroups[2].lines[0].contextGroups[1].lines,
+            [groups[2].lines[0]]);
+
+        assert.equal(collapsedGroups[3].type, GrDiffGroup.Type.BOTH);
+        assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
+      });
+
+      test('groups unchanged if the hidden range is empty', () => {
+        assert.deepEqual(
+            GrDiffGroup.hideInContextControl(groups, 0, 0), groups);
+      });
+
+      test('groups unchanged if there is only 1 line to hide', () => {
+        assert.deepEqual(
+            GrDiffGroup.hideInContextControl(groups, 3, 4), groups);
+      });
+    });
   });
 
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
index 978058f..48bb6e0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -20,19 +20,27 @@
   // Prevent redefinition.
   if (window.GrDiffLine) { return; }
 
-  function GrDiffLine(type) {
+  /**
+   * @param {GrDiffLine.Type} type
+   * @param {number|string=} opt_beforeLine
+   * @param {number|string=} opt_afterLine
+   */
+  function GrDiffLine(type, opt_beforeLine, opt_afterLine) {
     this.type = type;
+
+    /** @type {number|string} */
+    this.beforeNumber = opt_beforeLine || 0;
+    /** @type {number|string} */
+    this.afterNumber = opt_afterLine || 0;
+
     this.highlights = [];
+
+    /** @type {?Array<Object>} ?Array<!GrDiffGroup> */
+    this.contextGroups = null;
+
+    this.text = '';
   }
 
-  GrDiffLine.prototype.afterNumber = 0;
-
-  GrDiffLine.prototype.beforeNumber = 0;
-
-  GrDiffLine.prototype.contextGroup = null;
-
-  GrDiffLine.prototype.text = '';
-
   GrDiffLine.Type = {
     ADD: 'add',
     BOTH: 'both',
@@ -41,6 +49,23 @@
     REMOVE: 'remove',
   };
 
+  /**
+   * A line highlight object consists of three fields:
+   * - contentIndex: The index of the chunk `content` field (the line
+   *   being referred to).
+   * - startIndex: Index of the character where the highlight should begin.
+   * - endIndex: (optional) Index of the character where the highlight should
+   *   end. If omitted, the highlight is meant to be a continuation onto the
+   *   next line.
+   *
+   * @typedef {{
+   *  contentIndex: number,
+   *  startIndex: number,
+   *  endIndex: number
+   * }}
+   */
+  GrDiffLine.Highlights;
+
   GrDiffLine.FILE = 'FILE';
 
   GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 540df98..06f8a4f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -15,14 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<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">
 <link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
 <link rel="import" href="../gr-syntax-themes/gr-syntax-theme.html">
@@ -38,9 +35,19 @@
       :host(.no-left) .sideBySide ::content .right:not([data-value]) + td {
         display: none;
       }
+      ::slotted(*) .thread-group {
+        display: block;
+        max-width: var(--content-width, 80ch);
+        white-space: normal;
+      }
+      .thread-group {
+        display: block;
+        max-width: var(--content-width, 80ch);
+        white-space: normal;
+      }
       .diffContainer {
         display: flex;
-        font: var(--font-size-small) var(--monospace-font-family);
+        font-family: var(--monospace-font-family);
         @apply --diff-container-styles;
       }
       .diffContainer.hiddenscroll {
@@ -58,8 +65,11 @@
         text-align: center;
       }
       .image-diff img {
+        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         max-width: 50em;
-        outline: 1px solid var(--border-color);
+      }
+      .image-diff .right.lineNum {
+        border-left: 1px solid var(--border-color);
       }
       .image-diff label,
       .binary-diff label {
@@ -75,10 +85,15 @@
         background-color: var(--diff-selection-background-color);
         color: var(--primary-text-color);
       }
-      .blank,
       .content {
         background-color: var(--view-background-color);
       }
+      .blank {
+        background-color: var(--diff-blank-background-color);
+      }
+      .image-diff .content {
+        background-color: var(--table-header-background-color);
+      }
       .full-width {
         width: 100%;
       }
@@ -89,7 +104,7 @@
       .lineNum,
       .content {
         /* Set font size based the user's diff preference. */
-        font-size: var(--font-size, var(--font-size-small));
+        font-size: var(--font-size, var(--font-size-normal));
         vertical-align: top;
         white-space: pre;
       }
@@ -115,6 +130,8 @@
         width: var(--content-width, 80ch);
       }
       .content.add .intraline,
+      /* If there are no intraline changes, consider everything changed */
+      .content.add.no-highlights,
       .delta.total .content.add {
         background-color: var(--dark-add-highlight-color);
       }
@@ -122,12 +139,16 @@
         background-color: var(--light-add-highlight-color);
       }
       .content.remove .intraline,
+      /* If there are no intraline changes, consider everything changed */
+      .content.remove.no-highlights,
       .delta.total .content.remove {
         background-color: var(--dark-remove-highlight-color);
       }
       .content.remove {
         background-color: var(--light-remove-highlight-color);
       }
+
+      /* dueToRebase */
       .dueToRebase .content.add .intraline,
       .delta.total.dueToRebase .content.add {
         background-color: var(--dark-rebased-add-highlight-color);
@@ -142,19 +163,30 @@
       .dueToRebase .content.remove {
         background-color: var(--light-remove-add-highlight-color);
       }
+
+      /* ignoredWhitespaceOnly */
+      .ignoredWhitespaceOnly .content.add .intraline,
+      .delta.total.ignoredWhitespaceOnly .content.add,
+      .ignoredWhitespaceOnly .content.add,
+      .ignoredWhitespaceOnly .content.remove .intraline,
+      .delta.total.ignoredWhitespaceOnly .content.remove,
+      .ignoredWhitespaceOnly .content.remove {
+        background: none;
+      }
+
       .content .contentText:empty:after {
         /* Newline, to ensure empty lines are one line-height tall. */
         content: '\A';
       }
       .contextControl {
-        background-color: var(--diff-context-control-color);
+        background-color: var(--diff-context-control-background-color);
         border: 1px solid var(--diff-context-control-border-color);
       }
       .contextControl gr-button {
         display: inline-block;
         text-decoration: none;
         --gr-button: {
-          color: var(--deemphasized-text-color);
+          color: var(--diff-context-control-color);
           padding: .2em;
         }
       }
@@ -175,8 +207,14 @@
         color: var(--diff-tab-indicator-color);
         /* >> character */
         content: '\00BB';
+        position: absolute;
       }
-      .trailing-whitespace {
+      /* Is defined after other background-colors, such that this
+         rule wins in case of same specificity. */
+      .trailing-whitespace,
+      .content .trailing-whitespace,
+      .trailing-whitespace .intraline,
+      .content .trailing-whitespace .intraline {
         border-radius: .4em;
         background-color: var(--diff-trailing-whitespace-indicator);
       }
@@ -185,18 +223,23 @@
         border-bottom: 1px solid var(--border-color);
         color: var(--link-color);
         font-family: var(--monospace-font-family);
-        font-size: var(--font-size, var(--font-size-small));
+        font-size: var(--font-size, var(--font-size-normal));
         padding: 0.5em 0 0.5em 4em;
       }
+      #loadingError,
       #sizeWarning {
         display: none;
         margin: 1em auto;
         max-width: 60em;
         text-align: center;
       }
+      #loadingError {
+        color: var(--error-text-color);
+      }
       #sizeWarning gr-button {
         margin: 1em;
       }
+      #loadingError.showError,
       #sizeWarning.warn {
         display: block;
       }
@@ -209,7 +252,7 @@
       td.blame {
         display: none;
         font-family: var(--font-family);
-        font-size: var(--font-size, var(--font-size-small));
+        font-size: var(--font-size, var(--font-size-normal));
         padding: 0 .5em;
         white-space: pre;
       }
@@ -235,7 +278,7 @@
       /** Since the line limit position is determined by charachter size, blank
        lines also need to have the same font size as everything else */
       .full-width .blank {
-        font-size: var(--font-size, var(--font-size-small));
+        font-size: var(--font-size, var(--font-size-normal));
       }
       /** Support the line length indicator **/
       .full-width td.content,
@@ -245,6 +288,22 @@
         background-position: var(--line-limit) 0;
         background-repeat: repeat-y;
       }
+      .newlineWarning {
+        color: var(--deemphasized-text-color);
+        text-align: center;
+      }
+      .newlineWarning.hidden {
+        display: none;
+      }
+      .lineNum.COVERED {
+         background-color: #E0F2F1;
+      }
+      .lineNum.NOT_COVERED {
+        background-color: #FFD1A4;
+      }
+      .lineNum.PARTIALLY_COVERED {
+        background: linear-gradient(to right bottom, #FFD1A4 0%, #FFD1A4 50%, #E0F2F1 50%, #E0F2F1 100%);
+      }
     </style>
     <style include="gr-syntax-theme"></style>
     <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
@@ -254,28 +313,27 @@
         <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]]"
-            comments="{{comments}}">
+            logged-in="[[loggedIn]]"
+            comment-ranges="{{_commentRanges}}">
           <gr-diff-builder
               id="diffBuilder"
-              comments="[[comments]]"
+              comment-ranges="[[_commentRanges]]"
+              coverage-ranges="[[coverageRanges]]"
               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]]"
-              parent-index="[[_parentIndex]]"
-              line-of-interest="[[lineOfInterest]]">
+              base-image="[[baseImage]]"
+              revision-image="[[revisionImage]]">
             <table
                 id="diffTable"
                 class$="[[_diffTableClass]]"
@@ -284,10 +342,16 @@
         </gr-diff-highlight>
       </gr-diff-selection>
     </div>
+    <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
+      [[_newlineWarning]]
+    </div>
+    <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
+      [[errorMessage]]
+    </div>
     <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
       <p>
         Prevented render because "Whole file" is enabled and this diff is very
-        large (about [[_diffLength(_diff)]] lines).
+        large (about [[_diffLength]] lines).
       </p>
       <gr-button on-tap="_handleLimitedBypass">
         Render with limited context
@@ -296,8 +360,6 @@
         Render anyway (may be slow)
       </gr-button>
     </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting" category="diff"></gr-reporting>
   </template>
   <script src="gr-diff-line.js"></script>
   <script src="gr-diff-group.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index f280c33..633e081 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -21,11 +21,9 @@
   const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
       'of an edit.';
   const ERR_INVALID_LINE = 'Invalid line number: ';
-  const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
-  const EVENT_AGAINST_PARENT = 'diff-against-parent';
-  const EVENT_ZERO_REBASE = 'rebase-percent-zero';
-  const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
+  const NO_NEWLINE_BASE = 'No newline at end of base file.';
+  const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
 
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -37,12 +35,81 @@
     RIGHT: 'right',
   };
 
+  const Defs = {};
+
+  /**
+   * Special line number which should not be collapsed into a shared region.
+   *
+   * @typedef {{
+   *  number: number,
+   *  leftSide: boolean
+   * }}
+   */
+  Defs.LineOfInterest;
+
   const LARGE_DIFF_THRESHOLD_LINES = 10000;
   const FULL_CONTEXT = -1;
   const LIMITED_CONTEXT = 10;
 
+  /** @typedef {{start_line: number, start_character: number,
+   *             end_line: number, end_character: number}} */
+  Gerrit.Range;
+
+  /**
+   * Compare two ranges. Either argument may be falsy, but will only return
+   * true if both are falsy or if neither are falsy and have the same position
+   * values.
+   *
+   * @param {Gerrit.Range=} a range 1
+   * @param {Gerrit.Range=} b range 2
+   * @return {boolean}
+   */
+  Gerrit.rangesEqual = function(a, b) {
+    if (!a && !b) { return true; }
+    if (!a || !b) { return false; }
+    return a.start_line === b.start_line &&
+        a.start_character === b.start_character &&
+        a.end_line === b.end_line &&
+        a.end_character === b.end_character;
+  };
+
+  function isThreadEl(node) {
+    return node.nodeType === Node.ELEMENT_NODE &&
+        node.classList.contains('comment-thread');
+  }
+
+  /**
+   * Turn a slot element into the corresponding content element.
+   * Slots are only fully supported in Polymer 2 - in Polymer 1, they are
+   * replaced with content elements during template parsing. This conversion is
+   * not applied for imperatively created slot elements, so this method
+   * implements the same behavior as the template parsing for imperative slots.
+   */
+  Gerrit.slotToContent = function(slot) {
+    if (Polymer.Element) {
+      return slot;
+    }
+    const content = document.createElement('content');
+    content.name = slot.name;
+    content.setAttribute('select', `[slot='${slot.name}']`);
+    return content;
+  };
+
+  const COMMIT_MSG_PATH = '/COMMIT_MSG';
+  /**
+   * 72 is the inofficial length standard for git commit messages.
+   * Derived from the fact that git log/show appends 4 ws in the beginning of
+   * each line when displaying commit messages. To center the commit message
+   * in an 80 char terminal a 4 ws border is added to the rightmost side:
+   * 4 + 72 + 4
+   */
+  const COMMIT_MSG_LINE_LENGTH = 72;
+
+  const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
+
   Polymer({
     is: 'gr-diff',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the user selects a line.
@@ -56,9 +123,16 @@
      */
 
     /**
-     * Fired when a comment is saved or discarded
+     * Fired when a comment is created
      *
-     * @event diff-comments-modified
+     * @event create-comment
+     */
+
+    /**
+     * Fired when rendering, including syntax highlighting, is done. Also fired
+     * when no rendering can be done because required preferences are not set.
+     *
+     * @event render
      */
 
     properties: {
@@ -69,15 +143,14 @@
       },
       /** @type {?} */
       patchRange: Object,
-      path: String,
+      path: {
+        type: String,
+        observer: '_pathObserver',
+      },
       prefs: {
         type: Object,
         observer: '_prefsObserver',
       },
-      projectConfig: {
-        type: Object,
-        observer: '_projectConfigChanged',
-      },
       projectName: String,
       displayLine: {
         type: Boolean,
@@ -85,21 +158,23 @@
       },
       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,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      _commentRanges: {
+        type: Array,
+        value: () => [],
+      },
+      /** @type {!Array<!Gerrit.CoverageRange>} */
+      coverageRanges: {
+        type: Array,
+        value: () => [],
+      },
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -111,33 +186,36 @@
         observer: '_viewModeObserver',
       },
 
-      /**
-       * Special line number which should not be collapsed into a shared region.
-       * @type {{
-       *  number: number,
-       *  leftSide: {boolean}
-       * }|null}
-       */
+       /** @type ?Defs.LineOfInterest */
       lineOfInterest: Object,
 
-      _loggedIn: {
+      loading: {
+        type: Boolean,
+        value: false,
+        observer: '_loadingChanged',
+      },
+
+      loggedIn: {
         type: Boolean,
         value: false,
       },
-      _diff: Object,
+      diff: {
+        type: Object,
+        observer: '_diffChanged',
+      },
       _diffHeaderItems: {
         type: Array,
         value: [],
-        computed: '_computeDiffHeaderItems(_diff.*)',
+        computed: '_computeDiffHeaderItems(diff.*)',
       },
       _diffTableClass: {
         type: String,
         value: '',
       },
       /** @type {?Object} */
-      _baseImage: Object,
+      baseImage: Object,
       /** @type {?Object} */
-      _revisionImage: Object,
+      revisionImage: Object,
 
       /**
        * Whether the safety check for large diffs when whole-file is set has
@@ -154,21 +232,45 @@
 
       _showWarning: Boolean,
 
-      /** @type {?Object} */
-      _blame: {
-        type: Object,
+      /** @type {?string} */
+      errorMessage: {
+        type: String,
         value: null,
       },
-      isBlameLoaded: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeIsBlameLoaded(_blame)',
+
+      /** @type {?Object} */
+      blame: {
+        type: Object,
+        value: null,
+        observer: '_blameChanged',
       },
 
-      _parentIndex: {
-        type: Number,
-        computed: '_computeParentIndex(patchRange.*)',
+      parentIndex: Number,
+
+      _newlineWarning: {
+        type: String,
+        computed: '_computeNewlineWarning(diff)',
       },
+
+      _diffLength: Number,
+
+      /**
+       * Observes comment nodes added or removed after the initial render.
+       * Can be used to unregister when the entire diff is (re-)rendered or upon
+       * detachment.
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
+      _incrementalNodeObserver: Object,
+
+      /**
+       * Observes comment nodes added or removed at any point.
+       * Can be used to unregister upon detachment.
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
+      _nodeObserver: Object,
+
+      /** Set by Polymer. */
+      isAttached: Boolean,
     },
 
     behaviors: [
@@ -176,50 +278,135 @@
     ],
 
     listeners: {
-      'comment-discard': '_handleCommentDiscard',
-      'comment-update': '_handleCommentUpdate',
-      'comment-save': '_handleCommentSave',
-      'create-comment': '_handleCreateComment',
+      'create-range-comment': '_handleCreateRangeComment',
+      'render-content': '_handleRenderContent',
     },
 
+    observers: [
+      '_enableSelectionObserver(loggedIn, isAttached)',
+    ],
+
     attached() {
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
+      this._observeNodes();
     },
 
-    ready() {
-      if (this._canRender()) {
-        this.reload();
+    detached() {
+      this._unobserveIncrementalNodes();
+      this._unobserveNodes();
+    },
+
+    _enableSelectionObserver(loggedIn, isAttached) {
+      if (loggedIn && isAttached) {
+        this.listen(document, 'selectionchange', '_handleSelectionChange');
+        this.listen(document, 'mouseup', '_handleMouseUp');
+      } else {
+        this.unlisten(document, 'selectionchange', '_handleSelectionChange');
+        this.unlisten(document, 'mouseup', '_handleMouseUp');
       }
     },
 
-    /** @return {!Promise} */
-    reload() {
-      this.cancel();
-      this.clearBlame();
-      this._safetyBypass = null;
-      this._showWarning = false;
-      this.clearDiffContent();
+    _handleSelectionChange() {
+      // Because of shadow DOM selections, we handle the selectionchange here,
+      // and pass the shadow DOM selection into gr-diff-highlight, where the
+      // corresponding range is determined and normalized.
+      const selection = this._getShadowOrDocumentSelection();
+      this.$.highlights.handleSelectionChange(selection, false);
+    },
 
-      const promises = [];
+    _handleMouseUp(e) {
+      // To handle double-click outside of text creating comments, we check on
+      // mouse-up if there's a selection that just covers a line change. We
+      // can't do that on selection change since the user may still be dragging.
+      const selection = this._getShadowOrDocumentSelection();
+      this.$.highlights.handleSelectionChange(selection, true);
+    },
 
-      promises.push(this._getDiff().then(diff => {
-        this._diff = diff;
-        return this._loadDiffAssets();
-      }));
+    /** Gets the current selection, preferring the shadow DOM selection. */
+    _getShadowOrDocumentSelection() {
+      // When using native shadow DOM, the selection returned by
+      // document.getSelection() cannot reference the actual DOM elements making
+      // up the diff, because they are in the shadow DOM of the gr-diff element.
+      // This takes the shadow DOM selection if one exists.
+      return this.root.getSelection ?
+          this.root.getSelection() :
+          document.getSelection();
+    },
 
-      return Promise.all(promises).then(() => {
-        if (this.prefs) {
-          return this._renderDiffTable();
-        }
-        return Promise.resolve();
+    _observeNodes() {
+      this._nodeObserver = Polymer.dom(this).observeNodes(info => {
+        const addedThreadEls = info.addedNodes.filter(isThreadEl);
+        const removedThreadEls = info.removedNodes.filter(isThreadEl);
+        this._updateRanges(addedThreadEls, removedThreadEls);
+        this._redispatchHoverEvents(addedThreadEls);
       });
     },
 
+    _updateRanges(addedThreadEls, removedThreadEls) {
+      function commentRangeFromThreadEl(threadEl) {
+        const side = threadEl.getAttribute('comment-side');
+        const range = JSON.parse(threadEl.getAttribute('range'));
+        return {side, range, hovering: false};
+      }
+
+      const addedCommentRanges = addedThreadEls
+          .map(commentRangeFromThreadEl)
+          .filter(({range}) => range);
+      const removedCommentRanges = removedThreadEls
+          .map(commentRangeFromThreadEl)
+          .filter(({range}) => range);
+      for (const removedCommentRange of removedCommentRanges) {
+        const i = this._commentRanges.findIndex(commentRange => {
+          return commentRange.side === removedCommentRange.side &&
+              Gerrit.rangesEqual(commentRange.range, removedCommentRange.range);
+        });
+        this.splice('_commentRanges', i, 1);
+      }
+      this.push('_commentRanges', ...addedCommentRanges);
+    },
+
+    /**
+     * The key locations based on the comments and line of interests,
+     * where lines should not be collapsed.
+     *
+     * @return {{left: Object<(string|number), boolean>,
+     *     right: Object<(string|number), boolean>}}
+     */
+    _computeKeyLocations() {
+      const keyLocations = {left: {}, right: {}};
+      if (this.lineOfInterest) {
+        const side = this.lineOfInterest.leftSide ? 'left' : 'right';
+        keyLocations[side][this.lineOfInterest.number] = true;
+      }
+      const threadEls = Polymer.dom(this).getEffectiveChildNodes()
+          .filter(isThreadEl);
+
+      for (const threadEl of threadEls) {
+        const commentSide = threadEl.getAttribute('comment-side');
+        const lineNum = Number(threadEl.getAttribute('line-num')) ||
+            GrDiffLine.FILE;
+        keyLocations[commentSide][lineNum] = true;
+      }
+      return keyLocations;
+    },
+
+    // Dispatch events that are handled by the gr-diff-highlight.
+    _redispatchHoverEvents(addedThreadEls) {
+      for (const threadEl of addedThreadEls) {
+        threadEl.addEventListener('mouseenter', () => {
+          threadEl.dispatchEvent(new CustomEvent(
+              'comment-thread-mouseenter', {bubbles: true, composed: true}));
+        });
+        threadEl.addEventListener('mouseleave', () => {
+          threadEl.dispatchEvent(new CustomEvent(
+              'comment-thread-mouseleave', {bubbles: true, composed: true}));
+        });
+      }
+    },
+
     /** Cancel any remaining diff builder rendering work. */
     cancel() {
       this.$.diffBuilder.cancel();
+      this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
     },
 
     /** @return {!Array<!HTMLElement>} */
@@ -228,7 +415,9 @@
         return [];
       }
 
-      return Polymer.dom(this.root).querySelectorAll('.diff-row');
+      // Polymer2: querySelectorAll returns NodeList instead of Array.
+      return Array.from(
+          Polymer.dom(this.root).querySelectorAll('.diff-row'));
     },
 
     /** @return {boolean} */
@@ -240,59 +429,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');
-    },
-
-    _handleCommentSaveOrDiscard() {
-      this.dispatchEvent(new CustomEvent('diff-comments-modified',
-          {bubbles: true}));
-    },
-
-    /** @return {boolean}} */
-    _canRender() {
-      return !!this.changeNum && !!this.patchRange && !!this.path &&
-          !this.noAutoRender;
-    },
-
-    /** @return {!Array<!HTMLElement>} */
-    getThreadEls() {
-      let threads = [];
-      const threadGroupEls = Polymer.dom(this.root)
-          .querySelectorAll('gr-diff-comment-thread-group');
-      for (const threadGroupEl of threadGroupEls) {
-        threads = threads.concat(threadGroupEl.threadEls);
+    _blameChanged(newValue) {
+      this.$.diffBuilder.setBlame(newValue);
+      if (newValue) {
+        this.classList.add('showBlame');
+      } else {
+        this.classList.remove('showBlame');
       }
-      return threads;
     },
 
     /** @return {string} */
@@ -345,138 +488,104 @@
 
     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) {
+    _handleCreateRangeComment(e) {
       const range = e.detail.range;
       const side = e.detail.side;
-      const lineNum = range.endLine;
+      const lineNum = range.end_line;
       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;
     },
 
     /**
      * @param {!Object} lineEl
-     * @param {number=} opt_lineNum
-     * @param {string=} opt_side
-     * @param {!Object=} opt_range
+     * @param {number=} lineNum
+     * @param {string=} side
+     * @param {!Object=} range
      */
-    _createComment(lineEl, opt_lineNum, opt_side, opt_range) {
+    _createComment(lineEl, lineNum=undefined, side=undefined, range=undefined) {
       const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
       const contentEl = contentText.parentElement;
-      const side = opt_side ||
+      side = side ||
           this._getCommentSideByLineAndContent(lineEl, contentEl);
-      const patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
+      const patchForNewThreads = this._getPatchNumByLineAndContent(
+          lineEl, contentEl);
       const isOnParent =
-        this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      const threadEl = this._getOrCreateThread(contentEl, patchNum,
-          side, isOnParent, opt_range);
-      threadEl.addOrEditDraft(opt_lineNum, opt_range);
-    },
-
-    /**
-     * Fetch the thread group at the given range, or the range-less thread
-     * on the line if no range is provided.
-     *
-     * @param {!Object} threadGroupEl
-     * @param {string} commentSide
-     * @param {!Object=} opt_range
-     * @return {!Object}
-     */
-    _getThread(threadGroupEl, commentSide, opt_range) {
-      return threadGroupEl.getThread(commentSide, opt_range);
+          this._getIsParentCommentByLineAndContent(lineEl, contentEl);
+      this.dispatchEvent(new CustomEvent('create-comment', {
+        bubbles: true,
+        composed: true,
+        detail: {
+          lineNum,
+          side,
+          patchNum: patchForNewThreads,
+          isOnParent,
+          range,
+        },
+      }));
     },
 
     _getThreadGroupForLine(contentEl) {
-      return contentEl.querySelector('gr-diff-comment-thread-group');
+      return contentEl.querySelector('.thread-group');
     },
 
     /**
-     * @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.
-     *
+     * Gets or creates a comment thread group for a specific line and side on a
+     * diff.
      * @param {!Object} contentEl
-     * @param {number} patchNum
-     * @param {string} commentSide
-     * @param {boolean} isOnParent
-     * @param {!Object=} opt_range
-     * @return {!Object}
+     * @param {!Gerrit.DiffSide} commentSide
+     * @return {!Node}
      */
-    _getOrCreateThread(contentEl, patchNum, commentSide,
-        isOnParent, opt_range) {
+    _getOrCreateThreadGroup(contentEl, commentSide) {
       // Check if thread group exists.
       let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
-        threadGroupEl = this.$.diffBuilder.createCommentThreadGroup(
-            this.changeNum, patchNum, this.path, isOnParent, commentSide);
+        threadGroupEl = document.createElement('div');
+        threadGroupEl.className = 'thread-group';
+        threadGroupEl.setAttribute('data-side', commentSide);
         contentEl.appendChild(threadGroupEl);
       }
-
-      let threadEl = this._getThread(threadGroupEl, commentSide, opt_range);
-
-      if (!threadEl) {
-        threadGroupEl.addNewThread(commentSide, opt_range);
-        Polymer.dom.flush();
-        threadEl = this._getThread(threadGroupEl, commentSide, opt_range);
-      }
-      return threadEl;
+      return threadGroupEl;
     },
 
     /**
@@ -526,75 +635,6 @@
       return side;
     },
 
-    _handleCommentDiscard(e) {
-      const comment = e.detail.comment;
-      this._removeComment(comment);
-      this._handleCommentSaveOrDiscard();
-    },
-
-    _removeComment(comment) {
-      const side = comment.__commentSide;
-      this._removeCommentFromSide(comment, side);
-    },
-
-    _handleCommentSave(e) {
-      const comment = e.detail.comment;
-      const side = e.detail.comment.__commentSide;
-      const idx = this._findDraftIndex(comment, side);
-      this.set(['comments', side, idx], comment);
-      this._handleCommentSaveOrDiscard();
-    },
-
-    /**
-     * Closure annotation for Polymer.prototype.push is off. Submitted PR:
-     * https://github.com/Polymer/polymer/pull/4776
-     * but for not supressing annotations.
-     *
-     * @suppress {checkTypes} */
-    _handleCommentUpdate(e) {
-      const comment = e.detail.comment;
-      const side = e.detail.comment.__commentSide;
-      let idx = this._findCommentIndex(comment, side);
-      if (idx === -1) {
-        idx = this._findDraftIndex(comment, side);
-      }
-      if (idx !== -1) { // Update draft or comment.
-        this.set(['comments', side, idx], comment);
-      } else { // Create new draft.
-        this.push(['comments', side], comment);
-      }
-    },
-
-    _removeCommentFromSide(comment, side) {
-      let idx = this._findCommentIndex(comment, side);
-      if (idx === -1) {
-        idx = this._findDraftIndex(comment, side);
-      }
-      if (idx !== -1) {
-        this.splice('comments.' + side, idx, 1);
-      }
-    },
-
-    /** @return {number} */
-    _findCommentIndex(comment, side) {
-      if (!comment.id || !this.comments[side]) {
-        return -1;
-      }
-      return this.comments[side].findIndex(item => {
-        return item.id === comment.id;
-      });
-    },
-
-    /** @return {number} */
-    _findDraftIndex(comment, side) {
-      if (!comment.__draftID || !this.comments[side]) {
-        return -1;
-      }
-      return this.comments[side].findIndex(item => {
-        return item.__draftID === comment.__draftID;
-      });
-    },
-
     _prefsObserver(newPrefs, oldPrefs) {
       // Scan the preference objects one level deep to see if they differ.
       let differ = !oldPrefs;
@@ -611,10 +651,26 @@
       }
     },
 
+    _pathObserver() {
+      // Call _prefsChanged(), because line-limit style value depends on path.
+      this._prefsChanged(this.prefs);
+    },
+
     _viewModeObserver() {
       this._prefsChanged(this.prefs);
     },
 
+    /** @param {boolean} newValue */
+    _loadingChanged(newValue) {
+      if (newValue) {
+        this.cancel();
+        this._blame = null;
+        this._safetyBypass = null;
+        this._showWarning = false;
+        this.clearDiffContent();
+      }
+    },
+
     _lineWrappingObserver() {
       this._prefsChanged(this.prefs);
     },
@@ -622,19 +678,21 @@
     _prefsChanged(prefs) {
       if (!prefs) { return; }
 
-      this.clearBlame();
+      this._blame = null;
 
+      const lineLength = this.path === COMMIT_MSG_PATH ?
+        COMMIT_MSG_LINE_LENGTH : prefs.line_length;
       const stylesToUpdate = {};
 
       if (prefs.line_wrapping) {
         this._diffTableClass = 'full-width';
         if (this.viewMode === 'SIDE_BY_SIDE') {
           stylesToUpdate['--content-width'] = 'none';
-          stylesToUpdate['--line-limit'] = prefs.line_length + 'ch';
+          stylesToUpdate['--line-limit'] = lineLength + 'ch';
         }
       } else {
         this._diffTableClass = '';
-        stylesToUpdate['--content-width'] = prefs.line_length + 'ch';
+        stylesToUpdate['--content-width'] = lineLength + 'ch';
       }
 
       if (prefs.font_size) {
@@ -643,21 +701,99 @@
 
       this.updateStyles(stylesToUpdate);
 
-      if (this._diff && this.comments && !this.noRenderOnPrefsChange) {
-        this._renderDiffTable();
+      if (this.diff && !this.noRenderOnPrefsChange) {
+        this._debounceRenderDiffTable();
       }
     },
 
+    _diffChanged(newValue) {
+      if (newValue) {
+        this._diffLength = this.$.diffBuilder.getDiffLength();
+        this._debounceRenderDiffTable();
+      }
+    },
+
+    /**
+     * When called multiple times from the same microtask, will call
+     * _renderDiffTable only once, in the next microtask, unless it is cancelled
+     * before that microtask runs.
+     *
+     * This should be used instead of calling _renderDiffTable directly to
+     * render the diff in response to an input change, because there may be
+     * multiple inputs changing in the same microtask, but we only want to
+     * render once.
+     */
+    _debounceRenderDiffTable() {
+      this.debounce(
+          RENDER_DIFF_TABLE_DEBOUNCE_NAME, () => this._renderDiffTable());
+    },
+
     _renderDiffTable() {
+      this._unobserveIncrementalNodes();
+      if (!this.prefs) {
+        this.dispatchEvent(
+            new CustomEvent('render', {bubbles: true, composed: true}));
+        return;
+      }
       if (this.prefs.context === -1 &&
-          this._diffLength(this._diff) >= LARGE_DIFF_THRESHOLD_LINES &&
+          this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
           this._safetyBypass === null) {
         this._showWarning = true;
-        return Promise.resolve();
+        this.dispatchEvent(
+            new CustomEvent('render', {bubbles: true, composed: true}));
+        return;
       }
 
       this._showWarning = false;
-      return this.$.diffBuilder.render(this.comments, this._getBypassPrefs());
+
+      const keyLocations = this._computeKeyLocations();
+      this.$.diffBuilder.render(keyLocations, this._getBypassPrefs())
+          .then(() => {
+            this.dispatchEvent(
+                new CustomEvent('render', {bubbles: true, composed: true}));
+          });
+    },
+
+    _handleRenderContent() {
+      this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
+        const addedThreadEls = info.addedNodes.filter(isThreadEl);
+        // Removed nodes do not need to be handled because all this code does is
+        // adding a slot for the added thread elements, and the extra slots do
+        // not hurt. It's probably a bigger performance cost to remove them than
+        // to keep them around. Medium term we can even consider to add one slot
+        // for each line from the start.
+        for (const threadEl of addedThreadEls) {
+          const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
+          const commentSide = threadEl.getAttribute('comment-side');
+          const lineEl = this.$.diffBuilder.getLineElByNumber(
+              lineNumString, commentSide);
+          const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+          const contentEl = contentText.parentElement;
+          const threadGroupEl = this._getOrCreateThreadGroup(
+              contentEl, commentSide);
+          // Create a slot for the thread and attach it to the thread group.
+          // The Polyfill has some bugs and this only works if the slot is
+          // attached to the group after the group is attached to the DOM.
+          // The thread group may already have a slot with the right name, but
+          // that is okay because the first matching slot is used and the rest
+          // are ignored.
+          const slot = document.createElement('slot');
+          slot.name = threadEl.getAttribute('slot');
+          Polymer.dom(threadGroupEl).appendChild(Gerrit.slotToContent(slot));
+        }
+      });
+    },
+
+    _unobserveIncrementalNodes() {
+      if (this._incrementalNodeObserver) {
+        Polymer.dom(this).unobserveNodes(this._incrementalNodeObserver);
+      }
+    },
+
+    _unobserveNodes() {
+      if (this._nodeObserver) {
+        Polymer.dom(this).unobserveNodes(this._nodeObserver);
+      }
     },
 
     /**
@@ -671,136 +807,20 @@
     },
 
     clearDiffContent() {
+      this._unobserveIncrementalNodes();
       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++) {
-        threadEls[i].projectConfig = projectConfig;
-      }
-    },
-
     /** @return {!Array} */
     _computeDiffHeaderItems(diffInfoRecord) {
       const diffInfo = diffInfoRecord.base;
-      if (!diffInfo || !diffInfo.diff_header || diffInfo.binary) { return []; }
+      if (!diffInfo || !diffInfo.diff_header) { return []; }
       return diffInfo.diff_header.filter(item => {
         return !(item.startsWith('diff --git ') ||
             item.startsWith('index ') ||
             item.startsWith('+++ ') ||
-            item.startsWith('--- '));
+            item.startsWith('--- ') ||
+            item === 'Binary files differ');
       });
     },
 
@@ -809,33 +829,14 @@
       return items.length === 0;
     },
 
-    /**
-     * The number of lines in the diff. For delta chunks that are different
-     * sizes on the left and the right, the longer side is used.
-     * @param {!Object} diff
-     * @return {number}
-     */
-    _diffLength(diff) {
-      return diff.content.reduce((sum, sec) => {
-        if (sec.hasOwnProperty('ab')) {
-          return sum + sec.ab.length;
-        } else {
-          return sum + Math.max(
-              sec.hasOwnProperty('a') ? sec.a.length : 0,
-              sec.hasOwnProperty('b') ? sec.b.length : 0
-          );
-        }
-      }, 0);
-    },
-
     _handleFullBypass() {
       this._safetyBypass = FULL_CONTEXT;
-      this._renderDiffTable();
+      this._debounceRenderDiffTable();
     },
 
     _handleLimitedBypass() {
       this._safetyBypass = LIMITED_CONTEXT;
-      this._renderDiffTable();
+      this._debounceRenderDiffTable();
     },
 
     /** @return {string} */
@@ -844,13 +845,99 @@
     },
 
     /**
-     * @return {number|null}
+     * @param {string} errorMessage
+     * @return {string}
      */
-    _computeParentIndex(patchRangeRecord) {
-      if (!this.isMergeParent(patchRangeRecord.base.basePatchNum)) {
-        return null;
+    _computeErrorClass(errorMessage) {
+      return errorMessage ? 'showError' : '';
+    },
+
+    expandAllContext() {
+      this._handleFullBypass();
+    },
+
+    /**
+     * Find the last chunk for the given side.
+     * @param {!Object} diff
+     * @param {boolean} leftSide true if checking the base of the diff,
+     *     false if testing the revision.
+     * @return {Object|null} returns the chunk object or null if there was
+     *     no chunk for that side.
+     */
+    _lastChunkForSide(diff, leftSide) {
+      if (!diff.content.length) { return null; }
+
+      let chunkIndex = diff.content.length;
+      let chunk;
+
+      // Walk backwards until we find a chunk for the given side.
+      do {
+        chunkIndex--;
+        chunk = diff.content[chunkIndex];
+      } while (
+          // We haven't reached the beginning.
+          chunkIndex >= 0 &&
+
+          // The chunk doesn't have both sides.
+          !chunk.ab &&
+
+          // The chunk doesn't have the given side.
+          ((leftSide && !chunk.a) || (!leftSide && !chunk.b)));
+
+      // If we reached the beginning of the diff and failed to find a chunk
+      // with the given side, return null.
+      if (chunkIndex === -1) { return null; }
+
+      return chunk;
+    },
+
+    /**
+     * Check whether the specified side of the diff has a trailing newline.
+     * @param {!Object} diff
+     * @param {boolean} leftSide true if checking the base of the diff,
+     *     false if testing the revision.
+     * @return {boolean|null} Return true if the side has a trailing newline.
+     *     Return false if it doesn't. Return null if not applicable (for
+     *     example, if the diff has no content on the specified side).
+     */
+    _hasTrailingNewlines(diff, leftSide) {
+      const chunk = this._lastChunkForSide(diff, leftSide);
+      if (!chunk) { return null; }
+      let lines;
+      if (chunk.ab) {
+        lines = chunk.ab;
+      } else {
+        lines = leftSide ? chunk.a : chunk.b;
       }
-      return this.getParentIndex(patchRangeRecord.base.basePatchNum);
+      return lines[lines.length - 1] === '';
+    },
+
+    /**
+     * @param {!Object} diff
+     * @return {string|null}
+     */
+    _computeNewlineWarning(diff) {
+      const hasLeft = this._hasTrailingNewlines(diff, true);
+      const hasRight = this._hasTrailingNewlines(diff, false);
+      const messages = [];
+      if (hasLeft === false) {
+        messages.push(NO_NEWLINE_BASE);
+      }
+      if (hasRight === false) {
+        messages.push(NO_NEWLINE_REVISION);
+      }
+      if (!messages.length) { return null; }
+      return messages.join(' — ');
+    },
+
+    /**
+     * @param {string} warning
+     * @param {boolean} loading
+     * @return {string}
+     */
+    _computeNewlineWarningClass(warning, loading) {
+      if (loading || !warning) { return 'newlineWarning hidden'; }
+      return 'newlineWarning';
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 33abab4..9641a1e 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
@@ -40,6 +42,8 @@
     let element;
     let sandbox;
 
+    const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+
     setup(() => {
       sandbox = sinon.sandbox.create();
     });
@@ -48,39 +52,47 @@
       sandbox.restore();
     });
 
-    test('reload cancels before network resolves', () => {
-      element = fixture('basic');
-      const cancelStub = sandbox.stub(element, 'cancel');
+    suite('selectionchange event handling', () => {
+      const emulateSelection = function() {
+        document.dispatchEvent(new CustomEvent('selectionchange'));
+      };
 
-      // Stub the network calls into requests that never resolve.
-      sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
+      setup(() => {
+        element = fixture('basic');
+        sandbox.stub(element.$.highlights, 'handleSelectionChange');
+      });
 
-      element.reload();
-      assert.isTrue(cancelStub.called);
+      test('enabled if logged in', () => {
+        element.loggedIn = true;
+        emulateSelection();
+        assert.isTrue(element.$.highlights.handleSelectionChange.called);
+      });
+
+      test('ignored if logged out', () => {
+        element.loggedIn = false;
+        emulateSelection();
+        assert.isFalse(element.$.highlights.handleSelectionChange.called);
+      });
     });
 
+
     test('cancel', () => {
+      element = fixture('basic');
       const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
       element.cancel();
       assert.isTrue(cancelStub.calledOnce);
     });
 
-    test('_diffLength', () => {
-      element = fixture('basic');
-      const mock = document.createElement('mock-diff-response');
-      assert.equal(element._diffLength(mock.diffResponse), 52);
-    });
-
     test('line limit with line_wrapping', () => {
       element = fixture('basic');
-      element.prefs = {line_wrapping: true, line_length: 80, tab_size: 2};
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
       flushAsynchronousOperations();
       assert.equal(element.customStyle['--line-limit'], '80ch');
     });
 
     test('line limit without line_wrapping', () => {
       element = fixture('basic');
-      element.prefs = {line_wrapping: false, line_length: 80, tab_size: 2};
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
       flushAsynchronousOperations();
       assert.isNotOk(element.customStyle['--line-limit']);
     });
@@ -172,10 +184,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 +199,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,165 +220,8 @@
             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: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-            {id: 'd2', __draft: true, __commentSide: 'right'},
-          ],
-        };
-
-        element._removeComment({});
-        // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
-        // to believe that one object deepEquals another even when they do :-/.
-        assert.equal(JSON.stringify(element.comments), JSON.stringify({
-          meta: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-            {id: 'd2', __draft: true, __commentSide: 'right'},
-          ],
-        }));
-
-        element._removeComment({id: 'bc2', side: 'PARENT',
-          __commentSide: 'left'});
-        assert.deepEqual(element.comments, {
-          meta: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-            {id: 'd2', __draft: true, __commentSide: 'right'},
-          ],
-        });
-
-        element._removeComment({id: 'd2', __commentSide: 'right'});
-        assert.deepEqual(element.comments, {
-          meta: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-          ],
-        });
-      });
-
-      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';
-        const patchNum = 1;
-        const side = 'PARENT';
-        let range = {
-          startLine: 1,
-          startChar: 1,
-          endLine: 1,
-          endChar: 2,
-        };
 
         element.changeNum = 123;
         element.patchRange = {basePatchNum: 1, patchNum: 2};
@@ -375,42 +229,24 @@
 
         const mock = document.createElement('mock-diff-response');
         element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
-            mock.diffResponse, {}, {tab_size: 2, line_length: 80});
+            mock.diffResponse, Object.assign({}, MINIMAL_PREFS));
 
         // No thread groups.
         assert.isNotOk(element._getThreadGroupForLine(contentEl));
 
         // A thread group gets created.
-        assert.isOk(element._getOrCreateThread(contentEl,
-            patchNum, commentSide, side));
+        const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
+        assert.isOk(threadGroupEl);
 
-        // Try to fetch a thread with a different range.
-        range = {
-          startLine: 1,
-          startChar: 1,
-          endLine: 1,
-          endChar: 3,
-        };
-
-        assert.isOk(element._getOrCreateThread(
-            contentEl, patchNum, commentSide, side, range));
         // The new thread group can be fetched.
         assert.isOk(element._getThreadGroupForLine(contentEl));
 
-        assert.equal(contentEl.querySelectorAll(
-            'gr-diff-comment-thread-group').length, 1);
-
-        const threadGroup = contentEl.querySelector(
-            'gr-diff-comment-thread-group');
-        const threadLength = Polymer.dom(threadGroup.root).
-              querySelectorAll('gr-diff-comment-thread').length;
-        assert.equal(threadLength, 2);
+        assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
       });
 
       suite('image diffs', () => {
         let mockFile1;
         let mockFile2;
-        const stubs = [];
         setup(() => {
           mockFile1 = {
             body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
@@ -422,66 +258,28 @@
             '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 +334,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 +371,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 +428,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,10 +451,8 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
 
-          element.addEventListener('render', () => {
+          function rendered() {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
             assert.instanceOf(
@@ -655,12 +464,12 @@
             assert.isNotOk(leftImage);
             assert.isOk(rightImage);
             done();
-          });
+            element.removeEventListener('render', rendered);
+          }
+          element.addEventListener('render', rendered);
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.revisionImage = mockFile2;
+          element.diff = mockDiff;
         });
 
         test('renders removed image', done => {
@@ -679,10 +488,8 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
 
-          element.addEventListener('render', () => {
+          function rendered() {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
             assert.instanceOf(
@@ -694,12 +501,12 @@
             assert.isOk(leftImage);
             assert.isNotOk(rightImage);
             done();
-          });
+            element.removeEventListener('render', rendered);
+          }
+          element.addEventListener('render', rendered);
 
-          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,10 +527,7 @@
           };
           mockFile1.type = 'image/jpeg-evil';
 
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
-
-          element.addEventListener('render', () => {
+          function rendered() {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
             assert.instanceOf(
@@ -731,12 +535,12 @@
             const leftImage = element.$.diffTable.querySelector('td.left img');
             assert.isNotOk(leftImage);
             done();
-          });
+            element.removeEventListener('render', rendered);
+          }
+          element.addEventListener('render', rendered);
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.baseImage = mockFile1;
+          element.diff = mockDiff;
         });
       });
 
@@ -783,24 +587,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.comments = {
-            left: [],
-            right: [],
-          };
+          element.diff = mock.diffResponse;
           element.prefs = {
             context: 10,
             tab_size: 8,
@@ -845,14 +635,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 +647,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},
@@ -923,35 +689,14 @@
             change_type: 'MODIFIED',
             content: [{skip: 66}],
           };
-          element.comments = {
-            meta: {
-              changeNum: '42',
-              patchRange: {
-                basePatchNum: 'PARENT',
-                patchNum: 3,
-              },
-              path: '/path/to/foo',
-              projectConfig: {foo: 'bar'},
-            },
-            left: [
-              {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-              {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-              {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-              {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            ],
-            right: [
-              {id: 'c1', __commentSide: 'right'},
-              {id: 'c2', __commentSide: 'right'},
-              {id: 'd1', __draft: true, __commentSide: 'right'},
-              {id: 'd2', __draft: true, __commentSide: 'right'},
-            ],
-          };
+          element.flushDebouncer('renderDiffTable');
         });
 
         test('change in preferences re-renders diff', () => {
           sandbox.stub(element, '_renderDiffTable');
-          element.prefs = {};
-          element.prefs = {time_format: 'HHMM_12'};
+          element.prefs = Object.assign(
+              {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+          element.flushDebouncer('renderDiffTable');
           assert.isTrue(element._renderDiffTable.called);
         });
 
@@ -959,96 +704,18 @@
             'noRenderOnPrefsChange', () => {
           sandbox.stub(element, '_renderDiffTable');
           element.noRenderOnPrefsChange = true;
-          element.prefs = {};
-          element.prefs = {time_format: 'HHMM_12'};
+          element.prefs = Object.assign(
+              {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+          element.flushDebouncer('renderDiffTable');
           assert.isFalse(element._renderDiffTable.called);
         });
       });
-
-      suite('handle comment-update', () => {
-        setup(() => {
-          element.comments = {
-            meta: {
-              changeNum: '42',
-              patchRange: {
-                basePatchNum: 'PARENT',
-                patchNum: 3,
-              },
-              path: '/path/to/foo',
-              projectConfig: {foo: 'bar'},
-            },
-            left: [
-              {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-              {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-              {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-              {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            ],
-            right: [
-              {id: 'c1', __commentSide: 'right'},
-              {id: 'c2', __commentSide: 'right'},
-              {id: 'd1', __draft: true, __commentSide: 'right'},
-              {id: 'd2', __draft: true, __commentSide: 'right'},
-            ],
-          };
-        });
-
-        test('creating a draft', () => {
-          const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-            __commentSide: 'left'};
-          element.fire('comment-update', {comment});
-          assert.include(element.comments.left, comment);
-        });
-
-        test('discarding a draft', () => {
-          const draftID = 'tempID';
-          const id = 'savedID';
-          const comment = {
-            __draft: true,
-            __draftID: draftID,
-            side: 'PARENT',
-            __commentSide: 'left',
-          };
-          const diffCommentsModifiedStub = sandbox.stub();
-          element.addEventListener('diff-comments-modified',
-              diffCommentsModifiedStub);
-          element.comments.left.push(comment);
-          comment.id = id;
-          element.fire('comment-discard', {comment});
-          const drafts = element.comments.left.filter(item => {
-            return item.__draftID === draftID;
-          });
-          assert.equal(drafts.length, 0);
-          assert.isTrue(diffCommentsModifiedStub.called);
-        });
-
-        test('saving a draft', () => {
-          const draftID = 'tempID';
-          const id = 'savedID';
-          const comment = {
-            __draft: true,
-            __draftID: draftID,
-            side: 'PARENT',
-            __commentSide: 'left',
-          };
-          const diffCommentsModifiedStub = sandbox.stub();
-          element.addEventListener('diff-comments-modified',
-              diffCommentsModifiedStub);
-          element.comments.left.push(comment);
-          comment.id = id;
-          element.fire('comment-save', {comment});
-          const drafts = element.comments.left.filter(item => {
-            return item.__draftID === draftID;
-          });
-          assert.equal(drafts.length, 1);
-          assert.equal(drafts[0].id, id);
-          assert.isTrue(diffCommentsModifiedStub.called);
-        });
-      });
     });
 
     suite('diff header', () => {
       setup(() => {
-        element._diff = {
+        element = fixture('basic');
+        element.diff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
           meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
             lines: 560},
@@ -1061,21 +728,30 @@
 
       test('hidden', () => {
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+        element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', '--- a/test.jpg');
+        element.push('diff.diff_header', '--- a/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', '+++ b/test.jpg');
+        element.push('diff.diff_header', '+++ b/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'test');
+        element.push('diff.diff_header', 'test');
         assert.equal(element._diffHeaderItems.length, 1);
         flushAsynchronousOperations();
 
         assert.equal(element.$.diffHeader.textContent.trim(), 'test');
-        element.set('_diff.binary', true);
+      });
+
+      test('binary files', () => {
+        element.diff.binary = true;
         assert.equal(element._diffHeaderItems.length, 0);
+        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('diff.diff_header', 'test');
+        assert.equal(element._diffHeaderItems.length, 1);
+        element.push('diff.diff_header', 'Binary files differ');
+        assert.equal(element._diffHeaderItems.length, 1);
       });
     });
 
@@ -1085,39 +761,52 @@
       setup(() => {
         element = fixture('basic');
         renderStub = sandbox.stub(element.$.diffBuilder, 'render',
-            () => Promise.resolve());
+            () => {
+              Promise.resolve();
+              element.$.diffBuilder.dispatchEvent(
+                  new CustomEvent('render', {bubbles: true, composed: true}));
+            });
         const mock = document.createElement('mock-diff-response');
-        element._diff = mock.diffResponse;
-        element.comments = {left: [], right: []};
+        sandbox.stub(element.$.diffBuilder, 'getDiffLength').returns(10000);
+        element.diff = mock.diffResponse;
         element.noRenderOnPrefsChange = true;
       });
 
-      test('lage render w/ context = 10', () => {
-        element.prefs = {context: 10};
-        sandbox.stub(element, '_diffLength', () => 10000);
-        return element._renderDiffTable().then(() => {
+      test('large render w/ context = 10', done => {
+        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: 10});
+        function rendered() {
           assert.isTrue(renderStub.called);
           assert.isFalse(element._showWarning);
-        });
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+        element._renderDiffTable();
       });
 
-      test('lage render w/ whole file and bypass', () => {
-        element.prefs = {context: -1};
+      test('large render w/ whole file and bypass', done => {
+        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
         element._safetyBypass = 10;
-        sandbox.stub(element, '_diffLength', () => 10000);
-        return element._renderDiffTable().then(() => {
+        function rendered() {
           assert.isTrue(renderStub.called);
           assert.isFalse(element._showWarning);
-        });
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+        element._renderDiffTable();
       });
 
-      test('lage render w/ whole file and no bypass', () => {
-        element.prefs = {context: -1};
-        sandbox.stub(element, '_diffLength', () => 10000);
-        return element._renderDiffTable().then(() => {
+      test('large render w/ whole file and no bypass', done => {
+        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
+        function rendered() {
           assert.isFalse(renderStub.called);
           assert.isTrue(element._showWarning);
-        });
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+        element._renderDiffTable();
       });
     });
 
@@ -1126,144 +815,196 @@
         element = fixture('basic');
       });
 
-      test('clearBlame', () => {
-        element._blame = [];
+      test('unsetting', () => {
+        element.blame = [];
         const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
         element.classList.add('showBlame');
-        element.clearBlame();
-        assert.isNull(element._blame);
+        element.blame = null;
         assert.isTrue(setBlameSpy.calledWithExactly(null));
         assert.isFalse(element.classList.contains('showBlame'));
       });
 
-      test('loadBlame', () => {
+      test('setting', () => {
         const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame().then(() => {
-          assert.isTrue(getBlameStub.calledWithExactly(
-              42, 5, 'foo/bar.baz', true));
-          assert.isFalse(showAlertStub.called);
-          assert.equal(element._blame, mockBlame);
-          assert.isTrue(element.classList.contains('showBlame'));
-        });
-      });
-
-      test('loadBlame empty', () => {
-        const mockBlame = [];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame()
-            .then(() => {
-              assert.isTrue(false, 'Promise should not resolve');
-            })
-            .catch(() => {
-              assert.isTrue(showAlertStub.calledOnce);
-              assert.isNull(element._blame);
-              assert.isFalse(element.classList.contains('showBlame'));
-            });
+        element.blame = mockBlame;
+        assert.isTrue(element.classList.contains('showBlame'));
       });
     });
 
-    suite('_reportDiff', () => {
-      let reportStub;
+    suite('trailing newlines', () => {
+      setup(() => {
+        element = fixture('basic');
+      });
+
+      suite('_lastChunkForSide', () => {
+        test('deltas', () => {
+          const diff = {content: [
+            {a: ['foo', 'bar'], b: ['baz']},
+            {ab: ['foo', 'bar', 'baz']},
+            {b: ['foo']},
+          ]};
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
+
+          diff.content.push({a: ['foo'], b: ['bar']});
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
+        });
+
+        test('addition', () => {
+          const diff = {content: [
+            {b: ['foo', 'bar', 'baz']},
+          ]};
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+          assert.isNull(element._lastChunkForSide(diff, true));
+        });
+
+        test('deletion', () => {
+          const diff = {content: [
+            {a: ['foo', 'bar', 'baz']},
+          ]};
+          assert.isNull(element._lastChunkForSide(diff, false));
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+        });
+
+        test('empty', () => {
+          const diff = {content: []};
+          assert.isNull(element._lastChunkForSide(diff, false));
+          assert.isNull(element._lastChunkForSide(diff, true));
+        });
+      });
+
+      suite('_hasTrailingNewlines', () => {
+        test('shared no trailing', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide')
+              .returns({ab: ['foo', 'bar']});
+          assert.isFalse(element._hasTrailingNewlines(diff, false));
+          assert.isFalse(element._hasTrailingNewlines(diff, true));
+        });
+
+        test('delta trailing in right', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide')
+              .returns({a: ['foo', 'bar'], b: ['baz', '']});
+          assert.isTrue(element._hasTrailingNewlines(diff, false));
+          assert.isFalse(element._hasTrailingNewlines(diff, true));
+        });
+
+        test('addition', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+            if (leftSide) { return null; }
+            return {b: ['foo', '']};
+          });
+          assert.isTrue(element._hasTrailingNewlines(diff, false));
+          assert.isNull(element._hasTrailingNewlines(diff, true));
+        });
+
+        test('deletion', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+            if (!leftSide) { return null; }
+            return {a: ['foo']};
+          });
+          assert.isNull(element._hasTrailingNewlines(diff, false));
+          assert.isFalse(element._hasTrailingNewlines(diff, true));
+        });
+      });
+
+      test('_computeNewlineWarning', () => {
+        const NO_NEWLINE_BASE = 'No newline at end of base file.';
+        const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+
+        let hasLeft;
+        let hasRight;
+        sandbox.stub(element, '_hasTrailingNewlines', (diff, left) => {
+          if (left) { return hasLeft; }
+          return hasRight;
+        });
+        const diff = undefined;
+
+        // The revision has a trailing newline, but the base doesn't.
+        hasLeft = true;
+        hasRight = false;
+        assert.equal(element._computeNewlineWarning(diff), NO_NEWLINE_REVISION);
+
+        // Trailing newlines were undetermined in the revision.
+        hasLeft = true;
+        hasRight = null;
+        assert.isNull(element._computeNewlineWarning(diff));
+
+        // Missing trailing newlines in the base.
+        hasLeft = false;
+        hasRight = null;
+        assert.equal(element._computeNewlineWarning(diff), NO_NEWLINE_BASE);
+
+        // Missing trailing newlines in both.
+        hasLeft = false;
+        hasRight = false;
+        assert.equal(element._computeNewlineWarning(diff),
+            NO_NEWLINE_BASE + ' — ' + NO_NEWLINE_REVISION);
+      });
+
+      test('_computeNewlineWarningClass', () => {
+        const hidden = 'newlineWarning hidden';
+        const shown = 'newlineWarning';
+        assert.equal(element._computeNewlineWarningClass(null, true), hidden);
+        assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
+        assert.equal(element._computeNewlineWarningClass(null, false), hidden);
+        assert.equal(element._computeNewlineWarningClass('foo', false), shown);
+      });
+    });
+
+    suite('key locations', () => {
+      let renderStub;
 
       setup(() => {
         element = fixture('basic');
-        element.patchRange = {basePatchNum: 1};
-        reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
+        element.prefs = {};
+        renderStub = sandbox.stub(element.$.diffBuilder, 'render')
+            .returns(new Promise(() => {}));
       });
 
-      test('null and content-less', () => {
-        element._reportDiff(null);
-        assert.isFalse(reportStub.called);
-
-        element._reportDiff({});
-        assert.isFalse(reportStub.called);
+      test('lineOfInterest is a key location', () => {
+        element.lineOfInterest = {number: 789, leftSide: true};
+        element._renderDiffTable();
+        assert.isTrue(renderStub.called);
+        assert.deepEqual(renderStub.lastCall.args[0], {
+          left: {789: true},
+          right: {},
+        });
       });
 
-      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('line comments are key locations', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'right');
+        threadEl.setAttribute('line-num', 3);
+        Polymer.dom(element).appendChild(threadEl);
+        Polymer.dom.flush();
+
+        element._renderDiffTable();
+        assert.isTrue(renderStub.called);
+        assert.deepEqual(renderStub.lastCall.args[0], {
+          left: {},
+          right: {3: true},
+        });
       });
 
-      test('diff w/ no rebase delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], b: ['bar', 'baz']},
-            {ab: ['foo', 'bar']},
-            {b: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
-          ],
-        };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-        assert.isUndefined(reportStub.lastCall.args[1]);
-      });
+      test('file comments are key locations', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'left');
+        Polymer.dom(element).appendChild(threadEl);
+        Polymer.dom.flush();
 
-      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._renderDiffTable();
+        assert.isTrue(renderStub.called);
+        assert.deepEqual(renderStub.lastCall.args[0], {
+          left: {FILE: true},
+          right: {},
+        });
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index 3de4284..114d840 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/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">
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index cf8118f..ea8aa47 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -31,6 +31,7 @@
 
   Polymer({
     is: 'gr-patch-range-select',
+    _legacyUndefinedCheck: true,
 
     properties: {
       availablePatches: Array,
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index 2614885..922a11c 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-patch-range-select</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/page/page.js"></script>
+<script src="/bower_components/page/page.js"></script>
 
 <link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
@@ -76,7 +78,7 @@
       element = commentApiWrapper.$.patchRange;
 
       // Stub methods on the changeComments object after changeComments has
-      // been initalized.
+      // been initialized.
       return commentApiWrapper.loadComments();
     });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
index 71b5bc3..17a4866 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
@@ -15,11 +15,9 @@
 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="/bower_components/polymer/polymer.html">
 <dom-module id="gr-ranged-comment-layer">
   <template>
-    <gr-reporting id="reporting" category="comments"></gr-reporting>
   </template>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
   <script src="gr-ranged-comment-layer.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index 6c7903d..3c1f45f 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -17,40 +17,52 @@
 (function() {
   'use strict';
 
-  const HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
-  const SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
+  const HOVER_PATH_PATTERN = /^commentRanges\.\#(\d+)\.hovering$/;
 
   const RANGE_HIGHLIGHT = 'range';
   const HOVER_HIGHLIGHT = 'rangeHighlight';
 
-  const NORMALIZE_RANGE_EVENT = 'normalize-range';
+  /** @typedef {{side: string, range: Gerrit.Range, hovering: boolean}} */
+  Gerrit.HoveredRange;
 
   Polymer({
     is: 'gr-ranged-comment-layer',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the range in a range comment was malformed and had to be
+     * normalized.
+     *
+     * It's `detail` has a `lineNum` and `side` parameter.
+     *
+     * @event normalize-range
+     */
 
     properties: {
-      comments: Object,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: Array,
       _listeners: {
         type: Array,
         value() { return []; },
       },
-      _commentMap: {
+      _rangesMap: {
         type: Object,
-        value() { return {left: [], right: []}; },
+        value() { return {left: {}, right: {}}; },
       },
     },
 
     observers: [
-      '_handleCommentChange(comments.*)',
+      '_handleCommentRangesChange(commentRanges.*)',
     ],
 
     /**
      * Layer method to add annotations to a line.
      * @param {!HTMLElement} el The DIV.contentText element to apply the
      *     annotation to.
+     * @param {!HTMLElement} lineNumberEl
      * @param {!Object} line The line object. (GrDiffLine)
      */
-    annotate(el, line) {
+    annotate(el, lineNumberEl, line) {
       let ranges = [];
       if (line.type === GrDiffLine.Type.REMOVE || (
           line.type === GrDiffLine.Type.BOTH &&
@@ -72,7 +84,7 @@
 
     /**
      * Register a listener for layer updates.
-     * @param {Function<Number, Number, String>} fn The update handler function.
+     * @param {function(number, number, string)} fn The update handler function.
      *     Should accept as arguments the line numbers for the start and end of
      *     the update and the side as a string.
      */
@@ -93,106 +105,92 @@
     },
 
     /**
-     * Handle change in the comments by updating the comment maps and by
+     * Handle change in the ranges by updating the ranges maps and by
      * emitting appropriate update notifications.
      * @param {Object} record The change record.
      */
-    _handleCommentChange(record) {
-      if (!record.path) { return; }
+    _handleCommentRangesChange(record) {
+      if (!record) return;
 
       // If the entire set of comments was changed.
-      if (record.path === 'comments') {
-        this._commentMap.left = this._computeCommentMap(this.comments.left);
-        this._commentMap.right = this._computeCommentMap(this.comments.right);
-        return;
+      if (record.path === 'commentRanges') {
+        this._rangesMap = {left: {}, right: {}};
+        for (const {side, range, hovering} of record.value) {
+          this._updateRangesMap(
+              side, range, hovering, (forLine, start, end, hovering) => {
+                forLine.push({start, end, hovering});
+              });
+        }
       }
 
       // If the change only changed the `hovering` property of a comment.
-      let match = record.path.match(HOVER_PATH_PATTERN);
-      let side;
-
+      const match = record.path.match(HOVER_PATH_PATTERN);
       if (match) {
-        side = match[1];
-        const index = match[2];
-        const comment = this.comments[side][index];
-        if (comment && comment.range) {
-          this._commentMap[side] = this._computeCommentMap(this.comments[side]);
-          this._notifyUpdateRange(
-              comment.range.start_line, comment.range.end_line, side);
-        }
-        return;
+        const commentRangesIndex = match[1];
+        const {side, range, hovering} = this.commentRanges[commentRangesIndex];
+        this._updateRangesMap(
+            side, range, hovering, (forLine, start, end, hovering) => {
+              const index = forLine.findIndex(lineRange =>
+                  lineRange.start === start && lineRange.end === end);
+              forLine[index].hovering = hovering;
+            });
       }
 
       // If comments were spliced in or out.
-      match = record.path.match(SPLICE_PATH_PATTERN);
-      if (match) {
-        side = match[1];
-        this._commentMap[side] = this._computeCommentMap(this.comments[side]);
-        this._handleCommentSplice(record.value, side);
+      if (record.path === 'commentRanges.splices') {
+        for (const indexSplice of record.value.indexSplices) {
+          const removed = indexSplice.removed;
+          for (const {side, range, hovering} of removed) {
+            this._updateRangesMap(
+                side, range, hovering, (forLine, start, end) => {
+                  const index = forLine.findIndex(lineRange =>
+                      lineRange.start === start && lineRange.end === end);
+                  forLine.splice(index, 1);
+                });
+          }
+          const added = indexSplice.object.slice(
+              indexSplice.index, indexSplice.index + indexSplice.addedCount);
+          for (const {side, range, hovering} of added) {
+            this._updateRangesMap(
+                side, range, hovering, (forLine, start, end, hovering) => {
+                  forLine.push({start, end, hovering});
+                });
+          }
+        }
       }
     },
 
-    /**
-     * Take a list of comments and return a sparse list mapping line numbers to
-     * partial ranges. Uses an end-character-index of -1 to indicate the end of
-     * the line.
-     * @param {?} commentList The list of comments.
-     *    Getting this param to match closure requirements caused problems.
-     * @return {!Object} The sparse list.
-     */
-    _computeCommentMap(commentList) {
-      const result = {};
-      for (const comment of commentList) {
-        if (!comment.range) { continue; }
-        const range = comment.range;
-        for (let line = range.start_line; line <= range.end_line; line++) {
-          if (!result[line]) { result[line] = []; }
-          result[line].push({
-            comment,
-            start: line === range.start_line ? range.start_character : 0,
-            end: line === range.end_line ? range.end_character : -1,
-          });
-        }
+    _updateRangesMap(side, range, hovering, operation) {
+      const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
+      for (let line = range.start_line; line <= range.end_line; line++) {
+        const forLine = forSide[line] || (forSide[line] = []);
+        const start = line === range.start_line ? range.start_character : 0;
+        const end = line === range.end_line ? range.end_character : -1;
+        operation(forLine, start, end, hovering);
       }
-      return result;
-    },
-
-    /**
-     * Translate a splice record into range update notifications.
-     */
-    _handleCommentSplice(record, side) {
-      if (!record || !record.indexSplices) { return; }
-
-      for (const splice of record.indexSplices) {
-        const ranges = splice.removed.length ?
-            splice.removed.map(c => { return c.range; }) :
-            [splice.object[splice.index].range];
-        for (const range of ranges) {
-          if (!range) { continue; }
-          this._notifyUpdateRange(range.start_line, range.end_line, side);
-        }
-      }
+      this._notifyUpdateRange(range.start_line, range.end_line, side);
     },
 
     _getRangesForLine(line, side) {
       const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
-      const ranges = this.get(['_commentMap', side, lineNum]) || [];
+      const ranges = this.get(['_rangesMap', side, lineNum]) || [];
       return ranges
           .map(range => {
-            range = {
-              start: range.start,
-              end: range.end === -1 ? line.text.length : range.end,
-              hovering: !!range.comment.__hovering,
-            };
+            // Make a copy, so that the normalization below does not mess with
+            // our map.
+            range = Object.assign({}, range);
+            range.end = range.end === -1 ? line.text.length : range.end;
 
             // Normalize invalid ranges where the start is after the end but the
             // start still makes sense. Set the end to the end of the line.
             // @see Issue 5744
             if (range.start >= range.end && range.start < line.text.length) {
               range.end = line.text.length;
-              this.$.reporting.reportInteraction(NORMALIZE_RANGE_EVENT,
-                  'Modified invalid comment range on l.' + lineNum +
-                  ' of the ' + side + ' side');
+              this.dispatchEvent(new CustomEvent('normalize-range', {
+                bubbles: true,
+                composed: true,
+                detail: {lineNum, side},
+              }));
             }
 
             return range;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index c541e26..b057d69 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ranged-comment-layer</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../gr-diff/gr-diff-line.js"></script>
 
@@ -40,62 +42,48 @@
     let sandbox;
 
     setup(() => {
-      const initialComments = {
-        left: [
-          {
-            id: '12345',
-            line: 39,
-            message: 'range comment',
-            range: {
-              end_character: 9,
-              end_line: 39,
-              start_character: 6,
-              start_line: 36,
-            },
-          }, {
-            id: '23456',
-            line: 100,
-            message: 'non range comment',
+      const initialCommentRanges = [
+        {
+          side: 'left',
+          range: {
+            end_character: 9,
+            end_line: 39,
+            start_character: 6,
+            start_line: 36,
           },
-        ],
-        right: [
-          {
-            id: '34567',
-            line: 10,
-            message: 'range comment',
-            range: {
-              end_character: 22,
-              end_line: 12,
-              start_character: 10,
-              start_line: 10,
-            },
-          }, {
-            id: '45678',
-            line: 100,
-            message: 'single line range comment',
-            range: {
-              end_character: 15,
-              end_line: 100,
-              start_character: 5,
-              start_line: 100,
-            },
-          }, {
-            id: '8675309',
-            line: 55,
-            message: 'nonsense range',
-            range: {
-              end_character: 2,
-              end_line: 55,
-              start_character: 32,
-              start_line: 55,
-            },
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 22,
+            end_line: 12,
+            start_character: 10,
+            start_line: 10,
           },
-        ],
-      };
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 15,
+            end_line: 100,
+            start_character: 5,
+            start_line: 100,
+          },
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 2,
+            end_line: 55,
+            start_character: 32,
+            start_line: 55,
+          },
+        },
+      ];
 
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      element.comments = initialComments;
+      element.commentRanges = initialCommentRanges;
     });
 
     teardown(() => {
@@ -107,6 +95,7 @@
       let el;
       let line;
       let annotateElementStub;
+      const lineNumberEl = document.createElement('td');
 
       setup(() => {
         sandbox = sinon.sandbox.create();
@@ -125,7 +114,7 @@
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 40;
 
-        element.annotate(el, line);
+        element.annotate(el, lineNumberEl, line);
 
         assert.isFalse(annotateElementStub.called);
       });
@@ -136,7 +125,7 @@
         const expectedStart = 6;
         const expectedLength = line.text.length - expectedStart;
 
-        element.annotate(el, line);
+        element.annotate(el, lineNumberEl, line);
 
         assert.isTrue(annotateElementStub.called);
         const lastCall = annotateElementStub.lastCall;
@@ -149,12 +138,12 @@
       test('type=Remove has-comment hovering', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 36;
-        element.set(['comments', 'left', 0, '__hovering'], true);
+        element.set(['commentRanges', 0, 'hovering'], true);
 
         const expectedStart = 6;
         const expectedLength = line.text.length - expectedStart;
 
-        element.annotate(el, line);
+        element.annotate(el, lineNumberEl, line);
 
         assert.isTrue(annotateElementStub.called);
         const lastCall = annotateElementStub.lastCall;
@@ -171,7 +160,7 @@
         const expectedStart = 6;
         const expectedLength = line.text.length - expectedStart;
 
-        element.annotate(el, line);
+        element.annotate(el, lineNumberEl, line);
 
         assert.isTrue(annotateElementStub.called);
         const lastCall = annotateElementStub.lastCall;
@@ -186,7 +175,7 @@
         line.beforeNumber = 36;
         el.setAttribute('data-side', 'right');
 
-        element.annotate(el, line);
+        element.annotate(el, lineNumberEl, line);
 
         assert.isFalse(annotateElementStub.called);
       });
@@ -199,7 +188,7 @@
         const expectedStart = 0;
         const expectedLength = 22;
 
-        element.annotate(el, line);
+        element.annotate(el, lineNumberEl, line);
 
         assert.isTrue(annotateElementStub.called);
         const lastCall = annotateElementStub.lastCall;
@@ -210,29 +199,18 @@
       });
     });
 
-    test('_handleCommentChange overwrite', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange overwrite', () => {
+      element.set('commentRanges', []);
 
-      element.set('comments', {left: [], right: []});
-
-      assert.isTrue(handlerSpy.called);
-      assert.equal(mapSpy.callCount, 2);
-
-      assert.equal(Object.keys(element._commentMap.left).length, 0);
-      assert.equal(Object.keys(element._commentMap.right).length, 0);
+      assert.equal(Object.keys(element._rangesMap.left).length, 0);
+      assert.equal(Object.keys(element._rangesMap.right).length, 0);
     });
 
-    test('_handleCommentChange hovering', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange hovering', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.set(['comments', 'right', 0, '__hovering'], true);
-
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
+      element.set(['commentRanges', 1, 'hovering'], true);
 
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
@@ -241,16 +219,11 @@
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice out', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange splice out', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.splice('comments.right', 0, 1);
-
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
+      element.splice('commentRanges', 1, 1);
 
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
@@ -259,16 +232,12 @@
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice in', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange splice in', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.splice('comments.left', element.comments.left.length, 0, {
-        id: '56123',
-        line: 250,
-        message: 'new range comment',
+      element.splice('commentRanges', 1, 0, {
+        side: 'left',
         range: {
           end_character: 15,
           end_line: 275,
@@ -277,9 +246,6 @@
         },
       });
 
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
-
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 250);
@@ -291,48 +257,48 @@
       // There is only one ranged comment on the left, but it spans ll.36-39.
       const leftKeys = [];
       for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-      assert.deepEqual(Object.keys(element._commentMap.left).sort(),
+      assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
           leftKeys.sort());
 
-      assert.equal(element._commentMap.left[36].length, 1);
-      assert.equal(element._commentMap.left[36][0].start, 6);
-      assert.equal(element._commentMap.left[36][0].end, -1);
+      assert.equal(element._rangesMap.left[36].length, 1);
+      assert.equal(element._rangesMap.left[36][0].start, 6);
+      assert.equal(element._rangesMap.left[36][0].end, -1);
 
-      assert.equal(element._commentMap.left[37].length, 1);
-      assert.equal(element._commentMap.left[37][0].start, 0);
-      assert.equal(element._commentMap.left[37][0].end, -1);
+      assert.equal(element._rangesMap.left[37].length, 1);
+      assert.equal(element._rangesMap.left[37][0].start, 0);
+      assert.equal(element._rangesMap.left[37][0].end, -1);
 
-      assert.equal(element._commentMap.left[38].length, 1);
-      assert.equal(element._commentMap.left[38][0].start, 0);
-      assert.equal(element._commentMap.left[38][0].end, -1);
+      assert.equal(element._rangesMap.left[38].length, 1);
+      assert.equal(element._rangesMap.left[38][0].start, 0);
+      assert.equal(element._rangesMap.left[38][0].end, -1);
 
-      assert.equal(element._commentMap.left[39].length, 1);
-      assert.equal(element._commentMap.left[39][0].start, 0);
-      assert.equal(element._commentMap.left[39][0].end, 9);
+      assert.equal(element._rangesMap.left[39].length, 1);
+      assert.equal(element._rangesMap.left[39][0].start, 0);
+      assert.equal(element._rangesMap.left[39][0].end, 9);
 
       // The right has two ranged comments, one spanning ll.10-12 and the other
       // on line 100.
       const rightKeys = [];
       for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
       rightKeys.push('55', '100');
-      assert.deepEqual(Object.keys(element._commentMap.right).sort(),
+      assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
           rightKeys.sort());
 
-      assert.equal(element._commentMap.right[10].length, 1);
-      assert.equal(element._commentMap.right[10][0].start, 10);
-      assert.equal(element._commentMap.right[10][0].end, -1);
+      assert.equal(element._rangesMap.right[10].length, 1);
+      assert.equal(element._rangesMap.right[10][0].start, 10);
+      assert.equal(element._rangesMap.right[10][0].end, -1);
 
-      assert.equal(element._commentMap.right[11].length, 1);
-      assert.equal(element._commentMap.right[11][0].start, 0);
-      assert.equal(element._commentMap.right[11][0].end, -1);
+      assert.equal(element._rangesMap.right[11].length, 1);
+      assert.equal(element._rangesMap.right[11][0].start, 0);
+      assert.equal(element._rangesMap.right[11][0].end, -1);
 
-      assert.equal(element._commentMap.right[12].length, 1);
-      assert.equal(element._commentMap.right[12][0].start, 0);
-      assert.equal(element._commentMap.right[12][0].end, 22);
+      assert.equal(element._rangesMap.right[12].length, 1);
+      assert.equal(element._rangesMap.right[12][0].start, 0);
+      assert.equal(element._rangesMap.right[12][0].end, 22);
 
-      assert.equal(element._commentMap.right[100].length, 1);
-      assert.equal(element._commentMap.right[100][0].start, 5);
-      assert.equal(element._commentMap.right[100][0].end, 15);
+      assert.equal(element._rangesMap.right[100].length, 1);
+      assert.equal(element._rangesMap.right[100][0].start, 5);
+      assert.equal(element._rangesMap.right[100][0].end, 15);
     });
 
     test('_getRangesForLine normalizes invalid ranges', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index 633530f..c2983f7 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-tooltip/gr-tooltip.html">
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index 6349ab6..26bf738 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -19,11 +19,12 @@
 
   Polymer({
     is: 'gr-selection-action-box',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the comment creation action was taken (hotkey, click).
      *
-     * @event create-comment
+     * @event create-range-comment
      */
 
     properties: {
@@ -34,10 +35,10 @@
       range: {
         type: Object,
         value: {
-          startLine: NaN,
-          startChar: NaN,
-          endLine: NaN,
-          endChar: NaN,
+          start_line: NaN,
+          start_character: NaN,
+          end_line: NaN,
+          end_character: NaN,
         },
       },
       positionBelow: Boolean,
@@ -63,7 +64,7 @@
       Polymer.dom.flush();
       const rect = this._getTargetBoundingRect(el);
       const boxRect = this.$.tooltip.getBoundingClientRect();
-      const parentRect = this.parentElement.getBoundingClientRect();
+      const parentRect = this._getParentBoundingClientRect();
       this.style.top =
           rect.top - parentRect.top - boxRect.height - 6 + 'px';
       this.style.left =
@@ -74,11 +75,18 @@
       Polymer.dom.flush();
       const rect = this._getTargetBoundingRect(el);
       const boxRect = this.$.tooltip.getBoundingClientRect();
-      const parentRect = this.parentElement.getBoundingClientRect();
+      const parentRect = this._getParentBoundingClientRect();
       this.style.top =
-          rect.top - parentRect.top + boxRect.height - 6 + 'px';
+      rect.top - parentRect.top + boxRect.height - 6 + 'px';
       this.style.left =
-          rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
+      rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
+    },
+
+    _getParentBoundingClientRect() {
+      // With native shadow DOM, the parent is the shadow root, not the gr-diff
+      // element
+      const parent = this.parentElement || this.parentNode.host;
+      return parent.getBoundingClientRect();
     },
 
     _getTargetBoundingRect(el) {
@@ -110,7 +118,7 @@
     },
 
     _fireCreateComment() {
-      this.fire('create-comment', {side: this.side, range: this.range});
+      this.fire('create-range-comment', {side: this.side, range: this.range});
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index 19155e4..b950e7b 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-selection-action-box</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-selection-action-box.html">
 
@@ -89,16 +91,16 @@
     test('event fired contains playload', () => {
       const side = 'left';
       const range = {
-        startLine: 1,
-        startChar: 11,
-        endLine: 2,
-        endChar: 42,
+        start_line: 1,
+        start_character: 11,
+        end_line: 2,
+        end_character: 42,
       };
       element.side = 'left';
       element.range = range;
       MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
       assert(element.fire.calledWithExactly(
-          'create-comment',
+          'create-range-comment',
           {
             side,
             range,
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
index 017cd5d..dd6bfec 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
@@ -14,13 +14,14 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-lib-loader/gr-lib-loader.html">
 
 <dom-module id="gr-syntax-layer">
   <template>
     <gr-lib-loader id="libLoader"></gr-lib-loader>
   </template>
+  <script src="../../../scripts/util.js"></script>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
   <script src="gr-syntax-layer.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index e258520..627a279 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -20,7 +20,10 @@
   const LANGUAGE_MAP = {
     'application/dart': 'dart',
     'application/json': 'json',
+    'application/x-powershell': 'powershell',
     'application/typescript': 'typescript',
+    'application/xml': 'xml',
+    'application/xquery': 'xquery',
     'application/x-erb': 'erb',
     'text/css': 'css',
     'text/html': 'html',
@@ -29,30 +32,63 @@
     'text/x-c': 'cpp',
     'text/x-c++src': 'cpp',
     'text/x-clojure': 'clojure',
+    'text/x-cmake': 'cmake',
+    'text/x-coffeescript': 'coffeescript',
     'text/x-common-lisp': 'lisp',
+    'text/x-crystal': 'crystal',
     'text/x-csharp': 'csharp',
     'text/x-csrc': 'cpp',
     'text/x-d': 'd',
+    'text/x-diff': 'diff',
+    'text/x-django': 'django',
+    'text/x-dockerfile': 'dockerfile',
+    'text/x-ebnf': 'ebnf',
+    'text/x-elm': 'elm',
+    'text/x-erlang': 'erlang',
+    'text/x-fortran': 'fortran',
     'text/x-go': 'go',
+    'text/x-groovy': 'groovy',
+    'text/x-haml': 'haml',
     'text/x-haskell': 'haskell',
+    'text/x-haxe': 'haxe',
+    'text/x-ini': 'ini',
     'text/x-java': 'java',
+    'text/x-julia': 'julia',
     'text/x-kotlin': 'kotlin',
+    'text/x-latex': 'latex',
+    'text/x-less': 'less',
     'text/x-lua': 'lua',
+    'text/x-mathematica': 'mathematica',
+    'text/x-nginx-conf': 'nginx',
+    'text/x-nsis': 'nsis',
     'text/x-objectivec': 'objectivec',
     'text/x-ocaml': 'ocaml',
     'text/x-perl': 'perl',
+    'text/x-pgsql': 'pgsql', // postgresql
     'text/x-php': 'php',
+    'text/x-properties': 'properties',
     'text/x-protobuf': 'protobuf',
     'text/x-puppet': 'puppet',
     'text/x-python': 'python',
+    'text/x-q': 'q',
     'text/x-ruby': 'ruby',
     'text/x-rustsrc': 'rust',
     'text/x-scala': 'scala',
+    'text/x-scss': 'scss',
+    'text/x-scheme': 'scheme',
     'text/x-shell': 'shell',
+    'text/x-spreadsheet': 'excel',
     'text/x-sh': 'bash',
     'text/x-sql': 'sql',
     'text/x-swift': 'swift',
+    'text/x-systemverilog': 'sv',
+    'text/x-tcl': 'tcl',
+    'text/x-torque': 'torque',
+    'text/x-twig': 'twig',
+    'text/x-vb': 'vb',
+    'text/x-verilog': 'v',
     'text/x-yaml': 'yaml',
+    'text/vbscript': 'vbscript',
   };
   const ASYNC_DELAY = 10;
 
@@ -61,6 +97,7 @@
     'gr-diff gr-syntax gr-syntax-attribute': true,
     'gr-diff gr-syntax gr-syntax-built_in': true,
     'gr-diff gr-syntax gr-syntax-comment': true,
+    'gr-diff gr-syntax gr-syntax-function': true,
     'gr-diff gr-syntax gr-syntax-keyword': true,
     'gr-diff gr-syntax gr-syntax-link': true,
     'gr-diff gr-syntax gr-syntax-literal': true,
@@ -68,6 +105,7 @@
     'gr-diff gr-syntax gr-syntax-meta-keyword': true,
     'gr-diff gr-syntax gr-syntax-name': true,
     'gr-diff gr-syntax gr-syntax-number': true,
+    'gr-diff gr-syntax gr-syntax-params': true,
     'gr-diff gr-syntax gr-syntax-regexp': true,
     'gr-diff gr-syntax gr-syntax-selector-attr': true,
     'gr-diff gr-syntax gr-syntax-selector-class': true,
@@ -91,6 +129,7 @@
 
   Polymer({
     is: 'gr-syntax-layer',
+    _legacyUndefinedCheck: true,
 
     properties: {
       diff: {
@@ -117,6 +156,16 @@
       },
       /** @type {?number} */
       _processHandle: Number,
+      /**
+       * The promise last returned from `process()` while the asynchronous
+       * processing is running - `null` otherwise. Provides a `cancel()`
+       * method that rejects it with `{isCancelled: true}`.
+       * @type {?Object}
+       */
+      _processPromise: {
+        type: Object,
+        value: null,
+      },
       _hljs: Object,
     },
 
@@ -128,9 +177,10 @@
      * Annotation layer method to add syntax annotations to the given element
      * for the given line.
      * @param {!HTMLElement} el
+     * @param {!HTMLElement} lineNumberEl
      * @param {!Object} line (GrDiffLine)
      */
-    annotate(el, line) {
+    annotate(el, lineNumberEl, line) {
       if (!this.enabled) { return; }
 
       // Determine the side.
@@ -160,12 +210,23 @@
       }
     },
 
+    _getLanguage(diffFileMetaInfo) {
+      // The Gerrit API provides only content-type, but for other users of
+      // gr-diff it may be more convenient to specify the language directly.
+      return diffFileMetaInfo.language ||
+          LANGUAGE_MAP[diffFileMetaInfo.content_type];
+    },
+
     /**
-     * Start processing symtax for the loaded diff and notify layer listeners
+     * Start processing syntax for the loaded diff and notify layer listeners
      * as syntax info comes online.
      * @return {Promise}
      */
     process() {
+      // Cancel any still running process() calls, because they append to the
+      // same _baseRanges and _revisionRanges fields.
+      this.cancel();
+
       // Discard existing ranges.
       this._baseRanges = [];
       this._revisionRanges = [];
@@ -174,13 +235,11 @@
         return Promise.resolve();
       }
 
-      this.cancel();
-
       if (this.diff.meta_a) {
-        this._baseLanguage = LANGUAGE_MAP[this.diff.meta_a.content_type];
+        this._baseLanguage = this._getLanguage(this.diff.meta_a);
       }
       if (this.diff.meta_b) {
-        this._revisionLanguage = LANGUAGE_MAP[this.diff.meta_b.content_type];
+        this._revisionLanguage = this._getLanguage(this.diff.meta_b);
       }
       if (!this._baseLanguage && !this._revisionLanguage) {
         return Promise.resolve();
@@ -195,49 +254,55 @@
         lastNotify: {left: 1, right: 1},
       };
 
-      return this._loadHLJS().then(() => {
-        return new Promise(resolve => {
-          const nextStep = () => {
-            this._processHandle = null;
-            this._processNextLine(state);
+      this._processPromise = util.makeCancelable(this._loadHLJS()
+          .then(() => {
+            return new Promise(resolve => {
+              const nextStep = () => {
+                this._processHandle = null;
+                this._processNextLine(state);
 
-            // Move to the next line in the section.
-            state.lineIndex++;
+                // Move to the next line in the section.
+                state.lineIndex++;
 
-            // If the section has been exhausted, move to the next one.
-            if (this._isSectionDone(state)) {
-              state.lineIndex = 0;
-              state.sectionIndex++;
-            }
+                // If the section has been exhausted, move to the next one.
+                if (this._isSectionDone(state)) {
+                  state.lineIndex = 0;
+                  state.sectionIndex++;
+                }
 
-            // If all sections have been exhausted, finish.
-            if (state.sectionIndex >= this.diff.content.length) {
-              resolve();
-              this._notify(state);
-              return;
-            }
+                // If all sections have been exhausted, finish.
+                if (state.sectionIndex >= this.diff.content.length) {
+                  resolve();
+                  this._notify(state);
+                  return;
+                }
 
-            if (state.lineIndex % 100 === 0) {
-              this._notify(state);
-              this._processHandle = this.async(nextStep, ASYNC_DELAY);
-            } else {
-              nextStep.call(this);
-            }
-          };
+                if (state.lineIndex % 100 === 0) {
+                  this._notify(state);
+                  this._processHandle = this.async(nextStep, ASYNC_DELAY);
+                } else {
+                  nextStep.call(this);
+                }
+              };
 
-          this._processHandle = this.async(nextStep, 1);
-        });
-      });
+              this._processHandle = this.async(nextStep, 1);
+            });
+          }));
+      return this._processPromise
+          .finally(() => { this._processPromise = null; });
     },
 
     /**
      * Cancel any asynchronous syntax processing jobs.
      */
     cancel() {
-      if (this._processHandle) {
+      if (this._processHandle != null) {
         this.cancelAsync(this._processHandle);
         this._processHandle = null;
       }
+      if (this._processPromise) {
+        this._processPromise.cancel();
+      }
     },
 
     _diffChanged() {
@@ -311,7 +376,8 @@
       // To store the result of the syntax highlighter.
       let result;
 
-      if (this._baseLanguage && baseLine !== undefined) {
+      if (this._baseLanguage && baseLine !== undefined &&
+          this._hljs.getLanguage(this._baseLanguage)) {
         baseLine = this._workaround(this._baseLanguage, baseLine);
         result = this._hljs.highlight(this._baseLanguage, baseLine, true,
             state.baseContext);
@@ -319,7 +385,8 @@
         state.baseContext = result.top;
       }
 
-      if (this._revisionLanguage && revisionLine !== undefined) {
+      if (this._revisionLanguage && revisionLine !== undefined &&
+          this._hljs.getLanguage(this._revisionLanguage)) {
         revisionLine = this._workaround(this._revisionLanguage, revisionLine);
         result = this._hljs.highlight(this._revisionLanguage, revisionLine,
             true, state.revisionContext);
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index f2458fc..472db21 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-syntax-layer</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-syntax-layer.html">
@@ -38,6 +40,7 @@
     let sandbox;
     let diff;
     let element;
+    const lineNumberEl = document.createElement('td');
 
     function getMockHLJS() {
       const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
@@ -50,6 +53,11 @@
             top: state === undefined ? 1 : state + 1,
           };
         },
+        // Return something truthy because this method is used to check if the
+        // language is supported.
+        getLanguage(s) {
+          return {};
+        },
       };
     }
 
@@ -72,7 +80,7 @@
       const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 12;
 
-      element.annotate(el, line);
+      element.annotate(el, lineNumberEl, line);
 
       assert.isFalse(annotationSpy.called);
     });
@@ -94,7 +102,7 @@
         className,
       }];
 
-      element.annotate(el, line);
+      element.annotate(el, lineNumberEl, line);
 
       assert.isTrue(annotationSpy.called);
       assert.equal(annotationSpy.lastCall.args[0], el);
@@ -122,7 +130,7 @@
       }];
       element.enabled = false;
 
-      element.annotate(el, line);
+      element.annotate(el, lineNumberEl, line);
 
       assert.isFalse(annotationSpy.called);
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
index 0e028d8..a122113 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
@@ -29,10 +29,17 @@
       .contentText {
         color: var(--syntax-default-color);
       }
+      .gr-syntax-attribute {
+        color: var(--syntax-attribute-color);
+      }
+      .gr-syntax-function {
+        color: var(--syntax-function-color);
+      }
       .gr-syntax-meta {
         color: var(--syntax-meta-color);
       }
-      .gr-syntax-keyword {
+      .gr-syntax-keyword,
+      .gr-syntax-name {
         color: var(--syntax-keyword-color);
         line-height: 1;
       }
@@ -93,6 +100,9 @@
       .gr-syntax-template-tag {
         color: var(--syntax-template-tag-color);
       }
+      .gr-syntax-param {
+        color: var(--syntax-param-color);
+      }
     </style>
   </template>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
new file mode 100644
index 0000000..5072b9d
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
@@ -0,0 +1,61 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="/bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-documentation-search">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-table-styles"></style>
+    <gr-list-view
+        filter="[[_filter]]"
+        items=false
+        offset=0
+        loading="[[_loading]]"
+        path="[[_path]]">
+      <table id="list" class="genericList">
+        <tr class="headerRow">
+          <th class="name topHeader">Name</th>
+          <th class="name topHeader"></th>
+          <th class="name topHeader"></th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+        <tbody class$="[[computeLoadingClass(_loading)]]">
+          <template is="dom-repeat" items="[[_documentationSearches]]">
+            <tr class="table">
+              <td class="name">
+                <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
+              </td>
+              <td></td>
+              <td></td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </gr-list-view>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-documentation-search.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
new file mode 100644
index 0000000..995326f
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
@@ -0,0 +1,82 @@
+/**
+ * @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-documentation-search',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/Documentation',
+      },
+      _documentationSearches: Array,
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: {
+        type: String,
+        value: '',
+      },
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+    ],
+
+    attached() {
+      this.dispatchEvent(
+          new CustomEvent('title-change', {title: 'Documentation Search'}));
+    },
+
+    _paramsChanged(params) {
+      this._loading = true;
+      this._filter = this.getFilterValue(params);
+
+      return this._getDocumentationSearches(this._filter);
+    },
+
+    _getDocumentationSearches(filter) {
+      this._documentationSearches = [];
+      return this.$.restAPI.getDocumentationSearches(filter)
+          .then(searches => {
+            // Late response.
+            if (filter !== this._filter || !searches) { return; }
+            this._documentationSearches = searches;
+            this._loading = false;
+          });
+    },
+
+    _computeSearchUrl(url) {
+      if (!url) { return ''; }
+      return this.getBaseUrl() + '/' + url;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
new file mode 100644
index 0000000..84298e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
@@ -0,0 +1,122 @@
+<!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-documentation-search</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-documentation-search.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-documentation-search></gr-documentation-search>
+  </template>
+</test-fixture>
+
+<script>
+  let counter;
+  const documentationGenerator = () => {
+    return {
+      title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
+      url: 'Documentation/dev-rest-api.html',
+    };
+  };
+
+  suite('gr-documentation-search tests', () => {
+    let element;
+    let documentationSearches;
+    let sandbox;
+    let value;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(page, 'show');
+      element = fixture('basic');
+      counter = 0;
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list with searches for documentation', () => {
+      setup(done => {
+        documentationSearches = _.times(26, documentationGenerator);
+        stub('gr-rest-api-interface', {
+          getDocumentationSearches() {
+            return Promise.resolve(documentationSearches);
+          },
+        });
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('test for test repo in the list', done => {
+        flush(() => {
+          assert.equal(element._documentationSearches[0].title,
+              'Gerrit Code Review - REST API Developers Notes1');
+          assert.equal(element._documentationSearches[0].url,
+              'Documentation/dev-rest-api.html');
+          done();
+        });
+      });
+    });
+
+    suite('filter', () => {
+      setup(() => {
+        documentationSearches = _.times(25, documentationGenerator);
+        documentationSearchesFiltered = _.times(1, documentationSearches);
+      });
+
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getDocumentationSearches', () => {
+          return Promise.resolve(documentationSearches);
+        });
+        const value = {
+          filter: 'test',
+        };
+        element._paramsChanged(value).then(() => {
+          assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
+              .calledWithExactly('test'));
+          done();
+        });
+      });
+    });
+
+    suite('loading', () => {
+      test('correct contents are displayed', () => {
+        assert.isTrue(element._loading);
+        assert.equal(element.computeLoadingClass(element._loading), 'loading');
+        assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+        element._loading = false;
+        element._repos = _.times(25, documentationGenerator);
+
+        flushAsynchronousOperations();
+        assert.equal(element.computeLoadingClass(element._loading), '');
+        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
index 093e979..5c13ff9 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-default-editor">
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
index 168ded8..bae838e 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-default-editor',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the content of the editor changes.
@@ -31,8 +32,9 @@
     },
 
     _handleTextareaInput(e) {
-      this.dispatchEvent(new CustomEvent('content-change',
-          {detail: {value: e.target.value}, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'content-change',
+          {detail: {value: e.target.value}, bubbles: true, composed: true}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
index b79cd9d..c986e7c 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-default-editor</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-default-editor.html">
@@ -50,7 +52,7 @@
       element.addEventListener('content-change', contentChangedHandler);
       textarea.value = 'test';
       textarea.dispatchEvent(new CustomEvent('input',
-          {target: textarea, bubbles: true}));
+          {target: textarea, bubbles: true, composed: true}));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
index 61a9e69..2d66bc2 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
@@ -15,15 +15,15 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -46,10 +46,10 @@
         margin-left: 1em;
         text-decoration: none;
       }
-      gr-confirm-dialog {
+      gr-dialog {
         width: 50em;
       }
-      gr-confirm-dialog .main {
+      gr-dialog .main {
         width: 100%;
       }
       gr-autocomplete {
@@ -71,7 +71,7 @@
         width: 100%;
       }
       @media screen and (max-width: 50em) {
-        gr-confirm-dialog {
+        gr-dialog {
           width: 100vw;
         }
       }
@@ -84,7 +84,7 @@
           on-tap="_handleTap">[[action.label]]</gr-button>
     </template>
     <gr-overlay id="overlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="openDialog"
           class="invisible dialog"
           disabled$="[[!_isValidPath(_path)]]"
@@ -101,8 +101,8 @@
               query="[[_query]]"
               text="{{_path}}"></gr-autocomplete>
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="deleteDialog"
           class="invisible dialog"
           disabled$="[[!_isValidPath(_path)]]"
@@ -117,8 +117,8 @@
               query="[[_query]]"
               text="{{_path}}"></gr-autocomplete>
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="renameDialog"
           class="invisible dialog"
           disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
@@ -132,14 +132,19 @@
               placeholder="Enter an existing full file path."
               query="[[_query]]"
               text="{{_path}}"></gr-autocomplete>
-          <input
-              class="newPathInput"
-              is="iron-input"
+          <iron-input
+              class="newPathIronInput"
               bind-value="{{_newPath}}"
-              placeholder="Enter the new path."/>
+              placeholder="Enter the new path.">
+            <input
+                class="newPathInput"
+                is="iron-input"
+                bind-value="{{_newPath}}"
+                placeholder="Enter the new path.">
+          </iron-input>
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="restoreDialog"
           class="invisible dialog"
           confirm-label="Restore"
@@ -148,12 +153,16 @@
           on-cancel="_handleDialogCancel">
         <div class="header" slot="header">Restore this file?</div>
         <div class="main" slot="main">
-          <input
-              is="iron-input"
+          <iron-input
               disabled
-              bind-value="{{_path}}"/>
+              bind-value="{{_path}}">
+            <input
+                is="iron-input"
+                disabled
+                bind-value="{{_path}}">
+          </iron-input>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
index 8740b0d..2658bd2 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-edit-controls',
+    _legacyUndefinedCheck: true,
     properties: {
       change: Object,
       patchNum: String,
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
index c67a2af..df029ed 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-controls</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-edit-controls.html">
@@ -189,6 +191,9 @@
     let navStub;
     let renameStub;
     let renameAutocomplete;
+    const inputSelector = Polymer.Element ?
+        '.newPathIronInput' :
+        '.newPathInput';
 
     setup(() => {
       navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
@@ -208,7 +213,7 @@
         assert.isTrue(queryStub.called);
         assert.isTrue(element.$.renameDialog.disabled);
 
-        element.$.renameDialog.querySelector('.newPathInput').bindValue =
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
             'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
@@ -236,7 +241,7 @@
         assert.isTrue(queryStub.called);
         assert.isTrue(element.$.renameDialog.disabled);
 
-        element.$.renameDialog.querySelector('.newPathInput').bindValue =
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
             'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
@@ -258,7 +263,7 @@
         assert.isTrue(element.$.renameDialog.disabled);
         element.$.renameDialog.querySelector('gr-autocomplete').text =
             'src/test.cpp';
-        element.$.renameDialog.querySelector('.newPathInput').bindValue =
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
             'src/test.newPath';
         assert.isFalse(element.$.renameDialog.disabled);
         MockInteractions.tap(element.$.renameDialog.$$('gr-button'));
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
index c57a147..1ab08a3 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
index 82010f5..9c59a9a 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-edit-file-controls',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when an action in the overflow menu is tapped.
@@ -45,8 +46,9 @@
     },
 
     _dispatchFileAction(action, path) {
-      this.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action, path}, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'file-action-tap',
+          {detail: {action, path}, bubbles: true, composed: true}));
     },
 
     _computeFileActions(actions) {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
index 12d9e0b..7979e57 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-file-controls</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-edit-constants.html">
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..6c2cbb5 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
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
@@ -51,7 +51,6 @@
       }
       header gr-editable-label {
         font-size: var(--font-size-large);
-        font-weight: bold;
         --label-style: {
           text-overflow: initial;
           white-space: initial;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
index bf7fc99..ec6f110 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -26,6 +26,7 @@
 
   Polymer({
     is: 'gr-editor-view',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -103,7 +104,9 @@
     },
 
     _paramsChanged(value) {
-      if (value.view !== Gerrit.Nav.View.EDIT) { return; }
+      if (value.view !== Gerrit.Nav.View.EDIT) {
+        return;
+      }
 
       this._changeNum = value.changeNum;
       this._path = value.path;
@@ -133,7 +136,9 @@
 
     _handlePathChanged(e) {
       const path = e.detail;
-      if (path === this._path) { return Promise.resolve(); }
+      if (path === this._path) {
+        return Promise.resolve();
+      }
       return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
           this._path, path).then(res => {
             if (!res.ok) { return; }
@@ -157,8 +162,11 @@
           .then(res => {
             if (storedContent && storedContent.message &&
                 storedContent.message !== res.content) {
-              this.dispatchEvent(new CustomEvent('show-alert',
-                  {detail: {message: RESTORED_MESSAGE}, bubbles: true}));
+              this.dispatchEvent(new CustomEvent('show-alert', {
+                detail: {message: RESTORED_MESSAGE},
+                bubbles: true,
+                composed: true,
+              }));
 
               this._newContent = storedContent.message;
             } else {
@@ -196,11 +204,14 @@
       this.dispatchEvent(new CustomEvent('show-alert', {
         detail: {message},
         bubbles: true,
+        composed: true,
       }));
     },
 
     _computeSaveDisabled(content, newContent, saving) {
-      if (saving) { return true; }
+      if (saving) {
+        return true;
+      }
       return content === newContent;
     },
 
@@ -223,7 +234,9 @@
 
     _handleSaveShortcut(e) {
       e.preventDefault();
-      if (!this._saveDisabled) { this._saveEdit(); }
+      if (!this._saveDisabled) {
+        this._saveEdit();
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
index 2f5332d..abb8131 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editor-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-editor-view.html">
@@ -121,7 +123,7 @@
     const storeStub = sandbox.spy(element.$.storage, 'setEditableContentItem');
     element._newContent = 'test';
     element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
-      bubbles: true,
+      bubbles: true, composed: true,
       detail: {value: 'new content value'},
     }));
     element.flushDebouncer('store');
diff --git a/polygerrit-ui/app/elements/gr-app-element.html b/polygerrit-ui/app/elements/gr-app-element.html
new file mode 100644
index 0000000..46c4502
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element.html
@@ -0,0 +1,231 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script src="/bower_components/moment/moment.js"></script>
+<script src="../scripts/util.js"></script>
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../styles/shared-styles.html">
+<link rel="import" href="../styles/themes/app-theme.html">
+<link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
+<link rel="import" href="./documentation/gr-documentation-search/gr-documentation-search.html">
+<link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
+<link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
+<link rel="import" href="./change/gr-change-view/gr-change-view.html">
+<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
+<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
+<link rel="import" href="./core/gr-main-header/gr-main-header.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="./core/gr-router/gr-router.html">
+<link rel="import" href="./core/gr-smart-search/gr-smart-search.html">
+<link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./edit/gr-editor-view/gr-editor-view.html">
+<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="./plugins/gr-endpoint-param/gr-endpoint-param.html">
+<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
+<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
+<link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
+<link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
+<link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
+<link rel="import" href="./shared/gr-fixed-panel/gr-fixed-panel.html">
+<link rel="import" href="./shared/gr-lib-loader/gr-lib-loader.html">
+<link rel="import" href="./shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-app-element">
+  <template>
+    <style include="shared-styles">
+      :host {
+        background-color: var(--view-background-color);
+        display: flex;
+        flex-direction: column;
+        min-height: 100%;
+      }
+      gr-fixed-panel {
+        /**
+         * This one should be greater that the z-index in gr-diff-view
+         * because gr-main-header contains overlay.
+         */
+        z-index: 10;
+      }
+      gr-main-header,
+      footer {
+        color: var(--primary-text-color);
+      }
+      gr-main-header {
+        background-color: var(--header-background-color);
+        padding: 0 var(--default-horizontal-margin);
+        border-bottom: 1px solid var(--border-color);
+      }
+      footer {
+        background-color: var(--footer-background-color);
+        border-top: 1px solid var(--border-color);
+        display: flex;
+        justify-content: space-between;
+        padding: .5rem var(--default-horizontal-margin);
+        z-index: 100;
+      }
+      main {
+        flex: 1;
+        padding-bottom: 2em;
+        position: relative;
+      }
+      .errorView {
+        align-items: center;
+        display: none;
+        flex-direction: column;
+        justify-content: center;
+        position: absolute;
+        top: 0;
+        right: 0;
+        bottom: 0;
+        left: 0;
+      }
+      .errorView.show {
+        display: flex;
+      }
+      .errorEmoji {
+        font-size: 2.6rem;
+      }
+      .errorText,
+      .errorMoreInfo {
+        margin-top: .75em;
+      }
+      .errorText {
+        font-size: 1.2rem;
+      }
+      .errorMoreInfo {
+        color: var(--deemphasized-text-color);
+      }
+      .feedback {
+        color: var(--error-text-color);
+      }
+    </style>
+    <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
+    <gr-fixed-panel id="header">
+      <gr-main-header
+          id="mainHeader"
+          search-query="{{params.query}}"
+          on-mobile-search="_mobileSearchToggle">
+      </gr-main-header>
+    </gr-fixed-panel>
+    <main>
+      <gr-smart-search
+          id="search"
+          search-query="{{params.query}}"
+          hidden="[[!mobileSearch]]">
+      </gr-smart-search>
+      <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
+        <gr-change-list-view
+            params="[[params]]"
+            account="[[_account]]"
+            view-state="{{_viewState.changeListView}}"></gr-change-list-view>
+      </template>
+      <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
+        <gr-dashboard-view
+            account="[[_account]]"
+            params="[[params]]"
+            view-state="{{_viewState.dashboardView}}"></gr-dashboard-view>
+      </template>
+      <template is="dom-if" if="[[_showChangeView]]" restamp="true">
+        <gr-change-view
+            params="[[params]]"
+            view-state="{{_viewState.changeView}}"
+            back-page="[[_lastSearchPage]]"></gr-change-view>
+      </template>
+      <template is="dom-if" if="[[_showEditorView]]" restamp="true">
+        <gr-editor-view
+            params="[[params]]"></gr-editor-view>
+      </template>
+      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+          <gr-diff-view
+              params="[[params]]"
+              change-view-state="{{_viewState.changeView}}"></gr-diff-view>
+        </template>
+      <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
+        <gr-settings-view
+            params="[[params]]"
+            on-account-detail-update="_handleAccountDetailUpdate">
+        </gr-settings-view>
+      </template>
+      <template is="dom-if" if="[[_showAdminView]]" restamp="true">
+        <gr-admin-view path="[[_path]]"
+            params=[[params]]></gr-admin-view>
+      </template>
+      <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
+        <gr-endpoint-decorator name="[[_pluginScreenName]]">
+          <gr-endpoint-param name="token" value="[[params.screen]]"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </template>
+      <template is="dom-if" if="[[_showCLAView]]" restamp="true">
+        <gr-cla-view></gr-cla-view>
+      </template>
+      <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
+        <gr-documentation-search
+            params="[[params]]">
+        </gr-documentation-search>
+      </template>
+      <div id="errorView" class="errorView">
+        <div class="errorEmoji">[[_lastError.emoji]]</div>
+        <div class="errorText">[[_lastError.text]]</div>
+        <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
+      </div>
+    </main>
+    <footer r="contentinfo">
+      <div>
+        Powered by <a href="https://www.gerritcodereview.com/" rel="noopener"
+        target="_blank">Gerrit Code Review</a>
+        ([[_version]])
+        <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
+      </div>
+      <div>
+        <a class="feedback"
+            href$="[[_feedbackUrl]]"
+            rel="noopener"
+            target="_blank"
+            hidden$="[[!_showFeedbackUrl(_feedbackUrl)]]">Report bug</a>
+        | Press &ldquo;?&rdquo; for keyboard shortcuts
+        <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
+      </div>
+    </footer>
+    <gr-overlay id="keyboardShortcuts" with-backdrop>
+      <gr-keyboard-shortcuts-dialog
+          view="[[params.view]]"
+          on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
+    </gr-overlay>
+    <gr-overlay id="registrationOverlay" with-backdrop>
+      <gr-registration-dialog
+          id="registrationDialog"
+          settings-url="[[_settingsUrl]]"
+          on-account-detail-update="_handleAccountDetailUpdate"
+          on-close="_handleRegistrationDialogClose">
+      </gr-registration-dialog>
+    </gr-overlay>
+    <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
+    <gr-error-manager id="errorManager"></gr-error-manager>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-reporting id="reporting"></gr-reporting>
+    <gr-router id="router"></gr-router>
+    <gr-plugin-host id="plugins"
+        config="[[_serverConfig]]">
+    </gr-plugin-host>
+    <gr-lib-loader id="libLoader"></gr-lib-loader>
+    <gr-external-style id="externalStyle" name="app-theme"></gr-external-style>
+  </template>
+  <script src="gr-app-element.js" crossorigin="anonymous"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
new file mode 100644
index 0000000..eb35878
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -0,0 +1,449 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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-app-element',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the URL location changes.
+     *
+     * @event location-change
+     */
+
+    properties: {
+      /**
+       * @type {{ query: string, view: string, screen: string }}
+       */
+      params: Object,
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+
+      _account: {
+        type: Object,
+        observer: '_accountChanged',
+      },
+
+      /**
+       * The last time the g key was pressed in milliseconds (or a keydown event
+       * was handled if the key is held down).
+       * @type {number|null}
+       */
+      _lastGKeyPressTimestamp: {
+        type: Number,
+        value: null,
+      },
+
+      /**
+       * @type {{ plugin: Object }}
+       */
+      _serverConfig: Object,
+      _version: String,
+      _showChangeListView: Boolean,
+      _showDashboardView: Boolean,
+      _showChangeView: Boolean,
+      _showDiffView: Boolean,
+      _showSettingsView: Boolean,
+      _showAdminView: Boolean,
+      _showCLAView: Boolean,
+      _showEditorView: Boolean,
+      _showPluginScreen: Boolean,
+      _showDocumentationSearch: Boolean,
+      /** @type {?} */
+      _viewState: Object,
+      /** @type {?} */
+      _lastError: Object,
+      _lastSearchPage: String,
+      _path: String,
+      _pluginScreenName: {
+        type: String,
+        computed: '_computePluginScreenName(params)',
+      },
+      _settingsUrl: String,
+      _feedbackUrl: String,
+      // Used to allow searching on mobile
+      mobileSearch: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    listeners: {
+      'page-error': '_handlePageError',
+      'title-change': '_handleTitleChange',
+      'location-change': '_handleLocationChange',
+      'rpc-log': '_handleRpcLog',
+    },
+
+    observers: [
+      '_viewChanged(params.view)',
+      '_paramsChanged(params.*)',
+    ],
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyboardShortcuts() {
+      return {
+        [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
+        [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
+        [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
+        [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
+      };
+    },
+
+    created() {
+      this._bindKeyboardShortcuts();
+    },
+
+    ready() {
+      this.$.reporting.appStarted(document.visibilityState === 'hidden');
+      this.$.router.start();
+
+      this.$.restAPI.getAccount().then(account => {
+        this._account = account;
+      });
+      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;
+        this._logWelcome();
+      });
+
+      if (window.localStorage.getItem('dark-theme')) {
+        this.$.libLoader.getDarkTheme().then(module => {
+          Polymer.dom(this.root).appendChild(module);
+        });
+      }
+
+      // Note: this is evaluated here to ensure that it only happens after the
+      // router has been initialized. @see Issue 7837
+      this._settingsUrl = Gerrit.Nav.getUrlForSettings();
+
+      this._viewState = {
+        changeView: {
+          changeNum: null,
+          patchRange: null,
+          selectedFileIndex: 0,
+          showReplyDialog: false,
+          diffMode: null,
+          numFilesShown: null,
+          scrollTop: 0,
+        },
+        changeListView: {
+          query: null,
+          offset: 0,
+          selectedChangeIndex: 0,
+        },
+        dashboardView: {
+          selectedChangeIndex: 0,
+        },
+      };
+    },
+
+    _bindKeyboardShortcuts() {
+      this.bindShortcut(this.Shortcut.SEND_REPLY,
+          this.DOC_ONLY, 'ctrl+enter', 'meta+enter');
+
+      this.bindShortcut(
+          this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm');
+      this.bindShortcut(
+          this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a');
+
+      this.bindShortcut(
+          this.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+      this.bindShortcut(
+          this.Shortcut.CURSOR_PREV_CHANGE, 'k');
+      this.bindShortcut(
+          this.Shortcut.OPEN_CHANGE, 'o');
+      this.bindShortcut(
+          this.Shortcut.NEXT_PAGE, 'n', ']');
+      this.bindShortcut(
+          this.Shortcut.PREV_PAGE, 'p', '[');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_CHANGE_STAR, 's');
+      this.bindShortcut(
+          this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
+      this.bindShortcut(
+          this.Shortcut.EDIT_TOPIC, 't');
+
+      this.bindShortcut(
+          this.Shortcut.OPEN_REPLY_DIALOG, 'a');
+      this.bindShortcut(
+          this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+      this.bindShortcut(
+          this.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+      this.bindShortcut(
+          this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+      this.bindShortcut(
+          this.Shortcut.REFRESH_CHANGE, 'shift+r');
+      this.bindShortcut(
+          this.Shortcut.UP_TO_DASHBOARD, 'u');
+      this.bindShortcut(
+          this.Shortcut.UP_TO_CHANGE, 'u');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_DIFF_MODE, 'm');
+
+      this.bindShortcut(
+          this.Shortcut.NEXT_LINE, 'j', 'down');
+      this.bindShortcut(
+          this.Shortcut.PREV_LINE, 'k', 'up');
+      this.bindShortcut(
+          this.Shortcut.NEXT_CHUNK, 'n');
+      this.bindShortcut(
+          this.Shortcut.PREV_CHUNK, 'p');
+      this.bindShortcut(
+          this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+      this.bindShortcut(
+          this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+      this.bindShortcut(
+          this.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+      this.bindShortcut(
+          this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
+      this.bindShortcut(
+          this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+          this.DOC_ONLY, 'shift+e');
+      this.bindShortcut(
+          this.Shortcut.LEFT_PANE, 'shift+left');
+      this.bindShortcut(
+          this.Shortcut.RIGHT_PANE, 'shift+right');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+      this.bindShortcut(
+          this.Shortcut.NEW_COMMENT, 'c');
+      this.bindShortcut(
+          this.Shortcut.SAVE_COMMENT,
+          'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
+      this.bindShortcut(
+          this.Shortcut.OPEN_DIFF_PREFS, ',');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r');
+
+      this.bindShortcut(
+          this.Shortcut.NEXT_FILE, ']');
+      this.bindShortcut(
+          this.Shortcut.PREV_FILE, '[');
+      this.bindShortcut(
+          this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+      this.bindShortcut(
+          this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+      this.bindShortcut(
+          this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+      this.bindShortcut(
+          this.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+      this.bindShortcut(
+          this.Shortcut.OPEN_FILE, 'o', 'enter');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+      this.bindShortcut(
+          this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+      this.bindShortcut(
+          this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+
+      this.bindShortcut(
+          this.Shortcut.OPEN_FIRST_FILE, ']');
+      this.bindShortcut(
+          this.Shortcut.OPEN_LAST_FILE, '[');
+
+      this.bindShortcut(
+          this.Shortcut.SEARCH, '/');
+    },
+
+    _accountChanged(account) {
+      if (!account) { return; }
+
+      // Preferences are cached when a user is logged in; warm them.
+      this.$.restAPI.getPreferences();
+      this.$.restAPI.getDiffPreferences();
+      this.$.restAPI.getEditPreferences();
+      this.$.errorManager.knownAccountId =
+          this._account && this._account._account_id || null;
+    },
+
+    _viewChanged(view) {
+      this.$.errorView.classList.remove('show');
+      this.set('_showChangeListView', view === Gerrit.Nav.View.SEARCH);
+      this.set('_showDashboardView', view === Gerrit.Nav.View.DASHBOARD);
+      this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE);
+      this.set('_showDiffView', view === Gerrit.Nav.View.DIFF);
+      this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
+      this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN ||
+          view === Gerrit.Nav.View.GROUP || view === Gerrit.Nav.View.REPO);
+      this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
+      this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
+      const isPluginScreen = view === Gerrit.Nav.View.PLUGIN_SCREEN;
+      this.set('_showPluginScreen', false);
+      // Navigation within plugin screens does not restamp gr-endpoint-decorator
+      // because _showPluginScreen value does not change. To force restamp,
+      // change _showPluginScreen value between true and false.
+      if (isPluginScreen) {
+        this.async(() => this.set('_showPluginScreen', true), 1);
+      }
+      this.set('_showDocumentationSearch',
+          view === Gerrit.Nav.View.DOCUMENTATION_SEARCH);
+      if (this.params.justRegistered) {
+        this.$.registrationOverlay.open();
+        this.$.registrationDialog.loadData().then(() => {
+          this.$.registrationOverlay.refit();
+        });
+      }
+      this.$.header.unfloat();
+    },
+
+    _handlePageError(e) {
+      const props = [
+        '_showChangeListView',
+        '_showDashboardView',
+        '_showChangeView',
+        '_showDiffView',
+        '_showSettingsView',
+        '_showAdminView',
+      ];
+      for (const showProp of props) {
+        this.set(showProp, false);
+      }
+
+      this.$.errorView.classList.add('show');
+      const response = e.detail.response;
+      const err = {text: [response.status, response.statusText].join(' ')};
+      if (response.status === 404) {
+        err.emoji = '¯\\_(ツ)_/¯';
+        this._lastError = err;
+      } else {
+        err.emoji = 'o_O';
+        response.text().then(text => {
+          err.moreInfo = text;
+          this._lastError = err;
+        });
+      }
+    },
+
+    _handleLocationChange(e) {
+      const hash = e.detail.hash.substring(1);
+      let pathname = e.detail.pathname;
+      if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
+        pathname += '@' + hash;
+      }
+      this.set('_path', pathname);
+    },
+
+    _paramsChanged(paramsRecord) {
+      const params = paramsRecord.base;
+      const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
+      if (viewsToCheck.includes(params.view)) {
+        this.set('_lastSearchPage', location.pathname);
+      }
+    },
+
+    _handleTitleChange(e) {
+      if (e.detail.title) {
+        document.title = e.detail.title + ' · Gerrit Code Review';
+      } else {
+        document.title = '';
+      }
+    },
+
+    _showKeyboardShortcuts(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      this.$.keyboardShortcuts.open();
+    },
+
+    _handleKeyboardShortcutDialogClose() {
+      this.$.keyboardShortcuts.close();
+    },
+
+    _handleAccountDetailUpdate(e) {
+      this.$.mainHeader.reload();
+      if (this.params.view === Gerrit.Nav.View.SETTINGS) {
+        this.$$('gr-settings-view').reloadAccountDetail();
+      }
+    },
+
+    _handleRegistrationDialogClose(e) {
+      this.params.justRegistered = false;
+      this.$.registrationOverlay.close();
+    },
+
+    _goToOpenedChanges() {
+      Gerrit.Nav.navigateToStatusSearch('open');
+    },
+
+    _goToMergedChanges() {
+      Gerrit.Nav.navigateToStatusSearch('merged');
+    },
+
+    _goToAbandonedChanges() {
+      Gerrit.Nav.navigateToStatusSearch('abandoned');
+    },
+
+    _computePluginScreenName({plugin, screen}) {
+      return Gerrit._getPluginScreenName(plugin, screen);
+    },
+
+    _logWelcome() {
+      console.group('Runtime Info');
+      console.log('Gerrit UI (PolyGerrit)');
+      console.log(`Gerrit Server Version: ${this._version}`);
+      if (window.VERSION_INFO) {
+        console.log(`UI Version Info: ${window.VERSION_INFO}`);
+      }
+      console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
+      console.groupEnd();
+    },
+
+    /**
+     * Intercept RPC log events emitted by REST API interfaces.
+     * Note: the REST API interface cannot use gr-reporting directly because
+     * that would create a cyclic dependency.
+     */
+    _handleRpcLog(e) {
+      this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
+          e.detail.elapsed);
+    },
+
+    _mobileSearchToggle(e) {
+      this.mobileSearch = !this.mobileSearch;
+    },
+
+    _showFeedbackUrl(feedbackUrl) {
+      if (feedbackUrl) {
+        return feedbackUrl;
+      }
+
+      return false;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/gr-app-it_test.html b/polygerrit-ui/app/elements/gr-app-it_test.html
index 929156e..cc8b784 100644
--- a/polygerrit-ui/app/elements/gr-app-it_test.html
+++ b/polygerrit-ui/app/elements/gr-app-it_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app-it_test</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-app.html">
 
@@ -50,7 +52,6 @@
         getAccountCapabilities() { return Promise.resolve({}); },
         getConfig() {
           return Promise.resolve({
-            gerrit: {web_uis: ['GWT', 'POLYGERRIT']},
             plugin: {
               js_resource_paths: [],
               html_resource_paths: [
diff --git a/polygerrit-ui/app/elements/gr-app-p2.html b/polygerrit-ui/app/elements/gr-app-p2.html
new file mode 100644
index 0000000..dbac7fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-p2.html
@@ -0,0 +1,39 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script>
+  window.Gerrit = window.Gerrit || {};
+</script>
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer-resin/standalone/polymer-resin.html">
+<link rel="import" href="/bower_components/polymer/lib/legacy/legacy-data-mixin.html">
+<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
+<script>
+  security.polymer_resin.install({
+    allowedIdentifierPrefixes: [''],
+    reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
+  });
+</script>
+
+<link rel="import" href="./gr-app-element.html">
+<dom-module id="gr-app-p2">
+  <template>
+    <gr-app-element id="app-element"></gr-app-element>
+  </template>
+  <script src="gr-app-p2.js" crossorigin="anonymous"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app-p2.js b/polygerrit-ui/app/elements/gr-app-p2.js
new file mode 100644
index 0000000..2163c02
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-p2.js
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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-app-p2',
+  });
+})();
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index efcefe2..cb1d0a2 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -20,224 +20,32 @@
     window.Polymer = {
       dom: 'shadow',
       passiveTouchGestures: true,
+      lazyRegister: true,
     };
   } else if (!window.Polymer) {
     window.Polymer = {
       passiveTouchGestures: true,
+      lazyRegister: true,
     };
   }
+  window.Gerrit = window.Gerrit || {};
 </script>
 
-<link rel="import" href="../bower_components/polymer/polymer.html">
-<link rel="import" href="../bower_components/polymer-resin/standalone/polymer-resin.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer-resin/standalone/polymer-resin.html">
+<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
 <script>
   security.polymer_resin.install({
     allowedIdentifierPrefixes: [''],
     reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
   });
 </script>
 
-<link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../styles/shared-styles.html">
-<link rel="import" href="../styles/themes/app-theme.html">
-<link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
-<link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
-<link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
-<link rel="import" href="./change/gr-change-view/gr-change-view.html">
-<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
-<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
-<link rel="import" href="./core/gr-main-header/gr-main-header.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="./core/gr-router/gr-router.html">
-<link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
-<link rel="import" href="./edit/gr-editor-view/gr-editor-view.html">
-<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="./plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
-<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
-<link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
-<link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
-<link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
-<link rel="import" href="./shared/gr-fixed-panel/gr-fixed-panel.html">
-<link rel="import" href="./shared/gr-lib-loader/gr-lib-loader.html">
-<link rel="import" href="./shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<script src="../scripts/util.js"></script>
-
+<link rel="import" href="./gr-app-element.html">
 <dom-module id="gr-app">
   <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--view-background-color);
-        display: flex;
-        flex-direction: column;
-        min-height: 100%;
-      }
-      gr-fixed-panel {
-        /**
-         * This one should be greater that the z-index in gr-diff-view
-         * because gr-main-header contains overlay.
-         */
-        z-index: 10;
-      }
-      gr-main-header,
-      footer {
-        color: var(--primary-text-color);
-      }
-      gr-main-header {
-        background-color: var(--header-background-color);
-        padding: 0 var(--default-horizontal-margin);
-        border-bottom: 1px solid var(--border-color);
-      }
-      gr-main-header.shadow {
-        /* Make it obvious for shadow dom testing */
-        border-bottom: 1px solid pink;
-      }
-      footer {
-        background-color: var(--footer-background-color);
-        border-top: 1px solid var(--border-color);
-        display: flex;
-        justify-content: space-between;
-        padding: .5rem var(--default-horizontal-margin);
-        z-index: 100;
-      }
-      main {
-        flex: 1;
-        padding-bottom: 2em;
-        position: relative;
-      }
-      .errorView {
-        align-items: center;
-        display: none;
-        flex-direction: column;
-        justify-content: center;
-        position: absolute;
-        top: 0;
-        right: 0;
-        bottom: 0;
-        left: 0;
-      }
-      .errorView.show {
-        display: flex;
-      }
-      .errorEmoji {
-        font-size: 2.6rem;
-      }
-      .errorText,
-      .errorMoreInfo {
-        margin-top: .75em;
-      }
-      .errorText {
-        font-size: 1.2rem;
-      }
-      .errorMoreInfo {
-        color: var(--deemphasized-text-color);
-      }
-      .feedback {
-        color: var(--error-text-color);
-      }
-    </style>
-    <gr-fixed-panel id="header">
-      <gr-main-header
-          id="mainHeader"
-          search-query="{{params.query}}"
-          class$="[[_computeShadowClass(_isShadowDom)]]">
-      </gr-main-header>
-    </gr-fixed-panel>
-    <main>
-      <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
-        <gr-change-list-view
-            params="[[params]]"
-            account="[[_account]]"
-            view-state="{{_viewState.changeListView}}"></gr-change-list-view>
-      </template>
-      <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
-        <gr-dashboard-view
-            account="[[_account]]"
-            params="[[params]]"
-            view-state="{{_viewState.dashboardView}}"></gr-dashboard-view>
-      </template>
-      <template is="dom-if" if="[[_showChangeView]]" restamp="true">
-        <gr-change-view
-            params="[[params]]"
-            view-state="{{_viewState.changeView}}"
-            back-page="[[_lastSearchPage]]"></gr-change-view>
-      </template>
-      <template is="dom-if" if="[[_showEditorView]]" restamp="true">
-        <gr-editor-view
-            params="[[params]]"></gr-editor-view>
-      </template>
-      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
-          <gr-diff-view
-              params="[[params]]"
-              change-view-state="{{_viewState.changeView}}"></gr-diff-view>
-        </template>
-      <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
-        <gr-settings-view
-            params="[[params]]"
-            on-account-detail-update="_handleAccountDetailUpdate">
-        </gr-settings-view>
-      </template>
-      <template is="dom-if" if="[[_showAdminView]]" restamp="true">
-        <gr-admin-view path="[[_path]]"
-            params=[[params]]></gr-admin-view>
-      </template>
-      <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
-        <gr-endpoint-decorator name="[[_pluginScreenName]]">
-          <gr-endpoint-param name="token" value="[[params.screen]]"></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </template>
-      <template is="dom-if" if="[[_showCLAView]]" restamp="true">
-        <gr-cla-view></gr-cla-view>
-      </template>
-      <div id="errorView" class="errorView">
-        <div class="errorEmoji">[[_lastError.emoji]]</div>
-        <div class="errorText">[[_lastError.text]]</div>
-        <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
-      </div>
-    </main>
-    <footer r="contentinfo" class$="[[_computeShadowClass(_isShadowDom)]]">
-      <div>
-        Powered by <a href="https://www.gerritcodereview.com/" rel="noopener"
-        target="_blank">Gerrit Code Review</a>
-        ([[_version]])
-      </div>
-      <div>
-        <a class="feedback"
-            href$="[[_feedbackUrl]]"
-            rel="noopener" target="_blank">Send feedback</a>
-        <template is="dom-if" if="[[_computeShowGwtUiLink(_serverConfig)]]">
-          |
-          <a id="gwtLink" href$="[[computeGwtUrl(_path)]]" rel="external">Switch to Old UI</a>
-        </template>
-        | Press &ldquo;?&rdquo; for keyboard shortcuts
-      </div>
-    </footer>
-    <gr-overlay id="keyboardShortcuts" with-backdrop>
-      <gr-keyboard-shortcuts-dialog
-          view="[[params.view]]"
-          on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
-    </gr-overlay>
-    <gr-overlay id="registrationOverlay" with-backdrop>
-      <gr-registration-dialog
-          id="registrationDialog"
-          settings-url="[[_settingsUrl]]"
-          on-account-detail-update="_handleAccountDetailUpdate"
-          on-close="_handleRegistrationDialogClose">
-      </gr-registration-dialog>
-    </gr-overlay>
-    <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
-    <gr-error-manager id="errorManager"></gr-error-manager>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting"></gr-reporting>
-    <gr-router id="router"></gr-router>
-    <gr-plugin-host id="plugins"
-        config="[[_serverConfig]]">
-    </gr-plugin-host>
-    <gr-lib-loader id="libLoader"></gr-lib-loader>
-    <gr-external-style id="externalStyle" name="app-theme"></gr-external-style>
+    <gr-app-element id="app-element"></gr-app-element>
   </template>
   <script src="gr-app.js" crossorigin="anonymous"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 7acb680..5c74659 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -17,320 +17,13 @@
 (function() {
   'use strict';
 
-  // The maximum age of a keydown event to be used in a jump navigation. This is
-  // only for cases when the keyup event is lost.
-  const G_KEY_TIMEOUT_MS = 1000;
-
   // Eagerly render Polymer components when backgrounded. (Skips
   // requestAnimationFrame.)
   // @see https://github.com/Polymer/polymer/issues/3851
-  // TODO: Reassess after Polymer 2.0 upgrade.
   // @see Issue 4699
   Polymer.RenderStatus._makeReady();
 
   Polymer({
     is: 'gr-app',
-
-    /**
-     * Fired when the URL location changes.
-     *
-     * @event location-change
-     */
-
-    properties: {
-      /**
-       * @type {{ query: string, view: string, screen: string }}
-       */
-      params: Object,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-
-      _account: {
-        type: Object,
-        observer: '_accountChanged',
-      },
-
-      /**
-       * The last time the g key was pressed in milliseconds (or a keydown event
-       * was handled if the key is held down).
-       * @type {number|null}
-       */
-      _lastGKeyPressTimestamp: {
-        type: Number,
-        value: null,
-      },
-
-      /**
-       * @type {{ plugin: Object }}
-       */
-      _serverConfig: Object,
-      _version: String,
-      _showChangeListView: Boolean,
-      _showDashboardView: Boolean,
-      _showChangeView: Boolean,
-      _showDiffView: Boolean,
-      _showSettingsView: Boolean,
-      _showAdminView: Boolean,
-      _showCLAView: Boolean,
-      _showEditorView: Boolean,
-      _showPluginScreen: Boolean,
-      /** @type {?} */
-      _viewState: Object,
-      /** @type {?} */
-      _lastError: Object,
-      _lastSearchPage: String,
-      _path: String,
-      _isShadowDom: Boolean,
-      _pluginScreenName: {
-        type: String,
-        computed: '_computePluginScreenName(params)',
-      },
-      _settingsUrl: String,
-      _feedbackUrl: {
-        type: String,
-        value: 'https://bugs.chromium.org/p/gerrit/issues/entry' +
-          '?template=PolyGerrit%20Issue',
-      },
-    },
-
-    listeners: {
-      'page-error': '_handlePageError',
-      'title-change': '_handleTitleChange',
-      'location-change': '_handleLocationChange',
-    },
-
-    observers: [
-      '_viewChanged(params.view)',
-      '_paramsChanged(params.*)',
-    ],
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    keyBindings: {
-      '?': '_showKeyboardShortcuts',
-      'g:keydown': '_gKeyDown',
-      'g:keyup': '_gKeyUp',
-      'a m o': '_jumpKeyPressed',
-    },
-
-    ready() {
-      this._isShadowDom = Polymer.Settings.useShadow;
-      this.$.router.start();
-
-      this.$.restAPI.getAccount().then(account => {
-        this._account = account;
-      });
-      this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
-      });
-      this.$.restAPI.getVersion().then(version => {
-        this._version = version;
-        this._logWelcome();
-      });
-
-      if (window.localStorage.getItem('dark-theme')) {
-        this.$.libLoader.getDarkTheme().then(module => {
-          Polymer.dom(this.root).appendChild(module);
-        });
-      }
-
-      // Note: this is evaluated here to ensure that it only happens after the
-      // router has been initialized. @see Issue 7837
-      this._settingsUrl = Gerrit.Nav.getUrlForSettings();
-
-      this.$.reporting.appStarted(document.visibilityState === 'hidden');
-
-      this._viewState = {
-        changeView: {
-          changeNum: null,
-          patchRange: null,
-          selectedFileIndex: 0,
-          showReplyDialog: false,
-          diffMode: null,
-          numFilesShown: null,
-          scrollTop: 0,
-        },
-        changeListView: {
-          query: null,
-          offset: 0,
-          selectedChangeIndex: 0,
-        },
-        dashboardView: {
-          selectedChangeIndex: 0,
-        },
-      };
-    },
-
-    _accountChanged(account) {
-      if (!account) { return; }
-
-      // Preferences are cached when a user is logged in; warm them.
-      this.$.restAPI.getPreferences();
-      this.$.restAPI.getDiffPreferences();
-      this.$.restAPI.getEditPreferences();
-      this.$.errorManager.knownAccountId =
-          this._account && this._account._account_id || null;
-    },
-
-    _viewChanged(view) {
-      this.$.errorView.classList.remove('show');
-      this.set('_showChangeListView', view === Gerrit.Nav.View.SEARCH);
-      this.set('_showDashboardView', view === Gerrit.Nav.View.DASHBOARD);
-      this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE);
-      this.set('_showDiffView', view === Gerrit.Nav.View.DIFF);
-      this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
-      this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN ||
-          view === Gerrit.Nav.View.GROUP || view === Gerrit.Nav.View.REPO);
-      this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
-      this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
-      const isPluginScreen = view === Gerrit.Nav.View.PLUGIN_SCREEN;
-      this.set('_showPluginScreen', false);
-      // Navigation within plugin screens does not restamp gr-endpoint-decorator
-      // because _showPluginScreen value does not change. To force restamp,
-      // change _showPluginScreen value between true and false.
-      if (isPluginScreen) {
-        this.async(() => this.set('_showPluginScreen', true), 1);
-      }
-      if (this.params.justRegistered) {
-        this.$.registrationOverlay.open();
-        this.$.registrationDialog.loadData().then(() => {
-          this.$.registrationOverlay.refit();
-        });
-      }
-      this.$.header.unfloat();
-    },
-
-    _computeShowGwtUiLink(config) {
-      return !window.DEPRECATE_GWT_UI &&
-          config.gerrit.web_uis && config.gerrit.web_uis.includes('GWT');
-    },
-
-    _handlePageError(e) {
-      const props = [
-        '_showChangeListView',
-        '_showDashboardView',
-        '_showChangeView',
-        '_showDiffView',
-        '_showSettingsView',
-        '_showAdminView',
-      ];
-      for (const showProp of props) {
-        this.set(showProp, false);
-      }
-
-      this.$.errorView.classList.add('show');
-      const response = e.detail.response;
-      const err = {text: [response.status, response.statusText].join(' ')};
-      if (response.status === 404) {
-        err.emoji = '¯\\_(ツ)_/¯';
-        this._lastError = err;
-      } else {
-        err.emoji = 'o_O';
-        response.text().then(text => {
-          err.moreInfo = text;
-          this._lastError = err;
-        });
-      }
-    },
-
-    _handleLocationChange(e) {
-      const hash = e.detail.hash.substring(1);
-      let pathname = e.detail.pathname;
-      if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
-        pathname += '@' + hash;
-      }
-      this.set('_path', pathname);
-    },
-
-    _paramsChanged(paramsRecord) {
-      const params = paramsRecord.base;
-      const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
-      if (viewsToCheck.includes(params.view)) {
-        this.set('_lastSearchPage', location.pathname);
-      }
-    },
-
-    _handleTitleChange(e) {
-      if (e.detail.title) {
-        document.title = e.detail.title + ' · Gerrit Code Review';
-      } else {
-        document.title = '';
-      }
-    },
-
-    _showKeyboardShortcuts(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      this.$.keyboardShortcuts.open();
-    },
-
-    _handleKeyboardShortcutDialogClose() {
-      this.$.keyboardShortcuts.close();
-    },
-
-    _handleAccountDetailUpdate(e) {
-      this.$.mainHeader.reload();
-      if (this.params.view === Gerrit.Nav.View.SETTINGS) {
-        this.$$('gr-settings-view').reloadAccountDetail();
-      }
-    },
-
-    _handleRegistrationDialogClose(e) {
-      this.params.justRegistered = false;
-      this.$.registrationOverlay.close();
-    },
-
-    _computeShadowClass(isShadowDom) {
-      return isShadowDom ? 'shadow' : '';
-    },
-
-    _gKeyDown(e) {
-      if (this.modifierPressed(e)) { return; }
-      this._lastGKeyPressTimestamp = Date.now();
-    },
-
-    _gKeyUp() {
-      this._lastGKeyPressTimestamp = null;
-    },
-
-    _jumpKeyPressed(e) {
-      if (!this._lastGKeyPressTimestamp ||
-          (Date.now() - this._lastGKeyPressTimestamp > G_KEY_TIMEOUT_MS) ||
-          this.shouldSuppressKeyboardShortcut(e)) { return; }
-      e.preventDefault();
-
-      let status = null;
-      if (e.detail.key === 'a') {
-        status = 'abandoned';
-      } else if (e.detail.key === 'm') {
-        status = 'merged';
-      } else if (e.detail.key === 'o') {
-        status = 'open';
-      }
-      if (status !== null) {
-        Gerrit.Nav.navigateToStatusSearch(status);
-      }
-    },
-
-    _computePluginScreenName({plugin, screen}) {
-      return Gerrit._getPluginScreenName(plugin, screen);
-    },
-
-    _logWelcome() {
-      console.group('Runtime Info');
-      console.log('Gerrit UI (PolyGerrit)');
-      console.log(`Gerrit Server Version: ${this._version}`);
-      if (window.VERSION_INFO) {
-        console.log(`UI Version Info: ${window.VERSION_INFO}`);
-      }
-      const renderTime = new Date(window.performance.timing.loadEventStart);
-      console.log(`Document loaded at: ${renderTime}`);
-      console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
-      console.groupEnd();
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index fb1b241..73d012a 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-app.html">
 
@@ -45,13 +47,18 @@
       stub('gr-account-dropdown', {
         _getTopContent: sinon.stub(),
       });
+      stub('gr-router', {
+        start: sandbox.stub(),
+      });
       stub('gr-rest-api-interface', {
         getAccount() { return Promise.resolve({}); },
         getAccountCapabilities() { return Promise.resolve({}); },
         getConfig() {
           return Promise.resolve({
-            gerrit: {web_uis: ['GWT', 'POLYGERRIT']},
             plugin: {},
+            auth: {
+              auth_type: undefined,
+            },
           });
         },
         getPreferences() { return Promise.resolve({my: []}); },
@@ -69,100 +76,33 @@
       sandbox.restore();
     });
 
+    appElement = () => {
+      return element.$['app-element'];
+    };
+
     test('reporting', () => {
-      assert.isTrue(element.$.reporting.appStarted.calledOnce);
+      assert.isTrue(appElement().$.reporting.appStarted.calledOnce);
     });
 
-    test('location change updates gwt footer', done => {
-      element._path = '/test/path';
-      flush(() => {
-        const gwtLink = element.$$('#gwtLink');
-        assert.equal(gwtLink.href, 'http://' + location.host +
-            element.getBaseUrl() + '/?polygerrit=0#/test/path');
-        done();
-      });
-    });
-
-    test('_handleLocationChange handles hashes', done => {
-      const curLocation = {
-        pathname: '/c/1/1/testfile.txt',
-        hash: '#2',
-        host: location.host,
-      };
-      element._handleLocationChange({detail: curLocation});
-
-      flush(() => {
-        const gwtLink = element.$$('#gwtLink');
-        assert.equal(
-            gwtLink.href,
-            'http://' + location.host + element.getBaseUrl() +
-            '/?polygerrit=0#/c/1/1/testfile.txt@2'
-        );
-        done();
-      });
+    test('reporting called before router start', () => {
+      const element = appElement();
+      const appStartedStub = element.$.reporting.appStarted;
+      const routerStartStub = element.$.router.start;
+      sinon.assert.callOrder(appStartedStub, routerStartStub);
     });
 
     test('passes config to gr-plugin-host', () => {
-      return element.$.restAPI.getConfig.lastCall.returnValue.then(config => {
-        assert.deepEqual(element.$.plugins.config, config);
+      const config = appElement().$.restAPI.getConfig;
+      return config.lastCall.returnValue.then(config => {
+        assert.deepEqual(appElement().$.plugins.config, config);
       });
     });
 
     test('_paramsChanged sets search page', () => {
-      element._paramsChanged({base: {view: Gerrit.Nav.View.CHANGE}});
-      assert.notOk(element._lastSearchPage);
-      element._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}});
-      assert.ok(element._lastSearchPage);
-    });
-
-    suite('_jumpKeyPressed', () => {
-      let navStub;
-
-      setup(() => {
-        navStub = sandbox.stub(Gerrit.Nav, 'navigateToStatusSearch');
-        sandbox.stub(Date, 'now').returns(10000);
-      });
-
-      test('success', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._lastGKeyPressTimestamp = 9000;
-        element._jumpKeyPressed(e);
-        assert.isTrue(navStub.calledOnce);
-        assert.equal(navStub.lastCall.args[0], 'abandoned');
-      });
-
-      test('no g key', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._lastGKeyPressTimestamp = null;
-        element._jumpKeyPressed(e);
-        assert.isFalse(navStub.called);
-      });
-
-      test('g key too long ago', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._lastGKeyPressTimestamp = 3000;
-        element._jumpKeyPressed(e);
-        assert.isFalse(navStub.called);
-      });
-
-      test('should suppress', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
-        element._lastGKeyPressTimestamp = 9000;
-        element._jumpKeyPressed(e);
-        assert.isFalse(navStub.called);
-      });
-
-      test('unrecognized key', () => {
-        const e = {detail: {key: 'f'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._lastGKeyPressTimestamp = 9000;
-        element._jumpKeyPressed(e);
-        assert.isFalse(navStub.called);
-      });
+      appElement()._paramsChanged({base: {view: Gerrit.Nav.View.CHANGE}});
+      assert.notOk(appElement()._lastSearchPage);
+      appElement()._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}});
+      assert.ok(appElement()._lastSearchPage);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
index 966efac..6883e7e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-api</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="gr-admin-api.html">
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
index 208f1e8..ece8677 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-attribute-helper">
   <script src="gr-attribute-helper.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
index cd59f7d..efd6104 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-attribute-helper</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-attribute-helper.html"/>
 
@@ -30,6 +32,7 @@
   <script>
     Polymer({
       is: 'some-element',
+      _legacyUndefinedCheck: true,
       properties: {
         fooBar: {
           type: Object,
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
index eddb52b..dd532e1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-change-metadata-api">
   <script src="gr-change-metadata-api.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
index 252e812..8b9000f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
@@ -15,8 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-dom-hooks">
   <script src="gr-dom-hooks.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
index 230be0e..e0fa61e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -54,6 +54,7 @@
   GrDomHook.prototype._createPlaceholder = function(hookName) {
     Polymer({
       is: hookName,
+      _legacyUndefinedCheck: true,
       properties: {
         plugin: Object,
         content: Object,
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
index 3dde458..1e946c3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dom-hooks</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-dom-hooks.html"/>
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
index 50b80d5..53ff901 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-endpoint-decorator">
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index 5006461..f953b1d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -21,6 +21,7 @@
 
   Polymer({
     is: 'gr-endpoint-decorator',
+    _legacyUndefinedCheck: true,
 
     properties: {
       name: String,
@@ -29,6 +30,16 @@
         type: Map,
         value() { return new Map(); },
       },
+      /**
+       * This map prevents importing the same endpoint twice.
+       * Without caching, if a plugin is loaded after the loaded plugins
+       * callback fires, it will be imported twice and appear twice on the page.
+       * @type {!Map}
+       */
+      _initializedPlugins: {
+        type: Map,
+        value() { return new Map(); },
+      },
     },
 
     detached() {
@@ -37,9 +48,12 @@
       }
     },
 
+    /**
+     * @suppress {checkTypes}
+     */
     _import(url) {
       return new Promise((resolve, reject) => {
-        this.importHref(url, resolve, reject);
+        (this.importHref || Polymer.importHref)(url, resolve, reject);
       });
     },
 
@@ -61,7 +75,9 @@
     },
 
     _getEndpointParams() {
-      return Polymer.dom(this).querySelectorAll('gr-endpoint-param');
+      // Polymer2: querySelectorAll returns NodeList instead of Array.
+      return Array.from(
+          Polymer.dom(this).querySelectorAll('gr-endpoint-param'));
     },
 
     /**
@@ -102,6 +118,10 @@
     },
 
     _initModule({moduleName, plugin, type, domHook}) {
+      const name = plugin.getPluginName() + '.' + moduleName;
+      if (this._initializedPlugins.get(name)) {
+        return;
+      }
       let initPromise;
       switch (type) {
         case 'decorate':
@@ -112,9 +132,9 @@
           break;
       }
       if (!initPromise) {
-        console.warn('Unable to initialize module' +
-            `${moduleName} from ${plugin.getPluginName()}`);
+        console.warn('Unable to initialize module ' + name);
       }
+      this._initializedPlugins.set(name, true);
       initPromise.then(el => {
         domHook.handleInstanceAttached(el);
         this._domHooks.set(el, domHook);
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
index 31d3150..fa097ac 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-endpoint-decorator</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-endpoint-decorator.html">
 <link rel="import" href="../gr-endpoint-param/gr-endpoint-param.html">
@@ -127,6 +129,22 @@
       });
     });
 
+    test('two modules', done => {
+      plugin.registerCustomComponent('banana', 'mod-one');
+      plugin.registerCustomComponent('banana', 'mod-two');
+      flush(() => {
+        const element =
+            container.querySelector('gr-endpoint-decorator[name="banana"]');
+        const module1 = Polymer.dom(element.root).children.find(
+            element => element.nodeName === 'MOD-ONE');
+        assert.isOk(module1);
+        const module2 = Polymer.dom(element.root).children.find(
+            element => element.nodeName === 'MOD-TWO');
+        assert.isOk(module2);
+        done();
+      });
+    });
+
     test('late param setup', done => {
       const element =
           container.querySelector('gr-endpoint-decorator[name="banana"]');
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
index 9d28ac3..6a5b558 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-endpoint-param">
   <script src="gr-endpoint-param.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
index cbc3d6a..e21fc72 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-endpoint-param',
+    _legacyUndefinedCheck: true,
     properties: {
       name: String,
       value: {
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
index d34bdef..717d52b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-event-helper">
   <script src="gr-event-helper.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
index 709c042..08c1df9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-event-helper</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-event-helper.html"/>
 
@@ -30,6 +32,7 @@
   <script>
     Polymer({
       is: 'some-element',
+      _legacyUndefinedCheck: true,
       properties: {
         fooBar: {
           type: Object,
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
index a83b2ab..6a55349 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-external-style">
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
index 51ad0b7..e8b7212 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -19,27 +19,49 @@
 
   Polymer({
     is: 'gr-external-style',
+    _legacyUndefinedCheck: true,
 
     properties: {
       name: String,
+      _urlsImported: {
+        type: Array,
+        value() { return []; },
+      },
+      _stylesApplied: {
+        type: Array,
+        value() { return []; },
+      },
     },
 
+    /**
+     * @suppress {checkTypes}
+     */
     _import(url) {
+      if (this._urlsImported.includes(url)) { return Promise.resolve(); }
+      this._urlsImported.push(url);
       return new Promise((resolve, reject) => {
-        this.importHref(url, resolve, reject);
+        (this.importHref || Polymer.importHref)(url, resolve, reject);
       });
     },
 
     _applyStyle(name) {
+      if (this._stylesApplied.includes(name)) { return; }
+      this._stylesApplied.push(name);
+      // Hybrid custom-style syntax:
+      // https://polymer-library.polymer-project.org/2.0/docs/devguide/style-shadow-dom
       const s = document.createElement('style', 'custom-style');
       s.setAttribute('include', name);
-      Polymer.dom(this.root).appendChild(s);
+      const cs = document.createElement('custom-style');
+      cs.appendChild(s);
+      // When using Shadow DOM <custom-style> must be added to the <body>.
+      // Within <gr-external-style> itself the styles would have no effect.
+      const topEl = document.getElementsByTagName('body')[0];
+      topEl.insertBefore(cs, topEl.firstChild);
     },
 
-    ready() {
-      Gerrit.awaitPluginsLoaded().then(() => Promise.all(
-          Gerrit._endpoints.getPlugins(this.name).map(
-              pluginUrl => this._import(pluginUrl)))
+    _importAndApply() {
+      Promise.all(Gerrit._endpoints.getPlugins(this.name).map(
+          pluginUrl => this._import(pluginUrl))
       ).then(() => {
         const moduleNames = Gerrit._endpoints.getModules(this.name);
         for (const name of moduleNames) {
@@ -47,5 +69,13 @@
         }
       });
     },
+
+    attached() {
+      this._importAndApply();
+    },
+
+    ready() {
+      Gerrit.awaitPluginsLoaded().then(() => this._importAndApply());
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
index d1893ba..9566067 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-external-style</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-external-style.html">
 
@@ -32,38 +34,90 @@
 
 <script>
   suite('gr-external-style integration tests', () => {
+    const TEST_URL = 'http://some/plugin/url.html';
+
     let sandbox;
     let element;
+    let plugin;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-
-      // NB: Order is important.
-      let plugin;
+    const installPlugin = () => {
+      if (plugin) { return; }
       Gerrit.install(p => {
         plugin = p;
-        plugin.registerStyleModule('foo', 'some-module');
-      }, '0.1', 'http://some/plugin/url.html');
+      }, '0.1', TEST_URL);
+    };
 
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
-
+    const createElement = () => {
       element = fixture('basic');
-      sandbox.stub(element, '_applyStyle');
-      sandbox.stub(element, 'importHref', (url, resolve) => { resolve(); });
+      sandbox.spy(element, '_applyStyle');
+    };
 
-      flush(done);
+    /**
+     * Installs the plugin, creates the element, registers style module.
+     */
+    const lateRegister = () => {
+      installPlugin();
+      createElement();
+      plugin.registerStyleModule('foo', 'some-module');
+    };
+
+    /**
+     * Installs the plugin, registers style module, creates the element.
+     */
+    const earlyRegister = () => {
+      installPlugin();
+      plugin.registerStyleModule('foo', 'some-module');
+      createElement();
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-external-style', {
+        importHref: (url, resolve) => resolve(),
+      });
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    test('imports plugin-provided module', () => {
-      assert.isTrue(element.importHref.calledWith(
-          new URL('http://some/plugin/url.html')));
+    test('imports plugin-provided module', async () => {
+      lateRegister();
+      await new Promise(flush);
+      assert.isTrue(element.importHref.calledWith(new URL(TEST_URL)));
     });
 
-    test('applies plugin-provided styles', () => {
+    test('applies plugin-provided styles', async () => {
+      lateRegister();
+      await new Promise(flush);
+      assert.isTrue(element._applyStyle.calledWith('some-module'));
+    });
+
+    test('does not double import', async () => {
+      earlyRegister();
+      await new Promise(flush);
+      plugin.registerStyleModule('foo', 'some-module');
+      await new Promise(flush);
+      const urlsImported =
+          element._urlsImported.filter(url => url.toString() === TEST_URL);
+      assert.strictEqual(urlsImported.length, 1);
+    });
+
+    test('does not double apply', async () => {
+      earlyRegister();
+      await new Promise(flush);
+      plugin.registerStyleModule('foo', 'some-module');
+      await new Promise(flush);
+      const stylesApplied =
+          element._stylesApplied.filter(name => name === 'some-module');
+      assert.strictEqual(stylesApplied.length, 1);
+    });
+
+    test('loads and applies preloaded modules', async () => {
+      earlyRegister();
+      await new Promise(flush);
+      assert.isTrue(element.importHref.calledWith(new URL(TEST_URL)));
       assert.isTrue(element._applyStyle.calledWith('some-module'));
     });
   });
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
index 8e106cc..f277899 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
index c5bf21a..a99f23c 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
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-plugin-host',
+    _legacyUndefinedCheck: true,
 
     properties: {
       config: {
@@ -33,13 +34,19 @@
 
     _configChanged(config) {
       const plugins = config.plugin;
-      const htmlPlugins = plugins.html_resource_paths || [];
+      const htmlPlugins = (plugins.html_resource_paths || [])
+          .map(p => this._urlFor(p))
+          .filter(p => !Gerrit._isPluginPreloaded(p));
       const jsPlugins =
-          this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
-      const defaultTheme = config.default_theme;
+          this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins)
+          .map(p => this._urlFor(p))
+          .filter(p => !Gerrit._isPluginPreloaded(p));
+      const shouldLoadTheme = config.default_theme &&
+            !Gerrit._isPluginPreloaded('preloaded:gerrit-theme');
+      const defaultTheme =
+            shouldLoadTheme ? this._urlFor(config.default_theme) : null;
       const pluginsPending =
-          [].concat(jsPlugins, htmlPlugins, defaultTheme || []).map(
-              p => this._urlFor(p));
+          [].concat(jsPlugins, htmlPlugins, defaultTheme || []);
       Gerrit._setPluginsPending(pluginsPending);
       if (defaultTheme) {
         // Make theme first to be first to load.
@@ -73,7 +80,7 @@
       for (const url of plugins) {
         // onload (second param) needs to be a function. When null or undefined
         // were passed, plugins were not loaded correctly.
-        this.importHref(
+        (this.importHref || Polymer.importHref)(
             this._urlFor(url), () => {},
             Gerrit._pluginInstallError.bind(null, `${url} import error`),
             async);
@@ -95,8 +102,12 @@
     },
 
     _urlFor(pathOrUrl) {
-      if (pathOrUrl.startsWith('http')) {
-        // Plugins are loaded from another domain.
+      if (!pathOrUrl) {
+        return pathOrUrl;
+      }
+      if (pathOrUrl.startsWith('preloaded:') ||
+          pathOrUrl.startsWith('http')) {
+        // Plugins are loaded from another domain or preloaded.
         return pathOrUrl;
       }
       if (!pathOrUrl.startsWith('/')) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
index 0880d73..e577182 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-host</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-plugin-host.html">
 
@@ -187,5 +189,44 @@
       element.importHref.secondCall.args[2]();
       assert.equal(Gerrit._pluginInstallError.callCount, 2);
     });
+
+    test('default theme is loaded with html plugins', () => {
+      sandbox.stub(Gerrit, '_setPluginsPending');
+      element.config = {
+        default_theme: '/oof',
+        plugin: {},
+      };
+      assert.isTrue(Gerrit._setPluginsPending.calledWith([url + '/oof']));
+    });
+
+    test('skips default theme loading if preloaded', () => {
+      sandbox.stub(Gerrit, '_isPluginPreloaded')
+          .withArgs('preloaded:gerrit-theme').returns(true);
+      sandbox.stub(Gerrit, '_setPluginsPending');
+      element.config = {
+        default_theme: '/oof',
+        plugin: {},
+      };
+      assert.isFalse(element.importHref.calledWith(url + '/oof'));
+    });
+
+    test('skips preloaded plugins', () => {
+      sandbox.stub(Gerrit, '_isPluginPreloaded')
+          .withArgs(url + '/plugins/foo/bar').returns(true)
+          .withArgs(url + '/plugins/42').returns(true);
+      sandbox.stub(Gerrit, '_setPluginsCount');
+      sandbox.stub(Gerrit, '_setPluginsPending');
+      sandbox.stub(element, '_createScriptTag');
+      element.config = {
+        plugin: {
+          html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+          js_resource_paths: ['plugins/42'],
+        },
+      };
+      assert.isTrue(
+          Gerrit._setPluginsPending.calledWith([url + '/plugins/baz']));
+      assert.equal(element._createScriptTag.callCount, 0);
+      assert.isTrue(element.importHref.calledWith(url + '/plugins/baz'));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
index ce0bf1b..402d988 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 
 <dom-module id="gr-plugin-popup">
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
index dd37f84..3ef93e4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
@@ -18,6 +18,7 @@
   'use strict';
   Polymer({
     is: 'gr-plugin-popup',
+    _legacyUndefinedCheck: true,
     get opened() {
       return this.$.overlay.opened;
     },
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
index 91386b9..1f1e81e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-popup</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-plugin-popup.html"/>
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
index 2fdf28c..26ece30 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="gr-plugin-popup.html">
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
index 983c795..53370e2 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-popup-interface</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-popup-interface.html"/>
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html
index e47ba15..2fd4534 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../admin/gr-repo-command/gr-repo-command.html">
 
 <dom-module id="gr-plugin-repo-command">
@@ -25,6 +25,7 @@
   <script>
     Polymer({
       is: 'gr-plugin-repo-command',
+      _legacyUndefinedCheck: true,
       properties: {
         title: String,
         repoName: String,
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
index 34c9797..8e6c053 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="gr-plugin-repo-command.html">
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
index bb9ae87..7c7564b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-api</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="gr-repo-api.html">
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
index 7c916dc..20cc71b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../settings/gr-settings-view/gr-settings-item.html">
 <link rel="import" href="../../settings/gr-settings-view/gr-settings-menu-item.html">
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
index cabd26b..d34ca94 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-api</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="gr-settings-api.html">
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
index e6e8fa5..97966ed 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-custom-plugin-header">
   <template>
@@ -37,6 +37,7 @@
   <script>
     Polymer({
       is: 'gr-custom-plugin-header',
+      _legacyUndefinedCheck: true,
       properties: {
         logoUrl: String,
         title: String,
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
index b84f5b9..d6e67fe 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="gr-custom-plugin-header.html">
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
index 8d23ea2..82eb0f8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-theme-api</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="gr-theme-api.html">
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index 912deb9..3403d0d 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -15,8 +15,9 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
@@ -26,10 +27,35 @@
 
 <dom-module id="gr-account-info">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      gr-avatar {
+        height: 120px;
+        width: 120px;
+        margin-right: .15em;
+        vertical-align: -.25em;
+      }
+      div section.hide {
+        display: none;
+      }
+    </style>
     <style include="gr-form-styles"></style>
     <div class="gr-form-styles">
       <section>
+        <span class="title"></span>
+        <span class="value">
+          <gr-avatar account="[[_account]]"
+              image-size="120"></gr-avatar>
+        </span>
+      </section>
+      <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
+        <span class="title"></span>
+        <span class="value">
+          <a href$="[[_avatarChangeUrl]]">
+            Change avatar
+          </a>
+        </span>
+      </section>
+      <section>
         <span class="title">ID</span>
         <span class="value">[[_account._account_id]]</span>
       </section>
@@ -53,12 +79,18 @@
         <span
             hidden$="[[!usernameMutable]]"
             class="value">
-          <input
-              is="iron-input"
-              id="usernameInput"
+          <iron-input
               disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_username}}">
+            <input
+                is="iron-input"
+                id="usernameInput"
+                disabled="[[_saving]]"
+                on-keydown="_handleKeydown"
+                bind-value="{{_username}}">
+          </iron-input>
+        </span>
       </section>
       <section id="nameSection">
         <span class="title">Full name</span>
@@ -68,24 +100,34 @@
         <span
             hidden$="[[!nameMutable]]"
             class="value">
-          <input
-              is="iron-input"
-              id="nameInput"
+          <iron-input
               disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_account.name}}">
+            <input
+                is="iron-input"
+                id="nameInput"
+                disabled="[[_saving]]"
+                on-keydown="_handleKeydown"
+                bind-value="{{_account.name}}">
+          </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Status (e.g. "Vacation")</span>
         <span class="value">
-          <input
-              is="iron-input"
-              id="statusInput"
+          <iron-input
               disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_account.status}}">
-          </span>
+            <input
+                is="iron-input"
+                id="statusInput"
+                disabled="[[_saving]]"
+                on-keydown="_handleKeydown"
+                bind-value="{{_account.status}}">
+          </iron-input>
+        </span>
       </section>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 3ad151a..feeb7df 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-account-info',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when account details are changed.
@@ -62,6 +63,10 @@
         type: String,
         observer: '_usernameChanged',
       },
+      _avatarChangeUrl: {
+        type: String,
+        value: '',
+      },
     },
 
     observers: [
@@ -89,6 +94,10 @@
         this._username = account.username;
       }));
 
+      promises.push(this.$.restAPI.getAvatarChangeUrl().then(url => {
+        this._avatarChangeUrl = url;
+      }));
+
       return Promise.all(promises).then(() => {
         this._loading = false;
       });
@@ -167,5 +176,13 @@
         this.save();
       }
     },
+
+    _hideAvatarChangeUrl(avatarChangeUrl) {
+      if (!avatarChangeUrl) {
+        return 'hide';
+      }
+
+      return '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index d5682e0..de222a9 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-info</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-account-info.html">
 
@@ -330,5 +332,11 @@
 
       assert.isTrue(element._hasUsernameChange);
     });
+
+    test('_hideAvatarChangeUrl', () => {
+      assert.equal(element._hideAvatarChangeUrl(''), 'hide');
+
+      assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
index 72ea503..852161c 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
@@ -16,7 +16,7 @@
 -->
 
 <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="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
index 41595a98..fe36a86 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-agreements-list',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _agreements: Array,
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
index 56122a9..e0a3afa 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-agreements-list.html">
 
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..8b32be9 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
@@ -15,8 +15,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -37,6 +36,9 @@
         cursor: pointer;
         text-align: center;
       }
+      .checkboxContainer input {
+        cursor: pointer;
+      }
       .checkboxContainer:hover {
         outline: 1px solid var(--border-color);
       }
@@ -52,12 +54,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 +67,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..3909c99 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
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-change-table-editor',
+    _legacyUndefinedCheck: true,
 
     properties: {
       displayedColumns: {
@@ -35,40 +36,43 @@
       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() {
+      // Polymer2: querySelectorAll returns NodeList instead of Array.
+      return Array.from(Polymer.dom(this.root)
+          .querySelectorAll('.checkboxContainer input:not([name=number])'))
+          .filter(checkbox => checkbox.checked)
+          .map(checkbox => checkbox.name);
     },
 
     /**
-     * Handles tap on either the checkbox itself or the surrounding table cell.
+     * Handle a tap on a checkbox container and relay the tap to the checkbox it
+     * contains.
+     */
+    _handleCheckboxContainerTap(e) {
+      const checkbox = Polymer.dom(e.target).querySelector('input');
+      if (!checkbox) { return; }
+      checkbox.click();
+    },
+
+    /**
+     * Handle a tap on the number checkbox and update the showNumber property
+     * accordingly.
+     */
+    _handleNumberCheckboxTap(e) {
+      this.showNumber = Polymer.dom(e).rootTarget.checked;
+    },
+
+    /**
+     * Handle a tap on a displayed column checkboxes (excluding number) and
+     * update the displayedColumns property accordingly.
      */
     _handleTargetTap(e) {
-      let checkbox = Polymer.dom(e.target).querySelector('input');
-      if (checkbox) {
-        checkbox.click();
-      } else {
-        // The target is the checkbox itself.
-        checkbox = Polymer.dom(e).rootTarget;
-      }
-
-      if (checkbox.name === 'number') {
-        this.showNumber = checkbox.checked;
-        return;
-      }
-
-      this.set('displayedColumns',
-          this._updateDisplayedColumns(
-              this.displayedColumns, checkbox.name, checkbox.checked));
+      this.set('displayedColumns', this._getDisplayedColumns());
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
index daf9437..e1ec32c 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-table-editor.html">
 
@@ -47,12 +49,13 @@
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
       ];
 
       element.set('displayedColumns', columns);
+      element.showNumber = false;
       flushAsynchronousOperations();
     });
 
@@ -90,7 +93,7 @@
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
       ]);
@@ -108,66 +111,50 @@
           displayedLength + 1);
     });
 
-    test('_handleTargetTap', () => {
-      const checkbox = element.$$('table tr:nth-child(2) input');
-      let originalDisplayedColumns = element.displayedColumns;
-      const td = element.$$('table tr:nth-child(2) .checkboxContainer');
-      const displayedColumnStub =
-          sandbox.stub(element, '_updateDisplayedColumns');
-
-      MockInteractions.tap(checkbox);
-      assert.isTrue(displayedColumnStub.lastCall.calledWithExactly(
-          originalDisplayedColumns,
-          checkbox.name,
-          checkbox.checked));
-
-      originalDisplayedColumns = element.displayedColumns;
-      MockInteractions.tap(td);
-      assert.isTrue(displayedColumnStub.lastCall.calledWithExactly(
-          originalDisplayedColumns,
-          checkbox.name,
-          checkbox.checked));
+    test('_getDisplayedColumns', () => {
+      assert.deepEqual(element._getDisplayedColumns(), columns);
+      MockInteractions.tap(
+          element.$$('.checkboxContainer input[name=Assignee]'));
+      assert.deepEqual(element._getDisplayedColumns(),
+          columns.filter(c => c !== 'Assignee'));
     });
 
-    test('_handleTargetTap on number', () => {
-      element.showNumber = false;
-      const checkbox = element.$$('table tr:nth-child(1) input');
-      const displayedColumnStub =
-          sandbox.stub(element, '_updateDisplayedColumns');
+    test('_handleCheckboxContainerTap relayes taps to checkboxes', () => {
+      sandbox.stub(element, '_handleNumberCheckboxTap');
+      sandbox.stub(element, '_handleTargetTap');
 
-      MockInteractions.tap(checkbox);
-      assert.isFalse(displayedColumnStub.called);
+      MockInteractions.tap(
+          element.$$('table tr:first-of-type .checkboxContainer'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
+      assert.isFalse(element._handleTargetTap.called);
+
+      MockInteractions.tap(
+          element.$$('table tr:last-of-type .checkboxContainer'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
+      assert.isTrue(element._handleTargetTap.calledOnce);
+    });
+
+    test('_handleNumberCheckboxTap', () => {
+      sandbox.spy(element, '_handleNumberCheckboxTap');
+
+      MockInteractions
+          .tap(element.$$('.checkboxContainer input[name=number]'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
       assert.isTrue(element.showNumber);
 
-      MockInteractions.tap(checkbox);
+      MockInteractions
+          .tap(element.$$('.checkboxContainer input[name=number]'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledTwice);
       assert.isFalse(element.showNumber);
     });
 
-    test('_updateDisplayedColumns', () => {
-      let name = 'Subject';
-      let checked = false;
-      assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
-          [
-            'Status',
-            'Owner',
-            'Assignee',
-            'Project',
-            'Branch',
-            'Updated',
-          ]);
-      name = 'Size';
-      checked = true;
-      assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
-          [
-            'Subject',
-            'Status',
-            'Owner',
-            'Assignee',
-            'Project',
-            'Branch',
-            'Updated',
-            'Size',
-          ]);
+    test('_handleTargetTap', () => {
+      sandbox.spy(element, '_handleTargetTap');
+      assert.include(element.displayedColumns, 'Assignee');
+      MockInteractions
+          .tap(element.$$('.checkboxContainer input[name=Assignee]'));
+      assert.isTrue(element._handleTargetTap.calledOnce);
+      assert.notInclude(element.displayedColumns, 'Assignee');
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
index 233235e..2d5fd26 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
@@ -16,8 +16,8 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -40,7 +40,7 @@
         padding: 0.3em;
       }
       #claNewAgreementsLabel {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       #claNewAgreement {
         display: none;
@@ -49,7 +49,7 @@
         display: block;
       }
       .contributorAgreementButton {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       .alreadySubmittedText {
         color: var(--error-text-color);
@@ -95,7 +95,13 @@
         </div>
         <div class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]">
           <h3 class="smallHeading">Complete the agreement:</h3>
-          <input id="input-agreements" is="iron-input" bind-value="{{_agreementsText}}" placeholder="Enter 'I agree' here" />
+          <iron-input bind-value="{{_agreementsText}}"
+                      placeholder="Enter 'I agree' here">
+            <input id="input-agreements"
+                   is="iron-input"
+                   bind-value="{{_agreementsText}}"
+                   placeholder="Enter 'I agree' here">
+          </iron-input>
           <gr-button on-tap="_handleSaveAgreements" disabled="[[_disableAgreementsText(_agreementsText)]]">
             Submit
           </gr-button>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
index c771332..62bec12 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-cla-view',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _groups: Object,
@@ -65,7 +66,9 @@
 
     _getAgreementsUrl(configUrl) {
       let url;
-      if (!configUrl) { return ''; }
+      if (!configUrl) {
+        return '';
+      }
       if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
         url = configUrl;
       } else {
@@ -99,8 +102,8 @@
     },
 
     _createToast(message) {
-      this.dispatchEvent(new CustomEvent('show-alert',
-          {detail: {message}, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'show-alert', {detail: {message}, bubbles: true, composed: true}));
     },
 
     _computeShowAgreementsClass(agreements) {
@@ -132,9 +135,13 @@
     // then hides the text box and submit button.
     _computeHideAgreementClass(name, config) {
       for (const key in config) {
-        if (!config.hasOwnProperty(key)) { continue; }
+        if (!config.hasOwnProperty(key)) {
+          continue;
+        }
         for (const prop in config[key]) {
-          if (!config[key].hasOwnProperty(prop)) { continue; }
+          if (!config[key].hasOwnProperty(prop)) {
+            continue;
+          }
           if (name === config[key].name &&
               !config[key].auto_verify_group) {
             return 'hideAgreementsTextBox';
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
index 2304d15..53d6be1 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-cla-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-cla-view.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
index 782100e..53a30c3 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -29,37 +30,64 @@
       <section>
         <span class="title">Tab width</span>
         <span class="value">
-          <input
-              is="iron-input"
+          <iron-input
               type="number"
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{editPrefs.tab_size}}"
+              on-keypress="_handleEditPrefsChanged"
               on-change="_handleEditPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{editPrefs.tab_size}}"
+                on-keypress="_handleEditPrefsChanged"
+                on-change="_handleEditPrefsChanged">
+          </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Columns</span>
         <span class="value">
-          <input
-              is="iron-input"
+          <iron-input
               type="number"
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{editPrefs.line_length}}"
+              on-keypress="_handleEditPrefsChanged"
               on-change="_handleEditPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{editPrefs.line_length}}"
+                on-keypress="_handleEditPrefsChanged"
+                on-change="_handleEditPrefsChanged">
+          </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Indent unit</span>
         <span class="value">
-          <input
-              is="iron-input"
+          <iron-input
               type="number"
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{editPrefs.indent_unit}}"
+              on-keypress="_handleEditPrefsChanged"
               on-change="_handleEditPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{editPrefs.indent_unit}}"
+                on-keypress="_handleEditPrefsChanged"
+                on-change="_handleEditPrefsChanged">
+          </iron-input>
         </span>
       </section>
       <section>
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
index 86350f9..37bce08 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-edit-preferences',
+    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
index 42171b7..c1c5c52 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-preferences</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-edit-preferences.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
index 0a7433e..f508288 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.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="../../../styles/shared-styles.html">
@@ -63,14 +63,22 @@
             <tr>
               <td class="emailColumn">[[item.email]]</td>
               <td class="preferredControl" on-tap="_handlePreferredControlTap">
-                <input
-                    is="iron-input"
+                <iron-input
                     class="preferredRadio"
                     type="radio"
                     on-change="_handlePreferredChange"
                     name="preferred"
                     value="[[item.email]]"
                     checked$="[[item.preferred]]">
+                  <input
+                      is="iron-input"
+                      class="preferredRadio"
+                      type="radio"
+                      on-change="_handlePreferredChange"
+                      name="preferred"
+                      value="[[item.email]]"
+                      checked$="[[item.preferred]]">
+                </iron-input>
               </td>
               <td>
                 <gr-button
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
index d08cc90..71d75cc 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-email-editor',
+    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
index e937f8b..e8ecd7a 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-email-editor</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-email-editor.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
index 7a63605..5dbafae 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
@@ -27,9 +27,6 @@
   <template>
     <style include="shared-styles"></style>
     <style include="gr-form-styles">
-      .statusHeader {
-        width: 4em;
-      }
       .keyHeader {
         width: 9em;
       }
@@ -54,10 +51,6 @@
       #existing {
         margin-bottom: 1em;
       }
-      #existing .commentColumn {
-        min-width: 27em;
-        width: auto;
-      }
     </style>
     <div class="gr-form-styles">
       <fieldset id="existing">
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
index 78025d1..7348067 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-gpg-editor',
+    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
index 13b3152..c3cd8e1 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-gpg-editor</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-gpg-editor.html">
 
@@ -169,7 +171,7 @@
       const newKeyString = 'not even close to valid';
 
       const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
-          () => { return Promise.reject(); });
+          () => { return Promise.reject(new Error('error')); });
 
       element._newKey = newKeyString;
 
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
index 2c7afd3..ca500c8 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
index 0f43563..4de24aa 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-group-list',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _groups: Array,
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
index 3fa5a36..ac17521 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-group-list.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
index 2fe07ca..8e5db7d 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
index b3d1396..99f4504 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-http-password',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _username: String,
@@ -26,6 +27,10 @@
       _passwordUrl: String,
     },
 
+    attached() {
+      this.loadData();
+    },
+
     loadData() {
       const promises = [];
 
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
index ca50b2b..8924058 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-http-password.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
index 872e558..a876611 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
@@ -27,8 +27,20 @@
   <template>
     <style include="shared-styles"></style>
     <style include="gr-form-styles">
-      td {
-        width: 5em;
+      tr th.emailAddressHeader,
+      tr th.identityHeader {
+        width: 15em;
+        padding: 0 10px;
+      }
+      tr td.statusColumn,
+      tr td.emailAddressColumn,
+      tr td.identityColumn {
+        word-break: break-word;
+      }
+      tr td.emailAddressColumn,
+      tr td.identityColumn {
+        padding: 4px 10px;
+        width: 15em;
       }
       .deleteButton {
         float: right;
@@ -36,40 +48,38 @@
       .deleteButton:not(.show) {
         display: none;
       }
-      .statusColumn {
-        white-space: nowrap;
-      }
     </style>
     <div class="gr-form-styles">
-      <table id="identities">
-        <thead>
-          <tr>
-            <th class="statusHeader">Status</th>
-            <th class="emailAddressHeader">Email Address</th>
-            <th class="identityHeader">Identity</th>
-            <th class="deleteHeader"></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_identities]]" filter="filterIdentities">
+      <fieldset>
+        <table>
+          <thead>
             <tr>
-              <td class$="statusColumn">
-                [[_computeIsTrusted(item.trusted)]]
-              </td>
-              <td>[[item.email_address]]</td>
-              <td>[[_computeIdentity(item.identity)]]</td>
-              <td>
-                <gr-button
-                    link
-                    class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
-                    on-tap="_handleDeleteItem">
-                  Delete
-                </gr-button>
-              </td>
+              <th class="statusHeader">Status</th>
+              <th class="emailAddressHeader">Email Address</th>
+              <th class="identityHeader">Identity</th>
+              <th class="deleteHeader"></th>
             </tr>
-          </template>
-        </tbody>
-      </table>
+          </thead>
+          <tbody>
+            <template is="dom-repeat" items="[[_identities]]" filter="filterIdentities">
+              <tr>
+                <td class="statusColumn">
+                  [[_computeIsTrusted(item.trusted)]]
+                </td>
+                <td class="emailAddressColumn">[[item.email_address]]</td>
+                <td class="identityColumn">[[_computeIdentity(item.identity)]]</td>
+                <td class="deleteColumn">
+                  <gr-button
+                      class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
+                      on-tap="_handleDeleteItem">
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+      </fieldset>
     </div>
     <gr-overlay id="overlay" with-backdrop>
       <gr-confirm-delete-item-dialog
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
index da6ab28..0d053f7 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-identities',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _identities: Object,
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
index c77a1b9..19c9df3 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-identities</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-identities.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
index aa42623..703c34c 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -85,19 +85,30 @@
         <tfoot>
           <tr>
             <th>
-              <input
-                  is="iron-input"
+              <iron-input
                   placeholder="New Title"
                   on-keydown="_handleInputKeydown"
                   bind-value="{{_newName}}">
+                <input
+                    is="iron-input"
+                    placeholder="New Title"
+                    on-keydown="_handleInputKeydown"
+                    bind-value="{{_newName}}">
+              </iron-input>
             </th>
             <th>
-              <input
+              <iron-input
                   class="newUrlInput"
-                  is="iron-input"
                   placeholder="New URL"
                   on-keydown="_handleInputKeydown"
                   bind-value="{{_newUrl}}">
+                <input
+                    class="newUrlInput"
+                    is="iron-input"
+                    placeholder="New URL"
+                    on-keydown="_handleInputKeydown"
+                    bind-value="{{_newUrl}}">
+              </iron-input>
             </th>
             <th></th>
             <th></th>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
index aa83bf7..8587338 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-menu-editor',
+    _legacyUndefinedCheck: true,
 
     properties: {
       menuItems: Array,
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index c8a54b6..917026a 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-menu-editor.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
index 7a8cd6f..cdc0825 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -15,7 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -48,7 +49,7 @@
       }
       header {
         border-bottom: 1px solid var(--border-color);
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         margin-bottom: 1em;
       }
       .container {
@@ -81,19 +82,27 @@
         <hr>
         <section>
           <div class="title">Full Name</div>
-          <input
-              is="iron-input"
-              id="name"
+          <iron-input
               bind-value="{{_account.name}}"
               disabled="[[_saving]]">
+            <input
+                is="iron-input"
+                id="name"
+                bind-value="{{_account.name}}"
+                disabled="[[_saving]]">
+          </iron-input>
         </section>
         <section class$="[[_computeUsernameClass(_usernameMutable)]]">
           <div class="title">Username</div>
-          <input
-              is="iron-input"
-              id="username"
+          <iron-input
               bind-value="{{_account.username}}"
               disabled="[[_saving]]">
+            <input
+                is="iron-input"
+                id="username"
+                bind-value="{{_account.username}}"
+                disabled="[[_saving]]">
+          </iron-input>
         </section>
         <section>
           <div class="title">Preferred Email</div>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
index c6cd578..6b4ee18 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-registration-dialog',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when account details are changed.
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
index 93a3188..d1b5c80 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-registration-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-registration-dialog.html">
 
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html
index b61c7e0..704a8698d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html
@@ -15,16 +15,16 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-settings-item">
-  <style>
-    :host {
-      display: block;
-      margin-bottom: 2em;
-    }
-  </style>
   <template>
+    <style>
+      :host {
+        display: block;
+        margin-bottom: 2em;
+      }
+    </style>
     <h2 id="[[anchor]]">[[title]]</h2>
     <slot></slot>
   </template>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
index 0ee1b28..dc1aa93 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-settings-item',
+    _legacyUndefinedCheck: true,
     properties: {
       anchor: String,
       title: String,
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
index 3b47190..846f776 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
@@ -15,13 +15,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-page-nav-styles.html">
 
 <dom-module id="gr-settings-menu-item">
-  <style include="shared-styles"></style>
-  <style include="gr-page-nav-styles"></style>
   <template>
+    <style include="shared-styles"></style>
+    <style include="gr-page-nav-styles"></style>
     <div class="navStyles">
       <li><a href$="[[href]]">[[title]]</a></li>
     </div>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
index 38147cd..2a56b09 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-settings-menu-item',
+    _legacyUndefinedCheck: true,
     properties: {
       href: String,
       title: String,
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index 14e5e6f..d193041 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -15,10 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 
 <link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
-<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/gr-page-nav-styles.html">
@@ -27,6 +28,7 @@
 <link rel="import" href="../../settings/gr-change-table-editor/gr-change-table-editor.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
@@ -48,12 +50,15 @@
       :host {
         color: var(--primary-text-color);
       }
-      #newEmailInput {
+      .newEmailInput {
         width: 20em;
       }
       #email {
         margin-bottom: 1em;
       }
+      main section.darkToggle {
+        display: block;
+      }
       .filters p,
       .darkToggle p {
         margin-bottom: 1em;
@@ -194,6 +199,28 @@
               </gr-select>
             </span>
           </section>
+          <section hidden$="[[!_localPrefs.default_base_for_merges]]">
+            <span class="title">Default Base For Merges</span>
+            <span class="value">
+              <gr-select
+                  bind-value="{{_localPrefs.default_base_for_merges}}">
+                <select>
+                  <option value="AUTO_MERGE">Auto Merge</option>
+                  <option value="FIRST_PARENT">First Parent</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+          <section>
+            <span class="title">Show Relative Dates In Changes Table</span>
+            <span class="value">
+              <input
+                  id="relativeDateInChangeTable"
+                  type="checkbox"
+                  checked$="[[_localPrefs.relative_date_in_change_table]]"
+                  on-change="_handleRelativeDateInChangeTable">
+            </span>
+          </section>
           <section>
             <span class="title">Diff view</span>
             <span class="value">
@@ -227,6 +254,16 @@
             </span>
           </section>
           <section>
+            <span class="title">Set new changes to "work in progress" by default</span>
+            <span class="value">
+              <input
+                  id="workInProgressByDefault"
+                  type="checkbox"
+                  checked$="[[_localPrefs.work_in_progress_by_default]]"
+                  on-change="_handleWorkInProgressByDefault">
+            </span>
+          </section>
+          <section>
             <span class="title">
               Insert Signed-off-by Footer For Inline Edit Changes
             </span>
@@ -249,95 +286,9 @@
           Diff Preferences
         </h2>
         <fieldset id="diffPreferences">
-          <section>
-            <span class="title">Context</span>
-            <span class="value">
-              <gr-select bind-value="{{_diffPrefs.context}}">
-                <select>
-                  <option value="3">3 lines</option>
-                  <option value="10">10 lines</option>
-                  <option value="25">25 lines</option>
-                  <option value="50">50 lines</option>
-                  <option value="75">75 lines</option>
-                  <option value="100">100 lines</option>
-                  <option value="-1">Whole file</option>
-                </select>
-              </gr-select>
-            </span>
-          </section>
-          <section>
-            <span class="title">Fit to screen</span>
-            <span class="value">
-              <input
-                  id="diffLineWrapping"
-                  type="checkbox"
-                  checked$="[[_diffPrefs.line_wrapping]]"
-                  on-change="_handleDiffLineWrappingChanged">
-            </span>
-          </section>
-          <section id="columnsPref" hidden$="[[_diffPrefs.line_wrapping]]">
-            <span class="title">Diff width</span>
-            <span class="value">
-              <input
-                  is="iron-input"
-                  type="number"
-                  prevent-invalid-input
-                  allowed-pattern="[0-9]"
-                  bind-value="{{_diffPrefs.line_length}}">
-            </span>
-          </section>
-          <section>
-            <span class="title">Tab width</span>
-            <span class="value">
-              <input
-                  is="iron-input"
-                  type="number"
-                  prevent-invalid-input
-                  allowed-pattern="[0-9]"
-                  bind-value="{{_diffPrefs.tab_size}}">
-            </span>
-          </section>
-          <section hidden$="[[!_diffPrefs.font_size]]">
-            <span class="title">Font size</span>
-            <span class="value">
-              <input
-                  is="iron-input"
-                  type="number"
-                  prevent-invalid-input
-                  allowed-pattern="[0-9]"
-                  bind-value="{{_diffPrefs.font_size}}">
-            </span>
-          </section>
-          <section>
-            <span class="title">Show tabs</span>
-            <span class="value">
-              <input
-                  id="diffShowTabs"
-                  type="checkbox"
-                  checked$="[[_diffPrefs.show_tabs]]"
-                  on-change="_handleDiffShowTabsChanged">
-            </span>
-          </section>
-          <section>
-            <span class="title">Show trailing whitespace</span>
-            <span class="value">
-              <input
-                  id="showTrailingWhitespace"
-                  type="checkbox"
-                  checked$="[[_diffPrefs.show_whitespace_errors]]"
-                  on-change="_handleShowTrailingWhitespaceChanged">
-            </span>
-          </section>
-          <section>
-            <span class="title">Syntax highlighting</span>
-            <span class="value">
-              <input
-                  id="diffSyntaxHighlighting"
-                  type="checkbox"
-                  checked$="[[_diffPrefs.syntax_highlighting]]"
-                  on-change="_handleDiffSyntaxHighlightingChanged">
-            </span>
-          </section>
+          <gr-diff-preferences
+              id="diffPrefs"
+              has-unsaved-changes="{{_diffPrefsChanged}}"></gr-diff-preferences>
           <gr-button
               id="saveDiffPrefs"
               on-tap="_handleSaveDiffPreferences"
@@ -415,14 +366,22 @@
           <section>
             <span class="title">New email address</span>
             <span class="value">
-              <input
-                  id="newEmailInput"
+              <iron-input
+                  class="newEmailInput"
                   bind-value="{{_newEmail}}"
-                  is="iron-input"
                   type="text"
                   disabled="[[_addingEmail]]"
                   on-keydown="_handleNewEmailKeydown"
                   placeholder="email@example.com">
+                <input
+                    class="newEmailInput"
+                    bind-value="{{_newEmail}}"
+                    is="iron-input"
+                    type="text"
+                    disabled="[[_addingEmail]]"
+                    on-keydown="_handleNewEmailKeydown"
+                    placeholder="email@example.com">
+              </iron-input>
             </span>
           </section>
           <section
@@ -437,10 +396,14 @@
               disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
               on-tap="_handleAddEmailButton">Send verification</gr-button>
         </fieldset>
-        <h2 id="HTTPCredentials">HTTP Credentials</h2>
-        <fieldset>
-          <gr-http-password id="httpPass"></gr-http-password>
-        </fieldset>
+        <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
+          <div>
+            <h2 id="HTTPCredentials">HTTP Credentials</h2>
+            <fieldset>
+              <gr-http-password id="httpPass"></gr-http-password>
+            </fieldset>
+          </div>
+        </template>
         <div hidden$="[[!_serverConfig.sshd]]">
           <h2
               id="SSHKeys"
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 213ab65..041dc64 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -24,9 +24,12 @@
     'email_strategy',
     'diff_view',
     'publish_comments_on_push',
+    'work_in_progress_by_default',
+    'default_base_for_merges',
     'signed_off_by',
     'email_format',
     'size_bar_in_change_table',
+    'relative_date_in_change_table',
   ];
 
   const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
@@ -37,8 +40,14 @@
 
   const RELOAD_MESSAGE = 'Reloading...';
 
+  const HTTP_AUTH = [
+    'HTTP',
+    'HTTP_LDAP',
+  ];
+
   Polymer({
     is: 'gr-settings-view',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the title of the page should change.
@@ -63,8 +72,6 @@
       },
       _accountNameMutable: Boolean,
       _accountInfoChanged: Boolean,
-      /** @type {?} */
-      _diffPrefs: Object,
       _changeTableColumnsNotDisplayed: Array,
       /** @type {?} */
       _localPrefs: {
@@ -91,10 +98,8 @@
         type: Boolean,
         value: false,
       },
-      _diffPrefsChanged: {
-        type: Boolean,
-        value: false,
-      },
+      /** @type {?} */
+      _diffPrefsChanged: Boolean,
       /** @type {?} */
       _editPrefsChanged: Boolean,
       _menuChanged: {
@@ -148,7 +153,6 @@
 
     observers: [
       '_handlePrefsChanged(_localPrefs.*)',
-      '_handleDiffPrefsChanged(_diffPrefs.*)',
       '_handleMenuChanged(_localMenu.splices)',
       '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
     ],
@@ -162,9 +166,9 @@
         this.$.accountInfo.loadData(),
         this.$.watchedProjectsEditor.loadData(),
         this.$.groupList.loadData(),
-        this.$.httpPass.loadData(),
         this.$.identities.loadData(),
         this.$.editPrefs.loadData(),
+        this.$.diffPrefs.loadData(),
       ];
 
       promises.push(this.$.restAPI.getPreferences().then(prefs => {
@@ -175,10 +179,6 @@
         this._cloneChangeTableColumns();
       }));
 
-      promises.push(this.$.restAPI.getDiffPreferences().then(prefs => {
-        this._diffPrefs = prefs;
-      }));
-
       promises.push(this.$.restAPI.getConfig().then(config => {
         this._serverConfig = config;
         const configPromises = [];
@@ -248,7 +248,7 @@
     },
 
     _cloneChangeTableColumns() {
-      let columns = this.prefs.change_table;
+      let columns = this.getVisibleColumns(this.prefs.change_table);
 
       if (columns.length === 0) {
         columns = this.columnNames;
@@ -276,9 +276,9 @@
       this._prefsChanged = true;
     },
 
-    _handleDiffPrefsChanged() {
-      if (this._isLoading()) { return; }
-      this._diffPrefsChanged = true;
+    _handleRelativeDateInChangeTable() {
+      this.set('_localPrefs.relative_date_in_change_table',
+          this.$.relativeDateInChangeTable.checked);
     },
 
     _handleShowSizeBarsInFileListChanged() {
@@ -291,6 +291,11 @@
           this.$.publishCommentsOnPush.checked);
     },
 
+    _handleWorkInProgressByDefault() {
+      this.set('_localPrefs.work_in_progress_by_default',
+          this.$.workInProgressByDefault.checked);
+    },
+
     _handleInsertSignedOff() {
       this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
     },
@@ -312,24 +317,6 @@
       });
     },
 
-    _handleDiffLineWrappingChanged() {
-      this.set('_diffPrefs.line_wrapping', this.$.diffLineWrapping.checked);
-    },
-
-    _handleDiffShowTabsChanged() {
-      this.set('_diffPrefs.show_tabs', this.$.diffShowTabs.checked);
-    },
-
-    _handleShowTrailingWhitespaceChanged() {
-      this.set('_diffPrefs.show_whitespace_errors',
-          this.$.showTrailingWhitespace.checked);
-    },
-
-    _handleDiffSyntaxHighlightingChanged() {
-      this.set('_diffPrefs.syntax_highlighting',
-          this.$.diffSyntaxHighlighting.checked);
-    },
-
     _handleSaveChangeTable() {
       this.set('prefs.change_table', this._localChangeTableColumns);
       this.set('prefs.legacycid_in_change_table', this._showNumber);
@@ -340,10 +327,7 @@
     },
 
     _handleSaveDiffPreferences() {
-      return this.$.restAPI.saveDiffPreferences(this._diffPrefs)
-          .then(() => {
-            this._diffPrefsChanged = false;
-          });
+      this.$.diffPrefs.save();
     },
 
     _handleSaveEditPreferences() {
@@ -429,10 +413,21 @@
       this.dispatchEvent(new CustomEvent('show-alert', {
         detail: {message: RELOAD_MESSAGE},
         bubbles: true,
+        composed: true,
       }));
       this.async(() => {
         window.location.reload();
       }, 1);
     },
+
+    _showHttpAuth(config) {
+      if (config && config.auth &&
+          config.auth.git_basic_auth_policy) {
+        return HTTP_AUTH.includes(
+            config.auth.git_basic_auth_policy.toUpperCase());
+      }
+
+      return false;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index b208ba2..6dcf124 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-settings-view.html">
 
@@ -43,7 +45,6 @@
     let element;
     let account;
     let preferences;
-    let diffPreferences;
     let config;
     let sandbox;
 
@@ -88,6 +89,8 @@
         diff_view: 'UNIFIED_DIFF',
         email_strategy: 'ENABLED',
         email_format: 'HTML_PLAINTEXT',
+        default_base_for_merges: 'FIRST_PARENT',
+        relative_date_in_change_table: false,
         size_bar_in_change_table: true,
 
         my: [
@@ -96,31 +99,12 @@
         ],
         change_table: [],
       };
-      diffPreferences = {
-        context: 10,
-        tab_size: 8,
-        font_size: 12,
-        line_length: 100,
-        cursor_blink_rate: 0,
-        line_wrapping: false,
-        intraline_difference: true,
-        show_line_endings: true,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        auto_hide_diff_table_header: true,
-        theme: 'DEFAULT',
-        ignore_whitespace: 'IGNORE_NONE',
-      };
       config = {auth: {editable_account_fields: []}};
 
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(true); },
         getAccount() { return Promise.resolve(account); },
         getPreferences() { return Promise.resolve(preferences); },
-        getDiffPreferences() {
-          return Promise.resolve(diffPreferences);
-        },
         getWatchedProjects() {
           return Promise.resolve([]);
         },
@@ -168,6 +152,11 @@
           .firstElementChild.bindValue, preferences.email_strategy);
       assert.equal(valueOf('Email format', 'preferences')
           .firstElementChild.bindValue, preferences.email_format);
+      assert.equal(valueOf('Default Base For Merges', 'preferences')
+          .firstElementChild.bindValue, preferences.default_base_for_merges);
+      assert.equal(
+          valueOf('Show Relative Dates In Changes Table', 'preferences')
+              .firstElementChild.checked, false);
       assert.equal(valueOf('Diff view', 'preferences')
           .firstElementChild.bindValue, preferences.diff_view);
       assert.equal(valueOf('Show size bars in file list', 'preferences')
@@ -175,6 +164,9 @@
       assert.equal(valueOf('Publish comments on push', 'preferences')
           .firstElementChild.checked, false);
       assert.equal(valueOf(
+          'Set new changes to "work in progress" by default', 'preferences')
+          .firstElementChild.checked, false);
+      assert.equal(valueOf(
           'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
           .firstElementChild.checked, false);
 
@@ -234,56 +226,30 @@
       });
     });
 
-    test('diff preferences', done => {
-      // Rendered with the expected preferences selected.
-      assert.equal(valueOf('Context', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.context);
-      assert.equal(valueOf('Diff width', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.line_length);
-      assert.equal(valueOf('Tab width', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.tab_size);
-      assert.equal(valueOf('Font size', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.font_size);
-      assert.equal(valueOf('Show tabs', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.show_tabs);
-      assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.show_whitespace_errors);
-      assert.equal(valueOf('Fit to screen', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.line_wrapping);
+    test('set new changes work-in-progress', done => {
+      const newChangesWorkInProgress =
+        valueOf('Set new changes to "work in progress" by default',
+            'preferences').firstElementChild;
+      MockInteractions.tap(newChangesWorkInProgress);
 
-      assert.isFalse(element._diffPrefsChanged);
-
-      const showTabsCheckbox = valueOf('Show tabs', 'diffPreferences')
-          .firstElementChild;
-      showTabsCheckbox.checked = false;
-      element._handleDiffShowTabsChanged();
-
-      assert.isTrue(element._diffPrefsChanged);
+      assert.isFalse(element._menuChanged);
+      assert.isTrue(element._prefsChanged);
 
       stub('gr-rest-api-interface', {
-        saveDiffPreferences(prefs) {
-          assert.equal(prefs.show_tabs, false);
+        savePreferences(prefs) {
+          assert.equal(prefs.work_in_progress_by_default, true);
           return Promise.resolve();
         },
       });
 
       // Save the change.
-      element._handleSaveDiffPreferences().then(() => {
-        assert.isFalse(element._diffPrefsChanged);
+      element._handleSavePreferences().then(() => {
+        assert.isFalse(element._prefsChanged);
+        assert.isFalse(element._menuChanged);
         done();
       });
     });
 
-    test('columns input is hidden with fit to scsreen is selected', () => {
-      assert.isFalse(element.$.columnsPref.hidden);
-
-      MockInteractions.tap(element.$.diffLineWrapping);
-      assert.isTrue(element.$.columnsPref.hidden);
-
-      MockInteractions.tap(element.$.diffLineWrapping);
-      assert.isFalse(element.$.columnsPref.hidden);
-    });
-
     test('menu', done => {
       assert.isFalse(element._menuChanged);
       assert.isFalse(element._prefsChanged);
@@ -443,6 +409,46 @@
       assert.isTrue(overlayOpen.called);
     });
 
+    test('_showHttpAuth', () => {
+      let serverConfig;
+
+      serverConfig = {
+        auth: {
+          git_basic_auth_policy: 'HTTP',
+        },
+      };
+
+      assert.isTrue(element._showHttpAuth(serverConfig));
+
+      serverConfig = {
+        auth: {
+          git_basic_auth_policy: 'HTTP_LDAP',
+        },
+      };
+
+      assert.isTrue(element._showHttpAuth(serverConfig));
+
+      serverConfig = {
+        auth: {
+          git_basic_auth_policy: 'LDAP',
+        },
+      };
+
+      assert.isFalse(element._showHttpAuth(serverConfig));
+
+      serverConfig = {
+        auth: {
+          git_basic_auth_policy: 'OAUTH',
+        },
+      };
+
+      assert.isFalse(element._showHttpAuth(serverConfig));
+
+      serverConfig = {};
+
+      assert.isFalse(element._showHttpAuth(serverConfig));
+    });
+
     suite('_getFilterDocsLink', () => {
       test('with http: docs base URL', () => {
         const base = 'http://example.com/';
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
index ab12403..784e0fc 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
index 874173a..4c423e8 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-ssh-editor',
+    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
index 8607948..991f17c 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ssh-editor</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-ssh-editor.html">
 
@@ -155,7 +157,7 @@
       const newKeyString = 'not even close to valid';
 
       const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          () => { return Promise.reject(); });
+          () => { return Promise.reject(new Error('error')); });
 
       element._newKey = newKeyString;
 
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
index 52649db..35d43c7 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
@@ -14,7 +14,8 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -49,7 +50,7 @@
       <table id="watchedProjects">
         <thead>
           <tr>
-            <th class="projectHeader">Project</th>
+            <th>Repo</th>
             <template is="dom-repeat" items="[[_getTypes()]]">
               <th class="notifType">[[item.name]]</th>
             </template>
@@ -98,14 +99,20 @@
                   id="newProject"
                   query="[[_query]]"
                   threshold="1"
-                  placeholder="Project"></gr-autocomplete>
+                  allow-non-suggested-values
+                  tab-complete
+                  placeholder="Repo"></gr-autocomplete>
             </th>
             <th colspan$="[[_getTypeCount()]]">
-              <input
-                  id="newFilter"
+              <iron-input
                   class="newFilterInput"
-                  is="iron-input"
                   placeholder="branch:name, or other search expression">
+                <input
+                    id="newFilter"
+                    class="newFilterInput"
+                    is="iron-input"
+                    placeholder="branch:name, or other search expression">
+              </iron-input>
             </th>
             <th>
               <gr-button link on-tap="_handleAddProject">Add</gr-button>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
index ebf61db..bd18456 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
@@ -27,6 +27,7 @@
 
   Polymer({
     is: 'gr-watched-projects-editor',
+    _legacyUndefinedCheck: true,
 
     properties: {
       hasUnsavedChanges: {
@@ -110,8 +111,11 @@
       this.hasUnsavedChanges = true;
     },
 
-    _canAddProject(project, filter) {
-      if (!project || !project.id) { return false; }
+    _canAddProject(project, text, filter) {
+      if ((!project || !project.id) && !text) { return false; }
+
+      // This will only be used if not using the auto complete
+      if (!project && text) { return true; }
 
       // Check if the project with filter is already in the list. Compare
       // filters using == to coalesce null and undefined.
@@ -142,7 +146,7 @@
       const newProjectName = this.$.newProject.text;
       const filter = this.$.newFilter.value || null;
 
-      if (!this._canAddProject(newProject, filter)) { return; }
+      if (!this._canAddProject(newProject, newProjectName, filter)) { return; }
 
       const insertIndex = this._getNewProjectIndex(newProjectName, filter);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
index e018e42..4193382 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-watched-projects-editor.html">
 
@@ -132,25 +134,28 @@
     });
 
     test('_canAddProject', () => {
-      assert.isFalse(element._canAddProject(null, null));
-      assert.isFalse(element._canAddProject({}, null));
+      assert.isFalse(element._canAddProject(null, null, null));
+      assert.isFalse(element._canAddProject({}, null, null));
 
       // Can add a project that is not in the list.
-      assert.isTrue(element._canAddProject({id: 'project d'}, null));
-      assert.isTrue(element._canAddProject({id: 'project d'}, 'filter 3'));
+      assert.isTrue(element._canAddProject({id: 'project d'}, null, null));
+      assert.isTrue(element._canAddProject({id: 'project d'}, null, 'filter 3'));
 
       // Cannot add a project that is in the list with no filter.
-      assert.isFalse(element._canAddProject({id: 'project a'}, null));
+      assert.isFalse(element._canAddProject({id: 'project a'}, null, null));
 
       // Can add a project that is in the list if the filter differs.
-      assert.isTrue(element._canAddProject({id: 'project a'}, 'filter 4'));
+      assert.isTrue(element._canAddProject({id: 'project a'}, null, 'filter 4'));
 
       // Cannot add a project that is in the list with the same filter.
-      assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 1'));
-      assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 2'));
+      assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 1'));
+      assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 2'));
 
       // Can add a project that is in the list using a new filter.
-      assert.isTrue(element._canAddProject({id: 'project b'}, 'filter 3'));
+      assert.isTrue(element._canAddProject({id: 'project b'}, null, 'filter 3'));
+
+      // Can add a project that is not added by the auto complete
+      assert.isTrue(element._canAddProject(null, 'test', null));
     });
 
     test('_getNewProjectIndex', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
index 89ab8fe..6de10a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-account-link/gr-account-link.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../gr-icons/gr-icons.html">
@@ -70,6 +70,7 @@
       .transparentBackground,
       gr-button.transparentBackground {
         background-color: transparent;
+        padding: 0;
       }
       :host([disabled]) {
         opacity: .6;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 880cbc0..827e33b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -20,6 +20,7 @@
 
   Polymer({
     is: 'gr-account-chip',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired to indicate a key was pressed while this chip was focused.
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index 7fb4cab..fb760d5 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -17,7 +17,7 @@
 
 <link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../gr-avatar/gr-avatar.html">
 <link rel="import" href="../gr-limited-text/gr-limited-text.html">
@@ -64,7 +64,7 @@
           [[_computeEmailStr(account)]]
         </span>
         <template is="dom-if" if="[[account.status]]">
-          (<gr-limited-text limit="20" text="[[account.status]]"></gr-limited-text>)
+          (<gr-limited-text limit="10" text="[[account.status]]"></gr-limited-text>)
         </template>
       </span>
     </span>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 5b1b975..7983fad 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-account-label',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /**
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index 288a670..d3f29dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-label</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
index 34b0de6..d3575b2 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
@@ -16,7 +16,7 @@
 -->
 
 <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="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../gr-account-label/gr-account-label.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index faaf9c3..03967f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-account-link',
+    _legacyUndefinedCheck: true,
 
     properties: {
       additionalText: String,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
index 6d1831e..134c579 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-link</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-account-link.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index 5817fb7..2b043c4 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -54,7 +54,7 @@
       }
       .action {
         color: var(--link-color);
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         margin-left: 1em;
         text-decoration: none;
         --gr-button: {
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index e7c8b2c..ec7b6eb 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-alert',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the action button is pressed.
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
index 095e640..2338d55 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-alert</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-alert.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
index 4e076a89..64758ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
@@ -15,10 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/iron-fit-behavior/iron-fit-behavior.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <script src="../../../scripts/rootElement.js"></script>
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -38,6 +39,8 @@
       li {
         border-bottom: 1px solid var(--border-color);
         cursor: pointer;
+        display: flex;
+        justify-content: space-between;
         padding: .5em .75em;
       }
       li:last-of-type {
@@ -55,6 +58,20 @@
       .dropdown-content {
         background: var(--dropdown-background-color);
         box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+        max-height: 50vh;
+        overflow: auto;
+      }
+      @media only screen and (max-height: 35em) {
+        .dropdown-content {
+          max-height: 80vh;
+        }
+      }
+      .label {
+        color: var(--deemphasized-text-color);
+        padding-left: 1em;
+      }
+      .hide {
+        display: none;
       }
     </style>
     <div
@@ -68,8 +85,12 @@
               data-value$="[[item.dataValue]]"
               tabindex="-1"
               aria-label$="[[item.name]]"
+              class="autocompleteOption"
               role="option"
-              on-tap="_handleTapItem">[[item.text]]</li>
+              on-tap="_handleTapItem">
+            <span>[[item.text]]</span>
+            <span class$="label [[_computeLabelClass(item)]]">[[item.label]]</span>
+          </li>
         </template>
       </ul>
     </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
index 0c4d8f1..1af629d2 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-autocomplete-dropdown',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the dropdown is closed.
@@ -59,8 +60,8 @@
     },
 
     behaviors: [
-      Polymer.IronFitBehavior,
       Gerrit.KeyboardShortcutBehavior,
+      Polymer.IronFitBehavior,
     ],
 
     keyBindings: {
@@ -140,9 +141,14 @@
     _handleTapItem(e) {
       e.preventDefault();
       e.stopPropagation();
+      let selected = e.target;
+      while (!selected.classList.contains('autocompleteOption')) {
+        if (!selected || selected === this) { return; }
+        selected = selected.parentElement;
+      }
       this.fire('item-selected', {
         trigger: 'tap',
-        selected: e.target,
+        selected,
       });
     },
 
@@ -157,7 +163,9 @@
     _resetCursorStops() {
       if (this.suggestions.length > 0) {
         Polymer.dom.flush();
-        this._suggestionEls = this.$.suggestions.querySelectorAll('li');
+        // Polymer2: querySelectorAll returns NodeList instead of Array.
+        this._suggestionEls = Array.from(
+            this.$.suggestions.querySelectorAll('li'));
       } else {
         this._suggestionEls = [];
       }
@@ -166,5 +174,9 @@
     _resetCursorIndex() {
       this.$.cursor.setCursorAtIndex(0);
     },
+
+    _computeLabelClass(item) {
+      return item.label ? '' : 'hide';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
index 67a2ac4..07a3762 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-autocomplete-dropdown</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-autocomplete-dropdown.html">
 
@@ -42,7 +44,7 @@
       element = fixture('basic');
       element.open();
       element.suggestions = [
-        {dataValue: 'test value 1', name: 'test name 1', text: 1},
+        {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
         {dataValue: 'test value 2', name: 'test name 2', text: 2}];
       flushAsynchronousOperations();
     });
@@ -52,6 +54,12 @@
       if (element.isOpen) element.close();
     });
 
+    test('shows labels', () => {
+      const els = element.$.suggestions.querySelectorAll('li');
+      assert.equal(els[0].innerText.trim(), '1\nhi');
+      assert.equal(els[1].innerText.trim(), '2');
+    });
+
     test('escape key', done => {
       const closeSpy = sandbox.spy(element, 'close');
       MockInteractions.pressAndReleaseKeyOn(element, 27);
@@ -125,6 +133,19 @@
       });
     });
 
+    test('tapping child still selects item', () => {
+      const itemSelectedStub = sandbox.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+
+      MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
+          .lastElementChild);
+      flushAsynchronousOperations();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'tap',
+        selected: element.$.suggestions.querySelectorAll('li')[0],
+      });
+    });
+
     test('updated suggestions resets cursor stops', () => {
       resetStopsSpy = sandbox.spy(element, '_resetCursorStops');
       element.suggestions = [];
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index 4b447d1..ac739c3 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -14,8 +14,8 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/paper-input/paper-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/paper-input/paper-input.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
@@ -29,7 +29,7 @@
         display: none;
       }
       .searchIcon.showSearchIcon {
-        display: initial;
+        display: inline-block;
       }
       iron-icon {
         margin: 0 .25em;
@@ -68,7 +68,6 @@
         no-label-float
         id="input"
         class$="[[_computeClass(borderless)]]"
-        is="iron-input"
         disabled$="[[disabled]]"
         value="{{text}}"
         placeholder="[[placeholder]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 634bc00..76e14a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -22,6 +22,7 @@
 
   Polymer({
     is: 'gr-autocomplete',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a value is chosen.
@@ -47,10 +48,11 @@
       /**
        * Query for requesting autocomplete suggestions. The function should
        * accept the input as a string parameter and return a promise. The
-       * promise should yield an array of suggestion objects with "name" and
+       * promise yields an array of suggestion objects with "name", "label",
        * "value" properties. The "name" property will be displayed in the
-       * suggestion entry. The "value" property will be emitted if that
-       * suggestion is selected.
+       * suggestion entry. The "label" property will, when specified, appear
+       * next to the "name" as label text. The "value" property will be emitted
+       * if that suggestion is selected.
        *
        * @type {function(string): Promise<?>}
        */
@@ -209,7 +211,8 @@
     },
 
     get _inputElement() {
-      return this.$.input;
+      // Polymer2: this.$ can be undefined when this is first evaluated.
+      return this.$ && this.$.input;
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 585b16f..217321f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-autocomplete.html">
 
@@ -46,7 +48,7 @@
       sandbox.restore();
     });
 
-    test('renders', done => {
+    test('renders', () => {
       let promise;
       const queryStub = sandbox.spy(input => {
         return promise = Promise.resolve([
@@ -66,18 +68,17 @@
       assert.isTrue(queryStub.called);
       element._focused = true;
 
-      promise.then(() => {
+      return promise.then(() => {
         assert.isFalse(element.$.suggestions.isHidden);
         const suggestions =
             Polymer.dom(element.$.suggestions.root).querySelectorAll('li');
         assert.equal(suggestions.length, 5);
 
         for (let i = 0; i < 5; i++) {
-          assert.equal(suggestions[i].textContent, 'blah ' + i);
+          assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
         }
 
         assert.notEqual(element.$.suggestions.$.cursor.index, -1);
-        done();
       });
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
index bc63acf..1daffa2 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
@@ -28,7 +28,7 @@
         display: inline-block;
         border-radius: 50%;
         background-size: cover;
-        background-color: var(--background-color, #f1f2f3);
+        background-color: var(--avatar-background-color, #f1f2f3);
       }
     </style>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index f32e940b..2435e58 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-avatar',
+    _legacyUndefinedCheck: true,
 
     properties: {
       account: {
@@ -41,25 +42,30 @@
 
     attached() {
       Promise.all([
-        this.$.restAPI.getConfig(),
+        this._getConfig(),
         Gerrit.awaitPluginsLoaded(),
       ]).then(([cfg]) => {
         this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-        if (this._hasAvatars && this.account) {
-          // src needs to be set if avatar becomes visible
-          this._updateAvatarURL();
-        } else {
-          this.hidden = true;
-        }
+
+        this._updateAvatarURL();
       });
     },
 
+    _getConfig() {
+      return this.$.restAPI.getConfig();
+    },
+
     _accountChanged(account) {
       this._updateAvatarURL();
     },
 
     _updateAvatarURL() {
-      if (this.hidden || !this._hasAvatars) { return; }
+      if (!this._hasAvatars || !this.account) {
+        this.hidden = true;
+        return;
+      }
+      this.hidden = false;
+
       const url = this._buildAvatarURL(this.account);
       if (url) {
         this.style.backgroundImage = 'url("' + url + '")';
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
index f137c7f..42d7678 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-avatar</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-avatar.html">
 
@@ -35,14 +37,17 @@
 <script>
   suite('gr-avatar tests', () => {
     let element;
+    let sandbox;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({plugin: {has_avatars: true}}); },
-      });
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
+    teardown(() => {
+      sandbox.restore();
+    });
+
     test('methods', () => {
       assert.equal(element._buildAvatarURL(
           {
@@ -94,22 +99,32 @@
             ],
           }),
           '/accounts/123/avatar?s=16');
+      assert.equal(element._buildAvatarURL(undefined), '');
     });
 
     test('dom for existing account', () => {
       assert.isFalse(element.hasAttribute('hidden'));
+
+      sandbox.stub(element, '_getConfig', () => {
+        return Promise.resolve({plugin: {has_avatars: true}});
+      });
+
       element.imageSize = 64;
       element.account = {
         _account_id: 123,
       };
+
       assert.strictEqual(element.style.backgroundImage, '');
+
       // Emulate plugins loaded.
       Gerrit._setPluginsPending([]);
-      return Promise.all([
+
+      Promise.all([
         element.$.restAPI.getConfig(),
         Gerrit.awaitPluginsLoaded(),
       ]).then(() => {
         assert.isFalse(element.hasAttribute('hidden'));
+
         assert.isTrue(
             element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
       });
@@ -117,10 +132,57 @@
 
     test('dom for non available account', () => {
       assert.isFalse(element.hasAttribute('hidden'));
-      element.account = null;
-      assert.isFalse(element.hasAttribute('hidden'));
+
+      sandbox.stub(element, '_getConfig', () => {
+        return Promise.resolve({plugin: {has_avatars: true}});
+      });
+
       // Emulate plugins loaded.
       Gerrit._setPluginsPending([]);
+
+      return Promise.all([
+        element.$.restAPI.getConfig(),
+        Gerrit.awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isTrue(element.hasAttribute('hidden'));
+
+        assert.strictEqual(element.style.backgroundImage, '');
+      });
+    });
+
+    test('avatar config not set and account not set', () => {
+      assert.isFalse(element.hasAttribute('hidden'));
+
+      sandbox.stub(element, '_getConfig', () => {
+        return Promise.resolve({});
+      });
+
+      // Emulate plugins loaded.
+      Gerrit._setPluginsPending([]);
+
+      return Promise.all([
+        element.$.restAPI.getConfig(),
+        Gerrit.awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isTrue(element.hasAttribute('hidden'));
+      });
+    });
+
+    test('avatar config not set and account set', () => {
+      assert.isFalse(element.hasAttribute('hidden'));
+
+      sandbox.stub(element, '_getConfig', () => {
+        return Promise.resolve({});
+      });
+
+      element.imageSize = 64;
+      element.account = {
+        _account_id: 123,
+      };
+
+      // Emulate plugins loaded.
+      Gerrit._setPluginsPending([]);
+
       return Promise.all([
         element.$.restAPI.getConfig(),
         Gerrit.awaitPluginsLoaded(),
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index 8fff850..754c3c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -15,11 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/paper-button/paper-button.html">
+<link rel="import" href="/bower_components/paper-button/paper-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-button">
@@ -30,8 +30,6 @@
         --background-color: var(--button-background-color, var(--default-button-background-color));
         --text-color: var(--default-button-text-color);
         display: inline-block;
-        font-family: var(--font-family-bold);
-        font-size: var(--font-size-small);
         position: relative;
       }
       :host([hidden]) {
@@ -42,7 +40,7 @@
       }
       paper-button {
         /* paper-button sets this to anti-aliased, which appears different than
-        roboto-medium elsewhere. */
+          bold font elsewhere on macOS. */
         -webkit-font-smoothing: initial;
         align-items: center;
         background-color: var(--background-color);
@@ -52,7 +50,7 @@
         justify-content: center;
         margin: var(--margin, 0);
         min-width: var(--border, 0);
-        padding: var(--padding, 5px 10px);
+        padding: var(--padding, 4px 8px);
         @apply --gr-button;
       }
       paper-button:hover {
@@ -96,7 +94,9 @@
       }
 
       /* Styles for the optional down arrow */
-      :host:not([down-arrow]) .downArrow {display: none; }
+      :host(:not([down-arrow])) .downArrow {
+        display: none;
+      }
       :host([down-arrow]) .downArrow {
         border-top: .36em solid #ccc;
         border-left: .36em solid transparent;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index ca6705e..5988cde 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-button',
+    _legacyUndefinedCheck: true,
 
     properties: {
       tooltip: String,
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
index ed0da2e..807d095 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-button</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-button.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
index 70b2635..ebe3a6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
@@ -15,9 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-star">
@@ -36,7 +35,6 @@
           class$="[[_computeStarClass(change.starred)]]"
           icon$="[[_computeStarIcon(change.starred)]]"></iron-icon>
     </button>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-change-star.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index 9646735..98fd94a 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -19,6 +19,13 @@
 
   Polymer({
     is: 'gr-change-star',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when star state is toggled.
+     *
+     * @event toggle-star
+     */
 
     properties: {
       /** @type {?} */
@@ -26,8 +33,6 @@
         type: Object,
         notify: true,
       },
-
-      _xhrPromise: Object, // Used for testing.
     },
 
     _computeStarClass(starred) {
@@ -42,8 +47,11 @@
     toggleStar() {
       const newVal = !this.change.starred;
       this.set('change.starred', newVal);
-      this._xhrPromise = this.$.restAPI.saveChangeStarred(this.change._number,
-          newVal);
+      this.dispatchEvent(new CustomEvent('toggle-star', {
+        bubbles: true,
+        composed: true,
+        detail: {change: this.change, starred: newVal},
+      }));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
index f24b45c..7ee22a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-star</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-star.html">
 
@@ -37,9 +39,6 @@
     let element;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        saveChangeStarred() { return Promise.resolve({ok: true}); },
-      });
       element = fixture('basic');
       element.change = {
         _number: 2,
@@ -60,23 +59,21 @@
     });
 
     test('starring', done => {
-      element.set('change.starred', false);
-      MockInteractions.tap(element.$$('button'));
-
-      element._xhrPromise.then(req => {
+      element.addEventListener('toggle-star', () => {
         assert.equal(element.change.starred, true);
         done();
       });
+      element.set('change.starred', false);
+      MockInteractions.tap(element.$$('button'));
     });
 
     test('unstarring', done => {
-      element.set('change.starred', true);
-      MockInteractions.tap(element.$$('button'));
-
-      element._xhrPromise.then(req => {
+      element.addEventListener('toggle-star', () => {
         assert.equal(element.change.starred, false);
         done();
       });
+      element.set('change.starred', true);
+      MockInteractions.tap(element.$$('button'));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
index e256bf3..fa95382 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
@@ -68,7 +68,7 @@
         background-color: transparent;
         padding: .1em;
       }
-      :host:not([flat]) .chip {
+      :host(:not([flat])) .chip {
         color: white;
       }
     </style>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
index 8efd309..70c5d72 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
@@ -26,14 +26,15 @@
   };
 
   const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
-      'It will not appear in dashboards, and email notifications will be ' +
-      'silenced until the review is started.';
+      'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
+      'and email notifications will be silenced until the review is started.';
 
   const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
       'current reviewers (or anyone with "View Private Changes" permission).';
 
   Polymer({
     is: 'gr-change-status',
+    _legacyUndefinedCheck: true,
 
     properties: {
       flat: {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
index f73fc02..3ac4016 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-status</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-status.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
new file mode 100644
index 0000000..bee134b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
@@ -0,0 +1,126 @@
+<!--
+@license
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
+<link rel="import" href="../gr-comment/gr-comment.html">
+
+<dom-module id="gr-comment-thread">
+  <template>
+    <style include="shared-styles">
+      gr-button {
+        margin-left: .5em;
+      }
+      #actions {
+        margin-left: auto;
+        padding: .5em .7em;
+      }
+      #container {
+        background-color: var(--comment-background-color);
+        border: 1px solid var(--border-color);
+        color: var(--comment-text-color);
+        display: block;
+        margin-bottom: 1px;
+        white-space: normal;
+      }
+      #container.unresolved {
+        background-color: var(--unresolved-comment-background-color);
+      }
+      #commentInfoContainer {
+        border-top: 1px dotted var(--border-color);
+        display: flex;
+      }
+      #unresolvedLabel {
+        font-family: var(--font-family);
+        margin: auto 0;
+        padding: .5em .7em;
+      }
+      .pathInfo {
+        display: flex;
+        align-items: baseline;
+      }
+      .descriptionText {
+        margin-left: .5rem;
+        font-style: italic;
+      }
+    </style>
+    <template is="dom-if" if="[[showFilePath]]">
+      <div class="pathInfo">
+        <a href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]">[[_computeDisplayPath(path)]]</a>
+        <span class="descriptionText">Patchset [[patchNum]]</span>
+      </div>
+    </template>
+    <div id="container" class$="[[_computeHostClass(unresolved)]]">
+      <template id="commentList" is="dom-repeat" items="[[_orderedComments]]"
+          as="comment">
+        <gr-comment
+            comment="{{comment}}"
+            robot-button-disabled="[[_hideActions(_showActions, _lastComment)]]"
+            change-num="[[changeNum]]"
+            patch-num="[[patchNum]]"
+            draft="[[_isDraft(comment)]]"
+            show-actions="[[_showActions]]"
+            comment-side="[[comment.__commentSide]]"
+            side="[[comment.side]]"
+            root-id="[[rootId]]"
+            project-config="[[_projectConfig]]"
+            on-create-fix-comment="_handleCommentFix"
+            on-comment-discard="_handleCommentDiscard"
+            on-comment-save="_handleCommentSavedOrDiscarded"></gr-comment>
+      </template>
+      <div id="commentInfoContainer"
+          hidden$="[[_hideActions(_showActions, _lastComment)]]">
+        <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
+        <div id="actions">
+          <gr-button
+              id="replyBtn"
+              link
+              secondary
+              class="action reply"
+              on-tap="_handleCommentReply">Reply</gr-button>
+          <gr-button
+              id="quoteBtn"
+              link
+              secondary
+              class="action quote"
+              on-tap="_handleCommentQuote">Quote</gr-button>
+          <gr-button
+              id="ackBtn"
+              link
+              secondary
+              class="action ack"
+              on-tap="_handleCommentAck">Ack</gr-button>
+          <gr-button
+              id="doneBtn"
+              link
+              secondary
+              class="action done"
+              on-tap="_handleCommentDone">Done</gr-button>
+        </div>
+      </div>
+    </div>
+    <gr-reporting id="reporting"></gr-reporting>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
+  </template>
+  <script src="gr-comment-thread.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
new file mode 100644
index 0000000..cf98b42c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -0,0 +1,494 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  const UNRESOLVED_EXPAND_COUNT = 5;
+  const NEWLINE_PATTERN = /\n/g;
+
+  Polymer({
+    is: 'gr-comment-thread',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the thread should be discarded.
+     *
+     * @event thread-discard
+     */
+
+    /**
+     * Fired when a comment in the thread is permanently modified.
+     *
+     * @event thread-changed
+     */
+
+     /**
+      * gr-comment-thread exposes the following attributes that allow a
+      * diff widget like gr-diff to show the thread in the right location:
+      *
+      * line-num:
+      *     1-based line number or undefined if it refers to the entire file.
+      *
+      * comment-side:
+      *     "left" or "right". These indicate which of the two diffed versions
+      *     the comment relates to. In the case of unified diff, the left
+      *     version is the one whose line number column is further to the left.
+      *
+      * range:
+      *     The range of text that the comment refers to (start_line,
+      *     start_character, end_line, end_character), serialized as JSON. If
+      *     set, range's end_line will have the same value as line-num. Line
+      *     numbers are 1-based, char numbers are 0-based. The start position
+      *     (start_line, start_character) is inclusive, and the end position
+      *     (end_line, end_character) is exclusive.
+      */
+    properties: {
+      changeNum: String,
+      comments: {
+        type: Array,
+        value() { return []; },
+      },
+      /**
+       * @type {?{start_line: number, start_character: number, end_line: number,
+       *          end_character: number}}
+       */
+      range: {
+        type: Object,
+        reflectToAttribute: true,
+      },
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+      commentSide: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      patchNum: String,
+      path: String,
+      projectName: {
+        type: String,
+        observer: '_projectNameChanged',
+      },
+      hasDraft: {
+        type: Boolean,
+        notify: true,
+        reflectToAttribute: true,
+      },
+      isOnParent: {
+        type: Boolean,
+        value: false,
+      },
+      parentIndex: {
+        type: Number,
+        value: null,
+      },
+      rootId: {
+        type: String,
+        notify: true,
+        computed: '_computeRootId(comments.*)',
+      },
+      /**
+       * If this is true, the comment thread also needs to have the change and
+       * line properties property set
+       */
+      showFilePath: {
+        type: Boolean,
+        value: false,
+      },
+      /** Necessary only if showFilePath is true or when used with gr-diff */
+      lineNum: {
+        type: Number,
+        reflectToAttribute: true,
+      },
+      unresolved: {
+        type: Boolean,
+        notify: true,
+        reflectToAttribute: true,
+      },
+      _showActions: Boolean,
+      _lastComment: Object,
+      _orderedComments: Array,
+      _projectConfig: Object,
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PathListBehavior,
+    ],
+
+    listeners: {
+      'comment-update': '_handleCommentUpdate',
+    },
+
+    observers: [
+      '_commentsChanged(comments.*)',
+    ],
+
+    keyBindings: {
+      'e shift+e': '_handleEKey',
+    },
+
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
+        this._showActions = loggedIn;
+      });
+      this._setInitialExpandedState();
+    },
+
+    addOrEditDraft(opt_lineNum, opt_range) {
+      const lastComment = this.comments[this.comments.length - 1] || {};
+      if (lastComment.__draft) {
+        const commentEl = this._commentElWithDraftID(
+            lastComment.id || lastComment.__draftID);
+        commentEl.editing = true;
+
+        // If the comment was collapsed, re-open it to make it clear which
+        // actions are available.
+        commentEl.collapsed = false;
+      } else {
+        const range = opt_range ? opt_range :
+            lastComment ? lastComment.range : undefined;
+        const unresolved = lastComment ? lastComment.unresolved : undefined;
+        this.addDraft(opt_lineNum, range, unresolved);
+      }
+    },
+
+    addDraft(opt_lineNum, opt_range, opt_unresolved) {
+      const draft = this._newDraft(opt_lineNum, opt_range);
+      draft.__editing = true;
+      draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
+      this.push('comments', draft);
+    },
+
+    fireRemoveSelf() {
+      this.dispatchEvent(new CustomEvent('thread-discard',
+          {detail: {rootId: this.rootId}, bubbles: false}));
+    },
+
+    _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
+      return Gerrit.Nav.getUrlForDiffById(changeNum,
+          projectName, path, patchNum,
+          null, this.lineNum);
+    },
+
+    _computeDisplayPath(path) {
+      const lineString = this.lineNum ? `#${this.lineNum}` : '';
+      return this.computeDisplayPath(path) + lineString;
+    },
+
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _commentsChanged() {
+      this._orderedComments = this._sortedComments(this.comments);
+      this.updateThreadProperties();
+    },
+
+    updateThreadProperties() {
+      if (this._orderedComments.length) {
+        this._lastComment = this._getLastComment();
+        this.unresolved = this._lastComment.unresolved;
+        this.hasDraft = this._lastComment.__draft;
+      }
+    },
+
+    _hideActions(_showActions, _lastComment) {
+      return !_showActions || !_lastComment || !!_lastComment.__draft;
+    },
+
+    _getLastComment() {
+      return this._orderedComments[this._orderedComments.length - 1] || {};
+    },
+
+    _handleEKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      // Don’t preventDefault in this case because it will render the event
+      // useless for other handlers (other gr-comment-thread elements).
+      if (e.detail.keyboardEvent.shiftKey) {
+        this._expandCollapseComments(true);
+      } else {
+        if (this.modifierPressed(e)) { return; }
+        this._expandCollapseComments(false);
+      }
+    },
+
+    _expandCollapseComments(actionIsCollapse) {
+      const comments =
+          Polymer.dom(this.root).querySelectorAll('gr-comment');
+      for (const comment of comments) {
+        comment.collapsed = actionIsCollapse;
+      }
+    },
+
+    /**
+     * Sets the initial state of the comment thread.
+     * Expands the thread if one of the following is true:
+     * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
+     * thread is unresolved,
+     * - it's a robot comment.
+     */
+    _setInitialExpandedState() {
+      if (this._orderedComments) {
+        for (let i = 0; i < this._orderedComments.length; i++) {
+          const comment = this._orderedComments[i];
+          const isRobotComment = !!comment.robot_id;
+          // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
+          const resolvedThread = !this.unresolved ||
+                this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
+          comment.collapsed = !isRobotComment && resolvedThread;
+        }
+      }
+    },
+
+    _sortedComments(comments) {
+      return comments.slice().sort((c1, c2) => {
+        const c1Date = c1.__date || util.parseDate(c1.updated);
+        const c2Date = c2.__date || util.parseDate(c2.updated);
+        const dateCompare = c1Date - c2Date;
+        // Ensure drafts are at the end. There should only be one but in edge
+        // cases could be more. In the unlikely event two drafts are being
+        // compared, use the typical date compare.
+        if (c2.__draft && !c1.__draft ) { return -1; }
+        if (c1.__draft && !c2.__draft ) { return 1; }
+        if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
+        // If same date, fall back to sorting by id.
+        return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
+      });
+    },
+
+    _createReplyComment(parent, content, opt_isEditing,
+        opt_unresolved) {
+      this.$.reporting.recordDraftInteraction();
+      const reply = this._newReply(
+          this._orderedComments[this._orderedComments.length - 1].id,
+          parent.line,
+          content,
+          opt_unresolved,
+          parent.range);
+
+      // If there is currently a comment in an editing state, add an attribute
+      // so that the gr-comment knows not to populate the draft text.
+      for (let i = 0; i < this.comments.length; i++) {
+        if (this.comments[i].__editing) {
+          reply.__otherEditing = true;
+          break;
+        }
+      }
+
+      if (opt_isEditing) {
+        reply.__editing = true;
+      }
+
+      this.push('comments', reply);
+
+      if (!opt_isEditing) {
+        // Allow the reply to render in the dom-repeat.
+        this.async(() => {
+          const commentEl = this._commentElWithDraftID(reply.__draftID);
+          commentEl.save();
+        }, 1);
+      }
+    },
+
+    _isDraft(comment) {
+      return !!comment.__draft;
+    },
+
+    /**
+     * @param {boolean=} opt_quote
+     */
+    _processCommentReply(opt_quote) {
+      const comment = this._lastComment;
+      let quoteStr;
+      if (opt_quote) {
+        const msg = comment.message;
+        quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+      }
+      this._createReplyComment(comment, quoteStr, true, comment.unresolved);
+    },
+
+    _handleCommentReply(e) {
+      this._processCommentReply();
+    },
+
+    _handleCommentQuote(e) {
+      this._processCommentReply(true);
+    },
+
+    _handleCommentAck(e) {
+      const comment = this._lastComment;
+      this._createReplyComment(comment, 'Ack', false, false);
+    },
+
+    _handleCommentDone(e) {
+      const comment = this._lastComment;
+      this._createReplyComment(comment, 'Done', false, false);
+    },
+
+    _handleCommentFix(e) {
+      const comment = e.detail.comment;
+      const msg = comment.message;
+      const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+      const response = quoteStr + 'Please Fix';
+      this._createReplyComment(comment, response, false, true);
+    },
+
+    _commentElWithDraftID(id) {
+      const els = Polymer.dom(this.root).querySelectorAll('gr-comment');
+      for (const el of els) {
+        if (el.comment.id === id || el.comment.__draftID === id) {
+          return el;
+        }
+      }
+      return null;
+    },
+
+    _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
+        opt_range) {
+      const d = this._newDraft(opt_lineNum);
+      d.in_reply_to = inReplyTo;
+      d.range = opt_range;
+      if (opt_message != null) {
+        d.message = opt_message;
+      }
+      if (opt_unresolved !== undefined) {
+        d.unresolved = opt_unresolved;
+      }
+      return d;
+    },
+
+    /**
+     * @param {number=} opt_lineNum
+     * @param {!Object=} opt_range
+     */
+    _newDraft(opt_lineNum, opt_range) {
+      const d = {
+        __draft: true,
+        __draftID: Math.random().toString(36),
+        __date: new Date(),
+        path: this.path,
+        patchNum: this.patchNum,
+        side: this._getSide(this.isOnParent),
+        __commentSide: this.commentSide,
+      };
+      if (opt_lineNum) {
+        d.line = opt_lineNum;
+      }
+      if (opt_range) {
+        d.range = opt_range;
+      }
+      if (this.parentIndex) {
+        d.parent = this.parentIndex;
+      }
+      return d;
+    },
+
+    _getSide(isOnParent) {
+      if (isOnParent) { return 'PARENT'; }
+      return 'REVISION';
+    },
+
+    _computeRootId(comments) {
+      // Keep the root ID even if the comment was removed, so that notification
+      // to sync will know which thread to remove.
+      if (!comments.base.length) { return this.rootId; }
+      const rootComment = comments.base[0];
+      return rootComment.id || rootComment.__draftID;
+    },
+
+    _handleCommentDiscard(e) {
+      const diffCommentEl = Polymer.dom(e).rootTarget;
+      const comment = diffCommentEl.comment;
+      const idx = this._indexOf(comment, this.comments);
+      if (idx == -1) {
+        throw Error('Cannot find comment ' +
+            JSON.stringify(diffCommentEl.comment));
+      }
+      this.splice('comments', idx, 1);
+      if (this.comments.length === 0) {
+        this.fireRemoveSelf();
+      }
+      this._handleCommentSavedOrDiscarded(e);
+
+      // Check to see if there are any other open comments getting edited and
+      // set the local storage value to its message value.
+      for (const changeComment of this.comments) {
+        if (changeComment.__editing) {
+          const commentLocation = {
+            changeNum: this.changeNum,
+            patchNum: this.patchNum,
+            path: changeComment.path,
+            line: changeComment.line,
+          };
+          return this.$.storage.setDraftComment(commentLocation,
+              changeComment.message);
+        }
+      }
+    },
+
+    _handleCommentSavedOrDiscarded(e) {
+      this.dispatchEvent(new CustomEvent('thread-changed',
+          {detail: {rootId: this.rootId, path: this.path},
+            bubbles: false}));
+    },
+
+    _handleCommentUpdate(e) {
+      const comment = e.detail.comment;
+      const index = this._indexOf(comment, this.comments);
+      if (index === -1) {
+        // This should never happen: comment belongs to another thread.
+        console.warn('Comment update for another comment thread.');
+        return;
+      }
+      this.set(['comments', index], comment);
+      // Because of the way we pass these comment objects around by-ref, in
+      // combination with the fact that Polymer does dirty checking in
+      // observers, the this.set() call above will not cause a thread update in
+      // some situations.
+      this.updateThreadProperties();
+    },
+
+    _indexOf(comment, arr) {
+      for (let i = 0; i < arr.length; i++) {
+        const c = arr[i];
+        if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
+            (c.id != null && c.id == comment.id)) {
+          return i;
+        }
+      }
+      return -1;
+    },
+
+    _computeHostClass(unresolved) {
+      return unresolved ? 'unresolved' : '';
+    },
+
+    /**
+     * Load the project config when a project name has been provided.
+     * @param {string} name The project name.
+     */
+    _projectNameChanged(name) {
+      if (!name) { return; }
+      this.$.restAPI.getProjectConfig(name).then(config => {
+        this._projectConfig = config;
+      });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
new file mode 100644
index 0000000..86da001
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
@@ -0,0 +1,753 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-comment-thread</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-comment-thread.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-comment-thread></gr-comment-thread>
+  </template>
+</test-fixture>
+
+<test-fixture id="withComment">
+  <template>
+    <gr-comment-thread></gr-comment-thread>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-comment-thread tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('comments are sorted correctly', () => {
+      const comments = [
+        {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          __date: new Date('2015-12-25'),
+        }, {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }, {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000',
+        },
+      ];
+      const results = element._sortedComments(comments);
+      assert.deepEqual(results, [
+        {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }, {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          __date: new Date('2015-12-25'),
+        },
+      ]);
+    });
+
+    test('addOrEditDraft w/ edit draft', () => {
+      element.comments = [{
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        in_reply_to: 'sallys_confession',
+        updated: '2015-12-25 15:00:20.396000000',
+        __draft: true,
+      }];
+      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          () => { return {}; });
+      const addDraftStub = sandbox.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isTrue(commentElStub.called);
+      assert.isFalse(addDraftStub.called);
+    });
+
+    test('addOrEditDraft w/o edit draft', () => {
+      element.comments = [];
+      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          () => { return {}; });
+      const addDraftStub = sandbox.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isFalse(commentElStub.called);
+      assert.isTrue(addDraftStub.called);
+    });
+
+    test('_hideActions', () => {
+      let showActions = true;
+      const lastComment = {};
+      assert.equal(element._hideActions(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+    });
+
+    test('setting project name loads the project config', done => {
+      const projectName = 'foo/bar/baz';
+      const getProjectStub = sandbox.stub(element.$.restAPI, 'getProjectConfig')
+          .returns(Promise.resolve({}));
+      element.projectName = projectName;
+      flush(() => {
+        assert.isTrue(getProjectStub.calledWithExactly(projectName));
+        done();
+      });
+    });
+
+    test('optionally show file path', () => {
+      // Path info doesn't exist when showFilePath is false. Because it's in a
+      // dom-if it is not yet in the dom.
+      assert.isNotOk(element.$$('.pathInfo'));
+
+      sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      element.changeNum = 123;
+      element.projectName = 'test project';
+      element.path = 'path/to/file';
+      element.patchNum = 3;
+      element.lineNum = 5;
+      element.showFilePath = true;
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('.pathInfo'));
+      assert.notEqual(getComputedStyle(element.$$('.pathInfo')).display,
+          'none');
+      assert.isTrue(Gerrit.Nav.getUrlForDiffById.lastCall.calledWithExactly(
+          element.changeNum, element.projectName, element.path,
+          element.patchNum, null, element.lineNum));
+    });
+
+    test('_computeDisplayPath', () => {
+      const path = 'path/to/file';
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayPath(path), 'path/to/file#5');
+    });
+  });
+
+  suite('comment action tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+        saveDiffDraft() {
+          return Promise.resolve({
+            ok: true,
+            text() {
+              return Promise.resolve(')]}\'\n' +
+                  JSON.stringify({
+                    id: '7afa4931_de3d65bd',
+                    path: '/path/to/file.txt',
+                    line: 5,
+                    in_reply_to: 'baf0414d_60047215',
+                    updated: '2015-12-21 02:01:10.850000000',
+                    message: 'Done',
+                  }));
+            },
+          });
+        },
+        deleteDiffDraft() { return Promise.resolve({ok: true}); },
+      });
+      element = fixture('withComment');
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+        path: '/path/to/file.txt',
+      }];
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('reply', () => {
+      const commentEl = element.$$('gr-comment');
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      assert.ok(commentEl);
+
+      const replyBtn = element.$.replyBtn;
+      MockInteractions.tap(replyBtn);
+      flushAsynchronousOperations();
+
+      const drafts = element._orderedComments.filter(c => {
+        return c.__draft == true;
+      });
+      assert.equal(drafts.length, 1);
+      assert.notOk(drafts[0].message, 'message should be empty');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('quote reply', () => {
+      const commentEl = element.$$('gr-comment');
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      assert.ok(commentEl);
+
+      const quoteBtn = element.$.quoteBtn;
+      MockInteractions.tap(quoteBtn);
+      flushAsynchronousOperations();
+
+      const drafts = element._orderedComments.filter(c => {
+        return c.__draft == true;
+      });
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('quote reply multiline', () => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?\nIt might be!',
+        updated: '2015-12-08 19:48:33.843000000',
+      }];
+      flushAsynchronousOperations();
+
+      const commentEl = element.$$('gr-comment');
+      assert.ok(commentEl);
+
+      const quoteBtn = element.$.quoteBtn;
+      MockInteractions.tap(quoteBtn);
+      flushAsynchronousOperations();
+
+      const drafts = element._orderedComments.filter(c => {
+        return c.__draft == true;
+      });
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message,
+          '> is this a crossover episode!?\n> It might be!\n\n');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('ack', done => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      element.changeNum = '42';
+      element.patchNum = '1';
+
+      const commentEl = element.$$('gr-comment');
+      assert.ok(commentEl);
+
+      const ackBtn = element.$.ackBtn;
+      MockInteractions.tap(ackBtn);
+      flush(() => {
+        const drafts = element.comments.filter(c => {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(drafts[0].message, 'Ack');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        assert.equal(drafts[0].unresolved, false);
+        assert.isTrue(reportStub.calledOnce);
+        done();
+      });
+    });
+
+    test('done', done => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      element.changeNum = '42';
+      element.patchNum = '1';
+      const commentEl = element.$$('gr-comment');
+      assert.ok(commentEl);
+
+      const doneBtn = element.$.doneBtn;
+      MockInteractions.tap(doneBtn);
+      flush(() => {
+        const drafts = element.comments.filter(c => {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(drafts[0].message, 'Done');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        assert.isFalse(drafts[0].unresolved);
+        assert.isTrue(reportStub.calledOnce);
+        done();
+      });
+    });
+
+    test('save', done => {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.path = '/path/to/file.txt';
+      const commentEl = element.$$('gr-comment');
+      assert.ok(commentEl);
+
+      const saveOrDiscardStub = sandbox.stub();
+      element.addEventListener('thread-changed', saveOrDiscardStub);
+      element.$$('gr-comment')._fireSave();
+
+      flush(() => {
+        assert.isTrue(saveOrDiscardStub.called);
+        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+            'baf0414d_60047215');
+        assert.equal(element.rootId, 'baf0414d_60047215');
+        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+            '/path/to/file.txt');
+        done();
+      });
+    });
+
+    test('please fix', done => {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      const commentEl = element.$$('gr-comment');
+      assert.ok(commentEl);
+      commentEl.addEventListener('create-fix-comment', () => {
+        const drafts = element._orderedComments.filter(c => {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(
+            drafts[0].message, '> is this a crossover episode!?\n\nPlease Fix');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        assert.isTrue(drafts[0].unresolved);
+        done();
+      });
+      commentEl.fire('create-fix-comment', {comment: commentEl.comment},
+          {bubbles: false});
+    });
+
+    test('discard', done => {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.path = '/path/to/file.txt';
+      element.push('comments', element._newReply(
+          element.comments[0].id,
+          element.comments[0].line,
+          element.comments[0].path,
+          'it’s pronouced jiff, not giff'));
+      flushAsynchronousOperations();
+
+      const saveOrDiscardStub = sandbox.stub();
+      element.addEventListener('thread-changed', saveOrDiscardStub);
+      const draftEl =
+          Polymer.dom(element.root).querySelectorAll('gr-comment')[1];
+      assert.ok(draftEl);
+      draftEl.addEventListener('comment-discard', () => {
+        const drafts = element.comments.filter(c => {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 0);
+        assert.isTrue(saveOrDiscardStub.called);
+        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+            element.rootId);
+        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+            element.path);
+        done();
+      });
+      draftEl.fire('comment-discard', {comment: draftEl.comment},
+          {bubbles: false});
+    });
+
+    test('discard with a single comment still fires event with previous rootId',
+        done => {
+          element.changeNum = '42';
+          element.patchNum = '1';
+          element.path = '/path/to/file.txt';
+          element.comments = [];
+          element.addOrEditDraft('1');
+          flushAsynchronousOperations();
+          const rootId = element.rootId;
+          assert.isOk(rootId);
+
+          const saveOrDiscardStub = sandbox.stub();
+          element.addEventListener('thread-changed', saveOrDiscardStub);
+          const draftEl =
+          Polymer.dom(element.root).querySelectorAll('gr-comment')[0];
+          assert.ok(draftEl);
+          draftEl.addEventListener('comment-discard', () => {
+            assert.equal(element.comments.length, 0);
+            assert.isTrue(saveOrDiscardStub.called);
+            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+                rootId);
+            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+                element.path);
+            done();
+          });
+          draftEl.fire('comment-discard', {comment: draftEl.comment},
+          {bubbles: false});
+        });
+
+    test('first editing comment does not add __otherEditing attribute', () => {
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+        __draft: true,
+      }];
+
+      const replyBtn = element.$.replyBtn;
+      MockInteractions.tap(replyBtn);
+      flushAsynchronousOperations();
+
+      const editing = element._orderedComments.filter(c => {
+        return c.__editing == true;
+      });
+      assert.equal(editing.length, 1);
+      assert.equal(!!editing[0].__otherEditing, false);
+    });
+
+    test('When not editing other comments, local storage not set' +
+        ' after discard', done => {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:31.843000000',
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        __draftID: '1',
+        in_reply_to: 'baf0414d_60047215',
+        line: 5,
+        message: 'yes',
+        updated: '2015-12-08 19:48:32.843000000',
+        __draft: true,
+        __editing: true,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        __draftID: '2',
+        in_reply_to: 'baf0414d_60047215',
+        line: 5,
+        message: 'no',
+        updated: '2015-12-08 19:48:33.843000000',
+        __draft: true,
+      }];
+      const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+      flushAsynchronousOperations();
+
+      const draftEl =
+      Polymer.dom(element.root).querySelectorAll('gr-comment')[1];
+      assert.ok(draftEl);
+      draftEl.addEventListener('comment-discard', () => {
+        assert.isFalse(storageStub.called);
+        storageStub.restore();
+        done();
+      });
+      draftEl.fire('comment-discard', {comment: draftEl.comment},
+          {bubbles: false});
+    });
+
+    test('comment-update', () => {
+      const commentEl = element.$$('gr-comment');
+      const updatedComment = {
+        id: element.comments[0].id,
+        foo: 'bar',
+      };
+      commentEl.fire('comment-update', {comment: updatedComment});
+      assert.strictEqual(element.comments[0], updatedComment);
+    });
+
+    suite('jack and sally comment data test consolidation', () => {
+      setup(() => {
+        element.comments = [
+          {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            in_reply_to: 'sallys_confession',
+            updated: '2015-12-25 15:00:20.396000000',
+            unresolved: false,
+          }, {
+            id: 'sallys_confession',
+            in_reply_to: 'nonexistent_comment',
+            message: 'i like you, jack',
+            updated: '2015-12-24 15:00:20.396000000',
+          }, {
+            id: 'sally_to_dr_finklestein',
+            in_reply_to: 'nonexistent_comment',
+            message: 'i’m running away',
+            updated: '2015-10-31 09:00:20.396000000',
+          }, {
+            id: 'sallys_defiance',
+            message: 'i will poison you so i can get away',
+            updated: '2015-10-31 15:00:20.396000000',
+          }];
+      });
+
+      test('orphan replies', () => {
+        assert.equal(4, element._orderedComments.length);
+      });
+
+      test('keyboard shortcuts', () => {
+        const expandCollapseStub =
+            sinon.stub(element, '_expandCollapseComments');
+        MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
+        assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+        MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+        assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+      });
+
+      test('comment in_reply_to is either null or most recent comment', () => {
+        element._createReplyComment(element.comments[3], 'dummy', true);
+        flushAsynchronousOperations();
+        assert.equal(element._orderedComments.length, 5);
+        assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
+      });
+
+      test('resolvable comments', () => {
+        assert.isFalse(element.unresolved);
+        element._createReplyComment(element.comments[3], 'dummy', true, true);
+        flushAsynchronousOperations();
+        assert.isTrue(element.unresolved);
+      });
+
+      test('_setInitialExpandedState', () => {
+        element.unresolved = true;
+        element._setInitialExpandedState();
+        for (let i = 0; i < element.comments.length; i++) {
+          assert.isFalse(element.comments[i].collapsed);
+        }
+        element.unresolved = false;
+        element._setInitialExpandedState();
+        for (let i = 0; i < element.comments.length; i++) {
+          assert.isTrue(element.comments[i].collapsed);
+        }
+        for (let i = 0; i < element.comments.length; i++) {
+          element.comments[i].robot_id = 123;
+        }
+        element._setInitialExpandedState();
+        for (let i = 0; i < element.comments.length; i++) {
+          assert.isFalse(element.comments[i].collapsed);
+        }
+      });
+    });
+
+    test('_computeHostClass', () => {
+      assert.equal(element._computeHostClass(true), 'unresolved');
+      assert.equal(element._computeHostClass(false), '');
+    });
+
+    test('addDraft sets unresolved state correctly', () => {
+      let unresolved = true;
+      element.comments = [];
+      element.addDraft(null, null, unresolved);
+      assert.equal(element.comments[0].unresolved, true);
+
+      unresolved = false; // comment should get added as actually resolved.
+      element.comments = [];
+      element.addDraft(null, null, unresolved);
+      assert.equal(element.comments[0].unresolved, false);
+
+      element.comments = [];
+      element.addDraft();
+      assert.equal(element.comments[0].unresolved, true);
+    });
+
+    test('_newDraft', () => {
+      element.commentSide = 'left';
+      element.patchNum = 3;
+      const draft = element._newDraft();
+      assert.equal(draft.__commentSide, 'left');
+      assert.equal(draft.patchNum, 3);
+    });
+
+    test('new comment gets created', () => {
+      element.comments = [];
+      element.addOrEditDraft(1);
+      assert.equal(element.comments.length, 1);
+      // Mock a submitted comment.
+      element.comments[0].id = element.comments[0].__draftID;
+      element.comments[0].__draft = false;
+      element.addOrEditDraft(1);
+      assert.equal(element.comments.length, 2);
+    });
+
+    test('unresolved label', () => {
+      element.unresolved = false;
+      assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
+      element.unresolved = true;
+      assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
+    });
+
+    test('draft comments are at the end of orderedComments', () => {
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 2,
+        line: 5,
+        message: 'Earlier draft',
+        updated: '2015-12-08 19:48:33.843000000',
+        __draft: true,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter2',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 1,
+        line: 5,
+        message: 'This comment was left last but is not a draft',
+        updated: '2015-12-10 19:48:33.843000000',
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter2',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 3,
+        line: 5,
+        message: 'Later draft',
+        updated: '2015-12-09 19:48:33.843000000',
+        __draft: true,
+      }];
+      assert.equal(element._orderedComments[0].id, '1');
+      assert.equal(element._orderedComments[1].id, '2');
+      assert.equal(element._orderedComments[2].id, '3');
+    });
+
+    test('reflects lineNum and commentSide to attributes', () => {
+      element.lineNum = 7;
+      element.commentSide = 'left';
+
+      assert.equal(element.getAttribute('line-num'), '7');
+      assert.equal(element.getAttribute('comment-side'), 'left');
+    });
+
+    test('reflects range to JSON serialized attribute if set', () => {
+      element.range = {
+        start_line: 4,
+        end_line: 5,
+        start_character: 6,
+        end_character: 7,
+      };
+
+      assert.deepEqual(
+          JSON.parse(element.getAttribute('range')),
+          {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
+    });
+
+    test('removes range attribute if range is unset', () => {
+      element.range = {
+        start_line: 4,
+        end_line: 5,
+        start_character: 6,
+        end_character: 7,
+      };
+      element.range = undefined;
+
+      assert.notOk(element.hasAttribute('range'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
new file mode 100644
index 0000000..1460035
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
@@ -0,0 +1,388 @@
+<!--
+@license
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
+<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
+<link rel="import" href="../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html">
+<script src="../../../scripts/rootElement.js"></script>
+
+<dom-module id="gr-comment">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        font-family: var(--font-family);
+        padding: .7em .7em;
+        --iron-autogrow-textarea: {
+          box-sizing: border-box;
+          padding: 2px;
+        };
+      }
+      :host([disabled]) {
+        pointer-events: none;
+      }
+      :host([disabled]) .actions,
+      :host([disabled]) .robotActions,
+      :host([disabled]) .date {
+        opacity: .5;
+      }
+      :host([discarding]) {
+        display: none;
+      }
+      .header {
+        align-items: baseline;
+        cursor: pointer;
+        display: flex;
+        font-family: 'Open Sans', sans-serif;
+        margin: -.7em -.7em 0 -.7em;
+        padding: .7em;
+      }
+      .container.collapsed .header {
+        margin-bottom: -.7em;
+      }
+      .headerMiddle {
+        color: var(--deemphasized-text-color);
+        flex: 1;
+        overflow: hidden;
+      }
+      .authorName,
+      .draftLabel,
+      .draftTooltip {
+        font-weight: var(--font-weight-bold);
+      }
+      .draftLabel,
+      .draftTooltip {
+        color: var(--deemphasized-text-color);
+        display: none;
+      }
+      .date {
+        justify-content: flex-end;
+        margin-left: 5px;
+        min-width: 4.5em;
+        text-align: right;
+        white-space: nowrap;
+      }
+      span.date {
+        color: var(--deemphasized-text-color);
+      }
+      span.date:hover {
+        text-decoration: underline;
+      }
+      .actions {
+        display: flex;
+        justify-content: flex-end;
+        padding-top: 0;
+      }
+      .action {
+        margin-left: 1em;
+      }
+      .robotActions {
+        display: flex;
+        justify-content: flex-start;
+        padding-top: 0;
+      }
+      .robotActions .action {
+        /* Keep button text lined up with output text */
+        margin-left: -.3rem;
+        margin-right: 1em;
+      }
+      .rightActions {
+        display: flex;
+        justify-content: flex-end;
+      }
+      .editMessage {
+        display: none;
+        margin: .5em 0;
+        width: 100%;
+      }
+      .container:not(.draft) .actions .hideOnPublished {
+        display: none;
+      }
+      .draft .reply,
+      .draft .quote,
+      .draft .ack,
+      .draft .done {
+        display: none;
+      }
+      .draft .draftLabel,
+      .draft .draftTooltip {
+        display: inline;
+      }
+      .draft:not(.editing) .save,
+      .draft:not(.editing) .cancel {
+        display: none;
+      }
+      .editing .message,
+      .editing .reply,
+      .editing .quote,
+      .editing .ack,
+      .editing .done,
+      .editing .edit,
+      .editing .discard,
+      .editing .unresolved {
+        display: none;
+      }
+      .editing .editMessage {
+        display: block;
+      }
+      .show-hide {
+        margin-left: .4em;
+      }
+      .robotId {
+        color: var(--deemphasized-text-color);
+        margin-bottom: .8em;
+        margin-top: -.4em;
+      }
+      .robotIcon {
+        margin-right: .2em;
+        /* because of the antenna of the robot, it looks off center even when it
+         is centered. artificially adjust margin to account for this. */
+        margin-top: -.3em;
+      }
+      .runIdInformation {
+        margin: .7em 0;
+      }
+      .robotRun {
+        margin-left: .5em;
+      }
+      .robotRunLink {
+        margin-left: .5em;
+      }
+      input.show-hide {
+        display: none;
+      }
+      label.show-hide {
+        color: var(--comment-text-color);
+        cursor: pointer;
+        display: block;
+        font-size: .8rem;
+        height: 1.1em;
+        margin-top: .1em;
+      }
+      #container .collapsedContent {
+        display: none;
+      }
+      #container.collapsed {
+        padding-bottom: 3px;
+      }
+      #container.collapsed .collapsedContent {
+        display: block;
+        overflow: hidden;
+        padding-left: 5px;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      #container.collapsed .actions,
+      #container.collapsed gr-formatted-text,
+      #container.collapsed gr-textarea {
+        display: none;
+      }
+      .resolve,
+      .unresolved {
+        align-items: center;
+        display: flex;
+        flex: 1;
+        margin: 0;
+      }
+      .resolve label {
+        color: var(--comment-text-color);
+      }
+      gr-dialog .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+      #deleteBtn {
+        display: none;
+        --gr-button: {
+          color: var(--deemphasized-text-color);
+          padding: 0;
+        }
+      }
+      #deleteBtn.showDeleteButtons {
+        display: block;
+      }
+    </style>
+    <div id="container" class="container">
+      <div class="header" id="header" on-tap="_handleToggleCollapsed">
+        <div class="headerLeft">
+          <span class="authorName">[[comment.author.name]]</span>
+          <span class="draftLabel">DRAFT</span>
+          <gr-tooltip-content class="draftTooltip"
+              has-tooltip
+              title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'A' key."
+              max-width="20em"
+              show-icon></gr-tooltip-content>
+        </div>
+        <div class="headerMiddle">
+          <span class="collapsedContent">[[comment.message]]</span>
+        </div>
+        <gr-button
+            id="deleteBtn"
+            link
+            secondary
+            class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
+            on-tap="_handleCommentDelete">
+          (Delete)
+        </gr-button>
+        <span class="date" on-tap="_handleAnchorTap">
+          <gr-date-formatter
+              has-tooltip
+              date-str="[[comment.updated]]"></gr-date-formatter>
+        </span>
+        <div class="show-hide">
+          <label class="show-hide">
+            <input type="checkbox" class="show-hide"
+               checked$="[[collapsed]]"
+               on-change="_handleToggleCollapsed">
+            [[_computeShowHideText(collapsed)]]
+          </label>
+        </div>
+      </div>
+      <div class="body">
+        <template is="dom-if" if="[[comment.robot_id]]">
+          <div class="robotId" hidden$="[[collapsed]]">
+            <iron-icon class="robotIcon" icon="gr-icons:robot"></iron-icon>
+            [[comment.robot_id]]
+          </div>
+        </template>
+        <template is="dom-if" if="[[editing]]">
+          <gr-textarea
+              id="editTextarea"
+              class="editMessage"
+              autocomplete="on"
+              monospace
+              disabled="{{disabled}}"
+              rows="4"
+              text="{{_messageText}}"></gr-textarea>
+        </template>
+        <!--The message class is needed to ensure selectability from
+        gr-diff-selection.-->
+        <gr-formatted-text class="message"
+            content="[[comment.message]]"
+            no-trailing-margin="[[!comment.__draft]]"
+            collapsed="[[collapsed]]"
+            config="[[projectConfig.commentlinks]]"></gr-formatted-text>
+        <div hidden$="[[!comment.robot_run_id]]" class="message">
+          <div class="runIdInformation" hidden$="[[collapsed]]">
+            Run ID:
+            <template is="dom-if" if="[[comment.url]]">
+              <a class="robotRunLink" href$="[[comment.url]]">
+                <span class="robotRun link">[[comment.robot_run_id]]</span>
+              </a>
+            </template>
+            <template is="dom-if" if="[[!comment.url]]">
+              <span class="robotRun text">[[comment.robot_run_id]]</span>
+            </template>
+          </div>
+        </div>
+        <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
+          <div class="action resolve hideOnPublished">
+            <label>
+              <input type="checkbox"
+                  id="resolvedCheckbox"
+                  checked="[[resolved]]"
+                  on-change="_handleToggleResolved">
+              Resolved
+            </label>
+          </div>
+          <div class="rightActions">
+            <gr-button
+                link
+                secondary
+                class="action cancel hideOnPublished"
+                on-tap="_handleCancel">Cancel</gr-button>
+            <gr-button
+                link
+                secondary
+                class="action discard hideOnPublished"
+                on-tap="_handleDiscard">Discard</gr-button>
+            <gr-button
+                link
+                secondary
+                class="action edit hideOnPublished"
+                on-tap="_handleEdit">Edit</gr-button>
+            <gr-button
+                link
+                secondary
+                disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
+                class="action save hideOnPublished"
+                on-tap="_handleSave">Save</gr-button>
+          </div>
+        </div>
+        <div class="robotActions" hidden$="[[!_showRobotActions]]">
+          <template is="dom-if" if="[[isRobotComment]]">
+            <gr-button
+                link
+                secondary
+                class="action fix"
+                on-tap="_handleFix"
+                disabled="[[robotButtonDisabled]]">
+              Please Fix
+            </gr-button>
+            <gr-endpoint-decorator name="robot-comment-controls">
+              <gr-endpoint-param name="comment" value="[[comment]]">
+              </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </template>
+        </div>
+      </div>
+    </div>
+    <template is="dom-if" if="[[_enableOverlay]]">
+      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+        <gr-confirm-delete-comment-dialog id="confirmDeleteComment"
+            on-confirm="_handleConfirmDeleteComment"
+            on-cancel="_handleCancelDeleteComment">
+        </gr-confirm-delete-comment-dialog>
+      </gr-overlay>
+      <gr-overlay id="confirmDiscardOverlay" with-backdrop>
+        <gr-dialog
+            id="confirmDiscardDialog"
+            confirm-label="Discard"
+            confirm-on-enter
+            on-confirm="_handleConfirmDiscard"
+            on-cancel="_closeConfirmDiscardOverlay">
+          <div class="header" slot="header">
+            Discard comment
+          </div>
+          <div class="main" slot="main">
+            Are you sure you want to discard this draft comment?
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    </template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
+    <gr-reporting id="reporting"></gr-reporting>
+  </template>
+  <script src="gr-comment.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
new file mode 100644
index 0000000..bf7df71
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -0,0 +1,664 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  const STORAGE_DEBOUNCE_INTERVAL = 400;
+  const TOAST_DEBOUNCE_INTERVAL = 200;
+
+  const SAVING_MESSAGE = 'Saving';
+  const DRAFT_SINGULAR = 'draft...';
+  const DRAFT_PLURAL = 'drafts...';
+  const SAVED_MESSAGE = 'All changes saved';
+
+  const REPORT_CREATE_DRAFT = 'CreateDraftComment';
+  const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
+  const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
+
+  const FILE = 'FILE';
+
+  Polymer({
+    is: 'gr-comment',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the create fix comment action is triggered.
+     *
+     * @event create-fix-comment
+     */
+
+    /**
+     * Fired when this comment is discarded.
+     *
+     * @event comment-discard
+     */
+
+    /**
+     * Fired when this comment is saved.
+     *
+     * @event comment-save
+     */
+
+    /**
+     * Fired when this comment is updated.
+     *
+     * @event comment-update
+     */
+
+    /**
+     * Fired when the comment's timestamp is tapped.
+     *
+     * @event comment-anchor-tap
+     */
+
+    properties: {
+      changeNum: String,
+      /** @type {?} */
+      comment: {
+        type: Object,
+        notify: true,
+        observer: '_commentChanged',
+      },
+      isRobotComment: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      draft: {
+        type: Boolean,
+        value: false,
+        observer: '_draftChanged',
+      },
+      editing: {
+        type: Boolean,
+        value: false,
+        observer: '_editingChanged',
+      },
+      discarding: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      hasChildren: Boolean,
+      patchNum: String,
+      showActions: Boolean,
+      _showHumanActions: Boolean,
+      _showRobotActions: Boolean,
+      collapsed: {
+        type: Boolean,
+        value: true,
+        observer: '_toggleCollapseClass',
+      },
+      /** @type {?} */
+      projectConfig: Object,
+      robotButtonDisabled: Boolean,
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+
+      _xhrPromise: Object, // Used for testing.
+      _messageText: {
+        type: String,
+        value: '',
+        observer: '_messageTextChanged',
+      },
+      commentSide: String,
+
+      resolved: Boolean,
+
+      _numPendingDraftRequests: {
+        type: Object,
+        value:
+            {number: 0}, // Intentional to share the object across instances.
+      },
+
+      _enableOverlay: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * Property for storing references to overlay elements. When the overlays
+       * are moved to Gerrit.getRootElement() to be shown they are no-longer
+       * children, so they can't be queried along the tree, so they are stored
+       * here.
+       */
+      _overlays: {
+        type: Object,
+        value: () => ({}),
+      },
+    },
+
+    observers: [
+      '_commentMessageChanged(comment.message)',
+      '_loadLocalDraft(changeNum, patchNum, comment)',
+      '_isRobotComment(comment)',
+      '_calculateActionstoShow(showActions, isRobotComment)',
+    ],
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyBindings: {
+      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
+      'esc': '_handleEsc',
+    },
+
+    attached() {
+      if (this.editing) {
+        this.collapsed = false;
+      } else if (this.comment) {
+        this.collapsed = this.comment.collapsed;
+      }
+      this._getIsAdmin().then(isAdmin => {
+        this._isAdmin = isAdmin;
+      });
+    },
+
+    detached() {
+      this.cancelDebouncer('fire-update');
+      if (this.textarea) {
+        this.textarea.closeDropdown();
+      }
+    },
+
+    get textarea() {
+      return this.$$('#editTextarea');
+    },
+
+    get confirmDeleteOverlay() {
+      if (!this._overlays.confirmDelete) {
+        this._enableOverlay = true;
+        Polymer.dom.flush();
+        this._overlays.confirmDelete = this.$$('#confirmDeleteOverlay');
+      }
+      return this._overlays.confirmDelete;
+    },
+
+    get confirmDiscardOverlay() {
+      if (!this._overlays.confirmDiscard) {
+        this._enableOverlay = true;
+        Polymer.dom.flush();
+        this._overlays.confirmDiscard = this.$$('#confirmDiscardOverlay');
+      }
+      return this._overlays.confirmDiscard;
+    },
+
+    _computeShowHideText(collapsed) {
+      return collapsed ? '◀' : '▼';
+    },
+
+    _calculateActionstoShow(showActions, isRobotComment) {
+      this._showHumanActions = showActions && !isRobotComment;
+      this._showRobotActions = showActions && isRobotComment;
+    },
+
+    _isRobotComment(comment) {
+      this.isRobotComment = !!comment.robot_id;
+    },
+
+    isOnParent() {
+      return this.side === 'PARENT';
+    },
+
+    _getIsAdmin() {
+      return this.$.restAPI.getIsAdmin();
+    },
+
+    /**
+     * @param {*=} opt_comment
+     */
+    save(opt_comment) {
+      let comment = opt_comment;
+      if (!comment) {
+        comment = this.comment;
+      }
+
+      this.set('comment.message', this._messageText);
+      this.editing = false;
+      this.disabled = true;
+
+      if (!this._messageText) {
+        return this._discardDraft();
+      }
+
+      this._xhrPromise = this._saveDraft(comment).then(response => {
+        this.disabled = false;
+        if (!response.ok) { return response; }
+
+        this._eraseDraftComment();
+        return this.$.restAPI.getResponseObject(response).then(obj => {
+          const resComment = obj;
+          resComment.__draft = true;
+          // Maintain the ephemeral draft ID for identification by other
+          // elements.
+          if (this.comment.__draftID) {
+            resComment.__draftID = this.comment.__draftID;
+          }
+          resComment.__commentSide = this.commentSide;
+          this.comment = resComment;
+          this._fireSave();
+          return obj;
+        });
+      }).catch(err => {
+        this.disabled = false;
+        throw err;
+      });
+
+      return this._xhrPromise;
+    },
+
+    _eraseDraftComment() {
+      // Prevents a race condition in which removing the draft comment occurs
+      // prior to it being saved.
+      this.cancelDebouncer('store');
+
+      this.$.storage.eraseDraftComment({
+        changeNum: this.changeNum,
+        patchNum: this._getPatchNum(),
+        path: this.comment.path,
+        line: this.comment.line,
+        range: this.comment.range,
+      });
+    },
+
+    _commentChanged(comment) {
+      this.editing = !!comment.__editing;
+      this.resolved = !comment.unresolved;
+      if (this.editing) { // It's a new draft/reply, notify.
+        this._fireUpdate();
+      }
+    },
+
+    /**
+     * @param {!Object=} opt_mixin
+     *
+     * @return {!Object}
+     */
+    _getEventPayload(opt_mixin) {
+      return Object.assign({}, opt_mixin, {
+        comment: this.comment,
+        patchNum: this.patchNum,
+      });
+    },
+
+    _fireSave() {
+      this.fire('comment-save', this._getEventPayload());
+    },
+
+    _fireUpdate() {
+      this.debounce('fire-update', () => {
+        this.fire('comment-update', this._getEventPayload());
+      });
+    },
+
+    _draftChanged(draft) {
+      this.$.container.classList.toggle('draft', draft);
+    },
+
+    _editingChanged(editing, previousValue) {
+      this.$.container.classList.toggle('editing', editing);
+      if (this.comment && this.comment.id) {
+        this.$$('.cancel').hidden = !editing;
+      }
+      if (this.comment) {
+        this.comment.__editing = this.editing;
+      }
+      if (editing != !!previousValue) {
+        // To prevent event firing on comment creation.
+        this._fireUpdate();
+      }
+      if (editing) {
+        this.async(() => {
+          Polymer.dom.flush();
+          this.textarea.putCursorAtEnd();
+        }, 1);
+      }
+    },
+
+    _computeDeleteButtonClass(isAdmin, draft) {
+      return isAdmin && !draft ? 'showDeleteButtons' : '';
+    },
+
+    _computeSaveDisabled(draft, comment, resolved) {
+      // If resolved state has changed and a msg exists, save should be enabled.
+      if (comment.unresolved === resolved && draft) {
+        return false;
+      }
+      return !draft || draft.trim() === '';
+    },
+
+    _handleSaveKey(e) {
+      if (!this._computeSaveDisabled(this._messageText, this.comment,
+          this.resolved)) {
+        e.preventDefault();
+        this._handleSave(e);
+      }
+    },
+
+    _handleEsc(e) {
+      if (!this._messageText.length) {
+        e.preventDefault();
+        this._handleCancel(e);
+      }
+    },
+
+    _handleToggleCollapsed() {
+      this.collapsed = !this.collapsed;
+    },
+
+    _toggleCollapseClass(collapsed) {
+      if (collapsed) {
+        this.$.container.classList.add('collapsed');
+      } else {
+        this.$.container.classList.remove('collapsed');
+      }
+    },
+
+    _commentMessageChanged(message) {
+      this._messageText = message || '';
+    },
+
+    _messageTextChanged(newValue, oldValue) {
+      if (!this.comment || (this.comment && this.comment.id)) {
+        return;
+      }
+
+      this.debounce('store', () => {
+        const message = this._messageText;
+        const commentLocation = {
+          changeNum: this.changeNum,
+          patchNum: this._getPatchNum(),
+          path: this.comment.path,
+          line: this.comment.line,
+          range: this.comment.range,
+        };
+
+        if ((!this._messageText || !this._messageText.length) && oldValue) {
+          // If the draft has been modified to be empty, then erase the storage
+          // entry.
+          this.$.storage.eraseDraftComment(commentLocation);
+        } else {
+          this.$.storage.setDraftComment(commentLocation, message);
+        }
+      }, STORAGE_DEBOUNCE_INTERVAL);
+    },
+
+    _handleAnchorTap(e) {
+      e.preventDefault();
+      if (!this.comment.line) {
+        return;
+      }
+      this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
+        bubbles: true,
+        composed: true,
+        detail: {
+          number: this.comment.line || FILE,
+          side: this.side,
+        },
+      }));
+    },
+
+    _handleEdit(e) {
+      e.preventDefault();
+      this._messageText = this.comment.message;
+      this.editing = true;
+      this.$.reporting.recordDraftInteraction();
+    },
+
+    _handleSave(e) {
+      e.preventDefault();
+
+      // Ignore saves started while already saving.
+      if (this.disabled) {
+        return;
+      }
+      const timingLabel = this.comment.id ?
+          REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
+      const timer = this.$.reporting.getTimer(timingLabel);
+      this.set('comment.__editing', false);
+      return this.save().then(() => { timer.end(); });
+    },
+
+    _handleCancel(e) {
+      e.preventDefault();
+
+      if (!this.comment.message ||
+          this.comment.message.trim().length === 0 ||
+          !this.comment.id) {
+        this._fireDiscard();
+        return;
+      }
+      this._messageText = this.comment.message;
+      this.editing = false;
+    },
+
+    _fireDiscard() {
+      this.cancelDebouncer('fire-update');
+      this.fire('comment-discard', this._getEventPayload());
+    },
+
+    _handleFix() {
+      this.dispatchEvent(new CustomEvent('create-fix-comment', {
+        bubbles: true,
+        composed: true,
+        detail: this._getEventPayload(),
+      }));
+    },
+
+    _handleDiscard(e) {
+      e.preventDefault();
+      this.$.reporting.recordDraftInteraction();
+
+      if (!this._messageText) {
+        this._discardDraft();
+        return;
+      }
+
+      this._openOverlay(this.confirmDiscardOverlay).then(() => {
+        this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
+            .resetFocus();
+      });
+    },
+
+    _handleConfirmDiscard(e) {
+      e.preventDefault();
+      const timer = this.$.reporting.getTimer(REPORT_DISCARD_DRAFT);
+      this._closeConfirmDiscardOverlay();
+      return this._discardDraft().then(() => { timer.end(); });
+    },
+
+    _discardDraft() {
+      if (!this.comment.__draft) {
+        throw Error('Cannot discard a non-draft comment.');
+      }
+      this.discarding = true;
+      this.editing = false;
+      this.disabled = true;
+      this._eraseDraftComment();
+
+      if (!this.comment.id) {
+        this.disabled = false;
+        this._fireDiscard();
+        return;
+      }
+
+      this._xhrPromise = this._deleteDraft(this.comment).then(response => {
+        this.disabled = false;
+        if (!response.ok) {
+          this.discarding = false;
+          return response;
+        }
+
+        this._fireDiscard();
+      }).catch(err => {
+        this.disabled = false;
+        throw err;
+      });
+
+      return this._xhrPromise;
+    },
+
+    _closeConfirmDiscardOverlay() {
+      this._closeOverlay(this.confirmDiscardOverlay);
+    },
+
+    _getSavingMessage(numPending) {
+      if (numPending === 0) {
+        return SAVED_MESSAGE;
+      }
+      return [
+        SAVING_MESSAGE,
+        numPending,
+        numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
+      ].join(' ');
+    },
+
+    _showStartRequest() {
+      const numPending = ++this._numPendingDraftRequests.number;
+      this._updateRequestToast(numPending);
+    },
+
+    _showEndRequest() {
+      const numPending = --this._numPendingDraftRequests.number;
+      this._updateRequestToast(numPending);
+    },
+
+    _handleFailedDraftRequest() {
+      this._numPendingDraftRequests.number--;
+
+      // Cancel the debouncer so that error toasts from the error-manager will
+      // not be overridden.
+      this.cancelDebouncer('draft-toast');
+    },
+
+    _updateRequestToast(numPending) {
+      const message = this._getSavingMessage(numPending);
+      this.debounce('draft-toast', () => {
+        // Note: the event is fired on the body rather than this element because
+        // this element may not be attached by the time this executes, in which
+        // case the event would not bubble.
+        document.body.dispatchEvent(new CustomEvent(
+            'show-alert', {detail: {message}, bubbles: true, composed: true}));
+      }, TOAST_DEBOUNCE_INTERVAL);
+    },
+
+    _saveDraft(draft) {
+      this._showStartRequest();
+      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
+          .then(result => {
+            if (result.ok) {
+              this._showEndRequest();
+            } else {
+              this._handleFailedDraftRequest();
+            }
+            return result;
+          });
+    },
+
+    _deleteDraft(draft) {
+      this._showStartRequest();
+      return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
+          draft).then(result => {
+            if (result.ok) {
+              this._showEndRequest();
+            } else {
+              this._handleFailedDraftRequest();
+            }
+            return result;
+          });
+    },
+
+    _getPatchNum() {
+      return this.isOnParent() ? 'PARENT' : this.patchNum;
+    },
+
+    _loadLocalDraft(changeNum, patchNum, comment) {
+      // Only apply local drafts to comments that haven't been saved
+      // remotely, and haven't been given a default message already.
+      //
+      // Don't get local draft if there is another comment that is currently
+      // in an editing state.
+      if (!comment || comment.id || comment.message || comment.__otherEditing) {
+        delete comment.__otherEditing;
+        return;
+      }
+
+      const draft = this.$.storage.getDraftComment({
+        changeNum,
+        patchNum: this._getPatchNum(),
+        path: comment.path,
+        line: comment.line,
+        range: comment.range,
+      });
+
+      if (draft) {
+        this.set('comment.message', draft.message);
+      }
+    },
+
+    _handleToggleResolved() {
+      this.$.reporting.recordDraftInteraction();
+      this.resolved = !this.resolved;
+      // Modify payload instead of this.comment, as this.comment is passed from
+      // the parent by ref.
+      const payload = this._getEventPayload();
+      payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
+      this.fire('comment-update', payload);
+      if (!this.editing) {
+        // Save the resolved state immediately.
+        this.save(payload.comment);
+      }
+    },
+
+    _handleCommentDelete() {
+      this._openOverlay(this.confirmDeleteOverlay);
+    },
+
+    _handleCancelDeleteComment() {
+      this._closeOverlay(this.confirmDeleteOverlay);
+    },
+
+    _openOverlay(overlay) {
+      Polymer.dom(Gerrit.getRootElement()).appendChild(overlay);
+      return overlay.open();
+    },
+
+    _closeOverlay(overlay) {
+      Polymer.dom(Gerrit.getRootElement()).removeChild(overlay);
+      overlay.close();
+    },
+
+    _handleConfirmDeleteComment() {
+      const dialog =
+          this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
+      this.$.restAPI.deleteComment(
+          this.changeNum, this.patchNum, this.comment.id, dialog.message)
+          .then(newComment => {
+            this._handleCancelDeleteComment();
+            this.comment = newComment;
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
new file mode 100644
index 0000000..c829343
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
@@ -0,0 +1,849 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-comment</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/bower_components/page/page.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-comment.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-comment></gr-comment>
+  </template>
+</test-fixture>
+
+<test-fixture id="draft">
+  <template>
+    <gr-comment draft="true"></gr-comment>
+  </template>
+</test-fixture>
+
+<script>
+
+  function isVisible(el) {
+    assert.ok(el);
+    return getComputedStyle(el).getPropertyValue('display') !== 'none';
+  }
+
+  suite('gr-comment tests', () => {
+    let element;
+    let sandbox;
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+      });
+      element = fixture('basic');
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+      };
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('collapsible comments', () => {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+
+      // The header middle content is only visible when comments are collapsed.
+      // It shows the message in a condensed way, and limits to a single line.
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      // When the header row is clicked, the comment should expand
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
+    });
+
+    test('clicking on date link fires event', () => {
+      element.side = 'PARENT';
+      const stub = sinon.stub();
+      element.addEventListener('comment-anchor-tap', stub);
+      const dateEl = element.$$('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail,
+          {side: element.side, number: element.comment.line});
+    });
+
+    test('message is not retrieved from storage when other edits', done => {
+      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+        __otherEditing: true,
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isFalse(storageStub.called);
+        done();
+      });
+    });
+
+    test('message is retrieved from storage when no other edits', done => {
+      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isTrue(storageStub.called);
+        done();
+      });
+    });
+
+    test('_getPatchNum', () => {
+      element.side = 'PARENT';
+      element.patchNum = 1;
+      assert.equal(element._getPatchNum(), 'PARENT');
+      element.side = 'REVISION';
+      assert.equal(element._getPatchNum(), 1);
+    });
+
+    test('comment expand and collapse', () => {
+      element.collapsed = true;
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      element.collapsed = false;
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is is not visible');
+    });
+
+    suite('while editing', () => {
+      setup(() => {
+        element.editing = true;
+        element._messageText = 'test';
+        sandbox.stub(element, '_handleCancel');
+        sandbox.stub(element, '_handleSave');
+        flushAsynchronousOperations();
+      });
+
+      suite('when text is empty', () => {
+        setup(() => {
+          element._messageText = '';
+          element.comment = {};
+        });
+
+        test('esc closes comment when text is empty', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 27); // esc
+          assert.isTrue(element._handleCancel.called);
+        });
+
+        test('ctrl+enter does not save', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 13, 'ctrl'); // ctrl + enter
+          assert.isFalse(element._handleSave.called);
+        });
+
+        test('meta+enter does not save', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 13, 'meta'); // meta + enter
+          assert.isFalse(element._handleSave.called);
+        });
+
+        test('ctrl+s does not save', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 83, 'ctrl'); // ctrl + s
+          assert.isFalse(element._handleSave.called);
+        });
+      });
+
+      test('esc does not close comment that has content', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 27); // esc
+        assert.isFalse(element._handleCancel.called);
+      });
+
+      test('ctrl+enter saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 13, 'ctrl'); // ctrl + enter
+        assert.isTrue(element._handleSave.called);
+      });
+
+      test('meta+enter saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 13, 'meta'); // meta + enter
+        assert.isTrue(element._handleSave.called);
+      });
+
+      test('ctrl+s saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 83, 'ctrl'); // ctrl + s
+        assert.isTrue(element._handleSave.called);
+      });
+    });
+    test('delete comment button for non-admins is hidden', () => {
+      element._isAdmin = false;
+      assert.isFalse(element.$$('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment button for admins with draft is hidden', () => {
+      element._isAdmin = false;
+      element.draft = true;
+      assert.isFalse(element.$$('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment', done => {
+      sandbox.stub(
+          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
+      sandbox.spy(element.confirmDeleteOverlay, 'open');
+      element.changeNum = 42;
+      element.patchNum = 0xDEADBEEF;
+      element._isAdmin = true;
+      assert.isTrue(element.$$('.action.delete')
+          .classList.contains('showDeleteButtons'));
+      MockInteractions.tap(element.$$('.action.delete'));
+      flush(() => {
+        element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
+          const dialog =
+              this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
+          dialog.message = 'removal reason';
+          element._handleConfirmDeleteComment();
+          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
+              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
+          done();
+        });
+      });
+    });
+
+    suite('draft update reporting', () => {
+      let endStub;
+      let getTimerStub;
+      let mockEvent;
+
+      setup(() => {
+        mockEvent = {preventDefault() {}};
+        sandbox.stub(element, 'save')
+            .returns(Promise.resolve({}));
+        sandbox.stub(element, '_discardDraft')
+            .returns(Promise.resolve({}));
+        endStub = sinon.stub();
+        getTimerStub = sandbox.stub(element.$.reporting, 'getTimer')
+            .returns({end: endStub});
+      });
+
+      test('create', () => {
+        element.comment = {};
+        return element._handleSave(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
+        });
+      });
+
+      test('update', () => {
+        element.comment = {id: 'abc_123'};
+        return element._handleSave(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
+        });
+      });
+
+      test('discard', () => {
+        element.comment = {id: 'abc_123'};
+        sandbox.stub(element, '_closeConfirmDiscardOverlay');
+        return element._handleConfirmDiscard(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
+        });
+      });
+    });
+
+    test('edit reports interaction', () => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      MockInteractions.tap(element.$$('.edit'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('discard reports interaction', () => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      element.draft = true;
+      MockInteractions.tap(element.$$('.discard'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+  });
+
+  suite('gr-comment draft tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+        saveDiffDraft() {
+          return Promise.resolve({
+            ok: true,
+            text() {
+              return Promise.resolve(
+                  ')]}\'\n{' +
+                  '"id": "baf0414d_40572e03",' +
+                  '"path": "/path/to/file",' +
+                  '"line": 5,' +
+                  '"updated": "2015-12-08 21:52:36.177000000",' +
+                  '"message": "saved!"' +
+                '}'
+              );
+            },
+          });
+        },
+        removeChangeReviewer() {
+          return Promise.resolve({ok: true});
+        },
+      });
+      stub('gr-storage', {
+        getDraftComment() { return null; },
+      });
+      element = fixture('draft');
+      element.changeNum = 42;
+      element.patchNum = 1;
+      element.editing = false;
+      element.comment = {
+        __commentSide: 'right',
+        __draft: true,
+        __draftID: 'temp_draft_id',
+        path: '/path/to/file',
+        line: 5,
+      };
+      element.commentSide = 'right';
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('button visibility states', () => {
+      element.showActions = false;
+      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+
+      element.showActions = true;
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+
+      element.draft = true;
+      assert.isTrue(isVisible(element.$$('.edit')), 'edit is visible');
+      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
+      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
+      assert.isTrue(isVisible(element.$$('.resolve')), 'resolve is visible');
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+
+      element.editing = true;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.$$('.discard')), 'discard not visible');
+      assert.isTrue(isVisible(element.$$('.save')), 'save is visible');
+      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
+      assert.isTrue(isVisible(element.$$('.resolve')), 'resolve is visible');
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+
+      element.draft = false;
+      element.editing = false;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.$$('.discard')),
+          'discard is not visible');
+      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+
+      element.comment.id = 'foo';
+      element.draft = true;
+      element.editing = true;
+      flushAsynchronousOperations();
+      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+
+      element.isRobotComment = true;
+      element.draft = true;
+      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
+
+      // It is not expected to see Robot comment drafts, but if they appear,
+      // they will behave the same as non-drafts.
+      element.draft = false;
+      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
+
+      // A robot comment with run ID should display plain text.
+      element.set(['comment', 'robot_run_id'], 'text');
+      element.editing = false;
+      element.collapsed = false;
+      flushAsynchronousOperations();
+      assert.isNotOk(element.$$('.robotRun.link'));
+      assert.notEqual(getComputedStyle(element.$$('.robotRun.text')).display,
+          'none');
+
+      // A robot comment with run ID and url should display a link.
+      element.set(['comment', 'url'], '/path/to/run');
+      flushAsynchronousOperations();
+      assert.notEqual(getComputedStyle(element.$$('.robotRun.link')).display,
+          'none');
+      assert.equal(getComputedStyle(element.$$('.robotRun.text')).display,
+          'none');
+    });
+
+    test('collapsible drafts', () => {
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is is not visible');
+
+      // When the edit button is pressed, should still see the actions
+      // and also textarea
+      MockInteractions.tap(element.$$('.edit'));
+      flushAsynchronousOperations();
+      assert.isFalse(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
+
+      // When toggle again, everything should be hidden except for textarea
+      // and header middle content should be visible
+      MockInteractions.tap(element.$.header);
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
+          'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      // When toggle again, textarea should remain open in the state it was
+      // before
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
+    });
+
+    test('draft creation/cancellation', done => {
+      assert.isFalse(element.editing);
+      MockInteractions.tap(element.$$('.edit'));
+      assert.isTrue(element.editing);
+
+      element._messageText = '';
+      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+
+      // Save should be disabled on an empty message.
+      let disabled = element.$$('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+      element._messageText = '     ';
+      disabled = element.$$('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+
+      const updateStub = sinon.stub();
+      element.addEventListener('comment-update', updateStub);
+
+      let numDiscardEvents = 0;
+      element.addEventListener('comment-discard', e => {
+        numDiscardEvents++;
+        assert.isFalse(eraseMessageDraftSpy.called);
+        if (numDiscardEvents === 2) {
+          assert.isFalse(updateStub.called);
+          done();
+        }
+      });
+      MockInteractions.tap(element.$$('.cancel'));
+      element.flushDebouncer('fire-update');
+      element._messageText = '';
+      flushAsynchronousOperations();
+      MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
+    });
+
+    test('draft discard removes message from storage', done => {
+      element._messageText = '';
+      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+      sandbox.stub(element, '_closeConfirmDiscardOverlay');
+
+      element.addEventListener('comment-discard', e => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+      element._handleConfirmDiscard({preventDefault: sinon.stub()});
+    });
+
+    test('storage is cleared only after save success', () => {
+      element._messageText = 'test';
+      const eraseStub = sandbox.stub(element, '_eraseDraftComment');
+      sandbox.stub(element.$.restAPI, 'getResponseObject')
+          .returns(Promise.resolve({}));
+
+      sandbox.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
+
+      const savePromise = element.save();
+      assert.isFalse(eraseStub.called);
+      return savePromise.then(() => {
+        assert.isFalse(eraseStub.called);
+
+        element._saveDraft.restore();
+        sandbox.stub(element, '_saveDraft')
+            .returns(Promise.resolve({ok: true}));
+        return element.save().then(() => {
+          assert.isTrue(eraseStub.called);
+        });
+      });
+    });
+
+    test('_computeSaveDisabled', () => {
+      const comment = {unresolved: true};
+      const msgComment = {message: 'test', unresolved: true};
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      assert.equal(element._computeSaveDisabled('test', comment, false), false);
+      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
+      assert.equal(
+          element._computeSaveDisabled('test', msgComment, false), false);
+      assert.equal(
+          element._computeSaveDisabled('test2', msgComment, false), false);
+      assert.equal(element._computeSaveDisabled('test', comment, true), false);
+      assert.equal(element._computeSaveDisabled('', comment, true), true);
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+    });
+
+    suite('confirm discard', () => {
+      let discardStub;
+      let overlayStub;
+      let mockEvent;
+
+      setup(() => {
+        discardStub = sandbox.stub(element, '_discardDraft');
+        overlayStub = sandbox.stub(element, '_openOverlay')
+            .returns(Promise.resolve());
+        mockEvent = {preventDefault: sinon.stub()};
+      });
+
+      test('confirms discard of comments with message text', () => {
+        element._messageText = 'test';
+        element._handleDiscard(mockEvent);
+        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
+        assert.isFalse(discardStub.called);
+      });
+
+      test('no confirmation for comments without message text', () => {
+        element._messageText = '';
+        element._handleDiscard(mockEvent);
+        assert.isFalse(overlayStub.called);
+        assert.isTrue(discardStub.calledOnce);
+      });
+    });
+
+    test('ctrl+s saves comment', done => {
+      const stub = sinon.stub(element, 'save', () => {
+        assert.isTrue(stub.called);
+        stub.restore();
+        done();
+        return Promise.resolve();
+      });
+      element._messageText = 'is that the horse from horsing around??';
+      element.editing = true;
+      flushAsynchronousOperations();
+      MockInteractions.pressAndReleaseKeyOn(
+          element.textarea.$.textarea.textarea,
+          83, 'ctrl'); // 'ctrl + s'
+    });
+
+    test('draft saving/editing', done => {
+      const fireStub = sinon.stub(element, 'fire');
+      const cancelDebounce = sandbox.stub(element, 'cancelDebouncer');
+
+      element.draft = true;
+      MockInteractions.tap(element.$$('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+      assert(fireStub.calledWith('comment-update'),
+          'comment-update should be sent');
+      assert.isTrue(fireStub.calledOnce);
+
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+      assert.isTrue(fireStub.calledOnce,
+          'No events should fire for text editing');
+
+      MockInteractions.tap(element.$$('.save'));
+
+      assert.isTrue(element.disabled,
+          'Element should be disabled when creating draft.');
+
+      element._xhrPromise.then(draft => {
+        assert(fireStub.calledWith('comment-save'),
+            'comment-save should be sent');
+        assert(cancelDebounce.calledWith('store'));
+
+        assert.deepEqual(fireStub.lastCall.args[1], {
+          comment: {
+            __commentSide: 'right',
+            __draft: true,
+            __draftID: 'temp_draft_id',
+            id: 'baf0414d_40572e03',
+            line: 5,
+            message: 'saved!',
+            path: '/path/to/file',
+            updated: '2015-12-08 21:52:36.177000000',
+          },
+          patchNum: 1,
+        });
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done creating draft.');
+        assert.equal(draft.message, 'saved!');
+        assert.isFalse(element.editing);
+      }).then(() => {
+        MockInteractions.tap(element.$$('.edit'));
+        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
+            'a world where humans are killed on sight.';
+        MockInteractions.tap(element.$$('.save'));
+        assert.isTrue(element.disabled,
+            'Element should be disabled when updating draft.');
+
+        element._xhrPromise.then(draft => {
+          assert.isFalse(element.disabled,
+              'Element should be enabled when done updating draft.');
+          assert.equal(draft.message, 'saved!');
+          assert.isFalse(element.editing);
+          fireStub.restore();
+          done();
+        });
+      });
+    });
+
+    test('draft prevent save when disabled', () => {
+      const saveStub = sandbox.stub(element, 'save').returns(Promise.resolve());
+      element.showActions = true;
+      element.draft = true;
+      MockInteractions.tap(element.$.header);
+      MockInteractions.tap(element.$$('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+
+      element.disabled = true;
+      MockInteractions.tap(element.$$('.save'));
+      assert.isFalse(saveStub.called);
+
+      element.disabled = false;
+      MockInteractions.tap(element.$$('.save'));
+      assert.isTrue(saveStub.calledOnce);
+    });
+
+    test('proper event fires on resolve, comment is not saved', done => {
+      const save = sandbox.stub(element, 'save');
+      element.addEventListener('comment-update', e => {
+        assert.isTrue(e.detail.comment.unresolved);
+        assert.isFalse(save.called);
+        done();
+      });
+      MockInteractions.tap(element.$$('.resolve input'));
+    });
+
+    test('resolved comment state indicated by checkbox', () => {
+      sandbox.stub(element, 'save');
+      element.comment = {unresolved: false};
+      assert.isTrue(element.$$('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.$$('.resolve input').checked);
+    });
+
+    test('resolved checkbox saves with tap when !editing', () => {
+      element.editing = false;
+      const save = sandbox.stub(element, 'save');
+
+      element.comment = {unresolved: false};
+      assert.isTrue(element.$$('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.$$('.resolve input').checked);
+      assert.isFalse(save.called);
+      MockInteractions.tap(element.$.resolvedCheckbox);
+      assert.isTrue(element.$$('.resolve input').checked);
+      assert.isTrue(save.called);
+    });
+
+    suite('draft saving messages', () => {
+      test('_getSavingMessage', () => {
+        assert.equal(element._getSavingMessage(0), 'All changes saved');
+        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
+        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
+        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
+      });
+
+      test('_show{Start,End}Request', () => {
+        const updateStub = sandbox.stub(element, '_updateRequestToast');
+        element._numPendingDraftRequests.number = 1;
+
+        element._showStartRequest();
+        assert.isTrue(updateStub.calledOnce);
+        assert.equal(updateStub.lastCall.args[0], 2);
+        assert.equal(element._numPendingDraftRequests.number, 2);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledTwice);
+        assert.equal(updateStub.lastCall.args[0], 1);
+        assert.equal(element._numPendingDraftRequests.number, 1);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledThrice);
+        assert.equal(updateStub.lastCall.args[0], 0);
+        assert.equal(element._numPendingDraftRequests.number, 0);
+      });
+    });
+
+    test('cancelling an unsaved draft discards, persists in storage', () => {
+      const discardSpy = sandbox.spy(element, '_fireDiscard');
+      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
+      const eraseStub = sandbox.stub(element.$.storage, 'eraseDraftComment');
+      element._messageText = 'test text';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(storeStub.called);
+      assert.equal(storeStub.lastCall.args[1], 'test text');
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+      assert.isFalse(eraseStub.called);
+    });
+
+    test('cancelling edit on a saved draft does not store', () => {
+      element.comment.id = 'foo';
+      const discardSpy = sandbox.spy(element, '_fireDiscard');
+      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
+      element._messageText = 'test text';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isFalse(storeStub.called);
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+    });
+
+    test('deleting text from saved draft and saving deletes the draft', () => {
+      element.comment = {id: 'foo', message: 'test'};
+      element._messageText = '';
+      const discardStub = sandbox.stub(element, '_discardDraft');
+
+      element.save();
+      assert.isTrue(discardStub.called);
+    });
+
+    test('_handleFix fires create-fix event', done => {
+      element.addEventListener('create-fix-comment', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.isRobotComment = true;
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.$$('.fix'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
new file mode 100644
index 0000000..bf20429
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
@@ -0,0 +1,72 @@
+<!--
+@license
+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.
+-->
+
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-confirm-delete-comment-dialog">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        padding: 0;
+        width: 73ch; /* Add a char to account for the border. */
+
+        --iron-autogrow-textarea {
+          border: 1px solid var(--border-color);
+          box-sizing: border-box;
+          font-family: var(--monospace-font-family);
+        }
+      }
+    </style>
+    <gr-dialog
+        confirm-label="Delete"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header" slot="header">Delete Comment</div>
+      <div class="main" slot="main">
+        <label for="messageInput">Enter comment delete reason</label>
+        <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            placeholder="<Insert reasoning here>"
+            bind-value="{{message}}"></iron-autogrow-textarea>
+      </div>
+    </gr-dialog>
+  </template>
+  <script src="gr-confirm-delete-comment-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
new file mode 100644
index 0000000..4ac059d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * 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.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-delete-comment-dialog',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      message: String,
+    },
+
+    resetFocus() {
+      this.$.messageInput.textarea.focus();
+    },
+
+    _handleConfirmTap(e) {
+      e.preventDefault();
+      this.fire('confirm', {reason: this.message}, {bubbles: false});
+    },
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
deleted file mode 100644
index 92a7d5d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
+++ /dev/null
@@ -1,71 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-confirm-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        color: var(--primary-text-color);
-        display: block;
-        max-height: 90vh;
-      }
-      .container {
-        display: flex;
-        flex-direction: column;
-        max-height: 90vh;
-      }
-      header {
-        border-bottom: 1px solid var(--border-color);
-        flex-shrink: 0;
-        font-family: var(--font-family-bold);
-      }
-      main {
-        display: flex;
-        flex-shrink: 1;
-        width: 100%;
-      }
-      header,
-      main,
-      footer {
-        padding: .5em 1.5em;
-      }
-      gr-button {
-        margin-left: 1em;
-      }
-      footer {
-        display: flex;
-        flex-shrink: 0;
-        justify-content: flex-end;
-      }
-    </style>
-    <div class="container" on-keydown="_handleKeydown">
-      <header><slot name="header"></slot></header>
-      <main><slot name="main"></slot></main>
-      <footer>
-        <gr-button link on-tap="_handleCancelTap">[[cancelLabel]]</gr-button>
-        <gr-button id="confirm" link primary on-tap="_handleConfirm" disabled="[[disabled]]">
-          [[confirmLabel]]
-        </gr-button>
-      </footer>
-    </div>
-  </template>
-  <script src="gr-confirm-dialog.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
deleted file mode 100644
index b8d137b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-confirm-dialog',
-
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
-
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    properties: {
-      confirmLabel: {
-        type: String,
-        value: 'Confirm',
-      },
-      cancelLabel: {
-        type: String,
-        value: 'Cancel',
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-      },
-      confirmOnEnter: {
-        type: Boolean,
-        value: false,
-      },
-    },
-
-    hostAttributes: {
-      role: 'dialog',
-    },
-
-    _handleConfirm(e) {
-      if (this.disabled) { return; }
-
-      e.preventDefault();
-      this.fire('confirm', null, {bubbles: false});
-    },
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      this.fire('cancel', null, {bubbles: false});
-    },
-
-    _handleKeydown(e) {
-      if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
-    },
-
-    resetFocus() {
-      this.$.confirm.focus();
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
deleted file mode 100644
index 8300dd6..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
+++ /dev/null
@@ -1,77 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-confirm-dialog</title>
-
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-dialog.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-dialog></gr-confirm-dialog>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-confirm-dialog tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('events', done => {
-      let numEvents = 0;
-      function handler() { if (++numEvents == 2) { done(); } }
-
-      element.addEventListener('confirm', handler);
-      element.addEventListener('cancel', handler);
-
-      MockInteractions.tap(element.$$('gr-button[primary]'));
-      MockInteractions.tap(element.$$('gr-button:not([primary])'));
-    });
-
-    test('confirmOnEnter', () => {
-      element.confirmOnEnter = false;
-      const handleConfirmStub = sandbox.stub(element, '_handleConfirm');
-      const handleKeydownSpy = sandbox.spy(element, '_handleKeydown');
-      MockInteractions.pressAndReleaseKeyOn(element.$$('main'),
-          13, null, 'enter');
-      flushAsynchronousOperations();
-
-      assert.isTrue(handleKeydownSpy.called);
-      assert.isFalse(handleConfirmStub.called);
-
-      element.confirmOnEnter = true;
-      MockInteractions.pressAndReleaseKeyOn(element.$$('main'),
-          13, null, 'enter');
-      flushAsynchronousOperations();
-
-      assert.isTrue(handleConfirmStub.called);
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
index d1d9b33..d63a4ad 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
@@ -15,12 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-copy-clipboard">
   <template>
@@ -30,44 +29,49 @@
         display: flex;
         flex-wrap: wrap;
       }
-      .text label {
-        flex: 0 0 100%;
-      }
       .copyText {
         flex-grow: 1;
         margin-right: .3em;
       }
-      .hideInput,
-      .hideLabel label {
+      .hideInput {
         display: none;
       }
       input {
         font-family: var(--monospace-font-family);
         font-size: inherit;
+        @apply --text-container-style;
+        width: 100%;
       }
       #icon {
         height: 1.2em;
         width: 1.2em;
       }
     </style>
-    <div class$="text [[_computeLabelClass(hideLabel)]]">
-        <label>[[title]]</label>
-        <input id="input" is="iron-input"
-            class$="copyText [[_computeInputClass(hideInput)]]"
+    <div class="text">
+      <iron-input
+          class="copyText"
+          type="text"
+          bind-value="[[text]]"
+          on-tap="_handleInputTap"
+          readonly>
+        <input
+            id="input"
+            is="iron-input"
+            class$="[[_computeInputClass(hideInput)]]"
             type="text"
             bind-value="[[text]]"
             on-tap="_handleInputTap"
             readonly>
-        <gr-button id="button"
-            link
-            has-tooltip="[[hasTooltip]]"
-            class="copyToClipboard"
-            title="[[buttonTitle]]"
-            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>
+      </iron-input>
+      <gr-button id="button"
+          link
+          has-tooltip="[[hasTooltip]]"
+          class="copyToClipboard"
+          title="[[buttonTitle]]"
+          on-tap="_copyToClipboard">
+        <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+      </gr-button>
+    </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..550f1df 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
@@ -21,10 +21,10 @@
 
   Polymer({
     is: 'gr-copy-clipboard',
+    _legacyUndefinedCheck: true,
 
     properties: {
       text: String,
-      title: String,
       buttonTitle: String,
       hasTooltip: {
         type: Boolean,
@@ -34,10 +34,6 @@
         type: Boolean,
         value: false,
       },
-      hideLabel: {
-        type: Boolean,
-        value: false,
-      },
     },
 
     focusOnCopy() {
@@ -48,10 +44,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..c092b7c 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-copy-clipboard</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-copy-clipboard.html">
 
@@ -38,12 +40,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 +77,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-count-string-formatter/gr-count-string-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
index e4d896b..d061ac2 100644
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-count-string-formatter</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-count-string-formatter.html"/>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
index d619b18..94d7aaa 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-cursor-manager">
   <template></template>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index f750cd2..2c38c2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -24,6 +24,7 @@
 
   Polymer({
     is: 'gr-cursor-manager',
+    _legacyUndefinedCheck: true,
 
     properties: {
       stops: {
@@ -89,8 +90,21 @@
       this.unsetCursor();
     },
 
-    next(opt_condition, opt_getTargetHeight) {
-      this._moveCursor(1, opt_condition, opt_getTargetHeight);
+    /**
+     * Move the cursor forward. Clipped to the ends of the stop list.
+     * @param {!Function=} opt_condition Optional stop condition. If a condition
+     *    is passed the cursor will continue to move in the specified direction
+     *    until the condition is met.
+     * @param {!Function=} opt_getTargetHeight Optional function to calculate the
+     *    height of the target's 'section'. The height of the target itself is
+     *    sometimes different, used by the diff cursor.
+     * @param {boolean=} opt_clipToTop When none of the next indices match, move
+     *     back to first instead of to last.
+     * @private
+     */
+
+    next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
+      this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
     },
 
     previous(opt_condition) {
@@ -144,8 +158,8 @@
     },
 
     /**
-     * Move the cursor forward or backward by delta. Noop if moving past either
-     * end of the stop list.
+     * Move the cursor forward or backward by delta. Clipped to the beginning or
+     * end of stop list.
      * @param {number} delta either -1 or 1.
      * @param {!Function=} opt_condition Optional stop condition. If a condition
      *    is passed the cursor will continue to move in the specified direction
@@ -153,9 +167,11 @@
      * @param {!Function=} opt_getTargetHeight Optional function to calculate the
      *    height of the target's 'section'. The height of the target itself is
      *    sometimes different, used by the diff cursor.
+     * @param {boolean=} opt_clipToTop When none of the next indices match, move
+     *     back to first instead of to last.
      * @private
      */
-    _moveCursor(delta, opt_condition, opt_getTargetHeight) {
+    _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop) {
       if (!this.stops.length) {
         this.unsetCursor();
         return;
@@ -163,7 +179,7 @@
 
       this._unDecorateTarget();
 
-      const newIndex = this._getNextindex(delta, opt_condition);
+      const newIndex = this._getNextindex(delta, opt_condition, opt_clipToTop);
 
       let newTarget = null;
       if (newIndex !== -1) {
@@ -202,10 +218,12 @@
      * Get the next stop index indicated by the delta direction.
      * @param {number} delta either -1 or 1.
      * @param {!Function=} opt_condition Optional stop condition.
+     * @param {boolean=} opt_clipToTop When none of the next indices match, move
+     *     back to first instead of to last.
      * @return {number} the new index.
      * @private
      */
-    _getNextindex(delta, opt_condition) {
+    _getNextindex(delta, opt_condition, opt_clipToTop) {
       if (!this.stops.length || this.index === -1) {
         return -1;
       }
@@ -221,10 +239,10 @@
 
       // If we failed to satisfy the condition:
       if (opt_condition && !opt_condition(this.stops[newIndex])) {
-        if (delta > 0) {
-          return this.stops.length - 1;
-        } else if (delta < 0) {
+        if (delta < 0 || opt_clipToTop) {
           return 0;
+        } else if (delta > 0) {
+          return this.stops.length - 1;
         }
         return this.index;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
index adbe618..0793ccd 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-cursor-manager</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-cursor-manager.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
index 1090fea..f3ea177 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
@@ -15,12 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
-<script src="../../../bower_components/moment/moment.js"></script>
 <script src="../../../scripts/util.js"></script>
 
 <dom-module id="gr-date-formatter">
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 3417a0d..4d7f2bb 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -33,6 +33,7 @@
 
   Polymer({
     is: 'gr-date-formatter',
+    _legacyUndefinedCheck: true,
 
     properties: {
       dateStr: {
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
index ad4d0da..a0bf207 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-date-formatter</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
new file mode 100644
index 0000000..0321e58
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
@@ -0,0 +1,76 @@
+<!--
+@license
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-button/gr-button.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-dialog">
+  <template>
+    <style include="shared-styles">
+      :host {
+        color: var(--primary-text-color);
+        display: block;
+        max-height: 90vh;
+      }
+      .container {
+        display: flex;
+        flex-direction: column;
+        max-height: 90vh;
+      }
+      header {
+        border-bottom: 1px solid var(--border-color);
+        flex-shrink: 0;
+        font-weight: var(--font-weight-bold);
+      }
+      main {
+        display: flex;
+        flex-shrink: 1;
+        width: 100%;
+      }
+      header,
+      main,
+      footer {
+        padding: .5em 1.5em;
+      }
+      gr-button {
+        margin-left: 1em;
+      }
+      footer {
+        display: flex;
+        flex-shrink: 0;
+        justify-content: flex-end;
+      }
+      .hidden {
+        display: none;
+      }
+    </style>
+    <div class="container" on-keydown="_handleKeydown">
+      <header><slot name="header"></slot></header>
+      <main><slot name="main"></slot></main>
+      <footer>
+        <gr-button id="cancel" class$="[[_computeCancelClass(cancelLabel)]]" link on-tap="_handleCancelTap">
+          [[cancelLabel]]
+        </gr-button>
+        <gr-button id="confirm" link primary on-tap="_handleConfirm" disabled="[[disabled]]">
+          [[confirmLabel]]
+        </gr-button>
+      </footer>
+    </div>
+  </template>
+  <script src="gr-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
new file mode 100644
index 0000000..b8b2af4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-dialog',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      confirmLabel: {
+        type: String,
+        value: 'Confirm',
+      },
+      // Supplying an empty cancel label will hide the button completely.
+      cancelLabel: {
+        type: String,
+        value: 'Cancel',
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+      },
+      confirmOnEnter: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    hostAttributes: {
+      role: 'dialog',
+    },
+
+    _handleConfirm(e) {
+      if (this.disabled) { return; }
+
+      e.preventDefault();
+      this.fire('confirm', null, {bubbles: false});
+    },
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+
+    _handleKeydown(e) {
+      if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
+    },
+
+    resetFocus() {
+      this.$.confirm.focus();
+    },
+
+    _computeCancelClass(cancelLabel) {
+      return cancelLabel.length ? '' : 'hidden';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
new file mode 100644
index 0000000..1456e77
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-dialog</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-dialog></gr-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('events', done => {
+      let numEvents = 0;
+      function handler() { if (++numEvents == 2) { done(); } }
+
+      element.addEventListener('confirm', handler);
+      element.addEventListener('cancel', handler);
+
+      MockInteractions.tap(element.$$('gr-button[primary]'));
+      MockInteractions.tap(element.$$('gr-button:not([primary])'));
+    });
+
+    test('confirmOnEnter', () => {
+      element.confirmOnEnter = false;
+      const handleConfirmStub = sandbox.stub(element, '_handleConfirm');
+      const handleKeydownSpy = sandbox.spy(element, '_handleKeydown');
+      MockInteractions.pressAndReleaseKeyOn(element.$$('main'),
+          13, null, 'enter');
+      flushAsynchronousOperations();
+
+      assert.isTrue(handleKeydownSpy.called);
+      assert.isFalse(handleConfirmStub.called);
+
+      element.confirmOnEnter = true;
+      MockInteractions.pressAndReleaseKeyOn(element.$$('main'),
+          13, null, 'enter');
+      flushAsynchronousOperations();
+
+      assert.isTrue(handleConfirmStub.called);
+    });
+
+    test('resetFocus', () => {
+      const focusStub = sandbox.stub(element.$.confirm, 'focus');
+      element.resetFocus();
+      assert.isTrue(focusStub.calledOnce);
+    });
+
+    test('empty cancel label hides cancel btn', () => {
+      assert.isFalse(isHidden(element.$.cancel));
+      element.cancelLabel = '';
+      flushAsynchronousOperations();
+
+      assert.isTrue(isHidden(element.$.cancel));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html
new file mode 100644
index 0000000..9d85d44
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html
@@ -0,0 +1,187 @@
+<!--
+@license
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../gr-button/gr-button.html">
+<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-select/gr-select.html">
+
+<dom-module id="gr-diff-preferences">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles"></style>
+    <div id="diffPreferences" class="gr-form-styles">
+      <section>
+        <span class="title">Context</span>
+        <span class="value">
+          <gr-select
+              id="contextSelect"
+              bind-value="{{diffPrefs.context}}">
+            <select
+                on-keypress="_handleDiffPrefsChanged"
+                on-change="_handleDiffPrefsChanged">
+              <option value="3">3 lines</option>
+              <option value="10">10 lines</option>
+              <option value="25">25 lines</option>
+              <option value="50">50 lines</option>
+              <option value="75">75 lines</option>
+              <option value="100">100 lines</option>
+              <option value="-1">Whole file</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+      <section>
+        <span class="title">Fit to screen</span>
+        <span class="value">
+          <input
+              id="lineWrappingInput"
+              type="checkbox"
+              checked$="[[diffPrefs.line_wrapping]]"
+              on-change="_handleLineWrappingTap">
+        </span>
+      </section>
+      <section>
+        <span class="title">Diff width</span>
+        <span class="value">
+          <iron-input
+              type="number"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{diffPrefs.line_length}}"
+              on-keypress="_handleDiffPrefsChanged"
+              on-change="_handleDiffPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                id="columnsInput"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{diffPrefs.line_length}}"
+                on-keypress="_handleDiffPrefsChanged"
+                on-change="_handleDiffPrefsChanged">
+          </iron-input>
+        </span>
+      </section>
+      <section>
+        <span class="title">Tab width</span>
+        <span class="value">
+          <iron-input
+              type="number"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{diffPrefs.tab_size}}"
+              on-keypress="_handleDiffPrefsChanged"
+              on-change="_handleDiffPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                id="tabSizeInput"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{diffPrefs.tab_size}}"
+                on-keypress="_handleDiffPrefsChanged"
+                on-change="_handleDiffPrefsChanged">
+          </iron-input>
+        </span>
+      </section>
+      <section hidden$="[[!diffPrefs.font_size]]">
+        <span class="title">Font size</span>
+        <span class="value">
+          <iron-input
+              type="number"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{diffPrefs.font_size}}"
+              on-keypress="_handleDiffPrefsChanged"
+              on-change="_handleDiffPrefsChanged">
+            <input
+                is="iron-input"
+                type="number"
+                id="fontSizeInput"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{diffPrefs.font_size}}"
+                on-keypress="_handleDiffPrefsChanged"
+                on-change="_handleDiffPrefsChanged">
+          </iron-input>
+        </span>
+      </section>
+      <section>
+        <span class="title">Show tabs</span>
+        <span class="value">
+          <input
+              id="showTabsInput"
+              type="checkbox"
+              checked$="[[diffPrefs.show_tabs]]"
+              on-change="_handleShowTabsTap">
+        </span>
+      </section>
+      <section>
+        <span class="title">Show trailing whitespace</span>
+        <span class="value">
+          <input
+              id="showTrailingWhitespaceInput"
+              type="checkbox"
+              checked$="[[diffPrefs.show_whitespace_errors]]"
+              on-change="_handleShowTrailingWhitespaceTap">
+        </span>
+      </section>
+      <section>
+        <span class="title">Syntax highlighting</span>
+        <span class="value">
+          <input
+              id="syntaxHighlightInput"
+              type="checkbox"
+              checked$="[[diffPrefs.syntax_highlighting]]"
+              on-change="_handleSyntaxHighlightTap">
+        </span>
+      </section>
+      <section>
+        <span class="title">Automatically mark viewed files reviewed</span>
+        <span class="value">
+          <input
+              id="automaticReviewInput"
+              type="checkbox"
+              checked$="[[!diffPrefs.manual_review]]"
+              on-change="_handleAutomaticReviewTap">
+        </span>
+      </section>
+      <section>
+        <div class="pref">
+          <span class="title">Ignore Whitespace</span>
+          <span class="value">
+            <gr-select bind-value="{{diffPrefs.ignore_whitespace}}">
+              <select
+                  on-keypress="_handleDiffPrefsChanged"
+                  on-change="_handleDiffPrefsChanged">
+                <option value="IGNORE_NONE">None</option>
+                <option value="IGNORE_TRAILING">Trailing</option>
+                <option value="IGNORE_LEADING_AND_TRAILING">Leading & trailing</option>
+                <option value="IGNORE_ALL">All</option>
+              </select>
+            </gr-select>
+          </span>
+        </div>
+      </section>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-diff-preferences.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
new file mode 100644
index 0000000..89c3d74
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-preferences',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      /** @type {?} */
+      diffPrefs: Object,
+    },
+
+    loadData() {
+      return this.$.restAPI.getDiffPreferences().then(prefs => {
+        this.diffPrefs = prefs;
+      });
+    },
+
+    _handleDiffPrefsChanged() {
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleLineWrappingTap() {
+      this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
+      this._handleDiffPrefsChanged();
+    },
+
+    _handleShowTabsTap() {
+      this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
+      this._handleDiffPrefsChanged();
+    },
+
+    _handleShowTrailingWhitespaceTap() {
+      this.set('diffPrefs.show_whitespace_errors',
+          this.$.showTrailingWhitespaceInput.checked);
+      this._handleDiffPrefsChanged();
+    },
+
+    _handleSyntaxHighlightTap() {
+      this.set('diffPrefs.syntax_highlighting',
+          this.$.syntaxHighlightInput.checked);
+      this._handleDiffPrefsChanged();
+    },
+
+    _handleAutomaticReviewTap() {
+      this.set('diffPrefs.manual_review',
+          !this.$.automaticReviewInput.checked);
+      this._handleDiffPrefsChanged();
+    },
+
+    save() {
+      return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(res => {
+        this.hasUnsavedChanges = false;
+      });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
new file mode 100644
index 0000000..f7b10e0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
@@ -0,0 +1,125 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-preferences</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-preferences.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-preferences></gr-diff-preferences>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-preferences tests', () => {
+    let element;
+    let sandbox;
+    let diffPreferences;
+
+    function valueOf(title, fieldsetid) {
+      const sections = element.$[fieldsetid].querySelectorAll('section');
+      let titleEl;
+      for (let i = 0; i < sections.length; i++) {
+        titleEl = sections[i].querySelector('.title');
+        if (titleEl.textContent.trim() === title) {
+          return sections[i].querySelector('.value');
+        }
+      }
+    }
+
+    setup(() => {
+      diffPreferences = {
+        context: 10,
+        line_wrapping: false,
+        line_length: 100,
+        tab_size: 8,
+        font_size: 12,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        manual_review: false,
+        ignore_whitespace: 'IGNORE_NONE',
+      };
+
+      stub('gr-rest-api-interface', {
+        getDiffPreferences() {
+          return Promise.resolve(diffPreferences);
+        },
+      });
+
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      return element.loadData();
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('renders', () => {
+      // Rendered with the expected preferences selected.
+      assert.equal(valueOf('Context', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.context);
+      assert.equal(valueOf('Fit to screen', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.line_wrapping);
+      assert.equal(valueOf('Diff width', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.line_length);
+      assert.equal(valueOf('Tab width', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.tab_size);
+      assert.equal(valueOf('Font size', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.font_size);
+      assert.equal(valueOf('Show tabs', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.show_tabs);
+      assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.show_whitespace_errors);
+      assert.equal(valueOf('Syntax highlighting', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.syntax_highlighting);
+      assert.equal(
+          valueOf('Automatically mark viewed files reviewed', 'diffPreferences')
+              .firstElementChild.checked, !diffPreferences.manual_review);
+      assert.equal(valueOf('Ignore Whitespace', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.ignore_whitespace);
+
+      assert.isFalse(element.hasUnsavedChanges);
+    });
+
+    test('save changes', () => {
+      sandbox.stub(element.$.restAPI, 'saveDiffPreferences')
+          .returns(Promise.resolve());
+      const showTrailingWhitespaceCheckbox =
+          valueOf('Show trailing whitespace', 'diffPreferences')
+          .firstElementChild;
+      showTrailingWhitespaceCheckbox.checked = false;
+      element._handleShowTrailingWhitespaceTap();
+
+      assert.isTrue(element.hasUnsavedChanges);
+
+      // Save the change.
+      return element.save().then(() => {
+        assert.isFalse(element.hasUnsavedChanges);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
index 7570533..d4a2ab2 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
@@ -15,38 +15,34 @@
 limitations under the License.
 -->
 
+<link rel="import" href="/bower_components/polymer/polymer.html">
+
+
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
+<link rel="import" href="/bower_components/paper-tabs/paper-tabs.html">
+<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-download-commands">
   <template>
     <style include="shared-styles">
-      ul {
-        list-style: none;
+      paper-tabs {
+        height: 3rem;
         margin-bottom: .5em;
+        --paper-tabs-selection-bar-color: var(--link-color);
       }
-      li {
-        display: inline-block;
-        margin: 0;
-        padding: 0;
-      }
-      li gr-button {
-        margin-right: 1em;
+      paper-tab {
+        max-width: 15rem;
+        text-transform: uppercase;
+        --paper-tab-ink: var(--link-color);
       }
       label,
       input {
         display: block;
       }
       label {
-        font-family: var(--font-family-bold);
-      }
-      li[selected] gr-button {
-        color: var(--primary-text-color);
-        font-family: var(--font-family-bold);
-        text-decoration: none;
+        font-weight: var(--font-weight-bold);
       }
       .schemes {
         display: flex;
@@ -55,33 +51,33 @@
       .commands {
         display: flex;
         flex-direction: column;
-        border-bottom: 1px solid var(--border-color);
-        border-top: 1px solid var(--border-color);
-        padding: .5em;
       }
-      gr-copy-clipboard {
+      gr-shell-command {
         width: 60em;
         margin-bottom: .5em;
       }
+      .hidden {
+        display: none;
+      }
     </style>
     <div class="schemes">
-      <ul hidden$="[[!schemes.length]]" hidden>
+      <paper-tabs
+          id="downloadTabs"
+          class$="[[_computeShowTabs(schemes)]]"
+          selected="[[_computeSelected(schemes, selectedScheme)]]"
+          on-selected-changed="_handleTabChange">
         <template is="dom-repeat" items="[[schemes]]" as="scheme">
-          <li selected$="[[_computeSelected(scheme, selectedScheme)]]">
-            <gr-button link data-scheme$="[[scheme]]" on-tap="_handleSchemeTap">
-              [[scheme]]
-            </gr-button>
-          </li>
+          <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
         </template>
-      </ul>
+      </paper-tabs>
     </div>
     <div class="commands" hidden$="[[!schemes.length]]" hidden>
       <template is="dom-repeat"
           items="[[commands]]"
           as="command">
-        <gr-copy-clipboard
-            title=[[command.title]]
-            text=[[command.command]]></gr-copy-clipboard>
+        <gr-shell-command
+            label=[[command.title]]
+            command=[[command.command]]></gr-shell-command>
       </template>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
index 8f513cb..ed7c2cc 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
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-download-commands',
+    _legacyUndefinedCheck: true,
     properties: {
       commands: Array,
       _loggedIn: {
@@ -44,7 +45,7 @@
     },
 
     focusOnCopy() {
-      this.$$('gr-copy-clipboard').focusOnCopy();
+      this.$$('gr-shell-command').focusOnCopy();
     },
 
     _getLoggedIn() {
@@ -61,17 +62,24 @@
       });
     },
 
-    _computeSelected(item, selectedItem) {
-      return item === selectedItem;
+    _handleTabChange(e) {
+      const scheme = this.schemes[e.detail.value];
+      if (scheme && scheme !== this.selectedScheme) {
+        this.set('selectedScheme', scheme);
+        if (this._loggedIn) {
+          this.$.restAPI.savePreferences(
+              {download_scheme: this.selectedScheme});
+        }
+      }
     },
 
-    _handleSchemeTap(e) {
-      e.preventDefault();
-      const el = Polymer.dom(e).localTarget;
-      this.selectedScheme = el.getAttribute('data-scheme');
-      if (this._loggedIn) {
-        this.$.restAPI.savePreferences({download_scheme: this.selectedScheme});
-      }
+    _computeSelected(schemes, selectedScheme) {
+      return (schemes.findIndex(scheme => scheme === selectedScheme) || 0)
+          + '';
+    },
+
+    _computeShowTabs(schemes) {
+      return schemes.length > 1 ? '' : 'hidden';
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
index 4fe5569..3b7e8f8 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-download-commands</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-download-commands.html">
 
@@ -74,37 +76,28 @@
       });
 
       test('focusOnCopy', () => {
-        const focusStub = sandbox.stub(element.$$('gr-copy-clipboard'),
+        const focusStub = sandbox.stub(element.$$('gr-shell-command'),
             'focusOnCopy');
         element.focusOnCopy();
         assert.isTrue(focusStub.called);
       });
 
       test('element visibility', () => {
-        assert.isFalse(element.$$('ul').hasAttribute('hidden'));
-        assert.isFalse(element.$$('.commands').hasAttribute('hidden'));
+        assert.isFalse(isHidden(element.$$('paper-tabs')));
+        assert.isFalse(isHidden(element.$$('.commands')));
 
         element.schemes = [];
-        assert.isTrue(element.$$('ul').hasAttribute('hidden'));
-        assert.isTrue(element.$$('.commands').hasAttribute('hidden'));
+        assert.isTrue(isHidden(element.$$('paper-tabs')));
+        assert.isTrue(isHidden(element.$$('.commands')));
       });
 
       test('tab selection', () => {
-        flushAsynchronousOperations();
-        let el = element.$$('[data-scheme="http"]').parentElement;
-        assert.isTrue(el.hasAttribute('selected'));
-        for (const scheme of ['repo', 'ssh']) {
-          const el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
-          assert.isFalse(el.hasAttribute('selected'));
-        }
-
+        assert.equal(element.$.downloadTabs.selected, '0');
         MockInteractions.tap(element.$$('[data-scheme="ssh"]'));
-        el = element.$$('[data-scheme="ssh"]').parentElement;
-        assert.isTrue(el.hasAttribute('selected'));
-        for (const scheme of ['http', 'repo']) {
-          const el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
-          assert.isFalse(el.hasAttribute('selected'));
-        }
+        flushAsynchronousOperations();
+
+        assert.equal(element.selectedScheme, 'ssh');
+        assert.equal(element.$.downloadTabs.selected, '2');
       });
 
       test('loads scheme from preferences', done => {
@@ -136,18 +129,18 @@
 
       test('saves scheme to preferences', () => {
         element._loggedIn = true;
-        const savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences',
+        const savePrefsStub = sandbox.stub(element.$.restAPI, 'savePreferences',
             () => { return Promise.resolve(); });
 
         flushAsynchronousOperations();
 
-        const firstSchemeButton = element.$$('li gr-button[data-scheme]');
+        const repoTab = element.$$('paper-tab[data-scheme="repo"]');
 
-        MockInteractions.tap(firstSchemeButton);
+        MockInteractions.tap(repoTab);
 
         assert.isTrue(savePrefsStub.called);
         assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
-            firstSchemeButton.getAttribute('data-scheme'));
+            repoTab.getAttribute('data-scheme'));
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
index 3abe28b..1d608c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
@@ -14,11 +14,11 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="../../../bower_components/paper-item/paper-item.html">
-<link rel="import" href="../../../bower_components/paper-listbox/paper-listbox.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/paper-item/paper-item.html">
+<link rel="import" href="/bower_components/paper-listbox/paper-listbox.html">
 
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -78,7 +78,6 @@
       }
       .bottomContent {
         color: var(--deemphasized-text-color);
-        font-size: var(--font-size-small);
         /*
          * Should be 16px when the base font size is 13px (browser default of
          * 16px.
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
index 40d8811..bcf3729 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
@@ -47,6 +47,7 @@
 
   Polymer({
     is: 'gr-dropdown-list',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the selected value changes
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
index 87fd8de..5f07fc9 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dropdown-list</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-dropdown-list.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
index f527aa3..22dcbf0 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -17,8 +17,8 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -94,7 +94,7 @@
         @apply --gr-dropdown-item;
       }
       .bold-text {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
     </style>
     <gr-button
@@ -102,7 +102,7 @@
         class="dropdown-trigger" id="trigger"
         down-arrow="[[downArrow]]"
         on-tap="_dropdownTriggerTapHandler">
-      <content></content>
+      <slot></slot>
     </gr-button>
     <iron-dropdown id="dropdown"
         vertical-align="top"
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index dcb428f..6eb3108 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -22,6 +22,7 @@
 
   Polymer({
     is: 'gr-dropdown',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a non-link dropdown item with the given ID is tapped.
@@ -229,7 +230,7 @@
       if (typeof link.url === 'undefined') {
         return '';
       }
-      if (link.target) {
+      if (link.target || !link.url.startsWith('/')) {
         return link.url;
       }
       return this._computeRelativeURL(link.url);
@@ -280,7 +281,9 @@
      */
     _resetCursorStops() {
       Polymer.dom.flush();
-      this._listElements = Polymer.dom(this.root).querySelectorAll('li');
+      // Polymer2: querySelectorAll returns NodeList instead of Array.
+      this._listElements = Array.from(
+          Polymer.dom(this.root).querySelectorAll('li'));
     },
 
     _computeHasTooltip(tooltip) {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 456f235..bf1c9fa 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dropdown</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-dropdown.html">
 
@@ -73,6 +75,12 @@
 
     test('link URLs', () => {
       assert.equal(
+          element._computeLinkURL({url: 'http://example.com/test'}),
+          'http://example.com/test');
+      assert.equal(
+          element._computeLinkURL({url: 'https://example.com/test'}),
+          'https://example.com/test');
+      assert.equal(
           element._computeLinkURL({url: '/test'}),
           '//' + window.location.host + '/test');
       assert.equal(
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
index 6cd87f5..aa102e4 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../gr-storage/gr-storage.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
index f567d39..dc945a2 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -22,6 +22,7 @@
 
   Polymer({
     is: 'gr-editable-content',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the save button is pressed.
@@ -95,8 +96,11 @@
             this.$.storage.getEditableContentItem(this.storageKey);
         if (storedContent && storedContent.message) {
           content = storedContent.message;
-          this.dispatchEvent(new CustomEvent('show-alert',
-              {detail: {message: RESTORED_MESSAGE}, bubbles: true}));
+          this.dispatchEvent(new CustomEvent('show-alert', {
+            detail: {message: RESTORED_MESSAGE},
+            bubbles: true,
+            composed: true,
+          }));
         }
       }
       if (!content) {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
index cc44d9b..3f5ccb0 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-content</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-editable-content.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index 8581396..5faedd2 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -14,11 +14,11 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="../../../bower_components/paper-input/paper-input.html">
+<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="/bower_components/paper-input/paper-input.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-editable-label">
@@ -41,7 +41,7 @@
       label {
         color: var(--deemphasized-text-color);
         display: inline-block;
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         overflow: hidden;
         text-overflow: ellipsis;
         white-space: nowrap;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index b7d65d3..f23afea 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -22,6 +22,7 @@
 
   Polymer({
     is: 'gr-editable-label',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when the value is changed.
@@ -99,6 +100,12 @@
       });
     },
 
+    open() {
+      return this._open().then(() => {
+        this.$.input.$.input.focus();
+      });
+    },
+
     _open(...args) {
       this.$.dropdown.open();
       this._inputText = this.value;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
index 6815173..5dad9a6 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-label</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-editable-label.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
index 674ff97..47acd0c 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-fixed-panel">
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
index 2c32709..87cd4b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-fixed-panel',
+    _legacyUndefinedCheck: true,
 
     properties: {
       floatingDisabled: Boolean,
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
index 9eac7f7..75e9901 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-fixed-panel</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-fixed-panel.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
index 3995595..b6ad1af 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
index 4e68d42..8836574 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
@@ -22,6 +22,7 @@
 
   Polymer({
     is: 'gr-formatted-text',
+    _legacyUndefinedCheck: true,
 
     properties: {
       content: {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
index ad036c5..801190a 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-label</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-formatted-text.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
index 7e3246f..bb6fbb5 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -46,4 +46,4 @@
   </template>
   <script src="../../../scripts/rootElement.js"></script>
   <script src="gr-hovercard.js"></script>
-</dom-module>
\ No newline at end of file
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
index f9c1da1..498c590 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
@@ -27,6 +27,7 @@
 
   Polymer({
     is: 'gr-hovercard',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /**
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
index e3e252f..aa13407 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-hovercard</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-hovercard.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
index 34ca0ab..7bd6f48 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -14,40 +14,44 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="../../../bower_components/iron-iconset-svg/iron-iconset-svg.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="/bower_components/iron-iconset-svg/iron-iconset-svg.html">
 
 <iron-iconset-svg name="gr-icons" size="24">
   <svg>
     <defs>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
       <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
       <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"/><path d="M0 0h24v24H0V0z" fill="none"/></g>
       <!-- This is a custom PolyGerrit SVG -->
@@ -78,6 +82,8 @@
       <g id="submit"><path d="M22.23,5 L11.65,15.58 L7.47000001,11.41 L6.06000001,12.82 L11.65,18.41 L23.649,6.41 L22.23,5 Z M16.58,5 L10.239,11.34 L11.65,12.75 L17.989,6.41 L16.58,5 Z M0.400000006,12.82 L5.99000001,18.41 L7.40000001,17 L1.82000001,11.41 L0.400000006,12.82 Z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
index 9331173..45f28d1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
@@ -19,15 +19,19 @@
 
   /**
    * Used to create a context for GrAnnotationActionsInterface.
-   * @param {HTMLElement} el The DIV.contentText element to apply the
-   *     annotation to using annotateRange.
+   * @param {HTMLElement} contentEl The DIV.contentText element of the line
+   *     content to apply the annotation to using annotateRange.
+   * @param {HTMLElement} lineNumberEl The TD element of the line number to
+   *     apply the annotation to using annotateLineNumber.
    * @param {GrDiffLine} line The line object.
-   * @param {String} path The file path (eg: /COMMIT_MSG').
-   * @param {String} changeNum The Gerrit change number.
-   * @param {String} patchNum The Gerrit patch number.
+   * @param {string} path The file path (eg: /COMMIT_MSG').
+   * @param {string} changeNum The Gerrit change number.
+   * @param {string} patchNum The Gerrit patch number.
    */
-  function GrAnnotationActionsContext(el, line, path, changeNum, patchNum) {
-    this._el = el;
+  function GrAnnotationActionsContext(
+      contentEl, lineNumberEl, line, path, changeNum, patchNum) {
+    this._contentEl = contentEl;
+    this._lineNumberEl = lineNumberEl;
 
     this.line = line;
     this.path = path;
@@ -36,16 +40,28 @@
   }
 
   /**
-   * Method to add annotations to a line.
-   * @param {Number} start The line number where the update starts.
-   * @param {Number} end The line number where the update ends.
-   * @param {String} cssClass The name of a CSS class created using Gerrit.css.
-   * @param {String} side The side of the update. ('left' or 'right')
+   * Method to add annotations to a content line.
+   * @param {number} offset The char offset where the update starts.
+   * @param {number} length The number of chars that the update covers.
+   * @param {string} cssClass The name of a CSS class created using Gerrit.css.
+   * @param {string} side The side of the update. ('left' or 'right')
    */
   GrAnnotationActionsContext.prototype.annotateRange = function(
-      start, end, cssClass, side) {
-    if (this._el.getAttribute('data-side') == side) {
-      GrAnnotation.annotateElement(this._el, start, end, cssClass);
+      offset, length, cssClass, side) {
+    if (this._contentEl && this._contentEl.getAttribute('data-side') == side) {
+      GrAnnotation.annotateElement(this._contentEl, offset, length, cssClass);
+    }
+  };
+
+  /**
+   * Method to add a CSS class to the line number TD element.
+   * @param {string} cssClass The name of a CSS class created using Gerrit.css.
+   * @param {string} side The side of the update. ('left' or 'right')
+   */
+  GrAnnotationActionsContext.prototype.annotateLineNumber = function(
+      cssClass, side) {
+    if (this._lineNumberEl && this._lineNumberEl.classList.contains(side)) {
+      this._lineNumberEl.classList.add(cssClass);
     }
   };
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
index cd86fa9..653999c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation-actions-context</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <script src="../../diff/gr-diff-highlight/gr-annotation.js"></script>
 
 <link rel="import" href="../../../test/common-test-setup.html"/>
@@ -39,6 +41,7 @@
     let instance;
     let sandbox;
     let el;
+    let lineNumberEl;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
@@ -47,8 +50,10 @@
       el = document.createElement('div');
       el.textContent = str;
       el.setAttribute('data-side', 'right');
+      lineNumberEl = document.createElement('td');
+      lineNumberEl.classList.add('right');
       instance = new GrAnnotationActionsContext(
-          el, line, 'dummy/path', '123', '1');
+          el, lineNumberEl, line, 'dummy/path', '123', '1');
     });
 
     teardown(() => {
@@ -74,5 +79,17 @@
       assert.equal(args[2], end);
       assert.equal(args[3], cssClass);
     });
+
+    test('test annotateLineNumber', () => {
+      const cssClass = Gerrit.css('background-color: #000000');
+
+      // Assert that css class is *not* applied when side is different.
+      instance.annotateLineNumber(cssClass, 'left');
+      assert.isFalse(lineNumberEl.classList.contains(cssClass));
+
+      // Assert that css class is applied when side is the same.
+      instance.annotateLineNumber(cssClass, 'right');
+      assert.isTrue(lineNumberEl.classList.contains(cssClass));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
index cb8409e..349e441 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
@@ -26,15 +26,18 @@
     // notifying their listeners in the notify function.
     this._annotationLayers = [];
 
+    this._coverageProvider = null;
+
     // Default impl is a no-op.
     this._addLayerFunc = annotationActionsContext => {};
   }
 
   /**
    * Register a function to call to apply annotations. Plugins should use
-   * GrAnnotationActionsContext.annotateRange to apply a CSS class to a range
-   * within a line.
-   * @param {Function<GrAnnotationActionsContext>} addLayerFunc The function
+   * GrAnnotationActionsContext.annotateRange and
+   * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
+   * line content or the line number.
+   * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
    *     that will be called when the AnnotationLayer is ready to annotate.
    */
   GrAnnotationActionsInterface.prototype.addLayer = function(addLayerFunc) {
@@ -45,7 +48,7 @@
   /**
    * The specified function will be called with a notify function for the plugin
    * to call when it has all required data for annotation. Optional.
-   * @param {Function<Function<String, Number, Number, String>>} notifyFunc See
+   * @param {function(function(String, Number, Number, String))} notifyFunc See
    *     doc of the notify function below to see what it does.
    */
   GrAnnotationActionsInterface.prototype.addNotifier = function(notifyFunc) {
@@ -55,6 +58,37 @@
   };
 
   /**
+   * The specified function will be called when a gr-diff component is built,
+   * and feeds the returned coverage data into the diff. Optional.
+   *
+   * Be sure to call this only once and only from one plugin. Multiple coverage
+   * providers are not supported. A second call will just overwrite the
+   * provider of the first call.
+   *
+   * TODO(brohlfs): Replace Array<Object> type by Array<Gerrit.CoverageRange>.
+   *
+   * @param {function(changeNum, path, basePatchNum, patchNum):
+   * !Promise<!Array<Object>>} coverageProvider
+   * @return {GrAnnotationActionsInterface}
+   */
+  GrAnnotationActionsInterface.prototype.setCoverageProvider = function(
+      coverageProvider) {
+    if (this._coverageProvider) {
+      console.warn('Overwriting an existing coverage provider.');
+    }
+    this._coverageProvider = coverageProvider;
+    return this;
+  };
+
+  /**
+   * Used by Gerrit to look up the coverage provider. Not intended to be called
+   * by plugins.
+   */
+  GrAnnotationActionsInterface.prototype.getCoverageProvider = function() {
+    return this._coverageProvider;
+  };
+
+  /**
    * Returns a checkbox HTMLElement that can be used to toggle annotations
    * on/off. The checkbox will be initially disabled. Plugins should enable it
    * when data is ready and should add a click handler to toggle CSS on/off.
@@ -68,7 +102,7 @@
    *
    * @param {String} checkboxLabel Will be used as the label for the checkbox.
    *     Optional. "Enable" is used if this is not specified.
-   * @param {Function<HTMLElement>} onAttached The function that will be called
+   * @param {function(HTMLElement)} onAttached The function that will be called
    *     when the checkbox is attached to the page.
    */
   GrAnnotationActionsInterface.prototype.enableToggleCheckbox = function(
@@ -134,7 +168,7 @@
    * @param {String} path The file path (eg: /COMMIT_MSG').
    * @param {String} changeNum The Gerrit change number.
    * @param {String} patchNum The Gerrit patch number.
-   * @param {Function<GrAnnotationActionsContext>} addLayerFunc The function
+   * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
    *     that will be called when the AnnotationLayer is ready to annotate.
    */
   function AnnotationLayer(path, changeNum, patchNum, addLayerFunc) {
@@ -148,7 +182,7 @@
 
   /**
    * Register a listener for layer updates.
-   * @param {Function<Number, Number, String>} fn The update handler function.
+   * @param {function(Number, Number, String)} fn The update handler function.
    *     Should accept as arguments the line numbers for the start and end of
    *     the update and the side as a string.
    */
@@ -158,13 +192,16 @@
 
   /**
    * Layer method to add annotations to a line.
-   * @param {HTMLElement} el The DIV.contentText element to apply the
-   *     annotation to.
+   * @param {HTMLElement} contentEl The DIV.contentText element of the line
+   *     content to apply the annotation to using annotateRange.
+   * @param {HTMLElement} lineNumberEl The TD element of the line number to
+   *     apply the annotation to using annotateLineNumber.
    * @param {GrDiffLine} line The line object.
    */
-  AnnotationLayer.prototype.annotate = function(el, line) {
+  AnnotationLayer.prototype.annotate = function(contentEl, lineNumberEl, line) {
     const annotationActionsContext = new GrAnnotationActionsContext(
-        el, line, this._path, this._changeNum, this._patchNum);
+        contentEl, lineNumberEl, line, this._path, this._changeNum,
+        this._patchNum);
     this._addLayerFunc(annotationActionsContext);
   };
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
index bfb8b47..987b551 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation-actions-js-api-js-api</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="../../change/gr-change-actions/gr-change-actions.html">
 
@@ -28,7 +30,9 @@
   <template>
     <span hidden id="annotation-span">
       <label for="annotation-checkbox" id="annotation-label"></label>
-      <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+      <iron-input type="checkbox" disabled>
+        <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+      </iron-input>
     </span>
   </template>
 </test-fixture>
@@ -71,7 +75,8 @@
       const annotationLayer = annotationActions.getLayer(
           '/dummy/path', changeNum, patchNum);
 
-      annotationLayer.annotate(el, line);
+      const lineNumberEl = document.createElement('td');
+      annotationLayer.annotate(el, lineNumberEl, line);
       assert.isTrue(testLayerFuncCalled);
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index fef4fc9..30bd366 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-actions-js-api</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <!--
 This must refer to the element this interface is wrapping around. Otherwise
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index 278f95a..842a2fe 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-reply-js-api</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <!--
 This must refer to the element this interface is wrapping around. Otherwise
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index d8a662e..e04867a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index 820d2c0..f907ad6 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -28,6 +28,7 @@
     POST_REVERT: 'postrevert',
     ANNOTATE_DIFF: 'annotatediff',
     ADMIN_MENU_LINKS: 'admin-menu-links',
+    HIGHLIGHTJS_LOADED: 'highlightjs-loaded',
   };
 
   const Element = {
@@ -37,6 +38,7 @@
 
   Polymer({
     is: 'gr-js-api-interface',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _elements: {
@@ -69,6 +71,9 @@
           case EventType.LABEL_CHANGE:
             this._handleLabelChange(detail);
             break;
+          case EventType.HIGHLIGHTJS_LOADED:
+            this._handleHighlightjsLoaded(detail);
+            break;
           default:
             console.warn('handleEvent called with unsupported event type:',
                 type);
@@ -188,6 +193,16 @@
       }
     },
 
+    _handleHighlightjsLoaded(detail) {
+      for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
+        try {
+          cb(detail.hljs);
+        } catch (err) {
+          console.error(err);
+        }
+      }
+    },
+
     modifyRevertMsg(change, revertMsg, origMsg) {
       for (const cb of this._getEventCallbacks(EventType.REVERT)) {
         try {
@@ -213,6 +228,36 @@
       return layers;
     },
 
+    /**
+     * Retrieves coverage data possibly provided by a plugin.
+     *
+     * Will wait for plugins to be loaded. If multiple plugins offer a coverage
+     * provider, the first one is used. If no plugin offers a coverage provider,
+     * will resolve to [].
+     *
+     * TODO(brohlfs): Replace Array<Object> type by Array<Gerrit.CoverageRange>.
+     *
+     * @param {string|number} changeNum
+     * @param {string} path
+     * @param {string|number} basePatchNum
+     * @param {string|number} patchNum
+     * @return {!Promise<!Array<Object>>}
+     */
+    getCoverageRanges(changeNum, path, basePatchNum, patchNum) {
+      return Gerrit.awaitPluginsLoaded().then(() => {
+        for (const annotationApi of
+            this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+          const provider = annotationApi.getCoverageProvider();
+          // Only one coverage provider makes sense. If there are more, then we
+          // simply ignore them.
+          if (provider) {
+            return provider(changeNum, path, basePatchNum, patchNum);
+          }
+        }
+        return [];
+      });
+    },
+
     getAdminMenuLinks() {
       const links = [];
       for (const adminApi of
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index bd5e2a2..055fc3fcb 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-js-api-interface.html">
 
@@ -78,6 +80,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'),
@@ -290,6 +302,17 @@
       assert.isTrue(errorStub.calledTwice);
     });
 
+    test('highlightjs-loaded event', done => {
+      const testHljs = {_number: 42};
+      plugin.on(element.EventType.HIGHLIGHTJS_LOADED, throwErrFn);
+      plugin.on(element.EventType.HIGHLIGHTJS_LOADED, hljs => {
+        assert.deepEqual(hljs, testHljs);
+        assert.isTrue(errorStub.calledOnce);
+        done();
+      });
+      element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
+    });
+
     test('versioning', () => {
       const callback = sandbox.spy();
       Gerrit.install(callback, '0.0pre-alpha');
@@ -412,20 +435,51 @@
           element.EventType.ADMIN_MENU_LINKS);
     });
 
+    test('Gerrit._isPluginPreloaded', () => {
+      Gerrit._preloadedPlugins = {baz: ()=>{}};
+      assert.isFalse(Gerrit._isPluginPreloaded('plugins/foo/bar'));
+      assert.isFalse(Gerrit._isPluginPreloaded('http://a.com/42'));
+      assert.isTrue(Gerrit._isPluginPreloaded('preloaded:baz'));
+      Gerrit._preloadedPlugins = null;
+    });
+
+    test('preloaded plugins are installed', () => {
+      const installStub = sandbox.stub();
+      Gerrit._preloadedPlugins = {foo: installStub};
+      Gerrit._installPreloadedPlugins();
+      assert.isTrue(installStub.called);
+      const pluginApi = installStub.lastCall.args[0];
+      assert.strictEqual(pluginApi.getPluginName(), 'foo');
+    });
+
+    test('installing preloaded plugin', () => {
+      let plugin;
+      window.ASSETS_PATH = 'http://blips.com/chitz';
+      Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
+      assert.strictEqual(plugin.getPluginName(), 'foo');
+      assert.strictEqual(plugin.url('/some/thing.html'),
+          'http://blips.com/chitz/plugins/foo/some/thing.html');
+      delete window.ASSETS_PATH;
+    });
+
     suite('test plugin with base url', () => {
+      let baseUrlPlugin;
+
       setup(() => {
         sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
 
         Gerrit._setPluginsCount(1);
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/r/plugins/testplugin/static/test.js');
+        Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
+            'http://test.com/r/plugins/baseurlplugin/static/test.js');
       });
 
       test('url', () => {
-        assert.notEqual(plugin.url(), 'http://test.com/plugins/testplugin/');
-        assert.equal(plugin.url(), 'http://test.com/r/plugins/testplugin/');
-        assert.equal(plugin.url('/static/test.js'),
-            'http://test.com/r/plugins/testplugin/static/test.js');
+        assert.notEqual(baseUrlPlugin.url(),
+            'http://test.com/plugins/baseurlplugin/');
+        assert.equal(baseUrlPlugin.url(),
+            'http://test.com/r/plugins/baseurlplugin/');
+        assert.equal(baseUrlPlugin.url('/static/test.js'),
+            'http://test.com/r/plugins/baseurlplugin/static/test.js');
       });
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
index bf6a046..cff1a4e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-action-context</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-js-api-interface.html"/>
 
@@ -135,7 +137,7 @@
         __key: 'key',
         __url: '/changes/1/revisions/2/foo~bar',
       };
-      const sendStub = sandbox.stub().returns(Promise.reject('boom'));
+      const sendStub = sandbox.stub().returns(Promise.reject(new Error('boom')));
       sandbox.stub(plugin, 'restApi').returns({
         send: sendStub,
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
index 9931f72..8832a3f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
@@ -20,6 +20,7 @@
   function GrPluginEndpoints() {
     this._endpoints = {};
     this._callbacks = {};
+    this._dynamicPlugins = {};
   }
 
   GrPluginEndpoints.prototype.onNewEndpoint = function(endpoint, callback) {
@@ -51,8 +52,21 @@
     }
   };
 
+  /**
+   * Register a plugin to an endpoint.
+   *
+   * Dynamic plugins are registered to a specific prefix, such as
+   * 'change-list-header'. These plugins are then fetched by prefix to determine
+   * which endpoints to dynamically add to the page.
+   */
   GrPluginEndpoints.prototype.registerModule = function(plugin, endpoint, type,
-      moduleName, domHook) {
+      moduleName, domHook, dynamicEndpoint) {
+    if (dynamicEndpoint) {
+      if (!this._dynamicPlugins[dynamicEndpoint]) {
+        this._dynamicPlugins[dynamicEndpoint] = new Set();
+      }
+      this._dynamicPlugins[dynamicEndpoint].add(endpoint);
+    }
     if (!this._endpoints[endpoint]) {
       this._endpoints[endpoint] = [];
     }
@@ -63,6 +77,12 @@
     }
   };
 
+  GrPluginEndpoints.prototype.getDynamicEndpoints = function(dynamicEndpoint) {
+    const plugins = this._dynamicPlugins[dynamicEndpoint];
+    if (!plugins) return [];
+    return Array.from(plugins);
+  };
+
   /**
    * Get detailed information about modules registered with an extension
    * endpoint.
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
index b00b5ac..8ed7f14 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-endpoints</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-js-api-interface.html"/>
 
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..eb0c7e0 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
@@ -38,15 +38,23 @@
     return getRestApi().getVersion();
   };
 
+  GrPluginRestApi.prototype.invalidateReposCache = function() {
+    getRestApi().invalidateReposCache();
+  };
+
   /**
    * Fetch and return native browser REST API Response.
    * @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 +62,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-plugin-rest-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
index 5983621..8626280 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-rest-api</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-js-api-interface.html"/>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 3719877..fff2e33 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -31,6 +31,8 @@
 
   let _pluginsPendingCount = -1;
 
+  const PRELOADED_PROTOCOL = 'preloaded:';
+
   const UNKNOWN_PLUGIN = 'unknown';
 
   const PANEL_ENDPOINTS_MAPPING = {
@@ -101,6 +103,21 @@
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
   window.$wnd = window;
 
+  function flushPreinstalls() {
+    if (window.Gerrit.flushPreinstalls) {
+      window.Gerrit.flushPreinstalls();
+    }
+  }
+
+  function installPreloadedPlugins() {
+    if (!Gerrit._preloadedPlugins) { return; }
+    for (const name in Gerrit._preloadedPlugins) {
+      if (!Gerrit._preloadedPlugins.hasOwnProperty(name)) { continue; }
+      const callback = Gerrit._preloadedPlugins[name];
+      Gerrit.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
+    }
+  }
+
   function getPluginNameFromUrl(url) {
     if (!(url instanceof URL)) {
       try {
@@ -110,6 +127,9 @@
         return null;
       }
     }
+    if (url.protocol === PRELOADED_PROTOCOL) {
+      return url.pathname;
+    }
     const base = Gerrit.BaseUrlBehavior.getBaseUrl();
     const pathname = url.pathname.replace(base, '');
     // Site theme is server from predefined path.
@@ -147,6 +167,13 @@
 
     this._url = new URL(opt_url);
     this._name = getPluginNameFromUrl(this._url);
+    if (this._url.protocol === PRELOADED_PROTOCOL) {
+      // Original plugin URL is used in plugin assets URLs calculation.
+      const assetsBaseUrl = window.ASSETS_PATH ||
+          (window.location.origin + Gerrit.BaseUrlBehavior.getBaseUrl());
+      this._url = new URL(assetsBaseUrl + '/plugins/' + this._name +
+          '/static/' + this._name + '.js');
+    }
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -162,14 +189,36 @@
         this, endpointName, EndpointType.STYLE, moduleName);
   };
 
+  /**
+   * Registers an endpoint for the plugin.
+  */
   Plugin.prototype.registerCustomComponent = function(
       endpointName, opt_moduleName, opt_options) {
+    return this._registerCustomComponent(endpointName, opt_moduleName,
+        opt_options);
+  };
+
+  /**
+   * Registers a dynamic endpoint for the plugin.
+   *
+   * Dynamic plugins are registered by specific prefix, such as
+   * 'change-list-header'.
+  */
+  Plugin.prototype.registerDynamicCustomComponent = function(
+      endpointName, opt_moduleName, opt_options) {
+    const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
+    return this._registerCustomComponent(fullEndpointName, opt_moduleName,
+        opt_options, endpointName);
+  };
+
+  Plugin.prototype._registerCustomComponent = function(
+      endpointName, opt_moduleName, opt_options, dynamicEndpoint) {
     const type = opt_options && opt_options.replace ?
           EndpointType.REPLACE : EndpointType.DECORATE;
     const hook = this._domHooks.getDomHook(endpointName, opt_moduleName);
     const moduleName = opt_moduleName || hook.getModuleName();
     Gerrit._endpoints.registerModule(
-        this, endpointName, type, moduleName, hook);
+        this, endpointName, type, moduleName, hook, dynamicEndpoint);
     return hook.getPublicAPI();
   };
 
@@ -190,9 +239,14 @@
   };
 
   Plugin.prototype.url = function(opt_path) {
-    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
-    return this._url.origin + base + '/plugins/' +
-        this._name + (opt_path || '/');
+    const relPath = '/plugins/' + this._name + (opt_path || '/');
+    if (window.location.origin === this._url.origin) {
+      // Plugin loaded from the same origin as gr-app, getBaseUrl in effect.
+      return this._url.origin + Gerrit.BaseUrlBehavior.getBaseUrl() + relPath;
+    } else {
+      // Plugin loaded from assets bundle, expect assets placed along with it.
+      return this._url.href.split('/plugins/' + this._name)[0] + relPath;
+    }
   };
 
   Plugin.prototype.screenUrl = function(opt_screenName) {
@@ -421,7 +475,10 @@
     },
   };
 
-  const Gerrit = window.Gerrit || {};
+  flushPreinstalls();
+
+  window.Gerrit = window.Gerrit || {};
+  const Gerrit = window.Gerrit;
 
   let _resolveAllPluginsLoaded = null;
   let _allPluginsPromise = null;
@@ -432,6 +489,8 @@
   const app = document.querySelector('#app');
   if (!app) {
     // No gr-app found (running tests)
+    Gerrit._installPreloadedPlugins = installPreloadedPlugins;
+    Gerrit._flushPreinstalls = flushPreinstalls;
     Gerrit._resetPlugins = () => {
       _allPluginsPromise = null;
       _pluginsInstalled = [];
@@ -469,7 +528,11 @@
     // HTML import polyfill adds __importElement pointing to the import tag.
     const script = document.currentScript &&
         (document.currentScript.__importElement || document.currentScript);
-    const src = opt_src || (script && (script.src || script.baseURI));
+
+    let src = opt_src || (script && script.src);
+    if (!src || src.startsWith('data:')) {
+      src = script && script.baseURI;
+    }
     const name = getPluginNameFromUrl(src);
 
     if (opt_version && opt_version !== API_VERSION) {
@@ -610,6 +673,7 @@
       delete _pluginsPending[name];
       _pluginsInstalled.push(name);
       Gerrit._setPluginsCount(_pluginsPendingCount - 1);
+      getReporting().pluginLoaded(name);
       console.log(`Plugin ${name} installed.`);
     }
   };
@@ -622,5 +686,16 @@
     return `${pluginName}-screen-${screenName}`;
   };
 
-  window.Gerrit = Gerrit;
+  Gerrit._isPluginPreloaded = function(url) {
+    const name = getPluginNameFromUrl(url);
+    if (name && Gerrit._preloadedPlugins) {
+      return name in Gerrit._preloadedPlugins;
+    } else {
+      return false;
+    }
+  };
+
+  // Preloaded plugins should be installed after Gerrit.install() is set,
+  // since plugin preloader substitutes Gerrit.install() temporarily.
+  installPreloadedPlugins();
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
index 67537cf..e3cf34b 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
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../styles/gr-voting-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -29,13 +29,9 @@
   <template strip-whitespace>
     <style include="gr-voting-styles"></style>
     <style include="shared-styles">
-      .title {
-        font-size: var(--font-size-large);
-        font-weight: bold;
-      }
       .placeholder {
         color: var(--deemphasized-text-color);
-        padding-top: .5em;
+        padding-top: .2em;
       }
       .hidden {
         display: none;
@@ -44,7 +40,7 @@
         display: flex;
         justify-content: center;
         margin-right: .3em;
-        padding: .2em .85em;
+        padding: .05em .85em;
         @apply --vote-chip-styles;
       }
       .max {
@@ -68,16 +64,6 @@
       tr {
         min-height: 2.25em;
       }
-      tr td {
-        padding-top: .35em;
-      }
-      tr.currentUser td {
-        padding-bottom: .5em;
-      }
-      tr.currentUser + tr td {
-        border-top: 1px solid var(--border-color);
-        padding-top: .5em;
-      }
       gr-button {
         --gr-button: {
           height: 2em;
@@ -85,25 +71,29 @@
           width: 2em;
         }
       }
+      gr-button[disabled] iron-icon {
+        color: var(--border-color);
+      }
       gr-account-chip {
-        margin-right: 1.5em;
+        margin-right: .25em;
+      }
+      iron-icon {
+        height: 1.2em;
+        width: 1.2em;
+      }
+      .labelValueContainer:not(:first-of-type) td {
+        padding-top: .3em;
       }
     </style>
-    <p class="title">[[label]]</p>
-    <p class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]">
-      No votes for this label.
-    </p>
     <table>
+      <p class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]">
+        No votes.
+      </p>
       <template
           is="dom-repeat"
           items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
           as="mappedLabel">
-        <tr class$="labelValueContainer [[_computeLabelContainerClass(mappedLabel)]]">
-          <td>
-            <gr-account-chip
-                account="[[mappedLabel.account]]"
-                transparent-background></gr-account-chip>
-          </td>
+        <tr class="labelValueContainer">
           <td>
             <gr-label
                 has-tooltip
@@ -113,6 +103,11 @@
             </gr-label>
           </td>
           <td>
+            <gr-account-chip
+                account="[[mappedLabel.account]]"
+                transparent-background></gr-account-chip>
+          </td>
+          <td>
             <gr-button
                 link
                 aria-label="Remove"
@@ -129,4 +124,4 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-label-info.js"></script>
-</dom-module>
\ No newline at end of file
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
index 750ba1c..2036f2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-label-info',
+    _legacyUndefinedCheck: true,
 
     properties: {
       labelInfo: Object,
@@ -71,20 +72,16 @@
               labelClassName = 'negative';
             }
           }
+          const formattedLabel = {
+            value: labelValPrefix + label.value,
+            className: labelClassName,
+            account: label,
+          };
           if (label._account_id === account._account_id) {
-            // Put self-votes at the top, and add a flag.
-            result.unshift({
-              value: labelValPrefix + label.value,
-              className: labelClassName,
-              account: label,
-              isCurrentUser: true,
-            });
+            // Put self-votes at the top.
+            result.unshift(formattedLabel);
           } else {
-            result.push({
-              value: labelValPrefix + label.value,
-              className: labelClassName,
-              account: label,
-            });
+            result.push(formattedLabel);
           }
         }
       }
@@ -131,27 +128,8 @@
           this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
           .then(response => {
             target.disabled = false;
-            if (!response.ok) { return response; }
-
-            const label = this.change.labels[this.label];
-            const labels = label.all || [];
-            let wasChanged = false;
-            for (let i = 0; i < labels.length; i++) {
-              if (labels[i]._account_id === accountID) {
-                for (const key in label) {
-                  if (label.hasOwnProperty(key) &&
-                      label[key]._account_id === accountID) {
-                    // Remove special label field, keeping change label values
-                    // in sync with the backend.
-                    this.change.labels[this.label][key] = null;
-                  }
-                }
-                this.change.labels[this.label].all.splice(i, 1);
-                wasChanged = true;
-                break;
-              }
-            }
-            if (wasChanged) { this.notifySplices('change.labels'); }
+            if (!response.ok) { return; }
+            Gerrit.Nav.navigateToChange(this.change);
           }).catch(err => {
             target.disabled = false;
             return;
@@ -165,10 +143,6 @@
       return labelInfo.values[score];
     },
 
-    _computeLabelContainerClass(label) {
-      return label.isCurrentUser ? 'currentUser' : '';
-    },
-
     /**
      * @param {!Object} labelInfo
      * @param {Object} changeLabelsRecord not used, but added as a parameter in
@@ -177,10 +151,12 @@
     _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
       if (labelInfo.all) {
         for (const label of labelInfo.all) {
-          if (label.value) { return 'hidden'; }
+          if (label.value && label.value != labelInfo.default_value) {
+            return 'hidden';
+          }
         }
       }
       return '';
     },
   });
-})();
\ No newline at end of file
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
index 8bc358d..96d9fd7 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
@@ -17,9 +17,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-info</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-label-info.html">
 
@@ -227,4 +229,4 @@
       assert.isTrue(isHidden(element.$$('.placeholder')));
     });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.html b/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
index fe290b7..55ecc98 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <dom-module id="gr-label">
   <template strip-whitespace>
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
index 0de0881..c437885 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-label',
+    _legacyUndefinedCheck: true,
 
     behaviors: [
       Gerrit.TooltipBehavior,
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
new file mode 100644
index 0000000..986bce1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
@@ -0,0 +1,72 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-labeled-autocomplete">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        width: 12em;
+      }
+      #container {
+        background: var(--chip-background-color);
+        border-radius: 1em;
+        padding: .5em;
+      }
+      #header {
+        color: var(--deemphasized-text-color);
+        font-weight: var(--font-weight-bold);
+        font-size: var(--font-size-small);
+      }
+      #body {
+        display: flex;
+      }
+      gr-autocomplete {
+        height: 1.5em;
+        --gr-autocomplete: {
+          border: none;
+        }
+      }
+      #trigger {
+        border-left: 1px solid var(--deemphasized-text-color);
+        color: var(--deemphasized-text-color);
+        cursor: pointer;
+        padding-left: .4em;
+      }
+      #trigger:hover {
+        color: var(--primary-text-color);
+      }
+    </style>
+    <div id="container">
+      <div id="header">[[label]]</div>
+      <div id="body">
+        <gr-autocomplete
+            id="autocomplete"
+            threshold="[[_autocompleteThreshold]]"
+            query="[[query]]"
+            disabled="[[disabled]]"
+            placeholder="[[placeholder]]"
+            borderless></gr-autocomplete>
+        <div id="trigger" on-tap="_handleTriggerTap">▼</div>
+      </div>
+    </div>
+  </template>
+  <script src="gr-labeled-autocomplete.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
new file mode 100644
index 0000000..a892522
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-labeled-autocomplete',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when a value is chosen.
+     *
+     * @event commit
+     */
+
+    properties: {
+
+      /**
+       * Used just like the query property of gr-autocomplete.
+       *
+       * @type {function(string): Promise<?>}
+       */
+      query: {
+        type: Function,
+        value() {
+          return function() {
+            return Promise.resolve([]);
+          };
+        },
+      },
+
+      text: {
+        type: String,
+        value: '',
+        notify: true,
+      },
+      label: String,
+      placeholder: String,
+      disabled: Boolean,
+
+      _autocompleteThreshold: {
+        type: Number,
+        value: 0,
+        readOnly: true,
+      },
+    },
+
+    _handleTriggerTap(e) {
+      // Stop propagation here so we don't confuse gr-autocomplete, which
+      // listens for taps on body to try to determine when it's blurred.
+      e.stopPropagation();
+      this.$.autocomplete.focus();
+    },
+
+    setText(text) {
+      this.$.autocomplete.setText(text);
+    },
+
+    clear() {
+      this.setText('');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
new file mode 100644
index 0000000..bcd060b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-labeled-autocomplete</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-labeled-autocomplete.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-labeled-autocomplete></gr-labeled-autocomplete>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-labeled-autocomplete tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('tapping trigger focuses autocomplete', () => {
+      const e = {stopPropagation: () => undefined};
+      sandbox.stub(e, 'stopPropagation');
+      sandbox.stub(element.$.autocomplete, 'focus');
+      element._handleTriggerTap(e);
+      assert.isTrue(e.stopPropagation.calledOnce);
+      assert.isTrue(element.$.autocomplete.focus.calledOnce);
+    });
+
+    test('setText', () => {
+      sandbox.stub(element.$.autocomplete, 'setText');
+      element.setText('foo-bar');
+      assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html
index f70aff4..fb55c67 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html
@@ -14,8 +14,12 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-lib-loader">
+  <template>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  </template>
   <script src="gr-lib-loader.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
index ef8c112..adc8619 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
@@ -22,6 +22,7 @@
 
   Polymer({
     is: 'gr-lib-loader',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _hljsState: {
@@ -65,14 +66,16 @@
      * Loads the dark theme document. Returns a promise that resolves with a
      * custom-style DOM element.
      * @return {!Promise<Element>}
+     * @suppress {checkTypes}
      */
     getDarkTheme() {
       return new Promise((resolve, reject) => {
-        this.importHref(this._getLibRoot() + DARK_THEME_PATH, () => {
-          const module = document.createElement('style', 'custom-style');
-          module.setAttribute('include', 'dark-theme');
-          resolve(module);
-        });
+        (this.importHref || Polymer.importHref)(
+            this._getLibRoot() + DARK_THEME_PATH, () => {
+              const module = document.createElement('style', 'custom-style');
+              module.setAttribute('include', 'dark-theme');
+              resolve(module);
+            });
       });
     },
 
@@ -82,6 +85,9 @@
     _onHLJSLibLoaded() {
       const lib = this._getHighlightLib();
       this._hljsState.loading = false;
+      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.HIGHLIGHTJS_LOADED, {
+        hljs: lib,
+      });
       for (const cb of this._hljsState.callbacks) {
         cb(lib);
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
index cf9a41c..10d1608 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-lib-loader</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-lib-loader.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
index 91866e5..d00416b 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 
 <dom-module id="gr-limited-text">
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
index 0dc3a7d..44a8791 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
@@ -26,6 +26,7 @@
 
   Polymer({
     is: 'gr-limited-text',
+    _legacyUndefinedCheck: true,
 
     properties: {
       /** The un-truncated text to display. */
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
index 16eb960..b07971b 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-limited-text</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-limited-text.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
index fab562a..a3dd054 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../gr-icons/gr-icons.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
index f8f29b8..8388a07 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-linked-chip',
+    _legacyUndefinedCheck: true,
 
     properties: {
       href: String,
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
index eb57428..22a2eaf 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
@@ -18,11 +18,13 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-linked-chip</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-linked-chip.html">
 
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..5697e77 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
@@ -15,10 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<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>
+<script src="/bower_components/ba-linkify/ba-linkify.js"></script>
 <script src="link-text-parser.js"></script>
 <dom-module id="gr-linked-text">
   <template>
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..b1bd5cd 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
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-linked-text',
+    _legacyUndefinedCheck: true,
 
     properties: {
       removeZeroWidthSpace: Boolean,
@@ -60,15 +61,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 +89,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..73295b1 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-linked-text</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
@@ -37,29 +39,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 +77,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 +110,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 +142,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 +170,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 +229,7 @@
       assert.equal(links.length, 0);
     });
 
-    test('overlapping links', function() {
+    test('overlapping links', () => {
       element.config = {
         b1: {
           match: '(B:\\s*)(\\d+)',
@@ -238,7 +241,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 +253,31 @@
       assert.equal(links[1].textContent, '45');
     });
 
-    test('_contentOrConfigChanged called with config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged called with config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       assert.isTrue(contentStub.called);
       assert.isTrue(contentConfigStub.called);
     });
   });
 
-  suite('gr-linked-text with null config', function() {
-    var element;
-    var sandbox;
+  suite('gr-linked-text with null config', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('_contentOrConfigChanged not called without config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged not called without config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       assert.isTrue(contentStub.called);
       assert.isFalse(contentConfigStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index 8b49ca0..8526c3e 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -41,8 +41,8 @@
    * @param {Object|null|undefined} linkConfig Comment links as specified by the
    *     commentlinks field on a project config.
    * @param {Function} callback The callback to be fired when an intermediate
-   *     parse result is emitted. The callback is passed text and href strings if
-   *     a link is to be created, or a document fragment otherwise.
+   *     parse result is emitted. The callback is passed text and href strings
+   *     if a link is to be created, or a document fragment otherwise.
    * @param {boolean|undefined} opt_removeZeroWidthSpace If true, zero-width
    *     spaces will be removed from R=<email> and CC=<email> expressions.
    */
@@ -73,14 +73,14 @@
    */
   GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
     this.sortArrayReverse(outputArray);
-    var fragment = document.createDocumentFragment();
-    var cursor = text.length;
+    const fragment = document.createDocumentFragment();
+    let cursor = text.length;
 
     // Start inserting linkified URLs from the end of the String. That way, the
     // string positions of the items don't change as we iterate through.
-    outputArray.forEach(function(item) {
-      // Add any text between the current linkified item and the item added before
-      // if it exists.
+    outputArray.forEach(item => {
+      // Add any text between the current linkified item and the item added
+      // before if it exists.
       if (item.position + item.length !== cursor) {
         fragment.insertBefore(
             document.createTextNode(
@@ -130,32 +130,32 @@
    */
   GrLinkTextParser.prototype.addItem =
       function(text, href, html, position, length, outputArray) {
-    var htmlOutput = '';
+        let htmlOutput = '';
 
-    if (href) {
-      var a = document.createElement('a');
-      a.href = href;
-      a.textContent = text;
-      a.target = '_blank';
-      a.rel = 'noopener';
-      htmlOutput = a;
-    } else if (html) {
-      var fragment = document.createDocumentFragment();
+        if (href) {
+          const a = document.createElement('a');
+          a.href = href;
+          a.textContent = text;
+          a.target = '_blank';
+          a.rel = 'noopener';
+          htmlOutput = a;
+        } else if (html) {
+          const fragment = document.createDocumentFragment();
       // Create temporary div to hold the nodes in.
-      var div = document.createElement('div');
-      div.innerHTML = html;
-      while (div.firstChild) {
-        fragment.appendChild(div.firstChild);
-      }
-      htmlOutput = fragment;
-    }
+          const div = document.createElement('div');
+          div.innerHTML = html;
+          while (div.firstChild) {
+            fragment.appendChild(div.firstChild);
+          }
+          htmlOutput = fragment;
+        }
 
-    outputArray.push({
-      html: htmlOutput,
-      position: position,
-      length: length,
-    });
-  };
+        outputArray.push({
+          html: htmlOutput,
+          position,
+          length,
+        });
+      };
 
   /**
    * Create a CommentLinkItem for a link and append it to the given output
@@ -171,9 +171,9 @@
    */
   GrLinkTextParser.prototype.addLink =
       function(text, href, position, length, outputArray) {
-    if (!text || this.hasOverlap(position, length, outputArray)) { return; }
-    this.addItem(text, href, null, position, length, outputArray);
-  };
+        if (!text || this.hasOverlap(position, length, outputArray)) { return; }
+        this.addItem(text, href, null, position, length, outputArray);
+      };
 
   /**
    * Create a CommentLinkItem specified by an HTMl string and append it to the
@@ -188,9 +188,9 @@
    */
   GrLinkTextParser.prototype.addHTML =
       function(html, position, length, outputArray) {
-    if (this.hasOverlap(position, length, outputArray)) { return; }
-    this.addItem(null, null, html, position, length, outputArray);
-  };
+        if (this.hasOverlap(position, length, outputArray)) { return; }
+        this.addItem(null, null, html, position, length, outputArray);
+      };
 
   /**
    * Does the given range overlap with anything already in the item list.
@@ -200,18 +200,18 @@
    */
   GrLinkTextParser.prototype.hasOverlap =
       function(position, length, outputArray) {
-    var endPosition = position + length;
-    for (var i = 0; i < outputArray.length; i++) {
-      var arrayItemStart = outputArray[i].position;
-      var arrayItemEnd = outputArray[i].position + outputArray[i].length;
-      if ((position >= arrayItemStart && position < arrayItemEnd) ||
+        const endPosition = position + length;
+        for (let i = 0; i < outputArray.length; i++) {
+          const arrayItemStart = outputArray[i].position;
+          const arrayItemEnd = outputArray[i].position + outputArray[i].length;
+          if ((position >= arrayItemStart && position < arrayItemEnd) ||
         (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
         (position === arrayItemStart && position === arrayItemEnd)) {
             return true;
-      }
-    }
-    return false;
-  };
+          }
+        }
+        return false;
+      };
 
   /**
    * Parse the given source text and emit callbacks for the items that are
@@ -241,9 +241,9 @@
       text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
     }
 
-    // If the href is provided then ba-linkify has recognized it as a URL. If the
-    // source text does not include a protocol, the protocol will be added by
-    // ba-linkify. Create the link if the href is provided and its protocol
+    // If the href is provided then ba-linkify has recognized it as a URL. If
+    // the source text does not include a protocol, the protocol will be added
+    // by ba-linkify. Create the link if the href is provided and its protocol
     // matches the expected pattern.
     if (href && URL_PROTOCOL_PATTERN.test(href)) {
       this.addText(text, href);
@@ -262,9 +262,10 @@
    *   object.
    */
   GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
-    // The outputArray is used to store all of the matches found for all patterns.
-    var outputArray = [];
-    for (var p in patterns) {
+    // The outputArray is used to store all of the matches found for all
+    // patterns.
+    const outputArray = [];
+    for (const p in patterns) {
       if (patterns[p].enabled != null && patterns[p].enabled == false) {
         continue;
       }
@@ -279,38 +280,37 @@
         }
       }
 
-      var pattern = new RegExp(patterns[p].match, 'g');
+      const pattern = new RegExp(patterns[p].match, 'g');
 
-      var match;
-      var textToCheck = text;
-      var susbtrIndex = 0;
+      let match;
+      let textToCheck = text;
+      let susbtrIndex = 0;
 
       while ((match = pattern.exec(textToCheck)) != null) {
         textToCheck = textToCheck.substr(match.index + match[0].length);
-        var result = match[0].replace(pattern,
+        let result = match[0].replace(pattern,
             patterns[p].html || patterns[p].link);
 
+        let i;
         // Skip portion of replacement string that is equal to original.
-        for (var i = 0; i < result.length; i++) {
-          if (result[i] !== match[0][i]) {
-            break;
-          }
+        for (i = 0; i < result.length; i++) {
+          if (result[i] !== match[0][i]) { break; }
         }
         result = result.slice(i);
 
         if (patterns[p].html) {
           this.addHTML(
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray);
+              result,
+              susbtrIndex + match.index + i,
+              match[0].length - i,
+              outputArray);
         } else if (patterns[p].link) {
           this.addLink(
-            match[0],
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray);
+              match[0],
+              result,
+              susbtrIndex + match.index + i,
+              match[0].length - i,
+              outputArray);
         } else {
           throw Error('linkconfig entry ' + p +
               ' doesn’t contain a link or html attribute.');
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
index 7df77ca..765345c 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
@@ -14,10 +14,11 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-input/iron-input.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 
@@ -68,10 +69,15 @@
     <div id="topContainer">
       <div class="filterContainer">
         <label>Filter:</label>
-        <input is="iron-input"
+        <iron-input
             type="text"
-            id="filter"
             bind-value="{{filter}}">
+          <input
+              is="iron-input"
+              type="text"
+              id="filter"
+              bind-value="{{filter}}">
+        </iron-input>
       </div>
       <div id="createNewContainer"
           class$="[[_computeCreateClass(createNew)]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
index 8b83eb3..2508196 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
@@ -21,6 +21,7 @@
 
   Polymer({
     is: 'gr-list-view',
+    _legacyUndefinedCheck: true,
 
     properties: {
       createNew: Boolean,
@@ -89,11 +90,11 @@
     },
 
     _hideNextArrow(loading, items) {
-      let lastPage = false;
-      if (items.length < this.itemsPerPage + 1) {
-        lastPage = true;
+      if (loading || !items || !items.length) {
+        return true;
       }
-      return loading || lastPage || !items || !items.length;
+      const lastPage = items.length < this.itemsPerPage + 1;
+      return lastPage;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
index 09e68dd..c67d8b2 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-list-view</title>
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-list-view.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
index e94b655..82c1169 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -15,8 +15,8 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-overlay">
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index 6df04a2..c167b3b 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -23,6 +23,7 @@
 
   Polymer({
     is: 'gr-overlay',
+    _legacyUndefinedCheck: true,
 
     /**
      * Fired when a fullscreen overlay is closed
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
index ee05b69..08b7497 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-overlay</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../test/common-test-setup.html"/>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
index 3885497..f1c3a6f 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-page-nav">
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
index 9ccff600..0962540 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-page-nav',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _headerHeight: Number,
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
index 428bab3..b384b47 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-page-nav</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/page/page.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../test/common-test-setup.html"/>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html
new file mode 100644
index 0000000..416815b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html
@@ -0,0 +1,60 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../../shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-repo-branch-picker">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+      }
+      gr-labeled-autocomplete,
+      iron-icon {
+        display: inline-block;
+      }
+      iron-icon {
+        margin-bottom: 1.2em;
+      }
+    </style>
+    <div>
+      <gr-labeled-autocomplete
+          id="repoInput"
+          label="Repository"
+          placeholder="Select repo"
+          on-commit="_repoCommitted"
+          query="[[_repoQuery]]">
+      </gr-labeled-autocomplete>
+      <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+      <gr-labeled-autocomplete
+          id="branchInput"
+          label="Branch"
+          placeholder="Select branch"
+          disabled="[[_branchDisabled]]"
+          on-commit="_branchCommitted"
+          query="[[_query]]">
+      </gr-labeled-autocomplete>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-repo-branch-picker.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
new file mode 100644
index 0000000..2fccc8d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  const SUGGESTIONS_LIMIT = 15;
+  const REF_PREFIX = 'refs/heads/';
+
+  Polymer({
+    is: 'gr-repo-branch-picker',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      repo: {
+        type: String,
+        notify: true,
+        observer: '_repoChanged',
+      },
+      branch: {
+        type: String,
+        notify: true,
+      },
+      _branchDisabled: Boolean,
+      _query: {
+        type: Function,
+        value() {
+          return this._getRepoBranchesSuggestions.bind(this);
+        },
+      },
+      _repoQuery: {
+        type: Function,
+        value() {
+          return this._getRepoSuggestions.bind(this);
+        },
+      },
+    },
+
+    behaviors: [
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    attached() {
+      if (this.repo) {
+        this.$.repoInput.setText(this.repo);
+      }
+    },
+
+    ready() {
+      this._branchDisabled = !this.repo;
+    },
+
+    _getRepoBranchesSuggestions(input) {
+      if (!this.repo) { return Promise.resolve([]); }
+      if (input.startsWith(REF_PREFIX)) {
+        input = input.substring(REF_PREFIX.length);
+      }
+      return this.$.restAPI.getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
+          .then(this._branchResponseToSuggestions.bind(this));
+    },
+
+    _getRepoSuggestions(input) {
+      return this.$.restAPI.getRepos(input, SUGGESTIONS_LIMIT)
+          .then(this._repoResponseToSuggestions.bind(this));
+    },
+
+    _repoResponseToSuggestions(res) {
+      return res.map(repo => ({
+        name: repo.name,
+        value: this.singleDecodeURL(repo.id),
+      }));
+    },
+
+    _branchResponseToSuggestions(res) {
+      return Object.keys(res).map(key => {
+        let branch = res[key].ref;
+        if (branch.startsWith(REF_PREFIX)) {
+          branch = branch.substring(REF_PREFIX.length);
+        }
+        return {name: branch, value: branch};
+      });
+    },
+
+    _repoCommitted(e) {
+      this.repo = e.detail.value;
+    },
+
+    _branchCommitted(e) {
+      this.branch = e.detail.value;
+    },
+
+    _repoChanged() {
+      this.$.branchInput.clear();
+      this._branchDisabled = !this.repo;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
new file mode 100644
index 0000000..1ed9151
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
@@ -0,0 +1,142 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-repo-branch-picker</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-repo-branch-picker.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-branch-picker></gr-repo-branch-picker>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo-branch-picker tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    suite('_getRepoSuggestions', () => {
+      setup(() => {
+        sandbox.stub(element.$.restAPI, 'getRepos')
+            .returns(Promise.resolve([
+              {
+                id: 'plugins%2Favatars-external',
+                name: 'plugins/avatars-external',
+              }, {
+                id: 'plugins%2Favatars-gravatar',
+                name: 'plugins/avatars-gravatar',
+              }, {
+                id: 'plugins%2Favatars%2Fexternal',
+                name: 'plugins/avatars/external',
+              }, {
+                id: 'plugins%2Favatars%2Fgravatar',
+                name: 'plugins/avatars/gravatar',
+              },
+            ]));
+      });
+
+      test('converts to suggestion objects', () => {
+        const input = 'plugins/avatars';
+        return element._getRepoSuggestions(input).then(suggestions => {
+          assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
+          const unencodedNames = [
+            'plugins/avatars-external',
+            'plugins/avatars-gravatar',
+            'plugins/avatars/external',
+            'plugins/avatars/gravatar',
+          ];
+          assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
+          assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
+        });
+      });
+    });
+
+    suite('_getRepoBranchesSuggestions', () => {
+      setup(() => {
+        sandbox.stub(element.$.restAPI, 'getRepoBranches')
+            .returns(Promise.resolve([
+              {ref: 'refs/heads/stable-2.10'},
+              {ref: 'refs/heads/stable-2.11'},
+              {ref: 'refs/heads/stable-2.12'},
+              {ref: 'refs/heads/stable-2.13'},
+              {ref: 'refs/heads/stable-2.14'},
+              {ref: 'refs/heads/stable-2.15'},
+            ]));
+      });
+
+      test('converts to suggestion objects', () => {
+        const repo = 'gerrit';
+        const branchInput = 'stable-2.1';
+        element.repo = repo;
+        return element._getRepoBranchesSuggestions(branchInput)
+            .then(suggestions => {
+              assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                  branchInput, repo, 15));
+              const refNames = [
+                'stable-2.10',
+                'stable-2.11',
+                'stable-2.12',
+                'stable-2.13',
+                'stable-2.14',
+                'stable-2.15',
+              ];
+              assert.deepEqual(suggestions.map(s => s.name), refNames);
+              assert.deepEqual(suggestions.map(s => s.value), refNames);
+            });
+      });
+
+      test('filters out ref prefix', () => {
+        const repo = 'gerrit';
+        const branchInput = 'refs/heads/stable-2.1';
+        element.repo = repo;
+        return element._getRepoBranchesSuggestions(branchInput)
+            .then(suggestions => {
+              assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                  'stable-2.1', repo, 15));
+            });
+      });
+
+      test('does not query when repo is unset', () => {
+        return element._getRepoBranchesSuggestions('')
+            .then(() => {
+              assert.isFalse(element.$.restAPI.getRepoBranches.called);
+              element.repo = 'gerrit';
+              return element._getRepoBranchesSuggestions('');
+            })
+            .then(() => {
+              assert.isTrue(element.$.restAPI.getRepoBranches.called);
+            });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
index a571be9..cfdc6ee 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-auth</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="../../../behaviors/base-url-behavior/base-url-behavior.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
index c5a0dfe..d3500d8 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <dom-module id="gr-etag-decorator">
   <script src="gr-etag-decorator.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
index 09ae1da..76c8c2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-etag-decorator</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 
 <script src="gr-etag-decorator.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index 562980c..607802f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
@@ -23,8 +23,8 @@
 <link rel="import" href="gr-etag-decorator.html">
 
 <!-- NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 -->
-<script src="../../../bower_components/es6-promise/dist/es6-promise.min.js"></script>
-<script src="../../../bower_components/fetch/fetch.js"></script>
+<script src="/bower_components/es6-promise/dist/es6-promise.min.js"></script>
+<script src="/bower_components/fetch/fetch.js"></script>
 
 <dom-module id="gr-rest-api-interface">
   <!-- NB: Order is important, because of namespaced classes. -->
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 4fd47f1..c57a1eb 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -28,6 +28,15 @@
   Defs.patchRange;
 
   /**
+   * @typedef {{
+   *    url: string,
+   *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   * }}
+   */
+  Defs.FetchRequest;
+
+  /**
    * Object to describe a request for passing into _fetchJSON or _fetchRawJSON.
    * - url is the URL for the request (excluding get params)
    * - errFn is a function to invoke when the request fails.
@@ -40,6 +49,8 @@
    *    cancelCondition: (function()|null|undefined),
    *    params: (Object|null|undefined),
    *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   *    reportUrlAsIs: (boolean|undefined),
    * }}
    */
   Defs.FetchJSONRequest;
@@ -50,9 +61,10 @@
    *   endpoint: string,
    *   patchNum: (string|number|null|undefined),
    *   errFn: (function(?Response, string=)|null|undefined),
-   *   cancelCondition: (function()|null|undefined),
    *   params: (Object|null|undefined),
    *   fetchOptions: (Object|null|undefined),
+   *   anonymizedEndpoint: (string|undefined),
+   *   reportEndpointAsIs: (boolean|undefined),
    * }}
    */
   Defs.ChangeFetchRequest;
@@ -78,10 +90,29 @@
    *   contentType: (string|null|undefined),
    *   headers: (Object|undefined),
    *   parseResponse: (boolean|undefined),
+   *   anonymizedUrl: (string|undefined),
+   *   reportUrlAsIs: (boolean|undefined),
    * }}
    */
   Defs.SendRequest;
 
+  /**
+   * @typedef {{
+   *   changeNum: (string|number),
+   *   method: string,
+   *   patchNum: (string|number|undefined),
+   *   endpoint: string,
+   *   body: (string|number|Object|null|undefined),
+   *   errFn: (function(?Response, string=)|null|undefined),
+   *   contentType: (string|null|undefined),
+   *   headers: (Object|undefined),
+   *   parseResponse: (boolean|undefined),
+   *   anonymizedEndpoint: (string|undefined),
+   *   reportEndpointAsIs: (boolean|undefined),
+   * }}
+   */
+  Defs.ChangeSendRequest;
+
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
@@ -90,8 +121,6 @@
   const MAX_PROJECT_RESULTS = 25;
   const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
   const PARENT_PATCH_NUM = 'PARENT';
-  const CHECK_SIGN_IN_DEBOUNCE_MS = 3 * 1000;
-  const CHECK_SIGN_IN_DEBOUNCER_NAME = 'checkCredentials';
   const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
 
   const Requests = {
@@ -102,9 +131,67 @@
       'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
   const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
 
+  const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
+  const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
+      '/revisions/*';
+
+  /**
+   * Wrapper around Map for caching server responses. Site-based so that
+   * changes to CANONICAL_PATH will result in a different cache going into
+   * effect.
+   */
+  class SiteBasedCache {
+    constructor() {
+      // Container of per-canonical-path caches.
+      this._data = new Map();
+      if (window.INITIAL_DATA != undefined) {
+        // Put all data shipped with index.html into the cache. This makes it
+        // so that we spare more round trips to the server when the app loads
+        // initially.
+        Object
+            .entries(window.INITIAL_DATA)
+            .forEach(e => this._cache().set(e[0], e[1]));
+      }
+    }
+
+    // Returns the cache for the current canonical path.
+    _cache() {
+      if (!this._data.has(window.CANONICAL_PATH)) {
+        this._data.set(window.CANONICAL_PATH, new Map());
+      }
+      return this._data.get(window.CANONICAL_PATH);
+    }
+
+    has(key) {
+      return this._cache().has(key);
+    }
+
+    get(key) {
+      return this._cache().get(key);
+    }
+
+    set(key, value) {
+      this._cache().set(key, value);
+    }
+
+    delete(key) {
+      this._cache().delete(key);
+    }
+
+    invalidatePrefix(prefix) {
+      const newMap = new Map();
+      for (const [key, value] of this._cache().entries()) {
+        if (!key.startsWith(prefix)) {
+          newMap.set(key, value);
+        }
+      }
+      this._data.set(window.CANONICAL_PATH, newMap);
+    }
+  }
 
   Polymer({
     is: 'gr-rest-api-interface',
+    _legacyUndefinedCheck: true,
 
     behaviors: [
       Gerrit.PathListBehavior,
@@ -130,10 +217,20 @@
      * @event auth-error
      */
 
+    /**
+     * Fired after an RPC completes.
+     *
+     * @event rpc-log
+     */
+
     properties: {
       _cache: {
         type: Object,
-        value: {}, // Intentional to share the object across instances.
+        value: new SiteBasedCache(), // Shared across instances.
+      },
+      _credentialCheck: {
+        type: Object,
+        value: {checking: false}, // Shared across instances.
       },
       _sharedFetchPromises: {
         type: Object,
@@ -165,15 +262,14 @@
     /**
      * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
      * with timing and logging.
-     * @param {string} url
-     * @param {Object=} opt_fetchOptions
+     * @param {Defs.FetchRequest} req
      */
-    _fetch(url, opt_fetchOptions) {
+    _fetch(req) {
       const start = Date.now();
-      const xhr = this._auth.fetch(url, opt_fetchOptions);
+      const xhr = this._auth.fetch(req.url, req.fetchOptions);
 
       // Log the call after it completes.
-      xhr.then(res => this._logCall(url, opt_fetchOptions, start, res.status));
+      xhr.then(res => this._logCall(req, start, res.status));
 
       // Return the XHR directly (without the log).
       return xhr;
@@ -183,18 +279,27 @@
      * Log information about a REST call. Because the elapsed time is determined
      * by this method, it should be called immediately after the request
      * finishes.
-     * @param {string} url
-     * @param {Object|undefined} fetchOptions
+     * @param {Defs.FetchRequest} req
      * @param {number} startTime the time that the request was started.
      * @param {number} status the HTTP status of the response. The status value
      *     is used here rather than the response object so there is no way this
      *     method can read the body stream.
      */
-    _logCall(url, fetchOptions, startTime, status) {
-      const method = (fetchOptions && fetchOptions.method) ?
-          fetchOptions.method : 'GET';
-      const elapsed = (Date.now() - startTime) + 'ms';
-      console.log(['HTTP', status, method, elapsed, url].join(' '));
+    _logCall(req, startTime, status) {
+      const method = (req.fetchOptions && req.fetchOptions.method) ?
+          req.fetchOptions.method : 'GET';
+      const elapsed = (Date.now() - startTime);
+      console.log([
+        'HTTP',
+        status,
+        method,
+        elapsed + 'ms',
+        req.anonymizedUrl || req.url,
+      ].join(' '));
+      if (req.anonymizedUrl) {
+        this.fire('rpc-log',
+            {status, method, elapsed, anonymizedUrl: req.anonymizedUrl});
+      }
     },
 
     /**
@@ -206,26 +311,27 @@
      */
     _fetchRawJSON(req) {
       const urlWithParams = this._urlWithParams(req.url, req.params);
-      return this._fetch(urlWithParams, req.fetchOptions).then(res => {
+      const fetchReq = {
+        url: urlWithParams,
+        fetchOptions: req.fetchOptions,
+        anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
+      };
+      return this._fetch(fetchReq).then(res => {
         if (req.cancelCondition && req.cancelCondition()) {
           res.body.cancel();
           return;
         }
         return res;
       }).catch(err => {
-        const isLoggedIn = !!this._cache['/accounts/self/detail'];
+        const isLoggedIn = !!this._cache.get('/accounts/self/detail');
         if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
-          if (!this.isDebouncerActive(CHECK_SIGN_IN_DEBOUNCER_NAME)) {
-            this.checkCredentials();
-          }
-          this.debounce(CHECK_SIGN_IN_DEBOUNCER_NAME, this.checkCredentials,
-              CHECK_SIGN_IN_DEBOUNCE_MS);
-          return;
-        }
-        if (req.errFn) {
-          req.errFn.call(undefined, null, err);
+          this.checkCredentials();
         } else {
-          this.fire('network-error', {error: err});
+          if (req.errFn) {
+            req.errFn.call(undefined, null, err);
+          } else {
+            this.fire('network-error', {error: err});
+          }
         }
         throw err;
       });
@@ -247,7 +353,7 @@
             req.errFn.call(null, response);
             return;
           }
-          this.fire('server-error', {response});
+          this.fire('server-error', {request: req, response});
           return;
         }
         return response && this.getResponseObject(response);
@@ -256,7 +362,7 @@
 
     /**
      * @param {string} url
-     * @param {?Object=} opt_params URL params, key-value hash.
+     * @param {?Object|string=} opt_params URL params, key-value hash.
      * @return {string}
      */
     _urlWithParams(url, opt_params) {
@@ -311,10 +417,16 @@
 
     getConfig(noCache) {
       if (!noCache) {
-        return this._fetchSharedCacheURL({url: '/config/server/info'});
+        return this._fetchSharedCacheURL({
+          url: '/config/server/info',
+          reportUrlAsIs: true,
+        });
       }
 
-      return this._fetchJSON({url: '/config/server/info'});
+      return this._fetchJSON({
+        url: '/config/server/info',
+        reportUrlAsIs: true,
+      });
     },
 
     getRepo(repo, opt_errFn) {
@@ -323,6 +435,7 @@
       return this._fetchSharedCacheURL({
         url: '/projects/' + encodeURIComponent(repo),
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*',
       });
     },
 
@@ -332,6 +445,7 @@
       return this._fetchSharedCacheURL({
         url: '/projects/' + encodeURIComponent(repo) + '/config',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/config',
       });
     },
 
@@ -340,6 +454,7 @@
       // supports it.
       return this._fetchSharedCacheURL({
         url: '/access/?project=' + encodeURIComponent(repo),
+        anonymizedUrl: '/access/?project=*',
       });
     },
 
@@ -349,18 +464,21 @@
       return this._fetchSharedCacheURL({
         url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/dashboards?inherited',
       });
     },
 
     saveRepoConfig(repo, config, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      const encodeName = encodeURIComponent(repo);
+      const url = `/projects/${encodeURIComponent(repo)}/config`;
+      this._cache.delete(url);
       return this._send({
         method: 'PUT',
-        url: `/projects/${encodeName}/config`,
+        url,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/config',
       });
     },
 
@@ -374,6 +492,7 @@
         url: `/projects/${encodeName}/gc`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/gc',
       });
     },
 
@@ -391,6 +510,7 @@
         url: `/projects/${encodeName}`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*',
       });
     },
 
@@ -406,6 +526,7 @@
         url: `/groups/${encodeName}`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*',
       });
     },
 
@@ -413,6 +534,7 @@
       return this._fetchJSON({
         url: `/groups/${encodeURIComponent(group)}/detail`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/detail',
       });
     },
 
@@ -432,6 +554,7 @@
         url: `/projects/${encodeName}/branches/${encodeRef}`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches/*',
       });
     },
 
@@ -451,6 +574,7 @@
         url: `/projects/${encodeName}/tags/${encodeRef}`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags/*',
       });
     },
 
@@ -471,6 +595,7 @@
         url: `/projects/${encodeName}/branches/${encodeBranch}`,
         body: revision,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches/*',
       });
     },
 
@@ -491,6 +616,7 @@
         url: `/projects/${encodeName}/tags/${encodeTag}`,
         body: revision,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags/*',
       });
     },
 
@@ -500,7 +626,11 @@
      */
     getIsGroupOwner(groupName) {
       const encodeName = encodeURIComponent(groupName);
-      return this._fetchSharedCacheURL({url: `/groups/?owned&q=${encodeName}`})
+      const req = {
+        url: `/groups/?owned&q=${encodeName}`,
+        anonymizedUrl: '/groups/owned&q=*',
+      };
+      return this._fetchSharedCacheURL(req)
           .then(configs => configs.hasOwnProperty(groupName));
     },
 
@@ -509,12 +639,15 @@
       return this._fetchJSON({
         url: `/groups/${encodeName}/members/`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/members',
       });
     },
 
     getIncludedGroup(groupName) {
-      const encodeName = encodeURIComponent(groupName);
-      return this._fetchJSON({url: `/groups/${encodeName}/groups/`});
+      return this._fetchJSON({
+        url: `/groups/${encodeURIComponent(groupName)}/groups/`,
+        anonymizedUrl: '/groups/*/groups',
+      });
     },
 
     saveGroupName(groupId, name) {
@@ -523,6 +656,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/name`,
         body: {name},
+        anonymizedUrl: '/groups/*/name',
       });
     },
 
@@ -532,6 +666,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/owner`,
         body: {owner: ownerId},
+        anonymizedUrl: '/groups/*/owner',
       });
     },
 
@@ -541,6 +676,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/description`,
         body: {description},
+        anonymizedUrl: '/groups/*/description',
       });
     },
 
@@ -550,6 +686,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/options`,
         body: options,
+        anonymizedUrl: '/groups/*/options',
       });
     },
 
@@ -557,6 +694,7 @@
       return this._fetchSharedCacheURL({
         url: '/groups/' + group + '/log.audit',
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/log.audit',
       });
     },
 
@@ -567,6 +705,7 @@
         method: 'PUT',
         url: `/groups/${encodeName}/members/${encodeMember}`,
         parseResponse: true,
+        anonymizedUrl: '/groups/*/members/*',
       });
     },
 
@@ -577,6 +716,7 @@
         method: 'PUT',
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/groups/*',
       };
       return this._send(req).then(response => {
         if (response.ok) {
@@ -591,6 +731,7 @@
       return this._send({
         method: 'DELETE',
         url: `/groups/${encodeName}/members/${encodeMember}`,
+        anonymizedUrl: '/groups/*/members/*',
       });
     },
 
@@ -600,11 +741,15 @@
       return this._send({
         method: 'DELETE',
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+        anonymizedUrl: '/groups/*/groups/*',
       });
     },
 
     getVersion() {
-      return this._fetchSharedCacheURL({url: '/config/server/version'});
+      return this._fetchSharedCacheURL({
+        url: '/config/server/version',
+        reportUrlAsIs: true,
+      });
     },
 
     getDiffPreferences() {
@@ -612,6 +757,7 @@
         if (loggedIn) {
           return this._fetchSharedCacheURL({
             url: '/accounts/self/preferences.diff',
+            reportUrlAsIs: true,
           });
         }
         // These defaults should match the defaults in
@@ -642,6 +788,7 @@
         if (loggedIn) {
           return this._fetchSharedCacheURL({
             url: '/accounts/self/preferences.edit',
+            reportUrlAsIs: true,
           });
         }
         // These defaults should match the defaults in
@@ -683,6 +830,7 @@
         url: '/accounts/self/preferences',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -692,12 +840,13 @@
      */
     saveDiffPreferences(prefs, opt_errFn) {
       // Invalidate the cache.
-      this._cache['/accounts/self/preferences.diff'] = undefined;
+      this._cache.delete('/accounts/self/preferences.diff');
       return this._send({
         method: 'PUT',
         url: '/accounts/self/preferences.diff',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -707,28 +856,45 @@
      */
     saveEditPreferences(prefs, opt_errFn) {
       // Invalidate the cache.
-      this._cache['/accounts/self/preferences.edit'] = undefined;
+      this._cache.delete('/accounts/self/preferences.edit');
       return this._send({
         method: 'PUT',
         url: '/accounts/self/preferences.edit',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
     getAccount() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/detail',
+        reportUrlAsIs: true,
         errFn: resp => {
           if (!resp || resp.status === 403) {
-            this._cache['/accounts/self/detail'] = null;
+            this._cache.delete('/accounts/self/detail');
+          }
+        },
+      });
+    },
+
+    getAvatarChangeUrl() {
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/avatar.change.url',
+        reportUrlAsIs: true,
+        errFn: resp => {
+          if (!resp || resp.status === 403) {
+            this._cache.delete('/accounts/self/avatar.change.url');
           }
         },
       });
     },
 
     getExternalIds() {
-      return this._fetchJSON({url: '/accounts/self/external.ids'});
+      return this._fetchJSON({
+        url: '/accounts/self/external.ids',
+        reportUrlAsIs: true,
+      });
     },
 
     deleteAccountIdentity(id) {
@@ -737,6 +903,7 @@
         url: '/accounts/self/external.ids:delete',
         body: id,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -747,11 +914,15 @@
     getAccountDetails(userId) {
       return this._fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/detail`,
+        anonymizedUrl: '/accounts/*/detail',
       });
     },
 
     getAccountEmails() {
-      return this._fetchSharedCacheURL({url: '/accounts/self/emails'});
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/emails',
+        reportUrlAsIs: true,
+      });
     },
 
     /**
@@ -763,6 +934,7 @@
         method: 'PUT',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
+        anonymizedUrl: '/account/self/emails/*',
       });
     },
 
@@ -775,6 +947,7 @@
         method: 'DELETE',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
+        anonymizedUrl: '/accounts/self/email/*',
       });
     },
 
@@ -784,11 +957,16 @@
      */
     setPreferredAccountEmail(email, opt_errFn) {
       const encodedEmail = encodeURIComponent(email);
-      const url = `/accounts/self/emails/${encodedEmail}/preferred`;
-      return this._send({method: 'PUT', url, errFn: opt_errFn}).then(() => {
+      const req = {
+        method: 'PUT',
+        url: `/accounts/self/emails/${encodedEmail}/preferred`,
+        errFn: opt_errFn,
+        anonymizedUrl: '/accounts/self/emails/*/preferred',
+      };
+      return this._send(req).then(() => {
         // If result of getAccountEmails is in cache, update it in the cache
         // so we don't have to invalidate it.
-        const cachedEmails = this._cache['/accounts/self/emails'];
+        const cachedEmails = this._cache.get('/accounts/self/emails');
         if (cachedEmails) {
           const emails = cachedEmails.map(entry => {
             if (entry.email === email) {
@@ -797,7 +975,7 @@
               return {email};
             }
           });
-          this._cache['/accounts/self/emails'] = emails;
+          this._cache.set('/accounts/self/emails', emails);
         }
       });
     },
@@ -808,11 +986,11 @@
     _updateCachedAccount(obj) {
       // If result of getAccount is in cache, update it in the cache
       // so we don't have to invalidate it.
-      const cachedAccount = this._cache['/accounts/self/detail'];
+      const cachedAccount = this._cache.get('/accounts/self/detail');
       if (cachedAccount) {
         // Replace object in cache with new object to force UI updates.
-        this._cache['/accounts/self/detail'] =
-            Object.assign({}, cachedAccount, obj);
+        this._cache.set('/accounts/self/detail',
+            Object.assign({}, cachedAccount, obj));
       }
     },
 
@@ -827,6 +1005,7 @@
         body: {name},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newName => this._updateCachedAccount({name: newName}));
@@ -843,6 +1022,7 @@
         body: {username},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newName => this._updateCachedAccount({username: newName}));
@@ -859,6 +1039,7 @@
         body: {status},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newStatus => this._updateCachedAccount({status: newStatus}));
@@ -867,15 +1048,22 @@
     getAccountStatus(userId) {
       return this._fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/status`,
+        anonymizedUrl: '/accounts/*/status',
       });
     },
 
     getAccountGroups() {
-      return this._fetchJSON({url: '/accounts/self/groups'});
+      return this._fetchJSON({
+        url: '/accounts/self/groups',
+        reportUrlAsIs: true,
+      });
     },
 
     getAccountAgreements() {
-      return this._fetchJSON({url: '/accounts/self/agreements'});
+      return this._fetchJSON({
+        url: '/accounts/self/agreements',
+        reportUrlAsIs: true,
+      });
     },
 
     saveAccountAgreement(name) {
@@ -883,6 +1071,7 @@
         method: 'PUT',
         url: '/accounts/self/agreements',
         body: name,
+        reportUrlAsIs: true,
       });
     },
 
@@ -898,6 +1087,7 @@
       }
       return this._fetchSharedCacheURL({
         url: '/accounts/self/capabilities' + queryString,
+        anonymizedUrl: '/accounts/self/capabilities?q=*',
       });
     },
 
@@ -920,39 +1110,54 @@
     },
 
     checkCredentials() {
+      if (this._credentialCheck.checking) {
+        return;
+      }
+      this._credentialCheck.checking = true;
+      const req = {url: '/accounts/self/detail', reportUrlAsIs: true};
       // Skip the REST response cache.
-      return this._fetchRawJSON({url: '/accounts/self/detail'}).then(res => {
+      return this._fetchRawJSON(req).then(res => {
         if (!res) { return; }
         if (res.status === 403) {
           this.fire('auth-error');
-          this._cache['/accounts/self/detail'] = null;
+          this._cache.delete('/accounts/self/detail');
         } else if (res.ok) {
           return this.getResponseObject(res);
         }
       }).then(res => {
+        this._credentialCheck.checking = false;
         if (res) {
-          this._cache['/accounts/self/detail'] = res;
+          this._cache.set('/accounts/self/detail', res);
         }
         return res;
+      }).catch(err => {
+        this._credentialCheck.checking = false;
+        if (err && err.message === FAILED_TO_FETCH_ERROR) {
+          this.fire('auth-error');
+          this._cache.delete('/accounts/self/detail');
+        }
       });
     },
 
     getDefaultPreferences() {
-      return this._fetchSharedCacheURL({url: '/config/server/preferences'});
+      return this._fetchSharedCacheURL({
+        url: '/config/server/preferences',
+        reportUrlAsIs: true,
+      });
     },
 
     getPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL({url: '/accounts/self/preferences'})
-              .then(res => {
-                if (this._isNarrowScreen()) {
-                  res.default_diff_view = DiffViewMode.UNIFIED;
-                } else {
-                  res.default_diff_view = res.diff_view;
-                }
-                return Promise.resolve(res);
-              });
+          const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
+          return this._fetchSharedCacheURL(req).then(res => {
+            if (this._isNarrowScreen()) {
+              res.default_diff_view = DiffViewMode.UNIFIED;
+            } else {
+              res.default_diff_view = res.diff_view;
+            }
+            return Promise.resolve(res);
+          });
         }
 
         return Promise.resolve({
@@ -968,6 +1173,7 @@
     getWatchedProjects() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/watched.projects',
+        reportUrlAsIs: true,
       });
     },
 
@@ -982,6 +1188,7 @@
         body: projects,
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -995,6 +1202,7 @@
         url: '/accounts/self/watched.projects:delete',
         body: projects,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1006,13 +1214,13 @@
         return this._sharedFetchPromises[req.url];
       }
       // TODO(andybons): Periodic cache invalidation.
-      if (this._cache[req.url] !== undefined) {
-        return Promise.resolve(this._cache[req.url]);
+      if (this._cache.has(req.url)) {
+        return Promise.resolve(this._cache.get(req.url));
       }
       this._sharedFetchPromises[req.url] = this._fetchJSON(req)
           .then(response => {
             if (response !== undefined) {
-              this._cache[req.url] = response;
+              this._cache.set(req.url, response);
             }
             this._sharedFetchPromises[req.url] = undefined;
             return response;
@@ -1023,6 +1231,20 @@
       return this._sharedFetchPromises[req.url];
     },
 
+    /**
+     * @param {string} prefix
+     */
+    _invalidateSharedFetchPromisesPrefix(prefix) {
+      const newObject = {};
+      Object.entries(this._sharedFetchPromises).forEach(([key, value]) => {
+        if (!key.startsWith(prefix)) {
+          newObject[key] = value;
+        }
+      });
+      this._sharedFetchPromises = newObject;
+      this._cache.invalidatePrefix(prefix);
+    },
+
     _isNarrowScreen() {
       return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
     },
@@ -1059,7 +1281,12 @@
           this._maybeInsertInLookup(change);
         }
       };
-      return this._fetchJSON({url: '/changes/', params}).then(response => {
+      const req = {
+        url: '/changes/',
+        params,
+        reportUrlAsIs: true,
+      };
+      return this._fetchJSON(req).then(response => {
         // Response may be an array of changes OR an array of arrays of
         // changes.
         if (opt_query instanceof Array) {
@@ -1108,21 +1335,30 @@
      * @param {function()=} opt_cancelCondition
      */
     getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
-      const options = this.listChangesOptionsToHex(
-          this.ListChangesOption.ALL_COMMITS,
-          this.ListChangesOption.ALL_REVISIONS,
-          this.ListChangesOption.CHANGE_ACTIONS,
-          this.ListChangesOption.CURRENT_ACTIONS,
-          this.ListChangesOption.DETAILED_LABELS,
-          this.ListChangesOption.DOWNLOAD_COMMANDS,
-          this.ListChangesOption.MESSAGES,
-          this.ListChangesOption.SUBMITTABLE,
-          this.ListChangesOption.WEB_LINKS,
-          this.ListChangesOption.SKIP_MERGEABLE
-      );
-      return this._getChangeDetail(
-          changeNum, options, opt_errFn, opt_cancelCondition)
-          .then(GrReviewerUpdatesParser.parse);
+      // This list MUST be kept in sync with
+      // ChangeIT#changeDetailsDoesNotRequireIndex
+      const options = [
+        this.ListChangesOption.ALL_COMMITS,
+        this.ListChangesOption.ALL_REVISIONS,
+        this.ListChangesOption.CHANGE_ACTIONS,
+        this.ListChangesOption.CURRENT_ACTIONS,
+        this.ListChangesOption.DETAILED_LABELS,
+        this.ListChangesOption.DOWNLOAD_COMMANDS,
+        this.ListChangesOption.MESSAGES,
+        this.ListChangesOption.SUBMITTABLE,
+        this.ListChangesOption.WEB_LINKS,
+        this.ListChangesOption.SKIP_MERGEABLE,
+        this.ListChangesOption.SKIP_DIFFSTAT,
+      ];
+      return this.getConfig(false).then(config => {
+        if (config.receive && config.receive.enable_signed_push) {
+          options.push(this.ListChangesOption.PUSH_CERTIFICATES);
+        }
+        const optionsHex = this.listChangesOptionsToHex(...options);
+        return this._getChangeDetail(
+            changeNum, optionsHex, opt_errFn, opt_cancelCondition)
+            .then(GrReviewerUpdatesParser.parse);
+      });
     },
 
     /**
@@ -1131,28 +1367,33 @@
      * @param {function()=} opt_cancelCondition
      */
     getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
-      const params = this.listChangesOptionsToHex(
+      const optionsHex = this.listChangesOptionsToHex(
           this.ListChangesOption.ALL_COMMITS,
-          this.ListChangesOption.ALL_REVISIONS
+          this.ListChangesOption.ALL_REVISIONS,
+          this.ListChangesOption.SKIP_MERGEABLE,
+          this.ListChangesOption.SKIP_DIFFSTAT
       );
-      return this._getChangeDetail(changeNum, params, opt_errFn,
+      return this._getChangeDetail(changeNum, optionsHex, opt_errFn,
           opt_cancelCondition);
     },
 
     /**
      * @param {number|string} changeNum
+     * @param {string|undefined} optionsHex list changes options in hex
      * @param {function(?Response, string=)=} opt_errFn
      * @param {function()=} opt_cancelCondition
      */
-    _getChangeDetail(changeNum, params, opt_errFn, opt_cancelCondition) {
+    _getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) {
       return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
-        const urlWithParams = this._urlWithParams(url, params);
+        const urlWithParams = this._urlWithParams(url, optionsHex);
+        const params = {O: optionsHex};
         const req = {
           url,
           errFn: opt_errFn,
           cancelCondition: opt_cancelCondition,
-          params: {O: params},
+          params,
           fetchOptions: this._etags.getOptions(urlWithParams),
+          anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
         };
         return this._fetchRawJSON(req).then(response => {
           if (response && response.status === 304) {
@@ -1164,7 +1405,7 @@
             if (opt_errFn) {
               opt_errFn.call(null, response);
             } else {
-              this.fire('server-error', {response});
+              this.fire('server-error', {request: req, response});
             }
             return;
           }
@@ -1193,6 +1434,7 @@
         changeNum,
         endpoint: '/commit?links',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1213,6 +1455,7 @@
         endpoint: '/files',
         patchNum: patchRange.patchNum,
         params,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1222,10 +1465,16 @@
      */
     getChangeEditFiles(changeNum, patchRange) {
       let endpoint = '/edit?list';
+      let anonymizedEndpoint = endpoint;
       if (patchRange.basePatchNum !== 'PARENT') {
         endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
+        anonymizedEndpoint += '&base=*';
       }
-      return this._getChangeURLAndFetch({changeNum, endpoint});
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint,
+        anonymizedEndpoint,
+      });
     },
 
     /**
@@ -1239,6 +1488,7 @@
         changeNum,
         endpoint: `/files?q=${encodeURIComponent(query)}`,
         patchNum,
+        anonymizedEndpoint: '/files?q=*',
       });
     },
 
@@ -1267,7 +1517,12 @@
     },
 
     getChangeRevisionActions(changeNum, patchNum) {
-      const req = {changeNum, endpoint: '/actions', patchNum};
+      const req = {
+        changeNum,
+        endpoint: '/actions',
+        patchNum,
+        reportEndpointAsIs: true,
+      };
       return this._getChangeURLAndFetch(req).then(revisionActions => {
         // The rebase button on change screen is always enabled.
         if (revisionActions.rebase) {
@@ -1285,13 +1540,16 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
-      const params = {n: 10};
+      // More suggestions may obscure content underneath in the reply dialog,
+      // see issue 10793.
+      const params = {n: 6};
       if (inputVal) { params.q = inputVal; }
       return this._getChangeURLAndFetch({
         changeNum,
         endpoint: '/suggest_reviewers',
         errFn: opt_errFn,
         params,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1299,7 +1557,11 @@
      * @param {number|string} changeNum
      */
     getChangeIncludedIn(changeNum) {
-      return this._getChangeURLAndFetch({changeNum, endpoint: '/in'});
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/in',
+        reportEndpointAsIs: true,
+      });
     },
 
     _computeFilter(filter) {
@@ -1317,14 +1579,70 @@
      * @param {string} filter
      * @param {number} groupsPerPage
      * @param {number=} opt_offset
+     */
+    _getGroupsUrl(filter, groupsPerPage, opt_offset) {
+      const offset = opt_offset || 0;
+
+      return `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
+        this._computeFilter(filter);
+    },
+
+    /**
+     * @param {string} filter
+     * @param {number} reposPerPage
+     * @param {number=} opt_offset
+     */
+    _getReposUrl(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);
+
+      return `/projects/?n=${reposPerPage + 1}&S=${offset}` +
+        `&query=${encodedFilter}`;
+    },
+
+    invalidateGroupsCache() {
+      this._invalidateSharedFetchPromisesPrefix('/groups/?');
+    },
+
+    invalidateReposCache() {
+      this._invalidateSharedFetchPromisesPrefix('/projects/?');
+    },
+
+    /**
+     * @param {string} filter
+     * @param {number} groupsPerPage
+     * @param {number=} opt_offset
      * @return {!Promise<?Object>}
      */
     getGroups(filter, groupsPerPage, opt_offset) {
-      const offset = opt_offset || 0;
+      const url = this._getGroupsUrl(filter, groupsPerPage, opt_offset);
 
       return this._fetchSharedCacheURL({
-        url: `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
-            this._computeFilter(filter),
+        url,
+        anonymizedUrl: '/groups/?*',
       });
     },
 
@@ -1335,13 +1653,13 @@
      * @return {!Promise<?Object>}
      */
     getRepos(filter, reposPerPage, opt_offset) {
-      const offset = opt_offset || 0;
+      const url = this._getReposUrl(filter, reposPerPage, opt_offset);
 
       // 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,
+        anonymizedUrl: '/projects/?*',
       });
     },
 
@@ -1352,6 +1670,7 @@
         method: 'PUT',
         url: `/projects/${encodeURIComponent(repo)}/HEAD`,
         body: {ref},
+        anonymizedUrl: '/projects/*/HEAD',
       });
     },
 
@@ -1371,7 +1690,11 @@
       const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches?*',
+      });
     },
 
     /**
@@ -1391,7 +1714,11 @@
           encodedFilter;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags',
+      });
     },
 
     /**
@@ -1406,7 +1733,11 @@
       const encodedFilter = this._computeFilter(filter);
       const n = pluginsPerPage + 1;
       const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/plugins/?all',
+      });
     },
 
     getRepoAccessRights(repoName, opt_errFn) {
@@ -1415,6 +1746,7 @@
       return this._fetchJSON({
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/access',
       });
     },
 
@@ -1425,6 +1757,7 @@
         method: 'POST',
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         body: repoInfo,
+        anonymizedUrl: '/projects/*/access',
       });
     },
 
@@ -1434,6 +1767,7 @@
         url: `/projects/${encodeURIComponent(projectName)}/access:review`,
         body: projectInfo,
         parseResponse: true,
+        anonymizedUrl: '/projects/*/access:review',
       });
     },
 
@@ -1449,6 +1783,7 @@
         url: '/groups/',
         errFn: opt_errFn,
         params,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1468,6 +1803,7 @@
         url: '/projects/',
         errFn: opt_errFn,
         params,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1486,6 +1822,7 @@
         url: '/accounts/',
         errFn: opt_errFn,
         params,
+        anonymizedUrl: '/accounts/?n=*',
       });
     },
 
@@ -1521,13 +1858,15 @@
         changeNum,
         endpoint: '/related',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
     getChangesSubmittedTogether(changeNum) {
       return this._getChangeURLAndFetch({
         changeNum,
-        endpoint: '/submitted_together',
+        endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1540,7 +1879,11 @@
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/conflicts:*',
+      });
     },
 
     getChangeCherryPicks(project, changeID, changeNum) {
@@ -1558,21 +1901,34 @@
         O: options,
         q: query,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/change:*',
+      });
     },
 
-    getChangesWithSameTopic(topic) {
+    getChangesWithSameTopic(topic, changeNum) {
       const options = this.listChangesOptionsToHex(
           this.ListChangesOption.LABELS,
           this.ListChangesOption.CURRENT_REVISION,
           this.ListChangesOption.CURRENT_COMMIT,
           this.ListChangesOption.DETAILED_LABELS
       );
+      const query = [
+        'status:open',
+        '-change:' + changeNum,
+        `topic:"${topic}"`,
+      ].join(' ');
       const params = {
         O: options,
-        q: 'status:open topic:' + topic,
+        q: query,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/topic:*',
+      });
     },
 
     getReviewedFiles(changeNum, patchNum) {
@@ -1580,6 +1936,7 @@
         changeNum,
         endpoint: '/files?reviewed',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1591,10 +1948,14 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn) {
-      const method = reviewed ? 'PUT' : 'DELETE';
-      const endpoint = `/files/${encodeURIComponent(path)}/reviewed`;
-      return this._getChangeURLAndSend(changeNum, method, patchNum, endpoint,
-          null, opt_errFn);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: reviewed ? 'PUT' : 'DELETE',
+        patchNum,
+        endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
+        errFn: opt_errFn,
+        anonymizedEndpoint: '/files/*/reviewed',
+      });
     },
 
     /**
@@ -1626,6 +1987,7 @@
           changeNum,
           endpoint: '/edit/',
           params,
+          reportEndpointAsIs: true,
         });
       });
     },
@@ -1656,6 +2018,7 @@
           base_commit: opt_baseCommit,
         },
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1695,10 +2058,15 @@
      * @param {?function(?Response, string=)=} opt_errFn
      */
     _getFileInRevision(changeNum, path, patchNum, opt_errFn) {
-      const e = `/files/${encodeURIComponent(path)}/content`;
-      const headers = {Accept: 'application/json'};
-      return this._getChangeURLAndSend(changeNum, 'GET', patchNum, e, null,
-          opt_errFn, null, headers);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'GET',
+        patchNum,
+        endpoint: `/files/${encodeURIComponent(path)}/content`,
+        errFn: opt_errFn,
+        headers: {Accept: 'application/json'},
+        anonymizedEndpoint: '/files/*/content',
+      });
     },
 
     /**
@@ -1707,62 +2075,124 @@
      * @param {string} path
      */
     _getFileInChangeEdit(changeNum, path) {
-      const e = '/edit/' + encodeURIComponent(path);
-      const headers = {Accept: 'application/json'};
-      return this._getChangeURLAndSend(changeNum, 'GET', null, e, null, null,
-          null, headers);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'GET',
+        endpoint: '/edit/' + encodeURIComponent(path),
+        headers: {Accept: 'application/json'},
+        anonymizedEndpoint: '/edit/*',
+      });
     },
 
     rebaseChangeEdit(changeNum) {
-      return this._getChangeURLAndSend(changeNum, 'POST', null, '/edit:rebase');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/edit:rebase',
+        reportEndpointAsIs: true,
+      });
     },
 
     deleteChangeEdit(changeNum) {
-      return this._getChangeURLAndSend(changeNum, 'DELETE', null, '/edit');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'DELETE',
+        endpoint: '/edit',
+        reportEndpointAsIs: true,
+      });
     },
 
     restoreFileInChangeEdit(changeNum, restore_path) {
-      const p = {restore_path};
-      return this._getChangeURLAndSend(changeNum, 'POST', null, '/edit', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/edit',
+        body: {restore_path},
+        reportEndpointAsIs: true,
+      });
     },
 
     renameFileInChangeEdit(changeNum, old_path, new_path) {
-      const p = {old_path, new_path};
-      return this._getChangeURLAndSend(changeNum, 'POST', null, '/edit', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/edit',
+        body: {old_path, new_path},
+        reportEndpointAsIs: true,
+      });
     },
 
     deleteFileInChangeEdit(changeNum, path) {
-      const e = '/edit/' + encodeURIComponent(path);
-      return this._getChangeURLAndSend(changeNum, 'DELETE', null, e);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'DELETE',
+        endpoint: '/edit/' + encodeURIComponent(path),
+        anonymizedEndpoint: '/edit/*',
+      });
     },
 
     saveChangeEdit(changeNum, path, contents) {
-      const e = '/edit/' + encodeURIComponent(path);
-      return this._getChangeURLAndSend(changeNum, 'PUT', null, e, contents,
-          null, 'text/plain');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/edit/' + encodeURIComponent(path),
+        body: contents,
+        contentType: 'text/plain',
+        anonymizedEndpoint: '/edit/*',
+      });
     },
 
     // Deprecated, prefer to use putChangeCommitMessage instead.
     saveChangeCommitMessageEdit(changeNum, message) {
-      const p = {message};
-      return this._getChangeURLAndSend(changeNum, 'PUT', null, '/edit:message',
-          p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/edit:message',
+        body: {message},
+        reportEndpointAsIs: true,
+      });
     },
 
     publishChangeEdit(changeNum) {
-      return this._getChangeURLAndSend(changeNum, 'POST', null,
-          '/edit:publish');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/edit:publish',
+        reportEndpointAsIs: true,
+      });
     },
 
     putChangeCommitMessage(changeNum, message) {
-      const p = {message};
-      return this._getChangeURLAndSend(changeNum, 'PUT', null, '/message', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/message',
+        body: {message},
+        reportEndpointAsIs: true,
+      });
     },
 
     saveChangeStarred(changeNum, starred) {
-      const url = '/accounts/self/starred.changes/' + changeNum;
-      const method = starred ? 'PUT' : 'DELETE';
-      return this._send({method, url});
+      // Some servers may require the project name to be provided
+      // alongside the change number, so resolve the project name
+      // first.
+      return this.getFromProjectLookup(changeNum).then(project => {
+        const url = '/accounts/self/starred.changes/' +
+            (project ? encodeURIComponent(project) + '~' : '') + changeNum;
+        return this._send({
+          method: starred ? 'PUT' : 'DELETE',
+          url,
+          anonymizedUrl: '/accounts/self/starred.changes/*',
+        });
+      });
+    },
+
+    saveChangeReviewed(changeNum, reviewed) {
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: reviewed ? '/reviewed' : '/unreviewed',
+      });
     },
 
     /**
@@ -1788,12 +2218,17 @@
       }
       const url = req.url.startsWith('http') ?
           req.url : this.getBaseUrl() + req.url;
-      const xhr = this._fetch(url, options).then(response => {
+      const fetchReq = {
+        url,
+        fetchOptions: options,
+        anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
+      };
+      const xhr = this._fetch(fetchReq).then(response => {
         if (!response.ok) {
           if (req.errFn) {
             return req.errFn.call(undefined, response);
           }
-          this.fire('server-error', {response});
+          this.fire('server-error', {request: fetchReq, response});
         }
         return response;
       }).catch(err => {
@@ -1842,15 +2277,16 @@
      *     index.
      * @param {number|string} patchNum
      * @param {string} path
+     * @param {string=} opt_whitespace the ignore-whitespace level for the diff
+     *     algorithm.
      * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
      */
-    getDiff(changeNum, basePatchNum, patchNum, path,
-        opt_errFn, opt_cancelCondition) {
+    getDiff(changeNum, basePatchNum, patchNum, path, opt_whitespace,
+        opt_errFn) {
       const params = {
         context: 'ALL',
         intraline: null,
-        whitespace: 'IGNORE_NONE',
+        whitespace: opt_whitespace || 'IGNORE_NONE',
       };
       if (this.isMergeParent(basePatchNum)) {
         params.parent = this.getParentIndex(basePatchNum);
@@ -1864,8 +2300,8 @@
         endpoint,
         patchNum,
         errFn: opt_errFn,
-        cancelCondition: opt_cancelCondition,
         params,
+        anonymizedEndpoint: '/files/*/diff',
       });
     },
 
@@ -1957,6 +2393,7 @@
           changeNum,
           endpoint,
           patchNum: opt_patchNum,
+          reportEndpointAsIs: true,
         });
       };
 
@@ -2047,8 +2484,10 @@
     _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
       const isCreate = !draft.id && method === 'PUT';
       let endpoint = '/drafts';
+      let anonymizedEndpoint = endpoint;
       if (draft.id) {
         endpoint += '/' + draft.id;
+        anonymizedEndpoint += '/*';
       }
       let body;
       if (method === 'PUT') {
@@ -2059,8 +2498,16 @@
         this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
       }
 
-      const promise = this._getChangeURLAndSend(changeNum, method, patchNum,
-          endpoint, body);
+      const req = {
+        changeNum,
+        method,
+        patchNum,
+        endpoint,
+        body,
+        anonymizedEndpoint,
+      };
+
+      const promise = this._getChangeURLAndSend(req);
       this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
 
       if (isCreate) {
@@ -2074,13 +2521,16 @@
       return this._fetchJSON({
         url: '/projects/' + encodeURIComponent(project) +
             '/commits/' + encodeURIComponent(commit),
+        anonymizedUrl: '/projects/*/comments/*',
       });
     },
 
     _fetchB64File(url) {
-      return this._fetch(this.getBaseUrl() + url)
+      return this._fetch({url: this.getBaseUrl() + url})
           .then(response => {
-            if (!response.ok) { return Promise.reject(response.statusText); }
+            if (!response.ok) {
+              return Promise.reject(new Error(response.statusText));
+            }
             const type = response.headers.get('X-FYI-Content-Type');
             return response.text()
                 .then(text => {
@@ -2173,9 +2623,14 @@
      * parameter.
      */
     setChangeTopic(changeNum, topic) {
-      const p = {topic};
-      return this._getChangeURLAndSend(changeNum, 'PUT', null, '/topic', p)
-          .then(this.getResponseObject.bind(this));
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/topic',
+        body: {topic},
+        parseResponse: true,
+        reportUrlAsIs: true,
+      });
     },
 
     /**
@@ -2184,14 +2639,21 @@
      * parameter.
      */
     setChangeHashtag(changeNum, hashtag) {
-      return this._getChangeURLAndSend(changeNum, 'POST', null, '/hashtags',
-          hashtag).then(this.getResponseObject.bind(this));
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/hashtags',
+        body: hashtag,
+        parseResponse: true,
+        reportUrlAsIs: true,
+      });
     },
 
     deleteAccountHttpPassword() {
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/password.http',
+        reportUrlAsIs: true,
       });
     },
 
@@ -2206,11 +2668,15 @@
         url: '/accounts/self/password.http',
         body: {generate: true},
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
     getAccountSSHKeys() {
-      return this._fetchSharedCacheURL({url: '/accounts/self/sshkeys'});
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/sshkeys',
+        reportUrlAsIs: true,
+      });
     },
 
     addAccountSSHKey(key) {
@@ -2219,16 +2685,17 @@
         url: '/accounts/self/sshkeys',
         body: key,
         contentType: 'plain/text',
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(response => {
             if (response.status < 200 && response.status >= 300) {
-              return Promise.reject();
+              return Promise.reject(new Error('error'));
             }
             return this.getResponseObject(response);
           })
           .then(obj => {
-            if (!obj.valid) { return Promise.reject(); }
+            if (!obj.valid) { return Promise.reject(new Error('error')); }
             return obj;
           });
     },
@@ -2237,24 +2704,33 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/sshkeys/' + id,
+        anonymizedUrl: '/accounts/self/sshkeys/*',
       });
     },
 
     getAccountGPGKeys() {
-      return this._fetchJSON({url: '/accounts/self/gpgkeys'});
+      return this._fetchJSON({
+        url: '/accounts/self/gpgkeys',
+        reportUrlAsIs: true,
+      });
     },
 
     addAccountGPGKey(key) {
-      const req = {method: 'POST', url: '/accounts/self/gpgkeys', body: key};
+      const req = {
+        method: 'POST',
+        url: '/accounts/self/gpgkeys',
+        body: key,
+        reportUrlAsIs: true,
+      };
       return this._send(req)
           .then(response => {
             if (response.status < 200 && response.status >= 300) {
-              return Promise.reject();
+              return Promise.reject(new Error('error'));
             }
             return this.getResponseObject(response);
           })
           .then(obj => {
-            if (!obj) { return Promise.reject(); }
+            if (!obj) { return Promise.reject(new Error('error')); }
             return obj;
           });
     },
@@ -2263,18 +2739,27 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/gpgkeys/' + id,
+        anonymizedUrl: '/accounts/self/gpgkeys/*',
       });
     },
 
     deleteVote(changeNum, account, label) {
-      const e = `/reviewers/${account}/votes/${encodeURIComponent(label)}`;
-      return this._getChangeURLAndSend(changeNum, 'DELETE', null, e);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'DELETE',
+        endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+        anonymizedEndpoint: '/reviewers/*/votes/*',
+      });
     },
 
     setDescription(changeNum, patchNum, desc) {
-      const p = {description: desc};
-      return this._getChangeURLAndSend(changeNum, 'PUT', patchNum,
-          '/description', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT', patchNum,
+        endpoint: '/description',
+        body: {description: desc},
+        reportUrlAsIs: true,
+      });
     },
 
     confirmEmail(token) {
@@ -2282,6 +2767,7 @@
         method: 'PUT',
         url: '/config/server/email.confirm',
         body: {token},
+        reportUrlAsIs: true,
       };
       return this._send(req).then(response => {
         if (response.status === 204) {
@@ -2291,20 +2777,39 @@
       });
     },
 
-    getCapabilities(token, opt_errFn) {
+    getCapabilities(opt_errFn) {
       return this._fetchJSON({
         url: '/config/server/capabilities',
         errFn: opt_errFn,
+        reportUrlAsIs: true,
+      });
+    },
+
+    getTopMenus(opt_errFn) {
+      return this._fetchSharedCacheURL({
+        url: '/config/server/top-menus',
+        errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
     setAssignee(changeNum, assignee) {
-      const p = {assignee};
-      return this._getChangeURLAndSend(changeNum, 'PUT', null, '/assignee', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/assignee',
+        body: {assignee},
+        reportUrlAsIs: true,
+      });
     },
 
     deleteAssignee(changeNum) {
-      return this._getChangeURLAndSend(changeNum, 'DELETE', null, '/assignee');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'DELETE',
+        endpoint: '/assignee',
+        reportUrlAsIs: true,
+      });
     },
 
     probePath(path) {
@@ -2319,16 +2824,22 @@
      * @param {number|string=} opt_message
      */
     startWorkInProgress(changeNum, opt_message) {
-      const payload = {};
+      const body = {};
       if (opt_message) {
-        payload.message = opt_message;
+        body.message = opt_message;
       }
-      return this._getChangeURLAndSend(changeNum, 'POST', null, '/wip', payload)
-          .then(response => {
-            if (response.status === 204) {
-              return 'Change marked as Work In Progress.';
-            }
-          });
+      const req = {
+        changeNum,
+        method: 'POST',
+        endpoint: '/wip',
+        body,
+        reportUrlAsIs: true,
+      };
+      return this._getChangeURLAndSend(req).then(response => {
+        if (response.status === 204) {
+          return 'Change marked as Work In Progress.';
+        }
+      });
     },
 
     /**
@@ -2337,8 +2848,14 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     startReview(changeNum, opt_body, opt_errFn) {
-      return this._getChangeURLAndSend(changeNum, 'POST', null, '/ready',
-          opt_body, opt_errFn);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/ready',
+        body: opt_body,
+        errFn: opt_errFn,
+        reportUrlAsIs: true,
+      });
     },
 
     /**
@@ -2347,10 +2864,15 @@
      * parameter.
      */
     deleteComment(changeNum, patchNum, commentID, reason) {
-      const endpoint = `/comments/${commentID}/delete`;
-      const payload = {reason};
-      return this._getChangeURLAndSend(changeNum, 'POST', patchNum, endpoint,
-          payload).then(this.getResponseObject.bind(this));
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        patchNum,
+        endpoint: `/comments/${commentID}/delete`,
+        body: {reason},
+        parseResponse: true,
+        anonymizedEndpoint: '/comments/*/delete',
+      });
     },
 
     /**
@@ -2365,6 +2887,7 @@
       return this._fetchJSON({
         url: `/changes/?q=change:${changeNum}`,
         errFn: opt_errFn,
+        anonymizedUrl: '/changes/?q=change:*',
       }).then(res => {
         if (!res || !res.length) { return null; }
         return res[0];
@@ -2411,27 +2934,26 @@
     /**
      * Alias for _changeBaseURL.then(send).
      * @todo(beckysiegel) clean up comments
-     * @param {string|number} changeNum
-     * @param {string} method
-     * @param {?string|number} patchNum gets passed as null.
-     * @param {?string} endpoint gets passed as null.
-     * @param {?Object|number|string=} opt_payload gets passed as null, string,
-     *    Object, or number.
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @param {?=} opt_contentType
-     * @param {Object=} opt_headers
+     * @param {Defs.ChangeSendRequest} req
      * @return {!Promise<!Object>}
      */
-    _getChangeURLAndSend(changeNum, method, patchNum, endpoint, opt_payload,
-        opt_errFn, opt_contentType, opt_headers) {
-      return this._changeBaseURL(changeNum, patchNum).then(url => {
+    _getChangeURLAndSend(req) {
+      const anonymizedBaseUrl = req.patchNum ?
+          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
+      const anonymizedEndpoint = req.reportEndpointAsIs ?
+          req.endpoint : req.anonymizedEndpoint;
+
+      return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._send({
-          method,
-          url: url + endpoint,
-          body: opt_payload,
-          errFn: opt_errFn,
-          contentType: opt_contentType,
-          headers: opt_headers,
+          method: req.method,
+          url: url + req.endpoint,
+          body: req.body,
+          errFn: req.errFn,
+          contentType: req.contentType,
+          headers: req.headers,
+          parseResponse: req.parseResponse,
+          anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
@@ -2442,13 +2964,18 @@
      * @return {!Promise<!Object>}
      */
     _getChangeURLAndFetch(req) {
+      const anonymizedEndpoint = req.reportEndpointAsIs ?
+          req.endpoint : req.anonymizedEndpoint;
+      const anonymizedBaseUrl = req.patchNum ?
+          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._fetchJSON({
           url: url + req.endpoint,
           errFn: req.errFn,
-          cancelCondition: req.cancelCondition,
           params: req.params,
           fetchOptions: req.fetchOptions,
+          anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
@@ -2458,15 +2985,21 @@
      * @param {number} changeNum
      * @param {string} method
      * @param {string} endpoint
-     * @param {string|number|null|undefined} opt_patchNum
+     * @param {string|number|undefined} opt_patchNum
      * @param {Object=} opt_payload
      * @param {?function(?Response, string=)=} opt_errFn
      * @return {Promise}
      */
     executeChangeAction(changeNum, method, endpoint, opt_patchNum, opt_payload,
         opt_errFn) {
-      return this._getChangeURLAndSend(changeNum, method, opt_patchNum || null,
-          endpoint, opt_payload, opt_errFn);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method,
+        patchNum: opt_patchNum,
+        endpoint,
+        body: opt_payload,
+        errFn: opt_errFn,
+      });
     },
 
     /**
@@ -2485,6 +3018,7 @@
         endpoint: `/files/${encodedPath}/blame`,
         patchNum,
         params: opt_base ? {base: 't'} : undefined,
+        anonymizedEndpoint: '/files/*/blame',
       });
     },
 
@@ -2529,13 +3063,44 @@
     getDashboard(project, dashboard, opt_errFn) {
       const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
           encodeURIComponent(dashboard);
-      return this._fetchSharedCacheURL({url, errFn: opt_errFn});
+      return this._fetchSharedCacheURL({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/dashboards/*',
+      });
+    },
+
+    /**
+     * @param {string} filter
+     * @return {!Promise<?Object>}
+     */
+    getDocumentationSearches(filter) {
+      filter = filter.trim();
+      const encodedFilter = encodeURIComponent(filter);
+
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
+      return this._fetchSharedCacheURL({
+        url: `/Documentation/?q=${encodedFilter}`,
+        anonymizedUrl: '/Documentation/?*',
+      });
     },
 
     getMergeable(changeNum) {
-      return this._getChangeURLAndSend(changeNum, 'GET', null,
-          '/revisions/current/mergeable')
-          .then(this.getResponseObject.bind(this));
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/revisions/current/mergeable',
+        parseResponse: true,
+        reportEndpointAsIs: true,
+      });
+    },
+
+    deleteDraftComments(query) {
+      return this._send({
+        method: 'POST',
+        url: '/accounts/self/drafts:delete',
+        body: {query},
+      });
     },
   });
 })();
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 86816f6..4c35151 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
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rest-api-interface</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
@@ -38,11 +40,15 @@
   suite('gr-rest-api-interface tests', () => {
     let element;
     let sandbox;
+    let ctr = 0;
 
     setup(() => {
+      // Modify CANONICAL_PATH to effectively reset cache.
+      ctr += 1;
+      window.CANONICAL_PATH = `test${ctr}`;
+
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      element._cache = {};
       element._projectLookup = {};
       const testJSON = ')]}\'\n{"hello": "bonjour"}';
       sandbox.stub(window, 'fetch').returns(Promise.resolve({
@@ -84,33 +90,46 @@
     });
 
     test('cached promise', done => {
-      const promise = Promise.reject('foo');
-      element._cache['/foo'] = promise;
+      const promise = Promise.reject(new Error('foo'));
+      element._cache.set('/foo', promise);
       element._fetchSharedCacheURL({url: '/foo'}).catch(p => {
-        assert.equal(p, 'foo');
+        assert.equal(p.message, 'foo');
         done();
       });
     });
 
+    test('cache invalidation', () => {
+      element._cache.set('/foo/bar', 1);
+      element._cache.set('/bar', 2);
+      element._sharedFetchPromises['/foo/bar'] = 3;
+      element._sharedFetchPromises['/bar'] = 4;
+      element._invalidateSharedFetchPromisesPrefix('/foo/');
+      assert.isFalse(element._cache.has('/foo/bar'));
+      assert.isTrue(element._cache.has('/bar'));
+      assert.isUndefined(element._sharedFetchPromises['/foo/bar']);
+      assert.strictEqual(4, element._sharedFetchPromises['/bar']);
+    });
+
     test('params are properly encoded', () => {
       let url = element._urlWithParams('/path/', {
         sp: 'hola',
         gr: 'guten tag',
         noval: null,
       });
-      assert.equal(url, '/path/?sp=hola&gr=guten%20tag&noval');
+      assert.equal(url,
+          window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
 
       url = element._urlWithParams('/path/', {
         sp: 'hola',
         en: ['hey', 'hi'],
       });
-      assert.equal(url, '/path/?sp=hola&en=hey&en=hi');
+      assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
 
       // Order must be maintained with array params.
       url = element._urlWithParams('/path/', {
         l: ['c', 'b', 'a'],
       });
-      assert.equal(url, '/path/?l=c&l=b&l=a');
+      assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
     });
 
     test('request callbacks can be canceled', done => {
@@ -438,19 +457,38 @@
         status: 403,
       };
       window.fetch.onFirstCall().returns(
-          Promise.reject({message: 'Failed to fetch'}));
+          Promise.reject(new Error('Failed to fetch')));
       window.fetch.onSecondCall().returns(Promise.resolve(fakeAuthResponse));
       // Emulate logged in.
-      element._cache['/accounts/self/detail'] = {};
+      element._cache.set('/accounts/self/detail', {});
       const serverErrorStub = sandbox.stub();
       element.addEventListener('server-error', serverErrorStub);
       const authErrorStub = sandbox.stub();
       element.addEventListener('auth-error', authErrorStub);
-      element._fetchJSON('/bar').then(r => {
+      element._fetchJSON('/bar').finally(r => {
         flush(() => {
           assert.isTrue(authErrorStub.called);
           assert.isFalse(serverErrorStub.called);
-          assert.isNull(element._cache['/accounts/self/detail']);
+          assert.isFalse(element._cache.has('/accounts/self/detail'));
+          done();
+        });
+      });
+    });
+
+    test('auth failure - test all failed to fetch', done => {
+      window.fetch.returns(
+          Promise.reject(new Error('Failed to fetch')));
+      // Emulate logged in.
+      element._cache.set('/accounts/self/detail', {});
+      const serverErrorStub = sandbox.stub();
+      element.addEventListener('server-error', serverErrorStub);
+      const authErrorStub = sandbox.stub();
+      element.addEventListener('auth-error', authErrorStub);
+      element._fetchJSON('/bar').finally(r => {
+        flush(() => {
+          assert.isTrue(authErrorStub.called);
+          assert.isFalse(serverErrorStub.called);
+          assert.isFalse(element._cache.has('/accounts/self/detail'));
           done();
         });
       });
@@ -471,7 +509,7 @@
       ];
       window.fetch.restore();
       sandbox.stub(window, 'fetch', url => {
-        if (url === '/accounts/self/detail') {
+        if (url === window.CANONICAL_PATH + '/accounts/self/detail') {
           return Promise.resolve(responses.shift());
         }
       });
@@ -485,6 +523,26 @@
       });
     });
 
+    test('checkCredentials promise rejection', () => {
+      window.fetch.restore();
+      element._cache.set('/accounts/self/detail', true);
+      sandbox.spy(element, 'checkCredentials');
+      sandbox.stub(window, 'fetch', url => {
+        return Promise.reject(new Error('Failed to fetch'));
+      });
+      return element.getConfig(true)
+          .catch(err => undefined)
+          .then(() => {
+            // When the top-level fetch call throws an error, it invokes
+            // checkCredentials, which in turn makes another fetch call.
+            // The second fetch call also fails, which leads to a second
+            // invocation of checkCredentials, which should immediately
+            // return instead of making further fetch calls.
+            assert.isTrue(element.checkCredentials.calledTwice);
+            assert.isTrue(window.fetch.calledTwice);
+          });
+    });
+
     test('legacy n,z key in change url is replaced', () => {
       const stub = sandbox.stub(element, '_fetchJSON')
           .returns(Promise.resolve([]));
@@ -495,10 +553,10 @@
     test('saveDiffPreferences invalidates cache line', () => {
       const cacheKey = '/accounts/self/preferences.diff';
       sandbox.stub(element, '_send');
-      element._cache[cacheKey] = {tab_size: 4};
+      element._cache.set(cacheKey, {tab_size: 4});
       element.saveDiffPreferences({tab_size: 8});
       assert.isTrue(element._send.called);
-      assert.notOk(element._cache[cacheKey]);
+      assert.isFalse(element._cache.has(cacheKey));
     });
 
     test('getAccount when resp is null does not add anything to the cache',
@@ -509,11 +567,11 @@
 
           element.getAccount().then(() => {
             assert.isTrue(element._fetchSharedCacheURL.called);
-            assert.isNull(element._cache[cacheKey]);
+            assert.isFalse(element._cache.has(cacheKey));
             done();
           });
 
-          element._cache[cacheKey] = 'fake cache';
+          element._cache.set(cacheKey, 'fake cache');
           stub.lastCall.args[0].errFn();
         });
 
@@ -525,10 +583,10 @@
 
           element.getAccount().then(() => {
             assert.isTrue(element._fetchSharedCacheURL.called);
-            assert.isNull(element._cache[cacheKey]);
+            assert.isFalse(element._cache.has(cacheKey));
             done();
           });
-          element._cache[cacheKey] = 'fake cache';
+          element._cache.set(cacheKey, 'fake cache');
           stub.lastCall.args[0].errFn({status: 403});
         });
 
@@ -539,10 +597,10 @@
 
       element.getAccount().then(response => {
         assert.isTrue(element._fetchSharedCacheURL.called);
-        assert.equal(element._cache[cacheKey], 'fake cache');
+        assert.equal(element._cache.get(cacheKey), 'fake cache');
         done();
       });
-      element._cache[cacheKey] = 'fake cache';
+      element._cache.set(cacheKey, 'fake cache');
 
       stub.lastCall.args[0].errFn({});
     });
@@ -691,25 +749,15 @@
       sandbox.spy(element, '_send');
       element.confirmEmail('foo');
       assert.isTrue(element._send.calledOnce);
-      assert.deepEqual(element._send.lastCall.args[0], {
-        method: 'PUT',
-        url: '/config/server/email.confirm',
-        body: {token: 'foo'},
-      });
-    });
-
-    test('GrReviewerUpdatesParser.parse is used', () => {
-      sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
-          Promise.resolve('foo'));
-      return element.getChangeDetail(42).then(result => {
-        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
-        assert.equal(result, 'foo');
-      });
+      assert.equal(element._send.lastCall.args[0].method, 'PUT');
+      assert.equal(element._send.lastCall.args[0].url,
+          '/config/server/email.confirm');
+      assert.deepEqual(element._send.lastCall.args[0].body, {token: 'foo'});
     });
 
     test('setAccountStatus', () => {
       sandbox.stub(element, '_send').returns(Promise.resolve('OOO'));
-      element._cache['/accounts/self/detail'] = {};
+      element._cache.set('/accounts/self/detail', {});
       return element.setAccountStatus('OOO').then(() => {
         assert.isTrue(element._send.calledOnce);
         assert.equal(element._send.lastCall.args[0].method, 'PUT');
@@ -717,7 +765,7 @@
             '/accounts/self/status');
         assert.deepEqual(element._send.lastCall.args[0].body,
             {status: 'OOO'});
-        assert.deepEqual(element._cache['/accounts/self/detail'],
+        assert.deepEqual(element._cache.get('/accounts/self/detail'),
             {status: 'OOO'});
       });
     });
@@ -811,7 +859,7 @@
           Promise.resolve([change_num, file_name, file_contents]));
       sandbox.stub(element, 'getResponseObject')
           .returns(Promise.resolve([change_num, file_name, file_contents]));
-      element._cache['/changes/' + change_num + '/edit/' + file_name] = {};
+      element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
       return element.saveChangeEdit(change_num, file_name, file_contents)
           .then(() => {
             assert.isTrue(element._send.calledOnce);
@@ -830,7 +878,7 @@
           Promise.resolve([change_num, message]));
       sandbox.stub(element, 'getResponseObject')
           .returns(Promise.resolve([change_num, message]));
-      element._cache['/changes/' + change_num + '/message'] = {};
+      element._cache.set('/changes/' + change_num + '/message', {});
       return element.putChangeCommitMessage(change_num, message).then(() => {
         assert.isTrue(element._send.calledOnce);
         assert.equal(element._send.lastCall.args[0].method, 'PUT');
@@ -841,35 +889,54 @@
     });
 
     test('startWorkInProgress', () => {
-      sandbox.stub(element, '_getChangeURLAndSend')
+      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
           .returns(Promise.resolve('ok'));
       element.startWorkInProgress('42');
-      assert.isTrue(element._getChangeURLAndSend.calledWith(
-          '42', 'POST', null, '/wip', {}));
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+      assert.equal(sendStub.lastCall.args[0].method, 'POST');
+      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+      assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+      assert.deepEqual(sendStub.lastCall.args[0].body, {});
+
       element.startWorkInProgress('42', 'revising...');
-      assert.isTrue(element._getChangeURLAndSend.calledWith(
-          '42', 'POST', null, '/wip', {message: 'revising...'}));
+      assert.isTrue(sendStub.calledTwice);
+      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+      assert.equal(sendStub.lastCall.args[0].method, 'POST');
+      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+      assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+      assert.deepEqual(sendStub.lastCall.args[0].body,
+          {message: 'revising...'});
     });
 
     test('startReview', () => {
-      sandbox.stub(element, '_getChangeURLAndSend')
+      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
           .returns(Promise.resolve({}));
       element.startReview('42', {message: 'Please review.'});
-      assert.isTrue(element._getChangeURLAndSend.calledWith(
-          '42', 'POST', null, '/ready', {message: 'Please review.'}));
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+      assert.equal(sendStub.lastCall.args[0].method, 'POST');
+      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+      assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
+      assert.deepEqual(sendStub.lastCall.args[0].body,
+          {message: 'Please review.'});
     });
 
-    test('deleteComment', done => {
-      sandbox.stub(element, '_getChangeURLAndSend').returns(Promise.resolve());
-      sandbox.stub(element, 'getResponseObject').returns('some response');
-      element.deleteComment('foo', 'bar', '01234', 'removal reason')
+    test('deleteComment', () => {
+      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
+          .returns(Promise.resolve('some response'));
+      return element.deleteComment('foo', 'bar', '01234', 'removal reason')
           .then(response => {
             assert.equal(response, 'some response');
-            done();
+            assert.isTrue(sendStub.calledOnce);
+            assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
+            assert.equal(sendStub.lastCall.args[0].method, 'POST');
+            assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
+            assert.equal(sendStub.lastCall.args[0].endpoint,
+                '/comments/01234/delete');
+            assert.deepEqual(sendStub.lastCall.args[0].body,
+                {reason: 'removal reason'});
           });
-      assert.isTrue(element._getChangeURLAndSend.calledWith(
-          'foo', 'POST', 'bar', '/comments/01234/delete',
-          {reason: 'removal reason'}));
     });
 
     test('createRepo encodes name', () => {
@@ -885,48 +952,153 @@
       const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-        assert.deepEqual(fetchStub.lastCall.args[0], {
-          changeNum: '42',
-          endpoint: '/files?q=test%2Fpath.js',
-          patchNum: 'edit',
-        });
+        assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
+        assert.equal(fetchStub.lastCall.args[0].endpoint,
+            '/files?q=test%2Fpath.js');
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
       });
     });
 
-    test('getRepos', () => {
-      sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getRepos('test', 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=0&m=test');
+    test('normal use', () => {
+      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');
+      assert.equal(element._getReposUrl('test', 25),
+          '/projects/?n=26&S=0&query=test');
 
-      element.getRepos('test', 25, 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=25&m=test');
+      assert.equal(element._getReposUrl(null, 25),
+          `/projects/?n=26&S=0&query=${defaultQuery}`);
+
+      assert.equal(element._getReposUrl('test', 25, 25),
+          '/projects/?n=26&S=25&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');
+    test('invalidateReposCache', () => {
+      const url = '/projects/?n=26&S=0&query=test';
+
+      element._cache.set(url, {});
+
+      element.invalidateReposCache();
+
+      assert.isUndefined(element._sharedFetchPromises[url]);
+
+      assert.isFalse(element._cache.has(url));
     });
 
-    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.*');
+    suite('getRepos', () => {
+      const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
+
+      setup(() => {
+        sandbox.stub(element, '_fetchSharedCacheURL');
+      });
+
+      test('normal use', () => {
+        element.getRepos('test', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=test');
+
+        element.getRepos(null, 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            `/projects/?n=26&S=0&query=${defaultQuery}`);
+
+        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', () => {
-      sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getGroups('^test.*', 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/groups/?n=26&S=0&r=%5Etest.*');
+    test('_getGroupsUrl normal use', () => {
+      assert.equal(element._getGroupsUrl('test', 25),
+          '/groups/?n=26&S=0&m=test');
+
+      assert.equal(element._getGroupsUrl(null, 25),
+          '/groups/?n=26&S=0');
+
+      assert.equal(element._getGroupsUrl('test', 25, 25),
+          '/groups/?n=26&S=25&m=test');
+    });
+
+    test('invalidateGroupsCache', () => {
+      const url = '/groups/?n=26&S=0&m=test';
+
+      element._cache.set(url, {});
+
+      element.invalidateGroupsCache();
+
+      assert.isUndefined(element._sharedFetchPromises[url]);
+
+      assert.isFalse(element._cache.has(url));
+    });
+
+    suite('getGroups', () => {
+      setup(() => {
+        sandbox.stub(element, '_fetchSharedCacheURL');
+      });
+
+      test('normal use', () => {
+        element.getGroups('test', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/groups/?n=26&S=0&m=test');
+
+        element.getGroups(null, 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/groups/?n=26&S=0');
+
+        element.getGroups('test', 25, 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/groups/?n=26&S=25&m=test');
+      });
+
+      test('regex', () => {
+        element.getGroups('^test.*', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/groups/?n=26&S=0&r=%5Etest.*');
+
+        element.getGroups('^test.*', 25, 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/groups/?n=26&S=25&r=%5Etest.*');
+      });
     });
 
     test('gerrit auth is used', () => {
@@ -954,15 +1126,58 @@
       });
     });
 
-    suite('_getChangeDetail', () => {
+    suite('getChangeDetail', () => {
+      suite('change detail options', () => {
+        let toHexStub;
+
+        setup(() => {
+          toHexStub = sandbox.stub(element, 'listChangesOptionsToHex',
+              options => 'deadbeef');
+          sandbox.stub(element, '_getChangeDetail',
+              async (changeNum, options) => ({changeNum, options}));
+        });
+
+        test('signed pushes disabled', async () => {
+          const {PUSH_CERTIFICATES} = element.ListChangesOption;
+          sandbox.stub(element, 'getConfig', async () => ({}));
+          const {changeNum, options} = await element.getChangeDetail(123);
+          assert.strictEqual(123, changeNum);
+          assert.strictEqual('deadbeef', options);
+          assert.isTrue(toHexStub.calledOnce);
+          assert.isFalse(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
+        });
+
+        test('signed pushes enabled', async () => {
+          const {PUSH_CERTIFICATES} = element.ListChangesOption;
+          sandbox.stub(element, 'getConfig', async () => {
+            return {receive: {enable_signed_push: true}};
+          });
+          const {changeNum, options} = await element.getChangeDetail(123);
+          assert.strictEqual(123, changeNum);
+          assert.strictEqual('deadbeef', options);
+          assert.isTrue(toHexStub.calledOnce);
+          assert.isTrue(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
+        });
+      });
+
+      test('GrReviewerUpdatesParser.parse is used', () => {
+        sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
+            Promise.resolve('foo'));
+        return element.getChangeDetail(42).then(result => {
+          assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
+          assert.equal(result, 'foo');
+        });
+      });
+
       test('_getChangeDetail passes params to ETags decorator', () => {
         const changeNum = 4321;
         element._projectLookup[changeNum] = 'test';
-        const params = {foo: 'bar'};
-        const expectedUrl = '/changes/test~4321/detail?foo=bar';
+        const expectedUrl =
+            window.CANONICAL_PATH + '/changes/test~4321/detail?'+
+            '0=5&1=1&2=6&3=7&4=1&5=4';
         sandbox.stub(element._etags, 'getOptions');
         sandbox.stub(element._etags, 'collect');
-        return element._getChangeDetail(changeNum, params).then(() => {
+        return element._getChangeDetail(changeNum, '516714').then(() => {
           assert.isTrue(element._etags.getOptions.calledWithExactly(
               expectedUrl));
           assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
@@ -975,7 +1190,7 @@
             .returns(Promise.resolve(''));
         sandbox.stub(element, '_fetchRawJSON')
             .returns(Promise.resolve({ok: false, status: 500}));
-        return element._getChangeDetail(123, {}, errFn).then(() => {
+        return element._getChangeDetail(123, '516714', errFn).then(() => {
           assert.isTrue(errFn.called);
         });
       });
@@ -991,7 +1206,7 @@
           parsed: mockResponse,
           raw: JSON.stringify(mockResponse),
         }));
-        return element._getChangeDetail(1).then(() => {
+        return element._getChangeDetail(1, '516714').then(() => {
           assert.equal(Object.keys(element._projectLookup).length, 1);
           assert.equal(element._projectLookup[1], 'test');
         });
@@ -1022,7 +1237,7 @@
             ok: true,
           }));
 
-          return element._getChangeDetail(123, {}).then(detail => {
+          return element._getChangeDetail(123, '516714').then(detail => {
             assert.isFalse(getPayloadSpy.called);
             assert.isTrue(collectSpy.calledOnce);
             const cachedResponse = element._etags.getCachedPayload(requestUrl);
@@ -1133,7 +1348,14 @@
       element._projectLookup = {1: 'test'};
       const sendStub = sandbox.stub(element, '_send')
           .returns(Promise.resolve());
-      return element._getChangeURLAndSend(1, 'POST', 1, '/test').then(() => {
+
+      const req = {
+        changeNum: 1,
+        method: 'POST',
+        patchNum: 1,
+        endpoint: '/test',
+      };
+      return element._getChangeURLAndSend(req).then(() => {
         assert.isTrue(sendStub.calledOnce);
         assert.equal(sendStub.lastCall.args[0].method, 'POST');
         assert.equal(sendStub.lastCall.args[0].url,
@@ -1164,7 +1386,7 @@
       const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
       return element.setChangeTopic(123, 'foo-bar').then(() => {
         assert.isTrue(sendSpy.calledOnce);
-        assert.deepEqual(sendSpy.lastCall.args[4], {topic: 'foo-bar'});
+        assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
       });
     });
 
@@ -1172,7 +1394,7 @@
       const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
       return element.setChangeHashtag(123, 'foo-bar').then(() => {
         assert.isTrue(sendSpy.calledOnce);
-        assert.equal(sendSpy.lastCall.args[4], 'foo-bar');
+        assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
       });
     });
 
@@ -1341,12 +1563,48 @@
       sandbox.stub(element._auth, 'fetch').returns(Promise.resolve(response));
       const startTime = 123;
       sandbox.stub(Date, 'now').returns(startTime);
-      return element._fetch(url, fetchOptions).then(() => {
+      const req = {url, fetchOptions};
+      return element._fetch(req).then(() => {
         assert.isTrue(logStub.calledOnce);
-        assert.isTrue(logStub.calledWith(
-            url, fetchOptions, startTime, response.status));
+        assert.isTrue(logStub.calledWith(req, startTime, response.status));
         assert.isFalse(response.text.called);
       });
     });
+
+    test('_logCall only reports requests with anonymized URLss', () => {
+      sandbox.stub(Date, 'now').returns(200);
+      const handler = sinon.stub();
+      element.addEventListener('rpc-log', handler);
+
+      element._logCall({url: 'url'}, 100, 200);
+      assert.isFalse(handler.called);
+
+      element._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+      flushAsynchronousOperations();
+      assert.isTrue(handler.calledOnce);
+    });
+
+    test('saveChangeStarred', async () => {
+      sandbox.stub(element, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      const sendStub =
+          sandbox.stub(element, '_send').returns(Promise.resolve());
+
+      await element.saveChangeStarred(123, true);
+      assert.isTrue(sendStub.calledOnce);
+      assert.deepEqual(sendStub.lastCall.args[0], {
+        method: 'PUT',
+        url: '/accounts/self/starred.changes/test~123',
+        anonymizedUrl: '/accounts/self/starred.changes/*',
+      });
+
+      await element.saveChangeStarred(456, false);
+      assert.isTrue(sendStub.calledTwice);
+      assert.deepEqual(sendStub.lastCall.args[0], {
+        method: 'DELETE',
+        url: '/accounts/self/starred.changes/test~456',
+        anonymizedUrl: '/accounts/self/starred.changes/*',
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
index 202c52a..fdf79af 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-updates-parser</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 <script src="gr-reviewer-updates-parser.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
index 0a1b14e..9a24ddc 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <dom-module id="mock-diff-response">
   <template></template>
@@ -153,6 +153,7 @@
 
       Polymer({
         is: 'mock-diff-response',
+        _legacyUndefinedCheck: true,
         properties: {
           diffResponse: {
             type: Object,
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
index e73d41c..02afb38 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <dom-module id="gr-select">
   <slot></slot>
   <script src="gr-select.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index b732fa5..5e68c8b 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-select',
+    _legacyUndefinedCheck: true,
     properties: {
       bindValue: {
         type: String,
@@ -56,7 +57,9 @@
 
     ready() {
       // If not set via the property, set bind-value to the element value.
-      if (!this.bindValue) { this.bindValue = this.nativeSelect.value; }
+      if (this.bindValue == undefined) {
+        this.bindValue = this.nativeSelect.value;
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
index 1748ec06..66ebb79 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-select</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-select.html">
 
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..dbbf98b
--- /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..901b8ce
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
@@ -0,0 +1,33 @@
+/**
+ * @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',
+    _legacyUndefinedCheck: true,
+
+    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..3f2f8ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
@@ -0,0 +1,61 @@
+<!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="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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/elements/shared/gr-storage/gr-storage.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
index 6fc2f3f..7215b26 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
@@ -14,7 +14,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <dom-module id="gr-storage">
   <script src="gr-storage.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 62080a1..6146e0f 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -30,6 +30,7 @@
 
   Polymer({
     is: 'gr-storage',
+    _legacyUndefinedCheck: true,
 
     properties: {
       _lastCleanup: Number,
@@ -72,7 +73,7 @@
     },
 
     eraseEditableContentItem(key) {
-      this._storage.removeItem(key);
+      this._storage.removeItem(this._getEditableContentKey(key));
     },
 
     getPreferences() {
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index b7d73d4..0482584 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-storage.html">
 
@@ -188,7 +190,7 @@
 
       // eraseEditableContentItem performs as expected.
       element.eraseEditableContentItem(key);
-      assert.isNotOk(element._storage.getItem(key));
+      assert.isNotOk(element._storage.getItem(computedKey));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
index 10c9111..fc532af 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
@@ -14,14 +14,14 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-textarea">
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
index a3da7d8..7929fbe 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -54,6 +54,7 @@
 
   Polymer({
     is: 'gr-textarea',
+    _legacyUndefinedCheck: true,
 
     /**
      * @event bind-value-changed
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
index 3a52543..8b6eff2 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-textarea</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-textarea.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
index 65f1fda..b4fefe1 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 
 <dom-module id="gr-tooltip-content">
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
index c5de8f4..b46cafb 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-tooltip-content',
+    _legacyUndefinedCheck: true,
 
     properties: {
       title: {
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
index 438d436..f9350c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-tooltip-content.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
index 9947d61..36378f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-tooltip">
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
index fb87b558..3e16beb 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -19,6 +19,7 @@
 
   Polymer({
     is: 'gr-tooltip',
+    _legacyUndefinedCheck: true,
 
     properties: {
       text: String,
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
index 3a47288..f59f6e1 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
@@ -17,9 +17,11 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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-tooltip.html">
 
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
index 91f87d0..fca8ae1 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
@@ -35,6 +35,9 @@
      * @return {Number}
      */
     RevisionInfo.prototype.getMaxParents = function() {
+      if (!this._change || !this._change.revisions) {
+        return 0;
+      }
       return Object.values(this._change.revisions)
           .reduce((acc, rev) => Math.max(rev.commit.parents.length, acc), 0);
     };
@@ -46,6 +49,9 @@
      */
     RevisionInfo.prototype.getParentCountMap = function() {
       const result = {};
+      if (!this._change || !this._change.revisions) {
+        return {};
+      }
       Object.values(this._change.revisions)
           .forEach(rev => { result[rev._number] = rev.commit.parents.length; });
       return result;
@@ -72,9 +78,7 @@
       return rev.commit.parents[parentIndex].commit;
     };
 
-    if (!window.Gerrit) {
-      window.Gerrit = {};
-    }
+    window.Gerrit = window.Gerrit || {};
     window.Gerrit.RevisionInfo = RevisionInfo;
   })();
 </script>
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
index 433872d..7e5810b 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
@@ -18,9 +18,11 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>revision-info</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.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="revision-info.html">
 
diff --git a/polygerrit-ui/app/elements/test/plugin.html b/polygerrit-ui/app/elements/test/plugin.html
index bd29b90..a0d7467 100644
--- a/polygerrit-ui/app/elements/test/plugin.html
+++ b/polygerrit-ui/app/elements/test/plugin.html
@@ -7,12 +7,14 @@
 </dom-module>
 
 <dom-module id="myplugin-app-theme">
-  <style>
-    html {
-      --primary-text-color: #F00BAA;
-      --header-background-color: #F01BAA;
-      --header-title-content: "MyGerrit";
-      --footer-background-color: #F02BAA;
-    }
-  </style>
+  <template>
+    <style>
+      html {
+        --primary-text-color: #F00BAA;
+        --header-background-color: #F01BAA;
+        --header-title-content: "MyGerrit";
+        --footer-background-color: #F02BAA;
+      }
+    </style>
+  </template>
 </dom-module>
diff --git a/polygerrit-ui/app/embed/embed.html b/polygerrit-ui/app/embed/embed.html
index 9fb5c23..64e0137 100644
--- a/polygerrit-ui/app/embed/embed.html
+++ b/polygerrit-ui/app/embed/embed.html
@@ -14,11 +14,14 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../bower_components/polymer/polymer.html">
+<script>
+  window.Gerrit = window.Gerrit || {};
+</script>
+<link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../elements/change/gr-change-view/gr-change-view.html">
 <link rel="import" href="../elements/core/gr-search-bar/gr-search-bar.html">
 <link rel="import" href="../elements/diff/gr-diff-view/gr-diff-view.html">
 <link rel="import" href="../elements/change-list/gr-change-list-view/gr-change-list-view.html">
-<link rel="import" href="../elements/change-list/gr-change-list/gr-change-list.html">
 <link rel="import" href="../elements/change-list/gr-dashboard-view/gr-dashboard-view.html">
+<link rel="import" href="../elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html">
 <link rel="import" href="../styles/themes/app-theme.html">
diff --git a/polygerrit-ui/app/embed/embed_test.html b/polygerrit-ui/app/embed/embed_test.html
index 7ca75c9..1e3f5d7 100644
--- a/polygerrit-ui/app/embed/embed_test.html
+++ b/polygerrit-ui/app/embed/embed_test.html
@@ -18,10 +18,12 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>embed_test</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../polygerrit_ui/elements/embed.html"/>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="embed.html"/>
 
 <script>void(0);</script>
 
diff --git a/polygerrit-ui/app/embed/gr-diff.html b/polygerrit-ui/app/embed/gr-diff.html
new file mode 100644
index 0000000..f5f74bd
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff.html
@@ -0,0 +1,21 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script>
+  window.Gerrit = window.Gerrit || {};
+</script>
+<link rel="import" href="../elements/diff/gr-diff/gr-diff.html">
+<link rel="import" href="../elements/diff/gr-diff-cursor/gr-diff-cursor.html">
diff --git a/polygerrit-ui/app/embed/test.html b/polygerrit-ui/app/embed/test.html
index eed2fef..955eaee 100644
--- a/polygerrit-ui/app/embed/test.html
+++ b/polygerrit-ui/app/embed/test.html
@@ -19,8 +19,8 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>Embed Test Runner</title>
 <meta charset="utf-8">
-<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <script>
-  WCT.loadSuites(['embed_test.html']);
+  WCT.loadSuites(['../embed/embed_test.html']);
 </script>
diff --git a/polygerrit-ui/app/embed_test.sh b/polygerrit-ui/app/embed_test.sh
index d482796..0d8f58f 100755
--- a/polygerrit-ui/app/embed_test.sh
+++ b/polygerrit-ui/app/embed_test.sh
@@ -4,20 +4,19 @@
 
 t=$(mktemp -d || mktemp -d -t wct-XXXXXXXXXX)
 components=$TEST_SRCDIR/gerrit/polygerrit-ui/app/test_components.zip
-code=$TEST_SRCDIR/gerrit/polygerrit-ui/app/polygerrit_embed_ui.zip
-index=$TEST_SRCDIR/gerrit/polygerrit-ui/app/embed/test.html
-tests=$TEST_SRCDIR/gerrit/polygerrit-ui/app/embed/*_test.html
+code=$TEST_SRCDIR/gerrit/polygerrit-ui/app/pg_code.zip
 
+echo $t
 unzip -qd $t $components
 unzip -qd $t $code
+# Purge test/ directory contents coming from pg_code.zip.
+rm -rf $t/test
 mkdir -p $t/test
-cp $index $t/test/
-cp $tests $t/test/
+cp $TEST_SRCDIR/gerrit/polygerrit-ui/app/embed/test.html $t/test/
 
 if [ "${WCT_HEADLESS_MODE:-0}" != "0" ]; then
     CHROME_OPTIONS=[\'start-maximized\',\'headless\',\'disable-gpu\',\'no-sandbox\']
-    # TODO(paladox): Fix Firefox support for headless mode
-    FIREFOX_OPTIONS=[\'\']
+    FIREFOX_OPTIONS=[\'-headless\']
 else
     CHROME_OPTIONS=[\'start-maximized\']
     FIREFOX_OPTIONS=[\'\']
@@ -62,9 +61,9 @@
     };
 EOF
 
-export PATH="$(dirname $WCT):$(dirname $NPM):$PATH"
+export PATH="$(dirname $NPM):$PATH"
 
 cd $t
 test -n "${WCT}"
 
-$(basename ${WCT}) ${WCT_ARGS}
+${WCT} ${WCT_ARGS}
diff --git a/polygerrit-ui/app/externs/BUILD b/polygerrit-ui/app/externs/BUILD
new file mode 100644
index 0000000..26ead9a
--- /dev/null
+++ b/polygerrit-ui/app/externs/BUILD
@@ -0,0 +1,25 @@
+# Copyright (C) 2018 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library")
+
+package(
+    default_visibility = ["//visibility:public"],
+)
+
+closure_js_library(
+    name = "plugin",
+    srcs = ["plugin.js"],
+    no_closure_library = True,
+)
diff --git a/polygerrit-ui/app/externs/plugin.js b/polygerrit-ui/app/externs/plugin.js
new file mode 100644
index 0000000..c88c724
--- /dev/null
+++ b/polygerrit-ui/app/externs/plugin.js
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @fileoverview Closure compiler externs for the Gerrit UI plugins.
+ * @externs
+ */
+
+/* eslint-disable no-var */
+
+var Gerrit = {};
+
+/**
+ * @param {!Function} callback
+ */
+Gerrit.install = function(callback) {};
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.html b/polygerrit-ui/app/gr-diff/gr-diff-root.html
new file mode 100644
index 0000000..b3f0d34
--- /dev/null
+++ b/polygerrit-ui/app/gr-diff/gr-diff-root.html
@@ -0,0 +1,4 @@
+<script>
+  window.Gerrit = window.Gerrit || {};
+</script>
+<link rel="import" href="../elements/diff/gr-diff/gr-diff.html">
diff --git a/polygerrit-ui/app/polylint_test.sh b/polygerrit-ui/app/polylint_test.sh
index ca9f9a9..f6880a1 100755
--- a/polygerrit-ui/app/polylint_test.sh
+++ b/polygerrit-ui/app/polylint_test.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/bin/bash
 
 set -ex
 
@@ -8,13 +8,13 @@
     exit 1
 fi
 
-polylint_bin=$(which polylint)
-if [[ -z "$polylint_bin" ]]; then
-    echo "You must install polylint and its dependencies from NPM."
-    echo "> npm install -g polylint"
+npx_bin=$(which npx)
+if [[ -z "$npx_bin" ]]; then
+    echo "NPX must be on the path."
+    echo "> npm i -g npx"
     exit 1
 fi
 
 unzip -o polygerrit-ui/polygerrit_components.bower_components.zip -d polygerrit-ui/app
 
-${polylint_bin} --root polygerrit-ui/app --input elements/gr-app.html --b 'bower_components'
\ No newline at end of file
+npx polylint --root polygerrit-ui/app --input elements/gr-app.html --b 'bower_components' --verbose
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 199a947..3012f7f 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,107 +1,108 @@
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library", "closure_js_binary")
 load(
     "//tools/bzl:js.bzl",
-    "vulcanize",
-    "bower_component",
-    "js_component",
+    "bundle_assets",
 )
 
 def polygerrit_bundle(name, srcs, outs, app):
-  appName = app.split(".html")[0].split("/").pop() # eg: gr-app
+    appName = app.split(".html")[0].split("/").pop()  # eg: gr-app
 
-  closure_js_binary(
-    name = name + "_closure_bin",
-    # Known issue: Closure compilation not compatible with Polymer behaviors.
-    # See: https://github.com/google/closure-compiler/issues/2042
-    compilation_level = "WHITESPACE_ONLY",
-    defs = [
-      "--polymer_version=1",
-      "--jscomp_off=duplicate",
-      "--force_inject_library=es6_runtime",
-    ],
-    language = "ECMASCRIPT5",
-    deps = [name + "_closure_lib"],
-  )
+    closure_js_binary(
+        name = name + "_closure_bin",
+        # Known issue: Closure compilation not compatible with Polymer behaviors.
+        # See: https://github.com/google/closure-compiler/issues/2042
+        compilation_level = "WHITESPACE_ONLY",
+        defs = [
+            "--polymer_version=1",
+            "--jscomp_off=duplicate",
+            "--force_inject_library=es6_runtime",
+        ],
+        language = "ECMASCRIPT5",
+        deps = [name + "_closure_lib"],
+    )
 
-  closure_js_library(
-    name = name + "_closure_lib",
-    srcs = [appName + ".js"],
-    convention = "GOOGLE",
-    # TODO(davido): Clean up these issues: http://paste.openstack.org/show/608548
-    # and remove this supression
-    suppress = [
-       "JSC_JSDOC_MISSING_TYPE_WARNING",
-       "JSC_UNNECESSARY_ESCAPE",
-       "JSC_UNUSED_LOCAL_ASSIGNMENT",
-    ],
-    deps = [
-      "//lib/polymer_externs:polymer_closure",
-      "@io_bazel_rules_closure//closure/library",
-    ],
-  )
+    # TODO(davido): Remove JSC_REFERENCE_BEFORE_DECLARE when this is fixed upstream:
+    # https://github.com/Polymer/polymer-resin/issues/7
+    closure_js_library(
+        name = name + "_closure_lib",
+        srcs = [appName + ".js"],
+        convention = "GOOGLE",
+        # TODO(davido): Clean up these issues: http://paste.openstack.org/show/608548
+        # and remove this supression
+        suppress = [
+            "JSC_JSDOC_MISSING_TYPE_WARNING",
+            "JSC_REFERENCE_BEFORE_DECLARE",
+            "JSC_UNNECESSARY_ESCAPE",
+            "JSC_UNUSED_LOCAL_ASSIGNMENT",
+        ],
+        deps = [
+            "//lib/polymer_externs:polymer_closure",
+            "@io_bazel_rules_closure//closure/library",
+        ],
+    )
 
-  vulcanize(
-    name = appName,
-    srcs = srcs,
-    app = app,
-    deps = ["//polygerrit-ui:polygerrit_components.bower_components"],
-  )
+    bundle_assets(
+        name = appName,
+        srcs = srcs,
+        app = app,
+        deps = ["//polygerrit-ui:polygerrit_components.bower_components"],
+    )
 
-  native.filegroup(
-    name = name + "_app_sources",
-    srcs = [
-      name + "_closure_bin.js",
-      appName + ".html",
-    ],
-  )
+    native.filegroup(
+        name = name + "_app_sources",
+        srcs = [
+            name + "_closure_bin.js",
+            appName + ".html",
+        ],
+    )
 
-  native.filegroup(
-    name = name + "_css_sources",
-    srcs = native.glob(["styles/**/*.css"]),
-  )
+    native.filegroup(
+        name = name + "_css_sources",
+        srcs = native.glob(["styles/**/*.css"]),
+    )
 
-  native.filegroup(
-    name = name + "_theme_sources",
-    srcs = native.glob(
-      ["styles/themes/*.html"],
-      # app-theme.html already included via an import in gr-app.html.
-      exclude = ["styles/themes/app-theme.html"],
-    ),
-  )
+    native.filegroup(
+        name = name + "_theme_sources",
+        srcs = native.glob(
+            ["styles/themes/*.html"],
+            # app-theme.html already included via an import in gr-app.html.
+            exclude = ["styles/themes/app-theme.html"],
+        ),
+    )
 
-  native.filegroup(
-    name = name + "_top_sources",
-    srcs = [
-        "favicon.ico",
-    ],
-  )
+    native.filegroup(
+        name = name + "_top_sources",
+        srcs = [
+            "favicon.ico",
+        ],
+    )
 
-  genrule2(
-    name = name,
-    srcs = [
-      name + "_app_sources",
-      name + "_css_sources",
-      name + "_theme_sources",
-      name + "_top_sources",
-      "//lib/fonts:robotofonts",
-      "//lib/js:highlightjs_files",
-      # we extract from the zip, but depend on the component for license checking.
-      "@webcomponentsjs//:zipfile",
-      "//lib/js:webcomponentsjs"
-    ],
-    outs = outs,
-    cmd = " && ".join([
-      "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
-      "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/"  + appName + ".$$ext; done",
-      "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
-      "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
-      "for f in $(locations "+ name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
-      "for f in $(locations "+ name + "_theme_sources); do cp $$f $$TMP/polygerrit_ui/styles/themes; done",
-      "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
-      "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
-      "cd $$TMP",
-      "find . -exec touch -t 198001010000 '{}' ';'",
-      "zip -qr $$ROOT/$@ *",
-    ]),
-  )
+    genrule2(
+        name = name,
+        srcs = [
+            name + "_app_sources",
+            name + "_css_sources",
+            name + "_theme_sources",
+            name + "_top_sources",
+            "//lib/fonts:robotofonts",
+            "//lib/js:highlightjs_files",
+            # we extract from the zip, but depend on the component for license checking.
+            "@webcomponentsjs//:zipfile",
+            "//lib/js:webcomponentsjs",
+        ],
+        outs = outs,
+        cmd = " && ".join([
+            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
+            "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + appName + ".$$ext; done",
+            "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
+            "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
+            "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
+            "for f in $(locations " + name + "_theme_sources); do cp $$f $$TMP/polygerrit_ui/styles/themes; done",
+            "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
+            "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
+            "cd $$TMP",
+            "find . -exec touch -t 198001010000 '{}' ';'",
+            "zip -qr $$ROOT/$@ *",
+        ]),
+    )
diff --git a/polygerrit-ui/app/run_template_test.sh b/polygerrit-ui/app/run_template_test.sh
index 4cd6e7f..d2b6989 100755
--- a/polygerrit-ui/app/run_template_test.sh
+++ b/polygerrit-ui/app/run_template_test.sh
@@ -3,7 +3,7 @@
 if [[ -z "${TEMPLATE_NO_DEFAULT}" ]]; then
 bazel test \
       --test_env="HOME=$HOME" \
-      //polygerrit-ui/app:all
+      //polygerrit-ui/app:all \
       --test_tag_filters=template \
       "$@" \
       --test_output errors \
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index df210b8..3d92e11 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -6,9 +6,29 @@
     exit 1
 fi
 
+# From https://www.linuxquestions.org/questions/programming-9/bash-script-return-full-path-and-filename-680368/page3.html
+function abs_path {
+  if [[ -d "$1" ]]
+  then
+      pushd "$1" >/dev/null
+      pwd
+      popd >/dev/null
+  elif [[ -e $1 ]]
+  then
+      pushd "$(dirname "$1")" >/dev/null
+      echo "$(pwd)/$(basename "$1")"
+      popd >/dev/null
+  else
+      echo "$1" does not exist! >&2
+      return 127
+  fi
+}
 wct_bin=$(which wct)
 if [[ -z "$wct_bin" ]]; then
-    echo "WCT must be on the path. (https://github.com/Polymer/web-component-tester)"
+  wct_bin=$(abs_path ./node_modules/web-component-tester/bin/wct);
+fi
+if [[ -z "$wct_bin" ]]; then
+    echo "wct_bin must be set or WCT locally installed (npm install wct)."
     exit 1
 fi
 
diff --git a/polygerrit-ui/app/samples/bind-parameters.html b/polygerrit-ui/app/samples/bind-parameters.html
index dc7a87a..a7eb39a 100644
--- a/polygerrit-ui/app/samples/bind-parameters.html
+++ b/polygerrit-ui/app/samples/bind-parameters.html
@@ -15,6 +15,7 @@
   <script>
     Polymer({
       is: 'my-bind-sample',
+      _legacyUndefinedCheck: true,
       properties: {
         computedExample: {
           type: String,
diff --git a/polygerrit-ui/app/samples/coverage-plugin.html b/polygerrit-ui/app/samples/coverage-plugin.html
index 9bec658..f8b5560 100644
--- a/polygerrit-ui/app/samples/coverage-plugin.html
+++ b/polygerrit-ui/app/samples/coverage-plugin.html
@@ -50,6 +50,7 @@
           const linesMissingCoverage = coverageData[path].linesMissingCoverage;
           if (linesMissingCoverage.includes(line.afterNumber)) {
             context.annotateRange(0, line.text.length, cssClass, 'right');
+            context.annotateLineNumber(cssClass, 'right');
           }
         }
       }).enableToggleCheckbox('Display Coverage', checkbox => {
diff --git a/polygerrit-ui/app/samples/repo-command.html b/polygerrit-ui/app/samples/repo-command.html
index 67e528a..37aca04 100644
--- a/polygerrit-ui/app/samples/repo-command.html
+++ b/polygerrit-ui/app/samples/repo-command.html
@@ -29,6 +29,7 @@
   <script>
     Polymer({
       is: 'repo-command-low',
+      _legacyUndefinedCheck: true,
       attached() {
         console.log(this.repoName);
         console.log(this.config);
diff --git a/polygerrit-ui/app/samples/some-screen.html b/polygerrit-ui/app/samples/some-screen.html
index de29315..527ebce 100644
--- a/polygerrit-ui/app/samples/some-screen.html
+++ b/polygerrit-ui/app/samples/some-screen.html
@@ -38,6 +38,7 @@
   <script>
     Polymer({
       is: 'some-screen-main',
+      _legacyUndefinedCheck: true,
       properties: {
         rootUrl: String,
       },
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index b4ab21a..624992b 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -41,5 +41,39 @@
     }
     return '';
   };
+
+  /**
+   * Make the promise cancelable.
+   *
+   * Returns a promise with a `cancel()` method wrapped around `promise`.
+   * Calling `cancel()` will reject the returned promise with
+   * {isCancelled: true} synchronously. If the inner promise for a cancelled
+   * promise resolves or rejects this is ignored.
+   */
+  util.makeCancelable = promise => {
+    // True if the promise is either resolved or reject (possibly cancelled)
+    let isDone = false;
+
+    let rejectPromise;
+
+    const wrappedPromise = new Promise((resolve, reject) => {
+      rejectPromise = reject;
+      promise.then(val => {
+        if (!isDone) resolve(val);
+        isDone = true;
+      }, error => {
+        if (!isDone) reject(error);
+        isDone = true;
+      });
+    });
+
+    wrappedPromise.cancel = () => {
+      if (isDone) return;
+      rejectPromise({isCanceled: true});
+      isDone = true;
+    };
+    return wrappedPromise;
+  };
+
   window.util = util;
 })(window);
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.html b/polygerrit-ui/app/styles/dashboard-header-styles.html
index a88f68c..ccc17b0 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.html
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.html
@@ -21,7 +21,7 @@
       :host {
         background-color: var(--view-background-color);
         display: block;
-        height: 9em;
+        min-height: 9em;
         width: 100%;
       }
       gr-avatar {
@@ -39,7 +39,7 @@
       }
       .info > div > span {
         display: inline-block;
-        font-weight: bold;
+        font-weight: var(--font-weight-bold);
         text-align: right;
         width: 4em;
       }
diff --git a/polygerrit-ui/app/styles/fonts.css b/polygerrit-ui/app/styles/fonts.css
index 6a5da44..41aec27 100644
--- a/polygerrit-ui/app/styles/fonts.css
+++ b/polygerrit-ui/app/styles/fonts.css
@@ -42,9 +42,9 @@
 
 /* latin-ext */
 @font-face {
-  font-family: 'Roboto Medium';
+  font-family: 'Roboto';
   font-style: normal;
-  font-weight: 400;
+  font-weight: 500;
   src: local('Roboto Medium'), local('Roboto-Medium'),
        url('../fonts/Roboto-Medium.woff2') format('woff2'),
        url('../fonts/Roboto-Medium.woff') format('woff');
@@ -52,11 +52,11 @@
 }
 /* latin */
 @font-face {
-  font-family: 'Roboto Medium';
+  font-family: 'Roboto';
   font-style: normal;
-  font-weight: 400;
+  font-weight: 500;
   src: local('Roboto Medium'), local('Roboto-Medium'),
        url('../fonts/Roboto-Medium.woff2') format('woff2'),
        url('../fonts/Roboto-Medium.woff') format('woff');
   unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
-}
\ No newline at end of file
+}
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index 6d7469b..8f72216 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -55,8 +55,8 @@
       .cell {
         vertical-align: middle;
       }
-      th:not(.label),
-      .cell:not(.label) {
+      th:not(.label):not(.endpoint),
+      .cell:not(.label):not(.endpoint) {
         padding-right: 8px;
       }
       th.label {
@@ -64,7 +64,7 @@
       }
       .topHeader,
       .groupHeader {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       .topHeader th {
         background-color: var(--table-header-background-color);
@@ -116,7 +116,7 @@
       .updated,
       .size,
       .status,
-      .project {
+      .repo {
         white-space: nowrap;
       }
       .star {
@@ -128,15 +128,17 @@
       .star {
         width: 30px;
       }
-      .label {
+      .label, .endpoint {
         border-left: 1px solid var(--border-color);
+      }
+      .label {
         text-align: center;
         width: 3rem;
       }
       .topHeader .label {
         border: none;
       }
-      .truncatedProject {
+      .truncatedRepo {
         display: none;
       }
       @media only screen and (max-width: 150em) {
@@ -147,10 +149,10 @@
           max-width: 18rem;
           text-overflow: ellipsis;
         }
-        .truncatedProject {
+        .truncatedRepo {
           display: inline-block;
         }
-        .fullProject {
+        .fullRepo {
           display: none;
         }
       }
@@ -186,7 +188,7 @@
         .topHeader,
         .leftPadding,
         .status,
-        .project,
+        .repo,
         .branch,
         .updated,
         .label,
@@ -207,6 +209,10 @@
         .size {
           max-width: none;
         }
+        .noChanges .cell {
+          display: block;
+          height: auto;
+        }
       }
       @media only screen and (min-width: 1450px) {
         :host {
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
index 3cfd1d5c..65c1ae3 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -29,11 +29,15 @@
       .gr-form-styles h2 {
         margin-bottom: .3em;
       }
+      .gr-form-styles h4 {
+        font-weight: var(--font-weight-bold);
+      }
       .gr-form-styles fieldset {
         border: none;
         margin-bottom: 2em;
       }
       .gr-form-styles section {
+        display: flex;
         margin: .25em 0;
         min-height: 2em;
       }
@@ -46,7 +50,7 @@
       }
       .gr-form-styles .title {
         color: var(--deemphasized-text-color);
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         padding-right: .5em;
         width: 15em;
       }
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.html b/polygerrit-ui/app/styles/gr-page-nav-styles.html
index 49aa033..18ec143 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.html
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.html
@@ -45,14 +45,14 @@
         margin-top: 1em;
       }
       .navStyles .title {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         margin: .4em 0;
       }
       .navStyles .selected {
         background-color: var(--view-background-color);
         border-bottom: 1px solid var(--border-color);
         border-top: 1px solid var(--border-color);
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       .navStyles a {
         color: var(--primary-text-color);
diff --git a/polygerrit-ui/app/styles/gr-table-styles.html b/polygerrit-ui/app/styles/gr-table-styles.html
index 79d8100..1308952 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.html
+++ b/polygerrit-ui/app/styles/gr-table-styles.html
@@ -68,7 +68,7 @@
       .genericList .topHeader,
       .genericList .groupHeader {
         color: var(--primary-text-color);
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
         text-align: left;
         vertical-align: middle
       }
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.html b/polygerrit-ui/app/styles/gr-voting-styles.html
index 96c8026..3b1ee64 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.html
+++ b/polygerrit-ui/app/styles/gr-voting-styles.html
@@ -21,9 +21,9 @@
       :host {
         --vote-chip-styles: {
           border: 1px solid rgba(0,0,0,.12);
-          border-radius: 12px;
+          border-radius: 1em;
           box-shadow: none;
-          min-width: 40px;
+          min-width: 3em;
         }
       }
     </style>
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index 0c798f4..78abe3a 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -72,15 +72,15 @@
       /* Other Shared Styles*/
       h1 {
         font-size: 2rem;
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       h2 {
         font-size: 1.5rem;
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       h3 {
         font-size: 1.17em;
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       iron-icon {
         color: var(--deemphasized-text-color);
@@ -104,7 +104,7 @@
         --paper-toggle-button-checked-button-color: var(--link-color);
       }
       strong {
-        font-family: var(--font-family-bold);
+        font-weight: var(--font-weight-bold);
       }
       :host {
         color: var(--primary-text-color);
diff --git a/polygerrit-ui/app/styles/themes/app-theme.html b/polygerrit-ui/app/styles/themes/app-theme.html
index 21db329..ddac866 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.html
+++ b/polygerrit-ui/app/styles/themes/app-theme.html
@@ -14,8 +14,8 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<style is="custom-style">
-:root {
+<custom-style><style is="custom-style">
+html {
   /* Following vars have LTS for plugin API. */
   --primary-text-color: #000;
   --header-background-color: #eee;
@@ -34,7 +34,7 @@
   --default-horizontal-margin: 1rem;
   --deemphasized-text-color: #757575;
   --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-  --font-family-bold: 'Roboto Medium', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  --font-weight-bold: 500;
   --monospace-font-family: 'Roboto Mono', Menlo, 'Lucida Console', Monaco, monospace;
   --iron-overlay-backdrop: {
     transition: none;
@@ -76,6 +76,9 @@
   --vote-color-disliked: #f7c4cb;
   --vote-color-neutral: #ebf5fb;
 
+  --vote-text-color-recommended: #388E3C;
+  --vote-text-color-disliked: #D32F2F;
+
   /* Diff colors */
   --diff-selection-background-color: #c7dbf9;
   --light-remove-highlight-color: #FFEBEE;
@@ -86,12 +89,17 @@
   --dark-add-highlight-color: #AAF2AA;
   --dark-rebased-remove-highlight-color: #F7E8B7;
   --dark-rebased-add-highlight-color: #D7D7F9;
-  --diff-context-control-color: #fff7d4;
+  --diff-context-control-color: var(--deemphasized-text-color);
+  --diff-context-control-background-color: #fff7d4;
   --diff-context-control-border-color: #f6e6a5;
   --diff-tab-indicator-color: var(--deemphasized-text-color);
   --diff-trailing-whitespace-indicator: #ff9ad2;
   --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
   --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
+  --diff-blank-background-color: #fff;
+
+  --shell-command-background-color: #f5f5f5;
+  --shell-command-decoration-background-color: #ebebeb;
 
   --comment-text-color: #000;
   --comment-background-color: #fcfad6;
@@ -103,6 +111,8 @@
   --tooltip-text-color: #fff;
 
   --syntax-default-color: var(--primary-text-color);
+  --syntax-attribute-color: var(--primary-text-color);
+  --syntax-function-color: var(--primary-text-color);
   --syntax-meta-color: #FF1717;
   --syntax-keyword-color: #9E0069;
   --syntax-number-color: #164;
@@ -124,10 +134,13 @@
   --syntax-regexp-color: #FA8602;
   --syntax-selector-attr-color: #FA8602;
   --syntax-template-tag-color: #FA8602;
+  --syntax-param-color: var(--primary-text-color);
+
+  --reply-overlay-z-index: 1000;
 }
 @media screen and (max-width: 50em) {
-  :root {
+  html {
     --default-horizontal-margin: .7rem;
   }
 }
-</style>
+</style></custom-style>
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
index 1f473da..36d1812 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ b/polygerrit-ui/app/styles/themes/dark-theme.html
@@ -1,11 +1,11 @@
 <dom-module id="dark-theme">
-  <style is="custom-style">
+  <custom-style><style is="custom-style">
     html {
       --primary-text-color: #e2e2e2;
       --view-background-color: #212121;
       --border-color: #555555;
       --table-header-background-color: #353637;
-      --table-subheader-background-color: rgb(23, 27, 31);
+      --table-subheader-background-color: rgb(19, 20, 22);
       --header-background-color: #5487E5;
       --header-text-color: var(--primary-text-color);
       --deemphasized-text-color: #9a9a9a;
@@ -29,19 +29,24 @@
       --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);
       --dark-add-highlight-color: rgba(0, 255, 0, 0.15);
       --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
       --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
-      --diff-context-control-color: var(--table-header-background-color);
+      --diff-context-control-color: var(--deemphasized-text-color);
+      --diff-context-control-background-color: var(--table-header-background-color);
       --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-blank-background-color: #212121;
 
       --vote-color-approved: rgb(127, 182, 107);
       --vote-color-recommended: rgb(63, 103, 50);
@@ -77,7 +82,9 @@
       --syntax-selector-attr-color: #80CBBF;
       --syntax-template-tag-color: #C792EA;
 
+      --reply-overlay-z-index: 1000;
+
       background-color: var(--view-background-color);
     }
-  </style>
-</dom-module>
\ No newline at end of file
+  </style></custom-style>
+</dom-module>
diff --git a/polygerrit-ui/app/template_test.sh b/polygerrit-ui/app/template_test.sh
index fcadc1b..b1a2380 100755
--- a/polygerrit-ui/app/template_test.sh
+++ b/polygerrit-ui/app/template_test.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/bin/bash
 
 set -ex
 
@@ -14,19 +14,6 @@
     exit 1
 fi
 
-fried_twinkie_config=$(npm list -g | grep -c fried-twinkie)
-if [ -z "$npm_bin" ] || [ "$fried_twinkie_config" -eq "0" ]; then
-    echo "You must install fried twinkie and its dependencies from NPM."
-    echo "> npm install -g fried-twinkie"
-    exit 1
-fi
-
-twinkie_version=$(npm list -g fried-twinkie@\>0.1 | grep fried-twinkie || :)
-if [ -z "$twinkie_version" ]; then
-    echo "Outdated version of fried-twinkie found. Bypassing template check."
-    exit 0
-fi
-
 # Have to find where node_modules are installed and set the NODE_PATH
 
 get_node_path() {
diff --git a/polygerrit-ui/app/test/common-test-setup.html b/polygerrit-ui/app/test/common-test-setup.html
index 92b99e3..696f6a5 100644
--- a/polygerrit-ui/app/test/common-test-setup.html
+++ b/polygerrit-ui/app/test/common-test-setup.html
@@ -17,7 +17,8 @@
 -->
 
 <link rel="import"
-    href="../bower_components/polymer-resin/standalone/polymer-resin.html" />
+    href="/bower_components/polymer-resin/standalone/polymer-resin.html" />
+<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
 <script>
   security.polymer_resin.install({
     allowedIdentifierPrefixes: [''],
@@ -32,6 +33,7 @@
             + JSON.stringify(args));
       }
     },
+    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
   });
 </script>
 <script>
@@ -58,5 +60,6 @@
   })();
 </script>
 <link rel="import"
-    href="../bower_components/iron-test-helpers/iron-test-helpers.html" />
+    href="/bower_components/iron-test-helpers/iron-test-helpers.html" />
 <link rel="import" href="test-router.html" />
+<script src="/bower_components/moment/moment.js"></script>
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
new file mode 100644
index 0000000..7ceff7e
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Helps looking up the proper iron-input element during the Polymer 2
+ * transition. Polymer 2 uses the <iron-input> element, while Polymer 1 uses
+ * the nested <input is="iron-input"> element.
+ */
+window.ironInput = function(element) {
+  return Polymer.dom(element).querySelector(
+      Polymer.Element ? 'iron-input' : 'input[is=iron-input]');
+};
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 5a5dbcd..2b052a0 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -19,8 +19,8 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>Elements Test Runner</title>
 <meta charset="utf-8">
-<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
 <script>
   const testFiles = [];
   const elementsPath = '../elements/';
@@ -44,6 +44,7 @@
     'admin/gr-group-members/gr-group-members_test.html',
     'admin/gr-group/gr-group_test.html',
     'admin/gr-permission/gr-permission_test.html',
+    'admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html',
     'admin/gr-plugin-list/gr-plugin-list_test.html',
     'admin/gr-repo-access/gr-repo-access_test.html',
     'admin/gr-repo-command/gr-repo-command_test.html',
@@ -51,11 +52,14 @@
     'admin/gr-repo-dashboards/gr-repo-dashboards_test.html',
     'admin/gr-repo-detail-list/gr-repo-detail-list_test.html',
     'admin/gr-repo-list/gr-repo-list_test.html',
+    'admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html',
     'admin/gr-repo/gr-repo_test.html',
     'admin/gr-rule-editor/gr-rule-editor_test.html',
     'change-list/gr-change-list-item/gr-change-list-item_test.html',
     'change-list/gr-change-list-view/gr-change-list-view_test.html',
     'change-list/gr-change-list/gr-change-list_test.html',
+    'change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html',
+    'change-list/gr-create-change-help/gr-create-change-help_test.html',
     'change-list/gr-dashboard-view/gr-dashboard-view_test.html',
     'change-list/gr-user-header/gr-user-header_test.html',
     'change/gr-account-entry/gr-account-entry_test.html',
@@ -63,14 +67,17 @@
     'change/gr-change-actions/gr-change-actions_test.html',
     'change/gr-change-metadata/gr-change-metadata-it_test.html',
     'change/gr-change-metadata/gr-change-metadata_test.html',
+    'change/gr-change-requirements/gr-change-requirements_test.html',
     'change/gr-change-view/gr-change-view_test.html',
     'change/gr-comment-list/gr-comment-list_test.html',
     'change/gr-commit-info/gr-commit-info_test.html',
     'change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html',
     'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
+    'change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html',
     'change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html',
     'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
     'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
+    'change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html',
     'change/gr-download-dialog/gr-download-dialog_test.html',
     'change/gr-file-list-header/gr-file-list-header_test.html',
     'change/gr-file-list/gr-file-list_test.html',
@@ -84,8 +91,12 @@
     '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-key-binding-display/gr-key-binding-display_test.html',
+    'core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html',
     'core/gr-main-header/gr-main-header_test.html',
     'core/gr-navigation/gr-navigation_test.html',
     'core/gr-reporting/gr-jank-detector_test.html',
@@ -95,14 +106,10 @@
     'core/gr-smart-search/gr-smart-search_test.html',
     'diff/gr-comment-api/gr-comment-api_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html',
-    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
-    'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
     'diff/gr-diff-highlight/gr-diff-highlight_test.html',
     'diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html',
-    'diff/gr-diff-preferences/gr-diff-preferences_test.html',
     'diff/gr-diff-processor/gr-diff-processor_test.html',
     'diff/gr-diff-selection/gr-diff-selection_test.html',
     'diff/gr-diff-view/gr-diff-view_test.html',
@@ -112,6 +119,7 @@
     'diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html',
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
+    'documentation/gr-documentation-search/gr-documentation-search_test.html',
     'edit/gr-default-editor/gr-default-editor_test.html',
     'edit/gr-edit-controls/gr-edit-controls_test.html',
     'edit/gr-edit-file-controls/gr-edit-file-controls_test.html',
@@ -149,10 +157,13 @@
     'shared/gr-button/gr-button_test.html',
     'shared/gr-change-star/gr-change-star_test.html',
     'shared/gr-change-status/gr-change-status_test.html',
-    'shared/gr-confirm-dialog/gr-confirm-dialog_test.html',
+    'shared/gr-comment-thread/gr-comment-thread_test.html',
+    'shared/gr-comment/gr-comment_test.html',
     'shared/gr-copy-clipboard/gr-copy-clipboard_test.html',
     'shared/gr-cursor-manager/gr-cursor-manager_test.html',
     'shared/gr-date-formatter/gr-date-formatter_test.html',
+    'shared/gr-dialog/gr-dialog_test.html',
+    'shared/gr-diff-preferences/gr-diff-preferences_test.html',
     'shared/gr-download-commands/gr-download-commands_test.html',
     'shared/gr-dropdown-list/gr-dropdown-list_test.html',
     'shared/gr-editable-content/gr-editable-content_test.html',
@@ -164,12 +175,14 @@
     'shared/gr-js-api-interface/gr-plugin-endpoints_test.html',
     'shared/gr-js-api-interface/gr-plugin-rest-api_test.html',
     'shared/gr-fixed-panel/gr-fixed-panel_test.html',
+    'shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html',
     'shared/gr-lib-loader/gr-lib-loader_test.html',
     'shared/gr-limited-text/gr-limited-text_test.html',
     'shared/gr-linked-chip/gr-linked-chip_test.html',
     'shared/gr-linked-text/gr-linked-text_test.html',
     'shared/gr-list-view/gr-list-view_test.html',
     'shared/gr-page-nav/gr-page-nav_test.html',
+    'shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html',
     'shared/gr-rest-api-interface/gr-auth_test.html',
     'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
     'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
@@ -184,7 +197,6 @@
   for (let file of elements) {
     file = elementsPath + file;
     testFiles.push(file);
-    testFiles.push(file + '?dom=shadow');
   }
 
   // Behaviors tests.
@@ -203,6 +215,8 @@
     'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
     'gr-path-list-behavior/gr-path-list-behavior_test.html',
     'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
+    'gr-url-encoding-behavior/gr-url-encoding-behavior_test.html',
+    'safe-types-behavior/safe-types-behavior_test.html',
   ];
   /* eslint-enable max-len */
   for (let file of behaviors) {
diff --git a/polygerrit-ui/app/wct_test.sh b/polygerrit-ui/app/wct_test.sh
index a8394cd..f1b4666 100755
--- a/polygerrit-ui/app/wct_test.sh
+++ b/polygerrit-ui/app/wct_test.sh
@@ -14,8 +14,7 @@
 
 if [ "${WCT_HEADLESS_MODE:-0}" != "0" ]; then
     CHROME_OPTIONS=[\'start-maximized\',\'headless\',\'disable-gpu\',\'no-sandbox\']
-    # TODO(paladox): Fix Firefox support for headless mode
-    FIREFOX_OPTIONS=[\'\']
+    FIREFOX_OPTIONS=[\'-headless\']
 else
     CHROME_OPTIONS=[\'start-maximized\']
     FIREFOX_OPTIONS=[\'\']
@@ -60,9 +59,9 @@
     };
 EOF
 
-export PATH="$(dirname $WCT):$(dirname $NPM):$PATH"
+export PATH="$(dirname $NPM):$PATH"
 
 cd $t
 test -n "${WCT}"
 
-$(basename ${WCT}) ${WCT_ARGS}
+${WCT} ${WCT_ARGS}
diff --git a/polygerrit-ui/run-server.sh b/polygerrit-ui/run-server.sh
index cbe3563..64bca68 100755
--- a/polygerrit-ui/run-server.sh
+++ b/polygerrit-ui/run-server.sh
@@ -14,23 +14,7 @@
 # limitations under the License.
 
 set -eu
-
-while [[ ! -f WORKSPACE && "$PWD" != / ]]; do
-  cd ..
-done
-if [[ ! -f WORKSPACE ]]; then
-  echo "$(basename "$0"): must be run from a gerrit checkout" 1>&2
-  exit 1
-fi
-
-bazel build \
-  //polygerrit-ui/app:test_components \
-  //polygerrit-ui:fonts.zip
-
-cd polygerrit-ui/app
-rm -rf bower_components
-unzip -q ../../bazel-bin/polygerrit-ui/app/test_components.zip
-rm -rf fonts
-unzip -q ../../bazel-bin/polygerrit-ui/fonts.zip -d fonts
-cd ..
-exec go run server.go "$@"
+SCRIPTNAME=$(mktemp)
+trap "{ rm -f $SCRIPTNAME; }" EXIT
+bazel run --script_path="$SCRIPTNAME" //polygerrit-ui:devserver
+"$SCRIPTNAME" "$@"
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index f44f4d7..e849469 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -15,7 +15,9 @@
 package main
 
 import (
+	"archive/zip"
 	"bufio"
+	"bytes"
 	"compress/gzip"
 	"encoding/json"
 	"errors"
@@ -26,85 +28,113 @@
 	"net"
 	"net/http"
 	"net/url"
+	"os"
+	"path/filepath"
 	"regexp"
 	"strings"
 
-	"github.com/robfig/soy"
+	"golang.org/x/tools/godoc/vfs/httpfs"
+	"golang.org/x/tools/godoc/vfs/zipfs"
 )
 
 var (
-	restHost = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
-	port     = flag.String("port", ":8081", "Port to serve HTTP requests on")
-	prod     = flag.Bool("prod", false, "Serve production assets")
-	scheme   = flag.String("scheme", "https", "URL scheme")
-	plugins  = flag.String("plugins", "", "comma seperated plugin paths to serve")
-
-	tofu, _ = soy.NewBundle().
-		AddTemplateFile("../resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy").
-		CompileToTofu()
+	plugins    = flag.String("plugins", "", "comma seperated plugin paths to serve")
+	port       = flag.String("port", ":8081", "Port to serve HTTP requests on")
+	host       = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
+	scheme     = flag.String("scheme", "https", "URL scheme")
+	cdnPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
 )
 
 func main() {
 	flag.Parse()
 
-	http.HandleFunc("/index.html", handleIndex)
-
-	if *prod {
-		http.Handle("/", http.FileServer(http.Dir("dist")))
-	} else {
-		http.Handle("/", http.FileServer(http.Dir("app")))
+	fontsArchive, err := openDataArchive("fonts.zip")
+	if err != nil {
+		log.Fatal(err)
 	}
 
-	http.HandleFunc("/changes/", handleRESTProxy)
-	http.HandleFunc("/accounts/", handleRESTProxy)
-	http.HandleFunc("/config/", handleRESTProxy)
-	http.HandleFunc("/projects/", handleRESTProxy)
+	componentsArchive, err := openDataArchive("app/test_components.zip")
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	workspace := os.Getenv("BUILD_WORKSPACE_DIRECTORY")
+	if err := os.Chdir(filepath.Join(workspace, "polygerrit-ui")); err != nil {
+		log.Fatal(err)
+	}
+
+	http.Handle("/", http.FileServer(http.Dir("app")))
+	http.Handle("/bower_components/",
+		http.FileServer(httpfs.New(zipfs.New(componentsArchive, "bower_components"))))
+	http.Handle("/fonts/",
+		http.FileServer(httpfs.New(zipfs.New(fontsArchive, "fonts"))))
+
+	http.HandleFunc("/index.html", handleIndex)
+	http.HandleFunc("/changes/", handleProxy)
+	http.HandleFunc("/accounts/", handleProxy)
+	http.HandleFunc("/config/", handleProxy)
+	http.HandleFunc("/projects/", handleProxy)
+	http.HandleFunc("/static/", handleProxy)
 	http.HandleFunc("/accounts/self/detail", handleAccountDetail)
+
 	if len(*plugins) > 0 {
 		http.Handle("/plugins/", http.StripPrefix("/plugins/",
 			http.FileServer(http.Dir("../plugins"))))
 		log.Println("Local plugins from", "../plugins")
 	} else {
-		http.HandleFunc("/plugins/", handleRESTProxy)
+		http.HandleFunc("/plugins/", handleProxy)
 	}
 	log.Println("Serving on port", *port)
 	log.Fatal(http.ListenAndServe(*port, &server{}))
 }
 
-func handleIndex(w http.ResponseWriter, r *http.Request) {
-	var obj = map[string]interface{}{
-		"canonicalPath":      "",
-		"staticResourcePath": "",
+func openDataArchive(path string) (*zip.ReadCloser, error) {
+	absBinPath, err := resourceBasePath()
+	if err != nil {
+		return nil, err
 	}
-	w.Header().Set("Content-Type", "text/html")
-	tofu.Render(w, "com.google.gerrit.httpd.raw.Index", obj)
+	return zip.OpenReader(absBinPath + ".runfiles/gerrit/polygerrit-ui/" + path)
 }
 
-func handleRESTProxy(w http.ResponseWriter, r *http.Request) {
-	req := &http.Request{
+func resourceBasePath() (string, error) {
+	return filepath.Abs(os.Args[0])
+}
+
+func handleIndex(writer http.ResponseWriter, originalRequest *http.Request) {
+	fakeRequest := &http.Request{
+		URL: &url.URL{
+			Path: "/",
+			RawQuery: originalRequest.URL.RawQuery,
+		},
+	}
+	handleProxy(writer, fakeRequest)
+}
+
+func handleProxy(writer http.ResponseWriter, originalRequest *http.Request) {
+	patchedRequest := &http.Request{
 		Method: "GET",
 		URL: &url.URL{
 			Scheme:   *scheme,
-			Host:     *restHost,
-			Opaque:   r.URL.EscapedPath(),
-			RawQuery: r.URL.RawQuery,
+			Host:     *host,
+			Opaque:   originalRequest.URL.EscapedPath(),
+			RawQuery: originalRequest.URL.RawQuery,
 		},
 	}
-	res, err := http.DefaultClient.Do(req)
+	response, err := http.DefaultClient.Do(patchedRequest)
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		http.Error(writer, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	defer res.Body.Close()
-	for name, values := range res.Header {
+	defer response.Body.Close()
+	for name, values := range response.Header {
 		for _, value := range values {
 			if name != "Content-Length" {
-				w.Header().Add(name, value)
+				writer.Header().Add(name, value)
 			}
 		}
 	}
-	w.WriteHeader(res.StatusCode)
-	if _, err := io.Copy(w, patchResponse(r, res)); err != nil {
+	writer.WriteHeader(response.StatusCode)
+	if _, err := io.Copy(writer, patchResponse(originalRequest, response)); err != nil {
 		log.Println("Error copying response to ResponseWriter:", err)
 		return
 	}
@@ -137,8 +167,10 @@
 	}
 }
 
-func patchResponse(r *http.Request, res *http.Response) io.Reader {
-	switch r.URL.EscapedPath() {
+func patchResponse(req *http.Request, res *http.Response) io.Reader {
+	switch req.URL.EscapedPath() {
+	case "/":
+		return replaceCdn(res.Body)
 	case "/config/server/info":
 		return injectLocalPlugins(res.Body)
 	default:
@@ -146,13 +178,23 @@
 	}
 }
 
-func injectLocalPlugins(r io.Reader) io.Reader {
+func replaceCdn(reader io.Reader) io.Reader {
+	buf := new(bytes.Buffer)
+	buf.ReadFrom(reader)
+	original := buf.String()
+
+	replaced := cdnPattern.ReplaceAllString(original, "")
+
+	return strings.NewReader(replaced)
+}
+
+func injectLocalPlugins(reader io.Reader) io.Reader {
 	if len(*plugins) == 0 {
-		return r
+		return reader
 	}
 	// Skip escape prefix
-	io.CopyN(ioutil.Discard, r, 5)
-	dec := json.NewDecoder(r)
+	io.CopyN(ioutil.Discard, reader, 5)
+	dec := json.NewDecoder(reader)
 
 	var response map[string]interface{}
 	err := dec.Decode(&response)
diff --git a/prologtests/examples/BUILD b/prologtests/examples/BUILD
new file mode 100644
index 0000000..4048bc7
--- /dev/null
+++ b/prologtests/examples/BUILD
@@ -0,0 +1,7 @@
+package(default_visibility = ["//visibility:public"])
+
+sh_test(
+    name = "test_examples",
+    srcs = ["run.sh"],
+    data = glob(["*.pl"]) + ["//:gerrit.war"],
+)
diff --git a/prologtests/examples/README.md b/prologtests/examples/README.md
new file mode 100644
index 0000000..12eb256e
--- /dev/null
+++ b/prologtests/examples/README.md
@@ -0,0 +1,54 @@
+# Prolog Unit Test Examples
+
+## Run all examples
+
+Build a local gerrit.war and then run the script:
+
+    ./run.sh
+
+Note that a local Gerrit server is not needed because
+these unit test examples redefine wrappers of the `gerrit:change\*`
+rules to provide mocked change data.
+
+## Add a new unit test
+
+Please follow the pattern in `t1.pl`, `t2.pl`, or `t3.pl`.
+
+* Put code to be tested in a file, e.g. `rules.pl`.
+  For easy unit testing, split long clauses into short ones
+  and test every positive and negative path.
+
+* Create a new unit test file, e.g. `t1.pl`,
+  which should _load_ the test source file and `utils.pl`.
+
+      % First load all source files and the utils.pl.
+      :- load([aosp_rules,utils]).
+
+      :- begin_tests(t1).  % give this test any name
+
+      % Use test0/1 or test1/1 to verify failed/passed goals.
+
+      :- end_tests(_,0).   % check total pass/fail counts
+
+* Optionally replace calls to gerrit functions that depend on repository.
+  For example, define the following wrappers and in source code, use
+  `change_branch/1` instead of `gerrti:change_branch/1`.
+
+      change_branch(X) :- gerrit:change_branch(X).
+      commit_label(L,U) :- gerrit:commit_label(L,U).
+
+* In unit test file, redefine the gerrit function wrappers and test.
+  For example, in `t3.pl`, we have:
+
+      :- redefine(uploader,1,uploader(user(42))).  % mocked uploader
+      :- test1(uploader(user(42))).
+      :- test0(is_exempt_uploader).
+
+      % is_exempt_uploader/0 is expected to fail because it is
+      % is_exempt_uploader :- uploader(user(Id)), memberchk(Id, [104, 106]).
+
+      % Note that gerrit:remove_label does not depend on Gerrit repository,
+      % so its caller remove_label/1 is tested without any redefinition.
+
+      :- test1(remove_label('MyReview',[],[])).
+      :- test1(remove_label('MyReview',submit(),submit())).
diff --git a/prologtests/examples/aosp_rules.pl b/prologtests/examples/aosp_rules.pl
new file mode 100644
index 0000000..18e8a73
--- /dev/null
+++ b/prologtests/examples/aosp_rules.pl
@@ -0,0 +1,148 @@
+% A simplified and mocked AOSP rules.pl
+
+%%%%% wrapper functions for unit tests
+
+change_branch(X) :- gerrit:change_branch(X).
+change_project(X) :- gerrit:change_project(X).
+commit_author(U,N,M) :- gerrit:commit_author(U,N,M).
+commit_delta(X) :- gerrit:commit_delta(X).
+commit_label(L,U) :- gerrit:commit_label(L,U).
+uploader(X) :- gerrit:uploader(X).
+
+%%%%% true/false conditions
+
+% Special auto-merger accounts.
+is_exempt_uploader :-
+  uploader(user(Id)),
+  memberchk(Id, [104, 106]).
+
+% Build cop overrides everything.
+has_build_cop_override :-
+  commit_label(label('Build-Cop-Override', 1), _).
+
+is_exempt_from_reviews :-
+  or(is_exempt_uploader, has_build_cop_override).
+
+% Some files in selected projects need API review.
+needs_api_review :-
+  commit_delta('^(.*/)?api/|^(system-api/)'),
+  change_project(Project),
+  memberchk(Project, [
+    'platform/external/apache-http',
+    'platform/frameworks/base',
+    'platform/frameworks/support',
+    'platform/packages/services/Car',
+    'platform/prebuilts/sdk'
+  ]).
+
+% Some branches need DrNo review.
+needs_drno_review :-
+  change_branch(Branch),
+  memberchk(Branch, [
+    'refs/heads/my-alpha-dev',
+    'refs/heads/my-beta-dev'
+  ]).
+
+% Some author email addresses need Qualcomm-Review.
+needs_qualcomm_review :-
+  commit_author(_, _, M),
+  regex_matches(
+'.*@(qti.qualcomm.com|qca.qualcomm.com|quicinc.com|qualcomm.com)', M).
+
+% Special projects, branches, user accounts
+% can opt out owners review.
+opt_out_find_owners :-
+  change_branch(Branch),
+  memberchk(Branch, [
+    'refs/heads/my-beta-testing',
+    'refs/heads/my-testing'
+  ]).
+
+% Special projects, branches, user accounts
+% can opt in owners review.
+% Note that opt_out overrides opt_in.
+opt_in_find_owners :- true.
+
+
+%%%%% Simple list filters.
+
+remove_label(X, In, Out) :-
+  gerrit:remove_label(In, label(X, _), Out).
+
+% Slow but simple for short input list.
+remove_review_categories(In, Out) :-
+  remove_label('API-Review', In, L1),
+  remove_label('Code-Review', L1, L2),
+  remove_label('DrNo-Review', L2, L3),
+  remove_label('Owner-Review-Vote', L3, L4),
+  remove_label('Qualcomm-Review', L4, L5),
+  remove_label('Verified', L5, Out).
+
+
+%%%%% Missing rules in Gerrit Prolog Cafe.
+
+or(InA, InB) :- once((A;B)).
+
+not(Goal) :- Goal -> false ; true.
+
+% memberchk(+Element, +List)
+memberchk(X, [H|T]) :-
+  (X = H -> true ; memberchk(X, T)).
+
+maplist(Functor, In, Out) :-
+  (In = []
+  -> Out = []
+  ;  (In = [X1|T1],
+      Out = [X2|T2],
+      Goal =.. [Functor, X1, X2],
+      once(Goal),
+      maplist(Functor, T1, T2)
+     )
+  ).
+
+
+%%%%% Conditional rules and filters.
+
+submit_filter(In, Out) :-
+  (is_exempt_from_reviews
+  -> remove_review_categories(In, Out)
+  ;  (check_review(needs_api_review,
+          'API_Review', In, L1),
+      check_review(needs_drno_review,
+          'DrNo-Review', L1, L2),
+      check_review(needs_qualcomm_review,
+          'Qualcomm-Review', L2, L3),
+      check_find_owners(L3, Out)
+     )
+  ).
+
+check_review(NeedReview, Label, In, Out) :-
+  (NeedReview
+  -> Out = In
+  ;  remove_label(Label, In, Out)
+  ).
+
+% If opt_out_find_owners is true,
+% remove all 'Owner-Review-Vote' label;
+% else if opt_in_find_owners is true,
+%      call find_owners:submit_filter;
+% else default to no find_owners filter.
+check_find_owners(In, Out) :-
+  (opt_out_find_owners
+  -> remove_label('Owner-Review-Vote', In, Temp)
+  ; (opt_in_find_owners
+    -> find_owners:submit_filter(In, Temp)
+    ; In = Temp
+    )
+  ),
+  Temp =.. [submit | L1],
+  remove_label('Owner-Approved', L1, L2),
+  maplist(owner_may_to_need, L2, L3),
+  Out =.. [submit | L3].
+
+% change may(_) to need(_) to block submit.
+owner_may_to_need(In, Out) :-
+  (In = label('Owner-Review-Vote', may(_))
+  -> Out = label('Owner-Review-Vote', need(_))
+  ;  Out = In
+  ).
diff --git a/prologtests/examples/load.pl b/prologtests/examples/load.pl
new file mode 100644
index 0000000..f5b49e8
--- /dev/null
+++ b/prologtests/examples/load.pl
@@ -0,0 +1,26 @@
+% If you have 1.4.3 or older Prolog-Cafe, you need to
+% use (consult(load), load(load)) to get definition of load.
+% Then use load([f1,f2,...]) to load multiple source files.
+
+% Input is a list of file names or a single file name.
+% Use a conditional expression style without cut operator.
+load(X) :-
+  ( (X = [])
+  -> true
+  ; ( (X = [H|T])
+    -> (load_file(H), load(T))
+    ;  load_file(X)
+    )
+  ).
+
+% load_file is '$consult' without the bug of unbound 'File' variable.
+% For repeated unit tests, skip statistics and print_message.
+load_file(F) :- atom(F), !,
+  '$prolog_file_name'(F, PF),
+  open(PF, read, In),
+  % print_message(info, [loading,PF,'...']),
+  % statistics(runtime, _),
+  consult_stream(PF, In),
+  % statistics(runtime, [_,T]),
+  % print_message(info, [PF,'loaded in',T,msec]),
+  close(In).
diff --git a/prologtests/examples/rules.pl b/prologtests/examples/rules.pl
new file mode 100644
index 0000000..1a7b17c
--- /dev/null
+++ b/prologtests/examples/rules.pl
@@ -0,0 +1,29 @@
+% An example source file to be tested.
+
+% Add common rules missing in Prolog Cafe.
+memberchk(X, [H|T]) :-
+  (X = H) -> true ; memberchk(X, T).
+
+% A rule that can succeed/backtrack multiple times.
+super_users(1001).
+super_users(1002).
+
+% Deterministic rule that pass/fail only once.
+is_super_user(X) :- memberchk(X, [1001, 1002]).
+
+% Another rule that can pass 5 times.
+multi_users(101).
+multi_users(102).
+multi_users(103).
+multi_users(104).
+multi_users(105).
+
+% Okay, single deterministic fact.
+single_user(abc).
+
+% Wrap calls to gerrit repository, to be redefined in tests.
+change_owner(X) :- gerrit:change_owner(X).
+
+% To test is_owner without gerrit:change_owner,
+% we should redefine change_owner.
+is_owner(X) :- change_owner(X).
diff --git a/prologtests/examples/run.sh b/prologtests/examples/run.sh
new file mode 100755
index 0000000..947c153
--- /dev/null
+++ b/prologtests/examples/run.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+
+TESTS="t1 t2 t3"
+
+# Note that both t1.pl and t2.pl test code in rules.pl.
+# Unit tests are usually longer than the tested code.
+# So it is common to test one source file with multiple
+# unit test files.
+
+LF=$'\n'
+PASS=""
+FAIL=""
+
+echo "#### TEST_SRCDIR = ${TEST_SRCDIR}"
+
+if [ "${TEST_SRCDIR}" == "" ]; then
+  # Assume running alone
+  GERRIT_WAR="../../bazel-bin/gerrit.war"
+  SRCDIR="."
+else
+  # Assume running from bazel
+  GERRIT_WAR=`pwd`/gerrit.war
+  SRCDIR="prologtests/examples"
+fi
+
+# Default GERRIT_TMP is ~/.gerritcodereview/tmp,
+# which won't be writable in a bazel test sandbox.
+/bin/mkdir -p /tmp/gerrit
+export GERRIT_TMP=/tmp/gerrit
+
+for T in $TESTS
+do
+
+  pushd $SRCDIR
+
+  # Unit tests do not need to define clauses in packages.
+  # Use one prolog-shell per unit test, to avoid name collision.
+  echo "### Running test ${T}.pl"
+  echo "[$T]." | java -jar ${GERRIT_WAR} prolog-shell -q -s load.pl
+
+  if [ "x$?" != "x0" ]; then
+    echo "### Test ${T}.pl failed."
+    FAIL="${FAIL}${LF}FAIL: Test ${T}.pl"
+  else
+    PASS="${PASS}${LF}PASS: Test ${T}.pl"
+  fi
+
+  popd
+
+  # java -jar ../../bazel-bin/gerrit.war prolog-shell -s $T < /dev/null
+  # Calling prolog-shell with -s flag works for small files,
+  # but got run-time exception with t3.pl.
+  #   com.googlecode.prolog_cafe.exceptions.ReductionLimitException:
+  #   exceeded reduction limit of 1048576
+done
+
+echo "$PASS"
+
+if [ "$FAIL" != "" ]; then
+  echo "$FAIL"
+  exit 1
+fi
diff --git a/prologtests/examples/t1.pl b/prologtests/examples/t1.pl
new file mode 100644
index 0000000..caf9061
--- /dev/null
+++ b/prologtests/examples/t1.pl
@@ -0,0 +1,20 @@
+:- load([rules,utils]).
+:- begin_tests(t1).
+
+:- test1(true).     % expect true to pass
+:- test0(false).    % expect false to fail
+
+:- test1(X = 3).    % unification should pass
+:- test1(_ = 3).    % unification should pass
+:- test0(X \= 3).   % not-unified should fail
+
+% (7-4) should have expected result
+:- test1((X is (7-4), X =:= 3)).
+:- test1((X is (7-4), X =\= 4)).
+
+% memberchk should pass/fail exactly once
+:- test1(memberchk(3,[1,3,5,3])).
+:- test0(memberchk(2,[1,3,5,3])).
+:- test0(memberchk(2,[])).
+
+:- end_tests_or_halt(0).  % expect no failure
diff --git a/prologtests/examples/t2.pl b/prologtests/examples/t2.pl
new file mode 100644
index 0000000..9424b53
--- /dev/null
+++ b/prologtests/examples/t2.pl
@@ -0,0 +1,25 @@
+:- load([rules,utils]).
+:- begin_tests(t2).
+
+% expected to pass or fail once.
+:- test0(super_users(1000)).
+:- test1(super_users(1001)).
+
+:- test1(is_super_user(1001)).
+:- test1(is_super_user(1002)).
+:- test0(is_super_user(1003)).
+
+:- test1(super_users(X)).  % expected fail (pass twice)
+:- test1(multi_users(X)).  % expected fail (pass many times)
+
+:- test1(single_user(X)).  % expected pass once
+
+% Redefine change_owner, skip gerrit:change_owner,
+% then test is_owner without a gerrit repository.
+
+:- redefine(change_owner,1,(change_owner(42))).
+:- test1(is_owner(42)).
+:- test1(is_owner(X)).
+:- test0(is_owner(24)).
+
+:- end_tests_or_halt(2).  % expect 2 failures
diff --git a/prologtests/examples/t3.pl b/prologtests/examples/t3.pl
new file mode 100644
index 0000000..02badc0
--- /dev/null
+++ b/prologtests/examples/t3.pl
@@ -0,0 +1,69 @@
+:- load([aosp_rules,utils]).
+
+:- begin_tests(t3_basic_conditions).
+
+%% A negative test of is_exempt_uploader.
+:- redefine(uploader,1,uploader(user(42))).  % mocked uploader
+:- test1(uploader(user(42))).
+:- test0(is_exempt_uploader).
+
+%% Helper functions for positive test of is_exempt_uploader.
+test_is_exempt_uploader(List) :- maplist(test1_uploader, List, _).
+test1_uploader(X,_) :-
+  redefine(uploader,1,uploader(user(X))),
+  test1(uploader(user(X))),
+  test1(is_exempt_uploader).
+:- test_is_exempt_uploader([104, 106]).
+
+%% Test has_build_cop_override.
+:- redefine(commit_label,2,commit_label(label('Code-Review',1),user(102))).
+:- test0(has_build_cop_override).
+commit_label(label('Build-Cop-Override',1),user(101)).  % mocked 2nd label
+:- test1(has_build_cop_override).
+:- test1(commit_label(label(_,_),_)).           % expect fail, two matches
+:- test1(commit_label(label('Build-Cop-Override',_),_)).  % good, one pass
+
+%% TODO: more test for is_exempt_from_reviews.
+
+%% Test needs_api_review, which checks commit_delta and project.
+% Helper functions:
+test_needs_api_review(File, Project, Tester) :-
+  redefine(commit_delta,1,(commit_delta(R) :- regex_matches(R, File))),
+  redefine(change_project,1,change_project(Project)),
+  Goal =.. [Tester, needs_api_review],
+  msg('# check CL with changed file ', File, ' in ', Project),
+  once((Goal ; true)).  % do not backtrack
+
+:- test_needs_api_review('apio/test.cc', 'platform/art', test0).
+:- test_needs_api_review('api/test.cc', 'platform/art', test0).
+:- test_needs_api_review('api/test.cc', 'platform/prebuilts/sdk', test1).
+:- test_needs_api_review('d1/d2/api/test.cc', 'platform/prebuilts/sdk', test1).
+:- test_needs_api_review('system-api/d/t.c', 'platform/external/apache-http', test1).
+
+%% TODO: Test needs_drno_review, needs_qualcomm_review
+
+%% TODO: Test opt_out_find_owners.
+
+:- test1(opt_in_find_owners).  % default, unless opt_out_find_owners
+
+:- end_tests_or_halt(1).  % expect 1 failure of multiple commit_label
+
+%% Test remove_label
+:- begin_tests(t3_remove_label).
+
+:- test1(remove_label('MyReview',[],[])).
+:- test1(remove_label('MyReview',submit(),submit())).
+:- test1(remove_label(myR,[label(a,X)],[label(a,X)])).
+:- test1(remove_label(myR,[label(myR,_)],[])).
+:- test1(remove_label(myR,[label(a,X),label(myR,_)],[label(a,X)])).
+:- test1(remove_label(myR,submit(label(a,X)),submit(label(a,X)))).
+:- test1(remove_label(myR,submit(label(myR,_)),submit())).
+
+%% Test maplist
+double(X,Y) :- Y is X * X.
+:- test1(maplist(double, [2,4,6], [4,16,36])).
+:- test1(maplist(double, [], [])).
+
+:- end_tests_or_halt(0).  % expect no failure
+
+%% TODO: Add more tests.
diff --git a/prologtests/examples/utils.pl b/prologtests/examples/utils.pl
new file mode 100644
index 0000000..8d15067
--- /dev/null
+++ b/prologtests/examples/utils.pl
@@ -0,0 +1,78 @@
+%% Unit test helpers
+
+% Write one line message.
+msg(A) :- write(A), nl.
+msg(A,B) :- write(A), msg(B).
+msg(A,B,C) :- write(A), msg(B,C).
+msg(A,B,C,D) :- write(A), msg(B,C,D).
+msg(A,B,C,D,E) :- write(A), msg(B,C,D,E).
+msg(A,B,C,D,E,F) :- write(A), msg(B,C,D,E,F).
+
+% Redefine a caluse.
+redefine(Atom,Arity,Clause) :- abolish(Atom/Arity), assertz(Clause).
+
+% Increment/decrement of pass/fail counters.
+set_counters(N,X,Y) :- redefine(test_count,3,test_count(N,X,Y)).
+get_counters(N,X,Y) :- clause(test_count(N,X,Y), _) -> true ; (X=0, Y=0).
+inc_pass_count :- get_counters(N,P,F), P1 is P + 1, set_counters(N,P1,F).
+inc_fail_count :- get_counters(N,P,F), F1 is F + 1, set_counters(N,P,F1).
+
+% Report pass or fail of G.
+pass_1(G) :- msg('PASS: ', G), inc_pass_count.
+fail_1(G) :- msg('FAIL: ', G), inc_fail_count.
+
+% Report pass or fail of not(G).
+pass_0(G) :- msg('PASS: not(', G, ')'), inc_pass_count.
+fail_0(G) :- msg('FAIL: not(', G, ')'), inc_fail_count.
+
+% Report a test as failed if it passed 2 or more times
+pass_twice(G) :-
+  msg('FAIL: (pass twice): ', G),
+  inc_fail_count.
+pass_many(G) :-
+  G = [A,B|_],
+  length(G, N),
+  msg('FAIL: (pass ', N, ' times): ', [A,B,'...']),
+  inc_fail_count.
+
+% Test if G fails.
+test0(G) :- once(G) -> fail_0(G) ; pass_0(G).
+
+% Test if G passes exactly once.
+test1(G) :-
+  findall(G, G, S), length(S, N),
+  (N == 0
+   -> fail_1(G)
+   ;  (N == 1
+       -> pass_1(S)
+       ;  (N == 2 -> pass_twice(S) ; pass_many(S))
+      )
+  ).
+
+% Report the begin of test N.
+begin_tests(N) :-
+  nl,
+  msg('BEGIN test ',N),
+  set_counters(N,0,0).
+
+% Repot the end of test N and total pass/fail counts,
+% and check if the numbers are as exected OutP/OutF.
+end_tests(OutP,OutF) :-
+  get_counters(N,P,F),
+  (OutP = P
+   -> msg('Expected #PASS: ', OutP)
+   ;  (msg('ERROR: expected #PASS is ',OutP), !, fail)
+  ),
+  (OutF = F
+   -> msg('Expected #FAIL: ', OutF)
+   ;  (msg('ERROR: expected #FAIL is ',OutF), !, fail)
+  ),
+  msg('END test ', N),
+  nl.
+
+% Repot the end of test N and total pass/fail counts.
+end_tests(N) :- end_tests(N,_,_).
+
+% Call end_tests/2 and halt if the fail count is unexpected.
+end_tests_or_halt(ExpectedFails) :-
+  end_tests(_,ExpectedFails); (flush_output, halt(1)).
diff --git a/proto/BUILD b/proto/BUILD
index 4528dcb..cef28a1 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -8,3 +8,14 @@
     visibility = ["//visibility:public"],
     deps = [":cache_proto"],
 )
+
+proto_library(
+    name = "entities_proto",
+    srcs = ["entities.proto"],
+)
+
+java_proto_library(
+    name = "entities_java_proto",
+    visibility = ["//visibility:public"],
+    deps = [":entities_proto"],
+)
diff --git a/proto/cache.proto b/proto/cache.proto
index a826f8c..77b6908 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -61,10 +61,10 @@
 // were chosen ease of coding the initial implementation. In particular, where
 // there already exists another serialization mechanism in Gerrit for
 // serializing a particular field, we use that rather than defining a new proto
-// type. This includes ReviewDb types that can be serialized to proto using
-// ProtobufCodec as well as NoteDb and indexed types that are serialized using
-// JSON. We can always revisit this decision later, particularly when we
-// eliminate the ReviewDb types; it just requires bumping the cache version.
+// type. This includes types that can be serialized to proto using
+// ProtoConverters as well as NoteDb and indexed types that are serialized using
+// JSON. We can always revisit this decision later; it just requires bumping the
+// cache version.
 //
 // Note on nullability: there are a lot of nullable fields in ChangeNotesState
 // and its dependencies. It's likely we could make some of them non-nullable,
@@ -75,7 +75,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 19
+// Next ID: 20
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -134,10 +134,10 @@
 
   repeated string hashtag = 5;
 
-  // Raw PatchSet proto as produced by ProtobufCodec.
+  // Raw PatchSet proto as produced by PatchSetProtoConverter.
   repeated bytes patch_set = 6;
 
-  // Raw PatchSetApproval proto as produced by ProtobufCodec.
+  // Raw PatchSetApproval proto as produced by PatchSetApprovalProtoConverter.
   repeated bytes approval = 7;
 
   // Next ID: 4
@@ -175,14 +175,17 @@
   // com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord.
   repeated string submit_record = 14;
 
-  // Raw ChangeMessage proto as produced by ProtobufCodec.
+  // Raw ChangeMessage proto as produced by ChangeMessageProtoConverter.
   repeated bytes change_message = 15;
 
   // JSON produced from com.google.gerrit.reviewdb.client.Comment.
   repeated string published_comment = 16;
 
-  int64 read_only_until = 17;
-  bool has_read_only_until = 18;
+  reserved 17;  // read_only_until
+  reserved 18;  // has_read_only_until
+
+  // Number of updates to the change's meta ref.
+  int32 update_count = 19;
 }
 
 
@@ -193,3 +196,52 @@
   string submit_type = 3;
   bool content_merge = 4;
 }
+
+// Serialized form of com.google.gerrit.server.query.git.TagSetHolder.
+// Next ID: 3
+message TagSetHolderProto {
+  string project_name = 1;
+
+  // Next ID: 4
+  message TagSetProto {
+    string project_name = 1;
+
+    // Next ID: 3
+    message CachedRefProto {
+      bytes id = 1;
+      int32 flag = 2;
+    }
+    map<string, CachedRefProto> ref = 2;
+
+    // Next ID: 3
+    message TagProto {
+      bytes id = 1;
+      bytes flags = 2;
+    }
+    repeated TagProto tag = 3;
+  }
+  TagSetProto tags = 2;
+}
+
+// Serialized form of
+// com.google.gerrit.server.account.externalids.AllExternalIds.
+// Next ID: 2
+message AllExternalIdsProto {
+  // Next ID: 6
+  message ExternalIdProto {
+    string key = 1;
+    int32 accountId = 2;
+    string email = 3;
+    string password = 4;
+    bytes blobId = 5;
+  }
+  repeated ExternalIdProto external_id = 1;
+}
+
+// Key for com.google.gerrit.server.git.PureRevertCache.
+// Next ID: 4
+message PureRevertKeyProto {
+  string project = 1;
+  bytes claimed_original = 2;
+  bytes claimed_revert = 3;
+}
diff --git a/proto/entities.proto b/proto/entities.proto
new file mode 100644
index 0000000..153fe4e
--- /dev/null
+++ b/proto/entities.proto
@@ -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.
+
+syntax = "proto2";
+
+package devtools.gerritcodereview;
+
+option java_package = "com.google.gerrit.proto";
+
+// Serialized form of com.google.gerrit.reviewdb.client.Change.Id.
+// Next ID: 2
+message Change_Id {
+  required int32 id = 1;
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.Change.Key.
+// Next ID: 2
+message Change_Key {
+  optional string id = 1;
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.Change.
+// Next ID: 24
+message Change {
+  required Change_Id change_id = 1;
+  optional Change_Key change_key = 2;
+  optional int32 row_version = 3;
+  optional fixed64 created_on = 4;
+  optional fixed64 last_updated_on = 5;
+  optional Account_Id owner_account_id = 7;
+  optional Branch_NameKey dest = 8;
+  optional uint32 status = 10;
+  optional int32 current_patch_set_id = 12;
+  optional string subject = 13;
+  optional string topic = 14;
+  optional string original_subject = 17;
+  optional string submission_id = 18;
+  optional Account_Id assignee = 19;
+  optional bool is_private = 20;
+  optional bool work_in_progress = 21;
+  optional bool review_started = 22;
+  optional Change_Id revert_of = 23;
+
+  // Deleted fields, should not be reused:
+  reserved 6;    // sortkey
+  reserved 9;    // open
+  reserved 11;   // nbrPatchSets
+  reserved 15;   // lastSha1MergeTested
+  reserved 16;   // mergeable
+  reserved 101;  // note_db_state
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.ChangeMessage.
+// Next ID: 3
+message ChangeMessage_Key {
+  required Change_Id change_id = 1;
+  required string uuid = 2;
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.ChangeMessage.
+// Next ID: 8
+message ChangeMessage {
+  required ChangeMessage_Key key = 1;
+  optional Account_Id author_id = 2;
+  optional fixed64 written_on = 3;
+  optional string message = 4;
+  optional PatchSet_Id patchset = 5;
+  optional string tag = 6;
+  optional Account_Id real_author = 7;
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.PatchSet.Id.
+// Next ID: 3
+message PatchSet_Id {
+  required Change_Id change_id = 1;
+  required int32 id = 2;
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.PatchSet.
+// Next ID: 10
+message PatchSet {
+  required PatchSet_Id id = 1;
+  optional ObjectId commitId = 2;
+  optional Account_Id uploader_account_id = 3;
+  optional fixed64 created_on = 4;
+  optional string groups = 6;
+  optional string push_certificate = 8;
+  optional string description = 9;
+
+  // Deleted fields, should not be reused:
+  reserved 5;  // draft
+  reserved 7;  // pushCertficate
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.Account.Id.
+// Next ID: 2
+message Account_Id {
+  required int32 id = 1;
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.LabelId.
+// Next ID: 2
+message LabelId {
+  required string id = 1;
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.PatchSetApproval.Key.
+// Next ID: 4
+message PatchSetApproval_Key {
+  required PatchSet_Id patch_set_id = 1;
+  required Account_Id account_id = 2;
+  required LabelId label_id = 3;
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.PatchSetApproval.
+// Next ID: 9
+message PatchSetApproval {
+  required PatchSetApproval_Key key = 1;
+  optional int32 value = 2;
+  optional fixed64 granted = 3;
+  optional string tag = 6;
+  optional Account_Id real_account_id = 7;
+  optional bool post_submit = 8;
+
+  // Deleted fields, should not be reused:
+  reserved 4;  // changeOpen
+  reserved 5;  // changeSortKey
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.Project.NameKey.
+// Next ID: 2
+message Project_NameKey {
+  optional string name = 1;
+}
+
+// Serialized form of com.google.gerrit.reviewdb.client.Branch.NameKey.
+// Next ID: 3
+message Branch_NameKey {
+  optional Project_NameKey project = 1;
+  optional string branch = 2;
+}
+
+// Serialized form of org.eclipse.jgit.lib.ObjectId.
+// Next ID: 2
+message ObjectId {
+  // Hex string representation of the ID.
+  optional string name = 1;
+}
diff --git a/proto/testing/BUILD b/proto/testing/BUILD
new file mode 100644
index 0000000..b9032cf
--- /dev/null
+++ b/proto/testing/BUILD
@@ -0,0 +1,12 @@
+proto_library(
+    name = "test_proto",
+    testonly = 1,
+    srcs = ["test.proto"],
+)
+
+java_proto_library(
+    name = "test_java_proto",
+    testonly = 1,
+    visibility = ["//visibility:public"],
+    deps = [":test_proto"],
+)
diff --git a/proto/testing/test.proto b/proto/testing/test.proto
new file mode 100644
index 0000000..e28c9ff
--- /dev/null
+++ b/proto/testing/test.proto
@@ -0,0 +1,26 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto2";
+
+package devtools.gerritcodereview.testing;
+
+option java_package = "com.google.gerrit.proto.testing";
+
+// Test type for ProtobufSerializerTest
+// Next ID: 3
+message SerializableProto {
+  required int32 id = 1;
+  optional string text = 2;
+}
diff --git a/resources/com/google/gerrit/httpd/raw/BUILD b/resources/com/google/gerrit/httpd/raw/BUILD
new file mode 100644
index 0000000..3cd3ce8
--- /dev/null
+++ b/resources/com/google/gerrit/httpd/raw/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "raw",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 3dd6360..1f9615f 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -16,26 +16,50 @@
 
 {namespace com.google.gerrit.httpd.raw}
 
-/**
- * @param canonicalPath
- * @param staticResourcePath
- * @param? faviconPath
- * @param? versionInfo
- * @param? deprecateGwtUi
- */
 {template .Index}
+  {@param canonicalPath: ?}
+  {@param staticResourcePath: ?}
+  {@param gerritInitialData: /** {string} map of REST endpoint to response for startup. */ ?}
+  {@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? polymer2: ?}
+  {@param? polyfillCE: ?}
+  {@param? polyfillSD: ?}
+  {@param? polyfillSC: ?}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
   <meta name="description" content="Gerrit Code Review">{\n}
+  <meta name="referrer" content="never">{\n}
   <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">{\n}
 
+  <noscript>
+    To use PolyGerrit, please enable JavaScript in your browser settings, and then refresh this page.
+  </noscript>
+
   <script>
     window.CLOSURE_NO_DEPS = true;
     {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
-    {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}
+    {if $polymer2}window.POLYMER2 = true;{/if}
+    {if $polyfillCE}if (window.customElements) window.customElements.forcePolyfill = true;{/if}
+    {if $polyfillSD}{literal}ShadyDOM = { force: true };{/literal}{/if}
+    {if $polyfillSC}{literal}ShadyCSS = { shimcssproperties: true};{/literal}{/if}
+    {if $gerritInitialData}
+      // INITIAL_DATA is a string that represents a JSON map. It's inlined here so that we can
+      // spare calls to the API when starting up the app.
+      // The map maps from endpoint to returned value. This matches Gerrit's REST API 1:1, so the
+      // values here can be used as a drop-in replacement for calls to the API.
+      //
+      // Example:
+      // '/config/server/version' => '3.0.0-468-g0757b52a7d'
+      // '/accounts/self/detail' => { 'username' : 'gerrit-user' }
+      window.INITIAL_DATA = JSON.parse({$gerritInitialData});
+    {/if}
   </script>{\n}
 
   {if $faviconPath}
@@ -46,24 +70,41 @@
 
   // RobotoMono fonts are used in styles/fonts.css
   // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
-  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
-  <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
+
+  {if $polymer2}
+    <script src="{$staticResourcePath}/bower_components/webcomponentsjs-p2/webcomponents-lite.js"></script>{\n}
+  {else}
+    <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
+  {/if}
+
   // Content between webcomponents-lite and the load of the main app element
   // run before polymer-resin is installed so may have security consequences.
   // Contact your local security engineer if you have any questions, and
   // CC them on any changes that load content before gr-app.html.
   //
   // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
-  <link rel="preload" href="{$staticResourcePath}/elements/gr-app.js" as="script" crossorigin="anonymous">{\n}
-  <link rel="import" href="{$staticResourcePath}/elements/gr-app.html">{\n}
+  {if $assetsPath and $assetsBundle}
+    <link rel="import" href="{$assetsPath}/{$assetsBundle}">{\n}
+  {/if}
+
+  {if $polymer2}
+    <link rel="import" href="{$staticResourcePath}/elements/gr-app-p2.html">{\n}
+  {else}
+    <link rel="import" href="{$staticResourcePath}/elements/gr-app.html">{\n}
+  {/if}
 
   <body unresolved>{\n}
-  <gr-app id="app"></gr-app>{\n}
+  {if $polymer2}
+    <gr-app-p2 id="app"></gr-app-p2>{\n}
+  {else}
+    <gr-app id="app"></gr-app>{\n}
+  {/if}
 {/template}
diff --git a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt b/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
deleted file mode 100644
index a380955..0000000
--- a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
+++ /dev/null
@@ -1,20 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-syntax = "proto2";
-
-option java_api_version = 2;
-option java_package = "com.google.gerrit.proto.reviewdb";
-
-package devtools.gerritcodereview;
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index 5571e7c..d92ec51 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -63,7 +63,7 @@
 running() {
   test -f $1 || return 1
   PID=`cat $1`
-  ps -p $PID >/dev/null 2>/dev/null || return 1
+  ps ax -o pid | grep -w $PID >/dev/null 2>/dev/null || return 1
   return 0
 }
 
@@ -263,9 +263,9 @@
 fi
 
 if test -z "$JAVA" ; then
-  echo >&2 "Cannot find a JRE or JDK. Please set JAVA_HOME or"
-  echo >&2 "container.javaHome in $GERRIT_SITE/etc/gerrit.config"
-  echo >&2 "to a >=1.7 JRE"
+  echo >&2 "Cannot find a JRE or JDK. Please ensure that the JAVA_HOME environment"
+  echo >&2 "variable or container.javaHome in $GERRIT_SITE/etc/gerrit.config is"
+  echo >&2 "set to a valid >=1.8 JRE location"
   exit 1
 fi
 
@@ -434,8 +434,8 @@
       fi
     fi
 
+    PID=`cat "$GERRIT_PID"`
     if test $UID = 0; then
-        PID=`cat "$GERRIT_PID"`
         if test -f "/proc/${PID}/oom_score_adj" ; then
             echo -1000 > "/proc/${PID}/oom_score_adj"
         else
@@ -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/pgm/init/libraries.config b/resources/com/google/gerrit/pgm/init/libraries.config
index a82edb32..3d3545b 100644
--- a/resources/com/google/gerrit/pgm/init/libraries.config
+++ b/resources/com/google/gerrit/pgm/init/libraries.config
@@ -19,9 +19,9 @@
   remove = mysql-connector-java-.*[.]jar
 
 [library "mariadbDriver"]
-  name = MariaDB Connector/J 2.1.2
-  url = https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/2.1.2/mariadb-java-client-2.1.2.jar
-  sha1 = 002484a99a6a86491531d17d491c931de8942ae0
+  name = MariaDB Connector/J 2.3.0
+  url = https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/2.3.0/mariadb-java-client-2.3.0.jar
+  sha1 = c2b1a6002a169757d0649449288e9b3b776af76b
   remove = mariadb-java-client-.*[.]jar
 
 [library "oracleDriver"]
diff --git a/resources/com/google/gerrit/proto/BUILD b/resources/com/google/gerrit/proto/BUILD
new file mode 100644
index 0000000..ec2e05c
--- /dev/null
+++ b/resources/com/google/gerrit/proto/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "proto",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/resources/com/google/gerrit/proto/ProtoGenHeader.txt b/resources/com/google/gerrit/proto/ProtoGenHeader.txt
new file mode 100644
index 0000000..8797790
--- /dev/null
+++ b/resources/com/google/gerrit/proto/ProtoGenHeader.txt
@@ -0,0 +1,19 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto2";
+
+option java_package = "com.google.gerrit.proto.reviewdb";
+
+package devtools.gerritcodereview;
diff --git a/resources/com/google/gerrit/reviewdb/BUILD b/resources/com/google/gerrit/reviewdb/BUILD
deleted file mode 100644
index 8a1b457..0000000
--- a/resources/com/google/gerrit/reviewdb/BUILD
+++ /dev/null
@@ -1,8 +0,0 @@
-filegroup(
-    name = "reviewdb",
-    srcs = glob(
-        ["**/*"],
-        exclude = ["BUILD"],
-    ),
-    visibility = ["//visibility:public"],
-)
diff --git a/resources/com/google/gerrit/reviewdb/server/index_generic.sql b/resources/com/google/gerrit/reviewdb/server/index_generic.sql
deleted file mode 100644
index c58edb7..0000000
--- a/resources/com/google/gerrit/reviewdb/server/index_generic.sql
+++ /dev/null
@@ -1,40 +0,0 @@
--- Gerrit 2 : Generic
---
-
--- Indexes to support @Query
---
-
--- *********************************************************************
--- ApprovalCategoryAccess
---    too small to bother indexing
-
-
--- *********************************************************************
--- ApprovalCategoryValueAccess
---     @PrimaryKey covers: byCategory
-
-
--- *********************************************************************
--- BranchAccess
---    @PrimaryKey covers: byProject
-
-
--- *********************************************************************
--- ChangeMessageAccess
---    @PrimaryKey covers: byChange
-
---    covers:             byPatchSet
-CREATE INDEX change_messages_byPatchset
-ON change_messages (patchset_change_id, patchset_patch_set_id);
-
--- *********************************************************************
--- PatchLineCommentAccess
---    @PrimaryKey covers: published, draft
-CREATE INDEX patch_comment_drafts
-ON patch_comments (status, author_id);
-
-
--- *********************************************************************
--- PatchSetAccess
-CREATE INDEX patch_sets_byRevision
-ON patch_sets (revision);
diff --git a/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql b/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
deleted file mode 100644
index 7f0f1bd..0000000
--- a/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
+++ /dev/null
@@ -1,43 +0,0 @@
-delimiter #
--- Gerrit 2 : MaxDB
---
-
--- Indexes to support @Query
---
-
--- *********************************************************************
--- ApprovalCategoryAccess
---    too small to bother indexing
-
-
--- *********************************************************************
--- ApprovalCategoryValueAccess
---     @PrimaryKey covers: byCategory
-
-
--- *********************************************************************
--- BranchAccess
---    @PrimaryKey covers: byProject
-
-
--- *********************************************************************
--- ChangeMessageAccess
---    @PrimaryKey covers: byChange
-
---    covers:             byPatchSet
-CREATE INDEX change_messages_byPatchset
-ON change_messages (patchset_change_id, patchset_patch_set_id)
-#
-
--- *********************************************************************
--- PatchLineCommentAccess
---    @PrimaryKey covers: published, draft
-CREATE INDEX patch_comment_drafts
-ON patch_comments (status, author_id)
-#
-
--- *********************************************************************
--- PatchSetAccess
-CREATE INDEX patch_sets_byRevision
-ON patch_sets (revision)
-#
diff --git a/resources/com/google/gerrit/reviewdb/server/index_postgres.sql b/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
deleted file mode 100644
index f2f24e1..0000000
--- a/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
+++ /dev/null
@@ -1,88 +0,0 @@
--- Gerrit 2 : PostgreSQL
---
-
--- Cluster hot tables by their primary method of access
---
-ALTER TABLE patch_sets CLUSTER ON patch_sets_pkey;
-ALTER TABLE change_messages CLUSTER ON change_messages_pkey;
-ALTER TABLE patch_comments CLUSTER ON patch_comments_pkey;
-ALTER TABLE patch_set_approvals CLUSTER ON patch_set_approvals_pkey;
-
-ALTER TABLE account_group_members CLUSTER ON account_group_members_pkey;
-CLUSTER;
-
-
--- Define function for conditional installation of PL/pgSQL.
--- This is required, because starting with PostgreSQL 9.0, PL/pgSQL
--- language is installed by default and database returns error when
--- we try to install it again.
---
--- Source: http://wiki.postgresql.org/wiki/CREATE_OR_REPLACE_LANGUAGE
--- Author: David Fetter
---
-
-delimiter //
-
-CREATE OR REPLACE FUNCTION make_plpgsql()
-RETURNS VOID
-LANGUAGE SQL
-AS $$
-CREATE LANGUAGE plpgsql;
-$$;
-
-//
-
-delimiter ;
-
-SELECT
-    CASE
-    WHEN EXISTS(
-        SELECT 1
-        FROM pg_catalog.pg_language
-        WHERE lanname='plpgsql'
-    )
-    THEN NULL
-    ELSE make_plpgsql() END;
-
-DROP FUNCTION make_plpgsql();
-
-delimiter ;
-
--- Indexes to support @Query
---
-
--- *********************************************************************
--- ApprovalCategoryAccess
---    too small to bother indexing
-
-
--- *********************************************************************
--- ApprovalCategoryValueAccess
---     @PrimaryKey covers: byCategory
-
-
--- *********************************************************************
--- BranchAccess
---    @PrimaryKey covers: byProject
-
-
--- *********************************************************************
--- ChangeMessageAccess
---    @PrimaryKey covers: byChange
-
---    covers:             byPatchSet
-CREATE INDEX change_messages_byPatchset
-ON change_messages (patchset_change_id, patchset_patch_set_id);
-
--- *********************************************************************
--- PatchLineCommentAccess
---    @PrimaryKey covers: published, draft
-CREATE INDEX patch_comment_drafts
-ON patch_comments (author_id)
-WHERE status = 'd';
-
-
--- *********************************************************************
--- PatchSetAccess
-CREATE INDEX patch_sets_byRevision
-ON patch_sets (revision);
diff --git a/resources/com/google/gerrit/server/BUILD b/resources/com/google/gerrit/server/BUILD
index 688474e..e92c4e1 100644
--- a/resources/com/google/gerrit/server/BUILD
+++ b/resources/com/google/gerrit/server/BUILD
@@ -6,3 +6,9 @@
     ),
     visibility = ["//visibility:public"],
 )
+
+sh_test(
+    name = "commit-msg_test",
+    srcs = ["commit-msg_test.sh"],
+    data = [":server"],
+)
diff --git a/resources/com/google/gerrit/server/change/ChangeMessages.properties b/resources/com/google/gerrit/server/change/ChangeMessages.properties
index b2bcde3..ec20677 100644
--- a/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ b/resources/com/google/gerrit/server/change/ChangeMessages.properties
@@ -1,11 +1,7 @@
-# Changes to this file should also be made in
-# gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
 revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
 
 reviewerCantSeeChange = {0} does not have permission to see this change
-reviewerInactive = {0} identifies an inactive account
 reviewerInvalid = {0} is not a valid user identifier
-reviewerNotFoundUser = {0} does not identify a registered user
 reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
 
 groupIsNotAllowed = The group {0} cannot be added as reviewer.
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
new file mode 100755
index 0000000..d797be3
--- /dev/null
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -0,0 +1,189 @@
+#!/bin/bash
+
+set -eu
+
+hook=$(pwd)/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+
+cd $TEST_TMPDIR
+
+function fail {
+  echo "FAIL: $1"
+  exit 1
+}
+
+function prereq_modern_git {
+  # "git interpret-trailers --where" was introduced in Git 2.15.0.
+  git interpret-trailers -h 2>&1 | grep -e --where > /dev/null
+}
+
+function test_nonexistent_argument {
+  rm -f input
+  if ${hook} input ; then
+    fail "must fail for non-existent input"
+  fi
+}
+
+function test_empty {
+  rm -f input
+  touch input
+  if ${hook} input ; then
+    fail "must fail on empty message"
+  fi
+}
+
+function test_empty_with_comments {
+  rm -f input
+  cat << EOF > input
+# comment
+
+# comment2
+EOF
+  if ${hook} input ; then
+    fail "must fail on empty message"
+  fi
+}
+
+function test_keep_cutoff_line {
+  if ! prereq_modern_git ; then
+    echo "old version of Git detected; skipping scissors test."
+    return 0
+  fi
+  rm -f input
+  cat << EOF > input
+Do something nice
+
+# Please enter the commit message for your changes.
+# ------------------------ >8 ------------------------
+# Do not modify or remove the line above.
+# Everything below it will be ignored.
+diff --git a/file.txt b/file.txt
+index 625fd613d9..03aeba3b21 100755
+--- a/file.txt
++++ b/file.txt
+@@ -38,6 +38,7 @@
+ context
+ line
+ 
++hello, world
+ 
+ context
+ line
+EOF
+  ${hook} input || fail "failed hook execution"
+  grep '>8' input || fail "lost cut-off line"
+  sed -n -e '1,/>8/ p' input >top
+  grep '^Change-Id' top || fail "missing Change-Id above cut-off line"
+}
+
+# a Change-Id already set is preserved.
+function test_preserve_changeid {
+  cat << EOF > input
+bla bla
+
+Change-Id: I123
+EOF
+
+  ${hook} input || fail "failed hook execution"
+
+  found=$(grep -c '^Change-Id' input)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Ids, want 1"
+  fi
+  found=$(grep -c '^Change-Id: I123' input)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Id: I123, want 1"
+  fi
+}
+
+# Change-Id should not be inserted if gerrit.createChangeId=false
+function test_suppress_changeid {
+  cat << EOF > input
+bla bla
+EOF
+
+  git config gerrit.createChangeId false
+  ${hook} input || fail "failed hook execution"
+  git config --unset gerrit.createChangeId
+  found=$(grep -c '^Change-Id' input || true)
+  if [[ "${found}" != "0" ]]; then
+    fail "got ${found} Change-Ids, want 0"
+  fi
+}
+
+# Change-Id goes after existing trailers.
+function test_at_end {
+  cat << EOF > input
+bla bla
+
+Bug: #123
+EOF
+
+  ${hook} input || fail "failed hook execution"
+  result=$(tail -1 input | grep ^Change-Id)
+  if [[ -z "${result}" ]] ; then
+    echo "after: "
+    cat input
+
+    fail "did not find Change-Id at end"
+  fi
+}
+
+function test_dash_at_end {
+  if [[ ! -x /bin/dash ]] ; then
+    echo "/bin/dash not installed; skipping dash test."
+    return
+  fi
+
+  cat << EOF > input
+bla bla
+
+Bug: #123
+EOF
+
+  /bin/dash ${hook} input || fail "failed hook execution"
+
+  result=$(tail -1 input | grep ^Change-Id)
+  if [[ -z "${result}" ]] ; then
+    echo "after: "
+    cat input
+
+    fail "did not find Change-Id at end"
+  fi
+}
+
+function test_preserve_dash_changeid {
+  if [[ ! -x /bin/dash ]] ; then
+    echo "/bin/dash not installed; skipping dash test."
+    return
+  fi
+
+  cat << EOF > input
+bla bla
+
+Change-Id: I123
+EOF
+
+  /bin/dash ${hook} input || fail "failed hook execution"
+
+  found=$(grep -c '^Change-Id' input)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Ids, want 1"
+  fi
+  found=$(grep -c '^Change-Id: I123' input)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Id: I123, want 1"
+  fi
+}
+
+
+# Test driver.
+git init
+for func in $( declare -F | awk '{print $3;}' | sort); do
+  case ${func} in
+    test_*)
+      echo "=== testing $func"
+      ${func}
+      echo "--- done    $func"
+      ;;
+  esac
+done
diff --git a/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
index 6654837..ba590ee 100644
--- a/resources/com/google/gerrit/server/config/CapabilityConstants.properties
+++ b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
@@ -10,6 +10,7 @@
 maintainServer = Maintain Server
 modifyAccount = Modify Account
 priority = Priority
+readAs = Read As
 queryLimit = Query Limit
 runAs = Run As
 runGC = Run Garbage Collection
diff --git a/resources/com/google/gerrit/server/documentation/pegdown.css b/resources/com/google/gerrit/server/documentation/flexmark-java.css
similarity index 100%
rename from resources/com/google/gerrit/server/documentation/pegdown.css
rename to resources/com/google/gerrit/server/documentation/flexmark-java.css
diff --git a/resources/com/google/gerrit/server/mail/Abandoned.soy b/resources/com/google/gerrit/server/mail/Abandoned.soy
index 623cfe26..2785ffc 100644
--- a/resources/com/google/gerrit/server/mail/Abandoned.soy
+++ b/resources/com/google/gerrit/server/mail/Abandoned.soy
@@ -19,12 +19,12 @@
 /**
  * .Abandoned template will determine the contents of the email related to a
  * change being abandoned.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
  */
 {template .Abandoned kind="text"}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
   {$fromName} has abandoned this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
index 75d940f..9ad996e 100644
--- a/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -16,12 +16,10 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param coverLetter
- * @param email
- * @param fromName
- */
 {template .AbandonedHtml}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
   <p>
     {$fromName} <strong>abandoned</strong> this change.
   </p>
diff --git a/resources/com/google/gerrit/server/mail/AddKey.soy b/resources/com/google/gerrit/server/mail/AddKey.soy
index be76aee..8b609cf 100644
--- a/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -19,9 +19,9 @@
 /**
  * The .AddKey template will determine the contents of the email related to
  * adding a new SSH or GPG key to an account.
- * @param email
  */
 {template .AddKey kind="text"}
+  {@param email: ?}
   One or more new {$email.keyType} keys have been added to Gerrit Code Review at
   {sp}{$email.gerritHost}:
 
diff --git a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index 04a0635..ed4f435 100644
--- a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -16,10 +16,8 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param email
- */
 {template .AddKeyHtml}
+  {@param email: ?}
   <p>
     One or more new {$email.keyType} keys have been added to Gerrit Code Review
     at {$email.gerritHost}:
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
index f1d201b..a8170ca 100644
--- a/resources/com/google/gerrit/server/mail/ChangeFooter.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -19,9 +19,9 @@
 /**
  * The .ChangeFooter template will determine the contents of the footer text
  * that will be appended to ALL emails related to changes.
- * @param email
  */
 {template .ChangeFooter kind="text"}
+  {@param email: ?}
   --{sp}
   {\n}
 
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
index f802366..b619c53 100644
--- a/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -16,11 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param change
- * @param email
- */
 {template .ChangeFooterHtml}
+  {@param change: ?}
+  {@param email: ?}
   {if $email.changeUrl or $email.settingsUrl}
     <p>
       {if $email.changeUrl}
@@ -38,7 +36,7 @@
   {if $email.changeUrl}
     <div itemscope itemtype="http://schema.org/EmailMessage">
       <div itemscope itemprop="action" itemtype="http://schema.org/ViewAction">
-        <link itemprop="url" href="{$email.changeUrl |blessStringAsTrustedResourceUrlForLegacy}"/>
+        <link itemprop="url" href="{$email.changeUrl}"/>
         <meta itemprop="name" content="View Change"/>
       </div>
     </div>
diff --git a/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
index 48ec9a2..7fcd213 100644
--- a/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -19,13 +19,13 @@
 /**
  * The .ChangeSubject template will determine the contents of the email subject
  * line for ALL emails related to changes.
- * @param branch
- * @param change
- * @param shortProjectName
- * @param instanceAndProjectName
- * @param addInstanceNameInSubject boolean
  */
 {template .ChangeSubject kind="text"}
+  {@param branch: ?}
+  {@param change: ?}
+  {@param shortProjectName: ?}
+  {@param instanceAndProjectName: ?}
+  {@param addInstanceNameInSubject: ?}  /** boolean */
   {if not $addInstanceNameInSubject}
     Change in {$shortProjectName}[{$branch.shortName}]: {$change.shortSubject}
   {else}
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index f9a11cd..1eb016b 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -19,13 +19,13 @@
 /**
  * The .Comment template will determine the contents of the email related to a
  * user submitting comments on changes.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
- * @param commentFiles
  */
 {template .Comment kind="text"}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param commentFiles: ?}
   {$fromName} has posted comments on this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index d554258..534cbdb 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -16,15 +16,13 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param commentFiles
- * @param commentCount
- * @param email
- * @param labels
- * @param patchSet
- * @param patchSetCommentBlocks
- */
 {template .CommentHtml}
+  {@param commentFiles: ?}
+  {@param commentCount: ?}
+  {@param email: ?}
+  {@param labels: ?}
+  {@param patchSet: ?}
+  {@param patchSetCommentBlocks: ?}
   {let $commentHeaderStyle kind="css"}
     margin-bottom: 4px;
   {/let}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKey.soy b/resources/com/google/gerrit/server/mail/DeleteKey.soy
new file mode 100644
index 0000000..30548c8
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/DeleteKey.soy
@@ -0,0 +1,72 @@
+/**
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .DeleteKey template will determine the contents of the email related to
+ * deleting a SSH or GPG key.
+ */
+{template .DeleteKey kind="text"}
+  {@param email: ?}
+  One or more {$email.keyType} keys have been deleted on Gerrit Code Review at
+  {sp}{$email.gerritHost}:
+
+  {\n}
+  {\n}
+
+  {if $email.sshKey}
+    {$email.sshKey}
+  {elseif $email.gpgKeyFingerprints}
+    {$email.gpgKeyFingerprints}
+  {/if}
+
+  {\n}
+  {\n}
+
+
+  If this is not expected, please contact your Gerrit Administrators
+  immediately.
+
+  {\n}
+  {\n}
+
+  You can also manage your {$email.keyType} keys by visiting
+  {\n}
+  {if $email.sshKey}
+    {$email.gerritUrl}#/settings/ssh-keys
+  {elseif $email.gpgKey}
+    {$email.gerritUrl}#/settings/gpg-keys
+  {/if}
+  {\n}
+  {if $email.userNameEmail}
+    (while signed in as {$email.userNameEmail})
+  {else}
+    (while signed in as {$email.email})
+  {/if}
+
+  {\n}
+  {\n}
+
+  If clicking the link above does not work, copy and paste the URL in a new
+  browser window instead.
+
+  {\n}
+  {\n}
+
+  This is a send-only email address.  Replies to this message will not be read
+  or answered.
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
new file mode 100644
index 0000000..1ab3955
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
@@ -0,0 +1,64 @@
+/**
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .DeleteKeyHtml}
+  {@param email: ?}
+  <p>
+    One or more {$email.keyType} keys have been deleted on Gerrit Code Review
+    at {$email.gerritHost}:
+  </p>
+
+  {let $keyStyle kind="css"}
+    background: #f0f0f0;
+    border: 1px solid #ccc;
+    color: #555;
+    padding: 12px;
+    width: 400px;
+  {/let}
+
+  {if $email.sshKey}
+    <pre style="{$keyStyle}">{$email.sshKey}</pre>
+  {elseif $email.gpgKeyFingerprints}
+    <pre style="{$keyStyle}">{$email.gpgKeyFingerprints}</pre>
+  {/if}
+
+  <p>
+    If this is not expected, please contact your Gerrit Administrators
+    immediately.
+  </p>
+
+  <p>
+    You can also manage your {$email.keyType} keys by following{sp}
+    {if $email.sshKey}
+      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+    {elseif $email.gpgKeyFingerprints}
+      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+    {/if}
+    {sp}
+    {if $email.userNameEmail}
+      (while signed in as {$email.userNameEmail})
+    {else}
+      (while signed in as {$email.email})
+    {/if}.
+  </p>
+
+  <p>
+    This is a send-only email address.  Replies to this message will not be read
+    or answered.
+  </p>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewer.soy b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
index 065348a..3310249 100644
--- a/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
@@ -19,12 +19,12 @@
 /**
  * The .DeleteReviewer template will determine the contents of the email related
  * to removal of a reviewer (and the reviewer's votes) from reviews.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
  */
 {template .DeleteReviewer kind="text"}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
   {$fromName} has removed{sp}
   {for $reviewerName in $email.reviewerNames}
     {if not isFirst($reviewerName)},{sp}{/if}
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
index 0599b52..54720fe 100644
--- a/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -16,11 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param email
- * @param fromName
- */
 {template .DeleteReviewerHtml}
+  {@param email: ?}
+  {@param fromName: ?}
   <p>
     {$fromName}{sp}
     <strong>
diff --git a/resources/com/google/gerrit/server/mail/DeleteVote.soy b/resources/com/google/gerrit/server/mail/DeleteVote.soy
index 724e90d..0ee5454 100644
--- a/resources/com/google/gerrit/server/mail/DeleteVote.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteVote.soy
@@ -19,12 +19,13 @@
 /**
  * The .DeleteVote template will determine the contents of the email related
  * to removing votes on changes.
- * @param change
- * @param coverLetter
- * @param fromName
  */
 {template .DeleteVote kind="text"}
-  {$fromName} has removed a vote on this change.{\n}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {$fromName} has removed a vote from this change.{if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}{\n}
   {\n}
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
index cb8162d..3a82927 100644
--- a/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -16,12 +16,10 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param coverLetter
- * @param email
- * @param fromName
- */
 {template .DeleteVoteHtml}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
   <p>
     {$fromName} <strong>removed a vote</strong> from this change.
   </p>
diff --git a/resources/com/google/gerrit/server/mail/Footer.soy b/resources/com/google/gerrit/server/mail/Footer.soy
index e1890a8..7483cd9 100644
--- a/resources/com/google/gerrit/server/mail/Footer.soy
+++ b/resources/com/google/gerrit/server/mail/Footer.soy
@@ -20,9 +20,9 @@
  * The .Footer template will determine the contents of the footer text
  * appended to the end of all outgoing emails after the ChangeFooter and
  * CommentFooter.
- * @param footers
  */
 {template .Footer kind="text"}
+  {@param footers: ?}
   {for $footer in $footers}
     {$footer}{\n}
   {/for}
diff --git a/resources/com/google/gerrit/server/mail/FooterHtml.soy b/resources/com/google/gerrit/server/mail/FooterHtml.soy
index 938655c..ce934d3 100644
--- a/resources/com/google/gerrit/server/mail/FooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -16,10 +16,8 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param footers
- */
 {template .FooterHtml}
+  {@param footers: ?}
   {\n}
   {\n}
   {for $footer in $footers}
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
new file mode 100644
index 0000000..38e679e
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
@@ -0,0 +1,55 @@
+/**
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .HttpPasswordUpdate template will determine the contents of the email related to
+ * adding, changing or deleting the HTTP password.
+ */
+{template .HttpPasswordUpdate kind="text"}
+  {@param email: ?}
+  The HTTP password was {$email.operation} on Gerrit Code Review at
+  {sp}{$email.gerritHost}.
+
+  If this is not expected, please contact your Gerrit Administrators
+  immediately.
+
+  {\n}
+  {\n}
+
+  You can also manage your HTTP password by visiting
+  {\n}
+  {$email.gerritUrl}#/settings/http-password
+  {\n}
+  {if $email.userNameEmail}
+    (while signed in as {$email.userNameEmail})
+  {else}
+    (while signed in as {$email.email})
+  {/if}
+
+  {\n}
+  {\n}
+
+  If clicking the link above does not work, copy and paste the URL in a new
+  browser window instead.
+
+  {\n}
+  {\n}
+
+  This is a send-only email address.  Replies to this message will not be read
+  or answered.
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
new file mode 100644
index 0000000..3c4594c
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
@@ -0,0 +1,46 @@
+/**
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .HttpPasswordUpdateHtml}
+  {@param email: ?}
+  <p>
+    The HTTP password was {$email.operation} on Gerrit Code Review
+    at {$email.gerritHost}.
+  </p>
+
+  <p>
+    If this is not expected, please contact your Gerrit Administrators
+    immediately.
+  </p>
+
+  <p>
+    You can also manage your HTTP password by following{sp}
+    <a href="{$email.gerritUrl}#/settings/http-password">this link</a>
+    {sp}
+    {if $email.userNameEmail}
+      (while signed in as {$email.userNameEmail})
+    {else}
+      (while signed in as {$email.email})
+    {/if}.
+  </p>
+
+  <p>
+    This is a send-only email address.  Replies to this message will not be read
+    or answered.
+  </p>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
index e997776..e88c424 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
@@ -62,3 +62,8 @@
   This might be caused by an ongoing maintenance or a data corruption.
   {call .InboundEmailRejectionFooter /}
 {/template}
+
+{template .InboundEmailRejection_COMMENT_REJECTED kind="text"}
+  Gerrit Code Review rejected one or more comments because they did not pass validation.
+  {call .InboundEmailRejectionFooter /}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
index f879270..e17508d 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
@@ -78,3 +78,10 @@
   <p>
   {call .InboundEmailRejectionFooterHtml /}
 {/template}
+
+{template .InboundEmailRejectionHtml_COMMENT_REJECTED}
+  <p>
+    Gerrit Code Review rejected one or more comments because they did not pass validation.
+  </p>
+  {call .InboundEmailRejectionFooterHtml /}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Merged.soy b/resources/com/google/gerrit/server/mail/Merged.soy
index 40924e6..899d1c0 100644
--- a/resources/com/google/gerrit/server/mail/Merged.soy
+++ b/resources/com/google/gerrit/server/mail/Merged.soy
@@ -20,12 +20,12 @@
 /**
  * The .Merged template will determine the contents of the email related to
  * a change successfully merged to the head.
- * @param change
- * @param email
- * @param fromName
  */
 {template .Merged kind="text"}
-  {$fromName} has submitted this change and it was merged.
+  {@param change: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {$fromName} has submitted this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
   Change subject: {$change.subject}{\n}
diff --git a/resources/com/google/gerrit/server/mail/MergedHtml.soy b/resources/com/google/gerrit/server/mail/MergedHtml.soy
index b11c5e5..f0a47c7 100644
--- a/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -16,14 +16,12 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param diffLines
- * @param email
- * @param fromName
- */
 {template .MergedHtml}
+  {@param diffLines: ?}
+  {@param email: ?}
+  {@param fromName: ?}
   <p>
-    {$fromName} <strong>merged</strong> this change.
+    {$fromName} <strong>submitted</strong> this change.
   </p>
 
   {if $email.changeUrl}
diff --git a/resources/com/google/gerrit/server/mail/NewChange.soy b/resources/com/google/gerrit/server/mail/NewChange.soy
index f11edfe..fa447e9 100644
--- a/resources/com/google/gerrit/server/mail/NewChange.soy
+++ b/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -19,13 +19,13 @@
 /**
  * The .NewChange template will determine the contents of the email related to a
  * user submitting a new change for review.
- * @param change
- * @param email
- * @param ownerName
- * @param patchSet
- * @param projectName
  */
 {template .NewChange kind="text"}
+  {@param change: ?}
+  {@param email: ?}
+  {@param ownerName: ?}
+  {@param patchSet: ?}
+  {@param projectName: ?}
   {if $email.reviewerNames}
     Hello{sp}
     {for $reviewerName in $email.reviewerNames}
@@ -53,7 +53,7 @@
     {/if}
   {else}
     {$ownerName} has uploaded this change for review.
-    {if $email.changeUrl} ( {$email.changeUrl}{/if}
+    {if $email.changeUrl} ( {$email.changeUrl} ){/if}
   {/if}{\n}
 
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 5bce806..9de8707 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -16,15 +16,13 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param diffLines
- * @param email
- * @param fromName
- * @param ownerName
- * @param patchSet
- * @param projectName
- */
 {template .NewChangeHtml}
+  {@param diffLines: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param ownerName: ?}
+  {@param patchSet: ?}
+  {@param projectName: ?}
   <p>
     {if $email.reviewerNames}
       {$fromName} would like{sp}
diff --git a/resources/com/google/gerrit/server/mail/Private.soy b/resources/com/google/gerrit/server/mail/Private.soy
index bb32a7e9..510f15e 100644
--- a/resources/com/google/gerrit/server/mail/Private.soy
+++ b/resources/com/google/gerrit/server/mail/Private.soy
@@ -22,17 +22,17 @@
 
 /**
  * Private template to generate "View Change" buttons.
- * @param email
  */
 {template .ViewChangeButton}
+  {@param email: ?}
   <a href="{$email.changeUrl}">View Change</a>
 {/template}
 
 /**
  * Private template to render PRE block with consistent font-sizing.
- * @param content
  */
 {template .Pre}
+  {@param content: ?}
   {let $preStyle kind="css"}
     font-family: monospace,monospace; // Use this to avoid browsers scaling down
                                       // monospace text.
@@ -53,10 +53,9 @@
  *
  * This mechanism encodes as little structure as possible in order to depend on
  * the Soy autoescape mechanism for all of the content.
- *
- * @param content
  */
 {template .WikiFormat}
+  {@param content: ?}
   {let $blockquoteStyle kind="css"}
     border-left: 1px solid #aaa;
     margin: 10px 0;
@@ -87,10 +86,8 @@
   {/for}
 {/template}
 
-/**
- * @param diffLines
- */
 {template .UnifiedDiff}
+  {@param diffLines: ?}
   {let $addStyle kind="css"}
     color: hsl(120, 100%, 40%);
   {/let}
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
index 2886cc0..ee03de0 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -19,9 +19,9 @@
 /**
  * The .RegisterNewEmail template will determine the contents of the email
  * related to registering new email accounts.
- * @param email
  */
 {template .RegisterNewEmail kind="text"}
+  {@param email: ?}
   Welcome to Gerrit Code Review at {$email.gerritHost}.{\n}
 
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index 1cb0110..bb84cf1 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -19,14 +19,14 @@
 /**
  * The .ReplacePatchSet template will determine the contents of the email
  * related to a user submitting a new patchset for a change.
- * @param change
- * @param email
- * @param fromEmail
- * @param fromName
- * @param patchSet
- * @param projectName
  */
 {template .ReplacePatchSet kind="text"}
+  {@param change: ?}
+  {@param email: ?}
+  {@param fromEmail: ?}
+  {@param fromName: ?}
+  {@param patchSet: ?}
+  {@param projectName: ?}
   {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
     Hello{sp}
     {for $reviewerName in $email.reviewerNames}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index e618bef..96cba5f 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -16,15 +16,13 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param change
- * @param email
- * @param fromName
- * @param fromEmail
- * @param patchSet
- * @param projectName
- */
 {template .ReplacePatchSetHtml}
+  {@param change: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param fromEmail: ?}
+  {@param patchSet: ?}
+  {@param projectName: ?}
   <p>
     {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
     to{sp}
diff --git a/resources/com/google/gerrit/server/mail/Restored.soy b/resources/com/google/gerrit/server/mail/Restored.soy
index 4fc6d8c..0ec65b30 100644
--- a/resources/com/google/gerrit/server/mail/Restored.soy
+++ b/resources/com/google/gerrit/server/mail/Restored.soy
@@ -19,12 +19,12 @@
 /**
  * The .Restored template will determine the contents of the email related to a
  * change being restored.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
  */
 {template .Restored kind="text"}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
   {$fromName} has restored this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/resources/com/google/gerrit/server/mail/RestoredHtml.soy
index bb856ac..bcd358f 100644
--- a/resources/com/google/gerrit/server/mail/RestoredHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -16,11 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param email
- * @param fromName
- */
 {template .RestoredHtml}
+  {@param email: ?}
+  {@param fromName: ?}
   <p>
     {$fromName} <strong>restored</strong> this change.
   </p>
diff --git a/resources/com/google/gerrit/server/mail/Reverted.soy b/resources/com/google/gerrit/server/mail/Reverted.soy
index fba8744..32a65c6 100644
--- a/resources/com/google/gerrit/server/mail/Reverted.soy
+++ b/resources/com/google/gerrit/server/mail/Reverted.soy
@@ -19,12 +19,12 @@
 /**
  * The .Reverted template will determine the contents of the email related
  * to a change being reverted.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
  */
 {template .Reverted kind="text"}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
   {$fromName} has created a revert of this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
index b7b254e..69260ad 100644
--- a/resources/com/google/gerrit/server/mail/RevertedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -16,11 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param email
- * @param fromName
- */
 {template .RevertedHtml}
+  {@param email: ?}
+  {@param fromName: ?}
   <p>
     {$fromName} has <strong>created a revert</strong> of this change.
   </p>
diff --git a/resources/com/google/gerrit/server/mail/SetAssignee.soy b/resources/com/google/gerrit/server/mail/SetAssignee.soy
index 98290e9..1fdf690 100644
--- a/resources/com/google/gerrit/server/mail/SetAssignee.soy
+++ b/resources/com/google/gerrit/server/mail/SetAssignee.soy
@@ -19,13 +19,13 @@
 /**
  * The .SetAssignee template will determine the contents of the email related
  * to a user being assigned to a change.
- * @param change
- * @param email
- * @param fromName
- * @param patchSet
- * @param projectName
  */
 {template .SetAssignee kind="text"}
+  {@param change: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param patchSet: ?}
+  {@param projectName: ?}
   Hello{sp}
   {$email.assigneeName},
 
diff --git a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
index dbd3fae..1826314 100644
--- a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -16,14 +16,12 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param diffLines
- * @param email
- * @param fromName
- * @param patchSet
- * @param projectName
- */
 {template .SetAssigneeHtml}
+  {@param diffLines: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param patchSet: ?}
+  {@param projectName: ?}
   <p>
     {$fromName} has <strong>assigned</strong> a change to{sp}
     {$email.assigneeName}.{sp}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index a83dffb..6fbd341 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -83,6 +83,7 @@
 gss = text/x-gss
 h = text/x-csrc
 haml = text/x-haml
+handlebars = text/x-handlebars
 hh = text/x-c++src
 history.md = text/x-gfm
 hpp = text/x-c++src
@@ -123,7 +124,7 @@
 mbox = application/mbox
 md = text/x-markdown
 mirc = text/mirc
-mjs = text/x-mjs
+mjs = text/javascript
 mkd = text/x-markdown
 ml = text/x-ocaml
 mli = text/x-ocaml
@@ -148,6 +149,7 @@
 patch = text/x-diff
 pcss = text/x-pcss
 pgp = application/pgp
+pgsql = text/x-pgsql
 php = text/x-php
 php3 = text/x-php
 php4 = text/x-php
@@ -158,6 +160,8 @@
 pl = text/x-perl
 pls = text/x-plsql
 pm = text/x-perl
+postgres = text/x-pgsql
+postgresql = text/x-pgsql
 pp = text/x-puppet
 pro = text/x-idl
 properties = text/x-ini
@@ -205,7 +209,10 @@
 sql = text/x-sql
 ss = text/x-scheme
 st = text/x-stsrc
+star = text/x-python
 stex = text/x-stex
+sv = text/x-systemverilog
+svh = text/x-systemverilog
 swift = text/x-swift
 tcl = text/x-tcl
 tex = text/x-latex
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
old mode 100644
new mode 100755
index 4c64559..2901232
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -15,179 +15,48 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-#
 
-unset GREP_OPTIONS
+# avoid [[ which is not POSIX sh.
+if test "$#" != 1 ; then
+  echo "$0 requires an argument."
+  exit 1
+fi
 
-CHANGE_ID_AFTER="Bug|Depends-On|Issue|Test|Feature|Fixes|Fixed"
-MSG="$1"
+if test ! -f "$1" ; then
+  echo "file does not exist: $1"
+  exit 1
+fi
 
-# Check for, and add if missing, a unique Change-Id
-#
-add_ChangeId() {
-	clean_message=`sed -e '
-		/^diff --git .*/{
-			s///
-			q
-		}
-		/^Signed-off-by:/d
-		/^#/d
-	' "$MSG" | git stripspace`
-	if test -z "$clean_message"
-	then
-		return
-	fi
+# Do not create a change id if requested
+if test "false" = "`git config --bool --get gerrit.createChangeId`" ; then
+  exit 0
+fi
 
-	# Do not add Change-Id to temp commits
-	if echo "$clean_message" | head -1 | grep -q '^\(fixup\|squash\)!'
-	then
-		return
-	fi
+# $RANDOM will be undefined if not using bash, so don't use set -u
+random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
+dest="$1.tmp.${random}"
 
-	if test "false" = "`git config --bool --get gerrit.createChangeId`"
-	then
-		return
-	fi
+trap 'rm -f "${dest}"' EXIT
 
-	# Does Change-Id: already exist? if so, exit (no change).
-	if grep -i '^Change-Id:' "$MSG" >/dev/null
-	then
-		return
-	fi
+if ! git stripspace --strip-comments < "$1" > "${dest}" ; then
+   echo "cannot strip comments from $1"
+   exit 1
+fi
 
-	id=`_gen_ChangeId`
-	T="$MSG.tmp.$$"
-	AWK=awk
-	if [ -x /usr/xpg4/bin/awk ]; then
-		# Solaris AWK is just too broken
-		AWK=/usr/xpg4/bin/awk
-	fi
+if test ! -s "${dest}" ; then
+  echo "file is empty: $1"
+  exit 1
+fi
 
-	# Get core.commentChar from git config or use default symbol
-	commentChar=`git config --get core.commentChar`
-	commentChar=${commentChar:-#}
+# Avoid the --in-place option which only appeared in Git 2.8
+# Avoid the --if-exists option which only appeared in Git 2.15
+if ! git -c trailer.ifexists=doNothing interpret-trailers \
+      --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
+  echo "cannot insert change-id line in $1"
+  exit 1
+fi
 
-	# How this works:
-	# - parse the commit message as (textLine+ blankLine*)*
-	# - assume textLine+ to be a footer until proven otherwise
-	# - exception: the first block is not footer (as it is the title)
-	# - read textLine+ into a variable
-	# - then count blankLines
-	# - once the next textLine appears, print textLine+ blankLine* as these
-	#   aren't footer
-	# - in END, the last textLine+ block is available for footer parsing
-	$AWK '
-	BEGIN {
-		if (match(ENVIRON["OS"], "Windows")) {
-			RS="\r?\n" # Required on recent Cygwin
-		}
-		# while we start with the assumption that textLine+
-		# is a footer, the first block is not.
-		isFooter = 0
-		footerComment = 0
-		blankLines = 0
-	}
-
-	# Skip lines starting with commentChar without any spaces before it.
-	/^'"$commentChar"'/ { next }
-
-	# Skip the line starting with the diff command and everything after it,
-	# up to the end of the file, assuming it is only patch data.
-	# If more than one line before the diff was empty, strip all but one.
-	/^diff --git / {
-		blankLines = 0
-		while (getline) { }
-		next
-	}
-
-	# Count blank lines outside footer comments
-	/^$/ && (footerComment == 0) {
-		blankLines++
-		next
-	}
-
-	# Catch footer comment
-	/^\[[a-zA-Z0-9-]+:/ && (isFooter == 1) {
-		footerComment = 1
-	}
-
-	/]$/ && (footerComment == 1) {
-		footerComment = 2
-	}
-
-	# We have a non-blank line after blank lines. Handle this.
-	(blankLines > 0) {
-		print lines
-		for (i = 0; i < blankLines; i++) {
-			print ""
-		}
-
-		lines = ""
-		blankLines = 0
-		isFooter = 1
-		footerComment = 0
-	}
-
-	# Detect that the current block is not the footer
-	(footerComment == 0) && (!/^\[?[a-zA-Z0-9-]+:/ || /^[a-zA-Z0-9-]+:\/\//) {
-		isFooter = 0
-	}
-
-	{
-		# We need this information about the current last comment line
-		if (footerComment == 2) {
-			footerComment = 0
-		}
-		if (lines != "") {
-			lines = lines "\n";
-		}
-		lines = lines $0
-	}
-
-	# Footer handling:
-	# If the last block is considered a footer, splice in the Change-Id at the
-	# right place.
-	# Look for the right place to inject Change-Id by considering
-	# CHANGE_ID_AFTER. Keys listed in it (case insensitive) come first,
-	# then Change-Id, then everything else (eg. Signed-off-by:).
-	#
-	# Otherwise just print the last block, a new line and the Change-Id as a
-	# block of its own.
-	END {
-		unprinted = 1
-		if (isFooter == 0) {
-			print lines "\n"
-			lines = ""
-		}
-		changeIdAfter = "^(" tolower("'"$CHANGE_ID_AFTER"'") "):"
-		numlines = split(lines, footer, "\n")
-		for (line = 1; line <= numlines; line++) {
-			if (unprinted && match(tolower(footer[line]), changeIdAfter) != 1) {
-				unprinted = 0
-				print "Change-Id: I'"$id"'"
-			}
-			print footer[line]
-		}
-		if (unprinted) {
-			print "Change-Id: I'"$id"'"
-		}
-	}' "$MSG" > "$T" && mv "$T" "$MSG" || rm -f "$T"
-}
-_gen_ChangeIdInput() {
-	echo "tree `git write-tree`"
-	if parent=`git rev-parse "HEAD^0" 2>/dev/null`
-	then
-		echo "parent $parent"
-	fi
-	echo "author `git var GIT_AUTHOR_IDENT`"
-	echo "committer `git var GIT_COMMITTER_IDENT`"
-	echo
-	printf '%s' "$clean_message"
-}
-_gen_ChangeId() {
-	_gen_ChangeIdInput |
-	git hash-object -t commit --stdin
-}
-
-
-add_ChangeId
+if ! mv "${dest}" "$1" ; then
+  echo "cannot mv ${dest} to $1"
+  exit 1
+fi
diff --git a/tools/BUILD b/tools/BUILD
index 060cbd8..89ce558 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -1,6 +1,117 @@
+load(
+    "@bazel_tools//tools/jdk:default_java_toolchain.bzl",
+    "JDK9_JVM_OPTS",
+    "default_java_toolchain",
+)
+
+exports_files(["nongoogle.bzl"])
+
 py_binary(
     name = "merge_jars",
     srcs = ["merge_jars.py"],
     main = "merge_jars.py",
     visibility = ["//visibility:public"],
 )
+
+default_java_toolchain(
+    name = "error_prone_warnings_toolchain",
+    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
+    jvm_opts = JDK9_JVM_OPTS,
+    package_configuration = [
+        ":error_prone",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+# Error Prone errors enabled by default; see ../.bazelrc for how this is
+# enabled. This warnings list is originally based on:
+# https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
+# However, feel free to add any additional errors. Thus far they have all been pretty useful.
+java_package_configuration(
+    name = "error_prone",
+    javacopts = [
+        "-XepDisableWarningsInGeneratedCode",
+        "-Xep:AmbiguousMethodReference:ERROR",
+        "-Xep:AutoValueFinalMethods:ERROR",
+        "-Xep:BadAnnotationImplementation:ERROR",
+        "-Xep:BadComparable:ERROR",
+        "-Xep:BoxedPrimitiveConstructor:ERROR",
+        "-Xep:CannotMockFinalClass:ERROR",
+        "-Xep:ClassCanBeStatic:ERROR",
+        "-Xep:ClassNewInstance:ERROR",
+        "-Xep:DateFormatConstant:ERROR",
+        "-Xep:DefaultCharset:ERROR",
+        "-Xep:DoubleCheckedLocking:ERROR",
+        "-Xep:ElementsCountedInLoop:ERROR",
+        "-Xep:DoubleCheckedLocking:ERROR",
+        "-Xep:ElementsCountedInLoop:ERROR",
+        "-Xep:EqualsHashCode:ERROR",
+        "-Xep:EqualsIncompatibleType:ERROR",
+        "-Xep:ExpectedExceptionChecker:ERROR",
+        "-Xep:Finally:ERROR",
+        "-Xep:FloatingPointLiteralPrecision:ERROR",
+        "-Xep:FragmentInjection:ERROR",
+        "-Xep:FragmentNotInstantiable:ERROR",
+        "-Xep:FunctionalInterfaceClash:ERROR",
+        "-Xep:FutureReturnValueIgnored:ERROR",
+        "-Xep:GetClassOnEnum:ERROR",
+        "-Xep:ImmutableAnnotationChecker:ERROR",
+        "-Xep:ImmutableEnumChecker:ERROR",
+        "-Xep:IncompatibleModifiers:ERROR",
+        "-Xep:InjectOnConstructorOfAbstractClass:ERROR",
+        "-Xep:InputStreamSlowMultibyteRead:ERROR",
+        "-Xep:IterableAndIterator:ERROR",
+        "-Xep:JUnit3FloatingPointComparisonWithoutDelta:ERROR",
+        "-Xep:JUnitAmbiguousTestClass:ERROR",
+        "-Xep:LiteralClassName:ERROR",
+        "-Xep:MissingCasesInEnumSwitch:ERROR",
+        "-Xep:MissingFail:ERROR",
+        "-Xep:MissingOverride:ERROR",
+        "-Xep:MutableConstantField:ERROR",
+        "-Xep:NarrowingCompoundAssignment:ERROR",
+        "-Xep:NonAtomicVolatileUpdate:ERROR",
+        "-Xep:NonOverridingEquals:ERROR",
+        "-Xep:NullableConstructor:ERROR",
+        "-Xep:NullablePrimitive:ERROR",
+        "-Xep:NullableVoid:ERROR",
+        "-Xep:OperatorPrecedence:ERROR",
+        "-Xep:OverridesGuiceInjectableMethod:ERROR",
+        "-Xep:PreconditionsInvalidPlaceholder:ERROR",
+        "-Xep:ProtoFieldPreconditionsCheckNotNull:ERROR",
+        "-Xep:ProtocolBufferOrdinal:ERROR",
+        "-Xep:ReferenceEquality:ERROR",
+        "-Xep:RequiredModifiers:ERROR",
+        "-Xep:ShortCircuitBoolean:ERROR",
+        "-Xep:SimpleDateFormatConstant:ERROR",
+        "-Xep:StaticGuardedByInstance:ERROR",
+        "-Xep:StringEquality:ERROR",
+        "-Xep:SynchronizeOnNonFinalField:ERROR",
+        "-Xep:TruthConstantAsserts:ERROR",
+        "-Xep:TypeParameterShadowing:ERROR",
+        "-Xep:TypeParameterUnusedInFormals:ERROR",
+        "-Xep:URLEqualsHashCode:ERROR",
+        "-Xep:UnsynchronizedOverridesSynchronized:ERROR",
+        "-Xep:WaitNotInLoop:ERROR",
+        "-Xep:WildcardImport:ERROR",
+    ],
+    packages = ["error_prone_packages"],
+)
+
+package_group(
+    name = "error_prone_packages",
+    packages = [
+        "//java/...",
+        "//javatests/...",
+        "//plugins/codemirror-editor/...",
+        "//plugins/commit-message-length-validator/...",
+        "//plugins/delete-project/...",
+        "//plugins/download-commands/...",
+        "//plugins/gitiles/...",
+        "//plugins/hooks/...",
+        "//plugins/plugin-manager/...",
+        "//plugins/replication/...",
+        "//plugins/reviewnotes/...",
+        "//plugins/singleusergroup/...",
+        "//plugins/webhooks/...",
+    ],
+)
diff --git a/tools/bazel.rc b/tools/bazel.rc
deleted file mode 100644
index 7230cf3..0000000
--- a/tools/bazel.rc
+++ /dev/null
@@ -1,6 +0,0 @@
-build --workspace_status_command=./tools/workspace-status.sh --strategy=Closure=worker
-build --disk_cache=~/.gerritcodereview/bazel-cache/cas
-build --repository_cache=~/.gerritcodereview/bazel-cache/repository
-build --experimental_strict_action_env
-build --action_env=PATH
-test --build_tests_only
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index e20624d..f3c4646 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -1,124 +1,131 @@
 def documentation_attributes():
-  return [
-    "toc2",
-    'newline="\\n"',
-    'asterisk="&#42;"',
-    'plus="&#43;"',
-    'caret="&#94;"',
-    'startsb="&#91;"',
-    'endsb="&#93;"',
-    'tilde="&#126;"',
-    "last-update-label!",
-    "source-highlighter=prettify",
-    "stylesheet=DEFAULT",
-    "linkcss=true",
-    "prettifydir=.",
-    # Just a placeholder, will be filled in asciidoctor java binary:
-    "revnumber=%s",
-  ]
+    return [
+        "toc2",
+        'newline="\\n"',
+        'asterisk="&#42;"',
+        'plus="&#43;"',
+        'caret="&#94;"',
+        'startsb="&#91;"',
+        'endsb="&#93;"',
+        'tilde="&#126;"',
+        "last-update-label!",
+        "source-highlighter=prettify",
+        "stylesheet=DEFAULT",
+        "linkcss=true",
+        "prettifydir=.",
+        # Just a placeholder, will be filled in asciidoctor java binary:
+        "revnumber=%s",
+    ]
 
 def _replace_macros_impl(ctx):
-  cmd = [
-    ctx.file._exe.path,
-    '--suffix', ctx.attr.suffix,
-    "-s", ctx.file.src.path,
-    "-o", ctx.outputs.out.path,
-  ]
-  if ctx.attr.searchbox:
-    cmd.append('--searchbox')
-  else:
-    cmd.append('--no-searchbox')
-  ctx.actions.run_shell(
-    inputs = [ctx.file._exe, ctx.file.src],
-    outputs = [ctx.outputs.out],
-    command = cmd,
-    use_default_shell_env = True,
-    progress_message = "Replacing macros in %s" % ctx.file.src.short_path,
-  )
+    cmd = [
+        ctx.file._exe.path,
+        "--suffix",
+        ctx.attr.suffix,
+        "-s",
+        ctx.file.src.path,
+        "-o",
+        ctx.outputs.out.path,
+    ]
+    if ctx.attr.searchbox:
+        cmd.append("--searchbox")
+    else:
+        cmd.append("--no-searchbox")
+    ctx.actions.run_shell(
+        inputs = [ctx.file._exe, ctx.file.src],
+        outputs = [ctx.outputs.out],
+        command = cmd,
+        use_default_shell_env = True,
+        progress_message = "Replacing macros in %s" % ctx.file.src.short_path,
+    )
 
 _replace_macros = rule(
     attrs = {
-        "_exe": attr.label(
-            default = Label("//Documentation:replace_macros.py"),
-            allow_single_file = True,
-        ),
         "src": attr.label(
             mandatory = True,
             allow_single_file = [".txt"],
         ),
-        "suffix": attr.string(mandatory = True),
-        "searchbox": attr.bool(default = True),
         "out": attr.output(mandatory = True),
+        "searchbox": attr.bool(default = True),
+        "suffix": attr.string(mandatory = True),
+        "_exe": attr.label(
+            default = Label("//Documentation:replace_macros.py"),
+            allow_single_file = True,
+        ),
     },
     implementation = _replace_macros_impl,
 )
 
 def _generate_asciidoc_args(ctx):
-  args = []
-  if ctx.attr.backend:
-    args.extend(["-b", ctx.attr.backend])
-  revnumber = False
-  for attribute in ctx.attr.attributes:
-    if attribute.startswith("revnumber="):
-      revnumber = True
-    else:
-      args.extend(["-a", attribute])
-  if revnumber:
-    args.extend([
-      "--revnumber-file", ctx.file.version.path,
-    ])
-  for src in ctx.files.srcs:
-    args.append(src.path)
-  return args
+    args = []
+    if ctx.attr.backend:
+        args.extend(["-b", ctx.attr.backend])
+    revnumber = False
+    for attribute in ctx.attr.attributes:
+        if attribute.startswith("revnumber="):
+            revnumber = True
+        else:
+            args.extend(["-a", attribute])
+    if revnumber:
+        args.extend([
+            "--revnumber-file",
+            ctx.file.version.path,
+        ])
+    for src in ctx.files.srcs:
+        args.append(src.path)
+    return args
 
 def _invoke_replace_macros(name, src, suffix, searchbox):
-  fn = src
-  if fn.startswith(":"):
-    fn = src[1:]
+    fn = src
+    if fn.startswith(":"):
+        fn = src[1:]
 
-  _replace_macros(
-    name = "macros_%s_%s" % (name, fn),
-    src = src,
-    out = fn + suffix,
-    suffix = suffix,
-    searchbox = searchbox,
-  )
+    _replace_macros(
+        name = "macros_%s_%s" % (name, fn),
+        src = src,
+        out = fn + suffix,
+        suffix = suffix,
+        searchbox = searchbox,
+    )
 
-  return ":" + fn + suffix, fn.replace(".txt", ".html")
+    return ":" + fn + suffix, fn.replace(".txt", ".html")
 
 def _asciidoc_impl(ctx):
-  args = [
-    "--bazel",
-    "--in-ext", ".txt" + ctx.attr.suffix,
-    "--out-ext", ".html",
-  ]
-  args.extend(_generate_asciidoc_args(ctx))
-  ctx.actions.run(
-    inputs = ctx.files.srcs + [ctx.executable._exe, ctx.file.version],
-    outputs = ctx.outputs.outs,
-    executable = ctx.executable._exe,
-    arguments = args,
-    progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
-  )
+    args = [
+        "--bazel",
+        "--in-ext",
+        ".txt" + ctx.attr.suffix,
+        "--out-ext",
+        ".html",
+    ]
+    args.extend(_generate_asciidoc_args(ctx))
+    ctx.actions.run(
+        inputs = ctx.files.srcs + [ctx.file.version],
+        outputs = ctx.outputs.outs,
+        tools = [ctx.executable._exe],
+        executable = ctx.executable._exe,
+        arguments = args,
+        progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+    )
 
 _asciidoc_attrs = {
+    "srcs": attr.label_list(
+        mandatory = True,
+        allow_files = True,
+    ),
+    "attributes": attr.string_list(),
+    "backend": attr.string(),
+    "suffix": attr.string(mandatory = True),
+    "version": attr.label(
+        default = Label("//:version.txt"),
+        allow_single_file = True,
+    ),
     "_exe": attr.label(
         default = Label("//java/com/google/gerrit/asciidoctor:asciidoc"),
         cfg = "host",
         allow_files = True,
         executable = True,
     ),
-    "srcs": attr.label_list(
-        mandatory = True,
-        allow_files = True,
-    ),
-    "version": attr.label(
-        default = Label("//:version.txt"),
-        allow_single_file = True,
-    ),
-    "suffix": attr.string(mandatory = True),
-    "backend": attr.string(),
-    "attributes": attr.string_list(),
 }
 
 _asciidoc = rule(
@@ -129,81 +136,85 @@
 )
 
 def _genasciidoc_htmlonly(
-    name,
-    srcs = [],
-    attributes = [],
-    backend = None,
-    searchbox = True,
-    **kwargs):
-  SUFFIX = "." + name + "_macros"
-  new_srcs = []
-  outs = ["asciidoctor.css"]
+        name,
+        srcs = [],
+        attributes = [],
+        backend = None,
+        searchbox = True,
+        **kwargs):
+    SUFFIX = "." + name + "_macros"
+    new_srcs = []
+    outs = ["asciidoctor.css"]
 
-  for src in srcs:
-    new_src, html_name = _invoke_replace_macros(name, src, SUFFIX, searchbox)
-    new_srcs.append(new_src)
-    outs.append(html_name)
+    for src in srcs:
+        new_src, html_name = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+        new_srcs.append(new_src)
+        outs.append(html_name)
 
-  _asciidoc(
-    name = name + "_gen",
-    srcs = new_srcs,
-    suffix = SUFFIX,
-    backend = backend,
-    attributes = attributes,
-    outs = outs,
-  )
-
-  native.filegroup(
-    name = name,
-    data = outs,
-    **kwargs
-  )
-
-def genasciidoc(
-    name,
-    srcs = [],
-    attributes = [],
-    backend = None,
-    searchbox = True,
-    resources = True,
-    **kwargs):
-  SUFFIX = "_htmlonly"
-
-  _genasciidoc_htmlonly(
-    name = name + SUFFIX if resources else name,
-    srcs = srcs,
-    attributes = attributes,
-    backend = backend,
-    searchbox = searchbox,
-    **kwargs
-  )
-
-  if resources:
-    htmlonly = ":" + name + SUFFIX
-    native.filegroup(
-      name = name,
-      srcs = [
-        htmlonly,
-        "//Documentation:resources",
-      ],
-      **kwargs
+    _asciidoc(
+        name = name + "_gen",
+        srcs = new_srcs,
+        suffix = SUFFIX,
+        backend = backend,
+        attributes = attributes,
+        outs = outs,
     )
 
+    native.filegroup(
+        name = name,
+        data = outs,
+        **kwargs
+    )
+
+def genasciidoc(
+        name,
+        srcs = [],
+        attributes = [],
+        backend = None,
+        searchbox = True,
+        resources = True,
+        **kwargs):
+    SUFFIX = "_htmlonly"
+
+    _genasciidoc_htmlonly(
+        name = name + SUFFIX if resources else name,
+        srcs = srcs,
+        attributes = attributes,
+        backend = backend,
+        searchbox = searchbox,
+        **kwargs
+    )
+
+    if resources:
+        htmlonly = ":" + name + SUFFIX
+        native.filegroup(
+            name = name,
+            srcs = [
+                htmlonly,
+                "//Documentation:resources",
+            ],
+            **kwargs
+        )
+
 def _asciidoc_html_zip_impl(ctx):
-  args = [
-    "--mktmp",
-    "-z", ctx.outputs.out.path,
-    "--in-ext", ".txt" + ctx.attr.suffix,
-    "--out-ext", ".html",
-  ]
-  args.extend(_generate_asciidoc_args(ctx))
-  ctx.actions.run(
-    inputs = ctx.files.srcs + [ctx.executable._exe, ctx.file.version],
-    outputs = [ctx.outputs.out],
-    executable = ctx.executable._exe,
-    arguments = args,
-    progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
-  )
+    args = [
+        "--mktmp",
+        "-z",
+        ctx.outputs.out.path,
+        "--in-ext",
+        ".txt" + ctx.attr.suffix,
+        "--out-ext",
+        ".html",
+    ]
+    args.extend(_generate_asciidoc_args(ctx))
+    ctx.actions.run(
+        inputs = ctx.files.srcs + [ctx.file.version],
+        outputs = [ctx.outputs.out],
+        tools = [ctx.executable._exe],
+        executable = ctx.executable._exe,
+        arguments = args,
+        progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+    )
 
 _asciidoc_html_zip = rule(
     attrs = _asciidoc_attrs,
@@ -214,53 +225,54 @@
 )
 
 def _genasciidoc_htmlonly_zip(
-    name,
-    srcs = [],
-    attributes = [],
-    backend = None,
-    searchbox = True,
-    **kwargs):
-  SUFFIX = "." + name + "_expn"
-  new_srcs = []
+        name,
+        srcs = [],
+        attributes = [],
+        backend = None,
+        searchbox = True,
+        **kwargs):
+    SUFFIX = "." + name + "_expn"
+    new_srcs = []
 
-  for src in srcs:
-    new_src, _ = _invoke_replace_macros(name, src, SUFFIX, searchbox)
-    new_srcs.append(new_src)
+    for src in srcs:
+        new_src, _ = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+        new_srcs.append(new_src)
 
-  _asciidoc_html_zip(
-    name = name,
-    srcs = new_srcs,
-    suffix = SUFFIX,
-    backend = backend,
-    attributes = attributes,
-  )
+    _asciidoc_html_zip(
+        name = name,
+        srcs = new_srcs,
+        suffix = SUFFIX,
+        backend = backend,
+        attributes = attributes,
+    )
 
 def _asciidoc_zip_impl(ctx):
-  tmpdir = ctx.outputs.out.path + "_tmpdir"
-  cmd = [
-    "p=$PWD",
-    "rm -rf %s" % tmpdir,
-    "mkdir -p %s/%s/" % (tmpdir, ctx.attr.directory),
-    "unzip -q %s -d %s/%s/" % (ctx.file.src.path, tmpdir, ctx.attr.directory),
-  ]
-  for r in ctx.files.resources:
-    if r.path == r.short_path:
-      cmd.append("tar -cf- %s | tar -C %s -xf-" % (r.short_path, tmpdir))
-    else:
-      parent = r.path[:-len(r.short_path)]
-      cmd.append(
-        "tar -C %s -cf- %s | tar -C %s -xf-" % (parent, r.short_path, tmpdir))
-  cmd.extend([
-    "cd %s" % tmpdir,
-    "zip -qr $p/%s *" % ctx.outputs.out.path,
-  ])
-  ctx.actions.run_shell(
-    inputs = [ctx.file.src] + ctx.files.resources,
-    outputs = [ctx.outputs.out],
-    command = " && ".join(cmd),
-    progress_message =
-        "Generating asciidoctor zip file %s" % ctx.outputs.out.short_path,
-  )
+    tmpdir = ctx.outputs.out.path + "_tmpdir"
+    cmd = [
+        "p=$PWD",
+        "rm -rf %s" % tmpdir,
+        "mkdir -p %s/%s/" % (tmpdir, ctx.attr.directory),
+        "unzip -q %s -d %s/%s/" % (ctx.file.src.path, tmpdir, ctx.attr.directory),
+    ]
+    for r in ctx.files.resources:
+        if r.path == r.short_path:
+            cmd.append("tar -cf- %s | tar -C %s -xf-" % (r.short_path, tmpdir))
+        else:
+            parent = r.path[:-len(r.short_path)]
+            cmd.append(
+                "tar -C %s -cf- %s | tar -C %s -xf-" % (parent, r.short_path, tmpdir),
+            )
+    cmd.extend([
+        "cd %s" % tmpdir,
+        "zip -qr $p/%s *" % ctx.outputs.out.path,
+    ])
+    ctx.actions.run_shell(
+        inputs = [ctx.file.src] + ctx.files.resources,
+        outputs = [ctx.outputs.out],
+        command = " && ".join(cmd),
+        progress_message =
+            "Generating asciidoctor zip file %s" % ctx.outputs.out.short_path,
+    )
 
 _asciidoc_zip = rule(
     attrs = {
@@ -268,11 +280,11 @@
             mandatory = True,
             allow_single_file = [".zip"],
         ),
+        "directory": attr.string(mandatory = True),
         "resources": attr.label_list(
             mandatory = True,
             allow_files = True,
         ),
-        "directory": attr.string(mandatory = True),
     },
     outputs = {
         "out": "%{name}.zip",
@@ -281,30 +293,30 @@
 )
 
 def genasciidoc_zip(
-    name,
-    srcs = [],
-    attributes = [],
-    directory = None,
-    backend = None,
-    searchbox = True,
-    resources = True,
-    **kwargs):
-  SUFFIX = "_htmlonly"
+        name,
+        srcs = [],
+        attributes = [],
+        directory = None,
+        backend = None,
+        searchbox = True,
+        resources = True,
+        **kwargs):
+    SUFFIX = "_htmlonly"
 
-  _genasciidoc_htmlonly_zip(
-    name = name + SUFFIX if resources else name,
-    srcs = srcs,
-    attributes = attributes,
-    backend = backend,
-    searchbox = searchbox,
-    **kwargs
-  )
-
-  if resources:
-    htmlonly = ":" + name + SUFFIX
-    _asciidoc_zip(
-      name = name,
-      src = htmlonly,
-      resources = ["//Documentation:resources"],
-      directory = directory,
+    _genasciidoc_htmlonly_zip(
+        name = name + SUFFIX if resources else name,
+        srcs = srcs,
+        attributes = attributes,
+        backend = backend,
+        searchbox = searchbox,
+        **kwargs
     )
+
+    if resources:
+        htmlonly = ":" + name + SUFFIX
+        _asciidoc_zip(
+            name = name,
+            src = htmlonly,
+            resources = ["//Documentation:resources"],
+            directory = directory,
+        )
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
index 9448ed1..0d43be7 100644
--- a/tools/bzl/classpath.bzl
+++ b/tools/bzl/classpath.bzl
@@ -1,15 +1,18 @@
 def _classpath_collector(ctx):
-    all = depset()
+    all = []
     for d in ctx.attr.deps:
-        if hasattr(d, 'java'):
-            all += d.java.transitive_runtime_deps
-            all += d.java.compilation_info.runtime_classpath
-        elif hasattr(d, 'files'):
-            all += d.files
+        if hasattr(d, "java"):
+            all.append(d.java.transitive_runtime_deps)
+            if hasattr(d.java.compilation_info, "runtime_classpath"):
+                all.append(d.java.compilation_info.runtime_classpath)
+        elif hasattr(d, "files"):
+            all.append(d.files)
 
-    as_strs = [c.path for c in all]
-    ctx.file_action(output= ctx.outputs.runtime,
-                    content="\n".join(sorted(as_strs)))
+    as_strs = [c.path for c in depset(transitive = all).to_list()]
+    ctx.actions.write(
+        output = ctx.outputs.runtime,
+        content = "\n".join(sorted(as_strs)),
+    )
 
 classpath_collector = rule(
     attrs = {
diff --git a/tools/bzl/genrule2.bzl b/tools/bzl/genrule2.bzl
index 563a9ef..3113022 100644
--- a/tools/bzl/genrule2.bzl
+++ b/tools/bzl/genrule2.bzl
@@ -17,11 +17,12 @@
 #   expose TMP shell variable
 
 def genrule2(cmd, **kwargs):
-  cmd = ' && '.join([
-    'ROOT=$$PWD',
-    'TMP=$$(mktemp -d || mktemp -d -t bazel-tmp)',
-    '(' + cmd + ')',
-  ])
-  native.genrule(
-    cmd = cmd,
-    **kwargs)
+    cmd = " && ".join([
+        "ROOT=$$PWD",
+        "TMP=$$(mktemp -d || mktemp -d -t bazel-tmp)",
+        "(" + cmd + ")",
+    ])
+    native.genrule(
+        cmd = cmd,
+        **kwargs
+    )
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
deleted file mode 100644
index b09a608..0000000
--- a/tools/bzl/gwt.bzl
+++ /dev/null
@@ -1,303 +0,0 @@
-# Copyright (C) 2016 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:java.bzl", "java_library2")
-
-jar_filetype = FileType([".jar"])
-
-BROWSERS = [
-    "chrome",
-    "firefox",
-    "gecko1_8",
-    "safari",
-    "msie",
-    "ie8",
-    "ie9",
-    "ie10",
-    "edge",
-]
-
-ALIASES = {
-    "chrome": "safari",
-    "firefox": "gecko1_8",
-    "msie": "ie10",
-    "edge": "gecko1_8",
-}
-
-MODULE = "com.google.gerrit.GerritGwtUI"
-
-GWT_COMPILER = "com.google.gwt.dev.Compiler"
-
-GWT_JVM_ARGS = ["-Xmx512m"]
-
-GWT_COMPILER_ARGS = [
-    "-XdisableClassMetadata",
-]
-
-GWT_COMPILER_ARGS_RELEASE_MODE = GWT_COMPILER_ARGS + [
-    "-XdisableCastChecking",
-]
-
-GWT_PLUGIN_DEPS_NEVERLINK = [
-    "//gerrit-plugin-gwtui:gwtui-api-lib-neverlink",
-    "//lib/gwt:user-neverlink",
-]
-
-GWT_PLUGIN_DEPS = [
-    "//gerrit-plugin-gwtui:gwtui-api-lib",
-]
-
-GWT_TRANSITIVE_DEPS = [
-    "//lib:jsr305",
-    "//lib/gwt:ant",
-    "//lib/gwt:colt",
-    "//lib/gwt:javax-validation",
-    "//lib/gwt:javax-validation_src",
-    "//lib/gwt:jsinterop-annotations",
-    "//lib/gwt:jsinterop-annotations_src",
-    "//lib/gwt:tapestry",
-    "//lib/gwt:w3c-css-sac",
-    "//lib/ow2:ow2-asm",
-    "//lib/ow2:ow2-asm-analysis",
-    "//lib/ow2:ow2-asm-commons",
-    "//lib/ow2:ow2-asm-tree",
-    "//lib/ow2:ow2-asm-util",
-]
-
-DEPS = GWT_TRANSITIVE_DEPS + [
-    "//java/com/google/gwtexpui/css",
-    "//lib:gwtjsonrpc",
-    "//lib/gwt:dev",
-    "//lib/jgit/org.eclipse.jgit:jgit-source",
-]
-
-USER_AGENT_XML = """<module rename-to='gerrit_ui'>
-<inherits name='%s'/>
-<set-property name='user.agent' value='%s'/>
-<set-property name='locale' value='default'/>
-</module>
-"""
-
-def gwt_module(gwt_xml=None, resources=[], srcs=[], **kwargs):
-  if gwt_xml:
-    resources = resources + [gwt_xml]
-
-  java_library2(
-    srcs = srcs,
-    resources = resources,
-    **kwargs)
-
-def _gwt_user_agent_module(ctx):
-  """Generate user agent specific GWT module."""
-  if not ctx.attr.user_agent:
-    return None
-
-  ua = ctx.attr.user_agent
-  impl = ua
-  if ua in ALIASES:
-    impl = ALIASES[ua]
-
-  # intermediate artifact: user agent speific GWT xml file
-  gwt_user_agent_xml = ctx.new_file(ctx.label.name + "_gwt.xml")
-  ctx.file_action(output = gwt_user_agent_xml,
-                  content=USER_AGENT_XML % (MODULE, impl))
-
-  # intermediate artifact: user agent specific zip with GWT module
-  gwt_user_agent_zip = ctx.new_file(ctx.label.name + "_gwt.zip")
-  gwt = '%s_%s.gwt.xml' % (MODULE.replace('.', '/'), ua)
-  dir = gwt_user_agent_zip.path + ".dir"
-  cmd = " && ".join([
-    "p=$PWD",
-    "mkdir -p %s" % dir,
-    "cd %s" % dir,
-    "mkdir -p $(dirname %s)" % gwt,
-    "cp $p/%s %s" % (gwt_user_agent_xml.path, gwt),
-    "$p/%s cC $p/%s $(find . | sed 's|^./||')" % (ctx.executable._zip.path, gwt_user_agent_zip.path)
-  ])
-  ctx.actions.run_shell(
-    inputs = [gwt_user_agent_xml] + ctx.files._zip,
-    outputs = [gwt_user_agent_zip],
-    command = cmd,
-    mnemonic = "GenerateUserAgentGWTModule")
-
-  return struct(
-    zip=gwt_user_agent_zip,
-    module=MODULE + '_' + ua
-  )
-
-def _gwt_binary_impl(ctx):
-  module = ctx.attr.module[0]
-  output_zip = ctx.outputs.output
-  output_dir = output_zip.path + '.gwt_output'
-  deploy_dir = output_zip.path + '.gwt_deploy'
-
-  deps = _get_transitive_closure(ctx)
-
-  paths = []
-  for dep in deps:
-    paths.append(dep.path)
-
-  gwt_user_agent_modules = []
-  ua = _gwt_user_agent_module(ctx)
-  if ua:
-    paths.append(ua.zip.path)
-    gwt_user_agent_modules.append(ua.zip)
-    module = ua.module
-
-  cmd = "external/local_jdk/bin/java %s -Dgwt.normalizeTimestamps=true -cp %s %s -war %s -deploy %s " % (
-    " ".join(ctx.attr.jvm_args),
-    ":".join(paths),
-    GWT_COMPILER,
-    output_dir,
-    deploy_dir,
-  )
-  # TODO(davido): clean up command concatenation
-  cmd += " ".join([
-    "-style %s" % ctx.attr.style,
-    "-optimize %s" % ctx.attr.optimize,
-    "-strict",
-    " ".join(ctx.attr.compiler_args),
-    module + "\n",
-    "rm -rf %s/gwt-unitCache\n" % output_dir,
-    "root=`pwd`\n",
-    "cd %s; $root/%s Cc ../%s $(find .)\n" % (
-      output_dir,
-      ctx.executable._zip.path,
-      output_zip.basename,
-    )
-  ])
-
-  ctx.actions.run_shell(
-    inputs = list(deps) + ctx.files._jdk + ctx.files._zip + gwt_user_agent_modules,
-    outputs = [output_zip],
-    mnemonic = "GwtBinary",
-    progress_message = "GWT compiling " + output_zip.short_path,
-    command = "set -e\n" + cmd,
-  )
-
-def _get_transitive_closure(ctx):
-  deps = depset()
-  for dep in ctx.attr.module_deps:
-    deps += dep.java.transitive_runtime_deps
-    deps += dep.java.transitive_source_jars
-  for dep in ctx.attr.deps:
-    if hasattr(dep, 'java'):
-      deps += dep.java.transitive_runtime_deps
-    elif hasattr(dep, 'files'):
-      deps += dep.files
-
-  return deps
-
-gwt_binary = rule(
-    attrs = {
-        "user_agent": attr.string(),
-        "style": attr.string(default = "OBF"),
-        "optimize": attr.string(default = "9"),
-        "deps": attr.label_list(allow_files = jar_filetype),
-        "module": attr.string_list(default = [MODULE]),
-        "module_deps": attr.label_list(allow_files = jar_filetype),
-        "compiler_args": attr.string_list(),
-        "jvm_args": attr.string_list(),
-        "_jdk": attr.label(
-            default = Label("//tools/defaults:jdk"),
-        ),
-        "_zip": attr.label(
-            default = Label("@bazel_tools//tools/zip:zipper"),
-            cfg = "host",
-            executable = True,
-            single_file = True,
-        ),
-    },
-    outputs = {
-        "output": "%{name}.zip",
-    },
-    implementation = _gwt_binary_impl,
-)
-
-def gwt_genrule(suffix = ""):
-  dbg = 'ui_dbg' + suffix
-  opt = 'ui_opt' + suffix
-  module_dep = ':ui_module' + suffix
-  args = GWT_COMPILER_ARGS_RELEASE_MODE if suffix == "_r" else GWT_COMPILER_ARGS
-
-  genrule2(
-    name = 'ui_optdbg' + suffix,
-    srcs = [
-      ':' + dbg,
-      ':' + opt,
-     ],
-    cmd = 'cd $$TMP;' +
-      'unzip -q $$ROOT/$(location :%s);' % dbg +
-      'mv' +
-      ' gerrit_ui/gerrit_ui.nocache.js' +
-      ' gerrit_ui/dbg_gerrit_ui.nocache.js;' +
-      'unzip -qo $$ROOT/$(location :%s);' % opt +
-      'mkdir -p $$(dirname $@);' +
-      'zip -qrD $$ROOT/$@ .',
-    outs = ['ui_optdbg' + suffix + '.zip'],
-    visibility = ['//visibility:public'],
-   )
-
-  gwt_binary(
-    name = opt,
-    module = [MODULE],
-    module_deps = [module_dep],
-    deps = DEPS,
-    compiler_args = args,
-    jvm_args = GWT_JVM_ARGS,
-  )
-
-  gwt_binary(
-    name = dbg,
-    style = 'PRETTY',
-    optimize = "0",
-    module_deps = [module_dep],
-    deps = DEPS,
-    compiler_args = GWT_COMPILER_ARGS,
-    jvm_args = GWT_JVM_ARGS,
-  )
-
-def gen_ui_module(name, suffix = ""):
-  gwt_module(
-    name = name + suffix,
-    srcs = native.glob(['src/main/java/**/*.java']),
-    gwt_xml = 'src/main/java/%s.gwt.xml' % MODULE.replace('.', '/'),
-    resources = native.glob(
-        ['src/main/java/**/*'],
-        exclude = ['src/main/java/**/*.java'] +
-        ['src/main/java/%s.gwt.xml' % MODULE.replace('.', '/')]),
-    deps = [
-      '//gerrit-gwtui-common:diffy_logo',
-      '//gerrit-gwtui-common:client',
-      '//java/com/google/gwtexpui/css',
-      '//lib/codemirror:codemirror' + suffix,
-      '//lib/gwt:user',
-    ],
-    visibility = ['//visibility:public'],
-  )
-
-def gwt_user_agent_permutations():
-  for ua in BROWSERS:
-    gwt_binary(
-      name = "ui_%s" % ua,
-      user_agent = ua,
-      style = 'PRETTY',
-      optimize = "0",
-      module = [MODULE],
-      module_deps = [':ui_module'],
-      deps = DEPS,
-      compiler_args = GWT_COMPILER_ARGS,
-      jvm_args = GWT_JVM_ARGS,
-    )
diff --git a/tools/bzl/java.bzl b/tools/bzl/java.bzl
index 5fca724..7c41fbe 100644
--- a/tools/bzl/java.bzl
+++ b/tools/bzl/java.bzl
@@ -15,11 +15,12 @@
 # Syntactic sugar for native java_library() rule:
 #   accept exported_deps attributes
 
-def java_library2(deps=[], exported_deps=[], exports=[], **kwargs):
-  if exported_deps:
-    deps = deps + exported_deps
-    exports = exports + exported_deps
-  native.java_library(
-    deps = deps,
-    exports = exports,
-    **kwargs)
+def java_library2(deps = [], exported_deps = [], exports = [], **kwargs):
+    if exported_deps:
+        deps = deps + exported_deps
+        exports = exports + exported_deps
+    native.java_library(
+        deps = deps,
+        exports = exports,
+        **kwargs
+    )
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index f49c881..754bd96 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -15,64 +15,59 @@
 # Javadoc rule.
 
 def _impl(ctx):
-  zip_output = ctx.outputs.zip
+    zip_output = ctx.outputs.zip
 
-  transitive_jar_set = depset()
-  source_jars = depset()
-  for l in ctx.attr.libs:
-    source_jars += l.java.source_jars
-    transitive_jar_set += l.java.transitive_deps
+    transitive_jars = depset(transitive = [j.java.transitive_deps for j in ctx.attr.libs])
+    source_jars = depset(transitive = [j.java.source_jars for j in ctx.attr.libs])
 
-  transitive_jar_paths = [j.path for j in transitive_jar_set]
-  dir = ctx.outputs.zip.path + ".dir"
-  source = ctx.outputs.zip.path + ".source"
-  external_docs = ["http://docs.oracle.com/javase/8/docs/api"] + ctx.attr.external_docs
-  cmd = [
-      "TZ=UTC",
-      "export TZ",
-      "rm -rf %s" % source,
-      "mkdir %s" % source,
-      " && ".join(["unzip -qud %s %s" % (source, j.path) for j in source_jars]),
-      "rm -rf %s" % dir,
-      "mkdir %s" % dir,
-      " ".join([
-        ctx.file._javadoc.path,
-        "-Xdoclint:-missing",
-        "-protected",
-        "-encoding UTF-8",
-        "-charset UTF-8",
-        "-notimestamp",
-        "-quiet",
-        "-windowtitle '%s'" % ctx.attr.title,
-        " ".join(['-link %s' % url for url in external_docs]),
-        "-sourcepath %s" % source,
-        "-subpackages ",
-        ":".join(ctx.attr.pkgs),
-        " -classpath ",
-        ":".join(transitive_jar_paths),
-        "-d %s" % dir]),
-    "find %s -exec touch -t 198001010000 '{}' ';'" % dir,
-    "(cd %s && zip -Xqr ../%s *)" % (dir, ctx.outputs.zip.basename),
-  ]
-  ctx.actions.run_shell(
-      inputs = list(transitive_jar_set) + list(source_jars) + ctx.files._jdk,
-      outputs = [zip_output],
-      command = " && ".join(cmd))
+    transitive_jar_paths = [j.path for j in transitive_jars.to_list()]
+    dir = ctx.outputs.zip.path + ".dir"
+    source = ctx.outputs.zip.path + ".source"
+    external_docs = ["https://docs.oracle.com/javase/8/docs/api"] + ctx.attr.external_docs
+    cmd = [
+        "TZ=UTC",
+        "export TZ",
+        "rm -rf %s" % source,
+        "mkdir %s" % source,
+        " && ".join(["unzip -qud %s %s" % (source, j.path) for j in source_jars.to_list()]),
+        "rm -rf %s" % dir,
+        "mkdir %s" % dir,
+        " ".join([
+            "%s/bin/javadoc" % ctx.attr._jdk[java_common.JavaRuntimeInfo].java_home,
+            "-Xdoclint:-missing",
+            "-protected",
+            "-encoding UTF-8",
+            "-charset UTF-8",
+            "-notimestamp",
+            "-quiet",
+            "-windowtitle '%s'" % ctx.attr.title,
+            " ".join(["-link %s" % url for url in external_docs]),
+            "-sourcepath %s" % source,
+            "-subpackages ",
+            ":".join(ctx.attr.pkgs),
+            " -classpath ",
+            ":".join(transitive_jar_paths),
+            "-d %s" % dir,
+        ]),
+        "find %s -exec touch -t 198001010000 '{}' ';'" % dir,
+        "(cd %s && zip -Xqr ../%s *)" % (dir, ctx.outputs.zip.basename),
+    ]
+    ctx.actions.run_shell(
+        inputs = transitive_jars.to_list() + source_jars.to_list() + ctx.files._jdk,
+        outputs = [zip_output],
+        command = " && ".join(cmd),
+    )
 
 java_doc = rule(
     attrs = {
+        "external_docs": attr.string_list(),
         "libs": attr.label_list(allow_files = False),
         "pkgs": attr.string_list(),
         "title": attr.string(),
-        "external_docs": attr.string_list(),
-        "_javadoc": attr.label(
-            default = Label("@local_jdk//:bin/javadoc"),
-            single_file = True,
-            allow_files = True,
-        ),
         "_jdk": attr.label(
-            default = Label("@local_jdk//:jdk-default"),
+            default = Label("@bazel_tools//tools/jdk:current_java_runtime"),
             allow_files = True,
+            providers = [java_common.JavaRuntimeInfo],
         ),
     },
     outputs = {"zip": "%{name}.zip"},
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 2796f64..0408b2b 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,183 +1,199 @@
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
+load("//lib/js:npm.bzl", "NPM_SHA1S", "NPM_VERSIONS")
+
 NPMJS = "NPMJS"
 
 GERRIT = "GERRIT:"
 
-load("//lib/js:npm.bzl", "NPM_VERSIONS", "NPM_SHA1S")
-
 def _npm_tarball(name):
-  return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
+    return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
 
 def _npm_binary_impl(ctx):
-  """rule to download a NPM archive."""
-  name = ctx.name
-  version= NPM_VERSIONS[name]
-  sha1 = NPM_SHA1S[name]
+    """rule to download a NPM archive."""
+    name = ctx.name
+    version = NPM_VERSIONS[name]
+    sha1 = NPM_SHA1S[name]
 
-  dir = '%s-%s' % (name, version)
-  filename = '%s.tgz' % dir
-  base =  '%s@%s.npm_binary.tgz' % (name, version)
-  dest = ctx.path(base)
-  repository = ctx.attr.repository
-  if repository == GERRIT:
-    url = 'http://gerrit-maven.storage.googleapis.com/npm-packages/%s' % filename
-  elif repository == NPMJS:
-    url = 'http://registry.npmjs.org/%s/-/%s' % (name, filename)
-  else:
-    fail('repository %s not in {%s,%s}' % (repository, GERRIT, NPMJS))
+    dir = "%s-%s" % (name, version)
+    filename = "%s.tgz" % dir
+    base = "%s@%s.npm_binary.tgz" % (name, version)
+    dest = ctx.path(base)
+    repository = ctx.attr.repository
+    if repository == GERRIT:
+        url = "https://gerrit-maven.storage.googleapis.com/npm-packages/%s" % filename
+    elif repository == NPMJS:
+        url = "https://registry.npmjs.org/%s/-/%s" % (name, filename)
+    else:
+        fail("repository %s not in {%s,%s}" % (repository, GERRIT, NPMJS))
 
-  python = ctx.which("python")
-  script = ctx.path(ctx.attr._download_script)
+    python = ctx.which("python")
+    script = ctx.path(ctx.attr._download_script)
 
-  args = [python, script, "-o", dest, "-u", url, "-v", sha1]
-  out = ctx.execute(args)
-  if out.return_code:
-    fail("failed %s: %s" % (args, out.stderr))
-  ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
+    args = [python, script, "-o", dest, "-u", url, "-v", sha1]
+    out = ctx.execute(args)
+    if out.return_code:
+        fail("failed %s: %s" % (args, out.stderr))
+    ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
 
 npm_binary = repository_rule(
     attrs = {
+        "repository": attr.string(default = NPMJS),
         # Label resolves within repo of the .bzl file.
         "_download_script": attr.label(default = Label("//tools:download_file.py")),
-        "repository": attr.string(default = NPMJS),
     },
     local = True,
     implementation = _npm_binary_impl,
 )
 
+ComponentInfo = provider()
+
 # for use in repo rules.
 def _run_npm_binary_str(ctx, tarball, args):
-  python_bin = ctx.which("python")
-  return " ".join([
-    python_bin,
-    ctx.path(ctx.attr._run_npm),
-    ctx.path(tarball)] + args)
+    python_bin = ctx.which("python")
+    return " ".join([
+        str(python_bin),
+        str(ctx.path(ctx.attr._run_npm)),
+        str(ctx.path(tarball)),
+    ] + args)
 
 def _bower_archive(ctx):
-  """Download a bower package."""
-  download_name = '%s__download_bower.zip' % ctx.name
-  renamed_name = '%s__renamed.zip' % ctx.name
-  version_name = '%s__version.json' % ctx.name
+    """Download a bower package."""
+    download_name = "%s__download_bower.zip" % ctx.name
+    renamed_name = "%s__renamed.zip" % ctx.name
+    version_name = "%s__version.json" % ctx.name
 
-  cmd = [
-      ctx.which("python"),
-      ctx.path(ctx.attr._download_bower),
-      '-b', '%s' % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
-      '-n', ctx.name,
-      '-p', ctx.attr.package,
-      '-v', ctx.attr.version,
-      '-s', ctx.attr.sha1,
-      '-o', download_name,
+    cmd = [
+        ctx.which("python"),
+        ctx.path(ctx.attr._download_bower),
+        "-b",
+        "%s" % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
+        "-n",
+        ctx.name,
+        "-p",
+        ctx.attr.package,
+        "-v",
+        ctx.attr.version,
+        "-s",
+        ctx.attr.sha1,
+        "-o",
+        download_name,
     ]
 
-  out = ctx.execute(cmd)
-  if out.return_code:
-    fail("failed %s: %s" % (" ".join(cmd), out.stderr))
+    out = ctx.execute(cmd)
+    if out.return_code:
+        fail("failed %s: %s" % (cmd, out.stderr))
 
-  _bash(ctx, " && " .join([
-    "TMP=$(mktemp -d || mktemp -d -t bazel-tmp)",
-    "TZ=UTC",
-    "export UTC",
-    "cd $TMP",
-    "mkdir bower_components",
-    "cd bower_components",
-    "unzip %s" % ctx.path(download_name),
-    "cd ..",
-    "find . -exec touch -t 198001010000 '{}' ';'",
-    "zip -Xr %s bower_components" % renamed_name,
-    "cd ..",
-    "rm -rf ${TMP}",
-  ]))
+    _bash(ctx, " && ".join([
+        "TMP=$(mktemp -d || mktemp -d -t bazel-tmp)",
+        "TZ=UTC",
+        "export UTC",
+        "cd $TMP",
+        "mkdir bower_components",
+        "cd bower_components",
+        "unzip %s" % ctx.path(download_name),
+        "cd ..",
+        "find . -exec touch -t 198001010000 '{}' ';'",
+        "zip -Xr %s bower_components" % renamed_name,
+        "cd ..",
+        "rm -rf ${TMP}",
+    ]))
 
-  dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
-  ctx.file(version_name,
-           '"%s":"%s#%s"' % (ctx.name, ctx.attr.package, dep_version))
-  ctx.file(
-    "BUILD",
-    "\n".join([
-      "package(default_visibility=['//visibility:public'])",
-      "filegroup(name = 'zipfile', srcs = ['%s'], )" % download_name,
-      "filegroup(name = 'version_json', srcs = ['%s'], visibility=['//visibility:public'])" % version_name,
-    ]), False)
+    dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
+    ctx.file(
+        version_name,
+        '"%s":"%s#%s"' % (ctx.name, ctx.attr.package, dep_version),
+    )
+    ctx.file(
+        "BUILD",
+        "\n".join([
+            "package(default_visibility=['//visibility:public'])",
+            "filegroup(name = 'zipfile', srcs = ['%s'], )" % download_name,
+            "filegroup(name = 'version_json', srcs = ['%s'], visibility=['//visibility:public'])" % version_name,
+        ]),
+        False,
+    )
 
 def _bash(ctx, cmd):
-  cmd_list = ["bash", "-c", cmd]
-  out = ctx.execute(cmd_list)
-  if out.return_code:
-    fail("failed %s: %s" % (" ".join(cmd_list), out.stderr))
+    cmd_list = ["bash", "-c", cmd]
+    out = ctx.execute(cmd_list)
+    if out.return_code:
+        fail("failed %s: %s" % (cmd_list, out.stderr))
 
 bower_archive = repository_rule(
     _bower_archive,
     attrs = {
-        "_bower_archive": attr.label(default = Label("@bower//:%s" % _npm_tarball("bower"))),
-        "_run_npm": attr.label(default = Label("//tools/js:run_npm_binary.py")),
-        "_download_bower": attr.label(default = Label("//tools/js:download_bower.py")),
-        "sha1": attr.string(mandatory = True),
-        "version": attr.string(mandatory = True),
         "package": attr.string(mandatory = True),
         "semver": attr.string(),
+        "sha1": attr.string(mandatory = True),
+        "version": attr.string(mandatory = True),
+        "_bower_archive": attr.label(default = Label("@bower//:%s" % _npm_tarball("bower"))),
+        "_download_bower": attr.label(default = Label("//tools/js:download_bower.py")),
+        "_run_npm": attr.label(default = Label("//tools/js:run_npm_binary.py")),
     },
 )
 
 def _bower_component_impl(ctx):
-  transitive_zipfiles = depset([ctx.file.zipfile])
-  for d in ctx.attr.deps:
-    transitive_zipfiles += d.transitive_zipfiles
+    transitive_zipfiles = depset(
+        direct = [ctx.file.zipfile],
+        transitive = [d[ComponentInfo].transitive_zipfiles for d in ctx.attr.deps],
+    )
 
-  transitive_licenses = depset()
-  if ctx.file.license:
-    transitive_licenses += depset([ctx.file.license])
+    transitive_licenses = depset(
+        direct = [ctx.file.license],
+        transitive = [d[ComponentInfo].transitive_licenses for d in ctx.attr.deps],
+    )
 
-  for d in ctx.attr.deps:
-    transitive_licenses += d.transitive_licenses
+    transitive_versions = depset(
+        direct = ctx.files.version_json,
+        transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps],
+    )
 
-  transitive_versions = depset(ctx.files.version_json)
-  for d in ctx.attr.deps:
-    transitive_versions += d.transitive_versions
-
-  return struct(
-    transitive_zipfiles=transitive_zipfiles,
-    transitive_versions=transitive_versions,
-    transitive_licenses=transitive_licenses,
-  )
+    return [
+        ComponentInfo(
+            transitive_licenses = transitive_licenses,
+            transitive_versions = transitive_versions,
+            transitive_zipfiles = transitive_zipfiles,
+        ),
+    ]
 
 _common_attrs = {
-    "deps": attr.label_list(providers = [
-        "transitive_zipfiles",
-        "transitive_versions",
-        "transitive_licenses",
-    ]),
+    "deps": attr.label_list(providers = [ComponentInfo]),
 }
 
 def _js_component(ctx):
-  dir = ctx.outputs.zip.path + ".dir"
-  name = ctx.outputs.zip.basename
-  if name.endswith(".zip"):
-    name = name[:-4]
-  dest = "%s/%s" % (dir, name)
-  cmd = " && ".join([
-    "TZ=UTC",
-    "export TZ",
-    "mkdir -p %s" % dest,
-    "cp %s %s/" % (' '.join([s.path for s in ctx.files.srcs]), dest),
-    "cd %s" % dir,
-    "find . -exec touch -t 198001010000 '{}' ';'",
-    "zip -Xqr ../%s *" %  ctx.outputs.zip.basename
-  ])
+    dir = ctx.outputs.zip.path + ".dir"
+    name = ctx.outputs.zip.basename
+    if name.endswith(".zip"):
+        name = name[:-4]
+    dest = "%s/%s" % (dir, name)
+    cmd = " && ".join([
+        "TZ=UTC",
+        "export TZ",
+        "mkdir -p %s" % dest,
+        "cp %s %s/" % (" ".join([s.path for s in ctx.files.srcs]), dest),
+        "cd %s" % dir,
+        "find . -exec touch -t 198001010000 '{}' ';'",
+        "zip -Xqr ../%s *" % ctx.outputs.zip.basename,
+    ])
 
-  ctx.actions.run_shell(
-    inputs = ctx.files.srcs,
-    outputs = [ctx.outputs.zip],
-    command = cmd,
-    mnemonic = "GenBowerZip")
+    ctx.actions.run_shell(
+        inputs = ctx.files.srcs,
+        outputs = [ctx.outputs.zip],
+        command = cmd,
+        mnemonic = "GenBowerZip",
+    )
 
-  licenses = depset()
-  if ctx.file.license:
-    licenses += depset([ctx.file.license])
+    licenses = []
+    if ctx.file.license:
+        licenses.append(ctx.file.license)
 
-  return struct(
-    transitive_zipfiles=list([ctx.outputs.zip]),
-    transitive_versions=depset(),
-    transitive_licenses=licenses)
+    return [
+        ComponentInfo(
+            transitive_licenses = depset(licenses),
+            transitive_versions = depset(),
+            transitive_zipfiles = list([ctx.outputs.zip]),
+        ),
+    ]
 
 js_component = rule(
     _js_component,
@@ -193,175 +209,200 @@
 _bower_component = rule(
     _bower_component_impl,
     attrs = dict(_common_attrs.items() + {
-        "zipfile": attr.label(allow_single_file = [".zip"]),
         "license": attr.label(allow_single_file = True),
-        "version_json": attr.label(allow_files = [".json"]),
 
         # If set, define by hand, and don't regenerate this entry in bower2bazel.
         "seed": attr.bool(default = False),
+        "version_json": attr.label(allow_files = [".json"]),
+        "zipfile": attr.label(allow_single_file = [".zip"]),
     }.items()),
 )
 
 # TODO(hanwen): make license mandatory.
-def bower_component(name, license=None, **kwargs):
-  prefix = "//lib:LICENSE-"
-  if license and not license.startswith(prefix):
-    license = prefix + license
-  _bower_component(
-    name=name,
-    license=license,
-    zipfile="@%s//:zipfile"% name,
-    version_json="@%s//:version_json" % name,
-    **kwargs)
+def bower_component(name, license = None, **kwargs):
+    prefix = "//lib:LICENSE-"
+    if license and not license.startswith(prefix):
+        license = prefix + license
+    _bower_component(
+        name = name,
+        license = license,
+        zipfile = "@%s//:zipfile" % name,
+        version_json = "@%s//:version_json" % name,
+        **kwargs
+    )
 
 def _bower_component_bundle_impl(ctx):
-  """A bunch of bower components zipped up."""
-  zips = depset()
-  for d in ctx.attr.deps:
-    zips += d.transitive_zipfiles
+    """A bunch of bower components zipped up."""
+    zips = depset()
+    for d in ctx.attr.deps:
+        files = d[ComponentInfo].transitive_zipfiles
 
-  versions = depset()
-  for d in ctx.attr.deps:
-    versions += d.transitive_versions
+        # TODO(davido): Make sure the field always contains a depset
+        if type(files) == "list":
+            files = depset(files)
+        zips = depset(transitive = [zips, files])
 
-  licenses = depset()
-  for d in ctx.attr.deps:
-    licenses += d.transitive_versions
+    versions = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
 
-  out_zip = ctx.outputs.zip
-  out_versions = ctx.outputs.version_json
+    licenses = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
 
-  ctx.actions.run_shell(
-    inputs=list(zips),
-    outputs=[out_zip],
-    command=" && ".join([
-      "p=$PWD",
-      "TZ=UTC",
-      "export TZ",
-      "rm -rf %s.dir" % out_zip.path,
-      "mkdir -p %s.dir/bower_components" % out_zip.path,
-      "cd %s.dir/bower_components" % out_zip.path,
-      "for z in %s; do unzip -q $p/$z ; done" % " ".join(sorted([z.path for z in zips])),
-      "cd ..",
-      "find . -exec touch -t 198001010000 '{}' ';'",
-      "zip -Xqr $p/%s bower_components/*" % out_zip.path,
-    ]),
-    mnemonic="BowerCombine")
+    out_zip = ctx.outputs.zip
+    out_versions = ctx.outputs.version_json
 
-  ctx.actions.run_shell(
-    inputs=list(versions),
-    outputs=[out_versions],
-    mnemonic="BowerVersions",
-    command="(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions]), out_versions.path))
+    ctx.actions.run_shell(
+        inputs = zips.to_list(),
+        outputs = [out_zip],
+        command = " && ".join([
+            "p=$PWD",
+            "TZ=UTC",
+            "export TZ",
+            "rm -rf %s.dir" % out_zip.path,
+            "mkdir -p %s.dir/bower_components" % out_zip.path,
+            "cd %s.dir/bower_components" % out_zip.path,
+            "for z in %s; do unzip -q $p/$z ; done" % " ".join(sorted([z.path for z in zips.to_list()])),
+            "cd ..",
+            "find . -exec touch -t 198001010000 '{}' ';'",
+            "zip -Xqr $p/%s bower_components/*" % out_zip.path,
+        ]),
+        mnemonic = "BowerCombine",
+    )
 
-  return struct(
-    transitive_zipfiles=zips,
-    transitive_versions=versions,
-    transitive_licenses=licenses)
+    ctx.actions.run_shell(
+        inputs = versions.to_list(),
+        outputs = [out_versions],
+        mnemonic = "BowerVersions",
+        command = "(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions.to_list()]), out_versions.path),
+    )
+
+    return [
+        ComponentInfo(
+            transitive_licenses = licenses,
+            transitive_versions = versions,
+            transitive_zipfiles = zips,
+        ),
+    ]
 
 bower_component_bundle = rule(
     _bower_component_bundle_impl,
     attrs = _common_attrs,
     outputs = {
-        "zip": "%{name}.zip",
         "version_json": "%{name}-versions.json",
+        "zip": "%{name}.zip",
     },
 )
 
-"""Groups a set of bower components together in a zip file.
+def _bundle_impl(ctx):
+    """Groups a set of .html and .js together in a zip file.
 
-Outputs:
-  NAME-versions.json:
-    a JSON file containing a PKG-NAME => PKG-NAME#VERSION mapping for the
-    transitive dependencies.
-  NAME.zip:
-    a zip file containing the transitive dependencies for this bundle.
-"""
+    Outputs:
+      NAME-versions.json:
+        a JSON file containing a PKG-NAME => PKG-NAME#VERSION mapping for the
+        transitive dependencies.
+    NAME.zip:
+      a zip file containing the transitive dependencies for this bundle.
+    """
 
-def _vulcanize_impl(ctx):
-  # intermediate artifact if split is wanted.
-  if ctx.attr.split:
-    vulcanized = ctx.new_file(
-      ctx.configuration.genfiles_dir, ctx.outputs.html, ".vulcanized.html")
-  else:
-    vulcanized = ctx.outputs.html
-  destdir = ctx.outputs.html.path + ".dir"
-  zips =  [z for d in ctx.attr.deps for z in d.transitive_zipfiles ]
+    # intermediate artifact if split is wanted.
+    if ctx.attr.split:
+        bundled = ctx.actions.declare_file(ctx.outputs.html.path + ".bundled.html")
+    else:
+        bundled = ctx.outputs.html
+    destdir = ctx.outputs.html.path + ".dir"
+    zips = [z for d in ctx.attr.deps for z in d[ComponentInfo].transitive_zipfiles.to_list()]
 
-  hermetic_npm_binary = " ".join([
-    'python',
-    "$p/" + ctx.file._run_npm.path,
-    "$p/" + ctx.file._vulcanize_archive.path,
-    '--inline-scripts',
-    '--inline-css',
-    '--strip-comments',
-    '--out-html', "$p/" + vulcanized.path,
-    ctx.file.app.path
-  ])
+    # We are splitting off the package dir from the app.path such that
+    # we can set the package dir as the root for the bundler, which means
+    # that absolute imports are interpreted relative to that root.
+    pkg_dir = ctx.attr.pkg.lstrip("/")
+    app_path = ctx.file.app.path
+    app_path = app_path[app_path.index(pkg_dir) + len(pkg_dir):]
 
-  pkg_dir = ctx.attr.pkg.lstrip("/")
-  cmd = " && ".join([
-    # unpack dependencies.
-    "export PATH",
-    "p=$PWD",
-    "rm -rf %s" % destdir,
-    "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
-    "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
-      ' '.join([z.path for z in zips]), destdir, pkg_dir),
-    "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
-    "cd %s" % destdir,
-    hermetic_npm_binary,
-  ])
+    hermetic_npm_binary = " ".join([
+        "python",
+        "$p/" + ctx.file._run_npm.path,
+        "$p/" + ctx.file._bundler_archive.path,
+        "--inline-scripts",
+        "--inline-css",
+        "--strip-comments",
+        "--out-file",
+        "$p/" + bundled.path,
+        "--root",
+        pkg_dir,
+        app_path,
+    ])
 
-  # Node/NPM is not (yet) hermeticized, so we have to get the binary
-  # from the environment, and it may be under $HOME, so we can't run
-  # in the sandbox.
-  node_tweaks = dict(
-    use_default_shell_env = True,
-    execution_requirements = {"local": "1"},
-  )
-  ctx.actions.run_shell(
-    mnemonic = "Vulcanize",
-    inputs = [ctx.file._run_npm, ctx.file.app,
-              ctx.file._vulcanize_archive
-    ] + list(zips) + ctx.files.srcs,
-    outputs = [vulcanized],
-    command = cmd,
-    **node_tweaks)
-
-  if ctx.attr.split:
-    hermetic_npm_command = "export PATH && " + " ".join([
-      'python',
-      ctx.file._run_npm.path,
-      ctx.file._crisper_archive.path,
-      "--always-write-script",
-      "--source", vulcanized.path,
-      "--html", ctx.outputs.html.path,
-      "--js", ctx.outputs.js.path])
-
-    ctx.actions.run_shell(
-      mnemonic = "Crisper",
-      inputs = [ctx.file._run_npm, ctx.file.app,
-                ctx.file._crisper_archive, vulcanized],
-      outputs = [ctx.outputs.js, ctx.outputs.html],
-      command = hermetic_npm_command,
-      **node_tweaks)
-
-def _vulcanize_output_func(name, split):
-  _ignore = [name]  # unused.
-  out = {"html": "%{name}.html"}
-  if split:
-    out["js"] = "%{name}.js"
-  return out
-
-_vulcanize_rule = rule(
-    _vulcanize_impl,
-    attrs = {
-        "deps": attr.label_list(providers = ["transitive_zipfiles"]),
-        "app": attr.label(
-            mandatory = True,
-            allow_single_file = True,
+    cmd = " && ".join([
+        # unpack dependencies.
+        "export PATH",
+        "p=$PWD",
+        "rm -rf %s" % destdir,
+        "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
+        "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
+            " ".join([z.path for z in zips]),
+            destdir,
+            pkg_dir,
         ),
+        "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
+        "cd %s" % destdir,
+        hermetic_npm_binary,
+    ])
+
+    # Node/NPM is not (yet) hermeticized, so we have to get the binary
+    # from the environment, and it may be under $HOME, so we can't run
+    # in the sandbox.
+    node_tweaks = dict(
+        execution_requirements = {"local": "1"},
+        use_default_shell_env = True,
+    )
+    ctx.actions.run_shell(
+        mnemonic = "Bundle",
+        inputs = [
+            ctx.file._run_npm,
+            ctx.file.app,
+            ctx.file._bundler_archive,
+        ] + list(zips) + ctx.files.srcs,
+        outputs = [bundled],
+        command = cmd,
+        **node_tweaks
+    )
+
+    if ctx.attr.split:
+        hermetic_npm_command = "export PATH && " + " ".join([
+            "python",
+            ctx.file._run_npm.path,
+            ctx.file._crisper_archive.path,
+            "--always-write-script",
+            "--source",
+            bundled.path,
+            "--html",
+            ctx.outputs.html.path,
+            "--js",
+            ctx.outputs.js.path,
+        ])
+
+        ctx.actions.run_shell(
+            mnemonic = "Crisper",
+            inputs = [
+                ctx.file._run_npm,
+                ctx.file.app,
+                ctx.file._crisper_archive,
+                bundled,
+            ],
+            outputs = [ctx.outputs.js, ctx.outputs.html],
+            command = hermetic_npm_command,
+            **node_tweaks
+        )
+
+def _bundle_output_func(name, split):
+    _ignore = [name]  # unused.
+    out = {"html": "%{name}.html"}
+    if split:
+        out["js"] = "%{name}.js"
+    return out
+
+_bundle_rule = rule(
+    _bundle_impl,
+    attrs = {
         "srcs": attr.label_list(allow_files = [
             ".js",
             ".html",
@@ -369,28 +410,132 @@
             ".css",
             ".ico",
         ]),
-        "pkg": attr.string(mandatory = True),
-        "split": attr.bool(default = True),
-        "_run_npm": attr.label(
-            default = Label("//tools/js:run_npm_binary.py"),
+        "app": attr.label(
+            mandatory = True,
             allow_single_file = True,
         ),
-        "_vulcanize_archive": attr.label(
-            default = Label("@vulcanize//:%s" % _npm_tarball("vulcanize")),
+        "pkg": attr.string(mandatory = True),
+        "split": attr.bool(default = True),
+        "deps": attr.label_list(providers = [ComponentInfo]),
+        "_bundler_archive": attr.label(
+            default = Label("@polymer-bundler//:%s" % _npm_tarball("polymer-bundler")),
             allow_single_file = True,
         ),
         "_crisper_archive": attr.label(
             default = Label("@crisper//:%s" % _npm_tarball("crisper")),
             allow_single_file = True,
         ),
+        "_run_npm": attr.label(
+            default = Label("//tools/js:run_npm_binary.py"),
+            allow_single_file = True,
+        ),
     },
-    outputs = _vulcanize_output_func,
+    outputs = _bundle_output_func,
 )
 
-def vulcanize(*args, **kwargs):
-  """Vulcanize runs vulcanize and (optionally) crisper on a set of sources."""
-  _vulcanize_rule(*args, pkg=PACKAGE_NAME, **kwargs)
+def bundle_assets(*args, **kwargs):
+    """Combine html, js, css files and optionally split into js and html bundles."""
+    _bundle_rule(pkg = native.package_name(), *args, **kwargs)
 
-def polygerrit_plugin(*args, **kwargs):
-  """Bundles plugin dependencies for deployment."""
-  _vulcanize_rule(*args, pkg=PACKAGE_NAME, **kwargs)
+def polygerrit_plugin(name, app, srcs = [], deps = [], externs = [], assets = None, plugin_name = None, **kwargs):
+    """Bundles plugin dependencies for deployment.
+
+    This rule bundles all Polymer elements and JS dependencies into .html and .js files.
+    Run-time dependencies (e.g. JS libraries loaded after plugin starts) should be provided using "assets" property.
+    Output of this rule is a FileSet with "${name}_fs", with deploy artifacts in "plugins/${name}/static".
+
+    Args:
+      name: String, rule name.
+      app: String, the main or root source file.
+      externs: Fileset, external definitions that should not be bundled.
+      assets: Fileset, additional files to be used by plugin in runtime, exported to "plugins/${name}/static".
+      plugin_name: String, plugin name. ${name} is used if not provided.
+    """
+    if not plugin_name:
+        plugin_name = name
+
+    html_plugin = app.endswith(".html")
+    srcs = srcs if app in srcs else srcs + [app]
+
+    if html_plugin:
+        # Combines all .js and .html files into foo_combined.js and foo_combined.html
+        _bundle_rule(
+            name = name + "_combined",
+            app = app,
+            srcs = srcs,
+            deps = deps,
+            pkg = native.package_name(),
+            **kwargs
+        )
+        js_srcs = [name + "_combined.js"]
+    else:
+        js_srcs = srcs
+
+    closure_js_library(
+        name = name + "_closure_lib",
+        srcs = js_srcs + externs,
+        convention = "GOOGLE",
+        no_closure_library = True,
+        deps = [
+            "//lib/polymer_externs:polymer_closure",
+            "//polygerrit-ui/app/externs:plugin",
+        ],
+    )
+
+    closure_js_binary(
+        name = name + "_bin",
+        compilation_level = "WHITESPACE_ONLY",
+        defs = [
+            "--polymer_version=1",
+            "--language_out=ECMASCRIPT6",
+            "--rewrite_polyfills=false",
+        ],
+        deps = [
+            name + "_closure_lib",
+        ],
+    )
+
+    if html_plugin:
+        native.genrule(
+            name = name + "_rename_html",
+            srcs = [name + "_combined.html"],
+            outs = [plugin_name + ".html"],
+            cmd = "sed 's/<script src=\"" + name + "_combined.js\"/<script src=\"" + plugin_name + ".js\"/g' $(SRCS) > $(OUTS)",
+            output_to_bindir = True,
+        )
+
+    native.genrule(
+        name = name + "_rename_js",
+        srcs = [name + "_bin.js"],
+        outs = [plugin_name + ".js"],
+        cmd = "cp $< $@",
+        output_to_bindir = True,
+    )
+
+    if html_plugin:
+        static_files = [plugin_name + ".js", plugin_name + ".html"]
+    else:
+        static_files = [plugin_name + ".js"]
+
+    if assets:
+        nested, direct = [], []
+        for x in assets:
+            target = nested if "/" in x else direct
+            target.append(x)
+
+        static_files += direct
+
+        if nested:
+            native.genrule(
+                name = name + "_copy_assets",
+                srcs = assets,
+                outs = [f.split("/")[-1] for f in nested],
+                cmd = "cp $(SRCS) $(@D)",
+                output_to_bindir = True,
+            )
+            static_files += [":" + name + "_copy_assets"]
+
+    native.filegroup(
+        name = name,
+        srcs = static_files,
+    )
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 19974a7..1a30997 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -28,14 +28,14 @@
 
 _PREFIXES = ("org", "com", "edu")
 
-def _SafeIndex(l, val):
-    for i, v in enumerate(l):
+def _SafeIndex(j, val):
+    for i, v in enumerate(j):
         if val == v:
             return i
     return -1
 
 def _AsClassName(fname):
-    fname = [x.path for x in fname.files][0]
+    fname = [x.path for x in fname.files.to_list()][0]
     toks = fname[:-5].split("/")
     findex = -1
     for s in _PREFIXES:
@@ -43,15 +43,17 @@
         if findex != -1:
             break
     if findex == -1:
-        fail("%s does not contain any of %s",
-                         fname, _PREFIXES)
+        fail("%s does not contain any of %s" % (fname, _PREFIXES))
     return ".".join(toks[findex:]) + ".class"
 
 def _impl(ctx):
     classes = ",".join(
-        [_AsClassName(x) for x in ctx.attr.srcs])
-    ctx.file_action(output=ctx.outputs.out, content=_OUTPUT % (
-            classes, ctx.attr.outname))
+        [_AsClassName(x) for x in ctx.attr.srcs],
+    )
+    ctx.actions.write(output = ctx.outputs.out, content = _OUTPUT % (
+        classes,
+        ctx.attr.outname,
+    ))
 
 _GenSuite = rule(
     attrs = {
@@ -62,12 +64,29 @@
     implementation = _impl,
 )
 
+POST_JDK8_OPTS = [
+    # Enforce JDK 8 compatibility on Java 9, see
+    # https://docs.oracle.com/javase/9/intl/internationalization-enhancements-jdk-9.htm#JSINT-GUID-AF5AECA7-07C1-4E7D-BC10-BC7E73DC6C7F
+    "-Djava.locale.providers=COMPAT,CLDR,SPI",
+    "--add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED",
+]
+
 def junit_tests(name, srcs, **kwargs):
-    s_name = name + "TestSuite"
-    _GenSuite(name = s_name,
-              srcs = srcs,
-              outname = s_name)
-    native.java_test(name = name,
-                     test_class = s_name,
-                     srcs = srcs + [":"+s_name],
-                     **kwargs)
+    s_name = name.replace("-", "_") + "TestSuite"
+    _GenSuite(
+        name = s_name,
+        srcs = srcs,
+        outname = s_name,
+    )
+    jvm_flags = kwargs.get("jvm_flags", [])
+    jvm_flags = jvm_flags + select({
+        "//:java9": POST_JDK8_OPTS,
+        "//:java_next": POST_JDK8_OPTS,
+        "//conditions:default": [],
+    })
+    native.java_test(
+        name = name,
+        test_class = s_name,
+        srcs = srcs + [":" + s_name],
+        **dict(kwargs, jvm_flags = jvm_flags)
+    )
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 476ccb9..2779130 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
 
@@ -54,6 +54,8 @@
     # We don't want any blank line before "= Gerrit Code Review - Licenses"
     print("""= Gerrit Code Review - Licenses
 
+// DO NOT EDIT - GENERATED AUTOMATICALLY.
+
 Gerrit open source software is licensed under the <<Apache2_0,Apache
 License 2.0>>.  Executable distributions also include other software
 components that are provided under additional licenses.
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
index 38dfbe5..5a6bf7f 100644
--- a/tools/bzl/license.bzl
+++ b/tools/bzl/license.bzl
@@ -1,57 +1,57 @@
 def normalize_target_name(target):
-  return target.replace("//", "").replace("/", "__").replace(":", "___")
+    return target.replace("//", "").replace("/", "__").replace(":", "___")
 
 def license_map(name, targets = [], opts = [], **kwargs):
-  """Generate XML for all targets that depend directly on a LICENSE file"""
-  xmls = []
-  tools = [ "//tools/bzl:license-map.py", "//lib:all-licenses" ]
-  for target in targets:
-    subname = name + "_" + normalize_target_name(target) + ".xml"
-    xmls.append("$(location :%s)" % subname)
-    tools.append(subname)
-    native.genquery(
-      name = subname,
-      scope = [ target ],
+    """Generate XML for all targets that depend directly on a LICENSE file"""
+    xmls = []
+    tools = ["//tools/bzl:license-map.py", "//lib:all-licenses"]
+    for target in targets:
+        subname = name + "_" + normalize_target_name(target) + ".xml"
+        xmls.append("$(location :%s)" % subname)
+        tools.append(subname)
+        native.genquery(
+            name = subname,
+            scope = [target],
 
-      # Find everything that depends on a license file, but remove
-      # the license files themselves from this list.
-      expression = 'rdeps(%s, filter("//lib:LICENSE.*", deps(%s)),1) - filter("//lib:LICENSE.*", deps(%s))' % (target, target, target),
+            # Find everything that depends on a license file, but remove
+            # the license files themselves from this list.
+            expression = 'rdeps(%s, filter("//lib:LICENSE.*", deps(%s)),1) - filter("//lib:LICENSE.*", deps(%s))' % (target, target, target),
 
-      # We are interested in the edges of the graph ({java_library,
-      # license-file} tuples).  'query' provides this in the XML output.
-      opts = [ "--output=xml", ],
+            # We are interested in the edges of the graph ({java_library,
+            # license-file} tuples).  'query' provides this in the XML output.
+            opts = ["--output=xml"],
+        )
+
+    # post process the XML into our favorite format.
+    native.genrule(
+        name = "gen_license_txt_" + name,
+        cmd = "python $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
+        outs = [name + ".gen.txt"],
+        tools = tools,
+        **kwargs
     )
 
-  # post process the XML into our favorite format.
-  native.genrule(
-    name = "gen_license_txt_" + name,
-    cmd = "python $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
-    outs = [ name + ".txt" ],
-    tools = tools,
-    **kwargs
-  )
-
 def license_test(name, target):
-  """Make sure a target doesn't depend on DO_NOT_DISTRIBUTE license"""
-  txt = name + "-forbidden.txt"
+    """Make sure a target doesn't depend on DO_NOT_DISTRIBUTE license"""
+    txt = name + "-forbidden.txt"
 
-  # fully qualify target name.
-  if target[0] not in ":/":
-    target = ":" + target
-  if target[0] != "/":
-    target = "//" + PACKAGE_NAME + target
+    # fully qualify target name.
+    if target[0] not in ":/":
+        target = ":" + target
+    if target[0] != "/":
+        target = "//" + native.package_name() + target
 
-  forbidden = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
-  native.genquery(
-    name = txt,
-    scope = [ target, forbidden ],
-    # Find everything that depends on a license file, but remove
-    # the license files themselves from this list.
-    expression = 'rdeps(%s, "%s", 1) - rdeps(%s, "%s", 0)' % (target, forbidden, target, forbidden),
-  )
-  native.sh_test(
-    name = name,
-    srcs = [ "//tools/bzl:test_license.sh" ],
-    args  = [ "$(location :%s)" % txt ],
-    data = [ txt ],
-  )
+    forbidden = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
+    native.genquery(
+        name = txt,
+        scope = [target, forbidden],
+        # Find everything that depends on a license file, but remove
+        # the license files themselves from this list.
+        expression = 'rdeps(%s, "%s", 1) - rdeps(%s, "%s", 0)' % (target, forbidden, target, forbidden),
+    )
+    native.sh_test(
+        name = name,
+        srcs = ["//tools/bzl:test_license.sh"],
+        args = ["$(location :%s)" % txt],
+        data = [txt],
+    )
diff --git a/tools/bzl/maven.bzl b/tools/bzl/maven.bzl
index c255c0c..71aa91c 100644
--- a/tools/bzl/maven.bzl
+++ b/tools/bzl/maven.bzl
@@ -15,18 +15,18 @@
 # Merge maven files
 
 def cmd(jars):
-  return ('$(location //tools:merge_jars) $@ '
-          + ' '.join(['$(location %s)' % j for j in jars]))
+    return ("$(location //tools:merge_jars) $@ " +
+            " ".join(["$(location %s)" % j for j in jars]))
 
 def merge_maven_jars(name, srcs, **kwargs):
-  native.genrule(
-    name = '%s__merged_bin' % name,
-    cmd = cmd(srcs),
-    tools = srcs + ['//tools:merge_jars'],
-    outs = ['%s__merged.jar' % name],
-  )
-  native.java_import(
-    name = name,
-    jars = [':%s__merged_bin' % name],
-    **kwargs
-  )
+    native.genrule(
+        name = "%s__merged_bin" % name,
+        cmd = cmd(srcs),
+        tools = srcs + ["//tools:merge_jars"],
+        outs = ["%s__merged.jar" % name],
+    )
+    native.java_import(
+        name = name,
+        jars = [":%s__merged_bin" % name],
+        **kwargs
+    )
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index 55bfae1..fa5cbd1 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -6,70 +6,93 @@
 
 MAVEN_LOCAL = "MAVEN_LOCAL:"
 
+ECLIPSE = "ECLIPSE:"
+
+MAVEN_SNAPSHOT = "https://oss.sonatype.org/content/repositories/snapshots"
+
+SNAPSHOT = "-SNAPSHOT-"
+
 def _maven_release(ctx, parts):
-  """induce jar and url name from maven coordinates."""
-  if len(parts) not in [3, 4]:
-    fail('%s:\nexpected id="groupId:artifactId:version[:classifier]"'
-         % ctx.attr.artifact)
-  if len(parts) == 4:
-    group, artifact, version, classifier = parts
-    file_version = version + '-' + classifier
-  else:
-    group, artifact, version = parts
-    file_version = version
+    """induce jar and url name from maven coordinates."""
+    if len(parts) not in [3, 4]:
+        fail('%s:\nexpected id="groupId:artifactId:version[:classifier]"' %
+             ctx.attr.artifact)
+    if len(parts) == 4:
+        group, artifact, version, classifier = parts
+        file_version = version + "-" + classifier
+    else:
+        group, artifact, version = parts
+        file_version = version
 
-  jar = artifact.lower() + '-' + file_version
-  url = '/'.join([
-    ctx.attr.repository,
-    group.replace('.', '/'),
-    artifact,
-    version,
-    artifact + '-' + file_version])
+    repository = ctx.attr.repository
 
-  return jar, url
+    if "-SNAPSHOT-" in version:
+        start = version.index(SNAPSHOT)
+        end = start + len(SNAPSHOT) - 1
+
+        # file version without snapshot constant, but with post snapshot suffix
+        file_version = version[:start] + version[end:]
+
+        # version without post snapshot suffix
+        version = version[:end]
+
+        # overwrite the repository with Maven snapshot repository
+        repository = MAVEN_SNAPSHOT
+
+    jar = artifact.lower() + "-" + file_version
+
+    url = "/".join([
+        repository,
+        group.replace(".", "/"),
+        artifact,
+        version,
+        artifact + "-" + file_version,
+    ])
+
+    return jar, url
 
 # Creates a struct containing the different parts of an artifact's FQN
 def _create_coordinates(fully_qualified_name):
-  parts = fully_qualified_name.split(":")
-  packaging = None
-  classifier = None
+    parts = fully_qualified_name.split(":")
+    packaging = None
+    classifier = None
 
-  if len(parts) == 3:
-    group_id, artifact_id, version = parts
-  elif len(parts) == 4:
-    group_id, artifact_id, version, packaging = parts
-  elif len(parts) == 5:
-    group_id, artifact_id, version, packaging, classifier = parts
-  else:
-    fail("Invalid fully qualified name for artifact: %s" % fully_qualified_name)
+    if len(parts) == 3:
+        group_id, artifact_id, version = parts
+    elif len(parts) == 4:
+        group_id, artifact_id, version, packaging = parts
+    elif len(parts) == 5:
+        group_id, artifact_id, version, packaging, classifier = parts
+    else:
+        fail("Invalid fully qualified name for artifact: %s" % fully_qualified_name)
 
-  return struct(
-      fully_qualified_name = fully_qualified_name,
-      group_id = group_id,
-      artifact_id = artifact_id,
-      packaging = packaging,
-      classifier = classifier,
-      version = version,
-  )
+    return struct(
+        fully_qualified_name = fully_qualified_name,
+        group_id = group_id,
+        artifact_id = artifact_id,
+        packaging = packaging,
+        classifier = classifier,
+        version = version,
+    )
 
 def _format_deps(attr, deps):
-  formatted_deps = ""
-  if deps:
-    if len(deps) == 1:
-      formatted_deps += "%s = [\'%s\']," % (attr, deps[0])
-    else:
-      formatted_deps += "%s = [\n" % attr
-      for dep in deps:
-        formatted_deps += "        \'%s\',\n" % dep
-      formatted_deps += "    ],"
-  return formatted_deps
+    formatted_deps = ""
+    if deps:
+        if len(deps) == 1:
+            formatted_deps += "%s = [\'%s\']," % (attr, deps[0])
+        else:
+            formatted_deps += "%s = [\n" % attr
+            for dep in deps:
+                formatted_deps += "        \'%s\',\n" % dep
+            formatted_deps += "    ],"
+    return formatted_deps
 
 def _generate_build_files(ctx, binjar, srcjar):
-  header = "# DO NOT EDIT: automatically generated BUILD file for maven_jar rule %s" % ctx.name
-  srcjar_attr = ""
-  if srcjar:
-    srcjar_attr = 'srcjar = "%s",' % srcjar
-  contents = """
+    header = "# DO NOT EDIT: automatically generated BUILD file for maven_jar rule %s" % ctx.name
+    srcjar_attr = ""
+    if srcjar:
+        srcjar_attr = 'srcjar = "%s",' % srcjar
+    contents = """
 {header}
 package(default_visibility = ['//visibility:public'])
 java_import(
@@ -86,22 +109,24 @@
     {deps}
     {exports}
 )
-\n""".format(srcjar_attr = srcjar_attr,
-             header = header,
-             binjar = binjar,
-             deps = _format_deps("deps", ctx.attr.deps),
-             exports = _format_deps("exports", ctx.attr.exports))
-  if srcjar:
-    contents += """
+\n""".format(
+        srcjar_attr = srcjar_attr,
+        header = header,
+        binjar = binjar,
+        deps = _format_deps("deps", ctx.attr.deps),
+        exports = _format_deps("exports", ctx.attr.exports),
+    )
+    if srcjar:
+        contents += """
 java_import(
     name = 'src',
     jars = ['{srcjar}'],
 )
 """.format(srcjar = srcjar)
-  ctx.file('%s/BUILD' % ctx.path("jar"), contents, False)
+    ctx.file("%s/BUILD" % ctx.path("jar"), contents, False)
 
-  # Compatibility layer for java_import_external from rules_closure
-  contents = """
+    # Compatibility layer for java_import_external from rules_closure
+    contents = """
 {header}
 package(default_visibility = ['//visibility:public'])
 
@@ -110,65 +135,64 @@
     actual = "@{rule_name}//jar",
 )
 \n""".format(rule_name = ctx.name, header = header)
-  ctx.file("BUILD", contents, False)
+    ctx.file("BUILD", contents, False)
 
 def _maven_jar_impl(ctx):
-  """rule to download a Maven archive."""
-  coordinates = _create_coordinates(ctx.attr.artifact)
+    """rule to download a Maven archive."""
+    coordinates = _create_coordinates(ctx.attr.artifact)
 
-  name = ctx.name
-  sha1 = ctx.attr.sha1
+    name = ctx.name
+    sha1 = ctx.attr.sha1
 
-  parts = ctx.attr.artifact.split(':')
-  # TODO(davido): Only releases for now, implement handling snapshots
-  jar, url = _maven_release(ctx, parts)
+    parts = ctx.attr.artifact.split(":")
 
-  binjar = jar + '.jar'
-  binjar_path = ctx.path('/'.join(['jar', binjar]))
-  binurl = url + '.jar'
+    # TODO(davido): Only releases for now, implement handling snapshots
+    jar, url = _maven_release(ctx, parts)
 
-  python = ctx.which("python")
-  script = ctx.path(ctx.attr._download_script)
+    binjar = jar + ".jar"
+    binjar_path = ctx.path("/".join(["jar", binjar]))
+    binurl = url + ".jar"
 
-  args = [python, script, "-o", binjar_path, "-u", binurl]
-  if ctx.attr.sha1:
-    args.extend(["-v", sha1])
-  if ctx.attr.unsign:
-    args.append('--unsign')
-  for x in ctx.attr.exclude:
-    args.extend(['-x', x])
+    python = ctx.which("python")
+    script = ctx.path(ctx.attr._download_script)
 
-  out = ctx.execute(args)
+    args = [python, script, "-o", binjar_path, "-u", binurl]
+    if ctx.attr.sha1:
+        args.extend(["-v", sha1])
+    for x in ctx.attr.exclude:
+        args.extend(["-x", x])
 
-  if out.return_code:
-    fail("failed %s: %s" % (' '.join(args), out.stderr))
-
-  srcjar = None
-  if ctx.attr.src_sha1 or ctx.attr.attach_source:
-    srcjar = jar + '-src.jar'
-    srcurl = url + '-sources.jar'
-    srcjar_path = ctx.path('jar/' + srcjar)
-    args = [python, script, "-o", srcjar_path, "-u", srcurl]
-    if ctx.attr.src_sha1:
-      args.extend(['-v', ctx.attr.src_sha1])
     out = ctx.execute(args)
-    if out.return_code:
-      fail("failed %s: %s" % (args, out.stderr))
 
-  _generate_build_files(ctx, binjar, srcjar)
+    if out.return_code:
+        fail("failed %s: %s" % (args, out.stderr))
+
+    srcjar = None
+    if ctx.attr.src_sha1 or ctx.attr.attach_source:
+        srcjar = jar + "-src.jar"
+        srcurl = url + "-sources.jar"
+        srcjar_path = ctx.path("jar/" + srcjar)
+        args = [python, script, "-o", srcjar_path, "-u", srcurl]
+        if ctx.attr.src_sha1:
+            args.extend(["-v", ctx.attr.src_sha1])
+        out = ctx.execute(args)
+        if out.return_code:
+            fail("failed %s: %s" % (args, out.stderr))
+
+    _generate_build_files(ctx, binjar, srcjar)
 
 maven_jar = repository_rule(
     attrs = {
         "artifact": attr.string(mandatory = True),
+        "attach_source": attr.bool(default = True),
+        "exclude": attr.string_list(),
+        "repository": attr.string(default = MAVEN_CENTRAL),
         "sha1": attr.string(),
         "src_sha1": attr.string(),
-        "_download_script": attr.label(default = Label("//tools:download_file.py")),
-        "repository": attr.string(default = MAVEN_CENTRAL),
-        "attach_source": attr.bool(default = True),
         "unsign": attr.bool(default = False),
-        "deps": attr.string_list(),
         "exports": attr.string_list(),
-        "exclude": attr.string_list(),
+        "deps": attr.string_list(),
+        "_download_script": attr.label(default = Label("//tools:download_file.py")),
     },
     local = True,
     implementation = _maven_jar_impl,
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 46a4f9b..d451c66 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -14,12 +14,11 @@
 
 # War packaging.
 
-jar_filetype = FileType([".jar"])
+jar_filetype = [".jar"]
 
 LIBS = [
     "//java/com/google/gerrit/common:version",
     "//java/com/google/gerrit/httpd/init",
-    "//lib:postgresql",
     "//lib/bouncycastle:bcpkix",
     "//lib/bouncycastle:bcprov",
     "//lib/bouncycastle:bcpg",
@@ -32,93 +31,97 @@
 ]
 
 def _add_context(in_file, output):
-  input_path = in_file.path
-  return [
-    'unzip -qd %s %s' % (output, input_path)
-  ]
+    input_path = in_file.path
+    return [
+        "unzip -qd %s %s" % (output, input_path),
+    ]
 
 def _add_file(in_file, output):
-  output_path = output
-  input_path = in_file.path
-  short_path = in_file.short_path
-  n = in_file.basename
+    output_path = output
+    input_path = in_file.path
+    short_path = in_file.short_path
+    n = in_file.basename
 
-  if short_path.startswith('gerrit-'):
-    n = short_path.split('/')[0] + '-' + n
-  elif short_path.startswith('java/'):
-    n = short_path[5:].replace('/', '_')
-  output_path += n
-  return [
-    'test -L %s || ln -s $(pwd)/%s %s' % (output_path, input_path, output_path)
-  ]
+    if short_path.startswith("gerrit-"):
+        n = short_path.split("/")[0] + "-" + n
+    elif short_path.startswith("java/"):
+        n = short_path[5:].replace("/", "_")
+    output_path += n
+    return [
+        "test -L %s || ln -s $(pwd)/%s %s" % (output_path, input_path, output_path),
+    ]
 
 def _make_war(input_dir, output):
-  return '(%s)' % ' && '.join([
-    'root=$(pwd)',
-    'TZ=UTC',
-    'export TZ',
-    'cd %s' % input_dir,
-    "find . -exec touch -t 198001010000 '{}' ';' 2> /dev/null",
-    'zip -X -9qr ${root}/%s .' % (output.path),
-  ])
+    return "(%s)" % " && ".join([
+        "root=$(pwd)",
+        "TZ=UTC",
+        "export TZ",
+        "cd %s" % input_dir,
+        "find . -exec touch -t 198001010000 '{}' ';' 2> /dev/null",
+        "zip -X -9qr ${root}/%s ." % (output.path),
+    ])
 
 def _war_impl(ctx):
-  war = ctx.outputs.war
-  build_output = war.path + '.build_output'
-  inputs = []
+    war = ctx.outputs.war
+    build_output = war.path + ".build_output"
+    inputs = []
 
-  # Create war layout
-  cmd = [
-    'set -e;rm -rf ' + build_output,
-    'mkdir -p ' + build_output,
-    'mkdir -p %s/WEB-INF/lib' % build_output,
-    'mkdir -p %s/WEB-INF/pgm-lib' % build_output,
-  ]
+    # Create war layout
+    cmd = [
+        "set -e;rm -rf " + build_output,
+        "mkdir -p " + build_output,
+        "mkdir -p %s/WEB-INF/lib" % build_output,
+        "mkdir -p %s/WEB-INF/pgm-lib" % build_output,
+    ]
 
-  # Add lib
-  transitive_lib_deps = depset()
-  for l in ctx.attr.libs:
-    if hasattr(l, 'java'):
-      transitive_lib_deps += l.java.transitive_runtime_deps
-    elif hasattr(l, 'files'):
-      transitive_lib_deps += l.files
+    # Add lib
+    transitive_libs = []
+    for j in ctx.attr.libs:
+        if hasattr(j, "java"):
+            transitive_libs.append(j.java.transitive_runtime_deps)
+        elif hasattr(j, "files"):
+            transitive_libs.append(j.files)
 
-  for dep in transitive_lib_deps:
-    cmd += _add_file(dep, build_output + '/WEB-INF/lib/')
-    inputs.append(dep)
+    transitive_lib_deps = depset(transitive = transitive_libs)
+    for dep in transitive_lib_deps.to_list():
+        cmd += _add_file(dep, build_output + "/WEB-INF/lib/")
+        inputs.append(dep)
 
-  # Add pgm lib
-  transitive_pgmlib_deps = depset()
-  for l in ctx.attr.pgmlibs:
-    transitive_pgmlib_deps += l.java.transitive_runtime_deps
+    # Add pgm lib
+    transitive_pgmlibs = []
+    for j in ctx.attr.pgmlibs:
+        transitive_pgmlibs.append(j.java.transitive_runtime_deps)
 
-  for dep in transitive_pgmlib_deps:
-    if dep not in inputs:
-      cmd += _add_file(dep, build_output + '/WEB-INF/pgm-lib/')
-      inputs.append(dep)
+    transitive_pgmlib_deps = depset(transitive = transitive_pgmlibs)
+    for dep in transitive_pgmlib_deps.to_list():
+        if dep not in inputs:
+            cmd += _add_file(dep, build_output + "/WEB-INF/pgm-lib/")
+            inputs.append(dep)
 
-  # Add context
-  transitive_context_deps = depset()
-  if ctx.attr.context:
-    for jar in ctx.attr.context:
-      if hasattr(jar, 'java'):
-        transitive_context_deps += jar.java.transitive_runtime_deps
-      elif hasattr(jar, 'files'):
-        transitive_context_deps += jar.files
-  for dep in transitive_context_deps:
-    cmd += _add_context(dep, build_output)
-    inputs.append(dep)
+    # Add context
+    transitive_context_libs = []
+    if ctx.attr.context:
+        for jar in ctx.attr.context:
+            if hasattr(jar, "java"):
+                transitive_context_libs.append(jar.java.transitive_runtime_deps)
+            elif hasattr(jar, "files"):
+                transitive_context_libs.append(jar.files)
 
-  # Add zip war
-  cmd.append(_make_war(build_output, war))
+    transitive_context_deps = depset(transitive = transitive_context_libs)
+    for dep in transitive_context_deps.to_list():
+        cmd += _add_context(dep, build_output)
+        inputs.append(dep)
 
-  ctx.actions.run_shell(
-    inputs = inputs,
-    outputs = [war],
-    mnemonic = 'WAR',
-    command = '\n'.join(cmd),
-    use_default_shell_env = True,
-  )
+    # Add zip war
+    cmd.append(_make_war(build_output, war))
+
+    ctx.actions.run_shell(
+        inputs = inputs,
+        outputs = [war],
+        mnemonic = "WAR",
+        command = "\n".join(cmd),
+        use_default_shell_env = True,
+    )
 
 # context: go to the root directory
 # libs: go to the WEB-INF/lib directory
@@ -133,25 +136,23 @@
     implementation = _war_impl,
 )
 
-def pkg_war(name, ui = 'ui_optdbg', context = [], doc = False, **kwargs):
-  doc_ctx = []
-  doc_lib = []
-  ui_deps = []
-  if ui == 'polygerrit' or ui == 'ui_optdbg' or ui == 'ui_optdbg_r':
-    ui_deps.append('//polygerrit-ui/app:polygerrit_ui')
-  if ui and ui != 'polygerrit':
-    ui_deps.append('//gerrit-gwtui:%s' % ui)
-  if doc:
-    doc_ctx.append('//Documentation:html')
-    doc_lib.append('//Documentation:index')
+def pkg_war(name, ui = "polygerrit", context = [], doc = False, **kwargs):
+    doc_ctx = []
+    doc_lib = []
+    ui_deps = []
+    if ui == "polygerrit":
+        ui_deps.append("//polygerrit-ui/app:polygerrit_ui")
+    if doc:
+        doc_ctx.append("//Documentation:html")
+        doc_lib.append("//Documentation:index")
 
-  _pkg_war(
-    name = name,
-    libs = LIBS + doc_lib,
-    pgmlibs = PGMLIBS,
-    context = doc_ctx + context + ui_deps + [
-      '//java:gerrit-main-class_deploy.jar',
-      '//webapp:assets',
-    ],
-    **kwargs
-  )
+    _pkg_war(
+        name = name,
+        libs = LIBS + doc_lib,
+        pgmlibs = PGMLIBS,
+        context = doc_ctx + context + ui_deps + [
+            "//java:gerrit-main-class_deploy.jar",
+            "//webapp:assets",
+        ],
+        **kwargs
+    )
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 23f88df..066fe43 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -1,13 +1,4 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load(
-    "//tools/bzl:gwt.bzl",
-    "GWT_PLUGIN_DEPS",
-    "GWT_PLUGIN_DEPS_NEVERLINK",
-    "GWT_TRANSITIVE_DEPS",
-    "GWT_COMPILER_ARGS",
-    "GWT_JVM_ARGS",
-    "gwt_binary",
-)
 
 PLUGIN_DEPS = ["//plugins:plugin-lib"]
 
@@ -21,82 +12,53 @@
 ]
 
 def gerrit_plugin(
-    name,
-    deps = [],
-    provided_deps = [],
-    srcs = [],
-    gwt_module = [],
-    resources = [],
-    manifest_entries = [],
-    dir_name = None,
-    target_suffix = "",
-    **kwargs):
-  native.java_library(
-    name = name + '__plugin',
-    srcs = srcs,
-    resources = resources,
-    deps = provided_deps + deps + GWT_PLUGIN_DEPS_NEVERLINK + PLUGIN_DEPS_NEVERLINK,
-    visibility = ['//visibility:public'],
-    **kwargs
-  )
-
-  static_jars = []
-  if gwt_module:
-    static_jars = [':%s-static' % name]
-
-  if not dir_name:
-    dir_name = name
-
-  native.java_binary(
-    name = '%s__non_stamped' % name,
-    deploy_manifest_lines = manifest_entries + ["Gerrit-ApiType: plugin"],
-    main_class = 'Dummy',
-    runtime_deps = [
-      ':%s__plugin' % name,
-    ] + static_jars,
-    visibility = ['//visibility:public'],
-    **kwargs
-  )
-
-  if gwt_module:
+        name,
+        deps = [],
+        provided_deps = [],
+        srcs = [],
+        resources = [],
+        manifest_entries = [],
+        dir_name = None,
+        target_suffix = "",
+        **kwargs):
     native.java_library(
-      name = name + '__gwt_module',
-      resources = depset(srcs + resources).to_list(),
-      runtime_deps = deps + GWT_PLUGIN_DEPS,
-      visibility = ['//visibility:public'],
-      **kwargs
-    )
-    genrule2(
-      name = '%s-static' % name,
-      cmd = ' && '.join([
-        'mkdir -p $$TMP/static',
-        'unzip -qd $$TMP/static $(location %s__gwt_application)' % name,
-        'cd $$TMP',
-        'zip -qr $$ROOT/$@ .']),
-      tools = [':%s__gwt_application' % name],
-      outs = ['%s-static.jar' % name],
-    )
-    gwt_binary(
-      name = name + '__gwt_application',
-      module = [gwt_module],
-      deps = GWT_PLUGIN_DEPS + GWT_TRANSITIVE_DEPS + ['//lib/gwt:dev'],
-      module_deps = [':%s__gwt_module' % name],
-      compiler_args = GWT_COMPILER_ARGS,
-      jvm_args = GWT_JVM_ARGS,
+        name = name + "__plugin",
+        srcs = srcs,
+        resources = resources,
+        deps = provided_deps + deps + PLUGIN_DEPS_NEVERLINK,
+        visibility = ["//visibility:public"],
+        **kwargs
     )
 
-  # TODO(davido): Remove manual merge of manifest file when this feature
-  # request is implemented: https://github.com/bazelbuild/bazel/issues/2009
-  genrule2(
-    name = name + target_suffix,
-    stamp = 1,
-    srcs = ['%s__non_stamped_deploy.jar' % name],
-    cmd = " && ".join([
-      "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % dir_name.upper(),
-      "cd $$TMP",
-      "unzip -q $$ROOT/$<",
-      "echo \"Implementation-Version: $$GEN_VERSION\n$$(cat META-INF/MANIFEST.MF)\" > META-INF/MANIFEST.MF",
-      "zip -qr $$ROOT/$@ ."]),
-    outs = ['%s%s.jar' % (name, target_suffix)],
-    visibility = ['//visibility:public'],
-  )
+    static_jars = []
+
+    if not dir_name:
+        dir_name = name
+
+    native.java_binary(
+        name = "%s__non_stamped" % name,
+        deploy_manifest_lines = manifest_entries + ["Gerrit-ApiType: plugin"],
+        main_class = "Dummy",
+        runtime_deps = [
+            ":%s__plugin" % name,
+        ] + static_jars,
+        visibility = ["//visibility:public"],
+        **kwargs
+    )
+
+    # TODO(davido): Remove manual merge of manifest file when this feature
+    # request is implemented: https://github.com/bazelbuild/bazel/issues/2009
+    genrule2(
+        name = name + target_suffix,
+        stamp = 1,
+        srcs = ["%s__non_stamped_deploy.jar" % name],
+        cmd = " && ".join([
+            "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % dir_name.upper(),
+            "cd $$TMP",
+            "unzip -q $$ROOT/$<",
+            "echo \"Implementation-Version: $$GEN_VERSION\n$$(cat META-INF/MANIFEST.MF)\" > META-INF/MANIFEST.MF",
+            "zip -qr $$ROOT/$@ .",
+        ]),
+        outs = ["%s%s.jar" % (name, target_suffix)],
+        visibility = ["//visibility:public"],
+    )
diff --git a/tools/bzl/plugins.bzl b/tools/bzl/plugins.bzl
index 7fd7625..adde59e 100644
--- a/tools/bzl/plugins.bzl
+++ b/tools/bzl/plugins.bzl
@@ -1,11 +1,15 @@
 CORE_PLUGINS = [
     "codemirror-editor",
     "commit-message-length-validator",
+    "delete-project",
     "download-commands",
+    "gitiles",
     "hooks",
+    "plugin-manager",
     "replication",
     "reviewnotes",
     "singleusergroup",
+    "webhooks",
 ]
 
 CUSTOM_PLUGINS = [
diff --git a/tools/coverage.sh b/tools/coverage.sh
index 22b40d8..11e50e6 100755
--- a/tools/coverage.sh
+++ b/tools/coverage.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/bin/bash
 #
 # Usage
 #
@@ -22,15 +22,27 @@
 
 # coverage is expensive to run; use --jobs=2 to avoid overloading the
 # machine.
-bazel coverage -k --jobs=${COVERAGE_CPUS:-2} -- ... -//javatests/com/google/gerrit/common:auto_value_tests
+bazel coverage -k --jobs=${COVERAGE_CPUS:-2} -- ...
 
 # The coverage data contains filenames relative to the Java root, and
 # genhtml has no logic to search these elsewhere. Workaround this
 # limitation by running genhtml in a directory with the files in the
 # right place. Also -inexplicably- genhtml wants to have the source
 # files relative to the output directory.
-mkdir -p ${destdir}/
-cp -a */src/{main,test}/java/* ${destdir}/
+mkdir -p ${destdir}/java
+cp -r {java,javatests}/* ${destdir}/java
+
+mkdir -p ${destdir}/plugins
+for plugin in `find plugins/ -type d` -maxdepth 1
+do
+  mkdir -p ${destdir}/${plugin}/java
+  cp -r plugins/*/{java,javatests}/* ${destdir}/${plugin}/java
+
+  # for backwards compatibility support plugins with old file structure
+  mkdir -p ${destdir}/${plugin}/src/{main,test}/java
+  cp -r plugins/*/src/main/java/* ${destdir}/${plugin}/src/main/java
+  cp -r plugins/*/src/test/java/* ${destdir}/${plugin}/src/test/java
+done
 
 base=$(bazel info bazel-testlogs)
 for f in $(find ${base}  -name 'coverage.dat') ; do
diff --git a/tools/download_file.py b/tools/download_file.py
index 29398e6..d0fe96d 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -81,7 +81,6 @@
 opts.add_option('-v', help='expected content SHA-1')
 opts.add_option('-x', action='append', help='file to delete from ZIP')
 opts.add_option('--exclude_java_sources', action='store_true')
-opts.add_option('--unsign', action='store_true')
 args, _ = opts.parse_args()
 
 root_dir = args.o
@@ -140,18 +139,6 @@
         print('error opening %s: %s' % (cache_ent, err), file=stderr)
         exit(1)
 
-if args.unsign:
-    try:
-        with ZipFile(cache_ent, 'r') as zf:
-            for n in zf.namelist():
-                if (n.endswith('.RSA')
-                   or n.endswith('.SF')
-                   or n.endswith('.LIST')):
-                    exclude.append(n)
-    except (BadZipfile, LargeZipFile) as err:
-        print('error opening %s: %s' % (cache_ent, err), file=stderr)
-        exit(1)
-
 safe_mkdirs(path.dirname(args.o))
 if exclude:
     try:
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index 539423b..814a56f 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -1,5 +1,5 @@
-load("//tools/bzl:pkg_war.bzl", "LIBS", "PGMLIBS")
 load("//tools/bzl:classpath.bzl", "classpath_collector")
+load("//tools/bzl:pkg_war.bzl", "LIBS", "PGMLIBS")
 load(
     "//tools/bzl:plugins.bzl",
     "CORE_PLUGINS",
@@ -8,54 +8,39 @@
 )
 
 TEST_DEPS = [
-    "//gerrit-gwtui:ui_tests",
     "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
     "//javatests/com/google/gerrit/server:server_tests",
 ]
 
+TEST_DEPS_GENERATED = [
+    "//proto/testing:test_java_proto",
+]
+
 DEPS = [
-    "//gerrit-gwtdebug:gwtdebug",
-    "//gerrit-gwtui:ui_module",
-    "//gerrit-plugin-gwtui:gwtui-api-lib",
     "//java/com/google/gerrit/acceptance:lib",
     "//java/com/google/gerrit/server",
     "//java/com/google/gerrit/asciidoctor:asciidoc_lib",
     "//java/com/google/gerrit/asciidoctor:doc_indexer_lib",
     "//lib/auto:auto-value",
-    "//lib/gwt:ant",
-    "//lib/gwt:colt",
-    "//lib/gwt:javax-validation",
-    "//lib/gwt:javax-validation_src",
-    "//lib/gwt:jsinterop-annotations",
-    "//lib/gwt:jsinterop-annotations_src",
-    "//lib/gwt:tapestry",
-    "//lib/gwt:w3c-css-sac",
-    "//lib/jetty:servlets",
     "//lib/prolog:compiler-lib",
-    # TODO(davido): I do not understand why it must be on the Eclipse classpath
-    #'//Documentation:index',
+    "//proto:entities_java_proto",
 ]
 
 java_library(
     name = "classpath",
-    testonly = 1,
-    runtime_deps = LIBS + PGMLIBS + DEPS,
+    testonly = True,
+    runtime_deps = LIBS + PGMLIBS + DEPS + TEST_DEPS_GENERATED,
 )
 
 classpath_collector(
     name = "main_classpath_collect",
-    testonly = 1,
-    deps = LIBS + PGMLIBS + DEPS + TEST_DEPS +
+    testonly = True,
+    deps = LIBS + PGMLIBS + DEPS + TEST_DEPS + TEST_DEPS_GENERATED +
            ["//plugins/%s:%s__plugin" % (n, n) for n in CORE_PLUGINS + CUSTOM_PLUGINS] +
            ["//plugins/%s:%s__plugin_test_deps" % (n, n) for n in CUSTOM_PLUGINS_TEST_DEPS],
 )
 
 classpath_collector(
-    name = "gwt_classpath_collect",
-    deps = ["//gerrit-gwtui:ui_module"],
-)
-
-classpath_collector(
     name = "autovalue_classpath_collect",
     deps = ["//lib/auto:auto-value"],
 )
diff --git a/tools/eclipse/gerrit_gwt_debug.launch b/tools/eclipse/gerrit_gwt_debug.launch
deleted file mode 100644
index 593837a..0000000
--- a/tools/eclipse/gerrit_gwt_debug.launch
+++ /dev/null
@@ -1,22 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<launchConfiguration type="org.eclipse.jdt.launching.localJavaApplication">
-<listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_PATHS">
-<listEntry value="/gerrit/gerrit-gwtdebug/src/main/java/com/google/gerrit/gwtdebug/GerritGwtDebugLauncher.java"/>
-</listAttribute>
-<listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_TYPES">
-<listEntry value="1"/>
-</listAttribute>
-<listAttribute key="org.eclipse.debug.ui.favoriteGroups">
-<listEntry value="org.eclipse.debug.ui.launchGroup.debug"/>
-</listAttribute>
-<booleanAttribute key="org.eclipse.jdt.launching.ATTR_USE_START_ON_FIRST_THREAD" value="true"/>
-<listAttribute key="org.eclipse.jdt.launching.CLASSPATH">
-<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#10;&lt;runtimeClasspathEntry containerPath=&quot;org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.7&quot; javaProject=&quot;gerrit&quot; path=&quot;1&quot; type=&quot;4&quot;/&gt;&#10;"/>
-<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?&gt;&#10;&lt;runtimeClasspathEntry id=&quot;org.eclipse.jdt.launching.classpathentry.defaultClasspath&quot;&gt;&#10;&lt;memento exportedEntriesOnly=&quot;false&quot; project=&quot;gerrit&quot;/&gt;&#10;&lt;/runtimeClasspathEntry&gt;&#10;"/>
-</listAttribute>
-<booleanAttribute key="org.eclipse.jdt.launching.DEFAULT_CLASSPATH" value="false"/>
-<stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="com.google.gerrit.gwtdebug.GerritGwtDebugLauncher"/>
-<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-strict -noprecompile -src ${resource_loc:/gerrit}/java -workDir ${resource_loc:/gerrit}/.gwt_work_dir com.google.gerrit.GerritGwtUI -src ${resource_loc:/gerrit}/gerrit-plugin-gwtui/src/main/java -- --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
-<stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="gerrit"/>
-<stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Xmx1024M&#10;-XX:MaxPermSize=256M&#10;-Dgerrit.disable-gwtui-recompile=true"/>
-</launchConfiguration>
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index b99c04e..bfd85ae 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -27,7 +27,6 @@
 import sys
 
 MAIN = '//tools/eclipse:classpath'
-GWT = '//gerrit-gwtui:ui_module'
 AUTO = '//lib/auto:auto-value'
 JRE = '/'.join([
     'org.eclipse.jdt.launching.JRE_CONTAINER',
@@ -37,7 +36,6 @@
 # Map of targets to corresponding classpath collector rules
 cp_targets = {
     AUTO: '//tools/eclipse:autovalue_classpath_collect',
-    GWT: '//tools/eclipse:gwt_classpath_collect',
     MAIN: '//tools/eclipse:main_classpath_collect',
 }
 
@@ -52,17 +50,34 @@
                 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)')
+opts.add_option('-e', '--edge_java', action='store',
+                dest='edge_java', help='Post Java 9 support (10|11|...)')
+opts.add_option('--bazel', help='name of the bazel executable',
+                action='store', default='bazel', dest='bazel_exe')
+
 args, _ = opts.parse_args()
 
 batch_option = '--batch' if args.batch else None
-
+custom_java = args.java
+edge_java = args.edge_java
+bazel_exe = args.bazel_exe
 
 def _build_bazel_cmd(*args):
-    cmd = ['bazel']
+    build = False
+    cmd = [bazel_exe]
     if batch_option:
         cmd.append('--batch')
     for arg in args:
+        if arg == "build":
+            build = True
         cmd.append(arg)
+    if custom_java and not edge_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)
+        if edge_java and build:
+            cmd.append(edge_java)
     return cmd
 
 
@@ -70,9 +85,10 @@
     return check_output(_build_bazel_cmd('info', 'output_base')).strip()
 
 
-def gen_bazel_path():
-    bazel = check_output(['which', 'bazel']).strip().decode('UTF-8')
+def gen_bazel_path(ext_location):
+    bazel = check_output(['which', bazel_exe]).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"])
 
@@ -114,7 +130,9 @@
         if path.exists(path.join(root, 'src', 'test', 'java')):
             testpath = """
   <classpathentry excluding="**/BUILD" kind="src" path="src/test/java"\
- out="eclipse-out/test"/>"""
+ out="eclipse-out/test">
+    <attributes><attribute name="test" value="true"/></attributes>
+  </classpathentry>"""
         else:
             testpath = ""
         print("""\
@@ -147,14 +165,32 @@
             e.setAttribute('output', out)
         if exported:
             e.setAttribute('exported', 'true')
+        atts = None
+        if out and "test" in out:
+            atts = doc.createElement('attributes')
+            testAtt = doc.createElement('attribute')
+            testAtt.setAttribute('name', 'test')
+            testAtt.setAttribute('value', 'true')
+            atts.appendChild(testAtt)
+        if "apt_generated" in path:
+            if not atts:
+                atts = doc.createElement('attributes')
+            ignoreOptionalProblems = doc.createElement('attribute')
+            ignoreOptionalProblems.setAttribute('name', 'ignore_optional_problems')
+            ignoreOptionalProblems.setAttribute('value', 'true')
+            atts.appendChild(ignoreOptionalProblems)
+            optional = doc.createElement('attribute')
+            optional.setAttribute('name', 'optional')
+            optional.setAttribute('value', 'true')
+            atts.appendChild(optional)
+        if atts:
+            e.appendChild(atts)
         doc.documentElement.appendChild(e)
 
     doc = make_classpath()
     src = set()
     lib = set()
     proto = set()
-    gwt_src = set()
-    gwt_lib = set()
     plugins = set()
 
     # Classpath entries are absolute for cross-cell support
@@ -162,10 +198,6 @@
     srcs = re.compile('(.*/external/[^/]+)/jar/(.*)[.]jar')
     for p in _query_classpath(MAIN):
         if p.endswith('-src.jar'):
-            # gwt_module() depends on -src.jar for Java to JavaScript compiles.
-            if p.startswith("external"):
-                p = path.join(ext, p)
-            gwt_lib.add(p)
             continue
 
         m = java_library.match(p)
@@ -173,7 +205,9 @@
             src.add(m.group(1))
             # Exceptions: both source and lib
             if p.endswith('libquery_parser.jar') or \
-               p.endswith('libgerrit-prolog-common.jar'):
+               p.endswith('libgerrit-prolog-common.jar') or \
+         p.endswith('com_google_protobuf/libprotobuf_java.jar') or \
+               p.endswith('lucene-core-and-backward-codecs__merged.jar'):
                 lib.add(p)
             # JGit dependency from external repository
             if 'gerrit-' not in p and 'jgit' in p:
@@ -191,11 +225,6 @@
                 p = path.join(ext, p)
             lib.add(p)
 
-    for p in _query_classpath(GWT):
-        m = java_library.match(p)
-        if m:
-            gwt_src.add(m.group(1))
-
     classpathentry('src', 'java')
     classpathentry('src', 'javatests', out='eclipse-out/test')
     classpathentry('src', 'resources')
@@ -212,7 +241,10 @@
 
         p = path.join(s, 'java')
         if path.exists(p):
-            classpathentry('src', p, out=out)
+            classpathentry('src', p, out=out + '/main')
+            p = path.join(s, 'javatests')
+            if path.exists(p):
+                classpathentry('src', p, out=out + '/test')
             continue
 
         for env in ['main', 'test']:
@@ -227,7 +259,7 @@
                 if path.exists(p):
                     classpathentry('src', p, out=o)
 
-    for libs in [lib, gwt_lib]:
+    for libs in [lib]:
         for j in sorted(libs):
             s = None
             m = srcs.match(j)
@@ -240,30 +272,18 @@
             if args.plugins:
                 classpathentry('lib', j, s, exported=True)
             else:
-                # Filter out the source JARs that we pull through transitive
-                # closure of GWT plugin API (we add source directories
-                # themselves).  Exception is libEdit-src.jar, that is needed
-                # for GWT SDM to work.
-                m = java_library.match(j)
-                if m:
-                    if m.group(1).startswith("gerrit-") and \
-                       j.endswith("-src.jar") and \
-                       not j.endswith("libEdit-src.jar"):
-                        continue
                 classpathentry('lib', j, s)
 
     for p in sorted(proto):
         s = p.replace('-fastbuild/bin/proto/lib', '-fastbuild/genfiles/proto/')
+        s = p.replace('-fastbuild/bin/proto/testing/lib', '-fastbuild/genfiles/proto/testing/')
         s = s.replace('.jar', '-src.jar')
         classpathentry('lib', p, s)
 
-    for s in sorted(gwt_src):
-        p = path.join(ROOT, s, 'src', 'main', 'java')
-        if path.exists(p):
-            classpathentry('lib', p, out='eclipse-out/gwtsrc')
-
     classpathentry('con', JRE)
     classpathentry('output', 'eclipse-out/classes')
+    classpathentry('src', '.apt_generated')
+    classpathentry('src', '.apt_generated_tests', out="eclipse-out/test")
 
     p = path.join(ROOT, '.classpath')
     with open(p, 'w') as fd:
@@ -301,16 +321,10 @@
     gen_project(args.project_name)
     gen_classpath(ext_location)
     gen_factorypath(ext_location)
-    gen_bazel_path()
-
-    # TODO(davido): Remove this when GWT gone
-    gwt_working_dir = ".gwt_work_dir"
-    if not path.isdir(gwt_working_dir):
-        makedirs(path.join(ROOT, gwt_working_dir))
+    gen_bazel_path(ext_location)
 
     try:
-        check_call(_build_bazel_cmd('build', MAIN, GWT,
-                                    '//java/org/eclipse/jgit:libEdit-src.jar'))
+        check_call(_build_bazel_cmd('build', MAIN))
     except CalledProcessError:
         exit(1)
 except KeyboardInterrupt:
diff --git a/tools/js/bower2bazel.py b/tools/js/bower2bazel.py
index 7b24524..e728cc3 100755
--- a/tools/js/bower2bazel.py
+++ b/tools/js/bower2bazel.py
@@ -204,11 +204,12 @@
     for d in data:
         if d["name"] in seeds:
             continue
-        out.write("""  bower_archive(
-    name = "%(name)s",
-    package = "%(normalized-name)s",
-    version = "%(version)s",
-    sha1 = "%(bazel-sha1)s")
+        out.write("""    bower_archive(
+        name = "%(name)s",
+        package = "%(normalized-name)s",
+        version = "%(version)s",
+        sha1 = "%(bazel-sha1)s",
+    )
 """ % d)
 
 
@@ -216,21 +217,21 @@
     out.write('load("//tools/bzl:js.bzl", "bower_component")\n\n')
     out.write('def define_bower_components():\n')
     for d in data:
-        out.write("  bower_component(\n")
-        out.write("    name = \"%s\",\n" % d["name"])
-        out.write("    license = \"//lib:LICENSE-%s\",\n" % d["bazel-license"])
+        out.write("    bower_component(\n")
+        out.write("        name = \"%s\",\n" % d["name"])
+        out.write("        license = \"//lib:LICENSE-%s\",\n" % d["bazel-license"])
         deps = sorted(d.get("dependencies", {}).keys())
         if deps:
             if len(deps) == 1:
-                out.write("    deps = [ \":%s\" ],\n" % deps[0])
+                out.write("        deps = [\":%s\"],\n" % deps[0])
             else:
-                out.write("    deps = [\n")
+                out.write("        deps = [\n")
                 for dep in deps:
-                    out.write("      \":%s\",\n" % dep)
-                out.write("    ],\n")
+                    out.write("            \":%s\",\n" % dep)
+                out.write("        ],\n")
         if d["name"] in seeds:
-            out.write("    seed = True,\n")
-        out.write("  )\n")
+            out.write("        seed = True,\n")
+        out.write("    )\n")
     # done
 
 
diff --git a/tools/js/npm_pack.py b/tools/js/npm_pack.py
index d817701..57f3166 100755
--- a/tools/js/npm_pack.py
+++ b/tools/js/npm_pack.py
@@ -15,8 +15,8 @@
 
 """This downloads an NPM binary, and bundles it with its dependencies.
 
-This is used to assemble a pinned version of crisper, hosted on the
-Google storage bucket ("repository=GERRIT" in WORKSPACE).
+For full instructions on adding new binaries to the build, see
+http://gerrit-review.googlesource.com/Documentation/dev-bazel.html#npm-binary
 """
 
 from __future__ import print_function
@@ -54,7 +54,7 @@
 
     name, version = args
     filename = '%s-%s.tgz' % (name, version)
-    url = 'http://registry.npmjs.org/%s/-/%s' % (name, filename)
+    url = 'https://registry.npmjs.org/%s/-/%s' % (name, filename)
 
     tmpdir = tempfile.mkdtemp()
     tgz = os.path.join(tmpdir, filename)
diff --git a/tools/js/run_npm_binary.py b/tools/js/run_npm_binary.py
index dfcdaca..bdee5ab 100644
--- a/tools/js/run_npm_binary.py
+++ b/tools/js/run_npm_binary.py
@@ -56,7 +56,8 @@
                 extract_one(mem)
         # Extract bin last so other processes only short circuit when
         # extraction is finished.
-        extract_one(tar.getmember(bin))
+        if bin in tar.getnames():
+            extract_one(tar.getmember(bin))
 
 
 def main(args):
@@ -77,7 +78,9 @@
     sha1 = hashlib.sha1(open(path, 'rb').read()).hexdigest()
     outdir = '%s-%s' % (path[:-len(suffix)], sha1)
     rel_bin = os.path.join('package', 'bin', name)
+    rel_lib_bin = os.path.join('package', 'lib', 'bin', name + '.js')
     bin = os.path.join(outdir, rel_bin)
+    libbin = os.path.join(outdir, rel_lib_bin)
     if not os.path.isfile(bin):
         extract(path, outdir, rel_bin)
 
@@ -85,7 +88,12 @@
     if nodejs:
         # Debian installs Node.js as 'nodejs', due to a conflict with another
         # package.
-        subprocess.check_call([nodejs, bin] + args[1:])
+        if not os.path.isfile(bin) and os.path.isfile(libbin):
+            subprocess.check_call([nodejs, libbin] + args[1:])
+        else:
+            subprocess.check_call([nodejs, bin] + args[1:])
+    elif not os.path.isfile(bin) and os.path.isfile(libbin):
+        subprocess.check_call([libbin] + args[1:])
     else:
         subprocess.check_call([bin] + args[1:])
 
diff --git a/tools/maven/BUILD b/tools/maven/BUILD
index 10ed27d..6cbd219 100644
--- a/tools/maven/BUILD
+++ b/tools/maven/BUILD
@@ -10,19 +10,16 @@
         "gerrit-acceptance-framework": "//java/com/google/gerrit/acceptance:libframework-lib-src.jar",
         "gerrit-extension-api": "//java/com/google/gerrit/extensions:libapi-src.jar",
         "gerrit-plugin-api": "//plugins:plugin-api-sources_deploy.jar",
-        "gerrit-plugin-gwtui": "//gerrit-plugin-gwtui:gwtui-api-source_deploy.jar",
     },
     doc = {
         "gerrit-acceptance-framework": "//java/com/google/gerrit/acceptance:framework-javadoc",
         "gerrit-extension-api": "//java/com/google/gerrit/extensions:extension-api-javadoc",
         "gerrit-plugin-api": "//plugins:plugin-api-javadoc",
-        "gerrit-plugin-gwtui": "//gerrit-plugin-gwtui:gwtui-api-javadoc",
     },
     jar = {
         "gerrit-acceptance-framework": "//java/com/google/gerrit/acceptance:framework_deploy.jar",
         "gerrit-extension-api": "//java/com/google/gerrit/extensions:extension-api_deploy.jar",
         "gerrit-plugin-api": "//plugins:plugin-api_deploy.jar",
-        "gerrit-plugin-gwtui": "//gerrit-plugin-gwtui:gwtui-api_deploy.jar",
     },
     repository = MAVEN_REPOSITORY,
     url = URL,
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index a0f2e67..14a07d1 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.16-SNAPSHOT</version>
+  <version>3.1.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
@@ -26,10 +26,7 @@
       <name>Alice Kober-Sotzek</name>
     </developer>
     <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
+      <name>Ben Rohlfs</name>
     </developer>
     <developer>
       <name>Dave Borowitz</name>
@@ -44,29 +41,29 @@
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Han-Wen Nienhuys</name>
+    </developer>
+    <developer>
       <name>Hugo Arès</name>
     </developer>
     <developer>
-      <name>Kasper Nilsson</name>
+      <name>Luca Milanesio</name>
     </developer>
     <developer>
-      <name>Logan Hanks</name>
+      <name>Marco Miller</name>
     </developer>
     <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Ole Rehmsen</name>
+    </developer>
+    <developer>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index a8ae2e6..0cf1448 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>2.16-SNAPSHOT</version>
+  <version>3.1.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -26,10 +26,7 @@
       <name>Alice Kober-Sotzek</name>
     </developer>
     <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
+      <name>Ben Rohlfs</name>
     </developer>
     <developer>
       <name>Dave Borowitz</name>
@@ -44,35 +41,29 @@
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Han-Wen Nienhuys</name>
+    </developer>
+    <developer>
       <name>Hugo Arès</name>
     </developer>
     <developer>
-      <name>Kasper Nilsson</name>
-    </developer>
-    <developer>
-      <name>Logan Hanks</name>
-    </developer>
-    <developer>
       <name>Luca Milanesio</name>
     </developer>
     <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Ole Rehmsen</name>
+    </developer>
+    <developer>
       <name>Patrick Hiesel</name>
     </developer>
     <developer>
       <name>Saša Živkov</name>
     </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 84df44a..ebb66b4 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.16-SNAPSHOT</version>
+  <version>3.1.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -26,10 +26,7 @@
       <name>Alice Kober-Sotzek</name>
     </developer>
     <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
+      <name>Ben Rohlfs</name>
     </developer>
     <developer>
       <name>Dave Borowitz</name>
@@ -44,29 +41,29 @@
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Han-Wen Nienhuys</name>
+    </developer>
+    <developer>
       <name>Hugo Arès</name>
     </developer>
     <developer>
-      <name>Kasper Nilsson</name>
+      <name>Luca Milanesio</name>
     </developer>
     <developer>
-      <name>Logan Hanks</name>
+      <name>Marco Miller</name>
     </developer>
     <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Ole Rehmsen</name>
+    </developer>
+    <developer>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-plugin-gwtui_pom.xml b/tools/maven/gerrit-plugin-gwtui_pom.xml
deleted file mode 100644
index cc9aafc..0000000
--- a/tools/maven/gerrit-plugin-gwtui_pom.xml
+++ /dev/null
@@ -1,86 +0,0 @@
-<project>
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.16-SNAPSHOT</version>
-  <packaging>jar</packaging>
-  <name>Gerrit Code Review - Plugin GWT UI</name>
-  <description>Common Classes for Gerrit GWT UI Plugins</description>
-  <url>https://www.gerritcodereview.com/</url>
-
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <scm>
-    <url>https://gerrit.googlesource.com/gerrit</url>
-    <connection>https://gerrit.googlesource.com/gerrit</connection>
-  </scm>
-
-  <developers>
-    <developer>
-      <name>Alice Kober-Sotzek</name>
-    </developer>
-    <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
-    </developer>
-    <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
-      <name>David Ostrovsky</name>
-    </developer>
-    <developer>
-      <name>David Pursehouse</name>
-    </developer>
-    <developer>
-      <name>Edwin Kempin</name>
-    </developer>
-    <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
-      <name>Kasper Nilsson</name>
-    </developer>
-    <developer>
-      <name>Logan Hanks</name>
-    </developer>
-    <developer>
-      <name>Martin Fick</name>
-    </developer>
-    <developer>
-      <name>Saša Živkov</name>
-    </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
-  </developers>
-
-  <mailingLists>
-    <mailingList>
-      <name>Repo and Gerrit Discussion</name>
-      <post>repo-discuss@googlegroups.com</post>
-      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
-      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
-      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
-    </mailingList>
-  </mailingLists>
-
-  <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
-    <system>Gerrit Issue Tracker</system>
-  </issueManagement>
-</project>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index c43c098..51e517b 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.16-SNAPSHOT</version>
+  <version>3.1.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -26,10 +26,7 @@
       <name>Alice Kober-Sotzek</name>
     </developer>
     <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
+      <name>Ben Rohlfs</name>
     </developer>
     <developer>
       <name>Dave Borowitz</name>
@@ -44,29 +41,29 @@
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Han-Wen Nienhuys</name>
+    </developer>
+    <developer>
       <name>Hugo Arès</name>
     </developer>
     <developer>
-      <name>Kasper Nilsson</name>
+      <name>Luca Milanesio</name>
     </developer>
     <developer>
-      <name>Logan Hanks</name>
+      <name>Marco Miller</name>
     </developer>
     <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Ole Rehmsen</name>
+    </developer>
+    <developer>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/mvn.py b/tools/maven/mvn.py
index d47d027..59a5efb 100755
--- a/tools/maven/mvn.py
+++ b/tools/maven/mvn.py
@@ -46,6 +46,7 @@
     cmd = [
         'mvn',
         'gpg:sign-and-deploy-file',
+        '-Dversion=%s' % args.v,
         '-DrepositoryId=%s' % args.repository,
         '-Durl=%s' % args.url,
     ]
diff --git a/tools/maven/package.bzl b/tools/maven/package.bzl
index 3c32bb2..11e569d 100644
--- a/tools/maven/package.bzl
+++ b/tools/maven/package.bzl
@@ -25,73 +25,86 @@
 ]))
 
 def maven_package(
-    version,
-    repository = None,
-    url = None,
-    jar = {},
-    src = {},
-    doc = {},
-    war = {}):
+        version,
+        repository = None,
+        url = None,
+        jar = {},
+        src = {},
+        doc = {},
+        war = {}):
+    build_cmd = ["bazel", "build"]
+    mvn_cmd = ["python", "tools/maven/mvn.py", "-v", version]
+    api_cmd = mvn_cmd[:]
+    api_targets = []
+    for type, d in [("jar", jar), ("java-source", src), ("javadoc", doc)]:
+        for a, t in sorted(d.items()):
+            api_cmd.append("-s %s:%s:$(location %s)" % (a, type, t))
+            api_targets.append(t)
 
-  build_cmd = ['bazel', 'build']
-  mvn_cmd = ['python', 'tools/maven/mvn.py', '-v', version]
-  api_cmd = mvn_cmd[:]
-  api_targets = []
-  for type,d in [('jar', jar), ('java-source', src), ('javadoc', doc)]:
-    for a,t in sorted(d.items()):
-      api_cmd.append('-s %s:%s:$(location %s)' % (a,type,t))
-      api_targets.append(t)
-
-  native.genrule(
-    name = 'gen_api_install',
-    cmd = sh_bang_template % (
-      ' '.join(build_cmd + api_targets),
-      ' '.join(api_cmd + ['-a', 'install'])),
-    srcs = api_targets,
-    outs = ['api_install.sh'],
-    executable = True,
-    testonly = 1,
-  )
-
-  if repository and url:
     native.genrule(
-      name = 'gen_api_deploy',
-      cmd = sh_bang_template % (
-        ' '.join(build_cmd + api_targets),
-        ' '.join(api_cmd + ['-a', 'deploy',
-                            '--repository', repository,
-                            '--url', url])),
-      srcs = api_targets,
-      outs = ['api_deploy.sh'],
-      executable = True,
-      testonly = 1,
+        name = "gen_api_install",
+        cmd = sh_bang_template % (
+            " ".join(build_cmd + api_targets),
+            " ".join(api_cmd + ["-a", "install"]),
+        ),
+        srcs = api_targets,
+        outs = ["api_install.sh"],
+        executable = True,
+        testonly = True,
     )
 
-  war_cmd = mvn_cmd[:]
-  war_targets = []
-  for a,t in sorted(war.items()):
-    war_cmd.append('-s %s:war:$(location %s)' % (a,t))
-    war_targets.append(t)
+    if repository and url:
+        native.genrule(
+            name = "gen_api_deploy",
+            cmd = sh_bang_template % (
+                " ".join(build_cmd + api_targets),
+                " ".join(api_cmd + [
+                    "-a",
+                    "deploy",
+                    "--repository",
+                    repository,
+                    "--url",
+                    url,
+                ]),
+            ),
+            srcs = api_targets,
+            outs = ["api_deploy.sh"],
+            executable = True,
+            testonly = True,
+        )
 
-  native.genrule(
-    name = 'gen_war_install',
-    cmd = sh_bang_template % (' '.join(build_cmd + war_targets),
-                              ' '.join(war_cmd + ['-a', 'install'])),
-    srcs = war_targets,
-    outs = ['war_install.sh'],
-    executable = True,
-  )
+    war_cmd = mvn_cmd[:]
+    war_targets = []
+    for a, t in sorted(war.items()):
+        war_cmd.append("-s %s:war:$(location %s)" % (a, t))
+        war_targets.append(t)
 
-  if repository and url:
     native.genrule(
-      name = 'gen_war_deploy',
-      cmd = sh_bang_template % (
-          ' '.join(build_cmd + war_targets),
-          ' '.join(war_cmd + [
-        '-a', 'deploy',
-        '--repository', repository,
-        '--url', url])),
-      srcs = war_targets,
-      outs = ['war_deploy.sh'],
-      executable = True,
+        name = "gen_war_install",
+        cmd = sh_bang_template % (
+            " ".join(build_cmd + war_targets),
+            " ".join(war_cmd + ["-a", "install"]),
+        ),
+        srcs = war_targets,
+        outs = ["war_install.sh"],
+        executable = True,
     )
+
+    if repository and url:
+        native.genrule(
+            name = "gen_war_deploy",
+            cmd = sh_bang_template % (
+                " ".join(build_cmd + war_targets),
+                " ".join(war_cmd + [
+                    "-a",
+                    "deploy",
+                    "--repository",
+                    repository,
+                    "--url",
+                    url,
+                ]),
+            ),
+            srcs = war_targets,
+            outs = ["war_deploy.sh"],
+            executable = True,
+        )
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
new file mode 100644
index 0000000..6788bc9
--- /dev/null
+++ b/tools/nongoogle.bzl
@@ -0,0 +1,16 @@
+load("//tools/bzl:maven_jar.bzl", "maven_jar")
+
+def declare_nongoogle_deps():
+    """loads dependencies that are not used at Google.
+
+    Changes to versions are exempt from library compliance review. New
+    dependencies must pass through library compliance review. This is
+    enforced by //lib:nongoogle_test.
+    """
+
+    # Transitive dependency of commons-compress
+    maven_jar(
+        name = "tukaani-xz",
+        artifact = "org.tukaani:xz:1.8",
+        sha1 = "c4f7d054303948eb6a4066194253886c8af07128",
+    )
diff --git a/tools/release-announcement-template.txt b/tools/release-announcement-template.txt
deleted file mode 100644
index 2702f57..0000000
--- a/tools/release-announcement-template.txt
+++ /dev/null
@@ -1,26 +0,0 @@
-Gerrit version {{ data.version }} is now available.{% if data.summary %} {{ data.summary }} {% endif %}Please see the release notes for details.
-
-Release Notes:
-https://www.gerritcodereview.com/releases/{{ data.version.major }}.md{% if data.version.patch %}#{{ data.version.patch }}{% endif %}
-
-Documentation:
-http://gerrit-documentation.storage.googleapis.com/Documentation/{{ data.version }}/index.html
-{% if data.previous %}
-Log of changes since {{ data.previous }}:
-https://gerrit.googlesource.com/gerrit/+log/v{{ data.previous }}..v{{ data.version }}?no-merges
-{% endif %}
-Download:
-https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.version }}.war
-
-SHA1:
-{{ data.sha1 }}
-
-SHA256:
-{{ data.sha256 }}
-
-MD5:
-{{ data.md5 }}
-
-Maintainers' public keys:
-https://www.gerritcodereview.com/releases/public-keys.md
-
diff --git a/tools/release-announcement.py b/tools/release-announcement.py
deleted file mode 100755
index a25a340..0000000
--- a/tools/release-announcement.py
+++ /dev/null
@@ -1,156 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2017 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# Generates the text to paste into the email for announcing a new
-# release of Gerrit. The text is generated based on a template that
-# is filled with values either passed to the script or calculated
-# at runtime.
-#
-# The script outputs a plain text file with the announcement text:
-#
-#   release-announcement-gerrit-X.Y.txt
-#
-# and, if GPG is available, the announcement text wrapped with a
-# signature:
-#
-#   release-announcement-gerrit-X.Y.txt.asc
-#
-# Usage:
-#
-#   ./tools/release-announcement.py -v 2.14.2 -p 2.14.1 \
-#      -s "This release fixes several bugs since 2.14.1"
-#
-# Parameters:
-#
-#   --version (-v): The version of Gerrit being released.
-#
-#   --previous (-p): The previous version of Gerrit.  Optional. If
-#   specified, the generated text includes a link to the gitiles
-#   log of commits between the previous and new versions.
-#
-#   --summary (-s): Short summary of the release. Optional. When
-#   specified, the summary is inserted in the introductory sentence
-#   of the generated text.
-#
-# Prerequisites:
-#
-# - The Jinja2 python library [1] must be installed.
-#
-# - For GPG signing to work, the python-gnupg library [2] must be
-#   installed, and the ~/.gnupg folder must exist.
-#
-# - The war file must have been installed to the local Maven repository
-#   using the `./tools/mvn/api.sh war_install` command.
-#
-# [1] http://jinja.pocoo.org/
-# [2] http://pythonhosted.org/gnupg/
-
-
-from __future__ import print_function
-import argparse
-import hashlib
-import os
-import sys
-from gnupg import GPG
-from jinja2 import Template
-
-
-class Version:
-    def __init__(self, version):
-        self.version = version
-        parts = version.split('.')
-        if len(parts) > 2:
-            self.major = ".".join(parts[:2])
-            self.patch = version
-        else:
-            self.major = version
-            self.patch = None
-
-    def __str__(self):
-        return self.version
-
-
-def _main():
-    descr = 'Generate Gerrit release announcement email text'
-    parser = argparse.ArgumentParser(
-        description=descr,
-        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
-    parser.add_argument('-v', '--version', dest='version',
-                        required=True,
-                        help='gerrit version to release')
-    parser.add_argument('-p', '--previous', dest='previous',
-                        help='previous gerrit version (optional)')
-    parser.add_argument('-s', '--summary', dest='summary',
-                        help='summary of the release content (optional)')
-    options = parser.parse_args()
-
-    summary = options.summary
-    if summary and not summary.endswith("."):
-        summary = summary + "."
-
-    data = {
-        "version": Version(options.version),
-        "previous": options.previous,
-        "summary": summary
-    }
-
-    war = os.path.join(
-        os.path.expanduser("~/.m2/repository/com/google/gerrit/gerrit-war/"),
-        "%(version)s/gerrit-war-%(version)s.war" % data)
-    if not os.path.isfile(war):
-        print("Could not find war file for Gerrit %s in local Maven repository"
-              % data["version"], file=sys.stderr)
-        sys.exit(1)
-
-    md5 = hashlib.md5()
-    sha1 = hashlib.sha1()
-    sha256 = hashlib.sha256()
-    BUF_SIZE = 65536  # Read data in 64kb chunks
-    with open(war, 'rb') as f:
-        while True:
-            d = f.read(BUF_SIZE)
-            if not d:
-                break
-            md5.update(d)
-            sha1.update(d)
-            sha256.update(d)
-
-    data["sha1"] = sha1.hexdigest()
-    data["sha256"] = sha256.hexdigest()
-    data["md5"] = md5.hexdigest()
-
-    template = Template(open("tools/release-announcement-template.txt").read())
-    output = template.render(data=data)
-
-    filename = "release-announcement-gerrit-%s.txt" % data["version"]
-    with open(filename, "w") as f:
-        f.write(output)
-
-    gpghome = os.path.abspath(os.path.expanduser("~/.gnupg"))
-    if not os.path.isdir(gpghome):
-        print("Skipping signing due to missing gnupg home folder")
-    else:
-        try:
-            gpg = GPG(homedir=gpghome)
-        except TypeError:
-            gpg = GPG(gnupghome=gpghome)
-        signed = gpg.sign(output)
-        filename = filename + ".asc"
-        with open(filename, "w") as f:
-            f.write(str(signed))
-
-
-if __name__ == "__main__":
-    _main()
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
new file mode 100644
index 0000000..b26a8e8
--- /dev/null
+++ b/tools/remote-bazelrc
@@ -0,0 +1,107 @@
+# Copyright 2016 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is auto-generated from release/bazelrc.tpl and should not be
+# modified directly.
+
+# This .bazelrc file contains all of the flags required for the provided
+# toolchain with Remote Build Execution.
+#
+# This .bazelrc file also contains all of the flags required for the local
+# docker sandboxing.
+
+# Depending on how many machines are in the remote execution instance, setting
+# this higher can make builds faster by allowing more jobs to run in parallel.
+# Setting it too high can result in jobs that timeout, however, while waiting
+# for a remote machine to execute them.
+build:remote --jobs=50
+build:remote --disk_cache=
+
+# Set several flags related to specifying the platform, toolchain and java
+# properties.
+# These flags are duplicated rather than imported from (for example)
+# %workspace%/configs/ubuntu16_04_clang/1.2/toolchain.bazelrc to make this
+# bazelrc a standalone file that can be copied more easily.
+# These flags should only be used as is for the rbe-ubuntu16-04 container
+# and need to be adapted to work with other toolchain containers.
+build:remote --host_javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
+build:remote --javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
+build:remote --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8
+build:remote --java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8
+build:remote --crosstool_top=@bazel_toolchains//configs/ubuntu16_04_clang/1.2/bazel_0.25.0/default:toolchain
+build:remote --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
+# Platform flags:
+# The toolchain container used for execution is defined in the target indicated
+# by "extra_execution_platforms", "host_platform" and "platforms".
+# If you are using your own toolchain container, you need to create a platform
+# target with "constraint_values" that allow for the toolchain specified with
+# "extra_toolchains" to be selected (given constraints defined in
+# "exec_compatible_with").
+# More about platforms: https://docs.bazel.build/versions/master/platforms.html
+build:remote --extra_toolchains=@bazel_toolchains//configs/ubuntu16_04_clang/1.2/bazel_0.25.0/cpp:cc-toolchain-clang-x86_64-default
+build:remote --extra_execution_platforms=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:rbe_ubuntu1604
+build:remote --host_platform=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:rbe_ubuntu1604
+build:remote --platforms=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:rbe_ubuntu1604
+
+# Set various strategies so that all actions execute remotely. Mixing remote
+# and local execution will lead to errors unless the toolchain and remote
+# machine exactly match the host machine.
+build:remote --spawn_strategy=remote
+build:remote --strategy=Javac=remote
+build:remote --strategy=Closure=remote
+build:remote --strategy=Genrule=remote
+build:remote --define=EXECUTOR=remote
+
+# Enable the remote cache so action results can be shared across machines,
+# developers, and workspaces.
+build:remote --remote_cache=remotebuildexecution.googleapis.com
+
+# Enable remote execution so actions are performed on the remote systems.
+build:remote --remote_executor=remotebuildexecution.googleapis.com
+
+# Enable encryption.
+build:remote --tls_enabled=true
+
+# Set a higher timeout value, just in case.
+build:remote --remote_timeout=3600
+
+# Enable authentication. This will pick up application default credentials by
+# default. You can use --auth_credentials=some_file.json to use a service
+# account credential instead.
+build:remote --auth_enabled=true
+
+# The following flags are only necessary for local docker sandboxing
+# with the rbe-ubuntu16-04 container. Use of these flags is still experimental.
+build:docker-sandbox --host_javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
+build:docker-sandbox --javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
+build:docker-sandbox --crosstool_top=@bazel_toolchains//configs/ubuntu16_04_clang/1.2/bazel_0.25.0/default:toolchain
+build:docker-sandbox --experimental_docker_image=gcr.io/cloud-marketplace/google/rbe-ubuntu16-04@sha256:da0f21c71abce3bbb92c3a0c44c3737f007a82b60f8bd2930abc55fe64fc2729
+build:docker-sandbox --spawn_strategy=docker
+build:docker-sandbox --strategy=Javac=docker
+build:docker-sandbox --strategy=Closure=docker
+build:docker-sandbox --strategy=Genrule=docker
+build:docker-sandbox --define=EXECUTOR=remote
+build:docker-sandbox --experimental_docker_verbose
+build:docker-sandbox --experimental_enable_docker_sandbox
+
+# The following flags enable the remote cache so action results can be shared
+# across machines, developers, and workspaces.
+build:remote-cache --remote_cache=remotebuildexecution.googleapis.com
+build:remote-cache --tls_enabled=true
+build:remote-cache --remote_timeout=3600
+build:remote-cache --auth_enabled=true
+build:remote-cache --spawn_strategy=standalone
+build:remote-cache --strategy=Javac=standalone
+build:remote-cache --strategy=Closure=standalone
+build:remote-cache --strategy=Genrule=standalone
diff --git a/tools/setup_gjf.sh b/tools/setup_gjf.sh
index de2e0cc..119f9af 100755
--- a/tools/setup_gjf.sh
+++ b/tools/setup_gjf.sh
@@ -17,7 +17,7 @@
 set -eu
 
 # Keep this version in sync with dev-contributing.txt.
-VERSION=${1:-1.6}
+VERSION=${1:-1.7}
 
 case "$VERSION" in
 1.3)
@@ -29,6 +29,9 @@
 1.6)
     SHA1="02b3e84e52d2473e2c4868189709905a51647d03"
     ;;
+1.7)
+    SHA1="b6d34a51e579b08db7c624505bdf9af4397f1702"
+    ;;
 *)
     echo "unknown google-java-format version: $VERSION"
     exit 1
diff --git a/tools/util.py b/tools/util.py
index 45d0541..947e2c0 100644
--- a/tools/util.py
+++ b/tools/util.py
@@ -15,13 +15,12 @@
 from os import path
 
 REPO_ROOTS = {
-    'GERRIT': 'http://gerrit-maven.storage.googleapis.com',
-    'GERRIT_API':
-        'https://gerrit-api.commondatastorage.googleapis.com/release',
-    'MAVEN_CENTRAL': 'http://repo1.maven.org/maven2',
-    'MAVEN_LOCAL': 'file://' + path.expanduser('~/.m2/repository'),
-    'MAVEN_SNAPSHOT':
-        'https://oss.sonatype.org/content/repositories/snapshots',
+  'ECLIPSE': 'https://repo.eclipse.org/content/groups/releases',
+  'GERRIT': 'https://gerrit-maven.storage.googleapis.com',
+  'GERRIT_API': 'https://gerrit-api.commondatastorage.googleapis.com/release',
+  'MAVEN_CENTRAL': 'https://repo1.maven.org/maven2',
+  'MAVEN_LOCAL': 'file://' + path.expanduser('~/.m2/repository'),
+  'MAVEN_SNAPSHOT': 'https://oss.sonatype.org/content/repositories/snapshots',
 }
 
 
diff --git a/tools/util_test.py b/tools/util_test.py
index fa67696..1a389f5 100644
--- a/tools/util_test.py
+++ b/tools/util_test.py
@@ -29,18 +29,18 @@
 
     def testKnownRedirect(self):
         url = resolve_url('MAVEN_CENTRAL:foo.jar',
-                          {'MAVEN_CENTRAL': 'http://my.company.mirror/maven2'})
-        self.assertEqual(url, 'http://my.company.mirror/maven2/foo.jar')
+                          {'MAVEN_CENTRAL': 'https://my.company.mirror/maven2'})
+        self.assertEqual(url, 'https://my.company.mirror/maven2/foo.jar')
 
     def testCustom(self):
-        url = resolve_url('http://maven.example.com/release/foo.jar', {})
-        self.assertEqual(url, 'http://maven.example.com/release/foo.jar')
+        url = resolve_url('https://maven.example.com/release/foo.jar', {})
+        self.assertEqual(url, 'https://maven.example.com/release/foo.jar')
 
     def testCustomRedirect(self):
         url = resolve_url('MAVEN_EXAMPLE:foo.jar',
                           {'MAVEN_EXAMPLE':
-                           'http://maven.example.com/release'})
-        self.assertEqual(url, 'http://maven.example.com/release/foo.jar')
+                           'https://maven.example.com/release'})
+        self.assertEqual(url, 'https://maven.example.com/release/foo.jar')
 
 
 if __name__ == '__main__':
diff --git a/tools/version.py b/tools/version.py
index 4aafcb0..bb3b560 100755
--- a/tools/version.py
+++ b/tools/version.py
@@ -46,8 +46,7 @@
 src_pattern = re.compile(r'^(\s*<version>)([-.\w]+)(</version>\s*)$',
                          re.MULTILINE)
 for project in ['gerrit-acceptance-framework', 'gerrit-extension-api',
-                'gerrit-plugin-api', 'gerrit-plugin-gwtui',
-                'gerrit-war']:
+                'gerrit-plugin-api', 'gerrit-war']:
     pom = os.path.join('tools', 'maven', '%s_pom.xml' % project)
     replace_in_file(pom, src_pattern)
 
diff --git a/tools/workspace-status.cmd b/tools/workspace-status.cmd
index 4a3b88e..bc1560d 100644
--- a/tools/workspace-status.cmd
+++ b/tools/workspace-status.cmd
@@ -1,2 +1 @@
 echo STABLE_BUILD_GERRIT_LABEL dev
-echo STABLE_WORKSPACE_ROOT %cd%
diff --git a/tools/workspace-status.sh b/tools/workspace-status.sh
index af6e180..2b1a4ba 100755
--- a/tools/workspace-status.sh
+++ b/tools/workspace-status.sh
@@ -19,4 +19,3 @@
   test -d "$p" || continue
   echo STABLE_BUILD_$(echo $(basename $p)_LABEL|tr '[a-z]' '[A-Z]' ) $(rev $p || echo unknown)
 done
-echo "STABLE_WORKSPACE_ROOT ${PWD}"
diff --git a/version.bzl b/version.bzl
index 62d841f..42a576c 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "2.16-SNAPSHOT"
+GERRIT_VERSION = "3.1.0-SNAPSHOT"
diff --git a/webapp/WEB-INF/extra/jetty7/gerrit.xml b/webapp/WEB-INF/extra/jetty7/gerrit.xml
index cb0a256..4102f56 100644
--- a/webapp/WEB-INF/extra/jetty7/gerrit.xml
+++ b/webapp/WEB-INF/extra/jetty7/gerrit.xml
@@ -6,11 +6,7 @@
   so it answers to simple URLs like "/$changeid" and "/mine".
 
   * Copy this file to $JETTY_HOME/contexts/gerrit.xml
-  * Edit url, username, password as necessary below for database.
 
-  * Copy commons-dbcp-*.jar     to $JETTY_HOME/lib/ext/
-  * Copy commons-pool-*.jar     to $JETTY_HOME/lib/ext/
-  * Copy JDBC driver            to $JETTY_HOME/lib/ext/
   * Copy www/gerrit-*.war       to $JETTY_HOME/webapps/gerrit.war
 
   * Make sure you remove $JETTY_HOME/context/test.xml
@@ -33,36 +29,4 @@
       <Item>org.eclipse.jetty.webapp.JettyWebXmlConfiguration</Item>
     </Array>
   </Set>
-
-  <New id="ReviewDb" class="org.eclipse.jetty.plus.jndi.Resource">
-    <Arg></Arg>
-    <Arg>jdbc/ReviewDb</Arg>
-    <Arg>
-      <New class="org.apache.commons.dbcp.BasicDataSource">
-<!--  PostgreSQL
-        <Set name="driverClassName">org.postgresql.Driver</Set>
-        <Set name="url">jdbc:postgresql:reviewdb</Set>
-        <Set name="username">gerrit</Set>
-        <Set name="password">secretkey</Set>
--->
-<!--  MySQL
-        <Set name="driverClassName">com.mysql.jdbc.Driver</Set>
-        <Set name="url">jdbc:mysql://localhost/reviewdb?user=gerrit&amp;password=secretkey</Set>
--->
-<!--  MariaDB
-        <Set name="driverClassName">org.mariadb.jdbc.Driver</Set>
-        <Set name="url">jdbc:mariadb://localhost/reviewdb?user=gerrit&amp;password=secretkey</Set>
--->
-<!--  H2
-        <Set name="driverClassName">org.h2.Driver</Set>
-        <Set name="url">jdbc:h2:file:ReviewDb</Set>
--->
-        <Set name="initialSize">4</Set>
-        <Set name="maxActive">8</Set>
-        <Set name="minIdle">4</Set>
-        <Set name="maxIdle">4</Set>
-        <Set name="maxWait">30000</Set>
-      </New>
-    </Arg>
-  </New>
 </Configure>
diff --git a/webapp/WEB-INF/web.xml b/webapp/WEB-INF/web.xml
index 386eb07..540fe33 100644
--- a/webapp/WEB-INF/web.xml
+++ b/webapp/WEB-INF/web.xml
@@ -1,14 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <web-app>
-  <resource-ref>
-    <res-ref-name>jdbc/ReviewDb</res-ref-name>
-    <res-type>javax.sql.DataSource</res-type>
-    <res-auth>Container</res-auth>
-  </resource-ref>
-
   <filter>
     <filter-name>guiceFilter</filter-name>
-    <filter-class>com.google.gerrit.httpd.WebAppInitializer</filter-class>
+    <filter-class>com.google.gerrit.httpd.init.WebAppInitializer</filter-class>
   </filter>
   <filter-mapping>
     <filter-name>guiceFilter</filter-name>